Use Cases

Manipulação de CAPTCHA para extração de dados GIS e mapeamento

Toda vez que um pipeline de coleta de dados geoespaciais esbarra num portal de prefeitura, cartório ou órgão estadual, a resposta quase sempre vem com um CAPTCHA de imagem no meio do caminho — normalmente um sistema legado que não é atualizado desde a implantação original. Isso trava consultas de limite de parcela, zoneamento, zona de inundação e avaliação de imóveis: a matéria-prima de análises imobiliárias, planejamento urbano e estudos ambientais.

Times de crédito imobiliário e fintechs no Brasil enfrentam o mesmo obstáculo ao checar situação de IPTU e matrícula em portais municipais antes de liberar um financiamento — cada prefeitura implementa seu próprio CAPTCHA, sem padrão de formato entre cidades. Este guia mostra como resolver esses desafios automaticamente com a API da CaptchaAI, com exemplos prontos em Python e JavaScript e os parâmetros certos para OCR de baixa qualidade.

Padrões de CAPTCHA nos portais GIS

Antes de escrever qualquer linha de código, vale mapear qual tipo de portal você está automatizando — o desafio muda conforme o sistema:

Tipo de portal Tipo de CAPTCHA Gatilho
Prefeitura ou cartório (GIS municipal) CAPTCHA de texto em imagem Consultas de busca de parcela
Portais geoespaciais estaduais CAPTCHA personalizado Solicitações de download de dados
Portais de dados do USGS reCAPTCHA v2 Acesso a dados em massa
Mapas de zoneamento municipal CAPTCHA de imagem Consultas repetidas de imóvel
Bancos de dados ambientais CAPTCHA matemático Geração de relatórios
Consulta de zona de inundação CAPTCHA de texto em imagem Consultas por endereço

Parâmetros de CAPTCHA para OCR em portais GIS

Cada portal costuma ter seu próprio gerador de imagem, com comprimento e conjunto de caracteres diferentes — inspecione uma amostra antes de fixar os parâmetros abaixo:

Parâmetro Valor Caso de uso
method base64 Imagem padrão de CAPTCHA
numeric 1 CAPTCHAs só com dígitos
min_len 4 Quando o tamanho do código é conhecido
max_len 6 Quando o tamanho do código é conhecido
language 0 Caracteres latinos, incluindo português
textinstructions Personalizado CAPTCHAs matemáticos ou com formato específico

Antes de rodar a extração em lote

Um lote mal planejado desperdiça chamadas de API e mascara mudanças no comportamento do portal. Antes de disparar milhares de requisições:

  • Confira a janela de visualização do mapa, o filtro de região e os controles de paginação antes de iniciar uma coleta grande.
  • Guarde a carga útil de coordenadas normalizada junto com a resposta bruta do portal, para que qualquer bug de extração continue depurável depois.
  • Pause o lote se a frequência de CAPTCHA aumentar de repente, em vez de deixar as retentativas mascararem uma mudança de comportamento do alvo — isso costuma indicar bloqueio por IP ou um novo mecanismo anti-bot.
  • Se os dados extraídos incluem nome de proprietário ou outro dado pessoal, trate a retenção e o descarte desses registros conforme a LGPD.

Extração de dados GIS em Python

A classe abaixo resolve o CAPTCHA sob demanda: consulta o portal, detecta se a resposta trouxe uma imagem de verificação, resolve via API de OCR da CaptchaAI e reenvia a requisição com o token e os campos ocultos do formulário.

import requests
import base64
import time
import re

class GISDataExtractor:
    def __init__(self, api_key):
        self.api_key = api_key
        self.session = requests.Session()
        self.session.headers.update({
            "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
        })

    def lookup_parcel(self, portal_url, parcel_id):
        """Look up parcel data by ID, solving CAPTCHAs as needed."""
        response = self.session.get(
            f"{portal_url}/parcel", params={"id": parcel_id}
        )

        if self._has_image_captcha(response.text):
            captcha_url = self._extract_captcha_url(response.text, portal_url)
            captcha_text = self._solve_captcha(captcha_url)

            # Re-submit with solved CAPTCHA
            response = self.session.post(f"{portal_url}/parcel", data={
                "id": parcel_id,
                "captcha": captcha_text,
                **self._extract_hidden_fields(response.text)
            })

        return self._parse_parcel_data(response.text)

    def search_by_address(self, portal_url, address):
        """Search GIS records by street address."""
        response = self.session.get(
            f"{portal_url}/search", params={"address": address}
        )

        if self._has_image_captcha(response.text):
            captcha_url = self._extract_captcha_url(response.text, portal_url)
            captcha_text = self._solve_captcha(captcha_url)

            response = self.session.post(f"{portal_url}/search", data={
                "address": address,
                "captcha": captcha_text,
                **self._extract_hidden_fields(response.text)
            })

        return self._parse_search_results(response.text)

    def bulk_extract(self, portal_url, parcel_ids, delay=3):
        """Extract data for multiple parcels with rate limiting."""
        results = {}

        for parcel_id in parcel_ids:
            try:
                results[parcel_id] = self.lookup_parcel(portal_url, parcel_id)
            except Exception as e:
                results[parcel_id] = {"error": str(e)}
            time.sleep(delay)

        return results

    def _has_image_captcha(self, html):
        return bool(re.search(
            r'captcha|verification.?image|security.?code',
            html, re.IGNORECASE
        ))

    def _extract_captcha_url(self, html, base_url):
        from bs4 import BeautifulSoup
        from urllib.parse import urljoin
        soup = BeautifulSoup(html, "html.parser")

        img = (
            soup.find("img", attrs={"src": lambda s: s and "captcha" in s.lower()}) or
            soup.find("img", {"id": re.compile(r"captcha", re.I)}) or
            soup.find("img", {"class": re.compile(r"captcha", re.I)})
        )

        if img and img.get("src"):
            return urljoin(base_url, img["src"])
        raise ValueError("CAPTCHA image not found")

    def _solve_captcha(self, captcha_url):
        """Download and solve image CAPTCHA."""
        img_response = self.session.get(captcha_url)
        img_base64 = base64.b64encode(img_response.content).decode("utf-8")

        resp = requests.post("https://ocr.captchaai.com/in.php", data={
            "key": self.api_key,
            "method": "base64",
            "body": img_base64,
            "json": 1
        })
        task_id = resp.json()["request"]

        for _ in range(30):
            time.sleep(3)
            result = requests.get("https://ocr.captchaai.com/res.php", params={
                "key": self.api_key,
                "action": "get",
                "id": task_id,
                "json": 1
            })
            data = result.json()
            if data["status"] == 1:
                return data["request"]

        raise TimeoutError("CAPTCHA solve timed out")

    def _extract_hidden_fields(self, html):
        from bs4 import BeautifulSoup
        soup = BeautifulSoup(html, "html.parser")
        fields = {}
        for inp in soup.select("input[type='hidden']"):
            name = inp.get("name")
            if name:
                fields[name] = inp.get("value", "")
        return fields

    def _parse_parcel_data(self, html):
        from bs4 import BeautifulSoup
        soup = BeautifulSoup(html, "html.parser")

        def text_or_none(node):
            return node.text.strip() if node and node.text else None

        return {
            "parcel_id": text_or_none(soup.select_one(".parcel-id, #parcelId")),
            "owner": text_or_none(soup.select_one(".owner, .owner-name")),
            "address": text_or_none(soup.select_one(".address, .situs")),
            "zoning": text_or_none(soup.select_one(".zoning, .zone-code")),
            "acreage": text_or_none(soup.select_one(".acreage, .area")),
            "assessed_value": text_or_none(soup.select_one(".assessed, .value")),
            "land_use": text_or_none(soup.select_one(".land-use, .use-code"))
        }

    def _parse_search_results(self, html):
        from bs4 import BeautifulSoup
        soup = BeautifulSoup(html, "html.parser")

        def text_or_none(node):
            return node.text.strip() if node and node.text else None

        results = []
        for row in soup.select(".result-row, tr.parcel"):
            results.append({
                "parcel_id": text_or_none(row.select_one(".parcel-id")),
                "address": text_or_none(row.select_one(".address")),
                "owner": text_or_none(row.select_one(".owner"))
            })
        return results


# Usage
extractor = GISDataExtractor("YOUR_API_KEY")

# Single parcel lookup
parcel = extractor.lookup_parcel(
    "https://gis.county.example.gov",
    "12-34-567-890"
)
print(f"Owner: {parcel['owner']}, Zoning: {parcel['zoning']}")

# Bulk extraction
parcels = extractor.bulk_extract(
    "https://gis.county.example.gov",
    ["12-34-567-890", "12-34-567-891", "12-34-567-892"]
)

O método bulk_extract adiciona um intervalo (delay) entre parcelas — ajuste esse valor conforme o limite de requisições do portal e evite disparar dezenas de chamadas por segundo.

Extração por coordenadas em JavaScript

Quando o portal aceita consulta por latitude/longitude em vez de ID de parcela, o fluxo é o mesmo: detectar o CAPTCHA, resolver via API da CaptchaAI e repetir a requisição original com o token retornado.

class GISExtractor {
  constructor(apiKey) {
    this.apiKey = apiKey;
  }

  async extractByCoordinates(portalUrl, lat, lng) {
    const url = `${portalUrl}/identify?lat=${lat}&lng=${lng}`;
    const response = await fetch(url);
    const html = await response.text();

    if (this.hasCaptcha(html)) {
      return this.solveAndExtract(portalUrl, html, { lat, lng });
    }

    return this.parseGISData(html);
  }

  async extractRegion(portalUrl, bounds, gridSize = 0.01) {
    const results = [];
    const { north, south, east, west } = bounds;

    for (let lat = south; lat <= north; lat += gridSize) {
      for (let lng = west; lng <= east; lng += gridSize) {
        try {
          const data = await this.extractByCoordinates(portalUrl, lat, lng);
          if (data.parcelId) results.push(data);
        } catch (error) {
          console.error(`Failed at ${lat},${lng}: ${error.message}`);
        }
        // Rate limit
        await new Promise(r => setTimeout(r, 2000));
      }
    }

    return results;
  }

  hasCaptcha(html) {
    return /captcha|verification.?image|security.?code/i.test(html);
  }

  async solveAndExtract(portalUrl, html, params) {
    const imgMatch = html.match(/src="([^"]*captcha[^"]*)"/i);
    if (!imgMatch) throw new Error('CAPTCHA image not found');

    const imgUrl = new URL(imgMatch[1], portalUrl).href;
    const imgResp = await fetch(imgUrl);
    const buffer = await imgResp.arrayBuffer();
    const base64 = Buffer.from(buffer).toString('base64');

    const submitResp = await fetch('https://ocr.captchaai.com/in.php', {
      method: 'POST',
      body: new URLSearchParams({
        key: this.apiKey,
        method: 'base64',
        body: base64,
        json: '1'
      })
    });
    const { request: taskId } = await submitResp.json();

    for (let i = 0; i < 30; i++) {
      await new Promise(r => setTimeout(r, 3000));
      const result = await fetch(
        `https://ocr.captchaai.com/res.php?key=${this.apiKey}&action=get&id=${taskId}&json=1`
      );
      const data = await result.json();
      if (data.status === 1) {
        const response = await fetch(portalUrl, {
          method: 'POST',
          body: new URLSearchParams({
            ...params,
            captcha: data.request
          })
        });
        return this.parseGISData(await response.text());
      }
    }
    throw new Error('CAPTCHA solve timed out');
  }

  parseGISData(html) {
    return {
      parcelId: html.match(/parcel.?id[^>]*>([^<]+)/i)?.[1]?.trim(),
      zoning: html.match(/zon(?:e|ing)[^>]*>([^<]+)/i)?.[1]?.trim(),
      acreage: html.match(/acreage|area[^>]*>([^<]+)/i)?.[1]?.trim(),
      landUse: html.match(/land.?use[^>]*>([^<]+)/i)?.[1]?.trim()
    };
  }
}

// Usage
const gis = new GISExtractor('YOUR_API_KEY');

// Single coordinate lookup
const data = await gis.extractByCoordinates(
  'https://gis.county.example.gov',
  34.0522, -118.2437
);

// Extract entire region
const region = await gis.extractRegion('https://gis.county.example.gov', {
  north: 34.10, south: 34.00, east: -118.20, west: -118.30
});

Solução de problemas comuns

Estes são os erros mais comuns ao automatizar portais GIS, com a causa provável e a correção:

Problema Causa Correção
Imagem do CAPTCHA carrega quebrada Cookie de sessão obrigatório Carregue a página de busca antes de baixar a imagem
Texto resolvido é rejeitado Sensibilidade a maiúsculas e minúsculas Adicione o parâmetro case_sensitive=1
Portal devolve um CAPTCHA diferente a cada tentativa CAPTCHA vinculado à sessão Baixe e resolva dentro da mesma sessão HTTP
Nenhum dado de parcela retorna após o CAPTCHA Campos ocultos do formulário ausentes Extraia todos os inputs ocultos antes de enviar

Perguntas frequentes

Preciso resolver o CAPTCHA de novo a cada requisição, mesmo na mesma sessão?

Depende do portal. A maioria dos sistemas GIS vincula o CAPTCHA à sessão HTTP: uma vez resolvido, o token continua válido enquanto o cookie de sessão for reaproveitado. Mantenha a mesma requests.Session() (ou instância equivalente) durante todo o lote para evitar boa parte das chamadas repetidas ao endpoint de OCR.

Por que sistemas GIS de prefeituras e órgãos públicos ainda usam CAPTCHA de imagem tão antigo?

Boa parte da infraestrutura GIS pública roda sobre plataformas legadas, licitadas anos atrás e com ciclos de atualização lentos. Trocar o mecanismo de CAPTCHA exige nova licitação ou contrato de manutenção — por isso o texto distorcido de OCR ainda predomina nesses portais, mesmo com sites comerciais já migrados para reCAPTCHA ou Turnstile.

É seguro automatizar consultas em portais GIS públicos em grande volume?

Tecnicamente sim, mas trate isso como qualquer coleta de dados: respeite os termos de uso do portal, mantenha um intervalo entre requisições e documente a finalidade da coleta. Se os dados extraídos incluem informação de proprietário ou contribuinte, considere as obrigações da LGPD para armazenamento e retenção — resolver o CAPTCHA não isenta a responsabilidade sobre o dado coletado depois.

Como faço quando o CAPTCHA do portal é apenas numérico ou tem tamanho fixo?

Envie numeric=1 quando o desafio usa só dígitos, e defina min_len/max_len sempre que você já souber o tamanho do código — isso reduz erro de OCR em imagens de baixa resolução, comuns em portais GIS mais antigos. Para CAPTCHAs matemáticos ou com instrução textual, use textinstructions para descrever o formato esperado.

Dá para baixar shapefile ou GeoJSON que estão atrás de um CAPTCHA?

Sim. Se o portal libera o arquivo espacial só depois da verificação, resolva o CAPTCHA normalmente com a CaptchaAI e siga o link de download retornado na mesma sessão — o arquivo em si não passa pela API, só o desafio que bloqueia o acesso a ele.

Comece agora

Extraia dados GIS sem interrupções: crie sua chave de API da CaptchaAI e resolva automaticamente os CAPTCHAs de portais públicos, do primeiro parcel ID ao último.


Leia também

Os comentários estão desativados para este artigo.