clawshire-cli 0.1.0a2__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.
- clawshire_cli/__init__.py +1 -0
- clawshire_cli/commands/__init__.py +1 -0
- clawshire_cli/commands/annual_analysis.py +97 -0
- clawshire_cli/commands/annual_report.py +43 -0
- clawshire_cli/commands/auth.py +63 -0
- clawshire_cli/commands/notice.py +66 -0
- clawshire_cli/commands/user.py +21 -0
- clawshire_cli/config.py +61 -0
- clawshire_cli/context.py +19 -0
- clawshire_cli/main.py +67 -0
- clawshire_cli/output.py +103 -0
- clawshire_cli-0.1.0a2.dist-info/METADATA +410 -0
- clawshire_cli-0.1.0a2.dist-info/RECORD +22 -0
- clawshire_cli-0.1.0a2.dist-info/WHEEL +5 -0
- clawshire_cli-0.1.0a2.dist-info/entry_points.txt +3 -0
- clawshire_cli-0.1.0a2.dist-info/top_level.txt +2 -0
- clawshire_sdk/__init__.py +15 -0
- clawshire_sdk/client.py +126 -0
- clawshire_sdk/domains/__init__.py +4 -0
- clawshire_sdk/domains/annual_reports.py +129 -0
- clawshire_sdk/domains/filings.py +58 -0
- clawshire_sdk/errors.py +22 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__all__ = []
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__all__ = []
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from argparse import ArgumentParser, Namespace, _SubParsersAction
|
|
5
|
+
|
|
6
|
+
from clawshire_cli.context import build_client, resolve_output
|
|
7
|
+
from clawshire_cli.output import render
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def register(subparsers: _SubParsersAction[ArgumentParser]) -> None:
|
|
11
|
+
parser = subparsers.add_parser("annual-analysis", help="年报分析")
|
|
12
|
+
annual_analysis_subparsers = parser.add_subparsers(dest="annual_analysis_command", required=True)
|
|
13
|
+
|
|
14
|
+
pdf_file = annual_analysis_subparsers.add_parser("pdf-file", help="通过本地 PDF 文件提交分析")
|
|
15
|
+
pdf_file.add_argument("pdf_path", help="本地 PDF 路径")
|
|
16
|
+
_add_job_submit_args(pdf_file)
|
|
17
|
+
pdf_file.set_defaults(handler=_handle_pdf_file)
|
|
18
|
+
|
|
19
|
+
pdf_url = annual_analysis_subparsers.add_parser("pdf-url", help="通过 PDF 链接提交分析")
|
|
20
|
+
pdf_url.add_argument("pdf_url", help="PDF 直链")
|
|
21
|
+
_add_job_submit_args(pdf_url)
|
|
22
|
+
pdf_url.set_defaults(handler=_handle_pdf_url)
|
|
23
|
+
|
|
24
|
+
company = annual_analysis_subparsers.add_parser("company", help="通过公司代码或简称分析最新年报")
|
|
25
|
+
company.add_argument("keyword", help="公司证券代码或简称")
|
|
26
|
+
company.add_argument("--year", type=int, help="年份,如 2025")
|
|
27
|
+
company.add_argument("--exchange", choices=["sz", "sh", "bj"], help="交易所过滤")
|
|
28
|
+
company.add_argument("--notify-email", help="分析完成后通知邮箱")
|
|
29
|
+
company.set_defaults(handler=_handle_company)
|
|
30
|
+
|
|
31
|
+
get_cmd = annual_analysis_subparsers.add_parser("get", help="查询年报分析任务")
|
|
32
|
+
get_cmd.add_argument("task_or_job_id", help="分析任务 ID。支持 direct job_id 或 company task_id")
|
|
33
|
+
get_cmd.set_defaults(handler=_handle_get)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _add_job_submit_args(parser: ArgumentParser) -> None:
|
|
37
|
+
parser.add_argument("--lang", default="zh", choices=["zh", "en"], help="报告语言")
|
|
38
|
+
parser.add_argument("--wait", action="store_true", help="阻塞等待任务完成")
|
|
39
|
+
parser.add_argument("--poll-interval", type=int, default=10, help="轮询间隔秒数")
|
|
40
|
+
parser.add_argument("--max-polls", type=int, default=60, help="最大轮询次数")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _handle_pdf_file(args: Namespace) -> int:
|
|
44
|
+
client = build_client(args)
|
|
45
|
+
data = client.annual.analyze_submit(args.pdf_path, lang=args.lang)
|
|
46
|
+
return _render_or_wait_job(client, data, args)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _handle_pdf_url(args: Namespace) -> int:
|
|
50
|
+
client = build_client(args)
|
|
51
|
+
data = client.annual.analyze_submit_pdf_url(args.pdf_url, lang=args.lang)
|
|
52
|
+
return _render_or_wait_job(client, data, args)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _handle_company(args: Namespace) -> int:
|
|
56
|
+
client = build_client(args)
|
|
57
|
+
data = client.annual.analyze_company(
|
|
58
|
+
args.keyword,
|
|
59
|
+
year=args.year,
|
|
60
|
+
exchange=args.exchange,
|
|
61
|
+
notify_email=args.notify_email,
|
|
62
|
+
)
|
|
63
|
+
render(data, output=resolve_output(args))
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _handle_get(args: Namespace) -> int:
|
|
68
|
+
client = build_client(args)
|
|
69
|
+
if str(args.task_or_job_id).isdigit():
|
|
70
|
+
data = client.annual.get_analysis_task(int(args.task_or_job_id))
|
|
71
|
+
else:
|
|
72
|
+
data = client.annual.analyze_get(args.task_or_job_id)
|
|
73
|
+
render(data, output=resolve_output(args))
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _render_or_wait_job(client, data: dict, args: Namespace) -> int:
|
|
78
|
+
if not args.wait:
|
|
79
|
+
render(data, output=resolve_output(args))
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
job_id = data.get("job_id")
|
|
83
|
+
if not job_id:
|
|
84
|
+
render(data, output=resolve_output(args))
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
for attempt in range(args.max_polls):
|
|
88
|
+
result = client.annual.analyze_get(job_id)
|
|
89
|
+
status = result.get("status", "unknown")
|
|
90
|
+
print(f"[{attempt + 1}/{args.max_polls}] status={status}")
|
|
91
|
+
if status in {"completed", "failed"}:
|
|
92
|
+
render(result, output=resolve_output(args))
|
|
93
|
+
return 0
|
|
94
|
+
time.sleep(args.poll_interval)
|
|
95
|
+
|
|
96
|
+
print(f"轮询超时,job_id={job_id}")
|
|
97
|
+
return 1
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from argparse import ArgumentParser, Namespace, _SubParsersAction
|
|
4
|
+
|
|
5
|
+
from clawshire_cli.context import build_client, resolve_output
|
|
6
|
+
from clawshire_cli.output import render
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def register(subparsers: _SubParsersAction[ArgumentParser]) -> None:
|
|
10
|
+
parser = subparsers.add_parser("annual-report", help="年报查询")
|
|
11
|
+
annual_report_subparsers = parser.add_subparsers(dest="annual_report_command", required=True)
|
|
12
|
+
|
|
13
|
+
latest = annual_report_subparsers.add_parser("latest", help="获取最新年报列表")
|
|
14
|
+
latest.add_argument("--page", type=int, default=1, help="页码")
|
|
15
|
+
latest.add_argument("--page-size", type=int, default=20, help="每页数量")
|
|
16
|
+
latest.add_argument("--year", type=int, help="年份,如 2025")
|
|
17
|
+
latest.add_argument("--exchange", help="交易所,如 bj")
|
|
18
|
+
latest.add_argument("--keyword", help="关键词或公司代码")
|
|
19
|
+
latest.set_defaults(handler=_handle_latest)
|
|
20
|
+
|
|
21
|
+
data = annual_report_subparsers.add_parser("data", help="获取年报结构化数据")
|
|
22
|
+
data.add_argument("met_uuid", help="年报 met_uuid")
|
|
23
|
+
data.set_defaults(handler=_handle_data)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _handle_latest(args: Namespace) -> int:
|
|
27
|
+
client = build_client(args)
|
|
28
|
+
data = client.annual.latest(
|
|
29
|
+
page=args.page,
|
|
30
|
+
page_size=args.page_size,
|
|
31
|
+
year=args.year,
|
|
32
|
+
exchange=args.exchange,
|
|
33
|
+
keyword=args.keyword,
|
|
34
|
+
)
|
|
35
|
+
render(data, output=resolve_output(args))
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _handle_data(args: Namespace) -> int:
|
|
40
|
+
client = build_client(args)
|
|
41
|
+
data = client.annual.data(args.met_uuid)
|
|
42
|
+
render(data, output=resolve_output(args))
|
|
43
|
+
return 0
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from argparse import ArgumentParser, Namespace, _SubParsersAction
|
|
4
|
+
|
|
5
|
+
from clawshire_cli.config import load_config, mask_api_key, save_config
|
|
6
|
+
from clawshire_cli.context import build_client, resolve_output
|
|
7
|
+
from clawshire_cli.output import render
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def register(subparsers: _SubParsersAction[ArgumentParser]) -> None:
|
|
11
|
+
parser = subparsers.add_parser("auth", help="认证与连通性检查")
|
|
12
|
+
auth_subparsers = parser.add_subparsers(dest="auth_command", required=True)
|
|
13
|
+
|
|
14
|
+
set_key = auth_subparsers.add_parser("set-key", help="保存 API Key 到本地配置")
|
|
15
|
+
set_key.add_argument("api_key", help="ClawShire API Key")
|
|
16
|
+
set_key.set_defaults(handler=_handle_set_key)
|
|
17
|
+
|
|
18
|
+
clear_key = auth_subparsers.add_parser("clear-key", help="清除本地保存的 API Key")
|
|
19
|
+
clear_key.set_defaults(handler=_handle_clear_key)
|
|
20
|
+
|
|
21
|
+
show = auth_subparsers.add_parser("show", help="查看当前认证配置")
|
|
22
|
+
show.set_defaults(handler=_handle_show)
|
|
23
|
+
|
|
24
|
+
whoami = auth_subparsers.add_parser("whoami", help="检查当前 API Key")
|
|
25
|
+
whoami.set_defaults(handler=_handle_whoami)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _handle_set_key(args: Namespace) -> int:
|
|
29
|
+
config = load_config()
|
|
30
|
+
config.api_key = args.api_key
|
|
31
|
+
path = save_config(config)
|
|
32
|
+
print(f"API Key 已保存到 {path}")
|
|
33
|
+
print(f"当前 Key: {mask_api_key(args.api_key)}")
|
|
34
|
+
return 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _handle_clear_key(args: Namespace) -> int:
|
|
38
|
+
config = load_config()
|
|
39
|
+
config.api_key = None
|
|
40
|
+
path = save_config(config)
|
|
41
|
+
print(f"API Key 已清除: {path}")
|
|
42
|
+
return 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _handle_show(args: Namespace) -> int:
|
|
46
|
+
config = load_config()
|
|
47
|
+
render(
|
|
48
|
+
{
|
|
49
|
+
"base_url": config.base_url,
|
|
50
|
+
"api_key": mask_api_key(config.api_key),
|
|
51
|
+
"output": config.output,
|
|
52
|
+
"timeout": config.timeout,
|
|
53
|
+
},
|
|
54
|
+
output=resolve_output(args),
|
|
55
|
+
)
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _handle_whoami(args: Namespace) -> int:
|
|
60
|
+
client = build_client(args)
|
|
61
|
+
data = client.get("/api/v1/api-key/info", auth_required=True)
|
|
62
|
+
render(data, output=resolve_output(args))
|
|
63
|
+
return 0
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from argparse import ArgumentParser, Namespace, _SubParsersAction
|
|
4
|
+
|
|
5
|
+
from clawshire_cli.context import build_client, resolve_output
|
|
6
|
+
from clawshire_cli.output import render
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def register(subparsers: _SubParsersAction[ArgumentParser]) -> None:
|
|
10
|
+
parser = subparsers.add_parser("notice", help="公告查询")
|
|
11
|
+
notice_subparsers = parser.add_subparsers(dest="notice_command", required=True)
|
|
12
|
+
|
|
13
|
+
search = notice_subparsers.add_parser("search", help="按日期范围查询公告")
|
|
14
|
+
_add_search_args(search)
|
|
15
|
+
search.set_defaults(handler=_handle_search)
|
|
16
|
+
|
|
17
|
+
stock = notice_subparsers.add_parser("stock", help="按证券代码查询公告")
|
|
18
|
+
stock.add_argument("sec_code", help="证券代码")
|
|
19
|
+
_add_search_args(stock)
|
|
20
|
+
stock.set_defaults(handler=_handle_stock)
|
|
21
|
+
|
|
22
|
+
link = notice_subparsers.add_parser("link", help="按公告原文链接查询")
|
|
23
|
+
link.add_argument("--met-link", required=True, help="公告原文链接")
|
|
24
|
+
link.set_defaults(handler=_handle_link)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _add_search_args(parser: ArgumentParser) -> None:
|
|
28
|
+
parser.add_argument("--start-date", required=True, help="开始日期 YYYY-MM-DD")
|
|
29
|
+
parser.add_argument("--end-date", required=True, help="结束日期 YYYY-MM-DD")
|
|
30
|
+
parser.add_argument("--infotype", help="公告类别")
|
|
31
|
+
parser.add_argument("--page", type=int, default=1, help="页码")
|
|
32
|
+
parser.add_argument("--page-size", type=int, default=20, help="每页数量")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _handle_search(args: Namespace) -> int:
|
|
36
|
+
client = build_client(args)
|
|
37
|
+
data = client.filings.search(
|
|
38
|
+
start_date=args.start_date,
|
|
39
|
+
end_date=args.end_date,
|
|
40
|
+
infotype=args.infotype,
|
|
41
|
+
page=args.page,
|
|
42
|
+
page_size=args.page_size,
|
|
43
|
+
)
|
|
44
|
+
render(data, output=resolve_output(args))
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _handle_stock(args: Namespace) -> int:
|
|
49
|
+
client = build_client(args)
|
|
50
|
+
data = client.filings.stock(
|
|
51
|
+
args.sec_code,
|
|
52
|
+
start_date=args.start_date,
|
|
53
|
+
end_date=args.end_date,
|
|
54
|
+
infotype=args.infotype,
|
|
55
|
+
page=args.page,
|
|
56
|
+
page_size=args.page_size,
|
|
57
|
+
)
|
|
58
|
+
render(data, output=resolve_output(args))
|
|
59
|
+
return 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _handle_link(args: Namespace) -> int:
|
|
63
|
+
client = build_client(args)
|
|
64
|
+
data = client.filings.link(args.met_link)
|
|
65
|
+
render(data, output=resolve_output(args))
|
|
66
|
+
return 0
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from argparse import ArgumentParser, Namespace, _SubParsersAction
|
|
4
|
+
|
|
5
|
+
from clawshire_cli.context import build_client, resolve_output
|
|
6
|
+
from clawshire_cli.output import render
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def register(subparsers: _SubParsersAction[ArgumentParser]) -> None:
|
|
10
|
+
parser = subparsers.add_parser("user", help="用户信息与额度查询")
|
|
11
|
+
user_subparsers = parser.add_subparsers(dest="user_command", required=True)
|
|
12
|
+
|
|
13
|
+
info = user_subparsers.add_parser("info", help="查询余额、免费次数、配额等用户信息")
|
|
14
|
+
info.set_defaults(handler=_handle_info)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _handle_info(args: Namespace) -> int:
|
|
18
|
+
client = build_client(args)
|
|
19
|
+
data = client.get("/api/v1/api-key/info", auth_required=True)
|
|
20
|
+
render(data, output=resolve_output(args))
|
|
21
|
+
return 0
|
clawshire_cli/config.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import tomli_w
|
|
7
|
+
import tomllib
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DEFAULT_BASE_URL = "https://api.clawshire.cn"
|
|
11
|
+
CONFIG_PATH = Path.home() / ".config" / "clawshire" / "config.toml"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class CliConfig:
|
|
16
|
+
base_url: str = DEFAULT_BASE_URL
|
|
17
|
+
api_key: str | None = None
|
|
18
|
+
output: str = "table"
|
|
19
|
+
timeout: float = 30.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_config() -> CliConfig:
|
|
23
|
+
config = CliConfig()
|
|
24
|
+
|
|
25
|
+
if CONFIG_PATH.exists():
|
|
26
|
+
data = tomllib.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
|
27
|
+
config.base_url = str(data.get("base_url") or config.base_url)
|
|
28
|
+
config.api_key = data.get("api_key") or config.api_key
|
|
29
|
+
config.output = str(data.get("output") or config.output)
|
|
30
|
+
timeout = data.get("timeout")
|
|
31
|
+
if timeout is not None:
|
|
32
|
+
config.timeout = float(timeout)
|
|
33
|
+
|
|
34
|
+
config.base_url = os.getenv("CLAWSHIRE_BASE_URL", config.base_url)
|
|
35
|
+
config.api_key = os.getenv("CLAWSHIRE_API_KEY", config.api_key)
|
|
36
|
+
config.output = os.getenv("CLAWSHIRE_OUTPUT", config.output)
|
|
37
|
+
|
|
38
|
+
env_timeout = os.getenv("CLAWSHIRE_TIMEOUT")
|
|
39
|
+
if env_timeout:
|
|
40
|
+
config.timeout = float(env_timeout)
|
|
41
|
+
return config
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def save_config(config: CliConfig) -> Path:
|
|
45
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
data = {
|
|
47
|
+
"base_url": config.base_url,
|
|
48
|
+
"api_key": config.api_key,
|
|
49
|
+
"output": config.output,
|
|
50
|
+
"timeout": config.timeout,
|
|
51
|
+
}
|
|
52
|
+
CONFIG_PATH.write_text(tomli_w.dumps(data), encoding="utf-8")
|
|
53
|
+
return CONFIG_PATH
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def mask_api_key(api_key: str | None) -> str:
|
|
57
|
+
if not api_key:
|
|
58
|
+
return ""
|
|
59
|
+
if len(api_key) <= 8:
|
|
60
|
+
return "*" * len(api_key)
|
|
61
|
+
return f"{api_key[:6]}...{api_key[-4:]}"
|
clawshire_cli/context.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from argparse import Namespace
|
|
4
|
+
|
|
5
|
+
from clawshire_cli.config import load_config
|
|
6
|
+
from clawshire_sdk import ClawShireClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build_client(args: Namespace) -> ClawShireClient:
|
|
10
|
+
config = load_config()
|
|
11
|
+
base_url = args.base_url or config.base_url
|
|
12
|
+
api_key = args.api_key if args.api_key is not None else config.api_key
|
|
13
|
+
timeout = args.timeout if args.timeout is not None else config.timeout
|
|
14
|
+
return ClawShireClient(base_url=base_url, api_key=api_key, timeout=timeout)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def resolve_output(args: Namespace) -> str:
|
|
18
|
+
config = load_config()
|
|
19
|
+
return args.output or config.output
|
clawshire_cli/main.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from clawshire_cli.commands import annual_analysis, annual_report, auth, notice, user
|
|
7
|
+
from clawshire_sdk import (
|
|
8
|
+
ClawShireApiError,
|
|
9
|
+
ClawShireAuthError,
|
|
10
|
+
ClawShireConfigError,
|
|
11
|
+
ClawShireNetworkError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
prog="clawshire",
|
|
18
|
+
description="ClawShire CLI",
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument("--base-url", help="API 基础地址,如 https://api.clawshire.cn")
|
|
21
|
+
parser.add_argument("--api-key", help="API Key,优先级高于本地配置")
|
|
22
|
+
parser.add_argument("--output", choices=["table", "json", "markdown"], help="输出格式")
|
|
23
|
+
parser.add_argument("--timeout", type=float, help="HTTP 超时秒数")
|
|
24
|
+
|
|
25
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
26
|
+
notice.register(subparsers)
|
|
27
|
+
annual_report.register(subparsers)
|
|
28
|
+
annual_analysis.register(subparsers)
|
|
29
|
+
auth.register(subparsers)
|
|
30
|
+
user.register(subparsers)
|
|
31
|
+
return parser
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def run(argv: list[str] | None = None) -> int:
|
|
35
|
+
argv = list(argv or sys.argv[1:])
|
|
36
|
+
if argv:
|
|
37
|
+
alias_map = {"gg": "notice", "ar": "annual-report", "aa": "annual-analysis"}
|
|
38
|
+
if argv[0] in alias_map:
|
|
39
|
+
argv[0] = alias_map[argv[0]]
|
|
40
|
+
parser = build_parser()
|
|
41
|
+
args = parser.parse_args(argv)
|
|
42
|
+
handler = getattr(args, "handler", None)
|
|
43
|
+
if handler is None:
|
|
44
|
+
parser.print_help()
|
|
45
|
+
return 2
|
|
46
|
+
try:
|
|
47
|
+
return int(handler(args) or 0)
|
|
48
|
+
except ClawShireConfigError as exc:
|
|
49
|
+
print(f"配置错误: {exc}", file=sys.stderr)
|
|
50
|
+
return 2
|
|
51
|
+
except ClawShireAuthError as exc:
|
|
52
|
+
print(f"认证失败: {exc}", file=sys.stderr)
|
|
53
|
+
return 3
|
|
54
|
+
except ClawShireNetworkError as exc:
|
|
55
|
+
print(f"网络错误: {exc}", file=sys.stderr)
|
|
56
|
+
return 4
|
|
57
|
+
except ClawShireApiError as exc:
|
|
58
|
+
print(f"接口错误: {exc}", file=sys.stderr)
|
|
59
|
+
return 5
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main() -> None:
|
|
63
|
+
raise SystemExit(run())
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__":
|
|
67
|
+
main()
|
clawshire_cli/output.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def render(data: Any, *, output: str) -> None:
|
|
8
|
+
output = output.lower()
|
|
9
|
+
if output == "json":
|
|
10
|
+
print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
11
|
+
return
|
|
12
|
+
if output == "markdown":
|
|
13
|
+
print(_to_markdown(data))
|
|
14
|
+
return
|
|
15
|
+
print(_to_table(data))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _to_markdown(data: Any) -> str:
|
|
19
|
+
if isinstance(data, list):
|
|
20
|
+
if not data:
|
|
21
|
+
return "_No data_"
|
|
22
|
+
if all(isinstance(item, dict) for item in data):
|
|
23
|
+
return _dict_list_to_markdown(data)
|
|
24
|
+
if isinstance(data, dict):
|
|
25
|
+
if "items" in data and isinstance(data["items"], list):
|
|
26
|
+
head = {k: v for k, v in data.items() if k != "items"}
|
|
27
|
+
parts = []
|
|
28
|
+
if head:
|
|
29
|
+
parts.append(_dict_to_bullets(head))
|
|
30
|
+
parts.append(_dict_list_to_markdown(data["items"]))
|
|
31
|
+
return "\n\n".join(part for part in parts if part)
|
|
32
|
+
return _dict_to_bullets(data)
|
|
33
|
+
return str(data)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _to_table(data: Any) -> str:
|
|
37
|
+
if isinstance(data, list):
|
|
38
|
+
if not data:
|
|
39
|
+
return "No data"
|
|
40
|
+
if all(isinstance(item, dict) for item in data):
|
|
41
|
+
return _dict_list_to_table(data)
|
|
42
|
+
if isinstance(data, dict):
|
|
43
|
+
if "items" in data and isinstance(data["items"], list):
|
|
44
|
+
header = _dict_to_lines({k: v for k, v in data.items() if k != "items"})
|
|
45
|
+
body = _dict_list_to_table(data["items"])
|
|
46
|
+
if header:
|
|
47
|
+
return f"{header}\n\n{body}"
|
|
48
|
+
return body
|
|
49
|
+
return _dict_to_lines(data)
|
|
50
|
+
return str(data)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _dict_to_lines(data: dict[str, Any]) -> str:
|
|
54
|
+
return "\n".join(f"{key}: {_format_value(value)}" for key, value in data.items())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _dict_to_bullets(data: dict[str, Any]) -> str:
|
|
58
|
+
return "\n".join(f"- {key}: {_format_value(value)}" for key, value in data.items())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _dict_list_to_table(rows: list[dict[str, Any]]) -> str:
|
|
62
|
+
if not rows:
|
|
63
|
+
return "No data"
|
|
64
|
+
columns = []
|
|
65
|
+
for row in rows:
|
|
66
|
+
for key in row.keys():
|
|
67
|
+
if key not in columns:
|
|
68
|
+
columns.append(key)
|
|
69
|
+
widths = {col: len(col) for col in columns}
|
|
70
|
+
for row in rows:
|
|
71
|
+
for col in columns:
|
|
72
|
+
widths[col] = max(widths[col], len(_format_value(row.get(col))))
|
|
73
|
+
header = " | ".join(col.ljust(widths[col]) for col in columns)
|
|
74
|
+
sep = "-+-".join("-" * widths[col] for col in columns)
|
|
75
|
+
body = [
|
|
76
|
+
" | ".join(_format_value(row.get(col)).ljust(widths[col]) for col in columns)
|
|
77
|
+
for row in rows
|
|
78
|
+
]
|
|
79
|
+
return "\n".join([header, sep, *body])
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _dict_list_to_markdown(rows: list[dict[str, Any]]) -> str:
|
|
83
|
+
if not rows:
|
|
84
|
+
return "_No data_"
|
|
85
|
+
columns = []
|
|
86
|
+
for row in rows:
|
|
87
|
+
for key in row.keys():
|
|
88
|
+
if key not in columns:
|
|
89
|
+
columns.append(key)
|
|
90
|
+
header = "| " + " | ".join(columns) + " |"
|
|
91
|
+
sep = "| " + " | ".join("---" for _ in columns) + " |"
|
|
92
|
+
body = []
|
|
93
|
+
for row in rows:
|
|
94
|
+
body.append("| " + " | ".join(_format_value(row.get(col)) for col in columns) + " |")
|
|
95
|
+
return "\n".join([header, sep, *body])
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _format_value(value: Any) -> str:
|
|
99
|
+
if value is None:
|
|
100
|
+
return ""
|
|
101
|
+
if isinstance(value, (dict, list)):
|
|
102
|
+
return json.dumps(value, ensure_ascii=False)
|
|
103
|
+
return str(value)
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: clawshire-cli
|
|
3
|
+
Version: 0.1.0a2
|
|
4
|
+
Summary: ClawShire CLI for notice query, annual report query, and annual analysis
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: httpx>=0.28.1
|
|
8
|
+
Requires-Dist: tomli-w>=1.2.0
|
|
9
|
+
|
|
10
|
+
# ClawShire CLI
|
|
11
|
+
|
|
12
|
+
独立可安装的 ClawShire 命令行客户端。
|
|
13
|
+
|
|
14
|
+
## 安装
|
|
15
|
+
|
|
16
|
+
当前优先使用内网 PyPI 安装:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
uv tool install --index http://192.168.41.95:8141/memect/dev/+simple/ clawshire-cli
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
如果本机已安装 `pipx`,也可通过内网源安装:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
PIP_INDEX_URL=http://192.168.41.95:8141/memect/dev/+simple/ pipx install clawshire-cli
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
本地开发可直接从源码目录安装:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
uv tool install ./clawshire-cli
|
|
32
|
+
pipx install ./clawshire-cli
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
说明:
|
|
36
|
+
|
|
37
|
+
1. 当前文档以内网安装方式为准
|
|
38
|
+
2. 未来如果公开发布到外网 PyPI,再补充公共安装命令
|
|
39
|
+
|
|
40
|
+
安装后可使用两个命令入口:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
clawshire --help
|
|
44
|
+
cs --help
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
如果你只想快速确认安装成功,先跑:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
clawshire --help
|
|
51
|
+
clawshire auth --help
|
|
52
|
+
clawshire user info --api-key <your_api_key>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## 3 分钟上手
|
|
56
|
+
|
|
57
|
+
把下面几条命令直接复制执行即可:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
uv tool install --index http://192.168.41.95:8141/memect/dev/+simple/ clawshire-cli
|
|
61
|
+
clawshire auth set-key <your_api_key>
|
|
62
|
+
clawshire user info
|
|
63
|
+
clawshire annual-analysis pdf-url https://static.cninfo.com.cn/finalpage/2026-04-20/1225116956.PDF
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
如果你要查公告或年报,再继续:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
clawshire notice search --start-date 2026-04-19 --end-date 2026-04-20
|
|
70
|
+
clawshire annual-report latest --year 2025 --keyword 平安银行
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## 先配置 API Key
|
|
74
|
+
|
|
75
|
+
建议先把 API Key 保存到本地,这样后续不用每次手动加 `--api-key`。
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
clawshire auth set-key <your_api_key>
|
|
79
|
+
clawshire auth show
|
|
80
|
+
clawshire user info
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
如果想临时覆盖本地配置,也可以直接传:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
clawshire --api-key <your_api_key> user info
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
清除本地保存的 Key:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
clawshire auth clear-key
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## 命令
|
|
96
|
+
|
|
97
|
+
主命令分组:
|
|
98
|
+
|
|
99
|
+
1. `notice`
|
|
100
|
+
2. `annual-report`
|
|
101
|
+
3. `annual-analysis`
|
|
102
|
+
|
|
103
|
+
高频简写:
|
|
104
|
+
|
|
105
|
+
1. `gg` -> `notice`
|
|
106
|
+
2. `ar` -> `annual-report`
|
|
107
|
+
3. `aa` -> `annual-analysis`
|
|
108
|
+
|
|
109
|
+
## 全部命令能力
|
|
110
|
+
|
|
111
|
+
| 能力 | 命令 | 是否需要 API Key | 说明 |
|
|
112
|
+
|------|------|------------------|------|
|
|
113
|
+
| 保存 Key | `clawshire auth set-key <key>` | 否 | 保存本地 API Key |
|
|
114
|
+
| 查看认证配置 | `clawshire auth show` | 否 | 查看当前 base_url、key 掩码、输出格式、超时 |
|
|
115
|
+
| 清除 Key | `clawshire auth clear-key` | 否 | 清除本地保存的 API Key |
|
|
116
|
+
| 检查当前 Key | `clawshire auth whoami` | 是 | 校验当前 API Key 是否有效 |
|
|
117
|
+
| 用户信息 | `clawshire user info` | 是 | 查询余额、免费次数、配额等用户信息 |
|
|
118
|
+
| 公告按日期查询 | `clawshire notice search --start-date <d> --end-date <d>` | 否/建议有 | 按日期范围查询公告 |
|
|
119
|
+
| 公告按证券代码查询 | `clawshire notice stock <sec_code> --start-date <d> --end-date <d>` | 否/建议有 | 查询某证券代码公告 |
|
|
120
|
+
| 公告按链接查询 | `clawshire notice link --met-link <pdf_link>` | 否/建议有 | 按公告 PDF 链接查询 |
|
|
121
|
+
| 年报列表 | `clawshire annual-report latest --year <YYYY> --keyword <kw>` | 是 | 查询指定年份年报列表 |
|
|
122
|
+
| 年报结构化数据 | `clawshire annual-report data <met_uuid>` | 是 | 查询指定年报的结构化数据 |
|
|
123
|
+
| 年报分析: 本地文件 | `clawshire annual-analysis pdf-file <path>` | 是 | 上传本地 PDF 分析 |
|
|
124
|
+
| 年报分析: PDF 链接 | `clawshire annual-analysis pdf-url <url>` | 是 | 下载 PDF 链接后分析 |
|
|
125
|
+
| 年报分析: 公司 | `clawshire annual-analysis company <代码或简称> --year <YYYY>` | 是 | 先查询指定年份年报,再自动发起分析 |
|
|
126
|
+
| 查询分析任务 | `clawshire annual-analysis get <task_id_or_job_id>` | 是 | 查询分析任务状态或结果 |
|
|
127
|
+
|
|
128
|
+
简写命令:
|
|
129
|
+
|
|
130
|
+
| 简写 | 全称 | 示例 |
|
|
131
|
+
|------|------|------|
|
|
132
|
+
| `gg` | `notice` | `cs gg search --start-date 2026-04-19 --end-date 2026-04-20` |
|
|
133
|
+
| `ar` | `annual-report` | `cs ar latest --year 2025 --keyword 平安银行` |
|
|
134
|
+
| `aa` | `annual-analysis` | `cs aa company 000001 --year 2025` |
|
|
135
|
+
|
|
136
|
+
按命令分组查看帮助:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
clawshire auth --help
|
|
140
|
+
clawshire user --help
|
|
141
|
+
clawshire notice --help
|
|
142
|
+
clawshire annual-report --help
|
|
143
|
+
clawshire annual-analysis --help
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## 示例
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
clawshire user info
|
|
150
|
+
clawshire notice search --start-date 2026-04-01 --end-date 2026-04-20
|
|
151
|
+
clawshire annual-report latest --year 2025 --keyword 平安银行
|
|
152
|
+
clawshire annual-analysis pdf-file ./report.pdf
|
|
153
|
+
clawshire annual-analysis pdf-url https://example.com/report.pdf
|
|
154
|
+
clawshire annual-analysis company 000001 --year 2025
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
年报分析测试 PDF 示例:
|
|
158
|
+
|
|
159
|
+
```text
|
|
160
|
+
https://static.cninfo.com.cn/finalpage/2026-04-20/1225116956.PDF
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
推荐直接试:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
clawshire annual-analysis pdf-url https://static.cninfo.com.cn/finalpage/2026-04-20/1225116956.PDF
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## 配置
|
|
170
|
+
|
|
171
|
+
支持环境变量:
|
|
172
|
+
|
|
173
|
+
1. `CLAWSHIRE_API_KEY`
|
|
174
|
+
2. `CLAWSHIRE_BASE_URL`
|
|
175
|
+
3. `CLAWSHIRE_OUTPUT`
|
|
176
|
+
4. `CLAWSHIRE_TIMEOUT`
|
|
177
|
+
|
|
178
|
+
如果你更习惯环境变量,可直接这样配置:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
export CLAWSHIRE_API_KEY=<your_api_key>
|
|
182
|
+
clawshire user info
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## 最常用命令
|
|
186
|
+
|
|
187
|
+
查询用户信息:
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
clawshire user info
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
查询公告:
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
clawshire notice search --start-date 2026-04-19 --end-date 2026-04-20
|
|
197
|
+
clawshire notice stock 000001 --start-date 2026-04-01 --end-date 2026-04-20
|
|
198
|
+
clawshire notice link --met-link <pdf_link>
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
查询年报:
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
clawshire annual-report latest --year 2025 --keyword 平安银行
|
|
205
|
+
clawshire annual-report data <met_uuid>
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
发起年报分析:
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
clawshire annual-analysis pdf-file ./report.pdf
|
|
212
|
+
clawshire annual-analysis pdf-url https://static.cninfo.com.cn/finalpage/2026-04-20/1225116956.PDF
|
|
213
|
+
clawshire annual-analysis company 000001 --year 2025
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
查询分析任务:
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
clawshire annual-analysis get <task_id_or_job_id>
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## 必要提示
|
|
223
|
+
|
|
224
|
+
1. 全局参数要放在子命令前面,例如:`clawshire --output json user info`
|
|
225
|
+
2. `notice` 查询通常不需要 API Key,但如果本地配置了错误的 Key,可能会带上错误认证头
|
|
226
|
+
3. `annual-report`、`annual-analysis`、`user info` 都需要有效 API Key
|
|
227
|
+
4. CLI 不单独实现计费,仍然走服务端原有配额和计费逻辑
|
|
228
|
+
|
|
229
|
+
## 常见报错与处理
|
|
230
|
+
|
|
231
|
+
### 1. `认证失败: Authorization 格式错误,应为 Bearer <api_key>`
|
|
232
|
+
|
|
233
|
+
原因:
|
|
234
|
+
|
|
235
|
+
1. 本地保存了错误的 API Key
|
|
236
|
+
2. 环境变量里有错误的 `CLAWSHIRE_API_KEY`
|
|
237
|
+
3. 你传入的不是完整 API Key
|
|
238
|
+
|
|
239
|
+
处理:
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
clawshire auth show
|
|
243
|
+
clawshire auth clear-key
|
|
244
|
+
clawshire auth set-key <your_api_key>
|
|
245
|
+
clawshire user info
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
如果你使用环境变量,也检查:
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
echo $CLAWSHIRE_API_KEY
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### 2. `配置错误: 该命令需要 API Key`
|
|
255
|
+
|
|
256
|
+
原因:
|
|
257
|
+
|
|
258
|
+
1. 你在调用需要认证的命令,但还没有配置 Key
|
|
259
|
+
|
|
260
|
+
处理:
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
clawshire auth set-key <your_api_key>
|
|
264
|
+
clawshire user info
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
或者临时传参:
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
clawshire --api-key <your_api_key> user info
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### 3. `unrecognized arguments: --output json`
|
|
274
|
+
|
|
275
|
+
原因:
|
|
276
|
+
|
|
277
|
+
1. 把全局参数写在了子命令后面
|
|
278
|
+
|
|
279
|
+
错误示例:
|
|
280
|
+
|
|
281
|
+
```bash
|
|
282
|
+
clawshire notice search --start-date 2026-04-19 --end-date 2026-04-20 --output json
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
正确写法:
|
|
286
|
+
|
|
287
|
+
```bash
|
|
288
|
+
clawshire --output json notice search --start-date 2026-04-19 --end-date 2026-04-20
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
### 4. `未找到与 XXX 匹配的年报`
|
|
292
|
+
|
|
293
|
+
原因:
|
|
294
|
+
|
|
295
|
+
1. 公司代码或简称不匹配
|
|
296
|
+
2. 指定年份没有收录对应年报
|
|
297
|
+
3. 交易所筛选过窄
|
|
298
|
+
|
|
299
|
+
处理:
|
|
300
|
+
|
|
301
|
+
```bash
|
|
302
|
+
clawshire annual-report latest --year 2025 --keyword 平安银行
|
|
303
|
+
clawshire annual-report latest --year 2025 --keyword 000001
|
|
304
|
+
clawshire annual-analysis company 000001 --year 2025
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
如果仍然找不到,先去掉 `--exchange` 再试。
|
|
308
|
+
|
|
309
|
+
### 5. `查询分析任务` 没返回结果
|
|
310
|
+
|
|
311
|
+
原因:
|
|
312
|
+
|
|
313
|
+
1. `annual-analysis get` 传错了 ID
|
|
314
|
+
2. `pdf-file/pdf-url` 用的是 `job_id`
|
|
315
|
+
3. `company` 用的是平台异步分析 `task_id`
|
|
316
|
+
|
|
317
|
+
处理:
|
|
318
|
+
|
|
319
|
+
1. `pdf-file` / `pdf-url` 后续查询传 `job_id`
|
|
320
|
+
2. `company` 后续查询优先传 `task_id`
|
|
321
|
+
|
|
322
|
+
示例:
|
|
323
|
+
|
|
324
|
+
```bash
|
|
325
|
+
clawshire annual-analysis get 123
|
|
326
|
+
clawshire annual-analysis get job_xxx
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
## 发布流程
|
|
330
|
+
|
|
331
|
+
### 版本更新
|
|
332
|
+
|
|
333
|
+
1. 修改 [pyproject.toml](/Users/memect/work/code/sz_extract/hermeshub/clawshire-cli/pyproject.toml) 中的 `version`
|
|
334
|
+
2. 如有命令变化,同步更新本 README 与根目录 [README.md](/Users/memect/work/code/sz_extract/hermeshub/README.md)
|
|
335
|
+
|
|
336
|
+
### 本地构建
|
|
337
|
+
|
|
338
|
+
```bash
|
|
339
|
+
cd clawshire-cli
|
|
340
|
+
uv build
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
构建产物位于:
|
|
344
|
+
|
|
345
|
+
```text
|
|
346
|
+
clawshire-cli/dist/
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
### 本地安装验证
|
|
350
|
+
|
|
351
|
+
```bash
|
|
352
|
+
cd clawshire-cli
|
|
353
|
+
uv tool install .
|
|
354
|
+
clawshire --help
|
|
355
|
+
cs --help
|
|
356
|
+
clawshire notice search --start-date 2026-04-19 --end-date 2026-04-20
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
如果本机已安装 `pipx`,再补一轮:
|
|
360
|
+
|
|
361
|
+
```bash
|
|
362
|
+
cd clawshire-cli
|
|
363
|
+
pipx install .
|
|
364
|
+
clawshire --help
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
### 发布测试版
|
|
368
|
+
|
|
369
|
+
建议先发测试版,再发正式版,例如:
|
|
370
|
+
|
|
371
|
+
1. `0.1.0a1`
|
|
372
|
+
2. `0.1.0b1`
|
|
373
|
+
3. `0.1.0rc1`
|
|
374
|
+
|
|
375
|
+
发布命令示例:
|
|
376
|
+
|
|
377
|
+
```bash
|
|
378
|
+
cd clawshire-cli
|
|
379
|
+
uv build
|
|
380
|
+
uv publish
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
如果要发到 TestPyPI:
|
|
384
|
+
|
|
385
|
+
```bash
|
|
386
|
+
cd clawshire-cli
|
|
387
|
+
uv build
|
|
388
|
+
uv publish --publish-url https://test.pypi.org/legacy/
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
如果要发到内网 PyPI:
|
|
392
|
+
|
|
393
|
+
```bash
|
|
394
|
+
cd clawshire-cli
|
|
395
|
+
uv build
|
|
396
|
+
uv publish --publish-url http://192.168.41.95:8141/memect/dev
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
安装验证:
|
|
400
|
+
|
|
401
|
+
```bash
|
|
402
|
+
uv tool install --index memect clawshire-cli
|
|
403
|
+
clawshire --help
|
|
404
|
+
cs --help
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
### 当前仓库内的限制
|
|
408
|
+
|
|
409
|
+
当前仓库环境没有可用的公共 PyPI / TestPyPI 发布凭据。
|
|
410
|
+
如果存在内网源可直连且无需额外凭据,则可以直接发布到内网源并验证安装链路。
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
clawshire_cli/__init__.py,sha256=da1PTClDMl-IBkrSvq6JC1lnS-K_BASzCvxVhNxN5Ls,13
|
|
2
|
+
clawshire_cli/config.py,sha256=ch5LcNK4_ApdWYY4y3h-r8kdEB0II09gQDRXi334BFA,1753
|
|
3
|
+
clawshire_cli/context.py,sha256=kpfRrprafoOm77Hr0gElnmBnYgsGTe3czfzeRzMNqps,629
|
|
4
|
+
clawshire_cli/main.py,sha256=9oXNRiNY4NAfBPvN8i7fq-82WOlG4lZU3vxLK6FWWlc,2063
|
|
5
|
+
clawshire_cli/output.py,sha256=LP01YtB5ytMyFGBKkJ913TF9WQ_tovLByTtREjDTNi4,3343
|
|
6
|
+
clawshire_cli/commands/__init__.py,sha256=da1PTClDMl-IBkrSvq6JC1lnS-K_BASzCvxVhNxN5Ls,13
|
|
7
|
+
clawshire_cli/commands/annual_analysis.py,sha256=UG2o4Abj24VVCwkoIJQRaLwwDqDip_1OwjpwDfr3_Og,3863
|
|
8
|
+
clawshire_cli/commands/annual_report.py,sha256=MSKViEF6W2Guj0D3GEtrjvwRU-n-2AEgEppD1NLpcTw,1640
|
|
9
|
+
clawshire_cli/commands/auth.py,sha256=R4KsV0hj_zFII_8S-XOuBFqKahevsj_Naq1YfX_Q-mU,2078
|
|
10
|
+
clawshire_cli/commands/notice.py,sha256=leuZm0OJHrHHbHrWE6ok-Mi5xEQxyl4whtSrwopRWug,2328
|
|
11
|
+
clawshire_cli/commands/user.py,sha256=5j9ENnUJ_kZIXwlvt7f-I2VtAEk-eGnwcaBatsdXrrM,788
|
|
12
|
+
clawshire_sdk/__init__.py,sha256=Djxzwp60qYpiUw3yzBBDojdZv8zZv8LhDKSxLHJM-4g,332
|
|
13
|
+
clawshire_sdk/client.py,sha256=y6Cyc5Qmqxru1YX0iH3rG0EW3MZj95IHNvTpeOguVns,4103
|
|
14
|
+
clawshire_sdk/errors.py,sha256=UFZkh8peISPf3q-WylS6IblHs0-7WD7AmGjutyRRmLE,619
|
|
15
|
+
clawshire_sdk/domains/__init__.py,sha256=9rNGg008368ApuC31DOP9rFF8BlDJ8KdHNpH8AhleXE,177
|
|
16
|
+
clawshire_sdk/domains/annual_reports.py,sha256=r-7-w2ceNHSlkmZJr6XHoUNDa42kuHpgLsnj_JVFVaE,4602
|
|
17
|
+
clawshire_sdk/domains/filings.py,sha256=TIjK9L0Kv1RbZLO_T6YsafQz39IREDV4MrwLUrojhd0,1482
|
|
18
|
+
clawshire_cli-0.1.0a2.dist-info/METADATA,sha256=BVPcHY9nCOIvsqjXbuUMmpNo2nsz0koXcbaG29g4WLk,9671
|
|
19
|
+
clawshire_cli-0.1.0a2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
20
|
+
clawshire_cli-0.1.0a2.dist-info/entry_points.txt,sha256=F5_iwYGiXfJoZYApY0Iggq94mQoWD0X96mmPw8RHX7s,83
|
|
21
|
+
clawshire_cli-0.1.0a2.dist-info/top_level.txt,sha256=VUvb6M2T4sGoyZf9uvZrSkW9QwjXQvQmIQokT-mpRLc,28
|
|
22
|
+
clawshire_cli-0.1.0a2.dist-info/RECORD,,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from clawshire_sdk.client import ClawShireClient
|
|
2
|
+
from clawshire_sdk.errors import (
|
|
3
|
+
ClawShireApiError,
|
|
4
|
+
ClawShireAuthError,
|
|
5
|
+
ClawShireConfigError,
|
|
6
|
+
ClawShireNetworkError,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"ClawShireApiError",
|
|
11
|
+
"ClawShireAuthError",
|
|
12
|
+
"ClawShireClient",
|
|
13
|
+
"ClawShireConfigError",
|
|
14
|
+
"ClawShireNetworkError",
|
|
15
|
+
]
|
clawshire_sdk/client.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from clawshire_sdk.domains import AnnualReportsDomain, FilingsDomain
|
|
8
|
+
from clawshire_sdk.errors import (
|
|
9
|
+
ClawShireApiError,
|
|
10
|
+
ClawShireAuthError,
|
|
11
|
+
ClawShireConfigError,
|
|
12
|
+
ClawShireNetworkError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ClawShireClient:
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
*,
|
|
20
|
+
base_url: str,
|
|
21
|
+
api_key: str | None = None,
|
|
22
|
+
timeout: float = 30.0,
|
|
23
|
+
):
|
|
24
|
+
self.base_url = base_url.rstrip("/")
|
|
25
|
+
self.api_key = api_key
|
|
26
|
+
self.timeout = timeout
|
|
27
|
+
self.filings = FilingsDomain(self)
|
|
28
|
+
self.annual = AnnualReportsDomain(self)
|
|
29
|
+
|
|
30
|
+
def get(
|
|
31
|
+
self,
|
|
32
|
+
path: str,
|
|
33
|
+
*,
|
|
34
|
+
params: dict[str, Any] | None = None,
|
|
35
|
+
auth_required: bool,
|
|
36
|
+
unwrap: bool = True,
|
|
37
|
+
) -> dict[str, Any]:
|
|
38
|
+
return self._request("GET", path, params=params, auth_required=auth_required, unwrap=unwrap)
|
|
39
|
+
|
|
40
|
+
def post(
|
|
41
|
+
self,
|
|
42
|
+
path: str,
|
|
43
|
+
*,
|
|
44
|
+
json: dict[str, Any] | None = None,
|
|
45
|
+
data: dict[str, Any] | None = None,
|
|
46
|
+
files: dict[str, Any] | None = None,
|
|
47
|
+
auth_required: bool,
|
|
48
|
+
unwrap: bool = True,
|
|
49
|
+
) -> dict[str, Any]:
|
|
50
|
+
return self._request(
|
|
51
|
+
"POST",
|
|
52
|
+
path,
|
|
53
|
+
json=json,
|
|
54
|
+
data=data,
|
|
55
|
+
files=files,
|
|
56
|
+
auth_required=auth_required,
|
|
57
|
+
unwrap=unwrap,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def _request(
|
|
61
|
+
self,
|
|
62
|
+
method: str,
|
|
63
|
+
path: str,
|
|
64
|
+
*,
|
|
65
|
+
params: dict[str, Any] | None = None,
|
|
66
|
+
json: dict[str, Any] | None = None,
|
|
67
|
+
data: dict[str, Any] | None = None,
|
|
68
|
+
files: dict[str, Any] | None = None,
|
|
69
|
+
auth_required: bool,
|
|
70
|
+
unwrap: bool = True,
|
|
71
|
+
) -> dict[str, Any]:
|
|
72
|
+
headers = self._build_headers(auth_required=auth_required)
|
|
73
|
+
url = f"{self.base_url}{path}"
|
|
74
|
+
try:
|
|
75
|
+
with httpx.Client(timeout=self.timeout) as client:
|
|
76
|
+
response = client.request(
|
|
77
|
+
method,
|
|
78
|
+
url,
|
|
79
|
+
headers=headers,
|
|
80
|
+
params=params,
|
|
81
|
+
json=json,
|
|
82
|
+
data=data,
|
|
83
|
+
files=files,
|
|
84
|
+
)
|
|
85
|
+
except httpx.HTTPError as exc:
|
|
86
|
+
raise ClawShireNetworkError(str(exc)) from exc
|
|
87
|
+
|
|
88
|
+
payload = self._decode_payload(response)
|
|
89
|
+
if response.status_code in (401, 403):
|
|
90
|
+
raise ClawShireAuthError(self._extract_message(payload, fallback="认证失败"))
|
|
91
|
+
if response.status_code >= 400:
|
|
92
|
+
raise ClawShireApiError(
|
|
93
|
+
self._extract_message(payload, fallback=f"请求失败: HTTP {response.status_code}"),
|
|
94
|
+
status_code=response.status_code,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if unwrap and isinstance(payload, dict) and "code" in payload and "data" in payload:
|
|
98
|
+
code = payload.get("code")
|
|
99
|
+
if code != 200:
|
|
100
|
+
raise ClawShireApiError(self._extract_message(payload, fallback="业务请求失败"))
|
|
101
|
+
return payload["data"]
|
|
102
|
+
return payload
|
|
103
|
+
|
|
104
|
+
def _build_headers(self, *, auth_required: bool) -> dict[str, str]:
|
|
105
|
+
headers: dict[str, str] = {"Accept": "application/json"}
|
|
106
|
+
if self.api_key:
|
|
107
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
108
|
+
elif auth_required:
|
|
109
|
+
raise ClawShireConfigError("该命令需要 API Key。请设置 CLAWSHIRE_API_KEY 或使用 --api-key。")
|
|
110
|
+
return headers
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def _decode_payload(response: httpx.Response) -> dict[str, Any]:
|
|
114
|
+
try:
|
|
115
|
+
return response.json()
|
|
116
|
+
except ValueError as exc:
|
|
117
|
+
raise ClawShireApiError("上游返回了非 JSON 响应", status_code=response.status_code) from exc
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def _extract_message(payload: Any, *, fallback: str) -> str:
|
|
121
|
+
if isinstance(payload, dict):
|
|
122
|
+
for key in ("message", "detail", "error"):
|
|
123
|
+
value = payload.get(key)
|
|
124
|
+
if isinstance(value, str) and value.strip():
|
|
125
|
+
return value
|
|
126
|
+
return fallback
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from clawshire_sdk.errors import ClawShireApiError, ClawShireNetworkError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AnnualReportsDomain:
|
|
13
|
+
def __init__(self, client):
|
|
14
|
+
self._client = client
|
|
15
|
+
|
|
16
|
+
def latest(
|
|
17
|
+
self,
|
|
18
|
+
*,
|
|
19
|
+
page: int = 1,
|
|
20
|
+
page_size: int = 20,
|
|
21
|
+
year: int | None = None,
|
|
22
|
+
exchange: str | None = None,
|
|
23
|
+
keyword: str | None = None,
|
|
24
|
+
) -> dict[str, Any]:
|
|
25
|
+
params: dict[str, Any] = {"page": page, "page_size": page_size}
|
|
26
|
+
if year is not None:
|
|
27
|
+
params["year"] = year
|
|
28
|
+
if exchange:
|
|
29
|
+
params["exchange"] = exchange
|
|
30
|
+
if keyword:
|
|
31
|
+
params["keyword"] = keyword
|
|
32
|
+
return self._client.get("/api/v1/annual-report/latest", params=params, auth_required=True)
|
|
33
|
+
|
|
34
|
+
def data(self, met_uuid: str) -> dict[str, Any]:
|
|
35
|
+
return self._client.get(f"/api/v1/annual-report/data/{met_uuid}", auth_required=True)
|
|
36
|
+
|
|
37
|
+
def analyze_submit(self, pdf_path: str, *, lang: str = "zh") -> dict[str, Any]:
|
|
38
|
+
path = Path(pdf_path)
|
|
39
|
+
content = path.read_bytes()
|
|
40
|
+
return self.analyze_submit_bytes(content, filename=path.name, lang=lang)
|
|
41
|
+
|
|
42
|
+
def analyze_submit_bytes(
|
|
43
|
+
self,
|
|
44
|
+
content: bytes,
|
|
45
|
+
*,
|
|
46
|
+
filename: str = "report.pdf",
|
|
47
|
+
lang: str = "zh",
|
|
48
|
+
) -> dict[str, Any]:
|
|
49
|
+
files = {"file": (filename, content, "application/pdf")}
|
|
50
|
+
data = {"lang": lang}
|
|
51
|
+
return self._client.post(
|
|
52
|
+
"/api/v1/financial-analysis/jobs",
|
|
53
|
+
files=files,
|
|
54
|
+
data=data,
|
|
55
|
+
auth_required=True,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def analyze_submit_pdf_url(self, pdf_url: str, *, lang: str = "zh") -> dict[str, Any]:
|
|
59
|
+
try:
|
|
60
|
+
with httpx.Client(timeout=self._client.timeout) as client:
|
|
61
|
+
response = client.get(pdf_url)
|
|
62
|
+
response.raise_for_status()
|
|
63
|
+
except httpx.HTTPError as exc:
|
|
64
|
+
raise ClawShireNetworkError(f"下载 PDF 失败: {exc}") from exc
|
|
65
|
+
|
|
66
|
+
filename = Path(urlparse(pdf_url).path).name or "report.pdf"
|
|
67
|
+
return self.analyze_submit_bytes(response.content, filename=filename, lang=lang)
|
|
68
|
+
|
|
69
|
+
def analyze_get(self, job_id: str) -> dict[str, Any]:
|
|
70
|
+
return self._client.get(
|
|
71
|
+
f"/api/v1/financial-analysis/jobs/{job_id}",
|
|
72
|
+
auth_required=True,
|
|
73
|
+
unwrap=False,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def analyze_company(
|
|
77
|
+
self,
|
|
78
|
+
keyword: str,
|
|
79
|
+
*,
|
|
80
|
+
year: int | None = None,
|
|
81
|
+
exchange: str | None = None,
|
|
82
|
+
notify_email: str | None = None,
|
|
83
|
+
) -> dict[str, Any]:
|
|
84
|
+
reports = self.latest(page=1, page_size=20, year=year, exchange=exchange, keyword=keyword)
|
|
85
|
+
items = reports.get("items", [])
|
|
86
|
+
if not items:
|
|
87
|
+
if year is not None:
|
|
88
|
+
raise ClawShireApiError(f"未找到与 {keyword} 匹配的 {year} 年年报")
|
|
89
|
+
raise ClawShireApiError(f"未找到与 {keyword} 匹配的年报")
|
|
90
|
+
|
|
91
|
+
selected = self._pick_report(items, keyword)
|
|
92
|
+
payload: dict[str, Any] = {}
|
|
93
|
+
if notify_email:
|
|
94
|
+
payload["notify_email"] = notify_email
|
|
95
|
+
|
|
96
|
+
task = self._client.post(
|
|
97
|
+
f"/api/v1/annual-report/analyze/{selected['met_uuid']}",
|
|
98
|
+
json=payload,
|
|
99
|
+
auth_required=True,
|
|
100
|
+
)
|
|
101
|
+
return {
|
|
102
|
+
**task,
|
|
103
|
+
"selected_report": {
|
|
104
|
+
"met_uuid": selected.get("met_uuid"),
|
|
105
|
+
"company_code": selected.get("company_code"),
|
|
106
|
+
"company_name": selected.get("company_name"),
|
|
107
|
+
"pdf_url": selected.get("pdf_url"),
|
|
108
|
+
"publish_time": selected.get("publish_time"),
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
def get_analysis_task(self, task_id: int) -> dict[str, Any]:
|
|
113
|
+
tasks = self._client.get("/api/v1/annual-report/analysis-tasks", auth_required=True)
|
|
114
|
+
if isinstance(tasks, list):
|
|
115
|
+
for task in tasks:
|
|
116
|
+
if int(task.get("task_id", -1)) == int(task_id):
|
|
117
|
+
return task
|
|
118
|
+
raise ClawShireApiError(f"未找到 task_id={task_id} 的分析任务")
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def _pick_report(items: list[dict[str, Any]], keyword: str) -> dict[str, Any]:
|
|
122
|
+
normalized = keyword.strip().lower()
|
|
123
|
+
for item in items:
|
|
124
|
+
if str(item.get("company_code", "")).strip().lower() == normalized:
|
|
125
|
+
return item
|
|
126
|
+
for item in items:
|
|
127
|
+
if str(item.get("company_name", "")).strip().lower() == normalized:
|
|
128
|
+
return item
|
|
129
|
+
return items[0]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FilingsDomain:
|
|
7
|
+
def __init__(self, client):
|
|
8
|
+
self._client = client
|
|
9
|
+
|
|
10
|
+
def search(
|
|
11
|
+
self,
|
|
12
|
+
*,
|
|
13
|
+
start_date: str,
|
|
14
|
+
end_date: str,
|
|
15
|
+
infotype: str | None = None,
|
|
16
|
+
page: int = 1,
|
|
17
|
+
page_size: int = 20,
|
|
18
|
+
) -> dict[str, Any]:
|
|
19
|
+
params = {
|
|
20
|
+
"start_date": start_date,
|
|
21
|
+
"end_date": end_date,
|
|
22
|
+
"page": page,
|
|
23
|
+
"page_size": page_size,
|
|
24
|
+
}
|
|
25
|
+
if infotype:
|
|
26
|
+
params["infotype"] = infotype
|
|
27
|
+
return self._client.get("/api/v1/announcements", params=params, auth_required=False)
|
|
28
|
+
|
|
29
|
+
def stock(
|
|
30
|
+
self,
|
|
31
|
+
sec_code: str,
|
|
32
|
+
*,
|
|
33
|
+
start_date: str,
|
|
34
|
+
end_date: str,
|
|
35
|
+
infotype: str | None = None,
|
|
36
|
+
page: int = 1,
|
|
37
|
+
page_size: int = 20,
|
|
38
|
+
) -> dict[str, Any]:
|
|
39
|
+
params = {
|
|
40
|
+
"start_date": start_date,
|
|
41
|
+
"end_date": end_date,
|
|
42
|
+
"page": page,
|
|
43
|
+
"page_size": page_size,
|
|
44
|
+
}
|
|
45
|
+
if infotype:
|
|
46
|
+
params["infotype"] = infotype
|
|
47
|
+
return self._client.get(
|
|
48
|
+
f"/api/v1/stock/{sec_code}/announcements",
|
|
49
|
+
params=params,
|
|
50
|
+
auth_required=False,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def link(self, met_link: str) -> dict[str, Any]:
|
|
54
|
+
return self._client.get(
|
|
55
|
+
"/api/v1/met_link",
|
|
56
|
+
params={"met_link": met_link},
|
|
57
|
+
auth_required=False,
|
|
58
|
+
)
|
clawshire_sdk/errors.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class ClawShireError(Exception):
|
|
2
|
+
"""Base exception for SDK and CLI errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ClawShireConfigError(ClawShireError):
|
|
6
|
+
"""Missing or invalid local configuration."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ClawShireAuthError(ClawShireError):
|
|
10
|
+
"""Authentication or authorization failed."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ClawShireApiError(ClawShireError):
|
|
14
|
+
"""The upstream API returned an error response."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, message: str, status_code: int | None = None):
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.status_code = status_code
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ClawShireNetworkError(ClawShireError):
|
|
22
|
+
"""Network failure while calling the upstream API."""
|