thread-master/backend/internal/library/threadsapi/resolve_media.go

299 lines
9.3 KiB
Go
Raw Normal View History

2026-07-01 08:42:51 +00:00
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,
)
2026-07-03 14:42:19 +00:00
}