Skip to content

Ca3 oa - #2057

Open
flooie wants to merge 2 commits into
mainfrom
ca3-oa
Open

Ca3 oa#2057
flooie wants to merge 2 commits into
mainfrom
ca3-oa

Conversation

@flooie

@flooie flooie commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Fixes ca3, oral argument scraper.

Changed website.

flooie and others added 2 commits July 29, 2026 15:23
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.

Comment on lines +41 to 60
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],
}
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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", ...)
    continue

The 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 of td, 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

  1. 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).
  2. self.html.xpath("//table//tr[td]") matches this row because it has a <td> (in fact two).
  3. file_name = row.xpath("td[1]")[0].text_content().strip() succeeds → "25-9999_USAvExample.mp3".
  4. docket, name = self.parse_file_name(file_name) succeeds, name is non-empty, so execution continues past the if not name: continue check.
  5. row.xpath("td[2]/text()") evaluates against the empty <td></td> and returns [].
  6. [][0] raises IndexError: list index out of range.
  7. No try/except anywhere in _process_html, OralArgumentSiteLinear, or AbstractSite.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.

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