scrapyio-sdk 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.
- scrapyio_sdk-0.1.0/.gitignore +64 -0
- scrapyio_sdk-0.1.0/PKG-INFO +142 -0
- scrapyio_sdk-0.1.0/README.md +116 -0
- scrapyio_sdk-0.1.0/pyproject.toml +45 -0
- scrapyio_sdk-0.1.0/src/scrapyio_sdk/__init__.py +20 -0
- scrapyio_sdk-0.1.0/src/scrapyio_sdk/client.py +71 -0
- scrapyio_sdk-0.1.0/src/scrapyio_sdk/errors.py +122 -0
- scrapyio_sdk-0.1.0/src/scrapyio_sdk/execution.py +113 -0
- scrapyio_sdk-0.1.0/src/scrapyio_sdk/http.py +39 -0
- scrapyio_sdk-0.1.0/src/scrapyio_sdk/pagination.py +24 -0
- scrapyio_sdk-0.1.0/src/scrapyio_sdk/platform.py +105 -0
- scrapyio_sdk-0.1.0/src/scrapyio_sdk/py.typed +1 -0
- scrapyio_sdk-0.1.0/src/scrapyio_sdk/run.py +84 -0
- scrapyio_sdk-0.1.0/src/scrapyio_sdk/runs.py +46 -0
- scrapyio_sdk-0.1.0/src/scrapyio_sdk/schedules.py +73 -0
- scrapyio_sdk-0.1.0/src/scrapyio_sdk/tool.py +57 -0
- scrapyio_sdk-0.1.0/src/scrapyio_sdk/tools.py +50 -0
- scrapyio_sdk-0.1.0/src/scrapyio_sdk/types.py +16 -0
- scrapyio_sdk-0.1.0/tests/__init__.py +1 -0
- scrapyio_sdk-0.1.0/tests/test_sdk.py +206 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Binaries
|
|
2
|
+
*.exe
|
|
3
|
+
*.exe~
|
|
4
|
+
*.dll
|
|
5
|
+
*.so
|
|
6
|
+
*.dylib
|
|
7
|
+
bin/
|
|
8
|
+
dist/
|
|
9
|
+
|
|
10
|
+
# Test binary
|
|
11
|
+
*.test
|
|
12
|
+
|
|
13
|
+
# Output of the go coverage tool
|
|
14
|
+
*.out
|
|
15
|
+
|
|
16
|
+
# Dependency directories
|
|
17
|
+
vendor/
|
|
18
|
+
|
|
19
|
+
# Go workspace file
|
|
20
|
+
go.work
|
|
21
|
+
|
|
22
|
+
# Environment variables
|
|
23
|
+
.env
|
|
24
|
+
.env.local
|
|
25
|
+
.env.*.local
|
|
26
|
+
|
|
27
|
+
# IDE
|
|
28
|
+
.idea/
|
|
29
|
+
.vscode/
|
|
30
|
+
*.swp
|
|
31
|
+
*.swo
|
|
32
|
+
*~
|
|
33
|
+
|
|
34
|
+
# OS
|
|
35
|
+
.DS_Store
|
|
36
|
+
Thumbs.db
|
|
37
|
+
|
|
38
|
+
# Project specific
|
|
39
|
+
stashedfiles/
|
|
40
|
+
data/
|
|
41
|
+
tmp/
|
|
42
|
+
logs/
|
|
43
|
+
*.log
|
|
44
|
+
|
|
45
|
+
# Config (if contains secrets)
|
|
46
|
+
configs/*.local.yaml
|
|
47
|
+
|
|
48
|
+
# Node.js
|
|
49
|
+
node_modules/
|
|
50
|
+
|
|
51
|
+
# Next.js
|
|
52
|
+
.next/
|
|
53
|
+
out/
|
|
54
|
+
|
|
55
|
+
# Build
|
|
56
|
+
**/dist/
|
|
57
|
+
backend/.env.example
|
|
58
|
+
|
|
59
|
+
# Admin
|
|
60
|
+
frontend/src/lib/api/admin.ts
|
|
61
|
+
backend/src/services/s3Image.ts
|
|
62
|
+
backend/src/routes/admin.ts
|
|
63
|
+
frontend/src/app/admin/upload-client.tsx
|
|
64
|
+
**/upload-client.tsx
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: scrapyio-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the Scrapy.io Platform API and publisher execution APIs
|
|
5
|
+
Project-URL: Homepage, https://scrapy.io
|
|
6
|
+
Project-URL: Documentation, https://docs.scrapy.io
|
|
7
|
+
Project-URL: Repository, https://github.com/scrapyio/public-dd
|
|
8
|
+
Author: Scrapy.io
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: scrapy.io,scrapyio,sdk,web-scraping
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: httpx>=0.27
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# scrapyio-sdk
|
|
28
|
+
|
|
29
|
+
Official Python SDK for [Scrapy.io](https://scrapy.io).
|
|
30
|
+
|
|
31
|
+
**Server-side only.** Keep API keys off client devices.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install scrapyio-sdk
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Authentication
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import os
|
|
43
|
+
from scrapyio_sdk import ScrapyIO
|
|
44
|
+
|
|
45
|
+
client = ScrapyIO(api_key=os.environ["SCRAPYIO_API_KEY"])
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Always sends `Authorization: Bearer <api_key>`.
|
|
49
|
+
|
|
50
|
+
## First request
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
tools = client.tools.list(q="instagram", limit=20)
|
|
54
|
+
# {"items", "total", "offset", "limit"}
|
|
55
|
+
|
|
56
|
+
tool = client.tools.get("datadoping", "instagram-profile-scraper")
|
|
57
|
+
# or:
|
|
58
|
+
same = client.tool("datadoping/instagram-profile-scraper").get()
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Run a scraper
|
|
62
|
+
|
|
63
|
+
### Sync (`/v1/api`)
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
result = client.tool("datadoping/instagram-profile-scraper").call({
|
|
67
|
+
"username": "nasa",
|
|
68
|
+
})
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Async (`/v1/scraper`) + poll
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
run = client.tool("datadoping/instagram-profile-scraper").start({
|
|
75
|
+
"usernames": ["nasa"],
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
finished = client.run(run["id"]).wait(
|
|
79
|
+
poll_interval_ms=3000,
|
|
80
|
+
timeout_ms=15 * 60_000,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
page = client.run(run["id"]).list_items(offset=0, limit=100)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Schedules
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
schedule = client.schedules.create(
|
|
90
|
+
{
|
|
91
|
+
"publisher": "datadoping",
|
|
92
|
+
"slug": "instagram-profile-scraper",
|
|
93
|
+
"runName": "nightly-ig",
|
|
94
|
+
"timezone": "UTC",
|
|
95
|
+
"frequency": "one-time",
|
|
96
|
+
"date": "2026-09-20",
|
|
97
|
+
"time": "23:50",
|
|
98
|
+
"inputs": ["nasa"],
|
|
99
|
+
},
|
|
100
|
+
idempotency_key="nightly-ig-v1",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
client.schedules.update(schedule["id"], {"isActive": False})
|
|
104
|
+
client.schedules.delete(schedule["id"])
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Errors
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from scrapyio_sdk import ScrapyIO, ScrapyIOError
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
client.tools.get("nope", "missing")
|
|
114
|
+
except ScrapyIOError as err:
|
|
115
|
+
# err.type, err.status, err.message, err.doc_url
|
|
116
|
+
...
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Mental model
|
|
120
|
+
|
|
121
|
+
```text
|
|
122
|
+
client
|
|
123
|
+
├── tools.list / tools.get
|
|
124
|
+
├── tool("publisher/slug").get / .call / .start
|
|
125
|
+
├── runs.list / runs.get
|
|
126
|
+
├── run(id).get / .wait / .list_items
|
|
127
|
+
└── schedules.list / get / create / update / delete
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Development
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
cd sdk/python
|
|
134
|
+
python -m pip install -e ".[dev]"
|
|
135
|
+
pytest
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Notes
|
|
139
|
+
|
|
140
|
+
- GET requests may retry on transient 5xx/429; execution POSTs are not auto-retried.
|
|
141
|
+
- `list_items()` is JSON-only in v0.1.
|
|
142
|
+
- Companion JS package: [`@scrapyio/sdk`](https://www.npmjs.com/package/@scrapyio/sdk).
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# scrapyio-sdk
|
|
2
|
+
|
|
3
|
+
Official Python SDK for [Scrapy.io](https://scrapy.io).
|
|
4
|
+
|
|
5
|
+
**Server-side only.** Keep API keys off client devices.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install scrapyio-sdk
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Authentication
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import os
|
|
17
|
+
from scrapyio_sdk import ScrapyIO
|
|
18
|
+
|
|
19
|
+
client = ScrapyIO(api_key=os.environ["SCRAPYIO_API_KEY"])
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Always sends `Authorization: Bearer <api_key>`.
|
|
23
|
+
|
|
24
|
+
## First request
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
tools = client.tools.list(q="instagram", limit=20)
|
|
28
|
+
# {"items", "total", "offset", "limit"}
|
|
29
|
+
|
|
30
|
+
tool = client.tools.get("datadoping", "instagram-profile-scraper")
|
|
31
|
+
# or:
|
|
32
|
+
same = client.tool("datadoping/instagram-profile-scraper").get()
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Run a scraper
|
|
36
|
+
|
|
37
|
+
### Sync (`/v1/api`)
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
result = client.tool("datadoping/instagram-profile-scraper").call({
|
|
41
|
+
"username": "nasa",
|
|
42
|
+
})
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Async (`/v1/scraper`) + poll
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
run = client.tool("datadoping/instagram-profile-scraper").start({
|
|
49
|
+
"usernames": ["nasa"],
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
finished = client.run(run["id"]).wait(
|
|
53
|
+
poll_interval_ms=3000,
|
|
54
|
+
timeout_ms=15 * 60_000,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
page = client.run(run["id"]).list_items(offset=0, limit=100)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Schedules
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
schedule = client.schedules.create(
|
|
64
|
+
{
|
|
65
|
+
"publisher": "datadoping",
|
|
66
|
+
"slug": "instagram-profile-scraper",
|
|
67
|
+
"runName": "nightly-ig",
|
|
68
|
+
"timezone": "UTC",
|
|
69
|
+
"frequency": "one-time",
|
|
70
|
+
"date": "2026-09-20",
|
|
71
|
+
"time": "23:50",
|
|
72
|
+
"inputs": ["nasa"],
|
|
73
|
+
},
|
|
74
|
+
idempotency_key="nightly-ig-v1",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
client.schedules.update(schedule["id"], {"isActive": False})
|
|
78
|
+
client.schedules.delete(schedule["id"])
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Errors
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from scrapyio_sdk import ScrapyIO, ScrapyIOError
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
client.tools.get("nope", "missing")
|
|
88
|
+
except ScrapyIOError as err:
|
|
89
|
+
# err.type, err.status, err.message, err.doc_url
|
|
90
|
+
...
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Mental model
|
|
94
|
+
|
|
95
|
+
```text
|
|
96
|
+
client
|
|
97
|
+
├── tools.list / tools.get
|
|
98
|
+
├── tool("publisher/slug").get / .call / .start
|
|
99
|
+
├── runs.list / runs.get
|
|
100
|
+
├── run(id).get / .wait / .list_items
|
|
101
|
+
└── schedules.list / get / create / update / delete
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Development
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
cd sdk/python
|
|
108
|
+
python -m pip install -e ".[dev]"
|
|
109
|
+
pytest
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Notes
|
|
113
|
+
|
|
114
|
+
- GET requests may retry on transient 5xx/429; execution POSTs are not auto-retried.
|
|
115
|
+
- `list_items()` is JSON-only in v0.1.
|
|
116
|
+
- Companion JS package: [`@scrapyio/sdk`](https://www.npmjs.com/package/@scrapyio/sdk).
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "scrapyio-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for the Scrapy.io Platform API and publisher execution APIs"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "Scrapy.io" }]
|
|
13
|
+
keywords = ["scrapyio", "scrapy.io", "web-scraping", "sdk"]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"httpx>=0.27",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 4 - Beta",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://scrapy.io"
|
|
31
|
+
Documentation = "https://docs.scrapy.io"
|
|
32
|
+
Repository = "https://github.com/scrapyio/public-dd"
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
dev = ["pytest>=8.0", "respx>=0.21"]
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
only-include = ["scrapyio_sdk"]
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.sdist]
|
|
41
|
+
include = ["src/scrapyio_sdk", "README.md", "pyproject.toml", "tests"]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
testpaths = ["tests"]
|
|
45
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Official Python SDK for Scrapy.io."""
|
|
2
|
+
|
|
3
|
+
from .client import ScrapyIO
|
|
4
|
+
from .errors import ScrapyIOError
|
|
5
|
+
from .pagination import Page
|
|
6
|
+
from .run import RunHandle
|
|
7
|
+
from .tool import ToolHandle
|
|
8
|
+
from .types import TERMINAL_RUN_STATUSES, is_terminal_run_status
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ScrapyIO",
|
|
12
|
+
"ScrapyIOError",
|
|
13
|
+
"Page",
|
|
14
|
+
"RunHandle",
|
|
15
|
+
"ToolHandle",
|
|
16
|
+
"TERMINAL_RUN_STATUSES",
|
|
17
|
+
"is_terminal_run_status",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from .errors import ScrapyIOError
|
|
6
|
+
from .platform import PlatformClient
|
|
7
|
+
from .run import RunHandle
|
|
8
|
+
from .runs import RunsResource
|
|
9
|
+
from .schedules import SchedulesResource
|
|
10
|
+
from .tool import ToolHandle
|
|
11
|
+
from .tools import ToolsResource
|
|
12
|
+
|
|
13
|
+
DEFAULT_BASE_URL = "https://api.scrapy.io/v1"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ScrapyIO:
|
|
17
|
+
"""Official Scrapy.io Python SDK.
|
|
18
|
+
|
|
19
|
+
API keys must stay server-side. Do not embed keys in client apps.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
api_key: str,
|
|
26
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
27
|
+
transport: httpx.BaseTransport | None = None,
|
|
28
|
+
max_retries: int = 2,
|
|
29
|
+
retry_backoff_ms: int = 250,
|
|
30
|
+
timeout: float = 60.0,
|
|
31
|
+
) -> None:
|
|
32
|
+
key = (api_key or "").strip()
|
|
33
|
+
if not key:
|
|
34
|
+
raise ScrapyIOError(
|
|
35
|
+
"ScrapyIO requires an api_key (e.g. os.environ['SCRAPYIO_API_KEY'])",
|
|
36
|
+
type="sdk_error",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
self._api_key = key
|
|
40
|
+
self._transport = transport
|
|
41
|
+
self._platform = PlatformClient(
|
|
42
|
+
base_url=base_url,
|
|
43
|
+
api_key=key,
|
|
44
|
+
transport=transport,
|
|
45
|
+
max_retries=max_retries,
|
|
46
|
+
retry_backoff_ms=retry_backoff_ms,
|
|
47
|
+
timeout=timeout,
|
|
48
|
+
)
|
|
49
|
+
self.tools = ToolsResource(self._platform)
|
|
50
|
+
self.runs = RunsResource(self._platform)
|
|
51
|
+
self.schedules = SchedulesResource(self._platform)
|
|
52
|
+
|
|
53
|
+
def close(self) -> None:
|
|
54
|
+
self._platform.close()
|
|
55
|
+
|
|
56
|
+
def __enter__(self) -> ScrapyIO:
|
|
57
|
+
return self
|
|
58
|
+
|
|
59
|
+
def __exit__(self, *args: object) -> None:
|
|
60
|
+
self.close()
|
|
61
|
+
|
|
62
|
+
def tool(self, ref: str) -> ToolHandle:
|
|
63
|
+
return ToolHandle(
|
|
64
|
+
self._platform,
|
|
65
|
+
api_key=self._api_key,
|
|
66
|
+
transport=self._transport,
|
|
67
|
+
ref=ref,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def run(self, run_id: str) -> RunHandle:
|
|
71
|
+
return RunHandle(self._platform, run_id)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
PlatformErrorType = Literal[
|
|
6
|
+
"unauthorized",
|
|
7
|
+
"forbidden",
|
|
8
|
+
"not_found",
|
|
9
|
+
"validation_error",
|
|
10
|
+
"insufficient_credits",
|
|
11
|
+
"conflict",
|
|
12
|
+
"rate_limit_exceeded",
|
|
13
|
+
"internal_error",
|
|
14
|
+
"timeout",
|
|
15
|
+
"publisher_error",
|
|
16
|
+
"sdk_error",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
_PLATFORM_ERROR_TYPES = frozenset(
|
|
20
|
+
{
|
|
21
|
+
"unauthorized",
|
|
22
|
+
"forbidden",
|
|
23
|
+
"not_found",
|
|
24
|
+
"validation_error",
|
|
25
|
+
"insufficient_credits",
|
|
26
|
+
"conflict",
|
|
27
|
+
"rate_limit_exceeded",
|
|
28
|
+
"internal_error",
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ScrapyIOError(Exception):
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
message: str,
|
|
37
|
+
*,
|
|
38
|
+
type: PlatformErrorType = "sdk_error",
|
|
39
|
+
status: int | None = None,
|
|
40
|
+
doc_url: str | None = None,
|
|
41
|
+
body: Any = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
super().__init__(message)
|
|
44
|
+
self.message = message
|
|
45
|
+
self.type = type
|
|
46
|
+
self.status = status
|
|
47
|
+
self.doc_url = doc_url
|
|
48
|
+
self.body = body
|
|
49
|
+
|
|
50
|
+
def __str__(self) -> str:
|
|
51
|
+
return self.message
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def is_platform_error_type(value: str) -> bool:
|
|
55
|
+
return value in _PLATFORM_ERROR_TYPES
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def error_from_platform_body(
|
|
59
|
+
status: int,
|
|
60
|
+
body: Any,
|
|
61
|
+
fallback_message: str = "Platform API request failed",
|
|
62
|
+
) -> ScrapyIOError:
|
|
63
|
+
if isinstance(body, dict):
|
|
64
|
+
err = body.get("error")
|
|
65
|
+
if isinstance(err, dict):
|
|
66
|
+
type_raw = err.get("type") if isinstance(err.get("type"), str) else "internal_error"
|
|
67
|
+
err_type: PlatformErrorType = (
|
|
68
|
+
type_raw if is_platform_error_type(type_raw) else "internal_error" # type: ignore[assignment]
|
|
69
|
+
)
|
|
70
|
+
msg = err.get("message")
|
|
71
|
+
return ScrapyIOError(
|
|
72
|
+
msg if isinstance(msg, str) and msg else fallback_message,
|
|
73
|
+
type=err_type,
|
|
74
|
+
status=status,
|
|
75
|
+
doc_url=err.get("doc_url") if isinstance(err.get("doc_url"), str) else None,
|
|
76
|
+
body=body,
|
|
77
|
+
)
|
|
78
|
+
return ScrapyIOError(
|
|
79
|
+
fallback_message,
|
|
80
|
+
type="unauthorized" if status == 401 else "internal_error",
|
|
81
|
+
status=status,
|
|
82
|
+
body=body,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def error_from_publisher_body(
|
|
87
|
+
status: int,
|
|
88
|
+
body: Any,
|
|
89
|
+
fallback_message: str = "Publisher execution request failed",
|
|
90
|
+
) -> ScrapyIOError:
|
|
91
|
+
if isinstance(body, dict):
|
|
92
|
+
if body.get("error") == "INSUFFICIENT_CREDITS":
|
|
93
|
+
msg = body.get("message")
|
|
94
|
+
return ScrapyIOError(
|
|
95
|
+
msg if isinstance(msg, str) and msg else "Insufficient credits",
|
|
96
|
+
type="insufficient_credits",
|
|
97
|
+
status=status or 402,
|
|
98
|
+
body=body,
|
|
99
|
+
)
|
|
100
|
+
err = body.get("error")
|
|
101
|
+
if isinstance(err, str) and err:
|
|
102
|
+
message = body.get("message")
|
|
103
|
+
full = f"{err}: {message}" if isinstance(message, str) and message else err
|
|
104
|
+
if status == 401:
|
|
105
|
+
err_type: PlatformErrorType = "unauthorized"
|
|
106
|
+
elif status == 404:
|
|
107
|
+
err_type = "not_found"
|
|
108
|
+
elif status == 400:
|
|
109
|
+
err_type = "validation_error"
|
|
110
|
+
elif status == 409:
|
|
111
|
+
err_type = "conflict"
|
|
112
|
+
else:
|
|
113
|
+
err_type = "publisher_error"
|
|
114
|
+
return ScrapyIOError(full, type=err_type, status=status, body=body)
|
|
115
|
+
if body.get("success") is False and isinstance(body.get("error"), str):
|
|
116
|
+
return ScrapyIOError(
|
|
117
|
+
body["error"],
|
|
118
|
+
type="unauthorized" if status == 401 else "publisher_error",
|
|
119
|
+
status=status,
|
|
120
|
+
body=body,
|
|
121
|
+
)
|
|
122
|
+
return ScrapyIOError(fallback_message, type="publisher_error", status=status, body=body)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .errors import ScrapyIOError, error_from_publisher_body
|
|
9
|
+
from .types import JsonObject, RunStatus
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def unwrap_publisher_success(body: Any) -> Any:
|
|
13
|
+
current = body
|
|
14
|
+
for _ in range(4):
|
|
15
|
+
if not isinstance(current, dict) or "success" not in current:
|
|
16
|
+
return current
|
|
17
|
+
if current.get("success") is False:
|
|
18
|
+
raise error_from_publisher_body(400, current)
|
|
19
|
+
if current.get("success") is not True:
|
|
20
|
+
return current
|
|
21
|
+
current = current.get("data")
|
|
22
|
+
return current
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def normalize_publisher_status(raw: str | None) -> RunStatus:
|
|
26
|
+
if not raw:
|
|
27
|
+
return "queued"
|
|
28
|
+
if raw == "completed":
|
|
29
|
+
return "succeeded"
|
|
30
|
+
if raw in {"queued", "running", "succeeded", "failed", "cancelled", "partial"}:
|
|
31
|
+
return raw # type: ignore[return-value]
|
|
32
|
+
return "queued"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def execute_call(
|
|
36
|
+
*,
|
|
37
|
+
api_key: str,
|
|
38
|
+
api_url: str,
|
|
39
|
+
input: JsonObject,
|
|
40
|
+
transport: httpx.BaseTransport | None = None,
|
|
41
|
+
timeout: float = 120.0,
|
|
42
|
+
) -> Any:
|
|
43
|
+
with httpx.Client(transport=transport, timeout=timeout) as client:
|
|
44
|
+
response = client.post(
|
|
45
|
+
api_url,
|
|
46
|
+
headers={
|
|
47
|
+
"Authorization": f"Bearer {api_key}",
|
|
48
|
+
"Content-Type": "application/json",
|
|
49
|
+
"Accept": "application/json",
|
|
50
|
+
},
|
|
51
|
+
json=input,
|
|
52
|
+
)
|
|
53
|
+
body = _read_json(response)
|
|
54
|
+
if not response.is_success:
|
|
55
|
+
raise error_from_publisher_body(response.status_code, body)
|
|
56
|
+
return unwrap_publisher_success(body)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def execute_start(
|
|
60
|
+
*,
|
|
61
|
+
api_key: str,
|
|
62
|
+
scraper_url: str,
|
|
63
|
+
input: JsonObject,
|
|
64
|
+
transport: httpx.BaseTransport | None = None,
|
|
65
|
+
timeout: float = 120.0,
|
|
66
|
+
) -> dict[str, Any]:
|
|
67
|
+
with httpx.Client(transport=transport, timeout=timeout) as client:
|
|
68
|
+
response = client.post(
|
|
69
|
+
scraper_url,
|
|
70
|
+
headers={
|
|
71
|
+
"Authorization": f"Bearer {api_key}",
|
|
72
|
+
"Content-Type": "application/json",
|
|
73
|
+
"Accept": "application/json",
|
|
74
|
+
},
|
|
75
|
+
json=input,
|
|
76
|
+
)
|
|
77
|
+
body = _read_json(response)
|
|
78
|
+
if not response.is_success:
|
|
79
|
+
raise error_from_publisher_body(response.status_code, body)
|
|
80
|
+
if not isinstance(body, dict) or not isinstance(body.get("taskId"), str) or not body["taskId"]:
|
|
81
|
+
raise ScrapyIOError(
|
|
82
|
+
"Publisher did not return a taskId",
|
|
83
|
+
type="publisher_error",
|
|
84
|
+
status=response.status_code,
|
|
85
|
+
body=body,
|
|
86
|
+
)
|
|
87
|
+
status = normalize_publisher_status(body.get("status") if isinstance(body.get("status"), str) else None)
|
|
88
|
+
return {
|
|
89
|
+
"id": body["taskId"],
|
|
90
|
+
"kind": "async_batch",
|
|
91
|
+
"status": status,
|
|
92
|
+
"toolId": "",
|
|
93
|
+
"runName": body.get("runName"),
|
|
94
|
+
"totalItems": body.get("totalItems"),
|
|
95
|
+
"processedItems": body.get("processedItems"),
|
|
96
|
+
"completedItems": body.get("completedItems"),
|
|
97
|
+
"failedItems": body.get("failedItems"),
|
|
98
|
+
"estimatedCost": body.get("estimatedCost"),
|
|
99
|
+
"billedAmount": None,
|
|
100
|
+
"publisher": None,
|
|
101
|
+
"toolSlug": None,
|
|
102
|
+
"createdAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
103
|
+
"completedAt": None,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _read_json(response: httpx.Response) -> Any:
|
|
108
|
+
if not response.content:
|
|
109
|
+
return None
|
|
110
|
+
try:
|
|
111
|
+
return response.json()
|
|
112
|
+
except ValueError:
|
|
113
|
+
return response.text
|