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)
|