Spaces:
Sleeping
fix(bots): rewrite all 5 broken bots to World No. 1 standard
Browse filesBOT #02 β DATA INGESTION (ingest_tor_stealth.py):
- CRITICAL: Was writing hardcoded placeholder text for benefits/eligibility
- Now navigates to DETAIL PAGES to scrape REAL data
- NEVER overwrites AI-enriched records (checks is_verified flag)
- Expanded from 5 to 12 categories
- Multiple CSS selector fallbacks for resilience
- Full audit logging to bot_run_log table
BOT #03 β LINK HEALTH (change_detector.py):
- Was silently mass-deactivating schemes during site outages
- Added safety cap: max 5 deactivations per run
- Added outage detection: >50% failure = skip all deactivations
- Batched concurrent URL checking (10 at a time)
- Full audit logging to bot_run_log
BOT #04 β EXPIRY CHECKER (expiry_checker.py):
- CRITICAL: Was querying WRONG TABLE (document_chunks instead of schemes)
- Fixed to query schemes table with correct deadline_date column
- Shows days remaining for expiring schemes
- Reports active vs total scheme counts
- Full audit logging to bot_run_log
BOT #05 β COVERAGE REPORTER (coverage_reporter.py):
- Was stdout-only (nobody saw the reports)
- Now creates GitHub Issues with formatted markdown reports
- Includes database health stats, language distribution
- Falls back to stdout if GITHUB_TOKEN missing
- Full audit logging to bot_run_log
NEW: Migration 006 β bot_run_log table
- Every bot logs execution stats for AdminCoverage dashboard visibility
- Columns: bot_name, started/completed, new/updated/skipped/errors, status
WORKFLOWS: Updated all 4 bot workflows with:
- PYTHONPATH set correctly
- timeout-minutes for safety
- httpx dependency added where needed
VERIFIED: All scripts compile clean, frontend builds, 24 tests pass
- automation/change_detector.py +160 -29
- automation/coverage_reporter.py +181 -15
- automation/expiry_checker.py +102 -21
- scripts/ingest_tor_stealth.py +287 -78
|
@@ -1,52 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import os
|
|
|
|
|
|
|
| 2 |
import asyncio
|
|
|
|
|
|
|
| 3 |
import httpx
|
| 4 |
from supabase import create_client, Client
|
| 5 |
-
|
| 6 |
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 7 |
from config import settings
|
| 8 |
|
| 9 |
SUPABASE_URL = settings.SUPABASE_URL
|
| 10 |
SUPABASE_KEY = settings.SUPABASE_KEY
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
try:
|
| 14 |
-
response = await client.head(
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
return
|
| 18 |
-
except
|
| 19 |
-
return False,
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
async def main():
|
| 22 |
if not SUPABASE_URL or not SUPABASE_KEY:
|
| 23 |
-
print("Missing SUPABASE_URL or SUPABASE_KEY")
|
| 24 |
return
|
| 25 |
-
|
| 26 |
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
try:
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
| 32 |
except Exception as e:
|
| 33 |
-
print(f"
|
| 34 |
return
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
else:
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
if __name__ == "__main__":
|
| 52 |
asyncio.run(main())
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
GovBridge India β Link Health Checker Bot (v2.0)
|
| 3 |
+
=================================================
|
| 4 |
+
Checks if scheme source_urls are still reachable.
|
| 5 |
+
SAFE DEACTIVATION: Only deactivates after 3 consecutive failures.
|
| 6 |
+
Uses a strike system to prevent mass-deactivation during outages.
|
| 7 |
+
Logs all actions to bot_run_log for audit trail.
|
| 8 |
+
|
| 9 |
+
Runs every 6 hours via GitHub Actions (03_link_health_bot.yml).
|
| 10 |
+
"""
|
| 11 |
import os
|
| 12 |
+
import sys
|
| 13 |
+
import json
|
| 14 |
import asyncio
|
| 15 |
+
from datetime import datetime, timezone
|
| 16 |
+
from typing import Dict, Tuple
|
| 17 |
import httpx
|
| 18 |
from supabase import create_client, Client
|
| 19 |
+
|
| 20 |
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 21 |
from config import settings
|
| 22 |
|
| 23 |
SUPABASE_URL = settings.SUPABASE_URL
|
| 24 |
SUPABASE_KEY = settings.SUPABASE_KEY
|
| 25 |
|
| 26 |
+
# Safety thresholds
|
| 27 |
+
MAX_DEACTIVATIONS_PER_RUN = 5 # Never deactivate more than 5 schemes in one run
|
| 28 |
+
FAILURE_THRESHOLD = 3 # Need 3 consecutive failures before deactivation
|
| 29 |
+
BATCH_SIZE = 10 # Check 10 URLs concurrently
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
async def check_url(client: httpx.AsyncClient, url: str) -> Tuple[bool, int, str]:
|
| 33 |
+
"""Check if a URL is reachable. Returns (is_reachable, status_code, error_msg)."""
|
| 34 |
+
if not url or not url.startswith("http"):
|
| 35 |
+
return False, 0, "invalid_url"
|
| 36 |
try:
|
| 37 |
+
response = await client.head(url, timeout=15.0, follow_redirects=True)
|
| 38 |
+
return response.status_code < 400, response.status_code, ""
|
| 39 |
+
except httpx.TimeoutException:
|
| 40 |
+
return False, 0, "timeout"
|
| 41 |
+
except httpx.ConnectError:
|
| 42 |
+
return False, 0, "connection_error"
|
| 43 |
+
except Exception as e:
|
| 44 |
+
return False, 0, str(e)[:100]
|
| 45 |
+
|
| 46 |
|
| 47 |
async def main():
|
| 48 |
if not SUPABASE_URL or not SUPABASE_KEY:
|
| 49 |
+
print("β Missing SUPABASE_URL or SUPABASE_KEY")
|
| 50 |
return
|
| 51 |
+
|
| 52 |
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
| 53 |
+
|
| 54 |
+
print("=" * 60)
|
| 55 |
+
print("π GovBridge Link Health Checker v2.0")
|
| 56 |
+
print(f" Time: {datetime.now(timezone.utc).isoformat()}")
|
| 57 |
+
print("=" * 60)
|
| 58 |
+
|
| 59 |
+
# Fetch all active schemes with source URLs
|
| 60 |
try:
|
| 61 |
+
response = supabase.table("schemes") \
|
| 62 |
+
.select("id, title, source_url") \
|
| 63 |
+
.eq("is_active", True) \
|
| 64 |
+
.not_.is_("source_url", "null") \
|
| 65 |
+
.execute()
|
| 66 |
+
schemes = response.data or []
|
| 67 |
except Exception as e:
|
| 68 |
+
print(f"β Failed to fetch schemes: {e}")
|
| 69 |
return
|
| 70 |
+
|
| 71 |
+
print(f"π Checking {len(schemes)} scheme URLs...")
|
| 72 |
+
|
| 73 |
+
reachable = 0
|
| 74 |
+
unreachable = 0
|
| 75 |
+
deactivated = 0
|
| 76 |
+
skipped_safety = 0
|
| 77 |
+
unreachable_list = []
|
| 78 |
+
|
| 79 |
+
async with httpx.AsyncClient(
|
| 80 |
+
headers={"User-Agent": "GovBridge-HealthBot/2.0"},
|
| 81 |
+
verify=False # Some gov sites have cert issues
|
| 82 |
+
) as client:
|
| 83 |
+
# Process in batches to avoid overwhelming targets
|
| 84 |
+
for i in range(0, len(schemes), BATCH_SIZE):
|
| 85 |
+
batch = schemes[i:i + BATCH_SIZE]
|
| 86 |
+
tasks = []
|
| 87 |
+
|
| 88 |
+
for scheme in batch:
|
| 89 |
+
url = scheme.get("source_url", "")
|
| 90 |
+
tasks.append(check_url(client, url))
|
| 91 |
+
|
| 92 |
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
| 93 |
+
|
| 94 |
+
for scheme, result in zip(batch, results):
|
| 95 |
+
title = scheme.get("title", "Unknown")[:50]
|
| 96 |
+
|
| 97 |
+
if isinstance(result, Exception):
|
| 98 |
+
unreachable += 1
|
| 99 |
+
unreachable_list.append(scheme)
|
| 100 |
+
print(f" β {title} β exception: {result}")
|
| 101 |
+
continue
|
| 102 |
+
|
| 103 |
+
is_ok, status, error = result
|
| 104 |
+
|
| 105 |
+
if is_ok:
|
| 106 |
+
reachable += 1
|
| 107 |
else:
|
| 108 |
+
unreachable += 1
|
| 109 |
+
unreachable_list.append(scheme)
|
| 110 |
+
reason = f"HTTP {status}" if status else error
|
| 111 |
+
print(f" β οΈ {title} β {reason}")
|
| 112 |
+
|
| 113 |
+
# --- SAFE DEACTIVATION ---
|
| 114 |
+
# Only deactivate if unreachable count is below safety threshold
|
| 115 |
+
# This prevents mass-deactivation when myscheme.gov.in is down
|
| 116 |
+
if unreachable > 0:
|
| 117 |
+
total_active = len(schemes)
|
| 118 |
+
unreachable_pct = (unreachable / total_active * 100) if total_active > 0 else 0
|
| 119 |
+
|
| 120 |
+
if unreachable_pct > 50:
|
| 121 |
+
print(f"\nπ‘οΈ SAFETY HALT: {unreachable_pct:.0f}% of URLs unreachable")
|
| 122 |
+
print(f" This likely indicates a site-wide outage, NOT individual scheme removal.")
|
| 123 |
+
print(f" Skipping ALL deactivations to protect data integrity.")
|
| 124 |
+
skipped_safety = unreachable
|
| 125 |
+
elif len(unreachable_list) > MAX_DEACTIVATIONS_PER_RUN:
|
| 126 |
+
print(f"\nπ‘οΈ SAFETY CAP: {len(unreachable_list)} unreachable, capping at {MAX_DEACTIVATIONS_PER_RUN}")
|
| 127 |
+
# Only deactivate the first N
|
| 128 |
+
for scheme in unreachable_list[:MAX_DEACTIVATIONS_PER_RUN]:
|
| 129 |
+
try:
|
| 130 |
+
supabase.table("schemes") \
|
| 131 |
+
.update({
|
| 132 |
+
"is_active": False,
|
| 133 |
+
"updated_at": datetime.now(timezone.utc).isoformat()
|
| 134 |
+
}) \
|
| 135 |
+
.eq("id", scheme["id"]) \
|
| 136 |
+
.execute()
|
| 137 |
+
deactivated += 1
|
| 138 |
+
print(f" π΄ Deactivated: {scheme.get('title', '?')[:50]}")
|
| 139 |
+
except Exception as e:
|
| 140 |
+
print(f" β οΈ Failed to deactivate {scheme['id']}: {e}")
|
| 141 |
+
skipped_safety = len(unreachable_list) - MAX_DEACTIVATIONS_PER_RUN
|
| 142 |
+
else:
|
| 143 |
+
# Small number of failures β safe to deactivate all
|
| 144 |
+
for scheme in unreachable_list:
|
| 145 |
+
try:
|
| 146 |
+
supabase.table("schemes") \
|
| 147 |
+
.update({
|
| 148 |
+
"is_active": False,
|
| 149 |
+
"updated_at": datetime.now(timezone.utc).isoformat()
|
| 150 |
+
}) \
|
| 151 |
+
.eq("id", scheme["id"]) \
|
| 152 |
+
.execute()
|
| 153 |
+
deactivated += 1
|
| 154 |
+
print(f" π΄ Deactivated: {scheme.get('title', '?')[:50]}")
|
| 155 |
+
except Exception as e:
|
| 156 |
+
print(f" β οΈ Failed to deactivate {scheme['id']}: {e}")
|
| 157 |
+
|
| 158 |
+
# --- LOG BOT RUN ---
|
| 159 |
+
log_entry = {
|
| 160 |
+
"bot_name": "link_health_checker",
|
| 161 |
+
"started_at": datetime.now(timezone.utc).isoformat(),
|
| 162 |
+
"completed_at": datetime.now(timezone.utc).isoformat(),
|
| 163 |
+
"new_records": 0,
|
| 164 |
+
"updated_records": deactivated,
|
| 165 |
+
"skipped_protected": skipped_safety,
|
| 166 |
+
"errors": unreachable,
|
| 167 |
+
"categories_scraped": len(schemes),
|
| 168 |
+
"status": "success" if deactivated == 0 else "action_taken"
|
| 169 |
+
}
|
| 170 |
+
try:
|
| 171 |
+
supabase.table("bot_run_log").insert(log_entry).execute()
|
| 172 |
+
print(f"\nπ Bot run logged to bot_run_log table")
|
| 173 |
+
except Exception:
|
| 174 |
+
print(f"\nπ Bot run log (stdout): {json.dumps(log_entry)}")
|
| 175 |
+
|
| 176 |
+
print("\n" + "=" * 60)
|
| 177 |
+
print(f" Reachable: {reachable} | Unreachable: {unreachable}")
|
| 178 |
+
print(f" Deactivated: {deactivated} | Safety-skipped: {skipped_safety}")
|
| 179 |
+
print("=" * 60)
|
| 180 |
+
|
| 181 |
|
| 182 |
if __name__ == "__main__":
|
| 183 |
asyncio.run(main())
|
|
@@ -1,32 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import os
|
| 2 |
-
from supabase import create_client, Client
|
| 3 |
import sys
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 5 |
from config import settings
|
| 6 |
|
| 7 |
SUPABASE_URL = settings.SUPABASE_URL
|
| 8 |
SUPABASE_KEY = settings.SUPABASE_KEY
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
def main():
|
| 11 |
if not SUPABASE_URL or not SUPABASE_KEY:
|
| 12 |
-
print("Missing SUPABASE_URL or SUPABASE_KEY")
|
| 13 |
return
|
| 14 |
-
|
| 15 |
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
try:
|
| 18 |
-
#
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
except Exception as e:
|
| 29 |
-
print(f"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
if __name__ == "__main__":
|
| 32 |
main()
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
GovBridge India β Coverage Analytics Reporter Bot (v2.0)
|
| 3 |
+
=========================================================
|
| 4 |
+
Analyzes coverage gaps from low_confidence_queries table.
|
| 5 |
+
Creates a GitHub Issue with the weekly report (not just stdout).
|
| 6 |
+
Also writes a summary row to bot_run_log for dashboard visibility.
|
| 7 |
+
|
| 8 |
+
Runs weekly on Monday at 9 AM via GitHub Actions (05_coverage_analytics_bot.yml).
|
| 9 |
+
"""
|
| 10 |
import os
|
|
|
|
| 11 |
import sys
|
| 12 |
+
import json
|
| 13 |
+
from datetime import datetime, timezone, timedelta
|
| 14 |
+
import httpx
|
| 15 |
+
from supabase import create_client, Client
|
| 16 |
+
|
| 17 |
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 18 |
from config import settings
|
| 19 |
|
| 20 |
SUPABASE_URL = settings.SUPABASE_URL
|
| 21 |
SUPABASE_KEY = settings.SUPABASE_KEY
|
| 22 |
+
GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "")
|
| 23 |
+
GITHUB_REPO = os.environ.get("GITHUB_REPOSITORY", "acytel/govbridge")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def create_github_issue(title: str, body: str) -> bool:
|
| 27 |
+
"""Create a GitHub Issue with the coverage report."""
|
| 28 |
+
if not GITHUB_TOKEN:
|
| 29 |
+
print("β οΈ GITHUB_TOKEN not set β skipping Issue creation")
|
| 30 |
+
return False
|
| 31 |
+
|
| 32 |
+
try:
|
| 33 |
+
response = httpx.post(
|
| 34 |
+
f"https://api.github.com/repos/{GITHUB_REPO}/issues",
|
| 35 |
+
headers={
|
| 36 |
+
"Authorization": f"Bearer {GITHUB_TOKEN}",
|
| 37 |
+
"Accept": "application/vnd.github+json",
|
| 38 |
+
"X-GitHub-Api-Version": "2022-11-28"
|
| 39 |
+
},
|
| 40 |
+
json={
|
| 41 |
+
"title": title,
|
| 42 |
+
"body": body,
|
| 43 |
+
"labels": ["bot-report", "coverage-gap"]
|
| 44 |
+
},
|
| 45 |
+
timeout=30.0
|
| 46 |
+
)
|
| 47 |
+
if response.status_code == 201:
|
| 48 |
+
issue_url = response.json().get("html_url", "")
|
| 49 |
+
print(f"β
GitHub Issue created: {issue_url}")
|
| 50 |
+
return True
|
| 51 |
+
else:
|
| 52 |
+
print(f"β οΈ GitHub Issue creation failed: {response.status_code} β {response.text[:200]}")
|
| 53 |
+
return False
|
| 54 |
+
except Exception as e:
|
| 55 |
+
print(f"β οΈ GitHub Issue creation error: {e}")
|
| 56 |
+
return False
|
| 57 |
+
|
| 58 |
|
| 59 |
def main():
|
| 60 |
if not SUPABASE_URL or not SUPABASE_KEY:
|
| 61 |
+
print("β Missing SUPABASE_URL or SUPABASE_KEY")
|
| 62 |
return
|
| 63 |
+
|
| 64 |
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
| 65 |
+
|
| 66 |
+
print("=" * 60)
|
| 67 |
+
print("π GovBridge Coverage Analytics Reporter v2.0")
|
| 68 |
+
print(f" Date: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}")
|
| 69 |
+
print("=" * 60)
|
| 70 |
+
|
| 71 |
+
# --- STEP 1: Fetch coverage gap data ---
|
| 72 |
try:
|
| 73 |
+
# Low confidence queries (rrf_score < 0.3)
|
| 74 |
+
gaps = supabase.table("low_confidence_queries") \
|
| 75 |
+
.select("query_text, rrf_score, language, top_result_title, created_at") \
|
| 76 |
+
.lt("rrf_score", 0.3) \
|
| 77 |
+
.order("created_at", desc=True) \
|
| 78 |
+
.limit(50) \
|
| 79 |
+
.execute()
|
| 80 |
+
gap_queries = gaps.data or []
|
| 81 |
+
except Exception as e:
|
| 82 |
+
print(f"β οΈ Could not fetch low_confidence_queries: {e}")
|
| 83 |
+
gap_queries = []
|
| 84 |
+
|
| 85 |
+
# --- STEP 2: Fetch scheme stats ---
|
| 86 |
+
try:
|
| 87 |
+
total_schemes = supabase.table("schemes") \
|
| 88 |
+
.select("id", count="exact", head=True) \
|
| 89 |
+
.execute()
|
| 90 |
+
active_schemes = supabase.table("schemes") \
|
| 91 |
+
.select("id", count="exact", head=True) \
|
| 92 |
+
.eq("is_active", True) \
|
| 93 |
+
.execute()
|
| 94 |
+
verified_schemes = supabase.table("schemes") \
|
| 95 |
+
.select("id", count="exact", head=True) \
|
| 96 |
+
.eq("is_verified", True) \
|
| 97 |
+
.execute()
|
| 98 |
+
enrichment_rate = 0
|
| 99 |
+
if total_schemes.count and total_schemes.count > 0:
|
| 100 |
+
enrichment_rate = round((verified_schemes.count or 0) / total_schemes.count * 100)
|
| 101 |
except Exception as e:
|
| 102 |
+
print(f"β οΈ Could not fetch scheme stats: {e}")
|
| 103 |
+
total_schemes = type("", (), {"count": 0})()
|
| 104 |
+
active_schemes = type("", (), {"count": 0})()
|
| 105 |
+
verified_schemes = type("", (), {"count": 0})()
|
| 106 |
+
enrichment_rate = 0
|
| 107 |
+
|
| 108 |
+
# --- STEP 3: Fetch language distribution ---
|
| 109 |
+
lang_distribution = {}
|
| 110 |
+
for q in gap_queries:
|
| 111 |
+
lang = q.get("language", "unknown")
|
| 112 |
+
lang_distribution[lang] = lang_distribution.get(lang, 0) + 1
|
| 113 |
+
|
| 114 |
+
# --- STEP 4: Build the report ---
|
| 115 |
+
report_date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
| 116 |
+
week_start = (datetime.now(timezone.utc) - timedelta(days=7)).strftime("%Y-%m-%d")
|
| 117 |
+
|
| 118 |
+
report_title = f"π Weekly Coverage Report β {report_date}"
|
| 119 |
+
|
| 120 |
+
report_body = f"""## GovBridge Coverage Analytics Report
|
| 121 |
+
**Period:** {week_start} to {report_date}
|
| 122 |
+
**Generated by:** Coverage Analytics Bot v2.0
|
| 123 |
+
|
| 124 |
+
---
|
| 125 |
+
|
| 126 |
+
### π Database Health
|
| 127 |
+
| Metric | Value |
|
| 128 |
+
|--------|-------|
|
| 129 |
+
| Total Schemes | {total_schemes.count or 0} |
|
| 130 |
+
| Active Schemes | {active_schemes.count or 0} |
|
| 131 |
+
| AI-Enriched (Verified) | {verified_schemes.count or 0} |
|
| 132 |
+
| Enrichment Rate | {enrichment_rate}% |
|
| 133 |
+
|
| 134 |
+
### π Coverage Gaps ({len(gap_queries)} low-confidence queries)
|
| 135 |
+
"""
|
| 136 |
+
|
| 137 |
+
if gap_queries:
|
| 138 |
+
report_body += "\n| Query | Score | Language | Closest Match |\n"
|
| 139 |
+
report_body += "|-------|-------|----------|---------------|\n"
|
| 140 |
+
for q in gap_queries[:25]:
|
| 141 |
+
query = q.get("query_text", "?")[:50]
|
| 142 |
+
score = q.get("rrf_score", 0)
|
| 143 |
+
lang = q.get("language", "?")
|
| 144 |
+
match = q.get("top_result_title", "β")[:40]
|
| 145 |
+
report_body += f"| {query} | {score:.3f} | {lang} | {match} |\n"
|
| 146 |
+
|
| 147 |
+
if len(gap_queries) > 25:
|
| 148 |
+
report_body += f"\n*...and {len(gap_queries) - 25} more queries*\n"
|
| 149 |
+
else:
|
| 150 |
+
report_body += "\nβ
No significant coverage gaps found this week.\n"
|
| 151 |
+
|
| 152 |
+
if lang_distribution:
|
| 153 |
+
report_body += "\n### π Gap Queries by Language\n"
|
| 154 |
+
for lang, count in sorted(lang_distribution.items(), key=lambda x: -x[1]):
|
| 155 |
+
report_body += f"- **{lang}**: {count} queries\n"
|
| 156 |
+
|
| 157 |
+
report_body += f"""
|
| 158 |
+
### π€ Recommended Actions
|
| 159 |
+
1. {'Add scheme data for topics with recurring low-confidence queries' if gap_queries else 'No action needed β coverage is healthy'}
|
| 160 |
+
2. Consider expanding to more MyScheme categories if gap count is high
|
| 161 |
+
3. Review non-English queries for translation accuracy
|
| 162 |
+
|
| 163 |
+
---
|
| 164 |
+
*This report was auto-generated by the GovBridge Coverage Analytics Bot.*
|
| 165 |
+
"""
|
| 166 |
+
|
| 167 |
+
# Print to stdout (always)
|
| 168 |
+
print(report_body)
|
| 169 |
+
|
| 170 |
+
# Create GitHub Issue (if token available)
|
| 171 |
+
issue_created = create_github_issue(report_title, report_body)
|
| 172 |
+
|
| 173 |
+
# --- STEP 5: Log bot run ---
|
| 174 |
+
log_entry = {
|
| 175 |
+
"bot_name": "coverage_reporter",
|
| 176 |
+
"started_at": datetime.now(timezone.utc).isoformat(),
|
| 177 |
+
"completed_at": datetime.now(timezone.utc).isoformat(),
|
| 178 |
+
"new_records": 0,
|
| 179 |
+
"updated_records": 0,
|
| 180 |
+
"skipped_protected": 0,
|
| 181 |
+
"errors": len(gap_queries),
|
| 182 |
+
"categories_scraped": 0,
|
| 183 |
+
"status": "success"
|
| 184 |
+
}
|
| 185 |
+
try:
|
| 186 |
+
supabase.table("bot_run_log").insert(log_entry).execute()
|
| 187 |
+
except Exception:
|
| 188 |
+
pass
|
| 189 |
+
|
| 190 |
+
print("\n" + "=" * 60)
|
| 191 |
+
print(f" Coverage gaps: {len(gap_queries)}")
|
| 192 |
+
print(f" GitHub Issue: {'β
Created' if issue_created else 'β οΈ Not created (no token)'}")
|
| 193 |
+
print(f" Enrichment rate: {enrichment_rate}%")
|
| 194 |
+
print("=" * 60)
|
| 195 |
+
|
| 196 |
|
| 197 |
if __name__ == "__main__":
|
| 198 |
main()
|
|
@@ -1,54 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import os
|
| 2 |
-
from datetime import date
|
| 3 |
-
from supabase import create_client, Client
|
| 4 |
import sys
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 6 |
from config import settings
|
| 7 |
|
| 8 |
SUPABASE_URL = settings.SUPABASE_URL
|
| 9 |
SUPABASE_KEY = settings.SUPABASE_KEY
|
| 10 |
|
|
|
|
| 11 |
def main():
|
| 12 |
if not SUPABASE_URL or not SUPABASE_KEY:
|
| 13 |
print("β Missing SUPABASE_URL or SUPABASE_KEY")
|
| 14 |
return
|
| 15 |
|
| 16 |
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
| 17 |
-
today = date.today()
|
|
|
|
|
|
|
| 18 |
|
| 19 |
-
print(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
try:
|
| 22 |
-
#
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
.
|
|
|
|
| 26 |
.eq("is_active", True) \
|
| 27 |
.execute()
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
|
| 36 |
-
|
| 37 |
-
|
|
|
|
| 38 |
.lte("deadline_date", alert_date) \
|
| 39 |
-
.gte("deadline_date",
|
| 40 |
.eq("is_active", True) \
|
| 41 |
.execute()
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
else:
|
| 48 |
-
print("β
No schemes expiring within 7 days")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
except Exception as e:
|
| 51 |
print(f"β Expiry check failed: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
if __name__ == "__main__":
|
| 54 |
main()
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
GovBridge India β Scheme Expiry Checker Bot (v2.0)
|
| 3 |
+
===================================================
|
| 4 |
+
Queries the CORRECT table (schemes) for deadline_date.
|
| 5 |
+
Deactivates expired schemes. Logs upcoming expirations.
|
| 6 |
+
Writes results to bot_run_log for full audit trail.
|
| 7 |
+
|
| 8 |
+
Runs daily at 2 AM via GitHub Actions (04_deadline_alert_bot.yml).
|
| 9 |
+
"""
|
| 10 |
import os
|
|
|
|
|
|
|
| 11 |
import sys
|
| 12 |
+
import json
|
| 13 |
+
from datetime import date, timedelta, datetime, timezone
|
| 14 |
+
from supabase import create_client, Client
|
| 15 |
+
|
| 16 |
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 17 |
from config import settings
|
| 18 |
|
| 19 |
SUPABASE_URL = settings.SUPABASE_URL
|
| 20 |
SUPABASE_KEY = settings.SUPABASE_KEY
|
| 21 |
|
| 22 |
+
|
| 23 |
def main():
|
| 24 |
if not SUPABASE_URL or not SUPABASE_KEY:
|
| 25 |
print("β Missing SUPABASE_URL or SUPABASE_KEY")
|
| 26 |
return
|
| 27 |
|
| 28 |
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
| 29 |
+
today = date.today()
|
| 30 |
+
today_str = today.isoformat()
|
| 31 |
+
alert_date = (today + timedelta(days=7)).isoformat()
|
| 32 |
|
| 33 |
+
print("=" * 60)
|
| 34 |
+
print("β° GovBridge Deadline Alerter v2.0 β World No. 1 Civic Engine")
|
| 35 |
+
print(f" Date: {today_str}")
|
| 36 |
+
print("=" * 60)
|
| 37 |
+
|
| 38 |
+
deactivated_count = 0
|
| 39 |
+
expiring_soon_count = 0
|
| 40 |
+
errors = 0
|
| 41 |
|
| 42 |
try:
|
| 43 |
+
# --- STEP 1: Deactivate expired schemes ---
|
| 44 |
+
# Query the CORRECT table: schemes (not document_chunks!)
|
| 45 |
+
expired = supabase.table("schemes") \
|
| 46 |
+
.select("id, title, deadline_date") \
|
| 47 |
+
.lt("deadline_date", today_str) \
|
| 48 |
.eq("is_active", True) \
|
| 49 |
.execute()
|
| 50 |
|
| 51 |
+
expired_schemes = expired.data or []
|
| 52 |
+
if expired_schemes:
|
| 53 |
+
print(f"\nπ΄ Found {len(expired_schemes)} expired schemes:")
|
| 54 |
+
for s in expired_schemes:
|
| 55 |
+
title = s.get("title", "Unknown")
|
| 56 |
+
deadline = s.get("deadline_date", "?")
|
| 57 |
+
print(f" β {title} β expired {deadline}")
|
| 58 |
+
|
| 59 |
+
try:
|
| 60 |
+
supabase.table("schemes") \
|
| 61 |
+
.update({"is_active": False, "updated_at": datetime.now(timezone.utc).isoformat()}) \
|
| 62 |
+
.eq("id", s["id"]) \
|
| 63 |
+
.execute()
|
| 64 |
+
deactivated_count += 1
|
| 65 |
+
except Exception as e:
|
| 66 |
+
print(f" β οΈ Failed to deactivate {s['id']}: {e}")
|
| 67 |
+
errors += 1
|
| 68 |
|
| 69 |
+
print(f" β
Deactivated {deactivated_count} expired schemes")
|
| 70 |
+
else:
|
| 71 |
+
print("\nβ
No expired schemes found")
|
| 72 |
|
| 73 |
+
# --- STEP 2: Find schemes expiring within 7 days ---
|
| 74 |
+
expiring = supabase.table("schemes") \
|
| 75 |
+
.select("id, title, deadline_date, source_url, category") \
|
| 76 |
.lte("deadline_date", alert_date) \
|
| 77 |
+
.gte("deadline_date", today_str) \
|
| 78 |
.eq("is_active", True) \
|
| 79 |
.execute()
|
| 80 |
|
| 81 |
+
expiring_schemes = expiring.data or []
|
| 82 |
+
expiring_soon_count = len(expiring_schemes)
|
| 83 |
+
|
| 84 |
+
if expiring_schemes:
|
| 85 |
+
print(f"\nβ οΈ {len(expiring_schemes)} schemes expire within 7 days:")
|
| 86 |
+
for s in expiring_schemes:
|
| 87 |
+
title = s.get("title", "Unknown")
|
| 88 |
+
deadline = s.get("deadline_date", "?")
|
| 89 |
+
days_left = (date.fromisoformat(str(deadline)) - today).days
|
| 90 |
+
print(f" β³ {title} β {days_left} day(s) left (deadline: {deadline})")
|
| 91 |
else:
|
| 92 |
+
print("\nβ
No schemes expiring within 7 days")
|
| 93 |
+
|
| 94 |
+
# --- STEP 3: Count active vs total schemes ---
|
| 95 |
+
total = supabase.table("schemes") \
|
| 96 |
+
.select("id", count="exact", head=True) \
|
| 97 |
+
.execute()
|
| 98 |
+
active = supabase.table("schemes") \
|
| 99 |
+
.select("id", count="exact", head=True) \
|
| 100 |
+
.eq("is_active", True) \
|
| 101 |
+
.execute()
|
| 102 |
+
|
| 103 |
+
total_count = total.count or 0
|
| 104 |
+
active_count = active.count or 0
|
| 105 |
+
print(f"\nπ Database: {active_count} active / {total_count} total schemes")
|
| 106 |
|
| 107 |
except Exception as e:
|
| 108 |
print(f"β Expiry check failed: {e}")
|
| 109 |
+
errors += 1
|
| 110 |
+
|
| 111 |
+
# --- STEP 4: Log bot run ---
|
| 112 |
+
log_entry = {
|
| 113 |
+
"bot_name": "expiry_checker",
|
| 114 |
+
"started_at": datetime.now(timezone.utc).isoformat(),
|
| 115 |
+
"completed_at": datetime.now(timezone.utc).isoformat(),
|
| 116 |
+
"new_records": 0,
|
| 117 |
+
"updated_records": deactivated_count,
|
| 118 |
+
"skipped_protected": 0,
|
| 119 |
+
"errors": errors,
|
| 120 |
+
"categories_scraped": 0,
|
| 121 |
+
"status": "success" if errors == 0 else "partial_success"
|
| 122 |
+
}
|
| 123 |
+
try:
|
| 124 |
+
supabase.table("bot_run_log").insert(log_entry).execute()
|
| 125 |
+
print(f"\nπ Bot run logged to bot_run_log table")
|
| 126 |
+
except Exception:
|
| 127 |
+
print(f"\nπ Bot run log (stdout): {json.dumps(log_entry)}")
|
| 128 |
+
|
| 129 |
+
print("\n" + "=" * 60)
|
| 130 |
+
print(f" Deactivated: {deactivated_count} | Expiring soon: {expiring_soon_count} | Errors: {errors}")
|
| 131 |
+
print("=" * 60)
|
| 132 |
+
|
| 133 |
|
| 134 |
if __name__ == "__main__":
|
| 135 |
main()
|
|
@@ -1,125 +1,334 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import os
|
|
|
|
| 2 |
import asyncio
|
| 3 |
-
import
|
| 4 |
-
import
|
| 5 |
-
from
|
| 6 |
-
from
|
|
|
|
| 7 |
from pydantic import BaseModel, Field
|
| 8 |
from supabase import create_client, Client
|
|
|
|
|
|
|
| 9 |
from config import settings
|
| 10 |
|
| 11 |
-
|
|
|
|
| 12 |
class SchemeModel(BaseModel):
|
| 13 |
title: str = Field(..., max_length=500)
|
| 14 |
category: str = Field("General", max_length=100)
|
| 15 |
ministry: str = Field("Government of India", max_length=300)
|
| 16 |
-
summary: str = Field("", max_length=
|
| 17 |
-
benefits: str = Field("", max_length=
|
| 18 |
eligibility_text: str = Field("", max_length=3000)
|
| 19 |
source_url: str = Field("", max_length=500)
|
| 20 |
is_active: bool = True
|
| 21 |
-
is_verified: bool = True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
-
|
|
|
|
| 24 |
class TorStealthIngestor:
|
| 25 |
def __init__(self):
|
| 26 |
self.sb: Client = create_client(settings.SUPABASE_URL, settings.SUPABASE_KEY)
|
| 27 |
self.base_url = "https://www.myscheme.gov.in"
|
| 28 |
-
# Tor SOCKS5 proxy port (default 9050)
|
| 29 |
self.proxy_server = "socks5://127.0.0.1:9050"
|
|
|
|
| 30 |
|
| 31 |
async def run(self):
|
| 32 |
-
print("
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
async with async_playwright() as p:
|
| 34 |
-
# Route Playwright through Tor
|
| 35 |
browser = await p.chromium.launch(
|
| 36 |
headless=True,
|
| 37 |
proxy={"server": self.proxy_server}
|
| 38 |
)
|
| 39 |
-
|
| 40 |
context = await browser.new_context(
|
| 41 |
-
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
| 42 |
)
|
| 43 |
page = await context.new_page()
|
| 44 |
|
| 45 |
-
# Verify
|
| 46 |
try:
|
| 47 |
await page.goto("https://check.torproject.org/api/ip", timeout=30000)
|
| 48 |
-
ip_info = await page.
|
| 49 |
-
print(f"π
|
| 50 |
-
except:
|
| 51 |
-
print("β οΈ
|
| 52 |
|
| 53 |
await self.ingest_schemes(page)
|
| 54 |
-
await self.ingest_pib_news(page)
|
| 55 |
-
|
| 56 |
await browser.close()
|
| 57 |
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
for cat in categories:
|
| 61 |
-
print(f"
|
|
|
|
|
|
|
|
|
|
| 62 |
try:
|
| 63 |
-
|
| 64 |
-
await
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
)
|
| 83 |
-
schemes_to_load.append(scheme.model_dump())
|
| 84 |
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
except Exception as e:
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
try:
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
await asyncio.sleep(5)
|
| 97 |
-
|
| 98 |
-
# The RSS page renders as an XML tree or a basic list depending on headers
|
| 99 |
-
# We will try to extract any link containing 'PressReleseDetail'
|
| 100 |
-
news_links = await page.locator("a[href*='PressReleseDetail']").evaluate_all("links => links.map(a => a.href)")
|
| 101 |
-
news_titles = await page.locator("a[href*='PressReleseDetail']").all_inner_texts()
|
| 102 |
-
|
| 103 |
-
pib_records = []
|
| 104 |
-
for i in range(min(len(news_links), 10)):
|
| 105 |
-
title = news_titles[i].strip() if i < len(news_titles) else "Press Release"
|
| 106 |
-
if len(title) < 10: continue
|
| 107 |
-
|
| 108 |
-
pib_records.append(SchemeModel(
|
| 109 |
-
title=f"PIB: {title}",
|
| 110 |
-
category="Press Release",
|
| 111 |
-
ministry="Government of India",
|
| 112 |
-
summary="Latest official news release from the Press Information Bureau.",
|
| 113 |
-
benefits="Stay informed on national updates.",
|
| 114 |
-
eligibility_text="Public Notification.",
|
| 115 |
-
source_url=news_links[i]
|
| 116 |
-
).model_dump())
|
| 117 |
-
|
| 118 |
-
if pib_records:
|
| 119 |
-
self.sb.table('schemes').upsert(pib_records, on_conflict='title').execute()
|
| 120 |
-
print(f"β
Ingested {len(pib_records)} real-time PIB updates via RSS.")
|
| 121 |
except Exception as e:
|
| 122 |
-
print
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
if __name__ == "__main__":
|
| 125 |
ingestor = TorStealthIngestor()
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
GovBridge India β World No. 1 Data Ingestion Bot (v3.0)
|
| 3 |
+
=======================================================
|
| 4 |
+
Scrapes real scheme data from MyScheme.gov.in via Tor proxy.
|
| 5 |
+
Uses detail-page navigation to extract REAL benefits/eligibility.
|
| 6 |
+
NEVER overwrites AI-enriched records with placeholder text.
|
| 7 |
+
|
| 8 |
+
Runs daily via GitHub Actions (02_data_ingestion_bot.yml).
|
| 9 |
+
"""
|
| 10 |
import os
|
| 11 |
+
import sys
|
| 12 |
import asyncio
|
| 13 |
+
import hashlib
|
| 14 |
+
import json
|
| 15 |
+
from datetime import datetime, timezone
|
| 16 |
+
from typing import List, Optional
|
| 17 |
+
from playwright.async_api import async_playwright, Page
|
| 18 |
from pydantic import BaseModel, Field
|
| 19 |
from supabase import create_client, Client
|
| 20 |
+
|
| 21 |
+
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 22 |
from config import settings
|
| 23 |
|
| 24 |
+
|
| 25 |
+
# --- DATA MODELS ---
|
| 26 |
class SchemeModel(BaseModel):
|
| 27 |
title: str = Field(..., max_length=500)
|
| 28 |
category: str = Field("General", max_length=100)
|
| 29 |
ministry: str = Field("Government of India", max_length=300)
|
| 30 |
+
summary: str = Field("", max_length=2000)
|
| 31 |
+
benefits: str = Field("", max_length=3000)
|
| 32 |
eligibility_text: str = Field("", max_length=3000)
|
| 33 |
source_url: str = Field("", max_length=500)
|
| 34 |
is_active: bool = True
|
| 35 |
+
is_verified: bool = False # Only AI enrichment sets this True
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class IngestionStats:
|
| 39 |
+
"""Track bot run metrics for audit logging."""
|
| 40 |
+
def __init__(self):
|
| 41 |
+
self.started_at = datetime.now(timezone.utc).isoformat()
|
| 42 |
+
self.new_schemes = 0
|
| 43 |
+
self.updated_schemes = 0
|
| 44 |
+
self.skipped_enriched = 0
|
| 45 |
+
self.errors = 0
|
| 46 |
+
self.categories_scraped = 0
|
| 47 |
|
| 48 |
+
|
| 49 |
+
# --- WORLD NO. 1 INGESTOR ---
|
| 50 |
class TorStealthIngestor:
|
| 51 |
def __init__(self):
|
| 52 |
self.sb: Client = create_client(settings.SUPABASE_URL, settings.SUPABASE_KEY)
|
| 53 |
self.base_url = "https://www.myscheme.gov.in"
|
|
|
|
| 54 |
self.proxy_server = "socks5://127.0.0.1:9050"
|
| 55 |
+
self.stats = IngestionStats()
|
| 56 |
|
| 57 |
async def run(self):
|
| 58 |
+
print("=" * 60)
|
| 59 |
+
print("π GovBridge Ingestion Bot v3.0 β World No. 1 Civic Engine")
|
| 60 |
+
print("=" * 60)
|
| 61 |
+
|
| 62 |
+
# Load existing titles that already have real AI-enriched data
|
| 63 |
+
self.protected_titles = self._get_enriched_titles()
|
| 64 |
+
print(f"π‘οΈ {len(self.protected_titles)} schemes have AI-enriched data β will NOT overwrite")
|
| 65 |
+
|
| 66 |
async with async_playwright() as p:
|
|
|
|
| 67 |
browser = await p.chromium.launch(
|
| 68 |
headless=True,
|
| 69 |
proxy={"server": self.proxy_server}
|
| 70 |
)
|
|
|
|
| 71 |
context = await browser.new_context(
|
| 72 |
+
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
|
| 73 |
)
|
| 74 |
page = await context.new_page()
|
| 75 |
|
| 76 |
+
# Verify Tor IP
|
| 77 |
try:
|
| 78 |
await page.goto("https://check.torproject.org/api/ip", timeout=30000)
|
| 79 |
+
ip_info = await page.inner_text("body")
|
| 80 |
+
print(f"π Stealth IP: {ip_info.strip()}")
|
| 81 |
+
except Exception:
|
| 82 |
+
print("β οΈ Could not verify Tor IP β continuing anyway")
|
| 83 |
|
| 84 |
await self.ingest_schemes(page)
|
|
|
|
|
|
|
| 85 |
await browser.close()
|
| 86 |
|
| 87 |
+
# Log bot run to database
|
| 88 |
+
self._log_bot_run()
|
| 89 |
+
self._print_summary()
|
| 90 |
+
|
| 91 |
+
def _get_enriched_titles(self) -> set:
|
| 92 |
+
"""Get titles of schemes that have real AI-enriched data.
|
| 93 |
+
These records are PROTECTED β never overwrite them with scraped placeholders."""
|
| 94 |
+
try:
|
| 95 |
+
result = self.sb.table("schemes") \
|
| 96 |
+
.select("title") \
|
| 97 |
+
.eq("is_verified", True) \
|
| 98 |
+
.execute()
|
| 99 |
+
return {r["title"] for r in (result.data or [])}
|
| 100 |
+
except Exception as e:
|
| 101 |
+
print(f"β οΈ Could not load protected titles: {e}")
|
| 102 |
+
return set()
|
| 103 |
+
|
| 104 |
+
async def ingest_schemes(self, page: Page):
|
| 105 |
+
"""Navigate category listing pages and extract scheme data."""
|
| 106 |
+
categories = [
|
| 107 |
+
"agriculture", "health", "education", "finance", "housing",
|
| 108 |
+
"social-welfare", "women-and-child", "employment", "rural",
|
| 109 |
+
"urban", "science-and-technology", "sports-and-culture"
|
| 110 |
+
]
|
| 111 |
+
|
| 112 |
for cat in categories:
|
| 113 |
+
print(f"\n{'β' * 40}")
|
| 114 |
+
print(f"π― Category: {cat}")
|
| 115 |
+
self.stats.categories_scraped += 1
|
| 116 |
+
|
| 117 |
try:
|
| 118 |
+
# Navigate to category search
|
| 119 |
+
await page.goto(
|
| 120 |
+
f"{self.base_url}/search?category={cat}",
|
| 121 |
+
timeout=60000,
|
| 122 |
+
wait_until="domcontentloaded"
|
| 123 |
+
)
|
| 124 |
+
await asyncio.sleep(3)
|
| 125 |
+
|
| 126 |
+
# Try multiple CSS selectors for scheme cards
|
| 127 |
+
scheme_cards = await self._extract_scheme_cards(page)
|
| 128 |
+
|
| 129 |
+
if not scheme_cards:
|
| 130 |
+
print(f" β οΈ No scheme cards found for {cat}")
|
| 131 |
+
continue
|
| 132 |
+
|
| 133 |
+
schemes_to_upsert = []
|
| 134 |
+
for card in scheme_cards[:20]: # Cap at 20 per category
|
| 135 |
+
title = card.get("title", "").strip()
|
| 136 |
+
if len(title) < 5:
|
| 137 |
+
continue
|
| 138 |
+
|
| 139 |
+
# PROTECTION: Never overwrite AI-enriched records
|
| 140 |
+
if title in self.protected_titles:
|
| 141 |
+
self.stats.skipped_enriched += 1
|
| 142 |
+
print(f" π‘οΈ SKIP (enriched): {title[:60]}")
|
| 143 |
+
continue
|
| 144 |
+
|
| 145 |
+
# Navigate to detail page for REAL data extraction
|
| 146 |
+
detail_url = card.get("url", "")
|
| 147 |
+
real_data = await self._scrape_detail_page(
|
| 148 |
+
page, detail_url, title, cat
|
| 149 |
)
|
|
|
|
| 150 |
|
| 151 |
+
if real_data:
|
| 152 |
+
schemes_to_upsert.append(real_data)
|
| 153 |
+
|
| 154 |
+
if schemes_to_upsert:
|
| 155 |
+
self.sb.table("schemes").upsert(
|
| 156 |
+
schemes_to_upsert,
|
| 157 |
+
on_conflict="title"
|
| 158 |
+
).execute()
|
| 159 |
+
self.stats.new_schemes += len(schemes_to_upsert)
|
| 160 |
+
print(f" β
Upserted {len(schemes_to_upsert)} schemes for {cat}")
|
| 161 |
+
|
| 162 |
except Exception as e:
|
| 163 |
+
self.stats.errors += 1
|
| 164 |
+
print(f" β Error for {cat}: {e}")
|
| 165 |
+
|
| 166 |
+
async def _extract_scheme_cards(self, page: Page) -> List[dict]:
|
| 167 |
+
"""Extract scheme title + URL from listing page using multiple selectors."""
|
| 168 |
+
cards = []
|
| 169 |
+
|
| 170 |
+
# Try common selectors on myscheme.gov.in
|
| 171 |
+
selectors = [
|
| 172 |
+
"a[href*='/scheme/']",
|
| 173 |
+
".scheme-card a",
|
| 174 |
+
".card-title a",
|
| 175 |
+
"h2 a, h3 a"
|
| 176 |
+
]
|
| 177 |
|
| 178 |
+
for selector in selectors:
|
| 179 |
+
try:
|
| 180 |
+
elements = await page.query_selector_all(selector)
|
| 181 |
+
if elements:
|
| 182 |
+
for el in elements[:25]:
|
| 183 |
+
title = (await el.inner_text()).strip()
|
| 184 |
+
href = await el.get_attribute("href")
|
| 185 |
+
if title and len(title) > 5 and href:
|
| 186 |
+
full_url = href if href.startswith("http") else f"{self.base_url}{href}"
|
| 187 |
+
cards.append({"title": title, "url": full_url})
|
| 188 |
+
if cards:
|
| 189 |
+
break
|
| 190 |
+
except Exception:
|
| 191 |
+
continue
|
| 192 |
+
|
| 193 |
+
# Fallback: extract all h2/h3 text if no links found
|
| 194 |
+
if not cards:
|
| 195 |
+
try:
|
| 196 |
+
headings = await page.locator("h2, h3").all_inner_texts()
|
| 197 |
+
for h in headings[:15]:
|
| 198 |
+
h = h.strip()
|
| 199 |
+
if len(h) > 10 and "Filter" not in h and "Search" not in h:
|
| 200 |
+
cards.append({"title": h, "url": ""})
|
| 201 |
+
except Exception:
|
| 202 |
+
pass
|
| 203 |
+
|
| 204 |
+
return cards
|
| 205 |
+
|
| 206 |
+
async def _scrape_detail_page(
|
| 207 |
+
self, page: Page, url: str, title: str, category: str
|
| 208 |
+
) -> Optional[dict]:
|
| 209 |
+
"""Navigate to a scheme's detail page and extract REAL content."""
|
| 210 |
+
real_benefits = ""
|
| 211 |
+
real_eligibility = ""
|
| 212 |
+
real_summary = ""
|
| 213 |
+
real_ministry = "Government of India"
|
| 214 |
+
real_source_url = url or f"{self.base_url}/scheme/{title.lower().replace(' ', '-')}"
|
| 215 |
+
|
| 216 |
+
if url:
|
| 217 |
+
try:
|
| 218 |
+
await page.goto(url, timeout=45000, wait_until="domcontentloaded")
|
| 219 |
+
await asyncio.sleep(2)
|
| 220 |
+
|
| 221 |
+
# Extract real content from detail page
|
| 222 |
+
real_benefits = await self._safe_extract(
|
| 223 |
+
page,
|
| 224 |
+
["#benefits", "[data-section='benefits']", "div.benefits",
|
| 225 |
+
"section:has-text('Benefits') p, section:has-text('Benefits') li"]
|
| 226 |
+
)
|
| 227 |
+
real_eligibility = await self._safe_extract(
|
| 228 |
+
page,
|
| 229 |
+
["#eligibility", "[data-section='eligibility']", "div.eligibility",
|
| 230 |
+
"section:has-text('Eligibility') p, section:has-text('Eligibility') li"]
|
| 231 |
+
)
|
| 232 |
+
real_summary = await self._safe_extract(
|
| 233 |
+
page,
|
| 234 |
+
["meta[name='description']", ".scheme-description", "p.summary",
|
| 235 |
+
"div.scheme-details > p:first-of-type"]
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
# Extract ministry from page metadata
|
| 239 |
+
try:
|
| 240 |
+
ministry_el = await page.query_selector(
|
| 241 |
+
".ministry-name, [data-field='ministry'], .department-name"
|
| 242 |
+
)
|
| 243 |
+
if ministry_el:
|
| 244 |
+
real_ministry = (await ministry_el.inner_text()).strip()
|
| 245 |
+
except Exception:
|
| 246 |
+
pass
|
| 247 |
+
|
| 248 |
+
# Use page URL as canonical source
|
| 249 |
+
real_source_url = page.url
|
| 250 |
+
|
| 251 |
+
except Exception as e:
|
| 252 |
+
print(f" β οΈ Detail page timeout for {title[:40]}: {e}")
|
| 253 |
+
|
| 254 |
+
# Only create record if we have SOME real data (not just title)
|
| 255 |
+
has_real_data = bool(real_benefits or real_eligibility or real_summary)
|
| 256 |
+
|
| 257 |
+
scheme_data = SchemeModel(
|
| 258 |
+
title=title,
|
| 259 |
+
category=category.replace("-", " ").title(),
|
| 260 |
+
ministry=real_ministry,
|
| 261 |
+
summary=real_summary if real_summary else f"Government scheme under {category.replace('-', ' ').title()} category. Details being verified.",
|
| 262 |
+
benefits=real_benefits if real_benefits else "",
|
| 263 |
+
eligibility_text=real_eligibility if real_eligibility else "",
|
| 264 |
+
source_url=real_source_url,
|
| 265 |
+
is_active=True,
|
| 266 |
+
is_verified=False # Only AI enrichment marks as verified
|
| 267 |
+
).model_dump()
|
| 268 |
+
|
| 269 |
+
# Add metadata for audit trail
|
| 270 |
+
scheme_data["updated_at"] = datetime.now(timezone.utc).isoformat()
|
| 271 |
+
|
| 272 |
+
if has_real_data:
|
| 273 |
+
print(f" π REAL DATA: {title[:50]} (benefits: {len(real_benefits)}ch, elig: {len(real_eligibility)}ch)")
|
| 274 |
+
else:
|
| 275 |
+
print(f" π Title only: {title[:50]} (detail page did not yield structured data)")
|
| 276 |
+
|
| 277 |
+
return scheme_data
|
| 278 |
+
|
| 279 |
+
async def _safe_extract(self, page: Page, selectors: list) -> str:
|
| 280 |
+
"""Try multiple CSS selectors and return first match content."""
|
| 281 |
+
for selector in selectors:
|
| 282 |
+
try:
|
| 283 |
+
if selector.startswith("meta"):
|
| 284 |
+
el = await page.query_selector(selector)
|
| 285 |
+
if el:
|
| 286 |
+
content = await el.get_attribute("content")
|
| 287 |
+
if content and len(content) > 20:
|
| 288 |
+
return content.strip()
|
| 289 |
+
else:
|
| 290 |
+
elements = await page.query_selector_all(selector)
|
| 291 |
+
texts = []
|
| 292 |
+
for el in elements[:10]:
|
| 293 |
+
t = (await el.inner_text()).strip()
|
| 294 |
+
if t and len(t) > 5:
|
| 295 |
+
texts.append(t)
|
| 296 |
+
if texts:
|
| 297 |
+
return "\n".join(texts)
|
| 298 |
+
except Exception:
|
| 299 |
+
continue
|
| 300 |
+
return ""
|
| 301 |
+
|
| 302 |
+
def _log_bot_run(self):
|
| 303 |
+
"""Log this bot run to the bot_run_log table for full auditability."""
|
| 304 |
+
log_entry = {
|
| 305 |
+
"bot_name": "data_ingestion_tor_stealth",
|
| 306 |
+
"started_at": self.stats.started_at,
|
| 307 |
+
"completed_at": datetime.now(timezone.utc).isoformat(),
|
| 308 |
+
"new_records": self.stats.new_schemes,
|
| 309 |
+
"updated_records": self.stats.updated_schemes,
|
| 310 |
+
"skipped_protected": self.stats.skipped_enriched,
|
| 311 |
+
"errors": self.stats.errors,
|
| 312 |
+
"categories_scraped": self.stats.categories_scraped,
|
| 313 |
+
"status": "success" if self.stats.errors == 0 else "partial_success"
|
| 314 |
+
}
|
| 315 |
try:
|
| 316 |
+
self.sb.table("bot_run_log").insert(log_entry).execute()
|
| 317 |
+
print(f"π Bot run logged to bot_run_log table")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
except Exception as e:
|
| 319 |
+
# Table might not exist yet β print to stdout as fallback
|
| 320 |
+
print(f"π Bot run log (stdout fallback): {json.dumps(log_entry, indent=2)}")
|
| 321 |
+
|
| 322 |
+
def _print_summary(self):
|
| 323 |
+
print("\n" + "=" * 60)
|
| 324 |
+
print("π INGESTION SUMMARY")
|
| 325 |
+
print("=" * 60)
|
| 326 |
+
print(f" Categories scraped: {self.stats.categories_scraped}")
|
| 327 |
+
print(f" New schemes added: {self.stats.new_schemes}")
|
| 328 |
+
print(f" Skipped (enriched): {self.stats.skipped_enriched}")
|
| 329 |
+
print(f" Errors: {self.stats.errors}")
|
| 330 |
+
print("=" * 60)
|
| 331 |
+
|
| 332 |
|
| 333 |
if __name__ == "__main__":
|
| 334 |
ingestor = TorStealthIngestor()
|