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.
- hotspot_cli/__init__.py +3 -0
- hotspot_cli/__main__.py +5 -0
- hotspot_cli/cli.py +214 -0
- hotspot_cli/config.py +102 -0
- hotspot_cli/distribution.py +104 -0
- hotspot_cli/hotspots.py +280 -0
- hotspot_cli/last30days_safe.py +330 -0
- hotspot_cli/render_pdf_weasy.py +59 -0
- hotspot_cli/report.py +173 -0
- hotspot_cli/simple_report_html.py +189 -0
- hotspot_research_cli-0.1.0.dist-info/METADATA +261 -0
- hotspot_research_cli-0.1.0.dist-info/RECORD +15 -0
- hotspot_research_cli-0.1.0.dist-info/WHEEL +5 -0
- hotspot_research_cli-0.1.0.dist-info/entry_points.txt +2 -0
- hotspot_research_cli-0.1.0.dist-info/top_level.txt +1 -0
hotspot_cli/__init__.py
ADDED
hotspot_cli/__main__.py
ADDED
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
|