Tak0000000 commited on
Commit
024d5b4
·
verified ·
1 Parent(s): 9485029

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +183 -22
  2. news_sources.json +3 -19
app.py CHANGED
@@ -4,8 +4,9 @@ import random
4
  import re
5
  import time
6
  import xml.etree.ElementTree as ET
7
- from typing import List, Dict, Optional
8
  from concurrent.futures import ThreadPoolExecutor, as_completed
 
9
 
10
  import requests
11
  from fastapi import FastAPI
@@ -540,18 +541,24 @@ def fetch_html_articles(source: Dict) -> List[Dict]:
540
 
541
 
542
  def fetch_articles_from_source(source: Dict) -> List[Dict]:
543
- """統一入口:根據來源類型呼叫對應的擷取函式。"""
 
544
  try:
545
  if source["type"] == "rss":
546
- return fetch_rss_articles(source)
547
  elif source["type"] == "html":
548
- return fetch_html_articles(source)
549
  else:
550
  print(f"⚠️ 未知來源類型: {source['type']}", flush=True)
551
- return []
552
  except Exception as e:
553
  print(f"❌ [{source['name']}] 擷取異常: {e}", flush=True)
554
- return []
 
 
 
 
 
 
555
 
556
 
557
  # ─────────────────────────────────────────────────────────────────
@@ -610,6 +617,146 @@ SYSTEM_PROMPT = """你是一個專業的繁體中文新聞編輯秘書。你將
610
  - 回傳的 JSON 陣列長度必須等於輸入的文章數量"""
611
 
612
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
613
  def summarize_by_deepseek(articles: List[Dict]) -> List[Dict]:
614
  """將文章餵給 DeepSeek 進行摘要處理,回傳結構化 JSON 陣列。"""
615
  if not articles:
@@ -748,10 +895,11 @@ def home():
748
  def get_news():
749
  """
750
  主新聞端點:
751
- 1. 從 5 來源 15 篇最新新聞
752
- 2. 確保每篇都有圖片
753
- 3. 餵給 DeepSeek 做繁體中文摘要
754
- 4. 回傳統JSON 陣列
 
755
  """
756
  start_time = time.time()
757
  all_articles: List[Dict] = []
@@ -783,22 +931,35 @@ def get_news():
783
  "articles": [],
784
  }
785
 
786
- # ── 步驟 2:餵給 DeepSeek 摘要 ──
787
- # 若文章數量過多,分批次處理(每批最多 30 篇)
 
 
 
 
 
 
 
 
 
 
 
 
 
788
  BATCH_SIZE = 30
789
- final_articles: List[Dict] = []
790
 
791
- if len(all_articles) <= BATCH_SIZE:
792
- final_articles = summarize_by_deepseek(all_articles)
793
- else:
794
- for i in range(0, len(all_articles), BATCH_SIZE):
795
- batch = all_articles[i:i + BATCH_SIZE]
796
- print(f"📦 處理批次 {i // BATCH_SIZE + 1}/{(len(all_articles) + BATCH_SIZE - 1) // BATCH_SIZE} ({len(batch)} 篇)...", flush=True)
797
- batch_result = summarize_by_deepseek(batch)
798
- final_articles.extend(batch_result)
799
 
800
  total_time = time.time() - start_time
801
- print(f"🏁 全部完成:{len(final_articles)} 篇新聞(總耗時 {total_time:.1f}s)", flush=True)
802
  print(f"{'='*60}\n", flush=True)
803
 
804
  return final_articles
 
4
  import re
5
  import time
6
  import xml.etree.ElementTree as ET
7
+ from typing import List, Dict, Optional, Tuple
8
  from concurrent.futures import ThreadPoolExecutor, as_completed
9
+ from urllib.parse import urlparse
10
 
11
  import requests
12
  from fastapi import FastAPI
 
541
 
542
 
543
  def fetch_articles_from_source(source: Dict) -> List[Dict]:
544
+ """統一入口:根據來源類型呼叫對應的擷取函式;失敗時自動切換 HTML 備用爬蟲。"""
545
+ articles = []
546
  try:
547
  if source["type"] == "rss":
548
+ articles = fetch_rss_articles(source)
549
  elif source["type"] == "html":
550
+ articles = fetch_html_articles(source)
551
  else:
552
  print(f"⚠️ 未知來源類型: {source['type']}", flush=True)
 
553
  except Exception as e:
554
  print(f"❌ [{source['name']}] 擷取異常: {e}", flush=True)
555
+
556
+ # ── 若正常抓取失敗(0 篇),自動啟動 HTML 備用爬蟲 ──
557
+ if not articles:
558
+ print(f"🔄 [{source['name']}] 正常抓取取得 0 篇,觸發 HTML 備用爬蟲...", flush=True)
559
+ articles = fetch_html_fallback(source)
560
+
561
+ return articles
562
 
563
 
564
  # ─────────────────────────────────────────────────────────────────
 
617
  - 回傳的 JSON 陣列長度必須等於輸入的文章數量"""
618
 
619
 
620
+ # ── HTML 備用爬蟲專用提示詞 ──
621
+ HTML_FALLBACK_SYSTEM_PROMPT = """你是一個精準的網頁新聞提煉專家。我將提供一段從網站首頁擷取下來的 HTML 純文字內容(已移除 script/style 標籤)。
622
+ 這段文字混雜了選單、廣告、頁尾與真正的新聞條目。
623
+
624
+ 你的任務是:
625
+ 1. 從這段雜亂的文字中,精確找出**最新的 2 篇重要新聞**
626
+ 2. 自行判斷原文語言,遵循中英雙語對照規則(與主新聞秘書相同):
627
+ - 外語原文 → 上半段繁體中文 / 下半段英文對照
628
+ - 中文原文 → 僅繁體中文
629
+ 3. 繁體中文摘要 80~200 字,英文摘要 40~70 字
630
+ 4. 為每篇新聞指定一個 category(從:科技 | 財經 | 國際 | 旅遊 | 生活 | 科學 | 材料工業 | 冷知識 | 熱門趨勢 中挑選)
631
+ 5. 圖片欄位(image_url)若無法從文本中取得,請留空字串 ""
632
+
633
+ 你必須**嚴格回傳一個 JSON 物件**,內含 "articles" 陣列:
634
+ {
635
+ "articles": [
636
+ {
637
+ "title": "...",
638
+ "summary": "...",
639
+ "category": "...",
640
+ "image_url": "",
641
+ "source": "提供的網站名稱"
642
+ },
643
+ ...
644
+ ]
645
+ }
646
+
647
+ 注意:只回傳真正的新聞內容,忽略導覽選單、頁尾連結、廣告、社交媒體按鈕等雜訊。"""
648
+
649
+
650
+ def fetch_html_fallback(source: Dict) -> List[Dict]:
651
+ """
652
+ HTML 備用爬蟲:
653
+ 當 RSS/正常爬蟲失敗時,抓取網站首頁並請 DeepSeek 直接從雜亂文本中提取新聞。
654
+ 回傳的文章已具備最終格式(含 title, summary, category, image_url, source)。
655
+ """
656
+ name = source["name"]
657
+ url = source["url"]
658
+
659
+ # 從 URL 提取首頁網址
660
+ try:
661
+ parsed = urlparse(url)
662
+ base_url = f"{parsed.scheme}://{parsed.netloc}"
663
+ except Exception:
664
+ base_url = url
665
+
666
+ print(f"🔄 [{name}] RSS 失敗,啟動 HTML 備用爬蟲 → {base_url}", flush=True)
667
+
668
+ try:
669
+ resp = requests.get(base_url, headers=HEADERS, timeout=MAX_FETCH_SECONDS)
670
+ resp.raise_for_status()
671
+ resp.encoding = resp.apparent_encoding or "utf-8"
672
+ except Exception as e:
673
+ print(f" ❌ [{name}] HTML 備用連線失敗: {e}", flush=True)
674
+ return []
675
+
676
+ # ── 提取 body 純文字,限制 10000 字元 ──
677
+ try:
678
+ soup = BeautifulSoup(resp.text, "html.parser")
679
+ # 移除 script / style / nav / footer
680
+ for tag in soup(["script", "style", "nav", "footer", "header", "aside"]):
681
+ tag.decompose()
682
+ if soup.body:
683
+ text = soup.body.get_text(separator="\n", strip=True)
684
+ else:
685
+ text = soup.get_text(separator="\n", strip=True)
686
+ # 壓縮空白
687
+ text = re.sub(r"\n\s*\n", "\n", text)
688
+ text = re.sub(r" +", " ", text)
689
+ text = text[:10000]
690
+ print(f" 📄 [{name}] 提取純文字 {len(text)} 字元,餵給 DeepSeek...", flush=True)
691
+ except Exception as e:
692
+ print(f" ❌ [{name}] HTML 文本提取失敗: {e}", flush=True)
693
+ return []
694
+
695
+ if len(text) < 100:
696
+ print(f" ⚠️ [{name}] HTML 文本過短,跳過", flush=True)
697
+ return []
698
+
699
+ # ── 餵給 DeepSeek 提取新聞 ��─
700
+ try:
701
+ response = client.chat.completions.create(
702
+ model=DEEPSEEK_MODEL,
703
+ messages=[
704
+ {"role": "system", "content": HTML_FALLBACK_SYSTEM_PROMPT},
705
+ {"role": "user", "content": f"網站名稱:{name}\n網址:{base_url}\n\nHTML 文本內容:\n{text}"},
706
+ ],
707
+ response_format={"type": "json_object"},
708
+ temperature=0.3,
709
+ max_tokens=4096,
710
+ )
711
+
712
+ raw = response.choices[0].message.content
713
+ result = json.loads(raw)
714
+
715
+ # 提取 articles 陣列
716
+ articles = []
717
+ if isinstance(result, dict):
718
+ for key in ["articles", "news", "items", "results", "data"]:
719
+ if key in result and isinstance(result[key], list):
720
+ articles = result[key]
721
+ break
722
+ # 若只有單個 key 是 list
723
+ if not articles:
724
+ for val in result.values():
725
+ if isinstance(val, list):
726
+ articles = val
727
+ break
728
+ elif isinstance(result, list):
729
+ articles = result
730
+
731
+ # ── 補完欄位 ──
732
+ completed = []
733
+ for item in articles[:5]: # 最多取 5 篇
734
+ if not isinstance(item, dict):
735
+ continue
736
+ completed.append({
737
+ "title": str(item.get("title", "未知標題")).strip(),
738
+ "summary": str(item.get("summary", item.get("content", "暫無摘要"))).strip(),
739
+ "category": str(item.get("category", guess_category(name))).strip(),
740
+ "image_url": item.get("image_url") or get_fallback_image(str(item.get("title", ""))),
741
+ "source": name,
742
+ "_fallback": True, # 標記為備用爬蟲產出(已含摘要,不需再次 summarize)
743
+ })
744
+
745
+ if completed:
746
+ print(f" ✅ [{name}] HTML 備用爬蟲成功提取 {len(completed)} 篇新聞", flush=True)
747
+ else:
748
+ print(f" ⚠️ [{name}] HTML 備用爬蟲未找到新聞", flush=True)
749
+
750
+ return completed
751
+
752
+ except json.JSONDecodeError as e:
753
+ print(f" ❌ [{name}] DeepSeek JSON 解析失敗: {e}", flush=True)
754
+ except Exception as e:
755
+ print(f" ❌ [{name}] HTML 備用爬蟲 DeepSeek 呼叫失敗: {e}", flush=True)
756
+
757
+ return []
758
+
759
+
760
  def summarize_by_deepseek(articles: List[Dict]) -> List[Dict]:
761
  """將文章餵給 DeepSeek 進行摘要處理,回傳結構化 JSON 陣列。"""
762
  if not articles:
 
895
  def get_news():
896
  """
897
  主新聞端點:
898
+ 1. 從 news_sources.json 載入的所有來源平行最新新聞
899
+ 2. RSS/HTML 失敗時自動切換 HTML 備用爬蟲(DeepSeek 直接提取)
900
+ 3. 確保每篇都有圖片
901
+ 4. 一般文章餵給 DeepSeek 做繁體中文摘要(備用文章已含摘要則略過)
902
+ 5. 回傳統一 JSON 陣列
903
  """
904
  start_time = time.time()
905
  all_articles: List[Dict] = []
 
931
  "articles": [],
932
  }
933
 
934
+ # ── 步驟 2:分離「備用爬蟲文章」(已含 DeepSeek 摘要)與「一般文章」──
935
+ regular_articles: List[Dict] = []
936
+ fallback_articles: List[Dict] = []
937
+
938
+ for a in all_articles:
939
+ if a.pop("_fallback", False):
940
+ fallback_articles.append(a)
941
+ else:
942
+ regular_articles.append(a)
943
+
944
+ fb_count = len(fallback_articles)
945
+ reg_count = len(regular_articles)
946
+ print(f"📊 一般文章 {reg_count} 篇 + 備用爬蟲文章 {fb_count} 篇(已摘要,略過處理)", flush=True)
947
+
948
+ # ── 步驟 3:一般文章餵給 DeepSeek 摘要 ──
949
  BATCH_SIZE = 30
950
+ final_articles: List[Dict] = list(fallback_articles) # 備用文章直接加入
951
 
952
+ if regular_articles:
953
+ if len(regular_articles) <= BATCH_SIZE:
954
+ final_articles.extend(summarize_by_deepseek(regular_articles))
955
+ else:
956
+ for i in range(0, len(regular_articles), BATCH_SIZE):
957
+ batch = regular_articles[i:i + BATCH_SIZE]
958
+ print(f"📦 處理批次 {i // BATCH_SIZE + 1}/{(len(regular_articles) + BATCH_SIZE - 1) // BATCH_SIZE} ({len(batch)} 篇)...", flush=True)
959
+ final_articles.extend(summarize_by_deepseek(batch))
960
 
961
  total_time = time.time() - start_time
962
+ print(f"🏁 全部完成:{len(final_articles)} 篇新聞(一般 {reg_count} + 備用 {fb_count})(總耗時 {total_time:.1f}s)", flush=True)
963
  print(f"{'='*60}\n", flush=True)
964
 
965
  return final_articles
news_sources.json CHANGED
@@ -1,29 +1,13 @@
1
  [
2
  {"name": "科技新報", "type": "rss", "url": "https://technews.tw/feed/", "category": "科技/AI"},
3
- {"name": "癮科技", "type": "rss", "url": "https://chinese.engadget.com/rss.xml", "category": "科技/3C"},
4
- {"name": "數位時代", "type": "rss", "url": "https://www.bnext.com/rss", "category": "科技/商業"},
5
  {"name": "HKEPC", "type": "html", "url": "https://www.hkepc.com/", "category": "科技/硬體"},
6
- {"name": "QbitAI 機器之心", "type": "rss", "url": "https://www.jiqizhixin.com/rss", "category": "科技/AI"},
7
- {"name": "Inside 科技趨勢", "type": "rss", "url": "https://www.inside.com.tw/feed", "category": "科技/趨勢"},
8
  {"name": "明日科學", "type": "rss", "url": "https://tomorrowsci.com/feed/", "category": "科學/未來"},
9
  {"name": "Gizmodo", "type": "rss", "url": "https://gizmodo.com/rss", "category": "科技/極客"},
10
  {"name": "Hackaday", "type": "rss", "url": "https://hackaday.com/feed/", "category": "科技/DIY"},
11
- {"name": "地球圖輯隊", "type": "rss", "url": "https://world.yam.com/rss.php", "category": "國際/圖文"},
12
- {"name": "冷知識", "type": "rss", "url": "https://misstwocm.com/feed/", "category": "生活/趣味"},
13
  {"name": "鉅亨網 國際政經", "type": "rss", "url": "https://news.cnyes.com/rss/div/global_macro", "category": "財經/國際"},
14
- {"name": "阿斯達克財經網", "type": "rss", "url": "https://www.aastocks.com/tc/resources/rss.ashx?type=1", "category": "財經/港股"},
15
- {"name": "RTHK 財經新聞", "type": "rss", "url": "https://rthk.hk/rthk/news/rss/c_expressnews_cfinance.xml", "category": "財經/香港"},
16
- {"name": "RTHK 本地新聞", "type": "rss", "url": "https://rthk.hk/rthk/news/rss/c_expressnews_clocal.xml", "category": "時事/香港"},
17
- {"name": "RTHK 國際新聞", "type": "rss", "url": "https://rthk.hk/rthk/news/rss/c_expressnews_cinternational.xml", "category": "國際/時事"},
18
- {"name": "Plastics Today", "type": "rss", "url": "https://www.plasticstoday.com/rss.xml", "category": "材料工業/塑膠"},
19
- {"name": "Metal Miner", "type": "rss", "url": "https://agmetalminer.com/feed/", "category": "材料工業/金屬"},
20
- {"name": "AZoM 材料科學", "type": "rss", "url": "https://www.azom.com/azom-news-feed.xml", "category": "材料工業/科學"},
21
- {"name": "Wave 流行潮流", "type": "rss", "url": "https://www.wavetv.tw/feed/", "category": "生活/潮流"},
22
- {"name": "U Travel 旅遊", "type": "rss", "url": "https://utravel.com.hk/rss", "category": "旅遊/香港"},
23
- {"name": "Yahoo 旅遊 港台", "type": "rss", "url": "https://travel.yahoo.com.tw/rss/headline/", "category": "旅遊/港台"},
24
- {"name": "Yahoo 旅遊 香港", "type": "rss", "url": "https://hk.news.yahoo.com/rss/travel", "category": "旅遊/香港"},
25
- {"name": "港生活 北上", "type": "rss", "url": "https://hk.ulifestyle.com.hk/rss/travel-main", "category": "旅遊/深圳"},
26
- {"name": "香港01 大灣區", "type": "rss", "url": "https://www.hk01.com/rss/category/877", "category": "旅遊/大灣區"},
27
  {"name": "Google Trends 香港","type": "rss", "url": "https://trends.google.com.hk/trending/rss?geo=HK", "category": "熱門趨勢/香港"},
28
  {"name": "Google Trends 美國","type": "rss", "url": "https://trends.google.com/trending/rss?geo=US", "category": "熱門趨勢/全球"}
29
  ]
 
1
  [
2
  {"name": "科技新報", "type": "rss", "url": "https://technews.tw/feed/", "category": "科技/AI"},
3
+ {"name": "數位時代", "type": "rss", "url": "https://www.bnext.com/", "category": "科技/商業"},
 
4
  {"name": "HKEPC", "type": "html", "url": "https://www.hkepc.com/", "category": "科技/硬體"},
5
+ {"name": "QbitAI 機器之心", "type": "rss", "url": "https://www.jiqizhixin.com/", "category": "科技/AI"},
6
+ {"name": "Inside 科技趨勢", "type": "rss", "url": "https://www.inside.com.tw/", "category": "科技/趨勢"},
7
  {"name": "明日科學", "type": "rss", "url": "https://tomorrowsci.com/feed/", "category": "科學/未來"},
8
  {"name": "Gizmodo", "type": "rss", "url": "https://gizmodo.com/rss", "category": "科技/極客"},
9
  {"name": "Hackaday", "type": "rss", "url": "https://hackaday.com/feed/", "category": "科技/DIY"},
 
 
10
  {"name": "鉅亨網 國際政經", "type": "rss", "url": "https://news.cnyes.com/rss/div/global_macro", "category": "財經/國際"},
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  {"name": "Google Trends 香港","type": "rss", "url": "https://trends.google.com.hk/trending/rss?geo=HK", "category": "熱門趨勢/香港"},
12
  {"name": "Google Trends 美國","type": "rss", "url": "https://trends.google.com/trending/rss?geo=US", "category": "熱門趨勢/全球"}
13
  ]