Casos de Uso

Manejo de CAPTCHA para SIG y extracción de datos cartográficos

¿Tu script consulta parcelas o descarga capas geoespaciales y el portal lo frena con un CAPTCHA de imagen? La ruta fiable es corta: descarga la imagen, resuélvela con la API OCR de CaptchaAI y reenvía el formulario en la misma sesión. Casi nunca es un anti-bot moderno, sino un CAPTCHA de texto heredado, resuelto como OCR.

Detrás hay datos valiosos: parcelas, zonificación, zonas de inundación y valores catastrales para el análisis inmobiliario y urbano.

Dónde aparece el CAPTCHA en los portales SIG

Cada portal usa un desafío distinto y lo dispara una acción concreta:

Tipo de portal tipo CAPTCHA gatillo
Condado GIS/assessor CAPTCHA del texto de la imagen Consultas de búsqueda de paquetes
Portales geoespaciales estatales CAPTCHA personalizado Solicitudes de descarga de datos
Portales de datos del USGS reCAPTCHA v2 Acceso masivo a datos
Mapas de zonificación municipal CAPTCHA de imagen Búsquedas repetidas de propiedades
Bases de datos ambientales CAPTCHA de Matemáticas Generación de informes
Búsqueda de zonas de inundación CAPTCHA del texto de la imagen Consultas de direcciones

En el ámbito hispanohablante el patrón se repite: la Sede Electrónica del Catastro en España o los visores territoriales de México y Argentina esconden la parcela tras el mismo CAPTCHA heredado. El código vale para todos: solo cambian la URL y los selectores. Respeta los términos de servicio y la protección de datos.

Parámetros OCR para portales cartográficos

Declarar el formato en in.php evita rechazos:

Parámetro Valor Caso de uso
method base64 CAPTCHA de imagen estándar
numeric 1 CAPTCHA solo numéricos
min_len 4 Cuando se conoce el número de caracteres
max_len 6 Cuando se conoce el número de caracteres
language 0 Caracteres /Latin
textinstructions personalizado CAPTCHA matemáticos o códigos formateados

Antes de lanzar búsquedas de parcelas en lote

Lo que funciona con una parcela suele romperse con cientos:

Control Qué revisar Por qué importa
Reutilización de sesión Mantener cookies y headers entre búsqueda y detalle Muchos portales enlazan el token resuelto con la misma sesión
Cadencia por portal Limitar ráfagas y añadir espera entre parcelas Los portales SIG suelen disparar CAPTCHA por frecuencia, no solo por volumen
Evidencia de parsing Guardar HTML y captcha-id de casos fallidos Facilita distinguir un fallo de OCR de un cambio de DOM

Extractor de datos SIG en Python

Esta clase detecta el CAPTCHA, lo resuelve por OCR y reenvía el formulario con los campos ocultos; bulk_extract espacia las parcelas:

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"]
)

Extracción por coordenadas con JavaScript

Si el portal responde a coordenadas (lat/lng o un bounds), recorres una malla de puntos con el mismo sondeo por OCR:

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
});

Errores frecuentes y cómo resolverlos

La mayoría de los fallos son de sesión o campos ocultos, no de OCR:

Problema causa Solución
La imagen CAPTCHA se carga rota Se requiere cookie de sesión Cargar la página de búsqueda primero
Texto resuelto rechazado Sensibilidad entre mayúsculas y minúsculas Agregar el parámetro case_sensitive=1
El portal devuelve diferentes CAPTCHA CAPTCHA específico de la sesión Descargar y resolver en la misma sesión
No hay datos del paquete después de CAPTCHA Faltan campos de formulario ocultos Extraer todas las entradas ocultas antes de enviar

Preguntas frecuentes

¿Funciona con el Catastro o con una sede electrónica pública?

Sí, siempre que el portal use CAPTCHA de imagen, OCR o reCAPTCHA v2, los tres compatibles con CaptchaAI. Ajusta la URL y los selectores a cada sede y revisa sus términos de uso antes de automatizar.

¿Qué plan necesito para extraer una provincia entera?

Depende de la concurrencia, no del total de parcelas. Para volúmenes bajos basta BASIC ($15/mes, 5 threads); para lotes con varios workers en paralelo, STANDARD ($30/mes, 15 threads) o ADVANCE ($90/mes, 50 threads). Todos incluyen resoluciones ilimitadas por thread.

¿Cómo describo un CAPTCHA matemático o con formato especial?

Usa textinstructions para indicarle al motor qué esperas, por ejemplo "5 letras mayúsculas" o "resuelve la ecuación". Con ese CAPTCHA resuelto también accedes a descargas de shapefiles o GeoJSON en la misma sesión.

Siguientes pasos

Los comentarios están deshabilitados para este artículo.