From b00633af0fc76d091bdc8ea73a7a93b211c20134 Mon Sep 17 00:00:00 2001
From: Till Heidrich
Date: Wed, 16 Sep 2026 12:39:26 +0200
Subject: [PATCH] =?UTF-8?q?Aufr=C3=A4umen,=20Reddit-Aufbereitung,=20Obsidi?=
=?UTF-8?q?an,=20Verlauf,=20iPhone-Kurzbefehl;=20Spenden=20vorerst=20aus?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- clean.js: Webseiten aufräumen (Mozilla Readability für Artikel, sonst Navigation/Cookie-Banner/Knöpfe entfernen), Knopf 'Ohne Aufräumen'
- Reddit (neu und alt): Titel, Subreddit, Autor, keine Votes/Reply-Reste, Kommentare als verschachtelte Zitate mit (OP)
- Optionaler Knopf 'In Obsidian öffnen' (obsidian://new, Vault/Ordner einstellbar, Text über Zwischenablage)
- Optionaler Verlauf der letzten 10 Clips in IndexedDB, standardmäßig aus
- Anleitung iPhone-Kurzbefehl in der App und docs/ios-shortcut.md, ?from=ios weist auf Einfügen hin
- Spenden-Links ausgeblendet, bis ein funktionierender Link hinterlegt ist; PayPal-Abschnitt aus der Datenschutzerklärung entfernt
- Datenschutz um Verlauf und Obsidian ergänzt, SW-Cache v6
---
Dockerfile | 2 +-
README.md | 18 +-
clean.js | 159 ++
datenschutz.html | 41 +-
docs/ios-shortcut.md | 25 +
impressum.html | 3 +-
index.html | 261 ++-
sw.js | 4 +-
vendor/LICENSE-Readability.md | 13 +
vendor/Readability.js | 2786 +++++++++++++++++++++++++++++++++
10 files changed, 3260 insertions(+), 52 deletions(-)
create mode 100644 clean.js
create mode 100644 docs/ios-shortcut.md
create mode 100644 vendor/LICENSE-Readability.md
create mode 100644 vendor/Readability.js
diff --git a/Dockerfile b/Dockerfile
index bb59cb2..7cbe9f6 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -3,7 +3,7 @@ FROM nginx:1.27-alpine
# Static single-page app — no build step.
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY security-headers.conf /etc/nginx/security-headers.conf
-COPY index.html impressum.html datenschutz.html legal.css icon-mono.svg manifest.webmanifest sw.js icon.svg icon-192.png icon-512.png icon-maskable-512.png apple-touch-icon.png /usr/share/nginx/html/
+COPY index.html clean.js impressum.html datenschutz.html legal.css icon-mono.svg manifest.webmanifest sw.js icon.svg icon-192.png icon-512.png icon-maskable-512.png apple-touch-icon.png /usr/share/nginx/html/
COPY vendor/ /usr/share/nginx/html/vendor/
EXPOSE 80
diff --git a/README.md b/README.md
index 4333888..10a9cb8 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,16 @@ Obsidian vault, a wiki, or a repo.
picks the HTML version when there is one. Browsers ask once for permission; `⌘V` always works too.
- **Smart paste.** Formatted HTML (copy from a browser, e.g. Reddit `⌘A` → `⌘C`) is converted to
clean Markdown. Plain text stays raw. Same `⌘V` for both, no mode switch.
+- **Page clean-up.** Pasting a whole web page drops navigation, cookie banners, share buttons and
+ sidebars; articles are extracted with Mozilla Readability. *Undo clean-up* re-inserts the
+ unfiltered version with one click.
+- **Reddit threads.** New and old Reddit: post title, subreddit and author on top, no vote counts or
+ Reply/Share leftovers, comments as nested quotes with `(OP)` marked.
+- **Open in Obsidian.** Optional button that creates the note straight in your vault (vault and
+ folder configurable), via the `obsidian://` URI and the clipboard — no file detour.
+- **History (opt-in).** Keeps the last 10 clips in this browser's IndexedDB. Off by default.
+- **iPhone shortcut.** iOS keeps web apps out of the share sheet; a four-step Shortcut fills the gap
+ (see [docs/ios-shortcut.md](docs/ios-shortcut.md) or *iPhone shortcut* in the app footer).
- **Force raw.** `⌘/Ctrl+Shift+V` pastes the next clip as plain text, skipping conversion.
- **Live preview.** Toggle a side-by-side rendered Markdown view (marked + DOMPurify).
- **Auto filename.** Derived from the first heading + date, e.g. `2026-08-11-my-thread.md`.
@@ -79,11 +89,12 @@ Any static host works — point it at this repo. The reference deployment uses
## Update vendored libraries
```bash
-npm pack turndown turndown-plugin-gfm marked dompurify @fontsource/space-grotesk @fontsource/space-mono
+npm pack turndown turndown-plugin-gfm marked dompurify @mozilla/readability @fontsource/space-grotesk @fontsource/space-mono
# turndown/dist/turndown.js -> vendor/turndown.js
# turndown-plugin-gfm/dist/turndown-plugin-gfm.js -> vendor/turndown-plugin-gfm.js
# marked/lib/marked.umd.js -> vendor/marked.umd.js
# dompurify/dist/purify.min.js -> vendor/purify.min.js
+# @mozilla/readability/Readability.js -> vendor/Readability.js
# @fontsource/*/files/*-latin-{400,500,700}-normal.woff2 -> vendor/fonts/
```
@@ -91,8 +102,5 @@ Bump the `CACHE` constant in `sw.js` whenever an asset changes so clients pick i
## License
-[MIT](LICENSE). Turndown, marked, DOMPurify, Space Grotesk and Space Mono are under their respective MIT/Apache/OFL licenses.
+[MIT](LICENSE). Turndown, marked, DOMPurify, Mozilla Readability, Space Grotesk and Space Mono are under their respective MIT/Apache/OFL licenses.
-## Support
-
-clip2md is free. If it saves you time: [buy me a coffee via PayPal](https://www.paypal.com/donate/?business=info%40tillheidrich.de¤cy_code=EUR&item_name=clip2md).
diff --git a/clean.js b/clean.js
new file mode 100644
index 0000000..df2a477
--- /dev/null
+++ b/clean.js
@@ -0,0 +1,159 @@
+/* clip2md — page clean-up and Reddit formatting. Runs entirely in the browser.
+ Exposes window.Clip2mdClean = { prepare, postprocess }. */
+(function () {
+ 'use strict';
+
+ var BUTTON_WORDS = /^(reply|share|report|save|saved|follow|following|subscribe|unsubscribe|join|joined|award|give award|upvote|downvote|vote|like|likes|comment|comments|copy link|copy text|embed|print|tweet|pin it|email|more|more options|show more|show less|read more|see more|continue reading|load more|view more comments|back to top|skip to content|skip to main content|accept|accept all|reject|reject all|allow all|decline|manage cookies|cookie settings|close|dismiss|sign in|log in|sign up|antworten|teilen|melden|speichern|folgen|abonnieren|beitreten|gefällt mir|kommentieren|link kopieren|drucken|mehr|mehr anzeigen|weniger anzeigen|weiterlesen|mehr laden|nach oben|zum inhalt springen|akzeptieren|alle akzeptieren|ablehnen|alle ablehnen|einstellungen|cookie-einstellungen|schließen|anmelden|registrieren)$/i;
+ var JUNK_ATTR = /(^|[\s_-])(cookie|consent|gdpr|cmp|banner|newsletter|subscribe|share|sharing|social|advert|ad-slot|ads|sponsor|promo|popup|modal|overlay|sidebar|breadcrumb|related|recommend|paywall|toolbar|skip-link|comment-form|site-header|site-footer|masthead)([\s_-]|$)/i;
+ var JUNK_SELECTORS = 'nav, aside, footer, form, button, input, select, textarea, dialog, iframe, noscript, template, [hidden], [aria-hidden="true"], [role="navigation"], [role="banner"], [role="contentinfo"], [role="dialog"], [role="alertdialog"], [role="complementary"], [role="search"], [role="menu"], [role="menubar"], [role="toolbar"]';
+
+ function textLen(el) { return el ? (el.textContent || '').replace(/\s+/g, ' ').trim().length : 0; }
+ function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }
+
+ // ---------- Generic page clean-up ----------
+ function stripJunk(root) {
+ var total = textLen(root) || 1, removed = 0;
+ Array.prototype.slice.call(root.querySelectorAll(JUNK_SELECTORS)).forEach(function (el) {
+ // Never drop the bulk of the content just because it sits in an odd wrapper.
+ if (el.isConnected && textLen(el) < total * 0.5) { el.remove(); removed++; }
+ });
+ Array.prototype.slice.call(root.querySelectorAll('[class], [id]')).forEach(function (el) {
+ if (!el.isConnected || /^(html|body|main|article)$/i.test(el.tagName)) return;
+ var sig = (el.getAttribute('class') || '') + ' ' + (el.id || '');
+ if (JUNK_ATTR.test(sig) && textLen(el) < total * 0.25) { el.remove(); removed++; }
+ });
+ Array.prototype.slice.call(root.querySelectorAll('a, span, div, li, p, strong, b')).forEach(function (el) {
+ if (!el.isConnected) return;
+ var t = (el.textContent || '').replace(/\s+/g, ' ').trim();
+ if (t && t.length < 32 && BUTTON_WORDS.test(t) && !el.querySelector('img')) { el.remove(); removed++; }
+ });
+ Array.prototype.slice.call(root.querySelectorAll('img')).forEach(function (img) {
+ var w = parseInt(img.getAttribute('width'), 10), h = parseInt(img.getAttribute('height'), 10);
+ var alt = (img.getAttribute('alt') || '') + ' ' + (img.getAttribute('src') || '');
+ if ((w && w <= 48) || (h && h <= 48) || /avatar|icon|logo|emoji|spacer|pixel|tracking/i.test(alt)) { img.remove(); removed++; }
+ });
+ return removed;
+ }
+
+ function looksLikePage(doc) {
+ return !!doc.querySelector('article, main, [role="main"], nav, header, footer') || doc.querySelectorAll('a').length > 25;
+ }
+
+ // ---------- Reddit ----------
+ function isReddit(doc, html) {
+ if (doc.querySelector('shreddit-comment, shreddit-post, .thing.comment, .thing.link, [data-testid="comment"]')) return true;
+ return /reddit\.com\/(r|user)\//i.test(html) && /\b(reply|upvote|antworten)\b/i.test(doc.body ? doc.body.textContent : '');
+ }
+
+ function quote(lines, depth) {
+ var p = new Array(depth + 1).join('> ');
+ return lines.map(function (l) { return (p + l).replace(/\s+$/, ''); });
+ }
+
+ function nearest(el, sel) { var p = el.parentElement; return p ? p.closest(sel) : null; }
+
+ function redditToMd(doc, toMd, labels) {
+ var out = [], post, title, author, sub, body, comments = [];
+
+ // New Reddit (shreddit web components)
+ post = doc.querySelector('shreddit-post');
+ var sc = doc.querySelectorAll('shreddit-comment');
+ if (post || sc.length) {
+ title = post && post.getAttribute('post-title');
+ author = post && post.getAttribute('author');
+ sub = post && post.getAttribute('subreddit-prefixed-name');
+ body = post && post.querySelector('[slot="text-body"]');
+ var top = Array.prototype.filter.call(sc, function (c) { return !nearest(c, 'shreddit-comment'); });
+ var renderNew = function (c, depth) {
+ var a = c.getAttribute('author') || '[deleted]';
+ var bodyEl = Array.prototype.filter.call(c.querySelectorAll('[slot="comment"]'), function (b) { return b.closest('shreddit-comment') === c; })[0];
+ var lines = ['**u/' + a + '**' + (author && a === author ? ' (OP)' : ''), ''];
+ lines = lines.concat((bodyEl ? toMd(bodyEl.innerHTML) : '').split('\n'));
+ var res = quote(lines, depth);
+ Array.prototype.filter.call(c.querySelectorAll('shreddit-comment'), function (k) { return nearest(k, 'shreddit-comment') === c; })
+ .forEach(function (k) { res.push(quote([''], depth)[0]); res = res.concat(renderNew(k, depth + 1)); });
+ return res;
+ };
+ top.forEach(function (c) { comments.push(renderNew(c, 1).join('\n')); });
+ } else {
+ // Old Reddit
+ var link = doc.querySelector('.thing.link');
+ var oc = doc.querySelectorAll('.thing.comment');
+ if (!link && !oc.length) return null;
+ if (link) {
+ var ta = link.querySelector('a.title'); title = ta && ta.textContent.trim();
+ var au = link.querySelector('.tagline .author'); author = au && au.textContent.trim();
+ var sr = link.querySelector('.tagline .subreddit'); sub = sr && sr.textContent.trim();
+ body = link.querySelector('.usertext-body .md');
+ }
+ var topOld = Array.prototype.filter.call(oc, function (c) { return !nearest(c, '.thing.comment'); });
+ var renderOld = function (c, depth) {
+ var entry = c.querySelector(':scope > .entry') || c;
+ var au2 = entry.querySelector('.tagline .author'); var a = au2 ? au2.textContent.trim() : '[deleted]';
+ var md = entry.querySelector('.usertext-body .md');
+ var lines = ['**u/' + a + '**' + (author && a === author ? ' (OP)' : ''), ''].concat((md ? toMd(md.innerHTML) : '').split('\n'));
+ var res = quote(lines, depth);
+ Array.prototype.filter.call(c.querySelectorAll('.thing.comment'), function (k) { return nearest(k, '.thing.comment') === c; })
+ .forEach(function (k) { res.push(quote([''], depth)[0]); res = res.concat(renderOld(k, depth + 1)); });
+ return res;
+ };
+ topOld.forEach(function (c) { comments.push(renderOld(c, 1).join('\n')); });
+ }
+
+ if (!title) { var h1 = doc.querySelector('h1'); title = h1 && h1.textContent.trim(); }
+ if (title) out.push('# ' + title, '');
+ var meta = [sub, author ? 'u/' + author : ''].filter(Boolean).join(' · ');
+ if (meta) out.push('*' + meta + '*', '');
+ if (body) { var b = toMd(body.innerHTML); if (b) out.push(b, ''); }
+ if (comments.length) { out.push('---', '', '## ' + labels.comments + ' (' + comments.length + ')', '', comments.join('\n\n')); }
+ var res = out.join('\n').replace(/\n{3,}/g, '\n\n').trim();
+ return res.length > 20 ? res : null;
+ }
+
+ var REDDIT_LINE = /^(reply|share|report|save|follow|award|upvote|downvote|vote|more replies|\d+ more repl(y|ies)|level \d+|op|edited|•|·|u\/[\w-]+ avatar|r\/[\w-]+ icon|\d+(\.\d+)?\s*[kKmM]?|\d+\s*(s|m|h|d|w|mo|y|yr|min|hr)\.?\s*ago|go to comments|sort by:?|best|top|new|controversial|old|q&a|comments|antworten|teilen|melden)$/i;
+
+ function postprocess(md, opts) {
+ var lines = md.split('\n'), out = [], inFence = false;
+ for (var i = 0; i < lines.length; i++) {
+ var l = lines[i], t = l.trim();
+ if (/^(```|~~~)/.test(t)) { inFence = !inFence; out.push(l); continue; }
+ if (inFence) { out.push(l); continue; }
+ var bare = t.replace(/^[-*+]\s+/, '').replace(/^\[([^\]]*)\]\([^)]*\)$/, '$1').trim();
+ if (bare && BUTTON_WORDS.test(bare)) continue;
+ if (opts.reddit && bare && REDDIT_LINE.test(bare)) continue;
+ if (/^\[\s*\]\([^)]*\)$/.test(t) || /^[|•·\-–—]+$/.test(t) && !/^-{3,}$/.test(t)) continue;
+ out.push(l.replace(/\[\s*\]\([^)]*\)/g, ''));
+ }
+ return out.join('\n').replace(/\n{3,}/g, '\n\n').trim();
+ }
+
+ /* prepare(html, opts) → { md?, html?, kind, title? }
+ opts: { cleanup, reddit, toMd(htmlString)→md, labels:{comments} } */
+ function prepare(html, opts) {
+ var doc;
+ try { doc = new DOMParser().parseFromString(html, 'text/html'); } catch (e) { return { html: html, kind: 'plain' }; }
+ if (!doc || !doc.body) return { html: html, kind: 'plain' };
+
+ var reddit = isReddit(doc, html);
+ if (opts.reddit && reddit) {
+ try { var rmd = redditToMd(doc, opts.toMd, opts.labels); if (rmd) return { md: rmd, kind: 'reddit', reddit: true }; } catch (e) {}
+ }
+ if (!opts.cleanup) return { html: html, kind: 'plain', reddit: reddit };
+
+ var before = textLen(doc.body);
+ if (before > 1200 && typeof window.Readability === 'function' && looksLikePage(doc) && !reddit) {
+ try {
+ var art = new window.Readability(doc.cloneNode(true), { charThreshold: 250, keepClasses: false }).parse();
+ if (art && art.content && (art.textContent || '').trim().length > before * 0.3) {
+ var content = art.content;
+ if (art.title && content.indexOf(art.title) < 0) content = '' + esc(art.title) + '
' + content;
+ return { html: content, kind: 'article', title: art.title, reddit: reddit };
+ }
+ } catch (e) {}
+ }
+ var n = stripJunk(doc.body);
+ return { html: doc.body.innerHTML, kind: n ? 'cleaned' : 'plain', reddit: reddit };
+ }
+
+ window.Clip2mdClean = { prepare: prepare, postprocess: postprocess };
+})();
diff --git a/datenschutz.html b/datenschutz.html
index 0773c53..314f73f 100644
--- a/datenschutz.html
+++ b/datenschutz.html
@@ -64,6 +64,12 @@
Auf Smartphones übergibt „Speichern" die Datei an das Teilen-Menü Ihres Betriebssystems; wohin
Sie sie von dort weitergeben, entscheiden Sie selbst.
+
+ Das Aufräumen eingefügter Webseiten und die Aufbereitung von Reddit-Threads laufen ebenfalls
+ vollständig in Ihrem Browser. Der optionale Knopf „In Obsidian öffnen" legt den Text in die
+ Zwischenablage und übergibt ihn an die Obsidian-App auf Ihrem Gerät; an uns oder Dritte wird
+ dabei nichts übertragen.
+
3. Lokale Speicherung Ihrer Einstellungen
@@ -74,6 +80,13 @@
eingefügten Inhalte. Es handelt sich nicht um Cookies. Sie können die Werte jederzeit über
die Einstellungen Ihres Browsers löschen.
+
+ Nur wenn Sie in den Einstellungen „Letzte 10 Clips behalten" einschalten, speichert die Seite
+ die zuletzt bearbeiteten Texte in der lokalen Datenbank Ihres Browsers (IndexedDB).
+ Die Funktion ist standardmäßig ausgeschaltet. Die Einträge verlassen Ihr Gerät nicht, sind nur in
+ diesem Browser sichtbar und lassen sich im Verlauf einzeln oder vollständig löschen; beim
+ Ausschalten werden Sie gefragt, ob der Verlauf gelöscht werden soll.
+
Rechtsgrundlage für diese ausschließlich lokale Speicherung ist Ihr Wunsch nach einer
funktionsfähigen Bedienoberfläche; ein Zugriff unsererseits findet nicht statt.
@@ -132,28 +145,7 @@
vermeiden möchten, lassen Sie die Vorschau ausgeschaltet.
- 8. Spenden über PayPal
-
- Auf der Seite finden Sie einen Link „Spenden" bzw. „Buy me a coffee". Es handelt sich um einen
- einfachen Link; beim Aufruf von clip2md werden keine Inhalte, Skripte oder Cookies von PayPal
- geladen und keine Daten an PayPal übertragen. Erst wenn Sie den Link anklicken, verlassen Sie
- diese Seite und werden zu PayPal weitergeleitet.
-
-
- Anbieter ist die PayPal (Europe) S.à r.l. et Cie, S.C.A., 22–24 Boulevard Royal, L-2449
- Luxemburg. Für die Verarbeitung auf den Seiten von PayPal ist PayPal selbst verantwortlich; es
- gilt dessen Datenschutzerklärung:
- paypal.com/de/legalhub/privacy-full.
-
-
- Wenn Sie spenden, erhalten wir von PayPal die zur Abwicklung nötigen Angaben: Ihren Namen, Ihre
- E-Mail-Adresse, den Betrag, das Datum und gegebenenfalls eine Nachricht, die Sie hinzufügen.
- Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Abwicklung der Zuwendung) sowie Art. 6 Abs. 1
- lit. c DSGVO in Verbindung mit den steuer- und handelsrechtlichen Aufbewahrungspflichten
- (in der Regel zehn Jahre). Eine weitere Nutzung, etwa für Werbung, findet nicht statt.
-
-
- 9. Ihre Rechte
+ 8. Ihre Rechte
Sie haben das Recht auf Auskunft (Art. 15 DSGVO), Berichtigung (Art. 16), Löschung
(Art. 17), Einschränkung der Verarbeitung (Art. 18), Datenübertragbarkeit (Art. 20) sowie
@@ -161,20 +153,19 @@
(Art. 21). Wenden Sie sich dafür an die oben genannte Adresse.
- 10. Beschwerderecht
+ 9. Beschwerderecht
Sie können sich bei einer Datenschutz-Aufsichtsbehörde beschweren. Zuständig ist die
Landesbeauftragte für den Datenschutz Niedersachsen, Prinzenstraße 5, 30159 Hannover.
- 11. Stand
+ 10. Stand
Diese Datenschutzerklärung hat den Stand September 2026.