ai-chatbot/lib/product-planning/keyword-segmentation.ts
Your Name 478562c312
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: migrate product planning workspace
2026-03-26 16:38:48 +08:00

407 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, KeywordAnalysisRow>();
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");
});
}