diff --git a/apps/api/package.json b/apps/api/package.json
index c5584c6..3d52b69 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -14,6 +14,7 @@
"@cross-ai/domain": "file:../../packages/domain",
"@cross-ai/report-schema": "file:../../packages/report-schema",
"@fastify/cors": "^11.2.0",
- "fastify": "^5.8.4"
+ "fastify": "^5.8.4",
+ "playwright-core": "^1.59.1"
}
}
diff --git a/apps/api/src/ops/qr-login.test.ts b/apps/api/src/ops/qr-login.test.ts
new file mode 100644
index 0000000..5724058
--- /dev/null
+++ b/apps/api/src/ops/qr-login.test.ts
@@ -0,0 +1,214 @@
+import type { BrowserContext, Page, Request } from "playwright-core";
+import { describe, expect, it, vi } from "vitest";
+
+import type {
+ JdLiveSessionInput,
+ JdSessionManager,
+ JdSessionManagerConfigInput,
+ JdSessionManagerRunResult,
+ JdSessionManagerState
+} from "../platforms/jd/types";
+import { JdOpsQrLoginService, takeLocatorScreenshotDataUrl } from "./qr-login";
+
+const DEFAULT_TEST_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)";
+
+type FakeResources = {
+ context: Pick;
+ page: Page;
+};
+
+function createSessionManagerStub() {
+ const importManualSession = vi.fn<
+ (input: JdLiveSessionInput, source?: string) => Promise
+ >(async () => ({ status: "healthy" } as JdSessionManagerState));
+ const runHealthCheck = vi.fn<(trigger?: string) => Promise>(
+ async () =>
+ ({
+ recovered: false,
+ state: {
+ status: "healthy"
+ }
+ }) as JdSessionManagerRunResult
+ );
+
+ const stub: JdSessionManager = {
+ getState(): JdSessionManagerState {
+ return { status: "idle" } as JdSessionManagerState;
+ },
+ configure(_input: JdSessionManagerConfigInput): JdSessionManagerState {
+ return { status: "idle" } as JdSessionManagerState;
+ },
+ clearConfig(): JdSessionManagerState {
+ return { status: "idle" } as JdSessionManagerState;
+ },
+ importManualSession,
+ clearManagedSession(): JdSessionManagerState {
+ return { status: "idle" } as JdSessionManagerState;
+ },
+ runHealthCheck,
+ async runAutoRecovery() {
+ return {
+ recovered: false,
+ state: { status: "idle" } as JdSessionManagerState
+ };
+ },
+ async handleLiveFailure() {
+ return false;
+ },
+ shutdown() {
+ return;
+ }
+ };
+
+ return {
+ stub,
+ importManualSession,
+ runHealthCheck
+ };
+}
+
+class TestableJdOpsQrLoginService extends JdOpsQrLoginService {
+ setTestResources(resources: FakeResources) {
+ this.setResources(resources as unknown as {
+ browser: never;
+ context: BrowserContext;
+ page: Page;
+ });
+ }
+
+ setCapturedSession(session: Record) {
+ Object.assign(
+ (this as unknown as { latestCapturedSession: Record }).latestCapturedSession,
+ session
+ );
+ }
+}
+
+describe("JdOpsQrLoginService", () => {
+ it("skips a detached QR locator and captures the next visible selector", async () => {
+ const staleLocator = {
+ first: vi.fn(function (this: typeof staleLocator) {
+ return this;
+ }),
+ count: vi.fn(async () => 1),
+ isVisible: vi.fn(async () => true),
+ screenshot: vi.fn(async () => {
+ throw new Error("Element is not attached to the DOM");
+ })
+ };
+ const liveScreenshot = Uint8Array.from([1, 2, 3]);
+ const liveLocator = {
+ first: vi.fn(function (this: typeof liveLocator) {
+ return this;
+ }),
+ count: vi.fn(async () => 1),
+ isVisible: vi.fn(async () => true),
+ screenshot: vi.fn(async () => liveScreenshot)
+ };
+ const page = {
+ locator: vi.fn((selector: string) => {
+ return selector === ".stale-qr" ? staleLocator : liveLocator;
+ })
+ } as unknown as Page;
+
+ await expect(takeLocatorScreenshotDataUrl(page, [".stale-qr", ".live-qr"])).resolves.toBe(
+ "data:image/png;base64,AQID"
+ );
+ expect(staleLocator.screenshot).toHaveBeenCalledTimes(1);
+ expect(liveLocator.screenshot).toHaveBeenCalledTimes(1);
+ });
+
+ it("waits for a fresh search request during manual recovery and reimports captured headers", async () => {
+ const { stub, importManualSession, runHealthCheck } = createSessionManagerStub();
+ const service = new TestableJdOpsQrLoginService(stub);
+ let currentUrl =
+ "https://search.jd.com/Search?keyword=%E5%A4%A7%E7%96%86pocket3&enc=utf-8&suggest=old";
+ let resolveRequest: ((request: Request) => void) | undefined;
+
+ const request = {
+ url: () =>
+ "https://api.m.jd.com/api?functionId=pc_search_searchWare&body=%7B%22keyword%22:%22%E5%A4%A7%E7%96%86pocket3%22%7D&keyword=%E5%A4%A7%E7%96%86pocket3&enc=utf-8&suggest=2.def.0.real&wq=%E5%A4%A7%E7%96%86pocket3&pvid=real-pvid&spmTag=real-spm",
+ allHeaders: async () => ({
+ ":authority": "api.m.jd.com",
+ referer:
+ "https://search.jd.com/Search?keyword=%E5%A4%A7%E7%96%86pocket3&enc=utf-8&suggest=2.def.0.real&wq=%E5%A4%A7%E7%96%86pocket3&pvid=real-pvid&spmTag=real-spm",
+ "accept-language": "zh-CN,zh;q=0.9",
+ "sec-fetch-site": "same-site"
+ })
+ } as unknown as Request;
+
+ const page = {
+ url: () => currentUrl,
+ isClosed: () => false,
+ waitForRequest: vi.fn(async () => {
+ return await new Promise((resolve) => {
+ resolveRequest = resolve;
+ });
+ }),
+ reload: vi.fn(async () => {
+ currentUrl =
+ "https://search.jd.com/Search?keyword=%E5%A4%A7%E7%96%86pocket3&enc=utf-8&suggest=2.def.0.real&wq=%E5%A4%A7%E7%96%86pocket3&pvid=real-pvid&spmTag=real-spm";
+ resolveRequest?.(request);
+ }),
+ goto: vi.fn(async () => undefined),
+ waitForLoadState: vi.fn(async () => undefined),
+ waitForTimeout: vi.fn(async () => undefined),
+ evaluate: vi.fn(async () => DEFAULT_TEST_USER_AGENT),
+ close: vi.fn(async () => undefined)
+ } as unknown as Page;
+
+ const context = {
+ cookies: vi.fn(async () => [
+ {
+ name: "thor",
+ value: "masked",
+ domain: ".jd.com"
+ }
+ ]),
+ pages: vi.fn(() => [page]),
+ close: vi.fn(async () => undefined),
+ storageState: vi.fn(async () => ({
+ cookies: [],
+ origins: []
+ }))
+ } as unknown as Pick;
+
+ service.setTestResources({
+ context,
+ page
+ });
+ service.setCapturedSession({
+ targetSkuId: "100068388533",
+ targetSearchQuery: "大疆pocket3",
+ targetProductUrl: "https://item.jd.com/100068388533.html",
+ targetSearchUrl:
+ "https://search.jd.com/Search?keyword=%E5%A4%A7%E7%96%86pocket3&enc=utf-8&suggest=old",
+ detailTemplateUrl:
+ "https://api.m.jd.com/?functionId=pc_detailpage_wareBusiness&body=%7B%22skuId%22:%22100068388533%22%7D",
+ reviewsTemplateUrl:
+ "https://api.m.jd.com/?functionId=getLegoWareDetailComment&body=%7B%22sku%22:100068388533,%22page%22:1,%22commentNum%22:5%7D",
+ searchCapturedAt: 1
+ });
+
+ const state = await service.resumeManualRecovery();
+
+ expect(page.reload).toHaveBeenCalledTimes(1);
+ expect(importManualSession).toHaveBeenCalledWith(
+ expect.objectContaining({
+ cookieHeader: "thor=masked",
+ userAgent: DEFAULT_TEST_USER_AGENT,
+ searchApiTemplateUrl: request.url(),
+ searchReferer:
+ "https://search.jd.com/Search?keyword=%E5%A4%A7%E7%96%86pocket3&enc=utf-8&suggest=2.def.0.real&wq=%E5%A4%A7%E7%96%86pocket3&pvid=real-pvid&spmTag=real-spm",
+ searchRequestHeaders: {
+ "accept-language": "zh-CN,zh;q=0.9",
+ "sec-fetch-site": "same-site"
+ }
+ }),
+ "ops-qr-login-manual-recovery"
+ );
+ expect(runHealthCheck).toHaveBeenCalledWith("ops-qr-login-manual-recovery");
+ expect(state.status).toBe("completed");
+ expect(state.sessionImported).toBe(true);
+ });
+});
diff --git a/apps/api/src/ops/qr-login.ts b/apps/api/src/ops/qr-login.ts
new file mode 100644
index 0000000..cdbceb4
--- /dev/null
+++ b/apps/api/src/ops/qr-login.ts
@@ -0,0 +1,1243 @@
+import { Buffer } from "node:buffer";
+import { existsSync } from "node:fs";
+
+import type { Browser, BrowserContext, Page } from "playwright-core";
+import { chromium } from "playwright-core";
+
+import {
+ parseJdDetailApiResponse,
+ parseJdReviewsApiResponse,
+ parseJdSearchApiResponse,
+ parseJdSearchHtml
+} from "../platforms/jd/parsers";
+import type {
+ JdBrowserPreviewProvider,
+ JdDetailPreviewResult,
+ JdLiveSessionInput,
+ JdReviewsPaginationSummary,
+ JdReviewsPreviewOptions,
+ JdReviewsPreviewResult,
+ JdSearchMode,
+ JdSearchPreviewResult,
+ JdSessionManager
+} from "../platforms/jd/types";
+import type {
+ TmallLiveSessionInput,
+ TmallSessionManager
+} from "../platforms/tmall/types";
+
+const DEFAULT_JD_CAPTURE_SKU = "100068388533";
+const DEFAULT_TMALL_CAPTURE_ITEM_ID = "934454505228";
+const DEFAULT_WAIT_TIMEOUT_MS = 3 * 60 * 1000;
+const SCREENSHOT_POLL_INTERVAL_MS = 1500;
+const DEFAULT_OPS_BROWSER_USER_AGENT =
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
+ "(KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36";
+const JD_LOGIN_URL_PATTERN = /passport\.jd\.com/i;
+const JD_SEARCH_URL_PATTERN = /search\.jd\.com\/Search/i;
+const JD_PRODUCT_URL_PATTERN = /item\.jd\.com\/\d+\.html/i;
+const TMALL_LOGIN_URL_PATTERN = /login\.taobao\.com|havanaone\/login/i;
+
+export type OpsQrLoginStatus =
+ | "idle"
+ | "launching"
+ | "waiting_for_scan"
+ | "capturing_session"
+ | "completed"
+ | "failed"
+ | "cancelled";
+
+export interface OpsQrLoginState {
+ platform: "jd" | "tmall";
+ status: OpsQrLoginStatus;
+ note: string;
+ startedAt?: string | undefined;
+ updatedAt?: string | undefined;
+ completedAt?: string | undefined;
+ currentUrl?: string | undefined;
+ qrImageDataUrl?: string | undefined;
+ errorMessage?: string | undefined;
+ targetId?: string | undefined;
+ sessionImported: boolean;
+}
+
+export interface OpsQrLoginController {
+ getState(): OpsQrLoginState;
+ start(): Promise;
+ resumeManualRecovery(): Promise;
+ cancel(reason?: string): Promise;
+ shutdown(): Promise;
+}
+
+type BrowserResources = {
+ browser: Browser;
+ context: BrowserContext;
+ page: Page;
+};
+
+type BrowserStorageState = Awaited>;
+
+type PlatformSessionPayload = JdLiveSessionInput | TmallLiveSessionInput;
+
+type TmallQrLoginOptions = {
+ resolveTargetItemId?: () => string | null | undefined;
+ timeoutMs?: number;
+};
+
+type JdQrLoginOptions = {
+ resolveTargetSkuId?: () => string | null | undefined;
+ resolveSearchQuery?: () => string | null | undefined;
+ timeoutMs?: number;
+};
+
+function nowIso(): string {
+ return new Date().toISOString();
+}
+
+function normalizeOptionalString(value: string | null | undefined): string | undefined {
+ const normalized = value?.trim();
+ return normalized ? normalized : undefined;
+}
+
+function resolveBrowserExecutablePath(): string {
+ const explicit = normalizeOptionalString(process.env.OPS_QR_BROWSER_PATH);
+ if (explicit) {
+ return explicit;
+ }
+
+ const candidates = [
+ "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
+ "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
+ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
+ "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"
+ ];
+
+ const found = candidates.find((candidate) => {
+ return existsSync(candidate);
+ });
+
+ if (found) {
+ return found;
+ }
+
+ return candidates[0] ?? "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe";
+}
+
+function resolveHeadlessMode(): boolean {
+ const configured = normalizeOptionalString(process.env.OPS_QR_BROWSER_HEADLESS)?.toLowerCase();
+ if (!configured) {
+ return process.env.NODE_ENV === "production";
+ }
+
+ return configured !== "0" && configured !== "false" && configured !== "off" && configured !== "no";
+}
+
+function resolveBrowserUserAgent(): string {
+ return normalizeOptionalString(process.env.OPS_QR_BROWSER_USER_AGENT)
+ ?? DEFAULT_OPS_BROWSER_USER_AGENT;
+}
+
+async function createBrowserResources(): Promise {
+ return createBrowserResourcesWithOptions();
+}
+
+async function createBrowserResourcesWithOptions(options?: {
+ storageState?: BrowserStorageState | undefined;
+ userAgent?: string | undefined;
+}): Promise {
+ const browser = await chromium.launch({
+ headless: resolveHeadlessMode(),
+ executablePath: resolveBrowserExecutablePath(),
+ args: ["--disable-blink-features=AutomationControlled"]
+ });
+ const context = await browser.newContext({
+ locale: "zh-CN",
+ userAgent: options?.userAgent ?? resolveBrowserUserAgent(),
+ viewport: {
+ width: 1440,
+ height: 1180
+ },
+ ...(options?.storageState ? { storageState: options.storageState } : {})
+ });
+ await context.addInitScript(() => {
+ Object.defineProperty(window.navigator, "webdriver", {
+ configurable: true,
+ get: () => undefined
+ });
+ });
+ const page = await context.newPage();
+
+ return {
+ browser,
+ context,
+ page
+ };
+}
+
+async function closeBrowserResources(resources: Partial | null): Promise {
+ if (!resources) {
+ return;
+ }
+
+ await resources.page?.close().catch(() => undefined);
+ await resources.context?.close().catch(() => undefined);
+ await resources.browser?.close().catch(() => undefined);
+}
+
+function toCookieHeader(
+ cookies: Array<{
+ name: string;
+ value: string;
+ domain: string;
+ }>,
+ platform: "jd" | "tmall"
+): string {
+ const allowCookie = (domain: string) => {
+ const normalizedDomain = domain.toLowerCase();
+ if (platform === "jd") {
+ return normalizedDomain.includes("jd.com");
+ }
+
+ return normalizedDomain.includes("tmall.com") || normalizedDomain.includes("taobao.com");
+ };
+
+ return cookies
+ .filter((cookie) => allowCookie(cookie.domain))
+ .map((cookie) => `${cookie.name}=${cookie.value}`)
+ .join("; ");
+}
+
+function sanitizeCapturedRequestHeaders(
+ headers: Record
+): Record | undefined {
+ const isValidReplayHeaderName = (key: string) => /^[!#$%&'*+.^_`|~0-9a-z-]+$/.test(key);
+ const entries = Object.entries(headers)
+ .map(([key, value]) => [key.trim().toLowerCase(), value.trim()] as const)
+ .filter(([key, value]) => {
+ return (
+ key.length > 0 &&
+ value.length > 0 &&
+ !key.startsWith(":") &&
+ isValidReplayHeaderName(key) &&
+ key !== "cookie" &&
+ key !== "referer" &&
+ key !== "host" &&
+ key !== "content-length" &&
+ key !== "connection" &&
+ key !== "accept-encoding"
+ );
+ });
+
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
+}
+
+function buildTmallReviewsTemplateUrl(itemId: string): string {
+ const template = new URL("https://h5api.m.tmall.com/h5/mtop.taobao.rate.detaillist.get/6.0/");
+ template.searchParams.set("api", "mtop.taobao.rate.detaillist.get");
+ template.searchParams.set("v", "6.0");
+ template.searchParams.set(
+ "data",
+ JSON.stringify({
+ auctionNumId: itemId,
+ pageNo: 1,
+ pageSize: 20
+ })
+ );
+ return template.toString();
+}
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function normalizeJdReviewsPreviewOptions(
+ options?: number | JdReviewsPreviewOptions
+): {
+ requestedPage: number;
+ requestedCommentCount: number;
+ maxPages: number;
+} {
+ if (typeof options === "number") {
+ return {
+ requestedPage: 1,
+ requestedCommentCount: options,
+ maxPages: 1
+ };
+ }
+
+ return {
+ requestedPage: options?.page ?? 1,
+ requestedCommentCount: options?.commentCount ?? 5,
+ maxPages: options?.maxPages ?? 1
+ };
+}
+
+function mergeBrowserReviewPages(
+ skuId: string,
+ pages: JdReviewsPreviewResult["reviews"][]
+): JdReviewsPreviewResult["reviews"] {
+ const firstPage = pages[0];
+ if (!firstPage) {
+ return {
+ skuId,
+ total: null,
+ goodRate: null,
+ pictureCount: null,
+ tags: [],
+ comments: []
+ };
+ }
+
+ const commentsById = new Map(firstPage.comments.map((comment) => [comment.id, comment]));
+ for (const page of pages.slice(1)) {
+ for (const comment of page.comments) {
+ if (!commentsById.has(comment.id)) {
+ commentsById.set(comment.id, comment);
+ }
+ }
+ }
+
+ return {
+ ...firstPage,
+ comments: Array.from(commentsById.values())
+ };
+}
+
+export async function takeLocatorScreenshotDataUrl(
+ page: Page,
+ selectors: string[]
+): Promise {
+ for (const selector of selectors) {
+ const locator = page.locator(selector).first();
+
+ try {
+ if ((await locator.count()) === 0) {
+ continue;
+ }
+
+ if (!(await locator.isVisible())) {
+ continue;
+ }
+
+ // QR markup can be replaced mid-capture when the page redirects after scan.
+ const screenshot = await locator.screenshot({
+ type: "png"
+ });
+ return `data:image/png;base64,${Buffer.from(screenshot).toString("base64")}`;
+ } catch {
+ continue;
+ }
+ }
+
+ return undefined;
+}
+
+class BaseOpsQrLoginService implements OpsQrLoginController {
+ private state: OpsQrLoginState;
+ private runPromise: Promise | null = null;
+ private cancelled = false;
+ private resources: BrowserResources | null = null;
+ private preserveBrowserAfterRun = false;
+
+ constructor(protected readonly platform: "jd" | "tmall") {
+ this.state = {
+ platform,
+ status: "idle",
+ note: "尚未启动扫码登录。",
+ sessionImported: false
+ };
+ }
+
+ getState(): OpsQrLoginState {
+ return {
+ ...this.state
+ };
+ }
+
+ async start(): Promise {
+ await this.cancel("restart");
+ this.cancelled = false;
+ this.preserveBrowserAfterRun = false;
+ this.setState({
+ status: "launching",
+ note: "正在启动后台浏览器并加载登录二维码。",
+ startedAt: nowIso(),
+ updatedAt: nowIso(),
+ completedAt: undefined,
+ currentUrl: undefined,
+ qrImageDataUrl: undefined,
+ errorMessage: undefined,
+ sessionImported: false,
+ targetId: undefined
+ });
+
+ this.runPromise = this.run().finally(() => {
+ this.runPromise = null;
+ });
+ void this.runPromise;
+
+ return this.getState();
+ }
+
+ async resumeManualRecovery(): Promise {
+ throw new Error("Manual recovery resume is not supported for this platform.");
+ }
+
+ async cancel(reason = "manual"): Promise {
+ this.cancelled = true;
+ this.preserveBrowserAfterRun = false;
+ await closeBrowserResources(this.resources);
+ this.resources = null;
+
+ if (this.state.status !== "idle" && this.state.status !== "completed") {
+ this.setState({
+ status: "cancelled",
+ note: `扫码登录已取消:${reason}。`,
+ updatedAt: nowIso(),
+ completedAt: nowIso(),
+ currentUrl: undefined,
+ qrImageDataUrl: undefined,
+ errorMessage: undefined
+ });
+ }
+
+ return this.getState();
+ }
+
+ async shutdown(): Promise {
+ await this.cancel("shutdown");
+ }
+
+ protected setState(patch: Partial): void {
+ this.state = {
+ ...this.state,
+ ...patch,
+ updatedAt: patch.updatedAt ?? nowIso()
+ };
+ }
+
+ protected ensureNotCancelled(): void {
+ if (this.cancelled) {
+ throw new Error("QR login was cancelled.");
+ }
+ }
+
+ protected getResources(): BrowserResources | null {
+ return this.resources;
+ }
+
+ protected setResources(resources: BrowserResources | null): void {
+ this.resources = resources;
+ }
+
+ protected preserveBrowserForManualRecovery(): void {
+ this.preserveBrowserAfterRun = true;
+ }
+
+ protected getLatestOpenPage(): Page | null {
+ const pages = this.resources?.context
+ .pages()
+ .filter((page) => !page.isClosed())
+ ?? [];
+ return pages.at(-1) ?? this.resources?.page ?? null;
+ }
+
+ protected async waitForLoginCompletion(
+ getPage: () => Page,
+ screenshotSelectors: string[],
+ isCompleted: () => Promise,
+ timeoutMs: number
+ ): Promise {
+ const deadline = Date.now() + timeoutMs;
+
+ while (Date.now() < deadline) {
+ this.ensureNotCancelled();
+ const page = getPage();
+ const qrImageDataUrl = await takeLocatorScreenshotDataUrl(page, screenshotSelectors).catch(
+ () => undefined
+ );
+ this.setState({
+ status: "waiting_for_scan",
+ note: "二维码已生成,请用手机扫码并在 App 内确认登录。",
+ currentUrl: page.url(),
+ ...(qrImageDataUrl ? { qrImageDataUrl } : {})
+ });
+
+ if (await isCompleted()) {
+ return;
+ }
+
+ await sleep(SCREENSHOT_POLL_INTERVAL_MS);
+ }
+
+ throw new Error("等待扫码登录超时,请刷新二维码后重试。");
+ }
+
+ private async run(): Promise {
+ try {
+ const resources = await createBrowserResources();
+ this.setResources(resources);
+ await this.runWithBrowser(resources);
+ } catch (error) {
+ if (this.cancelled) {
+ return;
+ }
+
+ this.setState({
+ status: "failed",
+ note: error instanceof Error ? error.message : "扫码登录失败。",
+ errorMessage: error instanceof Error ? error.message : "扫码登录失败。",
+ completedAt: nowIso(),
+ currentUrl: this.resources?.page.url()
+ });
+ } finally {
+ if (!this.preserveBrowserAfterRun) {
+ await closeBrowserResources(this.resources);
+ this.resources = null;
+ }
+ }
+ }
+
+ protected async runWithBrowser(_resources: BrowserResources): Promise {
+ throw new Error("Not implemented.");
+ }
+}
+
+export class JdOpsQrLoginService
+ extends BaseOpsQrLoginService
+ implements JdBrowserPreviewProvider
+{
+ private readonly timeoutMs: number;
+ private latestStorageState: BrowserStorageState | null = null;
+ private latestCapturedSession: {
+ targetSkuId?: string;
+ targetSearchQuery?: string;
+ targetProductUrl?: string;
+ targetSearchUrl?: string;
+ searchApiTemplateUrl?: string;
+ detailTemplateUrl?: string;
+ reviewsTemplateUrl?: string;
+ searchCapturedAt?: number | undefined;
+ detailCapturedAt?: number | undefined;
+ reviewsCapturedAt?: number | undefined;
+ searchRequestHeaders?: Record | undefined;
+ detailRequestHeaders?: Record | undefined;
+ reviewsRequestHeaders?: Record | undefined;
+ } = {};
+
+ constructor(
+ private readonly sessionManager: JdSessionManager,
+ private readonly options: JdQrLoginOptions = {}
+ ) {
+ super("jd");
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
+ }
+
+ private resetCapturedSession(): void {
+ this.latestStorageState = null;
+ this.latestCapturedSession = {};
+ }
+
+ private captureSessionRequest(
+ url: string,
+ rawHeaders: Record,
+ currentPageUrl: string
+ ): void {
+ const headers = sanitizeCapturedRequestHeaders(rawHeaders);
+ const referer = normalizeOptionalString(rawHeaders.referer);
+ const normalizedCurrentPageUrl = normalizeOptionalString(currentPageUrl);
+
+ if (url.includes("functionId=pc_search_searchWare")) {
+ const nextSearchUrl =
+ referer ??
+ (normalizedCurrentPageUrl && JD_SEARCH_URL_PATTERN.test(normalizedCurrentPageUrl)
+ ? normalizedCurrentPageUrl
+ : this.latestCapturedSession.targetSearchUrl);
+ this.latestCapturedSession.searchApiTemplateUrl = url;
+ this.latestCapturedSession.searchRequestHeaders = headers;
+ this.latestCapturedSession.searchCapturedAt = Date.now();
+ if (nextSearchUrl) {
+ this.latestCapturedSession.targetSearchUrl = nextSearchUrl;
+ }
+ }
+
+ if (url.includes("functionId=pc_detailpage_wareBusiness")) {
+ const nextProductUrl =
+ referer ??
+ (normalizedCurrentPageUrl && JD_PRODUCT_URL_PATTERN.test(normalizedCurrentPageUrl)
+ ? normalizedCurrentPageUrl
+ : this.latestCapturedSession.targetProductUrl);
+ this.latestCapturedSession.detailTemplateUrl = url;
+ this.latestCapturedSession.detailRequestHeaders = headers;
+ this.latestCapturedSession.detailCapturedAt = Date.now();
+ if (nextProductUrl) {
+ this.latestCapturedSession.targetProductUrl = nextProductUrl;
+ }
+ }
+
+ if (url.includes("functionId=getLegoWareDetailComment")) {
+ const nextProductUrl =
+ referer ??
+ (normalizedCurrentPageUrl && JD_PRODUCT_URL_PATTERN.test(normalizedCurrentPageUrl)
+ ? normalizedCurrentPageUrl
+ : this.latestCapturedSession.targetProductUrl);
+ this.latestCapturedSession.reviewsTemplateUrl = url;
+ this.latestCapturedSession.reviewsRequestHeaders = headers;
+ this.latestCapturedSession.reviewsCapturedAt = Date.now();
+ if (nextProductUrl) {
+ this.latestCapturedSession.targetProductUrl = nextProductUrl;
+ }
+ }
+ }
+
+ private updateCapturedPageUrls(page: Page): void {
+ const currentUrl = normalizeOptionalString(page.url());
+ if (!currentUrl) {
+ return;
+ }
+
+ if (JD_SEARCH_URL_PATTERN.test(currentUrl)) {
+ this.latestCapturedSession.targetSearchUrl = currentUrl;
+ return;
+ }
+
+ if (JD_PRODUCT_URL_PATTERN.test(currentUrl)) {
+ this.latestCapturedSession.targetProductUrl = currentUrl;
+ }
+ }
+
+ private async refreshSearchCapture(page: Page, fallbackSearchUrl: string): Promise {
+ const previousCaptureAt = this.latestCapturedSession.searchCapturedAt ?? 0;
+ const currentUrl = page.url();
+ const navigationTarget = JD_SEARCH_URL_PATTERN.test(currentUrl)
+ ? currentUrl
+ : fallbackSearchUrl;
+ const requestPromise = page
+ .waitForRequest((request) => request.url().includes("functionId=pc_search_searchWare"), {
+ timeout: 20_000
+ })
+ .then(async (request) => {
+ const rawHeaders = await request.allHeaders().catch(() => ({}));
+ this.captureSessionRequest(request.url(), rawHeaders, page.url());
+ })
+ .catch(() => undefined);
+
+ if (JD_SEARCH_URL_PATTERN.test(currentUrl)) {
+ await page
+ .reload({
+ waitUntil: "domcontentloaded",
+ timeout: 60_000
+ })
+ .catch(async () => {
+ await page
+ .goto(navigationTarget, {
+ waitUntil: "domcontentloaded",
+ timeout: 60_000
+ })
+ .catch(() => undefined);
+ });
+ } else {
+ await page
+ .goto(navigationTarget, {
+ waitUntil: "domcontentloaded",
+ timeout: 60_000
+ })
+ .catch(() => undefined);
+ }
+ await page.waitForLoadState("networkidle").catch(() => undefined);
+ await requestPromise;
+ this.updateCapturedPageUrls(page);
+
+ if ((this.latestCapturedSession.searchCapturedAt ?? 0) <= previousCaptureAt) {
+ throw new Error(
+ "JD recovery browser did not emit a fresh search request. Refresh the search results in the browser and try resume again."
+ );
+ }
+
+ await page.waitForTimeout(1_000);
+ }
+
+ private async snapshotStorageState(context: BrowserContext): Promise {
+ this.latestStorageState = await context.storageState().catch(() => null);
+ }
+
+ private buildBrowserSearchUrl(query: string): string {
+ const searchUrl = this.latestCapturedSession.targetSearchUrl
+ ? new URL(this.latestCapturedSession.targetSearchUrl)
+ : new URL("https://search.jd.com/Search");
+ searchUrl.searchParams.set("keyword", query);
+ if (searchUrl.searchParams.has("wq")) {
+ searchUrl.searchParams.set("wq", query);
+ }
+ if (!searchUrl.searchParams.has("enc")) {
+ searchUrl.searchParams.set("enc", "utf-8");
+ }
+
+ return searchUrl.toString();
+ }
+
+ private async navigateForBrowserPreview(page: Page, url: string): Promise {
+ await page
+ .goto(url, {
+ waitUntil: "commit",
+ timeout: 15_000
+ })
+ .catch(() => undefined);
+ await page
+ .waitForLoadState("domcontentloaded", {
+ timeout: 8_000
+ })
+ .catch(() => undefined);
+ await page.waitForTimeout(1_500);
+ }
+
+ private async withBrowserPreviewPage(runner: (page: Page) => Promise): Promise {
+ const liveContext = this.getResources()?.context;
+ const livePage = this.getLatestOpenPage();
+ if (liveContext && livePage) {
+ return runner(livePage);
+ }
+
+ if (!this.latestStorageState) {
+ throw new Error("JD browser preview session is unavailable. Start QR login again.");
+ }
+
+ const resources = await createBrowserResourcesWithOptions({
+ storageState: this.latestStorageState
+ });
+ try {
+ const result = await runner(resources.page);
+ await this.snapshotStorageState(resources.context);
+ return result;
+ } finally {
+ await closeBrowserResources(resources);
+ }
+ }
+
+ private async captureBrowserResponse(
+ page: Page,
+ options: {
+ matcher: (url: string) => boolean;
+ action: () => Promise;
+ timeoutMs?: number;
+ }
+ ): Promise<{ status: number; finalUrl: string; text: string } | null> {
+ const responsePromise = page
+ .waitForResponse((response) => options.matcher(response.url()), {
+ timeout: options.timeoutMs ?? 20_000
+ })
+ .then(async (response) => ({
+ status: response.status(),
+ finalUrl: response.url(),
+ text: await response.text().catch(() => "")
+ }))
+ .catch(() => null);
+
+ await options.action();
+ return responsePromise;
+ }
+
+ async previewSearch(query: string, mode?: JdSearchMode): Promise {
+ const normalizedQuery = query.trim();
+ if (!normalizedQuery) {
+ throw new Error("query is required for JD browser preview search.");
+ }
+
+ return this.withBrowserPreviewPage(async (page) => {
+ const resolvedMode: JdSearchMode =
+ mode ?? (this.latestCapturedSession.searchApiTemplateUrl ? "api" : "html");
+ const searchUrl = this.buildBrowserSearchUrl(normalizedQuery);
+ const response = await this.captureBrowserResponse(page, {
+ matcher: (url) => url.includes("functionId=pc_search_searchWare"),
+ action: async () => {
+ await this.navigateForBrowserPreview(page, searchUrl);
+ await page.waitForLoadState("networkidle").catch(() => undefined);
+ await page.waitForTimeout(2_000);
+ }
+ });
+ this.updateCapturedPageUrls(page);
+
+ if (resolvedMode === "api" && response && response.status >= 200 && response.status < 400) {
+ const candidates = parseJdSearchApiResponse(normalizedQuery, { text: response.text });
+ return {
+ query: normalizedQuery,
+ source: "api",
+ candidateCount: candidates.length,
+ candidates
+ };
+ }
+
+ const html = await page.content();
+ const candidates = parseJdSearchHtml(normalizedQuery, html);
+ return {
+ query: normalizedQuery,
+ source: "html",
+ candidateCount: candidates.length,
+ candidates
+ };
+ });
+ }
+
+ async previewDetail(skuId: string): Promise {
+ const normalizedSkuId = skuId.trim();
+ if (!normalizedSkuId) {
+ throw new Error("skuId is required for JD browser preview detail.");
+ }
+
+ return this.withBrowserPreviewPage(async (page) => {
+ const response = await this.captureBrowserResponse(page, {
+ matcher: (url) => url.includes("functionId=pc_detailpage_wareBusiness"),
+ action: async () => {
+ await this.navigateForBrowserPreview(page, `https://item.jd.com/${normalizedSkuId}.html`);
+ await page.waitForLoadState("networkidle").catch(() => undefined);
+ await page.waitForTimeout(2_000);
+ }
+ });
+ this.updateCapturedPageUrls(page);
+
+ if (!response || response.status >= 400) {
+ throw new Error("JD browser preview could not capture a valid detail response.");
+ }
+
+ return {
+ skuId: normalizedSkuId,
+ source: "api",
+ detail: parseJdDetailApiResponse(normalizedSkuId, { text: response.text })
+ };
+ });
+ }
+
+ async previewReviews(
+ skuId: string,
+ options?: number | JdReviewsPreviewOptions
+ ): Promise {
+ const normalizedSkuId = skuId.trim();
+ if (!normalizedSkuId) {
+ throw new Error("skuId is required for JD browser preview reviews.");
+ }
+
+ return this.withBrowserPreviewPage(async (page) => {
+ const requested = normalizeJdReviewsPreviewOptions(options);
+ const response = await this.captureBrowserResponse(page, {
+ matcher: (url) => url.includes("functionId=getLegoWareDetailComment"),
+ timeoutMs: 25_000,
+ action: async () => {
+ await this.navigateForBrowserPreview(page, `https://item.jd.com/${normalizedSkuId}.html`);
+ await page.waitForLoadState("networkidle").catch(() => undefined);
+ await page.waitForTimeout(2_000);
+ this.updateCapturedPageUrls(page);
+ await page.mouse.wheel(0, 2600).catch(() => undefined);
+ await page.waitForTimeout(2_000);
+ await page.getByText(/鍟嗗搧璇勪环|鍏ㄩ儴璇勪环|璇勪环/).first().click().catch(() => undefined);
+ await page.waitForTimeout(3_000);
+ }
+ });
+
+ if (!response || response.status >= 400) {
+ throw new Error("JD browser preview could not capture a valid reviews response.");
+ }
+
+ const parsedPage = parseJdReviewsApiResponse(normalizedSkuId, { text: response.text });
+ const mergedReviews = mergeBrowserReviewPages(normalizedSkuId, [parsedPage]);
+ const pagination: JdReviewsPaginationSummary = {
+ requestedPage: requested.requestedPage,
+ requestedCommentCount: requested.requestedCommentCount,
+ maxPages: requested.maxPages,
+ pagesFetched: 1
+ };
+
+ return {
+ skuId: normalizedSkuId,
+ source: "api",
+ pagination,
+ reviews: {
+ ...mergedReviews,
+ comments: mergedReviews.comments.slice(0, requested.requestedCommentCount)
+ }
+ };
+ });
+ }
+
+ private async buildImportedSessionFromOpenBrowser(): Promise {
+ const resources = this.getResources();
+ const activePage = this.getLatestOpenPage();
+ if (!resources || !activePage) {
+ throw new Error("JD recovery browser is not available. Start QR login again.");
+ }
+
+ const userAgent =
+ (await activePage.evaluate(() => window.navigator.userAgent).catch(() => "")) || undefined;
+ const cookieHeader = toCookieHeader(await resources.context.cookies(), "jd");
+ const currentUrl = normalizeOptionalString(activePage.url());
+ const currentSearchUrl =
+ currentUrl && JD_SEARCH_URL_PATTERN.test(currentUrl) ? currentUrl : undefined;
+ const currentProductUrl =
+ currentUrl && JD_PRODUCT_URL_PATTERN.test(currentUrl) ? currentUrl : undefined;
+ const detailTemplateUrl = this.latestCapturedSession.detailTemplateUrl;
+ const reviewsTemplateUrl = this.latestCapturedSession.reviewsTemplateUrl;
+
+ if (!detailTemplateUrl || !reviewsTemplateUrl) {
+ throw new Error(
+ "JD recovery browser is missing detail/reviews templates. Start QR login again."
+ );
+ }
+
+ return {
+ cookieHeader,
+ ...(userAgent ? { userAgent } : {}),
+ ...(this.latestCapturedSession.searchApiTemplateUrl
+ ? { searchApiTemplateUrl: this.latestCapturedSession.searchApiTemplateUrl }
+ : {}),
+ detailTemplateUrl,
+ reviewsTemplateUrl,
+ ...(currentSearchUrl ?? this.latestCapturedSession.targetSearchUrl
+ ? { searchReferer: currentSearchUrl ?? this.latestCapturedSession.targetSearchUrl }
+ : {}),
+ ...(currentProductUrl ?? this.latestCapturedSession.targetProductUrl
+ ? { detailReferer: currentProductUrl ?? this.latestCapturedSession.targetProductUrl }
+ : {}),
+ ...(this.latestCapturedSession.searchRequestHeaders
+ ? { searchRequestHeaders: this.latestCapturedSession.searchRequestHeaders }
+ : {}),
+ ...(this.latestCapturedSession.detailRequestHeaders
+ ? { detailRequestHeaders: this.latestCapturedSession.detailRequestHeaders }
+ : {}),
+ ...(this.latestCapturedSession.reviewsRequestHeaders
+ ? { reviewsRequestHeaders: this.latestCapturedSession.reviewsRequestHeaders }
+ : {})
+ };
+ }
+
+ override async resumeManualRecovery(): Promise {
+ const resources = this.getResources();
+ const activePage = this.getLatestOpenPage();
+ if (!resources || !activePage) {
+ throw new Error("JD recovery browser is not available. Start QR login again.");
+ }
+
+ const targetSearchUrl =
+ (JD_SEARCH_URL_PATTERN.test(activePage.url()) ? activePage.url() : undefined) ??
+ this.latestCapturedSession.targetSearchUrl ??
+ `https://search.jd.com/Search?keyword=${encodeURIComponent(
+ this.latestCapturedSession.targetSearchQuery ?? "iPhone 15"
+ )}`;
+
+ await this.refreshSearchCapture(activePage, targetSearchUrl);
+ await this.snapshotStorageState(resources.context);
+
+ const payload = await this.buildImportedSessionFromOpenBrowser();
+ await this.sessionManager.importManualSession(payload, "ops-qr-login-manual-recovery");
+ const healthCheck = await this.sessionManager.runHealthCheck("ops-qr-login-manual-recovery");
+
+ if (
+ healthCheck.state.status === "manual_action_required" &&
+ healthCheck.state.lastFailureCode === "RISK_BLOCKED"
+ ) {
+ this.preserveBrowserForManualRecovery();
+ this.setState({
+ status: "failed",
+ note:
+ "已重新导入当前浏览器会话,但京东仍要求人工完成风控验证。请继续在浏览器中完成验证后,再点“人工恢复后重新导入”。",
+ currentUrl: activePage.url(),
+ completedAt: nowIso(),
+ sessionImported: true,
+ targetId: this.latestCapturedSession.targetSkuId,
+ errorMessage: healthCheck.state.lastFailureMessage
+ });
+ return this.getState();
+ }
+
+ const currentUrl = activePage.url();
+ await closeBrowserResources(resources);
+ this.setResources(null);
+ this.setState({
+ status: "completed",
+ note: "京东人工恢复完成,已重新导入风控后的浏览器会话。",
+ currentUrl,
+ completedAt: nowIso(),
+ sessionImported: true,
+ targetId: this.latestCapturedSession.targetSkuId,
+ errorMessage: undefined,
+ qrImageDataUrl: undefined
+ });
+ return this.getState();
+ }
+
+ protected override async runWithBrowser(resources: BrowserResources): Promise {
+ this.resetCapturedSession();
+ const targetSkuId =
+ normalizeOptionalString(this.options.resolveTargetSkuId?.()) ?? DEFAULT_JD_CAPTURE_SKU;
+ const targetSearchQuery =
+ normalizeOptionalString(this.options.resolveSearchQuery?.()) ?? "iPhone 15";
+ const targetProductUrl = `https://item.jd.com/${targetSkuId}.html`;
+ const targetSearchUrl = `https://search.jd.com/Search?keyword=${encodeURIComponent(targetSearchQuery)}`;
+ const loginUrl = `https://passport.jd.com/new/login.aspx?ReturnUrl=${encodeURIComponent(targetProductUrl)}`;
+ this.latestCapturedSession = {
+ targetSkuId,
+ targetSearchQuery,
+ targetProductUrl,
+ targetSearchUrl
+ };
+ let activePage = resources.page;
+
+ resources.context.on("page", (nextPage) => {
+ activePage = nextPage;
+ });
+ resources.context.on("request", (request) => {
+ const url = request.url();
+ const captureHeaders = async () => {
+ const headers = await request.allHeaders().catch(() => ({}));
+ this.captureSessionRequest(url, headers, activePage.url());
+ };
+
+ void captureHeaders();
+ });
+
+ await activePage.goto(loginUrl, {
+ waitUntil: "domcontentloaded",
+ timeout: 60_000
+ });
+ await activePage.waitForTimeout(3_000);
+
+ const qrSelectors = [".qrcode-login .qrcode-img", ".qrcode-img", "#J-qrcode"];
+ const qrImageDataUrl = await takeLocatorScreenshotDataUrl(activePage, qrSelectors);
+ this.setState({
+ status: "waiting_for_scan",
+ note: "京东二维码已加载,请用京东 App 扫码登录。",
+ currentUrl: activePage.url(),
+ targetId: targetSkuId,
+ ...(qrImageDataUrl ? { qrImageDataUrl } : {})
+ });
+
+ await this.waitForLoginCompletion(
+ () => activePage,
+ qrSelectors,
+ async () => {
+ const cookies = await resources.context.cookies();
+ const names = new Set(cookies.map((cookie) => cookie.name));
+ const currentUrl = activePage.url();
+ if (!JD_LOGIN_URL_PATTERN.test(currentUrl) && currentUrl.includes("jd.com")) {
+ return true;
+ }
+
+ return names.has("thor") || names.has("pin") || names.has("pt_key");
+ },
+ this.timeoutMs
+ );
+
+ this.setState({
+ status: "capturing_session",
+ note: "京东扫码成功,正在回放商品页并提取模板。",
+ currentUrl: activePage.url(),
+ targetId: targetSkuId
+ });
+
+ await activePage.goto(targetSearchUrl, {
+ waitUntil: "domcontentloaded",
+ timeout: 60_000
+ });
+ await activePage.waitForLoadState("networkidle").catch(() => undefined);
+ await activePage.waitForTimeout(4_000);
+ this.updateCapturedPageUrls(activePage);
+
+ const searchCaptureDeadline = Date.now() + 12_000;
+ while (Date.now() < searchCaptureDeadline) {
+ this.ensureNotCancelled();
+ if (this.latestCapturedSession.searchApiTemplateUrl) {
+ break;
+ }
+ await activePage.waitForTimeout(1_000);
+ }
+
+ await activePage.goto(targetProductUrl, {
+ waitUntil: "domcontentloaded",
+ timeout: 60_000
+ });
+ await activePage.waitForLoadState("networkidle").catch(() => undefined);
+ await activePage.waitForTimeout(4_000);
+ this.updateCapturedPageUrls(activePage);
+ await activePage.mouse.wheel(0, 2600).catch(() => undefined);
+ await activePage.waitForTimeout(3_000);
+ await activePage.getByText(/商品评价|全部评价|评价/).first().click().catch(() => undefined);
+ await activePage.waitForTimeout(4_000);
+
+ const captureDeadline = Date.now() + 20_000;
+ while (Date.now() < captureDeadline) {
+ this.ensureNotCancelled();
+ if (
+ this.latestCapturedSession.detailTemplateUrl &&
+ this.latestCapturedSession.reviewsTemplateUrl
+ ) {
+ break;
+ }
+ await activePage.waitForTimeout(1_000);
+ }
+
+ if (
+ !this.latestCapturedSession.detailTemplateUrl ||
+ !this.latestCapturedSession.reviewsTemplateUrl
+ ) {
+ throw new Error("京东已登录,但未捕获到详情或评论模板,请刷新后重试或暂时使用手工注入。");
+ }
+
+ const payload = await this.buildImportedSessionFromOpenBrowser();
+ await this.snapshotStorageState(resources.context);
+
+ await this.sessionManager.importManualSession(payload, "ops-qr-login");
+ const healthCheck = await this.sessionManager.runHealthCheck("ops-qr-login");
+
+ if (
+ healthCheck.state.status === "manual_action_required" &&
+ healthCheck.state.lastFailureCode === "RISK_BLOCKED"
+ ) {
+ const recoverySearchUrl = this.latestCapturedSession.targetSearchUrl ?? targetSearchUrl;
+ await activePage
+ .goto(recoverySearchUrl, {
+ waitUntil: "domcontentloaded",
+ timeout: 60_000
+ })
+ .catch(() => undefined);
+ this.updateCapturedPageUrls(activePage);
+ this.preserveBrowserForManualRecovery();
+ this.setState({
+ status: "failed",
+ note:
+ "Cookie 与模板已导入,但京东仍要求人工完成风控验证。请在已打开浏览器中完成验证后,回运维页点击“人工恢复后重新导入”。",
+ currentUrl: activePage.url(),
+ completedAt: nowIso(),
+ sessionImported: true,
+ targetId: targetSkuId,
+ errorMessage: healthCheck.state.lastFailureMessage
+ });
+ return;
+ }
+
+ this.setState({
+ status: "completed",
+ note: "京东扫码登录完成,Cookie 与模板已导入运维会话。",
+ currentUrl: activePage.url(),
+ completedAt: nowIso(),
+ sessionImported: true,
+ targetId: targetSkuId
+ });
+ }
+}
+
+export class TmallOpsQrLoginService extends BaseOpsQrLoginService {
+ private readonly timeoutMs: number;
+
+ constructor(
+ private readonly sessionManager: TmallSessionManager,
+ private readonly options: TmallQrLoginOptions = {}
+ ) {
+ super("tmall");
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
+ }
+
+ protected override async runWithBrowser(resources: BrowserResources): Promise {
+ const targetItemId =
+ normalizeOptionalString(this.options.resolveTargetItemId?.()) ?? DEFAULT_TMALL_CAPTURE_ITEM_ID;
+ const targetDetailUrl = `https://detail.tmall.com/item.htm?id=${targetItemId}`;
+ const loginUrl = `https://login.taobao.com/member/login.jhtml?redirectURL=${encodeURIComponent(
+ targetDetailUrl
+ )}`;
+ let activePage = resources.page;
+ let reviewsTemplateUrl: string | undefined;
+
+ resources.context.on("page", (nextPage) => {
+ activePage = nextPage;
+ });
+ resources.context.on("request", (request) => {
+ const url = request.url();
+ if (
+ (url.includes("mtop.taobao.rate.detaillist.get") ||
+ url.includes("mtop.alibaba.review.list.for.new.pc.detail")) &&
+ !reviewsTemplateUrl
+ ) {
+ reviewsTemplateUrl = url;
+ }
+ });
+
+ await activePage.goto(loginUrl, {
+ waitUntil: "domcontentloaded",
+ timeout: 60_000
+ });
+ await activePage.waitForTimeout(4_000);
+
+ const qrSelectors = [".qrcode-login .qrcode-img", ".qrcode-img", "canvas"];
+ const qrImageDataUrl = await takeLocatorScreenshotDataUrl(activePage, qrSelectors);
+ this.setState({
+ status: "waiting_for_scan",
+ note: "天猫二维码已加载,请用淘宝或天猫 App 扫码登录。",
+ currentUrl: activePage.url(),
+ targetId: targetItemId,
+ ...(qrImageDataUrl ? { qrImageDataUrl } : {})
+ });
+
+ await this.waitForLoginCompletion(
+ () => activePage,
+ qrSelectors,
+ async () => {
+ const cookies = await resources.context.cookies();
+ const names = new Set(cookies.map((cookie) => cookie.name));
+ const currentUrl = activePage.url();
+ if (!TMALL_LOGIN_URL_PATTERN.test(currentUrl) && /tmall\.com|taobao\.com/i.test(currentUrl)) {
+ return true;
+ }
+
+ return names.has("_m_h5_tk");
+ },
+ this.timeoutMs
+ );
+
+ this.setState({
+ status: "capturing_session",
+ note: "天猫扫码成功,正在回放商品页并提取评论模板。",
+ currentUrl: activePage.url(),
+ targetId: targetItemId
+ });
+
+ await activePage.goto(targetDetailUrl, {
+ waitUntil: "domcontentloaded",
+ timeout: 60_000
+ });
+ await activePage.waitForLoadState("networkidle").catch(() => undefined);
+ await activePage.waitForTimeout(4_000);
+ await activePage.mouse.wheel(0, 2200).catch(() => undefined);
+ await activePage.waitForTimeout(3_000);
+ await activePage.getByText(/商品评价|全部评价|评价/).first().click().catch(() => undefined);
+ await activePage.waitForTimeout(4_000);
+
+ const captureDeadline = Date.now() + 12_000;
+ while (Date.now() < captureDeadline) {
+ this.ensureNotCancelled();
+ if (reviewsTemplateUrl) {
+ break;
+ }
+ await activePage.waitForTimeout(1_000);
+ }
+
+ const userAgent =
+ (await activePage.evaluate(() => window.navigator.userAgent).catch(() => "")) || undefined;
+ const cookieHeader = toCookieHeader(await resources.context.cookies(), "tmall");
+ const payload: TmallLiveSessionInput = {
+ cookieHeader,
+ ...(userAgent ? { userAgent } : {}),
+ detailTemplateUrl: targetDetailUrl,
+ reviewsTemplateUrl: reviewsTemplateUrl ?? buildTmallReviewsTemplateUrl(targetItemId),
+ detailReferer: targetDetailUrl
+ };
+
+ await this.sessionManager.importManualSession(payload, "ops-qr-login");
+ await this.sessionManager.runHealthCheck("ops-qr-login");
+
+ this.setState({
+ status: "completed",
+ note: "天猫扫码登录完成,Cookie 与评论模板已导入运维会话。",
+ currentUrl: activePage.url(),
+ completedAt: nowIso(),
+ sessionImported: true,
+ targetId: targetItemId
+ });
+ }
+}
diff --git a/apps/api/src/platforms/jd/live-session.test.ts b/apps/api/src/platforms/jd/live-session.test.ts
index bb78ebe..eb8b73b 100644
--- a/apps/api/src/platforms/jd/live-session.test.ts
+++ b/apps/api/src/platforms/jd/live-session.test.ts
@@ -60,6 +60,65 @@ describe("JdLiveSessionService", () => {
});
});
+ it("reuses imported JD search API templates and rewrites query parameters", async () => {
+ const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise>(async () =>
+ buildResponse({
+ data: {
+ wareList: [
+ {
+ skuId: "100068388533",
+ imageurl: "//img14.360buyimg.com/n7/jfs/t1/example.jpg",
+ wname: "大疆 Pocket 3 全能套装",
+ good: "99%",
+ jdPrice: "5199.00",
+ shopName: "京东自营"
+ }
+ ]
+ }
+ })
+ );
+ vi.stubGlobal("fetch", fetchMock);
+
+ const service = new JdLiveSessionService();
+ service.importSession({
+ cookieHeader: "thor=masked;",
+ searchApiTemplateUrl:
+ "https://api.m.jd.com/api?functionId=pc_search_searchWare&body=%7B%22keyword%22:%22iPhone%2015%22,%22wq%22:%22iPhone%2015%22,%22page%22:1%7D&keyword=iPhone%2015&enc=utf-8&suggest=2.def.0.test&wq=iPhone%2015&pvid=test-pvid&spmTag=test-spm",
+ searchRequestHeaders: {
+ ":authority": "api.m.jd.com",
+ "accept-language": "zh-CN,zh;q=0.9",
+ "sec-fetch-site": "same-site",
+ cookie: "should-be-ignored"
+ }
+ });
+
+ const preview = await service.previewSearch("大疆pocket3");
+
+ expect(preview.source).toBe("api");
+ expect(preview.candidateCount).toBe(1);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const requestUrl = fetchMock.mock.calls[0]?.[0];
+ const requestInit = fetchMock.mock.calls[0]?.[1];
+ if (!requestUrl || !requestInit) {
+ throw new Error("Expected JD search preview to call fetch with url and init.");
+ }
+
+ const parsedUrl = new URL(requestUrl);
+ const decodedBody = decodeURIComponent(parsedUrl.searchParams.get("body") ?? "");
+ expect(parsedUrl.searchParams.get("keyword")).toBe("大疆pocket3");
+ expect(parsedUrl.searchParams.get("wq")).toBe("大疆pocket3");
+ expect(parsedUrl.searchParams.get("pvid")).toBe("test-pvid");
+ expect(decodedBody).toContain(`"keyword":"${parsedUrl.searchParams.get("keyword")}"`);
+ expect(decodedBody).toContain(`"wq":"${parsedUrl.searchParams.get("wq")}"`);
+ expect(requestInit.headers).toMatchObject({
+ Referer: expect.stringContaining("keyword=%E5%A4%A7%E7%96%86pocket3"),
+ "User-Agent": expect.any(String),
+ "accept-language": "zh-CN,zh;q=0.9",
+ "sec-fetch-site": "same-site"
+ });
+ expect(requestInit.headers).not.toHaveProperty(":authority");
+ });
+
it("paginates and deduplicates JD reviews across multiple pages", async () => {
const fetchMock = vi
.fn<(input: string, init?: RequestInit) => Promise>()
@@ -269,6 +328,155 @@ describe("JdLiveSessionService", () => {
});
});
+ it("falls back to browser-backed search preview when Node replay is risk-blocked", async () => {
+ const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise>(async () =>
+ new Response("blocked", { status: 403 })
+ );
+ vi.stubGlobal("fetch", fetchMock);
+
+ const service = new JdLiveSessionService();
+ const browserPreviewSearch = vi.fn(async () => ({
+ query: "大疆pocket3",
+ source: "html" as const,
+ candidateCount: 1,
+ candidates: [
+ {
+ candidateId: "jd-100068388533",
+ platform: "jd" as const,
+ title: "大疆 Pocket 3 全能套装",
+ price: 5199,
+ priceLabel: "CNY 5199",
+ storeName: "京东自营",
+ productUrl: "https://item.jd.com/100068388533.html",
+ imageUrl: "https://img14.360buyimg.com/n7/jfs/t1/example.jpg",
+ salesHint: "sold 500+",
+ specLabel: "标准版",
+ highlights: ["browser fallback"]
+ }
+ ]
+ }));
+ service.setBrowserPreviewProvider({
+ previewSearch: browserPreviewSearch,
+ previewDetail: vi.fn(),
+ previewReviews: vi.fn()
+ });
+ service.importSession({
+ cookieHeader: "thor=masked;",
+ searchApiTemplateUrl:
+ "https://api.m.jd.com/api?functionId=pc_search_searchWare&body=%7B%22keyword%22:%22iPhone%2015%22,%22wq%22:%22iPhone%2015%22,%22page%22:1%7D&keyword=iPhone%2015&enc=utf-8"
+ });
+
+ const preview = await service.previewSearch("大疆pocket3");
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(browserPreviewSearch).toHaveBeenCalledWith("大疆pocket3", "api");
+ expect(preview).toMatchObject({
+ source: "html",
+ candidateCount: 1
+ });
+ });
+
+ it("falls back to browser-backed detail preview when Node replay is risk-blocked", async () => {
+ const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise>(async () =>
+ new Response("blocked", { status: 403 })
+ );
+ vi.stubGlobal("fetch", fetchMock);
+
+ const service = new JdLiveSessionService();
+ const browserPreviewDetail = vi.fn(async () => ({
+ skuId: "100068388535",
+ source: "api" as const,
+ detail: {
+ skuId: "100068388535",
+ title: "Apple iPhone 15",
+ price: "4398.00",
+ originalPrice: "4599.00",
+ estimatedPrice: "4398.00",
+ shopName: "JD Self Operated",
+ vendorId: null,
+ categoryPath: [],
+ stockState: "in stock",
+ mainImage: null,
+ averageScore: "4.9"
+ }
+ }));
+ service.setBrowserPreviewProvider({
+ previewSearch: vi.fn(),
+ previewDetail: browserPreviewDetail,
+ previewReviews: vi.fn()
+ });
+ service.importSession({
+ cookieHeader: "thor=masked;",
+ detailTemplateUrl:
+ "https://api.m.jd.com/?functionId=pc_detailpage_wareBusiness&body=%7B%22skuId%22:%22100068388533%22,%22area%22:%222_2813_61125_0%22%7D"
+ });
+
+ const preview = await service.previewDetail("100068388535");
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(browserPreviewDetail).toHaveBeenCalledWith("100068388535");
+ expect(preview.detail.title).toBe("Apple iPhone 15");
+ });
+
+ it("falls back to browser-backed reviews preview when Node replay is risk-blocked", async () => {
+ const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise>(async () =>
+ new Response("blocked", { status: 403 })
+ );
+ vi.stubGlobal("fetch", fetchMock);
+
+ const service = new JdLiveSessionService();
+ const browserPreviewReviews = vi.fn(async () => ({
+ skuId: "100068388535",
+ source: "api" as const,
+ pagination: {
+ requestedPage: 1,
+ requestedCommentCount: 2,
+ maxPages: 1,
+ pagesFetched: 1
+ },
+ reviews: {
+ skuId: "100068388535",
+ total: "1000",
+ goodRate: "96%",
+ pictureCount: "120",
+ tags: [],
+ comments: [
+ {
+ id: "comment-1",
+ content: "great",
+ score: "5",
+ creationTime: null,
+ userLevelName: null
+ }
+ ]
+ }
+ }));
+ service.setBrowserPreviewProvider({
+ previewSearch: vi.fn(),
+ previewDetail: vi.fn(),
+ previewReviews: browserPreviewReviews
+ });
+ service.importSession({
+ cookieHeader: "thor=masked;",
+ reviewsTemplateUrl:
+ "https://api.m.jd.com/?functionId=getLegoWareDetailComment&body=%7B%22shopType%22:%220%22,%22sku%22:100068388533,%22commentNum%22:2,%22page%22:1,%22source%22:%22pc%22%7D"
+ });
+
+ const preview = await service.previewReviews("100068388535", {
+ commentCount: 2,
+ page: 1,
+ maxPages: 1
+ });
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(browserPreviewReviews).toHaveBeenCalledWith("100068388535", {
+ commentCount: 2,
+ page: 1,
+ maxPages: 1
+ });
+ expect(preview.reviews.comments).toHaveLength(1);
+ });
+
it("rejects a detail template that still resolves to another sku", async () => {
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise>(async () =>
buildResponse({
diff --git a/apps/api/src/platforms/jd/live-session.ts b/apps/api/src/platforms/jd/live-session.ts
index 40db654..0f62e34 100644
--- a/apps/api/src/platforms/jd/live-session.ts
+++ b/apps/api/src/platforms/jd/live-session.ts
@@ -5,6 +5,7 @@ import {
parseJdSearchHtml
} from "./parsers";
import type {
+ JdBrowserPreviewProvider,
JdDetailPreviewResult,
JdLiveService,
JdProductPreviewResult,
@@ -38,6 +39,7 @@ const REVIEW_PAGE_BODY_KEYS = [
"commentPage"
];
const REVIEW_PAGE_SIZE_BODY_KEYS = ["commentNum", "pageSize", "pageLimit"];
+const SEARCH_QUERY_BODY_KEYS = ["keyword", "wq", "word", "keyWord", "query"];
const SEARCH_FUNCTION_ID = "pc_search_searchWare";
const DETAIL_FUNCTION_ID = "pc_detailpage_wareBusiness";
const REVIEWS_FUNCTION_ID = "getLegoWareDetailComment";
@@ -81,6 +83,9 @@ type StoredJdLiveSession = {
reviewsTemplateUrl?: string | undefined;
searchReferer?: string | undefined;
detailReferer?: string | undefined;
+ searchRequestHeaders?: Record | undefined;
+ detailRequestHeaders?: Record | undefined;
+ reviewsRequestHeaders?: Record | undefined;
};
class JdLiveError extends Error {
@@ -158,20 +163,52 @@ function buildTemplateSummary(templateUrl: string | undefined): JdTemplateSummar
};
}
-function templateMatchesQuery(
- templateUrl: string | undefined,
- query: string
-): boolean {
- if (!templateUrl) {
- return false;
+function normalizeCapturedHeaders(
+ input: Record | undefined
+): Record | undefined {
+ if (!input) {
+ return undefined;
}
- try {
- const templateKeyword = new URL(templateUrl).searchParams.get("keyword");
- return Boolean(templateKeyword && templateKeyword === query);
- } catch {
- return false;
+ const isValidReplayHeaderName = (key: string) => /^[!#$%&'*+.^_`|~0-9a-z-]+$/.test(key);
+ const entries = Object.entries(input)
+ .map(([key, value]) => [key.trim().toLowerCase(), value.trim()] as const)
+ .filter(([key, value]) => {
+ return (
+ key.length > 0 &&
+ value.length > 0 &&
+ !key.startsWith(":") &&
+ isValidReplayHeaderName(key) &&
+ key !== "cookie" &&
+ key !== "referer" &&
+ key !== "host" &&
+ key !== "content-length" &&
+ key !== "connection" &&
+ key !== "accept-encoding"
+ );
+ });
+
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
+}
+
+function buildReplayHeaders(
+ capturedHeaders: Record | undefined,
+ required: {
+ accept: string;
+ cookie: string;
+ referer: string;
+ userAgent: string;
}
+): Record {
+ const normalizedCapturedHeaders = normalizeCapturedHeaders(capturedHeaders);
+
+ return {
+ ...(normalizedCapturedHeaders ?? {}),
+ ...(normalizedCapturedHeaders?.accept ? {} : { Accept: required.accept }),
+ Cookie: required.cookie,
+ Referer: required.referer,
+ "User-Agent": required.userAgent
+ };
}
function coerceBodyValue(existing: unknown, nextValue: number | string): number | string {
@@ -316,6 +353,78 @@ function buildDetailRequestUrl(templateUrl: string, skuId: string): string {
}));
}
+function buildSearchApiRequestUrl(templateUrl: string, query: string): string {
+ let requestUrl = new URL(templateUrl);
+ requestUrl.searchParams.set("keyword", query);
+
+ if (requestUrl.searchParams.has("wq")) {
+ requestUrl.searchParams.set("wq", query);
+ }
+
+ if (!requestUrl.searchParams.has("enc")) {
+ requestUrl.searchParams.set("enc", "utf-8");
+ }
+
+ if (readQueryBody(requestUrl)) {
+ requestUrl = new URL(
+ withUpdatedQueryBody(requestUrl, (currentBody) => {
+ const nextBody: Record = {
+ ...currentBody
+ };
+ let updated = false;
+
+ for (const key of SEARCH_QUERY_BODY_KEYS) {
+ if (!(key in currentBody)) {
+ continue;
+ }
+
+ nextBody[key] = coerceBodyValue(currentBody[key], query);
+ updated = true;
+ }
+
+ if (!updated) {
+ nextBody.keyword = query;
+ }
+
+ return nextBody;
+ })
+ );
+ }
+
+ return requestUrl.toString();
+}
+
+function buildSearchPageUrlFromTemplate(templateUrl: string, query: string): string {
+ const template = new URL(templateUrl);
+ const searchUrl = new URL("https://search.jd.com/Search");
+
+ for (const [key, value] of template.searchParams.entries()) {
+ if (
+ key === "functionId" ||
+ key === "appid" ||
+ key === "client" ||
+ key === "clientVersion" ||
+ key === "t" ||
+ key === "loginType" ||
+ key === "body"
+ ) {
+ continue;
+ }
+
+ searchUrl.searchParams.set(key, value);
+ }
+
+ searchUrl.searchParams.set("keyword", query);
+ if (searchUrl.searchParams.has("wq")) {
+ searchUrl.searchParams.set("wq", query);
+ }
+ if (!searchUrl.searchParams.has("enc")) {
+ searchUrl.searchParams.set("enc", "utf-8");
+ }
+
+ return searchUrl.toString();
+}
+
function buildReviewsRequestUrl(
templateUrl: string,
skuId: string,
@@ -472,6 +581,11 @@ export function getJdLiveErrorCode(error: unknown): JdLiveErrorCode | undefined
export class JdLiveSessionService implements JdLiveService {
private session: StoredJdLiveSession | null = readEnvSession();
+ private browserPreviewProvider: JdBrowserPreviewProvider | null = null;
+
+ setBrowserPreviewProvider(provider: JdBrowserPreviewProvider | null): void {
+ this.browserPreviewProvider = provider;
+ }
getSessionSummary(): JdLiveSessionSummary {
return {
@@ -491,6 +605,9 @@ export class JdLiveSessionService implements JdLiveService {
const reviewsTemplateUrl = input.reviewsTemplateUrl?.trim();
const searchReferer = input.searchReferer?.trim();
const detailReferer = input.detailReferer?.trim();
+ const searchRequestHeaders = normalizeCapturedHeaders(input.searchRequestHeaders);
+ const detailRequestHeaders = normalizeCapturedHeaders(input.detailRequestHeaders);
+ const reviewsRequestHeaders = normalizeCapturedHeaders(input.reviewsRequestHeaders);
validateImportedTemplates({
searchApiTemplateUrl,
@@ -506,7 +623,10 @@ export class JdLiveSessionService implements JdLiveService {
...(detailTemplateUrl ? { detailTemplateUrl } : {}),
...(reviewsTemplateUrl ? { reviewsTemplateUrl } : {}),
...(searchReferer ? { searchReferer } : {}),
- ...(detailReferer ? { detailReferer } : {})
+ ...(detailReferer ? { detailReferer } : {}),
+ ...(searchRequestHeaders ? { searchRequestHeaders } : {}),
+ ...(detailRequestHeaders ? { detailRequestHeaders } : {}),
+ ...(reviewsRequestHeaders ? { reviewsRequestHeaders } : {})
};
return this.getSessionSummary();
@@ -526,77 +646,75 @@ export class JdLiveSessionService implements JdLiveService {
throw new JdLiveError("query is required for JD live search preview.", 400, "BAD_REQUEST");
}
- const resolvedMode =
- mode ??
- (templateMatchesQuery(session.searchApiTemplateUrl, normalizedQuery) ? "api" : "html");
+ const resolvedMode = mode ?? (session.searchApiTemplateUrl ? "api" : "html");
- if (resolvedMode === "api") {
- if (!session.searchApiTemplateUrl) {
- throw new JdLiveError(
- "JD search API template is missing. Import a fresh search request URL or use mode=html.",
- 409,
- "TEMPLATE_MISSING"
- );
- }
-
- const templateUrl = new URL(session.searchApiTemplateUrl);
- const templateKeyword = templateUrl.searchParams.get("keyword");
- if (templateKeyword && templateKeyword !== normalizedQuery) {
- throw new JdLiveError(
- `Imported search API template is locked to query "${templateKeyword}". ` +
- "Capture a fresh request for the target query or use mode=html.",
- 409,
- "TEMPLATE_QUERY_LOCKED"
+ try {
+ if (resolvedMode === "api") {
+ if (!session.searchApiTemplateUrl) {
+ throw new JdLiveError(
+ "JD search API template is missing. Import a fresh search request URL or use mode=html.",
+ 409,
+ "TEMPLATE_MISSING"
+ );
+ }
+
+ const requestUrl = buildSearchApiRequestUrl(session.searchApiTemplateUrl, normalizedQuery);
+ const referer = buildSearchPageUrlFromTemplate(
+ session.searchApiTemplateUrl,
+ normalizedQuery
);
+ const response = await fetchTextOrThrow(
+ requestUrl,
+ {
+ headers: buildReplayHeaders(session.searchRequestHeaders, {
+ accept: "application/json, text/plain, */*",
+ cookie: session.cookieHeader,
+ referer,
+ userAgent: session.userAgent
+ })
+ },
+ "JD search session appears invalid. Re-login in the browser and re-import the cookie/header."
+ );
+
+ const candidates = parseJdSearchApiResponse(normalizedQuery, { text: response.text });
+ return {
+ query: normalizedQuery,
+ source: "api",
+ candidateCount: candidates.length,
+ candidates
+ };
}
+ const searchUrl = `https://search.jd.com/Search?keyword=${encodeURIComponent(normalizedQuery)}`;
const response = await fetchTextOrThrow(
- session.searchApiTemplateUrl,
+ searchUrl,
{
- headers: {
- Accept: "application/json, text/plain, */*",
- Cookie: session.cookieHeader,
- Referer:
- session.searchReferer ??
- `https://search.jd.com/Search?keyword=${encodeURIComponent(normalizedQuery)}`,
- "User-Agent": session.userAgent
- }
+ headers: buildReplayHeaders(session.searchRequestHeaders, {
+ accept:
+ "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
+ cookie: session.cookieHeader,
+ referer: session.searchReferer ?? "https://www.jd.com/",
+ userAgent: session.userAgent
+ })
},
"JD search session appears invalid. Re-login in the browser and re-import the cookie/header."
);
- const candidates = parseJdSearchApiResponse(normalizedQuery, { text: response.text });
+ const candidates = parseJdSearchHtml(normalizedQuery, response.text);
return {
query: normalizedQuery,
- source: "api",
+ source: "html",
candidateCount: candidates.length,
candidates
};
+ } catch (error) {
+ const browserPreview = await this.tryBrowserSearchFallback(error, normalizedQuery, resolvedMode);
+ if (browserPreview) {
+ return browserPreview;
+ }
+
+ throw error;
}
-
- const searchUrl = `https://search.jd.com/Search?keyword=${encodeURIComponent(normalizedQuery)}`;
- const response = await fetchTextOrThrow(
- searchUrl,
- {
- headers: {
- Accept:
- "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
- "Accept-Language": "zh-CN,zh;q=0.9",
- Cookie: session.cookieHeader,
- Referer: session.searchReferer ?? "https://www.jd.com/",
- "User-Agent": session.userAgent
- }
- },
- "JD search session appears invalid. Re-login in the browser and re-import the cookie/header."
- );
-
- const candidates = parseJdSearchHtml(normalizedQuery, response.text);
- return {
- query: normalizedQuery,
- source: "html",
- candidateCount: candidates.length,
- candidates
- };
}
async previewDetail(skuId: string): Promise {
@@ -616,33 +734,42 @@ export class JdLiveSessionService implements JdLiveService {
const requestUrl = buildDetailRequestUrl(session.detailTemplateUrl, normalizedSkuId);
- const response = await fetchTextOrThrow(
- requestUrl,
- {
- headers: {
- Accept: "application/json, text/plain, */*",
- Cookie: session.cookieHeader,
- Referer: session.detailReferer ?? `https://item.jd.com/${normalizedSkuId}.html`,
- "User-Agent": session.userAgent
- }
- },
- "JD detail session appears invalid. Re-login in the browser and re-import the cookie/header."
- );
-
- const detail = parseJdDetailApiResponse(normalizedSkuId, { text: response.text });
- if (detail.skuId !== normalizedSkuId) {
- throw new JdLiveError(
- `JD detail template appears bound to another sku (${detail.skuId}). Capture a fresh pc_detailpage_wareBusiness request first.`,
- 409,
- "TEMPLATE_EXPIRED"
+ try {
+ const response = await fetchTextOrThrow(
+ requestUrl,
+ {
+ headers: buildReplayHeaders(session.detailRequestHeaders, {
+ accept: "application/json, text/plain, */*",
+ cookie: session.cookieHeader,
+ referer: session.detailReferer ?? `https://item.jd.com/${normalizedSkuId}.html`,
+ userAgent: session.userAgent
+ })
+ },
+ "JD detail session appears invalid. Re-login in the browser and re-import the cookie/header."
);
- }
- return {
- skuId: normalizedSkuId,
- source: "api",
- detail
- };
+ const detail = parseJdDetailApiResponse(normalizedSkuId, { text: response.text });
+ if (detail.skuId !== normalizedSkuId) {
+ throw new JdLiveError(
+ `JD detail template appears bound to another sku (${detail.skuId}). Capture a fresh pc_detailpage_wareBusiness request first.`,
+ 409,
+ "TEMPLATE_EXPIRED"
+ );
+ }
+
+ return {
+ skuId: normalizedSkuId,
+ source: "api",
+ detail
+ };
+ } catch (error) {
+ const browserPreview = await this.tryBrowserDetailFallback(error, normalizedSkuId);
+ if (browserPreview) {
+ return browserPreview;
+ }
+
+ throw error;
+ }
}
async previewReviews(
@@ -668,64 +795,77 @@ export class JdLiveSessionService implements JdLiveService {
let pageKey: string | undefined;
const seenCommentIds = new Set();
- for (let pageOffset = 0; pageOffset < resolvedOptions.maxPages; pageOffset += 1) {
- const currentPage = resolvedOptions.page + pageOffset;
- const request = buildReviewsRequestUrl(
- session.reviewsTemplateUrl,
- normalizedSkuId,
- resolvedOptions,
- currentPage
- );
- pageKey ??= request.pageKey;
+ try {
+ for (let pageOffset = 0; pageOffset < resolvedOptions.maxPages; pageOffset += 1) {
+ const currentPage = resolvedOptions.page + pageOffset;
+ const request = buildReviewsRequestUrl(
+ session.reviewsTemplateUrl,
+ normalizedSkuId,
+ resolvedOptions,
+ currentPage
+ );
+ pageKey ??= request.pageKey;
- const response = await fetchTextOrThrow(
- request.url,
- {
- headers: {
- Accept: "application/json, text/plain, */*",
- Cookie: session.cookieHeader,
- Referer: session.detailReferer ?? `https://item.jd.com/${normalizedSkuId}.html`,
- "User-Agent": session.userAgent
+ const response = await fetchTextOrThrow(
+ request.url,
+ {
+ headers: buildReplayHeaders(session.reviewsRequestHeaders, {
+ accept: "application/json, text/plain, */*",
+ cookie: session.cookieHeader,
+ referer: session.detailReferer ?? `https://item.jd.com/${normalizedSkuId}.html`,
+ userAgent: session.userAgent
+ })
+ },
+ "JD reviews session appears invalid. Re-login in the browser and re-import the cookie/header."
+ );
+
+ const parsedPage = parseJdReviewsApiResponse(normalizedSkuId, { text: response.text });
+ reviewPages.push(parsedPage);
+ let newCommentCount = 0;
+ for (const comment of parsedPage.comments) {
+ if (seenCommentIds.has(comment.id)) {
+ continue;
}
- },
- "JD reviews session appears invalid. Re-login in the browser and re-import the cookie/header."
- );
- const parsedPage = parseJdReviewsApiResponse(normalizedSkuId, { text: response.text });
- reviewPages.push(parsedPage);
- let newCommentCount = 0;
- for (const comment of parsedPage.comments) {
- if (seenCommentIds.has(comment.id)) {
- continue;
+ seenCommentIds.add(comment.id);
+ newCommentCount += 1;
}
- seenCommentIds.add(comment.id);
- newCommentCount += 1;
+ if (
+ parsedPage.comments.length === 0 ||
+ newCommentCount === 0 ||
+ parsedPage.comments.length < resolvedOptions.commentCount
+ ) {
+ break;
+ }
}
- if (
- parsedPage.comments.length === 0 ||
- newCommentCount === 0 ||
- parsedPage.comments.length < resolvedOptions.commentCount
- ) {
- break;
+ const pagination: JdReviewsPaginationSummary = {
+ requestedPage: resolvedOptions.page,
+ requestedCommentCount: resolvedOptions.commentCount,
+ maxPages: resolvedOptions.maxPages,
+ pagesFetched: reviewPages.length,
+ ...(pageKey ? { pageKey } : {})
+ };
+
+ return {
+ skuId: normalizedSkuId,
+ source: "api",
+ pagination,
+ reviews: mergeReviewPages(normalizedSkuId, reviewPages)
+ };
+ } catch (error) {
+ const browserPreview = await this.tryBrowserReviewsFallback(
+ error,
+ normalizedSkuId,
+ options
+ );
+ if (browserPreview) {
+ return browserPreview;
}
+
+ throw error;
}
-
- const pagination: JdReviewsPaginationSummary = {
- requestedPage: resolvedOptions.page,
- requestedCommentCount: resolvedOptions.commentCount,
- maxPages: resolvedOptions.maxPages,
- pagesFetched: reviewPages.length,
- ...(pageKey ? { pageKey } : {})
- };
-
- return {
- skuId: normalizedSkuId,
- source: "api",
- pagination,
- reviews: mergeReviewPages(normalizedSkuId, reviewPages)
- };
}
async previewProduct(
@@ -757,4 +897,47 @@ export class JdLiveSessionService implements JdLiveService {
return this.session;
}
+
+ private shouldUseBrowserFallback(error: unknown): boolean {
+ const code = getJdLiveErrorCode(error);
+ return Boolean(
+ this.browserPreviewProvider &&
+ (code === "RISK_BLOCKED" || code === "NETWORK_ERROR" || code === "HTTP_ERROR")
+ );
+ }
+
+ private async tryBrowserSearchFallback(
+ error: unknown,
+ query: string,
+ mode: JdSearchMode
+ ): Promise {
+ if (!this.shouldUseBrowserFallback(error) || !this.browserPreviewProvider) {
+ return null;
+ }
+
+ return this.browserPreviewProvider.previewSearch(query, mode);
+ }
+
+ private async tryBrowserDetailFallback(
+ error: unknown,
+ skuId: string
+ ): Promise {
+ if (!this.shouldUseBrowserFallback(error) || !this.browserPreviewProvider) {
+ return null;
+ }
+
+ return this.browserPreviewProvider.previewDetail(skuId);
+ }
+
+ private async tryBrowserReviewsFallback(
+ error: unknown,
+ skuId: string,
+ options?: number | JdReviewsPreviewOptions
+ ): Promise {
+ if (!this.shouldUseBrowserFallback(error) || !this.browserPreviewProvider) {
+ return null;
+ }
+
+ return this.browserPreviewProvider.previewReviews(skuId, options);
+ }
}
diff --git a/apps/api/src/platforms/jd/parsers.test.ts b/apps/api/src/platforms/jd/parsers.test.ts
index 25d8543..9fc4c00 100644
--- a/apps/api/src/platforms/jd/parsers.test.ts
+++ b/apps/api/src/platforms/jd/parsers.test.ts
@@ -148,4 +148,62 @@ describe("JD parsers", () => {
userLevelName: "PLUS会员"
});
});
+ it("falls back to lastCommentInfoList when commentInfoList is missing", () => {
+ const reviews = parseJdReviewsApiResponse("100068388533", {
+ allCnt: "3",
+ goodRate: "98%",
+ lastCommentInfoList: [
+ {
+ commentId: "comment-last-1",
+ commentData: "fallback review",
+ commentDate: "2026-04-07 19:00:05",
+ userNickName: "fallback-user"
+ }
+ ]
+ });
+
+ expect(reviews.comments).toEqual([
+ expect.objectContaining({
+ id: "comment-last-1",
+ content: "fallback review",
+ creationTime: "2026-04-07 19:00:05",
+ userLevelName: "fallback-user"
+ })
+ ]);
+ });
+
+ it("unwraps nested JD review payloads and commentInfo wrappers", () => {
+ const reviews = parseJdReviewsApiResponse("100068388533", {
+ code: 0,
+ data: {
+ allCnt: "12",
+ goodRate: "96%",
+ commentInfoList: [
+ {
+ commentInfo: {
+ commentId: "comment-nested-1",
+ content: "nested review",
+ commentScore: 4,
+ creationTime: "2026-04-07 20:12:33",
+ userNickName: "nested-user"
+ }
+ }
+ ]
+ }
+ });
+
+ expect(reviews).toMatchObject({
+ total: "12",
+ goodRate: "96%"
+ });
+ expect(reviews.comments).toEqual([
+ expect.objectContaining({
+ id: "comment-nested-1",
+ content: "nested review",
+ score: "4",
+ creationTime: "2026-04-07 20:12:33",
+ userLevelName: "nested-user"
+ })
+ ]);
+ });
});
diff --git a/apps/api/src/platforms/jd/parsers.ts b/apps/api/src/platforms/jd/parsers.ts
index 32d4604..16b1e59 100644
--- a/apps/api/src/platforms/jd/parsers.ts
+++ b/apps/api/src/platforms/jd/parsers.ts
@@ -30,6 +30,66 @@ function unwrapCapturedPayload(input: unknown): unknown {
}
}
+function collectNestedPayloads(
+ root: Record | null
+): Record[] {
+ if (!root) {
+ return [];
+ }
+
+ const queue: Record[] = [root];
+ const seen = new Set>();
+ const collected: Record[] = [];
+
+ while (queue.length > 0) {
+ const current = queue.shift();
+ if (!current || seen.has(current)) {
+ continue;
+ }
+
+ seen.add(current);
+ collected.push(current);
+
+ for (const nested of [
+ asRecord(current.data),
+ asRecord(current.result),
+ asRecord(current.bizData),
+ asRecord(current.commentInfo),
+ asRecord(current.module)
+ ]) {
+ if (nested && !seen.has(nested)) {
+ queue.push(nested);
+ }
+ }
+ }
+
+ return collected;
+}
+
+function hasReviewPayloadMarkers(payload: Record): boolean {
+ return (
+ "commentInfoList" in payload ||
+ "lastCommentInfoList" in payload ||
+ "tagStatisticsinfoList" in payload ||
+ "allCnt" in payload ||
+ "allCntStr" in payload ||
+ "goodCnt" in payload ||
+ "goodRate" in payload ||
+ "goodRateShow" in payload ||
+ "pictureCnt" in payload ||
+ "showPicCnt" in payload
+ );
+}
+
+function resolveJdReviewsPayload(input: unknown): Record | null {
+ const root = asRecord(unwrapCapturedPayload(input));
+ if (!root) {
+ return null;
+ }
+
+ return collectNestedPayloads(root).find((payload) => hasReviewPayloadMarkers(payload)) ?? root;
+}
+
function extractSpecLabel(title: string): string {
const storageMatch = title.match(/\b\d+(?:GB|TB)\b/i);
if (storageMatch) {
@@ -313,11 +373,32 @@ function parseReviewTag(input: unknown): JdReviewTagSnapshot | null {
}
function parseReviewComment(input: unknown): JdReviewCommentSnapshot | null {
- const comment = asRecord(input);
+ const wrapper = asRecord(input);
+ const comment = asRecord(wrapper?.commentInfo) ?? wrapper;
const content = normalizeInlineText(
- firstString(comment?.content, comment?.commentData, comment?.tagCommentContent)
+ firstString(
+ comment?.content,
+ comment?.commentData,
+ comment?.tagCommentContent,
+ comment?.commentContent,
+ comment?.commentText,
+ comment?.text,
+ wrapper?.content,
+ wrapper?.commentData,
+ wrapper?.tagCommentContent,
+ wrapper?.commentContent,
+ wrapper?.commentText,
+ wrapper?.text
+ )
+ );
+ const id = firstString(
+ comment?.id,
+ comment?.commentId,
+ comment?.commentUuid,
+ wrapper?.id,
+ wrapper?.commentId,
+ wrapper?.commentUuid
);
- const id = firstString(comment?.id, comment?.commentId);
if (!content || !id) {
return null;
@@ -326,14 +407,37 @@ function parseReviewComment(input: unknown): JdReviewCommentSnapshot | null {
return {
id,
content,
- score: firstString(comment?.score, comment?.commentScore),
+ score: firstString(
+ comment?.score,
+ comment?.commentScore,
+ comment?.scoring,
+ wrapper?.score,
+ wrapper?.commentScore,
+ wrapper?.scoring
+ ),
creationTime: firstString(
comment?.creationTime,
comment?.creationDate,
- comment?.commentDate
+ comment?.commentDate,
+ comment?.time,
+ wrapper?.creationTime,
+ wrapper?.creationDate,
+ wrapper?.commentDate,
+ wrapper?.time
),
userLevelName: normalizeInlineText(
- firstString(comment?.userLevelName, comment?.userClientShow)
+ firstString(
+ comment?.userLevelName,
+ comment?.userClientShow,
+ comment?.userLevel,
+ comment?.userNickName,
+ comment?.userName,
+ wrapper?.userLevelName,
+ wrapper?.userClientShow,
+ wrapper?.userLevel,
+ wrapper?.userNickName,
+ wrapper?.userName
+ )
)
};
}
@@ -342,13 +446,20 @@ export function parseJdReviewsApiResponse(
skuId: string,
input: unknown
): JdProductReviewsSnapshot {
- const payload = asRecord(unwrapCapturedPayload(input));
+ const payload = resolveJdReviewsPayload(input);
const tags = asArray(payload?.tagStatisticsinfoList)
.map((tag) => parseReviewTag(tag))
.filter((tag): tag is JdReviewTagSnapshot => Boolean(tag));
- const comments = asArray(payload?.commentInfoList)
- .map((comment) => parseReviewComment(comment))
- .filter((comment): comment is JdReviewCommentSnapshot => Boolean(comment));
+ const commentsById = new Map();
+ for (const comment of [
+ ...asArray(payload?.commentInfoList),
+ ...asArray(payload?.lastCommentInfoList)
+ ]) {
+ const parsedComment = parseReviewComment(comment);
+ if (parsedComment && !commentsById.has(parsedComment.id)) {
+ commentsById.set(parsedComment.id, parsedComment);
+ }
+ }
return {
skuId,
@@ -356,6 +467,6 @@ export function parseJdReviewsApiResponse(
goodRate: firstString(payload?.goodRate, payload?.goodRateShow),
pictureCount: firstString(payload?.pictureCnt, payload?.showPicCnt),
tags,
- comments
+ comments: Array.from(commentsById.values())
};
}
diff --git a/apps/api/src/platforms/jd/session-manager.test.ts b/apps/api/src/platforms/jd/session-manager.test.ts
index 7af700f..96473fd 100644
--- a/apps/api/src/platforms/jd/session-manager.test.ts
+++ b/apps/api/src/platforms/jd/session-manager.test.ts
@@ -198,7 +198,7 @@ describe("JdSessionManagerService", () => {
vi.unstubAllEnvs();
});
- it("accepts manual session imports and marks the manager healthy", async () => {
+ it("accepts manual session imports and waits for a health check before marking ready", async () => {
const onSessionReady = vi.fn();
const manager = new JdSessionManagerService(createJdLiveServiceStub(), {
onSessionReady
@@ -213,8 +213,8 @@ describe("JdSessionManagerService", () => {
"https://api.m.jd.com/?functionId=getLegoWareDetailComment&body=%7B%22sku%22:100068388533%7D"
});
- expect(onSessionReady).toHaveBeenCalledOnce();
- expect(state.status).toBe("healthy");
+ expect(onSessionReady).not.toHaveBeenCalled();
+ expect(state.status).toBe("degraded");
expect(state.pendingManualAction).toBe(false);
expect(state.session).toMatchObject({
configured: true,
@@ -294,7 +294,7 @@ describe("JdSessionManagerService", () => {
expect(result.recovered).toBe(false);
expect(result.state.status).toBe("healthy");
expect(onSessionReady).toHaveBeenCalledOnce();
- expect(previewSearch).toHaveBeenCalledWith("iPhone 15", "html");
+ expect(previewSearch).toHaveBeenCalledWith("iPhone 15", "api");
expect(previewDetail).toHaveBeenCalledWith("100068388533");
expect(previewReviews).toHaveBeenCalledWith("100068388533", {
commentCount: 1,
diff --git a/apps/api/src/platforms/jd/session-manager.ts b/apps/api/src/platforms/jd/session-manager.ts
index 0bad1c4..8d847ee 100644
--- a/apps/api/src/platforms/jd/session-manager.ts
+++ b/apps/api/src/platforms/jd/session-manager.ts
@@ -276,15 +276,14 @@ export class JdSessionManagerService implements JdSessionManager {
source = "ops-manual"
): Promise {
this.liveService.importSession(input);
- this.callbacks.onSessionReady?.();
this.state = {
...this.state,
- status: "healthy",
+ status: "degraded",
pendingManualAction: false,
- note: `京东会话已通过 ${source} 更新,等待下一轮健康检查。`,
- publicNote: "京东会话由运维后台维护,当前可用。",
- lastRecoveredAt: nowIso(),
- lastHealthyAt: nowIso(),
+ note: `JD session imported via ${source}; waiting for health check.`,
+ publicNote: "JD session was updated and is being verified.",
+ lastRecoveredAt: undefined,
+ lastHealthyAt: undefined,
lastFailureCode: undefined,
lastFailureMessage: undefined,
session: this.liveService.getSessionSummary()
@@ -556,7 +555,10 @@ export class JdSessionManagerService implements JdSessionManager {
throw new Error("京东会话缺少可校验的详情/评论模板。");
}
- await this.liveService.previewSearch(this.config.heartbeatQuery, "html");
+ await this.liveService.previewSearch(
+ this.config.heartbeatQuery,
+ summary.searchApiTemplate.available ? "api" : "html"
+ );
await this.liveService.previewDetail(detailSkuId);
await this.liveService.previewReviews(reviewsSkuId, {
commentCount: 1,
diff --git a/apps/api/src/platforms/jd/types.ts b/apps/api/src/platforms/jd/types.ts
index e50d33d..0c9f9e4 100644
--- a/apps/api/src/platforms/jd/types.ts
+++ b/apps/api/src/platforms/jd/types.ts
@@ -15,6 +15,9 @@ export interface JdLiveSessionInput {
reviewsTemplateUrl?: string | undefined;
searchReferer?: string | undefined;
detailReferer?: string | undefined;
+ searchRequestHeaders?: Record | undefined;
+ detailRequestHeaders?: Record | undefined;
+ reviewsRequestHeaders?: Record | undefined;
}
export interface JdLiveSessionSummary {
@@ -157,6 +160,15 @@ export interface JdProductPreviewResult {
reviews: JdProductReviewsSnapshot;
}
+export interface JdBrowserPreviewProvider {
+ previewSearch(query: string, mode?: JdSearchMode): Promise;
+ previewDetail(skuId: string): Promise;
+ previewReviews(
+ skuId: string,
+ options?: number | JdReviewsPreviewOptions
+ ): Promise;
+}
+
export interface JdLiveService {
getSessionSummary(): JdLiveSessionSummary;
importSession(input: JdLiveSessionInput): JdLiveSessionSummary;
diff --git a/apps/api/src/server.dual-platform-live.test.ts b/apps/api/src/server.dual-platform-live.test.ts
index 93eee34..a4d586d 100644
--- a/apps/api/src/server.dual-platform-live.test.ts
+++ b/apps/api/src/server.dual-platform-live.test.ts
@@ -85,9 +85,18 @@ function createJdLiveServiceStub(overrides: Partial = {}): JdLive
importedAt: "2026-04-03T10:00:00.000Z",
hasCookie: true,
userAgent: input.userAgent ?? "stub-user-agent",
- searchApiTemplate: { available: Boolean(input.searchApiTemplateUrl) },
- detailTemplate: { available: Boolean(input.detailTemplateUrl) },
- reviewsTemplate: { available: Boolean(input.reviewsTemplateUrl) }
+ searchApiTemplate: {
+ available: Boolean(input.searchApiTemplateUrl),
+ skuId: input.searchApiTemplateUrl ? "100068388533" : undefined
+ },
+ detailTemplate: {
+ available: Boolean(input.detailTemplateUrl),
+ skuId: input.detailTemplateUrl ? "100068388533" : undefined
+ },
+ reviewsTemplate: {
+ available: Boolean(input.reviewsTemplateUrl),
+ skuId: input.reviewsTemplateUrl ? "100068388533" : undefined
+ }
};
return summary;
},
diff --git a/apps/api/src/server.jd-live.test.ts b/apps/api/src/server.jd-live.test.ts
index 930d9cf..9f26c11 100644
--- a/apps/api/src/server.jd-live.test.ts
+++ b/apps/api/src/server.jd-live.test.ts
@@ -36,9 +36,18 @@ function createJdLiveServiceStub(
importedAt: "2026-04-02T12:00:00.000Z",
hasCookie: true,
userAgent: input.userAgent ?? "stub-user-agent",
- searchApiTemplate: { available: Boolean(input.searchApiTemplateUrl) },
- detailTemplate: { available: Boolean(input.detailTemplateUrl) },
- reviewsTemplate: { available: Boolean(input.reviewsTemplateUrl) }
+ searchApiTemplate: {
+ available: Boolean(input.searchApiTemplateUrl),
+ skuId: input.searchApiTemplateUrl ? "100068388533" : undefined
+ },
+ detailTemplate: {
+ available: Boolean(input.detailTemplateUrl),
+ skuId: input.detailTemplateUrl ? "100068388533" : undefined
+ },
+ reviewsTemplate: {
+ available: Boolean(input.reviewsTemplateUrl),
+ skuId: input.reviewsTemplateUrl ? "100068388533" : undefined
+ }
};
return summary;
},
diff --git a/apps/api/src/server.test.ts b/apps/api/src/server.test.ts
index be50f71..5666999 100644
--- a/apps/api/src/server.test.ts
+++ b/apps/api/src/server.test.ts
@@ -12,6 +12,7 @@ import type {
JdReviewsPreviewResult,
JdSearchPreviewResult
} from "./platforms/jd/types";
+import type { OpsQrLoginController, OpsQrLoginState } from "./ops/qr-login";
import { createServer } from "./server";
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -63,6 +64,31 @@ async function createCompletedTask(app: ReturnType, query:
return confirmResponse.json().task;
}
+async function waitForTask(
+ app: ReturnType,
+ taskId: string,
+ predicate: (task: Awaited>) => boolean,
+ timeoutMs = 1000
+) {
+ const deadline = Date.now() + timeoutMs;
+
+ while (Date.now() < deadline) {
+ const response = await app.inject({
+ method: "GET",
+ url: `/api/tasks/${taskId}`
+ });
+ const task = response.json().task as Awaited>;
+
+ if (predicate(task)) {
+ return task;
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ }
+
+ throw new Error(`Task ${taskId} did not reach the expected state within ${timeoutMs}ms.`);
+}
+
function backdateStoredTask(storagePath: string, taskId: string, ageDays: number) {
const timestamp = new Date(Date.now() - ageDays * DAY_MS).toISOString();
const snapshot = JSON.parse(readFileSync(storagePath, "utf8")) as {
@@ -168,9 +194,18 @@ function createJdLiveServiceStub(
importedAt: "2026-04-02T12:00:00.000Z",
hasCookie: true,
userAgent: input.userAgent ?? "stub-user-agent",
- searchApiTemplate: { available: Boolean(input.searchApiTemplateUrl) },
- detailTemplate: { available: Boolean(input.detailTemplateUrl) },
- reviewsTemplate: { available: Boolean(input.reviewsTemplateUrl) }
+ searchApiTemplate: {
+ available: Boolean(input.searchApiTemplateUrl),
+ skuId: input.searchApiTemplateUrl ? "100068388533" : undefined
+ },
+ detailTemplate: {
+ available: Boolean(input.detailTemplateUrl),
+ skuId: input.detailTemplateUrl ? "100068388533" : undefined
+ },
+ reviewsTemplate: {
+ available: Boolean(input.reviewsTemplateUrl),
+ skuId: input.reviewsTemplateUrl ? "100068388533" : undefined
+ }
};
return summary;
},
@@ -305,6 +340,57 @@ function createJdLiveServiceStub(
};
}
+function createQrLoginStub(platform: "jd" | "tmall"): OpsQrLoginController {
+ let state: OpsQrLoginState = {
+ platform,
+ status: "idle",
+ note: "尚未启动扫码登录。",
+ sessionImported: false
+ };
+
+ return {
+ getState() {
+ return state;
+ },
+ async start() {
+ state = {
+ ...state,
+ status: "waiting_for_scan",
+ note: "二维码已生成,请扫码。",
+ targetId: platform === "jd" ? "100068388533" : "934454505228",
+ qrImageDataUrl: "data:image/png;base64,stub",
+ startedAt: "2026-04-07T12:00:00.000Z",
+ updatedAt: "2026-04-07T12:00:00.000Z"
+ };
+ return state;
+ },
+ async cancel() {
+ state = {
+ ...state,
+ status: "cancelled",
+ note: "扫码登录已取消。",
+ completedAt: "2026-04-07T12:10:00.000Z",
+ updatedAt: "2026-04-07T12:10:00.000Z"
+ };
+ return state;
+ },
+ async resumeManualRecovery() {
+ state = {
+ ...state,
+ status: "completed",
+ note: "人工恢复完成。",
+ completedAt: "2026-04-07T12:11:00.000Z",
+ updatedAt: "2026-04-07T12:11:00.000Z",
+ sessionImported: true
+ };
+ return state;
+ },
+ async shutdown() {
+ return;
+ }
+ };
+}
+
describe("API server", () => {
it("returns platform readiness with jd blocked by default", async () => {
const app = createServer();
@@ -331,6 +417,79 @@ describe("API server", () => {
await app.close();
});
+ it("exposes the JD qr-login state and can start the flow", async () => {
+ const app = createServer({
+ jdQrLoginService: createQrLoginStub("jd")
+ });
+ await app.ready();
+
+ const initialResponse = await app.inject({
+ method: "GET",
+ url: "/api/ops/jd/session-manager/qr-login"
+ });
+ expect(initialResponse.statusCode).toBe(200);
+ expect(initialResponse.json().qrLogin).toMatchObject({
+ platform: "jd",
+ status: "idle"
+ });
+
+ const startResponse = await app.inject({
+ method: "POST",
+ url: "/api/ops/jd/session-manager/qr-login/start"
+ });
+ expect(startResponse.statusCode).toBe(200);
+ expect(startResponse.json().qrLogin).toMatchObject({
+ platform: "jd",
+ status: "waiting_for_scan",
+ targetId: "100068388533"
+ });
+
+ await app.close();
+ });
+
+ it("can cancel the Tmall qr-login flow", async () => {
+ const qrLoginService = createQrLoginStub("tmall");
+ await qrLoginService.start();
+ const app = createServer({
+ tmallQrLoginService: qrLoginService
+ });
+ await app.ready();
+
+ const cancelResponse = await app.inject({
+ method: "POST",
+ url: "/api/ops/tmall/session-manager/qr-login/cancel"
+ });
+ expect(cancelResponse.statusCode).toBe(200);
+ expect(cancelResponse.json().qrLogin).toMatchObject({
+ platform: "tmall",
+ status: "cancelled"
+ });
+
+ await app.close();
+ });
+
+ it("can resume the JD qr-login manual recovery flow", async () => {
+ const qrLoginService = createQrLoginStub("jd");
+ await qrLoginService.start();
+ const app = createServer({
+ jdQrLoginService: qrLoginService
+ });
+ await app.ready();
+
+ const resumeResponse = await app.inject({
+ method: "POST",
+ url: "/api/ops/jd/session-manager/qr-login/resume"
+ });
+ expect(resumeResponse.statusCode).toBe(200);
+ expect(resumeResponse.json().qrLogin).toMatchObject({
+ platform: "jd",
+ status: "completed",
+ sessionImported: true
+ });
+
+ await app.close();
+ });
+
it("exposes session state with a 24-hour expiry window after preparation", async () => {
const app = createServer();
await app.ready();
@@ -404,6 +563,47 @@ describe("API server", () => {
await app.close();
});
+ it("runs confirmed tasks in the background when the execution mode is background", async () => {
+ const app = createServer({ executionMode: "background" });
+ await app.ready();
+
+ const createdTask = await createTask(app, "Nintendo Switch 2");
+ const candidatesResponse = await app.inject({
+ method: "GET",
+ url: `/api/tasks/${createdTask.taskId}/candidates`
+ });
+ const firstCandidateId = candidatesResponse.json().candidates.tmall[0].candidateId;
+
+ const confirmResponse = await app.inject({
+ method: "POST",
+ url: `/api/tasks/${createdTask.taskId}/confirm`,
+ payload: {
+ selections: [
+ {
+ platform: "tmall",
+ candidateIds: [firstCandidateId]
+ }
+ ]
+ }
+ });
+
+ expect(confirmResponse.statusCode).toBe(200);
+ expect(confirmResponse.json().task.taskStatus).toBe("Running");
+ expect(confirmResponse.json().task.taskStage).toBe("session_check");
+ expect(confirmResponse.json().task.defaultReportVersion).toBeUndefined();
+
+ const completedTask = await waitForTask(
+ app,
+ createdTask.taskId,
+ (task) => task.taskStatus === "Completed"
+ );
+
+ expect(completedTask.defaultReportVersion).toBe(1);
+ expect(completedTask.reportVersions).toEqual([1]);
+
+ await app.close();
+ });
+
it("records strategy attempts and observability metrics for platform search", async () => {
const app = createServer();
await app.ready();
@@ -1239,7 +1439,6 @@ describe("API server", () => {
method: "GET",
url: `/api/tasks/${createdTask.taskId}/audit`
});
-
expect(auditResponse.statusCode).toBe(200);
expect(auditResponse.json().audit).toEqual(
expect.arrayContaining([
@@ -1277,6 +1476,61 @@ describe("API server", () => {
await app.close();
});
+ it("auto-resumes JD SearchBlocked tasks after the managed session passes health check", async () => {
+ const app = createServer({
+ jdLiveService: createJdLiveServiceStub()
+ });
+ await app.ready();
+
+ const createdTask = await createTask(app, "iPhone 15 Pro");
+
+ expect(
+ createdTask.platformRuns.find((run: { platform: string }) => run.platform === "jd")
+ ).toMatchObject({
+ platform: "jd",
+ status: "SearchBlocked"
+ });
+
+ const importResponse = await app.inject({
+ method: "POST",
+ url: "/api/ops/jd/session-manager/session",
+ payload: {
+ cookieHeader: "thor=masked; pin=masked;",
+ detailTemplateUrl:
+ "https://api.m.jd.com/?functionId=pc_detailpage_wareBusiness&body=%7B%22skuId%22:%22100068388533%22%7D",
+ reviewsTemplateUrl:
+ "https://api.m.jd.com/?functionId=getLegoWareDetailComment&body=%7B%22sku%22:100068388533%7D"
+ }
+ });
+
+ expect(importResponse.statusCode).toBe(200);
+
+ const recoveredTask = await waitForTask(
+ app,
+ createdTask.taskId,
+ (task) =>
+ task.platformRuns.some(
+ (run: { platform: string; status: string }) =>
+ run.platform === "jd" && run.status === "AwaitingSelection"
+ )
+ );
+
+ expect(
+ recoveredTask.platformRuns.find((run: { platform: string }) => run.platform === "jd")
+ ).toMatchObject({
+ platform: "jd",
+ status: "AwaitingSelection"
+ });
+
+ const candidatesResponse = await app.inject({
+ method: "GET",
+ url: `/api/tasks/${createdTask.taskId}/candidates`
+ });
+ expect(candidatesResponse.json().candidates.jd).toHaveLength(1);
+
+ await app.close();
+ });
+
it("publishes a new report version when a blocked platform recovers successfully", async () => {
const app = createServer();
await app.ready();
diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts
index 349685a..99ca82c 100644
--- a/apps/api/src/server.ts
+++ b/apps/api/src/server.ts
@@ -16,6 +16,11 @@ import type {
JdSessionManager,
JdSessionManagerAutoMode
} from "./platforms/jd/types";
+import {
+ JdOpsQrLoginService,
+ TmallOpsQrLoginService,
+ type OpsQrLoginController
+} from "./ops/qr-login";
import { TmallLiveSessionService, isTmallLiveError } from "./platforms/tmall/live-session";
import { TmallSessionManagerService } from "./platforms/tmall/session-manager";
import type {
@@ -30,8 +35,11 @@ export function createServer(
storagePath?: string;
jdLiveService?: JdLiveService;
jdSessionManager?: JdSessionManager;
+ jdQrLoginService?: OpsQrLoginController;
tmallLiveService?: TmallLiveService;
tmallSessionManager?: TmallSessionManager;
+ tmallQrLoginService?: OpsQrLoginController;
+ executionMode?: "synchronous" | "background";
} = {}
) {
const app = Fastify({ logger: false });
@@ -43,12 +51,17 @@ export function createServer(
(process.env.NODE_ENV === "test"
? undefined
: resolve(process.cwd(), ".data", "task-store.json"));
- const store = new InMemoryTaskStore({ storagePath, jdLiveService, tmallLiveService });
+ const store = new InMemoryTaskStore({
+ storagePath,
+ jdLiveService,
+ tmallLiveService,
+ executionMode: options.executionMode
+ });
const jdSessionManager =
options.jdSessionManager ??
new JdSessionManagerService(jdLiveService, {
onSessionReady: () => {
- store.preparePlatform("jd");
+ store.notifyManagedSessionReady("jd");
},
onSessionUnavailable: () => {
store.clearPlatformSession("jd");
@@ -64,11 +77,27 @@ export function createServer(
store.clearPlatformSession("tmall");
}
});
+ const jdQrLoginService =
+ options.jdQrLoginService ??
+ new JdOpsQrLoginService(jdSessionManager, {
+ resolveSearchQuery: () => jdSessionManager.getState().heartbeatQuery
+ });
+ const tmallQrLoginService =
+ options.tmallQrLoginService ??
+ new TmallOpsQrLoginService(tmallSessionManager, {
+ resolveTargetItemId: () => tmallSessionManager.getState().heartbeatItemId
+ });
+ if (
+ jdLiveService instanceof JdLiveSessionService &&
+ jdQrLoginService instanceof JdOpsQrLoginService
+ ) {
+ jdLiveService.setBrowserPreviewProvider(jdQrLoginService);
+ }
store.setJdSessionManager(jdSessionManager);
store.setTmallSessionManager(tmallSessionManager);
if (jdLiveService.getSessionSummary().configured && !store.getSession("jd").ready) {
- store.preparePlatform("jd");
+ store.notifyManagedSessionReady("jd");
}
if (tmallLiveService.getSessionSummary().configured && !store.getSession("tmall").ready) {
store.preparePlatform("tmall");
@@ -77,6 +106,8 @@ export function createServer(
app.addHook("onClose", async () => {
jdSessionManager.shutdown();
tmallSessionManager.shutdown();
+ await jdQrLoginService.shutdown();
+ await tmallQrLoginService.shutdown();
});
app.register(cors, { origin: true });
@@ -211,9 +242,13 @@ export function createServer(
};
}>("/api/ops/jd/session-manager/session", async (request, reply) => {
try {
- const manager = await jdSessionManager.importManualSession(request.body, "ops-manual");
+ await jdSessionManager.importManualSession(request.body, "ops-manual");
+ const result = await jdSessionManager.runHealthCheck("ops-manual");
reply.code(200);
- return { manager };
+ return {
+ manager: result.state,
+ recovered: result.recovered
+ };
} catch (error) {
reply.code(isJdLiveError(error) ? error.statusCode : 400);
return {
@@ -226,6 +261,42 @@ export function createServer(
manager: jdSessionManager.clearManagedSession("ops-manual-clear")
}));
+ app.get("/api/ops/jd/session-manager/qr-login", async () => ({
+ qrLogin: jdQrLoginService.getState()
+ }));
+
+ app.post("/api/ops/jd/session-manager/qr-login/start", async (_request, reply) => {
+ try {
+ const qrLogin = await jdQrLoginService.start();
+ reply.code(200);
+ return { qrLogin };
+ } catch (error) {
+ reply.code(500);
+ return {
+ message: error instanceof Error ? error.message : "JD QR login start failed."
+ };
+ }
+ });
+
+ app.post("/api/ops/jd/session-manager/qr-login/cancel", async (_request, reply) => {
+ const qrLogin = await jdQrLoginService.cancel("ops-cancel");
+ reply.code(200);
+ return { qrLogin };
+ });
+
+ app.post("/api/ops/jd/session-manager/qr-login/resume", async (_request, reply) => {
+ try {
+ const qrLogin = await jdQrLoginService.resumeManualRecovery();
+ reply.code(200);
+ return { qrLogin };
+ } catch (error) {
+ reply.code(500);
+ return {
+ message: error instanceof Error ? error.message : "JD QR login manual recovery failed."
+ };
+ }
+ });
+
app.get("/api/ops/tmall/session-manager", async () => ({
manager: tmallSessionManager.getState()
}));
@@ -290,6 +361,29 @@ export function createServer(
manager: tmallSessionManager.clearManagedSession("ops-manual-clear")
}));
+ app.get("/api/ops/tmall/session-manager/qr-login", async () => ({
+ qrLogin: tmallQrLoginService.getState()
+ }));
+
+ app.post("/api/ops/tmall/session-manager/qr-login/start", async (_request, reply) => {
+ try {
+ const qrLogin = await tmallQrLoginService.start();
+ reply.code(200);
+ return { qrLogin };
+ } catch (error) {
+ reply.code(500);
+ return {
+ message: error instanceof Error ? error.message : "Tmall QR login start failed."
+ };
+ }
+ });
+
+ app.post("/api/ops/tmall/session-manager/qr-login/cancel", async (_request, reply) => {
+ const qrLogin = await tmallQrLoginService.cancel("ops-cancel");
+ reply.code(200);
+ return { qrLogin };
+ });
+
app.get("/api/platforms/tmall/live-session", async () => ({
session: tmallLiveService.getSessionSummary()
}));
@@ -307,6 +401,7 @@ export function createServer(
}>("/api/platforms/jd/live-session", async (request, reply) => {
try {
await jdSessionManager.importManualSession(request.body, "legacy-live-session");
+ await jdSessionManager.runHealthCheck("legacy-live-session");
reply.code(200);
return { session: jdLiveService.getSessionSummary() };
} catch (error) {
@@ -806,15 +901,28 @@ export function createServer(
return { message: "Task not found." };
}
+ reply.hijack();
reply.raw.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
- Connection: "keep-alive"
+ Connection: "keep-alive",
+ "Access-Control-Allow-Origin": request.headers.origin ?? "*",
+ Vary: "Origin"
+ });
+ const writeSnapshot = (nextTask = task) => {
+ reply.raw.write(`event: task.snapshot\n`);
+ reply.raw.write(`data: ${JSON.stringify({ task: nextTask })}\n\n`);
+ };
+ const unsubscribe = store.subscribeToTask(request.params.taskId, writeSnapshot);
+ const heartbeat = setInterval(() => {
+ reply.raw.write(`: keep-alive\n\n`);
+ }, 15000);
+
+ writeSnapshot(task);
+ request.raw.on("close", () => {
+ clearInterval(heartbeat);
+ unsubscribe();
});
- reply.raw.write(`event: task.snapshot\n`);
- reply.raw.write(`data: ${JSON.stringify(task)}\n\n`);
- reply.raw.end();
- return reply;
});
return app;
diff --git a/apps/api/src/store.ts b/apps/api/src/store.ts
index 5439128..824e0ec 100644
--- a/apps/api/src/store.ts
+++ b/apps/api/src/store.ts
@@ -282,12 +282,15 @@ function isReportableTaskStatus(
return status === "Completed" || status === "PartialCompleted";
}
+type TaskExecutionMode = "synchronous" | "background";
+
type InMemoryTaskStoreOptions = {
storagePath?: string | undefined;
jdLiveService?: JdLiveService | undefined;
jdSessionManager?: JdSessionManager | undefined;
tmallLiveService?: TmallLiveService | undefined;
tmallSessionManager?: TmallSessionManager | undefined;
+ executionMode?: TaskExecutionMode | undefined;
};
type TaskExecutionArtifact = {
@@ -424,11 +427,18 @@ export class InMemoryTaskStore {
Partial>
>();
private readonly executionArtifacts = new Map();
+ private readonly taskSubscribers = new Map<
+ string,
+ Set<(task: TaskRecord) => void>
+ >();
+ private readonly pendingExecutions = new Map>();
+ private readonly pendingManagedSessionRetries = new Set();
private readonly storagePath: string | undefined;
private readonly jdLiveService: JdLiveService | undefined;
private jdSessionManager: JdSessionManager | undefined;
private readonly tmallLiveService: TmallLiveService | undefined;
private tmallSessionManager: TmallSessionManager | undefined;
+ private readonly executionMode: TaskExecutionMode;
constructor(options: InMemoryTaskStoreOptions = {}) {
this.storagePath = options.storagePath;
@@ -436,6 +446,8 @@ export class InMemoryTaskStore {
this.jdSessionManager = options.jdSessionManager;
this.tmallLiveService = options.tmallLiveService;
this.tmallSessionManager = options.tmallSessionManager;
+ this.executionMode =
+ options.executionMode ?? (process.env.NODE_ENV === "test" ? "synchronous" : "background");
const persistedState = this.loadPersistedState();
if (persistedState) {
@@ -483,6 +495,12 @@ export class InMemoryTaskStore {
return this.toSessionRecord(next);
}
+ notifyManagedSessionReady(platform: PlatformId): SessionStateRecord {
+ const session = this.preparePlatform(platform);
+ void this.resumeTasksWaitingForManagedSession(platform);
+ return session;
+ }
+
clearPlatformSession(platform: PlatformId): void {
const cleared = createMissingSession(platform);
this.readiness.set(platform, cleared);
@@ -567,6 +585,24 @@ export class InMemoryTaskStore {
return this.tasks.get(taskId);
}
+ subscribeToTask(taskId: string, listener: (task: TaskRecord) => void): () => void {
+ const listeners = this.taskSubscribers.get(taskId) ?? new Set<(task: TaskRecord) => void>();
+ listeners.add(listener);
+ this.taskSubscribers.set(taskId, listeners);
+
+ return () => {
+ const currentListeners = this.taskSubscribers.get(taskId);
+ if (!currentListeners) {
+ return;
+ }
+
+ currentListeners.delete(listener);
+ if (currentListeners.size === 0) {
+ this.taskSubscribers.delete(taskId);
+ }
+ };
+ }
+
getCandidates(taskId: string): Record | undefined {
return this.tasks.get(taskId)?.platformCandidates;
}
@@ -714,6 +750,12 @@ export class InMemoryTaskStore {
task.taskStage = "session_check";
this.pushEvent(task, "task.running", "系统开始执行抓取前校验。");
+ if (this.executionMode === "background") {
+ this.persistState();
+ this.scheduleTaskExecution(task.taskId);
+ return task;
+ }
+
await this.executeSelectedPlatforms(task, selectedRuns);
task.taskStatus = deriveTaskStatusFromConfirmedPlatforms(task.platformRuns);
task.updatedAt = nowIso();
@@ -1664,6 +1706,44 @@ export class InMemoryTaskStore {
this.pushEvent(task, "task.awaiting_confirmation", "候选召回完成,等待人工确认。");
}
+ private async resumeTasksWaitingForManagedSession(platform: PlatformId): Promise {
+ const taskIds = Array.from(this.tasks.values())
+ .filter((task) => this.shouldResumeTaskAfterManagedSessionReady(task, platform))
+ .map((task) => task.taskId);
+
+ for (const taskId of taskIds) {
+ const retryKey = `${taskId}:${platform}`;
+ if (this.pendingManagedSessionRetries.has(retryKey)) {
+ continue;
+ }
+
+ this.pendingManagedSessionRetries.add(retryKey);
+ try {
+ await this.retryPlatform(taskId, platform);
+ } catch {
+ // retryPlatform already records recoverable failures on the task.
+ } finally {
+ this.pendingManagedSessionRetries.delete(retryKey);
+ }
+ }
+ }
+
+ private shouldResumeTaskAfterManagedSessionReady(
+ task: TaskRecord,
+ platform: PlatformId
+ ): boolean {
+ const run = task.platformRuns.find((item) => item.platform === platform);
+ if (!run) {
+ return false;
+ }
+
+ if (run.status === "SearchBlocked") {
+ return true;
+ }
+
+ return run.status === "Blocked" && run.selectedCandidateIds.length > 0;
+ }
+
private buildReport(task: TaskRecord): ReportSnapshot {
const reportVersion = this.getNextReportVersion(task);
const completedRuns = task.platformRuns.filter((run) => run.status === "Completed");
@@ -2064,6 +2144,11 @@ export class InMemoryTaskStore {
run.status = "Searching";
run.reason = undefined;
run.lastUpdatedAt = nowIso();
+ this.pushEvent(
+ task,
+ `platform.${run.platform}.searching`,
+ `${platformCatalogMap[run.platform].label} 正在执行候选搜索。`
+ );
let candidates: CandidateRecord[] = [];
if (run.platform === "jd" && this.hasConfiguredJdLiveSession()) {
@@ -2963,6 +3048,85 @@ export class InMemoryTaskStore {
};
task.events.push(event);
task.updatedAt = event.createdAt;
+ this.emitTaskSnapshot(task);
+ }
+
+ private emitTaskSnapshot(task: TaskRecord): void {
+ const listeners = this.taskSubscribers.get(task.taskId);
+ if (!listeners || listeners.size === 0) {
+ return;
+ }
+
+ for (const listener of listeners) {
+ listener(task);
+ }
+ }
+
+ private scheduleTaskExecution(taskId: string): void {
+ if (this.pendingExecutions.has(taskId)) {
+ return;
+ }
+
+ const execution = new Promise((resolve) => {
+ setTimeout(() => {
+ void (async () => {
+ const task = this.requireTask(taskId);
+ const selectedRuns = task.platformRuns.filter((run) => run.status === "Selected");
+
+ if (selectedRuns.length > 0) {
+ await this.executeSelectedPlatforms(task, selectedRuns);
+ }
+
+ task.taskStatus = deriveTaskStatusFromConfirmedPlatforms(task.platformRuns);
+ task.updatedAt = nowIso();
+ const published = this.publishReportIfNeeded(task);
+ this.persistState();
+
+ if (!published) {
+ this.emitTaskSnapshot(task);
+ }
+ })()
+ .catch((error) => {
+ this.failBackgroundTaskExecution(taskId, error);
+ })
+ .finally(() => {
+ this.pendingExecutions.delete(taskId);
+ resolve();
+ });
+ }, 0);
+ });
+
+ this.pendingExecutions.set(taskId, execution);
+ }
+
+ private failBackgroundTaskExecution(taskId: string, error: unknown): void {
+ const task = this.tasks.get(taskId);
+ if (!task) {
+ return;
+ }
+
+ for (const run of task.platformRuns) {
+ if (run.selectedCandidateIds.length === 0) {
+ continue;
+ }
+
+ if (run.status === "Selected" || run.status === "Running") {
+ run.status = "Failed";
+ run.reason = "后台执行异常中断,请稍后重试。";
+ run.lastUpdatedAt = nowIso();
+ }
+ }
+
+ task.taskStage = "publish";
+ task.taskStatus = deriveTaskStatusFromConfirmedPlatforms(task.platformRuns);
+ this.pushEvent(
+ task,
+ "task.execution_failed",
+ error instanceof Error
+ ? `后台执行异常中断:${error.message}`
+ : "后台执行异常中断,请稍后重试。"
+ );
+ this.persistState();
}
private requireTask(taskId: string): TaskRecord {
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 727ec3b..fae04ec 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -29,6 +29,7 @@ import {
clearJdSessionManagerConfig,
clearPlatformSession,
confirmTask,
+ createTaskEventsSource,
createTask,
deleteTask,
getJdKeywordPreview,
@@ -80,6 +81,50 @@ function isRetryablePlatformStatus(
return status === "SearchBlocked" || status === "Blocked" || status === "Failed";
}
+function useTaskLiveSync(
+ taskId: string,
+ options: {
+ includeCandidates?: boolean;
+ } = {}
+) {
+ const queryClient = useQueryClient();
+
+ useEffect(() => {
+ if (!taskId) {
+ return;
+ }
+
+ const eventSource = createTaskEventsSource(taskId);
+ const handleSnapshot = (event: MessageEvent) => {
+ const payload = JSON.parse(event.data) as { task: TaskRecord };
+ queryClient.setQueryData(["task", taskId], payload);
+
+ if (options.includeCandidates) {
+ void queryClient.invalidateQueries({ queryKey: ["task-candidates", taskId] });
+ }
+
+ if (
+ payload.task.taskStatus === "Completed" ||
+ payload.task.taskStatus === "PartialCompleted" ||
+ payload.task.taskStatus === "Blocked" ||
+ payload.task.taskStatus === "Failed"
+ ) {
+ void queryClient.invalidateQueries({ queryKey: ["history"] });
+ if (payload.task.defaultReportVersion) {
+ void queryClient.invalidateQueries({ queryKey: ["report", taskId] });
+ }
+ }
+ };
+
+ eventSource.addEventListener("task.snapshot", handleSnapshot as EventListener);
+
+ return () => {
+ eventSource.removeEventListener("task.snapshot", handleSnapshot as EventListener);
+ eventSource.close();
+ };
+ }, [options.includeCandidates, queryClient, taskId]);
+}
+
function getTaskDestination(
taskId: string,
taskStatus: TaskStatus,
@@ -552,6 +597,8 @@ function CandidateCard(props: {
function ConfirmPage() {
const navigate = useNavigate();
const { taskId = "" } = useParams();
+ const queryClient = useQueryClient();
+ useTaskLiveSync(taskId, { includeCandidates: true });
const taskQuery = useQuery({
queryKey: ["task", taskId],
queryFn: () => getTask(taskId)
@@ -590,6 +637,16 @@ function ConfirmPage() {
navigate(`/tasks/${task.taskId}/run`);
}
});
+ const retryMutation = useMutation({
+ mutationFn: (platform: PlatformId) => retryTaskPlatform(taskId, platform),
+ onSuccess: async () => {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: ["task", taskId] }),
+ queryClient.invalidateQueries({ queryKey: ["task-candidates", taskId] }),
+ queryClient.invalidateQueries({ queryKey: ["history"] })
+ ]);
+ }
+ });
if (!taskQuery.data || !candidatesQuery.data) {
return (
@@ -625,9 +682,27 @@ function ConfirmPage() {
{platformRun?.reason ?? "当前没有候选结果。"}
{platformRun?.status === "SearchBlocked" ? (
isOpsManagedPlatform(platform) ? (
-
- 京东阻塞恢复已切到运维后台,当前页面不提供直接恢复入口。
-
+ <>
+
+ 京东阻塞恢复已切到运维后台,当前页面不提供直接恢复入口。
+
+
+
+ 去运维恢复
+
+
+
+ >
) : (
)
) : null}
+ {platformRun?.status === "Failed" ? (
+
+
+
+ ) : null}
) : (
@@ -683,6 +770,7 @@ function ConfirmPage() {
function RunPage() {
const queryClient = useQueryClient();
const { taskId = "" } = useParams();
+ useTaskLiveSync(taskId);
const taskQuery = useQuery({
queryKey: ["task", taskId],
queryFn: () => getTask(taskId)
diff --git a/apps/web/src/NewTaskPage.test.tsx b/apps/web/src/NewTaskPage.test.tsx
index b5cda64..66085d6 100644
--- a/apps/web/src/NewTaskPage.test.tsx
+++ b/apps/web/src/NewTaskPage.test.tsx
@@ -1,11 +1,13 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./lib/api", () => ({
+ cancelJdQrLogin: vi.fn(),
+ cancelTmallQrLogin: vi.fn(),
clearJdManagedSession: vi.fn(),
clearJdSessionManagerConfig: vi.fn(),
clearPlatformSession: vi.fn(),
@@ -13,9 +15,15 @@ vi.mock("./lib/api", () => ({
clearTmallSessionManagerConfig: vi.fn(),
confirmTask: vi.fn(),
createTask: vi.fn(),
+ createTaskEventsSource: vi.fn(() => ({
+ addEventListener: vi.fn(),
+ close: vi.fn(),
+ removeEventListener: vi.fn()
+ })),
deleteTask: vi.fn(),
getJdKeywordPreview: vi.fn(),
getJdLiveSession: vi.fn(),
+ getJdQrLoginState: vi.fn(),
getJdSessionManager: vi.fn(),
getHistoryTasks: vi.fn(),
getPlatformReadiness: vi.fn(),
@@ -24,37 +32,50 @@ vi.mock("./lib/api", () => ({
getTaskCandidates: vi.fn(),
getTaskReport: vi.fn(),
getTmallLiveSession: vi.fn(),
+ getTmallQrLoginState: vi.fn(),
getTmallSessionManager: vi.fn(),
importJdManagedSession: vi.fn(),
importTmallManagedSession: vi.fn(),
preparePlatform: vi.fn(),
+ resumeJdQrLoginManualRecovery: vi.fn(),
runJdSessionManagerHealthCheck: vi.fn(),
runJdSessionManagerRecovery: vi.fn(),
runTmallSessionManagerHealthCheck: vi.fn(),
retryTaskPlatform: vi.fn(),
+ startJdQrLogin: vi.fn(),
+ startTmallQrLogin: vi.fn(),
updateJdSessionManagerConfig: vi.fn(),
updateTmallSessionManagerConfig: vi.fn()
}));
import { App, NewTaskPage } from "./App";
import {
+ cancelJdQrLogin,
+ cancelTmallQrLogin,
clearJdManagedSession,
clearPlatformSession,
clearTmallManagedSession,
getJdKeywordPreview,
getJdLiveSession,
+ getJdQrLoginState,
getJdSessionManager,
getHistoryTasks,
getPlatformReadiness,
getPlatformSession,
+ getTask,
+ getTaskCandidates,
getTmallLiveSession,
+ getTmallQrLoginState,
getTmallSessionManager,
importJdManagedSession,
importTmallManagedSession,
preparePlatform,
+ createTaskEventsSource,
runJdSessionManagerHealthCheck,
runJdSessionManagerRecovery,
runTmallSessionManagerHealthCheck,
+ startJdQrLogin,
+ startTmallQrLogin,
updateJdSessionManagerConfig
} from "./lib/api";
@@ -219,6 +240,14 @@ describe("task composer and session console", () => {
}
}
} as any);
+ vi.mocked(getJdQrLoginState).mockResolvedValue({
+ qrLogin: {
+ platform: "jd",
+ status: "idle",
+ note: "尚未启动扫码登录。",
+ sessionImported: false
+ }
+ } as any);
vi.mocked(getJdKeywordPreview).mockResolvedValue({
preview: {
query: "小米手环10",
@@ -323,6 +352,14 @@ describe("task composer and session console", () => {
vi.mocked(getTmallLiveSession).mockResolvedValue({
session: tmallManagerState.session
} as any);
+ vi.mocked(getTmallQrLoginState).mockResolvedValue({
+ qrLogin: {
+ platform: "tmall",
+ status: "idle",
+ note: "尚未启动扫码登录。",
+ sessionImported: false
+ }
+ } as any);
vi.mocked(clearPlatformSession).mockResolvedValue(undefined);
vi.mocked(importJdManagedSession).mockResolvedValue({
manager: {
@@ -408,10 +445,46 @@ describe("task composer and session console", () => {
state: jdManagerState,
recovered: true
} as any);
+ vi.mocked(startJdQrLogin).mockResolvedValue({
+ qrLogin: {
+ platform: "jd",
+ status: "waiting_for_scan",
+ note: "二维码已生成,请扫码。",
+ targetId: "100068388533",
+ qrImageDataUrl: "data:image/png;base64,stub",
+ sessionImported: false
+ }
+ } as any);
+ vi.mocked(cancelJdQrLogin).mockResolvedValue({
+ qrLogin: {
+ platform: "jd",
+ status: "cancelled",
+ note: "扫码已取消。",
+ sessionImported: false
+ }
+ } as any);
vi.mocked(runTmallSessionManagerHealthCheck).mockResolvedValue({
state: tmallManagerState,
recovered: false
} as any);
+ vi.mocked(startTmallQrLogin).mockResolvedValue({
+ qrLogin: {
+ platform: "tmall",
+ status: "waiting_for_scan",
+ note: "二维码已生成,请扫码。",
+ targetId: "934454505228",
+ qrImageDataUrl: "data:image/png;base64,stub",
+ sessionImported: false
+ }
+ } as any);
+ vi.mocked(cancelTmallQrLogin).mockResolvedValue({
+ qrLogin: {
+ platform: "tmall",
+ status: "cancelled",
+ note: "扫码已取消。",
+ sessionImported: false
+ }
+ } as any);
vi.mocked(preparePlatform).mockResolvedValue({
platform: "jd",
session_ready: true,
@@ -480,6 +553,134 @@ describe("task composer and session console", () => {
expect(screen.getByText(/返回业务页面:\/tasks\/new/)).toBeInTheDocument();
});
+ it("updates the confirm page when a task snapshot restores JD candidates", async () => {
+ let snapshotHandler: ((event: MessageEvent
) => void) | undefined;
+ vi.mocked(createTaskEventsSource).mockReturnValue({
+ addEventListener: vi.fn((_type: string, handler: EventListenerOrEventListenerObject) => {
+ snapshotHandler = handler as (event: MessageEvent) => void;
+ }),
+ close: vi.fn(),
+ removeEventListener: vi.fn()
+ } as unknown as EventSource);
+
+ vi.mocked(getTask).mockResolvedValue({
+ task: {
+ taskId: "task-confirm-live",
+ query: "iPhone 15",
+ createdAt: "2026-04-07T09:00:00.000Z",
+ updatedAt: "2026-04-07T09:00:00.000Z",
+ perLinkLimit: 100,
+ taskTotalLimit: 500,
+ taskStatus: "AwaitingConfirmation",
+ taskStage: "confirmation",
+ platformRuns: [
+ {
+ platform: "tmall",
+ searchRequirement: "recommended",
+ status: "AwaitingSelection",
+ candidateCount: 1,
+ selectedCandidateIds: [],
+ lastUpdatedAt: "2026-04-07T09:00:00.000Z"
+ },
+ {
+ platform: "jd",
+ searchRequirement: "required",
+ status: "SearchBlocked",
+ candidateCount: 0,
+ selectedCandidateIds: [],
+ reason: "waiting for ops recovery",
+ lastUpdatedAt: "2026-04-07T09:00:00.000Z"
+ }
+ ],
+ platformCandidates: {
+ tmall: [],
+ jd: []
+ },
+ events: [],
+ reportVersions: []
+ }
+ } as any);
+ vi.mocked(getTaskCandidates)
+ .mockResolvedValueOnce({
+ candidates: {
+ tmall: [],
+ jd: []
+ }
+ } as any)
+ .mockResolvedValue({
+ candidates: {
+ tmall: [],
+ jd: [
+ {
+ candidateId: "jd-100068388533",
+ platform: "jd",
+ title: "Apple iPhone 15",
+ price: 3898,
+ priceLabel: "CNY 3898",
+ storeName: "JD Self Operated",
+ productUrl: "https://item.jd.com/100068388533.html",
+ imageUrl: "https://img14.360buyimg.com/example.jpg",
+ salesHint: "sold 500+",
+ specLabel: "128GB",
+ highlights: ["A16"]
+ }
+ ]
+ }
+ } as any);
+
+ renderWithProviders(, ["/tasks/task-confirm-live/confirm"]);
+
+ expect(await screen.findByText("waiting for ops recovery")).toBeInTheDocument();
+
+ await act(async () => {
+ snapshotHandler?.(
+ new MessageEvent("task.snapshot", {
+ data: JSON.stringify({
+ task: {
+ taskId: "task-confirm-live",
+ query: "iPhone 15",
+ createdAt: "2026-04-07T09:00:00.000Z",
+ updatedAt: "2026-04-07T09:01:00.000Z",
+ perLinkLimit: 100,
+ taskTotalLimit: 500,
+ taskStatus: "AwaitingConfirmation",
+ taskStage: "confirmation",
+ platformRuns: [
+ {
+ platform: "tmall",
+ searchRequirement: "recommended",
+ status: "AwaitingSelection",
+ candidateCount: 1,
+ selectedCandidateIds: [],
+ lastUpdatedAt: "2026-04-07T09:00:00.000Z"
+ },
+ {
+ platform: "jd",
+ searchRequirement: "required",
+ status: "AwaitingSelection",
+ candidateCount: 1,
+ selectedCandidateIds: [],
+ lastUpdatedAt: "2026-04-07T09:01:00.000Z"
+ }
+ ],
+ platformCandidates: {
+ tmall: [],
+ jd: []
+ },
+ events: [],
+ reportVersions: []
+ }
+ })
+ })
+ );
+ });
+
+ expect(await screen.findByText("Apple iPhone 15")).toBeInTheDocument();
+ await waitFor(() => {
+ expect(getTaskCandidates).toHaveBeenCalledTimes(2);
+ });
+ });
+
it("imports jd managed session payload from the ops page", async () => {
const user = userEvent.setup();
diff --git a/apps/web/src/OpsSessionManagerPage.tsx b/apps/web/src/OpsSessionManagerPage.tsx
index 159710d..c72d3de 100644
--- a/apps/web/src/OpsSessionManagerPage.tsx
+++ b/apps/web/src/OpsSessionManagerPage.tsx
@@ -4,23 +4,31 @@ import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import {
+ cancelJdQrLogin,
+ cancelTmallQrLogin,
clearJdManagedSession,
clearJdSessionManagerConfig,
clearTmallManagedSession,
clearTmallSessionManagerConfig,
getJdLiveSession,
+ getJdQrLoginState,
getJdSessionManager,
getTmallLiveSession,
+ getTmallQrLoginState,
getTmallSessionManager,
importJdManagedSession,
importTmallManagedSession,
runJdSessionManagerHealthCheck,
runJdSessionManagerRecovery,
runTmallSessionManagerHealthCheck,
+ resumeJdQrLoginManualRecovery,
type JdLiveSessionInput,
type JdSessionManagerConfigInput,
+ type OpsQrLoginState,
type TmallLiveSessionInput,
type TmallSessionManagerConfigInput,
+ startJdQrLogin,
+ startTmallQrLogin,
updateJdSessionManagerConfig,
updateTmallSessionManagerConfig
} from "./lib/api";
@@ -52,6 +60,112 @@ function formatTimestamp(timestamp?: string) {
return new Date(timestamp).toLocaleString("zh-CN");
}
+const SCREENSHOT_POLL_INTERVAL_MS = 1500;
+
+function isActiveQrLogin(state?: OpsQrLoginState) {
+ return (
+ state?.status === "launching" ||
+ state?.status === "waiting_for_scan" ||
+ state?.status === "capturing_session"
+ );
+}
+
+function QrLoginCard(props: {
+ platformLabel: string;
+ platformHint: string;
+ qrLogin?: OpsQrLoginState | undefined;
+ onStart: () => void;
+ onCancel: () => void;
+ onResumeManualRecovery?: (() => void) | undefined;
+ startPending: boolean;
+ cancelPending: boolean;
+ resumePending?: boolean | undefined;
+}) {
+ const active = isActiveQrLogin(props.qrLogin);
+ const canResumeManualRecovery =
+ props.qrLogin?.status === "failed" &&
+ props.qrLogin.sessionImported &&
+ Boolean(props.onResumeManualRecovery);
+ const startLabel = active ? "重新生成二维码" : "开始扫码登录";
+ const qrPreview = active ? props.qrLogin?.qrImageDataUrl : undefined;
+
+ let emptyState = "点击“开始扫码登录”后,这里会显示实时二维码截图。";
+ if (props.qrLogin?.status === "launching") {
+ emptyState = "二维码生成中...";
+ } else if (props.qrLogin?.status === "failed") {
+ emptyState = "上一次二维码已失效,请点击“重新生成二维码”。";
+ } else if (props.qrLogin?.status === "cancelled") {
+ emptyState = "当前扫码流程已取消,请重新生成二维码。";
+ } else if (props.qrLogin?.status === "completed") {
+ emptyState = "扫码流程已完成,可在右侧查看会话导入状态。";
+ }
+
+ return (
+
+
QR Login
+
{props.platformLabel}扫码登录
+
{props.platformHint}
+
+
+ {qrPreview ? (
+

+ ) : (
+
{emptyState}
+ )}
+
+
+
+
+ Status
+ {props.qrLogin?.status ?? "idle"}
+
+
+ Target
+ {props.qrLogin?.targetId ?? "默认目标"}
+
+
+ Updated
+ {formatTimestamp(props.qrLogin?.updatedAt)}
+
+
{props.qrLogin?.note ?? "尚未启动扫码流程。"}
+ {props.qrLogin?.currentUrl ? (
+
{props.qrLogin.currentUrl}
+ ) : null}
+
+
+
+
+ {canResumeManualRecovery ? (
+
+ ) : null}
+
+
+
+
+ );
+}
+
export function OpsSessionManagerPage() {
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
@@ -102,6 +216,12 @@ export function OpsSessionManagerPage() {
queryKey: ["jd-live-session"],
queryFn: getJdLiveSession
});
+ const jdQrLoginQuery = useQuery({
+ queryKey: ["jd-qr-login"],
+ queryFn: getJdQrLoginState,
+ refetchInterval: (query) =>
+ isActiveQrLogin(query.state.data?.qrLogin) ? SCREENSHOT_POLL_INTERVAL_MS : false
+ });
const tmallManagerQuery = useQuery({
queryKey: ["tmall-session-manager"],
queryFn: getTmallSessionManager
@@ -110,11 +230,18 @@ export function OpsSessionManagerPage() {
queryKey: ["tmall-live-session"],
queryFn: getTmallLiveSession
});
+ const tmallQrLoginQuery = useQuery({
+ queryKey: ["tmall-qr-login"],
+ queryFn: getTmallQrLoginState,
+ refetchInterval: (query) =>
+ isActiveQrLogin(query.state.data?.qrLogin) ? SCREENSHOT_POLL_INTERVAL_MS : false
+ });
const invalidateJdOpsQueries = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["jd-session-manager"] }),
queryClient.invalidateQueries({ queryKey: ["jd-live-session"] }),
+ queryClient.invalidateQueries({ queryKey: ["jd-qr-login"] }),
queryClient.invalidateQueries({ queryKey: ["platform-readiness"] })
]);
};
@@ -123,6 +250,7 @@ export function OpsSessionManagerPage() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["tmall-session-manager"] }),
queryClient.invalidateQueries({ queryKey: ["tmall-live-session"] }),
+ queryClient.invalidateQueries({ queryKey: ["tmall-qr-login"] }),
queryClient.invalidateQueries({ queryKey: ["platform-readiness"] })
]);
};
@@ -157,6 +285,18 @@ export function OpsSessionManagerPage() {
mutationFn: clearJdManagedSession,
onSuccess: invalidateJdOpsQueries
});
+ const jdStartQrLoginMutation = useMutation({
+ mutationFn: startJdQrLogin,
+ onSuccess: invalidateJdOpsQueries
+ });
+ const jdCancelQrLoginMutation = useMutation({
+ mutationFn: cancelJdQrLogin,
+ onSuccess: invalidateJdOpsQueries
+ });
+ const jdResumeQrLoginMutation = useMutation({
+ mutationFn: resumeJdQrLoginManualRecovery,
+ onSuccess: invalidateJdOpsQueries
+ });
const tmallSaveConfigMutation = useMutation({
mutationFn: (payload: TmallSessionManagerConfigInput) =>
@@ -185,11 +325,21 @@ export function OpsSessionManagerPage() {
mutationFn: clearTmallManagedSession,
onSuccess: invalidateTmallOpsQueries
});
+ const tmallStartQrLoginMutation = useMutation({
+ mutationFn: startTmallQrLogin,
+ onSuccess: invalidateTmallOpsQueries
+ });
+ const tmallCancelQrLoginMutation = useMutation({
+ mutationFn: cancelTmallQrLogin,
+ onSuccess: invalidateTmallOpsQueries
+ });
const jdManager = jdManagerQuery.data?.manager;
const jdLiveSession = jdLiveSessionQuery.data?.session;
+ const jdQrLogin = jdQrLoginQuery.data?.qrLogin;
const tmallManager = tmallManagerQuery.data?.manager;
const tmallLiveSession = tmallLiveSessionQuery.data?.session;
+ const tmallQrLogin = tmallQrLoginQuery.data?.qrLogin;
useEffect(() => {
if (!jdManager || jdConfigDirty) {
@@ -221,6 +371,22 @@ export function OpsSessionManagerPage() {
});
}, [tmallConfigDirty, tmallManager]);
+ useEffect(() => {
+ if (!jdQrLogin?.sessionImported || jdQrLogin.status !== "completed") {
+ return;
+ }
+
+ void invalidateJdOpsQueries();
+ }, [jdQrLogin?.completedAt]);
+
+ useEffect(() => {
+ if (!tmallQrLogin?.sessionImported || tmallQrLogin.status !== "completed") {
+ return;
+ }
+
+ void invalidateTmallOpsQueries();
+ }, [tmallQrLogin?.completedAt]);
+
const switchPlatform = (platform: PlatformId) => {
const next = new URLSearchParams(searchParams);
next.set("platform", platform);
@@ -323,6 +489,18 @@ export function OpsSessionManagerPage() {
+ jdCancelQrLoginMutation.mutate()}
+ onResumeManualRecovery={() => jdResumeQrLoginMutation.mutate()}
+ onStart={() => jdStartQrLoginMutation.mutate()}
+ platformHint="后端会启动受控浏览器,实时截图京东登录二维码;扫码成功后会自动抓取 Cookie、详情模板和评论模板并导入当前运维会话。"
+ platformLabel="京东"
+ qrLogin={jdQrLogin}
+ resumePending={jdResumeQrLoginMutation.isPending}
+ startPending={jdStartQrLoginMutation.isPending}
+ />
+
Automation
自动恢复配置
@@ -604,6 +782,10 @@ export function OpsSessionManagerPage() {
Live Session
{jdLiveSession?.configured ? "已导入" : "未导入"}
+
+ QR Login
+ {jdQrLogin?.status ?? "idle"}
+
Detail Template
@@ -656,6 +838,24 @@ export function OpsSessionManagerPage() {
{jdClearSessionMutation.error.message}
) : null}
+ {jdQrLoginQuery.error instanceof Error ? (
+ {jdQrLoginQuery.error.message}
+ ) : null}
+ {jdStartQrLoginMutation.error instanceof Error ? (
+
+ {jdStartQrLoginMutation.error.message}
+
+ ) : null}
+ {jdCancelQrLoginMutation.error instanceof Error ? (
+
+ {jdCancelQrLoginMutation.error.message}
+
+ ) : null}
+ {jdResumeQrLoginMutation.error instanceof Error ? (
+
+ {jdResumeQrLoginMutation.error.message}
+
+ ) : null}
) : (
@@ -670,6 +870,16 @@ export function OpsSessionManagerPage() {
+ tmallCancelQrLoginMutation.mutate()}
+ onStart={() => tmallStartQrLoginMutation.mutate()}
+ platformHint="后端会启动受控浏览器,实时截图淘宝/天猫登录二维码;扫码成功后会自动导入 Cookie,并优先抓取或回退生成评论模板。"
+ platformLabel="天猫"
+ qrLogin={tmallQrLogin}
+ startPending={tmallStartQrLoginMutation.isPending}
+ />
+
Automation
自动巡检配置
@@ -874,6 +1084,10 @@ export function OpsSessionManagerPage() {
Live Session
{tmallLiveSession?.configured ? "已导入" : "未导入"}
+
+ QR Login
+ {tmallQrLogin?.status ?? "idle"}
+
Detail Template
@@ -927,6 +1141,19 @@ export function OpsSessionManagerPage() {
{tmallClearSessionMutation.error.message}
) : null}
+ {tmallQrLoginQuery.error instanceof Error ? (
+ {tmallQrLoginQuery.error.message}
+ ) : null}
+ {tmallStartQrLoginMutation.error instanceof Error ? (
+
+ {tmallStartQrLoginMutation.error.message}
+
+ ) : null}
+ {tmallCancelQrLoginMutation.error instanceof Error ? (
+
+ {tmallCancelQrLoginMutation.error.message}
+
+ ) : null}
)}
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
index 525c3a4..70c67f8 100644
--- a/apps/web/src/styles.css
+++ b/apps/web/src/styles.css
@@ -37,6 +37,7 @@ a {
.app-shell {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
+ align-items: start;
min-height: 100vh;
}
@@ -44,6 +45,12 @@ a {
display: flex;
flex-direction: column;
justify-content: space-between;
+ position: sticky;
+ top: 0;
+ align-self: start;
+ height: 100vh;
+ height: 100dvh;
+ overflow: hidden;
padding: 32px 24px;
border-right: 1px solid rgba(31, 42, 48, 0.08);
background: rgba(251, 248, 242, 0.72);
@@ -75,6 +82,7 @@ a {
.main-content {
display: grid;
gap: 24px;
+ min-height: 100vh;
padding: 32px;
}
@@ -669,6 +677,39 @@ a {
padding: 0 12px;
border-radius: 999px;
background: rgba(20, 108, 110, 0.08);
+.qr-login-card {
+ display: grid;
+ grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
+ gap: 16px;
+ align-items: start;
+}
+
+.qr-login-card__preview {
+ display: grid;
+ place-items: center;
+ min-height: 248px;
+ padding: 16px;
+ border-radius: 18px;
+ background:
+ linear-gradient(180deg, rgba(20, 108, 110, 0.08) 0%, rgba(255, 255, 255, 0.88) 100%);
+ border: 1px solid rgba(20, 108, 110, 0.14);
+}
+
+.qr-login-card__preview img {
+ display: block;
+ width: min(100%, 240px);
+ max-height: 240px;
+ object-fit: contain;
+ border-radius: 12px;
+ background: white;
+}
+
+.qr-login-card__empty {
+ color: var(--text-secondary);
+ text-align: center;
+ line-height: 1.6;
+}
+
color: var(--brand-primary);
font-size: 13px;
font-weight: 700;
@@ -711,3 +752,7 @@ a {
display: none;
}
}
+ .qr-login-card {
+ grid-template-columns: 1fr;
+ }
+
diff --git a/package-lock.json b/package-lock.json
index c519610..2f16e62 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -27,7 +27,8 @@
"@cross-ai/domain": "file:../../packages/domain",
"@cross-ai/report-schema": "file:../../packages/report-schema",
"@fastify/cors": "^11.2.0",
- "fastify": "^5.8.4"
+ "fastify": "^5.8.4",
+ "playwright-core": "^1.59.1"
}
},
"apps/web": {
@@ -3404,6 +3405,18 @@
"pathe": "^2.0.1"
}
},
+ "node_modules/playwright-core": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
+ "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",