import { randomUUID } from 'node:crypto'; import type { ListingAttribute, ListingSourceSnapshot } from '../domain.js'; import { canonicalHash } from '../scoring/rule-engine.js'; import { resolveJdCategoryRule } from '../scoring/jd-category-rules.js'; type UnknownRecord = Record; export const JD_LISTING_NORMALIZER_VERSION = 'jd-listing-v5'; const record = (value: unknown): UnknownRecord => value && typeof value === 'object' && !Array.isArray(value) ? value as UnknownRecord : {}; const list = (value: unknown): unknown[] => Array.isArray(value) ? value : []; const str = (value: unknown): string => value === null || value === undefined ? '' : String(value).trim(); const num = (value: unknown): number | null => { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; }; function epoch(value: unknown): string | null { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) return null; const date = new Date(parsed < 10_000_000_000 ? parsed * 1_000 : parsed); return Number.isNaN(date.valueOf()) ? null : date.toISOString(); } function imageUrl(value: unknown): string { const url = str(value); if (!url) return ''; if (/^https?:\/\//i.test(url)) return url.replace(/^http:\/\//i, 'https://'); if (url.startsWith('//')) return `https:${url}`; return `https://img10.360buyimg.com/n1/${url.replace(/^\/+/, '')}`; } function attrs(value: unknown): ListingAttribute[] { 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); } function status(value: unknown): string | null { if (value === null || value === undefined) return null; if (typeof value !== 'object') return str(value) || null; const item = record(value); return str(item['code'] ?? item['status'] ?? item['productStatusNew'] ?? item['productStatus'] ?? item['yn']) || null; } function htmlStructure(desktopHtml: string | null, mobileHtml: string | null): NonNullable { const canonical = [...new Set([desktopHtml, mobileHtml].map((value) => value?.trim()).filter((value): value is string => Boolean(value)))]; const html = canonical.join('\n'); const count = (pattern: RegExp): number => html.match(pattern)?.length ?? 0; const text = html.replace(/<[^>]+>/g, ' ').replace(/ /gi, ' ').replace(/\s+/g, ' '); return { observed: Boolean(html.trim()), imageCount: count(/ ({ groupIndex, groupId: str(group['uuid']) || null, images: list(group['imageInfoList']).map(record) })); const canonicalGroupIndex = imageGroups.find((group) => group.groupId === '0000000000')?.groupIndex ?? (imageGroups.length === 1 ? 0 : null); const canonicalPrimaryCount = canonicalGroupIndex === null ? 0 : imageGroups[canonicalGroupIndex]?.images.filter((item) => item['primaryFlag'] === true && Boolean(imageUrl(item['imgUrl']))).length ?? 0; const primaryConfirmed = canonicalGroupIndex !== null && canonicalPrimaryCount === 1; const canonicalPrimarySource = !primaryConfirmed ? 'unknown' as const : imageGroups[canonicalGroupIndex!]?.groupId === '0000000000' ? 'authoritative_field' as const : 'derived' as const; const groupRank = (index: number): number => index === canonicalGroupIndex ? 0 : index + 1; const images = imageGroups.flatMap((group) => group.images.map((item, itemIndex) => { const sourceOrder = num(item['orderSort']); const sourcePrimaryFlag = typeof item['primaryFlag'] === 'boolean' ? item['primaryFlag'] as boolean : null; const globalOrder = groupRank(group.groupIndex) * 1_000 + Math.max(0, (sourceOrder ?? itemIndex + 1) - 1); return { url: imageUrl(item['imgUrl']), order: globalOrder, groupId: group.groupId, groupIndex: group.groupIndex, itemIndex, sourceOrder, sourcePrimaryFlag, isPrimary: !primaryConfirmed ? null : group.groupIndex === canonicalGroupIndex ? sourcePrimaryFlag : false, primarySource: canonicalPrimarySource, gptFlag: typeof item['gptFlag'] === 'boolean' ? item['gptFlag'] as boolean : null, mediaType: 'image' as const, width: num(item['width']), height: num(item['height']), }; })).filter((item) => Boolean(item.url)).sort((a, b) => (a.order ?? 999_999) - (b.order ?? 999_999)); const uniqueImageMap = new Map(); for (const item of images) if (!uniqueImageMap.has(item.url)) uniqueImageMap.set(item.url, item); const uniqueImages = [...uniqueImageMap.values()]; const rawSkus = list(input.detail['skuList']).map(record); const isValidSku = (sku: UnknownRecord): boolean => { const value = sku['valid']; if (value === undefined || value === null || value === '') return true; return value === true || value === 1 || value === '1' || String(value).toLocaleLowerCase() === 'true'; }; const skus = rawSkus.filter(isValidSku).map((sku) => ({ skuId: str(sku['skuId']), name: str(sku['skuName']) || null, price: num(record(sku['priceInfo'])['jdPrice']), stock: num(sku['stockNum']), status: status(sku['skuEnableStatus'] ?? record(sku['skuStatus'])['onOffShelfStatus']), valid: true, enableStatus: status(sku['skuEnableStatus']), onOffShelfStatus: status(record(sku['skuStatus'])['onOffShelfStatus']), attributes: attrs(sku['goodsAttrInfos']), saleAttributes: attrs(sku['saleAttrs']), features: list(sku['features']).map(record).map((item) => ({ key: str(item['key']), value: str(item['value']) })).filter((item) => item.key || item.value), })).filter((sku) => sku.skuId); const adword = str(info['adword']) || null; const skuShortTitles = skus.flatMap((sku) => sku.features?.filter((item) => item.key === 'shortTitle' && item.value.trim()).map((item) => ({ skuId: sku.skuId, value: item.value.trim() })) ?? []); const sellingPoints = [ ...(adword ? [{ value: adword, source: 'product_adword' as const, fieldPath: 'productInfo.adword', skuId: null }] : []), ...skuShortTitles.map((item) => ({ value: item.value, source: 'sku_short_title' as const, fieldPath: 'skuList[].features[key=shortTitle]', skuId: item.skuId })), ].filter((item, index, array) => array.findIndex((candidate) => candidate.value.normalize('NFKC').trim() === item.value.normalize('NFKC').trim()) === index); const categoryIds = [category['thirdCategoryId'], category['lastCategoryId'], category['categoryId']].map(str).filter((value, index, array) => value && array.indexOf(value) === index); 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); const categoryId = categoryIds.at(-1) ?? null; const categoryRule = resolveJdCategoryRule(categoryNames, categoryId); const rawForHash = { productInfo: info, material, skuList: input.detail['skuList'] ?? [], categoryContext: { categoryIds, categoryNames } }; const syncedAt = input.syncedAt ?? new Date().toISOString(); return { id: randomUUID(), workspaceId: input.workspaceId, platform: 'jd', shopId: input.shopId, productId, sourceHash: canonicalHash({ normalizerVersion: JD_LISTING_NORMALIZER_VERSION, raw: rawForHash }), normalizerVersion: JD_LISTING_NORMALIZER_VERSION, title: str(productTitle['title'] ?? info['productName'] ?? input.row['productName']) || null, titleBrandName: str(productTitle['titleBrandName']) || null, brand: { id: str(brandInfo['brandId']) || null, name: str(brandInfo['brandName']) || null }, categoryIds, categoryContext: { names: categoryNames, ...categoryRule }, itemStatus: status(info['productStatus'] ?? input.row['productStatus']), price: { jd: num(price['jdPrice']), cost: num(price['costPrice']) }, descriptions: { desktopHtml, mobileHtml }, descriptionStructure: htmlStructure(desktopHtml, mobileHtml), features: list(info['features']).map(record).map((item) => ({ key: str(item['key']), value: str(item['value']) })).filter((item) => item.key || item.value), attributes: attrs(info['goodsAttrInfos']), images: uniqueImages.filter((item) => item.groupIndex === canonicalGroupIndex), imageAssets: { defaultImages: uniqueImages.filter((item) => item.groupIndex === canonicalGroupIndex), skuImages: skus.map((sku) => ({ skuId: sku.skuId, images: uniqueImages.filter((item) => item.groupId === sku.skuId) })), whiteBackgroundImages: list(material['whiteBackGroundImages']).map(record).map((item, index) => ({ url: imageUrl(item['imgUrl'] ?? item['imageUrl']), order: index, isPrimary: false, groupId: 'white_background', gptFlag: null, mediaType: 'image' as const, })).filter((item) => Boolean(item.url)), }, skus, dimensions: { length: num(info['length']), width: num(info['width']), height: num(info['height']), weight: num(info['weight']) }, logistics: record(info['logisticsInfo']), afterService: { ...record(info['afterServiceInfo']), to7ReturnFlag: info['to7ReturnFlag'] }, marketing: { adword, skuShortTitles, sellingPoints }, vocEvidence: [], sourceModifiedAt: epoch(info['modifiedTime']), syncedAt, detailStatus: Object.keys(info).length ? 'available' : 'empty', }; }