#!/usr/bin/env python3 """Re-crawl notice bodies with OCR and replace Supabase body values.""" from __future__ import annotations import argparse import re import sys import time from datetime import date, datetime from pathlib import Path from typing import Any try: from dotenv import load_dotenv load_dotenv() except ImportError: pass ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from crawling.crawler import get_post_content # noqa: E402 from crawling.supabase_store import SOURCE, _connect # noqa: E402 _CONTROL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]") def _parse_date(value: str) -> date: return datetime.strptime(value, "%Y-%m-%d").date() def _clean_body(body: str) -> str: return _CONTROL_RE.sub("", body) def _load_rows( start_date: date, end_date: date, hansung_only: bool, ids: list[int] | None, ) -> list[dict[str, Any]]: filters = [ "source = %s", "posted_at >= %s", "posted_at <= %s", ] params: list[Any] = [SOURCE, start_date, end_date] if hansung_only: filters.append("url like %s") params.append("https://www.hansung.ac.kr/%") if ids: filters.append("id = any(%s)") params.append(ids) sql = f""" select id, notice_id, title, url, posted_at, length(coalesce(body, '')) as old_body_len from notices where {' and '.join(filters)} order by posted_at asc nulls last, id asc """ with _connect() as conn: with conn.cursor() as cur: cur.execute(sql, params) rows = cur.fetchall() return [ { "id": row[0], "notice_id": row[1], "title": row[2], "url": row[3], "posted_at": row[4], "old_body_len": row[5], } for row in rows ] def _update_body(notice_id: int, body: str) -> None: body = _clean_body(body) with _connect() as conn: with conn.cursor() as cur: cur.execute( """ update notices set body = %s, raw = jsonb_set(coalesce(raw, '{}'::jsonb), '{body}', to_jsonb(%s::text), true), updated_at = now() where id = %s """, (body, body, notice_id), ) conn.commit() def backfill( start_date: date, end_date: date, *, hansung_only: bool, dry_run: bool, delay: float, limit: int | None, ids: list[int] | None, ) -> int: rows = _load_rows(start_date, end_date, hansung_only, ids) if limit is not None: rows = rows[:limit] print( f"Target notices: {len(rows)} " f"({start_date.isoformat()}..{end_date.isoformat()}, hansung_only={hansung_only})", flush=True, ) if dry_run: for row in rows[:20]: print( f"DRY {row['id']}\t{row['posted_at']}\t{row['notice_id']}\t" f"old_len={row['old_body_len']}\t{row['title'][:80]}", flush=True, ) if len(rows) > 20: print(f"... {len(rows) - 20} more", flush=True) return 0 updated = 0 skipped_empty = 0 failed = 0 for index, row in enumerate(rows, start=1): title = row["title"] or "" print( f"[{index}/{len(rows)}] id={row['id']} notice_id={row['notice_id']} " f"date={row['posted_at']} old_len={row['old_body_len']} {title[:70]}", flush=True, ) try: new_body = get_post_content(row["url"]) new_body = _clean_body(new_body or "") new_len = len(new_body or "") if not new_body: skipped_empty += 1 print(" skip: extracted empty body", flush=True) else: _update_body(row["id"], new_body) updated += 1 has_ocr = "[이미지 OCR]" in new_body or "[PDF OCR]" in new_body or "[첨부PDF]" in new_body print(f" updated: new_len={new_len} ocr_marker={has_ocr}", flush=True) except Exception as exc: failed += 1 print(f" failed: {type(exc).__name__}: {exc}", flush=True) if delay > 0: time.sleep(delay) print( f"Done. updated={updated}, skipped_empty={skipped_empty}, failed={failed}, total={len(rows)}", flush=True, ) return updated def main() -> None: parser = argparse.ArgumentParser( description="Backfill Supabase notice bodies by re-running crawler OCR.", ) parser.add_argument("--start-date", required=True, type=_parse_date) parser.add_argument("--end-date", required=True, type=_parse_date) parser.add_argument("--include-external", action="store_true", help="Include non-hansung URLs") parser.add_argument("--dry-run", action="store_true") parser.add_argument("--delay", type=float, default=0.2) parser.add_argument("--limit", type=int) parser.add_argument("--ids", nargs="+", type=int, help="Only process specific notices.id values") args = parser.parse_args() if args.end_date < args.start_date: raise ValueError("--end-date must be on or after --start-date") backfill( args.start_date, args.end_date, hansung_only=not args.include_external, dry_run=args.dry_run, delay=args.delay, limit=args.limit, ids=args.ids, ) if __name__ == "__main__": main()