| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190 |
- import { createHash, randomUUID } from 'node:crypto';
- import type {
- ListingCoverage,
- ListingDimension,
- ListingDimensionScore,
- ListingRuleEvidence,
- ListingScoreResult,
- ListingSourceSnapshot,
- } from '../domain.js';
- export const LISTING_RUBRIC_VERSION = 'listing-jd-v2';
- const DIMENSIONS: ListingDimension[] = ['title', 'selling_points', 'images', 'description', 'specifications'];
- function textFromHtml(value: string | null): string {
- return (value ?? '').replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' ')
- .replace(/<[^>]+>/g, ' ').replace(/ /gi, ' ').replace(/\s+/g, ' ').trim();
- }
- function evidence(
- ruleId: string,
- fieldPath: string,
- pass: boolean | null,
- penalty: number,
- message: string,
- ): ListingRuleEvidence {
- return { ruleId, fieldPath, outcome: pass === null ? 'unknown' : pass ? 'pass' : 'fail', delta: pass === false ? -penalty : 0, message };
- }
- function finish(dimension: ListingDimension, rows: ListingRuleEvidence[], covered: number, total: number): ListingDimensionScore {
- const coverage = Math.round((covered / total) * 100);
- if (covered === 0) {
- return { dimension, score: null, maxScore: 20, coverage: 0, status: 'blocked', evidence: rows, suggestions: rows.filter((row) => row.outcome !== 'pass').map((row) => row.message) };
- }
- const score = Math.max(0, Math.min(20, 20 + rows.reduce((sum, row) => sum + row.delta, 0)));
- return {
- dimension,
- score,
- maxScore: 20,
- coverage,
- status: coverage < 60 ? 'partial' : 'scored',
- evidence: rows,
- suggestions: rows.filter((row) => row.outcome === 'fail').map((row) => row.message),
- };
- }
- function scoreTitle(source: ListingSourceSnapshot): ListingDimensionScore {
- const title = source.title?.trim() ?? '';
- const brand = source.brand.name?.trim() ?? source.titleBrandName?.trim() ?? '';
- const rows = [
- evidence('title.present', 'title', Boolean(title), 20, '补充商品标题'),
- evidence('title.length', 'title', title ? title.length >= 12 && title.length <= 60 : null, 5, '标题建议保持在 12–60 个字符'),
- evidence('title.brand', 'brand.name', title && brand ? title.toLocaleLowerCase().includes(brand.toLocaleLowerCase()) : null, 3, '在标题中准确包含品牌'),
- evidence('title.no_repeated_tokens', 'title', title ? !/(.{2,8})\1{2,}/.test(title) : null, 4, '删除标题中的重复词组'),
- evidence('title.no_excess_symbols', 'title', title ? !/[!!]{2,}|[★☆]{2,}/.test(title) : null, 3, '减少连续营销符号'),
- ];
- return finish('title', rows, title ? (brand ? 5 : 4) : 0, 5);
- }
- function scoreSellingPoints(source: ListingSourceSnapshot): ListingDimensionScore {
- // JD productInfo.features also contains transport/control flags such as 0/1.
- // Those are not seller-facing selling points and must not participate in
- // duplicate detection. Until JD exposes a dedicated marketing-points field,
- // use only human-readable descriptors and attributes as derived candidates.
- const readableFeatures = source.features
- .filter((item) => ['nameWithoutBrand', 'model'].includes(item.key) || /[\u4e00-\u9fff]/.test(item.key))
- .map((item) => item.value.trim())
- .filter((value) => value && !/^[01]$/.test(value));
- const attributePoints = source.attributes
- .filter((item) => item.name.trim() && item.values.some((value) => value.trim()))
- .map((item) => `${item.name.trim()}:${item.values.map((value) => value.trim()).filter(Boolean).join('、')}`);
- const values = [...readableFeatures, ...attributePoints];
- const hasService = Object.keys(source.afterService).length > 0;
- const rows = [
- evidence('selling_points.present', 'derivedSellingPoints', values.length > 0, 20, '补充结构化核心卖点'),
- evidence('selling_points.count', 'derivedSellingPoints', values.length ? values.length >= 3 : null, 5, '至少提供 3 条可读的卖点信息'),
- evidence('selling_points.unique', 'derivedSellingPoints', null, 0, '上游未返回独立营销卖点,暂不执行重复性扣分'),
- evidence('selling_points.specific', 'derivedSellingPoints', values.length ? values.some((value) => /\d/.test(value)) : null, 3, '卖点中加入可验证的规格或数字'),
- evidence('selling_points.service', 'afterService', hasService, 2, '补充售后或履约承诺'),
- ];
- return finish('selling_points', rows, values.length ? 3 + Number(hasService) : 0, 5);
- }
- function scoreImages(source: ListingSourceSnapshot): ListingDimensionScore {
- const images = source.images;
- const validUrls = images.filter((item) => /^https?:\/\//i.test(item.url));
- const unique = new Set(images.map((item) => item.url));
- const primary = images.some((item) => item.isPrimary === true);
- const ordered = images.every((item, index) => item.order === null || index === 0 || (item.order ?? 0) >= (images[index - 1]?.order ?? 0));
- const rows = [
- evidence('images.present', 'images', images.length > 0, 20, '至少提供一张主图'),
- evidence('images.count', 'images', images.length ? images.length >= 5 : null, 5, '建议提供至少 5 张不同角度的图片'),
- evidence('images.primary', 'images[].isPrimary', images.length ? primary : null, 4, '明确设置主图'),
- evidence('images.valid_url', 'images[].url', images.length ? validUrls.length === images.length : null, 4, '修复不可识别的图片 URL'),
- evidence('images.unique_ordered', 'images[].order', images.length ? unique.size === images.length && ordered : null, 3, '去除重复图片并校正顺序'),
- ];
- return finish('images', rows, images.length ? 5 : 0, 5);
- }
- function scoreDescription(source: ListingSourceSnapshot): ListingDimensionScore {
- const desktopRaw = source.descriptions.desktopHtml ?? '';
- const mobileRaw = source.descriptions.mobileHtml ?? '';
- const desktop = textFromHtml(desktopRaw);
- const mobile = textFromHtml(mobileRaw);
- const desktopPresent = Boolean(desktop || /<img\b/i.test(desktopRaw));
- const mobilePresent = Boolean(mobile || /<img\b/i.test(mobileRaw));
- const combinedPresent = desktopPresent || mobilePresent;
- const unsafe = /<script|on\w+\s*=|javascript:/i.test(`${desktopRaw}${mobileRaw}`);
- const rows = [
- evidence('description.present', 'descriptions', combinedPresent, 20, '补充商品详情'),
- evidence('description.desktop', 'descriptions.desktopHtml', desktopPresent ? desktopRaw.length >= 120 : null, 5, '完善桌面端详情内容'),
- evidence('description.mobile', 'descriptions.mobileHtml', mobilePresent ? mobileRaw.length >= 80 : null, 4, '完善移动端详情内容'),
- evidence('description.safe_html', 'descriptions', combinedPresent ? !unsafe : null, 6, '移除不安全 HTML'),
- evidence('description.consistent', 'descriptions', desktopPresent && mobilePresent ? Math.min(desktopRaw.length, mobileRaw.length) / Math.max(desktopRaw.length, mobileRaw.length) >= 0.25 : null, 3, '保持桌面端与移动端信息一致'),
- ];
- return finish('description', rows, combinedPresent ? 3 + Number(desktopPresent) + Number(mobilePresent) : 0, 5);
- }
- function scoreSpecifications(source: ListingSourceSnapshot): ListingDimensionScore {
- const attributes = source.attributes.filter((item) => item.name && item.values.length);
- const skuAttributes = source.skus.flatMap((sku) => sku.attributes);
- const dimensions = Object.values(source.dimensions).filter((value) => value !== null && value > 0);
- const skuIds = new Set(source.skus.map((sku) => sku.skuId));
- const rows = [
- evidence('specifications.present', 'attributes', attributes.length > 0, 20, '补充商品规格属性'),
- evidence('specifications.count', 'attributes', attributes.length ? attributes.length >= 3 : null, 5, '至少提供 3 个有效规格属性'),
- evidence('specifications.sku_attrs', 'skus[].attributes', source.skus.length ? skuAttributes.length > 0 : null, 4, '补充 SKU 维度属性'),
- evidence('specifications.dimensions', 'dimensions', dimensions.length > 0, 3, '补充尺寸或重量'),
- evidence('specifications.unique_skus', 'skus[].skuId', source.skus.length ? skuIds.size === source.skus.length : null, 4, '修复重复 SKU 标识'),
- ];
- return finish('specifications', rows, attributes.length ? 3 + Number(source.skus.length > 0) + Number(dimensions.length > 0) : 0, 5);
- }
- export function listingCoverage(source: ListingSourceSnapshot): ListingCoverage {
- const required = [
- ['title', Boolean(source.title?.trim())],
- ['features', source.features.some((item) => item.value.trim())],
- ['images', source.images.length > 0],
- ['descriptions', Boolean(textFromHtml(source.descriptions.desktopHtml) || textFromHtml(source.descriptions.mobileHtml) || /<img\b/i.test(`${source.descriptions.desktopHtml ?? ''}${source.descriptions.mobileHtml ?? ''}`))],
- ['attributes', source.attributes.length > 0],
- ] as const;
- const present = required.filter(([, available]) => available).length;
- const percent = Math.round((present / required.length) * 100);
- return {
- percent,
- missing: required.filter(([, available]) => !available).map(([name]) => name),
- status: source.detailStatus !== 'available' || present <= 1 ? 'blocked' : percent < 60 ? 'partial' : 'eligible',
- };
- }
- export function scoreListing(
- source: ListingSourceSnapshot,
- options: { id?: string; now?: string; rubricVersion?: string } = {},
- ): ListingScoreResult {
- const dimensions = [scoreTitle(source), scoreSellingPoints(source), scoreImages(source), scoreDescription(source), scoreSpecifications(source)];
- const coverage = listingCoverage(source);
- const scores = dimensions.map((item) => item.score).filter((score): score is number => score !== null);
- const canTotal = coverage.status !== 'blocked' && dimensions.every((item) => item.score !== null);
- return {
- id: options.id ?? randomUUID(),
- workspaceId: source.workspaceId,
- productId: source.productId,
- sourceHash: source.sourceHash,
- rubricVersion: options.rubricVersion ?? LISTING_RUBRIC_VERSION,
- overallScore: canTotal ? scores.reduce((sum, value) => sum + value, 0) : null,
- coverage,
- dimensions,
- aiStatus: 'not_requested',
- aiSuggestions: [],
- aiCandidate: null,
- model: null,
- promptVersion: null,
- scoreKind: 'rules',
- baselineOverallScore: null,
- aiConfidence: null,
- createdAt: options.now ?? new Date().toISOString(),
- };
- }
- export function canonicalHash(value: unknown): string {
- const canonical = (input: unknown): unknown => {
- if (Array.isArray(input)) return input.map(canonical);
- if (input && typeof input === 'object') {
- return Object.fromEntries(Object.entries(input as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b)).map(([key, child]) => [key, canonical(child)]));
- }
- return input;
- };
- return createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex');
- }
- export const LISTING_DIMENSIONS = DIMENSIONS;
|