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 };