jd-listing.normalizer.ts 4.5 KB

12345678910111213141516171819202122
  1. import { randomUUID } from 'node:crypto';
  2. import type { ListingAttribute, ListingSourceSnapshot } from '../domain.js';
  3. import { canonicalHash } from '../scoring/rule-engine.js';
  4. type UnknownRecord=Record<string,unknown>;
  5. const record=(value:unknown):UnknownRecord=>value&&typeof value==='object'&&!Array.isArray(value)?value as UnknownRecord:{};
  6. const list=(value:unknown):unknown[]=>Array.isArray(value)?value:[];
  7. const str=(value:unknown):string=>value===null||value===undefined?'':String(value).trim();
  8. const num=(value:unknown):number|null=>{const parsed=Number(value);return Number.isFinite(parsed)?parsed:null;};
  9. function epoch(value:unknown):string|null{const parsed=Number(value);if(!Number.isFinite(parsed)||parsed<=0)return null;const millis=parsed<10_000_000_000?parsed*1_000:parsed;const date=new Date(millis);return Number.isNaN(date.valueOf())?null:date.toISOString();}
  10. function imageUrl(value:unknown):string{const url=str(value);if(!url)return'';if(/^https?:\/\//i.test(url))return url;if(url.startsWith('//'))return`https:${url}`;return`https://img10.360buyimg.com/n1/${url.replace(/^\/+/, '')}`;}
  11. 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);}
  12. 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;}
  13. export function normalizeJdListing(input:{workspaceId:string;shopId:string;row:UnknownRecord;detail:UnknownRecord;syncedAt?:string}):ListingSourceSnapshot{
  14. const info=record(input.detail['productInfo']);const productId=str(info['productId']??input.row['productId']??input.row['wareId']??input.row['id']);if(!productId)throw new Error('jd_product_id_missing');
  15. const productTitle=record(info['productTitle']);const brandInfo=record(info['brandInfo']);const category=record(info['categoryDetail']);const price=record(info['priceInfo']);const description=record(info['productDetailDesc']);const material=record(input.detail['material']);
  16. const images=list(material['mainImages']).flatMap((group)=>list(record(group)['imageInfoList'])).map(record).map((item)=>({url:imageUrl(item['imgUrl']),order:num(item['orderSort']),isPrimary:typeof item['primaryFlag']==='boolean'?item['primaryFlag'] as boolean:null,gptFlag:typeof item['gptFlag']==='boolean'?item['gptFlag'] as boolean:null})).filter((item)=>Boolean(item.url));
  17. const uniqueImages=[...new Map(images.map((item)=>[item.url,item])).values()].sort((a,b)=>(a.order??999)-(b.order??999));
  18. const rawForHash={productInfo:info,material,skuList:input.detail['skuList']??[]};const syncedAt=input.syncedAt??new Date().toISOString();
  19. return{id:randomUUID(),workspaceId:input.workspaceId,platform:'jd',shopId:input.shopId,productId,sourceHash:canonicalHash(rawForHash),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:[category['thirdCategoryId'],category['lastCategoryId']].map(str).filter((value,index,array)=>value&&array.indexOf(value)===index),itemStatus:status(info['productStatus']??input.row['productStatus']),price:{jd:num(price['jdPrice']),cost:num(price['costPrice'])},descriptions:{desktopHtml:str(description['desc'])||null,mobileHtml:str(description['mobileDesc'])||null},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,skus:list(input.detail['skuList']).map(record).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']),attributes:attrs(sku['goodsAttrInfos'])})).filter((sku)=>sku.skuId),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']},sourceModifiedAt:epoch(info['modifiedTime']),syncedAt,detailStatus:Object.keys(info).length?'available':'empty'};
  20. }