Assistente indipendente per Pegaso, Mercatorum e San Raffaele
Nei primi capitoli abbiamo guardato PlumePilot dall’esterno verso l’interno.
Abbiamo visto:
popup
↓
background
↓
bridge
↓
content script
↓
DOM
Ma una web app moderna non vive soltanto nel DOM.
Quello che vediamo nella pagina è spesso il risultato finale di un’altra conversazione:
browser
↓ HTTP
backend
↓ JSON
browser
↓ stato applicativo
DOM
Quando la piattaforma mostra un capitolo, una percentuale, una dispensa o un test, quei dati devono essere arrivati da qualche parte.
PlumePilot ha imparato progressivamente a osservare proprio questo livello.
Ed è qui che entra il tema del capitolo:
reverse engineering di una web app attraverso il traffico che la web app stessa genera.
Il termine può sembrare più aggressivo di ciò che accade realmente.
Nel nostro caso non significa:
bypassare autenticazione
forzare endpoint non autorizzati
indovinare credenziali
accedere a contenuti non disponibili all'utente
Significa invece:
la pagina è già autenticata
↓
la pagina chiama le proprie API
↓
PlumePilot osserva forma e semantica delle chiamate
↓
riusa, quando opportuno, la stessa sessione già autorizzata
↓
normalizza le risposte
↓
usa quei dati per funzioni locali dell'estensione
Questa distinzione sarà importante per tutto il capitolo.
Nel Capitolo 03 abbiamo visto che il DOM è una rappresentazione temporanea dello stato dell’applicazione.
Ora possiamo aggiungere un secondo livello al modello mentale:
backend
↓
API response
↓
frontend state
↓
DOM
Quindi, quando leggiamo:
80%
dalla sidebar, potremmo stare osservando un valore che il frontend ha:
Il DOM è quindi spesso l’ultimo anello della catena, non il primo.
Questo spiega perché, per alcune operazioni, interrogare i dati più vicino alla sorgente può essere:
Ma non significa automaticamente che una API sia sempre “più vera” del DOM.
Ci torneremo.
Quando si dice “API” si pensa spesso a qualcosa documentato per sviluppatori esterni:
GET /v1/users
Authorization: Bearer ...
con documentazione, versioning e contratti espliciti.
Una web app, però, utilizza quasi sempre anche interfacce HTTP pensate principalmente per il proprio frontend.
Il browser deve comunque poter chiedere al backend:
quali sono i moduli del corso?
qual è il progresso?
quali attività contiene una lezione?
dove si trova la dispensa?
quali esami risultano completati?
Questi endpoint possono essere perfettamente normali API HTTP anche se non sono pubblicizzati come API per integrazioni di terze parti.
Da un punto di vista architetturale:
Multiversity frontend
│
│ HTTP + JSON
▼
Multiversity backend
PlumePilot si trova già dentro quel client browser e può osservare questa conversazione.
commission-interceptor.js vive nel MAIN worldNel Capitolo 01 abbiamo visto che commission-interceptor.js viene caricato con:
"world": "MAIN"
Questa scelta diventa molto più chiara adesso.
Se vogliamo osservare:
window.fetch
usato dal JavaScript della pagina, dobbiamo trovarci nello stesso JavaScript world della pagina.
Un content script isolato vede lo stesso DOM, ma non necessariamente gli stessi oggetti JavaScript modificati dal sito.
PlumePilot sfrutta quindi il MAIN world come punto di osservazione del runtime host.
Riferimento: manifest.json.
All’inizio di commission-interceptor.js troviamo:
const PLATFORM_API_ORIGINS = Object.freeze([
["pegaso", "pegaso.multiversity.click", "https://lms-api.prod.pegaso.multiversity.click"],
["mercatorum", "mercatorum.multiversity.click", "https://lms-api.prod.mercatorum.multiversity.click"],
["utsr", "utsr.multiversity.click", "https://lms-api.prod.utsr.multiversity.click"],
]);
Riferimento: commission-interceptor.js L21-L25.
Questa tabella fa qualcosa di più importante di quanto sembri.
Definisce un confine esplicito:
pagina Pegaso → API Pegaso
pagina Mercatorum → API Mercatorum
pagina UTSR → API UTSR
L’interceptor non dice:
osserva qualunque Authorization header del browser
Dice invece:
interessati soltanto alle richieste dirette all'API della piattaforma corrente
Questo principio prende spesso il nome di least privilege o, più genericamente, minimizzazione dell’ambito.
fetch() senza rompere la paginaPlumePilot salva prima il riferimento originale:
const originalFetch = window.fetch;
poi installa un wrapper:
window.fetch = function studyWingFetch(input, init) {
// osservazione PlumePilot
const promise = originalFetch.call(this, input, init);
// osservazione della risposta
return promise;
};
Riferimento: commission-interceptor.js L708-L720.
Questo pattern viene spesso chiamato:
L’idea è:
pagina chiama fetch()
↓
wrapper PlumePilot
↓
fetch originale
↓
network
Il wrapper osserva la chiamata senza sostituirne il comportamento applicativo.
Il codice ritorna il Promise originale:
return promise;
Questo è essenziale.
Immaginiamo che il frontend faccia:
const response = await fetch(url);
Se il wrapper di PlumePilot cambiasse accidentalmente il tipo di ritorno:
window.fetch = () => undefined;
la piattaforma si romperebbe.
Un buon wrapper dovrebbe quindi preservare il contratto osservabile della funzione originale.
Idealmente:
stessi argomenti accettati
stesso this appropriato
stesso valore di ritorno
stessi errori della chiamata originale
L’instrumentation deve essere il meno invasiva possibile.
call(this, ...)Il wrapper invoca:
originalFetch.call(this, input, init)
anziché semplicemente:
originalFetch(input, init)
In questo specifico caso window.fetch non dipende normalmente da un this applicativo complesso, ma preservare il receiver rende il wrapper più fedele alla chiamata originale.
È un’abitudine importante quando si decorano API che non possediamo.
Response non è un valore riutilizzabile all’infinitoUna Response Fetch contiene uno stream.
Se facessimo semplicemente:
const body = await response.json();
nel wrapper, rischieremmo di consumare il body prima che lo faccia la pagina.
Il frontend host potrebbe poi tentare:
await response.json();
e trovare il body già usato.
PlumePilot evita questo problema con:
const body = await response.clone().json();
Riferimento: commission-interceptor.js L688-L705.
Il clone permette quindi due consumatori concettuali:
Response originale ─────► frontend Multiversity
Response clone ─────────► PlumePilot
Response.clone() è utilissimo, ma ha implicazioni di memoria e buffering.
Se una risposta è molto grande e i due consumer avanzano a velocità differenti, il browser può dover mantenere dati bufferizzati.
Per PlumePilot le risposte JSON intercettate in questi flussi sono molto più piccole di un PDF completo, quindi il compromesso è ragionevole.
Ma questa osservazione tornerà nel capitolo sulla performance:
una API comoda può nascondere copie e buffer impliciti.
fetch()Il frontend host potrebbe usare:
fetch
XMLHttpRequest
una libreria che internamente usa uno dei due
componenti vecchi e nuovi insieme
PlumePilot quindi instrumenta anche XMLHttpRequest.
Conserva:
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
const originalSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
Riferimento: commission-interceptor.js L723-L725.
Con XHR l’URL arriva durante:
xhr.open(method, url)
mentre l’header può arrivare dopo:
xhr.setRequestHeader("Authorization", value)
PlumePilot deve quindi ricordare temporaneamente:
this.__studyWingRequestUrl = url;
in open().
Poi, quando vede l’header authorization, può correlare:
questo token
↓
questa richiesta
↓
questa API origin
È già un piccolo esempio di correlation state.
Le richieste della piattaforma contengono un header di autenticazione.
PlumePilot cerca:
const authorization = new Headers(headers)
.get("authorization")
?.trim();
if (/^Bearer\s+\S+$/i.test(authorization || "")) {
bearerToken = authorization;
}
Riferimento: commission-interceptor.js L73-L83.
Un header del tipo:
Authorization: Bearer <token>
significa, in termini generali:
"questa richiesta presenta una credenziale che il server può verificare"
Il token non è la password dell’utente.
È una credenziale di sessione/accesso già ottenuta dal frontend attraverso il normale processo di autenticazione della piattaforma.
I termini vengono spesso confusi.
Possiamo distinguerli così:
Authentication
"chi sei?"
Authorization
"cosa ti è permesso fare?"
In pratica i due meccanismi sono spesso collegati.
Il Bearer token presentato alla API consente al backend di collegare la richiesta alla sessione e ai privilegi dell’utente.
PlumePilot non crea privilegi nuovi.
Riusa le richieste nel contesto dell’utente già autenticato.
Questa è una decisione architetturale molto importante.
Nel codice:
let bearerToken = null;
Il token vive nella closure di commission-interceptor.js.
Non viene messo in:
chrome.storage.local
localStorage PlumePilot
payload window.postMessage
log applicativi
server PlumePilot
Il resto dell’estensione non riceve:
{ token: "..." }
Riceve invece un’interfaccia di capacità:
{
action: "lesson",
courseCode,
lessonNumber,
...
}
L’interceptor usa internamente la credenziale e restituisce soltanto dati normalizzati.
Questo è un pattern architetturale molto interessante.
Possiamo rappresentarlo così:
content.js
│
│ "dammi i dati della lezione 5"
▼
commission-interceptor.js
│
│ possiede temporaneamente il Bearer token
▼
Multiversity API
contro una soluzione più rischiosa:
interceptor
│
└──► passa il token a content.js
│
├──► bridge
├──► background
├──► storage
└──► altri componenti
Ridurre il numero di componenti che vedono un segreto riduce anche la superficie in cui quel segreto può essere:
Possiamo chiamare questo principio secret containment o credential minimization.
La funzione:
function isCurrentPlatformApiUrl(value) {
try {
return new URL(String(value), window.location.href).origin === API_ORIGIN;
} catch {
return false;
}
}
Riferimento: commission-interceptor.js L52-L58.
viene usata prima di memorizzare l’autorizzazione.
La logica è:
request qualunque
↓
è diretta alla API della piattaforma corrente?
│
no └──► ignora
│
sì ▼
leggi eventualmente Authorization
Questa è una difesa semplice ma fondamentale.
La strategia può essere descritta come una allowlist:
solo questi host
solo questa API origin
solo alcune route interessanti
È preferibile a:
intercetta tutto e poi vediamo
perché rende più chiaro:
Uno degli endpoint più importanti è costruito così:
url = `${API_ORIGIN}/student/course/${encodeURIComponent(courseCode)}/video-lessons/0`;
Riferimento: commission-interceptor.js L453-L455.
Nel nostro lessico durante lo sviluppo l’abbiamo spesso chiamato master call.
Non è necessariamente il nome ufficiale dell’endpoint.
È il nostro nome concettuale per una risposta che descrive l’insieme dei moduli del corso e il loro stato sintetico.
Possiamo pensarla come:
corso
├── modulo A 100%
├── modulo B 100%
├── modulo C 80%
├── modulo D 0%
└── modulo E 100%
Questo tipo di vista aggregata è preziosissima.
PlumePilot non passa direttamente il JSON del server al resto dell’estensione.
Lo normalizza:
return {
displayOrder,
masterOrder,
folderId,
id,
lpId,
title,
percentage,
};
Riferimento: commission-interceptor.js L99-L120.
Questa fase è molto simile a ciò che in un backend potresti chiamare:
DTO mapping
adapter
anti-corruption layer
normalization layer
Il resto di PlumePilot non deve conoscere ogni dettaglio del JSON originale.
Supponiamo che il backend risponda con:
{
"display_order": "5",
"percentage": "80",
"lp_id": 123
}
Il codice di dominio preferisce ricevere:
{
displayOrder: 5,
percentage: 80,
lpId: 123
}
con vincoli già verificati.
Questo sposta la complessità al confine:
external schema
↓
validation + normalization
↓
internal schema
È un pattern estremamente riutilizzabile.
displayOrder non bastaLa risposta master contiene diversi identificatori:
displayOrder
masterOrder
folderId
id
lpId
Perché così tanti?
Perché il numero che l’utente vede non è necessariamente un identificatore globale.
Nel Capitolo 03 avevamo già incontrato il problema:
Sezione A
1 - Introduzione
2 - Concetti
Sezione B
1 - Introduzione
2 - Esempi
Quindi:
"capitolo 1"
non identifica necessariamente una sola entità nel corso.
playbackCourseRouteMap()Nel codice troviamo:
// A matching course-wide order is stronger than a display_order, which
// can restart at 1 in each folder. Use it only when every title agrees.
Riferimento: content.js L2900-L2914.
Questa frase contiene una lezione di modellazione dati importante:
un valore che sembra un ID può essere soltanto una label locale.
Prima di trattare qualcosa come identità dobbiamo capire il suo dominio di unicità.
Per i test il progetto usa:
function testRouteKey(route) {
return [route.folderId, route.lpId, route.id].join(":");
}
Riferimento: content.js L2925-L2928.
Questo è un esempio semplice di composite identity.
Quando nessun singolo campo è abbastanza affidabile, più campi insieme possono identificare meglio la risorsa.
La master call ci dice molto, ma non tutto.
Per ottenere il contenuto di una lezione PlumePilot costruisce una route del tipo:
/student/course/{courseCode}/video-lesson/{lpId}/paragraphs/{paragraphId}
Riferimento: commission-interceptor.js L456-L466.
La risposta di dettaglio può contenere informazioni sufficienti a ricostruire:
PlumePilot riduce la risposta a:
return {
dataAvailable,
objective,
test,
material,
playbackItems,
playbackDataComplete,
progressItems,
progressDataComplete,
};
Riferimento: commission-interceptor.js L384-L397.
Questo è un altro esempio di boundary normalization.
Il resto dell’applicazione ragiona su concetti PlumePilot, non sul payload grezzo.
postMessagecontent.js non conosce il tokenQuando content.js vuole dati API usa:
function turboApiRequest(action, payload = {}) {
return new Promise((resolve) => {
const requestId = `studywing-${Date.now()}-${++turboApiRequestSequence}`;
...
window.postMessage({
type: TURBO_API_REQUEST,
requestId,
action,
...payload,
}, "*");
});
}
Riferimento: content.js L4904-L4923.
Questa funzione trasforma un protocollo a messaggi in qualcosa che il chiamante può usare come una funzione asincrona:
const response = await turboApiRequest("lesson", {
courseCode,
lessonNumber,
});
Concettualmente è una piccola RPC locale.
Il requestId serve a correlare:
request A ────────────────┐
request B ───────────┐ │
│ │
response B ◄─────────┘ │
response A ◄──────────────┘
senza assumere che le risposte arrivino nello stesso ordine delle richieste.
Nel Capitolo 02 abbiamo chiamato questo concetto correlation ID.
Qui lo vediamo applicato a una vera API bridge.
content.js conserva:
const pendingTurboApiRequests = new Map();
Ogni richiesta salva:
requestId → resolve + timeout
Quando arriva:
STUDYWING_TURBO_API_RESPONSE
il listener cerca il requestId, cancella il timeout e risolve la Promise corretta.
Il pattern è identico a molti sistemi RPC reali.
Ogni richiesta ha anche:
setTimeout(() => {
pendingTurboApiRequests.delete(requestId);
resolve({ ok: false, error: "RESPONSE_TIMEOUT" });
}, TURBO_API_RESPONSE_TIMEOUT_MS);
Quindi il chiamante non resta bloccato per sempre se:
Un protocollo asincrono serio ha bisogno anche di una failure semantics.
L’interceptor riceve:
action = outline
action = lesson
action = completeaction = test-source
Poi decide internamente:
if (message.action === "outline") { ... }
else if (message.action === "lesson") { ... }
else if (message.action === "complete") { ... }
else if (message.action === "test-source") { ... }
Riferimento: commission-interceptor.js L433-L498.
Questo mantiene la conoscenza degli endpoint concentrata in un unico modulo.
content.js non deve sapere che la route concreta è:
/student/course/.../video-lesson/.../paragraphs/...
Gli basta sapere:
voglio i dati della lezione
Possiamo quindi vedere commission-interceptor.js anche come un API adapter.
Espone al resto di PlumePilot un contratto interno:
outline(courseCode)
lesson(courseCode, route)
complete(activity)
testSource(test)
mentre nasconde:
URL esatti
Authorization header
credentials
parsing JSON
normalizzazione
AbortController
errori HTTP
È separation of concerns applicata a una browser extension.
fetch() e HTTP errorifetch() non rigetta automaticamente la Promise per un normale errore HTTP come:
404
401
500
La Promise può risolversi correttamente con una Response il cui:
response.ok === false
Per questo PlumePilot ha:
if (!response.ok || Number(body?.code) !== 200) {
throw new Error(`API_${response.status || "INVALID_RESPONSE"}`);
}
Riferimento: commission-interceptor.js L420-L430.
Questo trasforma due livelli di errore:
HTTP status
application body code
in una semantica più semplice per il chiamante.
È possibile avere:
HTTP 200
ma un payload che rappresenta un errore applicativo.
Ed è possibile avere:
HTTP 401
con una risposta JSON perfettamente valida dal punto di vista sintattico.
Quindi dobbiamo distinguere:
la rete ha risposto?
la risposta HTTP è positiva?
il JSON è valido?
il codice applicativo è positivo?
i dati necessari sono presenti?
Sono cinque domande diverse.
Una API può rispondere correttamente senza avere ancora tutti i dati che ci servono.
Per questo esiste:
function hasRequiredLessonData(response, requiredData) {
if (response.data?.dataAvailable !== true) return false;
if (requiredData === "material") return Boolean(response.data.material);
if (requiredData === "test") return Boolean(response.data.test);
if (requiredData === "objective") return Boolean(response.data.objective);
if (requiredData === "playback") {
return response.data.playbackDataComplete === true;
}
return true;
}
Riferimento: content.js L2414-L2423.
Questa distinzione è molto utile:
request failed
non è la stessa cosa di:
request succeeded, but data is not ready/complete enough
requestLessonWithRetry() non ripete soltanto quando la rete fallisce.
Può riprovare quando:
response.ok = true
ma:
dati richiesti non ancora completi
Riferimento: content.js L2715-L2777.
Questo è un semantic retry.
Il sistema non domanda soltanto:
"la chiamata è riuscita?"
ma:
"la chiamata mi ha dato abbastanza informazione per continuare?"
Nel codice troviamo ritardi come:
const COURSE_INDEX_RETRY_DELAYS_MS = [350, 750];
const API_LESSON_RETRY_DELAYS_MS = [750, 1500];
const API_MATERIAL_PACING_MS = 150;
const TURBO_API_PACING_MS = 350;
Riferimento: content.js L60-L65.
Ci sono due concetti differenti:
Attendere prima di riprovare una richiesta che non ha prodotto dati utilizzabili.
Distanziare richieste valide di una procedura batch.
Non sono la stessa cosa.
Promise.all() su tutto il corso?Teoricamente potremmo immaginare:
await Promise.all(
lessons.map((lesson) => fetchLesson(lesson))
);
Ma una strategia del genere può:
PlumePilot usa molte operazioni sequenziali proprio perché la massima concorrenza non è automaticamente la massima qualità.
Nell’interceptor:
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
poi:
fetch(url, {
signal: controller.signal,
...
});
Riferimento: commission-interceptor.js L500-L529.
Questo trasforma:
request potenzialmente indefinita
in:
request con deadline
content.js può inoltre inviare:
STUDYWING_TURBO_API_CANCEL
L’interceptor reagisce:
for (const controller of turboControllers.values()) {
controller.abort();
}
Riferimento: commission-interceptor.js L792-L795.
Quindi la cancellazione attraversa più livelli:
utente
↓
operazione PlumePilot
↓
message
↓
AbortController
↓
fetch interrotta
Nel Capitolo 05 vedremo questo tema in modo molto più approfondito.
Una delle ottimizzazioni più eleganti dell’interceptor è questa:
if (message.action === "outline") {
const cached = courseOutlines.get(courseCode);
if (cached?.length) {
return { entries: cached, source: "page-cache" };
}
}
Riferimento: commission-interceptor.js L437-L442.
Se la pagina ha già fatto la chiamata, PlumePilot può avere già osservato la risposta.
Quindi:
pagina chiama API
↓
PlumePilot osserva risposta
↓
cache in memoria
↓
feature futura chiede gli stessi dati
↓
nessuna nuova richiesta necessaria
Questa è una forma di passive caching.
Il sistema ha due modalità:
la pagina fa la request
PlumePilot osserva
PlumePilot ha bisogno dei dati
PlumePilot esegue una request esplicita
Questo permette una strategia:
reuse if available
otherwise fetch
molto comune nei sistemi con cache.
Quando l’interceptor vede una route dettagliata:
/student/course/.../video-lesson/.../paragraphs/...
crea uno snapshot:
const snapshot = {
type: PAGE_LESSON_SNAPSHOT,
courseCode,
displayOrder,
lpId,
paragraphId,
data: normalizedLessonData(body),
};
Riferimento: commission-interceptor.js L399-L417.
La cache viene limitata:
if (pageLessonSnapshots.size > 50) {
pageLessonSnapshots.delete(pageLessonSnapshots.keys().next().value);
}
Quindi non cresce senza limite.
Una cache senza limite è un memory leak con un nome più elegante.
La semplice regola:
massimo 50 snapshot
non è una sofisticata LRU cache, ma impone una proprietà essenziale:
uso memoria superiore prevedibile
Il limite è spesso più importante dell’algoritmo di eviction perfetto.
content.jslessonApiCachecontent.js possiede a sua volta:
const lessonApiCache = new Map();
con chiave:
`${courseCode}:${lessonNumber}`
Riferimento: content.js L2425-L2427.
Questa cache non contiene il Bearer token.
Contiene dati di dominio già normalizzati.
È una separazione utile:
credential cache → interceptor only
application cache → content logic
La freschezza è definita da:
const API_LESSON_CACHE_FRESH_MS = 5 * 60 * 1000;
Per i dati mutabili la cache è riusata soltanto se abbastanza recente.
Riferimento: content.js L64.
Questa è una strategia TTL — time to live.
In cachedLessonResponse() troviamo logica diversa per:
data generici
playback
test
objective
Per esempio, un test già completato può rimanere utilizzabile anche quando l’entry non è più fresca:
if (requiredData === "test" && !fresh && entry.test?.completed !== true) {
return null;
}
Riferimento: content.js L2560-L2574.
Perché?
Perché nel dominio di PlumePilot:
"test completato"
è molto più monotono di:
"test ancora incompleto"
Il secondo può cambiare presto.
Il primo, normalmente, no.
Quando aggiorna attività e video, PlumePilot usa spesso:
Math.max(vecchiaPercentuale, nuovaPercentuale)
Per esempio:
const percentage = Math.max(
Number(activity.percentage) || 0,
sameActivity ? Number(previousActivity.percentage) || 0 : 0,
);
Riferimento: content.js L2461-L2484.
L’assunzione di dominio è:
il progresso registrato non dovrebbe diminuire
Questo permette di proteggersi da risposte temporaneamente stale.
Math.max() sarebbe pericoloso?Se il dominio consentisse realmente regressioni:
saldo conto corrente
scorte magazzino
punteggio che può essere corretto
stato che può essere revocato
allora conservare sempre il massimo sarebbe sbagliato.
Una cache può incorporare regole di dominio, non soltanto regole tecniche.
È importante saperle riconoscere.
Per alcune operazioni PlumePilot deve correlare:
capitolo visuale
con:
entry API
Per questo esiste courseIndexRouteMap().
La strategia prova:
Riferimento: content.js L2857-L2923.
Questo è un reconciliation problem.
Due rappresentazioni dello stesso dominio devono essere allineate.
Il commento:
// A stale label must never make us skip a genuinely unfinished chapter as "100%".
Riferimento: content.js L2913-L2919.
rivela una scelta molto importante:
falso positivo: "questo capitolo è completo"
è più pericoloso di:
incertezza: "non sono sicuro, verifico meglio"
Questo è un esempio di asymmetric error cost.
Gli errori non hanno tutti lo stesso costo.
getPlaybackCourseIndex() recupera e normalizza l’indice:
const entries = response.data.entries
.map(...)
.filter(Boolean)
.sort((a, b) => a.displayOrder - b.displayOrder);
Riferimento: content.js L5760-L5823.
Se vediamo:
A 100%
B 100%
C 80%
D 100%
non ha senso aprire subito A e B per cercare qualcosa di incompleto.
Possiamo partire da C.
Durante lo sviluppo abbiamo scoperto che le percentuali aggregate possono non essere sempre sufficienti per decidere con certezza quale attività specifica sia incompleta.
Quindi il modello corretto non è:
master percentage = verità definitiva
ma:
master percentage = ottimo indice di ricerca
La differenza è enorme.
Un indice risponde:
"dove vale la pena cercare prima?"
Una source of truth risponde:
"qual è lo stato definitivo?"
Possiamo usare un dato imperfetto in modo corretto se gli assegniamo il ruolo giusto.
Questa idea sarà centrale nel Capitolo 08.
La funzione:
collectCourseMaterialsViaApi(...)
prova a raccogliere le dispense direttamente dai dati API.
Riferimento: content.js L2995-L3188.
Prima recupera:
outline visuale del corso
+
master course index
↓
route map
↓
lesson detail API
↓
material URL
Se il percorso API non è disponibile:
return false;
il chiamante può continuare con la raccolta visuale.
Questa strategia è un esempio di:
API path disponibile?
│
sì│ usa percorso rapido/strutturato
│
no▼
usa percorso visuale più lento
Il sistema non assume che la via migliore sia sempre disponibile.
Questa proprietà viene spesso chiamata graceful degradation.
Perché l’API può fallire in modi che il DOM riesce comunque a superare:
Il DOM è più fragile strutturalmente.
L’API è più fragile contrattualmente.
Avere entrambe le strade aumenta la resilienza.
Durante il runtime possiamo avere contemporaneamente:
master response
lesson detail response
page-observed snapshot
memory cache
DOM percentage
video player state
La domanda diventa:
quale fonte è autorevole per quale decisione?
Non esiste una risposta universale.
Possiamo immaginare una gerarchia come:
dati completi API
↓
baseline DOM + aggiornamenti noti della sessione
↓
indicatore visuale
↓
euristica
Ma per un’altra funzione la gerarchia può essere diversa.
L’architettura migliore non è:
API sempre prima
È:
fonte appropriata al tipo di decisione
Per lo stato della commissione PlumePilot usa:
const TARGET_PATH = "/student/exam-online/exams-list-done";
Riferimento: commission-interceptor.js L11.
Quando serve un controllo, può eseguire:
originalFetch.call(window, `${API_ORIGIN}${TARGET_PATH}`, {
method: "GET",
headers: {
Accept: "application/json",
Authorization: bearerToken,
},
credentials: "include",
signal: commissionController.signal,
});
Riferimento: commission-interceptor.js L640-L671.
credentials: "include"Questo indica alla Fetch API di includere le credenziali gestite dal browser compatibili con quella richiesta, come cookie quando applicabile.
Qui vediamo una cosa importante:
Authorization header
e:
browser-managed credentials
possono coesistere.
Il backend decide quali informazioni usare.
PlumePilot replica il contesto di una richiesta autenticata che il browser è già autorizzato a eseguire.
Nel flusso commissione:
if (error?.message === "API_401" || error?.message === "API_403") {
bearerToken = null;
}
Riferimento: commission-interceptor.js L662-L665.
A livello HTTP, in modo semplificato:
401 → autenticazione assente/non valida
403 → richiesta riconosciuta ma non autorizzata
Le implementazioni reali possono usare questi status con sfumature diverse, ma per PlumePilot entrambi sono segnali ragionevoli per non continuare a fidarsi della credenziale memorizzata.
Il vantaggio della cache solo in memoria è anche questo:
pagina/contesto termina
↓
token dimenticato naturalmente
La durata del segreto è vicina alla durata del contesto che lo usa.
È spesso una proprietà desiderabile per credenziali temporanee.
Il protocollo tra content.js e commission-interceptor.js passa:
action
courseCode
lessonNumber
lpId
paragraphId
requestId
non:
bearerToken
Quindi il boundary può essere visto così:
untrusted-ish message surface
↓
validate action + arguments
↓
privileged local capability
↓
credential use
Questo ricorda molto i principi di capability-based design.
Il chiamante chiede un’operazione.
Non riceve il segreto necessario a implementarla da solo.
Prima di costruire URL, PlumePilot valida:
function validCourseCode(value) {
return typeof value === "string" &&
/^[A-Za-z0-9_-]{3,80}$/.test(value);
}
Riferimento: commission-interceptor.js L86-L88.
Allo stesso modo:
validPositiveInteger(...)
validNonNegativeInteger(...)
filtrano gli identificatori.
Non basta che un valore sia arrivato “da PlumePilot”.
Ogni boundary di messaggistica va trattato come un punto in cui i dati devono essere verificati.
La normalizzazione usa spesso:
slice(0, 1000)
slice(0, 200)
slice(0, 20)
safeString(..., maxLength)
Questi limiti hanno due ruoli:
È un esempio di defensive parsing.
Supponiamo che un giorno l’API cambi:
display_order
in:
position
Se tutto PlumePilot dipendesse direttamente da display_order, dovremmo modificare decine di funzioni.
Con un adapter possiamo idealmente cambiare soltanto:
raw API response
↓
normalizeCourseOutline()
↓
internal displayOrder
Il dominio interno resta stabile.
Nel Domain-Driven Design esiste un concetto chiamato anti-corruption layer.
L’idea è impedire che il modello di un sistema esterno “contamini” direttamente il nostro modello interno.
PlumePilot non implementa formalmente DDD, ma la normalizzazione dell’interceptor svolge una funzione molto simile.
Il processo mentale corretto non è:
prova URL casuali
ma qualcosa di molto più metodico:
1. esegui un'azione nella UI
2. osserva Network in DevTools
3. identifica la request correlata
4. guarda method, URL, headers e payload
5. osserva la response
6. ripeti cambiando una sola variabile
7. identifica quali campi restano stabili
8. formula un'ipotesi
9. verifica l'ipotesi
Questo è reverse engineering per osservazione.
Supponiamo di aprire il modulo 5.
In Network compare:
GET /student/course/ABC/video-lesson/123/paragraphs/456
Poi apriamo il modulo 6:
GET /student/course/ABC/video-lesson/789/paragraphs/999
Possiamo ipotizzare:
ABC → course code
123/789 → identificatore logico del lesson package
456/999 → identificatore paragraph/route
Poi confrontiamo il payload con ciò che appare nella UI.
Solo dopo diversi esempi trasformiamo l’ipotesi in codice.
Se vediamo:
{"id": 5}
non sappiamo ancora se è:
ID globale
ID locale alla lezione
ID del paragrafo
ordine di visualizzazione
foreign key
Il nome del campo è un indizio, non una prova.
La prova arriva dalla coerenza tra più osservazioni.
La nostra storia con displayOrder è un ottimo esempio.
Un numero che sembrava descrivere l’ordine dei capitoli poteva ripartire da 1 in ogni folder.
Quindi:
displayOrder = 1
non significava:
primo capitolo globale del corso
Questo tipo di scoperta è esattamente ciò che rende utile mantenere più identificatori nel modello normalizzato.
Una strategia puramente visuale può richiedere:
apri sezione
apri capitolo
attendi rendering
leggi attività
chiudi/apri altro capitolo
ripeti
Se il corso è lungo, il costo cresce.
Con il course index possiamo prima chiedere:
quali moduli sono sicuramente candidati?
Per esempio:
1 100% → salta
2 100% → salta
3 100% → salta
4 80% → verifica qui
Questo non elimina la verifica dettagliata.
La rende mirata.
Non è soltanto “più veloce”.
È un cambiamento nell’algoritmo.
Da:
scansiona tutto
a:
usa un indice economico
↓
seleziona candidati
↓
verifica soltanto ciò che serve
È lo stesso principio usato da database, motori di ricerca e file system.
Per raccogliere i test, PlumePilot:
const masterResponse = await turboApiRequest("outline", { courseCode });
const courseIndex = masterResponse.ok ? masterResponse.data?.entries : null;
outline = await recoverCourseTestOutline(..., courseIndex, ...);
Riferimento: content.js L3663-L3671.
Ancora una volta vediamo:
master data
+
visual structure
+
detail requests
invece di un’unica fonte assoluta.
Quando integriamo una API ufficialmente documentata, possiamo partire da:
OpenAPI schema
SDK
reference docs
Qui il contratto viene invece inferito da:
richieste reali
risposte reali
comportamento osservato
Questo significa che dobbiamo assumere un rischio maggiore di cambiamento.
Da qui alcune scelte sensate:
Questa è defensive integration.
Per outline e lesson PlumePilot usa:
GET
Concettualmente sta chiedendo una rappresentazione di dati esistenti.
Per operazioni come il completamento di un test introduttivo usa:
POST
con un body JSON.
Riferimento: commission-interceptor.js L467-L480.
Il metodo HTTP ci dà una forte indicazione semantica:
GET → leggere
POST → chiedere al server di processare/modificare qualcosa
Non è una legge assoluta per ogni API esistente, ma è il modello standard da cui partire.
Ripetere accidentalmente:
GET lesson details
ha normalmente un costo diverso dal ripetere:
POST complete activity
Per le operazioni che modificano stato dobbiamo preoccuparci di più di:
Queste considerazioni torneranno quando parleremo di idempotenza e state machine.
Quando PlumePilot usa:
originalFetch.call(window, API_ORIGIN, ...)
la richiesta continua a essere soggetta alle regole del browser e del server.
Il MAIN world non è:
modalità senza CORS
È semplicemente lo stesso contesto JavaScript della pagina.
Se la piattaforma può già parlare con quell’API, il server ha configurato il proprio modello di accesso di conseguenza.
È facile confondere:
"posso chiamare fetch da JavaScript"
con:
"posso chiamare qualsiasi server con qualsiasi credenziale"
Non è così.
Origin policy, CORS, cookie policy, autenticazione e autorizzazione continuano ad applicarsi.
Nel codice incontriamo errori come:
AUTH_UNAVAILABLE
INVALID_COURSE_CODE
INVALID_LESSON_NUMBER
INVALID_ACTION
REQUEST_ABORTED
REQUEST_TIMEOUT
LESSON_DATA_INCOMPLETE
API_401
API_403
Questi nomi sono utili perché distinguono categorie diverse.
catch (e) { return false; }Se tutto diventasse:
FAILED
non sapremmo se dobbiamo:
riprovare
aspettare autenticazione
usare fallback DOM
chiedere all'utente di ricaricare
correggere un bug
La qualità del recovery dipende dalla qualità della classificazione dell’errore.
La struttura attuale permette di mantenere:
session credential → memoria della pagina
normalized data → memoria locale dell'estensione
preferences/state → browser storage dove necessario
senza introdurre un backend PlumePilot.
Questo riduce drasticamente la superficie dati del progetto.
Una privacy policy può descrivere un comportamento.
Ma la proprietà più forte è quando il comportamento deriva dall’architettura stessa.
Per esempio:
non esiste server PlumePilot
significa che molti flussi di esfiltrazione semplicemente non hanno una destinazione progettata.
E:
il token non viene scritto nello storage
riduce la sua persistenza per costruzione.
Questo è un buon esempio di privacy by design.
outlineQuando content.js vuole il course index:
response = await turboApiRequest("outline", { courseCode });
succede questo:
sequenceDiagram
participant C as content.js
participant I as commission-interceptor.js
participant A as Multiversity API
C->>I: STUDYWING_TURBO_API_REQUEST\nrequestId + action=outline
alt outline già osservato
I-->>C: STUDYWING_TURBO_API_RESPONSE\nsource=page-cache
else cache assente
I->>A: GET /video-lessons/0\nAuthorization + credentials
A-->>I: JSON course outline
I->>I: validate + normalize + cache
I-->>C: response correlata con requestId
end
Questo diagramma riassume buona parte del capitolo.
lessonsequenceDiagram
participant C as content.js
participant I as interceptor
participant A as API
participant M as lessonApiCache
C->>M: cachedLessonResponse()
alt cache fresca e sufficiente
M-->>C: normalized lesson
else cache assente/stale/incompleta
C->>I: action=lesson + route IDs
I->>A: GET detailed lesson
A-->>I: raw JSON
I->>I: normalizedLessonData()
I-->>C: normalized response
C->>M: rememberLessonResponse()
end
Notiamo che la cache applicativa e quella dell’interceptor hanno ruoli diversi.
Possiamo immaginare:
content.js
≈ domain/service layer
commission-interceptor.js
≈ external API client / gateway
normalizeCourseOutline()
normalizedLessonData()
≈ DTO mappers
lessonApiCache
≈ application cache
window.postMessage + requestId
≈ RPC transport
L’analogia non è perfetta, ma è molto utile per leggere il codice con concetti che probabilmente hai già incontrato lato backend.
Se il progetto crescesse molto, potremmo immaginare:
api/
├── platform-client.js
├── course-api.js
├── exams-api.js
├── normalizers.js
├── validators.js
└── errors.js
con un’interfaccia tipo:
platformApi.getCourseOutline(courseCode)
platformApi.getLesson(route)
platformApi.completeActivity(activity)
platformApi.getTestSource(test)
Un’astrazione ha un costo.
Quando il progetto aveva pochi endpoint, concentrare la logica nell’interceptor era una scelta pratica.
Solo quando:
numero di endpoint
+ numero di piattaforme
+ numero di call site
+ complessità errori
crescono abbastanza, un modulo più formale diventa chiaramente vantaggioso.
Questo è un buon esempio di evolutionary architecture.
Una request osservata una volta può dipendere da:
stato corrente
feature flag
piattaforma
versione frontend
ordine di navigazione
cache
Serve più di un esempio.
Da qui controlli come:
Array.isArray(...)
Number.isInteger(...)
typeof value === "string"
Sono noiosi soltanto finché una response non cambia.
Questo lega tutto il progetto al formato esterno.
Meglio:
raw payload
↓
one normalization boundary
↓
small internal model
Se il token serve soltanto al client API, deve idealmente restare lì.
Una API inferita può cambiare.
Una UI può cambiare.
Le due debolezze sono diverse.
La strategia ibrida è spesso più robusta di una fede assoluta in una sola sorgente.
Supponiamo di osservare:
{
"code": 200,
"data": [
{
"display_order": 3,
"folder_id": 2,
"lp_id": 481,
"id": 912,
"title": "Limiti",
"percentage": 80
}
]
}
Prima di guardare la soluzione, prova a classificare i campi.
Domande:
display_order → ordine visuale / locale, da verificare
folder_id → scope/sezione
lp_id → identificatore tecnico utile alla route
id → altro identificatore tecnico/paragraph
percentage → indice di progresso
La parola chiave è sempre:
da verificare
Reverse engineering serio significa mantenere separate:
osservazione
ipotesi
conferma
const token = await getToken();
const response = await fetch(url, {
headers: { Authorization: token }
});
in ogni feature.
const lesson = await api.request("lesson", route);
con token confinato nel client API.
Per PlumePilot, B è molto più coerente con:
Componente che traduce un’API esterna in un’interfaccia interna più stabile.
Validazione e trasformazione dei dati nel punto in cui entrano nel sistema.
Credenziale inviata in un header Authorization: Bearer ... per autorizzare una richiesta secondo il modello definito dal server.
Principio di limitare il numero di componenti e la durata durante cui una credenziale è accessibile.
Riutilizzo di dati osservati durante richieste che l’applicazione host avrebbe comunque effettuato.
Time to live: durata oltre la quale un dato cached viene considerato non abbastanza fresco per determinati usi.
Retry eseguito non soltanto per errori di rete, ma perché la risposta ottenuta non contiene ancora i dati necessari.
Distribuzione temporale di una sequenza di richieste valide per evitare burst inutili.
Capacità del sistema di usare un percorso alternativo quando quello preferito non è disponibile.
Processo di allineamento tra due rappresentazioni differenti dello stesso dominio.
Layer che impedisce al modello di un sistema esterno di propagarsi direttamente nel modello interno dell’applicazione.
Situazione in cui due tipi di errore hanno conseguenze diverse e quindi l’algoritmo deve essere prudente soprattutto verso quello più costoso.
Cache con un limite esplicito alla quantità di dati che può trattenere.
Stato che, secondo le regole del dominio, può avanzare ma non dovrebbe regredire. Il progresso completato è trattato in molti punti di PlumePilot in questo modo.
Approccio a un sistema esterno che assume possibili risposte incomplete, cambi di schema e fallimenti, usando validazione, error taxonomy e fallback.
https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
Da studiare soprattutto:
Request;Response;Response.clone()https://developer.mozilla.org/en-US/docs/Web/API/Response/clone
È direttamente collegato al modo in cui commission-interceptor.js osserva il JSON senza consumare la response originale della pagina.
Headershttps://developer.mozilla.org/en-US/docs/Web/API/Headers
Authorizationhttps://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Authorization
Studia la differenza tra:
header HTTP
schema Bearer
contenuto reale del token
Sono tre livelli concettuali distinti.
AbortControllerhttps://developer.mozilla.org/en-US/docs/Web/API/AbortController
È la base del timeout/cancellation path delle request PlumePilot.
XMLHttpRequesthttps://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest
setRequestHeader()https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/setRequestHeader
Serve a capire perché PlumePilot deve intercettare open(), setRequestHeader() e send() in fasi differenti.
ExecutionWorldhttps://developer.chrome.com/docs/extensions/reference/api/scripting#type-ExecutionWorld
Rileggi la distinzione tra MAIN e ISOLATED pensando non più al DOM, ma a questo problema:
quale `window.fetch` sto realmente modificando?
Il cuore di questo capitolo non è l’elenco degli endpoint.
Gli endpoint possono cambiare.
La parte trasferibile ad altri progetti è il metodo.
Abbiamo visto che una web app può essere letta su più livelli:
UI
DOM
frontend runtime
HTTP requests
API responses
backend state
Abbiamo visto che PlumePilot:
osserva il runtime nel MAIN world
↓
riconosce soltanto le API origin attese
↓
riusa temporaneamente l'autenticazione già presente
↓
conserva la credenziale nel componente che ne ha bisogno
↓
normalizza i payload esterni
↓
espone operazioni interne tramite messaggi
↓
usa cache e retry
↓riconcilia API e DOM
↓
ricade sul percorso visuale quando necessario
La lezione più importante può essere riassunta così:
Reverse engineering utile non significa conoscere più endpoint possibile. Significa costruire il modello minimo necessario per comprendere il comportamento del sistema, verificare le proprie ipotesi e isolare le dipendenze fragili.
E c’è una seconda lezione altrettanto importante:
Una fonte può essere molto utile senza essere la verità assoluta.
La master call ne è l’esempio perfetto: è abbastanza informativa da ridurre enormemente lo spazio di ricerca, ma non così infallibile da permetterci di eliminare verifiche e fallback.
Abbiamo ormai incontrato continuamente:
Promise
await
setTimeout
retry
AbortController
listener
requestId
callback
MutationObserver
Nel Capitolo 05 fermeremo il tempo e studieremo che cosa sta realmente facendo JavaScript mentre tutte queste operazioni sembrano accadere insieme.
Seguiremo casi reali di PlumePilot per capire:
async / await;await non blocca il browser;Dopo questo capitolo, una parte molto grande dell’architettura di PlumePilot diventerà molto più leggibile.
← 03 — DOM e pagine dinamiche · Indice · 05 — Asincronia JavaScript →