feat: Paywall-Killer Tool (archive.is + PDF-Export)

This commit is contained in:
2026-06-12 00:09:17 +02:00
parent eef03510af
commit 1cf92c9a2f
6 changed files with 482 additions and 3 deletions

View File

@@ -12,7 +12,10 @@ RUN npm run build
# ── Stage 2: Express liefert API + React in einem Container ────────────────── # ── Stage 2: Express liefert API + React in einem Container ──────────────────
FROM node:20-alpine 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 WORKDIR /app
COPY backend/package.json ./ COPY backend/package.json ./
RUN npm install --production RUN npm install --production

View File

@@ -7,6 +7,8 @@
"better-sqlite3": "^9.4.3", "better-sqlite3": "^9.4.3",
"express": "^4.18.2", "express": "^4.18.2",
"jsonwebtoken": "^9.0.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"
} }
} }

View File

@@ -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;

View File

@@ -153,6 +153,13 @@ export const SkizzeIcon = ({size=20,color='currentColor',sw}) => base(<>
export const WrenchIcon = ({size=20,color='currentColor',sw}) => base(<> export const WrenchIcon = ({size=20,color='currentColor',sw}) => base(<>
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/> <path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
</>, size, color, sw); </>, size, color, sw);
export const PaywallIcon = ({size=20,color='currentColor',sw}) => base(<>
<rect x="3" y="11" width="18" height="11" rx="2" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M7 11V7a5 5 0 0 1 9.9-1" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
<line x1="8" y1="16" x2="10" y2="16" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
<line x1="14" y1="16" x2="16" y2="16" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
<line x1="11" y1="14" x2="13" y2="18" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
</>, size, color, sw);
export const FilmIcon = ({size=20,color='currentColor',sw}) => base(<> export const FilmIcon = ({size=20,color='currentColor',sw}) => base(<>
<rect x="2" y="2" width="20" height="20" rx="2.18" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/> <rect x="2" y="2" width="20" height="20" rx="2.18" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
<line x1="7" y1="2" x2="7" y2="22" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/> <line x1="7" y1="2" x2="7" y2="22" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>

View File

@@ -7,7 +7,8 @@ import CodeSchnipsel from './tools/codeschnipsel.jsx';
import Skizze from './tools/skizze.jsx'; import Skizze from './tools/skizze.jsx';
import DevTools from './tools/devtools.jsx'; import DevTools from './tools/devtools.jsx';
import Media from './tools/media.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 = [ export const TOOLS = [
{ {
@@ -82,6 +83,14 @@ export const TOOLS = [
group: 'Werkzeuge', group: 'Werkzeuge',
component: DevTools, component: DevTools,
}, },
{
id: 'paywallkiller',
Icon: PaywallIcon,
label: 'Paywall-Killer',
navLabel: 'Paywall',
group: 'Werkzeuge',
component: PaywallKiller,
},
{ {
id: 'media', id: 'media',
Icon: FilmIcon, Icon: FilmIcon,

View File

@@ -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 (
<div style={{ padding: '20px 16px', maxWidth: 660, margin: '0 auto' }}>
{/* Header */}
<div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
<span style={{ fontSize: 22 }}>🔓</span>
<span style={{ color: C.accent, fontFamily: 'monospace', fontSize: 13, letterSpacing: 1 }}>
PAYWALL-KILLER
</span>
</div>
<div style={{ color: C.muted, fontSize: 11, fontFamily: 'monospace' }}>
Artikel über archive.is abrufen · Als PDF speichern
</div>
</div>
{/* URL-Eingabe */}
<div style={{ ...S.card, marginBottom: 16 }}>
<div style={{ ...S.head, marginBottom: 10 }}>ARTIKEL-URL</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
<input
ref={inputRef}
value={inputUrl}
onChange={e => setInputUrl(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !isChecking && handleCheck()}
placeholder="https://www.zeitung.de/artikel/..."
style={{ ...S.inp, flex: 1 }}
/>
<button onClick={handlePaste} style={{ ...S.btn(C.muted, true), padding: '5px 12px' }} title="Aus Zwischenablage einfügen">
📋
</button>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={handleCheck}
disabled={isChecking || !inputUrl.trim()}
style={{
...S.btn(C.accent),
flex: 1,
opacity: (isChecking || !inputUrl.trim()) ? 0.5 : 1,
cursor: (isChecking || !inputUrl.trim()) ? 'default' : 'pointer',
}}
>
{isChecking ? '⏳ Suche Archiv...' : '🔍 Archiv suchen'}
</button>
{(isFound || isNotFound || isError) && (
<button onClick={handleReset} style={S.btn(C.muted, false)}>
Zurücksetzen
</button>
)}
</div>
</div>
{/* Gefunden */}
{isFound && (
<div style={{ ...S.card, borderColor: `${C.success}44`, marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<span style={{ fontSize: 16 }}></span>
<span style={{ color: C.success, fontFamily: 'monospace', fontSize: 12 }}>
Archiv gefunden
<span style={{
marginLeft: 8,
background: `${C.accent}22`,
border: `1px solid ${C.accent}44`,
borderRadius: 4,
padding: '1px 6px',
fontSize: 10,
color: C.accent,
}}>
{archiveType === 'newest' ? 'neuestes' : 'ältestes'} Archiv
</span>
</span>
</div>
{/* Archive-URL anzeigen */}
<div style={{
background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: 6,
padding: '8px 10px',
marginBottom: 14,
wordBreak: 'break-all',
}}>
<a href={archiveUrl} target="_blank" rel="noreferrer" style={{ color: C.accent, fontSize: 11, fontFamily: 'monospace' }}>
{archiveUrl}
</a>
</div>
{/* Aktions-Buttons */}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<a
href={archiveUrl}
target="_blank"
rel="noreferrer"
style={{ ...S.btn(C.success), textDecoration: 'none', display: 'inline-block' }}
>
🌐 Im Browser öffnen
</a>
<button
onClick={handlePdf}
disabled={pdfLoading}
style={{
...S.btn(C.accent),
opacity: pdfLoading ? 0.5 : 1,
cursor: pdfLoading ? 'default' : 'pointer',
}}
>
{pdfLoading ? '⏳ Erstelle PDF...' : '⬇ Als PDF speichern'}
</button>
</div>
{/* PDF-Hinweis */}
{pdfLoading && (
<div style={{ color: C.muted, fontSize: 11, fontFamily: 'monospace', marginTop: 10 }}>
Seite wird geladen und als PDF gerendert. Das kann 1530 Sekunden dauern...
</div>
)}
</div>
)}
{/* Kein Archiv */}
{isNotFound && (
<div style={{ ...S.card, borderColor: `${C.error}44` }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<span style={{ fontSize: 16 }}>📭</span>
<span style={{ color: C.error, fontFamily: 'monospace', fontSize: 12 }}>
Kein Archiv gefunden
</span>
</div>
<div style={{ color: C.muted, fontSize: 11, fontFamily: 'monospace', lineHeight: 1.6 }}>
Für <strong style={{ color: 'rgba(255,255,255,0.6)' }}>{getDomain(inputUrl)}</strong> existiert
kein Eintrag auf archive.is. Der Artikel wurde möglicherweise noch nie archiviert.
</div>
<div style={{ marginTop: 12 }}>
<a
href={`https://archive.is/${inputUrl.trim()}`}
target="_blank"
rel="noreferrer"
style={{ ...S.btn(C.muted), textDecoration: 'none', display: 'inline-block', fontSize: 11 }}
>
🔗 Manuell auf archive.is prüfen
</a>
</div>
</div>
)}
{/* Fehler */}
{isError && (
<div style={{ ...S.card, borderColor: `${C.error}44` }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 16 }}></span>
<span style={{ color: C.error, fontFamily: 'monospace', fontSize: 12 }}>
{errMsg}
</span>
</div>
</div>
)}
{/* Info-Sektion */}
{status === 'idle' && (
<div style={{ ...S.card, marginTop: 8 }}>
<div style={{ ...S.head, marginBottom: 10 }}>WIE ES FUNKTIONIERT</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{[
['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]) => (
<div key={num} style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
<span style={{
background: `${C.accent}22`,
border: `1px solid ${C.accent}44`,
borderRadius: '50%',
width: 20, height: 20,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 10, color: C.accent, fontFamily: 'monospace', flexShrink: 0,
}}>
{num}
</span>
<span style={{ color: C.muted, fontSize: 11, fontFamily: 'monospace', lineHeight: 1.6 }}>
{text}
</span>
</div>
))}
</div>
</div>
)}
</div>
);
}