PaywallFree: read paywalled articles free — Radical reader + proxy + method hub
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
c3103e6ecb
8 changed files with 2264 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
.wrangler/
|
||||
.dev.vars
|
||||
*.log
|
||||
1554
package-lock.json
generated
Normal file
1554
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
16
package.json
Normal file
16
package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "paywall",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.0.0"
|
||||
}
|
||||
}
|
||||
103
src/index.js
Normal file
103
src/index.js
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { Hono } from 'hono'
|
||||
import { fetchAsCrawler, stripPaywall, extractArticle } from './proxy.js'
|
||||
import { homePage, readPage, readerPage, errorPage, howPage, aboutPage, bookmarklet } from './views.js'
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
// Normalise a user-supplied URL: add https://, validate it's http(s).
|
||||
function normalizeUrl(raw) {
|
||||
if (!raw) return null
|
||||
let u = raw.trim()
|
||||
if (!/^https?:\/\//i.test(u)) {
|
||||
// allow "example.com/x" and accidental "https:/x" typos
|
||||
u = 'https://' + u.replace(/^\/+/, '').replace(/^https?:\/?/i, '')
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(u)
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null
|
||||
if (!parsed.hostname.includes('.')) return null
|
||||
return parsed.href
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
const HOST = (c) => c.req.header('host') || 'paywall.theradicalparty.com'
|
||||
|
||||
app.get('/', (c) => c.html(homePage(HOST(c))))
|
||||
app.get('/how', (c) => c.html(howPage(HOST(c))))
|
||||
app.get('/about', (c) => c.html(aboutPage()))
|
||||
|
||||
app.get('/bookmarklet.js', (c) =>
|
||||
c.text(bookmarklet(HOST(c)), 200, { 'Content-Type': 'text/plain; charset=utf-8' }))
|
||||
|
||||
// Methods hub for a given article URL.
|
||||
app.get('/read', (c) => {
|
||||
const url = normalizeUrl(c.req.query('url'))
|
||||
if (!url) return c.redirect('/')
|
||||
return c.html(readPage(url, HOST(c)))
|
||||
})
|
||||
|
||||
// Clean reader: fetch as crawler, extract article text.
|
||||
app.get('/reader', async (c) => {
|
||||
const url = normalizeUrl(c.req.query('url'))
|
||||
if (!url) return c.redirect('/')
|
||||
try {
|
||||
const res = await fetchAsCrawler(url, { ref: c.req.query('ref') || 'google' })
|
||||
const ct = res.headers.get('content-type') || ''
|
||||
if (!res.ok && res.status !== 403 && res.status !== 401) {
|
||||
return c.html(errorPage(url, `origin returned ${res.status}`), 200)
|
||||
}
|
||||
if (!ct.includes('html')) {
|
||||
// non-HTML (pdf, etc.) — just hand it through
|
||||
return c.redirect(`/proxy?url=${encodeURIComponent(url)}`)
|
||||
}
|
||||
const html = await res.text()
|
||||
const art = extractArticle(html, url)
|
||||
if (!art.bodyHtml || art.wordCount < 80) {
|
||||
return c.html(errorPage(url, 'not enough readable text'), 200)
|
||||
}
|
||||
return c.html(readerPage(art, url, HOST(c)))
|
||||
} catch (e) {
|
||||
return c.html(errorPage(url, e.message), 200)
|
||||
}
|
||||
})
|
||||
|
||||
// Full-page proxy: fetch as crawler, strip paywall scripts/overlays, keep layout.
|
||||
app.get('/proxy', async (c) => {
|
||||
const url = normalizeUrl(c.req.query('url'))
|
||||
if (!url) return c.redirect('/')
|
||||
try {
|
||||
const res = await fetchAsCrawler(url, { ref: c.req.query('ref') || 'google' })
|
||||
const ct = res.headers.get('content-type') || ''
|
||||
if (!ct.includes('html')) {
|
||||
// pass binary/other content straight through
|
||||
const h = new Headers(res.headers)
|
||||
h.delete('content-security-policy'); h.delete('x-frame-options')
|
||||
return new Response(res.body, { status: res.status, headers: h })
|
||||
}
|
||||
return stripPaywall(res, url)
|
||||
} catch (e) {
|
||||
return c.html(errorPage(url, e.message), 200)
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/health', (c) => c.json({ ok: true, service: 'paywall', ts: Date.now() }))
|
||||
|
||||
app.get('/robots.txt', (c) =>
|
||||
c.text(`User-agent: *\nAllow: /$\nAllow: /how\nAllow: /about\nDisallow: /proxy\nDisallow: /reader\nDisallow: /read\nSitemap: https://${HOST(c)}/sitemap.xml\n`, 200, { 'Content-Type': 'text/plain' }))
|
||||
|
||||
app.get('/sitemap.xml', (c) => {
|
||||
const h = HOST(c)
|
||||
const urls = ['/', '/how', '/about'].map((p) => `<url><loc>https://${h}${p}</loc></url>`).join('')
|
||||
return c.text(`<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}</urlset>`, 200, { 'Content-Type': 'application/xml' })
|
||||
})
|
||||
|
||||
// Path-style: paywall.theradicalparty.com/https://example.com/article
|
||||
app.get('/*', (c) => {
|
||||
const path = c.req.path.slice(1)
|
||||
const qs = new URL(c.req.url).search
|
||||
const candidate = normalizeUrl(decodeURIComponent(path) + (qs || ''))
|
||||
if (candidate) return c.redirect(`/read?url=${encodeURIComponent(candidate)}`)
|
||||
return c.redirect('/')
|
||||
})
|
||||
|
||||
export default app
|
||||
118
src/methods.js
Normal file
118
src/methods.js
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
// Bypass methods. Each returns a target URL for a given article URL.
|
||||
// `kind`: 'internal' = handled by our own Worker, 'external' = redirect to a 3rd-party service.
|
||||
|
||||
export const METHODS = [
|
||||
{
|
||||
id: 'reader',
|
||||
kind: 'internal',
|
||||
name: 'Radical Reader',
|
||||
tagline: 'Clean, distraction-free text',
|
||||
desc: 'We fetch the page as a search engine crawler, extract the article, and rebuild it as plain readable text. Best first try for most soft paywalls.',
|
||||
icon: '📖',
|
||||
build: (u) => `/reader?url=${encodeURIComponent(u)}`,
|
||||
},
|
||||
{
|
||||
id: 'proxy',
|
||||
kind: 'internal',
|
||||
name: 'Radical Proxy',
|
||||
tagline: 'Full page, paywall stripped',
|
||||
desc: 'Loads the original page (styles and images intact) through our server, spoofing a Googlebot fetch and stripping paywall overlays. Use when Reader misses the layout.',
|
||||
icon: '🛰️',
|
||||
build: (u) => `/proxy?url=${encodeURIComponent(u)}`,
|
||||
},
|
||||
{
|
||||
id: 'archive-ph',
|
||||
kind: 'external',
|
||||
name: 'Archive.today',
|
||||
tagline: 'Community snapshots',
|
||||
desc: 'Crowd-sourced permanent snapshots. Excellent for NYT, WSJ, FT, Bloomberg, The Atlantic and most hard paywalls.',
|
||||
icon: '🗄️',
|
||||
build: (u) => `https://archive.ph/newest/${u}`,
|
||||
},
|
||||
{
|
||||
id: 'wayback',
|
||||
kind: 'external',
|
||||
name: 'Wayback Machine',
|
||||
tagline: 'Internet Archive',
|
||||
desc: "archive.org's public archive. Great fallback when a snapshot exists but Archive.today doesn't have one.",
|
||||
icon: '🕰️',
|
||||
build: (u) => `https://web.archive.org/web/2/${u}`,
|
||||
},
|
||||
{
|
||||
id: 'google-cache',
|
||||
kind: 'external',
|
||||
name: 'Google Referer',
|
||||
tagline: 'Arrive "from Google"',
|
||||
desc: 'Opens the article as though you clicked it from Google search results — many metered sites unlock the first click.',
|
||||
icon: '🔎',
|
||||
build: (u) => `/proxy?url=${encodeURIComponent(u)}&ref=google`,
|
||||
},
|
||||
{
|
||||
id: 'freedium',
|
||||
kind: 'external',
|
||||
name: 'Freedium',
|
||||
tagline: 'Medium articles',
|
||||
desc: 'Purpose-built reader for Medium and Medium-hosted publications.',
|
||||
icon: '✍️',
|
||||
domains: ['medium.com', 'towardsdatascience.com', 'levelup.gitconnected.com', 'betterprogramming.pub', 'uxdesign.cc'],
|
||||
build: (u) => `https://freedium.cfd/${u}`,
|
||||
},
|
||||
{
|
||||
id: 'twelveft',
|
||||
kind: 'external',
|
||||
name: '12ft.io',
|
||||
tagline: 'Classic prepender',
|
||||
desc: 'Loads the Google-cached version of the page. Hit or miss lately, but free and worth a shot.',
|
||||
icon: '🪜',
|
||||
build: (u) => `https://12ft.io/proxy?q=${encodeURIComponent(u)}`,
|
||||
},
|
||||
{
|
||||
id: 'smry',
|
||||
kind: 'external',
|
||||
name: 'smry.ai',
|
||||
tagline: 'AI summary + text',
|
||||
desc: 'Pulls the article text and adds an AI summary. Handy when you just want the gist.',
|
||||
icon: '🤖',
|
||||
build: (u) => `https://smry.ai/${u}`,
|
||||
},
|
||||
{
|
||||
id: 'printfriendly',
|
||||
kind: 'external',
|
||||
name: 'PrintFriendly',
|
||||
tagline: 'Print layout',
|
||||
desc: 'Renders the print-optimised version of the page, which frequently omits the paywall script.',
|
||||
icon: '🖨️',
|
||||
build: (u) => `https://www.printfriendly.com/print/?url=${encodeURIComponent(u)}`,
|
||||
},
|
||||
{
|
||||
id: 'gtranslate',
|
||||
kind: 'external',
|
||||
name: 'Translate Trick',
|
||||
tagline: 'Google Translate proxy',
|
||||
desc: 'Routes the page through Google Translate (EN→EN-ish), which acts as a lightweight proxy that dodges some scripts.',
|
||||
icon: '🌐',
|
||||
build: (u) => {
|
||||
try {
|
||||
const host = new URL(u).host
|
||||
return `https://${host.replace(/\./g, '-')}.translate.goog/${new URL(u).pathname}${new URL(u).search}?_x_tr_sl=en&_x_tr_tl=fr&_x_tr_hl=en`
|
||||
} catch { return `https://translate.google.com/translate?sl=en&tl=fr&u=${encodeURIComponent(u)}` }
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// Order methods so domain-specific + likely-to-work ones bubble to the top.
|
||||
export function rankMethods(url) {
|
||||
let host = ''
|
||||
try { host = new URL(url).host.replace(/^www\./, '') } catch {}
|
||||
const scored = METHODS.map((m) => {
|
||||
let score = 0
|
||||
if (m.domains && m.domains.some((d) => host === d || host.endsWith('.' + d))) score += 100
|
||||
if (m.kind === 'internal') score += 10
|
||||
return { m, score }
|
||||
})
|
||||
// stable sort by score desc, preserving original order for ties
|
||||
return scored
|
||||
.map((s, i) => ({ ...s, i }))
|
||||
.sort((a, b) => b.score - a.score || a.i - b.i)
|
||||
.map((s) => s.m)
|
||||
}
|
||||
200
src/proxy.js
Normal file
200
src/proxy.js
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
// Server-side fetching + paywall stripping.
|
||||
// Soft/metered paywalls serve full article HTML to search-engine + social crawlers
|
||||
// for SEO, then hide it client-side with JS overlays. We fetch AS a crawler and
|
||||
// either (a) drop the scripts + unlock the layout (proxy mode) or (b) extract the
|
||||
// article text into a clean reader (reader mode).
|
||||
|
||||
const CRAWLER_UAS = {
|
||||
google: 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
|
||||
bing: 'Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)',
|
||||
facebook: 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)',
|
||||
twitter: 'Twitterbot/1.0',
|
||||
}
|
||||
|
||||
const REFERERS = {
|
||||
google: 'https://www.google.com/',
|
||||
facebook: 'https://www.facebook.com/',
|
||||
twitter: 'https://t.co/',
|
||||
reddit: 'https://www.reddit.com/',
|
||||
}
|
||||
|
||||
export async function fetchAsCrawler(url, { ref = 'google', bot = 'google' } = {}) {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': CRAWLER_UAS[bot] || CRAWLER_UAS.google,
|
||||
'Referer': REFERERS[ref] || REFERERS.google,
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'X-Forwarded-For': '66.249.66.1',
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
redirect: 'follow',
|
||||
cf: { cacheTtl: 300, cacheEverything: false },
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
// ---- Full-page proxy: keep the layout, drop the paywall ----
|
||||
|
||||
const UNLOCK_CSS = `
|
||||
html, body { overflow: auto !important; position: static !important; height: auto !important; max-height: none !important; }
|
||||
body { filter: none !important; -webkit-filter: none !important; }
|
||||
* { -webkit-user-select: auto !important; user-select: auto !important; }
|
||||
/* Common paywall / regwall vendors + generic markers */
|
||||
.tp-modal, .tp-backdrop, .tp-iframe-wrapper, #piano-id, [id^="piano"],
|
||||
[class*="paywall"], [class*="Paywall"], [id*="paywall"], [id*="Paywall"],
|
||||
[class*="regwall"], [class*="reg-wall"], [class*="regGate"], [class*="reg-gate"],
|
||||
[class*="subscribe-wall"], [class*="subscription-wall"], [class*="meteredContent"],
|
||||
[class*="poool"], .poool-widget, [class*="piano-"], [class*="pelcro"],
|
||||
[data-testid*="paywall"], [data-testid*="gate"], [aria-label*="paywall" i],
|
||||
[class*="article-gate"], [class*="content-gate"], [class*="barrier"] {
|
||||
display: none !important; visibility: hidden !important; opacity: 0 !important; pointer-events: none !important;
|
||||
}
|
||||
`
|
||||
|
||||
const UNLOCK_JS = `(function(){
|
||||
try {
|
||||
var kill = /paywall|reg-?wall|reg-?gate|subscribe-?wall|meter|barrier|piano|poool|pelcro|content-gate|article-gate/i;
|
||||
document.querySelectorAll('body *').forEach(function(el){
|
||||
try {
|
||||
var id = (el.id||'') + ' ' + (el.className && el.className.toString ? el.className.toString() : '');
|
||||
if (kill.test(id)) { el.remove(); return; }
|
||||
var s = getComputedStyle(el);
|
||||
// strip full-viewport fixed/sticky overlays
|
||||
if ((s.position === 'fixed' || s.position === 'sticky') && el.offsetHeight > innerHeight*0.6 && el.offsetWidth > innerWidth*0.6) {
|
||||
el.remove();
|
||||
}
|
||||
} catch(e){}
|
||||
});
|
||||
// restore scrolling that JS-locked the body
|
||||
document.documentElement.style.overflow='auto'; document.body.style.overflow='auto';
|
||||
document.documentElement.style.position='static'; document.body.style.position='static';
|
||||
} catch(e){}
|
||||
})();`
|
||||
|
||||
export function stripPaywall(res, url) {
|
||||
const banner = `<div style="position:sticky;top:0;z-index:2147483647;background:#0B0B09;color:#F0EDE6;font:600 13px/1.4 system-ui,sans-serif;padding:8px 14px;border-bottom:2px solid #FF2D55;display:flex;gap:12px;align-items:center;justify-content:center">
|
||||
<span style="color:#FF2D55">RADICAL</span> paywall bypass · <a href="/read?url=${encodeURIComponent(url)}" style="color:#FF6B82">try another method</a> · <a href="/reader?url=${encodeURIComponent(url)}" style="color:#FF6B82">reader view</a>
|
||||
</div>`
|
||||
|
||||
const rewriter = new HTMLRewriter()
|
||||
.on('head', {
|
||||
element(el) {
|
||||
el.prepend(`<base href="${url}">`, { html: true })
|
||||
el.append(`<style>${UNLOCK_CSS}</style>`, { html: true })
|
||||
},
|
||||
})
|
||||
// Drop scripts — the paywall overlay is injected by JS; static crawler HTML already has the text.
|
||||
.on('script', { element(el) { el.remove() } })
|
||||
.on('link[as="script"]', { element(el) { el.remove() } })
|
||||
.on('link[rel="preload"][as="script"]', { element(el) { el.remove() } })
|
||||
.on('body', {
|
||||
element(el) {
|
||||
el.prepend(banner, { html: true })
|
||||
el.append(`<script>${UNLOCK_JS}</script>`, { html: true })
|
||||
},
|
||||
})
|
||||
|
||||
const out = rewriter.transform(res)
|
||||
const headers = new Headers(out.headers)
|
||||
headers.delete('content-security-policy')
|
||||
headers.delete('content-security-policy-report-only')
|
||||
headers.delete('x-frame-options')
|
||||
headers.set('cache-control', 'public, max-age=300')
|
||||
return new Response(out.body, { status: 200, headers })
|
||||
}
|
||||
|
||||
// ---- Reader mode: extract the article into clean text ----
|
||||
|
||||
function meta(html, ...names) {
|
||||
for (const n of names) {
|
||||
const re = new RegExp(`<meta[^>]+(?:property|name)=["']${n}["'][^>]+content=["']([^"']+)["']`, 'i')
|
||||
const m = html.match(re) || html.match(new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+(?:property|name)=["']${n}["']`, 'i'))
|
||||
if (m) return decodeEntities(m[1])
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function decodeEntities(s) {
|
||||
return (s || '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, "'").replace(/'/g, "'")
|
||||
.replace(/ /g, ' ').replace(/’/g, '’').replace(/‘/g, '‘')
|
||||
.replace(/“/g, '“').replace(/”/g, '”').replace(/—/g, '—')
|
||||
.replace(/–/g, '–').replace(/…/g, '…').replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(+d))
|
||||
}
|
||||
|
||||
// Pick the densest content region, then keep only meaningful tags.
|
||||
export function extractArticle(html, url) {
|
||||
const title = meta(html, 'og:title', 'twitter:title') || (html.match(/<title[^>]*>([^<]+)<\/title>/i)?.[1] || '').trim()
|
||||
const description = meta(html, 'og:description', 'description', 'twitter:description')
|
||||
const image = meta(html, 'og:image', 'twitter:image')
|
||||
const author = meta(html, 'author', 'article:author') || (html.match(/<[^>]+rel=["']author["'][^>]*>([^<]+)</i)?.[1] || '')
|
||||
const published = meta(html, 'article:published_time', 'datePublished')
|
||||
const site = meta(html, 'og:site_name') || (() => { try { return new URL(url).host.replace(/^www\./, '') } catch { return '' } })()
|
||||
|
||||
// Isolate a candidate container: prefer <article>, then JSON-LD articleBody, then <main>, then body.
|
||||
let region = ''
|
||||
const artMatch = html.match(/<article[^>]*>([\s\S]*?)<\/article>/i)
|
||||
if (artMatch && countParagraphs(artMatch[1]) >= 2) region = artMatch[1]
|
||||
if (!region) {
|
||||
const ld = extractJsonLdBody(html)
|
||||
if (ld) region = ld
|
||||
}
|
||||
if (!region) {
|
||||
const mainMatch = html.match(/<main[^>]*>([\s\S]*?)<\/main>/i)
|
||||
if (mainMatch) region = mainMatch[1]
|
||||
}
|
||||
if (!region) region = html.match(/<body[^>]*>([\s\S]*)<\/body>/i)?.[1] || html
|
||||
|
||||
const bodyHtml = cleanRegion(region, url)
|
||||
const text = bodyHtml.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
return { title: decodeEntities(title), description, image, author: decodeEntities(author), published, site, bodyHtml, wordCount: text ? text.split(' ').length : 0 }
|
||||
}
|
||||
|
||||
function countParagraphs(s) { return (s.match(/<p[ >]/gi) || []).length }
|
||||
|
||||
function extractJsonLdBody(html) {
|
||||
const blocks = [...html.matchAll(/<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)]
|
||||
for (const b of blocks) {
|
||||
try {
|
||||
const data = JSON.parse(b[1].trim())
|
||||
const arr = Array.isArray(data) ? data : (data['@graph'] || [data])
|
||||
for (const node of arr) {
|
||||
if (node && node.articleBody && node.articleBody.length > 200) {
|
||||
return node.articleBody.split(/\n{1,}/).map((p) => `<p>${p.trim()}</p>`).join('')
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function cleanRegion(region, url) {
|
||||
let h = region
|
||||
// Remove non-content blocks entirely
|
||||
h = h.replace(/<(script|style|noscript|svg|form|iframe|nav|header|footer|aside|button|figure[^>]*aria-hidden)[\s\S]*?<\/\1>/gi, ' ')
|
||||
h = h.replace(/<!--[\s\S]*?-->/g, ' ')
|
||||
// Drop obvious junk containers by class/id
|
||||
h = h.replace(/<(div|section|ul|span)[^>]*(?:class|id)=["'][^"']*(share|social|newsletter|promo|related|recommend|comment|advert|ad-|subscribe|paywall|nav|menu|breadcrumb|tags?|caption|byline-social)[^"']*["'][\s\S]*?<\/\1>/gi, ' ')
|
||||
|
||||
// Collect meaningful blocks in document order
|
||||
const keep = []
|
||||
const re = /<(h1|h2|h3|h4|p|blockquote|li|pre)[^>]*>([\s\S]*?)<\/\1>/gi
|
||||
let m
|
||||
while ((m = re.exec(h))) {
|
||||
const tag = m[1].toLowerCase()
|
||||
let inner = m[2].replace(/<(?!\/?(a|b|i|em|strong|code|br)\b)[^>]+>/gi, '') // keep light inline tags
|
||||
inner = inner.replace(/\s+/g, ' ').trim()
|
||||
const plain = inner.replace(/<[^>]+>/g, '').trim()
|
||||
if (!plain) continue
|
||||
if (tag === 'p' && plain.length < 2) continue
|
||||
keep.push(tag === 'li' ? `<li>${inner}</li>` : `<${tag}>${inner}</${tag}>`)
|
||||
}
|
||||
let out = keep.join('\n')
|
||||
// wrap consecutive <li> in a <ul>
|
||||
out = out.replace(/(?:<li>[\s\S]*?<\/li>\n?)+/g, (block) => `<ul>${block}</ul>`)
|
||||
// absolutise anchor hrefs
|
||||
out = out.replace(/href=["'](\/[^"']*)["']/gi, (_, p) => { try { return `href="${new URL(p, url).href}"` } catch { return `href="${p}"` } })
|
||||
return decodeEntities(out)
|
||||
}
|
||||
260
src/views.js
Normal file
260
src/views.js
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
import { METHODS, rankMethods } from './methods.js'
|
||||
|
||||
const esc = (s) => (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
|
||||
const FONTS = 'https://fonts.googleapis.com/css2?family=Syne:wght@700;800&family=IBM+Plex+Mono:wght@400;500;700&display=swap'
|
||||
|
||||
const CSS = `
|
||||
:root{
|
||||
--red:#FF2D55;--pink:#FF6B82;--bg:#0B0B09;--raised:#12100d;--card:#17140f;
|
||||
--cream:#F0EDE6;--muted:#9c948a;--gold:#c9a84c;--line:#26221b;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;padding:0}
|
||||
body{background:var(--bg);color:var(--cream);font-family:system-ui,-apple-system,Inter,sans-serif;line-height:1.6;-webkit-font-smoothing:antialiased}
|
||||
a{color:var(--pink);text-decoration:none}
|
||||
a:hover{text-decoration:underline}
|
||||
.wrap{max-width:960px;margin:0 auto;padding:0 20px}
|
||||
.mono{font-family:'IBM Plex Mono',monospace}
|
||||
header.top{border-bottom:1px solid var(--line);background:rgba(11,11,9,.85);backdrop-filter:blur(10px);position:sticky;top:0;z-index:50}
|
||||
header.top .wrap{display:flex;align-items:center;justify-content:space-between;height:62px}
|
||||
.brand{font-family:'Syne',sans-serif;font-weight:800;font-size:20px;letter-spacing:-.5px}
|
||||
.brand b{color:var(--red)}
|
||||
.brand small{color:var(--muted);font-weight:700;font-size:11px;letter-spacing:1px;margin-left:8px;font-family:'IBM Plex Mono',monospace}
|
||||
.hero{text-align:center;padding:70px 0 30px}
|
||||
.hero h1{font-family:'Syne',sans-serif;font-weight:800;font-size:clamp(34px,6vw,58px);line-height:1.02;margin:0 0 16px;letter-spacing:-1.5px}
|
||||
.hero h1 span{color:var(--red)}
|
||||
.hero p.sub{font-size:clamp(16px,2.4vw,20px);color:var(--muted);max-width:620px;margin:0 auto 34px}
|
||||
form.search{display:flex;gap:10px;max-width:680px;margin:0 auto;flex-wrap:wrap}
|
||||
form.search input{flex:1;min-width:240px;background:var(--raised);border:1px solid var(--line);color:var(--cream);font-size:16px;padding:16px 18px;border-radius:12px;font-family:'IBM Plex Mono',monospace}
|
||||
form.search input:focus{outline:none;border-color:var(--red);box-shadow:0 0 0 3px rgba(255,45,85,.15)}
|
||||
.btn{background:var(--red);color:#fff;border:none;font-weight:700;font-size:16px;padding:16px 26px;border-radius:12px;cursor:pointer;font-family:'Syne',sans-serif;transition:transform .1s,background .15s}
|
||||
.btn:hover{background:#ff1744;transform:translateY(-1px);text-decoration:none}
|
||||
.btn.ghost{background:transparent;border:1px solid var(--line);color:var(--cream)}
|
||||
.btn.ghost:hover{border-color:var(--red);background:var(--raised)}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;margin:30px 0}
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:20px;display:flex;flex-direction:column;transition:border-color .15s,transform .1s}
|
||||
.card:hover{border-color:var(--red);transform:translateY(-2px)}
|
||||
.card .ic{font-size:26px;margin-bottom:10px}
|
||||
.card h3{font-family:'Syne',sans-serif;margin:0 0 2px;font-size:19px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.card .tag{font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.5px}
|
||||
.card p{color:var(--muted);font-size:14px;flex:1;margin:8px 0 16px}
|
||||
.card a.go{align-self:flex-start;font-weight:700;font-family:'IBM Plex Mono',monospace;font-size:14px;background:var(--raised);border:1px solid var(--line);padding:9px 16px;border-radius:9px;color:var(--cream)}
|
||||
.card a.go:hover{border-color:var(--red);text-decoration:none;color:var(--pink)}
|
||||
.badge{display:inline-block;background:rgba(201,168,76,.15);color:var(--gold);font-size:10px;font-weight:700;letter-spacing:.5px;padding:3px 8px;border-radius:20px;text-transform:uppercase}
|
||||
.badge.rec{background:rgba(255,45,85,.15);color:var(--pink)}
|
||||
.section{padding:20px 0 10px}
|
||||
.section h2{font-family:'Syne',sans-serif;font-size:24px;margin:0 0 4px}
|
||||
.section p.lead{color:var(--muted);margin:0 0 6px}
|
||||
.target{background:var(--raised);border:1px solid var(--line);border-radius:12px;padding:14px 18px;margin:18px 0;font-family:'IBM Plex Mono',monospace;font-size:14px;word-break:break-all}
|
||||
.target b{color:var(--gold)}
|
||||
.steps{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:20px;margin:24px 0}
|
||||
.step{background:var(--raised);border:1px solid var(--line);border-radius:14px;padding:22px}
|
||||
.step .n{font-family:'Syne',sans-serif;font-weight:800;font-size:28px;color:var(--red)}
|
||||
.step h4{margin:6px 0 6px;font-family:'Syne',sans-serif;font-size:17px}
|
||||
.step p{color:var(--muted);font-size:14px;margin:0}
|
||||
.bm{display:inline-block;background:var(--gold);color:#1a1408;font-weight:800;padding:10px 18px;border-radius:10px;font-family:'Syne',sans-serif;cursor:grab}
|
||||
footer{border-top:1px solid var(--line);margin-top:60px;padding:30px 0;color:var(--muted);font-size:13px;text-align:center}
|
||||
.legal{background:var(--raised);border:1px solid var(--line);border-left:3px solid var(--gold);border-radius:10px;padding:16px 20px;color:var(--muted);font-size:13px;margin:30px 0}
|
||||
.reader{max-width:720px}
|
||||
.reader h1.title{font-family:'Syne',sans-serif;font-size:clamp(28px,4vw,40px);line-height:1.1;letter-spacing:-1px;margin:26px 0 10px}
|
||||
.reader .meta{color:var(--muted);font-size:14px;font-family:'IBM Plex Mono',monospace;border-bottom:1px solid var(--line);padding-bottom:16px;margin-bottom:8px}
|
||||
.reader .lead{font-size:19px;color:#d8d2c8;margin:18px 0}
|
||||
.reader .content{font-size:18px;line-height:1.75}
|
||||
.reader .content p{margin:0 0 22px}
|
||||
.reader .content h2,.reader .content h3{font-family:'Syne',sans-serif;margin:34px 0 12px}
|
||||
.reader .content blockquote{border-left:3px solid var(--red);margin:24px 0;padding:4px 0 4px 20px;color:#d8d2c8;font-style:italic}
|
||||
.reader .content a{color:var(--pink);border-bottom:1px solid rgba(255,107,130,.4)}
|
||||
.reader .content img{max-width:100%;height:auto;border-radius:10px;margin:16px 0}
|
||||
.reader .heroimg{width:100%;max-height:380px;object-fit:cover;border-radius:14px;margin:8px 0 4px}
|
||||
.toolbar{display:flex;gap:10px;flex-wrap:wrap;margin:20px 0;padding:14px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}
|
||||
.toolbar a{font-family:'IBM Plex Mono',monospace;font-size:13px;background:var(--raised);border:1px solid var(--line);padding:8px 14px;border-radius:9px;color:var(--cream)}
|
||||
.toolbar a:hover{border-color:var(--red);text-decoration:none;color:var(--pink)}
|
||||
.notice{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:22px;margin:20px 0}
|
||||
@media(max-width:640px){.hero{padding:40px 0 20px}}
|
||||
`
|
||||
|
||||
export function layout(title, body, { desc = '', wide = false } = {}) {
|
||||
return `<!doctype html><html lang="en"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>${esc(title)}</title>
|
||||
<meta name="description" content="${esc(desc || 'Read paywalled articles free. Paste any link and pick a bypass method — reader mode, archive snapshots and more.')}">
|
||||
<meta name="robots" content="index,follow">
|
||||
<meta property="og:title" content="${esc(title)}"><meta property="og:description" content="${esc(desc)}">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="${FONTS}" rel="stylesheet">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔓</text></svg>">
|
||||
<style>${CSS}</style></head>
|
||||
<body>
|
||||
<header class="top"><div class="wrap">
|
||||
<a href="/" class="brand" style="color:var(--cream)">🔓 Paywall<b>Free</b><small>RADICAL</small></a>
|
||||
<a href="/how" class="mono" style="font-size:13px;color:var(--muted)">how it works</a>
|
||||
</div></header>
|
||||
<main class="wrap"${wide ? ' style="max-width:1100px"' : ''}>${body}</main>
|
||||
<footer><div class="wrap">
|
||||
A <a href="https://theradicalparty.com">Radical Party</a> project · <a href="/how">how it works</a> · <a href="/about">about</a><br>
|
||||
<span class="mono" style="font-size:12px">Vibe · Vote · Veto — free the news.</span>
|
||||
</div></footer>
|
||||
</body></html>`
|
||||
}
|
||||
|
||||
function methodCard(m, url) {
|
||||
const href = m.build(url)
|
||||
const rec = m._recommended
|
||||
return `<div class="card">
|
||||
<div class="ic">${m.icon}</div>
|
||||
<h3>${esc(m.name)} ${rec ? '<span class="badge rec">Recommended</span>' : m.kind === 'internal' ? '<span class="badge">Built-in</span>' : ''}</h3>
|
||||
<span class="tag">${esc(m.tagline)}</span>
|
||||
<p>${esc(m.desc)}</p>
|
||||
<a class="go" href="${href}"${m.kind === 'external' ? ' target="_blank" rel="noopener noreferrer"' : ''}>${m.kind === 'internal' ? 'Open →' : 'Try it ↗'}</a>
|
||||
</div>`
|
||||
}
|
||||
|
||||
export function homePage(host) {
|
||||
const bm = bookmarklet(host)
|
||||
const cards = METHODS.slice(0, 6).map((m) => methodCard(m, 'https://example.com/article')).join('')
|
||||
return layout('PaywallFree — read paywalled articles free', `
|
||||
<section class="hero">
|
||||
<h1>Read any <span>paywalled</span><br>article. Free.</h1>
|
||||
<p class="sub">Paste a link. We fetch it the way Google's crawler does and give you a clean, readable copy — plus every archive and bypass trick in one place.</p>
|
||||
<form class="search" action="/read" method="get">
|
||||
<input name="url" type="url" inputmode="url" placeholder="https://www.nytimes.com/2026/..." required autofocus>
|
||||
<button class="btn" type="submit">Unlock</button>
|
||||
</form>
|
||||
<p class="mono" style="color:var(--muted);font-size:13px;margin-top:14px">or drag this to your bookmarks bar → <a class="bm" href="${bm}" onclick="return false">🔓 PaywallFree</a></p>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Every method, one page</h2>
|
||||
<p class="lead">When you paste a link we rank the tricks most likely to work for that site. A preview of what's on offer:</p>
|
||||
<div class="grid">${cards}</div>
|
||||
</section>
|
||||
|
||||
<div class="legal">
|
||||
<b style="color:var(--gold)">Use it responsibly.</b> PaywallFree points you at publicly reachable copies (search-engine snapshots, archives, crawler-visible HTML). It's for readers who hit a wall on a single article — not a substitute for subscribing to journalism you rely on. If a publisher's work is worth your time, back it.
|
||||
</div>
|
||||
|
||||
<section class="section" style="text-align:center;padding:20px 0 50px">
|
||||
<a class="btn ghost" href="/how">See how it works →</a>
|
||||
</section>
|
||||
`, { desc: 'Paste any paywalled article link and read it free — reader mode, archive snapshots, and every bypass trick in one place.' })
|
||||
}
|
||||
|
||||
export function readPage(url, host) {
|
||||
const ranked = rankMethods(url)
|
||||
// mark the top-ranked non-generic method as recommended
|
||||
if (ranked[0]) ranked[0]._recommended = true
|
||||
let display = url
|
||||
try { const u = new URL(url); display = `<b>${esc(u.host)}</b>${esc(u.pathname)}` } catch {}
|
||||
const cards = ranked.map((m) => methodCard(m, url)).join('')
|
||||
return layout(`Unlock: ${safeHost(url)} — PaywallFree`, `
|
||||
<section class="section" style="padding-top:40px">
|
||||
<h2>Pick your bypass</h2>
|
||||
<p class="lead">Methods ranked for this article. Start at the top — if one falls short, try the next.</p>
|
||||
<div class="target">🔗 ${display}</div>
|
||||
</section>
|
||||
<div class="grid">${cards}</div>
|
||||
<div class="legal">Different sites yield to different tricks. Hard paywalls (WSJ, FT, Bloomberg) usually need <b style="color:var(--gold)">Archive.today</b>; soft/metered walls (most news sites) fall to <b style="color:var(--gold)">Radical Reader</b>. Medium → <b style="color:var(--gold)">Freedium</b>.</div>
|
||||
<section class="section" style="text-align:center;padding:10px 0 50px"><a class="btn ghost" href="/">← Try another link</a></section>
|
||||
`, { desc: `Bypass the paywall on ${safeHost(url)} — reader mode, archives and more.` })
|
||||
}
|
||||
|
||||
export function readerPage(art, url, host) {
|
||||
const body = `
|
||||
<article class="reader">
|
||||
<div class="toolbar">
|
||||
<a href="/proxy?url=${encodeURIComponent(url)}">🛰️ Full page view</a>
|
||||
<a href="/read?url=${encodeURIComponent(url)}">🔀 Other methods</a>
|
||||
<a href="${esc(url)}" target="_blank" rel="noopener noreferrer">↗ Original</a>
|
||||
<a href="/">🏠 Home</a>
|
||||
</div>
|
||||
${art.image ? `<img class="heroimg" src="${esc(art.image)}" alt="" loading="lazy">` : ''}
|
||||
<h1 class="title">${esc(art.title || 'Untitled')}</h1>
|
||||
<div class="meta">${art.site ? esc(art.site) : safeHost(url)}${art.author ? ' · ' + esc(art.author) : ''}${art.published ? ' · ' + esc(art.published.slice(0, 10)) : ''}${art.wordCount ? ' · ' + art.wordCount + ' words' : ''}</div>
|
||||
${art.description ? `<p class="lead">${esc(art.description)}</p>` : ''}
|
||||
<div class="content">${art.bodyHtml || ''}</div>
|
||||
<div class="toolbar" style="margin-top:36px">
|
||||
<a href="/read?url=${encodeURIComponent(url)}">🔀 Didn't work? Try another method</a>
|
||||
<a href="${esc(url)}" target="_blank" rel="noopener noreferrer">↗ Original article</a>
|
||||
</div>
|
||||
</article>`
|
||||
return layout(`${art.title || 'Reader'} — PaywallFree`, body, { desc: art.description || `Read ${safeHost(url)} free.` })
|
||||
}
|
||||
|
||||
export function errorPage(url, message) {
|
||||
return layout('Reader unavailable — PaywallFree', `
|
||||
<section class="section" style="padding-top:40px">
|
||||
<h2>That one needs a different trick</h2>
|
||||
<div class="notice">
|
||||
<p style="margin-top:0">Our reader couldn't pull clean text from this page${message ? ` <span class="mono" style="color:var(--muted)">(${esc(message)})</span>` : ''}. That's normal for hard paywalls and JavaScript-only sites — an archive snapshot usually works instead.</p>
|
||||
<div class="target" style="margin:16px 0">🔗 ${esc(url)}</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="grid">${rankMethods(url).filter((m) => m.id !== 'reader').slice(0, 6).map((m) => methodCard(m, url)).join('')}</div>
|
||||
<section class="section" style="text-align:center;padding:10px 0 50px"><a class="btn ghost" href="/">← Try another link</a></section>
|
||||
`)
|
||||
}
|
||||
|
||||
export function howPage(host) {
|
||||
const bm = bookmarklet(host)
|
||||
return layout('How it works — PaywallFree', `
|
||||
<section class="hero" style="padding:50px 0 10px">
|
||||
<h1 style="font-size:clamp(30px,5vw,46px)">How it <span>works</span></h1>
|
||||
<p class="sub">Three ways in. No account, no extension required.</p>
|
||||
</section>
|
||||
<div class="steps">
|
||||
<div class="step"><div class="n">1</div><h4>Paste the link</h4><p>Drop any article URL into the box on the home page and hit Unlock.</p></div>
|
||||
<div class="step"><div class="n">2</div><h4>We rank the tricks</h4><p>Based on the site, we sort the methods most likely to work — reader mode, archives, Freedium and more.</p></div>
|
||||
<div class="step"><div class="n">3</div><h4>Read it</h4><p>Open the top method. If a wall's still up, the next one down almost always clears it.</p></div>
|
||||
</div>
|
||||
|
||||
<section class="section">
|
||||
<h2>The bookmarklet</h2>
|
||||
<p class="lead">On any paywalled page, click this to jump straight to PaywallFree for that URL. Drag it to your bookmarks bar:</p>
|
||||
<p style="margin:18px 0"><a class="bm" href="${bm}" onclick="return false">🔓 PaywallFree</a></p>
|
||||
<p class="mono" style="color:var(--muted);font-size:13px">Can't drag? Make a new bookmark and paste the code as the URL: <br><code style="color:var(--gold);word-break:break-all">${esc(bm)}</code></p>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Or just edit the address bar</h2>
|
||||
<p class="lead">Put <b class="mono" style="color:var(--gold)">${esc(host)}/</b> in front of any link:</p>
|
||||
<div class="target">${esc(host)}/<b>https://www.nytimes.com/2026/...</b></div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>What each method does</h2>
|
||||
<div class="grid">${METHODS.map((m) => `<div class="card"><div class="ic">${m.icon}</div><h3>${esc(m.name)}</h3><span class="tag">${esc(m.tagline)}</span><p>${esc(m.desc)}</p></div>`).join('')}</div>
|
||||
</section>
|
||||
|
||||
<div class="legal"><b style="color:var(--gold)">The honest version:</b> paywalls are how a lot of journalism gets paid for. PaywallFree exists for the one-off article, the dead link, the thing behind a wall you'll read once. If a publication is part of your week, subscribe — that's the point of a free press worth reading.</div>
|
||||
<section class="section" style="text-align:center;padding:10px 0 50px"><a class="btn" href="/">Try it →</a></section>
|
||||
`, { desc: 'How PaywallFree unlocks paywalled articles: paste a link, pick a ranked bypass method, read free.' })
|
||||
}
|
||||
|
||||
export function aboutPage() {
|
||||
return layout('About — PaywallFree', `
|
||||
<section class="section" style="padding-top:50px">
|
||||
<h2>About PaywallFree</h2>
|
||||
<p class="lead" style="max-width:640px">A single page that collects every reliable way to read a paywalled article — a built-in reader that fetches pages the way search crawlers see them, plus a ranked menu of archives and bypass services.</p>
|
||||
<div class="notice" style="max-width:680px">
|
||||
<h3 style="font-family:'Syne',sans-serif;margin-top:0">Why it exists</h3>
|
||||
<p>Information wants to be reachable. Metered walls, "you've read your 3 free articles" gates and reg-walls block a lot of the public web — including public-interest journalism people are trying to cite, share and read once. This puts the open copies in one place.</p>
|
||||
<h3 style="font-family:'Syne',sans-serif">The ask</h3>
|
||||
<p style="margin-bottom:0">Back journalism you value. This is for the occasional wall, not for freeloading a paper you read daily. Support a free press worth having.</p>
|
||||
</div>
|
||||
<p class="mono" style="color:var(--muted);font-size:13px">Open source · a Radical Party project · <a href="https://git.theradicalparty.com/maverick/paywall">source</a></p>
|
||||
<a class="btn ghost" href="/">← Home</a>
|
||||
</section>
|
||||
`, { desc: 'About PaywallFree — a Radical Party project collecting every way to read paywalled articles free.' })
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
export function safeHost(url) { try { return new URL(url).host.replace(/^www\./, '') } catch { return 'this page' } }
|
||||
|
||||
export function bookmarklet(host) {
|
||||
const code = `(function(){var u=encodeURIComponent(location.href);location.href='https://${host}/read?url='+u;})();`
|
||||
return 'javascript:' + code
|
||||
}
|
||||
|
||||
export { esc }
|
||||
9
wrangler.toml
Normal file
9
wrangler.toml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
name = "paywall"
|
||||
main = "src/index.js"
|
||||
compatibility_date = "2024-11-01"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
|
||||
routes = [{ pattern = "paywall.theradicalparty.com", custom_domain = true }]
|
||||
|
||||
[observability]
|
||||
enabled = true
|
||||
Loading…
Add table
Reference in a new issue