Recupero API con richieste. Sessione
Ho scritto uno script che analizza l'API nei tempi previsti (martedì-sabato), scaricando tutto per il giorno precedente.
import requests
import pandas as pd
from datetime import date, timedelta
# # This is what I'd normally use, but since there would be no data today,
# # I assign specific date myself
# DATE = (date.today() - timedelta(days=1)).strftime("%Y-%m-%d")
DATE = "2020-10-23"
URL = "https://spending.gov.ua/portal-api/v2/api/transactions/page/"
def fetch(session, params):
next_page, last_page = 0, 0
while next_page <= last_page:
params["page"] = next_page
data = session.get(URL, params=params).json()
yield pd.json_normalize(data.get("transactions"))\
.assign(page=params.get("page"))
next_page, last_page = next_page+1, data["count"] // data["pageSize"]
def fetch_all():
with requests.Session() as session:
params = {"page": 0, "pageSize": 100, "startdate": DATE, "enddate": DATE}
yield from fetch(session, params)
if __name__ == "__main__":
data = fetch_all()
pd.concat(data).to_csv(f"data/{DATE}.csv", index=False)
Qui mi chiedo un paio di cose.
In primo luogo , se sto usando requests.Sessioncorrettamente.
Ho letto nella documentazione che:
L'oggetto Session consente di rendere persistenti determinati parametri tra le richieste. ... Quindi, se stai effettuando più richieste allo stesso host, la connessione TCP sottostante verrà riutilizzata, il che può comportare un significativo aumento delle prestazioni.
Non sono sicuro che sia così qui perché non ho notato alcun cambiamento nelle prestazioni.
In secondo luogo , se dividere il codice in due funzioni invece di una fosse una buona idea.
Qui ho pensato che sarebbe stato più facile da mantenere: la funzione sottostante fetchnon cambia mentre fetch_allpotenzialmente potrebbe. Ad esempio, potrei inserire un intervallo di date invece di una singola data, cambiando fetch_allin:
def fetch_all(date_range):
with requests.Session() as session:
for date in date_range:
params = {"page": 0, "pageSize": 100, "startdate": date, "enddate": date}
yield from fetch(session, params)
Inoltre, yielde yield from- avrebbero potuto utilizzare .appende restituire invece un elenco. Non sono sicuro di quale approccio sia migliore.
Risposte
Qui mi chiedo un paio di cose.
In primo luogo , se sto usando
requests.Sessioncorrettamente.
Sì, sei tu. In una delle mie altre recensioni , l'utilizzo requests.Sessionallo stesso modo per l'iterazione su un'API impaginata ha quasi dimezzato il tempo di esecuzione totale.
Ho eseguito alcuni test rapidi scaricando le ultime 7 pagine (pagine 1625-1631) per "2020-10-23" e ha funzionato leggermente meglio rispetto a richieste con requests.get:
requests.get: 23,2 secondirequests.Session: 17,7 secondi
In secondo luogo , se dividere il codice in due funzioni invece di una fosse una buona idea.
Penso che vada bene dividerlo in due funzioni. Detto questo, ho alcuni commenti sulle responsabilità e l'interfaccia di fetche su come sfruttare al meglio i tuoi usi di yielde yield fromin basso.
Nel complesso il codice sembra pulito ed è di facile lettura. Ecco come penso che possa essere migliorato:
Penso che tutti i dettagli di basso livello su come inviare richieste all'API dovrebbero essere astratti dal chiamante di
fetch. Cioè,fetchla firma della funzione di dovrebbe essere simile a questa:def fetch( session: requests.Session, start_date: date, end_date: date, starting_page: int = 0, page_size: int = 100, ) -> Iterator[pd.DataFrame]: passQuindi ora creare un appropriato
paramssarebbefetchuna responsabilità, nonfetch_alluna. Si noti anche chestart_dateeend_datesono di tipodatetime.date, nostr. Allo stesso modo,fetch_allnon dovrebbe essere necessario preoccuparsi del formato di serializzazione della stringa di data accettato dall'API; questa èfetchuna responsabilità.All'interno
fetch, invece di mantenere le variabilinext_pageelast_pagesu ogni richiesta, penso che sarebbe meglio calcolare il numero totale di pagine (n) solo una volta con la prima richiesta (pagina k), quindi utilizzare un ciclo for per le pagine k + 1 .. n-1:def to_dataframe(json_data: Dict[str, Any], page: int) -> pd.DataFrame: return pd.json_normalize(json_data["transactions"]).assign(page=page) def fetch( session: requests.Session, start_date: date, end_date: date, starting_page: int = 0, page_size: int = 100, ) -> Iterator[pd.DataFrame]: params = { "startdate": start_date.isoformat(), "enddate": end_date.isoformat(), "page": starting_page, "pageSize": page_size, } data = session.get(URL, params=params).json() page_count = math.ceil(data["count"] / data["pageSize"]) last_page = page_count - 1 if starting_page > last_page: return print(f"{starting_page} / {last_page}") yield to_dataframe(data, starting_page) for page in range(starting_page + 1, page_count): params["page"] = page data = session.get(URL, params=params).json() print(f"{page} / {last_page}") yield to_dataframe(data, page)Il compromesso qui è che c'è una piccola duplicazione del codice perché la prima richiesta viene gestita in modo leggermente diverso, ma ora abbiamo delegato la responsabilità dell'iterazione del numero di pagina al ciclo for.
Vi consiglio di aggiungere un gancio evento al
sessionoggetto in modo che chiami sempreraise_for_status()sull'oggetto risposta. Ciò garantisce che tutte le richieste effettuate con la sessione vengano sollevaterequests.HTTPErrorse il server ci fornisce una risposta 4xx o 5xx e ci impedisce di convertire i.json()dati di una risposta di errore in un dataframe:session.hooks["response"].append( lambda r, *args, **kwargs: r.raise_for_status() )Attualmente il programma combina tutti i dataframe in memoria prima di esportarli in un file CSV. Per trarre vantaggio
fetch_alldall'essere unIterator[pd.DataFrame], penso che sarebbe meglio scrivere immediatamente ogni dataframe nel CSV, quindi non abbiamo bisogno di tenerlo in memoria più del necessario:output_path = Path(f"data/{DATE}.csv") output_path.unlink(missing_ok=True) data = fetch_all() for i, dataframe in enumerate(data): write_header = True if i == 0 else False dataframe.to_csv( output_path, header=write_header, index=False, mode="a" )
Versione refactoring:
#!/usr/bin/env python3
import math
from datetime import date, timedelta
from pathlib import Path
from typing import Any, Dict, Iterator
import pandas as pd # type: ignore
import requests
# # This is what I'd normally use, but since there would be no data today,
# # I assign specific date myself
# DATE = date.today() - timedelta(days=1)
DATE = date.fromisoformat("2020-10-23")
URL = "https://spending.gov.ua/portal-api/v2/api/transactions/page/"
def to_dataframe(json_data: Dict[str, Any], page: int) -> pd.DataFrame:
return pd.json_normalize(json_data["transactions"]).assign(page=page)
def fetch(
session: requests.Session,
start_date: date,
end_date: date,
starting_page: int = 0,
page_size: int = 100,
) -> Iterator[pd.DataFrame]:
params = {
"startdate": start_date.isoformat(),
"enddate": end_date.isoformat(),
"page": starting_page,
"pageSize": page_size,
}
data = session.get(URL, params=params).json()
page_count = math.ceil(data["count"] / data["pageSize"])
last_page = page_count - 1
if starting_page > last_page:
return
print(f"{starting_page} / {last_page}")
yield to_dataframe(data, starting_page)
for page in range(starting_page + 1, page_count):
params["page"] = page
data = session.get(URL, params=params).json()
print(f"{page} / {last_page}")
yield to_dataframe(data, page)
def fetch_all() -> Iterator[pd.DataFrame]:
with requests.Session() as session:
session.hooks["response"].append(
lambda r, *args, **kwargs: r.raise_for_status()
)
yield from fetch(session, start_date=DATE, end_date=DATE)
if __name__ == "__main__":
output_path = Path(f"data/{DATE}.csv")
output_path.unlink(missing_ok=True)
data = fetch_all()
for i, dataframe in enumerate(data):
write_header = True if i == 0 else False
dataframe.to_csv(
output_path, header=write_header, index=False, mode="a"
)