brief-parser.js 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670
  1. const path = require('path');
  2. const { readTextMaybe } = require('../../core/files');
  3. const { extractPatternHits } = require('./brief-rule-patterns');
  4. function parseBrief(input = {}) {
  5. const productContextText = normalizeTextInput(input.productIntro || input.productDescription || input.productContext || input.product);
  6. const chatContextText = normalizeTextInput(input.chatText || input.chatRecords || input.customerChat || input.conversation);
  7. const briefText = [
  8. input.briefText || input.text || '',
  9. input.brief || input.briefPath ? readTextMaybe(path.resolve(input.brief || input.briefPath)) : '',
  10. productContextText,
  11. chatContextText
  12. ].filter(Boolean).join('\n\n');
  13. const text = [
  14. briefText,
  15. input.brand,
  16. input.category,
  17. Array.isArray(input.keywords) ? input.keywords.join(' ') : input.keyword
  18. ].filter(Boolean).join('\n\n');
  19. const platforms = resolvePlatforms(input, text);
  20. const platformTargets = resolvePlatformTargets(text);
  21. const keywords = resolveKeywords(input, text);
  22. const targetCount = Number(input.targetCount || sumTargets(platformTargets) || resolveTargetCount(text) || 10);
  23. const budgetRange = resolveBudget(input, text);
  24. const fanRange = resolveFanRange(input, text);
  25. const exclusions = resolveList(input.excludeTags || input.exclusions, text, [
  26. '硬广', '泛美妆', '医美', '整形', '虚假宣传', '品牌纠纷', '夸大减肥', '低质搬运', '争议账号',
  27. '纯娱乐', '泛娱乐', '宠物', '装修', '婚礼', '纯自拍', '纯娱乐账号'
  28. ]);
  29. const blockedCreators = resolveList(input.blockedCreators, text, []);
  30. const referenceLinks = resolveReferenceLinks(input, text);
  31. const referenceAccounts = resolveReferenceAccounts(input, text);
  32. const referenceStyleAnchors = resolveReferenceStyleAnchors(text, referenceLinks, referenceAccounts);
  33. const referenceSignals = resolveReferenceSignals(text, referenceStyleAnchors);
  34. return {
  35. rawText: text,
  36. source: input.brief || input.briefPath || (briefText ? 'inline' : 'input'),
  37. contextSources: buildContextSources({ input, briefText, productContextText, chatContextText, referenceLinks, referenceAccounts }),
  38. brand: input.brand || resolveBrand(text) || resolveProduct(text) || '未命名品牌',
  39. category: input.category || inferCategory(text),
  40. platforms,
  41. platformTargets,
  42. keywords,
  43. targetCount,
  44. budgetRange,
  45. fanRange,
  46. region: input.region || inferRegion(text),
  47. gender: input.gender || (text.includes('女性') ? 'female' : ''),
  48. stylePreferences: resolveList(input.stylePreferences || input.preferredStyles, text, [
  49. '真实体验', '素人感', '成分拆解', '敏感肌', '通勤生活', '空瓶记录', '真实测评',
  50. '真实试吃', '配料表解释', '场景化种草', '生活化开箱', '真实吃法', '成分党',
  51. '真实使用', '前后对比', '步骤教程', '家居清洁', '收纳', '租房生活',
  52. '教程型', '案例型', '职场效率', '效率工具', '创业', '企业管理',
  53. '本地生活', '探店', '餐饮创业', '同城转化', '门店拍摄',
  54. 'Vlog', '生活分享', '生活记录', '风景', '社交场景', '颜值', '自拍',
  55. '美食', '店铺', '门面', '美妆', '妆教', '妆容教程', '产品合集', '日常妆容',
  56. '时尚穿搭', '港风', '成熟风', '通勤穿搭', '女装', '男装', '精致生活'
  57. ]),
  58. referenceLinks,
  59. referenceAccounts,
  60. referenceStyleAnchors,
  61. referenceSignals,
  62. blockedCreators,
  63. exclusions,
  64. performanceRequirements: resolvePerformanceRequirements(text),
  65. briefRulePatterns: extractPatternHits(text),
  66. deliveryRequirements: ['账号名称', '平台', '粉丝数', '报价', '推荐理由', '风险提示', '分级']
  67. };
  68. }
  69. function resolvePerformanceRequirements(text) {
  70. const requirements = [];
  71. const cpe = text.match(/CPE\s*[≤<=]\s*(\d+(?:\.\d+)?)/i);
  72. if (cpe) requirements.push(`CPE≤${cpe[1]}`);
  73. const cp1 = text.match(/CP1\s*[≤<=]\s*(\d+(?:\.\d+)?)/i);
  74. if (cp1) requirements.push(`CP1≤${cp1[1]}`);
  75. const cpm = text.match(/CPM\s*[≤<=]\s*(\d+(?:\.\d+)?)/i);
  76. if (cpm) requirements.push(`CPM≤${cpm[1]}`);
  77. const cpc = text.match(/CPC\s*[≤<=<]\s*(\d+(?:\.\d+)?)/i);
  78. if (cpc) requirements.push(`CPC≤${cpc[1]}`);
  79. const cpv = text.match(/CPV\s*[≤<=<::]\s*(\d+(?:\.\d+)?)/i);
  80. if (cpv) requirements.push(`CPV≤${cpv[1]}`);
  81. const recentPlays = text.match(/近\s*3\s*条[^\n]{0,10}(?:平均)?播放\s*[>>≥]\s*(\d+(?:\.\d+)?)\s*万/);
  82. if (recentPlays) requirements.push(`近3条平均播放>${recentPlays[1]}万`);
  83. const readMedian = text.match(/阅读中位数\s*[≧≥>=]\s*(\d+(?:,\d{3})*)/);
  84. if (readMedian) requirements.push(`阅读中位数≥${readMedian[1]}`);
  85. const averageLikes = text.match(/(?:平均点赞|月均点赞|近\s*30\s*天平均点赞|近一个月平均点赞)[^\d]{0,12}(\d+(?:,\d{3})*)/);
  86. if (averageLikes) requirements.push(`近30天平均点赞≥${averageLikes[1]}`);
  87. if (/百赞以下|100赞以下|点赞低于\s*100|平均点赞低于\s*100/.test(text)) requirements.push('近30天平均点赞≥100');
  88. if (/近一个月(?:没有|没|未)发|停更一个月|一个月(?:没有|没|未)发布|近\s*30\s*天(?:没有|没|未)更新/.test(text)) requirements.push('近30天需有更新');
  89. if (/重复评论|同一个人评论|刷评|数据造假|数据真实性|数据波动/.test(text)) requirements.push('复核数据真实性/重复评论风险');
  90. const femaleRatio = text.match(/(?:女粉|女性粉丝|女性占比|女粉占比)[^\d]{0,8}(\d+)%/);
  91. if (femaleRatio) requirements.push(`女粉占比≥${femaleRatio[1]}%`);
  92. const maleRatio = text.match(/(?:男粉|男性粉丝|男性占比|男粉占比)[^\d]{0,8}(\d+)%/);
  93. if (maleRatio) requirements.push(`男粉占比≥${maleRatio[1]}%`);
  94. if (/报价.*(?:300|三百).*以上|300\s*元以上|高于\s*300|超过\s*300/.test(text)) requirements.push('报价300元以上需复核封面清晰度/背景统一/内容精致度');
  95. const readCost = text.match(/预估阅读单价\s*[≤<=]\s*(\d+(?:\.\d+)?)/);
  96. if (readCost) requirements.push(`预估阅读单价≤${readCost[1]}`);
  97. const homepageInteraction = text.match(/(?:主页互动数据|主页互动|互动数据|互动数)[^\d]{0,12}(\d+(?:,\d{3})*)\s*\+?/);
  98. if (homepageInteraction) requirements.push(`主页互动数据≥${homepageInteraction[1]}`);
  99. const audience = text.match(/(\d{2}-\d{2}岁占比\d+%以上)/);
  100. if (audience) requirements.push(audience[1]);
  101. const ageRatio = text.match(/(\d{2})-(\d{2})岁[^\n]{0,8}占比\s*(\d+)%\s*以上/);
  102. if (ageRatio) requirements.push(`${ageRatio[1]}-${ageRatio[2]}岁占比${ageRatio[3]}%以上`);
  103. const underAgeRatio = text.match(/(\d+)岁以下[^\n]{0,6}(?:的|占比)?[^\n]{0,6}(?:不能超过|不超过|≤|<)\s*(\d+)%/);
  104. if (underAgeRatio) requirements.push(`${underAgeRatio[1]}岁以下占比≤${underAgeRatio[2]}%`);
  105. const interestTags = text.match(/兴趣[^\n]{0,6}(?:前三)?标签[^\n]{0,6}(?:含|包含)\s*(\d+)\s*个/);
  106. if (interestTags) requirements.push(`兴趣前三标签含${interestTags[1]}个`);
  107. const ipMustBe = text.match(/(?:达人)?\s*ip\s*(?:必须|要求|需|为|在)\s*([^\s,,。;\n]+)/i);
  108. if (ipMustBe) requirements.push(`达人IP必须${ipMustBe[1]}`);
  109. if (/(?:同|同一)个?省(?:份)?[^\n]{0,12}(?:不要|不|禁止)?\s*跨区域/.test(text)) requirements.push('同省KOL不要跨区域');
  110. return unique(requirements);
  111. }
  112. function normalizeTextInput(value) {
  113. if (!value) return '';
  114. if (Array.isArray(value)) return value.map(normalizeTextInput).filter(Boolean).join('\n');
  115. if (typeof value === 'object') return Object.values(value).map(normalizeTextInput).filter(Boolean).join('\n');
  116. return String(value || '').trim();
  117. }
  118. function buildContextSources({ input, briefText, productContextText, chatContextText, referenceLinks = [], referenceAccounts = [] }) {
  119. return {
  120. brief: Boolean(input.brief || input.briefPath || input.briefText || input.text || briefText),
  121. product: Boolean(productContextText),
  122. chat: Boolean(chatContextText),
  123. reference: Boolean((input.referenceLinks || []).length || (input.referenceAccounts || []).length || referenceLinks.length || referenceAccounts.length)
  124. };
  125. }
  126. function resolvePlatformTargets(text) {
  127. const targets = {};
  128. const tableRows = text.split(/\r?\n/)
  129. .filter(line => /^\|\s*(小红书|抖音|B站|bilibili|xiaohongshu|douyin)\s*\|/.test(line));
  130. for (const line of tableRows) {
  131. const cells = line.split('|').map(cell => cell.trim()).filter(Boolean);
  132. const platform = normalizePlatforms([cells[0]])[0];
  133. const count = Number((cells[1] || '').match(/(\d+)/)?.[1] || 0);
  134. if (platform && count) targets[platform] = count;
  135. }
  136. for (const match of text.matchAll(/(小红书|抖音|B站|bilibili|xiaohongshu|douyin)\s*[::]?\s*(\d+)\s*(?:人|位|个|账号|达人)/g)) {
  137. const platform = normalizePlatforms([match[1]])[0];
  138. const count = Number(match[2] || 0);
  139. if (platform && count) targets[platform] = Math.max(Number(targets[platform] || 0), count);
  140. }
  141. return targets;
  142. }
  143. function sumTargets(platformTargets) {
  144. return Object.values(platformTargets || {}).reduce((sum, count) => sum + Number(count || 0), 0);
  145. }
  146. function resolveBrand(text) {
  147. const lines = text.split(/\r?\n/).map(cleanMarkdownLine).filter(Boolean);
  148. const explicit = lines.find(line => /^(客户名称|客户|品牌名称|品牌)[::]/.test(line));
  149. if (explicit) return explicit.replace(/^(客户名称|客户|品牌名称|品牌)[::]\s*/, '').trim();
  150. const productLine = lines.find(line => /本次推广产品|推广产品/.test(line));
  151. if (productLine) {
  152. const product = productLine.replace(/^(本次推广产品|推广产品)[::]\s*/, '').trim();
  153. const match = product.match(/^(.{2,12}?)(?:修护|精华|面膜|口红|产品)/);
  154. if (match) return match[1];
  155. }
  156. return '';
  157. }
  158. function resolveProduct(text) {
  159. const lines = text.split(/\r?\n/).map(cleanMarkdownLine).filter(Boolean);
  160. const explicit = lines.find(line => /^(产品|推广产品|本次推广产品)[::]/.test(line));
  161. return explicit ? explicit.replace(/^(产品|推广产品|本次推广产品)[::]\s*/, '').trim() : '';
  162. }
  163. function cleanMarkdownLine(line) {
  164. return String(line || '')
  165. .replace(/^\s*[-*#]+\s*/, '')
  166. .replace(/\*\*/g, '')
  167. .replace(/\s+/g, ' ')
  168. .trim();
  169. }
  170. function resolveTargetCount(text) {
  171. const tableRows = text.split(/\r?\n/)
  172. .filter(line => /^\|\s*(小红书|抖音|B站|bilibili|xiaohongshu|douyin)\s*\|/.test(line));
  173. const counts = tableRows
  174. .map(line => {
  175. const cells = line.split('|').map(cell => cell.trim()).filter(Boolean);
  176. const countCell = cells[1] || '';
  177. const match = countCell.match(/(\d+)\s*(?:人|位|个|账号|达人)?/);
  178. return match ? Number(match[1]) : 0;
  179. })
  180. .filter(Boolean);
  181. if (counts.length) return counts.reduce((sum, count) => sum + count, 0);
  182. const direct = text.match(/(?:需求人数|推荐数量|目标数量)[^\d]{0,12}(\d+)\s*(?:人|位|个|账号|达人)?/);
  183. return direct ? Number(direct[1]) : 0;
  184. }
  185. function resolvePlatforms(input, text) {
  186. if (Array.isArray(input.platforms) && input.platforms.length) return normalizePlatforms(input.platforms);
  187. const platforms = [];
  188. if (/小红书|xiaohongshu|xhs/i.test(text)) platforms.push('xiaohongshu');
  189. if (/抖音|douyin/i.test(text)) platforms.push('douyin');
  190. if (/B站|bilibili|哔哩/i.test(text)) platforms.push('bilibili');
  191. return platforms.length ? platforms : ['xiaohongshu', 'douyin'];
  192. }
  193. function normalizePlatforms(platforms) {
  194. const map = {
  195. xhs: 'xiaohongshu',
  196. '小红书': 'xiaohongshu',
  197. xiaohongshu: 'xiaohongshu',
  198. '抖音': 'douyin',
  199. douyin: 'douyin',
  200. 'B站': 'bilibili',
  201. bilibili: 'bilibili'
  202. };
  203. return [...new Set(platforms.map(item => map[String(item)] || String(item)))];
  204. }
  205. function resolveKeywords(input, text) {
  206. const provided = Array.isArray(input.keywords) ? input.keywords : String(input.keyword || '').split(/[,\s,、]+/);
  207. const categoryTermMap = {
  208. 护肤美妆: ['敏感肌', '屏障', '修护', '护肤', '成分党', '成分拆解', '美妆', '测评', '空瓶', '胶原蛋白', '面膜', '精华', '防晒', '洁面', '妆教', '妆容教程', '步骤教程', '产品合集', '日常妆容', '口红', '彩妆'],
  209. 母婴: ['DHA', '营养师', '育婴师', '妈妈', '母婴', '萌娃', '记录生活', '618合集', '干货科普', '儿童营养', '宝宝', '备孕'],
  210. 食品餐饮: ['食品', '零食', '低糖', '健康', '控糖', '健身', '办公室零食', '试吃', '开箱', '真实吃法', '配料表', '成分党', '燕麦', '蛋白', '餐饮', '美食', '探店', '本地生活', '同城转化', '门店拍摄', '餐饮创业', '小面', '加盟', '店铺', '门面', '店面', '店招', '门头'],
  211. 家居生活: ['家居', '家清', '清洁', '收纳', '租房', '家务', '厨房', '油污', '日化', '卫生间', '真实使用', '前后对比', '步骤教程', '妈妈'],
  212. B2B软件: ['SaaS', 'B2B', '项目管理', '效率工具', '创业', '企业管理', '职场效率', '教程型', '案例型', '协同办公', '软件', '工具'],
  213. 服饰穿搭: ['时尚穿搭', '穿搭', '港风', '成熟风', '通勤穿搭', '女装', '男装', '服饰', '搭配', '精致生活', '职场穿搭', '轻熟', '高级感'],
  214. 生活方式: ['种草', '国货', '通勤', '生活方式', '测评', '真实体验', '素人感', '开箱', '教程', '案例', 'Vlog', '生活分享', '生活记录', '风景', '社交场景', '颜值', '自拍', '精致生活']
  215. };
  216. const category = inferCategory(text);
  217. const commonTerms = [
  218. '种草', '测评', '开箱', '教程', '真实体验', '素人感', '生活方式',
  219. '女性', '妈妈', '职场', '同城', '本地生活', 'Vlog', '生活分享', '生活记录', '颜值'
  220. ];
  221. const domainTerms = [
  222. ...(categoryTermMap[category] || []),
  223. ...commonTerms,
  224. ...Object.values(categoryTermMap).flat()
  225. ];
  226. const explicitTerms = extractLabeledTerms(text);
  227. const hits = domainTerms.filter(term => text.includes(term));
  228. return unique([...provided, ...hits, ...explicitTerms]
  229. .map(cleanKeywordTerm)
  230. .filter(Boolean)).slice(0, 12);
  231. }
  232. function extractLabeledTerms(text) {
  233. const terms = [];
  234. for (const line of String(text || '').split(/\r?\n/).map(cleanMarkdownLine).filter(Boolean)) {
  235. const kv = parseKeyValueLine(line);
  236. if (!kv || !/关键词|关键字|风格|偏好|要求|重点|目标|任务|人群|内容/.test(kv.key)) continue;
  237. terms.push(...splitKeywordTerms(kv.value));
  238. }
  239. return terms;
  240. }
  241. function splitKeywordTerms(value) {
  242. return String(value || '')
  243. .replace(/找(小红书|抖音|B站|bilibili|xiaohongshu|douyin)?\s*\d+\s*(?:个|位|人|账号|达人)?/gi, ' ')
  244. .replace(/(小红书|抖音|B站|bilibili|xiaohongshu|douyin)\s*\d+\s*(?:个|位|人|账号|达人)?/gi, ' ')
  245. .replace(/\d+(?:\.\d+)?\s*(?:万|k|K)?\s*(?:-|到|至|~)\s*\d+(?:\.\d+)?\s*(?:万|k|K)?/g, ' ')
  246. .split(/[,,、;;\/|和与&\s]+/)
  247. .map(cleanKeywordTerm)
  248. .filter(Boolean);
  249. }
  250. function cleanKeywordTerm(value) {
  251. const cleaned = String(value || '')
  252. .replace(/^(找|寻找|需要|招募|重点看|要求|偏好|风格偏|不要|排除)/, '')
  253. .replace(/(?:博主|达人|账号|内容创作者|类内容创作者|类达人|类博主|流量号|经验|优先)$/g, '')
  254. .replace(/类$/g, '')
  255. .trim();
  256. if (cleaned.length < 2 || cleaned.length > 12) return '';
  257. if (/^(预算|粉丝|平台|人数|需求|目标|任务|产品|品牌|小红书|抖音|B站|bilibili|xiaohongshu|douyin)$/i.test(cleaned)) return '';
  258. return cleaned;
  259. }
  260. function resolveBudget(input, text) {
  261. if (input.budgetRange) return normalizeRange(input.budgetRange, { min: 0, max: 20000 });
  262. const upperBound = text.match(/(?:单个预算|预算|报价|单人预算|单篇预算)[^\d]{0,12}([\d,.]+)\s*(?:元|块|rmb|RMB)?\s*(?:内|以内|以下|封顶|上限)/i);
  263. if (upperBound) return { min: 0, max: toNumber(upperBound[1]) };
  264. const money = [...text.matchAll(/(?:¥|¥)?\s*([\d,.]+)\s*(?:-|到|至|~)\s*(?:¥|¥)?\s*([\d,.]+)/g)]
  265. .map(match => [toNumber(match[1]), toNumber(match[2])])
  266. .filter(([min, max]) => max > 0);
  267. const picked = money.find(([min, max]) => max >= 1000) || money[0];
  268. return picked ? { min: picked[0], max: picked[1] } : { min: 0, max: 20000 };
  269. }
  270. function resolveFanRange(input, text) {
  271. if (input.fanRange) return normalizeRange(input.fanRange, { min: 0, max: 500000 });
  272. const ranges = [];
  273. const lines = text.split(/\r?\n/);
  274. let inFanSection = false;
  275. for (const line of lines) {
  276. if (/粉丝量范围|粉丝范围|粉丝数范围/.test(line)) {
  277. inFanSection = true;
  278. continue;
  279. }
  280. if (inFanSection && /^#{1,4}\s+/.test(line) && !/粉丝/.test(line)) break;
  281. if (!inFanSection && !/粉丝|小红书|抖音|B站/.test(line)) continue;
  282. const match = line.match(/(\d+(?:\.\d+)?)\s*万?\s*(?:-|到|至|~)\s*(\d+(?:\.\d+)?)\s*万?\s*(?:粉|粉丝)?/);
  283. if (!match) continue;
  284. const hasWan = match[0].includes('万') || /粉丝/.test(line);
  285. const min = Number(match[1]) * (hasWan ? 10000 : 1);
  286. const max = Number(match[2]) * (hasWan ? 10000 : 1);
  287. if (max >= 10000) ranges.push({ min, max });
  288. }
  289. if (!ranges.length) return { min: 0, max: 500000 };
  290. return {
  291. min: Math.min(...ranges.map(range => range.min)),
  292. max: Math.max(...ranges.map(range => range.max))
  293. };
  294. }
  295. function normalizeRange(value, fallback) {
  296. return {
  297. min: Number(value.min ?? fallback.min),
  298. max: Number(value.max ?? fallback.max)
  299. };
  300. }
  301. function inferCategory(text) {
  302. if (/SaaS|B2B|项目管理|效率工具|企业管理|职场效率|协同办公|软件|CRM|企业级|数字化/.test(text)) return 'B2B软件';
  303. if (/家清|清洁|家居|收纳|洗衣|厨房|油污|卫生间|日化|租房|家务/.test(text)) return '家居生活';
  304. if (/食品|饮料|餐饮|美食|零食|低糖|控糖|燕麦|蛋白|试吃|配料表|探店|本地生活|同城|门店|小面|加盟|餐饮创业/.test(text)) return '食品餐饮';
  305. if (/母婴|宝宝|孕|育婴|备孕|DHA|儿童营养/.test(text)) return '母婴';
  306. if (/护肤|面膜|敏感肌|屏障|修护|精华|美妆|口红|胶原蛋白|洁面|防晒|泛红|刺痛/.test(text)) return '护肤美妆';
  307. if (/时尚穿搭|穿搭|港风|成熟风|通勤穿搭|女装|男装|服饰|搭配|轻熟|高级感/.test(text)) return '服饰穿搭';
  308. return '生活方式';
  309. }
  310. function inferRegion(text) {
  311. const cities = ['北京', '上海', '广州', '深圳', '杭州', '成都', '南京', '武汉', '重庆', '西安'];
  312. return cities.filter(city => text.includes(city));
  313. }
  314. function resolveList(inputList, text, vocabulary) {
  315. const provided = normalizeListInput(inputList);
  316. const hits = vocabulary.filter(item => text.includes(item));
  317. return [...new Set([...provided, ...hits].map(String).filter(Boolean))];
  318. }
  319. function normalizeListInput(inputList) {
  320. if (Array.isArray(inputList)) return inputList.flatMap(normalizeListInput);
  321. if (inputList == null || inputList === false) return [];
  322. if (typeof inputList === 'string') {
  323. return inputList.split(/[,,;;|]/).map(item => item.trim()).filter(Boolean);
  324. }
  325. return [inputList];
  326. }
  327. function toNumber(value) {
  328. return Number(String(value || '').replace(/,/g, '')) || 0;
  329. }
  330. function resolvePlatformTargets(text) {
  331. const targets = {};
  332. for (const line of text.split(/\r?\n/).map(cleanMarkdownLine).filter(Boolean)) {
  333. const cells = splitCells(line);
  334. if (cells.length >= 2) {
  335. const platform = normalizePlatforms([cells[0]])[0];
  336. const count = Number((cells[1] || '').match(/(\d+)/)?.[1] || 0);
  337. if (isKnownPlatform(platform) && count) targets[platform] = Math.max(Number(targets[platform] || 0), count);
  338. }
  339. for (const match of line.matchAll(/(小红书|抖音|B站|bilibili|xiaohongshu|douyin)\s*[::]?\s*(\d+)\s*(?:人|位|个|账号|达人)/gi)) {
  340. const platform = normalizePlatforms([match[1]])[0];
  341. const count = Number(match[2] || 0);
  342. if (isKnownPlatform(platform) && count) targets[platform] = Math.max(Number(targets[platform] || 0), count);
  343. }
  344. }
  345. return targets;
  346. }
  347. function resolveBrand(text) {
  348. const lines = text.split(/\r?\n/).map(cleanMarkdownLine).filter(Boolean);
  349. for (const line of lines) {
  350. const kv = parseKeyValueLine(line);
  351. if (!kv) continue;
  352. if (/^(客户名称|客户|品牌名称|品牌)$/i.test(kv.key)) return kv.value;
  353. }
  354. for (const line of lines) {
  355. const kv = parseKeyValueLine(line);
  356. if (!kv || !/^(产品|推广产品|本次推广产品)$/i.test(kv.key)) continue;
  357. const match = kv.value.match(/^(.{2,18}?)(?:修护|精华|面膜|口红|产品|(|\()/);
  358. if (match) return match[1].trim();
  359. return kv.value;
  360. }
  361. return '';
  362. }
  363. function resolveProduct(text) {
  364. const lines = text.split(/\r?\n/).map(cleanMarkdownLine).filter(Boolean);
  365. for (const line of lines) {
  366. const kv = parseKeyValueLine(line);
  367. if (kv && /^(产品|推广产品|本次推广产品)$/i.test(kv.key)) return kv.value;
  368. }
  369. return '';
  370. }
  371. function resolveTargetCount(text) {
  372. const counts = Object.values(resolvePlatformTargets(text)).map(Number).filter(Boolean);
  373. if (counts.length) return counts.reduce((sum, count) => sum + count, 0);
  374. const direct = text.match(/(?:需求人数|推荐数量|目标数量)[^\d]{0,12}(\d+)\s*(?:人|位|个|账号|达人)?/);
  375. return direct ? Number(direct[1]) : 0;
  376. }
  377. function resolvePlatforms(input, text) {
  378. if (Array.isArray(input.platforms) && input.platforms.length) return normalizePlatforms(input.platforms);
  379. const platforms = [];
  380. if (/小红书|xiaohongshu|xhs/i.test(text)) platforms.push('xiaohongshu');
  381. if (/抖音|douyin/i.test(text)) platforms.push('douyin');
  382. if (/B站|bilibili|哔哩/i.test(text)) platforms.push('bilibili');
  383. return platforms.length ? [...new Set(platforms)] : ['xiaohongshu', 'douyin'];
  384. }
  385. function normalizePlatforms(platforms) {
  386. const map = {
  387. xhs: 'xiaohongshu',
  388. 小红书: 'xiaohongshu',
  389. xiaohongshu: 'xiaohongshu',
  390. 抖音: 'douyin',
  391. douyin: 'douyin',
  392. B站: 'bilibili',
  393. b站: 'bilibili',
  394. bilibili: 'bilibili',
  395. 哔哩哔哩: 'bilibili'
  396. };
  397. return [...new Set(platforms.map(item => map[String(item).trim()] || String(item).trim()))];
  398. }
  399. function resolveFanRange(input, text) {
  400. if (input.fanRange) return normalizeRange(input.fanRange, { min: 0, max: 500000 });
  401. const ranges = [];
  402. for (const line of text.split(/\r?\n/).map(cleanMarkdownLine).filter(Boolean)) {
  403. if (!/粉丝|小红书|抖音|B站|bilibili|xiaohongshu|douyin/i.test(line)) continue;
  404. const cells = splitCells(line);
  405. const scan = isolateFanRangeText(cells.length >= 2 && /粉丝/.test(cells[0]) ? cells.slice(1).join(' ') : line);
  406. for (const match of scan.matchAll(/(\d+(?:\.\d+)?)\s*(万|w|W|k|K)?\s*(?:-|到|至|~)\s*(\d+(?:\.\d+)?)\s*(万|w|W|k|K)?/g)) {
  407. const min = normalizeFanCount(match[1], match[2], match[4]);
  408. const max = normalizeFanCount(match[3], match[4], match[2]);
  409. if (max >= 1000 && min > 0 && min <= max) ranges.push({ min, max });
  410. }
  411. }
  412. if (!ranges.length) return { min: 0, max: 500000 };
  413. return {
  414. min: Math.min(...ranges.map(range => range.min)),
  415. max: Math.max(...ranges.map(range => range.max))
  416. };
  417. }
  418. function isolateFanRangeText(line) {
  419. const text = String(line || '');
  420. const fanIndex = text.search(/粉丝|fans?/i);
  421. const platformOnly = fanIndex < 0;
  422. const start = platformOnly ? 0 : fanIndex;
  423. let scan = text.slice(start);
  424. const budgetIndex = scan.search(/预算|报价|单篇|费用|价格|投放|CPE|CPM|阅读单价|合作费|坑位费/i);
  425. if (budgetIndex >= 0) scan = scan.slice(0, budgetIndex);
  426. return scan;
  427. }
  428. function normalizeFanCount(value, unit, oppositeUnit) {
  429. const numeric = Number(value || 0);
  430. if (!numeric) return 0;
  431. const normalizedUnit = String(unit || '').toLowerCase();
  432. const normalizedOppositeUnit = String(oppositeUnit || '').toLowerCase();
  433. if (normalizedUnit === '万' || normalizedUnit === 'w') return Math.round(numeric * 10000);
  434. if (normalizedUnit === 'k') return Math.round(numeric * 1000);
  435. if (normalizedOppositeUnit === '万' || normalizedOppositeUnit === 'w') {
  436. return numeric >= 1000 ? Math.round(numeric) : Math.round(numeric * 10000);
  437. }
  438. if (normalizedOppositeUnit === 'k') return numeric >= 1000 ? Math.round(numeric) : Math.round(numeric * 1000);
  439. return Math.round(numeric);
  440. }
  441. function parseKeyValueLine(line) {
  442. const cells = splitCells(line);
  443. if (cells.length >= 2) return { key: cells[0], value: cells.slice(1).join(' | ').trim() };
  444. const match = line.match(/^(.{2,14}?)[::]\s*(.+)$/);
  445. return match ? { key: match[1].trim(), value: match[2].trim() } : null;
  446. }
  447. function splitCells(line) {
  448. return String(line || '')
  449. .split('|')
  450. .map(cell => cell.trim())
  451. .filter(Boolean)
  452. .filter(cell => !/^[-:]{2,}$/.test(cell));
  453. }
  454. function isKnownPlatform(platform) {
  455. return platform === 'xiaohongshu' || platform === 'douyin' || platform === 'bilibili';
  456. }
  457. function resolveReferenceLinks(input, text) {
  458. const provided = Array.isArray(input.referenceLinks) ? input.referenceLinks.map(normalizeReferenceLink).filter(Boolean) : [];
  459. const urls = [...String(text || '').matchAll(/https?:\/\/[^\s|,,;;))]+/gi)]
  460. .map(match => match[0].replace(/[。.!?]+$/, ''));
  461. const parsed = unique(urls).map(url => normalizeReferenceLink(url));
  462. return dedupeObjects([...provided, ...parsed], item => item.url || `${item.platform}:${item.noteId}`);
  463. }
  464. function normalizeReferenceLink(item) {
  465. if (!item) return null;
  466. if (typeof item === 'string') {
  467. return {
  468. url: item,
  469. platform: inferPlatformFromUrl(item),
  470. noteId: extractXhsNoteId(item),
  471. contentType: inferContentTypeFromText(item),
  472. needsEnrichment: true
  473. };
  474. }
  475. const url = String(item.url || item.link || '').trim();
  476. if (!url) return null;
  477. return {
  478. url,
  479. platform: item.platform || inferPlatformFromUrl(url),
  480. noteId: item.noteId || item.note_id || extractXhsNoteId(url),
  481. contentType: item.contentType || item.type || inferContentTypeFromText(`${item.sourceText || ''} ${url}`),
  482. sourceText: item.sourceText || '',
  483. needsEnrichment: item.needsEnrichment !== false
  484. };
  485. }
  486. function resolveReferenceAccounts(input, text) {
  487. const provided = Array.isArray(input.referenceAccounts) ? input.referenceAccounts : [];
  488. const accounts = provided.map(item => normalizeReferenceAccount(item)).filter(Boolean);
  489. const lines = String(text || '').split(/\r?\n/).map(cleanMarkdownLine).filter(Boolean);
  490. for (const line of lines) {
  491. if (!/参考|对标|标杆|账号|博主|达人|redId|小红书号|抖音号/i.test(line)) continue;
  492. if (/^(账号类型|.*参考账号|.*参考博主)[::]?$/.test(line)) continue;
  493. const withoutUrls = line.replace(/https?:\/\/\S+/gi, '').trim();
  494. const kv = parseKeyValueLine(withoutUrls);
  495. if (kv && /^(账号类型|参考账号|参考博主|风格参考)$/i.test(kv.key)) continue;
  496. const value = kv ? kv.value : withoutUrls;
  497. for (const part of value.split(/[、,,;;\/]+/).map(item => item.trim()).filter(Boolean)) {
  498. if (/^(图文|视频|名单字段|关键词参考|风格参考|参考账号|参考博主|账号类型)$/.test(part)) continue;
  499. if (/参考账号|参考博主|账号类型/.test(part)) continue;
  500. if (part.length < 2 || part.length > 40) continue;
  501. accounts.push({
  502. name: part,
  503. platform: inferPlatformFromText(line),
  504. sourceText: line,
  505. needsEnrichment: true
  506. });
  507. }
  508. }
  509. return dedupeObjects(accounts, item => `${item.platform || ''}:${item.name || item.redId || item.userId || item.url || ''}`);
  510. }
  511. function normalizeReferenceAccount(item) {
  512. if (!item) return null;
  513. if (typeof item === 'string') {
  514. return { name: item, platform: inferPlatformFromText(item), sourceText: item, needsEnrichment: true };
  515. }
  516. return {
  517. name: item.name || item.nickname || item.displayName || '',
  518. redId: item.redId || item.red_id || '',
  519. userId: item.userId || item.user_id || '',
  520. platform: item.platform || inferPlatformFromText(`${item.url || ''} ${item.sourceText || ''}`),
  521. url: item.url || '',
  522. sourceText: item.sourceText || '',
  523. needsEnrichment: item.needsEnrichment !== false
  524. };
  525. }
  526. function resolveReferenceStyleAnchors(text, referenceLinks, referenceAccounts) {
  527. const anchors = [];
  528. let currentBucket = '';
  529. for (const rawLine of String(text || '').split(/\r?\n/)) {
  530. const line = cleanMarkdownLine(rawLine);
  531. if (!line) continue;
  532. if (/参考账号|参考博主|对标账号|标杆账号|账号类型/.test(line)) {
  533. currentBucket = line.replace(/[::]\s*$/, '');
  534. }
  535. if (/https?:\/\//i.test(line)) {
  536. const links = (referenceLinks || []).filter(item => line.includes(item.url));
  537. anchors.push({
  538. bucket: currentBucket || inferReferenceBucket(line),
  539. contentType: /视频|video/i.test(line) ? 'video' : /图文|image|note/i.test(line) ? 'image_text' : (links[0]?.contentType || ''),
  540. platform: inferPlatformFromText(line) || links[0]?.platform || '',
  541. sourceText: line,
  542. links: links.map(item => item.url),
  543. needsSocialAnalysisEnrichment: true
  544. });
  545. }
  546. }
  547. for (const account of referenceAccounts || []) {
  548. anchors.push({
  549. bucket: inferReferenceBucket(account.sourceText || account.name || ''),
  550. platform: account.platform || '',
  551. sourceText: account.sourceText || account.name || '',
  552. accountHints: [account.name || account.redId || account.userId].filter(Boolean),
  553. needsSocialAnalysisEnrichment: true
  554. });
  555. }
  556. return dedupeObjects(anchors, item => `${item.bucket}:${item.sourceText}:${(item.links || []).join('|')}:${(item.accountHints || []).join('|')}`);
  557. }
  558. function resolveReferenceSignals(text, anchors) {
  559. const vocabulary = [
  560. '干货科普', '营养师', '育婴师', '妈妈', '母婴', '618合集', '合集', '图文', '视频',
  561. '素人感', '真实体验', '真实测评', '通勤生活', '成分党', '敏感肌', '开箱体验',
  562. '不硬广', '好物分享', '生活化', '专业背书', '选品建议'
  563. ];
  564. const source = [
  565. text,
  566. ...(anchors || []).map(item => `${item.bucket || ''} ${item.sourceText || ''}`)
  567. ].join('\n');
  568. return vocabulary.filter(item => source.includes(item));
  569. }
  570. function inferReferenceBucket(text) {
  571. if (/618|合集/.test(text)) return '618合集参考';
  572. if (/干货|科普|营养师|育婴师/.test(text)) return '干货科普参考';
  573. if (/风格/.test(text)) return '风格参考';
  574. return '参考账号';
  575. }
  576. function inferPlatformFromUrl(url) {
  577. if (/xhslink|xiaohongshu/i.test(url)) return 'xiaohongshu';
  578. if (/douyin/i.test(url)) return 'douyin';
  579. if (/bilibili|b23\.tv/i.test(url)) return 'bilibili';
  580. return '';
  581. }
  582. function inferPlatformFromText(text) {
  583. const value = String(text || '');
  584. if (/小红书|xhs|xiaohongshu|xhslink/i.test(value)) return 'xiaohongshu';
  585. if (/抖音|douyin/i.test(value)) return 'douyin';
  586. if (/B站|bilibili|b23\.tv/i.test(value)) return 'bilibili';
  587. return inferPlatformFromUrl(value);
  588. }
  589. function inferContentTypeFromText(text) {
  590. const value = String(text || '');
  591. if (/视频|video|douyin|bilibili|b23\.tv/i.test(value)) return 'video';
  592. if (/图文|image|note|xhslink|xiaohongshu/i.test(value)) return 'image_text';
  593. if (/主页|profile|account|home/i.test(value)) return 'account_home';
  594. return '';
  595. }
  596. function extractXhsNoteId(url) {
  597. const match = String(url || '').match(/\/(?:discovery\/item|explore)\/([0-9a-f]{16,32})/i);
  598. return match ? match[1] : '';
  599. }
  600. function unique(items) {
  601. return [...new Set(items.filter(Boolean).map(String))];
  602. }
  603. function dedupeObjects(items, keyFn) {
  604. const seen = new Set();
  605. const result = [];
  606. for (const item of items.filter(Boolean)) {
  607. const key = keyFn(item);
  608. if (seen.has(key)) continue;
  609. seen.add(key);
  610. result.push(item);
  611. }
  612. return result;
  613. }
  614. module.exports = {
  615. parseBrief
  616. };