import os import json import random import re import time import xml.etree.ElementTree as ET from typing import List, Dict, Optional, Tuple from concurrent.futures import ThreadPoolExecutor, as_completed from urllib.parse import urlparse import requests from fastapi import FastAPI from fastapi.responses import HTMLResponse from openai import OpenAI from bs4 import BeautifulSoup # ───────────────────────────────────────────────────────────────── # App 初始化 # ───────────────────────────────────────────────────────────────── app = FastAPI() # ───────────────────────────────────────────────────────────────── # 設定 # ───────────────────────────────────────────────────────────────── DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY") DEEPSEEK_MODEL = os.getenv("DEEPSEEK_MODEL", "deepseek-chat") client = OpenAI(api_key=DEEPSEEK_API_KEY, base_url="https://api.deepseek.com/v1") HEADERS = { "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/125.0.0.0 Safari/537.36" ), "Accept-Language": "zh-TW,zh;q=0.9,en;q=0.8", } ARTICLES_PER_SOURCE = 10 MAX_FETCH_SECONDS = 12 # 每個來源最多等幾秒 # ───────────────────────────────────────────────────────────────── # Unsplash 高品質科技/財經圖庫(防呆用) # ───────────────────────────────────────────────────────────────── UNSPLASH_POOL = [ # 科技類 "https://images.unsplash.com/photo-1518770660439-4636190af475?w=800&q=80", # 晶片 "https://images.unsplash.com/photo-1504711434969-e33886168f5c?w=800&q=80", # 新聞桌 "https://images.unsplash.com/photo-1488590528505-98d2b5aba04b?w=800&q=80", # 程式碼 "https://images.unsplash.com/photo-1531297484001-80022131f5a1?w=800&q=80", # 筆電 "https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=800&q=80", # 數據分析 "https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=800&q=80", # 儀表板 "https://images.unsplash.com/photo-1550751827-4bd374c3f58b?w=800&q=80", # 資安 "https://images.unsplash.com/photo-1519389950473-47ba0277781c?w=800&q=80", # 辦公室 "https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=800&q=80", # 全球網路 "https://images.unsplash.com/photo-1526374965328-7f61d4dc18c5?w=800&q=80", # 網絡 "https://images.unsplash.com/photo-1558494949-ef010cbdcc31?w=800&q=80", # 伺服器 "https://images.unsplash.com/photo-1560472354-b33ff0c44a43?w=800&q=80", # 數位行銷 "https://images.unsplash.com/photo-1504384308090-c894fdcc538d?w=800&q=80", # 工作空間 "https://images.unsplash.com/photo-1559028012-481c04fa702d?w=800&q=80", # 雲端 "https://images.unsplash.com/photo-1526628953301-3e589a6a8b74?w=800&q=80", # 金融科技 "https://images.unsplash.com/photo-1677442136019-21780ecad995?w=800&q=80", # AI "https://images.unsplash.com/photo-1511707171634-5f897ff02aa9?w=800&q=80", # 手機 "https://images.unsplash.com/photo-1633356122544-f134324a6cee?w=800&q=80", # 區塊鏈 "https://images.unsplash.com/photo-1581091226825-a39f28c8ff2a?w=800&q=80", # 工程 "https://images.unsplash.com/photo-1485827404703-89b55fcc595e?w=800&q=80", # 機器人 # 財經/商業類 "https://images.unsplash.com/photo-1611974789855-9c2a0a7236a3?w=800&q=80", # 股市 "https://images.unsplash.com/photo-1590283603385-17ffb3a7f3f4?w=800&q=80", # 財經圖表 "https://images.unsplash.com/photo-1553729459-afe8f2e2ed65?w=800&q=80", # 商業大樓 "https://images.unsplash.com/photo-1450101499163-c884c5c54a85?w=800&q=80", # 商業分析 # 旅遊/生活類 "https://images.unsplash.com/photo-1488646953014-85cb44e25828?w=800&q=80", # 旅遊 "https://images.unsplash.com/photo-1507525428034-b723cf961d3e?w=800&q=80", # 海灘 "https://images.unsplash.com/photo-1469854523086-cc02fe5d8800?w=800&q=80", # 旅行 "https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?w=800&q=80", # 山景 "https://images.unsplash.com/photo-1444723121867-7a241cacace9?w=800&q=80", # 城市 "https://images.unsplash.com/photo-1533105079780-92b9be482077?w=800&q=80", # 美食 "https://images.unsplash.com/photo-1414235077428-338989a2e8c0?w=800&q=80", # 餐飲 # 材料/工業類 "https://images.unsplash.com/photo-1535813547-99c4740f9fc5?w=800&q=80", # 金屬 "https://images.unsplash.com/photo-1581092160562-40aa08e78837?w=800&q=80", # 工廠 "https://images.unsplash.com/photo-1565122645852-3dd6403dcad2?w=800&q=80", # 材料科學 "https://images.unsplash.com/photo-1504917595217-d4dc5ebe6122?w=800&q=80", # 塑膠/聚合物 # 科學類 "https://images.unsplash.com/photo-1507413245164-6160d8298b31?w=800&q=80", # 科學 "https://images.unsplash.com/photo-1532094349884-543bc11b234d?w=800&q=80", # 實驗室 # 冷知識/趣味類 "https://images.unsplash.com/photo-1533073526757-2c8ca1df9f1c?w=800&q=80", # 好奇 "https://images.unsplash.com/photo-1516410529446-2e27be6d47d6?w=800&q=80", # 知識 ] # ───────────────────────────────────────────────────────────────── # 新聞來源定義 # ───────────────────────────────────────────────────────────────── # ── 備用清單(當 news_sources.json 無法讀取時使用)── FALLBACK_SOURCES: List[Dict] = [ {"name": "科技新報", "type": "rss", "url": "https://technews.tw/feed/"}, {"name": "癮科技", "type": "rss", "url": "https://chinese.engadget.com/rss.xml"}, {"name": "數位時代", "type": "rss", "url": "https://www.bnext.com/rss"}, {"name": "Yahoo 新聞", "type": "rss", "url": "https://hk.news.yahoo.com/rss/"}, {"name": "RTHK 本地新聞", "type": "rss", "url": "https://rthk.hk/rthk/news/rss/c_expressnews_clocal.xml"}, {"name": "RTHK 國際新聞", "type": "rss", "url": "https://rthk.hk/rthk/news/rss/c_expressnews_cinternational.xml"}, {"name": "Gizmodo", "type": "rss", "url": "https://gizmodo.com/rss"}, {"name": "Hackaday", "type": "rss", "url": "https://hackaday.com/feed/"}, ] def load_news_sources() -> List[Dict]: """從 news_sources.json 載入新聞來源清單;失敗時回退到備用清單。""" import os as _os config_path = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "news_sources.json") try: with open(config_path, "r", encoding="utf-8") as f: sources = json.load(f) if isinstance(sources, list) and len(sources) > 0: print(f"📋 成功載入 news_sources.json({len(sources)} 個來源)", flush=True) return sources else: raise ValueError("news_sources.json 格式無效(需為非空陣列)") except Exception as e: print(f"⚠️ 無法讀取 news_sources.json:{e},使用備用清單({len(FALLBACK_SOURCES)} 個來源)", flush=True) return FALLBACK_SOURCES NEWS_SOURCES: List[Dict] = load_news_sources() # ───────────────────────────────────────────────────────────────── # 圖片處理輔助函式 # ───────────────────────────────────────────────────────────────── def extract_image_from_html(html_text: str) -> Optional[str]: """從 HTML 內容中提取第一張圖片的網址。""" if not html_text: return None # 方法一:BeautifulSoup 解析 try: soup = BeautifulSoup(html_text, "html.parser") img = soup.find("img") if img: for attr in ["src", "data-src", "data-original"]: src = img.get(attr) if src: if src.startswith("http"): return src if src.startswith("//"): return "https:" + src except Exception: pass # 方法二:正則表達式兜底 try: match = re.search( r'(?:src|data-src|data-original)=["\'](https?://[^"\'>]+?\.(?:jpg|jpeg|png|webp|gif)[^"\'>]*)["\']', html_text, re.IGNORECASE, ) if match: return match.group(1) except Exception: pass return None # RSS/Atom 常用命名空間 NS = { "media": "http://search.yahoo.com/mrss/", "content": "http://purl.org/rss/1.0/modules/content/", "dc": "http://purl.org/dc/elements/1.1/", "atom": "http://www.w3.org/2005/Atom", } def extract_image_from_rss_item(item: ET.Element) -> Optional[str]: """從 RSS/Atom item 元素中提取圖片網址(使用 ElementTree)。""" # 1) media:content / media:thumbnail for tag in ["{http://search.yahoo.com/mrss/}content", "{http://search.yahoo.com/mrss/}thumbnail"]: media_el = item.find(tag) if media_el is not None: url = media_el.get("url", "") if url.startswith("http"): return url # 也可能在所有後代中 for el in item.iter(tag): url = el.get("url", "") if url.startswith("http"): return url # 2) enclosure (RSS 2.0) for enc in item.findall("enclosure"): url = enc.get("url", "") mime = enc.get("type", "") if url.startswith("http"): if "image" in mime or any( url.lower().endswith(ext) for ext in (".jpg", ".jpeg", ".png", ".webp", ".gif") ): return url # 3) content:encoded 裡的 content_el = item.find("{http://purl.org/rss/1.0/modules/content/}encoded") if content_el is not None and content_el.text: img = extract_image_from_html(content_el.text) if img: return img # 4) description 裡的 desc_el = item.find("description") if desc_el is not None and desc_el.text: img = extract_image_from_html(desc_el.text) if img: return img # 5) Atom: content 裡的 for tag in ["content", "{http://www.w3.org/2005/Atom}content"]: atom_content = item.find(tag) if atom_content is not None and atom_content.text: img = extract_image_from_html(atom_content.text) if img: return img return None def get_entry_text(item: ET.Element, *tags: str) -> str: """從 RSS/Atom item 中取得文字內容,依序嘗試多個 tag(含命名空間變體)。""" for tag in tags: # 嘗試純 tag 名稱 el = item.find(tag) if el is not None and el.text: return el.text.strip() # 嘗試帶 Atom 命名空間 el = item.find(f"{{http://www.w3.org/2005/Atom}}{tag}") if el is not None and el.text: return el.text.strip() return "" def get_fallback_image(title: str = "") -> str: """根據新聞關鍵字回傳相關 Unsplash 圖片;無匹配則隨機挑選。""" kw = title.lower() if title else "" mapping = [ # 科技 (["ai", "人工智慧", "artificial intelligence", "gpt", "llm", "深度學習", "machine learning"], "https://images.unsplash.com/photo-1677442136019-21780ecad995?w=800&q=80"), (["手機", "phone", "iphone", "android", "samsung", "mobile"], "https://images.unsplash.com/photo-1511707171634-5f897ff02aa9?w=800&q=80"), (["晶片", "chip", "半導體", "semiconductor", "nvidia", "intel", "amd", "tsmc", "台積電"], "https://images.unsplash.com/photo-1518770660439-4636190af475?w=800&q=80"), (["資安", "安全", "security", "cyber", "hack", "漏洞"], "https://images.unsplash.com/photo-1550751827-4bd374c3f58b?w=800&q=80"), (["雲端", "cloud", "aws", "azure", "gcp"], "https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=800&q=80"), (["數據", "data", "big data", "分析", "analytics"], "https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=800&q=80"), (["機器人", "robot", "automation", "自動化"], "https://images.unsplash.com/photo-1485827404703-89b55fcc595e?w=800&q=80"), (["電動車", "ev", "tesla", "汽車", "vehicle", "自駕"], "https://images.unsplash.com/photo-1593941707882-a5bba14938c7?w=800&q=80"), (["區塊鏈", "blockchain", "crypto", "加密", "bitcoin", "web3"], "https://images.unsplash.com/photo-1633356122544-f134324a6cee?w=800&q=80"), # 財經 (["金融", "財經", "finance", "fintech", "bank", "投資", "股票", "股市", "基金", "匯率", "港股", "美股"], "https://images.unsplash.com/photo-1611974789855-9c2a0a7236a3?w=800&q=80"), (["經濟", "economy", "貿易", "trade", "gdp", "通膨", "加息", "減息"], "https://images.unsplash.com/photo-1450101499163-c884c5c54a85?w=800&q=80"), # 旅遊/生活 (["旅遊", "travel", "旅行", "景點", "酒店", "機票", "美食", "餐廳", "打卡"], "https://images.unsplash.com/photo-1488646953014-85cb44e25828?w=800&q=80"), (["深圳", "北上", "大灣區", "灣區", "內地"], "https://images.unsplash.com/photo-1444723121867-7a241cacace9?w=800&q=80"), (["生活", "lifestyle", "潮流", "時尚", "購物", "shopping"], "https://images.unsplash.com/photo-1476514525535-07fb3b4ae5f1?w=800&q=80"), # 材料/工業 (["金屬", "metal", "鋼鐵", "礦", "原料", "塑膠", "plastic", "材料", "material", " polymer"], "https://images.unsplash.com/photo-1535813547-99c4740f9fc5?w=800&q=80"), (["工業", "製造", "工廠", "manufacturing"], "https://images.unsplash.com/photo-1581092160562-40aa08e78837?w=800&q=80"), # 科學 (["科學", "science", "研究", "research", "太空", "nasa", "生物", "物理", "化學"], "https://images.unsplash.com/photo-1507413245164-6160d8298b31?w=800&q=80"), # 冷知識/趣味 (["冷知識", "趣聞", "奇聞", "歷史", "考古", "謎團", "心理"], "https://images.unsplash.com/photo-1533073526757-2c8ca1df9f1c?w=800&q=80"), ] for keywords, url in mapping: if any(k in kw for k in keywords): return url return random.choice(UNSPLASH_POOL) def ensure_image_url(raw_url: Optional[str], title: str = "") -> str: """保證一定回傳有效圖片網址:優先原始網址,否則 Unsplash 補上。""" if raw_url and raw_url.startswith("http"): return raw_url return get_fallback_image(title) def clean_html(raw_html: str) -> str: """將 HTML 轉為純文字,保留基本段落結構。""" if not raw_html: return "" try: soup = BeautifulSoup(raw_html, "html.parser") # 移除 script / style for tag in soup(["script", "style", "nav", "footer", "header", "aside"]): tag.decompose() text = soup.get_text(separator="\n") # 壓縮多餘空白行 text = re.sub(r"\n\s*\n", "\n", text) text = re.sub(r" +", " ", text) return text.strip() except Exception: # 簡易 regex 去除 HTML tag text = re.sub(r"<[^>]+>", " ", raw_html) text = re.sub(r"\s+", " ", text) return text.strip() def guess_category(source_name: str) -> str: """根據來源名稱推測文章分類標籤(AI 分類失敗時的 fallback)。""" name = source_name.lower() if any(k in name for k in ["財經", "aastocks", "鉅亨", "finance", "money", "stock"]): return "財經" if any(k in name for k in ["rthk", "國際", "global", "world"]): return "國際" if any(k in name for k in ["旅遊", "travel", "utravel", "生活", "ulifestyle", "wave", "wavetv"]): return "旅遊" if any(k in name for k in ["01", "大灣區", "北上", "深圳"]): return "旅遊" if any(k in name for k in ["科學", "science", "tomorrowsci", "地球圖輯", "冷知識"]): return "科學" if any(k in name for k in ["plastic", "metal", "azom", "材料", "工業"]): return "材料工業" if any(k in name for k in ["trend", "熱搜", "google trend"]): return "熱門趨勢" if any(k in name for k in ["gizmodo", "hackaday", "科技新報", "癮科技", "數位時代", "inside", "qbit", "hkepc"]): return "科技" return "科技" # 預設 # ───────────────────────────────────────────────────────────────── # 新聞來源擷取函式 # ───────────────────────────────────────────────────────────────── def fetch_rss_articles(source: Dict) -> List[Dict]: """透過 RSS feed 抓取最新文章(最多 ARTICLES_PER_SOURCE 篇)。 支援 RSS 2.0 與 Atom 格式,使用內建 xml.etree.ElementTree 解析。""" name = source["name"] url = source["url"] articles = [] print(f"📡 [{name}] 開始抓取 RSS: {url}", flush=True) try: resp = requests.get(url, headers=HEADERS, timeout=MAX_FETCH_SECONDS) resp.raise_for_status() resp.encoding = resp.apparent_encoding or "utf-8" xml_text = resp.text except Exception as e: print(f"⚠️ [{name}] RSS 連線失敗: {e}", flush=True) return articles # 嘗試解析 XML try: root = ET.fromstring(xml_text) except ET.ParseError as e: print(f"⚠️ [{name}] XML 解析失敗: {e}", flush=True) return articles # 判斷 RSS 2.0 或 Atom 格式 items = [] # RSS 2.0: channel > item rss_items = root.findall(".//item") if rss_items: items = rss_items else: # Atom: feed > entry atom_items = root.findall(".//{http://www.w3.org/2005/Atom}entry") if not atom_items: atom_items = root.findall(".//entry") items = atom_items if not items: print(f"⚠️ [{name}] 找不到任何新聞條目", flush=True) return articles items = items[:ARTICLES_PER_SOURCE] print(f"📰 [{name}] 取得 {len(items)} 篇文章", flush=True) for item in items: # 標題 title = ( get_entry_text(item, "title") or get_entry_text(item, "{http://www.w3.org/2005/Atom}title") ) if not title: continue # 內文:優先 content:encoded,其次 description / summary / content content_text = get_entry_text( item, "{http://purl.org/rss/1.0/modules/content/}encoded", "description", "summary", "content", ) # 清理 HTML 標籤 clean_content = clean_html(content_text) # 限制內文長度(避免 token 爆炸) if len(clean_content) > 600: clean_content = clean_content[:600] + "..." # 圖片提取 image_url = extract_image_from_rss_item(item) image_url = ensure_image_url(image_url, title) articles.append({ "title": title, "content": clean_content or title, "image_url": image_url, "source": name, }) return articles def fetch_html_articles(source: Dict) -> List[Dict]: """從一般 HTML 網頁抓取新聞列表(例如 HKEPC)。""" name = source["name"] url = source["url"] articles = [] print(f"🌐 [{name}] 開始爬取 HTML: {url}", flush=True) try: resp = requests.get(url, headers=HEADERS, timeout=MAX_FETCH_SECONDS) resp.raise_for_status() resp.encoding = resp.apparent_encoding or "utf-8" soup = BeautifulSoup(resp.text, "html.parser") except Exception as e: print(f"⚠️ [{name}] HTML 連線失敗: {e}", flush=True) return articles # ── HKEPC 專用選擇器 ── # 嘗試多種可能的文章區塊選擇器(依優先序) candidates = [] # 常見新聞列表選擇器 selectors = [ "a[href*='view']", # HKEPC 新聞連結格式 ".news-item a", ".article-list a", ".post-title a", ".entry-title a", "article a", ".content a[href*='news']", ".main-content a", "h2 a", "h3 a", "h4 a", # 標題連結 ] seen_urls = set() for selector in selectors: try: for tag in soup.select(selector): href = tag.get("href", "") text = tag.get_text(strip=True) if not href or not text or len(text) < 6: continue # 過濾掉非新聞連結 if any(skip in href.lower() for skip in ["login", "register", "ad.", "banner", "javascript:"]): continue if href in seen_urls: continue seen_urls.add(href) candidates.append({"title": text, "link": href}) except Exception: continue if len(candidates) >= ARTICLES_PER_SOURCE: break print(f"🔗 [{name}] 找到 {len(candidates)} 個候選連結", flush=True) # 擷取前 N 篇 for item in candidates[:ARTICLES_PER_SOURCE]: title = item["title"] link = item["link"] # 補全相對路徑 if link.startswith("/"): link = "https://www.hkepc.com" + link elif not link.startswith("http"): link = "https://www.hkepc.com/" + link content_text = title # 預設用標題當內容 image_url = None # 嘗試進入文章內頁抓取更多內容 try: art_resp = requests.get(link, headers=HEADERS, timeout=6) art_resp.raise_for_status() art_resp.encoding = art_resp.apparent_encoding or "utf-8" art_soup = BeautifulSoup(art_resp.text, "html.parser") # 內文 for art_sel in ["article", ".article-content", ".content", ".post-content", ".entry-content", "main", ".news-content"]: body = art_soup.select_one(art_sel) if body: content_text = clean_html(str(body)) break if len(content_text) > 600: content_text = content_text[:600] + "..." # 圖片 page_html = str(art_soup) image_url = extract_image_from_html(page_html) except Exception as e: print(f" ⚠️ 無法抓取內頁 {link[:60]}: {e}", flush=True) image_url = ensure_image_url(image_url, title) articles.append({ "title": title, "content": content_text or title, "image_url": image_url, "source": name, }) print(f"📰 [{name}] 擷取 {len(articles)} 篇文章", flush=True) return articles def fetch_articles_from_source(source: Dict) -> List[Dict]: """統一入口:根據來源類型呼叫對應的擷取函式;失敗時自動切換 HTML 備用爬蟲。""" articles = [] try: if source["type"] == "rss": articles = fetch_rss_articles(source) elif source["type"] == "html": articles = fetch_html_articles(source) else: print(f"⚠️ 未知來源類型: {source['type']}", flush=True) except Exception as e: print(f"❌ [{source['name']}] 擷取異常: {e}", flush=True) # ── 若正常抓取失敗(0 篇),自動啟動 HTML 備用爬蟲 ── if not articles: print(f"🔄 [{source['name']}] 正常抓取取得 0 篇,觸發 HTML 備用爬蟲...", flush=True) articles = fetch_html_fallback(source) return articles # ───────────────────────────────────────────────────────────────── # DeepSeek AI 摘要處理 # ───────────────────────────────────────────────────────────────── SYSTEM_PROMPT = """你是一個專業的繁體中文新聞編輯秘書。你將收到一批新聞文章(JSON 陣列), 每篇文章包含 title、content、image_url、source 四個欄位。 請為每一篇文章完成以下任務: 1. **語言判斷**:先判斷原文 title 和 content 的主要語言。 - 若為**中文**:直接產出繁體中文的 title 和 summary(單語模式,摘要 80~200 字)。 - 若為**英文或其他外語**:自動切換為「中英對照模式」(見第 2 點)。 2. **中英對照模式**(僅適用於外語原文): title 和 summary 必須用換行符號 `\n` 分隔上下兩半段: - **上半段**:流暢、道地的繁體中文翻譯(使用香港/台灣常用表達方式),摘要 80~200 字。 - **下半段**:對應的英文原文(title 對照英文原標題;summary 對照由 AI 生成的流暢英文摘要,40~70 個英文字)。 對照模式輸出範例: { "title": "記憶體晶片短缺無解\nMemory Chip Shortage Remains Unresolved", "summary": "由於 AI 高價訂單增加,消費性電子業將面臨嚴重的晶片缺貨衝擊。\nDue to the surge in high-priced AI orders, the consumer electronics industry will face a severe chip shortage impact.", "category": "科技", "image_url": "https://...", "source": "..." } 3. **分類標籤**:根據文章內容,從以下 9 個選項中挑選最貼近的一個 category: 科技 | 財經 | 國際 | 旅遊 | 生活 | 科學 | 材料工業 | 冷知識 | 熱門趨勢 4. 保留原本的 image_url(不要更改)。 5. 保留原本的 source(不要更改)。 6. 保留原本的 title(中文模式可微調標點符號;對照模式上半段為翻譯、下半段保留英文原標題)。 你必須**嚴格回傳一個 JSON 陣列**,格式如下: [ { "title": "繁體中文標題(外語時:中文\nEnglish)", "summary": "80~200字繁體中文摘要(外語時:中文摘要\nEnglish summary)", "category": "科技", "image_url": "https://...", "source": "來源名稱" }, ... ] 注意事項: - 中文模式摘要必須在 80~200 個中文字之間,不可過短,也不可超過 200 字 - 對照模式上半段繁體中文摘要同樣 80~200 字,下半段英文摘要 40~70 個英文字 - category 必須是列出的 9 個選項之一,不可自行發明 - 所有繁體中文必須使用 zh-TW 習慣用語 - 不可遺漏任何一篇文章 - 不可新增不存在的新聞 - 回傳的 JSON 陣列長度必須等於輸入的文章數量""" # ── HTML 備用爬蟲專用提示詞 ── HTML_FALLBACK_SYSTEM_PROMPT = """你是一個精準的網頁新聞提煉專家。我將提供一段從網站首頁擷取下來的 HTML 純文字內容(已移除 script/style 標籤)。 這段文字混雜了選單、廣告、頁尾與真正的新聞條目。 你的任務是: 1. 從這段雜亂的文字中,精確找出**最新的 2 篇重要新聞** 2. 自行判斷原文語言,遵循中英雙語對照規則(與主新聞秘書相同): - 外語原文 → 上半段繁體中文 / 下半段英文對照 - 中文原文 → 僅繁體中文 3. 繁體中文摘要 80~200 字,英文摘要 40~70 字 4. 為每篇新聞指定一個 category(從:科技 | 財經 | 國際 | 旅遊 | 生活 | 科學 | 材料工業 | 冷知識 | 熱門趨勢 中挑選) 5. 圖片欄位(image_url)若無法從文本中取得,請留空字串 "" 你必須**嚴格回傳一個 JSON 物件**,內含 "articles" 陣列: { "articles": [ { "title": "...", "summary": "...", "category": "...", "image_url": "", "source": "提供的網站名稱" }, ... ] } 注意:只回傳真正的新聞內容,忽略導覽選單、頁尾連結、廣告、社交媒體按鈕等雜訊。""" def fetch_html_fallback(source: Dict) -> List[Dict]: """ HTML 備用爬蟲: 當 RSS/正常爬蟲失敗時,抓取網站首頁並請 DeepSeek 直接從雜亂文本中提取新聞。 回傳的文章已具備最終格式(含 title, summary, category, image_url, source)。 """ name = source["name"] url = source["url"] # 從 URL 提取首頁網址 try: parsed = urlparse(url) base_url = f"{parsed.scheme}://{parsed.netloc}" except Exception: base_url = url print(f"🔄 [{name}] RSS 失敗,啟動 HTML 備用爬蟲 → {base_url}", flush=True) try: resp = requests.get(base_url, headers=HEADERS, timeout=MAX_FETCH_SECONDS) resp.raise_for_status() resp.encoding = resp.apparent_encoding or "utf-8" except Exception as e: print(f" ❌ [{name}] HTML 備用連線失敗: {e}", flush=True) return [] # ── 提取 body 純文字,限制 10000 字元 ── try: soup = BeautifulSoup(resp.text, "html.parser") # 移除 script / style / nav / footer for tag in soup(["script", "style", "nav", "footer", "header", "aside"]): tag.decompose() if soup.body: text = soup.body.get_text(separator="\n", strip=True) else: text = soup.get_text(separator="\n", strip=True) # 壓縮空白 text = re.sub(r"\n\s*\n", "\n", text) text = re.sub(r" +", " ", text) text = text[:10000] print(f" 📄 [{name}] 提取純文字 {len(text)} 字元,餵給 DeepSeek...", flush=True) except Exception as e: print(f" ❌ [{name}] HTML 文本提取失敗: {e}", flush=True) return [] if len(text) < 100: print(f" ⚠️ [{name}] HTML 文本過短,跳過", flush=True) return [] # ── 餵給 DeepSeek 提取新聞 ── try: response = client.chat.completions.create( model=DEEPSEEK_MODEL, messages=[ {"role": "system", "content": HTML_FALLBACK_SYSTEM_PROMPT}, {"role": "user", "content": f"網站名稱:{name}\n網址:{base_url}\n\nHTML 文本內容:\n{text}"}, ], response_format={"type": "json_object"}, temperature=0.3, max_tokens=4096, ) raw = response.choices[0].message.content result = json.loads(raw) # 提取 articles 陣列 articles = [] if isinstance(result, dict): for key in ["articles", "news", "items", "results", "data"]: if key in result and isinstance(result[key], list): articles = result[key] break # 若只有單個 key 是 list if not articles: for val in result.values(): if isinstance(val, list): articles = val break elif isinstance(result, list): articles = result # ── 補完欄位 ── completed = [] for item in articles[:5]: # 最多取 5 篇 if not isinstance(item, dict): continue completed.append({ "title": str(item.get("title", "未知標題")).strip(), "summary": str(item.get("summary", item.get("content", "暫無摘要"))).strip(), "category": str(item.get("category", guess_category(name))).strip(), "image_url": item.get("image_url") or get_fallback_image(str(item.get("title", ""))), "source": name, "_fallback": True, # 標記為備用爬蟲產出(已含摘要,不需再次 summarize) }) if completed: print(f" ✅ [{name}] HTML 備用爬蟲成功提取 {len(completed)} 篇新聞", flush=True) else: print(f" ⚠️ [{name}] HTML 備用爬蟲未找到新聞", flush=True) return completed except json.JSONDecodeError as e: print(f" ❌ [{name}] DeepSeek JSON 解析失敗: {e}", flush=True) except Exception as e: print(f" ❌ [{name}] HTML 備用爬蟲 DeepSeek 呼叫失敗: {e}", flush=True) return [] def summarize_by_deepseek(articles: List[Dict]) -> List[Dict]: """將文章餵給 DeepSeek 進行摘要處理,回傳結構化 JSON 陣列。""" if not articles: return [] # 只傳必要欄位給 AI,節省 token input_data = [ { "title": a["title"], "content": a.get("content", a["title"]), "image_url": a["image_url"], "source": a["source"], } for a in articles ] payload = json.dumps(input_data, ensure_ascii=False) print(f"🤖 發送 {len(articles)} 篇文章給 DeepSeek 摘要...", flush=True) try: response = client.chat.completions.create( model=DEEPSEEK_MODEL, messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": payload}, ], response_format={"type": "json_object"}, temperature=0.3, max_tokens=16384, ) raw = response.choices[0].message.content print(f"✅ DeepSeek 回傳成功 ({len(raw)} 字元)", flush=True) # 嘗試解析 JSON result = json.loads(raw) # 如果 AI 回傳的是 { "articles": [...] } 之類的包裝物件,自動拆開 if isinstance(result, dict): for key in ["articles", "news", "items", "results", "data"]: if key in result and isinstance(result[key], list): result = result[key] break # 如果還是 dict 且只有一個 key 是 list if isinstance(result, dict): for val in result.values(): if isinstance(val, list) and len(val) == len(articles): result = val break if not isinstance(result, list): print(f"⚠️ AI 回傳格式非陣列(型態: {type(result).__name__}),嘗試修復", flush=True) # 最後手段:逐項重建 result = [] # ── 欄位補完機制 ── # 合法的分類標籤 valid_categories = {"科技", "財經", "國際", "旅遊", "生活", "科學", "材料工業", "冷知識", "熱門趨勢"} completed = [] for i, item in enumerate(result[:len(articles)]): if not isinstance(item, dict): continue original = articles[i] if i < len(articles) else {} # 分類智慧猜測:從來源名稱推斷 cat = str(item.get("category", "")).strip() if cat not in valid_categories: src = str(item.get("source", original.get("source", ""))) cat = guess_category(src) completed.append({ "title": str(item.get("title", original.get("title", "未知標題"))).strip(), "summary": str(item.get("summary", item.get("content", original.get("content", "暫無摘要")))).strip(), "category": cat, "image_url": item.get("image_url") or original.get("image_url", get_fallback_image("")), "source": str(item.get("source", original.get("source", "未知來源"))).strip(), }) # 如果 AI 漏掉了一些文章,用原始資料補齊 if len(completed) < len(articles): print(f"⚠️ AI 只回傳 {len(completed)}/{len(articles)} 篇,補齊中...", flush=True) for i in range(len(completed), len(articles)): a = articles[i] completed.append({ "title": a["title"], "summary": clean_html(a.get("content", a["title"]))[:200], "category": guess_category(a.get("source", "")), "image_url": a["image_url"], "source": a["source"], }) print(f"📋 最終輸出 {len(completed)} 篇新聞", flush=True) return completed except json.JSONDecodeError as e: print(f"❌ DeepSeek 回傳的 JSON 無法解析: {e}", flush=True) try: print(f" Raw (前 500 字): {raw[:500]}", flush=True) except NameError: print(" Raw: (無法取得原始回應)", flush=True) except Exception as e: print(f"❌ DeepSeek 呼叫失敗: {e}", flush=True) # ── Fallback:直接回傳原始文章(不做 AI 摘要)── print("🔄 降級:使用原始文章資料回傳", flush=True) fallback = [] for a in articles: fallback.append({ "title": a["title"], "summary": clean_html(a.get("content", a["title"]))[:200], "category": guess_category(a.get("source", "")), "image_url": a["image_url"], "source": a["source"], }) return fallback # ───────────────────────────────────────────────────────────────── # API Endpoints # ───────────────────────────────────────────────────────────────── @app.get("/", response_class=HTMLResponse) @app.head("/") def home(): """Hugging Face 健康檢查頁面""" return ( "" "

🟢 雲端大腦運行完美,等待手機呼叫!

" "

Multi-Source AI News Agent v2.0

" "

支援來源:科技新報 | 癮科技 | 數位時代 | Yahoo 新聞 | HKEPC

" "" ) @app.get("/get_news") def get_news(): """ 主新聞端點: 1. 從 news_sources.json 載入的所有來源平行抓取最新新聞 2. RSS/HTML 失敗時自動切換 HTML 備用爬蟲(DeepSeek 直接提取) 3. 確保每篇都有圖片 4. 一般文章餵給 DeepSeek 做繁體中文摘要(備用文章已含摘要則略過) 5. 回傳統一 JSON 陣列 """ start_time = time.time() all_articles: List[Dict] = [] # ── 步驟 1:平行抓取所有來源 ── print(f"\n{'='*60}", flush=True) print(f"🚀 開始抓取 {len(NEWS_SOURCES)} 個新聞來源...", flush=True) with ThreadPoolExecutor(max_workers=min(len(NEWS_SOURCES), 5)) as executor: future_map = { executor.submit(fetch_articles_from_source, src): src["name"] for src in NEWS_SOURCES } for future in as_completed(future_map): name = future_map[future] try: articles = future.result() all_articles.extend(articles) print(f"✅ [{name}] 完成,取得 {len(articles)} 篇", flush=True) except Exception as e: print(f"❌ [{name}] 執行失敗: {e}", flush=True) fetch_time = time.time() - start_time print(f"📦 總共收集 {len(all_articles)} 篇文章(耗時 {fetch_time:.1f}s)", flush=True) if not all_articles: return { "error": "所有新聞來源暫時無法取得,請稍後再試", "articles": [], } # ── 步驟 2:分離「備用爬蟲文章」(已含 DeepSeek 摘要)與「一般文章」── regular_articles: List[Dict] = [] fallback_articles: List[Dict] = [] for a in all_articles: if a.pop("_fallback", False): fallback_articles.append(a) else: regular_articles.append(a) fb_count = len(fallback_articles) reg_count = len(regular_articles) print(f"📊 一般文章 {reg_count} 篇 + 備用爬蟲文章 {fb_count} 篇(已摘要,略過處理)", flush=True) # ── 步驟 3:一般文章餵給 DeepSeek 摘要 ── BATCH_SIZE = 30 final_articles: List[Dict] = list(fallback_articles) # 備用文章直接加入 if regular_articles: if len(regular_articles) <= BATCH_SIZE: final_articles.extend(summarize_by_deepseek(regular_articles)) else: for i in range(0, len(regular_articles), BATCH_SIZE): batch = regular_articles[i:i + BATCH_SIZE] print(f"📦 處理批次 {i // BATCH_SIZE + 1}/{(len(regular_articles) + BATCH_SIZE - 1) // BATCH_SIZE} ({len(batch)} 篇)...", flush=True) final_articles.extend(summarize_by_deepseek(batch)) total_time = time.time() - start_time print(f"🏁 全部完成:{len(final_articles)} 篇新聞(一般 {reg_count} + 備用 {fb_count})(總耗時 {total_time:.1f}s)", flush=True) print(f"{'='*60}\n", flush=True) return final_articles