Upload 12 files
Browse files- app.py +21 -0
- config.py +56 -1
- db.py +48 -0
- ingest_pipeline.py +119 -1
- scraper.py +186 -34
app.py
CHANGED
|
@@ -169,10 +169,31 @@ def ingestion_status_fn(secret):
|
|
| 169 |
f"unchanged: {summary.get('unchanged', 0)}, "
|
| 170 |
f"skipped: {summary.get('skipped', 0)}, "
|
| 171 |
f"failed: {summary.get('failed', 0)}"
|
|
|
|
|
|
|
| 172 |
+ _tinyfish_status_line(summary)
|
| 173 |
)
|
| 174 |
|
| 175 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
def _tinyfish_status_line(summary) -> str:
|
| 177 |
by_src = summary.get("ingested_by_source") or {}
|
| 178 |
tf = summary.get("tinyfish") or {}
|
|
|
|
| 169 |
f"unchanged: {summary.get('unchanged', 0)}, "
|
| 170 |
f"skipped: {summary.get('skipped', 0)}, "
|
| 171 |
f"failed: {summary.get('failed', 0)}"
|
| 172 |
+
+ _pruned_status_line(summary)
|
| 173 |
+
+ _library_status_line(summary)
|
| 174 |
+ _tinyfish_status_line(summary)
|
| 175 |
)
|
| 176 |
|
| 177 |
|
| 178 |
+
def _pruned_status_line(summary) -> str:
|
| 179 |
+
pr = summary.get("pruned")
|
| 180 |
+
if not pr:
|
| 181 |
+
return ""
|
| 182 |
+
if "skipped" in pr or "error" in pr:
|
| 183 |
+
return "\nBill window: " + (pr.get("skipped") or pr.get("error"))
|
| 184 |
+
return (f"\nBill window: keeping newest {pr.get('kept', 0)} bills; "
|
| 185 |
+
f"removed {pr.get('bills_removed', 0)} older bills and {pr.get('pdfs_removed', 0)} PDFs")
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _library_status_line(summary) -> str:
|
| 189 |
+
lib = summary.get("library")
|
| 190 |
+
if not lib:
|
| 191 |
+
return ""
|
| 192 |
+
by = ", ".join(f"{k}: {v}" for k, v in sorted(lib.get("by_section", {}).items())) or "none new"
|
| 193 |
+
more = " — budget reached, run ingestion again to continue" if lib.get("more_remaining") else " — library up to date"
|
| 194 |
+
return f"\nPRS library: {lib.get('new_documents', 0)} new documents ({by}){more}"
|
| 195 |
+
|
| 196 |
+
|
| 197 |
def _tinyfish_status_line(summary) -> str:
|
| 198 |
by_src = summary.get("ingested_by_source") or {}
|
| 199 |
tf = summary.get("tinyfish") or {}
|
config.py
CHANGED
|
@@ -73,12 +73,67 @@ CONFIG = {
|
|
| 73 |
"bill_link_pattern": r"^/billtrack/[^/]+$",
|
| 74 |
"bill_link_exclude_prefixes": ("/billtrack/category/", "/billtrack/field_bill_category/"),
|
| 75 |
"rate_limit_seconds": 2,
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
"license_note": (
|
| 78 |
"PRS's disclaimer permits reproduction for non-commercial purposes "
|
| 79 |
"with due acknowledgement of PRS Legislative Research."
|
| 80 |
),
|
| 81 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
# Add more sources here as plain "seed URL lists" — the ingestion pipeline
|
| 83 |
# treats any HTTP(S) URL the same way: fetch, detect PDF vs HTML, extract
|
| 84 |
# full text, chunk, embed, store. See scraper.py:ArticleSource.
|
|
|
|
| 73 |
"bill_link_pattern": r"^/billtrack/[^/]+$",
|
| 74 |
"bill_link_exclude_prefixes": ("/billtrack/category/", "/billtrack/field_bill_category/"),
|
| 75 |
"rate_limit_seconds": 2,
|
| 76 |
+
# Rolling window: JanMat keeps the newest N bills (PRS lists newest first). Each ingestion
|
| 77 |
+
# adds any new bills and then deletes the oldest ones beyond N, with their PDFs and text.
|
| 78 |
+
"max_bills_kept": 25,
|
| 79 |
+
"listing_pages": 6, # how many ?page=N listing pages to read to find N bills
|
| 80 |
+
"delete_comments_with_bill": False, # True = also delete user comments on bills that rotate out
|
| 81 |
"license_note": (
|
| 82 |
"PRS's disclaimer permits reproduction for non-commercial purposes "
|
| 83 |
"with due acknowledgement of PRS Legislative Research."
|
| 84 |
),
|
| 85 |
},
|
| 86 |
+
# The rest of the PRS website (everything beyond the first page of bills).
|
| 87 |
+
# The crawler visits each section's index pages (following ?page=N pagination),
|
| 88 |
+
# collects links whose path matches `item_pattern`, and ingests each page plus
|
| 89 |
+
# any PDFs it links to. Add or remove sections freely.
|
| 90 |
+
"prs_library": {
|
| 91 |
+
"enabled": True,
|
| 92 |
+
# Each run ingests at most this many NEW documents, then stops. Documents
|
| 93 |
+
# already stored are skipped without being fetched, so just click
|
| 94 |
+
# "Run ingestion now" repeatedly and the library grows until it is complete.
|
| 95 |
+
# (Free Supabase = 500 MB; roughly 4 KB per chunk, ~100 chunks per document.)
|
| 96 |
+
"max_new_docs_per_run": 60,
|
| 97 |
+
"max_index_pages": 5, # pagination depth per index URL
|
| 98 |
+
"sections": [
|
| 99 |
+
{"key": "policy_review", "label": "Policy Reviews",
|
| 100 |
+
"index_urls": ["https://prsindia.org/policy/monthly-policy-review",
|
| 101 |
+
"https://prsindia.org/policy/annual-policy-review"],
|
| 102 |
+
"item_pattern": r"^/policy/(monthly|annual)-policy-review/[^/]+$"},
|
| 103 |
+
{"key": "committee_report", "label": "Committee Reports",
|
| 104 |
+
"index_urls": ["https://prsindia.org/policy/report-summaries",
|
| 105 |
+
"https://prsindia.org/parliamenttrack/report-summaries"],
|
| 106 |
+
"item_pattern": r"^/(policy|parliamenttrack)/report-summaries/[^/]+$"},
|
| 107 |
+
{"key": "session", "label": "Session Wraps",
|
| 108 |
+
"index_urls": ["https://prsindia.org/sessiontrack"],
|
| 109 |
+
"item_pattern": r"^/sessiontrack/[^/]+(/[^/]+)?$"},
|
| 110 |
+
{"key": "discussion_paper", "label": "Discussion Papers",
|
| 111 |
+
"index_urls": ["https://prsindia.org/policy/discussion-papers",
|
| 112 |
+
"https://prsindia.org/legislatures/discussion-papers",
|
| 113 |
+
"https://prsindia.org/budgets/discussionpapers"],
|
| 114 |
+
"item_pattern": r"^/(policy|legislatures|budgets)/(discussion-papers|discussionpapers)/[^/]+$"},
|
| 115 |
+
{"key": "blog", "label": "Blog",
|
| 116 |
+
"index_urls": ["https://prsindia.org/theprsblog"],
|
| 117 |
+
"item_pattern": r"^/theprsblog/(?!blog-author)[^/]+$"},
|
| 118 |
+
{"key": "article", "label": "Articles",
|
| 119 |
+
"index_urls": ["https://prsindia.org/articles-by-prs-team/2026",
|
| 120 |
+
"https://prsindia.org/articles-by-prs-team/2025",
|
| 121 |
+
"https://prsindia.org/articles-by-prs-team/2024"],
|
| 122 |
+
"item_pattern": r"^/articles-by-prs-team/(?!\d{4}$)[^/]+$"},
|
| 123 |
+
{"key": "act", "label": "Acts",
|
| 124 |
+
"index_urls": ["https://prsindia.org/acts/parliament", "https://prsindia.org/recent-acts"],
|
| 125 |
+
"item_pattern": r"^/(acts/[^/]+|recent-acts)/[^/]+$"},
|
| 126 |
+
{"key": "state_bill", "label": "State Bills",
|
| 127 |
+
"index_urls": ["https://prsindia.org/bills/states",
|
| 128 |
+
"https://prsindia.org/bills/state-legislative-briefs"],
|
| 129 |
+
"item_pattern": r"^/bills/(states|state-legislative-briefs)/[^/]+$"},
|
| 130 |
+
{"key": "budget", "label": "Budgets",
|
| 131 |
+
"index_urls": ["https://prsindia.org/budgets/parliament", "https://prsindia.org/budgets/states"],
|
| 132 |
+
"item_pattern": r"^/budgets/(parliament|states)/[^/]+$"},
|
| 133 |
+
],
|
| 134 |
+
# "Comments invited on ..." notices (bill name, deadline, link to submit).
|
| 135 |
+
"announcement_urls": ["https://prsindia.org/", "https://prsindia.org/announcements"],
|
| 136 |
+
},
|
| 137 |
# Add more sources here as plain "seed URL lists" — the ingestion pipeline
|
| 138 |
# treats any HTTP(S) URL the same way: fetch, detect PDF vs HTML, extract
|
| 139 |
# full text, chunk, embed, store. See scraper.py:ArticleSource.
|
db.py
CHANGED
|
@@ -119,6 +119,54 @@ def delete_chunks_for_document(document_id: int) -> None:
|
|
| 119 |
_raise_for_status(resp)
|
| 120 |
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
# --------------------------------------------------------------------- chunks
|
| 123 |
|
| 124 |
def insert_chunks(document_id: int, chunks: List[dict]) -> int:
|
|
|
|
| 119 |
_raise_for_status(resp)
|
| 120 |
|
| 121 |
|
| 122 |
+
def list_prs_bill_documents() -> List[dict]:
|
| 123 |
+
"""Every stored PRS bill page and bill PDF (id, url, kind, metadata), for the rolling window."""
|
| 124 |
+
rows, offset = [], 0
|
| 125 |
+
while True:
|
| 126 |
+
resp = requests.get(
|
| 127 |
+
f"{_base_url()}/documents",
|
| 128 |
+
headers=_headers(),
|
| 129 |
+
params={"source": "eq.prs", "kind": "in.(bill_page,bill_pdf)",
|
| 130 |
+
"select": "id,url,kind,metadata", "order": "id.asc", "limit": 1000, "offset": offset},
|
| 131 |
+
timeout=30,
|
| 132 |
+
)
|
| 133 |
+
_raise_for_status(resp)
|
| 134 |
+
batch = resp.json()
|
| 135 |
+
rows.extend(batch)
|
| 136 |
+
if len(batch) < 1000:
|
| 137 |
+
return rows
|
| 138 |
+
offset += 1000
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def delete_documents(ids: List[int]) -> int:
|
| 142 |
+
"""Delete documents by id. Their chunks go with them (ON DELETE CASCADE)."""
|
| 143 |
+
deleted = 0
|
| 144 |
+
for i in range(0, len(ids), 50):
|
| 145 |
+
batch = ids[i:i + 50]
|
| 146 |
+
resp = requests.delete(
|
| 147 |
+
f"{_base_url()}/documents",
|
| 148 |
+
headers=_headers(),
|
| 149 |
+
params={"id": f"in.({','.join(str(int(x)) for x in batch)})"},
|
| 150 |
+
timeout=60,
|
| 151 |
+
)
|
| 152 |
+
_raise_for_status(resp)
|
| 153 |
+
deleted += len(batch)
|
| 154 |
+
return deleted
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def delete_comments_for_topics(topic_keys: List[str]) -> None:
|
| 158 |
+
"""Delete user comments attached to the given topic keys (bill URLs)."""
|
| 159 |
+
for i in range(0, len(topic_keys), 20):
|
| 160 |
+
quoted = ",".join('"' + k.replace('"', '\\"') + '"' for k in topic_keys[i:i + 20])
|
| 161 |
+
resp = requests.delete(
|
| 162 |
+
f"{_base_url()}/comments",
|
| 163 |
+
headers=_headers(),
|
| 164 |
+
params={"topic_key": f"in.({quoted})"},
|
| 165 |
+
timeout=60,
|
| 166 |
+
)
|
| 167 |
+
_raise_for_status(resp)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
# --------------------------------------------------------------------- chunks
|
| 171 |
|
| 172 |
def insert_chunks(document_id: int, chunks: List[dict]) -> int:
|
ingest_pipeline.py
CHANGED
|
@@ -14,7 +14,7 @@ from config import CONFIG
|
|
| 14 |
from embeddings import chunk_pages, chunk_text, embed_texts
|
| 15 |
from pdf_ingest import ingest_source_document, BlockedHostError
|
| 16 |
import tinyfish_client
|
| 17 |
-
from scraper import (PRSDiscovery, DiscoveredDocument, discover_article_seed_urls,
|
| 18 |
discover_via_tinyfish_search, discover_comment_documents)
|
| 19 |
|
| 20 |
logger = logging.getLogger("janmat.ingest_pipeline")
|
|
@@ -23,6 +23,18 @@ logger = logging.getLogger("janmat.ingest_pipeline")
|
|
| 23 |
def _ingest_one(doc: DiscoveredDocument) -> dict:
|
| 24 |
"""Fetch, extract, chunk, embed, and store a single discovered document."""
|
| 25 |
result = {"url": doc.url, "status": "skipped", "chunks": 0, "source": doc.source}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
try:
|
| 27 |
if doc.text:
|
| 28 |
extracted = {"title": doc.title, "kind_hint": "html", "pages": [], "full_text": doc.text, "pdf_links": []}
|
|
@@ -165,6 +177,16 @@ def run_ingestion() -> dict:
|
|
| 165 |
processed_urls.add(article_doc.url)
|
| 166 |
_record(_ingest_one(article_doc))
|
| 167 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
# 3. TinyFish (optional): live-web discovery + structured comment extraction.
|
| 169 |
if tinyfish_client.enabled():
|
| 170 |
tf = CONFIG["tinyfish"]
|
|
@@ -187,3 +209,99 @@ def run_ingestion() -> dict:
|
|
| 187 |
db.finish_ingestion_run(run_id, datetime.now(timezone.utc).isoformat(), "failed", summary)
|
| 188 |
|
| 189 |
return summary
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
from embeddings import chunk_pages, chunk_text, embed_texts
|
| 15 |
from pdf_ingest import ingest_source_document, BlockedHostError
|
| 16 |
import tinyfish_client
|
| 17 |
+
from scraper import (PRSDiscovery, PRSLibraryCrawler, DiscoveredDocument, discover_article_seed_urls,
|
| 18 |
discover_via_tinyfish_search, discover_comment_documents)
|
| 19 |
|
| 20 |
logger = logging.getLogger("janmat.ingest_pipeline")
|
|
|
|
| 23 |
def _ingest_one(doc: DiscoveredDocument) -> dict:
|
| 24 |
"""Fetch, extract, chunk, embed, and store a single discovered document."""
|
| 25 |
result = {"url": doc.url, "status": "skipped", "chunks": 0, "source": doc.source}
|
| 26 |
+
|
| 27 |
+
# Archival PRS material (old reports, past reviews...) does not change. If it is already
|
| 28 |
+
# stored, skip it without fetching, so repeated runs only spend time on new documents.
|
| 29 |
+
if doc.metadata.get("archival"):
|
| 30 |
+
try:
|
| 31 |
+
stored = db.find_document_by_url(doc.url)
|
| 32 |
+
except Exception:
|
| 33 |
+
stored = None
|
| 34 |
+
if stored and stored.get("status") == "ingested":
|
| 35 |
+
result["status"] = "unchanged"
|
| 36 |
+
return result
|
| 37 |
+
|
| 38 |
try:
|
| 39 |
if doc.text:
|
| 40 |
extracted = {"title": doc.title, "kind_hint": "html", "pages": [], "full_text": doc.text, "pdf_links": []}
|
|
|
|
| 177 |
processed_urls.add(article_doc.url)
|
| 178 |
_record(_ingest_one(article_doc))
|
| 179 |
|
| 180 |
+
# 1b. Rolling window: drop bills older than the newest `max_bills_kept`.
|
| 181 |
+
_prune_old_bills([d.url for d in bill_pages], summary)
|
| 182 |
+
|
| 183 |
+
# 2b. The rest of PRS: policy reviews, committee reports, discussion papers, session wraps,
|
| 184 |
+
# blog, articles, acts, state bills, budgets, and "Comments invited" notices.
|
| 185 |
+
# Bounded per run; stored documents are skipped, so repeated runs finish the library.
|
| 186 |
+
lib_cfg = CONFIG.get("prs_library", {})
|
| 187 |
+
if lib_cfg.get("enabled"):
|
| 188 |
+
_crawl_prs_library(lib_cfg, processed_urls, _record, summary)
|
| 189 |
+
|
| 190 |
# 3. TinyFish (optional): live-web discovery + structured comment extraction.
|
| 191 |
if tinyfish_client.enabled():
|
| 192 |
tf = CONFIG["tinyfish"]
|
|
|
|
| 209 |
db.finish_ingestion_run(run_id, datetime.now(timezone.utc).isoformat(), "failed", summary)
|
| 210 |
|
| 211 |
return summary
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def _crawl_prs_library(lib_cfg: dict, processed_urls: set, record, summary: dict) -> None:
|
| 215 |
+
"""Ingest up to `max_new_docs_per_run` new documents from the PRS sections."""
|
| 216 |
+
budget = int(lib_cfg.get("max_new_docs_per_run", 60))
|
| 217 |
+
crawler = PRSLibraryCrawler(CONFIG["prs"], lib_cfg)
|
| 218 |
+
new_docs = 0
|
| 219 |
+
per_section = {}
|
| 220 |
+
|
| 221 |
+
def _docs():
|
| 222 |
+
yield from crawler.discover_announcements()
|
| 223 |
+
for section in lib_cfg.get("sections", []):
|
| 224 |
+
yield from crawler.discover_section(section)
|
| 225 |
+
|
| 226 |
+
try:
|
| 227 |
+
for doc in _docs():
|
| 228 |
+
if new_docs >= budget:
|
| 229 |
+
break
|
| 230 |
+
if doc.url in processed_urls:
|
| 231 |
+
continue
|
| 232 |
+
processed_urls.add(doc.url)
|
| 233 |
+
|
| 234 |
+
result = _ingest_one(doc)
|
| 235 |
+
record(result)
|
| 236 |
+
section = doc.metadata.get("section", "other")
|
| 237 |
+
if result["status"] == "ingested":
|
| 238 |
+
new_docs += 1
|
| 239 |
+
per_section[section] = per_section.get(section, 0) + 1
|
| 240 |
+
|
| 241 |
+
# Always finish a page's own PDFs even if the budget was just reached,
|
| 242 |
+
# otherwise they would never be found (the page is skipped next run).
|
| 243 |
+
for pdf_url in result.get("extra_pdf_links", []):
|
| 244 |
+
if pdf_url in processed_urls:
|
| 245 |
+
continue
|
| 246 |
+
processed_urls.add(pdf_url)
|
| 247 |
+
pdf_kind = "bill_pdf" if doc.kind == "bill_page" else "article"
|
| 248 |
+
pdf_doc = DiscoveredDocument(
|
| 249 |
+
url=pdf_url, kind=pdf_kind, source="prs", title=f"{doc.title} (PDF)",
|
| 250 |
+
metadata={**doc.metadata, "doc_type": "pdf", "parent_url": doc.url},
|
| 251 |
+
)
|
| 252 |
+
pr = _ingest_one(pdf_doc)
|
| 253 |
+
record(pr)
|
| 254 |
+
if pr["status"] == "ingested":
|
| 255 |
+
new_docs += 1
|
| 256 |
+
per_section[section] = per_section.get(section, 0) + 1
|
| 257 |
+
except Exception:
|
| 258 |
+
logger.exception("PRS library crawl stopped early")
|
| 259 |
+
summary["library_error"] = True
|
| 260 |
+
|
| 261 |
+
summary["library"] = {"new_documents": new_docs, "budget": budget,
|
| 262 |
+
"more_remaining": new_docs >= budget, "by_section": per_section}
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def _prune_old_bills(kept_urls: list, summary: dict) -> None:
|
| 266 |
+
"""
|
| 267 |
+
Keep only the newest bills. `kept_urls` is this run's listing (newest first). Anything stored
|
| 268 |
+
as a PRS bill (or a bill PDF) that is not in it gets deleted, text and embeddings included.
|
| 269 |
+
|
| 270 |
+
Safety: if PRS returned suspiciously few bills (site change, outage, blocked page), nothing is
|
| 271 |
+
deleted, so a bad scrape can never wipe the database.
|
| 272 |
+
"""
|
| 273 |
+
cfg = CONFIG["prs"]
|
| 274 |
+
keep = int(cfg.get("max_bills_kept") or 0)
|
| 275 |
+
if keep <= 0:
|
| 276 |
+
return
|
| 277 |
+
min_required = min(keep, 10)
|
| 278 |
+
if len(kept_urls) < min_required:
|
| 279 |
+
summary["pruned"] = {"skipped": f"PRS listing returned only {len(kept_urls)} bills; not deleting anything"}
|
| 280 |
+
logger.warning("Skipping bill pruning: listing returned %d bills (need at least %d)", len(kept_urls), min_required)
|
| 281 |
+
return
|
| 282 |
+
|
| 283 |
+
keep_set = set(kept_urls[:keep])
|
| 284 |
+
try:
|
| 285 |
+
stored = db.list_prs_bill_documents()
|
| 286 |
+
old_pages = [d for d in stored if d["kind"] == "bill_page" and d["url"] not in keep_set]
|
| 287 |
+
old_page_urls = {d["url"] for d in old_pages}
|
| 288 |
+
old_pdfs = []
|
| 289 |
+
for d in stored:
|
| 290 |
+
if d["kind"] != "bill_pdf":
|
| 291 |
+
continue
|
| 292 |
+
meta = d.get("metadata") or {}
|
| 293 |
+
parent = meta.get("parent_bill_url") or meta.get("parent_url")
|
| 294 |
+
if parent and (parent in old_page_urls or parent not in keep_set):
|
| 295 |
+
old_pdfs.append(d)
|
| 296 |
+
|
| 297 |
+
ids = [d["id"] for d in old_pages + old_pdfs]
|
| 298 |
+
if ids:
|
| 299 |
+
db.delete_documents(ids)
|
| 300 |
+
if cfg.get("delete_comments_with_bill") and old_page_urls:
|
| 301 |
+
db.delete_comments_for_topics(sorted(old_page_urls))
|
| 302 |
+
summary["pruned"] = {"bills_removed": len(old_pages), "pdfs_removed": len(old_pdfs), "kept": len(keep_set)}
|
| 303 |
+
if ids:
|
| 304 |
+
logger.info("Pruned %d old bills and %d PDFs; keeping the newest %d", len(old_pages), len(old_pdfs), len(keep_set))
|
| 305 |
+
except Exception:
|
| 306 |
+
logger.exception("Bill pruning failed (new bills were still ingested)")
|
| 307 |
+
summary["pruned"] = {"error": "pruning failed; see logs"}
|
scraper.py
CHANGED
|
@@ -49,47 +49,59 @@ class PRSDiscovery:
|
|
| 49 |
return resp
|
| 50 |
|
| 51 |
def discover_bill_pages(self) -> List[DiscoveredDocument]:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
link_pattern = re.compile(self.cfg["bill_link_pattern"])
|
| 53 |
exclude_prefixes = self.cfg["bill_link_exclude_prefixes"]
|
|
|
|
|
|
|
| 54 |
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
continue
|
| 69 |
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
metadata={"bill_status": status} if status else {},
|
| 89 |
)
|
| 90 |
-
)
|
|
|
|
| 91 |
|
| 92 |
-
limit = self.cfg.get("max_bills_per_run")
|
| 93 |
return docs[:limit] if limit else docs
|
| 94 |
|
| 95 |
def discover_pdfs_from_bill_page(self, bill_doc: DiscoveredDocument, pdf_links: List[str]) -> List[DiscoveredDocument]:
|
|
@@ -154,3 +166,143 @@ def discover_comment_documents(pages: List[DiscoveredDocument]) -> List[Discover
|
|
| 154 |
metadata={"parent_bill_url": page.url, "comment_count": len(comments)},
|
| 155 |
text=tinyfish_client.comments_to_text(page.title, comments)))
|
| 156 |
return docs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
return resp
|
| 50 |
|
| 51 |
def discover_bill_pages(self) -> List[DiscoveredDocument]:
|
| 52 |
+
"""
|
| 53 |
+
The newest `max_bills_kept` bills, in the order PRS lists them (newest first).
|
| 54 |
+
Reads ?page=N listing pages until enough bills are found.
|
| 55 |
+
"""
|
| 56 |
link_pattern = re.compile(self.cfg["bill_link_pattern"])
|
| 57 |
exclude_prefixes = self.cfg["bill_link_exclude_prefixes"]
|
| 58 |
+
limit = self.cfg.get("max_bills_kept") or self.cfg.get("max_bills_per_run")
|
| 59 |
+
docs, seen = [], set()
|
| 60 |
|
| 61 |
+
for page_no in range(self.cfg.get("listing_pages", 6)):
|
| 62 |
+
page_url = self.cfg["listing_url"] if page_no == 0 else f"{self.cfg['listing_url']}?page={page_no}"
|
| 63 |
+
try:
|
| 64 |
+
resp = self._get(page_url)
|
| 65 |
+
except Exception:
|
| 66 |
+
if page_no == 0:
|
| 67 |
+
raise # first page failing is a real error; later pages just end the walk
|
| 68 |
+
break
|
| 69 |
+
soup = BeautifulSoup(resp.text, "lxml")
|
| 70 |
+
before = len(docs)
|
| 71 |
|
| 72 |
+
for heading in soup.find_all(["h1", "h2", "h3", "h4", "h5", "h6"]):
|
| 73 |
+
link = heading.find("a", href=True)
|
| 74 |
+
if not link:
|
| 75 |
+
continue
|
| 76 |
+
href = link["href"]
|
| 77 |
+
path = href if href.startswith("/") else href.replace(self.cfg["base_url"], "", 1)
|
| 78 |
+
if any(path.startswith(p) for p in exclude_prefixes):
|
| 79 |
+
continue
|
| 80 |
+
if not link_pattern.match(path):
|
| 81 |
+
continue
|
|
|
|
| 82 |
|
| 83 |
+
url = href if href.startswith("http") else self.cfg["base_url"] + href
|
| 84 |
+
if url in seen:
|
| 85 |
+
continue
|
| 86 |
+
seen.add(url)
|
| 87 |
+
|
| 88 |
+
status = None
|
| 89 |
+
node = heading.find_next_sibling()
|
| 90 |
+
while node is not None and not node.get_text(strip=True):
|
| 91 |
+
node = node.find_next_sibling()
|
| 92 |
+
if node is not None and node.name not in ("h1", "h2", "h3", "h4", "h5", "h6"):
|
| 93 |
+
status = node.get_text(strip=True)
|
| 94 |
+
|
| 95 |
+
docs.append(
|
| 96 |
+
DiscoveredDocument(
|
| 97 |
+
url=url, kind="bill_page", source="prs", title=link.get_text(strip=True),
|
| 98 |
+
metadata={"bill_status": status, "section": "bill", "section_label": "Bills"}
|
| 99 |
+
if status else {"section": "bill", "section_label": "Bills"},
|
| 100 |
+
)
|
|
|
|
| 101 |
)
|
| 102 |
+
if len(docs) >= (limit or 10**9) or len(docs) == before:
|
| 103 |
+
break
|
| 104 |
|
|
|
|
| 105 |
return docs[:limit] if limit else docs
|
| 106 |
|
| 107 |
def discover_pdfs_from_bill_page(self, bill_doc: DiscoveredDocument, pdf_links: List[str]) -> List[DiscoveredDocument]:
|
|
|
|
| 166 |
metadata={"parent_bill_url": page.url, "comment_count": len(comments)},
|
| 167 |
text=tinyfish_client.comments_to_text(page.title, comments)))
|
| 168 |
return docs
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
# ---------------------------------------------------------------------------
|
| 172 |
+
# Whole-site PRS crawler
|
| 173 |
+
# ---------------------------------------------------------------------------
|
| 174 |
+
import re as _re
|
| 175 |
+
from datetime import datetime as _dt
|
| 176 |
+
from typing import Iterator, Tuple
|
| 177 |
+
from urllib.parse import urljoin, urlparse
|
| 178 |
+
|
| 179 |
+
_MD_LINK_RE = _re.compile(r"\[([^\]]*)\]\((https?://[^)\s]+)\)")
|
| 180 |
+
_DEADLINE_RE = _re.compile(
|
| 181 |
+
r"^(?P<title>.+?)\s*Deadline for submission:\s*(?P<date>[A-Z][a-z]{2,8} \d{1,2}, \d{4})\s*\|?\s*$", _re.S
|
| 182 |
+
)
|
| 183 |
+
_PRS_HOSTS = {"prsindia.org", "www.prsindia.org"}
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def _clean(text: str) -> str:
|
| 187 |
+
return _re.sub(r"\s+", " ", text or "").strip()
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
class PRSLibraryCrawler:
|
| 191 |
+
"""
|
| 192 |
+
Crawls the PRS sections listed in CONFIG["prs_library"] and yields
|
| 193 |
+
DiscoveredDocuments (pages and PDFs) tagged with their section, so the website
|
| 194 |
+
can browse them by section. It never relies on page-specific HTML: it just
|
| 195 |
+
follows links whose *path* matches the section's pattern, so a redesign of an
|
| 196 |
+
index page does not break it, and a changed URL scheme is a one-line config edit.
|
| 197 |
+
|
| 198 |
+
If PRS refuses a direct request, the page is re-fetched through TinyFish Fetch
|
| 199 |
+
(a real browser) and its links are read from the returned markdown.
|
| 200 |
+
"""
|
| 201 |
+
|
| 202 |
+
def __init__(self, prs_cfg: dict, lib_cfg: dict):
|
| 203 |
+
self.prs_cfg = prs_cfg
|
| 204 |
+
self.cfg = lib_cfg
|
| 205 |
+
self.delay = prs_cfg.get("rate_limit_seconds", 2)
|
| 206 |
+
self.session = requests.Session()
|
| 207 |
+
self.session.headers.update({"User-Agent": USER_AGENT})
|
| 208 |
+
|
| 209 |
+
# -- fetching -----------------------------------------------------------
|
| 210 |
+
def _links(self, url: str) -> List[Tuple[str, str]]:
|
| 211 |
+
"""All (absolute_url, anchor_text) links on a page. Empty list on failure."""
|
| 212 |
+
try:
|
| 213 |
+
resp = self.session.get(url, timeout=20)
|
| 214 |
+
resp.raise_for_status()
|
| 215 |
+
time.sleep(self.delay)
|
| 216 |
+
soup = BeautifulSoup(resp.text, "lxml")
|
| 217 |
+
return [(urljoin(url, a["href"]), _clean(a.get_text(" "))) for a in soup.find_all("a", href=True)]
|
| 218 |
+
except Exception as exc:
|
| 219 |
+
logger.info("Direct fetch of %s failed (%s); trying TinyFish Fetch", url, exc)
|
| 220 |
+
try:
|
| 221 |
+
page = tinyfish_client.fetch_text(url)
|
| 222 |
+
return [(href, _clean(text)) for text, href in _MD_LINK_RE.findall(page["text"])]
|
| 223 |
+
except Exception as exc:
|
| 224 |
+
logger.warning("Could not read index page %s: %s", url, exc)
|
| 225 |
+
return []
|
| 226 |
+
|
| 227 |
+
@staticmethod
|
| 228 |
+
def _normalise(url: str) -> Optional[str]:
|
| 229 |
+
"""Drop query/fragment; keep only prsindia.org (English site)."""
|
| 230 |
+
p = urlparse(url)
|
| 231 |
+
if p.scheme not in ("http", "https") or p.netloc.lower() not in _PRS_HOSTS:
|
| 232 |
+
return None
|
| 233 |
+
return f"https://prsindia.org{p.path}".rstrip("/") if p.path not in ("", "/") else None
|
| 234 |
+
|
| 235 |
+
# -- sections -----------------------------------------------------------
|
| 236 |
+
def discover_section(self, section: dict) -> Iterator[DiscoveredDocument]:
|
| 237 |
+
pattern = _re.compile(section["item_pattern"])
|
| 238 |
+
seen = set()
|
| 239 |
+
max_pages = section.get("max_index_pages", self.cfg.get("max_index_pages", 5))
|
| 240 |
+
page_kind = section.get("kind", "article")
|
| 241 |
+
pdf_kind = section.get("pdf_kind", "article")
|
| 242 |
+
base_meta = {
|
| 243 |
+
"section": section["key"],
|
| 244 |
+
"section_label": section["label"],
|
| 245 |
+
"archival": section.get("archival", True),
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
for index_url in section["index_urls"]:
|
| 249 |
+
for page_no in range(max_pages):
|
| 250 |
+
page_url = index_url if page_no == 0 else f"{index_url}?page={page_no}"
|
| 251 |
+
new_on_page = 0
|
| 252 |
+
for href, text in self._links(page_url):
|
| 253 |
+
is_pdf = urlparse(href).path.lower().endswith(".pdf") and "/files/" in href
|
| 254 |
+
if is_pdf and urlparse(href).netloc.lower() in _PRS_HOSTS:
|
| 255 |
+
url, kind, doc_type = href.split("#")[0], pdf_kind, "pdf"
|
| 256 |
+
else:
|
| 257 |
+
url = self._normalise(href)
|
| 258 |
+
if not url or not pattern.match(urlparse(url).path):
|
| 259 |
+
continue
|
| 260 |
+
kind, doc_type = page_kind, "page"
|
| 261 |
+
if url in seen:
|
| 262 |
+
continue
|
| 263 |
+
seen.add(url)
|
| 264 |
+
new_on_page += 1
|
| 265 |
+
title = text or urlparse(url).path.rsplit("/", 1)[-1].replace("-", " ").title()
|
| 266 |
+
yield DiscoveredDocument(url=url, kind=kind, source="prs", title=title,
|
| 267 |
+
metadata={**base_meta, "doc_type": doc_type})
|
| 268 |
+
if new_on_page == 0: # ran past the last page of this index
|
| 269 |
+
break
|
| 270 |
+
|
| 271 |
+
# -- "Comments invited on ..." notices ------------------------------------
|
| 272 |
+
def discover_announcements(self) -> List[DiscoveredDocument]:
|
| 273 |
+
docs, seen = [], set()
|
| 274 |
+
for page_url in self.cfg.get("announcement_urls", []):
|
| 275 |
+
for href, text in self._links(page_url):
|
| 276 |
+
m = _DEADLINE_RE.match(text)
|
| 277 |
+
if not m:
|
| 278 |
+
continue
|
| 279 |
+
title = _clean(m.group("title")).rstrip("|").strip()
|
| 280 |
+
try:
|
| 281 |
+
deadline = _dt.strptime(m.group("date"), "%b %d, %Y").date().isoformat()
|
| 282 |
+
except ValueError:
|
| 283 |
+
try:
|
| 284 |
+
deadline = _dt.strptime(m.group("date"), "%B %d, %Y").date().isoformat()
|
| 285 |
+
except ValueError:
|
| 286 |
+
deadline = None
|
| 287 |
+
key = (title, deadline)
|
| 288 |
+
if key in seen:
|
| 289 |
+
continue
|
| 290 |
+
seen.add(key)
|
| 291 |
+
slug = _re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")[:80]
|
| 292 |
+
text_body = (
|
| 293 |
+
f"Public comments are invited on: {title}.\n"
|
| 294 |
+
f"Deadline for submission: {m.group('date')}.\n"
|
| 295 |
+
f"Official notice and how to submit comments: {href}\n"
|
| 296 |
+
"Source: PRS Legislative Research announcements."
|
| 297 |
+
)
|
| 298 |
+
docs.append(DiscoveredDocument(
|
| 299 |
+
url=f"https://prsindia.org/announcements#{slug}",
|
| 300 |
+
kind="article", source="prs", title=f"Comments invited: {title}",
|
| 301 |
+
text=text_body,
|
| 302 |
+
metadata={"section": "announcement", "section_label": "Comments Invited",
|
| 303 |
+
"deadline": deadline, "notice_url": href, "archival": False, "doc_type": "notice"},
|
| 304 |
+
))
|
| 305 |
+
# The notice itself (PDF/page on a ministry site) also goes into the knowledge base.
|
| 306 |
+
docs.append(DiscoveredDocument(url=href, kind="article", source="prs", title=title,
|
| 307 |
+
metadata={"role": "announcement_notice", "archival": True}))
|
| 308 |
+
return docs
|