octocrawl-client 0.3.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,41 @@
1
+ # Node
2
+ node_modules/
3
+ dist/
4
+ *.tsbuildinfo
5
+ npm-debug.log*
6
+ yarn-debug.log*
7
+ yarn-error.log*
8
+ pnpm-debug.log*
9
+
10
+ # System
11
+ .DS_Store
12
+ Thumbs.db
13
+
14
+ # IDE
15
+ .vscode/
16
+ .idea/
17
+ *.swp
18
+ *.swo
19
+ *~
20
+
21
+ # Claude Code
22
+ .claude/worktrees/
23
+ .claude/scheduled-tasks/
24
+
25
+ # Testing
26
+ coverage/
27
+ .nyc_output/
28
+
29
+ # Build artifacts
30
+ *.log
31
+ .cache/
32
+ tmp/
33
+ temp/
34
+ output/
35
+ .w2l/
36
+
37
+ # Python client
38
+ python/**/__pycache__/
39
+ python/**/*.egg-info/
40
+ python/.pytest_cache/
41
+ python/dist/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 W2L contributors
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,47 @@
1
+ Metadata-Version: 2.5
2
+ Name: octocrawl-client
3
+ Version: 0.3.0
4
+ Summary: Python client for the Octocrawl API: web pages as Markdown, tables and JSON, each with its Evidence Record, into pandas.
5
+ Project-URL: Homepage, https://github.com/77777R7/Octocrawl
6
+ Project-URL: Source, https://github.com/77777R7/Octocrawl/tree/main/python
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
12
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
13
+ Requires-Python: >=3.9
14
+ Requires-Dist: httpx>=0.27
15
+ Provides-Extra: pandas
16
+ Requires-Dist: pandas>=1.5; extra == 'pandas'
17
+ Provides-Extra: test
18
+ Requires-Dist: pandas>=1.5; extra == 'test'
19
+ Requires-Dist: pytest>=7; extra == 'test'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # octocrawl-client (Python)
23
+
24
+ Python client for the Octocrawl API: web pages as Markdown, tables and JSON, each page with its **Evidence Record** (final URL, fetch time, HTTP status, robots.txt decision, hashes of what was read and delivered), into pandas.
25
+
26
+ ```bash
27
+ pip install 'octocrawl-client[pandas]'
28
+ npx octocrawl serve # a local Octocrawl API on http://127.0.0.1:8787
29
+ ```
30
+
31
+ ```python
32
+ import octocrawl_client
33
+
34
+ result = octocrawl_client.batch(
35
+ ["https://books.toscrape.com/", "https://quotes.toscrape.com/"],
36
+ formats=["markdown", "tables"],
37
+ )
38
+ df = result.to_pandas() # one row per page
39
+ df[["url", "status", "fetched_at", "http_status", "raw_sha256"]]
40
+ result.report["succeeded"], result.report["failed"]
41
+ ```
42
+
43
+ `to_pandas()` has one row per page, failed pages included, with these columns: `url`, `status`, `reason`, `final_url`, `fetched_at`, `http_status`, `lane`, `robots_decision`, `raw_sha256`, `markdown_sha256`, `extractor`, `source_commit`, `cache_state`, `cached_at` and `markdown`. A value W2L did not observe is missing (`None`, or `<NA>` for `http_status`), never 0. `result.items` keeps each page as the API answered it, its tables (`formats=["tables"]`) and full `evidenceRecord` included.
44
+
45
+ Options take the API's names in snake_case or camelCase: `max_age=3_600_000`, `only_main_content=False`, `include_tags=["main"]`, `parsers=[{"type": "pdf", "maxPages": 5}]`. `W2L(base_url=..., token=...)` names another API (default `W2L_API_URL`, else http://127.0.0.1:8787; `W2L_API_TOKEN`). A batch takes up to 1,000 URLs; `octocrawl_client.scrape`, `octocrawl_client.map` and `octocrawl_client.crawl` work the same way. (The name `octocrawl` on PyPI belongs to another project.)
46
+
47
+ Licence: MIT. Source and the API reference: https://github.com/77777R7/Octocrawl
@@ -0,0 +1,26 @@
1
+ # octocrawl-client (Python)
2
+
3
+ Python client for the Octocrawl API: web pages as Markdown, tables and JSON, each page with its **Evidence Record** (final URL, fetch time, HTTP status, robots.txt decision, hashes of what was read and delivered), into pandas.
4
+
5
+ ```bash
6
+ pip install 'octocrawl-client[pandas]'
7
+ npx octocrawl serve # a local Octocrawl API on http://127.0.0.1:8787
8
+ ```
9
+
10
+ ```python
11
+ import octocrawl_client
12
+
13
+ result = octocrawl_client.batch(
14
+ ["https://books.toscrape.com/", "https://quotes.toscrape.com/"],
15
+ formats=["markdown", "tables"],
16
+ )
17
+ df = result.to_pandas() # one row per page
18
+ df[["url", "status", "fetched_at", "http_status", "raw_sha256"]]
19
+ result.report["succeeded"], result.report["failed"]
20
+ ```
21
+
22
+ `to_pandas()` has one row per page, failed pages included, with these columns: `url`, `status`, `reason`, `final_url`, `fetched_at`, `http_status`, `lane`, `robots_decision`, `raw_sha256`, `markdown_sha256`, `extractor`, `source_commit`, `cache_state`, `cached_at` and `markdown`. A value W2L did not observe is missing (`None`, or `<NA>` for `http_status`), never 0. `result.items` keeps each page as the API answered it, its tables (`formats=["tables"]`) and full `evidenceRecord` included.
23
+
24
+ Options take the API's names in snake_case or camelCase: `max_age=3_600_000`, `only_main_content=False`, `include_tags=["main"]`, `parsers=[{"type": "pdf", "maxPages": 5}]`. `W2L(base_url=..., token=...)` names another API (default `W2L_API_URL`, else http://127.0.0.1:8787; `W2L_API_TOKEN`). A batch takes up to 1,000 URLs; `octocrawl_client.scrape`, `octocrawl_client.map` and `octocrawl_client.crawl` work the same way. (The name `octocrawl` on PyPI belongs to another project.)
25
+
26
+ Licence: MIT. Source and the API reference: https://github.com/77777R7/Octocrawl
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.21"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "octocrawl-client"
7
+ version = "0.3.0"
8
+ description = "Python client for the Octocrawl API: web pages as Markdown, tables and JSON, each with its Evidence Record, into pandas."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.9"
13
+ dependencies = ["httpx>=0.27"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Topic :: Internet :: WWW/HTTP :: Indexing/Search",
18
+ "Topic :: Scientific/Engineering :: Information Analysis",
19
+ ]
20
+
21
+ [project.optional-dependencies]
22
+ pandas = ["pandas>=1.5"]
23
+ test = ["pytest>=7", "pandas>=1.5"]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/77777R7/Octocrawl"
27
+ Source = "https://github.com/77777R7/Octocrawl/tree/main/python"
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/octocrawl_client"]
@@ -0,0 +1,17 @@
1
+ """Octocrawl: web pages as Markdown, tables and JSON, each with its Evidence Record.
2
+
3
+ import octocrawl_client
4
+ df = octocrawl_client.batch(["https://example.com/a", "https://example.com/b"], formats=["markdown", "tables"]).to_pandas()
5
+
6
+ The client talks to an Octocrawl API: `npx octocrawl serve` runs one locally (http://127.0.0.1:8787,
7
+ or set W2L_API_URL; W2L_API_TOKEN for a hosted one).
8
+ """
9
+
10
+ from ._version import __version__
11
+ from .client import DEFAULT_BASE_URL, MAX_BATCH_URLS, ORIGIN, W2L, JobResult, W2LError, api_name, batch, crawl, map, scrape
12
+ from .frame import EVIDENCE_COLUMNS, evidence_row, evidence_rows
13
+
14
+ __all__ = [
15
+ "__version__", "W2L", "W2LError", "JobResult", "batch", "crawl", "map", "scrape", "api_name",
16
+ "EVIDENCE_COLUMNS", "evidence_row", "evidence_rows", "DEFAULT_BASE_URL", "MAX_BATCH_URLS", "ORIGIN",
17
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
@@ -0,0 +1,201 @@
1
+ """The W2L API over HTTP: scrape, map, batch and crawl, each page with its Evidence Record."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import time
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Iterable, Mapping, Optional, Sequence
10
+
11
+ import httpx
12
+
13
+ from ._version import __version__
14
+ from .frame import evidence_rows, to_pandas
15
+
16
+ DEFAULT_BASE_URL = "http://127.0.0.1:8787"
17
+ #: What the client records as the `origin` of every request, for W2L's own records; nothing goes to the target.
18
+ ORIGIN = f"py-sdk@{__version__}"
19
+ #: The most URLs one batch takes (the API's limit).
20
+ MAX_BATCH_URLS = 1000
21
+ TERMINAL = ("completed", "failed", "cancelled")
22
+
23
+ # Option names whose acronym the plain snake_case → camelCase rule would spell wrong.
24
+ _ACRONYMS = {
25
+ "regex_on_full_url": "regexOnFullURL",
26
+ "ignore_invalid_urls": "ignoreInvalidURLs",
27
+ "deduplicate_similar_urls": "deduplicateSimilarURLs",
28
+ }
29
+
30
+
31
+ def api_name(key: str) -> str:
32
+ """`max_age` → `maxAge`; a name already in camelCase is kept as it is."""
33
+ if key in _ACRONYMS:
34
+ return _ACRONYMS[key]
35
+ return re.sub(r"_([a-z0-9])", lambda m: m.group(1).upper(), key)
36
+
37
+
38
+ def _options(options: Mapping[str, Any]) -> dict[str, Any]:
39
+ return {api_name(key): value for key, value in options.items() if value is not None}
40
+
41
+
42
+ class W2LError(Exception):
43
+ """An answer of the API that is not a result: a refused request (400), an unknown id (404), a rate limit (429), ..."""
44
+
45
+ def __init__(self, status: int, message: str, code: Optional[str] = None, details: Any = None, agent_hints: Optional[list[str]] = None):
46
+ super().__init__(f"{status} {code or 'error'}: {message}")
47
+ self.status = status
48
+ self.code = code
49
+ self.message = message
50
+ self.details = details
51
+ self.agent_hints = agent_hints or []
52
+
53
+
54
+ @dataclass
55
+ class JobResult:
56
+ """A finished batch or crawl: its final report and every page it recorded, failed ones included."""
57
+
58
+ task_id: str
59
+ report: dict[str, Any]
60
+ items: list[dict[str, Any]] = field(default_factory=list)
61
+
62
+ def rows(self, include_markdown: bool = True) -> list[dict[str, Any]]:
63
+ """One row per page with the evidence columns (see `EVIDENCE_COLUMNS`)."""
64
+ return evidence_rows(self.items, include_markdown=include_markdown)
65
+
66
+ def to_pandas(self, include_markdown: bool = True):
67
+ """A pandas DataFrame with one row per page and the evidence columns; needs `pip install 'octocrawl-client[pandas]'`."""
68
+ return to_pandas(self.rows(include_markdown=include_markdown))
69
+
70
+
71
+ class W2L:
72
+ """A client of one W2L API: a local `octocrawl serve` (default `W2L_API_URL`, else http://127.0.0.1:8787) or a hosted one with a token."""
73
+
74
+ def __init__(
75
+ self,
76
+ base_url: Optional[str] = None,
77
+ token: Optional[str] = None,
78
+ *,
79
+ timeout: float = 330.0,
80
+ poll_interval: float = 1.0,
81
+ transport: Optional[httpx.BaseTransport] = None,
82
+ ):
83
+ self.base_url = (base_url or os.environ.get("W2L_API_URL") or DEFAULT_BASE_URL).rstrip("/")
84
+ token = token if token is not None else os.environ.get("W2L_API_TOKEN") or None
85
+ headers = {"user-agent": ORIGIN}
86
+ if token:
87
+ headers["authorization"] = f"Bearer {token}"
88
+ # A scrape is answered at its deadline (up to 300 s), so reads wait a little longer than that.
89
+ self._http = httpx.Client(base_url=self.base_url, headers=headers, timeout=timeout, transport=transport)
90
+ self.poll_interval = poll_interval
91
+
92
+ def close(self) -> None:
93
+ self._http.close()
94
+
95
+ def __enter__(self) -> "W2L":
96
+ return self
97
+
98
+ def __exit__(self, *_exc: object) -> None:
99
+ self.close()
100
+
101
+ def _call(self, method: str, path: str, *, retries: int = 0, **kwargs: Any) -> Any:
102
+ """One API call; with `retries`, a network error, a 5xx or a 429 is tried again (a poll or a page read, never a start)."""
103
+ for attempt in range(retries + 1):
104
+ try:
105
+ response = self._http.request(method, path, **kwargs)
106
+ except httpx.TransportError:
107
+ if attempt == retries:
108
+ raise
109
+ time.sleep(min(2 ** attempt, 10))
110
+ continue
111
+ if (response.status_code >= 500 or response.status_code == 429) and attempt < retries:
112
+ time.sleep(min(float(response.headers.get("retry-after", 2 ** attempt)), 30))
113
+ continue
114
+ break
115
+ if response.status_code >= 400:
116
+ try:
117
+ body = response.json()
118
+ except ValueError:
119
+ body = {"error": response.text}
120
+ raise W2LError(response.status_code, str(body.get("error", "")), body.get("code"), body.get("details"), body.get("agentHints"))
121
+ return response.json()
122
+
123
+ def scrape(self, url: str, **options: Any) -> dict[str, Any]:
124
+ """One page: the scrape response with its `evidenceRecord` (compact unless `debug=True`)."""
125
+ return self._call("POST", "/v1/scrape", json={"debug": False, **_options(options), "url": url, "origin": ORIGIN})
126
+
127
+ def map(self, url: str, **options: Any) -> dict[str, Any]:
128
+ """A site's URLs from its sitemaps and start page, without fetching each page."""
129
+ return self._call("POST", "/v1/map", json={**_options(options), "url": url, "origin": ORIGIN})
130
+
131
+ def batch(self, urls: Iterable[str], *, wait_timeout: Optional[float] = None, **options: Any) -> JobResult:
132
+ """Scrape up to 1,000 URLs as one batch, wait until it ends, and return its report and every item."""
133
+ urls = list(urls)
134
+ if not urls:
135
+ raise ValueError("batch takes at least one URL")
136
+ if len(urls) > MAX_BATCH_URLS:
137
+ raise ValueError(f"a batch takes at most {MAX_BATCH_URLS} URLs; split the list and call batch for each part")
138
+ accepted = self._call("POST", "/v1/batches", json={**_options(options), "urls": urls, "origin": ORIGIN})
139
+ task_id = accepted["taskId"]
140
+ report = self._wait(f"/v1/batches/{task_id}", wait_timeout)
141
+ return JobResult(task_id, report, self._items(f"/v1/batches/{task_id}/items"))
142
+
143
+ def crawl(self, url: str, *, wait_timeout: Optional[float] = None, **options: Any) -> JobResult:
144
+ """Crawl from `url`, wait until the crawl ends, and return its report and every page of its latest attempt."""
145
+ accepted = self._call("POST", "/v1/crawl", json={**_options(options), "url": url, "origin": ORIGIN})
146
+ task_id = accepted["taskId"]
147
+ report = self._wait(f"/v1/crawl/{task_id}", wait_timeout)
148
+ return JobResult(task_id, report, self._items(f"/v1/crawl/{task_id}/pages") + self._items(f"/v1/crawl/{task_id}/errors"))
149
+
150
+ def _wait(self, path: str, wait_timeout: Optional[float]) -> dict[str, Any]:
151
+ deadline = None if wait_timeout is None else time.monotonic() + wait_timeout
152
+ while True:
153
+ report = self._call("GET", path, retries=5)
154
+ if report.get("status") in TERMINAL:
155
+ return report
156
+ if deadline is not None and time.monotonic() >= deadline:
157
+ raise TimeoutError(f"{path} is still {report.get('status')} after {wait_timeout} s; it keeps running on the server")
158
+ time.sleep(self.poll_interval)
159
+
160
+ def _items(self, path: str) -> list[dict[str, Any]]:
161
+ items: list[dict[str, Any]] = []
162
+ cursor: Optional[str] = None
163
+ while True:
164
+ params: dict[str, Any] = {"limit": 50}
165
+ if cursor is not None:
166
+ params["cursor"] = cursor
167
+ page = self._call("GET", path, params=params, retries=5)
168
+ items.extend(page.get("items", []))
169
+ cursor = page.get("nextCursor")
170
+ if not page.get("hasMore"):
171
+ return items
172
+ if cursor is None:
173
+ raise W2LError(200, f"{path} says it has more items but gives no cursor; {len(items)} items read so far")
174
+
175
+
176
+ def _default() -> W2L:
177
+ return W2L()
178
+
179
+
180
+ def scrape(url: str, **options: Any) -> dict[str, Any]:
181
+ """`W2L().scrape(...)` on the API named by `W2L_API_URL` (default http://127.0.0.1:8787)."""
182
+ with _default() as client:
183
+ return client.scrape(url, **options)
184
+
185
+
186
+ def map(url: str, **options: Any) -> dict[str, Any]: # noqa: A001 - the API's own name
187
+ """`W2L().map(...)` on the API named by `W2L_API_URL`."""
188
+ with _default() as client:
189
+ return client.map(url, **options)
190
+
191
+
192
+ def batch(urls: Sequence[str], **options: Any) -> JobResult:
193
+ """`W2L().batch(...)`: `octocrawl_client.batch(urls).to_pandas()` gives one row per page with its evidence."""
194
+ with _default() as client:
195
+ return client.batch(urls, **options)
196
+
197
+
198
+ def crawl(url: str, **options: Any) -> JobResult:
199
+ """`W2L().crawl(...)` on the API named by `W2L_API_URL`."""
200
+ with _default() as client:
201
+ return client.crawl(url, **options)
@@ -0,0 +1,73 @@
1
+ """Pages as rows with their evidence, for pandas or anything that takes a list of dicts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Iterable, Optional
6
+
7
+ #: The columns of `rows()` and `to_pandas()`, in order. A value W2L did not observe is None, never 0 or a guess.
8
+ EVIDENCE_COLUMNS = (
9
+ "url", # the URL the batch or crawl was given (or discovered)
10
+ "status", # success, partial, empty_verified, blocked, failed, cancelled, budget_exceeded, duplicate
11
+ "reason", # the failure, block or budget reason; None for other statuses
12
+ "final_url", # the URL that answered, after redirects
13
+ "fetched_at", # UTC ISO time the answer was received
14
+ "http_status",
15
+ "lane", # http, browser_local, provider, ...
16
+ "robots_decision", # allowed, disallowed, no_robots; None when none was made
17
+ "raw_sha256", # SHA-256 of the page as read
18
+ "markdown_sha256", # SHA-256 of the delivered Markdown
19
+ "extractor", # the extractor version, e.g. extract-tf/5 or pdf-text/1
20
+ "source_commit", # the W2L commit that ran, when the server declares it
21
+ "cache_state", # hit or miss when the request asked the cache; None otherwise
22
+ "cached_at", # on a hit, when the reused result was fetched
23
+ "markdown",
24
+ )
25
+
26
+
27
+ def _get(value: Any, *path: str) -> Any:
28
+ for key in path:
29
+ if not isinstance(value, dict):
30
+ return None
31
+ value = value.get(key)
32
+ return value
33
+
34
+
35
+ def evidence_row(item: dict[str, Any], include_markdown: bool = True) -> dict[str, Any]:
36
+ """One batch item or crawl page as a row of EVIDENCE_COLUMNS, read from its `evidenceRecord`."""
37
+ record: Optional[dict[str, Any]] = item.get("evidenceRecord")
38
+ row = {
39
+ "url": item.get("url"),
40
+ "status": item.get("status"),
41
+ "reason": _get(record, "reason") if record is not None else (item.get("failureReason") or item.get("blockReason") or item.get("budgetExceeded")),
42
+ "final_url": _get(record, "finalUrl"),
43
+ "fetched_at": _get(record, "fetchedAt"),
44
+ "http_status": _get(record, "httpStatus"),
45
+ "lane": _get(record, "lane") if record is not None else item.get("lane"),
46
+ "robots_decision": _get(record, "robotsDecision", "decision"),
47
+ "raw_sha256": _get(record, "rawSha256"),
48
+ "markdown_sha256": _get(record, "outputSha256", "markdown"),
49
+ "extractor": _get(record, "extractor", "version"),
50
+ "source_commit": _get(record, "extractor", "commit"),
51
+ "cache_state": item.get("cacheState"),
52
+ "cached_at": item.get("cachedAt"),
53
+ "markdown": item.get("markdown"),
54
+ }
55
+ if not include_markdown:
56
+ row.pop("markdown")
57
+ return row
58
+
59
+
60
+ def evidence_rows(items: Iterable[dict[str, Any]], include_markdown: bool = True) -> list[dict[str, Any]]:
61
+ return [evidence_row(item, include_markdown=include_markdown) for item in items]
62
+
63
+
64
+ def to_pandas(rows: list[dict[str, Any]]):
65
+ try:
66
+ import pandas as pd
67
+ except ImportError as error: # pragma: no cover - depends on the environment
68
+ raise ImportError("to_pandas needs pandas: pip install 'octocrawl-client[pandas]'") from error
69
+ columns = [column for column in EVIDENCE_COLUMNS if not rows or column in rows[0]]
70
+ frame = pd.DataFrame(rows, columns=columns)
71
+ # Unknown stays missing: an HTTP status W2L did not observe is <NA>, not 0.
72
+ frame["http_status"] = frame["http_status"].astype("Int64")
73
+ return frame
@@ -0,0 +1,139 @@
1
+ import json
2
+
3
+ import httpx
4
+ import pytest
5
+
6
+ import octocrawl_client as w2l
7
+ from octocrawl_client import EVIDENCE_COLUMNS, W2L, W2LError, api_name
8
+
9
+ RECORD = {
10
+ "schemaVersion": "w2l.evidence/1", "requestedUrl": "https://a.example/", "finalUrl": "https://a.example/", "fetchedAt": "2026-10-03T08:00:00.000Z",
11
+ "httpStatus": 200, "status": "success", "reason": None, "lane": "http",
12
+ "robotsDecision": {"decision": "allowed"}, "rawSha256": "a" * 64, "outputSha256": {"markdown": "b" * 64, "json": None},
13
+ "extractor": {"name": "extract-tf", "version": "extract-tf/5", "commit": None},
14
+ }
15
+ ITEMS = [
16
+ {"id": "1", "url": "https://a.example/", "status": "success", "markdown": "# A", "evidenceRecord": RECORD, "cacheState": "hit", "cachedAt": "2026-10-03T08:00:00.000Z"},
17
+ {"id": "2", "url": "https://b.example/", "status": "failed", "failureReason": "dns_error", "markdown": None, "evidenceRecord": {**RECORD, "requestedUrl": "https://b.example/", "finalUrl": None, "fetchedAt": None, "httpStatus": None, "status": "failed", "reason": "dns_error", "robotsDecision": None, "rawSha256": None, "outputSha256": {"markdown": None, "json": None}}},
18
+ {"id": "3", "url": "https://c.example/", "status": "pending", "markdown": None, "evidenceRecord": None},
19
+ ]
20
+
21
+
22
+ def api(calls):
23
+ """A fake W2L API: a batch that is running on the first poll and completed on the second, with its items on two pages."""
24
+ polls = {"n": 0}
25
+
26
+ def handle(request: httpx.Request) -> httpx.Response:
27
+ calls.append((request.method, request.url.path, dict(request.url.params), json.loads(request.content) if request.content else None, request.headers.get("authorization")))
28
+ if request.method == "POST" and request.url.path == "/v1/batches":
29
+ return httpx.Response(202, json={"taskId": "t1"})
30
+ if request.url.path == "/v1/batches/t1":
31
+ polls["n"] += 1
32
+ return httpx.Response(200, json={"taskId": "t1", "status": "running" if polls["n"] == 1 else "completed", "succeeded": 1, "failed": 1})
33
+ if request.url.path == "/v1/batches/t1/items":
34
+ first = request.url.params.get("cursor") is None
35
+ return httpx.Response(200, json={"items": ITEMS[:2] if first else ITEMS[2:], "hasMore": first, "nextCursor": "c2" if first else None})
36
+ if request.url.path == "/v1/scrape":
37
+ body = json.loads(request.content)
38
+ if body.get("maxAge") == -1:
39
+ return httpx.Response(400, json={"error": "maxAge must be an integer number of milliseconds from 0 to 315360000000", "code": "invalid_request"})
40
+ return httpx.Response(200, json={"status": "success", "markdown": "# A", "evidenceRecord": RECORD})
41
+ return httpx.Response(404, json={"error": "not found", "code": "not_found"})
42
+
43
+ return httpx.MockTransport(handle)
44
+
45
+
46
+ def test_batch_waits_pages_and_gives_evidence_rows():
47
+ calls = []
48
+ client = W2L("http://w2l.test", token="t0k", poll_interval=0, transport=api(calls))
49
+ result = client.batch(["https://a.example/", "https://b.example/", "https://c.example/"], formats=["markdown", "tables"], max_age=3_600_000, ignore_invalid_urls=True)
50
+ method, path, _, body, auth = calls[0]
51
+ assert (method, path, auth) == ("POST", "/v1/batches", "Bearer t0k")
52
+ assert body == {"formats": ["markdown", "tables"], "maxAge": 3_600_000, "ignoreInvalidURLs": True, "urls": ["https://a.example/", "https://b.example/", "https://c.example/"], "origin": w2l.ORIGIN}
53
+ assert [c[1] for c in calls].count("/v1/batches/t1") == 2
54
+ assert [c[2] for c in calls if c[1] == "/v1/batches/t1/items"] == [{"limit": "50"}, {"limit": "50", "cursor": "c2"}]
55
+ assert result.task_id == "t1" and result.report["status"] == "completed" and len(result.items) == 3
56
+
57
+ rows = result.rows()
58
+ assert list(rows[0]) == list(EVIDENCE_COLUMNS)
59
+ assert rows[0] == {
60
+ "url": "https://a.example/", "status": "success", "reason": None, "final_url": "https://a.example/", "fetched_at": "2026-10-03T08:00:00.000Z",
61
+ "http_status": 200, "lane": "http", "robots_decision": "allowed", "raw_sha256": "a" * 64, "markdown_sha256": "b" * 64,
62
+ "extractor": "extract-tf/5", "source_commit": None, "cache_state": "hit", "cached_at": "2026-10-03T08:00:00.000Z", "markdown": "# A",
63
+ }
64
+ # Unknown stays None: no fetch time, no status, no hash for a page that was not read; no record at all for one without a result.
65
+ assert rows[1]["reason"] == "dns_error" and rows[1]["http_status"] is None and rows[1]["fetched_at"] is None
66
+ assert rows[2]["status"] == "pending" and rows[2]["final_url"] is None and rows[2]["lane"] is None
67
+
68
+
69
+ def test_to_pandas_keeps_unknown_missing():
70
+ pd = pytest.importorskip("pandas")
71
+ client = W2L("http://w2l.test", poll_interval=0, transport=api([]))
72
+ frame = client.batch(["https://a.example/", "https://b.example/", "https://c.example/"]).to_pandas(include_markdown=False)
73
+ assert list(frame.columns) == [c for c in EVIDENCE_COLUMNS if c != "markdown"]
74
+ assert frame["http_status"].tolist()[0] == 200
75
+ assert pd.isna(frame["http_status"].tolist()[1]) and pd.isna(frame["http_status"].tolist()[2])
76
+ assert str(frame["http_status"].dtype) == "Int64"
77
+
78
+
79
+ def test_errors_carry_the_api_code_and_options_are_named_as_the_api_names_them():
80
+ client = W2L("http://w2l.test", transport=api([]))
81
+ with pytest.raises(W2LError) as error:
82
+ client.scrape("https://a.example/", max_age=-1)
83
+ assert error.value.status == 400 and error.value.code == "invalid_request" and "maxAge must be" in error.value.message
84
+ assert client.scrape("https://a.example/")["evidenceRecord"]["status"] == "success"
85
+ assert api_name("only_main_content") == "onlyMainContent"
86
+ assert api_name("regex_on_full_url") == "regexOnFullURL"
87
+ assert api_name("deduplicate_similar_urls") == "deduplicateSimilarURLs"
88
+ assert api_name("maxAge") == "maxAge"
89
+ with pytest.raises(ValueError):
90
+ client.batch(["https://a.example/"] * 1001)
91
+ with pytest.raises(ValueError):
92
+ client.batch([])
93
+
94
+
95
+ def test_crawl_reads_pages_and_errors_and_polls_through_a_transient_failure():
96
+ hits = {"status": 0}
97
+
98
+ def handle(request: httpx.Request) -> httpx.Response:
99
+ if request.method == "POST":
100
+ return httpx.Response(202, json={"taskId": "c1"})
101
+ if request.url.path == "/v1/crawl/c1":
102
+ hits["status"] += 1
103
+ if hits["status"] == 1:
104
+ raise httpx.ConnectError("blip")
105
+ if hits["status"] == 2:
106
+ return httpx.Response(503, json={"error": "busy"}, headers={"retry-after": "0"})
107
+ return httpx.Response(200, json={"taskId": "c1", "status": "completed"})
108
+ if request.url.path == "/v1/crawl/c1/pages":
109
+ return httpx.Response(200, json={"items": [ITEMS[0]], "hasMore": False, "nextCursor": None})
110
+ if request.url.path == "/v1/crawl/c1/errors":
111
+ return httpx.Response(200, json={"items": [ITEMS[1]], "hasMore": False, "nextCursor": None})
112
+ return httpx.Response(404, json={"error": "not found"})
113
+
114
+ client = W2L("http://w2l.test", poll_interval=0, transport=httpx.MockTransport(handle))
115
+ result = client.crawl("https://a.example/", max_pages=2, sitemap="skip")
116
+ assert result.report["status"] == "completed" and hits["status"] == 3
117
+ assert [row["status"] for row in result.rows()] == ["success", "failed"]
118
+
119
+
120
+ def test_a_listing_that_has_more_but_no_cursor_is_an_error():
121
+ def handle(request: httpx.Request) -> httpx.Response:
122
+ if request.method == "POST":
123
+ return httpx.Response(202, json={"taskId": "t1"})
124
+ if request.url.path == "/v1/batches/t1":
125
+ return httpx.Response(200, json={"status": "completed"})
126
+ return httpx.Response(200, json={"items": [ITEMS[0]], "hasMore": True, "nextCursor": None})
127
+
128
+ with pytest.raises(W2LError, match="no cursor"):
129
+ W2L("http://w2l.test", poll_interval=0, transport=httpx.MockTransport(handle)).batch(["https://a.example/"])
130
+
131
+
132
+ def test_the_base_url_and_token_come_from_the_environment(monkeypatch):
133
+ monkeypatch.setenv("W2L_API_URL", "http://env.test:9000/")
134
+ monkeypatch.setenv("W2L_API_TOKEN", "envtok")
135
+ calls = []
136
+ client = W2L(transport=api(calls))
137
+ assert client.base_url == "http://env.test:9000"
138
+ client.scrape("https://a.example/")
139
+ assert calls[0][4] == "Bearer envtok"