Skip to content

Commit fdd7e58

Browse files
aborrusoclaude
andcommitted
Release v2.1.10: progressive OpenData fallback
- Retry strategy with relaxed payload when initial request fails - Primary: date + year/number filters - Fallback: year/number only when results empty or download fails - Fixes issue #23: L. 118/2022 now downloads successfully - Progressive user feedback via stderr messages Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 36336d8 commit fdd7e58

4 files changed

Lines changed: 162 additions & 80 deletions

File tree

LOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@
22

33
Questo file documenta gli avanzamenti significativi e le decisioni chiave del progetto `normattiva_2_md`.
44

5+
## 2026-01-30
6+
7+
### Release v2.1.10: Progressive OpenData Fallback Retry
8+
9+
- Implementata strategia di retry progressiva per OpenData API
10+
- Primo tentativo: ricerca con filtri completi (data inizio/fine + numero + anno)
11+
- Fallback automatico: senza filtri data se primo tentativo fallisce o senza risultati
12+
- Risolve issue #23: documenti (es. L. 118/2022) che prima fallivano con errore HTML
13+
- Retry logic: gestisce sia errori di download che ZIP non valido
14+
- Messaggi progressivi: avviso utente quando passa al fallback senza data
15+
- Fix field API: da `dataInizioPubblicazione`/`dataFinePubblicazione` (corretti per OpenData)
16+
517
## 2026-01-29
618

719
### Fix messaggi di progresso fallback OpenData

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ def read_readme():
1919

2020
setup(
2121
name="normattiva2md",
22-
version="2.1.9",
22+
version="2.1.10",
2323
description="Convertitore da XML Akoma Ntoso a formato Markdown con download automatico delle leggi citate e cross-references inline (CLI: normattiva2md)",
2424
long_description=read_readme(),
2525
long_description_content_type="text/markdown",

src/normattiva2md/constants.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@
77
MAX_FILE_SIZE_MB = 50
88
MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
99
DEFAULT_TIMEOUT = 30
10-
VERSION = "2.1.9"
10+
VERSION = "2.1.10"

src/normattiva2md/normattiva_api.py

Lines changed: 148 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -477,102 +477,172 @@ def download_akoma_ntoso_via_opendata(url, output_path, session=None, quiet=Fals
477477
"modalita": "C",
478478
"tipoRicerca": "A",
479479
"parametriRicerca": {
480-
"dataInizioEmanazione": f"{export_meta['dataGU_human']}T00:00:00.000Z",
481-
"dataFineEmanazione": f"{export_meta['dataGU_human']}T23:59:59.999Z",
480+
"dataInizioPubblicazione": f"{export_meta['dataGU_human']}T00:00:00.000Z",
481+
"dataFinePubblicazione": f"{export_meta['dataGU_human']}T23:59:59.999Z",
482482
"numeroProvvedimento": int(numero),
483483
"annoProvvedimento": int(anno),
484484
},
485485
}
486486

487-
try:
488-
ricerca_response = session.post(
489-
nuova_ricerca_url,
490-
headers={**headers, "Content-Type": "application/json"},
491-
data=json.dumps(payload),
492-
timeout=DEFAULT_TIMEOUT,
493-
)
494-
ricerca_response.raise_for_status()
495-
except requests.RequestException as e:
496-
print(f"❌ Errore avvio ricerca OpenData: {e}", file=sys.stderr)
497-
return False, None, session
498-
499-
token = ricerca_response.text.strip().strip('"')
500-
if not token:
501-
print("❌ ERRORE: token ricerca OpenData non valido.", file=sys.stderr)
502-
return False, None, session
503-
504-
# Conferma ricerca (opzionale)
505-
conferma_url = f"{base_url}/api/v1/ricerca-asincrona/conferma-ricerca"
506-
try:
507-
session.put(
508-
conferma_url,
509-
headers={**headers, "Content-Type": "application/json"},
510-
data=json.dumps({"token": token}),
511-
timeout=DEFAULT_TIMEOUT,
512-
)
513-
except requests.RequestException:
514-
pass
515-
516-
status_url = f"{base_url}/api/v1/ricerca-asincrona/check-status/{token}"
517-
stato = None
518-
519-
if not quiet:
520-
print("⏳ Preparazione collezione OpenData", end="", file=sys.stderr, flush=True)
487+
relaxed_payload = {
488+
"formato": "AKN",
489+
"richiestaExport": "M",
490+
"modalita": "C",
491+
"tipoRicerca": "A",
492+
"parametriRicerca": {
493+
"numeroProvvedimento": int(numero),
494+
"annoProvvedimento": int(anno),
495+
},
496+
}
521497

522-
for _ in range(60):
498+
def _run_async_search(search_payload):
499+
try:
500+
ricerca_response = session.post(
501+
nuova_ricerca_url,
502+
headers={**headers, "Content-Type": "application/json"},
503+
data=json.dumps(search_payload),
504+
timeout=DEFAULT_TIMEOUT,
505+
)
506+
ricerca_response.raise_for_status()
507+
except requests.RequestException as e:
508+
print(f"❌ Errore avvio ricerca OpenData: {e}", file=sys.stderr)
509+
return None, None, None
510+
511+
token = ricerca_response.text.strip().strip('"')
512+
if not token:
513+
print("❌ ERRORE: token ricerca OpenData non valido.", file=sys.stderr)
514+
return None, None, None
515+
516+
# Conferma ricerca (opzionale)
517+
conferma_url = f"{base_url}/api/v1/ricerca-asincrona/conferma-ricerca"
523518
try:
524-
status_response = session.get(
525-
status_url, headers=headers, timeout=DEFAULT_TIMEOUT
519+
session.put(
520+
conferma_url,
521+
headers={**headers, "Content-Type": "application/json"},
522+
data=json.dumps({"token": token}),
523+
timeout=DEFAULT_TIMEOUT,
526524
)
527-
if status_response.status_code == 303:
528-
stato = 3
529-
break
530-
status_response.raise_for_status()
531-
status_data = status_response.json()
532-
stato = status_data.get("stato")
533-
if stato == 3:
534-
break
535525
except requests.RequestException:
536526
pass
537527

528+
status_url = f"{base_url}/api/v1/ricerca-asincrona/check-status/{token}"
529+
stato = None
530+
status_data = None
531+
532+
if not quiet:
533+
print(
534+
"⏳ Preparazione collezione OpenData",
535+
end="",
536+
file=sys.stderr,
537+
flush=True,
538+
)
539+
540+
for _ in range(60):
541+
try:
542+
status_response = session.get(
543+
status_url, headers=headers, timeout=DEFAULT_TIMEOUT
544+
)
545+
if status_response.status_code == 303:
546+
stato = 3
547+
break
548+
status_response.raise_for_status()
549+
status_data = status_response.json()
550+
stato = status_data.get("stato")
551+
if stato == 3:
552+
break
553+
except requests.RequestException:
554+
pass
555+
556+
if not quiet:
557+
print(".", end="", file=sys.stderr, flush=True)
558+
time.sleep(2)
559+
538560
if not quiet:
539-
print(".", end="", file=sys.stderr, flush=True)
540-
time.sleep(2)
561+
print() # New line after progress dots
541562

542-
if not quiet:
543-
print() # New line after progress dots
563+
return stato, status_data, token
544564

545-
if stato != 3:
546-
print("❌ ERRORE: ricerca OpenData non completata in tempo utile.", file=sys.stderr)
547-
return False, None, session
565+
payloads = [payload, relaxed_payload]
566+
download_url = None
567+
selected = None
548568

549-
download_url = (
550-
f"{base_url}/api/v1/collections/download/collection-asincrona/{token}"
551-
)
569+
for idx, current_payload in enumerate(payloads):
570+
stato, status_data, token = _run_async_search(current_payload)
571+
if stato != 3:
572+
if idx == 0:
573+
continue
574+
print(
575+
"❌ ERRORE: ricerca OpenData non completata in tempo utile.",
576+
file=sys.stderr,
577+
)
578+
return False, None, session
579+
580+
if status_data and status_data.get("totAtti") == 0:
581+
if idx == 0:
582+
if not quiet:
583+
print(
584+
"⚠️ Ricerca OpenData senza risultati, ritento senza filtro data...",
585+
file=sys.stderr,
586+
)
587+
continue
588+
print(
589+
"❌ ERRORE: ricerca OpenData non ha restituito risultati.",
590+
file=sys.stderr,
591+
)
592+
return False, None, session
552593

553-
try:
554-
download_response = session.get(
555-
download_url, headers=headers, timeout=DEFAULT_TIMEOUT
594+
download_url = (
595+
f"{base_url}/api/v1/collections/download/collection-asincrona/{token}"
556596
)
557-
download_response.raise_for_status()
558-
except requests.RequestException as e:
559-
print(f"❌ Errore download collezione OpenData: {e}", file=sys.stderr)
560-
return False, None, session
561597

562-
try:
563-
with zipfile.ZipFile(BytesIO(download_response.content)) as zf:
564-
target_date = _parse_yyyymmdd(export_meta.get("dataVigenza"))
565-
selected = _select_akoma_file_from_zip(zf, target_date)
566-
if not selected:
567-
print(
568-
"❌ ERRORE: nessun XML Akoma Ntoso trovato nel pacchetto OpenData.",
569-
file=sys.stderr,
570-
)
571-
return False, None, session
572-
with zf.open(selected) as src, open(output_path, "wb") as dst:
573-
dst.write(src.read())
574-
except zipfile.BadZipFile:
575-
print("❌ ERRORE: file ZIP OpenData non valido.", file=sys.stderr)
598+
try:
599+
download_response = session.get(
600+
download_url, headers=headers, timeout=DEFAULT_TIMEOUT
601+
)
602+
download_response.raise_for_status()
603+
except requests.RequestException as e:
604+
if idx == 0:
605+
if not quiet:
606+
print(
607+
"⚠️ Errore download collezione OpenData, ritento senza filtro data...",
608+
file=sys.stderr,
609+
)
610+
continue
611+
print(f"❌ Errore download collezione OpenData: {e}", file=sys.stderr)
612+
return False, None, session
613+
614+
try:
615+
with zipfile.ZipFile(BytesIO(download_response.content)) as zf:
616+
target_date = _parse_yyyymmdd(export_meta.get("dataVigenza"))
617+
selected = _select_akoma_file_from_zip(zf, target_date)
618+
if not selected:
619+
if idx == 0:
620+
if not quiet:
621+
print(
622+
"⚠️ ZIP OpenData senza XML AKN, ritento senza filtro data...",
623+
file=sys.stderr,
624+
)
625+
continue
626+
print(
627+
"❌ ERRORE: nessun XML Akoma Ntoso trovato nel pacchetto OpenData.",
628+
file=sys.stderr,
629+
)
630+
return False, None, session
631+
with zf.open(selected) as src, open(output_path, "wb") as dst:
632+
dst.write(src.read())
633+
break
634+
except zipfile.BadZipFile:
635+
if idx == 0:
636+
if not quiet:
637+
print(
638+
"⚠️ ZIP OpenData non valido, ritento senza filtro data...",
639+
file=sys.stderr,
640+
)
641+
continue
642+
print("❌ ERRORE: file ZIP OpenData non valido.", file=sys.stderr)
643+
return False, None, session
644+
645+
if not selected:
576646
return False, None, session
577647

578648
if not quiet:

0 commit comments

Comments
 (0)