brief-fixture-smoke.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const { parseBrief } = require('../mcp/src/features/tihao-sourcing/brief-parser');
  5. function main() {
  6. const repoRoot = path.resolve(__dirname, '..', '..');
  7. const docsDir = path.join(repoRoot, 'docs');
  8. const dhaBrief = path.join(repoRoot, 'dha_brief.xlsx');
  9. if (fs.existsSync(dhaBrief)) {
  10. const parsed = parseBrief({ brief: dhaBrief });
  11. assert(parsed.brand === 'DHA', 'DHA xlsx should parse product as brand');
  12. assert(parsed.platforms.length === 1 && parsed.platforms[0] === 'xiaohongshu', 'DHA xlsx should parse Xiaohongshu platform');
  13. assert(parsed.keywords.includes('DHA') && parsed.keywords.includes('营养师'), 'DHA xlsx should parse maternal nutrition keywords');
  14. assert(parsed.performanceRequirements.includes('CPE≤3'), 'DHA xlsx should parse CPE requirement');
  15. assert(parsed.referenceLinks.length === 4, 'DHA xlsx should parse 4 reference account links');
  16. assert(parsed.referenceStyleAnchors.length >= 4, 'DHA xlsx should build reference style anchors');
  17. assert(parsed.referenceSignals.includes('干货科普') && parsed.referenceSignals.includes('618合集'), 'DHA xlsx should parse reference signals');
  18. }
  19. const markdownBrief = findBriefFile(docsDir, '.md');
  20. if (markdownBrief) {
  21. const parsed = parseBrief({ brief: markdownBrief });
  22. assert(parsed.brand.includes('花漾肌研'), 'markdown brief should parse brand');
  23. assert(parsed.targetCount === 32, 'markdown brief should parse target count sum');
  24. assert(parsed.platformTargets.xiaohongshu === 20, 'markdown brief should parse Xiaohongshu target');
  25. assert(parsed.platformTargets.douyin === 8, 'markdown brief should parse Douyin target');
  26. assert(parsed.platformTargets.bilibili === 4, 'markdown brief should parse Bilibili target');
  27. assert(parsed.fanRange.min === 20000 && parsed.fanRange.max === 500000, 'markdown brief should parse fan range');
  28. assert(parsed.referenceSignals.includes('素人感'), 'markdown brief should parse reference style preference');
  29. }
  30. const htmlXlsBrief = findBriefFile(docsDir, '.xls');
  31. if (htmlXlsBrief) {
  32. const parsed = parseBrief({ brief: htmlXlsBrief });
  33. assert(parsed.brand.includes('花漾肌研'), 'html xls brief should parse brand from key-value cells');
  34. assert(parsed.targetCount === 32, 'html xls brief should parse target count sum');
  35. assert(parsed.platformTargets.xiaohongshu === 20, 'html xls brief should parse Xiaohongshu target');
  36. assert(parsed.platformTargets.douyin === 8, 'html xls brief should parse Douyin target');
  37. assert(parsed.platformTargets.bilibili === 4, 'html xls brief should parse Bilibili target');
  38. assert(parsed.fanRange.min === 20000 && parsed.fanRange.max === 500000, 'html xls brief should parse horizontal fan range table');
  39. assert(parsed.referenceSignals.includes('真实测评') && parsed.referenceSignals.includes('成分党'), 'html xls brief should parse reference keyword signals');
  40. }
  41. assertNaturalLanguageBriefCoverage();
  42. console.log('brief fixture smoke ok');
  43. }
  44. function findBriefFile(dir, ext) {
  45. if (!fs.existsSync(dir)) return '';
  46. return fs.readdirSync(dir)
  47. .map(name => path.join(dir, name))
  48. .find(file => fs.statSync(file).isFile() && path.basename(file).includes('Brief') && file.endsWith(ext)) || '';
  49. }
  50. function assertNaturalLanguageBriefCoverage() {
  51. const cases = [
  52. {
  53. name: 'skincare',
  54. text: [
  55. '客户名称:明澈肌研',
  56. '产品:敏感肌屏障修护精华',
  57. '平台与人数:小红书 6 人,抖音 2 人',
  58. '关键词:敏感肌、修护、成分、真实测评',
  59. '粉丝范围:2万-50万',
  60. '预算范围:3000-18000',
  61. '偏好真实体验、素人感,不要硬广。'
  62. ].join('\n'),
  63. assert(parsed) {
  64. assert(parsed.brand === '明澈肌研', 'skincare should parse brand');
  65. assert(parsed.category === '护肤美妆', 'skincare should infer beauty category');
  66. assert(parsed.platformTargets.xiaohongshu === 6 && parsed.platformTargets.douyin === 2, 'skincare should parse platform targets');
  67. assert(parsed.fanRange.min === 20000 && parsed.fanRange.max === 500000, 'skincare should parse fan range');
  68. assert(parsed.keywords.includes('敏感肌') && parsed.keywords.includes('修护'), 'skincare should parse skincare keywords');
  69. }
  70. },
  71. {
  72. name: 'real-douyin-beauty-brief',
  73. text: [
  74. '平台:抖音',
  75. '账号类型:美妆护肤',
  76. '粉丝要求:1k-10w',
  77. '单个预算:2000内',
  78. '数据要求:主页互动数据200+',
  79. '备注:产品是客单价比较高的面膜,想找调性高的美妆护肤博主,想要主页之前合作过大牌护肤品的',
  80. '',
  81. '参考账号:',
  82. 'https://www.douyin.com/user/MS4wLjABAAAAZ31qrM27_cxcps8cc2sGKlnUWIOUDuVmaGB7BhNEpYAcZfhxjgicQ3VVXfRNgcWv'
  83. ].join('\n'),
  84. assert(parsed) {
  85. assert(parsed.category === '护肤美妆', 'real douyin beauty brief should infer beauty category');
  86. assert(parsed.platforms.length === 1 && parsed.platforms[0] === 'douyin', 'real douyin beauty brief should parse Douyin only');
  87. assert(parsed.fanRange.min === 1000 && parsed.fanRange.max === 100000, 'real douyin beauty brief should parse 1k-10w fan range');
  88. assert(parsed.budgetRange.min === 0 && parsed.budgetRange.max === 2000, 'real douyin beauty brief should parse 2000 upper-bound budget');
  89. assert(parsed.performanceRequirements.includes('主页互动数据≥200'), 'real douyin beauty brief should parse homepage interaction requirement');
  90. assert(parsed.keywords.includes('护肤') && parsed.keywords.includes('美妆') && parsed.keywords.includes('面膜'), 'real douyin beauty brief should keep beauty keywords');
  91. assert(parsed.referenceLinks.length === 1, 'real douyin beauty brief should parse reference account link');
  92. }
  93. },
  94. {
  95. name: 'healthy-snack',
  96. text: [
  97. '品牌:谷物星球',
  98. '本次推广产品:高蛋白低脂燕麦棒',
  99. '目标:找小红书 5 个、抖音 3 个健身/控糖/办公室零食博主',
  100. '粉丝数范围:1万-30万',
  101. '单篇预算:1000-8000',
  102. '风格偏生活化开箱、真实吃法、成分党,不要医美和夸大减肥。'
  103. ].join('\n'),
  104. assert(parsed) {
  105. assert(parsed.brand === '谷物星球', 'healthy snack should parse brand');
  106. assert(parsed.category === '食品餐饮', 'healthy snack should infer food category instead of beauty');
  107. assert(parsed.targetCount === 8, 'healthy snack should parse platform target sum');
  108. assert(parsed.fanRange.min === 10000 && parsed.fanRange.max === 300000, 'healthy snack should parse fan range');
  109. assert(parsed.keywords.includes('控糖') && parsed.keywords.includes('办公室零食'), 'healthy snack should parse food creator keywords');
  110. assert(parsed.exclusions.includes('医美') && parsed.exclusions.includes('夸大减肥'), 'healthy snack should parse exclusions');
  111. }
  112. },
  113. {
  114. name: 'home-cleaning',
  115. text: [
  116. '客户:净住研究所',
  117. '产品:厨房重油污清洁喷雾',
  118. '需求人数:小红书 8 人',
  119. '要求:家居清洁、收纳、租房生活、妈妈人群,粉丝 5000-20万,预算 800-5000',
  120. '排除:硬广、低质搬运、争议账号。'
  121. ].join('\n'),
  122. assert(parsed) {
  123. assert(parsed.brand === '净住研究所', 'home cleaning should parse brand');
  124. assert(parsed.category === '家居生活', 'home cleaning should infer home category');
  125. assert(parsed.platforms.length === 1 && parsed.platforms[0] === 'xiaohongshu', 'home cleaning should parse Xiaohongshu only');
  126. assert(parsed.fanRange.min === 5000 && parsed.fanRange.max === 200000, 'home cleaning should not read budget as fan range');
  127. assert(parsed.budgetRange.min === 800 && parsed.budgetRange.max === 5000, 'home cleaning should parse budget on same line');
  128. assert(parsed.keywords.includes('清洁') && parsed.keywords.includes('收纳'), 'home cleaning should parse home keywords');
  129. }
  130. },
  131. {
  132. name: 'b2b-saas',
  133. text: [
  134. '品牌:FlowOps',
  135. '产品:企业项目管理 SaaS',
  136. '需求:找 B站 4 个、抖音 2 个效率工具/创业/企业管理类内容创作者',
  137. '粉丝:1万-40万',
  138. '预算:3000-20000',
  139. '偏好教程型、案例型、职场效率,不要纯娱乐账号。'
  140. ].join('\n'),
  141. assert(parsed) {
  142. assert(parsed.brand === 'FlowOps', 'B2B SaaS should parse brand');
  143. assert(parsed.category === 'B2B软件', 'B2B SaaS should infer software category');
  144. assert(parsed.platformTargets.bilibili === 4 && parsed.platformTargets.douyin === 2, 'B2B SaaS should parse Bilibili and Douyin targets');
  145. assert(parsed.keywords.includes('效率工具') && parsed.keywords.includes('企业管理'), 'B2B SaaS should parse software keywords');
  146. assert(parsed.exclusions.includes('纯娱乐') || parsed.exclusions.includes('纯娱乐账号'), 'B2B SaaS should parse entertainment exclusion');
  147. }
  148. },
  149. {
  150. name: 'local-food',
  151. text: [
  152. '品牌:山城小面加盟',
  153. '任务:找本地生活探店、餐饮创业类达人,重庆/成都优先',
  154. '平台:抖音 10 人',
  155. '粉丝 2万-80万,预算 2000-12000',
  156. '重点看同城转化、门店拍摄经验,排除泛娱乐流量号。'
  157. ].join('\n'),
  158. assert(parsed) {
  159. assert(parsed.brand === '山城小面加盟', 'local food should parse brand');
  160. assert(parsed.category === '食品餐饮', 'local food should infer food/local category');
  161. assert(parsed.platforms.length === 1 && parsed.platforms[0] === 'douyin', 'local food should parse Douyin only');
  162. assert(parsed.targetCount === 10, 'local food should parse target count');
  163. assert(parsed.fanRange.min === 20000 && parsed.fanRange.max === 800000, 'local food should parse fan range and ignore budget');
  164. assert(parsed.region.includes('重庆') && parsed.region.includes('成都'), 'local food should parse city priority');
  165. assert(parsed.keywords.includes('本地生活') && parsed.keywords.includes('同城转化'), 'local food should parse local conversion keywords');
  166. }
  167. },
  168. {
  169. name: 'fashion-product-chat-refinement',
  170. text: [
  171. '客户:南栖衣橱',
  172. 'Brief:找精致生活/时尚穿搭类账号,小红书 4 人',
  173. '粉丝:1万-20万,预算:300-3000',
  174. '数据要求:CPE≤3,CP1≤20,CPM≤80',
  175. '不要纯自拍。'
  176. ].join('\n'),
  177. input: {
  178. productIntro: '产品是港风通勤女装,偏成熟风、轻熟高级感,不是泛生活方式。',
  179. chatRecords: [
  180. '客户说要看最近一个月有更新,平均点赞不要百赞以下。',
  181. '如果多篇都是同一个人评论,怀疑刷评,先不要强推。'
  182. ],
  183. referenceAccounts: ['港风穿搭Alice']
  184. },
  185. assert(parsed) {
  186. assert(parsed.brand === '南栖衣橱', 'fashion should parse brand');
  187. assert(parsed.category === '服饰穿搭', 'fashion product/chat should refine broad lifestyle into apparel category');
  188. assert(parsed.contextSources.product && parsed.contextSources.chat && parsed.contextSources.reference, 'fashion should mark product/chat/reference context sources');
  189. assert(parsed.platformTargets.xiaohongshu === 4, 'fashion should parse Xiaohongshu target');
  190. assert(parsed.keywords.includes('港风') && parsed.keywords.includes('成熟风'), 'fashion should extract subtype keywords from product/chat context');
  191. assert(parsed.keywords.includes('时尚穿搭') || parsed.keywords.includes('穿搭'), 'fashion should keep broad fashion keyword');
  192. assert(parsed.exclusions.includes('纯自拍') && !parsed.exclusions.includes('穿搭'), 'fashion should not treat wearing/fashion as an exclusion');
  193. assert(parsed.performanceRequirements.includes('CPE≤3') && parsed.performanceRequirements.includes('CP1≤20') && parsed.performanceRequirements.includes('CPM≤80'), 'fashion should parse cost metrics');
  194. assert(parsed.performanceRequirements.includes('近30天平均点赞≥100'), 'fashion should turn low-like warning into average-like requirement');
  195. assert(parsed.performanceRequirements.includes('复核数据真实性/重复评论风险'), 'fashion should parse repeated-comment authenticity review');
  196. }
  197. }
  198. ];
  199. for (const item of cases) {
  200. const parsed = parseBrief({ briefText: item.text, ...(item.input || {}) });
  201. item.assert(parsed);
  202. }
  203. }
  204. function assert(condition, message) {
  205. if (!condition) throw new Error(message);
  206. }
  207. main();