hotspot-research-cli 0.1.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.
@@ -0,0 +1,3 @@
1
+ """Interactive hotspot research CLI."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ main()
hotspot_cli/cli.py ADDED
@@ -0,0 +1,214 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import typer
7
+ from rich.console import Console
8
+ from rich.panel import Panel
9
+ from rich.table import Table
10
+
11
+ from .config import ConfigError, ConfigManager, DEFAULT_TEMPLATE
12
+ from .distribution import ChannelRegistry, DistributionError
13
+ from .hotspots import HotspotCandidate, HotspotError, HotspotService
14
+ from .report import ReportError, ReportGenerator
15
+
16
+
17
+ app = typer.Typer(help="交互式热点研究报告 CLI")
18
+ config_app = typer.Typer(help="配置管理")
19
+ lark_app = typer.Typer(help="飞书配置")
20
+ config_app.add_typer(lark_app, name="lark")
21
+ app.add_typer(config_app, name="config")
22
+ console = Console()
23
+
24
+
25
+ def _print_candidates(title: str, items: list[HotspotCandidate]) -> None:
26
+ table = Table(title=title, show_lines=True)
27
+ table.add_column("序号", justify="right", style="cyan", width=4)
28
+ table.add_column("选题/领域", style="bold")
29
+ table.add_column("评分", justify="right")
30
+ table.add_column("依据")
31
+ for idx, item in enumerate(items, 1):
32
+ table.add_row(str(idx), item.title, f"{item.score:.0f}", item.evidence)
33
+ console.print(table)
34
+
35
+
36
+ def _choose_from_list(items: list[HotspotCandidate], *, refresh_word: str = "refresh") -> int | str:
37
+ while True:
38
+ raw = typer.prompt(f"输入序号确认,或输入 {refresh_word} 换一批").strip()
39
+ if raw.lower() == refresh_word:
40
+ return refresh_word
41
+ if raw.isdigit():
42
+ idx = int(raw)
43
+ if 1 <= idx <= len(items):
44
+ return idx - 1
45
+ console.print("[red]输入无效。请输入列表中的序号,或 refresh。[/red]")
46
+
47
+
48
+ def _resolve_domain(service: HotspotService) -> str:
49
+ raw = typer.prompt("你是否有想要研究的指定领域?有则直接输入领域;没有请直接回车", default="", show_default=False).strip()
50
+ if raw:
51
+ return raw
52
+
53
+ refresh_index = 0
54
+ while True:
55
+ console.print("[bold]正在使用 last30days-safe 拉取主流研究领域...[/bold]")
56
+ domains = service.top_domains(refresh_index=refresh_index)
57
+ if not domains:
58
+ raise HotspotError("未获取到热门领域。请检查网络,或直接输入指定领域。")
59
+ _print_candidates("最近30天主流研究领域 TOP10", domains)
60
+ choice = _choose_from_list(domains)
61
+ if choice == "refresh":
62
+ refresh_index += 1
63
+ continue
64
+ return domains[int(choice)].domain
65
+
66
+
67
+ def _resolve_hotspot(service: HotspotService, domain: str) -> HotspotCandidate:
68
+ refresh_index = 0
69
+ while True:
70
+ console.print(f"[bold]正在拉取「{domain}」近30天客观热点 TOP10...[/bold]")
71
+ hotspots = service.top_hotspots(domain, refresh_index=refresh_index)
72
+ if not hotspots:
73
+ raise HotspotError("未获取到符合规则的客观热点。可输入 refresh 再试,或换一个领域。")
74
+ _print_candidates(f"{domain} 近30天客观热点 TOP10", hotspots)
75
+ choice = _choose_from_list(hotspots)
76
+ if choice == "refresh":
77
+ refresh_index += 1
78
+ continue
79
+ return hotspots[int(choice)]
80
+
81
+
82
+ @app.command("run")
83
+ def run(
84
+ output_dir: Path = typer.Option(Path("reports"), "--output-dir", "-o", help="报告输出目录"),
85
+ push_lark: bool = typer.Option(False, "--push-lark", help="报告生成后推送到飞书"),
86
+ config_path: Optional[Path] = typer.Option(None, "--config", help="配置文件路径,支持 .json/.yaml"),
87
+ language: str = typer.Option("zh", "--language", help="报告语言:zh/en"),
88
+ ) -> None:
89
+ """启动交互式问答,选择领域和热点,生成报告并可推送飞书。"""
90
+ console.print(Panel.fit("Hotspot Research CLI", subtitle="last30days-safe + hotspot-research"))
91
+ config_manager = ConfigManager(config_path)
92
+ service = HotspotService()
93
+ try:
94
+ domain = _resolve_domain(service)
95
+ candidate = _resolve_hotspot(service, domain)
96
+ console.print(f"[green]已确认选题:{candidate.title}[/green]")
97
+ result = ReportGenerator(output_dir=output_dir).generate(candidate, language=language)
98
+ except (HotspotError, ReportError, ConfigError) as exc:
99
+ console.print(f"[red]执行失败:{exc}[/red]")
100
+ raise typer.Exit(code=1) from exc
101
+
102
+ console.print("[bold green]报告已生成[/bold green]")
103
+ console.print(f"Markdown: [cyan]{result.markdown_path}[/cyan]")
104
+ console.print(f"HTML: [cyan]{result.html_path}[/cyan]")
105
+ if result.pdf_path:
106
+ console.print(f"PDF: [cyan]{result.pdf_path}[/cyan]")
107
+ else:
108
+ console.print("[yellow]PDF 未生成;请检查 WeasyPrint/native 依赖。[/yellow]")
109
+
110
+ if push_lark:
111
+ try:
112
+ cfg = config_manager.load()
113
+ report_for_push = result.pdf_path or result.markdown_path
114
+ ChannelRegistry().get("lark").send(
115
+ chat_id=cfg.lark.chat_id,
116
+ topic=result.topic,
117
+ summary=result.summary,
118
+ report_path=report_for_push,
119
+ identity=cfg.lark.identity,
120
+ message_template=cfg.lark.message_template,
121
+ upload_folder_token=cfg.lark.upload_folder_token,
122
+ )
123
+ console.print("[green]已调用 lark-cli 推送飞书。[/green]")
124
+ except (ConfigError, DistributionError) as exc:
125
+ console.print(f"[red]飞书推送失败:{exc}[/red]")
126
+ console.print("[yellow]排查:确认 lark-cli 已 config init、bot/user 有 IM 与 Drive 权限、chat_id 正确。[/yellow]")
127
+ raise typer.Exit(code=2) from exc
128
+
129
+
130
+ @config_app.command("show")
131
+ def config_show(config_path: Optional[Path] = typer.Option(None, "--config", help="配置文件路径")) -> None:
132
+ """查看当前配置。"""
133
+ manager = ConfigManager(config_path)
134
+ try:
135
+ cfg = manager.load()
136
+ except ConfigError as exc:
137
+ console.print(f"[red]{exc}[/red]")
138
+ raise typer.Exit(code=1) from exc
139
+ console.print_json(data={"lark": cfg.lark.__dict__, "config_path": str(manager.path)})
140
+
141
+
142
+ @config_app.command("reset")
143
+ def config_reset(config_path: Optional[Path] = typer.Option(None, "--config", help="配置文件路径")) -> None:
144
+ """重置配置文件。"""
145
+ manager = ConfigManager(config_path)
146
+ try:
147
+ manager.reset()
148
+ except ConfigError as exc:
149
+ console.print(f"[red]{exc}[/red]")
150
+ raise typer.Exit(code=1) from exc
151
+ console.print(f"[green]配置已重置:{manager.path}[/green]")
152
+
153
+
154
+ @lark_app.command("setup")
155
+ def lark_setup(
156
+ chat_id: Optional[str] = typer.Option(None, "--chat-id", help="目标飞书群 chat_id,例如 oc_xxx"),
157
+ identity: str = typer.Option("bot", "--identity", help="bot 或 user"),
158
+ message_template: Optional[str] = typer.Option(None, "--message-template", help="消息模板,支持 {topic}/{summary}/{report_path}"),
159
+ upload_folder_token: Optional[str] = typer.Option(None, "--upload-folder-token", help="可选,报告上传到指定 Drive 文件夹"),
160
+ config_path: Optional[Path] = typer.Option(None, "--config", help="配置文件路径"),
161
+ ) -> None:
162
+ """通过参数或交互式方式配置飞书推送。"""
163
+ if chat_id is None:
164
+ chat_id = typer.prompt("目标飞书群 chat_id,例如 oc_xxx").strip()
165
+ if identity not in {"bot", "user"}:
166
+ identity = typer.prompt("身份类型只能是 bot 或 user", default="bot").strip()
167
+ if message_template is None:
168
+ message_template = typer.prompt("消息模板", default=DEFAULT_TEMPLATE)
169
+ if upload_folder_token is None:
170
+ upload_folder_token = typer.prompt("Drive 文件夹 token(可留空)", default="", show_default=False)
171
+
172
+ manager = ConfigManager(config_path)
173
+ try:
174
+ manager.update_lark(
175
+ chat_id=chat_id,
176
+ identity=identity,
177
+ message_template=message_template,
178
+ upload_folder_token=upload_folder_token,
179
+ )
180
+ except ConfigError as exc:
181
+ console.print(f"[red]{exc}[/red]")
182
+ raise typer.Exit(code=1) from exc
183
+ console.print(f"[green]飞书配置已保存:{manager.path}[/green]")
184
+
185
+
186
+ @app.command("send")
187
+ def send(
188
+ report_path: Path = typer.Argument(..., help="要推送的本地报告文件"),
189
+ topic: str = typer.Option("研究报告", "--topic"),
190
+ summary: str = typer.Option("详见附件", "--summary"),
191
+ channel: str = typer.Option("lark", "--channel"),
192
+ config_path: Optional[Path] = typer.Option(None, "--config", help="配置文件路径"),
193
+ ) -> None:
194
+ """将已有报告推送到指定渠道。"""
195
+ manager = ConfigManager(config_path)
196
+ try:
197
+ cfg = manager.load()
198
+ ChannelRegistry().get(channel).send(
199
+ chat_id=cfg.lark.chat_id,
200
+ topic=topic,
201
+ summary=summary,
202
+ report_path=report_path.resolve(),
203
+ identity=cfg.lark.identity,
204
+ message_template=cfg.lark.message_template,
205
+ upload_folder_token=cfg.lark.upload_folder_token,
206
+ )
207
+ except (ConfigError, DistributionError) as exc:
208
+ console.print(f"[red]推送失败:{exc}[/red]")
209
+ raise typer.Exit(code=1) from exc
210
+ console.print("[green]推送完成。[/green]")
211
+
212
+
213
+ def main() -> None:
214
+ app()
hotspot_cli/config.py ADDED
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import asdict, dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+
9
+ DEFAULT_TEMPLATE = "选题:{topic}\n简介:{summary}\n本地报告:{report_path}"
10
+
11
+
12
+ @dataclass
13
+ class LarkConfig:
14
+ chat_id: str = ""
15
+ identity: str = "bot"
16
+ message_template: str = DEFAULT_TEMPLATE
17
+ upload_folder_token: str = ""
18
+
19
+
20
+ @dataclass
21
+ class AppConfig:
22
+ lark: LarkConfig = field(default_factory=LarkConfig)
23
+
24
+
25
+ class ConfigError(RuntimeError):
26
+ pass
27
+
28
+
29
+ class ConfigManager:
30
+ def __init__(self, path: Path | None = None) -> None:
31
+ self.path = path or Path.home() / ".hotspot-research-cli" / "config.json"
32
+
33
+ def load(self) -> AppConfig:
34
+ if not self.path.exists():
35
+ return AppConfig()
36
+ try:
37
+ raw = self._read_mapping()
38
+ except Exception as exc: # noqa: BLE001
39
+ raise ConfigError(f"配置文件读取失败:{self.path}。请检查 JSON/YAML 格式。原始错误:{exc}") from exc
40
+ lark_raw = raw.get("lark", {}) if isinstance(raw, dict) else {}
41
+ return AppConfig(
42
+ lark=LarkConfig(
43
+ chat_id=str(lark_raw.get("chat_id", "") or ""),
44
+ identity=str(lark_raw.get("identity", "bot") or "bot"),
45
+ message_template=str(lark_raw.get("message_template", DEFAULT_TEMPLATE) or DEFAULT_TEMPLATE),
46
+ upload_folder_token=str(lark_raw.get("upload_folder_token", "") or ""),
47
+ )
48
+ )
49
+
50
+ def save(self, config: AppConfig) -> None:
51
+ try:
52
+ self.path.parent.mkdir(parents=True, exist_ok=True)
53
+ if self.path.suffix.lower() in {".yaml", ".yml"}:
54
+ self._write_yaml(asdict(config))
55
+ else:
56
+ self.path.write_text(json.dumps(asdict(config), ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
57
+ except PermissionError as exc:
58
+ raise ConfigError(f"无法写入配置文件:{self.path}。请检查目录权限。") from exc
59
+
60
+ def update_lark(
61
+ self,
62
+ *,
63
+ chat_id: str | None = None,
64
+ identity: str | None = None,
65
+ message_template: str | None = None,
66
+ upload_folder_token: str | None = None,
67
+ ) -> AppConfig:
68
+ config = self.load()
69
+ if chat_id is not None:
70
+ config.lark.chat_id = chat_id
71
+ if identity is not None:
72
+ if identity not in {"bot", "user"}:
73
+ raise ConfigError("飞书 identity 只能是 bot 或 user。")
74
+ config.lark.identity = identity
75
+ if message_template is not None:
76
+ config.lark.message_template = message_template
77
+ if upload_folder_token is not None:
78
+ config.lark.upload_folder_token = upload_folder_token
79
+ self.save(config)
80
+ return config
81
+
82
+ def reset(self) -> None:
83
+ self.save(AppConfig())
84
+
85
+ def _read_mapping(self) -> dict[str, Any]:
86
+ text = self.path.read_text(encoding="utf-8")
87
+ if self.path.suffix.lower() in {".yaml", ".yml"}:
88
+ try:
89
+ import yaml # type: ignore
90
+ except ImportError as exc:
91
+ raise ConfigError("读取 YAML 配置需要安装 PyYAML;也可以改用 config.json。") from exc
92
+ data = yaml.safe_load(text) or {}
93
+ return data if isinstance(data, dict) else {}
94
+ data = json.loads(text)
95
+ return data if isinstance(data, dict) else {}
96
+
97
+ def _write_yaml(self, data: dict[str, Any]) -> None:
98
+ try:
99
+ import yaml # type: ignore
100
+ except ImportError as exc:
101
+ raise ConfigError("写入 YAML 配置需要安装 PyYAML;也可以改用 config.json。") from exc
102
+ self.path.write_text(yaml.safe_dump(data, allow_unicode=True, sort_keys=False), encoding="utf-8")
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from abc import ABC, abstractmethod
5
+ from pathlib import Path
6
+ from typing import Callable, Optional
7
+
8
+
9
+ class DistributionError(RuntimeError):
10
+ pass
11
+
12
+
13
+ Runner = Callable[[list[str], Optional[Path]], str]
14
+
15
+
16
+ def subprocess_runner(argv: list[str], cwd: Path | None = None) -> str:
17
+ proc = subprocess.run(argv, cwd=str(cwd) if cwd else None, capture_output=True, text=True)
18
+ if proc.returncode != 0:
19
+ raise DistributionError(proc.stderr.strip() or proc.stdout.strip() or f"命令执行失败:{' '.join(argv)}")
20
+ return proc.stdout.strip()
21
+
22
+
23
+ class DistributionChannel(ABC):
24
+ @abstractmethod
25
+ def send(
26
+ self,
27
+ *,
28
+ chat_id: str,
29
+ topic: str,
30
+ summary: str,
31
+ report_path: Path,
32
+ identity: str,
33
+ message_template: str,
34
+ upload_folder_token: str = "",
35
+ ) -> None:
36
+ raise NotImplementedError
37
+
38
+
39
+ class LarkChannel(DistributionChannel):
40
+ def __init__(self, runner: Runner = subprocess_runner) -> None:
41
+ self.runner = runner
42
+
43
+ def send(
44
+ self,
45
+ *,
46
+ chat_id: str,
47
+ topic: str,
48
+ summary: str,
49
+ report_path: Path,
50
+ identity: str,
51
+ message_template: str,
52
+ upload_folder_token: str = "",
53
+ ) -> None:
54
+ if not chat_id:
55
+ raise DistributionError("缺少飞书群 chat_id。请先运行 config lark。")
56
+ if not report_path.exists():
57
+ raise DistributionError(f"报告文件不存在:{report_path}")
58
+ text = message_template.format(topic=topic, summary=summary, report_path=str(report_path))
59
+ self.runner(
60
+ [
61
+ "lark-cli",
62
+ "im",
63
+ "+messages-send",
64
+ "--chat-id",
65
+ chat_id,
66
+ "--text",
67
+ text,
68
+ "--as",
69
+ identity,
70
+ ],
71
+ None,
72
+ )
73
+ self.runner(
74
+ [
75
+ "lark-cli",
76
+ "im",
77
+ "+messages-send",
78
+ "--chat-id",
79
+ chat_id,
80
+ "--file",
81
+ report_path.name,
82
+ "--as",
83
+ identity,
84
+ ],
85
+ report_path.parent,
86
+ )
87
+ if upload_folder_token:
88
+ upload_cmd = ["lark-cli", "drive", "+upload", "--file", str(report_path), "--as", identity]
89
+ upload_cmd.extend(["--folder-token", upload_folder_token])
90
+ self.runner(upload_cmd, None)
91
+
92
+
93
+ class ChannelRegistry:
94
+ def __init__(self) -> None:
95
+ self._channels: dict[str, DistributionChannel] = {"lark": LarkChannel()}
96
+
97
+ def register(self, name: str, channel: DistributionChannel) -> None:
98
+ self._channels[name] = channel
99
+
100
+ def get(self, name: str) -> DistributionChannel:
101
+ try:
102
+ return self._channels[name]
103
+ except KeyError as exc:
104
+ raise DistributionError(f"未知分发渠道:{name}") from exc