[object Object]

← back to Purelymail Fleet Viewer

viewer: server-rendered screenshots for every domain + Site reachability column

d0d1ab64d1a79b1c7154908ea7fd04b01ccb5084 · 2026-05-18 16:54:29 -0700 · SteveStudio2

~half the fleet blocks iframe embedding (X-Frame-Options/CSP). Added a
headless-Chromium screenshot service (/api/shot, disk-cached 24h, conc 3)
so grid + table previews show every site reliably. New Site column shows
reachable/no-embed/unreachable per domain via /api/embedcheck.

Files touched

Diff

commit d0d1ab64d1a79b1c7154908ea7fd04b01ccb5084
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Mon May 18 16:54:29 2026 -0700

    viewer: server-rendered screenshots for every domain + Site reachability column
    
    ~half the fleet blocks iframe embedding (X-Frame-Options/CSP). Added a
    headless-Chromium screenshot service (/api/shot, disk-cached 24h, conc 3)
    so grid + table previews show every site reliably. New Site column shows
    reachable/no-embed/unreachable per domain via /api/embedcheck.
---
 .gitignore        |   1 +
 package-lock.json |  56 +++++++++++++++++++++++++++
 package.json      |   5 +++
 public/index.html |  82 ++++++++++++++++++++++++++++++++++++----
 server.js         | 111 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 5 files changed, 247 insertions(+), 8 deletions(-)

diff --git a/.gitignore b/.gitignore
index 3e0aace..e8638f2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@ build/
 .next/
 data/receive-test-sends.jsonl
 data/send-test-sends.jsonl
+data/shots/
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..8757ffe
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,56 @@
+{
+  "name": "purelymail-fleet-viewer",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "dependencies": {
+        "playwright": "^1.60.0"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/playwright": {
+      "version": "1.60.0",
+      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
+      "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright-core": "1.60.0"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "fsevents": "2.3.2"
+      }
+    },
+    "node_modules/playwright-core": {
+      "version": "1.60.0",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
+      "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
+      "license": "Apache-2.0",
+      "bin": {
+        "playwright-core": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..f2eab14
--- /dev/null
+++ b/package.json
@@ -0,0 +1,5 @@
+{
+  "dependencies": {
+    "playwright": "^1.60.0"
+  }
+}
diff --git a/public/index.html b/public/index.html
index bee4134..0c66567 100644
--- a/public/index.html
+++ b/public/index.html
@@ -58,10 +58,19 @@
   .dgrid{display:grid;grid-template-columns:repeat(var(--cols,3),1fr);gap:14px;margin-top:6px}
   .dcard{background:var(--panel);border:1px solid var(--line);border-radius:8px;overflow:hidden}
   .dcard .thumb{width:100%;height:230px;border:0;background:#fff;display:block}
+  .shot{width:100%;height:230px;object-fit:cover;object-position:top;
+    background:#fff;display:block}
+  .shot.tall{height:540px}
   .dcard .meta{padding:8px 10px}
   .dcard .meta .dn{font-size:12px;font-weight:600}
   .dcard .meta .dn a{color:var(--gold);text-decoration:none}
   .dcard .meta .st{margin-top:6px;display:flex;gap:4px;flex-wrap:wrap}
+  .thumb-wrap{display:block}
+  .frame-fallback{display:flex;flex-direction:column;align-items:center;justify-content:center;
+    gap:6px;background:#0d0c0a;color:var(--dim);text-align:center;padding:14px}
+  .frame-fallback .ff-i{font-size:26px}
+  .frame-fallback .ff-m{font-size:12px;color:var(--ink)}
+  .frame-fallback .ff-d{font-size:11px;color:var(--dim)}
   .foot{color:var(--dim);font-size:11px;margin-top:24px;padding-top:12px;border-top:1px solid var(--line)}
   #err{color:var(--bad);font-size:12px}
 </style>
@@ -114,7 +123,7 @@
   <table id="domains">
     <thead><tr>
       <th>Domain</th><th>MX</th><th>SPF</th><th>DKIM</th><th>DMARC</th>
-      <th>Routing</th><th>Deliverable</th><th>Issues</th>
+      <th>Routing</th><th>Deliverable</th><th>Issues</th><th>Site</th>
     </tr></thead>
     <tbody></tbody>
   </table>
@@ -202,6 +211,32 @@ function draw(){
   if (v==='grid') drawGrid(); else drawTable();
 }
 
+const embedResults = {};   // domain -> {reachable, embeddable, status, reason}
+let embedRunId = 0;
+
+function shotFail(img, domain, h){
+  const w = document.createElement('div');
+  w.className = 'frame-fallback';
+  w.style.height = (h||230)+'px';
+  w.innerHTML = '<div class="ff-i">✕</div><div class="ff-m">site not reachable</div>'
+    + '<div class="ff-d">'+domain+'</div>';
+  img.replaceWith(w);
+}
+
+// Table detail: live iframe when the site allows embedding, otherwise a
+// server-rendered screenshot (always shows — no blank frames).
+function frameHTML(domain){
+  const r = embedResults[domain];
+  const open = '<div class="frame-link"><a href="https://'+domain
+    +'" target="_blank" rel="noopener">open https://'+domain+' ↗</a></div>';
+  if (r && r.embeddable){
+    return '<iframe src="https://'+domain
+      +'" loading="lazy" referrerpolicy="no-referrer"></iframe>'+open;
+  }
+  return '<img class="shot tall" src="/api/shot?domain='+encodeURIComponent(domain)
+    +'" loading="lazy" onerror="shotFail(this,\''+domain+'\',540)">'+open;
+}
+
 function toggleRow(safe, domain){
   const det = $('#detail-'+safe), caret = $('#caret-'+safe);
   if (!det) return;
@@ -212,21 +247,50 @@ function toggleRow(safe, domain){
     const fw = $('#fw-'+safe);
     if (fw && !fw.dataset.loaded){
       fw.dataset.loaded = '1';
-      fw.innerHTML = '<iframe src="https://'+domain+'" loading="lazy" referrerpolicy="no-referrer"></iframe>'
-        + '<div class="frame-link"><a href="https://'+domain+'" target="_blank" rel="noopener">open https://'
-        + domain+' ↗ &nbsp;(blank = site blocks embedding)</a></div>';
+      fw.innerHTML = frameHTML(domain);
     }
   }
 }
 
+function applyEmbedResult(safe, domain, r){
+  const cell = $('#site-'+safe);
+  if (!cell) return;
+  const cls = r.embeddable ? 'ok' : r.reachable ? 'warn' : 'bad';
+  const txt = r.embeddable ? 'reachable' : r.reachable ? 'no-embed' : 'unreachable';
+  cell.innerHTML = '<span class="pill '+cls+'" title="'+(r.reason||'').replace(/"/g,'')+'">'+txt+'</span>';
+}
+
+async function runEmbedChecks(domains){
+  const myRun = ++embedRunId;
+  const queue = domains.slice();
+  async function worker(){
+    while(queue.length){
+      if (myRun !== embedRunId) return;          // a redraw superseded this run
+      const domain = queue.shift();
+      const safe = domain.replace(/[^a-z0-9]/gi,'-');
+      let r = embedResults[domain];
+      if (!r){
+        try{
+          r = await (await fetch('/api/embedcheck?domain='+encodeURIComponent(domain))).json();
+        }catch(e){ r = {domain, reachable:false, embeddable:false, reason:'check failed'}; }
+        embedResults[domain] = r;
+      }
+      applyEmbedResult(safe, domain, r);
+    }
+  }
+  await Promise.all(Array.from({length:8}, worker));   // 8 concurrent
+}
+
 function drawGrid(){
   const rows = getRows();
   $('#rowcount').textContent = rows.length + ' shown';
   $('#grid').innerHTML = rows.map(d=>{
+    const safe = d.domain.replace(/[^a-z0-9]/gi,'-');
     const deliv = d.deliverable ? pill('ok','yes') : pill('bad','no');
     const iss = (d.issues||[]).length ? pill('warn',d.issues.join(', ')) : pill('ok','clean');
     return '<div class="dcard">'
-      + '<iframe class="thumb" src="https://'+d.domain+'" loading="lazy" referrerpolicy="no-referrer"></iframe>'
+      + '<img class="shot" src="/api/shot?domain='+encodeURIComponent(d.domain)
+      + '" loading="lazy" onerror="shotFail(this,\''+d.domain+'\',230)">'
       + '<div class="meta"><div class="dn"><a href="https://'+d.domain
       + '" target="_blank" rel="noopener">'+d.domain+'</a></div>'
       + '<div class="st">'+deliv+' '+iss+'</div></div></div>';
@@ -246,10 +310,12 @@ function drawTable(){
       + '<td>'+yn(d.passesMx)+'</td><td>'+yn(d.passesSpf)+'</td>'
       + '<td>'+yn(d.passesDkim)+'</td><td>'+yn(d.passesDmarc)+'</td>'
       + '<td>'+yn(d.hasRouting)+'</td><td>'+deliv+'</td>'
-      + '<td class="muted">'+((d.issues||[]).join(', ')||'—')+'</td></tr>'
+      + '<td class="muted">'+((d.issues||[]).join(', ')||'—')+'</td>'
+      + '<td id="site-'+safe+'"><span class="pill pend">checking…</span></td></tr>'
       + '<tr class="detail" id="detail-'+safe+'" style="display:none">'
-      + '<td colspan="8"><div class="frame-wrap" id="fw-'+safe+'"></div></td></tr>';
-  }).join('') || '<tr><td colspan="8" class="muted">no domains match</td></tr>';
+      + '<td colspan="9"><div class="frame-wrap" id="fw-'+safe+'"></div></td></tr>';
+  }).join('') || '<tr><td colspan="9" class="muted">no domains match</td></tr>';
+  runEmbedChecks(rows.map(d => d.domain));
 }
 
 function setDensity(){
diff --git a/server.js b/server.js
index 4f87026..9d9080c 100644
--- a/server.js
+++ b/server.js
@@ -30,12 +30,123 @@ function buildStatus() {
   };
 }
 
+// --- embed-reachability check ----------------------------------------------
+// Server-side probe: is the site reachable, and does it allow being framed?
+// (X-Frame-Options / CSP frame-ancestors). Cached 15 min so the table fills
+// fast on reload and we don't hammer 200+ sites.
+const embedCache = new Map();
+const EMBED_TTL = 15 * 60 * 1000;
+
+async function embedCheck(domain) {
+  const hit = embedCache.get(domain);
+  if (hit && Date.now() - hit.ts < EMBED_TTL) return hit.result;
+  let result;
+  const ac = new AbortController();
+  const timer = setTimeout(() => ac.abort(), 9000);
+  try {
+    const r = await fetch('https://' + domain + '/', {
+      method: 'GET', redirect: 'follow', signal: ac.signal,
+      headers: { 'User-Agent': 'Mozilla/5.0 (purelymail-fleet-viewer embed-check)' },
+    });
+    const xfo = (r.headers.get('x-frame-options') || '').toLowerCase();
+    const csp = (r.headers.get('content-security-policy') || '').toLowerCase();
+    const fa = (csp.match(/frame-ancestors([^;]*)/) || [])[1];
+    const blockedByXFO = xfo.includes('deny') || xfo.includes('sameorigin');
+    const blockedByCSP = fa != null && !fa.includes('*');
+    const reachable = r.status > 0 && r.status < 500;
+    result = {
+      domain, reachable, status: r.status,
+      embeddable: reachable && !blockedByXFO && !blockedByCSP,
+      reason: !reachable ? ('HTTP ' + r.status)
+        : blockedByXFO ? 'blocks embedding (X-Frame-Options)'
+        : blockedByCSP ? 'blocks embedding (CSP frame-ancestors)' : 'ok',
+    };
+  } catch (e) {
+    result = { domain, reachable: false, status: 0, embeddable: false,
+      reason: e.name === 'AbortError' ? 'timeout / no response' : (e.cause && e.cause.code) || e.code || 'unreachable' };
+  } finally { clearTimeout(timer); }
+  embedCache.set(domain, { ts: Date.now(), result });
+  return result;
+}
+
+// --- website screenshot service --------------------------------------------
+// ~half the fleet blocks iframe embedding (X-Frame-Options / CSP). Screenshots
+// always render, so the grid + table previews show every domain reliably.
+// Captured via headless Chromium, disk-cached 24h, concurrency-limited to 3.
+const { chromium } = require('playwright');
+const SHOT_DIR = path.join(__dirname, 'data/shots');
+const SHOT_TTL = 24 * 60 * 60 * 1000;
+try { fs.mkdirSync(SHOT_DIR, { recursive: true }); } catch {}
+
+let browserP = null;
+const getBrowser = () => (browserP = browserP || chromium.launch({ args: ['--no-sandbox'] }));
+
+let active = 0;
+const shotQueue = [];
+function runQueue() {
+  while (active < 3 && shotQueue.length) {
+    const job = shotQueue.shift();
+    active++;
+    captureShot(job.domain).then(job.resolve, () => job.resolve({ ok: false }))
+      .finally(() => { active--; runQueue(); });
+  }
+}
+const queueShot = domain => new Promise(resolve => { shotQueue.push({ domain, resolve }); runQueue(); });
+
+async function captureShot(domain) {
+  const file = path.join(SHOT_DIR, domain + '.png');
+  try {
+    const st = fs.statSync(file);
+    if (Date.now() - st.mtimeMs < SHOT_TTL && st.size > 0) return { ok: true, file };
+  } catch {}
+  let page;
+  try {
+    page = await (await getBrowser()).newPage({ viewport: { width: 1280, height: 800 } });
+    await page.goto('https://' + domain + '/', { waitUntil: 'domcontentloaded', timeout: 15000 });
+    await page.waitForTimeout(1800);
+    await page.screenshot({ path: file, clip: { x: 0, y: 0, width: 1280, height: 800 } });
+    return { ok: true, file };
+  } catch (e) {
+    return { ok: false, error: e.message };
+  } finally {
+    if (page) await page.close().catch(() => {});
+  }
+}
+
 const server = http.createServer((req, res) => {
+  if (req.url.startsWith('/api/shot')) {
+    const domain = (new URL(req.url, 'http://x').searchParams.get('domain') || '')
+      .replace(/[^a-z0-9.\-]/gi, '');
+    if (!domain) { res.writeHead(400); res.end(); return; }
+    queueShot(domain).then(r => {
+      if (r && r.ok) {
+        res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'max-age=3600' });
+        fs.createReadStream(r.file).pipe(res);
+      } else {
+        res.writeHead(502, { 'Content-Type': 'application/json' });
+        res.end(JSON.stringify({ error: (r && r.error) || 'capture failed' }));
+      }
+    });
+    return;
+  }
   if (req.url === '/api/status') {
     res.writeHead(200, { 'Content-Type': 'application/json' });
     res.end(JSON.stringify(buildStatus()));
     return;
   }
+  if (req.url.startsWith('/api/embedcheck')) {
+    const domain = (new URL(req.url, 'http://x').searchParams.get('domain') || '')
+      .replace(/[^a-z0-9.\-]/gi, '');
+    if (!domain) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end('{}'); return; }
+    embedCheck(domain).then(r => {
+      res.writeHead(200, { 'Content-Type': 'application/json' });
+      res.end(JSON.stringify(r));
+    }).catch(() => {
+      res.writeHead(200, { 'Content-Type': 'application/json' });
+      res.end(JSON.stringify({ domain, reachable: false, embeddable: false, reason: 'check failed' }));
+    });
+    return;
+  }
   try {
     const html = fs.readFileSync(path.join(__dirname, 'public/index.html'), 'utf8');
     res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });

← 7696460 viewer: grid view + density slider + collapsible per-domain  ·  back to Purelymail Fleet Viewer  ·  viewer: add fleet-cover hero with 1-up/4-up rotate toggle 2aafd6e →