import type { KeywordAnalysisRow, KeywordInputRow } from "./types"; import { normalizeText } from "./utils"; const PRODUCT_TERMS = [ "换鞋凳", "换鞋椅", "穿鞋凳", "穿鞋椅", "储物凳", "收纳凳", "鞋柜", "鞋架", "鞋凳", "沙发", "茶几", "电视柜", "书桌", "衣柜", "床头柜", "餐桌", "椅子", "书架", "置物架", "电脑桌", "梳妆台", "床垫", "床架", "凳子", "桌子", "衣架", "挂钩", "热敷包", "盐袋", "拖鞋", "凉拖", "棉拖", "人字拖", "耳环", "项链", "戒指", "手链", "白酒", "红酒", ]; const MATERIAL_TERMS = [ "实木", "木质", "板式", "布艺", "皮质", "金属", "不锈钢", "铁艺", "藤编", "塑料", "大理石", "玻璃", "硅胶", "棉麻", "羊毛", "陶瓷", "亚克力", "牛皮", "皮革", "绒布", "铝合金", "abs", "pvc", ]; const AUDIENCE_TERMS = [ "儿童", "成人", "男士", "女士", "学生", "老人", "孕妇", "婴儿", "宝宝", "青年", "中年", "老年", "情侣", "新生儿", ]; const SCENARIO_TERMS = [ "家用", "办公", "客厅", "卧室", "厨房", "浴室", "阳台", "书房", "门口", "玄关", "入户", "户外", "旅行", "露营", "车载", "宿舍", ]; const ACTION_TERMS = [ "换鞋", "收纳", "储物", "折叠", "防滑", "防水", "保暖", "透气", "静音", "可折叠", "可拆洗", "可移动", "防臭", "耐磨", "加厚", "加宽", "加深", "大容量", ]; const ATTRIBUTE_TERMS = [ "简约", "现代", "北欧", "中式", "复古", "轻奢", "ins", "新款", "小户型", "多功能", "可坐", "组合", "一体", "年份", "2024", "2025", "2026", "2027", "春夏", "秋冬", "加长", "加大", "双层", "三层", ]; const BASE_DICTIONARY = Array.from( new Set([ ...PRODUCT_TERMS, ...MATERIAL_TERMS, ...AUDIENCE_TERMS, ...SCENARIO_TERMS, ...ACTION_TERMS, ...ATTRIBUTE_TERMS, ]) ).sort((left, right) => right.length - left.length); const YEAR_PATTERN = /^(19\d{2}|20\d{2}|21\d{2})(?:[年款版新])?/; const ALPHANUMERIC_PATTERN = /^[a-z0-9&-]+/i; function isSeparator(char: string) { return /[\s,,.。/\\|+_\-()[\]{}]/.test(char); } function splitUnknownChunk(chunk: string) { const text = normalizeText(chunk); if (!text) return [] as string[]; if (text.length <= 4) return [text]; const result: string[] = []; for (let index = 0; index < text.length; index += 2) { const next = text.slice(index, index + 2); if (!next) continue; if (next.length === 1 && result.length > 0) { result[result.length - 1] += next; continue; } result.push(next); } return result; } function getDictionary(categoryWords: string[]) { const normalizedCategoryWords = Array.from( new Set(categoryWords.map((item) => normalizeText(item)).filter(Boolean)) ).sort((left, right) => right.length - left.length); const dictionary = Array.from( new Set([...normalizedCategoryWords, ...BASE_DICTIONARY]) ).sort((left, right) => right.length - left.length); return { normalizedCategoryWords, dictionary, }; } function hasKnownMatchAt(text: string, start: number, dictionary: string[]) { const remaining = text.slice(start); if (YEAR_PATTERN.test(remaining) || ALPHANUMERIC_PATTERN.test(remaining)) { return true; } return dictionary.some((token) => remaining.startsWith(token)); } function matchLongestToken(text: string, start: number, dictionary: string[]) { const remaining = text.slice(start); const yearMatch = remaining.match(YEAR_PATTERN); if (yearMatch?.[0]) return yearMatch[0]; const alphanumericMatch = remaining.match(ALPHANUMERIC_PATTERN); if (alphanumericMatch?.[0]) return alphanumericMatch[0]; return dictionary.find((token) => remaining.startsWith(token)) || ""; } export function segmentKeyword(keyword: string, categoryWords: string[] = []) { const source = normalizeText(keyword).replace(/\s+/g, ""); if (!source) return [] as string[]; const { dictionary } = getDictionary(categoryWords); const tokens: string[] = []; let index = 0; while (index < source.length) { const char = source[index]; if (isSeparator(char)) { index += 1; continue; } const matchedToken = matchLongestToken(source, index, dictionary); if (matchedToken) { tokens.push(matchedToken); index += matchedToken.length; continue; } let end = index + 1; while (end < source.length) { if (isSeparator(source[end]) || hasKnownMatchAt(source, end, dictionary)) { break; } end += 1; } const chunk = source.slice(index, end); tokens.push(...splitUnknownChunk(chunk)); index = end; } return tokens.filter(Boolean); } export function inferKeywordCategory(keyword: string, categoryWords: string[] = []) { const value = normalizeText(keyword).toLowerCase(); const categorySet = new Set(categoryWords.map((item) => normalizeText(item).toLowerCase())); if (categorySet.has(value)) return "品类大词"; if (MATERIAL_TERMS.some((item) => item.toLowerCase() === value)) return "材质词"; if (AUDIENCE_TERMS.some((item) => item.toLowerCase() === value)) return "人群词"; if (SCENARIO_TERMS.some((item) => item.toLowerCase() === value)) return "场景词"; if (ACTION_TERMS.some((item) => item.toLowerCase() === value)) return "需求词"; if (ATTRIBUTE_TERMS.some((item) => item.toLowerCase() === value)) return "属性词"; if (YEAR_PATTERN.test(value) || ALPHANUMERIC_PATTERN.test(value)) return "属性词"; return "未分类"; } export function analyzeKeywordInputs( inputs: KeywordInputRow[], options?: { categoryWords?: string[] } ) { const categoryWords = options?.categoryWords || []; const aggregates = new Map< string, { occurrenceCount: number; popularityTotal: number; popularityCount: number; conversionTotal: number; conversionCount: number; } >(); for (const input of inputs) { const keyword = normalizeText(input.keyword); if (!keyword) continue; const uniqueTokens = new Set(segmentKeyword(keyword, categoryWords)); for (const token of uniqueTokens) { const entry = aggregates.get(token) || { occurrenceCount: 0, popularityTotal: 0, popularityCount: 0, conversionTotal: 0, conversionCount: 0, }; entry.occurrenceCount += 1; if (typeof input.searchPopularity === "number") { entry.popularityTotal += input.searchPopularity; entry.popularityCount += 1; } if (typeof input.conversionRate === "number") { entry.conversionTotal += input.conversionRate; entry.conversionCount += 1; } aggregates.set(token, entry); } } const rows: KeywordAnalysisRow[] = Array.from(aggregates.entries()).map(([keyword, stats]) => ({ keyword, segments: [keyword], category: inferKeywordCategory(keyword, categoryWords), occurrenceCount: stats.occurrenceCount, searchPopularity: stats.popularityCount > 0 ? Number((stats.popularityTotal / stats.popularityCount).toFixed(2)) : undefined, conversionRate: stats.conversionCount > 0 ? Number((stats.conversionTotal / stats.conversionCount).toFixed(4)) : undefined, })); return rows.sort((left, right) => { if (right.occurrenceCount !== left.occurrenceCount) { return right.occurrenceCount - left.occurrenceCount; } return left.keyword.localeCompare(right.keyword, "zh-CN"); }); } export function aggregateManualKeywordRows(rows: KeywordAnalysisRow[]) { const aggregates = new Map(); for (const row of rows) { const keyword = normalizeText(row.keyword); if (!keyword) continue; const existing = aggregates.get(keyword); if (!existing) { aggregates.set(keyword, { keyword, segments: row.segments.length > 0 ? row.segments : [keyword], category: row.category || "未分类", occurrenceCount: Math.max(row.occurrenceCount, 1), searchPopularity: row.searchPopularity, conversionRate: row.conversionRate, }); continue; } const totalCount = existing.occurrenceCount + Math.max(row.occurrenceCount, 1); const popularityValues = [existing.searchPopularity, row.searchPopularity].filter( (item): item is number => typeof item === "number" ); const conversionValues = [existing.conversionRate, row.conversionRate].filter( (item): item is number => typeof item === "number" ); aggregates.set(keyword, { keyword, segments: existing.segments.length > 0 ? existing.segments : [keyword], category: existing.category !== "未分类" ? existing.category : row.category || "未分类", occurrenceCount: totalCount, searchPopularity: popularityValues.length > 0 ? Number( ( popularityValues.reduce((sum, value) => sum + value, 0) / popularityValues.length ).toFixed(2) ) : undefined, conversionRate: conversionValues.length > 0 ? Number( ( conversionValues.reduce((sum, value) => sum + value, 0) / conversionValues.length ).toFixed(4) ) : undefined, }); } return Array.from(aggregates.values()).sort((left, right) => { if (right.occurrenceCount !== left.occurrenceCount) { return right.occurrenceCount - left.occurrenceCount; } return left.keyword.localeCompare(right.keyword, "zh-CN"); }); }