← back to Trending Dw
scripts/download-real-images.js
102 lines
#!/usr/bin/env node
// download-real-images.js — pull each resolved candidate image down to public/assets/real/.
// Reads the fan-out agents' outputs (data/real-images/candidates/*.json), folds them into the
// ledger, then downloads every entry still at status candidate. $0 (plain HTTPS fetches).
// Idempotent: qc-pass/downloaded entries are skipped; re-run freely.
//
// Guards: browser UA (marketplace CDNs 403 default agents), ≥400px min dimension (sips),
// sha256 dedupe — the same byte-identical file arriving for >3 different items is a CDN
// placeholder, not 4 different products, and gets rejected.
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const { CAND_DIR, ASSETS, loadLedger, saveLedger, sha256 } = require('./real-lib');
const DRY = process.argv.includes('--dry-run');
const LIMIT = (() => { const i = process.argv.indexOf('--limit'); return i > -1 ? parseInt(process.argv[i + 1], 10) : Infinity; })();
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36';
const EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif', 'image/avif': 'avif' };
const MIN_PX = 400;
const MAX_SAME_SHA = 3;
fs.mkdirSync(ASSETS, { recursive: true });
// ---- 1. fold candidate files into the ledger (agents never touch the ledger themselves) ----
const ledger = loadLedger();
let folded = 0;
if (fs.existsSync(CAND_DIR)){
for (const f of fs.readdirSync(CAND_DIR).filter(f => f.endsWith('.json'))){
let c; try { c = JSON.parse(fs.readFileSync(path.join(CAND_DIR, f), 'utf8')); } catch(e){ console.log(` BAD candidate file ${f}: ${e.message}`); continue; }
if (!c.fingerprint) continue;
const l = ledger[c.fingerprint] || {};
if (l.status === 'qc-pass') continue; // already settled — don't churn
ledger[c.fingerprint] = { ...l, fingerprint: c.fingerprint, id: c.id, title: c.title || l.title,
status: c.candidateImageUrl ? 'candidate' : 'unresolved',
candidateUrl: c.candidateImageUrl || null, sourceUrl: c.sourcePageUrl || null,
altCandidates: c.altCandidates || [], confidence: c.confidence ?? null,
method: c.method || null, exaCalls: c.exaCalls ?? null, estCostUsd: c.estCostUsd ?? null,
notes: c.notes || null, updatedAt: new Date().toISOString() };
folded++;
}
}
async function fetchImage(url){
const res = await fetch(url, { headers: { 'User-Agent': UA, 'Accept': 'image/avif,image/webp,image/png,image/jpeg,*/*' }, redirect: 'follow', signal: AbortSignal.timeout(30000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const type = (res.headers.get('content-type') || '').split(';')[0].trim();
const buf = Buffer.from(await res.arrayBuffer());
if (!buf.length) throw new Error('empty body');
if (!EXT[type] && !/^image\//.test(type)) throw new Error(`not an image (${type || 'no content-type'})`);
return { buf, ext: EXT[type] || 'jpg' };
}
function dimensions(fp){
try {
const out = execFileSync('sips', ['-g', 'pixelWidth', '-g', 'pixelHeight', fp], { encoding: 'utf8' });
const w = +(out.match(/pixelWidth: (\d+)/) || [])[1], h = +(out.match(/pixelHeight: (\d+)/) || [])[1];
return { w, h };
} catch(e){ return { w: 0, h: 0 }; }
}
(async () => {
const todo = Object.values(ledger).filter(l => l.status === 'candidate' && l.candidateUrl).slice(0, LIMIT);
console.log(`candidates folded: ${folded} · to download: ${todo.length}${DRY ? ' (dry-run)' : ''}`);
if (DRY){ todo.forEach(l => console.log(` ${l.id} ${l.candidateUrl}`)); return; }
const shaCount = {}; // sha256 -> #items this run+prior
for (const l of Object.values(ledger)) if (l.sha256) shaCount[l.sha256] = (shaCount[l.sha256] || 0) + 1;
let ok = 0, fail = 0;
for (const l of todo){
try {
const { buf, ext } = await fetchImage(l.candidateUrl);
const sha = sha256(buf);
if ((shaCount[sha] || 0) >= MAX_SAME_SHA) throw new Error('placeholder: same bytes already used by ' + shaCount[sha] + ' items');
let fname = `${l.fingerprint}.${ext}`; // fingerprint stem — survives TR-id churn
let fp = path.join(ASSETS, fname);
fs.writeFileSync(fp, buf);
// ollama vision models 400 on webp/avif — normalize to png so the QC gate can see it
if (ext === 'webp' || ext === 'avif' || ext === 'gif'){
const pngName = `${l.fingerprint}.png`, pngPath = path.join(ASSETS, pngName);
execFileSync('sips', ['-s', 'format', 'png', fp, '--out', pngPath], { stdio: 'ignore' });
fs.unlinkSync(fp); fname = pngName; fp = pngPath;
}
const { w, h } = dimensions(fp);
if (w < MIN_PX && h < MIN_PX){ fs.unlinkSync(fp); throw new Error(`too small (${w}×${h})`); }
shaCount[sha] = (shaCount[sha] || 0) + 1;
Object.assign(l, { status: 'downloaded', file: fname, sha256: sha, width: w, height: h, downloadedAt: new Date().toISOString() });
ok++; console.log(` ✓ ${l.id} ← ${l.candidateUrl.slice(0, 90)} (${w}×${h})`);
} catch(e){
fail++;
// burn this candidate; promote the next alternate if the agent supplied one
const alts = l.altCandidates || [];
if (alts.length){ l.candidateUrl = alts.shift(); l.altCandidates = alts; l.status = 'candidate'; console.log(` ↻ ${l.id} — ${e.message}; trying alt (${alts.length} left)`); }
else { l.status = 'unresolved'; l.failReason = e.message; console.log(` ✗ ${l.id} — ${e.message}`); }
l.updatedAt = new Date().toISOString();
}
}
saveLedger(ledger);
const retriable = Object.values(ledger).filter(l => l.status === 'candidate').length;
console.log(`downloaded ${ok}, failed ${fail}${retriable ? `, ${retriable} candidates remain (re-run to retry alts)` : ''}. $0 (plain fetches).`);
})();