154 lines
4.5 KiB
Go
154 lines
4.5 KiB
Go
|
|
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 ""
|
||
|
|
}
|