socialscrape-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.
- socialscrape_cli/__init__.py +3 -0
- socialscrape_cli/__main__.py +5 -0
- socialscrape_cli/catalog.json +18929 -0
- socialscrape_cli/catalog.py +166 -0
- socialscrape_cli/cli.py +689 -0
- socialscrape_cli/client.py +104 -0
- socialscrape_cli/config.py +40 -0
- socialscrape_cli/table.py +76 -0
- socialscrape_cli-0.1.0.dist-info/METADATA +76 -0
- socialscrape_cli-0.1.0.dist-info/RECORD +14 -0
- socialscrape_cli-0.1.0.dist-info/WHEEL +5 -0
- socialscrape_cli-0.1.0.dist-info/entry_points.txt +2 -0
- socialscrape_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- socialscrape_cli-0.1.0.dist-info/top_level.txt +1 -0
socialscrape_cli/cli.py
ADDED
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
"""socialscrape: social media data from the command line.
|
|
2
|
+
|
|
3
|
+
socialscrape github user torvalds
|
|
4
|
+
socialscrape transcript https://www.youtube.com/watch?v=dQw4w9WgXcQ
|
|
5
|
+
socialscrape search threads "open source" --all --max-credits 20
|
|
6
|
+
socialscrape facebook --help # the resources of a platform
|
|
7
|
+
socialscrape facebook profile-posts --help
|
|
8
|
+
|
|
9
|
+
Commands and platforms come from the generated catalog (stubs excluded), so
|
|
10
|
+
this file names no platform. Results go to stdout; the price of each call and
|
|
11
|
+
anything else for humans go to stderr, so `socialscrape ... | jq` stays clean.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import difflib
|
|
18
|
+
import getpass
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import IO, Any
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
from . import __version__, config, table
|
|
28
|
+
from .catalog import Catalog, Endpoint, Param, load_catalog
|
|
29
|
+
from .client import ApiClient, ApiResponse
|
|
30
|
+
|
|
31
|
+
EXIT_OK, EXIT_API, EXIT_USAGE, EXIT_AUTH, EXIT_RETRY = 0, 1, 2, 3, 4
|
|
32
|
+
AUTH_CODES = {"unauthorized", "forbidden", "insufficient_credits"}
|
|
33
|
+
GLOBAL_FLAGS = {"key", "api_url", "output", "json", "table", "ndjson", "envelope", "all", "pages", "max_pages",
|
|
34
|
+
"max_items", "max_credits", "quiet", "timeout", "raw", "help", "version", "dry_run"}
|
|
35
|
+
CROSS = {"profile": "profile", "post": "post", "comments": "comments", "search": "search"}
|
|
36
|
+
NATIVE_COMMANDS = ("credits", "usage", "job", "batch", "monitors")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class UsageError(Exception):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Parser(argparse.ArgumentParser):
|
|
44
|
+
"""argparse that reports a problem as UsageError instead of printing and exiting."""
|
|
45
|
+
|
|
46
|
+
def error(self, message: str): # type: ignore[override]
|
|
47
|
+
raise UsageError(message)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Env:
|
|
52
|
+
out: IO[str]
|
|
53
|
+
err: IO[str]
|
|
54
|
+
stdin: IO[str]
|
|
55
|
+
environ: dict[str, str]
|
|
56
|
+
http: httpx.Client | None
|
|
57
|
+
cat: Catalog
|
|
58
|
+
out_tty: bool
|
|
59
|
+
err_tty: bool
|
|
60
|
+
|
|
61
|
+
def say(self, msg: str) -> None:
|
|
62
|
+
print(msg, file=self.err)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def cli_name(s: str) -> str:
|
|
66
|
+
return s.replace("_", "-").replace(".", "-").lower()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------------ global options
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def global_parser() -> argparse.ArgumentParser:
|
|
73
|
+
g = argparse.ArgumentParser(add_help=False, allow_abbrev=False)
|
|
74
|
+
g.add_argument("--key", help="API key (default: $SOCIALSCRAPE_API_KEY, then the saved one)")
|
|
75
|
+
g.add_argument("--api-url", dest="api_url")
|
|
76
|
+
g.add_argument("-o", "--output", choices=("json", "table", "ndjson"))
|
|
77
|
+
g.add_argument("--json", action="store_true")
|
|
78
|
+
g.add_argument("--table", action="store_true")
|
|
79
|
+
g.add_argument("--ndjson", action="store_true")
|
|
80
|
+
g.add_argument("--envelope", action="store_true", help="print the whole response, not just its data")
|
|
81
|
+
g.add_argument("--raw", action="store_true", help="ask for the platform's original object and print the whole response")
|
|
82
|
+
g.add_argument("--all", action="store_true", help="follow cursors to the end (each page costs credits)")
|
|
83
|
+
g.add_argument("--pages", type=int, default=1)
|
|
84
|
+
g.add_argument("--max-pages", dest="max_pages", type=int, default=50)
|
|
85
|
+
g.add_argument("--max-items", dest="max_items", type=int)
|
|
86
|
+
g.add_argument("--max-credits", dest="max_credits", type=int)
|
|
87
|
+
g.add_argument("--dry-run", dest="dry_run", action="store_true", help="show the request and its price; send nothing")
|
|
88
|
+
g.add_argument("-q", "--quiet", action="store_true")
|
|
89
|
+
g.add_argument("--timeout", type=float, default=70.0)
|
|
90
|
+
g.add_argument("--version", action="store_true")
|
|
91
|
+
g.add_argument("-h", "--help", action="store_true")
|
|
92
|
+
return g
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ------------------------------------------------------------------ resource parsers
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _bool(v: str) -> bool:
|
|
99
|
+
if v.lower() in ("1", "true", "yes", "on"):
|
|
100
|
+
return True
|
|
101
|
+
if v.lower() in ("0", "false", "no", "off"):
|
|
102
|
+
return False
|
|
103
|
+
raise argparse.ArgumentTypeError(f"expected true or false, got {v!r}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _json_value(v: str) -> Any:
|
|
107
|
+
try:
|
|
108
|
+
return json.loads(v)
|
|
109
|
+
except ValueError as e:
|
|
110
|
+
raise argparse.ArgumentTypeError(f"expected JSON: {e}") from None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def positional_candidates(ep: Endpoint) -> list[Param]:
|
|
114
|
+
required = [p for p in ep.params if p.required and p.location != "body"]
|
|
115
|
+
if required:
|
|
116
|
+
return required[:1]
|
|
117
|
+
for group in ep.one_of:
|
|
118
|
+
return [p for n in group if (p := ep.param(n)) is not None]
|
|
119
|
+
return []
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def hyphenate(args: list[str]) -> list[str]:
|
|
123
|
+
"""`--max_age_days=7` and `--max-age-days=7` are the same option."""
|
|
124
|
+
out = []
|
|
125
|
+
for a in args:
|
|
126
|
+
if a.startswith("--") and len(a) > 2:
|
|
127
|
+
name, eq, val = a.partition("=")
|
|
128
|
+
a = name.replace("_", "-") + eq + val
|
|
129
|
+
out.append(a)
|
|
130
|
+
return out
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def looks_like_url(v: str) -> bool:
|
|
134
|
+
return v.startswith(("http://", "https://")) or ("/" in v and "." in v.split("/")[0])
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def resource_parser(ep: Endpoint, prog: str) -> argparse.ArgumentParser:
|
|
138
|
+
cands = positional_candidates(ep)
|
|
139
|
+
ap = Parser(prog=prog, allow_abbrev=False, description=f"{ep.summary}. {price(ep)}.\n{ep.description}".strip(),
|
|
140
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
141
|
+
epilog="Global options (anywhere on the line): -o json|table|ndjson, --all, --pages N, "
|
|
142
|
+
"--max-credits N, --raw, --envelope, --key, -q. See `socialscrape --help`.")
|
|
143
|
+
if cands:
|
|
144
|
+
ap.add_argument("value", nargs="?", help="/".join(p.name for p in cands) + " (the form is detected)")
|
|
145
|
+
taken: set[str] = set()
|
|
146
|
+
for p in ep.params:
|
|
147
|
+
if p.name == "raw":
|
|
148
|
+
continue # the global --raw
|
|
149
|
+
opts = [p.name]
|
|
150
|
+
extra = ["max-age"] if p.name == "max_age_days" else list(p.aliases)
|
|
151
|
+
for a in extra:
|
|
152
|
+
if a not in opts and a not in GLOBAL_FLAGS:
|
|
153
|
+
opts.append(a)
|
|
154
|
+
strings: list[str] = []
|
|
155
|
+
for o in opts:
|
|
156
|
+
flag = "--" + o.replace("_", "-") # `--max_age_days` is accepted too: see hyphenate()
|
|
157
|
+
if flag not in taken:
|
|
158
|
+
taken.add(flag)
|
|
159
|
+
strings.append(flag)
|
|
160
|
+
help_ = p.description + (f" One of: {', '.join(p.enum)}." if p.enum else "") + (" (required)" if p.required else "")
|
|
161
|
+
kw: dict[str, Any] = {"dest": p.name, "help": help_}
|
|
162
|
+
if p.type == "boolean":
|
|
163
|
+
ap.add_argument(*strings, nargs="?", const=True, type=_bool, default=None, **kw)
|
|
164
|
+
elif p.type == "integer":
|
|
165
|
+
ap.add_argument(*strings, type=int, default=None, **kw)
|
|
166
|
+
elif p.type == "number" and p.name != "max_age_days":
|
|
167
|
+
ap.add_argument(*strings, type=float, default=None, **kw)
|
|
168
|
+
elif p.type in ("array", "object"):
|
|
169
|
+
ap.add_argument(*strings, type=_json_value, default=None, **kw)
|
|
170
|
+
else:
|
|
171
|
+
ap.add_argument(*strings, default=None, choices=list(p.enum) if p.enum else None, **kw)
|
|
172
|
+
return ap
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def price(ep: Endpoint) -> str:
|
|
176
|
+
return {"free": "Free", "standard": "1 credit", "transcript": "1 credit (AI transcription: 1 up to 2 minutes, then "
|
|
177
|
+
"5 per started minute of the whole video)", "per_5_items": "1 credit per 5 items", "batch": "Sum of its requests"}.get(ep.credits, ep.credits)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def params_from(ep: Endpoint, ns: argparse.Namespace, g: argparse.Namespace) -> dict[str, Any]:
|
|
181
|
+
given = {p.name: getattr(ns, p.name) for p in ep.params if hasattr(ns, p.name) and getattr(ns, p.name) is not None}
|
|
182
|
+
value = getattr(ns, "value", None)
|
|
183
|
+
if value is not None:
|
|
184
|
+
cands = positional_candidates(ep)
|
|
185
|
+
if looks_like_url(value):
|
|
186
|
+
target = next((p for p in cands if p.type == "url"), cands[0])
|
|
187
|
+
else:
|
|
188
|
+
target = next((p for p in cands if p.type != "url"), cands[0])
|
|
189
|
+
if target.name in given:
|
|
190
|
+
raise UsageError(f"{target.name} given twice (as the argument and as --{target.name.replace('_', '-')})")
|
|
191
|
+
given[target.name] = value
|
|
192
|
+
if g.raw and ep.param("raw") is not None:
|
|
193
|
+
given["raw"] = True
|
|
194
|
+
missing = [p.name for p in ep.params if p.required and p.location != "body" and given.get(p.name) in (None, "")]
|
|
195
|
+
if missing:
|
|
196
|
+
raise UsageError(f"{ep.id} needs: {', '.join(missing)}")
|
|
197
|
+
for grp in ep.one_of:
|
|
198
|
+
if not any(given.get(n) not in (None, "") for n in grp):
|
|
199
|
+
raise UsageError(f"{ep.id} needs one of: {' or '.join(grp)}")
|
|
200
|
+
return given
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ------------------------------------------------------------------ running a request
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def key_for(env: Env, g: argparse.Namespace, cfg: dict) -> str | None:
|
|
207
|
+
return g.key or env.environ.get("SOCIALSCRAPE_API_KEY") or cfg.get("api_key")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def exit_code_for(body: dict) -> int:
|
|
211
|
+
err = body.get("error") or {}
|
|
212
|
+
code = err.get("code")
|
|
213
|
+
if code in AUTH_CODES:
|
|
214
|
+
return EXIT_AUTH
|
|
215
|
+
return EXIT_RETRY if err.get("retryable") else EXIT_API
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def emit(env: Env, g: argparse.Namespace, body: dict) -> None:
|
|
219
|
+
"""Print a successful result in the chosen format."""
|
|
220
|
+
fmt = "json"
|
|
221
|
+
if g.table:
|
|
222
|
+
fmt = "table"
|
|
223
|
+
elif g.ndjson:
|
|
224
|
+
fmt = "ndjson"
|
|
225
|
+
elif g.output:
|
|
226
|
+
fmt = g.output
|
|
227
|
+
elif g.json:
|
|
228
|
+
fmt = "json"
|
|
229
|
+
payload: Any = body if (g.envelope or g.raw) else body.get("data")
|
|
230
|
+
if fmt == "table":
|
|
231
|
+
print(table.render(payload.get("data") if (g.envelope or g.raw) else payload), file=env.out)
|
|
232
|
+
elif fmt == "ndjson":
|
|
233
|
+
data = payload.get("data") if (g.envelope or g.raw) else payload
|
|
234
|
+
rows = data.get("items") if isinstance(data, dict) and isinstance(data.get("items"), list) else [data]
|
|
235
|
+
for row in rows:
|
|
236
|
+
print(json.dumps(row, ensure_ascii=False, separators=(",", ":")), file=env.out)
|
|
237
|
+
elif env.out_tty:
|
|
238
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2), file=env.out)
|
|
239
|
+
else:
|
|
240
|
+
print(json.dumps(payload, ensure_ascii=False, separators=(",", ":")), file=env.out)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def footer(env: Env, g: argparse.Namespace, body: dict, charged: int, pages: int) -> None:
|
|
244
|
+
if g.quiet:
|
|
245
|
+
return
|
|
246
|
+
bits = [f"credits charged: {charged}"]
|
|
247
|
+
if body.get("credits_remaining") is not None:
|
|
248
|
+
bits.append(f"remaining: {body['credits_remaining']}")
|
|
249
|
+
if body.get("cached"):
|
|
250
|
+
bits.append("cached")
|
|
251
|
+
if pages > 1:
|
|
252
|
+
bits.append(f"pages: {pages}")
|
|
253
|
+
if body.get("has_more") and pages >= 1:
|
|
254
|
+
bits.append(f"more available (cursor: {body.get('cursor')})")
|
|
255
|
+
env.say(" | ".join(bits))
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def run_request(env: Env, g: argparse.Namespace, cfg: dict, ep: Endpoint, params: dict[str, Any],
|
|
259
|
+
path_params: dict[str, str] | None = None, body: Any = None) -> int:
|
|
260
|
+
if g.dry_run:
|
|
261
|
+
path = ep.path
|
|
262
|
+
for name, value in (path_params or {}).items():
|
|
263
|
+
path = path.replace("{" + name + "}", str(value))
|
|
264
|
+
shown = {"method": ep.method, "path": path, "query": {k: v for k, v in params.items() if v is not None},
|
|
265
|
+
"price": price(ep), "response": ep.response}
|
|
266
|
+
if body is not None:
|
|
267
|
+
shown["body"] = body
|
|
268
|
+
print(json.dumps(shown, ensure_ascii=False, indent=2 if env.out_tty else None), file=env.out)
|
|
269
|
+
return EXIT_OK
|
|
270
|
+
key = key_for(env, g, cfg)
|
|
271
|
+
if not key:
|
|
272
|
+
env.say("error: no API key. Run `socialscrape login`, or set SOCIALSCRAPE_API_KEY. "
|
|
273
|
+
f"Get a key at {env.cat.api['dashboard_url']}")
|
|
274
|
+
return EXIT_AUTH
|
|
275
|
+
api_url = g.api_url or env.environ.get("SOCIALSCRAPE_API_URL") or cfg.get("api_url") or env.cat.api["base_url"]
|
|
276
|
+
client = ApiClient(api_url, http=env.http, timeout=g.timeout, user_agent=f"socialscrape-cli/{__version__}")
|
|
277
|
+
try:
|
|
278
|
+
want_pages = (g.max_pages if g.all else g.pages) if ep.paginated else 1
|
|
279
|
+
total = 0
|
|
280
|
+
collected: list[Any] = []
|
|
281
|
+
resp: ApiResponse | None = None
|
|
282
|
+
pages = 0
|
|
283
|
+
query = dict(params)
|
|
284
|
+
while True:
|
|
285
|
+
resp = client.request(ep.method, ep.path, api_key=key, query=query, body=body, path_params=path_params)
|
|
286
|
+
pages += 1
|
|
287
|
+
rb = resp.body
|
|
288
|
+
if not rb.get("ok"):
|
|
289
|
+
if pages > 1 and collected:
|
|
290
|
+
env.say(f"stopped after {pages - 1} page(s): {(rb.get('error') or {}).get('message')}")
|
|
291
|
+
break
|
|
292
|
+
return fail(env, g, rb)
|
|
293
|
+
total += int(rb.get("credits_charged") or 0)
|
|
294
|
+
data = rb.get("data")
|
|
295
|
+
if want_pages == 1 or not isinstance(data, dict) or "items" not in data:
|
|
296
|
+
collected = []
|
|
297
|
+
break
|
|
298
|
+
collected += data["items"]
|
|
299
|
+
more = bool(rb.get("has_more")) and rb.get("cursor")
|
|
300
|
+
if not more or pages >= want_pages:
|
|
301
|
+
break
|
|
302
|
+
if g.max_items and len(collected) >= g.max_items:
|
|
303
|
+
break
|
|
304
|
+
if g.max_credits is not None and total >= g.max_credits:
|
|
305
|
+
env.say(f"stopped: --max-credits {g.max_credits} reached")
|
|
306
|
+
break
|
|
307
|
+
if not g.quiet:
|
|
308
|
+
env.say(f"page {pages}: {len(collected)} items so far, {total} credits")
|
|
309
|
+
query["cursor"] = rb["cursor"]
|
|
310
|
+
assert resp is not None
|
|
311
|
+
out = dict(resp.body)
|
|
312
|
+
if collected:
|
|
313
|
+
if g.max_items:
|
|
314
|
+
collected = collected[: g.max_items]
|
|
315
|
+
data = dict(out["data"])
|
|
316
|
+
data["items"] = collected
|
|
317
|
+
out["data"] = data
|
|
318
|
+
out["credits_charged"] = total
|
|
319
|
+
emit(env, g, out)
|
|
320
|
+
footer(env, g, out, total, pages)
|
|
321
|
+
return EXIT_OK
|
|
322
|
+
finally:
|
|
323
|
+
client.close()
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def fail(env: Env, g: argparse.Namespace, body: dict) -> int:
|
|
327
|
+
err = body.get("error") or {}
|
|
328
|
+
if g.envelope or g.raw:
|
|
329
|
+
print(json.dumps(body, ensure_ascii=False), file=env.out)
|
|
330
|
+
rid = f" (request {body['request_id']})" if body.get("request_id") else ""
|
|
331
|
+
env.say(f"error: {err.get('code', 'unknown')}: {err.get('message', 'request failed')}{rid}")
|
|
332
|
+
if err.get("retryable"):
|
|
333
|
+
env.say("this error is retryable; failures are free")
|
|
334
|
+
code = err.get("code")
|
|
335
|
+
if code == "insufficient_credits":
|
|
336
|
+
env.say(f"buy credits at {env.cat.api['dashboard_url']}")
|
|
337
|
+
elif code == "invalid_params":
|
|
338
|
+
env.say("see `socialscrape <platform> <resource> --help` for the parameters")
|
|
339
|
+
return exit_code_for(body)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# ------------------------------------------------------------------ commands
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def top_help(env: Env) -> str:
|
|
346
|
+
groups = "\n".join(f" {cli_name(g.key):<14} {g.title}" for g in env.cat.groups)
|
|
347
|
+
return f"""socialscrape {__version__}: social media data from the command line
|
|
348
|
+
|
|
349
|
+
usage: socialscrape <platform> <resource> [value] [--param value ...]
|
|
350
|
+
socialscrape <command> ...
|
|
351
|
+
|
|
352
|
+
shortcuts (the platform is an argument):
|
|
353
|
+
transcript URL transcript of a video or post, any supported platform
|
|
354
|
+
profile PLATFORM HANDLE a profile post PLATFORM URL one post
|
|
355
|
+
search PLATFORM QUERY keyword search comments PLATFORM URL comments on a post
|
|
356
|
+
|
|
357
|
+
account:
|
|
358
|
+
login | logout | config save, remove or show the API key
|
|
359
|
+
credits balance (free)
|
|
360
|
+
usage [daily|routes] request history (free)
|
|
361
|
+
job ID result of an async job (free)
|
|
362
|
+
batch FILE|- up to 100 requests from JSON, one call
|
|
363
|
+
monitors ACTION watch a list and get webhooks
|
|
364
|
+
endpoints [PLATFORM] list what you can call
|
|
365
|
+
|
|
366
|
+
platforms (socialscrape <platform> --help lists its resources):
|
|
367
|
+
{groups}
|
|
368
|
+
|
|
369
|
+
output: -o json|table|ndjson (also --json --table --ndjson), --envelope (whole response), --raw (platform's
|
|
370
|
+
original object included, whole response printed), -q (no price line on stderr)
|
|
371
|
+
preview: --dry-run shows the request and its price and sends nothing (no key needed)
|
|
372
|
+
paging: --pages N | --all [--max-pages N] [--max-items N] [--max-credits N] (each page costs credits)
|
|
373
|
+
auth: --key K | $SOCIALSCRAPE_API_KEY | `socialscrape login`; --api-url for another server
|
|
374
|
+
exit: 0 ok, 1 API error, 2 usage, 3 key or credits, 4 retryable (rate limit, upstream, network)
|
|
375
|
+
Data goes to stdout; the price and notices go to stderr."""
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def group_help(env: Env, key: str) -> str:
|
|
379
|
+
g = env.cat.group(key)
|
|
380
|
+
assert g is not None
|
|
381
|
+
rows = [f"{g.title}: {g.description}", "", f"usage: socialscrape {cli_name(key)} <resource> [value] [--param value ...]", ""]
|
|
382
|
+
for e in env.cat.group_endpoints(key):
|
|
383
|
+
need = e.signature()
|
|
384
|
+
rows.append(f" {cli_name(e.resource):<28} {e.summary} [{price(e)}]" + (f" needs {need}" if need else ""))
|
|
385
|
+
return "\n".join(rows)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def find_group(env: Env, name: str) -> str | None:
|
|
389
|
+
want = cli_name(name)
|
|
390
|
+
for g in env.cat.groups:
|
|
391
|
+
if cli_name(g.key) == want:
|
|
392
|
+
return g.key
|
|
393
|
+
return None
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def find_resource(env: Env, group: str, name: str) -> Endpoint | None:
|
|
397
|
+
want = cli_name(name)
|
|
398
|
+
for e in env.cat.group_endpoints(group):
|
|
399
|
+
if cli_name(e.resource) == want:
|
|
400
|
+
return e
|
|
401
|
+
return None
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def suggest(word: str, options: list[str]) -> str:
|
|
405
|
+
near = difflib.get_close_matches(cli_name(word), [cli_name(o) for o in options], n=3, cutoff=0.5)
|
|
406
|
+
return f" Did you mean: {', '.join(near)}?" if near else ""
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def run_resource(env: Env, g: argparse.Namespace, cfg: dict, ep: Endpoint, rest: list[str], prog: str) -> int:
|
|
410
|
+
ap = resource_parser(ep, prog)
|
|
411
|
+
if g.help:
|
|
412
|
+
print(ap.format_help(), file=env.out)
|
|
413
|
+
return EXIT_OK
|
|
414
|
+
try:
|
|
415
|
+
params = params_from(ep, ap.parse_args(hyphenate(rest)), g)
|
|
416
|
+
except UsageError as e:
|
|
417
|
+
env.say(f"error: {e}\n{ap.format_usage().strip()}")
|
|
418
|
+
return EXIT_USAGE
|
|
419
|
+
return run_request(env, g, cfg, ep, params)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def cmd_platform(env: Env, g: argparse.Namespace, cfg: dict, group: str, rest: list[str]) -> int:
|
|
423
|
+
if not rest:
|
|
424
|
+
print(group_help(env, group), file=env.out if g.help else env.err)
|
|
425
|
+
return EXIT_OK if g.help else EXIT_USAGE
|
|
426
|
+
ep = find_resource(env, group, rest[0])
|
|
427
|
+
if ep is None:
|
|
428
|
+
names = [e.resource for e in env.cat.group_endpoints(group)]
|
|
429
|
+
env.say(f"error: {cli_name(group)} has no resource {rest[0]!r}.{suggest(rest[0], names)}\n" + group_help(env, group))
|
|
430
|
+
return EXIT_USAGE
|
|
431
|
+
return run_resource(env, g, cfg, ep, rest[1:], f"socialscrape {cli_name(group)} {cli_name(ep.resource)}")
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def cmd_cross(env: Env, g: argparse.Namespace, cfg: dict, role: str, rest: list[str]) -> int:
|
|
435
|
+
table_ = env.cat.roles.get(role, {})
|
|
436
|
+
if not rest or rest[0].startswith("-"):
|
|
437
|
+
plats = ", ".join(cli_name(k) for k in table_)
|
|
438
|
+
print(f"usage: socialscrape {role} <platform> <value> [--param value ...]\nplatforms: {plats}", file=env.out if g.help else env.err)
|
|
439
|
+
return EXIT_OK if g.help else EXIT_USAGE
|
|
440
|
+
key = find_group(env, rest[0])
|
|
441
|
+
if key is None or key not in table_:
|
|
442
|
+
env.say(f"error: no {role} command for {rest[0]!r}.{suggest(rest[0], list(table_))}\nplatforms: "
|
|
443
|
+
+ ", ".join(cli_name(k) for k in table_))
|
|
444
|
+
return EXIT_USAGE
|
|
445
|
+
entry = table_[key]
|
|
446
|
+
args = rest[1:]
|
|
447
|
+
if role == "search":
|
|
448
|
+
kind = entry["default"]
|
|
449
|
+
if "--kind" in args:
|
|
450
|
+
i = args.index("--kind")
|
|
451
|
+
kind = args[i + 1] if i + 1 < len(args) else ""
|
|
452
|
+
args = args[:i] + args[i + 2:]
|
|
453
|
+
eid = entry["kinds"].get(kind) or next((v for k, v in entry["kinds"].items() if cli_name(k) == cli_name(kind)), None)
|
|
454
|
+
if eid is None:
|
|
455
|
+
env.say(f"error: kind must be one of: {', '.join(entry['kinds'])}")
|
|
456
|
+
return EXIT_USAGE
|
|
457
|
+
else:
|
|
458
|
+
eid = entry
|
|
459
|
+
ep = env.cat.by_id[eid]
|
|
460
|
+
return run_resource(env, g, cfg, ep, args, f"socialscrape {role} {cli_name(key)}")
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def cmd_transcript(env: Env, g: argparse.Namespace, cfg: dict, rest: list[str]) -> int:
|
|
464
|
+
ep = env.cat.by_id.get("transcript")
|
|
465
|
+
if ep is None:
|
|
466
|
+
env.say("error: transcripts are not available in this catalog")
|
|
467
|
+
return EXIT_USAGE
|
|
468
|
+
return run_resource(env, g, cfg, ep, rest, "socialscrape transcript")
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def cmd_endpoints(env: Env, g: argparse.Namespace, rest: list[str]) -> int:
|
|
472
|
+
if rest:
|
|
473
|
+
key = find_group(env, rest[0])
|
|
474
|
+
if key is None:
|
|
475
|
+
env.say(f"error: unknown platform {rest[0]!r}.{suggest(rest[0], [x.key for x in env.cat.groups])}")
|
|
476
|
+
return EXIT_USAGE
|
|
477
|
+
print(group_help(env, key), file=env.out)
|
|
478
|
+
return EXIT_OK
|
|
479
|
+
for grp in env.cat.groups:
|
|
480
|
+
names = ", ".join(cli_name(e.resource) for e in env.cat.group_endpoints(grp.key))
|
|
481
|
+
print(f"{cli_name(grp.key)}: {names}", file=env.out)
|
|
482
|
+
return EXIT_OK
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def native(env: Env, eid: str) -> Endpoint | None:
|
|
486
|
+
return env.cat.by_id.get(eid)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def cmd_account(env: Env, g: argparse.Namespace, cfg: dict, cmd: str, rest: list[str]) -> int:
|
|
490
|
+
def need(eid: str) -> Endpoint | None:
|
|
491
|
+
ep = native(env, eid)
|
|
492
|
+
if ep is None:
|
|
493
|
+
env.say(f"error: {cmd} is not available on this server")
|
|
494
|
+
return ep
|
|
495
|
+
|
|
496
|
+
try:
|
|
497
|
+
if cmd == "credits":
|
|
498
|
+
ep = need("account.credits")
|
|
499
|
+
return run_request(env, g, cfg, ep, {}) if ep else EXIT_USAGE
|
|
500
|
+
if cmd == "usage":
|
|
501
|
+
sub = rest[0] if rest and not rest[0].startswith("-") else ""
|
|
502
|
+
eid = {"": "account.usage", "daily": "account.usage.daily", "routes": "account.usage.routes"}.get(sub)
|
|
503
|
+
if eid is None:
|
|
504
|
+
raise UsageError("usage [daily|routes]")
|
|
505
|
+
ep = need(eid)
|
|
506
|
+
if ep is None:
|
|
507
|
+
return EXIT_USAGE
|
|
508
|
+
ap = resource_parser(ep, f"socialscrape usage {sub}".strip())
|
|
509
|
+
return run_request(env, g, cfg, ep, params_from(ep, ap.parse_args(hyphenate(rest[1:] if sub else rest)), g))
|
|
510
|
+
if cmd == "job":
|
|
511
|
+
ep = need("jobs.by_id")
|
|
512
|
+
if ep is None:
|
|
513
|
+
return EXIT_USAGE
|
|
514
|
+
if not rest:
|
|
515
|
+
raise UsageError("job JOB_ID")
|
|
516
|
+
return run_request(env, g, cfg, ep, {}, path_params={"job_id": rest[0]})
|
|
517
|
+
if cmd == "batch":
|
|
518
|
+
ep = need("batch.post")
|
|
519
|
+
if ep is None:
|
|
520
|
+
return EXIT_USAGE
|
|
521
|
+
return run_request(env, g, cfg, ep, {}, body={"requests": batch_requests(env, rest)})
|
|
522
|
+
if cmd == "monitors":
|
|
523
|
+
return cmd_monitors(env, g, cfg, rest)
|
|
524
|
+
except UsageError as e:
|
|
525
|
+
env.say(f"error: {e}")
|
|
526
|
+
return EXIT_USAGE
|
|
527
|
+
return EXIT_USAGE
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def batch_requests(env: Env, rest: list[str]) -> list[dict]:
|
|
531
|
+
if not rest:
|
|
532
|
+
raise UsageError("batch FILE|- (JSON: a list of {\"endpoint\": \"github.user\", \"params\": {...}})")
|
|
533
|
+
try:
|
|
534
|
+
if rest[0] == "-":
|
|
535
|
+
text = env.stdin.read()
|
|
536
|
+
else:
|
|
537
|
+
with open(rest[0], encoding="utf-8") as f:
|
|
538
|
+
text = f.read()
|
|
539
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
540
|
+
raise UsageError(f"cannot read {rest[0]}: {e.strerror or type(e).__name__}") from None
|
|
541
|
+
try:
|
|
542
|
+
data = json.loads(text)
|
|
543
|
+
except ValueError as e:
|
|
544
|
+
raise UsageError(f"batch input is not JSON: {e}") from None
|
|
545
|
+
items = data.get("requests") if isinstance(data, dict) else data
|
|
546
|
+
if not isinstance(items, list) or not items:
|
|
547
|
+
raise UsageError("batch input must be a non-empty list of requests")
|
|
548
|
+
out = []
|
|
549
|
+
for i, item in enumerate(items):
|
|
550
|
+
if not isinstance(item, dict):
|
|
551
|
+
raise UsageError(f"request {i} is not an object")
|
|
552
|
+
if "path" in item:
|
|
553
|
+
out.append({"path": item["path"], "params": item.get("params") or {}})
|
|
554
|
+
continue
|
|
555
|
+
ep = env.cat.by_id.get(str(item.get("endpoint")))
|
|
556
|
+
if ep is None or ep.method != "GET" or ep.kind == "native":
|
|
557
|
+
raise UsageError(f"request {i}: unknown endpoint {item.get('endpoint')!r} (see `socialscrape endpoints`)")
|
|
558
|
+
out.append({"path": ep.path, "params": item.get("params") or {}})
|
|
559
|
+
return out
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
MONITOR_ACTIONS = {"create": "monitors.post", "list": "monitors", "delete": "monitors.by_id.delete",
|
|
563
|
+
"test": "monitors.by_id.test.post", "deliveries": "monitors.by_id.deliveries",
|
|
564
|
+
"events": "monitors.events", "redeliver": "monitors.events.by_id.redeliver.post"}
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def cmd_monitors(env: Env, g: argparse.Namespace, cfg: dict, rest: list[str]) -> int:
|
|
568
|
+
avail = {a: env.cat.by_id[i] for a, i in MONITOR_ACTIONS.items() if i in env.cat.by_id}
|
|
569
|
+
if not rest or rest[0] not in avail:
|
|
570
|
+
env.say("usage: socialscrape monitors " + "|".join(avail) + " [args]\n"
|
|
571
|
+
" create ENDPOINT_ID --webhook-url URL [--interval-minutes N] [--param key=value ...]\n"
|
|
572
|
+
" delete|test|deliveries MONITOR_ID events [MONITOR_ID] redeliver EVENT_ID")
|
|
573
|
+
return EXIT_USAGE
|
|
574
|
+
action, args = rest[0], rest[1:]
|
|
575
|
+
ep = avail[action]
|
|
576
|
+
if action == "create":
|
|
577
|
+
ap = Parser(prog="socialscrape monitors create", allow_abbrev=False)
|
|
578
|
+
ap.add_argument("endpoint")
|
|
579
|
+
ap.add_argument("--webhook-url", required=True)
|
|
580
|
+
ap.add_argument("--interval-minutes", type=int)
|
|
581
|
+
ap.add_argument("--param", action="append", default=[], metavar="key=value")
|
|
582
|
+
try:
|
|
583
|
+
ns = ap.parse_args(hyphenate(args))
|
|
584
|
+
except UsageError as e:
|
|
585
|
+
env.say(f"error: {e}")
|
|
586
|
+
return EXIT_USAGE
|
|
587
|
+
target = env.cat.by_id.get(ns.endpoint)
|
|
588
|
+
if target is None or not target.paginated:
|
|
589
|
+
env.say("error: ENDPOINT_ID must be a list endpoint id (see `socialscrape endpoints`)")
|
|
590
|
+
return EXIT_USAGE
|
|
591
|
+
watched = dict(p.split("=", 1) for p in ns.param if "=" in p)
|
|
592
|
+
body = {"endpoint": target.path, "params": watched, "webhook_url": ns.webhook_url}
|
|
593
|
+
if ns.interval_minutes:
|
|
594
|
+
body["interval_minutes"] = ns.interval_minutes
|
|
595
|
+
return run_request(env, g, cfg, ep, {}, body=body)
|
|
596
|
+
if action == "list":
|
|
597
|
+
return run_request(env, g, cfg, ep, {})
|
|
598
|
+
if action == "events":
|
|
599
|
+
return run_request(env, g, cfg, ep, {"monitor_id": args[0]} if args else {})
|
|
600
|
+
if not args:
|
|
601
|
+
env.say(f"error: monitors {action} needs an id")
|
|
602
|
+
return EXIT_USAGE
|
|
603
|
+
name = "event_id" if action == "redeliver" else "monitor_id"
|
|
604
|
+
return run_request(env, g, cfg, ep, {}, path_params={name: args[0]}, body={} if ep.method == "POST" else None)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def cmd_login(env: Env, g: argparse.Namespace, path, cfg: dict, rest: list[str]) -> int:
|
|
608
|
+
key = g.key or (rest[0] if rest else None)
|
|
609
|
+
if not key:
|
|
610
|
+
if env.stdin.isatty():
|
|
611
|
+
key = getpass.getpass("SocialScrape API key (input hidden): ")
|
|
612
|
+
else:
|
|
613
|
+
key = env.stdin.readline().strip()
|
|
614
|
+
key = (key or "").strip()
|
|
615
|
+
if not key.startswith("ss_"):
|
|
616
|
+
env.say("error: that does not look like a SocialScrape key (ss_live_... or ss_test_...)")
|
|
617
|
+
return EXIT_USAGE
|
|
618
|
+
api_url = g.api_url or env.environ.get("SOCIALSCRAPE_API_URL") or cfg.get("api_url") or env.cat.api["base_url"]
|
|
619
|
+
client = ApiClient(api_url, http=env.http, timeout=g.timeout, user_agent=f"socialscrape-cli/{__version__}")
|
|
620
|
+
try:
|
|
621
|
+
resp = client.request("GET", "/v1/account/credits", api_key=key)
|
|
622
|
+
finally:
|
|
623
|
+
client.close()
|
|
624
|
+
if not resp.ok:
|
|
625
|
+
code = (resp.body.get("error") or {}).get("code")
|
|
626
|
+
if code in AUTH_CODES:
|
|
627
|
+
env.say(f"error: the API refused that key ({code}); nothing saved")
|
|
628
|
+
return EXIT_AUTH
|
|
629
|
+
env.say(f"warning: could not verify the key ({(resp.body.get('error') or {}).get('message')}); saving it anyway")
|
|
630
|
+
config.save(path, {**cfg, "api_key": key})
|
|
631
|
+
left = (resp.body.get("data") or {}).get("credits_remaining")
|
|
632
|
+
env.say(f"saved to {path}" + (f" | credits remaining: {left}" if left is not None else ""))
|
|
633
|
+
return EXIT_OK
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
# ------------------------------------------------------------------ entry point
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def main(argv: list[str] | None = None, *, environ: dict[str, str] | None = None, stdout: IO[str] | None = None,
|
|
640
|
+
stderr: IO[str] | None = None, stdin: IO[str] | None = None, http: httpx.Client | None = None) -> int:
|
|
641
|
+
env = Env(out=stdout or sys.stdout, err=stderr or sys.stderr, stdin=stdin or sys.stdin,
|
|
642
|
+
environ=dict(os.environ) if environ is None else environ, http=http, cat=load_catalog(),
|
|
643
|
+
out_tty=bool((stdout or sys.stdout).isatty()), err_tty=bool((stderr or sys.stderr).isatty()))
|
|
644
|
+
try:
|
|
645
|
+
g, rest = global_parser().parse_known_args(sys.argv[1:] if argv is None else argv)
|
|
646
|
+
except SystemExit:
|
|
647
|
+
return EXIT_USAGE
|
|
648
|
+
if g.version:
|
|
649
|
+
print(f"socialscrape {__version__}", file=env.out)
|
|
650
|
+
return EXIT_OK
|
|
651
|
+
if g.pages < 1 or g.max_pages < 1:
|
|
652
|
+
env.say("error: --pages and --max-pages must be at least 1")
|
|
653
|
+
return EXIT_USAGE
|
|
654
|
+
path = config.config_path(env.environ)
|
|
655
|
+
cfg = config.load(path)
|
|
656
|
+
if not rest:
|
|
657
|
+
print(top_help(env), file=env.out if g.help else env.err)
|
|
658
|
+
return EXIT_OK if g.help else EXIT_USAGE
|
|
659
|
+
cmd, args = rest[0], rest[1:]
|
|
660
|
+
if cmd == "help":
|
|
661
|
+
print(top_help(env), file=env.out)
|
|
662
|
+
return EXIT_OK
|
|
663
|
+
if cmd == "login":
|
|
664
|
+
return cmd_login(env, g, path, cfg, args)
|
|
665
|
+
if cmd == "logout":
|
|
666
|
+
if "api_key" in cfg:
|
|
667
|
+
config.save(path, {k: v for k, v in cfg.items() if k != "api_key"})
|
|
668
|
+
env.say("removed the saved key" if "api_key" in cfg else "no saved key")
|
|
669
|
+
return EXIT_OK
|
|
670
|
+
if cmd == "config":
|
|
671
|
+
key = key_for(env, g, cfg)
|
|
672
|
+
src = "--key" if g.key else "$SOCIALSCRAPE_API_KEY" if env.environ.get("SOCIALSCRAPE_API_KEY") else "config file" if cfg.get("api_key") else "-"
|
|
673
|
+
print(f"config file: {path}\napi key: {config.mask(key)} (from {src})\napi url: "
|
|
674
|
+
f"{g.api_url or env.environ.get('SOCIALSCRAPE_API_URL') or cfg.get('api_url') or env.cat.api['base_url']}", file=env.out)
|
|
675
|
+
return EXIT_OK
|
|
676
|
+
if cmd == "endpoints":
|
|
677
|
+
return cmd_endpoints(env, g, args)
|
|
678
|
+
if cmd == "transcript":
|
|
679
|
+
return cmd_transcript(env, g, cfg, args)
|
|
680
|
+
if cmd in CROSS:
|
|
681
|
+
return cmd_cross(env, g, cfg, CROSS[cmd], args)
|
|
682
|
+
if cmd in NATIVE_COMMANDS:
|
|
683
|
+
return cmd_account(env, g, cfg, cmd, args)
|
|
684
|
+
group = find_group(env, cmd)
|
|
685
|
+
if group is not None:
|
|
686
|
+
return cmd_platform(env, g, cfg, group, args)
|
|
687
|
+
every = [x.key for x in env.cat.groups] + ["transcript", "login", "credits", "usage", "batch", "monitors", "endpoints", *CROSS]
|
|
688
|
+
env.say(f"error: unknown command or platform {cmd!r}.{suggest(cmd, every)}\nrun `socialscrape --help`")
|
|
689
|
+
return EXIT_USAGE
|