llms-ink 0.3.0 → 0.4.0
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.
- package/README.md +42 -1
- package/dist/astro.d.ts +7 -3
- package/dist/astro.js +5 -2
- package/dist/files.d.ts +21 -3
- package/dist/files.js +101 -24
- package/dist/index.d.ts +14 -3
- package/dist/index.js +12 -3
- package/dist/middleware.d.ts +27 -8
- package/dist/middleware.js +13 -7
- package/dist/traffic.d.ts +68 -0
- package/dist/traffic.js +221 -0
- package/dist/vite.d.ts +6 -4
- package/dist/vite.js +4 -2
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -74,6 +74,16 @@ const { parsed, issues } = await ink.checkText(await readFile('public/llms.txt',
|
|
|
74
74
|
Without `base`, `site` and `url` are `null`; without `base` and `links`, `llmsFull` is `null` and
|
|
75
75
|
`links.checked` is 0. Up to 2 MB.
|
|
76
76
|
|
|
77
|
+
### `ink.markdown(html, { url?, signal? })` → `Promise<string>`
|
|
78
|
+
|
|
79
|
+
A page's HTML as Markdown, the way llms.ink reads pages: hidden-instruction text (comments, hidden elements,
|
|
80
|
+
off-screen text addressed to AI), scripts, navigation, headers, footers and forms are dropped first. `url` (never
|
|
81
|
+
fetched) resolves relative links. Up to 5 MB; nothing is stored.
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
const md = await ink.markdown(await readFile('dist/pricing/index.html', 'utf8'), { url: 'https://example.com/pricing/' });
|
|
85
|
+
```
|
|
86
|
+
|
|
77
87
|
### `ink.stream(url, { mode?, fresh?, signal? })` → `AsyncGenerator<StreamEvent>`
|
|
78
88
|
|
|
79
89
|
The same build as `llms()`, as it happens:
|
|
@@ -104,7 +114,15 @@ After `vite build` (not the dev server or SSR builds), writes `llms.txt` (and `l
|
|
|
104
114
|
`site` into `build.outDir`. llms.ink reads the live site, so the files describe the previous deploy. If llms.ink
|
|
105
115
|
fails, the plugin warns and keeps any existing file (e.g. one from `public/`); `strict: true` fails the build.
|
|
106
116
|
Options: `site`, `full`, `key` (default `LLMS_INK_KEY`), `native` (default `true`; a file llms.ink generated before
|
|
107
|
-
is regenerated, not echoed), `strict`, `timeout` (ms, default 300000), `baseUrl`.
|
|
117
|
+
is regenerated, not echoed), `strict`, `timeout` (ms, default 300000), `baseUrl`, `markdownPages`.
|
|
118
|
+
|
|
119
|
+
`markdownPages: true` also writes a Markdown version next to every HTML page of this build (`about.html` →
|
|
120
|
+
`about.md`, `docs/index.html` → `docs/index.md`), converted with `ink.markdown()` from the local output, so the
|
|
121
|
+
`.md` pages match what you're deploying. Existing `.md` files are kept; `/404`, `/500` and pages over 5 MB are
|
|
122
|
+
skipped; a rate limit is waited out (`Retry-After`). Failures warn and the build goes on, unless `strict`. Pass
|
|
123
|
+
`{ concurrency, exclude }` (default 4 at a time; `exclude` is a RegExp on the page path) to tune it. Large sites:
|
|
124
|
+
use an API key (60 conversions a minute anonymous per IP, the key's own limit otherwise). They're static files:
|
|
125
|
+
`/about.md` works as is; for `Accept: text/markdown` on the page URL, add a rewrite to `<page>.md` on your host.
|
|
108
126
|
|
|
109
127
|
### Astro: `llms-ink/astro`
|
|
110
128
|
|
|
@@ -137,6 +155,29 @@ Everything else, and llms.ink's own fetcher, gets `undefined` (Express: `next()`
|
|
|
137
155
|
`LLMS_INK_KEY`; recommended on shared hosting IPs), `markdown`, `baseUrl`. Don't also ship a static `llms.txt`:
|
|
138
156
|
most hosts serve it before the middleware runs.
|
|
139
157
|
|
|
158
|
+
#### AI traffic
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
const llms = llmsInkHandler({ site: 'https://example.com', traffic: { key: env.LLMS_INK_KEY } });
|
|
162
|
+
const response = (await llms(request, ctx)) ?? (await app(request)); // Workers: ctx; Next.js proxy: the event
|
|
163
|
+
|
|
164
|
+
expressApp.use(llmsInkExpress({ site: 'https://example.com', traffic: { key: process.env.LLMS_INK_KEY } }));
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
With `traffic: { key, site?, flushEvery? }` the middleware also counts requests from AI agents (`AI_AGENTS`):
|
|
168
|
+
user fetches (`ChatGPT-User`, `Claude-User`, `Perplexity-User`…, someone asked an assistant) and crawlers (`GPTBot`,
|
|
169
|
+
`OAI-SearchBot`, `ClaudeBot`, `PerplexityBot`, `Googlebot`, `bingbot`…). It counts every request it sees, served or
|
|
170
|
+
not, per path and agent in memory, and every `flushEvery` seconds (default 60, at least 10) sends the day's totals to
|
|
171
|
+
`POST /api/v0/traffic`, at most 1000 rows per request. Only `{ site, period, rows: [{ path, agent, kind, count }] }`
|
|
172
|
+
leaves your server: no IPs, user agents, query strings or cookies; browsers aren't counted. Sends never block a
|
|
173
|
+
response: with `ctx` they go to `ctx.waitUntil`, without one (Node, Bun, Deno) an unref'd timer sends. Failed sends
|
|
174
|
+
keep their counts and retry with backoff (honoring `Retry-After`); a `401` or `403 site_claimed` stops counting with
|
|
175
|
+
one warning. `site` defaults to the middleware's hostname; the first key to send for a site owns it. Counts not yet
|
|
176
|
+
sent are lost when the process stops: call `await llms.flush()` on shutdown. Read them at
|
|
177
|
+
[llms.ink/traffic](https://llms.ink/traffic/) or `GET /api/v0/traffic?site=&days=` with the same key.
|
|
178
|
+
`classifyAgent(userAgent)` and `trafficCounter({ key, site })` (`record(request, ctx?)`, `flush()`) are exported for
|
|
179
|
+
other servers.
|
|
180
|
+
|
|
140
181
|
## Errors
|
|
141
182
|
|
|
142
183
|
Every failure is an `LlmsInkError` with `status`, `code`, `title`, `detail`, `errors` (per-parameter issues of
|
package/dist/astro.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Astro integration: writes llms.txt (and llms-full.txt) from llms.ink into the build output
|
|
2
|
+
* Astro integration: writes llms.txt (and llms-full.txt) from llms.ink into the build output, and with
|
|
3
|
+
* `markdownPages: true` a Markdown version next to every built HTML page.
|
|
3
4
|
*
|
|
4
5
|
* @example
|
|
5
6
|
* ```ts
|
|
@@ -13,7 +14,8 @@
|
|
|
13
14
|
* @module
|
|
14
15
|
*/
|
|
15
16
|
import type { AstroIntegration } from 'astro';
|
|
16
|
-
import { type LlmsFilesOptions } from './files.ts';
|
|
17
|
+
import { type LlmsFilesOptions, type MarkdownPagesOptions } from './files.ts';
|
|
18
|
+
export type { MarkdownPagesOptions };
|
|
17
19
|
/** {@link LlmsFilesOptions} with `site` optional: it defaults to Astro's `site` (plus `base`). */
|
|
18
20
|
export type LlmsInkAstroOptions = Omit<LlmsFilesOptions, 'site'> & {
|
|
19
21
|
site?: string;
|
|
@@ -22,7 +24,9 @@ export type LlmsInkAstroOptions = Omit<LlmsFilesOptions, 'site'> & {
|
|
|
22
24
|
* After `astro build`, fetches llms.txt — and llms-full.txt with `full: true` — for `site` (default: the
|
|
23
25
|
* `site` and `base` of the Astro config) and writes them into the static output directory. llms.ink reads the
|
|
24
26
|
* live site, so the files describe the previous deploy. When llms.ink can't be reached it warns and keeps any
|
|
25
|
-
* existing file (e.g. one copied from `public/`); `strict: true` fails the build instead.
|
|
27
|
+
* existing file (e.g. one copied from `public/`); `strict: true` fails the build instead. `markdownPages: true`
|
|
28
|
+
* converts each prerendered `.html` page of this build through `POST /api/v0/markdown` and writes `about.md` /
|
|
29
|
+
* `docs/index.md` next to it; page URLs come from `site`.
|
|
26
30
|
*/
|
|
27
31
|
export declare function llmsInk(options?: LlmsInkAstroOptions): AstroIntegration;
|
|
28
32
|
export default llmsInk;
|
package/dist/astro.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Astro integration: writes llms.txt (and llms-full.txt) from llms.ink into the build output
|
|
2
|
+
* Astro integration: writes llms.txt (and llms-full.txt) from llms.ink into the build output, and with
|
|
3
|
+
* `markdownPages: true` a Markdown version next to every built HTML page.
|
|
3
4
|
*
|
|
4
5
|
* @example
|
|
5
6
|
* ```ts
|
|
@@ -18,7 +19,9 @@ import { llmsFilesWriter } from "./files.js";
|
|
|
18
19
|
* After `astro build`, fetches llms.txt — and llms-full.txt with `full: true` — for `site` (default: the
|
|
19
20
|
* `site` and `base` of the Astro config) and writes them into the static output directory. llms.ink reads the
|
|
20
21
|
* live site, so the files describe the previous deploy. When llms.ink can't be reached it warns and keeps any
|
|
21
|
-
* existing file (e.g. one copied from `public/`); `strict: true` fails the build instead.
|
|
22
|
+
* existing file (e.g. one copied from `public/`); `strict: true` fails the build instead. `markdownPages: true`
|
|
23
|
+
* converts each prerendered `.html` page of this build through `POST /api/v0/markdown` and writes `about.md` /
|
|
24
|
+
* `docs/index.md` next to it; page URLs come from `site`.
|
|
22
25
|
*/
|
|
23
26
|
export function llmsInk(options = {}) {
|
|
24
27
|
const settings = { ...options, site: options.site ?? '' };
|
package/dist/files.d.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared by the Vite plugin and the Astro integration: fetch llms.txt (and llms-full.txt) for a site and write
|
|
3
|
-
* them into a build's output directory.
|
|
3
|
+
* them into a build's output directory; with `markdownPages`, write a `.md` version next to every built HTML page.
|
|
4
4
|
*
|
|
5
5
|
* @module
|
|
6
6
|
*/
|
|
7
|
+
/** `markdownPages` settings beyond `true`. */
|
|
8
|
+
export interface MarkdownPagesOptions {
|
|
9
|
+
/** Pages converted at once. Default 4. */
|
|
10
|
+
concurrency?: number;
|
|
11
|
+
/** Pages to skip, matched against the page path (`/docs/`, `/about`). Default: `/404` and `/500` pages. */
|
|
12
|
+
exclude?: RegExp;
|
|
13
|
+
}
|
|
7
14
|
/** Options for the Vite plugin and the Astro integration. */
|
|
8
15
|
export interface LlmsFilesOptions {
|
|
9
16
|
/** The deployed site to describe, e.g. `https://example.com`. llms.ink reads the live site. */
|
|
@@ -18,7 +25,14 @@ export interface LlmsFilesOptions {
|
|
|
18
25
|
* generates (`native=0`).
|
|
19
26
|
*/
|
|
20
27
|
native?: boolean;
|
|
21
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Write a Markdown version of every HTML page of this build next to it (`about.html` → `about.md`,
|
|
30
|
+
* `docs/index.html` → `docs/index.md`), converted by llms.ink from the local output (`POST /api/v0/markdown`):
|
|
31
|
+
* chrome and hidden-instruction text dropped, relative links resolved against `site`. Existing `.md` files are
|
|
32
|
+
* never overwritten; pages over 5 MB are skipped. Default `false`.
|
|
33
|
+
*/
|
|
34
|
+
markdownPages?: boolean | MarkdownPagesOptions;
|
|
35
|
+
/** Fail the build when a file can't be fetched or a page can't be converted. Default `false`: warn and go on. */
|
|
22
36
|
strict?: boolean;
|
|
23
37
|
/** Milliseconds to wait for llms.ink per file. Default 300000 (building a big site can take minutes). */
|
|
24
38
|
timeout?: number;
|
|
@@ -30,8 +44,12 @@ export interface Log {
|
|
|
30
44
|
info(message: string): void;
|
|
31
45
|
warn(message: string): void;
|
|
32
46
|
}
|
|
47
|
+
/** The endpoint's body limit. */
|
|
48
|
+
export declare const MAX_PAGE_BYTES = 5000000;
|
|
33
49
|
/**
|
|
34
50
|
* A writer that fetches the files once and writes them into `dir` on every call (watch builds call it again;
|
|
35
|
-
* a failed fetch is retried on the next call).
|
|
51
|
+
* a failed fetch is retried on the next call). Markdown pages are converted on every call: they describe this build.
|
|
36
52
|
*/
|
|
37
53
|
export declare function llmsFilesWriter(options: LlmsFilesOptions): (dir: string, log: Log) => Promise<void>;
|
|
54
|
+
/** `docs/index.html` → `/docs/`, `about.html` → `/about`, `index.html` → `/`. */
|
|
55
|
+
export declare function pagePath(file: string): string;
|
package/dist/files.js
CHANGED
|
@@ -1,44 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared by the Vite plugin and the Astro integration: fetch llms.txt (and llms-full.txt) for a site and write
|
|
3
|
-
* them into a build's output directory.
|
|
3
|
+
* them into a build's output directory; with `markdownPages`, write a `.md` version next to every built HTML page.
|
|
4
4
|
*
|
|
5
5
|
* @module
|
|
6
6
|
*/
|
|
7
|
-
import { access, mkdir, writeFile } from 'node:fs/promises';
|
|
7
|
+
import { access, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
|
-
import {
|
|
9
|
+
import { setTimeout as wait } from 'node:timers/promises';
|
|
10
|
+
import { LlmsInk, LlmsInkError } from "./index.js";
|
|
10
11
|
const FILES = [['llms.txt', 'llms'], ['llms-full.txt', 'full']];
|
|
12
|
+
/** The endpoint's body limit. */
|
|
13
|
+
export const MAX_PAGE_BYTES = 5_000_000;
|
|
14
|
+
const PAGE_TIMEOUT = 60_000;
|
|
15
|
+
/** Tries per page when llms.ink says to slow down (429 with Retry-After). */
|
|
16
|
+
const RATE_TRIES = 4;
|
|
17
|
+
const DEFAULT_EXCLUDE = /^\/(404|500)(\.html|\/)?$/;
|
|
18
|
+
const reason = (error) => (error instanceof Error ? error.message : String(error));
|
|
11
19
|
/**
|
|
12
20
|
* A writer that fetches the files once and writes them into `dir` on every call (watch builds call it again;
|
|
13
|
-
* a failed fetch is retried on the next call).
|
|
21
|
+
* a failed fetch is retried on the next call). Markdown pages are converted on every call: they describe this build.
|
|
14
22
|
*/
|
|
15
23
|
export function llmsFilesWriter(options) {
|
|
16
24
|
let fetched;
|
|
17
25
|
return async (dir, log) => {
|
|
18
26
|
const { site, strict = false } = options;
|
|
19
|
-
if (!site) {
|
|
20
|
-
const message = 'No `site` set: llms.txt not written.';
|
|
21
|
-
if (strict)
|
|
22
|
-
throw new Error(message);
|
|
23
|
-
return log.warn(message);
|
|
24
|
-
}
|
|
25
|
-
fetched ??= fetchFiles(options);
|
|
26
|
-
const results = await fetched;
|
|
27
27
|
const failures = [];
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
28
|
+
if (!site)
|
|
29
|
+
failures.push('No `site` set: llms.txt not written.');
|
|
30
|
+
else {
|
|
31
|
+
fetched ??= fetchFiles(options);
|
|
32
|
+
for (const result of await fetched) {
|
|
33
|
+
const path = join(dir, result.name);
|
|
34
|
+
if ('text' in result) {
|
|
35
|
+
await mkdir(dir, { recursive: true });
|
|
36
|
+
await writeFile(path, result.text);
|
|
37
|
+
log.info(`Wrote ${result.name} for ${site} (${result.source}, ~${result.tokens} tokens).`);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const kept = await access(path).then(() => 'kept the existing file', () => 'none written');
|
|
41
|
+
failures.push(`Couldn't get ${result.name} for ${site}: ${reason(result.error)} (${kept}).`);
|
|
35
42
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
failures.push(`Couldn't get ${result.name} for ${site}: ${reason} (${kept}).`);
|
|
43
|
+
if (failures.length)
|
|
44
|
+
fetched = undefined;
|
|
39
45
|
}
|
|
40
|
-
if (
|
|
41
|
-
|
|
46
|
+
if (options.markdownPages)
|
|
47
|
+
failures.push(...(await writeMarkdownPages(dir, options, log)));
|
|
42
48
|
if (strict && failures.length)
|
|
43
49
|
throw new Error(failures.join(' '));
|
|
44
50
|
for (const message of failures)
|
|
@@ -50,3 +56,74 @@ async function fetchFiles({ site, full, key, native = true, timeout = 300_000, b
|
|
|
50
56
|
const files = full ? FILES : FILES.slice(0, 1);
|
|
51
57
|
return Promise.all(files.map(([name, mode]) => ink.llms(site, { mode, native, signal: AbortSignal.timeout(timeout) }).then(({ text, tokens, source }) => ({ name, text, tokens, source }), (error) => ({ name, error }))));
|
|
52
58
|
}
|
|
59
|
+
/** Every `.html` file under `dir`, as paths relative to it with `/` separators, sorted. Symlinks aren't followed. */
|
|
60
|
+
async function htmlFiles(dir, prefix = '') {
|
|
61
|
+
const entries = await readdir(join(dir, prefix), { withFileTypes: true }).catch(() => []);
|
|
62
|
+
const nested = await Promise.all(entries.map((e) => {
|
|
63
|
+
const path = prefix ? `${prefix}/${e.name}` : e.name;
|
|
64
|
+
if (e.isDirectory())
|
|
65
|
+
return htmlFiles(dir, path);
|
|
66
|
+
return e.isFile() && e.name.endsWith('.html') ? [path] : [];
|
|
67
|
+
}));
|
|
68
|
+
return nested.flat().sort();
|
|
69
|
+
}
|
|
70
|
+
/** `docs/index.html` → `/docs/`, `about.html` → `/about`, `index.html` → `/`. */
|
|
71
|
+
export function pagePath(file) {
|
|
72
|
+
const path = `/${file.replace(/\.html$/, '')}`;
|
|
73
|
+
return path.endsWith('/index') ? path.slice(0, -'index'.length) : path;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Converts each HTML page of `dir` through llms.ink and writes `<page>.md` next to it. Returns the failure messages
|
|
77
|
+
* (the caller warns or, with `strict`, fails the build).
|
|
78
|
+
*/
|
|
79
|
+
async function writeMarkdownPages(dir, options, log) {
|
|
80
|
+
const settings = typeof options.markdownPages === 'object' ? options.markdownPages : {};
|
|
81
|
+
const exclude = settings.exclude ?? DEFAULT_EXCLUDE;
|
|
82
|
+
const concurrency = Math.max(1, Math.floor(settings.concurrency ?? 4));
|
|
83
|
+
const ink = new LlmsInk({ key: options.key, baseUrl: options.baseUrl });
|
|
84
|
+
const site = options.site ? new URL(options.site.endsWith('/') ? options.site : `${options.site}/`) : null;
|
|
85
|
+
const pages = (await htmlFiles(dir)).filter((file) => !exclude.test(pagePath(file)));
|
|
86
|
+
const failures = [];
|
|
87
|
+
let written = 0;
|
|
88
|
+
let skipped = 0;
|
|
89
|
+
const convert = async (file) => {
|
|
90
|
+
const target = join(dir, file.replace(/\.html$/, '.md'));
|
|
91
|
+
const md = file.replace(/\.html$/, '.md');
|
|
92
|
+
if (await access(target).then(() => true, () => false)) {
|
|
93
|
+
skipped++;
|
|
94
|
+
return log.info(`Kept ${md}: it already exists.`);
|
|
95
|
+
}
|
|
96
|
+
const source = join(dir, file);
|
|
97
|
+
if ((await stat(source)).size > MAX_PAGE_BYTES) {
|
|
98
|
+
skipped++;
|
|
99
|
+
return log.warn(`Skipped ${file}: over ${MAX_PAGE_BYTES.toLocaleString('en-US')} bytes.`);
|
|
100
|
+
}
|
|
101
|
+
const html = await readFile(source, 'utf8');
|
|
102
|
+
// Page URL under `site` (its path included: Astro's `base`).
|
|
103
|
+
const url = site ? new URL(pagePath(file).slice(1), site).href : undefined;
|
|
104
|
+
for (let attempt = 1;; attempt++) {
|
|
105
|
+
try {
|
|
106
|
+
const text = await ink.markdown(html, { url, signal: AbortSignal.timeout(PAGE_TIMEOUT) });
|
|
107
|
+
await writeFile(target, text);
|
|
108
|
+
written++;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
const limited = error instanceof LlmsInkError && error.status === 429 && error.code === 'rate_limited';
|
|
113
|
+
if (limited && attempt < RATE_TRIES) {
|
|
114
|
+
await wait(Math.min(error.retryAfter ?? 10, 60) * 1000);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
failures.push(`Couldn't convert ${file} to Markdown: ${reason(error).replace(/\.$/, '')}.`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
let next = 0;
|
|
123
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, pages.length) }, async () => {
|
|
124
|
+
while (next < pages.length)
|
|
125
|
+
await convert(pages[next++]);
|
|
126
|
+
}));
|
|
127
|
+
log.info(`Wrote ${written} Markdown page${written === 1 ? '' : 's'}${skipped ? ` (${skipped} skipped)` : ''}${failures.length ? `; ${failures.length} failed` : ''}.`);
|
|
128
|
+
return failures;
|
|
129
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -13,12 +13,12 @@
|
|
|
13
13
|
*
|
|
14
14
|
* @module
|
|
15
15
|
*/
|
|
16
|
-
export declare const VERSION = "0.
|
|
16
|
+
export declare const VERSION = "0.4.0";
|
|
17
17
|
export declare const DEFAULT_BASE_URL = "https://llms.ink";
|
|
18
18
|
/** `llms` for the index (llms.txt), `full` for llms-full.txt. */
|
|
19
19
|
export type Mode = 'llms' | 'full';
|
|
20
20
|
/** What the link is. */
|
|
21
|
-
export type Kind = 'site' | 'section' | 'page' | 'repo' | 'document' | 'video';
|
|
21
|
+
export type Kind = 'site' | 'section' | 'page' | 'repo' | 'document' | 'video' | 'channel';
|
|
22
22
|
/** `native` = published by the site; `generated` = made by llms.ink. */
|
|
23
23
|
export type Source = 'native' | 'generated';
|
|
24
24
|
/** A transcript line; `start` and `end` are seconds into the source video. */
|
|
@@ -137,7 +137,7 @@ export interface CheckResult {
|
|
|
137
137
|
checkedAt: string;
|
|
138
138
|
}
|
|
139
139
|
/** Every error code the API answers with (https://llms.ink/docs/#errors). */
|
|
140
|
-
export type ErrorCode = 'invalid_request' | 'invalid_url' | 'invalid_key' | 'not_found' | 'unknown_version' | 'method_not_allowed' | 'payload_too_large' | 'unprocessable' | 'rate_limited' | 'quota_exceeded' | 'internal' | 'upstream_error' | 'unavailable' | 'timeout' | 'loop_detected';
|
|
140
|
+
export type ErrorCode = 'invalid_request' | 'invalid_url' | 'invalid_key' | 'key_required' | 'site_claimed' | 'not_found' | 'unknown_version' | 'method_not_allowed' | 'payload_too_large' | 'too_large' | 'unsupported_media_type' | 'unprocessable' | 'rate_limited' | 'quota_exceeded' | 'internal' | 'upstream_error' | 'unavailable' | 'timeout' | 'loop_detected';
|
|
141
141
|
/** `invalid_request` only: one invalid parameter. */
|
|
142
142
|
export interface Issue {
|
|
143
143
|
path: string;
|
|
@@ -244,6 +244,12 @@ export interface CheckTextOptions {
|
|
|
244
244
|
links?: boolean;
|
|
245
245
|
signal?: AbortSignal;
|
|
246
246
|
}
|
|
247
|
+
/** Options for {@link LlmsInk.markdown}. */
|
|
248
|
+
export interface MarkdownOptions {
|
|
249
|
+
/** Where the page lives (never fetched): relative links in the Markdown resolve against it. */
|
|
250
|
+
url?: string;
|
|
251
|
+
signal?: AbortSignal;
|
|
252
|
+
}
|
|
247
253
|
/**
|
|
248
254
|
* A client for https://llms.ink.
|
|
249
255
|
*
|
|
@@ -266,6 +272,11 @@ export declare class LlmsInk {
|
|
|
266
272
|
check(url: string, options?: CheckOptions): Promise<CheckResult>;
|
|
267
273
|
/** Parses and checks llms.txt text you already have (`POST /api/v0/check`); nothing is published. */
|
|
268
274
|
checkText(text: string, options?: CheckTextOptions): Promise<CheckResult>;
|
|
275
|
+
/**
|
|
276
|
+
* A page's HTML as Markdown (`POST /api/v0/markdown`, up to 5 MB): hidden-instruction text, scripts, navigation,
|
|
277
|
+
* headers and footers dropped, the way llms.ink reads pages. Nothing is fetched or stored.
|
|
278
|
+
*/
|
|
279
|
+
markdown(html: string, options?: MarkdownOptions): Promise<string>;
|
|
269
280
|
/**
|
|
270
281
|
* Like {@link LlmsInk.llms}, as the build happens: yields `progress` and `partial` events, then one
|
|
271
282
|
* `result`. Errors — including a terminal `error` event — are thrown as {@link LlmsInkError}s.
|
package/dist/index.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* @module
|
|
15
15
|
*/
|
|
16
16
|
import { env } from "./env.js";
|
|
17
|
-
export const VERSION = '0.
|
|
17
|
+
export const VERSION = '0.4.0';
|
|
18
18
|
export const DEFAULT_BASE_URL = 'https://llms.ink';
|
|
19
19
|
/**
|
|
20
20
|
* An API error, or llms.ink couldn't be reached (then `status` and `code` are undefined).
|
|
@@ -117,6 +117,15 @@ export class LlmsInk {
|
|
|
117
117
|
const res = await this.#request('/api/v0/check', params, 'application/json', options.signal, text);
|
|
118
118
|
return (await this.#json(res));
|
|
119
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* A page's HTML as Markdown (`POST /api/v0/markdown`, up to 5 MB): hidden-instruction text, scripts, navigation,
|
|
122
|
+
* headers and footers dropped, the way llms.ink reads pages. Nothing is fetched or stored.
|
|
123
|
+
*/
|
|
124
|
+
async markdown(html, options = {}) {
|
|
125
|
+
const params = options.url ? { url: options.url } : {};
|
|
126
|
+
const res = await this.#request('/api/v0/markdown', params, 'text/markdown', options.signal, html, 'text/html; charset=utf-8');
|
|
127
|
+
return res.text();
|
|
128
|
+
}
|
|
120
129
|
/**
|
|
121
130
|
* Like {@link LlmsInk.llms}, as the build happens: yields `progress` and `partial` events, then one
|
|
122
131
|
* `result`. Errors — including a terminal `error` event — are thrown as {@link LlmsInkError}s.
|
|
@@ -183,10 +192,10 @@ export class LlmsInk {
|
|
|
183
192
|
const known = event.type === 'progress' || event.type === 'partial' || event.type === 'result';
|
|
184
193
|
return known ? event : undefined;
|
|
185
194
|
}
|
|
186
|
-
async #request(path, params, accept, signal, body) {
|
|
195
|
+
async #request(path, params, accept, signal, body, type = 'text/markdown; charset=utf-8') {
|
|
187
196
|
const headers = { accept };
|
|
188
197
|
if (body !== undefined)
|
|
189
|
-
headers['content-type'] =
|
|
198
|
+
headers['content-type'] = type;
|
|
190
199
|
if (!IS_BROWSER)
|
|
191
200
|
headers['user-agent'] = `llms-ink-js/${VERSION}`;
|
|
192
201
|
if (this.key)
|
package/dist/middleware.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Serve llms.txt, llms-full.txt and (optionally) Markdown versions of pages from llms.ink, inside your own app
|
|
3
|
-
* A fetch-API handler for Cloudflare Workers, Next.js middleware, Deno, Bun and
|
|
4
|
-
* adapter.
|
|
2
|
+
* Serve llms.txt, llms-full.txt and (optionally) Markdown versions of pages from llms.ink, inside your own app, and
|
|
3
|
+
* (optionally) count AI-agent traffic. A fetch-API handler for Cloudflare Workers, Next.js middleware, Deno, Bun and
|
|
4
|
+
* Hono, plus an Express/Connect adapter.
|
|
5
5
|
*
|
|
6
6
|
* @example
|
|
7
7
|
* ```ts
|
|
@@ -9,14 +9,16 @@
|
|
|
9
9
|
*
|
|
10
10
|
* const llms = llmsInkHandler({ site: 'https://example.com', markdown: true });
|
|
11
11
|
* export default {
|
|
12
|
-
* async fetch(request: Request): Promise<Response> {
|
|
13
|
-
* return (await llms(request)) ?? fetch(request); // undefined: not ours, the app answers
|
|
12
|
+
* async fetch(request: Request, env: unknown, ctx: ExecutionContext): Promise<Response> {
|
|
13
|
+
* return (await llms(request, ctx)) ?? fetch(request); // undefined: not ours, the app answers
|
|
14
14
|
* },
|
|
15
15
|
* };
|
|
16
16
|
* ```
|
|
17
17
|
*
|
|
18
18
|
* @module
|
|
19
19
|
*/
|
|
20
|
+
import { type TrafficOptions, type WaitUntil } from './traffic.ts';
|
|
21
|
+
export { AI_AGENTS, classifyAgent, trafficCounter, type AgentKind, type AiAgent, type TrafficBatch, type TrafficCounter, type TrafficOptions, type TrafficRow, type WaitUntil } from './traffic.ts';
|
|
20
22
|
/** Options for {@link llmsInkHandler} and {@link llmsInkExpress}. */
|
|
21
23
|
export interface LlmsInkHandlerOptions {
|
|
22
24
|
/** The deployed site to describe, e.g. `https://example.com`. */
|
|
@@ -33,9 +35,22 @@ export interface LlmsInkHandlerOptions {
|
|
|
33
35
|
markdown?: boolean;
|
|
34
36
|
/** Defaults to the `LLMS_INK_API` environment variable, else https://llms.ink. */
|
|
35
37
|
baseUrl?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Count requests from AI agents (ChatGPT-User, Claude-User, GPTBot, ClaudeBot… see `AI_AGENTS`) per path and send
|
|
40
|
+
* daily aggregates to llms.ink every `flushEvery` seconds; read them at https://llms.ink/traffic/. Sends only
|
|
41
|
+
* `{ path, agent, kind, count }`: no IPs, user agents, query strings or cookies. Off unless set.
|
|
42
|
+
*/
|
|
43
|
+
traffic?: TrafficOptions;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A fetch-API handler: a `Response` for the requests it serves, `undefined` for everything else. Pass the
|
|
47
|
+
* platform's `ctx` (Workers' `ExecutionContext`, Next.js's `NextFetchEvent`) so traffic sends outlive the response.
|
|
48
|
+
*/
|
|
49
|
+
export interface LlmsInkHandler {
|
|
50
|
+
(request: Request, ctx?: WaitUntil): Promise<Response | undefined>;
|
|
51
|
+
/** Sends the traffic counted so far (with `traffic`); resolves at once without it. */
|
|
52
|
+
flush(): Promise<void>;
|
|
36
53
|
}
|
|
37
|
-
/** A fetch-API handler: a `Response` for the requests it serves, `undefined` for everything else. */
|
|
38
|
-
export type LlmsInkHandler = (request: Request) => Promise<Response | undefined>;
|
|
39
54
|
/**
|
|
40
55
|
* Answers `GET`/`HEAD` `/llms.txt` and `/llms-full.txt` with llms.ink's generated files for `site`
|
|
41
56
|
* (`native=0`, so a site whose /llms.txt is this handler can't loop), and, with `markdown: true`, Markdown
|
|
@@ -59,6 +74,10 @@ export interface NodeResponse {
|
|
|
59
74
|
end(chunk: Uint8Array): unknown;
|
|
60
75
|
}
|
|
61
76
|
/** Express/Connect middleware: `next()` when the request isn't one {@link llmsInkHandler} serves. */
|
|
62
|
-
export
|
|
77
|
+
export interface NodeMiddleware {
|
|
78
|
+
(req: NodeRequest, res: NodeResponse, next: (err?: unknown) => void): void;
|
|
79
|
+
/** Sends the traffic counted so far (with `traffic`). */
|
|
80
|
+
flush(): Promise<void>;
|
|
81
|
+
}
|
|
63
82
|
/** {@link llmsInkHandler} as Express/Connect middleware: `app.use(llmsInkExpress({ site }))`. */
|
|
64
83
|
export declare function llmsInkExpress(options: LlmsInkHandlerOptions): NodeMiddleware;
|
package/dist/middleware.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Serve llms.txt, llms-full.txt and (optionally) Markdown versions of pages from llms.ink, inside your own app
|
|
3
|
-
* A fetch-API handler for Cloudflare Workers, Next.js middleware, Deno, Bun and
|
|
4
|
-
* adapter.
|
|
2
|
+
* Serve llms.txt, llms-full.txt and (optionally) Markdown versions of pages from llms.ink, inside your own app, and
|
|
3
|
+
* (optionally) count AI-agent traffic. A fetch-API handler for Cloudflare Workers, Next.js middleware, Deno, Bun and
|
|
4
|
+
* Hono, plus an Express/Connect adapter.
|
|
5
5
|
*
|
|
6
6
|
* @example
|
|
7
7
|
* ```ts
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
*
|
|
10
10
|
* const llms = llmsInkHandler({ site: 'https://example.com', markdown: true });
|
|
11
11
|
* export default {
|
|
12
|
-
* async fetch(request: Request): Promise<Response> {
|
|
13
|
-
* return (await llms(request)) ?? fetch(request); // undefined: not ours, the app answers
|
|
12
|
+
* async fetch(request: Request, env: unknown, ctx: ExecutionContext): Promise<Response> {
|
|
13
|
+
* return (await llms(request, ctx)) ?? fetch(request); // undefined: not ours, the app answers
|
|
14
14
|
* },
|
|
15
15
|
* };
|
|
16
16
|
* ```
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { env } from "./env.js";
|
|
21
21
|
import { DEFAULT_BASE_URL, VERSION } from "./index.js";
|
|
22
|
+
import { trafficCounter } from "./traffic.js";
|
|
23
|
+
export { AI_AGENTS, classifyAgent, trafficCounter } from "./traffic.js";
|
|
22
24
|
const FILES = { '/llms.txt': 'llms', '/llms-full.txt': 'full' };
|
|
23
25
|
/** llms.ink's own fetcher (`compatible; llms.ink/0.1`): answering it from llms.ink would loop. */
|
|
24
26
|
const LLMS_INK_FETCHER = /\bllms\.ink\/\d/;
|
|
@@ -44,6 +46,7 @@ export function llmsInkHandler(options) {
|
|
|
44
46
|
headers.authorization = `Bearer ${key}`;
|
|
45
47
|
const cache = new Map();
|
|
46
48
|
const pending = new Map();
|
|
49
|
+
const traffic = options.traffic ? trafficCounter({ ...options.traffic, site: options.traffic.site ?? site.hostname, baseUrl: base }) : null;
|
|
47
50
|
/** Fetches `upstream`; on failure the last good copy, else llms.ink's error. */
|
|
48
51
|
async function refresh(upstream) {
|
|
49
52
|
const last = cache.get(upstream);
|
|
@@ -85,7 +88,8 @@ export function llmsInkHandler(options) {
|
|
|
85
88
|
// Concurrent requests share one fetch; each gets its own copy of the body.
|
|
86
89
|
return (await loading).clone();
|
|
87
90
|
}
|
|
88
|
-
|
|
91
|
+
const handler = async (request, ctx) => {
|
|
92
|
+
traffic?.record(request, ctx);
|
|
89
93
|
if (request.method !== 'GET' && request.method !== 'HEAD')
|
|
90
94
|
return undefined;
|
|
91
95
|
if (LLMS_INK_FETCHER.test(request.headers.get('user-agent') ?? ''))
|
|
@@ -110,11 +114,12 @@ export function llmsInkHandler(options) {
|
|
|
110
114
|
}
|
|
111
115
|
return request.method === 'HEAD' ? new Response(null, response) : response;
|
|
112
116
|
};
|
|
117
|
+
return Object.assign(handler, { flush: () => traffic?.flush() ?? Promise.resolve() });
|
|
113
118
|
}
|
|
114
119
|
/** {@link llmsInkHandler} as Express/Connect middleware: `app.use(llmsInkExpress({ site }))`. */
|
|
115
120
|
export function llmsInkExpress(options) {
|
|
116
121
|
const handle = llmsInkHandler(options);
|
|
117
|
-
|
|
122
|
+
const middleware = (req, res, next) => {
|
|
118
123
|
const headers = new Headers();
|
|
119
124
|
for (const name of ['accept', 'user-agent']) {
|
|
120
125
|
const value = req.headers[name];
|
|
@@ -132,6 +137,7 @@ export function llmsInkExpress(options) {
|
|
|
132
137
|
})
|
|
133
138
|
.catch(next);
|
|
134
139
|
};
|
|
140
|
+
return Object.assign(middleware, { flush: handle.flush });
|
|
135
141
|
}
|
|
136
142
|
function pick(from, names) {
|
|
137
143
|
const to = new Headers();
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI traffic: which AI agents fetch which pages of your site, counted in memory and sent to llms.ink as daily
|
|
3
|
+
* aggregates (`POST /api/v0/traffic`). Only requests from the agents in {@link AI_AGENTS} are counted; what leaves
|
|
4
|
+
* your server is `{ site, period, rows: [{ path, agent, kind, count }] }` — no IPs, no user agents, no query
|
|
5
|
+
* strings, no cookies, nothing about people who visit.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
/** `user`: fetched because someone asked an assistant; `crawler`: indexing for search or collecting for training. */
|
|
10
|
+
export type AgentKind = 'user' | 'crawler';
|
|
11
|
+
/** One AI agent: the name it puts in its User-Agent (matched case-insensitively), what kind it is, who runs it. */
|
|
12
|
+
export interface AiAgent {
|
|
13
|
+
agent: string;
|
|
14
|
+
kind: AgentKind;
|
|
15
|
+
operator: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The agents counted, from the operators' published bot lists. Robots-only tokens (`Google-Extended`,
|
|
19
|
+
* `Applebot-Extended`) never appear in a User-Agent, so they aren't here: Googlebot and Applebot do that crawling.
|
|
20
|
+
*/
|
|
21
|
+
export declare const AI_AGENTS: readonly AiAgent[];
|
|
22
|
+
/** The AI agent behind a User-Agent header, or null for browsers, other bots and anything unknown. */
|
|
23
|
+
export declare function classifyAgent(userAgent: string | null | undefined): AiAgent | null;
|
|
24
|
+
/** Options for {@link trafficCounter}, and the middleware's `traffic` option. */
|
|
25
|
+
export interface TrafficOptions {
|
|
26
|
+
/** API key (`lk_…`). The first key to send counts for a site owns it; read them back with the same key. */
|
|
27
|
+
key: string;
|
|
28
|
+
/** Hostname the counts are filed under, e.g. `example.com`. The middleware defaults it to its `site`. */
|
|
29
|
+
site?: string;
|
|
30
|
+
/** Seconds between sends. Default 60, at least 10. */
|
|
31
|
+
flushEvery?: number;
|
|
32
|
+
}
|
|
33
|
+
/** One aggregate: requests from one agent to one path in one UTC day. */
|
|
34
|
+
export interface TrafficRow {
|
|
35
|
+
path: string;
|
|
36
|
+
agent: string;
|
|
37
|
+
kind: AgentKind;
|
|
38
|
+
count: number;
|
|
39
|
+
}
|
|
40
|
+
/** The body of `POST /api/v0/traffic`. */
|
|
41
|
+
export interface TrafficBatch {
|
|
42
|
+
site: string;
|
|
43
|
+
/** UTC day, `YYYY-MM-DD`. */
|
|
44
|
+
period: string;
|
|
45
|
+
rows: TrafficRow[];
|
|
46
|
+
}
|
|
47
|
+
/** Workers' `ExecutionContext`, Next.js's `NextFetchEvent`: keeps a send alive after the response. */
|
|
48
|
+
export interface WaitUntil {
|
|
49
|
+
waitUntil(promise: Promise<unknown>): void;
|
|
50
|
+
}
|
|
51
|
+
/** Counts AI-agent requests and sends them in the background. */
|
|
52
|
+
export interface TrafficCounter {
|
|
53
|
+
/**
|
|
54
|
+
* Counts the request when an AI agent sent it (`GET`/`HEAD` only). When a send is due it starts one and hands it
|
|
55
|
+
* to `ctx.waitUntil`; without `ctx`, a background timer sends. Never throws and never waits.
|
|
56
|
+
*/
|
|
57
|
+
record(request: Request, ctx?: WaitUntil): void;
|
|
58
|
+
/** Sends everything counted so far. Never rejects; failed sends are kept and retried with backoff. */
|
|
59
|
+
flush(): Promise<void>;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* A counter for one site. Use it directly in servers the middleware doesn't cover:
|
|
63
|
+
* `counter.record(request, ctx)` for each request, `await counter.flush()` before shutting down.
|
|
64
|
+
*/
|
|
65
|
+
export declare function trafficCounter(options: TrafficOptions & {
|
|
66
|
+
site: string;
|
|
67
|
+
baseUrl?: string;
|
|
68
|
+
}): TrafficCounter;
|
package/dist/traffic.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI traffic: which AI agents fetch which pages of your site, counted in memory and sent to llms.ink as daily
|
|
3
|
+
* aggregates (`POST /api/v0/traffic`). Only requests from the agents in {@link AI_AGENTS} are counted; what leaves
|
|
4
|
+
* your server is `{ site, period, rows: [{ path, agent, kind, count }] }` — no IPs, no user agents, no query
|
|
5
|
+
* strings, no cookies, nothing about people who visit.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
import { DEFAULT_BASE_URL, VERSION } from "./index.js";
|
|
10
|
+
/**
|
|
11
|
+
* The agents counted, from the operators' published bot lists. Robots-only tokens (`Google-Extended`,
|
|
12
|
+
* `Applebot-Extended`) never appear in a User-Agent, so they aren't here: Googlebot and Applebot do that crawling.
|
|
13
|
+
*/
|
|
14
|
+
export const AI_AGENTS = [
|
|
15
|
+
// Fetches on behalf of someone asking an assistant right now.
|
|
16
|
+
{ agent: 'ChatGPT-User', kind: 'user', operator: 'OpenAI' },
|
|
17
|
+
{ agent: 'Claude-User', kind: 'user', operator: 'Anthropic' },
|
|
18
|
+
{ agent: 'Perplexity-User', kind: 'user', operator: 'Perplexity' },
|
|
19
|
+
{ agent: 'MistralAI-User', kind: 'user', operator: 'Mistral' },
|
|
20
|
+
{ agent: 'DuckAssistBot', kind: 'user', operator: 'DuckDuckGo' },
|
|
21
|
+
{ agent: 'meta-externalfetcher', kind: 'user', operator: 'Meta' },
|
|
22
|
+
{ agent: 'Google-NotebookLM', kind: 'user', operator: 'Google' },
|
|
23
|
+
{ agent: 'Amzn-User', kind: 'user', operator: 'Amazon' },
|
|
24
|
+
// Crawlers: AI search indexes, training collection, and the search engines AI answers draw on.
|
|
25
|
+
{ agent: 'OAI-SearchBot', kind: 'crawler', operator: 'OpenAI' },
|
|
26
|
+
{ agent: 'GPTBot', kind: 'crawler', operator: 'OpenAI' },
|
|
27
|
+
{ agent: 'Claude-SearchBot', kind: 'crawler', operator: 'Anthropic' },
|
|
28
|
+
{ agent: 'ClaudeBot', kind: 'crawler', operator: 'Anthropic' },
|
|
29
|
+
{ agent: 'anthropic-ai', kind: 'crawler', operator: 'Anthropic' },
|
|
30
|
+
{ agent: 'PerplexityBot', kind: 'crawler', operator: 'Perplexity' },
|
|
31
|
+
{ agent: 'Google-CloudVertexBot', kind: 'crawler', operator: 'Google' },
|
|
32
|
+
{ agent: 'Googlebot', kind: 'crawler', operator: 'Google' },
|
|
33
|
+
{ agent: 'bingbot', kind: 'crawler', operator: 'Microsoft' },
|
|
34
|
+
{ agent: 'Applebot', kind: 'crawler', operator: 'Apple' },
|
|
35
|
+
{ agent: 'Amzn-SearchBot', kind: 'crawler', operator: 'Amazon' },
|
|
36
|
+
{ agent: 'Amazonbot', kind: 'crawler', operator: 'Amazon' },
|
|
37
|
+
{ agent: 'meta-externalagent', kind: 'crawler', operator: 'Meta' },
|
|
38
|
+
{ agent: 'Bytespider', kind: 'crawler', operator: 'ByteDance' },
|
|
39
|
+
{ agent: 'CCBot', kind: 'crawler', operator: 'Common Crawl' },
|
|
40
|
+
{ agent: 'cohere-ai', kind: 'crawler', operator: 'Cohere' },
|
|
41
|
+
{ agent: 'YouBot', kind: 'crawler', operator: 'You.com' },
|
|
42
|
+
{ agent: 'DuckDuckBot', kind: 'crawler', operator: 'DuckDuckGo' },
|
|
43
|
+
];
|
|
44
|
+
const BY_TOKEN = new Map(AI_AGENTS.map((a) => [a.agent.toLowerCase(), a]));
|
|
45
|
+
/** One pass over the User-Agent: a known name not glued to a preceding letter, digit or hyphen. */
|
|
46
|
+
const MATCH = new RegExp(`(?:^|[^a-z0-9-])(${AI_AGENTS.map((a) => a.agent.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&')).join('|')})`, 'i');
|
|
47
|
+
/** The AI agent behind a User-Agent header, or null for browsers, other bots and anything unknown. */
|
|
48
|
+
export function classifyAgent(userAgent) {
|
|
49
|
+
if (!userAgent)
|
|
50
|
+
return null;
|
|
51
|
+
const match = MATCH.exec(userAgent);
|
|
52
|
+
return match ? BY_TOKEN.get(match[1].toLowerCase()) ?? null : null;
|
|
53
|
+
}
|
|
54
|
+
/** Rows per POST (the API's limit). */
|
|
55
|
+
const ROWS_PER_POST = 1000;
|
|
56
|
+
/** Distinct path/agent/day rows kept in memory; past it, new paths count as `(other)`. */
|
|
57
|
+
const MAX_PENDING = 5000;
|
|
58
|
+
/** The path new rows are folded into once {@link MAX_PENDING} is reached. */
|
|
59
|
+
const OTHER_PATH = '/(other)';
|
|
60
|
+
const MAX_PATH = 512;
|
|
61
|
+
const MAX_BACKOFF_MS = 3_600_000;
|
|
62
|
+
const SEND_TIMEOUT_MS = 10_000;
|
|
63
|
+
/** The path only: no query string or fragment (URL.pathname has neither), capped in length. */
|
|
64
|
+
function cleanPath(url) {
|
|
65
|
+
const path = new URL(url).pathname || '/';
|
|
66
|
+
return path.length > MAX_PATH ? path.slice(0, MAX_PATH) : path;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* A counter for one site. Use it directly in servers the middleware doesn't cover:
|
|
70
|
+
* `counter.record(request, ctx)` for each request, `await counter.flush()` before shutting down.
|
|
71
|
+
*/
|
|
72
|
+
export function trafficCounter(options) {
|
|
73
|
+
const key = options.key?.trim();
|
|
74
|
+
if (!key)
|
|
75
|
+
throw new TypeError('traffic needs an llms.ink API key (traffic: { key: "lk_…" }).');
|
|
76
|
+
// `https://Example.com/x` or `example.com` → `example.com`.
|
|
77
|
+
const site = new URL(/^https?:\/\//i.test(options.site) ? options.site : `https://${options.site}`).hostname.toLowerCase();
|
|
78
|
+
const every = Math.max(10, options.flushEvery ?? 60) * 1000;
|
|
79
|
+
const endpoint = `${(options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '')}/api/v0/traffic`;
|
|
80
|
+
const headers = { authorization: `Bearer ${key}`, 'content-type': 'application/json', 'user-agent': `llms-ink-js/${VERSION}` };
|
|
81
|
+
/** `period\tpath\tagent` → row. */
|
|
82
|
+
const pending = new Map();
|
|
83
|
+
let lastSend = Date.now();
|
|
84
|
+
let retryAt = 0;
|
|
85
|
+
let backoff = 0;
|
|
86
|
+
let inflight = null;
|
|
87
|
+
let timer;
|
|
88
|
+
/** Set when llms.ink refuses the key or the site: counting stops, once, with a warning. */
|
|
89
|
+
let stopped = false;
|
|
90
|
+
function add(period, path, agent, kind, count) {
|
|
91
|
+
let id = `${period}\t${path}\t${agent}`;
|
|
92
|
+
let row = pending.get(id);
|
|
93
|
+
if (!row && pending.size >= MAX_PENDING) {
|
|
94
|
+
path = OTHER_PATH;
|
|
95
|
+
id = `${period}\t${path}\t${agent}`;
|
|
96
|
+
row = pending.get(id);
|
|
97
|
+
}
|
|
98
|
+
if (row)
|
|
99
|
+
row.count += count;
|
|
100
|
+
else
|
|
101
|
+
pending.set(id, { period, path, agent, kind, count });
|
|
102
|
+
}
|
|
103
|
+
function stop(message) {
|
|
104
|
+
stopped = true;
|
|
105
|
+
pending.clear();
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
console.warn(`llms-ink traffic: ${message} Counting stopped.`);
|
|
108
|
+
}
|
|
109
|
+
function retryLater(rows, retryAfter) {
|
|
110
|
+
for (const r of rows)
|
|
111
|
+
add(r.period, r.path, r.agent, r.kind, r.count);
|
|
112
|
+
backoff = Math.min(MAX_BACKOFF_MS, Math.max(every, backoff * 2));
|
|
113
|
+
retryAt = Date.now() + Math.max(backoff, (retryAfter ?? 0) * 1000);
|
|
114
|
+
}
|
|
115
|
+
/** One POST; resolves to what to do with its rows. */
|
|
116
|
+
async function send(batch) {
|
|
117
|
+
let res;
|
|
118
|
+
try {
|
|
119
|
+
res = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(batch), signal: AbortSignal.timeout(SEND_TIMEOUT_MS) });
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return { retryAfter: null };
|
|
123
|
+
}
|
|
124
|
+
if (res.ok) {
|
|
125
|
+
await res.body?.cancel();
|
|
126
|
+
return 'sent';
|
|
127
|
+
}
|
|
128
|
+
// llms.ink's problem details (`code`, `detail`) say why a batch was refused.
|
|
129
|
+
const problem = await res.json().catch(() => null);
|
|
130
|
+
const why = problem ? `${problem.code ?? ''}: ${problem.detail ?? ''}` : '';
|
|
131
|
+
if (res.status === 429 || res.status >= 500)
|
|
132
|
+
return { retryAfter: Number(res.headers.get('retry-after')) || null };
|
|
133
|
+
if (res.status === 401 || res.status === 403) {
|
|
134
|
+
stop(`llms.ink refused ${site} (${res.status} ${why || 'check the key'}).`);
|
|
135
|
+
return 'stop';
|
|
136
|
+
}
|
|
137
|
+
console.warn(`llms-ink traffic: llms.ink rejected a batch for ${site} (${res.status} ${why}).`);
|
|
138
|
+
return 'drop';
|
|
139
|
+
}
|
|
140
|
+
async function sendAll(rows) {
|
|
141
|
+
const batches = [];
|
|
142
|
+
const byPeriod = new Map();
|
|
143
|
+
for (const r of rows)
|
|
144
|
+
byPeriod.set(r.period, [...(byPeriod.get(r.period) ?? []), r]);
|
|
145
|
+
for (const [period, list] of byPeriod) {
|
|
146
|
+
for (let i = 0; i < list.length; i += ROWS_PER_POST) {
|
|
147
|
+
const chunk = list.slice(i, i + ROWS_PER_POST);
|
|
148
|
+
batches.push({ batch: { site, period, rows: chunk.map(({ path, agent, kind, count }) => ({ path, agent, kind, count })) }, rows: chunk });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
for (let i = 0; i < batches.length; i++) {
|
|
152
|
+
const outcome = await send(batches[i].batch);
|
|
153
|
+
if (outcome === 'stop')
|
|
154
|
+
return;
|
|
155
|
+
if (typeof outcome === 'object') {
|
|
156
|
+
retryLater(batches.slice(i).flatMap((b) => b.rows), outcome.retryAfter);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
backoff = 0;
|
|
161
|
+
retryAt = 0;
|
|
162
|
+
}
|
|
163
|
+
/** Waits for a send in flight, then sends what was counted meanwhile. */
|
|
164
|
+
async function flush() {
|
|
165
|
+
while (inflight)
|
|
166
|
+
await inflight;
|
|
167
|
+
if (!pending.size || stopped)
|
|
168
|
+
return;
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
timer = undefined;
|
|
171
|
+
const rows = [...pending.values()];
|
|
172
|
+
pending.clear();
|
|
173
|
+
lastSend = Date.now();
|
|
174
|
+
inflight = sendAll(rows)
|
|
175
|
+
.catch(() => { })
|
|
176
|
+
.finally(() => {
|
|
177
|
+
inflight = null;
|
|
178
|
+
if (pending.size && !stopped)
|
|
179
|
+
arm();
|
|
180
|
+
});
|
|
181
|
+
await inflight;
|
|
182
|
+
}
|
|
183
|
+
/** A background send where nothing keeps one alive (Node, Bun, Deno); unref'd so it never holds the process open. */
|
|
184
|
+
function arm() {
|
|
185
|
+
if (timer)
|
|
186
|
+
return;
|
|
187
|
+
const delay = Math.max(1000, Math.max(lastSend + every, retryAt) - Date.now());
|
|
188
|
+
timer = setTimeout(() => {
|
|
189
|
+
timer = undefined;
|
|
190
|
+
void flush();
|
|
191
|
+
}, delay);
|
|
192
|
+
// Node's and Bun's timers have unref(); elsewhere setTimeout returns a number.
|
|
193
|
+
if (typeof timer === 'object' && 'unref' in timer)
|
|
194
|
+
timer.unref();
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
record(request, ctx) {
|
|
198
|
+
try {
|
|
199
|
+
if (stopped || (request.method !== 'GET' && request.method !== 'HEAD'))
|
|
200
|
+
return;
|
|
201
|
+
const agent = classifyAgent(request.headers.get('user-agent'));
|
|
202
|
+
if (!agent)
|
|
203
|
+
return;
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
add(new Date(now).toISOString().slice(0, 10), cleanPath(request.url), agent.agent, agent.kind, 1);
|
|
206
|
+
const due = now - lastSend >= every || pending.size >= ROWS_PER_POST;
|
|
207
|
+
if (!inflight && now >= retryAt && due) {
|
|
208
|
+
const sending = flush();
|
|
209
|
+
ctx?.waitUntil(sending);
|
|
210
|
+
}
|
|
211
|
+
else if (!ctx) {
|
|
212
|
+
arm();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// Counting must never break the site.
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
flush,
|
|
220
|
+
};
|
|
221
|
+
}
|
package/dist/vite.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Vite plugin: writes llms.txt (and llms-full.txt) from llms.ink into the build output
|
|
2
|
+
* Vite plugin: writes llms.txt (and llms-full.txt) from llms.ink into the build output, and with
|
|
3
|
+
* `markdownPages: true` a Markdown version next to every built HTML page.
|
|
3
4
|
*
|
|
4
5
|
* @example
|
|
5
6
|
* ```ts
|
|
@@ -13,13 +14,14 @@
|
|
|
13
14
|
* @module
|
|
14
15
|
*/
|
|
15
16
|
import type { Plugin } from 'vite';
|
|
16
|
-
import { type LlmsFilesOptions } from './files.ts';
|
|
17
|
-
export type { LlmsFilesOptions };
|
|
17
|
+
import { type LlmsFilesOptions, type MarkdownPagesOptions } from './files.ts';
|
|
18
|
+
export type { LlmsFilesOptions, MarkdownPagesOptions };
|
|
18
19
|
/**
|
|
19
20
|
* After `vite build` (not the dev server, not SSR builds), fetches llms.txt — and llms-full.txt with
|
|
20
21
|
* `full: true` — for `site` and writes them into `build.outDir`. llms.ink reads the live site, so the files
|
|
21
22
|
* describe the previous deploy. When llms.ink can't be reached it warns and keeps any existing file (e.g. one
|
|
22
|
-
* copied from `public/`); `strict: true` fails the build instead.
|
|
23
|
+
* copied from `public/`); `strict: true` fails the build instead. `markdownPages: true` converts each `.html` file
|
|
24
|
+
* of this build (not the deploy) through `POST /api/v0/markdown` and writes `about.md` / `docs/index.md` next to it.
|
|
23
25
|
*/
|
|
24
26
|
export declare function llmsInk(options: LlmsFilesOptions): Plugin;
|
|
25
27
|
export default llmsInk;
|
package/dist/vite.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Vite plugin: writes llms.txt (and llms-full.txt) from llms.ink into the build output
|
|
2
|
+
* Vite plugin: writes llms.txt (and llms-full.txt) from llms.ink into the build output, and with
|
|
3
|
+
* `markdownPages: true` a Markdown version next to every built HTML page.
|
|
3
4
|
*
|
|
4
5
|
* @example
|
|
5
6
|
* ```ts
|
|
@@ -18,7 +19,8 @@ import { llmsFilesWriter } from "./files.js";
|
|
|
18
19
|
* After `vite build` (not the dev server, not SSR builds), fetches llms.txt — and llms-full.txt with
|
|
19
20
|
* `full: true` — for `site` and writes them into `build.outDir`. llms.ink reads the live site, so the files
|
|
20
21
|
* describe the previous deploy. When llms.ink can't be reached it warns and keeps any existing file (e.g. one
|
|
21
|
-
* copied from `public/`); `strict: true` fails the build instead.
|
|
22
|
+
* copied from `public/`); `strict: true` fails the build instead. `markdownPages: true` converts each `.html` file
|
|
23
|
+
* of this build (not the deploy) through `POST /api/v0/markdown` and writes `about.md` / `docs/index.md` next to it.
|
|
22
24
|
*/
|
|
23
25
|
export function llmsInk(options) {
|
|
24
26
|
const write = llmsFilesWriter(options);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "llms-ink",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Turn any link — site, docs section, page, GitHub repo, PDF or video — into llms.txt and llms-full.txt. Typed client for llms.ink, plus a Vite plugin, an Astro integration and middleware that serve the files from your site. No dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
"scripts": {
|
|
32
32
|
"build": "rm -rf dist && tsc -p tsconfig.json",
|
|
33
33
|
"prepublishOnly": "npm run build && npm test",
|
|
34
|
-
"test": "npm run build && node --test test/client.test.mjs test/plugins.test.mjs test/middleware.test.mjs",
|
|
34
|
+
"test": "npm run build && node --test test/client.test.mjs test/plugins.test.mjs test/middleware.test.mjs test/traffic.test.mjs",
|
|
35
35
|
"test:live": "npm run build && node --test test/live.test.mjs",
|
|
36
|
-
"test:bun": "npm run build && bun test ./test/client.test.mjs ./test/plugins.test.mjs ./test/middleware.test.mjs",
|
|
36
|
+
"test:bun": "npm run build && bun test ./test/client.test.mjs ./test/plugins.test.mjs ./test/middleware.test.mjs ./test/traffic.test.mjs",
|
|
37
37
|
"test:bun:live": "npm run build && bun test --timeout 300000 ./test/live.test.mjs"
|
|
38
38
|
},
|
|
39
39
|
"keywords": ["llms.txt", "llms-full.txt", "llm", "ai", "markdown", "docs", "context", "rag", "sdk", "llms-ink", "vite-plugin", "astro-integration", "middleware"],
|