mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 23:39:40 +02:00
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:
parent
86c205ffc5
commit
efe1810b04
4 changed files with 590 additions and 92 deletions
|
|
@ -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',
|
||||
'datenschutzeinstellungen',
|
||||
'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 }),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 2–5 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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue