thread-master/apps/backend/internal/module/studio/usecase/profile_scrape.go

154 lines
4.5 KiB
Go
Raw Normal View History

2026-07-13 01:15:30 +00:00
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 ""
}