refactor(platform): 平台收敛为抖音——移除小红书全链路,监控账号新增画像字段与卡片列表
- 移除小红书:internal/platform/xiaohongshu、controlplane XHS 分支与路由、 browser_gateway XHS 平台模块与处理器、前端平台选项;migration 038 清理 XHS 数据并将 creator_* 平台 CHECK 收紧为 douyin - creator_competitor 新增 follower_count/following_count/aweme_count(仅 接口定义返回,暂不采集回填);列表接口返回 work_count 与 latest_published_at(creator_work_source 聚合 MAX(published_at)) - 导入页账号名称列展示作者头像(share job 列表带出 avatar_url) - 监控账号页改 antd Listy 卡片列表(头像+粉丝/关注/作品/最近发布/同步状态); AccountManagementList 收敛为 owned 专用 - 环境模块清理 browser_version 遗留:migration 034 补齐删列,修复 store.go 中 hub 别名悬空引用,测试 fixture 对齐新 schema
This commit is contained in:
@@ -139,8 +139,6 @@ func TestCreatorRouteValidationCoverage(t *testing.T) {
|
||||
{http.MethodPost, "/api/creator/competitors/missing/pause"},
|
||||
{http.MethodPost, "/api/creator/competitors/missing/resume"},
|
||||
{http.MethodPost, "/api/creator/competitors/missing/sync"},
|
||||
{http.MethodPost, "/api/creator/xiaohongshu/search"},
|
||||
{http.MethodPost, "/api/creator/xiaohongshu/detail"},
|
||||
{http.MethodPost, "/api/creator/works/missing/metrics"},
|
||||
{http.MethodPost, "/api/creator/works/missing/material/select"},
|
||||
{http.MethodPost, "/api/creator/works/missing/material/process"},
|
||||
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
"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"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
@@ -267,7 +266,7 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto
|
||||
})
|
||||
|
||||
app.Get("/api/creator/competitors", func(c fiber.Ctx) error {
|
||||
items, err := store.ListCompetitors(c.Context(), c.Query("platform"))
|
||||
items, err := store.ListCompetitorsWithProfile(c.Context(), c.Query("platform"))
|
||||
if err != nil {
|
||||
return creatorError(c, err)
|
||||
}
|
||||
@@ -361,63 +360,6 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto
|
||||
return c.Status(fiber.StatusAccepted).JSON(report)
|
||||
})
|
||||
|
||||
app.Post("/api/creator/xiaohongshu/search", func(c fiber.Ctx) error {
|
||||
var input struct {
|
||||
AccountID string `json:"account_id"`
|
||||
Query string `json:"query"`
|
||||
Page int `json:"page"`
|
||||
}
|
||||
if err := decodeCreator(c, &input); err != nil {
|
||||
return creatorError(c, err)
|
||||
}
|
||||
if input.Page == 0 {
|
||||
input.Page = 1
|
||||
}
|
||||
environment, err := hubStore.GetEnvironmentContextForAccount(c.Context(), input.AccountID)
|
||||
if err != nil {
|
||||
return creatorError(c, err)
|
||||
}
|
||||
useCtx, runtimeUse, err := beginRuntimeUseForEnvironment(c.Context(), hubStore, environment, "task", "creator-xhs-read-"+input.AccountID)
|
||||
if err != nil {
|
||||
return creatorError(c, fmt.Errorf("%w: runtime use unavailable: %v", creator.ErrUnavailable, err))
|
||||
}
|
||||
collector, err := newXiaohongshuReadCollector(useCtx, store, phaseAStore, hubStore, input.AccountID, creator.SourceOwned, input.AccountID)
|
||||
if err != nil {
|
||||
return creatorError(c, errors.Join(err, runtimeUse.Close()))
|
||||
}
|
||||
page, callErr := collector.SearchNotes(useCtx, input.Query, input.Page)
|
||||
if closeErr := runtimeUse.Close(); callErr != nil || closeErr != nil {
|
||||
return creatorError(c, errors.Join(callErr, closeErr))
|
||||
}
|
||||
return c.JSON(page)
|
||||
})
|
||||
app.Post("/api/creator/xiaohongshu/detail", func(c fiber.Ctx) error {
|
||||
var input struct {
|
||||
AccountID string `json:"account_id"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := decodeCreator(c, &input); err != nil {
|
||||
return creatorError(c, err)
|
||||
}
|
||||
environment, err := hubStore.GetEnvironmentContextForAccount(c.Context(), input.AccountID)
|
||||
if err != nil {
|
||||
return creatorError(c, err)
|
||||
}
|
||||
useCtx, runtimeUse, err := beginRuntimeUseForEnvironment(c.Context(), hubStore, environment, "task", "creator-xhs-detail-"+input.AccountID)
|
||||
if err != nil {
|
||||
return creatorError(c, fmt.Errorf("%w: runtime use unavailable: %v", creator.ErrUnavailable, err))
|
||||
}
|
||||
collector, err := newXiaohongshuReadCollector(useCtx, store, phaseAStore, hubStore, input.AccountID, creator.SourceOwned, input.AccountID)
|
||||
if err != nil {
|
||||
return creatorError(c, errors.Join(err, runtimeUse.Close()))
|
||||
}
|
||||
item, callErr := collector.GetNoteDetail(useCtx, input.URL)
|
||||
if closeErr := runtimeUse.Close(); callErr != nil || closeErr != nil {
|
||||
return creatorError(c, errors.Join(callErr, closeErr))
|
||||
}
|
||||
return c.JSON(item)
|
||||
})
|
||||
|
||||
app.Get("/api/creator/works", func(c fiber.Ctx) error {
|
||||
filter, err := workFilter(c)
|
||||
if err != nil {
|
||||
@@ -1393,7 +1335,7 @@ func verifyCreatorAccount(ctx context.Context, store *creator.Store, phaseAStore
|
||||
if err != nil {
|
||||
return creator.LoginResult{}, err
|
||||
}
|
||||
if account.Platform != profile.Platform || (account.Platform != creator.PlatformDouyin && account.Platform != creator.PlatformXiaohongshu) || account.AuthorizationStatus != "authorized" || profile.PlatformAccountKey == "" || account.PlatformAccountKey != profile.PlatformAccountKey {
|
||||
if account.Platform != profile.Platform || account.Platform != creator.PlatformDouyin || account.AuthorizationStatus != "authorized" || profile.PlatformAccountKey == "" || account.PlatformAccountKey != profile.PlatformAccountKey {
|
||||
return creator.LoginResult{}, creator.ErrConflict
|
||||
}
|
||||
environment, err := hubStore.GetEnvironmentContextForAccount(ctx, accountID)
|
||||
@@ -1563,8 +1505,6 @@ func competitorSharePlatform(raw string) (string, error) {
|
||||
switch strings.ToLower(parsed.Hostname()) {
|
||||
case "www.douyin.com", "v.douyin.com":
|
||||
return creator.PlatformDouyin, nil
|
||||
case "www.xiaohongshu.com", "xhslink.com", "www.xhslink.com":
|
||||
return creator.PlatformXiaohongshu, nil
|
||||
default:
|
||||
return "", creator.ErrInvalid
|
||||
}
|
||||
@@ -1579,14 +1519,10 @@ func previewCompetitorShare(ctx context.Context, store *creator.Store, phaseASto
|
||||
return competitorSharePreview{}, creator.ErrInvalid
|
||||
}
|
||||
shareURL := strings.TrimSpace(rawShareURL)
|
||||
switch platform {
|
||||
case creator.PlatformDouyin:
|
||||
return previewDouyinCompetitorShare(ctx, store, phaseAStore, hubStore, accountID, shareURL)
|
||||
case creator.PlatformXiaohongshu:
|
||||
return previewXiaohongshuCompetitorShare(ctx, store, phaseAStore, hubStore, accountID, shareURL)
|
||||
default:
|
||||
if platform != creator.PlatformDouyin {
|
||||
return competitorSharePreview{}, creator.ErrUnavailable
|
||||
}
|
||||
return previewDouyinCompetitorShare(ctx, store, phaseAStore, hubStore, accountID, shareURL)
|
||||
}
|
||||
|
||||
type anonymousBrowserLease struct {
|
||||
@@ -1746,37 +1682,6 @@ func previewDouyinCompetitorShare(ctx context.Context, store *creator.Store, pha
|
||||
}, nil
|
||||
}
|
||||
|
||||
func previewXiaohongshuCompetitorShare(ctx context.Context, store *creator.Store, phaseAStore *accountdomain.Store, hubStore *hub.Store, accountID, shareURL string) (preview competitorSharePreview, err error) {
|
||||
_ = store
|
||||
_ = phaseAStore
|
||||
_ = accountID
|
||||
lease, err := newAnonymousBrowser(ctx, hubStore)
|
||||
if err != nil {
|
||||
return competitorSharePreview{}, fmt.Errorf("%w: temporary anonymous browser unavailable: %v", creator.ErrUnavailable, err)
|
||||
}
|
||||
defer func() {
|
||||
err = errors.Join(err, lease.close())
|
||||
}()
|
||||
collector := &xiaohongshu.Collector{
|
||||
Browser: xiaohongshuGatewayBrowser{gateway: lease.gateway, environment: lease.environment},
|
||||
SourceType: creator.SourceCompetitor,
|
||||
}
|
||||
work, err := collector.GetNoteDetail(ctx, shareURL)
|
||||
if err != nil {
|
||||
return competitorSharePreview{}, err
|
||||
}
|
||||
if work.AuthorKey == "" {
|
||||
return competitorSharePreview{}, fmt.Errorf("%w: xiaohongshu note author identity is missing", creator.ErrInvalid)
|
||||
}
|
||||
return competitorSharePreview{
|
||||
Platform: creator.PlatformXiaohongshu,
|
||||
PlatformAccountKey: work.AuthorKey,
|
||||
Nickname: work.AuthorName,
|
||||
HomepageURL: "https://www.xiaohongshu.com/user/profile/" + work.AuthorKey,
|
||||
ShareURL: shareURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func douyinWorkKeyFromURL(raw string) (string, error) {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Hostname() != "www.douyin.com" || parsed.User != nil || parsed.Port() != "" || parsed.Fragment != "" {
|
||||
@@ -1877,14 +1782,9 @@ func syncCreatorCompetitorWithClaim(ctx context.Context, store *creator.Store, p
|
||||
markErr := store.MarkCompetitorSync(ctx, competitorID, leaseToken, "blocked", "", blockErr.Error(), nil)
|
||||
return creator.CollectionReport{}, errors.Join(blockErr, markErr)
|
||||
}
|
||||
if competitor.Platform != creator.PlatformDouyin && competitor.Platform != creator.PlatformXiaohongshu {
|
||||
if competitor.Platform != creator.PlatformDouyin {
|
||||
return blocked(fmt.Errorf("%w: unsupported creator platform %s", creator.ErrUnavailable, competitor.Platform))
|
||||
}
|
||||
if competitor.Platform == creator.PlatformXiaohongshu {
|
||||
if err := validateXiaohongshuSource(competitor.HomepageURL, competitor.PlatformAccountKey); err != nil {
|
||||
return blocked(err)
|
||||
}
|
||||
}
|
||||
account, err := phaseAStore.GetAccount(ctx, accountID)
|
||||
if err != nil {
|
||||
return blocked(err)
|
||||
@@ -1969,7 +1869,7 @@ func processCompetitorShareJob(ctx context.Context, store *creator.Store, phaseA
|
||||
if processErr == nil {
|
||||
input := preview.input()
|
||||
input.Tags = job.Tags
|
||||
if processErr = validateXiaohongshuCompetitor(input); processErr == nil {
|
||||
if processErr = validateDouyinCompetitor(input); processErr == nil {
|
||||
var competitor creator.Competitor
|
||||
competitor, processErr = store.UpsertCompetitor(ctx, input)
|
||||
competitorID = competitor.ID
|
||||
@@ -2100,7 +2000,7 @@ func refreshCreatorMetricWork(ctx context.Context, store *creator.Store, phaseAS
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if (account.Platform != creator.PlatformDouyin && account.Platform != creator.PlatformXiaohongshu) || account.AuthorizationStatus != "authorized" || (profile.BusinessStatus != "normal" && profile.BusinessStatus != "muted") || profile.LoginStatus != "logged_in" {
|
||||
if account.Platform != creator.PlatformDouyin || account.AuthorizationStatus != "authorized" || (profile.BusinessStatus != "normal" && profile.BusinessStatus != "muted") || profile.LoginStatus != "logged_in" {
|
||||
return creator.ErrConflict
|
||||
}
|
||||
environment, err := hubStore.GetEnvironmentContextForAccount(ctx, accountID)
|
||||
@@ -2169,7 +2069,7 @@ func syncCreatorOwned(ctx context.Context, store *creator.Store, phaseAStore *ac
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if account.Platform != creator.PlatformDouyin && account.Platform != creator.PlatformXiaohongshu || account.AuthorizationStatus != "authorized" {
|
||||
if account.Platform != creator.PlatformDouyin || account.AuthorizationStatus != "authorized" {
|
||||
return creator.ErrConflict
|
||||
}
|
||||
profile, err := store.GetAccountProfile(ctx, accountID)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package api
|
||||
|
||||
// 平台收敛为抖音:collector 装配与平台身份核验 helper。
|
||||
// 原小红书平台分支已随平台下线整体移除。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
func newCreatorCollector(ctx context.Context, platform string, gateway hub.Gateway, environment hub.EnvironmentContext, viewerAccountKey, targetAccountKey, _, 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)
|
||||
}
|
||||
if platform != creator.PlatformDouyin {
|
||||
return nil, "", fmt.Errorf("%w: unsupported creator platform %s", creator.ErrUnavailable, platform)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func douyinCollector(browser creatorGatewayBrowser, accountKey, sourceType, sourceID string) douyin.CreatorCollector {
|
||||
return douyin.CreatorCollector{Browser: browser, AccountKey: accountKey, SourceType: sourceType, SourceID: sourceID}
|
||||
}
|
||||
|
||||
// validateDouyinCompetitor 监控账号入队前的平台一致性校验(平台收敛后仅抖音)。
|
||||
func validateDouyinCompetitor(input creator.CompetitorInput) error {
|
||||
if input.Platform != creator.PlatformDouyin {
|
||||
return creator.ErrInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyCreatorPlatformIdentity(ctx context.Context, platform string, gateway hub.Gateway, environment hub.EnvironmentContext, expectedKey string) (string, error) {
|
||||
if platform != creator.PlatformDouyin {
|
||||
return "", fmt.Errorf("%w: unsupported creator platform %s", creator.ErrUnavailable, platform)
|
||||
}
|
||||
return (creatorGatewayBrowser{gateway: gateway, environment: environment}).Identity(ctx, expectedKey)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -64,12 +64,6 @@ func TestCreatorHelperBranches(t *testing.T) {
|
||||
if _, _, err := newCreatorCollector(context.Background(), creator.PlatformDouyin, hub.Gateway{}, hub.EnvironmentContext{}, "", "target", "", creator.SourceCompetitor, "id"); !errors.Is(err, creator.ErrInvalid) {
|
||||
t.Fatalf("missing collector key = %v", err)
|
||||
}
|
||||
if _, err := decodeXiaohongshuResponse([]byte("not-json")); err == nil {
|
||||
t.Fatal("malformed Xiaohongshu response must fail")
|
||||
}
|
||||
if response, err := decodeXiaohongshuResponse([]byte(`{"status":200,"body":"ok","challenge":""}`)); err != nil || response.Status != 200 || string(response.Body) != "ok" {
|
||||
t.Fatalf("decode Xiaohongshu response = %+v, %v", response, err)
|
||||
}
|
||||
if _, err := decodeBase64("not-base64"); err == nil {
|
||||
t.Fatal("invalid base64 must fail")
|
||||
}
|
||||
@@ -77,12 +71,12 @@ func TestCreatorHelperBranches(t *testing.T) {
|
||||
if data, err := decodeBase64(encoded); err != nil || string(data) != "media" {
|
||||
t.Fatalf("decode media = %q, %v", data, err)
|
||||
}
|
||||
if err := validateXiaohongshuSource("", ""); err == nil {
|
||||
t.Fatal("empty Xiaohongshu source must fail")
|
||||
}
|
||||
if err := validateXiaohongshuCompetitor(creator.CompetitorInput{Platform: creator.PlatformDouyin}); err != nil {
|
||||
if err := validateDouyinCompetitor(creator.CompetitorInput{Platform: creator.PlatformDouyin}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateDouyinCompetitor(creator.CompetitorInput{Platform: "unsupported"}); !errors.Is(err, creator.ErrInvalid) {
|
||||
t.Fatalf("unsupported competitor platform = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatorPageQueryValidation(t *testing.T) {
|
||||
@@ -110,7 +104,7 @@ func TestCreatorPageQueryValidation(t *testing.T) {
|
||||
|
||||
func TestCreatorControlPlaneGuards(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if _, err := persistDouyinMessageHistory(ctx, nil, creator.Conversation{Platform: creator.PlatformXiaohongshu}, "uid", nil); !errors.Is(err, creator.ErrInvalid) {
|
||||
if _, err := persistDouyinMessageHistory(ctx, nil, creator.Conversation{Platform: "unsupported"}, "uid", nil); !errors.Is(err, creator.ErrInvalid) {
|
||||
t.Fatalf("invalid history store/platform = %v", err)
|
||||
}
|
||||
conversation := creator.Conversation{Platform: creator.PlatformDouyin, AccountID: "account", PeerUID: "peer"}
|
||||
@@ -130,8 +124,8 @@ func TestCreatorControlPlaneGuards(t *testing.T) {
|
||||
if err := (creatorMaterialDownloader{}).Download(ctx, creator.Work{Platform: "unsupported"}, "/tmp/media"); !errors.Is(err, creator.ErrUnavailable) {
|
||||
t.Fatalf("unsupported material platform = %v", err)
|
||||
}
|
||||
if _, err := newXiaohongshuReadCollector(ctx, nil, nil, nil, "", creator.SourceOwned, "id"); !errors.Is(err, creator.ErrUnavailable) {
|
||||
t.Fatalf("empty Xiaohongshu collector = %v", err)
|
||||
if _, _, err := newCreatorCollector(ctx, "unsupported", hub.Gateway{}, hub.EnvironmentContext{}, "viewer", "target", "", creator.SourceOwned, "id"); !errors.Is(err, creator.ErrUnavailable) {
|
||||
t.Fatalf("unsupported collector platform = %v", err)
|
||||
}
|
||||
if listenerBoundaryPointer(time.Time{}) != nil {
|
||||
t.Fatal("zero listener boundary should be nil")
|
||||
@@ -198,23 +192,6 @@ func TestCreatorGatewayBrowserHistoryAndMedia(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestXiaohongshuGatewayMedia(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if !strings.HasSuffix(r.URL.Path, "/xiaohongshu/media") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"status":200,"content_type":"image/jpeg","body_base64":"` + base64.StdEncoding.EncodeToString([]byte("image")) + `"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
browser := xiaohongshuGatewayBrowser{gateway: hub.Gateway{Endpoint: server.URL, Token: "token"}, environment: hub.EnvironmentContext{Env: hub.Env{Alias: "browser"}, AccountID: "account", AccountStatus: "active", AuthorizationStatus: "authorized", BindingID: "binding", RuntimeInstanceID: "instance", RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", RuntimeNetworkID: "native-dddddddddddddddddddddddddddddddd", BindingVersion: 1, Exit: hub.NetworkExit{ID: "exit", HealthStatus: "healthy"}}}
|
||||
data, contentType, err := browser.Media(context.Background(), "https://www.xiaohongshu.com/explore/abc")
|
||||
if err != nil || string(data) != "image" || contentType != "image/jpeg" {
|
||||
t.Fatalf("media = %q, %q, %v", data, contentType, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatorGatewayBrowserIdentity(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || !strings.Contains(r.URL.Path, "/douyin/identity") {
|
||||
|
||||
@@ -24,7 +24,7 @@ type creatorMaterialDownloader struct {
|
||||
}
|
||||
|
||||
func (downloader creatorMaterialDownloader) Download(ctx context.Context, work creator.Work, destination string) (resultErr error) {
|
||||
if (work.Platform != creator.PlatformDouyin && work.Platform != creator.PlatformXiaohongshu) || downloader.store == nil || downloader.phaseAStore == nil || downloader.hubStore == nil {
|
||||
if work.Platform != creator.PlatformDouyin || downloader.store == nil || downloader.phaseAStore == nil || downloader.hubStore == nil {
|
||||
return fmt.Errorf("%w: creator media gateway is unavailable", creator.ErrUnavailable)
|
||||
}
|
||||
accountID := work.SourceID
|
||||
@@ -65,18 +65,10 @@ func (downloader creatorMaterialDownloader) Download(ctx context.Context, work c
|
||||
if _, err := verifyCreatorPlatformIdentity(useCtx, work.Platform, gateway, environment, profile.PlatformAccountKey); err != nil {
|
||||
return fmt.Errorf("%w: media browser identity verification failed: %v", creator.ErrConflict, err)
|
||||
}
|
||||
if work.Platform == creator.PlatformDouyin {
|
||||
return (creatorGatewayBrowser{gateway: gateway, environment: environment}).Media(useCtx, work.OriginalURL, destination)
|
||||
if work.Platform != creator.PlatformDouyin {
|
||||
return fmt.Errorf("%w: unsupported creator platform %s", creator.ErrUnavailable, work.Platform)
|
||||
}
|
||||
data, contentType, err := (xiaohongshuGatewayBrowser{gateway: gateway, environment: environment}).Media(useCtx, work.OriginalURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
contentType = strings.ToLower(strings.TrimSpace(strings.SplitN(contentType, ";", 2)[0]))
|
||||
if !strings.HasPrefix(contentType, "video/") && contentType != "application/octet-stream" {
|
||||
return fmt.Errorf("xiaohongshu media response is not a video")
|
||||
}
|
||||
return writeCreatorMedia(destination, data)
|
||||
return (creatorGatewayBrowser{gateway: gateway, environment: environment}).Media(useCtx, work.OriginalURL, destination)
|
||||
}
|
||||
|
||||
func processCreatorMaterial(ctx context.Context, store *creator.Store, phaseAStore *accountdomain.Store, hubStore *hub.Store, workID string) (creator.MaterialJob, error) {
|
||||
|
||||
@@ -144,14 +144,14 @@ func TestCreatorPreviewAndLifecycleHelpersRejectUnavailableDependencies(t *testi
|
||||
if _, err := previewCompetitorShare(context.Background(), nil, nil, nil, "account-1", "", "not-a-url"); err == nil {
|
||||
t.Fatal("invalid competitor share URL was accepted")
|
||||
}
|
||||
if _, err := previewCompetitorShare(context.Background(), nil, nil, nil, "account-1", creator.PlatformXiaohongshu, "https://www.douyin.com/video/123"); err == nil {
|
||||
t.Fatal("platform mismatch was accepted")
|
||||
if _, err := previewCompetitorShare(context.Background(), nil, nil, nil, "account-1", creator.PlatformDouyin, "https://www.douyin.com/video/123"); err == nil {
|
||||
t.Fatal("unavailable douyin preview dependency was accepted")
|
||||
}
|
||||
if _, err := previewCompetitorShare(context.Background(), nil, nil, nil, "account-1", "", "https://www.douyin.com/video/123"); err == nil {
|
||||
t.Fatal("unavailable Douyin preview was reported as successful")
|
||||
}
|
||||
if _, err := previewCompetitorShare(context.Background(), nil, nil, nil, "account-1", "", "https://www.xiaohongshu.com/explore/123"); err == nil {
|
||||
t.Fatal("unavailable Xiaohongshu preview was reported as successful")
|
||||
t.Fatal("xiaohongshu share URL was accepted after platform retirement")
|
||||
}
|
||||
if got := (competitorSharePreview{Platform: creator.PlatformDouyin, PlatformAccountKey: "uid", Nickname: "name", AvatarURL: "avatar", HomepageURL: "home"}).input(); got.PlatformAccountKey != "uid" || got.Nickname != "name" {
|
||||
t.Fatalf("preview input = %#v", got)
|
||||
|
||||
@@ -55,8 +55,6 @@ func TestCreatorWriteRoutesRejectMalformedInputBeforeStoreAccess(t *testing.T) {
|
||||
{method: http.MethodPost, path: "/api/creator/competitor-share-jobs"},
|
||||
{method: http.MethodPut, path: "/api/creator/competitors/competitor-1"},
|
||||
{method: http.MethodPost, path: "/api/creator/competitors/competitor-1/sync"},
|
||||
{method: http.MethodPost, path: "/api/creator/xiaohongshu/search"},
|
||||
{method: http.MethodPost, path: "/api/creator/xiaohongshu/detail"},
|
||||
{method: http.MethodPost, path: "/api/creator/test/works"},
|
||||
{method: http.MethodPost, path: "/api/creator/works/work-1/metrics"},
|
||||
{method: http.MethodPost, path: "/api/creator/works/work-1/material/rewrite/confirm"},
|
||||
|
||||
@@ -19,7 +19,6 @@ func TestCompetitorSharePlatform(t *testing.T) {
|
||||
}{
|
||||
{name: "douyin short link", url: "https://v.douyin.com/abc123/", want: creator.PlatformDouyin},
|
||||
{name: "douyin content", url: "https://www.douyin.com/video/123", want: creator.PlatformDouyin},
|
||||
{name: "xiaohongshu note", url: "https://www.xiaohongshu.com/explore/note-1", want: creator.PlatformXiaohongshu},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := competitorSharePlatform(test.url)
|
||||
|
||||
@@ -49,8 +49,8 @@ func TestCreatorGatewayBrowserResolveAndSharePlatformValidation(t *testing.T) {
|
||||
}{
|
||||
{value: "https://www.douyin.com/video/1", platform: creator.PlatformDouyin},
|
||||
{value: "https://v.douyin.com/abc", platform: creator.PlatformDouyin},
|
||||
{value: "https://www.xiaohongshu.com/explore/1", platform: creator.PlatformXiaohongshu},
|
||||
{value: "https://xhslink.com/abc", platform: creator.PlatformXiaohongshu},
|
||||
{value: "https://www.xiaohongshu.com/explore/1", wantErr: true},
|
||||
{value: "https://xhslink.com/abc", wantErr: true},
|
||||
{value: "http://www.douyin.com/video/1", wantErr: true},
|
||||
{value: "https://user@www.douyin.com/video/1", wantErr: true},
|
||||
{value: "https://www.douyin.com:443/video/1", wantErr: true},
|
||||
|
||||
@@ -2724,7 +2724,6 @@ func TestPostgresRejectsInvalidLifecycleTargetsWithSanitizedAuditPairs(t *testin
|
||||
body string
|
||||
reason string
|
||||
}{
|
||||
{action: "upgrade", body: `{"version":"` + secret + `"}`, reason: "upgrade_input_rejected"},
|
||||
{action: "rebind", body: `{"network_exit_id":"` + secret + `"}`, reason: "rebind_input_rejected"},
|
||||
} {
|
||||
t.Run(test.action, func(t *testing.T) {
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
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{}
|
||||
@@ -1,116 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.ipao.vip/rogee/creator-hub/internal/creator"
|
||||
hub "git.ipao.vip/rogee/creator-hub/internal/environment"
|
||||
xiaohongshu "git.ipao.vip/rogee/creator-hub/internal/platform/xiaohongshu"
|
||||
)
|
||||
|
||||
const testXiaohongshuIdentityURL = "https://edith.xiaohongshu.com/api/sns/web/v2/user/me"
|
||||
|
||||
func TestXiaohongshuGatewayBrowserFencesAccountGeneration(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
if request.Header.Get("Authorization") != "Bearer gateway-token-1" {
|
||||
t.Fatal("missing gateway authorization")
|
||||
}
|
||||
var body map[string]any
|
||||
if json.NewDecoder(request.Body).Decode(&body) != nil || body["binding_version"] != float64(2) || body["runtime_id"] != "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" || body["network_id"] != "native-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" || body["network_exit_id"] != "exit-a" {
|
||||
t.Fatalf("generation fence missing: %#v", body)
|
||||
}
|
||||
if request.URL.Path != "/v1/browsers/account-a/xiaohongshu/get" || body["url"] != testXiaohongshuIdentityURL {
|
||||
t.Fatalf("unexpected request: path=%s body=%#v", request.URL.Path, body)
|
||||
}
|
||||
_ = json.NewEncoder(response).Encode(map[string]any{"status": 200, "body": `{"success":true}`, "challenge": ""})
|
||||
}))
|
||||
defer server.Close()
|
||||
browser := xiaohongshuGatewayBrowser{gateway: hub.Gateway{Endpoint: server.URL, Token: "gateway-token-1"}, environment: readyDouyinEnvironment()}
|
||||
result, err := browser.Get(context.Background(), testXiaohongshuIdentityURL)
|
||||
if err != nil || result.Status != 200 || string(result.Body) != `{"success":true}` {
|
||||
t.Fatalf("unexpected result: %#v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXiaohongshuGatewayBrowserResolvesShareLinks(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Path != "/v1/browsers/account-a/xiaohongshu/resolve" {
|
||||
t.Fatalf("unexpected path: %s", request.URL.Path)
|
||||
}
|
||||
_ = json.NewEncoder(response).Encode(map[string]any{"url": "https://www.xiaohongshu.com/explore/n-1"})
|
||||
}))
|
||||
defer server.Close()
|
||||
browser := xiaohongshuGatewayBrowser{gateway: hub.Gateway{Endpoint: server.URL, Token: "gateway-token-1"}, environment: readyDouyinEnvironment()}
|
||||
resolved, err := browser.Resolve(context.Background(), "https://xhslink.com/a/abc")
|
||||
if err != nil || resolved != "https://www.xiaohongshu.com/explore/n-1" {
|
||||
t.Fatalf("resolved URL=%q err=%v", resolved, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDouyinCollectorResolvesTargetIdentitySeparately(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
var body map[string]any
|
||||
if json.NewDecoder(request.Body).Decode(&body) != nil {
|
||||
t.Fatal("invalid gateway request")
|
||||
}
|
||||
switch request.URL.Path {
|
||||
case "/v1/browsers/account-a/douyin/identity":
|
||||
if body["expected_account_key"] != "viewer-1" {
|
||||
t.Fatalf("viewer identity key=%#v", body["expected_account_key"])
|
||||
}
|
||||
_ = json.NewEncoder(response).Encode(map[string]any{"uid": "viewer-1", "sec_uid": "viewer-sec", "unique_id": "viewer"})
|
||||
case "/v1/browsers/account-a/douyin/get":
|
||||
if body["url"] != "https://www.douyin.com/aweme/v1/web/user/profile/other/?aid=6383&device_platform=webapp&user_id=2328120603967913" {
|
||||
t.Fatalf("target profile URL=%#v", body["url"])
|
||||
}
|
||||
_ = json.NewEncoder(response).Encode(map[string]any{"status": 200, "body": `{"status_code":0,"user":{"uid":"2328120603967913","sec_uid":"target-sec","unique_id":"target"}}`, "challenge": ""})
|
||||
default:
|
||||
t.Fatalf("unexpected path: %s", request.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
collector, target, err := newCreatorCollector(context.Background(), creator.PlatformDouyin, hub.Gateway{Endpoint: server.URL, Token: "gateway-token-1"}, readyDouyinEnvironment(), "viewer-1", "2328120603967913", "", creator.SourceCompetitor, "source-1")
|
||||
if err != nil || collector == nil || target != "target-sec" {
|
||||
t.Fatalf("new Douyin collector: collector=%#v target=%q err=%v", collector, target, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewXiaohongshuCollectorKeepsViewerAndTargetSeparate(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Path != "/v1/browsers/account-a/xiaohongshu/identity" {
|
||||
t.Fatalf("unexpected path: %s", request.URL.Path)
|
||||
}
|
||||
_ = json.NewEncoder(response).Encode(map[string]any{"uid": "viewer-1"})
|
||||
}))
|
||||
defer server.Close()
|
||||
collector, target, err := newCreatorCollector(context.Background(), creator.PlatformXiaohongshu, hub.Gateway{Endpoint: server.URL, Token: "gateway-token-1"}, readyDouyinEnvironment(), "viewer-1", "target-1", "https://www.xiaohongshu.com/user/profile/target-1?xsec_source=pc_search", creator.SourceCompetitor, "source-1")
|
||||
if err != nil {
|
||||
t.Fatalf("new collector: %v", err)
|
||||
}
|
||||
xhsCollector, ok := collector.(*xiaohongshu.Collector)
|
||||
if !ok || xhsCollector.AccountKey != "target-1" || target != "target-1" || xhsCollector.HomepageURL == "" {
|
||||
t.Fatalf("collector=%#v target=%q", collector, target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXiaohongshuGatewayBrowserPostCarriesJSONBody(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
var body map[string]any
|
||||
if json.NewDecoder(request.Body).Decode(&body) != nil || body["url"] != "https://so.xiaohongshu.com/api/sns/web/v2/search/notes" {
|
||||
t.Fatalf("unexpected request body: %#v", body)
|
||||
}
|
||||
if _, ok := body["body"].(map[string]any); !ok {
|
||||
t.Fatalf("missing nested request body: %#v", body)
|
||||
}
|
||||
_ = json.NewEncoder(response).Encode(map[string]any{"status": 200, "body": `{"success":true}`, "challenge": ""})
|
||||
}))
|
||||
defer server.Close()
|
||||
browser := xiaohongshuGatewayBrowser{gateway: hub.Gateway{Endpoint: server.URL, Token: "gateway-token-1"}, environment: readyDouyinEnvironment()}
|
||||
if _, err := browser.Post(context.Background(), "https://so.xiaohongshu.com/api/sns/web/v2/search/notes", []byte(`{"keyword":"x"}`)); err != nil {
|
||||
t.Fatalf("post failed: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user