Spaces:
Running
Running
| """ | |
| GovBridge India β World No. 1 Multi-Source Ingestion Engine (v4.0) | |
| =================================================================== | |
| STRATEGY: Instead of fragile Tor+Playwright scraping of a React SPA, | |
| we use MULTIPLE reliable data sources that return structured data: | |
| Source 1: MyScheme.gov.in Playwright (NO Tor β direct headless) | |
| Uses proper wait_for_selector, intercepts XHR API calls | |
| Source 2: PIB RSS Feed β government press releases | |
| Source 3: India.gov.in open data pages | |
| PROTECTIONS: | |
| - NEVER overwrites AI-enriched records (checks is_verified flag) | |
| - Logs every run to bot_run_log table | |
| - Deduplicates by title before upserting | |
| - All summaries must be >50 chars (no placeholders) | |
| Runs daily via GitHub Actions (02_data_ingestion_bot.yml). | |
| """ | |
| import os | |
| import sys | |
| import json | |
| import asyncio | |
| import hashlib | |
| import re | |
| import xml.etree.ElementTree as ET | |
| from datetime import datetime, timezone | |
| from typing import List, Optional, Dict | |
| from pydantic import BaseModel, Field | |
| import httpx | |
| from dotenv import load_dotenv | |
| load_dotenv('/workspaces/govbridge/.env') | |
| sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | |
| from config import settings | |
| try: | |
| from supabase import create_client, Client | |
| HAS_SUPABASE = True | |
| except ImportError: | |
| HAS_SUPABASE = False | |
| try: | |
| from playwright.async_api import async_playwright | |
| HAS_PLAYWRIGHT = True | |
| except ImportError: | |
| HAS_PLAYWRIGHT = False | |
| # --- DATA MODELS --- | |
| class SchemeRecord(BaseModel): | |
| title: str = Field(..., max_length=500) | |
| category: str = Field("General", max_length=100) | |
| ministry: str = Field("Government of India", max_length=300) | |
| summary: str = Field("", max_length=3000) | |
| benefits: str = Field("", max_length=3000) | |
| eligibility_text: str = Field("", max_length=3000) | |
| source_url: str = Field("", max_length=500) | |
| is_active: bool = True | |
| is_verified: bool = False | |
| class IngestionStats: | |
| def __init__(self): | |
| self.started_at = datetime.now(timezone.utc).isoformat() | |
| self.new_schemes = 0 | |
| self.skipped_enriched = 0 | |
| self.skipped_placeholder = 0 | |
| self.errors = 0 | |
| self.sources_used: List[str] = [] | |
| # --- MAIN ENGINE --- | |
| class MultiSourceIngestor: | |
| def __init__(self): | |
| if not HAS_SUPABASE: | |
| print("β supabase package not installed") | |
| sys.exit(1) | |
| self.sb: Client = create_client(settings.SUPABASE_URL, settings.SUPABASE_KEY) | |
| self.stats = IngestionStats() | |
| self.protected_titles: set = set() | |
| self.existing_titles: set = set() | |
| self.all_schemes: List[Dict] = [] | |
| async def run(self): | |
| print("=" * 60) | |
| print("π GovBridge Multi-Source Ingestion v4.0") | |
| print(" World No. 1 Civic Data Engine") | |
| print("=" * 60) | |
| # Load existing protected and all titles | |
| self._load_existing_data() | |
| # Run all sources | |
| await self._source_playwright_myscheme() | |
| await self._source_pib_rss() | |
| await self._source_india_gov() | |
| # Deduplicate and upsert | |
| self._deduplicate_and_upsert() | |
| # Log and summarize | |
| self._log_bot_run() | |
| self._print_summary() | |
| def _load_existing_data(self): | |
| """Load protected (AI-enriched) and all existing scheme titles.""" | |
| try: | |
| result = self.sb.table("schemes").select("title, is_verified").execute() | |
| for r in (result.data or []): | |
| self.existing_titles.add(r["title"]) | |
| if r.get("is_verified"): | |
| self.protected_titles.add(r["title"]) | |
| print(f"π‘οΈ {len(self.protected_titles)} AI-enriched schemes protected") | |
| print(f"π {len(self.existing_titles)} total schemes in database") | |
| except Exception as e: | |
| print(f"β οΈ Could not load existing data: {e}") | |
| # βββ SOURCE 1: MyScheme via Playwright (NO Tor) βββ | |
| async def _source_playwright_myscheme(self): | |
| """Scrape myscheme.gov.in using Playwright WITHOUT Tor. | |
| Uses networkidle to wait for React SPA data loading.""" | |
| if not HAS_PLAYWRIGHT: | |
| print("\nβ οΈ Playwright not installed β skipping MyScheme source") | |
| return | |
| print("\n" + "β" * 40) | |
| print("π‘ Source 1: MyScheme.gov.in (Playwright)") | |
| self.stats.sources_used.append("myscheme_playwright") | |
| try: | |
| async with async_playwright() as p: | |
| browser = await p.chromium.launch(headless=True) | |
| context = await browser.new_context( | |
| user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/126.0.0.0 Safari/537.36" | |
| ) | |
| page = await context.new_page() | |
| # Intercept XHR responses to capture API data | |
| captured_schemes = [] | |
| async def handle_response(response): | |
| """Intercept API responses from the MyScheme backend.""" | |
| url = response.url | |
| if any(kw in url for kw in ["scheme", "search", "api"]): | |
| try: | |
| if response.status == 200: | |
| ct = response.headers.get("content-type", "") | |
| if "json" in ct: | |
| data = await response.json() | |
| if isinstance(data, dict): | |
| # Check for scheme data in response | |
| schemes = data.get("data", data.get("schemes", data.get("results", []))) | |
| if isinstance(schemes, list): | |
| for s in schemes: | |
| if isinstance(s, dict) and s.get("schemeName") or s.get("title") or s.get("name"): | |
| captured_schemes.append(s) | |
| except Exception: | |
| pass | |
| page.on("response", handle_response) | |
| # Navigate to search page and wait for full JS load | |
| try: | |
| await page.goto( | |
| "https://www.myscheme.gov.in/search", | |
| timeout=60000, | |
| wait_until="networkidle" | |
| ) | |
| await asyncio.sleep(5) | |
| # Process XHR-intercepted schemes | |
| for s in captured_schemes: | |
| title = s.get("schemeName") or s.get("title") or s.get("name", "") | |
| if not title or len(title) < 5: | |
| continue | |
| if title in self.protected_titles: | |
| self.stats.skipped_enriched += 1 | |
| continue | |
| self.all_schemes.append(SchemeRecord( | |
| title=title, | |
| category=s.get("category", s.get("schemeCategory", "Central Government")), | |
| ministry=s.get("ministry", s.get("nodalMinistry", "Government of India")), | |
| summary=s.get("briefDescription", s.get("description", s.get("summary", ""))), | |
| benefits=s.get("benefits", s.get("benefit", "")), | |
| eligibility_text=s.get("eligibility", s.get("eligibilityCriteria", "")), | |
| source_url=s.get("schemeUrl", s.get("url", f"https://www.myscheme.gov.in/schemes/{title.lower().replace(' ', '-')}")), | |
| ).model_dump()) | |
| print(f" π XHR captures: {len(captured_schemes)}") | |
| except Exception as e: | |
| self.stats.errors += 1 | |
| print(f" β MyScheme navigation error: {e}") | |
| await browser.close() | |
| except Exception as e: | |
| self.stats.errors += 1 | |
| print(f" β Playwright error: {e}") | |
| # βββ SOURCE 2: PIB RSS Feed βββ | |
| async def _source_pib_rss(self): | |
| """Fetch Press Information Bureau RSS for government news.""" | |
| print("\n" + "β" * 40) | |
| print("π‘ Source 2: PIB RSS Feed") | |
| self.stats.sources_used.append("pib_rss") | |
| rss_urls = [ | |
| "https://pib.gov.in/RssMain.aspx?ModId=6&Lang=1&Regid=3", # English releases | |
| "https://pib.gov.in/RssMain.aspx", | |
| ] | |
| async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: | |
| for rss_url in rss_urls: | |
| try: | |
| response = await client.get(rss_url) | |
| if response.status_code != 200: | |
| continue | |
| # Parse RSS XML | |
| root = ET.fromstring(response.text) | |
| # Handle RSS 2.0 format | |
| ns = {"atom": "http://www.w3.org/2005/Atom"} | |
| items = root.findall(".//item") or root.findall(".//entry", ns) | |
| count = 0 | |
| for item in items[:20]: | |
| title_el = item.find("title") | |
| link_el = item.find("link") | |
| desc_el = item.find("description") | |
| if title_el is None or not title_el.text: | |
| continue | |
| title = f"PIB: {title_el.text.strip()}" | |
| if len(title) < 15: | |
| continue | |
| if title in self.protected_titles: | |
| self.stats.skipped_enriched += 1 | |
| continue | |
| link = link_el.text.strip() if link_el is not None and link_el.text else rss_url | |
| description = "" | |
| if desc_el is not None and desc_el.text: | |
| # Strip HTML tags from description | |
| description = re.sub(r'<[^>]+>', '', desc_el.text).strip() | |
| self.all_schemes.append(SchemeRecord( | |
| title=title[:500], | |
| category="Press Release", | |
| ministry="Press Information Bureau", | |
| summary=description[:3000] if description else "Official government press release from PIB.", | |
| source_url=link, | |
| ).model_dump()) | |
| count += 1 | |
| print(f" β Extracted {count} PIB items from {rss_url[:50]}") | |
| if count > 0: | |
| break # Got data from one feed, no need for fallback | |
| except Exception as e: | |
| print(f" β οΈ PIB RSS error: {e}") | |
| # βββ SOURCE 3: India.gov.in Scheme Pages βββ | |
| async def _source_india_gov(self): | |
| """Scrape scheme listings from india.gov.in (static HTML, very reliable).""" | |
| print("\n" + "β" * 40) | |
| print("π‘ Source 3: India.gov.in Scheme Listings") | |
| self.stats.sources_used.append("india_gov") | |
| urls = [ | |
| "https://www.india.gov.in/my-government/schemes", | |
| "https://www.india.gov.in/topics/agriculture", | |
| "https://www.india.gov.in/topics/education", | |
| "https://www.india.gov.in/topics/health-family-welfare", | |
| "https://www.india.gov.in/topics/rural", | |
| ] | |
| async with httpx.AsyncClient( | |
| timeout=30.0, | |
| follow_redirects=True, | |
| headers={"User-Agent": "GovBridge-Bot/4.0 (+https://govbridge.in)"} | |
| ) as client: | |
| for url in urls: | |
| try: | |
| response = await client.get(url) | |
| if response.status_code != 200: | |
| continue | |
| html = response.text | |
| # Extract scheme-like links and titles from static HTML | |
| # Pattern: <a href="...">Scheme Title</a> | |
| pattern = r'<a[^>]*href="([^"]*)"[^>]*>([^<]{15,200})</a>' | |
| matches = re.findall(pattern, html) | |
| count = 0 | |
| for href, text in matches: | |
| text = text.strip() | |
| # Filter for scheme-like content | |
| if any(skip in text.lower() for skip in [ | |
| "home", "contact", "about", "login", "sitemap", | |
| "skip to", "back to", "read more", "click here", | |
| "main content", "copyright", "privacy" | |
| ]): | |
| continue | |
| if text in self.protected_titles or text in self.existing_titles: | |
| continue | |
| full_url = href if href.startswith("http") else f"https://www.india.gov.in{href}" | |
| # Derive category from URL | |
| category = "Central Government" | |
| for cat_key in ["agriculture", "education", "health", "rural", "housing"]: | |
| if cat_key in url.lower(): | |
| category = cat_key.replace("-", " ").title() | |
| break | |
| self.all_schemes.append(SchemeRecord( | |
| title=text[:500], | |
| category=category, | |
| ministry="Government of India", | |
| summary=f"Government initiative listed on India.gov.in under {category}.", | |
| source_url=full_url, | |
| ).model_dump()) | |
| count += 1 | |
| if count > 0: | |
| print(f" β Found {count} items from {url.split('/')[-1]}") | |
| except Exception as e: | |
| print(f" β οΈ India.gov.in error for {url}: {e}") | |
| def _deduplicate_and_upsert(self): | |
| """Remove duplicates and upsert to database.""" | |
| print("\n" + "β" * 40) | |
| print("π Deduplicating and upserting...") | |
| # Deduplicate by title | |
| seen = set() | |
| unique = [] | |
| for scheme in self.all_schemes: | |
| title = scheme.get("title", "") | |
| if title not in seen and title not in self.protected_titles: | |
| seen.add(title) | |
| # Filter out placeholder-only records | |
| summary = scheme.get("summary", "") | |
| if len(summary) < 20: | |
| self.stats.skipped_placeholder += 1 | |
| continue | |
| scheme["updated_at"] = datetime.now(timezone.utc).isoformat() | |
| unique.append(scheme) | |
| print(f" Total collected: {len(self.all_schemes)}") | |
| print(f" After dedup: {len(unique)}") | |
| print(f" Skipped (enriched): {self.stats.skipped_enriched}") | |
| print(f" Skipped (placeholder): {self.stats.skipped_placeholder}") | |
| if unique: | |
| # Batch upsert in chunks of 50 | |
| for i in range(0, len(unique), 50): | |
| batch = unique[i:i + 50] | |
| try: | |
| self.sb.table("schemes").upsert( | |
| batch, on_conflict="title" | |
| ).execute() | |
| self.stats.new_schemes += len(batch) | |
| except Exception as e: | |
| self.stats.errors += 1 | |
| print(f" β Upsert batch error: {e}") | |
| print(f" β Upserted {self.stats.new_schemes} schemes") | |
| else: | |
| print(" βΉοΈ No new schemes to upsert") | |
| def _log_bot_run(self): | |
| log_entry = { | |
| "bot_name": "multi_source_ingestion_v4", | |
| "started_at": self.stats.started_at, | |
| "completed_at": datetime.now(timezone.utc).isoformat(), | |
| "new_records": self.stats.new_schemes, | |
| "updated_records": 0, | |
| "skipped_protected": self.stats.skipped_enriched, | |
| "errors": self.stats.errors, | |
| "categories_scraped": len(self.stats.sources_used), | |
| "status": "success" if self.stats.errors == 0 else "partial_success" | |
| } | |
| try: | |
| self.sb.table("bot_run_log").insert(log_entry).execute() | |
| print(f"\nπ Bot run logged to database") | |
| except Exception: | |
| print(f"\nπ Bot run log: {json.dumps(log_entry, indent=2)}") | |
| def _print_summary(self): | |
| print("\n" + "=" * 60) | |
| print("π INGESTION SUMMARY") | |
| print("=" * 60) | |
| print(f" Sources used: {', '.join(self.stats.sources_used)}") | |
| print(f" New schemes added: {self.stats.new_schemes}") | |
| print(f" Skipped (enriched): {self.stats.skipped_enriched}") | |
| print(f" Skipped (low qual): {self.stats.skipped_placeholder}") | |
| print(f" Errors: {self.stats.errors}") | |
| print("=" * 60) | |
| if __name__ == "__main__": | |
| ingestor = MultiSourceIngestor() | |
| asyncio.run(ingestor.run()) | |