Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,43 @@ export const ABORT_TYPES: string[]
*/
export const PDF_SIZE_TRESHOLD: number

export interface PdfMeta {
title?: string | null
author?: string | null
authors?: string | null
description?: string | null
publisher?: string | null
date?: string | null
lang?: string | null
image?: string | null
logo?: string | null
}

/** Read title, author, date, and the rest from PDF bytes. */
export function extractPdf(input: {
url: string
pdf: ArrayBuffer | ArrayBufferView
maxPages?: number
}): Promise<PdfMeta>

/** `extractPdf` that returns undefined on a non-PDF or a parse error. */
export function extractPdfSafe(
input: ArrayBuffer | ArrayBufferView,
url: string,
opts?: { maxPages?: number }
): Promise<PdfMeta | undefined>

/** `true` when the bytes start with `%PDF` (junk before the header is allowed). */
export function isPdf(input: ArrayBuffer | ArrayBufferView): boolean

/** `true` when `url` looks like a PDF link. */
export function isPdfLink(url?: string): boolean

/** Empty HTML document with PDF metadata stamped as meta tags. */
export function pdfToHtml(
input: ArrayBuffer | ArrayBufferView,
url: string,
opts?: { maxPages?: number }
): Promise<string>

export default htmlGet
18 changes: 16 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@
"p-cancelable": "~2.1.0",
"p-retry": "~4.6.0",
"tinyspawn": "~1.5.7",
"top-sites": "~1.1.224"
"top-sites": "~1.1.224",
"unpdf": "~1.8.1"
},
"devDependencies": {
"@browserless/test": "latest",
Expand All @@ -77,6 +78,17 @@
"finepack": "latest",
"git-authors-cli": "latest",
"github-generate-release": "latest",
"metascraper": "~5.56.2",
"metascraper-author": "~5.56.2",
"metascraper-date": "~5.56.2",
"metascraper-description": "~5.56.2",
"metascraper-image": "~5.56.2",
"metascraper-lang": "~5.56.2",
"metascraper-logo": "~5.56.2",
"metascraper-manifest": "~5.56.2",
"metascraper-publisher": "~5.56.2",
"metascraper-title": "~5.56.2",
"metascraper-url": "~5.56.2",
"nano-staged": "latest",
"pretty": "latest",
"puppeteer": "latest",
Expand Down Expand Up @@ -110,7 +122,9 @@
"ava": {
"files": [
"test/**/*.js",
"!test/helpers.js"
"!test/helpers.js",
"!test/pdf/helpers.js",
"!test/pdf/scrape.js"
],
"timeout": "2m",
"workerThreads": false
Expand Down
36 changes: 31 additions & 5 deletions src/html.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,27 @@ const has = el => el.length !== 0

const upsert = (el, collection, item) => !has(el) && collection.push(item)

const addHead = ({ $, url, headers }) => {
const escapeAttr = value =>
String(value).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')

const metaTag = (key, value, attr = 'name') =>
`<meta ${attr}="${key}" content="${escapeAttr(value)}">`

const addHead = ({ $, url, headers, pdfMeta = {} }) => {
const tags = []
const charset = getCharset(headers)
const { domain } = parseUrl(url)
const head = $('head')
const title = pdfMeta.title || path.basename(url)
const siteName = pdfMeta.publisher || domain

upsert(head.find('title'), tags, `<title>${path.basename(url)}</title>`)
upsert(head.find('title'), tags, `<title>${escapeAttr(title)}</title>`)

if (domain) {
if (siteName) {
upsert(
head.find('meta[property="og:site_name"]'),
tags,
`<meta property="og:site_name" content="${domain}">`
metaTag('og:site_name', siteName, 'property')
)
}

Expand All @@ -41,6 +49,23 @@ const addHead = ({ $, url, headers }) => {
upsert(head.find('meta[charset]'), tags, `<meta charset="${charset}">`)
}

if (pdfMeta.title) tags.push(metaTag('og:title', pdfMeta.title, 'property'))
if (pdfMeta.author) tags.push(metaTag('author', pdfMeta.author))
if (pdfMeta.description) {
tags.push(metaTag('description', pdfMeta.description))
tags.push(metaTag('og:description', pdfMeta.description, 'property'))
}
if (pdfMeta.date) {
tags.push(metaTag('date', pdfMeta.date))
tags.push(metaTag('article:published_time', pdfMeta.date, 'property'))
}
if (pdfMeta.image) tags.push(metaTag('og:image', pdfMeta.image, 'property'))
if (pdfMeta.logo) tags.push(metaTag('og:logo', pdfMeta.logo, 'property'))
if (pdfMeta.lang) {
tags.push(metaTag('og:locale', pdfMeta.lang, 'property'))
$('html').attr('lang', pdfMeta.lang)
}

tags.forEach(tag => head.append(tag))
}

Expand Down Expand Up @@ -157,6 +182,7 @@ module.exports = ({
html,
url,
headers = {},
pdfMeta,
styles,
hide,
remove,
Expand All @@ -173,7 +199,7 @@ module.exports = ({

if (rewriteHtml) rewriteMetaTags({ $, url })

addHead({ $, url, headers })
addHead({ $, url, headers, pdfMeta })

if (styles) injectStyle({ $, styles })

Expand Down
26 changes: 23 additions & 3 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const os = require('os')

const { getContentLength, getContentType } = require('./util')
const { getOfficeFormat, isOfficeUrl } = require('./office')
const { extractSafe } = require('./pdf')
const autoDomains = require('./auto-domains')
const addHtml = require('./html')

Expand Down Expand Up @@ -52,14 +53,17 @@ const fetch = PCancelable.fn(
try {
const res = await req

let pdfMeta
const html = await (async () => {
const contentType = getContentType(res.headers)

const officeFormat = pandoc && getOfficeFormat({ contentType, url: [res.url, url] })

// a recognized office file never goes through mutool, even if the
// response is mislabeled as application/pdf
if (mutool && !officeFormat && contentType === 'application/pdf') {
if (!officeFormat && contentType === 'application/pdf') {
pdfMeta = await extractSafe(res.body, res.url)
if (!mutool) return ''
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const file = getTemporalFile(url, 'pdf')
await writeFile(file.path, res.body)
try {
Expand Down Expand Up @@ -106,6 +110,7 @@ const fetch = PCancelable.fn(
return {
headers: res.headers,
html,
pdfMeta,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
mode: 'fetch',
url: res.url,
statusCode: res.statusCode,
Expand Down Expand Up @@ -350,14 +355,16 @@ const getContent = PCancelable.fn(
onCancel(() => promise.cancel())

return promise.then(content => {
const { pdfMeta, ...rest } = content
const $ = addHtml({
...content,
...rest,
pdfMeta,
...(isFetchMode ? puppeteerOpts : undefined),
rewriteUrls,
rewriteHtml
})

return { ...content, $ }
return { ...rest, $ }
})
}
)
Expand Down Expand Up @@ -461,3 +468,16 @@ module.exports.defaultPandoc = defaultPandoc
// the binary is not installed. Lazy: nothing runs until first call.
module.exports.getPandocPath = () => whichSync('pandoc')
module.exports.getMutoolPath = () => whichSync('mutool')
module.exports.extractPdf = require('./pdf').extract
module.exports.extractPdfSafe = extractSafe
module.exports.isPdf = require('./pdf').isPdf
module.exports.isPdfLink = require('./pdf').isPdfLink
module.exports.pdfToHtml = async (input, url, opts) => {
const pdfMeta = await extractSafe(input, url, opts)
return addHtml({
html: '',
url,
headers: { 'content-type': 'application/pdf' },
pdfMeta
}).html()
}
142 changes: 142 additions & 0 deletions src/pdf/author.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
'use strict'

const {
ORGANIZATION_WORDS,
PLACE_NAME,
flatten,
isBannerLine,
isInvertedName,
isPersonName,
splitNamePairs,
splitNames,
stripNoise,
tidy
} = require('./text')

const EDITOR_PREFIX =
/^(edited|reviewed|approved|submitted|received|accepted|published)\s+by\s*:?|^(editors?|reviewing editors?|action editors?)\s*:/i
const SECTION_WORDS =
/^(abstract|summary|introduction|contents|table of contents|keywords|index|preface|foreword|version|draft)\b/i

const AUTHOR_BLOCK_MARGIN = 4
const EDITOR_BLOCK_LINES = 4
const MAX_ORGANIZATION_WORDS = 4
const MAX_AUTHORS = 10
const NEIGHBOUR_OFFSETS = [1, 2, 3, 4]

const isCapitalizedWord = word => /^[\p{Lu}]/u.test(word)

const isOrganizationAuthor = text => {
if (/\S+@\S+/.test(text) || /\d/.test(text) || /^https?:/i.test(text)) {
return false
}
if (SECTION_WORDS.test(text) || PLACE_NAME.test(text)) return false
const words = text.split(/\s+/)
return words.length <= MAX_ORGANIZATION_WORDS && words.every(isCapitalizedWord)
}

const editorBlock = lines => {
const excluded = new Set()

for (const line of lines) {
if (!EDITOR_PREFIX.test(line.text)) continue
for (let offset = 0; offset <= EDITOR_BLOCK_LINES; offset++) {
excluded.add(line.index + offset)
}
}

return excluded
}

const toAuthor = (lines, indexes, options = {}) => {
const { organizationLimit = Infinity, allowOrganization = false } = options
const excluded = editorBlock(lines)
const usable = index => !excluded.has(index)

const names = indexes
.filter(usable)
.map(index => lines[index])
.filter(Boolean)
.map(line => line.text)
.filter(
text => !EDITOR_PREFIX.test(text) && !ORGANIZATION_WORDS.test(text) && !isBannerLine(text)
)
.flatMap(text => stripNoise(text).split(/;\s*/).flatMap(splitNames))
.map(tidy)
.filter(isPersonName)

const unique = [...new Set(names.map(flatten))].slice(0, MAX_AUTHORS)
if (unique.length > 1) return unique.join(', ')

const paired = indexes
.filter(usable)
.map(index => lines[index])
.filter(line => line && !ORGANIZATION_WORDS.test(line.text))
.flatMap(line => splitNamePairs(stripNoise(line.text)))
.filter(isPersonName)
if (paired.length > unique.length) {
return [...new Set(paired)].slice(0, MAX_AUTHORS).join(', ')
}
if (unique.length > 0) return unique.join(', ')

if (!allowOrganization) return null

const organization = indexes
.filter(index => index <= organizationLimit && usable(index))
.map(index => lines[index])
.filter(Boolean)
.map(line => flatten(stripNoise(line.text)))
.find(isOrganizationAuthor)

return organization || null
}

/**
* Bylines share a font size. Once one name is found, every line set in the same
* size around it belongs to the same block, which is what recovers the authors
* hidden between affiliation and email lines.
*/
const expandAuthorLines = (lines, indexes, { titleIndexes = [] } = {}) => {
const excludedTitle = new Set(titleIndexes)
const usable = indexes.filter(index => !excludedTitle.has(index))
const named = usable
.map(index => lines[index])
.filter(line => line && isPersonName(stripNoise(line.text)))

if (named.length === 0) return usable

const sizes = new Set(named.map(line => line.size))
const first = Math.min(...named.map(line => line.index)) - AUTHOR_BLOCK_MARGIN
const last = Math.max(...named.map(line => line.index)) + AUTHOR_BLOCK_MARGIN

return lines
.filter(line => line.index >= first && line.index <= last && !excludedTitle.has(line.index))
.filter(line => sizes.has(line.size) && isPersonName(stripNoise(line.text)))
.map(line => line.index)
}

const nameCount = value => {
if (!value) return 0
return value.split(/\s*;\s*/).reduce((count, part) => {
const trimmed = part.trim()
if (!trimmed) return count
if (isInvertedName(trimmed)) return count + 1
return count + trimmed.split(/,|\s+and\s+/i).filter(Boolean).length
}, 0)
}

const getAuthor = (lines, { titleIndexes = [] } = {}) => {
const titleIndex = titleIndexes.length > 0 ? titleIndexes[titleIndexes.length - 1] : 0
const neighbours = NEIGHBOUR_OFFSETS.map(offset => titleIndex + offset)
const organization = {
allowOrganization: true,
organizationLimit: titleIndex + AUTHOR_BLOCK_MARGIN
}

return (
toAuthor(lines, expandAuthorLines(lines, neighbours, { titleIndexes })) ||
toAuthor(lines, neighbours, organization)
)
}

module.exports = { expandAuthorLines, getAuthor, nameCount, toAuthor }
Loading