← back to Letsbegin
scrape_dg_specs.js
243 lines
#!/usr/bin/env node
/**
* Designers Guild Phase 2 Enrichment Scraper
* Scrapes individual product pages for full specs:
* fire_rating, repeat_v, repeat_h, match_type, roll length, weight, care, application, collection
* Saves to PostgreSQL designers_guild_catalog
*/
const https = require('https');
const { Pool } = require('pg');
const pool = new Pool({ connectionString: (process.env.DATABASE_URL || 'postgresql://dw_admin@127.0.0.1:5432/dw_unified') });
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
function fetchPage(url) {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const req = https.get({
hostname: parsed.hostname,
path: parsed.pathname + parsed.search,
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept': 'text/html' }
}, res => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
return fetchPage(res.headers.location).then(resolve).catch(reject);
}
let data = '';
res.on('data', c => data += c);
res.on('end', () => resolve({ status: res.statusCode, body: data }));
});
req.on('error', reject);
req.setTimeout(20000, () => { req.destroy(); reject(new Error('timeout')); });
});
}
function extractSpec(html, label) {
// Look for patterns like: <dt>Width</dt><dd>27.5 in</dd>
// or: "Width":"27.5 in"
// or: label followed by value in various HTML patterns
const patterns = [
new RegExp(`"${label}"\\s*:\\s*"([^"]*)"`, 'i'),
new RegExp(`>${label}<[^>]*>[^<]*<[^>]*>([^<]+)<`, 'i'),
new RegExp(`${label}[:\\s]*</[^>]+>\\s*<[^>]+>([^<]+)<`, 'i'),
new RegExp(`${label}[:\\s]+([^<\\n]+)`, 'i'),
];
for (const pat of patterns) {
const m = html.match(pat);
if (m && m[1]?.trim()) return m[1].trim();
}
return null;
}
function extractAllSpecs(html) {
const specs = {};
// Width
specs.width = extractSpec(html, 'Width') || extractSpec(html, 'width');
// Repeat
specs.repeat_v = extractSpec(html, 'Vertical Pattern Repeat') || extractSpec(html, 'Vertical Repeat') || extractSpec(html, 'verticalRepeat');
specs.repeat_h = extractSpec(html, 'Horizontal Pattern Repeat') || extractSpec(html, 'Horizontal Repeat') || extractSpec(html, 'horizontalRepeat');
// Match type
specs.match_type = extractSpec(html, 'Pattern Match') || extractSpec(html, 'Match') || extractSpec(html, 'patternMatch');
// Fire rating
specs.fire_rating = extractSpec(html, 'Fire') || extractSpec(html, 'Flame') || extractSpec(html, 'fireRating');
// Also check for EN13501 pattern
const fireMatch = html.match(/EN13501[^<"]{0,50}Class\s+([A-E]-s\d-d\d)/i) || html.match(/(EN13501[^<"]{0,30})/i);
if (fireMatch && !specs.fire_rating) specs.fire_rating = fireMatch[1]?.trim();
// Collection
specs.collection = extractSpec(html, 'Collection') || extractSpec(html, 'collection');
// Roll length
specs.roll_length = extractSpec(html, 'Roll Length') || extractSpec(html, 'rollLength');
// Weight
specs.weight = extractSpec(html, 'Weight') || extractSpec(html, 'weight');
// Care/cleaning
specs.care = extractSpec(html, 'Care') || extractSpec(html, 'Cleaning') || extractSpec(html, 'careInstructions');
// Application/installation
specs.application = extractSpec(html, 'Installation') || extractSpec(html, 'Hanging') || extractSpec(html, 'Paste');
// Coverage
specs.coverage = extractSpec(html, 'Coverage') || extractSpec(html, 'coverage');
// Try JSON-LD structured data
const jsonLdMatch = html.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g);
if (jsonLdMatch) {
for (const block of jsonLdMatch) {
try {
const json = JSON.parse(block.replace(/<script[^>]*>/, '').replace(/<\/script>/, ''));
if (json.additionalProperty) {
for (const prop of json.additionalProperty) {
const name = (prop.name || '').toLowerCase();
const val = prop.value;
if (name.includes('vertical') && name.includes('repeat') && !specs.repeat_v) specs.repeat_v = val;
if (name.includes('horizontal') && name.includes('repeat') && !specs.repeat_h) specs.repeat_h = val;
if (name.includes('match') && !specs.match_type) specs.match_type = val;
if (name.includes('fire') && !specs.fire_rating) specs.fire_rating = val;
if (name.includes('weight') && !specs.weight) specs.weight = val;
if (name.includes('collection') && !specs.collection) specs.collection = val;
if (name.includes('length') && !specs.roll_length) specs.roll_length = val;
if (name.includes('care') && !specs.care) specs.care = val;
if (name.includes('paste') || name.includes('install') && !specs.application) specs.application = val;
}
}
} catch {}
}
}
// Room setting images - look for lifestyle/room images
const roomImages = [];
const imgMatches = html.matchAll(/src=["']([^"']*(?:room|lifestyle|interior|setting|scene|styled)[^"']*\.(?:jpg|jpeg|png|webp))["']/gi);
for (const m of imgMatches) {
roomImages.push(m[1]);
}
// Also check for large images in the gallery
const galleryImgs = html.matchAll(/image-nonwebp\/\d+\/(\d+)/g);
const imageIds = new Set();
for (const m of galleryImgs) {
imageIds.add(m[1]);
}
specs.image_ids = [...imageIds];
specs.room_images = roomImages;
return specs;
}
function parseInches(val) {
if (!val) return null;
// "27.5 in" → 27.5
// "118 in" → 118
// "55 cm" → convert
const inMatch = val.match(/([\d.]+)\s*(?:in|inch)/i);
if (inMatch) return parseFloat(inMatch[1]);
const cmMatch = val.match(/([\d.]+)\s*cm/i);
if (cmMatch) return parseFloat(cmMatch[1]) / 2.54;
const numMatch = val.match(/([\d.]+)/);
if (numMatch) return parseFloat(numMatch[1]);
return null;
}
async function main() {
console.log('=== Designers Guild Phase 2 Enrichment Scraper ===\n');
// Ensure columns exist
const newCols = ['care_instructions', 'weight', 'roll_length', 'coverage', 'room_setting_urls'];
for (const col of newCols) {
await pool.query(`ALTER TABLE designers_guild_catalog ADD COLUMN IF NOT EXISTS ${col} TEXT`).catch(() => {});
}
// Get all products that need enrichment (no fire_rating = not yet scraped at product level)
const { rows } = await pool.query(`
SELECT id, mfr_sku, product_url
FROM designers_guild_catalog
WHERE product_url IS NOT NULL AND product_url != ''
AND (fire_rating IS NULL OR fire_rating = '')
ORDER BY id
`);
console.log(`Found ${rows.length} products to scrape\n`);
let success = 0, failed = 0, noData = 0;
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
try {
const { status, body } = await fetchPage(row.product_url);
if (status !== 200) {
failed++;
if (i < 5) console.log(` FAIL [${row.mfr_sku}]: HTTP ${status}`);
continue;
}
const specs = extractAllSpecs(body);
// Parse numeric values
const repeatV = parseInches(specs.repeat_v);
const repeatH = parseInches(specs.repeat_h);
// Update PostgreSQL
const updates = [];
const values = [];
let paramIdx = 1;
const addUpdate = (col, val) => {
if (val && val.toString().trim()) {
updates.push(`${col} = $${paramIdx}`);
values.push(val.toString().trim());
paramIdx++;
}
};
addUpdate('fire_rating', specs.fire_rating || 'N/A'); // Always set to mark as scraped
addUpdate('match_type', specs.match_type);
addUpdate('collection', specs.collection);
addUpdate('care_instructions', specs.care);
addUpdate('weight', specs.weight);
addUpdate('roll_length', specs.roll_length);
addUpdate('coverage', specs.coverage);
addUpdate('application', specs.application);
if (repeatV) { addUpdate('repeat_v', repeatV); }
if (repeatH) { addUpdate('repeat_h', repeatH); }
if (specs.room_images?.length > 0) {
addUpdate('room_setting_urls', JSON.stringify(specs.room_images));
}
if (updates.length > 0) {
values.push(row.id);
await pool.query(`UPDATE designers_guild_catalog SET ${updates.join(', ')} WHERE id = $${paramIdx}`, values);
success++;
} else {
noData++;
}
} catch (e) {
failed++;
if (i < 5) console.log(` ERROR [${row.mfr_sku}]: ${e.message}`);
}
await sleep(1200); // Be nice to the server
if ((i + 1) % 50 === 0) {
console.log(`[${i + 1}/${rows.length}] success:${success} failed:${failed} noData:${noData}`);
}
}
console.log('\n=== COMPLETE ===');
console.log(`Total: ${rows.length} | Success: ${success} | Failed: ${failed} | No data: ${noData}`);
await pool.end();
}
main().catch(e => { console.error('Fatal:', e); process.exit(1); });