package provider import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" ) // RemoteThread — Graph Threads 列表/單則媒體 type RemoteThread struct { ID string Text string MediaType string MediaURL string ThumbnailURL string Permalink string Shortcode string TopicTag string Username string Timestamp time.Time } // RemoteInsights — media insights type RemoteInsights struct { Views int Likes int Replies int Reposts int Quotes int Shares int Status string // ok | partial | error | skipped ErrorMsg string } // RemoteReply — conversation / replies under a media type RemoteReply struct { ID string Text string Username string Timestamp time.Time IsMine bool ParentMediaID string LikeCount int } // RemoteMention — GET /{user-id}/mentions(threads_manage_mentions) type RemoteMention struct { ID string Text string Username string Permalink string MediaType string Timestamp time.Time IsReply bool IsQuotePost bool HasReplies bool RootPostID string ParentID string } // MediaClient lists own threads + insights + conversation (Meta Graph). type MediaClient interface { ListThreads(ctx context.Context, accessToken string, limit int) ([]RemoteThread, error) GetInsights(ctx context.Context, accessToken, mediaID string) (RemoteInsights, error) ListConversation(ctx context.Context, accessToken, mediaID string, limit int) ([]RemoteReply, error) ListMentions(ctx context.Context, accessToken, threadsUserID string, limit int) ([]RemoteMention, error) // ListProfilePosts — 公開帳號貼文(threads_profile_discovery / profile_posts) ListProfilePosts(ctx context.Context, accessToken, username string, limit int) ([]RemoteThread, error) } // ListThreads GET /v1.0/me/threads func (m *MetaProvider) ListThreads(ctx context.Context, accessToken string, limit int) ([]RemoteThread, error) { if limit <= 0 { limit = 25 } if limit > 50 { limit = 50 } fields := "id,media_type,media_url,permalink,username,text,topic_tag,timestamp,shortcode,thumbnail_url,is_quote_post" q := url.Values{} q.Set("fields", fields) q.Set("limit", strconv.Itoa(limit)) q.Set("access_token", accessToken) u := strings.TrimRight(m.GraphBase, "/") + "/v1.0/me/threads?" + q.Encode() body, err := m.getJSON(ctx, u) if err != nil { return nil, err } var resp struct { Data []struct { ID string `json:"id"` MediaType string `json:"media_type"` MediaURL string `json:"media_url"` Permalink string `json:"permalink"` Username string `json:"username"` Text string `json:"text"` TopicTag string `json:"topic_tag"` Timestamp string `json:"timestamp"` Shortcode string `json:"shortcode"` ThumbnailURL string `json:"thumbnail_url"` } `json:"data"` Error *graphErr `json:"error"` } if err := json.Unmarshal(body, &resp); err != nil { return nil, fmt.Errorf("threads list parse: %w", err) } if resp.Error != nil { return nil, resp.Error } out := make([]RemoteThread, 0, len(resp.Data)) for _, d := range resp.Data { out = append(out, RemoteThread{ ID: d.ID, Text: d.Text, MediaType: d.MediaType, MediaURL: d.MediaURL, ThumbnailURL: d.ThumbnailURL, Permalink: d.Permalink, Shortcode: d.Shortcode, TopicTag: d.TopicTag, Username: d.Username, Timestamp: parseThreadsTime(d.Timestamp), }) } return out, nil } // GetInsights GET /v1.0/{media-id}/insights func (m *MetaProvider) GetInsights(ctx context.Context, accessToken, mediaID string) (RemoteInsights, error) { ins := RemoteInsights{Status: "ok"} if mediaID == "" { ins.Status = "skipped" return ins, nil } q := url.Values{} q.Set("metric", "views,likes,replies,reposts,quotes,shares") q.Set("access_token", accessToken) u := strings.TrimRight(m.GraphBase, "/") + "/v1.0/" + url.PathEscape(mediaID) + "/insights?" + q.Encode() body, err := m.getJSON(ctx, u) if err != nil { ins.Status = "error" ins.ErrorMsg = err.Error() return ins, nil // 不整批失敗 } var resp struct { Data []struct { Name string `json:"name"` Values []struct { Value json.Number `json:"value"` } `json:"values"` TotalValue *struct { Value json.Number `json:"value"` } `json:"total_value"` } `json:"data"` Error *graphErr `json:"error"` } if err := json.Unmarshal(body, &resp); err != nil { ins.Status = "error" ins.ErrorMsg = err.Error() return ins, nil } if resp.Error != nil { ins.Status = "error" ins.ErrorMsg = resp.Error.Error() return ins, nil } for _, d := range resp.Data { v := 0 if d.TotalValue != nil { v, _ = strconv.Atoi(string(d.TotalValue.Value)) } else if len(d.Values) > 0 { v, _ = strconv.Atoi(string(d.Values[0].Value)) } switch d.Name { case "views": ins.Views = v case "likes": ins.Likes = v case "replies": ins.Replies = v case "reposts": ins.Reposts = v case "quotes": ins.Quotes = v case "shares": ins.Shares = v } } return ins, nil } // ListConversation 先試 /replies(第一層留言),再試 /conversation(含巢狀)。 // 失敗不整批炸掉,回 empty + 可診斷的 error(由上層 log)。 func (m *MetaProvider) ListConversation(ctx context.Context, accessToken, mediaID string, limit int) ([]RemoteReply, error) { if mediaID == "" { return nil, nil } if limit <= 0 { limit = 30 } if limit > 50 { limit = 50 } // 官方 example 欄位 + username / is_reply_owned_by_me(文件有列) fields := "id,text,username,timestamp,media_type,has_replies,root_post,replied_to,is_reply,is_reply_owned_by_me,hide_status" // 1) top-level replies replies, err1 := m.fetchReplyEdge(ctx, accessToken, mediaID, "replies", fields, limit) if len(replies) > 0 { return normalizeParents(replies, mediaID), nil } // 2) full conversation (nested) convo, err2 := m.fetchReplyEdge(ctx, accessToken, mediaID, "conversation", fields, limit) if len(convo) > 0 { return normalizeParents(convo, mediaID), nil } // 兩者都空:若有錯誤回傳最後一個,方便 log if err1 != nil { return nil, err1 } if err2 != nil { return nil, err2 } return nil, nil } func (m *MetaProvider) fetchReplyEdge(ctx context.Context, accessToken, mediaID, edge, fields string, limit int) ([]RemoteReply, error) { q := url.Values{} q.Set("fields", fields) q.Set("limit", strconv.Itoa(limit)) q.Set("reverse", "false") q.Set("access_token", accessToken) u := strings.TrimRight(m.GraphBase, "/") + "/v1.0/" + url.PathEscape(mediaID) + "/" + edge + "?" + q.Encode() body, err := m.getJSON(ctx, u) if err != nil { return nil, err } var resp struct { Data []struct { ID string `json:"id"` Text string `json:"text"` Username string `json:"username"` Timestamp string `json:"timestamp"` IsReplyOwnedByMe bool `json:"is_reply_owned_by_me"` IsReply bool `json:"is_reply"` HideStatus string `json:"hide_status"` RepliedTo *struct { ID string `json:"id"` } `json:"replied_to"` RootPost *struct { ID string `json:"id"` } `json:"root_post"` } `json:"data"` Error *graphErr `json:"error"` } if err := json.Unmarshal(body, &resp); err != nil { return nil, fmt.Errorf("threads %s parse: %w", edge, err) } if resp.Error != nil { return nil, resp.Error } out := make([]RemoteReply, 0, len(resp.Data)) for _, d := range resp.Data { // 略過隱藏/封鎖 hs := strings.ToUpper(d.HideStatus) if hs == "HIDDEN" || hs == "BLOCKED" || hs == "RESTRICTED" || hs == "COVERED" { continue } parent := "" if d.RepliedTo != nil { parent = d.RepliedTo.ID } out = append(out, RemoteReply{ ID: d.ID, Text: d.Text, Username: d.Username, Timestamp: parseThreadsTime(d.Timestamp), IsMine: d.IsReplyOwnedByMe, ParentMediaID: parent, }) } return out, nil } // normalizeParents — 回覆根貼的 replied_to = root media id,對 UI 應視為第一層(parent 空) func normalizeParents(list []RemoteReply, rootMediaID string) []RemoteReply { for i := range list { if list[i].ParentMediaID == rootMediaID || list[i].ParentMediaID == "" { list[i].ParentMediaID = "" } } return list } type graphErr struct { Message string `json:"message"` Type string `json:"type"` Code int `json:"code"` } func (e *graphErr) Error() string { if e == nil { return "graph error" } return fmt.Sprintf("threads graph: %s (code %d)", e.Message, e.Code) } func (m *MetaProvider) getJSON(ctx context.Context, fullURL string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) if err != nil { return nil, err } cli := m.HTTPClient if cli == nil { cli = &http.Client{Timeout: 25 * time.Second} } resp, err := cli.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) if err != nil { return nil, err } if resp.StatusCode >= 300 { var ge struct { Error *graphErr `json:"error"` } _ = json.Unmarshal(body, &ge) if ge.Error != nil { return nil, ge.Error } return nil, fmt.Errorf("threads graph HTTP %d: %s", resp.StatusCode, truncateBody(string(body), 200)) } return body, nil } func parseThreadsTime(s string) time.Time { s = strings.TrimSpace(s) if s == "" { return time.Time{} } // 2023-10-17T05:42:03+0000 layouts := []string{ "2006-01-02T15:04:05-0700", "2006-01-02T15:04:05+0000", time.RFC3339, } for _, l := range layouts { if t, err := time.Parse(l, s); err == nil { return t.UTC() } } // try fix +0000 → Z if strings.HasSuffix(s, "+0000") { if t, err := time.Parse(time.RFC3339, strings.TrimSuffix(s, "+0000")+"Z"); err == nil { return t.UTC() } } return time.Time{} } // ListProfilePosts GET /v1.0/profile_posts?username=… — 公開個人頁貼文(threads_profile_discovery) // 用「已連帳」的 access token 即可讀其他公開帳號的貼文,不需 Chrome 爬蟲。 func (m *MetaProvider) ListProfilePosts(ctx context.Context, accessToken, username string, limit int) ([]RemoteThread, error) { username = strings.TrimPrefix(strings.TrimSpace(username), "@") if username == "" { return nil, fmt.Errorf("username is required") } if limit <= 0 { limit = 12 } if limit > 50 { limit = 50 } fields := "id,media_type,media_url,permalink,username,text,topic_tag,timestamp,shortcode,thumbnail_url,is_quote_post" q := url.Values{} q.Set("username", username) q.Set("fields", fields) q.Set("limit", strconv.Itoa(limit)) q.Set("access_token", accessToken) u := strings.TrimRight(m.GraphBase, "/") + "/v1.0/profile_posts?" + q.Encode() body, err := m.getJSON(ctx, u) if err != nil { return nil, err } var resp struct { Data []struct { ID string `json:"id"` MediaType string `json:"media_type"` MediaURL string `json:"media_url"` Permalink string `json:"permalink"` Username string `json:"username"` Text string `json:"text"` TopicTag string `json:"topic_tag"` Timestamp string `json:"timestamp"` Shortcode string `json:"shortcode"` ThumbnailURL string `json:"thumbnail_url"` } `json:"data"` Error *graphErr `json:"error"` } if err := json.Unmarshal(body, &resp); err != nil { return nil, fmt.Errorf("profile_posts parse: %w", err) } if resp.Error != nil { return nil, resp.Error } out := make([]RemoteThread, 0, len(resp.Data)) for _, d := range resp.Data { if strings.TrimSpace(d.ID) == "" && strings.TrimSpace(d.Text) == "" { continue } out = append(out, RemoteThread{ ID: d.ID, Text: d.Text, MediaType: d.MediaType, MediaURL: d.MediaURL, ThumbnailURL: d.ThumbnailURL, Permalink: d.Permalink, Shortcode: d.Shortcode, TopicTag: d.TopicTag, Username: d.Username, Timestamp: parseThreadsTime(d.Timestamp), }) } return out, nil } // ListMentions GET /v1.0/{user-id|me}/mentions — 別人 @ 你的貼文/回覆/引用 func (m *MetaProvider) ListMentions(ctx context.Context, accessToken, threadsUserID string, limit int) ([]RemoteMention, error) { if limit <= 0 { limit = 25 } if limit > 100 { limit = 100 } uid := strings.TrimSpace(threadsUserID) if uid == "" { uid = "me" } fields := "id,text,username,permalink,timestamp,media_type,is_reply,is_quote_post,has_replies,root_post,parent_id" q := url.Values{} q.Set("fields", fields) q.Set("limit", strconv.Itoa(limit)) q.Set("access_token", accessToken) u := strings.TrimRight(m.GraphBase, "/") + "/v1.0/" + url.PathEscape(uid) + "/mentions?" + q.Encode() body, err := m.getJSON(ctx, u) if err != nil { return nil, err } var resp struct { Data []struct { ID string `json:"id"` Text string `json:"text"` Username string `json:"username"` Permalink string `json:"permalink"` Timestamp string `json:"timestamp"` MediaType string `json:"media_type"` IsReply bool `json:"is_reply"` IsQuotePost bool `json:"is_quote_post"` HasReplies bool `json:"has_replies"` ParentID string `json:"parent_id"` RootPost *struct { ID string `json:"id"` } `json:"root_post"` } `json:"data"` Error *graphErr `json:"error"` } if err := json.Unmarshal(body, &resp); err != nil { return nil, fmt.Errorf("mentions parse: %w", err) } if resp.Error != nil { return nil, resp.Error } out := make([]RemoteMention, 0, len(resp.Data)) for _, d := range resp.Data { if strings.TrimSpace(d.ID) == "" { continue } rootID := "" if d.RootPost != nil { rootID = strings.TrimSpace(d.RootPost.ID) } out = append(out, RemoteMention{ ID: d.ID, Text: d.Text, Username: d.Username, Permalink: d.Permalink, MediaType: d.MediaType, Timestamp: parseThreadsTime(d.Timestamp), IsReply: d.IsReply, IsQuotePost: d.IsQuotePost, HasReplies: d.HasReplies, RootPostID: rootID, ParentID: strings.TrimSpace(d.ParentID), }) } return out, nil } // Ensure MetaProvider implements MediaClient. var _ MediaClient = (*MetaProvider)(nil)