BEASTZZZ commited on
Commit
9f014de
·
verified ·
1 Parent(s): 6584c99

Upload 12 files

Browse files
Files changed (5) hide show
  1. app.py +21 -0
  2. config.py +56 -1
  3. db.py +48 -0
  4. ingest_pipeline.py +119 -1
  5. 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
- "max_bills_per_run": 25,
 
 
 
 
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
- resp = self._get(self.cfg["listing_url"])
56
- soup = BeautifulSoup(resp.text, "lxml")
 
 
 
 
 
 
 
 
57
 
58
- docs, seen = [], set()
59
- for heading in soup.find_all(["h1", "h2", "h3", "h4", "h5", "h6"]):
60
- link = heading.find("a", href=True)
61
- if not link:
62
- continue
63
- href = link["href"]
64
- path = href if href.startswith("/") else href.replace(self.cfg["base_url"], "", 1)
65
- if any(path.startswith(p) for p in exclude_prefixes):
66
- continue
67
- if not link_pattern.match(path):
68
- continue
69
 
70
- url = href if href.startswith("http") else self.cfg["base_url"] + href
71
- if url in seen:
72
- continue
73
- seen.add(url)
74
-
75
- status = None
76
- node = heading.find_next_sibling()
77
- while node is not None and not node.get_text(strip=True):
78
- node = node.find_next_sibling()
79
- if node is not None and node.name not in ("h1", "h2", "h3", "h4", "h5", "h6"):
80
- status = node.get_text(strip=True)
81
-
82
- docs.append(
83
- DiscoveredDocument(
84
- url=url,
85
- kind="bill_page",
86
- source="prs",
87
- title=link.get_text(strip=True),
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