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; 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(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((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((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((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((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((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; }