265 lines
10 KiB
Go
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{}
|