407 lines
9.7 KiB
TypeScript
407 lines
9.7 KiB
TypeScript
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");
|
||
});
|
||
}
|