feat(api): 打通双平台 live 抓取与运维会话主链
This commit is contained in:
parent
1cf99688d8
commit
e506e7d9c2
@ -19,6 +19,7 @@ export function createMockCandidates(
|
||||
const storeName = platform === "tmall" ? "官方旗舰店" : "京东自营";
|
||||
const basePrice = platform === "tmall" ? 7999 : 8099;
|
||||
const slug = slugify(query);
|
||||
const tmallItemIds = ["833444005595", "833444005596", "833444005597"];
|
||||
|
||||
return Array.from({ length: 3 }, (_, index) => ({
|
||||
candidateId: `${platform}-${slug}-${index + 1}`,
|
||||
@ -27,7 +28,10 @@ export function createMockCandidates(
|
||||
price: basePrice + index * 120,
|
||||
priceLabel: `¥${basePrice + index * 120}`,
|
||||
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}`,
|
||||
salesHint:
|
||||
platform === "tmall"
|
||||
|
||||
170
apps/api/src/platforms/jd/keyword-preview.ts
Normal file
170
apps/api/src/platforms/jd/keyword-preview.ts
Normal 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;
|
||||
}
|
||||
301
apps/api/src/platforms/jd/live-session.test.ts
Normal file
301
apps/api/src/platforms/jd/live-session.test.ts
Normal 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")
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -7,19 +7,71 @@ import {
|
||||
import type {
|
||||
JdDetailPreviewResult,
|
||||
JdLiveService,
|
||||
JdProductPreviewResult,
|
||||
JdLiveSessionInput,
|
||||
JdLiveSessionSummary,
|
||||
JdReviewsPaginationSummary,
|
||||
JdReviewsPreviewOptions,
|
||||
JdReviewsPreviewResult,
|
||||
JdSearchMode,
|
||||
JdSearchPreviewResult,
|
||||
JdTemplateSummary
|
||||
} from "./types";
|
||||
import { firstString, readQueryBody, withUpdatedQueryBody } from "./utils";
|
||||
import {
|
||||
findFirstBodyKey,
|
||||
firstString,
|
||||
readQueryBody,
|
||||
withUpdatedQueryBody
|
||||
} from "./utils";
|
||||
|
||||
const DEFAULT_JD_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_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 = {
|
||||
cookieHeader: string;
|
||||
importedAt: string;
|
||||
@ -34,7 +86,8 @@ type StoredJdLiveSession = {
|
||||
class JdLiveError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly statusCode: number = 400
|
||||
readonly statusCode: number = 400,
|
||||
readonly code: JdLiveErrorCode = "BAD_REQUEST"
|
||||
) {
|
||||
super(message);
|
||||
this.name = "JdLiveError";
|
||||
@ -72,7 +125,11 @@ function readEnvSession(): StoredJdLiveSession | null {
|
||||
function requireNonEmptyCookie(cookieHeader: string): string {
|
||||
const normalized = cookieHeader.trim();
|
||||
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;
|
||||
@ -83,9 +140,13 @@ function extractTemplateSkuId(templateUrl: string | undefined): string | undefin
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const url = new URL(templateUrl);
|
||||
const body = readQueryBody(url);
|
||||
return firstString(body?.skuId, body?.sku) ?? undefined;
|
||||
try {
|
||||
const url = new URL(templateUrl);
|
||||
const body = readQueryBody(url);
|
||||
return firstString(body?.skuId, body?.sku) ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function buildTemplateSummary(templateUrl: string | undefined): JdTemplateSummary {
|
||||
@ -105,8 +166,226 @@ function templateMatchesQuery(
|
||||
return false;
|
||||
}
|
||||
|
||||
const templateKeyword = new URL(templateUrl).searchParams.get("keyword");
|
||||
return Boolean(templateKeyword && templateKeyword === query);
|
||||
try {
|
||||
const templateKeyword = new URL(templateUrl).searchParams.get("keyword");
|
||||
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(
|
||||
@ -126,19 +405,52 @@ async function fetchTextOrThrow(
|
||||
`JD live request failed before receiving a response: ${
|
||||
error instanceof Error ? error.message : "unknown error"
|
||||
}`,
|
||||
502
|
||||
502,
|
||||
"NETWORK_ERROR"
|
||||
);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (response.url.includes("passport.jd.com") || text.includes("passport.jd.com")) {
|
||||
throw new JdLiveError(sessionExpiredMessage, 409);
|
||||
const normalizedResponseUrl = response.url.toLowerCase();
|
||||
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) {
|
||||
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(
|
||||
`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;
|
||||
}
|
||||
|
||||
export function getJdLiveErrorCode(error: unknown): JdLiveErrorCode | undefined {
|
||||
return isJdLiveError(error) && typeof error.code === "string" ? error.code : undefined;
|
||||
}
|
||||
|
||||
export class JdLiveSessionService implements JdLiveService {
|
||||
private session: StoredJdLiveSession | null = readEnvSession();
|
||||
|
||||
@ -174,6 +492,12 @@ export class JdLiveSessionService implements JdLiveService {
|
||||
const searchReferer = input.searchReferer?.trim();
|
||||
const detailReferer = input.detailReferer?.trim();
|
||||
|
||||
validateImportedTemplates({
|
||||
searchApiTemplateUrl,
|
||||
detailTemplateUrl,
|
||||
reviewsTemplateUrl
|
||||
});
|
||||
|
||||
this.session = {
|
||||
cookieHeader: requireNonEmptyCookie(input.cookieHeader),
|
||||
importedAt: nowIso(),
|
||||
@ -199,7 +523,7 @@ export class JdLiveSessionService implements JdLiveService {
|
||||
const session = this.requireSession();
|
||||
const normalizedQuery = query.trim();
|
||||
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 =
|
||||
@ -209,7 +533,9 @@ export class JdLiveSessionService implements JdLiveService {
|
||||
if (resolvedMode === "api") {
|
||||
if (!session.searchApiTemplateUrl) {
|
||||
throw new JdLiveError(
|
||||
"JD search API template is missing. Import a fresh search request URL or use mode=html."
|
||||
"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) {
|
||||
throw new JdLiveError(
|
||||
`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 normalizedSkuId = skuId.trim();
|
||||
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) {
|
||||
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);
|
||||
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 requestUrl = buildDetailRequestUrl(session.detailTemplateUrl, normalizedSkuId);
|
||||
|
||||
const response = await fetchTextOrThrow(
|
||||
requestUrl,
|
||||
@ -350,20 +626,132 @@ export class JdLiveSessionService implements JdLiveService {
|
||||
"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 {
|
||||
skuId: normalizedSkuId,
|
||||
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 {
|
||||
if (!this.session?.cookieHeader) {
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
367
apps/api/src/platforms/jd/session-manager.test.ts
Normal file
367
apps/api/src/platforms/jd/session-manager.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
645
apps/api/src/platforms/jd/session-manager.ts
Normal file
645
apps/api/src/platforms/jd/session-manager.ts
Normal 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)}`
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -27,6 +27,57 @@ export interface JdLiveSessionSummary {
|
||||
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 {
|
||||
query: string;
|
||||
source: JdSearchMode;
|
||||
@ -71,6 +122,20 @@ export interface JdProductReviewsSnapshot {
|
||||
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 {
|
||||
skuId: string;
|
||||
source: "api";
|
||||
@ -80,6 +145,15 @@ export interface JdDetailPreviewResult {
|
||||
export interface JdReviewsPreviewResult {
|
||||
skuId: string;
|
||||
source: "api";
|
||||
pagination: JdReviewsPaginationSummary;
|
||||
reviews: JdProductReviewsSnapshot;
|
||||
}
|
||||
|
||||
export interface JdProductPreviewResult {
|
||||
skuId: string;
|
||||
source: "api";
|
||||
detail: JdProductDetailSnapshot;
|
||||
pagination: JdReviewsPaginationSummary;
|
||||
reviews: JdProductReviewsSnapshot;
|
||||
}
|
||||
|
||||
@ -89,5 +163,34 @@ export interface JdLiveService {
|
||||
clearSession(): void;
|
||||
previewSearch(query: string, mode?: JdSearchMode): Promise<JdSearchPreviewResult>;
|
||||
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;
|
||||
}
|
||||
|
||||
@ -103,6 +103,23 @@ export function readQueryBody(url: URL): Record<string, unknown> | null {
|
||||
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(
|
||||
url: URL,
|
||||
updater: (body: Record<string, unknown>) => Record<string, unknown>
|
||||
|
||||
356
apps/api/src/platforms/tmall/live-session.test.ts
Normal file
356
apps/api/src/platforms/tmall/live-session.test.ts
Normal 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")
|
||||
});
|
||||
});
|
||||
});
|
||||
705
apps/api/src/platforms/tmall/live-session.ts
Normal file
705
apps/api/src/platforms/tmall/live-session.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
94
apps/api/src/platforms/tmall/parsers.test.ts
Normal file
94
apps/api/src/platforms/tmall/parsers.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
918
apps/api/src/platforms/tmall/parsers.ts
Normal file
918
apps/api/src/platforms/tmall/parsers.ts
Normal 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
|
||||
};
|
||||
}
|
||||
187
apps/api/src/platforms/tmall/session-manager.test.ts
Normal file
187
apps/api/src/platforms/tmall/session-manager.test.ts
Normal 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。"
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
391
apps/api/src/platforms/tmall/session-manager.ts
Normal file
391
apps/api/src/platforms/tmall/session-manager.ts
Normal 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()
|
||||
};
|
||||
}
|
||||
}
|
||||
182
apps/api/src/platforms/tmall/types.ts
Normal file
182
apps/api/src/platforms/tmall/types.ts
Normal 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;
|
||||
}
|
||||
187
apps/api/src/platforms/tmall/utils.ts
Normal file
187
apps/api/src/platforms/tmall/utils.ts
Normal 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(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/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;
|
||||
}
|
||||
92
apps/api/src/review-sampling.test.ts
Normal file
92
apps/api/src/review-sampling.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
312
apps/api/src/review-sampling.ts
Normal file
312
apps/api/src/review-sampling.ts
Normal 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
|
||||
};
|
||||
}
|
||||
976
apps/api/src/server.dual-platform-live.test.ts
Normal file
976
apps/api/src/server.dual-platform-live.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -3,7 +3,9 @@ import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
JdDetailPreviewResult,
|
||||
JdLiveService,
|
||||
JdProductPreviewResult,
|
||||
JdLiveSessionSummary,
|
||||
JdReviewsPreviewOptions,
|
||||
JdReviewsPreviewResult,
|
||||
JdSearchPreviewResult
|
||||
} from "./platforms/jd/types";
|
||||
@ -107,14 +109,24 @@ function createJdLiveServiceStub(
|
||||
|
||||
return preview;
|
||||
},
|
||||
async previewReviews(skuId, commentCount) {
|
||||
async previewReviews(skuId, options) {
|
||||
if (overrides.previewReviews) {
|
||||
return overrides.previewReviews(skuId, commentCount);
|
||||
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 ?? 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: {
|
||||
skuId,
|
||||
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;
|
||||
}
|
||||
};
|
||||
@ -256,6 +285,21 @@ describe("JD live server endpoints", () => {
|
||||
skuId: "100068388533",
|
||||
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();
|
||||
});
|
||||
|
||||
341
apps/api/src/server.jd-session-manager.test.ts
Normal file
341
apps/api/src/server.jd-session-manager.test.ts
Normal 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
395
apps/api/src/server.tmall-live.test.ts
Normal file
395
apps/api/src/server.tmall-live.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
310
apps/api/src/server.tmall-session-manager.test.ts
Normal file
310
apps/api/src/server.tmall-session-manager.test.ts
Normal 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
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -5,15 +5,79 @@ import type {
|
||||
} from "@cross-ai/domain";
|
||||
import cors from "@fastify/cors";
|
||||
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 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";
|
||||
|
||||
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 store = new InMemoryTaskStore();
|
||||
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 });
|
||||
|
||||
@ -23,7 +87,19 @@ export function createServer(options: { jdLiveService?: JdLiveService } = {}) {
|
||||
}));
|
||||
|
||||
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 () => ({
|
||||
@ -61,6 +137,163 @@ export function createServer(options: { jdLiveService?: JdLiveService } = {}) {
|
||||
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<{
|
||||
Body: {
|
||||
cookieHeader: string;
|
||||
@ -73,10 +306,9 @@ export function createServer(options: { jdLiveService?: JdLiveService } = {}) {
|
||||
};
|
||||
}>("/api/platforms/jd/live-session", async (request, reply) => {
|
||||
try {
|
||||
const session = jdLiveService.importSession(request.body);
|
||||
store.preparePlatform("jd");
|
||||
await jdSessionManager.importManualSession(request.body, "legacy-live-session");
|
||||
reply.code(200);
|
||||
return { session };
|
||||
return { session: jdLiveService.getSessionSummary() };
|
||||
} catch (error) {
|
||||
reply.code(isJdLiveError(error) ? error.statusCode : 400);
|
||||
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) => {
|
||||
jdLiveService.clearSession();
|
||||
store.clearPlatformSession("jd");
|
||||
jdSessionManager.clearManagedSession("legacy-live-session-clear");
|
||||
reply.code(204);
|
||||
return null;
|
||||
});
|
||||
|
||||
app.delete("/api/platforms/tmall/live-session", async (_request, reply) => {
|
||||
tmallSessionManager.clearManagedSession("legacy-live-session-clear");
|
||||
reply.code(204);
|
||||
return null;
|
||||
});
|
||||
@ -100,6 +359,9 @@ export function createServer(options: { jdLiveService?: JdLiveService } = {}) {
|
||||
if (request.params.platform === "jd") {
|
||||
jdLiveService.clearSession();
|
||||
}
|
||||
if (request.params.platform === "tmall") {
|
||||
tmallLiveService.clearSession();
|
||||
}
|
||||
reply.code(204);
|
||||
return null;
|
||||
} catch {
|
||||
@ -111,7 +373,7 @@ export function createServer(options: { jdLiveService?: JdLiveService } = {}) {
|
||||
app.post<{
|
||||
Body: CreateTaskInput;
|
||||
}>("/api/tasks", async (request, reply) => {
|
||||
const task = store.createTask(request.body);
|
||||
const task = await store.createTask(request.body);
|
||||
reply.code(201);
|
||||
return { task };
|
||||
});
|
||||
@ -180,7 +442,7 @@ export function createServer(options: { jdLiveService?: JdLiveService } = {}) {
|
||||
Body: ConfirmTaskPayload;
|
||||
}>("/api/tasks/:taskId/confirm", async (request, reply) => {
|
||||
try {
|
||||
const task = store.confirmTask(request.params.taskId, request.body);
|
||||
const task = await store.confirmTask(request.params.taskId, request.body);
|
||||
return { task };
|
||||
} catch {
|
||||
reply.code(404);
|
||||
@ -192,7 +454,7 @@ export function createServer(options: { jdLiveService?: JdLiveService } = {}) {
|
||||
Params: { taskId: string; platform: PlatformId };
|
||||
}>("/api/tasks/:taskId/platforms/:platform/retry", async (request, reply) => {
|
||||
try {
|
||||
const task = store.retryPlatform(request.params.taskId, request.params.platform);
|
||||
const task = await store.retryPlatform(request.params.taskId, request.params.platform);
|
||||
return { task };
|
||||
} catch {
|
||||
reply.code(404);
|
||||
@ -256,7 +518,71 @@ export function createServer(options: { jdLiveService?: JdLiveService } = {}) {
|
||||
});
|
||||
|
||||
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) => {
|
||||
try {
|
||||
const skuId = request.query.skuId?.trim();
|
||||
@ -268,7 +594,15 @@ export function createServer(options: { jdLiveService?: JdLiveService } = {}) {
|
||||
const commentCount = request.query.commentCount
|
||||
? Number.parseInt(request.query.commentCount, 10)
|
||||
: 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 };
|
||||
} catch (error) {
|
||||
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 () => ({
|
||||
tasks: store.listHistory()
|
||||
}));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user