Assistente indipendente per Pegaso, Mercatorum e San Raffaele
Nel capitolo precedente abbiamo seguito i messaggi mentre attraversavano popup, background, bridge e MAIN world.
Ora arriviamo al punto in cui PlumePilot deve fare qualcosa di più difficile: capire una pagina web costruita da qualcun altro mentre quella pagina continua a cambiare sotto i suoi piedi.
È facile descrivere un content script dicendo:
legge il DOM e clicca alcuni elementi.
Tecnicamente è vero.
Ma è una descrizione quasi inutile.
Il vero problema non è trovare un elemento una volta.
Il vero problema è rispondere a domande come:
.click(), quando posso considerare concluso l’effetto del click?Queste non sono domande specifiche di PlumePilot.
Sono problemi tipici di qualunque software che debba integrarsi con una UI dinamica non controllata: browser extension, userscript, test end-to-end, automazione, accessibility tooling, web scraping interattivo o integrazioni con applicazioni legacy.
In questo capitolo useremo PlumePilot per imparare una regola fondamentale:
Il DOM è una rappresentazione temporanea dello stato dell’applicazione, non necessariamente lo stato dell’applicazione stesso.
Quando il browser interpreta HTML costruisce una struttura ad albero di oggetti JavaScript.
Per esempio:
<div class="course">
<div class="chapter">
<span>1 - Introduzione</span>
</div>
</div>
può essere immaginato come:
Document
└── div.course
└── div.chapter
└── span
└── "1 - Introduzione"
JavaScript può attraversare e modificare quell’albero:
document.querySelector(".chapter");
Il risultato è un oggetto Element che rappresenta quel nodo in quel momento.
Questa ultima parte è importante.
In un sito moderno la pagina può fare:
stato applicativo cambia
↓
frontend ricalcola la UI
↓
vecchi nodi rimossi
↓
nuovi nodi creati
Visivamente l’utente può vedere ancora:
1 - Introduzione
ma JavaScript potrebbe trovarsi davanti a un oggetto completamente diverso.
Il testo è lo stesso.
L’identità DOM no.
Supponiamo di fare:
const row = document.querySelector(".lesson");
Dopo qualche secondo la piattaforma aggiorna la sidebar e ricrea quel ramo del DOM.
Ora possono esistere due realtà:
variabile row ───────► vecchio nodo, ormai staccato
DOM corrente ────────► nuovo nodo che rappresenta la stessa lezione
La variabile continua a contenere un oggetto valido.
Ma quell’oggetto potrebbe non appartenere più al documento corrente.
Questo tipo di riferimento viene spesso chiamato stale reference: un riferimento che era corretto quando è stato ottenuto, ma non rappresenta più il componente attuale della UI.
È una distinzione che incontreremo ripetutamente:
DOM identity ≠ domain identity
Per PlumePilot:
"questo specifico <div>" ≠ "questa specifica lezione"
La lezione è un concetto del dominio.
Il <div> è soltanto una delle sue rappresentazioni temporanee.
lessons()In content.js PlumePilot individua le righe delle lezioni così:
function lessons() {
return [...document.querySelectorAll("div.border-t")].filter((x) =>
x.querySelector(":scope > div.cursor-pointer"),
);
}
Riferimento reale: content.js L647-L651.
Notiamo già una scelta importante.
La funzione non conserva una lista globale del tipo:
const allLessons = document.querySelectorAll(...);
E poi la riutilizza per sempre.
Ogni chiamata a lessons() riesegue la query sul DOM corrente.
Questo è particolarmente appropriato in una pagina dinamica.
querySelectorAll() restituisce uno snapshot staticoSecondo la documentazione DOM, querySelectorAll() restituisce una NodeList statica, non live.
In altre parole:
const rows = document.querySelectorAll(".lesson");
fotografa l’insieme dei nodi che corrispondono in quel momento.
Se successivamente il DOM cambia, rows non si aggiorna automaticamente con i nuovi elementi.
PlumePilot converte subito la NodeList in un array:
[...document.querySelectorAll(...)]
Da quel momento è ancora più chiaramente uno snapshot.
Possiamo immaginarlo così:
t0
DOM: A B C
querySelectorAll() → [A, B, C]
t1
DOM: A D C
array precedente → [A, B, C]
nuova query → [A, D, C]
Il vecchio array non acquisisce magicamente D.
E B può perfino essere stato rimosso dalla pagina.
Per questo funzioni come:
lessons()
chapters()
sections()
sono più interessanti di quanto sembrino.
Non sono semplicemente utility.
Sono lettori dello stato visibile corrente.
MDN descrive querySelectorAll() come una query che restituisce una NodeList non live:
https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelectorAll
Consideriamo ancora:
document.querySelectorAll("div.border-t")
Quella classe non è stata definita da PlumePilot.
Appartiene alla piattaforma.
Quindi la funzione dipende implicitamente da un contratto esterno:
la piattaforma continuerà a rappresentare le righe delle lezioni
con una struttura compatibile con questo selettore
Questo contratto non è formalizzato in una API.
Nessuno promette a PlumePilot che .border-t esisterà anche domani.
Per questo l’integrazione DOM è intrinsecamente più fragile di una API versionata.
Immaginiamo due possibilità.
div > div:nth-child(3) > div:nth-child(2) > span
[aria-label="lesson-progress"]
Il primo dipende fortemente dal layout.
Il secondo dipende da un significato.
Idealmente preferiremmo sempre elementi con identificatori semantici stabili:
data-* dedicati
aria-label
role
id documentati
URL
identificatori di dominio
Ma quando integriamo una pagina che non controlliamo, spesso questi dati non esistono.
PlumePilot deve quindi combinare diversi segnali:
classi CSS
struttura relativa
contenuto testuale
URL
icone
stato visuale
API
Non esiste un selettore universalmente perfetto.
La domanda corretta diventa:
Quale combinazione di segnali è abbastanza stabile per questo compito?
:scope riduce le ambiguitàIn lessons() troviamo:
x.querySelector(":scope > div.cursor-pointer")
>, in CSS, significa figlio diretto.
:scope indica l’elemento sul quale stiamo eseguendo la query.
Quindi non stiamo chiedendo:
esiste da qualche parte sotto questa riga un elemento cliccabile?
Stiamo chiedendo:
il figlio diretto previsto dalla struttura della riga è cliccabile?
La differenza sembra piccola, ma riduce i falsi positivi.
Questo:
row
└── wrapper.cursor-pointer
corrisponde.
Questo:
row
└── qualcos'altro
└── qualche widget interno
└── .cursor-pointer
non basta.
È un esempio di selettore che codifica una piccola aspettativa strutturale, non soltanto una classe.
closest() legge il contesto dell’elementoPlumePilot utilizza spesso anche closest().
Per esempio, nell’identificazione di capitoli Mercatorum:
const clickable =
span.closest("div.cursor-pointer.relative.align-middle") ||
span.closest("div.cursor-pointer");
Riferimento: content.js L1180-L1198.
closest() parte dal nodo corrente e risale verso gli antenati fino a trovare il primo match.
È utile quando conosciamo un elemento interno significativo ma l’azione reale appartiene a un contenitore superiore.
Mentalmente:
clickable div
└── wrapper
└── span con "3 - Titolo"
▲
│
partiamo qui
closest() ci permette di recuperare il componente interattivo che contiene quello span.
Documentazione:
https://developer.mozilla.org/en-US/docs/Web/API/Element/closest
sections() è già un piccolo adapter multipiattaformaQuando PlumePilot supportava soltanto una piattaforma, una certa struttura DOM poteva sembrare universale.
Con Pegaso, Mercatorum e San Raffaele, questa assunzione non regge più.
Guardiamo:
function sections() {
const nativeSections = pegasoStyleSections();
if (nativeSections.length) return nativeSections;
const fallbackChapters = mercatorumChapterRows();
if (!fallbackChapters.length) return [];
return [{
outer: document.documentElement,
header: null,
span: fallbackChapters[0].clickable,
text: MERCATORUM_STATIC_SECTION,
static: true,
}];
}
Riferimento: content.js L1201-L1213.
Il resto dell’algoritmo vuole ragionare in termini di:
sections
└── chapters
└── activities
Ma non tutte le piattaforme espongono esattamente la stessa UI.
Invece di spargere continuamente condizioni come:
if (isMercatorum) ...
else if (isPegaso) ...
ovunque, sections() prova a restituire una forma concettuale comune.
Questo è un primo esempio di DOM adapter.
La pagina dice:
"io rappresento il corso così"
PlumePilot traduce:
"per il mio dominio, questa cosa è una section"
Questa strategia è molto simile a ciò che faresti lato backend con provider differenti.
Immaginiamo:
Provider A → customer_id
Provider B → userCode
Provider C → account.id
L’application layer preferisce ricevere:
{
customerId: ...
}
in tutti i casi.
Qui abbiamo lo stesso principio:
DOM Pegaso ───────┐
├──► modello concettuale PlumePilot
DOM Mercatorum ───┘
Questa separazione diventerà ancora più importante quando parleremo dell’architettura che potremmo adottare se riscrivessimo PlumePilot oggi.
document_idle non significa “applicazione pronta”Nel manifest.json, content.js viene caricato con:
"run_at": "document_idle"
Potremmo interpretarlo ingenuamente così:
content.js parte
↓
la pagina è pronta
Ma document_idle riguarda il caricamento del documento dal punto di vista dell’estensione.
Non significa che tutti i dati asincroni dell’applicazione host siano già arrivati, né che ogni componente UI sia stato renderizzato.
Una Single Page Application può fare:
HTML iniziale disponibile
↓
content script avviato
↓
fetch applicativo
↓
risposta API
↓
render capitoli
↓
render lezione attiva
Quindi al tempo t0 questo può essere vero:
chapters().length === 0
mentre al tempo t0 + 800 ms:
chapters().length === 42
Nessuna delle due letture è “sbagliata”.
Descrivono due momenti diversi.
Il changelog di PlumePilot conserva una regressione molto istruttiva.
Nella v2.14.5 venne corretto il comportamento della ricerca della prima attività incompleta dopo un reload:
Riferimento storico: CHANGELOG.md L793-L799.
Il punto fondamentale è questo:
"non trovato ora"
non equivale necessariamente a:
"non esiste"
Può significare:
"non è ancora stato renderizzato"
Sono tre stati semanticamente diversi.
Molti bug DOM nascono perché modelliamo implicitamente una condizione come booleana:
found / not found
quando la realtà è più simile a:
FOUND
NOT_READY_YET
DEFINITELY_MISSING
Questa distinzione è fondamentale nelle UI asincrone.
Se chapters() restituisce zero elementi durante lo startup, come facciamo a sapere se:
Dal solo array vuoto non possiamo distinguerli.
Serve contesto temporale e applicativo.
waitFor()PlumePilot possiede una utility semplice ma centrale:
async function waitFor(fn, timeout = WAIT_MS) {
let start = Date.now();
while (Date.now() - start < timeout) {
const x = fn();
if (x) {
return x;
}
await sleep(100);
}
return null;
}
La versione reale gestisce anche la visibilità della pagina; il cuore del pattern però è questo.
Riferimento: content.js L1577-L1609.
Invece di dire:
await sleep(2000);
const chapter = findChapter(...);
PlumePilot dice:
const chapter = await waitFor(() => findChapter(...));
La differenza concettuale è enorme.
aspetta esattamente 2 secondi
↓
prova
Problemi:
la pagina è pronta dopo 100 ms → hai perso 1.9 secondi
la pagina è pronta dopo 2.1 s → fallisci comunque
ogni 100 ms:
la condizione è vera?
sì → continua subito
no → riprova fino al timeout
Ora il tempo massimo è una deadline, non la previsione esatta di quanto impiegherà la UI.
Questo è un pattern generale molto utile.
waitFor() implementa una forma di polling.
Il polling significa verificare periodicamente una condizione:
t0 check
false
t0+100 check
false
t0+200 check
true
↓
continua
Il polling viene spesso visto come meno elegante di un evento dedicato.
Ma quando integriamo una UI che non controlliamo, un evento affidabile può semplicemente non esistere.
In quel caso un polling:
può essere una soluzione robusta.
Se controllassimo la piattaforma potremmo avere qualcosa del tipo:
courseStore.on("chaptersLoaded", callback);
oppure:
window.dispatchEvent(new CustomEvent("course-ready"));
Ma PlumePilot non possiede quel contratto.
Deve inferire la readiness dall’esterno.
Quindi usa strumenti osservabili:
DOM
URL
video
API
MutationObserver
polling
Questa è una differenza fondamentale tra software che possiede lo stato e software che osserva lo stato di un altro sistema.
MutationObserver: sapere che qualcosa è cambiatoAlla fine di content.js troviamo:
function scan() {
document.querySelectorAll("video").forEach(attach);
const activeRow = currentLesson();
if (activeRow) rememberPlaybackLesson(activeRow);
scheduleSessionConflictScan();
if (!courseProgressState || courseProgressState.baselinePercent === null) {
scheduleCourseProgressInitialization(250);
}
}
new MutationObserver(scan).observe(document.documentElement, {
childList: true,
subtree: true,
});
scan();
Riferimento: content.js L7066-L7090.
La sequenza è:
1. scan iniziale
2. osserva mutazioni future
3. ogni volta che cambia l'albero → scan()
Questo risolve un problema essenziale.
Il video che ci interessa potrebbe non esistere ancora quando parte PlumePilot.
MutationObserverMutationObserver è una API standard del browser per ricevere notifiche quando cambia il DOM.
Qui PlumePilot osserva:
{
childList: true,
subtree: true,
}
cioè:
childList → nodi aggiunti o rimossi
subtree → non soltanto figli diretti, ma tutto il sottoalbero
Il target è:
document.documentElement
quindi praticamente l’intero documento.
MDN:
https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver
Questa distinzione è importante.
MutationObserver può dirci:
è stato aggiunto un nodo
ma non sa che quel nodo rappresenta:
"il nuovo player video della lezione 7"
Il significato lo deve ricostruire PlumePilot.
Per questo il callback non tenta di interpretare direttamente ogni singola mutation.
Fa semplicemente:
scan();
e scan() ricalcola le informazioni che interessano.
È una strategia molto comune:
mutation notification
↓
"qualcosa potrebbe essere cambiato"
↓
ricalcola una piccola vista dello stato
Possiamo pensare al MutationObserver come a un segnale di invalidazione:
cache mentale:
"so quali video esistono"
DOM mutation
↓
questa conoscenza potrebbe non essere più valida
↓
scan()
È concettualmente simile a una cache invalidation.
L’observer non ci consegna necessariamente la nuova verità già pronta.
Ci dice che dobbiamo rileggerla.
document.querySelectorAll("video").forEach(attach)Una delle responsabilità di scan() è:
document.querySelectorAll("video").forEach(attach);
Questo pattern risolve il caso:
PlumePilot parte
↓
nessun <video>
↓
frontend carica la lezione
↓
crea <video>
↓
MutationObserver
↓
scan()
↓
attach(video)
Senza observer, il primo scan con zero video potrebbe essere l’ultimo.
Osservare tutto:
document.documentElement
con:
subtree: true
significa ricevere notifiche per molte mutazioni potenzialmente irrilevanti.
In una UI complessa potrebbero cambiare:
Per questo un observer dovrebbe idealmente avere callback poco costosi o meccanismi di debounce/coalescing.
Nel caso di PlumePilot scan() è relativamente mirato, ma il principio architetturale rimane:
Osservare un’area ampia è comodo, ma aumenta il numero di invalidazioni che dobbiamo saper assorbire.
Potremmo immaginare due strategie.
observe(document.documentElement, {
childList: true,
subtree: true,
});
Vantaggi:
Svantaggi:
observe(sidebar, {
childList: true,
subtree: true,
});
Vantaggi:
Svantaggi:
sidebar viene sostituita interamente, stiamo osservando il vecchio nodo;In un DOM ostile o poco stabile, l’approccio più generale può essere pragmaticamente migliore.
Arriviamo a uno dei casi studio più importanti.
Una lezione termina.
PlumePilot deve:
1. confermare il completamento
2. trovare la lezione corrente
3. trovare quella successiva
4. aprirla
Sembra semplice.
Ma durante l’attesa la piattaforma può aggiornare la sidebar.
Oppure l’utente può chiudere l’accordion.
Il changelog della v2.35.1 ricorda esplicitamente:
miglioramento del recupero con accordion chiusi, handoff dagli Obiettivi e routing dei capitoli.
Riferimento: CHANGELOG.md L3-L10.
Il codice contiene una spiegazione ancora più interessante:
// Completion can update the sidebar or the user can close its accordion
// while we wait. Keep the identity so a detached row is never used for
// finding the next activity after the wait.
const completedLesson = playbackContext;
Riferimento: content.js L6680-L6683.
Queste tre righe descrivono un concetto architetturale molto importante.
Supponiamo che cur sia la riga corrente:
const cur = currentLesson();
Poi aspettiamo che la piattaforma confermi il completamento:
await waitForLessonCompletion(cur, video);
Durante l’attesa:
sidebar vecchia
└── row A ← cur
può essere sostituita con:
sidebar nuova
└── row B ← stessa lezione dal punto di vista del dominio
Ora cur continua a puntare a row A.
Ma row A non descrive più il DOM corrente.
Se cercassimo:
nextLesson(cur)
potremmo calcolare il successore partendo da una struttura ormai obsoleta.
PlumePilot introduce playbackContext:
playbackContext = {
videoElement,
courseCode: courseCodeFromUrl(),
lessonNumber: lessonNumberFromUrl(),
identity,
name: lessonName(row),
index,
};
Riferimento: content.js L663-L677.
Qui non memorizziamo soltanto il nodo.
Memorizziamo dati che ci permettono di ritrovare concettualmente la stessa lezione:
courseCode
lessonNumber
chapter identity
lesson name
index
Questa è una strategia fondamentale:
riferimento fragile
↓
estrai identità stabile
↓
quando serve, risolvi di nuovo l'identità sul DOM corrente
recoverPlaybackLesson()La funzione di recupero rende il pattern esplicito:
async function recoverPlaybackLesson(videoElement) {
const visible = currentLesson();
if (visible) {
rememberPlaybackLesson(visible, videoElement);
return visible;
}
const context = playbackContext;
// verifica che il contesto appartenga ancora allo stesso corso/modulo
// ...
if (!(await openChapter(context.identity))) return null;
const row = await waitFor(() => playbackLessonRow(context));
if (row) rememberPlaybackLesson(row, videoElement);
return row;
}
Riferimento: content.js L691-L705.
Il ragionamento è:
la riga è ancora visibile?
│
├── sì → usa la versione corrente
│
└── no
↓
ho un'identità salvata?
↓
riapri il capitolo
↓
ritrova la riga nel DOM nuovo
Questo è molto più robusto di:
const current = currentLesson();
await somethingAsync();
useForever(current);
Potremmo chiamare questa strategia resolve late.
Invece di risolvere un riferimento una volta e trascinarlo per tutta un’operazione lunga:
resolve DOM node
↓
wait 5 seconds
↓
use old node
manteniamo un’identità e risolviamo il nodo vicino al momento dell’uso:
store logical identity
↓
wait
↓
resolve current DOM node
↓
use it
È simile a memorizzare un userId invece di un oggetto ORM che potrebbe essere stale dopo una transazione lunga.
Pensa a:
const user = await repo.findById(id);
await longExternalOperation();
// assumere che user rappresenti ancora lo stato attuale
contro:
const userId = id;
await longExternalOperation();
const freshUser = await repo.findById(userId);
Non è esattamente lo stesso problema, ma il modello mentale è sorprendentemente simile.
.click() è l’inizio di una transizioneIn openSection() PlumePilot fa:
section.span.scrollIntoView({ block: "center", behavior: "instant" });
section.span.click();
Ma non ritorna immediatamente true.
Subito dopo verifica:
const opened = await waitFor(() => {
section = getSection();
return isOpen() && section ? section : null;
});
Riferimento: content.js L1668-L1705.
Quindi:
.click()
↓
richiesta di transizione
↓
frontend reagisce
↓
DOM cambia
↓
PlumePilot verifica il nuovo stato
Non:
.click() == operazione conclusa
Questo pattern può essere descritto come:
COMMAND
↓
WAIT
↓
VERIFY
Per esempio:
clicca capitolo
↓
attendi
↓
chevron-up esiste?
È concettualmente simile a sistemi distribuiti dove:
invia richiesta
↓
attendi convergenza
↓
leggi stato risultante
La UI è locale, ma la natura asincrona introduce una dinamica simile.
Questo codice è fragile:
chapter.span.click();
return isChapterOpen(identity);
Perché il click può innescare:
event handler
↓
aggiornamento di stato framework
↓
scheduling render
↓
DOM update
Il rendering non deve necessariamente essersi completato prima della riga JavaScript successiva.
PlumePilot quindi verifica il risultato in un intervallo temporale.
getSection() dentro il pollingDentro openSection():
const getSection = () =>
sections().find((section) => section.text === sectionText);
Poi, durante l’attesa:
section = getSection();
Perché non usare semplicemente l’oggetto section originale?
Perché il click potrebbe aver provocato una ricostruzione del DOM.
PlumePilot preferisce recuperare una versione fresca della section.
Ancora una volta:
identità: sectionText
rappresentazione corrente: getSection()
fresh nel codice non è casualeIn diverse parti del progetto compaiono variabili come:
fresh
freshModal
freshTest
Non è soltanto stile di naming.
Esprime una preoccupazione architetturale reale:
dopo un’operazione asincrona, verifica di nuovo il DOM invece di assumere che il vecchio nodo sia ancora autorevole.
Questa è una disciplina molto utile quando si lavora con UI dinamiche.
Molte UI ad accordion non mantengono necessariamente tutti i contenuti figli sempre renderizzati.
Quando un capitolo è chiuso possiamo avere:
Chapter header
Quando è aperto:
Chapter header
├── Video 1
├── Video 2
├── Test
└── Obiettivi
Quindi una query come:
find lesson row
può fallire non perché la lezione non esista, ma perché il contenitore che la rende visibile è chiuso.
Questo è un esempio di differenza tra:
logical state
e:
rendered state
openChapter() tratta l’apertura come una precondizionePlumePilot verifica prima se il capitolo è aperto:
const isOpen = () => {
const fresh = getChapter();
if (!fresh) return false;
const outer = getOuterChapter(fresh);
return !!outer?.querySelector('[id*="chevron-up"]');
};
Se non lo è:
chapter.span.click();
Poi attende una nuova lettura:
const readOpenedChapter = () => (isOpen() ? getChapter() : null);
const opened = await waitFor(readOpenedChapter);
Riferimento: content.js L5502-L5561.
Il capitolo è quindi trattato come una risorsa che deve trovarsi nello stato:
OPEN
prima di poter effettuare certe query sui figli.
Questo pattern può essere generalizzato:
voglio eseguire X
↓
precondizione P è vera?
│
├── sì → X
│
└── no → prova a ripristinare P
↓
verifica P
↓
X
Per PlumePilot:
voglio trovare la lezione
↓
capitolo aperto?
│
├── sì
└── no → aprilo → verifica → continua
È una forma di self-healing locale.
PlumePilot cerca la percentuale visuale con:
function readPegasoCoursePercentage() {
const candidates = [...document.querySelectorAll("div.percent")]
.filter((element) =>
element.querySelector("circle.circular-progress") &&
element.querySelector(".number-container"),
)
.map((element) => {
const match = String(element.textContent || "")
.trim()
.match(/^(\d{1,3})%$/);
const rect = element.getBoundingClientRect();
return match && rect.width > 0 && rect.height > 0
? Math.max(0, Math.min(100, Number(match[1])))
: null;
})
.filter((value) => value !== null);
return candidates.length === 1 ? candidates[0] : null;
}
Riferimento: content.js L4347-L4362.
Questa funzione è didatticamente molto interessante.
.percentIl codice non fa:
return document.querySelector(".percent").textContent;
Filtra invece i candidati usando più segnali:
classe .percent
+
contiene circular-progress
+
contiene number-container
+
testo esattamente NN%
+
dimensioni visibili
Poi impone una regola ulteriore:
return candidates.length === 1 ? candidates[0] : null;
Se esistono due candidati plausibili, PlumePilot preferisce non indovinare.
Questo è un principio molto importante.
Consideriamo:
0 candidati → non so
1 candidato → probabilmente corretto
2 candidati → ambiguo
Una strategia ingenua potrebbe fare:
return candidates[0];
Ma sarebbe una scelta arbitraria.
PlumePilot usa:
ambiguità → null
Il null non è sempre un fallimento.
Può rappresentare una decisione deliberata:
non possiedo abbastanza informazioni per affermare qualcosa con sicurezza.
Questa è una forma di fail-safe inference.
getBoundingClientRect() come filtro pragmaticoLa funzione controlla:
const rect = element.getBoundingClientRect();
rect.width > 0 && rect.height > 0
getBoundingClientRect() restituisce dimensioni e posizione dell’elemento rispetto al viewport.
Qui viene usato come euristica per escludere elementi che non occupano spazio visuale.
Documentazione:
https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect
Attenzione però: questo non equivale a una definizione universale di visibilità.
Un elemento può avere dimensioni positive ed essere:
opacity: 0
coperto da un altro elemento
fuori dalla parte visibile del viewport
Quindi il check significa più precisamente:
questo candidato possiede un rettangolo renderizzato non nullo.
È sufficiente per l’euristica specifica, non una funzione generale isVisible().
Il codice di progresso contiene:
const domPercent = readPegasoCoursePercentage();
if (domPercent !== null && courseProgressState.baselinePercent === null) {
courseProgressState.baselinePercent = domPercent;
courseProgressState.domPercent = domPercent;
}
Poi la percentuale visibile può essere calcolata con:
const value = exactAvailable
? Number(state.exactPercent)
: state.baselinePercent + Math.max(0, Number(state.sessionDelta) || 0);
Riferimenti:
Il DOM quindi fornisce una baseline.
Ma quando esiste un valore più preciso:
exactPercent
quello prevale.
Possiamo descrivere questa architettura come una gerarchia di evidenze:
valore esatto disponibile
│
├── sì → usa exactPercent
│
└── no
↓
baseline DOM
+
delta osservato nella sessione
Non esiste necessariamente una sola fonte che sappia tutto in ogni momento.
PlumePilot combina fonti con livelli diversi di affidabilità.
Questo ci prepara direttamente ai capitoli 04, 06 e 08:
DOM
API
cache
master call
stato locale
non avranno tutti lo stesso peso.
È utile distinguere due concetti.
Fonte considerata autorevole per determinare il valore reale.
Fonte che ci fornisce un’indicazione utile, senza essere necessariamente definitiva.
Il DOM spesso è una source of signal eccellente.
Per esempio:
la UI mostra 82%
è un ottimo punto di partenza.
Ma per alcune decisioni può essere preferibile confermare con dati più strutturati.
Nel codice del limite capitoli troviamo un commento molto interessante:
// The visible chapter label is not a stable identifier: Pegaso may rebuild
// it while lesson percentages change. The URL lesson number is the same
// display-order identity used by the course APIs and remains stable while
// PlumePilot moves between videos of the same chapter.
Il codice quindi preferisce:
const lessonNumber = lessonNumberFromUrl();
Riferimento al parser dell’URL: content.js L4336-L4345.
Questa è un’altra manifestazione dello stesso principio:
rappresentazione visuale mutevole
↓
identificatore logico più stabile
PlumePilot usa comunque anche testo:
{
sectionText: chapter.sectionText,
chapterText: chapter.text,
}
perché non sempre dispone immediatamente di ID migliori.
Quindi dobbiamo evitare una regola dogmatica del tipo:
non usare mai il testo come identità.
La domanda è sempre:
quali identificatori espone realmente il sistema che sto integrando?
Possiamo ordinare grossolanamente le preferenze così:
ID di dominio stabile
↓
ID/chiave nell'URL
↓
attributo semantico stabile
↓
combinazione di campi
↓
testo visuale
↓
posizione pura nel DOM
Ma ogni progetto può imporre compromessi diversi.
PlumePilot supporta corsi con più sezioni:
Analisi Matematica I
1 - ...
2 - ...
Analisi Matematica II
1 - ...
2 - ...
Se usassimo soltanto:
chapterNumber = 1
avremmo una collisione.
Per questo chapterIdentity() restituisce:
{
sectionText: chapter.sectionText,
chapterText: chapter.text,
}
Riferimento: content.js L1235-L1238.
Il changelog della v1.8.0 conserva esattamente questa decisione: i capitoli vengono identificati tramite sezione + titolo affinché la numerazione possa ricominciare da 1 in sezioni differenti.
Riferimento: CHANGELOG.md L1255-L1263.
Dal punto di vista dei dati, questo è molto simile a una chiave composta.
In un database potremmo avere:
PRIMARY KEY (section_id, chapter_number)
In PlumePilot abbiamo concettualmente:
(sectionText, chapterText)
Nessuno dei due valori è ideale quanto un ID ufficiale immutabile, ma la coppia distingue casi che il solo numero non saprebbe distinguere.
Alla fine di content.js troviamo anche:
document.addEventListener("click", (event) => {
const row = event.target?.closest?.("div.border-t");
if (row && row.querySelector(":scope > div.cursor-pointer")) {
setTimeout(() => {
const selected = currentLesson();
if (selected) rememberPlaybackLesson(selected);
}, 0);
}
}, true);
Riferimento: content.js L7080-L7089.
Questo è un esempio di event delegation.
Invece di aggiungere un listener a ogni riga:
row1 → listener
row2 → listener
row3 → listener
aggiungiamo un listener a un antenato stabile:
document → listener
Quando avviene un click, risaliamo dal target:
event.target.closest("div.border-t")
Se il frontend distrugge e ricrea le righe:
row A eliminata
row B creata
un listener attaccato direttamente ad A muore insieme ad A.
Il listener su document, invece, continua a esistere.
Quindi anche una futura row B può essere intercettata.
Questo è uno dei vantaggi più forti dell’event delegation nelle applicazioni dinamiche.
setTimeout(..., 0)?Dopo il click PlumePilot non legge immediatamente currentLesson().
Fa:
setTimeout(() => {
const selected = currentLesson();
// ...
}, 0);
Il timeout zero non significa:
esegui immediatamente
Significa, semplificando:
programma questo lavoro per un task futuro
Così il codice lascia prima terminare il task corrente e permette agli handler della pagina di aggiornare il proprio stato/DOM.
È una piccola forma di defer.
MDN, event loop e task/microtask:
https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide
Non garantisce che qualsiasi render asincrono sia concluso; infatti per operazioni più importanti PlumePilot usa waitFor(). Qui è sufficiente come tentativo di leggere la selezione immediatamente successiva al click.
La versione reale di waitFor() contiene più logica di quella mostrata inizialmente:
const hiddenEpochAtStart = pageHiddenEpoch;
const startedHidden = documentIsHidden();
Dopo il primo timeout controlla se la pagina è stata nascosta durante l’attesa e può concedere una nuova finestra di attesa quando torna visibile.
Perché?
Perché i browser applicano ottimizzazioni e throttling alle tab in background.
Quindi un timeout osservato in una tab nascosta può significare:
"l'operazione non è avvenuta"
oppure:
"la pagina non ha avuto le stesse opportunità temporali di quando era visibile"
Date.now() misura il tempo reale trascorso.
Ma il fatto che siano passati 5 secondi non implica che una tab in background abbia eseguito lo stesso volume di lavoro JavaScript/rendering di una tab attiva.
Quindi:
5 secondi wall-clock
non sono necessariamente:
5 secondi di progresso applicativo equivalente
Questa distinzione tornerà nel capitolo sull’asincronia.
Una domanda naturale potrebbe essere:
se abbiamo accesso ai dati delle API della piattaforma, perché continuare a leggere e manipolare il DOM?
Perché i due strumenti risolvono problemi differenti.
qual è la percentuale?
quali attività esistono?
qual è lo stato di una lezione?
qual è il dato strutturato del corso?
cosa è attualmente renderizzato?
quale riga è selezionata?
il capitolo è aperto?
dove posso cliccare?
il player esiste in questa pagina?
quale UI sta vedendo l'utente?
Non sono due tecnologie concorrenti.
Sono due proiezioni differenti del sistema.
Possiamo usare una distinzione informale:
API / dati strutturati
↓
data plane
DOM / elementi visibili
↓
presentation plane
PlumePilot deve spesso passare dall’uno all’altro.
Per esempio:
API: capitolo 5 contiene un'attività incompleta
↓
DOM: trova il componente che rappresenta capitolo 5
↓
DOM: aprilo
↓
DOM/API: verifica la specifica attività
Questa combinazione è alla base della ricerca moderna della prima attività incompleta, che studieremo nel Capitolo 8.
Possiamo classificare alcuni esempi.
"il terzo div della seconda colonna"
una classe CSS puramente stilistica
struttura + testo + ruolo visivo
URL + identificatore di dominio + conferma API
PlumePilot contiene combinazioni di tutti questi livelli perché è cresciuto insieme alla conoscenza della piattaforma.
Questo è normale nei software di integrazione.
Molte delle soluzioni più affidabili del progetto seguono implicitamente questa formula:
ROBUSTEZZA
≈
identità ragionevolmente stabile
+
lettura fresca
+
verifica post-azione
+
fallback
Esempio:
chapter identity
+
findChapter() fresco
+
click
+
waitFor(isOpen)
Non eliminiamo l’incertezza.
La gestiamo.
querySelector() può nascondere molto dominioGuardando soltanto questa riga:
const el = document.querySelector(...);
potremmo pensare che il problema sia “conoscere CSS selectors”.
Ma in un’integrazione reale dobbiamo decidere:
quando fare la query
su quale scope
con quale fallback
come riconoscere l'ambiguità
cosa fare se non esiste ancora
come capire se il nodo è stale
come riaprirne il contenitore
come validare l'effetto del click
come distinguere piattaforme diverse
Il selettore è quasi la parte meno interessante.
Gli stessi problemi compaiono in Playwright, Selenium, Cypress e strumenti simili.
Un test fragile spesso fa:
sleep 2 secondi
click sul terzo bottone
sleep 1 secondo
leggi testo
Un test più robusto ragiona in termini di:
aspetta la condizione significativa
individua l'elemento semanticamente
esegui l'azione
verifica lo stato risultante
PlumePilot non è un framework E2E, ma l’esperienza accumulata è molto simile.
CoursePageAdapterAttualmente molte funzioni DOM vivono direttamente in content.js.
Una possibile evoluzione architetturale potrebbe essere:
content.js
│
▼
CoursePageAdapter
│
├── getSections()
├── getChapters()
├── getCurrentLesson()
├── openChapter(identity)
├── readVisibleProgress()
└── findRenderedActivity(identity)
Poi implementazioni specifiche:
PegasoCoursePageAdapter
MercatorumCoursePageAdapter
UtsrCoursePageAdapter
Questo renderebbe esplicito qualcosa che nel codice attuale esiste già in forma più organica.
L’autoplay potrebbe ragionare in termini di:
await page.openChapter(identity);
senza conoscere classi CSS o chevron.
Potremmo testare il dominio contro un adapter finto:
fakePage.openChapter(...)
senza costruire sempre il DOM completo della piattaforma.
Se domani Pegaso cambia:
.bg-white.text-base...
potremmo modificare principalmente l’adapter della pagina.
Ogni astrazione ha un prezzo.
Dovremmo:
Quindi non significa che la struttura attuale sia “sbagliata”.
Significa che ora il progetto è abbastanza maturo da rendere visibile un boundary che all’inizio non era ancora evidente.
Questa è una delle lezioni più importanti dell’architettura evolutiva.
Immaginiamo:
utente sta guardando Video B
sidebar aperta
PlumePilot conosce row B
Il video termina.
PlumePilot aspetta che la piattaforma registri il completamento.
Nel frattempo può succedere:
A. la sidebar aggiorna le percentuali
B. React/il frontend ricrea le righe
C. l'utente chiude l'accordion
D. il nodo row B originale si stacca
L’algoritmo ingenuo sarebbe:
const row = currentLesson();
await waitForCompletion(row);
const next = nextLesson(row);
clickRow(next);
Il problema è l’ultima parte:
row potrebbe appartenere al vecchio DOM
Nel flusso attuale:
let cur = await recoverPlaybackLesson(v);
poi conserva:
const completedLesson = playbackContext;
attende:
const completed = await waitForLessonCompletion(cur, v);
quindi recupera di nuovo:
const stableCur = await recoverPlaybackLesson(v);
Infine verifica che la lezione recuperata corrisponda ancora al contesto completato.
Riferimento: content.js L6671-L6702.
Questa sequenza è importante:
resolve
↓
remember identity
↓
await external/dynamic change
↓
re-resolve
↓
validate identity
↓
continue
È quasi una piccola transazione ottimistica.
Non esiste un vero lock sul DOM.
PlumePilot non può dire alla piattaforma:
"non modificare la sidebar finché non ho finito"
Quindi procede ottimisticamente:
1. fotografa l'identità
2. lascia che il mondo cambi
3. verifica che le precondizioni siano ancora valide
4. solo allora continua
È concettualmente simile all’optimistic concurrency control:
read version
work
check version still compatible
commit
Qui la “versione” non è un numero esplicito, ma la corrispondenza tra il playback context e il DOM nuovamente risolto.
Quando una query ritorna null, le possibili cause sono molte:
1. selettore sbagliato
2. pagina non ancora renderizzata
3. accordion chiuso
4. siamo nella route sbagliata
5. tab nascosta / rendering ritardato
6. piattaforma differente
7. componente rimosso intenzionalmente
8. sessione cambiata
9. nodo presente in un frame differente
Un buon debug non dovrebbe saltare immediatamente alla conclusione:
il CSS selector non funziona.
Prima bisogna capire in quale stato si trova il sistema.
Messaggi come:
"Chapter did not open"
"Could not determine current lesson"
"Advance cancelled because the completed lesson changed or disappeared"
sono più utili di:
console.log(element);
perché descrivono l’invariante che è fallita.
Un log efficace dovrebbe rispondere a:
cosa stavamo cercando di fare?
quale precondizione mancava?
quale identità stavamo seguendo?
Non soltanto:
qual era il valore di questa variabile?
Prima di scrivere il selettore chiediti:
cosa rappresenta davvero questo elemento?
Esempio:
non "questo span"
ma "capitolo 5 della sezione X"
Solo dopo:
findChapter(identity)
Il DOM diventa un adapter verso quell’identità.
Chiediti:
quale condizione dimostra che il componente è pronto?
Non:
quanti millisecondi penso che servano?
Esempio:
chapters().length > 0
oppure:
isChapterOpen(identity)
Per esempio:
chapter.span.click();
await waitFor(() => isChapterOpen(identity));
Non assumere che l’azione abbia avuto successo soltanto perché non ha lanciato eccezioni.
Dopo ogni lunga operazione asincrona chiediti:
sto usando ancora un Element ottenuto prima dell'await?
Se sì, valuta se devi risolverlo di nuovo.
Questa domanda da sola può prevenire molti bug.
const chapter = findChapter(identity);
chapter.span.click();
await sleep(1000);
return chapter;
const chapter = findChapter(identity);
chapter.span.click();
return await waitFor(() => {
const fresh = findChapter(identity);
return fresh && isChapterOpen(identity) ? fresh : null;
});
Quale preferiresti?
La B.
Non perché 1000 sia necessariamente troppo corto.
Ma perché la B:
attende una condizione
+
risolve nuovamente il DOM
+
verifica il risultato
La A indovina il tempo e restituisce un riferimento potenzialmente stale.
const cachedLessons = lessons();
async function next() {
await somethingSlow();
return cachedLessons[3];
}
Perché l’array contiene riferimenti ottenuti da uno snapshot precedente.
La pagina può avere ricreato le righe durante somethingSlow().
Una soluzione migliore è conservare l’identità della lezione e richiamare lessons() o una funzione di lookup quando serve.
null?const percent = readPegasoCoursePercentage();
percent === null può significare:
A. non esistono indicatori
B. esistono ma non sono ancora renderizzati correttamente
C. esistono più candidati ambigui
D. siamo nella pagina sbagliata
E. il markup della piattaforma è cambiato
La risposta corretta è:
potenzialmente tutte.
Il chiamante deve interpretare il valore nel contesto.
Devi aspettare che un capitolo appena cliccato sia aperto.
Hai due possibilità:
MutationObserver globale
oppure:
waitFor(() => isChapterOpen(identity))
Quale useresti?
Per una singola transizione circoscritta, waitFor() è semplice e leggibile.
Per accorgersi in modo permanente che parti arbitrarie della pagina possono nascere in futuro, MutationObserver è più adatto.
Non esiste una API sempre superiore: rispondono a scale temporali differenti.
Hai questo elemento:
<span>2 - Funzioni</span>
Qual è l’identità?
Non possiamo saperlo dal solo nodo.
Potrebbero esistere:
Sezione A / 2 - Funzioni
Sezione B / 2 - Funzioni
Servono altre informazioni.
Questa è esattamente la ragione delle identità composte.
Una lettura del DOM descrive uno stato in un preciso momento. In una pagina dinamica non va confusa con una struttura immutabile.
Riferimento a un nodo ottenuto in passato che non rappresenta più il componente corrente della UI.
Identità del concetto di dominio indipendente dal particolare nodo DOM che lo rappresenta.
Dipendenza implicita dalla struttura, dalle classi o dagli attributi della pagina host utilizzati per ritrovare un elemento.
Condizione osservabile che indica quando un componente è realmente pronto per l’operazione successiva.
Controllo periodico di una condizione finché diventa vera o viene raggiunta una deadline.
API browser che notifica modifiche all’albero DOM. È particolarmente utile quando componenti rilevanti possono essere creati o rimossi dopo lo startup.
Gestione degli eventi tramite un antenato stabile invece di attaccare listener a ogni elemento dinamico.
Pattern in cui un’azione viene inviata alla UI e il codice verifica successivamente che il sistema abbia raggiunto lo stato desiderato.
Strategia di conservare identità stabili e risolvere i riferimenti concreti il più vicino possibile al momento del loro utilizzo.
Tentativo di ripristinare automaticamente una condizione necessaria prima di proseguire, per esempio riaprire un accordion prima di cercarne i figli.
Ordinamento delle fonti in base all’affidabilità. Un valore API esatto, una baseline DOM e un’euristica possono coesistere senza avere la stessa autorità.
Document.querySelector()https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelector
Document.querySelectorAll()https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelectorAll
La parte particolarmente importante per questo capitolo è che querySelectorAll() restituisce una NodeList statica.
https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model/Selection_and_traversal_on_the_DOM_tree
Element.closest()https://developer.mozilla.org/en-US/docs/Web/API/Element/closest
Utile per capire perché PlumePilot può partire da uno span o dal target di un click e risalire al componente logico che lo contiene.
MutationObserverhttps://developer.mozilla.org/en-US/docs/Web/API/MutationObserver
Quando lo studi, prova a distinguere sempre:
"so che il DOM è cambiato"
contro:
"so che cosa significa semanticamente il cambiamento"
La seconda informazione non viene fornita automaticamente dall’observer.
Element.getBoundingClientRect()https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect
Serve a comprendere perché una dimensione positiva può essere un segnale utile di rendering senza costituire una definizione completa di visibilità.
https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide
È utile per comprendere perché setTimeout(..., 0) non significa “esegui adesso” e perché gli effetti di un evento possono diventare osservabili soltanto in un momento successivo.
Il punto più importante di questo capitolo non è come usare querySelector().
È imparare a vedere una pagina dinamica come un sistema con rappresentazioni temporanee.
Abbiamo visto che:
un nodo DOM
non è necessariamente:
un'identità di dominio
che:
non trovato
non significa necessariamente:
non esiste
che:
.click()
non significa:
transizione completata
che:
MutationObserver
non ci consegna il significato del cambiamento, ma ci dice che la nostra fotografia potrebbe essere diventata obsoleta.
E soprattutto abbiamo visto un pattern che tornerà continuamente in PlumePilot:
identità stabile
↓
lettura fresca
↓
azione
↓
attesa condizionale
↓
verifica
↓
fallback / recovery
Questa è già architettura software, anche se stiamo lavorando con dei <div>.
Finora abbiamo osservato la piattaforma soprattutto dalla superficie:
DOM
URL
interazioni
Ma PlumePilot ha imparato progressivamente a guardare anche dietro l’interfaccia, verso le richieste che la piattaforma stessa effettua.
Nel Capitolo 04 entreremo in:
Vedremo:
Passeremo quindi da:
"che cosa vedo nella pagina?"
alla domanda:
"da dove arrivano i dati che la pagina sta mostrando?"
Ed è qui che l’integrazione con una web app comincia a diventare davvero interessante.
← 02 — Contesti e messaging · Indice · 04 — API e reverse engineering →