Skip to content

Commit 15e85e9

Browse files
committed
feat: 0.3.0 - typed result objects, list/nested extraction, pooled browser
Picks up the three deferred capability items from 0.2.0. Typed results (scrapo.results): - scrape() returns ScrapeResult, crawl() returns CrawlResult, result.extraction is an ExtractionView. Pydantic models: attribute access, validation, model_dump(). They also keep dict-style read access (result["key"], result.get(), "key" in result) so existing 0.1/0.2 code is unaffected; isinstance(result, dict) is no longer true. MCP server serializes via model_dump(mode="json"). List / nested extraction: - schema fields typed list[BaseModel] are extracted as repeated DOM elements: the LLM returns {"field": {"__list__": "<repeating el>", "<sub>": "<rel sel>"}}, verified against the live page, cached, and replayed with zero tokens like scalar fields. New scrapo.extract.schema.list_fields() does the detection and feeds a hint into the prompt. Browser pooling (scrapo.access.browser_pool.BrowserPool): - a TierRouter lazily launches one headless Chromium and reuses it across fetches; proxy settings move to the context level so one browser serves rotating proxies. crawl() shares one router across all pages instead of cold- launching per page. TierRouter.aclose() tears it down; scrape() closes the router it creates; scrape() gained a router= kwarg for explicit reuse. - playwright-stealth is applied to the page before navigation (was racing a context event) and tries both the old and new plugin APIs. Tests: typed-result and crawl-result assertions, list extraction (LLM path + cache replay), list_fields detection, browser-pool/router teardown (94 tests, still fully offline). ruff + mypy --strict clean. Docs and CHANGELOG updated; no em-dashes in Markdown. Bump to 0.3.0.
1 parent 1a207c3 commit 15e85e9

19 files changed

Lines changed: 644 additions & 245 deletions

CHANGELOG.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.3.0] - 2026-05-10
11+
12+
Capability release: typed results, list/nested extraction, and a reused browser.
13+
14+
### Added
15+
16+
- **Typed result objects** (`scrapo.results`): `scrape()` returns `ScrapeResult`, `crawl()` returns `CrawlResult`, and `extraction` on a result is an `ExtractionView`. They are Pydantic models, so you get attribute access (`result.markdown`), validation, and `result.model_dump()` for serialization. They also support `result["key"]`, `result.get("key", default)`, and `"key" in result` so code written against the 0.1/0.2 dict shape keeps working unchanged.
17+
- **List / nested extraction**: schema fields typed `list[SomeBaseModel]` are now extracted as repeated DOM elements. The LLM returns a container selector plus per-subfield selectors (`{"products": {"__list__": "ul.grid > li", "name": "h3", "price": ".price"}}`), those are verified against the live page, cached, and replayed on later runs with zero LLM tokens, exactly like scalar fields. `scrapo.extract.schema.list_fields()` exposes the detection.
18+
- **Browser-context pooling** (`scrapo.access.browser_pool.BrowserPool`): a `TierRouter` now lazily launches one Chromium and reuses it across fetches (proxy settings move to the context level so a single browser serves rotating proxies). A crawl no longer cold-launches a browser per page. `TierRouter.aclose()` tears it down; `scrape()` closes the router it creates, and `crawl()` shares one router across all pages. `scrape()` gained a `router=` keyword for callers that want to reuse one explicitly.
19+
- The flaky `playwright-stealth` integration is applied to the page before navigation (instead of via a context event that raced the first page) and tries both the old and new plugin entry points.
20+
21+
### Changed
22+
23+
- `scrape()` / `crawl()` return Pydantic models instead of plain `dict`. Dict-style read access still works; `isinstance(result, dict)` does not. The MCP server serializes results with `model_dump(mode="json")`.
24+
1025
## [0.2.0] - 2026-05-10
1126

1227
Hardening release: makes the "cost-aware" and "production crawling" claims real,
@@ -73,6 +88,7 @@ Initial public release.
7388
- The `robots.txt` gate is opt-in: set `SCRAPO_RESPECT_ROBOTS=1` (or `Config(respect_robots=True)`) to enable it. You are responsible for complying with each site's terms of use and applicable law.
7489
- Alpha status: the public API and core subsystems are stable, but the T4 agent driver, full action caching, an S3 snapshot adapter, and a hosted control plane are intentionally lightweight or not yet implemented.
7590

76-
[Unreleased]: https://github.com/vikast908/Scrapo/compare/v0.2.0...HEAD
91+
[Unreleased]: https://github.com/vikast908/Scrapo/compare/v0.3.0...HEAD
92+
[0.3.0]: https://github.com/vikast908/Scrapo/compare/v0.2.0...v0.3.0
7793
[0.2.0]: https://github.com/vikast908/Scrapo/compare/v0.1.0...v0.2.0
7894
[0.1.0]: https://github.com/vikast908/Scrapo/releases/tag/v0.1.0

README.md

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -107,13 +107,14 @@ Plus a feature nobody else ships: **deterministic replay** of every fetch, so ex
107107

108108
```
109109
scrapo/
110-
├── access/ # (1) 5-tier router + Bright Data / Oxylabs / Scrapfly / Zyte
111-
├── extract/ # (2) hybrid selector + LLM, model pinning, cost-aware budget
110+
├── access/ # (1) 5-tier router + pooled browser + Bright Data / Oxylabs / Scrapfly / Zyte
111+
├── extract/ # (2) hybrid selector + LLM (scalar & list fields), model pinning, cost-aware budget
112112
├── shape/ # (3) selectolax markdown + heading chunker
113113
├── replay/ # (4) snapshot store + field-level diff
114114
├── policy/ # (5) robots, PII (flag or redact), geo, append-only audit
115115
├── crawl/ # persistent SQLite queue + async scheduler
116116
├── agent/ # (6) MCP server + tool schemas
117+
├── results.py # typed ScrapeResult / CrawlResult / ExtractionView
117118
├── security.py # SSRF guard for fetch targets
118119
├── _db.py # tuned SQLite connections (WAL, busy timeout)
119120
├── logging.py # structlog setup for the CLI / MCP server
@@ -139,33 +140,38 @@ playwright install chromium
139140
import asyncio, scrapo
140141

141142
async def main():
142-
res = await scrapo.scrape("https://example.com/")
143-
print(res["markdown"])
144-
print("run_id:", res["run_id"])
143+
res = await scrapo.scrape("https://example.com/") # res is a typed ScrapeResult
144+
print(res.markdown)
145+
print("run_id:", res.run_id)
146+
# res["markdown"] / res.get("status") still work too (back-compat with the 0.1 dict)
145147

146148
asyncio.run(main())
147149
```
148150

149-
### 2. Typed extraction (LLM once, selectors forever)
151+
### 2. Typed extraction, including lists (LLM once, selectors forever)
150152

151153
```python
152154
import asyncio, scrapo
153155
from pydantic import BaseModel
154156

155-
class Product(BaseModel):
157+
class Offer(BaseModel):
156158
name: str
157159
price: str
158160

161+
class Listing(BaseModel):
162+
page_title: str
163+
offers: list[Offer] = [] # array fields become repeated-element extraction
164+
159165
async def main():
160-
res = await scrapo.scrape("https://example.com/widget", schema=Product)
161-
print(res["extraction"]["data"]) # {'name': 'Widget Pro', 'price': '$42'}
162-
print(res["extraction"]["method"]) # 'llm' on the first run, 'selector' after
163-
print(res["cost_usd"]) # 0.0 once selectors are cached
166+
res = await scrapo.scrape("https://example.com/shop", schema=Listing)
167+
print(res.extraction.data) # {'page_title': '...', 'offers': [{'name': ..., 'price': ...}, ...]}
168+
print(res.extraction.method) # 'llm' on the first run, 'selector' after
169+
print(res.cost_usd) # 0.0 once selectors are cached
164170

165171
asyncio.run(main())
166172
```
167173

168-
> First call uses the LLM and **caches the selectors it learns** (keyed by host + schema). Every subsequent call against that host + schema uses cached selectors and **zero LLM tokens**. When the layout drifts, validation fails, Scrapo falls back to the LLM, re-derives selectors, and self-heals; a cache entry that keeps failing is evicted automatically.
174+
> First call uses the LLM and **caches the selectors it learns** (keyed by host + schema; for `list[Model]` fields it caches a container selector plus per-subfield selectors). Every subsequent call against that host + schema uses cached selectors and **zero LLM tokens**. When the layout drifts, validation fails, Scrapo falls back to the LLM, re-derives selectors, and self-heals; a cache entry that keeps failing is evicted automatically.
169175
170176
### 3. Recursive crawl
171177

@@ -205,15 +211,15 @@ Escalation triggers: Cloudflare/Akamai/PerimeterX/DataDome/Distil fingerprints,
205211
</details>
206212

207213
<details>
208-
<summary><b>Hybrid selector + LLM extractor</b></summary>
214+
<summary><b>Hybrid selector + LLM extractor (scalar and list fields)</b></summary>
209215

210216
```
211217
cache hit + validates -> return (method=selector, llm_calls=0, cost_usd=0)
212218
miss / fail / over budget -> LLM with schema -> validate -> verify + persist selectors -> return (method=llm)
213219
repeated cache failures -> evict the stale entry, re-derive next run
214220
```
215221

216-
The LLM is asked to return both the JSON payload *and* CSS selectors per field. Returned selectors are verified against the live HTML before being cached, so a hallucinated selector never poisons the cache. The cache is keyed by host (not registered domain), so `blog.example.com` and `shop.example.com` never collide.
222+
The LLM is asked to return both the JSON payload *and* CSS selectors per field. A scalar field gets a string selector; a `list[Model]` field gets `{"__list__": "<repeating element>", "<subfield>": "<selector relative to it>", ...}`, which Scrapo applies as `tree.css(container)` then per-subfield extraction inside each match. Returned selectors are verified against the live HTML before being cached, so a hallucinated selector never poisons the cache. The cache is keyed by host (not registered domain), so `blog.example.com` and `shop.example.com` never collide.
217223

218224
</details>
219225

@@ -283,6 +289,7 @@ diff 9f3e1c... vs abc123...
283289
- **SSRF guard.** Every fetch target is checked before a request goes out; loopback, link-local (including `169.254.169.254`), private RFC 1918 / ULA ranges, and well-known local hostnames are refused. Set `allow_private_hosts=True` (or `SCRAPO_ALLOW_PRIVATE_HOSTS=1`) for internal scraping. Crawl link discovery applies the same filter and skips obvious binary URLs.
284290
- **Bounded HTTP retries.** Transient `429 / 5xx` and transport errors are retried with exponential backoff and jitter before the router escalates to a heavier tier (`SCRAPO_HTTP_RETRIES`, default `2`).
285291
- **Concurrency-safe storage.** All SQLite stores (replay, selector cache, crawl queue) open in WAL mode with a busy timeout, so concurrent crawl workers do not trip over each other.
292+
- **Browser reuse.** A `TierRouter` launches one headless Chromium lazily and reuses it across fetches (proxy applied per context), so a crawl is not paying a cold browser launch per page. `TierRouter.aclose()` tears it down; `scrape()` and `crawl()` handle that for you.
286293
- **Cost accounting.** LLM cost is computed per call, recorded on the run, and enforceable via `Budget(max_llm_calls=..., max_cost_usd=...)`.
287294
- **PII handling.** Flag PII in the audit log (`SCRAPO_PII_FILTER=1`), or redact it from the stored snapshot, markdown, and chunks (`SCRAPO_REDACT_SNAPSHOTS=1`).
288295
- **Local UI hardening.** `scrapo serve` binds `127.0.0.1` by default, validates the `Host` header against an allowlist (anti DNS-rebinding), serializes scrapes, and warns loudly if you bind a public interface.
@@ -436,7 +443,7 @@ Issues and PRs welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the dev setup
436443

437444
## Project status
438445

439-
Alpha. The public API (`scrape`, `extract`, `crawl`) is stable; tier escalation, model pinning, replay schema, and the MCP tool surface are stable. Parts that are intentionally lightweight today and slated for hardening: T4 agent driver, list/nested extraction, browser-context pooling, full Stagehand-style action caching, S3 snapshot adapter, hosted control plane.
446+
Alpha. The public API (`scrape`, `extract`, `crawl`) is stable; tier escalation, model pinning, replay schema, typed results, list extraction, and the MCP tool surface are stable. Parts that are intentionally lightweight today and slated for hardening: a batteries-included T4 agent driver, full Stagehand-style action caching, in-browser request interception, pagination/sitemap following, content-type routing (PDF/JSON/RSS), an S3 snapshot adapter, and a hosted control plane.
440447

441448
See [CHANGELOG.md](CHANGELOG.md) for release notes.
442449

layman.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ If you have ever copied information off a web page by hand, or wished a program
2222
## What's possible
2323

2424
- Get clean, readable text out of most public web pages.
25-
- Get structured data (the specific fields you define) out of pages, reliably and repeatably.
25+
- Get structured data (the specific fields you define) out of pages, reliably and repeatably, including lists (every product on a listing page, every row of a table) when you describe them as a list of records.
2626
- Scrape JavaScript-heavy pages that do not work with a plain download.
2727
- Get past light anti-bot defenses on your own; get past tougher ones by plugging in a commercial proxy service (Bright Data, Oxylabs, Scrapfly, and Zyte are supported out of the box).
2828
- Crawl a whole site: follow links automatically, with limits on depth and page count, skipping duplicates.
@@ -40,9 +40,8 @@ If you have ever copied information off a web page by hand, or wished a program
4040
- **It is not a no-code, point-and-click product.** You need to write a little Python or use the command line. The built-in web page is intentionally minimal; it is for trying things, not a polished app.
4141
- **It cannot magically beat every site's defenses.** Aggressive bot protection and CAPTCHAs are genuinely hard. A proxy provider helps a lot, but nothing is guaranteed. The most advanced mode (an AI that drives a browser through logins and CAPTCHAs) exists but is lightweight and experimental today, and ships without a default driver.
4242
- **It will not log into sites for you by default.** You can supply credentials or a saved login session, but automated login flows are still experimental.
43-
- **There is no built-in list/table extraction yet.** Today it extracts one record per page well; pulling every row out of a listing page is on the roadmap, not in the box.
4443
- **There is no hosted dashboard or scheduler.** Scrapo does not run your jobs in the cloud, send alerts, or give you a web console to manage everything. You run and schedule it yourself.
45-
- **It is alpha software.** The core works and is stable, but expect rough edges. Some pieces (browser pooling, cloud snapshot storage, advanced action caching, a hosted control plane) are planned, not built.
44+
- **It is alpha software.** The core works and is stable, but expect rough edges. Some pieces (a ready-made agent driver, in-browser request interception, following "next page" links and sitemaps, cloud snapshot storage, advanced action caching, a hosted control plane) are planned, not built.
4645
- **AI extraction costs money.** The first run on a new site (or after a layout change) calls a paid AI model. Scrapo is designed to minimize this (most runs use the free cached recipe) but it is not literally free.
4746
- **It is not legal advice or a compliance guarantee.** The robots rules, personal-data flagging, geo limits, and audit log are *tools* to help you scrape responsibly. You are still responsible for following each site's terms and the law. (Note: robots-rule enforcement is off by default; you have to turn it on.)
4847
- **It is Python-only.** No JavaScript, Java, Go, etc. versions. Requires Python 3.11 or newer.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "scrapo"
7-
version = "0.2.0"
7+
version = "0.3.0"
88
description = "AI-native, agent-first web scraping library with deterministic replay"
99
readme = "README.md"
1010
requires-python = ">=3.11"

scrapo/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from scrapo.api import crawl, extract, scrape
44
from scrapo.config import Config
5+
from scrapo.results import ChunkView, CrawlResult, ExtractionView, ScrapeResult
56
from scrapo.types import (
67
Budget,
78
ChunkedDocument,
@@ -12,16 +13,20 @@
1213
Tier,
1314
)
1415

15-
__version__ = "0.2.0"
16+
__version__ = "0.3.0"
1617

1718
__all__ = [
1819
"Budget",
20+
"ChunkView",
1921
"ChunkedDocument",
2022
"Config",
23+
"CrawlResult",
2124
"ExtractionResult",
25+
"ExtractionView",
2226
"FetchResult",
2327
"ProvenanceTag",
2428
"RunRecord",
29+
"ScrapeResult",
2530
"Tier",
2631
"crawl",
2732
"extract",

scrapo/access/agent_tier.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ def __init__(
3636
self.config = config
3737
self.driver = driver
3838

39+
async def aclose(self) -> None:
40+
"""No persistent resources today; defined so TierRouter.aclose can call it."""
41+
return
42+
3943
async def fetch(
4044
self,
4145
url: str,

scrapo/access/browser_pool.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""A lazily-launched, reused Playwright browser.
2+
3+
Launching Chromium costs roughly a second or two. When a :class:`TierRouter` is
4+
reused across many fetches (most importantly during a crawl), this keeps one
5+
browser process alive and hands out a fresh context per fetch instead of
6+
cold-launching every time. It stays lazy: the browser is launched the first time
7+
a browser-tier fetch actually runs, never on import or construction.
8+
9+
The browser is launched without a proxy; proxy settings (which may rotate per
10+
fetch) are applied at the context level, so a single pooled browser can serve
11+
every proxy.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import asyncio
17+
from collections.abc import AsyncIterator
18+
from contextlib import asynccontextmanager
19+
from typing import Any
20+
21+
22+
class BrowserPool:
23+
def __init__(self, *, headless: bool = True) -> None:
24+
self._headless = headless
25+
self._lock = asyncio.Lock()
26+
self._pw: Any = None
27+
self._browser: Any = None
28+
29+
async def _ensure_browser(self) -> Any:
30+
if self._browser is not None:
31+
return self._browser
32+
async with self._lock:
33+
if self._browser is not None:
34+
return self._browser
35+
from playwright.async_api import async_playwright
36+
37+
pw = await async_playwright().start()
38+
try:
39+
browser = await pw.chromium.launch(headless=self._headless)
40+
except Exception:
41+
await pw.stop()
42+
raise
43+
self._pw = pw
44+
self._browser = browser
45+
return self._browser
46+
47+
@asynccontextmanager
48+
async def context(self, **context_kwargs: Any) -> AsyncIterator[Any]:
49+
browser = await self._ensure_browser()
50+
ctx = await browser.new_context(**context_kwargs)
51+
try:
52+
yield ctx
53+
finally:
54+
await ctx.close()
55+
56+
async def aclose(self) -> None:
57+
if self._browser is not None:
58+
try:
59+
await self._browser.close()
60+
finally:
61+
self._browser = None
62+
if self._pw is not None:
63+
try:
64+
await self._pw.stop()
65+
finally:
66+
self._pw = None

0 commit comments

Comments
 (0)