#!/usr/bin/env python3
"""Re-run the check behind reading-thai-vegetarian-labels.

Method: public-web. A fixed sample of twenty-one publicly posted Thai delivery menus
(Wongnai x LINE MAN), fetched with curl, parsed for distinct dish names and for
any published ingredient text. Counts per menu: dishes, dishes carrying the
label เจ, dishes carrying มังสวิรัติ, dishes with any ingredient text, dishes
whose name contains one of the ten animal words the post prints, and where in
the name that word sits.

    python3 check.py            # fetch every URL, re-measure, compare to dataset.json
    python3 check.py --recheck  # same; no lab step here, so it is the same fetch
    python3 check.py --emit     # rebuild dataset.json/dataset.csv from extract.json
                                # (no network; the extraction recorded on 2026-10-26)
    python3 check.py --refresh  # fetch every menu, write extract.json, then --emit

Exits 0 when every page still answers, every recorded quote still appears and
every recorded number recomputes to the same value. Exits 1 otherwise.

Standard library only.
"""

import argparse
import csv
import html as htmlmod
import json
import os
import re
import statistics
import subprocess
import sys
import time

BASE = os.path.dirname(os.path.abspath(__file__))
DATASET_JSON = os.path.join(BASE, 'dataset.json')
DATASET_CSV = os.path.join(BASE, 'dataset.csv')
EXTRACT_JSON = os.path.join(BASE, 'extract.json')

UA = ('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 '
      '(KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36')

SAMPLE_FRAME_URL = 'https://www.wongnai.com/delivery'

MENUS = [
    ('2365760mP', 'Hotpot man ประชาชื่น'),
    ('501119Zh', 'เล็กใหญ่ ก๋วยเตี๋ยวต้มยำโบราณ หน้ามอ หอการค้าไทย'),
    ('559929nJ', 'KFC สรงประภา'),
    ('1115467AD', 'เล็กใหญ่ ก๋วยเตี๋ยวต้มยำโบราณ บรรทัดทอง'),
    ('330155it', 'โจ๊กสามย่าน บรรทัดทอง'),
    ('2806824Fb', 'Hotpotman (ฮอตพอตแมน) ประเวศ'),
    ('29714wl', "McDonald's เอ็ม พาร์ค รังสิต คลอง 3 (ไดร์ฟทรู)"),
    ('2103774Kx', 'อี้จาสุกี้หม่าล่า สะพานควาย'),
    ('1486Lk', 'รุ่งเรือง (ตั๋ง) ก๋วยเตี๋ยวหมู สุขุมวิท26 เจ้าเก่า สุขุมวิท 26'),
    ('23520xc', 'ข้าวมันไก่ป้าจ๋า 23 ชั่วโมง นครปฐม'),
    ('581236az', 'KFC มหิดลศาลายา (DT)'),
    ('388880rC', 'ตำยำยั่ว by โบตั๋น หอการค้า'),
    ('2922995tc', "McDonald's เซ็นทรัลบางรัก (โรบินสัน)"),
    ('351470wz', 'ก๋วยเตี๋ยวไก่กล้วยน้ำไท-เจ้าเก่า/ข้าวขาหมูเฮียชัย พระโขนง'),
    ('647591Fk', 'Café Amazon'),
    ('2656372Xg', 'เล็กใหญ่ ก๋วยเตี๋ยวต้มยำโบราณ เกษตร'),
    ('2399187fR', 'อี้จาสุกี้หม่าล่า พระโขนง'),
    ('535674Cf', 'เจ๊ดาอาหารตามสั่ง แอร์พอตลิ้งราชปรารถ -ราชปรารถ'),
    ('2342912fa', 'อี้จาสุกี้หม่าล่า รัชโยธิน'),
    ('2399193rM', 'อี้จาสุกี้หม่าล่า ศรีนครินทร์-ปากน้ำสมุทรปราการ'),
    ('2513860MW', 'บุญลักษณ์ ร้านอาหารเจ-มังสวิรัติ ซอยรามอินทรา 8'),
]

# The twenty ids above that come from the listing page, plus the one เจ
# restaurant (last) found by searching the same site, which is the only menu in
# the sample that uses the label at all. The trailing slash is the form this
# host answers without a Cloudflare challenge; the same menus were first read
# at /order and both forms returned identical dish lists on 2026-10-26.
OFF_FRAME = ['2513860MW']

BLOCKED = []

# The ten words the post prints, and the animal words it does not print that
# were counted to test whether the list is closed.
TEN = ['หมู', 'ไก่', 'เนื้อ', 'ปลา', 'กุ้ง', 'ปู', 'หอย', 'ไข่', 'นม', 'กะปิ']
EXTRA = ['เป็ด', 'หมึก', 'เลือด', 'น้ำมันหอย']

THAI = r'[฀-๿]'

THIRD_PARTY = [
    {
        'party': 'Anki',
        'statement': ('Anki, which describes itself as a flashcard program, can drill the two '
                      'words once you add them as cards'),
        'url': 'https://apps.ankiweb.net/',
        'quote': ('Anki is a flashcard program that helps you spend more time on '
                  'challenging material, and less on what you already know.'),
    },
    {
        'party': 'Ling',
        'statement': 'Ling advertises real conversation practice',
        'url': 'https://ling-app.com/',
        'quote': 'Real conversation practice',
    },
]


# ---------------------------------------------------------------- text helpers

def norm(text):
    """Whitespace, quotes and HTML entities collapsed, for quote comparison."""
    text = htmlmod.unescape(text or '')
    text = text.replace('‘', "'").replace('’', "'")
    text = text.replace('“', '"').replace('”', '"')
    text = text.replace(' ', ' ')
    return re.sub(r'\s+', ' ', text).strip()


def strip_tags(text):
    text = re.sub(r'<(script|style)\b.*?</\1>', ' ', text, flags=re.S | re.I)
    return norm(re.sub(r'<[^>]+>', ' ', text))


# ---------------------------------------------------------------- page parsing

ITEM_RE = re.compile(
    r'<div class="[^"]*Title-sc-1d7udcg-\d+[^"]*"><span>(.*?)</span></div>'
    r'(?:<div class="[^"]*Description-sc-1d7udcg-\d+[^"]*">(.*?)</div>)?',
    re.S)
TITLE_RE = re.compile(r'<title[^>]*>(.*?)</title>', re.S)


def parse_menu(page):
    """Title plus distinct dishes, in first-seen order, one description each.

    The page repeats every dish in a second copy of the list; deduplicating by
    name gives the number of distinct dish names the menu publishes.
    """
    title = ''
    m = TITLE_RE.search(page)
    if m:
        title = norm(re.sub(r'<[^>]+>', ' ', m.group(1)))

    dishes = {}
    for m in ITEM_RE.finditer(page):
        name = norm(re.sub(r'<[^>]+>', ' ', m.group(1)))
        if not name:
            continue
        desc = norm(re.sub(r'<[^>]+>', ' ', m.group(2) or ''))
        if name not in dishes:
            dishes[name] = desc
        elif not dishes[name] and desc:
            dishes[name] = desc
    return {'title': title, 'dishes': [{'name': k, 'desc': v} for k, v in dishes.items()]}


def occurrences(name, word):
    """Positions of `word` in `name`.

    Thai is written without spaces, so a substring can straddle a syllable
    boundary. Only นม needs a guard: it otherwise matches inside ขนม (a snack),
    อิตาเลียนมาโยดิป and เส้นมัน. Every other word was inspected by hand and
    matches cleanly.
    """
    out = []
    for m in re.finditer(re.escape(word), name):
        i = m.start()
        if word == 'นม' and i > 0 and re.match(THAI, name[i - 1]):
            continue
        out.append(i)
    return out


def has_label_jae(name):
    """เจ as a standalone token: not glued to another Thai word.

    Excludes ไข่เจียว (omelette), น้ำกระเจี๊ยบ and หมาเจียง; includes
    'ข้าวซอยเห็ดตุ๋น เจ' and 'ผัดไทย เจ'.
    """
    return re.search(r'(?<!' + THAI + r')เจ(?!' + THAI + r')', name) is not None


def menu_metrics(dishes):
    jae = 0
    jae_substr = 0
    mangs = 0
    with_desc = 0
    animal = 0
    animal_at0 = 0
    animal_within3 = 0
    first_positions = []
    for d in dishes:
        n = d['name']
        if has_label_jae(n):
            jae += 1
        if 'เจ' in n:
            jae_substr += 1
        if 'มังสวิรัติ' in n:
            mangs += 1
        if d['desc']:
            with_desc += 1
        idx = [i for w in TEN for i in occurrences(n, w)]
        if idx:
            animal += 1
            first = min(idx)
            first_positions.append(first)
            animal_at0 += 1 if first == 0 else 0
            animal_within3 += 1 if first < 3 else 0
    return {
        'dishes': len(dishes),
        'jae': jae,
        'jae_substr': jae_substr,
        'mangs': mangs,
        'with_desc': with_desc,
        'animal': animal,
        'animal_at0': animal_at0,
        'animal_within3': animal_within3,
        'first_positions': first_positions,
    }


def measure(menus_pages):
    """menus_pages: list of {'id','url','page'} -> per-menu rows and aggregate."""
    per_menu = []
    word_counts = dict((w, 0) for w in TEN)
    extra_counts = dict((w, 0) for w in EXTRA)
    desc_counts = {'น้ำปลา': 0, 'กะปิ': 0, 'น้ำมันหอย': 0, 'ปลาร้า': 0}
    all_positions = []
    all_dishes = []
    for mp in menus_pages:
        parsed = mp['parsed']
        m = menu_metrics(parsed['dishes'])
        row = {
            'id': mp['id'],
            'subject': mp['shop'],
            'shop': mp['shop'],
            'url': mp['url'],
            'title_quote': parsed['title'],
            'dishes': m['dishes'],
            'jae': m['jae'],
            'mangs': m['mangs'],
            'with_desc': m['with_desc'],
            'animal': m['animal'],
            'animal_at0': m['animal_at0'],
            'animal_within3': m['animal_within3'],
        }
        per_menu.append(row)
        all_positions.extend(m['first_positions'])
        for d in parsed['dishes']:
            all_dishes.append(d)
            for w in TEN:
                if occurrences(d['name'], w):
                    word_counts[w] += 1
            for w in EXTRA:
                if w in d['name']:
                    extra_counts[w] += 1
            for w in desc_counts:
                if w == 'น้ำปลา':
                    # 'น้ำปลา' sits inside 'น้ำปลาร้า', which is fermented fish, not fish sauce.
                    if 'น้ำปลา' in d['desc'].replace('น้ำปลาร้า', ''):
                        desc_counts[w] += 1
                elif w in d['desc']:
                    desc_counts[w] += 1
    n = sum(r['dishes'] for r in per_menu)
    plara_by_menu = {}
    for mp in menus_pages:
        hits = sum(1 for d in mp['parsed']['dishes'] if 'ปลาร้า' in d['desc'])
        if hits:
            plara_by_menu[mp['shop']] = hits
    jae_desc = dict((w, 0) for w in desc_counts)
    for d in all_dishes:
        if not (has_label_jae(d['name']) and d['desc']):
            continue
        for w in jae_desc:
            if w == 'น้ำปลา':
                if 'น้ำปลา' in d['desc'].replace('น้ำปลาร้า', ''):
                    jae_desc[w] += 1
            elif w in d['desc']:
                jae_desc[w] += 1
    aggregate = {
        'menus': len(per_menu),
        'dishes': n,
        'jae': sum(r['jae'] for r in per_menu),
        'mangs': sum(r['mangs'] for r in per_menu),
        'with_desc': sum(r['with_desc'] for r in per_menu),
        'animal': sum(r['animal'] for r in per_menu),
        'animal_at0': sum(r['animal_at0'] for r in per_menu),
        'animal_within3': sum(r['animal_within3'] for r in per_menu),
        'median_first_animal_index': statistics.median(all_positions) if all_positions else None,
        'word_counts': word_counts,
        'extra_counts': extra_counts,
        'extra_total': sum(1 for d in all_dishes if any(w in d['name'] for w in EXTRA)),
        'desc_counts': desc_counts,
        'jae_desc_counts': jae_desc,
        'desc_total': sum(1 for d in all_dishes if d['desc']),
        'jae_with_desc': sum(1 for d in all_dishes
                             if has_label_jae(d['name']) and d['desc']),
        # เจ cooking names its imitation meats after the animal (ไก่ผัดขิง เจ).
        'jae_with_animal_word': sum(1 for d in all_dishes
                                    if has_label_jae(d['name'])
                                    and any(occurrences(d['name'], w) for w in TEN)),
        'plara_desc_by_menu': plara_by_menu,
    }
    return per_menu, aggregate


# ------------------------------------------------------------------- fetching

RETRY_WAIT = 45
RETRIES = 2


def fetch(url):
    """Page text, or raise RuntimeError with the reason. 429 is Cloudflare's
    rate limiter on this host: wait once, twice, then give up loudly."""
    last = ''
    for attempt in range(RETRIES + 1):
        proc = subprocess.run(
            ['curl', '-sL', '--compressed', '-A', UA, '--max-time', '40',
             '-w', '\n%{http_code}', url],
            capture_output=True, text=True, encoding='utf-8', errors='replace')
        body = proc.stdout or ''
        code = body.rsplit('\n', 1)[-1].strip() if '\n' in body else ''
        page = body.rsplit('\n', 1)[0] if '\n' in body else body
        if code == '200' and len(page) > 20000:
            return page
        last = 'HTTP %s, %d bytes' % (code or '?', len(page))
        if code == '429' and attempt < RETRIES:
            time.sleep(RETRY_WAIT)
            continue
        if attempt >= RETRIES:
            break
        time.sleep(5)
    raise RuntimeError(last)


def fetch_all():
    out = []
    for bid, shop in MENUS:
        url = 'https://www.wongnai.com/delivery/businesses/%s/order/' % bid
        page = fetch(url)
        out.append({'id': bid, 'shop': shop, 'url': url, 'page': page})
        time.sleep(6)
    return out


# ------------------------------------------------------------------- commands

# The machine the 2026-10-26 reading ran on, for an extract.json that predates
# the tools field.
TOOLS_2026_10_26 = [
    {'name': 'curl', 'version': 'curl 8.7.1 (x86_64-apple-darwin26.0) libcurl/8.7.1'},
    {'name': 'python3', 'version': '3.9.6'},
    {'name': 'macOS', 'version': '27.0.1'},
]


def tools_now():
    """What this fetch actually ran on, recorded with the extraction."""
    import platform
    curl = subprocess.run(['curl', '--version'], capture_output=True, text=True).stdout
    return [
        {'name': 'curl', 'version': ' '.join(curl.splitlines()[0].split()[:3]) if curl else '?'},
        {'name': 'python3', 'version': platform.python_version()},
        {'name': platform.system(), 'version': platform.release()},
    ]


def refresh():
    """Fetch every menu now, save the extraction, rebuild the dataset from it."""
    extract = {'fetchedAt': time.strftime('%Y-%m-%d'), 'source': SAMPLE_FRAME_URL,
               'tools': tools_now(), 'menus': []}
    for item in fetch_all():
        parsed = parse_menu(item['page'])
        extract['menus'].append({'id': item['id'], 'shop': item['shop'], 'url': item['url'],
                                 'title': parsed['title'], 'dishes': parsed['dishes']})
    with open(EXTRACT_JSON, 'w', encoding='utf-8') as fh:
        json.dump(extract, fh, ensure_ascii=False, indent=1)
        fh.write('\n')
    return emit()


def emit():
    if not os.path.exists(EXTRACT_JSON):
        sys.stderr.write('no extract.json to emit from\n')
        return 1
    with open(EXTRACT_JSON, encoding='utf-8') as fh:
        extract = json.load(fh)
    pages = []
    for item in extract['menus']:
        pages.append({'id': item['id'], 'shop': item['shop'], 'url': item['url'],
                      'parsed': {'title': item['title'], 'dishes': item['dishes']}})
    per_menu, aggregate = measure(pages)
    dataset = build_dataset(per_menu, aggregate, extract)
    with open(DATASET_JSON, 'w', encoding='utf-8') as fh:
        json.dump(dataset, fh, ensure_ascii=False, indent=1)
        fh.write('\n')
    write_csv(per_menu, aggregate)
    print('wrote %s and %s' % (DATASET_JSON, DATASET_CSV))
    return 0


def write_csv(per_menu, aggregate):
    cols = ['subject', 'url', 'title_quote', 'dishes', 'jae', 'mangs',
            'with_desc', 'animal', 'animal_at0', 'animal_within3']
    with open(DATASET_CSV, 'w', encoding='utf-8', newline='') as fh:
        w = csv.DictWriter(fh, fieldnames=cols, extrasaction='ignore')
        w.writeheader()
        for row in per_menu:
            w.writerow(row)
        w.writerow({
            'subject': 'TOTAL (%d menus)' % aggregate['menus'],
            'url': SAMPLE_FRAME_URL,
            'title_quote': '',
            'dishes': aggregate['dishes'],
            'jae': aggregate['jae'],
            'mangs': aggregate['mangs'],
            'with_desc': aggregate['with_desc'],
            'animal': aggregate['animal'],
            'animal_at0': aggregate['animal_at0'],
            'animal_within3': aggregate['animal_within3'],
        })


def build_dataset(per_menu, aggregate, extract):
    rows = []
    for row in per_menu:
        rows.append({
            'subject': row['subject'],
            'property': 'dish counts',
            'value': {k: row[k] for k in ('dishes', 'jae', 'mangs', 'with_desc',
                                          'animal', 'animal_at0', 'animal_within3')},
            'quote': row['title_quote'],
            'url': row['url'],
        })
    for party in THIRD_PARTY:
        rows.append({
            'subject': party['party'],
            'property': 'sentence the post rests on',
            'value': party['statement'],
            'quote': party['quote'],
            'url': party['url'],
        })
    dataset = {
        'slug': 'reading-thai-vegetarian-labels',
        'method': 'public-web',
        'checkedAt': extract.get('fetchedAt', '2026-10-26'),
        'tools': extract.get('tools', TOOLS_2026_10_26),
        'inputs': {
            'sampleFrame': ('the twenty business pages listed on %s on 2026-10-26 in page '
                            'order, plus one เจ restaurant found by searching the same site '
                            '(id %s), which is the only menu in the sample that uses the label'
                            % (SAMPLE_FRAME_URL, ', '.join(OFF_FRAME))),
            'sampleFrameUrl': SAMPLE_FRAME_URL,
            'menus': [{'id': mp['id'], 'shop': mp['shop'], 'url': mp['url'],
                       'titleQuote': item_title(mp['id'], extract)}
                      for mp in per_menu],
            'notFetched': [{'id': bid,
                            'url': 'https://www.wongnai.com/delivery/businesses/%s/order/' % bid,
                            'reason': 'Cloudflare challenge, HTTP 429 on 2026-10-26'}
                           for bid in BLOCKED],
        },
        'rules': {
            'dishUnit': ('distinct dish names per menu; the page prints every dish twice '
                         'and the second copy is dropped'),
            'jae': ("'เจ' bounded by non-Thai characters on both sides (a standalone token)"),
            'mangs': "'มังสวิรัติ' anywhere in the name",
            'animalWords': TEN,
            'animalWordsOutside': EXTRA,
            'animalMatch': ('substring; นม is skipped when a Thai character precedes it, '
                            'because it otherwise matches inside ขนม, อิตาเลียนมาโยดิป and เส้นมัน'),
            'position': ('index of the earliest matching animal word in the dish name, '
                         '0 = first character'),
            'ingredientText': 'the description published under a dish, when there is one',
            'ingredientWords': ("น้ำปลา, ปลาร้า, กะปิ, น้ำมันหอย anywhere in that text; น้ำปลา is "
                                "not counted inside น้ำปลาร้า"),
        },
        'results': {
            'perMenu': per_menu,
            'aggregate': aggregate,
        },
        'rows': rows,
        'notes': [
            'The sample is %d menus, not a random draw: twenty of them are the businesses the '
            'sample page listed on 2026-10-26 in page order, and the twenty-first is a เจ '
            'restaurant added on purpose, because without it the sample contains no vegetarian '
            'label at all. Chains dominate it: the two McDonald\'s menus are %d of the %d dish '
            'names.'
            % (aggregate['menus'],
               sum(r['dishes'] for r in per_menu
                   if r['subject'].startswith("McDonald's")),
               aggregate['dishes']),
            'No dish name in the sample carries the label มังสวิรัติ, so the question the post '
            'asks (does a มังสวิรัติ dish still list fish sauce) cannot be answered from dish '
            'names here.',
            'Ingredient text exists for %d of the %d dishes; in it น้ำปลา appears %d times, '
            'กะปิ %d, น้ำมันหอย %d.'
            % (aggregate['desc_total'], aggregate['dishes'],
               aggregate['desc_counts']['น้ำปลา'], aggregate['desc_counts']['กะปิ'],
               aggregate['desc_counts']['น้ำมันหอย']),
            'Live menus change during the day: a first reading of the same 21 pages around 13:00 '
            'on 2026-10-26 gave 1,812 dish names; one menu (535674Cf) added five dishes before '
            'the 2026-10-26 reading the post first used, which gave 1,817. A later failure of '
            'this check on dish totals is a menu change, not an error in the dataset; the label '
            'counts are the values the post rests on.',
            'The one เจ restaurant writes both words in its own page title (see its titleQuote) '
            'but uses only เจ on its dishes; %d of its %d เจ dish names also contain one of the '
            'ten animal words, because the dish names an imitation meat after the animal.'
            % (aggregate['jae_with_animal_word'], aggregate['jae']),
            'Every word the post prints is matched as a substring, including compounds '
            '(หมูสับ, น้ำพริกกะปิ), which is what a reader scanning the front of a name sees.',
            'The camera-translation paragraph names no product, so there is no third party to '
            'cite; it was not checked and would need a lab test of a named translator.',
            'The cultural claims (Chinese Buddhist origin, five pungent vegetables, festival '
            'flags) are outside this brief and were not checked; they need a primary source.',
            'This host answers /delivery/businesses/<id>/order with a Cloudflare challenge once '
            'it has served a burst, and serves the same menu at .../order/ without one. The '
            'recheck uses the trailing-slash form; both forms returned identical dish lists on '
            '2026-10-26. If both are refused, wait and run the check again.',
        ],
    }
    return dataset


def item_title(bid, extract):
    for item in extract['menus']:
        if item['id'] == bid:
            return item['title']
    return ''


def verify():
    failures = []
    try:
        with open(DATASET_JSON, encoding='utf-8') as fh:
            dataset = json.load(fh)
    except OSError as exc:
        print('cannot read dataset.json: %s' % exc)
        return 1

    recorded_per_menu = dataset['results']['perMenu']
    recorded_agg = dataset['results']['aggregate']
    by_url = dict((row['url'], row) for row in recorded_per_menu)

    pages = []
    consecutive_failures = 0
    for bid, shop in MENUS:
        url = 'https://www.wongnai.com/delivery/businesses/%s/order/' % bid
        if consecutive_failures >= 2:
            failures.append('%s: not attempted (host refusing, see the rows above)' % shop)
            continue
        try:
            page = fetch(url)
            consecutive_failures = 0
        except RuntimeError as exc:
            consecutive_failures += 1
            failures.append('%s: not re-read (%s)' % (shop, exc))
            continue
        parsed = parse_menu(page)
        pages.append({'id': bid, 'shop': shop, 'url': url, 'parsed': parsed})
        rec = by_url.get(url)
        if rec is None:
            failures.append('%s: no row in dataset.json' % shop)
            continue
        if norm(rec['title_quote']) not in norm(strip_tags(page)):
            failures.append('%s: title quote gone: %s' % (shop, rec['title_quote']))
        fresh = menu_metrics(parsed['dishes'])
        for key in ('dishes', 'jae', 'mangs', 'with_desc', 'animal',
                    'animal_at0', 'animal_within3'):
            if fresh[key] != rec[key]:
                failures.append('%s: %s recorded %s, now %s'
                                % (shop, key, rec[key], fresh[key]))
        print('%-16s ok  dishes=%-4d jae=%-3d mangs=%-3d animal=%-4d'
              % (bid, fresh['dishes'], fresh['jae'], fresh['mangs'], fresh['animal']))

    if len(pages) == len(MENUS):
        fresh_per_menu, fresh_agg = measure(pages)
        for key in ('menus', 'dishes', 'jae', 'mangs', 'with_desc', 'animal',
                    'animal_at0', 'animal_within3', 'median_first_animal_index',
                    'extra_total', 'desc_total', 'jae_with_desc',
                    'jae_with_animal_word', 'plara_desc_by_menu'):
            if fresh_agg[key] != recorded_agg[key]:
                failures.append('aggregate %s recorded %s, now %s'
                                % (key, recorded_agg[key], fresh_agg[key]))
        for group in ('word_counts', 'extra_counts', 'desc_counts', 'jae_desc_counts'):
            for word, value in recorded_agg[group].items():
                if fresh_agg[group].get(word) != value:
                    failures.append('aggregate %s.%s recorded %s, now %s'
                                    % (group, word, value, fresh_agg[group].get(word)))
        print('aggregate ok: %d dishes on %d menus' % (fresh_agg['dishes'], fresh_agg['menus']))
    else:
        failures.append('aggregate not re-checked: %d of %d menus re-read'
                        % (len(pages), len(MENUS)))

    for party in THIRD_PARTY:
        try:
            page = fetch(party['url'])
        except RuntimeError as exc:
            failures.append('%s: not re-read (%s)' % (party['party'], exc))
            continue
        if norm(party['quote']) in strip_tags(page):
            print('%-16s ok  quote present' % party['party'])
        else:
            failures.append('%s: quote gone: %s' % (party['party'], party['quote']))

    if failures:
        print('\nFAIL')
        for line in failures:
            print('  ' + line)
        return 1
    print('\nOK: %d menus, every quote and every number holds' % len(MENUS))
    return 0


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--recheck', action='store_true',
                    help='re-read the cited pages (no lab step to skip here)')
    ap.add_argument('--emit', action='store_true',
                    help='rebuild dataset.json/dataset.csv from extract.json')
    ap.add_argument('--refresh', action='store_true',
                    help='fetch every menu, write extract.json, then --emit')
    args = ap.parse_args()
    if args.refresh:
        return refresh()
    if args.emit:
        return emit()
    return verify()


if __name__ == '__main__':
    sys.exit(main())
