Skip to content

WIP: Minutes - #455

Merged
symroe merged 12 commits into
masterfrom
minutes
Aug 21, 2026
Merged

WIP: Minutes#455
symroe merged 12 commits into
masterfrom
minutes

Conversation

@symroe

@symroe symroe commented Jul 23, 2026

Copy link
Copy Markdown
Member

No description provided.

@symroe
symroe force-pushed the minutes branch 2 times, most recently from 0aec81e to 85e73cf Compare August 21, 2026 09:39
symroe and others added 12 commits August 21, 2026 14:28
Storage sessions were scoped per council but not per data type, so a
minutes run would delete a council's councillors output and vice versa.
Sessions are now rooted at data/<COUNCIL>/<Type>/, matching the layout
the GitHub backend already used.

Alongside that, BaseStorage takes a storage_mode. REPLACE clears
everything stored for the council and data type before writing, which is
what councillors needs: the current set is the whole truth and someone
who has left should disappear. ACCUMULATE adds to what is there, which is
what any historical record needs, where last year's data is still a fact.

REPLACE remains the default, so existing scrapers are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Scraped documents are large binaries with different needs from the
metadata that describes them: a meeting's JSON is a few KB of text that
belongs in version control, while the agenda pack behind it can be
hundreds of megabytes that belongs in an object store.

lgsf/storage/documents/ stores those files, deliberately independent of
the metadata backend so the two can be pointed at different places. The
default LocalDocumentStorage writes to data/<COUNCIL>/documents/, beside
rather than inside the per-type metadata directories, since documents are
shared across scraper types and excluded from version control.

Each stored document reports its sha256 hash, size, storage key and
backend name, which is what keeps metadata and file linked. The resolved
URL is deliberately not part of that: for local storage it is an absolute
filesystem path, so recording it would put a developer's home directory
into metadata destined for git and give every person a different value
for the same file. Backends rebuild it from the key via url_for().

Unlike the metadata backends this is not session based. Documents are
immutable once written, so exists() is a cheap and reliable way for a
scraper to skip work it has already done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three additions to ScraperBase, all needed by scrapers that fetch
documents or revisit the same pages on a schedule.

get_conditional() sends If-None-Match and If-Modified-Since so a server
can answer 304 instead of resending a body. response_status() and
response_header() normalise the differences between the four supported
HTTP clients, since wreq returns a StatusCode object and bytes-valued
headers where the others return ints and strings.

get() was also silently discarding extra_headers under wreq, which made
conditional requests impossible; headers are now merged over the emulated
browser set rather than dropped.

document_storage is a lazily created document backend, so scrapers that
store no documents never construct one. storage_mode declares whether
this scraper's data replaces or accumulates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CouncilMetadata had one field per service, plus matching branches in
from_file(), to_dict() and a SERVICE_NAMES tuple. Adding a service meant
four coordinated edits, each with its own failure mode: missing the
to_dict() one silently dropped that service's config from every council
the next time metadata was saved, and missing SERVICE_NAMES made
get_service_metadata() return None so every scraper saw a base_url of
None.

Services are now a dict keyed by name. A new service works as soon as a
council's metadata.json declares it, with no change to the model and no
list of permitted names to keep in sync.

get_service_metadata() always returns a ServiceData rather than None for
an unknown service, so callers can read .base_url without a null check
and get None for "not configured" either way. get_summary() reports every
configured service rather than singling councillors out: a council has a
cms_type and base_url per service, and there is no such thing as "the"
one.

Verified behaviour-preserving by loading all 435 scrapers/*/metadata.json
through both the old and new models and comparing serialised output:
identical for every file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
manage.py metadata update was unusable. Two calls named methods that do
not exist, so updating any council raised AttributeError before writing
anything: update_everyelectiion_data (the real one has one i, matching
the method rather than the misspelled JSON key) and
get_councillors_service. Separately, --council only searched the first
page of results, but the EveryElection API ignores an
official_identifier filter and returns all 502 organisations paginated at
100, so every council after the first hundred alphabetically reported
"not found" - quietly, since that path prints rather than raises.

Three places assumed councillors, which is fine with one scraper type and
wrong with more. Validation looked for councillors.py and read
services.councillors, so a missing or broken scraper for any other
service was invisible; it now takes --service, reports councils that take
no part in a service as not applicable rather than failing them, and
treats a scraper file without metadata (or the reverse) as an error. Tag
filtering loaded councillors scrapers whatever service was requested, so
--service minutes --tags filtered on the wrong scrapers entirely. And
base_url was only ever auto-filled from councillors.py, so a council
could gain a service and never have its metadata completed - every
scraper file is now considered, named after its service by convention.

The CMS type check used a table of three councillor class names, so
scrapers written against any other base class went unchecked. Base
classes are named <CMS><Type>Scraper, so it now reads the CMS off the
prefix and needs no entry per scraper type. That found four real metadata
inaccuracies: three councils label a PagedHTML scraper as "Custom HTML",
and ERY labels a JSON scraper the same way.

Also derive report["valid"] from whether errors were recorded. The
base_url check appended an error without clearing the flag, so a council
could be reported as passing while listing errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Running every council was close to unusable. With -v the first broken
council raised and ended the run; without it, every failure went into one
of several hundred Rich tables scrolling past. Neither leaves you with a
list of what is broken, which is the reason to run them all.

Councils are independent sites, so they are now scraped concurrently
(--workers, default 4) and one failing never stops the others. Each
council's outcome is recorded in data/<COUNCIL>/<Type>/runlog.json,
beside the data that run produced, keeping the most recent run and the
scraper's console output for the ones that failed. --list-failing reads
those, so after a full run it reports what is actually broken rather than
what a production dashboard says about a different data type.

Run logs are written straight to disk rather than through the storage
session, because the runs most worth recording are the ones the session
never commits: a failure resets it without writing, and a run that
scrapes nothing skips the commit entirely.

Running exactly one council still raises with -v: that is someone working
on that scraper, or a scripted call that needs the failure to reach its
exit code. One council is also never run "concurrently", so its output
streams as it happens instead of being replayed at the end.

Concurrency needed two things to stop being shared. run_council mutated
self.options["council"], so parallel runs would overwrite each other's
council and scrape the wrong site; each council now gets its own options.
And they shared one recording console, which would interleave every
council's log into every other council's run record; each now gets its
own.

Lambda is unaffected: it runs a single council through its own handler,
which builds its own console and run log and never enters this loop. Run
logs are skipped there, and for any backend that keeps its data
elsewhere, since there is nowhere local to put them.

Also generalise --report to collect from any scraper type, and show real
council names in the listings - they read official_name from the top
level of metadata.json, where the current format nests it under
everyelectiion_data, so every council showed as "Unknown".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ports the meeting-scraping half of poteris/council-scraper into the
framework, mirroring the shape of the councillors vertical: MeetingBase,
BaseMinutesScraper, ModernGov and CMIS implementations, and a command.

Meetings are scraped over a rolling window that looks mostly backwards,
because minutes are published days or weeks after a meeting and
revisiting is how they get picked up. Minutes accumulate rather than
replace, since a meeting that has dropped out of the window is still a
fact.

Documents go to the document store, not into raw/ beside the metadata,
and a _index.json records where each one went along with its hash and any
HTTP validators. Documents already in the store are skipped, so a second
run over the same window costs almost nothing - without it a single
council re-downloaded 267 files and 104MB every run. The index is treated
as a cache rather than a source of truth: every entry is checked against
the store before it is used to skip, so a deleted file costs a
re-download rather than losing the document. The full agenda pack is
kept; categorise_document only labels documents, it doesn't decide which
are worth downloading.

The CMIS scraper walks a month calendar per month in the window. Its list
view renders only the server's current calendar month and ignores any
date parameter, so a CMIS council could otherwise never return the same
window as ModernGov. The month view is reachable only in DNN's path form,
which needs the site's tabid as well as the module id; both are lifted
from a real meeting link, and the list view remains as a fallback.

--skip-documents records documents without fetching them, which is what
you want while iterating on parsing, or when checking several hundred
scrapers still work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
manage.py templates was broken for every template, councillors included:
it unpacked scraper_abs_path()'s return value as a tuple, but that
function returns a Path, so any attempt to scaffold a scraper raised
TypeError. Nothing caught it because nothing exercised the command.

Adds minutes templates for ModernGov and CMIS, plus a custom starter for
councils on neither, where the scraper has to be written by hand and
there is no base class to inherit the work from. That template stubs both
required methods and shows the shape of a meeting, including how
documents are recorded.

Tests drive every template end to end: that it writes a file, that the
file parses as Python, that it defines a Scraper class, that base_url is
substituted, and that an existing scraper is never clobbered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A services.minutes block and a minutes.py for every council whose
meetings are on ModernGov or CMIS: 304 ModernGov and 32 CMIS, or 87% of
current councils. The remaining 49 are on custom sites that need a
hand-written scraper.

Generated from the council list in poteris/council-scraper, matched to
LGSF scraper directories by hostname - councillors and minutes almost
always share a domain - then checked by running every one of them.

That check mattered. It found four base_urls left ending in
/mgCalendarMonthView.asp, which a ModernGov scraper cannot use since it
needs the directory above mgWebService.asmx; nine councils whose minutes
host differed from their working councillors host, four of which did not
resolve at all; and two councils matched to the wrong organisation
entirely, Liverpool to Merseytravel and Rugby to the Welsh Parliament.
Rugby has no ModernGov to correct to and is left in the coverage gap
rather than configured wrongly.

307 of the 336 scrape cleanly. Most of the rest are councils whose own
sites are down, refusing connections or returning 5xx; those are listed
in PLAN-minutes.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The README said councillors were the only supported type, documented the
councillor scraper classes in detail with no equivalent for minutes, and
had grown to 445 lines of reference material. Anyone arriving had to read
all of it, or the source.

It is now an overview and an install guide: how the pieces fit together -
a council is a directory, a data type is a package, a council's scraper
is usually four lines, configuration lives in metadata rather than code -
followed by the commands to get a scraper running, and an index.

Everything else moved to docs/, split by what someone is actually doing:
running scrapers and diagnosing a broken one, the councillor classes, the
minutes classes, minutes coverage, and adding a whole data type. Splitting
it by task rather than by chapter means each file can be pointed at
directly.

Two things in there are worth knowing before touching anything, and
neither was written down. Running a single council without -v, the
command catches the exception, prints a summary table and exits 0, so a
broken scraper looks like a quiet one. And base_url comes from
metadata.json rather than the scraper class, so a scraper that only sets
it on the class runs against nothing.

The fixing guidance leads with configuration rather than parsing, because
that is where the failures are: running every configured council, the
large majority of breakage is a wrong or stale base_url.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Committed to .claude/skills/ rather than kept personally, so anyone who
clones the repo gets them and they are reviewed like any other file. Only
.claude/settings.local.json is ignored, which is personal to whoever is
running Claude Code.

Four skills, each a short router into docs/ rather than a copy of it, so
the human documentation and the agent instructions cannot drift apart:

  lgsf-run                commands shared by every data type, the dev
                          loop, and diagnosing a broken scraper
  lgsf-councillors        the councillor classes and their contract
  lgsf-minutes            meetings, the scrape window, document storage
  lgsf-add-scraper-type   adding a whole data type

The two domain skills carry `paths`, so editing a council's minutes.py
loads the minutes skill and not the councillors one. That is what keeps
them pointed at different documentation without either having to know
about the other.

Each carries inline the handful of things that are expensive to learn by
trial: that a single council needs -v or its exception is swallowed, that
base_url comes from metadata.json rather than the scraper class, that
councillors replace and minutes accumulate, and that a minutes scraper
finding no meetings may be correct rather than broken.

They are prefixed lgsf- because an unprefixed `run` would shadow the
bundled /run skill.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@symroe
symroe merged commit 2b7bcc5 into master Aug 21, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant