diff --git a/AGENTS.md b/AGENTS.md index 875012c..76824d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,3 +56,18 @@ python3 scripts/generate-category-reels.py Needs `MEDIA_UPLOAD_SECRET` + `XAI_API_KEY` (or Grok Build `~/.grok/auth.json`). Docs: [`docs/imagine-r2-videos.md`](docs/imagine-r2-videos.md). + +## Federated product library (kyasi.us) + +Adazo is a **consumer** of the network product library at **https://kyasi.us**. + +```bash +export KYASI_LIBRARY_TOKEN=… # kyasi-net ADMIN_API_TOKEN +npm run library:sync:dry +npm run library:sync # write-back only (catalog → library) +``` + +- Client: `scripts/lib/kyasi-library.mjs` +- Sync: `scripts/sync-from-library.mjs` (`imagesMode: replace`; no library→catalog image pull) +- Docs: `docs/KYASI-LIBRARY.md` +- Associate tag stays in site config / buy URLs only — never in library payloads. diff --git a/docs/KYASI-LIBRARY.md b/docs/KYASI-LIBRARY.md new file mode 100644 index 0000000..be1bede --- /dev/null +++ b/docs/KYASI-LIBRARY.md @@ -0,0 +1,27 @@ +# Adazo ↔ kyasi.us library + +Adazo consumes the federated product library at **https://kyasi.us**. + +## Commands + +```bash +export KYASI_LIBRARY_URL=https://kyasi.us +export KYASI_LIBRARY_TOKEN=… # ADMIN_API_TOKEN from kyasi-net + +npm run library:sync:dry +npm run library:sync +``` + +## Behavior + +1. **GET** each catalog ASIN from the library (hit/miss). +2. **Write-back** house name, brand, category, slug, and **this product's** images with `imagesMode: replace`. +3. **Does not** pull library images into the TS catalog (avoids seed pollution). + +## Files + +| Path | Role | +|------|------| +| `scripts/lib/kyasi-library.mjs` | HTTP client | +| `scripts/sync-from-library.mjs` | Sync loop | +| `tmp/library-sync-report.json` | Last run report | diff --git a/package.json b/package.json index be04105..e7223e4 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,9 @@ "refresh:images": "node scripts/bsr/refresh-catalog-images.mjs", "refresh:weekly": "npm run import:bsr && npm run build && wrangler deploy", "deploy": "npm run build && wrangler deploy", - "content:research": "node scripts/research-product-enrichment.mjs" + "content:research": "node scripts/research-product-enrichment.mjs", + "library:sync": "node scripts/sync-from-library.mjs", + "library:sync:dry": "node scripts/sync-from-library.mjs --dry-run" }, "dependencies": { "lucide-react": "^1.25.0", diff --git a/scripts/lib/kyasi-library.mjs b/scripts/lib/kyasi-library.mjs new file mode 100644 index 0000000..94e04ae --- /dev/null +++ b/scripts/lib/kyasi-library.mjs @@ -0,0 +1,142 @@ +/** + * Client for the federated product library at kyasi.us + * Reads are public. Writes need KYASI_LIBRARY_TOKEN (ADMIN_API_TOKEN). + */ +const DEFAULT_BASE = + process.env.KYASI_LIBRARY_URL || "https://kyasi.us"; + +export function libraryBase() { + return DEFAULT_BASE.replace(/\/+$/, ""); +} + +export function libraryToken() { + return ( + process.env.KYASI_LIBRARY_TOKEN || + process.env.ADMIN_API_TOKEN || + "" + ).trim(); +} + +async function req(path, opts = {}) { + const url = `${libraryBase()}${path}`; + const headers = { + Accept: "application/json", + ...(opts.headers || {}), + }; + if (opts.json) { + headers["Content-Type"] = "application/json"; + } + const token = libraryToken(); + if (opts.auth) { + if (!token) { + throw new Error( + "KYASI_LIBRARY_TOKEN (or ADMIN_API_TOKEN) required for library writes", + ); + } + headers.Authorization = `Bearer ${token}`; + } + const res = await fetch(url, { + method: opts.method || "GET", + headers, + body: opts.json ? JSON.stringify(opts.json) : undefined, + }); + const text = await res.text(); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = { raw: text }; + } + if (!res.ok) { + const msg = + (data && (data.error || data.message)) || + `${res.status} ${res.statusText}`; + const err = new Error(`kyasi library ${opts.method || "GET"} ${path}: ${msg}`); + err.status = res.status; + err.data = data; + throw err; + } + return data; +} + +/** Prefer real gallery CDN URLs over fragile images/P/{ASIN} pattern */ +export function isGoodAmazonImage(url) { + if (!url || typeof url !== "string") return false; + if (/images\/P\//i.test(url)) return false; + return /media-amazon\.com\/images\/I\//i.test(url) || + /ssl-images-amazon\.com\/images\//i.test(url); +} + +export function pickPrimaryImage(images, fallback) { + const list = Array.isArray(images) ? images : []; + const good = list.find(isGoodAmazonImage); + if (good) return good; + if (isGoodAmazonImage(fallback)) return fallback; + return good || fallback || list[0] || null; +} + +export async function getAmazonItem(asin) { + if (!asin) return null; + try { + const data = await req( + `/api/library/items/amazon/${encodeURIComponent(asin.toUpperCase())}`, + ); + return data.item || null; + } catch (e) { + if (e.status === 404) return null; + throw e; + } +} + +export async function listSiteItems(siteId, limit = 100) { + const data = await req( + `/api/library/items?site=${encodeURIComponent(siteId)}&limit=${limit}`, + ); + return data; +} + +export async function upsertAmazonItem(input) { + return req("/api/library/items", { + method: "POST", + auth: true, + json: { + networkId: "amazon", + externalId: input.asin, + marketplace: "www.amazon.com", + title: input.title, + brand: input.brand, + images: input.images || [], + /** Catalog is source of truth — never merge polluted library galleries */ + imagesMode: input.imagesMode || "replace", + price: input.price ?? null, + rating: input.rating ?? null, + reviewCount: input.reviewCount ?? null, + fetchStatus: input.fetchStatus, + attributes: input.attributes || {}, + }, + }); +} + +export async function linkSiteItem(input) { + return req("/api/library/site-items", { + method: "POST", + auth: true, + json: { + siteId: input.siteId, + networkId: "amazon", + externalId: input.asin, + marketplace: "www.amazon.com", + slug: input.slug, + houseName: input.houseName, + category: input.category, + tagline: input.tagline, + status: input.status || "active", + limitedTime: !!input.limitedTime, + period: input.period || null, + }, + }); +} + +export async function health() { + return req("/api/health"); +} diff --git a/scripts/sync-from-library.mjs b/scripts/sync-from-library.mjs new file mode 100644 index 0000000..b640e94 --- /dev/null +++ b/scripts/sync-from-library.mjs @@ -0,0 +1,202 @@ +#!/usr/bin/env node +/** + * SPA affiliate site ↔ kyasi.us library sync (write-back only) + * + * GET each ASIN from library; write-back house metadata + local images + * with imagesMode: replace. Does NOT pull library images into catalog. + * + * KYASI_LIBRARY_TOKEN=… npm run library:sync + * npm run library:sync:dry + * + * Configure via env: + * SITE_ID (required) e.g. kyasi, adazo, mrcuts, ibamboo + * PRODUCT_FILES comma-separated paths relative to repo root + * default: src/data/products.ts + */ +import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + getAmazonItem, + health, + isGoodAmazonImage, + linkSiteItem, + upsertAmazonItem, +} from "./lib/kyasi-library.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, ".."); +const SITE_ID = process.env.SITE_ID || "adazo"; +const PRODUCT_FILES = (process.env.PRODUCT_FILES || "src/data/products.ts,src/data/products.bsr.generated.ts") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + +const dryRun = process.argv.includes("--dry-run"); + +function parseProducts(text) { + const products = []; + const chunks = text.split(/\n\s*\{\s*\n/); + for (const block of chunks) { + const asinM = block.match(/["']?asin["']?\s*:\s*['"]([A-Z0-9]{10})['"]/i); + if (!asinM) continue; + const asin = asinM[1].toUpperCase(); + const g = (f) => { + const m = block.match( + new RegExp(`["']?${f}["']?\\s*:\\s*['"]([^'"]*)['"]`), + ); + return m ? m[1] : null; + }; + const num = (f) => { + const m = block.match(new RegExp(`["']?${f}["']?\\s*:\\s*([0-9.]+)`)); + return m ? Number(m[1]) : null; + }; + const slug = g("slug"); + if (slug && slug.startsWith("fill-")) continue; + + let images = []; + const imgBlock = block.match(/["']?images["']?\s*:\s*\[([\s\S]*?)\]/); + if (imgBlock) { + images = [ + ...imgBlock[1].matchAll( + /https:\/\/[^\s'"\\]+(?:media-amazon|images-amazon|ssl-images-amazon)[^\s'"\\]*/g, + ), + ].map((m) => m[0].replace(/['"\\]+$/, "")); + images = [...new Set(images)]; + } + const single = g("image"); + if (single && !images.length) images = [single]; + + products.push({ + id: g("id"), + slug, + name: g("name"), + brand: g("brand"), + category: g("category"), + tagline: g("tagline"), + asin, + priceHint: num("priceHint"), + rating: num("rating"), + reviewCount: num("reviewCount"), + images, + limitedTime: /limitedTime:\s*true/.test(block), + }); + } + return products; +} + +function loadAll() { + const byAsin = new Map(); + for (const rel of PRODUCT_FILES) { + const path = join(root, rel); + if (!existsSync(path)) { + console.warn("skip missing", rel); + continue; + } + const list = parseProducts(readFileSync(path, "utf8")); + console.log("loaded", list.length, "from", rel); + for (const p of list) { + if (!p.asin) continue; + if (!byAsin.has(p.asin)) byAsin.set(p.asin, p); + } + } + return [...byAsin.values()]; +} + +async function main() { + if (!SITE_ID || SITE_ID.startsWith("__")) { + throw new Error("SITE_ID not configured in sync-from-library.mjs"); + } + console.log(dryRun ? "=== DRY RUN ===" : "=== SYNC ==="); + console.log("site:", SITE_ID); + console.log("mode: write-back only (no library→catalog image upgrades)"); + + const h = await health(); + console.log("library:", h.app, h.host || "", h.ok ? "ok" : h); + + const products = loadAll(); + console.log("unique ASINs:", products.length); + + const stats = { + libraryHit: 0, + libraryMiss: 0, + writtenBack: 0, + linked: 0, + errors: 0, + }; + const report = []; + + for (const p of products) { + const asin = p.asin; + let lib = null; + try { + lib = await getAmazonItem(asin); + } catch (e) { + stats.errors++; + console.error("GET fail", asin, e.message); + continue; + } + if (lib) stats.libraryHit++; + else stats.libraryMiss++; + + const writeImages = (p.images || []).filter(Boolean).slice(0, 8); + report.push({ + id: p.id || p.slug, + asin, + library: lib ? "hit" : "miss", + images: writeImages.length, + good: writeImages.filter(isGoodAmazonImage).length, + }); + + if (dryRun) continue; + + try { + await upsertAmazonItem({ + asin, + title: p.name, + brand: p.brand || null, + images: writeImages, + imagesMode: "replace", + price: p.priceHint ?? null, + rating: p.rating ?? null, + reviewCount: p.reviewCount ?? null, + fetchStatus: writeImages.some(isGoodAmazonImage) ? "ok" : "partial", + attributes: { + [`${SITE_ID}Id`]: p.id, + category: p.category, + slug: p.slug, + }, + }); + stats.writtenBack++; + await linkSiteItem({ + siteId: SITE_ID, + asin, + slug: p.slug || p.id, + houseName: p.name, + category: p.category, + tagline: p.tagline, + limitedTime: !!p.limitedTime, + }); + stats.linked++; + } catch (e) { + stats.errors++; + console.error("WRITE fail", asin, e.message); + } + await new Promise((r) => setTimeout(r, 30)); + } + + mkdirSync(join(root, "tmp"), { recursive: true }); + writeFileSync( + join(root, "tmp/library-sync-report.json"), + JSON.stringify({ stats, report, at: new Date().toISOString() }, null, 2), + ); + console.log("\n--- stats ---"); + console.log(stats); + console.log("report → tmp/library-sync-report.json"); + if (stats.errors) process.exitCode = 1; +} + +main().catch((e) => { + console.error(e); + process.exit(1); +});