package api import ( "context" "encoding/base64" "encoding/json" "errors" "fmt" "net/http" "net/url" "strings" "time" accountdomain "git.ipao.vip/rogee/creator-hub/internal/account" "git.ipao.vip/rogee/creator-hub/internal/creator" hub "git.ipao.vip/rogee/creator-hub/internal/environment" douyin "git.ipao.vip/rogee/creator-hub/internal/platform/douyin" xiaohongshu "git.ipao.vip/rogee/creator-hub/internal/platform/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 *accountdomain.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.BusinessStatus != "muted") || 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) canonicalKey, err := collector.CanonicalTargetSecUID(ctx, targetAccountKey) if err != nil { return nil, "", err } return &collector, canonicalKey, 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{}