omnivore/packages/readabilityjs/test/generate-testcase.js

398 lines
12 KiB
JavaScript
Raw Permalink Normal View History

2023-09-12 08:49:05 +00:00
var debug = false
2022-02-11 17:24:33 +00:00
2023-09-12 08:49:05 +00:00
var path = require('path')
var fs = require('fs')
var prettyPrint = require('./utils').prettyPrint
var htmltidy = require('htmltidy2').tidy
2022-02-11 17:24:33 +00:00
2023-09-12 08:49:05 +00:00
var { Readability, isProbablyReaderable } = require('../index')
const { parseHTML } = require('linkedom')
2022-02-11 17:24:33 +00:00
2023-09-12 08:49:05 +00:00
const puppeteer = require('puppeteer-extra')
// Add stealth plugin to hide puppeteer usage
2023-09-12 08:49:05 +00:00
const StealthPlugin = require('puppeteer-extra-plugin-stealth')
puppeteer.use(StealthPlugin())
// Add adblocker plugin to block all ads and trackers (saves bandwidth)
2023-09-12 08:49:05 +00:00
const AdblockerPlugin = require('puppeteer-extra-plugin-adblocker')
puppeteer.use(AdblockerPlugin({ blockTrackers: true }))
2023-09-12 08:49:05 +00:00
var testcaseRoot = path.join(__dirname, 'test-pages')
2022-02-11 17:24:33 +00:00
2023-09-12 08:49:05 +00:00
var argURL = process.argv[3] // Could be undefined, we'll warn if it is if that is an issue.
2022-02-11 17:24:33 +00:00
2023-09-12 08:49:05 +00:00
const DESKTOP_USER_AGENT =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_6_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4372.0 Safari/537.36'
const NON_BOT_DESKTOP_USER_AGENT =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36'
const NON_BOT_HOSTS = ['bloomberg.com', 'forbes.com']
2023-09-12 08:49:05 +00:00
const NON_SCRIPT_HOSTS = ['medium.com', 'fastcompany.com']
const userAgentForUrl = (url) => {
try {
2023-09-12 08:49:05 +00:00
const u = new URL(url)
for (const host of NON_BOT_HOSTS) {
if (u.hostname.endsWith(host)) {
2023-09-12 08:49:05 +00:00
return NON_BOT_DESKTOP_USER_AGENT
}
}
} catch (e) {
console.log('error getting user agent for url', url, e)
}
return DESKTOP_USER_AGENT
2023-09-12 08:49:05 +00:00
}
2022-06-15 14:31:55 +00:00
const enableJavascriptForUrl = (url) => {
2022-06-15 14:27:41 +00:00
try {
2023-09-12 08:49:05 +00:00
const u = new URL(url)
2022-06-15 14:27:41 +00:00
for (const host of NON_SCRIPT_HOSTS) {
if (u.hostname.endsWith(host)) {
2023-09-12 08:49:05 +00:00
return false
2022-06-15 14:27:41 +00:00
}
}
} catch (e) {
console.log('error getting hostname for url', url, e)
}
return true
2023-09-12 08:49:05 +00:00
}
2022-06-15 14:27:41 +00:00
2022-02-11 17:24:33 +00:00
function generateTestcase(slug) {
const options = {
debug,
2023-09-12 08:49:05 +00:00
}
if (slug.startsWith('newsletters/')) {
// keep the newsletter content in tables
2023-09-12 08:49:05 +00:00
options.keepTables = true
options.ignoreLinkDensity = true
}
2023-09-12 08:49:05 +00:00
var destRoot = path.join(testcaseRoot, slug)
2022-02-11 17:24:33 +00:00
fs.mkdir(destRoot, function (err) {
if (err) {
2023-09-12 08:49:05 +00:00
var sourceFile = path.join(destRoot, 'source.html')
2022-02-11 17:24:33 +00:00
fs.exists(sourceFile, function (exists) {
if (exists) {
2023-09-12 08:49:05 +00:00
fs.readFile(
sourceFile,
{ encoding: 'utf-8' },
function (readFileErr, data) {
if (readFileErr) {
console.error("Source existed but couldn't be read?")
process.exit(1)
}
onResponseReceived(null, data, destRoot, options)
2022-02-11 17:24:33 +00:00
}
2023-09-12 08:49:05 +00:00
)
2022-02-11 17:24:33 +00:00
} else {
2023-09-12 08:49:05 +00:00
fs.writeFile(path.join(destRoot, 'url.txt'), argURL, () => null)
2022-02-11 17:24:33 +00:00
fetchSource(argURL, function (fetchErr, data) {
2023-09-12 08:49:05 +00:00
onResponseReceived(fetchErr, data, destRoot, options)
})
2022-02-11 17:24:33 +00:00
}
2023-09-12 08:49:05 +00:00
})
return
2022-02-11 17:24:33 +00:00
}
2023-09-12 08:49:05 +00:00
fs.writeFile(path.join(destRoot, 'url.txt'), argURL, () => null)
2022-02-11 17:24:33 +00:00
fetchSource(argURL, function (fetchErr, data) {
2023-09-12 08:49:05 +00:00
onResponseReceived(fetchErr, data, destRoot, options)
})
})
2022-02-11 17:24:33 +00:00
}
async function fetchSource(url, callbackFn) {
if (!url) {
2023-09-12 08:49:05 +00:00
console.error("You should pass a URL if the source doesn't exist yet!")
process.exit(1)
2022-02-11 17:24:33 +00:00
}
const browser = await puppeteer.launch({
args: [
'--autoplay-policy=user-gesture-required',
'--disable-component-update',
'--disable-domain-reliability',
'--disable-print-preview',
'--disable-setuid-sandbox',
'--disable-speech-api',
'--enable-features=SharedArrayBuffer',
'--hide-scrollbars',
'--mute-audio',
'--no-default-browser-check',
'--no-pings',
'--no-sandbox',
'--no-zygote',
2023-08-02 08:12:24 +00:00
'--disable-extensions',
'--disable-dev-shm-usage',
'--no-first-run',
'--disable-background-networking',
'--disable-gpu',
'--disable-software-rasterizer',
],
defaultViewport: {
deviceScaleFactor: 1,
hasTouch: false,
height: 1080,
isLandscape: true,
isMobile: false,
2023-09-12 08:49:05 +00:00
width: 1920,
},
headless: 'shell',
dumpio: true, // show console logs in the terminal
executablePath: process.env.CHROMIUM_PATH || '/opt/homebrew/bin/chromium',
// filter out targets
targetFilter: (target) =>
target.type() !== 'other' || !!target.url(),
2023-09-12 08:49:05 +00:00
})
2022-02-11 17:24:33 +00:00
2023-09-12 08:49:05 +00:00
const page = await browser.newPage()
2022-06-15 14:31:55 +00:00
if (!enableJavascriptForUrl(url)) {
2023-09-12 08:49:05 +00:00
await page.setJavaScriptEnabled(false)
2022-06-15 14:27:41 +00:00
}
2022-02-11 17:24:33 +00:00
try {
/*
2023-09-12 08:49:05 +00:00
* Disallow MathJax from running in Puppeteer and modifying the document,
* we shall instead run it in our frontend application to transform any
* mathjax content when present.
*/
await page.setRequestInterception(true)
let requestCount = 0
2023-09-12 08:49:05 +00:00
page.on('request', (request) => {
;(async () => {
if (request.resourceType() === 'font') {
// Disallow fonts from loading
return request.abort()
}
if (requestCount++ > 100) {
return request.abort()
}
if (
request.resourceType() === 'script' &&
request.url().toLowerCase().indexOf('mathjax') > -1
) {
return request.abort()
}
await request.continue()
})()
2023-09-12 08:49:05 +00:00
})
2022-02-11 17:24:33 +00:00
await page.goto(url, { waitUntil: ['networkidle0'] })
2022-02-11 17:24:33 +00:00
/* scroll with a 5 second timeout */
await Promise.race([
2023-09-12 08:49:05 +00:00
new Promise((resolve) => {
;(async function () {
2022-02-11 17:24:33 +00:00
try {
await page.evaluate(`(async () => {
/* credit: https://github.com/puppeteer/puppeteer/issues/305 */
return new Promise((resolve, reject) => {
let scrollHeight = document.body.scrollHeight;
let totalHeight = 0;
let distance = 500;
let timer = setInterval(() => {
window.scrollBy(0, distance);
totalHeight += distance;
if(totalHeight >= scrollHeight){
clearInterval(timer);
resolve(true);
}
}, 10);
});
2023-09-12 08:49:05 +00:00
})()`)
2022-02-11 17:24:33 +00:00
} catch (e) {
2023-09-12 08:49:05 +00:00
console.error('error in scrolling url', { e, url })
2022-02-11 17:24:33 +00:00
} finally {
2023-09-12 08:49:05 +00:00
resolve(true)
2022-02-11 17:24:33 +00:00
}
2023-09-12 08:49:05 +00:00
})()
2022-02-11 17:24:33 +00:00
}),
2024-06-01 03:20:12 +00:00
new Promise((resolve) => setTimeout(resolve, 5000)),
2023-09-12 08:49:05 +00:00
])
2022-02-11 17:24:33 +00:00
// get document body with all hidden elements removed
const domContent = await page.evaluate(() => {
2023-09-12 08:49:05 +00:00
const BI_SRC_REGEXP = /url\("(.+?)"\)/gi
2022-02-11 17:24:33 +00:00
2023-09-12 08:49:05 +00:00
Array.from(document.body.getElementsByTagName('*')).forEach((el) => {
const style = window.getComputedStyle(el)
2022-02-11 17:24:33 +00:00
try {
// Removing blurred images since they are mostly the copies of lazy loaded ones
2023-09-12 08:49:05 +00:00
if (
el.tagName &&
['img', 'image'].includes(el.tagName.toLowerCase())
) {
const filter = style.getPropertyValue('filter')
if (filter && filter.startsWith('blur')) {
2023-09-12 08:49:05 +00:00
el.parentNode && el.parentNode.removeChild(el)
}
2022-02-11 17:24:33 +00:00
}
} catch (err) {
// throw Error('error with element: ' + JSON.stringify(Array.from(document.body.getElementsByTagName('*'))))
2022-02-11 17:24:33 +00:00
}
// convert all nodes with background image to img nodes
2023-09-12 08:49:05 +00:00
if (
!['', 'none'].includes(style.getPropertyValue('background-image'))
) {
const filter = style.getPropertyValue('filter')
2022-02-11 17:24:33 +00:00
// avoiding image nodes with a blur effect creation
if (filter && filter.startsWith('blur')) {
2023-09-12 08:49:05 +00:00
el && el.parentNode && el.parentNode.removeChild(el)
2022-02-11 17:24:33 +00:00
} else {
2023-09-12 08:49:05 +00:00
const matchedSRC = BI_SRC_REGEXP.exec(
style.getPropertyValue('background-image')
)
2022-02-11 17:24:33 +00:00
// Using "g" flag with a regex we have to manually break down lastIndex to zero after every usage
// More details here: https://stackoverflow.com/questions/1520800/why-does-a-regexp-with-global-flag-give-wrong-results
2023-09-12 08:49:05 +00:00
BI_SRC_REGEXP.lastIndex = 0
2022-02-11 17:24:33 +00:00
if (matchedSRC && matchedSRC[1] && !el.src) {
// Replacing element only of there are no content inside, b/c might remove important div with content.
// Article example: http://www.josiahzayner.com/2017/01/genetic-designer-part-i.html
// DIV with class "content-inner" has `url("https://resources.blogblog.com/blogblog/data/1kt/travel/bg_container.png")` background image.
if (!el.textContent) {
2023-09-12 08:49:05 +00:00
const img = document.createElement('img')
img.src = matchedSRC[1]
el && el.parentNode && el.parentNode.replaceChild(img, el)
2022-02-11 17:24:33 +00:00
}
}
}
}
2023-09-12 08:49:05 +00:00
})
2024-03-04 04:28:51 +00:00
return document.documentElement.outerHTML
2023-09-12 08:49:05 +00:00
})
2022-02-11 17:24:33 +00:00
2023-09-12 08:49:05 +00:00
sanitizeSource(domContent, callbackFn)
2022-02-11 17:24:33 +00:00
} catch (error) {
console.error('Error occured while fetching content')
console.error(error)
} finally {
2023-09-12 08:49:05 +00:00
await page.close()
await browser.close()
2022-02-11 17:24:33 +00:00
}
}
function sanitizeSource(html, callbackFn) {
2023-09-12 08:49:05 +00:00
htmltidy(
html,
{
indent: true,
'indent-spaces': 4,
'numeric-entities': true,
'output-xhtml': true,
wrap: 0,
},
callbackFn
)
2022-02-11 17:24:33 +00:00
}
function onResponseReceived(error, source, destRoot, options) {
2022-02-11 17:24:33 +00:00
if (error) {
2023-09-12 08:49:05 +00:00
console.error("Couldn't tidy source html!")
console.error(error)
return
2022-02-11 17:24:33 +00:00
}
if (debug) {
2023-09-12 08:49:05 +00:00
console.log('writing')
2022-02-11 17:24:33 +00:00
}
2023-09-12 08:49:05 +00:00
var sourcePath = path.join(destRoot, 'source.html')
fs.writeFile(sourcePath, source, async function (err) {
2022-02-11 17:24:33 +00:00
if (err) {
2023-09-12 08:49:05 +00:00
console.error("Couldn't write data to source.html!")
console.error(err)
return
2022-02-11 17:24:33 +00:00
}
if (debug) {
2023-09-12 08:49:05 +00:00
console.log('Running readability stuff')
2022-02-11 17:24:33 +00:00
}
2023-09-12 08:49:05 +00:00
await runReadability(
source,
path.join(destRoot, 'expected.html'),
path.join(destRoot, 'expected-metadata.json'),
options
)
})
2022-02-11 17:24:33 +00:00
}
async function runReadability(source, destPath, metadataDestPath, options) {
console.log('running readability')
2023-09-12 08:49:05 +00:00
var uri = 'http://fakehost/test/page.html'
var myReader, result, readerable
2022-02-11 17:24:33 +00:00
try {
2022-07-14 02:59:44 +00:00
// Use linkedom for isProbablyReaderable because it supports querySelectorAll
2023-09-12 08:49:05 +00:00
var dom = parseHTML(source).document
readerable = isProbablyReaderable(dom)
2022-02-11 17:24:33 +00:00
// We pass `caption` as a class to check that passing in extra classes works,
// given that it appears in some of the test documents.
2023-09-12 08:49:05 +00:00
myReader = new Readability(dom, {
classesToPreserve: ['caption'],
url: uri,
...options,
})
result = await myReader.parse()
2022-02-11 17:24:33 +00:00
} catch (ex) {
2023-09-12 08:49:05 +00:00
console.error(ex)
ex.stack.forEach(console.log.bind(console))
2022-02-11 17:24:33 +00:00
}
console.log('result', result)
2022-02-11 17:24:33 +00:00
if (!result) {
2023-09-12 08:49:05 +00:00
console.error(
'No content generated by readability, not going to write expected.html!'
)
return
2022-02-11 17:24:33 +00:00
}
2023-09-12 08:49:05 +00:00
fs.writeFile(destPath, prettyPrint(result.content), function (fileWriteErr) {
2022-02-11 17:24:33 +00:00
if (fileWriteErr) {
2023-09-12 08:49:05 +00:00
console.error("Couldn't write data to expected.html!")
console.error(fileWriteErr)
2022-02-11 17:24:33 +00:00
}
// Delete the result data we don't care about checking.
2023-09-12 08:49:05 +00:00
delete result.content
delete result.textContent
delete result.length
2024-03-04 04:28:51 +00:00
delete result.documentElement
2022-02-11 17:24:33 +00:00
// Add isProbablyReaderable result
2023-09-12 08:49:05 +00:00
result.readerable = readerable
fs.writeFile(
metadataDestPath,
JSON.stringify(result, null, 2) + '\n',
function (metadataWriteErr) {
if (metadataWriteErr) {
console.error("Couldn't write data to expected-metadata.json!")
console.error(metadataWriteErr)
}
2022-02-11 17:24:33 +00:00
}
2023-09-12 08:49:05 +00:00
)
})
2022-02-11 17:24:33 +00:00
}
if (process.argv.length < 3) {
2023-09-12 08:49:05 +00:00
console.error(
"Need at least a destination slug and potentially a URL (if the slug doesn't have source)."
)
process.exit(0)
2022-02-11 17:24:33 +00:00
}
2023-09-12 08:49:05 +00:00
if (process.argv[2] === 'all') {
2022-02-11 17:24:33 +00:00
fs.readdir(testcaseRoot, function (err, files) {
if (err) {
2023-09-12 08:49:05 +00:00
console.error('error reading testcases')
return
2022-02-11 17:24:33 +00:00
}
files.forEach(function (file) {
2023-09-12 08:49:05 +00:00
generateTestcase(file)
})
})
2022-02-11 17:24:33 +00:00
} else {
2023-09-12 08:49:05 +00:00
generateTestcase(process.argv[2])
2022-02-11 17:24:33 +00:00
}