|
|
@@ -1,187 +1,190 @@
|
|
|
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';
|
|
|
+import type { ListingComplianceFinding, ListingComplianceResult, ListingCoverage, ListingDimension, ListingDimensionScore, ListingRuleEvidence, ListingScoreResult, ListingSourceSnapshot } from '../domain.js';
|
|
|
+import { resolveJdCategoryRule } from './jd-category-rules.js';
|
|
|
+
|
|
|
+export const LISTING_RUBRIC_VERSION = 'listing-jd-v7';
|
|
|
+export const LISTING_RULE_SET_VERSION = 'jd-five-dimension-2026-08-v4';
|
|
|
+export const LISTING_DIMENSION_MAX: Readonly<Record<ListingDimension, number>> = { title: 30, selling_points: 25, images: 20, description: 15, specifications: 10 };
|
|
|
+export const LISTING_HARD_MAX: Readonly<Record<ListingDimension, number>> = { title: 14, selling_points: 5, images: 20, description: 15, specifications: 10 };
|
|
|
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();
|
|
|
+ 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 };
|
|
|
-}
|
|
|
+export function normalizeListingTitle(value: string | null): string { return (value ?? '').normalize('NFKC').replace(/\s+/g, ' ').trim(); }
|
|
|
+export function listingTitleVisibleCharacters(value: string): number { return Array.from(normalizeListingTitle(value)).length; }
|
|
|
+/** JD title length is measured in normalized Unicode visible characters. */
|
|
|
+export function jdTitleDisplayUnits(value: string): number { return listingTitleVisibleCharacters(value); }
|
|
|
|
|
|
-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) };
|
|
|
+function titleAfterLeadingBrand(title: string, brands: string[]): string {
|
|
|
+ let output = title;
|
|
|
+ for (const brand of [...new Set(brands.map(normalizeListingTitle).filter(Boolean))].sort((a, b) => b.length - a.length)) {
|
|
|
+ if (output.toLocaleLowerCase().startsWith(brand.toLocaleLowerCase())) { output = output.slice(brand.length); break; }
|
|
|
}
|
|
|
- const score = Math.max(0, Math.min(20, 20 + rows.reduce((sum, row) => sum + row.delta, 0)));
|
|
|
+ return output
|
|
|
+ .replace(/^\s*(?:[((][A-Za-z0-9 .&+_-]{1,40}[))])?\s*/u, '')
|
|
|
+ .replace(/^[-—–·||::]+\s*/u, '')
|
|
|
+ .trim();
|
|
|
+}
|
|
|
+
|
|
|
+function criterion(ruleId: string, fieldPath: string, outcome: 'pass' | 'fail' | 'unknown', maxPoints: number, message: string): ListingRuleEvidence {
|
|
|
+ const pointsAwarded = outcome === 'pass' ? maxPoints : 0;
|
|
|
+ return { ruleId, fieldPath, outcome, delta: outcome === 'unknown' ? 0 : pointsAwarded - maxPoints, message, source: 'rule', pointsAwarded, maxPoints };
|
|
|
+}
|
|
|
+
|
|
|
+function finish(dimension: ListingDimension, rows: ListingRuleEvidence[], blocked = false): ListingDimensionScore {
|
|
|
+ const known = rows.filter((row) => row.outcome !== 'unknown');
|
|
|
+ const knownMaxScore = known.reduce((sum, row) => sum + (row.maxPoints ?? 0), 0);
|
|
|
+ const knownScore = known.reduce((sum, row) => sum + (row.pointsAwarded ?? 0), 0);
|
|
|
+ const dimensionMax = LISTING_DIMENSION_MAX[dimension];
|
|
|
+ const coverage = dimensionMax ? Math.round(knownMaxScore / dimensionMax * 100) : 0;
|
|
|
+ const complete = knownMaxScore === dimensionMax;
|
|
|
return {
|
|
|
- dimension,
|
|
|
- score,
|
|
|
- maxScore: 20,
|
|
|
- coverage,
|
|
|
- status: coverage < 60 ? 'partial' : 'scored',
|
|
|
- evidence: rows,
|
|
|
+ dimension, score: blocked || !complete ? null : knownScore, maxScore: LISTING_DIMENSION_MAX[dimension], knownScore, knownMaxScore, coverage,
|
|
|
+ status: blocked || knownMaxScore === 0 ? 'blocked' : complete ? 'scored' : 'partial', 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);
|
|
|
+ const title = normalizeListingTitle(source.title);
|
|
|
+ const observed = source.detailStatus === 'available' || source.title !== null;
|
|
|
+ const brand = normalizeListingTitle(source.brand.name ?? source.titleBrandName);
|
|
|
+ const categoryRule = resolveJdCategoryRule(source.categoryContext?.names ?? [], source.categoryContext?.categoryId ?? source.categoryIds.at(-1) ?? null);
|
|
|
+ const terms = [...new Set([...categoryRule.coreTerms, ...categoryRule.aliases])].map((item) => normalizeListingTitle(item)).filter(Boolean);
|
|
|
+ const semanticTitle = titleAfterLeadingBrand(title, [brand, source.titleBrandName ?? '']);
|
|
|
+ const first15 = Array.from(semanticTitle).slice(0, 15).join('').toLocaleLowerCase();
|
|
|
+ const titleCharacters = listingTitleVisibleCharacters(title);
|
|
|
+ const repeated = /(.{2,8})\1{1,}/u.test(title);
|
|
|
+ const forbidden = /(最好|最佳|最强|第一|国家级|顶级|今日特价|限时|加微信|加微|QQ群|联系电话|★{2,}|!{2,}|!{2,})/u.test(title);
|
|
|
+ return finish('title', [
|
|
|
+ criterion('title.length', 'title', !observed ? 'unknown' : title && titleCharacters >= 30 && titleCharacters <= 60 ? 'pass' : 'fail', 4, `标题需保持 30–60 个 Unicode 可见字符;当前 ${titleCharacters}`),
|
|
|
+ criterion('title.brand_first', 'brand.name', !observed ? 'unknown' : brand && title.toLocaleLowerCase().startsWith(brand.toLocaleLowerCase()) ? 'pass' : 'fail', 4, '品牌应位于标题开头并与商品品牌一致'),
|
|
|
+ criterion('title.category_in_first_15', 'categoryContext.coreTerms', !observed ? 'unknown' : terms.length > 0 && terms.some((term) => first15.includes(term.toLocaleLowerCase())) ? 'pass' : 'fail', 4, terms.length ? `核心品类词应出现在品牌后的前 15 个字内;可识别品类:${terms.join('、')}` : '当前商品数据中没有可识别的品类名称'),
|
|
|
+ criterion('title.token_hygiene', 'title', !observed ? 'unknown' : title && !repeated && !forbidden ? 'pass' : 'fail', 2, '删除重复词根、极限词、失效促销词、导流信息和连续营销符号'),
|
|
|
+ ], !observed);
|
|
|
}
|
|
|
|
|
|
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);
|
|
|
+ const observed = source.detailStatus === 'available';
|
|
|
+ const points = source.marketing?.sellingPoints ?? [];
|
|
|
+ const values = points.map((item) => item.value.normalize('NFKC').replace(/\s+/g, ' ').trim()).filter(Boolean);
|
|
|
+ const traceable = points.length > 0 && points.every((item) => item.fieldPath && (item.source !== 'sku_short_title' || item.skuId));
|
|
|
+ const skuIds = new Set(source.skus.map((sku) => sku.skuId));
|
|
|
+ const coveredSkuIds = new Set((source.marketing?.skuShortTitles ?? []).map((item) => item.skuId).filter((id) => skuIds.has(id)));
|
|
|
+ const skuCoverage = source.skus.length ? coveredSkuIds.size / source.skus.length : null;
|
|
|
+ return finish('selling_points', [
|
|
|
+ criterion('selling_points.source_observed', 'marketing', observed ? 'pass' : 'unknown', 1, '必须明确采集商品广告词和 SKU 短标题字段'),
|
|
|
+ criterion('selling_points.present', 'marketing.sellingPoints', !observed ? 'unknown' : values.length ? 'pass' : 'fail', 1, '至少提供一个真实营销候选文本'),
|
|
|
+ criterion('selling_points.provenance', 'marketing.sellingPoints[].fieldPath', !observed ? 'unknown' : traceable ? 'pass' : 'fail', 1, '每条卖点必须可追溯到字段路径和 SKU'),
|
|
|
+ criterion('selling_points.exact_dedup', 'marketing.sellingPoints', !observed ? 'unknown' : values.length && new Set(values).size === values.length && values.length === points.length ? 'pass' : 'fail', 1, '清理空白、传输标记和完全重复卖点'),
|
|
|
+ criterion('selling_points.sku_coverage', 'marketing.skuShortTitles', !observed ? 'unknown' : skuCoverage !== null && skuCoverage >= 0.7 ? 'pass' : 'fail', 1, '有效商品规格的短标题覆盖率应达到 70%'),
|
|
|
+ ], !observed);
|
|
|
}
|
|
|
|
|
|
function scoreImages(source: ListingSourceSnapshot): ListingDimensionScore {
|
|
|
+ const observed = source.detailStatus === 'available' || source.images.length > 0;
|
|
|
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);
|
|
|
+ const urls = images.map((item) => item.url.trim().toLocaleLowerCase());
|
|
|
+ const primary = images.filter((item) => item.isPrimary === true);
|
|
|
+ const primaryObserved = images.length > 0 && images.every((item) => item.isPrimary !== null) && images.some((item) => item.primarySource !== 'unknown');
|
|
|
+ const primaryFirst = primary.length === 1 && (primary[0]?.order === 0 || images[0]?.url === primary[0]?.url);
|
|
|
+ const ordered = images.every((item, index) => index === 0 || item.order === null || images[index - 1]?.order === null || item.order >= (images[index - 1]?.order ?? 0));
|
|
|
+ const metadataKnown = images.length > 0 && images.every((item) => item.mediaType === 'image' || /\.(?:jpe?g|png|webp|gif)(?:\?|$)/i.test(item.url));
|
|
|
+ return finish('images', [
|
|
|
+ criterion('images.count', 'images', !observed ? 'unknown' : new Set(urls).size >= 5 ? 'pass' : 'fail', 6, '至少提供 5 个有效且唯一的图片资产'),
|
|
|
+ criterion('images.primary', 'images[].isPrimary', !observed ? 'unknown' : primaryObserved && primaryFirst ? 'pass' : 'fail', 5, '默认商品图片组应恰好设置一个主图并放在首位'),
|
|
|
+ criterion('images.url_integrity', 'images[].url', !observed ? 'unknown' : images.length && images.every((item) => /^https:\/\//i.test(item.url)) && new Set(urls).size === images.length ? 'pass' : 'fail', 4, '图片必须使用安全 HTTPS URL 且归一化后不重复'),
|
|
|
+ criterion('images.order', 'images[].order', !observed ? 'unknown' : images.length && ordered ? 'pass' : 'fail', 3, '图片顺序必须稳定、连续且无冲突'),
|
|
|
+ criterion('images.asset_metadata', 'images[].mediaType', !observed ? 'unknown' : metadataKnown ? 'pass' : 'fail', 2, '图片应具有可验证的媒体类型和资产信息'),
|
|
|
+ ], !observed);
|
|
|
}
|
|
|
|
|
|
function scoreDescription(source: ListingSourceSnapshot): ListingDimensionScore {
|
|
|
+ const observed = source.detailStatus === 'available';
|
|
|
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 desktopPresent = Boolean(textFromHtml(desktopRaw) || /<img\b/i.test(desktopRaw));
|
|
|
+ const mobilePresent = Boolean(textFromHtml(mobileRaw) || /<img\b/i.test(mobileRaw));
|
|
|
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);
|
|
|
+ return finish('description', [
|
|
|
+ criterion('description.desktop_present', 'descriptions.desktopHtml', !observed ? 'unknown' : desktopPresent ? 'pass' : 'fail', 4, '应提供桌面端商品详情资产'),
|
|
|
+ criterion('description.mobile_present', 'descriptions.mobileHtml', !observed ? 'unknown' : mobilePresent ? 'pass' : 'fail', 3, '应提供移动端商品详情资产'),
|
|
|
+ criterion('description.safe_html', 'descriptions', !observed ? 'unknown' : !unsafe ? 'pass' : 'fail', 3, '详情中不得包含脚本、事件属性或危险链接'),
|
|
|
+ criterion('description.assets_present', 'descriptionStructure.imageCount', !observed ? 'unknown' : (source.descriptionStructure?.imageCount ?? 0) > 0 || Boolean(textFromHtml(desktopRaw) || textFromHtml(mobileRaw)) ? 'pass' : 'fail', 5, '详情应包含可用的图文资产'),
|
|
|
+ ], !observed);
|
|
|
}
|
|
|
|
|
|
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 标识'),
|
|
|
+ const observed = source.detailStatus === 'available';
|
|
|
+ const attributes = source.attributes.filter((item) => item.name.trim() && item.values.some((value) => value.trim()));
|
|
|
+ const normalized = attributes.length > 0 && attributes.every((item) => new Set(item.values.map((value) => value.normalize('NFKC').trim()).filter(Boolean)).size === item.values.filter((value) => value.trim()).length);
|
|
|
+ const skuAttributes = source.skus.flatMap((sku) => sku.attributes).filter((item) => item.name && item.values.length);
|
|
|
+ const dimensionValues = Object.values(source.dimensions).filter((value) => value !== null && value > 0);
|
|
|
+ const skuIds = source.skus.map((sku) => sku.skuId);
|
|
|
+ return finish('specifications', [
|
|
|
+ criterion('specifications.present', 'attributes', !observed ? 'unknown' : attributes.length ? 'pass' : 'fail', 3, '应提供有效商品属性'),
|
|
|
+ criterion('specifications.normalized', 'attributes', !observed ? 'unknown' : normalized ? 'pass' : 'fail', 2, '规格名称和值应非空、去重且格式清晰'),
|
|
|
+ criterion('specifications.sku_attributes', 'skus[].attributes', !observed ? 'unknown' : !source.skus.length || skuAttributes.length ? 'pass' : 'fail', 2, '有商品规格时应提供对应属性'),
|
|
|
+ criterion('specifications.dimensions_weight', 'dimensions', !observed ? 'unknown' : dimensionValues.length ? 'pass' : 'fail', 2, '应提供尺寸或重量信息'),
|
|
|
+ criterion('specifications.unique_skus', 'skus[].skuId', !observed ? 'unknown' : new Set(skuIds).size === skuIds.length ? 'pass' : 'fail', 1, '商品规格标识应唯一且映射无冲突'),
|
|
|
+ ], !observed);
|
|
|
+}
|
|
|
+
|
|
|
+export function listingCompliance(source: ListingSourceSnapshot): ListingComplianceResult {
|
|
|
+ const findings: ListingComplianceFinding[] = [];
|
|
|
+ const fields: Array<[string, string]> = [['title', normalizeListingTitle(source.title)], ...(source.marketing?.sellingPoints ?? []).map((item) => [item.fieldPath, item.value] as [string, string])];
|
|
|
+ const rules: Array<{ id: string; severity: ListingComplianceFinding['severity']; pattern: RegExp; message: string }> = [
|
|
|
+ { id: 'compliance.extreme_claim', severity: 'high', pattern: /(最好|最佳|最强|第一|国家级|顶级)/u, message: '发现极限或绝对化用语,需要合规复核' },
|
|
|
+ { id: 'compliance.expiring_promotion', severity: 'medium', pattern: /(今日特价|限时|仅限今天|最后一天)/u, message: '发现可能失效的促销时效用语' },
|
|
|
+ { id: 'compliance.external_redirect', severity: 'critical', pattern: /(加微信|加微|QQ群|https?:\/\/|www\.|联系电话\s*[::]?\s*1\d{10})/iu, message: '发现第三方导流或外部联系信息' },
|
|
|
];
|
|
|
- return finish('specifications', rows, attributes.length ? 3 + Number(source.skus.length > 0) + Number(dimensions.length > 0) : 0, 5);
|
|
|
+ for (const [fieldPath, value] of fields) for (const rule of rules) {
|
|
|
+ const match = value.match(rule.pattern);
|
|
|
+ if (match) findings.push({ ruleId: rule.id, severity: rule.severity, fieldPath, evidence: [match[0]], message: rule.message });
|
|
|
+ }
|
|
|
+ const qualifications = source.categoryContext?.qualificationNames ?? [];
|
|
|
+ const haystack = source.attributes.flatMap((item) => [item.name, ...item.values]).join(' ');
|
|
|
+ for (const qualification of qualifications.filter((item) => !haystack.includes(item))) findings.push({ ruleId: 'compliance.qualification_missing', severity: 'high', fieldPath: 'attributes', evidence: [qualification], message: `类目规则要求展示资质:${qualification}` });
|
|
|
+ const status = findings.some((item) => item.severity === 'critical') ? 'blocked' : findings.some((item) => item.severity === 'high') ? 'needs_review' : findings.length ? 'warning' : 'normal';
|
|
|
+ return { status, ruleSetVersion: source.categoryContext?.ruleVersion ?? LISTING_RULE_SET_VERSION, findings };
|
|
|
}
|
|
|
|
|
|
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],
|
|
|
+ ['title', Boolean(source.title?.trim())], ['marketing', Boolean(source.marketing?.sellingPoints.length)], ['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',
|
|
|
- };
|
|
|
+ const percent = Math.round(present / required.length * 100);
|
|
|
+ return { percent, missing: required.filter(([, available]) => !available).map(([name]) => name), status: source.detailStatus !== 'available' ? 'blocked' : 'eligible' };
|
|
|
+}
|
|
|
+
|
|
|
+export function listingUnknownCriteria(dimensions: ListingDimensionScore[]): NonNullable<ListingScoreResult['unknownCriteria']> {
|
|
|
+ void dimensions;
|
|
|
+ return [];
|
|
|
}
|
|
|
|
|
|
-export function scoreListing(
|
|
|
- source: ListingSourceSnapshot,
|
|
|
- options: { id?: string; now?: string; rubricVersion?: string } = {},
|
|
|
-): ListingScoreResult {
|
|
|
+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);
|
|
|
+ const knownOverallScore = dimensions.reduce((sum, item) => sum + (item.knownScore ?? item.score ?? 0), 0);
|
|
|
+ const knownOverallMaxScore = dimensions.reduce((sum, item) => sum + (item.knownMaxScore ?? (item.score === null ? 0 : item.maxScore)), 0);
|
|
|
+ const complete = coverage.status !== 'blocked' && dimensions.every((item) => item.status === 'scored' && 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(),
|
|
|
+ id: options.id ?? randomUUID(), workspaceId: source.workspaceId, productId: source.productId, sourceHash: source.sourceHash,
|
|
|
+ rubricVersion: options.rubricVersion ?? LISTING_RUBRIC_VERSION, overallScore: complete ? knownOverallScore : null, knownOverallScore, knownOverallMaxScore,
|
|
|
+ coverage, dimensions, compliance: listingCompliance(source), unknownCriteria: listingUnknownCriteria(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)]));
|
|
|
- }
|
|
|
+ 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');
|