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

629 lines
18 KiB
Go
Raw Permalink 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
ReplyControl string
}
// 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
HideStatus string
}
// 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)
}
const threadsListFields = "id,media_type,media_url,permalink,username,text,topic_tag,timestamp,shortcode,thumbnail_url,is_quote_post,reply_control"
const threadsListFieldsLegacy = "id,media_type,media_url,permalink,username,text,topic_tag,timestamp,shortcode,thumbnail_url,is_quote_post"
// ListThreads GET /v1.0/me/threads
func (m *MetaProvider) ListThreads(ctx context.Context, accessToken string, limit int) ([]RemoteThread, error) {
list, err := m.listThreads(ctx, accessToken, limit, threadsListFields)
if err != nil && strings.Contains(strings.ToLower(err.Error()), "reply_control") {
return m.listThreads(ctx, accessToken, limit, threadsListFieldsLegacy)
}
return list, err
}
func (m *MetaProvider) listThreads(ctx context.Context, accessToken string, limit int, fields string) ([]RemoteThread, error) {
if limit <= 0 {
limit = 25
}
if limit > 50 {
limit = 50
}
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"`
ReplyControl string `json:"reply_control"`
} `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),
ReplyControl: NormalizeReplyControl(d.ReplyControl),
})
}
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 {
if strings.TrimSpace(d.ID) == "" || d.ID == mediaID {
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, HideStatus: strings.TrimSpace(d.HideStatus),
})
}
return out, nil
}
// normalizeParents — 回覆根貼的 replied_to = root media id對 UI 應視為第一層parent 空)。
// conversation 有時會把主貼自己也列進來,隱藏時不能拿主貼 id 打 /manage_reply。
func normalizeParents(list []RemoteReply, rootMediaID string) []RemoteReply {
out := make([]RemoteReply, 0, len(list))
for _, r := range list {
if r.ID == "" || r.ID == rootMediaID {
continue
}
if r.ParentMediaID == rootMediaID {
r.ParentMediaID = ""
}
out = append(out, r)
}
return out
}
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
}
// ReplyControlEveryone is Threads' default audience.
const ReplyControlEveryone = "everyone"
// ValidReplyControls are the official Threads publishing reply_control values.
var ValidReplyControls = map[string]struct{}{
"everyone": {},
"accounts_you_follow": {},
"mentioned_only": {},
"parent_post_author_only": {},
"followers_only": {},
}
func NormalizeReplyControl(raw string) string {
s := strings.ToLower(strings.TrimSpace(raw))
if s == "" {
return ""
}
if _, ok := ValidReplyControls[s]; ok {
return s
}
return ""
}
// ManageReply POST /{reply-id}/manage_reply hide=true|false
func (m *MetaProvider) ManageReply(ctx context.Context, accessToken, replyID string, hide bool) error {
replyID = strings.TrimSpace(replyID)
if replyID == "" {
return fmt.Errorf("reply id is required")
}
form := url.Values{}
if hide {
form.Set("hide", "true")
} else {
form.Set("hide", "false")
}
form.Set("access_token", accessToken)
u := strings.TrimRight(m.GraphBase, "/") + "/v1.0/" + url.PathEscape(replyID) + "/manage_reply"
body, err := m.postForm(ctx, u, form)
if err != nil {
return err
}
var resp struct {
Success bool `json:"success"`
Error *graphErr `json:"error"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return fmt.Errorf("threads manage_reply parse: %w", err)
}
if resp.Error != nil {
return resp.Error
}
if !resp.Success {
return fmt.Errorf("threads manage_reply did not succeed")
}
return nil
}
// SetReplyControl POST /{media-id} reply_control=…
// Official docs set this at publish time; Graph also accepts it on the published media id.
func (m *MetaProvider) SetReplyControl(ctx context.Context, accessToken, mediaID, control string) error {
mediaID = strings.TrimSpace(mediaID)
control = NormalizeReplyControl(control)
if mediaID == "" {
return fmt.Errorf("media id is required")
}
if control == "" {
return fmt.Errorf("invalid reply_control")
}
form := url.Values{}
form.Set("reply_control", control)
form.Set("access_token", accessToken)
u := strings.TrimRight(m.GraphBase, "/") + "/v1.0/" + url.PathEscape(mediaID)
body, err := m.postForm(ctx, u, form)
if err != nil {
return err
}
var resp struct {
Success bool `json:"success"`
ID string `json:"id"`
Error *graphErr `json:"error"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return fmt.Errorf("threads reply_control parse: %w", err)
}
if resp.Error != nil {
return resp.Error
}
return nil
}
func (m *MetaProvider) postForm(ctx context.Context, fullURL string, form url.Values) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
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)