feat: enhance README and improve product data handling in cache, llm, reader, and writer modules
This commit is contained in:
216
src/reader.ts
216
src/reader.ts
@@ -1,6 +1,48 @@
|
||||
import * as XLSX from "xlsx";
|
||||
import type { ProductRecord } from "./types.ts";
|
||||
|
||||
const ASIN_REGEX = /^B[0-9A-Z]{9}$/;
|
||||
|
||||
const COLUMN_CANDIDATES = {
|
||||
asin: ["asin"],
|
||||
name: ["name", "product name", "title", "product title"],
|
||||
cost: ["unit cost", "cost", "unitcost", "unit_cost", "price", "buy cost"],
|
||||
brand: ["brand"],
|
||||
category: ["category"],
|
||||
amazonRank: ["amazon rank", "amazonrank", "sales rank", "bsr"],
|
||||
avgPrice90: [
|
||||
"90 day average",
|
||||
"90-day average",
|
||||
"avg price 90d",
|
||||
"avg 90 day",
|
||||
"90d average",
|
||||
],
|
||||
sellingPrice: ["selling price", "sale price", "sell price"],
|
||||
fbaNet: ["fba net", "fbanet", "fba_net"],
|
||||
grossProfit: ["gross profit $", "gross profit", "grossprofit"],
|
||||
grossProfitPct: ["gross profit %", "gross profit pct", "grossprofitpct"],
|
||||
netProfit: ["net profit", "netprofit"],
|
||||
roi: ["roi", "return on investment"],
|
||||
moq: ["moq", "min order qty", "minimum order quantity"],
|
||||
moqCost: ["moq cost", "moqcost", "moq_cost"],
|
||||
totalQty: ["total qty avail", "totalqtyavail", "qty available", "quantity"],
|
||||
link: ["link", "url", "source"],
|
||||
asinLink: ["asin link", "amazon link", "asin url"],
|
||||
sourceUrl: ["source url", "supplier url", "source link"],
|
||||
supplier: ["supplier", "vendor"],
|
||||
promoCouponCode: [
|
||||
"promo/coupon code",
|
||||
"promo coupon code",
|
||||
"coupon code",
|
||||
"promo code",
|
||||
],
|
||||
notes: ["notes", "note"],
|
||||
leadDate: ["date", "lead date"],
|
||||
} as const;
|
||||
|
||||
type ColumnKey = keyof typeof COLUMN_CANDIDATES;
|
||||
type ColumnMap = Record<ColumnKey, string | undefined>;
|
||||
|
||||
export function readProducts(filePath: string): ProductRecord[] {
|
||||
const workbook = XLSX.readFile(filePath);
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
@@ -12,58 +54,57 @@ export function readProducts(filePath: string): ProductRecord[] {
|
||||
if (rows.length === 0) throw new Error("File contains no data rows");
|
||||
|
||||
const headers = Object.keys(rows[0]!);
|
||||
const asinCol = findColumn(headers, ["asin"]);
|
||||
const nameCol = findColumn(headers, ["name", "product name", "title", "product title"]);
|
||||
const costCol = findColumn(headers, ["unit cost", "cost", "unitcost", "unit_cost", "price", "buy cost"]);
|
||||
const columns = detectColumns(headers);
|
||||
const asinColumn = columns.asin;
|
||||
|
||||
const brandCol = findColumn(headers, ["brand"]);
|
||||
const categoryCol = findColumn(headers, ["category"]);
|
||||
const amazonRankCol = findColumn(headers, ["amazon rank", "amazonrank", "sales rank", "bsr"]);
|
||||
const fbaNetCol = findColumn(headers, ["fba net", "fbanet", "fba_net"]);
|
||||
const grossProfitCol = findColumn(headers, ["gross profit $", "gross profit", "grossprofit"]);
|
||||
const grossProfitPctCol = findColumn(headers, ["gross profit %", "gross profit pct", "grossprofitpct"]);
|
||||
const moqCol = findColumn(headers, ["moq", "min order qty", "minimum order quantity"]);
|
||||
const moqCostCol = findColumn(headers, ["moq cost", "moqcost"]);
|
||||
const totalQtyCol = findColumn(headers, ["total qty avail", "totalqtyavail", "qty available", "quantity"]);
|
||||
if (!asinColumn)
|
||||
throw new Error(
|
||||
`No ASIN column found. Available columns: ${headers.join(", ")}`,
|
||||
);
|
||||
|
||||
const linkCol = findColumn(headers, ["link", "url", "source"]);
|
||||
logColumnDetection(headers, columns);
|
||||
|
||||
if (!asinCol) throw new Error(`No ASIN column found. Available columns: ${headers.join(", ")}`);
|
||||
|
||||
const knownCols = new Set([asinCol, nameCol, costCol, brandCol, categoryCol, amazonRankCol, fbaNetCol, grossProfitCol, grossProfitPctCol, moqCol, moqCostCol, totalQtyCol, linkCol].filter(Boolean));
|
||||
const knownCols = getKnownColumns(columns);
|
||||
|
||||
const products: ProductRecord[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const asin = String(row[asinCol] ?? "").trim().toUpperCase();
|
||||
if (!asin || !/^B[0-9A-Z]{9}$/.test(asin)) {
|
||||
console.warn(`Skipping invalid ASIN: "${asin}"`);
|
||||
continue;
|
||||
}
|
||||
const asin = parseAsin(row[asinColumn]);
|
||||
if (!asin) continue;
|
||||
|
||||
const name = nameCol ? String(row[nameCol] ?? "") : "";
|
||||
const unitCost = costCol ? parseFloat(String(row[costCol] ?? "0")) : 0;
|
||||
const sourceUrl = getOptionalString(row, columns.sourceUrl);
|
||||
const asinLink = getOptionalString(row, columns.asinLink);
|
||||
const link = sourceUrl ?? asinLink ?? getOptionalString(row, columns.link);
|
||||
|
||||
const extra: Record<string, unknown> = {};
|
||||
for (const h of headers) {
|
||||
if (!knownCols.has(h)) extra[h] = row[h];
|
||||
}
|
||||
const extra = getExtraFields(row, headers, knownCols);
|
||||
const netProfitFromSheet = getOptionalNumber(row, columns.netProfit);
|
||||
const roiFromSheet = getOptionalNumber(row, columns.roi);
|
||||
|
||||
products.push({
|
||||
asin,
|
||||
name,
|
||||
unitCost,
|
||||
brand: brandCol ? String(row[brandCol] ?? "") : undefined,
|
||||
category: categoryCol ? String(row[categoryCol] ?? "") : undefined,
|
||||
amazonRank: amazonRankCol ? Number(row[amazonRankCol]) || undefined : undefined,
|
||||
fbaNet: fbaNetCol ? Number(row[fbaNetCol]) || undefined : undefined,
|
||||
grossProfit: grossProfitCol ? Number(row[grossProfitCol]) || undefined : undefined,
|
||||
grossProfitPct: grossProfitPctCol ? Number(row[grossProfitPctCol]) || undefined : undefined,
|
||||
moq: moqCol ? Number(row[moqCol]) || undefined : undefined,
|
||||
moqCost: moqCostCol ? Number(row[moqCostCol]) || undefined : undefined,
|
||||
totalQtyAvail: totalQtyCol ? Number(row[totalQtyCol]) || undefined : undefined,
|
||||
|
||||
link: linkCol ? String(row[linkCol] ?? "") : undefined,
|
||||
name: getOptionalString(row, columns.name) ?? "",
|
||||
unitCost: getOptionalNumber(row, columns.cost) ?? 0,
|
||||
brand: getOptionalString(row, columns.brand),
|
||||
category: getOptionalString(row, columns.category),
|
||||
amazonRank: getOptionalNumber(row, columns.amazonRank),
|
||||
avgPrice90FromSheet: getOptionalNumber(row, columns.avgPrice90),
|
||||
sellingPriceFromSheet: getOptionalNumber(row, columns.sellingPrice),
|
||||
fbaNet: getOptionalNumber(row, columns.fbaNet),
|
||||
grossProfit: getOptionalNumber(row, columns.grossProfit) ?? netProfitFromSheet,
|
||||
grossProfitPct:
|
||||
getOptionalNumber(row, columns.grossProfitPct) ?? roiFromSheet,
|
||||
netProfitFromSheet,
|
||||
roiFromSheet,
|
||||
moq: getOptionalNumber(row, columns.moq),
|
||||
moqCost: getOptionalNumber(row, columns.moqCost),
|
||||
totalQtyAvail: getOptionalNumber(row, columns.totalQty),
|
||||
link,
|
||||
asinLink,
|
||||
sourceUrl,
|
||||
supplier: getOptionalString(row, columns.supplier),
|
||||
promoCouponCode: getOptionalString(row, columns.promoCouponCode),
|
||||
notes: getOptionalString(row, columns.notes),
|
||||
leadDate: getOptionalString(row, columns.leadDate),
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
@@ -72,10 +113,97 @@ export function readProducts(filePath: string): ProductRecord[] {
|
||||
return products;
|
||||
}
|
||||
|
||||
function findColumn(headers: string[], candidates: string[]): string | undefined {
|
||||
for (const candidate of candidates) {
|
||||
const match = headers.find((h) => h.toLowerCase().trim() === candidate);
|
||||
if (match) return match;
|
||||
function detectColumns(headers: string[]): ColumnMap {
|
||||
const columns = {} as ColumnMap;
|
||||
for (const key of Object.keys(COLUMN_CANDIDATES) as ColumnKey[]) {
|
||||
columns[key] = findColumn(headers, [...COLUMN_CANDIDATES[key]]);
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
function logColumnDetection(headers: string[], columns: ColumnMap): void {
|
||||
console.log(`Found columns: ${headers.join(", ")}`);
|
||||
console.log(
|
||||
`Detected columns -> ASIN: ${columns.asin ?? "n/a"}, Name: ${columns.name ?? "n/a"}, Cost: ${columns.cost ?? "n/a"}, 90d Avg: ${columns.avgPrice90 ?? "n/a"}, Selling Price: ${columns.sellingPrice ?? "n/a"}, Net Profit: ${columns.netProfit ?? columns.grossProfit ?? "n/a"}, ROI: ${columns.roi ?? columns.grossProfitPct ?? "n/a"}, Source URL: ${columns.sourceUrl ?? "n/a"}, ASIN Link: ${columns.asinLink ?? "n/a"}`,
|
||||
);
|
||||
}
|
||||
|
||||
function getKnownColumns(columns: ColumnMap): Set<string> {
|
||||
return new Set(Object.values(columns).filter((column): column is string => !!column));
|
||||
}
|
||||
|
||||
function parseAsin(value: unknown): string | undefined {
|
||||
const asin = String(value ?? "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (!asin || !ASIN_REGEX.test(asin)) {
|
||||
console.warn(`Skipping invalid ASIN: "${asin}"`);
|
||||
return undefined;
|
||||
}
|
||||
return asin;
|
||||
}
|
||||
|
||||
function getOptionalString(
|
||||
row: Record<string, unknown>,
|
||||
column: string | undefined,
|
||||
): string | undefined {
|
||||
if (!column) return undefined;
|
||||
return normalizeOptionalString(row[column]);
|
||||
}
|
||||
|
||||
function getOptionalNumber(
|
||||
row: Record<string, unknown>,
|
||||
column: string | undefined,
|
||||
): number | undefined {
|
||||
if (!column) return undefined;
|
||||
return parseOptionalNumber(row[column]);
|
||||
}
|
||||
|
||||
function getExtraFields(
|
||||
row: Record<string, unknown>,
|
||||
headers: string[],
|
||||
knownCols: Set<string>,
|
||||
): Record<string, unknown> {
|
||||
const extra: Record<string, unknown> = {};
|
||||
for (const header of headers) {
|
||||
if (!knownCols.has(header)) extra[header] = row[header];
|
||||
}
|
||||
return extra;
|
||||
}
|
||||
|
||||
function findColumn(
|
||||
headers: string[],
|
||||
candidates: string[],
|
||||
): string | undefined {
|
||||
const normalizedCandidates = new Set(candidates.map(normalizeHeader));
|
||||
|
||||
for (const header of headers) {
|
||||
if (normalizedCandidates.has(normalizeHeader(header))) {
|
||||
return header;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeHeader(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/%/g, " pct ")
|
||||
.replace(/\$/g, " usd ")
|
||||
.replace(/[^a-z0-9]/g, "");
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value: unknown): string | undefined {
|
||||
if (value == null) return undefined;
|
||||
const s = String(value).trim();
|
||||
return s.length > 0 ? s : undefined;
|
||||
}
|
||||
|
||||
function parseOptionalNumber(value: unknown): number | undefined {
|
||||
if (value == null || value === "") return undefined;
|
||||
const cleaned = String(value).trim().replace(/[$,%]/g, "").replace(/,/g, "");
|
||||
const parsed = Number(cleaned);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user