This commit is contained in:
√(noham)²
2026-06-19 15:48:32 +02:00
parent 58c210ec45
commit 13c9bcbcd2
8 changed files with 325 additions and 321 deletions

View File

@@ -9,6 +9,10 @@
"start": "node src/index.js", "start": "node src/index.js",
"test": "node src/test/test.js" "test": "node src/test/test.js"
}, },
"standard": {
"globals": ["chrome", "Readability", "indexedDB", "getComputedStyle"],
"ignore": ["extension/"]
},
"dependencies": { "dependencies": {
"commander": "^15.0.0", "commander": "^15.0.0",
"puppeteer": "^24.0.0" "puppeteer": "^24.0.0"

View File

@@ -1,72 +1,72 @@
import fs from "node:fs"; import fs from 'node:fs'
import path from "node:path"; import path from 'node:path'
import puppeteer from "puppeteer"; import puppeteer from 'puppeteer'
const EXTENSION_DIR = path.resolve("./extension/reader-view"); const EXTENSION_DIR = path.resolve('./extension/reader-view')
const MANIFEST_PATH = path.join(EXTENSION_DIR, "manifest.json"); const MANIFEST_PATH = path.join(EXTENSION_DIR, 'manifest.json')
const READABILITY_PATH = path.join(EXTENSION_DIR, "data/inject/Readability.js"); const READABILITY_PATH = path.join(EXTENSION_DIR, 'data/inject/Readability.js')
export function checkExtension() { export function checkExtension () {
if (!fs.existsSync(MANIFEST_PATH)) { if (!fs.existsSync(MANIFEST_PATH)) {
console.error( console.error(
`Extension not found at: ${EXTENSION_DIR}\n` + `Extension not found at: ${EXTENSION_DIR}\n` +
"Please extract the extension first:\n" + 'Please extract the extension first:\n' +
" mkdir -p extension/reader-view\n" + ' mkdir -p extension/reader-view\n' +
" unzip extension/Reader-View-Chrome-Web-Store.zip -d extension/reader-view", ' unzip extension/Reader-View-Chrome-Web-Store.zip -d extension/reader-view'
); )
process.exit(1); process.exit(1)
} }
if (!fs.existsSync(READABILITY_PATH)) { if (!fs.existsSync(READABILITY_PATH)) {
console.error( console.error(
`Readability.js not found at: ${READABILITY_PATH}\n` + `Readability.js not found at: ${READABILITY_PATH}\n` +
"The extension may be incomplete. Please re-extract it.", 'The extension may be incomplete. Please re-extract it.'
); )
process.exit(1); process.exit(1)
} }
} }
export function getReadabilityPath() { export function getReadabilityPath () {
return READABILITY_PATH; return READABILITY_PATH
} }
export function getExtensionDir() { export function getExtensionDir () {
return EXTENSION_DIR; return EXTENSION_DIR
} }
export async function launchBrowser() { export async function launchBrowser () {
console.log(`Launching browser with extension: ${EXTENSION_DIR}`); console.log(`Launching browser with extension: ${EXTENSION_DIR}`)
const browser = await puppeteer.launch({ const browser = await puppeteer.launch({
headless: true, headless: true,
ignoreDefaultArgs: [ ignoreDefaultArgs: [
"--disable-extensions", '--disable-extensions',
"--disable-component-extensions-with-background-pages", '--disable-component-extensions-with-background-pages',
"--enable-automation", '--enable-automation'
], ],
args: [ args: [
`--load-extension=${EXTENSION_DIR}`, `--load-extension=${EXTENSION_DIR}`,
"--no-sandbox", '--no-sandbox',
"--disable-setuid-sandbox", '--disable-setuid-sandbox'
], ]
}); })
return browser; return browser
} }
export async function discoverExtensionId(browser) { export async function discoverExtensionId (browser) {
const page = await browser.newPage(); const page = await browser.newPage()
await page.goto("chrome://extensions/", { timeout: 8000 }); await page.goto('chrome://extensions/', { timeout: 8000 })
await new Promise((r) => setTimeout(r, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000))
const extId = await page.evaluate(() => { const extId = await page.evaluate(() => {
return new Promise((resolve) => { return new Promise((resolve) => {
chrome.management.getAll((exts) => { chrome.management.getAll((exts) => {
const found = exts.find((e) => e.name === "Reader View"); const found = exts.find((e) => e.name === 'Reader View')
resolve(found ? found.id : null); resolve(found ? found.id : null)
}); })
}); })
}); })
await page.close(); await page.close()
if (!extId) { if (!extId) {
throw new Error("Could not discover Reader View extension ID"); throw new Error('Could not discover Reader View extension ID')
} }
console.log(`Discovered extension ID: ${extId}`); console.log(`Discovered extension ID: ${extId}`)
return extId; return extId
} }

View File

@@ -1,52 +1,52 @@
import { Command } from "commander"; import { Command } from 'commander'
export const VALID_THEMES = [ export const VALID_THEMES = [
"light", 'light',
"dark", 'dark',
"sepia", 'sepia',
"groove-dark", 'groove-dark',
"solarized-light", 'solarized-light',
"solarized-dark", 'solarized-dark',
"nord-light", 'nord-light',
"nord-dark", 'nord-dark'
]; ]
const program = new Command(); const program = new Command()
program program
.name("pdfy") .name('pdfy')
.description("Convert web articles to PDF using Reader View and Puppeteer") .description('Convert web articles to PDF using Reader View and Puppeteer')
.argument("<url>", "URL of the article to convert") .argument('<url>', 'URL of the article to convert')
.option( .option(
"-t, --theme <theme>", '-t, --theme <theme>',
`Reader View theme (${VALID_THEMES.join(", ")})`, `Reader View theme (${VALID_THEMES.join(', ')})`
) )
.option("-c, --css <path>", "path to custom CSS file") .option('-c, --css <path>', 'path to custom CSS file')
.option("-p, --prefs <path>", "path to extension preferences JSON") .option('-p, --prefs <path>', 'path to extension preferences JSON')
.option( .option(
"-o, --output <path>", '-o, --output <path>',
"output file path (default: ./output/<title>.pdf)", 'output file path (default: ./output/<title>.pdf)'
) )
.addHelpText( .addHelpText(
"after", 'after',
` `
Examples: Examples:
node src/index.js <URL> node src/index.js <URL>
node src/index.js <URL> --theme dark --css config/rules.css node src/index.js <URL> --theme dark --css config/rules.css
npm start -- <URL> --theme dark --css config/rules.css npm start -- <URL> --theme dark --css config/rules.css
`, `
); )
export function parseArgs() { export function parseArgs () {
program.parse(process.argv); program.parse(process.argv)
const opts = program.opts(); const opts = program.opts()
return { return {
url: program.args[0], url: program.args[0],
opts: { opts: {
theme: opts.theme || null, theme: opts.theme || null,
css: opts.css || null, css: opts.css || null,
prefs: opts.prefs || null, prefs: opts.prefs || null,
output: opts.output || null, output: opts.output || null
}, }
}; }
} }

View File

@@ -1,25 +1,25 @@
import fs from "node:fs"; import fs from 'node:fs'
import path from "node:path"; import path from 'node:path'
export function loadPreferences(prefsPath) { export function loadPreferences (prefsPath) {
if (!prefsPath || !fs.existsSync(prefsPath)) return {}; if (!prefsPath || !fs.existsSync(prefsPath)) return {}
try { try {
return JSON.parse(fs.readFileSync(prefsPath, "utf-8")); return JSON.parse(fs.readFileSync(prefsPath, 'utf-8'))
} catch (e) { } catch (e) {
console.warn(`Warning: could not parse preferences file: ${prefsPath}`); console.warn(`Warning: could not parse preferences file: ${prefsPath}`)
return {}; return {}
} }
} }
export function readCustomCss(cssPath) { export function readCustomCss (cssPath) {
if (!cssPath || !fs.existsSync(cssPath)) return null; if (!cssPath || !fs.existsSync(cssPath)) return null
const css = fs.readFileSync(cssPath, "utf-8").trim(); const css = fs.readFileSync(cssPath, 'utf-8').trim()
return css || null; return css || null
} }
export const DEFAULT_CONFIG_DIR = path.resolve("./config"); export const DEFAULT_CONFIG_DIR = path.resolve('./config')
export const DEFAULT_PREFS_PATH = path.join( export const DEFAULT_PREFS_PATH = path.join(
DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_DIR,
"reader-view-preferences.json", 'reader-view-preferences.json'
); )
export const DEFAULT_CSS_PATH = path.join(DEFAULT_CONFIG_DIR, "rules.css"); export const DEFAULT_CSS_PATH = path.join(DEFAULT_CONFIG_DIR, 'rules.css')

View File

@@ -1,119 +1,119 @@
import process from "node:process"; import process from 'node:process'
import path from "node:path"; import path from 'node:path'
import { parseArgs, VALID_THEMES } from "./cli.js"; import { parseArgs, VALID_THEMES } from './cli.js'
import { loadPreferences, readCustomCss } from "./config.js"; import { loadPreferences, readCustomCss } from './config.js'
import { import {
checkExtension, checkExtension,
launchBrowser, launchBrowser,
discoverExtensionId, discoverExtensionId,
getReadabilityPath, getReadabilityPath
} from "./browser.js"; } from './browser.js'
import { import {
extractArticle, extractArticle,
storeArticle, storeArticle,
setExtensionPreferences, setExtensionPreferences,
waitForReaderView, waitForReaderView,
extractRenderedContent, extractRenderedContent,
getArticleTitle, getArticleTitle
} from "./reader.js"; } from './reader.js'
import { import {
getOutputFilename, getOutputFilename,
resolveOutputPath, resolveOutputPath,
enhanceHtml, enhanceHtml,
generatePdf, generatePdf
} from "./pdf.js"; } from './pdf.js'
const EXTENSION_BASE = "chrome-extension://"; const EXTENSION_BASE = 'chrome-extension://'
async function main() { async function main () {
const { url, opts } = parseArgs(); const { url, opts } = parseArgs()
checkExtension(); checkExtension()
let preferences = {}; let preferences = {}
if (opts.prefs) { if (opts.prefs) {
preferences = loadPreferences(path.resolve(opts.prefs)); preferences = loadPreferences(path.resolve(opts.prefs))
} }
const theme = opts.theme || preferences.mode || "light"; const theme = opts.theme || preferences.mode || 'light'
if (!VALID_THEMES.includes(theme)) { if (!VALID_THEMES.includes(theme)) {
console.error( console.error(
`Invalid theme "${theme}". Valid themes: ${VALID_THEMES.join(", ")}`, `Invalid theme "${theme}". Valid themes: ${VALID_THEMES.join(', ')}`
); )
process.exit(1); process.exit(1)
} }
preferences.mode = theme; preferences.mode = theme
console.log(`Theme: ${theme}`); console.log(`Theme: ${theme}`)
let customCss = null; let customCss = null
if (opts.css) { if (opts.css) {
customCss = readCustomCss(path.resolve(opts.css)); customCss = readCustomCss(path.resolve(opts.css))
if (customCss !== null) { if (customCss !== null) {
preferences["user-css"] = customCss; preferences['user-css'] = customCss
console.log(`Custom CSS loaded: ${path.resolve(opts.css)}`); console.log(`Custom CSS loaded: ${path.resolve(opts.css)}`)
} }
} }
const browser = await launchBrowser(); const browser = await launchBrowser()
try { try {
const extId = await discoverExtensionId(browser); const extId = await discoverExtensionId(browser)
console.log(`Fetching article: ${url}`); console.log(`Fetching article: ${url}`)
const articlePage = await browser.newPage(); const articlePage = await browser.newPage()
await articlePage.goto(url, { waitUntil: "networkidle0", timeout: 30000 }); await articlePage.goto(url, { waitUntil: 'networkidle0', timeout: 30000 })
const article = await extractArticle(articlePage, getReadabilityPath()); const article = await extractArticle(articlePage, getReadabilityPath())
await articlePage.close(); await articlePage.close()
const extPage = await browser.newPage(); const extPage = await browser.newPage()
await storeArticle(extPage, extId, 1, article); await storeArticle(extPage, extId, 1, article)
const prefsToSet = Object.fromEntries( const prefsToSet = Object.fromEntries(
Object.entries(preferences).filter( Object.entries(preferences).filter(
([_, v]) => v !== undefined && v !== null, ([_, v]) => v !== undefined && v !== null
), )
); )
if (Object.keys(prefsToSet).length > 0) { if (Object.keys(prefsToSet).length > 0) {
await setExtensionPreferences(extPage, extId, prefsToSet); await setExtensionPreferences(extPage, extId, prefsToSet)
} }
await extPage.close(); await extPage.close()
const readerUrl = [ const readerUrl = [
`${EXTENSION_BASE}${extId}/data/reader/index.html`, `${EXTENSION_BASE}${extId}/data/reader/index.html`,
"?id=1", '?id=1',
`&url=${encodeURIComponent(url)}`, `&url=${encodeURIComponent(url)}`
].join(""); ].join('')
console.log(`Opening Reader View: ${readerUrl}`); console.log(`Opening Reader View: ${readerUrl}`)
const readerPage = await browser.newPage(); const readerPage = await browser.newPage()
await readerPage.goto(readerUrl, { waitUntil: "load", timeout: 30000 }); await readerPage.goto(readerUrl, { waitUntil: 'load', timeout: 30000 })
await waitForReaderView(readerPage); await waitForReaderView(readerPage)
await new Promise((r) => setTimeout(r, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000))
const title = await getArticleTitle(readerPage); const title = await getArticleTitle(readerPage)
const outputFile = opts.output const outputFile = opts.output
? resolveOutputPath(opts.output) ? resolveOutputPath(opts.output)
: getOutputFilename(title); : getOutputFilename(title)
const contentHtml = await extractRenderedContent( const contentHtml = await extractRenderedContent(
readerPage, readerPage,
`${EXTENSION_BASE}${extId}/data/reader/`, `${EXTENSION_BASE}${extId}/data/reader/`
); )
await readerPage.close(); await readerPage.close()
const enhancedHtml = enhanceHtml(contentHtml, customCss); const enhancedHtml = enhanceHtml(contentHtml, customCss)
const pdfPage = await browser.newPage(); const pdfPage = await browser.newPage()
await generatePdf(pdfPage, enhancedHtml, outputFile); await generatePdf(pdfPage, enhancedHtml, outputFile)
await pdfPage.close(); await pdfPage.close()
} finally { } finally {
await browser.close(); await browser.close()
console.log("Browser closed."); console.log('Browser closed.')
} }
} }
main().catch((err) => { main().catch((err) => {
console.error("Fatal error:", err); console.error('Fatal error:', err)
process.exit(1); process.exit(1)
}); })

View File

@@ -1,58 +1,58 @@
import fs from "node:fs"; import fs from 'node:fs'
import path from "node:path"; import path from 'node:path'
const HIGHLIGHT_CSS = const HIGHLIGHT_CSS =
'<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/default.min.css">'; '<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/default.min.css">'
const HIGHLIGHT_JS = const HIGHLIGHT_JS =
'<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js"><\/script><script>hljs.highlightAll();<\/script>'; '<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js"><script><script>hljs.highlightAll();<script>'
export function getOutputFilename(title, outputDir) { export function getOutputFilename (title, outputDir) {
const safe = title const safe = title
.replace(/[<>:"/\\|?*]/g, "") .replace(/[<>:"/\\|?*]/g, '')
.trim() .trim()
.slice(0, 120); .slice(0, 120)
const dir = outputDir ? path.resolve(outputDir) : path.resolve("./output"); const dir = outputDir ? path.resolve(outputDir) : path.resolve('./output')
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true })
return path.join(dir, `${safe || "article"}.pdf`); return path.join(dir, `${safe || 'article'}.pdf`)
} }
export function resolveOutputPath(outputFlag) { export function resolveOutputPath (outputFlag) {
if (!outputFlag) return null; if (!outputFlag) return null
const resolved = path.resolve(outputFlag); const resolved = path.resolve(outputFlag)
const dir = resolved.endsWith(".pdf") const dir = resolved.endsWith('.pdf')
? path.dirname(resolved) ? path.dirname(resolved)
: path.dirname(resolved); : path.dirname(resolved)
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true })
return resolved.endsWith(".pdf") ? resolved : `${resolved}.pdf`; return resolved.endsWith('.pdf') ? resolved : `${resolved}.pdf`
} }
export function enhanceHtml(html, customCss) { export function enhanceHtml (html, customCss) {
let enhanced = html.replace( let enhanced = html.replace(
"</head>", '</head>',
`${HIGHLIGHT_CSS}${HIGHLIGHT_JS}</head>`, `${HIGHLIGHT_CSS}${HIGHLIGHT_JS}</head>`
); )
if (customCss !== null) { if (customCss !== null) {
enhanced = enhanced.replace( enhanced = enhanced.replace(
"</head>", '</head>',
`<style>${customCss}</style></head>`, `<style>${customCss}</style></head>`
); )
} }
return enhanced; return enhanced
} }
export async function generatePdf(page, html, outputPath) { export async function generatePdf (page, html, outputPath) {
await page.setContent(html, { waitUntil: "networkidle0" }); await page.setContent(html, { waitUntil: 'networkidle0' })
console.log(`Generating PDF: ${outputPath}`); console.log(`Generating PDF: ${outputPath}`)
await page.pdf({ await page.pdf({
path: outputPath, path: outputPath,
format: "A4", format: 'A4',
printBackground: true, printBackground: true,
margin: { margin: {
top: "0mm", top: '0mm',
bottom: "0mm", bottom: '0mm',
left: "0mm", left: '0mm',
right: "0mm", right: '0mm'
}, }
}); })
console.log(`PDF saved: ${outputPath}`); console.log(`PDF saved: ${outputPath}`)
} }

View File

@@ -1,15 +1,15 @@
import fs from "node:fs"; import fs from 'node:fs'
export async function extractArticle(page, readabilityPath) { export async function extractArticle (page, readabilityPath) {
const readabilitySource = fs.readFileSync(readabilityPath, "utf-8"); const readabilitySource = fs.readFileSync(readabilityPath, 'utf-8')
await page.evaluate(readabilitySource); await page.evaluate(readabilitySource)
const article = await page.evaluate(() => { const article = await page.evaluate(() => {
const doc = document.cloneNode(true); const doc = document.cloneNode(true)
const reader = new Readability(doc); const reader = new Readability(doc)
const parsed = reader.parse(); const parsed = reader.parse()
if (!parsed) return null; if (!parsed) return null
const text = parsed.textContent || ""; const text = parsed.textContent || ''
const lang = document.documentElement.lang || "en"; const lang = document.documentElement.lang || 'en'
const readingSpeeds = { const readingSpeeds = {
en: { cpm: 987, variance: 118 }, en: { cpm: 987, variance: 118 },
ar: { cpm: 612, variance: 88 }, ar: { cpm: 612, variance: 88 },
@@ -26,17 +26,17 @@ export async function extractArticle(page, readabilityPath) {
ru: { cpm: 985, variance: 119 }, ru: { cpm: 985, variance: 119 },
sv: { cpm: 1015, variance: 116 }, sv: { cpm: 1015, variance: 116 },
tr: { cpm: 935, variance: 103 }, tr: { cpm: 935, variance: 103 },
zh: { cpm: 891, variance: 121 }, zh: { cpm: 891, variance: 121 }
}; }
const speed = readingSpeeds[lang] || readingSpeeds.en; const speed = readingSpeeds[lang] || readingSpeeds.en
const charsPerMinLow = speed.cpm - speed.variance; const charsPerMinLow = speed.cpm - speed.variance
const charsPerMinHigh = speed.cpm + speed.variance; const charsPerMinHigh = speed.cpm + speed.variance
const dateMeta = document.querySelector( const dateMeta = document.querySelector(
'meta[property="article:published_time"],meta[property="og:pubdate"],' + 'meta[property="article:published_time"],meta[property="og:pubdate"],' +
'meta[property="og:publish_date"],meta[name="citation_online_date"],' + 'meta[property="og:publish_date"],meta[name="citation_online_date"],' +
'meta[name="dc.Date"]', 'meta[name="dc.Date"]'
); )
const publishedTime = dateMeta?.content || parsed.publishedTime || ""; const publishedTime = dateMeta?.content || parsed.publishedTime || ''
return { return {
...parsed, ...parsed,
lang, lang,
@@ -44,131 +44,131 @@ export async function extractArticle(page, readabilityPath) {
readingTimeMinsSlow: Math.ceil(text.length / charsPerMinLow), readingTimeMinsSlow: Math.ceil(text.length / charsPerMinLow),
published_time: publishedTime published_time: publishedTime
? new Date(publishedTime).toLocaleDateString() ? new Date(publishedTime).toLocaleDateString()
: "", : '',
url: window.location.href, url: window.location.href
}; }
}); })
if (!article) { if (!article) {
throw new Error("Failed to extract article content with Readability"); throw new Error('Failed to extract article content with Readability')
} }
console.log( console.log(
`Extracted article: ${article.title} (${article.length} chars, reading ${article.readingTimeMinsFast}-${article.readingTimeMinsSlow} min` + `Extracted article: ${article.title} (${article.length} chars, reading ${article.readingTimeMinsFast}-${article.readingTimeMinsSlow} min` +
(article.published_time ? `, ${article.published_time}` : "") + (article.published_time ? `, ${article.published_time}` : '') +
")", ')'
); )
return article; return article
} }
export async function storeArticle(extPage, extId, articleId, article) { export async function storeArticle (extPage, extId, articleId, article) {
await extPage.goto(`chrome-extension://${extId}/manifest.json`, { await extPage.goto(`chrome-extension://${extId}/manifest.json`, {
timeout: 8000, timeout: 8000
}); })
await extPage.evaluate( await extPage.evaluate(
({ id, article }) => { ({ id, article }) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const req = indexedDB.open("content-temporary-storage", 1); const req = indexedDB.open('content-temporary-storage', 1)
req.onupgradeneeded = (e) => { req.onupgradeneeded = (e) => {
const db = e.target.result; const db = e.target.result
if (!db.objectStoreNames.contains("storage")) { if (!db.objectStoreNames.contains('storage')) {
db.createObjectStore("storage", { keyPath: "id" }); db.createObjectStore('storage', { keyPath: 'id' })
}
} }
};
req.onsuccess = (e) => { req.onsuccess = (e) => {
const db = e.target.result; const db = e.target.result
const tx = db.transaction(["storage"], "readwrite"); const tx = db.transaction(['storage'], 'readwrite')
const store = tx.objectStore("storage"); const store = tx.objectStore('storage')
const put = store.put({ id, content: article }); const put = store.put({ id, content: article })
put.onerror = () => reject(put.error?.message || "put failed"); put.onerror = () => reject(put.error?.message || 'put failed')
tx.oncomplete = () => { tx.oncomplete = () => {
db.close(); db.close()
resolve(); resolve()
}; }
tx.onerror = () => reject(tx.error?.message || "tx failed"); tx.onerror = () => reject(tx.error?.message || 'tx failed')
}; }
req.onerror = () => reject(req.error?.message || "open failed"); req.onerror = () => reject(req.error?.message || 'open failed')
}); })
}, },
{ id: articleId, article }, { id: articleId, article }
); )
console.log("Article stored in extension IndexedDB"); console.log('Article stored in extension IndexedDB')
} }
export async function setExtensionPreferences(page, extId, prefs) { export async function setExtensionPreferences (page, extId, prefs) {
await page.goto(`chrome-extension://${extId}/manifest.json`, { await page.goto(`chrome-extension://${extId}/manifest.json`, {
timeout: 8000, timeout: 8000
}); })
await page.evaluate((settings) => { await page.evaluate((settings) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
chrome.storage.local.set(settings, () => { chrome.storage.local.set(settings, () => {
if (chrome.runtime.lastError) reject(chrome.runtime.lastError.message); if (chrome.runtime.lastError) reject(chrome.runtime.lastError.message)
else resolve(); else resolve()
}); })
}); })
}, prefs); }, prefs)
console.log(`Extension preferences set: ${Object.keys(prefs).length} key(s)`); console.log(`Extension preferences set: ${Object.keys(prefs).length} key(s)`)
} }
export async function waitForReaderView(page) { export async function waitForReaderView (page) {
console.log("Waiting for Reader View to render article..."); console.log('Waiting for Reader View to render article...')
await page.waitForFunction( await page.waitForFunction(
() => { () => {
const title = document.title; const title = document.title
if (!title || title === "Loading... :: Reader View") return false; if (!title || title === 'Loading... :: Reader View') return false
const iframe = document.querySelector("#content iframe"); const iframe = document.querySelector('#content iframe')
if (!iframe) return false; if (!iframe) return false
try { try {
const body = iframe.contentDocument?.body; const body = iframe.contentDocument?.body
if (!body) return false; if (!body) return false
return body.textContent.trim().length > 200; return body.textContent.trim().length > 200
} catch { } catch {
return false; return false
} }
}, },
{ timeout: 60000 }, { timeout: 60000 }
); )
console.log("Article rendered."); console.log('Article rendered.')
} }
export async function extractRenderedContent(page, baseUrl) { export async function extractRenderedContent (page, baseUrl) {
const html = await page.evaluate((extBase) => { const html = await page.evaluate((extBase) => {
const iframe = document.querySelector("#content iframe"); const iframe = document.querySelector('#content iframe')
if (!iframe) return null; if (!iframe) return null
try { try {
const doc = iframe.contentDocument; const doc = iframe.contentDocument
if (!doc) return null; if (!doc) return null
const dataMode = document.documentElement.getAttribute("data-mode"); const dataMode = document.documentElement.getAttribute('data-mode')
if (dataMode) { if (dataMode) {
doc.documentElement.setAttribute("data-mode", dataMode); doc.documentElement.setAttribute('data-mode', dataMode)
} }
const cs = getComputedStyle(doc.documentElement); const cs = getComputedStyle(doc.documentElement)
const cssVars = ["--fg", "--bg", "--bd", "--lk", "--hg"]; const cssVars = ['--fg', '--bg', '--bd', '--lk', '--hg']
const inlineVars = cssVars const inlineVars = cssVars
.map((v) => `${v}: ${cs.getPropertyValue(v)}`) .map((v) => `${v}: ${cs.getPropertyValue(v)}`)
.filter((s) => s.length > 5) .filter((s) => s.length > 5)
.join("; "); .join('; ')
if (inlineVars) { if (inlineVars) {
const existing = doc.documentElement.style.cssText; const existing = doc.documentElement.style.cssText
doc.documentElement.style.cssText = existing doc.documentElement.style.cssText = existing
? existing + "; " + inlineVars ? existing + '; ' + inlineVars
: inlineVars; : inlineVars
} }
const b = doc.createElement("base"); const b = doc.createElement('base')
b.href = extBase; b.href = extBase
doc.head.insertBefore(b, doc.head.firstChild); doc.head.insertBefore(b, doc.head.firstChild)
return "<!DOCTYPE html>\n" + doc.documentElement.outerHTML; return '<!DOCTYPE html>\n' + doc.documentElement.outerHTML
} catch { } catch {
return null; return null
} }
}, baseUrl); }, baseUrl)
if (!html) throw new Error("Could not extract rendered content from iframe"); if (!html) throw new Error('Could not extract rendered content from iframe')
return html; return html
} }
export function getArticleTitle(page) { export function getArticleTitle (page) {
return page.evaluate(() => { return page.evaluate(() => {
return (document.title || "").replace(" :: Reader View", ""); return (document.title || '').replace(' :: Reader View', '')
}); })
} }

View File

@@ -1,70 +1,70 @@
import { execSync } from "node:child_process"; import { execSync } from 'node:child_process'
import fs from "node:fs"; import fs from 'node:fs'
import path from "node:path"; import path from 'node:path'
const TEST_DIR = path.dirname(new URL(import.meta.url).pathname); const TEST_DIR = path.dirname(new URL(import.meta.url).pathname)
const TEST_FILE = path.join(TEST_DIR, "test.txt"); const TEST_FILE = path.join(TEST_DIR, 'test.txt')
const OUTPUT_DIR = "output/test"; const OUTPUT_DIR = 'output/test'
const TESTS = [ const TESTS = [
{ label: "default (no theme/css)", args: "" }, { label: 'default (no theme/css)', args: '' },
{ label: "dark theme", args: "--theme dark" }, { label: 'dark theme', args: '--theme dark' },
{ label: "sepia theme", args: "--theme sepia" }, { label: 'sepia theme', args: '--theme sepia' },
{ label: "custom CSS", args: "--css config/rules.css" }, { label: 'custom CSS', args: '--css config/rules.css' },
{ {
label: "dark theme + custom CSS", label: 'dark theme + custom CSS',
args: "--theme dark --css config/rules.css", args: '--theme dark --css config/rules.css'
}, }
]; ]
if (!fs.existsSync(TEST_FILE)) { if (!fs.existsSync(TEST_FILE)) {
console.error(`Test file not found: ${TEST_FILE}`); console.error(`Test file not found: ${TEST_FILE}`)
process.exit(1); process.exit(1)
} }
const urls = fs const urls = fs
.readFileSync(TEST_FILE, "utf-8") .readFileSync(TEST_FILE, 'utf-8')
.split("\n") .split('\n')
.map((l) => l.trim()) .map((l) => l.trim())
.filter((l) => l && !l.startsWith("#")); .filter((l) => l && !l.startsWith('#'))
if (urls.length === 0) { if (urls.length === 0) {
console.error("No URLs found in test.txt"); console.error('No URLs found in test.txt')
process.exit(1); process.exit(1)
} }
fs.mkdirSync(OUTPUT_DIR, { recursive: true }); fs.mkdirSync(OUTPUT_DIR, { recursive: true })
let passed = 0; let passed = 0
let failed = 0; let failed = 0
console.log( console.log(
`Running ${TESTS.length} test scenario(s) across ${urls.length} URL(s)...\n`, `Running ${TESTS.length} test scenario(s) across ${urls.length} URL(s)...\n`
); )
for (const url of urls) { for (const url of urls) {
for (const { label, args } of TESTS) { for (const { label, args } of TESTS) {
const slug = label.replace(/[^a-z0-9]+/gi, "-").replace(/(^-|-$)/g, ""); const slug = label.replace(/[^a-z0-9]+/gi, '-').replace(/(^-|-$)/g, '')
const outputPath = path.join(OUTPUT_DIR, `${slug}.pdf`); const outputPath = path.join(OUTPUT_DIR, `${slug}.pdf`)
const testName = `[${label}] ${url}`; const testName = `[${label}] ${url}`
console.log(`\n=== ${testName} ===`); console.log(`\n=== ${testName} ===`)
try { try {
execSync(`node src/index.js "${url}" ${args} --output "${outputPath}"`, { execSync(`node src/index.js "${url}" ${args} --output "${outputPath}"`, {
stdio: "inherit", stdio: 'inherit',
timeout: 120000, timeout: 120000
}); })
console.log(` PASS: ${testName}`); console.log(` PASS: ${testName}`)
passed++; passed++
} catch (err) { } catch (err) {
console.error(` FAIL: ${testName}`); console.error(` FAIL: ${testName}`)
failed++; failed++
} }
} }
} }
console.log(`\n${"=".repeat(50)}`); console.log(`\n${'='.repeat(50)}`)
console.log( console.log(
`Results: ${passed} passed, ${failed} failed out of ${passed + failed} tests`, `Results: ${passed} passed, ${failed} failed out of ${passed + failed} tests`
); )
process.exit(failed > 0 ? 1 : 0); process.exit(failed > 0 ? 1 : 0)