Skip to content

Instantly share code, notes, and snippets.

@AronNovak
Created May 13, 2026 09:01
Show Gist options
  • Select an option

  • Save AronNovak/296db7e9d057ad17985c61afc6a198be to your computer and use it in GitHub Desktop.

Select an option

Save AronNovak/296db7e9d057ad17985c61afc6a198be to your computer and use it in GitHub Desktop.
site speed measurements + CMS breakdown
#!/usr/bin/env node
const { chromium } = require('playwright');
const ExcelJS = require('exceljs');
const fs = require('fs');
const path = require('path');
const TTFB_THRESHOLD_MS = 1000;
const ATTEMPTS = 5;
const CONCURRENCY = 10;
const TIMEOUT_MS = 15000;
const REPORT_DIR = path.join(__dirname, 'reports');
function parseArgs() {
const args = process.argv.slice(2);
const opts = {
years: [],
recheck: null,
limit: 0,
output: null,
};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--year':
case '-y':
opts.years.push(args[++i]);
break;
case '--recheck':
case '-r':
opts.recheck = args[++i];
break;
case '--limit':
case '-l':
opts.limit = parseInt(args[++i], 10);
break;
case '--output':
case '-o':
opts.output = args[++i];
break;
case '--help':
case '-h':
printUsage();
process.exit(0);
}
}
return opts;
}
function printUsage() {
console.log(`
Usage: node check-speed.js [options]
Options:
-y, --year <year> Year file(s) to scan (can repeat, e.g. -y 2024 -y 2025)
-r, --recheck <file> Re-check only slow domains from a previous XLSX report
-l, --limit <n> Limit to first N domains (useful for testing)
-o, --output <file> Output XLSX filename (default: auto-generated in reports/)
-h, --help Show this help
Examples:
node check-speed.js -y 2025 # Scan all 2025 domains
node check-speed.js -y 2024 -y 2025 -l 100 # First 100 domains from 2024+2025
node check-speed.js -r reports/slow-2025.xlsx # Re-check slow domains from report
`);
}
function loadDomainsFromYears(years) {
const domains = [];
for (const year of years) {
const file = path.join(__dirname, `${year}.txt`);
if (!fs.existsSync(file)) {
console.error(`File not found: ${file}`);
continue;
}
const lines = fs.readFileSync(file, 'utf8').trim().split('\n');
// Skip header line "domain date"
for (let i = 1; i < lines.length; i++) {
const parts = lines[i].trim().split(/\s+/);
if (parts[0]) domains.push(parts[0]);
}
}
return [...new Set(domains)];
}
async function loadDomainsFromXlsx(filePath) {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(filePath);
const sheet = workbook.getWorksheet('Slow Sites');
if (!sheet) {
console.error('No "Slow Sites" sheet found in the report.');
process.exit(1);
}
const domains = [];
sheet.eachRow((row, rowNumber) => {
if (rowNumber === 1) return; // skip header
const domain = row.getCell(1).value;
if (domain) domains.push(String(domain));
});
return domains;
}
async function measureTTFB(page, url) {
try {
const response = await page.goto(url, {
waitUntil: 'commit',
timeout: TIMEOUT_MS,
});
if (!response) return null;
const timing = await page.evaluate(() => {
const nav = performance.getEntriesByType('navigation')[0];
if (!nav) return null;
return {
ttfb: nav.responseStart - nav.requestStart,
dns: nav.domainLookupEnd - nav.domainLookupStart,
tcp: nav.connectEnd - nav.connectStart,
tls: nav.secureConnectionStart > 0 ? nav.connectEnd - nav.secureConnectionStart : 0,
serverProcessing: nav.responseStart - nav.requestStart,
fullTTFB: nav.responseStart - nav.startTime,
};
});
return {
status: response.status(),
...timing,
};
} catch {
return null;
}
}
async function testDomain(browser, domain) {
// First, determine which protocol works (try HTTPS, fallback to HTTP)
let url = null;
for (const proto of ['https://', 'http://']) {
const tryUrl = `${proto}${domain}`;
const ctx = await browser.newContext({
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
locale: 'hu-HU',
timezoneId: 'Europe/Budapest',
viewport: { width: 1920, height: 1080 },
});
const page = await ctx.newPage();
const result = await measureTTFB(page, tryUrl);
await ctx.close();
if (result) {
url = tryUrl;
break;
}
}
if (!url) {
return { domain, status: 'down' };
}
// Now run the actual measurements
const results = [];
for (let i = 0; i < ATTEMPTS; i++) {
const context = await browser.newContext({
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
locale: 'hu-HU',
timezoneId: 'Europe/Budapest',
viewport: { width: 1920, height: 1080 },
});
const page = await context.newPage();
const result = await measureTTFB(page, url);
await context.close();
if (result) {
results.push(result);
}
}
if (results.length === 0) {
return { domain, status: 'down' };
}
const ttfbs = results.map((r) => r.fullTTFB).filter((t) => t != null);
if (ttfbs.length === 0) {
return { domain, status: 'error' };
}
const avgTTFB = ttfbs.reduce((a, b) => a + b, 0) / ttfbs.length;
const minTTFB = Math.min(...ttfbs);
const maxTTFB = Math.max(...ttfbs);
const medianTTFB = ttfbs.sort((a, b) => a - b)[Math.floor(ttfbs.length / 2)];
const serverTimes = results.map((r) => r.serverProcessing).filter((t) => t != null);
const avgServer = serverTimes.length
? serverTimes.reduce((a, b) => a + b, 0) / serverTimes.length
: null;
return {
domain,
status: 'ok',
httpStatus: results[0].status,
attempts: results.length,
avgTTFB: Math.round(avgTTFB),
medianTTFB: Math.round(medianTTFB),
minTTFB: Math.round(minTTFB),
maxTTFB: Math.round(maxTTFB),
avgServerProcessing: avgServer != null ? Math.round(avgServer) : null,
slow: medianTTFB > TTFB_THRESHOLD_MS,
};
}
async function runBatch(browser, domains, concurrency) {
const results = [];
let processed = 0;
const total = domains.length;
async function worker(domainQueue) {
while (domainQueue.length > 0) {
const domain = domainQueue.shift();
const result = await testDomain(browser, domain);
results.push(result);
processed++;
const pct = ((processed / total) * 100).toFixed(1);
const tag =
result.status === 'down'
? 'DOWN'
: result.slow
? `SLOW ${result.medianTTFB}ms`
: `OK ${result.medianTTFB}ms`;
process.stdout.write(`\r[${pct}%] ${processed}/${total} - ${domain} → ${tag} `);
}
}
const queue = [...domains];
const workers = [];
for (let i = 0; i < concurrency; i++) {
workers.push(worker(queue));
}
await Promise.all(workers);
console.log('\n');
return results;
}
async function writeReport(results, outputPath) {
const workbook = new ExcelJS.Workbook();
// Sheet 1: Slow sites
const slowResults = results.filter((r) => r.slow);
const slowSheet = workbook.addWorksheet('Slow Sites');
slowSheet.columns = [
{ header: 'Domain', key: 'domain', width: 40 },
{ header: 'HTTP Status', key: 'httpStatus', width: 12 },
{ header: 'Median TTFB (ms)', key: 'medianTTFB', width: 18 },
{ header: 'Avg TTFB (ms)', key: 'avgTTFB', width: 15 },
{ header: 'Min TTFB (ms)', key: 'minTTFB', width: 15 },
{ header: 'Max TTFB (ms)', key: 'maxTTFB', width: 15 },
{ header: 'Avg Server Processing (ms)', key: 'avgServerProcessing', width: 28 },
{ header: 'Attempts', key: 'attempts', width: 10 },
];
// Style header
slowSheet.getRow(1).font = { bold: true };
slowSheet.getRow(1).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFD32F2F' },
};
slowSheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } };
slowResults
.sort((a, b) => b.medianTTFB - a.medianTTFB)
.forEach((r) => slowSheet.addRow(r));
// Conditional formatting: color TTFB cells
slowSheet.eachRow((row, rowNumber) => {
if (rowNumber === 1) return;
const ttfbCell = row.getCell(3);
const val = ttfbCell.value;
if (val > 3000) {
ttfbCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFF0000' } };
ttfbCell.font = { color: { argb: 'FFFFFFFF' }, bold: true };
} else if (val > 2000) {
ttfbCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFF9800' } };
} else {
ttfbCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFF176' } };
}
});
// Sheet 2: All results
const allSheet = workbook.addWorksheet('All Results');
allSheet.columns = [
{ header: 'Domain', key: 'domain', width: 40 },
{ header: 'Status', key: 'status', width: 10 },
{ header: 'HTTP Status', key: 'httpStatus', width: 12 },
{ header: 'Median TTFB (ms)', key: 'medianTTFB', width: 18 },
{ header: 'Avg TTFB (ms)', key: 'avgTTFB', width: 15 },
{ header: 'Min TTFB (ms)', key: 'minTTFB', width: 15 },
{ header: 'Max TTFB (ms)', key: 'maxTTFB', width: 15 },
{ header: 'Slow?', key: 'slow', width: 8 },
];
allSheet.getRow(1).font = { bold: true };
allSheet.getRow(1).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FF1565C0' },
};
allSheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } };
results
.sort((a, b) => {
if (a.status === 'down' && b.status !== 'down') return 1;
if (a.status !== 'down' && b.status === 'down') return -1;
return (b.medianTTFB || 0) - (a.medianTTFB || 0);
})
.forEach((r) =>
allSheet.addRow({
...r,
slow: r.slow ? 'YES' : r.status === 'down' ? '' : 'NO',
})
);
// Sheet 3: Summary
const summarySheet = workbook.addWorksheet('Summary');
const total = results.length;
const down = results.filter((r) => r.status === 'down').length;
const ok = results.filter((r) => r.status === 'ok').length;
const slow = slowResults.length;
const fast = ok - slow;
summarySheet.columns = [
{ header: 'Metric', key: 'metric', width: 30 },
{ header: 'Value', key: 'value', width: 15 },
];
summarySheet.getRow(1).font = { bold: true };
summarySheet.addRow({ metric: 'Total Domains Tested', value: total });
summarySheet.addRow({ metric: 'Domains Up', value: ok });
summarySheet.addRow({ metric: 'Domains Down/Unreachable', value: down });
summarySheet.addRow({
metric: `Slow (TTFB > ${TTFB_THRESHOLD_MS}ms)`,
value: slow,
});
summarySheet.addRow({
metric: `Fast (TTFB <= ${TTFB_THRESHOLD_MS}ms)`,
value: fast,
});
summarySheet.addRow({
metric: 'Slow % (of reachable)',
value: ok > 0 ? `${((slow / ok) * 100).toFixed(1)}%` : 'N/A',
});
summarySheet.addRow({ metric: 'Scan Date', value: new Date().toISOString() });
summarySheet.addRow({ metric: 'TTFB Threshold (ms)', value: TTFB_THRESHOLD_MS });
summarySheet.addRow({ metric: 'Attempts per Domain', value: ATTEMPTS });
await workbook.xlsx.writeFile(outputPath);
return { total, ok, down, slow, fast };
}
async function main() {
const opts = parseArgs();
if (!opts.recheck && opts.years.length === 0) {
console.error('Error: Specify --year or --recheck. Use --help for usage.');
process.exit(1);
}
// Load domains
let domains;
if (opts.recheck) {
console.log(`Re-checking slow domains from: ${opts.recheck}`);
domains = await loadDomainsFromXlsx(opts.recheck);
} else {
console.log(`Loading domains from year(s): ${opts.years.join(', ')}`);
domains = loadDomainsFromYears(opts.years);
}
if (opts.limit > 0) {
domains = domains.slice(0, opts.limit);
}
console.log(`Domains to test: ${domains.length}`);
console.log(`Concurrency: ${CONCURRENCY}, Attempts per domain: ${ATTEMPTS}`);
console.log(`TTFB threshold: ${TTFB_THRESHOLD_MS}ms\n`);
if (!fs.existsSync(REPORT_DIR)) {
fs.mkdirSync(REPORT_DIR, { recursive: true });
}
// Launch browser
const browser = await chromium.launch({ headless: true });
const startTime = Date.now();
const results = await runBatch(browser, domains, CONCURRENCY);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
await browser.close();
// Determine output filename
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const tag = opts.recheck ? 'recheck' : opts.years.join('+');
const outputPath = opts.output || path.join(REPORT_DIR, `speed-${tag}-${timestamp}.xlsx`);
const stats = await writeReport(results, outputPath);
console.log('=== SCAN COMPLETE ===');
console.log(`Time: ${elapsed}s`);
console.log(`Total: ${stats.total} | Up: ${stats.ok} | Down: ${stats.down}`);
console.log(`Slow: ${stats.slow} | Fast: ${stats.fast}`);
console.log(`Report: ${outputPath}`);
if (stats.slow > 0) {
console.log(`\nTo re-check slow sites:`);
console.log(` node check-speed.js --recheck ${outputPath}`);
}
}
main().catch((err) => {
console.error('Fatal error:', err);
process.exit(1);
});
#!/usr/bin/env python3
"""Deterministic CMS / framework detection for the top 200 slow Hungarian sites.
Reads reports/speed-recheck-2026-03-16-filtered.xlsx, takes the first 200
domains from the "Slow Sites (filtered)" sheet, fetches each homepage
(HTTPS first, then HTTP) and classifies it using header + HTML
fingerprints. No AI, just regex/string match.
Categories (first matching pattern wins, order matters):
Parking / Suspended – ELIN.hu, cPanel "Account Suspended", Apache
default, generic domain registrar parking.
Joomla, Webnode, UNAS, Shoprenter, Shopify, Magento, PrestaShop,
OpenCart, Wix, Squarespace, Webflow, Tilda, Jimdo, TYPO3, Umbraco,
Kentico, Next.js, Gatsby, Nuxt, webtrees
Drupal, WordPress, Sitecore – the four originally requested buckets.
Unknown – anything else.
WordPress sites get an extra `woocommerce` flag in the CSV.
"""
import concurrent.futures
import csv
import json
import re
import sys
import urllib3
from pathlib import Path
import openpyxl
import requests
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
ROOT = Path('/mnt/work/gizra/slow-hungarian-sites')
XLSX = ROOT / 'reports' / 'speed-recheck-2026-03-16-filtered.xlsx'
OUT_DIR = Path('/tmp/cms-scan')
HTML_DIR = OUT_DIR / 'html'
HTML_DIR.mkdir(parents=True, exist_ok=True)
RESULT_CSV = OUT_DIR / 'results.csv'
TOP_N = 200
TIMEOUT = 25
WORKERS = 16
UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
def rx(pattern, flags=re.I):
return re.compile(pattern.encode() if isinstance(pattern, str) else pattern, flags)
# ---------------------------------------------------------------------------
# Parking / suspended fingerprints – checked FIRST so an ELIN parking page
# never accidentally gets attributed to the underlying domain owner.
# ---------------------------------------------------------------------------
PARKING_PATTERNS = [
# ELIN.hu hosting / domain parking. Match on the distinctive ASCII bits
# ("ELIN.hu Kft" inside a <title>, the @font-face "elinfont" they ship,
# or their domain-services URL fragment) – avoids UTF-8 multibyte issues.
(rx(r'<title>[^<]{0,200}ELIN\.hu\s+Kft'), 'ELIN.hu parking page'),
(rx(r'font-family:\s*elinfont\b'), 'ELIN.hu parking page'),
(rx(r'domain-szolgaltatasok/domain-regisztracio'), 'ELIN.hu parking page'),
# cPanel "Account Suspended"
(rx(r'<title>\s*Account\s+Suspended\s*</title>'), 'cPanel "Account Suspended"'),
# Apache default landing page (Fedora/Debian/CentOS default index)
(rx(r'Apache\s+is\s+functioning\s+normally'), 'Apache default page'),
(rx(r'<title>\s*Apache2\s+(Debian|Ubuntu|Default)\s+Page'), 'Apache default page'),
(rx(r'<title>\s*Test\s+Page\s+for\s+the\s+Apache'), 'Apache default page'),
# Domain Regisztráció Kft parked page (e.g. klima-tesla.hu)
(rx(r'<title>[^<]{0,200}Domain\s+Regisztr'), 'Domain Regisztráció Kft parking'),
(rx(r'Domain\s+regisztr[^<]{0,40}\s+1\s*Ft'), 'Domain Regisztráció Kft parking'),
(rx(r'domreg_favicon'), 'Domain Regisztráció Kft parking'),
# Generic domain-for-sale / parking-as-a-service markers
(rx(r'this\s+domain\s+(?:is|may\s+be)\s+for\s+sale'), 'Generic parking'),
(rx(r'sedoparking\.com|cashparking\.com|parking\.bodis\.com'), 'Domain parking service'),
# nginx default
(rx(r'<title>\s*Welcome\s+to\s+nginx'), 'nginx default page'),
]
# ---------------------------------------------------------------------------
# CMS / framework fingerprints – first match wins.
# Each entry: (cms_label, [list of regex])
# ---------------------------------------------------------------------------
CMS_PATTERNS = [
('Joomla', [
rx(r'<meta[^>]+name="generator"[^>]+content="Joomla'),
rx(r'HELIX_ULTIMATE_GENERATOR_TEXT'),
rx(r'/media/jui/'),
rx(r'/components/com_'),
rx(r'/modules/mod_'),
rx(r'/templates/[a-z0-9_-]+/(html|css|js)/'),
rx(r'option=com_'),
]),
('Webnode', [
rx(r'<meta[^>]+name="generator"[^>]+content="Webnode'),
rx(r'assets\.webnode\.com'),
rx(r'cdn\.webnode\.com'),
]),
('UNAS', [
rx(r'<meta[^>]+name="generator"[^>]+content="UNAS'),
# UNAS-hosted webshops resolve assets via "<shop>.unas.hu/element/"
# or use *.unas.hu CDN / shop hosts.
rx(r'[a-z0-9-]+\.unas\.hu/element/'),
rx(r'(shop|cdn|static|kep|img)\.unas\.hu'),
rx(r'\bunas-shop\b'),
]),
('Shoprenter', [
rx(r'<meta[^>]+name="generator"[^>]+content="Shoprenter'),
rx(r'shoprenter\.hu'),
rx(r'cdn\.myshoprenter\.hu'),
rx(r'shp_(?:lang|currency|cookie)'),
]),
('Shopify', [
rx(r'cdn\.shopify\.com'),
rx(r'Shopify\.theme'),
rx(r'shopify-section'),
rx(r'<meta[^>]+name="shopify-'),
]),
('Magento', [
rx(r'/skin/frontend/'),
rx(r'Mage\.Cookies'),
rx(r'/static/version\d'),
rx(r'data-mage-init'),
rx(r'Magento_Ui/js'),
]),
('PrestaShop', [
rx(r'<meta[^>]+name="generator"[^>]+content="PrestaShop'),
rx(r'var\s+prestashop\b'),
rx(r'/themes/[^/]+/assets/cache/'),
rx(r'/modules/ps_'),
]),
('OpenCart', [
rx(r'<meta[^>]+name="generator"[^>]+content="OpenCart'),
rx(r'/catalog/view/theme/'),
rx(r'route=common/home'),
]),
('Wix', [
rx(r'static\.wixstatic\.com'),
rx(r'wix\.com/_partials'),
rx(r'<meta[^>]+name="generator"[^>]+content="Wix\.com'),
]),
('Squarespace', [
rx(r'static1\.squarespace\.com'),
rx(r'<!--\s*This\s+is\s+Squarespace'),
rx(r'squarespace-cdn\.com'),
]),
('Webflow', [
rx(r'<html[^>]+data-wf-site='),
rx(r'webflow\.js'),
rx(r'<meta[^>]+name="generator"[^>]+content="Webflow'),
]),
('Tilda', [
rx(r'tildacdn\.com'),
rx(r'tilda\.ws'),
rx(r'<meta[^>]+name="generator"[^>]+content="Tilda'),
]),
('Jimdo', [
rx(r'jimdo-static\.com'),
rx(r'<meta[^>]+name="generator"[^>]+content="Jimdo'),
]),
('TYPO3', [
rx(r'<meta[^>]+name="generator"[^>]+content="TYPO3'),
rx(r'/typo3conf/'),
rx(r'/typo3temp/'),
rx(r'/fileadmin/(templates|user_upload)'),
]),
('Umbraco', [
rx(r'<meta[^>]+name="generator"[^>]+content="Umbraco'),
rx(r'/umbraco/(surface|api|render)/'),
]),
('Kentico', [
rx(r'<meta[^>]+name="generator"[^>]+content="Kentico'),
rx(r'/CMSPages/'),
rx(r'/CMSScripts/'),
]),
('webtrees', [
rx(r'<meta[^>]+name="generator"[^>]+content="webtrees'),
rx(r'/vendor/fisharebest/webtrees'),
]),
('Next.js', [
rx(r'/_next/static/'),
rx(r'__NEXT_DATA__'),
rx(r'<script[^>]+id="__NEXT_DATA__"'),
]),
('Gatsby', [
rx(r'<div[^>]+id="___gatsby"'),
rx(r'window\.___gatsby'),
rx(r'gatsby-link'),
]),
('Nuxt', [
rx(r'window\.__NUXT__'),
rx(r'/_nuxt/'),
]),
# Originally requested buckets
('Drupal', [
rx(r'<meta[^>]+name="generator"[^>]+content="Drupal'),
rx(r'/sites/default/files/'),
rx(r'/sites/all/(modules|themes)/'),
rx(r'/core/misc/drupal\.'),
rx(r'data-drupal-'),
rx(r'Drupal\.settings'),
rx(r'drupalSettings'),
rx(r'jQuery\.extend\(Drupal'),
]),
('WordPress', [
rx(r'<meta[^>]+name="generator"[^>]+content="WordPress'),
rx(r'/wp-content/'),
rx(r'/wp-includes/'),
rx(r'/wp-json/'),
rx(r'wp-emoji-release'),
rx(r'wpemojiSettings'),
]),
('Sitecore', [
rx(r'/sitecore/'),
rx(r'<meta[^>]+name="generator"[^>]+content="Sitecore'),
rx(r'sc_site='),
rx(r'sitecore\.shell'),
rx(r'xmlns:sc='),
]),
]
# Sub-flag patterns – computed in addition to the main CMS bucket.
WOOCOMMERCE_PATTERNS = [
rx(r'/wp-content/plugins/woocommerce/'),
rx(r'\bwoocommerce(?:-js)?\b'),
rx(r'woocommerce_params'),
]
# HTTP-header fingerprints (checked before HTML to leverage server hints).
HEADER_PATTERNS = [
('Drupal', 'x-generator', re.compile(r'drupal', re.I)),
('Drupal', 'x-drupal-cache', re.compile(r'.', re.I)),
('Drupal', 'x-drupal-dynamic-cache', re.compile(r'.', re.I)),
('WordPress', 'x-powered-by', re.compile(r'wordpress', re.I)),
('WordPress', 'link', re.compile(r'wp-json', re.I)),
('Sitecore', 'x-powered-by', re.compile(r'sitecore', re.I)),
('Shopify', 'x-shopify-stage', re.compile(r'.', re.I)),
('Shopify', 'x-shopid', re.compile(r'.', re.I)),
('Shopify', 'powered-by', re.compile(r'shopify', re.I)),
('Magento', 'x-magento-cache-debug', re.compile(r'.', re.I)),
('TYPO3', 'x-typo3-parsetime', re.compile(r'.', re.I)),
]
def load_top_domains(n):
wb = openpyxl.load_workbook(XLSX, read_only=True)
ws = wb['Slow Sites (filtered)']
out = []
for row in ws.iter_rows(min_row=2, values_only=True):
domain = row[0]
if not domain:
continue
out.append({
'domain': domain.strip(),
'http_status': row[1],
'median_ttfb': row[2],
})
if len(out) >= n:
break
return out
def safe_name(domain):
return re.sub(r'[^A-Za-z0-9._-]', '_', domain)
def fetch(domain):
session = requests.Session()
session.headers.update({'User-Agent': UA, 'Accept': 'text/html,*/*'})
last_err = None
for scheme in ('https', 'http'):
try:
r = session.get(f'{scheme}://{domain}/', timeout=TIMEOUT,
allow_redirects=True, verify=False)
return r.url, r.status_code, dict(r.headers), r.content[:400_000]
except requests.RequestException as e:
last_err = f'{scheme}: {type(e).__name__}'
continue
return None, last_err, None, None
def get_header(headers, name):
if not headers:
return None
for k, v in headers.items():
if k.lower() == name.lower():
return v
return None
def classify(headers, body):
if body:
for pattern, label in PARKING_PATTERNS:
if pattern.search(body):
return 'Parking / Suspended', f'parking:{label}'
if headers:
for cms, hname, regex in HEADER_PATTERNS:
v = get_header(headers, hname)
if v and regex.search(v):
return cms, f'header:{hname}={v[:80]}'
if body:
for cms, patterns in CMS_PATTERNS:
for p in patterns:
if p.search(body):
return cms, f'html:{p.pattern.decode("utf-8", "replace")[:80]}'
return 'Unknown', ''
def detect_woocommerce(body):
if not body:
return False
return any(p.search(body) for p in WOOCOMMERCE_PATTERNS)
def process(entry):
domain = entry['domain']
final_url, status, headers, body = fetch(domain)
if body is None:
return {
**entry,
'final_url': '',
'http_fetch_status': status if isinstance(status, int) else 0,
'fetch_error': '' if isinstance(status, int) else str(status),
'cms': 'Unknown',
'evidence': 'fetch-failed',
'woocommerce': '',
'body_len': 0,
}
out_file = HTML_DIR / f'{safe_name(domain)}.html'
try:
out_file.write_bytes(body)
except OSError:
pass
cms, evidence = classify(headers, body)
woo = ''
if cms == 'WordPress':
woo = 'yes' if detect_woocommerce(body) else 'no'
return {
**entry,
'final_url': final_url or '',
'http_fetch_status': status if isinstance(status, int) else 0,
'fetch_error': '',
'cms': cms,
'evidence': evidence,
'woocommerce': woo,
'body_len': len(body),
}
def main():
domains = load_top_domains(TOP_N)
print(f'Loaded {len(domains)} domains from {XLSX.name}', file=sys.stderr)
results = []
with concurrent.futures.ThreadPoolExecutor(max_workers=WORKERS) as pool:
futures = {pool.submit(process, d): d['domain'] for d in domains}
for i, fut in enumerate(concurrent.futures.as_completed(futures), 1):
results.append(fut.result())
if i % 10 == 0 or i == len(domains):
print(f' {i}/{len(domains)} done', file=sys.stderr)
order = {d['domain']: i for i, d in enumerate(domains)}
results.sort(key=lambda r: order.get(r['domain'], 1e9))
with RESULT_CSV.open('w', newline='') as f:
w = csv.DictWriter(f, fieldnames=[
'domain', 'median_ttfb', 'http_status', 'http_fetch_status',
'cms', 'woocommerce', 'evidence', 'final_url', 'fetch_error',
'body_len',
])
w.writeheader()
for r in results:
w.writerow({k: r.get(k, '') for k in w.fieldnames})
counts = {}
for r in results:
counts[r['cms']] = counts.get(r['cms'], 0) + 1
print(json.dumps(counts, indent=2, sort_keys=True))
if __name__ == '__main__':
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment