mesharc 0.1.2__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.
- mesharc-0.1.2/LICENSE +21 -0
- mesharc-0.1.2/PKG-INFO +274 -0
- mesharc-0.1.2/README.md +246 -0
- mesharc-0.1.2/mesharc/__init__.py +542 -0
- mesharc-0.1.2/mesharc/mcp.py +208 -0
- mesharc-0.1.2/mesharc/py.typed +0 -0
- mesharc-0.1.2/mesharc.egg-info/PKG-INFO +274 -0
- mesharc-0.1.2/mesharc.egg-info/SOURCES.txt +13 -0
- mesharc-0.1.2/mesharc.egg-info/dependency_links.txt +1 -0
- mesharc-0.1.2/mesharc.egg-info/entry_points.txt +2 -0
- mesharc-0.1.2/mesharc.egg-info/requires.txt +11 -0
- mesharc-0.1.2/mesharc.egg-info/top_level.txt +1 -0
- mesharc-0.1.2/pyproject.toml +44 -0
- mesharc-0.1.2/setup.cfg +4 -0
- mesharc-0.1.2/tests/test_client.py +122 -0
mesharc-0.1.2/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MeshArc
|
|
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.
|
mesharc-0.1.2/PKG-INFO
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mesharc
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Python client for the MeshArc API: a URL in, clean content out, and a record of what changed.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://mesharc.dev
|
|
7
|
+
Project-URL: Documentation, https://mesharc.dev/docs/sdks
|
|
8
|
+
Project-URL: Source, https://github.com/mesharc-org/mesharc-python
|
|
9
|
+
Project-URL: Issues, https://github.com/mesharc-org/mesharc-python/issues
|
|
10
|
+
Keywords: scraping,crawler,web-data,change-detection,sitemap,markdown,llm
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: httpx>=0.25
|
|
21
|
+
Provides-Extra: mcp
|
|
22
|
+
Requires-Dist: mcp>=1.0; python_version >= "3.10" and extra == "mcp"
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
25
|
+
Requires-Dist: build; extra == "dev"
|
|
26
|
+
Requires-Dist: twine; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# mesharc
|
|
30
|
+
|
|
31
|
+
The Python client for the [MeshArc](https://mesharc.dev) API: a URL in, clean content out, and a record of what changed.
|
|
32
|
+
|
|
33
|
+
- **Scrape** one page or a batch — markdown, text, HTML, links, structured fields, a screenshot.
|
|
34
|
+
- **Crawl** a whole site with no project to set up first, and keep it as one if it turns out to be worth watching.
|
|
35
|
+
- **Map** what a site declares in its sitemaps before fetching any of it.
|
|
36
|
+
- **Watch** a site over time: projects, scheduled runs, and a change record — pages added, removed, modified, field by field.
|
|
37
|
+
|
|
38
|
+
Python 3.9 or newer. One dependency (`httpx`). Fully typed.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install mesharc
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Authentication
|
|
47
|
+
|
|
48
|
+
Every call needs an API key. Create one in the app under **Settings → API keys** — it is shown once — and give it to the client, or put it in `MESHARC_API_KEY` and construct the client with nothing:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from mesharc import MeshArc
|
|
52
|
+
|
|
53
|
+
arc = MeshArc("mesharc_...")
|
|
54
|
+
# or, with MESHARC_API_KEY in the environment:
|
|
55
|
+
arc = MeshArc()
|
|
56
|
+
# options: MeshArc(api_key, timeout=150.0, max_retries=2)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The key is only ever sent as a bearer header to `api.mesharc.dev`.
|
|
60
|
+
|
|
61
|
+
A key carries the scopes it was made with (`read`, `write`, `admin`), optionally a set of projects it may see, an expiry and a rate limit. A route the key may not use answers `403`; a project it may not see answers `404`.
|
|
62
|
+
|
|
63
|
+
## Quick start
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from mesharc import MeshArc
|
|
67
|
+
|
|
68
|
+
arc = MeshArc("mesharc_...")
|
|
69
|
+
|
|
70
|
+
page = arc.scrape("https://example.com/pricing")
|
|
71
|
+
print(page["markdown"])
|
|
72
|
+
print(page["verdict"], page["method"], page["credits"]) # ok crawler 1
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`scrape` holds the request open until the page comes back (60 s by default), so there is nothing to poll for an ordinary page.
|
|
76
|
+
|
|
77
|
+
## Reading pages
|
|
78
|
+
|
|
79
|
+
### One page
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
page = arc.scrape("https://quotes.toscrape.com/js/", config={"render_js": "always"})
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`config` is any setting a project takes, by its API name (`render_js`, `only_main_content`, `formats`, `max_tier`, `wait_for_selector`, `actions`, …). The full list, with defaults, is at [mesharc.dev/docs/configuration](https://mesharc.dev/docs/configuration).
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
page = arc.scrape_one(
|
|
89
|
+
"https://example.com/",
|
|
90
|
+
formats="markdown,text,cleanHtml", # which bodies to return
|
|
91
|
+
timeout_s=120, # how long the API holds the request (120 max)
|
|
92
|
+
idempotency_key="pricing-2026-09-18", # the same key returns the first answer for 24 h
|
|
93
|
+
)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Many pages
|
|
97
|
+
|
|
98
|
+
A list of URLs is a batch: grouped by host, fetched in parallel where the config allows, and returned as one row per URL.
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
batch = arc.scrape(["https://a.com/", "https://b.com/x"], config={"concurrency": 4})
|
|
102
|
+
for row in batch["pages"]:
|
|
103
|
+
print(row["url"], row["httpStatus"], row["verdict"], row["credits"])
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`arc.scrape(urls, wait=False)` returns the batch id at once; `arc.batch(id, wait=True)` finishes it later. Pass `webhook_url=` to be told instead of polling (`batch.finished`, signed with a secret returned once).
|
|
107
|
+
|
|
108
|
+
### What a page looks like
|
|
109
|
+
|
|
110
|
+
Every page row carries the same fields, whether it came from a scrape, a crawl or a project:
|
|
111
|
+
|
|
112
|
+
| Field | Meaning |
|
|
113
|
+
|---|---|
|
|
114
|
+
| `markdown`, `text`, `cleanHtml`, `html`, `links`, `fields`, `screenshot` | The bodies you asked for |
|
|
115
|
+
| `httpStatus` | The status the site answered with |
|
|
116
|
+
| `verdict` | `ok`, `thin` (short, but a page), `blocked` (refused, or a 404), `skipped` |
|
|
117
|
+
| `errorCode` | `OK`, or what went wrong: `BLOCKED`, `NOT_FOUND`, `TIER_LIMIT`, `CAPTCHA`, `LOGIN_REQUIRED`, `RATE_LIMITED`, `TIMEOUT` … |
|
|
118
|
+
| `shape`, `warnings`, `signals` | `listing` / `table` / `form` for a short page whose markup says what it is; `short`; why the judge decided as it did |
|
|
119
|
+
| `method`, `tier`, `climbedTo` | The engine that read it (`crawler`, `tls`, `minted`, `browser`, `browser-residential`, …), its tier, and how far a refused page climbed |
|
|
120
|
+
| `credits`, `billedAs` | What it cost; the rung it is priced at when not the one that fetched it |
|
|
121
|
+
| `words`, `language`, `head`, `reason`, `crawledAt` | Size, language, the head fields, the judge's sentence, when |
|
|
122
|
+
|
|
123
|
+
A page the site refused costs 0, and so does a 404.
|
|
124
|
+
|
|
125
|
+
## Crawling a site
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
job = arc.crawl(
|
|
129
|
+
"https://docs.example.com",
|
|
130
|
+
limit=200, # page budget
|
|
131
|
+
maxDepth=3, # link hops from the seed
|
|
132
|
+
includePaths=["/docs/*"],
|
|
133
|
+
crawlMode="sitemap_first", # what the sitemap declares first, then links
|
|
134
|
+
maxTier="browser", # how far a refused page may climb
|
|
135
|
+
scrapeOptions={"formats": ["markdown", "links"]},
|
|
136
|
+
config={"crawl_delay_ms": 500}, # any project setting, directly
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
for page in job.pages(): # follows the cursor while the crawl runs
|
|
140
|
+
print(page["url"], page["words"])
|
|
141
|
+
print(job.status, job.envelope["counts"], job.envelope["creditsUsed"])
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`crawl` returns a handle immediately; `job.pages()` yields pages as they land and ends when the crawl does. `wait=True` blocks until it finishes; `job.wait()`, `job.refresh()`, `job.cancel()` do what they say; `arc.get_crawl(id)` reattaches to a crawl started elsewhere.
|
|
145
|
+
|
|
146
|
+
A one-shot crawl expires after 30 days. If the site is worth watching:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
project = job.keep(name="Docs", schedule="weekly")
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
A `webhook=` in the options (`{"url", "events", "metadata"}`) is told about `crawl.started`, `crawl.page` (fifty pages a message) and `crawl.completed`; its signing secret comes back once as `job.webhook_secret`.
|
|
153
|
+
|
|
154
|
+
## Mapping a site
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
for u in arc.map("https://docs.example.com"):
|
|
158
|
+
print(u["url"], u["lastmod"])
|
|
159
|
+
|
|
160
|
+
details = arc.map_details("https://www.gov.uk/", search="visa", limit=500)
|
|
161
|
+
print(details["totals"], details["creditsUsed"]) # {'files': 29, 'urls': 508431, …} 29
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
A map costs one credit per sitemap file read — most sites are one file.
|
|
165
|
+
|
|
166
|
+
## Watching a site: projects and runs
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
project = arc.projects.create(
|
|
170
|
+
"https://docs.example.com",
|
|
171
|
+
name="Docs",
|
|
172
|
+
schedule="weekly", # manual | hourly | daily | weekly
|
|
173
|
+
config={"max_pages": 300, "include_paths": ["/docs/*"]},
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
run = arc.runs.start(project["id"], wait=True) # the first run
|
|
177
|
+
# ...a week later, or arc.runs.start again: the second run produces the change record
|
|
178
|
+
|
|
179
|
+
record = arc.changes(project["id"])
|
|
180
|
+
print(record["change"]["counts"]) # {'added': …, 'removed': …, 'modified': …, 'withheld': …}
|
|
181
|
+
|
|
182
|
+
diff = arc.page_diff(project["id"], "https://docs.example.com/pricing")
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
| Method | What it does |
|
|
186
|
+
|---|---|
|
|
187
|
+
| `projects.list()` · `projects.get(id)` · `projects.update(id, name=, schedule=, retention=, config=)` · `projects.delete(id)` | The projects |
|
|
188
|
+
| `runs.list(project_id)` · `runs.start(project_id, wait=)` · `runs.wait(project_id, run_id)` · `runs.get(project_id, run_id)` · `runs.cancel(project_id, run_id)` | Runs |
|
|
189
|
+
| `pages(project_id, run_id=None)` · `page(project_id, url, run_id=None)` | The pages of a run; one page in full |
|
|
190
|
+
| `changes(project_id, run_id=None)` · `page_diff(project_id, url, run_id=None)` | The change record; one page's word-level diff |
|
|
191
|
+
| `search(project_id, q, mode="content" \| "selector", run_id=None)` | Which pages say this (words, `"phrases"`) or contain this (CSS / XPath) |
|
|
192
|
+
| `recrawl(project_id, urls)` | Fetch these pages again, now |
|
|
193
|
+
| `sources(project_id)` | The seed, sitemap, URL list, feeds and patterns with what the last run found through each |
|
|
194
|
+
| `export(project_id, path, dataset="pages", fmt="jsonl", run_id=None, urls=None)` | Stream a dataset (`pages`, `markdown`, `changes`, `fields`, `sitemap`) as `jsonl` or `csv` to a file |
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
arc.export(project["id"], "pages.csv", dataset="pages", fmt="csv")
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## The workspace
|
|
201
|
+
|
|
202
|
+
```python
|
|
203
|
+
me = arc.me() # the workspace, its plan and limits, credits used and remaining, what this key may do
|
|
204
|
+
usage = arc.usage() # pages per day, this month by engine
|
|
205
|
+
monitor = arc.monitor() # what is queued and running
|
|
206
|
+
meta = arc.meta() # verdict meanings, engine costs, the config defaults
|
|
207
|
+
keys = arc.keys()
|
|
208
|
+
key = arc.create_key("ci", scopes=["read", "write"], projects=[project["id"]], expires_in_days=90) # key["key"], once
|
|
209
|
+
arc.revoke_key(key["id"])
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Errors
|
|
213
|
+
|
|
214
|
+
Every failure raises `MeshArcError`:
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
from mesharc import MeshArc, MeshArcError
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
arc.crawl("https://example.com", limit=1_000_000)
|
|
221
|
+
except MeshArcError as exc:
|
|
222
|
+
print(exc.status, exc.code, exc.detail, exc.request_id)
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
| `code` | Status | Meaning |
|
|
226
|
+
|---|---|---|
|
|
227
|
+
| `validation` | 400 / 422 | Something in the request is wrong; `detail` says what |
|
|
228
|
+
| `unauthorized` | 401 | No key, or a revoked or expired one |
|
|
229
|
+
| `plan_limit` | 402 | The plan does not include this, or the credits are spent |
|
|
230
|
+
| `forbidden` | 403 | The key's scopes do not allow it |
|
|
231
|
+
| `not_found` | 404 | No such thing — or not one this key may see |
|
|
232
|
+
| `conflict` | 409 | The request contradicts current state |
|
|
233
|
+
| `rate_limited` | 429 | Over the key's rate limit; `X-RateLimit-Reset` says when |
|
|
234
|
+
| `internal` | 500 | Quote `request_id` to support |
|
|
235
|
+
|
|
236
|
+
`request_id` is the id the API put on the response and in its own logs, so a support conversation starts from one string.
|
|
237
|
+
|
|
238
|
+
Two more cases: a network failure or a request that hits `timeout` raises `MeshArcError` with `status == 0` and `code` `network` or `timeout`; a job the client stopped waiting for raises `MeshArcTimeoutError` — both a `MeshArcError` and a `TimeoutError` — which carries `job_id` so you can poll it later (`arc.get_crawl(id)`, `arc.batch(id)`).
|
|
239
|
+
|
|
240
|
+
## Idempotency and timeouts
|
|
241
|
+
|
|
242
|
+
- `scrape`, `scrape_one` and `crawl` take `idempotency_key=`: send the same key again within 24 hours and you get the first answer back rather than a second job.
|
|
243
|
+
- Waiting calls take `wait=`, `poll=` (seconds between polls) and `timeout=` (seconds before `TimeoutError`). `wait=False` returns the envelope at once; the default polls every 3 s for up to an hour.
|
|
244
|
+
- `timeout_s` on a single scrape is how long the API itself holds the request open (60 s by default, 120 at most); a slower page comes back as an id and is polled.
|
|
245
|
+
- `MeshArc(..., timeout=150.0)` is the HTTP timeout per request. A request is retried on 429, 502, 503, 504 and network failures when it is safe to repeat — a GET, a DELETE, or a POST with an idempotency key — up to `max_retries` times (2), honouring `Retry-After`.
|
|
246
|
+
|
|
247
|
+
## Credits
|
|
248
|
+
|
|
249
|
+
Every response says what it cost: `credits` on a page, `creditsUsed` on a job envelope, `X-MeshArc-Credits` on the HTTP response. A page costs the engine that read it — a plain fetch 1, a render 4 — and a refused page or a 404 costs nothing. The schedule and the plans are at [mesharc.dev/docs/billing](https://mesharc.dev/docs/billing).
|
|
250
|
+
|
|
251
|
+
## The MCP server
|
|
252
|
+
|
|
253
|
+
The package also ships MeshArc as an MCP server, so Claude Desktop, Claude Code, Cursor and any MCP client can scrape, crawl, map and read change records as tools. Python 3.10+.
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
pip install "mesharc[mcp]"
|
|
257
|
+
MESHARC_API_KEY=mesharc_... mesharc-mcp # serves over stdio
|
|
258
|
+
|
|
259
|
+
# Claude Code
|
|
260
|
+
claude mcp add mesharc -e MESHARC_API_KEY=mesharc_... -- mesharc-mcp
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Tools: `scrape_urls`, `extract_url`, `map_site`, `crawl_site`, `keep_crawl_as_project`, `list_projects`, `create_project`, `start_run`, `list_pages`, `get_page`, `get_changes`, `search_pages`, `recrawl_pages`. Every tool is a call through this client, trimmed where a body would swamp a context window (markdown is capped per page; ask for one page to get all of it).
|
|
264
|
+
|
|
265
|
+
## Anything else
|
|
266
|
+
|
|
267
|
+
The client is a thin wrapper: every method is one API call and returns the API's JSON as a `dict`. The full reference is at [mesharc.dev/docs/api](https://mesharc.dev/docs/api). Call `arc.close()` when you are done, or use the client as a context manager.
|
|
268
|
+
|
|
269
|
+
- Documentation: [mesharc.dev/docs](https://mesharc.dev/docs)
|
|
270
|
+
- Node client: `npm install mesharc` — [mesharc-node](https://github.com/mesharc-org/mesharc-node)
|
|
271
|
+
- Issues and pull requests: [mesharc-python](https://github.com/mesharc-org/mesharc-python)
|
|
272
|
+
- Questions: hello@mesharc.dev
|
|
273
|
+
|
|
274
|
+
MIT.
|
mesharc-0.1.2/README.md
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# mesharc
|
|
2
|
+
|
|
3
|
+
The Python client for the [MeshArc](https://mesharc.dev) API: a URL in, clean content out, and a record of what changed.
|
|
4
|
+
|
|
5
|
+
- **Scrape** one page or a batch — markdown, text, HTML, links, structured fields, a screenshot.
|
|
6
|
+
- **Crawl** a whole site with no project to set up first, and keep it as one if it turns out to be worth watching.
|
|
7
|
+
- **Map** what a site declares in its sitemaps before fetching any of it.
|
|
8
|
+
- **Watch** a site over time: projects, scheduled runs, and a change record — pages added, removed, modified, field by field.
|
|
9
|
+
|
|
10
|
+
Python 3.9 or newer. One dependency (`httpx`). Fully typed.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install mesharc
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Authentication
|
|
19
|
+
|
|
20
|
+
Every call needs an API key. Create one in the app under **Settings → API keys** — it is shown once — and give it to the client, or put it in `MESHARC_API_KEY` and construct the client with nothing:
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from mesharc import MeshArc
|
|
24
|
+
|
|
25
|
+
arc = MeshArc("mesharc_...")
|
|
26
|
+
# or, with MESHARC_API_KEY in the environment:
|
|
27
|
+
arc = MeshArc()
|
|
28
|
+
# options: MeshArc(api_key, timeout=150.0, max_retries=2)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The key is only ever sent as a bearer header to `api.mesharc.dev`.
|
|
32
|
+
|
|
33
|
+
A key carries the scopes it was made with (`read`, `write`, `admin`), optionally a set of projects it may see, an expiry and a rate limit. A route the key may not use answers `403`; a project it may not see answers `404`.
|
|
34
|
+
|
|
35
|
+
## Quick start
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from mesharc import MeshArc
|
|
39
|
+
|
|
40
|
+
arc = MeshArc("mesharc_...")
|
|
41
|
+
|
|
42
|
+
page = arc.scrape("https://example.com/pricing")
|
|
43
|
+
print(page["markdown"])
|
|
44
|
+
print(page["verdict"], page["method"], page["credits"]) # ok crawler 1
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`scrape` holds the request open until the page comes back (60 s by default), so there is nothing to poll for an ordinary page.
|
|
48
|
+
|
|
49
|
+
## Reading pages
|
|
50
|
+
|
|
51
|
+
### One page
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
page = arc.scrape("https://quotes.toscrape.com/js/", config={"render_js": "always"})
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`config` is any setting a project takes, by its API name (`render_js`, `only_main_content`, `formats`, `max_tier`, `wait_for_selector`, `actions`, …). The full list, with defaults, is at [mesharc.dev/docs/configuration](https://mesharc.dev/docs/configuration).
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
page = arc.scrape_one(
|
|
61
|
+
"https://example.com/",
|
|
62
|
+
formats="markdown,text,cleanHtml", # which bodies to return
|
|
63
|
+
timeout_s=120, # how long the API holds the request (120 max)
|
|
64
|
+
idempotency_key="pricing-2026-09-18", # the same key returns the first answer for 24 h
|
|
65
|
+
)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Many pages
|
|
69
|
+
|
|
70
|
+
A list of URLs is a batch: grouped by host, fetched in parallel where the config allows, and returned as one row per URL.
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
batch = arc.scrape(["https://a.com/", "https://b.com/x"], config={"concurrency": 4})
|
|
74
|
+
for row in batch["pages"]:
|
|
75
|
+
print(row["url"], row["httpStatus"], row["verdict"], row["credits"])
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`arc.scrape(urls, wait=False)` returns the batch id at once; `arc.batch(id, wait=True)` finishes it later. Pass `webhook_url=` to be told instead of polling (`batch.finished`, signed with a secret returned once).
|
|
79
|
+
|
|
80
|
+
### What a page looks like
|
|
81
|
+
|
|
82
|
+
Every page row carries the same fields, whether it came from a scrape, a crawl or a project:
|
|
83
|
+
|
|
84
|
+
| Field | Meaning |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `markdown`, `text`, `cleanHtml`, `html`, `links`, `fields`, `screenshot` | The bodies you asked for |
|
|
87
|
+
| `httpStatus` | The status the site answered with |
|
|
88
|
+
| `verdict` | `ok`, `thin` (short, but a page), `blocked` (refused, or a 404), `skipped` |
|
|
89
|
+
| `errorCode` | `OK`, or what went wrong: `BLOCKED`, `NOT_FOUND`, `TIER_LIMIT`, `CAPTCHA`, `LOGIN_REQUIRED`, `RATE_LIMITED`, `TIMEOUT` … |
|
|
90
|
+
| `shape`, `warnings`, `signals` | `listing` / `table` / `form` for a short page whose markup says what it is; `short`; why the judge decided as it did |
|
|
91
|
+
| `method`, `tier`, `climbedTo` | The engine that read it (`crawler`, `tls`, `minted`, `browser`, `browser-residential`, …), its tier, and how far a refused page climbed |
|
|
92
|
+
| `credits`, `billedAs` | What it cost; the rung it is priced at when not the one that fetched it |
|
|
93
|
+
| `words`, `language`, `head`, `reason`, `crawledAt` | Size, language, the head fields, the judge's sentence, when |
|
|
94
|
+
|
|
95
|
+
A page the site refused costs 0, and so does a 404.
|
|
96
|
+
|
|
97
|
+
## Crawling a site
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
job = arc.crawl(
|
|
101
|
+
"https://docs.example.com",
|
|
102
|
+
limit=200, # page budget
|
|
103
|
+
maxDepth=3, # link hops from the seed
|
|
104
|
+
includePaths=["/docs/*"],
|
|
105
|
+
crawlMode="sitemap_first", # what the sitemap declares first, then links
|
|
106
|
+
maxTier="browser", # how far a refused page may climb
|
|
107
|
+
scrapeOptions={"formats": ["markdown", "links"]},
|
|
108
|
+
config={"crawl_delay_ms": 500}, # any project setting, directly
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
for page in job.pages(): # follows the cursor while the crawl runs
|
|
112
|
+
print(page["url"], page["words"])
|
|
113
|
+
print(job.status, job.envelope["counts"], job.envelope["creditsUsed"])
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`crawl` returns a handle immediately; `job.pages()` yields pages as they land and ends when the crawl does. `wait=True` blocks until it finishes; `job.wait()`, `job.refresh()`, `job.cancel()` do what they say; `arc.get_crawl(id)` reattaches to a crawl started elsewhere.
|
|
117
|
+
|
|
118
|
+
A one-shot crawl expires after 30 days. If the site is worth watching:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
project = job.keep(name="Docs", schedule="weekly")
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
A `webhook=` in the options (`{"url", "events", "metadata"}`) is told about `crawl.started`, `crawl.page` (fifty pages a message) and `crawl.completed`; its signing secret comes back once as `job.webhook_secret`.
|
|
125
|
+
|
|
126
|
+
## Mapping a site
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
for u in arc.map("https://docs.example.com"):
|
|
130
|
+
print(u["url"], u["lastmod"])
|
|
131
|
+
|
|
132
|
+
details = arc.map_details("https://www.gov.uk/", search="visa", limit=500)
|
|
133
|
+
print(details["totals"], details["creditsUsed"]) # {'files': 29, 'urls': 508431, …} 29
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
A map costs one credit per sitemap file read — most sites are one file.
|
|
137
|
+
|
|
138
|
+
## Watching a site: projects and runs
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
project = arc.projects.create(
|
|
142
|
+
"https://docs.example.com",
|
|
143
|
+
name="Docs",
|
|
144
|
+
schedule="weekly", # manual | hourly | daily | weekly
|
|
145
|
+
config={"max_pages": 300, "include_paths": ["/docs/*"]},
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
run = arc.runs.start(project["id"], wait=True) # the first run
|
|
149
|
+
# ...a week later, or arc.runs.start again: the second run produces the change record
|
|
150
|
+
|
|
151
|
+
record = arc.changes(project["id"])
|
|
152
|
+
print(record["change"]["counts"]) # {'added': …, 'removed': …, 'modified': …, 'withheld': …}
|
|
153
|
+
|
|
154
|
+
diff = arc.page_diff(project["id"], "https://docs.example.com/pricing")
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
| Method | What it does |
|
|
158
|
+
|---|---|
|
|
159
|
+
| `projects.list()` · `projects.get(id)` · `projects.update(id, name=, schedule=, retention=, config=)` · `projects.delete(id)` | The projects |
|
|
160
|
+
| `runs.list(project_id)` · `runs.start(project_id, wait=)` · `runs.wait(project_id, run_id)` · `runs.get(project_id, run_id)` · `runs.cancel(project_id, run_id)` | Runs |
|
|
161
|
+
| `pages(project_id, run_id=None)` · `page(project_id, url, run_id=None)` | The pages of a run; one page in full |
|
|
162
|
+
| `changes(project_id, run_id=None)` · `page_diff(project_id, url, run_id=None)` | The change record; one page's word-level diff |
|
|
163
|
+
| `search(project_id, q, mode="content" \| "selector", run_id=None)` | Which pages say this (words, `"phrases"`) or contain this (CSS / XPath) |
|
|
164
|
+
| `recrawl(project_id, urls)` | Fetch these pages again, now |
|
|
165
|
+
| `sources(project_id)` | The seed, sitemap, URL list, feeds and patterns with what the last run found through each |
|
|
166
|
+
| `export(project_id, path, dataset="pages", fmt="jsonl", run_id=None, urls=None)` | Stream a dataset (`pages`, `markdown`, `changes`, `fields`, `sitemap`) as `jsonl` or `csv` to a file |
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
arc.export(project["id"], "pages.csv", dataset="pages", fmt="csv")
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## The workspace
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
me = arc.me() # the workspace, its plan and limits, credits used and remaining, what this key may do
|
|
176
|
+
usage = arc.usage() # pages per day, this month by engine
|
|
177
|
+
monitor = arc.monitor() # what is queued and running
|
|
178
|
+
meta = arc.meta() # verdict meanings, engine costs, the config defaults
|
|
179
|
+
keys = arc.keys()
|
|
180
|
+
key = arc.create_key("ci", scopes=["read", "write"], projects=[project["id"]], expires_in_days=90) # key["key"], once
|
|
181
|
+
arc.revoke_key(key["id"])
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Errors
|
|
185
|
+
|
|
186
|
+
Every failure raises `MeshArcError`:
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
from mesharc import MeshArc, MeshArcError
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
arc.crawl("https://example.com", limit=1_000_000)
|
|
193
|
+
except MeshArcError as exc:
|
|
194
|
+
print(exc.status, exc.code, exc.detail, exc.request_id)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
| `code` | Status | Meaning |
|
|
198
|
+
|---|---|---|
|
|
199
|
+
| `validation` | 400 / 422 | Something in the request is wrong; `detail` says what |
|
|
200
|
+
| `unauthorized` | 401 | No key, or a revoked or expired one |
|
|
201
|
+
| `plan_limit` | 402 | The plan does not include this, or the credits are spent |
|
|
202
|
+
| `forbidden` | 403 | The key's scopes do not allow it |
|
|
203
|
+
| `not_found` | 404 | No such thing — or not one this key may see |
|
|
204
|
+
| `conflict` | 409 | The request contradicts current state |
|
|
205
|
+
| `rate_limited` | 429 | Over the key's rate limit; `X-RateLimit-Reset` says when |
|
|
206
|
+
| `internal` | 500 | Quote `request_id` to support |
|
|
207
|
+
|
|
208
|
+
`request_id` is the id the API put on the response and in its own logs, so a support conversation starts from one string.
|
|
209
|
+
|
|
210
|
+
Two more cases: a network failure or a request that hits `timeout` raises `MeshArcError` with `status == 0` and `code` `network` or `timeout`; a job the client stopped waiting for raises `MeshArcTimeoutError` — both a `MeshArcError` and a `TimeoutError` — which carries `job_id` so you can poll it later (`arc.get_crawl(id)`, `arc.batch(id)`).
|
|
211
|
+
|
|
212
|
+
## Idempotency and timeouts
|
|
213
|
+
|
|
214
|
+
- `scrape`, `scrape_one` and `crawl` take `idempotency_key=`: send the same key again within 24 hours and you get the first answer back rather than a second job.
|
|
215
|
+
- Waiting calls take `wait=`, `poll=` (seconds between polls) and `timeout=` (seconds before `TimeoutError`). `wait=False` returns the envelope at once; the default polls every 3 s for up to an hour.
|
|
216
|
+
- `timeout_s` on a single scrape is how long the API itself holds the request open (60 s by default, 120 at most); a slower page comes back as an id and is polled.
|
|
217
|
+
- `MeshArc(..., timeout=150.0)` is the HTTP timeout per request. A request is retried on 429, 502, 503, 504 and network failures when it is safe to repeat — a GET, a DELETE, or a POST with an idempotency key — up to `max_retries` times (2), honouring `Retry-After`.
|
|
218
|
+
|
|
219
|
+
## Credits
|
|
220
|
+
|
|
221
|
+
Every response says what it cost: `credits` on a page, `creditsUsed` on a job envelope, `X-MeshArc-Credits` on the HTTP response. A page costs the engine that read it — a plain fetch 1, a render 4 — and a refused page or a 404 costs nothing. The schedule and the plans are at [mesharc.dev/docs/billing](https://mesharc.dev/docs/billing).
|
|
222
|
+
|
|
223
|
+
## The MCP server
|
|
224
|
+
|
|
225
|
+
The package also ships MeshArc as an MCP server, so Claude Desktop, Claude Code, Cursor and any MCP client can scrape, crawl, map and read change records as tools. Python 3.10+.
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
pip install "mesharc[mcp]"
|
|
229
|
+
MESHARC_API_KEY=mesharc_... mesharc-mcp # serves over stdio
|
|
230
|
+
|
|
231
|
+
# Claude Code
|
|
232
|
+
claude mcp add mesharc -e MESHARC_API_KEY=mesharc_... -- mesharc-mcp
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Tools: `scrape_urls`, `extract_url`, `map_site`, `crawl_site`, `keep_crawl_as_project`, `list_projects`, `create_project`, `start_run`, `list_pages`, `get_page`, `get_changes`, `search_pages`, `recrawl_pages`. Every tool is a call through this client, trimmed where a body would swamp a context window (markdown is capped per page; ask for one page to get all of it).
|
|
236
|
+
|
|
237
|
+
## Anything else
|
|
238
|
+
|
|
239
|
+
The client is a thin wrapper: every method is one API call and returns the API's JSON as a `dict`. The full reference is at [mesharc.dev/docs/api](https://mesharc.dev/docs/api). Call `arc.close()` when you are done, or use the client as a context manager.
|
|
240
|
+
|
|
241
|
+
- Documentation: [mesharc.dev/docs](https://mesharc.dev/docs)
|
|
242
|
+
- Node client: `npm install mesharc` — [mesharc-node](https://github.com/mesharc-org/mesharc-node)
|
|
243
|
+
- Issues and pull requests: [mesharc-python](https://github.com/mesharc-org/mesharc-python)
|
|
244
|
+
- Questions: hello@mesharc.dev
|
|
245
|
+
|
|
246
|
+
MIT.
|