前情提要

最近在学习大模型应用开发时,有一段用Python的requests库爬取网页的代码,那么我就在想,能不能编写一个完整的项目,用来调用市面上常用的新闻平台,最后汇集之后用大模型解析呢,说干就干,先是对百度新闻下手,可惜百度新闻的列表可以获取,但它大都是点击后调用的百度搜索进行的查询,这样也算是可以随机获取最新消息的一种途径吧,但不是我预期的效果,之后辗转到网易老大哥上,发现正合我意,于是先把网易的新闻爬下来进行测试,未来也有打算把常用平台全部整合一下,但现在先发布这一个平台吧。

🤖 Python + Tkinter + Ollama:构建网易新闻AI自动化总结系统

这是一个结合了多线程爬虫、Tkinter GUI本地大模型(Ollama) 的完整应用案例。它能够并发抓取网易新闻多个分类的实时链接,获取新闻内容,并通过本地部署的LLM(如Qwen2.5)对内容进行自动化总结,并将结果在一个直观的图形界面(GUI)中展示和保存。

本文将从头到尾详细解析这个系统的设计思路和核心代码实现。

🚀 一、系统概览与核心技术栈

该系统旨在解决海量新闻信息的快速筛选与摘要问题,其核心工作流分为两个主要阶段:目录获取内容/AI处理

模块 职责 使用的技术
GUI界面 提供用户交互、配置和实时结果展示。 tkinter (ttk, scrolledtext)
网页爬取 获取新闻分类目录链接和详细内容。 requests, BeautifulSoup (lxml), urllib3
并发处理 加速内容抓取和AI总结的效率。 threading, concurrent.futures.ThreadPoolExecutor
AI总结 对抓取到的新闻文本进行自动化摘要。 requests (调用本地 Ollama API)
数据管理 存储爬取和处理后的新闻数据。 Python dictlist

🛠️ 二、核心工具函数解析

1. 配置与初始化

系统首先定义了新闻的分类配置和通用的请求头

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# NEWS_CATEGORIES 字典定义了分类名称和对应的网易新闻URL
NEWS_CATEGORIES = {
"国内": "https://news.163.com/domestic/",
# ... 其他分类
}

# HEADERS 模拟浏览器请求,避免被网站拒绝
HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
# ... 其他头部信息
}

# 禁用不安全请求警告
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

这个函数负责阶段一的工作:从单个分类页面的主目录中快速提取新闻的标题链接

  • 它使用 requests 访问分类URL,并用 BeautifulSoup 进行解析。
  • 使用了多个CSS选择器列表(content_selectors),以适应网易新闻不同分类页面可能不同的HTML结构。
  • 通过链接的关键词('163.com' in href)和标题的长度(len(title) > 5)进行严格过滤,确保获取的是有效新闻链接。

3. 内容爬取 (get_news_content)

该函数负责获取单条新闻的详细文本内容。

  • 与目录爬取类似,它也使用一个通用选择器列表content_selectors),来匹配新闻正文所在的 div 元素。
  • 使用 subContent.get_text(separator='\n', strip=True) 确保内容文本的段落清晰,并过滤掉内容过短的页面(如纯图片集)。

4. AI总结 (summarize_with_ollama)

这是系统的核心功能之一,它利用 requests 调用本地 Ollama 服务。

  • API 地址http://localhost:11434/api/generate
  • 模型:示例中使用了 "qwen2.5:7b"(用户可根据本地环境修改)。
  • Prompt 设计:清晰地指示LLM对标题和内容进行总结,并限制了输出长度(不超过150字),要求使用清晰的中文口吻。
  • 流式处理:设置 "stream": True,并通过 response.iter_lines() 逐行解析JSON数据,实时接收LLM的输出,直到遇到 {"done": true}
  • 包含了对 Ollama 连接失败或HTTP错误的健壮性处理。

💻 三、GUI应用类 (NewsCrawlerApp) 详解

NewsCrawlerApp 是整个应用程序的骨架,它管理着界面、数据和多线程任务。

1. 界面布局 (setup_ui)

界面分为以下几个区域:

  • 控制区域:包含分类选择数量选择AI总结开关开始/停止保存清空按钮。
  • 进度条 (ttk.Progressbar):实时显示任务的整体进度。
  • 状态标签 (self.status_label):显示当前操作状态和提示信息。
  • 新闻列表 (tk.Listbox):左侧展示爬取到的新闻标题,通过颜色区分处理状态。
  • 新闻详情 (scrolledtext.ScrolledText):右侧显示选中新闻的完整信息、AI总结和内容预览。

2. 多线程控制逻辑

为了不阻塞GUI,所有耗时的网络请求和AI处理都被放到了后台线程中。

🔹 start_crawlingtoggle_crawling

这是启动爬取的核心方法。它负责:

  1. 设置 self.is_crawling = True,并更新按钮状态。
  2. 创建一个主爬取线程 (self.master_thread) 来运行 _run_master_crawl

🔹 _run_master_crawl (主调度线程)

这个线程负责两阶段调度:

  1. 阶段一 (目录获取 0%-30%):串行遍历所有选定的分类,调用 get_category_links 获取链接。每获取一个分类,即时将标题和占位符(如 “等待获取内容…”)添加到 self.news_data 列表和 Listbox 中。
  2. 阶段二 (内容/AI处理 30%-100%):使用 ThreadPoolExecutor(max_workers=10) 创建一个线程池。为 self.news_data 中的每条新闻提交一个任务给线程池,运行 _process_single_news
  3. 使用 as_completed 迭代器等待任务完成,并在任务完成后,通过 root.after(0, ...) 将进度和UI更新请求提交回主线程

🔹 _process_single_news (线程池工作函数)

在线程池中并发执行:

  1. 调用 get_news_content 获取新闻正文。
  2. 如果启用了AI,调用 summarize_with_ollama 进行总结。
  3. 更新新闻字典中的 contentsummary 字段。

3. UI 交互与更新

由于Tkinter是单线程的,任何对UI的修改(如更新列表、进度条、状态标签)都必须在主线程中进行。系统通过 self.root.after(0, self.function_name, ...) 实现了跨线程的UI安全更新。

  • _add_news_to_list:将目录信息添加到 Listbox,初始显示为灰色。
  • _update_list_item:根据处理结果(内容和AI总结)更新 Listbox 项的颜色,例如:
    • 红色:获取失败或AI错误。
    • 深绿色:内容和AI总结成功完成。
  • show_news_detail:在右侧详情区域展示新闻的所有信息,并突出显示AI总结部分。

💾 四、保存与清空

save_results

该方法将已完成处理的新闻数据整理成结构清晰的 Markdown (.md) 文件。它按照分类进行分组,每条新闻都包含标题、链接、AI总结和内容预览,方便后续阅读和存档。


完整代码

温馨提示,程序需要Ollama接口和自定义或者下载模型qwen2.5:7b

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
import json
import requests
import urllib3
import time
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from bs4 import BeautifulSoup
import tkinter as tk
from tkinter import ttk, scrolledtext, messagebox, filedialog

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

NEWS_CATEGORIES = {
"国内": "https://news.163.com/domestic/",
"国际": "https://news.163.com/world/",
"体育": "https://sports.163.com/",
"NBA": "https://sports.163.com/nba/",
"娱乐": "https://ent.163.com/",
"电影": "https://ent.163.com/movie/",
"财经": "https://money.163.com/",
"科技": "https://tech.163.com/",
"互联网": "https://tech.163.com/internet/",
"汽车": "https://auto.163.com/",
"教育": "https://edu.163.com/",
}

HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1',
}

def get_category_links(category_url, max_links):
valid_links = []
try:
resp = requests.get(category_url, headers=HEADERS, verify=False, timeout=10)
resp.encoding = "utf-8"

if resp.status_code != 200:
return valid_links

html = BeautifulSoup(resp.text, "lxml")

content_selectors = [
"div.hidden", "div.news_list", "div.news-data",
"ul.newsList", "div.post_list", "div[class*='news']",
"div[class*='list']", "div.list_wrap"
]

content = None
for selector in content_selectors:
content = html.select_one(selector)
if content:
break

if content:
links = content.find_all("a", href=True)

for a in links:
href = a.get("href", "")
title = a.get_text(strip=True)
if (href and (href.startswith('https://www.163.com/dy/article/') or '163.com' in href) and
title and len(title) > 5 and len(valid_links) < max_links):
if not any(l[1] == href for l in valid_links):
valid_links.append((title, href))

except Exception:
pass

return valid_links[:max_links]


def get_news_content(url):
try:
subResponse = requests.get(url, headers=HEADERS, verify=False, timeout=10)
subResponse.encoding = "utf-8"

if subResponse.status_code != 200:
return ""

subHtml = BeautifulSoup(subResponse.text, "lxml")

content_selectors = [
"div.post_body", "div.content", "div.article-content",
"div.art-content", "div.main-content", "div[class*='content']",
"div[class*='body']", ".post_text"
]

for selector in content_selectors:
subContent = subHtml.select_one(selector)
if subContent:
text_content = subContent.get_text(separator='\n', strip=True)
if len(text_content) > 50:
return text_content
return ""

except Exception:
return ""


def summarize_with_ollama(title, content):
try:
url = "http://localhost:11434/api/generate"
content_preview = content[:1500]
prompt = f"请对以下新闻进行简要总结,不超过150字,重点突出,用清晰的中文口吻:\n标题:{title}\n内容:{content_preview}"

data = {
"model": "qwen2.5:7b",
"prompt": prompt,
"stream": True,
"options": {"temperature": 0.3}
}

response = requests.post(url, json=data, stream=True, timeout=60)
response.raise_for_status()

full_response = ""
for item in response.iter_lines():
if item:
try:
json_data = json.loads(item.decode())
if "response" in json_data:
full_response += json_data["response"]
if json_data.get("done"):
break
except json.JSONDecodeError:
continue

return full_response.strip()

except requests.exceptions.ConnectionError:
return "🚨 Ollama连接失败。请检查服务是否运行在 http://localhost:11434"
except requests.exceptions.HTTPError as e:
return f"🚨 AI总结HTTP错误: {e.response.status_code}"
except Exception as e:
return f"🚨 AI总结失败: {str(e)}"


class NewsCrawlerApp:
def __init__(self, root):
self.root = root
self.root.title("网易新闻AI总结系统 (优化版)")
self.root.geometry("1200x800")
self.news_data = []
self.is_crawling = False
self.thread_executor = ThreadPoolExecutor(max_workers=10)
self.crawl_futures = []
self.setup_ui()

def setup_ui(self):
main_frame = ttk.Frame(self.root, padding="10")
main_frame.pack(fill=tk.BOTH, expand=True)

control_frame = ttk.Frame(main_frame)
control_frame.pack(fill=tk.X, pady=(0, 10))

row1_frame = ttk.Frame(control_frame)
row1_frame.pack(fill=tk.X)

ttk.Label(row1_frame, text="选择分类:").pack(side=tk.LEFT, padx=(0, 5))
self.category_var = tk.StringVar(value="全部")
self.category_combo = ttk.Combobox(row1_frame, textvariable=self.category_var,
values=["全部"] + list(NEWS_CATEGORIES.keys()),
state="readonly", width=15)
self.category_combo.pack(side=tk.LEFT, padx=(0, 20))

ttk.Label(row1_frame, text="每类数量:").pack(side=tk.LEFT, padx=(0, 5))
self.count_var = tk.StringVar(value="10")
self.count_combo = ttk.Combobox(row1_frame, textvariable=self.count_var,
values=[str(i) for i in [5, 10, 20, 30, 50]],
state="readonly", width=8)
self.count_combo.pack(side=tk.LEFT, padx=(0, 20))

self.ai_var = tk.BooleanVar(value=True)
self.ai_check = ttk.Checkbutton(row1_frame, text="启用AI总结 (耗时)", variable=self.ai_var)
self.ai_check.pack(side=tk.LEFT, padx=(0, 20))

row2_frame = ttk.Frame(control_frame)
row2_frame.pack(fill=tk.X, pady=(10, 0))

self.start_btn = ttk.Button(row2_frame, text="🚀 开始爬取", command=self.toggle_crawling)
self.start_btn.pack(side=tk.LEFT, padx=(0, 10))

self.save_btn = ttk.Button(row2_frame, text="💾 保存结果", command=self.save_results, state='disabled')
self.save_btn.pack(side=tk.LEFT, padx=(0, 10))

self.clear_btn = ttk.Button(row2_frame, text="🗑️ 清空列表", command=self.clear_list)
self.clear_btn.pack(side=tk.LEFT)

self.progress = ttk.Progressbar(main_frame, mode='determinate')
self.progress.pack(fill=tk.X, pady=(0, 5))

self.status_var = tk.StringVar(value="准备就绪")
self.status_label = ttk.Label(main_frame, textvariable=self.status_var, foreground="#007bff")
self.status_label.pack(fill=tk.X)

content_frame = ttk.Frame(main_frame)
content_frame.pack(fill=tk.BOTH, expand=True)

list_frame = ttk.LabelFrame(content_frame, text="新闻列表", padding="5")
list_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 5))

self.news_list = tk.Listbox(list_frame, font=("Microsoft YaHei", 10))
scrollbar_list = ttk.Scrollbar(list_frame, orient=tk.VERTICAL, command=self.news_list.yview)
self.news_list.configure(yscrollcommand=scrollbar_list.set)

self.news_list.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar_list.pack(side=tk.RIGHT, fill=tk.Y)

self.news_list.bind('<<ListboxSelect>>', self.on_list_select)

detail_frame = ttk.LabelFrame(content_frame, text="新闻详情", padding="5")
detail_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True)

self.detail_text = scrolledtext.ScrolledText(detail_frame, wrap=tk.WORD,
font=("Microsoft YaHei", 10),
width=60)
self.detail_text.pack(fill=tk.BOTH, expand=True)

self.stats_var = tk.StringVar(value="总新闻数: 0 | 已处理内容/AI: 0")
self.stats_label = ttk.Label(main_frame, textvariable=self.stats_var)
self.stats_label.pack(fill=tk.X, pady=(5, 0))

def toggle_crawling(self):
if self.is_crawling:
self.stop_crawling()
else:
self.start_crawling()

def start_crawling(self):
selected_category = self.category_var.get()
categories_to_crawl = list(NEWS_CATEGORIES.keys()) if selected_category == "全部" else [selected_category]

max_news = int(self.count_var.get())
enable_ai = self.ai_var.get()

if self.is_crawling:
return

self.clear_list(silent=True)
self.is_crawling = True
self.start_btn.config(text="停止爬取", state='normal')
self.save_btn.config(state='disabled')
self.progress['value'] = 0
self.crawl_futures = []

self.update_status("🚀 **开始目录爬取**...")

self.master_thread = threading.Thread(
target=self._run_master_crawl,
args=(categories_to_crawl, max_news, enable_ai)
)
self.master_thread.daemon = True
self.master_thread.start()

def stop_crawling(self):
if not self.is_crawling:
return

self.is_crawling = False

for future in self.crawl_futures:
future.cancel()

self.start_btn.config(text="开始爬取")
self.save_btn.config(state='normal')
self.update_status("🛑 **爬取已停止**")
self.update_progress(0)

def _run_master_crawl(self, categories, max_news, enable_ai):
initial_news_count = 0

try:
self.update_status("✅ **阶段一:正在快速获取新闻目录...**")

for index, category in enumerate(categories):
if not self.is_crawling:
break

category_url = NEWS_CATEGORIES[category]
links = get_category_links(category_url, max_news)

progress = int(((index + 1) / len(categories)) * 30)
self.root.after(0, self.update_progress, progress)

for title, url in links:
news_data = {
'category': category,
'title': title,
'url': url,
'content': '等待获取内容...',
'summary': '等待AI总结...' if enable_ai else 'AI已禁用',
'timestamp': datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
'list_index': len(self.news_data)
}
self.news_data.append(news_data)
self.root.after(0, self._add_news_to_list, news_data)
initial_news_count += 1

time.sleep(0.5)

if not self.is_crawling:
return

self.root.after(0, self.update_stats, initial_news_count, 0)
self.update_status(f"🎉 **阶段一完成!** 找到 {initial_news_count} 条新闻。开始内容/AI处理...")

total_tasks = initial_news_count
if total_tasks == 0:
self.root.after(0, self.crawling_finished)
return

processed_count = 0

for data in self.news_data:
if not self.is_crawling:
break
future = self.thread_executor.submit(
self._process_single_news,
data,
enable_ai
)
self.crawl_futures.append(future)

for future in as_completed(self.crawl_futures):
if not self.is_crawling:
break

if future.cancelled():
continue

processed_data = future.result()

if processed_data:
self.root.after(0, self._update_list_item, processed_data)

processed_count += 1

progress = 30 + int((processed_count / total_tasks) * 70)
self.root.after(0, self.update_progress, progress)
self.root.after(0, self.update_stats, total_tasks, processed_count)

except Exception as e:
self.root.after(0, self.update_status, f"🚨 **主爬取线程出错**: {str(e)}")

finally:
self.root.after(0, self.crawling_finished)

def _process_single_news(self, news_data, enable_ai):
if not self.is_crawling:
return None

content = get_news_content(news_data['url'])
news_data['content'] = content if content else '获取内容失败或内容过短。'

if content and enable_ai:
summary = summarize_with_ollama(news_data['title'], content)
news_data['summary'] = summary
elif not enable_ai:
news_data['summary'] = 'AI总结已禁用。'
else:
news_data['summary'] = '未能获取内容,无法进行AI总结。'

news_data['finish_time'] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

return news_data

def _add_news_to_list(self, news_data):
display_text = f"[{news_data['category']}] {news_data['title']}"
self.news_list.insert(tk.END, display_text)
self.news_list.itemconfig(tk.END, {'fg': 'gray'})

def _update_list_item(self, processed_data):
index = processed_data['list_index']
self.news_data[index] = processed_data

if "🚨" in processed_data['summary'] or not processed_data['content']:
color = 'red'
elif "等待" in processed_data['summary'] or "已禁用" in processed_data['summary']:
color = 'blue'
else:
color = 'darkgreen'

self.news_list.itemconfig(index, {'fg': color})

selection = self.news_list.curselection()
if selection and selection[0] == index:
self.show_news_detail(index)

def on_list_select(self, event):
selection = self.news_list.curselection()
if selection:
index = selection[0]
if 0 <= index < len(self.news_data):
self.show_news_detail(index)

def show_news_detail(self, index):
news = self.news_data[index]

summary_color = 'darkgreen' if "🚨" not in news['summary'] and "等待" not in news['summary'] else 'red'

detail_text = f"""【分类】{news['category']}
【标题】{news['title']}
【获取时间】{news['timestamp']}
【处理完成】{news.get('finish_time', '未完成')}
【链接】{news['url']}

------------------------------------------
【AI总结】
{news['summary']}
------------------------------------------

【内容预览】
{news['content'][:800] + '...\n\n(内容可能已被截断)' if len(news['content']) > 800 else news['content']}
"""

self.detail_text.delete(1.0, tk.END)
self.detail_text.insert(1.0, detail_text)

start_index = self.detail_text.search("【AI总结】", "1.0", stopindex=tk.END)
if start_index:
end_index = self.detail_text.search("------------------------------------------", start_index + "+1c",
stopindex=tk.END)
self.detail_text.tag_config("summary_tag", foreground=summary_color, font=("Microsoft YaHei", 10, "bold"))
self.detail_text.tag_add("summary_tag", start_index, end_index)

def update_status(self, message):
def _update():
self.status_var.set(message)
self.status_label.config(foreground='red' if '🚨' in message or '🛑' in message else '#007bff')
self.root.after(0, _update)

def update_progress(self, value):
def _update():
self.progress['value'] = value
self.root.after(0, _update)

def update_stats(self, total, processed):
self.stats_var.set(f"总新闻数: {total} | 已处理内容/AI: {processed}")

def crawling_finished(self):
self.is_crawling = False
self.start_btn.config(text="🚀 开始爬取", state='normal')
self.save_btn.config(state='normal')
self.update_status(f"🎉 **所有任务完成!** 总计 {len(self.news_data)} 条新闻已处理。")
self.update_progress(100)

def save_results(self):
if not self.news_data:
messagebox.showwarning("警告", "没有数据可保存!")
return

finished_data = [d for d in self.news_data if d.get('finish_time')]
if not finished_data:
messagebox.showwarning("警告", "没有已完成处理(内容和AI)的数据可保存!")
return

try:
initial_filename = datetime.now().strftime("%Y%m%d_%H%M%S_news.md")
file_path = filedialog.asksaveasfilename(
defaultextension=".md",
initialfile=initial_filename,
filetypes=[("Markdown files", "*.md"), ("All files", "*.*")]
)

if not file_path:
return

with open(file_path, 'w', encoding='utf-8') as f:
f.write(f"# 网易新闻AI总结报告\n\n")
f.write(f"生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
f.write(f"新闻数量: {len(finished_data)}\n\n")

categories = {}
for news in finished_data:
categories.setdefault(news['category'], []).append(news)

for category, news_list in categories.items():
f.write(f"## {category}\n\n")
f.write("---\n\n")

for i, news in enumerate(news_list, 1):
f.write(f"### {i}. {news['title']}\n\n")
f.write(f"- **获取时间**: {news['timestamp']}\n")
f.write(f"- **处理完成**: {news.get('finish_time')}\n")
f.write(f"- **原文链接**: <{news['url']}>\n\n")

f.write("#### AI总结\n")
summary_lines = news['summary'].split('\n')
for line in summary_lines:
f.write(f"> {line.strip()}\n")
f.write("\n")

f.write("#### 内容预览 (前 200 字符)\n")
content_preview = news['content'][:200].replace('\n', ' ') + '...' if len(
news['content']) > 200 else news['content'].replace('\n', ' ')
f.write(f"**内容预览**: {content_preview}\n\n")
f.write("---\n\n")

messagebox.showinfo("成功", f"结果已保存到: {file_path}")
self.update_status(f"💾 结果已保存到: {file_path}")

except Exception as e:
messagebox.showerror("错误", f"保存文件时出错: {str(e)}")

def clear_list(self, silent=False):
self.news_data.clear()
self.news_list.delete(0, tk.END)
self.detail_text.delete(1.0, tk.END)
self.update_stats(0, 0)

if not silent:
self.update_status("列表已清空")


def main():
root = tk.Tk()

try:
root.tk.call('source', 'azure.tcl')
root.tk.call('set_theme', 'dark')
except Exception:
pass

app = NewsCrawlerApp(root)
root.mainloop()


if __name__ == "__main__":
main()