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-encoder-1Whisper ASR fill for the caption gap set (Project C3) — Faster-Whisper-XXL r245.4 on encoder-1; 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 (SER5/6, WO4) 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.
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.
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 megastor (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-encoder-1Whisper ASR fill for the caption gap set (Project C3) — Faster-Whisper-XXL r245.4 on encoder-1; large-v3-turbo, 2-process, 88–95× realtime.
encoder-1: Faster-Whisper-XXL r245.4 — as-built
Status: Installed, verified, benchmarked. Outstanding items in §7. Last updated: 2026-08-25
1. Environment (verified)
| Host | encoder-1 |
| 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.3.177 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)
# 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.
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 Access Humboldt 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.3.177 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 Access Humboldt 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-encoder-1/encoder-1-fwxxl-r245-upgrade.md
10.4whisper-cpp-cross-platform-benchmarkswhisper.cpp ASR benchmarks across the non-CUDA fleet — Vulkan on the AMD boxes (SER5/6, WO4) 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-wo4.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 |
|---|---|---|
| SER6 / WO4 / P14s (AMD iGPU, Vulkan) | cpp |
parakeet.cpp parakeet-cli |
| encoder-1 (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 p14s-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
# P14s (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 p14s-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 SER6, MacBook M2, Mac mini M1,
Peladn WO4, and ThinkPad P14s — against the same 2056.7 s Rio Dell file.
The P14s is the fastest box (~18×,
-mc 160, 54/55 nouns, RDNA3 12 CU); the WO4 remains the
well-characterised reference. The WO4 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 encoder-1 --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
megastor), 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 ser62 → 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:
ser62:~/<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 | |
|---|---|---|---|---|
| ser62 | 468 | wo44 | 285 | |
| ser56 | 336 | ser6 | 255 | |
| ser54 | 208 | ser55 | 181 | |
| ser5 | 178 | hp705g42 | 126 | |
| m710s02 | 70 | lt39 | 27 |
Files here
| file | contents |
|---|---|
raw/*.psv |
The ten source files, byte-identical to
ser62. 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 megastor, 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 |
ser55 → actvma |
ser62 → actonma |
rctvma |
wo44 → rctvma |
ser56 → 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 ser62 (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)
ser55|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 ser55,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 — megastor 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 megastor 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.
↑ 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 (p14s-ub, ser62)
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 p14s-ub attempts 1-3: total=5291, 5291, 5291
# ser62 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 megastor'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. |
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: ser62'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 ser62, live data:
| # | test | result |
|---|---|---|
| 1 | dry run, happy path | resolved ser62 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 ser62) 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 ser55 and ser5 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 ser62 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_zimacube, rsync_to_encoder1,
rsync_to_megastor, rsync*to_hp*) on the
current workers. Check every rsync line — several scripts carry
it on a later line
(wo44/rsync_results_to_zimacube.sh on 1 of 9;
ser56/rsync_to_encoder1.sh on 1 of 7):
| host | jobs carrying archive.txt |
|---|---|
| ser62 | rsync_results_to_zimacube.sh only |
| wo44 | results_to_zimacube, to_zimacube,
to_encoder1, to_megastor,
to_megastor_mnt9 |
| hp705g42 | results_to_zimacube, to_encoder1,
to_megastor, to_megastor_mnt9 |
| ser56 | to_encoder1, to_megastor |
| ser6 | results_to_zimacube, to_encoder1 |
| ser5, ser54, lt39 | one each |
| ser55 | none |
ser6 rsync_to_hpeliteg32.sh |
excluded explicitly (--exclude=archive.txt) |
All are outbound, so none can overwrite a worker's
archive.txt. ser62's to_megastor / to_encoder1
/ to_hp3601 do not carry it, so megastor's
copy of ser62'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 ser6 # dry run
./repair_backlog_urls.py --host ser6 --yes
./repair_backlog_urls.py --host ser6 --restore-stamp 20260901T172140Z --yes
./repair_backlog_urls.py --host ser6 --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 |
|---|---|---|---|---|
| ser6 | hrdvsc |
UCnHalPPO5Nr0mnp38-LcBkQ |
2 | 20260901T172140Z |
| ser6 | ntttx |
UCAbhx6ojqvvKNqWOvvnEerA (/videos +
/streams) |
2 (4 URLs) | 20260901T172140Z |
| ser6 | sptspsva |
UC-lT2HnZcxV29VD9uaQQCNQ |
2 | 20260901T172140Z |
| ser56 | mpactmi |
UCut6dZAdB8MNyr-L7CcXb1A |
2 | 20260901T172152Z |
| ser56 | vsuga |
UC3jHXbvpe12hU7yEq6lbeTg |
2 | 20260901T172152Z |
wo44 — 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_hpeliteg32 |
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
wo44:~/wo44_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 (wo44 2,602 + ser6/ser56 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 ser6, 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.
ser56 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 (ser6 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 ser62 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 ser62: 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 ser62; 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 ser62 (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 ser62), 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
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 Access Humboldt 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
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_program-map-mindmaps.md 9 prompts (the mind-map session)
↑ back to contents90.1-session-prompt-extract/README.md