arxiv-pulse 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
arxiv_pulse/cli.py ADDED
@@ -0,0 +1,1608 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ arXiv Pulse - 简化版命令行界面
4
+ 核心功能:初始化、更新同步、智能搜索、最近论文报告
5
+ """
6
+
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+ import click
11
+ from dotenv import load_dotenv
12
+ import json
13
+ from datetime import datetime, timedelta
14
+ import questionary
15
+
16
+ from arxiv_pulse.config import Config
17
+ from arxiv_pulse.arxiv_crawler import ArXivCrawler
18
+ from arxiv_pulse.summarizer import PaperSummarizer
19
+ from arxiv_pulse.report_generator import ReportGenerator
20
+ from arxiv_pulse.output_manager import output
21
+ from arxiv_pulse.search_engine import SearchEngine, SearchFilter
22
+ from arxiv_pulse.__version__ import __version__
23
+
24
+
25
+ # arXiv研究领域定义(用于交互式配置和横幅生成)
26
+ RESEARCH_FIELDS = {
27
+ # 物理学领域
28
+ "condensed_matter": {
29
+ "name": "凝聚态物理",
30
+ "query": "condensed matter physics AND cat:cond-mat.*",
31
+ "description": "包括超导、强关联电子、介观系统、材料科学等",
32
+ "keywords": ["condensed matter physics", "cond-mat"],
33
+ },
34
+ "astro_physics": {
35
+ "name": "天体物理",
36
+ "query": "cat:astro-ph.*",
37
+ "description": "天体物理学、宇宙学、天体观测等",
38
+ "keywords": ["astro-ph"],
39
+ },
40
+ "high_energy_physics": {
41
+ "name": "高能物理(粒子物理)",
42
+ "query": "cat:hep-ph.* OR cat:hep-ex.* OR cat:hep-th.* OR cat:hep-lat.*",
43
+ "description": "粒子物理、高能物理理论与实验",
44
+ "keywords": ["hep-ph", "hep-ex", "hep-th", "hep-lat"],
45
+ },
46
+ "nuclear_physics": {
47
+ "name": "核物理",
48
+ "query": "cat:nucl-th.* OR cat:nucl-ex.*",
49
+ "description": "核物理理论与实验",
50
+ "keywords": ["nucl-th", "nucl-ex"],
51
+ },
52
+ "general_relativity": {
53
+ "name": "广义相对论与宇宙学",
54
+ "query": "cat:gr-qc.*",
55
+ "description": "引力理论、宇宙学、黑洞物理",
56
+ "keywords": ["gr-qc"],
57
+ },
58
+ "quantum_physics": {
59
+ "name": "量子物理",
60
+ "query": "cat:quant-ph.*",
61
+ "description": "量子信息、量子计算、量子基础",
62
+ "keywords": ["quant-ph"],
63
+ },
64
+ "computational_physics": {
65
+ "name": "计算物理",
66
+ "query": "cat:physics.comp-ph",
67
+ "description": "数值计算方法在物理中的应用",
68
+ "keywords": ["physics.comp-ph"],
69
+ },
70
+ "chemical_physics": {
71
+ "name": "化学物理",
72
+ "query": "cat:physics.chem-ph",
73
+ "description": "化学过程的物理基础",
74
+ "keywords": ["physics.chem-ph"],
75
+ },
76
+ "physics_other": {
77
+ "name": "物理学(其他)",
78
+ "query": "cat:physics:* NOT cat:physics.comp-ph NOT cat:physics.chem-ph",
79
+ "description": "其他物理学领域",
80
+ "keywords": ["physics:"],
81
+ },
82
+ "nonlinear_science": {
83
+ "name": "非线性科学",
84
+ "query": "cat:nlin.*",
85
+ "description": "非线性动力学、复杂系统、混沌理论",
86
+ "keywords": ["nlin"],
87
+ },
88
+ "mathematical_physics": {
89
+ "name": "数学物理",
90
+ "query": "cat:math-ph.*",
91
+ "description": "物理问题的数学方法",
92
+ "keywords": ["math-ph"],
93
+ },
94
+ # 计算材料科学专业领域
95
+ "dft": {
96
+ "name": "密度泛函理论 (DFT)",
97
+ "query": '(ti:"density functional" OR abs:"density functional") AND (cat:physics.comp-ph OR cat:cond-mat.mtrl-sci OR cat:physics.chem-ph)',
98
+ "description": "第一性原理计算、材料设计",
99
+ "keywords": ["density functional"],
100
+ },
101
+ "first_principles": {
102
+ "name": "第一性原理计算",
103
+ "query": '(ti:"first principles" OR abs:"first principles" OR ti:"ab initio" OR abs:"ab initio") AND (cat:physics.comp-ph OR cat:cond-mat.mtrl-sci)',
104
+ "description": "从头计算、量子化学方法",
105
+ "keywords": ["first principles", "ab initio"],
106
+ },
107
+ "quantum_chemistry": {
108
+ "name": "量子化学",
109
+ "query": '(ti:"quantum chemistry" OR abs:"quantum chemistry") AND (cat:physics.chem-ph OR cat:physics.comp-ph)',
110
+ "description": "量子化学方法与计算",
111
+ "keywords": ["quantum chemistry"],
112
+ },
113
+ "force_fields": {
114
+ "name": "力场与分子动力学",
115
+ "query": '(ti:"force field" OR abs:"force field") AND (cat:physics.comp-ph OR cat:cond-mat.soft OR cat:physics.chem-ph)',
116
+ "description": "力场开发、分子动力学模拟",
117
+ "keywords": ["force field"],
118
+ },
119
+ "molecular_dynamics": {
120
+ "name": "分子动力学",
121
+ "query": '(ti:"molecular dynamics" OR abs:"molecular dynamics") AND (cat:physics.comp-ph OR cat:cond-mat.soft OR cat:physics.chem-ph)',
122
+ "description": "分子动力学模拟技术",
123
+ "keywords": ["molecular dynamics"],
124
+ },
125
+ "computational_materials": {
126
+ "name": "计算材料科学",
127
+ "query": 'cat:cond-mat.mtrl-sci AND (ti:"computational" OR abs:"computational" OR ti:"simulation" OR abs:"simulation")',
128
+ "description": "材料计算与模拟",
129
+ "keywords": ["computational materials", "materials science"],
130
+ },
131
+ # 数学领域
132
+ "mathematics": {
133
+ "name": "数学",
134
+ "query": "cat:math.* AND NOT cat:math-ph.*",
135
+ "description": "纯数学与应用数学",
136
+ "keywords": ["cat:math."],
137
+ },
138
+ "numerical_analysis": {
139
+ "name": "数值分析",
140
+ "query": "cat:math.NA",
141
+ "description": "数值计算方法与算法",
142
+ "keywords": ["math.NA"],
143
+ },
144
+ "optimization_control": {
145
+ "name": "优化与控制",
146
+ "query": "cat:math.OC",
147
+ "description": "数学优化、最优控制理论",
148
+ "keywords": ["math.OC"],
149
+ },
150
+ "statistics_math": {
151
+ "name": "统计学(数学)",
152
+ "query": "cat:math.ST",
153
+ "description": "数理统计理论",
154
+ "keywords": ["math.ST"],
155
+ },
156
+ # 计算机科学领域
157
+ "machine_learning": {
158
+ "name": "机器学习",
159
+ "query": '(ti:"machine learning" OR abs:"machine learning") AND (cat:physics.comp-ph OR cat:cond-mat.mtrl-sci OR cat:physics.chem-ph OR cat:cs.* OR cat:stat.*)',
160
+ "description": "机器学习在物理和材料科学中的应用",
161
+ "keywords": ["machine learning"],
162
+ },
163
+ "artificial_intelligence": {
164
+ "name": "人工智能",
165
+ "query": "cat:cs.AI OR cat:cs.LG OR cat:cs.NE",
166
+ "description": "人工智能、机器学习、神经网络",
167
+ "keywords": ["cs.AI", "cs.LG", "cs.NE"],
168
+ },
169
+ "computer_vision": {
170
+ "name": "计算机视觉",
171
+ "query": "cat:cs.CV",
172
+ "description": "图像处理、计算机视觉",
173
+ "keywords": ["cs.CV"],
174
+ },
175
+ "natural_language": {
176
+ "name": "自然语言处理",
177
+ "query": "cat:cs.CL",
178
+ "description": "计算语言学、自然语言处理",
179
+ "keywords": ["cs.CL"],
180
+ },
181
+ "computer_science_other": {
182
+ "name": "计算机科学(其他)",
183
+ "query": "cat:cs.* NOT cat:cs.AI NOT cat:cs.LG NOT cat:cs.NE NOT cat:cs.CV NOT cat:cs.CL",
184
+ "description": "其他计算机科学领域",
185
+ "keywords": ["cat:cs."],
186
+ },
187
+ # 统计学领域
188
+ "statistics": {
189
+ "name": "统计学",
190
+ "query": "cat:stat.*",
191
+ "description": "统计学理论与应用",
192
+ "keywords": ["cat:stat."],
193
+ },
194
+ "statistical_learning": {
195
+ "name": "统计学习",
196
+ "query": "cat:stat.ML",
197
+ "description": "统计学习方法与应用",
198
+ "keywords": ["stat.ML"],
199
+ },
200
+ # 跨学科领域
201
+ "quantitative_biology": {
202
+ "name": "定量生物学",
203
+ "query": "cat:q-bio.*",
204
+ "description": "生物信息学、系统生物学、定量生物方法",
205
+ "keywords": ["q-bio"],
206
+ },
207
+ "quantitative_finance": {
208
+ "name": "定量金融",
209
+ "query": "cat:q-fin.*",
210
+ "description": "金融数学、金融工程、计量金融",
211
+ "keywords": ["q-fin"],
212
+ },
213
+ "electrical_engineering": {
214
+ "name": "电子工程与系统科学",
215
+ "query": "cat:eess.*",
216
+ "description": "信号处理、控制系统、电子工程",
217
+ "keywords": ["eess"],
218
+ },
219
+ "economics": {
220
+ "name": "经济学",
221
+ "query": "cat:econ.*",
222
+ "description": "经济学理论、计量经济学",
223
+ "keywords": ["econ"],
224
+ },
225
+ }
226
+
227
+
228
+ def setup_environment(directory: Path):
229
+ """设置环境并验证给定目录的配置"""
230
+ original_cwd = os.getcwd()
231
+ try:
232
+ os.chdir(directory)
233
+
234
+ # 创建必要的目录
235
+ os.makedirs("data", exist_ok=True)
236
+ os.makedirs("reports", exist_ok=True)
237
+ os.makedirs("logs", exist_ok=True)
238
+
239
+ # 加载 .env 文件(如果存在)
240
+ env_file = directory / ".env"
241
+ if env_file.exists():
242
+ load_dotenv(env_file)
243
+ else:
244
+ output.warn(f"在 {directory} 中未找到 .env 文件。使用默认配置。")
245
+
246
+ # 将 DATABASE_URL 转换为绝对路径(如果是相对 SQLite 路径)
247
+ db_url = os.getenv("DATABASE_URL", "sqlite:///data/arxiv_papers.db")
248
+ if db_url.startswith("sqlite:///") and not db_url.startswith("sqlite:////"):
249
+ # 相对路径,转换为绝对路径
250
+ db_path = db_url.replace("sqlite:///", "")
251
+ abs_db_path = os.path.abspath(db_path)
252
+ os.environ["DATABASE_URL"] = f"sqlite:///{abs_db_path}"
253
+ output.debug(f"Converted DATABASE_URL to absolute path: {os.environ['DATABASE_URL']}")
254
+
255
+ # 直接更新 Config 类变量
256
+ Config.DATABASE_URL = os.environ["DATABASE_URL"]
257
+ # 基于新的 DATABASE_URL 更新 DATA_DIR
258
+ Config.DATA_DIR = os.path.dirname(Config.DATABASE_URL.replace("sqlite:///", ""))
259
+ # 如果相对,将 REPORT_DIR 转换为绝对路径
260
+ report_dir = os.getenv("REPORT_DIR", "reports")
261
+ if not os.path.isabs(report_dir):
262
+ Config.REPORT_DIR = os.path.abspath(report_dir)
263
+ output.debug(f"Converted REPORT_DIR to absolute path: {Config.REPORT_DIR}")
264
+
265
+ # 从环境变量更新其他 Config 变量
266
+ Config.AI_API_KEY = os.getenv("AI_API_KEY")
267
+ Config.AI_MODEL = os.getenv("AI_MODEL", "DeepSeek-V3.2-Thinking")
268
+ Config.AI_BASE_URL = os.getenv("AI_BASE_URL", "https://llmapi.paratera.com")
269
+ Config.SUMMARY_MAX_TOKENS = int(os.getenv("SUMMARY_MAX_TOKENS", "2000"))
270
+ Config.SUMMARY_SENTENCES_LIMIT = int(os.getenv("SUMMARY_SENTENCES_LIMIT", "3"))
271
+ Config.TOKEN_PRICE_PER_MILLION = float(os.getenv("TOKEN_PRICE_PER_MILLION", "3.0"))
272
+ Config.MAX_RESULTS_INITIAL = int(os.getenv("MAX_RESULTS_INITIAL", "100"))
273
+ Config.MAX_RESULTS_DAILY = int(os.getenv("MAX_RESULTS_DAILY", "20"))
274
+ Config.YEARS_BACK = int(os.getenv("YEARS_BACK", "3"))
275
+ Config.IMPORTANT_PAPERS_FILE = os.getenv("IMPORTANT_PAPERS_FILE", "important_papers.txt")
276
+ Config.REPORT_MAX_PAPERS = int(os.getenv("REPORT_MAX_PAPERS", "50"))
277
+
278
+ # 更新 SEARCH_QUERIES
279
+ search_queries_raw = os.getenv(
280
+ "SEARCH_QUERIES",
281
+ "condensed matter physics; density functional theory; machine learning; force fields; first principles calculation; molecular dynamics; quantum chemistry; computational materials science",
282
+ )
283
+ Config.SEARCH_QUERIES_RAW = search_queries_raw
284
+ Config.SEARCH_QUERIES = [q.strip() for q in search_queries_raw.split(";") if q.strip()]
285
+
286
+ # 验证配置
287
+ try:
288
+ Config.validate()
289
+ output.info("配置验证通过")
290
+ except Exception as e:
291
+ output.error(f"配置错误: {e}")
292
+ return False
293
+
294
+ return True
295
+ finally:
296
+ os.chdir(original_cwd)
297
+
298
+
299
+ def print_banner():
300
+ """打印应用横幅"""
301
+ print_banner_custom(["凝聚态物理", "密度泛函理论", "机器学习", "力场"])
302
+
303
+
304
+ def generate_banner_title(env_file):
305
+ """根据配置文件生成横幅标题"""
306
+ try:
307
+ # 读取 .env 文件,解析 SEARCH_QUERIES
308
+ import re
309
+ from pathlib import Path
310
+
311
+ env_path = Path(env_file) if isinstance(env_file, str) else env_file
312
+ if not env_path.exists():
313
+ return ["凝聚态物理", "密度泛函理论", "机器学习", "力场"]
314
+
315
+ with open(env_path, "r", encoding="utf-8") as f:
316
+ content = f.read()
317
+
318
+ # 提取 SEARCH_QUERIES
319
+ queries_match = re.search(r"SEARCH_QUERIES=(.*?)(?:\n#|\n$)", content, re.DOTALL | re.MULTILINE)
320
+ if not queries_match:
321
+ return ["凝聚态物理", "密度泛函理论", "机器学习", "力场"]
322
+
323
+ queries = queries_match.group(1).strip()
324
+
325
+ # 根据查询确定领域
326
+ fields = []
327
+
328
+ # 使用 RESEARCH_FIELDS 进行智能匹配
329
+ # 首先收集所有可能的匹配
330
+ matched_fields = []
331
+
332
+ for field_id, field_info in RESEARCH_FIELDS.items():
333
+ field_name = field_info["name"]
334
+ keywords = field_info.get("keywords", [])
335
+
336
+ # 检查每个关键词是否出现在查询中
337
+ for keyword in keywords:
338
+ # 对关键词进行转义,以便在正则表达式中使用
339
+ # 简单的字符串匹配(不区分大小写)
340
+ pattern = re.escape(keyword)
341
+ if re.search(pattern, queries, re.IGNORECASE):
342
+ # 记录匹配的字段和匹配的关键词数量(用于排序)
343
+ matched_fields.append(
344
+ {
345
+ "id": field_id,
346
+ "name": field_name,
347
+ "match_count": 1, # 简单计数,可以更复杂
348
+ }
349
+ )
350
+ break # 找到一个匹配就足够
351
+
352
+ # 根据匹配情况选择要显示的字段
353
+ if matched_fields:
354
+ # 去重(按字段名)
355
+ seen_names = set()
356
+ for match in matched_fields:
357
+ if match["name"] not in seen_names:
358
+ fields.append(match["name"])
359
+ seen_names.add(match["name"])
360
+
361
+ # 如果没有找到任何匹配,使用默认
362
+ if not fields:
363
+ # 尝试基于查询内容进行更宽松的匹配
364
+ queries_lower = queries.lower()
365
+
366
+ # 常见的arXiv分类检测
367
+ if "cond-mat" in queries_lower or "condensed matter" in queries_lower:
368
+ fields.append("凝聚态物理")
369
+ if "astro-ph" in queries_lower:
370
+ fields.append("天体物理")
371
+ if "hep-" in queries_lower:
372
+ fields.append("高能物理")
373
+ if "quant-ph" in queries_lower:
374
+ fields.append("量子物理")
375
+ if "physics.comp-ph" in queries_lower:
376
+ fields.append("计算物理")
377
+ if "math." in queries_lower:
378
+ fields.append("数学")
379
+ if "cs." in queries_lower:
380
+ fields.append("计算机科学")
381
+ if "stat." in queries_lower:
382
+ fields.append("统计学")
383
+
384
+ # 如果还是没有匹配,使用默认
385
+ if not fields:
386
+ return ["凝聚态物理", "密度泛函理论", "机器学习", "力场"]
387
+
388
+ # 限制最多显示4个领域
389
+ return fields[:4]
390
+
391
+ except Exception as e:
392
+ # 出错时返回默认
393
+ return ["凝聚态物理", "密度泛函理论", "机器学习", "力场"]
394
+
395
+
396
+ def print_banner_custom(fields):
397
+ """打印自定义字段的应用横幅"""
398
+ # 创建字段字符串
399
+ if len(fields) == 0:
400
+ field_str = "凝聚态物理 • 密度泛函理论 • 机器学习 • 力场"
401
+ elif len(fields) == 1:
402
+ field_str = fields[0]
403
+ elif len(fields) == 2:
404
+ field_str = f"{fields[0]} • {fields[1]}"
405
+ elif len(fields) == 3:
406
+ field_str = f"{fields[0]} • {fields[1]} • {fields[2]}"
407
+ else:
408
+ field_str = f"{fields[0]} • {fields[1]} • {fields[2]} • {fields[3]}"
409
+
410
+ # 计算居中位置 (横幅宽度为55字符,边框各占1字符,内容宽度53字符)
411
+ # 第一行标题:"arXiv Pulse - 文献追踪系统" (25字符)
412
+ # 需要将字段字符串居中显示
413
+ banner_width = 55
414
+ content_width = 53
415
+
416
+ # 创建横幅
417
+ border_top = "╔" + "═" * (banner_width - 2) + "╗"
418
+ border_bottom = "╚" + "═" * (banner_width - 2) + "╝"
419
+
420
+ # 第一行标题
421
+ title = "arXiv Pulse - 文献追踪系统"
422
+ # 标题居中
423
+ title_padding = (content_width - len(title) * 2) // 2 # 中文占2个英文字符宽度
424
+ if title_padding < 0:
425
+ title_padding = 0
426
+ title_line = "║" + " " * title_padding + title + " " * (content_width - len(title) * 2 - title_padding) + "║"
427
+
428
+ # 第二行字段
429
+ # 简单处理:如果字段字符串太长,截断
430
+ max_field_len = content_width - 4 # 留出一些边距
431
+ if len(field_str) * 2 > max_field_len: # 中文占2个英文字符宽度
432
+ # 截断字段字符串
433
+ field_str = field_str[: max_field_len // 2] + "..."
434
+
435
+ field_padding = (content_width - len(field_str) * 2) // 2
436
+ if field_padding < 0:
437
+ field_padding = 0
438
+ field_line = (
439
+ "║" + " " * field_padding + field_str + " " * (content_width - len(field_str) * 2 - field_padding) + "║"
440
+ )
441
+
442
+ banner = f"\n{border_top}\n{title_line}\n{field_line}\n{border_bottom}\n"
443
+ click.echo(banner)
444
+
445
+
446
+ def sync_papers(years_back=1, summarize=False):
447
+ """同步论文(内部函数)"""
448
+ crawler = ArXivCrawler()
449
+ summarizer = PaperSummarizer()
450
+
451
+ click.echo(f"正在同步缺失论文(回溯 {years_back} 年)...")
452
+ click.echo("=" * 50)
453
+
454
+ # 同步所有查询
455
+ click.echo("1. 正在同步搜索查询...")
456
+ sync_result = crawler.sync_all_queries(years_back=years_back)
457
+ click.echo(f" 从查询添加了 {sync_result['total_new_papers']} 篇新论文")
458
+
459
+ # 同步重要论文
460
+ click.echo("2. 正在同步重要论文...")
461
+ important_result = crawler.sync_important_papers()
462
+ click.echo(f" 添加了 {important_result['added']} 篇重要论文")
463
+ if important_result["errors"]:
464
+ click.echo(f" 错误: {len(important_result['errors'])}")
465
+
466
+ # 总结新论文(如果启用)
467
+ total_new = sync_result["total_new_papers"] + important_result["added"]
468
+ if summarize and total_new > 0:
469
+ click.echo("3. 正在总结新论文...")
470
+ summarize_result = summarizer.summarize_pending_papers(limit=min(50, total_new))
471
+ click.echo(f" 已总结 {summarize_result['successful']} 篇论文")
472
+ elif total_new > 0:
473
+ click.echo("3. 跳过论文总结")
474
+ else:
475
+ click.echo("3. 没有新论文需要总结")
476
+
477
+ # 更新数据库统计
478
+ crawl_stats = crawler.get_crawler_stats()
479
+ summary_stats = summarizer.get_summary_stats()
480
+
481
+ click.echo("\n" + "=" * 50)
482
+ click.echo("同步完成!")
483
+ click.echo(f"总共添加了新论文: {total_new}")
484
+ click.echo(f"数据库现有 {crawl_stats['total_papers']} 篇论文")
485
+ click.echo(f"已总结: {summary_stats['summarized_papers']} ({summary_stats['summarization_rate']:.1%})")
486
+
487
+ return {
488
+ "crawler": crawler,
489
+ "summarizer": summarizer,
490
+ "sync_result": sync_result,
491
+ "important_result": important_result,
492
+ "stats": {"crawl_stats": crawl_stats, "summary_stats": summary_stats},
493
+ }
494
+
495
+
496
+ def get_workday_cutoff(days_back):
497
+ """计算排除周末的截止日期"""
498
+ current = datetime.utcnow()
499
+ workdays_counted = 0
500
+ days_to_go_back = 0
501
+
502
+ while workdays_counted < days_back:
503
+ days_to_go_back += 1
504
+ # 检查是否为工作日(周一至周五)
505
+ if (current - timedelta(days=days_to_go_back)).weekday() < 5:
506
+ workdays_counted += 1
507
+
508
+ return current - timedelta(days=days_to_go_back)
509
+
510
+
511
+ def generate_report(paper_limit=50, days_back=2, summarize=True, max_summarize=10):
512
+ """生成最近论文的报告(内部函数)"""
513
+ reporter = ReportGenerator()
514
+
515
+ # 设置报告限制
516
+ original_limit = Config.REPORT_MAX_PAPERS
517
+ Config.REPORT_MAX_PAPERS = paper_limit
518
+
519
+ try:
520
+ # 生成报告数据
521
+ with reporter.db.get_session() as session:
522
+ from arxiv_pulse.models import Paper
523
+
524
+ # 获取最近N个工作日的论文(排除周末)
525
+ cutoff = get_workday_cutoff(days_back)
526
+ recent_papers = (
527
+ session.query(Paper)
528
+ .filter(Paper.published >= cutoff)
529
+ .order_by(Paper.published.desc())
530
+ .limit(paper_limit)
531
+ .all()
532
+ )
533
+
534
+ # 总结未总结的论文(限制数量避免过多API调用)
535
+ summarized_count = 0
536
+ summarizer = PaperSummarizer()
537
+
538
+ if summarize:
539
+ for paper in recent_papers:
540
+ if paper.summarized is False and (max_summarize == 0 or summarized_count < max_summarize):
541
+ if summarizer.summarize_paper(paper):
542
+ summarized_count += 1
543
+ # 刷新论文对象以获取更新后的总结数据
544
+ session.refresh(paper)
545
+
546
+ if summarized_count > 0:
547
+ output.info(f"已总结 {summarized_count} 篇论文用于报告")
548
+ # 显示累计token使用情况
549
+ summary_stats = summarizer.get_summary_stats()
550
+ token_usage = summary_stats.get("token_usage", {})
551
+ if token_usage:
552
+ output.info(
553
+ f"累计Token使用: 提示 {token_usage.get('total_prompt_tokens', 0)}, "
554
+ f"完成 {token_usage.get('total_completion_tokens', 0)}, "
555
+ f"总计 {token_usage.get('total_tokens', 0)}"
556
+ )
557
+
558
+ # 计算热门分类
559
+ category_counts = {}
560
+ for paper in recent_papers:
561
+ if paper.categories is not None:
562
+ # arXiv分类以空格分隔,例如 "cond-mat.mtrl-sci physics.comp-ph"
563
+ for cat in paper.categories.split():
564
+ category_counts[cat] = category_counts.get(cat, 0) + 1
565
+
566
+ # 取前5个热门分类
567
+ top_categories = dict(sorted(category_counts.items(), key=lambda x: x[1], reverse=True)[:5])
568
+
569
+ # 获取数据库总体统计
570
+ crawler = ArXivCrawler()
571
+ crawl_stats = crawler.get_crawler_stats()
572
+ summary_stats = summarizer.get_summary_stats()
573
+
574
+ # 创建报告数据
575
+ report_data = {
576
+ "stats": {
577
+ "total_recent": len(recent_papers),
578
+ "days_back": days_back,
579
+ "report_type": "recent",
580
+ "date_generated": datetime.now().isoformat(),
581
+ "database_stats": {
582
+ "total_papers": crawl_stats["total_papers"],
583
+ "summarized_papers": summary_stats["summarized_papers"],
584
+ },
585
+ "top_categories": top_categories,
586
+ },
587
+ "papers": recent_papers,
588
+ }
589
+
590
+ # 保存报告
591
+ files = []
592
+
593
+ # 保存Markdown报告
594
+ md_file = reporter.save_markdown_report(report_data)
595
+ if md_file:
596
+ files.append(md_file)
597
+
598
+ # 保存CSV报告
599
+ csv_file = reporter.save_csv_report(report_data)
600
+ if csv_file:
601
+ files.append(csv_file)
602
+
603
+ return files
604
+ finally:
605
+ Config.REPORT_MAX_PAPERS = original_limit
606
+
607
+
608
+ def generate_search_report(query, search_terms, papers, paper_limit=50, summarize=True, max_summarize=10):
609
+ """生成搜索结果的报告(内部函数)"""
610
+ reporter = ReportGenerator()
611
+
612
+ # 设置报告限制
613
+ original_limit = Config.REPORT_MAX_PAPERS
614
+ Config.REPORT_MAX_PAPERS = paper_limit
615
+
616
+ try:
617
+ # 总结未总结的论文(限制数量避免过多API调用)
618
+ summarized_count = 0
619
+ summarizer = PaperSummarizer()
620
+
621
+ if summarize:
622
+ # 收集需要总结的论文ID
623
+ papers_to_summarize = []
624
+ for paper in papers:
625
+ if paper.summarized is False and (max_summarize == 0 or summarized_count < max_summarize):
626
+ papers_to_summarize.append(paper)
627
+ summarized_count += 1
628
+
629
+ # 总结论文
630
+ for paper in papers_to_summarize:
631
+ summarizer.summarize_paper(paper)
632
+
633
+ if summarized_count > 0:
634
+ output.info(f"已总结 {summarized_count} 篇论文用于报告")
635
+ # 重新获取论文数据以确保包含最新总结
636
+ with summarizer.db.get_session() as session:
637
+ from arxiv_pulse.models import Paper
638
+
639
+ paper_ids = [p.arxiv_id for p in papers]
640
+ # 按原始顺序重新查询论文
641
+ updated_papers = []
642
+ for paper_id in paper_ids:
643
+ paper = session.query(Paper).filter_by(arxiv_id=paper_id).first()
644
+ if paper:
645
+ updated_papers.append(paper)
646
+ papers = updated_papers
647
+
648
+ # 计算热门分类
649
+ category_counts = {}
650
+ for paper in papers:
651
+ if paper.categories is not None:
652
+ # arXiv分类以空格分隔,例如 "cond-mat.mtrl-sci physics.comp-ph"
653
+ for cat in paper.categories.split():
654
+ category_counts[cat] = category_counts.get(cat, 0) + 1
655
+
656
+ # 取前5个热门分类
657
+ top_categories = dict(sorted(category_counts.items(), key=lambda x: x[1], reverse=True)[:5])
658
+
659
+ # 获取数据库总体统计
660
+ crawler = ArXivCrawler()
661
+ summarizer = PaperSummarizer()
662
+ crawl_stats = crawler.get_crawler_stats()
663
+ summary_stats = summarizer.get_summary_stats()
664
+
665
+ # 创建报告数据
666
+ report_data = {
667
+ "stats": {
668
+ "total_found": len(papers),
669
+ "original_query": query,
670
+ "search_terms": search_terms,
671
+ "report_type": "search",
672
+ "date_generated": datetime.now().isoformat(),
673
+ "database_stats": {
674
+ "total_papers": crawl_stats["total_papers"],
675
+ "summarized_papers": summary_stats["summarized_papers"],
676
+ },
677
+ "top_categories": top_categories,
678
+ },
679
+ "papers": papers,
680
+ }
681
+
682
+ # 保存报告
683
+ files = []
684
+
685
+ # 保存Markdown报告
686
+ md_file = reporter.save_markdown_report(report_data)
687
+ if md_file:
688
+ files.append(md_file)
689
+
690
+ # 保存CSV报告
691
+ csv_file = reporter.save_csv_report(report_data)
692
+ if csv_file:
693
+ files.append(csv_file)
694
+
695
+ return files
696
+ finally:
697
+ Config.REPORT_MAX_PAPERS = original_limit
698
+
699
+
700
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
701
+ @click.version_option(version=__version__, prog_name="arXiv Pulse")
702
+ def cli():
703
+ """arXiv Pulse: 智能arXiv文献追踪和分析系统"""
704
+ pass
705
+
706
+
707
+ def interactive_configuration():
708
+ """交互式配置 arXiv Pulse"""
709
+ config = {}
710
+ import openai
711
+
712
+ click.echo("\n" + "=" * 60)
713
+ click.echo("arXiv Pulse 交互式配置向导")
714
+ click.echo("=" * 60)
715
+
716
+ # 1. AI API 配置
717
+ click.echo("\n🔧 AI API 配置")
718
+ click.echo("-" * 40)
719
+
720
+ # 1.1 先询问 Base URL
721
+ ai_base_url = click.prompt("AI API Base URL", default="https://llmapi.paratera.com", show_default=True)
722
+ config["AI_BASE_URL"] = ai_base_url
723
+
724
+ # 1.2 询问 API 密钥
725
+ ai_api_key = click.prompt(
726
+ "请输入 AI API 密钥 (留空则跳过,稍后可在 .env 文件中添加)", default="", show_default=False, hide_input=True
727
+ )
728
+ if ai_api_key:
729
+ config["AI_API_KEY"] = ai_api_key
730
+ # 使用提供的密钥查询可用模型
731
+ available_models = []
732
+ try:
733
+ click.echo("正在查询可用模型...")
734
+ client = openai.OpenAI(base_url=ai_base_url, api_key=ai_api_key)
735
+ models_response = client.models.list()
736
+ available_models = [model.id for model in models_response.data]
737
+ click.echo(f"✅ 找到 {len(available_models)} 个可用模型")
738
+ except Exception as e:
739
+ click.echo(f"⚠️ 无法查询模型列表: {e}")
740
+ click.echo(" 将使用默认模型选项")
741
+ available_models = ["DeepSeek-V3.2-Thinking", "gpt-3.5-turbo", "gpt-4-turbo"]
742
+ else:
743
+ click.echo("⚠️ 未提供 API 密钥,AI 总结和翻译功能将受限")
744
+ click.echo(" 您可以稍后在 .env 文件中添加 AI_API_KEY 设置")
745
+ config["AI_API_KEY"] = "your_api_key_here"
746
+ available_models = ["DeepSeek-V3.2-Thinking", "gpt-3.5-turbo", "gpt-4-turbo"]
747
+
748
+ # 1.3 让用户选择模型
749
+ if available_models:
750
+ click.echo("\n可用模型列表:")
751
+
752
+ # 构建questionary选择选项
753
+ choices = []
754
+ for model in available_models:
755
+ choices.append(questionary.Choice(title=model, value=model))
756
+
757
+ # 添加自定义输入选项
758
+ choices.append(questionary.Choice(title="[自定义输入] - 输入其他模型名称", value="__custom_input__"))
759
+
760
+ # 显示交互式选择菜单
761
+ selected_model = questionary.select(
762
+ "请选择AI模型(使用上下箭头导航,回车确认):", choices=choices, instruction="(上下箭头导航,回车确认)"
763
+ ).ask()
764
+
765
+ if selected_model == "__custom_input__":
766
+ # 用户选择自定义输入
767
+ ai_model = click.prompt("请输入自定义模型名称", default="DeepSeek-V3.2-Thinking", show_default=True)
768
+ click.echo(f"✅ 使用自定义模型: {ai_model}")
769
+ else:
770
+ ai_model = selected_model
771
+ click.echo(f"✅ 已选择模型: {ai_model}")
772
+ else:
773
+ ai_model = click.prompt("AI 模型名称", default="DeepSeek-V3.2-Thinking", show_default=True)
774
+
775
+ config["AI_MODEL"] = ai_model
776
+
777
+ # 2. 爬虫配置
778
+ click.echo("\n📊 爬虫配置")
779
+ click.echo("-" * 40)
780
+
781
+ max_results_initial = click.prompt("初始同步每个查询的最大论文数", default=100, type=int, show_default=True)
782
+ config["MAX_RESULTS_INITIAL"] = str(max_results_initial)
783
+
784
+ max_results_daily = click.prompt("每日同步每个查询的最大论文数", default=20, type=int, show_default=True)
785
+ config["MAX_RESULTS_DAILY"] = str(max_results_daily)
786
+
787
+ years_back = click.prompt("初始同步回溯的年数", default=5, type=int, show_default=True)
788
+ config["YEARS_BACK"] = str(years_back)
789
+
790
+ # 3. 研究领域选择
791
+ click.echo("\n🎯 选择您的研究领域")
792
+ click.echo("-" * 40)
793
+ click.echo("请使用上下箭头导航,空格键选择/取消,回车确认(可多选):")
794
+
795
+ research_fields = RESEARCH_FIELDS
796
+
797
+ # 构建questionary选项
798
+ choices = []
799
+ for key, field in research_fields.items():
800
+ # 使用Choice对象,包含标题和描述
801
+ title = f"[{field['name']}] - {field['description']}"
802
+ choices.append(
803
+ questionary.Choice(
804
+ title=title,
805
+ value=key, # 保存字段ID用于后续查询
806
+ checked=False, # 默认不选中
807
+ )
808
+ )
809
+
810
+ # 添加全选选项
811
+ choices.insert(0, questionary.Choice(title="[全选] - 选择所有研究领域", value="__select_all__", checked=False))
812
+
813
+ # 显示交互式复选框
814
+ selected_keys = questionary.checkbox(
815
+ "请选择您感兴趣的研究领域:",
816
+ choices=choices,
817
+ instruction="(空格键切换选择,回车确认)",
818
+ validate=lambda selected: len(selected) > 0 or "请至少选择一个研究领域",
819
+ ).ask()
820
+
821
+ if not selected_keys:
822
+ click.echo("❌ 未选择任何研究领域,将使用默认配置")
823
+ selected_keys = ["condensed_matter", "dft", "machine_learning"]
824
+
825
+ selected_queries = []
826
+ selected_field_names = []
827
+
828
+ # 处理选择
829
+ if "__select_all__" in selected_keys:
830
+ # 选择全部(排除全选标记)
831
+ for field in research_fields.values():
832
+ selected_queries.append(field["query"])
833
+ selected_field_names.append(field["name"])
834
+ click.echo("✅ 已选择全部研究领域")
835
+ else:
836
+ # 处理用户选择
837
+ for key in selected_keys:
838
+ if key in research_fields:
839
+ field = research_fields[key]
840
+ selected_queries.append(field["query"])
841
+ selected_field_names.append(field["name"])
842
+ click.echo(f"✅ 已选择: {field['name']}")
843
+ else:
844
+ click.echo(f"⚠️ 未知的领域ID: {key}")
845
+
846
+ # 确保至少有一个选择
847
+ if not selected_queries:
848
+ click.echo("⚠️ 未选择任何领域,使用默认配置")
849
+ selected_queries = [
850
+ research_fields["condensed_matter"]["query"],
851
+ research_fields["dft"]["query"],
852
+ research_fields["machine_learning"]["query"],
853
+ ]
854
+ selected_field_names = [
855
+ research_fields["condensed_matter"]["name"],
856
+ research_fields["dft"]["name"],
857
+ research_fields["machine_learning"]["name"],
858
+ ]
859
+
860
+ config["SEARCH_QUERIES"] = "; ".join(selected_queries)
861
+ config["_SELECTED_FIELD_NAMES"] = selected_field_names
862
+
863
+ # 3.5 智能建议(基于选择的领域数量)
864
+ num_selected_fields = len(selected_field_names)
865
+ click.echo(f"\n📊 智能建议(基于您选择的 {num_selected_fields} 个研究领域)")
866
+ click.echo("-" * 40)
867
+
868
+ # 根据领域数量提供建议
869
+ recommended_initial = 100
870
+ recommended_daily = 20
871
+
872
+ if num_selected_fields <= 3:
873
+ click.echo("✅ 您选择了少量领域,保持默认配置即可。")
874
+ elif num_selected_fields <= 6:
875
+ recommended_initial = 70
876
+ recommended_daily = 15
877
+ click.echo(f"⚠️ 您选择了中等数量领域,建议调整爬虫配置以避免过多论文:")
878
+ click.echo(f" - 初始同步每个查询最大论文数: {recommended_initial} (原默认: 100)")
879
+ click.echo(f" - 每日同步每个查询最大论文数: {recommended_daily} (原默认: 20)")
880
+ else:
881
+ recommended_initial = 50
882
+ recommended_daily = 10
883
+ click.echo(f"⚠️ 您选择了大量领域 ({num_selected_fields}个),强烈建议调整爬虫配置:")
884
+ click.echo(f" - 初始同步每个查询最大论文数: {recommended_initial} (原默认: 100)")
885
+ click.echo(f" - 每日同步每个查询最大论文数: {recommended_daily} (原默认: 20)")
886
+ click.echo(f" - 注意:同步大量领域可能需要较长时间和更多存储空间。")
887
+
888
+ # 询问用户是否应用建议
889
+ if num_selected_fields > 3:
890
+ if click.confirm("\n💡 是否应用上述建议调整爬虫配置?", default=True):
891
+ config["MAX_RESULTS_INITIAL"] = str(recommended_initial)
892
+ config["MAX_RESULTS_DAILY"] = str(recommended_daily)
893
+ click.echo(
894
+ f"✅ 已应用建议配置:MAX_RESULTS_INITIAL={recommended_initial}, MAX_RESULTS_DAILY={recommended_daily}"
895
+ )
896
+ else:
897
+ click.echo("ℹ️ 保持您原有的爬虫配置。")
898
+
899
+ # 4. 报告配置
900
+ click.echo("\n📄 报告配置")
901
+ click.echo("-" * 40)
902
+
903
+ report_max_papers = click.prompt("每份报告显示的最大论文数", default=50, type=int, show_default=True)
904
+ config["REPORT_MAX_PAPERS"] = str(report_max_papers)
905
+
906
+ summary_sentences_limit = click.prompt("摘要句子数限制", default=3, type=int, show_default=True)
907
+ config["SUMMARY_SENTENCES_LIMIT"] = str(summary_sentences_limit)
908
+
909
+ click.echo("\n✅ 配置完成!")
910
+ return config, int(years_back)
911
+
912
+
913
+ @cli.command()
914
+ @click.argument("directory", type=click.Path(exists=True, file_okay=False), default=".")
915
+ @click.option("--years-back", type=int, default=None, help="初始同步回溯的年数(默认:交互式配置)")
916
+ def init(directory, years_back):
917
+ """初始化目录并同步历史论文"""
918
+ directory = Path(directory).resolve()
919
+
920
+ # 创建目录结构
921
+ (directory / "data").mkdir(exist_ok=True)
922
+ (directory / "reports").mkdir(exist_ok=True)
923
+ (directory / "logs").mkdir(exist_ok=True)
924
+
925
+ # 创建 .env 文件(如果不存在)
926
+ env_file = directory / ".env"
927
+ custom_banner_fields = None # 用于存储自定义横幅字段
928
+
929
+ if not env_file.exists():
930
+ # 交互式配置
931
+ config, interactive_years_back = interactive_configuration()
932
+
933
+ # 保存自定义横幅字段
934
+ custom_banner_fields = config.get("_SELECTED_FIELD_NAMES", [])
935
+
936
+ # 如果命令行参数没有指定 years_back,使用交互式配置的值
937
+ if years_back is None:
938
+ years_back = interactive_years_back
939
+
940
+ # 生成 .env 文件内容
941
+ env_content = f"""# arXiv Pulse 配置文件
942
+ # 由交互式配置向导于 {datetime.now().strftime("%Y-%m-%d %H:%M:%S")} 生成
943
+
944
+ # ========================
945
+ # AI API 配置 (支持 OpenAI 格式)
946
+ # ========================
947
+ AI_API_KEY={config.get("AI_API_KEY", "your_api_key_here")}
948
+ AI_MODEL={config.get("AI_MODEL", "DeepSeek-V3.2-Thinking")}
949
+ AI_BASE_URL={config.get("AI_BASE_URL", "https://llmapi.paratera.com")}
950
+
951
+ # ========================
952
+ # 数据库配置
953
+ # ========================
954
+ DATABASE_URL=sqlite:///data/arxiv_papers.db
955
+
956
+ # ========================
957
+ # 爬虫配置
958
+ # ========================
959
+ MAX_RESULTS_INITIAL={config.get("MAX_RESULTS_INITIAL", "100")} # init命令每个查询的论文数
960
+ MAX_RESULTS_DAILY={config.get("MAX_RESULTS_DAILY", "20")} # sync命令每个查询的论文数
961
+
962
+ # ========================
963
+ # 搜索查询配置
964
+ # ========================
965
+ # 分号分隔,允许查询中包含逗号
966
+ # 根据您的选择生成的研究领域查询
967
+ SEARCH_QUERIES={config.get("SEARCH_QUERIES", 'condensed matter physics AND cat:cond-mat.*; (ti:"density functional" OR abs:"density functional") AND (cat:physics.comp-ph OR cat:cond-mat.mtrl-sci OR cat:physics.chem-ph); (ti:"machine learning" OR abs:"machine learning") AND (cat:physics.comp-ph OR cat:cond-mat.mtrl-sci OR cat:physics.chem-ph)')}
968
+
969
+ # ========================
970
+ # 报告配置
971
+ # ========================
972
+ REPORT_DIR=reports
973
+ SUMMARY_MAX_TOKENS=2000 # 总结和翻译的最大token数
974
+ SUMMARY_SENTENCES_LIMIT={config.get("SUMMARY_SENTENCES_LIMIT", "3")}
975
+ TOKEN_PRICE_PER_MILLION=3.0
976
+ REPORT_MAX_PAPERS={config.get("REPORT_MAX_PAPERS", "50")}
977
+
978
+ # ========================
979
+ # 同步配置
980
+ # ========================
981
+ YEARS_BACK={config.get("YEARS_BACK", "3")} # 同步回溯的年数
982
+ IMPORTANT_PAPERS_FILE=important_papers.txt
983
+
984
+ # ========================
985
+ # 可选配置
986
+ # ========================
987
+ # 日志级别: DEBUG, INFO, WARNING, ERROR (默认: INFO)
988
+ LOG_LEVEL=INFO
989
+
990
+ # 爬虫延迟(秒,避免频繁请求 arXiv API)
991
+ CRAWL_DELAY=1.0
992
+ """
993
+
994
+ env_file.write_text(env_content)
995
+ click.echo(f"\n✅ 已在 {directory} 创建 .env 配置文件")
996
+
997
+ else:
998
+ click.echo(f".env 文件已存在于 {directory}")
999
+ if years_back is None:
1000
+ years_back = 5 # 默认值
1001
+
1002
+ # 创建 important_papers.txt(如果不存在)
1003
+ important_file = directory / "important_papers.txt"
1004
+ if not important_file.exists():
1005
+ important_file.write_text("# 在此添加重要论文的arXiv ID,每行一个\n")
1006
+ click.echo(f"✅ 已在 {directory} 创建 important_papers.txt 文件")
1007
+
1008
+ # 设置环境并验证配置
1009
+ if not setup_environment(directory):
1010
+ click.echo("❌ 配置验证失败,请检查 .env 文件")
1011
+ sys.exit(1)
1012
+
1013
+ # 确认同步
1014
+ click.echo("\n" + "=" * 60)
1015
+ click.echo("准备同步数据库")
1016
+ click.echo("=" * 60)
1017
+ click.echo(f"即将开始初始同步,回溯 {years_back} 年历史论文...")
1018
+ click.echo(f"这可能会花费一些时间,具体取决于您选择的领域数量。")
1019
+ click.echo(f"您可以在任何时候按 Ctrl+C 中断同步。")
1020
+
1021
+ if not click.confirm("\n🚀 确认开始同步数据库吗?", default=True):
1022
+ click.echo("❌ 已取消同步")
1023
+ sys.exit(0)
1024
+
1025
+ click.echo(f"\n⏳ 开始初始同步,回溯 {years_back} 年历史论文...")
1026
+ sync_result = sync_papers(years_back=years_back, summarize=False)
1027
+
1028
+ # 生成横幅标题
1029
+ if custom_banner_fields:
1030
+ banner_title = custom_banner_fields[:4] # 限制最多4个字段
1031
+ else:
1032
+ banner_title = generate_banner_title(env_file)
1033
+ print_banner_custom(banner_title)
1034
+
1035
+ click.echo(f"\n🎉 arXiv Pulse 初始化完成!")
1036
+ click.echo(f"\n📁 文件位置:")
1037
+ click.echo(f" 配置文件: {env_file}")
1038
+ click.echo(f" 数据库: {directory}/data/arxiv_papers.db")
1039
+ click.echo(f" 报告目录: {directory}/reports/")
1040
+ click.echo(f"\n🚀 下一步:")
1041
+ click.echo(f" 1. 运行 'pulse sync {directory}' 更新最新论文")
1042
+ click.echo(f" 2. 运行 'pulse search \"关键词\" {directory}' 搜索论文")
1043
+ click.echo(f" 3. 运行 'pulse recent {directory}' 查看最近论文报告")
1044
+ click.echo(f" 4. 编辑 {important_file} 添加重要论文")
1045
+
1046
+
1047
+ @cli.command()
1048
+ @click.argument("directory", type=click.Path(exists=True, file_okay=False), default=".")
1049
+ @click.option("--years-back", type=int, default=1, help="同步回溯的年数(默认:1年)")
1050
+ @click.option("--summarize/--no-summarize", default=False, help="是否总结新论文(默认:否)")
1051
+ def sync(directory, years_back, summarize):
1052
+ """同步最新论文到数据库"""
1053
+ directory = Path(directory).resolve()
1054
+ click.echo(f"正在同步 arXiv Pulse 于 {directory}")
1055
+
1056
+ if not setup_environment(directory):
1057
+ sys.exit(1)
1058
+
1059
+ print_banner()
1060
+
1061
+ # 同步论文
1062
+ sync_result = sync_papers(years_back=years_back, summarize=summarize)
1063
+
1064
+ click.echo("\n" + "=" * 50)
1065
+ click.echo("同步完成!数据库已更新。")
1066
+
1067
+
1068
+ @cli.command()
1069
+ @click.argument("query")
1070
+ @click.argument("directory", type=click.Path(exists=True, file_okay=False), default=".")
1071
+ @click.option("--limit", default=20, help="返回结果的最大数量(默认:20)")
1072
+ @click.option("--years-back", type=int, default=0, help="搜索前同步回溯的年数(默认:0,不更新)")
1073
+ @click.option("--use-ai/--no-ai", default=True, help="是否使用AI理解自然语言查询(默认:是)")
1074
+ @click.option("--summarize/--no-summarize", default=True, help="是否自动总结未总结的论文(默认:是)")
1075
+ @click.option("--max-summarize", type=int, default=0, help="最大总结论文数(默认:0表示无限制)")
1076
+ def search(query, directory, limit, years_back, use_ai, summarize, max_summarize):
1077
+ """智能搜索论文(支持自然语言查询)"""
1078
+ directory = Path(directory).resolve()
1079
+
1080
+ if not setup_environment(directory):
1081
+ sys.exit(1)
1082
+
1083
+ print_banner()
1084
+
1085
+ # 如果需要,先同步最新论文
1086
+ crawler = ArXivCrawler()
1087
+ if years_back > 0:
1088
+ click.echo(f"搜索前先同步最近 {years_back} 年论文...")
1089
+ sync_result = sync_papers(years_back=years_back, summarize=False)
1090
+ crawler = sync_result["crawler"]
1091
+
1092
+ click.echo(f"\n正在搜索: '{query}'")
1093
+ click.echo("=" * 50)
1094
+
1095
+ search_terms = [query]
1096
+
1097
+ # 如果启用AI且配置了AI API密钥,尝试解析自然语言查询
1098
+ if use_ai and Config.AI_API_KEY:
1099
+ try:
1100
+ import openai
1101
+
1102
+ client = openai.OpenAI(api_key=Config.AI_API_KEY, base_url=Config.AI_BASE_URL)
1103
+
1104
+ ai_prompt = f"""
1105
+ 用户正在搜索arXiv物理/计算材料科学论文,查询是: "{query}"
1106
+
1107
+ 请将自然语言查询转换为适合arXiv搜索的关键词或短语。
1108
+ 考虑以下领域:凝聚态物理、密度泛函理论(DFT)、机器学习、力场、分子动力学、量子化学。
1109
+
1110
+ 返回格式:JSON数组,包含最多5个搜索关键词/短语。
1111
+ 示例:["machine learning materials science", "density functional theory", "condensed matter physics"]
1112
+
1113
+ 只返回JSON数组,不要其他文本。
1114
+ """
1115
+
1116
+ response = client.chat.completions.create(
1117
+ model=Config.AI_MODEL,
1118
+ messages=[
1119
+ {"role": "system", "content": "你是arXiv论文搜索助手,擅长将自然语言查询转换为学术搜索关键词。"},
1120
+ {"role": "user", "content": ai_prompt},
1121
+ ],
1122
+ max_tokens=200,
1123
+ temperature=0.3,
1124
+ )
1125
+
1126
+ ai_response = response.choices[0].message.content
1127
+ try:
1128
+ search_terms = json.loads(ai_response)
1129
+ if isinstance(search_terms, list) and len(search_terms) > 0:
1130
+ click.echo(f"AI解析的搜索词: {', '.join(search_terms[:3])}")
1131
+ if len(search_terms) > 3:
1132
+ click.echo(f" 以及 {len(search_terms) - 3} 个其他关键词")
1133
+ except:
1134
+ # 如果AI响应不是有效JSON,使用原始查询
1135
+ pass
1136
+
1137
+ except Exception as e:
1138
+ click.echo(f"AI解析失败,使用原始查询: {e}")
1139
+
1140
+ # 在数据库中搜索
1141
+ with crawler.db.get_session() as session:
1142
+ from arxiv_pulse.models import Paper
1143
+
1144
+ all_results = []
1145
+ for term in search_terms:
1146
+ papers = (
1147
+ session.query(Paper)
1148
+ .filter(
1149
+ Paper.title.contains(term)
1150
+ | Paper.abstract.contains(term)
1151
+ | Paper.categories.contains(term)
1152
+ | Paper.search_query.contains(term)
1153
+ )
1154
+ .order_by(Paper.published.desc())
1155
+ .limit(limit)
1156
+ .all()
1157
+ )
1158
+ all_results.extend(papers)
1159
+
1160
+ # 去重并排序
1161
+ unique_papers = {}
1162
+ for paper in all_results:
1163
+ if paper.arxiv_id not in unique_papers:
1164
+ unique_papers[paper.arxiv_id] = paper
1165
+
1166
+ sorted_papers = sorted(unique_papers.values(), key=lambda p: p.published or datetime.min, reverse=True)
1167
+ papers_to_show = sorted_papers[:limit]
1168
+
1169
+ click.echo(f"找到 {len(papers_to_show)} 篇论文:")
1170
+
1171
+ # 生成搜索报告
1172
+ click.echo("正在生成搜索报告...")
1173
+ files = generate_search_report(
1174
+ query,
1175
+ search_terms,
1176
+ papers_to_show,
1177
+ paper_limit=limit,
1178
+ summarize=summarize,
1179
+ max_summarize=max_summarize,
1180
+ )
1181
+
1182
+ # 输出简要结果和报告文件
1183
+ for i, paper in enumerate(papers_to_show[:5], 1): # 只显示前5篇作为预览
1184
+ authors = json.loads(paper.authors) if paper.authors else []
1185
+ author_names = [a.get("name", "") for a in authors[:2]]
1186
+ if len(authors) > 2:
1187
+ author_names.append("等")
1188
+
1189
+ click.echo(f"\n{i}. {paper.title}")
1190
+ click.echo(f" 作者: {', '.join(author_names)}")
1191
+ click.echo(f" arXiv ID: {paper.arxiv_id}")
1192
+ click.echo(f" 发布日期: {paper.published.strftime('%Y-%m-%d') if paper.published else 'N/A'}")
1193
+
1194
+ if len(papers_to_show) > 5:
1195
+ click.echo(f"\n... 以及 {len(papers_to_show) - 5} 篇更多论文")
1196
+
1197
+ click.echo(f"\n报告生成完成:")
1198
+ for f in files:
1199
+ click.echo(f" - {f}")
1200
+ click.echo(f"\n详细论文信息、中文翻译和PDF链接请查看生成的Markdown报告。")
1201
+
1202
+
1203
+ @cli.command()
1204
+ @click.argument("directory", type=click.Path(exists=True, file_okay=False), default=".")
1205
+ @click.option("--limit", default=50, help="报告中包含的最大论文数(默认:50)")
1206
+ @click.option("--days-back", type=int, default=2, help="包含最近多少天的论文(默认:2天)")
1207
+ @click.option("--years-back", type=int, default=1, help="报告前同步回溯的年数(默认:1年)")
1208
+ @click.option("--summarize/--no-summarize", default=True, help="是否自动总结未总结的论文(默认:是)")
1209
+ @click.option("--max-summarize", type=int, default=0, help="最大总结论文数(默认:0表示无限制)")
1210
+ def recent(directory, limit, days_back, years_back, summarize, max_summarize):
1211
+ """生成最近论文的报告(先同步最新论文)"""
1212
+ directory = Path(directory).resolve()
1213
+
1214
+ if not setup_environment(directory):
1215
+ sys.exit(1)
1216
+
1217
+ print_banner()
1218
+
1219
+ # 先同步论文
1220
+ if years_back > 0:
1221
+ click.echo(f"报告前先同步最近 {years_back} 年论文...")
1222
+ sync_papers(years_back=years_back, summarize=False)
1223
+
1224
+ # 生成报告
1225
+ click.echo("\n" + "=" * 50)
1226
+ click.echo(f"正在生成最近 {days_back} 天论文报告...")
1227
+
1228
+ files = generate_report(paper_limit=limit, days_back=days_back, summarize=summarize, max_summarize=max_summarize)
1229
+
1230
+ click.echo(f"报告生成完成:")
1231
+ for f in files:
1232
+ click.echo(f" - {f}")
1233
+
1234
+
1235
+ @cli.command()
1236
+ @click.argument("directory", type=click.Path(exists=True, file_okay=False), default=".")
1237
+ def stat(directory):
1238
+ """显示数据库统计信息"""
1239
+ directory = Path(directory).resolve()
1240
+
1241
+ if not setup_environment(directory):
1242
+ sys.exit(1)
1243
+
1244
+ print_banner()
1245
+
1246
+ crawler = ArXivCrawler()
1247
+ summarizer = PaperSummarizer()
1248
+ report_generator = ReportGenerator()
1249
+
1250
+ click.echo("\n" + "=" * 50)
1251
+ click.echo("arXiv Pulse 数据库统计")
1252
+ click.echo("=" * 50)
1253
+
1254
+ # 获取统计信息
1255
+ crawl_stats = crawler.get_crawler_stats()
1256
+ summary_stats = summarizer.get_summary_stats()
1257
+
1258
+ # 显示基本统计
1259
+ click.echo(f"\n📊 基本统计:")
1260
+ click.echo(f" 总论文数: {crawl_stats['total_papers']}")
1261
+ click.echo(f" 今日论文: {crawl_stats['papers_today']}")
1262
+ click.echo(f" 已总结论文: {summary_stats['summarized_papers']}")
1263
+ click.echo(f" 总结率: {summary_stats['summarization_rate']:.1%}")
1264
+
1265
+ # 按搜索查询统计
1266
+ click.echo(f"\n🔍 按搜索查询分布:")
1267
+ for query, count in crawl_stats["papers_by_query"].items():
1268
+ percentage = count / crawl_stats["total_papers"] * 100 if crawl_stats["total_papers"] > 0 else 0
1269
+ click.echo(f" {query}: {count} 篇 ({percentage:.1f}%)")
1270
+
1271
+ # 分类统计
1272
+ click.echo(f"\n📁 分类统计:")
1273
+ with crawler.db.get_session() as session:
1274
+ from arxiv_pulse.models import Paper
1275
+ import json
1276
+
1277
+ papers = session.query(Paper).all()
1278
+ category_counts = {}
1279
+
1280
+ for paper in papers:
1281
+ if paper.categories:
1282
+ for cat in paper.categories.split():
1283
+ category_counts[cat] = category_counts.get(cat, 0) + 1
1284
+
1285
+ # 按数量排序
1286
+ sorted_categories = sorted(category_counts.items(), key=lambda x: x[1], reverse=True)
1287
+ for category, count in sorted_categories[:10]: # 显示前10个
1288
+ percentage = count / crawl_stats["total_papers"] * 100 if crawl_stats["total_papers"] > 0 else 0
1289
+ click.echo(f" {category}: {count} 篇 ({percentage:.1f}%)")
1290
+
1291
+ if len(sorted_categories) > 10:
1292
+ click.echo(f" ... 以及 {len(sorted_categories) - 10} 个其他分类")
1293
+
1294
+ # 时间分布
1295
+ click.echo(f"\n📅 时间分布:")
1296
+ with crawler.db.get_session() as session:
1297
+ from datetime import datetime, timedelta
1298
+
1299
+ # 按年统计
1300
+ year_stats = {}
1301
+ for paper in papers:
1302
+ if paper.published:
1303
+ year = paper.published.year
1304
+ year_stats[year] = year_stats.get(year, 0) + 1
1305
+
1306
+ sorted_years = sorted(year_stats.items())
1307
+ for year, count in sorted_years[-5:]: # 显示最近5年
1308
+ percentage = count / crawl_stats["total_papers"] * 100 if crawl_stats["total_papers"] > 0 else 0
1309
+ click.echo(f" {year}年: {count} 篇 ({percentage:.1f}%)")
1310
+
1311
+ # 总结统计
1312
+ pending_papers = crawl_stats["total_papers"] - summary_stats["summarized_papers"]
1313
+ click.echo(f"\n🤖 AI总结统计:")
1314
+ click.echo(f" 已总结: {summary_stats['summarized_papers']} 篇")
1315
+ click.echo(f" 待总结: {pending_papers} 篇")
1316
+ click.echo(f" 总结率: {summary_stats['summarization_rate']:.1%}")
1317
+
1318
+ click.echo("\n" + "=" * 50)
1319
+ click.echo("统计完成 ✅")
1320
+
1321
+
1322
+ @cli.command()
1323
+ @click.argument("paper_id")
1324
+ @click.argument("directory", type=click.Path(exists=True, file_okay=False), default=".")
1325
+ @click.option("--limit", default=10, help="返回结果的最大数量(默认:10)")
1326
+ @click.option("--threshold", type=float, default=0.5, help="相似度阈值(0.0-1.0,默认:0.5)")
1327
+ @click.option("--years-back", type=int, default=0, help="搜索前同步回溯的年数(默认:0,不更新)")
1328
+ def similar(paper_id, directory, limit, threshold, years_back):
1329
+ """查找与指定论文相似的论文"""
1330
+ directory = Path(directory).resolve()
1331
+
1332
+ if not setup_environment(directory):
1333
+ sys.exit(1)
1334
+
1335
+ print_banner()
1336
+
1337
+ # 如果需要,先同步最新论文
1338
+ crawler = ArXivCrawler()
1339
+ if years_back > 0:
1340
+ click.echo(f"搜索前先同步最近 {years_back} 年论文...")
1341
+ sync_result = sync_papers(years_back=years_back, summarize=False)
1342
+ crawler = sync_result["crawler"]
1343
+
1344
+ click.echo(f"\n查找与论文 '{paper_id}' 相似的论文")
1345
+ click.echo("=" * 50)
1346
+
1347
+ with crawler.db.get_session() as session:
1348
+ # 创建搜索引擎
1349
+ search_engine = SearchEngine(session)
1350
+
1351
+ # 查找相似论文
1352
+ click.echo(f"正在查找相似度≥{threshold}的论文...")
1353
+ similar_papers_with_scores = search_engine.search_similar_papers(paper_id, limit=limit, threshold=threshold)
1354
+
1355
+ if not similar_papers_with_scores:
1356
+ click.echo("未找到相似论文。")
1357
+ return
1358
+
1359
+ click.echo(f"找到 {len(similar_papers_with_scores)} 篇相似论文:")
1360
+
1361
+ # 提取paper列表用于报告生成
1362
+ similar_papers = [paper for paper, _ in similar_papers_with_scores]
1363
+
1364
+ # 显示结果
1365
+ for i, (paper, similarity) in enumerate(similar_papers_with_scores, 1):
1366
+ authors = json.loads(paper.authors) if paper.authors else []
1367
+ author_names = [a.get("name", "") for a in authors[:2]]
1368
+ if len(authors) > 2:
1369
+ author_names.append("等")
1370
+
1371
+ click.echo(f"\n{i}. {paper.title}")
1372
+ click.echo(f" 相似度: {similarity:.2f}")
1373
+ click.echo(f" 作者: {', '.join(author_names)}")
1374
+ click.echo(f" arXiv ID: {paper.arxiv_id}")
1375
+ click.echo(f" 分类: {paper.categories}")
1376
+ click.echo(f" 发布日期: {paper.published.strftime('%Y-%m-%d') if paper.published else 'N/A'}")
1377
+
1378
+ # 生成报告
1379
+ click.echo("\n正在生成相似论文报告...")
1380
+ report_files = generate_search_report(
1381
+ f"与 {paper_id} 相似的论文", [f"similar to {paper_id}"], similar_papers, paper_limit=limit
1382
+ )
1383
+
1384
+ click.echo(f"报告生成完成:")
1385
+ for f in report_files:
1386
+ click.echo(f" - {f}")
1387
+
1388
+
1389
+ @cli.command()
1390
+ @click.argument("query")
1391
+ @click.argument("directory", type=click.Path(exists=True, file_okay=False), default=".")
1392
+ @click.option("--limit", default=20, help="返回结果的最大数量(默认:20)")
1393
+ @click.option("--years-back", type=int, default=0, help="搜索前同步回溯的年数(默认:0,不更新)")
1394
+ @click.option("--use-ai/--no-ai", default=True, help="是否使用AI理解自然语言查询(默认:是)")
1395
+ @click.option("--categories", "-c", multiple=True, help="包含的分类(可多次使用)")
1396
+ @click.option("--exclude-categories", "-ec", multiple=True, help="排除的分类(可多次使用)")
1397
+ @click.option("--primary-category", "-pc", help="主要分类")
1398
+ @click.option("--authors", "-a", multiple=True, help="作者姓名(可多次使用)")
1399
+ @click.option(
1400
+ "--author-match",
1401
+ type=click.Choice(["contains", "exact", "any"]),
1402
+ default="contains",
1403
+ help="作者匹配方式:contains(包含)、exact(精确)、any(任一)",
1404
+ )
1405
+ @click.option("--date-from", type=click.DateTime(formats=["%Y-%m-%d"]), help="起始日期(格式:YYYY-MM-DD)")
1406
+ @click.option("--date-to", type=click.DateTime(formats=["%Y-%m-%d"]), help="结束日期(格式:YYYY-MM-DD)")
1407
+ @click.option("--days-back", type=int, help="回溯天数(例如:30表示最近30天)")
1408
+ @click.option("--summarized-only/--no-summarized-only", default=False, help="仅显示已总结的论文")
1409
+ @click.option("--downloaded-only/--no-downloaded-only", default=False, help="仅显示已下载的论文")
1410
+ @click.option(
1411
+ "--sort-by",
1412
+ type=click.Choice(["published", "relevance_score", "title", "updated", "created_at"]),
1413
+ default="published",
1414
+ help="排序字段",
1415
+ )
1416
+ @click.option("--sort-order", type=click.Choice(["asc", "desc"]), default="desc", help="排序顺序")
1417
+ @click.option("--match-all/--match-any", default=False, help="匹配所有条件(AND逻辑)或任一条件(OR逻辑)")
1418
+ def search_advanced(
1419
+ query,
1420
+ directory,
1421
+ limit,
1422
+ years_back,
1423
+ use_ai,
1424
+ categories,
1425
+ exclude_categories,
1426
+ primary_category,
1427
+ authors,
1428
+ author_match,
1429
+ date_from,
1430
+ date_to,
1431
+ days_back,
1432
+ summarized_only,
1433
+ downloaded_only,
1434
+ sort_by,
1435
+ sort_order,
1436
+ match_all,
1437
+ ):
1438
+ """高级搜索论文(支持多字段过滤)"""
1439
+ directory = Path(directory).resolve()
1440
+
1441
+ if not setup_environment(directory):
1442
+ sys.exit(1)
1443
+
1444
+ print_banner()
1445
+
1446
+ # 如果需要,先同步最新论文
1447
+ crawler = ArXivCrawler()
1448
+ if years_back > 0:
1449
+ click.echo(f"搜索前先同步最近 {years_back} 年论文...")
1450
+ sync_result = sync_papers(years_back=years_back, summarize=False)
1451
+ crawler = sync_result["crawler"]
1452
+
1453
+ click.echo(f"\n高级搜索: '{query}'")
1454
+ click.echo("=" * 50)
1455
+
1456
+ search_terms = [query]
1457
+
1458
+ # 如果启用AI且配置了AI API密钥,尝试解析自然语言查询
1459
+ if use_ai and Config.AI_API_KEY:
1460
+ try:
1461
+ import openai
1462
+
1463
+ client = openai.OpenAI(api_key=Config.AI_API_KEY, base_url=Config.AI_BASE_URL)
1464
+
1465
+ ai_prompt = f"""
1466
+ 用户正在搜索arXiv物理/计算材料科学论文,查询是: "{query}"
1467
+
1468
+ 请将自然语言查询转换为适合arXiv搜索的关键词或短语。
1469
+ 考虑以下领域:凝聚态物理、密度泛函理论(DFT)、机器学习、力场、分子动力学、量子化学。
1470
+
1471
+ 返回格式:JSON数组,包含最多5个搜索关键词/短语。
1472
+ 示例:["machine learning materials science", "density functional theory", "condensed matter physics"]
1473
+
1474
+ 只返回JSON数组,不要其他文本。
1475
+ """
1476
+
1477
+ response = client.chat.completions.create(
1478
+ model=Config.AI_MODEL,
1479
+ messages=[
1480
+ {"role": "system", "content": "你是arXiv论文搜索助手,擅长将自然语言查询转换为学术搜索关键词。"},
1481
+ {"role": "user", "content": ai_prompt},
1482
+ ],
1483
+ max_tokens=200,
1484
+ temperature=0.3,
1485
+ )
1486
+
1487
+ ai_response = response.choices[0].message.content
1488
+ try:
1489
+ search_terms = json.loads(ai_response)
1490
+ if isinstance(search_terms, list) and len(search_terms) > 0:
1491
+ click.echo(f"AI解析的搜索词: {', '.join(search_terms[:3])}")
1492
+ if len(search_terms) > 3:
1493
+ click.echo(f" 以及 {len(search_terms) - 3} 个其他关键词")
1494
+ except:
1495
+ # 如果AI响应不是有效JSON,使用原始查询
1496
+ pass
1497
+
1498
+ except Exception as e:
1499
+ click.echo(f"AI解析失败,使用原始查询: {e}")
1500
+
1501
+ # 使用增强搜索引擎
1502
+ with crawler.db.get_session() as session:
1503
+ # 创建搜索过滤器
1504
+ filter_config = SearchFilter(
1505
+ query=query,
1506
+ categories=list(categories) if categories else None,
1507
+ exclude_categories=list(exclude_categories) if exclude_categories else None,
1508
+ primary_category=primary_category,
1509
+ authors=list(authors) if authors else None,
1510
+ author_match=author_match,
1511
+ date_from=date_from,
1512
+ date_to=date_to,
1513
+ days_back=days_back,
1514
+ summarized_only=summarized_only,
1515
+ downloaded_only=downloaded_only,
1516
+ limit=limit,
1517
+ sort_by=sort_by,
1518
+ sort_order=sort_order,
1519
+ match_all=match_all,
1520
+ )
1521
+
1522
+ # 创建搜索引擎
1523
+ search_engine = SearchEngine(session)
1524
+
1525
+ # 执行搜索
1526
+ click.echo(f"正在搜索...")
1527
+ papers = search_engine.search_papers(filter_config)
1528
+
1529
+ if not papers:
1530
+ click.echo("未找到匹配的论文。")
1531
+ return
1532
+
1533
+ click.echo(f"找到 {len(papers)} 篇论文:")
1534
+
1535
+ # 显示简要结果
1536
+ for i, paper in enumerate(papers[:5], 1): # 只显示前5篇作为预览
1537
+ authors_list = json.loads(paper.authors) if paper.authors else []
1538
+ author_names = [a.get("name", "") for a in authors_list[:2]]
1539
+ if len(authors_list) > 2:
1540
+ author_names.append("等")
1541
+
1542
+ click.echo(f"\n{i}. {paper.title}")
1543
+ click.echo(f" 作者: {', '.join(author_names)}")
1544
+ click.echo(f" arXiv ID: {paper.arxiv_id}")
1545
+ click.echo(f" 分类: {paper.categories}")
1546
+ click.echo(f" 发布日期: {paper.published.strftime('%Y-%m-%d') if paper.published else 'N/A'}")
1547
+ click.echo(f" 总结状态: {'已总结' if paper.summarized else '未总结'}")
1548
+
1549
+ if len(papers) > 5:
1550
+ click.echo(f"\n... 以及 {len(papers) - 5} 篇更多论文")
1551
+
1552
+ # 生成搜索报告
1553
+ click.echo("\n正在生成搜索报告...")
1554
+ files = generate_search_report(directory, query, search_terms, papers, paper_limit=limit)
1555
+
1556
+ click.echo(f"报告生成完成:")
1557
+ for f in files:
1558
+ click.echo(f" - {f}")
1559
+ click.echo(f"\n详细论文信息、中文翻译和PDF链接请查看生成的Markdown报告。")
1560
+
1561
+
1562
+ @cli.command()
1563
+ @click.argument("directory", type=click.Path(exists=True, file_okay=False), default=".")
1564
+ @click.option("--limit", default=10, help="显示的搜索查询数量(默认:10)")
1565
+ def search_history(directory, limit):
1566
+ """显示搜索历史(按使用频率排序)"""
1567
+ directory = Path(directory).resolve()
1568
+
1569
+ if not setup_environment(directory):
1570
+ sys.exit(1)
1571
+
1572
+ print_banner()
1573
+
1574
+ crawler = ArXivCrawler()
1575
+
1576
+ click.echo("\n" + "=" * 50)
1577
+ click.echo("搜索历史")
1578
+ click.echo("=" * 50)
1579
+
1580
+ with crawler.db.get_session() as session:
1581
+ # 创建搜索引擎
1582
+ search_engine = SearchEngine(session)
1583
+
1584
+ # 获取搜索历史
1585
+ click.echo(f"正在获取搜索历史...")
1586
+ history = search_engine.get_search_history(limit=limit)
1587
+
1588
+ if not history:
1589
+ click.echo("暂无搜索历史。")
1590
+ return
1591
+
1592
+ click.echo(f"\n找到 {len(history)} 个搜索查询:")
1593
+ click.echo("-" * 50)
1594
+
1595
+ for i, item in enumerate(history, 1):
1596
+ last_used = item["last_used"].strftime("%Y-%m-%d") if item["last_used"] else "N/A"
1597
+ click.echo(f"\n{i}. 查询: {item['query']}")
1598
+ click.echo(f" 使用次数: {item['count']}")
1599
+ click.echo(f" 最后使用: {last_used}")
1600
+ if item["last_paper_id"]:
1601
+ click.echo(f" 最后论文ID: {item['last_paper_id']}")
1602
+
1603
+ click.echo(f"\n💡 提示: 使用 'pulse search \"查询内容\" .' 重用搜索")
1604
+ click.echo(f" 或 'pulse search-advanced \"查询内容\" . --categories 分类' 进行高级搜索")
1605
+
1606
+
1607
+ if __name__ == "__main__":
1608
+ cli()