299 lines
9.3 KiB
Go
299 lines
9.3 KiB
Go
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,
|
||
)
|
||
}
|