Use Cases

Обработка CAPTCHA для ГИС и извлечение картографических данных

Портал окружного асессора вместо координат участка возвращает картинку с шестью искажёнными символами? Это не сбой, а Image/OCR CAPTCHA — стандартная защита старых государственных ГИС-систем. Дальше два варианта: решать капчу вручную при каждом запросе или передавать её в CaptchaAI API и получать готовый текст за секунды, не останавливая скрипт сбора данных.

Кадастровые системы округов, порталы зонирования, геологические и экологические базы годами работают на устаревшем стеке — длинные циклы закупок и ограниченные бюджеты не оставляют времени на замену капчи чем-то современным. На входе чаще всего встречается CAPTCHA-изображение с искажённым текстом, реже — reCAPTCHA v2 или математическая капча. Для сбора границ участков, данных о зонировании, оценке недвижимости и зонах затопления это единственная преграда между HTTP-запросом и нужными данными.

Где на ГИС-порталах встречается CAPTCHA

Тип капчи и повод её показать зависят от конкретного источника данных — вот типичная картина по шести распространённым типам порталов:

Тип портала Тип CAPTCHA Когда запрашивается
ГИС округа / система оценки CAPTCHA-изображение с текстом Поиск участка по кадастровому номеру
Геопространственные порталы штата Пользовательская CAPTCHA Запрос на скачивание данных
Порталы Геологической службы США (USGS) reCAPTCHA v2 Массовая выгрузка данных
Карты муниципального зонирования CAPTCHA-изображение Повторные запросы по одному объекту
Экологические базы данных Математическая CAPTCHA Формирование отчёта
Поиск зон затопления CAPTCHA-изображение с текстом Запрос по адресу

Экстрактор данных ГИС на Python

Ниже — рабочий класс на requests: он делает обычный GET-запрос, ищет признаки капчи в HTML по регулярному выражению, скачивает изображение, отправляет его в CaptchaAI через base64 и опрашивает res.php, пока не получит решённый текст. Перед повторной отправкой формы важно собрать все скрытые поля (input[type='hidden']) — большинство ГИС-систем используют их для CSRF-токенов и идентификаторов сессии, и без них портал молча отклонит запрос.

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

Метод bulk_extract добавляет паузу между запросами — это не только вежливость по отношению к серверу округа, но и способ не спровоцировать более агрессивную защиту на самом портале.

Извлечение геоданных по координатам на JavaScript

Для команд на Node.js — тот же принцип, только вместо ID участка на вход идут широта и долгота, а extractRegion проходит сеткой по прямоугольной области и на каждой точке проверяет, нужна ли капча:

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

Шаг сетки (gridSize) напрямую влияет на нагрузку по капчам: более мелкая сетка даёт точнее покрытие региона, но и кратно больше запросов — а значит, и больше решений CAPTCHA за проход. Закладывайте это в расчёт потоков заранее, а не после того как выгрузка региона займёт полдня.

Параметры API CaptchaAI для капч на ГИС-порталах

Параметр Значение Когда использовать
method base64 Стандартная CAPTCHA-изображение
numeric 1 Только цифровые CAPTCHA
min_len 4 Известно минимальное число символов
max_len 6 Известно максимальное число символов
language 0 Английский/Latin символы
textinstructions Свой текст Математические CAPTCHA или капчи с нестандартным форматом

textinstructions стоит использовать сразу, как только видите, что округ применяет нетиповую капчу: одна строка вроде «5 заглавных латинских букв» или «решить пример» экономит часы на подборе min_len/max_len вручную.

Типичные проблемы при обработке CAPTCHA на ГИС-порталах

Проблема Причина Решение
Картинка CAPTCHA загружается битой Нужен сессионный cookie Сначала загрузите страницу поиска
Решённый текст портал отклоняет Чувствительность к регистру Добавьте параметр case_sensitive=1
Портал каждый раз присылает новую капчу CAPTCHA привязана к сессии Скачивайте и решайте в рамках одной сессии, не открывайте новую на каждый повтор
После капчи нет данных по участку Не отправлены скрытые поля формы Извлеките все скрытые поля перед отправкой

Чаще всего время теряют именно на третьей строке — скрипт открывает новую сессию на каждый повтор запроса, портал в ответ выдаёт новую капчу, и решение из предыдущего шага становится бесполезным ещё до того, как форма успела отправиться.

Сколько это стоит на практике

Допустим, вы собираете данные с двадцати окружных ГИС-порталов США по заказу риелторского агентства и получаете в среднем 300–400 капч в сутки. При таком объёме план STANDARD ($30/мес, 15 потоков) обычно закрывает нагрузку без роста времени ответа; для более плотного графика или параллельного сканирования нескольких округов разумнее сразу взять ADVANCE ($90/мес, 50 потоков). Тарификация по потокам, а не по числу решений, здесь удобна именно потому, что число капч на регион заранее не предсказать — сегодня двадцать, завтра двести после смены защиты на портале.

Чек-лист перед массовым извлечением

  • Прежде чем запускать большую выгрузку, проверьте область просмотра карты, фильтр региона и элементы управления пагинацией — ошибка в границах обходится дороже, чем кажется на старте.
  • Сохраняйте нормализованные координаты и необработанный ответ портала, чтобы ошибки извлечения оставались отлаживаемыми постфактум.
  • Ставьте пакет на паузу, когда плотность капч резко растёт, вместо того чтобы прятать это за повторными попытками — резкий рост обычно означает, что на портале что-то изменили.
  • Логируйте task_id каждого решения CaptchaAI рядом с координатами — при разборе инцидента это быстрее, чем воспроизводить запрос заново.

Часто задаваемые вопросы

Сколько времени скрипт ждёт решения одной капчи?

В примере выше опрос res.php идёт с интервалом 3 секунды и до 30 попыток — то есть до полутора минут на одну капчу, прежде чем сработает TimeoutError. На практике решение обычно приходит намного раньше этого потолка; сам потолок — это защита от зависшего цикла, а не ожидаемое время ответа.

Портал присылает новую капчу при каждом повторном запросе — что делать?

Это означает, что капча привязана к конкретной сессии, а не к запросу. Скачивайте изображение и отправляйте решённый текст в рамках одной и той же сессии (requests.Session() в примере выше уже это обеспечивает) — открывать новое соединение на каждую попытку бессмысленно.

Как обрабатывать нестандартные форматы капч у разных округов?

Единого формата нет — один округ просит пять заглавных букв, другой присылает математический пример. Опишите конкретный формат через параметр textinstructions CaptchaAI вместо того, чтобы подбирать min_len/max_len наугад под каждый портал.

Получится скачать shapefile или GeoJSON, если файл спрятан за капчей?

Да — если портал отдаёт пространственные данные по прямой ссылке после капчи, решите капчу через CaptchaAI, чтобы получить доступ к ссылке, а сам файл скачивайте обычным HTTP-запросом отдельно.

Стоит ли выбирать Python или JavaScript для парсинга ГИС-порталов?

Технической разницы нет — оба примера выше решают одну и ту же задачу. Берите тот стек, на котором уже стоит остальной пайплайн: Python — если данные потом уходят в pandas/GeoPandas, Node.js — если рядом уже крутится Puppeteer или другой браузерный слой.

Итог

Надёжное извлечение данных ГИС не требует ручного разбора капч на каждом округе. Получите API-ключ CaptchaAI, подключите его к парсеру и обрабатывайте капчи государственных порталов автоматически.

Следующие шаги

Комментарии для этой статьи отключены.