feat: Paywall-Killer Tool (archive.is + PDF-Export)
This commit is contained in:
@@ -153,6 +153,13 @@ export const SkizzeIcon = ({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"/>
|
||||
</>, 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(<>
|
||||
<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"/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
302
frontend/src/tools/paywallkiller.jsx
Normal file
302
frontend/src/tools/paywallkiller.jsx
Normal 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 15–30 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user