mirror of
https://github.com/NohamR/pdfy.git
synced 2026-10-10 18:29:55 +00:00
rewrite
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,3 +3,6 @@
|
||||
extension/Reader-View-Chrome-Web-Store.crx
|
||||
extension/Reader-View-Chrome-Web-Store.zip
|
||||
/node_modules
|
||||
/output
|
||||
config/reader-view-preferences.json
|
||||
config/rules.css
|
||||
|
||||
6
config/reader-view-preferences.json.example
Normal file
6
config/reader-view-preferences.json.example
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"tip.0": "s",
|
||||
"tip.1": "s",
|
||||
"tip.2": "s",
|
||||
"tip.3": "s"
|
||||
}
|
||||
5
config/rules.css.example
Normal file
5
config/rules.css.example
Normal file
@@ -0,0 +1,5 @@
|
||||
html[data-mode="light"] h1 {
|
||||
font-size: 40px;
|
||||
margin: 40px 0 32px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
342
index.js
342
index.js
@@ -1,340 +1,2 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import puppeteer from "puppeteer";
|
||||
|
||||
const EXTENSION_DIR = path.resolve("./extension/reader-view");
|
||||
const MANIFEST_PATH = path.join(EXTENSION_DIR, "manifest.json");
|
||||
const READABILITY_PATH = path.join(EXTENSION_DIR, "data/inject/Readability.js");
|
||||
|
||||
const VALID_THEMES = [
|
||||
"light", "dark", "sepia", "groove-dark",
|
||||
"solarized-light", "solarized-dark", "nord-light", "nord-dark",
|
||||
];
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const url = args[0];
|
||||
const opts = { theme: null, css: null, prefs: null };
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
if (args[i] === "--theme" && args[i + 1]) opts.theme = args[++i];
|
||||
else if (args[i] === "--css" && args[i + 1]) opts.css = args[++i];
|
||||
else if (args[i] === "--prefs" && args[i + 1]) opts.prefs = args[++i];
|
||||
}
|
||||
return { url, opts };
|
||||
}
|
||||
|
||||
function loadPreferences(prefsPath) {
|
||||
if (!prefsPath || !fs.existsSync(prefsPath)) return {};
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(prefsPath, "utf-8"));
|
||||
} catch (e) {
|
||||
console.warn(`Warning: could not parse preferences file: ${prefsPath}`);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function readCustomCss(cssPath) {
|
||||
if (!cssPath || !fs.existsSync(cssPath)) return null;
|
||||
const css = fs.readFileSync(cssPath, "utf-8").trim();
|
||||
return css || null;
|
||||
}
|
||||
|
||||
function getOutputFilename(title) {
|
||||
const safe = title
|
||||
.replace(/[<>:"/\\|?*]/g, "")
|
||||
.trim()
|
||||
.slice(0, 120);
|
||||
return `${safe || "article"}.pdf`;
|
||||
}
|
||||
|
||||
async function discoverExtensionId(browser) {
|
||||
const page = await browser.newPage();
|
||||
await page.goto("chrome://extensions/", { timeout: 8000 });
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
const extId = await page.evaluate(() => {
|
||||
return new Promise(resolve => {
|
||||
chrome.management.getAll(exts => {
|
||||
const found = exts.find(e => e.name === "Reader View");
|
||||
resolve(found ? found.id : null);
|
||||
});
|
||||
});
|
||||
});
|
||||
await page.close();
|
||||
if (!extId) {
|
||||
throw new Error("Could not discover Reader View extension ID");
|
||||
}
|
||||
console.log(`Discovered extension ID: ${extId}`);
|
||||
return extId;
|
||||
}
|
||||
|
||||
async function extractArticle(page, readabilityPath) {
|
||||
const readabilitySource = fs.readFileSync(readabilityPath, "utf-8");
|
||||
await page.evaluate(readabilitySource);
|
||||
const article = await page.evaluate(() => {
|
||||
const doc = document.cloneNode(true);
|
||||
const reader = new Readability(doc);
|
||||
const parsed = reader.parse();
|
||||
if (!parsed) return null;
|
||||
const text = parsed.textContent || "";
|
||||
const lang = document.documentElement.lang || "en";
|
||||
const readingSpeeds = {
|
||||
en: { cpm: 987, variance: 118 }, ar: { cpm: 612, variance: 88 },
|
||||
de: { cpm: 920, variance: 86 }, es: { cpm: 1025, variance: 127 },
|
||||
fi: { cpm: 1078, variance: 121 }, fr: { cpm: 998, variance: 126 },
|
||||
he: { cpm: 833, variance: 130 }, it: { cpm: 950, variance: 131 },
|
||||
jp: { cpm: 891, variance: 121 }, nl: { cpm: 1021, variance: 96 },
|
||||
pl: { cpm: 916, variance: 126 }, pt: { cpm: 1007, variance: 122 },
|
||||
ru: { cpm: 985, variance: 119 }, sv: { cpm: 1015, variance: 116 },
|
||||
tr: { cpm: 935, variance: 103 }, zh: { cpm: 891, variance: 121 },
|
||||
};
|
||||
const speed = readingSpeeds[lang] || readingSpeeds.en;
|
||||
const charsPerMinLow = speed.cpm - speed.variance;
|
||||
const charsPerMinHigh = speed.cpm + speed.variance;
|
||||
const dateMeta = document.querySelector(
|
||||
'meta[property="article:published_time"],meta[property="og:pubdate"],' +
|
||||
'meta[property="og:publish_date"],meta[name="citation_online_date"],' +
|
||||
'meta[name="dc.Date"]'
|
||||
);
|
||||
const publishedTime = dateMeta?.content || parsed.publishedTime || "";
|
||||
return {
|
||||
...parsed,
|
||||
lang,
|
||||
readingTimeMinsFast: Math.ceil(text.length / charsPerMinHigh),
|
||||
readingTimeMinsSlow: Math.ceil(text.length / charsPerMinLow),
|
||||
published_time: publishedTime ? (new Date(publishedTime)).toLocaleDateString() : "",
|
||||
url: window.location.href,
|
||||
};
|
||||
});
|
||||
if (!article) {
|
||||
throw new Error("Failed to extract article content with Readability");
|
||||
}
|
||||
console.log(`Extracted article: ${article.title} (${article.length} chars, reading ${article.readingTimeMinsFast}-${article.readingTimeMinsSlow} min` + (article.published_time ? `, ${article.published_time}` : "") + ")");
|
||||
return article;
|
||||
}
|
||||
|
||||
async function storeArticle(extPage, extId, articleId, article) {
|
||||
await extPage.goto(
|
||||
`chrome-extension://${extId}/manifest.json`,
|
||||
{ timeout: 8000 }
|
||||
);
|
||||
await extPage.evaluate(({ id, article }) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open("content-temporary-storage", 1);
|
||||
req.onupgradeneeded = e => {
|
||||
const db = e.target.result;
|
||||
if (!db.objectStoreNames.contains("storage")) {
|
||||
db.createObjectStore("storage", { keyPath: "id" });
|
||||
}
|
||||
};
|
||||
req.onsuccess = e => {
|
||||
const db = e.target.result;
|
||||
const tx = db.transaction(["storage"], "readwrite");
|
||||
const store = tx.objectStore("storage");
|
||||
const put = store.put({ id, content: article });
|
||||
put.onerror = () => reject(put.error?.message || "put failed");
|
||||
tx.oncomplete = () => { db.close(); resolve(); };
|
||||
tx.onerror = () => reject(tx.error?.message || "tx failed");
|
||||
};
|
||||
req.onerror = () => reject(req.error?.message || "open failed");
|
||||
});
|
||||
}, { id: articleId, article });
|
||||
console.log("Article stored in extension IndexedDB");
|
||||
}
|
||||
|
||||
async function waitForReaderView(page) {
|
||||
console.log("Waiting for Reader View to render article...");
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const title = document.title;
|
||||
if (!title || title === "Loading... :: Reader View") return false;
|
||||
const iframe = document.querySelector("#content iframe");
|
||||
if (!iframe) return false;
|
||||
try {
|
||||
const body = iframe.contentDocument?.body;
|
||||
if (!body) return false;
|
||||
return body.textContent.trim().length > 200;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ timeout: 60000 }
|
||||
);
|
||||
console.log("Article rendered.");
|
||||
}
|
||||
|
||||
async function setExtensionPreferences(page, extId, prefs) {
|
||||
await page.goto(
|
||||
`chrome-extension://${extId}/manifest.json`,
|
||||
{ timeout: 8000 }
|
||||
);
|
||||
await page.evaluate((settings) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.storage.local.set(settings, () => {
|
||||
if (chrome.runtime.lastError) reject(chrome.runtime.lastError.message);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}, prefs);
|
||||
console.log(`Extension preferences set: ${Object.keys(prefs).length} key(s)`);
|
||||
}
|
||||
|
||||
async function extractRenderedContent(page, baseUrl) {
|
||||
const html = await page.evaluate((extBase) => {
|
||||
const iframe = document.querySelector("#content iframe");
|
||||
if (!iframe) return null;
|
||||
try {
|
||||
const doc = iframe.contentDocument;
|
||||
if (!doc) return null;
|
||||
const b = doc.createElement("base");
|
||||
b.href = extBase;
|
||||
doc.head.insertBefore(b, doc.head.firstChild);
|
||||
return "<!DOCTYPE html>\n" + doc.documentElement.outerHTML;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, baseUrl);
|
||||
if (!html) throw new Error("Could not extract rendered content from iframe");
|
||||
return html;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { url, opts } = parseArgs();
|
||||
if (!url) {
|
||||
console.error("Usage: node index.js <URL> [--theme <theme>] [--css <path>] [--prefs <path>]");
|
||||
console.error("Themes: " + VALID_THEMES.join(", "));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(MANIFEST_PATH)) {
|
||||
console.error(
|
||||
`Extension not found at: ${EXTENSION_DIR}\n` +
|
||||
"Please extract the extension first:\n" +
|
||||
" mkdir -p extension/reader-view\n" +
|
||||
" unzip extension/Reader-View-Chrome-Web-Store.zip -d extension/reader-view"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!fs.existsSync(READABILITY_PATH)) {
|
||||
console.error(
|
||||
`Readability.js not found at: ${READABILITY_PATH}\n` +
|
||||
"The extension may be incomplete. Please re-extract it."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let preferences = {};
|
||||
if (opts.prefs) {
|
||||
preferences = loadPreferences(path.resolve(opts.prefs));
|
||||
}
|
||||
|
||||
const theme = opts.theme || preferences.mode || "light";
|
||||
if (!VALID_THEMES.includes(theme)) {
|
||||
console.error(`Invalid theme "${theme}". Valid themes: ${VALID_THEMES.join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
preferences.mode = theme;
|
||||
console.log(`Theme: ${theme}`);
|
||||
|
||||
let customCss = null;
|
||||
if (opts.css) {
|
||||
const cssFile = path.resolve(opts.css);
|
||||
customCss = readCustomCss(cssFile);
|
||||
if (customCss !== null) {
|
||||
preferences["user-css"] = customCss;
|
||||
console.log(`Custom CSS loaded: ${cssFile}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Launching browser with extension: ${EXTENSION_DIR}`);
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
ignoreDefaultArgs: [
|
||||
"--disable-extensions",
|
||||
"--disable-component-extensions-with-background-pages",
|
||||
"--enable-automation",
|
||||
],
|
||||
args: [
|
||||
`--load-extension=${EXTENSION_DIR}`,
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
const extId = await discoverExtensionId(browser);
|
||||
|
||||
console.log(`Fetching article: ${url}`);
|
||||
const articlePage = await browser.newPage();
|
||||
await articlePage.goto(url, { waitUntil: "networkidle0", timeout: 30000 });
|
||||
const article = await extractArticle(articlePage, READABILITY_PATH);
|
||||
await articlePage.close();
|
||||
|
||||
const extPage = await browser.newPage();
|
||||
await storeArticle(extPage, extId, 1, article);
|
||||
|
||||
const prefsToSet = Object.fromEntries(
|
||||
Object.entries(preferences).filter(([_, v]) => v !== undefined && v !== null)
|
||||
);
|
||||
if (Object.keys(prefsToSet).length > 0) {
|
||||
await setExtensionPreferences(extPage, extId, prefsToSet);
|
||||
}
|
||||
await extPage.close();
|
||||
|
||||
const readerUrl = [
|
||||
`chrome-extension://${extId}/data/reader/index.html`,
|
||||
"?id=1",
|
||||
`&url=${encodeURIComponent(url)}`,
|
||||
].join("");
|
||||
console.log(`Opening Reader View: ${readerUrl}`);
|
||||
|
||||
const readerPage = await browser.newPage();
|
||||
await readerPage.goto(readerUrl, { waitUntil: "load", timeout: 30000 });
|
||||
|
||||
await waitForReaderView(readerPage);
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
|
||||
const title = await readerPage.evaluate(() => {
|
||||
return (document.title || "").replace(" :: Reader View", "");
|
||||
});
|
||||
const outputFile = getOutputFilename(title);
|
||||
|
||||
const contentHtml = await extractRenderedContent(readerPage, `chrome-extension://${extId}/data/reader/`);
|
||||
await readerPage.close();
|
||||
|
||||
const highlightCss = '<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/default.min.css">';
|
||||
const highlightJs = '<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js"><\/script><script>hljs.highlightAll();<\/script>';
|
||||
let enhancedHtml = contentHtml.replace("</head>", `${highlightCss}${highlightJs}</head>`);
|
||||
|
||||
if (customCss !== null) {
|
||||
enhancedHtml = enhancedHtml.replace("</head>", `<style>${customCss}</style></head>`);
|
||||
}
|
||||
|
||||
const pdfPage = await browser.newPage();
|
||||
await pdfPage.setContent(enhancedHtml, { waitUntil: "networkidle0" });
|
||||
|
||||
console.log(`Generating PDF: ${outputFile}`);
|
||||
await pdfPage.pdf({
|
||||
path: outputFile,
|
||||
format: "A4",
|
||||
printBackground: true,
|
||||
margin: {
|
||||
top: "10mm",
|
||||
bottom: "10mm",
|
||||
left: "0mm",
|
||||
right: "0mm",
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`PDF saved: ${outputFile}`);
|
||||
await pdfPage.close();
|
||||
} finally {
|
||||
await browser.close();
|
||||
console.log("Browser closed.");
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error("Fatal error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
#!/usr/bin/env node
|
||||
import "./src/index.js";
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Convert web articles to PDF using Reader View and Puppeteer",
|
||||
"main": "index.js",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js"
|
||||
"start": "node src/index.js",
|
||||
"test": "node src/test/test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"puppeteer": "^24.0.0"
|
||||
|
||||
72
src/browser.js
Normal file
72
src/browser.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import puppeteer from "puppeteer";
|
||||
|
||||
const EXTENSION_DIR = path.resolve("./extension/reader-view");
|
||||
const MANIFEST_PATH = path.join(EXTENSION_DIR, "manifest.json");
|
||||
const READABILITY_PATH = path.join(EXTENSION_DIR, "data/inject/Readability.js");
|
||||
|
||||
export function checkExtension() {
|
||||
if (!fs.existsSync(MANIFEST_PATH)) {
|
||||
console.error(
|
||||
`Extension not found at: ${EXTENSION_DIR}\n` +
|
||||
"Please extract the extension first:\n" +
|
||||
" mkdir -p extension/reader-view\n" +
|
||||
" unzip extension/Reader-View-Chrome-Web-Store.zip -d extension/reader-view",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!fs.existsSync(READABILITY_PATH)) {
|
||||
console.error(
|
||||
`Readability.js not found at: ${READABILITY_PATH}\n` +
|
||||
"The extension may be incomplete. Please re-extract it.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export function getReadabilityPath() {
|
||||
return READABILITY_PATH;
|
||||
}
|
||||
|
||||
export function getExtensionDir() {
|
||||
return EXTENSION_DIR;
|
||||
}
|
||||
|
||||
export async function launchBrowser() {
|
||||
console.log(`Launching browser with extension: ${EXTENSION_DIR}`);
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
ignoreDefaultArgs: [
|
||||
"--disable-extensions",
|
||||
"--disable-component-extensions-with-background-pages",
|
||||
"--enable-automation",
|
||||
],
|
||||
args: [
|
||||
`--load-extension=${EXTENSION_DIR}`,
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
],
|
||||
});
|
||||
return browser;
|
||||
}
|
||||
|
||||
export async function discoverExtensionId(browser) {
|
||||
const page = await browser.newPage();
|
||||
await page.goto("chrome://extensions/", { timeout: 8000 });
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
const extId = await page.evaluate(() => {
|
||||
return new Promise((resolve) => {
|
||||
chrome.management.getAll((exts) => {
|
||||
const found = exts.find((e) => e.name === "Reader View");
|
||||
resolve(found ? found.id : null);
|
||||
});
|
||||
});
|
||||
});
|
||||
await page.close();
|
||||
if (!extId) {
|
||||
throw new Error("Could not discover Reader View extension ID");
|
||||
}
|
||||
console.log(`Discovered extension ID: ${extId}`);
|
||||
return extId;
|
||||
}
|
||||
59
src/cli.js
Normal file
59
src/cli.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import process from "node:process";
|
||||
|
||||
export const VALID_THEMES = [
|
||||
"light",
|
||||
"dark",
|
||||
"sepia",
|
||||
"groove-dark",
|
||||
"solarized-light",
|
||||
"solarized-dark",
|
||||
"nord-light",
|
||||
"nord-dark",
|
||||
];
|
||||
|
||||
export function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const url = args[0];
|
||||
const opts = { theme: null, css: null, prefs: null, output: null };
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
if (args[i] === "--theme" && args[i + 1]) opts.theme = args[++i];
|
||||
else if (args[i] === "--css" && args[i + 1]) opts.css = args[++i];
|
||||
else if (args[i] === "--prefs" && args[i + 1]) opts.prefs = args[++i];
|
||||
else if (args[i] === "--output" && args[i + 1]) opts.output = args[++i];
|
||||
}
|
||||
if (
|
||||
!opts.theme &&
|
||||
args[1] &&
|
||||
!args[1].startsWith("-") &&
|
||||
VALID_THEMES.includes(args[1])
|
||||
) {
|
||||
opts.theme = args[1];
|
||||
}
|
||||
return { url, opts };
|
||||
}
|
||||
|
||||
export function printUsage() {
|
||||
console.error("Usage: node src/index.js <URL> [theme] [options]");
|
||||
console.error("");
|
||||
console.error("Options:");
|
||||
console.error(
|
||||
" --theme <theme> Reader View theme (" + VALID_THEMES.join(", ") + ")",
|
||||
);
|
||||
console.error(" --css <path> Path to custom CSS file");
|
||||
console.error(" --prefs <path> Path to extension preferences JSON");
|
||||
console.error(
|
||||
" --output <path> Output file path (default: ./output/<title>.pdf)",
|
||||
);
|
||||
console.error("");
|
||||
console.error("Examples:");
|
||||
console.error(" node src/index.js <URL>");
|
||||
console.error(" node src/index.js <URL> dark --css config/rules.css");
|
||||
console.error("");
|
||||
console.error("IMPORTANT: npm start consumes --flags as npm config.");
|
||||
console.error("Use node directly or add -- before flags:");
|
||||
console.error(
|
||||
" node src/index.js <URL> --theme dark --css config/rules.css",
|
||||
);
|
||||
console.error(" npm start -- <URL> --theme dark --css config/rules.css");
|
||||
console.error("");
|
||||
}
|
||||
25
src/config.js
Normal file
25
src/config.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export function loadPreferences(prefsPath) {
|
||||
if (!prefsPath || !fs.existsSync(prefsPath)) return {};
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(prefsPath, "utf-8"));
|
||||
} catch (e) {
|
||||
console.warn(`Warning: could not parse preferences file: ${prefsPath}`);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function readCustomCss(cssPath) {
|
||||
if (!cssPath || !fs.existsSync(cssPath)) return null;
|
||||
const css = fs.readFileSync(cssPath, "utf-8").trim();
|
||||
return css || null;
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG_DIR = path.resolve("./config");
|
||||
export const DEFAULT_PREFS_PATH = path.join(
|
||||
DEFAULT_CONFIG_DIR,
|
||||
"reader-view-preferences.json",
|
||||
);
|
||||
export const DEFAULT_CSS_PATH = path.join(DEFAULT_CONFIG_DIR, "rules.css");
|
||||
123
src/index.js
Normal file
123
src/index.js
Normal file
@@ -0,0 +1,123 @@
|
||||
import process from "node:process";
|
||||
import path from "node:path";
|
||||
import { parseArgs, printUsage, VALID_THEMES } from "./cli.js";
|
||||
import { loadPreferences, readCustomCss } from "./config.js";
|
||||
import {
|
||||
checkExtension,
|
||||
launchBrowser,
|
||||
discoverExtensionId,
|
||||
getReadabilityPath,
|
||||
} from "./browser.js";
|
||||
import {
|
||||
extractArticle,
|
||||
storeArticle,
|
||||
setExtensionPreferences,
|
||||
waitForReaderView,
|
||||
extractRenderedContent,
|
||||
getArticleTitle,
|
||||
} from "./reader.js";
|
||||
import {
|
||||
getOutputFilename,
|
||||
resolveOutputPath,
|
||||
enhanceHtml,
|
||||
generatePdf,
|
||||
} from "./pdf.js";
|
||||
|
||||
const EXTENSION_BASE = "chrome-extension://";
|
||||
|
||||
async function main() {
|
||||
const { url, opts } = parseArgs();
|
||||
if (!url) {
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
checkExtension();
|
||||
|
||||
let preferences = {};
|
||||
if (opts.prefs) {
|
||||
preferences = loadPreferences(path.resolve(opts.prefs));
|
||||
}
|
||||
|
||||
const theme = opts.theme || preferences.mode || "light";
|
||||
if (!VALID_THEMES.includes(theme)) {
|
||||
console.error(
|
||||
`Invalid theme "${theme}". Valid themes: ${VALID_THEMES.join(", ")}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
preferences.mode = theme;
|
||||
console.log(`Theme: ${theme}`);
|
||||
|
||||
let customCss = null;
|
||||
if (opts.css) {
|
||||
customCss = readCustomCss(path.resolve(opts.css));
|
||||
if (customCss !== null) {
|
||||
preferences["user-css"] = customCss;
|
||||
console.log(`Custom CSS loaded: ${path.resolve(opts.css)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const browser = await launchBrowser();
|
||||
|
||||
try {
|
||||
const extId = await discoverExtensionId(browser);
|
||||
|
||||
console.log(`Fetching article: ${url}`);
|
||||
const articlePage = await browser.newPage();
|
||||
await articlePage.goto(url, { waitUntil: "networkidle0", timeout: 30000 });
|
||||
const article = await extractArticle(articlePage, getReadabilityPath());
|
||||
await articlePage.close();
|
||||
|
||||
const extPage = await browser.newPage();
|
||||
await storeArticle(extPage, extId, 1, article);
|
||||
|
||||
const prefsToSet = Object.fromEntries(
|
||||
Object.entries(preferences).filter(
|
||||
([_, v]) => v !== undefined && v !== null,
|
||||
),
|
||||
);
|
||||
if (Object.keys(prefsToSet).length > 0) {
|
||||
await setExtensionPreferences(extPage, extId, prefsToSet);
|
||||
}
|
||||
await extPage.close();
|
||||
|
||||
const readerUrl = [
|
||||
`${EXTENSION_BASE}${extId}/data/reader/index.html`,
|
||||
"?id=1",
|
||||
`&url=${encodeURIComponent(url)}`,
|
||||
].join("");
|
||||
console.log(`Opening Reader View: ${readerUrl}`);
|
||||
|
||||
const readerPage = await browser.newPage();
|
||||
await readerPage.goto(readerUrl, { waitUntil: "load", timeout: 30000 });
|
||||
|
||||
await waitForReaderView(readerPage);
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
|
||||
const title = await getArticleTitle(readerPage);
|
||||
const outputFile = opts.output
|
||||
? resolveOutputPath(opts.output)
|
||||
: getOutputFilename(title);
|
||||
|
||||
const contentHtml = await extractRenderedContent(
|
||||
readerPage,
|
||||
`${EXTENSION_BASE}${extId}/data/reader/`,
|
||||
);
|
||||
await readerPage.close();
|
||||
|
||||
const enhancedHtml = enhanceHtml(contentHtml, customCss);
|
||||
|
||||
const pdfPage = await browser.newPage();
|
||||
await generatePdf(pdfPage, enhancedHtml, outputFile);
|
||||
await pdfPage.close();
|
||||
} finally {
|
||||
await browser.close();
|
||||
console.log("Browser closed.");
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Fatal error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
58
src/pdf.js
Normal file
58
src/pdf.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const HIGHLIGHT_CSS =
|
||||
'<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/default.min.css">';
|
||||
const HIGHLIGHT_JS =
|
||||
'<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) {
|
||||
const safe = title
|
||||
.replace(/[<>:"/\\|?*]/g, "")
|
||||
.trim()
|
||||
.slice(0, 120);
|
||||
const dir = outputDir ? path.resolve(outputDir) : path.resolve("./output");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return path.join(dir, `${safe || "article"}.pdf`);
|
||||
}
|
||||
|
||||
export function resolveOutputPath(outputFlag) {
|
||||
if (!outputFlag) return null;
|
||||
const resolved = path.resolve(outputFlag);
|
||||
const dir = resolved.endsWith(".pdf")
|
||||
? path.dirname(resolved)
|
||||
: path.dirname(resolved);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return resolved.endsWith(".pdf") ? resolved : `${resolved}.pdf`;
|
||||
}
|
||||
|
||||
export function enhanceHtml(html, customCss) {
|
||||
let enhanced = html.replace(
|
||||
"</head>",
|
||||
`${HIGHLIGHT_CSS}${HIGHLIGHT_JS}</head>`,
|
||||
);
|
||||
if (customCss !== null) {
|
||||
enhanced = enhanced.replace(
|
||||
"</head>",
|
||||
`<style>${customCss}</style></head>`,
|
||||
);
|
||||
}
|
||||
return enhanced;
|
||||
}
|
||||
|
||||
export async function generatePdf(page, html, outputPath) {
|
||||
await page.setContent(html, { waitUntil: "networkidle0" });
|
||||
console.log(`Generating PDF: ${outputPath}`);
|
||||
await page.pdf({
|
||||
path: outputPath,
|
||||
format: "A4",
|
||||
printBackground: true,
|
||||
margin: {
|
||||
top: "0mm",
|
||||
bottom: "0mm",
|
||||
left: "0mm",
|
||||
right: "0mm",
|
||||
},
|
||||
});
|
||||
console.log(`PDF saved: ${outputPath}`);
|
||||
}
|
||||
174
src/reader.js
Normal file
174
src/reader.js
Normal file
@@ -0,0 +1,174 @@
|
||||
import fs from "node:fs";
|
||||
|
||||
export async function extractArticle(page, readabilityPath) {
|
||||
const readabilitySource = fs.readFileSync(readabilityPath, "utf-8");
|
||||
await page.evaluate(readabilitySource);
|
||||
const article = await page.evaluate(() => {
|
||||
const doc = document.cloneNode(true);
|
||||
const reader = new Readability(doc);
|
||||
const parsed = reader.parse();
|
||||
if (!parsed) return null;
|
||||
const text = parsed.textContent || "";
|
||||
const lang = document.documentElement.lang || "en";
|
||||
const readingSpeeds = {
|
||||
en: { cpm: 987, variance: 118 },
|
||||
ar: { cpm: 612, variance: 88 },
|
||||
de: { cpm: 920, variance: 86 },
|
||||
es: { cpm: 1025, variance: 127 },
|
||||
fi: { cpm: 1078, variance: 121 },
|
||||
fr: { cpm: 998, variance: 126 },
|
||||
he: { cpm: 833, variance: 130 },
|
||||
it: { cpm: 950, variance: 131 },
|
||||
jp: { cpm: 891, variance: 121 },
|
||||
nl: { cpm: 1021, variance: 96 },
|
||||
pl: { cpm: 916, variance: 126 },
|
||||
pt: { cpm: 1007, variance: 122 },
|
||||
ru: { cpm: 985, variance: 119 },
|
||||
sv: { cpm: 1015, variance: 116 },
|
||||
tr: { cpm: 935, variance: 103 },
|
||||
zh: { cpm: 891, variance: 121 },
|
||||
};
|
||||
const speed = readingSpeeds[lang] || readingSpeeds.en;
|
||||
const charsPerMinLow = speed.cpm - speed.variance;
|
||||
const charsPerMinHigh = speed.cpm + speed.variance;
|
||||
const dateMeta = document.querySelector(
|
||||
'meta[property="article:published_time"],meta[property="og:pubdate"],' +
|
||||
'meta[property="og:publish_date"],meta[name="citation_online_date"],' +
|
||||
'meta[name="dc.Date"]',
|
||||
);
|
||||
const publishedTime = dateMeta?.content || parsed.publishedTime || "";
|
||||
return {
|
||||
...parsed,
|
||||
lang,
|
||||
readingTimeMinsFast: Math.ceil(text.length / charsPerMinHigh),
|
||||
readingTimeMinsSlow: Math.ceil(text.length / charsPerMinLow),
|
||||
published_time: publishedTime
|
||||
? new Date(publishedTime).toLocaleDateString()
|
||||
: "",
|
||||
url: window.location.href,
|
||||
};
|
||||
});
|
||||
if (!article) {
|
||||
throw new Error("Failed to extract article content with Readability");
|
||||
}
|
||||
console.log(
|
||||
`Extracted article: ${article.title} (${article.length} chars, reading ${article.readingTimeMinsFast}-${article.readingTimeMinsSlow} min` +
|
||||
(article.published_time ? `, ${article.published_time}` : "") +
|
||||
")",
|
||||
);
|
||||
return article;
|
||||
}
|
||||
|
||||
export async function storeArticle(extPage, extId, articleId, article) {
|
||||
await extPage.goto(`chrome-extension://${extId}/manifest.json`, {
|
||||
timeout: 8000,
|
||||
});
|
||||
await extPage.evaluate(
|
||||
({ id, article }) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open("content-temporary-storage", 1);
|
||||
req.onupgradeneeded = (e) => {
|
||||
const db = e.target.result;
|
||||
if (!db.objectStoreNames.contains("storage")) {
|
||||
db.createObjectStore("storage", { keyPath: "id" });
|
||||
}
|
||||
};
|
||||
req.onsuccess = (e) => {
|
||||
const db = e.target.result;
|
||||
const tx = db.transaction(["storage"], "readwrite");
|
||||
const store = tx.objectStore("storage");
|
||||
const put = store.put({ id, content: article });
|
||||
put.onerror = () => reject(put.error?.message || "put failed");
|
||||
tx.oncomplete = () => {
|
||||
db.close();
|
||||
resolve();
|
||||
};
|
||||
tx.onerror = () => reject(tx.error?.message || "tx failed");
|
||||
};
|
||||
req.onerror = () => reject(req.error?.message || "open failed");
|
||||
});
|
||||
},
|
||||
{ id: articleId, article },
|
||||
);
|
||||
console.log("Article stored in extension IndexedDB");
|
||||
}
|
||||
|
||||
export async function setExtensionPreferences(page, extId, prefs) {
|
||||
await page.goto(`chrome-extension://${extId}/manifest.json`, {
|
||||
timeout: 8000,
|
||||
});
|
||||
await page.evaluate((settings) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.storage.local.set(settings, () => {
|
||||
if (chrome.runtime.lastError) reject(chrome.runtime.lastError.message);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}, prefs);
|
||||
console.log(`Extension preferences set: ${Object.keys(prefs).length} key(s)`);
|
||||
}
|
||||
|
||||
export async function waitForReaderView(page) {
|
||||
console.log("Waiting for Reader View to render article...");
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const title = document.title;
|
||||
if (!title || title === "Loading... :: Reader View") return false;
|
||||
const iframe = document.querySelector("#content iframe");
|
||||
if (!iframe) return false;
|
||||
try {
|
||||
const body = iframe.contentDocument?.body;
|
||||
if (!body) return false;
|
||||
return body.textContent.trim().length > 200;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
console.log("Article rendered.");
|
||||
}
|
||||
|
||||
export async function extractRenderedContent(page, baseUrl) {
|
||||
const html = await page.evaluate((extBase) => {
|
||||
const iframe = document.querySelector("#content iframe");
|
||||
if (!iframe) return null;
|
||||
try {
|
||||
const doc = iframe.contentDocument;
|
||||
if (!doc) return null;
|
||||
|
||||
const dataMode = document.documentElement.getAttribute("data-mode");
|
||||
if (dataMode) {
|
||||
doc.documentElement.setAttribute("data-mode", dataMode);
|
||||
}
|
||||
|
||||
const cs = getComputedStyle(doc.documentElement);
|
||||
const cssVars = ["--fg", "--bg", "--bd", "--lk", "--hg"];
|
||||
const inlineVars = cssVars
|
||||
.map((v) => `${v}: ${cs.getPropertyValue(v)}`)
|
||||
.filter((s) => s.length > 5)
|
||||
.join("; ");
|
||||
if (inlineVars) {
|
||||
const existing = doc.documentElement.style.cssText;
|
||||
doc.documentElement.style.cssText = existing
|
||||
? existing + "; " + inlineVars
|
||||
: inlineVars;
|
||||
}
|
||||
|
||||
const b = doc.createElement("base");
|
||||
b.href = extBase;
|
||||
doc.head.insertBefore(b, doc.head.firstChild);
|
||||
return "<!DOCTYPE html>\n" + doc.documentElement.outerHTML;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, baseUrl);
|
||||
if (!html) throw new Error("Could not extract rendered content from iframe");
|
||||
return html;
|
||||
}
|
||||
|
||||
export function getArticleTitle(page) {
|
||||
return page.evaluate(() => {
|
||||
return (document.title || "").replace(" :: Reader View", "");
|
||||
});
|
||||
}
|
||||
70
src/test/test.js
Normal file
70
src/test/test.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DIR = path.dirname(new URL(import.meta.url).pathname);
|
||||
const TEST_FILE = path.join(TEST_DIR, "test.txt");
|
||||
const OUTPUT_DIR = "output/test";
|
||||
|
||||
const TESTS = [
|
||||
{ label: "default (no theme/css)", args: "" },
|
||||
{ label: "dark theme", args: "--theme dark" },
|
||||
{ label: "sepia theme", args: "--theme sepia" },
|
||||
{ label: "custom CSS", args: "--css config/rules.css" },
|
||||
{
|
||||
label: "dark theme + custom CSS",
|
||||
args: "--theme dark --css config/rules.css",
|
||||
},
|
||||
];
|
||||
|
||||
if (!fs.existsSync(TEST_FILE)) {
|
||||
console.error(`Test file not found: ${TEST_FILE}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const urls = fs
|
||||
.readFileSync(TEST_FILE, "utf-8")
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l && !l.startsWith("#"));
|
||||
|
||||
if (urls.length === 0) {
|
||||
console.error("No URLs found in test.txt");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
console.log(
|
||||
`Running ${TESTS.length} test scenario(s) across ${urls.length} URL(s)...\n`,
|
||||
);
|
||||
|
||||
for (const url of urls) {
|
||||
for (const { label, args } of TESTS) {
|
||||
const slug = label.replace(/[^a-z0-9]+/gi, "-").replace(/(^-|-$)/g, "");
|
||||
const outputPath = path.join(OUTPUT_DIR, `${slug}.pdf`);
|
||||
const testName = `[${label}] ${url}`;
|
||||
console.log(`\n=== ${testName} ===`);
|
||||
|
||||
try {
|
||||
execSync(`node src/index.js "${url}" ${args} --output "${outputPath}"`, {
|
||||
stdio: "inherit",
|
||||
timeout: 120000,
|
||||
});
|
||||
console.log(` PASS: ${testName}`);
|
||||
passed++;
|
||||
} catch (err) {
|
||||
console.error(` FAIL: ${testName}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${"=".repeat(50)}`);
|
||||
console.log(
|
||||
`Results: ${passed} passed, ${failed} failed out of ${passed + failed} tests`,
|
||||
);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
3
src/test/test.txt
Normal file
3
src/test/test.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
https://blog.nns.ee/2026/02/20/katana-v2x-re/
|
||||
https://riverloopsecurity.com/blog/2020/03/hw-101-emmc/
|
||||
https://blog.quarkslab.com/black-box-probing-a-security-analysis-of-xiaomis-mja1-secure-chip.html
|
||||
Reference in New Issue
Block a user