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(//gi, ' ').replace(//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 || /= 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) || / 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).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;