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

298 lines
9.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
)
}