ai-chatbot/lib/product-planning/file-parsers.ts

374 lines
12 KiB
TypeScript

import Papa from "papaparse";
import type {
KeywordAnalysisRow,
KeywordInputRow,
TaobaoQaRow,
TaobaoReviewRow,
} from "./types";
import {
normalizeHeader,
normalizeText,
parseInteger,
parseOfficialFlag,
parseRangeValue,
splitUrlCell,
} from "./utils";
import { aggregateManualKeywordRows } from "./keyword-segmentation";
type SpreadsheetRecord = Record<string, unknown>;
function normalizeSpreadsheetCellValue(value: unknown) {
if (value == null) {
return "";
}
if (value instanceof Date) {
return value.toISOString();
}
if (typeof value !== "object") {
return value;
}
const cellObject = value as {
hyperlink?: unknown;
result?: unknown;
richText?: Array<{ text?: unknown }>;
text?: unknown;
};
if (Array.isArray(cellObject.richText)) {
return cellObject.richText.map((part) => String(part.text ?? "")).join("");
}
if (cellObject.result != null) {
return cellObject.result;
}
if (cellObject.text != null) {
return cellObject.text;
}
if (cellObject.hyperlink != null) {
return cellObject.hyperlink;
}
return String(value);
}
const KEYWORD_ALIASES = {
keyword: ["关键词", "搜索词", "搜索关键词", "keyword", "keywords", "query"],
searchPopularity: ["搜索人气", "搜索量", "人气", "searchvolume", "popularity", "volume"],
conversionRate: ["支付转化率", "转化率", "conversionrate", "conversion", "cvr"],
};
const MANUAL_ALIASES = {
keyword: ["词语", "关键词", "搜索词", "word", "keyword"],
category: ["标签分类", "分类", "category", "tagcategory"],
searchPopularity: KEYWORD_ALIASES.searchPopularity,
conversionRate: KEYWORD_ALIASES.conversionRate,
occurrenceCount: ["出现次数", "次数", "count", "occurrencecount"],
};
const REVIEW_ALIASES = {
itemId: ["宝贝id", "商品id", "itemid", "numiid"],
date: ["评论时间", "评价时间", "日期", "时间", "date"],
content: ["评价内容", "评论内容", "内容", "content", "ratecontent"],
appendComment: ["追评", "追加评价", "appendcomment", "addfeedback"],
sku: ["评价所属sku", "sku", "规格", "skuinfo", "auctionsku"],
userId: ["买家id", "用户id", "userid", "displayusernick"],
images: ["评论图片", "图片", "pics", "images"],
videoUrls: ["评价视频", "视频", "videourls", "videolist"],
};
const QA_ALIASES = {
itemId: ["商品id", "宝贝id", "itemid"],
question: ["问题", "提问", "question"],
questionDetail: ["问题补充", "问题详情", "questiondetail", "content"],
answer: ["回答", "答案", "answer"],
answerUser: ["回答用户", "用户", "answeruser"],
answerDate: ["回答时间", "时间", "answerdate", "date"],
isOfficial: ["是否官方", "官方回答", "isofficial", "official"],
};
function matchColumn(record: SpreadsheetRecord, aliases: string[]) {
const aliasSet = new Set(aliases.map((item) => normalizeHeader(item)));
return Object.keys(record).find((key) => aliasSet.has(normalizeHeader(key))) || null;
}
async function readSpreadsheetRecords(file: File) {
const extension = file.name.toLowerCase().split(".").pop() || "";
if (extension === "csv") {
const text = await file.text();
const parsed = Papa.parse<SpreadsheetRecord>(text, {
header: true,
skipEmptyLines: true,
transformHeader: (header) => normalizeText(header),
});
if (parsed.errors.length > 0) {
throw new Error(`CSV 解析失败:${parsed.errors[0]?.message || "未知错误"}`);
}
return parsed.data;
}
const { Workbook } = await import("exceljs");
const workbook = new Workbook();
const buffer = await file.arrayBuffer();
await workbook.xlsx.load(buffer);
const worksheet = workbook.worksheets[0];
if (!worksheet) {
throw new Error("表格中没有可读取的工作表");
}
const rawHeaderValues = worksheet.getRow(1).values;
const headerValues: unknown[] = Array.isArray(rawHeaderValues) ? rawHeaderValues.slice(1) : [];
const headers = headerValues.map((value) => normalizeText(normalizeSpreadsheetCellValue(value)));
const records: SpreadsheetRecord[] = [];
worksheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
if (rowNumber === 1) {
return;
}
const record: SpreadsheetRecord = {};
let hasValue = false;
headers.forEach((header, index) => {
if (!header) {
return;
}
const value = normalizeSpreadsheetCellValue(row.getCell(index + 1).value);
record[header] = value;
if (normalizeText(value)) {
hasValue = true;
}
});
if (hasValue) {
records.push(record);
}
});
return records;
}
export async function parseKeywordInputFile(file: File) {
const extension = file.name.toLowerCase().split(".").pop() || "";
if (extension === "txt") {
const text = await file.text();
const rows = text
.split(/\r?\n/)
.map((line) => normalizeText(line))
.filter(Boolean)
.map<KeywordInputRow>((keyword) => ({ keyword }));
if (rows.length === 0) {
throw new Error("关键词文件中没有有效内容");
}
return rows;
}
if (!["xlsx", "csv"].includes(extension)) {
throw new Error("仅支持 txt、xlsx、csv 文件");
}
const records = await readSpreadsheetRecords(file);
if (records.length === 0) {
throw new Error("关键词表中没有有效数据");
}
const firstRecord = records[0];
const keywordColumn =
matchColumn(firstRecord, KEYWORD_ALIASES.keyword) || Object.keys(firstRecord)[0] || null;
if (!keywordColumn) {
throw new Error("未找到关键词列");
}
const searchPopularityColumn = matchColumn(firstRecord, KEYWORD_ALIASES.searchPopularity);
const conversionRateColumn = matchColumn(firstRecord, KEYWORD_ALIASES.conversionRate);
const rows = records
.map<KeywordInputRow | null>((record) => {
const keyword = normalizeText(record[keywordColumn]);
if (!keyword) return null;
const popularity = searchPopularityColumn
? parseRangeValue(record[searchPopularityColumn])
: null;
const conversion = conversionRateColumn
? parseRangeValue(record[conversionRateColumn])
: null;
return {
keyword,
searchPopularity: typeof popularity === "number" ? popularity : undefined,
conversionRate:
typeof conversion === "number"
? conversion > 1
? Number((conversion / 100).toFixed(4))
: Number(conversion.toFixed(4))
: undefined,
};
})
.filter((item): item is KeywordInputRow => Boolean(item));
if (rows.length === 0) {
throw new Error("未从关键词文件中提取到有效关键词");
}
return rows;
}
export async function parseManualSegmentationFile(file: File) {
const extension = file.name.toLowerCase().split(".").pop() || "";
if (!["xlsx", "csv"].includes(extension)) {
throw new Error("手动分词文件仅支持 xlsx、csv");
}
const records = await readSpreadsheetRecords(file);
if (records.length === 0) {
throw new Error("手动分词表中没有有效数据");
}
const firstRecord = records[0];
const keywordColumn = matchColumn(firstRecord, MANUAL_ALIASES.keyword);
const categoryColumn = matchColumn(firstRecord, MANUAL_ALIASES.category);
if (!keywordColumn || !categoryColumn) {
throw new Error("手动分词表缺少核心列:词语、标签分类");
}
const searchPopularityColumn = matchColumn(firstRecord, MANUAL_ALIASES.searchPopularity);
const conversionRateColumn = matchColumn(firstRecord, MANUAL_ALIASES.conversionRate);
const occurrenceCountColumn = matchColumn(firstRecord, MANUAL_ALIASES.occurrenceCount);
const rows = records
.map<KeywordAnalysisRow | null>((record) => {
const keyword = normalizeText(record[keywordColumn]);
const category = normalizeText(record[categoryColumn]);
if (!keyword || !category) return null;
const searchPopularity = searchPopularityColumn
? parseRangeValue(record[searchPopularityColumn])
: null;
const conversionRate = conversionRateColumn
? parseRangeValue(record[conversionRateColumn])
: null;
const occurrenceCount = occurrenceCountColumn
? Math.max(parseInteger(record[occurrenceCountColumn], 1), 1)
: 1;
return {
keyword,
segments: [keyword],
category,
occurrenceCount,
searchPopularity: typeof searchPopularity === "number" ? searchPopularity : undefined,
conversionRate:
typeof conversionRate === "number"
? conversionRate > 1
? Number((conversionRate / 100).toFixed(4))
: Number(conversionRate.toFixed(4))
: undefined,
};
})
.filter((item): item is KeywordAnalysisRow => Boolean(item));
if (rows.length === 0) {
throw new Error("未从手动分词表中提取到有效数据");
}
return aggregateManualKeywordRows(rows);
}
export async function parseReviewImportFile(file: File) {
const records = await readSpreadsheetRecords(file);
if (records.length === 0) {
throw new Error("评价表中没有有效数据");
}
const firstRecord = records[0];
const contentColumn = matchColumn(firstRecord, REVIEW_ALIASES.content);
if (!contentColumn) {
throw new Error("评价表缺少核心列:评价内容");
}
const itemIdColumn = matchColumn(firstRecord, REVIEW_ALIASES.itemId);
const dateColumn = matchColumn(firstRecord, REVIEW_ALIASES.date);
const appendColumn = matchColumn(firstRecord, REVIEW_ALIASES.appendComment);
const skuColumn = matchColumn(firstRecord, REVIEW_ALIASES.sku);
const userIdColumn = matchColumn(firstRecord, REVIEW_ALIASES.userId);
const imagesColumn = matchColumn(firstRecord, REVIEW_ALIASES.images);
const videoUrlsColumn = matchColumn(firstRecord, REVIEW_ALIASES.videoUrls);
const rows = records
.map<TaobaoReviewRow | null>((record) => {
const content = normalizeText(record[contentColumn]);
if (!content) return null;
return {
itemId: itemIdColumn ? normalizeText(record[itemIdColumn]) : "",
date: dateColumn ? normalizeText(record[dateColumn]) : "",
content,
appendComment: appendColumn ? normalizeText(record[appendColumn]) || undefined : undefined,
sku: skuColumn ? normalizeText(record[skuColumn]) || undefined : undefined,
userId: userIdColumn ? normalizeText(record[userIdColumn]) || undefined : undefined,
images: imagesColumn ? splitUrlCell(record[imagesColumn]) : [],
videoUrls: videoUrlsColumn ? splitUrlCell(record[videoUrlsColumn]) : [],
};
})
.filter((item): item is TaobaoReviewRow => Boolean(item));
if (rows.length === 0) {
throw new Error("未从评价表中提取到有效评价数据");
}
return rows;
}
export async function parseQaImportFile(file: File) {
const records = await readSpreadsheetRecords(file);
if (records.length === 0) {
throw new Error("问大家表中没有有效数据");
}
const firstRecord = records[0];
const questionColumn = matchColumn(firstRecord, QA_ALIASES.question);
const answerColumn = matchColumn(firstRecord, QA_ALIASES.answer);
if (!questionColumn || !answerColumn) {
throw new Error("问大家表缺少核心列:问题、回答");
}
const itemIdColumn = matchColumn(firstRecord, QA_ALIASES.itemId);
const detailColumn = matchColumn(firstRecord, QA_ALIASES.questionDetail);
const answerUserColumn = matchColumn(firstRecord, QA_ALIASES.answerUser);
const answerDateColumn = matchColumn(firstRecord, QA_ALIASES.answerDate);
const officialColumn = matchColumn(firstRecord, QA_ALIASES.isOfficial);
const rows = records
.map<TaobaoQaRow | null>((record) => {
const question = normalizeText(record[questionColumn]);
const answer = normalizeText(record[answerColumn]);
if (!question || !answer) return null;
return {
itemId: itemIdColumn ? normalizeText(record[itemIdColumn]) : "",
question,
questionDetail: detailColumn ? normalizeText(record[detailColumn]) || undefined : undefined,
answer,
answerUser: answerUserColumn
? normalizeText(record[answerUserColumn]) || undefined
: undefined,
answerDate: answerDateColumn
? normalizeText(record[answerDateColumn]) || undefined
: undefined,
isOfficial: officialColumn ? parseOfficialFlag(record[officialColumn]) : false,
};
})
.filter((item): item is TaobaoQaRow => Boolean(item));
if (rows.length === 0) {
throw new Error("未从问大家表中提取到有效数据");
}
return rows;
}