From 1cf92c9a2f3df30dc6af2cb3a970909188f5ec6c Mon Sep 17 00:00:00 2001 From: Dicken Date: Fri, 12 Jun 2026 00:09:17 +0200 Subject: [PATCH] feat: Paywall-Killer Tool (archive.is + PDF-Export) --- Dockerfile | 5 +- backend/package.json | 4 +- backend/src/tools/paywallkiller/routes.js | 156 +++++++++++ frontend/src/icons.jsx | 7 + frontend/src/toolRegistry.js | 11 +- frontend/src/tools/paywallkiller.jsx | 302 ++++++++++++++++++++++ 6 files changed, 482 insertions(+), 3 deletions(-) create mode 100644 backend/src/tools/paywallkiller/routes.js create mode 100644 frontend/src/tools/paywallkiller.jsx diff --git a/Dockerfile b/Dockerfile index 531fb7a..4c605e3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,10 @@ RUN npm run build # ── Stage 2: Express liefert API + React in einem Container ────────────────── FROM node:20-alpine -RUN apk add --no-cache tzdata python3 make g++ +RUN apk add --no-cache tzdata python3 make g++ \ + chromium nss freetype harfbuzz ca-certificates ttf-freefont +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \ + CHROMIUM_PATH=/usr/bin/chromium-browser WORKDIR /app COPY backend/package.json ./ RUN npm install --production diff --git a/backend/package.json b/backend/package.json index 40bd080..457055b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,6 +7,8 @@ "better-sqlite3": "^9.4.3", "express": "^4.18.2", "jsonwebtoken": "^9.0.2", - "multer": "^1.4.5-lts.1" + "multer": "^1.4.5-lts.1", + "puppeteer-core": "^22.0.0", + "@sparticuz/chromium": "^123.0.0" } } diff --git a/backend/src/tools/paywallkiller/routes.js b/backend/src/tools/paywallkiller/routes.js new file mode 100644 index 0000000..0a93fcf --- /dev/null +++ b/backend/src/tools/paywallkiller/routes.js @@ -0,0 +1,156 @@ +const express = require('express'); +const router = express.Router(); +const { authenticate } = require('../../middleware/auth'); +const https = require('https'); +const http = require('http'); +const path = require('path'); +const fs = require('fs'); +const crypto = require('crypto'); + +// ── Hilfsfunktion: HTTP GET mit Follow-Redirect ──────────────────────────── +function fetchUrl(url, options = {}) { + return new Promise((resolve, reject) => { + const mod = url.startsWith('https') ? https : http; + const req = mod.get(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; DickenDock/1.0)', + ...options.headers, + }, + timeout: 10000, + }, (res) => { + // Redirects manuell folgen (max 5) + if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location && (options.redirects || 0) < 5) { + const location = res.headers.location.startsWith('http') + ? res.headers.location + : new URL(res.headers.location, url).href; + res.resume(); + return fetchUrl(location, { ...options, redirects: (options.redirects || 0) + 1 }) + .then(resolve).catch(reject); + } + resolve({ statusCode: res.statusCode, url, finalUrl: url, headers: res.headers, res }); + }); + req.on('error', reject); + req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); }); + }); +} + +// ── Prüfe ob archive.is-URL existiert (HEAD-ähnlich via GET + sofortiges Abbrechen) ── +async function checkArchive(archiveUrl) { + try { + const result = await fetchUrl(archiveUrl); + result.res.destroy(); + // archive.is gibt 200 zurück wenn Archiv existiert, 404 oder Redirect zu / wenn nicht + if (result.statusCode === 200) return true; + return false; + } catch { + return false; + } +} + +// ── POST /api/tools/paywallkiller/check ─────────────────────────────────── +// Body: { url: "https://..." } +// Response: { archiveUrl: "https://archive.is/...", type: "newest"|"oldest" } +router.post('/check', authenticate, async (req, res) => { + const { url } = req.body; + if (!url || typeof url !== 'string') return res.status(400).json({ error: 'URL fehlt' }); + + let targetUrl = url.trim(); + if (!targetUrl.startsWith('http')) targetUrl = 'https://' + targetUrl; + + const newestUrl = `https://archive.is/newest/${targetUrl}`; + const oldestUrl = `https://archive.is/oldest/${targetUrl}`; + + try { + const newestOk = await checkArchive(newestUrl); + if (newestOk) { + return res.json({ archiveUrl: newestUrl, type: 'newest' }); + } + const oldestOk = await checkArchive(oldestUrl); + if (oldestOk) { + return res.json({ archiveUrl: oldestUrl, type: 'oldest' }); + } + return res.status(404).json({ error: 'Kein Archiv für diese URL gefunden' }); + } catch (err) { + return res.status(500).json({ error: 'Fehler beim Prüfen: ' + err.message }); + } +}); + +// ── POST /api/tools/paywallkiller/pdf ───────────────────────────────────── +// Body: { archiveUrl: "https://archive.is/..." } +// Response: PDF als Download-Stream, Datei danach gelöscht +router.post('/pdf', authenticate, async (req, res) => { + const { archiveUrl } = req.body; + if (!archiveUrl || typeof archiveUrl !== 'string') return res.status(400).json({ error: 'archiveUrl fehlt' }); + if (!archiveUrl.startsWith('https://archive.is/')) return res.status(400).json({ error: 'Ungültige archive.is URL' }); + + let browser; + const tmpFile = path.join('/tmp', `pwk_${crypto.randomBytes(8).toString('hex')}.pdf`); + + try { + let puppeteer, chromium; + + // Versuche puppeteer-core + @sparticuz/chromium (Alpine-kompatibel) + try { + puppeteer = require('puppeteer-core'); + chromium = require('@sparticuz/chromium'); + } catch { + // Fallback: normales puppeteer (falls installiert) + try { + puppeteer = require('puppeteer'); + chromium = null; + } catch { + return res.status(500).json({ error: 'PDF-Rendering nicht verfügbar – puppeteer nicht installiert' }); + } + } + + const launchOptions = chromium + ? { + args: [ + ...chromium.args, + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + ], + defaultViewport: { width: 1280, height: 900 }, + executablePath: process.env.CHROMIUM_PATH || await chromium.executablePath(), + headless: true, + } + : { headless: 'new', args: ['--no-sandbox', '--disable-setuid-sandbox'] }; + + browser = await puppeteer.launch(launchOptions); + const page = await browser.newPage(); + + await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'); + await page.goto(archiveUrl, { waitUntil: 'networkidle2', timeout: 30000 }); + + await page.pdf({ + path: tmpFile, + format: 'A4', + printBackground: true, + margin: { top: '10mm', bottom: '10mm', left: '10mm', right: '10mm' }, + }); + + await browser.close(); + browser = null; + + const filename = 'archiv_' + Date.now() + '.pdf'; + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + + 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-Erstellung fehlgeschlagen: ' + err.message }); + } +}); + +module.exports = router; diff --git a/frontend/src/icons.jsx b/frontend/src/icons.jsx index aeb2261..7453d71 100644 --- a/frontend/src/icons.jsx +++ b/frontend/src/icons.jsx @@ -153,6 +153,13 @@ export const SkizzeIcon = ({size=20,color='currentColor',sw}) => base(<> export const WrenchIcon = ({size=20,color='currentColor',sw}) => base(<> , size, color, sw); +export const PaywallIcon = ({size=20,color='currentColor',sw}) => base(<> + + + + + +, size, color, sw); export const FilmIcon = ({size=20,color='currentColor',sw}) => base(<> diff --git a/frontend/src/toolRegistry.js b/frontend/src/toolRegistry.js index c713d5a..974672c 100644 --- a/frontend/src/toolRegistry.js +++ b/frontend/src/toolRegistry.js @@ -7,7 +7,8 @@ import CodeSchnipsel from './tools/codeschnipsel.jsx'; import Skizze from './tools/skizze.jsx'; import DevTools from './tools/devtools.jsx'; import Media from './tools/media.jsx'; -import { CalculatorIcon, ArchiveIcon, DatabaseIcon, AppsIcon, MessageIcon, LinkIcon, CodeIcon, SkizzeIcon, WrenchIcon, FilmIcon } from './icons.jsx'; +import PaywallKiller from './tools/paywallkiller.jsx'; +import { CalculatorIcon, ArchiveIcon, DatabaseIcon, AppsIcon, MessageIcon, LinkIcon, CodeIcon, SkizzeIcon, WrenchIcon, FilmIcon, PaywallIcon } from './icons.jsx'; export const TOOLS = [ { @@ -82,6 +83,14 @@ export const TOOLS = [ group: 'Werkzeuge', component: DevTools, }, + { + id: 'paywallkiller', + Icon: PaywallIcon, + label: 'Paywall-Killer', + navLabel: 'Paywall', + group: 'Werkzeuge', + component: PaywallKiller, + }, { id: 'media', Icon: FilmIcon, diff --git a/frontend/src/tools/paywallkiller.jsx b/frontend/src/tools/paywallkiller.jsx new file mode 100644 index 0000000..a94dd9b --- /dev/null +++ b/frontend/src/tools/paywallkiller.jsx @@ -0,0 +1,302 @@ +import { useState, useRef } from 'react'; +import { S, api } from '../lib.js'; + +// ── Farben ───────────────────────────────────────────────────────────────── +const C = { + accent: '#f59e0b', + success: '#4ade80', + error: '#f87171', + muted: 'rgba(255,255,255,0.4)', + dim: 'rgba(255,255,255,0.07)', +}; + +// ── Hilfsfunktion: Domain aus URL extrahieren ────────────────────────────── +function getDomain(url) { + try { return new URL(url).hostname.replace(/^www\./, ''); } + catch { return url; } +} + +export default function PaywallKiller() { + const [inputUrl, setInputUrl] = useState(''); + const [status, setStatus] = useState('idle'); // idle | checking | found | notfound | pdf | error + const [archiveUrl, setArchiveUrl] = useState(''); + const [archiveType, setArchiveType] = useState(''); + const [errMsg, setErrMsg] = useState(''); + const [pdfLoading, setPdfLoading] = useState(false); + const inputRef = useRef(null); + + // ── URL prüfen ──────────────────────────────────────────────────────────── + async function handleCheck() { + const url = inputUrl.trim(); + if (!url) return; + setStatus('checking'); + setErrMsg(''); + setArchiveUrl(''); + setArchiveType(''); + try { + const data = await api('/tools/paywallkiller/check', { body: { url } }); + setArchiveUrl(data.archiveUrl); + setArchiveType(data.type); + setStatus('found'); + } catch (err) { + if (err.message && err.message.includes('Kein Archiv')) { + setStatus('notfound'); + } else { + setErrMsg(err.message || 'Unbekannter Fehler'); + setStatus('error'); + } + } + } + + // ── PDF herunterladen ───────────────────────────────────────────────────── + async function handlePdf() { + setPdfLoading(true); + try { + const token = localStorage.getItem('sk_token'); + const res = await fetch('/api/tools/paywallkiller/pdf', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ archiveUrl }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || 'PDF-Fehler'); + } + + const blob = await res.blob(); + const objUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = objUrl; + a.download = 'archiv_' + Date.now() + '.pdf'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(objUrl), 5000); + } catch (err) { + setErrMsg(err.message || 'PDF konnte nicht erstellt werden'); + setStatus('error'); + } finally { + setPdfLoading(false); + } + } + + // ── Reset ───────────────────────────────────────────────────────────────── + function handleReset() { + setInputUrl(''); + setStatus('idle'); + setArchiveUrl(''); + setArchiveType(''); + setErrMsg(''); + setPdfLoading(false); + setTimeout(() => inputRef.current?.focus(), 50); + } + + // ── Paste aus Clipboard ─────────────────────────────────────────────────── + async function handlePaste() { + try { + const text = await navigator.clipboard.readText(); + if (text) setInputUrl(text.trim()); + } catch { + inputRef.current?.focus(); + } + } + + const isChecking = status === 'checking'; + const isFound = status === 'found'; + const isNotFound = status === 'notfound'; + const isError = status === 'error'; + + return ( +
+ + {/* Header */} +
+
+ 🔓 + + PAYWALL-KILLER + +
+
+ Artikel über archive.is abrufen · Als PDF speichern +
+
+ + {/* URL-Eingabe */} +
+
ARTIKEL-URL
+
+ setInputUrl(e.target.value)} + onKeyDown={e => e.key === 'Enter' && !isChecking && handleCheck()} + placeholder="https://www.zeitung.de/artikel/..." + style={{ ...S.inp, flex: 1 }} + /> + +
+
+ + {(isFound || isNotFound || isError) && ( + + )} +
+
+ + {/* Gefunden */} + {isFound && ( +
+
+ + + Archiv gefunden + + {archiveType === 'newest' ? 'neuestes' : 'ältestes'} Archiv + + +
+ + {/* Archive-URL anzeigen */} + + + {/* Aktions-Buttons */} +
+ + 🌐 Im Browser öffnen + + +
+ + {/* PDF-Hinweis */} + {pdfLoading && ( +
+ Seite wird geladen und als PDF gerendert. Das kann 15–30 Sekunden dauern... +
+ )} +
+ )} + + {/* Kein Archiv */} + {isNotFound && ( +
+
+ 📭 + + Kein Archiv gefunden + +
+
+ Für {getDomain(inputUrl)} existiert + kein Eintrag auf archive.is. Der Artikel wurde möglicherweise noch nie archiviert. +
+ +
+ )} + + {/* Fehler */} + {isError && ( +
+
+ ⚠️ + + {errMsg} + +
+
+ )} + + {/* Info-Sektion */} + {status === 'idle' && ( +
+
WIE ES FUNKTIONIERT
+
+ {[ + ['1', 'URL des gesperrten Artikels einfügen'], + ['2', 'Archiv auf archive.is suchen (newest → oldest)'], + ['3', 'Archivierte Version im Browser öffnen oder als PDF speichern'], + ].map(([num, text]) => ( +
+ + {num} + + + {text} + +
+ ))} +
+
+ )} +
+ ); +}