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