#!/usr/bin/env node /* Second look, in a real browser (Chrome, phone size, JavaScript on), at EVERY sampled website that opened in the first check. It answers tap-to-call, hours, address and "a way to reach them" again, both ways (a wrong "yes" counts as much as a wrong "no"): tel a tel: link with a real phone number (10+ digits) hours opening hours in the text a visitor sees, or in the page data (JSON-LD openingHours) and flags pages that open but show an error ("Not found", "404", almost no text). The rules for hours come from the 30-site review (20 Oct): "M - F: 8AM - 5PM" and "Open today / 08:30 am – 05:00 pm" were missed before. Reads out/frame.json + the scan, writes out/browser-all.json. Run: node browser-all.mjs [--pw , default: the current folder] */ import fs from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; const [scan, out] = process.argv.slice(2); const pi = process.argv.indexOf('--pw'); const pw = pi > 0 ? process.argv[pi + 1] : process.cwd(); const { chromium } = await import(pathToFileURL(path.join(pw, 'node_modules/playwright-core/index.mjs')).href); const S = JSON.parse(fs.readFileSync(scan, 'utf8')); const F = JSON.parse(fs.readFileSync(path.join(out, 'frame.json'), 'utf8')); const picked = new Set(Object.values(F.cells).flatMap((c) => c.picked)); const sites = S.rows.filter((r) => picked.has(r.place_id) && r.site_kind === 'own' && r.check?.fetch_status === 'ok').map((r) => ({ id: r.place_id, url: r.check.raw.final_url })); const DAY = '(?:mon|tue|wed|thu|fri|sat|sun)[a-z]*\\.?|m\\s*[-–]\\s*f|m\\s*[-–]\\s*th'; const TIME = '\\d{1,2}(?::\\d{2})?\\s*(?:am|pm|a\\.m\\.|p\\.m\\.|a\\b|p\\b)'; // "8:00a-12:00p" seen in the second review const RANGE = `${TIME}\\s*(?:-|–|—|to)\\s*${TIME}`; const HOURS = [ new RegExp(`\\b(?:${DAY})(?:\\s*(?:-|–|to|&|thru|through)\\s*(?:${DAY}))?[:,\\s]+(?:${RANGE}|open 24 hours|closed)`, 'i'), new RegExp(`\\b(?:hours|open today|open daily|we.re open)\\b[\\s\\S]{0,80}?${RANGE}`, 'i'), /\bopen 24 hours\b|\b24\/7\b/i, ]; /* a US street address: number + street name + street word (any case), e.g. "5022 South 33rd street", "515 W. WASHINGTON AVE" */ const ADDRESS = /\b\d{1,6}\s+(?:[NSEW]\.?\s+|north |south |east |west )?(?:[A-Za-z0-9.'-]+\s+){0,4}(?:st|street|ave|avenue|rd|road|blvd|boulevard|dr|drive|ln|lane|hwy|highway|way|pkwy|parkway|ct|court|pl|place|sq|square|cir|circle|trl|trail|pike|loop)\b\.?/i; const b = await chromium.launch({ executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' }); const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1' }); const res = {}; let i = 0; async function worker() { while (i < sites.length) { const s = sites[i++]; const p = await ctx.newPage(); try { const r = await p.goto(s.url, { waitUntil: 'domcontentloaded', timeout: 30000 }); await p.waitForLoadState('networkidle', { timeout: 12000 }).catch(() => {}); await p.waitForTimeout(1500); const look = await p.evaluate(() => ({ tels: [...document.querySelectorAll('a[href^="tel:" i]')].map((a) => a.getAttribute('href').replace(/\D/g, '')), mail: [...document.querySelectorAll('a[href^="mailto:" i]')].some((a) => /@/.test(a.getAttribute('href'))), contactLink: [...document.querySelectorAll('a[href]')].some((a) => /contact/i.test(a.getAttribute('href') + ' ' + a.textContent)), /* a form a customer can write in: a message box, or 3+ fields (a lone e-mail box is a newsletter sign-up) */ form: [...document.querySelectorAll('form')].some((f) => f.querySelector('textarea') || f.querySelectorAll('input:not([type=hidden]):not([type=submit]):not([type=button])').length >= 3), text: document.body?.innerText || '', title: document.title, ld: [...document.querySelectorAll('script[type="application/ld+json"]')].map((x) => x.textContent).join(' '), es: /^es\b/i.test(document.documentElement.lang) || !!document.querySelector('link[hreflang^="es" i]') || [...document.querySelectorAll('a[href]')].some((a) => /espa(ñ|n)ol|\bspanish\b/i.test(a.textContent) || /\/es(\/|$)|\/\/es\./i.test(a.getAttribute('href'))), })); const t = look.text.replace(/\s+/g, ' ').trim(); /* not the business's own website: a parked or for-sale domain (seen in the second review) */ const parked = /parked domain|domain (is )?for sale|buy this domain|this domain (may be|is) for sale/i.test(t.slice(0, 600)); const notFound = parked || t.length < 40 || /^(not found|404|page not found)\b/i.test(t) || /\b404\b.*not found|page (can.t|cannot) be found/i.test(t.slice(0, 300)) || /^404|not found/i.test(look.title.trim()); res[s.id] = { ok: true, status: r ? r.status() : 0, notFound, tel: look.tels.some((d) => d.length >= 10), hours: /openingHours/i.test(look.ld) || HOURS.some((re) => re.test(look.text)), address: /"streetAddress"/i.test(look.ld) || ADDRESS.test(look.text), reach: look.mail || look.contactLink || look.form, spanish: look.es, parked }; } catch (e) { res[s.id] = { ok: false, error: String(e.message || e).split('\n')[0].slice(0, 120) }; } await p.close(); process.stderr.write(`\r${i}/${sites.length}`); } } await Promise.all([worker(), worker(), worker(), worker()]); await b.close(); fs.writeFileSync(path.join(out, 'browser-all.json'), JSON.stringify(res, null, 1)); const v = Object.values(res); console.error(`\nok ${v.filter((x) => x.ok).length} · notFound ${v.filter((x) => x.notFound).length} · tel ${v.filter((x) => x.tel).length} · hours ${v.filter((x) => x.hours).length} · address ${v.filter((x) => x.address).length} · reach ${v.filter((x) => x.reach).length} · errors ${v.filter((x) => !x.ok).length}`);