langchain-mesharc 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.
@@ -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.
@@ -0,0 +1,127 @@
1
+ Metadata-Version: 2.4
2
+ Name: langchain-mesharc
3
+ Version: 0.1.0
4
+ Summary: LangChain integration for MeshArc: web pages as Documents, and web search and page reading as agent tools.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://mesharc.dev
7
+ Project-URL: Documentation, https://github.com/mesharc-org/langchain-mesharc#readme
8
+ Project-URL: Source, https://github.com/mesharc-org/langchain-mesharc
9
+ Project-URL: Issues, https://github.com/mesharc-org/langchain-mesharc/issues
10
+ Project-URL: Privacy, https://mesharc.dev/legal/privacy
11
+ Keywords: langchain,mesharc,web-scraping,crawler,document-loader,rag,markdown,change-detection
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Typing :: Typed
20
+ Classifier: Operating System :: OS Independent
21
+ Classifier: Topic :: Internet :: WWW/HTTP
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: langchain-core<2,>=1.0
26
+ Requires-Dist: mesharc<1,>=0.6
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=8; extra == "dev"
29
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
30
+ Requires-Dist: langchain-tests==1.1.9; extra == "dev"
31
+ Requires-Dist: build; extra == "dev"
32
+ Requires-Dist: twine; extra == "dev"
33
+ Requires-Dist: ruff>=0.16; extra == "dev"
34
+ Requires-Dist: mypy>=1.18; extra == "dev"
35
+ Dynamic: license-file
36
+
37
+ # langchain-mesharc
38
+
39
+ The LangChain integration for [MeshArc](https://mesharc.dev), the web crawler that turns sites into clean markdown and records what changed between crawls.
40
+
41
+ - **`MeshArcLoader`** loads web pages as LangChain `Document`s, in four modes: scrape, crawl, map and changes.
42
+ - **`MeshArcSearch`** and **`MeshArcScrape`** are tools an agent can call to search the web and read pages.
43
+
44
+ ```bash
45
+ pip install langchain-mesharc
46
+ ```
47
+
48
+ You need a MeshArc API key. Make one under **Settings → API keys** at [mesharc.dev](https://mesharc.dev); the free plan includes 1,000 credits with no card. Set it as `MESHARC_API_KEY`, or pass `api_key=`. Reading pages and searching need a key with **write** access.
49
+
50
+ ## Document loader
51
+
52
+ ```python
53
+ from langchain_mesharc import MeshArcLoader
54
+
55
+ # One page, or many read as one batch
56
+ docs = MeshArcLoader("https://example.com/pricing").load()
57
+ docs = MeshArcLoader(["https://example.com/", "https://example.com/about"]).load()
58
+
59
+ # A whole site, or a section of it; pages arrive as they are read
60
+ loader = MeshArcLoader("https://docs.example.com", mode="crawl",
61
+ params={"limit": 100, "includePaths": ["/guides/*"]})
62
+ for doc in loader.lazy_load():
63
+ print(doc.metadata["url"], len(doc.page_content))
64
+
65
+ # The URLs a site declares in its sitemaps, without fetching them
66
+ urls = MeshArcLoader("https://docs.example.com", mode="map").load()
67
+
68
+ # What changed on a watched site since the last crawl
69
+ changed = MeshArcLoader(mode="changes", project_id="prj_...").load()
70
+ ```
71
+
72
+ | Mode | Input | Each Document |
73
+ |---|---|---|
74
+ | `scrape` | a URL or a list of URLs | the page's markdown |
75
+ | `crawl` | a site's URL; `params` takes `limit`, `maxDepth`, `includePaths`, `excludePaths` and the rest of the [crawl options](https://mesharc.dev/docs/api) | one per page read |
76
+ | `map` | a site's URL; `params` takes `search` and `limit` | one per URL in the sitemaps, with the URL as its text |
77
+ | `changes` | `project_id`, and optionally `run_id` and `against` | one per added or modified page, holding the page as it is now |
78
+
79
+ **Metadata** on every page: `source` and `url`, `title`, `page_status` (one word: `ok`, `missing`, `blocked`, `captcha`, …), `crawled_at` and `mode`.
80
+ - Scraped and changed pages also have `status`, the HTTP status. A crawl's pages don't, because the API leaves it out of crawl results.
81
+ - Scraped pages also have `verdict` and `credits`.
82
+ - A crawl adds `crawl_id`.
83
+ - Changes add `project_id`, `run_id`, `previous_run_id`, `changed_at`, `change` (`added`, `modified`, `removed` or `fields`), `words_before` and `words_after`.
84
+
85
+ Values are always strings, numbers or booleans, so any vector store accepts them.
86
+
87
+ Pages that came back with no content (blocked, or a 404) are left out. Pass `include_empty=True` to keep them; their `page_status` and `error_code` say why. In `changes` mode, removed pages are left out unless `include_removed=True`.
88
+
89
+ `changes` mode keeps a vector store current: load it after each run of a [MeshArc project](https://mesharc.dev/docs), and re-embed only the pages that changed. Each Document's `id` is `run_id:url`.
90
+
91
+ ## Tools
92
+
93
+ ```python
94
+ from langchain_mesharc import MeshArcScrape, MeshArcSearch
95
+
96
+ tools = [MeshArcSearch(), MeshArcScrape()]
97
+ ```
98
+
99
+ | Tool | The agent gives | It gets back |
100
+ |---|---|---|
101
+ | `mesharc_search` (`MeshArcSearch`) | `query`, `limit` (1–10), `read_pages` | a numbered list of results (title, URL, snippet), and with `read_pages` each result's page text |
102
+ | `mesharc_scrape` (`MeshArcScrape`) | `urls` (1–20) | each page's title, URL and markdown, or why a page could not be read |
103
+
104
+ Both tools return `content_and_artifact`:
105
+ - the **content** is the text sent to the model, with each page cut at `max_chars_per_page` (8,000 by default; 0 for no limit);
106
+ - the **artifact** is MeshArc's full response.
107
+
108
+ A refused request, such as one with too few credits left, comes back to the agent as an error message rather than stopping it.
109
+
110
+ ## Cost
111
+
112
+ Requests are billed in MeshArc credits, the same as any other API call:
113
+ - a plain page read costs 1 credit, and blocked pages and 404s cost nothing;
114
+ - a web search costs what reading its results page costs;
115
+ - a sitemap read costs 1 credit per sitemap file.
116
+
117
+ See [pricing](https://mesharc.dev/pricing).
118
+
119
+ ## Links
120
+
121
+ - [MeshArc docs](https://mesharc.dev/docs) and [API reference](https://mesharc.dev/docs/api)
122
+ - [The Python SDK](https://github.com/mesharc-org/mesharc-python), which this package uses
123
+ - [Questions](https://github.com/orgs/mesharc-org/discussions) · security reports to security@mesharc.dev
124
+
125
+ ## License
126
+
127
+ MIT
@@ -0,0 +1,91 @@
1
+ # langchain-mesharc
2
+
3
+ The LangChain integration for [MeshArc](https://mesharc.dev), the web crawler that turns sites into clean markdown and records what changed between crawls.
4
+
5
+ - **`MeshArcLoader`** loads web pages as LangChain `Document`s, in four modes: scrape, crawl, map and changes.
6
+ - **`MeshArcSearch`** and **`MeshArcScrape`** are tools an agent can call to search the web and read pages.
7
+
8
+ ```bash
9
+ pip install langchain-mesharc
10
+ ```
11
+
12
+ You need a MeshArc API key. Make one under **Settings → API keys** at [mesharc.dev](https://mesharc.dev); the free plan includes 1,000 credits with no card. Set it as `MESHARC_API_KEY`, or pass `api_key=`. Reading pages and searching need a key with **write** access.
13
+
14
+ ## Document loader
15
+
16
+ ```python
17
+ from langchain_mesharc import MeshArcLoader
18
+
19
+ # One page, or many read as one batch
20
+ docs = MeshArcLoader("https://example.com/pricing").load()
21
+ docs = MeshArcLoader(["https://example.com/", "https://example.com/about"]).load()
22
+
23
+ # A whole site, or a section of it; pages arrive as they are read
24
+ loader = MeshArcLoader("https://docs.example.com", mode="crawl",
25
+ params={"limit": 100, "includePaths": ["/guides/*"]})
26
+ for doc in loader.lazy_load():
27
+ print(doc.metadata["url"], len(doc.page_content))
28
+
29
+ # The URLs a site declares in its sitemaps, without fetching them
30
+ urls = MeshArcLoader("https://docs.example.com", mode="map").load()
31
+
32
+ # What changed on a watched site since the last crawl
33
+ changed = MeshArcLoader(mode="changes", project_id="prj_...").load()
34
+ ```
35
+
36
+ | Mode | Input | Each Document |
37
+ |---|---|---|
38
+ | `scrape` | a URL or a list of URLs | the page's markdown |
39
+ | `crawl` | a site's URL; `params` takes `limit`, `maxDepth`, `includePaths`, `excludePaths` and the rest of the [crawl options](https://mesharc.dev/docs/api) | one per page read |
40
+ | `map` | a site's URL; `params` takes `search` and `limit` | one per URL in the sitemaps, with the URL as its text |
41
+ | `changes` | `project_id`, and optionally `run_id` and `against` | one per added or modified page, holding the page as it is now |
42
+
43
+ **Metadata** on every page: `source` and `url`, `title`, `page_status` (one word: `ok`, `missing`, `blocked`, `captcha`, …), `crawled_at` and `mode`.
44
+ - Scraped and changed pages also have `status`, the HTTP status. A crawl's pages don't, because the API leaves it out of crawl results.
45
+ - Scraped pages also have `verdict` and `credits`.
46
+ - A crawl adds `crawl_id`.
47
+ - Changes add `project_id`, `run_id`, `previous_run_id`, `changed_at`, `change` (`added`, `modified`, `removed` or `fields`), `words_before` and `words_after`.
48
+
49
+ Values are always strings, numbers or booleans, so any vector store accepts them.
50
+
51
+ Pages that came back with no content (blocked, or a 404) are left out. Pass `include_empty=True` to keep them; their `page_status` and `error_code` say why. In `changes` mode, removed pages are left out unless `include_removed=True`.
52
+
53
+ `changes` mode keeps a vector store current: load it after each run of a [MeshArc project](https://mesharc.dev/docs), and re-embed only the pages that changed. Each Document's `id` is `run_id:url`.
54
+
55
+ ## Tools
56
+
57
+ ```python
58
+ from langchain_mesharc import MeshArcScrape, MeshArcSearch
59
+
60
+ tools = [MeshArcSearch(), MeshArcScrape()]
61
+ ```
62
+
63
+ | Tool | The agent gives | It gets back |
64
+ |---|---|---|
65
+ | `mesharc_search` (`MeshArcSearch`) | `query`, `limit` (1–10), `read_pages` | a numbered list of results (title, URL, snippet), and with `read_pages` each result's page text |
66
+ | `mesharc_scrape` (`MeshArcScrape`) | `urls` (1–20) | each page's title, URL and markdown, or why a page could not be read |
67
+
68
+ Both tools return `content_and_artifact`:
69
+ - the **content** is the text sent to the model, with each page cut at `max_chars_per_page` (8,000 by default; 0 for no limit);
70
+ - the **artifact** is MeshArc's full response.
71
+
72
+ A refused request, such as one with too few credits left, comes back to the agent as an error message rather than stopping it.
73
+
74
+ ## Cost
75
+
76
+ Requests are billed in MeshArc credits, the same as any other API call:
77
+ - a plain page read costs 1 credit, and blocked pages and 404s cost nothing;
78
+ - a web search costs what reading its results page costs;
79
+ - a sitemap read costs 1 credit per sitemap file.
80
+
81
+ See [pricing](https://mesharc.dev/pricing).
82
+
83
+ ## Links
84
+
85
+ - [MeshArc docs](https://mesharc.dev/docs) and [API reference](https://mesharc.dev/docs/api)
86
+ - [The Python SDK](https://github.com/mesharc-org/mesharc-python), which this package uses
87
+ - [Questions](https://github.com/orgs/mesharc-org/discussions) · security reports to security@mesharc.dev
88
+
89
+ ## License
90
+
91
+ MIT
@@ -0,0 +1,7 @@
1
+ """LangChain integration for MeshArc: web pages as Documents, and web search and page reading as agent tools."""
2
+
3
+ from langchain_mesharc.document_loaders import MeshArcLoader
4
+ from langchain_mesharc.tools import MeshArcScrape, MeshArcSearch
5
+
6
+ __version__ = "0.1.0"
7
+ __all__ = ["MeshArcLoader", "MeshArcScrape", "MeshArcSearch", "__version__"]
@@ -0,0 +1,192 @@
1
+ """MeshArc results as plain document records, independent of any framework.
2
+
3
+ langchain-mesharc and llama-index-readers-mesharc carry this same file; each
4
+ wraps the records in its own Document class. Keep the two copies identical.
5
+
6
+ A record is ``{"id": str, "text": str, "metadata": dict}``. Metadata values
7
+ are str, int or bool only, never None, so every vector store accepts them.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Dict, Iterable, Iterator, Optional, Union
13
+
14
+ MODES = ("scrape", "crawl", "map", "changes")
15
+ CHANGE_KINDS = ("added", "modified", "removed", "fields")
16
+
17
+ Record = Dict[str, Any]
18
+
19
+
20
+ def make_client(api_key: Optional[str] = None, client: Any = None) -> Any:
21
+ """The client to call: the one given, or a MeshArc client for ``api_key``
22
+ (falling back to the ``MESHARC_API_KEY`` environment variable)."""
23
+ if client is not None:
24
+ return client
25
+ from mesharc import MeshArc
26
+
27
+ return MeshArc(api_key)
28
+
29
+
30
+ def _clean(metadata: Dict[str, Any]) -> Dict[str, Any]:
31
+ """Drop empty values; keep str, int and bool as they are, anything else as str."""
32
+ out: Dict[str, Any] = {}
33
+ for key, value in metadata.items():
34
+ if value is None or value == "":
35
+ continue
36
+ out[key] = value if isinstance(value, (str, int, bool)) else str(value)
37
+ return out
38
+
39
+
40
+ def page_metadata(row: Dict[str, Any], mode: str) -> Dict[str, Any]:
41
+ """What a page row says about itself, under the names every mode shares.
42
+
43
+ The API returns pages in two shapes. A scrape's rows carry the HTTP code
44
+ as ``httpStatus``, the engine's ``verdict`` and the one-word page status
45
+ as ``status``. A stored page (a crawl's rows, a project's page) carries
46
+ the HTTP code as ``status`` and the word as ``pageStatus`` -- and a
47
+ crawl's rows then overwrite ``status`` with the word. Both read alike here.
48
+ """
49
+ head = row.get("head") or {}
50
+ url = row.get("url") or ""
51
+ raw_status = row.get("status")
52
+ http = row.get("httpStatus")
53
+ if http is None and isinstance(raw_status, int) and not isinstance(raw_status, bool):
54
+ http = raw_status
55
+ word = row.get("pageStatus") or (raw_status if isinstance(raw_status, str) else "")
56
+ return {
57
+ "source": url,
58
+ "url": url,
59
+ "title": row.get("title") or head.get("title") or "",
60
+ "status": int(http or 0) or None,
61
+ "page_status": word,
62
+ "verdict": row.get("verdict") or "",
63
+ "error_code": row.get("errorCode") or "",
64
+ "words": int(row.get("words") or 0) or None,
65
+ "language": row.get("language") or "",
66
+ "crawled_at": row.get("crawledAt") or "",
67
+ "credits": row.get("credits"),
68
+ "mode": mode,
69
+ }
70
+
71
+
72
+ def page_record(row: Dict[str, Any], mode: str, **extra: Any) -> Record:
73
+ metadata = page_metadata(row, mode)
74
+ metadata.update(extra)
75
+ url = row.get("url") or ""
76
+ return {"id": url, "text": row.get("markdown") or "", "metadata": _clean(metadata)}
77
+
78
+
79
+ def batch_rows(batch: Optional[Dict[str, Any]]) -> list:
80
+ """The rows of a finished batch: one per URL, under ``pages`` (a batch
81
+ is not an envelope, so it has no ``data``)."""
82
+ return list((batch or {}).get("pages") or [])
83
+
84
+
85
+ def scrape(client: Any, urls: Union[str, Iterable[str]], params: Dict[str, Any],
86
+ include_empty: bool = False) -> Iterator[Record]:
87
+ """One URL, or many as one batch. Each page row becomes a record."""
88
+ if isinstance(urls, str):
89
+ rows = [client.scrape(urls, **params)]
90
+ else:
91
+ rows = batch_rows(client.scrape(list(urls), **params))
92
+ for row in rows:
93
+ record = page_record(row, "scrape")
94
+ if record["text"] or include_empty:
95
+ yield record
96
+
97
+
98
+ def crawl(client: Any, url: str, params: Dict[str, Any], include_empty: bool = False) -> Iterator[Record]:
99
+ """A one-shot crawl of a site. Pages are yielded as they land."""
100
+ job = client.crawl(url, **params)
101
+ for row in job.pages():
102
+ record = page_record(row, "crawl", crawl_id=job.id)
103
+ if record["text"] or include_empty:
104
+ yield record
105
+
106
+
107
+ def site_map(client: Any, url: str, params: Dict[str, Any]) -> Iterator[Record]:
108
+ """The URLs a site declares in its sitemaps. The text is the URL; nothing is fetched."""
109
+ for row in client.map(url, **params):
110
+ page_url = row.get("url") or ""
111
+ metadata = {
112
+ "source": page_url,
113
+ "url": page_url,
114
+ "lastmod": row.get("lastmod") or "",
115
+ "changefreq": row.get("changefreq") or "",
116
+ "section": row.get("section") or "",
117
+ "sitemap": row.get("file") or "",
118
+ "mode": "map",
119
+ }
120
+ yield {"id": page_url, "text": page_url, "metadata": _clean(metadata)}
121
+
122
+
123
+ def changes(client: Any, project_id: str, run_id: Optional[str] = None, against: Optional[str] = None,
124
+ include_removed: bool = False, include_empty: bool = False) -> Iterator[Record]:
125
+ """What changed in a project's run against the run before it (or against
126
+ ``against``). Each added, modified or fields-only page becomes a record
127
+ holding the page as it is now; a removed page has no text and is
128
+ yielded only with ``include_removed``."""
129
+ change = (client.changes(project_id, run_id=run_id, against=against) or {}).get("change")
130
+ if not change:
131
+ return
132
+ run = change.get("runId") or run_id or ""
133
+ shared = {
134
+ "project_id": project_id,
135
+ "run_id": run,
136
+ "previous_run_id": change.get("prevRunId") or "",
137
+ "changed_at": change.get("createdAt") or "",
138
+ }
139
+ for entry in change.get("feed") or []:
140
+ kind = entry.get("kind") or ""
141
+ url = entry.get("url") or ""
142
+ about = dict(shared, change=kind, words_before=int(entry.get("wordsBefore") or 0),
143
+ words_after=int(entry.get("wordsAfter") or 0))
144
+ if kind == "removed":
145
+ if include_removed:
146
+ metadata = dict(about, source=url, url=url, title=entry.get("section") or "", mode="changes")
147
+ yield {"id": f"{run}:{url}", "text": "", "metadata": _clean(metadata)}
148
+ continue
149
+ try:
150
+ page = client.page(project_id, url, run_id=run or None)
151
+ except Exception as exc:
152
+ # The stored copy is gone (pruned by the project's retention, or
153
+ # the run deleted): skip the page rather than end the whole load.
154
+ if getattr(exc, "status", None) != 404:
155
+ raise
156
+ if include_empty:
157
+ metadata = dict(about, source=url, url=url, title=entry.get("section") or "", mode="changes")
158
+ yield {"id": f"{run}:{url}", "text": "", "metadata": _clean(metadata)}
159
+ continue
160
+ record = page_record(dict(page, url=page.get("url") or url), "changes", **about)
161
+ record["id"] = f"{run}:{url}"
162
+ if record["text"] or include_empty:
163
+ yield record
164
+
165
+
166
+ def load(client: Any, mode: str, url: Union[str, Iterable[str], None] = None, project_id: Optional[str] = None,
167
+ run_id: Optional[str] = None, against: Optional[str] = None, params: Optional[Dict[str, Any]] = None,
168
+ include_empty: bool = False, include_removed: bool = False) -> Iterator[Record]:
169
+ """Records for one of the four modes."""
170
+ check(mode, url, project_id)
171
+ extra = dict(params or {})
172
+ if mode == "scrape":
173
+ return scrape(client, url, extra, include_empty) # type: ignore[arg-type]
174
+ if mode == "crawl":
175
+ return crawl(client, url, extra, include_empty) # type: ignore[arg-type]
176
+ if mode == "map":
177
+ return site_map(client, url, extra) # type: ignore[arg-type]
178
+ return changes(client, project_id, run_id, against, include_removed, include_empty) # type: ignore[arg-type]
179
+
180
+
181
+ def check(mode: str, url: Union[str, Iterable[str], None], project_id: Optional[str]) -> None:
182
+ """Refuse a combination that cannot work, before any request is made."""
183
+ if mode not in MODES:
184
+ raise ValueError(f"mode must be one of {', '.join(MODES)}, not {mode!r}")
185
+ if mode == "changes":
186
+ if not project_id:
187
+ raise ValueError("mode 'changes' needs project_id: the MeshArc project whose changes to load")
188
+ return
189
+ if not url:
190
+ raise ValueError(f"mode {mode!r} needs url")
191
+ if mode in ("crawl", "map") and not isinstance(url, str):
192
+ raise ValueError(f"mode {mode!r} takes one url, a string")
@@ -0,0 +1,100 @@
1
+ """MeshArcLoader: pages from MeshArc as LangChain Documents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Iterator, Literal, Optional, Sequence, Union
6
+
7
+ from langchain_core.document_loaders import BaseLoader
8
+ from langchain_core.documents import Document
9
+
10
+ from . import _core
11
+
12
+
13
+ class MeshArcLoader(BaseLoader):
14
+ """Load web pages through MeshArc as Documents of clean markdown.
15
+
16
+ Modes:
17
+ * ``scrape``: one URL, or a list of URLs read as one batch.
18
+ * ``crawl``: a one-shot crawl of a site, pages yielded as they land.
19
+ * ``map``: the URLs the site declares in its sitemaps, one Document
20
+ per URL whose text is the URL itself. Nothing is fetched.
21
+ * ``changes``: what changed in a MeshArc project's latest run (or
22
+ ``run_id``) against the run before it (or ``against``). Each
23
+ added or modified page is a Document of the page as it is now.
24
+
25
+ Metadata: ``source``/``url``, ``title``, ``page_status`` (one word:
26
+ ok, missing, blocked, captcha, ...), ``crawled_at`` and ``mode``;
27
+ scraped and changed pages add ``status`` (the HTTP status), scraped
28
+ pages ``verdict`` and ``credits``, crawls ``crawl_id``, and changes
29
+ ``project_id``, ``run_id``, ``previous_run_id``, ``changed_at``,
30
+ ``change`` (added, modified, removed or fields) and
31
+ ``words_before``/``words_after``.
32
+
33
+ Pages that came back without content (blocked, a 404) are skipped
34
+ unless ``include_empty`` is set; their ``page_status`` and
35
+ ``error_code`` then say why. ``params`` are passed to the MeshArc SDK call as they are,
36
+ for example ``{"limit": 50, "includePaths": ["/blog/*"]}`` for a crawl
37
+ or ``{"search": "pricing"}`` for a map.
38
+
39
+ Example:
40
+ .. code-block:: python
41
+
42
+ from langchain_mesharc import MeshArcLoader
43
+
44
+ docs = MeshArcLoader("https://example.com/pricing").load()
45
+ docs = MeshArcLoader("https://docs.example.com", mode="crawl",
46
+ params={"limit": 50}).load()
47
+ changed = MeshArcLoader(mode="changes", project_id="prj_...").load()
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ url: Union[str, Sequence[str], None] = None,
53
+ *,
54
+ mode: Literal["scrape", "crawl", "map", "changes"] = "scrape",
55
+ project_id: Optional[str] = None,
56
+ run_id: Optional[str] = None,
57
+ against: Optional[str] = None,
58
+ params: Optional[Dict[str, Any]] = None,
59
+ include_empty: bool = False,
60
+ include_removed: bool = False,
61
+ api_key: Optional[str] = None,
62
+ client: Any = None,
63
+ ) -> None:
64
+ """
65
+ Args:
66
+ url: The URL (or, for ``scrape``, a list of URLs). Not used by ``changes``.
67
+ mode: ``scrape``, ``crawl``, ``map`` or ``changes``.
68
+ project_id: The MeshArc project, for ``changes``.
69
+ run_id: For ``changes``: the run to report on; the latest finished one by default.
70
+ against: For ``changes``: compare with this run instead of the one before.
71
+ params: Extra arguments for the MeshArc SDK call.
72
+ include_empty: Also yield pages that came back without content.
73
+ include_removed: For ``changes``: also yield removed pages, with no text.
74
+ api_key: A MeshArc API key; ``MESHARC_API_KEY`` is used when omitted.
75
+ client: A ``mesharc.MeshArc`` client to use instead of making one.
76
+ """
77
+ _core.check(mode, url, project_id)
78
+ self.url = url
79
+ self.mode = mode
80
+ self.project_id = project_id
81
+ self.run_id = run_id
82
+ self.against = against
83
+ self.params = dict(params or {})
84
+ self.include_empty = include_empty
85
+ self.include_removed = include_removed
86
+ self._client = _core.make_client(api_key, client)
87
+
88
+ def lazy_load(self) -> Iterator[Document]:
89
+ for record in _core.load(
90
+ self._client,
91
+ self.mode,
92
+ url=self.url,
93
+ project_id=self.project_id,
94
+ run_id=self.run_id,
95
+ against=self.against,
96
+ params=self.params,
97
+ include_empty=self.include_empty,
98
+ include_removed=self.include_removed,
99
+ ):
100
+ yield Document(page_content=record["text"], metadata=record["metadata"], id=record["id"])
File without changes
@@ -0,0 +1,156 @@
1
+ """MeshArc tools for LangChain agents: search the web, and read pages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List, Optional, Tuple, Type
6
+
7
+ from langchain_core.callbacks import CallbackManagerForToolRun
8
+ from langchain_core.tools import BaseTool, ToolException
9
+ from pydantic import BaseModel, Field, PrivateAttr, SecretStr, model_validator
10
+
11
+ from . import _core
12
+
13
+ DEFAULT_MAX_CHARS = 8000
14
+
15
+
16
+ def _error(exc: Exception) -> ToolException:
17
+ """A refused request, as a message the model can act on."""
18
+ code = getattr(exc, "code", "")
19
+ return ToolException(f"MeshArc refused the request{f' ({code})' if code else ''}: {exc}")
20
+
21
+
22
+ def _clip(text: str, limit: int) -> str:
23
+ if limit and len(text) > limit:
24
+ return text[:limit].rstrip() + f"\n\n[... cut at {limit} characters]"
25
+ return text
26
+
27
+
28
+ class _MeshArcTool(BaseTool):
29
+ """What both tools share: the API key and the client."""
30
+
31
+ api_key: Optional[SecretStr] = Field(default=None, exclude=True)
32
+ """A MeshArc API key; ``MESHARC_API_KEY`` is used when omitted."""
33
+ client: Any = Field(default=None, exclude=True)
34
+ """A ``mesharc.MeshArc`` client to use instead of making one."""
35
+ max_chars_per_page: int = DEFAULT_MAX_CHARS
36
+ """How much of each page's markdown goes to the model; 0 for all of it."""
37
+ handle_tool_error: bool = True # type: ignore[assignment]
38
+ response_format: str = "content_and_artifact" # type: ignore[assignment]
39
+
40
+ _client: Any = PrivateAttr(default=None)
41
+
42
+ @model_validator(mode="after")
43
+ def _connect(self) -> "_MeshArcTool":
44
+ key = self.api_key.get_secret_value() if self.api_key else None
45
+ self._client = _core.make_client(key, self.client)
46
+ return self
47
+
48
+
49
+ class MeshArcSearchInput(BaseModel):
50
+ query: str = Field(description="What to search the web for, as you would type it into a search engine.")
51
+ limit: int = Field(default=5, ge=1, le=10, description="How many results to return, 1 to 10.")
52
+ read_pages: bool = Field(
53
+ default=False,
54
+ description="Also read each result's page and return its text. Slower and costs more credits; "
55
+ "use it when the snippets are not enough to answer.",
56
+ )
57
+
58
+
59
+ class MeshArcSearch(_MeshArcTool):
60
+ """Search the web through MeshArc.
61
+
62
+ Returns the organic results (title, URL, snippet) of a web search, and
63
+ with ``read_pages`` the text of each result's page. The content sent to
64
+ the model is a numbered list; the artifact is MeshArc's full response.
65
+
66
+ Example:
67
+ .. code-block:: python
68
+
69
+ from langchain_mesharc import MeshArcSearch
70
+
71
+ tool = MeshArcSearch()
72
+ tool.invoke({"query": "rail funding consultation 2026", "limit": 3})
73
+ """
74
+
75
+ name: str = "mesharc_search"
76
+ description: str = (
77
+ "Search the web. Returns the top results with title, URL and snippet; set read_pages to also get "
78
+ "the text of each result's page. Use for current events, facts to verify, or finding pages to read."
79
+ )
80
+ args_schema: Type[BaseModel] = MeshArcSearchInput
81
+
82
+ def _run(self, query: str, limit: int = 5, read_pages: bool = False,
83
+ run_manager: Optional[CallbackManagerForToolRun] = None) -> Tuple[str, Dict[str, Any]]:
84
+ try:
85
+ out = self._client.web_search(query, limit=limit, scrape=True if read_pages else None)
86
+ except Exception as exc: # the SDK's MeshArcError, a timeout or a network failure
87
+ raise _error(exc) from exc
88
+ if out.get("status") == "blocked":
89
+ return "The search engines refused this search; nothing was charged. Try again later.", out
90
+ hits = out.get("data") or []
91
+ if not hits:
92
+ return f"No results for {query!r}.", out
93
+ blocks: List[str] = []
94
+ for hit in hits:
95
+ lines = [f"{hit.get('position', len(blocks) + 1)}. {hit.get('title') or hit.get('url')}",
96
+ f" {hit.get('url', '')}"]
97
+ if hit.get("snippet"):
98
+ lines.append(f" {hit['snippet']}")
99
+ page = hit.get("page") or {}
100
+ if read_pages and page.get("markdown"):
101
+ lines.append("")
102
+ lines.append(_clip(page["markdown"], self.max_chars_per_page))
103
+ blocks.append("\n".join(lines))
104
+ return "\n\n".join(blocks), out
105
+
106
+
107
+ class MeshArcScrapeInput(BaseModel):
108
+ urls: List[str] = Field(
109
+ min_length=1, max_length=20,
110
+ description="The full URLs (with https://) of the pages to read, 1 to 20.",
111
+ )
112
+
113
+
114
+ class MeshArcScrape(_MeshArcTool):
115
+ """Read web pages through MeshArc as clean markdown.
116
+
117
+ Reads each URL (past bot walls when needed) and returns its text. The
118
+ content sent to the model is each page's title, URL and markdown, cut at
119
+ ``max_chars_per_page``; the artifact is the list of MeshArc page rows.
120
+
121
+ Example:
122
+ .. code-block:: python
123
+
124
+ from langchain_mesharc import MeshArcScrape
125
+
126
+ tool = MeshArcScrape()
127
+ tool.invoke({"urls": ["https://example.com/pricing"]})
128
+ """
129
+
130
+ name: str = "mesharc_scrape"
131
+ description: str = (
132
+ "Read web pages and return their main text as markdown. Give the full URLs. "
133
+ "Use when you know which pages you need; use mesharc_search first to find them."
134
+ )
135
+ args_schema: Type[BaseModel] = MeshArcScrapeInput
136
+
137
+ def _run(self, urls: List[str],
138
+ run_manager: Optional[CallbackManagerForToolRun] = None) -> Tuple[str, List[Dict[str, Any]]]:
139
+ try:
140
+ if len(urls) == 1:
141
+ rows = [self._client.scrape(urls[0])]
142
+ else:
143
+ rows = _core.batch_rows(self._client.scrape(list(urls)))
144
+ except Exception as exc:
145
+ raise _error(exc) from exc
146
+ blocks: List[str] = []
147
+ for row in rows:
148
+ meta = _core.page_metadata(row, "scrape")
149
+ if row.get("markdown"):
150
+ head = f"# {meta['title']}\n" if meta["title"] else ""
151
+ blocks.append(f"{head}URL: {meta['url']}\n\n{_clip(row['markdown'], self.max_chars_per_page)}")
152
+ else:
153
+ why = (row.get("reason") or row.get("error") or meta["error_code"] or meta["page_status"]
154
+ or meta["verdict"] or "no content")
155
+ blocks.append(f"URL: {meta['url']}\nNot read: {why}.")
156
+ return "\n\n---\n\n".join(blocks) or "Nothing came back.", rows
@@ -0,0 +1,127 @@
1
+ Metadata-Version: 2.4
2
+ Name: langchain-mesharc
3
+ Version: 0.1.0
4
+ Summary: LangChain integration for MeshArc: web pages as Documents, and web search and page reading as agent tools.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://mesharc.dev
7
+ Project-URL: Documentation, https://github.com/mesharc-org/langchain-mesharc#readme
8
+ Project-URL: Source, https://github.com/mesharc-org/langchain-mesharc
9
+ Project-URL: Issues, https://github.com/mesharc-org/langchain-mesharc/issues
10
+ Project-URL: Privacy, https://mesharc.dev/legal/privacy
11
+ Keywords: langchain,mesharc,web-scraping,crawler,document-loader,rag,markdown,change-detection
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Typing :: Typed
20
+ Classifier: Operating System :: OS Independent
21
+ Classifier: Topic :: Internet :: WWW/HTTP
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: langchain-core<2,>=1.0
26
+ Requires-Dist: mesharc<1,>=0.6
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=8; extra == "dev"
29
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
30
+ Requires-Dist: langchain-tests==1.1.9; extra == "dev"
31
+ Requires-Dist: build; extra == "dev"
32
+ Requires-Dist: twine; extra == "dev"
33
+ Requires-Dist: ruff>=0.16; extra == "dev"
34
+ Requires-Dist: mypy>=1.18; extra == "dev"
35
+ Dynamic: license-file
36
+
37
+ # langchain-mesharc
38
+
39
+ The LangChain integration for [MeshArc](https://mesharc.dev), the web crawler that turns sites into clean markdown and records what changed between crawls.
40
+
41
+ - **`MeshArcLoader`** loads web pages as LangChain `Document`s, in four modes: scrape, crawl, map and changes.
42
+ - **`MeshArcSearch`** and **`MeshArcScrape`** are tools an agent can call to search the web and read pages.
43
+
44
+ ```bash
45
+ pip install langchain-mesharc
46
+ ```
47
+
48
+ You need a MeshArc API key. Make one under **Settings → API keys** at [mesharc.dev](https://mesharc.dev); the free plan includes 1,000 credits with no card. Set it as `MESHARC_API_KEY`, or pass `api_key=`. Reading pages and searching need a key with **write** access.
49
+
50
+ ## Document loader
51
+
52
+ ```python
53
+ from langchain_mesharc import MeshArcLoader
54
+
55
+ # One page, or many read as one batch
56
+ docs = MeshArcLoader("https://example.com/pricing").load()
57
+ docs = MeshArcLoader(["https://example.com/", "https://example.com/about"]).load()
58
+
59
+ # A whole site, or a section of it; pages arrive as they are read
60
+ loader = MeshArcLoader("https://docs.example.com", mode="crawl",
61
+ params={"limit": 100, "includePaths": ["/guides/*"]})
62
+ for doc in loader.lazy_load():
63
+ print(doc.metadata["url"], len(doc.page_content))
64
+
65
+ # The URLs a site declares in its sitemaps, without fetching them
66
+ urls = MeshArcLoader("https://docs.example.com", mode="map").load()
67
+
68
+ # What changed on a watched site since the last crawl
69
+ changed = MeshArcLoader(mode="changes", project_id="prj_...").load()
70
+ ```
71
+
72
+ | Mode | Input | Each Document |
73
+ |---|---|---|
74
+ | `scrape` | a URL or a list of URLs | the page's markdown |
75
+ | `crawl` | a site's URL; `params` takes `limit`, `maxDepth`, `includePaths`, `excludePaths` and the rest of the [crawl options](https://mesharc.dev/docs/api) | one per page read |
76
+ | `map` | a site's URL; `params` takes `search` and `limit` | one per URL in the sitemaps, with the URL as its text |
77
+ | `changes` | `project_id`, and optionally `run_id` and `against` | one per added or modified page, holding the page as it is now |
78
+
79
+ **Metadata** on every page: `source` and `url`, `title`, `page_status` (one word: `ok`, `missing`, `blocked`, `captcha`, …), `crawled_at` and `mode`.
80
+ - Scraped and changed pages also have `status`, the HTTP status. A crawl's pages don't, because the API leaves it out of crawl results.
81
+ - Scraped pages also have `verdict` and `credits`.
82
+ - A crawl adds `crawl_id`.
83
+ - Changes add `project_id`, `run_id`, `previous_run_id`, `changed_at`, `change` (`added`, `modified`, `removed` or `fields`), `words_before` and `words_after`.
84
+
85
+ Values are always strings, numbers or booleans, so any vector store accepts them.
86
+
87
+ Pages that came back with no content (blocked, or a 404) are left out. Pass `include_empty=True` to keep them; their `page_status` and `error_code` say why. In `changes` mode, removed pages are left out unless `include_removed=True`.
88
+
89
+ `changes` mode keeps a vector store current: load it after each run of a [MeshArc project](https://mesharc.dev/docs), and re-embed only the pages that changed. Each Document's `id` is `run_id:url`.
90
+
91
+ ## Tools
92
+
93
+ ```python
94
+ from langchain_mesharc import MeshArcScrape, MeshArcSearch
95
+
96
+ tools = [MeshArcSearch(), MeshArcScrape()]
97
+ ```
98
+
99
+ | Tool | The agent gives | It gets back |
100
+ |---|---|---|
101
+ | `mesharc_search` (`MeshArcSearch`) | `query`, `limit` (1–10), `read_pages` | a numbered list of results (title, URL, snippet), and with `read_pages` each result's page text |
102
+ | `mesharc_scrape` (`MeshArcScrape`) | `urls` (1–20) | each page's title, URL and markdown, or why a page could not be read |
103
+
104
+ Both tools return `content_and_artifact`:
105
+ - the **content** is the text sent to the model, with each page cut at `max_chars_per_page` (8,000 by default; 0 for no limit);
106
+ - the **artifact** is MeshArc's full response.
107
+
108
+ A refused request, such as one with too few credits left, comes back to the agent as an error message rather than stopping it.
109
+
110
+ ## Cost
111
+
112
+ Requests are billed in MeshArc credits, the same as any other API call:
113
+ - a plain page read costs 1 credit, and blocked pages and 404s cost nothing;
114
+ - a web search costs what reading its results page costs;
115
+ - a sitemap read costs 1 credit per sitemap file.
116
+
117
+ See [pricing](https://mesharc.dev/pricing).
118
+
119
+ ## Links
120
+
121
+ - [MeshArc docs](https://mesharc.dev/docs) and [API reference](https://mesharc.dev/docs/api)
122
+ - [The Python SDK](https://github.com/mesharc-org/mesharc-python), which this package uses
123
+ - [Questions](https://github.com/orgs/mesharc-org/discussions) · security reports to security@mesharc.dev
124
+
125
+ ## License
126
+
127
+ MIT
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ langchain_mesharc/__init__.py
5
+ langchain_mesharc/_core.py
6
+ langchain_mesharc/document_loaders.py
7
+ langchain_mesharc/py.typed
8
+ langchain_mesharc/tools.py
9
+ langchain_mesharc.egg-info/PKG-INFO
10
+ langchain_mesharc.egg-info/SOURCES.txt
11
+ langchain_mesharc.egg-info/dependency_links.txt
12
+ langchain_mesharc.egg-info/requires.txt
13
+ langchain_mesharc.egg-info/top_level.txt
@@ -0,0 +1,11 @@
1
+ langchain-core<2,>=1.0
2
+ mesharc<1,>=0.6
3
+
4
+ [dev]
5
+ pytest>=8
6
+ pytest-asyncio>=0.23
7
+ langchain-tests==1.1.9
8
+ build
9
+ twine
10
+ ruff>=0.16
11
+ mypy>=1.18
@@ -0,0 +1 @@
1
+ langchain_mesharc
@@ -0,0 +1,58 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "langchain-mesharc"
7
+ version = "0.1.0"
8
+ description = "LangChain integration for MeshArc: web pages as Documents, and web search and page reading as agent tools."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ dependencies = ["langchain-core>=1.0,<2", "mesharc>=0.6,<1"]
14
+
15
+ keywords = ["langchain", "mesharc", "web-scraping", "crawler", "document-loader", "rag", "markdown", "change-detection"]
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Programming Language :: Python :: 3.14",
24
+ "Typing :: Typed",
25
+ "Operating System :: OS Independent",
26
+ "Topic :: Internet :: WWW/HTTP",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ dev = ["pytest>=8", "pytest-asyncio>=0.23", "langchain-tests==1.1.9", "build", "twine", "ruff>=0.16", "mypy>=1.18"]
31
+
32
+ [project.urls]
33
+ Homepage = "https://mesharc.dev"
34
+ Documentation = "https://github.com/mesharc-org/langchain-mesharc#readme"
35
+ Source = "https://github.com/mesharc-org/langchain-mesharc"
36
+ Issues = "https://github.com/mesharc-org/langchain-mesharc/issues"
37
+ Privacy = "https://mesharc.dev/legal/privacy"
38
+
39
+ [tool.setuptools.packages.find]
40
+ include = ["langchain_mesharc*"]
41
+
42
+ [tool.setuptools.package-data]
43
+ langchain_mesharc = ["py.typed"]
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
47
+ asyncio_mode = "auto"
48
+
49
+ [tool.ruff]
50
+ target-version = "py310"
51
+ extend-exclude = [".venv*"]
52
+
53
+ [tool.ruff.lint]
54
+ select = ["E4", "E7", "E9", "F"]
55
+
56
+ [tool.mypy]
57
+ python_version = "3.10"
58
+ files = ["langchain_mesharc"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+