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.
- snoopscan-0.1.0/.gitignore +31 -0
- snoopscan-0.1.0/LICENSE +21 -0
- snoopscan-0.1.0/PKG-INFO +118 -0
- snoopscan-0.1.0/README.md +99 -0
- snoopscan-0.1.0/pyproject.toml +37 -0
- snoopscan-0.1.0/snoopscan/__init__.py +33 -0
- snoopscan-0.1.0/snoopscan/cli.py +419 -0
- snoopscan-0.1.0/snoopscan/client.py +473 -0
|
@@ -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
|
snoopscan-0.1.0/LICENSE
ADDED
|
@@ -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.
|
snoopscan-0.1.0/PKG-INFO
ADDED
|
@@ -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()
|