306 lines
9.4 KiB
Python
306 lines
9.4 KiB
Python
import datetime as dt
|
|
import json
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
|
|
import httpx
|
|
from fastapi import FastAPI, HTTPException, Query
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import Response
|
|
from sqlalchemy import select
|
|
|
|
from . import wikipedia
|
|
from .clustering import build_clusters
|
|
from .config import settings
|
|
from .db import get_session, init_db, SessionLocal
|
|
from .ingest import fetch_all
|
|
from .markets import poll_markets, INSTRUMENTS
|
|
from .conflict import enabled as conflict_enabled, poll_conflict_events
|
|
from .models import Article, ConflictEvent, MarketPrice, MarketSpike
|
|
from .scheduler import start_scheduler
|
|
|
|
logging.basicConfig(level=logging.INFO)
|
|
log = logging.getLogger("newsatlas")
|
|
|
|
_scheduler = None
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
global _scheduler
|
|
init_db()
|
|
_scheduler = start_scheduler()
|
|
yield
|
|
if _scheduler:
|
|
_scheduler.shutdown(wait=False)
|
|
|
|
|
|
app = FastAPI(title="NewsAtlas API", lifespan=lifespan)
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
|
|
@app.get("/api/health")
|
|
def health():
|
|
return {"status": "ok", "time": dt.datetime.utcnow().isoformat()}
|
|
|
|
|
|
@app.get("/api/config")
|
|
def public_config():
|
|
"""Feature flags the frontend needs to decide what UI to show."""
|
|
return {
|
|
"weather_enabled": bool(settings.owm_api_key),
|
|
"conflict_enabled": conflict_enabled(),
|
|
"article_window_hours": settings.article_window_hours,
|
|
}
|
|
|
|
|
|
# ---- Articles / clusters -----------------------------------------------
|
|
|
|
@app.get("/api/clusters")
|
|
def api_clusters():
|
|
session = next(get_session())
|
|
try:
|
|
return build_clusters(session)
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.get("/api/clusters/{cluster_key}/articles")
|
|
def api_cluster_articles(cluster_key: str):
|
|
session = next(get_session())
|
|
try:
|
|
rows = session.execute(
|
|
select(Article)
|
|
.where(Article.cluster_key == cluster_key)
|
|
.order_by(Article.published_at.desc())
|
|
).scalars().all()
|
|
return [_article_dict(a) for a in rows]
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.get("/api/articles")
|
|
def api_articles(limit: int = Query(100, le=500), q: str | None = None):
|
|
session = next(get_session())
|
|
try:
|
|
stmt = select(Article).order_by(Article.published_at.desc()).limit(limit)
|
|
if q:
|
|
like = f"%{q}%"
|
|
stmt = select(Article).where(Article.title.ilike(like)).order_by(
|
|
Article.published_at.desc()
|
|
).limit(limit)
|
|
rows = session.execute(stmt).scalars().all()
|
|
return [_article_dict(a) for a in rows]
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.post("/api/refresh")
|
|
def api_refresh():
|
|
"""Manually trigger an RSS poll (in addition to the scheduled interval)."""
|
|
session = SessionLocal()
|
|
try:
|
|
added = fetch_all(session)
|
|
return {"added": added}
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
def _article_dict(a: Article) -> dict:
|
|
return {
|
|
"id": a.id,
|
|
"source": a.source,
|
|
"source_bias": a.source_bias,
|
|
"title": a.title,
|
|
"url": a.url,
|
|
"summary": a.summary,
|
|
"published_at": a.published_at.isoformat() if a.published_at else None,
|
|
"location_name": a.location_name,
|
|
"country": a.country,
|
|
"lat": a.lat,
|
|
"lon": a.lon,
|
|
}
|
|
|
|
|
|
# ---- Markets --------------------------------------------------------------
|
|
|
|
@app.get("/api/markets/latest")
|
|
def api_markets_latest():
|
|
session = next(get_session())
|
|
try:
|
|
out = []
|
|
for symbol, label, category, _currency in INSTRUMENTS:
|
|
row = session.execute(
|
|
select(MarketPrice)
|
|
.where(MarketPrice.symbol == symbol)
|
|
.order_by(MarketPrice.recorded_at.desc())
|
|
.limit(1)
|
|
).scalar_one_or_none()
|
|
if row:
|
|
out.append(
|
|
{
|
|
"symbol": row.symbol,
|
|
"label": row.label,
|
|
"category": row.category,
|
|
"price": row.price,
|
|
"currency": row.currency,
|
|
"change_pct": row.change_pct,
|
|
"recorded_at": row.recorded_at.isoformat(),
|
|
}
|
|
)
|
|
return out
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.get("/api/markets/history")
|
|
def api_markets_history(symbol: str, hours: int = Query(168, le=24 * 30)):
|
|
since = dt.datetime.utcnow() - dt.timedelta(hours=hours)
|
|
session = next(get_session())
|
|
try:
|
|
rows = session.execute(
|
|
select(MarketPrice)
|
|
.where(MarketPrice.symbol == symbol, MarketPrice.recorded_at >= since)
|
|
.order_by(MarketPrice.recorded_at.asc())
|
|
).scalars().all()
|
|
return [{"price": r.price, "recorded_at": r.recorded_at.isoformat()} for r in rows]
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.get("/api/markets/spikes")
|
|
def api_markets_spikes(symbol: str | None = None, hours: int = Query(168, le=24 * 30)):
|
|
since = dt.datetime.utcnow() - dt.timedelta(hours=hours)
|
|
session = next(get_session())
|
|
try:
|
|
stmt = select(MarketSpike).where(MarketSpike.detected_at >= since).order_by(MarketSpike.detected_at.desc())
|
|
if symbol:
|
|
stmt = stmt.where(MarketSpike.symbol == symbol)
|
|
spikes = session.execute(stmt).scalars().all()
|
|
|
|
out = []
|
|
for s in spikes:
|
|
article_ids = json.loads(s.article_ids_json or "[]")
|
|
articles = []
|
|
if article_ids:
|
|
rows = session.execute(select(Article).where(Article.id.in_(article_ids))).scalars().all()
|
|
by_id = {a.id: a for a in rows}
|
|
articles = [_article_dict(by_id[i]) for i in article_ids if i in by_id]
|
|
out.append(
|
|
{
|
|
"id": s.id,
|
|
"symbol": s.symbol,
|
|
"label": s.label,
|
|
"from_price": s.from_price,
|
|
"to_price": s.to_price,
|
|
"pct_change": s.pct_change,
|
|
"window_start": s.window_start.isoformat(),
|
|
"window_end": s.window_end.isoformat(),
|
|
"detected_at": s.detected_at.isoformat(),
|
|
"candidate_articles": articles,
|
|
}
|
|
)
|
|
return out
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.post("/api/markets/refresh")
|
|
def api_markets_refresh():
|
|
session = SessionLocal()
|
|
try:
|
|
added = poll_markets(session)
|
|
return {"added": added}
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
# ---- Conflict / military-movement overlay ---------------------------------
|
|
|
|
@app.get("/api/conflict-events")
|
|
def api_conflict_events(hours: int = Query(168, le=24 * 30)):
|
|
if not conflict_enabled():
|
|
return []
|
|
since = dt.datetime.utcnow() - dt.timedelta(hours=hours)
|
|
session = next(get_session())
|
|
try:
|
|
rows = session.execute(
|
|
select(ConflictEvent).where(ConflictEvent.event_date >= since)
|
|
).scalars().all()
|
|
return [
|
|
{
|
|
"id": r.id,
|
|
"event_type": r.event_type,
|
|
"actor1": r.actor1,
|
|
"actor2": r.actor2,
|
|
"fatalities": r.fatalities,
|
|
"notes": r.notes,
|
|
"location_name": r.location_name,
|
|
"country": r.country,
|
|
"lat": r.lat,
|
|
"lon": r.lon,
|
|
"event_date": r.event_date.isoformat(),
|
|
}
|
|
for r in rows
|
|
]
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@app.post("/api/conflict-events/refresh")
|
|
def api_conflict_refresh():
|
|
session = SessionLocal()
|
|
try:
|
|
added = poll_conflict_events(session)
|
|
return {"added": added}
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
# ---- Weather tile proxy (keeps the OWM key server-side) -------------------
|
|
|
|
_OWM_LAYERS = {"clouds", "precipitation", "pressure", "wind", "temp"}
|
|
|
|
|
|
@app.get("/api/weather/tiles/{layer}/{z}/{x}/{y}.png")
|
|
async def weather_tile(layer: str, z: int, x: int, y: int):
|
|
if not settings.owm_api_key:
|
|
raise HTTPException(503, "OWM_API_KEY not configured")
|
|
if layer not in _OWM_LAYERS:
|
|
raise HTTPException(404, "unknown layer")
|
|
|
|
url = f"https://tile.openweathermap.org/map/{layer}_new/{z}/{x}/{y}.png"
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
resp = await client.get(url, params={"appid": settings.owm_api_key})
|
|
if resp.status_code != 200:
|
|
raise HTTPException(resp.status_code, "upstream weather tile error")
|
|
return Response(content=resp.content, media_type="image/png", headers={"Cache-Control": "public, max-age=600"})
|
|
|
|
|
|
# ---- Wikipedia integration --------------------------------------------
|
|
|
|
@app.get("/api/wikipedia/summary")
|
|
async def api_wikipedia_summary(title: str):
|
|
result = await wikipedia.get_summary(title)
|
|
if not result:
|
|
raise HTTPException(404, "no Wikipedia summary found")
|
|
return result
|
|
|
|
|
|
@app.get("/api/wikipedia/search")
|
|
async def api_wikipedia_search(q: str, limit: int = Query(5, le=20)):
|
|
return await wikipedia.search(q, limit=limit)
|
|
|
|
|
|
@app.get("/api/wikipedia/parliament")
|
|
async def api_wikipedia_parliament(country: str):
|
|
result = await wikipedia.get_parliament(country)
|
|
if not result:
|
|
raise HTTPException(404, "no parliament diagram found for this country")
|
|
return result
|