374 lines
12 KiB
TypeScript
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;
|
|
}
|