enrich-local-competitors.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. import 'dotenv/config';
  2. import { mkdir, readFile, writeFile } from 'node:fs/promises';
  3. import { dirname, resolve } from 'node:path';
  4. import type {
  5. DomesticDataset,
  6. DomesticProduct,
  7. DomesticProductRelation,
  8. DomesticReview,
  9. } from '../src/types/domestic-dataset.js';
  10. import { adaptJdReviewResponse, JD_PRODUCT_COMMENTS_PATH } from '../src/modules/domestic-voc/adapters/jd-review.adapter.js';
  11. import { adaptJdSearchResponse, JD_PRODUCT_SEARCH_PATH } from '../src/modules/domestic-voc/adapters/jd-search.adapter.js';
  12. import { FmodeVocEcommerceClient } from '../src/modules/domestic-voc/upstream/fmode-client.js';
  13. interface BrandCategoryPair {
  14. key: string;
  15. brand: string;
  16. category: string;
  17. baseRelations: DomesticProductRelation[];
  18. }
  19. interface SearchResult {
  20. pair: BrandCategoryPair;
  21. products: DomesticProduct[];
  22. ok: boolean;
  23. }
  24. function defaultDatasetPath(): string {
  25. return resolve(process.cwd(), '..', '..', 'Saas-voc', 'src', 'assets', 'data', 'demashi-summary.json');
  26. }
  27. function positiveInteger(value: string | undefined, fallback: number): number {
  28. const parsed = Number(value);
  29. return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
  30. }
  31. async function mapLimit<T, R>(items: T[], concurrency: number, worker: (item: T, index: number) => Promise<R>): Promise<R[]> {
  32. const results = new Array<R>(items.length);
  33. let cursor = 0;
  34. async function run(): Promise<void> {
  35. while (cursor < items.length) {
  36. const index = cursor;
  37. cursor += 1;
  38. results[index] = await worker(items[index] as T, index);
  39. }
  40. }
  41. await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => run()));
  42. return results;
  43. }
  44. function buildPairs(relations: DomesticProductRelation[]): BrandCategoryPair[] {
  45. const grouped = new Map<string, DomesticProductRelation[]>();
  46. for (const relation of relations) {
  47. if (!relation.competitorBrand || !relation.category) continue;
  48. const key = `${relation.competitorBrand}\u0000${relation.category}`;
  49. const rows = grouped.get(key) ?? [];
  50. rows.push(relation);
  51. grouped.set(key, rows);
  52. }
  53. return [...grouped.entries()]
  54. .map(([key, rows]) => ({
  55. key,
  56. brand: rows[0]?.competitorBrand ?? '',
  57. category: rows[0]?.category ?? '',
  58. baseRelations: rows,
  59. }))
  60. .sort((left, right) => right.baseRelations.length - left.baseRelations.length || left.key.localeCompare(right.key, 'zh-CN'));
  61. }
  62. function relevanceScore(product: DomesticProduct, pair: BrandCategoryPair): number {
  63. const normalize = (value: string) => value.toLowerCase().replace(/^pop/i, '').replace(/[^a-z0-9\u3400-\u9fff]/g, '');
  64. const brand = normalize(pair.brand);
  65. const title = normalize(product.title);
  66. const shop = normalize(product.market?.shopName ?? '');
  67. let score = 0;
  68. if (brand && title.includes(brand)) score += 20;
  69. if (brand && shop.includes(brand)) score += 10;
  70. if (product.market?.monthSalesText) score += 4;
  71. if (product.market?.salesText) score += 2;
  72. if (product.market?.currentPrice) score += 1;
  73. if (/德玛仕|demashi/i.test(product.title)) score -= 100;
  74. return score;
  75. }
  76. function mergeProduct(existing: DomesticProduct | undefined, discovered: DomesticProduct): DomesticProduct {
  77. if (!existing) return discovered;
  78. return {
  79. ...existing,
  80. ...discovered,
  81. productId: existing.productId,
  82. productKey: existing.productKey,
  83. asin: existing.asin || discovered.asin,
  84. role: 'competitor',
  85. relationCount: existing.relationCount,
  86. };
  87. }
  88. async function main(): Promise<void> {
  89. const baseUrl = process.env.FMODE_BASE_URL?.trim();
  90. const apiKey = process.env.FMODE_API_KEY?.trim();
  91. if (!baseUrl || !apiKey) throw new Error('Company ecommerce gateway configuration is missing');
  92. const inputPath = resolve(process.env.COMPETITOR_BASE_DATASET_PATH || defaultDatasetPath());
  93. const outputPath = resolve(process.env.LOCAL_ENRICHED_DATASET_PATH || resolve(process.cwd(), 'logs', 'local-enriched-dataset.json'));
  94. const resultsPerPair = positiveInteger(process.env.COMPETITOR_RESULTS_PER_PAIR, 3);
  95. const reviewProductsPerPair = positiveInteger(process.env.COMPETITOR_REVIEW_PRODUCTS_PER_PAIR, 1);
  96. const concurrency = positiveInteger(process.env.COMPETITOR_ENRICH_CONCURRENCY, 3);
  97. const dataset = JSON.parse(await readFile(inputPath, 'utf8')) as DomesticDataset;
  98. dataset.reviews = Array.isArray(dataset.reviews) ? dataset.reviews : [];
  99. const collectedAt = new Date().toISOString();
  100. const baseRelations = dataset.relations.map((relation) => ({ ...relation, discoverySource: 'workbook' as const }));
  101. const pairs = buildPairs(baseRelations);
  102. const client = new FmodeVocEcommerceClient({
  103. baseUrl,
  104. apiKey,
  105. timeoutMs: positiveInteger(process.env.FMODE_TIMEOUT_MS, 30_000),
  106. retries: positiveInteger(process.env.FMODE_RETRIES, 2),
  107. });
  108. console.log(`[competitor-enrich] searching ${pairs.length} brand/category combinations`);
  109. const searches = await mapLimit(pairs, concurrency, async (pair): Promise<SearchResult> => {
  110. const keyword = `${pair.brand} ${pair.category}`;
  111. try {
  112. const response = await client.request<unknown>(JD_PRODUCT_SEARCH_PATH, { params: { keyword, page: 1 } });
  113. const products = adaptJdSearchResponse(response, { brand: pair.brand, category: pair.category, keyword, collectedAt })
  114. .sort((left, right) => relevanceScore(right, pair) - relevanceScore(left, pair))
  115. .filter((product) => relevanceScore(product, pair) >= 10)
  116. .slice(0, resultsPerPair);
  117. console.log(`[competitor-enrich] ${pair.brand} / ${pair.category}: ${products.length} products`);
  118. return { pair, products, ok: true };
  119. } catch (error) {
  120. const status = typeof error === 'object' && error && 'status' in error ? String(error.status ?? '') : '';
  121. console.warn(`[competitor-enrich] ${pair.brand} / ${pair.category}: request failed${status ? ` (${status})` : ''}`);
  122. return { pair, products: [], ok: false };
  123. }
  124. });
  125. const productsByKey = new Map(dataset.products.map((product) => [product.productKey, product]));
  126. const relationsByKey = new Map<string, DomesticProductRelation>(baseRelations.map((relation) => [relation.relationKey, relation]));
  127. const discoveredProductKeys = new Set<string>();
  128. for (const search of searches) {
  129. for (const product of search.products) {
  130. discoveredProductKeys.add(product.productKey);
  131. productsByKey.set(product.productKey, mergeProduct(productsByKey.get(product.productKey), product));
  132. const ownRelations = new Map(search.pair.baseRelations.map((relation) => [relation.ownProductKey, relation]));
  133. for (const relation of ownRelations.values()) {
  134. const relationKey = `${relation.ownProductKey}:${product.productKey}`;
  135. if (relationsByKey.has(relationKey)) continue;
  136. relationsByKey.set(relationKey, {
  137. relationKey,
  138. ownProductKey: relation.ownProductKey,
  139. ownProductId: relation.ownProductId,
  140. competitorProductKey: product.productKey,
  141. competitorProductId: product.productId,
  142. competitorBrand: search.pair.brand,
  143. category: search.pair.category,
  144. discoverySource: 'brand_category_search',
  145. ...(product.market?.searchKeyword ? { searchKeyword: product.market.searchKeyword } : {}),
  146. discoveredAt: collectedAt,
  147. });
  148. }
  149. }
  150. }
  151. const reviewTargets = [...new Map(searches.flatMap((search) => search.products.slice(0, reviewProductsPerPair))
  152. .map((product) => [product.productKey, product])).values()];
  153. console.log(`[competitor-enrich] collecting reviews for ${reviewTargets.length} representative products`);
  154. const reviewPages = await mapLimit(reviewTargets, concurrency, async (product) => {
  155. try {
  156. const response = await client.request<unknown>(JD_PRODUCT_COMMENTS_PATH, { params: { itemId: product.productId, page: 1 } });
  157. const reviews = adaptJdReviewResponse(response, product.productId, 1).reviews;
  158. console.log(`[competitor-enrich] ${product.productId}: ${reviews.length} reviews`);
  159. return { product, reviews, ok: true };
  160. } catch (error) {
  161. const status = typeof error === 'object' && error && 'status' in error ? String(error.status ?? '') : '';
  162. console.warn(`[competitor-enrich] ${product.productId}: review request failed${status ? ` (${status})` : ''}`);
  163. return { product, reviews: [] as DomesticReview[], ok: false };
  164. }
  165. });
  166. const reviewByKey = new Map(dataset.reviews.map((review) => [`${review.productId}:${review.reviewId}`, review]));
  167. for (const page of reviewPages) {
  168. for (const review of page.reviews) reviewByKey.set(`${review.productId}:${review.reviewId}`, review);
  169. }
  170. const relations = [...relationsByKey.values()];
  171. const relationCounts = new Map<string, Set<string>>();
  172. for (const relation of relations) {
  173. const ownSet = relationCounts.get(relation.ownProductKey) ?? new Set<string>();
  174. ownSet.add(relation.competitorProductKey);
  175. relationCounts.set(relation.ownProductKey, ownSet);
  176. const competitorSet = relationCounts.get(relation.competitorProductKey) ?? new Set<string>();
  177. competitorSet.add(relation.ownProductKey);
  178. relationCounts.set(relation.competitorProductKey, competitorSet);
  179. }
  180. const products = [...productsByKey.values()].map((product) => ({
  181. ...product,
  182. relationCount: relationCounts.get(product.productKey)?.size ?? product.relationCount,
  183. }));
  184. const relationsByOwnId = new Map<string, DomesticProductRelation[]>();
  185. for (const relation of relations) {
  186. const rows = relationsByOwnId.get(relation.ownProductId) ?? [];
  187. rows.push(relation);
  188. relationsByOwnId.set(relation.ownProductId, rows);
  189. }
  190. const mappingGroups = dataset.mappingGroups.map((group) => ({
  191. ...group,
  192. competitors: relationsByOwnId.get(group.ownProductId) ?? group.competitors,
  193. }));
  194. const reviews = [...reviewByKey.values()];
  195. const successfulQueries = searches.filter((search) => search.ok).length;
  196. const successfulReviewQueries = reviewPages.filter((page) => page.ok).length;
  197. const output: DomesticDataset = {
  198. ...dataset,
  199. generatedAt: collectedAt,
  200. products,
  201. relations,
  202. mappingGroups,
  203. reviews,
  204. summary: {
  205. ...dataset.summary,
  206. relations: relations.length,
  207. uniqueCompetitorProducts: products.filter((product) => product.role === 'competitor').length,
  208. reviewCount: reviews.length,
  209. },
  210. enrichment: {
  211. status: successfulQueries === pairs.length && successfulReviewQueries === reviewTargets.length ? 'complete' : 'partial',
  212. queryCount: pairs.length,
  213. successfulQueries,
  214. discoveredProducts: discoveredProductKeys.size,
  215. reviewedProducts: new Set(reviews.map((review) => review.productId)).size,
  216. collectedReviews: reviews.length,
  217. collectedAt,
  218. },
  219. };
  220. await mkdir(dirname(outputPath), { recursive: true });
  221. await writeFile(outputPath, `${JSON.stringify(output, null, 2)}\n`, 'utf8');
  222. console.log(`[competitor-enrich] wrote ${output.products.length} products, ${output.relations.length} relations, and ${output.reviews.length} reviews`);
  223. console.log(`[competitor-enrich] output ${outputPath}`);
  224. }
  225. main().catch((error) => {
  226. console.error('[competitor-enrich] failed', error instanceof Error ? error.message : error);
  227. process.exitCode = 1;
  228. });