Files
SolidScribe/server/helpers/SiteScrape.js
2026-11-03 10:23:03 -07:00

447 lines
13 KiB
JavaScript

//
// Pure parsing of an already-fetched page. No network I/O, no logging.
//
// $ = the cheerio scrape object
//
// Everything here is a pure function of ($, url), which is what makes it
// testable against saved HTML fixtures instead of the live internet.
//
const config = require('@config/scrape')
let SiteScrape = module.exports = {}
const removeWhitespace = /\s+/g
const commonWords = ['just','start','what','these','how', 'was', 'being','can','way','share','facebook','twitter','reddit','be','have','do','say','get','make','go','know','take','see','come','think','look','want',
'give','use','find','tell','ask','work','seem','feel','try','leave','call','good','new','first','last','long','great','little','own','other','old',
'right','big','high','different','small','large','next','early','young','important','few','public','bad','same','able','to','of','in','for','on',
'with','at','by','from','up','about','into','over','after','the','and','a','that','I','it','not','he','as','you','this','but','his','they','her',
'she','or','an','will','my','one','all','would','there','their','and','that','but','or','as','if','when','than','because','while','where','after',
'so','though','since','until','whether','before','although','nor','like','once','unless','now','except','are','also','is','your','its']
const clean = (value, maxLength) => {
if (value == null) { return '' }
const text = String(value).replace(removeWhitespace, ' ').trim()
return maxLength ? text.substring(0, maxLength) : text
}
// First non-empty wins. Each candidate is a thunk so a missing branch costs
// nothing and nothing throws on the way to the answer.
const firstOf = (candidates) => {
for (const candidate of candidates) {
let value = ''
try { value = clean(typeof candidate === 'function' ? candidate() : candidate) }
catch (error) { value = '' }
if (value) { return value }
}
return ''
}
const metaContent = ($, selector) => {
const node = $(selector).first()
return node.length ? node.attr('content') : ''
}
//
// JSON-LD. Malformed JSON-LD is extremely common, so every parse is guarded
// and a bad block is skipped rather than failing the whole page.
//
const LD_TYPES = ['Article', 'NewsArticle', 'BlogPosting', 'WebPage', 'Product', 'VideoObject', 'Recipe']
SiteScrape.getJsonLd = ($) => {
const blocks = []
$('script[type="application/ld+json"]').each((index, element) => {
const raw = $(element).contents().text()
if (!raw || !raw.trim()) { return }
let parsed = null
try { parsed = JSON.parse(raw) } catch (error) { return }
// A block may be an object, an array, or wrap its payload in @graph.
const flatten = (node) => {
if (!node || typeof node !== 'object') { return }
if (Array.isArray(node)) { return node.forEach(flatten) }
if (Array.isArray(node['@graph'])) { node['@graph'].forEach(flatten) }
blocks.push(node)
}
flatten(parsed)
})
if (!blocks.length) { return null }
// Prefer a block whose @type looks like page content.
const typed = blocks.find(block => {
const type = block['@type']
const types = Array.isArray(type) ? type : [type]
return types.some(entry => LD_TYPES.includes(entry))
})
return typed || blocks[0]
}
const jsonLdImage = (block) => {
if (!block || !block.image) { return '' }
const image = Array.isArray(block.image) ? block.image[0] : block.image
if (!image) { return '' }
if (typeof image === 'string') { return image }
if (typeof image === 'object') { return image.url || image.contentUrl || '' }
return ''
}
//
// Page title.
//
SiteScrape.getTitle = ($) => {
const ld = SiteScrape.getJsonLd($)
return firstOf([
() => metaContent($, 'meta[property="og:title"]'),
() => metaContent($, 'meta[name="twitter:title"]'),
() => ld && (ld.headline || ld.name),
() => $('title').first().text(),
() => $('h1').first().text(),
]).substring(0, 500)
}
//
// Page description. The old parser extracted none at all.
//
SiteScrape.getDescription = ($) => {
const ld = SiteScrape.getJsonLd($)
return firstOf([
() => metaContent($, 'meta[property="og:description"]'),
() => metaContent($, 'meta[name="twitter:description"]'),
() => metaContent($, 'meta[name="description"]'),
() => ld && ld.description,
]).substring(0, 1000)
}
SiteScrape.getSiteName = ($, url) => {
return firstOf([
() => metaContent($, 'meta[property="og:site_name"]'),
() => metaContent($, 'meta[name="application-name"]'),
() => {
try { return new URL(url).hostname.replace(/^www\./, '') }
catch (error) { return '' }
},
]).substring(0, 255)
}
//
// Canonical URL. Used as the base for resolving relative asset paths, which is
// what the old manual hostname-concatenation got wrong.
//
SiteScrape.getCanonicalUrl = ($, base) => {
const candidate = firstOf([
() => $('link[rel="canonical"]').first().attr('href'),
() => metaContent($, 'meta[property="og:url"]'),
])
if (!candidate) { return base || '' }
try { return new URL(candidate, base || undefined).href }
catch (error) { return base || '' }
}
//Site hostname with https:// eg: https://www.google.com
SiteScrape.getHostName = (url) => {
try {
return 'https://' + (new URL(url)).hostname
} catch (error) {
return ''
}
}
//
// Image selection.
//
// Extension allowlist plus an allow-if-extensionless rule. The old substring
// filter required .jpg/.jpeg/.png/.gif anywhere in the string, which silently
// dropped .webp, .avif, .ico and - more importantly - every extensionless
// og:image endpoint like /og?title=x, which is what most modern sites serve.
//
const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif', '.ico', '.bmp', '.jfif']
const TRACKING_PIXEL = /(^|[\/_\-.])(1x1|pixel|spacer|blank|transparent|dot)\.(gif|png)/i
const acceptableImagePath = (href) => {
let pathname = ''
try { pathname = new URL(href).pathname.toLowerCase() }
catch (error) { return false }
const lastSegment = pathname.split('/').pop() || ''
const dot = lastSegment.lastIndexOf('.')
// No extension at all - allow it. An og:image endpoint is the common case.
if (dot === -1) { return true }
const extension = lastSegment.slice(dot)
if (extension === '.svg') { return !!config.allowSvgThumbnails }
return IMAGE_EXTENSIONS.includes(extension)
}
// Resolve against the page's canonical base. Handles protocol-relative
// (//host/x), root-relative, and path-relative uniformly - no string surgery.
const resolveImage = (src, base) => {
if (!src) { return '' }
const value = String(src).trim()
if (!value || value.startsWith('data:')) { return '' }
try { return new URL(value, base).href }
catch (error) { return '' }
}
// Largest candidate in a srcset, by width descriptor.
const largestFromSrcset = (srcset) => {
if (!srcset) { return '' }
let best = ''
let bestWidth = -1
for (const entry of String(srcset).split(',')) {
const parts = entry.trim().split(/\s+/)
if (!parts[0]) { continue }
const descriptor = parts[1] || ''
const width = descriptor.endsWith('w') ? parseInt(descriptor, 10) : 0
if (width > bestWidth) {
bestWidth = width
best = parts[0]
}
}
return best
}
const iconCandidates = ($) => {
const icons = []
// apple-touch-icon is usually the largest and cleanest of the icons.
$('link[rel="apple-touch-icon"], link[rel="apple-touch-icon-precomposed"]').each((index, element) => {
const sizes = parseInt($(element).attr('sizes') || '0', 10) || 0
icons.push({ href: $(element).attr('href'), weight: 1000 + sizes })
})
$('link[rel="icon"], link[rel="shortcut icon"]').each((index, element) => {
const sizes = parseInt($(element).attr('sizes') || '0', 10) || 0
icons.push({ href: $(element).attr('href'), weight: sizes })
})
return icons
.sort((a, b) => b.weight - a.weight)
.map(icon => icon.href)
}
const inlineImageCandidates = ($) => {
const images = []
$('img').each((index, element) => {
const node = $(element)
// Guarded: the old code read element.attribs.src directly and threw on
// an <img> with no attributes.
const src = node.attr('src') || node.attr('data-src') || largestFromSrcset(node.attr('srcset'))
if (!src) { return }
const width = parseInt(node.attr('width') || '0', 10) || 0
const height = parseInt(node.attr('height') || '0', 10) || 0
// Tracking pixels and spacers.
if ((width > 0 && width <= 1) || (height > 0 && height <= 1)) { return }
if (TRACKING_PIXEL.test(src)) { return }
images.push({ src, area: width * height, order: index })
})
// Biggest first; ties keep document order, so the top of the page wins.
return images
.sort((a, b) => (b.area - a.area) || (a.order - b.order))
.map(image => image.src)
}
//
// Ordered candidate list, so "which image" is an explicit priority rather than
// the old reverse-iterate-and-overwrite loop that only worked by accident.
//
SiteScrape.getImageCandidates = ($, url) => {
const base = SiteScrape.getCanonicalUrl($, url) || url
const ld = SiteScrape.getJsonLd($)
const raw = [
metaContent($, 'meta[property="og:image:secure_url"]'),
metaContent($, 'meta[property="og:image:url"]'),
metaContent($, 'meta[property="og:image"]'),
metaContent($, 'meta[name="twitter:image"]'),
metaContent($, 'meta[name="twitter:image:src"]'),
jsonLdImage(ld),
...inlineImageCandidates($),
...iconCandidates($),
]
const seen = new Set()
const candidates = []
for (const entry of raw) {
const resolved = resolveImage(entry, base)
if (!resolved || seen.has(resolved)) { continue }
if (!acceptableImagePath(resolved)) { continue }
seen.add(resolved)
candidates.push(resolved)
}
return candidates
}
// URL for image that can be downloaded to represent website
SiteScrape.getDisplayImage = ($, url) => {
const candidates = SiteScrape.getImageCandidates($, url)
return candidates.length ? candidates[0] : ''
}
// Get all the site text and parse out the words that appear most
SiteScrape.getKeywords = ($) => {
// Strip chrome and scripts before counting, otherwise navigation labels and
// inline JS identifiers dominate the frequency table.
const working = $.root().clone()
working.find('script, style, noscript, nav, header, footer, aside, form, svg').remove()
let source = working.find('article, main, [role=main], [class*=content]').text()
if (!source || source.trim().length < 200) {
source = working.find('body').text() || working.text()
}
const majorContent = String(source)
.replace(removeWhitespace, ' ')
.substring(0, config.maxKeywordSourceChars)
.toLowerCase()
.replace(/[^A-Za-z0-9- ]/g, '')
//Count frequency of each word in scraped text
let frequency = {}
majorContent.split(' ').forEach(word => {
// Exclude short or common words
if(commonWords.includes(word) || word.length <= 2){
return
}
if(!frequency[word]){
frequency[word] = 0
}
// Skip some plurals
if(frequency[word+'s'] || frequency[word+'es']){
return
}
frequency[word]++
})
//Create a sortable array
var sortable = [];
for (var index in frequency) {
if(frequency[index] > 1){
sortable.push([index, frequency[index]]);
}
}
//Sort them by most used words in the list
sortable.sort(function(a, b) {
return b[1] - a[1];
});
let finalWords = []
for(let i=0; i<6; i++){
if(sortable[i] && sortable[i][0]){
finalWords.push(sortable[i][0])
}
}
if(finalWords.length > 0){
return 'Keywords: ' + finalWords.join(', ')
}
return ''
}
//
// The plaintext blob stored in attachment.text and indexed by the MySQL
// FULLTEXT index. Must stay unencrypted for MATCH ... AGAINST to work.
//
SiteScrape.buildSearchText = ({ title, description, siteName, keywords }) => {
const parts = []
if (title) { parts.push(title) }
if (description) { parts.push(description) }
if (siteName) { parts.push(siteName) }
if (keywords) { parts.push(keywords) }
return parts.join('\n').substring(0, config.maxSearchTextBytes)
}
//
// One call, everything a link preview needs.
//
SiteScrape.parse = ($, url) => {
const canonicalUrl = SiteScrape.getCanonicalUrl($, url)
const title = SiteScrape.getTitle($)
const description = SiteScrape.getDescription($)
const siteName = SiteScrape.getSiteName($, url)
const keywords = SiteScrape.getKeywords($)
const imageCandidates = SiteScrape.getImageCandidates($, url)
return {
title,
description,
siteName,
canonicalUrl,
keywords,
imageUrl: imageCandidates.length ? imageCandidates[0] : '',
imageCandidates,
hostname: SiteScrape.getHostName(url),
searchText: SiteScrape.buildSearchText({ title, description, siteName, keywords }),
}
}
//
// Finds all urls in text, removes duplicates, makes sure they have https://
//
// DELIBERATELY UNCHANGED. This decides which URLs are detected in note text and
// what string lands in attachment.url, so altering it would change link
// behaviour for every existing note - explicitly out of scope for the scraper
// rewrite. The @TODO below is left in place for the same reason.
//
SiteScrape.getCleanUrls = (textBlock) => {
//Find all URLs in text
//@TODO - Use the process text library for this function
const urlPattern = /(?:(?:https?|ftp|file):\/\/|www\.|ftp\.)(?:\([-A-Z0-9+&@#/%=~_|$?!:,.]*\)|[-A-Z0-9+&@#/%=~_|$?!:,.])*(?:\([-A-Z0-9+&@#/%=~_|$?!:,.]*\)|[A-Z0-9+&@#/%=~_|$])/igm
let allUrls = textBlock.match(urlPattern)
if(allUrls == null){
return []
}
//Every URL needs HTTPS!!!
let foundUrls = []
allUrls.forEach( (item, index) => {
//add protocol if it is missing
if(item.indexOf('https://') == -1 && item.indexOf('http://') == -1){
allUrls[index] = 'https://'+item
}
//convert http to https
if(item.indexOf('http://') >= 0){
allUrls[index] = item.replace('http://','https://')
}
})
//Remove all duplicates
foundUrls = [...new Set(allUrls)]
return foundUrls
}