package usecase import ( "bytes" "context" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "runtime" "strings" "time" ) // profileFetchFunc is overridable in tests. var profileFetchFunc = defaultFetchProfilePostTexts // SetProfileFetchForTest overrides profile scrape (tests). Restore with returned cleanup. func SetProfileFetchForTest(fn func(ctx context.Context, username, storageStateJSON string, limit int) ([]string, error)) (restore func()) { old := profileFetchFunc if fn == nil { profileFetchFunc = defaultFetchProfilePostTexts } else { profileFetchFunc = fn } return func() { profileFetchFunc = old } } // fetchProfilePostTexts tries to read public Threads posts for @username. // storageStateJSON is optional Playwright storageState (from Chrome extension sync). func fetchProfilePostTexts(ctx context.Context, username, storageStateJSON string, limit int) ([]string, error) { return profileFetchFunc(ctx, username, storageStateJSON, limit) } // defaultFetchProfilePostTexts uses Node Playwright scraper (real browser). // Pure HTTP cannot read Threads profile posts (logged-out shell / JS-rendered). func defaultFetchProfilePostTexts(ctx context.Context, username, storageStateJSON string, limit int) ([]string, error) { username = strings.TrimPrefix(strings.TrimSpace(username), "@") if username == "" { return nil, fmt.Errorf("empty username") } if limit <= 0 { limit = 12 } script, err := findProfileScrapeScript() if err != nil { return nil, err } // optional storage state temp file var storagePath string if strings.TrimSpace(storageStateJSON) != "" { // ensure valid JSON object with cookies var probe struct { Cookies []json.RawMessage `json:"cookies"` } if json.Unmarshal([]byte(storageStateJSON), &probe) == nil && len(probe.Cookies) > 0 { f, ferr := os.CreateTemp("", "haixun-storage-*.json") if ferr == nil { _, _ = f.WriteString(storageStateJSON) _ = f.Close() storagePath = f.Name() defer os.Remove(storagePath) } } } // timeout for browser scrape if _, ok := ctx.Deadline(); !ok { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, 55*time.Second) defer cancel() } args := []string{script, "--username", username, "--limit", fmt.Sprintf("%d", limit)} if storagePath != "" { args = append(args, "--storage", storagePath) } cmd := exec.CommandContext(ctx, "node", args...) // Playwright browsers cache cmd.Env = append(os.Environ(), "PLAYWRIGHT_BROWSERS_PATH="+playwrightBrowsersPath()) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { msg := strings.TrimSpace(stderr.String()) if msg == "" { msg = err.Error() } return nil, fmt.Errorf("profile scrape failed: %s", truncate(msg, 240)) } var out struct { OK bool `json:"ok"` Posts []string `json:"posts"` Error string `json:"error"` Username string `json:"username"` } if err := json.Unmarshal(stdout.Bytes(), &out); err != nil { return nil, fmt.Errorf("profile scrape bad json: %w", err) } if len(out.Posts) > 0 { return out.Posts, nil } if out.Error != "" { return nil, fmt.Errorf("%s", out.Error) } return nil, fmt.Errorf("找不到 @%s 的公開貼文", username) } func findProfileScrapeScript() (string, error) { // relative to process cwd and source file location candidates := []string{ "scripts/threads-profile/scrape.mjs", "apps/backend/scripts/threads-profile/scrape.mjs", filepath.Join("..", "scripts", "threads-profile", "scrape.mjs"), } // from this source file: internal/module/studio/usecase -> ../../../../scripts/... if _, file, _, ok := runtime.Caller(0); ok { base := filepath.Dir(file) candidates = append(candidates, filepath.Join(base, "..", "..", "..", "..", "scripts", "threads-profile", "scrape.mjs"), ) } if wd, err := os.Getwd(); err == nil { candidates = append(candidates, filepath.Join(wd, "scripts", "threads-profile", "scrape.mjs"), filepath.Join(wd, "apps", "backend", "scripts", "threads-profile", "scrape.mjs"), ) } for _, c := range candidates { if st, err := os.Stat(c); err == nil && !st.IsDir() { abs, _ := filepath.Abs(c) return abs, nil } } return "", fmt.Errorf("threads profile scrape script not found (scripts/threads-profile/scrape.mjs)") } func playwrightBrowsersPath() string { if v := os.Getenv("PLAYWRIGHT_BROWSERS_PATH"); v != "" { return v } // default cache used by npx playwright install home, _ := os.UserHomeDir() if home != "" { return filepath.Join(home, ".cache", "ms-playwright") } return "" }