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 = '
+ 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. +
@@ -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.
-- 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. -
- -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.
-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.
-Diese Datenschutzerklärung hat den Stand September 2026.