Conversation
Two file-name shapes were parsed incorrectly by the file lists at
OralArgContents30.html:
- Names containing an apostrophe close the court's single quoted href
early ("...oralarg/22-1688_Kelleyv.O'Malley.mp3'"), so lxml only sees a
truncated URL. That yielded a 404 download URL and the case name
"Kelleyv". Read the file name from the table cell instead, which is
intact either way. 5 files in the full archive are affected.
- A handful of names separate the docket with an underscore instead of a
hyphen ("25_1497USAv.KevinChristmas.mp3"). These produced an empty
docket number and leaked the digits into the case name
("25 1497USA v. Kevin Christmas"). 19 files are affected.
Also feed fix_camel_case each underscore delimited chunk separately; it
is a no-op on strings that already contain a space, so names like
"25-2762_Hartman_etalv.HonChudzik_etal.mp3" were left un-decamelized.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGFU84Ky5NwUyftfvVis5M
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HGFU84Ky5NwUyftfvVis5M
| docket_regex = re.compile(r"\d{2}[-_]+\d{3,4}") | ||
| # Multi part arguments get a "_a", "_b", ... suffix | ||
| part_regex = re.compile(r"_[a-z0-9]$", re.I) | ||
| et_al_regex = re.compile(r"[,\s]*et\.?\s?al\.?", re.I) |
There was a problem hiding this comment.
🔴 et_al_regex = re.compile(r"[,\s]*et\.?\s?al\.?", re.I) has no word boundary, so it matches the bare substring "etal" anywhere in a name, not just the standalone "et al." token. Case names containing words like Metal, Fetal, or Petaluma (e.g. "United States v. Sheet Metal Workers" or a case involving Petaluma) get silently truncated mid-word ("Metal" becomes "M", dropping "etal"). This new regex needs a word boundary or a required delimiter (comma/underscore/space) around the et al. token.
Extended reasoning...
et_al_regex = re.compile(r"[,\s]*et\.?\s?al\.?", re.I) is newly introduced by this PR as part of the parse_file_name rewrite in ca3.py. It runs via self.et_al_regex.sub(" ", ...) on the still-packed (camelCase/underscore) file-name chunk, before fix_camel_case splits words apart. The pattern has no \b word boundary and no requirement that it be preceded by a delimiter, so it matches the bare 4-character substring etal wherever it occurs — not just the intended standalone "et al." / "et al" token.
I verified this directly:
>>> et_al_regex.sub(' ', 'USAvMetalCorp')
'USAvM Corp'
>>> et_al_regex.sub(' ', 'SheetMetalWorkers')
'SheetM Workers'
>>> et_al_regex.sub(' ', 'USAvFetalCare')
'USAvF Care'
>>> et_al_regex.sub(' ', 'USAvPetaluma')
'USAvP uma'Walking through the first example step by step: the audio file name USAvMetalCorp.mp3 has no leading docket prefix, so parse_file_name falls into name = file_name = "USAvMetalCorp". That string is then passed through self.et_al_regex.sub(" ", self.part_regex.sub("", name)). The regex [,\s]*et\.?\s?al\.? matches the literal substring "etal" starting at index 6 (inside "Metal"), consuming M + nothing-before, i.e. it matches "etal" inside "Metal" and replaces it with a single space, turning "Metal" into "M ". After fix_camel_case/whitespace normalization this yields a case name like "USA v. M Corp" — silently dropping the real party name "Metal Corp" down to "M Corp".
This is realistic: "Sheet Metal Workers" is a common union litigant in federal courts, "In re ... Fetal ..." appears in medical/tort case names, and "Petaluma" (a California city) shows up in party names and case captions. None of these are contrived — they're the kind of party names CA3's oral-argument docket would plausibly include.
The PR's own test fixtures don't catch this because they only exercise the legitimate ,etal / _etal suffix forms (e.g. MarfiavGettysburgAreaSchoolDistrict,etal.mp3 → correctly stripped since the comma is consumed as part of the match), which happen to already have a delimiter before "et al" in the source data. There is no test case with an "etal"-containing word elsewhere in the name, so the over-matching goes undetected by the diff's own compare.json.
The fix is straightforward: anchor the match to require a word boundary or an explicit delimiter before "et" (e.g. r"[,_\s]+et\.?\s?al\.?\b" or wrap with \b...\b), so it only fires on the standalone "et al." token and not on substrings embedded in ordinary words.
| def _process_html(self): | ||
| for row in self.html.xpath("//table//tr[td]"): | ||
| links = row.xpath(".//a/@href") | ||
| dates = row.xpath("./td[2]/text()") | ||
| if not links or not dates: | ||
| logger.warning( | ||
| "ca3: skipping row without link or date: %s", | ||
| " ".join(row.text_content().split()), | ||
| ) | ||
| # Build the URL from the file name rather than the anchor's | ||
| # href: names containing an apostrophe ("Kelleyv.O'Malley.mp3") | ||
| # close the court's single quoted href early, so lxml sees a | ||
| # truncated URL. The cell's text is intact either way. | ||
| file_name = row.xpath("td[1]")[0].text_content().strip() | ||
| docket, name = self.parse_file_name(file_name) | ||
| if not name: | ||
| continue | ||
|
|
||
| url = links[0] | ||
| stem = unquote(url.split("/")[-1]).rsplit(".", 1)[0] | ||
| # Drop trailing re-upload markers, e.g. | ||
| # '24-3226_USAvHodges_a.mp3' is a re-upload of the same audio | ||
| # as '24-3226_USAvHodges.mp3' (see #2019). Both entries are | ||
| # ingested; stripping the marker keeps the case name clean | ||
| # and makes the duplicates easy to spot upstream | ||
| stem = re.sub(r"_[a-z]$", "", stem) | ||
|
|
||
| # Dockets are packed at the front of the name, usually | ||
| # underscore-separated, sometimes glued to the case name | ||
| leading_dockets = re.match(rf"(?:{self.docket_regex}[_&]*)+", stem) | ||
| if leading_dockets: | ||
| dockets = re.findall( | ||
| self.docket_regex, leading_dockets.group(0) | ||
| ) | ||
| name_str = stem[leading_dockets.end() :].strip("_ ") | ||
| else: | ||
| dockets = [] | ||
| name_str = stem | ||
|
|
||
| name = " ".join(fix_camel_case(name_str).replace("_", " ").split()) | ||
|
|
||
| self.cases.append( | ||
| { | ||
| "url": url, | ||
| "docket": ", ".join(dockets), | ||
| "url": self.base_url + quote(file_name), | ||
| "docket": docket, | ||
| "name": name, | ||
| # e.g. '6/26/2026 9:37:40 AM'; keep the date only | ||
| "date": dates[0].split()[0], | ||
| # The listing's second column is the file's upload | ||
| # timestamp; it is the only date the court exposes here | ||
| "date": row.xpath("td[2]/text()")[0].split()[0], | ||
| } | ||
| ) |
There was a problem hiding this comment.
🟡 The rewrite removed the old "if not links or not dates: continue" guard, so line 58's row.xpath("td[2]/text()")[0] will raise an unhandled IndexError and abort the entire scrape if any row has an empty date cell () or wraps the date in a child element instead of a direct text node. The outer filter //table//tr[td] only guarantees a first exists, not that td[2] has usable text, so it's worth restoring a skip-and-continue guard for that cell.
Extended reasoning...
What the bug is
_process_html used to guard against malformed rows before this PR:
dates = row.xpath("./td[2]/text()")
if not links or not dates:
logger.warning("ca3: skipping row without link or date: %s", ...)
continueThe rewrite in this PR drops that guard entirely. Line 58 now does:
"date": row.xpath("td[2]/text()")[0].split()[0],unconditionally, for every row matched by the outer loop for row in self.html.xpath("//table//tr[td]").
The code path that triggers it
The outer XPath predicate tr[td] only requires at least one <td> to exist somewhere in the row — it does not require a second <td>, and it does not require that <td> to contain a direct text node. td[1] is therefore always safe to index (line 47), but td[2]/text() is not: it returns lxml's empty list [] whenever
- the row has no second
<td>at all, or - the date cell is present but empty (
<td></td>), or - the date is wrapped in a child element, e.g.
<td><span>7/9/2026 3:19:26 PM</span></td>—text()only returns direct text nodes oftd, not descendant text.
In any of those cases, row.xpath("td[2]/text()")[0] raises IndexError, and since OralArgumentSiteLinear/AbstractSite.parse() calls _process_html() with no per-row error handling, that exception propagates out and aborts the entire scrape run — not just the one bad row.
Why existing code does not prevent it
Notably, td[1] (the file name) is read defensively via .text_content(), which walks all descendant text and would survive a child-wrapped value. td[2] (the date), by contrast, uses the fragile /text() axis with no fallback and no length check. This asymmetry — plus the fact that the pre-PR code explicitly had a guard for exactly this case — suggests the guard was dropped inadvertently during the rewrite rather than being an intentional simplification.
Impact
If the court ever emits a row with a still-uploading/empty date cell, or changes its markup to wrap the date in a <span> or similar (which is plausible — this exact PR exists because the court already changed its HTML layout once, from an RSS/.aspx feed to this HTML table), the scraper will crash entirely instead of degrading gracefully by skipping the one problem row. Every other case in that scrape run would be lost.
Step-by-step proof
- Court publishes a new row:
<tr><td><a href="...">25-9999_USAvExample.mp3</a></td><td></td></tr>(empty date cell — e.g., a file still being uploaded/processed). self.html.xpath("//table//tr[td]")matches this row because it has a<td>(in fact two).file_name = row.xpath("td[1]")[0].text_content().strip()succeeds →"25-9999_USAvExample.mp3".docket, name = self.parse_file_name(file_name)succeeds,nameis non-empty, so execution continues past theif not name: continuecheck.row.xpath("td[2]/text()")evaluates against the empty<td></td>and returns[].[][0]raisesIndexError: list index out of range.- No
try/exceptanywhere in_process_html,OralArgumentSiteLinear, orAbstractSite.parse()catches this, so the exception propagates all the way up and the scrape run terminates — even though every other row in the table was perfectly parseable.
How to fix
Restore a cheap guard before indexing, e.g.:
dates = row.xpath("td[2]/text()")
if not dates:
logger.warning("ca3: skipping row without date: %s", " ".join(row.text_content().split()))
continue
"date": dates[0].split()[0],Note on severity
Today's example HTML always has a populated, direct-text-node date cell in every row, so this does not fail on the data in this PR. It is a defensive-coding regression rather than a concrete failure today — but given this scraper's history of the court changing its markup, and that similar IndexError-from-dropped-guard regressions have repeatedly needed fixing elsewhere in this codebase (e.g. alaska, ky/kyctapp, neb per CHANGES.md), restoring the guard is cheap, low-risk hardening worth doing in this PR.
Fixes ca3, oral argument scraper.
Changed website.