ankiweb-api 1.0.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.
- ankicli.py +214 -0
- ankiweb_api-1.0.0.dist-info/METADATA +574 -0
- ankiweb_api-1.0.0.dist-info/RECORD +14 -0
- ankiweb_api-1.0.0.dist-info/WHEEL +4 -0
- ankiweb_api-1.0.0.dist-info/entry_points.txt +4 -0
- ankiweb_api-1.0.0.dist-info/licenses/LICENSE +21 -0
- ankiweb_auth.py +148 -0
- ankiweb_client.py +405 -0
- ankiweb_engine/index.js +44894 -0
- ankiweb_proto.py +372 -0
- ankiweb_schema.py +49 -0
- make_test_addon.py +66 -0
- terminal_ankiweb.py +342 -0
- upload.py +195 -0
ankicli.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Terminal CLI for AnkiWeb: info, download, mine, + full /svc/* access.
|
|
3
|
+
|
|
4
|
+
./ankicli.py info 805854302
|
|
5
|
+
./ankicli.py download 805854302 -o /tmp/test.ankiaddon
|
|
6
|
+
./ankicli.py mine # your shared decks + add-ons (needs login)
|
|
7
|
+
./ankicli.py call shared_item_info --query sharedId=805854302
|
|
8
|
+
./ankicli.py call shared_list_addons
|
|
9
|
+
./ankicli.py endpoints # list all 41 endpoints
|
|
10
|
+
|
|
11
|
+
Uploads go through ./upload.py (thin wrapper over the forked upstream action).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import datetime
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from ankiweb_auth import AuthError, default_env_file, load_dotenv, login
|
|
23
|
+
from terminal_ankiweb import (
|
|
24
|
+
AnkiWebError,
|
|
25
|
+
NotFoundError,
|
|
26
|
+
download_addon,
|
|
27
|
+
fetch_item_info,
|
|
28
|
+
fetch_list_mine,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
HERE = Path(__file__).resolve().parent
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def cmd_info(args) -> int:
|
|
35
|
+
try:
|
|
36
|
+
info = fetch_item_info(args.addon_id)
|
|
37
|
+
except NotFoundError as e:
|
|
38
|
+
print(f"error: {e}", file=sys.stderr)
|
|
39
|
+
return 1
|
|
40
|
+
except AnkiWebError as e:
|
|
41
|
+
print(f"error: {e}", file=sys.stderr)
|
|
42
|
+
return 1
|
|
43
|
+
print(f"id: {info['shared_id']}")
|
|
44
|
+
print(f"title: {info['title']}")
|
|
45
|
+
if info.get("tags"):
|
|
46
|
+
print(f"tags: {info['tags'].strip()}")
|
|
47
|
+
if info.get("support_url"):
|
|
48
|
+
print(f"support: {info['support_url']}")
|
|
49
|
+
print(f"size: {info['size']}")
|
|
50
|
+
if info["last_updated"]:
|
|
51
|
+
dt = datetime.datetime.fromtimestamp(info["last_updated"], datetime.timezone.utc)
|
|
52
|
+
print(f"last_updated: {info['last_updated']} ({dt.isoformat()})")
|
|
53
|
+
else:
|
|
54
|
+
print("last_updated: 0")
|
|
55
|
+
if info["versions"]:
|
|
56
|
+
print(f"versions: {', '.join(info['versions'])}")
|
|
57
|
+
print("description:")
|
|
58
|
+
print(info["description"] or "(empty)")
|
|
59
|
+
# AnkiWeb caches item-info for 10 minutes, so the page link needs the same
|
|
60
|
+
# `cb` cache-buster the CLI just read with, or the browser shows stale data.
|
|
61
|
+
cb = info.get("cache_bust")
|
|
62
|
+
if cb:
|
|
63
|
+
print(f"verify: https://ankiweb.net/shared/info/{info['shared_id']}?cb={cb}")
|
|
64
|
+
if args.verbose:
|
|
65
|
+
print(f"timestamps_seen: {info['timestamps_seen']}")
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def cmd_download(args) -> int:
|
|
70
|
+
try:
|
|
71
|
+
meta = download_addon(args.addon_id, dest=args.output, point_version=args.point_version)
|
|
72
|
+
except AnkiWebError as e:
|
|
73
|
+
print(f"error: {e}", file=sys.stderr)
|
|
74
|
+
return 1
|
|
75
|
+
print(f"saved: {meta['path']}")
|
|
76
|
+
print(f"filename: {meta['filename']}")
|
|
77
|
+
print(f"size: {meta['size']}")
|
|
78
|
+
print(f"t={meta['t']} minpt={meta['minpt']} maxpt={meta['maxpt']} bidx={meta['bidx']}")
|
|
79
|
+
print(f"via: {meta['final_url']}")
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def load_creds(env_file: str = "") -> tuple[str, str]:
|
|
84
|
+
env_path = Path(env_file).expanduser() if env_file else default_env_file()
|
|
85
|
+
load_dotenv(env_path)
|
|
86
|
+
u = os.environ.get("ANKIWEB_USERNAME", "")
|
|
87
|
+
p = os.environ.get("ANKIWEB_PASSWORD", "")
|
|
88
|
+
missing = [n for n, v in (("ANKIWEB_USERNAME", u), ("ANKIWEB_PASSWORD", p)) if not v]
|
|
89
|
+
if missing:
|
|
90
|
+
raise AuthError(
|
|
91
|
+
f"missing {' and '.join(missing)} — set them in {env_path} or the environment"
|
|
92
|
+
)
|
|
93
|
+
return u, p
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def cmd_mine(args) -> int:
|
|
97
|
+
try:
|
|
98
|
+
user, password = load_creds(args.env_file)
|
|
99
|
+
cookie = login(user, password)
|
|
100
|
+
data = fetch_list_mine(cookie)
|
|
101
|
+
except (AuthError, AnkiWebError) as e:
|
|
102
|
+
print(f"error: {e}", file=sys.stderr)
|
|
103
|
+
return 1
|
|
104
|
+
|
|
105
|
+
items = data["items"]
|
|
106
|
+
if not items:
|
|
107
|
+
print("no shared items")
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
header = f"{'ID':>12} {'UPD':<10} {'DL':>6} {'+':>3} {'-':>3} TITLE"
|
|
111
|
+
print(f"{len(items)} shared item(s), {data.get('review_count', 0)} review(s):\n")
|
|
112
|
+
print(header)
|
|
113
|
+
print("-" * len(header))
|
|
114
|
+
for it in items:
|
|
115
|
+
mtime = (
|
|
116
|
+
datetime.datetime.fromtimestamp(it["mtime"], datetime.timezone.utc).strftime("%Y-%m-%d")
|
|
117
|
+
if it["mtime"] else "n/a"
|
|
118
|
+
)
|
|
119
|
+
print(
|
|
120
|
+
f"{it['id']:>12} {mtime:<10} {it['downloads']:>6} "
|
|
121
|
+
f"{it['thumbs_up']:>3} {it['thumbs_down']:>3} {it['title']}"
|
|
122
|
+
)
|
|
123
|
+
if args.json:
|
|
124
|
+
import json as _json
|
|
125
|
+
|
|
126
|
+
print("\n" + _json.dumps(items, indent=2))
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def cmd_endpoints(args) -> int:
|
|
131
|
+
from ankiweb_schema import public_endpoints
|
|
132
|
+
for ep in public_endpoints():
|
|
133
|
+
print(f"{ep['method']:4} {ep['path']:40} req={ep['request']} resp={ep['response']}")
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def cmd_call(args) -> int:
|
|
138
|
+
import json as _json
|
|
139
|
+
from ankiweb_client import AnkiWebClient, AnkiWebError, _method_name
|
|
140
|
+
from ankiweb_schema import endpoint as _endpoint, public_endpoints
|
|
141
|
+
|
|
142
|
+
# find endpoint by method name
|
|
143
|
+
target = None
|
|
144
|
+
for ep in public_endpoints():
|
|
145
|
+
if _method_name(ep["path"]) == args.method or ep["path"] == args.method:
|
|
146
|
+
target = ep
|
|
147
|
+
break
|
|
148
|
+
if target is None:
|
|
149
|
+
print(f"error: unknown method '{args.method}' (see: ankicli.py endpoints)",
|
|
150
|
+
file=sys.stderr)
|
|
151
|
+
return 1
|
|
152
|
+
query = {}
|
|
153
|
+
for kv in args.query:
|
|
154
|
+
if "=" not in kv:
|
|
155
|
+
print(f"error: --query must be K=V, got '{kv}'", file=sys.stderr)
|
|
156
|
+
return 1
|
|
157
|
+
k, v = kv.split("=", 1)
|
|
158
|
+
try:
|
|
159
|
+
v = int(v)
|
|
160
|
+
except ValueError:
|
|
161
|
+
pass
|
|
162
|
+
query[k] = v
|
|
163
|
+
request = _json.loads(args.request) if args.request else None
|
|
164
|
+
client = AnkiWebClient(cookie=args.cookie or None,
|
|
165
|
+
env_file=args.env_file or None)
|
|
166
|
+
try:
|
|
167
|
+
out = client._call(target["path"], request,
|
|
168
|
+
query=query or None)
|
|
169
|
+
except AnkiWebError as e:
|
|
170
|
+
print(f"error: {e}", file=sys.stderr)
|
|
171
|
+
return 1
|
|
172
|
+
print(_json.dumps(out, indent=2, default=str))
|
|
173
|
+
return 0
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def main() -> int:
|
|
177
|
+
ap = argparse.ArgumentParser(description="AnkiWeb terminal: full /svc/* access")
|
|
178
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
179
|
+
|
|
180
|
+
p = sub.add_parser("info", help="fetch public title/description/size via svc/shared/item-info")
|
|
181
|
+
p.add_argument("addon_id", type=int)
|
|
182
|
+
p.add_argument("-v", "--verbose", action="store_true")
|
|
183
|
+
p.set_defaults(fn=cmd_info)
|
|
184
|
+
|
|
185
|
+
p = sub.add_parser("download", help="download .ankiaddon via /shared/download/<id>")
|
|
186
|
+
p.add_argument("addon_id", type=int)
|
|
187
|
+
p.add_argument("-o", "--output", default=None)
|
|
188
|
+
p.add_argument("--point-version", type=int, default=250902,
|
|
189
|
+
help="Anki int_version, e.g. 250902 for 25.09.02 (default: 250902)")
|
|
190
|
+
p.set_defaults(fn=cmd_download)
|
|
191
|
+
|
|
192
|
+
p = sub.add_parser("mine", help="list items shared by your account (needs login)")
|
|
193
|
+
p.add_argument("--env-file", default="", help="default: ./.env")
|
|
194
|
+
p.add_argument("--json", action="store_true", help="also print raw JSON")
|
|
195
|
+
p.set_defaults(fn=cmd_mine)
|
|
196
|
+
|
|
197
|
+
p = sub.add_parser("endpoints", help="list all 41 public endpoints")
|
|
198
|
+
p.set_defaults(fn=cmd_endpoints)
|
|
199
|
+
|
|
200
|
+
p = sub.add_parser("call", help="call any public endpoint by method name")
|
|
201
|
+
p.add_argument("method", help="e.g. shared_item_info, shared_list_addons, decks_create_deck")
|
|
202
|
+
p.add_argument("--request", default="", help="request body as JSON (for POST)")
|
|
203
|
+
p.add_argument("--query", action="append", default=[], metavar="K=V",
|
|
204
|
+
help="query param (repeatable; for GET)")
|
|
205
|
+
p.add_argument("--env-file", default="", help="default: ./.env (only needed for auth endpoints)")
|
|
206
|
+
p.add_argument("--cookie", default="", help="session cookie (else login via .env for auth endpoints)")
|
|
207
|
+
p.set_defaults(fn=cmd_call)
|
|
208
|
+
|
|
209
|
+
args = ap.parse_args()
|
|
210
|
+
return args.fn(args)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
if __name__ == "__main__":
|
|
214
|
+
raise SystemExit(main())
|