feat: 补充报告评论集合与评论详情证据

This commit is contained in:
2026-04-07 19:58:27 +08:00
parent 4ea9eaf4a6
commit 41a42bca25
8 changed files with 1712 additions and 457 deletions
@@ -0,0 +1,268 @@
import { describe, expect, it } from "vitest";
import type {
JdDetailPreviewResult,
JdLiveService,
JdLiveSessionSummary,
JdProductPreviewResult,
JdReviewsPreviewOptions,
JdReviewsPreviewResult,
JdSearchPreviewResult
} from "./platforms/jd/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: 5,
taskTotalLimit: 10
}
});
return response.json().task;
}
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-07T10: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: "api",
candidateCount: 1,
candidates: [
{
candidateId: "jd-100068388533",
platform: "jd",
title: "Nintendo Switch 2",
price: 2999,
priceLabel: "¥2999",
storeName: "京东自营",
productUrl: "https://item.jd.com/100068388533.html",
imageUrl: "https://img14.360buyimg.com/n2/jfs/t1/example.jpg",
salesHint: "已售 1000+",
specLabel: "标准版",
highlights: ["掌机", "续航"]
}
]
};
return preview;
},
async previewDetail(skuId) {
if (overrides.previewDetail) {
return overrides.previewDetail(skuId);
}
const preview: JdDetailPreviewResult = {
skuId,
source: "api",
detail: {
skuId,
title: "Nintendo Switch 2",
price: "2999.00",
originalPrice: "3299.00",
estimatedPrice: "2999.00",
shopName: "京东自营",
vendorId: null,
categoryPath: ["游戏设备", "掌机"],
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 ?? 5);
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: "2",
goodRate: "95%",
pictureCount: "1",
tags: [{ tagId: "tag-1", name: "续航稳定", count: "2" }],
comments: [
{
id: "comment-1",
content: "第一条抓取评论,重点提到运行流畅。",
score: "5",
creationTime: "2026-04-07 10:00:00",
userLevelName: "PLUS"
},
{
id: "comment-2",
content: "第二条抓取评论,重点提到续航稳定。",
score: "4",
creationTime: "2026-04-06 18:00:00",
userLevelName: "会员"
}
]
}
};
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("report review collections", () => {
it("publishes per-link review collections for report pages", async () => {
const app = createServer({ jdLiveService: createJdLiveServiceStub() });
await app.ready();
const importResponse = 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:%22switch%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"
}
});
expect(importResponse.statusCode).toBe(200);
const prepareResponse = await app.inject({
method: "POST",
url: "/api/platforms/jd/prepare"
});
expect(prepareResponse.statusCode).toBe(200);
const task = await createTask(app, "Nintendo Switch 2");
const candidatesResponse = await app.inject({
method: "GET",
url: `/api/tasks/${task.taskId}/candidates`
});
const firstCandidateId = candidatesResponse.json().candidates.jd[0].candidateId;
const confirmResponse = await app.inject({
method: "POST",
url: `/api/tasks/${task.taskId}/confirm`,
payload: {
selections: [
{
platform: "jd",
candidateIds: [firstCandidateId]
}
]
}
});
expect(confirmResponse.statusCode).toBe(200);
expect(confirmResponse.json().task.taskStatus).toBe("Completed");
const reportResponse = await app.inject({
method: "GET",
url: `/api/tasks/${task.taskId}/report`
});
expect(reportResponse.statusCode).toBe(200);
expect(reportResponse.json().report.review_collections).toEqual([
expect.objectContaining({
platform: "jd",
title: "Nintendo Switch 2",
review_count: 2,
product_evidence_id: expect.any(String),
sampled_review_refs: expect.arrayContaining(["comment-1"]),
comments: expect.arrayContaining([
expect.objectContaining({
review_ref: "comment-1",
content: "第一条抓取评论,重点提到运行流畅。"
}),
expect.objectContaining({
review_ref: "comment-2",
content: "第二条抓取评论,重点提到续航稳定。"
})
])
})
]);
await app.close();
});
});
+75 -1
View File
@@ -1074,7 +1074,26 @@ describe("API server", () => {
expect.objectContaining({
platform: "jd",
source_type: "review",
review_ref: "comment-1"
review_ref: "comment-1",
review_detail: expect.objectContaining({
content: expect.any(String)
})
})
])
);
expect(reportResponse.json().report.review_collections).toEqual(
expect.arrayContaining([
expect.objectContaining({
platform: "jd",
source_url: "https://item.jd.com/100068388533.html",
review_count: 1,
product_evidence_id: expect.any(String),
comments: [
expect.objectContaining({
review_ref: "comment-1",
content: expect.any(String)
})
]
})
])
);
@@ -1421,6 +1440,61 @@ describe("API server", () => {
await app.close();
});
it("auto-resumes JD SearchBlocked tasks after the managed session passes health check", async () => {
const app = createServer({
jdLiveService: createJdLiveServiceStub()
});
await app.ready();
const createdTask = await createTask(app, "iPhone 15 Pro");
expect(
createdTask.platformRuns.find((run: { platform: string }) => run.platform === "jd")
).toMatchObject({
platform: "jd",
status: "SearchBlocked"
});
const importResponse = await app.inject({
method: "POST",
url: "/api/ops/jd/session-manager/session",
payload: {
cookieHeader: "thor=masked; pin=masked;",
detailTemplateUrl:
"https://api.m.jd.com/?functionId=pc_detailpage_wareBusiness&body=%7B%22skuId%22:%22100068388533%22%7D",
reviewsTemplateUrl:
"https://api.m.jd.com/?functionId=getLegoWareDetailComment&body=%7B%22sku%22:100068388533%7D"
}
});
expect(importResponse.statusCode).toBe(200);
const recoveredTask = await waitForTask(
app,
createdTask.taskId,
(task) =>
task.platformRuns.some(
(run: { platform: string; status: string }) =>
run.platform === "jd" && run.status === "AwaitingSelection"
)
);
expect(
recoveredTask.platformRuns.find((run: { platform: string }) => run.platform === "jd")
).toMatchObject({
platform: "jd",
status: "AwaitingSelection"
});
const candidatesResponse = await app.inject({
method: "GET",
url: `/api/tasks/${createdTask.taskId}/candidates`
});
expect(candidatesResponse.json().candidates.jd).toHaveLength(1);
await app.close();
});
it("records recovery audit entries and retry metrics for recovered platforms", async () => {
const app = createServer();
await app.ready();
+175 -71
View File
@@ -374,6 +374,73 @@ function getExecutionCommentUserLabel(comment: ExecutionReviewComment): string |
return "userLevelName" in comment ? comment.userLevelName : comment.userNick;
}
function getExecutionCommentSkuLabels(comment: ExecutionReviewComment): string[] {
return "skuText" in comment ? comment.skuText.filter(Boolean) : [];
}
function getExecutionCommentLikeCount(comment: ExecutionReviewComment): string | null {
return "likeCount" in comment ? comment.likeCount : null;
}
function getExecutionCommentReply(comment: ExecutionReviewComment): string | null {
return "reply" in comment ? comment.reply : null;
}
function getExecutionCommentAppendContent(comment: ExecutionReviewComment): string | null {
return "appendContent" in comment ? comment.appendContent : null;
}
function getExecutionCommentPictureUrls(comment: ExecutionReviewComment): string[] {
return "pictureUrls" in comment ? comment.pictureUrls.filter(Boolean) : [];
}
function getExecutionCommentVideoUrls(comment: ExecutionReviewComment): string[] {
return "videoUrls" in comment ? comment.videoUrls.filter(Boolean) : [];
}
function getExecutionCommentAppendPictureUrls(comment: ExecutionReviewComment): string[] {
return "appendPictureUrls" in comment ? comment.appendPictureUrls.filter(Boolean) : [];
}
function toReportReviewCollectionComment(
comment: ExecutionReviewComment,
sampleBucket: ReviewSamplingBucket | null
): NonNullable<ReportSnapshot["review_collections"]>[number]["comments"][number] {
return {
review_ref: comment.id,
sample_bucket: sampleBucket,
content: comment.content,
score: getExecutionCommentScore(comment),
created_at: getExecutionCommentDate(comment),
author_label: getExecutionCommentUserLabel(comment),
sku_labels: getExecutionCommentSkuLabels(comment),
like_count: getExecutionCommentLikeCount(comment),
reply: getExecutionCommentReply(comment),
append_content: getExecutionCommentAppendContent(comment),
picture_urls: getExecutionCommentPictureUrls(comment),
video_urls: getExecutionCommentVideoUrls(comment),
append_picture_urls: getExecutionCommentAppendPictureUrls(comment)
};
}
function toReportEvidenceReviewDetail(
comment: ExecutionReviewComment
): NonNullable<ReportSnapshot["evidence_index"][number]["review_detail"]> {
return {
content: comment.content,
score: getExecutionCommentScore(comment),
created_at: getExecutionCommentDate(comment),
author_label: getExecutionCommentUserLabel(comment),
sku_labels: getExecutionCommentSkuLabels(comment),
like_count: getExecutionCommentLikeCount(comment),
reply: getExecutionCommentReply(comment),
append_content: getExecutionCommentAppendContent(comment),
picture_urls: getExecutionCommentPictureUrls(comment),
video_urls: getExecutionCommentVideoUrls(comment),
append_picture_urls: getExecutionCommentAppendPictureUrls(comment)
};
}
function toReviewSamplingComment(comment: ExecutionReviewComment): ReviewSamplingComment {
return {
id: comment.id,
@@ -1721,7 +1788,7 @@ export class InMemoryTaskStore {
try {
await this.retryPlatform(taskId, platform);
} catch {
// retryPlatform already records recoverable failures on the task.
// Keep the current task state; retryPlatform already records recoverable failures.
} finally {
this.pendingManagedSessionRetries.delete(retryKey);
}
@@ -1787,6 +1854,7 @@ export class InMemoryTaskStore {
const sourcePlatforms: PlatformId[] =
insightPlatforms.length > 0 ? insightPlatforms : ["tmall"];
const evidenceIndex: ReportSnapshot["evidence_index"] = [];
const reviewCollections: NonNullable<ReportSnapshot["review_collections"]> = [];
const evidenceIdsByPlatform = new Map<PlatformId, string[]>();
let evidenceCounter = 0;
@@ -1798,7 +1866,8 @@ export class InMemoryTaskStore {
sourceType: "product" | "review",
sourceUrl: string,
snippet: string,
reviewRef: string | null
reviewRef: string | null,
reviewDetail?: NonNullable<ReportSnapshot["evidence_index"][number]["review_detail"]>
) => {
const evidenceId = `evidence-${task.taskId}-${++evidenceCounter}`;
evidenceIndex.push({
@@ -1807,6 +1876,7 @@ export class InMemoryTaskStore {
source_type: sourceType,
source_url: sourceUrl,
review_ref: reviewRef,
...(reviewDetail ? { review_detail: reviewDetail } : {}),
snippet,
captured_at: nowIso()
});
@@ -1839,7 +1909,39 @@ export class InMemoryTaskStore {
.filter(Boolean)
.join(" | ");
addEvidence(candidate.platform, "product", candidate.productUrl, detailSummary, null);
const productEvidenceId = addEvidence(
candidate.platform,
"product",
candidate.productUrl,
detailSummary,
null
);
const sampledBucketsByCommentId = new Map(
sampledComments.map(({ bucket, comment }) => [comment.id, bucket] as const)
);
const executionComments = artifact?.reviews.comments ?? [];
if (artifact) {
reviewCollections.push({
collection_id: `review-collection-${candidate.candidateId}`,
candidate_id: candidate.candidateId,
product_evidence_id: productEvidenceId,
platform: candidate.platform,
source_url: candidate.productUrl,
title: headline,
store_name: storeName,
price_label: priceLabel,
captured_at: artifact.capturedAt,
review_count: executionComments.length,
sampled_review_refs: sampledComments.map(({ comment }) => comment.id),
comments: executionComments.map((comment) =>
toReportReviewCollectionComment(
comment,
sampledBucketsByCommentId.get(comment.id) ?? null
)
)
});
}
for (const { bucket, comment } of sampledComments.slice(0, 2)) {
const commentSummary = [
@@ -1856,7 +1958,8 @@ export class InMemoryTaskStore {
"review",
candidate.productUrl,
commentSummary,
comment.id
comment.id,
toReportEvidenceReviewDetail(comment)
);
}
}
@@ -2097,6 +2200,7 @@ export class InMemoryTaskStore {
)
],
evidence_index: evidenceIndex,
review_collections: reviewCollections,
quality_flags: {
sample_insufficient: sampleInsufficient,
partial_platform_failure:
@@ -2785,6 +2889,73 @@ export class InMemoryTaskStore {
return true;
}
private scheduleTaskExecution(taskId: string): void {
if (this.pendingExecutions.has(taskId)) {
return;
}
const execution = new Promise<void>((resolve) => {
setTimeout(() => {
void (async () => {
const task = this.requireTask(taskId);
const selectedRuns = task.platformRuns.filter((run) => run.status === "Selected");
if (selectedRuns.length > 0) {
await this.executeSelectedPlatforms(task, selectedRuns);
}
task.taskStatus = deriveTaskStatusFromConfirmedPlatforms(task.platformRuns);
task.updatedAt = nowIso();
const published = this.publishReportIfNeeded(task);
this.persistState();
if (!published) {
this.emitTaskSnapshot(task);
}
})()
.catch((error) => {
this.failBackgroundTaskExecution(taskId, error);
})
.finally(() => {
this.pendingExecutions.delete(taskId);
resolve();
});
}, 0);
});
this.pendingExecutions.set(taskId, execution);
}
private failBackgroundTaskExecution(taskId: string, error: unknown): void {
const task = this.tasks.get(taskId);
if (!task) {
return;
}
for (const run of task.platformRuns) {
if (run.selectedCandidateIds.length === 0) {
continue;
}
if (run.status === "Selected" || run.status === "Running") {
run.status = "Failed";
run.reason = "后台执行异常中断,请稍后重试。";
run.lastUpdatedAt = nowIso();
}
}
task.taskStage = "publish";
task.taskStatus = deriveTaskStatusFromConfirmedPlatforms(task.platformRuns);
this.pushEvent(
task,
"task.execution_failed",
error instanceof Error
? `后台执行异常中断:${error.message}`
: "后台执行异常中断,请稍后重试。"
);
this.persistState();
}
private buildReportFingerprint(task: TaskRecord): string {
const selectedLinkCount = task.platformRuns.reduce(
(sum, run) => sum + run.selectedCandidateIds.length,
@@ -3062,73 +3233,6 @@ export class InMemoryTaskStore {
}
}
private scheduleTaskExecution(taskId: string): void {
if (this.pendingExecutions.has(taskId)) {
return;
}
const execution = new Promise<void>((resolve) => {
setTimeout(() => {
void (async () => {
const task = this.requireTask(taskId);
const selectedRuns = task.platformRuns.filter((run) => run.status === "Selected");
if (selectedRuns.length > 0) {
await this.executeSelectedPlatforms(task, selectedRuns);
}
task.taskStatus = deriveTaskStatusFromConfirmedPlatforms(task.platformRuns);
task.updatedAt = nowIso();
const published = this.publishReportIfNeeded(task);
this.persistState();
if (!published) {
this.emitTaskSnapshot(task);
}
})()
.catch((error) => {
this.failBackgroundTaskExecution(taskId, error);
})
.finally(() => {
this.pendingExecutions.delete(taskId);
resolve();
});
}, 0);
});
this.pendingExecutions.set(taskId, execution);
}
private failBackgroundTaskExecution(taskId: string, error: unknown): void {
const task = this.tasks.get(taskId);
if (!task) {
return;
}
for (const run of task.platformRuns) {
if (run.selectedCandidateIds.length === 0) {
continue;
}
if (run.status === "Selected" || run.status === "Running") {
run.status = "Failed";
run.reason = "后台执行异常中断,请稍后重试。";
run.lastUpdatedAt = nowIso();
}
}
task.taskStage = "publish";
task.taskStatus = deriveTaskStatusFromConfirmedPlatforms(task.platformRuns);
this.pushEvent(
task,
"task.execution_failed",
error instanceof Error
? `后台执行异常中断:${error.message}`
: "后台执行异常中断,请稍后重试。"
);
this.persistState();
}
private requireTask(taskId: string): TaskRecord {
const task = this.tasks.get(taskId);
if (!task) {