news-agent-api / app.py
Tak0000000's picture
Upload 2 files
024d5b4 verified
Raw
History Blame Contribute Delete
42.5 kB
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 ่ฃก็š„ <img>
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 ่ฃก็š„ <img>
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 ่ฃก็š„ <img>
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 (
"<html><body style='font-family:sans-serif;text-align:center;padding-top:80px'>"
"<h1>๐ŸŸข ้›ฒ็ซฏๅคง่…ฆ้‹่กŒๅฎŒ็พŽ๏ผŒ็ญ‰ๅพ…ๆ‰‹ๆฉŸๅ‘ผๅซ๏ผ</h1>"
"<p>Multi-Source AI News Agent v2.0</p>"
"<p>ๆ”ฏๆดไพ†ๆบ๏ผš็ง‘ๆŠ€ๆ–ฐๅ ฑ | ็™ฎ็ง‘ๆŠ€ | ๆ•ธไฝๆ™‚ไปฃ | Yahoo ๆ–ฐ่ž | HKEPC</p>"
"</body></html>"
)
@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