@wrenautomation/weaver 0.0.0-stage → 0.1.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/LICENSE.md ADDED
@@ -0,0 +1,91 @@
1
+ # PolyForm Shield License 1.0.0
2
+
3
+ <https://polyformproject.org/licenses/shield/1.0.0>
4
+
5
+ ## Acceptance
6
+
7
+ In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
8
+
9
+ ## Copyright License
10
+
11
+ The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
12
+
13
+ ## Distribution License
14
+
15
+ The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
16
+
17
+ ## Notices
18
+
19
+ You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
20
+
21
+ > Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
22
+
23
+ ## Changes and New Works License
24
+
25
+ The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
26
+
27
+ ## Patent License
28
+
29
+ The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
30
+
31
+ ## Noncompete
32
+
33
+ Any purpose is a permitted purpose, except for providing any product that competes with the software or any product the licensor or any of its affiliates provides using the software.
34
+
35
+ ## Competition
36
+
37
+ Goods and services compete even when they provide functionality through different kinds of interfaces or for different technical platforms. Applications can compete with services, libraries with plugins, frameworks with development tools, and so on, even if they're written in different programming languages or for different computer architectures. Goods and services compete even when provided free of charge. If you market a product as a practical substitute for the software or another product, it definitely competes.
38
+
39
+ ## New Products
40
+
41
+ If you are using the software to provide a product that does not compete, but the licensor or any of its affiliates brings your product into competition by providing a new version of the software or another product using the software, you may continue using versions of the software available under these terms beforehand to provide your competing product, but not any later versions.
42
+
43
+ ## Discontinued Products
44
+
45
+ You may begin using the software to compete with a product or service that the licensor or any of its affiliates has stopped providing, unless the licensor includes a plain-text line beginning with `Licensor Line of Business:` with the software that mentions that line of business. For example:
46
+
47
+ > Licensor Line of Business: YoyodyneCMS Content Management System (http://example.com/cms)
48
+
49
+ ## Sales of Business
50
+
51
+ If the licensor or any of its affiliates sells a line of business developing the software or using the software to provide a product, the buyer can also enforce [Noncompete](#noncompete) for that product.
52
+
53
+ ## Fair Use
54
+
55
+ You may have "fair use" rights for the software under the law. These terms do not limit them.
56
+
57
+ ## No Other Rights
58
+
59
+ These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
60
+
61
+ ## Patent Defense
62
+
63
+ If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
64
+
65
+ ## Violations
66
+
67
+ The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
68
+
69
+ ## No Liability
70
+
71
+ ***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
72
+
73
+ ## Definitions
74
+
75
+ The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
76
+
77
+ A **product** can be a good or service, or a combination of them.
78
+
79
+ **You** refers to the individual or entity agreeing to these terms.
80
+
81
+ **Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all its affiliates.
82
+
83
+ **Affiliates** means the other organizations than an organization has control over, is under the control of, or is under common control with.
84
+
85
+ **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
86
+
87
+ **Your licenses** are all the licenses granted to you for the software under these terms.
88
+
89
+ **Use** means anything you do with the software requiring one of your licenses.
90
+
91
+ Required Notice: Copyright 2026 William Jin (https://wrenautomation.com)
package/README.md CHANGED
@@ -1,3 +1,52 @@
1
- # Temporary Holding Version
1
+ # weaver
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The web in. Documents out. No database, no accounts, no business rules: a
4
+ library that fetches, and a small CLI over it. Carved out of
5
+ [falcon](https://github.com/wrenautomation/falcon) (then `@wrenautomation/scribe`)
6
+ in 2026-10: everything that takes a URL or spawns yt-dlp lives here, everything
7
+ that reads a file, bytes or text stays there.
8
+
9
+ Two doors:
10
+
11
+ | Import | Does |
12
+ |---|---|
13
+ | `@wrenautomation/weaver/fetch` | `PoliteFetcher` (one request at a time per host, a contact in the User-Agent, retries on 429 and 5xx, a byte cap), `robots` in warn mode, `fetchLadder` (plain GET up to a browser through a `LadderClient`) |
14
+ | `@wrenautomation/weaver/video` | `readYoutube` (captions and chapters by yt-dlp, plus Gemini's read of the screen), `readYoutubeByUrl` (Gemini alone, no download), `readVideo` (any other video: yt-dlp, then falcon's `watch`), `readAudio` (a podcast by its audio URL, then falcon's `hearAudio`), `watch` over a URL or a file, `flatPlaylist` (what a profile, channel or search holds), `download` |
15
+
16
+ Every reader returns falcon's one `VideoRead` shape. What turns a page or a file into text is
17
+ falcon's (`@wrenautomation/falcon`). What searches by a source interface, runs a plan and keeps
18
+ the ledger comes next (phase 3.5c of wren's research engine design).
19
+
20
+ ## CLI
21
+
22
+ Needs Node 24+. `read` needs yt-dlp and ffmpeg.
23
+
24
+ ```sh
25
+ npm i @wrenautomation/weaver
26
+ npx weaver fetch https://example.com/report --contact you@example.com # a page as text
27
+ GEMINI_API_KEY=... npx weaver read https://www.youtube.com/watch?v=... # captions + screen
28
+ COHERE_API_KEY=... npx weaver read https://www.instagram.com/reel/... # watched: speech, screen, cuts
29
+ npx weaver list 'ytsearch10:cold email' --to 10 # hits as JSON, no downloads
30
+ ```
31
+
32
+ ## Library
33
+
34
+ ```ts
35
+ import { PoliteFetcher, userAgent } from "@wrenautomation/weaver/fetch";
36
+ import { readPage } from "@wrenautomation/falcon/text";
37
+
38
+ const fetcher = new PoliteFetcher(userAgent("you@example.com"));
39
+ const res = await fetcher.get(url);
40
+ const page = readPage(res.text, res.url);
41
+ ```
42
+
43
+ ## Develop
44
+
45
+ ```sh
46
+ pnpm install
47
+ pnpm lint && pnpm typecheck && pnpm test
48
+ ```
49
+
50
+ A `v*` tag publishes to npm (`.github/workflows/release.yml`, trusted publishing: no stored token).
51
+
52
+ License: PolyForm Shield 1.0.0 (`LICENSE.md`).
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import type { VideoRead } from "@wrenautomation/falcon/video";
3
+ /** The read as one markdown document: its facts as front matter, its title, its sections. */
4
+ export declare function renderRead(r: VideoRead): string;
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `weaver`: the library from a shell. `fetch <url>` prints a page as text, `read <url>` a video
4
+ * as one markdown document, `list <target>` what a profile, channel or search holds. A file on
5
+ * disk is falcon's (`npx falcon text|page|watch`). Keys come from the environment:
6
+ * COHERE_API_KEY (or COHERE_API_KEY_1..n), GEMINI_API_KEY.
7
+ */
8
+ import { readPage } from "@wrenautomation/falcon/text";
9
+ import { Command } from "commander";
10
+ import { PoliteFetcher, userAgent } from "../fetch/fetcher.js";
11
+ import { flatPlaylist } from "../video/list.js";
12
+ import { readVideo, readYoutube } from "../video/read.js";
13
+ /** `NAME`, then `NAME_1`, `NAME_2`, ... in order: a fleet of keys for one vendor. */
14
+ function keys(env, name) {
15
+ const out = [];
16
+ const one = env[name];
17
+ if (one)
18
+ out.push(one);
19
+ for (let i = 1;; i++) {
20
+ const k = env[`${name}_${i}`];
21
+ if (!k)
22
+ break;
23
+ out.push(k);
24
+ }
25
+ return out;
26
+ }
27
+ const program = new Command().name("weaver").description("pages and videos on the web as text");
28
+ program
29
+ .command("fetch <url>")
30
+ .description("a page as text (polite: one request at a time per host, a contact in the UA)")
31
+ .option("--contact <who>", "an email address or URL a site operator could reach you at (sent in the User-Agent)", process.env.WEAVER_CONTACT)
32
+ .option("--json", "title, text, links, mailtos and tels as JSON")
33
+ .action(async (url, o) => {
34
+ const fetcher = new PoliteFetcher(userAgent(o.contact));
35
+ const res = await fetcher.get(url);
36
+ const page = readPage(res.text, res.url);
37
+ if (o.json)
38
+ console.log(JSON.stringify({ status: res.status, url: res.url, ...page }, null, 2));
39
+ else
40
+ console.log(page.title ? `${page.title}\n\n${page.text}` : page.text);
41
+ });
42
+ /** The read as one markdown document: its facts as front matter, its title, its sections. */
43
+ export function renderRead(r) {
44
+ const facts = [
45
+ ["source", `${r.kind}:${r.id}`],
46
+ ["title", r.title],
47
+ ["url", r.url],
48
+ ["channel", r.channel],
49
+ ["uploaded", r.uploaded],
50
+ ["duration", r.duration],
51
+ ["thumbnail", r.thumbnail],
52
+ ];
53
+ const front = facts
54
+ .filter((f) => f[1] !== undefined)
55
+ .map(([k, v]) => `${k}: ${typeof v === "string" ? JSON.stringify(v) : v}`)
56
+ .join("\n");
57
+ return `---\n${front}\n---\n\n# ${r.title}\n\n${r.body}`;
58
+ }
59
+ program
60
+ .command("read <url>")
61
+ .description("a video as one markdown document: YouTube from its captions (plus the screen with GEMINI_API_KEY), any other video by watching it (COHERE_API_KEY)")
62
+ .option("--json", "the read as JSON")
63
+ .option("--ffmpeg <path>", "the ffmpeg binary", "ffmpeg")
64
+ .option("--yt-dlp <cmd>", "the yt-dlp command", "yt-dlp")
65
+ .action(async (url, o) => {
66
+ const geminiKeys = keys(process.env, "GEMINI_API_KEY");
67
+ let read;
68
+ if (/^https?:\/\/(www\.|m\.)?(youtube\.com|youtu\.be)\//.test(url))
69
+ read = await readYoutube(url, { ytDlp: o.ytDlp, geminiKeys });
70
+ else {
71
+ const cohereKeys = keys(process.env, "COHERE_API_KEY");
72
+ if (!cohereKeys.length)
73
+ throw new Error("read needs COHERE_API_KEY for a video with no captions");
74
+ read = await readVideo(url, { ytDlp: o.ytDlp, ffmpeg: o.ffmpeg, cohereKeys, geminiKeys });
75
+ }
76
+ console.log(o.json ? JSON.stringify(read, null, 2) : renderRead(read));
77
+ });
78
+ program
79
+ .command("list <target>")
80
+ .description("what a profile, channel or search (ytsearch10:<words>) holds, as JSON, no downloads")
81
+ .option("--from <n>", "first entry, 1-based", Number)
82
+ .option("--to <n>", "last entry, inclusive", Number)
83
+ .option("--yt-dlp <cmd>", "the yt-dlp command", "yt-dlp")
84
+ .action(async (target, o) => {
85
+ const range = o.from || o.to ? { start: o.from ?? 1, end: o.to ?? Number.MAX_SAFE_INTEGER } : undefined;
86
+ console.log(JSON.stringify(await flatPlaylist(o.ytDlp, target, range), null, 2));
87
+ });
88
+ program.parseAsync(process.argv).catch((err) => {
89
+ console.error(err instanceof Error ? err.message : String(err));
90
+ process.exit(1);
91
+ });
92
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1,84 @@
1
+ /**
2
+ * A fetch that failed after retries. `status` is the last HTTP status when the
3
+ * server answered (5xx after retries), null for transport failures: robots handling
4
+ * needs the difference (a 503 robots.txt means disallow-all; a dead host means the
5
+ * web's allow default).
6
+ */
7
+ export declare class FetchError extends Error {
8
+ name: string;
9
+ readonly status: number | null;
10
+ constructor(message: string, status?: number | null);
11
+ }
12
+ /** The declared UA, or a loud failure: contact info is an explicit operator choice, never invented. */
13
+ export declare function userAgent(contact: string | null | undefined): string;
14
+ /** What one GET produced. `url` is the final URL after redirects. */
15
+ export interface FetchResponse {
16
+ status: number;
17
+ url: string;
18
+ text: string;
19
+ /** The `fetch_tier` the page was kept under, when it was not the caller's own plain fetch. */
20
+ tier?: string;
21
+ }
22
+ /** The seam crawlers and gates depend on; tests substitute canned pages. */
23
+ export interface Fetcher {
24
+ readonly userAgent: string;
25
+ get(url: string): Promise<FetchResponse>;
26
+ }
27
+ export interface DownloadResult {
28
+ path: string;
29
+ /** false: already on disk, not re-fetched. */
30
+ downloaded: boolean;
31
+ size: number;
32
+ }
33
+ interface PoliteFetcherOptions {
34
+ /** Seconds between requests to one host. */
35
+ minInterval?: number;
36
+ /** Extra uniform(lo, hi) seconds per request so crawls don't tick like a metronome. */
37
+ jitter?: readonly [number, number] | null;
38
+ timeout?: number;
39
+ retries?: number;
40
+ /** Test seams. */
41
+ fetch?: typeof globalThis.fetch;
42
+ sleep?: (ms: number) => Promise<void>;
43
+ now?: () => number;
44
+ random?: () => number;
45
+ }
46
+ /**
47
+ * fetch() wrapper enforcing UA + throttle + retry in one place. The throttle is per
48
+ * host: a crawl across many small sites is not serialized globally.
49
+ */
50
+ export declare class PoliteFetcher implements Fetcher {
51
+ readonly userAgent: string;
52
+ private readonly minIntervalMs;
53
+ private readonly jitter;
54
+ private readonly timeoutMs;
55
+ private readonly retries;
56
+ private readonly fetchImpl;
57
+ private readonly sleep;
58
+ private readonly now;
59
+ private readonly random;
60
+ private readonly lastRequest;
61
+ /** Requests to one host are serialized so concurrent callers still honor the interval. */
62
+ private readonly hostQueues;
63
+ constructor(userAgent: string, opts?: PoliteFetcherOptions);
64
+ private throttle;
65
+ private request;
66
+ /**
67
+ * One throttled request as the raw `Response`, for a body the caller streams (a file it
68
+ * filters as it lands) or a method other than GET. `init` wins: its signal replaces the
69
+ * fetcher's timeout, its headers join the User-Agent. No retries: a half-read stream cannot be
70
+ * asked again, so the caller decides what a bad status means.
71
+ */
72
+ open(url: string, init?: RequestInit): Promise<Response>;
73
+ /**
74
+ * GET with throttle and bounded retries. 4xx returns (the caller interprets: a
75
+ * crawl 404 is data); transport errors and 5xx retry with backoff, then FetchError.
76
+ */
77
+ get(url: string): Promise<FetchResponse>;
78
+ /**
79
+ * Stream a (possibly large) file to `dest`; skip if already present. Writes to a
80
+ * .part file and renames, so an interrupted download never masquerades as complete.
81
+ */
82
+ download(url: string, dest: string): Promise<DownloadResult>;
83
+ }
84
+ export {};
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Polite HTTP fetching, shared by every layer that touches the network: the
3
+ * discovery ownership gate, the site crawler, bulk downloads. One class holds the
4
+ * etiquette (declared User-Agent with contact info, per-host throttle, bounded
5
+ * retries, never re-downloading what is on disk) so it cannot drift between callers.
6
+ */
7
+ import { createWriteStream } from "node:fs";
8
+ import { mkdir, rename, stat, unlink } from "node:fs/promises";
9
+ import { dirname } from "node:path";
10
+ import { Readable } from "node:stream";
11
+ import { pipeline } from "node:stream/promises";
12
+ const DEFAULT_RETRIES = 3;
13
+ const BACKOFF_MS = 2000;
14
+ /**
15
+ * A fetch that failed after retries. `status` is the last HTTP status when the
16
+ * server answered (5xx after retries), null for transport failures: robots handling
17
+ * needs the difference (a 503 robots.txt means disallow-all; a dead host means the
18
+ * web's allow default).
19
+ */
20
+ export class FetchError extends Error {
21
+ name = "FetchError";
22
+ status;
23
+ constructor(message, status = null) {
24
+ super(message);
25
+ this.status = status;
26
+ }
27
+ }
28
+ /** The declared UA, or a loud failure: contact info is an explicit operator choice, never invented. */
29
+ export function userAgent(contact) {
30
+ if (!contact) {
31
+ throw new FetchError("outbound fetches need a contact (an email address or URL a remote operator could reach you at)");
32
+ }
33
+ return `weaver/0.1 (${contact})`;
34
+ }
35
+ const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
36
+ /**
37
+ * fetch() wrapper enforcing UA + throttle + retry in one place. The throttle is per
38
+ * host: a crawl across many small sites is not serialized globally.
39
+ */
40
+ export class PoliteFetcher {
41
+ userAgent;
42
+ minIntervalMs;
43
+ jitter;
44
+ timeoutMs;
45
+ retries;
46
+ fetchImpl;
47
+ sleep;
48
+ now;
49
+ random;
50
+ lastRequest = new Map();
51
+ /** Requests to one host are serialized so concurrent callers still honor the interval. */
52
+ hostQueues = new Map();
53
+ constructor(userAgent, opts = {}) {
54
+ this.userAgent = userAgent;
55
+ this.minIntervalMs = (opts.minInterval ?? 1.0) * 1000;
56
+ this.jitter = opts.jitter ?? null;
57
+ this.timeoutMs = (opts.timeout ?? 30) * 1000;
58
+ this.retries = Math.max(1, opts.retries ?? DEFAULT_RETRIES);
59
+ this.fetchImpl = opts.fetch ?? globalThis.fetch;
60
+ this.sleep = opts.sleep ?? defaultSleep;
61
+ this.now = opts.now ?? (() => performance.now());
62
+ this.random = opts.random ?? Math.random;
63
+ }
64
+ async throttle(url) {
65
+ let host = "";
66
+ try {
67
+ host = new URL(url).host;
68
+ }
69
+ catch {
70
+ host = "";
71
+ }
72
+ const previous = this.hostQueues.get(host) ?? Promise.resolve();
73
+ const turn = previous.then(async () => {
74
+ let wait = this.minIntervalMs;
75
+ if (this.jitter) {
76
+ const [lo, hi] = this.jitter;
77
+ wait += (lo + (hi - lo) * this.random()) * 1000;
78
+ }
79
+ const elapsed = this.now() - (this.lastRequest.get(host) ?? Number.NEGATIVE_INFINITY);
80
+ if (elapsed < wait)
81
+ await this.sleep(wait - elapsed);
82
+ this.lastRequest.set(host, this.now());
83
+ });
84
+ this.hostQueues.set(host, turn.catch(() => { }));
85
+ await turn;
86
+ }
87
+ request(url, init = {}) {
88
+ const headers = new Headers(init.headers);
89
+ if (!headers.has("user-agent"))
90
+ headers.set("User-Agent", this.userAgent);
91
+ return this.fetchImpl(url, {
92
+ redirect: "follow",
93
+ signal: AbortSignal.timeout(this.timeoutMs),
94
+ ...init,
95
+ headers,
96
+ });
97
+ }
98
+ /**
99
+ * One throttled request as the raw `Response`, for a body the caller streams (a file it
100
+ * filters as it lands) or a method other than GET. `init` wins: its signal replaces the
101
+ * fetcher's timeout, its headers join the User-Agent. No retries: a half-read stream cannot be
102
+ * asked again, so the caller decides what a bad status means.
103
+ */
104
+ async open(url, init = {}) {
105
+ await this.throttle(url);
106
+ return this.request(url, init);
107
+ }
108
+ /**
109
+ * GET with throttle and bounded retries. 4xx returns (the caller interprets: a
110
+ * crawl 404 is data); transport errors and 5xx retry with backoff, then FetchError.
111
+ */
112
+ async get(url) {
113
+ let lastError = "";
114
+ let lastStatus = null;
115
+ for (let attempt = 0; attempt < this.retries; attempt++) {
116
+ await this.throttle(url);
117
+ try {
118
+ const resp = await this.request(url);
119
+ if (resp.status < 500) {
120
+ return { status: resp.status, url: resp.url || url, text: await resp.text() };
121
+ }
122
+ lastError = `HTTP ${resp.status}`;
123
+ lastStatus = resp.status;
124
+ await resp.body?.cancel().catch(() => { });
125
+ }
126
+ catch (err) {
127
+ lastError = describe(err);
128
+ lastStatus = null;
129
+ }
130
+ if (attempt < this.retries - 1)
131
+ await this.sleep(BACKOFF_MS * (attempt + 1));
132
+ }
133
+ throw new FetchError(`GET ${url} failed after ${this.retries} attempts: ${lastError}`, lastStatus);
134
+ }
135
+ /**
136
+ * Stream a (possibly large) file to `dest`; skip if already present. Writes to a
137
+ * .part file and renames, so an interrupted download never masquerades as complete.
138
+ */
139
+ async download(url, dest) {
140
+ const existing = await stat(dest).catch(() => null);
141
+ if (existing)
142
+ return { path: dest, downloaded: false, size: existing.size };
143
+ await mkdir(dirname(dest), { recursive: true });
144
+ const tmp = `${dest}.part`;
145
+ let lastError = "";
146
+ for (let attempt = 0; attempt < this.retries; attempt++) {
147
+ await this.throttle(url);
148
+ try {
149
+ const resp = await this.request(url);
150
+ if (resp.status >= 400) {
151
+ lastError = `HTTP ${resp.status}`;
152
+ await resp.body?.cancel().catch(() => { });
153
+ if (resp.status < 500)
154
+ break; // 4xx will not improve on retry
155
+ continue;
156
+ }
157
+ const body = resp.body ? Readable.fromWeb(resp.body) : Readable.from([]);
158
+ await pipeline(body, createWriteStream(tmp));
159
+ await rename(tmp, dest);
160
+ const size = (await stat(dest)).size;
161
+ return { path: dest, downloaded: true, size };
162
+ }
163
+ catch (err) {
164
+ lastError = describe(err);
165
+ if (attempt < this.retries - 1)
166
+ await this.sleep(BACKOFF_MS * (attempt + 1));
167
+ }
168
+ }
169
+ await unlink(tmp).catch(() => { });
170
+ throw new FetchError(`download ${url} failed: ${lastError}`);
171
+ }
172
+ }
173
+ function describe(err) {
174
+ if (err instanceof Error) {
175
+ const cause = err.cause instanceof Error ? `: ${err.cause.message}` : "";
176
+ return `${err.name}: ${err.message}${cause}`;
177
+ }
178
+ return String(err);
179
+ }
180
+ //# sourceMappingURL=fetcher.js.map
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The open web by HTTP: a polite fetcher (one request at a time per host, a real User-Agent with a
3
+ * contact, retries on 429 and 5xx, a byte cap), robots.txt in warn mode, and the fetch ladder
4
+ * that climbs from a plain GET to a browser (`LadderClient`: autobrowse's `web GET /fetch`).
5
+ * Turning the page into text is falcon's (`@wrenautomation/falcon/text`). No database here.
6
+ */
7
+ export * from "./fetcher.js";
8
+ export * from "./ladder.js";
9
+ export * from "./robots.js";
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The open web by HTTP: a polite fetcher (one request at a time per host, a real User-Agent with a
3
+ * contact, retries on 429 and 5xx, a byte cap), robots.txt in warn mode, and the fetch ladder
4
+ * that climbs from a plain GET to a browser (`LadderClient`: autobrowse's `web GET /fetch`).
5
+ * Turning the page into text is falcon's (`@wrenautomation/falcon/text`). No database here.
6
+ */
7
+ export * from "./fetcher.js";
8
+ export * from "./ladder.js";
9
+ export * from "./robots.js";
10
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The client of autobrowse's fetch ladder, `web GET /fetch`. A caller asks it for a blocked
3
+ * page; autobrowse climbs `impersonate`, `render` and `exit`. A block is data in the answer.
4
+ * A failed call is the other result.
5
+ */
6
+ /** The rungs a ladder climbs, lowest first. */
7
+ export declare const LADDER_TIERS: readonly ["impersonate", "render", "exit"];
8
+ export type LadderTier = (typeof LADDER_TIERS)[number];
9
+ export declare const isLadderTier: (tier: string | null | undefined) => tier is LadderTier;
10
+ /** Whoever answers `web GET /fetch`: autobrowse's site client, or a test double. */
11
+ export interface LadderClient {
12
+ call<T>(site: string, method: string, path: string, input?: unknown): Promise<T>;
13
+ }
14
+ /** One rung the ladder tried, and what it met there. */
15
+ export interface FetchStep {
16
+ tier: string;
17
+ status: number | null;
18
+ block: string | null;
19
+ }
20
+ /** What `web GET /fetch` answers (`Fetched`): the fields this side reads. */
21
+ interface Fetched {
22
+ finalUrl: string;
23
+ status: number | null;
24
+ html: string;
25
+ /** The rung that answered: the last one tried. */
26
+ tier: LadderTier;
27
+ /** null: a usable page; else the signal the last rung still hit. */
28
+ block: string | null;
29
+ tries: FetchStep[];
30
+ }
31
+ /** `cap`: the day's `fetch` bucket is spent (429). `offline`: any other failed call. */
32
+ type LadderFailure = "cap" | "offline";
33
+ /** One `web GET /fetch`. A failed call is a result, never a throw. */
34
+ export declare function fetchLadder(sites: LadderClient, url: string, from: LadderTier): Promise<{
35
+ fetched: Fetched;
36
+ } | {
37
+ failed: LadderFailure;
38
+ }>;
39
+ export {};
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The client of autobrowse's fetch ladder, `web GET /fetch`. A caller asks it for a blocked
3
+ * page; autobrowse climbs `impersonate`, `render` and `exit`. A block is data in the answer.
4
+ * A failed call is the other result.
5
+ */
6
+ /** The rungs a ladder climbs, lowest first. */
7
+ export const LADDER_TIERS = ["impersonate", "render", "exit"];
8
+ export const isLadderTier = (tier) => LADDER_TIERS.some((t) => t === tier);
9
+ /** One `web GET /fetch`. A failed call is a result, never a throw. */
10
+ export async function fetchLadder(sites, url, from) {
11
+ try {
12
+ return { fetched: await sites.call("web", "GET", "/fetch", { url, from }) };
13
+ }
14
+ catch (err) {
15
+ return { failed: err.status === 429 ? "cap" : "offline" };
16
+ }
17
+ }
18
+ //# sourceMappingURL=ladder.js.map
@@ -0,0 +1,14 @@
1
+ import { type Fetcher } from "./fetcher.js";
2
+ export declare class RobotsPolicy {
3
+ private readonly robot;
4
+ private readonly disallowAll;
5
+ private constructor();
6
+ static allowAll(): RobotsPolicy;
7
+ static disallowAll(): RobotsPolicy;
8
+ static parse(text: string): RobotsPolicy;
9
+ canFetch(userAgent: string, url: string): boolean;
10
+ }
11
+ export type RobotsCache = Map<string, RobotsPolicy>;
12
+ /** The robots policy for this url's scheme+host, fetched once each. */
13
+ export declare function robotsFor(fetcher: Fetcher, url: string, cache: RobotsCache): Promise<RobotsPolicy>;
14
+ export declare function canFetch(fetcher: Fetcher, url: string, cache: RobotsCache): Promise<boolean>;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * robots.txt reading, shared by every page-fetching consumer.
3
+ *
4
+ * The policy that binds a fetch is the one on the HOST actually being fetched, over
5
+ * the SCHEME actually in use: apex and www can serve different files, so policies
6
+ * are cached per scheme+host and fetched over the URL's own scheme.
7
+ *
8
+ * Absence semantics follow RFC 9309: a missing robots.txt (404, or a dead host)
9
+ * means everything is allowed, but a server that ANSWERED 5xx is saying
10
+ * "unavailable", which means full disallow.
11
+ *
12
+ * Matching is robots-parser's RFC 9309 reading: the group for our product token
13
+ * (groups naming it merged), else `*`; the longest matching rule wins, Allow on a
14
+ * tie; `*` and `$` in paths.
15
+ */
16
+ import parseRobots from "robots-parser";
17
+ import { FetchError } from "./fetcher.js";
18
+ /** robots-parser checks a URL's origin against the file's; ours are read per origin, so one stand-in. */
19
+ const ORIGIN = "https://robots.invalid";
20
+ // A CommonJS module: at runtime the default import is the function its typings
21
+ // declare, which NodeNext resolution types as a namespace instead.
22
+ const robotsParser = parseRobots;
23
+ export class RobotsPolicy {
24
+ robot;
25
+ disallowAll;
26
+ constructor(robot, disallowAll) {
27
+ this.robot = robot;
28
+ this.disallowAll = disallowAll;
29
+ }
30
+ static allowAll() {
31
+ return new RobotsPolicy(null, false);
32
+ }
33
+ static disallowAll() {
34
+ return new RobotsPolicy(null, true);
35
+ }
36
+ static parse(text) {
37
+ return new RobotsPolicy(robotsParser(`${ORIGIN}/robots.txt`, text), false);
38
+ }
39
+ canFetch(userAgent, url) {
40
+ if (this.disallowAll)
41
+ return false;
42
+ if (!this.robot)
43
+ return true;
44
+ return this.robot.isAllowed(onOrigin(url), userAgent) ?? true;
45
+ }
46
+ }
47
+ /** The url's path and query on the stand-in origin; an unparseable url reads as `/`. */
48
+ function onOrigin(url) {
49
+ try {
50
+ const u = new URL(url);
51
+ return `${ORIGIN}${u.pathname}${u.search}`;
52
+ }
53
+ catch {
54
+ return `${ORIGIN}/`;
55
+ }
56
+ }
57
+ /** The robots policy for this url's scheme+host, fetched once each. */
58
+ export async function robotsFor(fetcher, url, cache) {
59
+ let key;
60
+ try {
61
+ const u = new URL(url);
62
+ key = `${u.protocol}//${u.host}`;
63
+ }
64
+ catch {
65
+ key = `https://${url}`;
66
+ }
67
+ const hit = cache.get(key);
68
+ if (hit)
69
+ return hit;
70
+ let policy;
71
+ try {
72
+ const resp = await fetcher.get(`${key}/robots.txt`);
73
+ policy = resp.status === 200 ? RobotsPolicy.parse(resp.text) : RobotsPolicy.allowAll();
74
+ }
75
+ catch (err) {
76
+ if (err instanceof FetchError && err.status !== null && err.status >= 500) {
77
+ policy = RobotsPolicy.disallowAll(); // "unavailable" is not "absent"
78
+ }
79
+ else if (err instanceof FetchError) {
80
+ policy = RobotsPolicy.allowAll(); // unreachable host: the web's allow default
81
+ }
82
+ else {
83
+ throw err;
84
+ }
85
+ }
86
+ cache.set(key, policy);
87
+ return policy;
88
+ }
89
+ export async function canFetch(fetcher, url, cache) {
90
+ return (await robotsFor(fetcher, url, cache)).canFetch(fetcher.userAgent, url);
91
+ }
92
+ //# sourceMappingURL=robots.js.map
@@ -0,0 +1,9 @@
1
+ /**
2
+ * weaver: the web in, documents out. Two doors: `./fetch` (polite HTTP, robots, the fetch ladder
3
+ * up to a browser) and `./video` (YouTube, any other video and podcasts read from a URL through
4
+ * yt-dlp and falcon; what a channel or search holds). What reads a file, bytes or text is
5
+ * falcon's (`@wrenautomation/falcon`). The source interface, registry, runner and ledger come in
6
+ * phase 3.5c of wren's `designs/2026-10-26-research-engine.md`.
7
+ */
8
+ export * from "./fetch/index.js";
9
+ export * from "./video/index.js";
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * weaver: the web in, documents out. Two doors: `./fetch` (polite HTTP, robots, the fetch ladder
3
+ * up to a browser) and `./video` (YouTube, any other video and podcasts read from a URL through
4
+ * yt-dlp and falcon; what a channel or search holds). What reads a file, bytes or text is
5
+ * falcon's (`@wrenautomation/falcon`). The source interface, registry, runner and ledger come in
6
+ * phase 3.5c of wren's `designs/2026-10-26-research-engine.md`.
7
+ */
8
+ export * from "./fetch/index.js";
9
+ export * from "./video/index.js";
10
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Video on the web: the readers over a URL (`read.ts`: YouTube by captions or by URL, any other
3
+ * video by yt-dlp plus falcon's `watch`, a podcast by its audio URL), `watch` over a URL or a
4
+ * file, and the list reader (`list.ts`: what a profile, channel or search holds). The reading
5
+ * itself is falcon's. Every caller passes its own keys and tools. No database here.
6
+ */
7
+ export * from "./list.js";
8
+ export * from "./read.js";
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Video on the web: the readers over a URL (`read.ts`: YouTube by captions or by URL, any other
3
+ * video by yt-dlp plus falcon's `watch`, a podcast by its audio URL), `watch` over a URL or a
4
+ * file, and the list reader (`list.ts`: what a profile, channel or search holds). The reading
5
+ * itself is falcon's. Every caller passes its own keys and tools. No database here.
6
+ */
7
+ export * from "./list.js";
8
+ export * from "./read.js";
9
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,27 @@
1
+ /** One entry of yt-dlp's playlist view (`--flat-playlist`): a TikTok profile, a YouTube search. */
2
+ export interface YtDlpEntry {
3
+ id?: string;
4
+ url?: string;
5
+ webpage_url?: string;
6
+ title?: string;
7
+ description?: string;
8
+ timestamp?: number;
9
+ duration?: number;
10
+ uploader?: string;
11
+ channel?: string;
12
+ thumbnails?: {
13
+ url?: string;
14
+ }[];
15
+ view_count?: number;
16
+ like_count?: number;
17
+ comment_count?: number;
18
+ }
19
+ /**
20
+ * yt-dlp's playlist view of `target` (a profile, a channel's videos, `ytsearchN:<words>`), no
21
+ * downloads: the entries with an id, in yt-dlp's order. `range` is 1-based and inclusive. Some
22
+ * sites answer only a home IP. `ytDlp` is the command, split on spaces (`uvx yt-dlp`).
23
+ */
24
+ export declare function flatPlaylist(ytDlp: string, target: string, range?: {
25
+ start: number;
26
+ end: number;
27
+ }): Promise<YtDlpEntry[]>;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * What a profile, channel or search holds, through yt-dlp's playlist view: titles, ids and
3
+ * counts, no downloads. The list reader beside the piece readers in `read.ts`.
4
+ */
5
+ import { runFor } from "@wrenautomation/falcon/video";
6
+ const LIST_MS = 180_000;
7
+ /**
8
+ * yt-dlp's playlist view of `target` (a profile, a channel's videos, `ytsearchN:<words>`), no
9
+ * downloads: the entries with an id, in yt-dlp's order. `range` is 1-based and inclusive. Some
10
+ * sites answer only a home IP. `ytDlp` is the command, split on spaces (`uvx yt-dlp`).
11
+ */
12
+ export async function flatPlaylist(ytDlp, target, range) {
13
+ const [cmd, ...pre] = ytDlp.split(/\s+/);
14
+ const { stdout } = await runFor(cmd, [
15
+ ...pre,
16
+ ...["-J", "--flat-playlist", "--no-warnings"],
17
+ ...(range
18
+ ? ["--playlist-start", String(range.start), "--playlist-end", String(range.end)]
19
+ : []),
20
+ target,
21
+ ], LIST_MS, { maxBuffer: 64 * 1024 * 1024 });
22
+ return (JSON.parse(stdout).entries ?? []).filter((e) => e.id);
23
+ }
24
+ //# sourceMappingURL=list.js.map
@@ -0,0 +1,68 @@
1
+ import { type VideoInfo, type VideoRead, type Watched, type WatchOptions } from "@wrenautomation/falcon/video";
2
+ /** What yt-dlp's `-j` prints for a download; only what the readers use. */
3
+ export interface VideoMeta extends Pick<VideoInfo, "id" | "title" | "channel" | "upload_date" | "webpage_url" | "duration" | "thumbnail"> {
4
+ extractor_key?: string;
5
+ description?: string;
6
+ uploader?: string;
7
+ width?: number;
8
+ height?: number;
9
+ }
10
+ /** yt-dlp's download of a reel or video: 540p on the short side, remuxed to mp4. */
11
+ export declare function download(url: string, ytDlp: string, dir: string): Promise<{
12
+ file: string;
13
+ info: VideoMeta;
14
+ }>;
15
+ /**
16
+ * Watch a video: a URL yt-dlp fetches first (`ytDlp` is the command, split on spaces:
17
+ * `uvx yt-dlp`), or a file on disk, which goes straight to falcon's `watch`. A URL's yt-dlp
18
+ * metadata comes back as `info`; a duration ffmpeg could not read falls back to it.
19
+ */
20
+ export declare function watch(input: string, opts: WatchOptions & {
21
+ ytDlp?: string;
22
+ }): Promise<Watched & {
23
+ info?: VideoMeta;
24
+ }>;
25
+ /**
26
+ * A YouTube video from its captions and chapters, through `yt-dlp` (`ytDlp` is the command,
27
+ * split on spaces: `uvx yt-dlp`). With Gemini keys, an `## On screen` section follows: what the
28
+ * video showed, read from the frames, since the captions carry only the speech.
29
+ */
30
+ export declare function readYoutube(url: string, opts: {
31
+ ytDlp: string;
32
+ geminiKeys?: readonly string[];
33
+ fetchFn?: typeof fetch;
34
+ }): Promise<VideoRead>;
35
+ /**
36
+ * A YouTube video read from its URL alone, no download and no home IP: Gemini hears the speech
37
+ * and reads the screen in `CLIP_S` clips (one clip when the length isn't known). The title,
38
+ * channel and thumbnail come from YouTube's oEmbed. For a server; a machine with yt-dlp and a
39
+ * home IP reads the captions instead (`readYoutube`).
40
+ */
41
+ export declare function readYoutubeByUrl(url: string, opts: {
42
+ geminiKeys: readonly string[];
43
+ fetchFn?: typeof fetch;
44
+ durationS?: number | null | undefined;
45
+ }): Promise<VideoRead>;
46
+ /**
47
+ * Any other video `yt-dlp` fetches (an Instagram reel, TikTok, X). No captions there, so falcon's
48
+ * `watch` hears the audio and reads the frames with Cohere (Gemini adds its read of the screen
49
+ * when a key works). The body holds the caption, then Speech, On screen and Cuts.
50
+ */
51
+ export declare function readVideo(url: string, opts: WatchOptions & {
52
+ ytDlp: string;
53
+ }): Promise<VideoRead>;
54
+ /**
55
+ * A podcast episode heard from its audio URL: downloaded to a temp file, then falcon's
56
+ * `hearAudio` (ffmpeg clips, Gemini transcribes each). `url` is the episode's page; `title` and
57
+ * `creator` are what its feed said. The id is a hash of the audio URL.
58
+ */
59
+ export declare function readAudio(audioUrl: string, opts: {
60
+ url: string;
61
+ title?: string | null;
62
+ creator?: string | null;
63
+ geminiKeys: readonly string[];
64
+ fetchFn?: typeof fetch;
65
+ ffmpeg?: string;
66
+ /** Seconds per clip; `CLIP_S` unless a test cuts a short tone. */
67
+ clipS?: number;
68
+ }): Promise<VideoRead>;
@@ -0,0 +1,263 @@
1
+ /**
2
+ * A video or podcast on the web read as one markdown document (falcon's `VideoRead`). Four
3
+ * readers: a YouTube video from its captions and chapters (yt-dlp) plus Gemini's read of the
4
+ * screen; a YouTube video from its URL alone (Gemini hears and reads it: no download, no home
5
+ * IP); any other video yt-dlp fetches (falcon's `watch`: Cohere hears the audio and reads the
6
+ * frames); a podcast episode from its audio URL (fetched, then falcon's `hearAudio`). Everything
7
+ * that takes a URL or spawns yt-dlp lives here; the reading over a file is falcon's. Every caller
8
+ * passes its own keys and tools. Nothing here touches a database.
9
+ */
10
+ import { createHash } from "node:crypto";
11
+ import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { askGemini, askShown, CLIP_S, captionsBody, clock, hearAudio, isoDate, real, renderWatched, runFor, SHOWN, TOOL_MS, watch as watchFile, } from "@wrenautomation/falcon/video";
15
+ /** yt-dlp's download of a reel or video: 540p on the short side, remuxed to mp4. */
16
+ export async function download(url, ytDlp, dir) {
17
+ const [cmd, ...pre] = ytDlp.split(/\s+/);
18
+ const { stdout } = await runFor(cmd, [
19
+ ...pre,
20
+ ...["-j", "--no-simulate", "--no-warnings", "--no-playlist"],
21
+ // `res` is the short side, so a vertical reel gets 540x960, not its 1080x1920.
22
+ ...["-S", "res:540", "-f", "bv*+ba/b", "--remux-video", "mp4"],
23
+ ...["-o", join(dir, "video.%(ext)s"), url],
24
+ ], TOOL_MS, { maxBuffer: 256 * 1024 * 1024 });
25
+ const file = (await readdir(dir)).find((f) => f.endsWith(".mp4"));
26
+ if (!file)
27
+ throw new Error(`${url}: yt-dlp wrote no mp4`);
28
+ return { file: join(dir, file), info: JSON.parse(stdout) };
29
+ }
30
+ /**
31
+ * Watch a video: a URL yt-dlp fetches first (`ytDlp` is the command, split on spaces:
32
+ * `uvx yt-dlp`), or a file on disk, which goes straight to falcon's `watch`. A URL's yt-dlp
33
+ * metadata comes back as `info`; a duration ffmpeg could not read falls back to it.
34
+ */
35
+ export async function watch(input, opts) {
36
+ if (!/^https?:\/\//.test(input))
37
+ return watchFile(input, opts);
38
+ const dir = await mkdtemp(join(tmpdir(), "weaver-watch-"));
39
+ try {
40
+ const { file, info } = await download(input, opts.ytDlp ?? "yt-dlp", dir);
41
+ const watched = await watchFile(file, opts);
42
+ return { ...watched, durationS: watched.durationS || info.duration || 0, info };
43
+ }
44
+ finally {
45
+ await rm(dir, { recursive: true, force: true });
46
+ }
47
+ }
48
+ const captionTracks = (info) => [info.subtitles?.en, info.automatic_captions?.en, info.automatic_captions?.["en-orig"]]
49
+ .flatMap((t) => t ?? [])
50
+ .filter((t) => t.ext === "json3");
51
+ const SHOWN_ASK = (start, paraphrase) => `${SHOWN(start, paraphrase)} Markdown. If nothing is shown, reply with the single word none.`;
52
+ /**
53
+ * What the video shows on screen (documents, slides, tables, templates), read by Gemini straight
54
+ * from the YouTube URL in `CLIP_S` clips. One markdown block per clip, timestamps absolute.
55
+ * Empty when `keys` is empty.
56
+ */
57
+ async function screenText(url, durationS, keys, fetchFn) {
58
+ if (!keys.length)
59
+ return "";
60
+ const clips = Array.from({ length: Math.ceil(durationS / CLIP_S) }, (_, i) => i * CLIP_S);
61
+ const read = async (start) => {
62
+ const end = Math.min(start + CLIP_S, durationS);
63
+ const body = (paraphrase) => ({
64
+ contents: [
65
+ {
66
+ parts: [
67
+ {
68
+ fileData: { fileUri: url },
69
+ videoMetadata: { startOffset: `${start}s`, endOffset: `${end}s`, fps: 0.5 },
70
+ },
71
+ { text: SHOWN_ASK(start, paraphrase) },
72
+ ],
73
+ },
74
+ ],
75
+ // Low resolution reads a shown document fine at a quarter of the tokens.
76
+ generationConfig: { mediaResolution: "MEDIA_RESOLUTION_LOW" },
77
+ });
78
+ const r = await askShown(body, keys, fetchFn, clock(start));
79
+ if ("text" in r)
80
+ return r.text;
81
+ // One unread clip must not lose the other 23: leave a marker and carry on.
82
+ console.warn(`gemini ${clock(start)}: unread, last ${r.unread}`);
83
+ return `[${clock(start)}] (not read: ${r.unread})`;
84
+ };
85
+ const out = [];
86
+ // 4 clips at a time; limits are per key, so go wider if it ever drags.
87
+ for (let i = 0; i < clips.length; i += 4) {
88
+ const texts = await Promise.all(clips.slice(i, i + 4).map(read));
89
+ out.push(...texts.filter(real));
90
+ }
91
+ return out.join("\n\n");
92
+ }
93
+ /**
94
+ * A YouTube video from its captions and chapters, through `yt-dlp` (`ytDlp` is the command,
95
+ * split on spaces: `uvx yt-dlp`). With Gemini keys, an `## On screen` section follows: what the
96
+ * video showed, read from the frames, since the captions carry only the speech.
97
+ */
98
+ export async function readYoutube(url, opts) {
99
+ const fetchFn = opts.fetchFn ?? fetch;
100
+ const [cmd, ...pre] = opts.ytDlp.split(/\s+/);
101
+ const { stdout } = await runFor(cmd, [...pre, "-J", "--no-warnings", "--no-playlist", url], TOOL_MS, { maxBuffer: 256 * 1024 * 1024 });
102
+ const info = JSON.parse(stdout);
103
+ const tracks = captionTracks(info);
104
+ if (!tracks.length)
105
+ throw new Error(`${url}: no English captions (manual or auto) to read`);
106
+ // YouTube throttles single caption URLs (429); the same text sits behind several, so try each.
107
+ let json;
108
+ let last = 0;
109
+ for (const t of tracks) {
110
+ const res = await fetchFn(t.url);
111
+ last = res.status;
112
+ if (!res.ok)
113
+ continue;
114
+ json = (await res.json());
115
+ break;
116
+ }
117
+ if (!json)
118
+ throw new Error(`${url}: captions fetch HTTP ${last} on all ${tracks.length} tracks`);
119
+ let body = captionsBody(info, json);
120
+ const screen = await screenText(url, info.duration ?? 0, opts.geminiKeys ?? [], fetchFn);
121
+ if (screen)
122
+ body += `\n## On screen\n\nWhat the video showed (documents, slides, tables, visuals), read from the frames; the transcript above has only the speech.\n\n${screen}\n`;
123
+ const uploaded = isoDate(info.upload_date);
124
+ return {
125
+ kind: "youtube",
126
+ id: info.id,
127
+ title: info.title,
128
+ url: info.webpage_url ?? `https://www.youtube.com/watch?v=${info.id}`,
129
+ ...(info.channel ? { channel: info.channel } : {}),
130
+ ...(uploaded ? { uploaded } : {}),
131
+ ...(info.duration ? { duration: Math.round(info.duration) } : {}),
132
+ ...(info.thumbnail ? { thumbnail: info.thumbnail } : {}),
133
+ body,
134
+ };
135
+ }
136
+ const SPEECH = "Transcribe the speech in this video: every word spoken, in paragraphs, each starting with its [m:ss] video time. Fix only obvious mishearings. Markdown, no heading. If nobody speaks, reply with the single word none.";
137
+ /**
138
+ * A YouTube video read from its URL alone, no download and no home IP: Gemini hears the speech
139
+ * and reads the screen in `CLIP_S` clips (one clip when the length isn't known). The title,
140
+ * channel and thumbnail come from YouTube's oEmbed. For a server; a machine with yt-dlp and a
141
+ * home IP reads the captions instead (`readYoutube`).
142
+ */
143
+ export async function readYoutubeByUrl(url, opts) {
144
+ const fetchFn = opts.fetchFn ?? fetch;
145
+ const id = /(?:v=|youtu\.be\/|shorts\/|live\/)([\w-]{11})/.exec(url)?.[1];
146
+ if (!id)
147
+ throw new Error(`${url}: not a YouTube video`);
148
+ const page = `https://www.youtube.com/watch?v=${id}`;
149
+ const meta = (await fetchFn(`https://www.youtube.com/oembed?format=json&url=${encodeURIComponent(page)}`)
150
+ .then((r) => (r.ok ? r.json() : {}))
151
+ .catch(() => ({})));
152
+ const duration = opts.durationS ?? 0;
153
+ const starts = duration
154
+ ? Array.from({ length: Math.ceil(duration / CLIP_S) }, (_, i) => i * CLIP_S)
155
+ : [null];
156
+ const readClip = async (start) => {
157
+ const part = {
158
+ fileData: { fileUri: page },
159
+ ...(start === null
160
+ ? {}
161
+ : {
162
+ videoMetadata: {
163
+ startOffset: `${start}s`,
164
+ endOffset: `${Math.min(start + CLIP_S, duration)}s`,
165
+ },
166
+ }),
167
+ };
168
+ const ask = (text) => ({
169
+ contents: [{ parts: [part, { text }] }],
170
+ generationConfig: { mediaResolution: "MEDIA_RESOLUTION_LOW" },
171
+ });
172
+ const label = `${id} ${clock(start ?? 0)}`;
173
+ const [said, screen] = await Promise.all([
174
+ askGemini(ask(SPEECH), opts.geminiKeys, fetchFn, `${label} speech`),
175
+ askShown((paraphrase) => ask(SHOWN_ASK(start ?? 0, paraphrase)), opts.geminiKeys, fetchFn, `${label} screen`),
176
+ ]);
177
+ if ("unread" in said)
178
+ throw new Error(`gemini ${label} speech: unread, last ${said.unread}`);
179
+ return { said: said.text, screen: "text" in screen ? screen.text : "" };
180
+ };
181
+ const clips = [];
182
+ // 4 clips at a time, kept in order: a key fleet spreads them.
183
+ for (let i = 0; i < starts.length; i += 4)
184
+ clips.push(...(await Promise.all(starts.slice(i, i + 4).map(readClip))));
185
+ const speech = clips.map((c) => c.said).filter(real);
186
+ const shown = clips.map((c) => c.screen).filter(real);
187
+ return {
188
+ kind: "youtube",
189
+ id,
190
+ title: meta.title?.trim() || page,
191
+ url: page,
192
+ ...(meta.author_name ? { channel: meta.author_name } : {}),
193
+ ...(duration ? { duration: Math.round(duration) } : {}),
194
+ ...(meta.thumbnail_url ? { thumbnail: meta.thumbnail_url } : {}),
195
+ body: `## Speech\n\n${speech.join("\n\n") || "(no speech)"}\n${shown.length ? `\n## On screen\n\n${shown.join("\n\n")}\n` : ""}`,
196
+ };
197
+ }
198
+ /**
199
+ * Any other video `yt-dlp` fetches (an Instagram reel, TikTok, X). No captions there, so falcon's
200
+ * `watch` hears the audio and reads the frames with Cohere (Gemini adds its read of the screen
201
+ * when a key works). The body holds the caption, then Speech, On screen and Cuts.
202
+ */
203
+ export async function readVideo(url, opts) {
204
+ const watched = await watch(url, opts);
205
+ const info = watched.info;
206
+ const caption = info.description?.trim() ?? "";
207
+ const by = info.channel ?? info.uploader;
208
+ const uploaded = isoDate(info.upload_date);
209
+ const duration = info.duration ?? watched.durationS;
210
+ return {
211
+ kind: (info.extractor_key ?? "video").toLowerCase(),
212
+ id: info.id,
213
+ title: caption.split("\n")[0]?.slice(0, 100) || info.title,
214
+ url: info.webpage_url ?? url,
215
+ ...(by ? { channel: by } : {}),
216
+ ...(uploaded ? { uploaded } : {}),
217
+ ...(duration ? { duration: Math.round(duration) } : {}),
218
+ ...(info.thumbnail ? { thumbnail: info.thumbnail } : {}),
219
+ body: `${caption ? `## Caption\n\n${caption}\n\n` : ""}${renderWatched(watched)}\n`,
220
+ };
221
+ }
222
+ const AUDIO_MAX = 600_000_000;
223
+ /**
224
+ * A podcast episode heard from its audio URL: downloaded to a temp file, then falcon's
225
+ * `hearAudio` (ffmpeg clips, Gemini transcribes each). `url` is the episode's page; `title` and
226
+ * `creator` are what its feed said. The id is a hash of the audio URL.
227
+ */
228
+ export async function readAudio(audioUrl, opts) {
229
+ if (!opts.geminiKeys.length)
230
+ throw new Error(`${audioUrl}: needs Gemini keys to transcribe`);
231
+ const fetchFn = opts.fetchFn ?? fetch;
232
+ const dir = await mkdtemp(join(tmpdir(), "weaver-audio-"));
233
+ try {
234
+ const res = await fetchFn(audioUrl, { signal: AbortSignal.timeout(600_000) });
235
+ if (!res.ok || !res.body)
236
+ throw new Error(`${audioUrl}: HTTP ${res.status}`);
237
+ if (Number(res.headers.get("content-length") ?? 0) > AUDIO_MAX)
238
+ throw new Error(`${audioUrl}: over ${AUDIO_MAX / 1e6} MB`);
239
+ const whole = join(dir, "episode");
240
+ await writeFile(whole, Buffer.from(await res.arrayBuffer()));
241
+ const id = createHash("sha1").update(audioUrl).digest("hex").slice(0, 12);
242
+ const heard = await hearAudio(whole, {
243
+ geminiKeys: opts.geminiKeys,
244
+ fetchFn,
245
+ ...(opts.ffmpeg ? { ffmpeg: opts.ffmpeg } : {}),
246
+ ...(opts.clipS ? { clipS: opts.clipS } : {}),
247
+ label: id,
248
+ });
249
+ return {
250
+ kind: "podcast",
251
+ id,
252
+ title: opts.title?.trim() || opts.url,
253
+ url: opts.url,
254
+ ...(opts.creator ? { channel: opts.creator } : {}),
255
+ ...(heard.duration ? { duration: heard.duration } : {}),
256
+ body: heard.speech,
257
+ };
258
+ }
259
+ finally {
260
+ await rm(dir, { recursive: true, force: true });
261
+ }
262
+ }
263
+ //# sourceMappingURL=read.js.map
package/package.json CHANGED
@@ -1,6 +1,61 @@
1
1
  {
2
2
  "name": "@wrenautomation/weaver",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "The web in, documents out. Polite HTTP fetching with robots and a fetch ladder up to a browser; YouTube, any other video and podcasts read from a URL through yt-dlp and @wrenautomation/falcon; what a channel or search holds. The source interface, runner and ledger of Wren's research engine come next.",
5
+ "license": "PolyForm-Shield-1.0.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/wrenautomation/weaver.git"
9
+ },
10
+ "type": "module",
11
+ "files": [
12
+ "dist",
13
+ "!dist/**/*.test.*",
14
+ "!dist/**/*.map"
15
+ ],
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "import": "./dist/index.js"
23
+ },
24
+ "./fetch": {
25
+ "types": "./dist/fetch/index.d.ts",
26
+ "import": "./dist/fetch/index.js"
27
+ },
28
+ "./video": {
29
+ "types": "./dist/video/index.d.ts",
30
+ "import": "./dist/video/index.js"
31
+ }
32
+ },
33
+ "bin": {
34
+ "weaver": "dist/app/cli.js"
35
+ },
36
+ "engines": {
37
+ "node": ">=24"
38
+ },
39
+ "packageManager": "pnpm@9.15.0",
40
+ "scripts": {
41
+ "prepare": "git config core.hooksPath .githooks || true",
42
+ "lint": "biome check --error-on-warnings .",
43
+ "lint:fix": "biome check --write .",
44
+ "typecheck": "tsc --noEmit",
45
+ "build": "rm -rf dist && tsc && chmod +x dist/app/cli.js",
46
+ "test": "vitest run",
47
+ "gates": "lockf -t 900 /tmp/wren_automation-checks.lock sh -c 'pnpm lint && pnpm typecheck && pnpm test'",
48
+ "prepublishOnly": "pnpm build"
49
+ },
50
+ "dependencies": {
51
+ "@wrenautomation/falcon": "0.3.0",
52
+ "commander": "15.0.0",
53
+ "robots-parser": "3.0.1"
54
+ },
55
+ "devDependencies": {
56
+ "@biomejs/biome": "2.5.14",
57
+ "@types/node": "22.20.3",
58
+ "typescript": "5.9.3",
59
+ "vitest": "5.0.1"
60
+ }
61
+ }