Files
planet/backend/app/services/earth_news.py
linkong 93eb41a9f7
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
release: bump version to 0.58.0
Release 0.58.0 includes the Earth high-precision boundary PMTiles/MVT pipeline, standardized Earth boundary source collectors, China POV boundary configuration templates, and removal of the legacy low-precision GeoJSON fallback. It also adds Earth news target-location queueing/archive support, fixes datasource task status visibility, documents the Earth surface depth-spacing rules that prevent far-zoom z-fighting snow/black blocks, and updates bilingual operations/developer docs.
2026-05-15 17:40:07 +08:00

1139 lines
36 KiB
Python

from __future__ import annotations
import asyncio
from dataclasses import dataclass
from datetime import UTC, datetime
from email.utils import parsedate_to_datetime
import hashlib
import html
import json
import math
import re
from typing import Any
from urllib.parse import quote
import xml.etree.ElementTree as ET
import httpx
from bs4 import BeautifulSoup
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.countries import COUNTRY_VARIANTS_MAP, get_country_centroid, normalize_country
from app.schemas.ai import SituationalAnalysisRequest
from app.services.ai_client import AIProviderClient
from app.services.location.resolvers.nominatim import build_default_nominatim_geocoder
USER_AGENT = "PlanetEarthNewsBoard/1.0 (+https://planet.local)"
REQUEST_TIMEOUT = 12.0
MAX_ITEMS_PER_SOURCE = 6
MAX_ITEMS_TOTAL = 12
STALE_CACHE_MAX_AGE_SECONDS = 60 * 45
RSS_SUPPLEMENT_MAX_AGE_SECONDS = STALE_CACHE_MAX_AGE_SECONDS
MAX_TARGET_INFERENCE_CONCURRENCY = 3
TARGET_INFERENCE_TIMEOUT_SECONDS = 6.0
@dataclass(frozen=True)
class RegionProfile:
key: str
label: str
query: str
accent: str
@dataclass(frozen=True)
class RegionAnchor:
region: str
label: str
latitude: float
longitude: float
@dataclass(frozen=True)
class NewsFeedSource:
id: str
name: str
region: str
feed_url: str
homepage_url: str
source_type: str = "rss"
priority: int = 100
@dataclass(frozen=True)
class NewsTargetLocation:
latitude: float
longitude: float
label: str
source: str
confidence: float | None = None
country: str | None = None
city: str | None = None
@dataclass
class ParsedNewsItem:
id: str
title: str
summary: str
url: str
source: str
feed_name: str
feed_region: str
homepage_url: str
published_at: datetime | None
target_location: NewsTargetLocation | None = None
target_resolution_stage: str = "unresolved"
target_ai_attempted: bool = False
target_ai_status: str = "not_attempted"
target_ai_error: str | None = None
target_debug_note: str | None = None
location_patch: dict[str, Any] | None = None
@dataclass
class CachedRegionFeed:
region: str
fetched_at: datetime
items: list[ParsedNewsItem]
sources: list[NewsFeedSource]
REGION_PROFILES: dict[str, RegionProfile] = {
"americas": RegionProfile(
key="americas",
label="美洲焦点",
query='Americas geopolitics OR Latin America OR "United States" OR Canada',
accent="#79d3ff",
),
"europe": RegionProfile(
key="europe",
label="欧洲焦点",
query='Europe geopolitics OR EU OR NATO OR "Eastern Europe"',
accent="#8fd4ff",
),
"middle-east-africa": RegionProfile(
key="middle-east-africa",
label="中东与非洲焦点",
query='"Middle East" OR Africa geopolitics OR Red Sea OR Gulf',
accent="#ffb56a",
),
"asia-pacific": RegionProfile(
key="asia-pacific",
label="亚太焦点",
query='"Asia Pacific" OR Indo-Pacific OR China OR Japan OR Korea OR ASEAN',
accent="#78f2cf",
),
"global": RegionProfile(
key="global",
label="全球焦点",
query='"world news" OR geopolitics OR "global affairs"',
accent="#d6e6ff",
),
}
REGION_ANCHORS: dict[str, RegionAnchor] = {
"americas": RegionAnchor(
region="americas",
label="美洲",
latitude=37.0902,
longitude=-95.7129,
),
"europe": RegionAnchor(
region="europe",
label="欧洲",
latitude=50.1109,
longitude=8.6821,
),
"middle-east-africa": RegionAnchor(
region="middle-east-africa",
label="中东与非洲",
latitude=25.2048,
longitude=55.2708,
),
"asia-pacific": RegionAnchor(
region="asia-pacific",
label="亚太",
latitude=1.3521,
longitude=103.8198,
),
"global": RegionAnchor(
region="global",
label="全球",
latitude=20.0,
longitude=0.0,
),
}
def _google_news_feed(query: str, *, hl: str, gl: str, ceid: str) -> str:
return (
"https://news.google.com/rss/search?q="
+ quote(query, safe="")
+ f"&hl={hl}&gl={gl}&ceid={ceid}"
)
NEWS_FEED_SOURCES: tuple[NewsFeedSource, ...] = (
NewsFeedSource(
id="bbc-world",
name="BBC World",
region="global",
feed_url="https://feeds.bbci.co.uk/news/world/rss.xml",
homepage_url="https://www.bbc.com/news/world",
priority=10,
),
NewsFeedSource(
id="dw-top",
name="DW Top Stories",
region="europe",
feed_url="https://rss.dw.com/rdf/rss-en-top",
homepage_url="https://www.dw.com/en/top-stories/s-9097",
priority=20,
),
NewsFeedSource(
id="global-scan",
name="Global Monitor / World",
region="global",
feed_url=_google_news_feed(
REGION_PROFILES["global"].query,
hl="en-US",
gl="US",
ceid="US:en",
),
homepage_url="https://news.google.com/",
source_type="aggregated",
priority=30,
),
NewsFeedSource(
id="google-americas",
name="Global Monitor / Americas",
region="americas",
feed_url=_google_news_feed(
REGION_PROFILES["americas"].query,
hl="en-US",
gl="US",
ceid="US:en",
),
homepage_url="https://news.google.com/",
source_type="aggregated",
priority=40,
),
NewsFeedSource(
id="google-europe",
name="Global Monitor / Europe",
region="europe",
feed_url=_google_news_feed(
REGION_PROFILES["europe"].query,
hl="en-GB",
gl="GB",
ceid="GB:en",
),
homepage_url="https://news.google.com/",
source_type="aggregated",
priority=40,
),
NewsFeedSource(
id="google-mea",
name="Global Monitor / MEA",
region="middle-east-africa",
feed_url=_google_news_feed(
REGION_PROFILES["middle-east-africa"].query,
hl="en-US",
gl="US",
ceid="US:en",
),
homepage_url="https://news.google.com/",
source_type="aggregated",
priority=40,
),
NewsFeedSource(
id="google-apac",
name="Global Monitor / APAC",
region="asia-pacific",
feed_url=_google_news_feed(
REGION_PROFILES["asia-pacific"].query,
hl="en-SG",
gl="SG",
ceid="SG:en",
),
homepage_url="https://news.google.com/",
source_type="aggregated",
priority=40,
),
)
_REGION_CACHE: dict[str, CachedRegionFeed] = {}
_news_target_geocode = build_default_nominatim_geocoder(user_agent=USER_AGENT)
_CITY_HINTS: tuple[dict[str, str | None], ...] = (
{"name": "Beijing", "country": "中国"},
{"name": "Havana", "country": "古巴"},
{"name": "Kyiv", "country": "乌克兰"},
{"name": "Bangkok", "country": "泰国"},
{"name": "Tehran", "country": "伊朗"},
{"name": "Moscow", "country": "俄罗斯"},
{"name": "Taipei", "country": "中国(台湾)"},
{"name": "Hong Kong", "country": "中国(香港)"},
)
def determine_focus_region(lat: float | None, lon: float | None) -> str:
if lat is None or lon is None:
return "global"
if -170 <= lon <= -30:
return "americas"
if -30 < lon <= 45:
return "europe" if lat >= 30 else "middle-east-africa"
if 45 < lon <= 150:
return "middle-east-africa" if lat < 10 else "asia-pacific"
return "asia-pacific"
def get_region_profile(region: str) -> RegionProfile:
return REGION_PROFILES.get(region, REGION_PROFILES["global"])
def get_region_anchor(region: str) -> RegionAnchor:
return REGION_ANCHORS.get(region, REGION_ANCHORS["global"])
def _coerce_str(value: Any) -> str | None:
if value is None:
return None
if not isinstance(value, str):
value = str(value)
cleaned = re.sub(r"\s+", " ", value).strip()
return cleaned or None
def _contains_location_alias(text: str, alias: str) -> bool:
normalized_alias = _coerce_str(alias)
if not normalized_alias:
return False
if re.search(r"[A-Za-z]", normalized_alias):
pattern = r"(?<![A-Za-z])" + re.escape(normalized_alias) + r"(?![A-Za-z])"
return re.search(pattern, text, flags=re.IGNORECASE) is not None
return normalized_alias in text
def _iter_searchable_country_variants(
canonical: str,
variants: list[str],
) -> tuple[str, ...]:
searchable: list[str] = []
seen: set[str] = set()
for variant in (canonical, *variants):
normalized = _coerce_str(variant)
if not normalized:
continue
if re.fullmatch(r"[A-Z]{2,3}", normalized):
continue
if len(normalized) <= 2:
continue
key = normalized.casefold()
if key in seen:
continue
seen.add(key)
searchable.append(normalized)
return tuple(searchable)
def _coerce_float(value: Any) -> float | None:
try:
parsed = float(value)
except (TypeError, ValueError):
return None
if not math.isfinite(parsed):
return None
return parsed
def _first_json_object(text: str) -> dict[str, Any] | None:
if not text:
return None
decoder = json.JSONDecoder()
for index, char in enumerate(text):
if char != "{":
continue
try:
payload, _ = decoder.raw_decode(text[index:])
except ValueError:
continue
if isinstance(payload, dict):
return payload
return None
async def _geocode_target_location(query: str) -> dict[str, Any] | None:
return await asyncio.to_thread(_news_target_geocode, query)
async def _build_target_location_from_payload(
payload: dict[str, Any],
) -> NewsTargetLocation | None:
country = normalize_country(payload.get("country"))
city = _coerce_str(payload.get("city"))
matched_location_name = _coerce_str(payload.get("matched_location_name"))
confidence = _coerce_float(payload.get("confidence"))
if confidence is not None:
confidence = max(0.0, min(confidence, 1.0))
latitude = _coerce_float(payload.get("latitude"))
longitude = _coerce_float(payload.get("longitude"))
if latitude not in (None, 0.0) and longitude not in (None, 0.0):
label = matched_location_name or ", ".join(part for part in (city, country) if part) or "关联位置"
return NewsTargetLocation(
latitude=latitude,
longitude=longitude,
label=label,
source="ai_inferred_target",
confidence=confidence,
country=country,
city=city,
)
geocode_queries: list[str] = []
for value in (
", ".join(part for part in (city, country) if part),
matched_location_name,
city,
country,
):
normalized = _coerce_str(value)
if normalized and normalized not in geocode_queries:
geocode_queries.append(normalized)
for query in geocode_queries:
try:
result = await _geocode_target_location(query)
except Exception:
continue
if not isinstance(result, dict):
continue
latitude = _coerce_float(result.get("lat"))
longitude = _coerce_float(result.get("lon"))
if latitude in (None, 0.0) or longitude in (None, 0.0):
continue
label = (
_coerce_str(result.get("display_name"))
or matched_location_name
or ", ".join(part for part in (city, country) if part)
or query
)
return NewsTargetLocation(
latitude=latitude,
longitude=longitude,
label=label,
source="ai_inferred_target",
confidence=confidence,
country=country,
city=city,
)
centroid = get_country_centroid(country)
if centroid:
label = matched_location_name or city or country or "关联位置"
return NewsTargetLocation(
latitude=centroid["latitude"],
longitude=centroid["longitude"],
label=label,
source="ai_inferred_target",
confidence=confidence,
country=country,
city=city,
)
return None
async def _extract_target_location_from_text(item: ParsedNewsItem) -> NewsTargetLocation | None:
combined_text = " ".join(part for part in (item.title, item.summary) if part).strip()
if not combined_text:
return None
for hint in _CITY_HINTS:
city_name = _coerce_str(hint.get("name"))
if not city_name or not _contains_location_alias(combined_text, city_name):
continue
country = normalize_country(hint.get("country"))
geocode_query = ", ".join(part for part in (city_name, country) if part)
try:
result = await _geocode_target_location(geocode_query)
except Exception:
result = None
if isinstance(result, dict):
latitude = _coerce_float(result.get("lat"))
longitude = _coerce_float(result.get("lon"))
if latitude not in (None, 0.0) and longitude not in (None, 0.0):
return NewsTargetLocation(
latitude=latitude,
longitude=longitude,
label=_coerce_str(result.get("display_name")) or geocode_query,
source="headline_location_hint",
confidence=0.78,
country=country,
city=city_name,
)
centroid = get_country_centroid(country)
if centroid:
return NewsTargetLocation(
latitude=centroid["latitude"],
longitude=centroid["longitude"],
label=geocode_query,
source="headline_location_hint",
confidence=0.68,
country=country,
city=city_name,
)
for canonical, variants in COUNTRY_VARIANTS_MAP.items():
if not get_country_centroid(canonical):
continue
searchable_variants = _iter_searchable_country_variants(canonical, variants)
if not any(_contains_location_alias(combined_text, variant) for variant in searchable_variants):
continue
centroid = get_country_centroid(canonical)
if not centroid:
continue
return NewsTargetLocation(
latitude=centroid["latitude"],
longitude=centroid["longitude"],
label=canonical,
source="headline_country_hint",
confidence=0.62,
country=canonical,
city=None,
)
return None
async def _infer_news_target_location(
item: ParsedNewsItem,
*,
provider_client: AIProviderClient | None,
) -> NewsTargetLocation | None:
text_hint = await _extract_target_location_from_text(item)
if text_hint is not None and text_hint.city:
item.target_resolution_stage = text_hint.source
item.target_ai_attempted = False
item.target_ai_status = "skipped_text_hint"
item.target_ai_error = None
item.target_debug_note = f"text hint matched {text_hint.label}"
return text_hint
if provider_client is None:
item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved"
item.target_ai_attempted = False
item.target_ai_status = "unavailable"
item.target_ai_error = "AI provider is not configured or unavailable for earth-feed."
item.target_debug_note = (
f"text hint matched {text_hint.label}" if text_hint is not None else "no text location hint matched"
)
return text_hint
item.target_ai_attempted = True
item.target_ai_status = "attempted"
item.target_ai_error = None
item.target_debug_note = (
f"text hint matched {text_hint.label}" if text_hint is not None else "no text location hint matched"
)
request = SituationalAnalysisRequest(
title="Infer likely event location for Earth news cruise",
objective=(
"Return exactly one strict JSON object for the most likely physical "
"location the news event is about. Prefer the host city when a state "
"visit, summit, meeting, attack, or disaster is clearly centered in a "
"known city. Fall back to the best-supported country only when a city "
"cannot be inferred."
),
context={
"news_item": {
"title": item.title,
"summary": item.summary,
"source": item.source,
"feed_name": item.feed_name,
"feed_region": item.feed_region,
"url": item.url,
"published_at": (
item.published_at.isoformat().replace("+00:00", "Z")
if item.published_at
else None
),
},
"required_json_schema": {
"country": "string|null",
"city": "string|null",
"matched_location_name": "string|null",
"latitude": "number|null",
"longitude": "number|null",
"confidence": "number from 0 to 1",
"reasoning_summary": "short string",
},
},
constraints=[
"Return only strict JSON. Do not wrap it in markdown.",
"Prefer the event location, not the newsroom or publisher headquarters.",
"When a country visit or summit is the clear topic but the city is omitted, use the most likely host city only if it is broadly public knowledge.",
"Use null for unknown fields instead of inventing details.",
"Calibrate confidence conservatively: 0.75+ only when the city is strongly supported, 0.55-0.74 for country-level or likely city inference, below 0.55 when weak.",
],
)
try:
response = await provider_client.analyze(request)
except Exception as exc:
item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved"
item.target_ai_status = "provider_error"
item.target_ai_error = str(exc)
return text_hint
payload = _first_json_object(response.content)
if not isinstance(payload, dict):
item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved"
item.target_ai_status = "parse_error"
item.target_ai_error = "AI response did not contain a parseable JSON object."
return text_hint
target = await _build_target_location_from_payload(payload)
if target is None:
item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved"
item.target_ai_status = "no_result"
item.target_ai_error = "AI returned no usable target coordinates or geocodeable location."
return text_hint
if target.confidence is not None and target.confidence < 0.45:
item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved"
item.target_ai_status = "low_confidence"
item.target_ai_error = f"AI target confidence too low: {target.confidence:.2f}"
return text_hint
item.target_resolution_stage = target.source
item.target_ai_status = "success"
item.target_ai_error = None
item.target_debug_note = f"ai inferred {target.label}"
return target
async def _enrich_items_with_target_locations(
items: list[ParsedNewsItem],
*,
provider_client: AIProviderClient | None,
) -> list[ParsedNewsItem]:
if not items:
return items
semaphore = asyncio.Semaphore(MAX_TARGET_INFERENCE_CONCURRENCY)
async def enrich(item: ParsedNewsItem) -> ParsedNewsItem:
async with semaphore:
target = await _infer_news_target_location(item, provider_client=provider_client)
item.target_location = target
return item
return list(await asyncio.gather(*(enrich(item) for item in items)))
def get_sources_for_region(region: str) -> list[NewsFeedSource]:
return sorted(
[source for source in NEWS_FEED_SOURCES if source.region in {"global", region}],
key=lambda source: (source.priority, source.name),
)
def _strip_html(value: str) -> str:
if not value:
return ""
soup = BeautifulSoup(value, "html.parser")
return re.sub(r"\s+", " ", soup.get_text(" ", strip=True)).strip()
def _truncate(value: str, limit: int = 180) -> str:
text = value.strip()
if len(text) <= limit:
return text
return text[: limit - 1].rstrip() + ""
def _normalize_source_name(raw: str, fallback: str) -> str:
text = html.unescape((raw or "").strip())
if " - " in text:
return text.split(" - ")[-1].strip() or fallback
return text or fallback
def _parse_datetime(raw: str | None) -> datetime | None:
if not raw:
return None
text = raw.strip()
if not text:
return None
for parser in (
lambda value: parsedate_to_datetime(value),
lambda value: datetime.fromisoformat(value.replace("Z", "+00:00")),
):
try:
parsed = parser(text)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC)
except Exception:
continue
return None
def _extract_item_text(element: ET.Element, *names: str) -> str:
for name in names:
node = element.find(name)
if node is not None and node.text:
return node.text.strip()
return ""
def _parse_feed_entries(xml_text: str, source: NewsFeedSource) -> list[ParsedNewsItem]:
root = ET.fromstring(xml_text)
items: list[ParsedNewsItem] = []
rss_items = root.findall("./channel/item")
atom_entries = root.findall("{http://www.w3.org/2005/Atom}entry")
nodes = rss_items or atom_entries
for node in nodes[:MAX_ITEMS_PER_SOURCE]:
if node.tag.endswith("entry"):
title = _extract_item_text(node, "{http://www.w3.org/2005/Atom}title")
summary = _extract_item_text(
node,
"{http://www.w3.org/2005/Atom}summary",
"{http://www.w3.org/2005/Atom}content",
)
link_node = node.find("{http://www.w3.org/2005/Atom}link")
link = link_node.get("href", "").strip() if link_node is not None else ""
published = _extract_item_text(
node,
"{http://www.w3.org/2005/Atom}updated",
"{http://www.w3.org/2005/Atom}published",
)
else:
title = _extract_item_text(node, "title")
summary = _extract_item_text(node, "description", "content")
link = _extract_item_text(node, "link")
published = _extract_item_text(node, "pubDate", "published", "updated")
clean_title = html.unescape(title).strip()
clean_summary = _truncate(_strip_html(summary), 180)
if not clean_title or not link:
continue
item_source = _normalize_source_name(clean_title, source.name)
display_title = clean_title
if source.source_type == "aggregated" and " - " in clean_title:
parts = clean_title.rsplit(" - ", 1)
display_title = parts[0].strip()
item_source = _normalize_source_name(parts[1], source.name)
items.append(
ParsedNewsItem(
id=f"{source.id}:{hashlib.sha1(link.encode('utf-8')).hexdigest()[:12]}",
title=display_title,
summary=clean_summary,
url=link,
source=item_source,
feed_name=source.name,
feed_region=source.region,
homepage_url=source.homepage_url,
published_at=_parse_datetime(published),
)
)
return items
def _serialize_sources(sources: list[NewsFeedSource]) -> list[dict[str, Any]]:
return [
{
"id": source.id,
"name": source.name,
"region": source.region,
"homepage_url": source.homepage_url,
}
for source in sources
]
def _serialize_anchor(anchor: RegionAnchor) -> dict[str, Any]:
return {
"region": anchor.region,
"label": anchor.label,
"latitude": anchor.latitude,
"longitude": anchor.longitude,
}
def _serialize_target(target: NewsTargetLocation | None) -> dict[str, Any] | None:
if target is None:
return None
return {
"latitude": target.latitude,
"longitude": target.longitude,
"label": target.label,
"source": target.source,
"confidence": target.confidence,
"country": target.country,
"city": target.city,
}
def build_anchor_location_patch(
item: ParsedNewsItem,
*,
queued: bool = False,
queue_available: bool | None = None,
) -> dict[str, Any]:
anchor = get_region_anchor(item.feed_region)
if queued:
resolution_stage = "queued"
ai_status = "queued"
debug_note = "queued for async target location inference"
else:
resolution_stage = item.target_resolution_stage
ai_status = item.target_ai_status
debug_note = item.target_debug_note
return {
"latitude": anchor.latitude,
"longitude": anchor.longitude,
"location_label": anchor.label,
"location_source": "region_anchor",
"verified": False,
"location_meta": {
"resolution_stage": resolution_stage,
"ai_attempted": item.target_ai_attempted,
"ai_status": ai_status,
"ai_error": item.target_ai_error,
"debug_note": debug_note,
"queue_available": queue_available,
"target": None,
"anchor": _serialize_anchor(anchor),
},
}
def build_target_location_patch(item: ParsedNewsItem, target: NewsTargetLocation | None) -> dict[str, Any]:
if target is None:
return build_anchor_location_patch(item)
anchor = get_region_anchor(item.feed_region)
return {
"latitude": target.latitude,
"longitude": target.longitude,
"location_label": target.label,
"location_source": target.source,
"verified": True,
"location_meta": {
"resolution_stage": item.target_resolution_stage,
"ai_attempted": item.target_ai_attempted,
"ai_status": item.target_ai_status,
"ai_error": item.target_ai_error,
"debug_note": item.target_debug_note,
"target": _serialize_target(target),
"anchor": _serialize_anchor(anchor),
},
}
def build_target_location_job_payload(item: ParsedNewsItem) -> dict[str, Any]:
published_at = item.published_at
return {
"id": item.id,
"title": item.title,
"summary": item.summary,
"url": item.url,
"source": item.source,
"feed_name": item.feed_name,
"feed_region": item.feed_region,
"homepage_url": item.homepage_url,
"published_at": published_at.isoformat().replace("+00:00", "Z") if published_at else None,
}
def parsed_news_item_from_job_payload(payload: dict[str, Any]) -> ParsedNewsItem:
return ParsedNewsItem(
id=str(payload.get("id") or ""),
title=str(payload.get("title") or ""),
summary=str(payload.get("summary") or ""),
url=str(payload.get("url") or ""),
source=str(payload.get("source") or ""),
feed_name=str(payload.get("feed_name") or ""),
feed_region=str(payload.get("feed_region") or "global"),
homepage_url=str(payload.get("homepage_url") or ""),
published_at=_parse_datetime(_coerce_str(payload.get("published_at"))),
)
def _serialize_item(item: ParsedNewsItem, *, active_region: str) -> dict[str, Any]:
published_at = item.published_at
location_patch = item.location_patch or build_target_location_patch(item, item.target_location)
return {
"id": item.id,
"title": item.title,
"summary": item.summary,
"url": item.url,
"source": item.source,
"feed_name": item.feed_name,
"region": item.feed_region,
"homepage_url": item.homepage_url,
"published_at": published_at.isoformat().replace("+00:00", "Z") if published_at else None,
"latitude": location_patch["latitude"],
"longitude": location_patch["longitude"],
"location_label": location_patch["location_label"],
"location_source": location_patch["location_source"],
"verified": location_patch["verified"],
"location_meta": location_patch["location_meta"],
"is_focus_match": item.feed_region == active_region,
}
def _build_payload(
*,
lat: float | None,
lon: float | None,
active_region: str,
items: list[ParsedNewsItem],
sources: list[NewsFeedSource],
errors: list[str],
stale: bool,
generated_at: datetime | None = None,
) -> dict[str, Any]:
profile = get_region_profile(active_region)
timestamp = generated_at or datetime.now(UTC)
return {
"generated_at": timestamp.isoformat().replace("+00:00", "Z"),
"focus": {
"lat": lat,
"lon": lon,
"region": active_region,
"label": profile.label,
"accent": profile.accent,
},
"sources": _serialize_sources(sources),
"items": [_serialize_item(item, active_region=active_region) for item in items],
"errors": errors,
"stale": stale,
}
def _rank_and_trim_items(items: list[ParsedNewsItem], *, active_region: str) -> list[ParsedNewsItem]:
deduped: dict[str, ParsedNewsItem] = {}
for item in items:
key = item.url.strip() or item.title.strip().lower()
if key not in deduped:
deduped[key] = item
return sorted(
deduped.values(),
key=lambda item: (
item.feed_region != active_region,
item.published_at is None,
-(item.published_at.timestamp() if item.published_at else 0),
item.feed_name,
),
)[:MAX_ITEMS_TOTAL]
def _get_cached_region_feed(region: str) -> CachedRegionFeed | None:
cached = _REGION_CACHE.get(region)
if not cached:
return None
age_seconds = (datetime.now(UTC) - cached.fetched_at).total_seconds()
if age_seconds > STALE_CACHE_MAX_AGE_SECONDS:
return None
return cached
def _store_region_cache(region: str, *, items: list[ParsedNewsItem], sources: list[NewsFeedSource]) -> None:
_REGION_CACHE[region] = CachedRegionFeed(
region=region,
fetched_at=datetime.now(UTC),
items=list(items),
sources=list(sources),
)
async def _apply_cached_locations_and_enqueue(items: list[ParsedNewsItem]) -> list[ParsedNewsItem]:
if not items:
return items
from app.services.earth_news_queue import (
enqueue_target_location_job,
get_cached_target_location_patch,
)
async def apply_location(item: ParsedNewsItem) -> ParsedNewsItem:
cached_patch = await get_cached_target_location_patch(item.id)
if cached_patch:
item.location_patch = cached_patch
return item
queued = await enqueue_target_location_job(build_target_location_job_payload(item))
item.location_patch = build_anchor_location_patch(
item,
queued=queued,
queue_available=queued,
)
return item
return list(await asyncio.gather(*(apply_location(item) for item in items)))
async def _enqueue_unverified_locations(items: list[ParsedNewsItem]) -> None:
if not items:
return
from app.services.earth_news_queue import enqueue_target_location_job
await asyncio.gather(
*(
enqueue_target_location_job(build_target_location_job_payload(item))
for item in items
if item.location_patch is None or item.location_patch.get("verified") is False
)
)
async def _fetch_source(
client: httpx.AsyncClient,
source: NewsFeedSource,
) -> tuple[NewsFeedSource, list[ParsedNewsItem], str | None]:
try:
response = await client.get(source.feed_url)
response.raise_for_status()
return source, _parse_feed_entries(response.text, source), None
except Exception as exc:
return source, [], str(exc)
async def _fetch_rss_items_for_sources(
sources: list[NewsFeedSource],
) -> tuple[list[ParsedNewsItem], list[str]]:
errors: list[str] = []
async with httpx.AsyncClient(
timeout=REQUEST_TIMEOUT,
follow_redirects=True,
headers={"User-Agent": USER_AGENT},
) as client:
results = await asyncio.gather(*(_fetch_source(client, source) for source in sources))
fetched_items: list[ParsedNewsItem] = []
for source, items, error in results:
if error:
errors.append(f"{source.name}: {error}")
continue
fetched_items.extend(items)
return fetched_items, errors
def _needs_rss_supplement(*, item_count: int, newest_at: datetime | None) -> bool:
if item_count < MAX_ITEMS_TOTAL:
return True
if newest_at is None:
return True
age_seconds = (datetime.now(UTC) - newest_at).total_seconds()
return age_seconds > RSS_SUPPLEMENT_MAX_AGE_SECONDS
async def _get_earth_news_payload_from_rss_only(
*,
lat: float | None,
lon: float | None,
active_region: str,
sources: list[NewsFeedSource],
) -> dict[str, Any]:
fetched_items, errors = await _fetch_rss_items_for_sources(sources)
ranked_items = _rank_and_trim_items(fetched_items, active_region=active_region)
if ranked_items:
ranked_items = await _apply_cached_locations_and_enqueue(ranked_items)
_store_region_cache(active_region, items=ranked_items, sources=sources)
return _build_payload(
lat=lat,
lon=lon,
active_region=active_region,
items=ranked_items,
sources=sources,
errors=errors,
stale=False,
)
cached = _get_cached_region_feed(active_region)
if cached:
cached.items = await _apply_cached_locations_and_enqueue(cached.items)
return _build_payload(
lat=lat,
lon=lon,
active_region=active_region,
items=cached.items,
sources=cached.sources,
errors=errors,
stale=True,
generated_at=cached.fetched_at,
)
return _build_payload(
lat=lat,
lon=lon,
active_region=active_region,
items=[],
sources=sources,
errors=errors,
stale=False,
)
async def get_earth_news_payload(
lat: float | None = None,
lon: float | None = None,
*,
provider_client: AIProviderClient | None = None,
db: AsyncSession | None = None,
) -> dict[str, Any]:
del provider_client
active_region = determine_focus_region(lat, lon)
sources = get_sources_for_region(active_region)
if db is None:
return await _get_earth_news_payload_from_rss_only(
lat=lat,
lon=lon,
active_region=active_region,
sources=sources,
)
from app.services.earth_news_store import (
get_earth_news_freshness,
list_earth_news_items,
upsert_earth_news_items,
)
errors: list[str] = []
item_count, newest_at = await get_earth_news_freshness(db, active_region=active_region)
should_supplement = _needs_rss_supplement(item_count=item_count, newest_at=newest_at)
if should_supplement:
fetched_items, errors = await _fetch_rss_items_for_sources(sources)
ranked_fetched_items = _rank_and_trim_items(fetched_items, active_region=active_region)
await upsert_earth_news_items(db, ranked_fetched_items)
items = await list_earth_news_items(
db,
active_region=active_region,
limit=MAX_ITEMS_TOTAL,
)
await _enqueue_unverified_locations(items)
stale = bool(errors and items)
return _build_payload(
lat=lat,
lon=lon,
active_region=active_region,
items=items,
sources=sources,
errors=errors,
stale=stale,
)