| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670 |
- const path = require('path');
- const { readTextMaybe } = require('../../core/files');
- const { extractPatternHits } = require('./brief-rule-patterns');
- function parseBrief(input = {}) {
- const productContextText = normalizeTextInput(input.productIntro || input.productDescription || input.productContext || input.product);
- const chatContextText = normalizeTextInput(input.chatText || input.chatRecords || input.customerChat || input.conversation);
- const briefText = [
- input.briefText || input.text || '',
- input.brief || input.briefPath ? readTextMaybe(path.resolve(input.brief || input.briefPath)) : '',
- productContextText,
- chatContextText
- ].filter(Boolean).join('\n\n');
- const text = [
- briefText,
- input.brand,
- input.category,
- Array.isArray(input.keywords) ? input.keywords.join(' ') : input.keyword
- ].filter(Boolean).join('\n\n');
- const platforms = resolvePlatforms(input, text);
- const platformTargets = resolvePlatformTargets(text);
- const keywords = resolveKeywords(input, text);
- const targetCount = Number(input.targetCount || sumTargets(platformTargets) || resolveTargetCount(text) || 10);
- const budgetRange = resolveBudget(input, text);
- const fanRange = resolveFanRange(input, text);
- const exclusions = resolveList(input.excludeTags || input.exclusions, text, [
- '硬广', '泛美妆', '医美', '整形', '虚假宣传', '品牌纠纷', '夸大减肥', '低质搬运', '争议账号',
- '纯娱乐', '泛娱乐', '宠物', '装修', '婚礼', '纯自拍', '纯娱乐账号'
- ]);
- const blockedCreators = resolveList(input.blockedCreators, text, []);
- const referenceLinks = resolveReferenceLinks(input, text);
- const referenceAccounts = resolveReferenceAccounts(input, text);
- const referenceStyleAnchors = resolveReferenceStyleAnchors(text, referenceLinks, referenceAccounts);
- const referenceSignals = resolveReferenceSignals(text, referenceStyleAnchors);
- return {
- rawText: text,
- source: input.brief || input.briefPath || (briefText ? 'inline' : 'input'),
- contextSources: buildContextSources({ input, briefText, productContextText, chatContextText, referenceLinks, referenceAccounts }),
- brand: input.brand || resolveBrand(text) || resolveProduct(text) || '未命名品牌',
- category: input.category || inferCategory(text),
- platforms,
- platformTargets,
- keywords,
- targetCount,
- budgetRange,
- fanRange,
- region: input.region || inferRegion(text),
- gender: input.gender || (text.includes('女性') ? 'female' : ''),
- stylePreferences: resolveList(input.stylePreferences || input.preferredStyles, text, [
- '真实体验', '素人感', '成分拆解', '敏感肌', '通勤生活', '空瓶记录', '真实测评',
- '真实试吃', '配料表解释', '场景化种草', '生活化开箱', '真实吃法', '成分党',
- '真实使用', '前后对比', '步骤教程', '家居清洁', '收纳', '租房生活',
- '教程型', '案例型', '职场效率', '效率工具', '创业', '企业管理',
- '本地生活', '探店', '餐饮创业', '同城转化', '门店拍摄',
- 'Vlog', '生活分享', '生活记录', '风景', '社交场景', '颜值', '自拍',
- '美食', '店铺', '门面', '美妆', '妆教', '妆容教程', '产品合集', '日常妆容',
- '时尚穿搭', '港风', '成熟风', '通勤穿搭', '女装', '男装', '精致生活'
- ]),
- referenceLinks,
- referenceAccounts,
- referenceStyleAnchors,
- referenceSignals,
- blockedCreators,
- exclusions,
- performanceRequirements: resolvePerformanceRequirements(text),
- briefRulePatterns: extractPatternHits(text),
- deliveryRequirements: ['账号名称', '平台', '粉丝数', '报价', '推荐理由', '风险提示', '分级']
- };
- }
- function resolvePerformanceRequirements(text) {
- const requirements = [];
- const cpe = text.match(/CPE\s*[≤<=]\s*(\d+(?:\.\d+)?)/i);
- if (cpe) requirements.push(`CPE≤${cpe[1]}`);
- const cp1 = text.match(/CP1\s*[≤<=]\s*(\d+(?:\.\d+)?)/i);
- if (cp1) requirements.push(`CP1≤${cp1[1]}`);
- const cpm = text.match(/CPM\s*[≤<=]\s*(\d+(?:\.\d+)?)/i);
- if (cpm) requirements.push(`CPM≤${cpm[1]}`);
- const cpc = text.match(/CPC\s*[≤<=<]\s*(\d+(?:\.\d+)?)/i);
- if (cpc) requirements.push(`CPC≤${cpc[1]}`);
- const cpv = text.match(/CPV\s*[≤<=<::]\s*(\d+(?:\.\d+)?)/i);
- if (cpv) requirements.push(`CPV≤${cpv[1]}`);
- const recentPlays = text.match(/近\s*3\s*条[^\n]{0,10}(?:平均)?播放\s*[>>≥]\s*(\d+(?:\.\d+)?)\s*万/);
- if (recentPlays) requirements.push(`近3条平均播放>${recentPlays[1]}万`);
- const readMedian = text.match(/阅读中位数\s*[≧≥>=]\s*(\d+(?:,\d{3})*)/);
- if (readMedian) requirements.push(`阅读中位数≥${readMedian[1]}`);
- const averageLikes = text.match(/(?:平均点赞|月均点赞|近\s*30\s*天平均点赞|近一个月平均点赞)[^\d]{0,12}(\d+(?:,\d{3})*)/);
- if (averageLikes) requirements.push(`近30天平均点赞≥${averageLikes[1]}`);
- if (/百赞以下|100赞以下|点赞低于\s*100|平均点赞低于\s*100/.test(text)) requirements.push('近30天平均点赞≥100');
- if (/近一个月(?:没有|没|未)发|停更一个月|一个月(?:没有|没|未)发布|近\s*30\s*天(?:没有|没|未)更新/.test(text)) requirements.push('近30天需有更新');
- if (/重复评论|同一个人评论|刷评|数据造假|数据真实性|数据波动/.test(text)) requirements.push('复核数据真实性/重复评论风险');
- const femaleRatio = text.match(/(?:女粉|女性粉丝|女性占比|女粉占比)[^\d]{0,8}(\d+)%/);
- if (femaleRatio) requirements.push(`女粉占比≥${femaleRatio[1]}%`);
- const maleRatio = text.match(/(?:男粉|男性粉丝|男性占比|男粉占比)[^\d]{0,8}(\d+)%/);
- if (maleRatio) requirements.push(`男粉占比≥${maleRatio[1]}%`);
- if (/报价.*(?:300|三百).*以上|300\s*元以上|高于\s*300|超过\s*300/.test(text)) requirements.push('报价300元以上需复核封面清晰度/背景统一/内容精致度');
- const readCost = text.match(/预估阅读单价\s*[≤<=]\s*(\d+(?:\.\d+)?)/);
- if (readCost) requirements.push(`预估阅读单价≤${readCost[1]}`);
- const homepageInteraction = text.match(/(?:主页互动数据|主页互动|互动数据|互动数)[^\d]{0,12}(\d+(?:,\d{3})*)\s*\+?/);
- if (homepageInteraction) requirements.push(`主页互动数据≥${homepageInteraction[1]}`);
- const audience = text.match(/(\d{2}-\d{2}岁占比\d+%以上)/);
- if (audience) requirements.push(audience[1]);
- const ageRatio = text.match(/(\d{2})-(\d{2})岁[^\n]{0,8}占比\s*(\d+)%\s*以上/);
- if (ageRatio) requirements.push(`${ageRatio[1]}-${ageRatio[2]}岁占比${ageRatio[3]}%以上`);
- const underAgeRatio = text.match(/(\d+)岁以下[^\n]{0,6}(?:的|占比)?[^\n]{0,6}(?:不能超过|不超过|≤|<)\s*(\d+)%/);
- if (underAgeRatio) requirements.push(`${underAgeRatio[1]}岁以下占比≤${underAgeRatio[2]}%`);
- const interestTags = text.match(/兴趣[^\n]{0,6}(?:前三)?标签[^\n]{0,6}(?:含|包含)\s*(\d+)\s*个/);
- if (interestTags) requirements.push(`兴趣前三标签含${interestTags[1]}个`);
- const ipMustBe = text.match(/(?:达人)?\s*ip\s*(?:必须|要求|需|为|在)\s*([^\s,,。;\n]+)/i);
- if (ipMustBe) requirements.push(`达人IP必须${ipMustBe[1]}`);
- if (/(?:同|同一)个?省(?:份)?[^\n]{0,12}(?:不要|不|禁止)?\s*跨区域/.test(text)) requirements.push('同省KOL不要跨区域');
- return unique(requirements);
- }
- function normalizeTextInput(value) {
- if (!value) return '';
- if (Array.isArray(value)) return value.map(normalizeTextInput).filter(Boolean).join('\n');
- if (typeof value === 'object') return Object.values(value).map(normalizeTextInput).filter(Boolean).join('\n');
- return String(value || '').trim();
- }
- function buildContextSources({ input, briefText, productContextText, chatContextText, referenceLinks = [], referenceAccounts = [] }) {
- return {
- brief: Boolean(input.brief || input.briefPath || input.briefText || input.text || briefText),
- product: Boolean(productContextText),
- chat: Boolean(chatContextText),
- reference: Boolean((input.referenceLinks || []).length || (input.referenceAccounts || []).length || referenceLinks.length || referenceAccounts.length)
- };
- }
- function resolvePlatformTargets(text) {
- const targets = {};
- const tableRows = text.split(/\r?\n/)
- .filter(line => /^\|\s*(小红书|抖音|B站|bilibili|xiaohongshu|douyin)\s*\|/.test(line));
- for (const line of tableRows) {
- const cells = line.split('|').map(cell => cell.trim()).filter(Boolean);
- const platform = normalizePlatforms([cells[0]])[0];
- const count = Number((cells[1] || '').match(/(\d+)/)?.[1] || 0);
- if (platform && count) targets[platform] = count;
- }
- for (const match of text.matchAll(/(小红书|抖音|B站|bilibili|xiaohongshu|douyin)\s*[::]?\s*(\d+)\s*(?:人|位|个|账号|达人)/g)) {
- const platform = normalizePlatforms([match[1]])[0];
- const count = Number(match[2] || 0);
- if (platform && count) targets[platform] = Math.max(Number(targets[platform] || 0), count);
- }
- return targets;
- }
- function sumTargets(platformTargets) {
- return Object.values(platformTargets || {}).reduce((sum, count) => sum + Number(count || 0), 0);
- }
- function resolveBrand(text) {
- const lines = text.split(/\r?\n/).map(cleanMarkdownLine).filter(Boolean);
- const explicit = lines.find(line => /^(客户名称|客户|品牌名称|品牌)[::]/.test(line));
- if (explicit) return explicit.replace(/^(客户名称|客户|品牌名称|品牌)[::]\s*/, '').trim();
- const productLine = lines.find(line => /本次推广产品|推广产品/.test(line));
- if (productLine) {
- const product = productLine.replace(/^(本次推广产品|推广产品)[::]\s*/, '').trim();
- const match = product.match(/^(.{2,12}?)(?:修护|精华|面膜|口红|产品)/);
- if (match) return match[1];
- }
- return '';
- }
- function resolveProduct(text) {
- const lines = text.split(/\r?\n/).map(cleanMarkdownLine).filter(Boolean);
- const explicit = lines.find(line => /^(产品|推广产品|本次推广产品)[::]/.test(line));
- return explicit ? explicit.replace(/^(产品|推广产品|本次推广产品)[::]\s*/, '').trim() : '';
- }
- function cleanMarkdownLine(line) {
- return String(line || '')
- .replace(/^\s*[-*#]+\s*/, '')
- .replace(/\*\*/g, '')
- .replace(/\s+/g, ' ')
- .trim();
- }
- function resolveTargetCount(text) {
- const tableRows = text.split(/\r?\n/)
- .filter(line => /^\|\s*(小红书|抖音|B站|bilibili|xiaohongshu|douyin)\s*\|/.test(line));
- const counts = tableRows
- .map(line => {
- const cells = line.split('|').map(cell => cell.trim()).filter(Boolean);
- const countCell = cells[1] || '';
- const match = countCell.match(/(\d+)\s*(?:人|位|个|账号|达人)?/);
- return match ? Number(match[1]) : 0;
- })
- .filter(Boolean);
- if (counts.length) return counts.reduce((sum, count) => sum + count, 0);
- const direct = text.match(/(?:需求人数|推荐数量|目标数量)[^\d]{0,12}(\d+)\s*(?:人|位|个|账号|达人)?/);
- return direct ? Number(direct[1]) : 0;
- }
- function resolvePlatforms(input, text) {
- if (Array.isArray(input.platforms) && input.platforms.length) return normalizePlatforms(input.platforms);
- const platforms = [];
- if (/小红书|xiaohongshu|xhs/i.test(text)) platforms.push('xiaohongshu');
- if (/抖音|douyin/i.test(text)) platforms.push('douyin');
- if (/B站|bilibili|哔哩/i.test(text)) platforms.push('bilibili');
- return platforms.length ? platforms : ['xiaohongshu', 'douyin'];
- }
- function normalizePlatforms(platforms) {
- const map = {
- xhs: 'xiaohongshu',
- '小红书': 'xiaohongshu',
- xiaohongshu: 'xiaohongshu',
- '抖音': 'douyin',
- douyin: 'douyin',
- 'B站': 'bilibili',
- bilibili: 'bilibili'
- };
- return [...new Set(platforms.map(item => map[String(item)] || String(item)))];
- }
- function resolveKeywords(input, text) {
- const provided = Array.isArray(input.keywords) ? input.keywords : String(input.keyword || '').split(/[,\s,、]+/);
- const categoryTermMap = {
- 护肤美妆: ['敏感肌', '屏障', '修护', '护肤', '成分党', '成分拆解', '美妆', '测评', '空瓶', '胶原蛋白', '面膜', '精华', '防晒', '洁面', '妆教', '妆容教程', '步骤教程', '产品合集', '日常妆容', '口红', '彩妆'],
- 母婴: ['DHA', '营养师', '育婴师', '妈妈', '母婴', '萌娃', '记录生活', '618合集', '干货科普', '儿童营养', '宝宝', '备孕'],
- 食品餐饮: ['食品', '零食', '低糖', '健康', '控糖', '健身', '办公室零食', '试吃', '开箱', '真实吃法', '配料表', '成分党', '燕麦', '蛋白', '餐饮', '美食', '探店', '本地生活', '同城转化', '门店拍摄', '餐饮创业', '小面', '加盟', '店铺', '门面', '店面', '店招', '门头'],
- 家居生活: ['家居', '家清', '清洁', '收纳', '租房', '家务', '厨房', '油污', '日化', '卫生间', '真实使用', '前后对比', '步骤教程', '妈妈'],
- B2B软件: ['SaaS', 'B2B', '项目管理', '效率工具', '创业', '企业管理', '职场效率', '教程型', '案例型', '协同办公', '软件', '工具'],
- 服饰穿搭: ['时尚穿搭', '穿搭', '港风', '成熟风', '通勤穿搭', '女装', '男装', '服饰', '搭配', '精致生活', '职场穿搭', '轻熟', '高级感'],
- 生活方式: ['种草', '国货', '通勤', '生活方式', '测评', '真实体验', '素人感', '开箱', '教程', '案例', 'Vlog', '生活分享', '生活记录', '风景', '社交场景', '颜值', '自拍', '精致生活']
- };
- const category = inferCategory(text);
- const commonTerms = [
- '种草', '测评', '开箱', '教程', '真实体验', '素人感', '生活方式',
- '女性', '妈妈', '职场', '同城', '本地生活', 'Vlog', '生活分享', '生活记录', '颜值'
- ];
- const domainTerms = [
- ...(categoryTermMap[category] || []),
- ...commonTerms,
- ...Object.values(categoryTermMap).flat()
- ];
- const explicitTerms = extractLabeledTerms(text);
- const hits = domainTerms.filter(term => text.includes(term));
- return unique([...provided, ...hits, ...explicitTerms]
- .map(cleanKeywordTerm)
- .filter(Boolean)).slice(0, 12);
- }
- function extractLabeledTerms(text) {
- const terms = [];
- for (const line of String(text || '').split(/\r?\n/).map(cleanMarkdownLine).filter(Boolean)) {
- const kv = parseKeyValueLine(line);
- if (!kv || !/关键词|关键字|风格|偏好|要求|重点|目标|任务|人群|内容/.test(kv.key)) continue;
- terms.push(...splitKeywordTerms(kv.value));
- }
- return terms;
- }
- function splitKeywordTerms(value) {
- return String(value || '')
- .replace(/找(小红书|抖音|B站|bilibili|xiaohongshu|douyin)?\s*\d+\s*(?:个|位|人|账号|达人)?/gi, ' ')
- .replace(/(小红书|抖音|B站|bilibili|xiaohongshu|douyin)\s*\d+\s*(?:个|位|人|账号|达人)?/gi, ' ')
- .replace(/\d+(?:\.\d+)?\s*(?:万|k|K)?\s*(?:-|到|至|~)\s*\d+(?:\.\d+)?\s*(?:万|k|K)?/g, ' ')
- .split(/[,,、;;\/|和与&\s]+/)
- .map(cleanKeywordTerm)
- .filter(Boolean);
- }
- function cleanKeywordTerm(value) {
- const cleaned = String(value || '')
- .replace(/^(找|寻找|需要|招募|重点看|要求|偏好|风格偏|不要|排除)/, '')
- .replace(/(?:博主|达人|账号|内容创作者|类内容创作者|类达人|类博主|流量号|经验|优先)$/g, '')
- .replace(/类$/g, '')
- .trim();
- if (cleaned.length < 2 || cleaned.length > 12) return '';
- if (/^(预算|粉丝|平台|人数|需求|目标|任务|产品|品牌|小红书|抖音|B站|bilibili|xiaohongshu|douyin)$/i.test(cleaned)) return '';
- return cleaned;
- }
- function resolveBudget(input, text) {
- if (input.budgetRange) return normalizeRange(input.budgetRange, { min: 0, max: 20000 });
- const upperBound = text.match(/(?:单个预算|预算|报价|单人预算|单篇预算)[^\d]{0,12}([\d,.]+)\s*(?:元|块|rmb|RMB)?\s*(?:内|以内|以下|封顶|上限)/i);
- if (upperBound) return { min: 0, max: toNumber(upperBound[1]) };
- const money = [...text.matchAll(/(?:¥|¥)?\s*([\d,.]+)\s*(?:-|到|至|~)\s*(?:¥|¥)?\s*([\d,.]+)/g)]
- .map(match => [toNumber(match[1]), toNumber(match[2])])
- .filter(([min, max]) => max > 0);
- const picked = money.find(([min, max]) => max >= 1000) || money[0];
- return picked ? { min: picked[0], max: picked[1] } : { min: 0, max: 20000 };
- }
- function resolveFanRange(input, text) {
- if (input.fanRange) return normalizeRange(input.fanRange, { min: 0, max: 500000 });
- const ranges = [];
- const lines = text.split(/\r?\n/);
- let inFanSection = false;
- for (const line of lines) {
- if (/粉丝量范围|粉丝范围|粉丝数范围/.test(line)) {
- inFanSection = true;
- continue;
- }
- if (inFanSection && /^#{1,4}\s+/.test(line) && !/粉丝/.test(line)) break;
- if (!inFanSection && !/粉丝|小红书|抖音|B站/.test(line)) continue;
- const match = line.match(/(\d+(?:\.\d+)?)\s*万?\s*(?:-|到|至|~)\s*(\d+(?:\.\d+)?)\s*万?\s*(?:粉|粉丝)?/);
- if (!match) continue;
- const hasWan = match[0].includes('万') || /粉丝/.test(line);
- const min = Number(match[1]) * (hasWan ? 10000 : 1);
- const max = Number(match[2]) * (hasWan ? 10000 : 1);
- if (max >= 10000) ranges.push({ min, max });
- }
- if (!ranges.length) return { min: 0, max: 500000 };
- return {
- min: Math.min(...ranges.map(range => range.min)),
- max: Math.max(...ranges.map(range => range.max))
- };
- }
- function normalizeRange(value, fallback) {
- return {
- min: Number(value.min ?? fallback.min),
- max: Number(value.max ?? fallback.max)
- };
- }
- function inferCategory(text) {
- if (/SaaS|B2B|项目管理|效率工具|企业管理|职场效率|协同办公|软件|CRM|企业级|数字化/.test(text)) return 'B2B软件';
- if (/家清|清洁|家居|收纳|洗衣|厨房|油污|卫生间|日化|租房|家务/.test(text)) return '家居生活';
- if (/食品|饮料|餐饮|美食|零食|低糖|控糖|燕麦|蛋白|试吃|配料表|探店|本地生活|同城|门店|小面|加盟|餐饮创业/.test(text)) return '食品餐饮';
- if (/母婴|宝宝|孕|育婴|备孕|DHA|儿童营养/.test(text)) return '母婴';
- if (/护肤|面膜|敏感肌|屏障|修护|精华|美妆|口红|胶原蛋白|洁面|防晒|泛红|刺痛/.test(text)) return '护肤美妆';
- if (/时尚穿搭|穿搭|港风|成熟风|通勤穿搭|女装|男装|服饰|搭配|轻熟|高级感/.test(text)) return '服饰穿搭';
- return '生活方式';
- }
- function inferRegion(text) {
- const cities = ['北京', '上海', '广州', '深圳', '杭州', '成都', '南京', '武汉', '重庆', '西安'];
- return cities.filter(city => text.includes(city));
- }
- function resolveList(inputList, text, vocabulary) {
- const provided = normalizeListInput(inputList);
- const hits = vocabulary.filter(item => text.includes(item));
- return [...new Set([...provided, ...hits].map(String).filter(Boolean))];
- }
- function normalizeListInput(inputList) {
- if (Array.isArray(inputList)) return inputList.flatMap(normalizeListInput);
- if (inputList == null || inputList === false) return [];
- if (typeof inputList === 'string') {
- return inputList.split(/[,,;;|]/).map(item => item.trim()).filter(Boolean);
- }
- return [inputList];
- }
- function toNumber(value) {
- return Number(String(value || '').replace(/,/g, '')) || 0;
- }
- function resolvePlatformTargets(text) {
- const targets = {};
- for (const line of text.split(/\r?\n/).map(cleanMarkdownLine).filter(Boolean)) {
- const cells = splitCells(line);
- if (cells.length >= 2) {
- const platform = normalizePlatforms([cells[0]])[0];
- const count = Number((cells[1] || '').match(/(\d+)/)?.[1] || 0);
- if (isKnownPlatform(platform) && count) targets[platform] = Math.max(Number(targets[platform] || 0), count);
- }
- for (const match of line.matchAll(/(小红书|抖音|B站|bilibili|xiaohongshu|douyin)\s*[::]?\s*(\d+)\s*(?:人|位|个|账号|达人)/gi)) {
- const platform = normalizePlatforms([match[1]])[0];
- const count = Number(match[2] || 0);
- if (isKnownPlatform(platform) && count) targets[platform] = Math.max(Number(targets[platform] || 0), count);
- }
- }
- return targets;
- }
- function resolveBrand(text) {
- const lines = text.split(/\r?\n/).map(cleanMarkdownLine).filter(Boolean);
- for (const line of lines) {
- const kv = parseKeyValueLine(line);
- if (!kv) continue;
- if (/^(客户名称|客户|品牌名称|品牌)$/i.test(kv.key)) return kv.value;
- }
- for (const line of lines) {
- const kv = parseKeyValueLine(line);
- if (!kv || !/^(产品|推广产品|本次推广产品)$/i.test(kv.key)) continue;
- const match = kv.value.match(/^(.{2,18}?)(?:修护|精华|面膜|口红|产品|(|\()/);
- if (match) return match[1].trim();
- return kv.value;
- }
- return '';
- }
- function resolveProduct(text) {
- const lines = text.split(/\r?\n/).map(cleanMarkdownLine).filter(Boolean);
- for (const line of lines) {
- const kv = parseKeyValueLine(line);
- if (kv && /^(产品|推广产品|本次推广产品)$/i.test(kv.key)) return kv.value;
- }
- return '';
- }
- function resolveTargetCount(text) {
- const counts = Object.values(resolvePlatformTargets(text)).map(Number).filter(Boolean);
- if (counts.length) return counts.reduce((sum, count) => sum + count, 0);
- const direct = text.match(/(?:需求人数|推荐数量|目标数量)[^\d]{0,12}(\d+)\s*(?:人|位|个|账号|达人)?/);
- return direct ? Number(direct[1]) : 0;
- }
- function resolvePlatforms(input, text) {
- if (Array.isArray(input.platforms) && input.platforms.length) return normalizePlatforms(input.platforms);
- const platforms = [];
- if (/小红书|xiaohongshu|xhs/i.test(text)) platforms.push('xiaohongshu');
- if (/抖音|douyin/i.test(text)) platforms.push('douyin');
- if (/B站|bilibili|哔哩/i.test(text)) platforms.push('bilibili');
- return platforms.length ? [...new Set(platforms)] : ['xiaohongshu', 'douyin'];
- }
- function normalizePlatforms(platforms) {
- const map = {
- xhs: 'xiaohongshu',
- 小红书: 'xiaohongshu',
- xiaohongshu: 'xiaohongshu',
- 抖音: 'douyin',
- douyin: 'douyin',
- B站: 'bilibili',
- b站: 'bilibili',
- bilibili: 'bilibili',
- 哔哩哔哩: 'bilibili'
- };
- return [...new Set(platforms.map(item => map[String(item).trim()] || String(item).trim()))];
- }
- function resolveFanRange(input, text) {
- if (input.fanRange) return normalizeRange(input.fanRange, { min: 0, max: 500000 });
- const ranges = [];
- for (const line of text.split(/\r?\n/).map(cleanMarkdownLine).filter(Boolean)) {
- if (!/粉丝|小红书|抖音|B站|bilibili|xiaohongshu|douyin/i.test(line)) continue;
- const cells = splitCells(line);
- const scan = isolateFanRangeText(cells.length >= 2 && /粉丝/.test(cells[0]) ? cells.slice(1).join(' ') : line);
- for (const match of scan.matchAll(/(\d+(?:\.\d+)?)\s*(万|w|W|k|K)?\s*(?:-|到|至|~)\s*(\d+(?:\.\d+)?)\s*(万|w|W|k|K)?/g)) {
- const min = normalizeFanCount(match[1], match[2], match[4]);
- const max = normalizeFanCount(match[3], match[4], match[2]);
- if (max >= 1000 && min > 0 && min <= max) ranges.push({ min, max });
- }
- }
- if (!ranges.length) return { min: 0, max: 500000 };
- return {
- min: Math.min(...ranges.map(range => range.min)),
- max: Math.max(...ranges.map(range => range.max))
- };
- }
- function isolateFanRangeText(line) {
- const text = String(line || '');
- const fanIndex = text.search(/粉丝|fans?/i);
- const platformOnly = fanIndex < 0;
- const start = platformOnly ? 0 : fanIndex;
- let scan = text.slice(start);
- const budgetIndex = scan.search(/预算|报价|单篇|费用|价格|投放|CPE|CPM|阅读单价|合作费|坑位费/i);
- if (budgetIndex >= 0) scan = scan.slice(0, budgetIndex);
- return scan;
- }
- function normalizeFanCount(value, unit, oppositeUnit) {
- const numeric = Number(value || 0);
- if (!numeric) return 0;
- const normalizedUnit = String(unit || '').toLowerCase();
- const normalizedOppositeUnit = String(oppositeUnit || '').toLowerCase();
- if (normalizedUnit === '万' || normalizedUnit === 'w') return Math.round(numeric * 10000);
- if (normalizedUnit === 'k') return Math.round(numeric * 1000);
- if (normalizedOppositeUnit === '万' || normalizedOppositeUnit === 'w') {
- return numeric >= 1000 ? Math.round(numeric) : Math.round(numeric * 10000);
- }
- if (normalizedOppositeUnit === 'k') return numeric >= 1000 ? Math.round(numeric) : Math.round(numeric * 1000);
- return Math.round(numeric);
- }
- function parseKeyValueLine(line) {
- const cells = splitCells(line);
- if (cells.length >= 2) return { key: cells[0], value: cells.slice(1).join(' | ').trim() };
- const match = line.match(/^(.{2,14}?)[::]\s*(.+)$/);
- return match ? { key: match[1].trim(), value: match[2].trim() } : null;
- }
- function splitCells(line) {
- return String(line || '')
- .split('|')
- .map(cell => cell.trim())
- .filter(Boolean)
- .filter(cell => !/^[-:]{2,}$/.test(cell));
- }
- function isKnownPlatform(platform) {
- return platform === 'xiaohongshu' || platform === 'douyin' || platform === 'bilibili';
- }
- function resolveReferenceLinks(input, text) {
- const provided = Array.isArray(input.referenceLinks) ? input.referenceLinks.map(normalizeReferenceLink).filter(Boolean) : [];
- const urls = [...String(text || '').matchAll(/https?:\/\/[^\s|,,;;))]+/gi)]
- .map(match => match[0].replace(/[。.!?]+$/, ''));
- const parsed = unique(urls).map(url => normalizeReferenceLink(url));
- return dedupeObjects([...provided, ...parsed], item => item.url || `${item.platform}:${item.noteId}`);
- }
- function normalizeReferenceLink(item) {
- if (!item) return null;
- if (typeof item === 'string') {
- return {
- url: item,
- platform: inferPlatformFromUrl(item),
- noteId: extractXhsNoteId(item),
- contentType: inferContentTypeFromText(item),
- needsEnrichment: true
- };
- }
- const url = String(item.url || item.link || '').trim();
- if (!url) return null;
- return {
- url,
- platform: item.platform || inferPlatformFromUrl(url),
- noteId: item.noteId || item.note_id || extractXhsNoteId(url),
- contentType: item.contentType || item.type || inferContentTypeFromText(`${item.sourceText || ''} ${url}`),
- sourceText: item.sourceText || '',
- needsEnrichment: item.needsEnrichment !== false
- };
- }
- function resolveReferenceAccounts(input, text) {
- const provided = Array.isArray(input.referenceAccounts) ? input.referenceAccounts : [];
- const accounts = provided.map(item => normalizeReferenceAccount(item)).filter(Boolean);
- const lines = String(text || '').split(/\r?\n/).map(cleanMarkdownLine).filter(Boolean);
- for (const line of lines) {
- if (!/参考|对标|标杆|账号|博主|达人|redId|小红书号|抖音号/i.test(line)) continue;
- if (/^(账号类型|.*参考账号|.*参考博主)[::]?$/.test(line)) continue;
- const withoutUrls = line.replace(/https?:\/\/\S+/gi, '').trim();
- const kv = parseKeyValueLine(withoutUrls);
- if (kv && /^(账号类型|参考账号|参考博主|风格参考)$/i.test(kv.key)) continue;
- const value = kv ? kv.value : withoutUrls;
- for (const part of value.split(/[、,,;;\/]+/).map(item => item.trim()).filter(Boolean)) {
- if (/^(图文|视频|名单字段|关键词参考|风格参考|参考账号|参考博主|账号类型)$/.test(part)) continue;
- if (/参考账号|参考博主|账号类型/.test(part)) continue;
- if (part.length < 2 || part.length > 40) continue;
- accounts.push({
- name: part,
- platform: inferPlatformFromText(line),
- sourceText: line,
- needsEnrichment: true
- });
- }
- }
- return dedupeObjects(accounts, item => `${item.platform || ''}:${item.name || item.redId || item.userId || item.url || ''}`);
- }
- function normalizeReferenceAccount(item) {
- if (!item) return null;
- if (typeof item === 'string') {
- return { name: item, platform: inferPlatformFromText(item), sourceText: item, needsEnrichment: true };
- }
- return {
- name: item.name || item.nickname || item.displayName || '',
- redId: item.redId || item.red_id || '',
- userId: item.userId || item.user_id || '',
- platform: item.platform || inferPlatformFromText(`${item.url || ''} ${item.sourceText || ''}`),
- url: item.url || '',
- sourceText: item.sourceText || '',
- needsEnrichment: item.needsEnrichment !== false
- };
- }
- function resolveReferenceStyleAnchors(text, referenceLinks, referenceAccounts) {
- const anchors = [];
- let currentBucket = '';
- for (const rawLine of String(text || '').split(/\r?\n/)) {
- const line = cleanMarkdownLine(rawLine);
- if (!line) continue;
- if (/参考账号|参考博主|对标账号|标杆账号|账号类型/.test(line)) {
- currentBucket = line.replace(/[::]\s*$/, '');
- }
- if (/https?:\/\//i.test(line)) {
- const links = (referenceLinks || []).filter(item => line.includes(item.url));
- anchors.push({
- bucket: currentBucket || inferReferenceBucket(line),
- contentType: /视频|video/i.test(line) ? 'video' : /图文|image|note/i.test(line) ? 'image_text' : (links[0]?.contentType || ''),
- platform: inferPlatformFromText(line) || links[0]?.platform || '',
- sourceText: line,
- links: links.map(item => item.url),
- needsSocialAnalysisEnrichment: true
- });
- }
- }
- for (const account of referenceAccounts || []) {
- anchors.push({
- bucket: inferReferenceBucket(account.sourceText || account.name || ''),
- platform: account.platform || '',
- sourceText: account.sourceText || account.name || '',
- accountHints: [account.name || account.redId || account.userId].filter(Boolean),
- needsSocialAnalysisEnrichment: true
- });
- }
- return dedupeObjects(anchors, item => `${item.bucket}:${item.sourceText}:${(item.links || []).join('|')}:${(item.accountHints || []).join('|')}`);
- }
- function resolveReferenceSignals(text, anchors) {
- const vocabulary = [
- '干货科普', '营养师', '育婴师', '妈妈', '母婴', '618合集', '合集', '图文', '视频',
- '素人感', '真实体验', '真实测评', '通勤生活', '成分党', '敏感肌', '开箱体验',
- '不硬广', '好物分享', '生活化', '专业背书', '选品建议'
- ];
- const source = [
- text,
- ...(anchors || []).map(item => `${item.bucket || ''} ${item.sourceText || ''}`)
- ].join('\n');
- return vocabulary.filter(item => source.includes(item));
- }
- function inferReferenceBucket(text) {
- if (/618|合集/.test(text)) return '618合集参考';
- if (/干货|科普|营养师|育婴师/.test(text)) return '干货科普参考';
- if (/风格/.test(text)) return '风格参考';
- return '参考账号';
- }
- function inferPlatformFromUrl(url) {
- if (/xhslink|xiaohongshu/i.test(url)) return 'xiaohongshu';
- if (/douyin/i.test(url)) return 'douyin';
- if (/bilibili|b23\.tv/i.test(url)) return 'bilibili';
- return '';
- }
- function inferPlatformFromText(text) {
- const value = String(text || '');
- if (/小红书|xhs|xiaohongshu|xhslink/i.test(value)) return 'xiaohongshu';
- if (/抖音|douyin/i.test(value)) return 'douyin';
- if (/B站|bilibili|b23\.tv/i.test(value)) return 'bilibili';
- return inferPlatformFromUrl(value);
- }
- function inferContentTypeFromText(text) {
- const value = String(text || '');
- if (/视频|video|douyin|bilibili|b23\.tv/i.test(value)) return 'video';
- if (/图文|image|note|xhslink|xiaohongshu/i.test(value)) return 'image_text';
- if (/主页|profile|account|home/i.test(value)) return 'account_home';
- return '';
- }
- function extractXhsNoteId(url) {
- const match = String(url || '').match(/\/(?:discovery\/item|explore)\/([0-9a-f]{16,32})/i);
- return match ? match[1] : '';
- }
- function unique(items) {
- return [...new Set(items.filter(Boolean).map(String))];
- }
- function dedupeObjects(items, keyFn) {
- const seen = new Set();
- const result = [];
- for (const item of items.filter(Boolean)) {
- const key = keyFn(item);
- if (seen.has(key)) continue;
- seen.add(key);
- result.push(item);
- }
- return result;
- }
- module.exports = {
- parseBrief
- };
|