passageway-connector-llmstxt 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- passageway_connector_llmstxt-0.3.0/.gitignore +11 -0
- passageway_connector_llmstxt-0.3.0/PKG-INFO +94 -0
- passageway_connector_llmstxt-0.3.0/README.md +85 -0
- passageway_connector_llmstxt-0.3.0/passageway_connector_llmstxt/__init__.py +0 -0
- passageway_connector_llmstxt-0.3.0/passageway_connector_llmstxt/__main__.py +3 -0
- passageway_connector_llmstxt-0.3.0/passageway_connector_llmstxt/connector.py +209 -0
- passageway_connector_llmstxt-0.3.0/pyproject.toml +22 -0
- passageway_connector_llmstxt-0.3.0/tests/conftest.py +54 -0
- passageway_connector_llmstxt-0.3.0/tests/test_llmstxt_connector.py +183 -0
- passageway_connector_llmstxt-0.3.0/tests/test_llmstxt_contract.py +87 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: passageway-connector-llmstxt
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: passageway connector for the llms-full.txt of a list of websites
|
|
5
|
+
License-Expression: PolyForm-Noncommercial-1.0.0
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: passageway-sdk[web]~=0.3.0
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# passageway-connector-llmstxt
|
|
11
|
+
|
|
12
|
+
A [passageway](https://github.com/getpassageway/passageway#readme) connector that indexes the `llms-full.txt` of a list of websites, a single markdown file such as a `SKILL.md`, or the pages an `llms.txt` index links to. Most documentation sites publish one of these for language models, and this connector makes them searchable without crawling.
|
|
13
|
+
|
|
14
|
+
## What it does
|
|
15
|
+
|
|
16
|
+
- **Each top-level heading is one item.** A `llms-full.txt` that concatenates a whole site becomes one item per page, with the sections below each heading as passages. Its id is the file's address plus the heading (`https://docs.example.org/llms-full.txt#install`), and its version is a hash of the text, so an edit re-indexes only that page. A `#` inside a code block is not a heading, and front matter at the top of a file is not indexed.
|
|
17
|
+
- **A front matter block with a `url:` key starts a page, wherever it is in the file.** Some `llms-full.txt` files (the VitePress llms plugin writes them) open every page with `---`, `url: /howto/install.md` and `---`. Each such page is one item that runs to the next block, however many headings it has. Its hit links to the page itself (`https://docs.example.org/howto/install`, the `.md` dropped), its title is the page's first heading (else the last part of the path), and its `path` is the host and the folders of the page, so a `path:` filter works. Its id is the file's address plus the `url:` value. A file without such blocks is split by heading, as above.
|
|
18
|
+
- **A site address reads its `llms-full.txt`.** `https://docs.example.org` is read as `https://docs.example.org/llms-full.txt`.
|
|
19
|
+
- **A file address is read as it is:** `https://docs.example.org/llms-full.txt`, `https://passageway.marceltov.de/SKILL.md`, any markdown or text file.
|
|
20
|
+
- **An `llms.txt` index is followed.** Every link in it that stays on the same host is fetched and indexed as a file of its own. Links to other hosts are not followed.
|
|
21
|
+
- **Any failure fails the whole listing.** A page that returns an error, or a server that is down, stops the sync with a message that names it, so a half-read site never deletes what was indexed before.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
Add the connector to the environment that runs passageway (see [Install](https://github.com/getpassageway/passageway/blob/main/docs/install.md)):
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
uv tool install \
|
|
29
|
+
--with 'passageway-connector-llmstxt @ git+https://github.com/getpassageway/passageway#subdirectory=connectors/llmstxt' \
|
|
30
|
+
git+https://github.com/getpassageway/passageway
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## An example
|
|
34
|
+
|
|
35
|
+
A file `https://docs.example.org/llms-full.txt`:
|
|
36
|
+
|
|
37
|
+
```markdown
|
|
38
|
+
# Install
|
|
39
|
+
|
|
40
|
+
Run the installer.
|
|
41
|
+
|
|
42
|
+
## Linux
|
|
43
|
+
|
|
44
|
+
Use the package manager.
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
becomes the item:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"title": "Install",
|
|
52
|
+
"sections": [
|
|
53
|
+
{
|
|
54
|
+
"heading": null,
|
|
55
|
+
"text": "Run the installer."
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"heading": "Linux",
|
|
59
|
+
"text": "Use the package manager."
|
|
60
|
+
}
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
and is configured as:
|
|
66
|
+
|
|
67
|
+
```toml
|
|
68
|
+
[[connector]]
|
|
69
|
+
name = "docs"
|
|
70
|
+
command = ["/path/to/python", "-m", "passageway_connector_llmstxt"]
|
|
71
|
+
env = { URLS = "https://docs.example.org|https://passageway.marceltov.de/SKILL.md" }
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
For a site whose `llms-full.txt` opens every page with a `url:` front matter block, and whose release notes should rank below its how-to pages:
|
|
75
|
+
|
|
76
|
+
```toml
|
|
77
|
+
[[connector]]
|
|
78
|
+
name = "docs"
|
|
79
|
+
command = ["/path/to/python", "-m", "passageway_connector_llmstxt"]
|
|
80
|
+
env = { URLS = "https://docs.example.org", WEIGHTS = "changelog=0.5" }
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Settings
|
|
84
|
+
|
|
85
|
+
All settings are strings in the connector's `env`.
|
|
86
|
+
|
|
87
|
+
| Setting | Meaning | Default |
|
|
88
|
+
|---|---|---|
|
|
89
|
+
| `URLS` | Required. Site addresses, file addresses and `llms.txt` addresses, separated by `\|`. | |
|
|
90
|
+
| `MAX_BYTES` | The largest file that is read, in bytes. A larger one fails the sync. | `20000000` |
|
|
91
|
+
| `WEIGHTS` | Lowers or raises the weight of the pages whose path starts with a prefix: `changelog=0.5,archive/=0.3` (comma-separated `prefix=weight`, the weight above 0, the first match wins). Release notes repeat the vocabulary of how-to pages and push them down the results; a weight below 1 ranks them lower. The path is the page's address without the host. | all pages 1.0 |
|
|
92
|
+
| `MAX_PAGES` | The most pages followed from one `llms.txt` index. The rest are skipped with a warning. | `500` |
|
|
93
|
+
|
|
94
|
+
Requests carry the user agent `passageway-connector-llmstxt`, follow redirects and verify HTTPS against the operating system's trust store.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# passageway-connector-llmstxt
|
|
2
|
+
|
|
3
|
+
A [passageway](https://github.com/getpassageway/passageway#readme) connector that indexes the `llms-full.txt` of a list of websites, a single markdown file such as a `SKILL.md`, or the pages an `llms.txt` index links to. Most documentation sites publish one of these for language models, and this connector makes them searchable without crawling.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
- **Each top-level heading is one item.** A `llms-full.txt` that concatenates a whole site becomes one item per page, with the sections below each heading as passages. Its id is the file's address plus the heading (`https://docs.example.org/llms-full.txt#install`), and its version is a hash of the text, so an edit re-indexes only that page. A `#` inside a code block is not a heading, and front matter at the top of a file is not indexed.
|
|
8
|
+
- **A front matter block with a `url:` key starts a page, wherever it is in the file.** Some `llms-full.txt` files (the VitePress llms plugin writes them) open every page with `---`, `url: /howto/install.md` and `---`. Each such page is one item that runs to the next block, however many headings it has. Its hit links to the page itself (`https://docs.example.org/howto/install`, the `.md` dropped), its title is the page's first heading (else the last part of the path), and its `path` is the host and the folders of the page, so a `path:` filter works. Its id is the file's address plus the `url:` value. A file without such blocks is split by heading, as above.
|
|
9
|
+
- **A site address reads its `llms-full.txt`.** `https://docs.example.org` is read as `https://docs.example.org/llms-full.txt`.
|
|
10
|
+
- **A file address is read as it is:** `https://docs.example.org/llms-full.txt`, `https://passageway.marceltov.de/SKILL.md`, any markdown or text file.
|
|
11
|
+
- **An `llms.txt` index is followed.** Every link in it that stays on the same host is fetched and indexed as a file of its own. Links to other hosts are not followed.
|
|
12
|
+
- **Any failure fails the whole listing.** A page that returns an error, or a server that is down, stops the sync with a message that names it, so a half-read site never deletes what was indexed before.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
Add the connector to the environment that runs passageway (see [Install](https://github.com/getpassageway/passageway/blob/main/docs/install.md)):
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
uv tool install \
|
|
20
|
+
--with 'passageway-connector-llmstxt @ git+https://github.com/getpassageway/passageway#subdirectory=connectors/llmstxt' \
|
|
21
|
+
git+https://github.com/getpassageway/passageway
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## An example
|
|
25
|
+
|
|
26
|
+
A file `https://docs.example.org/llms-full.txt`:
|
|
27
|
+
|
|
28
|
+
```markdown
|
|
29
|
+
# Install
|
|
30
|
+
|
|
31
|
+
Run the installer.
|
|
32
|
+
|
|
33
|
+
## Linux
|
|
34
|
+
|
|
35
|
+
Use the package manager.
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
becomes the item:
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{
|
|
42
|
+
"title": "Install",
|
|
43
|
+
"sections": [
|
|
44
|
+
{
|
|
45
|
+
"heading": null,
|
|
46
|
+
"text": "Run the installer."
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"heading": "Linux",
|
|
50
|
+
"text": "Use the package manager."
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
and is configured as:
|
|
57
|
+
|
|
58
|
+
```toml
|
|
59
|
+
[[connector]]
|
|
60
|
+
name = "docs"
|
|
61
|
+
command = ["/path/to/python", "-m", "passageway_connector_llmstxt"]
|
|
62
|
+
env = { URLS = "https://docs.example.org|https://passageway.marceltov.de/SKILL.md" }
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
For a site whose `llms-full.txt` opens every page with a `url:` front matter block, and whose release notes should rank below its how-to pages:
|
|
66
|
+
|
|
67
|
+
```toml
|
|
68
|
+
[[connector]]
|
|
69
|
+
name = "docs"
|
|
70
|
+
command = ["/path/to/python", "-m", "passageway_connector_llmstxt"]
|
|
71
|
+
env = { URLS = "https://docs.example.org", WEIGHTS = "changelog=0.5" }
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Settings
|
|
75
|
+
|
|
76
|
+
All settings are strings in the connector's `env`.
|
|
77
|
+
|
|
78
|
+
| Setting | Meaning | Default |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| `URLS` | Required. Site addresses, file addresses and `llms.txt` addresses, separated by `\|`. | |
|
|
81
|
+
| `MAX_BYTES` | The largest file that is read, in bytes. A larger one fails the sync. | `20000000` |
|
|
82
|
+
| `WEIGHTS` | Lowers or raises the weight of the pages whose path starts with a prefix: `changelog=0.5,archive/=0.3` (comma-separated `prefix=weight`, the weight above 0, the first match wins). Release notes repeat the vocabulary of how-to pages and push them down the results; a weight below 1 ranks them lower. The path is the page's address without the host. | all pages 1.0 |
|
|
83
|
+
| `MAX_PAGES` | The most pages followed from one `llms.txt` index. The rest are skipped with a warning. | `500` |
|
|
84
|
+
|
|
85
|
+
Requests carry the user agent `passageway-connector-llmstxt`, follow redirects and verify HTTPS against the operating system's trust store.
|
|
File without changes
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""passageway connector for the llms-full.txt (and llms.txt) of a list of websites."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from urllib.parse import urldefrag, urljoin, urlparse
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from passageway_sdk import Connector, SettingsError, SourceError, split_markdown
|
|
12
|
+
from passageway_sdk.settings import whole
|
|
13
|
+
from passageway_sdk.web import TooLarge, Web
|
|
14
|
+
|
|
15
|
+
FRONT_MATTER = re.compile(r"\A---[ \t]*\r?\n.*?\r?\n(?:---|\.\.\.)[ \t]*(?:\r?\n|\Z)", re.S)
|
|
16
|
+
LINK = re.compile(r"\]\(\s*<?([^)\s>]+)")
|
|
17
|
+
FENCE = re.compile(r"^\s*(```|~~~)")
|
|
18
|
+
PAGE_URL = re.compile(r"url:[ \t]*(\S+)[ \t]*$")
|
|
19
|
+
BLOCK_LINES = 20 # a front matter block longer than this is not one
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _weights(text: str) -> list[tuple[str, float]]:
|
|
23
|
+
"""`changelog=0.5,archive/=0.3`: a weight for every page whose path starts with a prefix; the first match wins."""
|
|
24
|
+
out = []
|
|
25
|
+
for part in (p.strip() for p in text.split(",") if p.strip()):
|
|
26
|
+
prefix, _, value = part.partition("=")
|
|
27
|
+
try:
|
|
28
|
+
weight = float(value)
|
|
29
|
+
except ValueError:
|
|
30
|
+
weight = 0.0
|
|
31
|
+
if not prefix.strip() or weight <= 0:
|
|
32
|
+
raise SettingsError(f"WEIGHTS must be prefix=weight pairs with a weight above 0, got {part!r}")
|
|
33
|
+
out.append((prefix.strip().lstrip("/"), weight))
|
|
34
|
+
return out
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _slug(title: str) -> str:
|
|
38
|
+
return re.sub(r"[^a-z0-9]+", "-", title.casefold()).strip("-") or "page"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Page:
|
|
43
|
+
id: str
|
|
44
|
+
title: str
|
|
45
|
+
file: str
|
|
46
|
+
text: str
|
|
47
|
+
url: str = "" # the address a hit opens; the file's own when the page has none of its own
|
|
48
|
+
path: tuple[str, ...] = ()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def split_pages(file: str, text: str) -> list[Page]:
|
|
52
|
+
"""Pages of a file: at every front matter block with a `url:` key (the layout of the VitePress llms plugin), else at every
|
|
53
|
+
top-level heading. A block or a `#` inside a code fence does not count; text before the first block is split by heading."""
|
|
54
|
+
lines = text.splitlines()
|
|
55
|
+
starts = _page_blocks(lines)
|
|
56
|
+
if not starts:
|
|
57
|
+
return _by_headings(file, FRONT_MATTER.sub("", text, count=1))
|
|
58
|
+
pages = _by_headings(file, "\n".join(lines[:starts[0][0]]))
|
|
59
|
+
used = {p.id for p in pages}
|
|
60
|
+
for n, (start, end, url) in enumerate(starts):
|
|
61
|
+
stop = starts[n + 1][0] if n + 1 < len(starts) else len(lines)
|
|
62
|
+
body = "\n".join(lines[end:stop]).strip()
|
|
63
|
+
if body:
|
|
64
|
+
pages.append(_page_of_url(file, url, body, used))
|
|
65
|
+
return pages
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _page_blocks(lines: list[str]) -> list[tuple[int, int, str]]:
|
|
69
|
+
"""(first line, line after the last, url) of every front matter block with a `url:` key, outside code fences."""
|
|
70
|
+
blocks, fenced, i = [], False, 0
|
|
71
|
+
while i < len(lines):
|
|
72
|
+
if FENCE.match(lines[i]):
|
|
73
|
+
fenced = not fenced
|
|
74
|
+
elif not fenced and lines[i].strip() == "---":
|
|
75
|
+
close = next((j for j in range(i + 1, min(i + BLOCK_LINES, len(lines))) if lines[j].strip() == "---"), None)
|
|
76
|
+
found = close and next((m.group(1) for m in map(PAGE_URL.match, (l.strip() for l in lines[i + 1:close])) if m), None)
|
|
77
|
+
if found:
|
|
78
|
+
blocks.append((i, close + 1, found))
|
|
79
|
+
i = close
|
|
80
|
+
i += 1
|
|
81
|
+
return blocks
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _page_of_url(file: str, url: str, body: str, used: set[str]) -> Page:
|
|
85
|
+
path = urlparse(urljoin(file, url)).path
|
|
86
|
+
target = urljoin(file, url[:-3] if url.endswith(".md") else url) # the HTML page, not the raw markdown
|
|
87
|
+
heading = next((l[2:].strip() for l in body.splitlines() if l.startswith("# ") and l[2:].strip()), "")
|
|
88
|
+
title = heading or (path.rstrip("/").rsplit("/", 1)[-1].removesuffix(".md") or urlparse(file).netloc)
|
|
89
|
+
page_id, n = f"{file}#{url}", 1
|
|
90
|
+
while page_id in used:
|
|
91
|
+
n += 1
|
|
92
|
+
page_id = f"{file}#{url}-{n}"
|
|
93
|
+
used.add(page_id)
|
|
94
|
+
folders = tuple(p for p in path.split("/")[1:-1] if p)
|
|
95
|
+
return Page(page_id, title, file, body, target, (urlparse(file).netloc, *folders))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _by_headings(file: str, text: str) -> list[Page]:
|
|
99
|
+
"""One page per top-level heading; text before the first goes to a page of its own."""
|
|
100
|
+
host = urlparse(file).netloc
|
|
101
|
+
chunks: list[tuple[str, list[str]]] = [(host, [])]
|
|
102
|
+
fenced = False
|
|
103
|
+
for line in text.splitlines():
|
|
104
|
+
if FENCE.match(line):
|
|
105
|
+
fenced = not fenced
|
|
106
|
+
if not fenced and line.startswith("# "):
|
|
107
|
+
chunks.append((line[2:].strip() or host, [line]))
|
|
108
|
+
else:
|
|
109
|
+
chunks[-1][1].append(line)
|
|
110
|
+
pages, used = [], set()
|
|
111
|
+
for title, lines in chunks:
|
|
112
|
+
body = "\n".join(lines).strip()
|
|
113
|
+
if not body:
|
|
114
|
+
continue
|
|
115
|
+
slug, n = _slug(title), 1
|
|
116
|
+
while slug in used:
|
|
117
|
+
n += 1
|
|
118
|
+
slug = f"{_slug(title)}-{n}"
|
|
119
|
+
used.add(slug)
|
|
120
|
+
pages.append(Page(f"{file}#{slug}", title, file, body, file, (host,)))
|
|
121
|
+
return pages
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class LlmsTxt(Connector):
|
|
125
|
+
name = "llmstxt connector"
|
|
126
|
+
|
|
127
|
+
def __init__(self, env: Mapping[str, str], *, transport: httpx.BaseTransport | None = None,
|
|
128
|
+
sleep: Callable[[float], None] = time.sleep):
|
|
129
|
+
self.urls = [u.strip() for u in env.get("URLS", "").split("|") if u.strip()]
|
|
130
|
+
if not self.urls:
|
|
131
|
+
raise SettingsError("URLS must name at least one site or file")
|
|
132
|
+
for url in self.urls:
|
|
133
|
+
if urlparse(url).scheme not in ("http", "https") or not urlparse(url).netloc:
|
|
134
|
+
raise SettingsError(f"URLS must be http(s) addresses, got {url!r}")
|
|
135
|
+
self.max_bytes = whole(env, "MAX_BYTES", 20_000_000)
|
|
136
|
+
self.max_pages = whole(env, "MAX_PAGES", 500)
|
|
137
|
+
self.weights = _weights(env.get("WEIGHTS", ""))
|
|
138
|
+
self._web = Web("passageway-connector-llmstxt", max_bytes=self.max_bytes, transport=transport, sleep=sleep)
|
|
139
|
+
|
|
140
|
+
# -- the protocol ------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
def versions(self) -> dict[str, str]:
|
|
143
|
+
return {p.id: self._version(p) for p in self._pages()}
|
|
144
|
+
|
|
145
|
+
def fetch(self, ids: list[str]) -> Iterator[dict]:
|
|
146
|
+
wanted = set(ids)
|
|
147
|
+
for file in dict.fromkeys(i.split("#")[0] for i in ids if urlparse(i).scheme in ("http", "https")): # an id that is no address names nothing
|
|
148
|
+
for page in split_pages(file, self._get(file)):
|
|
149
|
+
if page.id in wanted:
|
|
150
|
+
yield self._item(page)
|
|
151
|
+
|
|
152
|
+
# -- the sites ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
def _files(self) -> list[str]:
|
|
155
|
+
files: list[str] = []
|
|
156
|
+
for url in self.urls:
|
|
157
|
+
parsed = urlparse(url)
|
|
158
|
+
if parsed.path in ("", "/"):
|
|
159
|
+
files.append(f"{parsed.scheme}://{parsed.netloc}/llms-full.txt")
|
|
160
|
+
elif parsed.path.rsplit("/", 1)[-1] == "llms.txt":
|
|
161
|
+
files.extend(self._linked(url))
|
|
162
|
+
else:
|
|
163
|
+
files.append(urldefrag(url)[0])
|
|
164
|
+
return list(dict.fromkeys(files))
|
|
165
|
+
|
|
166
|
+
def _linked(self, index: str) -> list[str]:
|
|
167
|
+
host, links = urlparse(index).netloc, []
|
|
168
|
+
for target in LINK.findall(self._get(index)):
|
|
169
|
+
link = urldefrag(urljoin(index, target))[0]
|
|
170
|
+
if urlparse(link).scheme in ("http", "https") and urlparse(link).netloc == host and link != index:
|
|
171
|
+
links.append(link)
|
|
172
|
+
links = list(dict.fromkeys(links))
|
|
173
|
+
if len(links) > self.max_pages:
|
|
174
|
+
self.warn(f"{index} links to {len(links)} pages; only the first {self.max_pages} are indexed (MAX_PAGES)")
|
|
175
|
+
return links[:self.max_pages]
|
|
176
|
+
|
|
177
|
+
def _pages(self) -> list[Page]:
|
|
178
|
+
return [page for file in self._files() for page in split_pages(file, self._get(file))]
|
|
179
|
+
|
|
180
|
+
def _get(self, url: str) -> str:
|
|
181
|
+
try:
|
|
182
|
+
return self._web.get(url, required=True).text
|
|
183
|
+
except TooLarge:
|
|
184
|
+
raise SourceError(f"{url}: larger than MAX_BYTES ({self.max_bytes})") from None
|
|
185
|
+
|
|
186
|
+
def _weight(self, page: Page) -> float:
|
|
187
|
+
where = urlparse(page.url).path.lstrip("/")
|
|
188
|
+
return next((w for prefix, w in self.weights if where.startswith(prefix)), 1.0)
|
|
189
|
+
|
|
190
|
+
def _version(self, page: Page) -> str:
|
|
191
|
+
weight = self._weight(page) # a changed weight is a changed item, so the index follows it
|
|
192
|
+
return hashlib.sha256((page.text if weight == 1.0 else f"{page.text}\0{weight}").encode()).hexdigest()[:16]
|
|
193
|
+
|
|
194
|
+
def _item(self, page: Page) -> dict:
|
|
195
|
+
item = {
|
|
196
|
+
"id": page.id,
|
|
197
|
+
"title": page.title,
|
|
198
|
+
"url": page.url,
|
|
199
|
+
"version": self._version(page),
|
|
200
|
+
"sections": split_markdown(page.text, skip_first_h1=True),
|
|
201
|
+
"path": list(page.path),
|
|
202
|
+
}
|
|
203
|
+
if self._weight(page) != 1.0:
|
|
204
|
+
item["weight"] = self._weight(page)
|
|
205
|
+
return item
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def main() -> None:
|
|
209
|
+
LlmsTxt.main()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling==1.33.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "passageway-connector-llmstxt"
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "passageway connector for the llms-full.txt of a list of websites"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = "PolyForm-Noncommercial-1.0.0"
|
|
12
|
+
dependencies = ["passageway-sdk[web]~=0.3.0"]
|
|
13
|
+
|
|
14
|
+
[tool.hatch.build.targets.wheel]
|
|
15
|
+
packages = ["passageway_connector_llmstxt"]
|
|
16
|
+
|
|
17
|
+
[dependency-groups]
|
|
18
|
+
dev = ["jsonschema>=4.23", "pytest>=8.3", "passageway"]
|
|
19
|
+
|
|
20
|
+
[tool.uv.sources]
|
|
21
|
+
passageway = { workspace = true }
|
|
22
|
+
passageway-sdk = { workspace = true }
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from passageway_connector_llmstxt.connector import LlmsTxt
|
|
8
|
+
|
|
9
|
+
DOCS = """# Install
|
|
10
|
+
|
|
11
|
+
Run the installer.
|
|
12
|
+
|
|
13
|
+
## Linux
|
|
14
|
+
|
|
15
|
+
Use the package manager.
|
|
16
|
+
|
|
17
|
+
# Configure
|
|
18
|
+
|
|
19
|
+
Set the token in the settings file.
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
# not a heading
|
|
23
|
+
```
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Sites:
|
|
28
|
+
"""A fake web: path -> (status, body); and a way to run the connector against it in-process."""
|
|
29
|
+
|
|
30
|
+
def __init__(self):
|
|
31
|
+
self.pages = {"https://docs.example/llms-full.txt": DOCS}
|
|
32
|
+
self.requests = []
|
|
33
|
+
|
|
34
|
+
def handler(self, request: httpx.Request) -> httpx.Response:
|
|
35
|
+
self.requests.append(str(request.url))
|
|
36
|
+
body = self.pages.get(str(request.url))
|
|
37
|
+
return httpx.Response(404) if body is None else httpx.Response(200, text=body)
|
|
38
|
+
|
|
39
|
+
def call(self, mode, stdin="", **settings):
|
|
40
|
+
out, err = io.StringIO(), io.StringIO()
|
|
41
|
+
env = {"URLS": "https://docs.example", **settings}
|
|
42
|
+
code = LlmsTxt.run(["llmstxt", mode], env, io.StringIO(stdin), out, err, transport=httpx.MockTransport(self.handler),
|
|
43
|
+
sleep=lambda s: None)
|
|
44
|
+
return code, [json.loads(line) for line in out.getvalue().splitlines()], err.getvalue()
|
|
45
|
+
|
|
46
|
+
def fetch(self, *ids, **settings):
|
|
47
|
+
code, records, err = self.call("fetch", "".join(json.dumps({"id": i}) + "\n" for i in ids), **settings)
|
|
48
|
+
assert code == 0, err
|
|
49
|
+
return {r["id"]: r for r in records}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.fixture
|
|
53
|
+
def sites():
|
|
54
|
+
return Sites()
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_each_top_level_heading_is_an_item_of_the_file(sites):
|
|
5
|
+
code, records, err = sites.call("list")
|
|
6
|
+
assert code == 0, err
|
|
7
|
+
ids = [r["id"] for r in records]
|
|
8
|
+
assert ids == ["https://docs.example/llms-full.txt#install", "https://docs.example/llms-full.txt#configure"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_a_page_has_its_title_sections_and_the_file_as_address(sites):
|
|
12
|
+
page = sites.fetch("https://docs.example/llms-full.txt#install")["https://docs.example/llms-full.txt#install"]
|
|
13
|
+
assert page["title"] == "Install" and page["url"] == "https://docs.example/llms-full.txt"
|
|
14
|
+
assert page["path"] == ["docs.example"]
|
|
15
|
+
assert {"heading": "Linux", "text": "Use the package manager."} in page["sections"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_a_hash_in_a_code_block_does_not_start_a_page(sites):
|
|
19
|
+
page = sites.fetch("https://docs.example/llms-full.txt#configure")["https://docs.example/llms-full.txt#configure"]
|
|
20
|
+
assert "not a heading" in json.dumps(page["sections"])
|
|
21
|
+
assert len(sites.call("list")[1]) == 2
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_the_version_changes_only_when_a_page_changes(sites):
|
|
25
|
+
before = {r["id"]: r["version"] for r in sites.call("list")[1]}
|
|
26
|
+
sites.pages["https://docs.example/llms-full.txt"] = sites.pages["https://docs.example/llms-full.txt"].replace("installer", "setup tool")
|
|
27
|
+
after = {r["id"]: r["version"] for r in sites.call("list")[1]}
|
|
28
|
+
assert before["https://docs.example/llms-full.txt#configure"] == after["https://docs.example/llms-full.txt#configure"]
|
|
29
|
+
assert before["https://docs.example/llms-full.txt#install"] != after["https://docs.example/llms-full.txt#install"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_a_direct_url_and_several_sites_are_read(sites):
|
|
33
|
+
sites.pages["https://other.example/docs/llms-full.txt"] = "# Other\n\nText."
|
|
34
|
+
code, records, err = sites.call("list", URLS="https://docs.example|https://other.example/docs/llms-full.txt")
|
|
35
|
+
assert code == 0, err
|
|
36
|
+
assert len(records) == 3
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_a_file_without_a_heading_is_one_item_named_after_the_host(sites):
|
|
40
|
+
sites.pages["https://docs.example/llms-full.txt"] = "Just text."
|
|
41
|
+
page = next(iter(sites.fetch("https://docs.example/llms-full.txt#docs-example").values()))
|
|
42
|
+
assert page["title"] == "docs.example"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_a_missing_file_fails_the_whole_listing_so_nothing_is_deleted(sites):
|
|
46
|
+
code, records, err = sites.call("list", URLS="https://docs.example|https://gone.example")
|
|
47
|
+
assert code == 1 and records == [] and "gone.example" in err
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_settings_are_checked(sites):
|
|
51
|
+
assert sites.call("list", URLS="")[0] == 2
|
|
52
|
+
assert sites.call("list", URLS="ftp://x.example")[0] == 2
|
|
53
|
+
assert sites.call("list", MAX_BYTES="0")[0] == 2
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_a_file_over_the_size_limit_fails_the_listing(sites):
|
|
57
|
+
code, _, err = sites.call("list", MAX_BYTES="10")
|
|
58
|
+
assert code == 1 and "MAX_BYTES" in err
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_a_single_markdown_file_with_front_matter_is_one_item(sites):
|
|
62
|
+
sites.pages["https://docs.example/SKILL.md"] = "---\nname: passageway\n---\n# Passageway\n\nAsk the sources.\n"
|
|
63
|
+
code, records, err = sites.call("list", URLS="https://docs.example/SKILL.md")
|
|
64
|
+
assert code == 0, err
|
|
65
|
+
assert [r["id"] for r in records] == ["https://docs.example/SKILL.md#passageway"]
|
|
66
|
+
page = sites.fetch("https://docs.example/SKILL.md#passageway", URLS="https://docs.example/SKILL.md")
|
|
67
|
+
assert "name: passageway" not in json.dumps(page) # the front matter is not indexed as text
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_an_llms_txt_index_is_followed_page_by_page_on_the_same_host(sites):
|
|
71
|
+
sites.pages["https://docs.example/llms.txt"] = (
|
|
72
|
+
"# Example\n\n> A site.\n\n## Docs\n\n- [Start](https://docs.example/start.md): begin here\n"
|
|
73
|
+
"- [Relative](/guide/setup.md)\n- [Elsewhere](https://evil.example/x.md)\n- [Start again](https://docs.example/start.md#top)\n")
|
|
74
|
+
sites.pages["https://docs.example/start.md"] = "# Start\n\nBegin here."
|
|
75
|
+
sites.pages["https://docs.example/guide/setup.md"] = "# Setup\n\nInstall it."
|
|
76
|
+
code, records, err = sites.call("list", URLS="https://docs.example/llms.txt")
|
|
77
|
+
assert code == 0, err
|
|
78
|
+
assert [r["id"] for r in records] == ["https://docs.example/start.md#start", "https://docs.example/guide/setup.md#setup"]
|
|
79
|
+
assert not any("evil.example" in url for url in sites.requests)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_a_broken_link_in_an_index_fails_the_listing(sites):
|
|
83
|
+
sites.pages["https://docs.example/llms.txt"] = "# Example\n\n- [Gone](https://docs.example/gone.md)\n"
|
|
84
|
+
code, records, err = sites.call("list", URLS="https://docs.example/llms.txt")
|
|
85
|
+
assert code == 1 and records == [] and "gone.md" in err
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_the_number_of_pages_followed_from_an_index_is_capped(sites):
|
|
89
|
+
sites.pages["https://docs.example/llms.txt"] = "".join(f"- [P{n}](https://docs.example/p{n}.md)\n" for n in range(5))
|
|
90
|
+
for n in range(5):
|
|
91
|
+
sites.pages[f"https://docs.example/p{n}.md"] = f"# P{n}\n\ntext"
|
|
92
|
+
code, records, err = sites.call("list", URLS="https://docs.example/llms.txt", MAX_PAGES="3")
|
|
93
|
+
assert code == 0 and len(records) == 3 and "MAX_PAGES" in err
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
TEAMSCALE = """---
|
|
97
|
+
url: /changelog/v1/patch-releases.md
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
# Patch Releases
|
|
101
|
+
|
|
102
|
+
Fixes.
|
|
103
|
+
|
|
104
|
+
## Details
|
|
105
|
+
|
|
106
|
+
# Not a new page
|
|
107
|
+
|
|
108
|
+
Still the same page.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
url: /howto/install.md
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
# Install
|
|
115
|
+
|
|
116
|
+
Run it.
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
url: /howto/no-heading.md
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
Text without any heading.
|
|
123
|
+
"""
|
|
124
|
+
FULL = "https://docs.example/llms-full.txt"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_front_matter_with_a_url_starts_a_page_wherever_it_is_in_the_file(sites):
|
|
128
|
+
sites.pages[FULL] = TEAMSCALE
|
|
129
|
+
ids = [r["id"] for r in sites.call("list")[1]]
|
|
130
|
+
assert ids == [f"{FULL}#/changelog/v1/patch-releases.md", f"{FULL}#/howto/install.md", f"{FULL}#/howto/no-heading.md"]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_such_a_page_links_to_its_own_address_and_carries_its_folders(sites):
|
|
134
|
+
sites.pages[FULL] = TEAMSCALE
|
|
135
|
+
page = sites.fetch(f"{FULL}#/howto/install.md")[f"{FULL}#/howto/install.md"]
|
|
136
|
+
assert page["url"] == "https://docs.example/howto/install" # the .md of the file's path is dropped
|
|
137
|
+
assert page["title"] == "Install"
|
|
138
|
+
assert page["path"] == ["docs.example", "howto"]
|
|
139
|
+
assert "url:" not in json.dumps(page["sections"]) # the front matter block is not text
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_a_page_runs_to_the_next_block_even_over_its_own_headings(sites):
|
|
143
|
+
sites.pages[FULL] = TEAMSCALE
|
|
144
|
+
page = sites.fetch(f"{FULL}#/changelog/v1/patch-releases.md")[f"{FULL}#/changelog/v1/patch-releases.md"]
|
|
145
|
+
assert "Still the same page" in json.dumps(page["sections"])
|
|
146
|
+
assert page["path"] == ["docs.example", "changelog", "v1"]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_a_page_without_a_heading_is_titled_by_the_last_part_of_its_path(sites):
|
|
150
|
+
sites.pages[FULL] = TEAMSCALE
|
|
151
|
+
page = sites.fetch(f"{FULL}#/howto/no-heading.md")[f"{FULL}#/howto/no-heading.md"]
|
|
152
|
+
assert page["title"] == "no-heading"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_text_before_the_first_block_is_still_indexed(sites):
|
|
156
|
+
sites.pages[FULL] = "# Intro\n\nWelcome.\n\n" + TEAMSCALE
|
|
157
|
+
ids = [r["id"] for r in sites.call("list")[1]]
|
|
158
|
+
assert ids[0] == f"{FULL}#intro" and len(ids) == 4
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_front_matter_in_a_code_block_is_not_a_page(sites):
|
|
162
|
+
sites.pages[FULL] = "# Guide\n\n```\n---\nurl: /fake.md\n---\n```\n"
|
|
163
|
+
assert [r["id"] for r in sites.call("list")[1]] == [f"{FULL}#guide"]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_a_weight_per_path_prefix_lowers_a_part_of_a_site(sites):
|
|
167
|
+
sites.pages[FULL] = TEAMSCALE
|
|
168
|
+
pages = sites.fetch(f"{FULL}#/changelog/v1/patch-releases.md", f"{FULL}#/howto/install.md", WEIGHTS="changelog=0.5")
|
|
169
|
+
assert pages[f"{FULL}#/changelog/v1/patch-releases.md"]["weight"] == 0.5
|
|
170
|
+
assert "weight" not in pages[f"{FULL}#/howto/install.md"]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_a_changed_weight_changes_the_version_so_the_index_follows(sites):
|
|
174
|
+
sites.pages[FULL] = TEAMSCALE
|
|
175
|
+
before = {r["id"]: r["version"] for r in sites.call("list")[1]}
|
|
176
|
+
after = {r["id"]: r["version"] for r in sites.call("list", WEIGHTS="changelog=0.5")[1]}
|
|
177
|
+
assert before[f"{FULL}#/changelog/v1/patch-releases.md"] != after[f"{FULL}#/changelog/v1/patch-releases.md"]
|
|
178
|
+
assert before[f"{FULL}#/howto/install.md"] == after[f"{FULL}#/howto/install.md"]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_weights_are_checked(sites):
|
|
182
|
+
for bad in ("changelog", "changelog=heavy", "changelog=0", "=0.5"):
|
|
183
|
+
assert sites.call("list", WEIGHTS=bad)[0] == 2, bad
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import http.server
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import jsonschema
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from passageway.protocol import Connector
|
|
15
|
+
from passageway.testing.contract import check_connector
|
|
16
|
+
|
|
17
|
+
README = Path(__file__).resolve().parents[1] / "README.md"
|
|
18
|
+
SCHEMA = Path(__file__).resolve().parents[3] / "docs" / "reference" / "connector-protocol.schema.json"
|
|
19
|
+
DOCS = "# Install\n\nRun the installer.\n\n## Linux\n\nUse the package manager.\n\n# Configure\n\nSet the token.\n"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Site(str):
|
|
23
|
+
def __new__(cls, url, root):
|
|
24
|
+
site = super().__new__(cls, url)
|
|
25
|
+
site.root = root
|
|
26
|
+
return site
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture
|
|
30
|
+
def site(tmp_path):
|
|
31
|
+
"""A real web server on localhost serving a llms-full.txt, so the connector runs as a real subprocess."""
|
|
32
|
+
(tmp_path / "llms-full.txt").write_text(DOCS)
|
|
33
|
+
handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(tmp_path))
|
|
34
|
+
handler.log_message = lambda *args: None
|
|
35
|
+
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
|
36
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
37
|
+
yield Site(f"http://127.0.0.1:{server.server_port}", tmp_path)
|
|
38
|
+
server.shutdown()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def command():
|
|
42
|
+
return [sys.executable, "-m", "passageway_connector_llmstxt"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_the_connector_passes_the_shared_contract_as_a_real_subprocess(site):
|
|
46
|
+
connector = Connector("llmstxt", command(), env={"URLS": site})
|
|
47
|
+
check_connector(connector)
|
|
48
|
+
items = {i.id: i for i in connector.fetch([f"{site}/llms-full.txt#install"])}
|
|
49
|
+
assert items[f"{site}/llms-full.txt#install"].title == "Install"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_the_output_validates_against_the_published_json_schema(site):
|
|
53
|
+
schema = json.loads(SCHEMA.read_text())
|
|
54
|
+
|
|
55
|
+
def valid(name, record):
|
|
56
|
+
return jsonschema.Draft202012Validator({"$ref": f"#/$defs/{name}", "$defs": schema["$defs"]}).is_valid(record)
|
|
57
|
+
|
|
58
|
+
def lines(mode, stdin=""):
|
|
59
|
+
out = subprocess.run(command() + [mode], env={**os.environ, "URLS": site}, input=stdin,
|
|
60
|
+
capture_output=True, text=True, check=True).stdout
|
|
61
|
+
return [json.loads(line) for line in out.splitlines()]
|
|
62
|
+
|
|
63
|
+
assert lines("check") and all(valid("check", r) for r in lines("check"))
|
|
64
|
+
assert lines("list") and all(valid("listRecord", r) for r in lines("list"))
|
|
65
|
+
items = lines("fetch", f'{{"id": "{site}/llms-full.txt#install"}}\n')
|
|
66
|
+
assert len(items) == 1 and valid("item", items[0])
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_every_setting_is_in_the_readme():
|
|
70
|
+
code = (Path(__file__).resolve().parents[1] / "passageway_connector_llmstxt" / "connector.py").read_text()
|
|
71
|
+
names = sorted(set(re.findall(r'(?:env\.get|names|whole|flag|seconds)\(\s*(?:env,\s*)?"([A-Z_]+)"', code)) | {"URLS"})
|
|
72
|
+
assert len(names) >= 3, names # the pattern must actually find the settings
|
|
73
|
+
readme = README.read_text()
|
|
74
|
+
for name in names:
|
|
75
|
+
assert f"`{name}`" in readme, f"{name} is read by the llmstxt connector but missing from its README"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_the_example_in_the_readme_is_what_the_connector_really_produces(site):
|
|
79
|
+
readme = README.read_text()
|
|
80
|
+
text = re.search(r"```markdown\n(.*?)```", readme, re.S).group(1)
|
|
81
|
+
shown = json.loads(re.search(r"```json\n(.*?)```", readme, re.S).group(1))
|
|
82
|
+
(site.root / "example.md").write_text(text)
|
|
83
|
+
out = subprocess.run(command() + ["fetch"], env={**os.environ, "URLS": f"{site}/example.md"},
|
|
84
|
+
input=f'{{"id": "{site}/example.md#install"}}\n', capture_output=True, text=True, check=True).stdout
|
|
85
|
+
real = json.loads(out)
|
|
86
|
+
for key, value in shown.items():
|
|
87
|
+
assert real[key] == value, f"the README shows {key} = {value!r} but the connector prints {real[key]!r}"
|