from __future__ import annotations import asyncio from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta from email.utils import parsedate_to_datetime import hashlib import html import json import math import re from time import perf_counter from typing import Any from urllib.parse import quote import xml.etree.ElementTree as ET import httpx from bs4 import BeautifulSoup from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.countries import COUNTRY_VARIANTS_MAP, get_country_centroid, normalize_country from app.core.enums import ( BreakingLevel, BreakingScope, BreakingSource, NewsEnrichmentStatus, NewsImportanceLevel, NewsMarketImpact, NewsSourceType, NewsTaggingSource, ) from app.models.system_setting import SystemSetting from app.ai_tasks.prompts import EffectiveAIPrompt, get_effective_prompt from app.schemas.ai import SituationalAnalysisRequest from app.services.ai_client import AIProviderClient from app.services.earth_news_classification import ( apply_news_classification as _apply_news_classification, breaking_sort_rank as _breaking_sort_rank, highest_breaking_level as _highest_breaking_level, normalize_breaking_level as _normalize_breaking_level_enum, normalize_breaking_scope as _normalize_breaking_scope_enum, ) 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 DEFAULT_NEWS_LOCALE = "zh-CN" SUPPORTED_NEWS_LOCALES = frozenset({"zh-CN", "en-US"}) NEWS_ENRICH_PROMPT_KEY = "earth.news.enrich" EARTH_NEWS_SOURCES_CATEGORY = "earth_news_sources" DEFAULT_NEWS_HEALTH_POLICY = { "timeout_seconds": REQUEST_TIMEOUT, "failure_threshold": 3, "cooldown_minutes": 30, "fetch_interval_minutes": 45, "circuit_breaker": True, } @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 NewsFeedEndpoint: id: str name: str url: str type: str = NewsSourceType.RSS.value region: str = "" enabled: bool = True default_category: str = "other" tags: tuple[str, ...] = () priority: int = 100 @dataclass(frozen=True) class NewsFeedSource: id: str name: str region: str feed_url: str homepage_url: str feed_directory_url: str = "" source_type: str = NewsSourceType.RSS.value feed_urls: tuple[str, ...] = () feeds: tuple[NewsFeedEndpoint, ...] = () priority: int = 100 enabled: bool = True source_tags: tuple[str, ...] = () default_category: str = "other" importance_weight: int = 0 health_policy: dict[str, Any] = field(default_factory=dict) @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 content_language: str = "en" localizations: dict[str, dict[str, str]] = field(default_factory=dict) enrichment_status: str = NewsEnrichmentStatus.PENDING.value enrichment_error: str | None = None enriched_at: datetime | None = 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 source_tags: list[str] = field(default_factory=list) feed_id: str = "" feed_type: str = NewsSourceType.RSS.value feed_default_category: str = "other" category: str = "other" item_tags: list[str] = field(default_factory=list) tagging_source: str = NewsTaggingSource.RULES.value tagging_confidence: float = 0.0 importance_score: int = 0 importance_level: str = NewsImportanceLevel.LOW.value importance_reasons: list[str] = field(default_factory=list) market_impact: str = NewsMarketImpact.NONE.value breaking_level: str = BreakingLevel.NONE.value breaking_scope: str = BreakingScope.REGIONAL.value breaking_reasons: list[str] = field(default_factory=list) breaking_source: str = BreakingSource.RULES.value breaking_confidence: float = 0.0 breaking_expires_at: datetime | 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", feeds=( NewsFeedEndpoint(id="world", name="World", url="https://feeds.bbci.co.uk/news/world/rss.xml", default_category="politics", priority=1), ), source_type="rss", priority=10, source_tags=("official_media", "business_news", "global"), default_category="politics", importance_weight=12, health_policy=DEFAULT_NEWS_HEALTH_POLICY, ), 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", feeds=( NewsFeedEndpoint(id="top", name="Top Stories", url="https://rss.dw.com/rdf/rss-en-top", default_category="politics", priority=1), ), source_type="rss", priority=20, source_tags=("official_media", "business_news", "global", "europe"), default_category="politics", importance_weight=12, health_policy=DEFAULT_NEWS_HEALTH_POLICY, ), NewsFeedSource( id="cnbc-business", name="CNBC Business", region="global", feed_url="https://www.cnbc.com/id/10001147/device/rss/rss.html", homepage_url="https://www.cnbc.com/business/", feeds=( NewsFeedEndpoint(id="business", name="Business", url="https://www.cnbc.com/id/10001147/device/rss/rss.html", default_category="business", priority=1), ), source_type="rss", priority=21, source_tags=("business_news", "global", "us"), default_category="business", importance_weight=16, health_policy=DEFAULT_NEWS_HEALTH_POLICY, ), NewsFeedSource( id="bbc-business", name="BBC Business", region="global", feed_url="https://feeds.bbci.co.uk/news/business/rss.xml", homepage_url="https://www.bbc.com/news/business", feeds=( NewsFeedEndpoint(id="business", name="Business", url="https://feeds.bbci.co.uk/news/business/rss.xml", default_category="business", priority=1), ), source_type="rss", priority=22, source_tags=("official_media", "business_news", "global"), default_category="business", importance_weight=14, health_policy=DEFAULT_NEWS_HEALTH_POLICY, ), NewsFeedSource( id="guardian-business", name="The Guardian Business", region="europe", feed_url="https://www.theguardian.com/uk/business/rss", homepage_url="https://www.theguardian.com/uk/business", feeds=( NewsFeedEndpoint(id="business", name="Business", url="https://www.theguardian.com/uk/business/rss", default_category="business", priority=1), ), source_type="rss", priority=24, source_tags=("business_news", "global", "europe"), default_category="business", importance_weight=12, health_policy=DEFAULT_NEWS_HEALTH_POLICY, ), NewsFeedSource( id="npr-business", name="NPR Business", region="americas", feed_url="https://feeds.npr.org/1006/rss.xml", homepage_url="https://www.npr.org/sections/business/", feeds=( NewsFeedEndpoint(id="business", name="Business", url="https://feeds.npr.org/1006/rss.xml", default_category="business", priority=1), ), source_type="rss", priority=25, source_tags=("business_news", "global", "us"), default_category="business", importance_weight=12, health_policy=DEFAULT_NEWS_HEALTH_POLICY, ), NewsFeedSource( id="marketwatch-top", name="MarketWatch Top Stories", region="americas", feed_url="https://feeds.content.dowjones.io/public/rss/mw_topstories", homepage_url="https://www.marketwatch.com/", feeds=( NewsFeedEndpoint(id="top", name="Top Stories", url="https://feeds.content.dowjones.io/public/rss/mw_topstories", default_category="finance", priority=1), ), source_type="rss", priority=26, source_tags=("business_news", "finance", "global", "us"), default_category="finance", importance_weight=12, health_policy=DEFAULT_NEWS_HEALTH_POLICY, ), NewsFeedSource( id="techcrunch", name="TechCrunch", region="global", feed_url="https://techcrunch.com/feed/", homepage_url="https://techcrunch.com/", feeds=( NewsFeedEndpoint(id="main", name="Main Feed", url="https://techcrunch.com/feed/", default_category="technology", priority=1), ), source_type="rss", priority=32, source_tags=("business_news", "ecommerce", "global"), default_category="technology", importance_weight=8, health_policy=DEFAULT_NEWS_HEALTH_POLICY, ), NewsFeedSource( id="retaildive", name="Retail Dive", region="global", feed_url="https://www.retaildive.com/feeds/news/", homepage_url="https://www.retaildive.com/", feeds=( NewsFeedEndpoint(id="news", name="News", url="https://www.retaildive.com/feeds/news/", default_category="business", priority=1), ), source_type="rss", priority=34, source_tags=("business_news", "retail", "ecommerce", "global"), default_category="business", importance_weight=10, health_policy=DEFAULT_NEWS_HEALTH_POLICY, ), NewsFeedSource( id="prnewswire-retail", name="PR Newswire Consumer Products & Retail", region="global", feed_url="https://www.prnewswire.com/rss/consumer-products-retail-latest-news/consumer-products-retail-latest-news-list.rss", homepage_url="https://www.prnewswire.com/news-releases/consumer-products-retail-latest-news/", feeds=( NewsFeedEndpoint(id="consumer-retail", name="Consumer Products & Retail", url="https://www.prnewswire.com/rss/consumer-products-retail-latest-news/consumer-products-retail-latest-news-list.rss", default_category="business", priority=1), ), source_type="rss", priority=48, source_tags=("press_release", "retail", "ecommerce", "global"), default_category="business", importance_weight=-4, health_policy={**DEFAULT_NEWS_HEALTH_POLICY, "failure_threshold": 2}, ), NewsFeedSource( id="36kr", name="36氪", region="asia-pacific", feed_url="https://36kr.com/feed-article", homepage_url="https://www.36kr.com/", feed_directory_url="https://www.36kr.com/rss-center", source_type="rss", feeds=( NewsFeedEndpoint(id="feed", name="综合资讯", url="https://36kr.com/feed", default_category="business", priority=1), NewsFeedEndpoint(id="article", name="文章资讯", url="https://36kr.com/feed-article", default_category="business", priority=2), NewsFeedEndpoint(id="newsflash", name="最新快讯", url="https://36kr.com/feed-newsflash", default_category="business", priority=3), NewsFeedEndpoint(id="moment", name="动态内容", url="https://36kr.com/feed-moment", default_category="business", priority=4), ), priority=35, source_tags=("business_news", "ecommerce", "china"), default_category="business", importance_weight=12, health_policy=DEFAULT_NEWS_HEALTH_POLICY, ), NewsFeedSource( id="businesswire-ecommerce", name="BusinessWire Electronic Commerce", region="global", feed_url="https://www.businesswire.com/newsroom/industry/technology/ecommerce", homepage_url="https://www.businesswire.com/newsroom/industry/technology/ecommerce", source_type="reference", priority=62, enabled=False, source_tags=("press_release", "ecommerce", "global", "low_stability"), default_category="ecommerce", importance_weight=-6, health_policy={**DEFAULT_NEWS_HEALTH_POLICY, "failure_threshold": 2}, ), NewsFeedSource( id="mckinsey-retail", name="McKinsey Retail Insights", region="global", feed_url="https://www.mckinsey.com/industries/retail/our-insights", homepage_url="https://www.mckinsey.com/industries/retail/our-insights", source_type="reference", priority=64, enabled=False, source_tags=("industry_insight", "retail", "global"), default_category="business", importance_weight=18, health_policy={**DEFAULT_NEWS_HEALTH_POLICY, "fetch_interval_minutes": 720}, ), NewsFeedSource( id="deloitte-retail", name="Deloitte Retail", region="global", feed_url="https://www.deloitte.com/us/en/Industries/retail/about.html", homepage_url="https://www.deloitte.com/us/en/Industries/retail/about.html", source_type="reference", priority=66, enabled=False, source_tags=("industry_insight", "retail", "global", "us"), default_category="business", importance_weight=16, health_policy={**DEFAULT_NEWS_HEALTH_POLICY, "fetch_interval_minutes": 720}, ), NewsFeedSource( id="us-census-ecommerce", name="US Census Retail / Quarterly E-Commerce", region="americas", feed_url="https://www.census.gov/retail/index.html#ecommerce", homepage_url="https://www.census.gov/retail/index.html#ecommerce", source_type="reference", priority=18, enabled=False, source_tags=("official_data", "ecommerce", "retail", "us"), default_category="ecommerce", importance_weight=34, health_policy={**DEFAULT_NEWS_HEALTH_POLICY, "fetch_interval_minutes": 1440}, ), NewsFeedSource( id="mofcom-data", name="商务数据中心", region="asia-pacific", feed_url="https://data.mofcom.gov.cn/index.shtml", homepage_url="https://data.mofcom.gov.cn/index.shtml", source_type="reference", priority=18, enabled=False, source_tags=("official_data", "business_news", "china"), default_category="business", importance_weight=34, health_policy={**DEFAULT_NEWS_HEALTH_POLICY, "fetch_interval_minutes": 1440}, ), NewsFeedSource( id="mofcom-ecommerce", name="商务部电商动态", region="asia-pacific", feed_url="https://www.mofcom.gov.cn/", homepage_url="https://www.mofcom.gov.cn/", source_type="reference", priority=19, enabled=False, source_tags=("official_data", "ecommerce", "china"), default_category="ecommerce", importance_weight=34, health_policy={**DEFAULT_NEWS_HEALTH_POLICY, "fetch_interval_minutes": 1440}, ), NewsFeedSource( id="stats-china-online-retail", name="国家统计局数据发布", region="asia-pacific", feed_url="https://www.stats.gov.cn/sj/zxfb/rss.xml", homepage_url="https://www.stats.gov.cn/sj/zxfb/", feeds=( NewsFeedEndpoint(id="release", name="数据发布", url="https://www.stats.gov.cn/sj/zxfb/rss.xml", default_category="ecommerce", priority=1), ), source_type="rss", priority=19, source_tags=("official_data", "ecommerce", "retail", "china"), default_category="ecommerce", importance_weight=36, health_policy={**DEFAULT_NEWS_HEALTH_POLICY, "fetch_interval_minutes": 1440}, ), NewsFeedSource( id="china-ecommerce-logistics-index", name="电商物流指数", region="asia-pacific", feed_url="https://www.gov.cn/", homepage_url="https://www.gov.cn/", source_type="reference", priority=20, enabled=False, source_tags=("official_data", "ecommerce", "retail", "china"), default_category="ecommerce", importance_weight=36, health_policy={**DEFAULT_NEWS_HEALTH_POLICY, "fetch_interval_minutes": 1440}, ), NewsFeedSource( id="ebrun", name="亿邦动力", region="asia-pacific", feed_url="https://www.ebrun.com/rss/news_b2c.xml", homepage_url="https://www.ebrun.com/", feed_directory_url="https://www.ebrun.com/rss/", source_type="rss", feeds=( NewsFeedEndpoint(id="b2c", name="B2C", url="https://www.ebrun.com/rss/news_b2c.xml", default_category="ecommerce", priority=1), NewsFeedEndpoint(id="b2b", name="B2B", url="https://www.ebrun.com/rss/news_b2b.xml", default_category="ecommerce", priority=2), NewsFeedEndpoint(id="retail", name="零售", url="https://www.ebrun.com/rss/news_retail.xml", default_category="ecommerce", priority=3), NewsFeedEndpoint(id="o2o", name="O2O", url="https://www.ebrun.com/rss/news_o2o.xml", default_category="ecommerce", priority=4), NewsFeedEndpoint(id="service", name="服务", url="https://www.ebrun.com/rss/news_service.xml", default_category="ecommerce", priority=5), NewsFeedEndpoint(id="data", name="数据", url="https://www.ebrun.com/rss/news_data.xml", default_category="ecommerce", priority=6), NewsFeedEndpoint(id="policy", name="政策", url="https://www.ebrun.com/rss/news_policy.xml", default_category="ecommerce", priority=7), ), priority=38, source_tags=("business_news", "ecommerce", "retail", "china"), default_category="ecommerce", importance_weight=14, health_policy=DEFAULT_NEWS_HEALTH_POLICY, ), NewsFeedSource( id="google-news", name="Google News", 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/", feed_directory_url="https://news.google.com/rss", feeds=( NewsFeedEndpoint(id="world", name="全球", url=_google_news_feed(REGION_PROFILES["global"].query, hl="en-US", gl="US", ceid="US:en"), type="aggregated", region="global", default_category="politics", priority=1), NewsFeedEndpoint(id="americas", name="美洲", url=_google_news_feed(REGION_PROFILES["americas"].query, hl="en-US", gl="US", ceid="US:en"), type="aggregated", region="americas", default_category="politics", priority=2), NewsFeedEndpoint(id="europe", name="欧洲", url=_google_news_feed(REGION_PROFILES["europe"].query, hl="en-GB", gl="GB", ceid="GB:en"), type="aggregated", region="europe", default_category="politics", priority=3), NewsFeedEndpoint(id="middle-east-africa", name="中东与非洲", url=_google_news_feed(REGION_PROFILES["middle-east-africa"].query, hl="en-US", gl="US", ceid="US:en"), type="aggregated", region="middle-east-africa", default_category="politics", priority=4), NewsFeedEndpoint(id="asia-pacific", name="亚太", url=_google_news_feed(REGION_PROFILES["asia-pacific"].query, hl="en-SG", gl="SG", ceid="SG:en"), type="aggregated", region="asia-pacific", default_category="politics", priority=5), ), source_type="aggregated", priority=90, source_tags=("aggregated", "business_news", "global", "europe", "low_stability"), default_category="politics", importance_weight=-2, health_policy=DEFAULT_NEWS_HEALTH_POLICY, ), ) DEFAULT_NEWS_SOURCE_BY_ID: dict[str, NewsFeedSource] = {source.id: source for source in NEWS_FEED_SOURCES} LEGACY_GOOGLE_NEWS_SOURCE_IDS = { "global-scan", "google-americas", "google-europe", "google-mea", "google-apac", } DEFAULT_SOURCE_TAGS: tuple[dict[str, Any], ...] = ( {"key": "official_media", "label": "官方媒体", "color": "#2563eb", "enabled": True, "sort_order": 10}, {"key": "official_data", "label": "官方数据", "color": "#059669", "enabled": True, "sort_order": 20}, {"key": "business_news", "label": "商业新闻", "color": "#0f766e", "enabled": True, "sort_order": 30}, {"key": "ecommerce", "label": "电商", "color": "#7c3aed", "enabled": True, "sort_order": 40}, {"key": "finance", "label": "金融", "color": "#1d4ed8", "enabled": True, "sort_order": 45}, {"key": "retail", "label": "零售", "color": "#ea580c", "enabled": True, "sort_order": 50}, {"key": "logistics", "label": "物流", "color": "#16a34a", "enabled": True, "sort_order": 55}, {"key": "industry_insight", "label": "行业洞察", "color": "#0891b2", "enabled": True, "sort_order": 60}, {"key": "press_release", "label": "企业公告", "color": "#64748b", "enabled": True, "sort_order": 70}, {"key": "china", "label": "中国", "color": "#dc2626", "enabled": True, "sort_order": 80}, {"key": "global", "label": "全球", "color": "#475569", "enabled": True, "sort_order": 90}, {"key": "us", "label": "美国", "color": "#1d4ed8", "enabled": True, "sort_order": 100}, {"key": "europe", "label": "欧洲", "color": "#0284c7", "enabled": True, "sort_order": 110}, {"key": "aggregated", "label": "聚合源", "color": "#9333ea", "enabled": True, "sort_order": 120}, {"key": "low_stability", "label": "低稳定性", "color": "#f59e0b", "enabled": True, "sort_order": 130}, ) DEFAULT_CATEGORIES: tuple[dict[str, Any], ...] = ( {"key": "politics", "label": "政治", "color": "#2563eb", "enabled": True, "sort_order": 10, "keywords": ["election", "government", "minister", "parliament", "sanction", "diplomatic", "policy", "summit", "选举", "政府", "制裁", "外交", "政策"]}, {"key": "business", "label": "商业", "color": "#0f766e", "enabled": True, "sort_order": 20, "keywords": ["market", "company", "earnings", "trade", "supply chain", "merger", "retail", "consumer", "商业", "公司", "贸易", "供应链", "消费", "零售"]}, {"key": "ecommerce", "label": "电商", "color": "#7c3aed", "enabled": True, "sort_order": 30, "keywords": ["e-commerce", "ecommerce", "online retail", "gmv", "marketplace", "shopify", "amazon", "tiktok shop", "网上零售", "电商", "直播电商", "跨境电商", "订单量", "物流指数", "履约"]}, {"key": "finance", "label": "金融", "color": "#0369a1", "enabled": True, "sort_order": 40, "keywords": ["stock", "bond", "inflation", "central bank", "rate cut", "rate hike", "bank", "金融", "股市", "通胀", "央行", "利率"]}, {"key": "sports", "label": "体育", "color": "#16a34a", "enabled": True, "sort_order": 50, "keywords": ["football", "basketball", "tennis", "match", "league", "world cup", "olympics", "体育", "足球", "篮球", "世界杯", "奥运"]}, {"key": "technology", "label": "科技", "color": "#0891b2", "enabled": True, "sort_order": 60, "keywords": ["ai", "semiconductor", "chip", "cyber", "satellite", "software", "科技", "人工智能", "半导体", "芯片", "网络安全"]}, {"key": "military", "label": "军事", "color": "#4b5563", "enabled": True, "sort_order": 70, "keywords": ["military", "missile", "airstrike", "defense", "warship", "军事", "导弹", "空袭", "防务", "军舰"]}, {"key": "disaster", "label": "灾害", "color": "#dc2626", "enabled": True, "sort_order": 80, "keywords": ["earthquake", "flood", "wildfire", "typhoon", "hurricane", "灾害", "地震", "洪水", "山火", "台风"]}, {"key": "energy", "label": "能源", "color": "#ca8a04", "enabled": True, "sort_order": 90, "keywords": ["oil", "gas", "opec", "energy", "power grid", "能源", "石油", "天然气", "电网"]}, {"key": "society", "label": "社会", "color": "#64748b", "enabled": True, "sort_order": 100, "keywords": ["health", "education", "crime", "migration", "社会", "医疗", "教育", "犯罪", "移民"]}, {"key": "culture", "label": "文化", "color": "#db2777", "enabled": True, "sort_order": 110, "keywords": ["film", "music", "art", "culture", "文化", "电影", "音乐", "艺术"]}, {"key": "other", "label": "其他", "color": "#64748b", "enabled": True, "sort_order": 999, "keywords": []}, ) ALLOWED_NEWS_CATEGORY_KEYS = tuple(item["key"] for item in DEFAULT_CATEGORIES) DEFAULT_ITEM_TAG_RULES: tuple[dict[str, Any], ...] = ( {"key": "cross_border_ecommerce", "label": "跨境电商", "category": "ecommerce", "keywords": ["cross-border e-commerce", "cross border ecommerce", "跨境电商"]}, {"key": "live_commerce", "label": "直播电商", "category": "ecommerce", "keywords": ["live commerce", "livestream shopping", "直播电商", "直播带货"]}, {"key": "retail_data", "label": "零售数据", "category": "ecommerce", "keywords": ["online retail sales", "retail sales", "网上零售额", "社零", "社会消费品零售总额"]}, {"key": "logistics_fulfillment", "label": "物流履约", "category": "ecommerce", "keywords": ["logistics", "fulfillment", "delivery", "物流指数", "履约", "配送"]}, {"key": "platform_governance", "label": "平台治理", "category": "ecommerce", "keywords": ["platform regulation", "marketplace rules", "平台治理", "平台监管"]}, {"key": "ai", "label": "AI", "category": "technology", "keywords": ["ai", "artificial intelligence", "人工智能", "大模型"]}, {"key": "semiconductor", "label": "半导体", "category": "technology", "keywords": ["semiconductor", "chip", "半导体", "芯片"]}, {"key": "election", "label": "选举", "category": "politics", "keywords": ["election", "vote", "campaign", "选举", "投票"]}, {"key": "oil_price", "label": "油价", "category": "energy", "keywords": ["oil price", "crude", "油价", "原油"]}, {"key": "football", "label": "足球", "category": "sports", "keywords": ["football", "soccer", "premier league", "足球"]}, {"key": "supply_chain", "label": "供应链", "category": "business", "keywords": ["supply chain", "供应链"]}, ) def default_earth_news_sources_payload() -> dict[str, Any]: return { "cache_version": 1, "source_tags": [dict(item) for item in DEFAULT_SOURCE_TAGS], "categories": [dict(item) for item in DEFAULT_CATEGORIES], "item_tag_rules": [dict(item) for item in DEFAULT_ITEM_TAG_RULES], "sources": [serialize_news_source_config(source) for source in NEWS_FEED_SOURCES], "health": {}, } _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 def _normalize_localizations(value: Any) -> dict[str, dict[str, str]]: if not isinstance(value, dict): return {} normalized: dict[str, dict[str, str]] = {} for locale, payload in value.items(): locale_key = _coerce_str(locale) if not locale_key or not isinstance(payload, dict): continue title = _coerce_str(payload.get("title")) summary = _coerce_str(payload.get("summary")) entry: dict[str, str] = {} if title: entry["title"] = title if summary: entry["summary"] = summary if entry: normalized[locale_key] = entry return normalized def _is_chinese_language(language: str | None) -> bool: return str(language or "").lower() in {"zh", "zh-cn", "zh-hans", "chinese"} def _is_english_language(language: str | None) -> bool: return str(language or "").lower() in {"en", "en-us", "english"} def _contains_cjk_text(value: str) -> bool: return bool(re.search(r"[\u3400-\u9fff]", value or "")) def _detect_content_language( *, title: str, summary: str, source: NewsFeedSource, feed: NewsFeedEndpoint | None = None, ) -> str: text = f"{title}\n{summary}" if _contains_cjk_text(text): return "zh-CN" identity = {source.id.lower(), *(tag.lower() for tag in source.source_tags)} if feed is not None: identity.add(feed.id.lower()) identity.update(tag.lower() for tag in feed.tags) if identity & {"china", "chinese", "36kr", "ebrun", "stats-china-online-retail"}: return "zh-CN" return "en" def _source_language_localizations( *, title: str, summary: str, content_language: str, ) -> dict[str, dict[str, str]]: if _is_chinese_language(content_language): return {"zh-CN": {"title": title, "summary": summary or title}} return {} def _target_localization_locale(item: ParsedNewsItem) -> str: return "en-US" if _is_chinese_language(item.content_language) else DEFAULT_NEWS_LOCALE def _target_locale_schema(locale: str) -> dict[str, dict[str, str]]: if locale == "en-US": return { "en-US": { "title": "faithful English title", "summary": "one-sentence newswire-style English lead summary", } } return { "zh-CN": { "title": "faithful Simplified Chinese title", "summary": "one-sentence newswire-style Simplified Chinese lead summary", } } def _get_locale_text( item: ParsedNewsItem, key: str, *, locale: str = DEFAULT_NEWS_LOCALE, ) -> str: localized = item.localizations.get(locale) if isinstance(localized, dict): value = _coerce_str(localized.get(key)) if value: return value if locale == "zh-CN" and _is_chinese_language(item.content_language): return item.title if key == "title" else item.summary if locale == "en-US" and _is_english_language(item.content_language): return item.title if key == "title" else item.summary fallback = item.localizations.get(DEFAULT_NEWS_LOCALE) if isinstance(fallback, dict): value = _coerce_str(fallback.get(key)) if value: return value if locale == "en-US" and _is_chinese_language(item.content_language): return item.title if key == "title" else item.summary return "" def _has_localization(item: ParsedNewsItem, locale: str) -> bool: localized = item.localizations.get(locale) if not isinstance(localized, dict): return False return bool(_coerce_str(localized.get("title")) and _coerce_str(localized.get("summary"))) def _has_default_localization(item: ParsedNewsItem) -> bool: return _has_localization(item, DEFAULT_NEWS_LOCALE) def _has_required_localization(item: ParsedNewsItem) -> bool: return _has_localization(item, _target_localization_locale(item)) def apply_enrichment_patch_to_item( item: ParsedNewsItem, patch: dict[str, Any], ) -> ParsedNewsItem: item.location_patch = patch if "content_language" in patch: item.content_language = _coerce_str(patch.get("content_language")) or item.content_language if "localizations" in patch: item.localizations = _normalize_localizations(patch.get("localizations")) if "enrichment_status" in patch: item.enrichment_status = _coerce_str(patch.get("enrichment_status")) or item.enrichment_status if "enrichment_error" in patch: item.enrichment_error = _coerce_str(patch.get("enrichment_error")) if "enriched_at" in patch: item.enriched_at = _parse_datetime(_coerce_str(patch.get("enriched_at"))) return item 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, prompt: EffectiveAIPrompt | None = None, ) -> NewsTargetLocation | None: target, _localizations = await _infer_news_enrichment( item, provider_client=provider_client, prompt=prompt, ) return target async def _infer_news_enrichment( item: ParsedNewsItem, *, provider_client: AIProviderClient | None, prompt: EffectiveAIPrompt | None = None, ) -> tuple[NewsTargetLocation | None, dict[str, dict[str, str]]]: text_hint = await _extract_target_location_from_text(item) content_error: str | None = None 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}" localizations: dict[str, dict[str, str]] = {} if provider_client is None: if text_hint is None or not text_hint.city: 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" ) item.enrichment_status = "unavailable" item.enrichment_error = "AI provider is not configured or unavailable for earth-feed." return text_hint, localizations if text_hint is None or not text_hint.city: 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" ) item.enrichment_status = "attempted" item.enrichment_error = None prompt = prompt or await get_effective_prompt(None, NEWS_ENRICH_PROMPT_KEY) target_locale = _target_localization_locale(item) target_locale_name = "English" if target_locale == "en-US" else "Simplified Chinese" request = SituationalAnalysisRequest( title=f"Enrich Earth news item with event location and {target_locale} content", objective=prompt.prompt, system_prompt=prompt.system_prompt or None, context={ "news_item": { "title": item.title, "summary": item.summary, "content_language": item.content_language, "source": item.source, "feed_name": item.feed_name, "feed_id": item.feed_id, "feed_type": item.feed_type, "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": { "location": { "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", }, "localizations": _target_locale_schema(target_locale), }, }, constraints=[ "Return only strict JSON. Do not wrap it in markdown.", "For localizations, do not add facts that are absent from the RSS headline, description, source, or date.", f"Write {target_locale} summary as one concise {target_locale_name} newswire-style sentence, like a breaking-news lead.", "If the RSS description is thin, write a conservative one-sentence summary that says only what is supported.", f"Keep {target_locale} summary factual, non-promotional, and avoid colon-heavy keyword labels.", "If the source content is Chinese and target locale is en-US, translate faithfully into English instead of rewriting the story.", "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: if text_hint is None or not text_hint.city: 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) item.enrichment_status = "provider_error" item.enrichment_error = str(exc) return text_hint, localizations payload = _first_json_object(response.content) if not isinstance(payload, dict): if text_hint is None or not text_hint.city: 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." item.enrichment_status = "parse_error" item.enrichment_error = "AI response did not contain a parseable JSON object." return text_hint, localizations localizations = _normalize_localizations(payload.get("localizations")) if not localizations: content_error = "AI returned no usable localizations." location_payload = payload.get("location") if isinstance(payload.get("location"), dict) else payload if text_hint is not None and text_hint.city: target = text_hint else: target = await _build_target_location_from_payload(location_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." target = text_hint elif 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}" target = text_hint else: 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}" item.localizations = {**dict(item.localizations or {}), **localizations} if localizations and item.target_ai_status in {"success", "skipped_text_hint"}: item.enrichment_status = "success" item.enrichment_error = None elif localizations: item.enrichment_status = "content_only" item.enrichment_error = item.target_ai_error else: item.enrichment_status = "location_only" if target is not None else "no_result" item.enrichment_error = content_error or item.target_ai_error item.enriched_at = datetime.now(UTC) if localizations else None return target, localizations async def _enrich_items_with_target_locations( items: list[ParsedNewsItem], *, provider_client: AIProviderClient | None, prompt: EffectiveAIPrompt | None = 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, prompt=prompt, ) 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.enabled and source.source_type in {"rss", "atom", "aggregated"} and _source_matches_active_region(source, region) ], key=lambda source: (source.priority, source.name), ) def _clean_feed_urls(*values: Any) -> tuple[str, ...]: urls: list[str] = [] for value in values: if isinstance(value, str): parts = re.split(r"[\n,]+", value) elif isinstance(value, (list, tuple)): parts = [str(item) for item in value] else: parts = [] for part in parts: url = str(part or "").strip() if url and url not in urls: urls.append(url) return tuple(urls) def _clean_feed_tags(value: Any) -> tuple[str, ...]: if isinstance(value, str): parts = re.split(r"[\n,,]+", value) elif isinstance(value, (list, tuple)): parts = [str(item) for item in value] else: parts = [] tags: list[str] = [] for part in parts: tag = str(part or "").strip() if tag and tag not in tags: tags.append(tag) return tuple(tags) def _slug_feed_id(value: str, fallback: str) -> str: text_value = str(value or "").strip().lower() slug = re.sub(r"[^a-z0-9_-]+", "-", text_value).strip("-") return slug or fallback def _feed_from_config(raw: Any, *, source: NewsFeedSource | None = None, index: int = 0) -> NewsFeedEndpoint | None: if not isinstance(raw, dict): return None url = str(raw.get("url") or raw.get("feed_url") or "").strip() if not url: return None feed_id = _slug_feed_id(str(raw.get("id") or raw.get("key") or raw.get("name") or ""), f"feed-{index + 1}") fallback_type = source.source_type if source else "rss" feed_type = str(raw.get("type") or raw.get("source_type") or fallback_type).strip().lower() or "rss" default_category = str(raw.get("default_category") or (source.default_category if source else "other") or "other").strip() or "other" try: priority = int(raw.get("priority", index + 1)) except (TypeError, ValueError): priority = index + 1 return NewsFeedEndpoint( id=feed_id, name=str(raw.get("name") or raw.get("label") or feed_id).strip() or feed_id, url=url, type=feed_type, region=str(raw.get("region") or (source.region if source else "") or "").strip(), enabled=raw.get("enabled") is not False and feed_type in {"rss", "atom", "aggregated"}, default_category=default_category, tags=_clean_feed_tags(raw.get("tags")), priority=priority, ) def _source_feed_urls(source: NewsFeedSource) -> tuple[str, ...]: return tuple(feed.url for feed in _source_feeds(source)) def _source_feeds(source: NewsFeedSource) -> tuple[NewsFeedEndpoint, ...]: if source.source_type == "reference": return tuple() feeds = tuple(feed for feed in source.feeds if feed.url) if feeds: return feeds urls = _clean_feed_urls(source.feed_urls, source.feed_url) return tuple( NewsFeedEndpoint( id=f"feed-{index + 1}", name=source.name if len(urls) == 1 else f"{source.name} {index + 1}", url=url, type=source.source_type, region=source.region, enabled=source.enabled and source.source_type in {"rss", "atom", "aggregated"}, default_category=source.default_category, priority=index + 1, ) for index, url in enumerate(urls) ) def _feed_matches_active_region(feed: NewsFeedEndpoint, active_region: str | None) -> bool: return ( active_region is None or active_region == "global" or not feed.region or feed.region in {"global", active_region} ) def _source_matches_active_region(source: NewsFeedSource, active_region: str) -> bool: return active_region == "global" or source.region in {"global", active_region} def _expected_feed_keys(sources: list[NewsFeedSource], *, active_region: str) -> set[tuple[str, str]]: return { (source.id, feed.id) for source in sources for feed in _source_feeds(source) if source.enabled and source.source_type in {"rss", "atom", "aggregated"} and feed.enabled and feed.type in {"rss", "atom", "aggregated"} and _feed_matches_active_region(feed, active_region) } def _serialize_feed_endpoint(feed: NewsFeedEndpoint) -> dict[str, Any]: return { "id": feed.id, "name": feed.name, "url": feed.url, "type": feed.type, "region": feed.region, "enabled": feed.enabled and feed.type in {"rss", "atom", "aggregated"}, "default_category": feed.default_category, "tags": list(feed.tags), "priority": feed.priority, } def _should_repair_builtin_source_feeds( source_id: str, *, raw_feeds: list[Any], feed_urls: tuple[str, ...], feed_url: str, homepage_url: str, feed_directory_url: str, ) -> bool: default_source = DEFAULT_NEWS_SOURCE_BY_ID.get(source_id) if not default_source or not default_source.feeds: return False if not raw_feeds: return True current_urls: set[str] = set(feed_urls) if feed_url: current_urls.add(feed_url) for raw_feed in raw_feeds: if isinstance(raw_feed, dict): current_url = str(raw_feed.get("url") or raw_feed.get("feed_url") or "").strip() if current_url: current_urls.add(current_url) non_feed_urls = { url for url in ( homepage_url, feed_directory_url, default_source.homepage_url, default_source.feed_directory_url, ) if url } return bool(current_urls & non_feed_urls) def serialize_news_source_config(source: NewsFeedSource) -> dict[str, Any]: feeds = _source_feeds(source) feed_urls = tuple(feed.url for feed in feeds) return { "id": source.id, "name": source.name, "region": source.region, "feed_url": source.feed_url or (feed_urls[0] if feed_urls else ""), "feed_urls": list(feed_urls), "feeds": [_serialize_feed_endpoint(feed) for feed in feeds], "homepage_url": source.homepage_url, "feed_directory_url": source.feed_directory_url, "source_type": source.source_type, "priority": source.priority, "enabled": source.enabled and source.source_type in {"rss", "atom", "aggregated"}, "source_tags": list(source.source_tags), "default_category": source.default_category, "importance_weight": source.importance_weight, "health_policy": dict(source.health_policy or DEFAULT_NEWS_HEALTH_POLICY), } def _source_from_config(payload: dict[str, Any]) -> NewsFeedSource | None: source_id = str(payload.get("id") or "").strip() name = str(payload.get("name") or "").strip() source_type = str(payload.get("source_type") or payload.get("type") or "rss").strip().lower() or "rss" default_source = DEFAULT_NEWS_SOURCE_BY_ID.get(source_id) homepage_url = str(payload.get("homepage_url") or "").strip() feed_directory_url = str(payload.get("feed_directory_url") or "").strip() raw_feeds = payload.get("feeds") if isinstance(payload.get("feeds"), list) else [] feed_urls = _clean_feed_urls(payload.get("feed_urls"), payload.get("feed_url")) feed_url = str(payload.get("feed_url") or "").strip() or (feed_urls[0] if feed_urls else "") enabled_override: bool | None = None if default_source: homepage_url = homepage_url or default_source.homepage_url feed_directory_url = feed_directory_url or default_source.feed_directory_url if source_type == "reference" and default_source.source_type in {"rss", "atom", "aggregated"}: source_type = default_source.source_type if not raw_feeds: enabled_override = default_source.enabled if _should_repair_builtin_source_feeds( source_id, raw_feeds=raw_feeds, feed_urls=feed_urls, feed_url=feed_url, homepage_url=homepage_url, feed_directory_url=feed_directory_url, ): raw_feeds = [_serialize_feed_endpoint(feed) for feed in default_source.feeds] feed_urls = tuple(feed.url for feed in default_source.feeds) feed_url = default_source.feed_url or (feed_urls[0] if feed_urls else "") if not feed_url and source_type == "reference": feed_url = homepage_url feed_urls = _clean_feed_urls(feed_url) if not source_id or not name or (not feed_url and not raw_feeds): return None try: priority = int(payload.get("priority", default_source.priority if default_source else 100)) except (TypeError, ValueError): priority = default_source.priority if default_source else 100 try: importance_weight = int(payload.get("importance_weight", default_source.importance_weight if default_source else 0)) except (TypeError, ValueError): importance_weight = default_source.importance_weight if default_source else 0 raw_tags = payload.get("source_tags") source_tags = ( tuple(str(tag).strip() for tag in raw_tags if str(tag).strip()) if isinstance(raw_tags, list) else (default_source.source_tags if default_source else ()) ) health_policy = payload.get("health_policy") if isinstance(payload.get("health_policy"), dict) else {} base_source = NewsFeedSource( id=source_id, name=name, region=str(payload.get("region") or "global").strip() or "global", feed_url=feed_url, homepage_url=homepage_url, feed_directory_url=feed_directory_url, source_type=source_type, priority=priority, enabled=(enabled_override if enabled_override is not None else payload.get("enabled") is not False) and source_type in {"rss", "atom", "aggregated"}, source_tags=source_tags, default_category=str(payload.get("default_category") or (default_source.default_category if default_source else "other") or "other").strip() or "other", importance_weight=importance_weight, health_policy={**DEFAULT_NEWS_HEALTH_POLICY, **health_policy}, ) feeds = tuple( feed for index, raw_feed in enumerate(raw_feeds) for feed in [_feed_from_config(raw_feed, source=base_source, index=index)] if feed is not None ) return NewsFeedSource( **{ **base_source.__dict__, "feed_urls": feed_urls or ((feed_url,) if feed_url else tuple()), "feeds": feeds, } ) def _merge_legacy_google_news_sources(sources: list[dict[str, Any]]) -> list[dict[str, Any]]: has_legacy_google = any(str(source.get("id") or "") in LEGACY_GOOGLE_NEWS_SOURCE_IDS for source in sources) if not has_legacy_google: return sources google_default = DEFAULT_NEWS_SOURCE_BY_ID.get("google-news") if google_default is None: return sources merged = [source for source in sources if str(source.get("id") or "") not in LEGACY_GOOGLE_NEWS_SOURCE_IDS] if not any(str(source.get("id") or "") == "google-news" for source in merged): merged.append(serialize_news_source_config(google_default)) return sorted(merged, key=lambda source: (int(source.get("priority") or 100), str(source.get("name") or ""))) def normalize_earth_news_sources_payload(payload: dict[str, Any] | None) -> dict[str, Any]: defaults = default_earth_news_sources_payload() if not isinstance(payload, dict): return defaults normalized = { "cache_version": int(payload.get("cache_version") or defaults["cache_version"]), "source_tags": payload.get("source_tags") if isinstance(payload.get("source_tags"), list) else defaults["source_tags"], "categories": payload.get("categories") if isinstance(payload.get("categories"), list) else defaults["categories"], "item_tag_rules": payload.get("item_tag_rules") if isinstance(payload.get("item_tag_rules"), list) else defaults["item_tag_rules"], "health": payload.get("health") if isinstance(payload.get("health"), dict) else {}, "sources": [], } for source in payload.get("sources") if isinstance(payload.get("sources"), list) else defaults["sources"]: if isinstance(source, dict): coerced = _source_from_config(source) if coerced: normalized["sources"].append(serialize_news_source_config(coerced)) if not normalized["sources"]: normalized["sources"] = defaults["sources"] else: normalized["sources"] = _merge_legacy_google_news_sources(normalized["sources"]) return normalized async def get_earth_news_sources_payload(db: AsyncSession | None) -> dict[str, Any]: if db is None: payload = default_earth_news_sources_payload() payload["is_default"] = True return payload result = await db.execute(select(SystemSetting).where(SystemSetting.category == EARTH_NEWS_SOURCES_CATEGORY)) record = result.scalar_one_or_none() payload = normalize_earth_news_sources_payload(record.payload if record else None) payload["is_default"] = record is None return payload async def save_earth_news_sources_payload(db: AsyncSession, payload: dict[str, Any]) -> dict[str, Any]: normalized = normalize_earth_news_sources_payload(payload) normalized["cache_version"] = int(normalized.get("cache_version") or 1) + 1 result = await db.execute(select(SystemSetting).where(SystemSetting.category == EARTH_NEWS_SOURCES_CATEGORY)) record = result.scalar_one_or_none() if record is None: record = SystemSetting(category=EARTH_NEWS_SOURCES_CATEGORY, payload=normalized) db.add(record) else: record.payload = normalized await db.commit() await db.refresh(record) clear_earth_news_region_cache() return {**normalize_earth_news_sources_payload(record.payload), "is_default": False} async def reset_earth_news_sources_payload(db: AsyncSession) -> dict[str, Any]: result = await db.execute(select(SystemSetting).where(SystemSetting.category == EARTH_NEWS_SOURCES_CATEGORY)) record = result.scalar_one_or_none() if record is not None: await db.delete(record) await db.commit() clear_earth_news_region_cache() payload = default_earth_news_sources_payload() payload["is_default"] = True return payload async def record_earth_news_source_health( db: AsyncSession | None, source_id: str, health: dict[str, Any], ) -> None: if db is None or not source_id or not callable(getattr(db, "execute", None)): return result = await db.execute(select(SystemSetting).where(SystemSetting.category == EARTH_NEWS_SOURCES_CATEGORY)) record = result.scalar_one_or_none() payload = normalize_earth_news_sources_payload(record.payload if record else None) payload_health = payload.get("health") if isinstance(payload.get("health"), dict) else {} payload_health[source_id] = health payload["health"] = payload_health if record is None: record = SystemSetting(category=EARTH_NEWS_SOURCES_CATEGORY, payload=payload) db.add(record) else: record.payload = payload await db.commit() async def record_earth_news_sources_health( db: AsyncSession | None, health_by_source: dict[str, dict[str, Any]], ) -> None: if db is None or not health_by_source or not callable(getattr(db, "execute", None)): return result = await db.execute(select(SystemSetting).where(SystemSetting.category == EARTH_NEWS_SOURCES_CATEGORY)) record = result.scalar_one_or_none() payload = normalize_earth_news_sources_payload(record.payload if record else None) payload_health = payload.get("health") if isinstance(payload.get("health"), dict) else {} payload_health.update(health_by_source) payload["health"] = payload_health if record is None: record = SystemSetting(category=EARTH_NEWS_SOURCES_CATEGORY, payload=payload) db.add(record) else: record.payload = payload await db.commit() async def get_configured_sources_for_region(db: AsyncSession | None, region: str) -> list[NewsFeedSource]: if db is None: return get_sources_for_region(region) payload = await get_earth_news_sources_payload(db) sources = [ source for raw in payload.get("sources", []) if isinstance(raw, dict) for source in [_source_from_config(raw)] if source ] enabled = [ source for source in sources if source.enabled and source.source_type in {"rss", "atom", "aggregated"} and _source_matches_active_region(source, region) ] return sorted(enabled, key=lambda source: (source.priority, source.name)) def _source_health_result( source: NewsFeedSource, *, ok: bool, count: int, error: str | None = None, status: str | None = None, status_code: int | None = None, content_type: str | None = None, latency_ms: int | None = None, final_url: str | None = None, feed: NewsFeedEndpoint | None = None, ) -> dict[str, Any]: return { "source_id": source.id, "feed_id": feed.id if feed else "", "feed_name": feed.name if feed else "", "feed_type": feed.type if feed else source.source_type, "feed_url": feed.url if feed else source.feed_url, "ok": ok, "status": status or ("ok" if ok else "failed"), "count": int(count or 0), "item_count": int(count or 0), "error": error, "status_code": status_code, "content_type": content_type or "", "latency_ms": latency_ms, "final_url": final_url or (feed.url if feed else source.feed_url), "fetched_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"), } def _format_source_fetch_error( *, status_code: int | None, content_type: str, body: str, parsed_count: int, ) -> tuple[str | None, str]: if status_code is not None and status_code >= 400: return f"HTTP {status_code}", "http_error" lower_content_type = content_type.lower() body_prefix = body[:500].lower() looks_like_html = "text/html" in lower_content_type or " dict[str, Any]: source = _source_from_config(raw_source) if source is None: return {"ok": False, "count": 0, "error": "新闻源缺少 id、名称或 URL"} if source.source_type not in {"rss", "atom", "aggregated"}: health = _source_health_result( source, ok=False, count=0, error="参考链接仅用于记录官网、报告页或未来采集器线索,不参与 RSS/Atom 抓取。", status="reference", ) await record_earth_news_source_health(db, source.id, health) return { "ok": False, "count": 0, "error": health["error"], "health": health, "source": serialize_news_source_config(source), } async with httpx.AsyncClient( timeout=float(source.health_policy.get("timeout_seconds", REQUEST_TIMEOUT)), follow_redirects=True, headers={"User-Agent": USER_AGENT}, ) as client: fetched_source, items, error, health = await _fetch_source(client, source) del fetched_source await record_earth_news_source_health(db, source.id, health) return { "ok": error is None, "count": len(items), "item_count": len(items), "error": error, "health": health, "source": serialize_news_source_config(source), } 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 None and not name.startswith("{"): node = element.find(f"{{*}}{name}") if node is not None and node.text: return node.text.strip() return "" def _parse_feed_entries( xml_text: str, source: NewsFeedSource, *, feed: NewsFeedEndpoint | None = None, config_payload: dict[str, Any] | None = None, ) -> list[ParsedNewsItem]: root = ET.fromstring(xml_text) items: list[ParsedNewsItem] = [] rss_items = root.findall("./channel/item") or root.findall(".//item") or root.findall(".//{*}item") atom_entries = root.findall("{http://www.w3.org/2005/Atom}entry") or root.findall(".//{*}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", "title") summary = _extract_item_text( node, "{http://www.w3.org/2005/Atom}summary", "{http://www.w3.org/2005/Atom}content", "summary", "content", ) link_node = node.find("{http://www.w3.org/2005/Atom}link") or node.find("{*}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", "updated", "published", ) else: title = _extract_item_text(node, "title") summary = _extract_item_text(node, "description", "content", "encoded") 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 = source.name display_title = clean_title feed_type = feed.type if feed else source.source_type if feed_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) content_language = _detect_content_language( title=display_title, summary=clean_summary, source=source, feed=feed, ) feed_id_segment = f"{feed.id}:" if feed is not None and len(source.feeds or ()) > 1 else "" item = ParsedNewsItem( id=f"{source.id}:{feed_id_segment}{hashlib.sha1(link.encode('utf-8')).hexdigest()[:12]}", title=display_title, summary=clean_summary, url=link, source=item_source, feed_name=feed.name if feed else source.name, feed_region=(feed.region if feed and feed.region else source.region), homepage_url=source.homepage_url, published_at=_parse_datetime(published), content_language=content_language, localizations=_source_language_localizations( title=display_title, summary=clean_summary, content_language=content_language, ), feed_id=feed.id if feed else "", feed_type=feed_type, feed_default_category=(feed.default_category if feed else source.default_category) or "other", source_tags=list(source.source_tags), ) apply_news_classification(item, source, feed=feed, config_payload=config_payload) items.append(item) return items def _normalize_breaking_level(value: str | None) -> str: return _normalize_breaking_level_enum(value).value def _normalize_breaking_scope(value: str | None) -> str: return _normalize_breaking_scope_enum(value).value def apply_news_classification( item: ParsedNewsItem, source: NewsFeedSource, *, feed: NewsFeedEndpoint | None = None, config_payload: dict[str, Any] | None = None, ) -> ParsedNewsItem: config = normalize_earth_news_sources_payload(config_payload) return _apply_news_classification(item, source, feed=feed, config=config) def _serialize_sources(sources: list[NewsFeedSource]) -> list[dict[str, Any]]: return [ { "id": source.id, "name": source.name, "region": source.region, "feed_url": source.feed_url, "feed_urls": list(_source_feed_urls(source)), "feeds": [_serialize_feed_endpoint(feed) for feed in _source_feeds(source)], "homepage_url": source.homepage_url, "feed_directory_url": source.feed_directory_url, "source_type": source.source_type, "priority": source.priority, "enabled": source.enabled, "source_tags": list(source.source_tags), "default_category": source.default_category, "importance_weight": source.importance_weight, } 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 _serialize_enriched_at(value: datetime | None) -> str | None: return value.isoformat().replace("+00:00", "Z") if value else None def _serialize_breaking_expires_at(value: datetime | None) -> str | None: return value.isoformat().replace("+00:00", "Z") if value else None def _content_patch(item: ParsedNewsItem) -> dict[str, Any]: return { "content_language": item.content_language, "localizations": item.localizations, "enrichment_status": item.enrichment_status, "enrichment_error": item.enrichment_error, "enriched_at": _serialize_enriched_at(item.enriched_at), } def _news_meta_patch(item: ParsedNewsItem) -> dict[str, Any]: source_id = item.id.split(":", 1)[0] if ":" in item.id else "" return { "source_id": source_id, "source_tags": list(item.source_tags or []), "feed_id": item.feed_id, "feed_name": item.feed_name, "feed_type": item.feed_type, "feed_default_category": item.feed_default_category, "category": item.category, "item_tags": list(item.item_tags or []), "tagging_source": item.tagging_source, "tagging_confidence": item.tagging_confidence, "importance_score": item.importance_score, "importance_level": item.importance_level, "importance_reasons": list(item.importance_reasons or []), "market_impact": item.market_impact, "breaking_level": _normalize_breaking_level(item.breaking_level), "breaking_scope": _normalize_breaking_scope(item.breaking_scope), "breaking_reasons": list(item.breaking_reasons or []), "breaking_source": item.breaking_source, "breaking_confidence": item.breaking_confidence, "breaking_expires_at": _serialize_breaking_expires_at(item.breaking_expires_at), } 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 content_patch = _content_patch(item) if queued and content_patch["enrichment_status"] == "pending": content_patch["enrichment_status"] = "queued" 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), "news_meta": _news_meta_patch(item), }, **content_patch, } 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), "news_meta": _news_meta_patch(item), }, **_content_patch(item), } 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, "content_language": item.content_language, "localizations": item.localizations, "enrichment_status": item.enrichment_status, "enrichment_error": item.enrichment_error, "enriched_at": _serialize_enriched_at(item.enriched_at), "url": item.url, "source": item.source, "feed_name": item.feed_name, "feed_id": item.feed_id, "feed_type": item.feed_type, "feed_default_category": item.feed_default_category, "feed_region": item.feed_region, "homepage_url": item.homepage_url, "published_at": published_at.isoformat().replace("+00:00", "Z") if published_at else None, "source_tags": list(item.source_tags or []), "category": item.category, "item_tags": list(item.item_tags or []), "tagging_source": item.tagging_source, "tagging_confidence": item.tagging_confidence, "importance_score": item.importance_score, "importance_level": item.importance_level, "importance_reasons": list(item.importance_reasons or []), "market_impact": item.market_impact, "breaking_level": _normalize_breaking_level(item.breaking_level), "breaking_scope": _normalize_breaking_scope(item.breaking_scope), "breaking_reasons": list(item.breaking_reasons or []), "breaking_source": item.breaking_source, "breaking_confidence": item.breaking_confidence, "breaking_expires_at": _serialize_breaking_expires_at(item.breaking_expires_at), } 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 ""), content_language=str(payload.get("content_language") or "en"), localizations=_normalize_localizations(payload.get("localizations")), enrichment_status=str(payload.get("enrichment_status") or "pending"), enrichment_error=_coerce_str(payload.get("enrichment_error")), enriched_at=_parse_datetime(_coerce_str(payload.get("enriched_at"))), url=str(payload.get("url") or ""), source=str(payload.get("source") or ""), feed_name=str(payload.get("feed_name") or ""), feed_id=str(payload.get("feed_id") or ""), feed_type=str(payload.get("feed_type") or "rss"), feed_default_category=str(payload.get("feed_default_category") or payload.get("category") or "other"), 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"))), source_tags=list(payload.get("source_tags") or []), category=str(payload.get("category") or "other"), item_tags=list(payload.get("item_tags") or []), tagging_source=str(payload.get("tagging_source") or "rules"), tagging_confidence=float(payload.get("tagging_confidence") or 0), importance_score=int(payload.get("importance_score") or 0), importance_level=str(payload.get("importance_level") or "low"), importance_reasons=list(payload.get("importance_reasons") or []), market_impact=str(payload.get("market_impact") or "none"), breaking_level=_normalize_breaking_level(str(payload.get("breaking_level") or "none")), breaking_scope=_normalize_breaking_scope(str(payload.get("breaking_scope") or "regional")), breaking_reasons=list(payload.get("breaking_reasons") or []), breaking_source=str(payload.get("breaking_source") or "rules"), breaking_confidence=float(payload.get("breaking_confidence") or 0), breaking_expires_at=_parse_datetime(_coerce_str(payload.get("breaking_expires_at"))), ) def _serialize_item(item: ParsedNewsItem, *, active_region: str, locale: str = DEFAULT_NEWS_LOCALE) -> dict[str, Any]: published_at = item.published_at location_patch = item.location_patch or build_target_location_patch(item, item.target_location) source_id = item.id.split(":", 1)[0] if ":" in item.id else "" return { "id": item.id, "source_id": source_id, "title": item.title, "summary": item.summary, "content_language": item.content_language, "localizations": item.localizations, "display_title": _get_locale_text(item, "title", locale=locale), "display_summary": _get_locale_text(item, "summary", locale=locale), "url": item.url, "source": item.source, "feed_name": item.feed_name, "feed_id": item.feed_id, "feed_type": item.feed_type, "feed_default_category": item.feed_default_category, "region": item.feed_region, "display_region": get_region_anchor(item.feed_region).label, "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"], "enrichment_status": item.enrichment_status, "enrichment_error": item.enrichment_error, "enriched_at": _serialize_enriched_at(item.enriched_at), "is_focus_match": item.feed_region == active_region, "source_tags": list(item.source_tags or []), "category": item.category, "item_tags": list(item.item_tags or []), "tagging_source": item.tagging_source, "tagging_confidence": item.tagging_confidence, "importance_score": item.importance_score, "importance_level": item.importance_level, "importance_reasons": list(item.importance_reasons or []), "market_impact": item.market_impact, "breaking_level": _normalize_breaking_level(item.breaking_level), "breaking_scope": _normalize_breaking_scope(item.breaking_scope), "breaking_reasons": list(item.breaking_reasons or []), "breaking_source": item.breaking_source, "breaking_confidence": item.breaking_confidence, "breaking_expires_at": _serialize_breaking_expires_at(item.breaking_expires_at), } def _build_payload( *, lat: float | None, lon: float | None, active_region: str, items: list[ParsedNewsItem], cruise_items: list[ParsedNewsItem] | None = None, sources: list[NewsFeedSource], errors: list[str], stale: bool, categories: set[str] | None = None, source_ids: set[str] | None = None, limit: int = MAX_ITEMS_TOTAL, locale: str = DEFAULT_NEWS_LOCALE, generated_at: datetime | None = None, ) -> dict[str, Any]: profile = get_region_profile(active_region) timestamp = generated_at or datetime.now(UTC) visible_items = list(items) cruise_visible_items = list(cruise_items if cruise_items is not None else items) highest_breaking_level = _highest_breaking_level(visible_items + cruise_visible_items) return { "generated_at": timestamp.isoformat().replace("+00:00", "Z"), "focus": { "lat": lat, "lon": lon, "region": active_region, "label": profile.label, "display_region": get_region_anchor(active_region).label, "accent": profile.accent, }, "sources": _serialize_sources(sources), "filters": { "region": active_region, "categories": sorted(categories or []), "sources": sorted(source_ids or []), "limit": limit, "locale": locale, "has_breaking": highest_breaking_level != "none", "highest_breaking_level": highest_breaking_level, }, "items": [_serialize_item(item, active_region=active_region, locale=locale) for item in visible_items], "cruise_items": [ _serialize_item(item, active_region=active_region, locale=locale) for item in cruise_visible_items ], "errors": errors, "stale": stale, } def _rank_and_trim_items( items: list[ParsedNewsItem], *, active_region: str, limit: int = MAX_ITEMS_TOTAL, ) -> 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: ( -_breaking_sort_rank(item), False if active_region == "global" or (_breaking_sort_rank(item) > 0 and _normalize_breaking_scope(item.breaking_scope) == "global") else item.feed_region != active_region, item.published_at is None, -(item.published_at.timestamp() if item.published_at else 0), item.feed_name, ), )[:limit] def _filter_news_items_by_categories( items: list[ParsedNewsItem], categories: set[str] | None, ) -> list[ParsedNewsItem]: if not categories: return items return [item for item in items if (item.category or "other") in categories] def _filter_news_items_by_source_ids( items: list[ParsedNewsItem], source_ids: set[str] | None, ) -> list[ParsedNewsItem]: if not source_ids: return items return [ item for item in items if (item.id.split(":", 1)[0] if ":" in item.id else "") in source_ids ] def _news_item_source_id(item: ParsedNewsItem) -> str: return item.id.split(":", 1)[0] if ":" in item.id else "" def _is_news_item_display_ready(item: ParsedNewsItem, *, locale: str) -> bool: return bool( _get_locale_text(item, "title", locale=locale) and _get_locale_text(item, "summary", locale=locale) ) def _diversify_news_items_for_locale( items: list[ParsedNewsItem], *, active_region: str, limit: int, locale: str, ) -> list[ParsedNewsItem]: ranked = sorted( _rank_and_trim_items(items, active_region=active_region, limit=max(len(items), limit)), key=lambda item: (not _is_news_item_display_ready(item, locale=locale),), ) buckets: dict[str, list[ParsedNewsItem]] = {} order: list[str] = [] for item in ranked: if active_region == "global": key = item.feed_region or "global" else: key = _news_item_source_id(item) or item.source or item.feed_name or item.id if key not in buckets: buckets[key] = [] order.append(key) buckets[key].append(item) diversified: list[ParsedNewsItem] = [] while len(diversified) < limit and order: next_order: list[str] = [] for source_id in order: bucket = buckets.get(source_id) or [] if bucket and len(diversified) < limit: diversified.append(bucket.pop(0)) if bucket: next_order.append(source_id) order = next_order return diversified async def _call_store_list_items(list_fn, db: AsyncSession, **kwargs): try: return await list_fn(db, **kwargs) except TypeError as error: if "source_ids" not in str(error): raise legacy_kwargs = dict(kwargs) legacy_kwargs.pop("source_ids", None) return await list_fn(db, **legacy_kwargs) 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 clear_earth_news_region_cache() -> None: _REGION_CACHE.clear() 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 enqueue_item(item: ParsedNewsItem, *, force: bool = False) -> bool: return await enqueue_target_location_job(build_target_location_job_payload(item), force=force) async def apply_location(item: ParsedNewsItem) -> ParsedNewsItem: cached_patch = await get_cached_target_location_patch(item.id) if cached_patch: apply_enrichment_patch_to_item(item, cached_patch) if not _has_required_localization(item): queued = await enqueue_item(item, force=True) if queued and item.enrichment_status in { "pending", "unavailable", "provider_error", "parse_error", "no_result", "location_only", }: item.enrichment_status = "queued" return item queued = await enqueue_item(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), force=not _has_default_localization(item), ) for item in items if ( item.location_patch is None or item.location_patch.get("verified") is False or not _has_required_localization(item) ) ) ) async def _fetch_single_feed_url( client: httpx.AsyncClient, source: NewsFeedSource, feed: NewsFeedEndpoint, *, config_payload: dict[str, Any] | None = None, ) -> tuple[NewsFeedSource, list[ParsedNewsItem], str | None, dict[str, Any]]: started_at = perf_counter() try: response = await client.get(feed.url) latency_ms = int((perf_counter() - started_at) * 1000) status_code = response.status_code content_type = response.headers.get("content-type", "") if status_code >= 400: error = f"HTTP {status_code}" return source, [], error, _source_health_result( source, ok=False, count=0, error=error, status="http_error", status_code=status_code, content_type=content_type, latency_ms=latency_ms, final_url=str(response.url), feed=feed, ) try: items = _parse_feed_entries(response.text, source, feed=feed, config_payload=config_payload) except Exception as exc: error, status = _format_source_fetch_error( status_code=status_code, content_type=content_type, body=response.text, parsed_count=0, ) if error is None: error = str(exc) status = "format_error" return source, [], error, _source_health_result( source, ok=False, count=0, error=error, status=status, status_code=status_code, content_type=content_type, latency_ms=latency_ms, final_url=str(response.url), feed=feed, ) error, status = _format_source_fetch_error( status_code=status_code, content_type=content_type, body=response.text, parsed_count=len(items), ) return source, items, error, _source_health_result( source, ok=error is None, count=len(items), error=error, status=status, status_code=status_code, content_type=content_type, latency_ms=latency_ms, final_url=str(response.url), feed=feed, ) except httpx.TimeoutException as exc: error = f"请求超时: {exc}" return source, [], error, _source_health_result( source, ok=False, count=0, error=error, status="timeout", latency_ms=int((perf_counter() - started_at) * 1000), feed=feed, ) except Exception as exc: error = str(exc) return source, [], error, _source_health_result( source, ok=False, count=0, error=error, status="network_error", latency_ms=int((perf_counter() - started_at) * 1000), feed=feed, ) async def _fetch_source( client: httpx.AsyncClient, source: NewsFeedSource, *, active_region: str | None = None, config_payload: dict[str, Any] | None = None, ) -> tuple[NewsFeedSource, list[ParsedNewsItem], str | None, dict[str, Any]]: feeds = tuple( feed for feed in _source_feeds(source) if feed.enabled and feed.type in {"rss", "atom", "aggregated"} and _feed_matches_active_region(feed, active_region) ) if not feeds: health = _source_health_result(source, ok=False, count=0, error="新闻源缺少已启用的 Feed 子项。", status="format_error") return source, [], health["error"], health results = await asyncio.gather(*( _fetch_single_feed_url(client, source, feed, config_payload=config_payload) for feed in sorted(feeds, key=lambda item: (item.priority, item.name)) )) merged_items: list[ParsedNewsItem] = [] feed_results: list[dict[str, Any]] = [] errors: list[str] = [] for _source, items, error, health in results: feed_results.append(health) if error: errors.append(f"{health.get('final_url') or source.feed_url}: {error}") continue merged_items.extend(items) deduped: dict[str, ParsedNewsItem] = {} for item in merged_items: key = item.url.strip() or item.title.strip().lower() if key and key not in deduped: deduped[key] = item items = list(deduped.values()) ok = bool(items) error = None if ok else "; ".join(errors) or "未解析到 RSS/Atom 条目,请检查 Feed 地址或源格式。" latency_ms = sum(int(result.get("latency_ms") or 0) for result in feed_results) status = "ok" if ok else (feed_results[0].get("status") if len(feed_results) == 1 else "failed") status_code = next((result.get("status_code") for result in feed_results if result.get("status_code")), None) content_type = ", ".join(sorted({str(result.get("content_type") or "") for result in feed_results if result.get("content_type")})) health = _source_health_result( source, ok=ok, count=len(items), error=error, status=status, status_code=int(status_code) if isinstance(status_code, int) else None, content_type=content_type, latency_ms=latency_ms, final_url=source.feed_url, ) health["feed_results"] = feed_results return source, items, error, health async def _fetch_rss_items_for_sources( sources: list[NewsFeedSource], *, active_region: str | None = None, config_payload: dict[str, Any] | None = None, ) -> tuple[list[ParsedNewsItem], list[str], dict[str, dict[str, Any]]]: errors: list[str] = [] async with httpx.AsyncClient( timeout=REQUEST_TIMEOUT, follow_redirects=True, headers={"User-Agent": USER_AGENT}, ) as client: if config_payload is None: results = await asyncio.gather(*(_fetch_source(client, source, active_region=active_region) for source in sources)) else: results = await asyncio.gather(*( _fetch_source(client, source, active_region=active_region, config_payload=config_payload) for source in sources )) fetched_items: list[ParsedNewsItem] = [] health_by_source: dict[str, dict[str, Any]] = {} for source, items, error, health in results: health_by_source[source.id] = health if error: errors.append(f"{source.name}: {error}") continue fetched_items.extend(items) return fetched_items, errors, health_by_source 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], categories: set[str] | None = None, source_ids: set[str] | None = None, limit: int = MAX_ITEMS_TOTAL, locale: str = DEFAULT_NEWS_LOCALE, ) -> dict[str, Any]: fetched_items, errors, _health_by_source = await _fetch_rss_items_for_sources(sources, active_region=active_region) ranked_items = _filter_news_items_by_source_ids( _filter_news_items_by_categories( _rank_and_trim_items(fetched_items, active_region=active_region, limit=limit), categories, ), source_ids, )[:limit] 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, categories=categories, source_ids=source_ids, limit=limit, locale=locale, ) cached = _get_cached_region_feed(active_region) if cached: cached_items = _filter_news_items_by_source_ids( _filter_news_items_by_categories(cached.items, categories), source_ids, )[:limit] 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, categories=categories, source_ids=source_ids, limit=limit, locale=locale, generated_at=cached.fetched_at, ) return _build_payload( lat=lat, lon=lon, active_region=active_region, items=[], sources=sources, errors=errors, stale=False, categories=categories, source_ids=source_ids, limit=limit, locale=locale, ) async def get_earth_news_payload( lat: float | None = None, lon: float | None = None, *, region: str | None = None, categories: set[str] | None = None, source_ids: set[str] | None = None, limit: int = MAX_ITEMS_TOTAL, locale: str = DEFAULT_NEWS_LOCALE, provider_client: AIProviderClient | None = None, db: AsyncSession | None = None, ) -> dict[str, Any]: del provider_client active_region = region if region in REGION_ANCHORS else determine_focus_region(lat, lon) has_settings_db = db is not None and callable(getattr(db, "execute", None)) source_config_payload = await get_earth_news_sources_payload(db) if has_settings_db else None sources = ( await get_configured_sources_for_region(db, active_region) if has_settings_db else 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, categories=categories, source_ids=source_ids, limit=limit, locale=locale, ) from app.services.earth_news_store import ( get_earth_news_feed_coverage, get_earth_news_freshness, list_earth_news_cruise_items, 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 not should_supplement and callable(getattr(db, "execute", None)): expected_feed_keys = _expected_feed_keys(sources, active_region=active_region) if expected_feed_keys: covered_feed_keys = await get_earth_news_feed_coverage( db, active_region=active_region, recent_after=datetime.now(UTC) - timedelta(seconds=RSS_SUPPLEMENT_MAX_AGE_SECONDS), ) should_supplement = bool(expected_feed_keys - covered_feed_keys) if should_supplement: if source_config_payload is None: fetched_items, errors, health_by_source = await _fetch_rss_items_for_sources(sources, active_region=active_region) else: fetched_items, errors, health_by_source = await _fetch_rss_items_for_sources( sources, active_region=active_region, config_payload=source_config_payload, ) await record_earth_news_sources_health(db, health_by_source) ranked_fetched_items = _rank_and_trim_items(fetched_items, active_region=active_region) await _enqueue_unverified_locations(ranked_fetched_items) await upsert_earth_news_items(db, ranked_fetched_items) items = await _call_store_list_items( list_earth_news_items, db, active_region=active_region, limit=limit, categories=categories, source_ids=source_ids, ) if not source_ids: ready_sources = { _news_item_source_id(item) for item in items if _is_news_item_display_ready(item, locale=locale) } missing_ready_sources = [ source.id for source in sources if source.id and source.id not in ready_sources ] if missing_ready_sources: extra_items: list[ParsedNewsItem] = [] for missing_source_id in missing_ready_sources: extra_items.extend( await _call_store_list_items( list_earth_news_items, db, active_region=active_region, limit=3, categories=categories, source_ids={missing_source_id}, ) ) if extra_items: items = _diversify_news_items_for_locale( [*items, *extra_items], active_region=active_region, limit=limit, locale=locale, ) if hasattr(db, "execute"): cruise_items = await _call_store_list_items( list_earth_news_cruise_items, db, limit=min(max(limit, MAX_ITEMS_TOTAL) * len(REGION_ANCHORS), 500), categories=categories, source_ids=source_ids, ) else: cruise_items = items enqueue_candidates = {item.id: item for item in [*items, *cruise_items] if item.id} await _enqueue_unverified_locations(list(enqueue_candidates.values())) stale = bool(errors and items) return _build_payload( lat=lat, lon=lon, active_region=active_region, items=items, cruise_items=cruise_items, sources=sources, errors=errors, stale=stale, categories=categories, source_ids=source_ids, limit=limit, locale=locale, )