diff --git a/backend/src/tools/paywallkiller/routes.js b/backend/src/tools/paywallkiller/routes.js index 6576e59..f486fce 100644 --- a/backend/src/tools/paywallkiller/routes.js +++ b/backend/src/tools/paywallkiller/routes.js @@ -2,12 +2,144 @@ const express = require('express'); const router = express.Router(); const { authenticate } = require('../../middleware/auth'); const https = require('https'); +const http = require('http'); +const crypto = require('crypto'); const path = require('path'); 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
ganz oben + html = html + // Kompletten Wayback/archive Header-Block entfernen + .replace(/]*id=["']HEADER["'][^>]*>[\s\S]*?<\/div>/gi, '') + .replace(/]*id=["']wm-ipp[^"']*["'][^>]*>[\s\S]*?<\/div>/gi, '') + // archive.ph spezifische Toolbar + .replace(/]*class=["'][^"']*THUMBS-BLOCK[^"']*["'][^>]*>[\s\S]*?<\/div>/gi, '') + .replace(/]*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 = ` + +`; + + // CSS vor einfügen + if (html.includes('')) { + html = html.replace('', printCss + ''); + } else { + html = printCss + html; + } + + // Base-Tag setzen damit relative URLs funktionieren + const baseTag = ``; + if (html.includes('')) { + html = html.replace('', '' + 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=... ────────────────────────────── -// Wayback CDX Proxy (für spätere Verwendung) router.get('/cdx', authenticate, async (req, res) => { const { url } = req.query; 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; const cdxUrl = `https://web.archive.org/cdx/search/cdx?url=${encodeURIComponent(targetUrl)}&output=json&limit=1&fl=timestamp&filter=statuscode:200&fastLatest=true`; try { - const result = await new Promise((resolve, reject) => { - const req2 = https.get(cdxUrl, { headers: { 'User-Agent': 'Mozilla/5.0' }, timeout: 20000 }, (res2) => { - let 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); + const r = await fetchHtml(cdxUrl); + if (r.status !== 200) return res.status(502).json({ error: `CDX ${r.status}` }); + const data = JSON.parse(r.body); 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}` }); } 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; diff --git a/frontend/src/tools/paywallkiller.jsx b/frontend/src/tools/paywallkiller.jsx index a7bded6..516c1fe 100644 --- a/frontend/src/tools/paywallkiller.jsx +++ b/frontend/src/tools/paywallkiller.jsx @@ -9,11 +9,13 @@ const C = { }; export default function PaywallKiller() { - const [inputUrl, setInputUrl] = useState(''); - const [status, setStatus] = useState('idle'); - const [archiveUrl, setArchiveUrl] = useState(''); - const [captchaDone, setCaptchaDone] = useState(false); - const inputRef = useRef(null); + const [inputUrl, setInputUrl] = useState(''); + const [snapshotUrl, setSnapshotUrl] = useState(''); + const [status, setStatus] = useState('idle'); // idle | step1 | step2 + const [archiveUrl, setArchiveUrl] = useState(''); + const [captchaDone, setCaptchaDone] = useState(false); + const inputRef = useRef(null); + const snapshotRef = useRef(null); function getTargetUrl() { const url = inputUrl.trim(); @@ -22,20 +24,38 @@ export default function PaywallKiller() { function handleSearch() { if (!inputUrl.trim()) return; - const aUrl = `https://archive.ph/newest/${getTargetUrl()}`; - setArchiveUrl(aUrl); - setStatus('open_archive'); + setArchiveUrl(`https://archive.ph/newest/${getTargetUrl()}`); + setStatus('step1'); 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() { setInputUrl(''); + setSnapshotUrl(''); setStatus('idle'); setArchiveUrl(''); setCaptchaDone(false); setTimeout(() => inputRef.current?.focus(), 50); } + const renderUrl = getRenderUrl(); + return (
@@ -44,7 +64,7 @@ export default function PaywallKiller() { 🔓 PAYWALL-KILLER
-
archive.ph · Als PDF speichern
+
archive.ph · Bereinigt · Als PDF drucken
{/* URL-Eingabe */} @@ -73,69 +93,75 @@ export default function PaywallKiller() { - {status === 'open_archive' && ( - <> - {/* Schritt 1 */} -
-
- 1 - ARCHIVE.PH ÖFFNEN & CAPTCHA LÖSEN -
- -
- Öffne archive.ph und löse das CAPTCHA. Warte bis der vollständige Artikel sichtbar ist, dann zurückkommen und Schritt 2 ausführen. -
- - - 🌐 archive.ph öffnen - - -
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 }}> -
- {captchaDone && } -
- - Artikel ist vollständig sichtbar - -
+ {/* Schritt 1: archive.ph öffnen */} + {(status === 'step1' || status === 'step2') && ( +
+
+ 1 + ARCHIVE.PH ÖFFNEN + {status === 'step2' && ✓ erledigt}
- {/* Schritt 2 – PDF über Browser-Druck */} - {captchaDone && ( -
-
- 2 - ALS PDF SPEICHERN + {status === 'step1' && ( + <> +
+ Öffne archive.ph. Falls ein CAPTCHA erscheint: lösen und warten bis der Artikel vollständig sichtbar ist. Dann die URL aus der Adressleiste kopieren – sie sieht dann so aus:
+ archive.ph/AbCdE oder archive.ph/20260608.../https://...
- -
- Öffne den Artikel nochmal und nutze die Drucken-Funktion deines Browsers um ihn als PDF zu speichern: -
- - {/* 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]) => ( -
- {icon} -
-
{label}
-
{desc}
-
-
- ))} - - 🖨 Artikel öffnen → Als PDF drucken + style={{ ...S.btn(C.accent), display: 'block', textAlign: 'center', textDecoration: 'none', boxSizing: 'border-box', fontSize: 13, marginBottom: 12 }}> + 🌐 archive.ph öffnen + + + )} +
+ )} + + {/* Schritt 2: Snapshot-URL einfügen + bereinigt öffnen */} + {status === 'step2' && ( +
+
+ 2 + SNAPSHOT-URL EINFÜGEN +
+ +
+ Kopiere die URL aus der Adressleiste von archive.ph und füge sie hier ein: +
+ + setSnapshotUrl(e.target.value)} + placeholder="https://archive.ph/AbCdE" + style={{ ...S.inp, width: '100%', boxSizing: 'border-box', marginBottom: 12, fontSize: 12 }} + /> + + {renderUrl ? ( + <> + + 🧹 Bereinigt öffnen (ohne Banner & Archiv-Links) + +
+
DANN ALS PDF DRUCKEN
+
+ 📱 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 +
+
+ + ) : ( +
+ Snapshot-URL oben einfügen um fortzufahren...
)} - +
)} {status === 'idle' && ( @@ -144,8 +170,8 @@ export default function PaywallKiller() { {[ ['1', 'URL des gesperrten Artikels einfügen'], ['2', 'archive.ph öffnen, CAPTCHA einmalig lösen'], - ['3', 'Bestätigen dass der Artikel sichtbar ist'], - ['4', 'Über Drucken-Funktion als PDF speichern'], + ['3', 'URL aus Adressleiste kopieren und hier einfügen'], + ['4', 'Bereinigt öffnen (kein Banner, normale Links) → als PDF drucken'], ].map(([num, text]) => (
{num}