feat: /render endpoint bereinigt archive.ph HTML (Banner weg, Links original)

This commit is contained in:
2026-06-12 16:04:41 +02:00
parent 2e89669c9b
commit 5469a17a3f
2 changed files with 230 additions and 162 deletions

View File

@@ -2,12 +2,144 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { authenticate } = require('../../middleware/auth'); const { authenticate } = require('../../middleware/auth');
const https = require('https'); const https = require('https');
const http = require('http');
const crypto = require('crypto');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const crypto = require('crypto');
// ── HTTPS GET Helper ───────────────────────────────────────────────────────
function fetchHtml(url, redirects = 0) {
return new Promise((resolve, reject) => {
if (redirects > 5) return reject(new Error('Zu viele Redirects'));
const mod = url.startsWith('https') ? https : http;
const req = mod.get(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml',
'Accept-Language': 'de,en;q=0.9',
'Accept-Encoding': 'identity',
},
timeout: 20000,
}, (res) => {
if ([301,302,303,307,308].includes(res.statusCode) && res.headers.location) {
let loc = res.headers.location;
if (!loc.startsWith('http')) {
const b = new URL(url);
loc = loc.startsWith('/') ? `${b.protocol}//${b.host}${loc}` : `${b.protocol}//${b.host}/${loc}`;
}
res.resume();
return fetchHtml(loc, redirects + 1).then(resolve).catch(reject);
}
let body = '';
res.setEncoding('utf8');
res.on('data', d => { if (body.length < 5000000) body += d; });
res.on('end', () => resolve({ status: res.statusCode, body, finalUrl: url }));
res.on('error', reject);
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); });
});
}
// ── GET /api/tools/paywallkiller/render?url=... ───────────────────────────
// Holt archive.ph Seite, entfernt Banner + schreibt Links um → sauberes HTML
router.get('/render', authenticate, async (req, res) => {
const { url } = req.query;
if (!url) return res.status(400).send('url fehlt');
// Nur archive.ph URLs erlauben
if (!/^https?:\/\/archive\.(ph|is|today|fo|li|vn|md|gg)\//.test(url)) {
return res.status(400).send('Nur archive.ph URLs erlaubt');
}
try {
const result = await fetchHtml(url);
if (result.status !== 200) {
return res.status(502).send(`archive.ph antwortete mit ${result.status}`);
}
let html = result.body;
// ── Banner entfernen ──────────────────────────────────────────────────
// Der archive.ph Banner hat die ID "HEADER" oder ist ein <div> ganz oben
html = html
// Kompletten Wayback/archive Header-Block entfernen
.replace(/<div[^>]*id=["']HEADER["'][^>]*>[\s\S]*?<\/div>/gi, '')
.replace(/<div[^>]*id=["']wm-ipp[^"']*["'][^>]*>[\s\S]*?<\/div>/gi, '')
// archive.ph spezifische Toolbar
.replace(/<div[^>]*class=["'][^"']*THUMBS-BLOCK[^"']*["'][^>]*>[\s\S]*?<\/div>/gi, '')
.replace(/<div[^>]*id=["']tools["'][^>]*>[\s\S]*?<\/div>/gi, '');
// ── Links zurück auf Originale umschreiben ────────────────────────────
// archive.ph schreibt Links um zu: https://archive.ph/TIMESTAMP/https://original.url
// Wir extrahieren die Original-URL
html = html.replace(
/https?:\/\/archive\.(?:ph|is|today|fo|li|vn|md|gg)\/[a-zA-Z0-9]+\/(https?:\/\/[^"'\s>]+)/g,
'$1'
);
// Auch relative Archive-Links
html = html.replace(
/href="\/[a-zA-Z0-9]{4,10}\/(https?:\/\/[^"]+)"/g,
'href="$1"'
);
// ── CSS für sauberes Drucken injizieren ───────────────────────────────
const printCss = `
<style id="pwk-print-style">
/* Archive.ph Banner + Toolbar ausblenden */
#HEADER, #tools, #THUMBS-BLOCK,
div[id^="HEADER"], div[id^="tools"],
.TEXT-BLOCK > div:first-child,
[class*="THUMBS"], [class*="tools-block"] {
display: none !important;
}
/* Druck-Optimierung */
@media print {
#HEADER, #tools, [id^="wm-"], [class*="archive-toolbar"],
div:first-child > div:first-child { display: none !important; }
body { margin: 0 !important; }
a[href] { color: inherit !important; text-decoration: none !important; }
a[href]::after { content: "" !important; }
}
</style>
<script>
// Banner sofort nach Load entfernen
document.addEventListener('DOMContentLoaded', function() {
['HEADER','tools','THUMBS-BLOCK'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.remove();
});
// Ersten Child-Div entfernen wenn er archive-Toolbar ist
var first = document.body && document.body.firstElementChild;
if (first && (first.id === 'HEADER' || first.className.includes('HEADER'))) {
first.remove();
}
});
</script>`;
// CSS vor </head> einfügen
if (html.includes('</head>')) {
html = html.replace('</head>', printCss + '</head>');
} else {
html = printCss + html;
}
// Base-Tag setzen damit relative URLs funktionieren
const baseTag = `<base href="${url}">`;
if (html.includes('<head>')) {
html = html.replace('<head>', '<head>' + baseTag);
}
res.setHeader('Content-Type', 'text/html; charset=utf-8');
// X-Frame-Options entfernen damit wir es anzeigen können
res.removeHeader('X-Frame-Options');
res.send(html);
} catch (err) {
res.status(500).send(`Fehler: ${err.message}`);
}
});
// ── GET /api/tools/paywallkiller/cdx?url=... ────────────────────────────── // ── GET /api/tools/paywallkiller/cdx?url=... ──────────────────────────────
// Wayback CDX Proxy (für spätere Verwendung)
router.get('/cdx', authenticate, async (req, res) => { router.get('/cdx', authenticate, async (req, res) => {
const { url } = req.query; const { url } = req.query;
if (!url) return res.status(400).json({ error: 'url fehlt' }); if (!url) return res.status(400).json({ error: 'url fehlt' });
@@ -15,19 +147,9 @@ router.get('/cdx', authenticate, async (req, res) => {
if (!targetUrl.startsWith('http')) targetUrl = 'https://' + targetUrl; if (!targetUrl.startsWith('http')) targetUrl = 'https://' + targetUrl;
const cdxUrl = `https://web.archive.org/cdx/search/cdx?url=${encodeURIComponent(targetUrl)}&output=json&limit=1&fl=timestamp&filter=statuscode:200&fastLatest=true`; const cdxUrl = `https://web.archive.org/cdx/search/cdx?url=${encodeURIComponent(targetUrl)}&output=json&limit=1&fl=timestamp&filter=statuscode:200&fastLatest=true`;
try { try {
const result = await new Promise((resolve, reject) => { const r = await fetchHtml(cdxUrl);
const req2 = https.get(cdxUrl, { headers: { 'User-Agent': 'Mozilla/5.0' }, timeout: 20000 }, (res2) => { if (r.status !== 200) return res.status(502).json({ error: `CDX ${r.status}` });
let body = ''; const data = JSON.parse(r.body);
res2.setEncoding('utf8');
res2.on('data', d => body += d);
res2.on('end', () => resolve({ status: res2.statusCode, body }));
res2.on('error', reject);
});
req2.on('error', reject);
req2.on('timeout', () => { req2.destroy(); reject(new Error('Timeout')); });
});
if (result.status !== 200) return res.status(502).json({ error: `CDX ${result.status}` });
const data = JSON.parse(result.body);
if (!Array.isArray(data) || data.length < 2 || !data[1]?.[0]) return res.json({ found: false }); if (!Array.isArray(data) || data.length < 2 || !data[1]?.[0]) return res.json({ found: false });
return res.json({ found: true, waybackUrl: `https://web.archive.org/web/${data[1][0]}/${targetUrl}` }); return res.json({ found: true, waybackUrl: `https://web.archive.org/web/${data[1][0]}/${targetUrl}` });
} catch (err) { } catch (err) {
@@ -35,84 +157,4 @@ router.get('/cdx', authenticate, async (req, res) => {
} }
}); });
// ── POST /api/tools/paywallkiller/pdf ─────────────────────────────────────
// Lädt archive.ph Snapshot-URL mit Puppeteer → PDF
// archive.ph Snapshot-URLs (hash-basiert) benötigen kein CAPTCHA
router.post('/pdf', authenticate, async (req, res) => {
const { archiveUrl } = req.body;
if (!archiveUrl || typeof archiveUrl !== 'string') return res.status(400).json({ error: 'archiveUrl fehlt' });
const tmpFile = path.join('/tmp', `pwk_${crypto.randomBytes(8).toString('hex')}.pdf`);
let browser = null;
try {
const puppeteer = require('puppeteer-core');
const candidates = [
process.env.CHROMIUM_PATH,
'/usr/bin/chromium-browser',
'/usr/bin/chromium',
'/usr/bin/google-chrome',
].filter(Boolean);
const executablePath = candidates.find(p => { try { fs.accessSync(p); return true; } catch { return false; } });
if (!executablePath) return res.status(500).json({ error: 'Chromium nicht gefunden' });
browser = await puppeteer.launch({
executablePath,
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage',
'--disable-gpu', '--single-process', '--no-zygote'],
timeout: 30000,
});
const page = await browser.newPage();
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36');
await page.setViewport({ width: 1280, height: 900 });
// archive.ph laden Snapshot-URL direkt (kein CAPTCHA bei direkten Snapshot-URLs)
await page.goto(archiveUrl, { waitUntil: 'networkidle0', timeout: 30000 });
// Prüfen ob CAPTCHA erschienen ist
const hasCaptcha = await page.evaluate(() => {
return document.title.includes('One more step') ||
document.body.innerText.includes('complete a CAPTCHA') ||
document.body.innerText.includes('security check');
});
if (hasCaptcha) {
await browser.close(); browser = null;
return res.status(403).json({ error: 'CAPTCHA bitte im Browser das Archiv öffnen und CAPTCHA lösen, dann erneut versuchen.' });
}
// UI-Störelemente entfernen
await page.evaluate(() => {
['#wm-ipp-base','#wm-ipp','#donato',
'#usercentrics-root','.uc-banner',
'#onetrust-consent-sdk','.onetrust-pc-dark-filter',
'[id*="cookie"]','[class*="cookie-banner"]'].forEach(sel => {
try { document.querySelectorAll(sel).forEach(el => el.remove()); } catch {}
});
document.body.style.overflow = 'visible';
});
await new Promise(r => setTimeout(r, 500));
await page.pdf({
path: tmpFile, format: 'A4', printBackground: true,
margin: { top: '10mm', bottom: '10mm', left: '10mm', right: '10mm' },
});
await browser.close(); browser = null;
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="artikel_${Date.now()}.pdf"`);
const stream = fs.createReadStream(tmpFile);
stream.pipe(res);
stream.on('end', () => fs.unlink(tmpFile, () => {}));
stream.on('error', () => fs.unlink(tmpFile, () => {}));
} catch (err) {
if (browser) try { await browser.close(); } catch {}
fs.unlink(tmpFile, () => {});
return res.status(500).json({ error: 'PDF fehlgeschlagen: ' + err.message });
}
});
module.exports = router; module.exports = router;

View File

@@ -9,11 +9,13 @@ const C = {
}; };
export default function PaywallKiller() { export default function PaywallKiller() {
const [inputUrl, setInputUrl] = useState(''); const [inputUrl, setInputUrl] = useState('');
const [status, setStatus] = useState('idle'); const [snapshotUrl, setSnapshotUrl] = useState('');
const [archiveUrl, setArchiveUrl] = useState(''); const [status, setStatus] = useState('idle'); // idle | step1 | step2
const [captchaDone, setCaptchaDone] = useState(false); const [archiveUrl, setArchiveUrl] = useState('');
const inputRef = useRef(null); const [captchaDone, setCaptchaDone] = useState(false);
const inputRef = useRef(null);
const snapshotRef = useRef(null);
function getTargetUrl() { function getTargetUrl() {
const url = inputUrl.trim(); const url = inputUrl.trim();
@@ -22,20 +24,38 @@ export default function PaywallKiller() {
function handleSearch() { function handleSearch() {
if (!inputUrl.trim()) return; if (!inputUrl.trim()) return;
const aUrl = `https://archive.ph/newest/${getTargetUrl()}`; setArchiveUrl(`https://archive.ph/newest/${getTargetUrl()}`);
setArchiveUrl(aUrl); setStatus('step1');
setStatus('open_archive');
setCaptchaDone(false); setCaptchaDone(false);
setSnapshotUrl('');
}
function handleCaptchaDone() {
setCaptchaDone(true);
setStatus('step2');
setTimeout(() => snapshotRef.current?.focus(), 100);
}
function getRenderUrl() {
const snap = snapshotUrl.trim();
// Snapshot-URL die der User eingefügt hat → über /render bereinigen
if (snap && snap.startsWith('https://archive.')) {
return `/api/tools/paywallkiller/render?url=${encodeURIComponent(snap)}`;
}
return null;
} }
function handleReset() { function handleReset() {
setInputUrl(''); setInputUrl('');
setSnapshotUrl('');
setStatus('idle'); setStatus('idle');
setArchiveUrl(''); setArchiveUrl('');
setCaptchaDone(false); setCaptchaDone(false);
setTimeout(() => inputRef.current?.focus(), 50); setTimeout(() => inputRef.current?.focus(), 50);
} }
const renderUrl = getRenderUrl();
return ( return (
<div style={{ padding: '20px 16px', maxWidth: 640, margin: '0 auto' }}> <div style={{ padding: '20px 16px', maxWidth: 640, margin: '0 auto' }}>
@@ -44,7 +64,7 @@ export default function PaywallKiller() {
<span style={{ fontSize: 20 }}>🔓</span> <span style={{ fontSize: 20 }}>🔓</span>
<span style={{ color: C.accent, fontFamily: 'monospace', fontSize: 13, letterSpacing: 1 }}>PAYWALL-KILLER</span> <span style={{ color: C.accent, fontFamily: 'monospace', fontSize: 13, letterSpacing: 1 }}>PAYWALL-KILLER</span>
</div> </div>
<div style={{ color: C.muted, fontSize: 11, fontFamily: 'monospace' }}>archive.ph · Als PDF speichern</div> <div style={{ color: C.muted, fontSize: 11, fontFamily: 'monospace' }}>archive.ph · Bereinigt · Als PDF drucken</div>
</div> </div>
{/* URL-Eingabe */} {/* URL-Eingabe */}
@@ -73,69 +93,75 @@ export default function PaywallKiller() {
</div> </div>
</div> </div>
{status === 'open_archive' && ( {/* Schritt 1: archive.ph öffnen */}
<> {(status === 'step1' || status === 'step2') && (
{/* Schritt 1 */} <div style={{ ...S.card, borderColor: status === 'step2' ? `${C.success}44` : `${C.accent}55`, marginBottom: 12 }}>
<div style={{ ...S.card, borderColor: captchaDone ? `${C.success}55` : `${C.accent}55`, marginBottom: 12 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}> <span style={{ background: `${C.accent}20`, border: `1px solid ${C.accent}44`, borderRadius: '50%', width: 22, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, color: C.accent, fontFamily: 'monospace', flexShrink: 0 }}>1</span>
<span style={{ background: `${C.accent}20`, border: `1px solid ${C.accent}44`, borderRadius: '50%', width: 22, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, color: C.accent, fontFamily: 'monospace', flexShrink: 0 }}>1</span> <span style={{ color: C.accent, fontFamily: 'monospace', fontSize: 12 }}>ARCHIVE.PH ÖFFNEN</span>
<span style={{ color: C.accent, fontFamily: 'monospace', fontSize: 12 }}>ARCHIVE.PH ÖFFNEN & CAPTCHA LÖSEN</span> {status === 'step2' && <span style={{ color: C.success, fontFamily: 'monospace', fontSize: 10 }}> erledigt</span>}
</div>
<div style={{ color: C.muted, fontSize: 11, fontFamily: 'monospace', lineHeight: 1.7, marginBottom: 12 }}>
Öffne archive.ph und löse das CAPTCHA. Warte bis der vollständige Artikel sichtbar ist, dann zurückkommen und Schritt 2 ausführen.
</div>
<a href={archiveUrl} target="_blank" rel="noreferrer"
style={{ ...S.btn(C.accent), display: 'block', textAlign: 'center', textDecoration: 'none', boxSizing: 'border-box', fontSize: 13, marginBottom: 12 }}>
🌐 archive.ph öffnen
</a>
<div onClick={() => setCaptchaDone(v => !v)}
style={{ display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer', padding: '10px 12px', background: captchaDone ? `${C.success}12` : 'rgba(255,255,255,0.03)', border: `1px solid ${captchaDone ? C.success + '55' : 'rgba(255,255,255,0.1)'}`, borderRadius: 8 }}>
<div style={{ width: 22, height: 22, borderRadius: 5, flexShrink: 0, background: captchaDone ? C.success : 'transparent', border: `2px solid ${captchaDone ? C.success : 'rgba(255,255,255,0.3)'}`, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{captchaDone && <span style={{ color: '#000', fontSize: 14, fontWeight: 'bold' }}></span>}
</div>
<span style={{ color: captchaDone ? C.success : 'rgba(255,255,255,0.6)', fontFamily: 'monospace', fontSize: 12 }}>
Artikel ist vollständig sichtbar
</span>
</div>
</div> </div>
{/* Schritt 2 PDF über Browser-Druck */} {status === 'step1' && (
{captchaDone && ( <>
<div style={{ ...S.card, borderColor: `${C.success}55`, marginBottom: 12 }}> <div style={{ color: C.muted, fontSize: 11, fontFamily: 'monospace', lineHeight: 1.7, marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}> Öffne archive.ph. Falls ein CAPTCHA erscheint: lösen und warten bis der Artikel vollständig sichtbar ist. Dann die <strong style={{ color: 'rgba(255,255,255,0.6)' }}>URL aus der Adressleiste</strong> kopieren sie sieht dann so aus:<br/>
<span style={{ background: `${C.success}20`, border: `1px solid ${C.success}44`, borderRadius: '50%', width: 22, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, color: C.success, fontFamily: 'monospace', flexShrink: 0 }}>2</span> <span style={{ color: C.accent }}>archive.ph/AbCdE</span> oder <span style={{ color: C.accent }}>archive.ph/20260608.../https://...</span>
<span style={{ color: C.success, fontFamily: 'monospace', fontSize: 12 }}>ALS PDF SPEICHERN</span>
</div> </div>
<div style={{ color: C.muted, fontSize: 11, fontFamily: 'monospace', lineHeight: 1.7, marginBottom: 14 }}>
Öffne den Artikel nochmal und nutze die <strong style={{ color: 'rgba(255,255,255,0.7)' }}>Drucken-Funktion</strong> deines Browsers um ihn als PDF zu speichern:
</div>
{/* Anleitung */}
{[
['📱', 'Android Chrome', 'Menü (⋮) → Teilen → Drucken → Als PDF speichern'],
['🍎', 'iOS Safari', 'Teilen → Als PDF sichern'],
['💻', 'Desktop', 'Strg+P / Cmd+P → Ziel: Als PDF speichern'],
].map(([icon, label, desc]) => (
<div key={label} style={{ display: 'flex', gap: 10, marginBottom: 8, padding: '8px 10px', background: 'rgba(255,255,255,0.03)', borderRadius: 6, border: '1px solid rgba(255,255,255,0.07)' }}>
<span style={{ fontSize: 16, flexShrink: 0 }}>{icon}</span>
<div>
<div style={{ color: 'rgba(255,255,255,0.7)', fontFamily: 'monospace', fontSize: 11, marginBottom: 2 }}>{label}</div>
<div style={{ color: C.muted, fontFamily: 'monospace', fontSize: 10, lineHeight: 1.5 }}>{desc}</div>
</div>
</div>
))}
<a href={archiveUrl} target="_blank" rel="noreferrer" <a href={archiveUrl} target="_blank" rel="noreferrer"
style={{ ...S.btn(C.success), display: 'block', textAlign: 'center', textDecoration: 'none', boxSizing: 'border-box', fontSize: 13, marginTop: 4 }}> style={{ ...S.btn(C.accent), display: 'block', textAlign: 'center', textDecoration: 'none', boxSizing: 'border-box', fontSize: 13, marginBottom: 12 }}>
🖨 Artikel öffnen Als PDF drucken 🌐 archive.ph öffnen
</a> </a>
<button onClick={handleCaptchaDone}
style={{ ...S.btn(C.success), width: '100%', fontSize: 12 }}>
Artikel ist sichtbar weiter
</button>
</>
)}
</div>
)}
{/* Schritt 2: Snapshot-URL einfügen + bereinigt öffnen */}
{status === 'step2' && (
<div style={{ ...S.card, borderColor: `${C.success}55`, marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
<span style={{ background: `${C.success}20`, border: `1px solid ${C.success}44`, borderRadius: '50%', width: 22, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, color: C.success, fontFamily: 'monospace', flexShrink: 0 }}>2</span>
<span style={{ color: C.success, fontFamily: 'monospace', fontSize: 12 }}>SNAPSHOT-URL EINFÜGEN</span>
</div>
<div style={{ color: C.muted, fontSize: 11, fontFamily: 'monospace', lineHeight: 1.6, marginBottom: 10 }}>
Kopiere die URL aus der Adressleiste von archive.ph und füge sie hier ein:
</div>
<input
ref={snapshotRef}
value={snapshotUrl}
onChange={e => setSnapshotUrl(e.target.value)}
placeholder="https://archive.ph/AbCdE"
style={{ ...S.inp, width: '100%', boxSizing: 'border-box', marginBottom: 12, fontSize: 12 }}
/>
{renderUrl ? (
<>
<a href={renderUrl} target="_blank" rel="noreferrer"
style={{ ...S.btn(C.success), display: 'block', textAlign: 'center', textDecoration: 'none', boxSizing: 'border-box', fontSize: 13, marginBottom: 10 }}>
🧹 Bereinigt öffnen (ohne Banner & Archiv-Links)
</a>
<div style={{ padding: '8px 12px', background: 'rgba(255,255,255,0.02)', borderRadius: 6, border: '1px solid rgba(255,255,255,0.06)' }}>
<div style={{ color: 'rgba(255,255,255,0.4)', fontFamily: 'monospace', fontSize: 10, marginBottom: 4 }}>DANN ALS PDF DRUCKEN</div>
<div style={{ color: C.muted, fontFamily: 'monospace', fontSize: 10, lineHeight: 1.6 }}>
📱 Android: Menü Teilen Drucken Als PDF · Kopf-/Fußzeilen aus{'\n'}
🍎 iOS: Teilen Als PDF sichern{'\n'}
💻 Desktop: Strg+P Als PDF · Kopf-/Fußzeilen aus
</div>
</div>
</>
) : (
<div style={{ color: C.muted, fontSize: 11, fontFamily: 'monospace', opacity: 0.5 }}>
Snapshot-URL oben einfügen um fortzufahren...
</div> </div>
)} )}
</> </div>
)} )}
{status === 'idle' && ( {status === 'idle' && (
@@ -144,8 +170,8 @@ export default function PaywallKiller() {
{[ {[
['1', 'URL des gesperrten Artikels einfügen'], ['1', 'URL des gesperrten Artikels einfügen'],
['2', 'archive.ph öffnen, CAPTCHA einmalig lösen'], ['2', 'archive.ph öffnen, CAPTCHA einmalig lösen'],
['3', 'Bestätigen dass der Artikel sichtbar ist'], ['3', 'URL aus Adressleiste kopieren und hier einfügen'],
['4', 'Über Drucken-Funktion als PDF speichern'], ['4', 'Bereinigt öffnen (kein Banner, normale Links) → als PDF drucken'],
].map(([num, text]) => ( ].map(([num, text]) => (
<div key={num} style={{ display: 'flex', gap: 10, alignItems: 'flex-start', marginBottom: 8 }}> <div key={num} style={{ display: 'flex', gap: 10, alignItems: 'flex-start', marginBottom: 8 }}>
<span style={{ background: `${C.accent}20`, border: `1px solid ${C.accent}44`, borderRadius: '50%', width: 20, height: 20, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 10, color: C.accent, fontFamily: 'monospace', marginTop: 2 }}>{num}</span> <span style={{ background: `${C.accent}20`, border: `1px solid ${C.accent}44`, borderRadius: '50%', width: 20, height: 20, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 10, color: C.accent, fontFamily: 'monospace', marginTop: 2 }}>{num}</span>