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

209 lines
6.7 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...)
2026-07-20 06:33:14 +00:00
browsersPath, err := playwrightBrowsersPath(script)
if err != nil {
return nil, err
}
cmd.Env = append(os.Environ(),
"PLAYWRIGHT_BROWSERS_PATH="+browsersPath,
"PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=ubuntu24.04-x64",
)
2026-07-13 01:15:30 +00:00
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) {
var candidates []string
// release layout: <release>/bin/{gateway,worker} + <release>/scripts/threads-profile/scrape.mjs
if exe, err := os.Executable(); err == nil {
if resolved, rerr := filepath.EvalSymlinks(exe); rerr == nil {
exe = resolved
}
releaseDir := filepath.Dir(filepath.Dir(exe)) // <release>/bin/worker -> <release>
candidates = append(candidates,
filepath.Join(releaseDir, "scripts", "threads-profile", "scrape.mjs"),
)
}
// relative to process cwd and source file location (local dev)
candidates = append(candidates,
2026-07-13 01:15:30 +00:00
"scripts/threads-profile/scrape.mjs",
"apps/backend/scripts/threads-profile/scrape.mjs",
filepath.Join("..", "scripts", "threads-profile", "scrape.mjs"),
)
2026-07-13 01:15:30 +00:00
// 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)")
}
2026-07-20 06:33:14 +00:00
func playwrightBrowsersPath(script string) (string, error) {
scriptDir := filepath.Dir(script)
archive := filepath.Join(scriptDir, "playwright-browsers.tar.gz")
if st, err := os.Stat(archive); err == nil && !st.IsDir() {
runtimeRoot := os.Getenv("PLAYWRIGHT_RUNTIME_BROWSERS_PATH")
if runtimeRoot == "" {
runtimeRoot = filepath.Join("/var/lib/harbor", "playwright")
}
target := filepath.Join(runtimeRoot, "1.55.1-ubuntu24.04-x64")
ready := filepath.Join(target, ".ready")
if _, err := os.Stat(ready); err == nil {
return target, nil
}
if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil {
return "", fmt.Errorf("prepare Playwright browser directory: %w", err)
}
tmp := fmt.Sprintf("%s.tmp-%d", target, os.Getpid())
_ = os.RemoveAll(tmp)
if err := os.Mkdir(tmp, 0o750); err != nil {
return "", fmt.Errorf("prepare Playwright browser extraction: %w", err)
}
defer os.RemoveAll(tmp)
if output, err := exec.Command("tar", "-xzf", archive, "-C", tmp).CombinedOutput(); err != nil {
return "", fmt.Errorf("extract Playwright browsers: %s", truncate(strings.TrimSpace(string(output)), 240))
}
if err := os.WriteFile(filepath.Join(tmp, ".ready"), []byte("1.55.1\n"), 0o640); err != nil {
return "", fmt.Errorf("mark Playwright browsers ready: %w", err)
}
if err := os.Rename(tmp, target); err != nil {
if _, statErr := os.Stat(ready); statErr != nil {
return "", fmt.Errorf("activate Playwright browsers: %w", err)
}
}
return target, nil
}
local := filepath.Join(filepath.Dir(script), "node_modules", "playwright-core", ".local-browsers")
if st, err := os.Stat(local); err == nil && st.IsDir() {
return "0", nil
}
2026-07-13 01:15:30 +00:00
if v := os.Getenv("PLAYWRIGHT_BROWSERS_PATH"); v != "" {
2026-07-20 06:33:14 +00:00
return v, nil
2026-07-13 01:15:30 +00:00
}
// default cache used by npx playwright install
home, _ := os.UserHomeDir()
if home != "" {
2026-07-20 06:33:14 +00:00
return filepath.Join(home, ".cache", "ms-playwright"), nil
2026-07-13 01:15:30 +00:00
}
2026-07-20 06:33:14 +00:00
return "", nil
2026-07-13 01:15:30 +00:00
}