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
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# GENERATED COPY of sdks/mcp/socialscrape_mcp/client.py: edit that file, then run sdks/mcp/generate.py.
|
|
2
|
+
"""A small synchronous client for the SocialScrape API.
|
|
3
|
+
|
|
4
|
+
One job: send a request with the caller's key and hand back the envelope.
|
|
5
|
+
It never raises for an API failure (the envelope carries the error); a
|
|
6
|
+
network failure becomes a synthetic, retryable error envelope with the same
|
|
7
|
+
shape, so callers have one path to handle.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any, Callable
|
|
16
|
+
from urllib.parse import quote
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from . import __version__
|
|
21
|
+
|
|
22
|
+
DEFAULT_API_URL = "https://api.socialscrape.dev"
|
|
23
|
+
USER_AGENT = f"socialscrape-mcp/{__version__}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ApiResponse:
|
|
28
|
+
status: int
|
|
29
|
+
body: dict[str, Any]
|
|
30
|
+
headers: dict[str, str]
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def ok(self) -> bool:
|
|
34
|
+
return bool(self.body.get("ok"))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def wire_value(v: Any) -> Any:
|
|
38
|
+
"""Query strings carry text: booleans as true/false."""
|
|
39
|
+
if isinstance(v, bool):
|
|
40
|
+
return "true" if v else "false"
|
|
41
|
+
return v
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ApiClient:
|
|
45
|
+
def __init__(self, base_url: str = DEFAULT_API_URL, *, http: httpx.Client | None = None, timeout: float = 70.0,
|
|
46
|
+
sleep: Callable[[float], None] = time.sleep, max_retry_wait: float = 5.0,
|
|
47
|
+
user_agent: str = USER_AGENT):
|
|
48
|
+
self.base_url = base_url.rstrip("/")
|
|
49
|
+
self.user_agent = user_agent
|
|
50
|
+
self._owns_http = http is None
|
|
51
|
+
self.http = http or httpx.Client(timeout=timeout)
|
|
52
|
+
self._sleep = sleep
|
|
53
|
+
self.max_retry_wait = max_retry_wait
|
|
54
|
+
|
|
55
|
+
def close(self) -> None:
|
|
56
|
+
if self._owns_http:
|
|
57
|
+
self.http.close()
|
|
58
|
+
|
|
59
|
+
def request(self, method: str, path: str, *, api_key: str | None, query: dict[str, Any] | None = None,
|
|
60
|
+
body: Any = None, path_params: dict[str, str] | None = None) -> ApiResponse:
|
|
61
|
+
for name, value in (path_params or {}).items():
|
|
62
|
+
path = path.replace("{" + name + "}", quote(str(value), safe=""))
|
|
63
|
+
headers = {"x-api-key": api_key or "", "user-agent": self.user_agent, "accept": "application/json"}
|
|
64
|
+
params = {k: wire_value(v) for k, v in (query or {}).items() if v is not None}
|
|
65
|
+
attempts = 0
|
|
66
|
+
while True:
|
|
67
|
+
attempts += 1
|
|
68
|
+
resp = self._send(method, path, headers, params, body)
|
|
69
|
+
err = resp.body.get("error") or {}
|
|
70
|
+
if err.get("code") == "rate_limited" and attempts == 1:
|
|
71
|
+
wait = _retry_after(resp.headers)
|
|
72
|
+
if wait is not None and wait <= self.max_retry_wait:
|
|
73
|
+
self._sleep(wait) # a failure is free, so one polite retry costs nothing
|
|
74
|
+
continue
|
|
75
|
+
return resp
|
|
76
|
+
|
|
77
|
+
def _send(self, method: str, path: str, headers: dict, params: dict, body: Any) -> ApiResponse:
|
|
78
|
+
try:
|
|
79
|
+
r = self.http.request(method, self.base_url + path, headers=headers, params=params or None,
|
|
80
|
+
json=body if body is not None else None)
|
|
81
|
+
except httpx.TimeoutException:
|
|
82
|
+
return _synthetic(504, "upstream_timeout", "the request to the SocialScrape API timed out", True)
|
|
83
|
+
except httpx.HTTPError as e:
|
|
84
|
+
return _synthetic(502, "upstream_error", f"could not reach the SocialScrape API: {type(e).__name__}", True)
|
|
85
|
+
try:
|
|
86
|
+
data = r.json()
|
|
87
|
+
except (json.JSONDecodeError, ValueError):
|
|
88
|
+
data = None
|
|
89
|
+
if not isinstance(data, dict):
|
|
90
|
+
return _synthetic(r.status_code if r.status_code >= 400 else 502, "upstream_error",
|
|
91
|
+
f"the API answered HTTP {r.status_code} with a body that is not JSON", True)
|
|
92
|
+
return ApiResponse(r.status_code, data, {k.lower(): v for k, v in r.headers.items()})
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _retry_after(headers: dict[str, str]) -> float | None:
|
|
96
|
+
try:
|
|
97
|
+
return float(headers.get("retry-after", ""))
|
|
98
|
+
except ValueError:
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _synthetic(status: int, code: str, message: str, retryable: bool) -> ApiResponse:
|
|
103
|
+
return ApiResponse(status, {"ok": False, "error": {"code": code, "message": message, "retryable": retryable},
|
|
104
|
+
"credits_charged": 0, "request_id": None}, {})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""The CLI's saved settings: ~/.config/socialscrape/config.json (mode 0600).
|
|
2
|
+
|
|
3
|
+
Order of precedence for the key: --key, SOCIALSCRAPE_API_KEY, this file.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def config_path(env: dict[str, str] | None = None) -> Path:
|
|
14
|
+
e = os.environ if env is None else env
|
|
15
|
+
base = e.get("XDG_CONFIG_HOME") or str(Path(e.get("HOME") or Path.home()) / ".config")
|
|
16
|
+
return Path(base) / "socialscrape" / "config.json"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load(path: Path) -> dict:
|
|
20
|
+
try:
|
|
21
|
+
data = json.loads(path.read_text())
|
|
22
|
+
except (OSError, ValueError):
|
|
23
|
+
return {}
|
|
24
|
+
return data if isinstance(data, dict) else {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def save(path: Path, data: dict) -> None:
|
|
28
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
tmp = path.with_suffix(".tmp")
|
|
30
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) # never world-readable, not even briefly
|
|
31
|
+
os.fchmod(fd, 0o600) # a leftover file from an older run keeps its old mode otherwise
|
|
32
|
+
with os.fdopen(fd, "w") as f:
|
|
33
|
+
f.write(json.dumps(data, indent=1) + "\n")
|
|
34
|
+
os.replace(tmp, path)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def mask(key: str | None) -> str:
|
|
38
|
+
if not key:
|
|
39
|
+
return "(none)"
|
|
40
|
+
return key[:12] + "..." + key[-2:] if len(key) > 16 else "(set)"
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Plain-text tables for `--output table`: no colour, no box drawing, so the
|
|
2
|
+
output pastes cleanly into a terminal, a ticket or an agent's context."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
PREFERRED = ("id", "handle", "name", "title", "text", "author.handle", "author.name", "published_at", "created_at",
|
|
10
|
+
"stats.views", "stats.likes", "stats.comments", "stats.followers", "language", "url")
|
|
11
|
+
MAX_COLS = 6
|
|
12
|
+
CELL = 42
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def flatten(obj: Any, prefix: str = "", depth: int = 2) -> dict[str, Any]:
|
|
16
|
+
out: dict[str, Any] = {}
|
|
17
|
+
if isinstance(obj, dict):
|
|
18
|
+
for k, v in obj.items():
|
|
19
|
+
key = f"{prefix}{k}"
|
|
20
|
+
if isinstance(v, dict) and depth > 0:
|
|
21
|
+
out.update(flatten(v, key + ".", depth - 1))
|
|
22
|
+
else:
|
|
23
|
+
out[key] = v
|
|
24
|
+
return out
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def cell(v: Any, width: int = CELL) -> str:
|
|
28
|
+
if v is None:
|
|
29
|
+
return ""
|
|
30
|
+
if isinstance(v, (dict, list)):
|
|
31
|
+
s = json.dumps(v, ensure_ascii=False, separators=(",", ":"))
|
|
32
|
+
if isinstance(v, list) and all(isinstance(x, (str, int, float)) for x in v):
|
|
33
|
+
s = ", ".join(str(x) for x in v)
|
|
34
|
+
else:
|
|
35
|
+
s = str(v)
|
|
36
|
+
s = " ".join(s.split())
|
|
37
|
+
return s if len(s) <= width else s[: width - 3] + "..."
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def render(data: Any) -> str:
|
|
41
|
+
"""A list page becomes rows; any other object becomes a field/value list."""
|
|
42
|
+
if isinstance(data, dict) and isinstance(data.get("items"), list):
|
|
43
|
+
text = _rows(data["items"])
|
|
44
|
+
tail = []
|
|
45
|
+
if data.get("has_more"):
|
|
46
|
+
tail.append(f"more available, cursor: {data.get('cursor')}")
|
|
47
|
+
return text + ("\n" + "\n".join(tail) if tail else "")
|
|
48
|
+
if isinstance(data, dict) and isinstance(data.get("results"), list):
|
|
49
|
+
return _rows(data["results"])
|
|
50
|
+
if isinstance(data, dict):
|
|
51
|
+
flat = flatten(data)
|
|
52
|
+
width = max((len(k) for k in flat), default=0)
|
|
53
|
+
return "\n".join(f"{k.ljust(width)} {cell(v, 100)}" for k, v in flat.items() if v not in (None, [], {}))
|
|
54
|
+
return cell(data, 200)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _rows(items: list[Any]) -> str:
|
|
58
|
+
if not items:
|
|
59
|
+
return "(no items)"
|
|
60
|
+
flat = [flatten(i) if isinstance(i, dict) else {"value": i} for i in items]
|
|
61
|
+
present = {}
|
|
62
|
+
for row in flat:
|
|
63
|
+
for k, v in row.items():
|
|
64
|
+
if v not in (None, "", [], {}):
|
|
65
|
+
present[k] = present.get(k, 0) + 1
|
|
66
|
+
cols = [c for c in PREFERRED if present.get(c, 0) * 2 >= len(flat)]
|
|
67
|
+
for c in present: # then whatever else is common and scalar
|
|
68
|
+
if len(cols) >= MAX_COLS:
|
|
69
|
+
break
|
|
70
|
+
if c not in cols and present[c] * 2 >= len(flat) and not any(isinstance(r.get(c), (dict, list)) for r in flat):
|
|
71
|
+
cols.append(c)
|
|
72
|
+
cols = cols[:MAX_COLS] or list(present)[:MAX_COLS]
|
|
73
|
+
grid = [[cell(r.get(c)) for c in cols] for r in flat]
|
|
74
|
+
widths = [max(len(c), *(len(row[i]) for row in grid)) for i, c in enumerate(cols)]
|
|
75
|
+
line = lambda cells: " ".join(c.ljust(w) for c, w in zip(cells, widths)).rstrip() # noqa: E731
|
|
76
|
+
return "\n".join([line(cols), line(["-" * w for w in widths]), *[line(r) for r in grid]])
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: socialscrape-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SocialScrape on the command line: social media data as JSON or a table
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://socialscrape.dev
|
|
7
|
+
Project-URL: Documentation, https://socialscrape.dev/docs
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: httpx<1,>=0.27
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# socialscrape-cli
|
|
15
|
+
|
|
16
|
+
`socialscrape`: social media data on the command line, as JSON for scripts and agents or a
|
|
17
|
+
table for people. Python 3.10+, one dependency (`httpx`).
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
pip install socialscrape-cli # or: pipx install socialscrape-cli
|
|
21
|
+
socialscrape login # paste your key once (saved to ~/.config/socialscrape/config.json, mode 600)
|
|
22
|
+
|
|
23
|
+
socialscrape github user torvalds
|
|
24
|
+
socialscrape transcript "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
|
25
|
+
socialscrape search threads "open source" -o table
|
|
26
|
+
socialscrape threads profile-posts zuck --pages 3
|
|
27
|
+
socialscrape ad-libraries facebook-search nike --region US --all --max-credits 5
|
|
28
|
+
socialscrape credits
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The CLI reads the same endpoint catalog as the `socialscrape-mcp` server, so a platform appears here
|
|
32
|
+
in the release after its endpoints are built. It uses plain `httpx` and needs no other SocialScrape package.
|
|
33
|
+
|
|
34
|
+
## Commands
|
|
35
|
+
|
|
36
|
+
| | |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `socialscrape <platform> <resource> [value] [--param value]` | Any endpoint. `socialscrape github --help` lists a platform's resources, `socialscrape github user --help` the parameters. Resource names use hyphens: `profile.posts` is `profile-posts` (dots and underscores also work). |
|
|
39
|
+
| `transcript URL` | Transcript of a video or post on any supported platform. |
|
|
40
|
+
| `profile`, `post`, `comments`, `search PLATFORM VALUE` | The same job on any platform that has it (`search` takes `--kind`). Shortcuts for `<platform> <resource>`. |
|
|
41
|
+
| `credits`, `usage [daily\|routes]`, `job ID` | Balance, request history, an async job. All free. |
|
|
42
|
+
| `batch FILE\|-` | Up to 100 requests in one call from JSON: `[{"endpoint": "github.user", "params": {"handle": "torvalds"}}]`. |
|
|
43
|
+
| `monitors list\|create\|delete\|test\|deliveries\|events\|redeliver` | Webhook monitors. |
|
|
44
|
+
| `endpoints [PLATFORM]` | What can be called. |
|
|
45
|
+
| `login`, `logout`, `config` | Save, remove, show (masked) the key. |
|
|
46
|
+
|
|
47
|
+
The positional `value` is the resource's main parameter; the form is detected (a URL goes to `url`,
|
|
48
|
+
anything else to `handle`, `id` or `query`). Every parameter is also a flag (`--handle`, `--url`,
|
|
49
|
+
`--sort`, `--limit`, `--region`, ...), with the API's aliases (`--username`).
|
|
50
|
+
|
|
51
|
+
## Output
|
|
52
|
+
|
|
53
|
+
* stdout is data only: pretty JSON on a terminal, compact JSON in a pipe. `-o table` and `-o ndjson`
|
|
54
|
+
(one item per line) change the format; `--envelope` prints the whole response (`data`,
|
|
55
|
+
`credits_charged`, `cursor`, ...); `--raw` asks for the platform's original object (`raw=true`)
|
|
56
|
+
and prints the whole response.
|
|
57
|
+
* stderr carries the price: `credits charged: 1 | remaining: 4999`. `-q` silences it.
|
|
58
|
+
* `--dry-run` prints the request and what it would cost and sends nothing (no key needed).
|
|
59
|
+
|
|
60
|
+
## Paging
|
|
61
|
+
|
|
62
|
+
`--pages N` follows the cursor up to N pages; `--all` follows it to the end (cap `--max-pages`,
|
|
63
|
+
default 50). Each page costs credits, so bound it: `--max-items N`, `--max-credits N`. The merged
|
|
64
|
+
result is one list with every item; if a later page fails you still get the earlier ones.
|
|
65
|
+
|
|
66
|
+
## Exit codes
|
|
67
|
+
|
|
68
|
+
`0` ok, `1` API error (not found, private, invalid parameters, ...), `2` usage error (nothing was
|
|
69
|
+
sent, nothing charged), `3` key or credits (`unauthorized`, `forbidden`, `insufficient_credits`),
|
|
70
|
+
`4` retryable (rate limited, upstream blocked or timed out, network). Failures are free.
|
|
71
|
+
|
|
72
|
+
## Key and server
|
|
73
|
+
|
|
74
|
+
The key comes from `--key`, then `$SOCIALSCRAPE_API_KEY`, then the saved config. `login` checks the
|
|
75
|
+
key against the API before it saves anything. `--api-url` or `$SOCIALSCRAPE_API_URL` points at another
|
|
76
|
+
server (default `https://api.socialscrape.dev`).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
socialscrape_cli/__init__.py,sha256=IqDue7WZynb9OVDQO70UGbpf1qWOrGwCxeB2Yn44yVo,60
|
|
2
|
+
socialscrape_cli/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
|
|
3
|
+
socialscrape_cli/catalog.json,sha256=Ami-dF3Qevt1Z8RQjOLG0s6IPZrv9ngpN5auOIUv2IU,461860
|
|
4
|
+
socialscrape_cli/catalog.py,sha256=7y3WW6E-JflPMe2HJvnl0qzIEVW_T3gwduVK8D4GOZg,5926
|
|
5
|
+
socialscrape_cli/cli.py,sha256=bNfjMDvsoTQ1c8tTffGLIzIyK1eAq4XBt_cJoMLXK2I,30323
|
|
6
|
+
socialscrape_cli/client.py,sha256=5GW4dikJfK5Rr6arcj1jVuA4chgd8vSx4VXS7VlJCZo,4240
|
|
7
|
+
socialscrape_cli/config.py,sha256=MEeFe3TOJQ2UuBUB3nwLn2wNWaS8R83-vPNNsXAxOyc,1264
|
|
8
|
+
socialscrape_cli/table.py,sha256=IDEUaIBzoJnNkE-WttgzQkHeqxkoyHXove0m3qCAZrQ,3106
|
|
9
|
+
socialscrape_cli-0.1.0.dist-info/licenses/LICENSE,sha256=LobQzESyU4GpkJMUTQA4TkMEwYV73_t5e07NyYN4EZI,1093
|
|
10
|
+
socialscrape_cli-0.1.0.dist-info/METADATA,sha256=BJOmjAvKBijF3BdvEEL7FsLiEhxpe94DWONO-hKAsTU,3958
|
|
11
|
+
socialscrape_cli-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
12
|
+
socialscrape_cli-0.1.0.dist-info/entry_points.txt,sha256=t7bvxrMj-ybPujPXLxPuHIIOQ79Tn9dpu5NnGtAf6Bk,59
|
|
13
|
+
socialscrape_cli-0.1.0.dist-info/top_level.txt,sha256=cPrNs4Sb-K0jUMRInQMP38J38dWkJtc6yPkMUaTugdE,17
|
|
14
|
+
socialscrape_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 The Casey Company LLC (SocialScrape)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
socialscrape_cli
|