Files
creator-hub/cmd/control-plane/xiaohongshu.go
T

265 lines
10 KiB
Go

package main
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"git.ipao.vip/rogee/creator-hub/internal/creator"
"git.ipao.vip/rogee/creator-hub/internal/douyin"
"git.ipao.vip/rogee/creator-hub/internal/hub"
"git.ipao.vip/rogee/creator-hub/internal/phasea"
"git.ipao.vip/rogee/creator-hub/internal/xiaohongshu"
)
type xiaohongshuGatewayBrowser struct {
gateway hub.Gateway
environment hub.EnvironmentContext
}
func (browser xiaohongshuGatewayBrowser) generation() (map[string]any, error) {
request, err := (douyinGatewayBrowser{gateway: browser.gateway, environment: browser.environment}).request()
if err != nil {
return nil, err
}
return map[string]any{
"binding_version": request.BindingVersion,
"runtime_id": request.RuntimeID,
"network_id": request.NetworkID,
"network_exit_id": request.NetworkExitID,
}, nil
}
func (browser xiaohongshuGatewayBrowser) Get(ctx context.Context, target string) (xiaohongshu.Response, error) {
request, err := browser.generation()
if err != nil {
return xiaohongshu.Response{}, err
}
request["url"] = target
status, body, err := gatewayCall(ctx, browser.gateway, http.MethodPost,
"/v1/browsers/"+url.PathEscape(browser.environment.Alias)+"/xiaohongshu/get", request, 30*time.Second)
if err != nil || status != http.StatusOK {
return xiaohongshu.Response{}, errors.New("restricted Xiaohongshu browser operation failed")
}
return decodeXiaohongshuResponse(body)
}
func (browser xiaohongshuGatewayBrowser) Post(ctx context.Context, target string, payload []byte) (xiaohongshu.Response, error) {
if len(payload) == 0 || len(payload) > 4<<20 {
return xiaohongshu.Response{}, errors.New("invalid Xiaohongshu browser body")
}
var bodyValue any
if err := json.Unmarshal(payload, &bodyValue); err != nil {
return xiaohongshu.Response{}, fmt.Errorf("invalid Xiaohongshu browser body: %w", err)
}
if _, ok := bodyValue.(map[string]any); !ok {
return xiaohongshu.Response{}, errors.New("Xiaohongshu browser body must be an object")
}
request, err := browser.generation()
if err != nil {
return xiaohongshu.Response{}, err
}
request["url"] = target
request["body"] = bodyValue
status, responseBody, err := gatewayCall(ctx, browser.gateway, http.MethodPost,
"/v1/browsers/"+url.PathEscape(browser.environment.Alias)+"/xiaohongshu/post", request, 30*time.Second)
if err != nil || status != http.StatusOK {
return xiaohongshu.Response{}, errors.New("restricted Xiaohongshu browser POST failed")
}
return decodeXiaohongshuResponse(responseBody)
}
func (browser xiaohongshuGatewayBrowser) Identity(ctx context.Context, expectedKey string) (string, error) {
request, err := browser.generation()
if err != nil {
return "", err
}
request["expected_account_key"] = expectedKey
status, body, err := gatewayCall(ctx, browser.gateway, http.MethodPost,
"/v1/browsers/"+url.PathEscape(browser.environment.Alias)+"/xiaohongshu/identity", request, 30*time.Second)
if err != nil || status != http.StatusOK {
return "", errors.New("Xiaohongshu identity verification failed")
}
var identity struct {
UID string `json:"uid"`
}
if err := json.Unmarshal(body, &identity); err != nil || strings.TrimSpace(identity.UID) == "" {
return "", errors.New("Xiaohongshu identity response omitted uid")
}
return identity.UID, nil
}
func (browser xiaohongshuGatewayBrowser) Resolve(ctx context.Context, target string) (string, error) {
request, err := browser.generation()
if err != nil {
return "", err
}
request["url"] = target
status, body, err := gatewayCall(ctx, browser.gateway, http.MethodPost,
"/v1/browsers/"+url.PathEscape(browser.environment.Alias)+"/xiaohongshu/resolve", request, 30*time.Second)
if err != nil || status != http.StatusOK {
return "", errors.New("restricted Xiaohongshu share resolution failed")
}
var response struct {
URL string `json:"url"`
}
if err := json.Unmarshal(body, &response); err != nil || strings.TrimSpace(response.URL) == "" {
return "", errors.New("Xiaohongshu share resolution response omitted url")
}
return response.URL, nil
}
func (browser xiaohongshuGatewayBrowser) Media(ctx context.Context, target string) ([]byte, string, error) {
request, err := browser.generation()
if err != nil {
return nil, "", err
}
request["url"] = target
status, body, err := gatewayCall(ctx, browser.gateway, http.MethodPost,
"/v1/browsers/"+url.PathEscape(browser.environment.Alias)+"/xiaohongshu/media", request, 90*time.Second)
if err != nil || status != http.StatusOK {
return nil, "", errors.New("restricted Xiaohongshu media request failed")
}
var response struct {
Status int `json:"status"`
ContentType string `json:"content_type"`
BodyBase64 string `json:"body_base64"`
}
if err := json.Unmarshal(body, &response); err != nil || response.Status < 200 || response.Status >= 300 || response.BodyBase64 == "" {
return nil, "", errors.New("Xiaohongshu media response is invalid")
}
data, err := decodeBase64(response.BodyBase64)
if err != nil {
return nil, "", err
}
return data, response.ContentType, nil
}
func decodeXiaohongshuResponse(body []byte) (xiaohongshu.Response, error) {
var response struct {
Status int `json:"status"`
Body string `json:"body"`
Challenge string `json:"challenge"`
}
if err := json.Unmarshal(body, &response); err != nil || response.Status < 100 || response.Status > 599 {
return xiaohongshu.Response{}, errors.New("restricted Xiaohongshu browser returned an invalid response")
}
return xiaohongshu.Response{Status: response.Status, Body: []byte(response.Body), Challenge: response.Challenge}, nil
}
func newXiaohongshuReadCollector(ctx context.Context, store *creator.Store, phaseAStore *phasea.Store, hubStore *hub.Store, accountID, sourceType, sourceID string) (*xiaohongshu.Collector, error) {
if store == nil || phaseAStore == nil || hubStore == nil || strings.TrimSpace(accountID) == "" {
return nil, creator.ErrUnavailable
}
account, err := phaseAStore.GetAccount(ctx, accountID)
if err != nil {
return nil, err
}
if account.Platform != creator.PlatformXiaohongshu || account.AuthorizationStatus != "authorized" {
return nil, creator.ErrConflict
}
profile, err := store.GetAccountProfile(ctx, accountID)
if err != nil {
return nil, err
}
if profile.Platform != creator.PlatformXiaohongshu || profile.BusinessStatus != "normal" || profile.LoginStatus != "logged_in" {
return nil, creator.ErrConflict
}
environment, err := hubStore.GetEnvironmentContextForAccount(ctx, accountID)
if err != nil {
return nil, fmt.Errorf("%w: account environment unavailable: %v", creator.ErrUnavailable, err)
}
if environment.RuntimeID == "" || environment.RuntimeNetworkID == "" || environment.BindingVersion <= 0 {
return nil, fmt.Errorf("%w: account runtime is not running", creator.ErrUnavailable)
}
gateway, err := hubStore.GetGateway(ctx, environment.Gateway)
if err != nil {
return nil, fmt.Errorf("%w: gateway unavailable: %v", creator.ErrUnavailable, err)
}
browser := xiaohongshuGatewayBrowser{gateway: gateway, environment: environment}
if _, err := browser.Identity(ctx, account.PlatformAccountKey); err != nil {
return nil, fmt.Errorf("%w: account identity verification failed: %v", creator.ErrConflict, err)
}
return &xiaohongshu.Collector{Browser: browser, AccountKey: account.PlatformAccountKey, SourceType: sourceType, SourceID: sourceID}, nil
}
func validateXiaohongshuSource(homepageURL, accountKey string) error {
return xiaohongshu.ValidateSourceURL(homepageURL, accountKey)
}
func validateXiaohongshuCompetitor(input creator.CompetitorInput) error {
if input.Platform != creator.PlatformXiaohongshu {
return nil
}
return validateXiaohongshuSource(input.HomepageURL, input.PlatformAccountKey)
}
func newCreatorCollector(ctx context.Context, platform string, gateway hub.Gateway, environment hub.EnvironmentContext, viewerAccountKey, targetAccountKey, homepageURL, sourceType, sourceID string) (creator.PlatformCollector, string, error) {
if strings.TrimSpace(viewerAccountKey) == "" || strings.TrimSpace(targetAccountKey) == "" {
return nil, "", fmt.Errorf("%w: creator collector account key is missing", creator.ErrInvalid)
}
switch platform {
case creator.PlatformDouyin:
browser := creatorGatewayBrowser{gateway: gateway, environment: environment}
if _, err := browser.Identity(ctx, viewerAccountKey); err != nil {
return nil, "", err
}
collector := douyinCollector(browser, targetAccountKey, sourceType, sourceID)
if _, err := collector.CanonicalSecUID(ctx, viewerAccountKey); err != nil {
return nil, "", err
}
return &collector, targetAccountKey, nil
case creator.PlatformXiaohongshu:
if homepageURL != "" {
if err := xiaohongshu.ValidateSourceURL(homepageURL, targetAccountKey); err != nil {
return nil, "", err
}
}
browser := xiaohongshuGatewayBrowser{gateway: gateway, environment: environment}
if _, err := browser.Identity(ctx, viewerAccountKey); err != nil {
return nil, "", err
}
return &xiaohongshu.Collector{Browser: browser, AccountKey: targetAccountKey, HomepageURL: homepageURL, SourceType: sourceType, SourceID: sourceID}, targetAccountKey, nil
default:
return nil, "", fmt.Errorf("%w: unsupported creator platform %s", creator.ErrUnavailable, platform)
}
}
func douyinCollector(browser creatorGatewayBrowser, accountKey, sourceType, sourceID string) douyin.CreatorCollector {
return douyin.CreatorCollector{Browser: browser, AccountKey: accountKey, SourceType: sourceType, SourceID: sourceID}
}
func verifyCreatorPlatformIdentity(ctx context.Context, platform string, gateway hub.Gateway, environment hub.EnvironmentContext, expectedKey string) (string, error) {
switch platform {
case creator.PlatformDouyin:
return (creatorGatewayBrowser{gateway: gateway, environment: environment}).Identity(ctx, expectedKey)
case creator.PlatformXiaohongshu:
return (xiaohongshuGatewayBrowser{gateway: gateway, environment: environment}).Identity(ctx, expectedKey)
default:
return "", fmt.Errorf("%w: unsupported creator platform %s", creator.ErrUnavailable, platform)
}
}
func decodeBase64(value string) ([]byte, error) {
const maxEncoded = 96 << 20
if len(value) > maxEncoded {
return nil, errors.New("media response is too large")
}
data, err := base64.StdEncoding.DecodeString(value)
if err != nil {
return nil, fmt.Errorf("decode media response: %w", err)
}
if len(data) > maxCreatorMediaBytes {
return nil, errors.New("media response is too large")
}
return data, nil
}
var _ xiaohongshu.Browser = xiaohongshuGatewayBrowser{}