# -*- coding: utf-8 -*- """Re-derive every headline figure from the CSVs and fail if the paper disagrees. python integrity.py The recurring failure in this programme is not arithmetic. It is a number that was correct when it was written, and stayed in the prose after the data changed under it. Six earlier instruments in this programme produced confident falsehoods and not one was caught by a checker that trusted the same source the prose did. So this recomputes each claim from `crawlers_verified.csv`, `verification.csv`, `referrals.csv`, `traffic.csv` and `format.md`, and then looks for the resulting figure in the assembled paper. A figure the paper does not contain is a figure the paper has stopped supporting. It also enforces the privacy rule that the whole method rests on: no published file may contain an IP address. """ import collections import csv import io import os import re import sys HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.normpath(os.path.join(HERE, '..')) PAPER = os.path.join(ROOT, 'draft', 'paper.md') sys.path.insert(0, HERE) import sitecfg # noqa: E402 SITE = 'broikos.gr' FAILED = [] def check(ok, msg): print(' %s %s' % ('ok ' if ok else 'FAIL', msg)) if not ok: FAILED.append(msg) def read(name, site=None): path = os.path.join(HERE, 'sites', site or SITE, name) if not os.path.exists(path): return [] with io.open(path, encoding='utf-8') as f: return list(csv.DictReader(f)) def says(text, *forms): """Is any spelling of this figure in the paper. Both spellings of every number, always. The prose writes 5,700 and the generated table writes 5700, and a checker that knows only one of those reports a correct paper as broken until its author stops reading it. """ for f in forms: f = str(f) if f in text or f.replace(',', '') in text: return True return False def group(n): return '{:,}'.format(n) def main(): if not os.path.exists(PAPER): print('assemble the paper first') return 1 text = io.open(PAPER, encoding='utf-8').read() ver = read('verification.csv') vday = read('crawlers_verified.csv') refer = read('referrals.csv') traffic = read('traffic.csv') fmt = io.open(os.path.join(HERE, 'sites', SITE, 'format.md'), encoding='utf-8').read() print('1 THE IDENTITY CHECK') tot = sum(int(r['requests']) for r in ver) ok = sum(int(r['verified_requests']) for r in ver) check(says(text, group(ok)), '%s verified requests appear in the paper' % group(ok)) check(says(text, group(tot)), '%s claimed requests appear in the paper' % group(tot)) pct = 100.0 * ok / tot check(says(text, '%.1f per cent' % pct, '%.1f%%' % pct), 'the verified share, %.1f per cent, appears in the paper' % pct) check(says(text, str(tot - ok)), 'the %d excluded requests appear in the paper' % (tot - ok)) print() print('2 WHAT WAS TAKEN') pages = sum(int(r['pages']) for r in vday) byts = sum(int(r['bytes']) for r in vday) check(says(text, group(pages)), '%s pages taken appear in the paper' % group(pages)) check(says(text, '%.1f MB' % (byts / 1e6)), '%.1f MB served appears in the paper' % (byts / 1e6)) by = {r['operator']: r for r in ver} ant = sum(int(by[a]['verified_pages']) for a in ('ClaudeBot', 'Claude-User', 'Claude-SearchBot') if a in by) oai = sum(int(by[a]['verified_pages']) for a in ('GPTBot', 'OAI-SearchBot', 'ChatGPT-User') if a in by) check(says(text, group(ant)), 'Anthropic pages, %s, appear in the paper' % group(ant)) check(says(text, group(oai)), 'OpenAI pages, %s, appear in the paper' % group(oai)) check(ant > oai, 'Anthropic took more pages than OpenAI, as the paper says') print() print('3 WHAT CAME BACK') readers = sum(int(r.get('browser') or 0) for r in refer) hits = sum(int(r['requests']) for r in refer) check(readers == 1, 'exactly one arrival was a reader in a browser') check(hits == 2, 'exactly two requests carried an answer engine referrer') ext = collections.Counter() for r in traffic: if r['kind'] != 'browser': continue h = r['referrer'] if h.startswith('(none') or 'broikos.gr' in h: continue ext[h] += int(r['page_views']) yt = ext.get('youtube.com', 0) check(says(text, group(yt)), 'YouTube page views, %s, appear in the paper' % group(yt)) print() print('4 THE RATIO') front = {'OpenAI': ('GPTBot', 'OAI-SearchBot', 'ChatGPT-User'), 'Anthropic': ('ClaudeBot', 'Claude-User', 'Claude-SearchBot'), 'Perplexity': ('PerplexityBot', 'Perplexity-User'), 'Google AI': ('Google-Extended',), 'Microsoft': ('Bingbot',), 'You.com': ('YouBot',)} taken = sum(int(by[a]['verified_pages']) for agents in front.values() for a in agents if a in by) check(says(text, group(taken)), 'pages taken by operators with a front door, %s, appear in the paper' % group(taken)) if readers: ratio = int(round(float(taken) / readers)) check(says(text, group(ratio)), 'the headline ratio, %s to 1, appears' % group(ratio)) print() print('5 THE TREND') per = collections.defaultdict(lambda: {'pages': 0, 'req': 0, 'days': set()}) for r in vday: m = per[r['day'][3:]] m['pages'] += int(r['pages']) m['req'] += int(r['requests']) m['days'].add(r['day']) a, z = per.get('Jul/2025'), per.get('Aug/2026') if a and z: pa = float(a['pages']) / len(a['days']) pz = float(z['pages']) / len(z['days']) check(says(text, '%.1f' % (pz / pa)), 'the growth factor in pages per day, %.1f, appears' % (pz / pa)) check(says(text, str(int(round(pa)))) and says(text, str(int(round(pz)))), 'both per-day figures, %d and %d, appear' % (round(pa), round(pz))) print() print('6 THE SIZE OF THE SITE') m = re.search(r'distinct page paths the site served to anybody \| (\d+)', fmt) if m: check(says(text, m.group(1)), 'the site size, %s distinct page paths, appears' % m.group(1)) print() print('7 THE SECOND SITE') p2 = read('verification.csv', 'practice-a') if p2: by2 = {r['operator']: r for r in p2} front2 = sum(int(by2[x]['verified_pages']) for agents in front.values() for x in agents if x in by2) r2 = read('referrals.csv', 'practice-a') readers2 = sum(int(x.get('browser') or 0) for x in r2) check(says(text, group(front2)), "the second site's %s pages appear in the paper" % group(front2)) check(says(text, str(readers2)), "the second site's %d readers appear in the paper" % readers2) if readers2: ratio2 = int(round(float(front2) / readers2)) check(says(text, group(ratio2)), "the second site's ratio, %s to 1, appears" % group(ratio2)) print() print('8 NO ADDRESS AND NO ANONYMISED HOST IN ANY PUBLISHED FILE') ip = re.compile(r'\b(?:\d{1,3}\.){3}\d{1,3}\b') # Every host this study promised not to name, and the bare label with it: a # cPanel preview URL and a staging subdomain both carry the name inside # somebody else's domain, and both got past the first version of the mask. secret = set() for host, al in (sitecfg._map() or {}).items(): if not sitecfg.describe(al).get('named', True): secret.add(host.lower()) secret.add(host.split('.')[0].lower()) targets = [] for al in sitecfg.aliases(): d = os.path.join(HERE, 'sites', al) if os.path.isdir(d): targets += [os.path.join(d, f) for f in sorted(os.listdir(d))] tdir = os.path.join(ROOT, 'draft', 'tables') if os.path.isdir(tdir): targets += [os.path.join(tdir, f) for f in sorted(os.listdir(tdir))] targets.append(PAPER) bad_ip, bad_host = [], [] for path in targets: body = io.open(path, encoding='utf-8', errors='replace').read() if ip.search(body): bad_ip.append(os.path.basename(path)) low = body.lower() if secret and any(w in low for w in secret): bad_host.append(os.path.basename(path)) check(not bad_ip, 'no address in %d published file(s)%s' % (len(targets), '' if not bad_ip else ': ' + ', '.join(bad_ip))) check(not bad_host, 'no anonymised hostname in %d published file(s)%s' % (len(targets), '' if not bad_host else ': ' + ', '.join(bad_host))) print() if FAILED: print('%d FAILURE(S)' % len(FAILED)) return 1 print('ALL CHECKS PASS') return 0 if __name__ == '__main__': raise SystemExit(main())