diff --git a/cmd/control-plane/creator.go b/cmd/control-plane/creator.go index 060028c..a013ce9 100644 --- a/cmd/control-plane/creator.go +++ b/cmd/control-plane/creator.go @@ -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) diff --git a/cmd/control-plane/xiaohongshu.go b/cmd/control-plane/xiaohongshu.go index 442cdad..c2c4875 100644 --- a/cmd/control-plane/xiaohongshu.go +++ b/cmd/control-plane/xiaohongshu.go @@ -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) } diff --git a/cmd/control-plane/xiaohongshu_test.go b/cmd/control-plane/xiaohongshu_test.go index b9723ff..89592a8 100644 --- a/cmd/control-plane/xiaohongshu_test.go +++ b/cmd/control-plane/xiaohongshu_test.go @@ -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 diff --git a/cmd/docker_gateway/douyin.py b/cmd/docker_gateway/douyin.py index 81faa98..b6a6da8 100644 --- a/cmd/docker_gateway/douyin.py +++ b/cmd/docker_gateway/douyin.py @@ -18,7 +18,7 @@ from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager, suppress from dataclasses import dataclass from datetime import datetime, timezone -from urllib.parse import urlsplit +from urllib.parse import parse_qs, urlsplit import websocket @@ -229,6 +229,7 @@ class DouyinBrowser: origin: str = ORIGIN, url_validator: Callable[[object], bool] | None = None, media_validator: Callable[[object], bool] | None = None, + media_selector: str = "video", ) -> None: self.endpoint = endpoint or ( lambda alias: f"http://creatorhub-browser-{alias}:9222" @@ -236,6 +237,7 @@ class DouyinBrowser: self.origin = origin self.url_validator = url_validator or is_douyin_url self.media_validator = media_validator or is_douyin_media_url + self.media_selector = media_selector @contextmanager def connection(self, alias: str): @@ -396,8 +398,8 @@ class DouyinBrowser: raise DouyinError("Douyin media page did not load") result = cdp.evaluate( f"""(async()=>{{ - const video=document.querySelector('video'); - const source=video?.currentSrc||video?.src||''; + const media=document.querySelector({json.dumps(self.media_selector)}); + const source=media?.currentSrc||media?.src||''; if(!source)return {{error:'media_source_unavailable'}}; const r=await fetch(source,{{credentials:'include',redirect:'error'}}); if(!r.body)return {{status:r.status,content_type:r.headers.get('content-type')||'',body:''}}; @@ -1289,7 +1291,9 @@ XHS_ORIGIN = "https://www.xiaohongshu.com" XHS_API_ORIGIN = "https://edith.xiaohongshu.com" XHS_SEARCH_ORIGIN = "https://so.xiaohongshu.com" XHS_IDENTITY_URL = XHS_API_ORIGIN + "/api/sns/web/v2/user/me" -XHS_ALLOWED_HOSTS = frozenset({"www.xiaohongshu.com", "edith.xiaohongshu.com", "so.xiaohongshu.com"}) +XHS_ALLOWED_HOSTS = frozenset( + {"www.xiaohongshu.com", "edith.xiaohongshu.com", "so.xiaohongshu.com"} +) class XiaohongshuBrowser(DouyinBrowser): @@ -1299,6 +1303,7 @@ class XiaohongshuBrowser(DouyinBrowser): origin=XHS_ORIGIN, url_validator=is_xiaohongshu_url, media_validator=is_xiaohongshu_media_url, + media_selector="video, img.note-slider-img", ) def post(self, alias: str, target: str, body: bytes) -> BrowserResponse: @@ -1334,7 +1339,29 @@ class XiaohongshuBrowser(DouyinBrowser): response_body = result.get("body") if not isinstance(response_body, str): raise DouyinError("restricted Xiaohongshu POST returned invalid body") - return BrowserResponse(status, response_body, detect_challenge(status, response_body)) + return BrowserResponse( + status, response_body, detect_challenge(status, response_body) + ) + + def resolve(self, alias: str, target: str) -> str: + if not is_xiaohongshu_share_url(target): + raise DouyinError("restricted Xiaohongshu share URL is invalid") + with self.connection(alias) as cdp: + if cdp.evaluate("location.origin") != self.origin: + raise DouyinError("restricted browser origin changed") + cdp.command("Page.navigate", {"url": target}) + event = cdp.wait_event( + "Page.frameNavigated", + lambda params: _is_xiaohongshu_page_url( + params.get("frame", {}).get("url", "") + ), + ) + final_url = event.get("frame", {}).get("url") + if not isinstance(final_url, str) or not _is_xiaohongshu_page_url(final_url): + raise DouyinError( + "Xiaohongshu share URL did not resolve to a supported page" + ) + return final_url def identity(self, alias: str, expected_uid: str | None = None) -> dict: response = self.get(alias, XHS_IDENTITY_URL) @@ -1362,7 +1389,9 @@ class XiaohongshuBrowser(DouyinBrowser): ): raise DouyinError("Xiaohongshu login is not valid") if expected_uid and user_id != expected_uid: - raise DouyinError("Xiaohongshu identity does not match the expected account") + raise DouyinError( + "Xiaohongshu identity does not match the expected account" + ) return {"uid": user_id, "user_id": user_id, "nickname": nickname or ""} @@ -1403,6 +1432,62 @@ def is_xiaohongshu_url(value: object) -> bool: ) +def is_xiaohongshu_share_url(value: object) -> bool: + if not isinstance(value, str): + return False + try: + parsed = urlsplit(value) + port = parsed.port + except (TypeError, ValueError): + return False + path = parsed.path.strip("/") + return ( + parsed.scheme == "https" + and parsed.hostname in {"xhslink.com", "www.xhslink.com"} + and port is None + and parsed.username is None + and parsed.password is None + and parsed.fragment == "" + and bool(path) + and len(path) <= 256 + and not parsed.query + ) + + +def _is_xiaohongshu_page_url(value: object) -> bool: + if not isinstance(value, str): + return False + try: + parsed = urlsplit(value) + port = parsed.port + query = parse_qs(parsed.query, keep_blank_values=True) + except (TypeError, ValueError): + return False + parts = parsed.path.strip("/").split("/") + if not ( + parsed.scheme == "https" + and parsed.hostname == "www.xiaohongshu.com" + and port is None + and parsed.username is None + and parsed.password is None + and parsed.fragment == "" + and ( + len(parts) == 2 + and parts[0] == "explore" + or len(parts) == 3 + and parts[:2] == ["user", "profile"] + ) + ): + return False + return all( + key in {"xsec_token", "xsec_source"} + and len(values) == 1 + and len(values[0]) <= 2048 + and not any(char in values[0] for char in "\r\n") + for key, values in query.items() + ) + + def notice_ids(event: dict) -> list[str]: try: payload = json.loads(event["payload"]) diff --git a/cmd/docker_gateway/gateway.py b/cmd/docker_gateway/gateway.py index 005fd25..a58f288 100644 --- a/cmd/docker_gateway/gateway.py +++ b/cmd/docker_gateway/gateway.py @@ -47,6 +47,7 @@ from .douyin import ( DouyinError, SubscriptionManager, XiaohongshuBrowser, + is_xiaohongshu_share_url, ) from .proxy import ProxyExit, ProxyRegistry @@ -99,7 +100,9 @@ class Gateway: self.token = token self.self_name = self_name self.browser = browser or DouyinBrowser(self._browser_endpoint) - self.xiaohongshu_browser = xiaohongshu_browser or XiaohongshuBrowser(self._browser_endpoint) + self.xiaohongshu_browser = xiaohongshu_browser or XiaohongshuBrowser( + self._browser_endpoint + ) self.proxies = ProxyRegistry() self.reservations = AliasReservationManager(docker, self_name) self.subscriptions = SubscriptionManager(self.browser) @@ -685,9 +688,15 @@ class Gateway: response = self.xiaohongshu_browser.get(alias, target) self._require_douyin_generation(alias, input) except DouyinError as exc: - LOG.warning("Xiaohongshu GET failed alias=%s reason=%s", alias, str(exc)) + LOG.warning( + "Xiaohongshu GET failed alias=%s reason=%s", alias, str(exc) + ) raise RequestError("restricted Xiaohongshu operation failed") from exc - return {"status": response.status, "body": response.body, "challenge": response.challenge} + return { + "status": response.status, + "body": response.body, + "challenge": response.challenge, + } def post_xiaohongshu(self, alias: str, input: dict) -> dict: target = input.get("url", "") @@ -699,7 +708,9 @@ class Gateway: ): raise RequestError("invalid restricted Xiaohongshu POST request", 400) try: - encoded = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode() + encoded = json.dumps( + body, ensure_ascii=False, separators=(",", ":") + ).encode() except (TypeError, ValueError) as exc: raise RequestError("invalid restricted Xiaohongshu POST body", 400) from exc with self._alias_lock(alias): @@ -708,13 +719,47 @@ class Gateway: response = self.xiaohongshu_browser.post(alias, target, encoded) self._require_douyin_generation(alias, input) except DouyinError as exc: - LOG.warning("Xiaohongshu POST failed alias=%s reason=%s", alias, str(exc)) + LOG.warning( + "Xiaohongshu POST failed alias=%s reason=%s", alias, str(exc) + ) raise RequestError("restricted Xiaohongshu operation failed") from exc - return {"status": response.status, "body": response.body, "challenge": response.challenge} + return { + "status": response.status, + "body": response.body, + "challenge": response.challenge, + } + + def resolve_xiaohongshu(self, alias: str, input: dict) -> dict: + target = input.get("url", "") + if not valid_xiaohongshu_generation(input) or not valid_xiaohongshu_source_url( + target + ): + raise RequestError("invalid restricted Xiaohongshu source URL", 400) + if not is_xiaohongshu_share_url(target): + return {"url": target} + with self._alias_lock(alias): + self._require_douyin_generation(alias, input) + try: + resolved = self.xiaohongshu_browser.resolve(alias, target) + self._require_douyin_generation(alias, input) + except DouyinError as exc: + LOG.warning( + "Xiaohongshu share resolution failed alias=%s reason=%s", + alias, + str(exc), + ) + raise RequestError( + "restricted Xiaohongshu share resolution failed" + ) from exc + if not valid_xiaohongshu_page_url(resolved): + raise RequestError("Xiaohongshu share resolved to an unsupported URL", 502) + return {"url": resolved} def get_xiaohongshu_media(self, alias: str, input: dict) -> dict: target = input.get("url", "") - if not valid_xiaohongshu_generation(input) or not valid_xiaohongshu_media_url(target): + if not valid_xiaohongshu_generation(input) or not valid_xiaohongshu_media_url( + target + ): raise RequestError("invalid restricted Xiaohongshu media request", 400) with self._alias_lock(alias): self._require_douyin_generation(alias, input) @@ -722,9 +767,19 @@ class Gateway: response = self.xiaohongshu_browser.get_media(alias, target) self._require_douyin_generation(alias, input) except DouyinError as exc: - LOG.warning("Xiaohongshu media download failed alias=%s reason=%s", alias, str(exc)) - raise RequestError("restricted Xiaohongshu media download failed") from exc - return {"status": response.status, "content_type": response.content_type, "body_base64": response.body_base64} + LOG.warning( + "Xiaohongshu media download failed alias=%s reason=%s", + alias, + str(exc), + ) + raise RequestError( + "restricted Xiaohongshu media download failed" + ) from exc + return { + "status": response.status, + "content_type": response.content_type, + "body_base64": response.body_base64, + } def xiaohongshu_identity(self, alias: str, input: dict) -> dict: expected_account_key = input.get("expected_account_key", "") @@ -739,10 +794,18 @@ class Gateway: try: identity = self.xiaohongshu_browser.identity(alias) except DouyinError as exc: - LOG.warning("Xiaohongshu identity verification failed alias=%s reason=%s", alias, str(exc)) - raise RequestError("Xiaohongshu login identity could not be verified") from exc + LOG.warning( + "Xiaohongshu identity verification failed alias=%s reason=%s", + alias, + str(exc), + ) + raise RequestError( + "Xiaohongshu login identity could not be verified" + ) from exc if identity.get("uid") != expected_account_key: - raise RequestError("Xiaohongshu identity does not match the expected account", 409) + raise RequestError( + "Xiaohongshu identity does not match the expected account", 409 + ) return identity def douyin_action(self, alias: str, input: dict) -> dict: @@ -1210,7 +1273,7 @@ class GatewayHandler(BaseHTTPRequestHandler): gateway.restore_proxy(alias, body) return None match = re.fullmatch( - r"/v1/browsers/([a-z0-9][a-z0-9-]{0,31})/xiaohongshu/(get|post|media|identity)", + r"/v1/browsers/([a-z0-9][a-z0-9-]{0,31})/xiaohongshu/(get|post|media|identity|resolve)", path, ) if match: @@ -1221,6 +1284,8 @@ class GatewayHandler(BaseHTTPRequestHandler): return gateway.post_xiaohongshu(alias, body) if action == "media" and method == "POST": return gateway.get_xiaohongshu_media(alias, body) + if action == "resolve" and method == "POST": + return gateway.resolve_xiaohongshu(alias, body) if action == "identity" and method == "POST": return gateway.xiaohongshu_identity(alias, body) match = re.fullmatch( @@ -1516,7 +1581,9 @@ def valid_xiaohongshu_generation(value: dict) -> bool: return valid_douyin_generation(value) -def valid_xhs_query(query: object, allowed: set[str], required: set[str] | None = None) -> bool: +def valid_xhs_query( + query: object, allowed: set[str], required: set[str] | None = None +) -> bool: if not isinstance(query, dict) or not isinstance(allowed, set): return False required = required or set() @@ -1525,7 +1592,12 @@ def valid_xhs_query(query: object, allowed: set[str], required: set[str] | None for key, values in query.items(): if not isinstance(key, str) or not isinstance(values, list) or len(values) != 1: return False - if not isinstance(values[0], str) or len(values[0]) > 2048 or "\r" in values[0] or "\n" in values[0]: + if ( + not isinstance(values[0], str) + or len(values[0]) > 2048 + or "\r" in values[0] + or "\n" in values[0] + ): return False return True @@ -1549,18 +1621,45 @@ def valid_xhs_url(raw: object) -> bool: query = parse_qs(parsed.query, keep_blank_values=True) except ValueError: return False - if _valid_xhs_host(parsed, "edith.xiaohongshu.com") and parsed.path == XHS_IDENTITY_PATH: + if ( + _valid_xhs_host(parsed, "edith.xiaohongshu.com") + and parsed.path == XHS_IDENTITY_PATH + ): return not query - if _valid_xhs_host(parsed, "edith.xiaohongshu.com") and parsed.path == XHS_USER_POSTED_PATH: + if ( + _valid_xhs_host(parsed, "edith.xiaohongshu.com") + and parsed.path == XHS_USER_POSTED_PATH + ): + return ( + valid_xhs_query( + query, + { + "user_id", + "cursor", + "num", + "image_formats", + "xsec_source", + "xsec_token", + }, + {"user_id", "num"}, + ) + and bool(XHS_ACCOUNT_KEY_RE.fullmatch(query["user_id"][0])) + and query["num"] == ["30"] + ) + if ( + _valid_xhs_host(parsed, "edith.xiaohongshu.com") + and parsed.path == XHS_COMMENTS_PATH + ): return valid_xhs_query( query, - {"user_id", "cursor", "num", "image_formats", "xsec_source", "xsec_token"}, - {"user_id", "num"}, - ) and bool(XHS_ACCOUNT_KEY_RE.fullmatch(query["user_id"][0])) and query["num"] == ["30"] - if _valid_xhs_host(parsed, "edith.xiaohongshu.com") and parsed.path == XHS_COMMENTS_PATH: - return valid_xhs_query( - query, - {"note_id", "cursor", "top_comment_id", "image_formats", "xsec_source", "xsec_token"}, + { + "note_id", + "cursor", + "top_comment_id", + "image_formats", + "xsec_source", + "xsec_token", + }, {"note_id", "cursor", "top_comment_id"}, ) and bool(XHS_ACCOUNT_KEY_RE.fullmatch(query["note_id"][0])) return False @@ -1570,6 +1669,34 @@ def valid_xiaohongshu_url(raw: object) -> bool: return valid_xhs_url(raw) +def valid_xiaohongshu_page_url(raw: object) -> bool: + if not isinstance(raw, str): + return False + try: + parsed = urlsplit(raw) + query = parse_qs(parsed.query, keep_blank_values=True) + except ValueError: + return False + if not _valid_xhs_host(parsed, "www.xiaohongshu.com") or not valid_xhs_query( + query, {"xsec_source", "xsec_token"} + ): + return False + parts = parsed.path.strip("/").split("/") + return ( + len(parts) == 2 + and parts[0] == "explore" + and bool(XHS_ACCOUNT_KEY_RE.fullmatch(parts[1])) + ) or ( + len(parts) == 3 + and parts[:2] == ["user", "profile"] + and bool(XHS_ACCOUNT_KEY_RE.fullmatch(parts[2])) + ) + + +def valid_xiaohongshu_source_url(raw: object) -> bool: + return valid_xiaohongshu_page_url(raw) or is_xiaohongshu_share_url(raw) + + def valid_xhs_post_url(raw: object) -> bool: if not isinstance(raw, str): return False @@ -1600,7 +1727,12 @@ def valid_xiaohongshu_media_url(raw: object) -> bool: if not _valid_xhs_host(parsed, "www.xiaohongshu.com"): return False parts = parsed.path.strip("/").split("/") - return len(parts) == 2 and parts[0] == "explore" and bool(XHS_ACCOUNT_KEY_RE.fullmatch(parts[1])) and valid_xhs_query(query, {"xsec_source", "xsec_token"}) + return ( + len(parts) == 2 + and parts[0] == "explore" + and bool(XHS_ACCOUNT_KEY_RE.fullmatch(parts[1])) + and valid_xhs_query(query, {"xsec_source", "xsec_token"}) + ) def valid_douyin_url(raw: object) -> bool: diff --git a/cmd/docker_gateway/test_xiaohongshu.py b/cmd/docker_gateway/test_xiaohongshu.py index 274bd16..e01a20b 100644 --- a/cmd/docker_gateway/test_xiaohongshu.py +++ b/cmd/docker_gateway/test_xiaohongshu.py @@ -13,31 +13,70 @@ valid_xhs_url = gateway_module.valid_xhs_url valid_xiaohongshu_url = gateway_module.valid_xiaohongshu_url valid_xiaohongshu_media_url = gateway_module.valid_xiaohongshu_media_url valid_xiaohongshu_generation = gateway_module.valid_xiaohongshu_generation +valid_xiaohongshu_page_url = gateway_module.valid_xiaohongshu_page_url +valid_xiaohongshu_source_url = gateway_module.valid_xiaohongshu_source_url class XiaohongshuValidationTests(unittest.TestCase): def test_read_urls_use_explicit_host_path_and_query_allowlist(self) -> None: - self.assertTrue(valid_xhs_url("https://edith.xiaohongshu.com/api/sns/web/v2/user/me")) - self.assertTrue(valid_xiaohongshu_url("https://edith.xiaohongshu.com/api/sns/web/v2/user/me")) + self.assertTrue( + valid_xhs_url("https://edith.xiaohongshu.com/api/sns/web/v2/user/me") + ) + self.assertTrue( + valid_xiaohongshu_url( + "https://edith.xiaohongshu.com/api/sns/web/v2/user/me" + ) + ) + self.assertTrue( + valid_xiaohongshu_page_url("https://www.xiaohongshu.com/user/profile/u-1") + ) + self.assertTrue(valid_xiaohongshu_source_url("https://xhslink.com/a/abc")) self.assertTrue( valid_xhs_url( "https://edith.xiaohongshu.com/api/sns/web/v1/user_posted?user_id=u-1&cursor=&num=30&xsec_source=pc_user" ) ) - self.assertFalse(valid_xhs_url("https://edith.xiaohongshu.com/api/sns/web/v1/user_posted?user_id=u-1&num=10")) - self.assertFalse(valid_xhs_url("https://edith.xiaohongshu.com.evil/api/sns/web/v2/user/me")) - self.assertTrue(valid_xhs_post_url("https://so.xiaohongshu.com/api/sns/web/v2/search/notes")) - self.assertTrue(valid_xhs_post_url("https://edith.xiaohongshu.com/api/sns/web/v1/feed")) - self.assertTrue(valid_xiaohongshu_media_url("https://www.xiaohongshu.com/explore/n-1?xsec_source=pc_search")) - self.assertFalse(valid_xiaohongshu_media_url("https://www.xiaohongshu.com/explore/n-1#fragment")) + self.assertFalse( + valid_xhs_url( + "https://edith.xiaohongshu.com/api/sns/web/v1/user_posted?user_id=u-1&num=10" + ) + ) + self.assertFalse( + valid_xhs_url("https://edith.xiaohongshu.com.evil/api/sns/web/v2/user/me") + ) + self.assertTrue( + valid_xhs_post_url("https://so.xiaohongshu.com/api/sns/web/v2/search/notes") + ) + self.assertTrue( + valid_xhs_post_url("https://edith.xiaohongshu.com/api/sns/web/v1/feed") + ) + self.assertTrue( + valid_xiaohongshu_media_url( + "https://www.xiaohongshu.com/explore/n-1?xsec_source=pc_search" + ) + ) + self.assertFalse( + valid_xiaohongshu_media_url( + "https://www.xiaohongshu.com/explore/n-1#fragment" + ) + ) def test_generation_shape_matches_existing_browser_fence(self) -> None: self.assertTrue( valid_xiaohongshu_generation( - {"binding_version": 1, "runtime_id": "a" * 64, "network_id": "network", "network_exit_id": ""} + { + "binding_version": 1, + "runtime_id": "a" * 64, + "network_id": "network", + "network_exit_id": "", + } + ) + ) + self.assertFalse( + valid_xiaohongshu_generation( + {"binding_version": 1, "runtime_id": "runtime", "network_id": "network"} ) ) - self.assertFalse(valid_xiaohongshu_generation({"binding_version": 1, "runtime_id": "runtime", "network_id": "network"})) class XiaohongshuRouteTests(unittest.TestCase): @@ -48,18 +87,42 @@ class XiaohongshuRouteTests(unittest.TestCase): gateway.post_xiaohongshu.return_value = {"status": 200} gateway.get_xiaohongshu_media.return_value = {"status": 200} gateway.xiaohongshu_identity.return_value = {"uid": "u-1"} + gateway.resolve_xiaohongshu.return_value = { + "url": "https://www.xiaohongshu.com/explore/n-1" + } server = Mock() server.gateway = gateway cast(Any, handler).server = server cast(Any, handler).server_as_gateway = lambda: server - self.assertEqual(handler._route("POST", "/v1/browsers/account-a/xiaohongshu/get", {}, {}), {"status": 200}) - self.assertEqual(handler._route("POST", "/v1/browsers/account-a/xiaohongshu/post", {}, {}), {"status": 200}) - self.assertEqual(handler._route("POST", "/v1/browsers/account-a/xiaohongshu/media", {}, {}), {"status": 200}) - self.assertEqual(handler._route("POST", "/v1/browsers/account-a/xiaohongshu/identity", {}, {}), {"uid": "u-1"}) + self.assertEqual( + handler._route("POST", "/v1/browsers/account-a/xiaohongshu/get", {}, {}), + {"status": 200}, + ) + self.assertEqual( + handler._route("POST", "/v1/browsers/account-a/xiaohongshu/post", {}, {}), + {"status": 200}, + ) + self.assertEqual( + handler._route("POST", "/v1/browsers/account-a/xiaohongshu/media", {}, {}), + {"status": 200}, + ) + self.assertEqual( + handler._route( + "POST", "/v1/browsers/account-a/xiaohongshu/identity", {}, {} + ), + {"uid": "u-1"}, + ) + self.assertEqual( + handler._route( + "POST", "/v1/browsers/account-a/xiaohongshu/resolve", {}, {} + ), + {"url": "https://www.xiaohongshu.com/explore/n-1"}, + ) with self.assertRaises(RequestError): handler._route("POST", "/v1/browsers/account-a/xiaohongshu/action", {}, {}) gateway.get_xiaohongshu.assert_called_once_with("account-a", {}) gateway.post_xiaohongshu.assert_called_once_with("account-a", {}) gateway.get_xiaohongshu_media.assert_called_once_with("account-a", {}) gateway.xiaohongshu_identity.assert_called_once_with("account-a", {}) + gateway.resolve_xiaohongshu.assert_called_once_with("account-a", {}) diff --git a/cmd/docker_gateway/xiaohongshu.py b/cmd/docker_gateway/xiaohongshu.py index 4423abf..3268120 100644 --- a/cmd/docker_gateway/xiaohongshu.py +++ b/cmd/docker_gateway/xiaohongshu.py @@ -12,6 +12,7 @@ from .douyin import ( XHS_SEARCH_ORIGIN, XiaohongshuBrowser, is_xiaohongshu_media_url, + is_xiaohongshu_share_url, is_xiaohongshu_url, ) @@ -23,5 +24,6 @@ __all__ = [ "XHS_SEARCH_ORIGIN", "XiaohongshuBrowser", "is_xiaohongshu_media_url", + "is_xiaohongshu_share_url", "is_xiaohongshu_url", ] diff --git a/docs/research/xhs-all-in-one.md b/docs/research/xhs-all-in-one.md index 279f1a0..855c76d 100644 --- a/docs/research/xhs-all-in-one.md +++ b/docs/research/xhs-all-in-one.md @@ -22,7 +22,8 @@ ## 1.1 当前实现边界 -- CreatorHub 当前已接入小红书只读 collector、详情/搜索/作品/一级评论读取、受限 gateway 路由和控制面平台分派;写操作与事件监听仍未接入。 +- CreatorHub 当前已接入小红书只读 collector、详情/搜索/作品/一级评论读取、分享链接解析、原始 payload 保存、受限 gateway 路由和控制面平台分派;写操作与事件监听仍未接入。 +- 小红书竞品主页输入会校验稳定账号标识并保留主页中的 `xsec_token/xsec_source`;作品详情可通过受限浏览器解析官方分享短链。 - `internal/creator/collection.go` 的分页、窗口、checkpoint 和 lease 模型可作为后续适配的复用边界,但不能证明小红书平台能力。 **未完成的真实能力验收:**私有接口签名是否能由浏览器当前会话完成、真实 UID/作品/评论分页、媒体下载、写操作和事件监听仍需真实小红书环境分别验证。HTTP 200、离线 fixture 和本地单元测试不能替代这些证据。 diff --git a/internal/creator/content.go b/internal/creator/content.go index 430c4b1..8c573c2 100644 --- a/internal/creator/content.go +++ b/internal/creator/content.go @@ -12,6 +12,8 @@ import ( "unicode/utf8" ) +const maxRawPayloadBytes = 4 << 20 + func validateHomepage(value string) error { parsed, err := url.Parse(strings.TrimSpace(value)) if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil { @@ -218,7 +220,8 @@ func (s *Store) UpsertWork(ctx context.Context, input WorkInput, now time.Time) (input.SourceType != SourceOwned && input.SourceType != SourceCompetitor) || utf8.RuneCountInString(input.WorkKey) > 255 || utf8.RuneCountInString(input.Title) > 1000 || utf8.RuneCountInString(input.Body) > 100000 || utf8.RuneCountInString(input.OriginalURL) > 2000 || - utf8.RuneCountInString(input.CoverURL) > 2000 { + utf8.RuneCountInString(input.CoverURL) > 2000 || len(input.RawPayload) > maxRawPayloadBytes || + input.RawPayload != "" && !json.Valid([]byte(input.RawPayload)) { return Work{}, false, ErrInvalid } if err := s.validateWorkSource(ctx, input.Platform, input.SourceType, input.SourceID); err != nil { @@ -257,8 +260,8 @@ func (s *Store) UpsertWork(ctx context.Context, input WorkInput, now time.Time) var inserted bool err = tx.QueryRowContext(ctx, ` INSERT INTO creator_work (id, platform, work_key, source_type, source_id, author_name, title, body, - published_at, published_at_status, original_url, cover_url, likes, comments_count, shares) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + published_at, published_at_status, original_url, cover_url, raw_payload, likes, comments_count, shares) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) ON CONFLICT (platform, work_key) DO UPDATE SET author_name = CASE WHEN EXCLUDED.author_name = '' THEN creator_work.author_name ELSE EXCLUDED.author_name END, title = CASE WHEN EXCLUDED.title = '' THEN creator_work.title ELSE EXCLUDED.title END, @@ -267,12 +270,13 @@ func (s *Store) UpsertWork(ctx context.Context, input WorkInput, now time.Time) published_at_status = CASE WHEN EXCLUDED.published_at IS NULL THEN creator_work.published_at_status ELSE EXCLUDED.published_at_status END, original_url = CASE WHEN EXCLUDED.original_url = '' THEN creator_work.original_url ELSE EXCLUDED.original_url END, cover_url = CASE WHEN EXCLUDED.cover_url = '' THEN creator_work.cover_url ELSE EXCLUDED.cover_url END, + raw_payload = COALESCE(EXCLUDED.raw_payload, creator_work.raw_payload), likes = COALESCE(EXCLUDED.likes, creator_work.likes), comments_count = COALESCE(EXCLUDED.comments_count, creator_work.comments_count), shares = COALESCE(EXCLUDED.shares, creator_work.shares), updated_at = now() RETURNING id, (xmax = 0)`, id, input.Platform, input.WorkKey, input.SourceType, input.SourceID, input.AuthorName, input.Title, input.Body, input.PublishedAt, status, input.OriginalURL, input.CoverURL, - input.Likes, input.CommentsCount, input.Shares).Scan(&returnedID, &inserted) + nullableRawPayload(input.RawPayload), input.Likes, input.CommentsCount, input.Shares).Scan(&returnedID, &inserted) if err != nil { return Work{}, false, databaseError(err) } @@ -297,24 +301,35 @@ func (s *Store) UpsertWork(ctx context.Context, input WorkInput, now time.Time) func ptrTime(value time.Time) *time.Time { return &value } +func nullableRawPayload(value string) any { + if value == "" { + return nil + } + return value +} + func scanWork(scanner interface{ Scan(...any) error }) (Work, error) { var result Work var publishedAt, latestAt, nextAt sql.NullTime var likes, commentsCount, shares sql.NullInt64 + var rawPayload sql.NullString if err := scanner.Scan(&result.ID, &result.Platform, &result.WorkKey, &result.SourceType, &result.SourceID, &result.AuthorName, &result.Title, &result.Body, &publishedAt, &result.PublishedAtStatus, - &result.OriginalURL, &result.CoverURL, &likes, &commentsCount, &shares, &latestAt, &nextAt, + &result.OriginalURL, &result.CoverURL, &rawPayload, &likes, &commentsCount, &shares, &latestAt, &nextAt, &result.MetricStopReason, &result.CreatedAt, &result.UpdatedAt); err != nil { return Work{}, err } result.PublishedAt = nullableTime(publishedAt) + if rawPayload.Valid { + result.RawPayload = rawPayload.String + } result.Likes, result.CommentsCount, result.Shares = nullableInt64(likes), nullableInt64(commentsCount), nullableInt64(shares) result.LatestMetricsAt, result.NextMetricAt = nullableTime(latestAt), nullableTime(nextAt) return result, nil } const workSelect = `SELECT id, platform, work_key, source_type, source_id, author_name, title, body, - published_at, published_at_status, original_url, cover_url, likes, comments_count, shares, + published_at, published_at_status, original_url, cover_url, raw_payload, likes, comments_count, shares, latest_metrics_at, next_metric_at, metric_stop_reason, created_at, updated_at FROM creator_work` func (s *Store) loadWorkSources(ctx context.Context, work *Work) error { @@ -629,7 +644,7 @@ func (s *Store) SaveRewrite(ctx context.Context, workID, title, script string) ( func (s *Store) SaveComment(ctx context.Context, input CommentInput) (Comment, bool, error) { input.Platform, input.CommentKey, input.WorkID, input.AuthorUID, input.AuthorName, input.CommentType = strings.TrimSpace(input.Platform), strings.TrimSpace(input.CommentKey), strings.TrimSpace(input.WorkID), strings.TrimSpace(input.AuthorUID), strings.TrimSpace(input.AuthorName), strings.TrimSpace(input.CommentType) - if !ValidatePlatform(input.Platform) || input.CommentKey == "" || input.WorkID == "" || strings.TrimSpace(input.Content) == "" || (input.CommentType != "top_level" && input.CommentType != "unknown") || utf8.RuneCountInString(input.Content) > 10000 { + if !ValidatePlatform(input.Platform) || input.CommentKey == "" || input.WorkID == "" || strings.TrimSpace(input.Content) == "" || (input.CommentType != "top_level" && input.CommentType != "unknown") || utf8.RuneCountInString(input.Content) > 10000 || len(input.RawPayload) > maxRawPayloadBytes || input.RawPayload != "" && !json.Valid([]byte(input.RawPayload)) { return Comment{}, false, ErrInvalid } work, err := s.GetWork(ctx, input.WorkID) @@ -642,7 +657,7 @@ func (s *Store) SaveComment(ctx context.Context, input CommentInput) (Comment, b id := newID("comment") var returnedID string var inserted bool - err = s.db.QueryRowContext(ctx, `INSERT INTO creator_comment (id, platform, comment_key, work_id, author_uid, author_name, content, published_at, comment_type) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT (platform, comment_key) DO UPDATE SET work_id = EXCLUDED.work_id, author_uid = EXCLUDED.author_uid, author_name = EXCLUDED.author_name, content = EXCLUDED.content, published_at = EXCLUDED.published_at, comment_type = EXCLUDED.comment_type RETURNING id, (xmax = 0)`, id, input.Platform, input.CommentKey, input.WorkID, input.AuthorUID, input.AuthorName, input.Content, input.PublishedAt, input.CommentType).Scan(&returnedID, &inserted) + err = s.db.QueryRowContext(ctx, `INSERT INTO creator_comment (id, platform, comment_key, work_id, author_uid, author_name, content, raw_payload, published_at, comment_type) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT (platform, comment_key) DO UPDATE SET work_id = EXCLUDED.work_id, author_uid = EXCLUDED.author_uid, author_name = EXCLUDED.author_name, content = EXCLUDED.content, raw_payload = COALESCE(EXCLUDED.raw_payload, creator_comment.raw_payload), published_at = EXCLUDED.published_at, comment_type = EXCLUDED.comment_type RETURNING id, (xmax = 0)`, id, input.Platform, input.CommentKey, input.WorkID, input.AuthorUID, input.AuthorName, input.Content, nullableRawPayload(input.RawPayload), input.PublishedAt, input.CommentType).Scan(&returnedID, &inserted) if err != nil { return Comment{}, false, databaseError(err) } @@ -653,15 +668,19 @@ func (s *Store) SaveComment(ctx context.Context, input CommentInput) (Comment, b func scanComment(scanner interface{ Scan(...any) error }) (Comment, error) { var result Comment var publishedAt sql.NullTime - if err := scanner.Scan(&result.ID, &result.Platform, &result.CommentKey, &result.WorkID, &result.AuthorUID, &result.AuthorName, &result.Content, &publishedAt, &result.CollectedAt, &result.CommentType); err != nil { + var rawPayload sql.NullString + if err := scanner.Scan(&result.ID, &result.Platform, &result.CommentKey, &result.WorkID, &result.AuthorUID, &result.AuthorName, &result.Content, &rawPayload, &publishedAt, &result.CollectedAt, &result.CommentType); err != nil { return Comment{}, err } result.PublishedAt = nullableTime(publishedAt) + if rawPayload.Valid { + result.RawPayload = rawPayload.String + } result.CollectedAt = result.CollectedAt.UTC() return result, nil } -const commentSelect = `SELECT id, platform, comment_key, work_id, author_uid, author_name, content, published_at, collected_at, comment_type FROM creator_comment` +const commentSelect = `SELECT id, platform, comment_key, work_id, author_uid, author_name, content, raw_payload, published_at, collected_at, comment_type FROM creator_comment` func (s *Store) GetComment(ctx context.Context, id string) (Comment, error) { result, err := scanComment(s.db.QueryRowContext(ctx, commentSelect+` WHERE id = $1`, id)) diff --git a/internal/creator/migrations/031_xhs_raw_payloads.sql b/internal/creator/migrations/031_xhs_raw_payloads.sql new file mode 100644 index 0000000..40ead21 --- /dev/null +++ b/internal/creator/migrations/031_xhs_raw_payloads.sql @@ -0,0 +1,2 @@ +ALTER TABLE creator_work ADD COLUMN IF NOT EXISTS raw_payload text; +ALTER TABLE creator_comment ADD COLUMN IF NOT EXISTS raw_payload text; diff --git a/internal/creator/models.go b/internal/creator/models.go index 7421f5b..da232c0 100644 --- a/internal/creator/models.go +++ b/internal/creator/models.go @@ -156,6 +156,7 @@ type Work struct { MetricStopReason string `json:"metric_stop_reason,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + RawPayload string `json:"-"` } type WorkInput struct { @@ -173,6 +174,7 @@ type WorkInput struct { Likes *int64 `json:"likes"` CommentsCount *int64 `json:"comments_count"` Shares *int64 `json:"shares"` + RawPayload string `json:"-"` } type WorkFilter struct { @@ -232,6 +234,7 @@ type Comment struct { PublishedAt *time.Time `json:"published_at,omitempty"` CollectedAt time.Time `json:"collected_at"` CommentType string `json:"comment_type"` + RawPayload string `json:"-"` } type CommentInput struct { @@ -243,6 +246,7 @@ type CommentInput struct { Content string `json:"content"` PublishedAt *time.Time `json:"published_at"` CommentType string `json:"comment_type"` + RawPayload string `json:"-"` } type LeadRule struct { diff --git a/internal/creator/store.go b/internal/creator/store.go index 947a639..5938db5 100644 --- a/internal/creator/store.go +++ b/internal/creator/store.go @@ -58,6 +58,9 @@ var migration029 string //go:embed migrations/030_creator_event_gateway_time.sql var migration030 string +//go:embed migrations/031_xhs_raw_payloads.sql +var migration031 string + type SecretReference struct { ID string Provider string @@ -135,6 +138,7 @@ func (s *Store) migrate(ctx context.Context) error { {version: 28, sql: migration028}, {version: 29, sql: migration029}, {version: 30, sql: migration030}, + {version: 31, sql: migration031}, } for _, migration := range migrations { var applied bool diff --git a/internal/xiaohongshu/collector.go b/internal/xiaohongshu/collector.go index a2f47df..0dffa42 100644 --- a/internal/xiaohongshu/collector.go +++ b/internal/xiaohongshu/collector.go @@ -46,6 +46,27 @@ type Identity struct { Nickname string } +type LinkKind string + +const ( + LinkHomepage LinkKind = "homepage" + LinkNote LinkKind = "note" + LinkShare LinkKind = "share" +) + +type LinkContext struct { + Kind LinkKind + AccountKey string + WorkKey string + Token string + Source string + CanonicalURL string +} + +type ShareResolver interface { + Resolve(context.Context, string) (string, error) +} + type Collector struct { Browser Browser AccountKey string @@ -84,6 +105,98 @@ func (c *Collector) Identity(ctx context.Context, expectedKey string) (Identity, return identity, nil } +func ParseSourceURL(raw string) (LinkContext, error) { + raw = strings.TrimSpace(raw) + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme != "https" || parsed.User != nil || parsed.Fragment != "" { + return LinkContext{}, fmt.Errorf("%w: invalid xiaohongshu source URL", creator.ErrInvalid) + } + if parsed.Port() != "" { + return LinkContext{}, fmt.Errorf("%w: invalid xiaohongshu source URL", creator.ErrInvalid) + } + query, err := parseAccessQuery(parsed) + if err != nil { + return LinkContext{}, err + } + parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") + context := LinkContext{Token: query.Token, Source: query.Source, CanonicalURL: parsed.String()} + switch parsed.Hostname() { + case "www.xiaohongshu.com": + switch { + case len(parts) == 2 && parts[0] == "explore" && keyPattern.MatchString(parts[1]): + context.Kind, context.WorkKey = LinkNote, parts[1] + case len(parts) == 3 && parts[0] == "user" && parts[1] == "profile" && keyPattern.MatchString(parts[2]): + context.Kind, context.AccountKey = LinkHomepage, parts[2] + default: + return LinkContext{}, fmt.Errorf("%w: unsupported xiaohongshu source path", creator.ErrInvalid) + } + case "xhslink.com", "www.xhslink.com": + if len(parts) < 1 || len(parts) > 4 || parsed.RawQuery != "" { + return LinkContext{}, fmt.Errorf("%w: invalid xiaohongshu share URL", creator.ErrInvalid) + } + context.Kind = LinkShare + default: + return LinkContext{}, fmt.Errorf("%w: unsupported xiaohongshu source host", creator.ErrInvalid) + } + return context, nil +} + +type accessQuery struct { + Token string + Source string +} + +func parseAccessQuery(parsed *url.URL) (accessQuery, error) { + values, err := url.ParseQuery(parsed.RawQuery) + if err != nil { + return accessQuery{}, fmt.Errorf("%w: invalid xiaohongshu access query", creator.ErrInvalid) + } + for key, items := range values { + if key != "xsec_token" && key != "xsec_source" || len(items) != 1 || strings.ContainsAny(items[0], "\r\n") || len(items[0]) > 2048 { + return accessQuery{}, fmt.Errorf("%w: unsupported xiaohongshu access query", creator.ErrInvalid) + } + } + result := accessQuery{Token: values.Get("xsec_token"), Source: values.Get("xsec_source")} + if result.Source != "" && !keyPattern.MatchString(result.Source) { + return accessQuery{}, fmt.Errorf("%w: invalid xiaohongshu access source", creator.ErrInvalid) + } + return result, nil +} + +func ValidateSourceURL(raw, expectedAccountKey string) error { + link, err := ParseSourceURL(raw) + if err != nil { + return err + } + if link.Kind != LinkHomepage || link.AccountKey != expectedAccountKey { + return fmt.Errorf("%w: xiaohongshu homepage does not match the account", creator.ErrConflict) + } + return nil +} + +func (c *Collector) resolveSourceURL(ctx context.Context, raw string) (LinkContext, error) { + link, err := ParseSourceURL(raw) + if err != nil { + return LinkContext{}, err + } + if link.Kind != LinkShare { + return link, nil + } + resolver, ok := c.Browser.(ShareResolver) + if !ok { + return LinkContext{}, fmt.Errorf("%w: xiaohongshu share URL resolver is unavailable", creator.ErrUnavailable) + } + canonical, err := resolver.Resolve(ctx, link.CanonicalURL) + if err != nil { + return LinkContext{}, err + } + resolved, err := ParseSourceURL(canonical) + if err != nil || resolved.Kind != LinkNote { + return LinkContext{}, fmt.Errorf("%w: xiaohongshu share URL did not resolve to a note", creator.ErrInvalid) + } + return resolved, nil +} + func (c *Collector) ListWorks(ctx context.Context, accountKey, cursor string) (creator.WorkPage, error) { if c == nil { return creator.WorkPage{}, fmt.Errorf("%w: xiaohongshu collector is nil", creator.ErrUnavailable) @@ -94,14 +207,21 @@ func (c *Collector) ListWorks(ctx context.Context, accountKey, cursor string) (c if c.Browser == nil || !keyPattern.MatchString(accountKey) || !validCursor(cursor) { return creator.WorkPage{}, fmt.Errorf("%w: invalid xiaohongshu work collection request", creator.ErrInvalid) } - access := c.defaultContext() + access, err := c.defaultContext() + if err != nil { + return creator.WorkPage{}, err + } query := url.Values{ "user_id": {accountKey}, "cursor": {cursor}, "num": {"30"}, "image_formats": {"jpg,webp,avif"}, - "xsec_source": {access.Source}, - "xsec_token": {access.Token}, + } + if access.Source != "" { + query.Set("xsec_source", access.Source) + } + if access.Token != "" { + query.Set("xsec_token", access.Token) } response, err := c.Browser.Get(ctx, APIOrigin+UserPostedPath+"?"+query.Encode()) if err != nil { @@ -121,14 +241,21 @@ func (c *Collector) ListTopLevelComments(ctx context.Context, workKey, cursor st if c == nil || c.Browser == nil || !keyPattern.MatchString(workKey) || !validCursor(cursor) { return creator.CommentPage{}, fmt.Errorf("%w: invalid xiaohongshu comment collection request", creator.ErrInvalid) } - access := c.contextFor(workKey) + access, err := c.contextFor(workKey) + if err != nil { + return creator.CommentPage{}, err + } query := url.Values{ "note_id": {workKey}, "cursor": {cursor}, "top_comment_id": {""}, "image_formats": {"jpg,webp,avif"}, - "xsec_source": {access.Source}, - "xsec_token": {access.Token}, + } + if access.Source != "" { + query.Set("xsec_source", access.Source) + } + if access.Token != "" { + query.Set("xsec_token", access.Token) } response, err := c.Browser.Get(ctx, APIOrigin+CommentsPath+"?"+query.Encode()) if err != nil { @@ -178,7 +305,11 @@ func (c *Collector) SearchNotes(ctx context.Context, queryText string, page int) if err := responseError(response, "search"); err != nil { return creator.WorkPage{}, err } - items, nextCursor, hasMore, ok := parseWorksPage(response.Body, c, c.defaultContext()) + fallback, err := c.defaultContext() + if err != nil { + return creator.WorkPage{}, err + } + items, nextCursor, hasMore, ok := parseWorksPage(response.Body, c, fallback) if !ok { return creator.WorkPage{}, fmt.Errorf("%w: invalid xiaohongshu search response", creator.ErrInvalid) } @@ -193,51 +324,47 @@ func randomID() (string, error) { return hex.EncodeToString(value), nil } -func (c *Collector) defaultContext() accessContext { - access := accessContext{Source: "pc_user"} - if c != nil && strings.TrimSpace(c.HomepageURL) != "" { - parsed, err := url.Parse(c.HomepageURL) - if err == nil { - if token := strings.TrimSpace(parsed.Query().Get("xsec_token")); token != "" { - access.Token = token - } - if source := strings.TrimSpace(parsed.Query().Get("xsec_source")); source != "" { - access.Source = source - } - } +func (c *Collector) defaultContext() (accessContext, error) { + if c == nil || strings.TrimSpace(c.HomepageURL) == "" { + return accessContext{}, nil } - return access + link, err := ParseSourceURL(c.HomepageURL) + if err != nil { + return accessContext{}, err + } + if link.Kind != LinkHomepage { + return accessContext{}, fmt.Errorf("%w: xiaohongshu homepage URL is required", creator.ErrInvalid) + } + return accessContext{Token: link.Token, Source: link.Source}, nil } func (c *Collector) SetWorkContext(workKey, originalURL string) error { if c == nil || !keyPattern.MatchString(workKey) { return fmt.Errorf("%w: invalid xiaohongshu work context", creator.ErrInvalid) } - access := c.defaultContext() + access, err := c.defaultContext() + if err != nil { + return err + } if strings.TrimSpace(originalURL) != "" { - parsed, err := url.Parse(originalURL) - if err != nil || parsed.Scheme != "https" || parsed.Host != "www.xiaohongshu.com" || !strings.Contains(parsed.Path, workKey) { + link, parseErr := ParseSourceURL(originalURL) + if parseErr != nil || link.Kind != LinkNote || link.WorkKey != workKey { return fmt.Errorf("%w: invalid xiaohongshu work URL", creator.ErrInvalid) } - if token := strings.TrimSpace(parsed.Query().Get("xsec_token")); token != "" { - access.Token = token - } - if source := strings.TrimSpace(parsed.Query().Get("xsec_source")); source != "" { - access.Source = source - } + access.Token, access.Source = link.Token, link.Source } - if len(access.Token) > 2048 || !keyPattern.MatchString(access.Source) { + if len(access.Token) > 2048 || access.Source != "" && !keyPattern.MatchString(access.Source) { return fmt.Errorf("%w: invalid xiaohongshu work context", creator.ErrInvalid) } c.contexts.Store(workKey, access) return nil } -func (c *Collector) contextFor(workKey string) accessContext { +func (c *Collector) contextFor(workKey string) (accessContext, error) { if c != nil { if value, ok := c.contexts.Load(workKey); ok { if access, ok := value.(accessContext); ok { - return access + return access, nil } } } @@ -339,6 +466,7 @@ func parseWork(raw json.RawMessage, fallback accessContext) (creator.WorkInput, if !ok { return creator.WorkInput{}, accessContext{}, false } + outer := object if nested := firstObject(object, "note_card", "noteCard"); nested != nil { object = nested } @@ -376,17 +504,32 @@ func parseWork(raw json.RawMessage, fallback accessContext) (creator.WorkInput, return creator.WorkInput{}, accessContext{}, false } access := fallback + if token := firstString(outer, "xsec_token"); token != "" { + access.Token = token + } + if source := firstString(outer, "xsec_source"); source != "" { + access.Source = source + } if token := firstString(object, "xsec_token"); token != "" { access.Token = token } if source := firstString(object, "xsec_source"); source != "" { access.Source = source } - if utf8.RuneCountInString(access.Token) > 2048 || !keyPattern.MatchString(access.Source) { + if utf8.RuneCountInString(access.Token) > 2048 || access.Source != "" && !keyPattern.MatchString(access.Source) { return creator.WorkInput{}, accessContext{}, false } originalURL := firstString(object, "original_url", "note_url", "url") - if !validOriginalURL(originalURL, id) { + if originalURL == "" { + originalURL = firstString(outer, "original_url", "note_url", "url") + } + if originalURL != "" { + link, err := ParseSourceURL(originalURL) + if err != nil || link.Kind != LinkNote || link.WorkKey != id { + return creator.WorkInput{}, accessContext{}, false + } + originalURL = link.CanonicalURL + } else { originalURL = noteURL(id, access) } cover := coverURL(object) @@ -396,6 +539,7 @@ func parseWork(raw json.RawMessage, fallback accessContext) (creator.WorkInput, } return creator.WorkInput{ Platform: creator.PlatformXiaohongshu, + RawPayload: string(raw), WorkKey: id, AuthorName: authorName, Title: title, @@ -457,6 +601,7 @@ func parseCommentsPage(body []byte, workKey string) ([]creator.CommentInput, str } items = append(items, creator.CommentInput{ Platform: creator.PlatformXiaohongshu, + RawPayload: string(raw), CommentKey: id, WorkID: workKey, AuthorUID: firstString(user, "user_id", "uid", "id"), @@ -478,7 +623,7 @@ func responseError(response Response, resource string) error { return fmt.Errorf("%w: xiaohongshu %s challenge %s", creator.ErrUnavailable, resource, response.Challenge) } if response.Status >= 200 && response.Status < 300 { - return nil + return platformFailure(response.Body, resource) } switch response.Status { case 401, 403, 406, 461: @@ -490,6 +635,29 @@ func responseError(response Response, resource string) error { } } +func platformFailure(body []byte, resource string) error { + var envelope struct { + Success *bool `json:"success"` + Code string `json:"code"` + Msg string `json:"msg"` + Message string `json:"message"` + } + if json.Unmarshal(body, &envelope) != nil || envelope.Success == nil || *envelope.Success { + return nil + } + reason := strings.TrimSpace(envelope.Msg) + if reason == "" { + reason = strings.TrimSpace(envelope.Message) + } + if len(reason) > 512 { + reason = reason[:512] + } + if strings.Contains(strings.ToLower(reason), "rate") || strings.Contains(reason, "频繁") || strings.Contains(reason, "验证") { + return fmt.Errorf("%w: xiaohongshu %s rejected request code=%s message=%s", creator.ErrUnavailable, resource, envelope.Code, reason) + } + return fmt.Errorf("%w: xiaohongshu %s rejected request code=%s message=%s", creator.ErrConflict, resource, envelope.Code, reason) +} + func validCursor(cursor string) bool { return cursor == "" || len(cursor) <= 512 && !strings.ContainsAny(cursor, "\r\n") } @@ -582,7 +750,7 @@ func optionalTimestamp(object map[string]json.RawMessage, names ...string) (*tim } parsed, err := strconv.ParseInt(number.String(), 10, 64) if err != nil || parsed <= 0 { - return nil, true + return nil, false } if parsed > 1_000_000_000_000 { parsed /= 1000 @@ -633,14 +801,6 @@ func coverURL(object map[string]json.RawMessage) string { return firstString(object, "cover_url", "cover") } -func validOriginalURL(raw, id string) bool { - if raw == "" { - return false - } - parsed, err := url.Parse(raw) - return err == nil && parsed.Scheme == "https" && (parsed.Host == "www.xiaohongshu.com" || parsed.Host == "xhslink.com") && strings.Contains(parsed.Path, id) -} - func noteURL(id string, access accessContext) string { query := url.Values{} if access.Token != "" { diff --git a/internal/xiaohongshu/collector_test.go b/internal/xiaohongshu/collector_test.go index 9d02f8e..d2b42b4 100644 --- a/internal/xiaohongshu/collector_test.go +++ b/internal/xiaohongshu/collector_test.go @@ -10,12 +10,14 @@ import ( ) type fakeBrowser struct { - getURL string - postURL string - getBody []byte - postBody []byte - getResp Response - postResp Response + getURL string + postURL string + getBody []byte + postBody []byte + getResp Response + postResp Response + resolvedURL string + resolveInput string } func (f *fakeBrowser) Get(_ context.Context, target string) (Response, error) { @@ -26,6 +28,10 @@ func (f *fakeBrowser) Post(_ context.Context, target string, body []byte) (Respo f.postURL, f.postBody = target, body return f.postResp, nil } +func (f *fakeBrowser) Resolve(_ context.Context, target string) (string, error) { + f.resolveInput = target + return f.resolvedURL, nil +} func TestIdentityRequiresMatchingUser(t *testing.T) { browser := &fakeBrowser{getResp: Response{Status: 200, Body: []byte(`{"success":true,"data":{"user_id":"u-1","nickname":"作者"}}`)}} @@ -43,7 +49,7 @@ func TestListWorksCapturesContextForComments(t *testing.T) { browser := &fakeBrowser{getResp: Response{Status: 200, Body: []byte(`{"success":true,"data":{"cursor":"next","has_more":true,"notes":[{"note_id":"n-1","title":"标题","desc":"正文","time":1710000000,"user":{"user_id":"u-1","nickname":"作者"},"interact_info":{"liked_count":"2","comment_count":3,"shared_count":4},"xsec_token":"token"}]}}`)}} collector := &Collector{Browser: browser, AccountKey: "u-1", SourceType: creator.SourceOwned, SourceID: "source-1"} page, err := collector.ListWorks(context.Background(), "ignored", "") - if err != nil || len(page.Items) != 1 || page.NextCursor != "next" || !page.HasMore { + if err != nil || len(page.Items) != 1 || page.NextCursor != "next" || !page.HasMore || page.Items[0].RawPayload == "" { t.Fatalf("page = %#v, err = %v", page, err) } parsed, err := url.Parse(browser.getURL) @@ -52,7 +58,7 @@ func TestListWorksCapturesContextForComments(t *testing.T) { } browser.getResp = Response{Status: 200, Body: []byte(`{"success":true,"data":{"cursor":"","has_more":false,"comments":[{"id":"c-1","content":"评论","create_time":1710000000,"user_info":{"user_id":"u-2","nickname":"读者"}}]}}`)} comments, err := collector.ListTopLevelComments(context.Background(), "n-1", "") - if err != nil || len(comments.Items) != 1 || comments.Items[0].CommentType != "top_level" { + if err != nil || len(comments.Items) != 1 || comments.Items[0].CommentType != "top_level" || comments.Items[0].RawPayload == "" { t.Fatalf("comments = %#v, err = %v", comments, err) } commentURL, err := url.Parse(browser.getURL) @@ -74,6 +80,44 @@ func TestSearchUsesBoundedPostAndRejectsMalformedPage(t *testing.T) { } } +func TestHomepageContextIsUsedWithoutInventingDefaults(t *testing.T) { + browser := &fakeBrowser{getResp: Response{Status: 200, Body: []byte(`{"success":true,"data":{"cursor":"","has_more":false,"notes":[]}}`)}} + collector := &Collector{Browser: browser, AccountKey: "u-1", HomepageURL: "https://www.xiaohongshu.com/user/profile/u-1?xsec_token=home-token&xsec_source=pc_search"} + if _, err := collector.ListWorks(context.Background(), "ignored", ""); err != nil { + t.Fatalf("list works: %v", err) + } + requestURL, err := url.Parse(browser.getURL) + if err != nil || requestURL.Query().Get("xsec_token") != "home-token" || requestURL.Query().Get("xsec_source") != "pc_search" { + t.Fatalf("homepage context was not forwarded: %s", browser.getURL) + } +} + +func TestSourceURLParsingAndShareResolution(t *testing.T) { + homepage, err := ParseSourceURL("https://www.xiaohongshu.com/user/profile/u-1?xsec_source=pc_search") + if err != nil || homepage.Kind != LinkHomepage || homepage.AccountKey != "u-1" { + t.Fatalf("homepage = %#v, err = %v", homepage, err) + } + share, err := ParseSourceURL("https://xhslink.com/a/abc") + if err != nil || share.Kind != LinkShare { + t.Fatalf("share = %#v, err = %v", share, err) + } + browser := &fakeBrowser{resolvedURL: "https://www.xiaohongshu.com/explore/n-1?xsec_token=tok-1&xsec_source=pc_search", postResp: Response{Status: 200, Body: []byte(`{"success":true,"data":{"items":[{"note_id":"n-1","title":"详情","time":1710000000,"interact_info":{}}]}}`)}} + collector := &Collector{Browser: browser} + item, err := collector.GetNoteDetail(context.Background(), "https://xhslink.com/a/abc") + if err != nil || item.WorkKey != "n-1" || browser.resolveInput != "https://xhslink.com/a/abc" { + t.Fatalf("resolved detail = %#v, err = %v, input = %s", item, err, browser.resolveInput) + } +} + +func TestResponseErrorClassifiesRejectedPlatformPayload(t *testing.T) { + if err := responseError(Response{Status: 200, Body: []byte(`{"success":false,"code":"LOGIN_REQUIRED","msg":"请重新登录"}`)}, "works"); !errors.Is(err, creator.ErrConflict) { + t.Fatalf("expected conflict, got %v", err) + } + if err := responseError(Response{Status: 200, Body: []byte(`{"success":false,"code":"RATE_LIMIT","msg":"请求过于频繁"}`)}, "works"); !errors.Is(err, creator.ErrUnavailable) { + t.Fatalf("expected unavailable, got %v", err) + } +} + func TestSetWorkContextRejectsWrongHost(t *testing.T) { collector := &Collector{} if err := collector.SetWorkContext("n-1", "https://evil.example/explore/n-1"); !errors.Is(err, creator.ErrInvalid) { diff --git a/internal/xiaohongshu/detail.go b/internal/xiaohongshu/detail.go index b037922..65412b1 100644 --- a/internal/xiaohongshu/detail.go +++ b/internal/xiaohongshu/detail.go @@ -4,8 +4,6 @@ import ( "context" "encoding/json" "fmt" - "net/url" - "strings" "git.ipao.vip/rogee/creator-hub/internal/creator" ) @@ -16,23 +14,16 @@ func (c *Collector) GetNoteDetail(ctx context.Context, originalURL string) (crea if c == nil || c.Browser == nil { return creator.WorkInput{}, fmt.Errorf("%w: invalid xiaohongshu detail request", creator.ErrInvalid) } - parsed, err := url.Parse(originalURL) - if err != nil || parsed.Scheme != "https" || parsed.Host != "www.xiaohongshu.com" { - return creator.WorkInput{}, fmt.Errorf("%w: invalid xiaohongshu note URL", creator.ErrInvalid) + link, err := c.resolveSourceURL(ctx, originalURL) + if err != nil { + return creator.WorkInput{}, err } - parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") - if len(parts) != 2 || parts[0] != "explore" || !keyPattern.MatchString(parts[1]) { - return creator.WorkInput{}, fmt.Errorf("%w: invalid xiaohongshu note URL", creator.ErrInvalid) - } - access := accessContext{Token: parsed.Query().Get("xsec_token"), Source: parsed.Query().Get("xsec_source")} - if access.Source == "" { - access.Source = "pc_search" - } - if len(access.Token) > 2048 || !keyPattern.MatchString(access.Source) { - return creator.WorkInput{}, fmt.Errorf("%w: invalid xiaohongshu note URL context", creator.ErrInvalid) + if link.Kind != LinkNote { + return creator.WorkInput{}, fmt.Errorf("%w: xiaohongshu note URL is required", creator.ErrInvalid) } + access := accessContext{Token: link.Token, Source: link.Source} body, err := json.Marshal(map[string]any{ - "source_note_id": parts[1], + "source_note_id": link.WorkKey, "image_formats": []string{"jpg", "webp", "avif"}, "extra": map[string]string{"need_body_topic": "1"}, "xsec_source": access.Source, @@ -52,10 +43,10 @@ func (c *Collector) GetNoteDetail(ctx context.Context, originalURL string) (crea if !ok { return creator.WorkInput{}, fmt.Errorf("%w: invalid xiaohongshu detail response", creator.ErrInvalid) } - if item.WorkKey != parts[1] { + if item.WorkKey != link.WorkKey { return creator.WorkInput{}, fmt.Errorf("%w: xiaohongshu detail returned another note", creator.ErrConflict) } - item.OriginalURL = originalURL + item.OriginalURL = link.CanonicalURL item.SourceType = c.SourceType item.SourceID = c.SourceID if item.SourceType == "" { diff --git a/web/src/CreatorCompetitorsPage.jsx b/web/src/CreatorCompetitorsPage.jsx index 1e24e27..5662a81 100644 --- a/web/src/CreatorCompetitorsPage.jsx +++ b/web/src/CreatorCompetitorsPage.jsx @@ -110,13 +110,19 @@ export function CreatorCompetitorsPage() { const parseHomepage = () => { try { const parsed = new URL(form.homepage_url); - const allowed = - form.platform === "douyin" - ? parsed.hostname === "www.douyin.com" - : parsed.hostname === "www.xiaohongshu.com" || - parsed.hostname === "xiaohongshu.com"; const parts = parsed.pathname.split("/").filter(Boolean); - const candidate = parts.at(-1) || ""; + const isXiaohongshu = form.platform === "xiaohongshu"; + const allowed = isXiaohongshu + ? parsed.protocol === "https:" && + parsed.hostname === "www.xiaohongshu.com" + : parsed.hostname === "www.douyin.com"; + let candidate = parts.at(-1) || ""; + if ( + isXiaohongshu && + !(parts.length === 3 && parts[0] === "user" && parts[1] === "profile") + ) { + candidate = ""; + } if ( !allowed || !candidate || diff --git a/web/src/CreatorPages.test.jsx b/web/src/CreatorPages.test.jsx index 67ecb42..7d8ac59 100644 --- a/web/src/CreatorPages.test.jsx +++ b/web/src/CreatorPages.test.jsx @@ -203,6 +203,36 @@ describe("creator pages", () => { ); }); + it("parses and confirms a Xiaohongshu profile URL", async () => { + const dataProvider = provider(); + renderPage(, dataProvider); + fireEvent.click(screen.getAllByRole("combobox")[0]); + fireEvent.click(screen.getByRole("option", { name: "小红书" })); + fireEvent.change(screen.getByLabelText("主页 URL", { exact: false }), { + target: { + value: "https://www.xiaohongshu.com/user/profile/xhs-b", + }, + }); + fireEvent.click(screen.getByRole("button", { name: "解析链接预览" })); + await waitFor(() => + expect( + screen.getAllByText("xhs-b", { exact: false }).length, + ).toBeGreaterThan(0), + ); + fireEvent.click(screen.getByRole("button", { name: "确认预览内容" })); + fireEvent.click(screen.getByRole("button", { name: "加入监测" })); + await waitFor(() => expect(dataProvider.create).toHaveBeenCalled()); + expect(dataProvider.create).toHaveBeenCalledWith( + expect.objectContaining({ + resource: "creator-competitors", + variables: expect.objectContaining({ + platform: "xiaohongshu", + platform_account_key: "xhs-b", + }), + }), + ); + }); + it("keeps account password out of the returned profile and exposes big-account action", async () => { const dataProvider = provider(); renderPage(, dataProvider);