snoopscan 0.1.0__tar.gz

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,31 @@
1
+ # Secrets and local config — constraint C3: nothing identifying in the repo.
2
+ .env
3
+ .env.*
4
+ !.env.example
5
+ *.pem
6
+ *.key
7
+
8
+ # Lead exports are real people's contact details. They are the output of a
9
+ # run, never source, and a repo is exactly the wrong place for them.
10
+ exports/
11
+ *.csv
12
+
13
+ # Python
14
+ __pycache__/
15
+ *.py[cod]
16
+ .venv/
17
+ venv/
18
+ *.egg-info/
19
+ dist/
20
+ build/
21
+
22
+ # Tooling
23
+ .pytest_cache/
24
+ .mypy_cache/
25
+ .ruff_cache/
26
+ .coverage
27
+ htmlcov/
28
+ licences.json
29
+
30
+ # OS
31
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
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,118 @@
1
+ Metadata-Version: 2.5
2
+ Name: snoopscan
3
+ Version: 0.1.0
4
+ Summary: Python client for the SnoopScan web scraping API
5
+ Project-URL: Homepage, https://snoopscan.com
6
+ Project-URL: Documentation, https://snoopscan.com/docs
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: agents,crawler,llm,markdown,scraping,web-scraping
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Internet :: WWW/HTTP
15
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: httpx>=0.27
18
+ Description-Content-Type: text/markdown
19
+
20
+ # snoopscan
21
+
22
+ Python client for the SnoopScan scraping snoop.
23
+
24
+ MIT licensed. The server is AGPL-3.0; a client library must not be, or every
25
+ application that imports it inherits the copyleft.
26
+
27
+ ```bash
28
+ pip install snoopscan
29
+ ```
30
+
31
+ ```python
32
+ from snoopscan import SnoopScan
33
+
34
+ snoop = SnoopScan(api_key="sk_...")
35
+
36
+ page = snoop.scrape("https://example.com/")
37
+ print(page.markdown)
38
+
39
+ for link in snoop.map("https://example.com/", limit=100):
40
+ print(link.url)
41
+
42
+ job = snoop.crawl_and_wait("https://example.com/", limit=50)
43
+ print(job.completed, "of", job.total)
44
+ ```
45
+
46
+ ## Platforms
47
+
48
+ A site that publishes its data as JSON is asked, not crawled:
49
+
50
+ ```python
51
+ catalogue = snoop.products("https://store.example.com") # Shopify, WooCommerce, Squarespace, Magento
52
+ for p in catalogue["products"]:
53
+ print(p["title"], p["price"], p["currency"], p["available"])
54
+
55
+ posts = snoop.posts("https://blog.example.com") # WordPress, Substack, Squarespace, Discourse; else the feed
56
+ print(posts["source"], len(posts["posts"]))
57
+ ```
58
+
59
+ Every page's `metadata.platform` says what built it, and a `scrape()` of a
60
+ Shopify, WooCommerce or Amazon product page carries `product` beside the
61
+ markdown. Amazon shows the honest client no price — pass `tier="browser"`.
62
+
63
+ ## Monitors
64
+
65
+ ```python
66
+ m = snoop.create_monitor("Pricing", ["https://example.com/pricing"], intervalMinutes=60,
67
+ webhook="https://hooks.example.com/snoop")
68
+ check = snoop.run_monitor(m["id"]) # a check now: {"counts": {...}, "pages": [...]}
69
+ snoop.monitor_checks(m["id"]) # recent checks
70
+ snoop.delete_monitor(m["id"])
71
+ ```
72
+
73
+ Each page in a check is `same`, `changed` (with a git diff), `new` or `error`.
74
+ The webhook `monitor.check.completed` fires only when a check has something to
75
+ say.
76
+
77
+ ## Base URL
78
+
79
+ Defaults to `http://localhost:8099`, the engine's own dev port. Point it
80
+ elsewhere with the `SNOOP_BASE_URL` environment variable, or per client:
81
+
82
+ ```python
83
+ snoop = SnoopScan(api_key="sk_...", base_url="https://api.example.com")
84
+ ```
85
+
86
+ ## Errors
87
+
88
+ Every failure raises `SnoopScanError` carrying the API's machine-readable
89
+ code, so callers can branch on what actually happened:
90
+
91
+ ```python
92
+ from snoopscan import SnoopScanError
93
+
94
+ try:
95
+ page = snoop.scrape(url)
96
+ except SnoopScanError as exc:
97
+ if exc.is_blocked:
98
+ ... # BLOCKED — the target refused us; retrying as-is will not help
99
+ elif exc.code == "FETCH_FAILED":
100
+ ... # the target could not be reached; retrying may help
101
+ elif exc.code == "INVALID_REQUEST":
102
+ ... # our request was wrong; fix it, do not retry
103
+ ```
104
+
105
+ ## Cost
106
+
107
+ Every response carries what it cost to produce — the tier that answered, every
108
+ tier attempted, proxy bytes, browser milliseconds, and whether it came from
109
+ cache. A cache hit reports the accounting of the fetch that filled it, so
110
+ `cost.tier` is never null on a page that was really fetched once.
111
+
112
+ ## Development
113
+
114
+ From a checkout of the engine repo:
115
+
116
+ ```bash
117
+ uv pip install -e sdk/python --python .venv/bin/python
118
+ ```
@@ -0,0 +1,99 @@
1
+ # snoopscan
2
+
3
+ Python client for the SnoopScan scraping snoop.
4
+
5
+ MIT licensed. The server is AGPL-3.0; a client library must not be, or every
6
+ application that imports it inherits the copyleft.
7
+
8
+ ```bash
9
+ pip install snoopscan
10
+ ```
11
+
12
+ ```python
13
+ from snoopscan import SnoopScan
14
+
15
+ snoop = SnoopScan(api_key="sk_...")
16
+
17
+ page = snoop.scrape("https://example.com/")
18
+ print(page.markdown)
19
+
20
+ for link in snoop.map("https://example.com/", limit=100):
21
+ print(link.url)
22
+
23
+ job = snoop.crawl_and_wait("https://example.com/", limit=50)
24
+ print(job.completed, "of", job.total)
25
+ ```
26
+
27
+ ## Platforms
28
+
29
+ A site that publishes its data as JSON is asked, not crawled:
30
+
31
+ ```python
32
+ catalogue = snoop.products("https://store.example.com") # Shopify, WooCommerce, Squarespace, Magento
33
+ for p in catalogue["products"]:
34
+ print(p["title"], p["price"], p["currency"], p["available"])
35
+
36
+ posts = snoop.posts("https://blog.example.com") # WordPress, Substack, Squarespace, Discourse; else the feed
37
+ print(posts["source"], len(posts["posts"]))
38
+ ```
39
+
40
+ Every page's `metadata.platform` says what built it, and a `scrape()` of a
41
+ Shopify, WooCommerce or Amazon product page carries `product` beside the
42
+ markdown. Amazon shows the honest client no price — pass `tier="browser"`.
43
+
44
+ ## Monitors
45
+
46
+ ```python
47
+ m = snoop.create_monitor("Pricing", ["https://example.com/pricing"], intervalMinutes=60,
48
+ webhook="https://hooks.example.com/snoop")
49
+ check = snoop.run_monitor(m["id"]) # a check now: {"counts": {...}, "pages": [...]}
50
+ snoop.monitor_checks(m["id"]) # recent checks
51
+ snoop.delete_monitor(m["id"])
52
+ ```
53
+
54
+ Each page in a check is `same`, `changed` (with a git diff), `new` or `error`.
55
+ The webhook `monitor.check.completed` fires only when a check has something to
56
+ say.
57
+
58
+ ## Base URL
59
+
60
+ Defaults to `http://localhost:8099`, the engine's own dev port. Point it
61
+ elsewhere with the `SNOOP_BASE_URL` environment variable, or per client:
62
+
63
+ ```python
64
+ snoop = SnoopScan(api_key="sk_...", base_url="https://api.example.com")
65
+ ```
66
+
67
+ ## Errors
68
+
69
+ Every failure raises `SnoopScanError` carrying the API's machine-readable
70
+ code, so callers can branch on what actually happened:
71
+
72
+ ```python
73
+ from snoopscan import SnoopScanError
74
+
75
+ try:
76
+ page = snoop.scrape(url)
77
+ except SnoopScanError as exc:
78
+ if exc.is_blocked:
79
+ ... # BLOCKED — the target refused us; retrying as-is will not help
80
+ elif exc.code == "FETCH_FAILED":
81
+ ... # the target could not be reached; retrying may help
82
+ elif exc.code == "INVALID_REQUEST":
83
+ ... # our request was wrong; fix it, do not retry
84
+ ```
85
+
86
+ ## Cost
87
+
88
+ Every response carries what it cost to produce — the tier that answered, every
89
+ tier attempted, proxy bytes, browser milliseconds, and whether it came from
90
+ cache. A cache hit reports the accounting of the fetch that filled it, so
91
+ `cost.tier` is never null on a page that was really fetched once.
92
+
93
+ ## Development
94
+
95
+ From a checkout of the engine repo:
96
+
97
+ ```bash
98
+ uv pip install -e sdk/python --python .venv/bin/python
99
+ ```
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "snoopscan"
3
+ version = "0.1.0"
4
+ description = "Python client for the SnoopScan web scraping API"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ # MIT, NOT AGPL. The server is AGPL; a client library must not be, or every
8
+ # application that imports it inherits the copyleft. An AGPL SDK is an
9
+ # adoption blocker, which is the opposite of what a client is for.
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ dependencies = ["httpx>=0.27"]
13
+ keywords = ["scraping", "crawler", "web-scraping", "markdown", "llm", "agents"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Internet :: WWW/HTTP",
20
+ "Topic :: Text Processing :: Markup :: Markdown",
21
+ ]
22
+
23
+ [project.scripts]
24
+ # The command IS the package name: `snoop` is taken on both PyPI and npm, and
25
+ # a short alias like `ss` would shadow a real tool on Linux.
26
+ snoopscan = "snoopscan.cli:main"
27
+
28
+ [project.urls]
29
+ Homepage = "https://snoopscan.com"
30
+ Documentation = "https://snoopscan.com/docs"
31
+
32
+ [build-system]
33
+ requires = ["hatchling"]
34
+ build-backend = "hatchling.build"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["snoopscan"]
@@ -0,0 +1,33 @@
1
+ """Python client for the SnoopScan web scraping API."""
2
+
3
+ from snoopscan.client import (
4
+ AsyncSnoopScan,
5
+ Cost,
6
+ CrawlJob,
7
+ Document,
8
+ SnoopScan,
9
+ SnoopScanError,
10
+ )
11
+
12
+ __all__ = [
13
+ "AsyncSnoopScan",
14
+ "Cost",
15
+ "CrawlJob",
16
+ "Document",
17
+ "SnoopScan",
18
+ "SnoopScanError",
19
+ ]
20
+
21
+ def _version() -> str:
22
+ """From the installed distribution, so pyproject.toml is the only place the
23
+ number is written. A client that reports a version it is not is worse than
24
+ one that says it does not know."""
25
+ from importlib.metadata import PackageNotFoundError, version
26
+
27
+ try:
28
+ return version("snoopscan")
29
+ except PackageNotFoundError:
30
+ return "0.0.0+unknown"
31
+
32
+
33
+ __version__ = _version()
@@ -0,0 +1,419 @@
1
+ """The `snoopscan` command line.
2
+
3
+ One vocabulary across the REST API, the SDK and this: `scrape` here is
4
+ `/v1/scrape` there is `client.scrape()` in Python. Learning one surface teaches
5
+ the other two, and a docs example pastes into any of them.
6
+
7
+ Two things this deliberately does that a wrapper usually does not:
8
+
9
+ **It tells you what it cost and how it got there.** Every result prints the
10
+ tier that answered and the credits spent. A scraper that hides whether a page
11
+ came from a plain HTTP request or forty seconds of stealth browser is hiding
12
+ the only number that predicts the bill.
13
+
14
+ **It distinguishes the ways a page can fail.** BLOCKED, THIN, TARGET_ERROR and
15
+ ROBOTS_DENIED are different problems with different fixes, and collapsing them
16
+ into "failed" is how a formatting bug gets mistaken for a WAF.
17
+
18
+ Standard library only: argparse, not click. A CLI that drags a dependency tree
19
+ into every project that installs the SDK is a tax on people who only wanted the
20
+ client.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import json
27
+ import os
28
+ import sys
29
+ from typing import Any
30
+
31
+ from .client import DEFAULT_BASE_URL, SnoopScan, SnoopScanError
32
+
33
+ ENV_KEY = "SNOOPSCAN_API_KEY"
34
+ ENV_URL = "SNOOPSCAN_BASE_URL"
35
+
36
+ # Exit codes, so a shell script can branch. 1 is "we failed", 2 is "the target
37
+ # refused us" — different problems, and a caller retrying the second one
38
+ # forever is exactly the waste this separation prevents.
39
+ EXIT_OK = 0
40
+ EXIT_ERROR = 1
41
+ EXIT_BLOCKED = 2
42
+
43
+
44
+ # --------------------------------------------------------------------------
45
+ # Output
46
+ # --------------------------------------------------------------------------
47
+
48
+
49
+ def _emit(value: Any, args: argparse.Namespace) -> None:
50
+ """Write the result, to a file when asked, to stdout otherwise."""
51
+ if isinstance(value, str):
52
+ text = value
53
+ else:
54
+ text = json.dumps(value, indent=2 if args.pretty else None, ensure_ascii=False)
55
+
56
+ if args.output:
57
+ with open(args.output, "w", encoding="utf-8") as handle:
58
+ handle.write(text)
59
+ _note(f"written to {args.output} ({len(text):,} chars)", args)
60
+ return
61
+ print(text)
62
+
63
+
64
+ def _note(message: str, args: argparse.Namespace) -> None:
65
+ """A line for the operator, on stderr so it never pollutes piped output."""
66
+ if not args.quiet:
67
+ print(message, file=sys.stderr)
68
+
69
+
70
+ def _receipt(payload: dict[str, Any], args: argparse.Namespace) -> None:
71
+ """How the page was got, and what it cost.
72
+
73
+ On stderr deliberately: `snoopscan scrape url > page.md` must produce a
74
+ clean file, and the receipt is for the human watching, not the pipe.
75
+ """
76
+ if args.quiet or not isinstance(payload, dict):
77
+ return
78
+ cost = payload.get("cost") or {}
79
+ meta = payload.get("metadata") or {}
80
+ bits = []
81
+ if tier := (cost.get("tier") or meta.get("tier")):
82
+ bits.append(f"tier={tier}")
83
+ if (credits := cost.get("credits")) is not None:
84
+ bits.append(f"credits={credits}")
85
+ if (ms := cost.get("durationMs") or cost.get("duration_ms")) is not None:
86
+ bits.append(f"{int(ms) / 1000:.1f}s")
87
+ if bits:
88
+ print(" " + " ".join(bits), file=sys.stderr)
89
+
90
+
91
+ # --------------------------------------------------------------------------
92
+ # Commands
93
+ # --------------------------------------------------------------------------
94
+
95
+
96
+ def _options(args: argparse.Namespace) -> dict[str, Any]:
97
+ """Shared scrape-ish options, omitting anything the caller left alone so
98
+ the API applies its own defaults rather than ours."""
99
+ out: dict[str, Any] = {}
100
+ if args.tier:
101
+ out["tier"] = args.tier
102
+ if args.timeout:
103
+ out["timeout"] = args.timeout
104
+ if getattr(args, "max_age", None) is not None:
105
+ out["maxAge"] = args.max_age
106
+ if getattr(args, "formats", None):
107
+ out["formats"] = args.formats.split(",")
108
+ return out
109
+
110
+
111
+ def cmd_scrape(client: SnoopScan, args: argparse.Namespace) -> int:
112
+ doc = client.scrape(args.url, **_options(args))
113
+ _receipt(doc.raw, args)
114
+ if doc.is_suspect and not args.quiet:
115
+ # Worth saying out loud: a low-confidence extraction reads like a
116
+ # normal result and is the shape a menu bar or a nav shell arrives in.
117
+ print(f" low confidence ({doc.extraction_confidence:.2f}) — check it", file=sys.stderr)
118
+ _emit(doc.raw if args.json else (doc.markdown or ""), args)
119
+ return EXIT_OK
120
+
121
+
122
+ def cmd_map(client: SnoopScan, args: argparse.Namespace) -> int:
123
+ links = client.map(args.url, **_options(args))
124
+ _note(f" {len(links)} links", args)
125
+ if args.json:
126
+ _emit(links, args)
127
+ else:
128
+ _emit("\n".join(str(row.get("url", row)) for row in links), args)
129
+ return EXIT_OK
130
+
131
+
132
+ def cmd_search(client: SnoopScan, args: argparse.Namespace) -> int:
133
+ body: dict[str, Any] = {"limit": args.limit}
134
+ if args.scrape:
135
+ body["scrapeResults"] = True
136
+ data = client.search(args.query, **body)
137
+ results = data.get("results", []) if isinstance(data, dict) else data
138
+ # Which provider answered. The ladder falling through to a weaker engine is
139
+ # correct behaviour and invisible unless it is printed — a search that
140
+ # silently degrades returns a worse source mix at the same speed.
141
+ if isinstance(data, dict) and (provider := data.get("provider")):
142
+ _note(f" provider={provider} results={len(results)}", args)
143
+ if args.json:
144
+ _emit(data, args)
145
+ else:
146
+ _emit("\n".join(f"{r.get('title', '')}\n {r.get('url', '')}" for r in results), args)
147
+ return EXIT_OK
148
+
149
+
150
+ def cmd_crawl(client: SnoopScan, args: argparse.Namespace) -> int:
151
+ options = _options(args)
152
+ if args.limit:
153
+ options["limit"] = args.limit
154
+ if args.wait:
155
+ docs = client.crawl_and_wait(args.url, max_wait=args.max_wait, **options)
156
+ _note(f" {len(docs)} pages", args)
157
+ joined = "\n\n---\n\n".join(d.markdown or "" for d in docs)
158
+ _emit([d.raw for d in docs] if args.json else joined, args)
159
+ return EXIT_OK
160
+ job = client.crawl(args.url, **options)
161
+ _note(f" job {job.id} started — snoopscan crawl-status {job.id}", args)
162
+ _emit({"id": job.id, "status": job.status}, args)
163
+ return EXIT_OK
164
+
165
+
166
+ def cmd_crawl_status(client: SnoopScan, args: argparse.Namespace) -> int:
167
+ job = client.crawl_status(args.job_id)
168
+ _emit(
169
+ {
170
+ "id": job.id,
171
+ "status": job.status,
172
+ "total": job.total,
173
+ "completed": job.completed,
174
+ "failed": job.failed,
175
+ },
176
+ args,
177
+ )
178
+ return EXIT_OK
179
+
180
+
181
+ def cmd_extract(client: SnoopScan, args: argparse.Namespace) -> int:
182
+ schema = json.loads(args.schema) if args.schema.strip().startswith("{") else json.loads(
183
+ open(args.schema, encoding="utf-8").read()
184
+ )
185
+ rows = client.extract(args.urls, schema, prompt=args.prompt)
186
+ _emit(rows, args)
187
+ return EXIT_OK
188
+
189
+
190
+ def cmd_parse(client: SnoopScan, args: argparse.Namespace) -> int:
191
+ if args.target.startswith(("http://", "https://")):
192
+ data = client.parse(url=args.target)
193
+ else:
194
+ with open(args.target, encoding="utf-8", errors="replace") as handle:
195
+ data = client.parse(content=handle.read())
196
+ _receipt(data, args)
197
+ _emit(data if args.json else data.get("markdown", ""), args)
198
+ return EXIT_OK
199
+
200
+
201
+ def _listing(rows: list[dict[str, Any]], title_keys: tuple[str, ...]) -> str:
202
+ """A readable line per item.
203
+
204
+ The default output of a catalogue command must not be the catalogue. One
205
+ blog returned 4.2 MB of post bodies to a terminal — technically the right
206
+ data, unusably delivered. Full fidelity is one flag away (`--json`), and
207
+ `-o` writes it to a file where that size belongs.
208
+ """
209
+ lines = []
210
+ for row in rows:
211
+ title = next((str(row[k]) for k in title_keys if row.get(k)), "(untitled)")
212
+ url = row.get("url") or row.get("link") or ""
213
+ price = row.get("price")
214
+ suffix = f" {price}" if price else ""
215
+ lines.append(f"{title}{suffix}\n {url}")
216
+ return "\n".join(lines)
217
+
218
+
219
+ def cmd_products(client: SnoopScan, args: argparse.Namespace) -> int:
220
+ data = client.products(args.url)
221
+ products = data.get("products", [])
222
+ _note(f" platform={data.get('platform')} total={data.get('total')} returned={len(products)}", args)
223
+ _emit(data if args.json else _listing(products, ("title", "name")), args)
224
+ return EXIT_OK
225
+
226
+
227
+ def cmd_posts(client: SnoopScan, args: argparse.Namespace) -> int:
228
+ data = client.posts(args.url)
229
+ posts = data.get("posts", [])
230
+ _note(f" platform={data.get('platform')} source={data.get('source')} returned={len(posts)}", args)
231
+ _emit(data if args.json else _listing(posts, ("title", "name")), args)
232
+ return EXIT_OK
233
+
234
+
235
+ def cmd_monitor(client: SnoopScan, args: argparse.Namespace) -> int:
236
+ if args.action == "list":
237
+ _emit(client.monitors(), args)
238
+ elif args.action == "get":
239
+ _emit(client.monitor(args.monitor_id), args)
240
+ elif args.action == "run":
241
+ _emit(client.run_monitor(args.monitor_id), args)
242
+ elif args.action == "checks":
243
+ _emit(client.monitor_checks(args.monitor_id), args)
244
+ elif args.action == "delete":
245
+ client.delete_monitor(args.monitor_id)
246
+ _note(f" deleted {args.monitor_id}", args)
247
+ elif args.action == "create":
248
+ _emit(
249
+ client.create_monitor(
250
+ args.name, args.urls.split(","), intervalMinutes=args.interval, goal=args.goal
251
+ ),
252
+ args,
253
+ )
254
+ return EXIT_OK
255
+
256
+
257
+ def cmd_status(client: SnoopScan, args: argparse.Namespace) -> int:
258
+ """Whether this is configured AND whether the engine can do the hard work.
259
+
260
+ The second half matters: Camoufox's browser build lives in an OS cache
261
+ directory that cleaning tools empty, and losing it drops the only rungs
262
+ that pass a hard anti-bot check. The engine then answers BLOCKED for those
263
+ domains, which reads as the targets refusing us rather than as a missing
264
+ dependency. One line here beats an hour of misdirected debugging.
265
+ """
266
+ import httpx
267
+
268
+ base = client.base_url
269
+ print(f" api {base}")
270
+ try:
271
+ health = httpx.get(f"{base}/health", timeout=10).json()
272
+ print(f" engine {health.get('status', '?')} v{health.get('version', '?')}")
273
+ tiers = health.get("tiers") or []
274
+ deep = health.get("deepTiersAvailable")
275
+ print(f" tiers {', '.join(tiers) if tiers else 'unknown'}")
276
+ if deep is False:
277
+ print(" WARNING the deep rungs are missing — hard sites will report BLOCKED")
278
+ print(" fix: python -m camoufox fetch")
279
+ if degraded := health.get("searchProvidersDegraded"):
280
+ print(f" WARNING search rungs degraded: {', '.join(degraded)}")
281
+ if health.get("saturated"):
282
+ print(f" WARNING engine saturated (loop lag {health.get('recentLagMs')}ms)")
283
+ except Exception as exc: # noqa: BLE001 - status must report, never raise
284
+ print(f" engine unreachable ({type(exc).__name__})")
285
+ return EXIT_ERROR
286
+ return EXIT_OK
287
+
288
+
289
+ # --------------------------------------------------------------------------
290
+ # Parser
291
+ # --------------------------------------------------------------------------
292
+
293
+
294
+ def build_parser() -> argparse.ArgumentParser:
295
+ # Global flags live on a PARENT parser so they work on either side of the
296
+ # subcommand. argparse's default puts them before it only, and
297
+ # `snoopscan scrape URL --json` — which is what everyone types — would fail
298
+ # with "unrecognized arguments". A CLI that rejects the natural word order
299
+ # is a CLI people stop using.
300
+ common = argparse.ArgumentParser(add_help=False)
301
+ common.add_argument("--api-key", help=f"defaults to ${ENV_KEY}")
302
+ common.add_argument("--base-url", help=f"defaults to ${ENV_URL} or {DEFAULT_BASE_URL}")
303
+ common.add_argument("-o", "--output", help="write to a file instead of stdout")
304
+ common.add_argument("--json", action="store_true", help="full JSON, not just the content")
305
+ common.add_argument("--pretty", action="store_true", help="indent the JSON")
306
+ common.add_argument("-q", "--quiet", action="store_true", help="no receipts on stderr")
307
+
308
+ # Flags belong AFTER the subcommand, as in git and docker: `snoopscan
309
+ # scrape URL --json`. They are deliberately NOT on the top-level parser —
310
+ # with `parents` on both, argparse re-applies the subparser's default and
311
+ # silently discards a flag given before the verb, which is worse than
312
+ # rejecting it.
313
+ parser = argparse.ArgumentParser(
314
+ prog="snoopscan",
315
+ description="Scrape, crawl, search and extract the web. Same verbs as the API and the SDK.",
316
+ )
317
+
318
+ subs = parser.add_subparsers(dest="command", required=True, parser_class=argparse.ArgumentParser)
319
+
320
+ def page_options(sub: argparse.ArgumentParser) -> None:
321
+ sub.add_argument("--tier", choices=["http", "impersonate", "browser", "stealth", "stealth_hard", "mobile", "auto"])
322
+ sub.add_argument("--timeout", type=int, help="milliseconds")
323
+ sub.add_argument("--max-age", type=int, help="accept a cached page this many ms old")
324
+ sub.add_argument("--formats", help="comma separated: markdown,html,rawHtml,links,screenshot")
325
+
326
+ s = subs.add_parser("scrape", parents=[common], help="get clean content from one URL")
327
+ s.add_argument("url")
328
+ page_options(s)
329
+ s.set_defaults(func=cmd_scrape)
330
+
331
+ s = subs.add_parser("crawl", parents=[common], help="crawl a site")
332
+ s.add_argument("url")
333
+ s.add_argument("--limit", type=int, help="maximum pages")
334
+ s.add_argument("--wait", action="store_true", help="block until it finishes")
335
+ s.add_argument("--max-wait", type=float, default=900.0)
336
+ page_options(s)
337
+ s.set_defaults(func=cmd_crawl)
338
+
339
+ s = subs.add_parser("crawl-status", parents=[common], help="how a crawl is going")
340
+ s.add_argument("job_id")
341
+ s.set_defaults(func=cmd_crawl_status)
342
+
343
+ s = subs.add_parser("map", parents=[common], help="discover URLs without fetching bodies")
344
+ s.add_argument("url")
345
+ page_options(s)
346
+ s.set_defaults(func=cmd_map)
347
+
348
+ s = subs.add_parser("search", parents=[common], help="search the web")
349
+ s.add_argument("query")
350
+ s.add_argument("--limit", type=int, default=10)
351
+ s.add_argument("--scrape", action="store_true", help="fetch each result too")
352
+ s.set_defaults(func=cmd_search)
353
+
354
+ s = subs.add_parser("extract", parents=[common], help="structured data against a schema")
355
+ s.add_argument("urls", nargs="+")
356
+ s.add_argument("--schema", required=True, help="inline JSON or a path to a .json file")
357
+ s.add_argument("--prompt", help="what to pull out, in words")
358
+ s.set_defaults(func=cmd_extract)
359
+
360
+ s = subs.add_parser("parse", parents=[common], help="a document to markdown — PDF, DOCX, XLSX, HTML")
361
+ s.add_argument("target", help="a URL or a local file")
362
+ s.set_defaults(func=cmd_parse)
363
+
364
+ s = subs.add_parser("products", parents=[common], help="a store's catalogue, from its own endpoint")
365
+ s.add_argument("url")
366
+ s.set_defaults(func=cmd_products)
367
+
368
+ s = subs.add_parser("posts", parents=[common], help="a site's posts, from its API or feed")
369
+ s.add_argument("url")
370
+ s.set_defaults(func=cmd_posts)
371
+
372
+ s = subs.add_parser("monitor", parents=[common], help="watch pages for changes")
373
+ s.add_argument("action", choices=["list", "get", "run", "checks", "delete", "create"])
374
+ s.add_argument("monitor_id", nargs="?")
375
+ s.add_argument("--name")
376
+ s.add_argument("--urls", help="comma separated")
377
+ s.add_argument("--interval", type=int, default=60, help="minutes, minimum 5")
378
+ s.add_argument("--goal", help="what change matters")
379
+ s.set_defaults(func=cmd_monitor)
380
+
381
+ s = subs.add_parser("status", parents=[common], help="is this configured, and can the engine do the hard work")
382
+ s.set_defaults(func=cmd_status)
383
+
384
+ return parser
385
+
386
+
387
+ def main(argv: list[str] | None = None) -> int:
388
+ args = build_parser().parse_args(argv)
389
+
390
+ api_key = args.api_key or os.environ.get(ENV_KEY, "")
391
+ base_url = args.base_url or os.environ.get(ENV_URL) or DEFAULT_BASE_URL
392
+ if not api_key and args.command != "status":
393
+ print(
394
+ f"No API key. Pass --api-key or set {ENV_KEY}.",
395
+ file=sys.stderr,
396
+ )
397
+ return EXIT_ERROR
398
+
399
+ client = SnoopScan(api_key or "none", base_url=base_url)
400
+ try:
401
+ return int(args.func(client, args))
402
+ except SnoopScanError as exc:
403
+ # The distinction is the point. A target refusing us is not the same
404
+ # problem as a thin page or a broken request, and a caller that retries
405
+ # all three identically wastes money on the two that will never change.
406
+ # `str(exc)` already carries the code, so printing it again reads
407
+ # "BLOCKED: BLOCKED: ...".
408
+ print(str(exc), file=sys.stderr)
409
+ if exc.detail and not args.quiet:
410
+ print(f" detail: {json.dumps(exc.detail)[:300]}", file=sys.stderr)
411
+ return EXIT_BLOCKED if exc.code in {"BLOCKED", "ROBOTS_DENIED"} else EXIT_ERROR
412
+ except KeyboardInterrupt:
413
+ return EXIT_ERROR
414
+ finally:
415
+ client.close()
416
+
417
+
418
+ if __name__ == "__main__":
419
+ raise SystemExit(main())
@@ -0,0 +1,473 @@
1
+ """Python client for the SnoopScan web scraping API.
2
+
3
+ Deliberately mirrors the shape of the established clients in this space —
4
+ `scrape`, `crawl`, `map`, `extract`, `search`, plus a blocking `crawl_and_wait`
5
+ — because the migration promise is that existing code changes a base URL and
6
+ keeps working. Method and option names are the API's names.
7
+
8
+ Written from our own OpenAPI surface, not from any other client's source
9
+ (constraint C1).
10
+
11
+ from snoopscan import SnoopScan
12
+
13
+ snoop = SnoopScan(api_key="sk_...")
14
+ page = snoop.scrape("https://example.com")
15
+ print(page.markdown)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import time
22
+ from dataclasses import dataclass, field
23
+ from typing import Any
24
+
25
+ import httpx
26
+
27
+ # The engine's documented dev port (README, .claude/launch.json). This used to
28
+ # be 8000, which no deployment of ours listens on, so every local caller had to
29
+ # pass `base_url` and the first call of a session failed with a connection
30
+ # error (measured, Sep 2026). `SNOOP_BASE_URL` overrides it, so pointing
31
+ # a script at staging or production is an environment variable, not a code edit.
32
+ # `SNOOP_BASE_URL` is the pre-rename spelling, still honoured so an existing
33
+ # deployment does not break. The CLI reads `SNOOPSCAN_BASE_URL`, and before
34
+ # this the two disagreed: setting the documented variable did nothing to the
35
+ # SDK, which is the worst kind of configuration bug — silent and correct-looking.
36
+ #
37
+ # The default is localhost because the engine is self-hosted; a published
38
+ # client cannot guess where YOUR instance runs. Set SNOOPSCAN_BASE_URL.
39
+ DEFAULT_BASE_URL = (
40
+ os.environ.get("SNOOPSCAN_BASE_URL")
41
+ or os.environ.get("SNOOP_BASE_URL")
42
+ or "http://localhost:8099"
43
+ )
44
+ DEFAULT_TIMEOUT = 120.0
45
+
46
+
47
+ class SnoopScanError(Exception):
48
+ """An error returned by the API.
49
+
50
+ Carries the machine-readable code so callers can branch on it —
51
+ `BLOCKED` and `TARGET_ERROR` mean different things and deserve different
52
+ handling.
53
+ """
54
+
55
+ def __init__(self, code: str, message: str, detail: dict[str, Any] | None = None) -> None:
56
+ super().__init__(f"{code}: {message}")
57
+ self.code = code
58
+ self.message = message
59
+ self.detail = detail or {}
60
+
61
+ @property
62
+ def is_blocked(self) -> bool:
63
+ return self.code == "BLOCKED"
64
+
65
+ @property
66
+ def is_target_error(self) -> bool:
67
+ """The site responded, but not with the page — a genuine 404 or 5xx,
68
+ not us being blocked. Retrying will not help."""
69
+ return self.code == "TARGET_ERROR"
70
+
71
+ @property
72
+ def is_rate_limited(self) -> bool:
73
+ return self.code == "RATE_LIMITED"
74
+
75
+
76
+ @dataclass
77
+ class Cost:
78
+ """What the request actually cost. Failed requests carry no cost."""
79
+
80
+ tier: str | None = None
81
+ tiers_attempted: list[str] = field(default_factory=list)
82
+ proxy_used: bool = False
83
+ proxy_type: str | None = None
84
+ proxy_bytes: int = 0
85
+ browser_ms: int = 0
86
+ extraction_path: str | None = None
87
+ cached: bool = False
88
+
89
+
90
+ @dataclass
91
+ class Document:
92
+ markdown: str | None = None
93
+ html: str | None = None
94
+ raw_html: str | None = None
95
+ links: list[str] = field(default_factory=list)
96
+ json_: dict[str, Any] | None = None
97
+ metadata: dict[str, Any] = field(default_factory=dict)
98
+ cost: Cost = field(default_factory=Cost)
99
+ # The payload as it arrived. Kept because the typed fields are a curated
100
+ # view: anything the API adds later, and anything a caller wants to dump
101
+ # verbatim, is otherwise lost the moment it is parsed.
102
+ raw: dict[str, Any] = field(default_factory=dict)
103
+
104
+ @property
105
+ def title(self) -> str | None:
106
+ return self.metadata.get("title")
107
+
108
+ @property
109
+ def url(self) -> str | None:
110
+ return self.metadata.get("url")
111
+
112
+ @property
113
+ def page_type(self) -> str:
114
+ return self.metadata.get("pageType", "unknown")
115
+
116
+ @property
117
+ def word_count(self) -> int:
118
+ return int(self.metadata.get("wordCount", 0))
119
+
120
+ @property
121
+ def extraction_confidence(self) -> float:
122
+ """0-1. Treat anything below 0.5 as suspect and cross-check it."""
123
+ return float(self.metadata.get("extractionConfidence", 0.0))
124
+
125
+ @property
126
+ def is_suspect(self) -> bool:
127
+ return self.extraction_confidence < 0.5
128
+
129
+ @classmethod
130
+ def from_payload(cls, data: dict[str, Any]) -> Document:
131
+ return cls(
132
+ markdown=data.get("markdown"),
133
+ html=data.get("html"),
134
+ raw_html=data.get("rawHtml"),
135
+ links=data.get("links") or [],
136
+ json_=data.get("json"),
137
+ metadata=data.get("metadata") or {},
138
+ cost=Cost(
139
+ **{k: v for k, v in (data.get("cost") or {}).items() if k in Cost.__annotations__}
140
+ ),
141
+ raw=data,
142
+ )
143
+
144
+
145
+ @dataclass
146
+ class CrawlJob:
147
+ id: str
148
+ status: str
149
+ total: int = 0
150
+ completed: int = 0
151
+ failed: int = 0
152
+ cost: dict[str, Any] = field(default_factory=dict)
153
+
154
+ @property
155
+ def finished(self) -> bool:
156
+ return self.status in ("completed", "failed", "cancelled")
157
+
158
+
159
+ class SnoopScan:
160
+ """Synchronous client. See AsyncSnoopScan for the async form."""
161
+
162
+ def __init__(
163
+ self,
164
+ api_key: str,
165
+ base_url: str = DEFAULT_BASE_URL,
166
+ timeout: float = DEFAULT_TIMEOUT,
167
+ ) -> None:
168
+ self.base_url = base_url.rstrip("/")
169
+ self._client = httpx.Client(
170
+ timeout=timeout,
171
+ headers={
172
+ "Authorization": f"Bearer {api_key}",
173
+ "Content-Type": "application/json",
174
+ },
175
+ )
176
+
177
+ # -- plumbing ---------------------------------------------------------
178
+
179
+ def _post(self, path: str, body: dict[str, Any]) -> Any:
180
+ return self._unwrap(self._client.post(f"{self.base_url}{path}", json=body))
181
+
182
+ def _get(self, path: str, params: dict[str, Any] | None = None) -> Any:
183
+ return self._unwrap(self._client.get(f"{self.base_url}{path}", params=params))
184
+
185
+ def _delete(self, path: str) -> Any:
186
+ return self._unwrap(self._client.delete(f"{self.base_url}{path}"))
187
+
188
+ @staticmethod
189
+ def _unwrap(response: httpx.Response) -> Any:
190
+ try:
191
+ payload = response.json()
192
+ except ValueError:
193
+ # Never leak an httpx exception for an API-level failure: callers
194
+ # branch on `SnoopScanError.code`, and a raw HTTPStatusError
195
+ # would bypass that entirely. A non-JSON body means something in
196
+ # front of the API answered — a proxy, a load balancer, an nginx
197
+ # error page — so it is reported as INTERNAL with the status.
198
+ raise SnoopScanError(
199
+ "INTERNAL",
200
+ f"Non-JSON response from the API (HTTP {response.status_code})",
201
+ {"status_code": response.status_code},
202
+ ) from None
203
+
204
+ if not payload.get("success", False):
205
+ error = payload.get("error") or {}
206
+ raise SnoopScanError(
207
+ error.get("code", "INTERNAL"),
208
+ error.get("message", "Unknown error"),
209
+ error.get("detail"),
210
+ )
211
+ return payload.get("data")
212
+
213
+ # -- endpoints --------------------------------------------------------
214
+
215
+ def scrape(self, url: str, **options: Any) -> Document:
216
+ """Fetch and extract one URL."""
217
+ return Document.from_payload(self._post("/v1/scrape", {"url": url, **options}))
218
+
219
+ def crawl(self, url: str, **options: Any) -> CrawlJob:
220
+ """Start a crawl. Returns immediately with a job id."""
221
+ data = self._post("/v1/crawl", {"url": url, **options})
222
+ return CrawlJob(id=data["id"], status=data["status"])
223
+
224
+ def crawl_status(self, job_id: str) -> CrawlJob:
225
+ data = self._get(f"/v1/crawl/{job_id}")
226
+ return CrawlJob(
227
+ id=data["id"],
228
+ status=data["status"],
229
+ total=data.get("total", 0),
230
+ completed=data.get("completed", 0),
231
+ failed=data.get("failed", 0),
232
+ cost=data.get("cost") or {},
233
+ )
234
+
235
+ def crawl_pages(
236
+ self, job_id: str, cursor: str | None = None, limit: int = 50
237
+ ) -> tuple[list[Document], str | None]:
238
+ """One page of results, plus the cursor for the next.
239
+
240
+ Results are paginated rather than inlined because a 10,000-page crawl
241
+ must not arrive as a single JSON body.
242
+ """
243
+ params: dict[str, Any] = {"limit": limit}
244
+ if cursor:
245
+ params["cursor"] = cursor
246
+ data = self._get(f"/v1/crawl/{job_id}/pages", params)
247
+ documents = [Document.from_payload(row) for row in data.get("pages", [])]
248
+ next_link = data.get("next")
249
+ next_cursor = next_link.split("cursor=")[-1] if next_link else None
250
+ return documents, next_cursor
251
+
252
+ def crawl_errors(self, job_id: str) -> list[dict[str, Any]]:
253
+ return self._get(f"/v1/crawl/{job_id}/errors").get("errors", [])
254
+
255
+ def cancel_crawl(self, job_id: str) -> CrawlJob:
256
+ data = self._client.delete(f"{self.base_url}/v1/crawl/{job_id}")
257
+ payload = self._unwrap(data)
258
+ return CrawlJob(id=payload["id"], status=payload["status"])
259
+
260
+ def crawl_and_wait(
261
+ self,
262
+ url: str,
263
+ poll_interval: float = 3.0,
264
+ max_wait: float = 900.0,
265
+ **options: Any,
266
+ ) -> list[Document]:
267
+ """Start a crawl and block until it finishes, then return every page.
268
+
269
+ Convenience only. For anything large, start the crawl and read pages
270
+ as they arrive rather than holding the whole result in memory.
271
+ """
272
+ job = self.crawl(url, **options)
273
+ deadline = time.monotonic() + max_wait
274
+
275
+ while time.monotonic() < deadline:
276
+ current = self.crawl_status(job.id)
277
+ if current.finished:
278
+ break
279
+ time.sleep(poll_interval)
280
+ else:
281
+ raise SnoopScanError(
282
+ "TIMEOUT", f"Crawl {job.id} did not finish within {max_wait:.0f}s"
283
+ )
284
+
285
+ documents: list[Document] = []
286
+ cursor: str | None = None
287
+ while True:
288
+ page, cursor = self.crawl_pages(job.id, cursor)
289
+ documents.extend(page)
290
+ if not cursor:
291
+ return documents
292
+
293
+ def map(self, url: str, **options: Any) -> list[dict[str, Any]]:
294
+ """Discover URLs without fetching page bodies. Fast and cheap."""
295
+ return self._post("/v1/map", {"url": url, **options}).get("links", [])
296
+
297
+ def products(self, url: str, **options: Any) -> dict[str, Any]:
298
+ """Every product a Shopify or WooCommerce store publishes, from its own
299
+ catalogue endpoint. Returns {platform, total, products, pages_fetched, cost}."""
300
+ return self._post("/v1/products", {"url": url, **options})
301
+
302
+ def posts(self, url: str, **options: Any) -> dict[str, Any]:
303
+ """A site's posts from its API (WordPress, Substack, Squarespace,
304
+ Discourse) or its RSS/Atom feed. Returns {platform, source, posts, cost}."""
305
+ return self._post("/v1/posts", {"url": url, **options})
306
+
307
+ # --- monitors: watch pages for changes on a schedule ------------------
308
+
309
+ def create_monitor(self, name: str, urls: list[str] | str, **options: Any) -> dict[str, Any]:
310
+ """intervalMinutes (>= 5, default 60), goal, webhook. Returns the monitor."""
311
+ body: dict[str, Any] = {"name": name, **options}
312
+ body["urls" if isinstance(urls, list) else "url"] = urls
313
+ return self._post("/v1/monitor", body)
314
+
315
+ def monitors(self) -> list[dict[str, Any]]:
316
+ return self._get("/v1/monitor").get("monitors", [])
317
+
318
+ def monitor(self, monitor_id: str) -> dict[str, Any]:
319
+ return self._get(f"/v1/monitor/{monitor_id}")
320
+
321
+ def delete_monitor(self, monitor_id: str) -> None:
322
+ self._delete(f"/v1/monitor/{monitor_id}")
323
+
324
+ def run_monitor(self, monitor_id: str) -> dict[str, Any]:
325
+ """A check right now; returns it."""
326
+ return self._post(f"/v1/monitor/{monitor_id}/run", {})
327
+
328
+ def monitor_checks(self, monitor_id: str, limit: int = 20) -> list[dict[str, Any]]:
329
+ out = self._get(f"/v1/monitor/{monitor_id}/checks", params={"limit": limit})
330
+ return out.get("checks", [])
331
+
332
+ def batch_scrape(self, urls: list[str], **options: Any) -> CrawlJob:
333
+ data = self._post("/v1/batch/scrape", {"urls": urls, **options})
334
+ return CrawlJob(id=data["id"], status=data["status"])
335
+
336
+ def extract(
337
+ self, urls: list[str], schema: dict[str, Any], prompt: str | None = None, **options: Any
338
+ ) -> list[dict[str, Any]]:
339
+ """Schema-constrained extraction.
340
+
341
+ Output is validated against the schema before it is returned, so a row
342
+ with an `error` means the page genuinely lacked the fields — not that
343
+ the request should be retried.
344
+ """
345
+ body: dict[str, Any] = {"urls": urls, "schema": schema, **options}
346
+ if prompt:
347
+ body["prompt"] = prompt
348
+ return self._post("/v1/extract", body)
349
+
350
+ def search(self, query: str, **options: Any) -> dict[str, Any]:
351
+ return self._post("/v1/search", {"query": query, **options})
352
+
353
+ def parse(self, *, url: str | None = None, content: str | None = None, **options: Any) -> dict[str, Any]:
354
+ """Turn a document into markdown — PDF, DOCX, XLSX, HTML.
355
+
356
+ Either a URL to fetch or content already in hand. The endpoint existed
357
+ before this method did, which meant the CLI would have had to reach
358
+ past the SDK to use it; one surface is the point of having an SDK.
359
+ """
360
+ body: dict[str, Any] = dict(options)
361
+ if url:
362
+ body["url"] = url
363
+ if content is not None:
364
+ body["content"] = content
365
+ return self._post("/v1/parse", body)
366
+
367
+ # -- lifecycle --------------------------------------------------------
368
+
369
+ def close(self) -> None:
370
+ self._client.close()
371
+
372
+ def __enter__(self) -> SnoopScan:
373
+ return self
374
+
375
+ def __exit__(self, *exc: object) -> None:
376
+ self.close()
377
+
378
+
379
+ class AsyncSnoopScan:
380
+ """Async client. Same surface as the synchronous one."""
381
+
382
+ def __init__(
383
+ self,
384
+ api_key: str,
385
+ base_url: str = DEFAULT_BASE_URL,
386
+ timeout: float = DEFAULT_TIMEOUT,
387
+ ) -> None:
388
+ self.base_url = base_url.rstrip("/")
389
+ self._client = httpx.AsyncClient(
390
+ timeout=timeout,
391
+ headers={
392
+ "Authorization": f"Bearer {api_key}",
393
+ "Content-Type": "application/json",
394
+ },
395
+ )
396
+
397
+ async def _post(self, path: str, body: dict[str, Any]) -> Any:
398
+ response = await self._client.post(f"{self.base_url}{path}", json=body)
399
+ return SnoopScan._unwrap(response)
400
+
401
+ async def _get(self, path: str, params: dict[str, Any] | None = None) -> Any:
402
+ response = await self._client.get(f"{self.base_url}{path}", params=params)
403
+ return SnoopScan._unwrap(response)
404
+
405
+ async def _delete(self, path: str) -> Any:
406
+ return self._unwrap(await self._client.delete(f"{self.base_url}{path}"))
407
+
408
+ async def scrape(self, url: str, **options: Any) -> Document:
409
+ return Document.from_payload(await self._post("/v1/scrape", {"url": url, **options}))
410
+
411
+ async def crawl(self, url: str, **options: Any) -> CrawlJob:
412
+ data = await self._post("/v1/crawl", {"url": url, **options})
413
+ return CrawlJob(id=data["id"], status=data["status"])
414
+
415
+ async def crawl_status(self, job_id: str) -> CrawlJob:
416
+ data = await self._get(f"/v1/crawl/{job_id}")
417
+ return CrawlJob(
418
+ id=data["id"],
419
+ status=data["status"],
420
+ total=data.get("total", 0),
421
+ completed=data.get("completed", 0),
422
+ failed=data.get("failed", 0),
423
+ cost=data.get("cost") or {},
424
+ )
425
+
426
+ async def map(self, url: str, **options: Any) -> list[dict[str, Any]]:
427
+ return (await self._post("/v1/map", {"url": url, **options})).get("links", [])
428
+
429
+ async def products(self, url: str, **options: Any) -> dict[str, Any]:
430
+ return await self._post("/v1/products", {"url": url, **options})
431
+
432
+ async def posts(self, url: str, **options: Any) -> dict[str, Any]:
433
+ return await self._post("/v1/posts", {"url": url, **options})
434
+
435
+ async def create_monitor(
436
+ self, name: str, urls: list[str] | str, **options: Any
437
+ ) -> dict[str, Any]:
438
+ body: dict[str, Any] = {"name": name, **options}
439
+ body["urls" if isinstance(urls, list) else "url"] = urls
440
+ return await self._post("/v1/monitor", body)
441
+
442
+ async def monitors(self) -> list[dict[str, Any]]:
443
+ return (await self._get("/v1/monitor")).get("monitors", [])
444
+
445
+ async def monitor(self, monitor_id: str) -> dict[str, Any]:
446
+ return await self._get(f"/v1/monitor/{monitor_id}")
447
+
448
+ async def delete_monitor(self, monitor_id: str) -> None:
449
+ await self._delete(f"/v1/monitor/{monitor_id}")
450
+
451
+ async def run_monitor(self, monitor_id: str) -> dict[str, Any]:
452
+ return await self._post(f"/v1/monitor/{monitor_id}/run", {})
453
+
454
+ async def monitor_checks(self, monitor_id: str, limit: int = 20) -> list[dict[str, Any]]:
455
+ out = await self._get(f"/v1/monitor/{monitor_id}/checks", params={"limit": limit})
456
+ return out.get("checks", [])
457
+
458
+ async def extract(
459
+ self, urls: list[str], schema: dict[str, Any], prompt: str | None = None, **options: Any
460
+ ) -> list[dict[str, Any]]:
461
+ body: dict[str, Any] = {"urls": urls, "schema": schema, **options}
462
+ if prompt:
463
+ body["prompt"] = prompt
464
+ return await self._post("/v1/extract", body)
465
+
466
+ async def aclose(self) -> None:
467
+ await self._client.aclose()
468
+
469
+ async def __aenter__(self) -> AsyncSnoopScan:
470
+ return self
471
+
472
+ async def __aexit__(self, *exc: object) -> None:
473
+ await self.aclose()