From 82001be79d83ba3f1ebc69b296ccda29e781d627 Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Sat, 22 Aug 2026 15:38:19 +0200 Subject: [PATCH 1/7] feat: add metascraper-pdf Embedded PDF metadata is usually junk, so the rules fetch the URL and read the page. Co-authored-by: Cursor --- README.md | 1 + packages/metascraper-pdf/README.md | 124 +++ packages/metascraper-pdf/package.json | 58 ++ packages/metascraper-pdf/src/author.js | 145 ++++ packages/metascraper-pdf/src/date.js | 120 +++ packages/metascraper-pdf/src/description.js | 61 ++ packages/metascraper-pdf/src/document.js | 88 ++ packages/metascraper-pdf/src/embedded.js | 116 +++ packages/metascraper-pdf/src/index.d.ts | 30 + packages/metascraper-pdf/src/index.js | 189 +++++ packages/metascraper-pdf/src/lang.js | 35 + packages/metascraper-pdf/src/layout.js | 71 ++ packages/metascraper-pdf/src/media.js | 92 +++ packages/metascraper-pdf/src/publisher.js | 121 +++ packages/metascraper-pdf/src/text.js | 147 ++++ packages/metascraper-pdf/src/title.js | 103 +++ packages/metascraper-pdf/test/fixtures.js | 41 + .../metascraper-pdf/test/fixtures/.gitignore | 1 + .../metascraper-pdf/test/fixtures/download.sh | 1 + .../metascraper-pdf/test/fixtures/urls.txt | 50 ++ .../metascraper-pdf/test/helpers/index.js | 86 ++ packages/metascraper-pdf/test/index.js | 153 ++++ .../test/snapshots/fixtures.js.md | 755 ++++++++++++++++++ .../test/snapshots/fixtures.js.snap | Bin 0 -> 11318 bytes packages/metascraper-pdf/test/unit.js | 207 +++++ 25 files changed, 2795 insertions(+) create mode 100644 packages/metascraper-pdf/README.md create mode 100644 packages/metascraper-pdf/package.json create mode 100644 packages/metascraper-pdf/src/author.js create mode 100644 packages/metascraper-pdf/src/date.js create mode 100644 packages/metascraper-pdf/src/description.js create mode 100644 packages/metascraper-pdf/src/document.js create mode 100644 packages/metascraper-pdf/src/embedded.js create mode 100644 packages/metascraper-pdf/src/index.d.ts create mode 100644 packages/metascraper-pdf/src/index.js create mode 100644 packages/metascraper-pdf/src/lang.js create mode 100644 packages/metascraper-pdf/src/layout.js create mode 100644 packages/metascraper-pdf/src/media.js create mode 100644 packages/metascraper-pdf/src/publisher.js create mode 100644 packages/metascraper-pdf/src/text.js create mode 100644 packages/metascraper-pdf/src/title.js create mode 100644 packages/metascraper-pdf/test/fixtures.js create mode 100644 packages/metascraper-pdf/test/fixtures/.gitignore create mode 100755 packages/metascraper-pdf/test/fixtures/download.sh create mode 100644 packages/metascraper-pdf/test/fixtures/urls.txt create mode 100644 packages/metascraper-pdf/test/helpers/index.js create mode 100644 packages/metascraper-pdf/test/index.js create mode 100644 packages/metascraper-pdf/test/snapshots/fixtures.js.md create mode 100644 packages/metascraper-pdf/test/snapshots/fixtures.js.snap create mode 100644 packages/metascraper-pdf/test/unit.js diff --git a/README.md b/README.md index 5d5e6986d..fdc20cb6f 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,7 @@ const metascraper = require('metascraper')([ - [metascraper-logo](https://github.com/microlinkhq/metascraper/tree/master/packages/metascraper-logo) – Get logo property from HTML markup. - [metascraper-manifest](https://github.com/microlinkhq/metascraper/tree/master/packages/metascraper-manifest) – Metascraper integration for detecting PWA Web app [manifests](https://developer.mozilla.org/en-US/docs/Web/Manifest). - [metascraper-media-provider](https://github.com/microlinkhq/metascraper/tree/master/packages/metascraper-media-provider) – Get specific video provider url (Facebook/Twitter/Vimeo/etc). +- [metascraper-pdf](https://github.com/microlinkhq/metascraper/tree/master/packages/metascraper-pdf) – Get title, author, date, description, publisher, image, logo, and lang from a PDF document. - [metascraper-publisher](https://github.com/microlinkhq/metascraper/tree/master/packages/metascraper-publisher) – Get publisher property from HTML markup. - [metascraper-readability](https://github.com/microlinkhq/metascraper/tree/master/packages/metascraper-readability) – A Mozilla readability connector for metascraper. - [metascraper-title](https://github.com/microlinkhq/metascraper/tree/master/packages/metascraper-title) – Get title property from HTML markup. diff --git a/packages/metascraper-pdf/README.md b/packages/metascraper-pdf/README.md new file mode 100644 index 000000000..72cb98ce2 --- /dev/null +++ b/packages/metascraper-pdf/README.md @@ -0,0 +1,124 @@ +
+
+ metascraper +
+
+

metascraper-pdf: Get title, author, date, description, publisher, image, logo, and lang out of a PDF document.

+

See our website for more information.

+
+
+ +## Install + +```bash +$ npm install metascraper-pdf --save +``` + +## Usage + +The rules download the document at `url` when it looks like a PDF. + +```js +const metascraper = require('metascraper')([require('metascraper-pdf')()]) + +const metadata = await metascraper({ + url: 'https://arxiv.org/pdf/1706.03762v7' +}) + +// { +// title: 'Attention Is All You Need', +// author: 'Ashish Vaswani', +// publisher: 'arXiv', +// date: '2017-06-01T00:00:00.000Z', +// description: 'The dominant sequence transduction models are based on…', +// lang: 'en', +// logo: 'https://www.google.com/s2/favicons?domain_url=…', +// image: null +// } +``` + +The bundle is a no-op unless [`test`](#test) sees a PDF URL, so it is safe to mix with the HTML rules: + +```js +const metascraper = require('metascraper')([ + require('metascraper-pdf')(), + require('metascraper-title')(), + require('metascraper-author')() +]) + +const metadata = await metascraper({ url, html }) +``` + +## How it reads a document + +The package fetches the URL, then reads the page the way a person does. Embedded PDF metadata is +mostly unusable — arXiv ships an empty `Title`, LaTeX ships `pedregosa11a.dvi`, Word ships +`Microsoft Word - draft.docx`, conference templates ship the venue as the `Subject`. + +- **title** — the largest type on the first page, skipping the banner publishers print above it + (`NBER WORKING PAPER SERIES`, `arXiv:2303.08774v6`, `REVIEW`) and any byline set in the same size. +- **author** — the block under the title, stripped of emails, affiliation superscripts and + organisation names. Following metascraper's convention this returns a single name. +- **description** — the abstract, or the first paragraph of body text when there is no abstract. +- **publisher** — the venue in the running header or footer; otherwise a known host (`arXiv`, + `NBER`, `PLOS`) or the domain. +- **date** — the identifier when the url encodes it (an arXiv id is a year-month; proceedings hosts + put the year in the path). Otherwise the markers on the page win over the PDF creation date. +- **lang** — `dc:language` when present, then the host, then the words on the page. +- **image** — a first-page figure encoded as a PNG data URI, when the PDF embeds one that is not + just a decoration. +- **logo** — a first-page mark encoded as a PNG data URI, or the publisher favicon. + +## API + +### metascraper-pdf([options]) + +#### options + +##### maxPages + +Type: `number`
+Default: `2` + +How many pages to read text from. The title, author and publisher only ever come from the first +page; the extra page feeds the description when a document has no abstract. + +##### gotOpts + +Type: `object` + +Any option provided here will passed to [got#options](https://github.com/sindresorhus/got#options). + +##### keyvOpts + +Type: `object` + +Any option provided here will passed to [@keyvhq/memoize#options](https://github.com/microlinkhq/keyv/tree/master/packages/memoize#keyvoptions). + +##### getPdf + +Type: `function` + +It will be called to get the PDF bytes behind `url`. Defaults to downloading the URL with `got`. + +### .test(props) + +Type: `function`
+Returns: `boolean` + +`true` when `props.url` points at a PDF (`.pdf`, an `/pdf` path, or `type=printable`), which is how +the bundle stays inert for HTML input. + +```js +const { test: isPdf } = require('metascraper-pdf') + +isPdf({ url: 'https://arxiv.org/pdf/1706.03762v7' }) // => true +isPdf({ url: 'https://example.com' }) // => false +``` + +## License + +**metascraper-pdf** © [microlink.io](https://microlink.io), released under the [MIT](https://github.com/microlinkhq/metascraper/blob/master/LICENSE.md) License.
+Authored and maintained by [Kiko Beats](https://kikobeats.com) with help from [contributors](https://github.com/microlinkhq/metascraper/contributors). + +> [microlink.io](https://microlink.io) · GitHub [microlink.io](https://github.com/microlinkhq) · X [@microlinkhq](https://x.com/microlinkhq) diff --git a/packages/metascraper-pdf/package.json b/packages/metascraper-pdf/package.json new file mode 100644 index 000000000..ce8e25205 --- /dev/null +++ b/packages/metascraper-pdf/package.json @@ -0,0 +1,58 @@ +{ + "name": "metascraper-pdf", + "description": "Metascraper rules for extracting title, author, date, description, publisher, image, logo, and lang from PDF documents.", + "homepage": "https://github.com/microlinkhq/metascraper/packages/metascraper-pdf", + "version": "5.57.0", + "types": "src/index.d.ts", + "main": "src/index.js", + "author": { + "email": "hello@microlink.io", + "name": "microlink.io", + "url": "https://microlink.io" + }, + "repository": { + "directory": "packages/metascraper-pdf", + "type": "git", + "url": "git+https://github.com/microlinkhq/metascraper.git" + }, + "bugs": { + "url": "https://github.com/microlinkhq/metascraper/issues" + }, + "keywords": [ + "document", + "extract", + "metadata", + "metascraper", + "paper", + "pdf", + "scraper" + ], + "dependencies": { + "@keyvhq/memoize": "~2.2.4", + "@metascraper/helpers": "workspace:*", + "async-memoize-one": "~1.2.1", + "got": "~11.8.6", + "unpdf": "~1.8.1" + }, + "devDependencies": { + "async-listen": "~3.1.0", + "ava": "8", + "metascraper": "workspace:*" + }, + "engines": { + "node": ">= 22" + }, + "files": [ + "src" + ], + "scripts": { + "test": "NODE_PATH=.. TZ=UTC ava --timeout 30s" + }, + "license": "MIT", + "ava": { + "files": [ + "test/**/*.js", + "!test/helpers/**" + ] + } +} diff --git a/packages/metascraper-pdf/src/author.js b/packages/metascraper-pdf/src/author.js new file mode 100644 index 000000000..49c64970f --- /dev/null +++ b/packages/metascraper-pdf/src/author.js @@ -0,0 +1,145 @@ +'use strict' + +const { + ORGANIZATION_WORDS, + PLACE_NAME, + flatten, + isBannerLine, + 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 => + value ? value.split(/[,;]| and /).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 } diff --git a/packages/metascraper-pdf/src/date.js b/packages/metascraper-pdf/src/date.js new file mode 100644 index 000000000..1fd4985a7 --- /dev/null +++ b/packages/metascraper-pdf/src/date.js @@ -0,0 +1,120 @@ +'use strict' + +const YEAR = '((?:19|20)\\d{2})' +const MONTHS = + 'january|february|march|april|may|june|july|august|september|october|november|december|enero|febrero|marzo|abril|mayo|junio|julio|agosto|septiembre|octubre|noviembre|diciembre' + +const ARXIV_NEW = + /arxiv\.org\/(?:abs|pdf)\/(\d{2})(\d{2})\.\d{4,5}|arxiv:(\d{2})(\d{2})\.\d{4,5}/i +const ARXIV_OLD = + /arxiv\.org\/(?:abs|pdf)\/[a-z-]+(?:\.[A-Z]{2})?\/(\d{2})(\d{2})\d{3}|arxiv:[a-z-]+(?:\.[A-Z]{2})?\/(\d{2})(\d{2})\d{3}/i +const PATH_YEAR = /(?:^|[/_-])((?:19|20)\d{2})(?:[/_-]|$)/ + +const MARKED_YEAR = [ + new RegExp( + `\\b(?:published|accepted|issued|publicado|publication date)\\b[^\\n]{0,40}?${YEAR}`, + 'i' + ), + new RegExp(`(?:©|\\(c\\)|copyright)[^\\n]{0,40}?${YEAR}`, 'i'), + new RegExp(`\\barxiv:\\S+[^\\n]{0,30}?${YEAR}`, 'i'), + new RegExp(`\\b(?:vol\\.?|volume|núm\\.?|no\\.)[^\\n]{0,40}?${YEAR}\\b`, 'i'), + new RegExp(`\\b(?:${MONTHS})\\s+${YEAR}\\b`, 'i'), + new RegExp( + `\\b(?:cvpr|iccv|eccv|neurips|nips|icml|iclr|acl|emnlp|naacl|interspeech|aaai|ijcai)\\s*${YEAR}\\b`, + 'i' + ), + new RegExp( + `${YEAR}\\s+(?:ieee|acm|international conference|conference on)`, + 'i' + ) +] + +const HEADER_LINES = 12 +const FOOTER_LINES = 4 +const BODY_LINE_WORDS = 18 +const ARXIV_EPOCH = 1991 + +const toFullYear = shortYear => { + const year = Number(shortYear) + return year >= 91 ? 1900 + year : 2000 + year +} + +const arxivStamp = source => { + const match = ARXIV_NEW.exec(source) || ARXIV_OLD.exec(source) + if (!match) return null + const [year, month] = match.slice(1).filter(Boolean) + return { year: toFullYear(year), month: Number(month) } +} + +/** + * An arXiv id encodes the month it was announced, and proceedings hosts put the + * year in the path. Both beat the PDF creation date, which is the day the file + * was last built. + */ +const identifierDate = (url, { text = '' } = {}) => { + const stamp = arxivStamp(url) || arxivStamp(text.slice(0, 400)) + if ( + stamp && + stamp.year >= ARXIV_EPOCH && + stamp.month >= 1 && + stamp.month <= 12 + ) { + const month = String(stamp.month).padStart(2, '0') + return `${stamp.year}-${month}-01T00:00:00.000Z` + } + + const { pathname } = new URL(url) + const match = PATH_YEAR.exec(pathname) + const year = match ? Number(match[1]) : 0 + return year >= 1900 ? `${year}-01-01T00:00:00.000Z` : null +} + +const headerAndFooter = lines => { + const header = [] + + for (const line of lines) { + if (header.length >= HEADER_LINES) break + header.push(line.text) + if (line.text.split(/\s+/).length >= BODY_LINE_WORDS && header.length > 1) { + break + } + } + + return [...header, ...lines.slice(-FOOTER_LINES).map(line => line.text)].join( + '\n' + ) +} + +const firstMarkedYear = (text, currentYear) => { + for (const pattern of MARKED_YEAR) { + const match = pattern.exec(text) + const year = match ? Number(match[1]) : 0 + if (year >= 1900 && year <= currentYear) return year + } + return null +} + +const documentYear = (lines, { embedded = {}, now = new Date() } = {}) => { + const currentYear = now.getUTCFullYear() + const venue = [embedded.description, embedded.keywords, embedded.title] + .filter(Boolean) + .join('\n') + return ( + firstMarkedYear(headerAndFooter(lines), currentYear) || + firstMarkedYear(venue, currentYear) + ) +} + +const getDate = (url, { lines, text, embedded, rawEmbedded, now }) => { + const fromIdentifier = identifierDate(url, { text }) + if (fromIdentifier) return fromIdentifier + + const year = documentYear(lines, { embedded: rawEmbedded, now }) + if (year == null) return embedded.date + if (embedded.date && new Date(embedded.date).getUTCFullYear() === year) { + return embedded.date + } + return `${year}-01-01T00:00:00.000Z` +} + +module.exports = { documentYear, getDate, identifierDate } diff --git a/packages/metascraper-pdf/src/description.js b/packages/metascraper-pdf/src/description.js new file mode 100644 index 000000000..36fca4c53 --- /dev/null +++ b/packages/metascraper-pdf/src/description.js @@ -0,0 +1,61 @@ +'use strict' + +const ABSTRACT_HEADING = + /(?:^|\n)\s*(?:abstract|summary|executive summary)\b[.:—-]?\s*/i +const RUNNING_HEADER = /[|·•‖]|^\d+\s|\bdoi:/i +const SENTENCE_END = /(?<=[.!?])\s+/ +const MAX_SUMMARY_LENGTH = 300 +const MIN_SUMMARY_LENGTH = 40 + +const dehyphenate = text => text.replace(/([a-z])-\s+([a-z])/g, '$1$2') + +/** The size that carries most of the page is the body text, not the furniture. */ +const dominantSize = lines => { + const weight = new Map() + for (const line of lines) { + weight.set(line.size, (weight.get(line.size) || 0) + line.text.length) + } + return [...weight.entries()] + .sort((left, right) => right[1] - left[1]) + .map(([size]) => size)[0] +} + +const firstSentences = (text, maxLength) => { + const sentences = text.split(SENTENCE_END) + let summary = '' + + for (const sentence of sentences) { + if (summary && `${summary} ${sentence}`.length > maxLength) break + summary = summary ? `${summary} ${sentence}` : sentence + if (summary.length >= maxLength) break + } + + if (summary.length <= maxLength) return summary.trim() + const clipped = summary.slice(0, maxLength) + return `${clipped.slice(0, clipped.lastIndexOf(' ')).trim()}…` +} + +/** + * The abstract, or the first real paragraph when the document has none. + */ +const getDescription = (lines, { maxLength = MAX_SUMMARY_LENGTH } = {}) => { + const readable = lines.filter(line => !RUNNING_HEADER.test(line.text)) + const text = dehyphenate(readable.map(line => line.text).join('\n')) + const abstract = ABSTRACT_HEADING.exec(text) + + const bodySize = dominantSize(readable) + const body = abstract + ? text.slice(abstract.index + abstract[0].length) + : readable + .filter(line => line.size === bodySize) + .map(line => line.text) + .join(' ') + + const summary = firstSentences( + dehyphenate(body).replace(/\s+/g, ' ').trim(), + maxLength + ) + return summary.length >= MIN_SUMMARY_LENGTH ? summary : null +} + +module.exports = { getDescription } diff --git a/packages/metascraper-pdf/src/document.js b/packages/metascraper-pdf/src/document.js new file mode 100644 index 000000000..9978c7fc9 --- /dev/null +++ b/packages/metascraper-pdf/src/document.js @@ -0,0 +1,88 @@ +'use strict' + +const { extractImages, getDocumentProxy } = require('unpdf') + +const SAME_LINE_TOLERANCE = 2 +const MIN_LINE_LENGTH = 2 +const WORD_GAP_RATIO = 0.08 +const COLUMN_GAP_RATIO = 1.5 +const DEFAULT_MAX_PAGES = 2 + +const normalizeSpaces = text => + text + .replace(/[^\S ]+/g, ' ') + .replace(/ {3,}/g, ' ') + .replace(/^ +| +$/g, '') + +const toLines = items => { + const lines = [] + + for (const item of items) { + if (!item.str.trim()) continue + const x = item.transform[4] + const y = item.transform[5] + const size = item.height || item.transform[0] + const previous = lines[lines.length - 1] + + if (previous && Math.abs(previous.y - y) <= SAME_LINE_TOLERANCE) { + const gap = x - previous.endX + const separator = gap > size * COLUMN_GAP_RATIO ? ' ' : ' ' + const needsSpace = + gap > size * WORD_GAP_RATIO && + !/\s$/.test(previous.text) && + !/^\s/.test(item.str) + previous.text += needsSpace ? `${separator}${item.str}` : item.str + previous.size = Math.max(previous.size, size) + previous.endX = x + item.width + } else { + lines.push({ y, size, text: item.str, endX: x + item.width }) + } + } + + return lines + .map(({ y, size, text }) => ({ + y, + size: Number(size.toFixed(2)), + text: normalizeSpaces(text) + })) + .filter(line => line.text.length >= MIN_LINE_LENGTH) +} + +const pageLines = async (pdf, pageNumber) => { + const page = await pdf.getPage(pageNumber) + const { items } = await page.getTextContent() + const height = page.view[3] + return toLines(items).map(line => ({ ...line, pageHeight: height })) +} + +const readDocument = async (buffer, { maxPages = DEFAULT_MAX_PAGES } = {}) => { + const pdf = await getDocumentProxy(Uint8Array.from(buffer)) + const { info, metadata } = await pdf.getMetadata() + const pages = [] + + for ( + let pageNumber = 1; + pageNumber <= Math.min(maxPages, pdf.numPages); + pageNumber++ + ) { + pages.push(await pageLines(pdf, pageNumber)) + } + + const lines = pages.flat() + let images = [] + try { + images = await extractImages(pdf, 1) + } catch (_) {} + + return { + info: info || {}, + xmp: (metadata && metadata.getAll && metadata.getAll()) || {}, + pageCount: pdf.numPages, + firstPageLines: pages[0] || [], + lines, + images, + text: lines.map(line => line.text).join('\n') + } +} + +module.exports = { readDocument, toLines } diff --git a/packages/metascraper-pdf/src/embedded.js b/packages/metascraper-pdf/src/embedded.js new file mode 100644 index 000000000..857349020 --- /dev/null +++ b/packages/metascraper-pdf/src/embedded.js @@ -0,0 +1,116 @@ +'use strict' + +const JUNK_TITLE = [ + /^untitled/i, + /^arxiv:/i, + /^document ?\d*$/i, + /^microsoft word/i, + /^microsoft powerpoint/i, + /^print$/i, + /^slide ?\d*$/i, + /^layout ?\d*$/i, + /^book ?\d*$/i, + /\.(pdf|doc|docx|dot|ppt|pptx|xls|xlsx|tex|indd|qxd|pages|odt|rtf|md|htm|html|dvi)$/i, + /^[\da-f-]{16,}$/i, + /^[/\\~]/ +] + +const JUNK_AUTHOR = [ + /^(user|users|admin|administrator|owner|guest|unknown|anonymous|author|me|none|null|n\/?a)$/i, + /^(windows|microsoft|office|word|acrobat|adobe|wps|libreoffice|openoffice|hp|dell|toshiba|acer|lenovo|asus|sony|compaq)[\s-]*(user|office user|inc\.?)?$/i, + /\.(pdf|doc|docx|tex)$/i +] + +const SERIES_PREFIX = + /^[^:]{0,60}?\b(working paper|discussion paper|technical report|staff report)s?\s*\d*\s*:\s*/i +const CATALOG_PREFIX = /^(redalyc|scielo|dialnet)\.\s*/i +const VENUE_DESCRIPTION = + /^((19|20)\d{2}\s+)?(neural information|ieee|acm|proceedings|conference|workshop|journal|volume|doi|https?:|www\.)|https?:\/\/|\bdoi:\s*10\.\d/i + +const MIN_TITLE_LENGTH = 3 +const MIN_DESCRIPTION_LENGTH = 40 + +const clean = value => { + const text = Array.isArray(value) ? value.join(', ') : value + if (typeof text !== 'string') return null + const normalized = text.replace(/\s+/g, ' ').trim() + return normalized.length > 0 ? normalized : null +} + +const isJunk = (value, patterns) => + patterns.some(pattern => pattern.test(value)) + +const sameAsGenerator = (value, { creator, producer }) => + [creator, producer] + .filter(Boolean) + .some(generator => generator.toLowerCase() === value.toLowerCase()) + +const toDate = value => { + const match = /^D:(\d{4})(\d{2})?(\d{2})?(\d{2})?(\d{2})?(\d{2})?/.exec( + String(value || '') + ) + if (!match) return null + const [ + , + year, + month = '01', + day = '01', + hour = '00', + minute = '00', + second = '00' + ] = match + const date = new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}Z`) + return Number.isNaN(date.getTime()) ? null : date.toISOString() +} + +/** + * The Info dictionary and XMP packet, minus the noise every PDF writer leaves + * behind: LaTeX ships an empty title, Word ships the filename, conference + * templates ship the venue as the subject. + */ +const readEmbedded = ({ info = {}, xmp = {} } = {}) => { + const creator = clean(info.Creator) + const producer = clean(info.Producer) + const generators = { creator, producer } + + const rawTitle = (clean(xmp['dc:title']) || clean(info.Title) || '') + .replace(SERIES_PREFIX, '') + .replace(CATALOG_PREFIX, '') + const rawAuthor = clean(xmp['dc:creator']) || clean(info.Author) + const rawDescription = clean(xmp['dc:description']) || clean(info.Subject) + + const title = + rawTitle.length >= MIN_TITLE_LENGTH && + !isJunk(rawTitle, JUNK_TITLE) && + !sameAsGenerator(rawTitle, generators) + ? rawTitle + : null + + const author = + rawAuthor && + !isJunk(rawAuthor, JUNK_AUTHOR) && + !sameAsGenerator(rawAuthor, generators) + ? rawAuthor + : null + + const description = + rawDescription && + rawDescription.length >= MIN_DESCRIPTION_LENGTH && + !VENUE_DESCRIPTION.test(rawDescription) + ? rawDescription + : null + + return { + title, + author, + description, + publisher: clean(xmp['dc:publisher']), + date: toDate(info.CreationDate) || clean(xmp['xmp:createdate']), + lang: clean(xmp['dc:language']) || clean(info.Language) || clean(info.Lang), + creator, + producer, + keywords: clean(info.Keywords) + } +} + +module.exports = { readEmbedded, toDate } diff --git a/packages/metascraper-pdf/src/index.d.ts b/packages/metascraper-pdf/src/index.d.ts new file mode 100644 index 000000000..99c99eee1 --- /dev/null +++ b/packages/metascraper-pdf/src/index.d.ts @@ -0,0 +1,30 @@ +declare function rules(options?: rules.Options): import('metascraper').Rules; + +declare namespace rules { + interface Options { + /** + * How many pages to read text from. Only the first page is used for the + * title, author and publisher; extra pages feed the description fallback. + * + * @default 2 + */ + maxPages?: number; + /** + * https://github.com/sindresorhus/got#options + */ + gotOpts?: import('got').Options; + /** + * https://github.com/microlinkhq/keyv/tree/master/packages/memoize#keyvoptions + */ + keyvOpts?: import('@keyvhq/core').Options; + /** + * Called to get the PDF bytes behind `url`. Defaults to a `got` download. + */ + getPdf?: (url: string) => Buffer | Uint8Array | Promise; + } + + /** `true` when `url` points at a PDF document. */ + function test(props: { url?: string }): boolean; +} + +export = rules; diff --git a/packages/metascraper-pdf/src/index.js b/packages/metascraper-pdf/src/index.js new file mode 100644 index 000000000..b17bd41b2 --- /dev/null +++ b/packages/metascraper-pdf/src/index.js @@ -0,0 +1,189 @@ +'use strict' + +const asyncMemoizeOne = require('async-memoize-one') +const memoize = require('@keyvhq/memoize') +const got = require('got') + +const helpers = require('@metascraper/helpers') + +const { getAuthor, nameCount } = require('./author') +const { getDescription } = require('./description') +const { getDate } = require('./date') +const { getPublisher } = require('./publisher') +const { getTitle } = require('./title') +const { getLang } = require('./lang') +const { getMedia } = require('./media') +const { headerLines } = require('./layout') +const { readDocument } = require('./document') +const { readEmbedded } = require('./embedded') +const { isInvertedName } = require('./text') + +const PDF_MAGIC = '%PDF-' +const PDF_PATH = /(?:^|\/)pdf(?:\/|$)/i +const PDF_TYPE = /^(?:pdf|printable)$/i +const MAX_PAGES = 2 + +const isPdf = value => + ArrayBuffer.isView(value) && + value.byteLength >= PDF_MAGIC.length && + Buffer.from(value.buffer, value.byteOffset, PDF_MAGIC.length).toString() === + PDF_MAGIC + +const isPdfLink = url => { + if (!url || !helpers.isUrl(url)) return false + if (helpers.isPdfUrl(url)) return true + try { + const parsed = new URL(url) + return ( + PDF_PATH.test(parsed.pathname) || + PDF_TYPE.test(parsed.searchParams.get('type') || '') + ) + } catch (_) { + return false + } +} + +/** "Surname, Given" is one person; "A, B" is two. */ +const firstAuthor = value => { + if (!value) return null + const first = value.split(/\s*;\s*/)[0] + if (isInvertedName(first)) { + const [surname, given] = first.split(/,\s*/) + return `${given} ${surname}` + } + return first.split(/,\s*/)[0] +} + +/** A name lifted out of the title block is a title fragment, not an author. */ +const withoutContext = (names, context) => { + if (!names) return null + const kept = names + .split(', ') + .filter(name => !context.some(entry => entry && appearsIn(name, entry))) + return kept.length > 0 ? kept.join(', ') : null +} + +const appearsIn = (value, text) => { + const normalize = input => + String(input || '') + .toLowerCase() + .replace(/[^a-z0-9]/g, '') + const needle = normalize(value) + return needle.length > 0 && normalize(text).includes(needle) +} + +const extract = async ({ url, pdf, maxPages }) => { + const document = await readDocument(pdf, { maxPages }) + const embedded = readEmbedded(document) + const rawEmbedded = { + description: document.info.Subject, + keywords: document.info.Keywords, + title: document.info.Title + } + + const lines = headerLines(document.firstPageLines) + const layoutTitle = getTitle(lines) + + const title = + embedded.title && appearsIn(embedded.title, document.text) + ? embedded.title + : (layoutTitle && layoutTitle.text) || embedded.title + + const layoutAuthor = withoutContext( + getAuthor(lines, { + titleIndexes: (layoutTitle && layoutTitle.indexes) || [] + }), + [title] + ) + const authors = + nameCount(layoutAuthor) > nameCount(embedded.author) + ? layoutAuthor + : embedded.author || layoutAuthor + + const author = firstAuthor(authors) + const publisher = + embedded.publisher || getPublisher(lines, { url, title, author: authors }) + const description = embedded.description || getDescription(document.lines) + const date = getDate(url, { + lines: document.firstPageLines, + text: document.text, + embedded, + rawEmbedded + }) + const lang = getLang(document.text, { url, embedded }) + const { image, logo } = getMedia(document.images, { url }) + + return { + title, + author, + authors, + description, + publisher, + date, + lang, + image, + logo + } +} + +const defaultGetPdf = gotOpts => async url => { + try { + const { body } = await got(url, { responseType: 'buffer', ...gotOpts }) + return isPdf(body) ? body : null + } catch (_) { + return null + } +} + +const createLoad = ({ maxPages, gotOpts, keyvOpts, getPdf }) => { + const fetchPdf = getPdf || defaultGetPdf(gotOpts) + + const parse = async url => { + const pdf = await fetchPdf(url) + if (!pdf) return {} + return extract({ url, pdf, maxPages }) + } + + return asyncMemoizeOne( + memoize(parse, keyvOpts, { + value: value => (value === undefined ? null : value) + }) + ) +} + +const NORMALIZERS = { + author: helpers.author, + date: helpers.date, + description: helpers.description, + image: helpers.image, + lang: helpers.lang, + logo: helpers.logo, + publisher: helpers.publisher, + title: helpers.title +} + +const fromPdf = + (propName, load) => + async ({ url }) => { + const metadata = await load(url) + return NORMALIZERS[propName](metadata && metadata[propName], { url }) + } + +module.exports = ({ maxPages = MAX_PAGES, gotOpts, keyvOpts, getPdf } = {}) => { + const load = createLoad({ maxPages, gotOpts, keyvOpts, getPdf }) + + return { + pkgName: 'metascraper-pdf', + test: ({ url }) => isPdfLink(url), + author: [fromPdf('author', load)], + date: [fromPdf('date', load)], + description: [fromPdf('description', load)], + image: [fromPdf('image', load)], + lang: [fromPdf('lang', load)], + logo: [fromPdf('logo', load)], + publisher: [fromPdf('publisher', load)], + title: [fromPdf('title', load)] + } +} + +module.exports.test = ({ url }) => isPdfLink(url) diff --git a/packages/metascraper-pdf/src/lang.js b/packages/metascraper-pdf/src/lang.js new file mode 100644 index 000000000..18930c7d1 --- /dev/null +++ b/packages/metascraper-pdf/src/lang.js @@ -0,0 +1,35 @@ +'use strict' + +const { lang, parseUrl } = require('@metascraper/helpers') + +const HOST_LANG = { + redalyc: 'es', + scielo: 'es' +} + +const EN = + '\\b(the|and|of|to|in|for|with|this|that|from|are|was|we|is|on|as|by|an|a|be|or|it)\\b' +const ES = + '\\b(el|la|los|las|del|una|para|con|por|que|este|esta|como|más|un|se|al)\\b' + +const hostLang = url => { + const { domainWithoutSuffix } = parseUrl(url) || {} + return HOST_LANG[domainWithoutSuffix] || null +} + +const count = (text, pattern) => + (text.match(new RegExp(pattern, 'gi')) || []).length + +const fromText = text => { + const sample = String(text || '').slice(0, 2000) + const en = count(sample, EN) + const es = count(sample, ES) + if (es > en && es >= 8) return 'es' + if (en >= 4) return 'en' + return null +} + +const getLang = (text, { url, embedded } = {}) => + lang(embedded && embedded.lang) || hostLang(url) || fromText(text) + +module.exports = { getLang } diff --git a/packages/metascraper-pdf/src/layout.js b/packages/metascraper-pdf/src/layout.js new file mode 100644 index 000000000..9342e4117 --- /dev/null +++ b/packages/metascraper-pdf/src/layout.js @@ -0,0 +1,71 @@ +'use strict' + +const MAX_HEADER_LINES = 20 +const MAX_FOOTER_LINES = 8 +const MAX_FOOTER_WORDS = 30 +const MAX_PROMINENT_LINES = 8 +const PROMINENT_SIZE_LEVELS = 2 +const FOLLOWING_LINES = 2 +const FOOTER_BAND = 0.12 +const BODY_LINE_WORDS = 18 + +const wordCount = text => text.split(/\s+/).length + +const leadingBlock = lines => { + const header = [] + + for (const line of lines) { + if (header.length >= MAX_HEADER_LINES) break + header.push(line) + if (wordCount(line.text) >= BODY_LINE_WORDS && header.length > 1) break + } + + return header +} + +const prominentLines = lines => { + const sizes = [...new Set(lines.map(line => line.size))] + .sort((left, right) => right - left) + .slice(0, PROMINENT_SIZE_LEVELS) + + return lines + .filter(line => sizes.includes(line.size)) + .slice(0, MAX_PROMINENT_LINES) + .flatMap(line => + lines.slice(line.pageIndex, line.pageIndex + 1 + FOLLOWING_LINES) + ) +} + +/** + * The lines worth reading on the first page: the block above the first + * paragraph, the largest type anywhere on the page (magazine layouts put the + * title well below the fold of the text stream), and the running footer. + */ +const headerLines = lines => { + const numbered = lines.map((line, pageIndex) => ({ ...line, pageIndex })) + const leading = leadingBlock(numbered) + const pageHeight = numbered.length > 0 ? numbered[0].pageHeight : null + + const isFooter = line => + pageHeight + ? line.y <= pageHeight * FOOTER_BAND + : line.pageIndex >= numbered.length - MAX_FOOTER_LINES + + const footer = numbered + .filter(line => isFooter(line) && wordCount(line.text) < MAX_FOOTER_WORDS) + .slice(-MAX_FOOTER_LINES) + + const chosen = new Map() + for (const line of [...leading, ...prominentLines(numbered)]) { + chosen.set(line.pageIndex, { ...line, region: 'body' }) + } + for (const line of footer) { + chosen.set(line.pageIndex, { ...line, region: 'footer' }) + } + + return [...chosen.values()] + .sort((left, right) => left.pageIndex - right.pageIndex) + .map((line, index) => ({ ...line, index })) +} + +module.exports = { headerLines } diff --git a/packages/metascraper-pdf/src/media.js b/packages/metascraper-pdf/src/media.js new file mode 100644 index 000000000..29e47c29f --- /dev/null +++ b/packages/metascraper-pdf/src/media.js @@ -0,0 +1,92 @@ +'use strict' + +const { deflateSync } = require('node:zlib') + +const MIN_SIDE = 16 +const MAX_PIXELS = 400 * 400 +const MAX_LOGO_SIDE = 256 + +const crcTable = new Uint32Array(256) +for (let n = 0; n < 256; n++) { + let c = n + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 + crcTable[n] = c >>> 0 +} + +const crc32 = buf => { + let crc = 0xffffffff + for (let i = 0; i < buf.length; i++) { + crc = crcTable[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8) + } + return (crc ^ 0xffffffff) >>> 0 +} + +const chunk = (type, data) => { + const length = Buffer.alloc(4) + length.writeUInt32BE(data.length) + const body = Buffer.concat([Buffer.from(type), data]) + const crc = Buffer.alloc(4) + crc.writeUInt32BE(crc32(body)) + return Buffer.concat([length, body, crc]) +} + +/** Encode raw RGB/RGBA from unpdf into a PNG data URI. node:zlib only. */ +const toPngDataUri = ({ width, height, channels, data }) => { + const source = Buffer.from(data.buffer, data.byteOffset, data.byteLength) + const stride = width * channels + const raw = Buffer.alloc((stride + 1) * height) + for (let y = 0; y < height; y++) { + source.copy(raw, y * (stride + 1) + 1, y * stride, y * stride + stride) + } + const ihdr = Buffer.alloc(13) + ihdr.writeUInt32BE(width, 0) + ihdr.writeUInt32BE(height, 4) + ihdr[8] = 8 + ihdr[9] = channels === 4 ? 6 : 2 + const png = Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + chunk('IHDR', ihdr), + chunk('IDAT', deflateSync(raw)), + chunk('IEND', Buffer.alloc(0)) + ]) + return `data:image/png;base64,${png.toString('base64')}` +} + +const usable = images => + images.filter( + img => + img && + img.data && + img.width >= MIN_SIDE && + img.height >= MIN_SIDE && + img.width * img.height <= MAX_PIXELS && + (img.channels === 3 || img.channels === 4) + ) + +const isLogo = img => + img.width <= MAX_LOGO_SIDE && + img.height <= MAX_LOGO_SIDE && + img.width / img.height >= 0.4 && + img.width / img.height <= 2.5 + +const favicon = url => + `https://www.google.com/s2/favicons?domain_url=${url}&sz=128` + +const getMedia = (images, { url } = {}) => { + const candidates = usable(images) + .slice() + .sort( + (left, right) => right.width * right.height - left.width * left.height + ) + + const logoImage = [...candidates].reverse().find(isLogo) + const picture = candidates.find(img => img !== logoImage) || logoImage + const logo = logoImage ? toPngDataUri(logoImage) : url ? favicon(url) : null + + return { + image: picture ? toPngDataUri(picture) : null, + logo + } +} + +module.exports = { favicon, getMedia, toPngDataUri, usable } diff --git a/packages/metascraper-pdf/src/publisher.js b/packages/metascraper-pdf/src/publisher.js new file mode 100644 index 000000000..ccdbe7a87 --- /dev/null +++ b/packages/metascraper-pdf/src/publisher.js @@ -0,0 +1,121 @@ +'use strict' + +const { parseUrl } = require('@metascraper/helpers') + +const { flatten, isBannerLine, tidy } = require('./text') + +const PUBLISHER_NOISE = + /https?:\/\/\S+|\bwww\.\S+|\bdoi:\S+|\b10\.\d{4,9}\/\S+|\(\s*\d{4}\s*\)|,?\s*\bpages?\b.*$|\b\d[\d:;,.()-]*\b/gi +const PUBLISHER_SEGMENTS = /\s*[|·•‖]\s*/ +const LETTER_SPACED = /\b(?:\p{L}\s){3,}\p{L}\b/gu +const RIGHTS_NOTICE = /©|\(c\)|\ball rights reserved\b\.?/gi +const WORKFLOW_PREFIX = + /^(published|received|accepted|submitted|revised|edited|reviewed|updated|available)\b/i +const STRUCTURAL_WORD = + /^(january|february|march|april|may|june|july|august|september|october|november|december|winter|spring|summer|fall|autumn|volume|vol|issue|no|number|article|page|pp|supplement|edition|part|series)$/i + +const VENUE_OPENING = + /^(proceedings|journal|transactions|communications|frontiers|revista|revue|zeitschrift|annals|acta|bulletin|nature|scientific reports|plos|ieee|acm|arxiv|biorxiv|the journal)\b/i +const VENUE_MARK = /\b(publish\w+|proceedings|conference|symposium)\b/i +const AFFILIATION = + /\b(universi\w*|department|departamento|dept|laborator\w*|labs?|faculty|school|college|institut\w*|hospital|clinic|cent(er|re|ro)|research|group|team|division)\b/i + +const CITY_STATE = /^\p{Lu}[\p{L}.' -]+,\s*[A-Z]{2}$/u + +const HOST_PUBLISHER = { + arxiv: 'arXiv', + nber: 'NBER', + jmlr: 'JMLR', + thecvf: 'CVF', + neurips: 'NeurIPS', + plos: 'PLOS', + nature: 'Nature', + elifesciences: 'eLife', + frontiersin: 'Frontiers', + aclanthology: 'ACL Anthology', + redalyc: 'Redalyc', + 'ceur-ws': 'CEUR-WS', + springer: 'Springer', + biomedcentral: 'BMC', + bis: 'BIS' +} + +const MAX_PUBLISHER_WORDS = 12 + +const collapseLetterSpacing = text => + text.replace(LETTER_SPACED, match => match.replace(/\s+/g, '')) + +const hostName = url => { + const { domainWithoutSuffix, hostname } = parseUrl(url) || {} + return domainWithoutSuffix || hostname || null +} + +const publisherFromUrl = url => { + const { domainWithoutSuffix, hostname } = parseUrl(url) || {} + if (HOST_PUBLISHER[domainWithoutSuffix]) { return HOST_PUBLISHER[domainWithoutSuffix] } + const name = domainWithoutSuffix || hostname || url + return name.length > 1 ? name[0].toUpperCase() + name.slice(1) : name +} + +const matchesHost = (value, url) => { + const host = hostName(url) + if (!host) return false + const normalize = input => input.toLowerCase().replace(/[^a-z0-9]/g, '') + return ( + normalize(value).startsWith(normalize(host)) || + normalize(host).startsWith(normalize(value)) + ) +} + +const isVenue = (value, url) => + Boolean(value) && + (VENUE_OPENING.test(value) || + VENUE_MARK.test(value) || + matchesHost(value, url)) + +/** + * Running headers and footers carry the venue next to volume, doi and page + * numbers: `Scientific Reports | (2023) 13:1234 | https://doi.org/...`. + */ +const publisherFromLine = text => + flatten(collapseLetterSpacing(text).replace(RIGHTS_NOTICE, ' ')) + .split(PUBLISHER_SEGMENTS) + .map(part => flatten(tidy(part.replace(PUBLISHER_NOISE, ' ')))) + .filter(part => { + const words = part.split(/\s+/).filter(Boolean) + if (words.length === 0 || words.length > MAX_PUBLISHER_WORDS) return false + if (WORKFLOW_PREFIX.test(part)) return false + return ( + /\p{L}{3}/u.test(part) && + !words.every(word => STRUCTURAL_WORD.test(word)) + ) + })[0] || null + +const sameText = (left, right) => { + const normalize = value => + String(value || '') + .toLowerCase() + .replace(/[^a-z0-9]/g, '') + return Boolean(left && right) && normalize(left) === normalize(right) +} + +const isUsablePublisher = (value, { author, title }) => + Boolean(value) && + !sameText(value, author) && + !sameText(value, title) && + !AFFILIATION.test(value) && + !CITY_STATE.test(value) + +const getPublisher = (lines, { url, title, author }) => { + const venue = lines + .filter(line => !isBannerLine(line.text)) + .map(line => publisherFromLine(line.text)) + .find( + value => + isVenue(value, url) && isUsablePublisher(value, { author, title }) + ) + + return venue || publisherFromUrl(url) +} + +module.exports = { getPublisher, isVenue, publisherFromLine, publisherFromUrl } diff --git a/packages/metascraper-pdf/src/text.js b/packages/metascraper-pdf/src/text.js new file mode 100644 index 000000000..00e99b6ea --- /dev/null +++ b/packages/metascraper-pdf/src/text.js @@ -0,0 +1,147 @@ +'use strict' + +const EMAIL = /\S+@\S+/ +const EMAIL_GLOBAL = /\S+@\S+/g +const FOOTNOTE_MARKS = /[*∗†‡§¶#]/g +const SUPERSCRIPTS = /(?<=\p{L})\d+(?:,\d+)*(?=\W|$)/gu +const COUNTRY_ABBREVIATION = /(^|\s)\p{Lu}\.\p{Lu}\.?($|\s|,)/u +const PLACE_NAME = + /^(costa rica|united states|united kingdom|new zealand|south africa|puerto rico|el salvador|saudi arabia|south korea|hong kong)$/i +const INVERTED_NAME = + /^\p{Lu}[\p{L}'’-]+(?:\s\p{Lu}[\p{L}'’-]+)*,\s\p{Lu}[\p{L}'’-]+(?:\s\p{Lu}[\p{L}'’.-]*)*$/u +const NAME_RUN_BOUNDARY = /(?<=\p{Ll})\s(?=\p{Lu}[\p{Ll}])/u + +const ORGANIZATION_WORDS = + /\b(universi\w*|institut\w*|department\w*|departamento|dept|laborator\w*|labs?|college|school|escuela|academy|academia|research|brain|cent(er|re|ro)|foundation|fundaci\w*|hospital|ministry|ministerio|agency|agencia|association|society|press|journal|proceedings|conference|inc|llc|ltd|gmbh|corp|corporation|company|co|google|microsoft|facebook|meta|openai|deepmind|nvidia|amazon|apple|ibm|baidu|alibaba|tencent|huawei|samsung|intel|adobe|anthropic)\b/i + +/** Lower case only: a middle initial is `A`, a function word is `and`. */ +const FUNCTION_WORDS = + /\b(for|and|the|with|in|on|to|at|of|from|via|using|towards?|a|an|is|are|be|by)\b/ + +const NAME_PARTICLES = new Set([ + 'van', + 'von', + 'de', + 'del', + 'della', + 'di', + 'da', + 'dos', + 'der', + 'den', + 'la', + 'le', + 'bin', + 'ibn' +]) + +const BANNER_LINE = + /^((nber|bis|ecb|imf|oecd)\s+)?(working|discussion|conference|staff)\s+papers?(\s+series)?\s*\d*\b|^technical report$|^(review|research|original research|short|regular)\s+(article|paper)s?$|^preprints?$|^no\.?\s*\d+$|^volume\s+\d+|^published as a conference paper|^arxiv:\S+|^submitted to |^(reviews?|articles?|letters?|analysis|perspectives?|comments?|editorials?|news|features?|insights?|reports?|columns?|correspondence)$/i + +const MAX_NAME_WORDS = 6 +const MAX_INVERTED_WORDS = 3 +const MIN_GIVEN_LETTERS = 3 +const MIN_RUN_WORDS = 4 +const MAX_RUN_WORDS = 14 + +const tidy = value => + value + .replace(/[^\S ]+/g, ' ') + .replace(/ {3,}/g, ' ') + .trim() + .replace(/^[,;:.\-–—&]+\s*|[,;:.\-–—&]+$/g, '') + +const flatten = value => value.replace(/\s+/g, ' ').trim() + +const stripNoise = text => + tidy( + text + .replace(EMAIL_GLOBAL, ' ') + .replace(FOOTNOTE_MARKS, ' ') + .replace(SUPERSCRIPTS, '') + ) + +const isShouting = text => text === text.toUpperCase() && /\p{Lu}/u.test(text) + +const isCapitalized = word => + /^[\p{Lu}]/u.test(word) || NAME_PARTICLES.has(word.toLowerCase()) + +const isInvertedName = text => { + if ( + !INVERTED_NAME.test(text) || + text.split(/\s+/).length > MAX_INVERTED_WORDS + ) { + return false + } + const given = text.split(',')[1] || '' + return (given.match(/\p{L}/gu) || []).length >= MIN_GIVEN_LETTERS +} + +const isPersonName = rawText => { + const text = stripNoise(rawText) + if (isInvertedName(text)) return !ORGANIZATION_WORDS.test(text) + if (EMAIL.test(text) || /\d/.test(text) || /^https?:/i.test(text)) { + return false + } + if ( + isShouting(text) || + COUNTRY_ABBREVIATION.test(text) || + PLACE_NAME.test(text) + ) { + return false + } + if (ORGANIZATION_WORDS.test(text) || FUNCTION_WORDS.test(text)) return false + const words = text.split(/\s+/) + return ( + words.length >= 2 && + words.length <= MAX_NAME_WORDS && + words.every(isCapitalized) + ) +} + +const isBannerLine = text => BANNER_LINE.test(tidy(text)) + +const splitNamePairs = text => { + const words = text.split(/\s+/).filter(Boolean) + if ( + words.length < MIN_RUN_WORDS || + words.length > MAX_RUN_WORDS || + words.length % 2 !== 0 + ) { + return [] + } + if (!words.every(word => /^\p{Lu}/u.test(word))) return [] + return words.flatMap((word, index) => + index % 2 === 0 ? [`${word} ${words[index + 1]}`] : [] + ) +} + +const splitNameRun = part => { + if (part.split(/\s+/).length < MIN_RUN_WORDS) return [part] + const pieces = part.split(NAME_RUN_BOUNDARY) + return pieces.every( + piece => /^\p{Lu}/u.test(piece) && piece.split(/\s+/).length <= 3 + ) + ? pieces + : [part] +} + +const splitNames = text => + isInvertedName(text) + ? [text] + : text.split(/\s{2,}|\s*(?:,|;| and | & )\s*/).flatMap(splitNameRun) + +module.exports = { + EMAIL, + ORGANIZATION_WORDS, + PLACE_NAME, + flatten, + isBannerLine, + isInvertedName, + isPersonName, + isShouting, + splitNamePairs, + splitNames, + stripNoise, + tidy +} diff --git a/packages/metascraper-pdf/src/title.js b/packages/metascraper-pdf/src/title.js new file mode 100644 index 000000000..a7c7f8385 --- /dev/null +++ b/packages/metascraper-pdf/src/title.js @@ -0,0 +1,103 @@ +'use strict' + +const { EMAIL, flatten, isBannerLine, isPersonName } = require('./text') + +const TITLE_BLOCK_LIMIT = 6 +const TITLE_SIZE_LEVELS = 3 +const BYLINE_DISTANCE = 4 +const MIN_TITLE_LENGTH = 3 +const MAX_TITLE_LENGTH = 300 + +const CONTINUES_TITLE = + /\b(for|of|and|the|in|on|to|with|from|a|an|at|by|via|using|towards?|de|del|la)$/i +const LINE_NUMBER = /(?<=\p{L}{3,})\d{1,2}(?=\s|$)/gu + +const distinctSizes = lines => + [...new Set(lines.map(line => line.size))].sort((left, right) => right - left) + +/** + * A title keeps its font size while it wraps, and ends where the byline starts. + */ +const titleLines = (lines, titleLine) => { + const block = [titleLine] + + for (let offset = 1; offset < TITLE_BLOCK_LIMIT; offset++) { + const next = lines[titleLine.index + offset] + const previous = block[block.length - 1] + if (!next || next.size !== titleLine.size) break + if (next.pageIndex != null && next.pageIndex !== previous.pageIndex + 1) { + break + } + if (/[.?!]$/.test(previous.text) && !/:$/.test(previous.text)) break + const continues = CONTINUES_TITLE.test(previous.text) + if (EMAIL.test(next.text)) break + if (!continues && (isPersonName(next.text) || isBannerLine(next.text))) { + break + } + block.push(next) + } + + return block +} + +const titleBlock = (lines, titleLine) => + flatten( + titleLines(lines, titleLine) + .map(line => line.text.replace(LINE_NUMBER, '')) + .join(' ') + ) + +const titleBlockIndexes = (lines, titleLine) => + titleLines(lines, titleLine).map(line => line.index) + +/** A line of names, rather than a title, has more names set beside it. */ +const isByline = (line, lines) => + isPersonName(line.text) && + lines.some( + other => + other.index !== line.index && + other.size === line.size && + Math.abs(other.index - line.index) <= BYLINE_DISTANCE && + isPersonName(other.text) + ) + +/** + * The title is the largest type on the page, skipping the banners publishers + * print above it (`NBER WORKING PAPER SERIES`, `arXiv:2303.08774v6`, `REVIEW`) + * and any byline set in the same size. + */ +const findTitleLine = lines => { + for (const size of distinctSizes(lines).slice(0, TITLE_SIZE_LEVELS)) { + const line = lines.find( + candidate => + candidate.size === size && + !isBannerLine(candidate.text) && + !isByline(candidate, lines) + ) + if (line) return line + } + return null +} + +const isUsableTitle = text => + typeof text === 'string' && + text.length >= MIN_TITLE_LENGTH && + text.length <= MAX_TITLE_LENGTH + +const getTitle = lines => { + const line = findTitleLine(lines) + if (!line) return null + const text = titleBlock(lines, line) + return isUsableTitle(text) + ? { text, line, indexes: titleBlockIndexes(lines, line) } + : null +} + +module.exports = { + findTitleLine, + getTitle, + isByline, + isUsableTitle, + titleBlock, + titleBlockIndexes +} diff --git a/packages/metascraper-pdf/test/fixtures.js b/packages/metascraper-pdf/test/fixtures.js new file mode 100644 index 000000000..7da4cda81 --- /dev/null +++ b/packages/metascraper-pdf/test/fixtures.js @@ -0,0 +1,41 @@ +'use strict' + +const { existsSync, readFileSync } = require('fs') +const { readFile } = require('fs/promises') +const { resolve } = require('path') +const test = require('ava').default + +const FIXTURES = resolve(__dirname, 'fixtures') +const skipReason = existsSync(resolve(FIXTURES, '1.pdf')) + ? null + : 'PDF fixtures missing; run test/fixtures/download.sh' + +const urls = existsSync(resolve(FIXTURES, 'urls.txt')) + ? readFileSync(resolve(FIXTURES, 'urls.txt'), 'utf8').trim().split('\n') + : [] + +const getPdf = async url => { + const index = urls.indexOf(url) + return readFile(resolve(FIXTURES, `${index + 1}.pdf`)) +} + +const metascraper = require('metascraper')([require('..')({ getPdf })]) + +const summarize = metadata => { + const compact = value => + typeof value === 'string' && value.startsWith('data:') + ? `${value.slice(0, 21)}…${value.length}` + : value + return { + ...metadata, + image: compact(metadata.image), + logo: compact(metadata.logo) + } +} + +for (const url of urls) { + test(url, async t => { + if (skipReason) return t.pass() + t.snapshot(summarize(await metascraper({ url }))) + }) +} diff --git a/packages/metascraper-pdf/test/fixtures/.gitignore b/packages/metascraper-pdf/test/fixtures/.gitignore new file mode 100644 index 000000000..a13633799 --- /dev/null +++ b/packages/metascraper-pdf/test/fixtures/.gitignore @@ -0,0 +1 @@ +*.pdf diff --git a/packages/metascraper-pdf/test/fixtures/download.sh b/packages/metascraper-pdf/test/fixtures/download.sh new file mode 100755 index 000000000..5d4e0ea53 --- /dev/null +++ b/packages/metascraper-pdf/test/fixtures/download.sh @@ -0,0 +1 @@ +nl -ba urls.txt | xargs -n 2 -P 8 sh -c 'curl -fL --retry 3 --retry-all-errors -o "$0.pdf" "$1"' diff --git a/packages/metascraper-pdf/test/fixtures/urls.txt b/packages/metascraper-pdf/test/fixtures/urls.txt new file mode 100644 index 000000000..190e34027 --- /dev/null +++ b/packages/metascraper-pdf/test/fixtures/urls.txt @@ -0,0 +1,50 @@ +https://www.nber.org/system/files/working_papers/w28110/w28110.pdf +https://www.cs.toronto.edu/~hinton/absps/NatureDeepReview.pdf +https://proceedings.neurips.cc/paper/2012/file/c399862d3b9d6b76c8436e924a68c45b-Paper.pdf +https://www.stat.berkeley.edu/~breiman/randomforest2001.pdf +https://homes.cs.washington.edu/~pedrod/papers/cacm12.pdf +https://arxiv.org/pdf/1706.03762v7 +https://journals.plos.org/plosone/article/file?id=10.1371/journal.pone.0230416&type=printable +https://aclanthology.org/N19-1423.pdf +https://jmlr.org/papers/volume15/srivastava14a/srivastava14a.pdf +https://journals.plos.org/plosone/article/file?id=10.1371/journal.pone.0000308&type=printable +https://openaccess.thecvf.com/content_CVPR_2020/papers/He_Momentum_Contrast_for_Unsupervised_Visual_Representation_Learning_CVPR_2020_paper.pdf +https://www.frontiersin.org/articles/10.3389/fpsyg.2019.00001/pdf +https://www.nature.com/articles/s41598-023-28020-5.pdf +https://link.springer.com/content/pdf/10.1186/s13059-020-02007-1.pdf +https://www.nber.org/system/files/working_papers/w31161/w31161.pdf +https://cdn.elifesciences.org/articles/00013/elife-00013-v1.pdf +https://aclanthology.org/2020.acl-main.703.pdf +https://arxiv.org/pdf/2303.08774v6 +https://www.redalyc.org/pdf/440/44029444004.pdf +https://ceur-ws.org/Vol-2600/paper1.pdf +https://arxiv.org/pdf/1512.03385v1 +https://proceedings.neurips.cc/paper_files/paper/2022/file/b1efde53be364a73914f58805a001731-Paper-Conference.pdf +https://journals.plos.org/plosbiology/article/file?id=10.1371/journal.pbio.3000410&type=printable +https://arxiv.org/pdf/2005.14165v4 +https://arxiv.org/pdf/1412.6980v9 +https://arxiv.org/pdf/2010.11929v2 +https://arxiv.org/pdf/1810.04805v2 +https://arxiv.org/pdf/2203.02155v1 +https://arxiv.org/pdf/cs/0102004v1 +https://arxiv.org/pdf/2104.08663v4 +https://www.jmlr.org/papers/volume12/pedregosa11a/pedregosa11a.pdf +https://www.jmlr.org/papers/volume13/bergstra12a/bergstra12a.pdf +https://openaccess.thecvf.com/content_cvpr_2016/papers/He_Deep_Residual_Learning_CVPR_2016_paper.pdf +https://openaccess.thecvf.com/content_ICCV_2017/papers/He_Mask_R-CNN_ICCV_2017_paper.pdf +https://aclanthology.org/D19-1410.pdf +https://aclanthology.org/P16-1162.pdf +https://proceedings.neurips.cc/paper/2015/file/14bfa6bb14875e45bba028a21ed38046-Paper.pdf +https://www.nber.org/system/files/working_papers/w26947/w26947.pdf +https://www.nber.org/system/files/working_papers/w30957/w30957.pdf +https://journals.plos.org/plosmedicine/article/file?id=10.1371/journal.pmed.1003583&type=printable +https://journals.plos.org/ploscompbiol/article/file?id=10.1371/journal.pcbi.1005510&type=printable +https://cdn.elifesciences.org/articles/57443/elife-57443-v2.pdf +https://www.frontiersin.org/articles/10.3389/fnins.2020.00001/pdf +https://www.nature.com/articles/s41598-020-69250-1.pdf +https://www.nature.com/articles/s41467-020-17419-7.pdf +https://link.springer.com/content/pdf/10.1186/s12874-020-01057-0.pdf +https://ceur-ws.org/Vol-3226/paper1.pdf +https://www.redalyc.org/pdf/567/56712871011.pdf +https://bmcbioinformatics.biomedcentral.com/counter/pdf/10.1186/s12859-020-3418-9.pdf +https://www.bis.org/publ/work1000.pdf diff --git a/packages/metascraper-pdf/test/helpers/index.js b/packages/metascraper-pdf/test/helpers/index.js new file mode 100644 index 000000000..7eebc223e --- /dev/null +++ b/packages/metascraper-pdf/test/helpers/index.js @@ -0,0 +1,86 @@ +'use strict' + +const PAGE_WIDTH = 612 +const PAGE_HEIGHT = 792 +const TOP_MARGIN = 72 +const LEFT_MARGIN = 72 + +const escapeText = text => text.replace(/([\\()])/g, '\\$1') + +const toStream = lines => { + let cursor = PAGE_HEIGHT - TOP_MARGIN + const operations = [] + + for (const { text, size = 10, gap = 6 } of lines) { + cursor -= size + gap + operations.push( + `BT /F1 ${size} Tf ${LEFT_MARGIN} ${cursor.toFixed(2)} Td (${escapeText( + text + )}) Tj ET` + ) + } + + return operations.join('\n') +} + +/** + * Builds a one page PDF out of `{ text, size }` lines, so tests exercise the + * real parser without committing binaries to the repository. + */ +const createPdf = (lines, { info = {} } = {}) => { + const stream = toStream(lines) + const infoEntries = Object.entries(info) + .map(([key, value]) => `/${key} (${escapeText(String(value))})`) + .join(' ') + + const objects = [ + '<< /Type /Catalog /Pages 2 0 R >>', + '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', + `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${PAGE_WIDTH} ${PAGE_HEIGHT}] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>`, + `<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`, + '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', + `<< ${infoEntries} >>` + ] + + let pdf = '%PDF-1.4\n' + const offsets = [] + + objects.forEach((body, index) => { + offsets.push(pdf.length) + pdf += `${index + 1} 0 obj\n${body}\nendobj\n` + }) + + const startxref = pdf.length + pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n` + for (const offset of offsets) { + pdf += `${String(offset).padStart(10, '0')} 00000 n \n` + } + pdf += `trailer\n<< /Size ${ + objects.length + 1 + } /Root 1 0 R /Info 6 0 R >>\nstartxref\n${startxref}\n%%EOF\n` + + return Buffer.from(pdf, 'latin1') +} + +const { default: listen } = require('async-listen') +const { createServer } = require('http') + +const closeServer = server => + require('util').promisify(server.close.bind(server))() + +const runServer = async (t, handler) => { + const server = createServer(async (req, res) => { + try { + await handler({ req, res }) + } catch (error) { + console.error(error) + res.statusCode = 500 + res.end() + } + }) + const url = await listen(server, { port: 0, host: '127.0.0.1' }) + t.teardown(() => closeServer(server)) + return url.toString() +} + +module.exports = { createPdf, runServer } diff --git a/packages/metascraper-pdf/test/index.js b/packages/metascraper-pdf/test/index.js new file mode 100644 index 000000000..e63504caf --- /dev/null +++ b/packages/metascraper-pdf/test/index.js @@ -0,0 +1,153 @@ +'use strict' + +const test = require('ava').default + +const { createPdf, runServer } = require('./helpers') + +const { test: isPdfLink } = require('..') + +const createMetascraper = (pdf, opts) => + require('metascraper')([require('..')({ getPdf: async () => pdf, ...opts })]) + +const PAPER = createPdf( + [ + { text: 'Attention Is All You Need', size: 18 }, + { text: 'Ashish Vaswani Noam Shazeer Niki Parmar', size: 11 }, + { text: 'Google Brain', size: 11 }, + { text: 'avaswani@google.com', size: 9 }, + { text: 'Abstract', size: 11 }, + { + text: 'The dominant sequence transduction models are based on complex recurrent or convolutional neural networks.', + size: 9 + }, + { + text: 'We propose the Transformer, based solely on attention mechanisms.', + size: 9 + } + ], + { info: { Title: '', Author: '', Creator: 'LaTeX with hyperref' } } +) + +const WORKING_PAPER = createPdf( + [ + { text: 'NBER WORKING PAPER SERIES', size: 12 }, + { text: 'GENERATIVE AI AT WORK', size: 12 }, + { text: 'Erik Brynjolfsson', size: 12 }, + { text: 'Danielle Li', size: 12 }, + { text: 'Working Paper 31161', size: 12 }, + { text: 'Abstract', size: 10 }, + { + text: 'We study the staggered introduction of a generative AI assistant among customer support agents.', + size: 10 + } + ], + { info: { Title: 'printmgr file' } } +) + +const JOURNAL = createPdf([ + { text: 'Frontiers in Psychology | Volume 10 | Article 1', size: 7 }, + { + text: 'Institutional Violence Against Users of the Family Law Courts', + size: 16 + }, + { text: 'Miguel Clemente, Dolores Padilla-Racero', size: 10 }, + { text: 'Abstract', size: 10 }, + { + text: 'This work analyses the psychological consequences of institutional violence in family law courts.', + size: 9 + } +]) + +test('test() only accepts a PDF url', t => { + t.true(isPdfLink({ url: 'https://arxiv.org/pdf/1706.03762v7' })) + t.true(isPdfLink({ url: 'https://example.com/paper.pdf' })) + t.true( + isPdfLink({ + url: 'https://journals.plos.org/plosone/article/file?id=10.1371/x&type=printable' + }) + ) + t.true( + isPdfLink({ + url: 'https://www.frontiersin.org/articles/10.3389/fpsyg.2019.00001/pdf' + }) + ) + t.false(isPdfLink({ url: 'https://arxiv.org/abs/1706.03762' })) + t.false(isPdfLink({ url: 'https://example.com' })) + t.false(isPdfLink({})) +}) + +test('reads the metadata of a paper', async t => { + const metascraper = createMetascraper(PAPER) + const metadata = await metascraper({ + url: 'https://arxiv.org/pdf/1706.03762v7' + }) + + t.is(metadata.title, 'Attention Is All You Need') + t.is(metadata.author, 'Ashish Vaswani') + t.is(metadata.publisher, 'arXiv') + t.is(metadata.date, '2017-06-01T00:00:00.000Z') + t.is(metadata.lang, 'en') + t.is( + metadata.logo, + 'https://www.google.com/s2/favicons?domain_url=https://arxiv.org/pdf/1706.03762v7&sz=128' + ) + t.true( + metadata.description.startsWith('The dominant sequence transduction models') + ) +}) + +test('skips the banner a working paper prints above its title', async t => { + const metascraper = createMetascraper(WORKING_PAPER) + const metadata = await metascraper({ + url: 'https://www.nber.org/system/files/working_papers/w31161/w31161.pdf' + }) + + t.is(metadata.title, 'GENERATIVE AI AT WORK') + t.is(metadata.author, 'Erik Brynjolfsson') + t.is(metadata.publisher, 'NBER') + t.is(metadata.lang, 'en') +}) + +test('reads the journal out of the running header', async t => { + const metascraper = createMetascraper(JOURNAL) + const metadata = await metascraper({ + url: 'https://www.frontiersin.org/articles/10.3389/fpsyg.2019.00001/pdf' + }) + + t.is(metadata.publisher, 'Frontiers in Psychology') + t.is(metadata.author, 'Miguel Clemente') + t.is( + metadata.title, + 'Institutional Violence Against Users of the Family Law Courts' + ) +}) + +test('fetches the PDF from the url', async t => { + const origin = await runServer(t, ({ res }) => { + res.setHeader('content-type', 'application/pdf') + res.end(PAPER) + }) + const metascraper = require('metascraper')([require('..')()]) + const metadata = await metascraper({ url: new URL('paper.pdf', origin).href }) + t.is(metadata.title, 'Attention Is All You Need') + t.is(metadata.author, 'Ashish Vaswani') +}) + +test('is a no-op without a PDF url', async t => { + const metascraper = require('metascraper')([require('..')()]) + const metadata = await metascraper({ + url: 'https://example.com', + html: '' + }) + + t.deepEqual(metadata, { + author: null, + date: null, + description: null, + image: null, + lang: null, + logo: null, + publisher: null, + title: null + }) +}) diff --git a/packages/metascraper-pdf/test/snapshots/fixtures.js.md b/packages/metascraper-pdf/test/snapshots/fixtures.js.md new file mode 100644 index 000000000..18a63b312 --- /dev/null +++ b/packages/metascraper-pdf/test/snapshots/fixtures.js.md @@ -0,0 +1,755 @@ +# Snapshot report for `test/fixtures.js` + +The actual snapshot is saved in `fixtures.js.snap`. + +Generated by [AVA](https://avajs.dev). + +## https://www.nber.org/system/files/working_papers/w28110/w28110.pdf + +> Snapshot 1 + + { + author: 'Bruce Sacerdote', + date: '2020-11-11T10:41:50.000Z', + description: 'We analyze the tone of COVID-19 related English-language news articles written since January 1, 2020. Ninety one percent of stories by U.S. major media outlets are negative in tone versus fifty four percent for non-U.S. major sources and sixty five percent for scientific journals.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://www.nber.org/system/files/working_papers/w28110/w28110.pdf&sz=128', + publisher: 'NBER', + title: 'WHY IS ALL COVID-19 NEWS BAD NEWS?', + } + +## https://www.cs.toronto.edu/~hinton/absps/NatureDeepReview.pdf + +> Snapshot 1 + + { + author: 'Yann LeCun', + date: '2015-05-21T14:57:35.000Z', + description: 'achine-learning technology powers many aspects of modern society: from web searches to content filtering on social networks to recommendations on e-commerce websites, and it is increasingly present in consumer products such as cameras and smartphones.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://www.cs.toronto.edu/~hinton/absps/NatureDeepReview.pdf&sz=128', + publisher: 'NATURE', + title: 'Deep learning', + } + +## https://proceedings.neurips.cc/paper/2012/file/c399862d3b9d6b76c8436e924a68c45b-Paper.pdf + +> Snapshot 1 + + { + author: 'Alex Krizhevsky', + date: '2012-01-01T00:00:00.000Z', + description: 'We trained a large, deep convolutional neural network to classify the 1.2 million high-resolution images in the ImageNet LSVRC-2010 contest into the 1000 different classes.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://proceedings.neurips.cc/paper/2012/file/c399862d3b9d6b76c8436e924a68c45b-Paper.pdf&sz=128', + publisher: 'NeurIPS', + title: 'ImageNet Classification with Deep Convolutional Neural Networks', + } + +## https://www.stat.berkeley.edu/~breiman/randomforest2001.pdf + +> Snapshot 1 + + { + author: 'Leo Breiman', + date: '2001-12-12T10:48:11.000Z', + description: 'Random forests are a combination of tree predictors such that each tree depends on the values of a random vector sampled independently and with the same distribution for all trees in the forest. The generalization error for forests converges a.s.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://www.stat.berkeley.edu/~breiman/randomforest2001.pdf&sz=128', + publisher: 'Berkeley', + title: 'RANDOM FORESTS', + } + +## https://homes.cs.washington.edu/~pedrod/papers/cacm12.pdf + +> Snapshot 1 + + { + author: null, + date: '2012-09-13T08:46:33.000Z', + description: 'is needed to successfully develop machine learning applications is not readily available in them. As a result, many machine learning projects take much longer than necessary or wind up producing less-than-ideal results. Yet much of this folk knowledge is fairly easy to communicate.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://homes.cs.washington.edu/~pedrod/papers/cacm12.pdf&sz=128', + publisher: 'communications of the acm', + title: 'a few useful things to Know about machine Learning', + } + +## https://arxiv.org/pdf/1706.03762v7 + +> Snapshot 1 + + { + author: 'Ashish Vaswani', + date: '2017-06-01T00:00:00.000Z', + description: 'The dominant sequence transduction models are based on complex recurrent or convolutional neural networks that include an encoder and a decoder. The best performing models also connect the encoder and decoder through an attention mechanism.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://arxiv.org/pdf/1706.03762v7&sz=128', + publisher: 'arXiv', + title: 'Attention Is All You Need', + } + +## https://journals.plos.org/plosone/article/file?id=10.1371/journal.pone.0230416&type=printable + +> Snapshot 1 + + { + author: 'Giovanni Colavizza', + date: '2020-04-21T09:07:32.000Z', + description: 'Efforts to make research results open and reproducible are increasingly reflected by journal policies encouraging or mandating authors to provide data availability statements. As a consequence of this, there has been a strong uptake of data availability statements in recent literature.', + image: 'data:image/png;base64…1810', + lang: 'en', + logo: 'data:image/png;base64…1810', + publisher: 'PLOS ONE', + title: 'The citation advantage of linking publications to research data', + } + +## https://aclanthology.org/N19-1423.pdf + +> Snapshot 1 + + { + author: 'Jacob Devlin', + date: '2019-04-29T17:36:03.000Z', + description: 'We introduce a new language representation model called BERT, which stands for Bidirectional Encoder Representations from Transformers.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://aclanthology.org/N19-1423.pdf&sz=128', + publisher: 'Proceedings of NAACL-HLT', + title: 'BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding', + } + +## https://jmlr.org/papers/volume15/srivastava14a/srivastava14a.pdf + +> Snapshot 1 + + { + author: 'Nitish Srivastava', + date: '2014-07-17T19:58:32.000Z', + description: 'Deep neural nets with a large number of parameters are very powerful machine learning systems. However, overfitting is a serious problem in such networks.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://jmlr.org/papers/volume15/srivastava14a/srivastava14a.pdf&sz=128', + publisher: 'JMLR', + title: 'Dropout: A Simple Way to Prevent Neural Networks from Overfitting', + } + +## https://journals.plos.org/plosone/article/file?id=10.1371/journal.pone.0000308&type=printable + +> Snapshot 1 + + { + author: 'Heather A. Piwowar', + date: '2007-03-06T20:02:06.000Z', + description: ', only trial design features such as size and clinical endpoint showed a significant association with citation rate; covariates relating to the data collection and how the data was made available only showed very weak trends.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://journals.plos.org/plosone/article/file?id=10.1371/journal.pone.0000308&type=printable&sz=128', + publisher: 'PLoS ONE', + title: 'Sharing Detailed Research Data Is Associated with Increased Citation Rate', + } + +## https://openaccess.thecvf.com/content_CVPR_2020/papers/He_Momentum_Contrast_for_Unsupervised_Visual_Representation_Learning_CVPR_2020_paper.pdf + +> Snapshot 1 + + { + author: 'Kaiming He', + date: '2020-01-01T00:00:00.000Z', + description: 'We present Momentum Contrast (MoCo) for unsupervised visual representation learning. From a perspective on contrastive learning [29] as dictionary look-up, we build a dynamic dictionary with a queue and a moving-averaged encoder.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://openaccess.thecvf.com/content_CVPR_2020/papers/He_Momentum_Contrast_for_Unsupervised_Visual_Representation_Learning_CVPR_2020_paper.pdf&sz=128', + publisher: 'CVF', + title: 'Momentum Contrast for Unsupervised Visual Representation Learning', + } + +## https://www.frontiersin.org/articles/10.3389/fpsyg.2019.00001/pdf + +> Snapshot 1 + + { + author: 'Miguel Clemente', + date: '2019-01-16T18:44:26.000Z', + description: 'The term harassment is often used to refer two contexts, the workplace and school, but not the legal system itself.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://www.frontiersin.org/articles/10.3389/fpsyg.2019.00001/pdf&sz=128', + publisher: 'Frontiers in Psychology', + title: 'Institutional Violence Against Users of the Family Law Courts and the Legal Harassment Scale', + } + +## https://www.nature.com/articles/s41598-023-28020-5.pdf + +> Snapshot 1 + + { + author: 'Loredana Bellantuono', + date: '2023-01-13T16:45:25.000Z', + description: 'The European Quality of Government Index (EQI) measures the perceived level of government quality by European Union citizens, combining surveys on corruption, impartiality and quality of provided services. It is, thus, an index based on individual subjective evaluations.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://www.nature.com/articles/s41598-023-28020-5.pdf&sz=128', + publisher: 'Scientific Reports', + title: 'Detecting the socio-economic drivers of confidence in government with eXplainable Artificial Intelligence', + } + +## https://link.springer.com/content/pdf/10.1186/s13059-020-02007-1.pdf + +> Snapshot 1 + + { + author: 'Qiang Zhang', + date: '2020-04-24T11:36:38.000Z', + description: 'Background: Influenza is a severe respiratory illness that continually threatens global health. It has been widely known that gut microbiota modulates the host response to protect against influenza infection, but mechanistic details remain largely unknown.', + image: 'data:image/png;base64…15142', + lang: 'en', + logo: 'data:image/png;base64…342', + publisher: 'Springer', + title: 'Influenza infection elicits an expansion of gut population of endogenous Bifidobacterium animalis which protects mice against infection', + } + +## https://www.nber.org/system/files/working_papers/w31161/w31161.pdf + +> Snapshot 1 + + { + author: 'Erik Brynjolfsson', + date: '2023-11-03T20:58:21.000Z', + description: 'New AI tools have the potential to change the way workers perform and learn, but little is known about their impacts on the job. In this paper, we study the staggered introduction of a generative AI-based conversational assistant using data from 5,179 customer support agents.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://www.nber.org/system/files/working_papers/w31161/w31161.pdf&sz=128', + publisher: 'NBER', + title: 'GENERATIVE AI AT WORK', + } + +## https://cdn.elifesciences.org/articles/00013/elife-00013-v1.pdf + +> Snapshot 1 + + { + author: 'Rosanna A Alegado', + date: '2012-10-05T16:47:37.000Z', + description: 'Bacterially-produced small molecules exert profound influences on animal health, morphogenesis, and evolution through poorly understood mechanisms.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://cdn.elifesciences.org/articles/00013/elife-00013-v1.pdf&sz=128', + publisher: 'eLife', + title: 'A bacterial sulfonolipid triggers multicellular development in the closest living relatives of animals', + } + +## https://aclanthology.org/2020.acl-main.703.pdf + +> Snapshot 1 + + { + author: 'Mike Lewis', + date: '2020-06-06T22:24:05.000Z', + description: 'We present BART, a denoising autoencoder for pretraining sequence-to-sequence models. BART is trained by (1) corrupting text with an arbitrary noising function, and (2) learning a model to reconstruct the original text.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://aclanthology.org/2020.acl-main.703.pdf&sz=128', + publisher: 'Proceedings of the 58th Annual Meeting of the Association for Computational Linguistics', + title: 'BART: Denoising Sequence-to-Sequence Pre-training for Natural Language Generation, Translation, and Comprehension', + } + +## https://arxiv.org/pdf/2303.08774v6 + +> Snapshot 1 + + { + author: 'OpenAI', + date: '2023-03-01T00:00:00.000Z', + description: 'We report the development of GPT-4, a large-scale, multimodal model which can accept image and text inputs and produce text outputs.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://arxiv.org/pdf/2303.08774v6&sz=128', + publisher: 'arXiv', + title: 'GPT-4 Technical Report', + } + +## https://www.redalyc.org/pdf/440/44029444004.pdf + +> Snapshot 1 + + { + author: 'Zayra Elisa Carvajal-Portuguez', + date: '2013-01-01T00:00:00.000Z', + description: 'Enseñanza del inglés en secundaria: una propuesta innovadora. Educación Carvajal-Portuguez, Zayra Elisa vol. 37, núm. 2, julio-diciembre, 2013, pp. 79-101 Universidad de Costa Rica San Pedro, Montes de Oca, Costa Rica', + image: 'data:image/png;base64…2574', + lang: 'es', + logo: 'data:image/png;base64…15010', + publisher: 'Redalyc', + title: 'Enseñanza del inglés en secundaria: una propuesta innovadora', + } + +## https://ceur-ws.org/Vol-2600/paper1.pdf + +> Snapshot 1 + + { + author: 'Sheikh Rabiul Islam', + date: '2020-05-05T13:01:56.000Z', + description: 'Artificial Intelligence (AI) has become an integral part of modern-day security solutions for its ability to learn very complex functions and handling “Big Data”.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://ceur-ws.org/Vol-2600/paper1.pdf&sz=128', + publisher: 'CEUR-WS', + title: 'Domain Knowledge Aided Explainable Artificial Intelligence for Intrusion Detection and Response', + } + +## https://arxiv.org/pdf/1512.03385v1 + +> Snapshot 1 + + { + author: 'Kaiming He', + date: '2015-12-01T00:00:00.000Z', + description: 'Deeper neural networks are more difficult to train. We present a residual learning framework to ease the training of networks that are substantially deeper than those used previously.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://arxiv.org/pdf/1512.03385v1&sz=128', + publisher: 'arXiv', + title: 'Deep Residual Learning for Image Recognition', + } + +## https://proceedings.neurips.cc/paper_files/paper/2022/file/b1efde53be364a73914f58805a001731-Paper-Conference.pdf + +> Snapshot 1 + + { + author: 'Long Ouyang', + date: '2022-01-01T00:00:00.000Z', + description: 'Making language models bigger does not inherently make them better at following a user’s intent. For example, large language models can generate outputs that are untruthful, toxic, or simply not helpful to the user. In other words, these models are not aligned with their users.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://proceedings.neurips.cc/paper_files/paper/2022/file/b1efde53be364a73914f58805a001731-Paper-Conference.pdf&sz=128', + publisher: '36th Conference on Neural Information Processing Systems (NeurIPS )', + title: 'Training language models to follow instructions with human feedback', + } + +## https://journals.plos.org/plosbiology/article/file?id=10.1371/journal.pbio.3000410&type=printable + +> Snapshot 1 + + { + author: 'Nathalie Percie du Sert', + date: '2020-07-11T10:27:33.000Z', + description: 'Reproducible science requires transparent reporting. The ARRIVE guidelines (Animal Research: Reporting of In Vivo Experiments) were originally developed in 2010 to improve the reporting of animal research.', + image: 'data:image/png;base64…1810', + lang: 'en', + logo: 'data:image/png;base64…1810', + publisher: 'PLOS BIOLOGY', + title: 'The ARRIVE guidelines 2.0: Updated guidelines for reporting animal research', + } + +## https://arxiv.org/pdf/2005.14165v4 + +> Snapshot 1 + + { + author: 'Tom B. Brown', + date: '2020-05-01T00:00:00.000Z', + description: 'Recent work has demonstrated substantial gains on many NLP tasks and benchmarks by pre-training on a large corpus of text followed by fine-tuning on a specific task.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://arxiv.org/pdf/2005.14165v4&sz=128', + publisher: 'arXiv', + title: 'Language Models are Few-Shot Learners', + } + +## https://arxiv.org/pdf/1412.6980v9 + +> Snapshot 1 + + { + author: 'Diederik P. Kingma', + date: '2014-12-01T00:00:00.000Z', + description: 'We introduce Adam, an algorithm for first-order gradient-based optimization of stochastic objective functions, based on adaptive estimates of lower-order moments.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://arxiv.org/pdf/1412.6980v9&sz=128', + publisher: 'arXiv', + title: 'Adam: A Method for Stochastic Optimization', + } + +## https://arxiv.org/pdf/2010.11929v2 + +> Snapshot 1 + + { + author: 'Alexey Dosovitskiy', + date: '2020-10-01T00:00:00.000Z', + description: 'While the Transformer architecture has become the de-facto standard for natural language processing tasks, its applications to computer vision remain limited.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://arxiv.org/pdf/2010.11929v2&sz=128', + publisher: 'arXiv', + title: 'AN IMAGE IS WORTH 16X16 WORDS: TRANSFORMERS FOR IMAGE RECOGNITION AT SCALE', + } + +## https://arxiv.org/pdf/1810.04805v2 + +> Snapshot 1 + + { + author: 'Jacob Devlin', + date: '2018-10-01T00:00:00.000Z', + description: 'We introduce a new language representation model called BERT, which stands for Bidirectional Encoder Representations from Transformers.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://arxiv.org/pdf/1810.04805v2&sz=128', + publisher: 'arXiv', + title: 'BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding', + } + +## https://arxiv.org/pdf/2203.02155v1 + +> Snapshot 1 + + { + author: 'Long Ouyang', + date: '2022-03-01T00:00:00.000Z', + description: 'Making language models bigger does not inherently make them better at following a user’s intent. For example, large language models can generate outputs that are untruthful, toxic, or simply not helpful to the user. In other words, these models are not aligned with their users.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://arxiv.org/pdf/2203.02155v1&sz=128', + publisher: 'arXiv', + title: 'Training language models to follow instructions with human feedback', + } + +## https://arxiv.org/pdf/cs/0102004v1 + +> Snapshot 1 + + { + author: 'Joseph O’Rourke', + date: '2001-02-01T00:00:00.000Z', + description: 'The recent result that n congruent balls in R have at most 4 distinct geometric permutations is described. A line ℓ stabs a set S of geometric objects in R if ℓ intersects every member of S. Such a stabber is often called a line traversal of S.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://arxiv.org/pdf/cs/0102004v1&sz=128', + publisher: 'arXiv', + title: 'Computational Geometry Column 41', + } + +## https://arxiv.org/pdf/2104.08663v4 + +> Snapshot 1 + + { + author: 'Nandan Thakur', + date: '2021-04-01T00:00:00.000Z', + description: 'Existing neural information retrieval (IR) models have often been studied in homogeneous and narrow settings, which has considerably limited insights into their out-of-distribution (OOD) generalization capabilities.', + image: 'data:image/png;base64…11398', + lang: 'en', + logo: 'data:image/png;base64…11398', + publisher: 'arXiv', + title: 'BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models', + } + +## https://www.jmlr.org/papers/volume12/pedregosa11a/pedregosa11a.pdf + +> Snapshot 1 + + { + author: 'Fabian Pedregosa', + date: '2011-10-11T14:45:40.000Z', + description: 'Scikit-learn is a Python module integrating a wide range of state-of-the-art machine learning algorithms for medium-scale supervised and unsupervised problems. This package focuses on bringing machine learning to non-specialists using a general-purpose high-level language.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://www.jmlr.org/papers/volume12/pedregosa11a/pedregosa11a.pdf&sz=128', + publisher: 'JMLR', + title: 'Scikit-learn: Machine Learning in Python', + } + +## https://www.jmlr.org/papers/volume13/bergstra12a/bergstra12a.pdf + +> Snapshot 1 + + { + author: 'James Bergstra', + date: '2012-02-16T16:46:50.000Z', + description: 'Grid search and manual search are the most widely used strategies for hyper-parameter optimization. This paper shows empirically and theoretically that randomly chosen trials are more efficient for hyper-parameter optimization than trials on a grid.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://www.jmlr.org/papers/volume13/bergstra12a/bergstra12a.pdf&sz=128', + publisher: 'JMLR', + title: 'Random Search for Hyper-Parameter Optimization', + } + +## https://openaccess.thecvf.com/content_cvpr_2016/papers/He_Deep_Residual_Learning_CVPR_2016_paper.pdf + +> Snapshot 1 + + { + author: 'Kaiming He', + date: '2016-01-01T00:00:00.000Z', + description: 'Deeper neural networks are more difficult to train. We present a residual learning framework to ease the training of networks that are substantially deeper than those used previously.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://openaccess.thecvf.com/content_cvpr_2016/papers/He_Deep_Residual_Learning_CVPR_2016_paper.pdf&sz=128', + publisher: 'CVF', + title: 'Deep Residual Learning for Image Recognition', + } + +## https://openaccess.thecvf.com/content_ICCV_2017/papers/He_Mask_R-CNN_ICCV_2017_paper.pdf + +> Snapshot 1 + + { + author: 'Kaiming He', + date: '2017-01-01T00:00:00.000Z', + description: 'We present a conceptually simple, flexible, and general framework for object instance segmentation. Our approach efficiently detects objects in an image while simultaneously generating a high-quality segmentation mask for each instance.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://openaccess.thecvf.com/content_ICCV_2017/papers/He_Mask_R-CNN_ICCV_2017_paper.pdf&sz=128', + publisher: 'CVF', + title: 'Mask R-CNN', + } + +## https://aclanthology.org/D19-1410.pdf + +> Snapshot 1 + + { + author: 'Nils Reimers', + date: '2019-08-27T10:41:10.000Z', + description: 'BERT (Devlin et al., 2018) and RoBERTa (Liu et al., 2019) has set a new state-of-the-art performance on sentence-pair regression tasks like semantic textual similarity (STS).', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://aclanthology.org/D19-1410.pdf&sz=128', + publisher: 'Proceedings of the Conference on Empirical Methods in Natural Language Processing', + title: 'Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks', + } + +## https://aclanthology.org/P16-1162.pdf + +> Snapshot 1 + + { + author: 'Rico Sennrich', + date: '2016-07-20T19:45:13.000Z', + description: 'Neural machine translation (NMT) models typically operate with a fixed vocabulary, but translation is an open-vocabulary problem. Previous work addresses the translation of out-of-vocabulary words by backing off to a dictionary.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://aclanthology.org/P16-1162.pdf&sz=128', + publisher: 'Proceedings of the 54th Annual Meeting of the Association for Computational Linguistics', + title: 'Neural Machine Translation of Rare Words with Subword Units', + } + +## https://proceedings.neurips.cc/paper/2015/file/14bfa6bb14875e45bba028a21ed38046-Paper.pdf + +> Snapshot 1 + + { + author: 'Shaoqing Ren', + date: '2015-01-01T00:00:00.000Z', + description: 'State-of-the-art object detection networks depend on region proposal algorithms to hypothesize object locations. Advances like SPPnet [7] and Fast R-CNN [5] have reduced the running time of these detection networks, exposing region proposal computation as a bottleneck.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://proceedings.neurips.cc/paper/2015/file/14bfa6bb14875e45bba028a21ed38046-Paper.pdf&sz=128', + publisher: 'NeurIPS', + title: 'Faster R-CNN: Towards Real-Time Object Detection with Region Proposal Networks', + } + +## https://www.nber.org/system/files/working_papers/w26947/w26947.pdf + +> Snapshot 1 + + { + author: 'Titan Alon', + date: '2020-04-01T18:28:10.000Z', + description: 'The economic downturn caused by the current COVID-19 outbreak has substantial implications for gender equality, both during the downturn and the subsequent recovery.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://www.nber.org/system/files/working_papers/w26947/w26947.pdf&sz=128', + publisher: 'NBER', + title: 'THE IMPACT OF COVID-19 ON GENDER EQUALITY', + } + +## https://www.nber.org/system/files/working_papers/w30957/w30957.pdf + +> Snapshot 1 + + { + author: 'Anton Korinek', + date: '2023-02-10T10:18:13.000Z', + description: 'Large language models (LLMs) such as ChatGPT have the potential to revolutionize research in economics and other disciplines.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://www.nber.org/system/files/working_papers/w30957/w30957.pdf&sz=128', + publisher: 'NBER', + title: 'LANGUAGE MODELS AND COGNITIVE AUTOMATION FOR ECONOMIC RESEARCH', + } + +## https://journals.plos.org/plosmedicine/article/file?id=10.1371/journal.pmed.1003583&type=printable + +> Snapshot 1 + + { + author: 'Matthew J. Page', + date: '2021-03-24T15:01:13.000Z', + description: 'points complete, and accurate account of why the review was done, what they did, and what they found PLOS MEDICINE (UG1EY020522), National Institutes of Health, United States. LAM is supported by a National Institute for Health Research Doctoral Research Fellowship (DRF-2018-11-ST2-048).', + image: 'data:image/png;base64…1810', + lang: 'en', + logo: 'data:image/png;base64…1810', + publisher: 'PLOS Medicine', + title: 'The PRISMA 2020 statement: An updated guideline for reporting systematic reviews', + } + +## https://journals.plos.org/ploscompbiol/article/file?id=10.1371/journal.pcbi.1005510&type=printable + +> Snapshot 1 + + { + author: 'Greg Wilson', + date: '2017-06-21T14:38:26.000Z', + description: 'Computers are now essential in all branches of science, but most researchers are never taught the equivalent of basic lab skills for research computing.', + image: 'data:image/png;base64…1070', + lang: 'en', + logo: 'data:image/png;base64…1070', + publisher: 'PLOS Computational Biology', + title: 'Good enough practices in scientific computing', + } + +## https://cdn.elifesciences.org/articles/57443/elife-57443-v2.pdf + +> Snapshot 1 + + { + author: 'Louis K. Scheffer', + date: '2020-09-03T01:24:34.000Z', + description: 'The neural circuits responsible for animal behavior remain largely unknown. We33 summarize new methods and present the circuitry of a large fraction of the brain of the fruit fly34 Drosophila melanogaster.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://cdn.elifesciences.org/articles/57443/elife-57443-v2.pdf&sz=128', + publisher: 'eLife', + title: 'A Connectome and Analysis of the Adult Drosophila Central Brain', + } + +## https://www.frontiersin.org/articles/10.3389/fnins.2020.00001/pdf + +> Snapshot 1 + + { + author: 'Yu Xie', + date: '2020-01-21T11:32:46.000Z', + description: 'Networks, such as social networks, biochemical networks, and protein-protein interaction networks are ubiquitous in the real world.', + image: 'data:image/png;base64…1610', + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://www.frontiersin.org/articles/10.3389/fnins.2020.00001/pdf&sz=128', + publisher: 'Frontiers in Neuroscience', + title: 'Multi-Task Network Representation Learning', + } + +## https://www.nature.com/articles/s41598-020-69250-1.pdf + +> Snapshot 1 + + { + author: 'Micah J. Sheller', + date: '2020-07-22T12:59:35.000Z', + description: 'Several studies underscore the potential of deep learning in identifying complex patterns, leading to diagnostic and prognostic biomarkers.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://www.nature.com/articles/s41598-020-69250-1.pdf&sz=128', + publisher: 'Scientific RepoRtS', + title: 'Federated learning in medicine: facilitating multi-institutional collaborations without sharing patient data', + } + +## https://www.nature.com/articles/s41467-020-17419-7.pdf + +> Snapshot 1 + + { + author: 'Jonathan G. Richens', + date: '2021-03-30T18:30:12.000Z', + description: 'Machine learning promises to revolutionize clinical decision making and diagnosis. In medical diagnosis a doctor aims to explain a patient’s symptoms by determining the diseases causing them.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://www.nature.com/articles/s41467-020-17419-7.pdf&sz=128', + publisher: 'NATURE COMMUNICATIONS', + title: 'Improving the accuracy of medical diagnosis with causal machine learning', + } + +## https://link.springer.com/content/pdf/10.1186/s12874-020-01057-0.pdf + +> Snapshot 1 + + { + author: 'Helen Le Sueur', + date: '2020-06-23T22:17:55.000Z', + description: 'Background: Individual clinical trials and cohort studies are a useful source of data, often under-utilised once a study has ended.', + image: 'data:image/png;base64…22214', + lang: 'en', + logo: 'data:image/png;base64…302', + publisher: 'Springer', + title: 'The challenges in data integration – heterogeneity and complexity in clinical trials and patient registries of Systemic Lupus Erythematosus', + } + +## https://ceur-ws.org/Vol-3226/paper1.pdf + +> Snapshot 1 + + { + author: 'Josef Zelinka', + date: '2022-09-08T22:32:41.000Z', + description: 'For autonomous robots operating in an unknown environment, it is important to assess the traversability of the surrounding terrain to improve path planning and decision-making on where to navigate next in a cost-efficient way.', + image: 'data:image/png;base64…1658', + lang: 'en', + logo: 'data:image/png;base64…26950', + publisher: 'CEUR-WS', + title: 'Deep Transfer Learning of Traversability Assessment for Heterogeneous Robots', + } + +## https://www.redalyc.org/pdf/567/56712871011.pdf + +> Snapshot 1 + + { + author: 'Begoña Martínez Domínguez', + date: '2009-01-01T00:00:00.000Z', + description: 'UNA OPORTUNIDAD PARA QUE JÓVENES QUE FRACASAN EN LA ESCUELA PUEDAN SALIR DE LA ZONA DE RIESGO DE EXCLUSIÓN. LA EXPERIENCIA DE LOS CENTROS DE INICIACIÓN PROFESIONAL EN LA COMUNIDAD AUTÓNOMA VASCA. Profesorado. Revista de Currículum y Formación de Profesorado Martínez Domínguez, Begoña; Mendizábal Ituarte, Amaia; Sostoa Gaztelu-Urrutia, Virginia P. vol. 13, núm. 3, 2009, pp. 239-271 Universidad de Granada Granada, España', + image: 'data:image/png;base64…2574', + lang: 'es', + logo: 'data:image/png;base64…21742', + publisher: 'Redalyc', + title: 'UNA OPORTUNIDAD PARA QUE JÓVENES QUE FRACASAN EN LA ESCUELA PUEDAN SALIR DE LA ZONA DE RIESGO DE EXCLUSIÓN. LA EXPERIENCIA DE LOS CENTROS DE INICIACIÓN PROFESIONAL EN LA COMUNIDAD AUTÓNOMA VASCA', + } + +## https://bmcbioinformatics.biomedcentral.com/counter/pdf/10.1186/s12859-020-3418-9.pdf + +> Snapshot 1 + + { + author: 'Lucile Mégret', + date: '2020-02-24T10:45:03.000Z', + description: 'Background: MicroRNA (miRNA) regulation is associated with several diseases, including neurodegenerative diseases. Several approaches can be used for modeling miRNA regulation. However, their precision may be limited for analyzing multidimensional data.', + image: 'data:image/png;base64…34334', + lang: 'en', + logo: 'data:image/png;base64…302', + publisher: 'BMC', + title: 'Combining feature selection and shape analysis uncovers precise rules for miRNA regulation in Huntington’s disease mice', + } + +## https://www.bis.org/publ/work1000.pdf + +> Snapshot 1 + + { + author: 'Valentina Bruno', + date: '2022-02-15T08:11:00.000Z', + description: 'by Valentina Bruno, Ilhyock Shim and Hyun Song Shin February 2022 JEL classification: G12, G15, G23. Keywords: global liquidity, pricing factor, emerging market, exchange rate.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https://www.bis.org/publ/work1000.pdf&sz=128', + publisher: 'BIS', + title: 'Dollar beta and stock returns', + } diff --git a/packages/metascraper-pdf/test/snapshots/fixtures.js.snap b/packages/metascraper-pdf/test/snapshots/fixtures.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..fdb1a8296e59d7d5b6d1ed838231c13aa928aca4 GIT binary patch literal 11318 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 literal 0 HcmV?d00001 diff --git a/packages/metascraper-pdf/test/unit.js b/packages/metascraper-pdf/test/unit.js new file mode 100644 index 000000000..e191906aa --- /dev/null +++ b/packages/metascraper-pdf/test/unit.js @@ -0,0 +1,207 @@ +'use strict' + +const test = require('ava').default + +const { identifierDate, documentYear } = require('../src/date') +const { getDescription } = require('../src/description') +const { getTitle, isByline } = require('../src/title') +const { isBannerLine, isPersonName, splitNamePairs } = require('../src/text') +const { + publisherFromLine, + publisherFromUrl, + isVenue +} = require('../src/publisher') +const { readEmbedded } = require('../src/embedded') +const { toAuthor } = require('../src/author') +const { getLang } = require('../src/lang') +const { getMedia } = require('../src/media') + +const line = (index, size, text) => ({ + index, + pageIndex: index, + size, + text, + y: 700 - index * 12 +}) + +test('a heading, a company and a place are not authors', t => { + t.true(isPersonName('Yoshua Bengio')) + t.true(isPersonName('Rosanna A Alegado')) + t.true(isPersonName('Carvajal-Portuguez, Zayra Elisa')) + t.false(isPersonName('RANDOM FORESTS')) + t.false(isPersonName('Google AI Language')) + t.false(isPersonName('Cookeville, U.S')) + t.false(isPersonName('Costa Rica')) + t.false(isPersonName('A Few Useful Things to Know')) +}) + +test('publishers print banners above the title', t => { + t.true(isBannerLine('NBER WORKING PAPER SERIES')) + t.true(isBannerLine('Working Paper 31161')) + t.true(isBannerLine('arXiv:cs/0102004v1 [cs.CG] 6 Feb 2001')) + t.true(isBannerLine('REVIEW')) + t.false(isBannerLine('Deep learning')) +}) + +test('a title wraps, a byline repeats', t => { + const wrapped = [ + line(0, 17, 'AN IMAGE IS WORTH 16X16 WORDS:'), + line(1, 17, 'TRANSFORMERS FOR IMAGE RECOGNITION AT SCALE'), + line(2, 10, 'Alexey Dosovitskiy') + ] + t.is( + getTitle(wrapped).text, + 'AN IMAGE IS WORTH 16X16 WORDS: TRANSFORMERS FOR IMAGE RECOGNITION AT SCALE' + ) + + const cover = [ + line(0, 12, 'NBER WORKING PAPER SERIES'), + line(1, 12, 'GENERATIVE AI AT WORK'), + line(2, 12, 'Erik Brynjolfsson'), + line(3, 12, 'Danielle Li') + ] + t.is(getTitle(cover).text, 'GENERATIVE AI AT WORK') + t.true(isByline(cover[2], cover)) + t.false(isByline(cover[1], cover)) +}) + +test('a byline survives emails, superscripts and editors', t => { + t.is( + toAuthor([line(0, 10, 'Nitish Srivastava nitish@cs.toronto.edu')], [0]), + 'Nitish Srivastava' + ) + t.is( + toAuthor( + [line(0, 10, 'Loredana Bellantuono1,2, Flaviana Palmisano3')], + [0] + ), + 'Loredana Bellantuono, Flaviana Palmisano' + ) + t.is(toAuthor([line(0, 10, 'Editor: Yoshua Bengio')], [0]), null) + t.deepEqual(splitNamePairs('Jacob Devlin Ming-Wei Chang Kenton Lee'), [ + 'Jacob Devlin', + 'Ming-Wei Chang', + 'Kenton Lee' + ]) + t.deepEqual(splitNamePairs('Deep Residual Learning'), []) +}) + +test('a venue is read out of the running header', t => { + t.is( + publisherFromLine( + 'Scientific Reports | (2023) 13:1234 | https://doi.org/10.1038/x' + ), + 'Scientific Reports' + ) + t.is( + publisherFromLine('4 3 6 | N A T U R E | V O L 5 2 1 | 2 8 M A Y 2 0 1 5'), + 'NATURE' + ) + t.is(publisherFromUrl('https://cdn.openai.com/papers/gpt-4.pdf'), 'Openai') + t.is(publisherFromUrl('https://arxiv.org/pdf/1706.03762v7'), 'arXiv') + t.is(publisherFromUrl('https://www.nber.org/papers/w31161.pdf'), 'NBER') + t.true( + isVenue('Frontiers in Psychology', 'https://www.frontiersin.org/x/pdf') + ) + t.false(isVenue('official NBER publications', 'https://www.nber.org/x.pdf')) +}) + +test('the identifier dates the document', t => { + t.is( + identifierDate('https://arxiv.org/pdf/1706.03762v7'), + '2017-06-01T00:00:00.000Z' + ) + t.is( + identifierDate('https://arxiv.org/pdf/cs/0102004v1'), + '2001-02-01T00:00:00.000Z' + ) + t.is( + identifierDate('https://proceedings.neurips.cc/paper/2012/file/x.pdf'), + '2012-01-01T00:00:00.000Z' + ) + t.is(identifierDate('https://bitcoin.org/bitcoin.pdf'), null) + t.is( + documentYear( + [line(0, 10, 'Published as a conference paper at ICLR 2021')], + { now: new Date('2026-01-01') } + ), + 2021 + ) +}) + +test('the description is the abstract, never the running header', t => { + const lines = [ + line(0, 9, 'Frontiers in Psychology | Volume 10 | Article 1'), + line(1, 14, 'Institutional Violence'), + line(2, 10, 'Abstract'), + line( + 3, + 10, + 'This work analyses the psychological consequences of institutional violence in family law courts.' + ) + ] + + t.true(getDescription(lines).startsWith('This work analyses')) +}) + +test('generator noise never reaches a property', t => { + const embedded = readEmbedded({ + info: { + Title: 'Microsoft Word - final draft.docx', + Author: 'Windows User', + Subject: '2017 IEEE International Conference on Computer Vision', + Creator: 'Acrobat' + } + }) + + t.is(embedded.title, null) + t.is(embedded.author, null) + t.is(embedded.description, null) +}) + +test('a journal name plus a doi is not a description', t => { + const embedded = readEmbedded({ + info: { + Subject: 'Genome Biology, 2020, doi:10.1186/s13059-020-02007-1' + } + }) + t.is(embedded.description, null) +}) + +test('lang comes from the host or the words on the page', t => { + t.is( + getLang( + 'We study the staggered introduction of a generative AI assistant', + { + url: 'https://example.com/paper.pdf' + } + ), + 'en' + ) + t.is( + getLang( + 'Enseñanza del inglés en secundaria para los estudiantes de la escuela', + { + url: 'https://www.redalyc.org/pdf/x.pdf' + } + ), + 'es' + ) +}) + +test('a small square image is the logo; otherwise the host favicon', t => { + const pixels = Buffer.alloc(16 * 16 * 3, 80) + const { image, logo } = getMedia( + [{ width: 16, height: 16, channels: 3, data: pixels }], + { url: 'https://journals.plos.org/article.pdf' } + ) + t.true(image.startsWith('data:image/png;base64,')) + t.true(logo.startsWith('data:image/png;base64,')) + + const fallback = getMedia([], { url: 'https://arxiv.org/pdf/x' }) + t.is(fallback.image, null) + t.is( + fallback.logo, + 'https://www.google.com/s2/favicons?domain_url=https://arxiv.org/pdf/x&sz=128' + ) +}) From ed19119f74085181714b4da6c411ae8dfbca0070 Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Sat, 22 Aug 2026 15:49:20 +0200 Subject: [PATCH 2/7] fix(metascraper-pdf): count inverted names as one Comma-split author counts treated "Doe, Jane" as two people and let a thin embedded byline win. Co-authored-by: Cursor --- packages/metascraper-pdf/README.md | 6 +- packages/metascraper-pdf/src/author.js | 12 ++- packages/metascraper-pdf/src/description.js | 8 +- packages/metascraper-pdf/src/index.d.ts | 2 +- packages/metascraper-pdf/src/media.js | 4 +- packages/metascraper-pdf/test/fixtures.js | 5 +- .../metascraper-pdf/test/fixtures/download.sh | 1 + packages/metascraper-pdf/test/index.js | 4 +- .../test/snapshots/fixtures.js.md | 78 +++++++++--------- .../test/snapshots/fixtures.js.snap | Bin 11318 -> 11584 bytes packages/metascraper-pdf/test/unit.js | 17 +++- 11 files changed, 82 insertions(+), 55 deletions(-) diff --git a/packages/metascraper-pdf/README.md b/packages/metascraper-pdf/README.md index 72cb98ce2..43bd7382d 100644 --- a/packages/metascraper-pdf/README.md +++ b/packages/metascraper-pdf/README.md @@ -37,7 +37,7 @@ const metadata = await metascraper({ // } ``` -The bundle is a no-op unless [`test`](#test) sees a PDF URL, so it is safe to mix with the HTML rules: +The bundle is a no-op unless [`.test()`](#testprops) sees a PDF URL, so it is safe to mix with the HTML rules: ```js const metascraper = require('metascraper')([ @@ -87,13 +87,13 @@ page; the extra page feeds the description when a document has no abstract. Type: `object` -Any option provided here will passed to [got#options](https://github.com/sindresorhus/got#options). +Any option provided here will be passed to [got#options](https://github.com/sindresorhus/got#options). ##### keyvOpts Type: `object` -Any option provided here will passed to [@keyvhq/memoize#options](https://github.com/microlinkhq/keyv/tree/master/packages/memoize#keyvoptions). +Any option provided here will be passed to [@keyvhq/memoize#options](https://github.com/microlinkhq/keyv/tree/master/packages/memoize#keyvoptions). ##### getPdf diff --git a/packages/metascraper-pdf/src/author.js b/packages/metascraper-pdf/src/author.js index 49c64970f..354e0ae43 100644 --- a/packages/metascraper-pdf/src/author.js +++ b/packages/metascraper-pdf/src/author.js @@ -5,6 +5,7 @@ const { PLACE_NAME, flatten, isBannerLine, + isInvertedName, isPersonName, splitNamePairs, splitNames, @@ -124,8 +125,15 @@ const expandAuthorLines = (lines, indexes, { titleIndexes = [] } = {}) => { .map(line => line.index) } -const nameCount = value => - value ? value.split(/[,;]| and /).filter(Boolean).length : 0 +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(/,| and /).filter(Boolean).length + }, 0) +} const getAuthor = (lines, { titleIndexes = [] } = {}) => { const titleIndex = diff --git a/packages/metascraper-pdf/src/description.js b/packages/metascraper-pdf/src/description.js index 36fca4c53..0f3b9d71b 100644 --- a/packages/metascraper-pdf/src/description.js +++ b/packages/metascraper-pdf/src/description.js @@ -2,12 +2,14 @@ const ABSTRACT_HEADING = /(?:^|\n)\s*(?:abstract|summary|executive summary)\b[.:—-]?\s*/i -const RUNNING_HEADER = /[|·•‖]|^\d+\s|\bdoi:/i +const NEXT_SECTION = + /(?:^|\n)\s*(?:(?:\d+|[ivxlc]+)\.?\s+)?(?:introduction|keywords|contents|table of contents|references|acknowledgements?)\b/i +const RUNNING_HEADER = /[|·•‖]|^\d+\s*$|\bdoi:/i const SENTENCE_END = /(?<=[.!?])\s+/ const MAX_SUMMARY_LENGTH = 300 const MIN_SUMMARY_LENGTH = 40 -const dehyphenate = text => text.replace(/([a-z])-\s+([a-z])/g, '$1$2') +const dehyphenate = text => text.replace(/(\p{Ll})-\s+(\p{Ll})/gu, '$1$2') /** The size that carries most of the page is the body text, not the furniture. */ const dominantSize = lines => { @@ -45,7 +47,7 @@ const getDescription = (lines, { maxLength = MAX_SUMMARY_LENGTH } = {}) => { const bodySize = dominantSize(readable) const body = abstract - ? text.slice(abstract.index + abstract[0].length) + ? text.slice(abstract.index + abstract[0].length).split(NEXT_SECTION)[0] : readable .filter(line => line.size === bodySize) .map(line => line.text) diff --git a/packages/metascraper-pdf/src/index.d.ts b/packages/metascraper-pdf/src/index.d.ts index 99c99eee1..bc513db34 100644 --- a/packages/metascraper-pdf/src/index.d.ts +++ b/packages/metascraper-pdf/src/index.d.ts @@ -20,7 +20,7 @@ declare namespace rules { /** * Called to get the PDF bytes behind `url`. Defaults to a `got` download. */ - getPdf?: (url: string) => Buffer | Uint8Array | Promise; + getPdf?: (url: string) => Buffer | Uint8Array | null | undefined | Promise; } /** `true` when `url` points at a PDF document. */ diff --git a/packages/metascraper-pdf/src/media.js b/packages/metascraper-pdf/src/media.js index 29e47c29f..6ed5793d1 100644 --- a/packages/metascraper-pdf/src/media.js +++ b/packages/metascraper-pdf/src/media.js @@ -70,7 +70,9 @@ const isLogo = img => img.width / img.height <= 2.5 const favicon = url => - `https://www.google.com/s2/favicons?domain_url=${url}&sz=128` + `https://www.google.com/s2/favicons?domain_url=${encodeURIComponent( + url + )}&sz=128` const getMedia = (images, { url } = {}) => { const candidates = usable(images) diff --git a/packages/metascraper-pdf/test/fixtures.js b/packages/metascraper-pdf/test/fixtures.js index 7da4cda81..c0deacc2f 100644 --- a/packages/metascraper-pdf/test/fixtures.js +++ b/packages/metascraper-pdf/test/fixtures.js @@ -33,9 +33,10 @@ const summarize = metadata => { } } +const run = skipReason ? test.skip : test + for (const url of urls) { - test(url, async t => { - if (skipReason) return t.pass() + run(url, async t => { t.snapshot(summarize(await metascraper({ url }))) }) } diff --git a/packages/metascraper-pdf/test/fixtures/download.sh b/packages/metascraper-pdf/test/fixtures/download.sh index 5d4e0ea53..f4bcada62 100755 --- a/packages/metascraper-pdf/test/fixtures/download.sh +++ b/packages/metascraper-pdf/test/fixtures/download.sh @@ -1 +1,2 @@ +cd "$(dirname "$0")" nl -ba urls.txt | xargs -n 2 -P 8 sh -c 'curl -fL --retry 3 --retry-all-errors -o "$0.pdf" "$1"' diff --git a/packages/metascraper-pdf/test/index.js b/packages/metascraper-pdf/test/index.js index e63504caf..3cd0f7bc7 100644 --- a/packages/metascraper-pdf/test/index.js +++ b/packages/metascraper-pdf/test/index.js @@ -89,7 +89,9 @@ test('reads the metadata of a paper', async t => { t.is(metadata.lang, 'en') t.is( metadata.logo, - 'https://www.google.com/s2/favicons?domain_url=https://arxiv.org/pdf/1706.03762v7&sz=128' + `https://www.google.com/s2/favicons?domain_url=${encodeURIComponent( + 'https://arxiv.org/pdf/1706.03762v7' + )}&sz=128` ) t.true( metadata.description.startsWith('The dominant sequence transduction models') diff --git a/packages/metascraper-pdf/test/snapshots/fixtures.js.md b/packages/metascraper-pdf/test/snapshots/fixtures.js.md index 18a63b312..0f514cfc1 100644 --- a/packages/metascraper-pdf/test/snapshots/fixtures.js.md +++ b/packages/metascraper-pdf/test/snapshots/fixtures.js.md @@ -14,7 +14,7 @@ Generated by [AVA](https://avajs.dev). description: 'We analyze the tone of COVID-19 related English-language news articles written since January 1, 2020. Ninety one percent of stories by U.S. major media outlets are negative in tone versus fifty four percent for non-U.S. major sources and sixty five percent for scientific journals.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://www.nber.org/system/files/working_papers/w28110/w28110.pdf&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Fwww.nber.org%2Fsystem%2Ffiles%2Fworking_papers%2Fw28110%2Fw28110.pdf&sz=128', publisher: 'NBER', title: 'WHY IS ALL COVID-19 NEWS BAD NEWS?', } @@ -29,7 +29,7 @@ Generated by [AVA](https://avajs.dev). description: 'achine-learning technology powers many aspects of modern society: from web searches to content filtering on social networks to recommendations on e-commerce websites, and it is increasingly present in consumer products such as cameras and smartphones.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://www.cs.toronto.edu/~hinton/absps/NatureDeepReview.pdf&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Fwww.cs.toronto.edu%2F~hinton%2Fabsps%2FNatureDeepReview.pdf&sz=128', publisher: 'NATURE', title: 'Deep learning', } @@ -44,7 +44,7 @@ Generated by [AVA](https://avajs.dev). description: 'We trained a large, deep convolutional neural network to classify the 1.2 million high-resolution images in the ImageNet LSVRC-2010 contest into the 1000 different classes.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://proceedings.neurips.cc/paper/2012/file/c399862d3b9d6b76c8436e924a68c45b-Paper.pdf&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Fproceedings.neurips.cc%2Fpaper%2F2012%2Ffile%2Fc399862d3b9d6b76c8436e924a68c45b-Paper.pdf&sz=128', publisher: 'NeurIPS', title: 'ImageNet Classification with Deep Convolutional Neural Networks', } @@ -59,7 +59,7 @@ Generated by [AVA](https://avajs.dev). description: 'Random forests are a combination of tree predictors such that each tree depends on the values of a random vector sampled independently and with the same distribution for all trees in the forest. The generalization error for forests converges a.s.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://www.stat.berkeley.edu/~breiman/randomforest2001.pdf&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Fwww.stat.berkeley.edu%2F~breiman%2Frandomforest2001.pdf&sz=128', publisher: 'Berkeley', title: 'RANDOM FORESTS', } @@ -74,7 +74,7 @@ Generated by [AVA](https://avajs.dev). description: 'is needed to successfully develop machine learning applications is not readily available in them. As a result, many machine learning projects take much longer than necessary or wind up producing less-than-ideal results. Yet much of this folk knowledge is fairly easy to communicate.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://homes.cs.washington.edu/~pedrod/papers/cacm12.pdf&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Fhomes.cs.washington.edu%2F~pedrod%2Fpapers%2Fcacm12.pdf&sz=128', publisher: 'communications of the acm', title: 'a few useful things to Know about machine Learning', } @@ -89,7 +89,7 @@ Generated by [AVA](https://avajs.dev). description: 'The dominant sequence transduction models are based on complex recurrent or convolutional neural networks that include an encoder and a decoder. The best performing models also connect the encoder and decoder through an attention mechanism.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://arxiv.org/pdf/1706.03762v7&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Farxiv.org%2Fpdf%2F1706.03762v7&sz=128', publisher: 'arXiv', title: 'Attention Is All You Need', } @@ -119,7 +119,7 @@ Generated by [AVA](https://avajs.dev). description: 'We introduce a new language representation model called BERT, which stands for Bidirectional Encoder Representations from Transformers.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://aclanthology.org/N19-1423.pdf&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Faclanthology.org%2FN19-1423.pdf&sz=128', publisher: 'Proceedings of NAACL-HLT', title: 'BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding', } @@ -134,7 +134,7 @@ Generated by [AVA](https://avajs.dev). description: 'Deep neural nets with a large number of parameters are very powerful machine learning systems. However, overfitting is a serious problem in such networks.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://jmlr.org/papers/volume15/srivastava14a/srivastava14a.pdf&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Fjmlr.org%2Fpapers%2Fvolume15%2Fsrivastava14a%2Fsrivastava14a.pdf&sz=128', publisher: 'JMLR', title: 'Dropout: A Simple Way to Prevent Neural Networks from Overfitting', } @@ -149,7 +149,7 @@ Generated by [AVA](https://avajs.dev). description: ', only trial design features such as size and clinical endpoint showed a significant association with citation rate; covariates relating to the data collection and how the data was made available only showed very weak trends.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://journals.plos.org/plosone/article/file?id=10.1371/journal.pone.0000308&type=printable&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Fjournals.plos.org%2Fplosone%2Farticle%2Ffile%3Fid%3D10.1371%2Fjournal.pone.0000308%26type%3Dprintable&sz=128', publisher: 'PLoS ONE', title: 'Sharing Detailed Research Data Is Associated with Increased Citation Rate', } @@ -164,7 +164,7 @@ Generated by [AVA](https://avajs.dev). description: 'We present Momentum Contrast (MoCo) for unsupervised visual representation learning. From a perspective on contrastive learning [29] as dictionary look-up, we build a dynamic dictionary with a queue and a moving-averaged encoder.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://openaccess.thecvf.com/content_CVPR_2020/papers/He_Momentum_Contrast_for_Unsupervised_Visual_Representation_Learning_CVPR_2020_paper.pdf&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Fopenaccess.thecvf.com%2Fcontent_CVPR_2020%2Fpapers%2FHe_Momentum_Contrast_for_Unsupervised_Visual_Representation_Learning_CVPR_2020_paper.pdf&sz=128', publisher: 'CVF', title: 'Momentum Contrast for Unsupervised Visual Representation Learning', } @@ -179,7 +179,7 @@ Generated by [AVA](https://avajs.dev). description: 'The term harassment is often used to refer two contexts, the workplace and school, but not the legal system itself.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://www.frontiersin.org/articles/10.3389/fpsyg.2019.00001/pdf&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Fwww.frontiersin.org%2Farticles%2F10.3389%2Ffpsyg.2019.00001%2Fpdf&sz=128', publisher: 'Frontiers in Psychology', title: 'Institutional Violence Against Users of the Family Law Courts and the Legal Harassment Scale', } @@ -194,7 +194,7 @@ Generated by [AVA](https://avajs.dev). description: 'The European Quality of Government Index (EQI) measures the perceived level of government quality by European Union citizens, combining surveys on corruption, impartiality and quality of provided services. It is, thus, an index based on individual subjective evaluations.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://www.nature.com/articles/s41598-023-28020-5.pdf&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Fwww.nature.com%2Farticles%2Fs41598-023-28020-5.pdf&sz=128', publisher: 'Scientific Reports', title: 'Detecting the socio-economic drivers of confidence in government with eXplainable Artificial Intelligence', } @@ -224,7 +224,7 @@ Generated by [AVA](https://avajs.dev). description: 'New AI tools have the potential to change the way workers perform and learn, but little is known about their impacts on the job. In this paper, we study the staggered introduction of a generative AI-based conversational assistant using data from 5,179 customer support agents.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://www.nber.org/system/files/working_papers/w31161/w31161.pdf&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Fwww.nber.org%2Fsystem%2Ffiles%2Fworking_papers%2Fw31161%2Fw31161.pdf&sz=128', publisher: 'NBER', title: 'GENERATIVE AI AT WORK', } @@ -239,7 +239,7 @@ Generated by [AVA](https://avajs.dev). description: 'Bacterially-produced small molecules exert profound influences on animal health, morphogenesis, and evolution through poorly understood mechanisms.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://cdn.elifesciences.org/articles/00013/elife-00013-v1.pdf&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Fcdn.elifesciences.org%2Farticles%2F00013%2Felife-00013-v1.pdf&sz=128', publisher: 'eLife', title: 'A bacterial sulfonolipid triggers multicellular development in the closest living relatives of animals', } @@ -254,7 +254,7 @@ Generated by [AVA](https://avajs.dev). description: 'We present BART, a denoising autoencoder for pretraining sequence-to-sequence models. BART is trained by (1) corrupting text with an arbitrary noising function, and (2) learning a model to reconstruct the original text.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://aclanthology.org/2020.acl-main.703.pdf&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Faclanthology.org%2F2020.acl-main.703.pdf&sz=128', publisher: 'Proceedings of the 58th Annual Meeting of the Association for Computational Linguistics', title: 'BART: Denoising Sequence-to-Sequence Pre-training for Natural Language Generation, Translation, and Comprehension', } @@ -269,7 +269,7 @@ Generated by [AVA](https://avajs.dev). description: 'We report the development of GPT-4, a large-scale, multimodal model which can accept image and text inputs and produce text outputs.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://arxiv.org/pdf/2303.08774v6&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Farxiv.org%2Fpdf%2F2303.08774v6&sz=128', publisher: 'arXiv', title: 'GPT-4 Technical Report', } @@ -299,7 +299,7 @@ Generated by [AVA](https://avajs.dev). description: 'Artificial Intelligence (AI) has become an integral part of modern-day security solutions for its ability to learn very complex functions and handling “Big Data”.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://ceur-ws.org/Vol-2600/paper1.pdf&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Fceur-ws.org%2FVol-2600%2Fpaper1.pdf&sz=128', publisher: 'CEUR-WS', title: 'Domain Knowledge Aided Explainable Artificial Intelligence for Intrusion Detection and Response', } @@ -314,7 +314,7 @@ Generated by [AVA](https://avajs.dev). description: 'Deeper neural networks are more difficult to train. We present a residual learning framework to ease the training of networks that are substantially deeper than those used previously.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://arxiv.org/pdf/1512.03385v1&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Farxiv.org%2Fpdf%2F1512.03385v1&sz=128', publisher: 'arXiv', title: 'Deep Residual Learning for Image Recognition', } @@ -329,7 +329,7 @@ Generated by [AVA](https://avajs.dev). description: 'Making language models bigger does not inherently make them better at following a user’s intent. For example, large language models can generate outputs that are untruthful, toxic, or simply not helpful to the user. In other words, these models are not aligned with their users.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://proceedings.neurips.cc/paper_files/paper/2022/file/b1efde53be364a73914f58805a001731-Paper-Conference.pdf&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Fproceedings.neurips.cc%2Fpaper_files%2Fpaper%2F2022%2Ffile%2Fb1efde53be364a73914f58805a001731-Paper-Conference.pdf&sz=128', publisher: '36th Conference on Neural Information Processing Systems (NeurIPS )', title: 'Training language models to follow instructions with human feedback', } @@ -359,7 +359,7 @@ Generated by [AVA](https://avajs.dev). description: 'Recent work has demonstrated substantial gains on many NLP tasks and benchmarks by pre-training on a large corpus of text followed by fine-tuning on a specific task.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://arxiv.org/pdf/2005.14165v4&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Farxiv.org%2Fpdf%2F2005.14165v4&sz=128', publisher: 'arXiv', title: 'Language Models are Few-Shot Learners', } @@ -374,7 +374,7 @@ Generated by [AVA](https://avajs.dev). description: 'We introduce Adam, an algorithm for first-order gradient-based optimization of stochastic objective functions, based on adaptive estimates of lower-order moments.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://arxiv.org/pdf/1412.6980v9&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Farxiv.org%2Fpdf%2F1412.6980v9&sz=128', publisher: 'arXiv', title: 'Adam: A Method for Stochastic Optimization', } @@ -389,7 +389,7 @@ Generated by [AVA](https://avajs.dev). description: 'While the Transformer architecture has become the de-facto standard for natural language processing tasks, its applications to computer vision remain limited.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://arxiv.org/pdf/2010.11929v2&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Farxiv.org%2Fpdf%2F2010.11929v2&sz=128', publisher: 'arXiv', title: 'AN IMAGE IS WORTH 16X16 WORDS: TRANSFORMERS FOR IMAGE RECOGNITION AT SCALE', } @@ -404,7 +404,7 @@ Generated by [AVA](https://avajs.dev). description: 'We introduce a new language representation model called BERT, which stands for Bidirectional Encoder Representations from Transformers.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://arxiv.org/pdf/1810.04805v2&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Farxiv.org%2Fpdf%2F1810.04805v2&sz=128', publisher: 'arXiv', title: 'BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding', } @@ -419,7 +419,7 @@ Generated by [AVA](https://avajs.dev). description: 'Making language models bigger does not inherently make them better at following a user’s intent. For example, large language models can generate outputs that are untruthful, toxic, or simply not helpful to the user. In other words, these models are not aligned with their users.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://arxiv.org/pdf/2203.02155v1&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Farxiv.org%2Fpdf%2F2203.02155v1&sz=128', publisher: 'arXiv', title: 'Training language models to follow instructions with human feedback', } @@ -434,7 +434,7 @@ Generated by [AVA](https://avajs.dev). description: 'The recent result that n congruent balls in R have at most 4 distinct geometric permutations is described. A line ℓ stabs a set S of geometric objects in R if ℓ intersects every member of S. Such a stabber is often called a line traversal of S.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://arxiv.org/pdf/cs/0102004v1&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Farxiv.org%2Fpdf%2Fcs%2F0102004v1&sz=128', publisher: 'arXiv', title: 'Computational Geometry Column 41', } @@ -464,7 +464,7 @@ Generated by [AVA](https://avajs.dev). description: 'Scikit-learn is a Python module integrating a wide range of state-of-the-art machine learning algorithms for medium-scale supervised and unsupervised problems. This package focuses on bringing machine learning to non-specialists using a general-purpose high-level language.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://www.jmlr.org/papers/volume12/pedregosa11a/pedregosa11a.pdf&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Fwww.jmlr.org%2Fpapers%2Fvolume12%2Fpedregosa11a%2Fpedregosa11a.pdf&sz=128', publisher: 'JMLR', title: 'Scikit-learn: Machine Learning in Python', } @@ -479,7 +479,7 @@ Generated by [AVA](https://avajs.dev). description: 'Grid search and manual search are the most widely used strategies for hyper-parameter optimization. This paper shows empirically and theoretically that randomly chosen trials are more efficient for hyper-parameter optimization than trials on a grid.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://www.jmlr.org/papers/volume13/bergstra12a/bergstra12a.pdf&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Fwww.jmlr.org%2Fpapers%2Fvolume13%2Fbergstra12a%2Fbergstra12a.pdf&sz=128', publisher: 'JMLR', title: 'Random Search for Hyper-Parameter Optimization', } @@ -494,7 +494,7 @@ Generated by [AVA](https://avajs.dev). description: 'Deeper neural networks are more difficult to train. We present a residual learning framework to ease the training of networks that are substantially deeper than those used previously.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://openaccess.thecvf.com/content_cvpr_2016/papers/He_Deep_Residual_Learning_CVPR_2016_paper.pdf&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Fopenaccess.thecvf.com%2Fcontent_cvpr_2016%2Fpapers%2FHe_Deep_Residual_Learning_CVPR_2016_paper.pdf&sz=128', publisher: 'CVF', title: 'Deep Residual Learning for Image Recognition', } @@ -509,7 +509,7 @@ Generated by [AVA](https://avajs.dev). description: 'We present a conceptually simple, flexible, and general framework for object instance segmentation. Our approach efficiently detects objects in an image while simultaneously generating a high-quality segmentation mask for each instance.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://openaccess.thecvf.com/content_ICCV_2017/papers/He_Mask_R-CNN_ICCV_2017_paper.pdf&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Fopenaccess.thecvf.com%2Fcontent_ICCV_2017%2Fpapers%2FHe_Mask_R-CNN_ICCV_2017_paper.pdf&sz=128', publisher: 'CVF', title: 'Mask R-CNN', } @@ -524,7 +524,7 @@ Generated by [AVA](https://avajs.dev). description: 'BERT (Devlin et al., 2018) and RoBERTa (Liu et al., 2019) has set a new state-of-the-art performance on sentence-pair regression tasks like semantic textual similarity (STS).', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://aclanthology.org/D19-1410.pdf&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Faclanthology.org%2FD19-1410.pdf&sz=128', publisher: 'Proceedings of the Conference on Empirical Methods in Natural Language Processing', title: 'Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks', } @@ -539,7 +539,7 @@ Generated by [AVA](https://avajs.dev). description: 'Neural machine translation (NMT) models typically operate with a fixed vocabulary, but translation is an open-vocabulary problem. Previous work addresses the translation of out-of-vocabulary words by backing off to a dictionary.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://aclanthology.org/P16-1162.pdf&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Faclanthology.org%2FP16-1162.pdf&sz=128', publisher: 'Proceedings of the 54th Annual Meeting of the Association for Computational Linguistics', title: 'Neural Machine Translation of Rare Words with Subword Units', } @@ -554,7 +554,7 @@ Generated by [AVA](https://avajs.dev). description: 'State-of-the-art object detection networks depend on region proposal algorithms to hypothesize object locations. Advances like SPPnet [7] and Fast R-CNN [5] have reduced the running time of these detection networks, exposing region proposal computation as a bottleneck.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://proceedings.neurips.cc/paper/2015/file/14bfa6bb14875e45bba028a21ed38046-Paper.pdf&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Fproceedings.neurips.cc%2Fpaper%2F2015%2Ffile%2F14bfa6bb14875e45bba028a21ed38046-Paper.pdf&sz=128', publisher: 'NeurIPS', title: 'Faster R-CNN: Towards Real-Time Object Detection with Region Proposal Networks', } @@ -569,7 +569,7 @@ Generated by [AVA](https://avajs.dev). description: 'The economic downturn caused by the current COVID-19 outbreak has substantial implications for gender equality, both during the downturn and the subsequent recovery.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://www.nber.org/system/files/working_papers/w26947/w26947.pdf&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Fwww.nber.org%2Fsystem%2Ffiles%2Fworking_papers%2Fw26947%2Fw26947.pdf&sz=128', publisher: 'NBER', title: 'THE IMPACT OF COVID-19 ON GENDER EQUALITY', } @@ -584,7 +584,7 @@ Generated by [AVA](https://avajs.dev). description: 'Large language models (LLMs) such as ChatGPT have the potential to revolutionize research in economics and other disciplines.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://www.nber.org/system/files/working_papers/w30957/w30957.pdf&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Fwww.nber.org%2Fsystem%2Ffiles%2Fworking_papers%2Fw30957%2Fw30957.pdf&sz=128', publisher: 'NBER', title: 'LANGUAGE MODELS AND COGNITIVE AUTOMATION FOR ECONOMIC RESEARCH', } @@ -629,7 +629,7 @@ Generated by [AVA](https://avajs.dev). description: 'The neural circuits responsible for animal behavior remain largely unknown. We33 summarize new methods and present the circuitry of a large fraction of the brain of the fruit fly34 Drosophila melanogaster.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://cdn.elifesciences.org/articles/57443/elife-57443-v2.pdf&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Fcdn.elifesciences.org%2Farticles%2F57443%2Felife-57443-v2.pdf&sz=128', publisher: 'eLife', title: 'A Connectome and Analysis of the Adult Drosophila Central Brain', } @@ -644,7 +644,7 @@ Generated by [AVA](https://avajs.dev). description: 'Networks, such as social networks, biochemical networks, and protein-protein interaction networks are ubiquitous in the real world.', image: 'data:image/png;base64…1610', lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://www.frontiersin.org/articles/10.3389/fnins.2020.00001/pdf&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Fwww.frontiersin.org%2Farticles%2F10.3389%2Ffnins.2020.00001%2Fpdf&sz=128', publisher: 'Frontiers in Neuroscience', title: 'Multi-Task Network Representation Learning', } @@ -659,7 +659,7 @@ Generated by [AVA](https://avajs.dev). description: 'Several studies underscore the potential of deep learning in identifying complex patterns, leading to diagnostic and prognostic biomarkers.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://www.nature.com/articles/s41598-020-69250-1.pdf&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Fwww.nature.com%2Farticles%2Fs41598-020-69250-1.pdf&sz=128', publisher: 'Scientific RepoRtS', title: 'Federated learning in medicine: facilitating multi-institutional collaborations without sharing patient data', } @@ -674,7 +674,7 @@ Generated by [AVA](https://avajs.dev). description: 'Machine learning promises to revolutionize clinical decision making and diagnosis. In medical diagnosis a doctor aims to explain a patient’s symptoms by determining the diseases causing them.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://www.nature.com/articles/s41467-020-17419-7.pdf&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Fwww.nature.com%2Farticles%2Fs41467-020-17419-7.pdf&sz=128', publisher: 'NATURE COMMUNICATIONS', title: 'Improving the accuracy of medical diagnosis with causal machine learning', } @@ -749,7 +749,7 @@ Generated by [AVA](https://avajs.dev). description: 'by Valentina Bruno, Ilhyock Shim and Hyun Song Shin February 2022 JEL classification: G12, G15, G23. Keywords: global liquidity, pricing factor, emerging market, exchange rate.', image: null, lang: 'en', - logo: 'https://www.google.com/s2/favicons?domain_url=https://www.bis.org/publ/work1000.pdf&sz=128', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Fwww.bis.org%2Fpubl%2Fwork1000.pdf&sz=128', publisher: 'BIS', title: 'Dollar beta and stock returns', } diff --git a/packages/metascraper-pdf/test/snapshots/fixtures.js.snap b/packages/metascraper-pdf/test/snapshots/fixtures.js.snap index fdb1a8296e59d7d5b6d1ed838231c13aa928aca4..e443a9e5a1b2a321c56d52e62e6c6fcde0003351 100644 GIT binary patch literal 11584 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 diff --git a/packages/metascraper-pdf/test/unit.js b/packages/metascraper-pdf/test/unit.js index e191906aa..04330fda5 100644 --- a/packages/metascraper-pdf/test/unit.js +++ b/packages/metascraper-pdf/test/unit.js @@ -12,7 +12,7 @@ const { isVenue } = require('../src/publisher') const { readEmbedded } = require('../src/embedded') -const { toAuthor } = require('../src/author') +const { nameCount, toAuthor } = require('../src/author') const { getLang } = require('../src/lang') const { getMedia } = require('../src/media') @@ -138,10 +138,19 @@ test('the description is the abstract, never the running header', t => { 3, 10, 'This work analyses the psychological consequences of institutional violence in family law courts.' - ) + ), + line(4, 10, '1 Introduction'), + line(5, 10, 'Funding was provided by the ministry of science.') ] t.true(getDescription(lines).startsWith('This work analyses')) + t.false(getDescription(lines).includes('Funding was provided')) +}) + +test('an inverted name is one author', t => { + t.is(nameCount('Doe, Jane'), 1) + t.is(nameCount('Jane Doe, John Smith'), 2) + t.is(nameCount('Doe, Jane; Smith, John'), 2) }) test('generator noise never reaches a property', t => { @@ -202,6 +211,8 @@ test('a small square image is the logo; otherwise the host favicon', t => { t.is(fallback.image, null) t.is( fallback.logo, - 'https://www.google.com/s2/favicons?domain_url=https://arxiv.org/pdf/x&sz=128' + `https://www.google.com/s2/favicons?domain_url=${encodeURIComponent( + 'https://arxiv.org/pdf/x' + )}&sz=128` ) }) From 9d41cd3de2fb3af9f705fdf002cfb223ee157888 Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Sat, 22 Aug 2026 15:50:40 +0200 Subject: [PATCH 3/7] test(metascraper-pdf): add bitcoin, berkshire, gpt-4 fixtures These three sit outside the academic hosts the suite already covers. Co-authored-by: Cursor --- packages/metascraper-pdf/src/publisher.js | 9 +++- .../metascraper-pdf/test/fixtures/urls.txt | 3 ++ .../test/snapshots/fixtures.js.md | 45 ++++++++++++++++++ .../test/snapshots/fixtures.js.snap | Bin 11584 -> 12137 bytes packages/metascraper-pdf/test/unit.js | 7 ++- 5 files changed, 61 insertions(+), 3 deletions(-) diff --git a/packages/metascraper-pdf/src/publisher.js b/packages/metascraper-pdf/src/publisher.js index ccdbe7a87..526a9df0b 100644 --- a/packages/metascraper-pdf/src/publisher.js +++ b/packages/metascraper-pdf/src/publisher.js @@ -37,7 +37,10 @@ const HOST_PUBLISHER = { 'ceur-ws': 'CEUR-WS', springer: 'Springer', biomedcentral: 'BMC', - bis: 'BIS' + bis: 'BIS', + bitcoin: 'Bitcoin', + berkshirehathaway: 'Berkshire Hathaway', + openai: 'OpenAI' } const MAX_PUBLISHER_WORDS = 12 @@ -52,7 +55,9 @@ const hostName = url => { const publisherFromUrl = url => { const { domainWithoutSuffix, hostname } = parseUrl(url) || {} - if (HOST_PUBLISHER[domainWithoutSuffix]) { return HOST_PUBLISHER[domainWithoutSuffix] } + if (HOST_PUBLISHER[domainWithoutSuffix]) { + return HOST_PUBLISHER[domainWithoutSuffix] + } const name = domainWithoutSuffix || hostname || url return name.length > 1 ? name[0].toUpperCase() + name.slice(1) : name } diff --git a/packages/metascraper-pdf/test/fixtures/urls.txt b/packages/metascraper-pdf/test/fixtures/urls.txt index 190e34027..cd72a0978 100644 --- a/packages/metascraper-pdf/test/fixtures/urls.txt +++ b/packages/metascraper-pdf/test/fixtures/urls.txt @@ -48,3 +48,6 @@ https://ceur-ws.org/Vol-3226/paper1.pdf https://www.redalyc.org/pdf/567/56712871011.pdf https://bmcbioinformatics.biomedcentral.com/counter/pdf/10.1186/s12859-020-3418-9.pdf https://www.bis.org/publ/work1000.pdf +https://bitcoin.org/bitcoin.pdf +https://www.berkshirehathaway.com/letters/2023ltr.pdf +https://cdn.openai.com/papers/gpt-4.pdf diff --git a/packages/metascraper-pdf/test/snapshots/fixtures.js.md b/packages/metascraper-pdf/test/snapshots/fixtures.js.md index 0f514cfc1..fdd361ca7 100644 --- a/packages/metascraper-pdf/test/snapshots/fixtures.js.md +++ b/packages/metascraper-pdf/test/snapshots/fixtures.js.md @@ -753,3 +753,48 @@ Generated by [AVA](https://avajs.dev). publisher: 'BIS', title: 'Dollar beta and stock returns', } + +## https://bitcoin.org/bitcoin.pdf + +> Snapshot 1 + + { + author: 'Satoshi Nakamoto', + date: '2009-03-24T11:33:15.000Z', + description: 'A purely peer-to-peer version of electronic cash would allow online payments to be sent directly from one party to another without going through a financial institution.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Fbitcoin.org%2Fbitcoin.pdf&sz=128', + publisher: 'Bitcoin', + title: 'Bitcoin: A Peer-to-Peer Electronic Cash System', + } + +## https://www.berkshirehathaway.com/letters/2023ltr.pdf + +> Snapshot 1 + + { + author: null, + date: '2024-02-23T17:06:20.000Z', + description: 'Charlie Munger died on November 28, just 33 days before his 100th birthday. Though born and raised in Omaha, he spent 80% of his life domiciled elsewhere. Consequently, it was not until 1959 when he was 35 that I first met him. In 1962, he decided that he should take up money management.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Fwww.berkshirehathaway.com%2Fletters%2F2023ltr.pdf&sz=128', + publisher: 'Berkshire Hathaway', + title: 'Charlie Munger – The Architect of Berkshire Hathaway', + } + +## https://cdn.openai.com/papers/gpt-4.pdf + +> Snapshot 1 + + { + author: 'OpenAI', + date: '2023-03-27T17:45:47.000Z', + description: 'We report the development of GPT-4, a large-scale, multimodal model which can accept image and text inputs and produce text outputs.', + image: null, + lang: 'en', + logo: 'https://www.google.com/s2/favicons?domain_url=https%3A%2F%2Fcdn.openai.com%2Fpapers%2Fgpt-4.pdf&sz=128', + publisher: 'OpenAI', + title: 'GPT-4 Technical Report', + } diff --git a/packages/metascraper-pdf/test/snapshots/fixtures.js.snap b/packages/metascraper-pdf/test/snapshots/fixtures.js.snap index e443a9e5a1b2a321c56d52e62e6c6fcde0003351..0b4e6522009b52cf9c4400a24b57743cd800a516 100644 GIT binary patch literal 12137 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 literal 11584 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 yF_Fa|5`r2# { publisherFromLine('4 3 6 | N A T U R E | V O L 5 2 1 | 2 8 M A Y 2 0 1 5'), 'NATURE' ) - t.is(publisherFromUrl('https://cdn.openai.com/papers/gpt-4.pdf'), 'Openai') + t.is(publisherFromUrl('https://cdn.openai.com/papers/gpt-4.pdf'), 'OpenAI') + t.is( + publisherFromUrl('https://www.berkshirehathaway.com/letters/2023ltr.pdf'), + 'Berkshire Hathaway' + ) + t.is(publisherFromUrl('https://bitcoin.org/bitcoin.pdf'), 'Bitcoin') t.is(publisherFromUrl('https://arxiv.org/pdf/1706.03762v7'), 'arXiv') t.is(publisherFromUrl('https://www.nber.org/papers/w31161.pdf'), 'NBER') t.true( From 02161441a147af598757f370e48221aa3db1b526 Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Sat, 22 Aug 2026 16:01:49 +0200 Subject: [PATCH 4/7] fix(metascraper-pdf): accept leading junk and PDF offsets A valid file can start after a few bytes, and CreationDate carries a timezone the old parser treated as UTC. Co-authored-by: Cursor --- packages/metascraper-pdf/src/document.js | 56 ++++++++++------- packages/metascraper-pdf/src/embedded.js | 28 +++++++-- packages/metascraper-pdf/src/index.d.ts | 2 +- packages/metascraper-pdf/src/index.js | 36 ++++++++--- packages/metascraper-pdf/test/index.js | 11 ++++ .../test/snapshots/fixtures.js.md | 58 +++++++++--------- .../test/snapshots/fixtures.js.snap | Bin 12137 -> 12141 bytes packages/metascraper-pdf/test/unit.js | 9 ++- 8 files changed, 132 insertions(+), 68 deletions(-) diff --git a/packages/metascraper-pdf/src/document.js b/packages/metascraper-pdf/src/document.js index 9978c7fc9..70d1dd859 100644 --- a/packages/metascraper-pdf/src/document.js +++ b/packages/metascraper-pdf/src/document.js @@ -56,32 +56,42 @@ const pageLines = async (pdf, pageNumber) => { } const readDocument = async (buffer, { maxPages = DEFAULT_MAX_PAGES } = {}) => { - const pdf = await getDocumentProxy(Uint8Array.from(buffer)) - const { info, metadata } = await pdf.getMetadata() - const pages = [] + const pdf = await getDocumentProxy(buffer, { + isEvalSupported: false, + verbosity: 0 + }) - for ( - let pageNumber = 1; - pageNumber <= Math.min(maxPages, pdf.numPages); - pageNumber++ - ) { - pages.push(await pageLines(pdf, pageNumber)) - } - - const lines = pages.flat() - let images = [] try { - images = await extractImages(pdf, 1) - } catch (_) {} + const { info, metadata } = await pdf.getMetadata() + const pages = [] + + for ( + let pageNumber = 1; + pageNumber <= Math.min(maxPages, pdf.numPages); + pageNumber++ + ) { + pages.push(await pageLines(pdf, pageNumber)) + } + + const lines = pages.flat() + let images = [] + try { + images = await extractImages(pdf, 1) + } catch (_) {} - return { - info: info || {}, - xmp: (metadata && metadata.getAll && metadata.getAll()) || {}, - pageCount: pdf.numPages, - firstPageLines: pages[0] || [], - lines, - images, - text: lines.map(line => line.text).join('\n') + return { + info: info || {}, + xmp: (metadata && metadata.getAll && metadata.getAll()) || {}, + pageCount: pdf.numPages, + firstPageLines: pages[0] || [], + lines, + images, + text: lines.map(line => line.text).join('\n') + } + } finally { + try { + await pdf.loadingTask.destroy() + } catch (_) {} } } diff --git a/packages/metascraper-pdf/src/embedded.js b/packages/metascraper-pdf/src/embedded.js index 857349020..0c130efa1 100644 --- a/packages/metascraper-pdf/src/embedded.js +++ b/packages/metascraper-pdf/src/embedded.js @@ -46,9 +46,10 @@ const sameAsGenerator = (value, { creator, producer }) => .some(generator => generator.toLowerCase() === value.toLowerCase()) const toDate = value => { - const match = /^D:(\d{4})(\d{2})?(\d{2})?(\d{2})?(\d{2})?(\d{2})?/.exec( - String(value || '') - ) + const match = + /^D:(\d{4})(\d{2})?(\d{2})?(\d{2})?(\d{2})?(\d{2})?(?:([Zz]|[+-])(\d{2})'?(\d{2})?'?)?/.exec( + String(value || '') + ) if (!match) return null const [ , @@ -57,10 +58,25 @@ const toDate = value => { day = '01', hour = '00', minute = '00', - second = '00' + second = '00', + sign, + offsetHour = '00', + offsetMinute = '00' ] = match - const date = new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}Z`) - return Number.isNaN(date.getTime()) ? null : date.toISOString() + if (Number(year) < 1800) return null + let time = Date.UTC( + Number(year), + Number(month) - 1, + Number(day), + Number(hour), + Number(minute), + Number(second) + ) + if (sign === '+' || sign === '-') { + const offset = (Number(offsetHour) * 60 + Number(offsetMinute)) * 60 * 1000 + time -= sign === '+' ? offset : -offset + } + return Number.isFinite(time) ? new Date(time).toISOString() : null } /** diff --git a/packages/metascraper-pdf/src/index.d.ts b/packages/metascraper-pdf/src/index.d.ts index bc513db34..5f1b4e3c2 100644 --- a/packages/metascraper-pdf/src/index.d.ts +++ b/packages/metascraper-pdf/src/index.d.ts @@ -20,7 +20,7 @@ declare namespace rules { /** * Called to get the PDF bytes behind `url`. Defaults to a `got` download. */ - getPdf?: (url: string) => Buffer | Uint8Array | null | undefined | Promise; + getPdf?: (url: string) => ArrayBuffer | Buffer | Uint8Array | null | undefined | Promise; } /** `true` when `url` points at a PDF document. */ diff --git a/packages/metascraper-pdf/src/index.js b/packages/metascraper-pdf/src/index.js index b17bd41b2..a2b479c74 100644 --- a/packages/metascraper-pdf/src/index.js +++ b/packages/metascraper-pdf/src/index.js @@ -18,16 +18,36 @@ const { readDocument } = require('./document') const { readEmbedded } = require('./embedded') const { isInvertedName } = require('./text') -const PDF_MAGIC = '%PDF-' +const PDF_MAGIC = [0x25, 0x50, 0x44, 0x46] +const PDF_HEAD = 1024 const PDF_PATH = /(?:^|\/)pdf(?:\/|$)/i const PDF_TYPE = /^(?:pdf|printable)$/i const MAX_PAGES = 2 -const isPdf = value => - ArrayBuffer.isView(value) && - value.byteLength >= PDF_MAGIC.length && - Buffer.from(value.buffer, value.byteOffset, PDF_MAGIC.length).toString() === - PDF_MAGIC +const toBytes = input => + input instanceof ArrayBuffer + ? new Uint8Array(input) + : ArrayBuffer.isView(input) + ? new Uint8Array(input.buffer, input.byteOffset, input.byteLength) + : null + +/** `%PDF` may sit anywhere in the first 1KB; the spec allows leading junk. */ +const isPdf = input => { + const bytes = toBytes(input) + if (!bytes || bytes.length < PDF_MAGIC.length) return false + const head = bytes.subarray(0, PDF_HEAD) + for (let i = 0; i <= head.length - PDF_MAGIC.length; i++) { + if ( + head[i] === PDF_MAGIC[0] && + head[i + 1] === PDF_MAGIC[1] && + head[i + 2] === PDF_MAGIC[2] && + head[i + 3] === PDF_MAGIC[3] + ) { + return true + } + } + return false +} const isPdfLink = url => { if (!url || !helpers.isUrl(url)) return false @@ -139,8 +159,8 @@ const createLoad = ({ maxPages, gotOpts, keyvOpts, getPdf }) => { const fetchPdf = getPdf || defaultGetPdf(gotOpts) const parse = async url => { - const pdf = await fetchPdf(url) - if (!pdf) return {} + const pdf = toBytes(await fetchPdf(url)) + if (!isPdf(pdf)) return {} return extract({ url, pdf, maxPages }) } diff --git a/packages/metascraper-pdf/test/index.js b/packages/metascraper-pdf/test/index.js index 3cd0f7bc7..d4c9c3863 100644 --- a/packages/metascraper-pdf/test/index.js +++ b/packages/metascraper-pdf/test/index.js @@ -98,6 +98,17 @@ test('reads the metadata of a paper', async t => { ) }) +test('reads a PDF with leading junk or an ArrayBuffer', async t => { + const url = 'https://arxiv.org/pdf/1706.03762v7' + const junk = Buffer.concat([Buffer.from('\0\0'), PAPER]) + const fromJunk = await createMetascraper(junk)({ url }) + t.is(fromJunk.title, 'Attention Is All You Need') + + const copy = Uint8Array.from(PAPER) + const fromArrayBuffer = await createMetascraper(copy.buffer)({ url }) + t.is(fromArrayBuffer.title, 'Attention Is All You Need') +}) + test('skips the banner a working paper prints above its title', async t => { const metascraper = createMetascraper(WORKING_PAPER) const metadata = await metascraper({ diff --git a/packages/metascraper-pdf/test/snapshots/fixtures.js.md b/packages/metascraper-pdf/test/snapshots/fixtures.js.md index fdd361ca7..1fe698c24 100644 --- a/packages/metascraper-pdf/test/snapshots/fixtures.js.md +++ b/packages/metascraper-pdf/test/snapshots/fixtures.js.md @@ -10,7 +10,7 @@ Generated by [AVA](https://avajs.dev). { author: 'Bruce Sacerdote', - date: '2020-11-11T10:41:50.000Z', + date: '2020-11-11T15:41:50.000Z', description: 'We analyze the tone of COVID-19 related English-language news articles written since January 1, 2020. Ninety one percent of stories by U.S. major media outlets are negative in tone versus fifty four percent for non-U.S. major sources and sixty five percent for scientific journals.', image: null, lang: 'en', @@ -25,7 +25,7 @@ Generated by [AVA](https://avajs.dev). { author: 'Yann LeCun', - date: '2015-05-21T14:57:35.000Z', + date: '2015-05-21T13:57:35.000Z', description: 'achine-learning technology powers many aspects of modern society: from web searches to content filtering on social networks to recommendations on e-commerce websites, and it is increasingly present in consumer products such as cameras and smartphones.', image: null, lang: 'en', @@ -70,7 +70,7 @@ Generated by [AVA](https://avajs.dev). { author: null, - date: '2012-09-13T08:46:33.000Z', + date: '2012-09-13T12:46:33.000Z', description: 'is needed to successfully develop machine learning applications is not readily available in them. As a result, many machine learning projects take much longer than necessary or wind up producing less-than-ideal results. Yet much of this folk knowledge is fairly easy to communicate.', image: null, lang: 'en', @@ -100,7 +100,7 @@ Generated by [AVA](https://avajs.dev). { author: 'Giovanni Colavizza', - date: '2020-04-21T09:07:32.000Z', + date: '2020-04-21T03:37:32.000Z', description: 'Efforts to make research results open and reproducible are increasingly reflected by journal policies encouraging or mandating authors to provide data availability statements. As a consequence of this, there has been a strong uptake of data availability statements in recent literature.', image: 'data:image/png;base64…1810', lang: 'en', @@ -130,7 +130,7 @@ Generated by [AVA](https://avajs.dev). { author: 'Nitish Srivastava', - date: '2014-07-17T19:58:32.000Z', + date: '2014-07-17T18:58:32.000Z', description: 'Deep neural nets with a large number of parameters are very powerful machine learning systems. However, overfitting is a serious problem in such networks.', image: null, lang: 'en', @@ -145,7 +145,7 @@ Generated by [AVA](https://avajs.dev). { author: 'Heather A. Piwowar', - date: '2007-03-06T20:02:06.000Z', + date: '2007-03-06T12:02:06.000Z', description: ', only trial design features such as size and clinical endpoint showed a significant association with citation rate; covariates relating to the data collection and how the data was made available only showed very weak trends.', image: null, lang: 'en', @@ -175,7 +175,7 @@ Generated by [AVA](https://avajs.dev). { author: 'Miguel Clemente', - date: '2019-01-16T18:44:26.000Z', + date: '2019-01-16T13:14:26.000Z', description: 'The term harassment is often used to refer two contexts, the workplace and school, but not the legal system itself.', image: null, lang: 'en', @@ -190,7 +190,7 @@ Generated by [AVA](https://avajs.dev). { author: 'Loredana Bellantuono', - date: '2023-01-13T16:45:25.000Z', + date: '2023-01-13T11:15:25.000Z', description: 'The European Quality of Government Index (EQI) measures the perceived level of government quality by European Union citizens, combining surveys on corruption, impartiality and quality of provided services. It is, thus, an index based on individual subjective evaluations.', image: null, lang: 'en', @@ -205,7 +205,7 @@ Generated by [AVA](https://avajs.dev). { author: 'Qiang Zhang', - date: '2020-04-24T11:36:38.000Z', + date: '2020-04-24T03:36:38.000Z', description: 'Background: Influenza is a severe respiratory illness that continually threatens global health. It has been widely known that gut microbiota modulates the host response to protect against influenza infection, but mechanistic details remain largely unknown.', image: 'data:image/png;base64…15142', lang: 'en', @@ -235,7 +235,7 @@ Generated by [AVA](https://avajs.dev). { author: 'Rosanna A Alegado', - date: '2012-10-05T16:47:37.000Z', + date: '2012-10-05T11:17:37.000Z', description: 'Bacterially-produced small molecules exert profound influences on animal health, morphogenesis, and evolution through poorly understood mechanisms.', image: null, lang: 'en', @@ -295,7 +295,7 @@ Generated by [AVA](https://avajs.dev). { author: 'Sheikh Rabiul Islam', - date: '2020-05-05T13:01:56.000Z', + date: '2020-05-05T11:01:56.000Z', description: 'Artificial Intelligence (AI) has become an integral part of modern-day security solutions for its ability to learn very complex functions and handling “Big Data”.', image: null, lang: 'en', @@ -340,7 +340,7 @@ Generated by [AVA](https://avajs.dev). { author: 'Nathalie Percie du Sert', - date: '2020-07-11T10:27:33.000Z', + date: '2020-07-11T04:57:33.000Z', description: 'Reproducible science requires transparent reporting. The ARRIVE guidelines (Animal Research: Reporting of In Vivo Experiments) were originally developed in 2010 to improve the reporting of animal research.', image: 'data:image/png;base64…1810', lang: 'en', @@ -460,7 +460,7 @@ Generated by [AVA](https://avajs.dev). { author: 'Fabian Pedregosa', - date: '2011-10-11T14:45:40.000Z', + date: '2011-10-11T19:45:40.000Z', description: 'Scikit-learn is a Python module integrating a wide range of state-of-the-art machine learning algorithms for medium-scale supervised and unsupervised problems. This package focuses on bringing machine learning to non-specialists using a general-purpose high-level language.', image: null, lang: 'en', @@ -475,7 +475,7 @@ Generated by [AVA](https://avajs.dev). { author: 'James Bergstra', - date: '2012-02-16T16:46:50.000Z', + date: '2012-02-16T22:46:50.000Z', description: 'Grid search and manual search are the most widely used strategies for hyper-parameter optimization. This paper shows empirically and theoretically that randomly chosen trials are more efficient for hyper-parameter optimization than trials on a grid.', image: null, lang: 'en', @@ -520,7 +520,7 @@ Generated by [AVA](https://avajs.dev). { author: 'Nils Reimers', - date: '2019-08-27T10:41:10.000Z', + date: '2019-08-27T08:41:10.000Z', description: 'BERT (Devlin et al., 2018) and RoBERTa (Liu et al., 2019) has set a new state-of-the-art performance on sentence-pair regression tasks like semantic textual similarity (STS).', image: null, lang: 'en', @@ -535,7 +535,7 @@ Generated by [AVA](https://avajs.dev). { author: 'Rico Sennrich', - date: '2016-07-20T19:45:13.000Z', + date: '2016-07-21T02:45:13.000Z', description: 'Neural machine translation (NMT) models typically operate with a fixed vocabulary, but translation is an open-vocabulary problem. Previous work addresses the translation of out-of-vocabulary words by backing off to a dictionary.', image: null, lang: 'en', @@ -580,7 +580,7 @@ Generated by [AVA](https://avajs.dev). { author: 'Anton Korinek', - date: '2023-02-10T10:18:13.000Z', + date: '2023-02-10T15:18:13.000Z', description: 'Large language models (LLMs) such as ChatGPT have the potential to revolutionize research in economics and other disciplines.', image: null, lang: 'en', @@ -595,7 +595,7 @@ Generated by [AVA](https://avajs.dev). { author: 'Matthew J. Page', - date: '2021-03-24T15:01:13.000Z', + date: '2021-03-24T09:31:13.000Z', description: 'points complete, and accurate account of why the review was done, what they did, and what they found PLOS MEDICINE (UG1EY020522), National Institutes of Health, United States. LAM is supported by a National Institute for Health Research Doctoral Research Fellowship (DRF-2018-11-ST2-048).', image: 'data:image/png;base64…1810', lang: 'en', @@ -610,7 +610,7 @@ Generated by [AVA](https://avajs.dev). { author: 'Greg Wilson', - date: '2017-06-21T14:38:26.000Z', + date: '2017-06-21T09:08:26.000Z', description: 'Computers are now essential in all branches of science, but most researchers are never taught the equivalent of basic lab skills for research computing.', image: 'data:image/png;base64…1070', lang: 'en', @@ -625,7 +625,7 @@ Generated by [AVA](https://avajs.dev). { author: 'Louis K. Scheffer', - date: '2020-09-03T01:24:34.000Z', + date: '2020-09-03T05:24:34.000Z', description: 'The neural circuits responsible for animal behavior remain largely unknown. We33 summarize new methods and present the circuitry of a large fraction of the brain of the fruit fly34 Drosophila melanogaster.', image: null, lang: 'en', @@ -640,7 +640,7 @@ Generated by [AVA](https://avajs.dev). { author: 'Yu Xie', - date: '2020-01-21T11:32:46.000Z', + date: '2020-01-21T06:02:46.000Z', description: 'Networks, such as social networks, biochemical networks, and protein-protein interaction networks are ubiquitous in the real world.', image: 'data:image/png;base64…1610', lang: 'en', @@ -655,7 +655,7 @@ Generated by [AVA](https://avajs.dev). { author: 'Micah J. Sheller', - date: '2020-07-22T12:59:35.000Z', + date: '2020-07-22T07:29:35.000Z', description: 'Several studies underscore the potential of deep learning in identifying complex patterns, leading to diagnostic and prognostic biomarkers.', image: null, lang: 'en', @@ -670,7 +670,7 @@ Generated by [AVA](https://avajs.dev). { author: 'Jonathan G. Richens', - date: '2021-03-30T18:30:12.000Z', + date: '2021-03-30T13:00:12.000Z', description: 'Machine learning promises to revolutionize clinical decision making and diagnosis. In medical diagnosis a doctor aims to explain a patient’s symptoms by determining the diseases causing them.', image: null, lang: 'en', @@ -685,7 +685,7 @@ Generated by [AVA](https://avajs.dev). { author: 'Helen Le Sueur', - date: '2020-06-23T22:17:55.000Z', + date: '2020-06-23T14:17:55.000Z', description: 'Background: Individual clinical trials and cohort studies are a useful source of data, often under-utilised once a study has ended.', image: 'data:image/png;base64…22214', lang: 'en', @@ -700,7 +700,7 @@ Generated by [AVA](https://avajs.dev). { author: 'Josef Zelinka', - date: '2022-09-08T22:32:41.000Z', + date: '2022-09-08T19:32:41.000Z', description: 'For autonomous robots operating in an unknown environment, it is important to assess the traversability of the surrounding terrain to improve path planning and decision-making on where to navigate next in a cost-efficient way.', image: 'data:image/png;base64…1658', lang: 'en', @@ -730,7 +730,7 @@ Generated by [AVA](https://avajs.dev). { author: 'Lucile Mégret', - date: '2020-02-24T10:45:03.000Z', + date: '2020-02-24T02:45:03.000Z', description: 'Background: MicroRNA (miRNA) regulation is associated with several diseases, including neurodegenerative diseases. Several approaches can be used for modeling miRNA regulation. However, their precision may be limited for analyzing multidimensional data.', image: 'data:image/png;base64…34334', lang: 'en', @@ -745,7 +745,7 @@ Generated by [AVA](https://avajs.dev). { author: 'Valentina Bruno', - date: '2022-02-15T08:11:00.000Z', + date: '2022-02-15T07:11:00.000Z', description: 'by Valentina Bruno, Ilhyock Shim and Hyun Song Shin February 2022 JEL classification: G12, G15, G23. Keywords: global liquidity, pricing factor, emerging market, exchange rate.', image: null, lang: 'en', @@ -760,7 +760,7 @@ Generated by [AVA](https://avajs.dev). { author: 'Satoshi Nakamoto', - date: '2009-03-24T11:33:15.000Z', + date: '2009-03-24T17:33:15.000Z', description: 'A purely peer-to-peer version of electronic cash would allow online payments to be sent directly from one party to another without going through a financial institution.', image: null, lang: 'en', @@ -775,7 +775,7 @@ Generated by [AVA](https://avajs.dev). { author: null, - date: '2024-02-23T17:06:20.000Z', + date: '2024-02-23T23:06:20.000Z', description: 'Charlie Munger died on November 28, just 33 days before his 100th birthday. Though born and raised in Omaha, he spent 80% of his life domiciled elsewhere. Consequently, it was not until 1959 when he was 35 that I first met him. In 1962, he decided that he should take up money management.', image: null, lang: 'en', diff --git a/packages/metascraper-pdf/test/snapshots/fixtures.js.snap b/packages/metascraper-pdf/test/snapshots/fixtures.js.snap index 0b4e6522009b52cf9c4400a24b57743cd800a516..beac95fd492a7683e40915158729353f66176b40 100644 GIT binary patch delta 10412 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 delta 10408 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 Sui-?kHvbb%jl{+5* diff --git a/packages/metascraper-pdf/test/unit.js b/packages/metascraper-pdf/test/unit.js index 1539b9740..f85229570 100644 --- a/packages/metascraper-pdf/test/unit.js +++ b/packages/metascraper-pdf/test/unit.js @@ -11,7 +11,7 @@ const { publisherFromUrl, isVenue } = require('../src/publisher') -const { readEmbedded } = require('../src/embedded') +const { readEmbedded, toDate } = require('../src/embedded') const { nameCount, toAuthor } = require('../src/author') const { getLang } = require('../src/lang') const { getMedia } = require('../src/media') @@ -158,6 +158,13 @@ test('an inverted name is one author', t => { t.is(nameCount('Doe, Jane; Smith, John'), 2) }) +test('a PDF date keeps its offset and rejects year zero', t => { + t.is(toDate("D:20201111104150-05'00'"), '2020-11-11T15:41:50.000Z') + t.is(toDate('D:20201111104150Z'), '2020-11-11T10:41:50.000Z') + t.is(toDate('D:20201111104150'), '2020-11-11T10:41:50.000Z') + t.is(toDate('D:00000000000000'), null) +}) + test('generator noise never reaches a property', t => { const embedded = readEmbedded({ info: { From bb204c2d5a409d7026700de126937e4c7be9de3d Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Sat, 22 Aug 2026 16:06:04 +0200 Subject: [PATCH 5/7] fix(metascraper-pdf): count AND as an author split Embedded bylines sometimes join names in all caps. Co-authored-by: Cursor --- packages/metascraper-pdf/src/author.js | 2 +- packages/metascraper-pdf/test/fixtures/download.sh | 2 +- packages/metascraper-pdf/test/unit.js | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/metascraper-pdf/src/author.js b/packages/metascraper-pdf/src/author.js index 354e0ae43..7e60902e6 100644 --- a/packages/metascraper-pdf/src/author.js +++ b/packages/metascraper-pdf/src/author.js @@ -131,7 +131,7 @@ const nameCount = value => { const trimmed = part.trim() if (!trimmed) return count if (isInvertedName(trimmed)) return count + 1 - return count + trimmed.split(/,| and /).filter(Boolean).length + return count + trimmed.split(/,|\s+and\s+/i).filter(Boolean).length }, 0) } diff --git a/packages/metascraper-pdf/test/fixtures/download.sh b/packages/metascraper-pdf/test/fixtures/download.sh index f4bcada62..ad2e4d85a 100755 --- a/packages/metascraper-pdf/test/fixtures/download.sh +++ b/packages/metascraper-pdf/test/fixtures/download.sh @@ -1,2 +1,2 @@ -cd "$(dirname "$0")" +cd "$(dirname "$0")" || exit 1 nl -ba urls.txt | xargs -n 2 -P 8 sh -c 'curl -fL --retry 3 --retry-all-errors -o "$0.pdf" "$1"' diff --git a/packages/metascraper-pdf/test/unit.js b/packages/metascraper-pdf/test/unit.js index f85229570..57b2632e7 100644 --- a/packages/metascraper-pdf/test/unit.js +++ b/packages/metascraper-pdf/test/unit.js @@ -155,6 +155,7 @@ test('the description is the abstract, never the running header', t => { test('an inverted name is one author', t => { t.is(nameCount('Doe, Jane'), 1) t.is(nameCount('Jane Doe, John Smith'), 2) + t.is(nameCount('Jane Doe AND John Smith'), 2) t.is(nameCount('Doe, Jane; Smith, John'), 2) }) From 54afecdc24670063c2f6eefcb568976848841b37 Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Sat, 22 Aug 2026 16:14:03 +0200 Subject: [PATCH 6/7] fix(metascraper-pdf): copy bytes before pdf.js pdf.js transfers the input buffer, so a cached getPdf result would be empty on the next call. Co-authored-by: Cursor --- packages/metascraper-pdf/src/document.js | 2 +- packages/metascraper-pdf/src/embedded.js | 47 +++++++++++++++++------- packages/metascraper-pdf/test/unit.js | 1 + 3 files changed, 36 insertions(+), 14 deletions(-) diff --git a/packages/metascraper-pdf/src/document.js b/packages/metascraper-pdf/src/document.js index 70d1dd859..c1fc4138b 100644 --- a/packages/metascraper-pdf/src/document.js +++ b/packages/metascraper-pdf/src/document.js @@ -56,7 +56,7 @@ const pageLines = async (pdf, pageNumber) => { } const readDocument = async (buffer, { maxPages = DEFAULT_MAX_PAGES } = {}) => { - const pdf = await getDocumentProxy(buffer, { + const pdf = await getDocumentProxy(Uint8Array.from(buffer), { isEvalSupported: false, verbosity: 0 }) diff --git a/packages/metascraper-pdf/src/embedded.js b/packages/metascraper-pdf/src/embedded.js index 0c130efa1..a87d23393 100644 --- a/packages/metascraper-pdf/src/embedded.js +++ b/packages/metascraper-pdf/src/embedded.js @@ -47,7 +47,7 @@ const sameAsGenerator = (value, { creator, producer }) => const toDate = value => { const match = - /^D:(\d{4})(\d{2})?(\d{2})?(\d{2})?(\d{2})?(\d{2})?(?:([Zz]|[+-])(\d{2})'?(\d{2})?'?)?/.exec( + /^D:(\d{4})(\d{2})?(\d{2})?(\d{2})?(\d{2})?(\d{2})?(?:Z|z|([+-])(\d{2})'?(\d{2})?'?)?$/.exec( String(value || '') ) if (!match) return null @@ -63,20 +63,41 @@ const toDate = value => { offsetHour = '00', offsetMinute = '00' ] = match - if (Number(year) < 1800) return null - let time = Date.UTC( - Number(year), - Number(month) - 1, - Number(day), - Number(hour), - Number(minute), - Number(second) - ) + const y = Number(year) + const mo = Number(month) + const d = Number(day) + const h = Number(hour) + const mi = Number(minute) + const s = Number(second) + const oh = Number(offsetHour) + const om = Number(offsetMinute) + if ( + y < 1800 || + mo < 1 || + mo > 12 || + d < 1 || + d > 31 || + h > 23 || + mi > 59 || + s > 59 || + oh > 14 || + om > 59 + ) { + return null + } + let time = Date.UTC(y, mo - 1, d, h, mi, s) + const utc = new Date(time) + if ( + utc.getUTCFullYear() !== y || + utc.getUTCMonth() !== mo - 1 || + utc.getUTCDate() !== d + ) { + return null + } if (sign === '+' || sign === '-') { - const offset = (Number(offsetHour) * 60 + Number(offsetMinute)) * 60 * 1000 - time -= sign === '+' ? offset : -offset + time -= (sign === '+' ? 1 : -1) * (oh * 60 + om) * 60 * 1000 } - return Number.isFinite(time) ? new Date(time).toISOString() : null + return new Date(time).toISOString() } /** diff --git a/packages/metascraper-pdf/test/unit.js b/packages/metascraper-pdf/test/unit.js index 57b2632e7..d38a0dde0 100644 --- a/packages/metascraper-pdf/test/unit.js +++ b/packages/metascraper-pdf/test/unit.js @@ -164,6 +164,7 @@ test('a PDF date keeps its offset and rejects year zero', t => { t.is(toDate('D:20201111104150Z'), '2020-11-11T10:41:50.000Z') t.is(toDate('D:20201111104150'), '2020-11-11T10:41:50.000Z') t.is(toDate('D:00000000000000'), null) + t.is(toDate('D:20240230'), null) }) test('generator noise never reaches a property', t => { From 68f98b702d2ee18ff3951a63cd0ae5c57b25e5e4 Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Sat, 22 Aug 2026 16:34:00 +0200 Subject: [PATCH 7/7] refactor(metascraper-pdf): simplify internals Title walked the same wrap twice, and three call sites each folded text for compare. Co-authored-by: Cursor --- packages/metascraper-pdf/src/document.js | 4 +- packages/metascraper-pdf/src/index.js | 53 +++++++++-------------- packages/metascraper-pdf/src/lang.js | 2 +- packages/metascraper-pdf/src/layout.js | 7 +-- packages/metascraper-pdf/src/media.js | 10 ++--- packages/metascraper-pdf/src/publisher.js | 16 +++---- packages/metascraper-pdf/src/text.js | 7 ++- packages/metascraper-pdf/src/title.js | 26 +++-------- 8 files changed, 46 insertions(+), 79 deletions(-) diff --git a/packages/metascraper-pdf/src/document.js b/packages/metascraper-pdf/src/document.js index c1fc4138b..8494e3dab 100644 --- a/packages/metascraper-pdf/src/document.js +++ b/packages/metascraper-pdf/src/document.js @@ -12,7 +12,7 @@ const normalizeSpaces = text => text .replace(/[^\S ]+/g, ' ') .replace(/ {3,}/g, ' ') - .replace(/^ +| +$/g, '') + .trim() const toLines = items => { const lines = [] @@ -81,7 +81,7 @@ const readDocument = async (buffer, { maxPages = DEFAULT_MAX_PAGES } = {}) => { return { info: info || {}, - xmp: (metadata && metadata.getAll && metadata.getAll()) || {}, + xmp: metadata?.getAll?.() || {}, pageCount: pdf.numPages, firstPageLines: pages[0] || [], lines, diff --git a/packages/metascraper-pdf/src/index.js b/packages/metascraper-pdf/src/index.js index a2b479c74..8c2b9c253 100644 --- a/packages/metascraper-pdf/src/index.js +++ b/packages/metascraper-pdf/src/index.js @@ -16,37 +16,28 @@ const { getMedia } = require('./media') const { headerLines } = require('./layout') const { readDocument } = require('./document') const { readEmbedded } = require('./embedded') -const { isInvertedName } = require('./text') +const { comparable, isInvertedName } = require('./text') -const PDF_MAGIC = [0x25, 0x50, 0x44, 0x46] +const PDF_MAGIC = Buffer.from('%PDF') const PDF_HEAD = 1024 const PDF_PATH = /(?:^|\/)pdf(?:\/|$)/i const PDF_TYPE = /^(?:pdf|printable)$/i const MAX_PAGES = 2 -const toBytes = input => - input instanceof ArrayBuffer - ? new Uint8Array(input) - : ArrayBuffer.isView(input) - ? new Uint8Array(input.buffer, input.byteOffset, input.byteLength) - : null +const toBytes = input => { + if (input instanceof ArrayBuffer) return new Uint8Array(input) + if (ArrayBuffer.isView(input)) { + return new Uint8Array(input.buffer, input.byteOffset, input.byteLength) + } + return null +} /** `%PDF` may sit anywhere in the first 1KB; the spec allows leading junk. */ const isPdf = input => { const bytes = toBytes(input) - if (!bytes || bytes.length < PDF_MAGIC.length) return false - const head = bytes.subarray(0, PDF_HEAD) - for (let i = 0; i <= head.length - PDF_MAGIC.length; i++) { - if ( - head[i] === PDF_MAGIC[0] && - head[i + 1] === PDF_MAGIC[1] && - head[i + 2] === PDF_MAGIC[2] && - head[i + 3] === PDF_MAGIC[3] - ) { - return true - } - } - return false + return Boolean( + bytes && Buffer.from(bytes.subarray(0, PDF_HEAD)).includes(PDF_MAGIC) + ) } const isPdfLink = url => { @@ -74,6 +65,11 @@ const firstAuthor = value => { return first.split(/,\s*/)[0] } +const appearsIn = (value, text) => { + const needle = comparable(value) + return needle.length > 0 && comparable(text).includes(needle) +} + /** A name lifted out of the title block is a title fragment, not an author. */ const withoutContext = (names, context) => { if (!names) return null @@ -83,15 +79,6 @@ const withoutContext = (names, context) => { return kept.length > 0 ? kept.join(', ') : null } -const appearsIn = (value, text) => { - const normalize = input => - String(input || '') - .toLowerCase() - .replace(/[^a-z0-9]/g, '') - const needle = normalize(value) - return needle.length > 0 && normalize(text).includes(needle) -} - const extract = async ({ url, pdf, maxPages }) => { const document = await readDocument(pdf, { maxPages }) const embedded = readEmbedded(document) @@ -107,11 +94,11 @@ const extract = async ({ url, pdf, maxPages }) => { const title = embedded.title && appearsIn(embedded.title, document.text) ? embedded.title - : (layoutTitle && layoutTitle.text) || embedded.title + : layoutTitle?.text || embedded.title const layoutAuthor = withoutContext( getAuthor(lines, { - titleIndexes: (layoutTitle && layoutTitle.indexes) || [] + titleIndexes: layoutTitle?.indexes || [] }), [title] ) @@ -186,7 +173,7 @@ const fromPdf = (propName, load) => async ({ url }) => { const metadata = await load(url) - return NORMALIZERS[propName](metadata && metadata[propName], { url }) + return NORMALIZERS[propName](metadata?.[propName], { url }) } module.exports = ({ maxPages = MAX_PAGES, gotOpts, keyvOpts, getPdf } = {}) => { diff --git a/packages/metascraper-pdf/src/lang.js b/packages/metascraper-pdf/src/lang.js index 18930c7d1..49e91734a 100644 --- a/packages/metascraper-pdf/src/lang.js +++ b/packages/metascraper-pdf/src/lang.js @@ -30,6 +30,6 @@ const fromText = text => { } const getLang = (text, { url, embedded } = {}) => - lang(embedded && embedded.lang) || hostLang(url) || fromText(text) + lang(embedded?.lang) || hostLang(url) || fromText(text) module.exports = { getLang } diff --git a/packages/metascraper-pdf/src/layout.js b/packages/metascraper-pdf/src/layout.js index 9342e4117..abbfabc38 100644 --- a/packages/metascraper-pdf/src/layout.js +++ b/packages/metascraper-pdf/src/layout.js @@ -56,11 +56,8 @@ const headerLines = lines => { .slice(-MAX_FOOTER_LINES) const chosen = new Map() - for (const line of [...leading, ...prominentLines(numbered)]) { - chosen.set(line.pageIndex, { ...line, region: 'body' }) - } - for (const line of footer) { - chosen.set(line.pageIndex, { ...line, region: 'footer' }) + for (const line of [...leading, ...prominentLines(numbered), ...footer]) { + chosen.set(line.pageIndex, line) } return [...chosen.values()] diff --git a/packages/metascraper-pdf/src/media.js b/packages/metascraper-pdf/src/media.js index 6ed5793d1..9e9c16cee 100644 --- a/packages/metascraper-pdf/src/media.js +++ b/packages/metascraper-pdf/src/media.js @@ -75,13 +75,11 @@ const favicon = url => )}&sz=128` const getMedia = (images, { url } = {}) => { - const candidates = usable(images) - .slice() - .sort( - (left, right) => right.width * right.height - left.width * left.height - ) + const candidates = usable(images).sort( + (left, right) => right.width * right.height - left.width * left.height + ) - const logoImage = [...candidates].reverse().find(isLogo) + const logoImage = candidates.findLast(isLogo) const picture = candidates.find(img => img !== logoImage) || logoImage const logo = logoImage ? toPngDataUri(logoImage) : url ? favicon(url) : null diff --git a/packages/metascraper-pdf/src/publisher.js b/packages/metascraper-pdf/src/publisher.js index 526a9df0b..f4822ae1c 100644 --- a/packages/metascraper-pdf/src/publisher.js +++ b/packages/metascraper-pdf/src/publisher.js @@ -2,7 +2,7 @@ const { parseUrl } = require('@metascraper/helpers') -const { flatten, isBannerLine, tidy } = require('./text') +const { comparable, flatten, isBannerLine, tidy } = require('./text') const PUBLISHER_NOISE = /https?:\/\/\S+|\bwww\.\S+|\bdoi:\S+|\b10\.\d{4,9}\/\S+|\(\s*\d{4}\s*\)|,?\s*\bpages?\b.*$|\b\d[\d:;,.()-]*\b/gi @@ -65,10 +65,9 @@ const publisherFromUrl = url => { const matchesHost = (value, url) => { const host = hostName(url) if (!host) return false - const normalize = input => input.toLowerCase().replace(/[^a-z0-9]/g, '') return ( - normalize(value).startsWith(normalize(host)) || - normalize(host).startsWith(normalize(value)) + comparable(value).startsWith(comparable(host)) || + comparable(host).startsWith(comparable(value)) ) } @@ -96,13 +95,8 @@ const publisherFromLine = text => ) })[0] || null -const sameText = (left, right) => { - const normalize = value => - String(value || '') - .toLowerCase() - .replace(/[^a-z0-9]/g, '') - return Boolean(left && right) && normalize(left) === normalize(right) -} +const sameText = (left, right) => + Boolean(left && right) && comparable(left) === comparable(right) const isUsablePublisher = (value, { author, title }) => Boolean(value) && diff --git a/packages/metascraper-pdf/src/text.js b/packages/metascraper-pdf/src/text.js index 00e99b6ea..d216a6a4c 100644 --- a/packages/metascraper-pdf/src/text.js +++ b/packages/metascraper-pdf/src/text.js @@ -53,6 +53,11 @@ const tidy = value => const flatten = value => value.replace(/\s+/g, ' ').trim() +const comparable = value => + String(value || '') + .toLowerCase() + .replace(/[^a-z0-9]/g, '') + const stripNoise = text => tidy( text @@ -135,11 +140,11 @@ module.exports = { EMAIL, ORGANIZATION_WORDS, PLACE_NAME, + comparable, flatten, isBannerLine, isInvertedName, isPersonName, - isShouting, splitNamePairs, splitNames, stripNoise, diff --git a/packages/metascraper-pdf/src/title.js b/packages/metascraper-pdf/src/title.js index a7c7f8385..27f59c563 100644 --- a/packages/metascraper-pdf/src/title.js +++ b/packages/metascraper-pdf/src/title.js @@ -40,16 +40,6 @@ const titleLines = (lines, titleLine) => { return block } -const titleBlock = (lines, titleLine) => - flatten( - titleLines(lines, titleLine) - .map(line => line.text.replace(LINE_NUMBER, '')) - .join(' ') - ) - -const titleBlockIndexes = (lines, titleLine) => - titleLines(lines, titleLine).map(line => line.index) - /** A line of names, rather than a title, has more names set beside it. */ const isByline = (line, lines) => isPersonName(line.text) && @@ -87,17 +77,13 @@ const isUsableTitle = text => const getTitle = lines => { const line = findTitleLine(lines) if (!line) return null - const text = titleBlock(lines, line) + const block = titleLines(lines, line) + const text = flatten( + block.map(entry => entry.text.replace(LINE_NUMBER, '')).join(' ') + ) return isUsableTitle(text) - ? { text, line, indexes: titleBlockIndexes(lines, line) } + ? { text, indexes: block.map(entry => entry.index) } : null } -module.exports = { - findTitleLine, - getTitle, - isByline, - isUsableTitle, - titleBlock, - titleBlockIndexes -} +module.exports = { getTitle, isByline }