tidyread 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 parzival1l
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # tidyread
2
+
3
+ Clean a web article once, then hand it to Instapaper pre-chewed — and build a
4
+ Kobo-ready EPUB while you're at it.
5
+
6
+ ## Why
7
+
8
+ Instapaper's parser re-fetches every URL you save and often mangles the page:
9
+ lazy-loaded images vanish, code blocks reflow into prose, figures lose their
10
+ captions. tidyread extracts and repairs the article locally, serves the clean
11
+ copy on a short-lived public URL, and saves *that* to Instapaper. Instapaper's
12
+ parser reads a page with nothing left to get wrong. The URL dies when the
13
+ process exits.
14
+
15
+ It also writes an EPUB (images embedded, e-ink stylesheet) and a Kobo-native
16
+ KEPUB, so you can skip Instapaper entirely and sideload.
17
+
18
+ ## Install
19
+
20
+ ```sh
21
+ npm install -g tidyread
22
+ brew install cloudflared # short-lived tunnel for the Instapaper handoff
23
+ brew install kepubify # optional: Kobo-native output
24
+ ```
25
+
26
+ ## Use
27
+
28
+ ```sh
29
+ # clean + save to Instapaper via your logged-in browser
30
+ tidyread <url> --open
31
+
32
+ # clean + save with Instapaper credentials (Simple API)
33
+ INSTAPAPER_USERNAME=you INSTAPAPER_PASSWORD=pw tidyread <url>
34
+
35
+ # build files only, no tunnel, nothing leaves the machine
36
+ tidyread <url> --no-send
37
+
38
+ # choose the output directory (default: ~/Kobo/inbox)
39
+ tidyread <url> -o ~/Desktop
40
+ ```
41
+
42
+ Outputs per article: `<slug>.html`, `<slug>.epub`, `<slug>.kepub.epub`.
43
+
44
+ ## How it works
45
+
46
+ 1. **extract** — [defuddle](https://github.com/kepano/defuddle) finds the
47
+ article; @mozilla/readability catches pages defuddle can't read.
48
+ 2. **normalise** — real image URLs recovered from lazy-load attributes and
49
+ srcset, links made absolute, tracking pixels dropped, bare `<pre>` wrapped
50
+ in `<code>`, empty wrappers collapsed.
51
+ 3. **serve** — the cleaned HTML sits on localhost behind a 128-bit random path.
52
+ 4. **tunnel** — `cloudflared` quick tunnel exposes it, no account needed.
53
+ 5. **deliver** — the tunnel URL goes to Instapaper (Simple API or browser).
54
+ After Instapaper fetches, the tunnel closes and the URL stops resolving.
55
+ 6. **epub** — images downloaded and embedded, e-ink stylesheet applied,
56
+ kepubify converts for Kobo page numbers and reading stats.
57
+
58
+ ## Boundaries
59
+
60
+ - The public copy is one article, behind an unguessable URL, alive for
61
+ seconds, for your own reading. tidyread refuses to be a mirror: the server
62
+ dies with the process.
63
+ - Article text is never rewritten. Attribution and the original link sit at
64
+ the top of every artifact.
65
+
66
+ ## License
67
+
68
+ MIT
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { mkdir, writeFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+ import { extract } from "./extract.js";
7
+ import { renderHtml, readingMinutes } from "./render.js";
8
+ import { serveOnce } from "./serve.js";
9
+ import { openTunnel } from "./tunnel.js";
10
+ import { buildEpub, toKepub } from "./epub.js";
11
+ import { addByApi, addByBrowser, credentialsFromEnv } from "./instapaper.js";
12
+ const DEFAULT_OUT = join(homedir(), "Kobo", "inbox");
13
+ const FETCH_WAIT_MS = 90_000;
14
+ function slug(title) {
15
+ return (title
16
+ .toLowerCase()
17
+ .replace(/[^a-z0-9]+/g, "-")
18
+ .replace(/^-+|-+$/g, "")
19
+ .slice(0, 60) || "article");
20
+ }
21
+ function log(step, detail = "") {
22
+ process.stderr.write(` ${step.padEnd(12)} ${detail}\n`);
23
+ }
24
+ const program = new Command();
25
+ program
26
+ .name("tidyread")
27
+ .description("Clean an article once, then hand it to Instapaper pre-chewed " +
28
+ "and build a Kobo-ready EPUB.")
29
+ .version("0.1.0");
30
+ program
31
+ .argument("<url>", "article URL")
32
+ .option("-o, --out <dir>", "output directory", DEFAULT_OUT)
33
+ .option("--no-epub", "skip the EPUB and KEPUB build")
34
+ .option("--no-send", "build files only, never open a tunnel")
35
+ .option("--open", "save via the browser instead of the Simple API")
36
+ .option("--keep-open", "leave the tunnel up until you press Ctrl-C")
37
+ .action(async (url, opts) => {
38
+ const outDir = resolve(opts.out);
39
+ await mkdir(outDir, { recursive: true });
40
+ log("fetching", url);
41
+ const article = await extract(url);
42
+ const name = slug(article.title);
43
+ log("extracted", `"${article.title}" · ${readingMinutes(article.text)} min · ` +
44
+ `${article.images.length} images`);
45
+ const html = renderHtml(article);
46
+ const htmlPath = join(outDir, `${name}.html`);
47
+ await writeFile(htmlPath, html, "utf8");
48
+ log("wrote", htmlPath);
49
+ if (opts.epub) {
50
+ const epubPath = await buildEpub(article, join(outDir, `${name}.epub`));
51
+ log("wrote", epubPath);
52
+ const kepubPath = await toKepub(epubPath);
53
+ if (kepubPath)
54
+ log("wrote", kepubPath);
55
+ else
56
+ log("skipped", "kepubify not found — install with: brew install kepubify");
57
+ }
58
+ if (!opts.send)
59
+ return;
60
+ const hosted = await serveOnce(html);
61
+ const tunnel = await openTunnel(hosted.port);
62
+ const publicUrl = `${tunnel.origin}${hosted.path}`;
63
+ const teardown = async () => {
64
+ await tunnel.close();
65
+ await hosted.close();
66
+ };
67
+ process.once("SIGINT", async () => {
68
+ await teardown();
69
+ process.exit(130);
70
+ });
71
+ try {
72
+ log("tunnel", publicUrl);
73
+ const creds = credentialsFromEnv();
74
+ if (opts.open || !creds) {
75
+ addByBrowser(publicUrl);
76
+ log("browser", "opened Instapaper save page — confirm it in the tab");
77
+ }
78
+ else {
79
+ await addByApi(publicUrl, article.title, creds);
80
+ log("instapaper", `saved as "${article.title}"`);
81
+ }
82
+ const timeout = new Promise((r) => setTimeout(() => r(null), FETCH_WAIT_MS));
83
+ const hit = await Promise.race([hosted.fetched, timeout]);
84
+ if (hit)
85
+ log("fetched", `by ${hit.userAgent.slice(0, 60)}`);
86
+ else
87
+ log("warning", `nothing fetched in ${FETCH_WAIT_MS / 1000}s — the save may not have landed`);
88
+ if (opts.keepOpen) {
89
+ log("holding", "tunnel open — press Ctrl-C to close");
90
+ await new Promise(() => { });
91
+ }
92
+ }
93
+ finally {
94
+ if (!opts.keepOpen) {
95
+ await teardown();
96
+ log("closed", "tunnel down, URL no longer resolves");
97
+ }
98
+ }
99
+ });
100
+ program.parseAsync(process.argv).catch((err) => {
101
+ process.stderr.write(`\ntidyread: ${err.message}\n`);
102
+ process.exit(1);
103
+ });
104
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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"}
package/dist/epub.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { basename } from "node:path";
2
+ import type { Article } from "./types.js";
3
+ /** Write a minimal, valid EPUB 3 for the article. */
4
+ export declare function buildEpub(article: Article, outPath: string): Promise<string>;
5
+ /** Convert to Kobo's EPUB dialect, for real page numbers and reading stats. */
6
+ export declare function toKepub(epubPath: string): Promise<string | null>;
7
+ export { basename };
package/dist/epub.js ADDED
@@ -0,0 +1,164 @@
1
+ import JSZip from "jszip";
2
+ import { parseHTML } from "linkedom";
3
+ import { writeFile } from "node:fs/promises";
4
+ import { spawn } from "node:child_process";
5
+ import { randomUUID } from "node:crypto";
6
+ import { basename, extname } from "node:path";
7
+ import { readingMinutes } from "./render.js";
8
+ /** Typography tuned for a small, slow, greyscale screen. */
9
+ const STYLESHEET = `
10
+ body { margin: 0 1.1em; line-height: 1.5; text-align: justify;
11
+ hyphens: auto; -webkit-hyphens: auto; }
12
+ h1 { font-size: 1.5em; line-height: 1.25; text-align: left; margin: 1em 0 .2em; }
13
+ h2 { font-size: 1.2em; text-align: left; page-break-after: avoid; }
14
+ h3 { font-size: 1.05em; text-align: left; page-break-after: avoid; }
15
+ p { margin: 0 0 .7em; widows: 2; orphans: 2; }
16
+ img { max-width: 100%; height: auto; page-break-inside: avoid; margin: 1em auto; }
17
+ figcaption, .credit { font-size: .8em; font-style: italic; text-align: center; }
18
+ pre { white-space: pre-wrap; word-wrap: break-word; font-size: .75em;
19
+ background: #f2f2f2; padding: .5em; text-align: left; }
20
+ code { font-family: monospace; }
21
+ blockquote { margin: 1em 1.2em; font-style: italic; }
22
+ table { border-collapse: collapse; width: 100%; font-size: .8em; }
23
+ td, th { border: 1px solid #999; padding: .3em; }
24
+ .credit { margin-bottom: 2em; }
25
+ `;
26
+ function escape(value) {
27
+ return value
28
+ .replace(/&/g, "&amp;")
29
+ .replace(/</g, "&lt;")
30
+ .replace(/>/g, "&gt;")
31
+ .replace(/"/g, "&quot;");
32
+ }
33
+ const MIME = {
34
+ ".jpg": "image/jpeg",
35
+ ".jpeg": "image/jpeg",
36
+ ".png": "image/png",
37
+ ".gif": "image/gif",
38
+ ".webp": "image/webp",
39
+ ".svg": "image/svg+xml",
40
+ };
41
+ /**
42
+ * Download body images so the book reads offline.
43
+ *
44
+ * Anything that fails is dropped rather than fatal: a missing figure should
45
+ * not cost you the article.
46
+ */
47
+ async function collectImages(urls) {
48
+ const assets = [];
49
+ const map = new Map();
50
+ await Promise.all([...new Set(urls)].map(async (url, index) => {
51
+ try {
52
+ const res = await fetch(url);
53
+ if (!res.ok)
54
+ return;
55
+ const bytes = new Uint8Array(await res.arrayBuffer());
56
+ const type = (res.headers.get("content-type") ?? "").split(";")[0];
57
+ let ext = extname(new URL(url).pathname).toLowerCase() ||
58
+ Object.entries(MIME).find(([, m]) => m === type)?.[0] ||
59
+ ".jpg";
60
+ if (!MIME[ext])
61
+ ext = ".jpg";
62
+ const href = `images/img${index}${ext}`;
63
+ assets.push({ id: `img${index}`, href, mime: MIME[ext], bytes });
64
+ map.set(url, href);
65
+ }
66
+ catch {
67
+ /* skip unreachable image */
68
+ }
69
+ }));
70
+ return { assets, map };
71
+ }
72
+ /** Point <img> at the copies bundled in the book; drop the rest. */
73
+ function rewriteImages(html, map) {
74
+ const { document } = parseHTML("<!DOCTYPE html><html><body></body></html>");
75
+ const root = document.createElement("div");
76
+ root.innerHTML = html;
77
+ for (const img of Array.from(root.querySelectorAll("img"))) {
78
+ const local = map.get(img.getAttribute("src") ?? "");
79
+ if (local)
80
+ img.setAttribute("src", local);
81
+ else
82
+ img.remove();
83
+ }
84
+ return root.innerHTML;
85
+ }
86
+ /** Write a minimal, valid EPUB 3 for the article. */
87
+ export async function buildEpub(article, outPath) {
88
+ const { assets, map } = await collectImages(article.images);
89
+ const body = rewriteImages(article.html, map);
90
+ const uuid = randomUUID();
91
+ const modified = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
92
+ const credit = [article.siteName, article.byline].filter(Boolean).join(" — ");
93
+ const chapter = `<?xml version="1.0" encoding="utf-8"?>
94
+ <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="${escape(article.lang)}">
95
+ <head><meta charset="utf-8"/><title>${escape(article.title)}</title>
96
+ <link rel="stylesheet" type="text/css" href="style.css"/></head>
97
+ <body>
98
+ <section epub:type="chapter">
99
+ <h1>${escape(article.title)}</h1>
100
+ <p class="credit">${credit ? `${escape(credit)} · ` : ""}${readingMinutes(article.text)} min read<br/>
101
+ <a href="${escape(article.sourceUrl)}">${escape(article.sourceUrl)}</a></p>
102
+ ${body}
103
+ </section>
104
+ </body></html>`;
105
+ const nav = `<?xml version="1.0" encoding="utf-8"?>
106
+ <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="${escape(article.lang)}">
107
+ <head><meta charset="utf-8"/><title>Contents</title></head>
108
+ <body><nav epub:type="toc" id="toc"><h1>Contents</h1>
109
+ <ol><li><a href="chapter.xhtml">${escape(article.title)}</a></li></ol>
110
+ </nav></body></html>`;
111
+ const manifest = assets
112
+ .map((a) => `<item id="${a.id}" href="${a.href}" media-type="${a.mime}"/>`)
113
+ .join("\n ");
114
+ const opf = `<?xml version="1.0" encoding="utf-8"?>
115
+ <package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="bookid">
116
+ <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
117
+ <dc:identifier id="bookid">urn:uuid:${uuid}</dc:identifier>
118
+ <dc:title>${escape(article.title)}</dc:title>
119
+ <dc:language>${escape(article.lang)}</dc:language>
120
+ <dc:creator>${escape(article.byline ?? article.siteName ?? "Unknown")}</dc:creator>
121
+ <dc:source>${escape(article.sourceUrl)}</dc:source>
122
+ <meta property="dcterms:modified">${modified}</meta>
123
+ </metadata>
124
+ <manifest>
125
+ <item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
126
+ <item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml"/>
127
+ <item id="style" href="style.css" media-type="text/css"/>
128
+ ${manifest}
129
+ </manifest>
130
+ <spine><itemref idref="chapter"/></spine>
131
+ </package>`;
132
+ const zip = new JSZip();
133
+ zip.file("mimetype", "application/epub+zip", { compression: "STORE" });
134
+ zip.file("META-INF/container.xml", `<?xml version="1.0" encoding="utf-8"?>
135
+ <container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
136
+ <rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
137
+ </container>`);
138
+ zip.file("OEBPS/content.opf", opf);
139
+ zip.file("OEBPS/nav.xhtml", nav);
140
+ zip.file("OEBPS/chapter.xhtml", chapter);
141
+ zip.file("OEBPS/style.css", STYLESHEET);
142
+ for (const asset of assets)
143
+ zip.file(`OEBPS/${asset.href}`, asset.bytes);
144
+ const buffer = await zip.generateAsync({
145
+ type: "nodebuffer",
146
+ compression: "DEFLATE",
147
+ });
148
+ await writeFile(outPath, buffer);
149
+ return outPath;
150
+ }
151
+ /** Convert to Kobo's EPUB dialect, for real page numbers and reading stats. */
152
+ export async function toKepub(epubPath) {
153
+ const outPath = epubPath.replace(/\.epub$/, ".kepub.epub");
154
+ const ok = await new Promise((resolve) => {
155
+ const child = spawn("kepubify", ["-o", outPath, epubPath], {
156
+ stdio: "ignore",
157
+ });
158
+ child.once("error", () => resolve(false));
159
+ child.once("exit", (code) => resolve(code === 0));
160
+ });
161
+ return ok ? outPath : null;
162
+ }
163
+ export { basename };
164
+ //# sourceMappingURL=epub.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"epub.js","sourceRoot":"","sources":["../src/epub.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,13 @@
1
+ import type { Article } from "./types.js";
2
+ /** Download a page as text, following redirects. */
3
+ export declare function fetchPage(url: string): Promise<{
4
+ html: string;
5
+ finalUrl: string;
6
+ }>;
7
+ /**
8
+ * Turn a URL into a clean Article.
9
+ *
10
+ * Defuddle extracts; Readability catches what it misses; normalise repairs
11
+ * what any extractor leaves behind (lazy images, relative URLs, bare <pre>).
12
+ */
13
+ export declare function extract(url: string): Promise<Article>;
@@ -0,0 +1,94 @@
1
+ import { parseHTML } from "linkedom";
2
+ import { Readability } from "@mozilla/readability";
3
+ import { Defuddle } from "defuddle/node";
4
+ import { normalise } from "./normalise.js";
5
+ const UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
6
+ "(KHTML, like Gecko) Chrome/125.0 Safari/537.36";
7
+ /** Below this many characters, an extraction is judged a failure. */
8
+ const MIN_TEXT = 500;
9
+ /** Download a page as text, following redirects. */
10
+ export async function fetchPage(url) {
11
+ const res = await fetch(url, {
12
+ redirect: "follow",
13
+ headers: { "user-agent": UA, accept: "text/html,application/xhtml+xml" },
14
+ });
15
+ if (!res.ok) {
16
+ throw new Error(`fetch failed: ${res.status} ${res.statusText} for ${url}`);
17
+ }
18
+ return { html: await res.text(), finalUrl: res.url || url };
19
+ }
20
+ function textLength(html) {
21
+ return html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().length;
22
+ }
23
+ /**
24
+ * Primary extractor. Defuddle keeps structure Readability flattens:
25
+ * code blocks, footnotes, math, and tables survive it.
26
+ */
27
+ async function byDefuddle(html, url) {
28
+ try {
29
+ const result = await Defuddle(html, url);
30
+ if (!result?.content || textLength(result.content) < MIN_TEXT)
31
+ return null;
32
+ return {
33
+ content: result.content,
34
+ title: result.title || null,
35
+ byline: result.author || null,
36
+ siteName: result.site || null,
37
+ excerpt: result.description || null,
38
+ lang: result.language || null,
39
+ };
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ /** Fallback extractor for pages Defuddle cannot read. */
46
+ function byReadability(html, url) {
47
+ const { document } = parseHTML(html);
48
+ const lang = document.documentElement?.getAttribute("lang")?.trim() || null;
49
+ const parsed = new Readability(document, {
50
+ charThreshold: 250,
51
+ keepClasses: false,
52
+ }).parse();
53
+ if (!parsed?.content || textLength(parsed.content) < MIN_TEXT)
54
+ return null;
55
+ return {
56
+ content: parsed.content,
57
+ title: parsed.title || null,
58
+ byline: parsed.byline || null,
59
+ siteName: parsed.siteName || null,
60
+ excerpt: parsed.excerpt || null,
61
+ lang,
62
+ };
63
+ }
64
+ /**
65
+ * Turn a URL into a clean Article.
66
+ *
67
+ * Defuddle extracts; Readability catches what it misses; normalise repairs
68
+ * what any extractor leaves behind (lazy images, relative URLs, bare <pre>).
69
+ */
70
+ export async function extract(url) {
71
+ const { html, finalUrl } = await fetchPage(url);
72
+ const picked = (await byDefuddle(html, finalUrl)) ?? byReadability(html, finalUrl);
73
+ if (!picked) {
74
+ throw new Error(`could not find an article body at ${finalUrl}. ` +
75
+ `The page may render its content with JavaScript.`);
76
+ }
77
+ const { html: body, images } = normalise(picked.content, finalUrl);
78
+ const text = picked.content
79
+ .replace(/<[^>]+>/g, " ")
80
+ .replace(/\s+/g, " ")
81
+ .trim();
82
+ return {
83
+ sourceUrl: finalUrl,
84
+ title: (picked.title || "Untitled").trim(),
85
+ byline: picked.byline?.trim() || null,
86
+ siteName: picked.siteName || new URL(finalUrl).hostname,
87
+ excerpt: picked.excerpt?.trim() || null,
88
+ html: body,
89
+ text,
90
+ images,
91
+ lang: (picked.lang || "en").split(/[_-]/)[0] || "en",
92
+ };
93
+ }
94
+ //# sourceMappingURL=extract.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"extract.js","sourceRoot":"","sources":["../src/extract.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,20 @@
1
+ export interface Credentials {
2
+ username: string;
3
+ password: string;
4
+ }
5
+ /** Read credentials from the environment, if the user set them. */
6
+ export declare function credentialsFromEnv(): Credentials | null;
7
+ /**
8
+ * Add a URL with the Simple API.
9
+ *
10
+ * This endpoint needs no developer approval, unlike the Full API. It accepts
11
+ * only a URL, which is all this tool needs: the URL already points at the
12
+ * cleaned payload.
13
+ */
14
+ export declare function addByApi(url: string, title: string, creds: Credentials): Promise<void>;
15
+ /**
16
+ * Open the save page in the browser, using the existing session.
17
+ * Set TIDYREAD_BROWSER (e.g. "Safari") to pick an app; otherwise the
18
+ * system default browser opens it.
19
+ */
20
+ export declare function addByBrowser(url: string): string;
@@ -0,0 +1,50 @@
1
+ import { spawn } from "node:child_process";
2
+ const ADD_URL = "https://www.instapaper.com/api/add";
3
+ const SAVE_URL = "https://www.instapaper.com/hello2";
4
+ /** Read credentials from the environment, if the user set them. */
5
+ export function credentialsFromEnv() {
6
+ const username = process.env.INSTAPAPER_USERNAME?.trim();
7
+ if (!username)
8
+ return null;
9
+ return { username, password: process.env.INSTAPAPER_PASSWORD ?? "" };
10
+ }
11
+ /**
12
+ * Add a URL with the Simple API.
13
+ *
14
+ * This endpoint needs no developer approval, unlike the Full API. It accepts
15
+ * only a URL, which is all this tool needs: the URL already points at the
16
+ * cleaned payload.
17
+ */
18
+ export async function addByApi(url, title, creds) {
19
+ const body = new URLSearchParams({
20
+ username: creds.username,
21
+ password: creds.password,
22
+ url,
23
+ title,
24
+ });
25
+ const res = await fetch(ADD_URL, {
26
+ method: "POST",
27
+ headers: { "content-type": "application/x-www-form-urlencoded" },
28
+ body,
29
+ });
30
+ if (res.status === 201)
31
+ return;
32
+ if (res.status === 403) {
33
+ throw new Error("Instapaper rejected those credentials (403). If you sign in with " +
34
+ "Google or Apple your account has no password, so use --open instead.");
35
+ }
36
+ throw new Error(`Instapaper returned ${res.status} ${res.statusText}`);
37
+ }
38
+ /**
39
+ * Open the save page in the browser, using the existing session.
40
+ * Set TIDYREAD_BROWSER (e.g. "Safari") to pick an app; otherwise the
41
+ * system default browser opens it.
42
+ */
43
+ export function addByBrowser(url) {
44
+ const saveUrl = `${SAVE_URL}?url=${encodeURIComponent(url)}`;
45
+ const app = process.env.TIDYREAD_BROWSER?.trim();
46
+ const args = app ? ["-a", app, saveUrl] : [saveUrl];
47
+ spawn("open", args, { stdio: "ignore", detached: true }).unref();
48
+ return saveUrl;
49
+ }
50
+ //# sourceMappingURL=instapaper.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"instapaper.js","sourceRoot":"","sources":["../src/instapaper.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAE3C,MAAM,OAAO,GAAG,oCAAoC,CAAC;AACrD,MAAM,QAAQ,GAAG,mCAAmC,CAAC;AAOrD,mEAAmE;AACnE,MAAM,UAAU,kBAAkB;IAChC,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,mBAAmB,EAAE,IAAI,EAAE,CAAC;IACzD,IAAI,CAAC,QAAQ;QAAE,OAAO,IAAI,CAAC;IAC3B,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,OAAO,CAAC,GAAG,CAAC,mBAAmB,IAAI,EAAE,EAAE,CAAC;AACvE,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,QAAQ,CAC5B,GAAW,EACX,KAAa,EACb,KAAkB;IAElB,MAAM,IAAI,GAAG,IAAI,eAAe,CAAC;QAC/B,QAAQ,EAAE,KAAK,CAAC,QAAQ;QACxB,QAAQ,EAAE,KAAK,CAAC,QAAQ;QACxB,GAAG;QACH,KAAK;KACN,CAAC,CAAC;IAEH,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,OAAO,EAAE;QAC/B,MAAM,EAAE,MAAM;QACd,OAAO,EAAE,EAAE,cAAc,EAAE,mCAAmC,EAAE;QAChE,IAAI;KACL,CAAC,CAAC;IAEH,IAAI,GAAG,CAAC,MAAM,KAAK,GAAG;QAAE,OAAO;IAC/B,IAAI,GAAG,CAAC,MAAM,KAAK,GAAG,EAAE,CAAC;QACvB,MAAM,IAAI,KAAK,CACb,mEAAmE;YACjE,sEAAsE,CACzE,CAAC;IACJ,CAAC;IACD,MAAM,IAAI,KAAK,CAAC,uBAAuB,GAAG,CAAC,MAAM,IAAI,GAAG,CAAC,UAAU,EAAE,CAAC,CAAC;AACzE,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,YAAY,CAAC,GAAW;IACtC,MAAM,OAAO,GAAG,GAAG,QAAQ,QAAQ,kBAAkB,CAAC,GAAG,CAAC,EAAE,CAAC;IAC7D,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,gBAAgB,EAAE,IAAI,EAAE,CAAC;IACjD,MAAM,IAAI,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,GAAG,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC;IACpD,KAAK,CAAC,MAAM,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC,KAAK,EAAE,CAAC;IACjE,OAAO,OAAO,CAAC;AACjB,CAAC"}
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Repair a Readability body so a downstream parser reads it correctly.
3
+ *
4
+ * Images keep their original absolute URLs rather than being rehosted, so the
5
+ * artifact stays text-only and the publisher still serves its own assets.
6
+ */
7
+ export declare function normalise(bodyHtml: string, baseUrl: string): {
8
+ html: string;
9
+ images: string[];
10
+ };
@@ -0,0 +1,159 @@
1
+ import { parseHTML } from "linkedom";
2
+ import sanitizeHtml from "sanitize-html";
3
+ /**
4
+ * Final gate before the body leaves this module. The article is third-party
5
+ * HTML that we serve on a public URL, so structure passes and active content
6
+ * does not: no event handlers, no javascript: URLs, no unknown tags.
7
+ */
8
+ const SANITIZE = {
9
+ allowedTags: [
10
+ "a", "abbr", "b", "blockquote", "br", "caption", "cite", "code", "dd",
11
+ "del", "dfn", "dl", "dt", "em", "figcaption", "figure", "h1", "h2", "h3",
12
+ "h4", "h5", "h6", "hr", "i", "img", "ins", "kbd", "li", "mark", "ol", "p",
13
+ "pre", "q", "s", "samp", "small", "strong", "sub", "sup", "table", "tbody",
14
+ "td", "tfoot", "th", "thead", "tr", "u", "ul", "div", "span",
15
+ ],
16
+ allowedAttributes: {
17
+ a: ["href", "title"],
18
+ img: ["src", "alt", "width", "height"],
19
+ td: ["colspan", "rowspan"],
20
+ th: ["colspan", "rowspan", "scope"],
21
+ },
22
+ allowedSchemes: ["http", "https", "mailto"],
23
+ allowProtocolRelative: false,
24
+ disallowedTagsMode: "discard",
25
+ };
26
+ /** Attributes lazy-loading scripts hide the real image URL behind. */
27
+ const LAZY_SRC = [
28
+ "data-src",
29
+ "data-original",
30
+ "data-lazy-src",
31
+ "data-actual-src",
32
+ "data-hi-res-src",
33
+ ];
34
+ const LAZY_SRCSET = ["data-srcset", "data-lazy-srcset"];
35
+ /** Wrappers that carry no meaning once styling is gone. */
36
+ const UNWRAP = ["div", "span", "section", "article"];
37
+ function absolute(value, base) {
38
+ try {
39
+ return new URL(value.trim(), base).href;
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ /** Pick the largest candidate from a srcset, so e-ink gets a sharp image. */
46
+ function widestFromSrcset(srcset, base) {
47
+ let best = null;
48
+ for (const part of srcset.split(",")) {
49
+ const [rawUrl, descriptor] = part.trim().split(/\s+/, 2);
50
+ if (!rawUrl)
51
+ continue;
52
+ const url = absolute(rawUrl, base);
53
+ if (!url)
54
+ continue;
55
+ const width = descriptor?.endsWith("w")
56
+ ? Number.parseInt(descriptor, 10) || 0
57
+ : 0;
58
+ if (!best || width > best.width)
59
+ best = { url, width };
60
+ }
61
+ return best?.url ?? null;
62
+ }
63
+ /**
64
+ * Repair a Readability body so a downstream parser reads it correctly.
65
+ *
66
+ * Images keep their original absolute URLs rather than being rehosted, so the
67
+ * artifact stays text-only and the publisher still serves its own assets.
68
+ */
69
+ export function normalise(bodyHtml, baseUrl) {
70
+ // linkedom silently drops content passed as a bare `<body>` fragment, so
71
+ // parse a real document and fill a container element instead.
72
+ const { document } = parseHTML("<!DOCTYPE html><html><body></body></html>");
73
+ const root = document.createElement("div");
74
+ root.innerHTML = bodyHtml;
75
+ // Drop anything that is chrome, tracking, or noise.
76
+ root
77
+ .querySelectorAll("script,style,noscript,iframe,form,button,svg,link,meta," +
78
+ "[role=navigation],[role=banner],[role=complementary],[aria-hidden=true]")
79
+ .forEach((el) => el.remove());
80
+ const images = [];
81
+ for (const img of Array.from(root.querySelectorAll("img"))) {
82
+ let src = null;
83
+ for (const attr of LAZY_SRC) {
84
+ const value = img.getAttribute(attr);
85
+ if (value) {
86
+ src = absolute(value, baseUrl);
87
+ if (src)
88
+ break;
89
+ }
90
+ }
91
+ for (const attr of LAZY_SRCSET) {
92
+ if (src)
93
+ break;
94
+ const value = img.getAttribute(attr);
95
+ if (value)
96
+ src = widestFromSrcset(value, baseUrl);
97
+ }
98
+ if (!src) {
99
+ const srcset = img.getAttribute("srcset");
100
+ if (srcset)
101
+ src = widestFromSrcset(srcset, baseUrl);
102
+ }
103
+ if (!src) {
104
+ const raw = img.getAttribute("src");
105
+ if (raw && !raw.startsWith("data:"))
106
+ src = absolute(raw, baseUrl);
107
+ }
108
+ if (src && !/^https?:/i.test(src))
109
+ src = null;
110
+ const width = Number.parseInt(img.getAttribute("width") ?? "0", 10);
111
+ const height = Number.parseInt(img.getAttribute("height") ?? "0", 10);
112
+ const isPixel = width > 0 && width <= 2 && height > 0 && height <= 2;
113
+ if (!src || isPixel) {
114
+ img.remove();
115
+ continue;
116
+ }
117
+ const alt = img.getAttribute("alt")?.trim() ?? "";
118
+ for (const attr of Array.from(img.attributes)) {
119
+ img.removeAttribute(attr.name);
120
+ }
121
+ img.setAttribute("src", src);
122
+ img.setAttribute("alt", alt);
123
+ images.push(src);
124
+ }
125
+ // Make links absolute so they still work off the original domain.
126
+ for (const a of Array.from(root.querySelectorAll("a[href]"))) {
127
+ const href = absolute(a.getAttribute("href") ?? "", baseUrl);
128
+ if (href)
129
+ a.setAttribute("href", href);
130
+ else
131
+ a.removeAttribute("href");
132
+ }
133
+ // Readability often leaves code as a bare <pre> or a <code> soup. Parsers
134
+ // reflow that into prose unless the <pre><code> pair is explicit.
135
+ for (const pre of Array.from(root.querySelectorAll("pre"))) {
136
+ if (!pre.querySelector("code")) {
137
+ const code = document.createElement("code");
138
+ code.textContent = pre.textContent ?? "";
139
+ pre.textContent = "";
140
+ pre.appendChild(code);
141
+ }
142
+ }
143
+ // Collapse wrappers that hold a single child and add no structure.
144
+ for (const el of Array.from(root.querySelectorAll(UNWRAP.join(",")))) {
145
+ const kept = Array.from(el.childNodes).filter((n) => n.nodeType !== 3 || (n.textContent ?? "").trim() !== "");
146
+ if (kept.length === 1 && kept[0].nodeType === 1) {
147
+ el.replaceWith(kept[0]);
148
+ }
149
+ }
150
+ // Remove blocks left empty by the passes above.
151
+ for (const el of Array.from(root.querySelectorAll("p,div,li,h1,h2,h3,h4"))) {
152
+ const hasMedia = el.querySelector("img,pre,code,table");
153
+ if (!hasMedia && !(el.textContent ?? "").trim())
154
+ el.remove();
155
+ }
156
+ const clean = sanitizeHtml(root.innerHTML, SANITIZE).trim();
157
+ return { html: clean, images };
158
+ }
159
+ //# sourceMappingURL=normalise.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"normalise.js","sourceRoot":"","sources":["../src/normalise.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AACrC,OAAO,YAAY,MAAM,eAAe,CAAC;AAEzC;;;;GAIG;AACH,MAAM,QAAQ,GAA0B;IACtC,WAAW,EAAE;QACX,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,YAAY,EAAE,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI;QACrE,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,YAAY,EAAE,QAAQ,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI;QACxE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,GAAG,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG;QACzE,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO;QAC1E,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,GAAG,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM;KAC7D;IACD,iBAAiB,EAAE;QACjB,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,CAAC;QACpB,GAAG,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,QAAQ,CAAC;QACtC,EAAE,EAAE,CAAC,SAAS,EAAE,SAAS,CAAC;QAC1B,EAAE,EAAE,CAAC,SAAS,EAAE,SAAS,EAAE,OAAO,CAAC;KACpC;IACD,cAAc,EAAE,CAAC,MAAM,EAAE,OAAO,EAAE,QAAQ,CAAC;IAC3C,qBAAqB,EAAE,KAAK;IAC5B,kBAAkB,EAAE,SAAS;CAC9B,CAAC;AAEF,sEAAsE;AACtE,MAAM,QAAQ,GAAG;IACf,UAAU;IACV,eAAe;IACf,eAAe;IACf,iBAAiB;IACjB,iBAAiB;CAClB,CAAC;AACF,MAAM,WAAW,GAAG,CAAC,aAAa,EAAE,kBAAkB,CAAC,CAAC;AAExD,2DAA2D;AAC3D,MAAM,MAAM,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,SAAS,CAAC,CAAC;AAErD,SAAS,QAAQ,CAAC,KAAa,EAAE,IAAY;IAC3C,IAAI,CAAC;QACH,OAAO,IAAI,GAAG,CAAC,KAAK,CAAC,IAAI,EAAE,EAAE,IAAI,CAAC,CAAC,IAAI,CAAC;IAC1C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,6EAA6E;AAC7E,SAAS,gBAAgB,CAAC,MAAc,EAAE,IAAY;IACpD,IAAI,IAAI,GAA0C,IAAI,CAAC;IACvD,KAAK,MAAM,IAAI,IAAI,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC;QACrC,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;QACzD,IAAI,CAAC,MAAM;YAAE,SAAS;QACtB,MAAM,GAAG,GAAG,QAAQ,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;QACnC,IAAI,CAAC,GAAG;YAAE,SAAS;QACnB,MAAM,KAAK,GAAG,UAAU,EAAE,QAAQ,CAAC,GAAG,CAAC;YACrC,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,UAAU,EAAE,EAAE,CAAC,IAAI,CAAC;YACtC,CAAC,CAAC,CAAC,CAAC;QACN,IAAI,CAAC,IAAI,IAAI,KAAK,GAAG,IAAI,CAAC,KAAK;YAAE,IAAI,GAAG,EAAE,GAAG,EAAE,KAAK,EAAE,CAAC;IACzD,CAAC;IACD,OAAO,IAAI,EAAE,GAAG,IAAI,IAAI,CAAC;AAC3B,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,SAAS,CACvB,QAAgB,EAChB,OAAe;IAEf,yEAAyE;IACzE,8DAA8D;IAC9D,MAAM,EAAE,QAAQ,EAAE,GAAG,SAAS,CAC5B,2CAA2C,CACP,CAAC;IACvC,MAAM,IAAI,GAAG,QAAQ,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;IAC3C,IAAI,CAAC,SAAS,GAAG,QAAQ,CAAC;IAE1B,oDAAoD;IACpD,IAAI;SACD,gBAAgB,CACf,yDAAyD;QACvD,yEAAyE,CAC5E;SACA,OAAO,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,CAAC;IAEhC,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,KAAK,MAAM,GAAG,IAAI,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,gBAAgB,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC;QAC3D,IAAI,GAAG,GAAkB,IAAI,CAAC;QAE9B,KAAK,MAAM,IAAI,IAAI,QAAQ,EAAE,CAAC;YAC5B,MAAM,KAAK,GAAG,GAAG,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC;YACrC,IAAI,KAAK,EAAE,CAAC;gBACV,GAAG,GAAG,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC;gBAC/B,IAAI,GAAG;oBAAE,MAAM;YACjB,CAAC;QACH,CAAC;QACD,KAAK,MAAM,IAAI,IAAI,WAAW,EAAE,CAAC;YAC/B,IAAI,GAAG;gBAAE,MAAM;YACf,MAAM,KAAK,GAAG,GAAG,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC;YACrC,IAAI,KAAK;gBAAE,GAAG,GAAG,gBAAgB,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC;QACpD,CAAC;QACD,IAAI,CAAC,GAAG,EAAE,CAAC;YACT,MAAM,MAAM,GAAG,GAAG,CAAC,YAAY,CAAC,QAAQ,CAAC,CAAC;YAC1C,IAAI,MAAM;gBAAE,GAAG,GAAG,gBAAgB,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QACtD,CAAC;QACD,IAAI,CAAC,GAAG,EAAE,CAAC;YACT,MAAM,GAAG,GAAG,GAAG,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC;YACpC,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,UAAU,CAAC,OAAO,CAAC;gBAAE,GAAG,GAAG,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;QACpE,CAAC;QAED,IAAI,GAAG,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,GAAG,CAAC;YAAE,GAAG,GAAG,IAAI,CAAC;QAE9C,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,YAAY,CAAC,OAAO,CAAC,IAAI,GAAG,EAAE,EAAE,CAAC,CAAC;QACpE,MAAM,MAAM,GAAG,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,YAAY,CAAC,QAAQ,CAAC,IAAI,GAAG,EAAE,EAAE,CAAC,CAAC;QACtE,MAAM,OAAO,GAAG,KAAK,GAAG,CAAC,IAAI,KAAK,IAAI,CAAC,IAAI,MAAM,GAAG,CAAC,IAAI,MAAM,IAAI,CAAC,CAAC;QAErE,IAAI,CAAC,GAAG,IAAI,OAAO,EAAE,CAAC;YACpB,GAAG,CAAC,MAAM,EAAE,CAAC;YACb,SAAS;QACX,CAAC;QAED,MAAM,GAAG,GAAG,GAAG,CAAC,YAAY,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;QAClD,KAAK,MAAM,IAAI,IAAI,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,UAAU,CAAC,EAAE,CAAC;YAC9C,GAAG,CAAC,eAAe,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACjC,CAAC;QACD,GAAG,CAAC,YAAY,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC;QAC7B,GAAG,CAAC,YAAY,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC;QAC7B,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACnB,CAAC;IAED,kEAAkE;IAClE,KAAK,MAAM,CAAC,IAAI,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,gBAAgB,CAAC,SAAS,CAAC,CAAC,EAAE,CAAC;QAC7D,MAAM,IAAI,GAAG,QAAQ,CAAC,CAAC,CAAC,YAAY,CAAC,MAAM,CAAC,IAAI,EAAE,EAAE,OAAO,CAAC,CAAC;QAC7D,IAAI,IAAI;YAAE,CAAC,CAAC,YAAY,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;;YAClC,CAAC,CAAC,eAAe,CAAC,MAAM,CAAC,CAAC;IACjC,CAAC;IAED,0EAA0E;IAC1E,kEAAkE;IAClE,KAAK,MAAM,GAAG,IAAI,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,gBAAgB,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC;QAC3D,IAAI,CAAC,GAAG,CAAC,aAAa,CAAC,MAAM,CAAC,EAAE,CAAC;YAC/B,MAAM,IAAI,GAAG,QAAQ,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC;YAC5C,IAAI,CAAC,WAAW,GAAG,GAAG,CAAC,WAAW,IAAI,EAAE,CAAC;YACzC,GAAG,CAAC,WAAW,GAAG,EAAE,CAAC;YACrB,GAAG,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC;QACxB,CAAC;IACH,CAAC;IAED,mEAAmE;IACnE,KAAK,MAAM,EAAE,IAAI,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,gBAAgB,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;QACrE,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,CAAC,MAAM,CAC3C,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,WAAW,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,CAC/D,CAAC;QACF,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,QAAQ,KAAK,CAAC,EAAE,CAAC;YAChD,EAAE,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;QAC1B,CAAC;IACH,CAAC;IAED,gDAAgD;IAChD,KAAK,MAAM,EAAE,IAAI,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,gBAAgB,CAAC,sBAAsB,CAAC,CAAC,EAAE,CAAC;QAC3E,MAAM,QAAQ,GAAG,EAAE,CAAC,aAAa,CAAC,oBAAoB,CAAC,CAAC;QACxD,IAAI,CAAC,QAAQ,IAAI,CAAC,CAAC,EAAE,CAAC,WAAW,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE;YAAE,EAAE,CAAC,MAAM,EAAE,CAAC;IAC/D,CAAC;IAED,MAAM,KAAK,GAAG,YAAY,CAAC,IAAI,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC,IAAI,EAAE,CAAC;IAC5D,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC;AACjC,CAAC"}
@@ -0,0 +1,12 @@
1
+ import type { Article } from "./types.js";
2
+ /** Rough reading time at 220 words per minute. */
3
+ export declare function readingMinutes(text: string): number;
4
+ /**
5
+ * Render the delivery payload.
6
+ *
7
+ * Deliberately no <link rel="canonical">: a canonical tag invites the reader
8
+ * service to resolve back to the original URL and re-parse it, which is the
9
+ * behaviour this whole tool exists to avoid. Attribution instead sits in the
10
+ * visible header, which no parser strips.
11
+ */
12
+ export declare function renderHtml(article: Article): string;
package/dist/render.js ADDED
@@ -0,0 +1,60 @@
1
+ function escape(value) {
2
+ return value
3
+ .replace(/&/g, "&amp;")
4
+ .replace(/</g, "&lt;")
5
+ .replace(/>/g, "&gt;")
6
+ .replace(/"/g, "&quot;");
7
+ }
8
+ /** Rough reading time at 220 words per minute. */
9
+ export function readingMinutes(text) {
10
+ return Math.max(1, Math.round(text.split(/\s+/).filter(Boolean).length / 220));
11
+ }
12
+ /**
13
+ * Render the delivery payload.
14
+ *
15
+ * Deliberately no <link rel="canonical">: a canonical tag invites the reader
16
+ * service to resolve back to the original URL and re-parse it, which is the
17
+ * behaviour this whole tool exists to avoid. Attribution instead sits in the
18
+ * visible header, which no parser strips.
19
+ */
20
+ export function renderHtml(article) {
21
+ const minutes = readingMinutes(article.text);
22
+ const credit = [article.siteName, article.byline].filter(Boolean).join(" — ");
23
+ return `<!doctype html>
24
+ <html lang="${escape(article.lang)}">
25
+ <head>
26
+ <meta charset="utf-8">
27
+ <meta name="viewport" content="width=device-width, initial-scale=1">
28
+ <meta name="robots" content="noindex, nofollow, noarchive">
29
+ <title>${escape(article.title)}</title>
30
+ ${article.byline ? `<meta name="author" content="${escape(article.byline)}">` : ""}
31
+ ${article.excerpt ? `<meta name="description" content="${escape(article.excerpt)}">` : ""}
32
+ <style>
33
+ body{max-width:38em;margin:0 auto;padding:1.5rem;
34
+ font:1rem/1.6 Georgia,"Iowan Old Style",serif;color:#111}
35
+ img{max-width:100%;height:auto;display:block;margin:1.4em auto}
36
+ figcaption,.tidyread-credit{font-size:.85em;color:#555}
37
+ pre{white-space:pre-wrap;word-wrap:break-word;overflow-x:auto;
38
+ background:#f6f6f6;padding:.8em;border-radius:4px;font-size:.85em}
39
+ code{font-family:ui-monospace,Menlo,Consolas,monospace}
40
+ blockquote{margin:1.2em 0;padding-left:1em;border-left:3px solid #ccc;color:#444}
41
+ table{border-collapse:collapse;width:100%;font-size:.9em}
42
+ td,th{border:1px solid #ddd;padding:.4em}
43
+ h1{font-size:1.7em;line-height:1.25}
44
+ .tidyread-credit{margin:0 0 2em;padding-bottom:1em;border-bottom:1px solid #eee}
45
+ </style>
46
+ </head>
47
+ <body>
48
+ <article>
49
+ <h1>${escape(article.title)}</h1>
50
+ <p class="tidyread-credit">
51
+ ${credit ? `${escape(credit)} · ` : ""}${minutes} min read<br>
52
+ Original: <a href="${escape(article.sourceUrl)}">${escape(article.sourceUrl)}</a>
53
+ </p>
54
+ ${article.html}
55
+ </article>
56
+ </body>
57
+ </html>
58
+ `;
59
+ }
60
+ //# sourceMappingURL=render.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render.js","sourceRoot":"","sources":["../src/render.ts"],"names":[],"mappings":"AAEA,SAAS,MAAM,CAAC,KAAa;IAC3B,OAAO,KAAK;SACT,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC;SACtB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;AAC7B,CAAC;AAED,kDAAkD;AAClD,MAAM,UAAU,cAAc,CAAC,IAAY;IACzC,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,MAAM,GAAG,GAAG,CAAC,CAAC,CAAC;AACjF,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,UAAU,CAAC,OAAgB;IACzC,MAAM,OAAO,GAAG,cAAc,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7C,MAAM,MAAM,GAAG,CAAC,OAAO,CAAC,QAAQ,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IAE9E,OAAO;cACK,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC;;;;;SAKzB,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC;EAC5B,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,gCAAgC,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE;EAChF,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,qCAAqC,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE;;;;;;;;;;;;;;;;;;MAkBnF,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC;;EAEzB,MAAM,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,GAAG,OAAO;qBAC3B,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,KAAK,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC;;EAE1E,OAAO,CAAC,IAAI;;;;CAIb,CAAC;AACF,CAAC"}
@@ -0,0 +1,18 @@
1
+ export interface Hosted {
2
+ /** Unguessable path the payload is served at, e.g. "/a1b2c3.../". */
3
+ path: string;
4
+ port: number;
5
+ /** Resolves the first time something outside this machine fetches it. */
6
+ fetched: Promise<{
7
+ userAgent: string;
8
+ at: Date;
9
+ }>;
10
+ close(): Promise<void>;
11
+ }
12
+ /**
13
+ * Serve one HTML payload on localhost at a random path.
14
+ *
15
+ * The path is 128 bits of randomness, so the URL is not discoverable during
16
+ * the seconds it is reachable. Every other path returns 404.
17
+ */
18
+ export declare function serveOnce(html: string): Promise<Hosted>;
package/dist/serve.js ADDED
@@ -0,0 +1,61 @@
1
+ import { createServer } from "node:http";
2
+ import { randomBytes } from "node:crypto";
3
+ import { once } from "node:events";
4
+ function isLoopback(address) {
5
+ if (!address)
6
+ return false;
7
+ const ip = address.replace(/^::ffff:/, "");
8
+ return ip === "127.0.0.1" || ip === "::1";
9
+ }
10
+ /**
11
+ * Serve one HTML payload on localhost at a random path.
12
+ *
13
+ * The path is 128 bits of randomness, so the URL is not discoverable during
14
+ * the seconds it is reachable. Every other path returns 404.
15
+ */
16
+ export async function serveOnce(html) {
17
+ const token = randomBytes(16).toString("hex");
18
+ const path = `/${token}`;
19
+ const body = Buffer.from(html, "utf8");
20
+ let announceFetch;
21
+ const fetched = new Promise((resolve) => {
22
+ announceFetch = resolve;
23
+ });
24
+ const server = createServer((req, res) => {
25
+ const url = (req.url ?? "").split("?")[0].replace(/\/$/, "") || "/";
26
+ if (url !== path) {
27
+ res.writeHead(404, { "content-type": "text/plain" });
28
+ res.end("not found");
29
+ return;
30
+ }
31
+ res.writeHead(200, {
32
+ "content-type": "text/html; charset=utf-8",
33
+ "content-length": String(body.length),
34
+ "cache-control": "no-store",
35
+ "x-robots-tag": "noindex, nofollow",
36
+ });
37
+ res.end(req.method === "HEAD" ? undefined : body);
38
+ if (!isLoopback(req.socket.remoteAddress)) {
39
+ announceFetch({
40
+ userAgent: req.headers["user-agent"] ?? "unknown",
41
+ at: new Date(),
42
+ });
43
+ }
44
+ });
45
+ server.listen(0, "127.0.0.1");
46
+ await once(server, "listening");
47
+ const address = server.address();
48
+ if (typeof address === "string" || address === null) {
49
+ throw new Error("could not bind a local port");
50
+ }
51
+ return {
52
+ path,
53
+ port: address.port,
54
+ fetched,
55
+ async close() {
56
+ server.closeAllConnections?.();
57
+ await new Promise((resolve) => server.close(() => resolve()));
58
+ },
59
+ };
60
+ }
61
+ //# sourceMappingURL=serve.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serve.js","sourceRoot":"","sources":["../src/serve.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,14 @@
1
+ export interface Tunnel {
2
+ /** Public https origin, e.g. "https://foo-bar.trycloudflare.com". */
3
+ origin: string;
4
+ close(): Promise<void>;
5
+ }
6
+ /**
7
+ * Open a Cloudflare quick tunnel to a local port.
8
+ *
9
+ * Quick tunnels need no account and no login. The address exists only while
10
+ * this process lives, so the payload cannot outlive the run. Cloudflare offers
11
+ * these on a best-effort basis with no SLA, which suits one-off personal use
12
+ * and nothing heavier.
13
+ */
14
+ export declare function openTunnel(port: number): Promise<Tunnel>;
package/dist/tunnel.js ADDED
@@ -0,0 +1,64 @@
1
+ import { spawn } from "node:child_process";
2
+ const URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
3
+ const START_TIMEOUT_MS = 30_000;
4
+ /**
5
+ * Open a Cloudflare quick tunnel to a local port.
6
+ *
7
+ * Quick tunnels need no account and no login. The address exists only while
8
+ * this process lives, so the payload cannot outlive the run. Cloudflare offers
9
+ * these on a best-effort basis with no SLA, which suits one-off personal use
10
+ * and nothing heavier.
11
+ */
12
+ export async function openTunnel(port) {
13
+ const child = spawn("cloudflared", [
14
+ "tunnel",
15
+ "--no-autoupdate",
16
+ "--url",
17
+ `http://127.0.0.1:${port}`,
18
+ ], { stdio: ["ignore", "pipe", "pipe"] });
19
+ const origin = await new Promise((resolve, reject) => {
20
+ const timer = setTimeout(() => {
21
+ reject(new Error("cloudflared did not produce a URL within 30s"));
22
+ }, START_TIMEOUT_MS);
23
+ const scan = (chunk) => {
24
+ const match = URL_PATTERN.exec(chunk.toString());
25
+ if (match) {
26
+ clearTimeout(timer);
27
+ resolve(match[0]);
28
+ }
29
+ };
30
+ child.stdout?.on("data", scan);
31
+ child.stderr?.on("data", scan);
32
+ child.once("error", (err) => {
33
+ clearTimeout(timer);
34
+ reject(new Error(`could not start cloudflared: ${err.message}. ` +
35
+ `Install it with: brew install cloudflared`));
36
+ });
37
+ child.once("exit", (code) => {
38
+ clearTimeout(timer);
39
+ reject(new Error(`cloudflared exited early with code ${code}`));
40
+ });
41
+ }).catch(async (err) => {
42
+ child.kill("SIGKILL");
43
+ throw err;
44
+ });
45
+ return {
46
+ origin,
47
+ async close() {
48
+ if (child.exitCode !== null || child.killed)
49
+ return;
50
+ child.kill("SIGTERM");
51
+ await new Promise((resolve) => {
52
+ const force = setTimeout(() => {
53
+ child.kill("SIGKILL");
54
+ resolve();
55
+ }, 3000);
56
+ child.once("exit", () => {
57
+ clearTimeout(force);
58
+ resolve();
59
+ });
60
+ });
61
+ },
62
+ };
63
+ }
64
+ //# sourceMappingURL=tunnel.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tunnel.js","sourceRoot":"","sources":["../src/tunnel.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAqB,MAAM,oBAAoB,CAAC;AAQ9D,MAAM,WAAW,GAAG,2CAA2C,CAAC;AAChE,MAAM,gBAAgB,GAAG,MAAM,CAAC;AAEhC;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,IAAY;IAC3C,MAAM,KAAK,GAAiB,KAAK,CAC/B,aAAa,EACb;QACE,QAAQ;QACR,iBAAiB;QACjB,OAAO;QACP,oBAAoB,IAAI,EAAE;KAC3B,EACD,EAAE,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,CACtC,CAAC;IAEF,MAAM,MAAM,GAAG,MAAM,IAAI,OAAO,CAAS,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QAC3D,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE;YAC5B,MAAM,CAAC,IAAI,KAAK,CAAC,8CAA8C,CAAC,CAAC,CAAC;QACpE,CAAC,EAAE,gBAAgB,CAAC,CAAC;QAErB,MAAM,IAAI,GAAG,CAAC,KAAa,EAAE,EAAE;YAC7B,MAAM,KAAK,GAAG,WAAW,CAAC,IAAI,CAAC,KAAK,CAAC,QAAQ,EAAE,CAAC,CAAC;YACjD,IAAI,KAAK,EAAE,CAAC;gBACV,YAAY,CAAC,KAAK,CAAC,CAAC;gBACpB,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;YACpB,CAAC;QACH,CAAC,CAAC;QACF,KAAK,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;QAC/B,KAAK,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;QAC/B,KAAK,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,EAAE;YAC1B,YAAY,CAAC,KAAK,CAAC,CAAC;YACpB,MAAM,CACJ,IAAI,KAAK,CACP,gCAAgC,GAAG,CAAC,OAAO,IAAI;gBAC7C,2CAA2C,CAC9C,CACF,CAAC;QACJ,CAAC,CAAC,CAAC;QACH,KAAK,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,EAAE;YAC1B,YAAY,CAAC,KAAK,CAAC,CAAC;YACpB,MAAM,CAAC,IAAI,KAAK,CAAC,sCAAsC,IAAI,EAAE,CAAC,CAAC,CAAC;QAClE,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,EAAE,GAAG,EAAE,EAAE;QACrB,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;QACtB,MAAM,GAAG,CAAC;IACZ,CAAC,CAAC,CAAC;IAEH,OAAO;QACL,MAAM;QACN,KAAK,CAAC,KAAK;YACT,IAAI,KAAK,CAAC,QAAQ,KAAK,IAAI,IAAI,KAAK,CAAC,MAAM;gBAAE,OAAO;YACpD,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;YACtB,MAAM,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,EAAE;gBAClC,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE;oBAC5B,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;oBACtB,OAAO,EAAE,CAAC;gBACZ,CAAC,EAAE,IAAI,CAAC,CAAC;gBACT,KAAK,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,EAAE;oBACtB,YAAY,CAAC,KAAK,CAAC,CAAC;oBACpB,OAAO,EAAE,CAAC;gBACZ,CAAC,CAAC,CAAC;YACL,CAAC,CAAC,CAAC;QACL,CAAC;KACF,CAAC;AACJ,CAAC"}
@@ -0,0 +1,26 @@
1
+ /** A web page after extraction and normalisation. */
2
+ export interface Article {
3
+ /** The page this came from. Always kept for attribution. */
4
+ sourceUrl: string;
5
+ title: string;
6
+ /** Author line, when the page declares one. */
7
+ byline: string | null;
8
+ /** Publication or site name. */
9
+ siteName: string | null;
10
+ /** Short summary the page declares, if any. */
11
+ excerpt: string | null;
12
+ /** Cleaned article body as an HTML fragment. */
13
+ html: string;
14
+ /** Plain text, used for reading-time and diagnostics. */
15
+ text: string;
16
+ /** Absolute URLs of every image kept in the body. */
17
+ images: string[];
18
+ /** Language tag, defaults to "en". */
19
+ lang: string;
20
+ }
21
+ /** Where a built artifact ended up on disk. */
22
+ export interface BuildResult {
23
+ htmlPath: string;
24
+ epubPath?: string;
25
+ kepubPath?: string;
26
+ }
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "tidyread",
3
+ "version": "0.1.0",
4
+ "description": "Clean an article once, hand it to Instapaper's parser pre-chewed, and build a Kobo-ready EPUB.",
5
+ "main": "dist/cli.js",
6
+ "scripts": {
7
+ "test": "node --test",
8
+ "build": "tsc",
9
+ "dev": "tsc --watch",
10
+ "typecheck": "tsc --noEmit",
11
+ "check": "npm run build && npm test",
12
+ "prepublishOnly": "npm run check",
13
+ "pack:dry-run": "npm pack --dry-run"
14
+ },
15
+ "keywords": [
16
+ "instapaper",
17
+ "kobo",
18
+ "epub",
19
+ "kepub",
20
+ "readability",
21
+ "read-later",
22
+ "article",
23
+ "e-ink",
24
+ "defuddle"
25
+ ],
26
+ "author": "parzival1l <nandhu02111997@gmail.com>",
27
+ "license": "MIT",
28
+ "type": "module",
29
+ "dependencies": {
30
+ "@mozilla/readability": "^0.6.0",
31
+ "commander": "^15.0.0",
32
+ "defuddle": "^0.19.3",
33
+ "jszip": "^3.10.2",
34
+ "linkedom": "^0.18.13",
35
+ "sanitize-html": "^2.17.7"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^26.5.0",
39
+ "@types/sanitize-html": "^2.16.1",
40
+ "typescript": "^7.0.2"
41
+ },
42
+ "bin": {
43
+ "tidyread": "dist/cli.js"
44
+ },
45
+ "files": [
46
+ "dist",
47
+ "README.md",
48
+ "LICENSE"
49
+ ],
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "git+https://github.com/parzival1l/tidyread.git"
53
+ },
54
+ "homepage": "https://github.com/parzival1l/tidyread#readme",
55
+ "bugs": {
56
+ "url": "https://github.com/parzival1l/tidyread/issues"
57
+ },
58
+ "engines": {
59
+ "node": ">=20"
60
+ }
61
+ }