From 3bca288cd55964b86a5e6a368885b99f5a2c27c7 Mon Sep 17 00:00:00 2001 From: Codewriter90x Date: Thu, 30 Jul 2026 13:59:32 +0200 Subject: [PATCH] Improve Pages SEO and indexability --- .github/workflows/pages.yml | 4 + README.md | 34 +- scripts/build_pages.py | 602 +++++++++++++++++++++++++++++++++++- site/assets/app.js | 7 +- site/assets/core.mjs | 10 + site/assets/styles.css | 232 +++++++++++++- site/index.html | 185 ++++++++--- tests/pages_core.test.mjs | 13 + tests/test_pages.py | 102 ++++-- 9 files changed, 1093 insertions(+), 96 deletions(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 98f172e..d257023 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -6,7 +6,9 @@ on: - ".github/workflows/pages.yml" - "assets/social-preview.jpg" - "data/italian_locations.csv" + - "project.json" - "scripts/build_pages.py" + - "scripts/project_metadata.py" - "site/**" - "tests/pages_core.test.mjs" - "tests/test_pages.py" @@ -17,7 +19,9 @@ on: - ".github/workflows/pages.yml" - "assets/social-preview.jpg" - "data/italian_locations.csv" + - "project.json" - "scripts/build_pages.py" + - "scripts/project_metadata.py" - "site/**" - "tests/pages_core.test.mjs" - "tests/test_pages.py" diff --git a/README.md b/README.md index 39b996d..0a5b03e 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@ coordinate, costruito con una pipeline **clean room**. **Structural quality:** `passed` · **Operational data readiness:** `experimental_non_official` -Il tag e la release `v2.0.0` non sono ancora pubblicati. Gli output presenti -su `main` sono il candidato di release. L'ultima release pubblicata resta -[`v1.1.0`](https://github.com/Codewriter90x/Italian_Cities/releases/tag/v1.1.0). +Il tag e la prerelease +[`v2.0.0`](https://github.com/Codewriter90x/Italian_Cities/releases/tag/v2.0.0) +sono pubblicati con asset immutabili e checksum SHA-256. ## Fonti canoniche @@ -36,7 +36,7 @@ Entrambe le fonti richiedono attribuzione CC BY 4.0. Il file GeoNames originale ma non contribuisce ad alcun CSV, JSON, XLSX, SQLite, SQL o dato della Pages. È letto soltanto per [`reports/legacy-comparison.json`](reports/legacy-comparison.json). -## Copertura del candidato v2 +## Copertura della v2 | Contenuto | Conteggio | Interpretazione | | --- | ---: | --- | @@ -76,7 +76,7 @@ Alla grana del luogo univoco: 7.498 comuni hanno coordinate | `data/italian_locations.xlsx` | stessa vista più foglio informativo | | `data/italian_locations.sqlite` | stessa vista e metadata | -Il bundle preparato aggiunge `italian_locations.sql` e `SHA256SUMS`. +Il bundle pubblicato aggiunge `italian_locations.sql` e `SHA256SUMS`. Tutti gli output sono generati: non modificarli manualmente. ## Stati principali @@ -93,17 +93,17 @@ Tutti gli output sono generati: non modificarli manualmente. Nessun candidato multiplo viene scelto automaticamente. Lo schema completo è in [`SCHEMA.md`](SCHEMA.md). -## Download del candidato +## Download della release v2.0.0 -Fino alla pubblicazione della release v2: - -- [municipalities.csv](https://github.com/Codewriter90x/Italian_Cities/raw/refs/heads/main/data/municipalities.csv) -- [localities.csv](https://github.com/Codewriter90x/Italian_Cities/raw/refs/heads/main/data/localities.csv) -- [postal_codes.csv](https://github.com/Codewriter90x/Italian_Cities/raw/refs/heads/main/data/postal_codes.csv) -- [italian_locations.csv](https://github.com/Codewriter90x/Italian_Cities/raw/refs/heads/main/data/italian_locations.csv) -- [italian_locations.json](https://github.com/Codewriter90x/Italian_Cities/raw/refs/heads/main/data/italian_locations.json) -- [italian_locations.xlsx](https://github.com/Codewriter90x/Italian_Cities/raw/refs/heads/main/data/italian_locations.xlsx) -- [italian_locations.sqlite](https://github.com/Codewriter90x/Italian_Cities/raw/refs/heads/main/data/italian_locations.sqlite) +- [municipalities.csv](https://github.com/Codewriter90x/Italian_Cities/releases/download/v2.0.0/municipalities.csv) +- [localities.csv](https://github.com/Codewriter90x/Italian_Cities/releases/download/v2.0.0/localities.csv) +- [postal_codes.csv](https://github.com/Codewriter90x/Italian_Cities/releases/download/v2.0.0/postal_codes.csv) +- [italian_locations.csv](https://github.com/Codewriter90x/Italian_Cities/releases/download/v2.0.0/italian_locations.csv) +- [italian_locations.json](https://github.com/Codewriter90x/Italian_Cities/releases/download/v2.0.0/italian_locations.json) +- [italian_locations.xlsx](https://github.com/Codewriter90x/Italian_Cities/releases/download/v2.0.0/italian_locations.xlsx) +- [italian_locations.sqlite](https://github.com/Codewriter90x/Italian_Cities/releases/download/v2.0.0/italian_locations.sqlite) +- [italian_locations.sql](https://github.com/Codewriter90x/Italian_Cities/releases/download/v2.0.0/italian_locations.sql) +- [SHA256SUMS](https://github.com/Codewriter90x/Italian_Cities/releases/download/v2.0.0/SHA256SUMS) ## Esempi @@ -113,8 +113,8 @@ Fino alla pubblicazione della release v2: using System.Net.Http; using System.Text.Json; -var url = "https://raw.githubusercontent.com/Codewriter90x/" + - "Italian_Cities/main/data/italian_locations.json"; +var url = "https://github.com/Codewriter90x/Italian_Cities/" + + "releases/download/v2.0.0/italian_locations.json"; using var http = new HttpClient(); await using var stream = await http.GetStreamAsync(url); using var document = await JsonDocument.ParseAsync(stream); diff --git a/scripts/build_pages.py b/scripts/build_pages.py index 8cb16d7..5f1efe0 100644 --- a/scripts/build_pages.py +++ b/scripts/build_pages.py @@ -6,10 +6,14 @@ import argparse import csv import hashlib +import html import json +import re import shutil +import unicodedata from pathlib import Path from typing import Any +from urllib.parse import quote_plus from project_metadata import ( BUILD_DATE, @@ -29,6 +33,12 @@ SOCIAL_PREVIEW = REPOSITORY_ROOT / "assets" / "social-preview.jpg" DEFAULT_OUTPUT = REPOSITORY_ROOT / "dist" / "pages" RELEASE_VERSION = DATASET_VERSION +SITE_ROOT = "https://codewriter90x.github.io/Italian_Cities/" +REPOSITORY_URL = "https://github.com/Codewriter90x/Italian_Cities" +RELEASE_URL = f"{REPOSITORY_URL}/releases/tag/{RELEASE_VERSION}" +RELEASE_DOWNLOAD_ROOT = ( + f"{REPOSITORY_URL}/releases/download/{RELEASE_VERSION}" +) WEB_FIELDS = ( "location_id", @@ -131,6 +141,582 @@ def calculate_stats(rows: list[dict[str, Any]]) -> dict[str, Any]: } +def format_integer(value: int) -> str: + return f"{value:,}".replace(",", ".") + + +def slugify(value: str) -> str: + normalized = unicodedata.normalize("NFKD", value) + ascii_value = normalized.encode("ascii", "ignore").decode("ascii") + return re.sub(r"[^a-z0-9]+", "-", ascii_value.casefold()).strip("-") + + +def release_asset_url(name: str) -> str: + return f"{RELEASE_DOWNLOAD_ROOT}/{name}" + + +def structured_data(stats: dict[str, Any]) -> str: + graph = { + "@context": "https://schema.org", + "@graph": [ + { + "@type": "WebSite", + "@id": f"{SITE_ROOT}#website", + "url": SITE_ROOT, + "name": "Italian Cities", + "description": ( + "Dataset open data di comuni italiani, CAP non ufficiali, " + "località e coordinate." + ), + "inLanguage": "it-IT", + "potentialAction": { + "@type": "SearchAction", + "target": { + "@type": "EntryPoint", + "urlTemplate": f"{SITE_ROOT}?q={{search_term_string}}#search", + }, + "query-input": "required name=search_term_string", + }, + }, + { + "@type": "Dataset", + "@id": f"{SITE_ROOT}#dataset", + "name": "Italian Cities", + "alternateName": "Dataset comuni italiani, CAP e coordinate", + "description": ( + f"Dataset open data {RELEASE_VERSION} con " + f"{format_integer(stats['municipalities'])} comuni ISTAT, " + f"{format_integer(stats['unique_postal_codes'])} CAP " + "GeoNames non ufficiali, località e coordinate." + ), + "url": SITE_ROOT, + "version": RELEASE_VERSION.removeprefix("v"), + "dateModified": BUILD_DATE, + "inLanguage": "it-IT", + "isAccessibleForFree": True, + "license": "https://creativecommons.org/licenses/by/4.0/", + "keywords": [ + "comuni italiani", + "CAP italiani", + "codici ISTAT", + "coordinate geografiche", + "open data Italia", + "CSV", + "GeoNames", + ], + "spatialCoverage": { + "@type": "Country", + "name": "Italia", + "identifier": "IT", + }, + "creator": { + "@type": "Person", + "name": "Codewriter90x", + "url": "https://github.com/Codewriter90x", + }, + "citation": [ + "https://www.istat.it/", + "https://www.geonames.org/", + ], + "distribution": [ + { + "@type": "DataDownload", + "encodingFormat": media_type, + "contentUrl": release_asset_url(filename), + } + for filename, media_type in ( + ("italian_locations.csv", "text/csv"), + ("italian_locations.json", "application/json"), + ( + "italian_locations.xlsx", + "application/vnd.openxmlformats-officedocument." + "spreadsheetml.sheet", + ), + ("italian_locations.sqlite", "application/vnd.sqlite3"), + ("italian_locations.sql", "application/sql"), + ) + ], + }, + { + "@type": "FAQPage", + "@id": f"{SITE_ROOT}#faq", + "mainEntity": [ + { + "@type": "Question", + "name": "I CAP del dataset sono ufficiali?", + "acceptedAnswer": { + "@type": "Answer", + "text": ( + "No. I CAP provengono dal dump GeoNames e non " + "da Poste Italiane; non sono dati postali " + "certificati." + ), + }, + }, + { + "@type": "Question", + "name": "Quali formati sono disponibili?", + "acceptedAnswer": { + "@type": "Answer", + "text": ( + "La release offre CSV, JSON, XLSX, SQLite e " + "SQL, generati dalla stessa vista canonica." + ), + }, + }, + { + "@type": "Question", + "name": "Quali fonti usa Italian Cities?", + "acceptedAnswer": { + "@type": "Answer", + "text": ( + "I comuni e i codici amministrativi provengono " + "da ISTAT; località, CAP e coordinate da " + "GeoNames. Entrambe le fonti sono attribuite " + "CC BY 4.0." + ), + }, + }, + ], + }, + ], + } + return json.dumps(graph, ensure_ascii=False, separators=(",", ":")) + + +def render_homepage(output: Path, stats: dict[str, Any]) -> None: + template_path = SOURCE_SITE / "index.html" + source = template_path.read_text(encoding="utf-8") + values = { + "BUILD_DATE": BUILD_DATE, + "DATASET_VERSION": RELEASE_VERSION, + "GEONAMES_REFERENCE_DATE": GEONAMES_REFERENCE_DATE, + "ISTAT_REFERENCE_DATE": ISTAT_REFERENCE_DATE, + "JSON_LD": structured_data(stats), + "MUNICIPALITIES": format_integer(stats["municipalities"]), + "POSTAL_CODE_RELATIONS": format_integer(stats["postal_code_relations"]), + "RELEASE_DOWNLOAD_ROOT": RELEASE_DOWNLOAD_ROOT, + "RELEASE_STATUS_LABEL": ( + "prerelease pubblicata" + if RELEASE_STATUS == "prerelease" + else "release stabile" + ), + "RELEASE_URL": RELEASE_URL, + "UNIQUE_POSTAL_CODES": format_integer(stats["unique_postal_codes"]), + "WITH_COORDINATES": format_integer(stats["with_coordinates"]), + } + for name, value in values.items(): + source = source.replace(f"{{{{{name}}}}}", str(value)) + unresolved = sorted(set(re.findall(r"\{\{[A-Z0-9_]+\}\}", source))) + if unresolved: + raise ValueError(f"Unresolved homepage template tokens: {unresolved}") + (output / "index.html").write_text(source, encoding="utf-8") + + +def page_document( + *, + title: str, + description: str, + canonical_path: str, + body: str, + page_type: str = "WebPage", + extra_structured_data: dict[str, Any] | None = None, +) -> str: + canonical = f"{SITE_ROOT}{canonical_path}" + graph: list[dict[str, Any]] = [ + { + "@type": page_type, + "@id": f"{canonical}#page", + "url": canonical, + "name": title, + "description": description, + "dateModified": BUILD_DATE, + "inLanguage": "it-IT", + "isPartOf": {"@id": f"{SITE_ROOT}#website"}, + } + ] + if extra_structured_data: + graph.append(extra_structured_data) + json_ld = json.dumps( + {"@context": "https://schema.org", "@graph": graph}, + ensure_ascii=False, + separators=(",", ":"), + ) + escaped_title = html.escape(title) + escaped_description = html.escape(description, quote=True) + return f""" + + + + + {escaped_title} — Italian Cities + + + + + + + + + + + + + + + + + + + + + + +
+ {body} +
+ + + +""" + + +def write_page(output: Path, relative_path: str, document: str) -> str: + target = output / relative_path / "index.html" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(document, encoding="utf-8") + return f"{relative_path}/" + + +def build_information_pages( + output: Path, + rows: list[dict[str, Any]], + stats: dict[str, Any], +) -> list[str]: + pages: list[str] = [] + version = html.escape(RELEASE_VERSION) + dataset_body = f""" + +

Open data geografici italiani

+

Dataset dei comuni italiani, CAP e coordinate

+

Italian Cities {version} raccoglie + {format_integer(stats['municipalities'])} comuni ISTAT, + {format_integer(stats['postal_code_relations'])} relazioni luogo–CAP e + {format_integer(stats['with_coordinates'])} luoghi con coordinate. + CAP e coordinate GeoNames non sono dati postali ufficiali.

+
+

Cosa contiene

Comuni, codici + ISTAT, province, regioni, località GeoNames, CAP a cinque cifre, + coordinate WGS84 e campi di provenienza.

+

Formati

CSV, JSON, XLSX, + SQLite e SQL sono prodotti dalla stessa pipeline deterministica.

+

Qualità dichiarata

I controlli + strutturali sono superati; l'idoneità operativa resta sperimentale e + non ufficiale.

+
+

Download della release {version}

+

Gli asset pubblicati sono immutabili e accompagnati da checksum + SHA-256.

+
+ CSV + JSON + XLSX + SQLite + SQL +
+
+

Documentazione

+ +
+ """ + pages.append( + write_page( + output, + "dataset", + page_document( + title="Dataset comuni italiani, CAP e coordinate", + description=( + "Scarica il dataset open data dei comuni italiani con " + "codici ISTAT, CAP GeoNames, località e coordinate in CSV, " + "JSON, XLSX, SQLite e SQL." + ), + canonical_path="dataset/", + body=dataset_body, + page_type="CollectionPage", + ), + ) + ) + + schema_body = f""" + +

Schema {SCHEMA_VERSION}

+

Schema del dataset Italian Cities

+

La v2 separa comuni, località e relazioni CAP. + Identificativi e CAP restano stringhe per preservare gli zeri iniziali.

+
+

municipalities.csv

Una riga + per comune ISTAT, con codice amministrativo, territorio, coordinate + opzionali e provenienza.

+

localities.csv

Località + GeoNames non riconciliate in modo univoco con un comune.

+

postal_codes.csv

Relazioni + molti-a-molti fra luogo e CAP; il CAP non è una chiave univoca.

+

italian_locations.*

Vista + unificata esportata in CSV, JSON, XLSX, SQLite e SQL.

+
+

Campi principali

+
+ + + + + + + + + + +
CampoSignificato
location_idIdentificativo stabile del comune o della località.
municipality_istat_codeCodice ISTAT a sei cifre, presente sui comuni.
postal_codeCAP GeoNames a cinque cifre oppure vuoto con stato missing.
province_codeSigla della provincia.
latitude, longitudeCoordinate WGS84 opzionali.
coordinate_verificationOrigine e livello di associazione della coordinata.
source_idsFonti canoniche che contribuiscono al record.
+

Consulta lo schema tecnico completo.

+
+ """ + pages.append( + write_page( + output, + "schema", + page_document( + title="Schema CSV, JSON, SQLite e SQL", + description=( + "Schema e significato delle colonne del dataset Italian " + "Cities: comuni ISTAT, località, CAP, coordinate e " + "provenienza." + ), + canonical_path="schema/", + body=schema_body, + page_type="TechArticle", + ), + ) + ) + + sources_body = f""" + +

Provenienza verificabile

+

Fonti, licenze e limiti del dataset

+

La build {version} usa fonti dichiarate e + riproducibili. Il legacy con provenienza irrisolta non contribuisce + agli output correnti.

+
+

ISTAT

Elenco dei comuni e + codici amministrativi, snapshot del {ISTAT_REFERENCE_DATE}, licenza + CC BY 4.0.

+

GeoNames

Località, CAP e + coordinate dal dump italiano del {GEONAMES_REFERENCE_DATE}, licenza + CC BY 4.0.

+
+ +

Attribuzione e riuso

+

Il riuso richiede l'attribuzione a ISTAT e GeoNames secondo CC BY + 4.0. Il repository documenta fonti, date, record sorgente e metodo di + riconciliazione per rendere verificabile ogni build.

+ +
+ """ + pages.append( + write_page( + output, + "fonti", + page_document( + title="Fonti e licenza del dataset", + description=( + "Provenienza e licenze del dataset Italian Cities: comuni " + "ISTAT e località, CAP e coordinate GeoNames sotto CC BY " + "4.0, con limiti dichiarati." + ), + canonical_path="fonti/", + body=sources_body, + ), + ) + ) + + unique_locations = {row["location_id"]: row for row in reversed(rows)} + region_rows: dict[str, list[dict[str, Any]]] = {} + for row in unique_locations.values(): + region_rows.setdefault(row["region_name"], []).append(row) + region_cards = [] + for region_name in sorted(region_rows): + region_slug = slugify(region_name) + values = region_rows[region_name] + municipality_count = sum( + item["location_kind"] == "municipality" for item in values + ) + region_cards.append( + f'
  • ' + f"{html.escape(region_name)}" + f"{format_integer(municipality_count)} comuni ISTAT" + "
  • " + ) + region_index_body = f""" + +

    Copertura territoriale

    +

    Comuni, CAP e coordinate per regione

    +

    Esplora la copertura delle 20 regioni italiane. + Ogni pagina elenca i comuni ISTAT presenti e riassume CAP e coordinate + disponibili nella release {version}.

    + + """ + pages.append( + write_page( + output, + "regioni", + page_document( + title="Comuni italiani per regione", + description=( + "Copertura regionale del dataset Italian Cities: comuni " + "ISTAT, CAP GeoNames e coordinate per tutte le 20 regioni " + "italiane." + ), + canonical_path="regioni/", + body=region_index_body, + page_type="CollectionPage", + ), + ) + ) + + for region_name in sorted(region_rows): + region_slug = slugify(region_name) + values = region_rows[region_name] + municipalities = sorted( + ( + row + for row in values + if row["location_kind"] == "municipality" + ), + key=lambda row: ( + row["province_name"], + row["name"], + row["municipality_istat_code"], + ), + ) + all_relations = [row for row in rows if row["region_name"] == region_name] + postal_codes = { + row["postal_code"] for row in all_relations if row["postal_code"] + } + with_coordinates = sum(row["latitude"] is not None for row in values) + province_groups: dict[tuple[str, str], list[dict[str, Any]]] = {} + for municipality in municipalities: + key = ( + municipality["province_name"], + municipality["province_code"], + ) + province_groups.setdefault(key, []).append(municipality) + province_sections = [] + for (province_name, province_code), items in sorted(province_groups.items()): + links = [] + for municipality in items: + query = quote_plus(municipality["name"]) + links.append( + f'
  • ' + f"{html.escape(municipality['name'])}" + f" ISTAT {html.escape(municipality['municipality_istat_code'])}
  • " + ) + province_sections.append( + f"

    " + f"{html.escape(province_name)} ({html.escape(province_code)})" + f"

    " + ) + region_body = f""" + +

    Copertura regionale · {version}

    +

    Comuni, CAP e coordinate: {html.escape(region_name)}

    +

    La release contiene + {format_integer(len(municipalities))} comuni ISTAT della + {html.escape(region_name)}, {format_integer(len(postal_codes))} CAP + GeoNames distinti e {format_integer(with_coordinates)} luoghi con + coordinate.

    +
    +
    {format_integer(len(municipalities))}comuni ISTAT
    +
    {format_integer(len(postal_codes))}CAP distinti
    +
    {format_integer(with_coordinates)}luoghi con coordinate
    +
    + +

    Elenco dei comuni

    +

    Seleziona un comune per aprirlo nella ricerca interattiva.

    + {''.join(province_sections)} +
    + """ + pages.append( + write_page( + output, + f"regioni/{region_slug}", + page_document( + title=f"Comuni, CAP e coordinate della {region_name}", + description=( + f"Elenco di {len(municipalities)} comuni ISTAT della " + f"{region_name}, con copertura CAP GeoNames e " + f"coordinate nella release {RELEASE_VERSION}." + ), + canonical_path=f"regioni/{region_slug}/", + body=region_body, + page_type="CollectionPage", + extra_structured_data={ + "@type": "AdministrativeArea", + "@id": f"{SITE_ROOT}regioni/{region_slug}/#region", + "name": region_name, + "containedInPlace": { + "@type": "Country", + "name": "Italia", + }, + }, + ), + ) + ) + return pages + + +def write_sitemap(output: Path, paths: list[str]) -> None: + urls = ["", *paths] + entries = "".join( + " \n" + f" {SITE_ROOT}{html.escape(path)}\n" + f" {BUILD_DATE}\n" + " \n" + for path in urls + ) + (output / "sitemap.xml").write_text( + '\n' + '\n' + f"{entries}\n", + encoding="utf-8", + ) + + def prepare_output(output: Path) -> None: output = output.resolve() forbidden = { @@ -179,7 +765,8 @@ def build_site(output: Path = DEFAULT_OUTPUT) -> dict[str, Any]: ), }, "stats": stats, - "rows": rows, + "fields": list(WEB_FIELDS), + "rows": [[row[field] for field in WEB_FIELDS] for row in rows], } assets = output / "assets" @@ -196,27 +783,22 @@ def build_site(output: Path = DEFAULT_OUTPUT) -> dict[str, Any]: encoding="utf-8", ) shutil.copy2(SOCIAL_PREVIEW, assets / "social-preview.jpg") + render_homepage(output, stats) + information_pages = build_information_pages(output, rows, stats) (output / ".nojekyll").write_text("", encoding="utf-8") (output / "robots.txt").write_text( "User-agent: *\nAllow: /\n" "Sitemap: https://codewriter90x.github.io/Italian_Cities/sitemap.xml\n", encoding="utf-8", ) - (output / "sitemap.xml").write_text( - '\n' - '\n' - " \n" - " https://codewriter90x.github.io/Italian_Cities/\n" - " \n" - "\n", - encoding="utf-8", - ) + write_sitemap(output, information_pages) manifest = { "release": RELEASE_VERSION, "canonical_rows": len(rows), "canonical_sha256": sha256(CANONICAL_DATA), "locations_json_sha256": sha256(locations_path), + "page_count": 1 + len(information_pages), "stats": stats, } (output / "build-manifest.json").write_text( diff --git a/site/assets/app.js b/site/assets/app.js index 25e8bf4..19dfb65 100644 --- a/site/assets/app.js +++ b/site/assets/app.js @@ -2,6 +2,7 @@ import { filtersFromSearchParams, filtersToSearchParams, formatInteger, + inflateRows, projectCoordinates, searchLocations, } from "./core.mjs"; @@ -335,12 +336,12 @@ async function loadDataset() { response.json(), boundaryResponse.json(), ]); - state.rows = payload.rows; + state.rows = inflateRows(payload.fields, payload.rows); state.stats = payload.stats; state.boundaries = boundaries; populateStats(payload.stats); - populateProvinces(payload.rows); - populateAccessibleMap(payload.rows); + populateProvinces(state.rows); + populateAccessibleMap(state.rows); applyFiltersFromUrl(); renderResults(); drawMap(); diff --git a/site/assets/core.mjs b/site/assets/core.mjs index a24e1db..9de02bf 100644 --- a/site/assets/core.mjs +++ b/site/assets/core.mjs @@ -3,6 +3,16 @@ const COLLATOR = new Intl.Collator("it", { numeric: true, }); +export function inflateRows(fields = [], rows = []) { + if (!rows.length || !Array.isArray(rows[0])) return rows; + if (!fields.length) { + throw new Error("Compact dataset rows require a field list."); + } + return rows.map((values) => + Object.fromEntries(fields.map((field, index) => [field, values[index]])), + ); +} + export function normalizeTerm(value = "") { return String(value) .normalize("NFD") diff --git a/site/assets/styles.css b/site/assets/styles.css index 381cfaf..626f169 100644 --- a/site/assets/styles.css +++ b/site/assets/styles.css @@ -65,8 +65,11 @@ select { .site-header, .trust-banner, .hero, +.seo-intro, .page-shell, .download-strip, +.faq-section, +.content-page, .site-footer { width: min(1180px, calc(100% - 2rem)); margin-inline: auto; @@ -162,6 +165,59 @@ h2 { font-size: 1.1rem; } +.seo-intro { + margin-bottom: 3rem; + padding: clamp(1.5rem, 4vw, 2.5rem); + background: rgba(11, 28, 42, 0.66); + border: 1px solid var(--line); + border-radius: 1rem; +} + +.seo-intro > p { + max-width: 72ch; + color: var(--muted); +} + +.feature-links, +.region-list, +.municipality-list, +.resource-list { + margin: 0; + padding: 0; + list-style: none; +} + +.feature-links { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.75rem; + margin-top: 1.5rem; +} + +.feature-links a, +.region-list a { + display: grid; + gap: 0.25rem; + height: 100%; + padding: 1rem; + color: var(--text); + background: var(--surface-raised); + border: 1px solid var(--line); + border-radius: 0.75rem; + text-decoration: none; +} + +.feature-links a:hover, +.region-list a:hover { + border-color: rgba(69, 215, 232, 0.48); +} + +.feature-links span, +.region-list span { + color: var(--muted); + font-size: 0.82rem; +} + .hero-actions, .download-actions { display: flex; @@ -494,6 +550,161 @@ a:focus-visible { color: var(--muted); } +.faq-section { + margin-bottom: 4rem; +} + +.faq-section details { + padding: 1rem 0; + border-top: 1px solid var(--line); +} + +.faq-section details:last-child { + border-bottom: 1px solid var(--line); +} + +.faq-section summary { + color: var(--text); + font-weight: 750; + cursor: pointer; +} + +.faq-section details p { + max-width: 70ch; + margin: 0.75rem 0 0; + color: var(--muted); +} + +.content-page { + padding-block: clamp(3rem, 7vw, 6rem); +} + +.content-page > h1 { + max-width: 18ch; +} + +.breadcrumbs { + margin-bottom: 2rem; + color: var(--muted); + font-size: 0.86rem; +} + +.content-lead { + max-width: 72ch; + margin-bottom: 2.5rem; + color: var(--muted); + font-size: 1.12rem; +} + +.content-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; + margin-bottom: 3rem; +} + +.content-card { + padding: 1.25rem; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 0.85rem; +} + +.content-card h2 { + font-size: 1.2rem; + letter-spacing: -0.01em; +} + +.content-card p, +.content-section > p, +.municipality-group > p { + color: var(--muted); +} + +.content-section { + max-width: 74rem; + margin-top: 3.5rem; +} + +.content-warning { + width: 100%; + margin-block: 2rem; +} + +.resource-list { + display: grid; + gap: 0.55rem; +} + +.resource-list li { + padding-bottom: 0.55rem; + border-bottom: 1px solid var(--line); +} + +.schema-table { + width: 100%; + border-collapse: collapse; +} + +.schema-table th, +.schema-table td { + padding: 0.75rem; + text-align: left; + border-bottom: 1px solid var(--line); +} + +.schema-table th { + color: var(--text); +} + +.schema-table td { + color: var(--muted); +} + +.region-list { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; +} + +.region-summary .content-card { + display: grid; + gap: 0.15rem; +} + +.region-summary strong { + color: var(--cyan); + font-size: 2rem; +} + +.region-summary span { + color: var(--muted); +} + +.municipality-group { + margin-top: 2.5rem; +} + +.municipality-group h2 { + font-size: 1.35rem; +} + +.municipality-list { + columns: 3 15rem; + column-gap: 2rem; +} + +.municipality-list li { + break-inside: avoid; + padding-block: 0.28rem; + border-bottom: 1px solid var(--line); +} + +.municipality-list small { + display: block; + color: var(--muted); +} + .site-footer { display: grid; grid-template-columns: 1fr auto; @@ -518,15 +729,30 @@ a:focus-visible { .map-panel { position: static; } + + .feature-links, + .content-grid, + .region-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } @media (max-width: 560px) { - .site-nav a:first-child { - display: none; + .site-header { + align-items: flex-start; + padding-block: 1rem; + } + + .site-nav { + gap: 0.65rem; + font-size: 0.82rem; } .search-grid, - .site-footer { + .site-footer, + .feature-links, + .content-grid, + .region-list { grid-template-columns: 1fr; } diff --git a/site/index.html b/site/index.html index 39418de..dfe7627 100644 --- a/site/index.html +++ b/site/index.html @@ -3,17 +3,24 @@ - Italian Cities — dataset clean-room ISTAT e GeoNames + Dataset comuni italiani, CAP e coordinate | Italian Cities + + - + + + + + + + + + + + @@ -38,7 +70,8 @@ Italian Cities @@ -50,50 +83,84 @@ garanzia; alcune coordinate sono stimate algoritmicamente. Non usare questi dati per certificare o indirizzare spedizioni. +
    -

    Clean-room dataset v2.0.0 · prerelease

    -

    Trova un luogo italiano.

    +

    + Clean-room dataset {{DATASET_VERSION}} · {{RELEASE_STATUS_LABEL}} +

    +

    Comuni italiani, CAP e coordinate.

    - Tutti i comuni provengono dallo snapshot ISTAT. CAP, località e - coordinate provengono dal dump gratuito GeoNames CC BY 4.0, con - stato di riconciliazione e accuracy conservati. + Cerca nei comuni ISTAT e nelle località GeoNames. Scarica un + dataset open data riproducibile con codici ISTAT, CAP a cinque + cifre, province, regioni e coordinate WGS84.

    - 18.811 + {{POSTAL_CODE_RELATIONS}} relazioni luogo–CAP
    - 7.894 + {{MUNICIPALITIES}} comuni ISTAT
    - 4.735 + {{UNIQUE_POSTAL_CODES}} CAP distinti
    - 17.768 + {{WITH_COORDINATES}} luoghi con coordinate GeoNames
    +
    +

    Open data Italia

    +

    + Un dataset geografico italiano documentato e riproducibile +

    +

    + Italian Cities unisce l’elenco dei comuni italiani e i codici + amministrativi ISTAT con relazioni CAP, località e coordinate + GeoNames. La provenienza rimane esplicita e ogni formato è generato + dalla stessa pipeline. +

    + +
    +
    -

    Dati riproducibili

    -

    Usali nel formato che preferisci.

    +

    Release {{DATASET_VERSION}} pubblicata

    +

    Scarica CSV, JSON, XLSX, SQLite o SQL.

    - CSV, JSON, XLSX, SQLite e SQL derivano dalla stessa vista canonica. - Gli asset della release includono checksum SHA-256. Il dataset non è - un elenco postale ufficiale. La release v2 è preparata ma non ancora - pubblicata; questi link scaricano gli output correnti da main. + Tutti i formati derivano dalla stessa vista canonica. Gli asset della + release sono accompagnati da checksum SHA-256. Il dataset non è un + elenco postale ufficiale.

    + +
    +

    Domande frequenti

    +

    Come usare correttamente Italian Cities

    +
    + I CAP del dataset sono ufficiali? +

    + No. I CAP provengono dal dump GeoNames e non da Poste Italiane; + non sono dati postali certificati. +

    +
    +
    + Quali formati sono disponibili? +

    + La release offre CSV, JSON, XLSX, SQLite e SQL, generati dalla + stessa vista canonica. +

    +
    +
    + Quali fonti usa Italian Cities? +

    + I comuni e i codici amministrativi provengono da ISTAT; località, + CAP e coordinate da GeoNames. Entrambe le fonti sono attribuite + CC BY 4.0. +

    +
    +