Laura Tonsi
⬡ github.com/lauratonsi📊 Statistiche Comparative — Dati Estratti dai File XML
I numeri in questa pagina sono calcolati a runtime rileggendo i 30 file
data/xml_dataset/*.xml già validati contro il DTD: non sono scritti a mano nel report.
Gli XML, a loro volta, derivano dai dati sorgente (con alcune correzioni manuali documentate
sui dati grezzi, p.es. gli override distretti per Luxembourg e Stockholm). Il report è quindi il
riflesso diretto dei documenti — se cambia un XML, cambia la statistica.
Scarica il dataset in formati alternativi all'XML (generati dalla pipeline dai documenti validati):
📥 Dataset completo (CSV — 30 capitali)Ogni scheda città offre anche il download JSON del singolo documento
(convenzione xmltodict: @attr / #text). Nota: la conversione XML→JSON dei campi a
content-model misto (wiki_intro, transport, description) è lossy — come
avvertono le slide del corso, i formati per dati strutturati non catturano fedelmente testo + markup inline.
🔍 Da dove viene ogni numero — provenienza degli indicatori
In ogni XML confluiscono due famiglie di dati: i contenuti testuali estratti
algoritmicamente dai dump Wikivoyage (trasporti, attrazioni, distretti, locali) e gli
indici numerici presi da city_indices.json (fonti internazionali). L'unico
valore derivato dalla pipeline è l'Appeal Score, ricalcolato dai tre indici.
| Indicatore | Fonte | Come si ottiene | Scala |
|---|---|---|---|
| Appeal Score | 🧮 Derivato | Ricalcolato: Safety×0.4 + Green×0.4 + (100−Costo)×0.2; salvato come attributo appeal_score di <city_report>. |
0–100 |
| Indice di Sicurezza | 📊 city_indices.json | Numbeo Safety Index 2024; letto da <safety index_score="…">. |
0–100 |
| Green Score | 📊 city_indices.json | EU Green Capital Award / EEA; letto da <environment green_score="…">. |
0–100 |
| Accessibilità economica | 📊 city_indices.json | Numbeo Cost of Living 2024; letto da <economic_accessibility score="…">. |
0–100 |
| Budget alloggio | 🧮 Derivato 📊 city_indices.json | Stima sintetica, non un prezzo osservato: <hotel_price> = cost_of_living × 1.85
(proxy in euro del costo della vita Numbeo). Serve a confrontare le città, non a prenotare. |
€/notte |
| Valuta locale | 📊 currency_rates.json | Codice ISO nell'attributo opzionale <city_report currency="…"> (solo capitali fuori
area euro); l'equivalente locale mostrato è una conversione indicativa a tassi di riferimento. |
ISO 4217 |
| Macro-regione | 📊 geo_regions.json | Attributo enumerato obbligatorio <city_report region="…"> secondo il geoscheme
UN M49 (Northern/Western/Southern/Eastern). Cipro, classificato da M49 in Asia occidentale, è
assegnato a southern come eccezione UE documentata. Usato come filtro per area nell'index. |
UN M49 |
| Attrazioni · Locali · Distretti | 📂 Wikivoyage | Conteggio diretto degli elementi <attraction> / <venue> / <district> nei documenti. |
n |
Le fonti complete con i link a Numbeo, EEA e l'avvertenza accademica sono nella sezione AI Usage → Fonti degli Indici Numerici.
🧮 Come si calcola l'Appeal Score
È l'unico indice composito: pesa la sicurezza e la sostenibilità al 40% ciascuna e
l'accessibilità economica al 20% (il costo viene invertito con 100−Costo, così
«più economico = punteggio più alto»). Determina il ranking qui sotto.
Media del dataset: Appeal 61.3 · Safety 66.0 · Green 71.8 (calcolate sui 30 file).
🍺 Locali notturni per categoria — estratti con un predicato XPath
Questi conteggi non scorrono l'intera lista filtrando in Python: usano direttamente un
predicato per valore d'attributo, //venue[@category="…"], che lascia
selezionare al motore XPath i soli elementi desiderati (l'attributo category è
enumerato nel DTD: bar | pub | nightclub).
Dettaglio tecnico del costrutto nella sezione Uso di XPath.
🏆 Classifiche per Indicatore
Top 5 capitali per ogni indicatore. La barra è proporzionale al valore, normalizzata sul massimo del dataset; per il prezzo la scala è invertita (barra più lunga = più economica), così in ogni grafico la barra più piena è sempre la posizione migliore.
Appeal Score
compositoIndice di Sicurezza
0–100Green Score
sostenibilitàPiù Economiche
€/notte⚙️ Architettura della Pipeline TEAM
Il progetto è composto da cinque componenti Python. La pipeline trasforma dump MediaWiki in file XML conformi al DTD (con content-model misto), li valida, genera le pagine HTML navigabili annotate con microdata e indicizza i contenuti in un sistema RAG per le query in linguaggio naturale.
extract_wiki_info.py — Preparazione dati
Analizza i dump Wikivoyage in data/original_source/. Utilizza
mwparserfromhell e spaCy per estrarre testo strutturato.
Produce wiki_text_pulito.csv (trasporti, hotel, distretti) e
attrazione_descrizione_fixed.csv (attrazioni con coordinate geografiche).
I dati di sicurezza, costo della vita e green score provengono da city_indices.json.
final_processor.py — Elaborazione e validazione XML
Script centrale del progetto. Per ogni capitale individua la pagina principale nel dump,
pulisce il Wikitext con regex, estrae distretti con descrizioni dalle sotto-pagine, e
costruisce l'albero XML. I campi transport, description e
wiki_intro sono generati come content-model misto (testo + markup
inline <b>/<i>/<link>): gli iperlink sono reali,
presi dai wikilink del sorgente Wikivoyage e preservati come
<link href> (interni risolti su en.wikivoyage.org, max 3
per campo) — 79 elementi link su 29/30 città. Ogni documento riceve l'URI
canonico <source_url>. L'albero viene validato rispetto al DTD
con lxml.etree.DTD prima della scrittura in data/xml_dataset/.
deploy_dashboard.py — Generazione HTML
Legge la directory dei 30 file XML e li ri-valida in lettura (distinguendo
documenti mal formati da non validi, con contatore 30/30
nell'index.html). Produce index.html, le 30 pagine città e
questo report.html, preservando il markup inline del content-model misto.
Ogni documento è annotato con microdata Schema.org annidati
(City > containsPlace/TouristAttraction > geo/GeoCoordinates), dove
l'itemid è l'URI letto dal documento (<source_url>).
I dati sono anche serializzati come JSON inline per il Virtual Analyst client-side.
validate.py — Validazione DTD standalone
Script indipendente dal resto della pipeline (modellato sui laboratori del corso):
scorre la directory XML, costruisce il DOM con etree.parse() e lo
valida con etree.DTD(...).validate(), distinguendo mal formati,
non validi e validi. Stampa l'esito per file e restituisce codice di uscita
0 solo se tutti i documenti sono validi. Vedi sezione
Schema DTD.
rag/ — Sistema RAG per query in linguaggio naturale
Modulo indipendente (ingest.py + vectorstore.py + api.py):
indicizza i 30 XML in 452 chunk su un indice FAISS + BM25Okapi con
Reciprocal Rank Fusion, e applica un pre-filtro per metadati (regione / valuta /
categoria / soglie) prima della ricerca semantica. Documentazione completa nella sezione dedicata
🔎 Virtual Analyst (RAG).
⚠️ Dati Non Reperibili da Wikivoyage — Integrazione AI
Le seguenti informazioni non erano presenti nei dump Wikivoyage scaricati e sono state integrate con testo sintetico generato da AI (Gemini), chiaramente identificato nel dataset:
| Città | Sezione | Motivo |
|---|---|---|
| Parigi | Trasporti urbani | Campo Transport_Text assente nel CSV estratto da Wikivoyage |
| Bruxelles | Trasporti urbani | Testo estratto dal CSV era in italiano (non dalla pagina principale Wikivoyage) |
| Lussemburgo | Trasporti urbani | Testo estratto dal CSV era in italiano (non dalla pagina principale Wikivoyage) |
| Lussemburgo, Stoccolma | Descrizioni distretti | Il CSV conteneva dati errati: siti naturali (Mullerthal) per Lussemburgo, comuni della contea per Stoccolma. Override manuali applicati. |
| Tutte le 30 città | Strategic Summary | Sintesi strategiche generate con AI (Gemini) tramite prompt strutturati; non estratte da Wikivoyage |
⚠️ Tutti i testi generati da AI sono identificabili nel dataset dalla presenza della stringa
(Source: AI-generated — ...) nel campo <transport> del file XML.
📄 Schema DTD — Struttura dei Documenti XML
Ogni file in xml_dataset/ è validato rispetto a city_report.dtd
prima della scrittura. La validazione è eseguita a runtime con lxml.etree.DTD.
I file non validi vengono segnalati ma non scritti.
Script di validazione dedicato — validate.py
Oltre alla validazione integrata nella pipeline, un script Python standalone
(scripts/validate.py), indipendente dal resto del progetto, costruisce il
DOM di ogni documento e lo valida rispetto al DTD. Distingue i tre esiti:
mal formato (etree.parse() solleva XMLSyntaxError),
non valido (dtd.validate() → False) e valido.
from lxml import etree
dtd = etree.DTD(open("data/city_report.dtd", "rb")) # carica il DTD
for filename in files: # scorre la directory XML
try:
tree = etree.parse(path) # parsing → DOM (buona forma)
except etree.XMLSyntaxError:
... # documento MAL FORMATO
continue
if dtd.validate(tree): # validazione del DOM
... # VALIDO
else:
print(dtd.error_log.filter_from_errors()) # NON VALIDO
✅ Esecuzione: python scripts/validate.py →
30/30 validi · 0 non validi ·
0 malformati. Lo script restituisce codice di uscita
0 solo se tutti i documenti sono validi.
Struttura ad albero
city_report [@appeal_score, @currency?, @region] ├── metadata │ ├── title (nome EN) │ ├── name_it (nome IT) │ └── flag (emoji bandiera) ├── indicators │ ├── hotel_count │ ├── hotel_price │ ├── safety [@index_score] │ ├── environment [@green_score] │ ├── cost_index [@value] │ └── economic_accessibility [@score] ├── transport (testo descrittivo) ├── accommodation │ └── hotel* │ ├── name │ └── price ├── highlights │ └── attraction* [@lat @lon] │ ├── name │ └── description ├── districts? │ └── district* │ ├── name │ └── description ├── description (sintesi strategica EN) ├── wiki_intro? (intro da Wikivoyage, ripulita) └── landmark_image? (URL immagine simbolo)
Dichiarazione DTD (file sorgente)
<!ELEMENT city_report (metadata, indicators, transport, accommodation, highlights, districts?, description, wiki_intro?, landmark_image?, nightlife?)>
<!-- currency: codice ISO 4217 della valuta locale (es. "GBP", "SEK").
Attributo OPZIONALE (#IMPLIED): è assente per le città dell'area euro,
presente solo per le capitali che usano una valuta diversa dall'euro.
region: macro-regione geografica (geoscheme UN M49). Attributo ENUMERATO
e OBBLIGATORIO: ogni capitale appartiene a una e una sola area. -->
<!ATTLIST city_report
appeal_score CDATA #REQUIRED
currency CDATA #IMPLIED
region (northern | western | southern | eastern) #REQUIRED>
<!ELEMENT metadata (title, name_it, flag, source_url)>
<!ELEMENT title (#PCDATA)>
<!ELEMENT name_it (#PCDATA)>
<!ELEMENT flag (#PCDATA)>
<!-- Canonical URI/IRI of the city: used as microdata itemid in the HTML so that
the identifiers in the site correspond exactly to those in the documents -->
<!ELEMENT source_url (#PCDATA)>
<!ELEMENT indicators (hotel_count, hotel_price, safety, environment, cost_index, economic_accessibility)>
<!ELEMENT hotel_count (#PCDATA)>
<!ELEMENT hotel_price (#PCDATA)>
<!ELEMENT safety EMPTY>
<!ATTLIST safety index_score CDATA #REQUIRED>
<!ELEMENT environment EMPTY>
<!ATTLIST environment green_score CDATA #REQUIRED>
<!ELEMENT cost_index EMPTY>
<!ATTLIST cost_index value CDATA #REQUIRED>
<!ELEMENT economic_accessibility EMPTY>
<!ATTLIST economic_accessibility score CDATA #REQUIRED>
<!ELEMENT transport (#PCDATA | b | i | link)*>
<!ELEMENT accommodation (hotel*)>
<!ELEMENT hotel (name, price)>
<!ELEMENT price (#PCDATA)>
<!ELEMENT highlights (attraction*)>
<!ELEMENT attraction (name, description)>
<!ATTLIST attraction
lat CDATA #REQUIRED
lon CDATA #REQUIRED>
<!ELEMENT districts (district*)>
<!ELEMENT district (name, description)>
<!ELEMENT name (#PCDATA)>
<!ELEMENT description (#PCDATA | b | i | link)*>
<!ELEMENT wiki_intro (#PCDATA | b | i | link)*>
<!ELEMENT landmark_image (#PCDATA)>
<!-- Inline elements for mixed content (content-model misto) -->
<!ELEMENT b (#PCDATA)>
<!ELEMENT i (#PCDATA)>
<!ELEMENT link (#PCDATA)>
<!ATTLIST link href CDATA #REQUIRED>
<!ELEMENT nightlife (venue*)>
<!ELEMENT venue (name)>
<!-- lat/lon obbligatorie: ogni venue è geolocalizzata sulla mappa.
category è un attributo ENUMERATO: solo questi tre valori sono ammessi. -->
<!ATTLIST venue lat CDATA #REQUIRED
lon CDATA #REQUIRED
category (bar | pub | nightclub) #REQUIRED>
🔬 Tecniche di Parsing e Pulizia del Testo
Parsing MediaWiki XML con lxml
I dump Wikivoyage sono file XML con namespace
http://www.mediawiki.org/xml/export-0.11/.
La libreria lxml.etree li analizza con
tree.findall('.//mw:page', ns).
Ogni dump contiene più pagine: la pagina principale della città
e le pagine dei singoli distretti (Città/Distretto).
Parsing del Wikitext con mwparserfromhell
All'interno di ogni pagina MediaWiki, il contenuto non è XML ma wikitext
(la sintassi con {template}, [[link]], == sezioni ==,
tabelle, ecc.). mwparserfromhell (“MediaWiki Parser from Hell”) è la
libreria Python — usata anche dai bot di Wikipedia — che analizza questo wikitext e lo
trasforma in un albero di nodi tipizzati (template, wikilink, tag, heading…).
Permette di estrarre e ripulire il testo in modo strutturato e robusto — ad es.
wikicode.filter_templates() per individuare e rimuovere i template, o
strip_code() per ottenere il testo leggibile — invece di affidarsi solo a
fragili espressioni regolari. È impiegata in extract_wiki_info.py nella fase
di preparazione del dataset.
Selezione della Pagina Principale
L'algoritmo cerca nell'ordine: (1) pagina con titolo esatto uguale al nome della
città; (2) pagina Città/Understand (intro Wikivoyage). Se il dump
contiene solo pagine di quartiere (es. Amsterdam, Berlino, Parigi, Roma),
la sezione Wiki Archive viene omessa per evitare di mostrare testo distrettuale
al posto dell'intro cittadina. La comparazione è accent-insensitive
tramite unicodedata.normalize('NFKD'), necessario per Reykjavík.
Pulizia del Wikitext
Il testo grezzo in formato Wikitext viene ripulito con sequenze di regex:
rimozione dei template {"{"}...{"}"} (5 passate per nested),
link [[...]] → testo visibile,
link [http://url testo] → solo testo,
header == ... ==, tag HTML, marcatori bold/italic ''',
hatnote di disambiguazione (:For other...).
Il testo finale viene troncato a 400 caratteri.
Estrazione dei Distretti
La colonna Districts del CSV fornisce i nomi dei distretti
(estratti nella fase 1). I nomi vengono ripuliti da rumore (es. "Visitor info:",
"Buses", toponomastica storica come "Laibach").
Per ogni distretto si cerca la sotto-pagina corrispondente nel dump XML
e se ne estrae il testo introduttivo come descrizione.
Per Luxembourg e Stockholm sono stati definiti override manuali
perché il CSV conteneva dati errati (siti naturali, municipalità di contea).
Validazione DTD con lxml
Prima di ogni scrittura, l'albero XML viene validato con
lxml.etree.DTD(open('city_report.dtd', 'rb')).validate(root).
Tutti i 30 file superano la validazione. Gli elementi opzionali
(districts?, wiki_intro?, landmark_image?)
non vengono creati se i dati corrispondenti sono assenti.
Microdata Schema.org nell'HTML
Ogni card nella dashboard è annotata con
itemscope itemtype="https://schema.org/City"
e itemprop="name" sul nome della città.
Le annotazioni microdata sono generate direttamente dallo script
deploy_dashboard.py.
HTML semantico nell'output
L'HTML prodotto segue il principio dell'HTML semantico: nessun tag procedurale
deprecato (<font>, <center>) né attributi
presentazionali (align, bgcolor), e una struttura logica con
elementi di blocco (<header>, <nav>,
<main>, <section>, <article>,
<footer>).
Per gli elementi inline, il vocabolario del content-model misto (b,
i, link nel file XML) viene mappato dalla funzione
inline_to_html() ai tag semantici dell'HTML — b→<strong>,
i→<em>, link→<a> — e non ai presentazionali
<b>/<i>, perché in quei punti l'enfasi porta significato
(nome della città, termine straniero). I rari <b> residui nei template
sono enfasi puramente visiva (in HTML5 <b> non è deprecato: indica testo
stilisticamente distinto senza valore semantico).
Gestione delle Eccezioni
Ogni città è elaborata in un blocco try/except indipendente:
un file corrotto non blocca la pipeline. La funzione find_orig_file()
usa confronto accent-insensitive per trovare i file sorgente con nomi accentati.
I dati mancanti nel CSV (es. Paris transport = NaN, Reykjavik non presente)
vengono gestiti con TRANSPORT_PATCH e iterando su
city_indices.json anziché sul CSV.
Coordinate Geografiche e Mappa
Le attrazioni hanno attributi @lat e @lon estratti
dal CSV attrazione_descrizione_fixed.csv. La dashboard genera link
diretti a Google Maps (?q=lat,lon) e la mappa interattiva
mappa_attrazioni.html visualizza tutti i punti su Leaflet.js.
🧭 Uso di XPath
La fase di lettura ed estrazione dei documenti XML si basa su XPath, a due
livelli: il motore XPath completo di lxml (metodo .xpath()) e il suo
sottoinsieme ElementPath (.find() / .findall() /
.findtext()).
1. XPath completo — .xpath()
| Script | Espressione XPath | Costrutto |
|---|---|---|
deploy_dashboard.py |
string(.//safety/@index_score) |
funzione string() + asse attributo @ |
deploy_dashboard.py |
.//hotel · .//attraction · .//venue |
asse discendente // |
extract_wiki_info.py |
//mw:page · string(.//mw:text) |
// + namespace + string() |
deploy_dashboard.py |
.//venue[@category="pub"] |
predicato per valore d'attributo — filtro di precisione |
deploy_dashboard.py |
string(/city_report/@appeal_score) |
path assoluto + asse attributo sull'attributo obbligatorio del root |
built_dataset.py |
string(//*[local-name()='text']) |
predicato con funzione [local-name()=…] |
Due esempi mostrano l'intera gamma dei costrutti. string(.//safety/@index_score)
combina path discendente, asse attributo e funzione XPath per leggere gli
indici memorizzati come attributi di elementi EMPTY. .//venue[@category="pub"]
usa invece un predicato ([…]): il filtro per valore d'attributo che lascia
selezionare al motore XPath i soli locali desiderati — più robusto del filtraggio manuale in
Python e immune all'ordine degli elementi. Entrambe le espressioni alimentano numeri
reali nella sezione Statistiche.
Resilienza nel casting. I valori estratti sono sempre stringhe: la conversione a numero
(medie di Appeal, Safety, prezzi) passa per una funzione fv() che applica
float(str(x).strip()) dentro un try/except. Così un eventuale valore
corrotto (es. "dieci") non manda in crash la pipeline ma viene neutralizzato,
permettendo di proseguire con gli altri documenti.
2. ElementPath — find() / findall() / findtext()
Sottoinsieme di XPath di ElementTree/lxml, usato pervasivamente per navigare il DOM —
es. tree.findall('.//mw:page', ns) (final_processor.py),
root.findall('.//attraction') (validate.py),
root.findtext(".//title") (deploy_dashboard.py). Gli attributi
(es. @category, @lat) si leggono invece con
element.get('attr').
🏷️ Microdata Schema.org — Quantificazione
Lo script deploy_dashboard.py annota i documenti HTML con microdata
Schema.org (vincolo del progetto). Le annotazioni sono generate dallo script e i
conteggi qui sotto sono calcolati a runtime ri-scansionando i file HTML appena
prodotti (index.html + 30 pagine città), quindi non sono
valori statici.
Item per tipo Schema.org
schema.org/GeoCoordinates | 570 |
schema.org/TouristAttraction | 300 |
schema.org/BarOrPub | 227 |
schema.org/PropertyValue | 120 |
schema.org/Hotel | 88 |
schema.org/City | 60 |
schema.org/AggregateRating | 30 |
schema.org/Place | 30 |
schema.org/Continent | 30 |
schema.org/NightClub | 13 |
Proprietà annotate (itemprop)
name 838 · containsPlace 628 · geo 570 · latitude 570 · longitude 570 · description 330 · value 120 · additionalProperty 90 · priceRange 88 · containedInPlace 60 · alternateName 36 · aggregateRating 30 · ratingValue 30 · bestRating 30 · worstRating 30 · ratingCount 30 · identifier 30 · propertyID 30 · sameAs 30 · image 30 · url 30
Struttura annidata e identità (itemid ↔ documento)
La gerarchia rispecchia il pattern multi-livello visto a lezione
(lab_microdata.py): ogni <city report> diventa una
City che contiene (containsPlace) le entità annidate del
documento —
TouristAttraction, Hotel e i locali notturni — ognuna con il proprio
blocco geo/GeoCoordinates (latitude/longitude), e che a
sua volta è contenuta (containedInPlace) nella propria macro-regione e nel
continente:
City [itemid = source_url] ├─ containedInPlace → Place "… Europe" [identifier → PropertyValue "UN M49"] │ └─ containedInPlace → Continent "Europe" (sameAs → wikidata.org/Q46) ├─ aggregateRating → AggregateRating (ratingValue = appeal_score, best/worst 100/0) ├─ additionalProperty → PropertyValue (Safety Index · Green Score · Economic Accessibility) ├─ containsPlace → TouristAttraction (name, description, geo → GeoCoordinates) ├─ containsPlace → Hotel (name, priceRange) └─ containsPlace → BarOrPub | NightClub (name, geo → GeoCoordinates)
Due scelte semantiche degne di nota: (1) l'attributo XML enumerato
category (bar | pub | nightclub) viene mappato sulla classe
Schema.org più specifica — NightClub per le discoteche, BarOrPub
altrimenti; (2) gli indicatori numerici sono modellati correttamente: l'Appeal
Score (composito) come AggregateRating — l'unico aggregateRating
ammesso su un Place — e i tre sotto-indici come PropertyValue via
additionalProperty, evitando di forzare più rating distinti su una sola
proprietà; (3) l'attributo XML region (UN M49) diventa la relazione di
contenimento geografico containedInPlace — speculare a containsPlace —
risalendo dalla città alla macro-regione (Place, con il codice M49 come
identifier) fino al Continent "Europe" (sameAs Wikidata).
Ogni itemid è l'URI canonico letto dal documento XML
(elemento <source_url>): gli identificatori nel sito
corrispondono esattamente a quelli nei documenti, come nell'esempio dei castelli
del corso (itemid ↔ id del file XML).
Verifica round-trip. Lo script standalone scripts/check_microdata.py
ri-estrae i microdata dalle pagine con la libreria microdata
(microdata.get_items(), la stessa del laboratorio lab_microdata.py) e
controlla che: ogni pagina esponga una City top-level; l'itemid
ri-letto dall'HTML coincida con il <source_url> del relativo XML; gli oggetti
annidati (TouristAttraction/Hotel/BarOrPub) portino
GeoCoordinates e l'AggregateRating il suo ratingValue.
Esito: round-trip coerente su 30/30 pagine, con conteggi per tipo identici a
quelli del contatore qui sopra — due metodi indipendenti (regex di scrittura ↔ parser di
rilettura) che concordano.
♿ HTML Semantico e Accessibilità
«HTML è il linguaggio base del Web. Deve essere usato in modo semantico, evitando
tag procedurali deprecati come <font> e preferendo una chiara
organizzazione logica. Gli elementi si dividono in quelli di blocco (che vanno a
capo) e inline (come <em>, <strong> o il
generico <span>), che si inseriscono nel flusso del testo senza
spezzarlo.»
Tutto l'HTML prodotto da deploy_dashboard.py applica questo principio. Di
seguito come la teoria si traduce nel codice generato e, soprattutto, in un effetto reale.
1. Niente tag procedurali deprecati
Sulle 33 pagine generate: zero <font>, <center>,
<marquee>, <strike> e zero attributi
presentazionali (align, bgcolor, valign…). La
presentazione è interamente delegata al foglio di stile stile.css.
2. Organizzazione logica con elementi di blocco
Ogni pagina è strutturata con elementi di blocco semantici — non <div>
generici — che descrivono il ruolo del contenuto: <header> (testata),
<nav> (navigazione), <main> (contenuto principale, uno
per pagina), <section> / <article> (blocchi tematici),
<footer> (piè di pagina), con una gerarchia coerente di heading
h1→h2→h3.
3. Inline semantici (strong / em / span)
Il vocabolario inline del content-model misto nel XML (b, i,
link) viene mappato dalla funzione inline_to_html() ai tag
semantici dell'HTML — b→<strong>, i→<em>,
link→<a> — perché in quei punti l'enfasi porta significato
(nome città, termine straniero). Il generico <span> è usato per i
frammenti senza semantica propria (es. valori delle statistiche). I rari <b>
residui sono enfasi puramente visiva: in HTML5 <b> non è deprecato ma
ridefinito come «testo stilisticamente distinto senza importanza semantica».
4. La «traduzione» del semantico: l'accessibilità
L'HTML semantico è volutamente invisibile all'occhio (<strong> si
vede come un grassetto), ma si traduce in significato leggibile dalle macchine. Il
canale principale è l'albero di accessibilità: gli elementi semantici generano
automaticamente dei landmark ARIA — <header>→banner,
<nav>→navigation, <main>→main,
<footer>→contentinfo — tra i quali uno screen reader può
saltare. Per rendere questa struttura realmente usabile abbiamo aggiunto:
- Skip link «Salta al contenuto» come primo elemento (visibile solo al focus da tastiera), per bypassare la navigazione;
- aria-label sulle
<nav>duplicate (Principale / Tra le città / Sezioni del report) così lo screen reader le distingue; - aria-current="page" sul link attivo; lang sul documento e alt su tutte le immagini.
Verifica: DevTools del browser → scheda Accessibility (mostra i landmark etichettati), oppure la navigazione per landmark di uno screen reader (VoiceOver / NVDA).
5. Il sito come riflesso diretto dei documenti
Coerentemente con la semantica, ogni dato del documento XML è reso visibile nella
pagina, non solo scaricabile. In particolare l'URI canonico del documento (elemento
<source_url>) è ora un link cliccabile in ogni pagina città, e
coincide con l'itemid dei microdata: lo stesso identificatore vive nel
documento, nel link visibile e nell'annotazione machine-readable. Il download del file XML
resta un'aggiunta, non l'unico modo di accedere ai dati.
Sintesi: principio → costrutto → effetto
| Principio (slide) | Costrutto HTML | Effetto reale |
|---|---|---|
| No tag procedurali | 0 <font>; stile in CSS |
separazione contenuto/presentazione |
| Organizzazione logica (blocco) | header/nav/main/section/footer |
landmark ARIA + document outline |
| Inline semantico (em/strong) | <strong>/<em>/<span> |
enfasi pronunciata dagli screen reader |
| Web come grafo di risorse | <a itemprop="url" href=URI> |
URI documento = link = itemid microdata |
6. Target tattili (tap-target) sulla mappa
Un audit Lighthouse (accessibilità 96/100) ha segnalato che alcuni marker Leaflet della mappa (es. Stoccolma, Oslo, Roma) avevano un'area tattile inferiore a 24×24 px o parzialmente sovrapposta — un problema per chi naviga da touch o ha difficoltà motorie. Interventi correttivi:
- marker dei locali e delle attrazioni (pagine città) portati a 32×32 px, ben oltre la soglia di 24;
- i marker delle capitali, che a zoom basso si sovrapponevano, sono ora raggruppati con
L.markerClusterGroup: gli overlap si fondono in un unico bersaglio cliccabile e si riaprono allo zoom (≥ 6); - raggio dei punti-attrazione aumentato per un'area di tocco più comoda.
7. Contrasto del colore (WCAG AA)
Un controllo del contrasto ha rilevato un link sotto la soglia AA per testo piccolo
(4.5:1): il link Report & Documentation nel footer usava l'accent
#E74C3C su sfondo chiaro (3.82:1). È stato portato all'accent scuro
#C0392B (5.44:1, conforme AA). La barra di navigazione superiore, pur
avendo testo chiaro, è invece su sfondo scuro #0B1524 (10.48:1):
già ampiamente conforme.
7. Albero di accessibilità — index.html
È la struttura che uno screen reader «vede»: solo ruoli e nomi accessibili, non lo
stile. I landmark (banner / navigation / main / contentinfo) e i heading
h1→h2 offrono una mappa navigabile della pagina; il widget chat flottante è
esposto come dialog. Questa è una copia dell'albero reale della home:
document "EuroCity Strategic Intelligence" [lang="en"]
├─ link "Salta al contenuto" (skip-link, visibile al focus)
├─ banner <header>
│ └─ navigation "Navigazione principale" <nav> → Home · Report · Map
├─ heading h1 "30 European capitals, one intelligence."
├─ region "How the data is built" <section aria-label>
│ └─ heading h2 "How the data is built"
├─ main "Griglia delle capitali" <main id="city-grid">
│ ├─ article → heading h2 "Amsterdam" + link
│ ├─ article → heading h2 "Athens" + link
│ └─ … (30 article, una card per capitale)
├─ contentinfo <footer> → link "Report & Documentation"
├─ button "Back to top"
└─ dialog "Virtual Analyst" <div role="dialog"> ← widget flottante
button "Ask the analyst" · textbox · button "Ask" · button "Close the analyst"
🎨 Architettura Frontend — CSS & JavaScript
L'interfaccia Travel 2026 applica una rigorosa separazione delle competenze: tutta
la presentazione vive in un unico file esterno, stile.css. Il JavaScript è limitato
a tre soli compiti — commutare le classi dei tab, leggere gli attributi data-pct per
alimentare le custom property CSS e chiamare map.invalidateSize() quando il
contenitore della mappa torna visibile. Nel markup generato non viene scritto alcun
attributo style="" inline.
1. Foglio di stile esterno — stile.css
Tutte le 30 pagine città condividono un unico foglio di stile, versionato con un hash MD5 del suo
contenuto (stile.css?v=hash): così ogni modifica forza l'invalidazione della
cache del browser. Le nuove regole delle pagine città sono confinate sotto i prefissi
.city-detail e .city-hero per non intaccare index.html né
questo report.html.
Palette Travel 2026 — custom property CSS
Quattro variabili di brand aggiunte a :root:
--terra: #E07A5F (terracotta),
--crema: #F4F1DE,
--night: #3D405B (blu notte),
--gold: #E9C46A. I titoli hero e questo report usano Playfair Display (serif) via Google Fonts, caricato insieme a Inter.
Anello di progresso — conic-gradient
Gli anelli di Safety e Green sono puro CSS:
conic-gradient(currentColor calc(var(--pct,0) * 3.6deg), var(--slate-200) 0deg).
Il fattore 3.6 mappa un punteggio 0–100 su 0–360°.
Uno pseudo-elemento ::after (inset 7 px) maschera il centro creando la forma a ciambella.
Barre animate — var(--bar-w)
Le barre orizzontali usano width: var(--bar-w, 0%) con
transition: width 0.8s cubic-bezier(.4,0,.2,1).
La proprietà parte da 0; il JS imposta il valore finale dopo il render, innescando l'animazione
senza scrivere alcuno style="" inline.
Sistema a tab — solo toggle di classe
.city-tab-panel { display: none } nasconde tutti i pannelli per default.
.city-tab-panel.active { display: block; animation: tabIn 0.22s }
mostra quello attivo con una dissolvenza in entrata. Il JS si limita ad aggiungere o togliere la
classe active — niente innerHTML, niente stili inline.
2. I tab della scheda città
Ogni scheda città è organizzata in cinque tab — Overview, Sights, Nightlife,
Stay (mostrato solo se la città ha hotel catalogati) e Tips. Il gestore dei click
commuta soltanto le classi active:
var btns = document.querySelectorAll('.city-tab-btn');
var panels = document.querySelectorAll('.city-tab-panel');
btns.forEach(function(btn){
btn.addEventListener('click', function(){
btns.forEach(function(b){ b.classList.remove('active'); });
panels.forEach(function(p){ p.classList.remove('active'); });
this.classList.add('active');
var panel = document.getElementById('tab-' + this.dataset.tab);
if(panel){ panel.classList.add('active'); }
// Leaflet deve ricalcolare le dimensioni quando il tab Overview torna attivo
if(this.dataset.tab === 'overview' && cityMap){
setTimeout(function(){ cityMap.invalidateSize(); }, 50);
}
});
});
3. data-pct → custom property CSS
Le metric card portano un attributo HTML data-pct con il valore numerico (0–100).
Quando il DOM è pronto, un unico ciclo legge ciascun attributo e ne riversa il valore nella
custom property CSS corrispondente — l'unica eccezione ammessa alla regola «zero stili inline»
(impostare una custom property con style.setProperty alimenta una variabile, non una regola di presentazione):
document.querySelectorAll('[data-pct]').forEach(function(el){
var v = el.dataset.pct;
if(el.classList.contains('circular-ring')){
el.style.setProperty('--pct', v); // pilota l'angolo del conic-gradient
} else {
el.style.setProperty('--bar-w', v + '%'); // pilota la larghezza della barra → parte la transizione CSS
}
});
4. Leaflet — il pattern invalidateSize()
La mappa Leaflet si trova nel tab Overview (il pannello attivo di default), quindi viene
renderizzata correttamente al primo caricamento. Quando l'utente esce e torna, il contenitore è già
stato dimensionato una volta; la chiamata setTimeout(..., 50) lascia al browser il tempo
di completare il ciclo di paint del display: block prima che Leaflet rilegga le dimensioni.
La variabile cityMap è dichiarata fuori dalla IIFE così che il gestore dei tab possa raggiungerla.
5. Soglie dei badge hero (calcolate in fase di build)
I badge sono calcolati da deploy_dashboard.py durante la rigenerazione delle pagine,
tarati sugli intervalli reali del dataset (appeal 48–68, safety 47–75, green 60–88, prezzo 88–163 €):
| Badge | Condizione | Esempio verificato |
|---|---|---|
| 🌿 Green City | green > 75 | Stoccolma 88 ✓ |
| 🛡️ Ultra Safe | safety > 70 | Lussemburgo 73.5 ✓ |
| ⭐ Top Rated | appeal > 60 | Copenaghen 66 ✓ |
| 💰 Budget Pick | price < 130 € | Lisbona 112 € ✓ |
🔎 Virtual Analyst — l'assistente che risponde alle domande
Il Virtual Analyst è l'assistente del sito: risponde a domande scritte in linguaggio naturale (italiano o inglese) sulle 30 capitali — per esempio «bar a Dublino» o «discoteche nelle città fuori dall'euro». È un sistema RAG (Retrieval-Augmented Generation): invece di "sapere" le risposte, le va a cercare nei 30 file XML e le mostra in modo ordinato. Non inventa: riporta solo ciò che è nel dataset.
Come trova la risposta giusta
Per ogni domanda cerca in due modi complementari allo stesso tempo: per significato (capisce il senso anche con parole diverse — «dove dormire» trova gli hotel) e per parole chiave (cerca i termini esatti). Poi fonde le due classifiche in un'unica graduatoria: un risultato che convince entrambi i metodi finisce in cima.
La marcia in più: cercare tra i metadati
La ricerca nel testo va bene per le domande discorsive, ma fa fatica con quelle precise (es. «discoteche nelle città che non usano l'euro»). Per questo l'analista sfrutta i metadati già iniettati in ogni città — macro-regione, valuta, tipo di locale, punteggi numerici — esattamente come i filtri di un sito di e-commerce: prima restringe il campo a colpo sicuro (es. valuta ≠ euro e tipo = discoteca), poi cerca nel testo solo dentro quel sottoinsieme. È il punto in cui RAG e Web of Data (i microdata Schema.org) si fondono: la stessa informazione che descrive le città per i motori di ricerca serve anche a rispondere meglio.
I tre vantaggi della fusione RAG × Web of Data
- Precisione: i filtri su
@region,@currency,@categorysono esatti — niente più «zuppa di tag» da indovinare nel testo. - Niente confusione tra categorie: un pub e una discoteca si distinguono dall'etichetta tipizzata (
BarOrPub/NightClub), non dal contesto della frase. - Numeri garantiti: coordinate (
GeoCoordinates), appeal (AggregateRating) e indicatori sono restituiti identici al dato — zero allucinazioni sui numeri.
I due "motori": uno in locale, uno nel browser
La versione completa è un piccolo server Python (FastAPI) con gli indici di ricerca veri: è la
reference implementation, si avvia sul proprio computer. Ma il sito è pubblicato su
GitHub Pages, che ospita solo file statici e non può eseguire un server Python: perciò, quando
non riesce a contattare il server locale, la pagina usa una versione che gira interamente nel browser
(metadataQuery()) sui dati già inclusi nella pagina. I due motori usano la stessa logica di
filtraggio: quello del browser è ciò che i visitatori usano davvero, e funziona senza alcun server.
Dettagli tecnici (approfondimento)
Le sezioni precedenti bastano per capire il sistema; qui sotto il "sotto il cofano".
data/xml_dataset/*.xml → rag/ingest.py → rag_index/ (index.faiss 384-dim · docs.json 452 chunk + meta)
↓
rag/vectorstore.py FAISS + BM25Okapi → Reciprocal Rank Fusion + pre-filtro metadati
↓
rag/api.py FastAPI (127.0.0.1:8000) · parse_filters() · rilevamento intento
- FAISS
IndexFlatIP(384 dim,all-MiniLM-L6-v2) per la ricerca semantica; BM25Okapi per quella lessicale; fusione con Reciprocal Rank Fusion (α=0.5, k=60); - il rilevamento d'intento (transport / hotel / attractions / safety / districts / nightlife) dà priorità ai chunk della sezione pertinente.
ingest.py allega a ogni chunk i metadati strutturati dell'XML, così la ricerca filtra su valori esatti invece di cercarli nel testo:
{
"text": "[BERLIN] Bar e locali notturni: ...",
"city": "BERLIN", "section": "nightlife", "source": "Berlin.xml",
"meta": { "appeal": 63.2, "currency": "EUR", "region": "western",
"safety": 72.5, "green": 78.0, "cost": 85.0, "economy": 15.0, "price": 157.25 },
"categories": ["bar", "pub", "nightclub"]
}
Dimensioni di filtro riconosciute
| Dimensione | Sorgente metadato | Esempio di domanda | Filtro applicato |
|---|---|---|---|
| Macro-regione | @region (UN M49) | attractions in northern europe | region = northern |
| Valuta | @currency | nightclubs in non-euro cities | currency ≠ EUR |
| Categoria locale | @category | pubs in eastern europe | category = pub |
| Soglie numeriche | appeal/safety/green/price | cities with appeal over 60 | appeal ≥ 60 |
I vincoli si combinano in AND (es. eurozone + green over 70). Lato server la risposta
/query include il campo filters con le dimensioni applicate (trasparenza); lato
client il motore restituisce in più i valori garantiti verbatim dai metadati.
Limiti noti: alcune città (Londra, Budapest, Parigi…) mancano di dati hotel nelle sorgenti Wikivoyage; la sintesi delle risposte testuali è euristica (estrazione di frasi), non generativa.
🤖 Dichiarazione Utilizzo Strumenti AI
Claude Code (Anthropic) — Assistenza allo Sviluppo
Claude Code / claude-sonnet-4-6Lo strumento Claude Code è stato utilizzato per:
- Debugging della pipeline di parsing (problema selezione pagina principale nei dump Wikivoyage multi-pagina)
- Scrittura della funzione
advanced_wiki_cleaner()per la pulizia del Wikitext con regex - Risoluzione del problema accent-insensitive per Reykjavík (uso di
unicodedata.normalize) - Identificazione e correzione del bug nell'estrazione distretti in
deploy_dashboard.py(d.textinvece did.findtext('name')) - Generazione della griglia di override distretti per Luxembourg e Stockholm
- Generazione di questo file
report.htmle delREADME.md
AI per i Contenuti del Dataset
city_descriptions.json
Le sintesi strategiche in inglese in city_descriptions.json
(mostrate come Strategic Summary nel tab Tips delle schede città) sono state generate con
Gemini (Google AI) tramite prompt strutturati. Fonte: testo a scopo
didattico, non estratto da una singola fonte primaria.
Genera una descrizione strategica in inglese (max 2 frasi) di [CITTÀ] come capitale europea, focalizzandoti su: innovazione urbana, sostenibilità, sicurezza, accessibilità economica. Tono: analitico, da report istituzionale.
Fonti degli Indici Numerici
city_indices.json| Indicatore | Fonte originale | Rielaborazione | Nota |
|---|---|---|---|
| Safety Index | Numbeo Safety Index 2024 | AI (Gemini) | Valori adattati; scarto stimato ±5–10 pt rispetto ai dati Numbeo originali |
| Cost of Living | Numbeo Cost of Living Index 2024 | AI (Gemini) | Normalizzato sulla scala europea (Numbeo usa NYC = 100 come riferimento) |
| Green Score | EU Green Capital Award + EEA City Statistics | Stima sintetica AI (Gemini) | ⚠️ Non validato da un indice ufficiale 0–100. Stima composita basata su: EU Green Capital Award, aree verdi pro capite, emissioni CO₂, politiche mobilità. |
⚠️ Avvertenza accademica: i valori numerici in city_indices.json
sono stime rielaborate con AI a partire da fonti pubbliche. Non sostituiscono
la consultazione diretta dei dataset originali Numbeo o EEA per usi di ricerca.
📁 Elenco Completo dei File e Fonti
Inventario completo del repository, con link diretto a ogni file su GitHub e alla fonte originale. I conteggi qui sotto sono calcolati a runtime scandendo le cartelle del progetto.
| File | Contenuto | Fonte | Licenza |
|---|---|---|---|
data/original_source/*.xml |
Dump MediaWiki delle 30 capitali (testi, trasporti, hotel, distretti) | Wikivoyage — download manuale | CC-BY-SA 3.0 |
data/wiki_text_pulito.csv |
Trasporti, hotel e distretti estratti dai dump tramite extract_wiki_info.py |
Derivato da Wikivoyage | CC-BY-SA 3.0 |
data/attrazione_descrizione_fixed.csv |
300 attrazioni con nome, descrizione e coordinate geografiche | Derivato da Wikivoyage + curato manualmente | CC-BY-SA 3.0 |
data/city_indices.json |
Safety Index, Cost of Living, Green Score per 30 città | Numbeo 2024, EU Green Capital Award, EEA — rielaborati con AI (Gemini) | Stima — non ufficiale |
data/city_descriptions.json |
Sintesi strategiche in inglese per 30 città (campo Strategic Summary) | Generato con AI (Gemini) — testo originale | Testo didattico |
data/nightlife.json |
240 locali notturni (bar, pub, nightclub) con coordinate geografiche | OpenStreetMap via Overpass API | ODbL (Open Database Licence) |
data/xml_dataset/*.xml |
30 file XML output, validati DTD, uno per capitale | Generato dalla pipeline — derivato dalle fonti sopra | — |
data/transport_patches.json |
Testi trasporto AI per 5 città (Paris, Brussels, Luxembourg, Lisbon, Nicosia) | Generato con AI (Gemini) — testo originale | Testo didattico |
data/currency_rates.json |
Valuta locale ISO 4217 + tassi EUR→locale (snapshot 2026-01) per 10 capitali non-euro | Tassi di cambio indicativi raccolti manualmente | — |
data/geo_regions.json |
Macro-regione UN M49 per ogni capitale (fonte + nota su Cipro) | UN Statistics Division (geoscheme M49) | — |
data/city_report.dtd |
Schema DTD per la validazione dei file XML | Creato originalmente per il progetto | — |
| Script Python | |||
scripts/extract_wiki_info.py |
Step 1 pipeline — dump Wikivoyage → CSV/JSON (usa mwparserfromhell, spaCy) |
Progetto originale | — |
scripts/built_dataset.py |
Precursore / variante di final_processor.py — costruisce gli XML dal dataset; menzionato nella sezione XPath |
Progetto originale | — |
scripts/final_processor.py |
Step 2 pipeline — elaborazione principale: XML con content-model misto + validazione DTD in scrittura | Progetto originale | — |
scripts/deploy_dashboard.py |
Step 3 pipeline — ri-validazione DTD in lettura + generazione HTML con microdata Schema.org | Progetto originale | — |
scripts/validate.py |
Step 4 pipeline — validatore DTD standalone: DOM + etree.DTD().validate() |
Progetto originale | — |
scripts/check_microdata.py |
Verifica round-trip microdata: ri-estrae gli item schema.org dalle pagine HTML con microdata.get_items() e li confronta con i dati XML sorgente |
Progetto originale | — |
scripts/fetch_nightlife.py |
One-shot: recupera bar/pub/nightclub da OpenStreetMap via Overpass API per le 30 capitali → produce nightlife.json |
OpenStreetMap (ODbL) | — |
scripts/download_images.py |
One-shot: scarica immagini landmark per le 30 città tramite Wikipedia pageimages API → assets/images/*.jpg |
Wikimedia Commons (CC-BY-SA) | CC-BY-SA |
scripts/map.py |
Prototipo iniziale della mappa interattiva con Folium; sostituito dall'implementazione Leaflet.js | Progetto originale | — |
👥 Il Team
Progetto sviluppato nell'ambito della Laurea Magistrale in Governance e Politiche dell'Innovazione Digitale (GEPID) — Università di Bologna, A.A. 2024/2025.
Susanna Cioni
⬡ github.com/SusannaCioni