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.
- langchain_mesharc-0.1.0/LICENSE +21 -0
- langchain_mesharc-0.1.0/PKG-INFO +127 -0
- langchain_mesharc-0.1.0/README.md +91 -0
- langchain_mesharc-0.1.0/langchain_mesharc/__init__.py +7 -0
- langchain_mesharc-0.1.0/langchain_mesharc/_core.py +192 -0
- langchain_mesharc-0.1.0/langchain_mesharc/document_loaders.py +100 -0
- langchain_mesharc-0.1.0/langchain_mesharc/py.typed +0 -0
- langchain_mesharc-0.1.0/langchain_mesharc/tools.py +156 -0
- langchain_mesharc-0.1.0/langchain_mesharc.egg-info/PKG-INFO +127 -0
- langchain_mesharc-0.1.0/langchain_mesharc.egg-info/SOURCES.txt +13 -0
- langchain_mesharc-0.1.0/langchain_mesharc.egg-info/dependency_links.txt +1 -0
- langchain_mesharc-0.1.0/langchain_mesharc.egg-info/requires.txt +11 -0
- langchain_mesharc-0.1.0/langchain_mesharc.egg-info/top_level.txt +1 -0
- langchain_mesharc-0.1.0/pyproject.toml +58 -0
- langchain_mesharc-0.1.0/setup.cfg +4 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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"]
|