""" 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 python scraper_meteorologia.py --fetch-catalog # grava catálogo de aeródromos no banco Importável pelo pipeline: from scraper_meteorologia import make_driver, scrape_year, fetch_aerodrome_catalog, 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 fetch_aerodrome_catalog(headless: bool = True) -> list[dict]: """ Acessa o site ICEA e extrai a lista completa de aeródromos disponíveis. Retorna lista de dicts com chaves 'icao', 'nome', 'uf'. """ driver = make_driver(headless=headless) try: driver.get(BASE_URL) WebDriverWait(driver, 30).until( EC.presence_of_element_located( (By.CSS_SELECTOR, "select.selectpicker, select") ) ) time.sleep(1.5) # aguarda Bootstrap Select renderizar todas as opções raw_options = driver.execute_script(""" var candidates = document.querySelectorAll( 'select.selectpicker, select[name*="localidade"], select[name*="aerodrome"], select' ); var result = []; for (var s = 0; s < candidates.length; s++) { var sel = candidates[s]; if (sel.options.length > 5) { for (var i = 0; i < sel.options.length; i++) { var txt = sel.options[i].text.trim(); if (txt) result.push(txt); } break; } } return result; """) finally: driver.quit() catalog = [] for text in (raw_options or []): if not text or len(text) < 4: continue icao = text[:4].upper() if not re.match(r"[A-Z]{4}", icao): continue # formato típico: "SBGR - GUARULHOS / CUMBICA (SP)" nome = text[4:].lstrip(" -–—").strip() uf_match = re.search(r"\(([A-Z]{2})\)\s*$", nome) uf = uf_match.group(1) if uf_match else "" if uf_match: nome = nome[: uf_match.start()].strip().rstrip("(),- ") catalog.append({"icao": icao, "nome": nome, "uf": uf}) return catalog 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)") parser.add_argument("--fetch-catalog", action="store_true", help="Baixa lista de aeródromos do site ICEA e grava no banco SQLite") parser.add_argument("--db-path", default="dados/met.db", metavar="PATH", help="Caminho do banco SQLite (usado com --fetch-catalog)") args = parser.parse_args() # ── fetch-catalog: ação independente ────────────────────────────────────── if args.fetch_catalog: from pathlib import Path import db as _db print("Acessando site ICEA para obter lista de aeródromos…") catalog = fetch_aerodrome_catalog(headless=not args.no_headless) print(f" {len(catalog)} aeródromos encontrados.") db_path = Path(args.db_path) db_path.parent.mkdir(parents=True, exist_ok=True) conn = _db.get_connection(db_path) _db.ensure_schema(conn) n = _db.upsert_aerodromes(conn, catalog) conn.close() print(f" {n} registros gravados em '{db_path}'.") return 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()