thread-master/apps/backend/internal/module/threads/provider/media.go

489 lines
14 KiB
Go
Raw Normal View History

2026-07-13 01:15:30 +00:00
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}/mentionsthreads_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)