fix: complete Xiaohongshu read-only flow
This commit is contained in:
@@ -255,6 +255,9 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto
|
||||
if err := decodeCreator(c, &input); err != nil {
|
||||
return creatorError(c, err)
|
||||
}
|
||||
if err := validateXiaohongshuCompetitor(input); err != nil {
|
||||
return creatorError(c, err)
|
||||
}
|
||||
item, err := store.CreateCompetitor(c.Context(), input)
|
||||
if err != nil {
|
||||
return creatorError(c, err)
|
||||
@@ -296,6 +299,47 @@ 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
|
||||
}
|
||||
collector, err := newXiaohongshuReadCollector(c.Context(), store, phaseAStore, hubStore, input.AccountID, creator.SourceOwned, input.AccountID)
|
||||
if err != nil {
|
||||
return creatorError(c, err)
|
||||
}
|
||||
page, err := collector.SearchNotes(c.Context(), input.Query, input.Page)
|
||||
if err != nil {
|
||||
return creatorError(c, err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
collector, err := newXiaohongshuReadCollector(c.Context(), store, phaseAStore, hubStore, input.AccountID, creator.SourceOwned, input.AccountID)
|
||||
if err != nil {
|
||||
return creatorError(c, err)
|
||||
}
|
||||
item, err := collector.GetNoteDetail(c.Context(), input.URL)
|
||||
if err != nil {
|
||||
return creatorError(c, err)
|
||||
}
|
||||
return c.JSON(item)
|
||||
})
|
||||
|
||||
app.Get("/api/creator/works", func(c fiber.Ctx) error {
|
||||
filter, err := workFilter(c)
|
||||
if err != nil {
|
||||
@@ -1128,6 +1172,11 @@ func syncCreatorCompetitorWithClaim(ctx context.Context, store *creator.Store, p
|
||||
if competitor.Platform != creator.PlatformDouyin && competitor.Platform != creator.PlatformXiaohongshu {
|
||||
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)
|
||||
@@ -1153,7 +1202,7 @@ func syncCreatorCompetitorWithClaim(ctx context.Context, store *creator.Store, p
|
||||
if err != nil {
|
||||
return blocked(fmt.Errorf("%w: gateway unavailable: %v", creator.ErrUnavailable, err))
|
||||
}
|
||||
collector, _, err := newCreatorCollector(ctx, competitor.Platform, gateway, environment, account.PlatformAccountKey, creator.SourceCompetitor, competitor.ID)
|
||||
collector, _, err := newCreatorCollector(ctx, competitor.Platform, gateway, environment, account.PlatformAccountKey, competitor.PlatformAccountKey, competitor.HomepageURL, creator.SourceCompetitor, competitor.ID)
|
||||
if err != nil {
|
||||
return blocked(fmt.Errorf("%w: account identity verification failed: %v", creator.ErrConflict, err))
|
||||
}
|
||||
@@ -1283,7 +1332,18 @@ func refreshCreatorMetricWork(ctx context.Context, store *creator.Store, phaseAS
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: gateway unavailable: %v", creator.ErrUnavailable, err)
|
||||
}
|
||||
collector, collectionKey, err := newCreatorCollector(ctx, work.Platform, gateway, environment, account.PlatformAccountKey, work.SourceType, work.SourceID)
|
||||
targetAccountKey, homepageURL := account.PlatformAccountKey, ""
|
||||
if work.SourceType == creator.SourceCompetitor {
|
||||
competitor, competitorErr := store.GetCompetitor(ctx, work.SourceID)
|
||||
if competitorErr != nil {
|
||||
return competitorErr
|
||||
}
|
||||
if competitor.Platform != work.Platform {
|
||||
return creator.ErrConflict
|
||||
}
|
||||
targetAccountKey, homepageURL = competitor.PlatformAccountKey, competitor.HomepageURL
|
||||
}
|
||||
collector, collectionKey, err := newCreatorCollector(ctx, work.Platform, gateway, environment, account.PlatformAccountKey, targetAccountKey, homepageURL, work.SourceType, work.SourceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: account identity verification failed: %v", creator.ErrConflict, err)
|
||||
}
|
||||
@@ -1356,7 +1416,7 @@ func syncCreatorOwned(ctx context.Context, store *creator.Store, phaseAStore *ph
|
||||
logrus.WithError(releaseErr).WithField("account_id", account.ID).Warn("creator source sync lease release failed")
|
||||
}
|
||||
}()
|
||||
collector, _, err := newCreatorCollector(ctx, account.Platform, gateway, environment, account.PlatformAccountKey, creator.SourceOwned, account.ID)
|
||||
collector, _, err := newCreatorCollector(ctx, account.Platform, gateway, environment, account.PlatformAccountKey, account.PlatformAccountKey, "", creator.SourceOwned, account.ID)
|
||||
if err != nil {
|
||||
blockErr := store.MarkCollectionBlocked(ctx, creator.SourceOwned, account.ID, err.Error(), now, settings.LookbackDays)
|
||||
return errors.Join(fmt.Errorf("%w: account identity verification failed: %v", creator.ErrConflict, err), blockErr)
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -94,6 +95,26 @@ func (browser xiaohongshuGatewayBrowser) Identity(ctx context.Context, expectedK
|
||||
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 {
|
||||
@@ -132,28 +153,79 @@ func decodeXiaohongshuResponse(body []byte) (xiaohongshu.Response, error) {
|
||||
return xiaohongshu.Response{Status: response.Status, Body: []byte(response.Body), Challenge: response.Challenge}, nil
|
||||
}
|
||||
|
||||
func newCreatorCollector(ctx context.Context, platform string, gateway hub.Gateway, environment hub.EnvironmentContext, accountKey, sourceType, sourceID string) (creator.PlatformCollector, string, error) {
|
||||
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}
|
||||
uid, err := browser.Identity(ctx, accountKey)
|
||||
if err != nil {
|
||||
if _, err := browser.Identity(ctx, viewerAccountKey); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
collector := douyinCollector(browser, accountKey, sourceType, sourceID)
|
||||
canonical, err := collector.CanonicalSecUID(ctx, uid)
|
||||
if err != nil {
|
||||
collector := douyinCollector(browser, targetAccountKey, sourceType, sourceID)
|
||||
if _, err := collector.CanonicalSecUID(ctx, viewerAccountKey); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
collector.AccountKey = canonical
|
||||
return &collector, canonical, nil
|
||||
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}
|
||||
uid, err := browser.Identity(ctx, accountKey)
|
||||
if err != nil {
|
||||
if _, err := browser.Identity(ctx, viewerAccountKey); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return &xiaohongshu.Collector{Browser: browser, AccountKey: uid, SourceType: sourceType, SourceID: sourceID}, uid, nil
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.ipao.vip/rogee/creator-hub/internal/creator"
|
||||
"git.ipao.vip/rogee/creator-hub/internal/hub"
|
||||
"git.ipao.vip/rogee/creator-hub/internal/xiaohongshu"
|
||||
)
|
||||
|
||||
const testXiaohongshuIdentityURL = "https://edith.xiaohongshu.com/api/sns/web/v2/user/me"
|
||||
@@ -34,6 +36,39 @@ func TestXiaohongshuGatewayBrowserFencesAccountGeneration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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 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
|
||||
|
||||
Reference in New Issue
Block a user