mirror of
https://github.com/NohamR/pdfy.git
synced 2026-10-11 02:39:58 +00:00
Lint
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
58
src/cli.js
58
src/cli.js
@@ -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
|
||||||
},
|
}
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
124
src/index.js
124
src/index.js
@@ -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)
|
||||||
});
|
})
|
||||||
|
|||||||
72
src/pdf.js
72
src/pdf.js
@@ -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}`)
|
||||||
}
|
}
|
||||||
|
|||||||
192
src/reader.js
192
src/reader.js
@@ -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', '')
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user