You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
233 lines
7.7 KiB
Python
233 lines
7.7 KiB
Python
"""
|
|
GDELT Collector — event-centric GDELT news article collection.
|
|
|
|
Collects articles for window: event_date - 1d to event_date + 1d.
|
|
Deduplicates by URL before inserting.
|
|
|
|
Historical coverage: GDELT V2 DOC API covers from 2017-01-01 onwards.
|
|
|
|
Rate limit: GDELT uses a global per-IP quota shared across all users worldwide.
|
|
- _MIN_INTERVAL: process-wide minimum gap between calls (enforced via asyncio.Lock)
|
|
- On 429: exponential backoff — 30s, 60s, 120s (up to _MAX_RETRIES attempts)
|
|
- GDELT collection must NEVER be triggered on-demand from user-facing endpoints.
|
|
Use the scheduler (POST /admin/collect/gdelt/{ticker}) only.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from datetime import date, datetime, timedelta, timezone
|
|
from typing import Optional
|
|
from urllib.parse import urlparse
|
|
|
|
import httpx
|
|
from sqlalchemy import select
|
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models.attention import CompanyEntityMap, GdeltArticleRaw
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_GDELT_DOC_API = "https://api.gdeltproject.org/api/v2/doc/doc"
|
|
_MAX_RECORDS = 250
|
|
_MIN_INTERVAL = 10.0 # minimum seconds between any two GDELT calls (process-wide)
|
|
_RETRY_BACKOFF = [30, 60, 120] # wait times on successive 429s
|
|
_MAX_RETRIES = len(_RETRY_BACKOFF)
|
|
|
|
# Earliest date covered by GDELT V2 DOC API
|
|
GDELT_EARLIEST_DATE = date(2017, 1, 1)
|
|
|
|
# Process-wide rate guard — enforced inside collect_gdelt_articles()
|
|
_gdelt_lock = asyncio.Lock()
|
|
_last_gdelt_call_ts: float = 0.0
|
|
|
|
|
|
def _date_to_gdelt_ts(d: date, end_of_day: bool = False) -> str:
|
|
"""Format date as GDELT datetime string YYYYMMDDHHMMSS."""
|
|
if end_of_day:
|
|
return d.strftime("%Y%m%d235959")
|
|
return d.strftime("%Y%m%d000000")
|
|
|
|
|
|
async def _do_gdelt_request(params: dict, ticker: str) -> Optional[dict]:
|
|
"""Fire the GDELT HTTP request with retry/backoff on 429. Returns parsed JSON or None."""
|
|
for attempt in range(_MAX_RETRIES + 1):
|
|
try:
|
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
resp = await client.get(_GDELT_DOC_API, params=params)
|
|
|
|
if resp.status_code == 429:
|
|
if attempt < _MAX_RETRIES:
|
|
wait = _RETRY_BACKOFF[attempt]
|
|
logger.warning(
|
|
"GDELT rate limit (429) for %s — retry %d/%d in %ds",
|
|
ticker, attempt + 1, _MAX_RETRIES, wait,
|
|
)
|
|
await asyncio.sleep(wait)
|
|
continue
|
|
else:
|
|
logger.error(
|
|
"GDELT rate limit persists after %d retries for %s — giving up",
|
|
_MAX_RETRIES, ticker,
|
|
)
|
|
return None
|
|
|
|
resp.raise_for_status()
|
|
|
|
if not resp.content.strip():
|
|
logger.warning("GDELT returned empty response for %s", ticker)
|
|
return None
|
|
try:
|
|
return resp.json()
|
|
except Exception:
|
|
logger.warning("GDELT returned non-JSON for %s: %r", ticker, resp.text[:200])
|
|
return None
|
|
|
|
except httpx.HTTPStatusError as exc:
|
|
logger.error("GDELT API HTTP error %s: %s", exc.response.status_code, exc)
|
|
raise
|
|
except Exception as exc:
|
|
logger.error("GDELT API request failed: %s", exc)
|
|
raise
|
|
|
|
return None
|
|
|
|
|
|
def _extract_domain(url: str) -> Optional[str]:
|
|
try:
|
|
return urlparse(url).netloc or None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
async def collect_gdelt_articles(
|
|
db: AsyncSession,
|
|
ticker: str,
|
|
event_date: date,
|
|
) -> int:
|
|
"""Collect GDELT articles for event_date ± 1 day.
|
|
|
|
Returns number of new records inserted.
|
|
Raises ValueError if no entity mapping or gdelt_query exists.
|
|
"""
|
|
global _last_gdelt_call_ts
|
|
|
|
ticker = ticker.upper()
|
|
|
|
if event_date < GDELT_EARLIEST_DATE:
|
|
logger.warning(
|
|
"GDELT data not available before %s (requested %s) — skipping %s",
|
|
GDELT_EARLIEST_DATE, event_date, ticker,
|
|
)
|
|
return 0
|
|
|
|
result = await db.execute(
|
|
select(CompanyEntityMap).where(CompanyEntityMap.ticker == ticker)
|
|
)
|
|
entity = result.scalars().first()
|
|
if not entity:
|
|
raise ValueError(f"No entity mapping found for {ticker!r}. Run entity resolution first.")
|
|
if not entity.gdelt_query:
|
|
raise ValueError(f"Entity {ticker!r} has no gdelt_query.")
|
|
|
|
gdelt_query = entity.gdelt_query
|
|
start_date = event_date - timedelta(days=1)
|
|
end_date = event_date + timedelta(days=1)
|
|
|
|
start_ts = _date_to_gdelt_ts(start_date, end_of_day=False)
|
|
end_ts = _date_to_gdelt_ts(end_date, end_of_day=True)
|
|
|
|
logger.info(
|
|
"Fetching GDELT articles for %s query=%r window=%s→%s",
|
|
ticker, gdelt_query, start_ts, end_ts,
|
|
)
|
|
|
|
params = {
|
|
"query": gdelt_query,
|
|
"mode": "ArtList",
|
|
"maxrecords": str(_MAX_RECORDS),
|
|
"format": "json",
|
|
"startdatetime": start_ts,
|
|
"enddatetime": end_ts,
|
|
}
|
|
|
|
# Global process-wide rate guard: at most 1 call per _MIN_INTERVAL seconds.
|
|
# Uses a lock so concurrent callers queue up rather than both firing.
|
|
async with _gdelt_lock:
|
|
import time as _time
|
|
elapsed = _time.monotonic() - _last_gdelt_call_ts
|
|
wait = max(0.0, _MIN_INTERVAL - elapsed)
|
|
if wait > 0:
|
|
logger.debug("GDELT rate guard: sleeping %.1fs before request", wait)
|
|
await asyncio.sleep(wait)
|
|
|
|
data = None
|
|
try:
|
|
data = await _do_gdelt_request(params, ticker)
|
|
finally:
|
|
_last_gdelt_call_ts = _time.monotonic()
|
|
|
|
if data is None:
|
|
return 0
|
|
|
|
articles = data.get("articles", []) if isinstance(data, dict) else []
|
|
if not articles:
|
|
logger.info("No GDELT articles returned for %s", ticker)
|
|
return 0
|
|
|
|
# Collect existing URLs to deduplicate
|
|
urls = [a.get("url", "") for a in articles if a.get("url")]
|
|
existing_result = await db.execute(
|
|
select(GdeltArticleRaw.url).where(GdeltArticleRaw.url.in_(urls))
|
|
)
|
|
existing_urls = set(existing_result.scalars().all())
|
|
|
|
rows_to_insert = []
|
|
for article in articles:
|
|
url = article.get("url", "")
|
|
if not url or url in existing_urls:
|
|
continue
|
|
|
|
# Parse published_at from seendate or socialimage timestamp
|
|
published_at: Optional[datetime] = None
|
|
seen_date = article.get("seendate", "")
|
|
if seen_date and len(seen_date) >= 14:
|
|
try:
|
|
published_at = datetime(
|
|
int(seen_date[0:4]),
|
|
int(seen_date[4:6]),
|
|
int(seen_date[6:8]),
|
|
int(seen_date[8:10]),
|
|
int(seen_date[10:12]),
|
|
int(seen_date[12:14]),
|
|
tzinfo=timezone.utc,
|
|
)
|
|
except ValueError:
|
|
pass
|
|
|
|
rows_to_insert.append({
|
|
"url": url,
|
|
"title": article.get("title"),
|
|
"domain": _extract_domain(url),
|
|
"published_at": published_at,
|
|
"sourcecountry": article.get("sourcecountry"),
|
|
"matched_ticker": ticker,
|
|
"match_method": "gdelt_query",
|
|
"match_confidence": 1.0,
|
|
})
|
|
|
|
if not rows_to_insert:
|
|
logger.info("No new GDELT articles for %s (all duplicates)", ticker)
|
|
return 0
|
|
|
|
stmt = (
|
|
pg_insert(GdeltArticleRaw)
|
|
.values(rows_to_insert)
|
|
.on_conflict_do_nothing(index_elements=["url"])
|
|
)
|
|
await db.execute(stmt)
|
|
await db.commit()
|
|
|
|
logger.info("Inserted %d GDELT article records for %s", len(rows_to_insert), ticker)
|
|
return len(rows_to_insert)
|