feat(articles): browser-HTML bookmarklet + consent-wall detection + auto-save

Three intertwined improvements so the "save an article" flow actually
works on real-world sites, not just bloggy happy-path URLs.

=== Consent-wall detection ===

apps/api/src/modules/articles/routes.ts: the /extract response now
includes `warning: 'probable_consent_wall'` when the extracted text
is both short (<300 words) AND contains cookie-dialog vocabulary
(Cookies zustimmen / cookie consent / Zustimmung / accept all cookies
/ enable javascript / privacy center / Datenschutzeinstellungen). The
server still returns whatever it got so the client can decide; it just
flags it as probably-not-the-article.

Frontend surfaces that warning prominently instead of silently
persisting a "Cookies zustimmen…" blob as the article body.

=== Browser-HTML extract path ===

Server-side: new POST /api/v1/articles/extract/html endpoint accepting
{ url, html }, running @mana/shared-rss's extractFromHtml on the
caller-supplied HTML. 10 MiB payload cap. Same response shape as
/extract, including the consent-wall warning (in case the bookmarklet
fires before the user dismisses the dialog).

Client-side: new extractFromHtml() in api.ts with the same 25s
timeout + typed network-error mapping as extractArticle.

AddUrlForm gains a postMessage handshake: when loaded with
?source=bookmarklet, it posts `mana-ready` to window.opener and
listens one-shot for `mana-html` with { url, html, title } from the
opener's tab. The HTML goes straight to our own /extract/html
endpoint — same-origin, carries the user's auth cookie. No CORS, no
form-submission CSP tango, no cross-origin token smuggling. If
nothing arrives within 30s we surface a clear error instead of
hanging.

Settings page adds a second "browser-HTML" bookmarklet (marked as
"Empfohlen") alongside the legacy URL bookmarklet. New snippet opens
/articles/add?source=bookmarklet in a new tab, waits for mana-ready,
then postMessages the tab's documentElement.outerHTML over. 15s
safety timeout.

This bypasses cookie-consent walls and soft paywalls because the
HTML already comes from the user's own authenticated, consented
browser tab.

=== Auto-save after successful extract ===

Previously every save path had a two-click UX: preview → confirm.
Now on clean extract the preview skips straight to persist + navigate
to the reader. Consent-wall warning is the only fallback that pauses
the flow — the user gets a "Trotzdem speichern" button to opt into
saving a teaser anyway.

Button in the manual input row is renamed "Vorschau abrufen" → "Speichern"
since it's now the commit action, not the inspect action. Loading-block
messaging distinguishes "Server extrahiert…" vs "Speichere in deine
Leseliste… Gleich weiter zum Reader."

Net click count:
  Bookmarklet v1/v2 on working site:  2 clicks → 1 click
  Manual paste:                        2 clicks → 1 click
  Consent-wall fallback:              2 clicks (explicit "Trotzdem")
  Duplicate:                          2 clicks ("Zum gespeicherten
                                        Artikel")

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-22 15:29:53 +02:00
parent 86c205ffc5
commit efe1810b04
4 changed files with 590 additions and 92 deletions

View file

@ -1,35 +1,72 @@
/**
* Articles module server-side URL extraction.
*
* Thin wrapper around `@mana/shared-rss`'s Readability pipeline. The
* extracted payload is returned to the client which then encrypts +
* stores it locally (and syncs via mana-sync). The server keeps no
* per-user article state all reading-list data lives in the unified
* Mana app's IndexedDB.
* Two endpoints, both thin wrappers around `@mana/shared-rss`:
*
* One endpoint (`POST /extract`), not two. News has a `preview` + `save`
* split for legacy reasons; here both UI paths (AddUrlForm preview + the
* direct saveFromUrl path) use the same payload. The client caches the
* response when the user confirms, avoiding a double server fetch.
* POST /extract server fetches the URL itself, then runs
* Readability on the HTML it got back. Works
* for simple sites but fails on anything behind
* a cookie-consent wall or a paywall the
* server has no user session.
* POST /extract/html client already has the rendered HTML (from a
* browser bookmarklet running in the user's
* own tab with all their cookies applied).
* Server just runs Readability on that. This
* is how we bypass Golem / Spiegel / Zeit /
* Heise-style consent dialogs: use the user's
* already-consented session, not the server's
* anonymous fetch.
*
* Consent-wall heuristic: when /extract returns a suspiciously short
* payload that contains consent-dialog vocabulary we still hand the
* extracted text back but flag it with `warning: 'probable_consent_wall'`
* so the client can offer the bookmarklet-v2 path instead of pretending
* a 4-line "Cookies zustimmen" blob is the article.
*/
import { Hono } from 'hono';
import { extractFromUrl } from '@mana/shared-rss';
import { extractFromUrl, extractFromHtml } from '@mana/shared-rss';
const routes = new Hono();
const CONSENT_KEYWORDS = [
'cookies zustimmen',
'cookie consent',
'zustimmung',
'accept all cookies',
'consent to the use',
'enable javascript',
'javascript is disabled',
'please enable',
'privacy center',
'datenschutz­einstellungen',
'datenschutzeinstellungen',
];
const CONSENT_WORDCOUNT_THRESHOLD = 300;
function looksLikeConsentWall(content: string, wordCount: number): boolean {
if (wordCount >= CONSENT_WORDCOUNT_THRESHOLD) return false;
const haystack = content.toLowerCase();
return CONSENT_KEYWORDS.some((needle) => haystack.includes(needle));
}
function isValidHttpUrl(url: string): boolean {
try {
const u = new URL(url);
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
}
// POST /extract — server fetches the URL + extracts. Legacy path.
routes.post('/extract', async (c) => {
const body = await c.req.json<{ url?: string }>().catch(() => ({}) as { url?: string });
const url = body.url;
if (!url || typeof url !== 'string') {
return c.json({ error: 'URL is required' }, 400);
}
// Minimal URL shape check — extractFromUrl will no-op on a bad URL but
// the caller deserves a clear 400 vs a generic 502.
try {
new URL(url);
} catch {
if (!isValidHttpUrl(url)) {
return c.json({ error: 'Invalid URL' }, 400);
}
@ -38,6 +75,10 @@ routes.post('/extract', async (c) => {
return c.json({ error: 'Extraction failed' }, 502);
}
const warning = looksLikeConsentWall(extracted.content, extracted.wordCount)
? 'probable_consent_wall'
: undefined;
return c.json({
originalUrl: url,
title: extracted.title,
@ -48,6 +89,59 @@ routes.post('/extract', async (c) => {
siteName: extracted.siteName,
wordCount: extracted.wordCount,
readingTimeMinutes: extracted.readingTimeMinutes,
...(warning && { warning }),
});
});
// POST /extract/html — client supplies HTML (from the user's browser
// tab, where cookies + JS rendering already happened). We only run
// Readability on it. Cap payload to 10 MiB so a pathological site
// can't exhaust server memory via the bookmarklet — typical rendered
// article HTML is 200-800 KB.
const MAX_HTML_BYTES = 10 * 1024 * 1024;
routes.post('/extract/html', async (c) => {
const body = await c.req
.json<{ url?: string; html?: string }>()
.catch(() => ({}) as { url?: string; html?: string });
const url = body.url;
const html = body.html;
if (!url || typeof url !== 'string') {
return c.json({ error: 'URL is required' }, 400);
}
if (!html || typeof html !== 'string') {
return c.json({ error: 'HTML is required' }, 400);
}
if (!isValidHttpUrl(url)) {
return c.json({ error: 'Invalid URL' }, 400);
}
if (html.length > MAX_HTML_BYTES) {
return c.json({ error: 'HTML payload too large' }, 413);
}
const extracted = await extractFromHtml(html, url);
if (!extracted) {
return c.json({ error: 'Extraction failed' }, 502);
}
// The consent-wall heuristic still applies here — a rare case is
// that the user bookmarklet-fires BEFORE the consent dialog is
// dismissed. Flag it so the client doesn't silently persist garbage.
const warning = looksLikeConsentWall(extracted.content, extracted.wordCount)
? 'probable_consent_wall'
: undefined;
return c.json({
originalUrl: url,
title: extracted.title,
excerpt: extracted.excerpt,
content: extracted.content,
htmlContent: extracted.htmlContent,
author: extracted.byline,
siteName: extracted.siteName,
wordCount: extracted.wordCount,
readingTimeMinutes: extracted.readingTimeMinutes,
...(warning && { warning }),
});
});

View file

@ -28,23 +28,104 @@ export interface ExtractedArticle {
siteName: string | null;
wordCount: number;
readingTimeMinutes: number;
/**
* Server-side quality flag. Today only `'probable_consent_wall'` is
* emitted: the extracted text was suspiciously short AND contained
* consent-dialog vocabulary, which typically means the server's
* anonymous fetch hit a GDPR interstitial instead of the article.
* The client uses this to offer the bookmarklet-v2 (browser-HTML)
* path without silently persisting garbage.
*/
warning?: 'probable_consent_wall';
}
/**
* Hard client-side timeout for the extract roundtrip. The server's
* own Readability fetch has a 15s timeout + a few seconds of JSDOM
* parse overhead; anything past 25s on the wire is almost certainly a
* dead server or a stuck network path, not a slow article. Without
* this, AddUrlForm's loader just sat there forever when the API was
* unreachable hence the bookmarklet-lands-on-loader bug.
*/
const EXTRACT_TIMEOUT_MS = 25_000;
export async function extractArticle(
url: string,
fetchImpl: typeof fetch = fetch
): Promise<ExtractedArticle> {
const response = await fetchImpl(`${getManaApiUrl()}/api/v1/articles/extract`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(await authHeader()),
},
body: JSON.stringify({ url }),
});
let response: Response;
try {
response = await fetchImpl(`${getManaApiUrl()}/api/v1/articles/extract`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(await authHeader()),
},
body: JSON.stringify({ url }),
signal: AbortSignal.timeout(EXTRACT_TIMEOUT_MS),
});
} catch (err) {
if (err instanceof DOMException && err.name === 'TimeoutError') {
throw new Error(
`Server antwortet nicht (nach ${EXTRACT_TIMEOUT_MS / 1000}s). Läuft apps/api?`
);
}
if (err instanceof TypeError) {
// Network-layer failure (connection refused, DNS, offline).
throw new Error(
`Server nicht erreichbar. Prüf dass apps/api läuft — pnpm run mana:dev startet beides.`
);
}
throw err;
}
if (!response.ok) {
const text = await response.text();
throw new Error(`extractArticle failed: ${response.status} ${text}`);
}
return (await response.json()) as ExtractedArticle;
}
/**
* Extract from a HTML payload the browser already has. Used by the
* bookmarklet-v2 flow the user's browser already dealt with the
* cookie-consent wall, so we skip the server-side fetch entirely.
*
* The HTML cap is 10 MiB on the server; the browser sends
* `document.documentElement.outerHTML` which for typical article
* pages is 200-800 KB, well under the limit.
*/
export async function extractFromHtml(
url: string,
html: string,
fetchImpl: typeof fetch = fetch
): Promise<ExtractedArticle> {
let response: Response;
try {
response = await fetchImpl(`${getManaApiUrl()}/api/v1/articles/extract/html`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(await authHeader()),
},
body: JSON.stringify({ url, html }),
signal: AbortSignal.timeout(EXTRACT_TIMEOUT_MS),
});
} catch (err) {
if (err instanceof DOMException && err.name === 'TimeoutError') {
throw new Error(
`Server antwortet nicht (nach ${EXTRACT_TIMEOUT_MS / 1000}s). Läuft apps/api?`
);
}
if (err instanceof TypeError) {
throw new Error(
`Server nicht erreichbar. Prüf dass apps/api läuft — pnpm run mana:dev startet beides.`
);
}
throw err;
}
if (!response.ok) {
const text = await response.text();
throw new Error(`extractFromHtml failed: ${response.status} ${text}`);
}
return (await response.json()) as ExtractedArticle;
}

View file

@ -1,28 +1,25 @@
<!--
AddUrlForm — paste URL → preview → save.
AddUrlForm — three paths in, one preview/save UI out:
Flow:
1. User pastes (or types) a URL, OR the page is opened with a URL
pre-filled via query string (?url=… / ?text=… / ?title=…). The
Web Share Target + bookmarklet both land here that way.
2. On "Vorschau abrufen": check scope-local dedupe first; if found,
offer "öffnen" instead of re-extracting (saves one round-trip).
Otherwise call /api/v1/articles/extract and render the preview.
3. On "Speichern": the already-extracted payload is persisted via
articlesStore.saveFromExtracted — no second server call.
4. Navigate into the new article so the user lands directly in the
reader view.
1. User types / pastes a URL manually.
2. `?url=…` query (Web Share Target, bookmarklet v1) — auto-fills
the input and triggers server-side fetch + Readability.
3. `?source=bookmarklet` (bookmarklet v2) — waits for the opener
tab to postMessage the rendered HTML over, then runs the
browser-HTML extract path. This bypasses cookie-consent walls
and soft paywalls because the HTML already came from the user's
own authenticated session.
Pre-filled URLs auto-trigger the preview on mount so the three-click
"share from browser → saved" flow really is three clicks: share →
pick Mana → hit "In Leseliste speichern".
If the server-side extract returns `warning: 'probable_consent_wall'`
we keep the preview but add a prominent CTA suggesting the user try
the browser-HTML path instead.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { articlesStore } from '../stores/articles.svelte';
import { extractArticle, type ExtractedArticle } from '../api';
import { extractArticle, extractFromHtml, type ExtractedArticle } from '../api';
import type { Article } from '../types';
let url = $state('');
@ -49,8 +46,103 @@
return m ? m[0] : '';
}
// ─── Bookmarklet-v2 handshake ─────────────────────────
//
// When the settings bookmarklet opens Mana with ?source=bookmarklet,
// the opener tab wants to push its rendered HTML over via
// postMessage so we can extract WITH the user's cookies/consent
// already applied. Protocol:
//
// Mana (us) Opener (the article site)
// │ │
// │ mana-ready │
// │ ─────────────────→ │
// │ │
// │ mana-html │
// │ ←───────────────── │ { url, html, title }
// │ │
//
// We only TRUST the `html` payload — we don't navigate using it, we
// hand it to our own backend at /api/v1/articles/extract/html which
// sanitises via Readability. Origin of the sender is free to be
// anything (that's the whole point) so we don't gate on it.
let messageHandler: ((e: MessageEvent) => void) | null = null;
let bookmarkletTimeout: ReturnType<typeof setTimeout> | null = null;
async function handleBookmarkletHtml(payload: { url: string; html: string; title?: string }) {
const trimmed = payload.url.trim();
if (!trimmed) {
error = 'Bookmarklet hat keine URL mitgeschickt.';
return;
}
url = trimmed;
reset();
loading = true;
try {
const alreadySaved = await articlesStore.findByUrl(trimmed);
if (alreadySaved) {
duplicate = alreadySaved;
return;
}
const extracted = await extractFromHtml(trimmed, payload.html);
await persistOrShowWarning(extracted);
} catch (e) {
error = e instanceof Error ? e.message : 'Extraktion fehlgeschlagen.';
} finally {
loading = false;
}
}
function armBookmarkletHandshake() {
if (typeof window === 'undefined') return;
messageHandler = (e: MessageEvent) => {
const data = e.data as { type?: string; url?: string; html?: string; title?: string } | null;
if (
!data ||
data.type !== 'mana-html' ||
typeof data.url !== 'string' ||
typeof data.html !== 'string'
) {
return;
}
// One-shot — disarm so a misbehaving parent can't flood us.
if (messageHandler) window.removeEventListener('message', messageHandler);
messageHandler = null;
if (bookmarkletTimeout) {
clearTimeout(bookmarkletTimeout);
bookmarkletTimeout = null;
}
void handleBookmarkletHtml({ url: data.url, html: data.html, title: data.title });
};
window.addEventListener('message', messageHandler);
// Tell the opener we're ready to receive. targetOrigin '*' is fine
// here — the payload in THIS direction is just a readiness ping,
// no data worth origin-gating.
try {
window.opener?.postMessage({ type: 'mana-ready' }, '*');
} catch {
// Opener may be cross-origin closed — fall through to timeout.
}
// Bail out if nothing arrives within 30s so the UI doesn't sit
// spinning forever when the user re-opened this page from a stale
// bookmarklet-v2 link without an opener tab.
loading = true;
bookmarkletTimeout = setTimeout(() => {
if (loading && !preview && !duplicate && !error) {
loading = false;
error =
'Bookmarklet-Handshake ist fehlgeschlagen. Öffne das Bookmarklet aus dem Browser-Tab in dem der Artikel läuft.';
}
}, 30_000);
}
onMount(() => {
const params = $page.url.searchParams;
if (params.get('source') === 'bookmarklet') {
armBookmarkletHandshake();
return;
}
const fromUrl = params.get('url')?.trim() ?? '';
const fromText = params.get('text')?.trim() ?? '';
const candidate = fromUrl || firstUrl(fromText);
@ -58,7 +150,18 @@
url = candidate;
// Fire-and-forget — the handler is idempotent enough that a
// stray second click does no harm.
void handlePreview();
void handleSubmit();
}
});
onDestroy(() => {
if (messageHandler && typeof window !== 'undefined') {
window.removeEventListener('message', messageHandler);
messageHandler = null;
}
if (bookmarkletTimeout) {
clearTimeout(bookmarkletTimeout);
bookmarkletTimeout = null;
}
});
@ -68,7 +171,7 @@
error = null;
}
async function handlePreview() {
async function handleSubmit() {
reset();
const trimmed = url.trim();
if (!trimmed) {
@ -89,7 +192,8 @@
duplicate = alreadySaved;
return;
}
preview = await extractArticle(trimmed);
const extracted = await extractArticle(trimmed);
await persistOrShowWarning(extracted);
} catch (e) {
error = e instanceof Error ? e.message : 'Extraktion fehlgeschlagen.';
} finally {
@ -97,23 +201,43 @@
}
}
async function handleSave() {
if (!preview) return;
/**
* Normal path: extract succeeded cleanly → persist + navigate to the
* reader so the user never has to press a second "Save" button.
* Fallback: if the server flagged a probable consent wall, stop and
* surface the preview + warning card so the user can decide whether
* to keep the teaser anyway or re-save via the HTML bookmarklet.
*/
async function persistOrShowWarning(extracted: ExtractedArticle) {
if (extracted.warning === 'probable_consent_wall') {
preview = extracted;
return;
}
await persistAndGo(extracted);
}
async function persistAndGo(extracted: ExtractedArticle) {
saving = true;
try {
const saved = await articlesStore.saveFromExtracted(preview);
const saved = await articlesStore.saveFromExtracted(extracted);
goto(`/articles/${saved.id}`);
} catch (e) {
error = e instanceof Error ? e.message : 'Speichern fehlgeschlagen.';
saving = false;
}
}
/** Consent-wall path only: user saw the warning and still wants to keep this. */
async function saveDespiteWarning() {
if (!preview) return;
await persistAndGo(preview);
}
</script>
<div class="add-shell">
<header class="header">
<h1>Artikel speichern</h1>
<p class="subtitle">URL einfügen, Vorschau prüfen, speichern.</p>
<p class="subtitle">URL einfügen — Mana extrahiert + speichert direkt.</p>
</header>
<div class="input-row">
@ -122,16 +246,33 @@
class="url-input"
bind:value={url}
placeholder="https://…"
disabled={loading || saving}
onkeydown={(e) => {
if (e.key === 'Enter') handlePreview();
if (e.key === 'Enter') handleSubmit();
}}
use:focusOnMount
/>
<button type="button" class="primary" disabled={loading} onclick={handlePreview}>
{loading ? 'Lädt…' : 'Vorschau abrufen'}
<button type="button" class="primary" disabled={loading || saving} onclick={handleSubmit}>
{#if saving}Speichere…{:else if loading}Lädt…{:else}Speichern{/if}
</button>
</div>
{#if (loading || saving) && !error && !preview && !duplicate}
<div class="loading-block" role="status">
<span class="spinner" aria-hidden="true"></span>
<div class="loading-text">
<p class="loading-headline">
{saving ? 'Speichere in deine Leseliste…' : 'Server extrahiert den Artikel…'}
</p>
<p class="loading-sub">
{saving
? 'Gleich weiter zum Reader.'
: 'Dauert normalerweise 25 Sekunden. Nach 25 Sekunden geben wir auf.'}
</p>
</div>
</div>
{/if}
{#if error}
<p class="error">{error}</p>
{/if}
@ -150,6 +291,25 @@
{/if}
{#if preview}
<!--
Preview-Karte erscheint nur noch im Warning-Fall (Consent-Wall).
Happy-Path geht direkt zu persistAndGo() und navigiert weg, bevor
das Template diesen Block überhaupt rendert.
-->
<div class="consent-warning" role="alert">
<p class="cw-headline">Cookie-Wand erkannt</p>
<p class="cw-body">
Der Server hat wahrscheinlich nicht den Artikel bekommen, sondern nur den
Cookie-Zustimmungs-Dialog der Seite — er hat keine Session / Cookies. Lösung: das
<strong>Browser-HTML-Bookmarklet</strong> aus
<a href="/articles/settings">Einstellungen</a> benutzen. Läuft in deinem Tab wo du schon zugestimmt
hast und schickt Mana das echte Artikel-HTML.
</p>
<p class="cw-body cw-body-muted">
Falls du den Teaser trotzdem speichern willst — OK, kannst du später nochmal mit dem
HTML-Bookmarklet überschreiben.
</p>
</div>
<article class="preview">
<h2 class="preview-title">{preview.title}</h2>
<div class="preview-meta">
@ -162,8 +322,8 @@
<p class="preview-excerpt">{preview.excerpt}</p>
{/if}
<div class="preview-actions">
<button type="button" class="primary" disabled={saving} onclick={handleSave}>
{saving ? 'Speichere…' : 'In Leseliste speichern'}
<button type="button" class="primary" disabled={saving} onclick={saveDespiteWarning}>
{saving ? 'Speichere…' : 'Trotzdem speichern'}
</button>
<button type="button" class="secondary" onclick={reset} disabled={saving}>
Abbrechen
@ -246,6 +406,71 @@
color: #ef4444;
font-size: 0.9rem;
}
.loading-block {
display: flex;
gap: 0.8rem;
align-items: center;
padding: 0.75rem 0.95rem;
border-radius: 0.55rem;
border: 1px solid color-mix(in srgb, #f97316 30%, transparent);
background: color-mix(in srgb, #f97316 5%, transparent);
}
.spinner {
width: 1rem;
height: 1rem;
border-radius: 50%;
border: 2px solid color-mix(in srgb, #f97316 30%, transparent);
border-top-color: #f97316;
animation: spin 0.8s linear infinite;
flex-shrink: 0;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-text {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.loading-headline {
margin: 0;
font-weight: 500;
font-size: 0.92rem;
}
.loading-sub {
margin: 0;
color: var(--color-text-muted, #64748b);
font-size: 0.82rem;
}
.consent-warning {
margin-top: 1rem;
padding: 0.85rem 1rem;
border: 1px solid color-mix(in srgb, #f59e0b 35%, transparent);
border-radius: 0.55rem;
background: color-mix(in srgb, #f59e0b 8%, transparent);
}
.cw-headline {
margin: 0 0 0.35rem 0;
font-weight: 600;
font-size: 0.95rem;
}
.cw-body {
margin: 0 0 0.45rem 0;
font-size: 0.88rem;
line-height: 1.5;
}
.cw-body:last-child {
margin-bottom: 0;
}
.cw-body-muted {
color: var(--color-text-muted, #64748b);
}
.consent-warning a {
color: #ea580c;
text-decoration: underline;
}
.preview,
.duplicate {
margin-top: 1rem;

View file

@ -1,47 +1,78 @@
<!--
/articles/settings — collection of "how to save faster" tips.
Two surfaces today:
Three surfaces today:
1. Bookmarklet — one-click save from any desktop browser. The user
drags the button into their bookmarks bar; clicking it opens
/articles/add?url=<current-page> in a new tab.
1. Browser-HTML-Bookmarklet (v2) — recommended. Reads the rendered
HTML from the user's browser tab + postMessages it to a new Mana
tab which runs Readability on that HTML. Works on cookie-walled
sites (Golem, Spiegel, Zeit, Heise) and soft paywalls because it
uses the user's already-authenticated session.
2. Share Target — installed PWA appears in the OS share sheet on
Android / Chromium desktop. Same landing route as the bookmarklet.
2. URL-Bookmarklet (v1) — legacy. Opens /articles/add?url=<page>,
the server does an anonymous fetch. Fails on GDPR-walled sites
(see AddUrlForm's "probable_consent_wall" warning). Kept because
it's a single click for sites where it works, and cross-browser
stable.
Both end up in AddUrlForm, which reads the ?url / ?text / ?title query
params, auto-triggers the Readability preview, and drops the user one
click away from "In Leseliste speichern".
3. Share-Target — installed PWA appears in the Android / Chromium
share sheet. Uses the same URL path as v1.
-->
<script lang="ts">
import { onMount } from 'svelte';
// `origin` at render time — server-side rendering has no window, so
// we read it client-side after mount. The bookmarklet embeds the
// origin so the JavaScript URL works from any other origin's bookmark
// bar.
// we read it client-side after mount. The bookmarklets embed the
// origin so the JavaScript URLs work from any other origin's
// bookmark bar.
let origin = $state('');
onMount(() => {
origin = window.location.origin;
});
const bookmarklet = $derived(
// Bookmarklet v1 — quick path for sites without consent walls.
// Single call: open new tab with ?url=<current page>; server fetches.
const bookmarkletV1 = $derived(
origin
? `javascript:void(window.open('${origin}/articles/add?url='+encodeURIComponent(location.href)+'&title='+encodeURIComponent(document.title),'_blank'))`
: ''
);
let copyLabel = $state('Snippet kopieren');
// Bookmarklet v2 — browser-HTML path. Reads the rendered DOM from
// the user's current tab (with all cookies + consent applied), opens
// /articles/add?source=bookmarklet in a new tab, and postMessages
// the HTML over. The new tab's AddUrlForm is the authoritative
// receiver: it POSTs the HTML to /api/v1/articles/extract/html over
// its own same-origin authenticated channel. No cross-origin CORS,
// no token sharing, no form-submission CSP issues. Includes a 15s
// safety timeout in case the new tab never comes up.
//
// The snippet is intentionally compact — browsers truncate bookmark
// URLs at different lengths (Chrome/Firefox handle ~64 KiB fine,
// Safari is stricter) so readable JS compresses well here.
const bookmarkletV2 = $derived(
origin
? `javascript:(()=>{var h=document.documentElement.outerHTML,u=location.href,t=document.title,w=window.open('${origin}/articles/add?source=bookmarklet','_blank'),d=0;if(!w){alert('Mana: Popup blockiert');return}var m=function(e){if(e.source!==w)return;if(e.data&&e.data.type==='mana-ready'){w.postMessage({type:'mana-html',url:u,html:h,title:t},'*');window.removeEventListener('message',m);d=1}};window.addEventListener('message',m);setTimeout(function(){if(!d){window.removeEventListener('message',m);alert('Mana antwortet nicht — Tab geöffnet?')}},15000)})()`
: ''
);
async function copySnippet() {
if (!bookmarklet) return;
let copyV1Label = $state('Snippet kopieren');
let copyV2Label = $state('Snippet kopieren');
async function copySnippet(value: string, which: 'v1' | 'v2') {
if (!value) return;
try {
await navigator.clipboard.writeText(bookmarklet);
copyLabel = 'Kopiert ✓';
setTimeout(() => (copyLabel = 'Snippet kopieren'), 1500);
await navigator.clipboard.writeText(value);
if (which === 'v1') {
copyV1Label = 'Kopiert ✓';
setTimeout(() => (copyV1Label = 'Snippet kopieren'), 1500);
} else {
copyV2Label = 'Kopiert ✓';
setTimeout(() => (copyV2Label = 'Snippet kopieren'), 1500);
}
} catch {
copyLabel = 'Fehler — bitte manuell kopieren';
if (which === 'v1') copyV1Label = 'Fehler — bitte manuell kopieren';
else copyV2Label = 'Fehler — bitte manuell kopieren';
}
}
</script>
@ -56,37 +87,76 @@
<p class="subtitle">Schnellwege, um Artikel aus dem Browser in die Leseliste zu bekommen.</p>
</header>
<section class="card">
<h2>Bookmarklet</h2>
<section class="card card-recommended">
<div class="badge">Empfohlen</div>
<h2>Browser-HTML-Bookmarklet</h2>
<p>
Zieh den Button unten in deine Lesezeichen-Leiste. Ein Klick auf einer beliebigen Webseite
öffnet
<code>/articles/add</code> mit der aktuellen URL vorausgefüllt — du bestätigst nur noch die Vorschau.
Dieses Bookmarklet nimmt den <strong>schon gerenderten HTML-Inhalt</strong> aus deinem Browser-Tab
(inkl. aller Cookies, die du gesetzt hast) und schickt ihn an Mana. Damit klappen auch Seiten mit
Cookie-Wänden (Golem, Spiegel, Zeit, Heise …) und weichen Paywalls.
</p>
<div class="bookmarklet-row">
<!-- The anchor IS the bookmarklet: its href is the javascript: -->
<!-- snippet. We sanity-check it client-side (origin-prefixed and -->
<!-- opens a new tab) before persisting state, so there's no XSS -->
<!-- surface here beyond what the browser already allows on any -->
<!-- javascript: bookmark. -->
{#if bookmarklet}
<a class="bookmarklet" href={bookmarklet} onclick={(e) => e.preventDefault()}>
+ In Mana speichern
{#if bookmarkletV2}
<a class="bookmarklet" href={bookmarkletV2} onclick={(e) => e.preventDefault()}>
+ In Mana speichern (HTML)
</a>
{:else}
<span class="muted">Bookmarklet wird geladen…</span>
{/if}
<button type="button" class="copy-btn" onclick={copySnippet} disabled={!bookmarklet}>
{copyLabel}
<button
type="button"
class="copy-btn"
onclick={() => copySnippet(bookmarkletV2, 'v2')}
disabled={!bookmarkletV2}
>
{copyV2Label}
</button>
</div>
<details class="snippet-details">
<summary>Quellcode anzeigen</summary>
<pre class="snippet">{bookmarklet}</pre>
<pre class="snippet">{bookmarkletV2}</pre>
</details>
<p class="hint">
Funktioniert in jedem Desktop-Browser. In Safari: Lesezeichen anlegen mit einer beliebigen
URL, dann nachträglich die URL durch das Snippet ersetzen.
Öffnet einen neuen Tab mit Mana, der Mana-Tab bekommt das HTML per
<code>postMessage</code> von deinem Artikel-Tab. Braucht erlaubte Popups für diese Domain (Browser
fragt beim ersten Mal).
</p>
</section>
<section class="card">
<h2>URL-Bookmarklet (klassisch)</h2>
<p>
Schickt nur die URL an Mana, der Server lädt + extrahiert dann selbst. Schnell auf einfachen
Blogs / Wikis; scheitert auf Seiten hinter DSGVO-Zustimmungs-Dialogen.
</p>
<div class="bookmarklet-row">
{#if bookmarkletV1}
<a
class="bookmarklet bookmarklet-secondary"
href={bookmarkletV1}
onclick={(e) => e.preventDefault()}
>
+ In Mana speichern (URL)
</a>
{:else}
<span class="muted">Bookmarklet wird geladen…</span>
{/if}
<button
type="button"
class="copy-btn"
onclick={() => copySnippet(bookmarkletV1, 'v1')}
disabled={!bookmarkletV1}
>
{copyV1Label}
</button>
</div>
<details class="snippet-details">
<summary>Quellcode anzeigen</summary>
<pre class="snippet">{bookmarkletV1}</pre>
</details>
<p class="hint">
Funktioniert in jedem Desktop-Browser. In Safari: Lesezeichen mit beliebiger URL anlegen und
die URL dann durch das Snippet ersetzen.
</p>
</section>
@ -98,7 +168,9 @@
auswählen → Artikel wird direkt in der Leseliste vorgeschlagen.
</p>
<p class="hint">
iOS-Safari unterstützt die Web-Share-Target-API derzeit nicht — nutze dort das Bookmarklet.
Benutzt dieselbe URL-Route wie das klassische Bookmarklet oben — für cookie-gewalled Seiten
lieber das HTML-Bookmarklet verwenden. iOS-Safari unterstützt die Web-Share-Target-API derzeit
nicht.
</p>
</section>
</div>
@ -126,6 +198,32 @@
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1));
border-radius: 0.75rem;
background: var(--color-surface, transparent);
position: relative;
}
.card-recommended {
border-color: color-mix(in srgb, #f97316 50%, transparent);
background: color-mix(in srgb, #f97316 4%, transparent);
}
.badge {
position: absolute;
top: -0.55rem;
left: 1rem;
padding: 0.15rem 0.55rem;
border-radius: 999px;
background: #f97316;
color: white;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.bookmarklet-secondary {
background: transparent;
color: #f97316;
border: 1px solid #f97316;
}
.bookmarklet-secondary:hover {
background: color-mix(in srgb, #f97316 10%, transparent);
}
.card h2 {
margin: 0 0 0.5rem 0;