thread-master/apps/backend/internal/module/studio/publish/meta.go

348 lines
9.9 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 publish
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"apps/backend/internal/module/studio/domain"
"github.com/zeromicro/go-zero/core/logx"
)
// MetaTransport — 真 Threads 發文回覆container → publish
// 支援 TEXT / 單圖 IMAGE / 多圖 CAROUSELimage_url 須為 Meta 可抓的公開 https
type MetaTransport struct {
GraphBase string
HTTPClient *http.Client
}
func NewMeta(graphBase string) *MetaTransport {
if graphBase == "" {
graphBase = "https://graph.threads.net"
}
return &MetaTransport{
GraphBase: strings.TrimRight(graphBase, "/"),
HTTPClient: &http.Client{Timeout: 90 * time.Second},
}
}
func (t *MetaTransport) Publish(ctx context.Context, req domain.PublishRequest) (*domain.PublishResult, error) {
token := strings.TrimSpace(req.AccessToken)
if token == "" || strings.HasPrefix(token, "fake-") {
return nil, fmt.Errorf("Threads token 無效,請重新連帳")
}
text := strings.TrimSpace(req.Text)
images := publicImageURLs(req.ImageURLs)
if text == "" && len(images) == 0 {
return nil, fmt.Errorf("內容不可為空")
}
// Meta 會從公網 cURL image_url先 HEAD 自檢,避免只得到 code 1 unknown
for i, img := range images {
if err := t.preflightImageURL(ctx, img); err != nil {
return nil, fmt.Errorf("第 %d 張圖 Meta 無法抓取:%w", i+1, err)
}
}
createURL := t.GraphBase + "/v1.0/me/threads"
replyTo := strings.TrimSpace(req.ReplyTo)
topicTag := ""
if replyTo == "" {
topicTag = normalizeTopicTag(req.TopicTag)
}
var containerID string
var wait time.Duration
var err error
switch {
case len(images) == 0:
// 純文字
form := url.Values{}
form.Set("media_type", "TEXT")
form.Set("text", text)
form.Set("access_token", token)
if replyTo != "" {
form.Set("reply_to_id", replyTo)
}
if topicTag != "" {
form.Set("topic_tag", topicTag)
}
containerID, err = t.createContainer(ctx, createURL, form)
wait = 8 * time.Second
case len(images) == 1:
// 單圖
form := url.Values{}
form.Set("media_type", "IMAGE")
form.Set("image_url", images[0])
form.Set("access_token", token)
if text != "" {
form.Set("text", text)
}
if replyTo != "" {
form.Set("reply_to_id", replyTo)
}
if topicTag != "" {
form.Set("topic_tag", topicTag)
}
containerID, err = t.createContainer(ctx, createURL, form)
wait = 30 * time.Second
default:
// 多圖 carousel先建 is_carousel_item 子容器,再建 CAROUSEL
childIDs := make([]string, 0, len(images))
for i, img := range images {
// Threads carousel 最多 20這裡保守 10
if i >= 10 {
break
}
cf := url.Values{}
cf.Set("media_type", "IMAGE")
cf.Set("image_url", img)
cf.Set("is_carousel_item", "true")
cf.Set("access_token", token)
cid, cerr := t.createContainer(ctx, createURL, cf)
if cerr != nil {
return nil, fmt.Errorf("Threads 建立 carousel 子圖失敗:%w", cerr)
}
// 子項需 FINISHED 才可組 carousel
if werr := t.waitContainerReady(ctx, token, cid, 30*time.Second); werr != nil {
return nil, fmt.Errorf("Threads 子圖未就緒:%w", werr)
}
childIDs = append(childIDs, cid)
}
form := url.Values{}
form.Set("media_type", "CAROUSEL")
form.Set("children", strings.Join(childIDs, ","))
form.Set("access_token", token)
if text != "" {
form.Set("text", text)
}
if replyTo != "" {
form.Set("reply_to_id", replyTo)
}
if topicTag != "" {
form.Set("topic_tag", topicTag)
}
containerID, err = t.createContainer(ctx, createURL, form)
wait = 30 * time.Second
}
if err != nil {
return nil, err
}
if werr := t.waitContainerReady(ctx, token, containerID, wait); werr != nil {
// 超時仍嘗試 publish硬錯誤則直接失敗
if wait > 0 && strings.Contains(werr.Error(), "container") {
// ERROR/EXPIRED 會回 error message
if !strings.Contains(werr.Error(), "timeout") {
return nil, fmt.Errorf("Threads 媒體處理失敗:%w", werr)
}
}
}
// publish
pForm := url.Values{}
pForm.Set("creation_id", containerID)
pForm.Set("access_token", token)
pubURL := t.GraphBase + "/v1.0/me/threads_publish"
pBody, pStatus, err := t.postForm(ctx, pubURL, pForm)
if err != nil {
return nil, fmt.Errorf("Threads 發佈失敗:%w", err)
}
if pStatus >= 300 {
return nil, fmt.Errorf("Threads 發佈失敗:%s", parseGraphError(pBody, pStatus))
}
var published struct {
ID string `json:"id"`
}
if err := json.Unmarshal(pBody, &published); err != nil || published.ID == "" {
return nil, fmt.Errorf("Threads 發佈失敗:無法解析 media id%s", truncate(string(pBody), 180))
}
logx.Infof("threads publish ok media_id=%s images=%d reply_to=%s", published.ID, len(images), req.ReplyTo)
return &domain.PublishResult{MediaID: published.ID}, nil
}
func publicImageURLs(raw []string) []string {
out := make([]string, 0, len(raw))
seen := make(map[string]struct{}, len(raw))
for _, u := range raw {
u = strings.TrimSpace(u)
if u == "" {
continue
}
// data URL 無法給 Meta 抓,跳過(前端應先上傳)
if strings.HasPrefix(u, "data:") {
continue
}
if strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "http://") {
if _, ok := seen[u]; ok {
continue
}
seen[u] = struct{}{}
out = append(out, u)
}
}
return out
}
// preflightImageURL — 確認公開 URL 對匿名 GET 可讀Meta crawler 同樣匿名)
func (t *MetaTransport) preflightImageURL(ctx context.Context, imageURL string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodHead, imageURL, nil)
if err != nil {
return err
}
// 部分 CDN 對 HEAD 不友善,失敗再 GET 前幾個 byte
res, err := t.HTTPClient.Do(req)
if err != nil {
return fmt.Errorf("無法連線 %s%w", truncate(imageURL, 80), err)
}
_ = res.Body.Close()
if res.StatusCode == http.StatusMethodNotAllowed || res.StatusCode == http.StatusForbidden || res.StatusCode >= 400 {
// fallback GET
gReq, gerr := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil)
if gerr != nil {
return gerr
}
gReq.Header.Set("Range", "bytes=0-1023")
gRes, gerr := t.HTTPClient.Do(gReq)
if gerr != nil {
return fmt.Errorf("無法連線 %s%w", truncate(imageURL, 80), gerr)
}
_, _ = io.Copy(io.Discard, io.LimitReader(gRes.Body, 2048))
_ = gRes.Body.Close()
if gRes.StatusCode >= 300 {
return fmt.Errorf("HTTP %d請確認 MinIO/nginx 允許公開讀取 image_url", gRes.StatusCode)
}
return nil
}
if res.StatusCode >= 300 {
return fmt.Errorf("HTTP %d請確認物件儲存允許公開讀取", res.StatusCode)
}
return nil
}
func (t *MetaTransport) createContainer(ctx context.Context, createURL string, form url.Values) (string, error) {
cBody, cStatus, err := t.postForm(ctx, createURL, form)
if err != nil {
return "", fmt.Errorf("Threads 建立容器失敗:%w", err)
}
if cStatus >= 300 {
return "", fmt.Errorf("Threads 建立容器失敗:%s", parseGraphError(cBody, cStatus))
}
var created struct {
ID string `json:"id"`
}
if err := json.Unmarshal(cBody, &created); err != nil || created.ID == "" {
return "", fmt.Errorf("Threads 建立容器失敗:無法解析 container id%s", truncate(string(cBody), 180))
}
return created.ID, nil
}
// normalizeTopicTag — Threads API150 字,不可含 . &
func normalizeTopicTag(raw string) string {
s := strings.TrimSpace(raw)
s = strings.TrimPrefix(s, "#")
s = strings.TrimSpace(s)
if s == "" {
return ""
}
s = strings.ReplaceAll(s, ".", "")
s = strings.ReplaceAll(s, "&", "")
s = strings.TrimSpace(s)
r := []rune(s)
if len(r) > 50 {
s = string(r[:50])
}
if s == "" {
return ""
}
return s
}
func (t *MetaTransport) postForm(ctx context.Context, fullURL string, form url.Values) ([]byte, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, strings.NewReader(form.Encode()))
if err != nil {
return nil, 0, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
res, err := t.HTTPClient.Do(req)
if err != nil {
return nil, 0, err
}
defer res.Body.Close()
body, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
return body, res.StatusCode, nil
}
func (t *MetaTransport) waitContainerReady(ctx context.Context, token, containerID string, maxWait time.Duration) error {
deadline := time.Now().Add(maxWait)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
q := url.Values{}
q.Set("fields", "status,error_message")
q.Set("access_token", token)
u := t.GraphBase + "/v1.0/" + url.PathEscape(containerID) + "?" + q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return err
}
res, err := t.HTTPClient.Do(req)
if err != nil {
time.Sleep(800 * time.Millisecond)
continue
}
body, _ := io.ReadAll(io.LimitReader(res.Body, 64<<10))
_ = res.Body.Close()
var st struct {
Status string `json:"status"`
ErrorMessage string `json:"error_message"`
}
_ = json.Unmarshal(body, &st)
switch strings.ToUpper(st.Status) {
case "FINISHED", "PUBLISHED":
return nil
case "ERROR":
if st.ErrorMessage != "" {
return fmt.Errorf("%s", st.ErrorMessage)
}
return fmt.Errorf("container error")
case "EXPIRED":
return fmt.Errorf("container expired")
}
time.Sleep(800 * time.Millisecond)
}
return fmt.Errorf("container wait timeout")
}
func parseGraphError(body []byte, httpStatus int) string {
var ge struct {
Error *struct {
Message string `json:"message"`
Type string `json:"type"`
Code int `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(body, &ge); err == nil && ge.Error != nil && ge.Error.Message != "" {
return fmt.Sprintf("%s (code %d)", ge.Error.Message, ge.Error.Code)
}
return fmt.Sprintf("HTTP %d %s", httpStatus, truncate(string(body), 160))
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
var _ Transport = (*MetaTransport)(nil)