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"(? 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, )