[urls] 'trekking / tourism' => [ 'https://www.nepalhikingteam.com/', 'https://www.sevensummittreks.com/', 'https://www.welcomenepaltreks.com/', 'https://www.greenvalleynepaltreks.com/', 'https://www.thenepaltrekkingcompany.com/', 'https://taan.org.np/', 'https://ntb.gov.np/', ], 'health / clinics' => [ 'https://www.kdh.com.np/', 'https://dent-inn.com/', 'https://www.smilenepal.com/', 'https://kantipurdental.edu.np/', 'https://www.phectnepal.org/', ], 'hospitality' => [ 'https://hotelthamel.com/', 'https://www.hotelyakandyeti.com/', 'https://www.hotelhimalaya.com/', 'https://www.dwarikas.com/', ], 'news / media' => [ 'https://www.onlinekhabar.com/', 'https://ekantipur.com/', 'https://www.setopati.com/', 'https://myrepublica.nagariknetwork.com/', 'https://kathmandupost.com/', 'https://english.insurancekhabar.com/', 'https://bankingnews.com.np/', ], 'finance / commerce' => [ 'https://www.sharesansar.com/', 'https://www.nrb.org.np/', 'https://daraz.com.np/', 'https://www.hamrobazaar.com/', 'https://esewa.com.np/', 'https://khalti.com/', ], 'education / institutions' => [ 'https://tribhuvan-university.edu.np/', 'https://ku.edu.np/', 'https://www.pu.edu.np/', 'https://moe.gov.np/', ], 'seo & digital agencies' => [ 'https://seoexpertinnepal.com/', 'https://aviralacharya.com.np/', 'https://www.clickbenz.com/', 'https://krizmatic.com/', 'https://kathmanduinfotech.com/', 'https://dipakpaneru.com.np/', 'https://baamaconsultant.com.np/', 'https://pigeonwebsolutions.com/', ], ]; /* ------------------------------------------------------------------ */ function fetchOne(string $url): array { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 5, CURLOPT_HEADER => true, CURLOPT_TIMEOUT => 30, CURLOPT_CONNECTTIMEOUT => 12, CURLOPT_SSL_VERIFYPEER => false, // we record TLS problems, not enforce them CURLOPT_ENCODING => '', // advertise gzip/br so we can see if it is used CURLOPT_USERAGENT => 'Mozilla/5.0 (Linux; Android 12; SM-A125F) AppleWebKit/537.36 ' . '(KHTML, like Gecko) Chrome/120 Mobile Safari/537.36', ]); $raw = curl_exec($ch); $err = curl_error($ch); $info = curl_getinfo($ch); curl_close($ch); if ($raw === false) { return ['ok' => false, 'error' => $err, 'url' => $url]; } $hsize = $info['header_size']; return [ 'ok' => true, 'url' => $url, 'final_url' => $info['url'], 'code' => $info['http_code'], 'redirects' => $info['redirect_count'], 'ttfb' => $info['starttransfer_time'], 'total_time' => $info['total_time'], 'size' => $info['size_download'], 'headers' => substr($raw, 0, $hsize), 'body' => substr($raw, $hsize), ]; } function analyse(array $r): array { $h = $r['headers']; $b = $r['body']; $out = []; $out['url'] = $r['url']; $out['final_url'] = $r['final_url']; $out['code'] = $r['code']; $out['redirects'] = $r['redirects']; $out['ttfb_ms'] = (int)round($r['ttfb'] * 1000); $out['html_bytes'] = $r['size']; // HTTPS actually enforced (the final URL after following redirects) $out['https'] = str_starts_with($r['final_url'], 'https://'); // Transfer compression $out['compressed'] = (bool)preg_match('#^content-encoding:\s*(gzip|br|deflate|zstd)#mi', $h); // preg_match('#]*\blang=["\']([^"\']+)#i', $b, $m); $out['lang'] = $m[1] ?? null; // Mobile viewport $out['viewport'] = (bool)preg_match('#]+name=["\']viewport["\']#i', $b); // Title preg_match('#]*>(.*?)#is', $b, $m); $title = isset($m[1]) ? trim(html_entity_decode(strip_tags($m[1]), ENT_QUOTES, 'UTF-8')) : ''; $out['title'] = $title; $out['title_len'] = mb_strlen($title); // Meta description preg_match('#]+name=["\']description["\'][^>]*content=["\']([^"\']*)#i', $b, $m); $desc = isset($m[1]) ? trim(html_entity_decode($m[1], ENT_QUOTES, 'UTF-8')) : ''; $out['description'] = $desc; $out['desc_len'] = mb_strlen($desc); // Canonical $out['canonical'] = (bool)preg_match('#]+rel=["\']canonical["\']#i', $b); // Headings $out['h1_count'] = preg_match_all('#]#i', $b); $out['h2_count'] = preg_match_all('#]#i', $b); // Open Graph / Twitter $out['og'] = (bool)preg_match('#]+property=["\']og:title["\']#i', $b); $out['og_image'] = (bool)preg_match('#]+property=["\']og:image["\']#i', $b); $out['twitter'] = (bool)preg_match('#]+name=["\']twitter:card["\']#i', $b); // Structured data preg_match_all('#]+application/ld\+json[^>]*>(.*?)#is', $b, $ld); $out['jsonld_blocks'] = count($ld[1]); $valid = 0; $types = []; foreach ($ld[1] as $j) { $d = json_decode(trim($j), true); if (json_last_error() === JSON_ERROR_NONE) { $valid++; $flat = json_encode($d); preg_match_all('#"@type"\s*:\s*"([A-Za-z]+)"#', $flat, $t); $types = array_merge($types, $t[1]); } } $out['jsonld_valid'] = $valid; $out['jsonld_invalid'] = count($ld[1]) - $valid; $out['jsonld_types'] = array_values(array_unique($types)); // Images preg_match_all('#]*>#i', $b, $imgs); $out['img_count'] = count($imgs[0]); $noAlt = 0; $noDim = 0; $lazy = 0; foreach ($imgs[0] as $tag) { if (!preg_match('#\balt\s*=\s*["\'][^"\']+["\']#i', $tag)) $noAlt++; if (!preg_match('#\bwidth\s*=#i', $tag) || !preg_match('#\bheight\s*=#i', $tag)) $noDim++; if (preg_match('#\bloading\s*=\s*["\']lazy#i', $tag)) $lazy++; } $out['img_no_alt'] = $noAlt; $out['img_no_dimensions'] = $noDim; $out['img_lazy'] = $lazy; // Render-blocking resources in $head = ''; if (preg_match('##is', $b, $m)) $head = $m[0]; $out['head_scripts_blocking'] = preg_match_all('#]*\b(async|defer|type=["\']application/ld)\b)[^>]*\bsrc=#i', $head); $out['head_stylesheets'] = preg_match_all('#]+rel=["\']stylesheet["\']#i', $head); // Platform fingerprint $out['wordpress'] = (bool)(preg_match('#/wp-content/|/wp-includes/#i', $b)); return $out; } /* ------------------------------------------------------------------ */ if (in_array('--report', $argv, true)) { $data = json_decode(file_get_contents(OUT), true); } else { $data = ['generated' => date('c'), 'sites' => []]; foreach ($SITES as $sector => $urls) { foreach ($urls as $u) { fwrite(STDERR, " fetching $u ... "); $r = fetchOne($u); if (!$r['ok'] || $r['code'] >= 400 || $r['size'] === 0) { fwrite(STDERR, "SKIP (" . ($r['error'] ?? 'HTTP ' . $r['code']) . ")\n"); continue; } $a = analyse($r); $a['sector'] = $sector; $data['sites'][] = $a; fwrite(STDERR, "ok (HTTP {$a['code']}, {$a['ttfb_ms']}ms)\n"); } } file_put_contents(OUT, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); fwrite(STDERR, "\nwrote " . OUT . "\n\n"); } /* ---------------- summary ---------------- */ $s = $data['sites']; $n = count($s); if (!$n) { echo "no sites captured\n"; exit(1); } function pct(int $k, int $n): string { return sprintf('%d/%d (%d%%)', $k, $n, round($k / $n * 100)); } function count_where(array $s, callable $f): int { return count(array_filter($s, $f)); } echo "NEPALI WEBSITE TECHNICAL AUDIT\n"; echo "sample: $n live sites, " . count(array_unique(array_column($s, 'sector'))) . " sectors\n"; echo "run: " . $data['generated'] . "\n"; echo str_repeat('=', 62) . "\n\n"; $checks = [ 'serves over HTTPS' => fn($x) => $x['https'], 'has a mobile viewport tag' => fn($x) => $x['viewport'], 'compresses HTML' => fn($x) => $x['compressed'], 'has a ' => fn($x) => $x['title_len'] > 0, 'title within 10-60 chars' => fn($x) => $x['title_len'] >= 10 && $x['title_len'] <= 60, 'has a meta description' => fn($x) => $x['desc_len'] > 0, 'description within 50-160 chars' => fn($x) => $x['desc_len'] >= 50 && $x['desc_len'] <= 160, 'has a canonical tag' => fn($x) => $x['canonical'], 'has exactly one <h1>' => fn($x) => $x['h1_count'] === 1, 'has NO <h1> at all' => fn($x) => $x['h1_count'] === 0, 'has an <html lang> attribute' => fn($x) => !empty($x['lang']), 'has Open Graph title' => fn($x) => $x['og'], 'has an og:image' => fn($x) => $x['og_image'], 'has any JSON-LD' => fn($x) => $x['jsonld_blocks'] > 0, 'has BROKEN JSON-LD' => fn($x) => $x['jsonld_invalid'] > 0, 'every image has alt text' => fn($x) => $x['img_count'] > 0 && $x['img_no_alt'] === 0, 'every image has width+height' => fn($x) => $x['img_count'] > 0 && $x['img_no_dimensions'] === 0, 'runs WordPress' => fn($x) => $x['wordpress'], ]; foreach ($checks as $label => $fn) { printf(" %-34s %s\n", $label, pct(count_where($s, $fn), $n)); } /* distributions */ $ttfb = array_column($s, 'ttfb_ms'); sort($ttfb); $bytes = array_column($s, 'html_bytes'); sort($bytes); $blocking = array_column($s, 'head_scripts_blocking'); sort($blocking); function median(array $a) { $c = count($a); return $c % 2 ? $a[intdiv($c,2)] : ($a[$c/2-1]+$a[$c/2])/2; } echo "\n"; printf(" median TTFB %d ms (range %d-%d)\n", median($ttfb), $ttfb[0], end($ttfb)); printf(" median HTML transferred %.0f KB (range %.0f-%.0f)\n", median($bytes)/1024, $bytes[0]/1024, end($bytes)/1024); printf(" median render-blocking <head> JS %d (worst %d)\n", median($blocking), end($blocking)); $noAlt = array_sum(array_column($s, 'img_no_alt')); $imgs = array_sum(array_column($s, 'img_count')); printf(" images missing alt text %d of %d (%d%%)\n", $noAlt, $imgs, $imgs ? round($noAlt/$imgs*100) : 0); $noDim = array_sum(array_column($s, 'img_no_dimensions')); printf(" images missing width/height %d of %d (%d%%)\n", $noDim, $imgs, $imgs ? round($noDim/$imgs*100) : 0); /* language handling */ echo "\n <html lang> values in use:\n"; $langs = array_count_values(array_filter(array_column($s, 'lang'))); arsort($langs); foreach ($langs as $l => $c) printf(" %-12s %d\n", $l, $c); /* schema types */ echo "\n schema types found across the sample:\n"; $all = []; foreach ($s as $x) foreach ($x['jsonld_types'] as $t) $all[$t] = ($all[$t] ?? 0) + 1; arsort($all); $i = 0; foreach ($all as $t => $c) { printf(" %-22s %d\n", $t, $c); if (++$i >= 12) break; } /* per-sector */ echo "\n by sector (one h1 / canonical / any schema):\n"; $sectors = []; foreach ($s as $x) $sectors[$x['sector']][] = $x; foreach ($sectors as $name => $rows) { printf(" %-26s n=%-3d h1:%-6s canon:%-6s schema:%s\n", $name, count($rows), count_where($rows, fn($x) => $x['h1_count'] === 1) . '/' . count($rows), count_where($rows, fn($x) => $x['canonical']) . '/' . count($rows), count_where($rows, fn($x) => $x['jsonld_blocks'] > 0) . '/' . count($rows)); } echo "\n";