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

348 lines
9.9 KiB
Go
Raw Normal View History

2026-07-13 01:15:30 +00:00
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)