Vai al contenuto

Google Scholar annotation exporter

Pubblicato:

Oggi, qualcuno è entrato in un ufficio che adesso non gli appartiene più e, oltre a fornirmi un’ottima scusa per non lavorare, mi ha indirettamente proposto una sfida parecchio interessante: estrarre in maniera programmatica le evidenziazioni e note aggiunte usando l’estensione Google Scholar PDF Reader. Si tratta di una task che, in partenza, mi sembrava abbastanza banale: qualche semplice funzione Javascript da eseguire direttamente nella console del browser sarebbe probabilmente stata sufficiente. Ed in effetti, mi è bastato qualche minuto per ottenere un semplice script che, partendo dal modal che si apre cliccando sul menù “Evidenziazioni”, fosse in grado di estrarre buona parte dei dati che mi interessavano. Se non fosse che a questa richiesta se nè aggiunta un’altra, all’apparenza innocua: “Non è che si può fare in modo che le evidenziazioni includano l’intera frase, per dare un po’ più di contesto?”. La difficoltà aggiuntiva deriva dal fatto che non vi è un modo diretto per ottenere questo genere di informazioni, e bisogna quindi necessariamente andare a recuperare il testo direttamente dal PDF originale, operazione della quale disconoscevo le possibili complicazioni. Tuttavia, la mia curiosità era stata ormai stimolata a sufficienza, e così ho deciso di accettare la sfida. Questo post sarà quindi un resoconto di come mi sono approcciato al problema, di quali scogli ho incontrato e di quali motivazioni mi hanno spinto a preferire una metodologia piuttosto che un’altra.

Risultato

Il risultato finale è un’estensione per Chrome chiamata Google Scholar Annotation Exporter. L’estensione è open source ed è disponibile su GitHub.

Progettazione

Glossario

  • Evidenziazione: porzione di testo selezionata dall’utente e salvata tramite l’estensione Google Scholar PDF Reader. Può avere diversi colori.
  • Nota: commento testuale associato ad una evidenziazione. Può essere vuoto.
  • Annotazione: insieme di evidenziazione e nota. Ogni annotazione è associata ad un documento PDF.
  • Documento PDF: file PDF visualizzato tramite Google Scholar PDF Reader. Ogni documento può avere più annotazioni associate.
  • Pagina: porzione di un documento PDF. Ogni annotazione è associata ad una pagina del documento.
  • Frase: porzione di testo che inizia e termina con un punto, un punto interrogativo o un punto esclamativo. Ogni annotazione è associata ad una frase del documento PDF.

Requisiti

L’obiettivo di questo progetto è quello di creare un tool che soddisfi i seguenti requisiti:

  • Deve essere possibile estrarre annotazioni da Google Scholar in maniera programmatica.
  • Ogni nota deve essere associate all’evidenziazione che l’ha generata.
  • Ogni annotazioni deve essere associata alla pagina del documento PDF da cui è stata generata.
  • Ogni annotazione deve essere associata al più piccolo gruppo di frasi che la contiene.
  • L’output del tool deve essere un file
    • in formato JSON;
    • in formato CSV;
    • in formato Markdown.
  • Il tool deve essere semplice da usare e non richiedere conoscenze tecniche particolari.

Implementazione

Struttura del progetto

Clonando la repository, la struttura del progetto sarà la seguente:

.
├── _locales # Contiene le traduzioni
   ├── en
   └── it
├── assets # Contiene le risorse statiche
   ├── html
   ├── style
   └── icons
├── src # Contiene il codice sorgente
   ├── background # Entrypoint del background script
   └── content.ts # Entrypoint del content script
├── dist # Creata da Rolldown
├── package.json
├── rollup.config.ts # Configurazione di RollDown
└── manifest.json # Configurazione dell'estensione

Tipo di tool

La prima decisione da prendere riguardava il tipo di tool da implementare. A prima vista, vi sono diverse opzioni:

  • un piccolo script javascript da eseguire direttamente nella console del browser;
  • una semplice applicazione da riga di comando, scritta in Python o Node.js, che vada a manipolare il browser tramite un tool come Selenium o Puppeteer;
  • un’estensione di Chrome.

Fra queste tre opzioni, la terza sembra quella più comoda: per poter creare le annotazioni, gli utenti devono già avere installato l’estensione Google Scholar PDF Reader: installare un’estensione rientra quindi nelle loro competenze. Inoltre, ci permette di evitare di dover gestire dettagli di sicurezza (cookies di sessione, autenticazione, ecc.) che sarebbero stati necessari se avessimo optato per la seconda opzione, e non dà lo stesso senso di “insicurezza” e non richiede la stessa (seppur minima) dimestichezza con il coding che invece caratterizzano la prima opzione.

Funzionamento dell’estensione

Le estensioni di Chrome sono piuttosto flessibili e possono eseguire del codice in diversi contesti, a seconda delle necessità. Ciò che fa al caso nostro sono due categorie di script: i content script e i background script.

  • I content script sono eseguiti all’interno della pagina web che l’utente sta visualizzando. Possono interagire con il DOM della pagina, ed in generale hanno accesso a tutte le funzionalità di uno script importato direttamente nell’HTML.
  • I background script sono eseguiti in un contesto separato. Sono utilizzati per gestire lo stato dell’estensione, comunicare con i content script e non bloccano l’esecuzione della pagina web.

La necessità per il primo tipo di script dovrebbe essere evidente: dobbiamo poter leggere lo stato della pagina (per avere accesso agli articoli annotati dall’utente) e modificarne il contenuto (aggiungere dei comandi). Il secondo script ci permette di utilizzare la libreria pdf.js, che utilizzeremo per arricchire le annotazioni con il contesto della frase completa. Per farlo, dobbiamo manualmente scaricare il PDF original, parsarlo correttamente, cercare l’evidenziazione e restituire la frase completa. La maggior parte dei passaggi in questa pipeline può andare storto molto facilmente (il PDF potrebbe non essere disponibile, il testo irriconoscibile, la frase divisa fra troppe sezioni diverse), ma qualcosa che funziona una buona percentuale delle volte sarebbe già un successo.

I due script comunicano fra di loro tramite un sistema di messaggi asincroni: il content script invia una richiesta, e riceve la risposta dopo che il background script ha completato l’elaborazione.

Loading diagram...

Linguaggio

Per quanto riguarda il linguaggio da utilizzare, la scelta è stata abbastanza semplice: TypeScript. Dovendo essere eseguito in un contesto di browser, il tutto deve essere ovviamente transpilato in JavaScript e possibilmente minificato, entrambe operazioni che RollDown gestisce in maniera eccellente e senza troppi fronzoli. Infatti, è sufficiente installare rollup, creare un file di configurazione e aggiungere uno script di build al file package.json per ottenere un bundle pronto da distribuire.

# Installazione di Rollup e le definizioni
# dei tipi di Chrome per TypeScript
npm install --save-dev rollup chrome-types
// rollup.config.ts
import { defineConfig } from "rolldown";

export default defineConfig({
  input: "src/content.ts",
  output: {
    file: "dist/content.js",
    minify: true,
  },
});
// package.json
{
  "scripts": {
    "build": "rollup -c",
    "watch": "rollup -c -w"
  }
}

Manifest

Un estensione di Chrome è composta da un insieme di file, fra i quali il più importante è il manifest.json, che contene le informazioni necessarie al browser per poterla installare e gestire. I campi sono abbastanza autoesplicativi, ma per maggiori informazioni è sempre consigliato consultare la documentazione ufficiale. L’unica cosa degna di nota è l’utilizzo delle stringhe localizzate __MSG_extension_name__ e __MSG_extension_description__, che permettono di fornire una traduzione dei campi name e description in base alla lingua dell’utente. Per dare loro il valore corretto, è necessario creare un file messages.json all’interno della cartella _locales/<lang>/, dove <lang> è il codice della lingua (ad esempio en per l’inglese, it per l’italiano, ecc.).

{
  "$schema": "https://json.schemastore.org/chrome-manifest",
  "manifest_version": 3,
  "name": "__MSG_extension_name__",
  "version": "0.0.1",
  "description": "__MSG_extension_description__",
  "icons": {
    "48": "./assets/icons/icon-48.png",
    "128": "./assets/icons/icon-128.png"
  },
  "default_locale": "en",
  "content_scripts": [
    {
      "js": ["dist/content.js"],
      "matches": [
        "https://developer.chrome.com/docs/extensions/*",
        "https://developer.chrome.com/docs/webstore/*"
      ]
    }
  ]
}
.
├── _locales
   ├── en
   └── messages.json
   └── it
       └── messages.json
└── ...
// _locales/it/messages.json
{
  "extension_name": {
    "message": "Esportatore di annotazioni da Google Scholar",
    "description": "The name of the extension"
  },
  "extension_description": {
    "message": "Esporta le tue annotazioni del lettore PDF di Google Scholar.",
    "description": "The description of the extension"
  }
}

Templating

Ci sono parecchi templating engines in Javascript, anche se molti (ragionevolmente) sono principalmente orientati alla generazione di HTML. Dopo una breve ricerca, ho deciso di utilizzare Handlebars.js: ha una sintassi simile a quella a cui sono abituato, è piuttosto semplice da utilizzare, è piuttosto utilizzato ed è ben documentato.

Utilizzare un template engine introduce delle difficoltà che non avevo considerato. Chrome è estremamente stringente per quanto riguarda la sicurezza: a tutti gli script viene associato un Content Security Policy minimale che non permette l’esecuzione di codice inline tramite eval(), new Function(), o simili.
La prima soluzione che ho trovato è stata quella di precompilare i template, trasformandoli in normalissimi file Javascript da includere nell’estensione. Una volta integrati nel workflow di build del bundle, il tutto funziona correttamente, ed ovviamente anche l’efficienza migliora notevolmente. Tuttavia, questo approccio ha un grosso svantaggio: non è possibile modificare o aggiungere nuovi template senza ricompilare l’estensione, ed io volevo che gli utenti avessero la possibilità di utilizzare dei template personalizzati senza dover smanettare con il codice sorgente.
C’è un workaround noto, ovvero relegare queste operazioni pericolose ad uno script all’interno di un iframe sandbox. La complicazione è che non è possibile aggiungere questo script ovunque: innanzitutto, la pagina HTML deve essere dichiarata nel manifest.json, e in secondo luogo, l’iframe può essere inserito solo in pagine che appartengono al dominio dell’estensione, il che esclude di farlo direttamente nella pagina di Google Scholar, tramite il content script. Fortunatamente, ho trovato una soluzione anche a questa problematica, ispirato da questo articolo: utilizzando l’API Offscreen, è possibile creare un documento HTML “invisibile”. Sebbene non sia considerato un sandbox, da questo possiamo fare un ulteriore passo e inserire l’iframe del sandbox che ci permette di eseguire codice inline senza problemi di sicurezza. Il risultato è quello sperato, anche se la cascata di chiamate non è certo la più elegante.

Loading diagram...

Utilizzare RollDown come build system

RollDown presenta già un sistema di plugin e funzionalità piuttosto avanzate, soprattutto considerando il fatto che si tratta di un progetto relativamente giovane. Le uniche funzionalità che ho dovuto aggiungere personalmente sono state:

  • la possibilità di includere file raw (come i file HTML) nel bundle finale;
  • compilare i template HBS in file JS;
  • tracciare l’aggiornamento dei file HTML e template HBS, in modo da aggiornare lo stato dell’estensione senza dover riavviare il processo di watch;

Nulla di particolarmente complesso. Il risultato è stato il seguente file di configurazione:

import { defineConfig } from "rolldown";
import Raw from "unplugin-raw/rolldown";
import build from "./scripts/build";
import path from "path";
import fs from "fs";

export default defineConfig(config => {
  const minify = config.watch ? false : true;
  build();
  return [
    {
      input: "src/content.ts",
      output: {
        dir: "dist",
        minify: minify,
      },
      plugins: [
        Raw(),
        {
          name: "handlebars-and-html-watcher",
          buildStart() {
            // Aggiunge i file HTML e HBS alla lista dei file da osservare
            fs.globSync(path.join(__dirname, "/src/**/*.{html,hbs}")).forEach(
              this.addWatchFile.bind(this)
            );
          },
          // Compila i template HBS quando vengono modificati
          watchChange(id) {
            if (id.endsWith(".hbs")) build();
          },
        },
      ],
    },
    {
      input: "src/background.ts",
      output: {
        dir: "dist",
        minify: minify,
      },
      plugins: [Raw()],
    },
    // ...
  ];
});

dove Raw() è un plugin che permette di includere file raw nel bundle, e build() è una funzione che si occupa di compilare i template HBS in file JS, e di copiare i file HTML nella cartella dist/.

import Handlebars from "handlebars";
import fs from "fs";

export default function precompileTemplates() {
  // Leggi tutti i file HBS nella cartella src/templates
  fs.readdirSync("src/templates")
    .filter(file => file.endsWith(".hbs"))
    .map(file => file.replace(".hbs", ""))
    .forEach(f => {
      // Precompila ogni file HBS e scrivi il risultato in un file JS
      const template = fs.readFileSync(`src/templates/${f}.hbs`, "utf-8");
      const specification = Handlebars.precompile(template, {
        destName: `${f}.hbs.js`,
        srcName: `${f}.hbs`,
        knownHelpersOnly: true,
        knownHelpers: {
          escapeDoubleQuotes: true,
          escapePipe: true,
          escapeTex: true,
        },
      }) as unknown as { code: string };
      fs.writeFileSync(
        `src/templates/${f}.hbs.js`,
        `export default ${specification.code};`
      );
    });
}

Limitazioni

Nel suo stato attuale, “Google Scholar annotation exporter” presenta diverse limitazioni, alcune delle quali apparentemente insormontabili.

LimitazioneCausaPossibile soluzione
Rate limiting di GoogleLimiti di utilizzo di Google ScholarUtilizzare il browser in incognito, cambiare connessione, cambiare computer
Fallimento nell’estrazione del contestoPDF non disponibile, struttura del PDF irregolareAssicurarsi che l’URL sia valido, preferire PDF con strutture facilmente parsabili TODO 1
PDF protetto da CORSMolti PDF sono protetti da cross-origin requests policiesTODO 2

TODO

  1. Permettere agli utenti di sovrascrivere i link ai PDF forniti da Google Scholar con delle alternative indicate da loro. In pratica si tradurrebbe con un sistema con una mappa URL => URL. Se uno qualsiasi degli URL che sta per essere utilizzato appare come chiave nella mappa, il suo valore viene utilizzato al suo posto.
  2. Creare un sistema che aggiri il CORS. Magari creare un componente esterno al browser che faccia da proxy, oppure utilizzare un servizio esterno come CORS Anywhere, o anche utilizzare un servizio come PDF.co per scaricare i PDF.
  3. Permettere logiche custom nel parsing del PDF. Funzionalità particolarmente delicata e piuttosto discutibile dal punto di vista della sicurezza, ma che potrebbe essere utile per alcuni utenti avanzati.
  4. Aggiungere ulteriori opzioni di esportazione, come ad esempio il formato BibTeX o EndNote.