Build a basic version of this website, step by step
In about fifteen minutes you build a small web page that downloads the projects of a municipality from OpenCoesione, adds up funded and paid amounts and lists the largest ongoing projects. It is a rough, working version of this site, not a copy of it.
Project titles are in Italian, as in the source. We really ran every step below from an empty folder; the result is declared at the end.
What you will get
One page, site/index.html, with: total funded and total paid for all projects of the municipality (shown separately), the 15 largest ongoing projects, the source with its licence and the download date.
What you need
- Python 3 (version 3.8 or newer; we used 3.12). Check with
python3 --version(on Windows:python --version). If it is missing, install it from python.org. - A terminal (Terminal on Mac and Linux, PowerShell on Windows) and a web browser.
- An internet connection. Nothing else to install: the program uses only Python's standard library.
Step 1. Create an empty folder
mkdir mini-site
cd mini-site
Step 2. Save the program
Create a file called build.py in that folder and paste the code below, or download it and rename it. Read it first: the comments explain each part.
"""Mini website of a municipality's public projects, from OpenCoesione data. Python standard library only."""
import html, json, os, time, urllib.error, urllib.request
from datetime import date
COMUNE = "arezzo-comune" # change this value for another municipality
URL = "https://opencoesione.gov.it/it/api/progetti/?format=json&territorio=" + COMUNE + "&page_size=500&page="
os.makedirs("cache", exist_ok=True)
def download(page):
"""Download one page of results; if you already have it, read it back from disk."""
name = f"cache/page-{page}.json"
if os.path.exists(name):
return json.load(open(name, encoding="utf-8"))
time.sleep(1) # courtesy pause: above about one request per second the service answers 429
request = urllib.request.Request(URL + str(page), headers={"User-Agent": "mini-site-tutorial/1.0"})
for attempt in range(1, 6):
try:
with urllib.request.urlopen(request, timeout=90) as response:
data = json.load(response)
break
except urllib.error.HTTPError as error:
if error.code != 429 or attempt == 5:
raise
time.sleep(20 * attempt) # too many requests: wait longer and try again
json.dump(data, open(name, "w", encoding="utf-8"), ensure_ascii=False)
return data
projects, page = [], 1
while True:
data = download(page)
projects += data["results"]
if page >= data["last_page"]:
break
page += 1
print(f"Downloaded {page} pages, {len(projects)} projects")
euro = lambda text: float((text or "0").replace(",", ".")) # "51817680,65" -> 51817680.65
fmt = lambda x: f"{round(x):,}" + " EUR"
funded = sum(euro(p["oc_finanz_tot_pub_netto"]) for p in projects)
paid = sum(euro(p["tot_pagamenti"]) for p in projects)
ongoing = sorted((p for p in projects if p["oc_stato_progetto"] == "In corso"), key=lambda p: -euro(p["oc_finanz_tot_pub_netto"]))
print("Funded:", fmt(funded), "- Already paid:", fmt(paid), "- Ongoing:", len(ongoing))
rows = "".join(
f"<tr><td>{html.escape(p['oc_titolo_progetto'])}</td><td>{fmt(euro(p['oc_finanz_tot_pub_netto']))}</td><td>{fmt(euro(p['tot_pagamenti']))}</td></tr>"
for p in ongoing[:15])
page_html = f"""<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Public projects in Arezzo</title>
<style>body{{font:18px/1.5 system-ui,sans-serif;max-width:900px;margin:0 auto;padding:16px;color:#1b2330}}
table{{border-collapse:collapse;width:100%}}td,th{{border-bottom:1px solid #d9e0ea;padding:6px;text-align:left}}.n{{font-size:1.6rem;font-weight:700}}</style></head><body>
<p>Source: <a href="https://opencoesione.gov.it/">OpenCoesione</a> (licence CC BY 4.0). Data downloaded on {date.today().isoformat()}. Project titles are in Italian, as in the source.</p>
<h1>Public projects in Arezzo</h1>
<p><span class="n">{fmt(funded)}</span> funded and <span class="n">{fmt(paid)}</span> already paid, across {len(projects)} projects.</p>
<h2>The 15 largest ongoing projects</h2>
<div style="overflow-x:auto"><table><tr><th>Title (Italian)</th><th>Funded</th><th>Already paid</th></tr>{rows}</table></div>
</body></html>"""
os.makedirs("site", exist_ok=True)
open("site/index.html", "w", encoding="utf-8").write(page_html)
print("Created site/index.html")
Step 3. Run it
python3 build.py
The program downloads 26 pages of 500 projects, waiting a second between requests, and it keeps every page in the cache folder: if it stops, run it again and it resumes. It takes about three minutes. This is what we obtained:
Downloaded 26 pages, 12590 projects
Funded: 357,478,579 EUR - Already paid: 291,577,787 EUR - Ongoing: 473
Created site/index.html
Step 4. Look at the result
python3 -m http.server 8000 --directory site
Open http://localhost:8000 in the browser. To stop the server press Ctrl+C. The two big figures are the same ones you can read on the home page for all 12,590 projects of Arezzo (the home page counts a narrower perimeter, explained in Method and sources).
How it works, in four parts
- Download with a pause and a memory:
download()asks one page at a time, waits a second, waits longer and retries if the service answers 429 («too many requests»), and saves each answer incache. - Pagination: the loop continues until
last_page, as explained in the tutorial. - Sums: amounts are text with a comma, so
euro()turns"51817680,65"into a number. Funded and paid are summed separately. - The page: a text template with the figures and a table;
html.escapeprotects the page from special characters in titles.
Next steps
- Change
COMUNEto another municipality: the codes are listed in the API's territories section. - Add a column with the status, or a link to each project's official page (
oc_linkfield). - Publish: the
sitefolder is a normal static website; copy it to any static hosting. - Look at how this full site is built: the code is in the project repository and the pages are generated by a script.
Outcome of our test
Run on 2 November 2026 from an empty folder, with Python 3.12.3 on Linux, following exactly the steps above. The first attempt failed: the service answered 429 partway through the download, because the one-second pause was not enough. We added the wait-and-retry you see in the code, repeated the test from an empty folder and it completed in 3 minutes 32 seconds (English version: 2 minutes 32 seconds). The totals match the full list of the Arezzo projects used by this site. After the test we wrapped the table in a scrolling container because on a phone (390 px) it ran off the screen; we rechecked it by re-running the program with the already downloaded cache. Not tested: Windows and macOS, other Python versions, other municipalities.