[object Object]

← back to Stack Map Viewer

scraper staleness: measure TRUE last-scrape (prefer last_scraped/scraped_at/imported_at); flag db-touch dates (updated_at bulk migrations no longer read as fresh runs)

db6fb5eeacf704030535c7b7a19ad5bbd88afd9b · 2026-09-22 13:00:15 -0700 · Steve Abrams

Files touched

Diff

commit db6fb5eeacf704030535c7b7a19ad5bbd88afd9b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Sep 22 13:00:15 2026 -0700

    scraper staleness: measure TRUE last-scrape (prefer last_scraped/scraped_at/imported_at); flag db-touch dates (updated_at bulk migrations no longer read as fresh runs)
---
 index.html |  2 +-
 server.js  | 40 +++++++++++++++++++++++++---------------
 2 files changed, 26 insertions(+), 16 deletions(-)

diff --git a/index.html b/index.html
index c0f9ae3..d4020e9 100644
--- a/index.html
+++ b/index.html
@@ -421,7 +421,7 @@ function renderScrapersTable(){
   const rows=sortScr(SCR.rows).map((r,i)=>'<tr data-skill="'+r.skill+'">'
     +'<td class="num">'+(i+1)+'</td>'
     +'<td>'+r.skill.replace(/-scraper-manager$|-scraper$/,'')+'</td>'
-    +'<td class="age">'+(r.last||'<span style="color:#6f7d97">—</span>')+'</td>'
+    +'<td class="age">'+(r.last?esc(r.last)+(r.source==='db-touch'?' <span style="color:#c79a4a;font-weight:400;font-size:10px" title="no true scrape timestamp; date is from created_at/updated_at (a DB touch)">⚠ db-touch</span>':''):'<span style="color:#6f7d97">—</span>')+'</td>'
     +'<td class="age">'+ageCell(r.days)+'</td>'
     +'<td class="num">'+(r.rows==null?'—':r.rows.toLocaleString())+'</td>'
     +'<td class="tbl">'+(r.tbl||'<span style="color:#8a4a4a">unmapped</span>')+'</td></tr>').join('');
diff --git a/server.js b/server.js
index d5c1180..2b37976 100644
--- a/server.js
+++ b/server.js
@@ -202,22 +202,33 @@ function parseSkillMd(id) {
   return { dir, name, desc, body };
 }
 
-// last time a vendor scraper actually ran = newest scrape-ish timestamp in its dw_unified staging table
-const TS_COLS = ['last_scraped', 'scraped_at', 'specs_scraped_at', 'imported_at', 'updated_at', 'price_updated_at', 'created_at'];
+// last time a vendor scraper actually RAN. Prefer true-scrape columns; fall back to
+// created/updated only when no scrape column exists — because updated_at moves on ANY
+// bulk DB touch (e.g. the Apr-1 gemini-tagger migration hit 152 catalog tables), which
+// would falsely read as a fresh "run". Source is labelled so a db-touch can't pose as a scrape.
+const SCRAPE_COLS = ['last_scraped', 'scraped_at', 'specs_scraped_at', 'imported_at', 'price_updated_at'];
+const FALLBACK_COLS = ['created_at', 'updated_at'];
+const TS_COLS = [...SCRAPE_COLS, ...FALLBACK_COLS];
+const gexpr = (cols) => cols.length ? (cols.length > 1 ? 'greatest(' + cols.map(c => c + '::timestamptz').join(',') + ')' : cols[0] + '::timestamptz') : 'null::timestamptz';
+function lastRunFromParts(sLast, fLast, rows) {
+  const last = sLast || fLast; if (!last) return null;
+  const source = sLast ? 'scrape' : (fLast ? 'db-touch' : '');
+  const days = Math.floor((Date.now() - Date.parse(last.replace(' ', 'T'))) / 86400000);
+  const age = isNaN(days) ? '' : days === 0 ? 'today' : days === 1 ? '1 day ago' : days + ' days ago';
+  return { last, rows: +rows, days: isNaN(days) ? null : days, age, source };
+}
 function tableLastRun(tbl) {
   if (!/^[a-z0-9_]+$/.test(tbl || '')) return null;
   const q1 = `select column_name from information_schema.columns where table_name='${tbl}' and column_name in (${TS_COLS.map(c => `'${c}'`).join(',')})`;
   const cols = lines(sh(`psql -h /tmp -d dw_unified -tAc ${shq(q1)} 2>/dev/null`));
   if (!cols.length) return null;
-  const g = cols.map(c => c + '::timestamptz').join(',');
-  const expr = cols.length > 1 ? 'greatest(' + g + ')' : g;
-  const q2 = `select coalesce(to_char(max(${expr}),'YYYY-MM-DD HH24:MI'),'') || '|' || count(*) || '|' || coalesce(floor(extract(epoch from now()-max(${expr}))/86400)::text,'') from ${tbl}`;
+  const sc = cols.filter(c => SCRAPE_COLS.includes(c)), fc = cols.filter(c => FALLBACK_COLS.includes(c));
+  const q2 = `select coalesce(to_char(max(${gexpr(sc)}),'YYYY-MM-DD HH24:MI'),'') || '|' || coalesce(to_char(max(${gexpr(fc)}),'YYYY-MM-DD HH24:MI'),'') || '|' || count(*) from ${tbl}`;
   const out = sh(`psql -h /tmp -d dw_unified -tAc ${shq(q2)} 2>/dev/null`).trim();
   if (!out) return null;
-  const [last, rows, days] = out.split('|');
-  if (!last) return null;
-  const age = days === '' ? '' : (+days === 0 ? 'today' : +days === 1 ? '1 day ago' : days + ' days ago');
-  return { last, rows, age, tbl };
+  const [sLast, fLast, rows] = out.split('|');
+  const lr = lastRunFromParts(sLast, fLast, rows);
+  return lr ? { ...lr, tbl } : null;
 }
 
 function skillInfo(id) {
@@ -264,7 +275,7 @@ function skillInfo(id) {
   // LAST RUN — newest scrape timestamp in the vendor's staging table (scrapers only)
   const vendorTbl = tables.find(t => /_catalog$/.test(t)) || tables[0];
   const lr = vendorTbl ? tableLastRun(vendorTbl) : null;
-  const lastRun = lr ? (lr.last + (lr.age ? ' (' + lr.age + ')' : '') + ' · ' + Number(lr.rows).toLocaleString() + ' rows · ' + lr.tbl) : null;
+  const lastRun = lr ? (lr.last + (lr.age ? ' (' + lr.age + ')' : '') + ' · ' + Number(lr.rows).toLocaleString() + ' rows · ' + lr.tbl + (lr.source === 'db-touch' ? ' · ⚠ db-touch only, no scrape timestamp' : '')) : null;
   return {
     description: desc || '(no description found in SKILL.md)',
     what, why,
@@ -440,15 +451,14 @@ function scraperStaleness() {
       const parts = [];
       for (const t of tbls) {
         const cs = colsByTbl[t]; if (!cs || !cs.length) continue;
-        const g = cs.map(c => c + '::timestamptz').join(',');
-        const expr = cs.length > 1 ? 'greatest(' + g + ')' : g;
-        parts.push(`select '${t}' t, coalesce(to_char(max(${expr}),'YYYY-MM-DD HH24:MI'),'') last, count(*) n, coalesce(floor(extract(epoch from now()-max(${expr}))/86400)::int::text,'') days from ${t}`);
+        const sc = cs.filter(c => SCRAPE_COLS.includes(c)), fc = cs.filter(c => FALLBACK_COLS.includes(c));
+        parts.push(`select '${t}' t, coalesce(to_char(max(${gexpr(sc)}),'YYYY-MM-DD HH24:MI'),'') s, coalesce(to_char(max(${gexpr(fc)}),'YYYY-MM-DD HH24:MI'),'') f, count(*) n from ${t}`);
       }
       if (parts.length) for (const r of lines(sh(`psql -h /tmp -d dw_unified -tAF'|' -c ${shq(parts.join(' union all '))} 2>/dev/null`))) {
-        const [t, last, n, days] = r.split('|'); stats[t] = { last, rows: +n, days: days === '' ? null : +days };
+        const [t, s, f, n] = r.split('|'); stats[t] = lastRunFromParts(s, f, n);
       }
     }
-    const rows = map.map(x => { const st = x.tbl ? stats[x.tbl] : null; return { skill: x.skill, tbl: x.tbl || '', last: st ? st.last : '', rows: st ? st.rows : null, days: st && st.days != null ? st.days : null }; });
+    const rows = map.map(x => { const st = x.tbl ? stats[x.tbl] : null; return { skill: x.skill, tbl: x.tbl || '', last: st ? st.last : '', rows: st ? st.rows : null, days: st && st.days != null ? st.days : null, source: st ? st.source : '' }; });
     rows.sort((a, b) => a.days == null && b.days == null ? a.skill.localeCompare(b.skill) : a.days == null ? 1 : b.days == null ? -1 : b.days - a.days);
     const stale30 = rows.filter(r => r.days != null && r.days > 30).length;
     const nodata = rows.filter(r => r.days == null).length;

← 175dbc0 viewer: Scrapers ⏱ view — sortable staleness table (all 50 s  ·  back to Stack Map Viewer  ·  scraper view: History column — cadence-lapsed vs onboarded-o 1a1a9cd →