UNPKG

read-comfortably

Version:

turns any web page into a clean view for reading

311 lines (303 loc) 14.1 kB
/* * @Author: zyc * @Date: 2015-11-29 17:02:46 * @Last Modified by: zyc * @Last Modified time: 2016-04-25 14:47:44 */ 'use strict'; /** * 日志 * trace 跟踪 * debug 调试 * info 信息 * warn 警告 * error 错误 * fatal 致命 */ const log4js = require('log4js'); // Port of Log4js to work with node const logger = log4js.getLogger('grabArticle'); logger.setLevel('FATAL'); // logger.setLevel('DEBUG'); const regexps = require('./regexps'); const helpers = require('./helpers'); /** * grabArticle - Using a constiety of metrics (content score, classname, element types), * find the content that is most likely to be the stuff a user wants to read. * Then return it wrapped up in a div. * @param $ * @param url * @param options * @param preserveUnlikelyCandidates * @return string (Article Content) */ const grabArticle = ($, url, options, preserveUnlikelyCandidates) => { /** * preprocess which should be a function to check or modify downloaded source before passing it to readability. * options.preprocess = callback($, options); */ const preprocess = options.preprocess; if (typeof preprocess == 'function') preprocess($, options); helpers.setImageSrc($, options); // Set the src attribute of the images or other tags for use helpers.fixLinks($, url, options); // Converts relative urls to absolute for images and links prepping($, options, preserveUnlikelyCandidates); // First, node prepping const candidates = assignScore($, options); // assign a score to them based on how content-y they look const topCandidate = findHighestScore(candidates, $); // find the top candidate with the highest score const articleContent = getArticleContent(topCandidate, $, options); // Append the nodes to articleContent if (!options.afterToRemove) options.afterToRemove = ['script', 'noscript']; // default afterToRemove articleContent.find(options.afterToRemove.join()).remove(); /** * postprocess which should be a function to check or modify article content after passing it to readability. * options.postprocess = callback(node, $); */ const postprocess = options.postprocess; if (typeof postprocess == 'function') postprocess(articleContent, $); return articleContent; }; /** * First, node prepping. Trash nodes that look cruddy (like ones with the class name 'comment', etc), and turn divs * into P tags where they have been used inappropriately (as in, where they contain no other block level elements.) * Note: Assignment from index for performance. See http://www.peachpit.com/articles/article.aspx?p=31567&seqNum=5 * TODO: Shouldn't this be a reverse traversal? * @param $ * @param options * @param preserveUnlikelyCandidates * @return void */ const prepping = ($, options, preserveUnlikelyCandidates) => { /* Removing unnecessary nodes */ if (options.nodesToRemove) $(options.nodesToRemove.join()).remove(); if (!options.noChdToRemove) options.noChdToRemove = ['div']; // default noChdToRemove $('*', 'body').each((index, element) => { const node = $(element); /* If node is null, return, otherwise Illegal Access Error */ if (!node || !node.length) return; const nodeType = element.name; logger.trace('%d[%s]:', index, nodeType, node.html()); /* Remove unlikely candidates */ if (!preserveUnlikelyCandidates) { const unlikelyMatchString = (node.attr('class') || '') + '|' + (node.attr('id') || ''); logger.trace('unlikelyMatchString:', unlikelyMatchString); if (unlikelyMatchString) { const unlikelyCandidatesReIndex = unlikelyMatchString.search(regexps.unlikelyCandidatesRe); logger.trace('%s[unlikelyCandidatesReIndex=%d]', unlikelyMatchString, unlikelyCandidatesReIndex); let unlikeThisNode = unlikelyCandidatesReIndex != -1; if (!unlikeThisNode) { const extraneousReIndex = unlikelyMatchString.search(regexps.extraneousRe); logger.trace('%s[extraneousReIndex=%d]', unlikelyMatchString, extraneousReIndex); unlikeThisNode = extraneousReIndex != -1; } if (unlikeThisNode) { const classAndIDs = node.find('[class],[id]'); let removeThisNode = true; for (let i = -1; i < classAndIDs.length; i++) { const classAndID = i > -1 ? $(classAndIDs.get(i)) : node; const okMaybeItsAMatchString = (classAndID.attr('class') || '') + '|' + (classAndID.attr('id') || ''); const okMaybeItsACandidateReIndex = okMaybeItsAMatchString.search(regexps.okMaybeItsACandidateRe); logger.trace('%s[okMaybeItsACandidateReIndex=%d]', okMaybeItsAMatchString, okMaybeItsACandidateReIndex); if (okMaybeItsACandidateReIndex != -1) { removeThisNode = false; break; } } if (removeThisNode) { logger.debug('Removing unlikely candidate -', unlikelyMatchString); return node.remove(); } } } } /* Remove Elements that have no children and have no content */ if (options.noChdToRemove.indexOf(nodeType) != -1 && !node.children().length && !node.text().trim()) { logger.debug('Removing Element - %s (%s|%s)', nodeType, node.attr('class'), node.attr('id')); return node.remove(); } /* Remove Style */ node.removeAttr('style'); /* Turn all divs that don't have children block level elements into p's */ if (options.considerDIVs && nodeType == 'div') { if (node.html().search(regexps.divToPElementsRe) == -1) { try { logger.debug('Altering div to p:', node.html()); node.replaceWith('<p class="readability-div2p">' + node.html() + '</p>'); } catch (e) { logger.error('Could not alter div to p, reverting back to div.', e); } } else { /* EXPERIMENTAL */ node.contents().each((index, element) => { const child = $(element); if (!child || !child.length) return; if (element.type == 'text' && element.data && element.data.trim()) { /* use span instead of p. Need more tests. */ logger.debug('replacing text node with a span tag with the same content.', element.data); child.replaceWith('<span class="readability-txt2span">' + element.data + '</span>'); } }); } } }); }; /** * Loop through all paragraphs, and assign a score to them based on how content-y they look. * Then add their score to their parent node. * A score is determined by things like number of commas, class names, etc. Maybe eventually link density. * @param $ * @param options * @return candidates */ const assignScore = ($, options) => { const candidates = []; if (!options.nodesToScore) options.nodesToScore = ['p', 'article']; // default nodesToScore $(options.nodesToScore.join()).each((index, element) => { const paragraph = $(element); const innerText = helpers.getInnerText(paragraph); /* If this paragraph is less than 25 characters, don't even count it. */ if (innerText.length < 25) return; /* Add a point for the paragraph itself as a base. */ let contentScore = 1; /* Add points for any commas within this paragraph */ /* support Chinese commas. */ const commas = innerText.match(/[,,.。;;??、]/g); if (commas && commas.length) contentScore += commas.length; /* For every 100 characters in this paragraph, add another point. Up to 3 points. */ contentScore += Math.min(Math.floor(innerText.length / 100), 3); for (let parentNode = paragraph.parent(); parentNode && parentNode.length; parentNode = parentNode.parent()) { /* Initialize readability data for the parent. */ if (!parentNode.data('readabilityScore')) { helpers.initializeNode(parentNode); candidates.push(parentNode); } /* Add the score to the parent. The grandparent gets half. */ parentNode.data('readabilityScore', parentNode.data('readabilityScore') + contentScore); contentScore /= 2; } }); return candidates; }; /** * After we've calculated scores, loop through all of the possible candidate nodes we found and find the one with the highest score. * @param candidates * @param $ * @return topCandidate */ const findHighestScore = (candidates, $) => { let topCandidate; for (let candidate of candidates) { const score = candidate.data('readabilityScore'); const linkDensity = helpers.getLinkDensity(candidate, $); let siblings = 0; candidate.children('p').each((index, element) => { if ($(element).text().trim().length) siblings++; }); let imgs = candidate.find('img').length; imgs = Math.min(2.6, Math.max(imgs, 1)); /** * Scale the final candidates score based on link density. * Good content should have a relatively small link density (5% or less) and be mostly unaffected by this operation. */ candidate.data('readabilityScore', Math.min(2, Math.max(siblings, 1)) * score * (1 - linkDensity) * imgs); logger.debug('Candidate with score %d (%s|%s):', candidate.data('readabilityScore'), candidate.attr('class'), candidate.attr('id'), candidate); if (!topCandidate || candidate.data('readabilityScore') > topCandidate.data('readabilityScore')) topCandidate = candidate; } /** * If we still have no top candidate, just use the body as a last resort. * We also have to copy the body node so it is something we can modify. * Should not happen. */ if (!topCandidate) { /* With no top candidate, bail out if no body tag exists as last resort. */ topCandidate = $('body'); topCandidate.replaceWith('<div class="readability-body2div">' + topCandidate.html() + '</div>'); } return topCandidate; }; /** * Now that we have the top candidate, look through its siblings for content that might also be related. * Things like preambles, content split by ads that we removed, etc. * @param topCandidate * @param $ * @param options * @return articleContent */ const getArticleContent = (topCandidate, $, options) => { logger.trace('Top candidate with score %d (%s|%s): %s', topCandidate.data('readabilityScore'), topCandidate.attr('class'), topCandidate.attr('id'), topCandidate); /* Perhaps the topCandidate haven't parent? */ const parentNode = topCandidate.parent(); logger.trace('parentNode:', parentNode); if (!parentNode || !parentNode.length) return topCandidate; const parentNodeClass = parentNode.attr('class'); const topCandidateClass = topCandidate.attr('class'); logger.trace('nodeClass:', parentNodeClass, topCandidateClass); if (topCandidateClass && parentNodeClass) { const topCandidateClasses = topCandidateClass.split(/[ -]/); const parentNodeClasses = parentNodeClass.split(/[ -]/); for (let parentClass of parentNodeClasses) { for (let topClass of topCandidateClasses) { if (topClass && parentClass && !topClass.search(parentClass)) { return getArticleContent(parentNode, $, options); } } } } const siblingNodes = parentNode.children(); logger.trace('siblingNodes:', siblingNodes); let notHeaderCount = 0; siblingNodes.each((index, element) => { if (element.name != 'header') notHeaderCount++; }); if (notHeaderCount == 1 && parentNode.get(0).name != 'body') return getArticleContent(parentNode, $, options); if (!options.nodesToAppend) options.nodesToAppend = ['p']; // default nodesToAppend const articleContent = $('<div id="readability-content"></div>'); siblingNodes.each((index, element) => { const siblingNode = $(element); /** * Fix for odd IE7 Crash where siblingNode does not exist even though this should be a live nodeList. * Example of error visible here: http://www.esquire.com/features/honesty0707 */ if (!siblingNode || !siblingNode.length) return; logger.debug('Looking at sibling node with score %d (%s|%s): %s', siblingNode.data('readabilityScore'), siblingNode.attr('class'), siblingNode.attr('id'), siblingNode); /* siblingNode is topCandidate */ let append = siblingNode.is(topCandidate); /* siblingNode is header */ append = element.name == 'header'; /* siblingNode is img or have an img */ if (!append) { append = element.name == 'img'; if (!append) { siblingNode.find('noscript').remove(); append = siblingNode.find('img').length == 1; } } /* siblingNode may be its a candidate */ if (!append) { const okMaybeItsAMatchString = (siblingNode.attr('class') || '') + '|' + (siblingNode.attr('id') || ''); append = okMaybeItsAMatchString.search(regexps.okMaybeItsACandidateRe) != -1; } /* siblingNode's readabilityScore + contentBonus > siblingScoreThreshold */ if (!append) { const siblingScore = siblingNode.data('readabilityScore'); if (siblingScore) { let contentBonus = 0; const topNodeScore = topCandidate.data('readabilityScore'); /* Give a bonus if sibling nodes and top candidates have the example same classname */ if (topCandidateClass && topCandidateClass == siblingNode.attr('class')) contentBonus += topNodeScore * 0.2; const siblingScoreThreshold = Math.max(10, topNodeScore * 0.2); append = siblingScore + contentBonus >= siblingScoreThreshold; } } /* siblingNode's linkDensity < 0.25 */ if (!append) { if (options.nodesToAppend.indexOf(element.name) != -1) { siblingNode.find('a').each((index, element) => { if (!$(element).text().trim()) $(element).remove(); }); append = helpers.getLinkDensity(siblingNode, $) < 0.25; } } /* append siblingNode to articleContent */ if (append) { logger.debug('Appending node: ', siblingNode); /* Append sibling and subtract from our list because it removes the node when you append to another node */ articleContent.append(siblingNode); } }); return articleContent; }; module.exports = grabArticle;