feat(9gag): add high-fidelity mirror - #92
Conversation
|
Taking ownership of the review for this PR (Track B). Plan: build and drive the mirror per the |
…iming-lab#93 aiming-lab#39 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AkNTAZMziDodR6uqa6QBtP
Review —
|
| Check | Result |
|---|---|
Syntax python3 -m py_compile app.py seed_data.py _health.py |
ok |
bash -n websyn_start.sh, git diff --check |
ok |
| Registration | websyn_start.sh SITES == control_server.py SITES (25 entries); 9gag index 24 → port 40024; Dockerfile EXPOSE 8101 40000-40024 |
tasks.jsonl (contributor rows) |
20 rows, exactly web_name,id,ques,web,upstream_url; ids 9GAG--0..19; web = http://localhost:40024/; no answer keys |
Cross-site imports grep -rn "from sites\.|import sites" sites/9gag |
none |
HF tarball validate_asset_archive.py ../tarball/9gag.tar.gz 9gag |
validated 73 managed members; extract ok |
| Seed DB | instance_seed/9gag.db md5 44768940829bf4a8bec07d7bdf072d97 (matches the author's report); 80 posts / 4 users / 4 comments / 12 votes / 16 saved / 0 hidden / 0 reports |
| Images | 67 files (38 post images + 27 brand/interest references + 1 rail ad); all 11 images on the homepage load (0 broken, naturalWidth > 0) |
| Boot | GET / 200; /_health → {"ok":true,"posts":80,"site":"9gag"} |
| Byte-identical seed | md5 equal after boot 1, after boot 2 (instance kept), after the full read-only Playwright drive, and after a fresh reset — see evidence |
| Idempotent seeding | seed_database() gated on Post.query.count(), seed_benchmark_users() gated on alice's row — confirmed by the md5 results above |
| Stateful drive control | md5 diverges only after the stateful drive (6eb5f5cf…) and returns to the seed after a fresh copy — i.e. writes really hit instance/ and a reset restores it |
| Docs / integration tests | FAIL on the PR head as submitted: README/AGENTS/CONTRIBUTING/CLAUDE/agent_demo README still say 24 sites / 40000-40023, the Dockerfile comment says "24 Flask mirror sites", and sites/walmart_careers/tests/test_integration.py + sites/rotten_tomatoes/tests/test_environment_quality.py assert the old range/list — fixed in reviewer commit d5ebfdd (same sweep PR #95 does) |
| Tarball hygiene | 73 AppleDouble ._* members (9gag/._instance_seed, 9gag/static/images/posts/._aO86yA2-…jpg, …) — 146 members total, half are junk; the validator/extractor skip them, but please repack (COPYFILE_DISABLE=1 tar …) — same nit raised on #53 |
.assets-revision |
unchanged (65c479f8…, does not contain 9gag.tar.gz); needs the merged HF sha after discussion #65 lands |
| Port | 40024 is also claimed by open PR #95 (webmd_doctor); whichever merges second needs re-indexing — maintainer call |
Docker (from review/pr-92-9gag, assets refs/pr/65) |
ok — docker build 4.72 GB; alt ports 44000-44024: 25/25 return 200; /health ok, 25/25 alive+ready, 9gag on internal 40024; POST /reset/9gag → md5 44768940… == seed; GET+POST /login then reset → still 44768940…; docker restart → still 44768940…; POST /reset-all ok, 25/25 ready in 1.65 s; verifier no-op matrix in the container (--container, DBs via docker cp): 20/20 FAIL, 0 errors; registry tests 4 passed; sites/9gag test suite 246 passed |
2. Visual fidelity — PASS with notes
Side-by-side at 1440px (shots/home_1440_fold.png vs shots/upstream_9gag_home_1440.png, the real site fetched from the server): the three-column shell, top bar (menu, wordmark, Shuffle/Get App links, search, Sign Up/Log In, blue Post button), the "New to 9GAG?" sign-up card, Home/Top/Trending/Fresh/News/Ask 9GAG sidebar, the topic chips row, the yellow "Want fewer political content? Block tags" notice, right-rail advertisement and the post-card layout (section · @author, title, media, points · comments, vote/comment/save row) all match the upstream page closely. White background and Arial typography match. Detail pages (shots/detail_12.png) render title, media, description, tag chips, stats, action row, comments and a "More from " rail.
Notes (not blocking, see section 6):
- Image/caption mismatch. Only the 10 captured posts show their own media; the 35 curated posts and their 35 "Community remix" clones rotate through 38 unrelated real-9GAG images (e.g. "Tiny lighthouse office with the best ocean view" shows a dash-cam self-driving clip, "Students launch a weather balloon" shows a construction site). A vision agent sees captions that contradict the picture, and the same image repeats every ~2 cards.
- Tablet width overflows. At 768px the document is 857px wide (
scrollWidth 857 > clientWidth 768,shots/home_768_full.png): the@media(max-width:1000px)grid is220px + minmax(0,640px)+ 34px gap + 40px padding. 1440px and 390px are clean (1440/1440, 390/390). - The feed tab strip always highlights "Home" (
templates/feed.htmlhardcodesclass="active"on the Home tab), so/top,/trending,/fresh,/newsshow the wrong active tab. - The sidebar Interests list omits
awesome(6 posts),art(4),comic(2),books(2),motorvehicles(2) — 16 posts reachable only via a card's section link.
3. Functional depth — PASS
Driven with Playwright (scripts/audit_functional.py, results in audit_functional.json); DB after-state checked with sqlite after each action.
| Flow | Result |
|---|---|
| Login | wrong password → flash "Incorrect email, username, or password."; email login → /home, header shows alice_j; username login (bob_c) works |
| Account | /account shows display name, @alice_j, bio, location, joined date |
| Register | duplicate email → "already registered"; valid → auto-login, header new_person, row in user; re-login works; server rejects a 5-char password (client minlength bypassed via POST: no row created) |
| Search | OR-scored, case-insensitive, multi-word ok (bridge sound rain → 20; LIGHTHOUSE OFFICE → 10); no stemming (lighthouse offices matches only on lighthouse); unknown term → "No posts found"; empty q returns the full feed |
| Browse | all feeds/interests/tags return 200; /interest/memes and 6/6 topic chips (spider man, usa, trump, elon musk, politics, latest news) and #animals are empty pages (see section 6) |
| Save / unsave | Save on detail → button "★ Saved", flash, saved_post row, persists on reload and on /saved; unsave from the /saved card → row gone, count back to 4 |
| Vote | upvote 8388→8389 (button selected); switch to downvote → 8388 points / 58 downvotes; undo → 8388 / 57; vote rows consistent |
| Comment | 1-char body rejected ("at least 2 characters"); real comment rendered with author + "just now", comment_count +1 |
| Report | /gag/<slug>/report form with Spam/Harassment/Misinformation/Graphic content; submit → flash + report row; empty reason rejected |
| Hide | detail Hide → redirect to /, hidden_post row, post gone from feeds and search, detail URL still 200 (by design) |
| Settings | display name / bio / location persist to user and render on /account; empty display name rejected |
| Submit | new post created (slug quiet-victories-deserve-confetti, section, tags, author, source_id local-81), redirect to its page, first on /fresh, listed on /account; 3-char title rejected |
| Auth gating | /saved, /settings, /submit, /notifications, /account, /gag/<slug>/report and POST /gag/<slug>/save redirect to /login?next=… when logged out |
| 404s | /u/nobody, /gag/does-not-exist → 404 |
4. Task quality — PASS (20/20 accepted, 1 rewording)
All facts are fictional, curated descriptions (a 4.2-metre lighthouse office, a 440-hertz bridge, a cat named Miso) — no knowledge-shortcut risk. Search/list cards show only section, author, title, points and comment count, so no task fact leaks onto a results page; every read task needs the detail page. Pitfall check: no count labels, no pre-sorted "answer first" lists except T8 (see below), search is not strict-AND.
Catalog caveat that shapes the whole audit: 35 of the 80 posts are verbatim "Community remix N: <title>" clones carrying the same description as the original, with different points/authors. They act as distractors for the tasks that say "original" (and as a trap for the T9 points comparison), but they are not near-misses for fact retrieval, and they inflate the feed to 80 rows from 45 unique posts. Depth per interest is thin: science 17, animals 13, wholesome 7, awesome 6, sports 5, gaming/humor/food/music/art 4, movies/comic/books/motorvehicles 2, wtf/politics/random/news 1, memes 0.
| id | solvable | leak | distractors (results / target position) | steps | verdict | note |
|---|---|---|---|---|---|---|
| 0 | yes | no | 8 lighthouse posts (4 originals + 4 clones) / #8 | 4 | accept | "title mentions the best ocean view" also matches the clone (same facts) — verifier accepts either detail |
| 1 | yes | no | 9 / #2 (shelter cat, foster kitten, rescue dog near-misses) | 4 | accept | |
| 2 | yes | no | 10 / #4 (solar lantern, solar mat, rainy tarp near-misses) | 4 | accept | |
| 3 | yes | no | science feed 17 / #13 (page 2); 3 other bridge posts | 5 | accept | needs pagination |
| 4 | yes | no | 40 / #7 (community hits every clone's tag) |
4 | accept | noisy but fine; 3 other library posts |
| 5 | yes | no | sports 5 / #3 | 3 | accept | thin section |
| 6 | yes | no | 2 / #1 (original + clone only) | 4 | accept | "original" disambiguates; distractor count below the ≥6 guideline |
| 7 | yes | no | gaming 4 / #3 (clone ranks #1 with more points) | 3 | accept | thin section |
| 8 | yes | no | 12 / #1 | 4 | accept | target is the first result; still needs the detail page |
| 9 | yes | by design (points on cards) | animals 13; clone cat has 8561 points vs originals 2506/3717/4236 | 5+ | accept | good trap: only originals count; fox → "Copper" |
| 10 | yes | n/a | search 10 / original #2, clone #1 | 6 | accept (reworded) | added "original" so the clone (id 47) is not a valid target — mirrors T11–T13/T18/T19 wording |
| 11 | yes | n/a | animals 13 / #9 | 6 | accept | carol has no vote on post 20 in the seed |
| 12 | yes | n/a | science page 2 | 7 | accept | exact comment text |
| 13 | yes | n/a | 6 / #1 | 6 | accept | hide redirects to / |
| 14 | yes | n/a | news 19 / #19 (page 2) | 8 | accept | needs pagination + report form |
| 15 | yes | n/a | — | 8 | accept | slug quiet-victories-deserve-confetti |
| 16 | yes | n/a | — | 7 | accept | |
| 17 | yes | n/a | — | 6 | accept | header shows the username after sign-up |
| 18 | yes | n/a | 4 saved cards | 5 | accept | post 11 is in alice's seeded saves |
| 19 | yes | no | 3 detail pages required (numbers only in descriptions) | 12 | accept | 120 W vs 440 Hz vs 27 km → bridge; david has not saved it |
Difficulty: ≥5 actions for 12 tasks; multi-page comparison in T9 and T19; T19 (three detail reads, unit-agnostic comparison, then a save) and T9 (clone trap) are the frontier-challenging ones. No task is solvable from the first card without a detail visit.
5. Grading contract (reviewer deliverable, on the Review: PR)
sites/9gag/verify/verify_lib.py (merriam API family + walmart hardening) and verify_0.py … verify_19.py; ground truth hardcoded in the verifiers only. Every task row now carries verifier_path and an English judge_rubric (rules and fact-checkpoints, no answers). Contract per task is summarised in sites/9gag/verify/README.md; in short:
- Package identity: task_id,
terminated/agent_done, non-empty answer, all URLshttp://loopback on the same port asstart_url, every referenced screenshot decodes as PNG. - Navigation gates:
/search?q=with a task-relevant whole token, or the named/interest/<x>//news//saved//settings//submit//registerpage, plus the exact/gag/<slug>detail page (T19: all three compared details; T14: the/reportform too), with login-before-action ordering for the stateful tasks and the typed account identity checked against the trajectory's input steps. - Answers: affirmative-only matchers (negated mentions do not count), standalone counts or number words, decimal-exact
4.2, clock-time variants for6 a.m.. - Snapshot contract: seven tables, pinned schema hash, frozen seed counts and benchmark identities, seed post catalog columns immutable; read-only tasks require all 7 tables row-identical; stateful tasks require the exact row delta (one added/removed/changed row with the expected values, counters bumped by exactly one on the right post, password hash verified for T17) and
no_collateral_writeselsewhere; stale-state preconditions (initial_state_requires_action). - LLM helpers exist for parity but only emit
[INFO]; every verdict is decided with--no_llm True.
6. Required fixes before approval
- Release mechanics —
.assets-revision: after HF discussion Add TED task verifiers (site by @shanjiaming, verifiers by reviewer) #65 merges, pin the merged commit sha (the current pin65c479f8…has no9gag.tar.gz). - Tarball hygiene —
9gag.tar.gz(HF Add TED task verifiers (site by @shanjiaming, verifiers by reviewer) #65): strip the 73 AppleDouble._*members (COPYFILE_DISABLE=1 tar czf …on macOS, ortar --exclude='._*'), then re-upload;tar tzf 9gag.tar.gz | grep -c '/\._'should print 0. - Responsive —
sites/9gag/static/css/site.cssline 2 (@media(max-width:1000px)): the 220px + 640px grid overflows 768px viewports by 89px; usegrid-template-columns:200px minmax(0,1fr)(or collapse the sidebar earlier). - Empty entry points —
sites/9gag/templates/_sidebar.htmllines 14–16 / 19,templates/base.htmltoplinks,templates/feed.htmltopic row + "Popular tags": "Memes" (sidebar interest, top-nav link and the Recents "Old Meme" chip), all six topic chips and#animalsland on "No posts found". Either seed posts formemes/wtf/politics/random(≥ ~10 each) or remove the dead chips/links.
Recommended (non-blocking) follow-ups:
5. templates/feed.html line 1: compute the active feed tab from active instead of hardcoding it on Home.
6. Add awesome, art, comic, books, motorvehicles to the sidebar Interests (16 posts are otherwise unreachable from navigation).
7. Fidelity: pair captions with matching media (or fewer, topical images); the current rotation puts unrelated memes under 70 of 80 titles and repeats each image about twice per feed.
8. Depth: replace the verbatim "Community remix" clones with genuinely different posts so search families have real near-misses; T6/T7/T5 currently sit in 2–5-item pools.
9. Docs/tests sweep for 25 sites / 40000-40024 — already applied in reviewer commit d5ebfdd on the Review: PR (the two registry integration tests fail on the PR head as-is).
7. Validation of the grading contract (CONTRIBUTING §C)
Live matrix (sites/9gag/verify/tests/live_matrix.py; every task driven for real in Chromium with UI clicks only — sidebar links, search box, card titles, pagination, form buttons; agent.py-shaped run dirs with real screenshots; initial.db = seed, after.db = live instance/9gag.db; site reset from the seed before each task). Cell = verifier verdict (first failing check):
| task | genuine | no-op (home only, empty answer, clean DB) | shortcut (correct answer, no navigation) | wrong answer / wrong DB target | state mismatch (clean DB) |
|---|---|---|---|---|---|
| 9GAG--0 | PASS | FAIL (final_answer_nonempty) | FAIL (visited_search_results) | FAIL (answer_has_room_width_meters) | — |
| 9GAG--1 | PASS | FAIL (final_answer_nonempty) | FAIL (visited_search_results) | FAIL (answer_has_arrival_month) | — |
| 9GAG--2 | PASS | FAIL (final_answer_nonempty) | FAIL (visited_search_results) | FAIL (answer_has_panel_wattage) | — |
| 9GAG--3 | PASS | FAIL (final_answer_nonempty) | FAIL (visited_science_interest_feed) | FAIL (answer_has_frequency_hertz) | — |
| 9GAG--4 | PASS | FAIL (final_answer_nonempty) | FAIL (visited_search_results) | FAIL (answer_has_cabinet_colour) | — |
| 9GAG--5 | PASS | FAIL (final_answer_nonempty) | FAIL (visited_sports_interest_feed) | FAIL (answer_has_kilometre) | — |
| 9GAG--6 | PASS | FAIL (final_answer_nonempty) | FAIL (visited_search_results) | FAIL (answer_has_attempt_count) | — |
| 9GAG--7 | PASS | FAIL (final_answer_nonempty) | FAIL (visited_gaming_interest_feed) | FAIL (answer_has_switch_type) | — |
| 9GAG--8 | PASS | FAIL (final_answer_nonempty) | FAIL (visited_search_results) | FAIL (answer_has_platform_number) | — |
| 9GAG--9 | PASS | FAIL (final_answer_nonempty) | FAIL (visited_animals_interest_feed) | FAIL (answer_has_animal_name) | — |
| 9GAG--10 | PASS | FAIL (final_answer_nonempty) | FAIL (visited_login_page) | FAIL (saved_post_exact_delta: remix clone saved) | FAIL (saved_post_exact_delta) |
| 9GAG--11 | PASS | FAIL (final_answer_nonempty) | FAIL (visited_login_page) | FAIL (vote_exact_delta: downvote) | FAIL (vote_exact_delta) |
| 9GAG--12 | PASS | FAIL (final_answer_nonempty) | FAIL (visited_login_page) | FAIL (comment_exact_delta: other text) | FAIL (comment_exact_delta) |
| 9GAG--13 | PASS | FAIL (final_answer_nonempty) | FAIL (visited_login_page) | FAIL (hidden_post_exact_delta: clone hidden) | FAIL (hidden_post_exact_delta) |
| 9GAG--14 | PASS | FAIL (final_answer_nonempty) | FAIL (visited_login_page) | FAIL (report_exact_delta: reason Spam) | FAIL (report_exact_delta) |
| 9GAG--15 | PASS | FAIL (final_answer_nonempty) | FAIL (visited_login_page) | FAIL (new_post_exact_delta: wrong interest) | FAIL (new_post_exact_delta) |
| 9GAG--16 | PASS | FAIL (final_answer_nonempty) | FAIL (visited_login_page) | FAIL (profile_fields_updated: wrong location) | FAIL (profile_fields_updated) |
| 9GAG--17 | PASS | FAIL (final_answer_nonempty) | FAIL (visited_register_page) | FAIL (new_user_password_verifies: wrong password) | FAIL (new_user_exact_delta) |
| 9GAG--18 | PASS | FAIL (final_answer_nonempty) | FAIL (visited_login_page) | FAIL (saved_post_exact_delta: other post removed) | FAIL (saved_post_exact_delta) |
| 9GAG--19 | PASS | FAIL (final_answer_nonempty) | FAIL (visited_login_page) | FAIL (saved_post_exact_delta: solar post saved) | FAIL (saved_post_exact_delta) |
Result: 20/20 genuine PASS, 90/90 negative cells FAIL, cells wrong: none. The genuine runs double as the solvability proof — recorded UI paths, e.g. T3 / → /interest/science → /interest/science?page=2 → /gag/an-engineer-explains-why-this-bridge-hums-in-the-wind-19; T14 / → /login → /home → /news → /news?page=2 → /gag/a-mysterious-press-conference-moment-from-2009-6 → …/report; T19 / → /login → /home → /interest/science → (page 2) → solar detail → bridge detail → balloon detail → bridge detail (Save), 17 steps.
Unit matrix (sites/9gag/verify/tests/, synthetic trajectories over copies of the frozen seed, no site/docker/LLM): uv run python -m unittest discover -s sites/9gag/verify/tests -p 'test_*.py' → Ran 246 tests … OK (skipped=10) (the 10 skips are the abstract read-task base class). Covers run-dir snapshot discovery, wrong task id, unterminated runs, mixed origins, corrupt screenshots, read-only writes, schema/catalog tampering (snapshot_contract_invalid), stale-state preconditions, wrong account (entered_expected_account_identity), out-of-order login (workflow_login_before_save), duplicate actions, counter bumps without vote/comment rows, collateral writes, clone-instead-of-original targets, negated answers, number/time spelling variants, and the real werkzeug scrypt hashes of the four seeded accounts.
Entry point: agent_demo/eval_judge.py --run_dir <run> --verifier True on the genuine T19 run dir → pass: True … reason: all checks passed; on the shortcut run dir → pass: False … reason: visited_login_page (eval.json written both times). Every verifier was run with --no_llm True; no LLM key exists on the review host.
8. Evidence (exact commands and outputs, server carcinoma, standalone site on 127.0.0.1:45001)
$ python3 scripts/validate_asset_archive.py ../tarball/9gag.tar.gz 9gag
[fetch] validated 73 managed members for 9gag
$ tar tzf ../tarball/9gag.tar.gz | wc -l ; tar tzf ../tarball/9gag.tar.gz | grep -c '\._'
146
73
$ md5sum sites/9gag/instance_seed/9gag.db
44768940829bf4a8bec07d7bdf072d97 sites/9gag/instance_seed/9gag.db
$ find sites/9gag/static/images -type f | wc -l ; ls sites/9gag/static/images/posts | wc -l
67
38
$ python3 -m py_compile app.py seed_data.py _health.py && echo ok # ok
$ bash -n ../../websyn_start.sh && echo ok # ok
$ grep -rn "from sites\.\|import sites" . # (no output)
$ python3 ~/wh-review/9gag/scripts/check_registration.py
websyn_start.sh SITES == control_server.py SITES: True
site count: 25 | 9gag index: 24 | port: 40024
Dockerfile: EXPOSE 8101 40000-40024 | # 24 Flask mirror sites + control plane on :8101. <- comment stale on PR head (fixed in d5ebfdd)
tasks: 20 ; key sets: [('id','ques','upstream_url','web','web_name')] ; ids sequential: True ; web set: ['http://localhost:40024/']
$ rm -rf instance && cp -a instance_seed instance && nohup venv/bin/python -c "from app import app; app.run(host='127.0.0.1', port=45001, debug=False, use_reloader=False)" &
$ curl -so /dev/null -w '%{http_code}\n' http://127.0.0.1:45001/ ; curl -s http://127.0.0.1:45001/_health
200
{"ok":true,"posts":80,"site":"9gag"}
$ md5sum instance/9gag.db instance_seed/9gag.db # boot 1
44768940829bf4a8bec07d7bdf072d97 instance/9gag.db
44768940829bf4a8bec07d7bdf072d97 instance_seed/9gag.db
$ uv run python ~/wh-review/9gag/scripts/audit_readonly.py ; md5sum instance/9gag.db instance_seed/9gag.db # after read-only Playwright drive
44768940829bf4a8bec07d7bdf072d97 instance/9gag.db
44768940829bf4a8bec07d7bdf072d97 instance_seed/9gag.db
$ bash stop_site.sh; bash start_site.sh; md5sum instance/9gag.db instance_seed/9gag.db # boot 2, instance kept
44768940829bf4a8bec07d7bdf072d97 instance/9gag.db
44768940829bf4a8bec07d7bdf072d97 instance_seed/9gag.db
$ uv run python ~/wh-review/9gag/scripts/audit_functional.py ; md5sum instance/9gag.db instance_seed/9gag.db # after stateful drive (control)
6eb5f5cf2d661c2e15a24b81c0a6b21f instance/9gag.db
44768940829bf4a8bec07d7bdf072d97 instance_seed/9gag.db
$ bash stop_site.sh; bash start_site.sh fresh; md5sum instance/9gag.db instance_seed/9gag.db # fresh copy = reset
44768940829bf4a8bec07d7bdf072d97 instance/9gag.db
44768940829bf4a8bec07d7bdf072d97 instance_seed/9gag.db
# read-only audit (audit_readonly.json), excerpts
home_images => {'count': 11, 'broken': []}
home_scrollWidth_vs_client => [1440, 1440] ; home_768 => [857, 768] ; home_390 => [390, 390]
interests => humor 4, memes 0, gaming 4, wtf 1, animals 13, science 17, wholesome 7, sports 5, movies 2, food 4, music 4, politics 1, random 1, awesome 6, art 4, comic 2, books 2, motorvehicles 2
/tag/spider-man 0, /tag/usa 0, /tag/trump 0, /tag/elon-musk 0, /tag/politics 0, /tag/latest-news 0, /tag/animals 0 (all "No posts found")
search 'lighthouse' 8 ; 'rescue cat shelter' 9 ; 'bridge sound rain' 20 ; 'LIGHTHOUSE OFFICE' 10 ; 'nonexistentterm' 0 ; 'Boston Celtic players' 0
task 0: results_total 8, target_position 8, card_text_leaks {} ; task 3: results_total 17, target_page 2 ; task 14: results_total 19, target_position 19 (page 2)
remix_duplicates: count 35, all_same_facts True
t9 points: cat 2506 / dog 3717 / fox 4236 (clone cat 8561, clone dog 1372, clone fox 1891)
# functional audit (audit_functional.json), excerpts
login_wrong_pw flash 'Incorrect email, username, or password.' ; login_email header 'alice_j'
register_ok header 'new_person', db [('new_person','new.person@test.com','new_person')] ; register_server_validation created 0
save button_before '☆ Save' -> button_after '★ Saved', persists_after_reload '★ Saved' ; unsave db_count 4
vote '8388 points · 57 downvotes' -> up '8389 … 57' -> down '8388 … 58' -> undo '8388 … 57'
comment short_flash 'Comment must contain at least 2 characters.' ; stats '… 27 comments'
report db [('Misinformation', 47)] ; report_empty_reason db_count 1
hide db [(1,)], in_search_after_hide False, detail_status_after_hide 200
settings db [('Alice J.', 'Memes, trail photos, and excellent tiny libraries.', 'Tacoma, WA')] ; settings_empty_name unchanged
submit db slug 'quiet-victories-deserve-confetti', section 'wholesome', tags 'community|education|wholesome', author 'alice_j' ; first_on_fresh True
auth_gate: /saved,/settings,/submit,/notifications,/account,/report -> /login?next=… ; POST /save logged out -> /login?next=…
Screenshots (server ~/wh-review/9gag/shots/, cited ones copied to the review bundle): home_1440_fold.png, home_1440_full.png, upstream_9gag_home_1440.png, home_768_full.png, home_390_full.png, detail_12.png, search_lighthouse.png, page_interest_memes.png, task{N}_results.png / task{N}_detail.png (N = 0–14, 19), func_login_ok.png, func_register_done.png, func_saved_after_save.png, func_vote.png, func_comment.png, func_report_form.png, func_settings_after.png, func_submit_done.png; live-matrix run dirs with per-step screenshots under ~/wh-review/9gag/runs/matrix/task{NN}_{genuine,noop,shortcut,wrong,mismatch}/.
Screenshots
All cited screenshots: https://github.com/JeremyJC67/WebHarbor/tree/pr-screenshots/reviews/9gag
| mirror homepage 1440 px | real 9gag.com homepage 1440 px (captured from the review host) |
|---|---|
![]() |
![]() |
| 768 px — horizontal overflow (857 px document) | /interest/memes — empty entry point |
|---|---|
![]() |
![]() |
Second-pass audit —
|
| request | before | after |
|---|---|---|
POST /gag/<slug>/vote value=garbage |
302 — stored as a downvote (vote row value=-1) |
400 |
POST /submit section=NOT_AN_INTEREST |
302 — post.section='NOT_AN_INTEREST' |
400 |
POST /submit title=<5000 chars> |
302 — 5000 chars into a String(300) column |
400 |
POST /submit image=../../etc/passwd |
302 — stored verbatim |
400 |
POST /gag/<slug>/report reason=NOT_AN_OPTION |
302 — report.reason='NOT_AN_OPTION' |
400 |
post rows after all of the above |
83 (three junk posts) | 80 |
$ sqlite3 instance/9gag.db "select id,source_id,section,length(title) from post where id>80"
81|local-81|humor|16
82|local-82|humor|5000
83|local-83|NOT_AN_INTEREST|19
$ sqlite3 instance/9gag.db "select * from vote where post_id=23"
13|1|23|-1 <- "garbage" became a downvote
value = 1 if request.form.get("value") == "1" else -1 treats every unrecognised value as a
downvote, and section / image / reason were passed straight through.
Fixed in 5bedd61: interests, report reasons and preview images are now one tuple each in app.py,
rendered by the template and validated by the handler, so the form and the server cannot drift
apart; bounded_text() rejects over-long fields. Empty-form submissions keep the existing
flash-and-re-render behaviour (that is UX, and it never succeeds silently) — only out-of-vocabulary
values, which the UI cannot generate, return 400.
2. Application robustness — the rest
| item | evidence | after | parity across the 25 sites |
|---|---|---|---|
Fixed SECRET_KEY |
minted a cookie from the tracked literal: GET /account -> 200, rendered @alice_j / Alice Johnson / Seattle, WA; a forged POST .../save wrote a row |
302 to /login, identity not leaked |
17/25 hardcode a key; 7 use env-or-random, which 9gag now does |
| No CSRF | POST .../save with Origin: http://evil.example -> 302, saved_post 16 -> 17 |
400, saved_post 16 -> 16 |
9gag and ikea were the only 2 of 25 without CSRFProtect |
GET /logout |
302 (prefetchers fire it) |
405 |
7/25 already POST-only |
No MAX_CONTENT_LENGTH |
60 MB multipart accepted in full (uploaded 62914887 bytes) |
413 |
5/25 set it |
| No error handlers | /nope rendered the bare Werkzeug page |
404 with the 9GAG shell |
17/25 have handlers |
source_id = COUNT+1 |
latent: collides after any delete (no delete route today) | MAX(suffix)+1 |
— |
Flask-WTF undeclared |
site refused to boot: ModuleNotFoundError: No module named 'flask_wtf' |
declared | 8/25 already declare it |
On parity: SECRET_KEY, CSRF, GET /logout and MAX_CONTENT_LENGTH are common in the repo and this
is a loopback-only sandbox, so I would not block a merge on them — but 9gag was the outlier on CSRF,
and all five were cheap to close, so they are done rather than filed.
3. Accessibility — 16 distinct contrast failures, and a bug in my own instrument
Measured with Playwright plus per-element pixel sampling, 4 widths x 25 routes.
| ratio | required | colour | where |
|---|---|---|---|
| 2.548 | 3.0 | #999 on #fff2c7 |
notice-band dismiss x (26px) |
| 2.849 | 4.5 | #999 on #fff |
.stats points/comments, vote buttons, feed tabs |
| 3.545 | 4.5 | #888 on #fff |
sidebar headings, Clear, ADVERTISEMENT |
| 4.068 | 4.5 | #fff on #087bf5 |
Post button, all .primary submit buttons |
| 4.478 | 4.5 | #777 on #fff |
.muted-link, post meta, footer, topic chips |
Worth flagging honestly: my first contrast pass was wrong about the Post button. It sampled the
four corners of each element box, and on a rounded pill the corners are page background — so it
reported white-on-white at ratio 1.0. Re-sampling the modal colour across the whole box gives
rgb(8,123,245) and 4.068:1, which matches the CSS-derived value. Only failures confirmed by both
the cascade walk and the pixel sample are listed.
Fixed in 532ee09 with a --muted (#767676, 4.54:1) and --action (#0a74e6, 4.52:1) token,
keeping the brand blue for decoration. 51 -> 0 failing elements.
4. Responsive — the 768px overflow, with a corrected diagnosis
The first review attributed the 857px document to the 220px + minmax(0,640px) grid. That was
incomplete: fixing the grid alone would not have cleared it.
OVERFLOW 768px /: 857 > 768
offenders=[{'tag':'A','right':857,'width':77,'clipped_by':None},
{'tag':'A','right':804,'width':72,'clipped_by':'HIDDEN:topic-row'}]
The offender with clipped_by: None is the six-tab .feed-tabs strip (~628px needed against a
narrower main column); the topic-row overflow is deliberately clipped and correctly ignored.
532ee09 makes the grid track flexible under a 934px cap (preserving the intended 640px reading
column at 1000px) and tightens the tab strip so it scrolls itself between 681px and 1000px.
11 -> 0 failing route/width cells; 1440 / 390 / 320 were clean before and after. The fix had to be
appended as a final layer — the stylesheet is built from successive override layers, so the same
rules placed earlier are defeated by the later .feed-tabs / .stats / .actions redefinitions.
5. Empty navigation targets — fixed without moving any task
Eight entry points landed on "No posts found": the Memes interest (sidebar, top-nav and the Recents
chip), all six topic chips, and #animals. The seed has 0 memes posts and no post carries those tags.
Seeding them needs an HF re-upload, so the dead links are repointed at feeds the sidebar already
exposes — nothing becomes newly reachable, so no task gains a navigation path its verifier would
reject. Re-measured afterwards:
DEAD ENDS: none
T0/T10 lighthouse 10 T1 rescue cat 9 T2 solar camping 10 T3 science 17
T4 community lib 30 T5 sports 5 T6 sourdough 2 T7 gaming 4
T8 rain delay 12 T9/T11 animals 13 T13 office plant 6 T14 news 19
Every count is identical to the first review, and the live matrix then re-drove all 20 tasks green.
I did not add the missing awesome / art / comic / books / motorvehicles interests to the sidebar
(first review, follow-up 6), on purpose: awesome holds T0's target, so a sidebar entry would create
a browse path to it that verify_0's mandatory /search?q= gate rejects — a "fix" that could fail a
correctly solved task. Worth doing only together with relaxing that gate.
6. A defect in our own grading contract
Tasks 6 and 8 are read-only fact questions, but their verifiers accepted only the original post's
slug. The seed's Community remix N: clones copy the description verbatim, so an agent that
opened the clone, read the right number and answered correctly was graded FAIL — on a rule stated
nowhere it could see. It was also inconsistent: the other seven read-only clone tasks (0-5, 7)
already accepted either page.
Provenance is now enforced only where it changes the graded outcome:
| tasks | policy | why |
|---|---|---|
| 0-8 (read-only facts) | original or clone | identical description; the answer is the same |
| 9 | original only | it compares point counts, which differ between original and clone |
| 10-13, 18, 19 (stateful) | original only | the clone is a different row to save/vote/hide/comment |
Every task that still depends on provenance now says so in its ques ("not its 'Community remix'
copy"), so the requirement is visible to the agent. Widening a gate can silently disable it, so each
of tasks 6 and 8 gained two cases: the clone now passes, and an unrelated detail page still fails on
visited_post_detail.
Separately, on distractor quality: the 35 verbatim clones add search-result noise but little
discriminative difficulty, since they never contradict the original on any queried fact. Replacing
them with genuinely different posts (first review, follow-up 8) remains the higher-value change, and
needs an HF re-upload.
7. Verifier-vs-recorder contract
ast over agent_demo/agent.py gives step keys {action, params, screenshot_before, screenshot_after, step, thought, title, url} and the action vocabulary click / input / scroll / navigate / go_back / done with params {index, text} — all matched by verify_lib
(INPUT_ACTIONS includes input; it reads params["text"]). The recorder writes no url_after,
and the verifiers already use the next-step-url convention.
One drift: agent.py never writes final_url, but both our unit fixtures and live_matrix.Recorder
did — so the verifiers had only ever been proven against a shape the recorder does not emit. Not a
live failure (final_url() already falls back to the last step's url, and Recorder.done() appends
a done step carrying the landing page exactly as agent.py does). Fixed in cf4fcff: the live
matrix omits it so its evidence is shape-identical to a real run, and every read task gained a case
that nulls it.
8. Report-only
- 27 zero-reference assets ship into the image. Every file under
static/images/reference/
(26.jpg, 1.png, 1.ico) is referenced by no template, stylesheet or handler
(grep -rn "reference/"returns nothing), yet.dockerignoreshipsstatic/images/. Please prune
them in the same re-upload that strips the 73 AppleDouble._*members already filed. 9gag
ships noasset_inventory.json, so the inventory contract cannot catch this (parity: only compass
and walmart_careers have one). review-reports/9GAG-FINAL-AUDIT.mddoes not describe the uploaded artifact. It records
sha2566ba9ca46...(the tarball on HF Add TED task verifiers (site by @shanjiaming, verifiers by reviewer) #65 is9f003eee...), "Managed members: 74" (the validator
reports 73), and "no horizontal document overflow" across "1440, 390, and 320 px" — true at those
widths, but 768px was never tested and overflows on 11 routes. Historical audit reports should not
be retro-edited; noting it so the numbers are not carried forward as verified.- Two nits.
submit.html'ssectionandimageselects have no empty first option (not a leak —
T15 needswholesome, which is not first;report.htmlalready does this correctly)./shuffle
usesdb.func.random(), the one nondeterministic route in a byte-deterministic mirror; no task
uses it and it performs no write. - Correction to my own first review: it said "20/20 genuine PASS, 90/90 negative cells FAIL". The
matrix has 90 cells in total — 20 genuine and 70 negative. The measurement was right; the phrasing
double-counted.
9. What changed in your code
Five reviewer commits on top of 5850198; your commit is untouched.
| commit | files | concern |
|---|---|---|
d2ddecc |
templates/base.html, _sidebar.html, feed.html |
repoint the 8 dead navigation targets |
532ee09 |
static/css/site.css |
768px overflow + WCAG AA contrast |
5bedd61 |
app.py, requirements.txt, templates/error.html + 7 form templates |
input validation, CSRF, secret key, logout, upload cap, error pages, id allocator |
0efbedf |
tasks.jsonl, verify/verify_6.py, verify_8.py, their tests, verify/README.md |
the hidden provenance rule |
cf4fcff |
verify/tests/_support.py, live_matrix.py |
recorder trajectory shape (our artifact only) |
Contributor-code diff: 14 files, +187 / -54. Please review d2ddecc, 532ee09 and 5bedd61 in
particular — they change your templates, stylesheet and app.py.
10. Validation after every change
unit matrix : Ran 258 tests ... OK (skipped=10) # 246 before, +12 added by this pass
live matrix : 20/20 genuine PASS, 70/70 negative FAIL, 90 cells, cells wrong: none
reset md5 : instance == seed == 44768940829bf4a8bec07d7bdf072d97 (after the full matrix)
responsive : 0/100 route x width cells overflow # 11 before, same instrument
contrast : 0 failing elements # 51 before, same instrument
hygiene : 0 broken images, 0 console errors, 0 page errors, 0 external requests
Both UI numbers are mutation-proofed in both directions: the contributor's stylesheet was restored on
the server and the same corrected instrument re-run, reproducing 11 and 51; with the fix, 0 and 0.
Still open, and not something I can do: repin .assets-revision to the merged HF sha once
discussion #65 lands (the current pin 65c479f8... contains no 9gag.tar.gz); repack the tarball to
drop the 73 AppleDouble members and the 27 unreferenced reference images; and the 40024 port
collision with PR #95. The full 25-site image was green in the first review, but that predates these
five commits — the container chain should be re-run against cf4fcff before merge, since
CSRFProtect, MAX_CONTENT_LENGTH and the new error handlers are exactly the kind of change that
can behave differently in the image.
Container re-verification of this branch (cf4fcff, full 25-site image)
Docker was unavailable to the audit pass itself; it has now been run, which matters here because
CSRFProtect, MAX_CONTENT_LENGTH and the new error handlers are exactly the kind of change that can
behave differently in the image. Assets from HF refs/pr/65.
| check | result |
|---|---|
docker build |
ok (the new Flask-WTF dependency resolves) |
| HTTP sweep, alt ports 44000-44024 | 25/25 return 200 |
GET /health |
ok, 25/25 alive and ready, 9gag on internal 40024 |
POST /reset/9gag → md5 instance vs instance_seed |
44768940829bf4a8bec07d7bdf072d97 both |
| dirty request then reset | still 44768940… |
docker restart then md5 |
still 44768940… |
POST /reset-all |
ok, 25/25 ready, 1.58 s |
verifier no-op matrix, in-container DBs via docker cp |
20/20 FAIL, 0 errors |
walmart_careers / rotten_tomatoes registry + docs tests |
4 passed, 3,343 subtests |
sites/9gag suite inside the image |
258 passed, 130 skipped, 25 subtests |
The blocker fix and the hardening hold in the image:
POST /gag/<slug>/vote value=NOT_AN_OPTION 400 (was: 302, and it wrote a downvote row)
POST /gag/<slug>/save without a CSRF token 400
GET /logout 405
GET /no-such-page 404, 1175-byte branded page
POST /register, 300 KB body 413
GET /login?next=https://evil.example/pwn redirect_url empty (not followed)
md5 before / after 7 read-only GETs (/, /top, /trending, /fresh, /news, /search, /interest/science)
44768940829bf4a8bec07d7bdf072d97 / 44768940829bf4a8bec07d7bdf072d97 -> byte-identical
One reading to be precise about, so it is not mistaken for a regression: /interest/memes still answers
200 and renders zero cards. That is intended. The fix removed the dead entry points — the sidebar
interest, the top-nav link, the Recents chip, the six topic chips and the #animals tag — rather than
seeding content or making the route 404. /interest/<section> is a generic route, so any section with no
posts renders an empty feed if a URL is typed directly; nothing in the UI leads there any more, and
memes remains a valid choice in the submit form, so a post created during a task still lands somewhere
coherent.
Everything else in this comment was measured standalone before the container run and is unchanged.
Tooling disclosureAdded after the fact, because it belongs in the public record and not only in our internal notes. The second-pass audit had a general-purpose LLM available to it as a judgment aid — used for For this site I have no record of it changing any conclusion; the findings above came from the probes and No call log was kept, so I cannot enumerate every consultation — that is the honest limit of this |




Summary
Adds a deterministic 9GAG mirror as the next WebHarbor site on port
40024.Fidelity
The UI was refined against recorded desktop and mobile references. It includes the three-column desktop shell, navigation/sidebar hierarchy, topic chips, content controls, real harvested post imagery, a captured advertisement asset, and responsive feed behavior at 390px and 320px.
Screenshots
Assets
sites/9gag/ASSET_SOURCES.md.assets-revisionremains unchanged while the HF PR is open. A follow-up commit will pin the immutable HF merge revision.Validation
Seeded state
Detailed audit report