jd-listing.normalizer.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
  1. import { randomUUID } from 'node:crypto';
  2. import type { ListingAttribute, ListingSourceSnapshot } from '../domain.js';
  3. import { canonicalHash } from '../scoring/rule-engine.js';
  4. import { resolveJdCategoryRule } from '../scoring/jd-category-rules.js';
  5. type UnknownRecord = Record<string, unknown>;
  6. export const JD_LISTING_NORMALIZER_VERSION = 'jd-listing-v5';
  7. const record = (value: unknown): UnknownRecord => value && typeof value === 'object' && !Array.isArray(value) ? value as UnknownRecord : {};
  8. const list = (value: unknown): unknown[] => Array.isArray(value) ? value : [];
  9. const str = (value: unknown): string => value === null || value === undefined ? '' : String(value).trim();
  10. const num = (value: unknown): number | null => { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; };
  11. function epoch(value: unknown): string | null {
  12. const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) return null;
  13. const date = new Date(parsed < 10_000_000_000 ? parsed * 1_000 : parsed);
  14. return Number.isNaN(date.valueOf()) ? null : date.toISOString();
  15. }
  16. function imageUrl(value: unknown): string {
  17. const url = str(value); if (!url) return '';
  18. if (/^https?:\/\//i.test(url)) return url.replace(/^http:\/\//i, 'https://');
  19. if (url.startsWith('//')) return `https:${url}`;
  20. return `https://img10.360buyimg.com/n1/${url.replace(/^\/+/, '')}`;
  21. }
  22. function attrs(value: unknown): ListingAttribute[] {
  23. return list(value).map(record).map((item) => ({ id: str(item['attrId']), name: str(item['attrName']), values: list(item['values']).map(record).map((child) => str(child['attrValueAlias'] || child['attrValue'])).filter(Boolean) })).filter((item) => item.name && item.values.length);
  24. }
  25. function status(value: unknown): string | null {
  26. if (value === null || value === undefined) return null;
  27. if (typeof value !== 'object') return str(value) || null;
  28. const item = record(value); return str(item['code'] ?? item['status'] ?? item['productStatusNew'] ?? item['productStatus'] ?? item['yn']) || null;
  29. }
  30. function htmlStructure(desktopHtml: string | null, mobileHtml: string | null): NonNullable<ListingSourceSnapshot['descriptionStructure']> {
  31. const canonical = [...new Set([desktopHtml, mobileHtml].map((value) => value?.trim()).filter((value): value is string => Boolean(value)))];
  32. const html = canonical.join('\n');
  33. const count = (pattern: RegExp): number => html.match(pattern)?.length ?? 0;
  34. const text = html.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/gi, ' ').replace(/\s+/g, ' ');
  35. return {
  36. observed: Boolean(html.trim()), imageCount: count(/<img\b/gi), videoCount: count(/<(?:video|iframe)\b/gi), headingCount: count(/<h[1-6]\b/gi),
  37. faqCandidateCount: (text.match(/(?:常见问题|FAQ|问[::]|答[::]|Q[::]|A[::])/gi) ?? []).length,
  38. };
  39. }
  40. export function normalizeJdListing(input: { workspaceId: string; shopId: string; row: UnknownRecord; detail: UnknownRecord; syncedAt?: string }): ListingSourceSnapshot {
  41. const info = record(input.detail['productInfo']);
  42. const productId = str(info['productId'] ?? input.row['productId'] ?? input.row['wareId'] ?? input.row['id']);
  43. if (!productId) throw new Error('jd_product_id_missing');
  44. const productTitle = record(info['productTitle']); const brandInfo = record(info['brandInfo']);
  45. const category = record(info['categoryDetail'] ?? info['categoryInfo']); const price = record(info['priceInfo']);
  46. const description = record(info['productDetailDesc']); const material = record(input.detail['material']);
  47. const desktopHtml = str(description['desc']) || null; const mobileHtml = str(description['mobileDesc']) || null;
  48. const imageGroups = list(material['mainImages']).map(record).map((group, groupIndex) => ({ groupIndex, groupId: str(group['uuid']) || null, images: list(group['imageInfoList']).map(record) }));
  49. const canonicalGroupIndex = imageGroups.find((group) => group.groupId === '0000000000')?.groupIndex ?? (imageGroups.length === 1 ? 0 : null);
  50. const canonicalPrimaryCount = canonicalGroupIndex === null ? 0 : imageGroups[canonicalGroupIndex]?.images.filter((item) => item['primaryFlag'] === true && Boolean(imageUrl(item['imgUrl']))).length ?? 0;
  51. const primaryConfirmed = canonicalGroupIndex !== null && canonicalPrimaryCount === 1;
  52. const canonicalPrimarySource = !primaryConfirmed ? 'unknown' as const : imageGroups[canonicalGroupIndex!]?.groupId === '0000000000' ? 'authoritative_field' as const : 'derived' as const;
  53. const groupRank = (index: number): number => index === canonicalGroupIndex ? 0 : index + 1;
  54. const images = imageGroups.flatMap((group) => group.images.map((item, itemIndex) => {
  55. const sourceOrder = num(item['orderSort']);
  56. const sourcePrimaryFlag = typeof item['primaryFlag'] === 'boolean' ? item['primaryFlag'] as boolean : null;
  57. const globalOrder = groupRank(group.groupIndex) * 1_000 + Math.max(0, (sourceOrder ?? itemIndex + 1) - 1);
  58. return {
  59. url: imageUrl(item['imgUrl']), order: globalOrder, groupId: group.groupId, groupIndex: group.groupIndex, itemIndex, sourceOrder, sourcePrimaryFlag,
  60. isPrimary: !primaryConfirmed ? null : group.groupIndex === canonicalGroupIndex ? sourcePrimaryFlag : false,
  61. primarySource: canonicalPrimarySource,
  62. gptFlag: typeof item['gptFlag'] === 'boolean' ? item['gptFlag'] as boolean : null, mediaType: 'image' as const, width: num(item['width']), height: num(item['height']),
  63. };
  64. })).filter((item) => Boolean(item.url)).sort((a, b) => (a.order ?? 999_999) - (b.order ?? 999_999));
  65. const uniqueImageMap = new Map<string, (typeof images)[number]>();
  66. for (const item of images) if (!uniqueImageMap.has(item.url)) uniqueImageMap.set(item.url, item);
  67. const uniqueImages = [...uniqueImageMap.values()];
  68. const rawSkus = list(input.detail['skuList']).map(record);
  69. const isValidSku = (sku: UnknownRecord): boolean => {
  70. const value = sku['valid'];
  71. if (value === undefined || value === null || value === '') return true;
  72. return value === true || value === 1 || value === '1' || String(value).toLocaleLowerCase() === 'true';
  73. };
  74. const skus = rawSkus.filter(isValidSku).map((sku) => ({
  75. skuId: str(sku['skuId']), name: str(sku['skuName']) || null, price: num(record(sku['priceInfo'])['jdPrice']), stock: num(sku['stockNum']),
  76. status: status(sku['skuEnableStatus'] ?? record(sku['skuStatus'])['onOffShelfStatus']), valid: true,
  77. enableStatus: status(sku['skuEnableStatus']), onOffShelfStatus: status(record(sku['skuStatus'])['onOffShelfStatus']),
  78. attributes: attrs(sku['goodsAttrInfos']), saleAttributes: attrs(sku['saleAttrs']),
  79. features: list(sku['features']).map(record).map((item) => ({ key: str(item['key']), value: str(item['value']) })).filter((item) => item.key || item.value),
  80. })).filter((sku) => sku.skuId);
  81. const adword = str(info['adword']) || null;
  82. const skuShortTitles = skus.flatMap((sku) => sku.features?.filter((item) => item.key === 'shortTitle' && item.value.trim()).map((item) => ({ skuId: sku.skuId, value: item.value.trim() })) ?? []);
  83. const sellingPoints = [
  84. ...(adword ? [{ value: adword, source: 'product_adword' as const, fieldPath: 'productInfo.adword', skuId: null }] : []),
  85. ...skuShortTitles.map((item) => ({ value: item.value, source: 'sku_short_title' as const, fieldPath: 'skuList[].features[key=shortTitle]', skuId: item.skuId })),
  86. ].filter((item, index, array) => array.findIndex((candidate) => candidate.value.normalize('NFKC').trim() === item.value.normalize('NFKC').trim()) === index);
  87. const categoryIds = [category['thirdCategoryId'], category['lastCategoryId'], category['categoryId']].map(str).filter((value, index, array) => value && array.indexOf(value) === index);
  88. const categoryNames = [category['thirdCategoryName'], category['lastCategoryName'], category['categoryName'], input.row['thirdCategoryName'], input.row['categoryName']].map(str).filter((value, index, array) => value && array.indexOf(value) === index);
  89. const categoryId = categoryIds.at(-1) ?? null;
  90. const categoryRule = resolveJdCategoryRule(categoryNames, categoryId);
  91. const rawForHash = { productInfo: info, material, skuList: input.detail['skuList'] ?? [], categoryContext: { categoryIds, categoryNames } };
  92. const syncedAt = input.syncedAt ?? new Date().toISOString();
  93. return {
  94. id: randomUUID(), workspaceId: input.workspaceId, platform: 'jd', shopId: input.shopId, productId,
  95. sourceHash: canonicalHash({ normalizerVersion: JD_LISTING_NORMALIZER_VERSION, raw: rawForHash }), normalizerVersion: JD_LISTING_NORMALIZER_VERSION,
  96. title: str(productTitle['title'] ?? info['productName'] ?? input.row['productName']) || null, titleBrandName: str(productTitle['titleBrandName']) || null,
  97. brand: { id: str(brandInfo['brandId']) || null, name: str(brandInfo['brandName']) || null }, categoryIds,
  98. categoryContext: { names: categoryNames, ...categoryRule },
  99. itemStatus: status(info['productStatus'] ?? input.row['productStatus']), price: { jd: num(price['jdPrice']), cost: num(price['costPrice']) },
  100. descriptions: { desktopHtml, mobileHtml }, descriptionStructure: htmlStructure(desktopHtml, mobileHtml),
  101. features: list(info['features']).map(record).map((item) => ({ key: str(item['key']), value: str(item['value']) })).filter((item) => item.key || item.value),
  102. attributes: attrs(info['goodsAttrInfos']), images: uniqueImages.filter((item) => item.groupIndex === canonicalGroupIndex),
  103. imageAssets: {
  104. defaultImages: uniqueImages.filter((item) => item.groupIndex === canonicalGroupIndex),
  105. skuImages: skus.map((sku) => ({ skuId: sku.skuId, images: uniqueImages.filter((item) => item.groupId === sku.skuId) })),
  106. whiteBackgroundImages: list(material['whiteBackGroundImages']).map(record).map((item, index) => ({
  107. url: imageUrl(item['imgUrl'] ?? item['imageUrl']), order: index, isPrimary: false, groupId: 'white_background', gptFlag: null, mediaType: 'image' as const,
  108. })).filter((item) => Boolean(item.url)),
  109. }, skus,
  110. dimensions: { length: num(info['length']), width: num(info['width']), height: num(info['height']), weight: num(info['weight']) },
  111. logistics: record(info['logisticsInfo']), afterService: { ...record(info['afterServiceInfo']), to7ReturnFlag: info['to7ReturnFlag'] },
  112. marketing: { adword, skuShortTitles, sellingPoints }, vocEvidence: [], sourceModifiedAt: epoch(info['modifiedTime']), syncedAt,
  113. detailStatus: Object.keys(info).length ? 'available' : 'empty',
  114. };
  115. }