Skip to content
This repository was archived by the owner on Jul 20, 2026. It is now read-only.

refactor(scrapers): blend court spiders onto shared bases + fix data-quality bugs - #89

Merged
damo-da merged 2 commits into
mainfrom
refactor/blend-court-spiders
Jun 26, 2026
Merged

refactor(scrapers): blend court spiders onto shared bases + fix data-quality bugs#89
damo-da merged 2 commits into
mainfrom
refactor/blend-court-spiders

Conversation

@damo-da

@damo-da damo-da commented Jun 26, 2026

Copy link
Copy Markdown
Member

Summary

Blends the 8 court spiders (4 *_court_cases listing + 4 *_case_enrichment) onto a shared base (ngm/ngscrape/base_spiders.py), removing ~1,350 lines of duplication (+1042/−2392 across the 8 spiders + models), while folding in correctness + data-quality fixes surfaced by a full code review and a live-DB audit. Adds the first-ever spider test suite.

Per-court HTML parsing stays in the subclasses; the base owns the shared skeleton: the date-range loop (AD→BS computed once — a big perf win for district's 77 courts), the per-row guard, bench accumulation + errback, and the single-transaction enrichment save + entity cleaning.

Correctness / data-quality fixes

Listing

  • Supreme: one malformed parties row no longer raises and discards the entire day (graceful fallback + per-row guard).
  • High/Special: bench detail requests now have an errback, so one failed bench can't strand the date forever or leak accumulated rows in memory.

Enrichment

  • The "no verdict yet" sentinel **** ** ** is no longer stored as the fake date ****-**-** (this had polluted ~140k district rows — see backfill).
  • CaseEntity rebuild now dedupes and strips header-label / placeholder / whitespace-padded names.
  • Supreme detail-link caseno parse is =-safe (parse_qs).
  • High: defendant panel parsed independently of the plaintiff panel; parsed addresses persisted.
  • High: label-match fix (portal labels carry a trailing . and use the स्थिती spelling) so registration_number / case_status populate again.
  • District: pending query uses .in_(codes) instead of a non-sargable LIKE '%dc'.
  • Check + update happen in one row-locked transaction (removes the per-case double-SELECT and the TOCTOU window).

Court orders

  • Transient FilesPipeline (FileException) download failures are no longer persisted as permanent orders_failed (retry counter, escalate only after N) — this stranded 13 decided cases that likely do have downloadable orders.
  • The spider no longer green-completes on a swallowed DB error: _get_cases_to_scrape() retries with a fresh connection and raises loudly if it can't recover (the silent except: return is why the CronJob reported Total: 0 for ~40 days).
  • get_engine() now uses pool_pre_ping=True.

Schema

Adds the 4 columns the enrichment code parses but which never existed, so the values were silently discarded: verdict_type, case_subject, hearing_count, enriched_at. create_all does not ALTER existing tables — the prod migration is scripts/migrate_add_enrichment_columns.sql and must run before this image deploys.

Tests

No spider/parser tests existed before. Adds tests/unit/ (small, real Devanagari examples) + tests/large/ (base machinery, enrichment save round-trip, orders failure classification) on an in-memory SQLite harness (tests/conftest.py, JSONB→JSON). 147 pass; scripts/format.sh --check (black + ruff) clean.

Follow-ups (not in this PR)

  • Run scripts/migrate_add_enrichment_columns.sql (columns + indexes) on ngm_v1 before deploy.
  • Apply scripts/backfill_data_quality.sql after review (verdict sentinel → NULL; HC fields recovered from extra_data, no re-scrape; supreme judge_names; release the 13 transient orders).
  • Infra: add libpq keepalives to the orders DATABASE_URL and restart the ngm-supreme-court-orders CronJob.

🤖 Generated with Claude Code

…quality bugs

Collapse the 8 court spiders (4 listing + 4 enrichment) onto
ngm/ngscrape/base_spiders.py (BaseScrapeSpider / BaseCourtCasesSpider /
BaseCaseEnrichmentSpider), removing ~1,350 lines of duplication. Per-court HTML
parsing stays in the subclasses; the base owns the date-range loop (AD->BS
computed once), the per-row guard, bench accumulation + errback, and the
single-transaction enrichment save + entity cleaning.

Bug fixes folded in:
- listing: a single malformed Supreme row no longer aborts the whole day
  (graceful parties fallback + per-row guard); high/special add a bench errback
  so one failed bench can't strand a date or leak memory; per-date BS->AD and
  "today" hoisted out of hot loops.
- enrichment: verdict sentinel '**** ** **' no longer stored as '****-**-**';
  entity dedup + junk/label/placeholder stripping; supreme caseno parse is
  '='-safe (parse_qs); high defendant panel parsed independently + address
  persisted; high label-match fix (trailing-dot / स्थिती spelling) so
  registration_number/case_status populate; district uses .in_() not LIKE '%dc';
  single locked transaction (no double-SELECT / TOCTOU).
- court orders: transient FilesPipeline (FileException) downloads are no longer
  marked permanent (retry counter, escalate after N); the spider no longer
  green-completes on a swallowed DB error (retry + reconnect, then raise);
  get_engine uses pool_pre_ping.

Schema: add the 4 enrichment columns the code parsed but silently discarded
(verdict_type, case_subject, hearing_count, enriched_at). create_all does not
ALTER existing tables -- see scripts/migrate_add_enrichment_columns.sql.

Tests: first-ever spider/parser tests (none existed) -- tests/unit (small, real
Devanagari values) + tests/large (base machinery, enrichment save round-trip,
orders failure classification). 147 pass; black + ruff clean.

Prepared (not auto-run) SQL: scripts/migrate_add_enrichment_columns.sql and
scripts/backfill_data_quality.sql.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@damo-da, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 25 minutes and 10 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b62dac84-5fbb-4aef-96a0-5dc6c36fa44f

📥 Commits

Reviewing files that changed from the base of the PR and between 8a6d4e1 and 0576c33.

📒 Files selected for processing (24)
  • ngm/database/models.py
  • ngm/ngscrape/base_spiders.py
  • ngm/ngscrape/pipelines.py
  • ngm/ngscrape/spiders/district_case_enrichment.py
  • ngm/ngscrape/spiders/district_court_cases.py
  • ngm/ngscrape/spiders/high_court_cases.py
  • ngm/ngscrape/spiders/high_court_enrichment.py
  • ngm/ngscrape/spiders/special_case_enrichment.py
  • ngm/ngscrape/spiders/special_court_cases.py
  • ngm/ngscrape/spiders/supreme_case_enrichment.py
  • ngm/ngscrape/spiders/supreme_court_cases.py
  • ngm/ngscrape/spiders/supreme_court_orders.py
  • scripts/backfill_data_quality.sql
  • scripts/migrate_add_enrichment_columns.sql
  • tests/conftest.py
  • tests/large/__init__.py
  • tests/large/test_base_machinery.py
  • tests/large/test_enrichment_save.py
  • tests/large/test_orders_pipeline.py
  • tests/unit/__init__.py
  • tests/unit/test_db_helpers.py
  • tests/unit/test_normalizer.py
  • tests/unit/test_parse_helpers.py
  • tests/unit/test_pipelines.py
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/blend-court-spiders

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request refactors the court-data scrapers by introducing shared base classes (BaseScrapeSpider, BaseCourtCasesSpider, and BaseCaseEnrichmentSpider) to reduce code duplication across district, high, special, and supreme court spiders. It also adds new enrichment columns to the database schema, implements a transient failure retry mechanism in the orders pipeline, and introduces comprehensive unit and integration tests. The code review feedback focuses on preventing potential connection and memory leaks in long-running Scrapy spiders by ensuring that SQLAlchemy sessions are explicitly closed after transactions, during early returns, and when resetting database connections.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread ngm/ngscrape/base_spiders.py Outdated
Comment on lines +220 to +226
def save_cases(self, data, court_key, date_bs, note=None):
"""Persist cases + hearings for a date and mark the date scraped."""
with self.session.begin():
for case, hearing in data:
self.session.merge(case)
self.session.add(hearing)
mark_date_scraped(self.session, court_key, date_bs, note)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

To prevent memory leaks and excessive memory accumulation in long-running Scrapy spiders, it is highly recommended to close the SQLAlchemy session after completing the transaction. Since self.session is reused across multiple dates, calling self.session.close() will clear the session's identity map and release the connection back to the pool, while subsequent operations will transparently open a new transaction/connection when needed.

Suggested change
def save_cases(self, data, court_key, date_bs, note=None):
"""Persist cases + hearings for a date and mark the date scraped."""
with self.session.begin():
for case, hearing in data:
self.session.merge(case)
self.session.add(hearing)
mark_date_scraped(self.session, court_key, date_bs, note)
def save_cases(self, data, court_key, date_bs, note=None):
"""Persist cases + hearings for a date and mark the date scraped."""
try:
with self.session.begin():
for case, hearing in data:
self.session.merge(case)
self.session.add(hearing)
mark_date_scraped(self.session, court_key, date_bs, note)
finally:
self.session.close()

Comment on lines +339 to +378
def save_enrichment(
self,
case_number,
court_identifier,
core_fields,
extra_updates,
entities,
):
"""Apply parsed enrichment in a single locked transaction.

Returns ``True`` if the row is in the enriched state afterwards (saved now
or already enriched by a concurrent worker), ``False`` only if the case is
missing from the DB.
"""
now = self._now_ktm()
with self.session.begin():
case = self._get_case(case_number, court_identifier, lock=True)
if not case:
self.logger.error(f"Case {case_number} not found for enrichment")
return False

if case.status == "enriched":
self.logger.info(f"Case {case_number} already enriched, skipping")
return True

for key, value in core_fields.items():
setattr(case, key, value)

if case.extra_data is None:
case.extra_data = {}
case.extra_data.update(extra_updates)
flag_modified(case, "extra_data")

case.status = "enriched"
case.enriched_at = now
case.updated_at = now

self._replace_entities(case_number, court_identifier, entities, now)
return True

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Similar to save_cases, save_enrichment is called repeatedly across thousands of cases. To avoid memory leaks from the accumulation of CourtCase and CaseEntity objects in the SQLAlchemy session's identity map, ensure the session is closed at the end of the transaction.

    def save_enrichment(
        self,
        case_number,
        court_identifier,
        core_fields,
        extra_updates,
        entities,
    ):
        """Apply parsed enrichment in a single locked transaction.

        Returns ``True`` if the row is in the enriched state afterwards (saved now
        or already enriched by a concurrent worker), ``False`` only if the case is
        missing from the DB.
        """
        now = self._now_ktm()
        try:
            with self.session.begin():
                case = self._get_case(case_number, court_identifier, lock=True)
                if not case:
                    self.logger.error(f"Case {case_number} not found for enrichment")
                    return False

                if case.status == "enriched":
                    self.logger.info(f"Case {case_number} already enriched, skipping")
                    return True

                for key, value in core_fields.items():
                    setattr(case, key, value)

                if case.extra_data is None:
                    case.extra_data = {}
                case.extra_data.update(extra_updates)
                flag_modified(case, "extra_data")

                case.status = "enriched"
                case.enriched_at = now
                case.updated_at = now

                self._replace_entities(case_number, court_identifier, entities, now)
                return True
        finally:
            self.session.close()

Comment thread ngm/ngscrape/pipelines.py
Comment on lines +518 to +571
def _mark_transient(self, spider, case_number, court_identifier, error):
"""Transient failure (download/S3/timeout) — NON-terminal.

Does not set orders_failed, so the selection query still re-picks the
case next run. Tracks a retry counter and only escalates to a permanent
failure after MAX_TRANSIENT_RETRIES so a genuinely-dead URL eventually
stops being re-queued.
"""
try:
with self.session.begin():
case = (
self.session.query(CourtCase)
.filter_by(
case_number=case_number, court_identifier=court_identifier
)
.first()
)
if not case:
spider.logger.error(
f"[{case_number}] Not in DB. Cannot mark transient."
)
return
if case.extra_data is None:
case.extra_data = {}

retries = int(case.extra_data.get("orders_transient_retries", 0)) + 1
case.extra_data["orders_transient_retries"] = retries
case.extra_data["orders_transient_error"] = error
case.extra_data["orders_transient_at"] = self._now_iso()
# A transient path must never leave a stale permanent flag.
case.extra_data.pop("orders_failed", None)
case.extra_data.pop("orders_error", None)
case.extra_data.pop("orders_failed_at", None)

if retries >= self.MAX_TRANSIENT_RETRIES:
case.extra_data["orders_failed"] = True
case.extra_data["orders_error"] = (
f"transient_exhausted after {retries} retries: {error}"
)
case.extra_data["orders_failed_at"] = self._now_iso()
spider.logger.error(
f"[{case_number}] Transient retries exhausted ({retries}) "
"— marking permanent."
)
else:
spider.logger.warning(
f"[{case_number}] Transient download failure "
f"(retry {retries}/{self.MAX_TRANSIENT_RETRIES}) — "
"will retry next run."
)
flag_modified(case, "extra_data")
except Exception:
spider.logger.exception(f"[{case_number}] Error marking transient")
raise

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

To prevent connection and memory leaks in the SupremeCourtOrdersPipeline when handling transient errors, explicitly close the SQLAlchemy session in a finally block after the transaction completes.

    def _mark_transient(self, spider, case_number, court_identifier, error):
        """Transient failure (download/S3/timeout) — NON-terminal.

        Does not set orders_failed, so the selection query still re-picks the
        case next run. Tracks a retry counter and only escalates to a permanent
        failure after MAX_TRANSIENT_RETRIES so a genuinely-dead URL eventually
        stops being re-queued.
        """
        try:
            with self.session.begin():
                case = (
                    self.session.query(CourtCase)
                    .filter_by(
                        case_number=case_number, court_identifier=court_identifier
                    )
                    .first()
                )
                if not case:
                    spider.logger.error(
                        f"[{case_number}] Not in DB. Cannot mark transient."
                    )
                    return
                if case.extra_data is None:
                    case.extra_data = {}

                retries = int(case.extra_data.get("orders_transient_retries", 0)) + 1
                case.extra_data["orders_transient_retries"] = retries
                case.extra_data["orders_transient_error"] = error
                case.extra_data["orders_transient_at"] = self._now_iso()
                # A transient path must never leave a stale permanent flag.
                case.extra_data.pop("orders_failed", None)
                case.extra_data.pop("orders_error", None)
                case.extra_data.pop("orders_failed_at", None)

                if retries >= self.MAX_TRANSIENT_RETRIES:
                    case.extra_data["orders_failed"] = True
                    case.extra_data["orders_error"] = (
                        f"transient_exhausted after {retries} retries: {error}"
                    )
                    case.extra_data["orders_failed_at"] = self._now_iso()
                    spider.logger.error(
                        f"[{case_number}] Transient retries exhausted ({retries}) "
                        "— marking permanent."
                    )
                else:
                    spider.logger.warning(
                        f"[{case_number}] Transient download failure "
                        f"(retry {retries}/{self.MAX_TRANSIENT_RETRIES}) — "
                        "will retry next run."
                    )
                flag_modified(case, "extra_data")
        except Exception:
            spider.logger.exception(f"[{case_number}] Error marking transient")
            raise
        finally:
            self.session.close()

Comment on lines 149 to 164
if self.backfill_case_type:
# If a parallel worker already enriched this row, only backfill the
# missing case_type (don't rebuild entities/hearings).
with self.session.begin():
case = self._get_case(case_number, code_name, lock=True)
if case and case.status == "enriched":
if case.case_type:
return
case_type = enrichment_data.get("case_type")
if case_type:
case.case_type = case_type[:200]
case.updated_at = self._now_ktm()
self.logger.info(
f"Backfilled case_type for {case_number} ({code_name})"
)
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

When returning early during the backfill_case_type check, the SQLAlchemy session is left open with the loaded CourtCase object in its identity map. Wrap this block in a try...finally to ensure the session is closed and resources are freed, even on early returns.

Suggested change
if self.backfill_case_type:
# If a parallel worker already enriched this row, only backfill the
# missing case_type (don't rebuild entities/hearings).
with self.session.begin():
case = self._get_case(case_number, code_name, lock=True)
if case and case.status == "enriched":
if case.case_type:
return
case_type = enrichment_data.get("case_type")
if case_type:
case.case_type = case_type[:200]
case.updated_at = self._now_ktm()
self.logger.info(
f"Backfilled case_type for {case_number} ({code_name})"
)
return
if self.backfill_case_type:
# If a parallel worker already enriched this row, only backfill the
# missing case_type (don't rebuild entities/hearings).
try:
with self.session.begin():
case = self._get_case(case_number, code_name, lock=True)
if case and case.status == "enriched":
if case.case_type:
return
case_type = enrichment_data.get("case_type")
if case_type:
case.case_type = case_type[:200]
case.updated_at = self._now_ktm()
self.logger.info(
f"Backfilled case_type for {case_number} ({code_name})"
)
return
finally:
self.session.close()

Comment on lines +355 to +359
try:
self.engine.dispose()
self.session = get_session(self.engine)
except Exception:
self.logger.exception("Failed to reset DB session before retry")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

When resetting the database connection during a retry attempt, the old self.session is abandoned without being closed. This can leak database connections or leave them in an active state on the server. Explicitly close the old session before disposing of the engine and creating a new session.

Suggested change
try:
self.engine.dispose()
self.session = get_session(self.engine)
except Exception:
self.logger.exception("Failed to reset DB session before retry")
try:
if hasattr(self, "session") and self.session:
self.session.close()
self.engine.dispose()
self.session = get_session(self.engine)
except Exception:
self.logger.exception("Failed to reset DB session before retry")

… on missing detail link

Addresses the PR review (Gemini + self-review):
- base save_cases / save_enrichment, pipeline _mark_transient, district
  backfill block, and the orders retry now close the SQLAlchemy session in a
  finally so the identity map / connections don't accumulate over a long crawl.
- correct the BaseCaseEnrichmentSpider docstring: re-enrichment fully replaces
  parties and does NOT preserve nes_id (the preservation was intentionally
  dropped — the docstring claimed otherwise).
- supreme enrichment: a missing detail-link / caseno no longer permanently
  marks the case failed (restores the original "leave pending, retry" behavior;
  the mark_failed was an unintended behavior change).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@damo-da

damo-da commented Jun 26, 2026

Copy link
Copy Markdown
Member Author

Addressed review + ran a prod-safe dry run

Review comments — fixed in 0576c33:

  • Gemini ×5 — close the SQLAlchemy session in a finally to avoid identity-map / connection accumulation on a long crawl: applied to save_cases, save_enrichment, _mark_transient, the district backfill_case_type block, and the orders retry path.
  • Self-review — corrected the BaseCaseEnrichmentSpider docstring (re-enrichment fully replaces parties; nes_id is intentionally not preserved), and reverted an unintended behavior change (supreme enrichment now leaves a case pending on a missing detail-link/caseno instead of permanently failing it — matching the original).

Dry run — local SQLite only, zero prod writes (hard-gated to refuse anything but sqlite://; the court spiders use no item pipelines, so the only outbound traffic is read-only GET/POST to the public portal):

  • Enrichment — 10 real sample cases across all 4 enrichment spiders enriched cleanly (supreme / special / kathmandudc / patanhc). Validated on real data: the new enriched_at + case_subject columns now persist, and entity dedup/cleaning works (e.g. special 082-CR-0161 → 1 plaintiff / 7 defendants). The portal returned a garbage BS date (2078-51-16, month 51) and the code degraded gracefully (→ NULL, no crash).
  • Listing — district (5,699 cases / 6,107 hearings over the sampled window), supreme (537 / 661 via the table-finding heuristics), and the special two-stage bench flow (saved per date once all benches resolved). High (patanhc) found its 21–22 benches and extracted per-bench cases but didn't reach the all-benches-saved point under an artificial CLOSESPIDER_PAGECOUNT=10 cutoff — expected.

No exceptions from spider code in either run, and the new session-close behavior was exercised under thousands of real saves without issue.

@damo-da
damo-da merged commit ff1bd59 into main Jun 26, 2026
4 checks passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant