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.
@@ -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