package threadsapi import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "regexp" "strings" "unicode/utf8" ) var threadsPermalinkRe = regexp.MustCompile(`(?i)threads\.(?:com|net)/@([^/]+)/post/([^/?#]+)`) // IsNumericMediaID reports whether the value looks like a Threads Graph media id. func IsNumericMediaID(raw string) bool { raw = strings.TrimSpace(raw) if len(raw) < 8 { return false } for _, r := range raw { if r < '0' || r > '9' { return false } } return true } func parsePermalinkParts(permalink string) (username, shortcode string) { permalink = strings.TrimSpace(permalink) if permalink == "" { return "", "" } permalink = strings.Split(permalink, "?")[0] permalink = strings.Split(permalink, "#")[0] match := threadsPermalinkRe.FindStringSubmatch(permalink) if len(match) < 3 { return "", "" } return strings.TrimSpace(match[1]), strings.TrimSpace(match[2]) } type ResolveReplyMediaInput struct { ExternalID string Permalink string HintText string } // ResolveReplyMediaID returns a Graph API media id suitable for reply_to_id. func (c *Client) ResolveReplyMediaID(ctx context.Context, externalID, permalink string) (string, error) { return c.ResolveReplyMedia(ctx, ResolveReplyMediaInput{ ExternalID: externalID, Permalink: permalink, }) } // ResolveReplyMedia resolves shortcode/permalink targets into numeric media ids for reply_to_id. func (c *Client) ResolveReplyMedia(ctx context.Context, in ResolveReplyMediaInput) (string, error) { if !c.Enabled() { return "", fmt.Errorf("threads api access token is required") } candidate := strings.TrimSpace(in.ExternalID) if IsNumericMediaID(candidate) { return candidate, nil } username, shortcode := parsePermalinkParts(in.Permalink) if !IsNumericMediaID(candidate) && candidate != "" { shortcode = candidate } if IsNumericMediaID(shortcode) { return shortcode, nil } if shortcode == "" { return "", fmt.Errorf("缺少有效的 Threads 貼文 ID") } if username == "" { return "", fmt.Errorf("permalink 缺少作者資訊,無法解析貼文 ID") } for _, query := range keywordQueriesFromHint(in.HintText) { if id, err := c.lookupMediaIDByKeywordText(ctx, username, shortcode, query); err == nil && id != "" { return id, nil } } if id, err := c.lookupMediaIDViaProfilePosts(ctx, username, shortcode); err == nil && id != "" { return id, nil } if id, err := c.lookupMediaIDByAuthorAndShortcode(ctx, username, shortcode); err == nil && id != "" { return id, nil } return "", fmt.Errorf( "無法將貼文 shortcode 轉成 Threads media id(%s/@%s/post/%s)。請確認 OAuth 具備 threads_profile_discovery 與 threads_keyword_search,或重新以 API 海巡抓取貼文", shortcode, username, shortcode, ) } func keywordQueriesFromHint(hint string) []string { hint = strings.TrimSpace(hint) if hint == "" { return nil } hint = strings.Join(strings.Fields(hint), " ") seen := map[string]struct{}{} out := make([]string, 0, 3) add := func(query string) { query = strings.TrimSpace(query) if query == "" || utf8.RuneCountInString(query) < 4 { return } if _, ok := seen[query]; ok { return } seen[query] = struct{}{} out = append(out, query) } add(trimRunes(hint, 80)) if idx := strings.IndexAny(hint, "。!?!?.\n"); idx > 0 { add(trimRunes(hint[:idx], 60)) } add(trimRunes(hint, 32)) return out } func trimRunes(raw string, max int) string { raw = strings.TrimSpace(raw) if max <= 0 || utf8.RuneCountInString(raw) <= max { return raw } runes := []rune(raw) return strings.TrimSpace(string(runes[:max])) } func (c *Client) lookupMediaIDViaProfilePosts(ctx context.Context, username, shortcode string) (string, error) { return c.FindMediaIDByAuthorShortcode(ctx, username, shortcode) } func (c *Client) lookupMediaIDByKeywordText(ctx context.Context, username, shortcode, query string) (string, error) { query = strings.TrimSpace(query) if query == "" { return "", fmt.Errorf("keyword query is required") } for _, searchType := range []string{"TOP", "RECENT"} { id, err := c.keywordSearchMediaByShortcode(ctx, username, query, searchType, true, shortcode) if err == nil && id != "" { return id, nil } } return "", fmt.Errorf("threads keyword_search did not return media for @%s post %s", username, shortcode) } func (c *Client) lookupMediaIDByAuthorAndShortcode(ctx context.Context, username, shortcode string) (string, error) { username = strings.TrimPrefix(strings.TrimSpace(username), "@") shortcode = strings.TrimSpace(shortcode) if username == "" || shortcode == "" { return "", fmt.Errorf("username and shortcode are required") } type lookupStrategy struct { searchType string withAuthor bool } strategies := []lookupStrategy{ {searchType: "TOP", withAuthor: true}, {searchType: "RECENT", withAuthor: true}, {searchType: "TOP", withAuthor: false}, {searchType: "RECENT", withAuthor: false}, } var lastErr error for _, strategy := range strategies { id, err := c.keywordSearchMediaByShortcode(ctx, username, shortcode, strategy.searchType, strategy.withAuthor, shortcode) if err == nil && id != "" { return id, nil } if err != nil { lastErr = err } } if lastErr != nil { return "", lastErr } return "", fmt.Errorf("threads keyword_search did not return media for @%s post %s", username, shortcode) } func (c *Client) keywordSearchMediaByShortcode( ctx context.Context, username, query, searchType string, withAuthor bool, wantShortcode string, ) (string, error) { query = strings.TrimSpace(query) wantShortcode = strings.TrimSpace(wantShortcode) if query == "" || wantShortcode == "" { return "", fmt.Errorf("query and shortcode are required") } params := url.Values{} params.Set("access_token", c.accessToken) params.Set("q", query) params.Set("search_type", searchType) if withAuthor { params.Set("author_username", username) } params.Set("fields", "id,permalink,shortcode") params.Set("limit", "25") endpoint := graphBaseURL + "/keyword_search?" + params.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return "", err } res, err := c.http.Do(req) if err != nil { return "", err } defer res.Body.Close() body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) if err != nil { return "", err } if res.StatusCode != http.StatusOK { return "", parseAPIError(body, res.StatusCode) } var payload struct { Data []struct { ID string `json:"id"` Permalink string `json:"permalink"` Shortcode string `json:"shortcode"` } `json:"data"` } if err := json.Unmarshal(body, &payload); err != nil { return "", err } wantSuffix := "/post/" + wantShortcode for _, item := range payload.Data { id := strings.TrimSpace(item.ID) if !IsNumericMediaID(id) { continue } if withAuthor { link := strings.TrimSpace(item.Permalink) if link != "" && !strings.Contains(strings.ToLower(link), "/@"+strings.ToLower(username)+"/") { continue } } if strings.EqualFold(strings.TrimSpace(item.Shortcode), wantShortcode) { return id, nil } link := strings.TrimSpace(item.Permalink) if link != "" && strings.HasSuffix(strings.Split(link, "?")[0], wantSuffix) { return id, nil } } return "", fmt.Errorf("threads keyword_search did not return media for @%s post %s", username, wantShortcode) } // PrimeReplyTargetForPublish registers a public post via Threads API search before reply_to_id publish. // Meta only allows replying to public posts that were recently returned by keyword_search. func (c *Client) PrimeReplyTargetForPublish(ctx context.Context, in ResolveReplyMediaInput, mediaID string) error { if !c.Enabled() { return fmt.Errorf("threads api access token is required") } username, shortcode := parsePermalinkParts(in.Permalink) if candidate := strings.TrimSpace(in.ExternalID); !IsNumericMediaID(candidate) && candidate != "" { shortcode = candidate } mediaID = strings.TrimSpace(mediaID) if username == "" || shortcode == "" || mediaID == "" { return fmt.Errorf("缺少作者、shortcode 或 media id,無法預先搜尋貼文") } // Fast path: shortcode / author queries with author_username filter (fewer API round-trips). for _, query := range []string{shortcode, username, "@" + username} { for _, searchType := range []string{"TOP", "RECENT"} { id, err := c.keywordSearchMediaByShortcode(ctx, username, query, searchType, true, shortcode) if err == nil && strings.TrimSpace(id) == mediaID { return nil } } } for _, query := range keywordQueriesFromHint(in.HintText) { for _, searchType := range []string{"TOP", "RECENT"} { id, err := c.keywordSearchMediaByShortcode(ctx, username, query, searchType, true, shortcode) if err == nil && strings.TrimSpace(id) == mediaID { return nil } } } if id, err := c.FindMediaIDByAuthorShortcode(ctx, username, shortcode); err == nil && strings.TrimSpace(id) == mediaID { for _, searchType := range []string{"TOP", "RECENT"} { if _, err := c.keywordSearchMediaByShortcode(ctx, username, shortcode, searchType, true, shortcode); err == nil { return nil } } } return fmt.Errorf( "Threads API 搜尋不到 @%s 的這篇貼文(%s)。回覆他人貼文前必須先用 keyword_search 找到它,請確認 threads_keyword_search 權限已核准,或改用「開始海巡(API)」重新抓取", username, shortcode, ) }