mirror of
https://github.com/samsonjs/spirit-tracker.git
synced 2026-03-25 09:25:51 +00:00
feat: Support for legacy liquor
This commit is contained in:
parent
0659775fb0
commit
8fd926eb8b
2 changed files with 312 additions and 0 deletions
|
|
@ -8,6 +8,7 @@ const { createStore: createMaltsAndGrains } = require("./maltsandgrains");
|
|||
const { createStore: createCraftCellars } = require("./craftcellars");
|
||||
const { createStore: createBCL } = require("./bcl");
|
||||
const { createStore: createStrath } = require("./strath");
|
||||
const { createStore: createLegacy } = require("./legacyliquor");
|
||||
|
||||
function createStores({ defaultUa } = {}) {
|
||||
return [
|
||||
|
|
@ -19,6 +20,7 @@ function createStores({ defaultUa } = {}) {
|
|||
createCraftCellars(defaultUa),
|
||||
createBCL(defaultUa),
|
||||
createStrath(defaultUa),
|
||||
createLegacy(defaultUa),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
310
src/stores/legacyliquor.js
Normal file
310
src/stores/legacyliquor.js
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
"use strict";
|
||||
|
||||
const { normalizeCspc } = require("../utils/sku");
|
||||
const { humanBytes } = require("../utils/bytes");
|
||||
const { padLeft, padRight } = require("../utils/string");
|
||||
|
||||
const { mergeDiscoveredIntoDb } = require("../tracker/merge");
|
||||
const { buildDbObject, writeJsonAtomic } = require("../tracker/db");
|
||||
const { addCategoryResultToReport } = require("../tracker/report");
|
||||
|
||||
function kbStr(bytes) {
|
||||
return humanBytes(bytes).padStart(8, " ");
|
||||
}
|
||||
|
||||
function secStr(ms) {
|
||||
const s = Number.isFinite(ms) ? ms / 1000 : 0;
|
||||
const tenths = Math.round(s * 10) / 10;
|
||||
let out;
|
||||
if (tenths < 10) out = `${tenths.toFixed(1)}s`;
|
||||
else out = `${Math.round(s)}s`;
|
||||
return out.padStart(7, " ");
|
||||
}
|
||||
|
||||
function pageStr(i, total) {
|
||||
const leftW = String(total).length;
|
||||
return `${padLeft(i, leftW)}/${total}`;
|
||||
}
|
||||
|
||||
function pctStr(done, total) {
|
||||
const pct = total ? Math.floor((done / total) * 100) : 0;
|
||||
return `${padLeft(pct, 3)}%`;
|
||||
}
|
||||
|
||||
function cad(n) {
|
||||
const x = Number(n);
|
||||
if (!Number.isFinite(x)) return "";
|
||||
return `$${x.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function normalizeAbsUrl(raw) {
|
||||
const s = String(raw || "").trim();
|
||||
if (!s) return "";
|
||||
if (s.startsWith("//")) return `https:${s}`;
|
||||
if (/^https?:\/\//i.test(s)) return s;
|
||||
try {
|
||||
return new URL(s, "https://www.legacyliquorstore.com/").toString();
|
||||
} catch {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
const LEGACY_GQL_URL = "https://production-storefront-api-hagnfhf3sq-uc.a.run.app/graphql";
|
||||
|
||||
// Keep it exactly a GraphQL string; variables are provided separately.
|
||||
const PRODUCTS_QUERY = `
|
||||
query(
|
||||
$allTags: [String],
|
||||
$anyTags: [String],
|
||||
$collectionSlug: String,
|
||||
$countries: [String],
|
||||
$isBestSeller: Boolean,
|
||||
$isNewArrival: Boolean,
|
||||
$isFeatured: Boolean,
|
||||
$isFeaturedOnHomepage: Boolean,
|
||||
$isOnSale: Boolean,
|
||||
$isStaffPick: Boolean,
|
||||
$pageCursor: String,
|
||||
$pageLimit: Int,
|
||||
$pointsMin: Int,
|
||||
$priceMin: Float,
|
||||
$priceMax: Float,
|
||||
$quantityMin: Float,
|
||||
$regions: [String],
|
||||
$brandValue: String,
|
||||
$searchValue: String,
|
||||
$sortOrder: String,
|
||||
$sortBy: String,
|
||||
$storeId: String!,
|
||||
) {
|
||||
products(
|
||||
allTags: $allTags,
|
||||
anyTags: $anyTags,
|
||||
collectionSlug: $collectionSlug,
|
||||
countries: $countries,
|
||||
isBestSeller: $isBestSeller,
|
||||
isNewArrival: $isNewArrival,
|
||||
isFeatured: $isFeatured,
|
||||
isFeaturedOnHomepage: $isFeaturedOnHomepage,
|
||||
isOnSale: $isOnSale,
|
||||
isStaffPick: $isStaffPick,
|
||||
pageCursor: $pageCursor,
|
||||
pageLimit: $pageLimit,
|
||||
pointsMin: $pointsMin,
|
||||
priceMin: $priceMin,
|
||||
priceMax: $priceMax,
|
||||
quantityMin: $quantityMin,
|
||||
regions: $regions,
|
||||
brandValue: $brandValue,
|
||||
searchValue: $searchValue,
|
||||
sortOrder: $sortOrder,
|
||||
sortBy: $sortBy,
|
||||
storeId: $storeId,
|
||||
) {
|
||||
items {
|
||||
id
|
||||
name
|
||||
slug
|
||||
priceFrom
|
||||
priceTo
|
||||
tags { id name slug }
|
||||
variants {
|
||||
id
|
||||
fullName
|
||||
shortName
|
||||
image
|
||||
price
|
||||
quantity
|
||||
sku
|
||||
alcoholByVolume
|
||||
deposit
|
||||
}
|
||||
}
|
||||
nextPageCursor
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function pickInStockVariant(p) {
|
||||
const vars = Array.isArray(p?.variants) ? p.variants : [];
|
||||
for (const v of vars) {
|
||||
const q = Number(v?.quantity);
|
||||
if (Number.isFinite(q) && q > 0) return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function legacyProductToItem(p, ctx) {
|
||||
const v = pickInStockVariant(p);
|
||||
if (!v) return null;
|
||||
|
||||
const slug = String(p?.slug || "").trim();
|
||||
if (!slug) return null;
|
||||
|
||||
const base = "https://www.legacyliquorstore.com";
|
||||
// Matches observed pattern: /LL/product/spirits/<category>/<slug>
|
||||
const url = new URL(`/LL/product/spirits/${encodeURIComponent(ctx.cat.key)}/${encodeURIComponent(slug)}`, base).toString();
|
||||
|
||||
const nameRaw =
|
||||
String(v?.fullName || "").trim() ||
|
||||
[String(p?.name || "").trim(), String(v?.shortName || "").trim()].filter(Boolean).join(" | ");
|
||||
const name = String(nameRaw || "").trim();
|
||||
if (!name) return null;
|
||||
|
||||
const price =
|
||||
cad(v?.price) ||
|
||||
cad(p?.priceFrom) ||
|
||||
cad(p?.priceTo) ||
|
||||
"";
|
||||
|
||||
const sku = normalizeCspc(v?.sku || "") || normalizeCspc(url) || "";
|
||||
const img = normalizeAbsUrl(v?.image || "");
|
||||
|
||||
return { name, price, url, sku, img };
|
||||
}
|
||||
|
||||
async function legacyFetchPage(ctx, pageCursor, pageLimit) {
|
||||
const body = {
|
||||
query: PRODUCTS_QUERY,
|
||||
variables: {
|
||||
allTags: ctx.cat.allTags || null,
|
||||
anyTags: null,
|
||||
collectionSlug: null,
|
||||
countries: null,
|
||||
isBestSeller: null,
|
||||
isNewArrival: null,
|
||||
isFeatured: null,
|
||||
isFeaturedOnHomepage: null,
|
||||
isOnSale: null,
|
||||
isStaffPick: null,
|
||||
pageCursor: pageCursor || null,
|
||||
pageLimit: pageLimit,
|
||||
pointsMin: null,
|
||||
priceMin: null,
|
||||
priceMax: null,
|
||||
quantityMin: null,
|
||||
regions: null,
|
||||
brandValue: null,
|
||||
searchValue: null,
|
||||
sortOrder: "asc",
|
||||
sortBy: "name",
|
||||
storeId: "LL",
|
||||
},
|
||||
};
|
||||
|
||||
return await ctx.http.fetchJsonWithRetry(LEGACY_GQL_URL, `legacy:${ctx.cat.key}:${pageCursor || "first"}`, ctx.store.ua, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"content-type": "application/json",
|
||||
Origin: "https://www.legacyliquorstore.com",
|
||||
Referer: "https://www.legacyliquorstore.com/",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function scanCategoryLegacyLiquor(ctx, prevDb, report) {
|
||||
const t0 = Date.now();
|
||||
const pageLimit = 100;
|
||||
|
||||
const discovered = new Map();
|
||||
|
||||
let cursor = null;
|
||||
let page = 0;
|
||||
let done = 0;
|
||||
const maxPagesCap = ctx.config.maxPages === null ? 5000 : ctx.config.maxPages;
|
||||
|
||||
while (page < maxPagesCap) {
|
||||
page++;
|
||||
|
||||
let r;
|
||||
try {
|
||||
r = await legacyFetchPage(ctx, cursor, pageLimit);
|
||||
} catch (e) {
|
||||
ctx.logger.warn(`${ctx.catPrefixOut} | LegacyLiquor fetch failed p${page}: ${e?.message || e}`);
|
||||
break;
|
||||
}
|
||||
|
||||
const items = r?.json?.data?.products?.items;
|
||||
const next = r?.json?.data?.products?.nextPageCursor;
|
||||
|
||||
const arr = Array.isArray(items) ? items : [];
|
||||
let kept = 0;
|
||||
|
||||
for (const p of arr) {
|
||||
const it = legacyProductToItem(p, ctx);
|
||||
if (!it) continue;
|
||||
discovered.set(it.url, it);
|
||||
kept++;
|
||||
}
|
||||
|
||||
done++;
|
||||
ctx.logger.ok(
|
||||
`${ctx.catPrefixOut} | Page ${pageStr(done, done)} | ${String(r.status || "").padEnd(3)} | ${pctStr(done, done)} | kept=${padLeft(
|
||||
kept,
|
||||
3
|
||||
)} | bytes=${kbStr(r.bytes)} | ${padRight(ctx.http.inflightStr(), 11)} | ${secStr(r.ms)}`
|
||||
);
|
||||
|
||||
if (!next || !arr.length) break;
|
||||
if (next === cursor) break; // safety
|
||||
cursor = next;
|
||||
}
|
||||
|
||||
const { merged, newItems, updatedItems, removedItems, restoredItems } = mergeDiscoveredIntoDb(prevDb, discovered);
|
||||
const dbObj = buildDbObject(ctx, merged);
|
||||
writeJsonAtomic(ctx.dbFile, dbObj);
|
||||
|
||||
const elapsed = Date.now() - t0;
|
||||
ctx.logger.ok(
|
||||
`${ctx.catPrefixOut} | Done in ${secStr(elapsed)}. New=${newItems.length} Updated=${updatedItems.length} Removed=${removedItems.length} Restored=${restoredItems.length} Total(DB)=${merged.size}`
|
||||
);
|
||||
|
||||
report.categories.push({
|
||||
store: ctx.store.name,
|
||||
label: ctx.cat.label,
|
||||
key: ctx.cat.key,
|
||||
dbFile: ctx.dbFile,
|
||||
scannedPages: Math.max(1, page),
|
||||
discoveredUnique: discovered.size,
|
||||
newCount: newItems.length,
|
||||
updatedCount: updatedItems.length,
|
||||
removedCount: removedItems.length,
|
||||
restoredCount: restoredItems.length,
|
||||
elapsedMs: elapsed,
|
||||
});
|
||||
report.totals.newCount += newItems.length;
|
||||
report.totals.updatedCount += updatedItems.length;
|
||||
report.totals.removedCount += removedItems.length;
|
||||
report.totals.restoredCount += restoredItems.length;
|
||||
|
||||
addCategoryResultToReport(report, ctx.store.name, ctx.cat.label, newItems, updatedItems, removedItems, restoredItems);
|
||||
}
|
||||
|
||||
function createStore(defaultUa) {
|
||||
return {
|
||||
key: "legacyliquor",
|
||||
name: "Legacy Liquor",
|
||||
host: "www.legacyliquorstore.com",
|
||||
ua: defaultUa,
|
||||
scanCategory: scanCategoryLegacyLiquor,
|
||||
categories: [
|
||||
{
|
||||
key: "whisky",
|
||||
label: "Whisky",
|
||||
startUrl: "https://www.legacyliquorstore.com/LL/category/spirits/whisky",
|
||||
allTags: ["spirits", "whisky"],
|
||||
},
|
||||
{
|
||||
key: "rum",
|
||||
label: "Rum",
|
||||
startUrl: "https://www.legacyliquorstore.com/LL/category/spirits/rum",
|
||||
allTags: ["spirits", "rum"],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createStore };
|
||||
Loading…
Reference in a new issue