Program Index

Community Media Archive & Archive Corps

One program captured across many working sessions — not a set of unrelated projects. Everything traces to the charter in 00.0: preserving community and local civic media, and building the volunteer effort (Archive Corps) around it. Two work streams sit underneath: the media “beast” (the video → Internet Archive pipeline) and the agendas & minutes rebuild.

Start hereOVERVIEW — “Preserving Community Media: A Three-Part Effort”The three-database design — present / past / future — and how the whole effort fits together. Brief and plain-language; read it before the section summaries or the gory details.Open PDF

The program at a glance — the levels

The Map Room ↗ The same program as three clickable mind maps — by provenance, by operation, or by what it taught. Switch map, visual look and light level live; arrow keys or a presenter remote walk all six views. Opens beside this file.

Numbering scheme

The tens digit is the work stream; within the per-vendor tier the tens digit is the vendor and the ones digit runs build → runs/diagnostics.

00Program & strategy
10Media archive infrastructure (“the beast”)
20Agendas: landscape & pipeline (do before per-vendor work)
30CivicPlus scraper + legacy-corpus forensics
31CivicClerk scrapers
32Legistar scrapers
40Community / Archive Corps
90Shared utilities

New here? Read in this order: 00.0 the why → 20.1 the agenda-landscape summary and open decisions → the per-vendor bundle relevant to your task (30 / 31 / 32).

Folders — click any to expand its entry-point doc

00Program & strategy

10Media archive infrastructure — “the beast”

  • 10.0civic-data-pipelineThe civic.db pipeline and detectors.
  • 10.1ia_metadata_xml_to_sqliteIA _meta.xml → SQLite/Datasette; weekly sync, dark-item detection.
  • 10.2archive-dashboard-projectMonitors the ~10 worker machines uploading video to IA.
  • 10.3whisper-asr-gpu-01Whisper ASR fill for the caption gap set (Project C3) — Faster-Whisper-XXL r245.4 on gpu-01; large-v3-turbo, 2-process, 88–95× realtime.
  • 10.4whisper-cpp-cross-platform-benchmarkswhisper.cpp ASR benchmarks across the non-CUDA fleet — Vulkan on the AMD boxes (worker-05/6, worker-12) and Metal on the Macs; the counterpart to 10.3's CUDA/Purfview work.
  • 10.5fleet-dirname-collection-mapThe rescued dirname → collection map (2,133 rows, 10 workers); 132 deliberate exceptions, 42 multi-directory collections.
  • 10.6ia-scrape-api-anomalyReproducible bug report: the IA Scrape API returns another query's results for certain count+fields combinations, HTTP 200, no error.
  • 10.7cma-collection-freshnessWhich collections have stopped being updated — and the TITLE_COLLISION class of "missing" that no rerun can fix.
  • 10.8youtube-update-uploader-v2The per-channel upload script rewrite + lessons learned; starting point for the cross-platform uploader.
  • 10.9archive-txt-surgeryRemoving ids from a worker's yt-dlp download-archive so items lost on the way to IA can re-fetch — 66% of "missing" videos are blocked this way, not backlog.
  • 10.10backlog-url-repairStale channel handles -> channel ids: 16 collections were running weekly and fetching nothing because a handle changed.
  • 10.11cleanup-script-analysisWhich local media gets deleted after upload — the filename/identifier join, its defects, and a safer design using the video id.
  • 10.12cma-story-minerMines the whole archive for subjects worth writing about — 119 editorial themes scored against all 3,241,074 items, ranked into a top-50 worksheet with exemplars for a writer.
  • 10.13archiveteam-warrior-decompositionDecision paper: the ArchiveTeam Warrior model — a claim-ledger tracker handing work units to stateless clients — as an alternative to per-channel ingest. Adopt the work distribution; defer untrusted contribution until egress is measured. Nothing built.
  • 10.14openpublica-api-mappingCan the CMA, civic.db and IA's Community Meetings map to the OpenPublica API? Yes, with three crosswalks (place, body, meeting); the YouTube video id is an exact join. 33% overlap; 4,585 of 6,118 matched CMA items lack English captions. Analysis only.
  • 10.15ia-community-meetings-scale-estimateIA's experimental Community Meetings DB (4,862 meetings, 772 MB) sized at CMA scale: ~92–146 GB with full transcripts for 0.73–1.16 M city/town meeting hours, found across all 3.24 M archive.db items.

20Agendas — landscape & pipeline

30CivicPlus

31CivicClerk

32Legistar

40Community / Archive Corps

  • 40.0volunteer-contributionHow people can help — coordination, recognition, and review.
  • 40.1VOLUNTEER_PLATFORM_DESIGNDesign for the volunteer microtask & recognition platform.
  • 40.2archive.org-30-day-search-challengeVolunteer engagement challenge for Archive Corps.
  • 40.3archive-corps-training-curriculumThe depth track — 4 sessions / 6 weeks turning 6–8 volunteers into stewards who can describe and upload; incl. the House Style Guide whose unique-title rule is the upstream fix for 10.7's TITLE_COLLISION.
  • 40.4archive-corps-bof-recruitmentThe ACM Birds of a Feather kit that recruits the cohort 40.3 trains — handout, speaker notes, day-of run sheet, and the sign-up form build reference.
  • 40.5acm-50th-booklet-extractionThe ACM 50th Anniversary Booklet → 105 structured per-contributor records. The booklet's typography is a semantic stylesheet, so extraction is deterministic; `pdftotext -layout` was rejected on evidence.
  • 40.6community-media-voices-reviewReview of communitymediavoices.org plus a measured cross-match to the CMA: 133 of 479 hubs exact-match a CMA collection, 246 need review. Prepared for a call with the site's creator.
  • 40.7cma-public-storiesOutward-facing writing: three story versions and “258 Years of Tuesday Night”, a guest post drafted for blog.archive.org — every named station and video links to its archive.org page. Not published yet.
  • 40.8ia-collection-ownership-anomaly105 archive.org collections list a placeholder account as owner while the CMA is their only contributor (117,876 items). Draft email to IA, unsent; a retraction kept on purpose.
  • 40.9civic-media-cookbook-intersectionsNews Futures' Civic Media Cookbook read against the CMA: 50 projects, none an archive — custody is the missing layer. 19 of 21 named US places have a name-matched CMA collection (review trigger).

90Shared utilities

  • 90.0diagram-docx-font-style-fixDocx mono-font patch — applies to any bundle's doc generator.
  • 90.1session-prompt-extractLifts the human-typed prompts out of a Claude Code session transcript into readable Markdown — the transcripts under ~/.claude/projects/ are system working state, large and not guaranteed to persist.

The documents

00.0stepping-back-from-CMA-operationsThe charter — why all of this exists.
Open PDF · Stories in the Numbers — a storyteller’s take

Community Media Archive & Archive Corps — Project Notes

The strategic shift

What "Move focus from Community Media Archive operations to CMA Stewardship" means:

  • Move off operations, onto the underdeveloped/unexplored aspects
  • Bring the community in to understand the resource they have and what it could become
  • Drop the "that video's not going to archive itself!" pressure

Why Archive Corps matters (the value proposition to participants):

  • Discover a passion or interest without a scary commitment — no career change, no months of full-time retraining, no degree that might end in "I don't like this field"
  • Earn peer recognition for real contributions
  • Contribute at scale to something that preserves this community's work and history, and gives researchers a resource for studying community and local civic media

The integration question: how does Archive Corps map onto operating / feeding / improving / extending the beast?


Curriculum: IA interface fundamentals first

Taught with real examples, three levels:

  1. GUI
  2. Search → advanced search
  3. CLI

Rationale: you can't understand how metadata decisions affect discovery, findability, searchability, and usability until you've worked the retrieval side. Only then does the link between what you put in and how people find it click.

Outcomes: participants can archive their own media — born-digital, whether or not it's on YouTube/Vimeo. Also a jumping-off point for physical digitization projects.

Then: operating the CMA. Worked example — default TelVue archiving module vs. exporting metadata alongside the videos.


CMA work areas

Operating the beast

  • Onboarding new YouTube channels
  • Reporting to operators, fleet managers, channel owners

Feeding / expanding the fleet

  • New worker process for Linux/Mac/Windows that doesn't require deep Linux tooling knowledge
  • Migrate existing fleet to the new Linux process

Incorporating Agendas & Minutes

  • Existing corpus: ~800,000 CivicPlus documents, ~1,500 communities, run 2022–2023 covering 2018–2022, via BigLocalNews civic-scraper
  • CivicPlus UI has changed; the 2022 scraper code no longer works
  • Need extractors matching current UIs for the common agenda vendors in civic.db
  • Need a way to track vendor changes over time

Improving the beast — metadata grooming bots

  • Language attribution: mark Spanish based on description text, not naive title matching
  • Clean \r and \" in descriptions
  • Automated transcript local dictionaries

Infrastructure

  • Captioning machines: Faster-Whisper-XXL on cards that need newer Nvidia software than 3060 generation can support

Extending the beast — volunteer systems

  • Microtask distribution, volunteer direction, recognition tracking

The biggest unfinished areas
  1. Volunteer infrastructure — microtasks, recognition, coordination. Nothing else in Archive Corps works without it.
  2. The onboarding curriculum — IA fundamentals → CMA operation. Currently a sketch.
  3. Agendas/minutes rebuild — highest-value corpus, currently broken.
  4. Cross-platform worker process — the gate on fleet expansion beyond Linux-fluent people.

↑ back to contents00.0-stepping-back-from-CMA-operations/cma-notes.md

10.0civic-data-pipelineThe civic.db pipeline and detectors.
Executive summaryRead this first

Civic Data Pipeline (Cities)

A pipeline that, for every incorporated city/town in a U.S. state, discovers its official website, its YouTube channel, and its Agendas & Minutes page (with any copyright statement). All data lives in a single SQLite database (civic.db by default) with one row per place across every state. CSV files are used only for review and hand-editing, via export and import.

Data source: the U.S. Census Bureau 2024 Places Gazetteer.


Files
File Role
civic.py The command-line entry point. All subcommands run from here.
civic_db.py SQLite data-access layer (schema, queries, upserts).
civic_common.py Shared library: detection logic, HTTP/caching, gazetteer.
detect_sites.py / detect_youtube.py / detect_agendas.py Per-stage detection libraries — each exposes one function (detect_site / detect_youtube / detect_agenda) that civic.py calls. No CLI; not run directly.
cma_onboard.py Standalone tool (stdlib-only; not imported by civic.py). Onboards places that have a channel but are not yet in the Community Media Archive: picks the cohort from civic.db, mints each a free mnemonic IA collection identifier, and writes ia-ready spreadsheets. Creates nothing on archive.org. See Onboarding new channels below.
youtube_triage.py Standalone tool (stdlib + civic_common). Triages scrape-youtube --dry-run review CSVs before import, sorting proposals by how they can be decided — auto-rejectable, group-decidable, judgement-needed, skimmable. See Triaging the review queue.
body_discriminator.py + refresh_body_vocab.sh + archive_body_extract.py Narrows same-name channel candidates by the governing bodies in their video titles, scored against the full federated archive.db on archive-store (2.76M titles / 1,974 collections). Refresh after each weekly_sync. See runbook §1.6.
make_group_worksheet.py Renders the GROUP-DECIDE triage rows as a markable review worksheet (URLs alone on their own line, for terminal 'open link'). Applies the evidence precedence HUMAN > SITE > NAME > BODY > CODE.
harvest_worksheet.py Reads the [x] marks back out of that worksheet into triage/_group_decisions.csv. Refuses groups with more than one box ticked; backs up before every write. Runbook §1.7 has the workflow.
cma_survey.py Standalone tool (stdlib + yt-dlp). Surveys each candidate channel's /videos and /streams tabs to sequence the download queue: duplicate/colliding titles, channels with no meetings, dead-vs-active, size in hours. Downloads nothing. Consumes cma_onboard.py's manifest. See Sequencing the download queue.
wiki_official_site.py Standalone tool (stdlib-only; not imported by civic.py). Finds official municipal websites via Wikipedia — infobox / external-links / Wikidata P856 — for civic.db places with no official_site. Writes a CSV that civic.py import-wiki-sites back-flushes.

Sequencing a state for the workers is documented separately in docs/CMA_ONBOARDING_RUNBOOK.md (also .docx/.pdf) — the plan → survey → review → tune → assign cycle, how to choose scoring weights, the review flags that must stop a collection being created, and how to decide when to spin up a worker on a new IP.

Core files must live in the same directory (civic.py imports the others); wiki_official_site.py, cma_onboard.py, cma_survey.py, youtube_triage.py, body_discriminator.py and check_rescrape.py are standalone and run on their own.


Requirements
  • Python 3.8+ (SQLite is in the standard library)
  • pip install requests beautifulsoup4

Quick start
# Stage 1 — discover official sites for WA (downloads/caches the gazetteer,
# caches every homepage it fetches). Writes into civic.db.
python3 civic.py scrape-sites --state WA \
    --gazetteer ~/civic/gazetteer.zip --cache-dir ~/civic/html_cache

# Stage 2 — YouTube channels (reuses cached homepages, no re-fetch).
python3 civic.py scrape-youtube --state WA --cache-dir ~/civic/html_cache

# Stage 3 — Agendas & Minutes + copyright.
python3 civic.py scrape-agendas --state WA --cache-dir ~/civic/html_cache

# See coverage across everything in the database.
python3 civic.py stats

# Get a CSV of the finished data.
python3 civic.py export --state WA --out wa_final.csv

Use --db path/to/other.db on any command to target a different database (default is civic.db in the current directory). Process more states by re-running scrape-sites --state XX; they all accumulate in the same DB.

The --cache-dir and --gazetteer flags above are optional — both default to ~/.civic_cache/ (the homepage cache lives in ~/.civic_cache/html/), so you can omit them entirely and caching still works across runs. Pass them only to use a custom location.


Output schema

These columns live in the places table (and in every exported CSV). scrape-sites fills the first group; scrape-youtube and scrape-agendas fill theirs. The database also keeps an updated_at timestamp per row (not exported).

Column Filled by Meaning
state sites USPS code (e.g. WA).
city_name sites Bare place name (type suffix stripped).
place_type sites city / town / village (or county/parish/… for county-source rows).
geoid sites Census GEOID (primary key; also the HTML cache key).
source sites Which Gazetteer file this unit came from: place / cousub / county.
official_site sites Verified official URL, or blank if none found.
match_confidence sites confident / low / unverified / manual / manual-unreachable / blank.
match_score sites Numeric verification score (9 for manual).
youtube_channel youtube Channel URL, or blank.
youtube_source youtube homepage / search / manual / blank.
agenda_page agendas URL of the Agendas & Minutes page.
agenda_source agendas homepage when scraped, manual when locked by hand, else blank.
agenda_description agendas Short description (tables, PDFs, CMS platform, etc.).
agenda_vendor agendas Portal vendor detected from the agenda_page URL (granicus, legistar, civicplus, civicclerk, primegov, municode, …), or blank if no known vendor. Routing key for the agendas-extraction project.
copyright_statement agendas Copyright text found on the agendas page.
population census 2020 Decennial total population (integer as text), or blank. Loaded by load-population.
pop_tier census Size bucket: XL (≥250k), L (≥50k), M (≥10k), S (≥2.5k), XS (<2.5k).
pop_over_threshold census yes/no vs the --threshold chosen at load time — the volunteer-triage flag.
video_channel backflush Generic canonical channel URL (YouTube channel/UC… or vimeo.com/…).
channel_platform backflush youtube / vimeo / blank.
channel_source backflush Provenance, e.g. cma_backflush; that value also locks the channel against re-crawl.
in_cma backflush yes if already preserved in the Community Media Archive.
cma_collection_url backflush Internet Archive collection URL, if any.

A blank official_site (confidence unverified) means no site cleared the verification bar; the youtube/agenda stages skip those rows.


How detection works (brief)
  • Stage 1 guesses common municipal URL patterns (cityofX.gov, Xwa.gov, X-wa.gov, ci.X.wa.us, townofX.com, …), type-aware (towns try townof… first). A fast DNS pre-check skips hosts that don't resolve. Each responding candidate is scored against the target city and state (name in title/page, state in domain/page, .gov host, wrong-state penalties) so a same-named city in another state or a domain squatter is rejected. Hard rejects (no signal can rescue them): real-estate portals (nhrealestate.com, *realty*, Zillow/Redfin/Coldwell Banker…), domain marketplaces (afternic, hugedomains, expireddomains, */buy-domain/*, */domain/*), and parked/for-sale pages — these score well on name+state signals (a listing for "Nashua, NH" has the city, state, and an address), so they are refused outright rather than penalized. A .gov host is never hard-rejected.
  • A name match alone is not a match. A generic site that merely carries the city's word in its title (liberty.org for Liberty TX, commerce.gov for Commerce TX, scotland.org for Scotland SD) is rejected, not stored at low confidence. This matters: a bad match occupies the site field, so needs=site skips the row forever and the real domain is never found — a blank is strictly better. Corroboration is required: state evidence, or a governmental signal that isn't just a bare <name>.gov (federal departments live at bare <name>.gov, so that alone proves nothing). Wrong-state defenses are strict: a domain or <title> announcing a different state (adjuntas-ny.gov, "Springfield, MO" when seeking IL) is penalized heavily; street addresses are ZIP-verified against the target state (a city-hall address whose ZIP places it in another state is a strong rejection, while a ZIP-confirmed in-state address is strong acceptance evidence); and a candidate with no positive evidence of the right state can't reach "confident" on name + .gov host alone — it's capped to "low" and flagged rather than accepted. A bare ", ST" token (e.g. a sister-city link) no longer counts as state evidence on its own.
  • Stage 2 scans the homepage for YouTube links — including social-icon bars, data-* attributes, entity-encoded and protocol-relative URLs. If the homepage has none, it follows the most promising internal subpages ("Watch Meetings", "Live Stream", "Connect With Us", …) and scans those (youtube_source='subpage'). Every found channel is then fetched once to (a) canonicalize it — @handle / /user/ / /c/ forms resolve to the stable /channel/UC… id the wider ecosystem joins on — and (b) verify it belongs to this place: a channel page with zero overlap with the place name and no civic vocabulary (e.g. a linked news station) is dropped rather than stored; an unreachable channel page keeps the URL as found (benefit of the doubt). Stage 2 also — and ranks real channels above shared videos. When neither the homepage nor its subpages link a channel, Stage 2 falls back to a YouTube search — the same move a human makes, typing "city of <name> <ST>" into the search bar. It queries YouTube's channel-filtered results, parses the candidates, and ranks them by name (word-boundary match), state (a channel titled "…, SD" or describing "South Dakota" is penalized/rejected for an Oregon place), civic vocabulary, and handle pattern (cityof<slug>, <slug>oregon, city_of_<slug>, … — including the auto-appended digits YouTube adds, e.g. @cityofboardmanoregon7618). Only a candidate that clears the acceptance bar is kept; places with no real channel (a search returning only same-named churches or businesses) yield nothing rather than a bad guess. The pick is then run through the same canonicalize-and-verify page fetch as homepage hits (youtube_source='search'). A wrong-entity guard rejects the three leaks that name+state scoring misses because the channel never spells out its state: (1) a different place — the "City/Town of X" in the title must resolve to this place, so "City of Oakland Park" is rejected for Oakland and "Town of Union City" for Union; (2) a non-government entity — a newspaper, church, school, business, or single department (police, public safety, attorney…) that merely names the town; (3) a different-state code in the title, e.g. "City of Mitchell IN" for Mitchell OR. Selection then prefers a state-confirmed active channel; failing that it takes the best active survivor, and failing that it stores nothing — a tiny town whose only real channel is abandoned gets no channel rather than a same-named one from another state. Finally, every found channel — homepage, subpage, or search — passes an activity gate: its /about page reports a total video count, and a channel with ≤ MIN_YOUTUBE_VIDEOS (30) videos is dropped. Small towns often have a channel they tried once and abandoned (a few clips from years ago); those aren't where meetings live. Past live streams count toward the total, so an active government-access channel (e.g. one with 2.3K videos) passes; an undeterminable count is kept rather than dropped on a parse miss. On the search path the gate is applied while verifying down the ranked list, so a dead top pick is skipped and an active same-city channel below it wins (e.g. a 3-video …oregon8825 variant falls through to the 52-video @CityofTillamook).
  • Stage 3 finds the link whose text/href best matches "Agendas & Minutes" / "Minutes & Agendas" (combined phrases rank highest), fetches it, extracts a copyright statement, and identifies the agenda portal vendor (Granicus, Legistar, CivicPlus, CivicClerk, PrimeGov, Municode, …) from the URL — the routing key for downstream extraction.

Which places count: governance models and data sources

The hard part of "list every local government" isn't scraping — it's that the United States has no single, uniform tier of local government. Most states have incorporated cities and towns, but several do not, and the Census Bureau splits these different structures across different Gazetteer files. A naïve tool that only reads the "Places" file silently returns almost nothing for those states.

This pipeline handles the common structural cases with a per-state source profile, choosing the right Gazetteer file(s) automatically:

Profile States Source file(s) Why
DEFAULT ~44 states Places Local governments are incorporated places (cities/towns/villages).
NEW_ENGLAND ME, NH, VT, MA, RI, CT Places + County Subdivisions The town governments are county subdivisions (MCDs), not "places." The Places file lists them as CDPs or omits them; the real towns live in the County Subdivisions file. We merge both (a few incorporated cities from Places + the towns from County Subdivisions).
COUNTY HI, PR Counties The governing units are county-level / county-equivalents, and the Places file holds only statistical areas. Hawaii has no incorporated sub-county municipalities at all (Places file is 100% CDPs; even Honolulu is a consolidated City & County). Puerto Rico's governments are its 78 municipios (county-equivalents); its Places file is 292 comunidad / zona urbana CDPs — zero governments. So we use the Counties file.

Each row records which file it came from in the source column (place / cousub / county), and Stage 1 uses source-appropriate URL patterns (e.g. Xcounty.gov and co.X.st.us for counties, townof… first for New England towns).

Concretely, this is the difference between scrape-sites --state MA returning ~350 town + city governments instead of just the ~58 cities, and --state HI returning Hawaii's 5 counties — or --state PR returning Puerto Rico's 78 municipios — instead of nothing usable.

Known limitation: township states are not yet special-cased

States with civil townships — e.g. NY, NJ, PA, OH, IN, IL, MI, WI, MN, MO, KS, ND, SD, NE — are currently treated as DEFAULT (Places file only). In some of these, townships exercise real governing authority (and have their own websites and meeting agendas); in others they are weak or nominal. Because that authority varies so much, they are intentionally left for a later refinement rather than swept in wholesale. For now, a run of one of these states covers its incorporated cities/villages but not its townships. If you need township coverage for a specific state, that state's governments live in the County Subdivisions file and could be added to the NEW_ENGLAND-style merge — ask, or edit STATE_SOURCE_PROFILE and the COUSUB_GOVERNING_SUFFIXES map in civic_common.py.

Territories

Puerto Rico (PR) and Hawaii use the COUNTY profile (see the table above). The other U.S. territories — Guam (GU), the U.S. Virgin Islands (VI), American Samoa (AS), and the Northern Mariana Islands (MP) — each have their own governance structure and are not yet profiled; running them falls through to DEFAULT and may return statistical areas rather than governments, exactly as Puerto Rico did before it was added. They can be profiled the same way once their Gazetteer structure is verified.

The county layer (opt-in)

By default the pipeline is municipal-focused: a DEFAULT state returns its incorporated cities/towns, and counties are pulled only for COUNTY- profile states (HI, PR) where the county is the primary government. Counties everywhere else are not included unless you ask for them.

To add the full county layer, use the opt-in scrape-counties command:

python3 civic.py scrape-counties --state TX      # one state's counties
python3 civic.py scrape-counties --all-states    # every state's counties

County rows carry source='county' and coexist with the municipal rows (different GEOIDs — a "Los Angeles city" place row and a "Los Angeles County" county row both live in places, distinguishable by source). All county equivalents are handled: Louisiana parishes, Alaska boroughs / census areas / municipalities, and Virginia independent cities. Because the source column separates them, you can export just cities, just counties, or both.

Two honest caveats. First, adding counties roughly doubles the row count and changes what the database represents (municipal → municipal + county). Second, county site-discovery is weaker than municipal — county domains are highly varied, so expect a higher "no site" rate than for cities even with the county-specific URL patterns. County rows with no site can be corrected via the same export → edit → import round-trip.

More broadly: "local government" is genuinely fuzzy. Special districts (school, water, fire), tribal governments, and independent authorities are out of scope entirely. This pipeline models the general-purpose municipal and county tier, which covers the vast majority of what people mean by "city/county websites," but is not a complete census of every governing body in the U.S.


Re-running just the failures

Re-running is automatic and built into the scrape commands: each one only processes rows that still need it. scrape-sites does cities with no official_site; scrape-youtube does sites with no youtube_channel; scrape-agendas does sites with no agenda_page. Run the same command again and it picks up only the gaps — already-filled rows are skipped, and manual rows are never touched.

python3 civic.py scrape-sites   --state WA   # fills only missing sites
python3 civic.py scrape-youtube --state WA   # fills only missing channels
python3 civic.py scrape-agendas --state WA   # fills only missing agendas

Each scrape writes results row-by-row inside transactions, so an interrupted run leaves the database consistent and the next run resumes from the gaps. Omit --state on scrape-youtube/scrape-agendas to process every state in the database at once.


Manual corrections — the export → edit → import round-trip

Automated discovery will miss or mis-identify some sites. You correct them by editing a CSV exported from the database, then importing it back. Manual rows are authoritative and are never overwritten by future scrapes.

Workflow:

  1. Export the rows you want to review. You can target exactly the gaps:

    python3 civic.py export --state WA --needs site --out wa_fix.csv

    (Drop --needs to export everything; drop --state to export all states.)

  2. Open wa_fix.csv in a spreadsheet and fix the bad rows: set official_site to the correct URL and set match_confidence to manual. The score is normalized to 9 for you. Works for both "no site" rows and wrong-URL rows. Don't change the geoid — it's the match key.

  3. Import the edits back into the database:

    python3 civic.py import --in wa_fix.csv --cache-dir ~/civic/html_cache

    Each manual URL is fetched once to confirm it's reachable and to populate the HTML cache (so the YouTube/agenda stages work without re-fetching). A URL that doesn't resolve (or a manual row with no URL) is flagged manual-unreachable and listed in a warning.

Blank cells do not erase. An empty column in the CSV leaves whatever is stored in that field alone; import reports how many values it kept. This matters because an export is a snapshot: if you exported last month and the database has been backfilled or re-scraped since, every cell that was empty at export time is still empty in your file — and re-importing it would silently roll those columns back. That is not hypothetical. On 2026-08-22 an import of reviewed-YouTube CSVs exported before the 2026-08-21 population backfill wrote population='' over 35 freshly-filled Connecticut and New Jersey rows.

To genuinely blank a field, pass --clear-blanks, which is gated behind --yes and previews the damage first:

python3 civic.py import --in wa_fix.csv --clear-blanks          # dry run: lists what would be cleared
python3 civic.py import --in wa_fix.csv --clear-blanks --yes    # actually clears it

The gate only engages when there is something to lose — --clear-blanks on a CSV whose blanks match the database runs straight through. If you only meant to clear one field on one row, civic.py clear is narrower and safer than round-tripping a whole spreadsheet.

Why your edits survive: manual rows live in the same database as everything else, distinguished only by match_confidence = manual. The gazetteer upsert in scrape-sites explicitly skips manual rows, and the scrape stages only process rows still missing data — so a manual correction is never re-scraped or overwritten. There is no sidecar to manage; the database is the durable store.

Correcting YouTube channels and agenda pages

The same round-trip fixes a wrong or missing YouTube channel or agenda page, locked independently of the site (and of each other):

  1. export the rows (e.g. --needs youtube or --needs agenda, or just the whole state).
  2. In the CSV, set the correct value and mark its source column manual:
    • YouTube: set youtube_channel and youtube_source = manual.
    • Agenda: set agenda_page and agenda_source = manual.
  3. import the CSV. These values are trusted as entered — not fetched or validated (unlike manual site URLs, which drive both later stages and so are still validated + cached).

A manual source locks that field: scrape-youtube / scrape-agendas skip it on every future run. This also lets you assert a negative — set youtube_channel to blank with youtube_source = manual to record "this city has no channel; stop looking." Each field is independent, so you can lock a channel while leaving the agenda open for the scraper, or vice versa.

Audit your corrections at any time (read-only):

python3 civic.py list-manual            # all states
python3 civic.py list-manual --state WA # one state

This prints every manual / manual-unreachable site row. The stats command shows per-field manual counts (mSITE, mYT, mAG) so you can see how many channels and agendas you've locked.

Zeroing out a contaminated row

If a row's official_site is flat wrong (e.g. a same-named city in another state), the YouTube and agenda data derived from it are wrong too. clear wipes the site and everything derived from it in one step, and (with --cache-dir) drops the now-wrong cached homepage:

# Blank site + youtube + agenda, leave the row OPEN for re-scraping:
python3 civic.py clear --geoid 7250123 --cache-dir ~/civic/cache

# Blank everything and LOCK it so the scraper won't re-fill it
# (use when you'll fix it by hand, or there is no correct site):
python3 civic.py clear --geoid 7250123 --lock

Without --lock the next scrape re-attempts the row. With --lock the site/youtube/agenda fields are set to manual so all three stages skip it; set the correct values later via the export → edit → import round-trip, or run clear again without --lock to reopen it.

Why was a verified URL flagged manual-unreachable? The importer fetches each manual site URL once; it only passes if the response is HTTP 200 with more than ~500 characters of HTML. A URL that works in your browser can still fail this if the site blocks non-browser User-Agents (a 403 from a WAF), is JavaScript-rendered (the raw HTML is a near-empty shell), or needs a scheme /www. you didn't include. The flag does not discard your URL — it's kept and still flows to later stages; only the one-time homepage caching was skipped. To see the exact reason per URL:

python3 civic.py diagnose                      # every manual-unreachable row
python3 civic.py diagnose --geoid 5377105      # one place by geoid
python3 civic.py diagnose --url https://x.gov  # any URL directly

It reports each step (scheme, DNS, HTTP status, body size), gives a verdict, and retries with a browser-like User-Agent to tell you whether that alone would fix it.

Automatic recovery from User-Agent blocks: the fetcher identifies itself honestly (Civic-Research/1.0) by default, but if a request is refused with a 401/403/429 it automatically retries that one request with a browser-like User-Agent. So sites behind a WAF that blocks non-browser agents are handled transparently — you don't need to do anything. If some sites were flagged manual-unreachable before this behavior existed, just re-import the same CSV: the rows are re-validated and flip to manual once they fetch.

Mislabeled status codes: some municipal servers and bot-mitigation layers return a full, real page under an odd non-200 status (e.g. a 415 with 400 KB of HTML — observed on Puerto Rico municipio sites). The fetcher accepts any response carrying a substantial body, so these pages are used rather than discarded on a status technicality, while genuine error pages (small bodies) stay rejected.

Non-English pages: site verification recognizes Spanish-language civic terms ("municipio de", "gobierno municipal", "alcaldía", "asamblea municipal"), so a legitimate Puerto Rico municipio page (often a bare <name>.com or <name>.pr.gov) clears the confidence bar — while an unrelated same-named .com with no civic content is still rejected.


Caching

The pipeline caches two things to avoid redundant network work.

Gazetteer (--gazetteer PATH)

The pipeline may use up to three national Gazetteer files (Places, County Subdivisions, Counties — see Governance models above). Each is downloaded at most once and cached on disk; subsequent runs (any state) load from disk.

--gazetteer accepts either a directory (the three files are stored inside it under their standard Census names) or a specific .zip (used as the Places file, back-compat; the other two are derived as siblings in the same directory).

  • Default location: ~/.civic_cache/ (e.g. ~/.civic_cache/2024_Gaz_place_national.zip, …cousubs…, …counties…).
  • Override with --gazetteer on scrape-sites, or set GAZETTEER_PATH.

Only the file(s) a given state needs are downloaded — a DEFAULT state fetches just Places; a New England state also fetches County Subdivisions; Hawaii and Puerto Rico fetch Counties. Only scrape-sites reads the gazetteer.

Homepage HTML (--cache-dir DIR)

scrape-sites saves each official homepage it fetched (keyed by geoid, as DIR/<geoid>.html); scrape-youtube and scrape-agendas read homepages from the same DIR instead of re-fetching — turning three homepage fetches per city into one. import also caches the homepage of each validated manual URL.

This cache is on by default: if you don't pass --cache-dir, it defaults to ~/.civic_cache/html/ (a flat html/ subdirectory alongside the gazetteer cache), so caching "just works" across runs. Files are stored flat — GEOIDs are globally unique, so no per-state subdirectories are needed. Override the location with --cache-dir, or set the CIVIC_CACHE_DIR environment variable.

Cache control flags on Stages 2 and 3:

Flag Effect
--cache-dir DIR Read homepages from DIR (and write on a miss).
--refresh Ignore the cache; re-fetch every homepage live (updates cache).
--max-age DAYS Treat cache entries older than DAYS as stale and re-fetch them (updates cache). Omit (or 0) = never expire.

Note: only homepages are cached. scrape-agendas still fetches each discovered agendas subpage live, since that URL isn't known until runtime.


All command-line options

Global: --db PATH (database to use, default civic.db) goes before the subcommand: python3 civic.py --db wa.db stats.

The three scrape-* commands share: --cache-dir DIR, --workers N (default 8), and a selection option for partial runs:

  • --limit N — the first N items (deterministic).
  • --sample N — N items chosen at random (sorted by geoid). Mutually exclusive with --limit.
  • --seed S — fix the random seed for --sample (reproducible subset).

scrape-sites

--state STATE     USPS code or full name. Required.
--gazetteer PATH  Gazetteer zip location (load if present, else download+save).
--cache-dir DIR   Save fetched homepages here for the youtube/agenda stages.

export (with population filters)

--state STATE     One state (default: all).
--needs FIELD     Only rows missing site / youtube / agenda.
--min-pop N       Only rows with population >= N   (applied live, no reload).
--max-pop N       Only rows with population <= N.
--tier XL,L,...   Only these size buckets (XL/L/M/S/XS).
--sort-pop        Order biggest-first (unknown population last).
--format FMT      Field delimiter: csv (default), psv (vertical bar '|'),
                  or tsv (tab). For psv/tsv the delimiter is also stripped
                  from values so every row splits cleanly for naive tools.
--psv             Shorthand for --format psv.
--out FILE        Destination file. Required.

Population thresholds are computed at query time from the stored number, so a different cutoff is just a different --min-pop — never a reload. Volunteer triage — biggest governments still missing an agenda, largest first:

python3 civic.py export --needs agenda --min-pop 25000 --sort-pop --out gaps.csv

Pipe-delimited (PSV) output for tools that prefer it — the | character is stripped from any field value first, so a scraped description containing a pipe can't shift columns:

python3 civic.py export --psv --out all.psv          # or --format psv

CSV output is plain, greppable text

Scraped fields (agenda_description, copyright_statement, …) come from arbitrary HTML and can carry control characters — CR (^M), shift-out/in (^N/^O), NUL. Left alone these make grep report "binary file matches" and break normal text tooling. Every CSV this pipeline writes is therefore sanitized: embedded CR/LF collapse to a space (so one row is always one physical line), other control bytes are removed, and lines end with a plain Unix \n rather than the CSV-spec \r\n. Values are otherwise preserved.

Data scraped before this was added is still dirty in the DB. Scrub it in place (no re-scrape needed):

python3 check_rescrape.py --sanitize-text         # dry run: what's affected
python3 check_rescrape.py --sanitize-text --yes   # scrub

adjudicate-dupes (who does this domain actually belong to?)

--state STATE    Limit to one state.
--report FILE    Full per-claimant scoring to CSV.
--clear-losers   Blank the non-owning rows (needs --yes).
--yes            Confirm the clear.

dupe-sites shows which rows share a domain; this answers who owns it. Each duplicated domain is fetched once and scored with the current verifier against every claiming place:

  • WINNER — one place verifies; the others are cleared and reopened for re-scrape.
  • NO-OWNER — nobody verifies (a generic site like liberty.org, or a real-estate/domain-sale host). All claimants are cleared.
  • TIE — several verify equally; left for a human.
python3 civic.py adjudicate-dupes --report adj.csv          # dry run
python3 civic.py adjudicate-dupes --clear-losers --yes      # apply
python3 civic.py scrape-sites --state TX                    # re-find the real ones

dupe-sites (find shared-domain false positives)

--state STATE  Limit to one state (default: all).
--top N        How many duplicated domains to print (default 40).
--report FILE  Write EVERY individual row behind each duplicate to CSV.

A domain used by more than one place is nearly always a false positive. The report assigns each a verdict:

  • REJECT-ALL — a known non-government host (real-estate portal, domain marketplace) or a domain-sale URL. Every row using it should be cleared.
  • REVIEW — a plausible municipal domain shared by several places (the "same city name, wrong state" case). One row probably owns it; the rest need clearing. The CSV includes population / pop_tier so a volunteer can see which place most plausibly owns the domain.
python3 civic.py dupe-sites                            # summary
python3 civic.py dupe-sites --report dupes.csv         # + every row, for review
# then clear the wrong ones:
python3 civic.py clear --geoid <geoid>                 # reopen for re-scrape
python3 civic.py clear --geoid <geoid> --lock          # keep empty

Progress reporting on long runs

Every stage reports [done/total], but the format adapts to the run length so a 16,000-row job does not bury its own signal:

  • Short run (<= 200 items): one line per item, as before.
  • Long run: a rolled-up counter line every 100 items, with rate and ETA.
[100/1200]   8% | confident=80  low=15 miss=2  unverified=3  | 85.1/s eta 12s
[600/1200]  50% | confident=535 low=35 miss=10 unverified=20 | 84.6/s eta 7s

--verbose forces per-item output on any run.

Redirected output is line-buffered. civic.py calls sys.stdout.reconfigure(line_buffering=True) at startup, so civic.py ... > run.log & shows progress in the log as it happens. Without it Python block-buffers to a pipe or file and a long run writes nothing until it exits — which reads exactly like a hung process. (Observed: seven minutes of silence from a wiki import that was working fine.) You do not need python3 -u.

Review worksheets (make_*_review.py / harvest_*_review.py)

Two markable-text workflows for decisions the automatic signals cannot settle. Both follow the same conventions as the YouTube worksheets: one URL per line, alone and last, boxes pre-marked with a recommendation, and a --check pass before anything is written.

python3 make_site_review.py                     # sites reverify scored below the floor
python3 harvest_site_review.py --check          # counts / conflicts / unmarked
python3 harvest_site_review.py --apply --yes

python3 make_dupe_review.py                     # one decision per SHARED DOMAIN
python3 harvest_dupe_review.py --check
python3 harvest_dupe_review.py --apply --yes

FIX -> <url> beats every box. If you had to look a town up to decide, record what you found instead of clearing the row and waiting for a later scrape to rediscover it. A FIX is written verbatim as match_confidence='manual', score 9 — never fetched, never validated, never second-guessed. A URL a human opened in a browser is ground truth; a fetch failure here is a fact about this scraper, not about the site.

An unmarked row does nothing. Unlike the YouTube flow (where unmarked meant import), silence never clears a site — the destructive direction requires an explicit mark.

make_dupe_review.py decides per domain, not per row, with three verdicts:

verdict meaning
KEEP-ALL same government (New England place+cousub), or genuinely shared (Mapleton ME hosts Castle Hill and Chapman)
KEEP-MARKED at most one claimant owns it; tick the owner, the rest are cleared
CLEAR-ALL nobody owns it — a county district page, a federal research station, a vendor

A shared domain is not a duplicate. 36 of 71 shared domains were coterminous New England pairs — one government appearing as place/city and cousub/town. Clearing the "loser" would blank one row of every New England city.

reverify --revalidate-unreachable

manual-unreachable is otherwise a terminal state — reverify skips MANUAL_STATUSES and needs=site excludes them, so nothing ever looks again. This re-tests rows that still hold a URL and promotes the responders to manual. On first run it recovered 18 of 18 live sites (Pomona, Merced, Tulare, Richland) that a single failed fetch had mislabelled months earlier. Rows with a blank site are left alone — those are deliberate "no site exists" decisions wearing the same label.

See docs/LESSONS_LEARNED.md §9-§12.

reverify (re-score existing matches)

--state STATE     Limit to one state (default: all non-manual matched rows).
--yes             Write updated confidence/scores (default: dry-run report).
--clear-failed    With --yes: clear rows now below the 'low' floor —
                  site + derived youtube/agenda fields blanked, cache entry
                  removed, row reopened for re-scrape. Manual rows are
                  never touched.
--refresh / --max-age DAYS   Homepage cache staleness controls.

Verification logic improves over time, but scrape-sites only visits rows with a blank site — old matches are never re-examined. reverify re-scores every already-matched site with the current verifier, reading homepages from the cache (live fetch only on a cache miss), and reports upgrades, downgrades, and outright failures (e.g. wrong-state false positives caught by newer checks). Typical flow after a verifier improvement:

python3 civic.py reverify --state WA              # dry run: see what changes
python3 civic.py reverify --state WA --yes --clear-failed
python3 civic.py scrape-sites --state WA          # re-discover cleared rows

load-backflush-towns (surgical township add)

--in FILE     CSV with state + city_name columns — typically the
              <report>_unmatched.csv from import-backflush.
--gazetteer P Gazetteer cache location (dir or .zip).
--yes         Add the rows (default: dry-run report).

For the township-belt states (NY/PA/MI/NJ/WI/…), a back-flush "Town of X" row often has no match because only the places layer was loaded, not the County Subdivisions (cousub) layer where townships live. Loading a whole state's townships would add thousands of mostly-unwanted rows, so this command does it surgically: it treats the unmatched list as a whitelist and adds only those specific towns, looked up in the cousub gazetteer. Townships parse as place_type='town', source='cousub'. Rows not found in the cousub file (CSV spelling variants, non-cousub places) are reported and skipped. Typical A-pass flow after an import-backflush dry run:

python3 civic.py load-backflush-towns --in bf_review_unmatched.csv         # preview
python3 civic.py load-backflush-towns --in bf_review_unmatched.csv --yes   # add towns
python3 civic.py import-backflush --in civic_backflush_2026-05-26.csv --yes # attach channels

import-backflush (CMA channel back-flush)

--in FILE   CMA back-flush CSV (dircode, state, city_name, org_name,
            video_channel, channel_platform, ia_collection_url, in_cma, …).
--yes       Apply (default: dry-run report).
--report F  Write the FULL unmatched + ambiguous + resolved lists to
            F_unmatched.csv / F_ambiguous.csv / F_resolved.csv. Unmatched rows
            carry a fuzzy 'did_you_mean' nearest-city suggestion.
--apply-disambiguated
            Also apply rows the org-name rule resolves ('City of X' picks the
            city row over a same-named town/village/cousub; place-source is the
            tiebreak). Needs --yes. Rows the rule can't resolve stay ambiguous.

Imports archive-verified video channels so onboarding volunteers don't re-derive them. Filters (case-insensitive): rejects any org_name containing Library / School / University / College; requires a municipal signal in org_name — city or town, or a consolidated-government word (consolidated / metro / metropolitan / unified / municipal) so consolidated city-counties (Augusta, Nashville, Louisville, Athens…) match. Plain counties/parishes/boroughs — not loaded outside HI — are still excluded. Matching normalizes names, so a backflush "Augusta" reaches the places-file row "Augusta-Richmond County consolidated government (balance)". Matches by (state, city_name); ambiguous city+state (more than one row) is skipped and reported. Stores video_channel + channel_platform + channel_source='cma_backflush', keeps youtube_channel in sync for YouTube rows, and records in_cma / cma_collection_url. Imported channels are locked (the crawler skips youtube_source='cma_backflush') and never clobber an existing or manually-locked channel (fill-only-where- empty). Dry-run first to see the filter/match breakdown:

python3 civic.py import-backflush --in civic_backflush_2026-05-26.csv        # preview
python3 civic.py import-backflush --in civic_backflush_2026-05-26.csv --yes  # apply

import-wiki-sites (Wikipedia official-site back-flush)

--in FILE          wiki_official_site.py output CSV (geoid-keyed).
--state XX         Limit to one state.
--yes              Apply (default: dry-run report).
--confident-only   Apply only strong 'confident' place-matches; the rest go to review.
--strict           Use the scraper's floor (confident/low only; name-only -> review).
                   Default TRUSTS the wiki link and keeps name-only matches.
--report F         Write F_applied.csv and F_review.csv.
(also --cache-dir / --workers / --limit / --sample / --seed — shared scrape args)

Back-flushes the sites wiki_official_site.py found into places that are still site-blank (fill-only; never clobbers an existing or manual value). A Wikipedia official-site link is editorially curated — far better than the scraper's URL-guessing — so the URL is trusted unless there is negative evidence: each candidate is fetched and scored with the same place-match verifier scrape-sites uses, but only a parked/real-estate page or a strong wrong-state signal is rejected. The scorer's "name-only, no state evidence" case is kept (tier namematch) rather than thrown out — that is exactly the good small-town site Wikipedia handed us. A URL civic_common can't re-fetch is still kept when the wiki tool already verified it (verified) or when it answered a bot-block code like 403/406/5xx (blocked — the host is live); only a 404 or dead connection lands in unreachable review. Applied rows are tagged match_confidence='wiki-<tier>' (fill-only) and their homepage cached for the youtube/agenda stages. Dry-run first, then apply and adjudicate duplicates:

python3 civic.py import-wiki-sites --in wiki_official_sites.csv --state WA --report /tmp/wa  # preview
python3 civic.py import-wiki-sites --in wiki_official_sites.csv --yes --report /tmp/wiki     # apply
python3 civic.py dupe-sites          # catch shared / regional URLs
python3 civic.py adjudicate-dupes

load-population (opt-in, run once)

FILES...          One or more 2020 Decennial population files (place / county
                  P1 tables; CSV or pipe/tab-delimited; prefixed GEOIDs OK).
--threshold N     Population at/above which pop_over_threshold='yes'
                  (default 10000).
--yes             Write the values (default: dry-run report).

Joins population onto existing rows by GEOID — it never adds places, so run it after your places exist. It matches any geography whose GEOID is in your DB, so to cover incorporated places and New England cousub towns and counties, load all three 2020 Decennial P1 files (Place, County Subdivision, County); the loader keys purely on GEOID, so each row is filled from whichever file contains it.

Only the raw population number and its pop_tier bucket are stored. Any population threshold is applied at retrieval time, not baked in at load — so you never reload to change a cutoff (see export --min-pop below). Get the files from data.census.gov (2020 Decennial table P1, "Total Population", for geographies Place and County), or the Census API. The loader auto-detects the GEOID and P1_001N columns and strips the 1600000US… / 0500000US… prefixes. Dry-run shows the tier distribution and how many rows clear the threshold before writing.

Volunteer-triage example — export the biggest cities still missing an agenda:

python3 civic.py load-population place_P1.csv county_P1.csv --yes
python3 civic.py export --needs agenda --out gaps.csv
#   then sort/filter gaps.csv by pop_tier / pop_over_threshold

scrape-counties (opt-in county layer)

--state STATE     One state's counties (mutually exclusive with --all-states).
--all-states      Load counties for every state.
--gazetteer PATH  Gazetteer cache location.
--cache-dir DIR   Save fetched homepages here.
(also supports --limit / --sample / --seed / --workers)

scrape-youtube / scrape-agendas

--state STATE     Limit to one state (default: every state in the DB).
--cache-dir DIR   Read homepages from cache instead of fetching.
--refresh         Re-fetch homepages live, ignoring the cache.
--max-age DAYS    Re-fetch cached homepages older than DAYS.

scrape-youtube also:
--dry-run         Detect channels but DON'T write the DB. Writes a review CSV
                  instead (see --report), one row per proposed channel with its
                  video count, resolved handle/title, and a review_flag:
                    OK-STATE-CONFIRMED   channel's page names this state
                    REVIEW-UNCONFIRMED   names no state — eyeball it
                    REVIEW-WRONG-STATE?  names another state — probably wrong
--report FILE     Where --dry-run writes the CSV (default youtube_dry_run.csv).
--include-siteless
                  Also try rows with NO official_site (default: skip them).

--include-siteless. Stage 2 has two detection paths: read the official site's homepage/subpages, and — when that finds nothing — search for the channel by name. Only the first needs a site. The two were coupled when scraping the homepage was the only method, and the search fallback, added later, inherited a precondition it never needed. The result was that 5,012 rows could never be reached by a channel search at all, including Chula Vista, Gilbert AZ, North Las Vegas and Richmond VA.

The flag drops that filter (11,407 → 16,419 candidate rows). The added rows are 96% XS with a median population of 222, so the hit rate is low — pair it with a population or tier bound rather than running it across all 5,012. On the five largest site-less rows it found two real municipal channels: Gilbert AZ ("Gilbert, Arizona", 1,900 videos) and Richmond VA ("Richmond Virginia", 995).

See docs/2026-08-22-site-coverage-analysis.md for the measurement.

The review CSV is directly importable: geoid, youtube_channel, and youtube_source are real columns, the rest are review-only. Delete the rows you reject, then import the file to apply the good ones — a dry-run first, commit second workflow that avoids a bulk back-out.

redirect-youtube (prototype)

--state STATE   Limit the SOURCE places searched to one state.
--dry-run       Required. Write a CSV of proposed redirects; never writes the DB.
--report FILE   Where the CSV is written (default youtube_redirects.csv).

A search for one place often surfaces the real, active channel of a different same-named place ("City of Merrill", WI shows up while searching Merrill, OR). Rather than only rejecting it, redirect-youtube redelivers it to the place it belongs to — a blank, unlocked, same-name row in the DB — disambiguated by one of: a distinct name (only one place in the country carries it, e.g. Oakland Park → FL), an explicit state named in the channel, or (LOW confidence) a state code in the handle. Each proposal carries a match_basis and confidence; the CSV is importable like the --dry-run one (review, delete, import). The Census geoid is what makes this safe — it turns "same name" into an exact place identity. Prototype: dry-run only, and it only redelivers government channels (a newspaper or church belongs to no civic slot); genuinely ambiguous cases (a common name with no state signal) are left out rather than guessed.

export

--state STATE     Limit to one state (default: all).
--needs FIELD     Only rows missing this field: site | youtube | agenda.
--out PATH        Output CSV path. Required.

import

--in PATH         CSV to import (must have a geoid column). Required.
--cache-dir DIR   Cache homepages of validated manual URLs here.
--workers N       Concurrency for manual-URL validation.

list-manual / stats

list-manual [--state STATE]   Print manual / manual-unreachable rows.
stats                         Per-state coverage summary.

Typical workflows

Process several states into one database, gazetteer downloaded once:

for st in WA OR CA; do
  python3 civic.py scrape-sites --state $st \
      --gazetteer ~/civic/gaz.zip --cache-dir ~/civic/cache
done
python3 civic.py scrape-youtube --cache-dir ~/civic/cache   # all states
python3 civic.py scrape-agendas --cache-dir ~/civic/cache   # all states
python3 civic.py stats

Refresh a months-old dataset cheaply (only re-fetch homepages older than 30 days; everything fresh is reused):

python3 civic.py scrape-youtube --cache-dir ~/civic/cache --max-age 30
python3 civic.py scrape-agendas --cache-dir ~/civic/cache --max-age 30

Test on a handful of cities first:

# First 10 (deterministic):
python3 civic.py scrape-sites --state WA --limit 10

# A random 10, reproducible via the seed (unbiased spot-check across the
# whole alphabet rather than just A-named cities):
python3 civic.py scrape-sites --state WA --sample 10 --seed 42

Politeness & good citizenship
  • Requests are rate-limited per host and run under a worker pool, so unrelated city sites are hit concurrently but no single host is hammered.

  • Set a real contact address in the USER_AGENT string in civic_common.py before large runs.

  • Consider checking each site's robots.txt. These are public .gov sites, but courtesy still applies.

  • Identify honestly first; escalate only when refused. Every request goes out under the plain USER_AGENT. Only after a site has explicitly refused us — a 401/403/429, or a body carrying a bot-mitigation challenge signature — do we retry with the full browser header set (BROWSER_HEADERS: Sec-Fetch-*, sec-ch-ua, a real Accept). Sites that don't block us never see anything but the honest agent.

    This matters more than it used to. Municipal sites increasingly run enterprise bot-mitigation, and a User-Agent alone does not satisfy Akamai — it answers with a ~400-byte "Access Denied" stub. Because a WAF stub and a 404 stub are both short, the old "small body = error page" heuristic threw live sites away as unreachable: measured over 92 blocked candidates, UA-only recovered 16 while the full header set recovered 44. Chula Vista went from a 412-byte stub to 296 KB of real page.

    _try_url also upgrades a failed http:// candidate to https://, and records why a fetch failed — last_fetch_failure() returns blocked | dns | dead | error. Callers previously saw only (None, None) and could not distinguish "this town has no website" from "this town's WAF refused us": opposite facts, one a finding about the world and one a blind spot in our own tooling.


Extending

civic_common.py still has one stub you can implement to raise hit rates:

  • search_fallback(query) — return a best-guess official-site URL from a search API (used by Stage 1 when URL guessing fails). Returns None by default, so Stage 1 runs without API keys.

The Stage-2 youtube_search_fallback(place_name, usps, place_type) is now implemented (no API key needed): it scrapes YouTube's channel-filtered results page, parses the embedded ytInitialData, scores candidates on name / state / civic-vocabulary / municipal-handle-pattern signals, and requires government evidence before trusting a hit. detect_youtube consumes the ranked list via _youtube_ranked_channels(...) and verifies down it, preferring a channel whose page confirms the target state. On Oregon's hand-verified set it reproduces ~94% of manually-found channels (and the misses are places whose channel simply doesn't surface for the query, not bad guesses). Because it scrapes an undocumented response shape, keep it behind the homepage/subpage scans (its natural place) and expect to re-check the ytInitialData parser if YouTube changes their markup.



cma_onboard.py — onboarding new channels into the Community Media Archive

civic.py answers "which places publish?". cma_onboard.py answers the next question: which of those are not yet in the Community Media Archive, and what should their collections be called? It reads civic.db, picks the cohort, mints each place a free mnemonic collection identifier, and writes the spreadsheets the ia CLI consumes.

It creates nothing on archive.org. It only reads (?mode=ro on every database) and writes local files for you to review.

Standard library only, Python 3.8+, and it runs unchanged on Windows, macOS and Linux. The internetarchive Python library is used if you pass --use-ia-lib and it imports; otherwise availability checks go over plain HTTPS.

What "new" means

A place is a candidate when it has a video channel and is not already preserved. Four exclusions, cheapest first:

Excluded when Source
in_cma = 'yes', or cma_collection_url set, or channel_source = 'cma_backflush' civic.db (written by import-backflush)
its channel is already tracked by the fleet collector.db channels.channel_id
its channel exists only in an un-imported --reports CSV the review queue — see below (--reports-as-source opts in)
the match isn't trusted yet review_flag / youtube_source gate (below)
you filtered it out --state / --platform / --min-videos / --min-population / --limit

The confidence gate. By default only channels the project already trusts are onboarded: a dry-run review_flag of OK-STATE-CONFIRMED, or a youtube_source of homepage / manual. An unreviewed search hit is not auto-onboarded — that's the "never auto-onboard a low/unverified match" rule from civic-data-integration.md, enforced in code. Widen it deliberately with --include-unconfirmed, --accept-flag, or --any-flag.

The slug convention

The identifier is minted, not chosen by hand, and it obeys one contract:

4–11 characters, [A-Za-z0-9] only, the 2-character state code in the last two positions — followed by a single v when the channel is Vimeo.

That contract was derived from the 749 existing CMA collections recorded in civic.db; 96% of them already comply, and the generator reproduces 25 of 25 hand-checked real slugs (median rank: 1st candidate).

[type prefix][name mnemonic][state][v if vimeo]

cormd     City of Rockville, MD          co + r    + md
tobme     Town of Brunswick, ME          to + b    + me
coscia    City of Sioux City, IA         co + sc   + ia
cowcca    City of Walnut Creek, CA       co + wc   + ca
tosmyrtn  Town of Smyrna, TN             to + smyr + tn
clvrdca   Cloverdale, CA                      clvrd + ca
hemetca   Hemet, CA                           hemet + ca
copoflv   City of Port Orange, FL (Vimeo) co + po + fl + v

For each place the generator emits a ranked ladder of contract-valid candidates — co+initials first (the house style), then the bare name, then consonant skeletons, then digit-disambiguated variants — and takes the first one that is actually free. Preview a place's ladder any time:

python3 cma_onboard.py slug "Walnut Creek" CA --place-type city
python3 cma_onboard.py slug "Smyrna" TN --place-type town --check   # ask IA too

Proving the identifier is free

This is the part that matters: a collection identifier must not exist at the moment it is created. Two gates:

  1. Local — every ia_collection in collector.db (~2,000), every cma_collection_url in civic.db (~750), anything in a --reserve file, and everything minted earlier in the same run. Free, catches the common case, and guarantees the batch is internally unique.
  2. Remote — the metadata API first, then archive.org's own check_identifier service. An identifier counts as free only when both agree; anything ambiguous comes back unknown and is refused, so a single endpoint hiccup can never read as "free".

That order is a deliberate optimisation. check_identifier answers in ~0.6 s when an identifier is taken but takes **~11 s** when it is available, while the metadata API answers in ~0.4 s either way. Testing metadata first means the slow call happens roughly once per slug actually minted, rather than once per candidate tried as the ladder falls through. In practice a 12-collection cohort cost 25 archive.org calls.

Three speed/safety settings:

Flag Network Slug status Use for
(default) both gates available normal planning, any cohort you intend to create
--fast-check metadata API only available sizing a large cohort quickly; verify still runs both gates before creation
--offline none UNVERIFIED-OFFLINE counting/shaping a cohort with no network — never safe to create from

Results are cached to <out-dir>/.ia_identifier_cache.json, so re-planning a cohort is nearly free.

Work state by state. Checks are sequential and paced by --delay — good citizenship toward archive.org, the same discipline the scrapers follow. --state is also the natural review unit: a manifest you can actually read before consenting to it.

Budget the time — this is slow by nature, not broken. archive.org answers "is this identifier free?" in ~10-25 s when the answer is yes, and it must be asked once per slug actually minted. So:

cohort rough wall-clock
one small state (4-12 places) 1-5 min
one large state (45-150 places) 10-40 min
the whole national cohort many hours — don't

A real VT run: 45 collections in ~11 minutes. The tool prints one line per place with elapsed time and an ETA, so you can watch it move. Ctrl-C is safe — the availability cache is flushed every 10 places and again on exit, so a re-run skips everything already checked.

Because identifiers can be claimed by anyone between planning and creation, run verify immediately before you create:

python3 cma_onboard.py verify --manifest onboard/VT/cma_onboarding_manifest.csv

It re-checks every slug, re-validates the contract, catches duplicates, and exits non-zero if anything is taken, invalid, or unverifiable.

What it writes

File For
cma_onboarding_manifest.csv The review artifact. One row per proposed collection: slug, place, channel, title, description, and the candidates that were rejected. Delete a row to reject it — the row is the unit of consent.
ia_collections_upload.csv ia upload --spreadsheet= — identifier, file, mediatype=collection, collection[0..2], title, description, subject[0..1], …
ia_collections_metadata.csv ia metadata --spreadsheet= — same metadata, no file; for fixing collections that already exist.
dirname_collection_map.psv set_collections.py on the collector (dirname|ia_collection).
collector_channel_seed.csv channel ↔︎ collection ↔︎ metadata_dir triples for fleet onboarding.
cma_onboarding_skipped.csv Everything left out, with the reason.
NEXT_STEPS.txt The exact commands for this cohort, in order.

The collection metadata matches the existing CMA collections exactly — mediatype=collection, parents community_media / newsandpublicaffairs / movies, collectionsource=patron, subject = "Government Access Television" + the state name, and the house-style description:

This collection is an archive of the "Town of Smyrna" Youtube channel: https://www.youtube.com/@townofsmyrna<br>The intent is that it will be updated roughly weekly.

--make-start-files writes a placeholder start-file.txt per collection and points the sheet's file column at it — IA needs at least one file to bring an item into existence, which is exactly how the existing collections were created.

The slug is also the directory name

collector.db stores metadata_dir = <root>/<ia_collection> for every channel — the identifier and the on-disk directory name are the same string. Create the tree from a manifest:

python3 cma_onboard.py make-dirs --manifest onboard/VT/cma_onboarding_manifest.csv \
    --root ~/youtube-archive/metadata --dry-run

Full example

# plan one state, trusting only state-confirmed channels
python3 cma_onboard.py plan \
    --db ~/city-county-civic-scans/v10/civic.db \
    --collector-db ~/datasette-project/collector.db \
    --reports "reports/youtube_*.csv" \
    --state VT --make-start-files --out-dir onboard/VT

# review onboard/VT/cma_onboarding_manifest.csv, delete rows you reject
# re-prove the identifiers, then create
python3 cma_onboard.py verify --manifest onboard/VT/cma_onboarding_manifest.csv
ia upload --spreadsheet=onboard/VT/ia_collections_upload.csv

--reports is what makes the descriptions good: the scrape-youtube --dry-run review CSVs carry channel_title, video_count and review_flag, none of which are in places. The globs are opened read-only — the tool never writes into reports/, so it is safe to point at a sweep that is still running.

Reports enrich; they do not, by default, recruit. A dry-run CSV routinely proposes a channel for a place whose places.youtube_channel is still empty — that is the whole point of the review queue (review → delete rejects → civic.py import). Onboarding straight from those rows would create IA collections ahead of the human review, so by default such a place is skipped with reason channel-not-yet-imported, and the CSV is used only to enrich places whose channel is already in civic.db.

Pass --reports-as-source to opt in deliberately — worth doing only for states you have already reviewed. On the current data the difference is large:

cohort skipped channel-not-yet-imported
default 2,160 1,402
--reports-as-source 2,596 0

Closing the loop

Once the collections exist, write back to civic.db so those places stop appearing as new — set in_cma='yes' and cma_collection_url='https://archive.org/details/<slug>' per geoid (import-backflush is the supported route). Until you do, a re-plan will propose them again — though it will mint different slugs, because the first ones are now taken.

All command-line options

plan        --db PATH                civic.db (read-only). Source of youtube_channel.
            --collector-db PATH      already-onboarded channels + known-taken identifiers
            --archive-db PATH        more known-taken identifiers (dirname)
            --reports GLOB           scrape-youtube --dry-run CSVs (repeatable, read-only)
            --reports-as-source      also onboard channels that exist only in a
                                     --reports CSV and are not yet imported
            --reserve FILE           extra identifiers to treat as taken (repeatable)
            --out-dir DIR            where the spreadsheets go (default ./onboard)
            --state ST               limit to one state
            --platform youtube|vimeo
            --limit N                cap the cohort (highest population first)
            --min-videos N           needs --reports
            --min-population N
            --accept-flag FLAG       review_flag values to accept (repeatable)
            --include-unconfirmed    also accept REVIEW-UNCONFIRMED
            --any-flag               accept everything (review the manifest!)
            --parent A,B,C           parent collections
            --metadata-root PATH     recorded in the manifest's metadata_dir
            --make-dirs ROOT         also create ROOT/<slug>/ now
            --make-start-files       placeholder file per collection
            --no-hidden              omit the hidden=true column
            --fast-check             metadata API only (much faster; verify still full)
            --offline                no network; slugs marked UNVERIFIED-OFFLINE
            --cache PATH             availability cache
verify      --manifest FILE [--db PATH] [--collector-db PATH] [--reserve FILE]
check       IDENTIFIER [IDENTIFIER …]
slug        CITY STATE [--place-type T] [--platform P] [--top N] [--check]
make-dirs   --manifest FILE --root DIR [--dry-run]

network (plan/verify/check/slug): --delay SECS (default 0.5) --timeout SECS
            --retries N --use-ia-lib --verbose


youtube_triage.py — triaging the review queue

scrape-youtube --dry-run proposes channels; a human decides. On the 48-state sweep that was 1,348 rows flagged for review — days of work, most of it repetitive. This tool doesn't decide correctness; it sorts proposals by how they can be decided, so attention goes where judgement is actually needed.

python3 youtube_triage.py --reports "reports/youtube_*.csv" --out-dir triage
python3 youtube_triage.py --reports "reports/youtube_*.csv" --apply   # strip AUTO-REJECT

Runs in 0.3 s over 2,000 proposals. Writes <ST>_triaged.csv per state plus _auto_reject.csv, _group_decide.csv, _needs_review.csv, _name_match.csv.

tier rows share what to do
AUTO-REJECT 19 0.9% delete unread
GROUP-DECIDE 202 9.9% 202 rows → 90 group decisions
NEEDS-REVIEW 199 9.8% real judgement
NAME-MATCH 1,620 79.4% skim (entity risk only)

GROUP-DECIDE is the biggest win. One channel proposed for several places — "City of Mount Vernon" for Mount Vernon in GA, IN, MO and OH — is one decision, not four investigations. At most one place can be right.

AUTO-REJECT is deliberately tiny. Only two rules are trusted to delete unread: a wrong entity (civic_common's own reject-entity logic — a dealership, chamber, EDC, hospital, church, school) and a longer real place that contains this one: Belle ← "City of Belle Meade", Hoyt ← "City of Hoyt Lakes", Eden ← "City of Eden Prairie", Freedom ← "New Freedom Borough".

What is deliberately NOT auto-rejected

Three rules were tried and pulled back, each after finding a false positive that would have deleted correct data:

  • "names a different place" → demoted to NEEDS-REVIEW. It fires on ordinary words that are also place names (Media PA, Brandon MN, Wright WY) and produced a real casualty: Kansas City, KS ← "Unified Government of Wyandotte County" — which is the correct channel, because KCK is that unified government.
  • <place> + a civic word → excluded. Knox, ME ← "Knox City Council" matched Knox City, MO; Murray City Live matched Murray City, OH. Usually it's this place plus its own institution, not a different municipality.
  • bare inc / llc / corporation in the entity list → removed. PEG stations are routinely incorporated nonprofits (Stoughton Media Access Corporation, Granby Community Access & Media, Inc.) and are exactly what the CMA archives. Narrower area development / development corp is used instead — false-positive rate against 1,589 known-good CMA channel names: 0.13%.

NAME-MATCH is not an import instruction. "The title names this place and the channel is proposed nowhere else" is strong but not proof — the residual risk is entity type, which reads instantly from a title. Validated against 17 ground-truth cases (channels the survey proved wrong, and rows known correct): 0 leaked into NAME-MATCH, 0 wrongly auto-rejected.

cma_survey.py — sequencing the download queue

cma_onboard.py decides who is eligible and mints their slug. cma_survey.py decides what order the workers actually do the work in, by looking at what is really on each channel before a single byte is downloaded.

python3 cma_survey.py --manifest onboard/VT/cma_onboarding_manifest.csv \
    --out-dir survey

Downloads nothing — it fetches channel listings only. Stdlib + yt-dlp, Windows/macOS/Linux. Re-running skips channels already surveyed, so a state can be resumed or a single channel redone (--redo --slug tomivt).

Why a separate program

It shares no code path with identifier minting and shouldn't: different remote host (YouTube vs archive.org), different politeness budget, different failure modes, and this one will change often as the sequencing is tuned. The manifest CSV is the contract between them — cma_onboard.py writes it, this reads it.

Four questions, all from cheap metadata

1 · Which titles collide. The pipeline turns a video title into an IA identifier, and title-derived failures are the known top cause. This models the real transform and flags two distinct problems:

  • duplicate titles — two videos literally named the same thing;
  • identifier collisions after truncation — strictly worse and easy to miss: IA identifiers cap at 100 characters, so two different 90-character titles can truncate to the same identifier.

Both land in needs_date_suffix, which is your "append a date to disambiguate" worklist.

The transform was derived empirically against 1,286 real (title, identifier) pairs from the codtx corpus in ~/youtube-archive/metadata/ and reproduces them 97.7% exactly. Rules: : and en/em dashes → _-_; @ → at; quotes and !? deleted; any other illegal character → _; runs of _ collapsed; ends trimmed; prefix <slug>-; truncate to 100. The residual 2.3% is human renames and titles edited on YouTube after upload, not transform error. If the real rule differs, fix derive_identifier() — everything else follows from it.

2 · Which channels have no meetings at all. Government channels that carry only promos and talk shows aren't CMA work. Titles are matched against a word-boundary vocabulary (council, selectboard, board of …, commission, planning and zoning, public hearing, work session, town meeting, …; extend with --meeting-words). A channel with zero hits is marked NO_MEETINGS and scored out of the queue rather than silently ranked low.

The vocabulary is the measurement instrument for meeting_hours, so it is derived from the existing CMA corpus (368,291 real titles in ~/youtube-archive/metadata/youtube_metadata.db) rather than guessed — and re-evaluated offline with --rescore --revocab, never by re-surveying. The method, including the closed-compound trap that once cost 945 meeting-hours, is §3.5 of the runbook.

3 · Dead vs active. Days since the most recent upload on either tab, banded ACTIVE ≤90d · SLOWING ≤365d · STALE ≤3y · DEAD beyond.

Two dates, deliberately. --flat-playlist returns no date at all unless youtubetab:approximate_date is passed, and that date is back-computed from YouTube's relative label, so it drifts badly with age — measured on real VT channels: 148 days off for Williston, 200 days for Barre. Good enough for ordering, useless for a dead/active call. So the tool also makes one cheap RSS request (feeds/videos.xml) for an exact timestamp of the latest upload. latest_upload_exact drives the banding; latest_upload_approx is kept beside it so the drift stays visible. --no-rss skips it.

4 · How big the job is. Video count split by tab, and total hours summed from the free duration field — so a worker can be handed a week of work instead of a surprise.

Both tabs

/videos and /streams are separate YouTube tabs and the bare channel URL does not reliably union them. Government channels routinely livestream meetings, so the survey fetches both and reports n_videos / n_streams separately. A channel whose meetings are all on /streams looks empty if you only ask for /videos.

Output

survey/
├── survey_summary.csv                  # every channel, all states, ranked
└── VT/
    ├── _channel_summary.csv            # this state, ranked + tiered
    ├── _channels_with_duplicate_titles.csv
    ├── tomivt_youtube_channel_flat_playlist_output.csv   # per video
    └── tomivt_duplicate_titles.csv                       # just the collisions

The per-channel CSV is one row per video: tab, position, id, title, duration, views, url, approximate date, is_meeting, plus derived_identifier, identifier_len, identifier_truncated, dup_title_count, identifier_collision_count and needs_date_suffix.

Priority, tuning, and capacity → see the runbook

priority_score ranks the queue; every input is a column beside it. The default objective is most hours of civic record preserved:

score =  2.0 × log10(meeting_hours + 1)      # the objective
       + 0.0 × log10(residents + 1)          # off: reach is a different goal
       + 0.8 × risk(activity)                # DEAD 1.0 · STALE 0.7 · ACTIVE 0
       - 0.5 × (items_needing_date / videos) # known upload-failure cause

meeting_hours is summed duration of meeting-like titles, so 40 three-hour council meetings outrank 200 ninety-second clips. status of EMPTY, UNREACHABLE, BAD_URL, NO_MEETINGS or DUPLICATE_CHANNEL scores negative and is tiered SKIP; the rest are cut into quartile tiers A–D.

Re-rank without re-fetching:

python3 cma_survey.py --manifest onboard/VT/cma_onboarding_manifest.csv \
    --out-dir survey --rescore --weight-urgency 0.4      # VT: 0.096s

Choosing weights, reviewing bad matches, sizing disk, and deciding when to spin up a worker on a new IP all live in docs/CMA_ONBOARDING_RUNBOOK.md. That is the document to read before sequencing a state — this section is only the mechanism.

Always pass --collector-db

Without it the queue will recommend re-downloading channels the fleet already has. civic.db's in_cma is partial by construction — only "City of" and "Government Access Television" collections were back-flushed into it, so PEG stations (catalogued as "Public Access Television") are archived but invisible there. And cma_onboard.py's offline check can only match a canonical /channel/UC… URL, so a /user/<name> or youtu.be/<id> URL silently passes as "new". In the VT pilot that put Brattleboro Community TV (brattvvt) and MCTV (mctvvtv) at ranks 1 and 2 — 62% of the state's ranked hours, all already preserved. The survey resolves the real channel_id, so it is the first place the question can be answered; matches are marked ALREADY_IN_CMA and excluded.

Review flags that stop a collection being created

The survey's main value is catching what onboarding's confidence gate could not. From the 45-channel VT pilot: a Kia dealership matched to Berlin (it passed OK-STATE-CONFIRMED because its name contains "Vermont"); youtube.com/privacy recorded as Londonderry's channel; three town/village slug pairs sharing one channel_id (which would create two collections fed by one channel); and the state's single most valuable channel — 5,474 meeting-hours of Brattleboro Community TV — attributed to the town of Marlboro.

So the survey emits BAD_URL, DUPLICATE_CHANNEL, NO_MEETINGS and a name_match column, resolves stray youtu.be/<id> links to their owning channel, and keeps the largest-population claimant when two slugs collide. Read name_match = no sorted by meeting-hours — regional stations legitimately don't carry their host town's name, so it is a review flag, not a rejection.

Working the GROUP-DECIDE queue

One channel claimed by several places is one decision, not N investigations. make_group_worksheet.py renders those groups as a markable text file; put an x in one box per group (KEEP beside the owning place, or REJECT ALL), then harvest_worksheet.py writes your marks into triage/_group_decisions.csv and the worksheet re-renders with your calls on top.

python3 make_group_worksheet.py     # render
$EDITOR triage/_group_decide_worksheet.txt
python3 harvest_worksheet.py        # marks -> CSV  (--dry-run to preview)

Lines carry g=<geoid> because several groups have two candidates in the same state (Waterbury CT city + town, Oakwood OH ×3) where a state-level answer cannot express the decision, and KEY= so a decision survives re-rendering. Full procedure and cautions: runbook §1.7.

--prune-onboarding — take the excluded rows back out

Surveying happens after cma_onboard.py has already written its spreadsheets, so a channel the survey excludes is still sitting in them. Removing it by hand is a trap: the slug appears in five artifacts, and the one that matters is not the one you would think to edit.

artifact key column why it matters
cma_onboarding_manifest.csv slug the human review artifact
ia_collections_upload.csv identifier what ia upload actually reads
ia_collections_metadata.csv identifier later metadata fixes
collector_channel_seed.csv ia_collection fleet hand-off
dirname_collection_map.psv column 0 set_collections.py

Editing only the manifest yields a clean-looking review file that still creates the collections. So prune all five together:

python3 cma_survey.py --manifest onboard/VT/cma_onboarding_manifest.csv \
    --out-dir survey --rescore \
    --collector-db ~/datasette-project/collector.db \
    --prune-onboarding onboard/VT --prune-dry-run   # preview, then drop the flag

Defaults to pruning ALREADY_IN_CMA only — the rows that would create a duplicate collection for something already preserved. That is the one case that is purely mechanical. --prune-status BAD_URL --prune-status DUPLICATE_CHANNEL extends it; NO_MEETINGS and name_match = no are deliberately not default, because they are judgment calls that belong to the human review pass.

Reversible and auditable: originals are copied to DIR/pruned_backup/ and every removed row is logged to DIR/pruned_rows.csv with its reason. The Vermont run removed 10 rows across 5 files for 2 slugs (tomavt → brattvvt, tomivt → mctvvtv), leaving 43 rows in each artifact.

NEXT_STEPS.txt is not pruned — it is generated narrative, not an input to any tool, so it goes stale rather than dangerous. Re-running cma_onboard.py plan regenerates it (and re-checks the surviving identifiers).

Failure honesty

yt-dlp exits 0 with valid JSON and an empty entry list when an extractor breaks, which is indistinguishable from an empty tab. The survey carries the same guard as the worker's enrich_missing.py and reports EXTRACTOR REGRESSION? in the channel's errors column rather than recording a zero. The RSS call likewise retries and reports throttling instead of returning a blank date that reads as "never uploaded".

Known limitations
  • URL-pattern discovery can't find sites on domains that follow no convention; implement search_fallback for the long tail.
  • JavaScript-rendered homepages may hide links from a plain HTTP fetch; those need a headless browser (not included).
  • The HTML cache never expires on its own unless you pass --max-age (or --refresh); a redesigned site keeps serving the old cached copy.
  • Township states are not yet special-cased — NY/NJ/PA/MI/WI/etc. return their incorporated cities/villages but not their civil townships. See Which places count above. New England towns and Hawaii's counties are handled via per-state source profiles.
  • Scope is the general-purpose municipal + county tier. Special districts (school/water/fire), tribal governments, and independent authorities are out of scope.

↑ back to contents10.0-civic-data-pipeline(22)/civic-data-pipeline/README.md

10.1ia_metadata_xml_to_sqliteIA _meta.xml → SQLite/Datasette; weekly sync, dark-item detection.
Open PDF · OVERVIEW.pdf — “Preserving Community Media”

IA Metadata XML → SQLite / Datasette

Ingest Internet Archive _meta.xml files (~3.2M items across ~2,200 collections) into a SQLite database for analysis with Datasette. Handles a weekly incremental sync of new/modified items and new collections, plus out-of-band detection of items that Internet Archive has "made dark."

Bundle layout

This bundle is organized so the documents are easy to send out for review and the code stays separate:

README.md                 ← you are here (entry point)
docs/                     markdown sources of every document
  docs/pdf/               the same documents as PDF   (for reviewers / posting)
  docs/docx/              the same documents as Word   (for academics & comment)
code/                     all runnable code, config, and fixtures
  code/conf/              skip-list, directory→collection map, secrets example
  code/fixtures/          sample caption files used by the caption design

Every document under docs/ is provided in all three formats — edit the markdown; the docx and PDF are regenerated from it by code/make_bundle.sh. (OVERVIEW is the one exception: its Word/PDF are built by a small dedicated script for a tight two-page layout.) File names below are given without their code/ or docs/ prefix for brevity.

Contents
File Purpose
common.py Shared library: schema, blake2b hashing, lxml XML parser, PSV map + skip-list loaders. Imported by the others.
build_map.py Generate conf/directory_collection_map.psv empirically from the tree (first <collection> per directory).
bootstrap.py One-time bulk load via shard-per-collection → merge → build FTS/indexes once.
sync.py Weekly incremental sync (--auto / --collections / --file), mtime+hash change detection, upserts, per-row FTS maintenance.
dark_reconcile.py Dark-item detection (Option C): diff local primary collections against IA's scrape API. Separate cadence.
promote.sh Build-then-swap publish: compact, atomically swap the DB into the serve path, restart Datasette. Run weekly after sync.
weekly_sync.sh Cron-safe weekly cycle: back up the live DB, sync a copy, promote atomically, restart Datasette. Never writes the live file. Use this instead of running sync.py directly.
rebuild_from_scratch.sh Safeguarded full rebuild: drops the DB (backup-aside by default), re-runs bootstrap, promotes. Typed confirmation, preflight checks, failure-recovery instructions.
diagnose_parents.sql Diagnostic SQL: classify primary collections (=directories) by parent anomaly, and list unexpected parent values with the dirnames they appear under.
skip_anomalous_parents.py Two-axis investigator: anomalous DIRECTORIES (uppercase, --dirs --apply to skip.conf) and unexpected PARENT values (lowercase, report-only, shown with their dirnames). Both reports by default.
load_collection_meta.py Load collection-level metadata (mediatype:collection) from the per-collection JSON files into collection_meta. Incremental (mtime+hash), idempotent.
load_captions.py Load IA-side caption coverage from {identifier}_files.xml manifests into the caption table and refresh the metadata caption rollups. Incremental. Gives the caption inventory / ASR gap set (Sub-project C-gap).
migrate_add_collection_meta_classification.py Migrate an existing DB: add access_type/state/subject_type to collection_meta tables and backfill from loaded data (no reload). Idempotent.
migrate_add_captions.py Migrate an existing DB: add the caption table and the metadata caption-rollup columns. Idempotent; then run load_captions.py to populate.
migrate_ia_collection_to_dirname.py One-time migration for pre-rename databases: renames the ia_collection column to dirname (metadata, index, and FTS). Idempotent.
datasette.yaml Datasette config: facets, FTS wiring, status soft-filter.
crontab.example Weekly sync, monthly sweep, dark-reconcile schedule.
conf/skip.conf Collections to skip completely (parents + retired broadcast_ready).
conf/directory_collection_map.psv Directory → canonical IA collection-name map (example; regenerate with build_map.py).
conf/ia_secrets.env.example Template for IA S3 keys used by the dark job. Copy to ia_secrets.env, chmod 600.
DESIGN.md Architecture and rationale.
CAPTIONS_DESIGN.md Design (not yet implemented) for caption coverage & gap analysis (roadmap Sub-project C-gap): four caption sources, ISO-code language detection, the ASR gap worklist, and federation notes.
SCHEMA.md Standalone SQL schema reference: every table/column, indexes, FTS, relationships, and example queries.
RUNBOOK.md Step-by-step operations.
Quick start
pip install lxml datasette

# 1. Build the directory->collection map
python3 build_map.py --root /mnt/md0/.../archive_stats \
    --out conf/directory_collection_map.psv --jobs 16 --include-identity

# 2. Initial bulk load (once)
python3 bootstrap.py --root /mnt/md0/.../archive_stats \
    --db /mnt/md0/scratch/archive.build.db --shard-dir /mnt/md0/scratch/shards \
    --map conf/directory_collection_map.psv --skip conf/skip.conf --jobs 36

# 3. Weekly incremental (cron)
python3 sync.py --root /mnt/md0/.../archive_stats --db /mnt/md0/datasette/archive.db \
    --map conf/directory_collection_map.psv --skip conf/skip.conf --auto --jobs 36

# 4. Dark reconciliation (separate cadence; needs IA keys)
set -a && . conf/ia_secrets.env && set +a
python3 dark_reconcile.py --db /mnt/md0/datasette/archive.db \
    --map conf/directory_collection_map.psv --skip conf/skip.conf --fraction 1.0

# 5. Serve
datasette -i /mnt/md0/datasette/archive.db --metadata datasette.yaml

Full procedures — including the build-then-swap publish flow and recovery scenarios — are in RUNBOOK.md. Read DESIGN.md first for the why.

Requirements

Python 3.10+, lxml. Everything else is stdlib (sqlite3, hashlib, urllib, concurrent.futures). Datasette for serving.

Security note

The dark job authenticates to Internet Archive with S3-style keys. Keep them in conf/ia_secrets.env (chmod 600), never in code or version control. If a key is ever exposed, rotate it at https://archive.org/account/s3.php.

↑ back to contents10.1-ia_metadata_xml_to_sql(26)/ia_metadata_xml_to_sqlite/README.md

10.2archive-dashboard-projectMonitors the ~10 worker machines uploading video to IA.

Distributed Archive Worker Dashboard

A monitoring system for ~10 distributed Linux "worker" machines that download YouTube/Vimeo channels via yt-dlp (orchestrated by GNU parallel), process them through bash scripts, and upload to the Community Media collections on the Internet Archive (IA). The system tracks, per channel, how far each has progressed through the pipeline (discovered → downloaded → processed → uploaded → verified-in-archive), tracks machine health, and surfaces — in plain language for non-technical viewers — the few things that need a human.

How it fits together
  workers (agent.py)  ── POST status every 2 min ──▶  collector.py
   read-only observers                                 (always-on service)
   of the existing pipeline                            • ingest (fast write)
                                                        • rollup + alerting
  reconcile.py  ── hourly, queries IA ──▶ collector DB  • serves dashboard
   (separate process, holds IA creds)                  • SQLite (only durable state)
                                                              │
                                                              ▼
                                                        dashboard (in browser)
                                                        "is it working, and
                                                         what needs a human?"

Core principles: workers are dumb read-only counters that push; the collector owns all reconciliation, alerting, and rollup; reconciliation runs as a separate process so a slow IA query never blocks ingest; the channel (its canonical UC…/Vimeo id) is the stable join key; and everything is stateless-restartable with one SQLite file as the only durable truth.

Three numbers, three different sources (don't conflate them)

Per channel, the system tracks three counts that look similar but come from different places and answer different questions. Keeping them straight is the key to reading the dashboard correctly:

Number Source Answers
remote_total YouTube/Vimeo — fetched by the agent on the worker via yt-dlp "How many videos exist on the channel?"
downloaded The worker's local disk — the agent counts .info.json files "How many videos does this worker have on its own disk right now?"
verified Internet Archive — produced by reconcile.py querying IA "How many of the channel's videos are confirmed safely in the archive?"

The division of labor follows the source: the agent (on each worker) talks to YouTube (remote_total) and the local filesystem (downloaded); the reconcile.py job (on the collector) talks to IA (verified). Reconciliation checks IA only — never YouTube and never the worker's disk.

Archival progress on the dashboard is verified / remote_total — an IA number over a YouTube number. It deliberately does not use downloaded, because a worker's local inventory reflects only what's on that machine now (especially on migrated or multi-generation workers), not what's actually preserved in the archive. verified is the authoritative, worker-independent measure of "is the content safe"; downloaded is a local operational detail shown as a secondary stat. (See the design doc's "Three numbers easy to conflate" for the full treatment.)

What's in this package

sql/

Project A aggregate rollups over the IA metadata DB (archive.db): per-collection / per-year / fleet trend tables, a refresh script, and example research queries. Tested against the real schema. See sql/README.md.

code/ — the programs, split by where they run

The code is organized by deployment target so it's clear what goes where (each subdirectory has its own README):

  • code/worker/ → deploy to /opt/archive-agent/ on each worker. The stateless agent (agent.py + agent_platform.py), config/claims tools, and the enrich_missing.py diagnostic. None touch a database.
  • code/collector/ → deploy to /opt/archive-collector/ on the one always-on collector. The service (collector.py), IA verification (reconcile.py), the DB-writing operator tools, and the SQL exports/cleanups. This side owns the only durable state (collector.db).
  • code/shared/ → role-aware tools that run on either side: rebuild_missing.sh (export on collector, redownload on worker) and migrate_env.sh (OS-upgrade recovery on whichever machine you upgraded). Kept single-copy to avoid skew. | File | Role | |---|---| | agent.py | Runs on each worker from cron. Reads the existing pipeline's files (download-archive, joblogs, .info.json, markers) across the metadata/download/TEMP volumes, computes the funnel counts, and POSTs a status blob. Read-only; never touches the pipeline. | | agent_platform.py | Cross-platform helpers (time, file mtime, process counting, single-instance lock, volume/disk resolution) so agent.py runs unmodified on Linux, macOS, and WSL2. No shell-outs to date/stat/pgrep/flock/df. | | collector.py | The always-on service: ingest, rollup, alerting, the dashboard, DB migrations. Stdlib only (http.server + sqlite3). | | reconcile.py | Verifies claimed uploads against Internet Archive only (never YouTube, never local disk), matching on the youtube-id/vimeo-id metadata every existing item already carries. Path A (query by collection) with a Path B (match by video id) fallback for multi-channel collections. Produces the verified count. | | generate_config.py | Builds a worker's config.toml by parsing the existing per-directory get_new_video_metadata_backlog.sh scripts. Supports --verify (stat the paths), --append (merge into an existing config, with auto-backup and dedup), and --channel-id-map (use pre-resolved channel ids to skip network calls). | | yt_channel_names.sh | One-time pre-resolver: reads a dirs list, resolves each YouTube channel's UC… id via yt-dlp (politely, resumable), and writes a dirname → channel_id TSV map. Feed it to generate_config.py --channel-id-map so config generation is fast and network-free. Vimeo is skipped (resolved URL-side by the generator). | | set_collections.py | Populates each channel's IA collection in the collector DB from a dirname → ia_collection mapping file (pipe/tab/whitespace delimited). This is the prerequisite that lets reconciliation verify a channel against IA — without it, channels stay "upload not yet verified". Run once per worker on the collector. | | set_channel_names.py | Populates human-readable channel names in the collector DB (from the yt_channel_names.sh TSV or a channel_id\|name map), so the dashboard shows "Anchorage School District" instead of a raw UC… id. The collector also falls back to the dirname before the id. Run once per worker. | | scan_claims.py | Worker-side batch job: scans each channel's download_dir (read from config.toml) for .info.json files, extracts the video ids, and POSTs them to the collector's /claims endpoint. This populates the claims table so reconciliation can compute missing (claimed-but-not-in-archive). Heavier than the agent's per-cycle work, so run on a slow timer (e.g. daily or post-pipeline), not every cycle. Has --dry-run. | | seed_one_channel.py | Test helper: seeds one channel + collection + a few claimed video ids into the collector DB, for a live single-channel reconciliation check (Level 3 test). Not a production tool. | | rebuild_missing.sh | Re-fetch a worker's missing videos per channel (dirname). Exports each channel's missing ids to {dirname}_missing_ids.txt (from the v3 missing_video table), then either (--mode redownload, recommended) re-downloads only those ids into the channel's configured download_dir with --no-download-archive (shared archive untouched, zero blast radius), or (--mode archive) backs up the shared archive.txt timestamped, removes just those ids from it, and re-runs the dirname's backlog script. --dry-run, --only DIRNAME, --export-only/--skip-export for split collector/worker execution. Throttle-safe pacing mirrors the agent's model: yt-dlp per-video sleeps (--sleep-min/-max, default 3-8s) so a channel's downloads never burst the shared IP, plus optional channel-level GNU --parallel (--jobs, --delay stagger) — parallelism at the channel granularity, never by splitting one channel's id list. | | enrich_missing.py | Worker-side diagnostic for the missing-video failure analysis (Archive Corps). Takes a channel's missing video ids (from the missing_videos_by_worker.sql export) and enriches each from the cheapest source: local .info.json (full metadata — title, duration, tags, etc.), then optionally a yt-dlp --flat-playlist probe (one call/channel) for ids with no local file, then optionally a full per-video fetch. Tags each record with its source tier. JSONL or TSV output. The source itself is diagnostic — no .info.json means the video failed earlier in the pipeline than one that downloaded but never uploaded. | | migrate_env.sh | OS-upgrade recovery/repair tool. After a worker (or collector) is upgraded across the PEP 668 boundary (Ubuntu 22.04 → 24.04), pip --user deps become invisible to the new Python and the agent silently dies. This script idempotently re-homes the machine onto a venv, fixes yt-dlp, repoints cron at the venv interpreter, and proves the agent runs. Safe to run on any machine in any state (--dry-run, --role, --venv). | | test_reconcile.py | Offline test of the verification logic — substitutes a fake IA client and asserts verified/missing/orphan outcomes across both reconciliation paths. Run with python3 test_reconcile.py; no IA account or network needed. |

docs/

  • archive-dashboard-design.md — the full design document (architecture, data model, status-blob schema, the collector, reconciliation, schema-version rollout, deployment, SQLite backup/migration, the dashboard, and design principles). The reference for why everything works the way it does.
  • collector-schema.md — the database reference: every table and column, a data-access map (which program reads/writes which table), the blob-vs-DB schema version distinction, and Mermaid entity-relationship diagrams for the v1 and v2 schema. Generated from collector.py and validated against a real migrated DB.
  • collector-sql-reference.md — a teaching inventory of every SQL statement the code actually runs, verbatim, grouped by pattern (upserts, INSERT OR IGNORE vs OR REPLACE, delete-then-insert, safe optional filtering) and annotated with why each is written that way. A model to learn from when writing new SQL.
  • how-it-works-now.md — the pre-existing worker fleet & pipeline this monitoring system observes (scale, hardware, schedule, networking, output). Current-state context, not the dashboard design.
  • metadata-enhancements.md — forward-looking research roadmap: what a researcher would ask of the ~3.2M-item corpus across all collections, and a sequenced series of metadata-enhancement projects (aggregates → transcription → entity/topic extraction → research API) to answer those questions.
  • volunteer-contribution.md — a "how can I help?" guide for volunteer contributors (laypersons, community-media pros, editors, MARA/MLIS students, locals with deep community knowledge): roles mapped to the enhancement tasks, a contribution ladder, a FAQ, and the management/supervisory infrastructure to coordinate and quality-control their work.
  • civic-data-integration.md — federating the civic-data-pipeline (Census-GEOID discovery of every US local government's official site, YouTube channel, and agenda/minutes portal) as the discovery source: confidence-gated channel onboarding, the civic-coverage denominator for Democracy's Library, and a sibling agendas-&-minutes extraction project (with the video↔︎agenda linking decision).
  • metadata-db-integration.md — how to federate this project's collector.db ("what we sent") with the separate IA metadata database ("what's actually on IA", incl. post-upload deletions): join keys, the DARKENED failure class, reconcile-against-local-truth, and where each report draws its data.
  • report-mockups.md — worked mock-ups of all three reports (operator/manager/owner) on sample data: exactly what each shows, what issues it surfaces, and the questions each answers.
  • reporting-and-expansion.md — analysis + recommendations: audience-scoped reporting (operator/manager/channel-owner) with suggested cadences, and an automated capacity-first channel-expansion process that fills spare worker capacity before provisioning new machines.

runbooks/

Runbook Purpose
collector-standup-runbook.md Stand up the one always-on collector service: service user, secrets (ingest token + IA creds), systemd units, trust boundaries, backups. Start here — the collector must be running before any worker can be migrated or onboarded.
migration-runbook.md Convert the existing ~10 Linux workers from the old monitoring to this system. The agent is read-only and runs in parallel with the old setup — no flag day. Run after the collector standup, then repeat per worker.
linux-worker-runbook.md Set up a fresh Linux worker from scratch (the reference platform).
windows-worker-runbook.md Add a Windows machine via WSL2.
macos-worker-runbook.md Add an Apple Silicon Mac (native).
rebuild-missing-runbook.md Re-download a worker's missing videos (downloaded locally but never verified in IA), split collector→worker, in Mode 2 (--redownload). Export per-channel id lists on the collector, copy to the worker, re-fetch with throttle-safe pacing and optional channel-level parallelism, then re-reconcile.
Suggested reading / build order
  1. docs/archive-dashboard-design.md — understand the target system (the why).
  2. docs/collector-schema.md — skim the data model: the tables, the channel_id join key, and the three-numbers distinction in concrete columns. Useful context before you stand anything up.
  3. runbooks/collector-standup-runbook.md — stand up the one always-on collector first, so there's a live endpoint for workers to post to. Note its Step 4 (host allowlist): every worker's host must be added there (and the env file regenerated + service restarted) or its posts are rejected 400 unknown host.
  4. runbooks/migration-runbook.md — convert the first existing worker. This is the main per-worker procedure; follow its steps in order:
    • install (Step 1 auto-handles the 22.04 pip --user vs 24.04 venv / PEP 668 split and sets $AGENT_PY; verify yt-dlp is on the agent's PATH),
    • generate config from the on-disk scripts (Steps 2–3),
    • trial-run --no-remote and confirm the worker appears (Step 4),
    • schedule cron (Step 5), map collections (5b), set names (5c), optionally populate claims (5d), then seed remote totals so the bars appear (5e). Validate the dashboard against the old monitoring before trusting it.
  5. Repeat step 4 for the remaining workers. Each is independent: add its host to the allowlist, run its config/collections/names/seed. The per-worker checklist at the end of the migration runbook is the quick reference.
  6. As needed: add fresh or cross-platform machines via linux-/windows-/macos-worker-runbook.md; after any worker's OS upgrade across the 22.04→24.04 boundary, run code/migrate_env.sh (see the migration runbook's "Maintenance: OS upgrade" section).
Status of the code

All programs were tested during development (unit tests of the parsing and reconciliation logic, and end-to-end runs of the agent → collector → dashboard path against fixtures), and the system has since been deployed and debugged across a live multi-worker fleet. Two environment-specific wiring points to confirm per deployment:

  • reconcile.py's IAClient needs live IA credentials (configured on the collector per the standup runbook). The real query layer is isolated in one injectable class.
  • agent.py's remote-total fetch resolves yt-dlp by absolute path (env YTDLP_PATH → PATH → ~/.local/bin → /usr/local/bin → /usr/bin) and fails loudly if it can't find it, rather than silently returning null totals. Confirm sudo -u access yt-dlp --version works (symlink into /usr/local/bin if it lives in ~/.local/bin); see the migration runbook's Step 1 and the "remote_total stays null" troubleshooting.

Nothing requires changes to the existing upload pipeline before reconciliation works — it matches on the youtube-id/vimeo-id metadata the corpus already has.

Dependencies
  • Workers (agent.py): Python 3.8+, psutil (live job count; optional — degrades gracefully), tomli on Python <3.11 (3.11+ has tomllib built in). The pipeline already provides yt-dlp, jq, ffmpeg, GNU parallel. On Ubuntu 22.04 install deps with pip --user; on 24.04 / Debian 12+ that's blocked by PEP 668, so use a venv — the migration runbook's Step 1 auto-detects which and sets $AGENT_PY accordingly, and code/migrate_env.sh re-homes a worker onto a venv after an OS upgrade.
  • Collector (collector.py): Python 3.8+ stdlib only.
  • Reconciliation (reconcile.py): the internetarchive package.
  • All machines: Tailscale (already in use).

↑ back to contents10.2-archive-dashboard-project(71)/archive-dashboard-project/README.md

10.3whisper-asr-gpu-01Whisper ASR fill for the caption gap set (Project C3) — Faster-Whisper-XXL r245.4 on gpu-01; large-v3-turbo, 2-process, 88–95× realtime.

gpu-01: Faster-Whisper-XXL r245.4 — as-built

Status: Installed, verified, benchmarked. Outstanding items in §7. Last updated: 2026-08-25


1. Environment (verified)
Host gpu-01
OS Ubuntu Server 22.04.5 LTS (glibc 2.35)
CPU Intel i7-8700K (6C/12T, AVX2)
RAM 32 GB
GPU RTX 3060 12GB. Power limit lifted to the 170 W default (was capped at 150 W) during concurrency testing — see §5 power notes.
Driver 555.42.06 / CUDA 12.5 — exceeded the ≥525 floor, no driver work performed
Secure Boot Disabled — no MOK enrollment
Reboot Never required

The r245.4 bundle ships its own CUDA 12.x / cuDNN 8.x libs, CTranslate2 4.4.0, onnxruntime-gpu 1.18.0, and Python 3.10.11 via PyInstaller. Nothing installed system-wide. No distro upgrade — 22.04 is supported to April 2027.


2. Paths and names
r192.3.1 (old) r245.4 (new)
Install dir /home/access/Whisper-Faster-XXL /mnt0/fwxxl-r245.4
Executable whisper-faster-xxl faster-whisper-xxl
Full path — /mnt0/fwxxl-r245.4/faster-whisper-xxl
Models .../Whisper-Faster-XXL/_models /mnt0/whisper-models (--model_dir)
Temp system default (on /) /mnt0/whisper-tmp (TMPDIR)
Old install / manifests /home/access/Whisper-Faster-XXL retained (r192.3.1)
Audio working dir — /mnt0/Whisper-Faster-XXL (rsynced; run from here)

Upstream reversed the executable's word order — whisper-faster-xxl → faster-whisper-xxl. Most likely thing to break existing automation.

Optional stable alias so future upgrades are a symlink repoint:

ln -s /mnt0/fwxxl-r245.4/faster-whisper-xxl ~/bin/fwxxl

Storage:

/dev/nvme0n1p2   915G  93G avail   /          ← 90% full; data + manifests only
/dev/sda1         17T 5.5T avail   /mnt0      ← ext4; binary, models, temp, output
/dev/sdb1         13T 4.1T avail   /mnt1      ← ext4, spare
//192.168.x.x   17T 9.8T avail   /mnt3,/mnt5 ← CIFS; never models or temp

3. Install — as performed
mkdir -p /mnt0/whisper-models /mnt0/whisper-tmp

cd /mnt0
wget -c https://github.com/Purfview/whisper-standalone-win/releases/download/Faster-Whisper-XXL/Faster-Whisper-XXL_r245.4_linux.7z

7z x Faster-Whisper-XXL_r245.4_linux.7z -o./Whisper-Faster-XXL-r245.4
# → 634 folders, 4393 files, 6.09 GB extracted (not ~3.5 GB)
# NOTE the "-o./" — do NOT write "-o~/...". 7z takes the -o path GLUED to the
# switch, which puts the tilde mid-word where the shell performs no expansion,
# so 7z creates a directory literally named "~". See gotcha 4 below.

# Archive nests everything under Faster-Whisper-XXL/ — flatten it
mv /mnt0/Whisper-Faster-XXL-r245.4/Faster-Whisper-XXL /mnt0/fwxxl-r245.4
mv /mnt0/Whisper-Faster-XXL-r245.4/license.txt /mnt0/fwxxl-r245.4/
rmdir /mnt0/Whisper-Faster-XXL-r245.4

# 7z does not preserve the execute bit
chmod +x /mnt0/fwxxl-r245.4/faster-whisper-xxl /mnt0/fwxxl-r245.4/One_Click_Transcribe.sh

env -u LD_LIBRARY_PATH /mnt0/fwxxl-r245.4/faster-whisper-xxl --help | head -20

Contents: faster-whisper-xxl (41.9 MB), _xxl_data/ (5.7 GB), One_Click_Transcribe.sh, license.txt.

Four 7z gotchas — it does not behave like tar or unzip

  1. It nests everything under Faster-Whisper-XXL/. Flatten after extracting, as above.

  2. It extracts 6.09 GB, not the ~3.5 GB the archive size suggests. Check free space first.

  3. It does not preserve the execute bit. chmod +x the binary and One_Click_Transcribe.sh, or the build looks installed and simply will not run.

  4. -o does not tilde-expand — -o~/foo creates a directory literally named ~. 7z requires the output path glued to the switch with no space, which puts the tilde mid-word; the shell only expands ~ at the start of a word. So:

    7z x archive.7z -o~/Faster-Whisper-XXL     # WRONG → ./~/Faster-Whisper-XXL
    7z x archive.7z -o"$HOME"/Faster-Whisper-XXL   # right
    7z x archive.7z -o./Faster-Whisper-XXL         # right (what this runbook uses)

    Found 2026-09-12 on gpu-02, where 7z x … -o~/ had left a 7.1 GB /home/access/~/ holding a byte-identical, never-chmod +x'd duplicate of the Pro build — gotchas 3 and 4 compounding. Quote the path when cleaning one up (rm -rf "$HOME/~"): an unquoted ~ will not mean what you want.


4. Migration reference

Renames

r192.3.1 r245.4 Changed in
whisper-faster-xxl (binary) faster-whisper-xxl —
--vad_alt_method --vad_method r193.1
--ff_mdx_kim2 --ff_vocal_extract mdx_kim2 r245.3
--mdx_device --voc_device r245.3

Removed in r239.1

--nullify_non_speech, --no_speech_strict_lvl, --prompt_max, first option of --reprompt.

grep -rn -e 'whisper-faster-xxl' -e 'vad_alt_method' -e 'ff_mdx_kim2' -e 'mdx_device' \
         -e 'nullify_non_speech' -e 'no_speech_strict_lvl' -e 'prompt_max' \
         /home/access/Whisper-Faster-XXL /mnt0/Whisper-Faster-XXL \
         ~/bin ~/.local/bin /etc/cron.d 2>/dev/null

Behavior changes affecting output

  • --sentence now affects all formats except json (r194.2; previously srt/vtt only). When diffing old vs new, compare srt only — txt and tsv differ by design.
  • lrc is a new format. --output_format all emits one extra file per input.

Flags in the old production command (--model, --sentence, --beam_size, --output_format, --output_dir) all carry over unchanged.


5. Benchmarks

Denominators

Every figure below names the corpus it came from. Neither corpus is a sample of the wider archive.

Corpus A — Rio Dell City Council, 2026. 29 mp3, 88,580 s = 24.6 h. Mean 50.9 min, median 45.8 min, range 19.2-111.5 min. Chosen deliberately as a hard case: worst room audio and least orderly council conduct of the jurisdictions under test, and meetings shorter than most. Fourteen of fifteen meetings appear twice from two capture sources under the the access center joint franchise (7 places + 1 county); other jurisdictions do not share this property.

Corpus B — Humboldt County Board of Supervisors, 2026. 27 mp3, 274,802 s = 76.3 h. Mean 169.6 min, median 166.7 min, range 3.1-444.2 min. Bimodal: four "Special" sessions of 3-25 min, the rest 73-444 min. 18 files exceed 2 h, 12 exceed 3 h, 7 exceed 4 h. Mean file length is 3.3x Corpus A's.

Corpus B was added specifically because Corpus A could not speak to long-meeting behaviour. The two agree within 8%, which is the basis for treating the rates below as more than single-jurisdiction artefacts — but they remain two jurisdictions in one county, not a projection onto the 3M-item archive.

Corpus A — Rio Dell (24.6 h)

Config Wall-clock Realtime CPU (user/sys)
medium.en, serial (pre-upgrade production) — 38-45× (per-file) —
medium.en, batched bs=8 21m12.9s 69.6× 31m24s / 7m45s
large-v3-turbo, batched bs=8 20m18.6s 72.7× 29m01s / 8m41s
large-v3-turbo, bs=12 ~same — —
large-v3-turbo, bs=12 + hotwords 21m49s — hotwords cost ≈ 90 s
turbo + --vad_device cuda 20m16.0s 72.7× 29m12s / 8m35s — no change
turbo, 2 concurrent processes 15m28.4s 95.4× 38m07s / 8m12s

Corpus B — Humboldt County BoS (76.3 h)

Config Wall-clock Realtime CPU (user/sys)
turbo, solo run of the single 7.4 h file 6m46.3s 65.6× 9m44s / 2m57s
turbo, 2 concurrent, duration-balanced 51m55.5s 88.2× 129m16s / 26m54s

Halves finished 46 s apart across a 52-minute run — the greedy longest-first split (S5) balanced to 1.3 min of audio across two 38-hour halves.

Findings

The two corpora agree within 8%. Corpus B ran at 88.2× against Corpus A's 95.4× under identical settings, despite files averaging 3.3× longer. Projecting Corpus A's rate onto Corpus B predicted 48.0 min; actual was 51.9 min. So per-file overhead is not what dominates, and the worst-audio corpus was not meaningfully harder than the long-meeting one.

Long files are safe. The 7.4 h file transcribed with no truncation (last cue 07:23:50 against 07:24:14 duration), no repetition loops (max consecutive duplicate = 2), and a well-formed 4,980-segment SRT. Cues longer than 15 s were 10 of 4,980 (0.2%), all dead-air absorption rather than collapsed speech. The model question is closed: large-v3-turbo on everything, including 7+ hour files.

The tool's reported audio-seconds/s is per-file, not per-run — it reports the last file in the batch. On the Corpus B run the two processes reported 94.86 and 48.82 audio-s/s, which are two different meetings, not two run averages. The figure is valid for the file it describes and excludes model load and inter-file overhead. For batch throughput use wall-clock over a corpus of known total duration; do not sum or average the per-file figures.

Concurrency is the second win — 1.31× over single-process. Two processes on the same GPU finished the same 29 files in 15m28s vs 20m18s. This was predicted not to work, on the basis of a pre-batching observation that two jobs simply halved each other's rate. That prediction was wrong under batched inference.

Mechanism: GPU decode is saturated, but each process spends ~24% of its wall-clock on CPU-side work (VAD, model load, output writing) during which the GPU idles. Two processes interleave and fill each other's holes. This is not more GPU throughput — it is better duty cycle. Predicted ceiling from the overhead arithmetic was ~96×; measured 95.4×.

The split was near-balanced (last outputs one minute apart), so the figure is sound for this corpus. Three-way concurrency is untested and expected to show diminishing returns, since the overhead is now largely absorbed.

Batching is the win. 2.5–2.7× at zero quality cost, purely from loading the model once per batch instead of once per file. Nothing else measured comes close.

Batch size beyond 8 does nothing. bs=8 and bs=12 landed within a minute across 29 files. Don't tune it further.

Power is not a constraint — for batched transcription. At a 150 W cap the card peaks around 132 W and rarely. A clock log (374 samples) showed mean 1,764 MHz with no meaningful throttling — SwPowerCap appears on 182 samples but at 1,906 MHz average, which is normal boost behavior, not a limit. This does not generalise to diarization (see the diarization measurement below), whose torch-based pass saturates the card. The 150 W cap was lifted to the 170 W default during concurrency testing and remains off.

7.5% of sampled time the GPU was fully idle (28 samples at 210 MHz, flag 0x1 GpuIdle) — CPU-side VAD and mp3 decode. time corroborates: real 20m18s / user 29m01s / sys 8m40s, i.e. 37+ min of CPU against 20 min wall-clock. The CPU works harder than the GPU on this pipeline.

GPU utilization % is a useless metric here — it reports whether any kernel is resident, not how much of the card is used. It read 100% at every batch size. Use wall-clock instead. Note also that nvidia-smi dmon's mem column is memory bandwidth utilization, not VRAM occupancy.

Diarization — first measurement (Corpus A, 2026-08-26)

One file, --diarize pyannote_v3.1, --batched OFF (VRAM contention), solo, via test/test_diarize.sh. Denominator: a single 1:42:10 Rio Dell meeting — not a rate.

File Rio Dell, 2026-07-21 · 6,130 s (1:42:10)
Wall-clock (diarize + transcribe) 5:37.8 (tool) / 5:39.9 (time)
Overall realtime ~18×
Diarization pass alone ≈ 187 s (~55% of wall-clock; ~33× realtime)
Transcription (unbatched) 48.76 audio-s/s (tool, last-file) ≈ 126 s (~37%)
Speakers detected 11, at a --max_speakers 12 ceiling
Power / thermal (diar. stage) 168 W of the 170 W cap, 75 °C, 77 % fan, sustained

Reading of it:

  • Diarization roughly quarters throughput — ~18× vs the settled 72–95× for transcription. Diarizing all of Corpus B (76.3 h) would be ~4.2 h solo, against ~52 min for the 2-way transcription pipeline. Speaker labels are expensive.
  • The 48.76 audio-s/s is unbatched and consistent with the batching finding (~48× unbatched vs ~72× batched), not a regression.
  • Diarization is power-bound where transcription was not. The torch pass sat at 168 W of 170 W the whole diarization stage. So the "1.31× from 2 processes" win — which came from the GPU being idle ~24% of the time under transcription — cannot be assumed for diarized runs: two diarization passes would contend for a saturated, power-limited card. Measure before running diarization N-way.
  • 11 speakers at a 12 ceiling is near the top of the range; it may over-split if raised. Rio Dell is 5 members + ~3 officials + public commenters, so 11 is plausible but wants a check. Iterate cheaply with MODE=only MAX_SPK=8 (a --diarize_only pass is ~3 min) and eyeball a roll-call vote — the known weak spot. Accuracy is still unjudged; only cost is now measured.

Model decision — SETTLED: use large-v3-turbo

large-v3-turbo is marginally faster than medium.en on this corpus (20m18.6s vs 21m12.9s), not slower. There is no speed/quality trade-off to weigh: turbo delivers a large-class model at equal-or-better throughput.

Why: the pipeline is bound by fixed CPU-side overhead, not decode. medium.en reported 91.03 audio-s/s from the tool but achieved 69.6× wall-clock — a ~300 s gap, ~24% of the run, outside the decode loop. That cost is constant regardless of model, so turbo's shallower decoder (4 layers vs 24) has little left to win. medium.en even used more CPU time (31m24s vs 29m01s).

Still worth checking transcript quality on a worst-audio meeting before locking it in — turbo was distilled with less robustness emphasis and may degrade late in long sessions:

diff <(sed 's/^[0-9:,>< -]*$//' /mnt0/whisper-tmp/medium-batched/SAMPLE.srt) \
     <(sed 's/^[0-9:,>< -]*$//' /mnt0/whisper-tmp/turbo/SAMPLE.srt) | head -60

Check proper nouns, numbers, and the tail of a 90-minute meeting.


6. Test scenarios

All assume:

export TMPDIR=/mnt0/whisper-tmp
cd /mnt0/Whisper-Faster-XXL      # audio lives here; relative input paths resolve from it
FW="/mnt0/fwxxl-r245.4/faster-whisper-xxl"

--output_dir is created on demand. TMPDIR must exist.

6.1 Current best-known production config

time env -u LD_LIBRARY_PATH "$FW" \
  /mnt0/Whisper-Faster-XXL/Rio_Dell_City_Council/2026/*.mp3 \
  --model=large-v3-turbo -l en --sentence --beam_size=1 \
  --model_dir /mnt0/whisper-models \
  --batched --batch_size 8 \
  --output_format srt json txt \
  --output_dir /mnt0/transcripts/Rio_Dell_City_Council/2026 \
  --skip

--skip passes over files already present in the output dir — makes bulk runs resumable. Omit it when benchmarking.

--output_format srt json txt emits three views of each meeting: srt (timed cues, plus [Speaker_XX] labels when diarizing), json (word-level timestamps, no speaker labels — see §7), and txt (plain reading transcript, no timestamps). txt is a sentence-path format, so a diarized run's txt carries the speaker labels; the json never does.

6.2 Isolate one variable

Change exactly one thing per run against §6.1.

# Model comparison
--model=medium.en          # old model, batched
--model=large-v2           # full large model (untested)
--model=distil-large-v3.5  # English-only, less hallucination-prone

# VAD on GPU — tests the 7.5% idle finding
--vad_method pyannote_v3

# Beam width — may be near-free on a latency-bound card
--beam_size=5

6.3 Duration-balanced split (S5) and concurrency

File counts balance badly when durations range 3 min to 7.4 h. Greedy longest-first assignment got Corpus B to 1.3 min of imbalance across two 38-hour halves; the halves then finished 46 s apart. Round-robin on a shuffle would not have.

Log each process to its own file. Merging both to one stdout makes per-process completion times unrecoverable — this was learned the hard way.

# S5: duration-balanced N-way split. Pass the audio dir; N via NBINS.
NBINS=2 python3 - /mnt0/Whisper-Faster-XXL/Humboldt_County_Board_of_Supervisors/2026 << 'SPLIT'
import subprocess, sys, os, glob
d = sys.argv[1]
N = int(os.environ.get('NBINS', '2'))
fs = []
for f in sorted(glob.glob(os.path.join(d, '*.mp3'))):
    dur = float(subprocess.check_output(
        ['ffprobe','-v','error','-show_entries','format=duration',
         '-of','csv=p=0', f]))
    fs.append((dur, f))
if not fs:
    sys.exit("no mp3 found in " + d)
fs.sort(reverse=True)                      # longest first
bins = [[] for _ in range(N)]; tot = [0.0]*N
for dur, f in fs:
    i = tot.index(min(tot)); bins[i].append(f); tot[i] += dur
for i, b in enumerate(bins):
    with open("/tmp/bin-%d.txt" % i, "w") as fh:
        fh.write("\n".join(b) + "\n")
    print("bin %d: %.2f h, %d files" % (i, tot[i]/3600, len(b)), file=sys.stderr)
SPLIT

# Run the bins concurrently, one log each
export TMPDIR=/mnt0/whisper-tmp
FW=/mnt0/fwxxl-r245.4/faster-whisper-xxl

time (
  for i in 0 1; do
    env -u LD_LIBRARY_PATH "$FW" $(cat /tmp/bin-$i.txt) \
      --model=large-v3-turbo -l en --sentence --beam_size=1 \
      --model_dir /mnt0/whisper-models --batched --batch_size 8 \
      --output_format srt json txt --output_dir /mnt0/whisper-tmp/bin-$i \
      > /mnt0/whisper-tmp/bin-$i.log 2>&1 &
  done
  wait
)

Each process loads its own model copy — watch VRAM before raising NBINS past 2. At 3.0 cores busy with the GPU as the constraint, 3-way is expected to add little.

6.3a Output verification (S1–S4)

Run these after any long-meeting batch. They catch the three failure modes that matter — silent truncation, repetition loops, malformed output — without listening to the audio.

S1 — truncation check. Compare each transcript's last cue against the source file's real duration. A transcript ending well short is turbo dropping segments, its known failure mode.

for f in /mnt0/whisper-tmp/bin-*/*.srt; do
  base=$(basename "$f" .srt)
  src=$(find /mnt0/Whisper-Faster-XXL -name "$base.mp3" | head -1)
  [ -z "$src" ] && { printf '%-64s NO SOURCE\n' "$base"; continue; }
  dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$src")
  last=$(grep -- '-->' "$f" | tail -1 | awk '{print $3}' | tr ',' '.')
  lastsec=$(echo "$last" | awk -F: '{print $1*3600+$2*60+$3}')
  gap=$(echo "$dur $lastsec" | awk '{printf "%.0f", $1-$2}')
  flag=""; [ "$gap" -gt 120 ] && flag="  <-- CHECK"
  printf '%-64s dur=%7.0fs last=%7.0fs gap=%5ss%s\n' \
    "$base" "$dur" "$lastsec" "$gap" "$flag"
done | sort

A gap under ~60 s is trailing silence after the gavel — normal. Two minutes or more warrants a look.

S2 — repetition loops (consecutive). The one that matters. A loop repeats the same line consecutively; a busy meeting repeats common phrases scattered across hours, which is not a defect.

for f in /mnt0/whisper-tmp/bin-*/*.srt; do
  echo "== $(basename "$f")"
  awk '!/^[0-9]+$/ && !/-->/ && NF' "$f" | uniq -c | sort -rn | head -5
done

Max consecutive run of 2–3 is normal speech — two people thanking each other a half-second apart. Any line repeating 10+ times consecutively is a loop; re-run that file with --model=large-v2 or a higher --beam_size.

S3 — frequency table (context, not a verdict). Total counts, ignoring adjacency. Useful for judging whether the vocabulary looks like a meeting.

grep -hv '^[0-9]' /mnt0/whisper-tmp/bin-*/*.srt | grep -v -- '-->' \
  | sort | uniq -c | sort -rn | head -20

Interpret against the meeting: 112 "Thank you." across 7.4 h of public comment is one every four minutes — real. A chair's script ("I will unmute you and you will have two minutes") recurring is corroboration, not repetition. S2, not S3, decides whether there is a loop.

S4 — over-long cues. Cues far longer than their neighbours usually mean VAD handed the decoder a region it could not subdivide — dead air absorbed into a short phrase. Harmless for reading; matters only for timestamp-linked search, where the link can land 10–20 s early.

for f in /mnt0/whisper-tmp/bin-*/*.srt; do
  awk -v F="$(basename "$f")" '/-->/{
    split($1,a,":"); split($3,b,":");
    s=a[1]*3600+a[2]*60+a[3]; e=b[1]*3600+b[2]*60+b[3];
    if (e-s > 15) printf "%5.0fs  %s  %s\n", e-s, F, $0 }' "$f"
done | sort -rn | head -20

Corpus B baseline: 10 of 4,980 cues (0.2%) over 15 s, longest 29 s. Whisper's encoder works in 30 s windows, so a 25–29 s cue is one window holding little transcribable content. Inspect a couple with grep -A2 '<timestamp>' file.srt — a short phrase means dead air was absorbed (fine); a full paragraph means a real segment did not get subdivided.

Segment-count sanity. grep -c '^[0-9]*$' returns roughly 2x the segment count (index lines plus blank separators). Halve it and compare against the last index in the file; a mismatch means malformed output.

6.4 Earlier round-robin approach (superseded by 6.3)

Round-robin the split so long files distribute evenly — file counts alone balance poorly when durations range 19–111 min. Log each process separately; merging both to one stdout makes the results unreadable.

ls /mnt0/Whisper-Faster-XXL/Rio_Dell_City_Council/2026/*.mp3 | shuf > /tmp/all.txt
split -n r/2 /tmp/all.txt /tmp/part-

time (
  for P in /tmp/part-*; do
    env -u LD_LIBRARY_PATH "$FW" $(cat $P) \
      --model=large-v3-turbo -l en --sentence --beam_size=1 \
      --model_dir /mnt0/whisper-models --batched --batch_size 8 \
      --output_format srt json txt --output_dir /mnt0/whisper-tmp/$(basename $P) \
      > /mnt0/whisper-tmp/$(basename $P).log 2>&1 &
  done
  wait
)

Watch VRAM: each process loads its own model copy. split -n r/3 for three-way.

6.5 Batch size sweep (already answered: use 8)

for BS in 4 8 12 16; do
  echo "=== bs=$BS ==="
  /usr/bin/time -f "%e sec" env -u LD_LIBRARY_PATH "$FW" "$F" \
    --model=large-v3-turbo -l en --sentence --beam_size=1 \
    --model_dir /mnt0/whisper-models --batched --batch_size $BS \
    --output_format srt txt --output_dir /mnt0/whisper-tmp/bs$BS
done

6.6 Corpus duration and realtime factor

for f in /mnt0/Whisper-Faster-XXL/Rio_Dell_City_Council/2026/*.mp3; do
  ffprobe -v error -show_entries format=duration -of csv=p=0 "$f"
done | awk '{s+=$1} END {printf "%.0f sec  %.2f h  n=%d  mean=%.1f min\n", s, s/3600, NR, s/NR/60}'

Track audio-seconds per wall-clock second, not raw times — it's comparable across files and models.

6.7 GPU instrumentation

# VRAM occupancy + real utilization
nvidia-smi --query-gpu=memory.used,memory.total,utilization.gpu --format=csv -l 1

# Log a full run
nvidia-smi --query-gpu=timestamp,memory.used,utilization.gpu,power.draw,clocks.sm \
  --format=csv -l 1 -f /mnt0/whisper-tmp/gpu-run.log

# Throttle check
nvidia-smi --query-gpu=clocks_throttle_reasons.active,clocks.sm,clocks.max.sm --format=csv -l 1

# Idle-sample count (flag 0x1 = GpuIdle)
awk -F, 'NR>1{gsub(/ MHz/,"",$2); if($2+0<1000) c++} END{print c+0" idle samples"}' gpu-run.log

6.8 Hotwords

Plain quoted string — no file support. Load from a file via shell substitution:

HOT=$(tr '\n' ' ' < /mnt0/hotwords/rio_dell.txt)
... --hotwords "$HOT"

Constraints: shares the 224-token prompt window with initial_prompt, so keep to a few dozen terms — a long roster overflows and terms are silently dropped. It is a bias, not a guarantee. Measured cost ≈ 90 s across 29 files. Enabling it also auto-changes --prompt_reset_on_no_end behavior. --rehot is documented as "for Dev experiments" — leave alone.

Verify hotwords actually help by grepping target names in output with and without, not just by timing.

Measured 2026-08-28 (test/test_hotwords.sh, Humboldt County BoS, two files). Real but partial, and it scales with how often the target names occur:

File Baseline → hot (canonical proper-noun hits) Cost
Special session, 3 min no change (0 delta) negligible
Regular meeting, 7.4 h 331 → 368 (+37, +11%), 0 regressions +16 s (~4%)

On the 7.4 h file, 11 of 21 terms improved (Madrone +6, Humboldt +8, Bushnell/Arroyo/Bohn +4, six place names +2, Wilson +1); none fell. The 3 min special changed nothing — too few mentions for the bias to land.

The count overstates it — read the context. The surname Bohn (homophone of "bone") shows 0 → 4 in the table, but in the transcript baseline wrote "Supervisor Bone" ×25 / "Bohn" ×0 and hot wrote "Bohn" ×3 / "Bone" ×23 — the bias flipped only 3 of ~25 mentions. Hotwords nudge; they do not reliably win a hard homophone. Never trust a per-term delta without grepping the actual lines (the whisper.cpp side, 10.4, learned the same for unique-line %). Bottom line: worth enabling on long meetings (+11% canonical forms, ~4% cost, zero regressions), pointless on short clips, and not a fix for hard homophones.


7. Diarization

Bundled — no Hugging Face token needed

Confirmed present in _xxl_data/faster_whisper/assets/:

  • pyannote_vad_v3.bin / .onnx — segmentation (speech regions, overlap detection)
  • pyannote_wespeaker-voxceleb-resnet34-LM.bin — speaker embedding model
  • pyannote_wespeaker-voxceleb-resnet34-LM.yaml — pipeline config

Purfview ships extracted weights as plain assets, bypassing the gated HF repo. The HF-token requirement widely documented online is a WhisperX requirement, not this tool's.

reverb_v1 / reverb_v2 are not bundled and would download on demand — and are licensed for personal non-profit use only. Given this corpus's destination, stay on pyannote unless you've reviewed those terms yourself.

Backends

Option Notes
pyannote_v3.0 Fastest on CPU
pyannote_v3.1 Same quality, faster with CUDA — use this
reverb_v1 Reportedly better than pyannote v3; license-restricted
reverb_v2 Slowest, reportedly best; license-restricted

First run

mkdir -p /mnt0/whisper-tmp/diar

time env -u LD_LIBRARY_PATH "$FW" \
  Rio_Dell_City_Council/2026/Rio_Dell_City_Council_Meeting_of_2026-08-04.mp3 \
  --model=large-v3-turbo -l en --beam_size=1 \
  --model_dir /mnt0/whisper-models \
  --diarize pyannote_v3.1 \
  --speaker Speaker \
  --min_speakers 3 --max_speakers 12 \
  --output_format srt json txt \
  --output_dir /mnt0/whisper-tmp/diar

Scripted as test/test_diarize.sh <Jurisdiction> [year] [sample.mp3] (honours solo timing, no --sentence, --batched off, per-run log). First run measured 2026-08-26 — see §5 "Diarization — first measurement": ~18× realtime, ~55% of the wall-clock is the diarization pass, and it saturates the power cap.

Options

Flag Purpose
--speaker WORD Replaces the SPEAKER label string — this is how you get Speaker_00 style output
--num_speakers N Exact count when known; disables min/max
--min_speakers / --max_speakers Bounds when count is uncertain
--diarize_dump Writes diarization output to a file
-embeddings Outputs speaker embedding vectors
--diarize_only Diarize without transcribing — fast iteration on speaker counts
--diarize_device / --diarize_threads Automatic; documented as do-not-touch
--diarize_ff Automatic; enables diarization after --ff_... filters

Behavior to expect

  • --diarize auto-activates --sentence — don't pass both. Affects all output formats except json.
  • JSON carries no speaker labels — confirmed 2026-08-26. A diarized run's json segment has no speaker field and unlabeled text (an observed segment: text: ' You gotta watch Victor.', speaker: None), while the srt for the same run had [Speaker_XX] on every cue. Labels are injected only into the sentence/subtitle formats (srt/vtt/tsv/lrc, and txt). For speaker data parse the SRT, or use --diarize_dump for structured turns — never the json. To get word-level text with speakers, join json word times against --diarize_dump turns by timestamp. This matters for the C3 write-back: the json alone cannot populate a speaker column.
  • Labels are arbitrary per file. SPEAKER_00 in one meeting has no relation to SPEAKER_00 in the next. Diarization clusters voices within a recording; it does not identify people.
  • It is a full second pass — embedding extraction plus clustering. Time one file before running a batch.
  • VRAM contention. Diarization and --batched both want the card; on 12 GB they may not coexist at bs=8. Test separately, then together.
  • Bounding speaker count helps. Council meetings have a few members plus rotating public commenters. Bounds prevent splitting one person across labels or merging two quiet speakers.
  • Known weak spots: crosstalk, very short utterances ("Aye."), and roll-call votes where each person says one word in sequence — unfortunate, since roll calls are exactly what's most valuable to label.

Use --diarize_only to tune --min_speakers / --max_speakers without re-transcribing.

Cross-meeting speaker identity

-embeddings emits WeSpeaker ResNet34 vectors — voice fingerprints. The same council member should occupy a similar region of that space across meetings, so cosine similarity against a small hand-labeled reference set could map Speaker_00 → an actual name without any training. That's the path from per-file labels to a genuinely searchable archive. A project, not a flag.


8. Outstanding

8.1 LD_LIBRARY_PATH pollution — before pointing cron at r245.4

/home/access/Whisper-Faster-XXL/_xxl_data is on LD_LIBRARY_PATH system-wide. Evidence: /usr/bin/find emits

find: /home/access/Whisper-Faster-XXL/_xxl_data/libselinux.so.1: no version information available

System binaries are already loading r192.3.1's bundled libs. Harmless for find; not harmless for r245.4, whose CUDA 12 libs get shadowed by r192.3.1's CUDA 11 libs — surfacing as a confusing cuDNN error rather than a path problem.

echo "$LD_LIBRARY_PATH"
grep -rn 'LD_LIBRARY_PATH' ~/.bashrc ~/.profile ~/.bash_profile /etc/environment /etc/profile.d/ 2>/dev/null

Comment out the _xxl_data entry, then exec bash -l. env -u LD_LIBRARY_PATH is a per-command workaround — it won't protect cron jobs or scripts that omit it.

8.2 Move the model cache off root

du -sh /home/access/Whisper-Faster-XXL/_models
mv /home/access/Whisper-Faster-XXL/_models/* /mnt0/whisper-models/
df -h /

Leave .cache alone — bound to the old install; r245.4 builds its own.

8.3 CIFS mounts not in fstab

/mnt3 and /mnt5 are mounted from 192.168.x.x but absent from /etc/fstab. No impact here (no reboot), but they may not return after an unexpected restart.

systemctl list-units --type=mount | grep -E 'mnt3|mnt5'
findmnt -o TARGET,SOURCE,FSTYPE,OPTIONS /mnt3 /mnt5

8.4 Separate binary from data

/home/access/Whisper-Faster-XXL holds the old binary, _xxl_data, _models, .cache, and hundreds of *_already_done / *_mp3s / *_still_to_do manifests in one directory — which is how _xxl_data ended up on LD_LIBRARY_PATH. Binary is now on /mnt0; finishing the split makes the next upgrade a directory swap.

8.5 Unmeasured

  • --vad_method pyannote_v3 (tests the GPU-idle finding)
  • large-v2 batched — now more interesting: if overhead dominates, large-v2 may also cost little
  • --beam_size=5 (may be near-free when latency-bound)
  • turbo vs medium.en transcript quality (speed settled; quality still unmeasured)
  • three-way concurrency (expected to add little — GPU is the constraint)
  • diarization accuracy (cost now measured — §5: ~18× realtime, power-bound; concurrency for diarized runs untested and not safe to assume)

8.6 Retire r192.3.1

Keep until a full production batch runs clean on r245.4. Rollback is invoking the old path; nothing was overwritten.


9. Method notes

Two measurement mistakes worth not repeating:

  • Confounded runs. The bs=8 vs bs=12 comparison also changed hotwords. Change one variable per run.
  • Wrong model assumed. Several runs were analyzed as large-v2 when they were large-v3-turbo. Record the full command line with each timing.

A third error, caught 2026-08-25: the §5 table originally compared medium.en at 103–115× (the tool's per-file reported speed) against turbo at ~73× (wall-clock over the batch). Different metrics, and the comparison inverted the actual result. Only compare wall-clock over identical inputs.

A fourth error: a duplicate-file pattern observed in this 29-file corpus was described as "roughly half your transcription compute is redundant" and projected onto the 3M-item corpus. The duplication is a known property of the the access center joint franchise, affects some jurisdictions and not others, and is not a rate that generalises. State the denominator before stating the rate.

A fifth: two concurrent processes were told to write to one stdout, making the per-process completion times unrecoverable. Redirect each to its own log.

Metrics that mislead: GPU utilization % (any kernel resident reads 100%), dmon's mem column (bandwidth, not occupancy), and the tool's per-file transcription speed — it reports only the last file of a batch and excludes model load and inter-file overhead, so it is neither a run average nor comparable across concurrent processes.

↑ back to contents10.3-whisper-asr-gpu-01/gpu-01-fwxxl-r245-upgrade.md

10.4whisper-cpp-cross-platform-benchmarkswhisper.cpp ASR benchmarks across the non-CUDA fleet — Vulkan on the AMD boxes (worker-05/6, worker-12) and Metal on the Macs; the counterpart to 10.3's CUDA/Purfview work.

whisper.cpp Cross-Platform Transcription Benchmarks

Local ASR pipeline for civic meeting audio, measured across five platforms.

Start with CLAUDE.md — full session context, all benchmark results, and the findings and script bugs that are expensive to rediscover.

Layout
  • CLAUDE.md — project context, auto-loaded by Claude Code
  • docs/ — runbooks and analysis (.md is canonical; .docx/.pdf generated)
  • scripts/ — benchmark harness, scorers, per-fleet deploy, audio conversion
Quick start

Benchmark a Linux/Vulkan box:

cd scripts
./fetch-models.sh /path/to/whisper.cpp        # ~9.4 GB, four models
./bench-worker-12.sh /path/to/meeting.wav my-run

Benchmark a Mac (or any platform, fewer variants):

THREADS=4 ./run-detached-portable.sh /path/to/meeting.wav my-run

Score a transcript on its own:

./score-transcript.sh --detail transcript.txt
./score-transcript.sh --table  ~/whisper-bench/my-run/*.txt
Trying Parakeet (alternative to whisper.cpp)

NVIDIA Parakeet tops the Open ASR Leaderboard on average WER (tdt-0.6b-v2 ~6.05%) and is far faster than whisper turbo. But it is CTC/RNNT/TDT — there is no --prompt, no --carry-initial-prompt, no hotwords. The entire -mc machinery that fixed "Rio Dell" on whisper.cpp has no equivalent, so the open question is whether Parakeet's acoustic model gets the civic proper nouns right unaided. bench-parakeet.sh answers that on the same Rio Dell file with the same score-transcript.sh/terms.txt, so the result is directly comparable to the whisper turbo -mc 160 baseline (54/55). Read the NOUNS column first — Parakeet wins on speed regardless.

Two backends, one scorer (auto-detected: mlx on Apple Silicon, else cpp):

Fleet box Backend Runtime
worker-06 / worker-12 / worker-11 (AMD iGPU, Vulkan) cpp parakeet.cpp parakeet-cli
gpu-01 (RTX 3060) cpp parakeet.cpp (CUDA)
M1 / M2 (Metal) mlx parakeet-mlx via uv

Linux / Vulkan — parakeet.cpp

git clone --recursive https://github.com/mudler/parakeet.cpp
cd parakeet.cpp
cmake -B build -DPARAKEET_GGML_VULKAN=ON && cmake --build build -j
# same Vulkan toolchain traps as whisper.cpp: render group, glslc,
# spirv-headers, and rm -rf build after fixing any of them.

# GGUF weights from HF mudler/parakeet-cpp-gguf into $HOME/parakeet-models/.
# Filenames are the SHORT model name + -f16.gguf, e.g. tdt-0.6b-v2-f16.gguf — no
# "parakeet-" prefix. Other quants: q8_0 q6_k q5_k q4_k.
mkdir -p ~/parakeet-models && cd ~/parakeet-models
for m in tdt-0.6b-v2 tdt-0.6b-v3 ctc-1.1b; do
  curl -fL -o "$m-f16.gguf" \
    "https://huggingface.co/mudler/parakeet-cpp-gguf/resolve/main/$m-f16.gguf"
done

# run the benchmark (Phase A model sweep + Phase B beam sweep on the winner)
cd -  # back to whisper-benchmarks/scripts
PARAKEET_BIN=~/parakeet.cpp/build/examples/cli/parakeet-cli \
  ./bench-parakeet.sh Rio_Dell_City_Council_Meeting_of_2026-08-18.wav worker-11-parakeet

uv on the Macs (parakeet-mlx)

parakeet-mlx is the easy Apple-Silicon path — native MLX, no compile. It is a Python tool, run through uv so nothing touches the system Python.

# 1. install uv (either one)
curl -LsSf https://astral.sh/uv/install.sh | sh     # official installer
brew install uv                                     # or via Homebrew

# 2. ffmpeg is required (parakeet-mlx shells out to it to decode/resample)
brew install ffmpeg

# 3. install the CLI as a uv-managed tool — puts `parakeet-mlx` on PATH,
#    isolated in its own venv, no `pip install` into anything shared
uv tool install parakeet-mlx -U
#    later: `uv tool upgrade parakeet-mlx`  /  `uv tool uninstall parakeet-mlx`

# 4. run the benchmark — BACKEND=mlx is auto-detected on Darwin.
#    Models auto-download from HF (mlx-community/parakeet-*) on first use.
./bench-parakeet.sh Rio_Dell_City_Council_Meeting_of_2026-08-18.wav m1-parakeet

The 8 GB M1 is fine — tdt-0.6b is smaller than the whisper turbo it already runs. First run downloads the model into ~/.cache/huggingface.

Unattended (detached) runs

The Rio Dell file is 34 min and Phase A sweeps several models, so on a laptop run it detached — it survives disconnect, and on macOS caffeinate keeps the system awake. Pass the harness via BENCH; all extra env is inherited, and console.log lands with the results.

# Mac (mlx) — caffeinated, survives SSH drop
BENCH=bench-parakeet.sh BACKEND=mlx \
  ./run-detached-portable.sh Rio_Dell_City_Council_Meeting_of_2026-08-18.wav m1-parakeet

# worker-11 (Vulkan / parakeet.cpp)
BENCH=bench-parakeet.sh PARAKEET_BIN=~/parakeet.cpp/build/examples/cli/parakeet-cli \
  ./run-detached-portable.sh Rio_Dell_City_Council_Meeting_of_2026-08-18.wav worker-11-parakeet

# then, per the wrapper's printed hints:
tail -f ~/parakeet-bench/<tag>/console.log     # watch
cat     ~/parakeet-bench/<tag>/summary.txt      # result

Compare Parakeet against the whisper baseline

./score-transcript.sh --table \
  ~/parakeet-bench/*/A-*.txt  ~/whisper-bench/<best-run>/*.txt

The number that matters: does any Parakeet variant match whisper turbo's 54/55 proper nouns, given it has no vocabulary lever to lean on?

State

Measured on five platforms — Beelink worker-06, MacBook M2, Mac mini M1, Peladn worker-12, and ThinkPad worker-11 — against the same 2056.7 s Rio Dell file. The worker-11 is the fastest box (~18×, -mc 160, 54/55 nouns, RDNA3 12 CU); the worker-12 remains the well-characterised reference. The worker-12 large-v3-turbo -mc sweep is complete (-mc 160 won, 54/55). See the Results section and "Open items" in CLAUDE.md for what's left — a multi-meeting corpus, one hand-corrected transcript for real WER, and the gpu-01 --hotwords and Parakeet comparisons.

Regenerating documents

Follow the pile-wide convention (root CLAUDE.md): rebuild the .docx from the existing one as a style reference, normalize the paragraph style pandoc tags on the first paragraph, then render the PDF.

# 1. edit docs/NAME.md, then rebuild the docx using the current one as the
#    style reference (write to a temp so pandoc isn't reading its own output)
pandoc docs/NAME.md -o docs/NAME.new.docx --reference-doc=docs/NAME.docx
mv docs/NAME.new.docx docs/NAME.docx

# 2. normalize pandoc's first-paragraph style (else it renders over-indented)
d=docs/NAME.docx; tmp=$(mktemp -d); unzip -q "$d" -d "$tmp"
sed -i 's/w:val="FirstParagraph"/w:val="BodyText"/g' "$tmp/word/document.xml"
(cd "$tmp" && zip -qr - .) > "$d"; rm -rf "$tmp"

# 3. render the PDF
soffice --headless --convert-to pdf docs/NAME.docx --outdir docs/

Do not add --toc — LibreOffice renders pandoc's TOC field as an empty heading. Keep the version table in each doc current.

↑ back to contents10.4-whisper-cpp-cross-platform-benchmarks/whisper-benchmarks/README.md

10.5fleet-dirname-collection-mapThe rescued dirname → collection map (2,133 rows, 10 workers); 132 deliberate exceptions, 42 multi-directory collections.

Fleet dirname → collection map — rescue copy

What this is. The mapping from each worker directory (basename(metadata_dir)) to the Internet Archive collection that directory's uploads land in. It is the bridge between the two grains the CMA is keyed on:

  • dirname — one per channel/working directory; lowercase on the workers and in collector.db, UPPERCASE in archive.db.metadata.dirname.
  • collection — the IA collection identifier; the grain a manager asks about.

They are equal for 1,998 directories and deliberately different for 132. Those 132 are not errors — they are the reason this file has to exist. acctvny really does feed collection ac5ny; datv really does feed datvdayton.

This is the same concept the archive-side pipeline calls conf/directory_collection_map.psv (built by build_map.py from the _meta.xml tree on archive-store), but derived independently, from the worker side.

Why it was rescued

The chain of custody was /tmp on ten worker machines → one operator's home directory on worker-01 → nowhere else. Three things made that untenable:

  1. /tmp was the origin. A rebuilt worker cannot regenerate its rows.
  2. 63 dirnames in this map have no channel row in collector.db. For those, this file is the only record that the directory maps to a collection at all.
  3. The two conflicts below are unresolvable without all ten files, because each host holds only its own half.
Provenance
  • Source: worker-01:~/<host>_dirname_collection_map.psv, ten files.
  • Copied: 2026-08-30, scp -p (mtimes preserved), by Claude, at the user's instruction. Source files were not modified, moved, or deleted.
  • Verified: all ten md5sums identical to source after copy.
  • File mtimes span 2026-06-26 → 2026-07-04, i.e. this is a snapshot of the fleet as it stood at the end of June 2026, not a live view.
host rows host rows
worker-01 468 worker-07 285
worker-02 336 worker-06 255
worker-04 208 worker-03 181
worker-05 178 worker-08 126
worker-10 70 worker-09 27
Files here
file contents
raw/*.psv The ten source files, byte-identical to worker-01. Never edit these.
dirname_collection_map_consolidated.psv host|dirname|collection, header, LF. 2,133 well-formed rows.
MALFORMED.psv host|source_line|raw_text. 1 row. Kept verbatim rather than guessed at.
CONFLICTS.psv Dirnames that map to more than one collection across hosts. 2 rows.

Round-trip verified: stripping the host column from the consolidated file reproduces each raw file's well-formed lines exactly, per host. 2,133 + 1 malformed = 2,134 non-empty raw lines. Nothing was dropped or normalized silently.

Population (denominators)

Counted over all 2,133 well-formed rows — the entire map, not a sample:

count
unique dirnames 2,128
unique collections 2,079
identity pairs (dirname == collection) 1,998
exception pairs (dirname != collection) 132
collections fed by more than one dirname 42

The 42 matter operationally: a collection fed by four directories is only quiet when all four are quiet. whcama and lmctvnyv have four feeders each; rompeviento_tv, nwctc, hwcamma, ehmmav, bcoco have three.

Known defects — recorded, not repaired

Four dirnames out of 2,128 (0.19%). Nothing here has been corrected; the right resolution is evidence from build_map.py on archive-store, which derives the same mapping independently from IA's own metadata.

Conflicts — one dirname, two collections (CONFLICTS.psv)

dirname ser/host A ser/host B
actvma worker-03 → actvma worker-01 → actonma
rctvma worker-07 → rctvma worker-02 → randolphcommunitytv

Both also appear in collector.db as a directory with two YouTube channels on two different hosts with different remote_totals — the same problem seen from the other side, which is mutual confirmation rather than two separate bugs.

Map vs collector.db drift

Compared against collector.db on worker-01 (read ?immutable=1, 2026-08-30):

  • 6 disagreeing rows of 2,075. Four are the two conflicts above. The other two are genuine drift: lpsmo (map lpsmo, db klps18mo) and vmawi (map vmawi, db cvctvwi).
  • 63 dirnames in the map with no collector.db channel row.
  • 7 dirnames in collector.db not in the map.

Otherwise collector.db.channels.ia_collection is a faithful copy of this map.

Malformed row (MALFORMED.psv)

worker-03|130|nasa_tv?mnt4

Sorts where nasa_tv belongs, between nactv1mb and npctv. A ? sits where the | should be; mnt4 may be a volume name rather than a collection. It is not guessed at here. Corroborating evidence from collector.db:

  • A channel exists with metadata_dir=/home/access/youtube-archive/metadata/nasa_tv on worker-03, remote_total 6,168, status=active — and ia_collection blank (one of only 6 such channels).
  • That same channel (UCLA_DiR1FfKNvjuUpBHmylQ) is attached to 155 rows in channel_collection — by far the most of any channel, consistent with a fallback firing because the collection was never resolved.

So this single malformed line is very likely the root cause of a 6,168-video channel having no collection attribution anywhere in the system. Worth a human decision, not an automated fix.

Caveats
  • This is a June-2026 snapshot. It is not authoritative for the fleet today.
  • It is worker-side. It records where uploads were sent, not what IA actually holds. archive.db is authoritative for the latter.
  • Coverage is not the whole CMA. 2,079 collections here against roughly 2,200 in archive.db (a documented figure, not re-verified — archive-store was unreachable on 2026-08-30). The remainder are largely direct-to-IA / user-supplied collections with no fleet directory.
  1. Load into collector.db as dirname_collection_map(dirname, collection, host, source, first_seen, last_seen) so it enters the backup path and becomes joinable. This file stays as the provenance record.
  2. Cross-check against archive-store once reachable: run build_map.py --root /mnt/md0/poweredge710/data/archive_stats --include-identity and diff. Two independent derivations of the same fact settle the four defects on evidence.
  3. Resolve nasa_tv — 6,168 videos with no collection attribution.
  4. Stop writing this to /tmp. Whatever produces these files should write to a durable path and push to the collector, like every other worker artifact.

Addition 2026-09-09 — dirname_primary_collection_2026-09-09.psv

The archive.db side of the map, which "Recommended next steps" #2 asked for. Derived on archive-store from IA's own metadata, not from worker directories:

select m.dirname, c.collection, count(*)
from metadata m
join collection c on c.identifier = m.identifier and c.role = 'primary'
where m.status = 'active' and m.dirname <> ''
group by m.dirname, c.collection;

file:/mnt/md0/datasette/archive.db?immutable=1 (WAL — ?mode=ro fails), sync of 2026-09-02.

rows 2,231
distinct dirnames 2,188 — the whole CMA, vs 2,079 in channel_map.psv
distinct primary collections 2,193
collection == lower(dirname) 2,064
collection != lower(dirname) 167
dirnames with >1 primary collection 24
collections fed by >1 dirname 22

The 167 are the point. Guessing a collection identifier by lowercasing the dirname is right 92.5% of the time and silently wrong the rest, and the misses cluster in exactly the collections a writer or researcher reaches for first — the old, hand-built, digitised-from-tape ones:

dirname actual IA collection items
FCA foxborocableaccess 2,708
SCATV somervillecommunityaccesstelevision 3,649
AACS doubleacs (Attleboro Access Cable System) 6,694
AH access_humboldt 12,381
WCCA wcca_tv 10,261
SCM SeattleCommunityMedia 11,096
MCAT mcatcollection 12,004
EAS eastoncat 11,952
MA akakumauicommunitymedia 5,819
AMC / AMA AmherstMedia / amherstmediaarchives 5,376 / 1,618
BETV berkeleycommunitymedia 1,331
LTC ltcmediacenter 3,856
BRATTVT brattvvt 12,473
PUBLICIUK public-i-webcast-archive 12,671
ROMPTV rompeviento_tv 12,155

channel_map.psv resolves only 4 of those 18, because it is fleet-derived and these are largely direct-to-IA or legacy collections with no scraping directory. Note also that identifiers are case-sensitive: AmherstMedia and SeattleCommunityMedia are not lowercase.

Caveats unchanged from above: this is a derived map, role='primary' is one item's directory collection rather than a curatorial statement, and the 24 dirnames with more than one primary collection are the same grain problem recorded in 10.7-cma-collection-freshness/RETRACTION_2026-09-01_dirname_vs_collection_grain.md — a dirname is not a collection.

First consumer: 40.7-cma-public-stories, which needed real collection URLs for 37 stations named in the text.

↑ back to contents10.5-fleet-dirname-collection-map/PROVENANCE.md

10.6ia-scrape-api-anomalyReproducible bug report: the IA Scrape API returns another query's results for certain count+fields combinations, HTTP 200, no error.

Scrape API returns another query's results — /services/search/v1/scrape

Reported by: John Hauser (jhauser@pobox.com) Date of observations: 2026-08-30, 17:2x–17:40 UTC (plus an independent sighting via the ia CLI on the evening of 2026-08-29) Client public IP: 206.124.26.30 Severity (our view): high — wrong data is returned with HTTP 200 and no error, so a client cannot detect it.


Status: submitted 2026-08-31. Filed as a GitHub issue on jjjake/internetarchive, and this document sent by email with wider project context to Jake Johnson, the project maintainer. Related to #797, which reports the same endpoint returning a stale value for every query.

Summary

For certain (count, fields) parameter combinations, GET/POST https://archive.org/services/search/v1/scrape returns a result set belonging to a different query, ignoring the q parameter entirely. The response is HTTP 200, well-formed, and carries no error, no Age, no X-Cache, and no Retry-After header.

The clearest demonstration: a query for a collection that does not exist returns 5,291 items belonging to an unrelated collection.

Three different queries issued one second apart returned byte-identical results:

# captured 2026-08-30T17:37:37Z
GET  17:37:37  q=collection:sctvct                      total=5291  first_item=2012_Voice_of_The_Voter_Debate_CT_16th_District_State_Representative
GET  17:37:38  q=collection:cobcov                      total=5291  first_item=2012_Voice_of_The_Voter_Debate_CT_16th_District_State_Representative
GET  17:37:38  q=collection:zzzznotarealcollection12345 total=5291  first_item=2012_Voice_of_The_Voter_Debate_CT_16th_District_State_Representative

Ground truth (confirmed via the internetarchive Python library at 17:37:51, same host, 13 seconds later):

LIB  17:37:51  q=collection:sctvct                      total=5291  first_item=2012_Voice_of_The_Voter_Debate_CT_16th_District_State_Representative
LIB  17:37:53  q=collection:cobcov                      total=4814  first_item=021017_TEASE
LIB  17:37:53  q=collection:zzzznotarealcollection12345 total=0     first_item=none

So cobcov has 4,814 items and the bogus collection has 0 — but the first set of requests reported 5,291 for all three, with sctvct's payload.

How to reproduce

Please read the caveat below before spending time on this — we could not reproduce it from a clean starting point, only observe it on already-affected parameter tuples.

1. The tuples that were failing for us

These two returned sctvct's payload for any q, repeatedly, from two machines, for at least 15 minutes on 2026-08-30 ~17:25–17:40 UTC:

U='https://archive.org/services/search/v1/scrape'

# Both of these should return total:0 (the collection does not exist).
curl -sS --compressed "$U?q=collection:zzzznotarealcollection12345&fields=identifier&count=100" | jq '.total'
curl -sS --compressed "$U?q=collection:zzzznotarealcollection12345&count=100" | jq '.total'

Expected 0. We observed 5291, with sctvct's items in .items.

Control — the same query with any other count was correct throughout:

curl -sS --compressed "$U?q=collection:zzzznotarealcollection12345&fields=identifier&count=101" | jq '.total'   # -> 0, correct

2. The pattern we suspect, and could not trigger deliberately

Our working theory is that a response gets bound to a (count, fields) key and then returned for later requests using that key regardless of q. The obvious test is to prime a fresh tuple and then reuse it:

U='https://archive.org/services/search/v1/scrape'
N=698; F='identifier,mediatype,language'          # a tuple never used before

curl -sS --compressed "$U?q=collection:sctvct&fields=$F&count=$N" | jq '.total'   # prime with collection A
curl -sS --compressed "$U?q=collection:cobcov&fields=$F&count=$N" | jq '.total'   # then ask for collection B

If the theory held, the second call would return 5291 (A's answer). It did not — we got the correct 4814, and a third call for the nonexistent collection correctly returned 0. So priming a fresh tuple did not reproduce it for us.

3. What this means for your investigation

The affected tuples were already in the failing state when we found them, and we never saw one transition from correct to incorrect under our own traffic. So:

  • Reproducing from a clean client may not work. Inspecting the state associated with the keys (count=100, fields=identifier) and (count=100, no fields) may be more productive than re-running our commands.
  • The wrong payloads we saw were sctvct (5,291 items) and, earlier, a total of 5,416,280, which we never queried for and which looks like an unfiltered result.
  • Requests were plain HTTPS to archive.org, mostly anonymous, at a low rate (a few requests per second at most, a few hundred over ~20 minutes).
  • If it is per-client state, our public IP was 206.124.26.30 during the window above.

4. Ground-truth values, for checking any result

collection:sctvct                       5291
collection:cobcov                       4814
collection:bouldercolorado                 0   (a Vimeo username, not an IA collection)
collection:zzzznotarealcollection12345     0
collection:community_media          3224860
Environment
Endpoint https://archive.org/services/search/v1/scrape
Resolved peer 207.241.224.2 (server: nginx/1.31.3)
Auth tested both anonymous and authenticated (S3 Authorization header, and session cookies)
Network no HTTP(S) proxy env vars, no VPN/tun interface, no local proxy listening; direct TLS to 207.241.224.2
Reproduced from two separate machines (worker-11, worker-01) sharing public IP 206.124.26.30

Exact client versions:

ia CLI / internetarchive   5.11.1   (also reproduced on 5.4.0, before upgrading)
python                     3.12.3
  requests                 2.32.3
  urllib3                  2.4.0
curl                       8.5.0 (libcurl/8.5.0 OpenSSL/3.0.13 nghttp2/1.59.0)
jq                         1.7
OS                         Ubuntu 24.04.4 LTS, kernel 6.17.0-1030-oem

We upgraded internetarchive 5.4.0 → 5.11.1 mid-session specifically to rule the client out. Both versions behave identically, and both issue the same requests: POST …/scrape?q=<query>&count=10000&total_only=true for counts, and POST …/scrape?q=<query>&count=10000&fields=<fields> for item listing.

Response headers on an affected request (nothing indicating a cache):

HTTP/2 200
server: nginx/1.31.3
date: Sun, 30 Aug 2026 17:37:54 GMT
cache-control: no-cache

No age, vary, etag, x-cache, or via header was present.


Detailed evidence

Our original reproduction

sctvct (Simsbury Community Television) has 5,291 items; cobcov has 4,814; zzzznotarealcollection12345 does not exist and must return 0.

U='https://archive.org/services/search/v1/scrape'
for C in sctvct cobcov zzzznotarealcollection12345; do
  curl -sS --compressed "$U?q=collection:$C&fields=identifier&count=100" \
    | jq -c '{q:"'"$C"'", total, first:.items[0].identifier}'
done

Observed: all three return total: 5291 and the same first item. Expected: 5291, 4814, 0.

Parameter isolation

All rows below query the nonexistent collection, so the correct answer is always total: 0. Captured 2026-08-30T17:38:50Z–17:39:02Z:

count       other params              total returned   correct?
--------------------------------------------------------------
100         fields=identifier         5291             NO
100         (none)                    5291             NO
100         fields=identifier,title      0             yes
101         fields=identifier            0             yes
150         fields=identifier            0             yes
200         fields=identifier            0             yes
500         fields=identifier            0             yes
1000        fields=identifier            0             yes
5000        fields=identifier            0             yes
10000       fields=identifier            0             yes
100         total_only=true              0             yes
10000       total_only=true              0             yes

Only specific (count, fields) tuples are affected. count=100 — the documented minimum — was affected for two field configurations; count=101 with the same field list was correct.

The tuple appears to be the key, not count itself

Using a parameter tuple never issued before in the session (count=698&fields=identifier,mediatype,language), every query was correct, including a repeat:

# 2026-08-30T17:39:26Z
q=collection:sctvct                      total=5291  first=2012_Voice_of_The_Voter_Debate_...
q=collection:cobcov                      total=4814  first=021017_TEASE
q=collection:zzzznotarealcollection12345 total=0     first=none
q=collection:cobcov                      total=4814  first=021017_TEASE

Meanwhile count=100&fields=identifier remained wrong on every retry, from both machines, for at least 15 minutes:

# 17:40:13Z  worker-11   attempts 1-3: total=5291, 5291, 5291
#            worker-01     attempts 1-3: total=5291, 5291, 5291   (same minute; not separately timestamped)

Earlier in the same session, the same tuple returned different wrong answers

The affected tuple did not always return 5,291. Over roughly fifteen minutes it returned, for the same query:

  • a correct value (5,291 for sctvct, 4,814 for cobcov) on first use;
  • total: 0 for every collection, HTTP 200;
  • total: 5416280 for every collection, HTTP 200 — three consecutive probes at 17:30:50, 17:31:10, 17:31:30 (see captures/retry_probe.log);
  • total: 5291 for every collection, with sctvct's payload.

5,416,280 is far larger than any collection we query and looks like the result of an unfiltered query.


What we ruled out

Each of these was tested directly and made no difference — the affected tuple stayed wrong and the unaffected tuples stayed right:

  • Query encoding — literal :, %3a, %3A, and collection:"value" all identical.
  • HTTP method — GET and POST both affected. (POST with parameters in the body returns 400 {"error":"Missing query"}; the library sends POST with parameters in the URL and an empty body.)
  • Authentication — anonymous, S3 Authorization header, and session cookies all identical.
  • Connection: close vs keep-alive.
  • User-Agent — curl/8.5.0, internetarchive/5.4.0 (linux), a Chrome UA, and a custom UA all identical.
  • Client library version — internetarchive 5.4.0 and 5.11.1 behave the same.
  • Client-side network — no proxy env vars, no VPN interface, no local proxy; direct TLS to 207.241.224.2. Reproduced from two machines.

The API's input validation is correct and loud, which makes the contrast sharper: count=3 returns 400 {"error":"count '3' is too small (min count=100)","errorType":"RangeException"} and an absent q returns 400 {"error":"Missing query","errorType":"DomainException"}. It is only the results that are silently wrong.

Our hypothesis (offered as a lead, not a conclusion)

The behaviour is consistent with a response cache somewhere in front of the service whose key includes count and fields but not q. That would explain: first use of a tuple being correct; subsequent different queries with that tuple returning the first one's payload; a fresh tuple behaving correctly; and the wrong value changing over time as entries are replaced.

We cannot see your infrastructure, so this is inference from client-side behaviour only. cache-control: no-cache is present on the responses, which argues against a deliberate cache and may point at something unintended.

Why this matters to us

We maintain a mirror of Internet Archive metadata for the Community Media Archive (~3.2M items across ~2,200 collections; collection:community_media reported 3,224,860 items via the library today). We were about to use the Scrape API to enumerate every collection's items in order to detect new and changed items.

Under this bug that harvest would have written one collection's item list into another collection's record, with no error to catch. Because responses are HTTP 200 and well-formed, there is no client-side signal to validate against short of already knowing the right answer.

Note that the ia CLI and Python library are not immune by design — only by their current parameter choices. Counting uses count=10000&total_only=true, but item listing uses count=10000&fields=<your fields>, which is exactly the kind of (count, fields) tuple that was affected here. An ia search --field ... invocation could return another collection's items. We believe this is what we saw on the evening of 2026-08-29, when CLI responses "did not make sense".

Questions
  1. Is there a caching layer in front of /services/search/v1/scrape, and does its key include q?
  2. Is this per-client (IP/session) or shared across clients? If shared, results could be contaminated between unrelated users.
  3. Is there a request pattern or rate that triggers it, so we can avoid it while it is investigated?
  4. Is there a header we can send to bypass the affected path, or a recommended parameterisation that is known-safe for bulk enumeration?
  5. Would you prefer we use a different endpoint entirely for full-collection enumeration at this scale?
Attachments
file contents
captures/ia_repro_get.txt the three-identical-responses capture, 17:37:37Z
captures/ia_repro_lib.txt library ground truth, 17:37:51Z
captures/ia_repro_headers.txt response headers on an affected request
captures/retry_probe.log the total=5416280 episode, 17:30:50–17:31:30Z

Happy to run any diagnostic you'd like from this client, and to hold off on bulk enumeration until you've had a look.

↑ back to contents10.6-ia-scrape-api-anomaly/IA_SCRAPE_API_ANOMALY.md

10.7cma-collection-freshnessWhich collections have stopped being updated — and the TITLE_COLLISION class of "missing" that no rerun can fix.

CMA collection freshness — which collections have stopped being updated?

Answers a manager's question — "which collections haven't been updated in a while?" — and, more usefully, distinguishes work that can be done from work that cannot.

Two tools, run in that order. Both stream output, flush per record, resume after Ctrl-C, and sample reproducibly by --seed.

tool asks costs
ia_scrape_harvest.py what does IA hold for this collection? IA Scrape API, ~60s/collection, --jobs 4
yt_freshness_probe.py what does the source channel have that IA does not? one yt-dlp --flat-playlist call per channel
yt_title_census.py which channels repeat titles badly enough to need a disambiguator? one yt-dlp --flat-playlist call per channel, ~4/min

yt_title_census.py exists because the equivalent local scan cannot see the problem: with the default %(title).90s template, every same-titled video writes to the same filename, so only one survives on disk. Measured on cofsc — local scan 0 duplicates, remote census ×206. The count has to come from YouTube.

P=~/.local/share/pipx/venvs/internetarchive/bin/python
$P ia_scrape_harvest.py --sample 75 --seed 1        # archive side
./yt_freshness_probe.py --all                       # YouTube side

Outputs land in ~/cma_scrape_out/ and ~/cma_probe_out/. The durable record is collection_item_index.psv (dirname|ia_collection|item_identifier), which also feeds re-seeding archive-store's _meta.xml tree.

Reading the census output

yt_title_census.py prints three independent measured counts per channel — none is a window, a tunable, or scaled per channel:

[7/200] 22cvma   461 vids  dup=43  max=x25
field meaning
vids videos on the channel
dup= videos whose title is shared with ≥1 other
max=xN size of the single largest same-title group

For 22cvma, N=25 because 25 videos are all titled 22-CityView - Cambridge, MA Live Stream, plus nine more groups of 2 (43 = 25 + 9×2). N varies because publishing practice varies — that is the signal, not a setting — and it is independent of channel size (pbcgfl reaches ×7 on 1,050 videos; ccxnewsmn ×12 on 16,929).

Why max drives the flag and dup does not. dup conflates one catastrophic group with many harmless pairs. Only one video per identical title can ever occupy the derived identifier, so max_group − 1 is the floor on items lost from the worst group — which is why wvual (×423) outranks klegks (×5) on comparable dup counts.

The single tunable is --min-group (default 5), identical for every channel. It encodes the practice of tolerating small duplicate counts and splitting processing only when the number gets large, and because raw/ persists every title list it can be re-tuned offline with --summary-only — no re-fetching.

Two guards that are not optional

The IA Scrape API can return another query's payload with HTTP 200 and no error (see 10.6-ia-scrape-api-anomaly/). The harvester queries a collection that cannot exist, before starting and every 25 collections, and aborts the run if it ever returns non-zero. Without it a bulk harvest silently writes one collection's items into another's.

--flat-playlist dates are approximate — median 1 day drift, mean 39, max 342. Every comparison here is keyed on video id, never date or title. That also makes it robust to the publisher practice of renaming a stream with a date and moving it to /videos.

What building this turned up
document finding
UNPREFIXED_IDENTIFIER_CLASS.md Highest-priority finding. 155 channels build identifiers with no <slug>- prefix, so they compete in archive.org's global namespace. 69 of 77 colliding titles are already taken — 7 by non-CMA collections. One awk line fixes it; nothing blocks it.
TITLE_COLLISION_CLASS.md A named class of "missing" that no rerun can fix: the derived IA identifier is already taken, so upload is a silent permanent no-op. 380 of 598 unseen videos in a 75-collection sample. Includes the v1.1/v1.2 retractions as the mechanism became clear.
PLANNING_INPUT_streams_and_identifiers.md Requirements for CMA-onboarding and the OS-independent uploader. The root cause is one conflated check: ia metadata "$id" cannot tell "same video already uploaded" from "different video, same derived id".
SCOPE_2026-08-30_where_the_fix_goes.md A dated population baseline, and the argument for fixing in onboarding (3,850 greenfield candidates — 1.85× the existing fleet) rather than in 2,079 bespoke channel directories.
Fleet-state snapshots — check these before re-sweeping

Derived host/template/prefix maps, each costing a multi-host sweep. Read them rather than re-deriving. They are snapshots: re-measure and re-date, never edit in place. Each directory carries its own README.md.

directory what it holds
fleet_state_2026-08-31/ the original set — host|dirname execution maps, output templates, awk identifier prefixes, the collision survey, orphan resolutions
fleet_state_2026-09-10/ awk/org metadata harvest, cycle-driver map, and the cobcov host-attribution correction
fleet_state_2026-09-14/ TITLE_COLLISION fix coverage — the %(id)s disambiguator measured against each host's own cycle-script population (2,004 slots, 9 hosts; worker-08 offline). 1,685 patched, 311 gap, 0 patch failures; the gap is entirely channels the patch was never aimed at. Corrects the provenance: id_patches/ is what is live, patches/ was never applied.
  • 10.5-fleet-dirname-collection-map/ — the rescued dirname → collection map these tools key on. Three naming grains must be held at once: dirname, ia_collection, and the download subdir.
  • 10.6-ia-scrape-api-anomaly/ — the Scrape API bug report, and why the sentinel guard exists.
  • 10.8-youtube-update-uploader-v2/ — the per-channel upload script these findings are about, and the best existing version of what a portable uploader must replace.
Caveats carried by everything here
  • The 75-collection sample is 3.6% of 2,079. cofsc and vernca alone are 62% of all blocked videos found — do not extrapolate the 63.5%.
  • Counter-example from the same session: worker-01's 3,102 missing videos contain 0 title collisions. The class is concentrated, not uniform.
  • Probe windows cap at 500; BEHIND+ means the window was truncated and the count is a floor.
  • archive.db was unreachable while this was built, so the archive side comes from the live IA API rather than the weekly mirror.

↑ back to contents10.7-cma-collection-freshness/README.md

10.8youtube-update-uploader-v2The per-channel upload script rewrite + lessons learned; starting point for the cross-platform uploader.

youtube_update-v2 — the per-channel upload script rewrite

The v2 rewrite of the a58ma archive/upload script, plus its lessons-learned write-up. Imported 2026-08-30 from ~/Downloads; not previously in the pile. All three files are byte-identical to the copies they came from.

file what
youtube_update-v2.sh the current script — 326 lines, matches the lessons doc in full
youtube_update-v2.lessons-learned.md what changed and what was subtler than it looked
youtube_update-v2.PRE-FIX-2026-06-18.sh the earlier 203-line snapshot, kept for the record — do not run

Ignore the .sh mtime. The current script's timestamp is when it was retrieved, not when it was written; the script itself has not been modified recently. The PRE-FIX copy's 2026-06-18 mtime is genuine.

The script matches the document — verified

An earlier revision of this README flagged the two as out of sync, because the only copy then available was the 203-line pre-fix snapshot. The complete version has since been recovered and installed. Every fix the document describes is present:

fix described in the doc in the current .sh
§2 field-count validator using Python's csv module — the same engine ia upload --spreadsheet uses yes — _ia_bad_rows(), csv.reader
§2 79-field / 47-field gate keyed on the header's first column yes
§4.1 escape interior quotes s/"/""/g before | → "," and the row wrap yes, with the ordering explained in-comment
§4.2 on failure: back up to .bak, rebuild from the | source, re-test yes
§6 header rename repaired to ^item"," after the reorder broke "item" yes
§7 mawk reserves exp → variable renamed yes — local want=

The diff is exactly what the document predicts: the current script is a strict superset of the earlier one apart from two replaced lines — the old s/|/","/g and the old s/"item"/"identifier"/ — plus one wholly new section, 8b. Validate ia_upload.csv before uploading. The validator's comment even cites the failing value verbatim (the Tennyson title whose interior quotes and commas made a 79-field row parse as 81).

This is now a working reference for the fixes, not just a description of them — which matters for the cross-platform rewrite, since U8–U11 in 10.7-cma-collection-freshness/PLANNING_INPUT_streams_and_identifiers.md are drawn from exactly these mechanics.

Why this is filed at 10.8

This is the uploader, which is distinct from 10.2 (the archive dashboard / collector / monitoring). 10.2 already made the monitoring half cross-platform (agent_platform.py, macOS and Windows worker runbooks). The download/upload half is still this: bash + jq + awk + sed + GNU rename + ia. That gap is item 4 of "the biggest unfinished areas" in 00.0-stepping-back-from-CMA-operations/cma-notes.md — "Cross-platform worker process — the gate on fleet expansion beyond Linux-fluent people."

This directory is therefore the concrete starting point for that work: the best existing version of what a portable uploader has to replace.

What this script confirms about identifiers

Two things visible here corroborate findings recorded in 10.7-cma-collection-freshness/:

1. The identifier prefix follows the dirname, not the collection.

CHAN=a58ma                                  # dirname
ia search -i collection:ccatma > itemlist   # DIFFERENT collection
sed 's/a58ma-//' itemlist > itemlist_no_prefix

a58ma → ccatma is one of the 132 documented dirname≠collection exceptions (see 10.5-fleet-dirname-collection-map/). The script correctly uses the dirname for paths and the identifier prefix, and the collection for the IA query. Any reimplementation must keep those two keys distinct — see [[cma-dirname-vs-collection-grain]].

2. /videos only — no /streams. Consistent with the fleet sweep: 38 of 4,111 backlog scripts fetch /streams at all. See 10.7-cma-collection-freshness/PLANNING_INPUT_streams_and_identifiers.md.

The lessons that generalise beyond this script

Four of the document's takeaways are not a58ma-specific and should carry into the OS-independent uploader (folded into 10.7's planning input as requirements U8–U11):

  • A validator that disagrees with the real parser is worse than none — it manufactures false confidence. The naive ","-token counter failed open on a genuinely malformed row (the Tennyson title with interior quotes and commas), reporting 79 fields for a row the real parser read as 81. Measure with the same library the consumer uses.
  • Don't hand-roll a CSV parser. The quote-aware awk attempt returned 5 for a row the real parser read as 7, and the error failed open.
  • Fix format corruption at the last unambiguous point — the |-delimited source, where every boundary is still explicit. After the lossy transform you cannot distinguish structural from content quotes, so repair becomes guessing. This is why the repair rebuilds rather than patches.
  • Design repairs around a real failing example, not a hypothesised one. The earlier \" → "" repair was a no-op for the problem it claimed to fix; the actual corruption was bare interior quotes. A single concrete failing case beat a page of reasoning.

Plus two operational gotchas worth keeping: reordering a sed step invalidates every later step that matches on intermediate text (it silently broke the item→identifier rename), and mawk reserves exp as a builtin so awk -v exp=… dies with a non-obvious message.


REQUIREMENTS FOR THE REWRITE — filename collisions (added 2026-09-02)

The cross-platform uploader must not inherit the current filename scheme. A defect measured on 2026-09-02 makes this concrete: see 10.7-cma-collection-freshness/FINDING_2026-09-02_restrict_filenames_collapses_non_ascii_titles.md.

Fleet-wide, live channels with no disambiguator: 1,734 channels, 87,025 videos would be lost. Of those, 523 videos across 60 channels have titles that reduce to _ or - entirely — the source of the stray _.mp4 files seen on the filesystem.

What the new uploader must get right
  1. The identifier must be unique by construction, not by hope. Today it is derived from the filename, which is derived from the title. Two videos with the same title — or two whose titles both vanish under --restrict-filenames — produce the same identifier. Include the video id. %(upload_date)s is NOT sufficient: multilingual and live-stream channels post several videos the same day (177 of mcpsmd's 224 losses survive a date suffix).

  2. Non-ASCII titles must survive as metadata even when they cannot survive as a filename. The current architecture is right here and must be preserved: IA identifiers are ASCII (0 non-ASCII fleet-wide) while IA titles keep Unicode (97,806 items fleet-wide). Only the identifier degenerates. Do not "fix" this by transliterating.

  3. A skipped download must never be recorded as complete. --no-force-overwrites + a shared --download-archive means yt-dlp skips the write and still records the id, so the loss is permanent and silent. Whatever the new uploader uses for state, record only on verified success.

  4. The identifier source of truth must be explicit. Today it is ._filename inside the info.json, not the file on disk — renaming a file does not change the identifier. That indirection cost real debugging time; make it obvious.

  5. Enforce the 100-char cap at construction, not by truncating afterwards. prefix + title_budget + separator + 11-char id must be < 100, and the budget must be derived from the prefix length per channel.

  6. Fail loudly. Every symptom here was silent: yt-dlp exited 0, the log said only "already been downloaded", and the id was recorded. The new uploader should treat "the output filename already exists for a different video id" as an error, not a skip.

A test case worth keeping

mcpsmd (Montgomery County Public Schools, MD) publishes the same parent- information series in English, Amharic, Chinese and Korean on the same day. Any scheme that survives that channel handles the general case.

이제 압니다! 성적표          -> old: '_'   new: '_-IQFVuU0WRjY'
현재您知道了! 返校倒計時      -> old: '_'   new: '_-<id>'

↑ back to contents10.8-youtube-update-uploader-v2/README.md

10.9archive-txt-surgeryRemoving ids from a worker's yt-dlp download-archive so items lost on the way to IA can re-fetch — 66% of "missing" videos are blocked this way, not backlog.

archive.txt surgery — remove ids so a channel can re-fetch

The problem it solves

An id in a worker's yt-dlp --download-archive means that video was already fetched and recorded. If the item then collapsed (TITLE_COLLISION) or landed under the wrong identifier, it is missing from IA and a re-run silently skips it. The id is the thing preventing recovery. Measured on a 20-channel sample, 66% of "missing" videos are in this state — they are not backlog, they are blocked.

What it refuses to do
guard behaviour
wrong worker resolves the host from the live cycle lists, not directory presence or ia_upload.csv mtime
ambiguous worker when a dirname runs on several hosts, disambiguates by which awk mints --collection; refuses if 0 or >1 match
id not in the archive ERROR, names each missing id and the file searched — never silent
prefix mismatch ERROR if the awk's prefix != --collection, or if the awk's own collection1 disagrees
no prefix at all ERROR — items would be minted into archive.org's global namespace
leftover media on disk ERROR — --no-force-overwrites means yt-dlp would skip the download and re-record the id, making removal a silent no-op
a cycle is running ERROR — refuses to edit the archive under a live job
backup already exists ERROR — refuses to overwrite a previous backup

It does not fetch anything and does not touch archive.org. Re-fetching is a separate deliberate step; the command is printed.

Resolving the archive file — never assumed

The path is resolved the way yt-dlp resolves it: the backlog script's own --download-archive wins, else the --config-locations chain is followed. 145 configs on the fleet use a relative archive.txt (per-dirname) and 191 the shared /home/access/archive.txt, so assuming the shared path would be wrong about a third of the time. The resolved path, how it was resolved, the config chain, size, line count and mtime are all reported before anything happens.

Usage
./archive_txt_surgery.py --self-test
./archive_txt_surgery.py --dirname <d> --collection <c> --ids-file <f>            # dry run
./archive_txt_surgery.py --dirname <d> --collection <c> --ids-file <f> --sandbox  # rehearse on a copy
./archive_txt_surgery.py --dirname <d> --collection <c> --ids-file <f> --yes      # apply
./archive_txt_surgery.py --dirname <d> --restore <backup-path> --yes              # revert

The ids file takes one id per line; # comments, blanks, and id|title worksheet rows are all accepted.

Safety sequence when applying
  1. Resolve worker -> resolve archive path -> report both
  2. Prefix check, id-presence check, leftover-media check, running-cycle check
  3. Audit JSON written BEFORE any mutation (~/archive_surgery/audit_<dir>_<stamp>.json) — records host, archive path, every id, its line numbers, the expected backup path and the exact restore command
  4. cp -p backup to <archive>.bak-<stamp> (refuses if one exists)
  5. Rewrite to a temp file in the same directory, fsync, line-count check, then os.replace (atomic)
  6. Verify: every id absent, and after == before - removed
  7. Audit updated with the result; restore and re-fetch commands printed
Restore
./archive_txt_surgery.py --dirname <d> --restore <archive>.bak-<stamp> --yes

Keeps a <archive>.prerestore copy of what it replaced, and verifies the line count matches the backup. Dry-run by default here too.

Test results — 2026-09-01, all passing

16 offline self-tests (--self-test): id parsing, worker resolution including the multi-host case, all five prefix-check outcomes, and the removable/missing/blocked/duplicate-line classifier.

End-to-end against worker-01, live data:

# test result
1 dry run, happy path resolved worker-01 from 2 candidates by collection; found /home/access/archive.txt via the config chain; 6/6 removable; exit 0
2 wrong --collection (actvma vs actonma) ERROR, refused, exit 1
3 ids absent from the archive ERROR naming both bogus ids and the file searched, exit 1
4 sandbox end-to-end copied 677,447 lines; removed exactly 6 -> 677,441; all 6 verified absent; restore verified; sandbox cleaned up
5 leftover media on disk (cobva) ERROR, named the blocking .mp4, exit 1
6 --restore dry run resolved archive, acted on nothing without --yes
7 --restore with a missing backup ERROR, exit 1

The real /home/access/archive.txt was never modified — verified after the run: 677,447 lines, mtime unchanged at 2026-08-29 16:15:55, zero stray backup or sandbox files.

Two bugs were found by the end-to-end test and fixed: the driver sent $HOME/... as a literal that the worker never expanded (every path was wrong, and it presented as "no prefix found" rather than an error); and the multi-host case originally forced --host when the supplied collection could disambiguate.

The test case — 6 stranded ActonTV items

actontv_6.ids. These were published 2020-10-25 under the wrong dirname and are the only copy — none of their YouTube ids appears in actonma. They are the missing first episodes of three series otherwise complete there: Transgender in the South ch.1–2, The Creature Teachers ep.1, Kelly's Quest ep.2. All six target identifiers (actonma-…) are currently free, so a re-fetch would not immediately re-collide.

APPLIED 2026-09-01 — the full loop is closed

independent pre-flight backup   archive.txt.PREFLIGHT-20260901T040834Z  (md5-verified identical)
tool's own backup               archive.txt.bak-20260901T040843Z
edit                            677,447 -> 677,441 lines, 6 removed, VERIFIED
independent diff vs backup      exactly the 6 target lines gone, 0 lines added
re-fetch (JH, on worker-01)         all 6 downloaded and re-recorded in archive.txt
upload to archive.org           all 6 present, collection=['actonma', 'community_media', 'newsandpublicaffairs']

Verified on IA with a sentinel query (actonma-zzzznotarealitem12345 -> MISSING), per the scrape-API contamination lesson; ia CLI 5.11.1, direct metadata endpoint.

Three series in actonma are now complete. The audit JSON records the whole operation at ~/archive_surgery/audit_actvma_20260901T040843Z.json.

One title had changed upstream since 2020: krPQxSK9vEc is now "The Transgender Community and Religion - Kelly's Quest - Episode 2", not "Kelly's Quest: Episode 2 - Transgender and Religion". The minted identifier follows the CURRENT title. Do not predict identifiers from archive.db titles — they are a snapshot of what was fetched, not of what the channel shows now.

Two media-guard bugs the live run exposed (v1.1)

Both would have caused the guard to mis-report, and neither was reachable in the sandbox because the sandbox never had leftover media.

  1. Extension allow-list was wrong. The guard looked for .mp4/.mkv/.webm/.m4a/.part. ActonTV produces .HD.mov, which the allow-list silently passed — the exact silent no-op the tool exists to prevent. Replaced with a sidecar deny-list: anything sharing the stem that is not a known sidecar counts as media.
  2. Ownership and prefix matching. grep -l on the video id also matched channel/playlist-level dumps (ActonTV.info.json contains all 37 ids), and glob(stem + "*") is a PREFIX match, so stem ActonTV swallowed ActonTV-2015_in_Review.HD.mov. Result: all six ids reported the same media file. Now the info.json's own top-level "id" must equal the video id and carry no entries, and a candidate must be stem + "." + suffix.

Regression-tested: cobva (.mp4) still blocks correctly, and each of the six ActonTV ids now names its own .HD.mov. They block correctly today because the media is back on disk after the re-fetch — removing those ids again would be a no-op until the uploader's cleanup runs, which is exactly what the guard is for.

What this tool deliberately leaves alone
  • The 6 existing actvma-… items sitting in Auburn's collection. IA identifiers are immutable, so a re-fetch creates correct new items; darking the old ones is a separate archive.org action.
  • Other hosts holding the same ids. Only the current worker's archive is edited. For this case worker-03 and worker-05 also hold the 6 ids, but their actvma points at ACTVAuburn, so they would never re-fetch ActonTV content.
v1.2 — mtime now advances (was: preserved)

v1.0/v1.1 used shutil.copystat / copy2, which preserved the original mtime across the atomic replace. That was wrong for two independent reasons:

  1. ls -lth is JH's default way of finding recently-changed files. A surgery that leaves mtime untouched is invisible to it — the file sorts as though nothing happened.
  2. rsync quick-check safety. rsync -auvx transfers when size OR mtime differs. Today's edit propagated only because the byte count changed. A future size-neutral edit (remove and re-add to the same length) with a preserved mtime would be invisible to every sync job, in both directions, with no error — precisely the silent-wrong failure this tool exists to prevent.

Change: copystat -> copymode (+ best-effort chown) on the live file, and copy2 -> copy for the backup and restore. Permissions and ownership survive; mtime becomes "now" on the edited file, the .bak-<stamp>, and any restore, so all three surface at the top of ls -lth. The pre-edit mtime is recorded in the audit JSON (archive_mtime_before_edit) and printed in the report, so nothing is lost.

Verified on worker-01 with a controlled file (mtime forced to 2020-01-02, mode 640):

BEFORE  mtime=2020-01-02 03:04:05  mode=640  lines=3
AFTER   mtime=2026-08-31 21:51:02  mode=640  lines=2   <- advanced, mode kept
BACKUP  mtime=2026-08-31 21:51:02  mode=640  lines=3
ls -lth  both files at the top

Full sandbox E2E re-run after the change: 677,447 -> 677,444, verified, restore verified, real archive untouched.

Which sync jobs actually carry /home/access/archive.txt

Audited across the jobs JH named as important (rsync_to_nas-01, rsync_to_gpu-01, rsync_to_archive-store, rsync*to_hp*) on the current workers. Check every rsync line — several scripts carry it on a later line (worker-07/rsync_results_to_nas-01.sh on 1 of 9; worker-02/rsync_to_gpu-01.sh on 1 of 7):

host jobs carrying archive.txt
worker-01 rsync_results_to_nas-01.sh only
worker-07 results_to_nas-01, to_nas-01, to_gpu-01, to_archive-store, to_archive-store_mnt9
worker-08 results_to_nas-01, to_gpu-01, to_archive-store, to_archive-store_mnt9
worker-02 to_gpu-01, to_archive-store
worker-06 results_to_nas-01, to_gpu-01
worker-05, worker-04, worker-09 one each
worker-03 none
worker-06 rsync_to_desktop-04.sh excluded explicitly (--exclude=archive.txt)

All are outbound, so none can overwrite a worker's archive.txt. worker-01's to_archive-store / to_gpu-01 / to_server-02 do not carry it, so archive-store's copy of worker-01's archive.txt will not reflect surgery done there.

↑ back to contents10.9-archive-txt-surgery/README.md

10.10backlog-url-repairStale channel handles -> channel ids: 16 collections were running weekly and fetching nothing because a handle changed.

Backlog URL repair — replace stale handles with channel ids

The problem

Every channel URL in the fleet's get_new_video_metadata*.sh is a handle (@somename) or a legacy /user/ name. Both change when an operator renames. Channel ids do not.

16 collections holding ~16,000 archived items were found running weekly in live cycle lists and fetching nothing because their URL no longer resolved — silent for 6 months to 7 years. Nothing surfaced it: the collections are absent from collector.db, so neither the title census nor the dormant analysis ever saw them, and a yt-dlp 404 just rolls on to the next dirname.

Replacing the handle with /channel/UC… removes the failure class rather than resetting its clock. See 10.7-cma-collection-freshness/STALE_URL_COLLECTIONS_2026-09-01.md for how the replacement ids were established and proved.

How a replacement id is proved before it is used

Not by searching for the channel name. "Canton Community Television" matches several Cantons, and name-matching is exactly what put another organisation's videos into cotmfl and nactvmb.

A video we already archived came from that collection's channel by definition. So: newest archived youtube_id -> resolve the video -> read its channel_id. Then confirm by overlap — how many ids we already hold appear on that channel. Overlap proves both that the channel is the collection's real source and that it is fetchable. mcpsmd 1,974 of 1,981; lrksprca 479 of 479.

Safety model
guard behaviour
host scope --host is required — one worker at a time, on purpose
not live refuses a dirname that is not in a live cycle list on that host
no match refuses if no URL in the file starts with the expected stale prefix
every line scans all URL lines in every file, not the first
tab preservation keeps /videos, /streams, /shorts, /live, /playlists, /featured; drops anything else
idempotent a file already containing the new base is skipped
backup per-file *.bak-<stamp>; refuses to overwrite an existing one
write atomic os.replace; mode and owner preserved, mtime advances
verify stale prefix gone and new base present, checked after the write
revert --restore-stamp <stamp> --yes

Dry-run is the default; --yes applies.

Usage
./repair_backlog_urls.py --self-test
./repair_backlog_urls.py --host worker-06                          # dry run
./repair_backlog_urls.py --host worker-06 --yes
./repair_backlog_urls.py --host worker-06 --restore-stamp 20260901T172140Z --yes
./repair_backlog_urls.py --host worker-06 --only-backlog           # skip the sibling script

By default it patches both get_new_video_metadata_backlog.sh (the executed one) and get_new_video_metadata.sh (its non-executed sibling, which carries the same stale URL). Leaving the sibling stale guarantees a future audit finds an inconsistency; --only-backlog opts out.

Applied 2026-09-01
host dirname new channel id files revert stamp
worker-06 hrdvsc UCnHalPPO5Nr0mnp38-LcBkQ 2 20260901T172140Z
worker-06 ntttx UCAbhx6ojqvvKNqWOvvnEerA (/videos + /streams) 2 (4 URLs) 20260901T172140Z
worker-06 sptspsva UC-lT2HnZcxV29VD9uaQQCNQ 2 20260901T172140Z
worker-02 mpactmi UCut6dZAdB8MNyr-L7CcXb1A 2 20260901T172152Z
worker-02 vsuga UC3jHXbvpe12hU7yEq6lbeTg 2 20260901T172152Z

worker-07 — applied 2026-09-02

dirname new channel id live list not yet collected
mcpsmd UC_lFLRgazwc3vttgNKtM0ww dirs_2024-01-15 997
msuky UCT0e2eqhOX1drXMvILDjvLQ dirs_2024-01-15 653
canctvmi UCyrSQCgLlAcyAdD5uu1vA5g dirs_2024-01-15 621
sctvme UC3rtuuyMosA1zNcbuNAsa4A dirs_2024-01-15 254
kpawca UCrLp1pV_yBUW6EgD2w52IeA 2023-11-22_dirs 45
lrksprca UCn93I9hPfvEivHnwDo32XbA ca_new_dirs_desktop-04 24
logtvbc UCqK5W26aJ2gj4lU-Gut9C-A lji_dirs 8

14 files, all verified. Revert stamp 20260902T045832Z. An independent pre-flight snapshot of all 14 originals is at worker-07:~/worker-07_urlrepair_PREFLIGHT-20260902T045824Z.tar.gz (14 members, 14 stale URLs captured), separate from the tool's per-file backups.

JH intends to run these individually rather than inside the weekly cycle batch, because of the volume of new files they will pull.

All 12 confirmed collections are now repaired — ~3,113 videos become collectable (worker-07 2,602 + worker-06/worker-02 511).

Verification performed

11 offline self-tests (5 driver, 6 helper URL-parsing). Dry run on both hosts. After applying, an independent read-back: 0 stale handle/user URLs remaining, modes preserved (664 on worker-06, 644 on mpactmi), 2 backups per dirname. Then every URL as written in the patched file was executed from the worker itself, read-only — all six resolve.

Three traps this hit

  1. ntttx had two URL lines (/videos and /streams) in each of two files, and the streams line carries the date disambiguator in its -o template. Patching only the first line would have left half the channel dead.
  2. vsuga's URL was malformed — /user/valdostastateuniv/video345s. The tab logic drops anything that is not a real channel tab, so the typo was removed rather than carried forward. Its sibling file had the clean form; the two needed different handling and both landed correctly.
  3. The first verification was wrong, not the patch. worker-02 reported entries=0 for both dirnames — it was yt-dlp: command not found. The binary is at ~/.local/bin/yt-dlp there and is not on PATH in a non-interactive ssh shell (worker-06 has it at /usr/local/bin). Any future remote check must resolve the binary via a login shell or an absolute path, or it silently reports zero.
Files
repair_backlog_urls.py   driver (self-tests, dry-run default, restore)
_url_helper.py           runs on the worker; JSON in/out; no policy decisions
url_repair_plan.psv      dirname|host|live_list|stale_url|new_channel_id|new_url|evidence

↑ back to contents10.10-backlog-url-repair/README.md

10.11cleanup-script-analysisWhich local media gets deleted after upload — the filename/identifier join, its defects, and a safer design using the video id.

<dirname>_clean_up.sh — analysis and proposed changes

Nothing has been modified. This is analysis and a proposal.

These scripts delete local media that has been successfully uploaded to archive.org. They decide by reconstructing the identifier from the filename — which is exactly the coupling the 2026-09-01/02 template work just changed.

What the script does
1  ls <downloaddir>/*.mpeg4 *.HD.mov | grep -v % | grep -v + | grep -v @   -> mnt7_files
2  ...same, strip dir and extension                                        -> mnt7_base
3  ia search -i collection:<coll> | sort -u                                -> itemlist
4  sed 's/<coll>-//' itemlist                                              -> itemlist_no_prefix
5  grep -Fxf itemlist_no_prefix mnt7_base | awk '{print "rm <dir>/" $1 ".mpeg4"}' > rm_already_uploaded.sh
6  sh ./rm_already_uploaded.sh                                             <- DELETES

The join is filename stem == identifier minus prefix.

Which copy actually runs

backlog_full_cycle.sh invokes sh {}_clean_up.sh from the metadata root, not {}/{}_clean_up.sh. On worker-01 there are 669 root-level scripts and 300 in subdirectories, and the two copies of a channel differ (the root copy points at /mnt7, the subdir copy at /mnt4). The root copy is the one that executes. Any change must target it. Called 10+ times per cycle.


Defects, worst first

A. Deletion is gated on ia search — the API we filed a bug against

669 of 669 root scripts use ia search -i collection:X. Per 10.6-ia-scrape-api-anomaly, that API has been observed returning another query's results with HTTP 200 and no error.

  • An empty response is safe: grep -Fxf on an empty pattern file matches nothing, so nothing is deleted.
  • A contaminated response is not. Identifiers from an unrelated collection could match local filename stems and delete media that was never uploaded.

There is no sentinel query, and no check that the matched item actually contains a media file — ia search proves an identifier exists, not that the upload succeeded. An empty or failed item satisfies the test.

This is the only step in the pipeline that destroys the sole local copy.

B. The filename->identifier reconstruction does not mirror the awk

rv_format_jq_metadata.awk applies transformations the clean_up does not undo:

gsub(/\+/,"",item)  gsub(/%/,"",item)  gsub(/@/,"",item)
sub(/\.m4v/,"",item)  sub(/\.mov.mp4/,"",item)  sub(/\.mov/,"",item)
if (length(item) >100) { item = substr(item,1,99) }        <- TRUNCATION

The clean_up only strips the directory and the extension. So:

  • Special characters. A file Foo+Bar.mpeg4 uploads as <coll>-FooBar. De-prefixed that is FooBar, which never equals the stem Foo+Bar. Rather than handle this, the scripts exclude such files from consideration entirely (grep -v \% | grep -v \+ | grep -v \@, present in 666 of 669). Consequence: those files are uploaded but can never be cleaned up. Measured on worker-01: 181 of 333 remaining media files (54%) contain %, + or @. They accumulate forever.

  • Long identifiers. Where prefix + title exceeded 100, the awk truncated to 99. The truncated identifier never matches the full filename stem, so the file is never deleted. This is the "long filenames aren't handled well" case.

Note the asymmetry JH identified: the special characters are irrelevant to the filename and decisive for the identifier, yet the exclusion is applied to the filename.

C. The prefix strip is unanchored

sed 's/<coll>-//' replaces the first occurrence anywhere on the line, not at the start. 1,334 unanchored strips on worker-01; 0 anchored. An identifier whose title happens to contain the collection name is mangled, and the mangled value is then used as a deletion key.

D. awk '{print "rm ..." $1 ...}' splits on whitespace

$1 is the first whitespace-delimited field. Any filename containing a space yields a wrong rm target. --restrict-filenames makes this rare today, but older files predate it.

E. Shared download directories

The download path is the uploader name (/mnt7/downloaded/143tv_CCTV), not the dirname. Where two dirnames feed one uploader directory — and 42 collections are fed by more than one dirname — channel A's clean_up lists channel B's files and matches them against A's identifier list.


How the recent changes affect this

The -%(id)s suffix does NOT break the existing join

New filename Title-abc123XYZ_9.mpeg4 -> stem Title-abc123XYZ_9; the awk mints <coll>-Title-abc123XYZ_9; de-prefixed it matches the stem. The round trip still works, because the identifier is derived from the filename.

It does not fix defects A-E either

Every defect above is orthogonal to the suffix and remains.

But it makes a much better join possible — for the first time

The video id is now in the filename, and it is already in the IA item metadata (youtube_id; archive.db holds 2.7M of these). That is an exact, fixed-length key that is immune to every transformation in defect B: no character stripping, no truncation, no prefix arithmetic.


Proposed changes

1. Join on the video id, not on reconstructed strings. Take the id from the filename suffix (or the local .info.json, which is authoritative), and compare against the IA item's youtube_id. This removes defects B, C and D at once — there is nothing left to mirror.

2. Prove the upload before deleting. The test should be: an item exists, is in the expected collection, and carries a media file of plausible size. Existence of an identifier is not evidence that the media landed.

3. Do not use bare ia search as the deletion gate. At minimum include a sentinel query per run and abort if it returns anything, per ia-scrape-api-contamination. Better: verify per item, or drive the check from archive.db, which already stores youtube_id per identifier.

4. Stop excluding %, +, @ — handle them. With an id join the characters stop mattering, and the 181 stranded files on worker-01 (plus their equivalents fleet-wide) become collectable garbage again.

5. Dry-run by default. Today the script writes rm_already_uploaded.sh and immediately executes it. It should require an explicit flag, and log what it deleted, with counts, to a dated file.

6. Never delete on a zero-match run. If the item list is empty or the sentinel fails, exit non-zero and delete nothing rather than proceeding.

7. Patch the ROOT copies (669 on worker-01), not the subdirectory copies — and reconcile the two, since they currently disagree about the mount point.

Suggested order

The safety fixes (2, 3, 5, 6) are independent of the id join and can land first; they reduce the blast radius of the defect that can destroy a sole copy. The id join (1, 4) is the structural fix and is much easier now that the suffix exists.

What to measure before and after
  • media files remaining on disk that ARE uploaded (should fall)
  • media files deleted whose item lacks a media file (must be zero)
  • files containing %, +, @ still stranded (should fall to zero)

↑ back to contents10.11-cleanup-script-analysis/README.md

10.12cma-story-minerMines the whole archive for subjects worth writing about — 119 editorial themes scored against all 3,241,074 items, ranked into a top-50 worksheet with exemplars for a writer.

10.12 — CMA story miner

Surfaces blog-post ideas from the Community Media Archive: what is genuinely interesting in there, on the public-access side rather than the government-meeting side, with exemplar items and IA links ready for a writer.

python3 tools/story_miner.py --corpus corpus.db --since 90d --top 50 --out-dir out
The question it answers

Not "what arrived last week" — that is a list of school-committee meetings. It is: which subjects does this archive hold that nobody knows it holds, and of those, which just got fresh material worth hanging a post on.

So themes are scored against the entire population (3,241,074 active items in 2,188 collections, publicdate 2005-08-01 → 2026-08-31, as of the 2026-09-02 sync). The --since window is only a freshness signal, never the basis for selection.

How it works
  1. build_corpus.py — extracts a compact working corpus from the 16.5 GB archive.db on archive-store: title, description, dates, PEG access type, and four item-level flags. Read-only (?immutable=1; the DB is WAL, so ?mode=ro fails). ~4 min, streams and resumes.
  2. mine_phrases.py — discovers recurring title phrases in the non-government material, so the taxonomy is grounded in what is actually there. It found ~25 themes that were not guessed: COVID-era programming in 484 collections, heritage months in 627, tree lightings in 596, a 937-episode psychic-medium show, French-Quebec and Mexican channels.
  3. themes.py — 119 editorial themes in 20 subject areas. Each is a two-stage matcher (FTS5 recall → regex precision), carries the reason it is worth writing about, and an explicit novelty weight.
  4. story_miner.py — scores, ranks, and writes the worksheet.
  5. audit_theme.py — samples what a theme actually matched, and where. Nothing here should be believed on the strength of a plausible regex alone.
The five signals

Each theme is scored on five independent 0–1 signals, then modified by three visible multipliers. The reason printed against each idea is the signal on which that theme most stands out from the other 118 — a z-score, not a raw value, because a signal that runs high for everything explains nothing.

signal means
SCALE how much of the 2,188-collection fleet does this
DEPTH one collection holds a deep, sustained run
VINTAGE unusual amount of pre-2012 material (only 29k items in the whole corpus predate 2010)
DISTINCTIVE in many places yet rare in each — the strongest "you didn't know this was here" signal
SURGE over-represented in the --since window relative to its own share of the corpus

Multipliers: voice (public-access weighted up, meeting boilerplate down), precision (title matches beat description-only ones), and novelty — an explicit, editable editorial judgement of how surprising a subject is in an archive of civic video. Counts cannot measure surprise, so it is stated in themes.py and printed in the report rather than smuggled into the arithmetic.

Counting basis

Default --basis title: an item counts when the theme's pattern appears in its title. Across the 119 themes the median title-share is 0.48 — about half of all matches are description-only, and sampling those showed them materially noisier (an Eclipse Awards ceremony matching astronomy, a comedian's film credit matching paranormal). Description-only mentions are reported per idea but excluded from headline counts. --basis all restores the older behaviour.

Things that were got wrong on the way here

Recorded because each was a real error in an earlier revision of this tool, and each produced confident, plausible, wrong output:

  • A uniform baseline made everything surge. Ingest is nothing like uniform (1.75M of 3.08M dated items are 2020+), so measuring the window against a flat rate over the archive's span reported SURGE for 14 of the top 50. The baseline must be the window's share of the corpus.
  • Missing metadata was scored as if it were government access. 477,713 items (15%) belong to collections with no access_type. Treating unknown as negative pushed a 557-item accordion collection to #80 and Quebec's channels to #108 purely for being untagged.
  • "Distinctive" required near-ubiquity. Saturating at 100 collections meant roller derby (69) and accordion (78) — the two examples the brief named — ranked #76 and #92. Being in dozens of towns already proves a subject is not one station's quirk.
  • The reason label named whichever signal ran high for everything. SCALE sits at 0.65–0.95 for nearly every theme, so raw-argmax said "SCALE" for 44 of the top 50. Reasons must be relative to the scored population.
  • A keyword matcher cannot detect nationality. The non-US theme matched US channels that mentioned Mexico and missed every actual Quebec station. Collection membership is the honest matcher; hence dirname_any.
  • | head over ssh kills the remote job. SIGPIPE truncated a run and left stale output that looked like a scoring bug for several minutes.
Conventions honoured
  • Government-meeting detection is a scoring penalty, never a gate — no terminal verdicts. A theme hit survives even when the title trips the meeting detector.
  • Meeting patterns are closed-compound-safe (Selectboard, not \bselect\b+\bboard\b), with a unit test in cma_lexicon.py.
  • Every rate and count is reported with its denominator and whether it is a sample. None of these are samples.
  • Long runs stream to disk and checkpoint per theme; Ctrl-C loses at most one theme and a rerun skips finished work.
  • Worksheet URLs sit alone on their own line, last.
Where things run

archive.db is on archive-store.REDACTED.ts.net:/mnt/md0/datasette/. The tools run there (40 cores, 503 GB RAM) against a local corpus.db; nothing large is copied over the tailnet. Working copy: ~/cma_story_miner/ on archive-store.

  • Project H, series discovery (10.2-archive-dashboard-project/docs/metadata-enhancements.md) mines title fragments for metadata enrichment; this mines them for editorial discovery. Same corpus, different output, no overlap in effect.
  • 10.7-cma-collection-freshness — which collections are quiet vs behind.

↑ back to contents10.12-cma-story-miner/README.md

10.13archiveteam-warrior-decompositionDecision paper: the ArchiveTeam Warrior model — a claim-ledger tracker handing work units to stateless clients — as an alternative to per-channel ingest. Adopt the work distribution; defer untrusted contribution until egress is measured. Nothing built.
Open PDF · Decision paper (v2) — the ArchiveTeam model as an alternative

An alternative ingest architecture for CMA YouTube archiving

Evaluating the ArchiveTeam Warrior model against per-channel ingest

Status: decision paper. Nothing built, nothing run, no fleet resource modified. Baseline it is measured against: 10.8-youtube-update-uploader-v2 (greenfield v2), the plan of record. What this document decides: nothing. It sets out an alternative, what it costs, what it buys, and what evidence would make it the right choice.


0. How to read this

CMA's plan of record for YouTube ingest is greenfield v2: a Python worker driven by a manifest row, id-based identifiers, contributors who donate a machine that joins the tailnet and is operated remotely. That plan is locked and this document does not seek to reopen it.

This is a study of a different architecture for the same job — the one ArchiveTeam use to run crowd-sourced archiving at a scale no single operator administers. It is worth studying for two reasons: parts of it solve problems CMA demonstrably has, and the whole of it solves a problem CMA may or may not have. Those are different claims and this document keeps them apart.

Where 10.8 has established a fact about the domain — that dirname and collection diverge 132 times, that captions must upload after media, that ingest runs at 0.61 GB/h — that fact is used as evidence. Where 10.8 has made a decision, that decision is recorded as the baseline's position, and the alternative is allowed to differ from it. A decision that is right for administered machines on a tailnet is not automatically right for machines nobody administers, and collapsing the two is the mistake v1 made.

Read §1, §6 and §7 for the argument. §2–§5 are the evidence. §8–§9 are what to do and what I could not check.


1. The question, and the three things it bundles

The prompting question was whether the Warrior model is a viable alternate for archiving YouTube by distributed IP address, with workers that hold no state and own no channels.

Studying it from source, the model turns out to bundle three separable concerns. ArchiveTeam needed all three simultaneously, so their architecture fuses them. CMA can adopt them independently, and most of the analysis that follows is about which ones are actually wanted.

  1. Work distribution. Who decides what gets fetched next, what happens to work in flight when a worker dies, and where "have we already got this?" lives.
  2. Egress diversity. How many distinct addresses and networks the fetching originates from.
  3. Trust boundary. Who may cause bytes to appear in a CMA collection on archive.org, and what verification stands between a contributor and the published record.

The Warrior is impressive because it solves (2) at a scale no operator could administer, which forces it to solve (3) defensively, which is why (1) looks the way it does — server-held state, expiring claims, per-contributor budgets and blocks, byte accounting cross-checked against expected domains.

Take only (1) and you get something cheap, safe, and directly useful. Take (2) and you inherit (3) and everything it implies. The central judgement in this document is that (1) is worth having on evidence CMA already possesses, and (2)–(3) rest on a constraint that has never been measured.


2. The alternative, described on its own terms

Five services, independently deployable. CMA would want three of them, could skip one entirely, and must write the fifth from scratch.

        ┌──────────────────────────────┐
        │ HQ  (project directory)      │  which projects exist; clients self-update
        └───────────────┬──────────────┘
                        ▼
 ┌──────────┐  request  ┌─────────────────┐
 │  client  │◄──────────┤    TRACKER      │  todo / out / claims / done
 │(stateless├──────────►│ (work units)    │  claims expire; stale work returns
 └────┬─────┘   done    └─────────────────┘
      │  upload ──► target chosen by the tracker, per request
      │
      ├──► fetched bytes ──► [ landing zone ] ──► [ INGEST ] ──► archive.org
      │
      └──► discovered ids ──► [ BACKFEED ] ──► dedup ──► back into the tracker

2.1 The tracker is a claim ledger, and that is the whole point

Seven HTTP endpoints under a project slug, JSON bodies: request an item, report it done, release it, ask where to upload, report uploaded, query one item's status. Rejections are meaningful and the client is written to expect them — 404 means no work available right now, which is normal rather than an error; 429 means rate-limited; 455 means your code is too old, upgrade; 403 means blocked.

Server state is a set of queues popped in a fixed priority order — items pinned to a named worker, then the main pool, then a secondary pool, then a redo pool which deliberately skips the worker that did it last.

The property that matters:

A claim is a timestamped entry in a sorted set. A sweep releases anything older than the stale window back into the pool. A worker that dies, is unplugged, or reboots mid-item loses nothing. No reconciliation, no manual repair, no operator noticing.

Around that sit the abuse controls that make an open contributor pool survivable: per-address and per-nickname block sets, a global block set, a per-contributor item budget, a rate limiter that disables itself when the queues are empty, and a check that cross-references the bytes a worker reports against the domains the project expects — mismatches auto-block the address. These are not incidental; they are the price of (2) and (3) from §1.

2.2 Items are typed strings, and they can carry routing constraints

An item name is type:value — a video id, a community post id — with an optional trailing field carrying a country-code restriction. A client detects its own country, rejects items it cannot serve, and releases them back to the tracker.

Worth dwelling on: ArchiveTeam already built geo-routed work distribution. Not because they wanted egress diversity as such, but because YouTube serves different content and different blocks by country. The mechanism is exactly "route this work unit to a worker whose network egress has property X", and it is the closest thing in the system to the original question.

2.3 Backfeed: discovery without worker state

The grab script accumulates newly discovered ids in memory during a crawl and, at the end, posts them in batches to an HTTP endpoint — delimiter-separated, compression accepted, several named channels for different discovery classes (ordinary finds, self-references, and a set of "problem" channels for errors and limitations, which deliberately bypass deduplication because there you want every report).

Server side, submissions are deduplicated against a Bloom filter and survivors are added to the tracker's queue. So a worker can discover four hundred videos, fire them at a URL, and forget them.

That Bloom filter is the piece of state CMA currently keeps in ~2,100 archive.txt files on worker disks. It is also the design's one clear mismatch: a Bloom filter accepts a small false-duplicate rate — an item silently never queued — which is right for a corpus of billions and wrong for one of ~10⁶ civic meetings. CMA has an exact membership oracle already: archive.db, 2.7M items. If this model were adopted, the filter should be exact, with a Bloom only as a front-line cache if measurement demands one.

2.4 The client is the part worth taking unmodified

The client framework is current, actively maintained, and does a great deal that would otherwise have to be rewritten: a task pipeline over an item dictionary, separate concurrency limits for fetching and for uploading, retry with backoff, a small web UI, project auto-update. Two details are worth copying outright:

  • Working directories are named by a hash of the item name, not by the item or its title. No filename escaping, no character-set collapse. CMA has a live fleet-wide defect of exactly that class.
  • The upload destination is chosen by the server at upload time, from a pool. A full or dead ingest box is a server-side configuration change, not a fleet edit.

2.5 Deployment, briefly

The virtual appliance is deliberately dumb — boot, pull code, make a data disk, run — because it holds nothing worth preserving. Running it under Proxmox is a solved and documented problem: import the appliance disk, attach, fix the boot order. A commenter on that write-up notes the project now publishes a qcow2 image, which imports as a single disk and suits Proxmox far better than the older appliance format.

For CMA neither is the right answer. The project-specific container supports substantially more concurrent items than the appliance, and on Proxmox the sensible shape is a container running it — which also matches the LXC-only decision already reached in the Proxmox evaluation. The appliance exists for volunteers on desktop machines.


3. What the alternative does not provide

Three things that CMA needs and that this architecture has no answer for. These are not criticisms of ArchiveTeam — they are consequences of a different consumer.

3.1 There is no collection routing to inherit

I expected a routing layer and there is none. The destination is four shell variables in a per-deployment configuration file — collection, identifier prefix, title prefix, filename prefix — and the uploader is a single HTTP PUT per file carrying metadata headers. One deployment serves exactly one collection; changing it means editing a file and restarting a daemon.

This cuts both ways. Nothing to reverse-engineer, and the tracker, client and backfeed layers never mention archive.org at all — destination is not in the wire protocol, not in the item name, not in the client. But equally, nothing to reuse: per-channel routing to ~2,100 collections would be entirely new work.

3.2 Their aggregation granularity is incompatible, not merely configured differently

Finished fetches are swept into a pile until it reaches roughly 25 GB, then packed and uploaded as one archive.org item containing many contributors' output. Per-video identity lives inside the container and is recoverable only through an index.

They can do this because their consumer is a web-scale replay system. CMA's consumer is a person looking for their town's meeting: one item per video, in that organisation's collection, with a readable identifier. That is a different granularity, not a different setting, so the entire packing-and-upload chain is deleted rather than adapted. What survives is only the shape — landing zone → server-side ingest → archive.org.

3.3 Their captured artifact is self-describing; ours is not

An ArchiveTeam capture contains the metadata, because the capture is the HTTP traffic. CMA's metadata arrives as a yt-dlp .info.json sidecar, and today it is projected through a fixed field list into a pipe-delimited file, joined with per-channel organisational metadata, converted twice, and uploaded as a spreadsheet. The sidecar itself is never uploaded to archive.org. The complete metadata record for 2.7M archived items exists only as a scratch file on a worker.

In the current model that is survivable, because a channel directory lives on the worker that owns the channel. Under any stateless-worker model it is not. Whatever else is decided, .info.json must become a retained artifact — named as a retained file class alongside captions, and uploaded to the item as a sidecar so the item is self-describing and worker disks stop being load-bearing for metadata.

Already closed for the baseline, on the same day this was written. The greenfield v2 worker's P1 (10.8-youtube-update-uploader-v2/cma-worker/) performs one ordered upload per item — media, then captions, then .info.json — with mediatype set explicitly and per-file-class retention in cleanup. So this is no longer a gap in the plan of record; it remains a requirement any alternative must also meet, and the argument above is why.


4. What it would cost

Honest estimate, based on reading the source rather than on the wiki.

component availability work
client framework open, current, actively maintained reuse as-is. The single largest saving available, and it should be a design constraint: if our server speaks the protocol, we inherit concurrency, retry, upload throttling, the web UI and auto-update for free
backfeed open, current, clean usable, but wants a clustered cache with a Bloom module — three separate datastores. And its default queue is one the open tracker never reads, so the two open components do not actually fit together as shipped
tracker the production one is not published anywhere I could find; the open one is five years stale and demonstrably a generation behind build. Roughly 500–1,000 lines over the databases we already have. Do not resurrect a 2020 Ruby/Passenger stack
ingest tier nothing upstream build. Per-channel routing, identifier minting, metadata construction, ordered upload, verification. This is the larger half
HQ / appliance open skip

Plus operational cost that is not code: a service to run, a queue database to back up, and — if contributors are untrusted — a landing zone sized to arrival rate times ingest latency, and a central ingest daemon that becomes a new single point of failure. ArchiveTeam mitigate the equivalent by running several independent instances, which they can do because their routing is a constant. Per-collection routing makes sharding a real design problem rather than a copied configuration file.

The build risk is concentrated in one place: the tracker that actually runs production is closed. Everything in §2.1 is read from a five-year-old implementation plus a current client, and there is at least one unexplained mismatch between them — the client parses fields the old server never sends. Resolving that is cheap (run one contributing client and watch) and should happen before any design is fixed.


5. Evidence from the current fleet

Gathered 2026-09-10 while scoping this. Full detail and caveats in 10.7-cma-collection-freshness/fleet_state_2026-09-10/.

Denominator: 2,131 (host, dirname) pairs actually executed, derived from the cycle scripts on ten hosts — not from walking filesystems, which would have counted other workers' backup copies. 2,128 have a per-channel formatter file.

observation figure
per-channel formatter files, all hand-edited, all distinct 2,128
mediatype value cardinality across all of them 1
collection differs from directory name 129 (plus 6 blank, 53 with no record)
no identifier prefix — competing in a global namespace 11
access-type field values that normalise to ~4 categories 9
state field distinct values, where ~50 belong 348
hosts with a scheduled (cron/timer) cycle 0 — every cycle is run by hand

Two of these matter for the argument.

The per-channel formatter file is the organisational metadata map — collection, creator, city, state, access type, subject vocabulary, media type, identifier prefix. Every one distinct, all of them on worker disks, and three of their fields are recorded as missing from the onboarding manifest. That is thousands of hand-made decisions with no backup, and it is a strong argument for the baseline's manifest work regardless of anything in this document.

Nothing is scheduled. Ten hosts, hand-run cycles, weekly-ish cadence. Taken with the per-channel file sprawl and channel-to-host assignment encoded in shell list files, the picture is a system whose limiting resource is operator attention.


6. Where the two designs genuinely diverge

Greenfield v2 as the baseline; the alternative in its untrusted-contributor form, since that is the form that is actually different. (In a trusted form the right-hand column collapses toward the left for everything below the line.)

dimension greenfield v2 (plan of record) AT-style alternative
unit of work a channel a video
who assigns work scheduler; today, shell list files worker claims the next item
worker state per-worker database none
worker dies mid-work that channel stalls until noticed item returns to the pool automatically
"do we have this already?" per-worker archive file one server-side set
discovery of new videos playlist walk per channel discovery returned to the server, deduplicated
—
upload path worker → archive.org directly worker → landing zone → server-side ingest
who holds credentials the worker (safely: operator drives it) nobody but the server
who mints identifiers the worker the server
who may cause a published item an administered machine anyone the server accepts
contributor pool donors who join the tailnet anyone
egress diversity scales with donors you administer scales with strangers
new failure modes — central ingest SPOF; forged submissions; namespace abuse

The line divides what changes with work distribution from what changes with the trust boundary. Everything above it is available without touching credentials, upload paths, or the contributor model. Everything below it arrives together, or not at all.

That is the practical form of §1's unbundling, and it is the most useful thing in this document: the top half is separable, cheap, and addresses failures that are already documented. The bottom half is a package deal contingent on a constraint nobody has measured.


7. Which constraint actually binds?

The decision turns on this and not on architecture taste.

Compute — not binding. The oldest workers lack modern vector instructions and are ~11× slower at floating point, but only ~1.03× slower at scalar work. Fetching and uploading are fine on all of them. Compute constrains transcription, not ingest.

Storage and throughput — known, not binding. Bytes per hour is measured and is a property of YouTube's encoder ladder rather than of any machine; it varies ±4% across six hosts. This does not need re-measuring and should not be re-measured.

Operator attention — strong evidence that it binds. No scheduled cycles on any host. ~2,128 hand-edited per-channel files. Channel-to-host assignment encoded in shell list files, with the same channel appearing in several hosts' lists and staleness resolved only by inspecting downloaded content. Per-worker state files that block recovery. Onboarding a channel means creating a directory of generated files. Every one of these is an operator-time cost that scales linearly with channel count.

Egress — UNMEASURED. Not "fine", not "constrained". This is the important correction in v2, and it invalidates a claim I made in v1's framing.

Any statement that this fleet has never been throttled, if it rests on the parallel job logs, is unsound. Those logs record sequence, host, start time, runtime, byte counts, exit value, signal and command — they never capture standard error, which is where yt-dlp writes its ERROR: lines. The cycles run 4–8 jobs concurrently with no per-job redirection, so those lines interleave onto the operator's terminal and are lost with the session. Exit value is a weak proxy too: yt-dlp continues past per-video errors and can exit successfully from a run full of them.

The control that settles it: yt-dlp certainly errors on an event scheduled for a future date, and that error is also absent from the job logs. A signal known to occur, missing from the channel, proves the channel is blind — not that the sky is clear.

Consequence for this decision: the case for the top half of §6's table rests on a constraint with direct evidence. The case for the bottom half rests on one that has never been instrumented. That asymmetry, not any judgement about ArchiveTeam's engineering, is why the recommendation in §8 splits the way it does.

Measuring it is cheap and is written up as an outstanding task in the 2026-09-10 fleet snapshot. One caution recorded there is worth repeating, because it is the same class of error: the parallel job is the channel, so per-job capture attributes every error to a channel — and rate-limiting is a property of the host's address at that moment, not of whichever channel happened to be running. The discriminator is time-clustering across concurrent jobs, not identity. Attributing throttling per channel would manufacture a confident wrong finding about "problem channels".


8. What to choose, and what would change it

Adopt the top half of §6 — work distribution and statelessness. The evidence for it is already in hand: eight separately documented failure classes trace directly to per-channel ownership and per-worker state, and the operator-attention constraint is the one with real evidence behind it. In CMA's context this is not a rival to greenfield v2; it is the half of the problem greenfield v2 does not address. Greenfield v2 removes per-channel configuration sprawl by replacing a directory of generated files with a manifest row. A claim ledger removes per-worker state and assignment sprawl. Together, a channel becomes a row and a video becomes a work unit.

Do not adopt the bottom half yet. Untrusted contribution buys reach beyond the tailnet and genuine egress diversity, and costs an ingest tier, a verification story, and an unresolved question about accepting content into named civic collections from machines nobody administers. That question is for the operator and it is not primarily technical. Until egress is instrumented, this is purchasing a remedy for an undiagnosed complaint.

If egress does turn out to bind, the cheapest fix is not this. It is more donors on the tailnet — the baseline's own model, which distributes addresses without touching the trust boundary at all. The alternative wins only when you need more contributors than you can administer. At the greenfield scale described in 10.8 — a beachhead of a few hundred channels, a median around 130 videos each — that threshold may never arrive.

A third position exists and may be the interesting one. Known partner stations, given identity and accountability but not tailnet access. Smaller than the open crowd, larger than the administered fleet, and it would need most of the same verification machinery — which makes it the honest test of whether the bottom half is worth building.

Two things to rule out explicitly. Do not point CMA workers at ArchiveTeam's tracker, and do not run a parallel project impersonating theirs; their published rules are clear and our use case is different. But do consider them as an emergency backstop for an at-risk station — a station announcing shutdown is exactly what they are good at, a conversation on their channel costs nothing, and that option should exist before it is needed rather than after.

Evidence that would change the recommendation:

finding effect
captured stderr shows sustained rate-limiting under real concurrency egress binds; the bottom half becomes urgent
a partner station wants to contribute compute but will not join the tailnet the third position becomes concrete
the production tracker's source becomes available build cost drops sharply; worth one polite ask
greenfield channel count heads toward thousands rather than hundreds administered-donor model stops scaling
the live protocol diverges from the open implementation build cost roughly doubles; must be checked first

9. If it is built: sequencing

Ordered so that nothing depends on something that does not exist, and nothing touches a production resource before it has to.

  1. Baseline first — and it has moved. A claim ledger distributing work to a worker program that does not exist is worth nothing. Greenfield v2's worker is the prerequisite, and P1 landed 2026-09-10: a pipx-installable package with identifier minting, worker state, manifest handling, fetch, ordered upload and per-file-class cleanup, verified end to end on a real channel (nothing uploaded to archive.org yet). It has a conference deadline; this must not compete with it.
  2. Instrument egress (the fleet-snapshot outstanding task). One host, one cycle, additive, operator-confirmed. It is the input to the §8 decision and costs almost nothing.
  3. Verify the protocol by running one contributing client politely and watching it — one container, one item at a time, no code modification. This resolves the client/server field mismatch and tells us whether we are building against a specification or against reverse-engineered traffic. If we cannot do this as a good citizen, skip it and record the uncertainty.
  4. Specify, from source, precisely enough to implement without reading the original. Not from the wiki, which documents installation and explicitly not the protocol.
  5. Prototype offline against our own databases, with exact deduplication rather than probabilistic, reusing the client framework unmodified. The go/no-go: a worker killed mid-item must lose nothing, and a re-queued item must produce a comparable result.
  6. Shadow, then one greenfield channel, on machines already idle or out of service — never a healthy production worker. Audit file written before any mutation; database backed up; dry-run by default.
  7. Only then revisit the trust boundary, with §8's evidence in hand.

10. Provenance, and what I did not check

Read from source on 2026-09-10, commits pinned in MANIFEST.psv, reproducible via appendix-clone.sh: the tracker (last changed 2020), the client framework (current), the live YouTube project (changed four times on the day I read it), the appliance scripts, the project directory service, the backfeed service, and the packing/upload chain.

Denominator: seven repositories, chosen because the live project list named the YouTube project as active and because the client's pipeline names the other components explicitly. I did not survey the other 28 project repositories. Statements about "how these projects work" generalise from one project plus the framework; the framework is the stronger evidence.

Not checked, and load-bearing to some degree:

  • The production tracker's actual behaviour. Everything in §2.1 is inferred from a stale open implementation plus a current client, and they disagree in at least one place.
  • Whether .info.json survives the current cleanup. There are no cleanup scripts on this laptop; 10.8 inspected one on a worker. §3.3's claim that the sidecar is never uploaded is verified from the upload spreadsheet construction; its retention on disk is not.
  • Whether the per-channel formatter files vary in ways the harvest's field extraction missed. 41 columns were extracted; the files also contain conditional rules and title-cleanup substitutions that were counted but not parsed.
  • Egress. See §7. Unmeasured, and the instrument that appeared to measure it could not have.

Nothing in this document has been built or run. The first thing that touches a network is a single volunteer client; the first thing that touches CMA's fleet is step 6, and it is gated on an operator decision.

↑ back to contents10.13-archiveteam-warrior-decomposition/ARCHIVETEAM_WARRIOR_DECOMPOSITION_PLAN-v2.md

10.14openpublica-api-mappingCan the CMA, civic.db and IA's Community Meetings map to the OpenPublica API? Yes, with three crosswalks (place, body, meeting); the YouTube video id is an exact join. 33% overlap; 4,585 of 6,118 matched CMA items lack English captions. Analysis only.
Open PDF · OpenPublica ↔ CMA mapping report

OpenPublica API ↔︎ CMA / Community Meetings: what maps, what is missing, what must be built

1. Bottom line
  • The API is usable today and richer than what IA's Community Meetings (CM) uses. It is free, needs no key, and is GET-only JSON. It returns summaries, topic percentages, full timestamped transcripts, source links and IA video links for 41,987 meetings (75,480 h) in 379 governments.
  • CM does not call the API. It harvests OpenPublica's own archive.org uploads (creator:OpenPublica, 38,002 items). As a result its 308 OpenPublica rows carry no transcripts (has_transcript = 0 on all 308), and 65 have an empty summary.
  • No shared key exists between OpenPublica and the CMA. Three crosswalks have to be built:
    • place: OpenPublica government_id → Census GEOID → CMA collection;
    • body: OpenPublica's category_tag is a feed, not a body;
    • meeting: OpenPublica meeting_id / media key ↔︎ CMA identifier.
  • The meeting crosswalk has one exact join and several fuzzy ones.
    • Exact: for YouTube-sourced meetings, OpenPublica's media key is the YouTube video id. 2,503 of the 5,442 YouTube-keyed meetings are already in archive.db.
    • Everything else is date + body + duration, confirmed where possible by aligning OpenPublica's transcript to the CMA caption file.
  • Measured overlap is about one third. In the 164 OpenPublica governments that map to CMA collections, 6,267 of 19,235 non-stub OpenPublica meetings (33%) have a CMA copy. 5,605 of those (29%) are in the higher-confidence tiers.
    • The rest are mostly real gaps. Many city YouTube channels in the CMA are PR channels, while council video lives on Granicus, which is where OpenPublica gets it.
  • OpenPublica fills caption gaps. 4,585 of the 6,118 matched CMA items have no English caption file.
  • Transcript timecodes do not transfer for free.
    • Same media (same YouTube video): offset 1–10 s.
    • Different media (Granicus clip vs. CMA YouTube copy): −923 s to +260 s in samples, and thousands of seconds inside combined recordings.
    • Every link therefore needs a stored offset.
2. What OpenPublica exposes (pulled 2026-09-14)
Item Value
Base URL https://api.openpublica.com/v1 (MCP server at /mcp)
Auth / terms none; "free for public-interest, research and civic projects"; asks for caching, a descriptive User-Agent, and gzip. No licence is stated for summaries or transcripts.
Envelope {data, meta}; errors {"error":{"message"}} with real 400/404/429 (Retry-After)/500
Documented routes /meetings, /meetings/search, /meetings/{id}, /governments, /governments/{id}, /governments/{id}/stats, /governments/{id}/documents, /governments/{id}/plan, /documents/search, /documents/{id}, /documents/{id}/text, /vocabulary, /stats
Undocumented but listed in /v1 /operations, /meetings/{id}/attendees, /meetings/{id}/officials, /meetings/{id}/officials/details, /governments/{id}/officials, /officials/{id}, /officials/{id}/meetings
Corpus 41,987 meetings; 75,480 h (sum of duration_seconds; 784 lack it); 379 governments (1 with no meetings); 50 states + DC; 356 documents
Recency 2025–2026 = 32,619 of 41,987 meetings (78%); earliest 2010
Recording sources (source_url) Granicus 29,792 · YouTube 5,056 · none 3,706 · Cablecast 1,886 · Swagit 1,308 · CivicClerk 152 · other 87
IA video video_url null for 3,890; archive.org search creator:OpenPublica = 38,002 items, all in opensource_movies
Transcript transcript_url → S3 JSON {text, segments[{id,start,end,text,no_speech_prob}], metadata{model: nvidia/parakeet-tdt-0.6b-v3, backend: nemo, chunk 300 s, overlap 30 s}}
Bodies category_name has 168 distinct values and category_tag 522 (a tag is per government)
Officials/attendees Empty for the one government probed (Half Moon Bay): coverage.state = needs_review / unprocessed
Pagination /meetings limit 1–100 with page; /governments limit 1–50; fields= sparse fieldsets

A full pull costs 659 requests at 1 req/s (13 min) with fields= set. Raw pages are in raw/meetings/.

3. How IA's Community Meetings uses OpenPublica today
  • Source. CM reads OpenPublica's archive.org items (identifier: openpublica-*, uploader uploader@example.invalid), not the API. Its 308 rows are Half Moon Bay 151, Berkeley 127 and Montgomery County PA 30.
  • Fields taken. summary_md comes from the item description, and outcomes_md is the "Key Outcomes" section split out. topics are the item's subjects without percentages. has_transcript = 0 everywhere.
  • Links to recordings. related_identifier ties 80 of the 151 Half Moon Bay rows to a PCTCA recording.
  • Coverage gap. The API has 175 Half Moon Bay meetings. All 151 CM rows are among them, so 24 are missing from CM.
  • CM's own JSON API (/community-meetings/api/meetings.json) is a flat projection of its meetings table.
4. The identifier model

This is the crux of the mapping. Nothing below is shared across systems unless a crosswalk says so.

Key System Shape Notes
meeting_id OpenPublica 24-hex ObjectId (6aa122f90591d0da0f6871b7) The only id the meeting routes accept. A UUID returns 400 Invalid meeting_id.
media key OpenPublica basename of transcript_url Granicus → UUID; swagit-<tenant>-<n>; cablecast-<site>-<n>; legistar-<client>-<n>; civicclerk-<tenant>-<n>; YouTube → the 11-char video id
IA identifier (OpenPublica) archive.org openpublica-<government_id>-<media key> The same key names transcriptions/<key>.json; it is what CM stores as identifier
government_id OpenPublica <city_slug>-<state_slug> (half_moon_bay-california) Includes 37 counties and consolidated governments; no FIPS/GEOID
category_tag OpenPublica UUID, per government A feed, not a body. "Meeting Portal", "City Council & Commissions" and "Other" hold many bodies
geoid civic.db Census place/cousub GEOID Our authoritative place key
dirname / identifier CMA / archive.db collection dirname; item identifier Items carry youtube_id and an upload date; the meeting date is only in the title
identifier, body_code CM IA identifier; CM's own body codes (HMBCC) related_identifier is single-valued
5. Field map
OpenPublica field CM column CMA / archive.db Transform / gap
meeting_id — — Add op_meeting_id
media key identifier suffix youtube_id when 11-char Add op_media_key; exact join to archive.db for YouTube sources
government_id place (free text) dirname via crosswalk Build place crosswalk (§6.1)
title title title OpenPublica titles carry topic words; 3,399 contain "Summary"; 20 are in Chinese. Don't use raw as a meeting name
date (UTC) meeting_date (local) date = upload date Evening meetings land +1 day. Some OpenPublica dates are the YouTube upload date. Prefer the title date, fall back to OpenPublica date ±1 (§6.5)
epoch_timestamp — — Needs the government's time zone to localize
duration_seconds runtime_sec duration Differs by source (PCT ≈0.8×, YouTube ≈1.05–1.2×). Evidence, not identity
category_name, category_tag body_name, body_code, meeting_type none Build body crosswalk (§6.2)
summary (markdown) summary_md (+ outcomes_md) none Blank the stubs ("No agenda, minutes, or transcript were provided… no summary can be generated"). A bad value is worse than none
topics[{topic, conversation_percentage}] topics[names] subject CM drops the percentages. Add a topics-with-weight field
transcript_url (segments) transcripts.text, windows (60 s), has_transcript, transcript_source caption files (37–46% of meeting hours have English captions) Build 60-s windows from segments. transcript_source = openpublica:parakeet-tdt-0.6b-v3. Store the per-copy time offset (§6.4)
video_url (OpenPublica IA item) archive_url CMA identifier (a different copy) The same meeting can exist as two IA items. Add a many-to-many link table
source_url source_url youtube_webpage_url Keep as provenance
thumbnail_url thumbnail_url IA thumbnails Not fetched in bulk here
attendees / officials — — Mostly unpopulated; out of scope for now
government location [lon, lat] places.lat/lon none (geo_map is empty) GeoJSON order is longitude first
government categories[] agenda_rss_url, minutes_rss_url, video_rss_url, playlist_url per-meeting agenda_url, minutes_url civic.db agenda_page, agenda_vendor, youtube_channel A second opinion for civic.db's agenda and channel fields, never an overwrite
documents (plans, budgets) — — Out of scope; different object
6. The maps that have to be built

6.1 Place: government_id → GEOID → CMA collection

  • Name + state match against civic.db. Of 342 place governments, 315 matched unambiguously and 27 did not (20 no hit, 7 ambiguous). The 27 are in review/REVIEW_op_governments_unresolved.txt.
    • The unresolved ones are consolidated governments (Nashville, Indianapolis, Anchorage, Athens-Clarke), New England place/cousub pairs (Barre VT city ≠ Barre town), NJ/NY/PA townships and towns.
    • An automatic tie-break was tried and discarded: it picked Franklin borough for Franklin Township NJ and Huntington Bay village for Huntington NY.
  • Counties (37) need county FIPS, which civic.db does not carry (5 county rows).
  • GEOID → CMA collection. 160 place governments reached a CMA collection. 157 remain after dropping school, education and county collections, and 164 after adding collections proven by YouTube identity (§6.3).
  • YouTube identity found collections the place map missed: Sacramento County (SACOCA), Nashua Board of Aldermen (NBOANH), Anchorage (MOAAK) and LA City Clerk (LACCCA). See review/REVIEW_youtube_identity_collections.txt.
  • My own name matcher produced a wrong link: Sacramento → "Sacramento Faith TV" (SACFCA). civic.db was not involved (in_cma blank).
    • Collections linked to a place but carrying almost no meetings in OpenPublica's period are in review/REVIEW_cma_links_without_meetings.txt (51 rows). Most are right (San Diego, Frederick, Oakland, New Orleans, Arlington TX, Hampton post PR and programming on YouTube and meetings on Granicus). They are listed for a decision, not changed.

6.2 Body: OpenPublica category ↔︎ CM meeting_type ↔︎ CMA (none)

  • OpenPublica's category_tag names the ingest feed. One tag can hold a council, a CRA and a pension board ("Meeting Portal", "Other", "City Council & Commissions").
  • The body has to be parsed from the OpenPublica title. The CMA side is title-only as well.
  • CM's 28-value meeting_type taxonomy is the natural target. The map is a classifier run on both sides, not a lookup table.

6.3 Meeting: OpenPublica meeting ↔︎ CMA item

  • Exact identity (YouTube). An 11-char media key equals archive.db youtube_id.
    • OpenPublica-wide: 2,503 of 5,442 YouTube-keyed meetings are already in archive.db.
    • The other 2,939 are YouTube videos OpenPublica has and the CMA does not (by video id).
  • Everything else is proposed, not asserted. Logic is in tools/op_match_core.py, one core shared by the full run and the positive control.
Tier Rule Links (164 govs) Alignment-confirmed in samples
yt_exact media key = youtube_id 1,905 5 / 5
title_date CMA title date within ±1 day of OpenPublica date or OpenPublica title date; shared body word; duration ratio 0.7–1.4 (0.75–1.4 if the only shared word is generic) 3,610 16 / 18
upload_date no title date; upload in [date, +3 d]; same body and duration rules 90 7 / 8
ia_contains one CMA recording ≥1.4× longer holds this OpenPublica meeting 495 6 / 9
op_duplicate an OpenPublica near-duplicate of an already-linked meeting 70 4 / 6
ia_parts PCT-style "Part N" items summing to OpenPublica's length 17 1 / 3 (check invalid for parts; see §8)
same_body_same_day the only same-body item that day, any length 89 2 / 6 — do not use without review
  • Precision samples are small (n = 20 then 35). They cover only pairs whose CMA item has an English caption and whose OpenPublica meeting runs ≥10 min. A match is "confirmed" when ≥25% of the first 3,000 OpenPublica transcript words fall in aligned runs of ≥8 words.
  • Positive control: Half Moon Bay ↔︎ PCTCA vs. CM's own 80 links. Agree 68. The 11 misses are 1 snapshot lag (the 2026-09-01 item postdates the 2026-09-02 archive.db copy) and 10 OpenPublica closed-session stubs ≤4 min, excluded on purpose. The 1 disagreement is Part 1 vs Part 2 of a split recording, where both are linked. The matcher also proposed 32 links CM does not have; they look plausible on inspection.
  • Recommendation:
    • Auto-propose yt_exact and title_date, plus upload_date pending a larger sample.
    • Confirm every captioned pair by alignment.
    • Route the other tiers to a worksheet.

6.4 Time: OpenPublica transcript timecodes → CMA copy

Pair type Measured offset (IA − OpenPublica)
Same media (yt_exact, Avondale, 147 min, 76% of words aligned) +1 to +10 s across the whole meeting
Different media (title_date, 16 confirmed pairs) −923 s to +260 s
OpenPublica meeting inside a combined CMA recording (ia_contains) +569 s to +13,141 s (the segment's start)
  • The link table needs offset_s, measured by alignment. Without captions on the CMA side, the offset can't be measured yet.
  • The median offset is a first cut. Drift inside long, edited recordings was not modelled.

6.5 Date

  • OpenPublica date is UTC. In 155 exact YouTube pairs: 50 same day, 84 off by one day, 21 off by more.
  • The larger errors cluster by government, and the OpenPublica date equals the YouTube upload date: Avondale 10 of 22, Brattleboro 9 of 26. One (Biloxi) is a CMA title typo, 2025 for 2026.
  • archive.db date is the upload date, so the meeting date must come from the CMA title.
  • Rule: prefer the title date, fall back to OpenPublica date ±1. Never "correct" either side automatically.
7. Coverage findings

Denominator: the 164 OpenPublica governments that map to CMA collections, 19,235 non-stub OpenPublica meetings. OpenPublica pull of 2026-09-14 against the archive.db copy of 2026-09-02.

  • Matched: 6,267 OpenPublica meetings (33%). Per government the median is 36%: 54 governments are at least 80% matched and 64 are under 5%.
  • Unmatched: 12,968.
    • CMA gap (6,412): the CMA collection has nothing of ≥15 min within ±3 days.
    • Same week, other content (4,793): the CMA has recordings that week, but not this meeting. Examples: committees not on the city's YouTube channel; Boston City Council is not in COBMA.
    • CMA behind (1,763): the OpenPublica meeting is newer than anything in the CMA collection.
  • CMA meeting items (≥15 min, meeting-like titles) inside OpenPublica's date spans: 15,658, of which 5,594 are matched (36%).
  • CMA history before OpenPublica coverage begins: 67,486 meeting items (110,089 h) for the same governments. OpenPublica's corpus is recent; the CMA is deep.
  • Caption gap: 4,585 of 6,118 matched CMA items (75%) lack an English caption file.
  • OpenPublica governments with no CMA collection: 155 places with a GEOID. civic.db lists a video channel for about 100 of them (computed on a superset of 161). These are onboarding leads to check against archive.db, not conclusions.
8. Method, denominators and limits
  • Place map. Name + state only, against civic.db (21,004 places). Name hits are review triggers.
  • CMA side. archive.db copy of 2026-09-02, 320,545 items in the 182 matched collections, plus a full-corpus youtube_id scan for the identity join.
  • Precision. Two alignment samples: n = 20 on an earlier matcher version, n = 35 on the final one. Both are captioned-only, so precision on uncaptioned pairs is untested.
  • Known check limitations.
    • The alignment check reads the first 3,000 OpenPublica words, so it cannot validate ia_parts links to later parts.
    • Weak YouTube auto-captions lower coverage even for true matches.
  • Duplicates. The duplicate rate (3.6% of meetings, 466 pairs, none sharing a source_url) was measured on the first ~226 governments of the pull, not all 379.
  • Not examined. Summary accuracy, topic accuracy, transcript WER, the officials routes beyond one government, and documents.
9. Issues to report to OpenPublica
  1. ashland-kentucky is Miami Beach. Its only tracked playlist is Miami Beach's. All 21 "Ashland" meetings are titled "Miami Beach City Commission…", and the CMA collection CIG_0640 ("City of Miami Beach TV") holds the same 20 videos.
  2. Some YouTube-sourced meeting dates are upload dates, e.g. Avondale AZ (up to 21 days late).
  3. Near-duplicate meetings, sometimes under different categories, e.g. Fort Smith CBID with three records for 2026-09-06.
  4. 20 meeting titles are in Chinese (one each in 20 governments), and some Spanish.
  5. Stub summaries ("…no summary can be generated") are served as summaries. 276 meetings are in category "Other".
  6. GET /v1/governments/all/stats?days=365 returned 500 (one request).
  7. Licence / terms for bulk reuse of transcripts and summaries are not stated. Ask before ingesting.
10. Schema additions (CM and ours)
  • Meeting link table (many-to-many): op_meeting_id, op_media_key, op_government_id, cm_identifier, cma_identifier, basis, align_coverage, offset_s, part_no, reviewed_by, reviewed_at. It replaces the single-valued related_identifier.
  • Place crosswalk: op_government_id, geoid (or county FIPS), basis, reviewed.
  • Body crosswalk: op_category_tag, the body parsed from the title, CM meeting_type.
  • Meetings: topics_weighted (topic, %), transcript_source / transcript_model, local meeting_date with its derivation, time zone.
11. Files (10.14-openpublica-api-mapping/)
Path What
tools/pull_meetings.py Resumable, page-streaming OpenPublica pull (sparse fields, 1 req/s, honours 429)
tools/op_match_core.py Matching tiers, date/body/duration rules, align_check
tools/match_op_cma.py Full run → data/op_cma_match.psv, op_unmatched.psv, op_cma_coverage_by_gov.psv
tools/control_hmb.py Positive control against CM's related_identifier
tools/place_map.py, rollup2.py, meeting_scan.py CMA collection→place classes and meeting-title scan
data/op_gov_to_civic.psv OpenPublica government → GEOID → CMA collections
data/op_youtube_keys.tsv, op_youtube_hits.tsv YouTube identity join
data/align/*.jsonl Alignment precision samples
review/*.txt Worksheets; unmarked rows do nothing
raw/ API docs, OpenAPI, route index, stats, every meeting page and government page pulled
work/ Exact mirror of the session working directory. Scripts run unchanged from here (work/openpublica/match_op_cma.py). Includes op_cma_items.tsv (320,545 CMA items), the discarded op_gov_to_civic_v2.psv, and task_logs/

↑ back to contents10.14-openpublica-api-mapping/OPENPUBLICA_CMA_MAPPING_2026-09-14.md

10.15ia-community-meetings-scale-estimateIA's experimental Community Meetings DB (4,862 meetings, 772 MB) sized at CMA scale: ~92–146 GB with full transcripts for 0.73–1.16 M city/town meeting hours, found across all 3.24 M archive.db items.
Open PDF · Community Meetings scale estimate

IA "Community Meetings" database: what it is, and its size at CMA scale

Source DB: ~/Downloads/community_meetings.sqlite (772 MB, built 2026-09-14T17:57Z). Web: https://wayback-labs.sf.archive.org/community-meetings/ (about page: /about). Compared against archive.db (archive-store copy of 2026-09-02, 3,241,074 active items) and civic.db.

1. Bottom line

Extending the database to the CMA's cities and towns gives a DB of roughly 90–150 GB with full transcripts, holding 0.5–0.8 M meetings (0.73–1.16 M hours). That is ~100–170× the pilot. If only meetings that already have English caption files are transcribed, the range is ~36–61 GB.

Storage is not the hard part. Transcription and title parsing are.

  • English captions exist for only 37–46% of the meeting hours.
  • PCTCA's clean titles ("HMBCC 5/5/26 - …") are unusually easy to parse; most CMA titles are not.
2. What the pilot is
Records 4,862 meetings; 7,018 h (stats) / 7,823 h (sum of runtime_sec); 76 bodies; 11 places
Transcripts 2,876 (2,781 YouTube captions + 99 Whisper); 49.8 M words; cover 78% of hours
Summaries 308 OpenPublica rows; 243 non-empty (avg 6,063 chars)
Sources pct 3,058 rows / 6,641 h (CMA PCTCA items uploaded by jhauser); bozrah_ct 619 (documents + audio); upper_merion_pa 459 + umasd 210 (documents); umt_vimeo 142; bcm 66 (CMA berkeleycommunitymedia); OpenPublica 308
Place coverage Half Moon Bay/Midcoast/Pacifica CA, Berkeley CA, Upper Merion PA, Bozrah CT

Size model (dbstat):

  • The transcript side (transcripts 256 MB + 60-s windows 322 MB + FTS 116 MB + indexes 30 MB) is 764 MB over 6,138 transcribed hours = 124.5 KB/h.
  • Meeting rows are 1,623 B each.
  • Text is stored twice: dropping transcripts.text (rebuildable from windows) would save about a third.
3. Finding meetings in archive.db

Two existing lexicons were run over all 3,241,074 active items: cma_lexicon.py (strict) and cma_survey.py (broad).

Positive control on PCTCA (3,905 items), scored against the 3,053 items IA kept (matched on title):

Lexicon Hour-recall Hour-precision
broad 98.9% 99.4%
strict 73.7% 99.4%

Strict misses titles like "School District Meeting" and "Sanitary District Meeting".

Corpus-wide spot checks (60 uniform-random titles each; eyeballed):

  • Broad-only matches: ~94% of flagged hours are meetings. Misses are short clips, promos and manager updates.
  • Strict matches: ~98%.

Trap: PCTCA uses two identifier schemes (pctca- prefix and bare). An identifier join failed on 2,148 items and made the lexicon look 30% precise. Title matching fixed it.

4. Which collections are cities and towns
  • archive.db geo_map is empty. collector.db has channel↔︎collection links and 1,589 channel names, but no place data.
  • Classes were built from three sources (tools/place_map.py, tools/rollup2.py):
    • civic.db in_cma (730 collections);
    • title and channel-name matches to same-state civic.db place names;
    • county, school/education and library cues split out.
  • The name-matched tier is review-grade. A later session (10.14) found it linked Sacramento → "Sacramento Faith TV" (SACFCA), and several city collections there carry PR content rather than meetings. Tier B below is therefore somewhat inflated.
5. Estimate

Broad lexicon, active archive.db items as of 2026-09-02.

Scope Collections Meetings Hours English captions exist Full-transcript DB Captioned-only DB
A. civic.db-linked cities/towns (730 places) 730 346 k 516 k 189 k h (37%) ~65 GB ~24 GB
B. A + name-matched cities/towns (955 places) 978 505 k 729 k 282 k h ~92 GB ~36 GB
C. B + public/government-access stations with no place resolved (many New England town stations) 1,520 836 k 1.16 M 482 k h ~146 GB ~61 GB
Excluded: county 157 95 k 138 k
Excluded: school/education 380 37 k 60 k
  • Misfire correction. Allowing ~5% misfire, full-transcript sizes are ~62 / 87 / 139 GB.
  • B is the floor. The true city/town figure lies between B and C, because civic.db lists only 13 MA places for 191 MA collections.
  • Summaries for every meeting add only 3–5 GB.
  • Transcription need: ~450 k h of ASR (B) to ~680 k h (C), or YouTube caption fetches. The pilot evidently got captions from YouTube directly: 78% of its hours are transcribed, against 52% of PCTCA hours with IA caption files.
6. Limits
  • Hours are undercounted. Some items lack a duration.
  • No de-duplication within or across collections.
  • Accuracy figures rest on one station (PCTCA) and 60-title eyeball samples.
  • archive.db snapshot: 2026-09-02.
  • Out of scope: the pilot's document-only sources (CivicPlus Agenda Center, BoardDocs) are not in the CMA and were not estimated.
7. Files
Path What
tools/meeting_scan.py Full archive.db scan (ran on archive-store) → per-dirname strict/broad items, hours, captioned hours; PCTCA dump
tools/sample_titles.py Uniform reservoir samples of broad-only and strict titles
tools/place_map.py, tools/rollup2.py Collection → place mapping and class rollup + size model
data/meeting_scan_cm_by_dirname.tsv Per-dirname scan output (2,188 dirnames)
data/meeting_scan_cm_pctca.tsv PCTCA items with strict/broad flags (positive control)
data/dirname_class.psv Class, place and hours per dirname
data/collection_place_map.psv, collection_meta.psv, collector_channels.psv, civic_in_cma.psv Inputs and mapping

The runnable working copy (original layout) is in ../10.14-openpublica-api-mapping/work/.

↑ back to contents10.15-ia-community-meetings-scale-estimate/IA_COMMUNITY_MEETINGS_SCALE_ESTIMATE_2026-09-14.md

20.0discover_agenda_systems_via_wayback_machineTarget discovery — 12,362 tenants across 10 vendors.
Executive summaryRead this first Open PDF · Democracy’s Library decision brief

Discover Agenda Systems via Wayback Machine

Tools and documentation for identifying which agenda-management vendor each US local government uses, by enumerating vendor tenant subdomains from the Internet Archive's Wayback CDX index.

Purpose: expand the set of known jurisdictions using one of ten agenda systems, so existing scrapers can be pointed at more targets and new extractor work can be prioritized by real client counts.

Status at packaging (2026-08-03): discovery complete — 12,362 tenants enumerated across ten vendors, up from a prior all-vendor working list of 2,414 — a 5.1x expansion. Next step is classification.


/code
File Purpose
vendor_host_harvest.py Main tool. Enumerates vendor tenant hosts from the Wayback CDX index using a SURT skip-scan. Also supports Common Crawl and crt.sh as secondary sources.
vendor_merge_classify.py Stage 2. Merges discovered hosts against existing target lists and classifies each as municipality / county / school board / library board / transit / special district.
cross_vendor_match.py Stage 3. Finds tenants appearing under more than one vendor — reveals vendor migrations and CMS/backend layering — and reports unique vs shared counts per vendor.
analyze_filtered.py Diagnostic. Explains the gap between raw_<vendor>.csv and hosts_<vendor>.csv — buckets every dropped identity by the rule that rejected it, so an over-strict filter can be found and fixed offline.
cdx_resume_probe.py Diagnostic. Determines the CDX server's resumeKey wire format — run this if index seeks start returning HTTP 400.
cc_columnar_hosts.py Alternative host enumeration via Common Crawl's columnar (Parquet) index using DuckDB.
cc_index_diag.py Diagnostic. Inspects Common Crawl Parquet schema, footer statistics, and file ordering.

All are stdlib-only except cc_columnar_hosts.py, which needs duckdb.

Quick start:

python3 code/vendor_host_harvest.py --sources wayback --sleep 1 --resume \
    --out-dir ./vendor_hosts

Read the runbook before a first run — particularly the four run modes (--resume / --append / --force / none), which govern whether existing results are protected.


/docs

Each document is provided as .md, .docx, and .pdf.

Document Audience Contents
agenda-vendor-discovery-2026-08-02 Operators Runbook. How the technique works (SURT keys, skip-scan, the undocumented resumeKey format), run procedures, log triage, known limitations. Start here to run anything.
agenda-vendor-discovery-exec-summary-2026-08-02 Project stakeholders Executive summary. Scope, effort anticipated vs actual, unexpected issues, lessons carried forward.
ia-democracys-library-decision-brief-2026-08-02 Internet Archive Decision brief. Eight decisions needed to scope and size a contribution — entity scope, collection name, item granularity, identifier scheme, delivery model. Each has a stated default so work is not blocked.

Not included

Harvested data (hosts_*.csv, raw_*.csv, all_vendor_hosts.csv, summary.txt) and scan state files are not packaged here — they are outputs, not assets, and are regenerated by running the tools.


Version History
Version Date Author Changes
1.0 2026-08-02 Claude.ai/John Hauser Initial package — five tools, three documents in three formats
1.1 2026-08-03 Claude.ai/John Hauser Added analyze_filtered.py; --import-hosts added to the harvester
1.2 2026-08-03 Claude.ai/John Hauser Discovery complete; documents updated with final counts
1.3 2026-08-03 Claude.ai/John Hauser Corrected baseline figure to 2,414 (all vendors)
1.4 2026-08-03 Claude.ai/John Hauser Added cross_vendor_match.py for cross-vendor tenant overlap

↑ back to contents20.0-discover_agenda_systems_via_wayback_machine/README.md

20.1civic-scrapers-bundle_2026-08-03Corpus classify + priority queue; the Granicus scraper.
Executive summaryRead this first

Civic meeting scrapers — review bundle

2026-08-03

Start here

docs/EXECUTIVE-SUMMARY_2026-07-26 — scope, effort, what went wrong, and the decisions now needed. Everything else is detail behind it.

Each document is provided three ways: .pdf (read anywhere), .docx (comment and track changes), .md (plain text, version-controllable). The content is identical.

What is in here
docs/    seven documents, each as .pdf / .docx / .md
code/    six tools and three test suites

Documents, in reading order

Document What it covers For
EXECUTIVE-SUMMARY Status, effort, lessons, open decisions Everyone
RUNBOOK How the tools fit together; what to re-run when Operators
workstream-a-findings What the 21,041-place corpus actually contains Analysts
backend-findings Vendors found by hand-checking 38 sites Analysts
tier4-selfhosted-runbook Sites with no vendor visible in the URL Analysts
legistar-build-manifest Plan for the next build Developers
granicus-notes Every bug found, with its evidence Developers

Code

File Purpose
classify_corpus.py Assigns a vendor to every place in the corpus
resolve_selfhosted.py Finds the agenda system behind a branded website
build_priority_queue.py Orders remaining work by population, largest first
granicus_resolve.py Turns a city page into a Granicus portal address
granicus_scrape.py Collects agendas and minutes from Granicus
granicus_renorm.py Repairs board names in already-collected data, offline
test_*.py Three test suites; run each with python3 <file>
Three things a reviewer should know

The numbers answer different questions. The corpus counts places (cities, towns, villages). The Wayback CDX search counts vendor client sites, which include school districts, counties and special districts. They should never be added together — the executive summary explains why this matters for scope.

"Self-hosted" does not mean bespoke. It means no vendor name appeared in the web address. Councils commonly run a branded page on top of a commercial agenda system, with the join hidden in JavaScript. An earlier version of this summary mistook that label for a finding and wrote off ~4,800 sites; the correction is recorded in the summary and the tier-4 document.

Nothing here is finished collecting. The tools work and have been run against real sites, but collection is early. The summary's open-items list is the current state.

↑ back to contents20.1-civic-scrapers-bundle_2026-08-03/README.md

30.0agenda-scraper-bundle_2026-07-21CivicPlus AgendaCenter scraper — first working extractor.
Executive summaryRead this first

Municipal Agenda Scraper — Bundle Manifest

Bundle date: 2026-07-21 Purpose: Self-contained snapshot of the municipal agenda-scraper project — working code plus all project documentation — for review and for importing into other working sessions.


What this project is (30 seconds)

A toolkit for collecting agendas and minutes from municipal government websites at scale. A working scraper handles CivicPlus AgendaCenter sites today. The project has mapped the full ~2,400-site landscape across ~11 meeting-management platforms and has a decided build order for extending coverage.

If you read one file: docs/civicplus-scraper-exec-summary_2026-07-21.* (one page, non-technical).


Bundle layout
/
├── MANIFEST.md          ← you are here
├── code/
│   └── civicplus_agendas.py   (the scraper, ~1,600 lines, Python 3)
├── data/                (small representative sample — see data/README.md)
│   ├── sample_input_sites.csv        (what --preflight-csv consumes)
│   ├── sample_rejected_sites.csv     (what --summarize reads)
│   ├── sample_recovered_sites.csv    (a passing-output example)
│   └── vendor_distribution_summary.txt  (full 2,414-site platform tally)
└── docs/                (4 documents, each in 3 formats: .md .docx .pdf)
    ├── civicplus-scraper-exec-summary_2026-07-21.*
    ├── civicplus-scraper-project_2026-07-21.*
    ├── civicplus-scraper-runbook_2026-07-21.*
    └── civicplus-agenda-scraper_2026-07-20.*

Formats: every document is provided as Markdown (.md, source), Word (.docx), and PDF. Non-technical reviewers should use the .pdf or .docx; the .md is the editable source.


The documents — what each is for, and who should read it
Document Audience What it covers
exec-summary Leadership / reviewers One page: scope, effort (expected vs. real), surprises, lessons, the decision needed. Start here.
project Anyone continuing the work Full state: complete vendor map (2,414 sites), decided build order (CivicClerk → Granicus/Legistar), open questions, coverage math.
runbook Operators running the tool How to run every mode, flag reference, the read-vs-write gotcha, diagnosis steps, parallelism rules, sanity checks.
agenda-scraper (07-20) Historical reference Earlier project summary from the CivicPlus build phase. Superseded in parts by the 07-21 project doc; kept for continuity.

Read order for a new person: exec-summary → project → runbook. The 07-20 doc is background.


The code

code/civicplus_agendas.py — single-file Python 3 scraper.

  • Dependencies: pip install requests beautifulsoup4 lxml
  • Handles: CivicPlus AgendaCenter (categories, years, agendas, minutes), PDF download into a State/City/Category/Year/Type/ tree, legacy vendor subdomain resolution, endpoint auto-detection with manual overrides.
  • Batch tooling: --preflight-csv (test many sites for compatibility), --summarize (triage the rejects by platform/vendor), vendor detection.
  • Quick start: python3 code/civicplus_agendas.py --help

Full operating instructions are in the runbook.


Current status at a glance
  • CivicPlus AgendaCenter scraper: working (~1,300+ sites addressable).
  • Full platform map: complete (2,414 sites classified).
  • Build order: decided — CivicClerk first (public API, largest untapped block), then the Granicus/Legistar family. Details and rationale in the project doc.
  • Not yet built: refactor into a per-platform plugin architecture; any second extractor.

Important caveats (please read before acting on the numbers)
  • Vendor counts come from an imperfect upstream classifier (a separate effort). Treat site counts as approximate (±10–20%). Confirm before committing resources.
  • Platform feasibility is unconfirmed for platforms beyond CivicPlus. Each new extractor should begin with a quick check that its API/site structure is accessible with plain HTTP (some may need a real browser).
  • The docs candidly record several mid-project course corrections. That transparency is intentional — the lessons are part of the deliverable.

Sample data included

A small representative slice of the working data is in data/ (see data/README.md) — enough to understand the file formats and try the tooling. The full working data (complete input site lists, full preflight output, debug HTML dumps) is not bundled: it is large, environment-specific, and regenerable by the code. The docs reference these fuller files by name where relevant.

↑ back to contents30.0-agenda-scraper-bundle_2026-07-21/MANIFEST.md

30.1civicplus_forensics_bundleForensic assessment of the legacy ~800k-doc CivicPlus corpus; incl. the blank-PDF finding (13,550 empty shells, mostly never-published meetings) + civicplus_blank_scan.py.
Executive summaryRead this first
CivicPlus Archive — Forensic Assessment Bundle
==============================================

  docs/   Start here.  (All findings current as of the 2026-08 full run.)
            EXECUTIVE_SUMMARY  — 3 pages, non-technical: what this collection is,
                                 what survived, and what to do next.
            PROGRESS_SUMMARY   — what was investigated and established.
            RUNBOOK            — how to re-run the tools, what every category
                                 means, and what to investigate first.
            SQL_REFERENCE      — the database schema, how the tables join, and
                                 ready-made queries for your own questions.
            LESSONS_LEARNED    — what the collection taught, and what the
                                 assessment's own mistakes taught.
          Each is provided as .md (source), .docx (Word) and .pdf.

  code/   civicplus_forensics.py    builds the index and classifies everything
          civicplus_investigate.py  named diagnostic checks, no SQL needed
          civicplus_refetch.py      probe whether source sites still answer,
                                    fingerprint who serves them now, and
                                    repair what they will serve
          civicplus_migration_test.py  for an address that has gone dark: did the
                                    jurisdiction leave, or just move?
          civicplus_blank_scan.py   find "blank" PDFs -- structurally valid
                                    (%PDF, %%EOF, one page) but no text, fonts,
                                    or images, so they render empty. Passes every
                                    other check yet carries nothing. Modes:
                                    strata (size histogram), sample (blank rate),
                                    full (exact count), breakdown (--resolve the
                                    www bucket + --rebuild-url a re-fetch
                                    worklist), fetch (re-download and verify not
                                    blank), ledger (roll the fetch log into a
                                    per-outcome/per-tenant census), check (spot a
                                    file).
          Python 3.8+, standard library only. The corpus is read-only.
          (blank_scan's optional --render uses poppler's pdftoppm if present.)

  data/   civicplus_host_lookup.csv       web address -> state/place (2,063 rows)
          civicplus_slug_lookup.csv       registry, normalized (1,550 rows)
          civicplus_script_crosswalk.csv  each batch script and its target
          sample_*                        shape of each input and output file

Quick start
-----------
  python3 code/civicplus_forensics.py \
      --root ~/civic-data-project \
      --sites civicplus_sites.csv \
      --host-lookup data/civicplus_host_lookup.csv \
      --db civicplus_index.db --out reports --stage all --verify-magic

  python3 code/civicplus_investigate.py --db civicplus_index.db --check orphans

See docs/RUNBOOK for everything else.

↑ back to contents30.1-civicplus_forensics_bundle(13)/README.txt

31.0civicclerk_scrapers_bundleCivicClerk/CivicWeb scraper effort.

Municipal Meeting-Document Scrapers — Bundle

Bundle date: 2026-07-26 Purpose: complete, current snapshot of the CivicClerk/CivicWeb scraper effort — code + docs in one place, so work can resume in a fresh conversation with no context loss.

Layout
/code   — the tools (current versions only; superseded drafts excluded)
/docs   — runbook, master plan, project summary
/code — tools (run order per docs/runbook.md)
File Purpose Stage
civicclerk_identify.py Parse portal URLs/slugs (CDX list) → state/place. ⚠ manual place-name review required after (see runbook §2 A1). A (input prep)
civicclerk_discover.py Find portals from place names by probing the API. A (alt)
civicclerk_resolve.py Resolve a city agenda page → embedded portal URL. A (alt)
civicclerk_snapshot.py Record per-portal event counts, timestamped (audit baseline + drift). B
civicclerk_api_scrape.py PRIMARY. OData scraper + --preflight gate + --download (emits per-client records) + --heartbeat liveness. C/D
civicclerk_backfill.py Reconstruct runs/{siteid}.csv records for runs done before records existed. E (catch-up)
civicweb_archive.py Pre-2021 archives on CivicWeb/iCompass (*.civicweb.net). separate track
civicclerk_html_archive.py Generic HTML-archive fallback enumerator. separate track

NOT YET BUILT: civicclerk_reconcile.py (Stage F audit — joins runs/ + disk tree + snapshots). Design is in runbook §7; records + snapshots are being captured now so it can run retroactively.

Primary scraper is at feature level: records emission + Ctrl-C-safe atomic records + archive-then-write eviction + progress heartbeat. (Built up over versions v1→v3; only the current consolidated file is included here.)

/docs
File What it is
runbook.md Operator playbook. Tool inventory, run sequence by stage, failure triage, the living variant log (§9), and future weekly-automation design intent (§11). Start here to RUN the pipeline.
master-plan-meeting-scrapers_2026-07-22.md Strategy across ALL platforms (the 2,414-site vendor map, classification-first plan, build order). Start here for the BIG PICTURE.
civicclerk-scraper-project_2026-07-22.md CivicClerk/CivicWeb project summary (API discoveries, tool details).
Key facts that took real work to learn (don't re-derive)
  • civic-scraper library is BROKEN for modern CivicClerk (parses a __VIEWSTATE field React portals lack). All tools here are from-scratch against the real API.
  • CivicClerk wildcards DNS — every hostname resolves. Existence must be checked via the API (/v1/Events → JSON vs empty body), never DNS.
  • Pagination is mandatory (@odata.nextLink, ~15 rows/page). Files come from the single-event Events({id}) record, not the collection.
  • Preflight samples events across the WHOLE date range (recent events are often empty placeholders — sampling only recent = false negatives).
  • Two recurring "honest" failures: phantom tenants (@events: no rows → drop) and video-only CivicClerk (@files fail → docs are on CivicPlus, route there).
  • Data root /mnt0/civicclerk_output/ is self-describing: {ST}/{Place}/{Board}/{Year}/*.pdf
    • runs/{siteid}.csv (records) + discovery/ (snapshots) + logs/.
Environment
  • Ubuntu; Python 3, standard library only (no third-party deps for the API tools; civicclerk_html_archive.py uses BeautifulSoup if present, else stdlib).
  • Politeness: keep --delay ≥ 0.3–0.5, modest --jobs.

↑ back to contents31.0-civicclerk_scrapers_bundle/civicclerk-scrapers-bundle_2026-07-26/MANIFEST.md

31.1civicclerk_TX_diagnostics_and_code_changesTexas run — 74,177 docs; v3→v5 diagnostics & fixes.

CivicClerk — Texas run: diagnostics, findings and code changes

Handoff note. This work happened inside a Legistar-focused conversation and belongs with the CivicClerk effort. Everything needed to pick it up is here.

Date: 2026-08-05/06. Scraper went from v3 (2026-07-26) to v5 (2026-08-05).


1. The run
119 clients | 74,177 downloaded | 5,785 cached | 1 failed | 32h24m | ~41/min

Command as issued:

python3 civicclerk_api_scrape-2026-07-26-v3.py \
    --portals TX_civicclerk_remaining.csv --jobs 8 --delay 1 \
    --start-date 2000-01-01 --end-date 2026-12-31 --download \
    --download-dir /mnt0/civicclerk_output

Output tree /mnt0/civicclerk_output/{ST}/{Place}/{Board}/{Year}/, records CSVs in runs/<client>.csv.


2. Findings

2.1 Zero-file meetings — 11,553, and nothing recorded them

The log carries lines like 2022-09-15 | City Council (#26) - 0 file(s). A meeting with no documents was not a match, not a failure, and not written anywhere — it existed only as a line in a multiplexed stdout log. The run summary (1518 file(s) matched; downloaded 1518, cached 0, failed 0) cannot express it.

Counted from the log:

zero-file meetings 11,553
dated in the future (placeholders) 1,464
dated in the past 10,089

By year, past only: 2016 302 · 2017 550 · 2018 406 · 2019 708 · 2020 845 · 2021 885 · 2022 997 · 2023 1,261 · 2024 1,378 · 2025 1,378 · 2026 665, with a thin tail back to 2006.

Three distinct causes are mixed together, and only one is benign:

  1. Scheduled placeholders. Cities create a year of calendar entries in advance. Watauga's monthly City Council pairs run empty to 2026-05-25 while 2026-06-22 has agendas. The 1,464 future-dated ones are all of this kind — and they were in scope only because --end-date 2026-12-31 was five months past the run date.
  2. Genuinely never published. Wimberley's third-Thursday council series (#24, #25, #26) goes empty while the first-Thursday series keeps publishing. A real property of that body, not a scrape failure.
  3. The one that matters: documents the API did not report but the portal shows. This is unverified. It is exactly the failure that cost 60,781 meetings on the Legistar side, where a status field's silence was read as absence.

--section archived is a Legistar flag; CivicClerk's scraper has no equivalent. Bounding --end-date to the run date is what excludes placeholders.

2.2 The log could not be demultiplexed

With --jobs 8, eight workers write one stdout. Lines naming a file are attributable by their path; the 0 file(s) lines named no client at all. Wimberley file paths appear inside Watauga's section in the same log. So 31 hours of work produced 74,177 documents and no way to attribute any gap to a city — not by cleverness at analysis time, because the information was never written.

2.3 Duplicate place directories

Baycity/Bay_City and Bigspring/Big_Spring — an earlier run was killed, place names corrected, and the state re-run. Verified before deleting:

TX/Baycity:  762 files   TX/Bay_City:  1,123    in OLD not NEW: 0
TX/Bigspring: 308 files  TX/Big_Spring:  343    in OLD not NEW: 0

New runs were strict supersets, so the old trees were removable. Worth noting the general practice: a killed run can leave a file the re-run never reached, so the set-difference is what proves deletion is safe.

2.4 GUID-named documents — a false alarm

Bay City logs MEET-Agenda-f68bb121… where other cities log human titles. The GUID is the meeting's, not the file's — an Agenda and Minutes for one meeting share it. Checked on disk: all %PDF, sizes scattering 30 KB–410 KB, which is the signature of uploaded documents rather than generated placeholders. The name never reaches the tree, since on-disk filenames are built from date/board/type/file-id. Nothing to fix.

2.5 "General" board — 6,613 files, 106 tenants, the real defect

General was the most common board directory in Texas (93 places). It is not a body: CivicClerk defaults an uncategorized meeting's categoryName to "General", and 106 tenants leave it there.

Friendswood shows the damage — four bodies in one directory:

Friendswood | 2018-01-08 | Agenda  | City Council 01-08-2018 Regular Agenda
Friendswood | 2018-01-08 | Agenda  | P&Z 01-08-2018 Agenda
Friendswood | 2018-01-09 | Agenda  | FDEDC 01-09-2018 Agenda
Friendswood | 2018-01-09 | Agenda  | Senior Citizen 01-09-2018 Agenda

Diagnosis, in order:

  • board == category for all 6,613 rows — the records CSV offers no alternative field.
  • Filename-prefix extraction matches only 1,292 of 6,613 (20%), and conventions differ per city (Royse City puts the date first). A regex approach would mis-attribute more than it fixed.
  • The API probe settled it. A raw event dump showed eventName carrying the body — "City Council Meeting", "Investment Committee Meeting" — while categoryName is the literal "General".

Root cause, one line:

board = cat or ev_name or "Board"      # "General" is truthy, so ev_name never ran

A generic value is not a value. Test for meaning, not for presence.

2.6 event_id was never written

_record() passed event_id="" on every row. The key tying a document to its meeting is blank in every CivicClerk records CSV produced to date, including the completed Texas run.

2.7 Flagged, not resolved

Notice_of_Possible_Quorum appears as a board directory. It is a Texas posting category, not a deliberative body — the same class as Legistar's Miscellaneous Agendas and Public Notice buckets. Decide before item identifiers are minted: keep with a note, or map to the posting body.


3. Code changes

v4 — attribution and pending records

  • PendingWriter (subclasses RecordsWriter, same atomic partial-then-promote): writes runs/<client>_pending.csv, one row per zero-file meeting, with a classified reason — scheduled for future dates, no_files_reported for past ones. This is the CivicClerk equivalent of Legistar's _pending.csv, and without it the gap is uncountable.
  • ClientLog — every per-meeting line prefixed [<client>], so a combined log demultiplexes with grep.
  • --client-log-dir DIR — optional per-client log files alongside the combined stream.

v5 — the body name

  • body_from_event(category, event_name) → (board, source). A category is used only if it names something; GENERIC_CATEGORIES covers general, uncategorized, default, other, misc, n/a and blank, so this is not a Texas-specific patch. Falls back to eventName.
  • Trailing "Meeting" trimmed so City Council Meeting and City Council — both present in one tenant — are one body. _MEETING_KIND protects Special Meeting, Regular Meeting and similar from becoming Special.
  • board_source column (category | event_name) in the records CSV, so a derived body name is visible and reversible rather than silently overwriting.
  • event_id now actually written.

Files: civicclerk_api_scrape-2026-08-05-v5.py, civicclerk_v3_to_v5.diff (270 lines against v3).

civicclerk_regeneral.py — remediating the 6,613 already on disk

Three phases, each stopping for review; deletion is never automatic.

python3 civicclerk_regeneral.py --output-dir /mnt0/civicclerk_output plan
python3 civicclerk_regeneral.py --output-dir /mnt0/civicclerk_output rerun \
    --scraper ~/Downloads/civicclerk_api_scrape-2026-08-05-v5.py \
    --portals TX_civicclerk_remaining.csv --jobs 8 --delay 1 --apply
python3 civicclerk_regeneral.py --output-dir /mnt0/civicclerk_output verify

plan finds affected clients from the records CSVs. rerun slices the portals CSV to just those clients and re-runs them — the destination path changes (General → City_Council), so skip-if-exists cannot match the old copy and the files are re-fetched into the right place. verify set-differences each General/ tree against the rest of that city's tree and lists a directory as safe only when every filename appears elsewhere; it writes the delete list but never deletes.

Expect some directories to remain unsafe: meetings where eventName was empty too have nowhere else to go and legitimately stay General.


4. Open items
  1. Regenerate the Texas inventory. A metadata-only pass (no --download) with v5 writes the pending CSVs for all 119 clients in minutes rather than 31 hours, and bounds the window to the run date:

    python3 civicclerk_api_scrape-2026-08-05-v5.py \
        --portals TX_civicclerk_remaining.csv --jobs 8 --delay 1 \
        --start-date 2000-01-01 --end-date $(date +%F) \
        --records-dir /mnt0/civicclerk_output/runs \
        --client-log-dir /mnt0/civicclerk_output/logs/clients
  2. Answer the index-or-source question. Sample ten no_files_reported rows across different cities and open the portal pages by hand. Documents present means CivicClerk's API is an index like Legistar's and 10,089 meetings need a recovery path. This decides whether the Texas corpus is complete or 12% short.

  3. Run the General remediation (§3) — 6,613 files across 106 tenants.

  4. Decide on Notice_of_Possible_Quorum and any similar posting categories.

  5. Investigate the single failed 1. One failure across 32 hours is findable now and impossible later.

  6. Note that existing records CSVs lack event_id. Any join built on the Texas run's current CSVs has no meeting key until they are regenerated.


5. Carried over from the Legistar effort

Three patterns that recurred and are worth applying to CivicClerk directly:

  • A field being populated is not the same as its contents being usable. On Legistar this appeared four separate times (bare filenames that broke the downloader, the pending path, a CSV validator, and provenance selection). On CivicClerk it is "General" — populated, truthy, and meaningless.
  • "We did not look" must never be recorded as "it is not there." Every guard that stops trying needs its own reason code, or the gap silently becomes a coverage statistic.
  • Count the thing itself. downloaded/kept conflates fetches with skips; grep -c '^\[saved\]' does not. Order-of-magnitude wrong conclusions came from the former.

The Legistar runbook's §13 is a symptom-to-cause diagnostic playbook built from these; most of it transfers with the vendor name changed.

↑ back to contents31.1-civicclerk_TX_diagnostics_and_code_changes/CIVICCLERK_TX_HANDOFF.md

32.0legistar-scraper-build1Legistar scraper build #1; 339 offline tests.
Executive summaryRead this first
LEGISTAR SCRAPER -- BUILD #1 SNAPSHOT
=====================================
Packaged: 2026-08-05

READ THIS FIRST
---------------
This archive is a point-in-time snapshot of the first working build of the
Legistar civic-meeting scraper, packaged for review. Development continued
after this was cut: a later working session may have productionalized or
superseded what is here. If you have received more than one archive, or have
access to the project's later conversations, CHECK FOR A NEWER VERSION before
building on this one. The findings log (docs/legistar-notes.md) is the
authoritative record of what was known at packaging time.

WHAT THIS PROJECT IS
--------------------
The third scraper in a family (Granicus, CivicClerk, Legistar) that collects
municipal meeting agendas and minutes into a common archive layout
(State/Place/Board/Year/DocType) with a shared record schema. This build
covers the 215 US municipalities running Legistar, of which 170 are live,
scrapeable clients. It scrapes the Legistar web API for enumeration and
falls back to the human-facing InSite meeting pages for the roughly one
tenth of documents the API links to but does not report.

CONTENTS
--------
MANIFEST.txt                          this file

code/
  legistar_api_scrape.py              the scraper (~4,000 lines). All modes in
                                      one file: scrape, download, preflight,
                                      field census, InSite survey, InSite
                                      document recovery, pending-document
                                      audit. Run with --help for usage; the
                                      design doc explains the architecture.
  test_legistar_offline.py            339 offline tests (no network needed):
                                      python3 -m unittest test_legistar_offline
  legistar_recovery_audit.py          joins a run's pending CSVs against its
                                      records CSVs to report how many
                                      API-missing documents were recovered
                                      from InSite, and what is still gone.
  legistar_action_summary_clients.py  filters an --insite-survey result down
                                      to the clients whose boards publish
                                      "Action Summary" documents, for targeted
                                      collection.

docs/  (each document in three identical-content formats: .md source,
        .docx for commenting in Word, .pdf for reading)
  legistar-executive-summary.*        ONE PAGE. Start here: status, scope,
                                      planned-vs-actual effort, the five
                                      unexpected issues, lessons, and the
                                      decisions still open.
  legistar-scraper-design.*           the original design/plan written before
                                      implementation. Kept as-written; the
                                      notes record where reality disagreed.
  legistar-notes.*                    the findings log, v1-v26: every live
                                      run, every anomaly, every bug (mine and
                                      Legistar's), in chronological order.
                                      The project's institutional memory.

SUGGESTED READING ORDER (non-technical reviewers)
-------------------------------------------------
1. legistar-executive-summary.pdf    (3 pages)
2. legistar-scraper-design.pdf       (9 pages, skim)
3. legistar-notes.pdf                (35 pages, reference -- skim the section
                                      headers, which are dated and titled by
                                      finding)

THE ONE-SENTENCE FINDING
------------------------
The Legistar API is a reliable index and an unreliable inventory: it
eliminated the structural guesswork of HTML scraping, but silently omits
documents that exist, so this build uses the API to decide what to fetch and
the human-facing meeting pages to actually fetch it.

NOT INCLUDED
------------
No scraped documents, no run outputs, no target lists with per-client tokens.
The scraper produces those; this archive is the tool and its paper trail.

↑ back to contents32.0-legistar-scraper-build1/MANIFEST.txt

32.1legistar_client_runsProduction runs — 418,871 docs, 170 jurisdictions, 33 states.
Executive summaryRead this first Open PDF · IA decision brief — Legistar corpus

legistar_client_runs

Tooling and documentation for collecting US local-government meeting agendas and minutes from Legistar, one of ten agenda-management vendors.

Packaged 2026-08-02. Scraper version 2026-08-02.6, pipeline driver 2026-07-30.1.


What this produced

418,871 documents from 170 jurisdictions across 33 states, spanning 2000–2026. Every file traces to a source URL, event id, publishing body, meeting date and document type — reconciliation is clean in both directions, with zero unexplained files and 1,918 records (0.48%) whose document could not be retrieved, each classified rather than reported as generic loss.

The documents themselves are not in this archive. This is the method: the scrapers, the verification tooling, and the record of why each decision was made.


Start here
if you want to… read
understand the project in ten minutes docs/EXECUTIVE_SUMMARY.md
decide something with the Internet Archive docs/IA_DECISION_BRIEF.md
run or verify a collection docs/LEGISTAR_RUNBOOK.md
know why the code looks like it does docs/legistar-notes.md

If you have been handed a corpus to verify or upload and did not do the collection, start at runbook §12. It is self-contained: it explains the artifacts before asking you to act on them, and every check states what a good result looks like.


docs/

Each document is provided in three formats — .md (source), .docx (Word, for review and comment) and .pdf (for reading and printing). The .md files are authoritative; the other two are generated from them, so edit the markdown and regenerate rather than editing a Word file that will be overwritten.

cd docs
for f in *.md; do pandoc "$f" -o "${f%.md}.docx" --from=gfm --toc --standalone; done
soffice --headless --convert-to pdf --outdir . *.docx
  • EXECUTIVE_SUMMARY.md — scope, effort estimated versus actual, the six categories of problem we did not anticipate, and the lessons that generalise to other vendors.
  • IA_DECISION_BRIEF.md — eight decisions pending with the Internet Archive: item granularity, identifier scheme, delivery model, document scope, and who owns ongoing refresh. Each section ends in a question.
  • LEGISTAR_RUNBOOK.md — operator procedure. Collection stages, the per-state pipeline, document format policy, the weekly refresh job, and (§12) reconciliation, remediation and upload preparation.
  • legistar-notes.md — the engineering log. Every non-obvious decision with the evidence that forced it, in version order. The most transferable artifact here: it records which failure modes are real rather than theoretical.
code/

Collection

  • legistar_api_scrape.py — the scraper. Preflight, survey, download, InSite recovery, per-tenant host repair, content sniffing. --version reports what it supports; check it before any long run.
  • legistar_run_state.sh — pipeline driver. Walks one state through slice → preflight → survey → plan → download → audit, with pinned filenames, gates and a status ledger. -n for a dry run.
  • legistar_weekly.sh — cron-schedulable refresh across every state, with audit gates and a count of what arrived.
  • legistar_recovery_clients.py — splits a state's clients into the batch that needs InSite recovery and the batch that does not.

Verification

  • legistar_reconcile.py — the tree against the records CSVs, both directions. Files with no provenance, and records with no file, with a per-client host census that classifies why.
  • legistar_recovery_audit.py — recovery yield per pending reason, each against its own denominator. --min-pct for the weekly gate.
  • legistar_minutes_gap_count.py — sizes the missing-minutes gap by body, separating bodies that publish minutes sometimes from those that never do.
  • legistar_packet_scan.py — finds agenda packets misfiled as agendas, by size relative to each tenant's own norm, confirmed by page count.

Repair

  • legistar_refetch.py — re-fetches documents listed in missing.csv using the scraper's host-repair ladder, for records the API no longer returns.
  • legistar_unfile.py — moves documents written under a placeholder state back to the client tree they belong to.

Upload

  • legistar_ia_manifest.py — builds an ia CLI upload spreadsheet from the download tree joined to the records CSVs. Configurable granularity and identifier prefix; emits neutral column names for late field mapping.

legistar_api_scrape_v34.diff is the cumulative diff of the scraper against the version that began this round of work, if you want to see what changed rather than what it does.


The one thing worth knowing before you read the code

The Legistar API is an index, not a source. It reports what it stored when a meeting was published; the meeting's own page is a superset — more document classes, current links, and the truth about what exists today. The architecture is API-to-decide-which-pages, page-to-fetch-content, and most of the complexity in this codebase exists because that was learned late rather than assumed early.

For the next vendor, the ten-minute test: fetch one record from the API and its human-facing page, and diff the document sets. If they differ, design for an index from day one.

↑ back to contents32.1-legistar_client_runs(2)/legistar_client_runs/README.md

40.0volunteer-contributionHow people can help — coordination, recognition, and review.

Volunteers & Contribution — "How can I help?"

The metadata-enhancement roadmap (metadata-enhancements.md) has a large amount of work that is better done by people than by machines — and specifically by people with local knowledge, subject expertise, or archival training. This document maps volunteer contributors to that work: who can help, what they'd do, how they're supported and supervised, and answers to the questions a prospective volunteer asks.

The core insight: enrichment projects like transcription-correction, place mapping, speaker identification, and topic tagging are exactly the kind of work that scales through motivated volunteers — if the coordination and quality-control infrastructure exists to make their contributions trustworthy. This document is about building that infrastructure, not just recruiting.


1. Who can help, and what they're uniquely good at

Different contributors bring different strengths. The work is deliberately sliced so each profile has a natural, valuable lane.

Contributor Brings Best-fit work
Interested laypersons time, care, willingness to learn caption correction, tagging, simple verification, coverage-gap flagging
Community-media professionals how public-access production works; local station relationships identifying collections, filling metadata gaps, quality-checking others' work in their domain
Editors / writers language precision, consistency transcript cleanup, description writing, controlled-vocabulary tagging, style consistency
MARA / MLIS graduate students archival theory, metadata standards, controlled vocabularies schema/vocabulary design, authority control, provenance work, supervising quality — the librarian layer
Seniors with deep local knowledge irreplaceable memory of people, places, events speaker identification, place/event disambiguation, historical context, correcting "who is this / where is this" that no algorithm knows

The last row deserves emphasis: local knowledge is the one input that cannot be automated or outsourced. A retired city clerk who can identify every council member by voice across fifteen years of meetings is providing data that no ASR or NER pipeline can produce. Designing roles that capture that knowledge is the highest-value part of a volunteer program for this corpus.


2. What volunteers actually do — tasks mapped to the roadmap

Each task ties to a project in metadata-enhancements.md. Tasks are sized so a newcomer can do a useful unit in one sitting.

Caption & transcript correction (Project C)

The harvest tiers (on-disk YouTube captions, pre-2019 IA captions) produce usable but imperfect text; ASR on the gaps produces more. Volunteers review and correct segments — fixing misheard words, speaker turns, and punctuation. This is the largest pool of volunteer work and the most accessible: anyone literate can improve a transcript against the audio.

  • Layperson / editor / senior. Editors raise consistency; seniors catch local names ("that's Commissioner Yoakum, not 'commissioner yokum'") that others can't.

Place & jurisdiction mapping (Project B)

The dirname→place map (lvnwks → Leavenworth, KS) is mostly mechanical but has exceptions and ambiguities a human resolves quickly. Volunteers confirm/complete the locality, county, and coordinates per collection.

  • Community-media pro / senior / student. Locals disambiguate same-named towns; students apply geographic authority control.

Speaker & entity identification (Project E)

After NER proposes people/orgs, volunteers confirm, correct, and link them — especially recurring speakers across meetings. This is where local knowledge is decisive.

  • Senior / community-media pro. The "name that voice / face" work is uniquely human and uniquely valuable.

Topic & subject tagging (Projects A/E)

Apply a controlled vocabulary of civic topics (zoning, budget, public safety, …) to items or transcript segments, so topical search and prevalence analysis work.

  • Editor / student / layperson. Students design/steward the vocabulary; others apply it.

Coverage-gap flagging (Sub-project C-gap; Project G)

Volunteers spot and report gaps: a collection missing recent meetings, an item whose caption is garbage, a video that's gone dark. This mirrors the operational missing-video work, done by humans who watch the content.

  • Anyone. A great first task — low skill floor, immediately useful.

Description & context writing (Project A)

Write or improve item/collection descriptions — what a meeting covered, why a collection matters. Turns thin auto-metadata into human-useful context.

  • Editor / community-media pro / senior.

Metadata standards & authority control (cross-cutting)

Design the controlled vocabularies, name-authority files, and quality rubrics the other tasks depend on. This is the librarian layer that makes volunteer output consistent and trustworthy.

  • MARA / MLIS students and professionals. This is their discipline; it's also the supervisory backbone (see §4).

3. Contribution levels (a ladder, not a wall)

Volunteers should be able to start trivially and grow into responsibility. A tiered ladder also is the quality-control structure (higher tiers review lower tiers):

  1. Contributor — do discrete micro-tasks (correct a transcript, confirm a place, tag a topic). No commitment, no training beyond a short guide. Work is queued and reviewed before it lands.
  2. Reviewer — trusted contributors who check others' work and approve it. Earned by a track record of accepted contributions. This tier is what lets the program scale without every edit hitting a paid supervisor.
  3. Domain steward — owns a slice (a region, a collection group, the topic vocabulary, the speaker-authority file). Coordinates contributors, resolves disputes, maintains standards in their area. Often MARA/MLIS students/pros or deeply-engaged locals.
  4. Program coordinator — the paid/lead role(s); see §4.

Progression is earned through accepted work, not tenure — which keeps quality tied to demonstrated reliability.


4. Management & supervisory infrastructure

Volunteer enrichment fails without coordination and quality control. The insight: the same "claimed vs. verified" discipline the archiving pipeline uses for videos applies to human contributions. A volunteer edit is a claim; a reviewer's approval is verification; unreviewed edits don't land in the authoritative dataset. That mental model — already proven in this project — is the backbone.

The people

  • Program coordinator(s) — paid or lead volunteers who recruit, onboard, set priorities, and handle escalations. Realistically 1 coordinator per ~30–50 active contributors. This is the role that most needs to be resourced; everything else can be volunteer-run if a coordinator holds it together.
  • Domain stewards (tier 3 above) — the distributed supervisory layer; each owns a region/collection-group/vocabulary and reviews or delegates review.
  • A metadata/archival advisor — a MARA/MLIS professional (or faculty partner) who owns standards, vocabularies, and authority control. Part-time but essential: they keep volunteer output aligned with real archival practice, and they make the program a credential-worthy experience for students (see §5, practicum angle).

The systems (what has to exist)

  • A task queue / work-assignment system — hands out micro-tasks, tracks who did what, prevents duplicate work. This is the single most important piece of infrastructure; without it, volunteers don't know what to do and effort collides.
  • A review/approval workflow — every contribution enters as pending, gets reviewed (by a tier-2+ reviewer), and only then merges into the authoritative metadata. Mirrors the collector's claimed→verified model exactly.
  • Provenance on every edit — who changed what, when, from which source, reviewed by whom. The metadata DB's soft-delete/history discipline extends naturally to human edits; nothing is ever silently overwritten.
  • Contributor guides & rubrics — short, task-specific "how to correct a transcript," "how to tag a topic," maintained by the stewards/advisor.
  • Recognition — visible credit, contribution stats, and (for students) a documentable record for their program. Recognition is the volunteer program's currency; budget for it.

Quality control model

  • Redundancy for high-stakes fields — e.g. two independent confirmations before a speaker identification is authoritative; consensus resolves disagreement.
  • Sampling — stewards spot-check accepted work to keep reviewers calibrated.
  • Reputation — contributors whose work is consistently accepted earn lighter review; new contributors get more. This concentrates supervisory attention where it's needed and rewards reliability.
  • Nothing lands unreviewed — the authoritative dataset only ever ingests verified contributions; raw edits live in a staging layer. (Same architecture as keeping archive.db authoritative and collector.db claims pending until reconciled.)

5. FAQ

Q: I'm not technical at all. Can I still help? Yes — most of the work is not technical. Correcting a transcript against the audio, confirming which town a collection belongs to, identifying a speaker you recognize, or tagging what a meeting was about needs care and knowledge, not coding. The task queue hands you a small, clear unit of work with a short guide.

Q: How much time do I need to commit? None ongoing. The work is designed as micro-tasks — do one transcript segment or one tagging task in a few minutes, or spend an afternoon. Contribute once or become a steward; both are welcome.

Q: I have deep knowledge of my town's history/government. Where's that most useful? Speaker and place identification, and historical context — the things no algorithm can know. Recognizing officials by voice across years of meetings, disambiguating local places and events, and explaining "why this meeting mattered" is the single most valuable contribution for this corpus, and it's irreplaceable.

Q: I'm a MARA/MLIS student — is there work at my level, and does it count? Yes, and this is designed as a practicum-quality experience: controlled-vocabulary design, name-authority control, provenance modeling, and supervising contributor quality are core archival practice on a real 3.2M-item corpus. With a faculty/advisor partnership it can be structured as documentable, credential-relevant work.

Q: How do I know my corrections actually matter / won't be lost? Every edit is attributed, reviewed, and versioned — nothing is silently overwritten, and you can see your accepted contributions. Reviewed work merges into the authoritative archive that researchers and the public use.

Q: What if I make a mistake? That's what the review layer is for — contributions are checked before they land, so mistakes are caught, not shipped. New contributors get more review; there's no penalty for honest errors.

Q: Can my community-media station / historical society / class participate as a group? Yes — a group can adopt a collection or region as domain stewards. Group participation with a local coordinator is one of the most effective patterns, because it concentrates local knowledge where it's most relevant.

Q: Who decides the standards (vocabularies, how to tag, name spellings)? The metadata advisor and domain stewards, following established archival practice. Standards are documented in the contributor guides so everyone works consistently; proposed changes go through the stewards.

Q: Is my work going to train AI / be sold? [A policy question for the program to answer explicitly and honestly up front — volunteers deserve a clear, truthful statement of how their contributions are used and licensed. State it plainly in the volunteer agreement.]


6. Why this fits this project specifically
  • The work is already sliced. The enhancement roadmap's tasks (caption correction, place mapping, speaker ID, tagging, gap-flagging) are naturally human-sized and queueable — the volunteer program is mostly coordinating work the roadmap already defines.
  • The quality model already exists. "Claimed vs. verified," soft-delete history, provenance-on-everything, coverage-gap worklists — the disciplines this project built for videos transfer directly to human contributions. The supervisory infrastructure is the same architecture applied to people.
  • Local knowledge is the corpus's missing input. Civic media is inherently local; the people who understand it are distributed across exactly the communities the archive covers. A volunteer program isn't just cheaper labor — it's the only source of the local-knowledge metadata that makes the corpus truly navigable.
  • It creates a virtuous loop with the reporting work. Channel-owner reports (report-mockups.md) put the archive in front of the very communities whose members are the ideal volunteers — the report that shows a town "here's your preserved public record" is also the natural on-ramp to "…and here's how you can help make it better."

This is a planning document for a future volunteer program, not an operational guide. It assumes the metadata-enhancement roadmap is underway and focuses on the human-coordination infrastructure that enrichment at scale requires.

↑ back to contents10.2-archive-dashboard-project(71)/archive-dashboard-project/docs/volunteer-contribution.md

40.1VOLUNTEER_PLATFORM_DESIGNDesign for the volunteer microtask & recognition platform.

Volunteer Contribution Platform — Design Notes

Status: design/scoping, not yet built. This document works through the infrastructure the volunteer program (volunteer-contribution.md) needs: how volunteers see and edit data, how edits round-trip safely into the canonical databases, whether SQLite still suffices, and how tasks, review, reputation, and gamification fit together. It builds on the three-database ecosystem (metadata-db-integration.md) and reuses its "claimed → verified" discipline.

The core realization: one new multi-writer store, not four converted ones

The instinct to consider Postgres comes from a real worry — many volunteers writing at once will make SQLite's single-writer lock a bottleneck. But that worry applies to the wrong databases. The three canonical stores (collector.db, archive.db, civic.db) are single-writer by design, each written by one pipeline on a cadence, and that is a feature, not a limitation: it is what makes them safe to rebuild, promote, and federate read-only.

Volunteers must never write the canonical databases directly. The moment they did, we'd lose the single-writer guarantee, the atomic build-then-promote, and the clean federation. So the multi-writer requirement doesn't belong to the canonical DBs at all — it belongs to a new, separate store that holds volunteer activity:

   VOLUNTEERS (many, concurrent)
        │  submit edits, claim tasks
        ▼
  +-------------------------------------------------+
  |  contrib store   (the ONLY multi-writer store)  |
  |  tasks · submissions · reviews · reputation     |
  +-------------------------------------------------+
        │ reads canonical DBs read-only (to build tasks + show context)
        │ writes back ONLY accepted edits, via a reviewed merge job
        ▼
  +------------+   +------------+   +------------+
  | collector  |   |  archive   |   |   civic    |   ← still single-writer,
  |    .db     |   |    .db     |   |    .db     |     still federated read-only
  +------------+   +------------+   +------------+

This keeps everything the project already relies on and localizes the concurrency problem to one component we can choose the right technology for.

Does SQLite still work? Mostly yes — with one caveat
  • Canonical DBs: keep SQLite. Nothing changes. Single writer, WAL for concurrent readers, Datasette for serving. The volume and access pattern that made SQLite the right call still hold.
  • Contrib store: SQLite in WAL is fine to start; Postgres if/when write volume demands it. SQLite in WAL mode allows many concurrent readers and one writer at a time; writes are serialized but each volunteer edit is tiny (milliseconds), so a single writer can absorb a surprising rate — realistically hundreds of submissions/minute, far beyond early program scale. The honest threshold: if sustained concurrent writes ever cause lock timeouts, that is the signal to move the contrib store (and only that store) to Postgres. The schema is designed to port cleanly, so this is a swap, not a redesign.

The decision rule, stated plainly: don't migrate to Postgres preemptively. Build the contrib store on SQLite/WAL, measure, and move just that one store to Postgres only if real write contention appears. The canonical three never move.

How edits round-trip (the "claimed → verified → merge" reuse)

The project already has the exact pattern this needs, proven on videos:

  1. A volunteer edit is a claim. It's written to the contrib store as a submission (status submitted), never to the canonical DB. It records the target (which DB, table, row, field), the proposed value, the volunteer, and a timestamp.
  2. A reviewer verifies it. A trusted volunteer (see the ladder in volunteer-contribution.md §3) approves or rejects; status becomes accepted or rejected. High-stakes fields can require two independent approvals.
  3. A merge job applies accepted edits. On a cadence, a job reads all accepted submissions for a canonical DB and applies them — exactly like the existing sync/promote flow: build a new copy, apply the accepted edits with full provenance, integrity-check, atomically promote. The canonical DB thus still has exactly one writer (the merge job), preserving every guarantee.

Provenance travels with every applied edit (who proposed, who reviewed, when, from which submission), so nothing is silently overwritten and any edit can be traced or rolled back — the same soft-delete/history discipline already in the schema.

How volunteers access the data — three options, phased

Option A — CSV round-trip (today's approach). Export a slice ("give me 200 unconfirmed governments in Nebraska"), volunteers edit in a spreadsheet, re-import as submissions. Pros: zero new services, familiar tools. Cons: manual, no live validation, no concurrency awareness, easy to fumble a re-import. Verdict: fine for a handful of expert volunteers right now; it does not scale to an open program, but it's a valid Phase 0 that needs only small export/import helpers.

Option B — Mutable Datasette over the contrib store. Datasette already serves these databases read-only; a writable Datasette instance (canned write queries / the write API) pointed at the contrib store gives volunteers a web UI with no local database access, per-row editing, and built-in query views. Pros: reuses tech already in the stack, quick to stand up, good for the civic catalog's tabular editing. Cons: Datasette is read-optimized; it's a thin editing surface, not a task-queue/review system, so it covers access but not workflow. Verdict: a strong Phase 1 for the civic-catalog track specifically — tabular, low-volume, benefits most from a simple grid UI.

Option C — A purpose-built contribution web app + API. A lightweight web application whose API owns all writes to the contrib store, presents micro-tasks, captures submissions, runs the review queue, and tracks reputation. Pros: the real answer — it's where task distribution, review, reputation, and gamification live; it can present the right micro-task UI per track (a transcript editor, a "confirm this government" card, a "is this the right channel?" yes/no). Cons: the most build effort. Verdict: the Phase 2 destination; Options A and B are stepping stones that don't block it.

Phasing: A now (expert volunteers, CSV), B for the civic catalog (writable Datasette, quick win), C as the program opens up (the full platform).

The task-distribution, review, reputation & gamification system

All of this lives in the contrib store and (in Phase 2) the contribution app.

Task queue

  • Task generation. A job reads the canonical DBs read-only and materializes tasks — e.g. "confirm government X" (civic rows with unconfirmed), "find the agendas page for Y" (governments with no portal URL), "correct segment Z" (items flagged needs_review), "is channel C official for station S?" A task names its track, its target row, and what "done" means.
  • Leasing, not assigning. A volunteer requests a task; the queue hands out the next appropriate one and leases it (a short hold with a timeout) so two people don't do the same unit. If the lease expires unfinished, the task returns to the pool. This prevents duplicate work without locking anyone out.
  • Right-sized & routed. Tasks are filtered by track and by the volunteer's tier (newcomers get well-defined, low-stakes tasks; trusted volunteers can lease review tasks). Locality-aware routing — offering a volunteer governments/channels in their own region — makes the local-knowledge work land where it's strongest.

Review workflow (states)

  submitted ──▶ in_review ──▶ accepted ──▶ (merged into canonical DB)
       │
       └──────────▶ rejected (with a reason; volunteer can learn/redo)
  • Every submission enters submitted. A tier-2+ reviewer moves it to accepted or rejected (with a short reason). Only accepted submissions are eligible for the merge job. High-stakes fields (e.g. a speaker identification, a government's canonical name) can require two independent accepted votes; consensus resolves disagreement.
  • Sampling. Stewards spot-check a fraction of already-accepted work to keep reviewers calibrated — quality control on the quality-controllers.

Reputation (climbing the ladder)

  • Each volunteer has a reputation score driven by accepted contributions (not raw submissions — quality, not volume). Rejections don't punish beyond not earning; honest mistakes carry no penalty.
  • Reputation gates the ladder from volunteer-contribution.md §3: enough accepted work in a track promotes a Contributor to Reviewer (can approve others' work), and sustained stewardship to Domain steward. This is how the program scales its own supervision — trusted volunteers review newcomers, so not every edit hits a paid coordinator.
  • Trust reduces friction. Higher reputation earns lighter review (e.g. sampled rather than full review), concentrating scarce reviewer attention on newcomers and high-stakes fields.

Gamification (motivation, tied to quality)

Recognition is the volunteer program's currency; a few mechanics, all keyed to accepted work so they reward real contribution, not gaming:

  • Leaderboards — per track and per region ("top contributors in Kansas this month"), plus all-time. Regional boards tap local pride and keep them human-scaled rather than one intimidating global list.
  • Streaks & milestones — consecutive active weeks; badges at 10/100/1000 accepted contributions; a "first accepted edit" welcome badge.
  • Track-specific badges — "Portal Historian" (traced N vendor migrations in Track 1), "Channel Scout" (onboarded N channels in Track 2), "Transcript Editor" (N corrected segments in Track 3), "Local Historian" (N speaker/place identifications). Badges map to the tracks so people see progress in the work they chose.
  • Visible impact — "your confirmed governments are now searchable by 1,200 researchers," "the meeting you transcribed has been viewed 340 times." Impact beats points for sustained motivation on civic work.

A caution: gamify accepted, reviewed work only. Rewarding raw submission volume invites low-quality spam; rewarding accepted work aligns the incentive with the mission.

A sketch of the contrib store schema (illustrative)

Not final — just to show the shape. One store, multi-writer, portable to Postgres.

  • task — id, track, target_db, target_table, target_key, kind, prompt, state (open/leased/done), lease_owner, lease_expires.
  • submission — id, task_id, volunteer_id, target (db/table/key/field), proposed value, status (submitted/in_review/accepted/rejected), created_at.
  • review — id, submission_id, reviewer_id, decision, reason, created_at.
  • volunteer — id, display_name, region, tier, reputation, joined_at.
  • badge / volunteer_badge — the gamification layer.
  • merge_log — which accepted submissions were applied to which canonical DB when (provenance + rollback).

The canonical DBs are attached read-only for task generation and context; they are written only by their own merge job.

Open questions
  1. Contrib store engine: start on SQLite/WAL (recommended) and port to Postgres only if measured write contention demands — or start on Postgres now to avoid a later migration? (Trade-off: operational simplicity now vs. a possible swap later. The schema is written to port cleanly either way.)
  2. Identity & auth: how do volunteers sign in (email magic-link, OAuth via a civic/education identity, etc.), and what's the minimum PII we store?
  3. Datasette-write vs. custom app for Phase 1 civic editing: is writable Datasette enough for the civic catalog to start, or do we jump to the custom app sooner?
  4. Merge cadence: how often does the merge job fold accepted edits into each canonical DB — nightly, weekly (aligned to the existing sync), on a threshold?
  5. Conflict handling: two accepted edits touching the same field — last-write, steward-resolves, or task-level locking to prevent it upstream?
  6. The "use of contributions" policy (volunteer-contribution.md FAQ) — the licensing/AI-training answer should be settled before the platform opens.

↑ back to contents10.1-ia_metadata_xml_to_sql(26)/ia_metadata_xml_to_sqlite/docs/VOLUNTEER_PLATFORM_DESIGN.md

40.2archive.org-30-day-search-challengeVolunteer engagement challenge for Archive Corps.

archive.org 30-day search challenge

A volunteer engagement challenge for the Archive Corps effort — a 30-day community-media search activity with its schedule and participant tracking.

Contents
File What it is
community_media_challenge_calendar.pdf The 30-day challenge calendar
community_media_challenge.xlsx Working spreadsheet for the challenge
participant_tracker.csv Participant list and progress

↑ back to contents40.2-archive.org-30-day-search-challenge/README.md

40.3archive-corps-training-curriculumThe depth track — 4 sessions / 6 weeks turning 6–8 volunteers into stewards who can describe and upload; incl. the House Style Guide whose unique-title rule is the upstream fix for 10.7's TITLE_COLLISION.
Open PDF · House Style Guide — the metadata conventions Open PDF · Curriculum — 4 sessions / 6 weeks (facilitator) Open PDF · Participant Workbook Open PDF · Practice Set — facilitator build sheet

Archive Corps — training curriculum

The in-depth training track for an Archive Corps cohort: four live sessions over six weeks, 6–8 mostly non-technical participants, ending with each person owning a named slice of the archive and having made real, supervised uploads to a live collection.

Where 40.2 is light engagement (a 30-day search activity anyone can join) and 40.4 is recruitment (the room where you find the cohort), this is the depth track — the one that produces stewards rather than participants.

The design goal, in the curriculum's own words:

A script can download 26,000 videos a month, but only a person can decide whether a video is worth keeping, whether it's described well enough to be found in ten years, and whether it's in the right place. This curriculum trains that human-judgment layer — the part that can't be automated.

The four sessions
Wk Session What they can do after Emphasis
1 Why we save, and what we're part of Explain the mission and the whole pipeline; navigate the archive; spot good vs. bad metadata in the wild Digital archiving
3 Describing things so they're findable Write strong titles, descriptions, dates, subjects; apply a house style; make a first sandbox upload Metadata principles
5 Inside the Internet Archive Understand items / collections / identifiers and which fields they can edit; fix broken metadata IA specifics
6 The real thing, supervised Steward a real slice end-to-end; make supervised live uploads; know how to ask for help All three, applied

Four design choices shape the whole thing, and they're the reusable part even if the session content changes:

  • Anchored to the real workflow, not archiving theory. Principles are taught just-in-time, the moment they matter for a real task.
  • The web path, not the command line. The browser uploader and metadata editor throughout; the batch scripts are explained so people know where their work fits, but nobody has to touch a terminal.
  • Practice in a sandbox, graduate to live. Sessions 1–3 are a test collection where mistakes are harmless. Only session 4 touches the live archive.
  • The gaps between sessions do the teaching. Each session ends with a small hands-on task; the next opens by working through what people hit.
Contents

Four documents, split by who holds them.

File Role Who holds it
docs/CMA_ArchiveCorps_Curriculum.* The design — purpose, run-of-show, concepts introduced, and between-session task for each of the four sessions, plus a "what you'll need to prepare" list Facilitator
docs/CMA_ArchiveCorps_Workbook.* The companion — per-session notes pages, the between-session tasks, and an Upload & Describe working checklist for the back pocket Participant
docs/CMA_House_Style_Guide.* The metadata conventions: five required fields, unique titles, the 180-character rule, acronyms, the backlog-Year exception, language/ASCII, runtime, license Both — permanently
docs/CMA_PracticeSet_Facilitator.* Blank build sheet for the seven sandbox samples, each chosen to force one judgment call, with a slot for the link and the answer key Facilitator
tools/derive_md.py Regenerates the derived .md text versions from the .docx masters (see Provenance) —

Each document is present as .md + .docx + .pdf, with the caveats in Provenance below.

How the four fit together

The Curriculum names a house-style guide as "the single most reusable artifact; it outlasts the cohort", and the Workbook tells participants to keep it open beside them whenever they describe or upload. The Practice Set is the sample material the Curriculum's prep list calls for — and it is not generic: each of its seven samples is built to force one specific House Style Guide rule, and is mapped to the session where that rule is taught.

Practice sample Forces Rule it exercises Session
1 Clean baseline The upload flow itself, no traps Five required fields 2 (guided)
2 Title collision Two items that'd take the same generic title Unique titles 2 (solo task)
3 Backlog / wrong-date Old content uploaded late — upload date gives the wrong Year The backlog exception 3
4 Acronym minefield Local acronyms incl. one ambiguous reuse (CAC = two things) Expand, never reuse 3
5 Boilerplate-buried Long generic series blurb, little specific info The 180-character rule 2 (solo task)
6 Non-English / accented Non-English audio or accented metadata ISO 639 + ASCII alternate 3
7 Judgment edge case Ambiguous provenance, possibly out of scope, or a maker who may not want web distribution Know when to stop and ask 4

The Practice Set ships blank on purpose: the sample links and answer key are filled in as the set is assembled, and the completed answer key is what lets cohort 1 teach cohort 2.

Why the House Style Guide matters beyond the classroom

Its first rule — titles must be unique — is not a tidiness preference. It is the human-side statement of the failure class measured in 10.7: the YouTube→Archive pipeline derives the IA identifier from the title, so a repeated title means the identifier is already taken and the upload becomes a silent, permanent no-op. 10.7 found that class accounted for 380 of 598 unseen videos in a 75-collection sample — a sample, not the full corpus, and concentrated rather than uniform (some collections showed zero).

Worth stating plainly, because it changes how the guide should be read: this document is the upstream fix. 10.7 and 10.9 are the downstream repair of items where the rule was already broken, and re-running a harvest does not recover them.

Two other rules carry the same weight and are flagged in the guide as the pair that "prevent real damage":

  • Only the first 180 characters of the description are indexed. Boilerplate first means hundreds of items are mutually indistinguishable to search.
  • The backlog Year exception. Subcollections are faceted by Year, and Year is derived from the upload date — so a backlog dump of old video lands every item in the wrong facet unless the year is pulled from the title or description.
Provenance

Authored 2026-06-15 in a Claude web conversation (the Practice Set follows on 2026-06-22), as .docx — no .md original was ever written. They were never filed into the pile and were sitting in ~/Downloads until 2026-09-03.

The usual document direction is reversed here. The pile convention is .md → pandoc → .docx → .pdf, with the .md as master. For these four the .docx is the layout master: its boxed callout cards, the two-column handout blocks and the practice-set grid are Word constructs that a round-trip from markdown would not reproduce.

So the .md files here are derived, not masters — text renderings for reading, grepping and diffing. Each carries a header comment saying so. Edit the .docx, then re-derive:

python3 tools/derive_md.py docs/CMA_House_Style_Guide.docx

derive_md.py collapses the docx's single-cell callout tables to blockquotes (including callouts nested inside callouts) and leaves genuine multi-column tables alone.

Format status of the originals

Document .md .docx .pdf
Curriculum derived here original generated here (none existed)
Workbook derived here original original, 2026-06-22
House Style Guide derived here original original, 2026-06-22
Practice Set derived from PDF none exists original, 2026-06-22

Two notes on what that table means:

  • The Curriculum PDF was produced on 2026-09-03 by soffice --headless --convert-to pdf from the docx. It is a faithful conversion, but it was not the file reviewed in June.
  • The Practice Set has no .docx — only the PDF was ever exported. Its .md is a hand-transcription of the PDF's table, so the PDF remains authoritative and the table cannot be re-derived mechanically.

CMA_ArchiveCorps_Workbook(1).docx in ~/Downloads is byte-identical to CMA_ArchiveCorps_Workbook.docx (same MD5) — not a variant, and not carried separately.

Status

Never run. No cohort has been through it, and none was ever convened: the Birds of a Feather session in 40.4 that was to recruit the cohort did not take place. The Practice Set is consequently still blank — the seven samples were never assembled and no answer key was written.

The design itself is complete. It is in the pile as a record of the work, not as an active plan. Read it for reference; don't treat it as a live plan of record, and don't assume the approach it describes is the current one.

The House Style Guide is the exception worth separating out: unlike the session plans, its content is not contingent on a cohort ever running. It documents how the archive's metadata should actually look, and its unique-title rule describes a live failure mode measured in 10.7.

↑ back to contents40.3-archive-corps-training-curriculum/README.md

40.4archive-corps-bof-recruitmentThe ACM Birds of a Feather kit that recruits the cohort 40.3 trains — handout, speaker notes, day-of run sheet, and the sign-up form build reference.
Open PDF · Handout — five roles + sign-up (double-sided) Open PDF · Run-of-show & speaker notes Open PDF · Day-of run sheet Open PDF · Google Form build reference

Archive Corps — BoF recruitment kit

The room-ready kit for the ACM Birds of a Feather session — the 45–60 minute conversation, for 10–30 people, that asks a professional community to take shared ownership of the Community Media Archive, and walks out with names.

This is the front half of the same effort as 40.3: 40.4 recruits the cohort, 40.3 trains it. The BoF's stated success condition is "a read on Archive Corps appetite — ideally ~6–8 genuine yes", which is exactly the cohort size the curriculum is built for.

The framing the speaker notes insist on:

This was never meant to live or die with one person. I'm here to hand it to a community, not to close it down.

Contents

Four documents: one for the room, one for the facilitator's hand, one for preparation, one for building the form.

File What it is Used when
docs/CMA_BoF_Handout.* Double-sided leave-behind — the five roles on the front, the Skills & Interest sign-up form on the back, with a QR block to paste before printing In the room
docs/CMA_BoF_SpeakerNotes.* Run-of-show with what to say, what to avoid, and why, minute by minute Prep + in hand
docs/CMA_BoF_RunSheet.* The one-page day-of sheet: print-and-bring checklist, links to have ready, the 50-minute timing grid, and what "won" looks like Day of
docs/CMA_GoogleForm_BuildReference.* Exact click-through order to build the Skills & Interest Google Form, with paste-ready blocks for every question and option list Before the session
docs/CMA_BoF_SignupForm_QR.svg / .png The sign-up form QR, generated and decode-verified 2026-09-03 — paste into the handout's placeholder box Before printing
tools/derive_md.py Regenerates the derived .md text versions from the .docx masters (see Provenance) —
The five roles

The handout's core move is shrinking an impossible-sounding number into pieces one person can hold: 2,100 channels, ~26,000 videos a month → five named jobs, only one of which needs a developer.

Role What it involves Good fit for
Channel Steward Own a slice of the 2,100 source channels by region, station, or topic. Watch for channels that move, rename, or go dark. No coding. Most people — low barrier
Pipeline Operator Run the batch download / processing scripts on a schedule. Command line, following a runbook. Technical, trainable
Script Maintainer Fix and adapt the scripts when YouTube or Vimeo changes. Developers — rarest, highest-risk
Collection Curator Govern metadata standards, what belongs in which collection, quality control. Detail / metadata minded
Coordinator Keep the distributed group connected and moving. Organizers

Note the split this implies and 40.3 makes explicit: the curriculum trains Channel Steward and Collection Curator only. Operator and Maintainer are called out in the curriculum as "a separate, smaller, more technical track for the rare developer-minded volunteer, and a good candidate for a future cohort once this model is proven." The BoF is where you find those two or three people; nothing in this pile yet trains them.

The 50-minute shape
Time Segment
0–5 Why I'm stepping back — short, honest, forward-looking
5–13 What the work is — say the scale, then shrink it into pieces
13–20 The five roles + float the Archive Corps idea (appetite test)
20–30 Fill out the form — quiet working time. Protect this.
30–42 Open discussion — surface concerns, find technical people
42–50 Next steps — collect forms, confirm the list, name a follow-up

The run sheet is blunt about the failure mode it is designed against: "Pens — a fistful. The #1 failure mode is 'I'll sign up later.'" Hence a protected ten-minute block to fill the form in the room, on paper, with a QR fallback for people who'd rather use a phone — and forms collected at the door.

What "won" looks like

  • A stack of completed forms with emails.
  • 2–3 names against the hard technical roles (Operator / Maintainer).
  • A read on Archive Corps appetite — ideally ~6–8 genuine "yes".
  • One concrete follow-up set, with a date.
Provenance

Authored 2026-06-10 to 2026-06-18 in a Claude web conversation, as .docx — no .md originals were written. PDFs were exported 2026-06-22. They were never filed into the pile and were sitting in ~/Downloads until 2026-09-03.

The usual document direction is reversed here, as in 40.3: the pile convention is .md → pandoc → .docx → .pdf with the .md as master, but for these the .docx is the layout master — the double-sided handout, the two-column QR block and the paste-block formatting are Word constructs a markdown round-trip would not reproduce.

The .md files here are therefore derived, not masters — text renderings for reading, grepping and diffing, each carrying a header comment saying so. Edit the .docx, then re-derive:

python3 tools/derive_md.py docs/CMA_BoF_Handout.docx

Which version is carried

~/Downloads held three generations of the handout and two of the form reference. What is here:

Document Carried Note
Handout CMA_BoF_Handout(2).docx, 2026-06-18 Newest. Adds the QR / "Scan to sign up" block absent from the 2026-06-10 original. Text-identical to (1). Modified here 2026-09-03 — real QR swapped in for the placeholder; the PDF was regenerated to match, so it no longer matches the June export.
Speaker Notes CMA_BoF_SpeakerNotes.docx, 2026-06-10 Only version
Run Sheet CMA_BoF_RunSheet.docx, 2026-06-15 Only version
Form Reference CMA_GoogleForm_BuildReference.docx, 2026-06-15 (1) is text-identical; not carried separately

The Google Form Build Reference PDF was generated on 2026-09-03 by soffice --headless --convert-to pdf — no PDF was exported in June. It is a faithful conversion but was not the file reviewed at the time.

The handout's .md retains one HTML table: the two-column QR/sign-up layout block, which is a genuine multi-column construct rather than a callout box.

The live form

The Skills & Interest form has been built from CMA_GoogleForm_BuildReference and is live. Supplied by the operator 2026-09-03; recorded verbatim, not fetched or validated by any tool here.

This is the URL the handout's QR code encodes, and the one to send to the email list so people who can't attend can still register interest:

https://docs.google.com/forms/d/e/1FAIpQLScWJtAJ2YJTvqq4KEgGMj5ziJ7eD5kfYQqHItkKlsF_21lnpw/viewform

The QR code

Generated from that URL on 2026-09-03 and verified by decoding the image back — it round-trips to the URL above character for character.

It is already in the handout. CMA_BoF_Handout.docx was updated on 2026-09-03: the dashed "[ QR ] paste before printing" placeholder box is gone, replaced by the code at 1.2 in (3.05 cm) wide with the "Scan to sign up" caption beneath, and CMA_BoF_Handout.pdf was regenerated from it. Nothing needs to be pasted before printing any more — the handout is print-ready.

The standalone image files are kept for reuse (slides, posters, the email list):

File Use
docs/CMA_BoF_SignupForm_QR.svg Vector — for anything printed larger
docs/CMA_BoF_SignupForm_QR.png 980×980 raster; this is the one embedded in the handout

QR version 6, 49×49 modules, error correction M — ample for a close-range phone scan off a printed page. In the handout the code itself measures ~2.6 cm square with its quiet zone outside that; keep the white margin if you reuse it elsewhere, and don't print it below about 2.5 cm.

How it was verified

Twice, because a wrong QR on a printed handout fails silently in a room:

  1. The generated image was decoded back and matched the URL character for character.
  2. The regenerated PDF was rasterised at 300 dpi and the QR decoded out of the rendered page — so what verified is the artifact that actually gets printed, not just the source image. It decodes to the URL above.

If the form URL ever changes, regenerate rather than editing the image:

python3 -c "import segno; segno.make_qr('<url>', error='m').save('docs/CMA_BoF_SignupForm_QR.svg', scale=10, border=4)"
Published on the web

These documents are hosted on the the access center CMA wiki, under the 2026 ACM National Conference heading. Supplied by the operator 2026-09-03; recorded verbatim, not fetched or validated by any tool here.

This is the link to send the email list, so people who couldn't attend can read the handout and reach the form:

https://cma.accesshumboldt.net/wiki/index.php?title=Community_media_archive#2026_ACM_National_Conference

That closes the run sheet's "handout + form hosted somewhere the email list can reach" line. Note that the wiki holds its own copies: if a document here is revised, the wiki needs re-uploading too — this bundle is the source, not the published surface.

Status

The BoF never happened. The kit was finished and everything the run sheet asks for ahead of the day was in place:

  • The Skills & Interest form was live, with both routing paths tested in Preview (confirmed by the operator).
  • The QR is generated, verified, and embedded in the handout; the PDF is print-ready.
  • The documents are published on the wiki where the email list can reach them.

The session simply did not take place. Nothing here is blocked or unfinished — it is a complete, ready kit that was never used.

It is in the pile as a record of the work, not as an active plan. Read it for reference; don't treat it as a live checklist, and don't assume the approach it describes is the current one.

40.3 is the other half — the curriculum the cohort this kit would have recruited was to walk into. It was never run either.

↑ back to contents40.4-archive-corps-bof-recruitment/README.md

40.5acm-50th-booklet-extractionThe ACM 50th Anniversary Booklet → 105 structured per-contributor records. The booklet's typography is a semantic stylesheet, so extraction is deterministic; `pdftotext -layout` was rejected on evidence.
Open PDF · Extraction analysis & plan — why this method, and what it caught

40.5 — ACM 50th Anniversary Booklet extraction

Structured extraction of the Alliance for Community Media 50th Anniversary Booklet (1976–2026) into per-contributor records: the speaker or author, their role or the piece's title, and the complete text of that person's section.

105 records from 47 text-bearing pages of a 60-page PDF. 45 origin stories, 23 conversation turns (12 people), 11 In Remembrance entries, 6 feature articles, and 19 records flagged for human review.

Read first

docs/ACM50_EXTRACTION_ANALYSIS_AND_PLAN.md (.docx/.pdf alongside) — what the source is, why pdftotext -layout was rejected on evidence, why PyMuPDF was chosen over four alternatives, and §6.3: the eight defects found while building, every one of which produced fluent, non-erroring, wrong output.

Run
/home/access/.venvs/acm50/bin/python tools/diagnose_layout.py       # run FIRST on a new edition
/home/access/.venvs/acm50/bin/python tools/extract_acm50.py         # dry-run
/home/access/.venvs/acm50/bin/python tools/extract_acm50.py --yes   # apply
/home/access/.venvs/acm50/bin/python tools/make_review_sheet.py     # worksheet

Requires pymupdf (venv at ~/.venvs/acm50). Source PDF is read from ~/Downloads/; it is not copied into the bundle (159 MB).

Layout
Path What
docs/ analysis and plan (.md/.docx/.pdf)
tools/diagnose_layout.py re-derives the layout facts every rule depends on — run first on any new edition
tools/extract_acm50.py the pipeline — streaming, checkpointed, resumable
tools/make_review_sheet.py worksheet generator for flagged records
out/acm50_sections.jsonl the records
out/run_manifest.json source SHA-256, page inventory, rule version
out/checkpoint.json completed pages; drives resume
out/REVIEW_flagged.txt 19 rows awaiting a human decision
out/layout_diagnostics.txt stylesheet, text-layer inventory, gutters, rotated text
Status

Extraction complete and verified. The 19 flagged records have not been reviewed — the worksheet is generated, not worked. Nothing downstream (the proper-noun gazetteer, any NER pass) has been run; see §9 of the analysis for the recommended sequence and why NER should not be used for the author field.

↑ back to contents40.5-acm-50th-booklet-extraction/README.md

40.6community-media-voices-reviewReview of communitymediavoices.org plus a measured cross-match to the CMA: 133 of 479 hubs exact-match a CMA collection, 246 need review. Prepared for a call with the site's creator.

40.6 — communitymediavoices.org review + collaboration brief

Review of the Community Media Voices site (site design, structure, content model, governance) and a measured cross-match against the Community Media Archive, prepared 2026-09-09 for a call with the site's creator.

Read first

SESSION_HANDOFF_2026-09-09_community-media-voices.md — state, blockers, next actions. docs/CMV_REVIEW_AND_COLLABORATION_BRIEF.md — the full brief. docs/cmv-review.html — the same content as a published page: https://claude.ai/code/artifact/f9894252-803f-4620-a6e4-51c2b514a063

Headline numbers (every one states its denominator)
Population Finding
479 CMV hubs 133 exact name match to a CMA collection; 246 need review; 100 no match
133 matched hubs 476,250 archived items; 105 have CMA content <90d old
2,168 CMA collections (3,189,566 items) 2,029 not exact-matching a CMV hub (2,706,075 items) — upper bound on the growth list
62 ACM50 booklet people 36 already in CMV, 26 not (≈39/23 after name variants)
479 CMV hubs 145 in MA (30%) — source-driven, not a national census
193 CMV people 0 have structured Source fields; 160 have a source URL buried in Notes
Method / provenance
  • Site: ~14 public HTTP requests, spaced, identified user-agent, no forms submitted. Hub and people records were read from the RSC payload the site itself serves.
  • Archive: archive-store.REDACTED.ts.net:/mnt/md0/datasette/archive.db, opened ?immutable=1 (WAL — ?mode=ro fails). Snapshot dated 2026-09-02. Counts are per IA collection (collection join metadata), NOT per dirname via rollup_collection — see the caveat below.
  • ACM50 records: 40.5-acm-50th-booklet-extraction/out/acm50_sections.jsonl. Its 19 flagged records are still unreviewed — nothing from the booklet should be published before that review and before ACM permission.
Caveats carried forward
  • Name-only matching is a review trigger, never an auto-accept. The 246-row review file contains real matches (PEGTV Rutland) and false ones (Alabama Public Television → University of Alabama) side by side.
  • The 2,029-row growth list is an upper bound; naming variants inflate it.
  • The first cut of this cross-match joined on the wrong grain. rollup_collection is keyed on dirname (the on-disk directory); collection_meta.identifier is the IA collection. They are different grains — access_humboldt has 12,458 items via the collection table and 0 via dirname. That bug dropped 144 collections and ~390k items, understating every total. Corrected 2026-09-10; all figures in docs/ and data/ are the corrected ones. Only 4 matched hubs now lack a usable date, and those have items but no parseable date field.
Layout
Path What
docs/CMV_REVIEW_AND_COLLABORATION_BRIEF.md the brief
docs/cmv-review.html published version
data/cmv_x_cma_exact.psv 133 usable hub ↔︎ collection matches
data/cmv_x_cma_review.psv 246 candidates — worksheet, contains false matches
data/cmv_x_cma_nomatch.psv 100 CMV hubs with no CMA name match
data/cma_hubs_missing_from_cmv.psv 2,029 CMA collections absent from CMV
data/acm50_x_cmv_people.psv 62 booklet people, in/out of CMV
data/cmv_hubs.json, data/cmv_people.json CMV records as publicly served
data/cma_collections.psv 2,168 CMA collections + counts + date spans (header row included)
evidence/*.html.gz the raw captures every measurement rests on, + SHA256SUMS
evidence/MANIFEST.md what each capture is, which section cites it, and the units
SESSION_HANDOFF_2026-09-09_community-media-voices.md handoff
Not done, deliberately

The pile index.html / INDEX.md were not regenerated and no zip was rebuilt — the operator may move this bundle out of the pile. Nothing outside this directory points into it. The bundle is self-contained apart from one reference to 40.5-acm-50th-booklet-extraction in §6 of the brief.

↑ back to contents40.6-community-media-voices-review/README.md

40.7cma-public-storiesOutward-facing writing: three story versions and “258 Years of Tuesday Night”, a guest post drafted for blog.archive.org — every named station and video links to its archive.org page. Not published yet.
Open PDF · “258 Years of Tuesday Night” — guest post draft Open PDF · CMA stories — three versions

40.7 — CMA public stories

Outward-facing writing about the Community Media Archive: three story versions for general readers, and a guest post drafted for the Internet Archive blog (https://blog.archive.org/). Every station, series and video named in the text is a live hyperlink to its Internet Archive details page, so a reader can leave the page and go straight into the archive.

This is the publication companion to 00.0-stepping-back-from-CMA-operations (project-stories.md, which is about the agendas/civic-scans side and is written for us, not for readers).

What's here
document what it is
cma-stories three versions of the same story — the intimate one, the browse-it one, the stakes one — plus a station index and a series index
ia-guest-post "258 Years of Tuesday Night", a first-person guest post for blog.archive.org — 1,153 words, 21 paragraphs under a single title, 34 links
LINK_CHECK.psv every linked identifier, what it resolves to, and how it was verified
story_items.psv the 67-item candidate shortlist the stories were written from (50 made it in) — raw material for the next piece
ia-guest-post.superseded-2026-09-09.md the pre-edit draft, kept because it carried a standfirst, a closing methods note and two section headings that the current version does not
build_docs.sh regenerates .html / .docx / .pdf / .paste.md from the .md sources
make_paste.py unwraps a .md into the paste-ready .paste.md (one line per paragraph)
assets/head-include.html shared page style (pile house palette, light/dark, print rules)
assets/head-include-ia-guest-post.html page style for the guest post only — blog.archive.org's own palette and typeface

Each document ships as .md (source) + .html (web page) + .docx + .pdf. The HTML is generated from the same .md as the docx, so the four formats cannot drift; edit the .md and re-run ./build_docs.sh.

build_docs.sh styles a page with assets/head-include-<doc>.html when that file exists and the shared house style otherwise. ia-guest-post has one: because the post is written for blog.archive.org, its web page is set in that blog's own type and color — Georgia running text at #333, "Helvetica Neue" for the title and byline, links #06c / visited #743399, a white article card on #e6e6e6 at the blog's 625px measure. Every value was read out of the stylesheets blog.archive.org serves (WordPress Twenty Twelve plus the twentytwelve-child sheet that Brenton Cheng wrote to match the old blog.archive.org theme), fetched 2026-09-10 and cited in a comment at the top of the include. The blog renders light-only, so that page does too — it is the one document in the pile with no dark mode, on purpose.

The prose is American English: the seven British forms the draft carried — centre, catalogue, neighbourhood, programme, digitising, organisations, recognise — were changed in the .md, so all four formats carry the change. Verified present in .md/.html/.docx/.pdf and absent everywhere else; the 35 archive.org links survive the rebuild unchanged.

Pasting into WordPress

blog.archive.org runs WordPress 6.8 with the block editor (verified from the page: wp-block-image markup, qodef-gutenberg--no-touch, qi-blocks). WordPress has no markdown support in core; what turns loose text into paragraphs is wpautop() in wp-includes/formatting.php, and its rule is that a blank line becomes <p> but a single newline becomes <br />.

Our sources are hard-wrapped at ~79 columns, so pasting one into a Classic/Text field would scatter <br> through every paragraph — 119 of them in the guest post. So every document also builds a .paste.md: the same markdown with each paragraph on one unwrapped line, blank lines between blocks untouched. Paste that, or the .html, or hand over the .docx. Do not paste the hard-wrapped .md.

make_paste.py preserves structure rather than joining blindly — fenced code, headings, rules, lists, tables and blockquotes keep their line structure. The build then renders both files and diffs the HTML, and stops if they differ, so a .paste.md cannot silently drift from its source. The comparison normalizes &#xA0;: pandoc's smart extension adds a non-breaking space after an abbreviation ("vs. the") once the line break that followed it is gone, which is pandoc improving the typography rather than the unwrap changing text.

ia-guest-post carries no version table — unlike the rest of the pile. It is a page written to be handed to an editor, and a pile-internal changelog is not part of what gets handed over. Change history for it lives here and in the file dates, not in the document.

Hyperlinks survive into all four formats: 88 distinct archive.org links, carried as real <a href> in the HTML, as document relationships in the .docx, and as /URI annotations in the .pdf.

Where the numbers came from

All figures are full-population queries — never a sample — against the archive's own item metadata:

  • archive.db on archive-store.REDACTED.ts.net:/mnt/md0/datasette/ (16.5 GB, the record of 2.7M+ IA items), and the compact corpus.db built from it by 10.12-cma-story-miner.
  • Population: 3,241,074 active items in 2,188 collections, public dates 2005-08-01 to 2026-08-31, as of the 2026-09-02 sync.
  • Both databases are WAL and actively written, so they are opened ?immutable=1 — ?mode=ro fails.

Subject counts (quilting, roller derby, accordion, …) come from 10.12-cma-story-miner/out/BLOG_IDEAS.md and are title-basis only: an item counts when the pattern appears in its title. Across the 119 mined themes the median title-share is 0.48, so roughly half of all matches are description-only and are excluded as too noisy to trust.

Runtime (2,265,627 hours / ~258 years) is a floor — it comes from the 3,029,491 items (93.5%) that report a duration.

Dormancy figures are dated 2026-08-31 and come from 10.7-cma-collection-freshness. Quiet is not the same as behind: a seasonal town and a closed station look identical from outside.

Two independent instruments, and they agree: 88 of 88 links resolve.

  1. archive.db — every linked identifier checked against IA's own metadata as synced on 2026-09-02. 50 resolve to active items in metadata, 36 to rows in collection_meta. Two (community_media, rompeviento_tv) are real IA collections with no collection_meta row, because neither was harvested as a CMA station collection.
  2. Live HTTP against https://archive.org/metadata/<id> — all 88 returned real metadata; none returned {}.

Getting there took two instrument corrections worth recording:

  • Fetching https://archive.org/details/<id> serially returned a wall of 000s. Those are curl timing out on a heavy page render, not missing items — one details page that did complete returned 200, and every one of those identifiers later returned full metadata from the API. Recorded as FETCHFAIL, never as missing.
  • Counting <a href= in the generated HTML said only 4 of 35 links had rendered. They all had; pandoc wraps <a onto its own line, so the pattern missed them. Counting href="https://archive.org/details/ instead gives 104 and 35 — matching the sources exactly.

Per-identifier results are in LINK_CHECK.psv.

The station-URL problem

Linking a station is not "lowercase the dirname". That guess is right 92.5% of the time and silently wrong for 167 of 2,188 dirnames, and the misses cluster in exactly the collections a writer reaches for first — FCA is foxborocableaccess, SCATV is somervillecommunityaccesstelevision, AACS is doubleacs, AH is access_humboldt, WCCA is wcca_tv. Some are case-sensitive (AmherstMedia, SeattleCommunityMedia).

10.5-fleet-dirname-collection-map/channel_map.psv resolves only 4 of the 18 this document needed, because it is fleet-derived and these are legacy or direct-to-IA collections with no scraping directory. The full archive.db-side map was generated for this work and lives with its siblings: 10.5-fleet-dirname-collection-map/dirname_primary_collection_2026-09-09.psv (2,188 dirnames, documented in that tier's PROVENANCE.md).

Rebuilding
./build_docs.sh                 # both documents, all formats
./build_docs.sh ia-guest-post   # just one

Needs pandoc and libreoffice. The docx is generated against ../00.0-stepping-back-from-CMA-operations/project-stories.docx as the reference doc, then w:val="FirstParagraph" is normalized to "BodyText", per the pile convention. HTML is emitted --ascii with an explicit charset so it survives the wiki host (see the program-map-mindmaps note).

Status / open
  • Not published anywhere yet. The guest post is a draft; pitching it to blog.archive.org is a decision for the operator, not a step in this build.
  • Length. At 1,153 words the guest post is 2.0x the mean and 3.0x the median of the 20 most recent blog.archive.org posts (mean 573, median 388, range 114-1,709, pulled from the WordPress REST API on 2026-09-10). It is the third longest of 21, and 1.34x the post it is styled after, A Computer History Time Capsule (858). Those 20 are half short announcements, so the mean flatters it; against the feature-length posts it is merely long, not an outlier.
  • No subheadings. The current draft runs 21 paragraphs unbroken. The reference post does too, but at 858 words and with three floated images to break the column. Worth raising with an editor.
  • The station index lists 37 of the 2,188 collections — the ones the stories name. It is not a directory of the archive.
  • Station locations are read from each collection's own title and state tag; a few are regional rather than a single town.

↑ back to contents40.7-cma-public-stories/README.md

40.8ia-collection-ownership-anomaly105 archive.org collections list a placeholder account as owner while the CMA is their only contributor (117,876 items). Draft email to IA, unsent; a retraction kept on purpose.

40.8 — IA collection ownership anomaly

Finding, 2026-09-10. 105 archive.org collections list alan@smithee.com as uploader/owner while the Community Media Archive (jhauser@pobox.com) is the sole contributor of their contents.

fact value
collections owned by alan@smithee.com 105
items in them uploaded by jhauser@pobox.com 117,876 (live archive.org, 2026-09-10)
items ever uploaded by alan@smithee.com, anywhere 0
container creation dates 2011-09-20 → 2023-10-06
CMA upload dates into them 2018-07-26 → 2026-08-28
CMA uploads in the last 90 days 2,183
assigned to a CMA worker 95 active, 7 complete, 3 unassigned
collections with zero CMA items 0 — all 105 are populated

All 105 sit in community_media / newsandpublicaffairs / movies and are named for US municipalities and stations.

Checked and ruled out: that this is the PUBLICUK contributor. All 105 are US community media, not UK place names.

On the name. "Alan Smithee" is the pseudonym the Directors Guild of America designated in 1968 for a director disowning a film they could not control — the industry's standard credit for no attributable author, retired around 2000 and widely known after the 1998 satire An Alan Smithee Film: Burn Hollywood Burn. Taken with the account having uploaded zero items anywhere, the account is almost certainly a deliberate placeholder for unattributed ownership, not a person. That reading also fits the operator's recollection that the collections were set up by archive.org staff directly without contact.

Files
file what
EMAIL_DRAFT_ia_collection_ownership.md draft, not sent — for collections-services@archive.org
RETRACTED_six_unpopulated_collections.txt retraction — the "six unpopulated collections" finding does not exist; all 105 are populated. Records why it was wrong three times
attachment_collections.csv 105 rows: identifier, title, container created, CMA item count, first/last upload, worker host, status, videos downloaded
A correction worth carrying

Three successive wrong conclusions were drawn here — all from archive.db reporting zero items for collections that in fact hold thousands. A zero from that mirror means "not indexed", not "empty": it indexes no items for 39 of its 2,168 collections, 26 of which are populated. See 10.1-ia_metadata_xml_to_sql(26)/FINDING_2026-09-10_collection_index_coverage_gap.md.

The live re-count nearly repeated it: search_items(...).num_found returns an error dict containing 'total': 0 when rate-limited, rather than raising. Twelve collections were briefly recorded as empty for that reason. Assert the type.

Sources
  • live archive.org via the internetarchive library, with sentinels (a nonsense collection that must return 0, a known collection that must return its known count) — the authority for item counts here.
  • archive-store:/mnt/md0/datasette/archive.db — collection_meta (2,168 collections), collection, metadata (3.24M items). Queried in place, ?immutable=1.
  • worker-01:/var/lib/archive-collector/collector.db — channels (2,076), for worker assignment and status.
Why it mattered internally

This surfaced while building the greenfield v2 worker, which briefly had a gate refusing to upload into any collection not owned by our account. That gate would have refused 103 active channels. Container ownership is not write access — see 10.8-youtube-update-uploader-v2/GREENFIELD_V2_PLAN.md §4e.

Not established
  • Whether alan@smithee.com is a staff account, a service account, or a person.
  • Whether these 105 relate to the earlier access change the operator recalls. That is an operator recollection; nothing in the data dates or confirms it.
  • Whether alan@smithee.com relates to the earlier access change the operator recalls. Operator recollection; nothing in the data dates or confirms it.

↑ back to contents40.8-ia-collection-ownership-anomaly/README.md

40.9civic-media-cookbook-intersectionsNews Futures' Civic Media Cookbook read against the CMA: 50 projects, none an archive — custody is the missing layer. 19 of 21 named US places have a name-matched CMA collection (review trigger).
Open PDF · Civic Media Cookbook brief

40.9 — Civic Media Cookbook × Community Media Archive

A reading of News Futures' Producing Civic Media cookbook (Jolly & Brandel, 2026-04-28, 155 pp) against the CMA and Archive Corps work, written 2026-09-14.

Read first

CIVIC_MEDIA_COOKBOOK_BRIEF.md (+ .docx, .pdf) — summary, intersections, a 21-place cross-match, and eight opportunities sized snack / meal / feast.

cookbook-and-the-record.html — the same material as a shareable page, written for readers outside the pile (no hostnames, no internal paths). Published link: see Published below.

Headline (every figure states its basis)
Population Finding
50 cookbook projects (curated, self-submitted; not a sample) 0 are archives; none addresses long-term custody
21 US places named in the field guide (San Jose inferred) 19 have ≥1 name-matched CMA collection; Berwyn/Cicero IL and Providence RI have none
every .md/.txt in the pile 0 mentions of News Futures, Hearken, City Bureau, Documenters
Layout
Path What
CIVIC_MEDIA_COOKBOOK_BRIEF.md master
CIVIC_MEDIA_COOKBOOK_BRIEF.docx / .pdf generated by build_docs.sh
cookbook-and-the-record.html shareable page (hand-authored; update by hand)
data/cookbook_places_x_cma.psv cross-match rows incl. rejected false matches and none rows
build_docs.sh md → docx → pdf
Method and caveats
  • Cross-match source: 40.6-community-media-voices-review/data/cma_collections.psv (2,168 collections, 3,189,566 items, snapshot 2026-09-02). Numbers in the PSV are copied from that file by script, not typed.
  • Name-only matching is a review trigger. Which name matches to list was a judgement call; obvious wrong places are kept as rejected rows.
  • A zero or an old last-item date means not indexed / quiet in this index, not empty or behind (10.1 coverage-gap finding).
  • Nothing was fetched from the web and no one has been contacted.
  • The source PDF was not copied into the pile: ~/Downloads/2026-04-28_CivicMediaCookbook_Pages.pdf.
Published

cookbook-and-the-record.html published 2026-09-14 as a claude.ai artifact (private until shared from the page's share menu):

https://claude.ai/code/artifact/612de522-8be8-4c2b-9e38-64fe98d0fea6

↑ back to contents40.9-civic-media-cookbook-intersections/README.md

90.0diagram-docx-font-style-fixDocx mono-font patch — applies to any bundle's doc generator.

Patch — fix ASCII diagram collapsing in OVERVIEW.docx

Symptom: the ASCII federation diagram renders with correct monospace spacing in the Markdown and PDF, but collapses to proportional spacing in the .docx, destroying the box alignment.

Root cause: the builder specified Consolas, which is a Windows-only font. When the reader's Word/LibreOffice cannot find it and no monospace fallback is declared, it substitutes the proportional document default (Calibri) — and every character width changes, so the boxes collapse.

This is environment-dependent, which is why it did not show up in the Linux LibreOffice conversion used to build the PDF: there, Consolas silently falls back to DejaVu Sans Mono, which is monospace, so the PDF looked fine while the .docx broke on the reader's machine.

Fix: name a font that exists everywhere, and declare it in two places so a renderer that drops one still honours the other.

Change 1 — the font constant
// BEFORE
const MONO = "Consolas";

// AFTER
const MONO = "Courier New";   // present on Windows, macOS, and every Office
                              // install; Linux/LibreOffice maps it to
                              // Liberation Mono. Consolas is Windows-only and
                              // silently falls back to a PROPORTIONAL font
                              // elsewhere, which collapses the diagram.
Change 2 — add a paragraph style carrying the mono font

Add this constant near the other helpers:

// Paragraph style for ASCII diagrams. Belt-and-braces: if a renderer ignores
// the run-level font property, the paragraph style still supplies monospace.
const MONO_STYLE = {
  id: "DiagramMono",
  name: "Diagram Mono",
  basedOn: "Normal",
  quickFormat: false,
  run: { font: MONO, size: 15 },
  paragraph: { spacing: { after: 0, line: 216 } },
};

Register it in the Document styles block:

const doc = new Document({
  creator: "Community Media Preservation",
  styles: {
    default: { document: { run: { font: FONT, size: 20 } } },
    paragraphStyles: [MONO_STYLE],          // <-- ADD THIS LINE
  },
  sections: [{ /* ... unchanged ... */ }],
});
Change 3 — apply the style in diagram()
// BEFORE
function diagram(lines) {
  return lines.map((ln, i) =>
    new Paragraph({
      spacing: { after: 0, line: 216, before: i === 0 ? 40 : 0 },
      children: [new TextRun({ text: ln || " ", font: MONO, size: 15 })],
    })
  );
}

// AFTER
function diagram(lines) {
  return lines.map((ln, i) =>
    new Paragraph({
      style: "DiagramMono",                          // <-- ADD THIS LINE
      spacing: { after: 0, line: 216, before: i === 0 ? 40 : 0 },
      children: [new TextRun({ text: ln || " ", font: MONO, size: 15 })],
    })
  );
}
Verification

After patching, rebuild and confirm the font appears in both XML parts:

node build_overview_docx.js /tmp/ov.docx
mkdir -p /tmp/ovx && cd /tmp/ovx && unzip -o -q /tmp/ov.docx

grep -o 'w:styleId="DiagramMono"' word/styles.xml     # paragraph style present
grep -o 'Courier New' word/styles.xml                 # font in the style
grep -o 'w:ascii="Courier New"' word/document.xml     # font on the runs

All three must return matches. Then open the .docx in Word (not just the converted PDF) and confirm the box corners line up — the PDF alone will not reveal this class of bug.

Note for other documents

The other bundle documents render their diagrams inside Markdown fenced code blocks, which pandoc maps to its own code style — those were not affected. This fix applies only to build_overview_docx.js, the one purpose-built generator.

If a future document needs an ASCII diagram in a hand-built docx, reuse the DiagramMono style rather than setting a run font alone.

↑ back to contents90.0-diagram-docx-font-style-fix/PATCH-overview-docx-mono-font.md

90.1session-prompt-extractLifts the human-typed prompts out of a Claude Code session transcript into readable Markdown — the transcripts under ~/.claude/projects/ are system working state, large and not guaranteed to persist.

90.1 — session prompt extract

Lifts the human-typed prompts out of a Claude Code session transcript into readable, durable Markdown.

Why

Claude Code writes one JSONL transcript per session to ~/.claude/projects/<project-slug>/<session-id>.jsonl. Those are the system's own working state — large (this project's run to 5–11 MB), full of tool traffic, and not guaranteed to survive a cleanup. If the prompts matter as a record, copy them somewhere that is yours.

The filtering is the whole job

In a transcript, role "user" covers four different things, only one of which is a prompt:

appears as user is it a prompt?
what a person typed yes
tool results fed back to the model no
slash-command wrappers (<command-name>/clear</command-name>) no
injected <system-reminder> blocks no

In this project's own ACM-booklet session, 90 of 96 user entries were tool results. A naive grep '"role":"user"' returns almost entirely machine traffic.

Use
./extract_prompts.py --list                      # sessions for this project, newest first
./extract_prompts.py                             # newest session -> stdout
./extract_prompts.py --session <id> -o out.md    # one session
./extract_prompts.py --all -o all-prompts.md     # every session, oldest first

Runs on any Python 3 — no dependencies. --project-dir overrides the auto-detected transcript directory (the slug replaces both / and _ with -, so /home/access/big_pile_for_claude → -home-access-big-pile-for-claude).

Caveat

A transcript is appended to while the session runs, so the final exchange of a live session may not be in the file yet. Re-run after the session ends to capture it.

A fifth kind of role: "user" — background-task notifications (fixed 2026-09-04)

The filtering rationale above lists four things role: "user" covers. There is a fifth: background-task completion notifications. When a long job started with run_in_background finishes, the harness injects a <task-notification> block as a user-role message. Nobody typed it.

First extraction of session b818defe… returned 166 prompts; 10 were notifications:

### 59. 2026-08-31 20:47:38 UTC
> <task-notification>
> <task-id>bhuoxu9d7</task-id>

Sessions that never background a job will not show this, which is why it survived until a session that used them heavily.

Fix: <task-notification> and [SYSTEM NOTIFICATION added to SKIP_PREFIX, plus a new SKIP_CONTAINS test for markers that appear after a preamble rather than at the start of the message — a prefix test alone misses those. Backup of the prior version: extract_prompts.py.bak-20260904.

Re-extracted: 156 genuine prompts, 0 notifications.

Gotcha: it derives the project slug from the working directory

Run it from the project root:

cd /home/access/big_pile_for_claude
python3 90.1-session-prompt-extract/extract_prompts.py --list

Run from inside 90.1-session-prompt-extract/ it looks for a project slug of -home-access-big-pile-for-claude-90.1-session-prompt-extract and reports "no transcript directory for this project" — accurate, but easy to misread as "there are no sessions".

Extracted here
prompts_b818defe_2026-08-31_to_2026-09-04.md   156 prompts, 51,711 bytes
prompts_c1066e5c_2026-09-05_to_2026-09-08_program-map-mindmaps.md   the mind-map session

↑ back to contents90.1-session-prompt-extract/README.md