package placement import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "strings" "time" ) type resolveMediaInput struct { Permalink string `json:"permalink"` StorageState string `json:"storage_state,omitempty"` } type resolveMediaOutput struct { MediaID string `json:"mediaId"` } // RunResolveMediaFromPermalink opens a Threads post page via Playwright and extracts numeric media id. func RunResolveMediaFromPermalink(ctx context.Context, permalink, storageState string) (string, error) { permalink = strings.TrimSpace(permalink) if permalink == "" { return "", fmt.Errorf("permalink is required") } if endpoint := strings.TrimSpace(os.Getenv("HAIXUN_RESOLVE_MEDIA_URL")); endpoint != "" { return runHTTPResolveMedia(ctx, endpoint, permalink, storageState) } repoRoot, cliPath, err := resolveMediaCLI() if err != nil { return "", err } payload, err := json.Marshal(resolveMediaInput{ Permalink: permalink, StorageState: strings.TrimSpace(storageState), }) if err != nil { return "", err } runCtx, cancel := context.WithTimeout(ctx, 90*time.Second) defer cancel() cmd := exec.CommandContext(runCtx, "npx", "tsx", cliPath) cmd.Dir = repoRoot cmd.Stdin = bytes.NewReader(payload) 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 "", fmt.Errorf("permalink media resolve failed: %s", msg) } var out resolveMediaOutput if err := json.Unmarshal(stdout.Bytes(), &out); err != nil { return "", fmt.Errorf("permalink media resolve output parse failed: %w", err) } mediaID := strings.TrimSpace(out.MediaID) if mediaID == "" { return "", fmt.Errorf("permalink media resolve returned empty media id") } return mediaID, nil } func runHTTPResolveMedia(ctx context.Context, endpoint, permalink, storageState string) (string, error) { payload, err := json.Marshal(resolveMediaInput{Permalink: permalink, StorageState: strings.TrimSpace(storageState)}) if err != nil { return "", err } runCtx, cancel := context.WithTimeout(ctx, 90*time.Second) defer cancel() url := strings.TrimRight(endpoint, "/") + "/resolve" req, err := http.NewRequestWithContext(runCtx, http.MethodPost, url, bytes.NewReader(payload)) if err != nil { return "", err } req.Header.Set("Content-Type", "application/json") res, err := http.DefaultClient.Do(req) if err != nil { return "", fmt.Errorf("permalink media resolve service failed: %w", err) } defer res.Body.Close() body, _ := io.ReadAll(res.Body) if res.StatusCode < 200 || res.StatusCode >= 300 { return "", fmt.Errorf("permalink media resolve service returned HTTP %d: %s", res.StatusCode, strings.TrimSpace(string(body))) } var out resolveMediaOutput if err := json.Unmarshal(body, &out); err != nil { return "", fmt.Errorf("permalink media resolve service output parse failed: %w", err) } mediaID := strings.TrimSpace(out.MediaID) if mediaID == "" { return "", fmt.Errorf("permalink media resolve service returned empty media id") } return mediaID, nil } func resolveMediaCLI() (repoRoot, cliPath string, err error) { if root := strings.TrimSpace(os.Getenv("HAIXUN_REPO_ROOT")); root != "" { cli := filepath.Join(root, "haixun-backend", "worker", "threads-resolve-media-cli.ts") if fileExists(cli) { return root, cli, nil } cli = filepath.Join(root, "worker", "threads-resolve-media-cli.ts") if fileExists(cli) { return root, cli, nil } } cwd, err := os.Getwd() if err != nil { return "", "", fmt.Errorf("resolve media cli: %w", err) } dir := cwd for i := 0; i < 6; i++ { cli := filepath.Join(dir, "haixun-backend", "worker", "threads-resolve-media-cli.ts") if fileExists(cli) { return dir, cli, nil } cli = filepath.Join(dir, "worker", "threads-resolve-media-cli.ts") if fileExists(cli) { return dir, cli, nil } parent := filepath.Dir(dir) if parent == dir { break } dir = parent } return "", "", fmt.Errorf("找不到 threads-resolve-media-cli.ts,請設定 HAIXUN_REPO_ROOT") }