feat(api): 打通双平台 live 抓取与运维会话主链

This commit is contained in:
renzhiye 2026-04-03 13:58:38 +08:00
parent 1cf99688d8
commit e506e7d9c2
26 changed files with 11015 additions and 279 deletions

View File

@ -19,6 +19,7 @@ export function createMockCandidates(
const storeName = platform === "tmall" ? "官方旗舰店" : "京东自营"; const storeName = platform === "tmall" ? "官方旗舰店" : "京东自营";
const basePrice = platform === "tmall" ? 7999 : 8099; const basePrice = platform === "tmall" ? 7999 : 8099;
const slug = slugify(query); const slug = slugify(query);
const tmallItemIds = ["833444005595", "833444005596", "833444005597"];
return Array.from({ length: 3 }, (_, index) => ({ return Array.from({ length: 3 }, (_, index) => ({
candidateId: `${platform}-${slug}-${index + 1}`, candidateId: `${platform}-${slug}-${index + 1}`,
@ -27,7 +28,10 @@ export function createMockCandidates(
price: basePrice + index * 120, price: basePrice + index * 120,
priceLabel: `¥${basePrice + index * 120}`, priceLabel: `¥${basePrice + index * 120}`,
storeName, storeName,
productUrl: `https://example.com/${platformName}/${slug}-${index + 1}`, productUrl:
platform === "tmall"
? `https://detail.tmall.com/item.htm?id=${tmallItemIds[index] ?? tmallItemIds[0]}`
: `https://example.com/${platformName}/${slug}-${index + 1}`,
imageUrl: `https://placehold.co/640x480?text=${platformName.toUpperCase()}+${index + 1}`, imageUrl: `https://placehold.co/640x480?text=${platformName.toUpperCase()}+${index + 1}`,
salesHint: salesHint:
platform === "tmall" platform === "tmall"

View File

@ -0,0 +1,170 @@
import type { CandidateRecord } from "@cross-ai/domain";
import type { JdProductPreviewResult, JdSearchMode } from "./types";
const NON_ALPHANUMERIC_PATTERN = /[^\p{L}\p{N}]+/gu;
const TOKEN_SPLIT_PATTERN = /[\s,/\\|+_:-]+/u;
export interface RankedJdKeywordCandidate {
candidate: CandidateRecord;
skuId: string;
score: number;
summary: string;
matchedTokens: string[];
}
export interface JdKeywordPreviewResult {
query: string;
search: {
source: JdSearchMode;
candidateCount: number;
selected: RankedJdKeywordCandidate;
alternatives: RankedJdKeywordCandidate[];
};
product: JdProductPreviewResult;
}
function normalizeText(value: string): string {
return value.toLowerCase().replace(NON_ALPHANUMERIC_PATTERN, "");
}
function tokenizeQuery(query: string): string[] {
const normalizedQuery = normalizeText(query);
const rawTokens = query
.split(TOKEN_SPLIT_PATTERN)
.map((token) => normalizeText(token))
.filter(Boolean);
const uniqueTokens = new Set<string>();
for (const token of rawTokens) {
if (token.length >= 2 || /[\u4e00-\u9fff]/u.test(token)) {
uniqueTokens.add(token);
}
}
if (normalizedQuery) {
uniqueTokens.add(normalizedQuery);
}
return [...uniqueTokens];
}
function extractSkuId(candidate: CandidateRecord): string | null {
const productUrlMatch = candidate.productUrl.match(/item\.jd\.com\/(\d+)\.html/i);
if (productUrlMatch?.[1]) {
return productUrlMatch[1];
}
const candidateIdMatch = candidate.candidateId.match(/^jd-(\d+)$/i);
return candidateIdMatch?.[1] ?? null;
}
function isFallbackCandidate(candidate: CandidateRecord): boolean {
return (
candidate.candidateId.startsWith("jd-fallback-") ||
candidate.highlights.some((highlight) => highlight.includes("需要刷新搜索模板")) ||
candidate.salesHint.includes("未解析出稳定商品卡片")
);
}
function buildSelectionSummary(
candidate: CandidateRecord,
matchedTokens: string[],
fullQueryMatched: boolean,
index: number,
hasSkuId: boolean
): string {
const parts: string[] = [];
if (fullQueryMatched) {
parts.push("标题完整命中关键词");
} else if (matchedTokens.length > 0) {
parts.push(`标题/卖点命中 ${matchedTokens.length} 个关键词片段`);
}
if (index === 0) {
parts.push("位于搜索结果前列");
}
if (hasSkuId) {
parts.push("已解析出可回放 SKU");
}
if (candidate.storeName.includes("自营")) {
parts.push("店铺信息完整");
}
return parts.join("") || "沿用搜索结果排序命中候选";
}
export function rankJdCandidatesForKeyword(
query: string,
candidates: CandidateRecord[]
): RankedJdKeywordCandidate[] {
const normalizedQuery = normalizeText(query);
const tokens = tokenizeQuery(query);
return candidates
.map((candidate, index) => {
const skuId = extractSkuId(candidate);
if (!skuId || isFallbackCandidate(candidate)) {
return null;
}
const title = normalizeText(candidate.title);
const storeName = normalizeText(candidate.storeName);
const highlights = candidate.highlights.map((item) => normalizeText(item));
const matchedTokens = tokens.filter(
(token) =>
title.includes(token) ||
storeName.includes(token) ||
highlights.some((highlight) => highlight.includes(token))
);
const fullQueryMatched = normalizedQuery.length > 0 && title.includes(normalizedQuery);
let score = 0;
if (fullQueryMatched) {
score += 120;
}
score += matchedTokens.reduce(
(sum, token) => sum + (token.length >= 4 || /[\u4e00-\u9fff]/u.test(token) ? 26 : 16),
0
);
if (matchedTokens.length === tokens.length && tokens.length > 1) {
score += 36;
}
if (/item\.jd\.com\/\d+\.html/i.test(candidate.productUrl)) {
score += 8;
}
if (candidate.price > 0) {
score += 4;
}
if (candidate.storeName.includes("自营")) {
score += 4;
}
score -= index * 2;
return {
candidate,
skuId,
score,
summary: buildSelectionSummary(candidate, matchedTokens, fullQueryMatched, index, true),
matchedTokens
} satisfies RankedJdKeywordCandidate;
})
.filter((candidate): candidate is RankedJdKeywordCandidate => Boolean(candidate))
.sort((left, right) => right.score - left.score);
}
export function selectJdCandidateForKeyword(
query: string,
candidates: CandidateRecord[]
): RankedJdKeywordCandidate | null {
return rankJdCandidatesForKeyword(query, candidates)[0] ?? null;
}

View File

@ -0,0 +1,301 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { JdLiveSessionService } from "./live-session";
function buildResponse(body: Record<string, unknown>): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: {
"Content-Type": "application/json"
}
});
}
describe("JdLiveSessionService", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("rebinds detail templates to the requested sku", async () => {
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(async () =>
buildResponse({
skuHeadVO: {
skuTitle: "Apple iPhone 15"
},
price: {
p: "4398.00"
}
})
);
vi.stubGlobal("fetch", fetchMock);
const service = new JdLiveSessionService();
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(preview.detail).toMatchObject({
skuId: "100068388535",
title: "Apple iPhone 15",
price: "4398.00"
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const firstCall = fetchMock.mock.calls[0];
expect(firstCall).toBeDefined();
const requestUrl = firstCall?.[0];
const requestInit = firstCall?.[1];
if (!requestUrl || !requestInit) {
throw new Error("Expected fetch to receive both url and init.");
}
expect(decodeURIComponent(new URL(requestUrl).searchParams.get("body") ?? "")).toContain(
'"skuId":"100068388535"'
);
expect(requestInit.headers).toMatchObject({
Referer: "https://item.jd.com/100068388535.html"
});
});
it("paginates and deduplicates JD reviews across multiple pages", async () => {
const fetchMock = vi
.fn<(input: string, init?: RequestInit) => Promise<Response>>()
.mockResolvedValueOnce(
buildResponse({
allCnt: "10000",
goodRate: "95%",
pictureCnt: "500",
tagStatisticsinfoList: [
{
tagId: "tag-1",
name: "拍照效果超清晰",
count: "9313"
}
],
commentInfoList: [
{
commentId: "comment-1",
commentData: "第一页评论一",
commentScore: 5
},
{
commentId: "comment-2",
commentData: "第一页评论二",
commentScore: 5
}
]
})
)
.mockResolvedValueOnce(
buildResponse({
allCnt: "10000",
goodRate: "95%",
pictureCnt: "500",
commentInfoList: [
{
commentId: "comment-2",
commentData: "第一页评论二",
commentScore: 5
},
{
commentId: "comment-3",
commentData: "第二页评论三",
commentScore: 4
}
]
})
);
vi.stubGlobal("fetch", fetchMock);
const service = new JdLiveSessionService();
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: 2
});
expect(fetchMock).toHaveBeenCalledTimes(2);
const firstUrl = fetchMock.mock.calls[0]?.[0];
const secondUrl = fetchMock.mock.calls[1]?.[0];
if (!firstUrl || !secondUrl) {
throw new Error("Expected paged review requests to be issued.");
}
expect(decodeURIComponent(new URL(firstUrl).searchParams.get("body") ?? "")).toContain(
'"sku":100068388535'
);
expect(decodeURIComponent(new URL(firstUrl).searchParams.get("body") ?? "")).toContain(
'"page":1'
);
expect(decodeURIComponent(new URL(secondUrl).searchParams.get("body") ?? "")).toContain(
'"page":2'
);
expect(preview.pagination).toMatchObject({
requestedPage: 1,
requestedCommentCount: 2,
maxPages: 2,
pagesFetched: 2,
pageKey: "page"
});
expect(preview.reviews.tags).toHaveLength(1);
expect(preview.reviews.comments.map((comment) => comment.id)).toEqual([
"comment-1",
"comment-2",
"comment-3"
]);
});
it("rejects multi-page review replay when the imported template has no page field", async () => {
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>();
vi.stubGlobal("fetch", fetchMock);
const service = new JdLiveSessionService();
service.importSession({
cookieHeader: "thor=masked;",
reviewsTemplateUrl:
"https://api.m.jd.com/?functionId=getLegoWareDetailComment&body=%7B%22shopType%22:%220%22,%22sku%22:100068388533,%22commentNum%22:5,%22source%22:%22pc%22%7D"
});
await expect(
service.previewReviews("100068388535", {
commentCount: 5,
maxPages: 2
})
).rejects.toMatchObject({
message: expect.stringContaining("does not expose a page field")
});
expect(fetchMock).not.toHaveBeenCalled();
});
it("stops paged review replay when the next page contains no new comments", async () => {
const fetchMock = vi
.fn<(input: string, init?: RequestInit) => Promise<Response>>()
.mockResolvedValueOnce(
buildResponse({
commentInfoList: [
{
commentId: "comment-1",
commentData: "第一页评论一",
commentScore: 5
},
{
commentId: "comment-2",
commentData: "第一页评论二",
commentScore: 4
}
]
})
)
.mockResolvedValueOnce(
buildResponse({
commentInfoList: [
{
commentId: "comment-1",
commentData: "第一页评论一",
commentScore: 5
},
{
commentId: "comment-2",
commentData: "第一页评论二",
commentScore: 4
}
]
})
)
.mockResolvedValueOnce(
buildResponse({
commentInfoList: [
{
commentId: "comment-3",
commentData: "第三页评论三",
commentScore: 4
}
]
})
);
vi.stubGlobal("fetch", fetchMock);
const service = new JdLiveSessionService();
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: 3
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(preview.pagination.pagesFetched).toBe(2);
expect(preview.reviews.comments.map((comment) => comment.id)).toEqual([
"comment-1",
"comment-2"
]);
});
it("surfaces JD verification pages as a risk-blocked error", async () => {
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(async () =>
new Response("请完成验证码验证后继续访问", {
status: 200,
headers: {
"Content-Type": "text/html"
}
})
);
vi.stubGlobal("fetch", fetchMock);
const service = new JdLiveSessionService();
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"
});
await expect(service.previewDetail("100068388535")).rejects.toMatchObject({
statusCode: 423,
code: "RISK_BLOCKED"
});
});
it("rejects a detail template that still resolves to another sku", async () => {
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(async () =>
buildResponse({
wareInfo: {
skuId: "100068388533"
},
skuHeadVO: {
skuTitle: "Apple iPhone 15"
},
price: {
p: "4398.00"
}
})
);
vi.stubGlobal("fetch", fetchMock);
const service = new JdLiveSessionService();
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"
});
await expect(service.previewDetail("100068388535")).rejects.toMatchObject({
statusCode: 409,
code: "TEMPLATE_EXPIRED",
message: expect.stringContaining("another sku")
});
});
});

View File

@ -7,19 +7,71 @@ import {
import type { import type {
JdDetailPreviewResult, JdDetailPreviewResult,
JdLiveService, JdLiveService,
JdProductPreviewResult,
JdLiveSessionInput, JdLiveSessionInput,
JdLiveSessionSummary, JdLiveSessionSummary,
JdReviewsPaginationSummary,
JdReviewsPreviewOptions,
JdReviewsPreviewResult, JdReviewsPreviewResult,
JdSearchMode, JdSearchMode,
JdSearchPreviewResult, JdSearchPreviewResult,
JdTemplateSummary JdTemplateSummary
} from "./types"; } from "./types";
import { firstString, readQueryBody, withUpdatedQueryBody } from "./utils"; import {
findFirstBodyKey,
firstString,
readQueryBody,
withUpdatedQueryBody
} from "./utils";
const DEFAULT_JD_USER_AGENT = const DEFAULT_JD_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36"; "(KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36";
const DETAIL_SKU_BODY_KEYS = ["skuId", "sku", "wareId", "itemSkuId"];
const REVIEW_SKU_BODY_KEYS = ["sku", "skuId", "wareId", "itemSkuId"];
const REVIEW_PAGE_BODY_KEYS = [
"page",
"pageIndex",
"pageNum",
"currentPage",
"commentPage"
];
const REVIEW_PAGE_SIZE_BODY_KEYS = ["commentNum", "pageSize", "pageLimit"];
const SEARCH_FUNCTION_ID = "pc_search_searchWare";
const DETAIL_FUNCTION_ID = "pc_detailpage_wareBusiness";
const REVIEWS_FUNCTION_ID = "getLegoWareDetailComment";
const JD_LOGIN_MARKERS = ["passport.jd.com", "请登录", "登录后可见"];
const JD_RISK_MARKERS = [
"验证码",
"安全验证",
"请完成验证",
"访问受限",
"异常访问",
"verify",
"captcha"
];
type JdLiveErrorCode =
| "INVALID_COOKIE"
| "MISSING_SESSION"
| "INVALID_TEMPLATE"
| "TEMPLATE_MISSING"
| "TEMPLATE_EXPIRED"
| "TEMPLATE_PAGE_FIELD_MISSING"
| "TEMPLATE_QUERY_LOCKED"
| "SESSION_REQUIRED"
| "RISK_BLOCKED"
| "NETWORK_ERROR"
| "HTTP_ERROR"
| "BAD_REQUEST";
type ResolvedJdReviewsPreviewOptions = {
commentCount: number;
page: number;
maxPages: number;
};
type StoredJdLiveSession = { type StoredJdLiveSession = {
cookieHeader: string; cookieHeader: string;
importedAt: string; importedAt: string;
@ -34,7 +86,8 @@ type StoredJdLiveSession = {
class JdLiveError extends Error { class JdLiveError extends Error {
constructor( constructor(
message: string, message: string,
readonly statusCode: number = 400 readonly statusCode: number = 400,
readonly code: JdLiveErrorCode = "BAD_REQUEST"
) { ) {
super(message); super(message);
this.name = "JdLiveError"; this.name = "JdLiveError";
@ -72,7 +125,11 @@ function readEnvSession(): StoredJdLiveSession | null {
function requireNonEmptyCookie(cookieHeader: string): string { function requireNonEmptyCookie(cookieHeader: string): string {
const normalized = cookieHeader.trim(); const normalized = cookieHeader.trim();
if (!normalized) { if (!normalized) {
throw new JdLiveError("cookieHeader is required for JD live requests."); throw new JdLiveError(
"cookieHeader is required for JD live requests.",
400,
"INVALID_COOKIE"
);
} }
return normalized; return normalized;
@ -83,9 +140,13 @@ function extractTemplateSkuId(templateUrl: string | undefined): string | undefin
return undefined; return undefined;
} }
try {
const url = new URL(templateUrl); const url = new URL(templateUrl);
const body = readQueryBody(url); const body = readQueryBody(url);
return firstString(body?.skuId, body?.sku) ?? undefined; return firstString(body?.skuId, body?.sku) ?? undefined;
} catch {
return undefined;
}
} }
function buildTemplateSummary(templateUrl: string | undefined): JdTemplateSummary { function buildTemplateSummary(templateUrl: string | undefined): JdTemplateSummary {
@ -105,8 +166,226 @@ function templateMatchesQuery(
return false; return false;
} }
try {
const templateKeyword = new URL(templateUrl).searchParams.get("keyword"); const templateKeyword = new URL(templateUrl).searchParams.get("keyword");
return Boolean(templateKeyword && templateKeyword === query); return Boolean(templateKeyword && templateKeyword === query);
} catch {
return false;
}
}
function coerceBodyValue(existing: unknown, nextValue: number | string): number | string {
if (typeof existing === "number") {
return typeof nextValue === "number" ? nextValue : Number.parseInt(nextValue, 10);
}
return String(nextValue);
}
function normalizePositiveInteger(
value: number | undefined,
fieldName: string,
fallback: number,
max: number
): number {
if (value === undefined) {
return fallback;
}
if (!Number.isInteger(value) || value < 1 || value > max) {
throw new JdLiveError(`${fieldName} must be an integer between 1 and ${max}.`);
}
return value;
}
function normalizeReviewsPreviewOptions(
options?: number | JdReviewsPreviewOptions
): ResolvedJdReviewsPreviewOptions {
if (typeof options === "number") {
return {
commentCount: normalizePositiveInteger(options, "commentCount", 5, 50),
page: 1,
maxPages: 1
};
}
return {
commentCount: normalizePositiveInteger(options?.commentCount, "commentCount", 5, 50),
page: normalizePositiveInteger(options?.page, "page", 1, 1000),
maxPages: normalizePositiveInteger(options?.maxPages, "maxPages", 1, 10)
};
}
function parseTemplateUrlOrThrow(
templateUrl: string,
templateLabel: string
): URL {
try {
return new URL(templateUrl);
} catch {
throw new JdLiveError(
`${templateLabel} must be a valid URL.`,
400,
"INVALID_TEMPLATE"
);
}
}
function validateTemplateFunctionId(
templateUrl: string,
templateLabel: string,
expectedFunctionId: string
): URL {
const url = parseTemplateUrlOrThrow(templateUrl, templateLabel);
const functionId = url.searchParams.get("functionId");
if (functionId !== expectedFunctionId) {
throw new JdLiveError(
`${templateLabel} must point to functionId=${expectedFunctionId}.`,
400,
"INVALID_TEMPLATE"
);
}
return url;
}
function validateTemplateBodyKey(
url: URL,
bodyKeys: string[],
_templateLabel: string,
errorMessage: string
): void {
const body = readQueryBody(url);
if (!findFirstBodyKey(body, bodyKeys)) {
throw new JdLiveError(errorMessage, 409, "TEMPLATE_EXPIRED");
}
}
function validateImportedTemplates(input: {
searchApiTemplateUrl?: string | undefined;
detailTemplateUrl?: string | undefined;
reviewsTemplateUrl?: string | undefined;
}): void {
if (input.searchApiTemplateUrl) {
validateTemplateFunctionId(
input.searchApiTemplateUrl,
"JD search API template",
SEARCH_FUNCTION_ID
);
}
if (input.detailTemplateUrl) {
const detailUrl = validateTemplateFunctionId(
input.detailTemplateUrl,
"JD detail template",
DETAIL_FUNCTION_ID
);
validateTemplateBodyKey(
detailUrl,
DETAIL_SKU_BODY_KEYS,
"JD detail template",
"JD detail template does not expose a sku field. Capture a fresh pc_detailpage_wareBusiness request first."
);
}
if (input.reviewsTemplateUrl) {
const reviewsUrl = validateTemplateFunctionId(
input.reviewsTemplateUrl,
"JD reviews template",
REVIEWS_FUNCTION_ID
);
validateTemplateBodyKey(
reviewsUrl,
REVIEW_SKU_BODY_KEYS,
"JD reviews template",
"JD reviews template does not expose a sku field. Capture a fresh getLegoWareDetailComment request first."
);
}
}
function buildDetailRequestUrl(templateUrl: string, skuId: string): string {
const template = new URL(templateUrl);
const body = readQueryBody(template);
const skuKey = findFirstBodyKey(body, DETAIL_SKU_BODY_KEYS) ?? "skuId";
return withUpdatedQueryBody(template, (currentBody) => ({
...currentBody,
[skuKey]: coerceBodyValue(currentBody[skuKey], skuId)
}));
}
function buildReviewsRequestUrl(
templateUrl: string,
skuId: string,
options: ResolvedJdReviewsPreviewOptions,
page: number
): {
url: string;
pageKey?: string | undefined;
} {
const template = new URL(templateUrl);
const body = readQueryBody(template);
const skuKey = findFirstBodyKey(body, REVIEW_SKU_BODY_KEYS) ?? "sku";
const pageKey = findFirstBodyKey(body, REVIEW_PAGE_BODY_KEYS) ?? undefined;
const pageSizeKey = findFirstBodyKey(body, REVIEW_PAGE_SIZE_BODY_KEYS) ?? "commentNum";
if ((options.maxPages > 1 || options.page > 1) && !pageKey) {
throw new JdLiveError(
"Imported reviews template does not expose a page field. Capture a paged getLegoWareDetailComment request first.",
409,
"TEMPLATE_PAGE_FIELD_MISSING"
);
}
return {
url: withUpdatedQueryBody(template, (currentBody) => {
const nextBody: Record<string, unknown> = {
...currentBody,
[skuKey]: coerceBodyValue(currentBody[skuKey], skuId),
[pageSizeKey]: coerceBodyValue(currentBody[pageSizeKey], options.commentCount)
};
if (pageKey) {
nextBody[pageKey] = coerceBodyValue(currentBody[pageKey], page);
}
return nextBody;
}),
...(pageKey ? { pageKey } : {})
};
}
function mergeReviewPages(
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())
};
} }
async function fetchTextOrThrow( async function fetchTextOrThrow(
@ -126,19 +405,52 @@ async function fetchTextOrThrow(
`JD live request failed before receiving a response: ${ `JD live request failed before receiving a response: ${
error instanceof Error ? error.message : "unknown error" error instanceof Error ? error.message : "unknown error"
}`, }`,
502 502,
"NETWORK_ERROR"
); );
} }
const text = await response.text(); const text = await response.text();
if (response.url.includes("passport.jd.com") || text.includes("passport.jd.com")) { const normalizedResponseUrl = response.url.toLowerCase();
throw new JdLiveError(sessionExpiredMessage, 409); const normalizedText = text.toLowerCase();
if (
JD_LOGIN_MARKERS.some(
(marker) =>
normalizedResponseUrl.includes(marker.toLowerCase()) ||
normalizedText.includes(marker.toLowerCase())
)
) {
throw new JdLiveError(sessionExpiredMessage, 409, "SESSION_REQUIRED");
}
if (
JD_RISK_MARKERS.some(
(marker) =>
normalizedResponseUrl.includes(marker.toLowerCase()) ||
normalizedText.includes(marker.toLowerCase())
)
) {
throw new JdLiveError(
"JD request hit verification or risk control. Refresh the session/template in the browser first.",
423,
"RISK_BLOCKED"
);
} }
if (!response.ok) { if (!response.ok) {
const errorCode: JdLiveErrorCode =
response.status === 401
? "SESSION_REQUIRED"
: response.status === 403 || response.status === 412 || response.status === 423
? "RISK_BLOCKED"
: "HTTP_ERROR";
const errorStatus =
errorCode === "SESSION_REQUIRED" ? 409 : errorCode === "RISK_BLOCKED" ? 423 : 502;
throw new JdLiveError( throw new JdLiveError(
`JD live request failed with status ${response.status}.`, `JD live request failed with status ${response.status}.`,
502 errorStatus,
errorCode
); );
} }
@ -148,10 +460,16 @@ async function fetchTextOrThrow(
}; };
} }
export function isJdLiveError(error: unknown): error is Error & { statusCode: number } { export function isJdLiveError(
error: unknown
): error is Error & { statusCode: number; code?: JdLiveErrorCode } {
return error instanceof Error && "statusCode" in error; return error instanceof Error && "statusCode" in error;
} }
export function getJdLiveErrorCode(error: unknown): JdLiveErrorCode | undefined {
return isJdLiveError(error) && typeof error.code === "string" ? error.code : undefined;
}
export class JdLiveSessionService implements JdLiveService { export class JdLiveSessionService implements JdLiveService {
private session: StoredJdLiveSession | null = readEnvSession(); private session: StoredJdLiveSession | null = readEnvSession();
@ -174,6 +492,12 @@ export class JdLiveSessionService implements JdLiveService {
const searchReferer = input.searchReferer?.trim(); const searchReferer = input.searchReferer?.trim();
const detailReferer = input.detailReferer?.trim(); const detailReferer = input.detailReferer?.trim();
validateImportedTemplates({
searchApiTemplateUrl,
detailTemplateUrl,
reviewsTemplateUrl
});
this.session = { this.session = {
cookieHeader: requireNonEmptyCookie(input.cookieHeader), cookieHeader: requireNonEmptyCookie(input.cookieHeader),
importedAt: nowIso(), importedAt: nowIso(),
@ -199,7 +523,7 @@ export class JdLiveSessionService implements JdLiveService {
const session = this.requireSession(); const session = this.requireSession();
const normalizedQuery = query.trim(); const normalizedQuery = query.trim();
if (!normalizedQuery) { if (!normalizedQuery) {
throw new JdLiveError("query is required for JD live search preview."); throw new JdLiveError("query is required for JD live search preview.", 400, "BAD_REQUEST");
} }
const resolvedMode = const resolvedMode =
@ -209,7 +533,9 @@ export class JdLiveSessionService implements JdLiveService {
if (resolvedMode === "api") { if (resolvedMode === "api") {
if (!session.searchApiTemplateUrl) { if (!session.searchApiTemplateUrl) {
throw new JdLiveError( throw new JdLiveError(
"JD search API template is missing. Import a fresh search request URL or use mode=html." "JD search API template is missing. Import a fresh search request URL or use mode=html.",
409,
"TEMPLATE_MISSING"
); );
} }
@ -218,7 +544,9 @@ export class JdLiveSessionService implements JdLiveService {
if (templateKeyword && templateKeyword !== normalizedQuery) { if (templateKeyword && templateKeyword !== normalizedQuery) {
throw new JdLiveError( throw new JdLiveError(
`Imported search API template is locked to query "${templateKeyword}". ` + `Imported search API template is locked to query "${templateKeyword}". ` +
"Capture a fresh request for the target query or use mode=html." "Capture a fresh request for the target query or use mode=html.",
409,
"TEMPLATE_QUERY_LOCKED"
); );
} }
@ -275,70 +603,18 @@ export class JdLiveSessionService implements JdLiveService {
const session = this.requireSession(); const session = this.requireSession();
const normalizedSkuId = skuId.trim(); const normalizedSkuId = skuId.trim();
if (!normalizedSkuId) { if (!normalizedSkuId) {
throw new JdLiveError("skuId is required for JD detail preview."); throw new JdLiveError("skuId is required for JD detail preview.", 400, "BAD_REQUEST");
} }
if (!session.detailTemplateUrl) { if (!session.detailTemplateUrl) {
throw new JdLiveError( throw new JdLiveError(
"JD detail template is missing. Capture a fresh pc_detailpage_wareBusiness request and import it first." "JD detail template is missing. Capture a fresh pc_detailpage_wareBusiness request and import it first.",
409,
"TEMPLATE_MISSING"
); );
} }
const templateSkuId = extractTemplateSkuId(session.detailTemplateUrl); const requestUrl = buildDetailRequestUrl(session.detailTemplateUrl, normalizedSkuId);
if (templateSkuId && templateSkuId !== normalizedSkuId) {
throw new JdLiveError(
`Imported detail template is bound to sku ${templateSkuId}. Open the matching JD item page and capture a fresh request for sku ${normalizedSkuId}.`
);
}
const response = await fetchTextOrThrow(
session.detailTemplateUrl,
{
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."
);
return {
skuId: normalizedSkuId,
source: "api",
detail: parseJdDetailApiResponse(normalizedSkuId, { text: response.text })
};
}
async previewReviews(
skuId: string,
commentCount = 5
): Promise<JdReviewsPreviewResult> {
const session = this.requireSession();
const normalizedSkuId = skuId.trim();
if (!normalizedSkuId) {
throw new JdLiveError("skuId is required for JD reviews preview.");
}
if (!session.reviewsTemplateUrl) {
throw new JdLiveError(
"JD reviews template is missing. Capture a fresh getLegoWareDetailComment request and import it first."
);
}
const templateSkuId = extractTemplateSkuId(session.reviewsTemplateUrl);
if (templateSkuId && templateSkuId !== normalizedSkuId) {
throw new JdLiveError(
`Imported reviews template is bound to sku ${templateSkuId}. Open the matching JD item page and capture a fresh request for sku ${normalizedSkuId}.`
);
}
const templateUrl = new URL(session.reviewsTemplateUrl);
const requestUrl = withUpdatedQueryBody(templateUrl, (body) => ({
...body,
commentNum: commentCount
}));
const response = await fetchTextOrThrow( const response = await fetchTextOrThrow(
requestUrl, requestUrl,
@ -350,20 +626,132 @@ export class JdLiveSessionService implements JdLiveService {
"User-Agent": session.userAgent "User-Agent": session.userAgent
} }
}, },
"JD reviews session appears invalid. Re-login in the browser and re-import the cookie/header." "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"
);
}
return { return {
skuId: normalizedSkuId, skuId: normalizedSkuId,
source: "api", source: "api",
reviews: parseJdReviewsApiResponse(normalizedSkuId, { text: response.text }) detail
};
}
async previewReviews(
skuId: string,
options?: number | JdReviewsPreviewOptions
): Promise<JdReviewsPreviewResult> {
const session = this.requireSession();
const normalizedSkuId = skuId.trim();
const resolvedOptions = normalizeReviewsPreviewOptions(options);
if (!normalizedSkuId) {
throw new JdLiveError("skuId is required for JD reviews preview.", 400, "BAD_REQUEST");
}
if (!session.reviewsTemplateUrl) {
throw new JdLiveError(
"JD reviews template is missing. Capture a fresh getLegoWareDetailComment request and import it first.",
409,
"TEMPLATE_MISSING"
);
}
const reviewPages: JdReviewsPreviewResult["reviews"][] = [];
let pageKey: string | undefined;
const seenCommentIds = new Set<string>();
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
}
},
"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;
}
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)
};
}
async previewProduct(
skuId: string,
options?: number | JdReviewsPreviewOptions
): Promise<JdProductPreviewResult> {
const [detailPreview, reviewsPreview] = await Promise.all([
this.previewDetail(skuId),
this.previewReviews(skuId, options)
]);
return {
skuId: detailPreview.skuId,
source: "api",
detail: detailPreview.detail,
pagination: reviewsPreview.pagination,
reviews: reviewsPreview.reviews
}; };
} }
private requireSession(): StoredJdLiveSession { private requireSession(): StoredJdLiveSession {
if (!this.session?.cookieHeader) { if (!this.session?.cookieHeader) {
throw new JdLiveError( throw new JdLiveError(
"JD live session is not configured. Import a browser cookie/header first." "JD live session is not configured. Import a browser cookie/header first.",
409,
"MISSING_SESSION"
); );
} }

View File

@ -0,0 +1,367 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { JdSessionManagerService } from "./session-manager";
import type {
JdDetailPreviewResult,
JdLiveService,
JdLiveSessionInput,
JdLiveSessionSummary,
JdProductPreviewResult,
JdReviewsPreviewOptions,
JdReviewsPreviewResult,
JdSearchPreviewResult
} from "./types";
function createConfiguredSummary(): JdLiveSessionSummary {
return {
configured: true,
importedAt: "2026-04-03T08:00:00.000Z",
hasCookie: true,
userAgent: "stub-user-agent",
searchApiTemplate: {
available: true,
skuId: "100068388533"
},
detailTemplate: {
available: true,
skuId: "100068388533"
},
reviewsTemplate: {
available: true,
skuId: "100068388533"
}
};
}
function createEmptySummary(): JdLiveSessionSummary {
return {
configured: false,
hasCookie: false,
searchApiTemplate: {
available: false
},
detailTemplate: {
available: false
},
reviewsTemplate: {
available: false
}
};
}
function createJdLiveServiceStub(
overrides: Partial<JdLiveService> = {}
): JdLiveService {
let summary = createEmptySummary();
return {
getSessionSummary() {
return overrides.getSessionSummary?.() ?? summary;
},
importSession(input: JdLiveSessionInput) {
if (overrides.importSession) {
return overrides.importSession(input);
}
summary = {
...createConfiguredSummary(),
importedAt: "2026-04-03T08:30:00.000Z",
userAgent: input.userAgent ?? "stub-user-agent",
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;
},
clearSession() {
if (overrides.clearSession) {
overrides.clearSession();
return;
}
summary = createEmptySummary();
},
async previewSearch(query, mode) {
if (overrides.previewSearch) {
return overrides.previewSearch(query, mode);
}
const preview: JdSearchPreviewResult = {
query,
source: mode ?? "html",
candidateCount: 1,
candidates: []
};
return preview;
},
async previewDetail(skuId) {
if (overrides.previewDetail) {
return overrides.previewDetail(skuId);
}
const preview: JdDetailPreviewResult = {
skuId,
source: "api",
detail: {
skuId,
title: "Apple iPhone 15",
price: "4398.00",
originalPrice: "4599.00",
estimatedPrice: "4398.00",
shopName: "JD Self Operated",
vendorId: null,
categoryPath: ["phones"],
stockState: "in stock",
mainImage: "https://img14.360buyimg.com/n2/jfs/t1/example.jpg",
averageScore: "4.9"
}
};
return preview;
},
async previewReviews(skuId, options) {
if (overrides.previewReviews) {
return overrides.previewReviews(skuId, options);
}
const preview: JdReviewsPreviewResult = {
skuId,
source: "api",
pagination: {
requestedPage: typeof options === "object" ? (options?.page ?? 1) : 1,
requestedCommentCount:
typeof options === "number" ? options : (options?.commentCount ?? 1),
maxPages: typeof options === "object" ? (options?.maxPages ?? 1) : 1,
pagesFetched: 1,
pageKey: "page"
},
reviews: {
skuId,
total: "1000",
goodRate: "96%",
pictureCount: "120",
tags: [],
comments: [
{
id: "comment-1",
content: "great product",
score: "5",
creationTime: "2026-04-03 08:00:00",
userLevelName: "PLUS"
}
]
}
};
return preview;
},
async previewProduct(skuId, options?: number | JdReviewsPreviewOptions) {
if (overrides.previewProduct) {
return overrides.previewProduct(skuId, options);
}
const detail = await this.previewDetail(skuId);
const reviews = await this.previewReviews(skuId, options);
const preview: JdProductPreviewResult = {
skuId,
source: "api",
detail: detail.detail,
pagination: reviews.pagination,
reviews: reviews.reviews
};
return preview;
}
};
}
describe("JdSessionManagerService", () => {
const managers: JdSessionManagerService[] = [];
afterEach(() => {
for (const manager of managers.splice(0)) {
manager.shutdown();
}
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
it("accepts manual session imports and marks the manager healthy", async () => {
const onSessionReady = vi.fn();
const manager = new JdSessionManagerService(createJdLiveServiceStub(), {
onSessionReady
});
managers.push(manager);
const state = await manager.importManualSession({
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(onSessionReady).toHaveBeenCalledOnce();
expect(state.status).toBe("healthy");
expect(state.pendingManualAction).toBe(false);
expect(state.session).toMatchObject({
configured: true,
detailTemplate: {
available: true,
skuId: "100068388533"
},
reviewsTemplate: {
available: true,
skuId: "100068388533"
}
});
});
it("passes health checks for a configured and valid session", async () => {
const previewSearch = vi.fn<
(query: string, mode?: "html" | "api") => Promise<JdSearchPreviewResult>
>(async (query, mode) => ({
query,
source: mode ?? "html",
candidateCount: 1,
candidates: []
}));
const previewDetail = vi.fn<(skuId: string) => Promise<JdDetailPreviewResult>>(async (skuId) => ({
skuId,
source: "api",
detail: {
skuId,
title: "Apple iPhone 15",
price: "4398.00",
originalPrice: "4599.00",
estimatedPrice: "4398.00",
shopName: "JD Self Operated",
vendorId: null,
categoryPath: ["phones"],
stockState: "in stock",
mainImage: "https://img14.360buyimg.com/n2/jfs/t1/example.jpg",
averageScore: "4.9"
}
}));
const previewReviews = vi.fn<
(skuId: string, options?: number | JdReviewsPreviewOptions) => Promise<JdReviewsPreviewResult>
>(async (skuId, options) => ({
skuId,
source: "api",
pagination: {
requestedPage: typeof options === "object" ? (options?.page ?? 1) : 1,
requestedCommentCount:
typeof options === "number" ? options : (options?.commentCount ?? 1),
maxPages: typeof options === "object" ? (options?.maxPages ?? 1) : 1,
pagesFetched: 1,
pageKey: "page"
},
reviews: {
skuId,
total: "1000",
goodRate: "96%",
pictureCount: "120",
tags: [],
comments: []
}
}));
const onSessionReady = vi.fn();
const liveService = createJdLiveServiceStub({
getSessionSummary: () => createConfiguredSummary(),
previewSearch,
previewDetail,
previewReviews
});
const manager = new JdSessionManagerService(liveService, {
onSessionReady
});
managers.push(manager);
const result = await manager.runHealthCheck("ops");
expect(result.recovered).toBe(false);
expect(result.state.status).toBe("healthy");
expect(onSessionReady).toHaveBeenCalledOnce();
expect(previewSearch).toHaveBeenCalledWith("iPhone 15", "html");
expect(previewDetail).toHaveBeenCalledWith("100068388533");
expect(previewReviews).toHaveBeenCalledWith("100068388533", {
commentCount: 1,
maxPages: 1
});
});
it("auto-recovers a missing session via the configured recovery command", async () => {
const executeRecoveryCommand = vi.fn(async () => ({
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"
}));
const onSessionReady = vi.fn();
const manager = new JdSessionManagerService(
createJdLiveServiceStub(),
{
onSessionReady
},
{
executeRecoveryCommand
}
);
managers.push(manager);
manager.configure({
enabled: true,
autoLoginMode: "command",
loginCommand: "node scripts/jd-login-ops.mjs"
});
const result = await manager.runHealthCheck("ops");
expect(executeRecoveryCommand).toHaveBeenCalledOnce();
expect(onSessionReady).toHaveBeenCalledOnce();
expect(result.recovered).toBe(true);
expect(result.state.status).toBe("healthy");
expect(result.state.session.configured).toBe(true);
});
it("switches to manual_action_required when JD triggers risk verification", async () => {
const riskBlockedError = Object.assign(new Error("captcha required"), {
statusCode: 423,
code: "RISK_BLOCKED" as const
});
const onSessionUnavailable = vi.fn();
const manager = new JdSessionManagerService(
createJdLiveServiceStub({
getSessionSummary: () => createConfiguredSummary(),
previewSearch: async () => {
throw riskBlockedError;
}
}),
{
onSessionUnavailable
}
);
managers.push(manager);
const result = await manager.runHealthCheck("ops");
expect(result.recovered).toBe(false);
expect(result.state.status).toBe("manual_action_required");
expect(result.state.pendingManualAction).toBe(true);
expect(result.state.lastFailureCode).toBe("RISK_BLOCKED");
expect(onSessionUnavailable).toHaveBeenCalledOnce();
});
});

View File

@ -0,0 +1,645 @@
import { spawn } from "node:child_process";
import { getJdLiveErrorCode, isJdLiveError } from "./live-session";
import type {
JdLiveService,
JdLiveSessionInput,
JdSessionManager,
JdSessionManagerAutoMode,
JdSessionManagerConfigInput,
JdSessionManagerRunResult,
JdSessionManagerState,
JdSessionManagerStatus
} from "./types";
const DEFAULT_HEARTBEAT_QUERY = "iPhone 15";
const DEFAULT_CHECK_INTERVAL_MS = 10 * 60 * 1000;
const DEFAULT_RUNNER_TIMEOUT_MS = 5 * 60 * 1000;
type StoredJdSessionManagerConfig = {
enabled: boolean;
autoLoginMode: JdSessionManagerAutoMode;
loginCommand?: string | undefined;
browserProfilePath?: string | undefined;
heartbeatQuery: string;
account?: string | undefined;
password?: string | undefined;
checkIntervalMs: number;
runnerTimeoutMs: number;
configuredAt?: string | undefined;
};
type JdSessionManagerCallbacks = {
onSessionReady?: () => void;
onSessionUnavailable?: () => void;
};
type JdSessionManagerDependencies = {
executeRecoveryCommand?: (
config: StoredJdSessionManagerConfig
) => Promise<JdLiveSessionInput>;
};
function nowIso(): string {
return new Date().toISOString();
}
function readBooleanEnv(name: string, fallback: boolean): boolean {
const value = process.env[name]?.trim().toLowerCase();
if (!value) {
return fallback;
}
return value === "1" || value === "true" || value === "yes" || value === "on";
}
function readPositiveIntegerEnv(name: string, fallback: number): number {
const value = process.env[name]?.trim();
if (!value) {
return fallback;
}
const parsed = Number.parseInt(value, 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function normalizeOptionalString(value: string | null | undefined): string | undefined {
const normalized = value?.trim();
return normalized ? normalized : undefined;
}
function maskAccount(value: string | undefined): string | undefined {
if (!value) {
return undefined;
}
if (value.length <= 4) {
return "*".repeat(value.length);
}
return `${value.slice(0, 2)}***${value.slice(-2)}`;
}
function buildConfigFromEnv(): StoredJdSessionManagerConfig {
const autoLoginMode =
normalizeOptionalString(process.env.JD_OPS_LOGIN_COMMAND) ? "command" : "disabled";
return {
enabled: readBooleanEnv("JD_OPS_AUTO_ENABLED", true),
autoLoginMode,
loginCommand: normalizeOptionalString(process.env.JD_OPS_LOGIN_COMMAND),
browserProfilePath: normalizeOptionalString(process.env.JD_OPS_BROWSER_PROFILE_DIR),
heartbeatQuery:
normalizeOptionalString(process.env.JD_OPS_HEARTBEAT_QUERY) ?? DEFAULT_HEARTBEAT_QUERY,
account: normalizeOptionalString(process.env.JD_OPS_ACCOUNT),
password: normalizeOptionalString(process.env.JD_OPS_PASSWORD),
checkIntervalMs: readPositiveIntegerEnv(
"JD_OPS_CHECK_INTERVAL_MS",
DEFAULT_CHECK_INTERVAL_MS
),
runnerTimeoutMs: readPositiveIntegerEnv(
"JD_OPS_RUNNER_TIMEOUT_MS",
DEFAULT_RUNNER_TIMEOUT_MS
),
configuredAt:
normalizeOptionalString(process.env.JD_OPS_LOGIN_COMMAND) ||
normalizeOptionalString(process.env.JD_OPS_ACCOUNT) ||
normalizeOptionalString(process.env.JD_OPS_BROWSER_PROFILE_DIR)
? nowIso()
: undefined
};
}
function createBaseState(
liveService: JdLiveService,
config: StoredJdSessionManagerConfig
): JdSessionManagerState {
return {
status: liveService.getSessionSummary().configured ? "degraded" : "idle",
enabled: config.enabled,
autoLoginMode: config.autoLoginMode,
commandConfigured: Boolean(config.loginCommand),
accountConfigured: Boolean(config.account),
passwordConfigured: Boolean(config.password),
accountLabel: maskAccount(config.account),
browserProfilePath: config.browserProfilePath,
heartbeatQuery: config.heartbeatQuery,
checkIntervalMs: config.checkIntervalMs,
runnerTimeoutMs: config.runnerTimeoutMs,
pendingManualAction: false,
note: liveService.getSessionSummary().configured
? "京东会话已导入,等待后台健康检查确认。"
: "京东会话管理器已初始化,等待首次会话注入或自动恢复。",
publicNote: liveService.getSessionSummary().configured
? "京东会话正在后台校验。"
: "京东会话由运维后台维护,当前尚未就绪。",
configuredAt: config.configuredAt,
session: liveService.getSessionSummary()
};
}
function isRecoverableFailureCode(code: string | undefined): boolean {
return (
code === "MISSING_SESSION" ||
code === "SESSION_REQUIRED" ||
code === "INVALID_COOKIE" ||
code === "TEMPLATE_MISSING" ||
code === "TEMPLATE_EXPIRED" ||
code === "TEMPLATE_PAGE_FIELD_MISSING" ||
code === "TEMPLATE_QUERY_LOCKED" ||
code === "INVALID_TEMPLATE" ||
code === "RISK_BLOCKED"
);
}
export class JdSessionManagerService implements JdSessionManager {
private config = buildConfigFromEnv();
private state: JdSessionManagerState;
private timer: NodeJS.Timeout | null = null;
private recoveryInFlight: Promise<boolean> | null = null;
constructor(
private readonly liveService: JdLiveService,
private readonly callbacks: JdSessionManagerCallbacks = {},
private readonly dependencies: JdSessionManagerDependencies = {}
) {
this.state = createBaseState(liveService, this.config);
this.restartScheduler();
}
getState(): JdSessionManagerState {
return {
...this.state,
session: this.liveService.getSessionSummary()
};
}
configure(input: JdSessionManagerConfigInput): JdSessionManagerState {
const nextConfig: StoredJdSessionManagerConfig = {
...this.config,
...(typeof input.enabled === "boolean" ? { enabled: input.enabled } : {}),
...(input.autoLoginMode ? { autoLoginMode: input.autoLoginMode } : {}),
...(input.loginCommand !== undefined
? { loginCommand: normalizeOptionalString(input.loginCommand) }
: {}),
...(input.browserProfilePath !== undefined
? { browserProfilePath: normalizeOptionalString(input.browserProfilePath) }
: {}),
...(input.heartbeatQuery !== undefined
? {
heartbeatQuery:
normalizeOptionalString(input.heartbeatQuery) ?? DEFAULT_HEARTBEAT_QUERY
}
: {}),
...(input.account !== undefined ? { account: normalizeOptionalString(input.account) } : {}),
...(input.password !== undefined
? { password: normalizeOptionalString(input.password) }
: {}),
...(input.checkIntervalMs !== undefined && input.checkIntervalMs !== null
? {
checkIntervalMs: Number.isInteger(input.checkIntervalMs) && input.checkIntervalMs > 0
? input.checkIntervalMs
: this.config.checkIntervalMs
}
: {}),
...(input.runnerTimeoutMs !== undefined && input.runnerTimeoutMs !== null
? {
runnerTimeoutMs:
Number.isInteger(input.runnerTimeoutMs) && input.runnerTimeoutMs > 0
? input.runnerTimeoutMs
: this.config.runnerTimeoutMs
}
: {}),
configuredAt: nowIso()
};
if (!nextConfig.loginCommand) {
nextConfig.autoLoginMode = "disabled";
}
this.config = nextConfig;
this.state = {
...this.state,
enabled: nextConfig.enabled,
autoLoginMode: nextConfig.autoLoginMode,
commandConfigured: Boolean(nextConfig.loginCommand),
accountConfigured: Boolean(nextConfig.account),
passwordConfigured: Boolean(nextConfig.password),
accountLabel: maskAccount(nextConfig.account),
browserProfilePath: nextConfig.browserProfilePath,
heartbeatQuery: nextConfig.heartbeatQuery,
checkIntervalMs: nextConfig.checkIntervalMs,
runnerTimeoutMs: nextConfig.runnerTimeoutMs,
configuredAt: nextConfig.configuredAt,
note: "京东运维配置已更新。",
publicNote: this.state.publicNote,
session: this.liveService.getSessionSummary()
};
this.restartScheduler();
return this.getState();
}
clearConfig(): JdSessionManagerState {
this.config = {
enabled: false,
autoLoginMode: "disabled",
heartbeatQuery: DEFAULT_HEARTBEAT_QUERY,
checkIntervalMs: DEFAULT_CHECK_INTERVAL_MS,
runnerTimeoutMs: DEFAULT_RUNNER_TIMEOUT_MS
};
this.state = {
...this.state,
enabled: false,
autoLoginMode: "disabled",
commandConfigured: false,
accountConfigured: false,
passwordConfigured: false,
accountLabel: undefined,
browserProfilePath: undefined,
heartbeatQuery: DEFAULT_HEARTBEAT_QUERY,
checkIntervalMs: DEFAULT_CHECK_INTERVAL_MS,
runnerTimeoutMs: DEFAULT_RUNNER_TIMEOUT_MS,
configuredAt: nowIso(),
note: "京东运维配置已清空,自动恢复已停用。",
publicNote: this.liveService.getSessionSummary().configured
? "京东会话已存在,但后台自动恢复已停用。"
: "京东会话由运维后台维护,当前自动恢复未启用。",
pendingManualAction: false,
session: this.liveService.getSessionSummary()
};
this.restartScheduler();
return this.getState();
}
async importManualSession(
input: JdLiveSessionInput,
source = "ops-manual"
): Promise<JdSessionManagerState> {
this.liveService.importSession(input);
this.callbacks.onSessionReady?.();
this.state = {
...this.state,
status: "healthy",
pendingManualAction: false,
note: `京东会话已通过 ${source} 更新,等待下一轮健康检查。`,
publicNote: "京东会话由运维后台维护,当前可用。",
lastRecoveredAt: nowIso(),
lastHealthyAt: nowIso(),
lastFailureCode: undefined,
lastFailureMessage: undefined,
session: this.liveService.getSessionSummary()
};
return this.getState();
}
clearManagedSession(reason = "ops-manual-clear"): JdSessionManagerState {
this.liveService.clearSession();
this.callbacks.onSessionUnavailable?.();
this.state = {
...this.state,
status: "idle",
pendingManualAction: false,
note: `京东会话已清理:${reason}`,
publicNote: "京东会话由运维后台维护,当前尚未就绪。",
session: this.liveService.getSessionSummary()
};
return this.getState();
}
async runHealthCheck(trigger = "manual"): Promise<JdSessionManagerRunResult> {
this.state = {
...this.state,
lastCheckAt: nowIso(),
session: this.liveService.getSessionSummary()
};
const summary = this.liveService.getSessionSummary();
if (!summary.configured) {
if (this.canAutoRecover()) {
return this.runAutoRecovery(`${trigger}:missing_session`);
}
this.callbacks.onSessionUnavailable?.();
this.state = {
...this.state,
status: "manual_action_required",
pendingManualAction: true,
note: "当前没有可用京东会话,等待运维注入或自动恢复命令。",
publicNote: "京东会话由运维后台维护,当前尚未就绪。",
session: summary
};
return {
state: this.getState(),
recovered: false
};
}
if (!summary.detailTemplate.available || !summary.reviewsTemplate.available) {
if (this.canAutoRecover()) {
return this.runAutoRecovery(`${trigger}:template_missing`);
}
this.callbacks.onSessionUnavailable?.();
this.state = {
...this.state,
status: "manual_action_required",
pendingManualAction: true,
note: "京东详情或评论模板缺失,需要运维刷新模板。",
publicNote: "京东会话缺少有效模板,运维后台正在处理。",
session: summary
};
return {
state: this.getState(),
recovered: false
};
}
try {
await this.verifyCurrentSession();
this.callbacks.onSessionReady?.();
this.state = {
...this.state,
status: "healthy",
pendingManualAction: false,
note: `京东会话健康检查通过:${trigger}`,
publicNote: "京东会话由运维后台维护,当前可用。",
lastHealthyAt: nowIso(),
lastFailureCode: undefined,
lastFailureMessage: undefined,
session: this.liveService.getSessionSummary()
};
return {
state: this.getState(),
recovered: false
};
} catch (error) {
const recovered = await this.handleFailure(error, `${trigger}:health_check`);
return {
state: this.getState(),
recovered
};
}
}
async runAutoRecovery(trigger = "manual"): Promise<JdSessionManagerRunResult> {
const recovered = await this.attemptAutoRecovery(trigger);
return {
state: this.getState(),
recovered
};
}
async handleLiveFailure(
error: unknown,
context: {
capability?: "search" | "detail" | "reviews";
taskId?: string | undefined;
trigger?: string | undefined;
} = {}
): Promise<boolean> {
const contextLabel = [
context.trigger ?? "system",
context.capability ?? "unknown",
context.taskId ?? "no-task"
].join(":");
return this.handleFailure(error, contextLabel);
}
shutdown(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
private restartScheduler(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
if (!this.config.enabled) {
return;
}
this.timer = setInterval(() => {
void this.runHealthCheck("scheduler");
}, this.config.checkIntervalMs);
this.timer.unref?.();
}
private canAutoRecover(): boolean {
return (
this.config.enabled &&
this.config.autoLoginMode === "command" &&
Boolean(this.config.loginCommand)
);
}
private async handleFailure(error: unknown, trigger: string): Promise<boolean> {
const code = getJdLiveErrorCode(error);
const message = error instanceof Error ? error.message : "unknown error";
this.state = {
...this.state,
status: isRecoverableFailureCode(code) ? "degraded" : "manual_action_required",
pendingManualAction: code === "RISK_BLOCKED" || !this.canAutoRecover(),
lastFailureCode: code,
lastFailureMessage: message,
note: `京东会话检测失败:${message}`,
publicNote:
code === "RISK_BLOCKED"
? "京东触发了风控验证,运维后台需要人工恢复。"
: "京东会话正在后台恢复,请稍后自动重试。",
session: this.liveService.getSessionSummary()
};
if (!isRecoverableFailureCode(code)) {
this.callbacks.onSessionUnavailable?.();
return false;
}
if (code === "RISK_BLOCKED" && !this.canAutoRecover()) {
this.callbacks.onSessionUnavailable?.();
this.state = {
...this.state,
status: "manual_action_required",
pendingManualAction: true,
note: "京东触发风控验证,需要运维人工处理。",
publicNote: "京东触发了风控验证,运维后台需要人工恢复。"
};
return false;
}
return this.attemptAutoRecovery(trigger);
}
private async attemptAutoRecovery(trigger: string): Promise<boolean> {
if (!this.canAutoRecover()) {
this.callbacks.onSessionUnavailable?.();
this.state = {
...this.state,
status: "manual_action_required",
pendingManualAction: true,
note: "未配置自动恢复命令,等待运维人工更新京东会话。",
publicNote: "京东会话需要运维人工恢复,系统会在恢复后自动继续。"
};
return false;
}
if (this.recoveryInFlight) {
return this.recoveryInFlight;
}
this.recoveryInFlight = this.runRecoveryCommand(trigger).finally(() => {
this.recoveryInFlight = null;
});
return this.recoveryInFlight;
}
private async runRecoveryCommand(trigger: string): Promise<boolean> {
this.callbacks.onSessionUnavailable?.();
this.state = {
...this.state,
status: "recovering",
pendingManualAction: false,
lastRecoverAttemptAt: nowIso(),
note: `正在执行京东自动恢复:${trigger}`,
publicNote: "京东会话正在后台恢复,请稍后自动重试。",
session: this.liveService.getSessionSummary()
};
try {
const payload = await this.executeRecoveryCommand();
this.liveService.importSession(payload);
await this.verifyCurrentSession();
this.callbacks.onSessionReady?.();
this.state = {
...this.state,
status: "healthy",
pendingManualAction: false,
note: `京东自动恢复成功:${trigger}`,
publicNote: "京东会话由运维后台维护,当前可用。",
lastRecoveredAt: nowIso(),
lastHealthyAt: nowIso(),
lastFailureCode: undefined,
lastFailureMessage: undefined,
session: this.liveService.getSessionSummary()
};
return true;
} catch (error) {
const message = error instanceof Error ? error.message : "unknown error";
const code =
getJdLiveErrorCode(error) ??
(isJdLiveError(error) ? String(error.statusCode) : "OPS_RECOVERY_FAILED");
this.callbacks.onSessionUnavailable?.();
this.state = {
...this.state,
status: "manual_action_required",
pendingManualAction: true,
note: `京东自动恢复失败:${message}`,
publicNote: "京东会话需要运维人工恢复,系统会在恢复后自动继续。",
lastFailureCode: code,
lastFailureMessage: message,
session: this.liveService.getSessionSummary()
};
return false;
}
}
private async verifyCurrentSession(): Promise<void> {
const summary = this.liveService.getSessionSummary();
const detailSkuId = summary.detailTemplate.skuId;
const reviewsSkuId = summary.reviewsTemplate.skuId ?? detailSkuId;
if (!summary.configured || !detailSkuId || !reviewsSkuId) {
throw new Error("京东会话缺少可校验的详情/评论模板。");
}
await this.liveService.previewSearch(this.config.heartbeatQuery, "html");
await this.liveService.previewDetail(detailSkuId);
await this.liveService.previewReviews(reviewsSkuId, {
commentCount: 1,
maxPages: 1
});
}
private executeRecoveryCommand(): Promise<JdLiveSessionInput> {
if (this.dependencies.executeRecoveryCommand) {
return this.dependencies.executeRecoveryCommand(this.config);
}
return new Promise((resolve, reject) => {
const command = this.config.loginCommand;
if (!command) {
reject(new Error("JD ops login command is not configured."));
return;
}
const child = spawn(command, {
cwd: process.cwd(),
env: {
...process.env,
JD_OPS_ACCOUNT: this.config.account ?? "",
JD_OPS_PASSWORD: this.config.password ?? "",
JD_OPS_BROWSER_PROFILE_DIR: this.config.browserProfilePath ?? "",
JD_OPS_HEARTBEAT_QUERY: this.config.heartbeatQuery
},
shell: true,
stdio: ["ignore", "pipe", "pipe"]
});
let stdout = "";
let stderr = "";
const timeout = setTimeout(() => {
child.kill();
reject(
new Error(
`JD ops login command timed out after ${this.config.runnerTimeoutMs}ms.`
)
);
}, this.config.runnerTimeoutMs);
child.stdout.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
child.on("error", (error) => {
clearTimeout(timeout);
reject(error);
});
child.on("close", (code) => {
clearTimeout(timeout);
if (code !== 0) {
reject(
new Error(
`JD ops login command failed with code ${code}. ${stderr.trim() || stdout.trim()}`
)
);
return;
}
const trimmed = stdout.trim();
if (!trimmed) {
reject(new Error("JD ops login command returned an empty payload."));
return;
}
try {
const payload = JSON.parse(trimmed) as JdLiveSessionInput;
resolve(payload);
} catch {
reject(
new Error(
`JD ops login command did not return valid JSON. Output: ${trimmed.slice(-500)}`
)
);
}
});
});
}
}

View File

@ -27,6 +27,57 @@ export interface JdLiveSessionSummary {
reviewsTemplate: JdTemplateSummary; reviewsTemplate: JdTemplateSummary;
} }
export type JdSessionManagerStatus =
| "idle"
| "healthy"
| "degraded"
| "recovering"
| "manual_action_required";
export type JdSessionManagerAutoMode = "disabled" | "command";
export interface JdSessionManagerConfigInput {
enabled?: boolean | undefined;
autoLoginMode?: JdSessionManagerAutoMode | undefined;
loginCommand?: string | null | undefined;
browserProfilePath?: string | null | undefined;
heartbeatQuery?: string | null | undefined;
account?: string | null | undefined;
password?: string | null | undefined;
checkIntervalMs?: number | null | undefined;
runnerTimeoutMs?: number | null | undefined;
}
export interface JdSessionManagerState {
status: JdSessionManagerStatus;
enabled: boolean;
autoLoginMode: JdSessionManagerAutoMode;
commandConfigured: boolean;
accountConfigured: boolean;
passwordConfigured: boolean;
accountLabel?: string | undefined;
browserProfilePath?: string | undefined;
heartbeatQuery: string;
checkIntervalMs: number;
runnerTimeoutMs: number;
pendingManualAction: boolean;
note: string;
publicNote: string;
configuredAt?: string | undefined;
lastCheckAt?: string | undefined;
lastHealthyAt?: string | undefined;
lastRecoverAttemptAt?: string | undefined;
lastRecoveredAt?: string | undefined;
lastFailureCode?: string | undefined;
lastFailureMessage?: string | undefined;
session: JdLiveSessionSummary;
}
export interface JdSessionManagerRunResult {
state: JdSessionManagerState;
recovered: boolean;
}
export interface JdSearchPreviewResult { export interface JdSearchPreviewResult {
query: string; query: string;
source: JdSearchMode; source: JdSearchMode;
@ -71,6 +122,20 @@ export interface JdProductReviewsSnapshot {
comments: JdReviewCommentSnapshot[]; comments: JdReviewCommentSnapshot[];
} }
export interface JdReviewsPreviewOptions {
commentCount?: number | undefined;
page?: number | undefined;
maxPages?: number | undefined;
}
export interface JdReviewsPaginationSummary {
requestedPage: number;
requestedCommentCount: number;
maxPages: number;
pagesFetched: number;
pageKey?: string | undefined;
}
export interface JdDetailPreviewResult { export interface JdDetailPreviewResult {
skuId: string; skuId: string;
source: "api"; source: "api";
@ -80,6 +145,15 @@ export interface JdDetailPreviewResult {
export interface JdReviewsPreviewResult { export interface JdReviewsPreviewResult {
skuId: string; skuId: string;
source: "api"; source: "api";
pagination: JdReviewsPaginationSummary;
reviews: JdProductReviewsSnapshot;
}
export interface JdProductPreviewResult {
skuId: string;
source: "api";
detail: JdProductDetailSnapshot;
pagination: JdReviewsPaginationSummary;
reviews: JdProductReviewsSnapshot; reviews: JdProductReviewsSnapshot;
} }
@ -89,5 +163,34 @@ export interface JdLiveService {
clearSession(): void; clearSession(): void;
previewSearch(query: string, mode?: JdSearchMode): Promise<JdSearchPreviewResult>; previewSearch(query: string, mode?: JdSearchMode): Promise<JdSearchPreviewResult>;
previewDetail(skuId: string): Promise<JdDetailPreviewResult>; previewDetail(skuId: string): Promise<JdDetailPreviewResult>;
previewReviews(skuId: string, commentCount?: number): Promise<JdReviewsPreviewResult>; previewReviews(
skuId: string,
options?: number | JdReviewsPreviewOptions
): Promise<JdReviewsPreviewResult>;
previewProduct(
skuId: string,
options?: number | JdReviewsPreviewOptions
): Promise<JdProductPreviewResult>;
}
export interface JdSessionManager {
getState(): JdSessionManagerState;
configure(input: JdSessionManagerConfigInput): JdSessionManagerState;
clearConfig(): JdSessionManagerState;
importManualSession(
input: JdLiveSessionInput,
source?: string
): Promise<JdSessionManagerState>;
clearManagedSession(reason?: string): JdSessionManagerState;
runHealthCheck(trigger?: string): Promise<JdSessionManagerRunResult>;
runAutoRecovery(trigger?: string): Promise<JdSessionManagerRunResult>;
handleLiveFailure(
error: unknown,
context?: {
capability?: "search" | "detail" | "reviews";
taskId?: string | undefined;
trigger?: string | undefined;
}
): Promise<boolean>;
shutdown(): void;
} }

View File

@ -103,6 +103,23 @@ export function readQueryBody(url: URL): Record<string, unknown> | null {
return parseEmbeddedJson(url.searchParams.get("body")); return parseEmbeddedJson(url.searchParams.get("body"));
} }
export function findFirstBodyKey(
body: Record<string, unknown> | null,
candidates: string[]
): string | null {
if (!body) {
return null;
}
for (const candidate of candidates) {
if (candidate in body) {
return candidate;
}
}
return null;
}
export function withUpdatedQueryBody( export function withUpdatedQueryBody(
url: URL, url: URL,
updater: (body: Record<string, unknown>) => Record<string, unknown> updater: (body: Record<string, unknown>) => Record<string, unknown>

View File

@ -0,0 +1,356 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { TmallLiveSessionService } from "./live-session";
function buildResponse(body: Record<string, unknown>): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: {
"Content-Type": "application/json"
}
});
}
function buildHtmlResponse(body: string): Response {
return new Response(body, {
status: 200,
headers: {
"Content-Type": "text/html"
}
});
}
function buildTextResponse(body: string): Response {
return new Response(body, {
status: 200,
headers: {
"Content-Type": "application/javascript"
}
});
}
describe("TmallLiveSessionService", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("fetches the mall search page and parses candidates from embedded state", async () => {
const html = `<script>var g_page_config = ${JSON.stringify({
mods: {
itemlist: {
data: {
auctions: [
{
nid: "934454505228",
raw_title: "Apple iPhone 15 Natural Titanium 128GB",
view_price: "4399.00",
nick: "Apple Flagship Store",
detail_url: "//detail.tmall.com/item.htm?id=934454505228",
pic_url: "//img.alicdn.com/example-search.jpg",
view_sales: "sold 10k+",
isTmall: "true"
}
]
}
}
}
})};</script>`;
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(async () =>
buildHtmlResponse(html)
);
vi.stubGlobal("fetch", fetchMock);
const service = new TmallLiveSessionService();
service.importSession({
cookieHeader: "_tb_token_=masked;"
});
const preview = await service.previewSearch("iPhone 15");
expect(preview).toMatchObject({
query: "iPhone 15",
source: "html",
candidateCount: 1,
candidates: [
expect.objectContaining({
candidateId: "tmall-934454505228",
productUrl: "https://detail.tmall.com/item.htm?id=934454505228"
})
]
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
"https://s.taobao.com/search?q=iPhone%2015&tab=mall"
);
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
headers: {
Referer: "https://www.tmall.com/"
}
});
});
it("parses detail from the embedded Tmall SSR page state", async () => {
const pageState = {
appData: null,
loaderData: {
home: {
data: {
res: {
seller: {
shopName: "Apple Flagship Store",
pcShopUrl: "//apple.tmall.com",
sellerType: "B"
},
item: {
itemId: "833444005596",
title: "Apple iPhone 15",
images: ["//img.alicdn.com/example.jpg"],
vagueSellCount: "sold 10k+"
},
componentsVO: {
titleVO: {
title: {
title: "Apple iPhone 15"
},
salesDesc: "sold 10k+"
},
priceVO: {
extraPrice: {
priceText: "4399.00"
},
price: {
priceText: "4999.00"
}
},
rateVO: {
totalCount: "20k+"
},
headImageVO: {
images: ["//img.alicdn.com/example.jpg"]
}
}
}
}
}
}
};
const html = [
"<html><head><title>detail</title></head><body>",
"<script>",
"!(function () {",
"var a = window.__ICE_APP_CONTEXT__ || {};",
`var b = ${JSON.stringify(pageState)};`,
"window.__ICE_APP_CONTEXT__ = Object.assign({}, a, b);",
"})();",
"</script>",
"</body></html>"
].join("");
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(async () =>
buildHtmlResponse(html)
);
vi.stubGlobal("fetch", fetchMock);
const service = new TmallLiveSessionService();
service.importSession({
cookieHeader: "_tb_token_=masked;"
});
const preview = await service.previewDetail("833444005596");
expect(preview.detail).toMatchObject({
itemId: "833444005596",
title: "Apple iPhone 15",
shopName: "Apple Flagship Store",
price: "4399.00",
originalPrice: "4999.00",
salesDesc: "sold 10k+",
commentCount: "20k+",
sellerType: "tmall"
});
expect(preview.source).toBe("html");
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 fetch to receive both url and init.");
}
expect(requestUrl).toBe("https://detail.tmall.com/item.htm?id=833444005596");
expect(requestInit.headers).toMatchObject({
Referer: "https://detail.tmall.com/"
});
});
it("paginates and deduplicates Tmall PC reviews across multiple pages", async () => {
vi.spyOn(Date, "now").mockReturnValue(1775186257185);
const fetchMock = vi
.fn<(input: string, init?: RequestInit) => Promise<Response>>()
.mockResolvedValueOnce(
buildResponse({
ret: ["SUCCESS::ok"],
data: {
module: {
hasNext: "true",
reviewVOList: [
{
id: "review-1",
userNick: "Alice",
reviewDate: "2026-04-03",
reviewWordContent: "First review",
reviewPicPathList: ["//img.alicdn.com/review-1.jpg"]
},
{
id: "review-2",
userNick: "Bob",
reviewDate: "2026-04-03",
reviewWordContent: "Second review"
}
]
}
}
})
)
.mockResolvedValueOnce(
buildResponse({
ret: ["SUCCESS::ok"],
data: {
module: {
hasNext: "false",
reviewVOList: [
{
id: "review-2",
userNick: "Bob",
reviewDate: "2026-04-03",
reviewWordContent: "Second review"
},
{
id: "review-3",
userNick: "Cathy",
reviewDate: "2026-04-04",
reviewWordContent: "Third review"
}
]
}
}
})
);
vi.stubGlobal("fetch", fetchMock);
const service = new TmallLiveSessionService();
service.importSession({
cookieHeader:
"_tb_token_=masked; _m_h5_tk=fcce0507fd586e928c94a9d54fec6a5c_1775194955427;",
reviewsTemplateUrl:
"https://h5api.m.taobao.com/h5/mtop.alibaba.review.list.for.new.pc.detail/1.0/?" +
"api=mtop.alibaba.review.list.for.new.pc.detail&v=1.0&" +
"data=%7B%22itemId%22%3A%22833444005595%22%2C%22bizCode%22%3A%22ali.china.tmall%22%2C%22channel%22%3A%22pc_detail%22%2C%22pageNum%22%3A1%2C%22pageSize%22%3A2%7D"
});
const preview = await service.previewReviews("833444005596", {
commentCount: 2,
page: 1,
maxPages: 2
});
expect(fetchMock).toHaveBeenCalledTimes(2);
const firstUrl = fetchMock.mock.calls[0]?.[0];
const secondUrl = fetchMock.mock.calls[1]?.[0];
if (!firstUrl || !secondUrl) {
throw new Error("Expected paged reviews requests to be issued.");
}
const firstData = JSON.parse(
decodeURIComponent(new URL(firstUrl).searchParams.get("data") ?? "")
) as Record<string, unknown>;
const secondData = JSON.parse(
decodeURIComponent(new URL(secondUrl).searchParams.get("data") ?? "")
) as Record<string, unknown>;
expect(firstData.itemId).toBe("833444005596");
expect(firstData.pageNum).toBe(1);
expect(secondData.pageNum).toBe(2);
expect(new URL(firstUrl).searchParams.get("t")).toBe("1775186257185");
expect(new URL(firstUrl).searchParams.get("sign")).toBe(
"b8fa133d502fd87c9d5bc9dbf95032d7"
);
expect(new URL(secondUrl).searchParams.get("sign")).toBe(
"8eda756f901c9f588209b0498e898928"
);
expect(preview.pagination).toMatchObject({
requestedPage: 1,
requestedCommentCount: 2,
maxPages: 2,
pagesFetched: 2,
pageKey: "pageNum"
});
expect(preview.reviews.comments.map((comment) => comment.id)).toEqual([
"review-1",
"review-2",
"review-3"
]);
});
it("parses H5 review JSONP responses from mtop.taobao.rate.detaillist.get", async () => {
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(async () =>
buildTextResponse(
'mtopjsonp13({"api":"mtop.taobao.rate.detaillist.get","data":{"hasNext":"true","fuzzyRateCount":"5万+","feedPicCountFuzzy":"7000+","feedAppendCountFuzzy":"800+","imprItemVOS":[{"title":"发货速度快","count":"139"}],"rateList":[{"id":"1300870633402","feedback":"挺好的,外观高级","feedbackDate":"2026年3月27日","userNick":"可**月","headPicUrl":"//sns.m.taobao.com/avatar/example","skuMap":{"颜色分类":"小米手环10 银色"},"feedPicPathList":["//img.alicdn.com/review-1.jpg"],"interactInfo":{"likeCount":"1"},"reply":"感谢您的认可"}]}})'
)
);
vi.stubGlobal("fetch", fetchMock);
const service = new TmallLiveSessionService();
service.importSession({
cookieHeader:
"_tb_token_=masked; _m_h5_tk=41fb9bae259f3b525fa0be5bfa989739_1775196962107;",
reviewsTemplateUrl:
"https://h5api.m.tmall.com/h5/mtop.taobao.rate.detaillist.get/6.0/?" +
"api=mtop.taobao.rate.detaillist.get&v=6.0&" +
"data=%7B%22auctionNumId%22%3A%22934454505228%22%2C%22pageNo%22%3A1%2C%22pageSize%22%3A20%7D"
});
const preview = await service.previewReviews("934454505228");
expect(preview.reviews.tags).toEqual([{ name: "发货速度快", count: "139" }]);
expect(preview.reviews.comments).toHaveLength(1);
expect(preview.reviews.comments[0]).toMatchObject({
id: "1300870633402",
content: "挺好的,外观高级",
date: "2026年3月27日",
userNick: "可**月",
likeCount: "1",
reply: "感谢您的认可"
});
expect(preview.reviews.comments[0]?.pictureUrls).toEqual([
"https://img.alicdn.com/review-1.jpg"
]);
expect(preview.reviews.comments[0]?.skuText).toEqual(["小米手环10 银色"]);
});
it("rejects review replay with a login redirect response", async () => {
const fetchMock = vi.fn<(input: string, init?: RequestInit) => Promise<Response>>(async () =>
buildResponse({
ret: ["RGV587_ERROR::SM::session required"],
data: {
url: "https://login.taobao.com/member/login.jhtml?redirectURL=masked"
}
})
);
vi.stubGlobal("fetch", fetchMock);
const service = new TmallLiveSessionService();
service.importSession({
cookieHeader:
"_tb_token_=masked; _m_h5_tk=fcce0507fd586e928c94a9d54fec6a5c_1775194955427;",
reviewsTemplateUrl:
"https://h5api.m.taobao.com/h5/mtop.taobao.rate.detaillist.get/6.0/?" +
"api=mtop.taobao.rate.detaillist.get&v=6.0&data=%7B%22auctionNumId%22%3A%22833444005595%22%2C%22pageNo%22%3A1%2C%22pageSize%22%3A20%7D"
});
await expect(service.previewReviews("833444005596")).rejects.toMatchObject({
statusCode: 409,
message: expect.stringContaining("Re-login")
});
});
});

View File

@ -0,0 +1,705 @@
import { createHash } from "node:crypto";
import {
extractTmallRetCodes,
hasTmallSearchNoResultMarker,
parseTmallDetailApiResponse,
parseTmallDetailHtmlResponse,
parseTmallSearchHtml,
parseTmallReviewsApiResponse
} from "./parsers";
import type {
TmallDetailPreviewResult,
TmallLiveService,
TmallLiveSessionInput,
TmallLiveSessionSummary,
TmallProductPreviewResult,
TmallReviewsPaginationSummary,
TmallReviewsPreviewOptions,
TmallReviewsPreviewResult,
TmallSearchPreviewResult,
TmallTemplateSummary
} from "./types";
import {
asRecord,
findFirstRecordKey,
firstString,
parseEmbeddedJson,
readNestedJsonRecord,
readQueryData,
stringFrom,
withUpdatedQueryData
} from "./utils";
const DEFAULT_TMALL_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 DETAIL_ITEM_DATA_KEYS = ["id", "itemId", "itemNumId"];
const REVIEW_PC_ITEM_DATA_KEYS = ["itemId", "id"];
const REVIEW_H5_ITEM_DATA_KEYS = ["auctionNumId", "itemId", "id"];
const REVIEW_PAGE_DATA_KEYS = ["pageNum", "pageNo", "page", "currentPage"];
const REVIEW_PAGE_SIZE_DATA_KEYS = ["pageSize", "pageLimit", "commentSize"];
const DEFAULT_LOGIC_VERSION = "2025031302";
type ResolvedTmallReviewsPreviewOptions = {
commentCount: number;
page: number;
maxPages: number;
};
type StoredTmallLiveSession = {
cookieHeader: string;
importedAt: string;
userAgent: string;
detailTemplateUrl?: string | undefined;
reviewsTemplateUrl?: string | undefined;
detailReferer?: string | undefined;
};
class TmallLiveError extends Error {
constructor(
message: string,
readonly statusCode: number = 400
) {
super(message);
this.name = "TmallLiveError";
}
}
function nowIso(): string {
return new Date().toISOString();
}
function readEnvSession(): StoredTmallLiveSession | null {
const cookieHeader = process.env.TMALL_COOKIE_HEADER?.trim();
if (!cookieHeader) {
return null;
}
const detailTemplateUrl = process.env.TMALL_DETAIL_TEMPLATE_URL?.trim();
const reviewsTemplateUrl = process.env.TMALL_REVIEWS_TEMPLATE_URL?.trim();
const detailReferer = process.env.TMALL_DETAIL_REFERER?.trim();
return {
cookieHeader,
importedAt: nowIso(),
userAgent: process.env.TMALL_USER_AGENT?.trim() || DEFAULT_TMALL_USER_AGENT,
...(detailTemplateUrl ? { detailTemplateUrl } : {}),
...(reviewsTemplateUrl ? { reviewsTemplateUrl } : {}),
...(detailReferer ? { detailReferer } : {})
};
}
function requireNonEmptyCookie(cookieHeader: string): string {
const normalized = cookieHeader.trim();
if (!normalized) {
throw new TmallLiveError("cookieHeader is required for Tmall live requests.");
}
return normalized;
}
function extractCookieValue(cookieHeader: string, name: string): string | null {
let matched: string | null = null;
for (const part of cookieHeader.split(";")) {
const trimmed = part.trim();
if (!trimmed) {
continue;
}
const separatorIndex = trimmed.indexOf("=");
if (separatorIndex < 0) {
continue;
}
const key = trimmed.slice(0, separatorIndex).trim();
if (key !== name) {
continue;
}
matched = trimmed.slice(separatorIndex + 1).trim();
}
return matched;
}
function extractMtopToken(cookieHeader: string): string {
const tokenCookie = extractCookieValue(cookieHeader, "_m_h5_tk");
const token = tokenCookie?.split("_")[0]?.trim();
if (!token) {
throw new TmallLiveError(
"Tmall cookie header is missing _m_h5_tk, so signed mtop replay cannot be rebuilt."
);
}
return token;
}
function signMtopData(token: string, timestamp: string, appKey: string, data: string): string {
return createHash("md5").update(`${token}&${timestamp}&${appKey}&${data}`).digest("hex");
}
function refreshMtopSignature(url: string, cookieHeader: string): string {
const nextUrl = new URL(url);
const data = nextUrl.searchParams.get("data") ?? nextUrl.searchParams.get("body");
if (!data) {
return nextUrl.toString();
}
const appKey = nextUrl.searchParams.get("appKey") ?? "12574478";
const timestamp = String(Date.now());
const sign = signMtopData(extractMtopToken(cookieHeader), timestamp, appKey, data);
nextUrl.searchParams.set("appKey", appKey);
nextUrl.searchParams.set("t", timestamp);
nextUrl.searchParams.set("sign", sign);
return nextUrl.toString();
}
function extractTemplateApi(templateUrl: string | undefined): string | undefined {
if (!templateUrl) {
return undefined;
}
const url = new URL(templateUrl);
const direct = url.searchParams.get("api");
if (direct) {
return direct;
}
const match = url.pathname.match(/\/h5\/([^/]+)\/[^/]+\/$/i);
return match?.[1] ? match[1] : undefined;
}
function extractTemplateItemId(templateUrl: string | undefined): string | undefined {
if (!templateUrl) {
return undefined;
}
const data = readQueryData(new URL(templateUrl));
const direct =
firstString(data?.id, data?.itemId, data?.itemNumId, data?.auctionNumId) ?? undefined;
if (direct) {
return direct;
}
const exParams = readNestedJsonRecord(data?.exParams);
const queryParams = stringFrom(exParams?.queryParams);
if (!queryParams) {
return undefined;
}
const params = new URLSearchParams(queryParams);
return firstString(
params.get("itemId"),
params.get("id"),
params.get("itemNumId"),
params.get("auctionNumId")
) ?? undefined;
}
function buildTemplateSummary(templateUrl: string | undefined): TmallTemplateSummary {
const api = extractTemplateApi(templateUrl);
const itemId = extractTemplateItemId(templateUrl);
return {
available: Boolean(templateUrl),
...(api ? { api } : {}),
...(itemId ? { itemId } : {})
};
}
function coerceValue(existing: unknown, nextValue: number | string): number | string {
if (typeof existing === "number") {
return typeof nextValue === "number" ? nextValue : Number.parseInt(nextValue, 10);
}
return String(nextValue);
}
function normalizePositiveInteger(
value: number | undefined,
fieldName: string,
fallback: number,
max: number
): number {
if (value === undefined) {
return fallback;
}
if (!Number.isInteger(value) || value < 1 || value > max) {
throw new TmallLiveError(`${fieldName} must be an integer between 1 and ${max}.`);
}
return value;
}
function normalizeReviewsPreviewOptions(
options?: number | TmallReviewsPreviewOptions
): ResolvedTmallReviewsPreviewOptions {
if (typeof options === "number") {
return {
commentCount: normalizePositiveInteger(options, "commentCount", 20, 50),
page: 1,
maxPages: 1
};
}
return {
commentCount: normalizePositiveInteger(options?.commentCount, "commentCount", 20, 50),
page: normalizePositiveInteger(options?.page, "page", 1, 1000),
maxPages: normalizePositiveInteger(options?.maxPages, "maxPages", 1, 10)
};
}
function buildSecurityNonce(itemId: string, logicVer: string): string {
const hash = createHash("sha256").update(`${itemId}${logicVer}`).digest("hex");
const prefix = (
14802254 ^
Number.parseInt(hash.slice(0, 6), 16) ^
Number.parseInt(hash.slice(0, 6), 16)
)
.toString(16)
.padStart(6, "0");
return `${prefix}${hash.slice(6)}`;
}
function updateDetailExParams(
currentData: Record<string, unknown>,
nextData: Record<string, unknown>,
itemId: string
): void {
const exParams = readNestedJsonRecord(currentData.exParams);
if (!exParams) {
return;
}
const queryParams = new URLSearchParams(stringFrom(exParams.queryParams) ?? "");
for (const key of ["itemId", "id", "itemNumId", "auctionNumId"]) {
if (queryParams.has(key)) {
queryParams.set(key, itemId);
}
}
if (![...queryParams.keys()].some((key) => key === "itemId" || key === "id")) {
queryParams.set("itemId", itemId);
queryParams.set("id", itemId);
}
const logicVer = stringFrom(exParams.logicVer) ?? DEFAULT_LOGIC_VERSION;
const nextExParams: Record<string, unknown> = {
...exParams,
queryParams: queryParams.toString()
};
if ("logicVer" in exParams || "nonce" in exParams) {
nextExParams.logicVer = logicVer;
nextExParams.nonce = buildSecurityNonce(itemId, logicVer);
}
nextData.exParams = JSON.stringify(nextExParams);
}
function buildDetailRequestUrl(templateUrl: string, itemId: string): string {
const template = new URL(templateUrl);
const data = readQueryData(template);
return withUpdatedQueryData(template, (currentData) => {
const nextData: Record<string, unknown> = {
...currentData
};
let foundItemKey = false;
for (const key of DETAIL_ITEM_DATA_KEYS) {
if (key in currentData) {
nextData[key] = coerceValue(currentData[key], itemId);
foundItemKey = true;
}
}
if (!foundItemKey) {
const fallbackKey = findFirstRecordKey(data, DETAIL_ITEM_DATA_KEYS) ?? "id";
nextData[fallbackKey] = itemId;
}
updateDetailExParams(currentData, nextData, itemId);
return nextData;
});
}
function buildReviewsRequestUrl(
templateUrl: string,
itemId: string,
options: ResolvedTmallReviewsPreviewOptions,
page: number,
cookieHeader: string
): { url: string; pageKey?: string | undefined } {
const template = new URL(templateUrl);
const data = readQueryData(template);
const api = extractTemplateApi(templateUrl) ?? "";
const itemKeys = api.endsWith("mtop.taobao.rate.detaillist.get")
? REVIEW_H5_ITEM_DATA_KEYS
: REVIEW_PC_ITEM_DATA_KEYS;
const pageKey = findFirstRecordKey(data, REVIEW_PAGE_DATA_KEYS) ?? undefined;
const pageSizeKey = findFirstRecordKey(data, REVIEW_PAGE_SIZE_DATA_KEYS) ?? "pageSize";
if ((options.maxPages > 1 || options.page > 1) && !pageKey) {
throw new TmallLiveError(
"Imported Tmall reviews template does not expose a page field. Capture a paged reviews request first."
);
}
return {
url: refreshMtopSignature(
withUpdatedQueryData(template, (currentData) => {
const nextData: Record<string, unknown> = {
...currentData
};
let foundItemKey = false;
for (const key of itemKeys) {
if (key in currentData) {
nextData[key] = coerceValue(currentData[key], itemId);
foundItemKey = true;
}
}
if (!foundItemKey) {
nextData[itemKeys[0] ?? "itemId"] = itemId;
}
nextData[pageSizeKey] = coerceValue(currentData[pageSizeKey], options.commentCount);
if (pageKey) {
nextData[pageKey] = coerceValue(currentData[pageKey], page);
}
return nextData;
}),
cookieHeader
),
...(pageKey ? { pageKey } : {})
};
}
function mergeReviewPages(
itemId: string,
pages: TmallReviewsPreviewResult["reviews"][]
): TmallReviewsPreviewResult["reviews"] {
const firstPage = pages[0];
if (!firstPage) {
return {
itemId,
total: null,
hasNext: false,
allCount: null,
pictureCount: null,
appendCount: 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())
};
}
function extractJsonRecord(text: string): Record<string, unknown> | null {
return parseEmbeddedJson(text);
}
function extractLoginUrl(text: string): string | null {
if (text.includes("login.taobao.com") || text.includes("login.m.taobao.com")) {
return "login";
}
const payload = extractJsonRecord(text);
const data = asRecord(payload?.data);
return firstString(data?.url, data?.h5url, asRecord(data?.pcTrade)?.redirectUrl);
}
function summarizeRetCodes(text: string): string[] {
const payload = extractJsonRecord(text);
if (!payload) {
return [];
}
return extractTmallRetCodes(payload);
}
function isSuccessfulRet(retCode: string): boolean {
return retCode.includes("SUCCESS::");
}
async function fetchTextOrThrow(
url: string,
init: RequestInit,
sessionExpiredMessage: string
): Promise<{ finalUrl: string; text: string }> {
let response: Response;
try {
response = await fetch(url, {
...init,
redirect: "follow"
});
} catch (error) {
throw new TmallLiveError(
`Tmall live request failed before receiving a response: ${
error instanceof Error ? error.message : "unknown error"
}`,
502
);
}
const text = await response.text();
const loginUrl = extractLoginUrl(text);
const retCodes = summarizeRetCodes(text);
if (response.url.includes("login.taobao.com") || loginUrl) {
throw new TmallLiveError(sessionExpiredMessage, 409);
}
if (!response.ok) {
throw new TmallLiveError(
`Tmall live request failed with status ${response.status}.`,
502
);
}
if (retCodes.length > 0 && retCodes.every((code) => !isSuccessfulRet(code))) {
throw new TmallLiveError(
`Tmall live request returned ${retCodes.join(" | ")}.`,
502
);
}
return {
finalUrl: response.url,
text
};
}
export function isTmallLiveError(error: unknown): error is Error & { statusCode: number } {
return error instanceof Error && "statusCode" in error;
}
export class TmallLiveSessionService implements TmallLiveService {
private session: StoredTmallLiveSession | null = readEnvSession();
getSessionSummary(): TmallLiveSessionSummary {
return {
configured: Boolean(this.session),
hasCookie: Boolean(this.session?.cookieHeader),
...(this.session?.importedAt ? { importedAt: this.session.importedAt } : {}),
...(this.session?.userAgent ? { userAgent: this.session.userAgent } : {}),
detailTemplate: buildTemplateSummary(this.session?.detailTemplateUrl),
reviewsTemplate: buildTemplateSummary(this.session?.reviewsTemplateUrl)
};
}
importSession(input: TmallLiveSessionInput): TmallLiveSessionSummary {
const detailTemplateUrl = input.detailTemplateUrl?.trim();
const reviewsTemplateUrl = input.reviewsTemplateUrl?.trim();
const detailReferer = input.detailReferer?.trim();
this.session = {
cookieHeader: requireNonEmptyCookie(input.cookieHeader),
importedAt: nowIso(),
userAgent: input.userAgent?.trim() || DEFAULT_TMALL_USER_AGENT,
...(detailTemplateUrl ? { detailTemplateUrl } : {}),
...(reviewsTemplateUrl ? { reviewsTemplateUrl } : {}),
...(detailReferer ? { detailReferer } : {})
};
return this.getSessionSummary();
}
clearSession(): void {
this.session = readEnvSession();
}
async previewSearch(query: string): Promise<TmallSearchPreviewResult> {
const session = this.requireSession();
const normalizedQuery = query.trim();
if (!normalizedQuery) {
throw new TmallLiveError("query is required for Tmall live search preview.");
}
const requestUrl = `https://s.taobao.com/search?q=${encodeURIComponent(normalizedQuery)}&tab=mall`;
const response = await fetchTextOrThrow(
requestUrl,
{
headers: {
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
Cookie: session.cookieHeader,
Referer: "https://www.tmall.com/",
"User-Agent": session.userAgent
}
},
"Tmall search session appears invalid. Re-login in the browser and re-import the cookie/header."
);
const candidates = parseTmallSearchHtml(normalizedQuery, response.text);
if (candidates.length === 0 && !hasTmallSearchNoResultMarker(response.text)) {
throw new TmallLiveError(
"Tmall search page fetched successfully, but no stable mall candidates could be parsed.",
502
);
}
return {
query: normalizedQuery,
source: "html",
candidateCount: candidates.length,
candidates
};
}
async previewDetail(itemId: string): Promise<TmallDetailPreviewResult> {
const session = this.requireSession();
const normalizedItemId = itemId.trim();
if (!normalizedItemId) {
throw new TmallLiveError("itemId is required for Tmall detail preview.");
}
const requestUrl = `https://detail.tmall.com/item.htm?id=${normalizedItemId}`;
const response = await fetchTextOrThrow(
requestUrl,
{
headers: {
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
Cookie: session.cookieHeader,
Referer: session.detailReferer ?? "https://detail.tmall.com/",
"User-Agent": session.userAgent
}
},
"Tmall detail session appears invalid. Re-login in the browser and re-import the cookie/header."
);
const detail = parseTmallDetailHtmlResponse(normalizedItemId, { text: response.text });
if (!detail.title && !detail.shopName && !detail.mainImage) {
throw new TmallLiveError(
"Tmall detail page fetched successfully, but the embedded page state could not be parsed.",
502
);
}
return {
itemId: normalizedItemId,
source: "html",
detail
};
}
async previewReviews(
itemId: string,
options?: number | TmallReviewsPreviewOptions
): Promise<TmallReviewsPreviewResult> {
const session = this.requireSession();
const normalizedItemId = itemId.trim();
const resolvedOptions = normalizeReviewsPreviewOptions(options);
if (!normalizedItemId) {
throw new TmallLiveError("itemId is required for Tmall reviews preview.");
}
if (!session.reviewsTemplateUrl) {
throw new TmallLiveError(
"Tmall reviews template is missing. Capture a fresh reviews request and import it first."
);
}
const reviewPages = [];
let pageKey: string | undefined;
for (let pageOffset = 0; pageOffset < resolvedOptions.maxPages; pageOffset += 1) {
const currentPage = resolvedOptions.page + pageOffset;
const request = buildReviewsRequestUrl(
session.reviewsTemplateUrl,
normalizedItemId,
resolvedOptions,
currentPage,
session.cookieHeader
);
pageKey ??= request.pageKey;
const response = await fetchTextOrThrow(
request.url,
{
headers: {
Accept: "application/json, text/plain, */*",
Cookie: session.cookieHeader,
Referer:
session.detailReferer ??
`https://detail.tmall.com/wow/z/app/tbpc/pc-detail-ssr-2025/home?itemId=${normalizedItemId}`,
"User-Agent": session.userAgent
}
},
"Tmall reviews session appears invalid. Re-login in the browser and re-import the cookie/header."
);
const parsedPage = parseTmallReviewsApiResponse(normalizedItemId, { text: response.text });
reviewPages.push(parsedPage);
if (parsedPage.comments.length < resolvedOptions.commentCount || !parsedPage.hasNext) {
break;
}
}
const pagination: TmallReviewsPaginationSummary = {
requestedPage: resolvedOptions.page,
requestedCommentCount: resolvedOptions.commentCount,
maxPages: resolvedOptions.maxPages,
pagesFetched: reviewPages.length,
...(pageKey ? { pageKey } : {})
};
return {
itemId: normalizedItemId,
source: "api",
pagination,
reviews: mergeReviewPages(normalizedItemId, reviewPages)
};
}
async previewProduct(
itemId: string,
options?: number | TmallReviewsPreviewOptions
): Promise<TmallProductPreviewResult> {
const [detailPreview, reviewsPreview] = await Promise.all([
this.previewDetail(itemId),
this.previewReviews(itemId, options)
]);
return {
itemId: detailPreview.itemId,
source: detailPreview.source === reviewsPreview.source ? detailPreview.source : "hybrid",
detail: detailPreview.detail,
pagination: reviewsPreview.pagination,
reviews: reviewsPreview.reviews
};
}
private requireSession(): StoredTmallLiveSession {
if (!this.session?.cookieHeader) {
throw new TmallLiveError(
"Tmall live session is not configured. Import a browser cookie/header first."
);
}
return this.session;
}
}

View File

@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import { hasTmallSearchNoResultMarker, parseTmallSearchHtml } from "./parsers";
describe("Tmall search parsers", () => {
it("parses mall candidates from g_page_config payloads", () => {
const payload = {
mods: {
itemlist: {
data: {
auctions: [
{
nid: "934454505228",
raw_title: "Apple iPhone 15 Natural Titanium 128GB",
view_price: "4399.00",
nick: "Apple Flagship Store",
detail_url: "//detail.tmall.com/item.htm?id=934454505228",
pic_url: "//img.alicdn.com/example-1.jpg",
view_sales: "sold 10k+",
comment_count: "20k+",
isTmall: "true",
iconList: [{ text: "Free shipping" }]
},
{
nid: "934454505229",
raw_title: "Apple iPhone 15 Pink 256GB",
view_price: "4799.00",
nick: "Apple Flagship Store",
detail_url: "//detail.tmall.com/item.htm?id=934454505229",
pic_url: "//img.alicdn.com/example-2.jpg",
view_sales: "sold 5k+",
comment_count: "12k+",
isTmall: true
}
]
}
}
}
};
const html = `<script>var g_page_config = ${JSON.stringify(payload)};</script>`;
const candidates = parseTmallSearchHtml("iPhone 15", html);
expect(candidates).toHaveLength(2);
expect(candidates[0]).toMatchObject({
candidateId: "tmall-934454505228",
platform: "tmall",
title: "Apple iPhone 15 Natural Titanium 128GB",
price: 4399,
priceLabel: "CNY 4399",
storeName: "Apple Flagship Store",
productUrl: "https://detail.tmall.com/item.htm?id=934454505228",
imageUrl: "https://img.alicdn.com/example-1.jpg",
specLabel: "128GB"
});
expect(candidates[0]?.highlights).toEqual(["Free shipping", "天猫"]);
});
it("parses fallback mall candidate blocks when embedded JSON is missing", () => {
const html = [
"<div>",
'<a href="//detail.tmall.com/item.htm?id=934454505230" title="Apple iPhone 15 Blue 512GB">item</a>',
'<img src="//img.alicdn.com/example-3.jpg" />',
'<script type="application/json">',
JSON.stringify({
nick: "Apple Flagship Store",
view_price: "5799.00",
view_sales: "sold 2k+"
}),
"</script>",
"</div>"
].join("");
const candidates = parseTmallSearchHtml("iPhone 15", html);
expect(candidates).toEqual([
expect.objectContaining({
candidateId: "tmall-934454505230",
productUrl: "https://detail.tmall.com/item.htm?id=934454505230",
title: "Apple iPhone 15 Blue 512GB",
price: 5799,
storeName: "Apple Flagship Store",
imageUrl: "https://img.alicdn.com/example-3.jpg"
})
]);
});
it("detects no-result markers from the search page", () => {
expect(hasTmallSearchNoResultMarker("<div>很抱歉,没有找到与 iPhone 15 相关的商品</div>")).toBe(
true
);
expect(hasTmallSearchNoResultMarker("<div>search results ready</div>")).toBe(false);
});
});

View File

@ -0,0 +1,918 @@
import type { CandidateRecord } from "@cross-ai/domain";
import type {
TmallProductDetailSnapshot,
TmallProductReviewsSnapshot,
TmallReviewCommentSnapshot,
TmallReviewTagSnapshot
} from "./types";
import {
absolutizeTmallUrl,
asArray,
asRecord,
firstString,
normalizeWhitespace,
parseBooleanish,
parseEmbeddedJson,
pickFirstStringByPaths,
readPath,
stringFrom,
uniqueStrings
} from "./utils";
type PathSegment = string | number;
const TMALL_SEARCH_RESULT_PATHS: PathSegment[][] = [
["mods", "itemlist", "data", "auctions"],
["root", "fields", "mods", "itemlist", "data", "auctions"],
["data", "root", "fields", "mods", "itemlist", "data", "auctions"],
["props", "pageProps", "mods", "itemlist", "data", "auctions"],
["itemlist", "data", "auctions"],
["itemList"],
["items"]
];
const TMALL_SEARCH_JSON_MARKERS = [
"g_page_config =",
"window.g_page_config =",
"window.__SEARCH_DATA__ =",
"window.__INITIAL_STATE__ =",
"window.__PRELOADED_STATE__ ="
];
const TMALL_SEARCH_NO_RESULT_MARKERS = [
"没有找到相关宝贝",
"没有找到相应的宝贝",
"很抱歉,没有找到与",
"未找到相关商品",
"暂无相关商品"
];
function extractSpecLabel(title: string): string {
const storageMatch = title.match(/\b\d+(?:GB|TB)\b/i);
if (storageMatch) {
return storageMatch[0].toUpperCase();
}
const colorMatch = title.match(
/(黑色|白色|蓝色|粉色|绿色|黄色|紫色|原色|银色|金色|灰色|红色|深空黑|午夜色|星光色|natural titanium)/i
);
if (colorMatch?.[0]) {
return normalizeWhitespace(colorMatch[0]);
}
return "标准款";
}
function normalizePriceText(value: string | null): { value: number; label: string } | null {
if (!value) {
return null;
}
const stripped = value.replace(/[^\d.]/g, "");
if (!stripped) {
return null;
}
const parsed = Number.parseFloat(stripped);
if (Number.isNaN(parsed)) {
return null;
}
const normalizedLabel = Number.isInteger(parsed) ? parsed.toFixed(0) : parsed.toFixed(2);
return {
value: parsed,
label: `CNY ${normalizedLabel}`
};
}
function normalizeInlineText(value: string | null): string | null {
if (!value) {
return null;
}
return normalizeWhitespace(value) || null;
}
function matchFirst(value: string, pattern: RegExp): string | null {
const match = pattern.exec(value);
return match?.[1] ? normalizeWhitespace(match[1]) : null;
}
function unwrapCapturedPayload(input: unknown): Record<string, unknown> | null {
const record = asRecord(input);
const text = stringFrom(record?.text);
if (text) {
try {
return asRecord(JSON.parse(text));
} catch {
return parseEmbeddedJson(text) ?? record;
}
}
return record;
}
function unwrapTmallData(input: unknown): Record<string, unknown> | null {
const payload = unwrapCapturedPayload(input);
return asRecord(payload?.data) ?? payload;
}
function extractTextPayload(input: unknown): string | null {
if (typeof input === "string") {
return input;
}
const record = asRecord(input);
return stringFrom(record?.text);
}
function extractJsonObjectLiteral(
source: string,
startIndex: number
): Record<string, unknown> | null {
let depth = 0;
let inString = false;
let isEscaped = false;
let objectStart = -1;
for (let index = startIndex; index < source.length; index += 1) {
const current = source[index];
if (!current) {
continue;
}
if (objectStart < 0) {
if (current === "{") {
objectStart = index;
depth = 1;
}
continue;
}
if (inString) {
if (isEscaped) {
isEscaped = false;
} else if (current === "\\") {
isEscaped = true;
} else if (current === "\"") {
inString = false;
}
continue;
}
if (current === "\"") {
inString = true;
continue;
}
if (current === "{") {
depth += 1;
continue;
}
if (current === "}") {
depth -= 1;
if (depth === 0) {
return parseEmbeddedJson(source.slice(objectStart, index + 1));
}
}
}
return null;
}
function extractSearchPayloads(html: string): Record<string, unknown>[] {
const payloads: Record<string, unknown>[] = [];
for (const marker of TMALL_SEARCH_JSON_MARKERS) {
let searchIndex = 0;
while (searchIndex < html.length) {
const markerIndex = html.indexOf(marker, searchIndex);
if (markerIndex < 0) {
break;
}
const payload = extractJsonObjectLiteral(html, markerIndex + marker.length);
if (payload) {
payloads.push(payload);
}
searchIndex = markerIndex + marker.length;
}
}
const jsonScriptMatches = html.matchAll(
/<script[^>]*type="application\/json"[^>]*>([\s\S]*?)<\/script>/gi
);
for (const match of jsonScriptMatches) {
const payload = parseEmbeddedJson(match[1] ?? null);
if (payload) {
payloads.push(payload);
}
}
return payloads;
}
function looksLikeTmallSearchItem(input: Record<string, unknown>): boolean {
const detailUrl = normalizeInlineText(
firstString(
input.detail_url,
input.detailUrl,
input.itemUrl,
input.url,
input.item_url
)
);
const itemId = firstString(
input.nid,
input.itemId,
input.item_id,
input.id,
input.auctionNumId
);
const title = normalizeInlineText(
firstString(
input.raw_title,
input.title,
input.name,
input.itemTitle,
asRecord(input.item)?.title
)
);
if (!title) {
return false;
}
if (detailUrl && /detail\.tmall\.com\/item\.htm/i.test(detailUrl)) {
return true;
}
return Boolean(itemId && (detailUrl || "view_price" in input || "price" in input));
}
function collectSearchItems(
root: unknown,
depth = 0,
seenCollections = new Set<string>()
): Record<string, unknown>[] {
if (depth > 8) {
return [];
}
const directList = TMALL_SEARCH_RESULT_PATHS.flatMap((path) => {
const value = readPath(root, path);
const collection = asArray(value)
.map((entry) => asRecord(entry))
.filter((entry): entry is Record<string, unknown> => Boolean(entry))
.filter((entry) => looksLikeTmallSearchItem(entry));
return collection.length > 0 ? [collection] : [];
});
if (directList.length > 0) {
return directList[0] ?? [];
}
const record = asRecord(root);
if (record) {
const signature = Object.keys(record).sort().join("|");
if (signature) {
seenCollections.add(signature);
}
const nestedCollections = Object.values(record).flatMap((value) => {
const entries = asArray(value)
.map((entry) => asRecord(entry))
.filter((entry): entry is Record<string, unknown> => Boolean(entry));
if (entries.length > 0 && entries.some((entry) => looksLikeTmallSearchItem(entry))) {
const collectionSignature = entries
.map((entry) =>
firstString(entry.nid, entry.itemId, entry.item_id, entry.id, entry.auctionNumId) ??
firstString(entry.detail_url, entry.detailUrl, entry.url) ??
""
)
.filter(Boolean)
.join("|");
if (collectionSignature && !seenCollections.has(collectionSignature)) {
seenCollections.add(collectionSignature);
return [entries];
}
}
return [];
});
if (nestedCollections.length > 0) {
return nestedCollections[0] ?? [];
}
for (const value of Object.values(record)) {
const nested = collectSearchItems(value, depth + 1, seenCollections);
if (nested.length > 0) {
return nested;
}
}
}
const array = asArray(root);
for (const value of array) {
const nested = collectSearchItems(value, depth + 1, seenCollections);
if (nested.length > 0) {
return nested;
}
}
return [];
}
function extractTmallDetailPageState(input: unknown): Record<string, unknown> | null {
const text = extractTextPayload(input);
if (!text) {
return null;
}
const anchor = text.indexOf("window.__ICE_APP_CONTEXT__");
if (anchor < 0) {
return null;
}
const marker = "var b = ";
const markerIndex = text.indexOf(marker, anchor);
if (markerIndex < 0) {
return null;
}
const context = extractJsonObjectLiteral(text, markerIndex + marker.length);
const loaderData = asRecord(context?.loaderData);
const home = asRecord(loaderData?.home);
const data = asRecord(home?.data);
return asRecord(data?.res);
}
export function extractTmallRetCodes(input: unknown): string[] {
const payload = unwrapCapturedPayload(input);
const ret = payload?.ret;
if (Array.isArray(ret)) {
return ret
.map((value) => stringFrom(value))
.filter((value): value is string => Boolean(value));
}
const asString = stringFrom(ret);
return asString ? [asString] : [];
}
function pickFirstImageByPaths(root: unknown, paths: PathSegment[][]): string | null {
for (const path of paths) {
const value = readPath(root, path);
const candidate =
stringFrom(value) ??
firstString(
asRecord(value)?.url,
asRecord(value)?.image,
asRecord(value)?.imageUrl,
asRecord(value)?.picUrl
);
if (candidate) {
return absolutizeTmallUrl(candidate);
}
}
return null;
}
function pickCategoryPath(root: unknown): string[] {
const candidates: PathSegment[][] = [
["item", "categoryPath"],
["item", "categoryNamePath"],
["item", "categoryPathNames"],
["vertical", "categoryPath"],
["categoryPath"],
["categoryNamePath"]
];
for (const path of candidates) {
const value = readPath(root, path);
if (typeof value === "string") {
const parts = value
.split(/[>/|]/g)
.map((part) => normalizeWhitespace(part))
.filter(Boolean);
if (parts.length > 0) {
return parts;
}
}
const items = asArray(value)
.map((entry) => {
const record = asRecord(entry);
return firstString(entry, record?.name, record?.text, record?.title);
})
.filter((entry): entry is string => Boolean(entry))
.map((entry) => normalizeWhitespace(entry));
if (items.length > 0) {
return items;
}
}
return [];
}
function parseSkuText(value: unknown): string[] {
if (typeof value === "string") {
return uniqueStrings(
value
.split(/[;/]/g)
.map((entry) => normalizeWhitespace(entry))
.filter(Boolean)
);
}
const record = asRecord(value);
if (!record) {
return [];
}
return uniqueStrings(
Object.values(record).map((entry) =>
typeof entry === "string" ? normalizeWhitespace(entry) : null
)
);
}
function collectImageUrls(value: unknown): string[] {
return uniqueStrings(
asArray(value).flatMap((entry) => {
const record = asRecord(entry);
const direct = absolutizeTmallUrl(stringFrom(entry));
if (direct) {
return [direct];
}
return uniqueStrings([
absolutizeTmallUrl(firstString(record?.url, record?.image, record?.picPath)),
absolutizeTmallUrl(firstString(record?.sourceUrl, record?.cloudVideoUrl))
]);
})
);
}
function parseReviewTag(input: unknown): TmallReviewTagSnapshot | null {
const tag = asRecord(input);
const name = firstString(tag?.name, tag?.imprName, tag?.label, tag?.title);
if (!name) {
return null;
}
return {
name: normalizeWhitespace(name),
count: firstString(tag?.count, tag?.showCount, tag?.num)
};
}
function parseH5ReviewComment(input: unknown): TmallReviewCommentSnapshot | null {
const comment = asRecord(input);
const appended = asRecord(comment?.appendedFeed);
const video = asRecord(comment?.video);
const content = firstString(comment?.feedback, comment?.reviewWordContent, comment?.content);
const id = firstString(comment?.id, comment?.commentId, comment?.rateId);
if (!content || !id) {
return null;
}
return {
id,
content: normalizeWhitespace(content),
date: firstString(comment?.feedbackDate, comment?.reviewDate, comment?.date),
userNick: firstString(comment?.userNick, asRecord(comment?.userInfo)?.nickName),
userAvatar: absolutizeTmallUrl(
firstString(comment?.headPicUrl, asRecord(comment?.userInfo)?.image)
),
skuText: parseSkuText(comment?.skuMap ?? comment?.skuText),
pictureUrls: collectImageUrls(comment?.feedPicPathList ?? comment?.reviewPicPathList),
videoUrls: collectImageUrls(video?.videoId ? [video] : []),
likeCount: firstString(asRecord(comment?.interactInfo)?.likeCount),
reply: firstString(comment?.reply),
appendContent: firstString(appended?.appendedFeedback),
appendPictureUrls: collectImageUrls(appended?.appendFeedPicPathList)
};
}
function parsePcReviewComment(input: unknown): TmallReviewCommentSnapshot | null {
const comment = asRecord(input);
const append = asRecord(comment?.reviewAppendVO);
const content = firstString(comment?.reviewWordContent, comment?.feedback, comment?.content);
const id = firstString(comment?.id, comment?.commentId);
if (!content || !id) {
return null;
}
return {
id,
content: normalizeWhitespace(content),
date: firstString(comment?.reviewDate, comment?.feedbackDate, comment?.date),
userNick: firstString(comment?.userNick, asRecord(comment?.userInfo)?.nickName),
userAvatar: absolutizeTmallUrl(
firstString(comment?.headPicUrl, asRecord(comment?.userInfo)?.image)
),
skuText: parseSkuText(comment?.skuText ?? comment?.skuMap),
pictureUrls: collectImageUrls(comment?.reviewPicPathList),
videoUrls: collectImageUrls(comment?.videoVOList),
likeCount: firstString(asRecord(comment?.interactionVO)?.likeCount),
reply: firstString(comment?.reply),
appendContent: firstString(append?.appendedWordContent),
appendPictureUrls: collectImageUrls(append?.reviewPicPathList)
};
}
function parseSearchItem(input: Record<string, unknown>): CandidateRecord | null {
const nestedItem = asRecord(input.item);
const record = nestedItem ? { ...nestedItem, ...input } : input;
const detailUrl = absolutizeTmallUrl(
firstString(record.detail_url, record.detailUrl, record.itemUrl, record.url, record.item_url)
);
const itemId =
firstString(record.nid, record.itemId, record.item_id, record.id, record.auctionNumId) ??
detailUrl?.match(/[?&](?:id|itemId|itemNumId|auctionNumId)=(\d+)/i)?.[1] ??
null;
const title = normalizeInlineText(
firstString(
record.raw_title,
record.title,
record.name,
record.itemTitle,
asRecord(record.item)?.title
)
);
const price = normalizePriceText(
firstString(
record.view_price,
record.price,
record.priceText,
asRecord(record.priceInfo)?.priceText,
asRecord(record.priceInfo)?.price
)
);
const storeName =
normalizeInlineText(
firstString(
record.nick,
record.shopName,
record.sellerNick,
asRecord(record.shopcard)?.shopName
)
) ?? "天猫店铺";
const salesHints = uniqueStrings([
normalizeInlineText(
firstString(
record.view_sales,
record.sold,
record.salesDesc,
record.soldText,
record.comment_count ? `累计评价 ${stringFrom(record.comment_count)}` : null
)
),
normalizeInlineText(firstString(record.commentCount, record.comment_count))
]);
const featureFlags = asArray(record.iconList)
.map((entry) =>
normalizeInlineText(firstString(entry, asRecord(entry)?.text, asRecord(entry)?.name))
)
.filter((entry): entry is string => Boolean(entry));
const highlights = uniqueStrings([
...featureFlags,
parseBooleanish(record.isTmall) ? "天猫" : null,
normalizeInlineText(firstString(record.shopIcon, record.shopType))
]).slice(0, 4);
if (!itemId || !title || !detailUrl || !/detail\.tmall\.com\/item\.htm/i.test(detailUrl)) {
return null;
}
return {
candidateId: `tmall-${itemId}`,
platform: "tmall",
title,
price: price?.value ?? 0,
priceLabel: price?.label ?? "CNY 0",
storeName,
productUrl: detailUrl,
imageUrl:
absolutizeTmallUrl(
firstString(
record.pic_url,
record.image,
record.imageUrl,
asRecord(record.pic)?.url
)
) ?? "https://placehold.co/640x480?text=TMALL",
salesHint: salesHints.join(" | ") || "搜索页已返回,但缺少稳定销量文案",
specLabel: extractSpecLabel(title),
highlights: highlights.length > 0 ? highlights : ["天猫搜索结果候选"]
};
}
function parseSearchCandidateBlocks(query: string, html: string): CandidateRecord[] {
const blocks = html.matchAll(
/((?:https?:)?\/\/detail\.tmall\.com\/item\.htm\?[^"'<>\\\s]*\bid=\d+[\s\S]{0,1200})/gi
);
const seen = new Set<string>();
const candidates: CandidateRecord[] = [];
for (const match of blocks) {
const block = match[1] ?? "";
const itemId = block.match(/[?&](?:id|itemId|itemNumId|auctionNumId)=(\d+)/i)?.[1];
if (!itemId || seen.has(itemId)) {
continue;
}
seen.add(itemId);
const detailUrlMatch = block.match(
/((?:https?:)?\/\/detail\.tmall\.com\/item\.htm\?[^"'<>\\\s]*\bid=\d+)/i
);
const title =
matchFirst(block, /"(?:raw_title|title|itemTitle)"\s*:\s*"([^"]+)"/i) ??
matchFirst(block, /title="([^"]{4,200})"/i) ??
query;
const price = normalizePriceText(
matchFirst(block, /"(?:view_price|price|priceText)"\s*:\s*"([^"]+)"/i) ??
matchFirst(block, />\s*([0-9]+(?:\.[0-9]+)?)\s*</i)
);
const storeName =
matchFirst(block, /"(?:nick|shopName|sellerNick)"\s*:\s*"([^"]+)"/i) ?? "天猫店铺";
const imageUrl =
absolutizeTmallUrl(
matchFirst(block, /"(?:pic_url|image|imageUrl)"\s*:\s*"([^"]+)"/i) ??
matchFirst(block, /<img[^>]+src="([^"]+)"/i)
) ?? "https://placehold.co/640x480?text=TMALL";
const salesHint =
matchFirst(block, /"(?:view_sales|sold|salesDesc|comment_count)"\s*:\s*"([^"]+)"/i) ??
"搜索页已返回,但未抽取到稳定销量文案";
candidates.push({
candidateId: `tmall-${itemId}`,
platform: "tmall",
title,
price: price?.value ?? 0,
priceLabel: price?.label ?? "CNY 0",
storeName,
productUrl: absolutizeTmallUrl(detailUrlMatch?.[1] ?? null) ??
`https://detail.tmall.com/item.htm?id=${itemId}`,
imageUrl,
salesHint,
specLabel: extractSpecLabel(title),
highlights: ["搜索页候选回退解析"]
});
}
return candidates;
}
function normalizeTmallSellerType(value: string | null): string | null {
if (!value) {
return null;
}
const normalized = value.trim().toLowerCase();
if (normalized === "b" || normalized.includes("tmall")) {
return "tmall";
}
if (normalized === "c" || normalized.includes("taobao")) {
return "taobao";
}
return value.trim();
}
export function hasTmallSearchNoResultMarker(html: string): boolean {
return TMALL_SEARCH_NO_RESULT_MARKERS.some((marker) => html.includes(marker));
}
export function parseTmallSearchHtml(query: string, html: string): CandidateRecord[] {
const seen = new Set<string>();
const candidates: CandidateRecord[] = [];
for (const payload of extractSearchPayloads(html)) {
const items = collectSearchItems(payload);
for (const item of items) {
const candidate = parseSearchItem(item);
if (!candidate || seen.has(candidate.candidateId)) {
continue;
}
seen.add(candidate.candidateId);
candidates.push(candidate);
}
}
if (candidates.length > 0) {
return candidates;
}
for (const candidate of parseSearchCandidateBlocks(query, html)) {
if (seen.has(candidate.candidateId)) {
continue;
}
seen.add(candidate.candidateId);
candidates.push(candidate);
}
return candidates;
}
export function parseTmallDetailHtmlResponse(
itemId: string,
input: unknown
): TmallProductDetailSnapshot {
const payload = extractTmallDetailPageState(input) ?? {};
return {
itemId:
pickFirstStringByPaths(payload, [["item", "itemId"], ["itemId"], ["id"]]) ?? itemId,
title:
pickFirstStringByPaths(payload, [
["componentsVO", "titleVO", "title", "title"],
["item", "title"]
]) ?? null,
subtitle:
pickFirstStringByPaths(payload, [["item", "subtitle"], ["item", "subTitle"]]) ?? null,
price:
pickFirstStringByPaths(payload, [
["componentsVO", "priceVO", "extraPrice", "priceText"],
["componentsVO", "priceVO", "price", "priceText"]
]) ?? null,
originalPrice:
pickFirstStringByPaths(payload, [["componentsVO", "priceVO", "price", "priceText"]]) ??
null,
shopName:
pickFirstStringByPaths(payload, [
["seller", "shopName"],
["componentsVO", "storeCardVO", "shopName"]
]) ?? null,
shopUrl:
pickFirstImageByPaths(payload, [
["seller", "pcShopUrl"],
["componentsVO", "storeCardVO", "shopUrl"]
]) ?? null,
sellerType: normalizeTmallSellerType(
pickFirstStringByPaths(payload, [
["seller", "sellerType"],
["componentsVO", "storeCardVO", "sellerType"]
])
),
categoryPath: pickCategoryPath(payload),
mainImage:
pickFirstImageByPaths(payload, [
["componentsVO", "headImageVO", "images", 0],
["item", "images", 0]
]) ?? null,
salesDesc:
pickFirstStringByPaths(payload, [
["componentsVO", "titleVO", "salesDesc"],
["item", "vagueSellCount"]
]) ?? null,
commentCount:
pickFirstStringByPaths(payload, [
["componentsVO", "rateVO", "totalCount"],
["componentsVO", "rateVO", "total"],
["item", "commentCount"]
]) ?? null
};
}
export function parseTmallDetailApiResponse(
itemId: string,
input: unknown
): TmallProductDetailSnapshot {
const payload = unwrapTmallData(input) ?? {};
const price = asRecord(payload.price) ?? asRecord(payload.priceInfo) ?? {};
return {
itemId:
pickFirstStringByPaths(payload, [["item", "itemId"], ["itemId"], ["id"]]) ?? itemId,
title:
pickFirstStringByPaths(payload, [
["item", "title"],
["item", "itemTitle"],
["itemInfo", "title"],
["itemInfoModel", "title"],
["title"]
]) ?? null,
subtitle:
pickFirstStringByPaths(payload, [
["item", "subtitle"],
["item", "subTitle"],
["subTitle"]
]) ?? null,
price:
pickFirstStringByPaths(payload, [
["price", "componentsVO", "priceVO", "priceText"],
["price", "priceText"],
["price", "price"],
["priceInfo", "priceText"],
["vertical", "price", "priceText"]
]) ??
pickFirstStringByPaths(price, [["priceText"], ["price"]]) ??
null,
originalPrice:
pickFirstStringByPaths(payload, [
["price", "componentsVO", "priceVO", "crossPriceText"],
["price", "componentsVO", "priceVO", "subPriceText"],
["price", "originPriceText"],
["price", "originalPrice"],
["priceInfo", "originPriceText"]
]) ?? null,
shopName:
pickFirstStringByPaths(payload, [
["seller", "shopName"],
["seller", "shopTitle"],
["shop", "shopName"],
["shopInfo", "shopName"]
]) ?? null,
shopUrl:
pickFirstImageByPaths(payload, [
["seller", "shopUrl"],
["shop", "shopUrl"],
["shopInfo", "shopUrl"]
]) ?? null,
sellerType: normalizeTmallSellerType(
pickFirstStringByPaths(payload, [
["seller", "sellerType"],
["shop", "sellerType"],
["feature", "sellerType"]
])
),
categoryPath: pickCategoryPath(payload),
mainImage:
pickFirstImageByPaths(payload, [
["item", "mainPic"],
["item", "mainImage"],
["item", "images", 0],
["item", "images", 0, "url"],
["item", "picGallery", 0],
["item", "picGallery", 0, "image"],
["skuBase", "images", 0],
["skuBase", "images", 0, "url"]
]) ?? null,
salesDesc:
pickFirstStringByPaths(payload, [
["item", "sellCount"],
["item", "sellCountText"],
["item", "soldCount"],
["item", "soldCountText"],
["vertical", "salesDesc"]
]) ?? null,
commentCount:
pickFirstStringByPaths(payload, [
["rate", "total"],
["rate", "commentCount"],
["review", "total"],
["item", "commentCount"]
]) ?? null
};
}
export function parseTmallReviewsApiResponse(
itemId: string,
input: unknown
): TmallProductReviewsSnapshot {
const payload = unwrapTmallData(input) ?? {};
const module = asRecord(payload.module);
const h5Comments = asArray(payload.rateList)
.map((comment) => parseH5ReviewComment(comment))
.filter((comment): comment is TmallReviewCommentSnapshot => Boolean(comment));
const pcComments = asArray(module?.reviewVOList)
.map((comment) => parsePcReviewComment(comment))
.filter((comment): comment is TmallReviewCommentSnapshot => Boolean(comment));
const tags = asArray(payload.imprItemList ?? payload.imprItemVOS ?? module?.imprItemList)
.map((tag) => parseReviewTag(tag))
.filter((tag): tag is TmallReviewTagSnapshot => Boolean(tag));
return {
itemId,
total: firstString(payload.totalFuzzy, payload.total, module?.totalFuzzy, module?.total),
hasNext: parseBooleanish(firstString(payload.hasNext, module?.hasNext)),
allCount: firstString(payload.fuzzyRateCount, payload.feedAllCount, module?.feedAllCount),
pictureCount: firstString(
payload.feedMediaCountFuzzy,
payload.feedMediaCount,
module?.feedMediaCountFuzzy,
module?.feedMediaCount
),
appendCount: firstString(
payload.feedAppendCountFuzzy,
payload.feedAppendCount,
module?.feedAppendCountFuzzy,
module?.feedAppendCount
),
tags,
comments: h5Comments.length > 0 ? h5Comments : pcComments
};
}

View File

@ -0,0 +1,187 @@
import { describe, expect, it, vi } from "vitest";
import { TmallSessionManagerService } from "./session-manager";
import type { TmallLiveService, TmallLiveSessionSummary } from "./types";
function createSessionSummary(configured: boolean): TmallLiveSessionSummary {
return configured
? {
configured: true,
importedAt: "2026-04-03T10:00:00.000Z",
hasCookie: true,
userAgent: "stub-user-agent",
detailTemplate: {
available: true,
api: "mtop.taobao.pcdetail.data.get",
itemId: "934454505228"
},
reviewsTemplate: {
available: true,
api: "mtop.taobao.rate.detaillist.get",
itemId: "934454505228"
}
}
: {
configured: false,
hasCookie: false,
detailTemplate: {
available: false
},
reviewsTemplate: {
available: false
}
};
}
function createLiveService(): TmallLiveService {
let summary = createSessionSummary(false);
return {
getSessionSummary() {
return summary;
},
importSession() {
summary = createSessionSummary(true);
return summary;
},
clearSession() {
summary = createSessionSummary(false);
},
async previewSearch(query) {
return {
query,
source: "html",
candidateCount: 1,
candidates: [
{
candidateId: "tmall-934454505228",
platform: "tmall",
title: "Apple iPhone 15",
price: 4399,
priceLabel: "CNY 4399",
storeName: "Apple 官方旗舰店",
productUrl: "https://detail.tmall.com/item.htm?id=934454505228",
imageUrl: "https://img.alicdn.com/example.jpg",
salesHint: "已售 70万+",
specLabel: "128GB",
highlights: ["天猫"]
}
]
};
},
async previewDetail(itemId) {
return {
itemId,
source: "html",
detail: {
itemId,
title: "Apple iPhone 15",
subtitle: null,
price: "4399.00",
originalPrice: "4999.00",
shopName: "Apple 官方旗舰店",
shopUrl: null,
sellerType: "tmall",
categoryPath: [],
mainImage: null,
salesDesc: "已售 70万+",
commentCount: "20万+"
}
};
},
async previewReviews(itemId) {
return {
itemId,
source: "api",
pagination: {
requestedPage: 1,
requestedCommentCount: 1,
maxPages: 1,
pagesFetched: 1
},
reviews: {
itemId,
total: "20万+",
hasNext: false,
allCount: "20万+",
pictureCount: "1万+",
appendCount: "5000+",
tags: [],
comments: []
}
};
},
async previewProduct(itemId, options) {
const detail = await this.previewDetail(itemId);
const reviews = await this.previewReviews(itemId, options);
return {
itemId,
source: "hybrid",
detail: detail.detail,
pagination: reviews.pagination,
reviews: reviews.reviews
};
}
};
}
describe("TmallSessionManagerService", () => {
it("imports a manual session and exposes healthy ops state", async () => {
const onSessionReady = vi.fn();
const service = createLiveService();
const manager = new TmallSessionManagerService(service, {
onSessionReady
});
const state = await manager.importManualSession({
cookieHeader: "_m_h5_tk=masked_token_123;",
detailTemplateUrl: "https://detail.tmall.com/item.htm?id=934454505228",
reviewsTemplateUrl:
"https://h5api.m.tmall.com/h5/mtop.taobao.rate.detaillist.get/6.0/?data=%7B%22auctionNumId%22%3A%22934454505228%22%7D"
});
expect(onSessionReady).toHaveBeenCalledTimes(1);
expect(state).toMatchObject({
status: "healthy",
publicNote: "天猫会话由运维后台维护,当前可用。",
session: {
configured: true
}
});
});
it("marks the manager for manual action when the session has expired", async () => {
const onSessionUnavailable = vi.fn();
const service = createLiveService();
service.importSession({
cookieHeader: "_m_h5_tk=masked_token_123;",
detailTemplateUrl: "https://detail.tmall.com/item.htm?id=934454505228",
reviewsTemplateUrl:
"https://h5api.m.tmall.com/h5/mtop.taobao.rate.detaillist.get/6.0/?data=%7B%22auctionNumId%22%3A%22934454505228%22%7D"
});
service.previewDetail = vi.fn(async () => {
const error = new Error("Tmall detail session appears invalid.") as Error & {
statusCode: number;
};
error.statusCode = 409;
throw error;
});
const manager = new TmallSessionManagerService(service, {
onSessionUnavailable
});
const result = await manager.runHealthCheck("ops");
expect(onSessionUnavailable).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({
recovered: false,
state: {
status: "manual_action_required",
pendingManualAction: true,
lastFailureCode: "SESSION_REQUIRED",
publicNote: "天猫会话需要运维重新登录并更新 Cookie。"
}
});
});
});

View File

@ -0,0 +1,391 @@
import { isTmallLiveError } from "./live-session";
import type {
TmallLiveService,
TmallLiveSessionInput,
TmallSessionManager,
TmallSessionManagerConfigInput,
TmallSessionManagerRunResult,
TmallSessionManagerState,
TmallSessionManagerStatus
} from "./types";
const DEFAULT_CHECK_INTERVAL_MS = 10 * 60 * 1000;
const DEFAULT_HEARTBEAT_ITEM_ID = "934454505228";
type StoredTmallSessionManagerConfig = {
enabled: boolean;
heartbeatItemId?: string | undefined;
checkIntervalMs: number;
configuredAt?: string | undefined;
};
type TmallSessionManagerCallbacks = {
onSessionReady?: () => void;
onSessionUnavailable?: () => void;
};
function nowIso(): string {
return new Date().toISOString();
}
function readBooleanEnv(name: string, fallback: boolean): boolean {
const value = process.env[name]?.trim().toLowerCase();
if (!value) {
return fallback;
}
return value === "1" || value === "true" || value === "yes" || value === "on";
}
function readPositiveIntegerEnv(name: string, fallback: number): number {
const value = process.env[name]?.trim();
if (!value) {
return fallback;
}
const parsed = Number.parseInt(value, 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function normalizeOptionalString(value: string | null | undefined): string | undefined {
const normalized = value?.trim();
return normalized ? normalized : undefined;
}
function buildConfigFromEnv(): StoredTmallSessionManagerConfig {
const heartbeatItemId =
normalizeOptionalString(process.env.TMALL_OPS_HEARTBEAT_ITEM_ID) ?? DEFAULT_HEARTBEAT_ITEM_ID;
return {
enabled: readBooleanEnv("TMALL_OPS_ENABLED", true),
heartbeatItemId,
checkIntervalMs: readPositiveIntegerEnv(
"TMALL_OPS_CHECK_INTERVAL_MS",
DEFAULT_CHECK_INTERVAL_MS
),
configuredAt: heartbeatItemId ? nowIso() : undefined
};
}
function createBaseState(
liveService: TmallLiveService,
config: StoredTmallSessionManagerConfig
): TmallSessionManagerState {
return {
status: liveService.getSessionSummary().configured ? "degraded" : "idle",
enabled: config.enabled,
heartbeatItemId: config.heartbeatItemId,
checkIntervalMs: config.checkIntervalMs,
pendingManualAction: false,
note: liveService.getSessionSummary().configured
? "天猫会话已导入,等待后台健康检查确认。"
: "天猫会话管理器已初始化,等待首次会话注入。",
publicNote: liveService.getSessionSummary().configured
? "天猫会话正在后台校验。"
: "天猫会话由运维后台维护,当前尚未就绪。",
configuredAt: config.configuredAt,
session: liveService.getSessionSummary()
};
}
function resolveFailureDescriptor(error: unknown): {
code: string;
status: TmallSessionManagerStatus;
pendingManualAction: boolean;
publicNote: string;
} {
const message = error instanceof Error ? error.message : "unknown error";
const normalizedMessage = message.toLowerCase();
if (
(isTmallLiveError(error) && error.statusCode === 409) ||
normalizedMessage.includes("session appears invalid") ||
normalizedMessage.includes("live session is not configured") ||
normalizedMessage.includes("cookie/header")
) {
return {
code: "SESSION_REQUIRED",
status: "manual_action_required",
pendingManualAction: true,
publicNote: "天猫会话需要运维重新登录并更新 Cookie。"
};
}
if (
normalizedMessage.includes("template is missing") ||
normalizedMessage.includes("capture a fresh") ||
normalizedMessage.includes("does not expose a page field")
) {
return {
code: "TEMPLATE_REQUIRED",
status: "manual_action_required",
pendingManualAction: true,
publicNote: "天猫会话缺少有效模板,运维后台需要刷新模板。"
};
}
return {
code:
isTmallLiveError(error) && typeof error.statusCode === "number"
? `TMALL_${error.statusCode}`
: "TMALL_HEALTH_CHECK_FAILED",
status: "degraded",
pendingManualAction: false,
publicNote: "天猫会话健康检查失败,运维后台需要复核。"
};
}
export class TmallSessionManagerService implements TmallSessionManager {
private config = buildConfigFromEnv();
private state: TmallSessionManagerState;
private timer: NodeJS.Timeout | null = null;
constructor(
private readonly liveService: TmallLiveService,
private readonly callbacks: TmallSessionManagerCallbacks = {}
) {
this.state = createBaseState(liveService, this.config);
this.restartScheduler();
}
getState(): TmallSessionManagerState {
return {
...this.state,
session: this.liveService.getSessionSummary()
};
}
configure(input: TmallSessionManagerConfigInput): TmallSessionManagerState {
const nextConfig: StoredTmallSessionManagerConfig = {
...this.config,
...(typeof input.enabled === "boolean" ? { enabled: input.enabled } : {}),
...(input.heartbeatItemId !== undefined
? { heartbeatItemId: normalizeOptionalString(input.heartbeatItemId) }
: {}),
...(input.checkIntervalMs !== undefined && input.checkIntervalMs !== null
? {
checkIntervalMs:
Number.isInteger(input.checkIntervalMs) && input.checkIntervalMs > 0
? input.checkIntervalMs
: this.config.checkIntervalMs
}
: {}),
configuredAt: nowIso()
};
this.config = nextConfig;
this.state = {
...this.state,
enabled: nextConfig.enabled,
heartbeatItemId: nextConfig.heartbeatItemId,
checkIntervalMs: nextConfig.checkIntervalMs,
configuredAt: nextConfig.configuredAt,
note: "天猫运维配置已更新。",
publicNote: this.state.publicNote,
session: this.liveService.getSessionSummary()
};
this.restartScheduler();
return this.getState();
}
clearConfig(): TmallSessionManagerState {
this.config = {
enabled: false,
heartbeatItemId: DEFAULT_HEARTBEAT_ITEM_ID,
checkIntervalMs: DEFAULT_CHECK_INTERVAL_MS
};
this.state = {
...this.state,
enabled: false,
heartbeatItemId: DEFAULT_HEARTBEAT_ITEM_ID,
checkIntervalMs: DEFAULT_CHECK_INTERVAL_MS,
configuredAt: nowIso(),
note: "天猫运维配置已清空,自动巡检已停用。",
publicNote: this.liveService.getSessionSummary().configured
? "天猫会话已存在,但后台自动巡检已停用。"
: "天猫会话由运维后台维护,当前自动巡检未启用。",
pendingManualAction: false,
session: this.liveService.getSessionSummary()
};
this.restartScheduler();
return this.getState();
}
async importManualSession(
input: TmallLiveSessionInput,
source = "ops-manual"
): Promise<TmallSessionManagerState> {
this.liveService.importSession(input);
this.callbacks.onSessionReady?.();
this.state = {
...this.state,
status: "healthy",
pendingManualAction: false,
note: `天猫会话已通过 ${source} 更新,等待下一轮健康检查。`,
publicNote: "天猫会话由运维后台维护,当前可用。",
lastHealthyAt: nowIso(),
lastFailureCode: undefined,
lastFailureMessage: undefined,
session: this.liveService.getSessionSummary()
};
return this.getState();
}
clearManagedSession(reason = "ops-manual-clear"): TmallSessionManagerState {
this.liveService.clearSession();
this.callbacks.onSessionUnavailable?.();
this.state = {
...this.state,
status: "idle",
pendingManualAction: false,
note: `天猫会话已清理:${reason}`,
publicNote: "天猫会话由运维后台维护,当前尚未就绪。",
session: this.liveService.getSessionSummary()
};
return this.getState();
}
async runHealthCheck(trigger = "manual"): Promise<TmallSessionManagerRunResult> {
this.state = {
...this.state,
lastCheckAt: nowIso(),
session: this.liveService.getSessionSummary()
};
const summary = this.liveService.getSessionSummary();
if (!summary.configured) {
this.callbacks.onSessionUnavailable?.();
this.state = {
...this.state,
status: "manual_action_required",
pendingManualAction: true,
note: "当前没有可用天猫会话,等待运维注入。",
publicNote: "天猫会话由运维后台维护,当前尚未就绪。",
session: summary
};
return {
state: this.getState(),
recovered: false
};
}
if (!summary.detailTemplate.available || !summary.reviewsTemplate.available) {
this.callbacks.onSessionUnavailable?.();
this.state = {
...this.state,
status: "manual_action_required",
pendingManualAction: true,
note: "天猫详情或评论模板缺失,需要运维刷新模板。",
publicNote: "天猫会话缺少有效模板,运维后台正在处理。",
session: summary
};
return {
state: this.getState(),
recovered: false
};
}
try {
await this.verifyCurrentSession();
this.callbacks.onSessionReady?.();
this.state = {
...this.state,
status: "healthy",
pendingManualAction: false,
note: `天猫会话健康检查通过:${trigger}`,
publicNote: "天猫会话由运维后台维护,当前可用。",
lastHealthyAt: nowIso(),
lastFailureCode: undefined,
lastFailureMessage: undefined,
session: this.liveService.getSessionSummary()
};
return {
state: this.getState(),
recovered: false
};
} catch (error) {
await this.handleFailure(error);
return {
state: this.getState(),
recovered: false
};
}
}
async handleLiveFailure(
error: unknown,
_context: {
capability?: "search" | "detail" | "reviews";
taskId?: string | undefined;
trigger?: string | undefined;
} = {}
): Promise<boolean> {
await this.handleFailure(error);
return false;
}
shutdown(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
private restartScheduler(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
if (!this.config.enabled) {
return;
}
this.timer = setInterval(() => {
void this.runHealthCheck("scheduler");
}, this.config.checkIntervalMs);
this.timer.unref?.();
}
private resolveHeartbeatItemId(): string | null {
const summary = this.liveService.getSessionSummary();
return (
this.config.heartbeatItemId ??
summary.detailTemplate.itemId ??
summary.reviewsTemplate.itemId ??
null
);
}
private async verifyCurrentSession(): Promise<void> {
const summary = this.liveService.getSessionSummary();
const heartbeatItemId = this.resolveHeartbeatItemId();
if (!summary.configured || !heartbeatItemId) {
throw new Error("天猫会话缺少可校验的详情/评论模板。");
}
await this.liveService.previewDetail(heartbeatItemId);
await this.liveService.previewReviews(heartbeatItemId, {
commentCount: 1,
maxPages: 1
});
}
private async handleFailure(error: unknown): Promise<void> {
const message = error instanceof Error ? error.message : "unknown error";
const failure = resolveFailureDescriptor(error);
this.callbacks.onSessionUnavailable?.();
this.state = {
...this.state,
status: failure.status,
pendingManualAction: failure.pendingManualAction,
lastFailureCode: failure.code,
lastFailureMessage: message,
note: `天猫会话检测失败:${message}`,
publicNote: failure.publicNote,
session: this.liveService.getSessionSummary()
};
}
}

View File

@ -0,0 +1,182 @@
import type { CandidateRecord } from "@cross-ai/domain";
export interface TmallTemplateSummary {
available: boolean;
api?: string | undefined;
itemId?: string | undefined;
}
export interface TmallLiveSessionInput {
cookieHeader: string;
userAgent?: string | undefined;
detailTemplateUrl?: string | undefined;
reviewsTemplateUrl?: string | undefined;
detailReferer?: string | undefined;
}
export interface TmallLiveSessionSummary {
configured: boolean;
importedAt?: string | undefined;
hasCookie: boolean;
userAgent?: string | undefined;
detailTemplate: TmallTemplateSummary;
reviewsTemplate: TmallTemplateSummary;
}
export type TmallSessionManagerStatus =
| "idle"
| "healthy"
| "degraded"
| "manual_action_required";
export interface TmallSessionManagerConfigInput {
enabled?: boolean | undefined;
heartbeatItemId?: string | null | undefined;
checkIntervalMs?: number | null | undefined;
}
export interface TmallSessionManagerState {
status: TmallSessionManagerStatus;
enabled: boolean;
heartbeatItemId?: string | undefined;
checkIntervalMs: number;
pendingManualAction: boolean;
note: string;
publicNote: string;
configuredAt?: string | undefined;
lastCheckAt?: string | undefined;
lastHealthyAt?: string | undefined;
lastFailureCode?: string | undefined;
lastFailureMessage?: string | undefined;
session: TmallLiveSessionSummary;
}
export interface TmallSessionManagerRunResult {
state: TmallSessionManagerState;
recovered: boolean;
}
export interface TmallProductDetailSnapshot {
itemId: string;
title: string | null;
subtitle: string | null;
price: string | null;
originalPrice: string | null;
shopName: string | null;
shopUrl: string | null;
sellerType: string | null;
categoryPath: string[];
mainImage: string | null;
salesDesc: string | null;
commentCount: string | null;
}
export interface TmallReviewTagSnapshot {
name: string;
count: string | null;
}
export interface TmallReviewCommentSnapshot {
id: string;
content: string;
date: string | null;
userNick: string | null;
userAvatar: string | null;
skuText: string[];
pictureUrls: string[];
videoUrls: string[];
likeCount: string | null;
reply: string | null;
appendContent: string | null;
appendPictureUrls: string[];
}
export interface TmallProductReviewsSnapshot {
itemId: string;
total: string | null;
hasNext: boolean;
allCount: string | null;
pictureCount: string | null;
appendCount: string | null;
tags: TmallReviewTagSnapshot[];
comments: TmallReviewCommentSnapshot[];
}
export interface TmallReviewsPreviewOptions {
commentCount?: number | undefined;
page?: number | undefined;
maxPages?: number | undefined;
}
export interface TmallReviewsPaginationSummary {
requestedPage: number;
requestedCommentCount: number;
maxPages: number;
pagesFetched: number;
pageKey?: string | undefined;
}
export interface TmallSearchPreviewResult {
query: string;
source: "html";
candidateCount: number;
candidates: CandidateRecord[];
}
export interface TmallDetailPreviewResult {
itemId: string;
source: "api" | "html";
detail: TmallProductDetailSnapshot;
}
export interface TmallReviewsPreviewResult {
itemId: string;
source: "api";
pagination: TmallReviewsPaginationSummary;
reviews: TmallProductReviewsSnapshot;
}
export interface TmallProductPreviewResult {
itemId: string;
source: "api" | "html" | "hybrid";
detail: TmallProductDetailSnapshot;
pagination: TmallReviewsPaginationSummary;
reviews: TmallProductReviewsSnapshot;
}
export interface TmallLiveService {
getSessionSummary(): TmallLiveSessionSummary;
importSession(input: TmallLiveSessionInput): TmallLiveSessionSummary;
clearSession(): void;
previewSearch(query: string): Promise<TmallSearchPreviewResult>;
previewDetail(itemId: string): Promise<TmallDetailPreviewResult>;
previewReviews(
itemId: string,
options?: number | TmallReviewsPreviewOptions
): Promise<TmallReviewsPreviewResult>;
previewProduct(
itemId: string,
options?: number | TmallReviewsPreviewOptions
): Promise<TmallProductPreviewResult>;
}
export interface TmallSessionManager {
getState(): TmallSessionManagerState;
configure(input: TmallSessionManagerConfigInput): TmallSessionManagerState;
clearConfig(): TmallSessionManagerState;
importManualSession(
input: TmallLiveSessionInput,
source?: string
): Promise<TmallSessionManagerState>;
clearManagedSession(reason?: string): TmallSessionManagerState;
runHealthCheck(trigger?: string): Promise<TmallSessionManagerRunResult>;
handleLiveFailure(
error: unknown,
context?: {
capability?: "search" | "detail" | "reviews";
taskId?: string | undefined;
trigger?: string | undefined;
}
): Promise<boolean>;
shutdown(): void;
}

View File

@ -0,0 +1,187 @@
type PathSegment = string | number;
export function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
export function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
export function stringFrom(value: unknown): string | null {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return null;
}
export function firstString(...values: unknown[]): string | null {
for (const value of values) {
const resolved = stringFrom(value);
if (resolved) {
return resolved;
}
}
return null;
}
export function parseBooleanish(value: unknown): boolean {
const normalized = stringFrom(value)?.toLowerCase();
return normalized === "true" || normalized === "1" || normalized === "yes";
}
export function decodeHtmlEntities(value: string): string {
return value
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">");
}
export function stripTags(value: string): string {
return value.replace(/<[^>]+>/g, " ");
}
export function normalizeWhitespace(value: string): string {
return decodeHtmlEntities(stripTags(value)).replace(/\s+/g, " ").trim();
}
export function uniqueStrings(values: Array<string | null | undefined>): string[] {
return Array.from(
new Set(
values
.map((value) => value?.trim())
.filter((value): value is string => Boolean(value))
)
);
}
export function absolutizeTmallUrl(value: string | null | undefined): string | null {
if (!value) {
return null;
}
if (value.startsWith("http://") || value.startsWith("https://")) {
return value;
}
if (value.startsWith("//")) {
return `https:${value}`;
}
if (value.startsWith("/")) {
return `https://detail.tmall.com${value}`;
}
return `https://${value}`;
}
export function parseEmbeddedJson(value: string | null): Record<string, unknown> | null {
if (!value) {
return null;
}
try {
const parsed = JSON.parse(value) as unknown;
return asRecord(parsed);
} catch {
const jsonpMatch = value.match(/^[^(]+\(([\s\S]*)\)\s*;?\s*$/);
if (!jsonpMatch?.[1]) {
return null;
}
try {
const parsed = JSON.parse(jsonpMatch[1]) as unknown;
return asRecord(parsed);
} catch {
return null;
}
}
}
export function readNestedJsonRecord(value: unknown): Record<string, unknown> | null {
if (typeof value === "string") {
return parseEmbeddedJson(value);
}
return asRecord(value);
}
export function readQueryData(url: URL): Record<string, unknown> | null {
return (
parseEmbeddedJson(url.searchParams.get("data")) ??
parseEmbeddedJson(url.searchParams.get("body"))
);
}
export function findFirstRecordKey(
record: Record<string, unknown> | null,
candidates: string[]
): string | null {
if (!record) {
return null;
}
for (const candidate of candidates) {
if (candidate in record) {
return candidate;
}
}
return null;
}
export function withUpdatedQueryData(
url: URL,
updater: (data: Record<string, unknown>) => Record<string, unknown>
): string {
const nextUrl = new URL(url.toString());
const paramName = nextUrl.searchParams.has("data") ? "data" : "body";
const currentData = readQueryData(nextUrl) ?? {};
nextUrl.searchParams.set(paramName, JSON.stringify(updater(currentData)));
return nextUrl.toString();
}
export function readPath(root: unknown, path: PathSegment[]): unknown {
let current: unknown = root;
for (const segment of path) {
if (typeof segment === "number") {
const currentArray = asArray(current);
current = currentArray[segment];
continue;
}
const currentRecord = asRecord(current);
current = currentRecord?.[segment];
}
return current;
}
export function pickFirstStringByPaths(
root: unknown,
paths: PathSegment[][]
): string | null {
for (const path of paths) {
const value = stringFrom(readPath(root, path));
if (value) {
return normalizeWhitespace(value);
}
}
return null;
}

View File

@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import {
buildReviewBudgetPlan,
sampleReviewComments,
type ReviewSamplingComment
} from "./review-sampling";
function buildComment(
id: string,
content: string,
score: string,
authorLabel = "PLUS"
): ReviewSamplingComment {
return {
id,
content,
score,
createdAt: "2026-04-03 10:00:00",
authorLabel
};
}
describe("review-sampling", () => {
it("distributes the task-level review budget across selected links", () => {
const budgets = buildReviewBudgetPlan(["a", "b", "c"], 100, 5);
expect(Array.from(budgets.entries())).toEqual([
["a", 2],
["b", 2],
["c", 1]
]);
expect(Array.from(budgets.values()).reduce((sum, value) => sum + value, 0)).toBe(5);
});
it("allows zero-review allocations when the task total limit is below selected link count", () => {
const budgets = buildReviewBudgetPlan(["a", "b", "c", "d", "e"], 100, 2);
expect(Array.from(budgets.entries())).toEqual([
["a", 1],
["b", 1],
["c", 0],
["d", 0],
["e", 0]
]);
});
it("samples comments with 40/30/30 latest-hot-negative targets when all buckets are available", () => {
const comments = [
buildComment("latest-1", "物流很快,系统很流畅。", "5"),
buildComment("latest-2", "拍照效果不错,续航也稳定。", "5"),
buildComment("latest-3", "屏幕亮度高,外观好看。", "5"),
buildComment("latest-4", "音质清晰,做工扎实。", "5"),
buildComment("hot-1", "这是我写过最长的一条好评,细节描述非常完整,便于归入热评桶。", "5"),
buildComment("hot-2", "另一条较长的高分评论,包含更多使用场景和优点描述。", "5"),
buildComment("hot-3", "第三条高信息量评论,强调拍照、手感和续航表现。", "4"),
buildComment("negative-1", "发热明显,打游戏会卡顿。", "2"),
buildComment("negative-2", "续航一般,晚上掉电有点快。", "3"),
buildComment("negative-3", "边框有瑕疵,体验一般。", "2")
];
const sampled = sampleReviewComments(comments, 10, ["发热", "卡顿", "掉电", "瑕疵"]);
expect(sampled.actualCount).toBe(10);
expect(sampled.sampleInsufficient).toBe(false);
expect(sampled.bucketCounts).toEqual({
latest: 4,
hot: 3,
negative: 3
});
});
it("redistributes missing negative quota to the remaining buckets", () => {
const comments = [
buildComment("c-1", "外观好看。", "5"),
buildComment("c-2", "系统流畅。", "5"),
buildComment("c-3", "续航不错。", "5"),
buildComment("c-4", "拍照清晰。", "5"),
buildComment("c-5", "做工扎实。", "4")
];
const sampled = sampleReviewComments(comments, 5, ["发热", "卡顿"]);
expect(sampled.actualCount).toBe(5);
expect(sampled.sampleInsufficient).toBe(false);
expect(sampled.bucketCounts).toEqual({
latest: 3,
hot: 2,
negative: 0
});
});
});

View File

@ -0,0 +1,312 @@
export type ReviewSamplingBucket = "latest" | "hot" | "negative";
export interface ReviewSamplingComment {
id: string;
content: string;
score: string | null;
createdAt: string | null;
authorLabel: string | null;
}
export interface SampledReviewComment<T extends ReviewSamplingComment = ReviewSamplingComment> {
bucket: ReviewSamplingBucket;
comment: T;
}
export interface ReviewSamplingResult<T extends ReviewSamplingComment = ReviewSamplingComment> {
targetCount: number;
actualCount: number;
sampleInsufficient: boolean;
bucketCounts: Record<ReviewSamplingBucket, number>;
comments: Array<SampledReviewComment<T>>;
}
const BUCKET_WEIGHTS: Record<ReviewSamplingBucket, number> = {
latest: 0.4,
hot: 0.3,
negative: 0.3
};
const QUOTA_PRIORITY: ReviewSamplingBucket[] = ["latest", "hot", "negative"];
const SELECTION_PRIORITY: ReviewSamplingBucket[] = ["negative", "hot", "latest"];
const FALLBACK_PRIORITY: ReviewSamplingBucket[] = ["latest", "hot", "negative"];
function normalizeBudget(value: number): number {
if (!Number.isFinite(value) || value <= 0) {
return 0;
}
return Math.floor(value);
}
function parseScore(score: string | null): number | null {
if (!score) {
return null;
}
const parsed = Number.parseInt(score, 10);
return Number.isNaN(parsed) ? null : parsed;
}
function isNegativeComment(
comment: ReviewSamplingComment,
negativeKeywords: string[]
): boolean {
const score = parseScore(comment.score);
if (score !== null && score <= 3) {
return true;
}
const normalizedContent = comment.content.toLowerCase();
return negativeKeywords.some((keyword) => normalizedContent.includes(keyword.toLowerCase()));
}
function getHotScore(comment: ReviewSamplingComment): number {
const score = parseScore(comment.score);
const scoreBonus = score !== null && score >= 4 ? 16 : score === 3 ? 8 : 0;
const authorBonus = comment.authorLabel ? 12 : 0;
const timestampBonus = comment.createdAt ? 4 : 0;
return Math.min(comment.content.length, 160) + scoreBonus + authorBonus + timestampBonus;
}
function dedupeComments<T extends ReviewSamplingComment>(comments: T[]): T[] {
const deduped = new Map<string, T>();
for (const comment of comments) {
if (!deduped.has(comment.id)) {
deduped.set(comment.id, comment);
}
}
return Array.from(deduped.values());
}
function buildBucketTargets(
targetCount: number,
availability: Record<ReviewSamplingBucket, boolean>
): Record<ReviewSamplingBucket, number> {
const normalizedTarget = normalizeBudget(targetCount);
const activeBuckets = QUOTA_PRIORITY.filter((bucket) => availability[bucket]);
const targets: Record<ReviewSamplingBucket, number> = {
latest: 0,
hot: 0,
negative: 0
};
if (normalizedTarget === 0 || activeBuckets.length === 0) {
return targets;
}
const activeWeight = activeBuckets.reduce((sum, bucket) => sum + BUCKET_WEIGHTS[bucket], 0);
let allocated = 0;
const fractions = activeBuckets.map((bucket) => {
const raw = (normalizedTarget * BUCKET_WEIGHTS[bucket]) / activeWeight;
const base = Math.floor(raw);
targets[bucket] = base;
allocated += base;
return {
bucket,
fraction: raw - base
};
});
let remainder = normalizedTarget - allocated;
fractions
.sort(
(left, right) =>
right.fraction - left.fraction ||
QUOTA_PRIORITY.indexOf(left.bucket) - QUOTA_PRIORITY.indexOf(right.bucket)
)
.forEach(({ bucket }) => {
if (remainder <= 0) {
return;
}
targets[bucket] += 1;
remainder -= 1;
});
return targets;
}
function hasUnusedComment<T extends ReviewSamplingComment>(pool: T[], usedIds: Set<string>): boolean {
return pool.some((comment) => !usedIds.has(comment.id));
}
function pickNextComment<T extends ReviewSamplingComment>(
bucket: ReviewSamplingBucket,
pool: T[],
usedIds: Set<string>,
selected: Array<SampledReviewComment<T>>,
bucketCounts: Record<ReviewSamplingBucket, number>
): boolean {
for (const comment of pool) {
if (usedIds.has(comment.id)) {
continue;
}
usedIds.add(comment.id);
selected.push({
bucket,
comment
});
bucketCounts[bucket] += 1;
return true;
}
return false;
}
export function buildReviewBudgetPlan(
candidateIds: string[],
perLinkLimit: number,
taskTotalLimit: number
): Map<string, number> {
const uniqueCandidateIds = Array.from(new Set(candidateIds));
const budgets = new Map<string, number>(
uniqueCandidateIds.map((candidateId) => [candidateId, 0] as const)
);
const normalizedPerLinkLimit = normalizeBudget(perLinkLimit);
const normalizedTaskTotalLimit = normalizeBudget(taskTotalLimit);
if (
uniqueCandidateIds.length === 0 ||
normalizedPerLinkLimit === 0 ||
normalizedTaskTotalLimit === 0
) {
return budgets;
}
const totalBudget = Math.min(
normalizedTaskTotalLimit,
uniqueCandidateIds.length * normalizedPerLinkLimit
);
const baseAllocation = Math.min(
normalizedPerLinkLimit,
Math.floor(totalBudget / uniqueCandidateIds.length)
);
for (const candidateId of uniqueCandidateIds) {
budgets.set(candidateId, baseAllocation);
}
let remainder = totalBudget - baseAllocation * uniqueCandidateIds.length;
while (remainder > 0) {
let assigned = false;
for (const candidateId of uniqueCandidateIds) {
const currentBudget = budgets.get(candidateId) ?? 0;
if (currentBudget >= normalizedPerLinkLimit) {
continue;
}
budgets.set(candidateId, currentBudget + 1);
remainder -= 1;
assigned = true;
if (remainder === 0) {
break;
}
}
if (!assigned) {
break;
}
}
return budgets;
}
export function sampleReviewComments<T extends ReviewSamplingComment>(
comments: T[],
targetCount: number,
negativeKeywords: string[]
): ReviewSamplingResult<T> {
const uniqueComments = dedupeComments(comments);
const normalizedTarget = normalizeBudget(targetCount);
const bucketCounts: Record<ReviewSamplingBucket, number> = {
latest: 0,
hot: 0,
negative: 0
};
if (normalizedTarget === 0 || uniqueComments.length === 0) {
return {
targetCount: normalizedTarget,
actualCount: 0,
sampleInsufficient: normalizedTarget > 0,
bucketCounts,
comments: []
};
}
const negativeCommentIds = new Set(
uniqueComments
.filter((comment) => isNegativeComment(comment, negativeKeywords))
.map((comment) => comment.id)
);
const negativePool = uniqueComments.filter((comment) => negativeCommentIds.has(comment.id));
const latestPool = uniqueComments.filter((comment) => !negativeCommentIds.has(comment.id));
const hotPool = [...latestPool].sort(
(left, right) =>
getHotScore(right) - getHotScore(left) ||
latestPool.findIndex((comment) => comment.id === left.id) -
latestPool.findIndex((comment) => comment.id === right.id)
);
const bucketPools: Record<ReviewSamplingBucket, T[]> = {
latest: latestPool,
hot: hotPool,
negative: negativePool
};
const bucketTargets = buildBucketTargets(normalizedTarget, {
latest: latestPool.length > 0,
hot: hotPool.length > 0,
negative: negativePool.length > 0
});
const selected: Array<SampledReviewComment<T>> = [];
const usedIds = new Set<string>();
for (const bucket of SELECTION_PRIORITY) {
while (
bucketCounts[bucket] < bucketTargets[bucket] &&
pickNextComment(bucket, bucketPools[bucket], usedIds, selected, bucketCounts)
) {
// Continue until this bucket reaches its target or runs out of comments.
}
}
while (selected.length < normalizedTarget) {
const deficitBucket = SELECTION_PRIORITY.find(
(bucket) =>
bucketCounts[bucket] < bucketTargets[bucket] &&
hasUnusedComment(bucketPools[bucket], usedIds)
);
if (deficitBucket) {
if (pickNextComment(deficitBucket, bucketPools[deficitBucket], usedIds, selected, bucketCounts)) {
continue;
}
}
const fallbackBucket = FALLBACK_PRIORITY.find((bucket) =>
hasUnusedComment(bucketPools[bucket], usedIds)
);
if (!fallbackBucket) {
break;
}
if (!pickNextComment(fallbackBucket, bucketPools[fallbackBucket], usedIds, selected, bucketCounts)) {
break;
}
}
return {
targetCount: normalizedTarget,
actualCount: selected.length,
sampleInsufficient: selected.length < normalizedTarget,
bucketCounts,
comments: selected
};
}

View File

@ -0,0 +1,976 @@
import { describe, expect, it } from "vitest";
import type {
JdDetailPreviewResult,
JdLiveService,
JdLiveSessionSummary,
JdProductPreviewResult,
JdReviewsPreviewOptions,
JdReviewsPreviewResult,
JdSearchPreviewResult
} from "./platforms/jd/types";
import type {
TmallDetailPreviewResult,
TmallLiveService,
TmallLiveSessionSummary,
TmallProductPreviewResult,
TmallReviewsPreviewResult,
TmallSearchPreviewResult
} from "./platforms/tmall/types";
import { createServer } from "./server";
async function createTask(app: ReturnType<typeof createServer>, query: string) {
const response = await app.inject({
method: "POST",
url: "/api/tasks",
payload: {
query,
perLinkLimit: 100,
taskTotalLimit: 500
}
});
return response.json().task;
}
async function importDualLiveSessions(app: ReturnType<typeof createServer>) {
const jdResponse = await app.inject({
method: "POST",
url: "/api/platforms/jd/live-session",
payload: {
cookieHeader: "thor=masked; pin=masked;",
searchApiTemplateUrl:
"https://api.m.jd.com/?functionId=pc_search_searchWare&body=%7B%22keyword%22:%22iphone%2015%22%7D",
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"
}
});
const tmallResponse = await app.inject({
method: "POST",
url: "/api/platforms/tmall/live-session",
payload: {
cookieHeader: "_tb_token_=masked;",
detailTemplateUrl: "https://detail.tmall.com/item.htm?id=934454505228",
reviewsTemplateUrl:
"https://h5api.m.tmall.com/h5/mtop.taobao.rate.detaillist.get/6.0/?data=%7B%22auctionNumId%22%3A%22934454505228%22%7D"
}
});
expect(jdResponse.statusCode).toBe(200);
expect(tmallResponse.statusCode).toBe(200);
}
function createJdLiveServiceStub(overrides: Partial<JdLiveService> = {}): JdLiveService {
let summary: JdLiveSessionSummary = {
configured: false,
hasCookie: false,
searchApiTemplate: { available: false },
detailTemplate: { available: false },
reviewsTemplate: { available: false }
};
return {
getSessionSummary() {
return overrides.getSessionSummary?.() ?? summary;
},
importSession(input) {
if (overrides.importSession) {
return overrides.importSession(input);
}
summary = {
configured: true,
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) }
};
return summary;
},
clearSession() {
if (overrides.clearSession) {
overrides.clearSession();
return;
}
summary = {
configured: false,
hasCookie: false,
searchApiTemplate: { available: false },
detailTemplate: { available: false },
reviewsTemplate: { available: false }
};
},
async previewSearch(query) {
if (overrides.previewSearch) {
return overrides.previewSearch(query);
}
const preview: JdSearchPreviewResult = {
query,
source: "html",
candidateCount: 1,
candidates: [
{
candidateId: "jd-100068388533",
platform: "jd",
title: "Apple/苹果 iPhone 15",
price: 3898,
priceLabel: "CNY 3898",
storeName: "Apple产品京东自营旗舰店",
productUrl: "https://item.jd.com/100068388533.html",
imageUrl: "https://img14.360buyimg.com/n2/jfs/t1/example.jpg",
salesHint: "已售500万+",
specLabel: "128GB",
highlights: ["A16仿生芯片"]
}
]
};
return preview;
},
async previewDetail(skuId) {
if (overrides.previewDetail) {
return overrides.previewDetail(skuId);
}
const preview: JdDetailPreviewResult = {
skuId,
source: "api",
detail: {
skuId,
title: "Apple/苹果 iPhone 15",
price: "4398.00",
originalPrice: "4599.00",
estimatedPrice: "3898.00",
shopName: "Apple产品京东自营旗舰店",
vendorId: null,
categoryPath: ["手机通讯", "手机", "Apple"],
stockState: "现货",
mainImage: "https://img14.360buyimg.com/n2/jfs/t1/example.jpg",
averageScore: "4.9"
}
};
return preview;
},
async previewReviews(skuId, options) {
if (overrides.previewReviews) {
return overrides.previewReviews(skuId, options);
}
const requestedCommentCount =
typeof options === "number" ? options : (options?.commentCount ?? 20);
const preview: JdReviewsPreviewResult = {
skuId,
source: "api",
pagination: {
requestedPage: typeof options === "object" ? (options?.page ?? 1) : 1,
requestedCommentCount,
maxPages: typeof options === "object" ? (options?.maxPages ?? 1) : 1,
pagesFetched: 1
},
reviews: {
skuId,
total: String(requestedCommentCount),
goodRate: "95%",
pictureCount: "500",
tags: [{ tagId: "jd-tag-1", name: "拍照清晰", count: "1200" }],
comments: [
{
id: "jd-comment-1",
content: "系统流畅,拍照清晰。",
score: "5",
creationTime: "2026-04-03 10:00:00",
userLevelName: "PLUS会员"
}
]
}
};
return preview;
},
async previewProduct(skuId, options?: number | JdReviewsPreviewOptions) {
if (overrides.previewProduct) {
return overrides.previewProduct(skuId, options);
}
const detail = await this.previewDetail(skuId);
const reviews = await this.previewReviews(skuId, options);
const preview: JdProductPreviewResult = {
skuId,
source: "api",
detail: detail.detail,
pagination: reviews.pagination,
reviews: reviews.reviews
};
return preview;
}
};
}
function createTmallLiveServiceStub(overrides: Partial<TmallLiveService> = {}): TmallLiveService {
let summary: TmallLiveSessionSummary = {
configured: false,
hasCookie: false,
detailTemplate: { available: false },
reviewsTemplate: { available: false }
};
return {
getSessionSummary() {
return overrides.getSessionSummary?.() ?? summary;
},
importSession(input) {
if (overrides.importSession) {
return overrides.importSession(input);
}
summary = {
configured: true,
importedAt: "2026-04-03T10:05:00.000Z",
hasCookie: true,
userAgent: input.userAgent ?? "stub-user-agent",
detailTemplate: { available: Boolean(input.detailTemplateUrl), itemId: "934454505228" },
reviewsTemplate: { available: Boolean(input.reviewsTemplateUrl), itemId: "934454505228" }
};
return summary;
},
clearSession() {
if (overrides.clearSession) {
overrides.clearSession();
return;
}
summary = {
configured: false,
hasCookie: false,
detailTemplate: { available: false },
reviewsTemplate: { available: false }
};
},
async previewSearch(query) {
if (overrides.previewSearch) {
return overrides.previewSearch(query);
}
const preview: TmallSearchPreviewResult = {
query,
source: "html",
candidateCount: 1,
candidates: [
{
candidateId: "tmall-934454505228",
platform: "tmall",
title: "Apple iPhone 15",
price: 4399,
priceLabel: "CNY 4399",
storeName: "Apple 官方旗舰店",
productUrl: "https://detail.tmall.com/item.htm?id=934454505228",
imageUrl: "https://img.alicdn.com/example.jpg",
salesHint: "已售 70万+",
specLabel: "128GB",
highlights: ["天猫", "官方旗舰店"]
}
]
};
return preview;
},
async previewDetail(itemId) {
if (overrides.previewDetail) {
return overrides.previewDetail(itemId);
}
const preview: TmallDetailPreviewResult = {
itemId,
source: "html",
detail: {
itemId,
title: "Apple iPhone 15",
subtitle: null,
price: "4399.00",
originalPrice: "4999.00",
shopName: "Apple 官方旗舰店",
shopUrl: null,
sellerType: "tmall",
categoryPath: ["手机", "Apple"],
mainImage: "https://img.alicdn.com/example.jpg",
salesDesc: "已售 70万+",
commentCount: "20万+"
}
};
return preview;
},
async previewReviews(itemId, options) {
if (overrides.previewReviews) {
return overrides.previewReviews(itemId, options);
}
const requestedCommentCount =
typeof options === "number" ? options : (options?.commentCount ?? 20);
const preview: TmallReviewsPreviewResult = {
itemId,
source: "api",
pagination: {
requestedPage: typeof options === "object" ? (options?.page ?? 1) : 1,
requestedCommentCount,
maxPages: typeof options === "object" ? (options?.maxPages ?? 1) : 1,
pagesFetched: 1,
pageKey: "pageNum"
},
reviews: {
itemId,
total: String(requestedCommentCount),
hasNext: false,
allCount: "20万+",
pictureCount: "5000+",
appendCount: "1200+",
tags: [{ name: "手感好", count: "5313" }],
comments: [
{
id: "tmall-review-1",
content: "外观精致,物流很快。",
date: "2026-04-03",
userNick: "Alice",
userAvatar: null,
skuText: ["黑色", "128GB"],
pictureUrls: [],
videoUrls: [],
likeCount: "3",
reply: null,
appendContent: null,
appendPictureUrls: []
}
]
}
};
return preview;
},
async previewProduct(itemId, options) {
if (overrides.previewProduct) {
return overrides.previewProduct(itemId, options);
}
const detail = await this.previewDetail(itemId);
const reviews = await this.previewReviews(itemId, options);
const preview: TmallProductPreviewResult = {
itemId,
source: "hybrid",
detail: detail.detail,
pagination: reviews.pagination,
reviews: reviews.reviews
};
return preview;
}
};
}
describe("Dual-platform live loop", () => {
it("closes the JD + Tmall live loop and publishes a combined report", async () => {
const app = createServer({
jdLiveService: createJdLiveServiceStub(),
tmallLiveService: createTmallLiveServiceStub()
});
await app.ready();
await importDualLiveSessions(app);
const createdTask = await createTask(app, "iPhone 15");
const candidatesResponse = await app.inject({
method: "GET",
url: `/api/tasks/${createdTask.taskId}/candidates`
});
expect(candidatesResponse.statusCode).toBe(200);
const candidates = candidatesResponse.json().candidates;
expect(candidates.jd[0]).toMatchObject({
candidateId: "jd-100068388533",
productUrl: "https://item.jd.com/100068388533.html"
});
expect(candidates.tmall[0]).toMatchObject({
candidateId: "tmall-934454505228",
productUrl: "https://detail.tmall.com/item.htm?id=934454505228"
});
const confirmResponse = await app.inject({
method: "POST",
url: `/api/tasks/${createdTask.taskId}/confirm`,
payload: {
selections: [
{
platform: "jd",
candidateIds: [candidates.jd[0].candidateId]
},
{
platform: "tmall",
candidateIds: [candidates.tmall[0].candidateId]
}
]
}
});
expect(confirmResponse.statusCode).toBe(200);
expect(confirmResponse.json().task).toMatchObject({
taskStatus: "Completed",
defaultReportVersion: 1
});
expect(confirmResponse.json().task.platformRuns).toEqual(
expect.arrayContaining([
expect.objectContaining({ platform: "jd", status: "Completed" }),
expect.objectContaining({ platform: "tmall", status: "Completed" })
])
);
const reportResponse = await app.inject({
method: "GET",
url: `/api/tasks/${createdTask.taskId}/report`
});
expect(reportResponse.statusCode).toBe(200);
expect(reportResponse.json().report.task_status).toBe("Completed");
expect(reportResponse.json().report.evidence_index).toEqual(
expect.arrayContaining([
expect.objectContaining({
platform: "jd",
source_type: "product",
source_url: "https://item.jd.com/100068388533.html"
}),
expect.objectContaining({
platform: "tmall",
source_type: "product",
source_url: "https://detail.tmall.com/item.htm?id=934454505228"
}),
expect.objectContaining({
platform: "jd",
source_type: "review",
review_ref: "jd-comment-1"
}),
expect.objectContaining({
platform: "tmall",
source_type: "review",
review_ref: "tmall-review-1"
})
])
);
await app.close();
});
it("keeps the task confirmable when Tmall live search is blocked but JD succeeds", async () => {
const tmallLiveService = createTmallLiveServiceStub({
async previewSearch() {
const error = new Error("Tmall search session appears invalid.") as Error & {
statusCode: number;
};
error.statusCode = 409;
throw error;
}
});
const app = createServer({
jdLiveService: createJdLiveServiceStub(),
tmallLiveService
});
await app.ready();
await importDualLiveSessions(app);
const createdTask = await createTask(app, "iPhone 15");
expect(
createdTask.platformRuns.find((run: { platform: string }) => run.platform === "tmall")
).toMatchObject({
platform: "tmall",
status: "SearchBlocked"
});
expect(
createdTask.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`
});
const candidates = candidatesResponse.json().candidates;
expect(candidates.tmall).toEqual([]);
expect(candidates.jd).toHaveLength(1);
const confirmResponse = await app.inject({
method: "POST",
url: `/api/tasks/${createdTask.taskId}/confirm`,
payload: {
selections: [
{
platform: "jd",
candidateIds: [candidates.jd[0].candidateId]
}
]
}
});
expect(confirmResponse.statusCode).toBe(200);
expect(confirmResponse.json().task.taskStatus).toBe("Completed");
const reportResponse = await app.inject({
method: "GET",
url: `/api/tasks/${createdTask.taskId}/report`
});
expect(reportResponse.statusCode).toBe(200);
expect(reportResponse.json().report.quality_flags.blocked_platforms).toEqual(["tmall"]);
await app.close();
});
it("keeps the task confirmable when Tmall live search returns no result but JD succeeds", async () => {
const tmallLiveService = createTmallLiveServiceStub({
async previewSearch(query) {
return {
query,
source: "html",
candidateCount: 0,
candidates: []
};
}
});
const app = createServer({
jdLiveService: createJdLiveServiceStub(),
tmallLiveService
});
await app.ready();
await importDualLiveSessions(app);
const createdTask = await createTask(app, "iPhone 15");
expect(
createdTask.platformRuns.find((run: { platform: string }) => run.platform === "tmall")
).toMatchObject({
platform: "tmall",
status: "NoResult"
});
expect(
createdTask.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`
});
const candidates = candidatesResponse.json().candidates;
expect(candidates.tmall).toEqual([]);
expect(candidates.jd).toHaveLength(1);
const confirmResponse = await app.inject({
method: "POST",
url: `/api/tasks/${createdTask.taskId}/confirm`,
payload: {
selections: [
{
platform: "jd",
candidateIds: [candidates.jd[0].candidateId]
}
]
}
});
expect(confirmResponse.statusCode).toBe(200);
expect(confirmResponse.json().task.taskStatus).toBe("Completed");
await app.close();
});
it("publishes a partial report when Tmall crawl is blocked after both platforms are selected", async () => {
const tmallLiveService = createTmallLiveServiceStub({
async previewProduct() {
const error = new Error("Tmall detail session appears invalid.") as Error & {
statusCode: number;
};
error.statusCode = 409;
throw error;
}
});
const app = createServer({
jdLiveService: createJdLiveServiceStub(),
tmallLiveService
});
await app.ready();
await importDualLiveSessions(app);
const createdTask = await createTask(app, "iPhone 15");
const candidatesResponse = await app.inject({
method: "GET",
url: `/api/tasks/${createdTask.taskId}/candidates`
});
const candidates = candidatesResponse.json().candidates;
const confirmResponse = await app.inject({
method: "POST",
url: `/api/tasks/${createdTask.taskId}/confirm`,
payload: {
selections: [
{
platform: "jd",
candidateIds: [candidates.jd[0].candidateId]
},
{
platform: "tmall",
candidateIds: [candidates.tmall[0].candidateId]
}
]
}
});
expect(confirmResponse.statusCode).toBe(200);
expect(confirmResponse.json().task.taskStatus).toBe("PartialCompleted");
expect(confirmResponse.json().task.platformRuns).toEqual(
expect.arrayContaining([
expect.objectContaining({ platform: "jd", status: "Completed" }),
expect.objectContaining({ platform: "tmall", status: "Blocked" })
])
);
const reportResponse = await app.inject({
method: "GET",
url: `/api/tasks/${createdTask.taskId}/report`
});
expect(reportResponse.statusCode).toBe(200);
expect(reportResponse.json().report.task_status).toBe("PartialCompleted");
expect(reportResponse.json().report.quality_flags.blocked_platforms).toEqual(["tmall"]);
expect(reportResponse.json().report.evidence_index).toEqual(
expect.not.arrayContaining([
expect.objectContaining({
platform: "tmall",
source_type: "review"
})
])
);
expect(reportResponse.json().report.evidence_index).toEqual(
expect.arrayContaining([
expect.objectContaining({
platform: "tmall",
source_type: "product",
source_url: "https://detail.tmall.com/item.htm?id=934454505228"
}),
expect.objectContaining({
platform: "jd",
source_type: "product",
source_url: "https://item.jd.com/100068388533.html"
})
])
);
await app.close();
});
it("publishes a new report version when a blocked Tmall platform is retried successfully", async () => {
let allowTmallProduct = false;
const tmallLiveService = createTmallLiveServiceStub();
const originalPreviewProduct = tmallLiveService.previewProduct.bind(tmallLiveService);
tmallLiveService.previewProduct = async (itemId, options) => {
if (!allowTmallProduct) {
const error = new Error("Tmall detail session appears invalid.") as Error & {
statusCode: number;
};
error.statusCode = 409;
throw error;
}
return originalPreviewProduct(itemId, options);
};
const app = createServer({
jdLiveService: createJdLiveServiceStub(),
tmallLiveService
});
await app.ready();
await importDualLiveSessions(app);
const createdTask = await createTask(app, "iPhone 15");
const candidatesResponse = await app.inject({
method: "GET",
url: `/api/tasks/${createdTask.taskId}/candidates`
});
const candidates = candidatesResponse.json().candidates;
const confirmResponse = await app.inject({
method: "POST",
url: `/api/tasks/${createdTask.taskId}/confirm`,
payload: {
selections: [
{
platform: "jd",
candidateIds: [candidates.jd[0].candidateId]
},
{
platform: "tmall",
candidateIds: [candidates.tmall[0].candidateId]
}
]
}
});
expect(confirmResponse.statusCode).toBe(200);
expect(confirmResponse.json().task.taskStatus).toBe("PartialCompleted");
expect(confirmResponse.json().task.reportVersions).toEqual([1]);
allowTmallProduct = true;
const retryResponse = await app.inject({
method: "POST",
url: `/api/tasks/${createdTask.taskId}/platforms/tmall/retry`
});
expect(retryResponse.statusCode).toBe(200);
expect(retryResponse.json().task.taskStatus).toBe("Completed");
expect(retryResponse.json().task.reportVersions).toEqual([1, 2]);
expect(retryResponse.json().task.defaultReportVersion).toBe(2);
expect(retryResponse.json().task.platformRuns).toEqual(
expect.arrayContaining([
expect.objectContaining({ platform: "tmall", status: "Completed" })
])
);
const secondReportResponse = await app.inject({
method: "GET",
url: `/api/tasks/${createdTask.taskId}/report?version=2`
});
expect(secondReportResponse.statusCode).toBe(200);
expect(secondReportResponse.json().report.task_status).toBe("Completed");
expect(secondReportResponse.json().report.quality_flags.blocked_platforms).toEqual([]);
expect(secondReportResponse.json().report.evidence_index).toEqual(
expect.arrayContaining([
expect.objectContaining({
platform: "tmall",
source_type: "review",
review_ref: "tmall-review-1"
})
])
);
await app.close();
});
it("executes only the newly recovered Tmall platform after search recovery", async () => {
let allowTmallSearch = false;
let jdPreviewProductCalls = 0;
let tmallPreviewProductCalls = 0;
const jdLiveService = createJdLiveServiceStub();
const originalJdPreviewProduct = jdLiveService.previewProduct.bind(jdLiveService);
jdLiveService.previewProduct = async (skuId, options) => {
jdPreviewProductCalls += 1;
return originalJdPreviewProduct(skuId, options);
};
const tmallLiveService = createTmallLiveServiceStub();
const originalTmallPreviewSearch = tmallLiveService.previewSearch.bind(tmallLiveService);
const originalTmallPreviewProduct = tmallLiveService.previewProduct.bind(tmallLiveService);
tmallLiveService.previewSearch = async (query) => {
if (!allowTmallSearch) {
const error = new Error("Tmall search session appears invalid.") as Error & {
statusCode: number;
};
error.statusCode = 409;
throw error;
}
return originalTmallPreviewSearch(query);
};
tmallLiveService.previewProduct = async (itemId, options) => {
tmallPreviewProductCalls += 1;
return originalTmallPreviewProduct(itemId, options);
};
const app = createServer({
jdLiveService,
tmallLiveService
});
await app.ready();
await importDualLiveSessions(app);
const createdTask = await createTask(app, "iPhone 15");
expect(
createdTask.platformRuns.find((run: { platform: string }) => run.platform === "tmall")
).toMatchObject({
platform: "tmall",
status: "SearchBlocked"
});
const firstCandidatesResponse = await app.inject({
method: "GET",
url: `/api/tasks/${createdTask.taskId}/candidates`
});
const firstCandidates = firstCandidatesResponse.json().candidates;
const firstConfirmResponse = await app.inject({
method: "POST",
url: `/api/tasks/${createdTask.taskId}/confirm`,
payload: {
selections: [
{
platform: "jd",
candidateIds: [firstCandidates.jd[0].candidateId]
}
]
}
});
expect(firstConfirmResponse.statusCode).toBe(200);
expect(firstConfirmResponse.json().task.taskStatus).toBe("Completed");
expect(firstConfirmResponse.json().task.reportVersions).toEqual([1]);
expect(jdPreviewProductCalls).toBe(1);
expect(tmallPreviewProductCalls).toBe(0);
allowTmallSearch = true;
const retryResponse = await app.inject({
method: "POST",
url: `/api/tasks/${createdTask.taskId}/platforms/tmall/retry`
});
expect(retryResponse.statusCode).toBe(200);
expect(retryResponse.json().task.taskStatus).toBe("AwaitingConfirmation");
expect(
retryResponse
.json()
.task.platformRuns.find((run: { platform: string }) => run.platform === "tmall")
).toMatchObject({
platform: "tmall",
status: "AwaitingSelection"
});
const recoveredCandidatesResponse = await app.inject({
method: "GET",
url: `/api/tasks/${createdTask.taskId}/candidates`
});
const recoveredCandidates = recoveredCandidatesResponse.json().candidates;
expect(recoveredCandidates.tmall).toHaveLength(1);
const secondConfirmResponse = await app.inject({
method: "POST",
url: `/api/tasks/${createdTask.taskId}/confirm`,
payload: {
selections: [
{
platform: "tmall",
candidateIds: [recoveredCandidates.tmall[0].candidateId]
}
]
}
});
expect(secondConfirmResponse.statusCode).toBe(200);
expect(secondConfirmResponse.json().task.taskStatus).toBe("Completed");
expect(secondConfirmResponse.json().task.reportVersions).toEqual([1, 2]);
expect(secondConfirmResponse.json().task.defaultReportVersion).toBe(2);
expect(jdPreviewProductCalls).toBe(1);
expect(tmallPreviewProductCalls).toBe(1);
const secondReportResponse = await app.inject({
method: "GET",
url: `/api/tasks/${createdTask.taskId}/report?version=2`
});
expect(secondReportResponse.statusCode).toBe(200);
expect(secondReportResponse.json().report.task_status).toBe("Completed");
expect(secondReportResponse.json().report.evidence_index).toEqual(
expect.arrayContaining([
expect.objectContaining({
platform: "jd",
source_type: "product",
source_url: "https://item.jd.com/100068388533.html"
}),
expect.objectContaining({
platform: "tmall",
source_type: "review",
review_ref: "tmall-review-1"
})
])
);
await app.close();
});
it("keeps the current report version when a blocked Tmall retry does not recover", async () => {
const tmallLiveService = createTmallLiveServiceStub({
async previewProduct() {
const error = new Error("Tmall detail session appears invalid.") as Error & {
statusCode: number;
};
error.statusCode = 409;
throw error;
}
});
const app = createServer({
jdLiveService: createJdLiveServiceStub(),
tmallLiveService
});
await app.ready();
await importDualLiveSessions(app);
const createdTask = await createTask(app, "iPhone 15");
const candidatesResponse = await app.inject({
method: "GET",
url: `/api/tasks/${createdTask.taskId}/candidates`
});
const candidates = candidatesResponse.json().candidates;
const confirmResponse = await app.inject({
method: "POST",
url: `/api/tasks/${createdTask.taskId}/confirm`,
payload: {
selections: [
{
platform: "jd",
candidateIds: [candidates.jd[0].candidateId]
},
{
platform: "tmall",
candidateIds: [candidates.tmall[0].candidateId]
}
]
}
});
expect(confirmResponse.statusCode).toBe(200);
expect(confirmResponse.json().task.taskStatus).toBe("PartialCompleted");
expect(confirmResponse.json().task.reportVersions).toEqual([1]);
const retryResponse = await app.inject({
method: "POST",
url: `/api/tasks/${createdTask.taskId}/platforms/tmall/retry`
});
expect(retryResponse.statusCode).toBe(200);
expect(retryResponse.json().task.taskStatus).toBe("PartialCompleted");
expect(retryResponse.json().task.reportVersions).toEqual([1]);
expect(retryResponse.json().task.defaultReportVersion).toBe(1);
expect(retryResponse.json().task.platformRuns).toEqual(
expect.arrayContaining([
expect.objectContaining({ platform: "tmall", status: "Blocked" })
])
);
const reportResponse = await app.inject({
method: "GET",
url: `/api/tasks/${createdTask.taskId}/report`
});
expect(reportResponse.statusCode).toBe(200);
expect(reportResponse.json().report.task_status).toBe("PartialCompleted");
await app.close();
});
});

View File

@ -3,7 +3,9 @@ import { describe, expect, it } from "vitest";
import type { import type {
JdDetailPreviewResult, JdDetailPreviewResult,
JdLiveService, JdLiveService,
JdProductPreviewResult,
JdLiveSessionSummary, JdLiveSessionSummary,
JdReviewsPreviewOptions,
JdReviewsPreviewResult, JdReviewsPreviewResult,
JdSearchPreviewResult JdSearchPreviewResult
} from "./platforms/jd/types"; } from "./platforms/jd/types";
@ -107,14 +109,24 @@ function createJdLiveServiceStub(
return preview; return preview;
}, },
async previewReviews(skuId, commentCount) { async previewReviews(skuId, options) {
if (overrides.previewReviews) { if (overrides.previewReviews) {
return overrides.previewReviews(skuId, commentCount); return overrides.previewReviews(skuId, options);
} }
const preview: JdReviewsPreviewResult = { const preview: JdReviewsPreviewResult = {
skuId, skuId,
source: "api", source: "api",
pagination: {
requestedPage: typeof options === "object" ? (options?.page ?? 1) : 1,
requestedCommentCount:
typeof options === "number"
? options
: (options?.commentCount ?? 5),
maxPages: typeof options === "object" ? (options?.maxPages ?? 1) : 1,
pagesFetched: typeof options === "object" ? (options?.maxPages ?? 1) : 1,
pageKey: typeof options === "object" && options?.maxPages ? "page" : undefined
},
reviews: { reviews: {
skuId, skuId,
total: "10000", total: "10000",
@ -139,6 +151,23 @@ function createJdLiveServiceStub(
} }
}; };
return preview;
},
async previewProduct(skuId, options?: number | JdReviewsPreviewOptions) {
if (overrides.previewProduct) {
return overrides.previewProduct(skuId, options);
}
const detail = (await this.previewDetail(skuId)) as JdDetailPreviewResult;
const reviews = (await this.previewReviews(skuId, options)) as JdReviewsPreviewResult;
const preview: JdProductPreviewResult = {
skuId,
source: "api",
detail: detail.detail,
pagination: reviews.pagination,
reviews: reviews.reviews
};
return preview; return preview;
} }
}; };
@ -256,6 +285,21 @@ describe("JD live server endpoints", () => {
skuId: "100068388533", skuId: "100068388533",
goodRate: "95%" goodRate: "95%"
}); });
expect(reviewsResponse.json().preview.pagination).toMatchObject({
requestedCommentCount: 3
});
const productResponse = await app.inject({
method: "GET",
url: "/api/platforms/jd/live-product-preview?skuId=100068388533&commentCount=3&maxPages=2"
});
expect(productResponse.statusCode).toBe(200);
expect(productResponse.json().preview).toMatchObject({
skuId: "100068388533",
pagination: {
maxPages: 2
}
});
await app.close(); await app.close();
}); });

View File

@ -0,0 +1,341 @@
import { afterEach, describe, expect, it } from "vitest";
import { createServer } from "./server";
import type { JdLiveService, JdSessionManager, JdSessionManagerState } from "./platforms/jd/types";
function createSessionSummary(configured: boolean) {
return configured
? {
configured: true,
importedAt: "2026-04-03T08:30:00.000Z",
hasCookie: true,
userAgent: "stub-user-agent",
searchApiTemplate: {
available: true,
skuId: "100068388533"
},
detailTemplate: {
available: true,
skuId: "100068388533"
},
reviewsTemplate: {
available: true,
skuId: "100068388533"
}
}
: {
configured: false,
hasCookie: false,
searchApiTemplate: {
available: false
},
detailTemplate: {
available: false
},
reviewsTemplate: {
available: false
}
};
}
function createManagerState(
overrides: Partial<JdSessionManagerState> = {}
): JdSessionManagerState {
return {
status: "idle",
enabled: true,
autoLoginMode: "disabled",
commandConfigured: false,
accountConfigured: false,
passwordConfigured: false,
heartbeatQuery: "iPhone 15",
checkIntervalMs: 600000,
runnerTimeoutMs: 300000,
pendingManualAction: false,
note: "京东会话等待运维注入。",
publicNote: "京东会话由运维后台维护,当前尚未就绪。",
session: createSessionSummary(false),
...overrides
};
}
describe("JD ops session manager routes", () => {
const apps: Array<Awaited<ReturnType<typeof createServer>>> = [];
afterEach(async () => {
while (apps.length > 0) {
await apps.pop()!.close();
}
});
it("exposes ops endpoints and keeps readiness/live-session state in sync", async () => {
let state = createManagerState();
const manager: JdSessionManager = {
getState() {
return state;
},
configure(input) {
state = createManagerState({
...state,
enabled: input.enabled ?? state.enabled,
autoLoginMode: input.autoLoginMode ?? state.autoLoginMode,
commandConfigured: Boolean(input.loginCommand),
heartbeatQuery: input.heartbeatQuery ?? state.heartbeatQuery,
checkIntervalMs: input.checkIntervalMs ?? state.checkIntervalMs,
runnerTimeoutMs: input.runnerTimeoutMs ?? state.runnerTimeoutMs,
note: "京东运维配置已更新。"
});
return state;
},
clearConfig() {
state = createManagerState({
...state,
enabled: false,
autoLoginMode: "disabled",
commandConfigured: false,
note: "京东运维配置已清空。"
});
return state;
},
async importManualSession() {
state = createManagerState({
...state,
status: "healthy",
note: "京东会话已通过 ops-manual 更新。",
publicNote: "京东会话由运维后台维护,当前可用。",
session: createSessionSummary(true)
});
return state;
},
clearManagedSession() {
state = createManagerState({
...state,
status: "idle",
note: "京东会话已清理。",
publicNote: "京东会话由运维后台维护,当前尚未就绪。",
session: createSessionSummary(false)
});
return state;
},
async runHealthCheck() {
state = createManagerState({
...state,
status: "healthy",
note: "京东会话健康检查通过。",
publicNote: "京东会话由运维后台维护,当前可用。",
session: createSessionSummary(true)
});
return {
state,
recovered: false
};
},
async runAutoRecovery() {
state = createManagerState({
...state,
status: "healthy",
autoLoginMode: "command",
commandConfigured: true,
note: "京东自动恢复成功。",
publicNote: "京东会话由运维后台维护,当前可用。",
session: createSessionSummary(true)
});
return {
state,
recovered: true
};
},
async handleLiveFailure() {
return true;
},
shutdown() {}
};
const jdLiveService: JdLiveService = {
getSessionSummary() {
return state.session;
},
importSession() {
return state.session;
},
clearSession() {},
async previewSearch() {
return {
query: "iPhone 15",
source: "html",
candidateCount: 0,
candidates: []
};
},
async previewDetail(skuId) {
return {
skuId,
source: "api",
detail: {
skuId,
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"
}
};
},
async previewReviews(skuId) {
return {
skuId,
source: "api",
pagination: {
requestedPage: 1,
requestedCommentCount: 1,
maxPages: 1,
pagesFetched: 1
},
reviews: {
skuId,
total: "1000",
goodRate: "96%",
pictureCount: "120",
tags: [],
comments: []
}
};
},
async previewProduct(skuId) {
const detail = await this.previewDetail(skuId);
const reviews = await this.previewReviews(skuId);
return {
skuId,
source: "api",
detail: detail.detail,
pagination: reviews.pagination,
reviews: reviews.reviews
};
}
};
const app = createServer({
jdLiveService,
jdSessionManager: manager
});
apps.push(app);
await app.ready();
const getManagerResponse = await app.inject({
method: "GET",
url: "/api/ops/jd/session-manager"
});
expect(getManagerResponse.statusCode).toBe(200);
expect(getManagerResponse.json().manager).toMatchObject({
status: "idle",
publicNote: "京东会话由运维后台维护,当前尚未就绪。"
});
const readinessResponse = await app.inject({
method: "GET",
url: "/api/platforms/readiness"
});
expect(
readinessResponse
.json()
.platforms.find((platform: { platform: string }) => platform.platform === "jd")
).toMatchObject({
platform: "jd",
reason: "京东会话由运维后台维护,当前尚未就绪。"
});
const configResponse = await app.inject({
method: "POST",
url: "/api/ops/jd/session-manager/config",
payload: {
enabled: true,
autoLoginMode: "command",
loginCommand: "node scripts/jd-login-ops.mjs"
}
});
expect(configResponse.statusCode).toBe(200);
expect(configResponse.json().manager).toMatchObject({
autoLoginMode: "command",
commandConfigured: true
});
const healthCheckResponse = await app.inject({
method: "POST",
url: "/api/ops/jd/session-manager/check"
});
expect(healthCheckResponse.statusCode).toBe(200);
expect(healthCheckResponse.json()).toMatchObject({
recovered: false,
state: {
status: "healthy"
}
});
const recoverResponse = await app.inject({
method: "POST",
url: "/api/ops/jd/session-manager/recover"
});
expect(recoverResponse.statusCode).toBe(200);
expect(recoverResponse.json()).toMatchObject({
recovered: true,
state: {
status: "healthy",
autoLoginMode: "command"
}
});
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);
expect(importResponse.json().manager).toMatchObject({
status: "healthy",
session: {
configured: true
}
});
const liveSessionResponse = await app.inject({
method: "GET",
url: "/api/platforms/jd/live-session"
});
expect(liveSessionResponse.statusCode).toBe(200);
expect(liveSessionResponse.json().session).toMatchObject({
configured: true,
detailTemplate: {
available: true
}
});
const clearResponse = await app.inject({
method: "DELETE",
url: "/api/ops/jd/session-manager/session"
});
expect(clearResponse.statusCode).toBe(200);
expect(clearResponse.json().manager).toMatchObject({
status: "idle",
session: {
configured: false
}
});
});
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,395 @@
import { describe, expect, it } from "vitest";
import type {
TmallDetailPreviewResult,
TmallLiveService,
TmallLiveSessionSummary,
TmallProductPreviewResult,
TmallReviewsPreviewResult,
TmallSearchPreviewResult
} from "./platforms/tmall/types";
import { createServer } from "./server";
async function createTask(app: ReturnType<typeof createServer>, query: string) {
const response = await app.inject({
method: "POST",
url: "/api/tasks",
payload: {
query,
perLinkLimit: 100,
taskTotalLimit: 500
}
});
return response.json().task;
}
function createTmallLiveServiceStub(
overrides: Partial<TmallLiveService> = {}
): TmallLiveService {
let summary: TmallLiveSessionSummary = {
configured: false,
hasCookie: false,
detailTemplate: { available: false },
reviewsTemplate: { available: false }
};
return {
getSessionSummary() {
return overrides.getSessionSummary?.() ?? summary;
},
importSession(input) {
if (overrides.importSession) {
return overrides.importSession(input);
}
summary = {
configured: true,
importedAt: "2026-04-03T02:30:00.000Z",
hasCookie: true,
userAgent: input.userAgent ?? "stub-user-agent",
detailTemplate: { available: Boolean(input.detailTemplateUrl), itemId: "833444005595" },
reviewsTemplate: { available: Boolean(input.reviewsTemplateUrl), itemId: "833444005595" }
};
return summary;
},
clearSession() {
if (overrides.clearSession) {
overrides.clearSession();
return;
}
summary = {
configured: false,
hasCookie: false,
detailTemplate: { available: false },
reviewsTemplate: { available: false }
};
},
async previewSearch(query) {
if (overrides.previewSearch) {
return overrides.previewSearch(query);
}
const preview: TmallSearchPreviewResult = {
query,
source: "html",
candidateCount: 1,
candidates: [
{
candidateId: "tmall-833444005595",
platform: "tmall",
title: "Apple iPhone 15",
price: 4399,
priceLabel: "CNY 4399",
storeName: "Apple Flagship Store",
productUrl: "https://detail.tmall.com/item.htm?id=833444005595",
imageUrl: "https://img.alicdn.com/example.jpg",
salesHint: "sold 10k+",
specLabel: "128GB",
highlights: ["Tmall", "Fast shipping"]
}
]
};
return preview;
},
async previewDetail(itemId) {
if (overrides.previewDetail) {
return overrides.previewDetail(itemId);
}
const preview: TmallDetailPreviewResult = {
itemId,
source: "api",
detail: {
itemId,
title: "Apple iPhone 15",
subtitle: null,
price: "4399.00",
originalPrice: "4999.00",
shopName: "Apple Flagship Store",
shopUrl: null,
sellerType: "tmall",
categoryPath: ["Phones", "Mobile", "Apple"],
mainImage: "https://img.alicdn.com/example.jpg",
salesDesc: "sold 10k+",
commentCount: "20k+"
}
};
return preview;
},
async previewReviews(itemId, options) {
if (overrides.previewReviews) {
return overrides.previewReviews(itemId, options);
}
const commentCount =
typeof options === "number" ? options : (options?.commentCount ?? 20);
const page = typeof options === "number" ? 1 : (options?.page ?? 1);
const maxPages = typeof options === "number" ? 1 : (options?.maxPages ?? 1);
const preview: TmallReviewsPreviewResult = {
itemId,
source: "api",
pagination: {
requestedPage: page,
requestedCommentCount: commentCount,
maxPages,
pagesFetched: 1,
pageKey: "pageNum"
},
reviews: {
itemId,
total: "20k+",
hasNext: false,
allCount: "20k+",
pictureCount: "5000+",
appendCount: "1200+",
tags: [
{
name: "good looking",
count: "5313"
}
],
comments: [
{
id: "review-1",
content: "Smooth and well built.",
date: "2026-04-03",
userNick: "Alice",
userAvatar: null,
skuText: ["Black", "128GB"],
pictureUrls: [],
videoUrls: [],
likeCount: "3",
reply: null,
appendContent: null,
appendPictureUrls: []
}
]
}
};
return preview;
},
async previewProduct(itemId, options) {
if (overrides.previewProduct) {
return overrides.previewProduct(itemId, options);
}
const detail = await this.previewDetail(itemId);
const reviews = await this.previewReviews(itemId, options);
const preview: TmallProductPreviewResult = {
itemId,
source: "api",
detail: detail.detail,
pagination: reviews.pagination,
reviews: reviews.reviews
};
return preview;
}
};
}
describe("Tmall live session endpoints", () => {
it("imports a Tmall live session and updates readiness", async () => {
const tmallLiveService = createTmallLiveServiceStub();
const app = createServer({ tmallLiveService });
await app.ready();
const response = await app.inject({
method: "POST",
url: "/api/platforms/tmall/live-session",
payload: {
cookieHeader: "_tb_token_=masked;",
detailTemplateUrl:
"https://h5api.m.taobao.com/h5/mtop.taobao.pcdetail.data.get/1.0/?data=%7B%22id%22%3A%22833444005595%22%7D",
reviewsTemplateUrl:
"https://h5api.m.taobao.com/h5/mtop.alibaba.review.list.for.new.pc.detail/1.0/?data=%7B%22itemId%22%3A%22833444005595%22%7D"
}
});
expect(response.statusCode).toBe(200);
expect(response.json().session).toMatchObject({
configured: true,
hasCookie: true,
detailTemplate: {
available: true
},
reviewsTemplate: {
available: true
}
});
const readinessResponse = await app.inject({
method: "GET",
url: "/api/platforms/readiness"
});
expect(
readinessResponse
.json()
.platforms.find((platform: { platform: string }) => platform.platform === "tmall")
).toMatchObject({
platform: "tmall",
ready: true,
status: "ready"
});
await app.close();
});
it("exposes Tmall live preview endpoints through the injected service", async () => {
const tmallLiveService = createTmallLiveServiceStub();
const app = createServer({ tmallLiveService });
await app.ready();
const searchResponse = await app.inject({
method: "GET",
url: "/api/platforms/tmall/live-search-preview?query=iPhone%2015"
});
expect(searchResponse.statusCode).toBe(200);
expect(searchResponse.json().preview).toMatchObject({
query: "iPhone 15",
source: "html",
candidateCount: 1
});
const detailResponse = await app.inject({
method: "GET",
url: "/api/platforms/tmall/live-detail-preview?itemId=833444005595"
});
expect(detailResponse.statusCode).toBe(200);
expect(detailResponse.json().preview.detail).toMatchObject({
itemId: "833444005595",
shopName: "Apple Flagship Store"
});
const reviewsResponse = await app.inject({
method: "GET",
url: "/api/platforms/tmall/live-reviews-preview?itemId=833444005595&commentCount=20&page=1&maxPages=2"
});
expect(reviewsResponse.statusCode).toBe(200);
expect(reviewsResponse.json().preview.pagination).toMatchObject({
requestedCommentCount: 20,
maxPages: 2
});
const productResponse = await app.inject({
method: "GET",
url: "/api/platforms/tmall/live-product-preview?itemId=833444005595&commentCount=20&page=1&maxPages=2"
});
expect(productResponse.statusCode).toBe(200);
expect(productResponse.json().preview).toMatchObject({
itemId: "833444005595",
pagination: {
requestedCommentCount: 20,
maxPages: 2
}
});
await app.close();
});
it("closes the tmall minimal loop with live detail/reviews replay and report publishing", async () => {
const tmallLiveService = createTmallLiveServiceStub();
const app = createServer({ tmallLiveService });
await app.ready();
const importResponse = await app.inject({
method: "POST",
url: "/api/platforms/tmall/live-session",
payload: {
cookieHeader: "_tb_token_=masked;",
detailTemplateUrl:
"https://h5api.m.taobao.com/h5/mtop.taobao.pcdetail.data.get/1.0/?data=%7B%22id%22%3A%22833444005595%22%7D",
reviewsTemplateUrl:
"https://h5api.m.taobao.com/h5/mtop.alibaba.review.list.for.new.pc.detail/1.0/?data=%7B%22itemId%22%3A%22833444005595%22%7D"
}
});
expect(importResponse.statusCode).toBe(200);
const createdTask = await createTask(app, "iPhone 15");
const candidatesResponse = await app.inject({
method: "GET",
url: `/api/tasks/${createdTask.taskId}/candidates`
});
const firstTmallCandidate = candidatesResponse.json().candidates.tmall[0];
expect(firstTmallCandidate).toMatchObject({
productUrl: "https://detail.tmall.com/item.htm?id=833444005595"
});
const confirmResponse = await app.inject({
method: "POST",
url: `/api/tasks/${createdTask.taskId}/confirm`,
payload: {
selections: [
{
platform: "tmall",
candidateIds: [firstTmallCandidate.candidateId]
}
]
}
});
expect(confirmResponse.statusCode).toBe(200);
expect(confirmResponse.json().task).toMatchObject({
taskStatus: "Completed",
defaultReportVersion: 1
});
const reportResponse = await app.inject({
method: "GET",
url: `/api/tasks/${createdTask.taskId}/report`
});
expect(reportResponse.statusCode).toBe(200);
expect(reportResponse.json().report.summary.headline).toContain("天猫");
expect(reportResponse.json().report.product_snapshot.review_sample_count).toBe(1);
expect(reportResponse.json().report.evidence_index).toEqual(
expect.arrayContaining([
expect.objectContaining({
platform: "tmall",
source_type: "product",
source_url: "https://detail.tmall.com/item.htm?id=833444005595"
}),
expect.objectContaining({
platform: "tmall",
source_type: "review",
review_ref: "review-1"
})
])
);
await app.close();
});
it("surfaces Tmall live preview failures with service-provided status codes", async () => {
const tmallLiveService = createTmallLiveServiceStub({
async previewDetail() {
const error = new Error("Tmall detail session appears invalid.") as Error & {
statusCode: number;
};
error.statusCode = 409;
throw error;
}
});
const app = createServer({ tmallLiveService });
await app.ready();
const response = await app.inject({
method: "GET",
url: "/api/platforms/tmall/live-detail-preview?itemId=833444005595"
});
expect(response.statusCode).toBe(409);
expect(response.json()).toMatchObject({
message: "Tmall detail session appears invalid."
});
await app.close();
});
});

View File

@ -0,0 +1,310 @@
import { afterEach, describe, expect, it } from "vitest";
import { createServer } from "./server";
import type {
TmallLiveService,
TmallSessionManager,
TmallSessionManagerState
} from "./platforms/tmall/types";
function createSessionSummary(configured: boolean) {
return configured
? {
configured: true,
importedAt: "2026-04-03T08:30:00.000Z",
hasCookie: true,
userAgent: "stub-user-agent",
detailTemplate: {
available: true,
api: "mtop.taobao.pcdetail.data.get",
itemId: "934454505228"
},
reviewsTemplate: {
available: true,
api: "mtop.taobao.rate.detaillist.get",
itemId: "934454505228"
}
}
: {
configured: false,
hasCookie: false,
detailTemplate: {
available: false
},
reviewsTemplate: {
available: false
}
};
}
function createManagerState(
overrides: Partial<TmallSessionManagerState> = {}
): TmallSessionManagerState {
return {
status: "idle",
enabled: true,
heartbeatItemId: "934454505228",
checkIntervalMs: 600000,
pendingManualAction: false,
note: "天猫会话等待运维注入。",
publicNote: "天猫会话由运维后台维护,当前尚未就绪。",
session: createSessionSummary(false),
...overrides
};
}
describe("Tmall ops session manager routes", () => {
const apps: Array<Awaited<ReturnType<typeof createServer>>> = [];
afterEach(async () => {
while (apps.length > 0) {
await apps.pop()!.close();
}
});
it("exposes ops endpoints and keeps readiness/live-session state in sync", async () => {
let state = createManagerState();
const manager: TmallSessionManager = {
getState() {
return state;
},
configure(input) {
state = createManagerState({
...state,
enabled: input.enabled ?? state.enabled,
heartbeatItemId: input.heartbeatItemId ?? state.heartbeatItemId,
checkIntervalMs: input.checkIntervalMs ?? state.checkIntervalMs,
note: "天猫运维配置已更新。"
});
return state;
},
clearConfig() {
state = createManagerState({
...state,
enabled: false,
note: "天猫运维配置已清空。"
});
return state;
},
async importManualSession() {
state = createManagerState({
...state,
status: "healthy",
note: "天猫会话已通过 ops-manual 更新。",
publicNote: "天猫会话由运维后台维护,当前可用。",
session: createSessionSummary(true)
});
return state;
},
clearManagedSession() {
state = createManagerState({
...state,
status: "idle",
note: "天猫会话已清理。",
publicNote: "天猫会话由运维后台维护,当前尚未就绪。",
session: createSessionSummary(false)
});
return state;
},
async runHealthCheck() {
state = createManagerState({
...state,
status: "healthy",
note: "天猫会话健康检查通过。",
publicNote: "天猫会话由运维后台维护,当前可用。",
session: createSessionSummary(true)
});
return {
state,
recovered: false
};
},
async handleLiveFailure() {
return false;
},
shutdown() {}
};
const tmallLiveService: TmallLiveService = {
getSessionSummary() {
return state.session;
},
importSession() {
return state.session;
},
clearSession() {},
async previewSearch(query) {
return {
query,
source: "html",
candidateCount: 1,
candidates: [
{
candidateId: "tmall-934454505228",
platform: "tmall",
title: "Apple iPhone 15",
price: 4399,
priceLabel: "CNY 4399",
storeName: "Apple 官方旗舰店",
productUrl: "https://detail.tmall.com/item.htm?id=934454505228",
imageUrl: "https://img.alicdn.com/example.jpg",
salesHint: "已售 70万+",
specLabel: "128GB",
highlights: ["天猫"]
}
]
};
},
async previewDetail(itemId) {
return {
itemId,
source: "html",
detail: {
itemId,
title: "Apple iPhone 15",
subtitle: null,
price: "4399.00",
originalPrice: "4999.00",
shopName: "Apple 官方旗舰店",
shopUrl: null,
sellerType: "tmall",
categoryPath: [],
mainImage: null,
salesDesc: "已售 70万+",
commentCount: "20万+"
}
};
},
async previewReviews(itemId) {
return {
itemId,
source: "api",
pagination: {
requestedPage: 1,
requestedCommentCount: 1,
maxPages: 1,
pagesFetched: 1
},
reviews: {
itemId,
total: "20万+",
hasNext: false,
allCount: "20万+",
pictureCount: "1万+",
appendCount: "5000+",
tags: [],
comments: []
}
};
},
async previewProduct(itemId, options) {
const detail = await this.previewDetail(itemId);
const reviews = await this.previewReviews(itemId, options);
return {
itemId,
source: "hybrid",
detail: detail.detail,
pagination: reviews.pagination,
reviews: reviews.reviews
};
}
};
const app = createServer({
tmallLiveService,
tmallSessionManager: manager
});
apps.push(app);
await app.ready();
const getManagerResponse = await app.inject({
method: "GET",
url: "/api/ops/tmall/session-manager"
});
expect(getManagerResponse.statusCode).toBe(200);
expect(getManagerResponse.json().manager).toMatchObject({
status: "idle",
publicNote: "天猫会话由运维后台维护,当前尚未就绪。"
});
const readinessResponse = await app.inject({
method: "GET",
url: "/api/platforms/readiness"
});
expect(
readinessResponse
.json()
.platforms.find((platform: { platform: string }) => platform.platform === "tmall")
).toMatchObject({
platform: "tmall",
reason: "天猫会话由运维后台维护,当前尚未就绪。"
});
const configResponse = await app.inject({
method: "POST",
url: "/api/ops/tmall/session-manager/config",
payload: {
enabled: true,
heartbeatItemId: "934454505228"
}
});
expect(configResponse.statusCode).toBe(200);
expect(configResponse.json().manager).toMatchObject({
heartbeatItemId: "934454505228"
});
const healthCheckResponse = await app.inject({
method: "POST",
url: "/api/ops/tmall/session-manager/check"
});
expect(healthCheckResponse.statusCode).toBe(200);
expect(healthCheckResponse.json()).toMatchObject({
recovered: false,
state: {
status: "healthy"
}
});
const importResponse = await app.inject({
method: "POST",
url: "/api/ops/tmall/session-manager/session",
payload: {
cookieHeader: "_m_h5_tk=masked_token_123;",
detailTemplateUrl: "https://detail.tmall.com/item.htm?id=934454505228",
reviewsTemplateUrl:
"https://h5api.m.tmall.com/h5/mtop.taobao.rate.detaillist.get/6.0/?data=%7B%22auctionNumId%22%3A%22934454505228%22%7D"
}
});
expect(importResponse.statusCode).toBe(200);
expect(importResponse.json().manager).toMatchObject({
status: "healthy",
session: {
configured: true
}
});
const liveSessionResponse = await app.inject({
method: "GET",
url: "/api/platforms/tmall/live-session"
});
expect(liveSessionResponse.statusCode).toBe(200);
expect(liveSessionResponse.json().session).toMatchObject({
configured: true,
detailTemplate: {
available: true
}
});
const clearResponse = await app.inject({
method: "DELETE",
url: "/api/ops/tmall/session-manager/session"
});
expect(clearResponse.statusCode).toBe(200);
expect(clearResponse.json().manager).toMatchObject({
status: "idle",
session: {
configured: false
}
});
});
});

View File

@ -5,15 +5,79 @@ import type {
} from "@cross-ai/domain"; } from "@cross-ai/domain";
import cors from "@fastify/cors"; import cors from "@fastify/cors";
import Fastify from "fastify"; import Fastify from "fastify";
import { resolve } from "node:path";
import { rankJdCandidatesForKeyword } from "./platforms/jd/keyword-preview";
import { JdLiveSessionService, isJdLiveError } from "./platforms/jd/live-session"; import { JdLiveSessionService, isJdLiveError } from "./platforms/jd/live-session";
import type { JdLiveService, JdSearchMode } from "./platforms/jd/types"; import { JdSessionManagerService } from "./platforms/jd/session-manager";
import type {
JdLiveService,
JdSearchMode,
JdSessionManager,
JdSessionManagerAutoMode
} from "./platforms/jd/types";
import { TmallLiveSessionService, isTmallLiveError } from "./platforms/tmall/live-session";
import { TmallSessionManagerService } from "./platforms/tmall/session-manager";
import type {
TmallLiveService,
TmallSessionManager,
TmallSessionManagerConfigInput
} from "./platforms/tmall/types";
import { InMemoryTaskStore } from "./store"; import { InMemoryTaskStore } from "./store";
export function createServer(options: { jdLiveService?: JdLiveService } = {}) { export function createServer(
options: {
storagePath?: string;
jdLiveService?: JdLiveService;
jdSessionManager?: JdSessionManager;
tmallLiveService?: TmallLiveService;
tmallSessionManager?: TmallSessionManager;
} = {}
) {
const app = Fastify({ logger: false }); const app = Fastify({ logger: false });
const store = new InMemoryTaskStore();
const jdLiveService = options.jdLiveService ?? new JdLiveSessionService(); const jdLiveService = options.jdLiveService ?? new JdLiveSessionService();
const tmallLiveService = options.tmallLiveService ?? new TmallLiveSessionService();
const storagePath =
options.storagePath ??
process.env.TASK_STORE_PATH ??
(process.env.NODE_ENV === "test"
? undefined
: resolve(process.cwd(), ".data", "task-store.json"));
const store = new InMemoryTaskStore({ storagePath, jdLiveService, tmallLiveService });
const jdSessionManager =
options.jdSessionManager ??
new JdSessionManagerService(jdLiveService, {
onSessionReady: () => {
store.preparePlatform("jd");
},
onSessionUnavailable: () => {
store.clearPlatformSession("jd");
}
});
const tmallSessionManager =
options.tmallSessionManager ??
new TmallSessionManagerService(tmallLiveService, {
onSessionReady: () => {
store.preparePlatform("tmall");
},
onSessionUnavailable: () => {
store.clearPlatformSession("tmall");
}
});
store.setJdSessionManager(jdSessionManager);
store.setTmallSessionManager(tmallSessionManager);
if (jdLiveService.getSessionSummary().configured && !store.getSession("jd").ready) {
store.preparePlatform("jd");
}
if (tmallLiveService.getSessionSummary().configured && !store.getSession("tmall").ready) {
store.preparePlatform("tmall");
}
app.addHook("onClose", async () => {
jdSessionManager.shutdown();
tmallSessionManager.shutdown();
});
app.register(cors, { origin: true }); app.register(cors, { origin: true });
@ -23,7 +87,19 @@ export function createServer(options: { jdLiveService?: JdLiveService } = {}) {
})); }));
app.get("/api/platforms/readiness", async () => ({ app.get("/api/platforms/readiness", async () => ({
platforms: store.getPlatformReadiness() platforms: store.getPlatformReadiness().map((platform) =>
platform.platform === "jd"
? {
...platform,
reason: jdSessionManager.getState().publicNote
}
: platform.platform === "tmall"
? {
...platform,
reason: tmallSessionManager.getState().publicNote
}
: platform
)
})); }));
app.get("/api/sessions", async () => ({ app.get("/api/sessions", async () => ({
@ -61,6 +137,163 @@ export function createServer(options: { jdLiveService?: JdLiveService } = {}) {
session: jdLiveService.getSessionSummary() session: jdLiveService.getSessionSummary()
})); }));
app.get("/api/ops/jd/session-manager", async () => ({
manager: jdSessionManager.getState()
}));
app.post<{
Body: {
enabled?: boolean;
autoLoginMode?: JdSessionManagerAutoMode;
loginCommand?: string | null;
browserProfilePath?: string | null;
heartbeatQuery?: string | null;
account?: string | null;
password?: string | null;
checkIntervalMs?: number | null;
runnerTimeoutMs?: number | null;
};
}>("/api/ops/jd/session-manager/config", async (request, reply) => {
try {
const manager = jdSessionManager.configure(request.body);
reply.code(200);
return { manager };
} catch (error) {
reply.code(400);
return {
message:
error instanceof Error ? error.message : "Invalid JD ops session manager config."
};
}
});
app.delete("/api/ops/jd/session-manager/config", async () => ({
manager: jdSessionManager.clearConfig()
}));
app.post("/api/ops/jd/session-manager/check", async (_request, reply) => {
try {
const result = await jdSessionManager.runHealthCheck("ops");
reply.code(200);
return result;
} catch (error) {
reply.code(502);
return {
message:
error instanceof Error ? error.message : "JD ops health check failed."
};
}
});
app.post("/api/ops/jd/session-manager/recover", async (_request, reply) => {
try {
const result = await jdSessionManager.runAutoRecovery("ops");
reply.code(200);
return result;
} catch (error) {
reply.code(502);
return {
message:
error instanceof Error ? error.message : "JD ops auto recovery failed."
};
}
});
app.post<{
Body: {
cookieHeader: string;
userAgent?: string;
searchApiTemplateUrl?: string;
detailTemplateUrl?: string;
reviewsTemplateUrl?: string;
searchReferer?: string;
detailReferer?: string;
};
}>("/api/ops/jd/session-manager/session", async (request, reply) => {
try {
const manager = await jdSessionManager.importManualSession(request.body, "ops-manual");
reply.code(200);
return { manager };
} catch (error) {
reply.code(isJdLiveError(error) ? error.statusCode : 400);
return {
message: error instanceof Error ? error.message : "Invalid JD ops live session payload."
};
}
});
app.delete("/api/ops/jd/session-manager/session", async () => ({
manager: jdSessionManager.clearManagedSession("ops-manual-clear")
}));
app.get("/api/ops/tmall/session-manager", async () => ({
manager: tmallSessionManager.getState()
}));
app.post<{
Body: TmallSessionManagerConfigInput;
}>("/api/ops/tmall/session-manager/config", async (request, reply) => {
try {
const manager = tmallSessionManager.configure(request.body);
reply.code(200);
return { manager };
} catch (error) {
reply.code(400);
return {
message:
error instanceof Error ? error.message : "Invalid Tmall ops session manager config."
};
}
});
app.delete("/api/ops/tmall/session-manager/config", async () => ({
manager: tmallSessionManager.clearConfig()
}));
app.post("/api/ops/tmall/session-manager/check", async (_request, reply) => {
try {
const result = await tmallSessionManager.runHealthCheck("ops");
reply.code(200);
return result;
} catch (error) {
reply.code(502);
return {
message:
error instanceof Error ? error.message : "Tmall ops health check failed."
};
}
});
app.post<{
Body: {
cookieHeader: string;
userAgent?: string;
detailTemplateUrl?: string;
reviewsTemplateUrl?: string;
detailReferer?: string;
};
}>("/api/ops/tmall/session-manager/session", async (request, reply) => {
try {
const manager = await tmallSessionManager.importManualSession(request.body, "ops-manual");
reply.code(200);
return { manager };
} catch (error) {
reply.code(isTmallLiveError(error) ? error.statusCode : 400);
return {
message:
error instanceof Error ? error.message : "Invalid Tmall ops live session payload."
};
}
});
app.delete("/api/ops/tmall/session-manager/session", async () => ({
manager: tmallSessionManager.clearManagedSession("ops-manual-clear")
}));
app.get("/api/platforms/tmall/live-session", async () => ({
session: tmallLiveService.getSessionSummary()
}));
app.post<{ app.post<{
Body: { Body: {
cookieHeader: string; cookieHeader: string;
@ -73,10 +306,9 @@ export function createServer(options: { jdLiveService?: JdLiveService } = {}) {
}; };
}>("/api/platforms/jd/live-session", async (request, reply) => { }>("/api/platforms/jd/live-session", async (request, reply) => {
try { try {
const session = jdLiveService.importSession(request.body); await jdSessionManager.importManualSession(request.body, "legacy-live-session");
store.preparePlatform("jd");
reply.code(200); reply.code(200);
return { session }; return { session: jdLiveService.getSessionSummary() };
} catch (error) { } catch (error) {
reply.code(isJdLiveError(error) ? error.statusCode : 400); reply.code(isJdLiveError(error) ? error.statusCode : 400);
return { return {
@ -85,9 +317,36 @@ export function createServer(options: { jdLiveService?: JdLiveService } = {}) {
} }
}); });
app.post<{
Body: {
cookieHeader: string;
userAgent?: string;
detailTemplateUrl?: string;
reviewsTemplateUrl?: string;
detailReferer?: string;
};
}>("/api/platforms/tmall/live-session", async (request, reply) => {
try {
await tmallSessionManager.importManualSession(request.body, "legacy-live-session");
const session = tmallLiveService.getSessionSummary();
reply.code(200);
return { session };
} catch (error) {
reply.code(isTmallLiveError(error) ? error.statusCode : 400);
return {
message: error instanceof Error ? error.message : "Invalid Tmall live session payload."
};
}
});
app.delete("/api/platforms/jd/live-session", async (_request, reply) => { app.delete("/api/platforms/jd/live-session", async (_request, reply) => {
jdLiveService.clearSession(); jdSessionManager.clearManagedSession("legacy-live-session-clear");
store.clearPlatformSession("jd"); reply.code(204);
return null;
});
app.delete("/api/platforms/tmall/live-session", async (_request, reply) => {
tmallSessionManager.clearManagedSession("legacy-live-session-clear");
reply.code(204); reply.code(204);
return null; return null;
}); });
@ -100,6 +359,9 @@ export function createServer(options: { jdLiveService?: JdLiveService } = {}) {
if (request.params.platform === "jd") { if (request.params.platform === "jd") {
jdLiveService.clearSession(); jdLiveService.clearSession();
} }
if (request.params.platform === "tmall") {
tmallLiveService.clearSession();
}
reply.code(204); reply.code(204);
return null; return null;
} catch { } catch {
@ -111,7 +373,7 @@ export function createServer(options: { jdLiveService?: JdLiveService } = {}) {
app.post<{ app.post<{
Body: CreateTaskInput; Body: CreateTaskInput;
}>("/api/tasks", async (request, reply) => { }>("/api/tasks", async (request, reply) => {
const task = store.createTask(request.body); const task = await store.createTask(request.body);
reply.code(201); reply.code(201);
return { task }; return { task };
}); });
@ -180,7 +442,7 @@ export function createServer(options: { jdLiveService?: JdLiveService } = {}) {
Body: ConfirmTaskPayload; Body: ConfirmTaskPayload;
}>("/api/tasks/:taskId/confirm", async (request, reply) => { }>("/api/tasks/:taskId/confirm", async (request, reply) => {
try { try {
const task = store.confirmTask(request.params.taskId, request.body); const task = await store.confirmTask(request.params.taskId, request.body);
return { task }; return { task };
} catch { } catch {
reply.code(404); reply.code(404);
@ -192,7 +454,7 @@ export function createServer(options: { jdLiveService?: JdLiveService } = {}) {
Params: { taskId: string; platform: PlatformId }; Params: { taskId: string; platform: PlatformId };
}>("/api/tasks/:taskId/platforms/:platform/retry", async (request, reply) => { }>("/api/tasks/:taskId/platforms/:platform/retry", async (request, reply) => {
try { try {
const task = store.retryPlatform(request.params.taskId, request.params.platform); const task = await store.retryPlatform(request.params.taskId, request.params.platform);
return { task }; return { task };
} catch { } catch {
reply.code(404); reply.code(404);
@ -256,7 +518,71 @@ export function createServer(options: { jdLiveService?: JdLiveService } = {}) {
}); });
app.get<{ app.get<{
Querystring: { skuId?: string; commentCount?: string }; Querystring: {
query?: string;
commentCount?: string;
maxPages?: string;
mode?: JdSearchMode;
};
}>("/api/platforms/jd/live-keyword-preview", async (request, reply) => {
try {
const query = request.query.query?.trim();
if (!query) {
reply.code(400);
return { message: "query is required." };
}
const commentCount = request.query.commentCount
? Number.parseInt(request.query.commentCount, 10)
: undefined;
const maxPages = request.query.maxPages
? Number.parseInt(request.query.maxPages, 10)
: undefined;
const searchPreview = await jdLiveService.previewSearch(query, request.query.mode);
const rankedCandidates = rankJdCandidatesForKeyword(query, searchPreview.candidates);
const selectedCandidate = rankedCandidates[0];
if (!selectedCandidate) {
reply.code(404);
return {
message:
"JD live search returned no valid item candidates for this keyword. Capture a fresher search session or refine the query."
};
}
const preview = await jdLiveService.previewProduct(selectedCandidate.skuId, {
commentCount,
maxPages: Number.isNaN(maxPages ?? Number.NaN) ? undefined : maxPages
});
return {
preview: {
query,
search: {
source: searchPreview.source,
candidateCount: searchPreview.candidateCount,
selected: selectedCandidate,
alternatives: rankedCandidates.slice(1, 4)
},
product: preview
}
};
} catch (error) {
reply.code(isJdLiveError(error) ? error.statusCode : 502);
return {
message:
error instanceof Error ? error.message : "JD live keyword preview failed."
};
}
});
app.get<{
Querystring: {
skuId?: string;
commentCount?: string;
page?: string;
maxPages?: string;
};
}>("/api/platforms/jd/live-reviews-preview", async (request, reply) => { }>("/api/platforms/jd/live-reviews-preview", async (request, reply) => {
try { try {
const skuId = request.query.skuId?.trim(); const skuId = request.query.skuId?.trim();
@ -268,7 +594,15 @@ export function createServer(options: { jdLiveService?: JdLiveService } = {}) {
const commentCount = request.query.commentCount const commentCount = request.query.commentCount
? Number.parseInt(request.query.commentCount, 10) ? Number.parseInt(request.query.commentCount, 10)
: undefined; : undefined;
const preview = await jdLiveService.previewReviews(skuId, commentCount); const page = request.query.page ? Number.parseInt(request.query.page, 10) : undefined;
const maxPages = request.query.maxPages
? Number.parseInt(request.query.maxPages, 10)
: undefined;
const preview = await jdLiveService.previewReviews(skuId, {
commentCount,
page: Number.isNaN(page ?? Number.NaN) ? undefined : page,
maxPages: Number.isNaN(maxPages ?? Number.NaN) ? undefined : maxPages
});
return { preview }; return { preview };
} catch (error) { } catch (error) {
reply.code(isJdLiveError(error) ? error.statusCode : 502); reply.code(isJdLiveError(error) ? error.statusCode : 502);
@ -279,6 +613,182 @@ export function createServer(options: { jdLiveService?: JdLiveService } = {}) {
} }
}); });
app.get<{
Querystring: {
skuId?: string;
commentCount?: string;
page?: string;
maxPages?: string;
};
}>("/api/platforms/jd/live-product-preview", async (request, reply) => {
try {
const skuId = request.query.skuId?.trim();
if (!skuId) {
reply.code(400);
return { message: "skuId is required." };
}
const commentCount = request.query.commentCount
? Number.parseInt(request.query.commentCount, 10)
: undefined;
const page = request.query.page ? Number.parseInt(request.query.page, 10) : undefined;
const maxPages = request.query.maxPages
? Number.parseInt(request.query.maxPages, 10)
: undefined;
const preview = await jdLiveService.previewProduct(skuId, {
commentCount,
page: Number.isNaN(page ?? Number.NaN) ? undefined : page,
maxPages: Number.isNaN(maxPages ?? Number.NaN) ? undefined : maxPages
});
return { preview };
} catch (error) {
reply.code(isJdLiveError(error) ? error.statusCode : 502);
return {
message:
error instanceof Error ? error.message : "JD live product preview failed."
};
}
});
app.get<{
Querystring: { itemId?: string };
}>("/api/platforms/tmall/live-detail-preview", async (request, reply) => {
try {
const itemId = request.query.itemId?.trim();
if (!itemId) {
reply.code(400);
return { message: "itemId is required." };
}
const preview = await tmallLiveService.previewDetail(itemId);
return { preview };
} catch (error) {
reply.code(isTmallLiveError(error) ? error.statusCode : 502);
return {
message:
error instanceof Error ? error.message : "Tmall live detail preview failed."
};
}
});
app.get<{
Querystring: { query?: string };
}>("/api/platforms/tmall/live-search-preview", async (request, reply) => {
try {
const query = request.query.query?.trim();
if (!query) {
reply.code(400);
return { message: "query is required." };
}
const preview = await tmallLiveService.previewSearch(query);
return { preview };
} catch (error) {
reply.code(isTmallLiveError(error) ? error.statusCode : 502);
return {
message:
error instanceof Error ? error.message : "Tmall live search preview failed."
};
}
});
app.get<{
Querystring: {
itemId?: string;
commentCount?: string;
page?: string;
maxPages?: string;
};
}>("/api/platforms/tmall/live-reviews-preview", async (request, reply) => {
try {
const itemId = request.query.itemId?.trim();
if (!itemId) {
reply.code(400);
return { message: "itemId is required." };
}
const commentCount = request.query.commentCount
? Number.parseInt(request.query.commentCount, 10)
: undefined;
const page = request.query.page ? Number.parseInt(request.query.page, 10) : undefined;
const maxPages = request.query.maxPages
? Number.parseInt(request.query.maxPages, 10)
: undefined;
const preview = await tmallLiveService.previewReviews(itemId, {
commentCount,
page: Number.isNaN(page ?? Number.NaN) ? undefined : page,
maxPages: Number.isNaN(maxPages ?? Number.NaN) ? undefined : maxPages
});
return { preview };
} catch (error) {
reply.code(isTmallLiveError(error) ? error.statusCode : 502);
return {
message:
error instanceof Error ? error.message : "Tmall live reviews preview failed."
};
}
});
app.get<{
Querystring: {
itemId?: string;
commentCount?: string;
page?: string;
maxPages?: string;
};
}>("/api/platforms/tmall/live-product-preview", async (request, reply) => {
try {
const itemId = request.query.itemId?.trim();
if (!itemId) {
reply.code(400);
return { message: "itemId is required." };
}
const commentCount = request.query.commentCount
? Number.parseInt(request.query.commentCount, 10)
: undefined;
const page = request.query.page ? Number.parseInt(request.query.page, 10) : undefined;
const maxPages = request.query.maxPages
? Number.parseInt(request.query.maxPages, 10)
: undefined;
const preview = await tmallLiveService.previewProduct(itemId, {
commentCount,
page: Number.isNaN(page ?? Number.NaN) ? undefined : page,
maxPages: Number.isNaN(maxPages ?? Number.NaN) ? undefined : maxPages
});
return { preview };
} catch (error) {
reply.code(isTmallLiveError(error) ? error.statusCode : 502);
return {
message:
error instanceof Error ? error.message : "Tmall live product preview failed."
};
}
});
app.post<{
Body?: {
dryRun?: boolean;
asOf?: string;
rawRetentionDays?: number;
reportRetentionDays?: number;
};
}>("/api/retention/cleanup", async (request, reply) => {
try {
const cleanup = store.runRetentionCleanup(request.body ?? {});
return { cleanup };
} catch (error) {
reply.code(400);
return {
message:
error instanceof Error ? error.message : "Invalid retention cleanup request."
};
}
});
app.get("/api/history", async () => ({ app.get("/api/history", async () => ({
tasks: store.listHistory() tasks: store.listHistory()
})); }));

File diff suppressed because it is too large Load Diff