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