Source: OpenCoesione · latest data update: 2026-06-30IT
Independent project, for educational purposes, using official data.

← Tutorial

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

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

  1. 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 in cache.
  2. Pagination: the loop continues until last_page, as explained in the tutorial.
  3. Sums: amounts are text with a comma, so euro() turns "51817680,65" into a number. Funded and paid are summed separately.
  4. The page: a text template with the figures and a table; html.escape protects the page from special characters in titles.

Next steps

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.