#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const { parseBrief } = require('../mcp/src/features/tihao-sourcing/brief-parser'); function main() { const repoRoot = path.resolve(__dirname, '..', '..'); const docsDir = path.join(repoRoot, 'docs'); const dhaBrief = path.join(repoRoot, 'dha_brief.xlsx'); if (fs.existsSync(dhaBrief)) { const parsed = parseBrief({ brief: dhaBrief }); assert(parsed.brand === 'DHA', 'DHA xlsx should parse product as brand'); assert(parsed.platforms.length === 1 && parsed.platforms[0] === 'xiaohongshu', 'DHA xlsx should parse Xiaohongshu platform'); assert(parsed.keywords.includes('DHA') && parsed.keywords.includes('营养师'), 'DHA xlsx should parse maternal nutrition keywords'); assert(parsed.performanceRequirements.includes('CPE≤3'), 'DHA xlsx should parse CPE requirement'); assert(parsed.referenceLinks.length === 4, 'DHA xlsx should parse 4 reference account links'); assert(parsed.referenceStyleAnchors.length >= 4, 'DHA xlsx should build reference style anchors'); assert(parsed.referenceSignals.includes('干货科普') && parsed.referenceSignals.includes('618合集'), 'DHA xlsx should parse reference signals'); } const markdownBrief = findBriefFile(docsDir, '.md'); if (markdownBrief) { const parsed = parseBrief({ brief: markdownBrief }); assert(parsed.brand.includes('花漾肌研'), 'markdown brief should parse brand'); assert(parsed.targetCount === 32, 'markdown brief should parse target count sum'); assert(parsed.platformTargets.xiaohongshu === 20, 'markdown brief should parse Xiaohongshu target'); assert(parsed.platformTargets.douyin === 8, 'markdown brief should parse Douyin target'); assert(parsed.platformTargets.bilibili === 4, 'markdown brief should parse Bilibili target'); assert(parsed.fanRange.min === 20000 && parsed.fanRange.max === 500000, 'markdown brief should parse fan range'); assert(parsed.referenceSignals.includes('素人感'), 'markdown brief should parse reference style preference'); } const htmlXlsBrief = findBriefFile(docsDir, '.xls'); if (htmlXlsBrief) { const parsed = parseBrief({ brief: htmlXlsBrief }); assert(parsed.brand.includes('花漾肌研'), 'html xls brief should parse brand from key-value cells'); assert(parsed.targetCount === 32, 'html xls brief should parse target count sum'); assert(parsed.platformTargets.xiaohongshu === 20, 'html xls brief should parse Xiaohongshu target'); assert(parsed.platformTargets.douyin === 8, 'html xls brief should parse Douyin target'); assert(parsed.platformTargets.bilibili === 4, 'html xls brief should parse Bilibili target'); assert(parsed.fanRange.min === 20000 && parsed.fanRange.max === 500000, 'html xls brief should parse horizontal fan range table'); assert(parsed.referenceSignals.includes('真实测评') && parsed.referenceSignals.includes('成分党'), 'html xls brief should parse reference keyword signals'); } assertNaturalLanguageBriefCoverage(); console.log('brief fixture smoke ok'); } function findBriefFile(dir, ext) { if (!fs.existsSync(dir)) return ''; return fs.readdirSync(dir) .map(name => path.join(dir, name)) .find(file => fs.statSync(file).isFile() && path.basename(file).includes('Brief') && file.endsWith(ext)) || ''; } function assertNaturalLanguageBriefCoverage() { const cases = [ { name: 'skincare', text: [ '客户名称:明澈肌研', '产品:敏感肌屏障修护精华', '平台与人数:小红书 6 人,抖音 2 人', '关键词:敏感肌、修护、成分、真实测评', '粉丝范围:2万-50万', '预算范围:3000-18000', '偏好真实体验、素人感,不要硬广。' ].join('\n'), assert(parsed) { assert(parsed.brand === '明澈肌研', 'skincare should parse brand'); assert(parsed.category === '护肤美妆', 'skincare should infer beauty category'); assert(parsed.platformTargets.xiaohongshu === 6 && parsed.platformTargets.douyin === 2, 'skincare should parse platform targets'); assert(parsed.fanRange.min === 20000 && parsed.fanRange.max === 500000, 'skincare should parse fan range'); assert(parsed.keywords.includes('敏感肌') && parsed.keywords.includes('修护'), 'skincare should parse skincare keywords'); } }, { name: 'real-douyin-beauty-brief', text: [ '平台:抖音', '账号类型:美妆护肤', '粉丝要求:1k-10w', '单个预算:2000内', '数据要求:主页互动数据200+', '备注:产品是客单价比较高的面膜,想找调性高的美妆护肤博主,想要主页之前合作过大牌护肤品的', '', '参考账号:', 'https://www.douyin.com/user/MS4wLjABAAAAZ31qrM27_cxcps8cc2sGKlnUWIOUDuVmaGB7BhNEpYAcZfhxjgicQ3VVXfRNgcWv' ].join('\n'), assert(parsed) { assert(parsed.category === '护肤美妆', 'real douyin beauty brief should infer beauty category'); assert(parsed.platforms.length === 1 && parsed.platforms[0] === 'douyin', 'real douyin beauty brief should parse Douyin only'); assert(parsed.fanRange.min === 1000 && parsed.fanRange.max === 100000, 'real douyin beauty brief should parse 1k-10w fan range'); assert(parsed.budgetRange.min === 0 && parsed.budgetRange.max === 2000, 'real douyin beauty brief should parse 2000 upper-bound budget'); assert(parsed.performanceRequirements.includes('主页互动数据≥200'), 'real douyin beauty brief should parse homepage interaction requirement'); assert(parsed.keywords.includes('护肤') && parsed.keywords.includes('美妆') && parsed.keywords.includes('面膜'), 'real douyin beauty brief should keep beauty keywords'); assert(parsed.referenceLinks.length === 1, 'real douyin beauty brief should parse reference account link'); } }, { name: 'healthy-snack', text: [ '品牌:谷物星球', '本次推广产品:高蛋白低脂燕麦棒', '目标:找小红书 5 个、抖音 3 个健身/控糖/办公室零食博主', '粉丝数范围:1万-30万', '单篇预算:1000-8000', '风格偏生活化开箱、真实吃法、成分党,不要医美和夸大减肥。' ].join('\n'), assert(parsed) { assert(parsed.brand === '谷物星球', 'healthy snack should parse brand'); assert(parsed.category === '食品餐饮', 'healthy snack should infer food category instead of beauty'); assert(parsed.targetCount === 8, 'healthy snack should parse platform target sum'); assert(parsed.fanRange.min === 10000 && parsed.fanRange.max === 300000, 'healthy snack should parse fan range'); assert(parsed.keywords.includes('控糖') && parsed.keywords.includes('办公室零食'), 'healthy snack should parse food creator keywords'); assert(parsed.exclusions.includes('医美') && parsed.exclusions.includes('夸大减肥'), 'healthy snack should parse exclusions'); } }, { name: 'home-cleaning', text: [ '客户:净住研究所', '产品:厨房重油污清洁喷雾', '需求人数:小红书 8 人', '要求:家居清洁、收纳、租房生活、妈妈人群,粉丝 5000-20万,预算 800-5000', '排除:硬广、低质搬运、争议账号。' ].join('\n'), assert(parsed) { assert(parsed.brand === '净住研究所', 'home cleaning should parse brand'); assert(parsed.category === '家居生活', 'home cleaning should infer home category'); assert(parsed.platforms.length === 1 && parsed.platforms[0] === 'xiaohongshu', 'home cleaning should parse Xiaohongshu only'); assert(parsed.fanRange.min === 5000 && parsed.fanRange.max === 200000, 'home cleaning should not read budget as fan range'); assert(parsed.budgetRange.min === 800 && parsed.budgetRange.max === 5000, 'home cleaning should parse budget on same line'); assert(parsed.keywords.includes('清洁') && parsed.keywords.includes('收纳'), 'home cleaning should parse home keywords'); } }, { name: 'b2b-saas', text: [ '品牌:FlowOps', '产品:企业项目管理 SaaS', '需求:找 B站 4 个、抖音 2 个效率工具/创业/企业管理类内容创作者', '粉丝:1万-40万', '预算:3000-20000', '偏好教程型、案例型、职场效率,不要纯娱乐账号。' ].join('\n'), assert(parsed) { assert(parsed.brand === 'FlowOps', 'B2B SaaS should parse brand'); assert(parsed.category === 'B2B软件', 'B2B SaaS should infer software category'); assert(parsed.platformTargets.bilibili === 4 && parsed.platformTargets.douyin === 2, 'B2B SaaS should parse Bilibili and Douyin targets'); assert(parsed.keywords.includes('效率工具') && parsed.keywords.includes('企业管理'), 'B2B SaaS should parse software keywords'); assert(parsed.exclusions.includes('纯娱乐') || parsed.exclusions.includes('纯娱乐账号'), 'B2B SaaS should parse entertainment exclusion'); } }, { name: 'local-food', text: [ '品牌:山城小面加盟', '任务:找本地生活探店、餐饮创业类达人,重庆/成都优先', '平台:抖音 10 人', '粉丝 2万-80万,预算 2000-12000', '重点看同城转化、门店拍摄经验,排除泛娱乐流量号。' ].join('\n'), assert(parsed) { assert(parsed.brand === '山城小面加盟', 'local food should parse brand'); assert(parsed.category === '食品餐饮', 'local food should infer food/local category'); assert(parsed.platforms.length === 1 && parsed.platforms[0] === 'douyin', 'local food should parse Douyin only'); assert(parsed.targetCount === 10, 'local food should parse target count'); assert(parsed.fanRange.min === 20000 && parsed.fanRange.max === 800000, 'local food should parse fan range and ignore budget'); assert(parsed.region.includes('重庆') && parsed.region.includes('成都'), 'local food should parse city priority'); assert(parsed.keywords.includes('本地生活') && parsed.keywords.includes('同城转化'), 'local food should parse local conversion keywords'); } }, { name: 'fashion-product-chat-refinement', text: [ '客户:南栖衣橱', 'Brief:找精致生活/时尚穿搭类账号,小红书 4 人', '粉丝:1万-20万,预算:300-3000', '数据要求:CPE≤3,CP1≤20,CPM≤80', '不要纯自拍。' ].join('\n'), input: { productIntro: '产品是港风通勤女装,偏成熟风、轻熟高级感,不是泛生活方式。', chatRecords: [ '客户说要看最近一个月有更新,平均点赞不要百赞以下。', '如果多篇都是同一个人评论,怀疑刷评,先不要强推。' ], referenceAccounts: ['港风穿搭Alice'] }, assert(parsed) { assert(parsed.brand === '南栖衣橱', 'fashion should parse brand'); assert(parsed.category === '服饰穿搭', 'fashion product/chat should refine broad lifestyle into apparel category'); assert(parsed.contextSources.product && parsed.contextSources.chat && parsed.contextSources.reference, 'fashion should mark product/chat/reference context sources'); assert(parsed.platformTargets.xiaohongshu === 4, 'fashion should parse Xiaohongshu target'); assert(parsed.keywords.includes('港风') && parsed.keywords.includes('成熟风'), 'fashion should extract subtype keywords from product/chat context'); assert(parsed.keywords.includes('时尚穿搭') || parsed.keywords.includes('穿搭'), 'fashion should keep broad fashion keyword'); assert(parsed.exclusions.includes('纯自拍') && !parsed.exclusions.includes('穿搭'), 'fashion should not treat wearing/fashion as an exclusion'); assert(parsed.performanceRequirements.includes('CPE≤3') && parsed.performanceRequirements.includes('CP1≤20') && parsed.performanceRequirements.includes('CPM≤80'), 'fashion should parse cost metrics'); assert(parsed.performanceRequirements.includes('近30天平均点赞≥100'), 'fashion should turn low-like warning into average-like requirement'); assert(parsed.performanceRequirements.includes('复核数据真实性/重复评论风险'), 'fashion should parse repeated-comment authenticity review'); } } ]; for (const item of cases) { const parsed = parseBrief({ briefText: item.text, ...(item.input || {}) }); item.assert(parsed); } } function assert(condition, message) { if (!condition) throw new Error(message); } main();