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,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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ socialscrape = socialscrape_cli.cli:main
@@ -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