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 PDFThe program at a glance — the levels
3 databases + ASR (encoder + fleet) + the freshness check and the uploader · federate, don’t merge — civic.db seeds the agenda work →
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.
| 00 | Program & strategy |
| 10 | Media archive infrastructure (“the beast”) |
| 20 | Agendas: landscape & pipeline (do before per-vendor work) |
| 30 | CivicPlus scraper + legacy-corpus forensics |
| 31 | CivicClerk scrapers |
| 32 | Legistar scrapers |
| 40 | Community / Archive Corps |
| 90 | Shared 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
- 00.0stepping-back-from-CMA-operationsThe charter — why all of this exists.
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
- 20.0discover_agenda_systems_via_wayback_machineTarget discovery — 12,362 tenants across 10 vendors.
- 20.1civic-scrapers-bundle_2026-08-03Corpus classify + priority queue; the Granicus scraper.
30CivicPlus
- 30.0agenda-scraper-bundle_2026-07-21CivicPlus AgendaCenter scraper — first working extractor.
- 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.
31CivicClerk
- 31.0civicclerk_scrapers_bundleCivicClerk/CivicWeb scraper effort.
- 31.1civicclerk_TX_diagnostics_and_code_changesTexas run — 74,177 docs; v3→v5 diagnostics & fixes.
32Legistar
- 32.0legistar-scraper-build1Legistar scraper build #1; 339 offline tests.
- 32.1legistar_client_runsProduction runs — 418,871 docs, 170 jurisdictions, 33 states.
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.
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:
- GUI
- Search → advanced search
- 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
\rand\"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
- Volunteer infrastructure — microtasks, recognition, coordination. Nothing else in Archive Corps works without it.
- The onboarding curriculum — IA fundamentals → CMA operation. Currently a sketch.
- Agendas/minutes rebuild — highest-value corpus, currently broken.
- 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.
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.csvUse --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 trytownof…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,.govhost, 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.govhost 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.orgfor Liberty TX,commerce.govfor Commerce TX,scotland.orgfor Scotland SD) is rejected, not stored at low confidence. This matters: a bad match occupies the site field, soneeds=siteskips 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 +.govhost 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/aboutpage 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…oregon8825variant 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 countiesCounty 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 agendasEach 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:
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
--needsto export everything; drop--stateto export all states.)Open
wa_fix.csvin a spreadsheet and fix the bad rows: setofficial_siteto the correct URL and setmatch_confidencetomanual. The score is normalized to 9 for you. Works for both "no site" rows and wrong-URL rows. Don't change thegeoid— it's the match key.Import the edits back into the database:
python3 civic.py import --in wa_fix.csv --cache-dir ~/civic/html_cacheEach 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
manualrow with no URL) is flaggedmanual-unreachableand 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 itThe 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):
exportthe rows (e.g.--needs youtubeor--needs agenda, or just the whole state).- In the CSV, set the correct value and mark its
source column
manual:- YouTube: set
youtube_channelandyoutube_source = manual. - Agenda: set
agenda_pageandagenda_source = manual.
- YouTube: set
importthe 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 stateThis 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 --lockWithout --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 directlyIt 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
--gazetteeronscrape-sites, or setGAZETTEER_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 bygeoid). 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.csvPipe-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 psvCSV 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 # scrubadjudicate-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 onesdupe-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_tierso 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 emptyProgress 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 --yesFIX -> <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/cityandcousub/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 rowsload-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 channelsimport-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 # applyimport-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-dupesload-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_thresholdscrape-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 statsRefresh 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 30Test 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 42Politeness & 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_AGENTstring incivic_common.pybefore large runs.Consider checking each site's
robots.txt. These are public.govsites, 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 realAccept). 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_urlalso upgrades a failedhttp://candidate tohttps://, and records why a fetch failed —last_fetch_failure()returnsblocked|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). ReturnsNoneby 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 singlevwhen 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 tooProving the identifier is free
This is the part that matters: a collection identifier must not exist at the moment it is created. Two gates:
- Local — every
ia_collectionincollector.db(~2,000), everycma_collection_urlincivic.db(~750), anything in a--reservefile, and everything minted earlier in the same run. Free, catches the common case, and guarantees the batch is internally unique. - Remote — the metadata API first,
then archive.org's own
check_identifierservice. An identifier counts as free only when both agree; anything ambiguous comes backunknownand 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.csvIt 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-runFull 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-REJECTRuns 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 Livematched Murray City, OH. Usually it's this place plus its own institution, not a different municipality.- bare
inc/llc/corporationin 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. Narrowerarea development/development corpis 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 surveyDownloads 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
codtxcorpus 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, fixderive_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-playlistreturns no date at all unlessyoutubetab:approximate_dateis 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_exactdrives the banding;latest_upload_approxis kept beside it so the drift stays visible.--no-rssskips 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.096sChoosing 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 flagDefaults 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.txtis not pruned — it is generated narrative, not an input to any tool, so it goes stale rather than dangerous. Re-runningcma_onboard.py planregenerates 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_fallbackfor 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.
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.yamlFull 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 theenrich_missing.pydiagnostic. 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) andmigrate_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) soagent.pyruns unmodified on Linux, macOS, and WSL2. No shell-outs todate/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 theyoutube-id/vimeo-idmetadata every existing item already carries. Path A (query by collection) with a Path B (match by video id) fallback for multi-channel collections. Produces theverifiedcount. | |generate_config.py| Builds a worker'sconfig.tomlby parsing the existing per-directoryget_new_video_metadata_backlog.shscripts. 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'sUC…id via yt-dlp (politely, resumable), and writes adirname → channel_idTSV map. Feed it togenerate_config.py --channel-id-mapso 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 adirname → ia_collectionmapping 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 theyt_channel_names.shTSV or achannel_id\|namemap), so the dashboard shows "Anchorage School District" instead of a rawUC…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'sdownload_dir(read fromconfig.toml) for.info.jsonfiles, extracts the video ids, and POSTs them to the collector's/claimsendpoint. This populates theclaimstable so reconciliation can computemissing(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 v3missing_videotable), then either (--mode redownload, recommended) re-downloads only those ids into the channel's configureddownload_dirwith--no-download-archive(shared archive untouched, zero blast radius), or (--mode archive) backs up the sharedarchive.txttimestamped, removes just those ids from it, and re-runs the dirname's backlog script.--dry-run,--only DIRNAME,--export-only/--skip-exportfor 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,--delaystagger) — 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 themissing_videos_by_worker.sqlexport) and enriches each from the cheapest source: local.info.json(full metadata — title, duration, tags, etc.), then optionally ayt-dlp --flat-playlistprobe (one call/channel) for ids with no local file, then optionally a full per-video fetch. Tags each record with itssourcetier. JSONL or TSV output. Thesourceitself is diagnostic — no.info.jsonmeans 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 --userdeps become invisible to the new Python and the agent silently dies. This script idempotently re-homes the machine onto a venv, fixesyt-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 withpython3 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 fromcollector.pyand 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 IGNOREvsOR 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'scollector.db("what we sent") with the separate IA metadata database ("what's actually on IA", incl. post-upload deletions): join keys, theDARKENEDfailure 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
docs/archive-dashboard-design.md— understand the target system (the why).docs/collector-schema.md— skim the data model: the tables, thechannel_idjoin key, and the three-numbers distinction in concrete columns. Useful context before you stand anything up.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'shostmust be added there (and theenvfile regenerated + service restarted) or its posts are rejected400 unknown host.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 --uservs 24.04 venv / PEP 668 split and sets$AGENT_PY; verifyyt-dlpis on the agent's PATH), - generate config from the on-disk scripts (Steps 2–3),
- trial-run
--no-remoteand 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.
- install (Step 1 auto-handles the 22.04
- 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.
- 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, runcode/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'sIAClientneeds 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 resolvesyt-dlpby absolute path (envYTDLP_PATH→ PATH →~/.local/bin→/usr/local/bin→/usr/bin) and fails loudly if it can't find it, rather than silently returning null totals. Confirmsudo -u access yt-dlp --versionworks (symlink into/usr/local/binif 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),tomlion Python <3.11 (3.11+ hastomllibbuilt in). The pipeline already providesyt-dlp,jq,ffmpeg, GNUparallel. On Ubuntu 22.04 install deps withpip --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_PYaccordingly, andcode/migrate_env.shre-homes a worker onto a venv after an OS upgrade. - Collector (
collector.py): Python 3.8+ stdlib only. - Reconciliation (
reconcile.py): theinternetarchivepackage. - 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/fwxxlStorage:
/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 -20Contents: 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
It nests everything under
Faster-Whisper-XXL/. Flatten after extracting, as above.It extracts 6.09 GB, not the ~3.5 GB the archive size suggests. Check free space first.
It does not preserve the execute bit.
chmod +xthe binary andOne_Click_Transcribe.sh, or the build looks installed and simply will not run.-odoes not tilde-expand —-o~/foocreates 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/nullBehavior changes affecting output
--sentencenow 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.lrcis a new format.--output_format allemits 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_onlypass 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 -60Check 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=56.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 | sortA 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
doneMax 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 -20Interpret 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 -20Corpus 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
done6.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.log6.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 modelpyannote_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/diarScripted 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
--diarizeauto-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
speakerfield and unlabeledtext(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_dumpfor structured turns — never the json. To get word-level text with speakers, join json word times against--diarize_dumpturns by timestamp. This matters for the C3 write-back: the json alone cannot populate a speaker column. - Labels are arbitrary per file.
SPEAKER_00in one meeting has no relation toSPEAKER_00in 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
--batchedboth 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/nullComment 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 /mnt58.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-v2batched — 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-v2when they werelarge-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 Codedocs/— runbooks and analysis (.mdis canonical;.docx/.pdfgenerated)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-parakeetuv 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-parakeetThe 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 # resultCompare Parakeet against the whisper baseline
./score-transcript.sh --table \
~/parakeet-bench/*/A-*.txt ~/whisper-bench/<best-run>/*.txtThe 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 incollector.db, UPPERCASE inarchive.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:
/tmpwas the origin. A rebuilt worker cannot regenerate its rows.- 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. - 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(maplpsmo, dbklps18mo) andvmawi(mapvmawi, dbcvctvwi). - 63 dirnames in the map with no
collector.dbchannel row. - 7 dirnames in
collector.dbnot 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_tvon worker-03,remote_total6,168,status=active— andia_collectionblank (one of only 6 such channels). - That same channel (
UCLA_DiR1FfKNvjuUpBHmylQ) is attached to 155 rows inchannel_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.dbis 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.
Recommended next steps
- Load into
collector.dbasdirname_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. - Cross-check against archive-store once reachable: run
build_map.py --root /mnt/md0/poweredge710/data/archive_stats --include-identityand diff. Two independent derivations of the same fact settle the four defects on evidence. - Resolve
nasa_tv— 6,168 videos with no collection attribution. - 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, correct2. 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 BIf 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, atotalof5,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.30during 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}'
doneObserved: 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 forcobcov) on first use; total: 0for every collection, HTTP 200;total: 5416280for every collection, HTTP 200 — three consecutive probes at 17:30:50, 17:31:10, 17:31:30 (seecaptures/retry_probe.log);total: 5291for every collection, withsctvct'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, andcollection:"value"all identical. - HTTP method —
GETandPOSTboth affected. (POSTwith parameters in the body returns400 {"error":"Missing query"}; the library sendsPOSTwith parameters in the URL and an empty body.) - Authentication — anonymous, S3
Authorizationheader, and session cookies all identical. Connection: closevs 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 —
internetarchive5.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
- Is there a caching layer in front of
/services/search/v1/scrape, and does its key includeq? - Is this per-client (IP/session) or shared across clients? If shared, results could be contaminated between unrelated users.
- Is there a request pattern or rate that triggers it, so we can avoid it while it is investigated?
- Is there a header we can send to bypass the affected path, or a recommended parameterisation that is known-safe for bulk enumeration?
- 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 sideOutputs 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. |
Related sections
10.5-fleet-dirname-collection-map/— the rescueddirname → collectionmap 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.
cofscandverncaalone 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.dbwas 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
.shmtime. The current script's timestamp is when it was retrieved, not when it was written; the script itself has not been modified recently. ThePRE-FIXcopy'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
awkattempt 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
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)sis NOT sufficient: multilingual and live-stream channels post several videos the same day (177 ofmcpsmd's 224 losses survive a date suffix).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.
A skipped download must never be recorded as complete.
--no-force-overwrites+ a shared--download-archivemeans 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.The identifier source of truth must be explicit. Today it is
._filenameinside 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.Enforce the 100-char cap at construction, not by truncating afterwards.
prefix + title_budget + separator + 11-char idmust be < 100, and the budget must be derived from the prefix length per channel.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
- Resolve worker -> resolve archive path -> report both
- Prefix check, id-presence check, leftover-media check, running-cycle check
- 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 cp -pbackup to<archive>.bak-<stamp>(refuses if one exists)- Rewrite to a temp file in the same directory,
fsync, line-count check, thenos.replace(atomic) - Verify: every id absent, and
after == before - removed - 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.
- 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. - Ownership and prefix matching.
grep -lon the video id also matched channel/playlist-level dumps (ActonTV.info.jsoncontains all 37 ids), andglob(stem + "*")is a PREFIX match, so stemActonTVswallowedActonTV-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 noentries, and a candidate must bestem + "." + 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
actvmapoints 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:
ls -lthis 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.- rsync quick-check safety.
rsync -auvxtransfers 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
ntttxhad two URL lines (/videosand/streams) in each of two files, and the streams line carries the date disambiguator in its-otemplate. Patching only the first line would have left half the channel dead.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.- The first verification was wrong, not the patch.
worker-02 reported
entries=0for both dirnames — it wasyt-dlp: command not found. The binary is at~/.local/bin/yt-dlpthere 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 -Fxfon 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.mpeg4uploads as<coll>-FooBar. De-prefixed that isFooBar, which never equals the stemFoo+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 + titleexceeded 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
build_corpus.py— extracts a compact working corpus from the 16.5 GBarchive.dbon archive-store: title, description, dates, PEG access type, and four item-level flags. Read-only (?immutable=1; the DB is WAL, so?mode=rofails). ~4 min, streams and resumes.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.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 explicitnoveltyweight.story_miner.py— scores, ranks, and writes the worksheet.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. | headover 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 incma_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.
Related
- 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.
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.
- 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.
- Egress diversity. How many distinct addresses and networks the fetching originates from.
- 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— withmediatypeset 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.
- 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.
- 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.
- 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.
- Specify, from source, precisely enough to implement without reading the original. Not from the wiki, which documents installation and explicitly not the protocol.
- 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.
- 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.
- 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.jsonsurvives 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.
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 = 0on 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_tagis a feed, not a body; - meeting: OpenPublica
meeting_id/ media key ↔︎ CMA identifier.
- place: OpenPublica
- 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-*, uploaderuploader@example.invalid), not the API. Its 308 rows are Half Moon Bay 151, Berkeley 127 and Montgomery County PA 30. - Fields taken.
summary_mdcomes from the item description, andoutcomes_mdis the "Key Outcomes" section split out.topicsare the item's subjects without percentages.has_transcript = 0everywhere. - Links to recordings.
related_identifierties 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 itsmeetingstable.
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). Seereview/REVIEW_youtube_identity_collections.txt. - My own name matcher produced a wrong link:
Sacramento → "Sacramento Faith TV" (
SACFCA). civic.db was not involved (in_cmablank).- 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.
- Collections linked to a place but carrying almost no meetings in
OpenPublica's period are in
6.2
Body: OpenPublica category ↔︎ CM meeting_type ↔︎ CMA
(none)
- OpenPublica's
category_tagnames 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_typetaxonomy 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_exactandtitle_date, plusupload_datepending a larger sample. - Confirm every captioned pair by alignment.
- Route the other tiers to a worksheet.
- Auto-propose
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
dateis 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
dateis 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_idscan 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_partslinks to later parts. - Weak YouTube auto-captions lower coverage even for true matches.
- The alignment check reads the first 3,000 OpenPublica words, so it
cannot validate
- 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
ashland-kentuckyis Miami Beach. Its only tracked playlist is Miami Beach's. All 21 "Ashland" meetings are titled "Miami Beach City Commission…", and the CMA collectionCIG_0640("City of Miami Beach TV") holds the same 20 videos.- Some YouTube-sourced meeting dates are upload dates, e.g. Avondale AZ (up to 21 days late).
- Near-duplicate meetings, sometimes under different categories, e.g. Fort Smith CBID with three records for 2026-09-06.
- 20 meeting titles are in Chinese (one each in 20 governments), and some Spanish.
- Stub summaries ("…no summary can be generated") are served as summaries. 276 meetings are in category "Other".
GET /v1/governments/all/stats?days=365returned 500 (one request).- 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-valuedrelated_identifier. - Place crosswalk:
op_government_id,geoid(or county FIPS),basis,reviewed. - Body crosswalk:
op_category_tag, the body parsed from the title, CMmeeting_type. - Meetings:
topics_weighted(topic, %),transcript_source/transcript_model, localmeeting_datewith 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.
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 (
transcripts256 MB + 60-swindows322 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 fromwindows) 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_mapis 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.
- civic.db
- 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.
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_hostsRead 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.
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.
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.
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-scraperlibrary is BROKEN for modern CivicClerk (parses a__VIEWSTATEfield 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-eventEvents({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 (@filesfail → docs are on CivicPlus, route there). - Data root
/mnt0/civicclerk_output/is self-describing:{ST}/{Place}/{Board}/{Year}/*.pdfruns/{siteid}.csv(records) +discovery/(snapshots) +logs/.
Environment
- Ubuntu; Python 3, standard library only (no third-party deps for the
API tools;
civicclerk_html_archive.pyuses 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_outputOutput 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:
- 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-31was five months past the run date. - 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.
- 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 == categoryfor 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
eventNamecarrying the body —"City Council Meeting","Investment Committee Meeting"— whilecategoryNameis the literal"General".
Root cause, one line:
board = cat or ev_name or "Board" # "General" is truthy, so ev_name never ranA 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(subclassesRecordsWriter, same atomic partial-then-promote): writesruns/<client>_pending.csv, one row per zero-file meeting, with a classifiedreason—scheduledfor future dates,no_files_reportedfor 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_CATEGORIEScoversgeneral,uncategorized,default,other,misc,n/aand blank, so this is not a Texas-specific patch. Falls back toeventName.- Trailing "Meeting" trimmed so
City Council MeetingandCity Council— both present in one tenant — are one body._MEETING_KINDprotectsSpecial Meeting,Regular Meetingand similar from becomingSpecial. board_sourcecolumn (category|event_name) in the records CSV, so a derived body name is visible and reversible rather than silently overwriting.event_idnow 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 verifyplan 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
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/clientsAnswer the index-or-source question. Sample ten
no_files_reportedrows 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.Run the
Generalremediation (§3) — 6,613 files across 106 tenants.Decide on
Notice_of_Possible_Quorumand any similar posting categories.Investigate the single
failed 1. One failure across 32 hours is findable now and impossible later.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/keptconflates 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.
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.
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.--versionreports 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.-nfor 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-pctfor 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 inmissing.csvusing 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 aniaCLI 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):
- 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.
- 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.
- 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.
- 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.dbauthoritative andcollector.dbclaims 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:
- 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. - A reviewer verifies it. A trusted
volunteer (see the ladder in
volunteer-contribution.md§3) approves or rejects; status becomesacceptedorrejected. High-stakes fields can require two independent approvals. - A merge job applies accepted edits. On a cadence, a
job reads all
acceptedsubmissions 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 flaggedneeds_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 toacceptedorrejected(with a short reason). Onlyacceptedsubmissions are eligible for the merge job. High-stakes fields (e.g. a speaker identification, a government's canonical name) can require two independentacceptedvotes; 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
- 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.)
- 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?
- 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?
- 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?
- Conflict handling: two accepted edits touching the same field — last-write, steward-resolves, or task-level locking to prevent it upstream?
- The "use of contributions" policy
(
volunteer-contribution.mdFAQ) — 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.
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 pdffrom 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.mdis 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.
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:
- The generated image was decoded back and matched the URL character for character.
- 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:
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.
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=rofails). Snapshot dated 2026-09-02. Counts are per IA collection (collectionjoinmetadata), NOT perdirnameviarollup_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_collectionis keyed ondirname(the on-disk directory);collection_meta.identifieris the IA collection. They are different grains —access_humboldthas 12,458 items via thecollectiontable and 0 viadirname. That bug dropped 144 collections and ~390k items, understating every total. Corrected 2026-09-10; all figures indocs/anddata/are the corrected ones. Only 4 matched hubs now lack a usable date, and those have items but no parseabledatefield.
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.
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  : 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.dbonarchive-store.REDACTED.ts.net:/mnt/md0/datasette/(16.5 GB, the record of 2.7M+ IA items), and the compactcorpus.dbbuilt from it by10.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=rofails.
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.
How the links were verified
Two independent instruments, and they agree: 88 of 88 links resolve.
archive.db— every linked identifier checked against IA's own metadata as synced on 2026-09-02. 50 resolve to active items inmetadata, 36 to rows incollection_meta. Two (community_media,rompeviento_tv) are real IA collections with nocollection_metarow, because neither was harvested as a CMA station collection.- 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 of000s. 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 asFETCHFAIL, never as missing. - Counting
<a href=in the generated HTML said only 4 of 35 links had rendered. They all had; pandoc wraps<aonto its own line, so the pattern missed them. Countinghref="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
internetarchivelibrary, 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.comis 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.comrelates 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).
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
rejectedrows. - A zero or an old last-item date means not indexed / quiet in
this index, not empty or behind (
10.1coverage-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 runsAll 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