Files
asin-check/src/reader.ts

210 lines
6.9 KiB
TypeScript

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];
if (!sheetName) throw new Error("No sheets found in file");
const sheet = workbook.Sheets[sheetName]!;
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet);
if (rows.length === 0) throw new Error("File contains no data rows");
const headers = Object.keys(rows[0]!);
const columns = detectColumns(headers);
const asinColumn = columns.asin;
if (!asinColumn)
throw new Error(
`No ASIN column found. Available columns: ${headers.join(", ")}`,
);
logColumnDetection(headers, columns);
const knownCols = getKnownColumns(columns);
const products: ProductRecord[] = [];
for (const row of rows) {
const asin = parseAsin(row[asinColumn]);
if (!asin) continue;
const sourceUrl = getOptionalString(row, columns.sourceUrl);
const asinLink = getOptionalString(row, columns.asinLink);
const link = sourceUrl ?? asinLink ?? getOptionalString(row, columns.link);
const extra = getExtraFields(row, headers, knownCols);
const netProfitFromSheet = getOptionalNumber(row, columns.netProfit);
const roiFromSheet = getOptionalNumber(row, columns.roi);
products.push({
asin,
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,
});
}
console.log(`Read ${products.length} valid products from ${filePath}`);
return products;
}
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;
}