govbridge-api / scripts /ingest_tor_stealth.py
harshrawat18's picture
feat: ingestion fix
70bc888
Raw
History Blame Contribute Delete
17.2 kB
"""
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())