Carga Inicial - SBGR
This commit is contained in:
444
softwares/test/meteorologia_aeroportos/scraper_meteorologia.py
Normal file
444
softwares/test/meteorologia_aeroportos/scraper_meteorologia.py
Normal file
@@ -0,0 +1,444 @@
|
||||
"""
|
||||
Scraper de dados meteorológicos de superfície - ICEA/DECEA
|
||||
Site: https://pesquisa.icea.decea.mil.br/superficie_list/
|
||||
|
||||
Uso standalone:
|
||||
python scraper_meteorologia.py --aerodrome SBGR --all-years
|
||||
python scraper_meteorologia.py --aerodrome SBGR --start-year 2020 --end-year 2025
|
||||
python scraper_meteorologia.py --aerodrome SBSP --start-year 2023 --end-year 2023 --no-headless
|
||||
|
||||
Importável pelo pipeline:
|
||||
from scraper_meteorologia import make_driver, scrape_year, SITE_MIN_DATE
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import calendar
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from datetime import date
|
||||
from typing import Callable, Optional
|
||||
|
||||
import pandas as pd
|
||||
from bs4 import BeautifulSoup
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from webdriver_manager.chrome import ChromeDriverManager
|
||||
|
||||
BASE_URL = "https://pesquisa.icea.decea.mil.br/superficie_list/"
|
||||
SITE_MIN_DATE = date(1947, 12, 1)
|
||||
SITE_MAX_DATE = date.today() # dinâmico: nunca maior que hoje
|
||||
CHUNK_MONTHS = 3
|
||||
STOP_EMPTY_YEARS = 2
|
||||
|
||||
|
||||
def fetch_site_max_date(driver: webdriver.Chrome) -> date:
|
||||
"""
|
||||
Lê o atributo data-date-end-date do campo de período no site e atualiza
|
||||
SITE_MAX_DATE globalmente. Retorna date.today() como fallback.
|
||||
"""
|
||||
global SITE_MAX_DATE
|
||||
try:
|
||||
val = driver.execute_script(
|
||||
"var el = document.getElementById('dtPeriodo');"
|
||||
"return el ? el.getAttribute('data-date-end-date') : null;"
|
||||
)
|
||||
if val:
|
||||
d, m, y = val.strip().split("/")
|
||||
SITE_MAX_DATE = date(int(y), int(m), int(d))
|
||||
return SITE_MAX_DATE
|
||||
except Exception:
|
||||
pass
|
||||
return SITE_MAX_DATE
|
||||
|
||||
TABS = [
|
||||
("tab_cgt", "CGT"),
|
||||
("tab_nuvem", "Nuvem"),
|
||||
("tab_prec", "Precipitação"),
|
||||
("tab_pres", "Pressão"),
|
||||
("tab_rvr", "RVR"),
|
||||
("tab_temp", "Temperatura"),
|
||||
("tab_teto", "Teto"),
|
||||
("tab_vent", "Vento"),
|
||||
("tab_visib", "Visibilidade"),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WebDriver
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def make_driver(headless: bool = True) -> webdriver.Chrome:
|
||||
opts = webdriver.ChromeOptions()
|
||||
if headless:
|
||||
opts.add_argument("--headless=new")
|
||||
opts.add_argument("--no-sandbox")
|
||||
opts.add_argument("--disable-dev-shm-usage")
|
||||
opts.add_argument("--window-size=1920,1080")
|
||||
opts.add_argument("--disable-blink-features=AutomationControlled")
|
||||
opts.add_experimental_option("excludeSwitches", ["enable-automation"])
|
||||
service = Service(ChromeDriverManager().install())
|
||||
driver = webdriver.Chrome(service=service, options=opts)
|
||||
driver.set_page_load_timeout(300)
|
||||
driver.set_script_timeout(120)
|
||||
try:
|
||||
driver.command_executor.client_config.timeout = 300
|
||||
except Exception:
|
||||
pass
|
||||
return driver
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Formulário
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def select_aerodrome(driver: webdriver.Chrome, wait: WebDriverWait, code: str) -> str:
|
||||
"""Seleciona aeródromo no Bootstrap Select (JS-first, fallback visual)."""
|
||||
result = driver.execute_script(f"""
|
||||
var candidates = document.querySelectorAll(
|
||||
'select.selectpicker, select[name*="localidade"], select[name*="aerodrome"], select'
|
||||
);
|
||||
for (var s = 0; s < candidates.length; s++) {{
|
||||
var sel = candidates[s];
|
||||
for (var i = 0; i < sel.options.length; i++) {{
|
||||
if (sel.options[i].text.indexOf('{code}') !== -1) {{
|
||||
sel.selectedIndex = i;
|
||||
var txt = sel.options[i].text;
|
||||
if (typeof jQuery !== 'undefined') {{
|
||||
try {{ jQuery(sel).selectpicker('val', sel.options[i].value); }} catch(e) {{}}
|
||||
jQuery(sel).trigger('change');
|
||||
}} else {{
|
||||
sel.dispatchEvent(new Event('change', {{bubbles: true}}));
|
||||
}}
|
||||
return txt;
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
return null;
|
||||
""")
|
||||
if result:
|
||||
return result.strip()
|
||||
|
||||
btn = wait.until(EC.element_to_be_clickable(
|
||||
(By.CSS_SELECTOR, "div.bootstrap-select > button.dropdown-toggle")
|
||||
))
|
||||
btn.click()
|
||||
time.sleep(0.4)
|
||||
try:
|
||||
search = driver.find_element(By.CSS_SELECTOR, ".bootstrap-select .bs-searchbox input")
|
||||
search.clear()
|
||||
search.send_keys(code)
|
||||
time.sleep(0.5)
|
||||
except Exception:
|
||||
pass
|
||||
option_link = wait.until(EC.element_to_be_clickable((
|
||||
By.XPATH,
|
||||
f"//ul[contains(@class,'dropdown-menu inner')]"
|
||||
f"//li[not(contains(@class,'hidden')) and not(contains(@class,'d-none'))]"
|
||||
f"//span[contains(text(),'{code}')]/..",
|
||||
)))
|
||||
try:
|
||||
text = option_link.find_element(By.CSS_SELECTOR, "span.text").text.strip()
|
||||
except Exception:
|
||||
text = code
|
||||
option_link.click()
|
||||
time.sleep(0.3)
|
||||
return text
|
||||
|
||||
|
||||
def set_period(driver: webdriver.Chrome, start: date, end: date) -> None:
|
||||
s = start.strftime("%d/%m/%Y")
|
||||
e = end.strftime("%d/%m/%Y")
|
||||
driver.execute_script(f"""
|
||||
var el = document.getElementById('dtPeriodo');
|
||||
if (!el) return;
|
||||
if (typeof jQuery !== 'undefined' && jQuery(el).data('daterangepicker')) {{
|
||||
jQuery(el).data('daterangepicker').setStartDate('{s}');
|
||||
jQuery(el).data('daterangepicker').setEndDate('{e}');
|
||||
}}
|
||||
el.value = '{s} - {e}';
|
||||
el.dispatchEvent(new Event('change', {{bubbles: true}}));
|
||||
""")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extração de dados
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def show_all_rows(driver: webdriver.Chrome, tab_id: str) -> None:
|
||||
driver.execute_script(f"""
|
||||
try {{
|
||||
var dt = jQuery('#{tab_id} table').DataTable();
|
||||
dt.page.len(-1).draw();
|
||||
}} catch(e) {{
|
||||
var sel = document.querySelector('#{tab_id} select[name$="_length"]');
|
||||
if (sel) {{
|
||||
var best = 0;
|
||||
for (var i = 0; i < sel.options.length; i++) {{
|
||||
var v = parseInt(sel.options[i].value);
|
||||
if (v === -1 || v > parseInt(sel.options[best].value)) best = i;
|
||||
}}
|
||||
sel.selectedIndex = best;
|
||||
sel.dispatchEvent(new Event('change', {{bubbles: true}}));
|
||||
}}
|
||||
}}
|
||||
""")
|
||||
time.sleep(1.2)
|
||||
|
||||
|
||||
def parse_html_table(html: str) -> Optional[pd.DataFrame]:
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
table = soup.find("table")
|
||||
if not table:
|
||||
return None
|
||||
headers = []
|
||||
thead = table.find("thead")
|
||||
if thead:
|
||||
headers = [th.get_text(strip=True) for th in thead.find_all(["th", "td"])]
|
||||
rows = []
|
||||
tbody = table.find("tbody")
|
||||
if tbody:
|
||||
for tr in tbody.find_all("tr"):
|
||||
cells = [td.get_text(strip=True) for td in tr.find_all(["th", "td"])]
|
||||
if any(c.strip() for c in cells):
|
||||
rows.append(cells)
|
||||
if not rows:
|
||||
return None
|
||||
return pd.DataFrame(rows, columns=headers if headers else None)
|
||||
|
||||
|
||||
def get_full_table(driver: webdriver.Chrome, tab_id: str) -> Optional[pd.DataFrame]:
|
||||
frames: list[pd.DataFrame] = []
|
||||
seen: set[str] = set()
|
||||
while True:
|
||||
tab_elem = driver.find_element(By.ID, tab_id)
|
||||
html = tab_elem.get_attribute("innerHTML")
|
||||
fp = html[:800]
|
||||
if fp in seen:
|
||||
break
|
||||
seen.add(fp)
|
||||
df = parse_html_table(html)
|
||||
if df is not None:
|
||||
frames.append(df)
|
||||
try:
|
||||
next_btn = driver.find_element(
|
||||
By.CSS_SELECTOR, f"#{tab_id} .dataTables_paginate .next"
|
||||
)
|
||||
if "disabled" in (next_btn.get_attribute("class") or ""):
|
||||
break
|
||||
driver.execute_script("arguments[0].click();", next_btn)
|
||||
time.sleep(0.5)
|
||||
except Exception:
|
||||
break
|
||||
if not frames:
|
||||
return None
|
||||
if len(frames) == 1:
|
||||
return frames[0]
|
||||
return pd.concat(frames, ignore_index=True).drop_duplicates()
|
||||
|
||||
|
||||
def extract_tab(driver: webdriver.Chrome, wait: WebDriverWait, tab_id: str) -> Optional[pd.DataFrame]:
|
||||
link = driver.find_element(By.CSS_SELECTOR, f"a[href='#{tab_id}']")
|
||||
driver.execute_script("arguments[0].click();", link)
|
||||
try:
|
||||
wait.until(lambda d: "active" in (
|
||||
d.find_element(By.ID, tab_id).get_attribute("class") or ""
|
||||
))
|
||||
except Exception:
|
||||
time.sleep(1.5)
|
||||
show_all_rows(driver, tab_id)
|
||||
return get_full_table(driver, tab_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Saída
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def make_filename(tab_name: str, localidade: str, start: date, end: date) -> str:
|
||||
safe = re.sub(r'[<>:"/\\|?*\r\n]', '', localidade).strip()
|
||||
s = start.strftime("%d%m%Y")
|
||||
e = end.strftime("%d%m%Y")
|
||||
return f"Dados de Superfície {tab_name} - Localidade {safe} - Período {s} - {e}.csv"
|
||||
|
||||
|
||||
def save_csv(df: pd.DataFrame, path: str) -> None:
|
||||
df.to_csv(path, index=False, encoding="utf-8-sig", quoting=csv.QUOTE_ALL)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lógica principal de scraping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _quarter_chunks(year: int) -> list[tuple[date, date]]:
|
||||
"""Divide o ano em chunks de CHUNK_MONTHS meses (do mais recente para o mais antigo)."""
|
||||
chunks = []
|
||||
month = 1
|
||||
while month <= 12:
|
||||
end_month = min(month + CHUNK_MONTHS - 1, 12)
|
||||
last_day = calendar.monthrange(year, end_month)[1]
|
||||
chunks.append((date(year, month, 1), date(year, end_month, last_day)))
|
||||
month = end_month + 1
|
||||
return list(reversed(chunks)) # Q4 → Q3 → Q2 → Q1
|
||||
|
||||
|
||||
def scrape_period(
|
||||
driver: webdriver.Chrome,
|
||||
wait: WebDriverWait,
|
||||
aerodrome: str,
|
||||
start: date,
|
||||
end: date,
|
||||
output_dir: str,
|
||||
log: Callable[[str], None] = print,
|
||||
) -> bool:
|
||||
"""
|
||||
Faz o scraping de um único período e salva os CSVs das 9 abas.
|
||||
Retorna True se pelo menos uma aba teve dados, False se tudo veio vazio.
|
||||
"""
|
||||
log(f" [{start.strftime('%d/%m/%Y')} -> {end.strftime('%d/%m/%Y')}]")
|
||||
|
||||
driver.get(BASE_URL)
|
||||
wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, "div.bootstrap-select")))
|
||||
fetch_site_max_date(driver) # atualiza SITE_MAX_DATE com o valor real do site
|
||||
time.sleep(1.5)
|
||||
|
||||
localidade = select_aerodrome(driver, wait, aerodrome)
|
||||
if not localidade:
|
||||
log(f" ERR Aerodrome '{aerodrome}' nao encontrado.")
|
||||
return False
|
||||
log(f" Localidade: {localidade}")
|
||||
|
||||
set_period(driver, start, end)
|
||||
submit = wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, "button[type='submit']")))
|
||||
submit.click()
|
||||
wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, ".nav-tabs")))
|
||||
time.sleep(3)
|
||||
|
||||
found_any = False
|
||||
for tab_id, tab_name in TABS:
|
||||
try:
|
||||
df = extract_tab(driver, wait, tab_id)
|
||||
if df is not None and not df.empty:
|
||||
found_any = True
|
||||
fname = make_filename(tab_name, localidade, start, end)
|
||||
fpath = os.path.join(output_dir, fname)
|
||||
save_csv(df, fpath)
|
||||
log(f" OK {tab_name:<15s} {len(df):>6} linhas -> {fname}")
|
||||
else:
|
||||
log(f" -- {tab_name:<15s} sem dados")
|
||||
except Exception as exc:
|
||||
log(f" ERR {tab_name:<15s} {exc}")
|
||||
|
||||
return found_any
|
||||
|
||||
|
||||
def scrape_year(
|
||||
driver: webdriver.Chrome,
|
||||
wait: WebDriverWait,
|
||||
aerodrome: str,
|
||||
year: int,
|
||||
output_dir: str,
|
||||
stop_before: Optional[date] = None,
|
||||
log: Callable[[str], None] = print,
|
||||
) -> int:
|
||||
"""
|
||||
Itera os chunks trimestrais do ano (do mais recente para o mais antigo).
|
||||
Para se o trimestre for anterior a stop_before (data já coberta).
|
||||
Retorna o número de trimestres que tiveram dados.
|
||||
"""
|
||||
non_empty = 0
|
||||
for chunk_start, chunk_end in _quarter_chunks(year):
|
||||
start = max(chunk_start, SITE_MIN_DATE)
|
||||
end = min(chunk_end, SITE_MAX_DATE)
|
||||
if start > end:
|
||||
continue
|
||||
# Para quando atingir dados já cobertos pelo arquivo existente
|
||||
if stop_before is not None and end <= stop_before:
|
||||
log(f" [trimestre {start.strftime('%d/%m/%Y')} -> {end.strftime('%d/%m/%Y')} já coberto, parando]")
|
||||
break
|
||||
had_data = scrape_period(driver, wait, aerodrome, start, end, output_dir, log)
|
||||
if had_data:
|
||||
non_empty += 1
|
||||
return non_empty
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point (CLI standalone)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _fix_stdout() -> None:
|
||||
"""Garante UTF-8 no terminal Windows ao rodar como script."""
|
||||
try:
|
||||
if hasattr(sys.stdout, "buffer"):
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
||||
if hasattr(sys.stderr, "buffer"):
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> None:
|
||||
_fix_stdout()
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Scraper de meteorologia de superfície — ICEA/DECEA",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
exemplos:
|
||||
python scraper_meteorologia.py --aerodrome SBGR --all-years
|
||||
python scraper_meteorologia.py --aerodrome SBGR --start-year 2020 --end-year 2025
|
||||
python scraper_meteorologia.py --aerodrome SBSP --start-year 2023 --end-year 2023 --no-headless
|
||||
""",
|
||||
)
|
||||
parser.add_argument("--aerodrome", default="SBGR", metavar="ICAO",
|
||||
help="Código ICAO do aeródromo (padrão: SBGR)")
|
||||
parser.add_argument("--all-years", action="store_true",
|
||||
help="Coleta todos os anos disponíveis (para automaticamente sem dados)")
|
||||
parser.add_argument("--start-year", type=int, metavar="ANO",
|
||||
help="Ano inicial (obrigatório sem --all-years)")
|
||||
parser.add_argument("--end-year", type=int, metavar="ANO",
|
||||
help="Ano final (obrigatório sem --all-years)")
|
||||
parser.add_argument("--output-dir", default="dados", metavar="DIR",
|
||||
help="Diretório de saída (padrão: dados/)")
|
||||
parser.add_argument("--no-headless", action="store_true",
|
||||
help="Abre janela do Chrome visível (útil para debug)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.all_years and (args.start_year is None or args.end_year is None):
|
||||
parser.error("Use --all-years ou informe --start-year e --end-year")
|
||||
|
||||
end_year = args.end_year if not args.all_years else date.today().year
|
||||
start_year = args.start_year if not args.all_years else SITE_MIN_DATE.year
|
||||
|
||||
os.makedirs(args.output_dir, exist_ok=True)
|
||||
driver = make_driver(headless=not args.no_headless)
|
||||
wait = WebDriverWait(driver, 60)
|
||||
|
||||
try:
|
||||
consecutive_empty = 0
|
||||
for year in range(end_year, start_year - 1, -1): # do mais recente para o mais antigo
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"Ano {year} — {args.aerodrome}")
|
||||
print(f"{'=' * 60}")
|
||||
non_empty = scrape_year(driver, wait, args.aerodrome, year, args.output_dir)
|
||||
if non_empty == 0:
|
||||
consecutive_empty += 1
|
||||
print(f" [sem dados] ({consecutive_empty}/{STOP_EMPTY_YEARS} consecutivos)")
|
||||
if args.all_years and consecutive_empty >= STOP_EMPTY_YEARS:
|
||||
print(f"\n{STOP_EMPTY_YEARS} anos consecutivos sem dados — parando busca.")
|
||||
break
|
||||
else:
|
||||
consecutive_empty = 0
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrompido pelo usuário.")
|
||||
finally:
|
||||
driver.quit()
|
||||
print("\nFinalizado.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user