← back to Gracie Scraper
scripts/scrape.mjs
203 lines
#!/usr/bin/env node
/**
* Gracie Studio scraper — FEED-FIRST, $0 plain-fetch.
*
* Gracie is a Next.js site over a Sanity CMS backend. There is NO usable
* sitemap.xml (it returns the SPA HTML). The catalog is exposed as Next.js
* page-data JSON:
* https://graciestudio.com/_next/data/<buildId>/collection/<slug>.json?slug=<slug>
*
* pageProps.collectionContent.wallpapers[] is the full product array.
* Each wallpaper: title, subTitle (Gracie product code = mfr_sku), slug,
* tags[], rightBody (rich-text description), image / hoverImage / imageList[]
* (Sanity asset refs).
*
* Sanity image URL: https://cdn.sanity.io/images/<projectId>/<dataset>/<assetId>-<WxH>.<ext>
* from ref image-<assetId>-<WxH>-<ext>.
*
* The buildId is discovered live from the homepage __NEXT_DATA__, so a Gracie
* redeploy (new buildId) does not break the scraper.
*
* Upserts into dw_unified.gracie_catalog keyed on mfr_sku (ON CONFLICT).
* Cost: $0 (local) — pure HTTP fetch, no Browserbase / proxy / captcha / paid API.
*/
import { Client } from 'pg';
const UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36';
const BASE = 'https://graciestudio.com';
const SANITY = { projectId: 'fiu2vnvt', dataset: 'production' };
const DELAY_MS = 1200; // polite delay between collection fetches
// Sections that are wallcovering scenic panels vs fabric vs prints.
// Gracie is quote-only bespoke; we treat all as 'wallcovering' EXCEPT the
// explicit fabric collection.
const FABRIC_COLLECTIONS = new Set(['gracie-fabric']);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function fetchText(url) {
const res = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json,text/html' } });
if (!res.ok) throw new Error(`HTTP ${res.status} ${url}`);
return res.text();
}
async function getBuildIdAndCollections() {
const html = await fetchText(`${BASE}/`);
const bid = (html.match(/"buildId":"([^"]+)"/) || [])[1];
if (!bid) throw new Error('could not find Next.js buildId on homepage');
const cols = [...new Set([...html.matchAll(/href="\/collection\/([^"]+)"/g)].map((m) => m[1]))];
return { buildId: bid, collections: cols };
}
// image-<assetId>-<WxH>-<ext> -> cdn URL
function refToUrl(ref) {
if (!ref || typeof ref !== 'string') return null;
const m = ref.match(/^image-([a-f0-9]+)-(\d+x\d+)-(\w+)$/);
if (!m) return null;
const [, assetId, dims, ext] = m;
return `https://cdn.sanity.io/images/${SANITY.projectId}/${SANITY.dataset}/${assetId}-${dims}.${ext}`;
}
function imgUrl(imgObj) {
return refToUrl(imgObj?.asset?._ref);
}
// flatten Sanity portable-text rich body to a plain string
function flattenBody(body) {
if (!Array.isArray(body)) return '';
const parts = [];
for (const blk of body) {
for (const ch of blk.children || []) if (ch.text) parts.push(ch.text);
}
return parts.join(' ').replace(/\s+/g, ' ').trim();
}
// strip the boilerplate CTA sentences Gracie appends to every description
function cleanDescription(txt) {
if (!txt) return '';
return txt
.replace(/Complete the form below[^.]*\./gi, '')
.replace(/Click the link below[^.]*\./gi, '')
.replace(/Typically stocked pre-painted[^.]*\./gi, '')
.replace(/\s+/g, ' ')
.trim();
}
// Banned-word rule: OUR titles/descriptions say "Wallcovering", never "Wallpaper".
function sanitize(txt) {
if (!txt) return txt;
return txt
.replace(/\bwallpapers\b/gi, 'wallcoverings')
.replace(/\bwallpaper\b/gi, 'wallcovering');
}
function parseWallpaper(w, collectionSlug, collectionTitle) {
const mfr = (w.subTitle || w.slug?.current || w._id || '').trim();
if (!mfr) return null;
const pattern = (w.title || mfr).trim();
const gallery = (w.imageList || []).map((im) => imgUrl(im)).filter(Boolean);
const primary = imgUrl(w.image) || imgUrl(w.hoverImage) || gallery[0] || null;
const rawDesc = flattenBody(w.rightBody);
const desc = sanitize(cleanDescription(rawDesc));
const tags = (w.tags || []).map((t) => String(t)).filter(Boolean);
const productUrl = w.slug?.current
? `${BASE}/${w.slug.current}`
: `${BASE}/collection/${collectionSlug}`;
const isFabric = FABRIC_COLLECTIONS.has(collectionSlug);
return {
mfr_sku: mfr,
pattern_name: sanitize(pattern),
collection: sanitize(collectionTitle || collectionSlug),
image_url: primary,
product_url: productUrl,
product_type: isFabric ? 'fabric' : 'wallcovering',
material: 'Handpainted',
description: desc,
all_images: gallery.join('|'),
gallery_images: gallery,
specs: {
gracie_id: w._id,
subtitle: w.subTitle || null,
tags,
hover_image: imgUrl(w.hoverImage),
created_at: w._createdAt,
updated_at: w._updatedAt,
},
ai_tags: tags.length ? tags : null,
};
}
async function main() {
const { buildId, collections } = await getBuildIdAndCollections();
console.log(`[gracie] buildId=${buildId} collections=${collections.length}: ${collections.join(', ')}`);
const pg = new Client({ connectionString: process.env.PG || 'postgresql://localhost/dw_unified' });
await pg.connect();
const seen = new Set();
let upserts = 0;
const perCol = {};
for (const slug of collections) {
const url = `${BASE}/_next/data/${buildId}/collection/${encodeURIComponent(slug)}.json?slug=${encodeURIComponent(slug)}`;
let json;
try {
json = JSON.parse(await fetchText(url));
} catch (e) {
console.warn(`[gracie] SKIP ${slug}: ${e.message}`);
continue;
}
const cc = json?.pageProps?.collectionContent;
if (!cc) {
console.warn(`[gracie] ${slug}: no collectionContent`);
continue;
}
const wallpapers = cc.wallpapers || [];
perCol[slug] = 0;
for (const w of wallpapers) {
const rec = parseWallpaper(w, slug, cc.title);
if (!rec) continue;
if (seen.has(rec.mfr_sku)) continue; // dedup within run (patterns repeat across collections)
seen.add(rec.mfr_sku);
await pg.query(
`INSERT INTO gracie_catalog
(mfr_sku, pattern_name, collection, image_url, product_url, product_type,
material, description, all_images, gallery_images, specs, ai_tags, last_scraped, crawled_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,NOW(),NOW())
ON CONFLICT (mfr_sku) DO UPDATE SET
pattern_name=EXCLUDED.pattern_name,
collection=EXCLUDED.collection,
image_url=COALESCE(EXCLUDED.image_url, gracie_catalog.image_url),
product_url=EXCLUDED.product_url,
product_type=EXCLUDED.product_type,
material=EXCLUDED.material,
description=EXCLUDED.description,
all_images=EXCLUDED.all_images,
gallery_images=EXCLUDED.gallery_images,
specs=EXCLUDED.specs,
ai_tags=COALESCE(EXCLUDED.ai_tags, gracie_catalog.ai_tags),
last_scraped=NOW(), updated_at=NOW()`,
[
rec.mfr_sku, rec.pattern_name, rec.collection, rec.image_url, rec.product_url,
rec.product_type, rec.material, rec.description, rec.all_images, rec.gallery_images,
JSON.stringify(rec.specs), rec.ai_tags ? JSON.stringify(rec.ai_tags) : null,
]
);
upserts++;
perCol[slug]++;
}
console.log(`[gracie] ${slug}: ${perCol[slug]} patterns (of ${wallpapers.length} raw)`);
await sleep(DELAY_MS);
}
const { rows } = await pg.query('SELECT COUNT(*) c, COUNT(image_url) img FROM gracie_catalog');
console.log(`[gracie] DONE. upserts this run=${upserts}. table total=${rows[0].c}, with image=${rows[0].img}. Cost: $0 (local)`);
await pg.end();
}
main().catch((e) => {
console.error('[gracie] FATAL', e);
process.exit(1);
});