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

489 lines
14 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 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)