Цитата, по которой открывается страница, еще не является доказательством. Представьте исследовательского ассистента, которого спросили, кипит ли вода при 50 градусах Цельсия на уровне моря. Он отвечает уверенным абзацем и ссылкой. Ссылка работает. На странице написано: 100 градусов. URL был настоящим, но цитата не подтверждала утверждение.

Именно этот пробел остается в проверках на галлюцинации, которые смотрят только на сгенерированный ответ. Предложение может быть гладким, правдоподобным и снабженным рабочим URL, а цитируемый источник при этом говорит совсем другое. Ссылка лишь указывает на источник. Проверить ее означает загрузить то, что стоит за этим указателем, сравнить это с утверждением и сохранить доказательства, на которых основано решение.

Сервис из этой статьи автоматизирует такое сравнение. Вы передаете ему утверждение и URL, на которые сослалась модель. Он загружает каждую страницу через Crawling API, оставляет фрагменты, наиболее релевантные утверждению, запрашивает вердикт у модели-судьи, проверяет, что цитата-доказательство судьи действительно присутствует на странице, и объединяет результаты по отдельным цитатам в одну метку: supported, contradicted, not_found или unreachable.

Коротко
  • HTTP 200 не является доказательством. Страница может загрузиться и при этом оказаться защитой от ботов, пустой оболочкой, soft 404 или главной страницей сайта.
  • Отправляйте судье три релевантных фрагмента, а не всю страницу. Меньше шума, ниже стоимость, точнее вердикты.
  • Никогда не доверяйте цитате судьи. Принимайте supported или contradicted только тогда, когда цитата действительно является подстрокой загруженной страницы.
  • Агрегируйте по правилам, а не с помощью еще одной модели: одно опровержение перевешивает все подтверждающие цитаты.
  • Сохраняйте каждую загрузку, чтобы вердикт можно было воспроизвести после изменения страницы.
Пять этапов, и только один из них является языковой моделью. Загрузка, извлечение, проверка цитаты и агрегация детерминированы. Судья интерпретирует доказательства; код решает, разрешено ли учитывать эту интерпретацию.

Код находится в репозитории ScraperHub/llm-citation-verification-service-with-crawling-api, с контрольной точкой для каждого этапа в каталоге steps/ и полным пакетом в каталоге final/citation_verifier/. Примеры кода ниже взяты из final/ (кроме фрагментов, отмеченных отдельно). Вам понадобится аккаунт Crawlbase; храните его токены в переменных окружения и никогда в репозитории.

Загружайте саму цитируемую страницу, а не только HTTP 200

Почему простого GET недостаточно

Успешный запрос не означает, что вы получили контент, на который указывает цитата. Одностраничное приложение может вернуть 200 с пустым <div id="root">. Защита от ботов может вернуть 200 и при этом отдавать проверку для ботов. Soft 404 возвращает 200 с шаблоном «эта страница больше не существует», а глубокая ссылка может перенаправить на главную страницу. Этап загрузки должен устанавливать не только доступность: он сохраняет исходный статус, замечает редиректы и возвращает текст, пригодный для использования в качестве доказательства.

Markdown после очистки readability

Сервис Crawling API отвечает за загрузку. Верификатор запрашивает format=md, который возвращает страницу в формате GitHub Flavored Markdown, и md_readability=true, который удаляет навигацию, боковые панели, подвалы и рекламу до конвертации, чтобы извлечение начиналось с самой статьи. Кроме того, он задает store=true и благодаря этому любую загрузку можно позже проверить.

python
# final/citation_verifier/fetch.py
def crawl_options(readability: bool) -> dict[str, str]:
    """Query parameters for one Crawling API call.

    ``md_readability`` is omitted on the fallback request. The docs define it
    only as a switch on ``format=md``, not as a guarantee about thin pages.
    """
    options = {
        "format": "md",
        "store": "true",
        "custom_success_codes": "404,410",
    }
    if readability:
        options["md_readability"] = "true"
    return options

Один из параметров здесь ничего не делает. custom_success_codes предназначен для статусов исходного сайта, которые API иначе счел бы ошибками, но 404 и 410 уже считаются успешным краулингом: они возвращаются с cb_status 200 и реальным original_status, и повторно не запрашиваются. Его можно убрать, не меняя поведения.

На коротких или нестандартных страницах readability может удалить слишком много, поэтому верификатор считает результат с менее чем 200 непробельными символами неудачным и загружает тот же URL повторно без md_readability. Порог в 200 символов является правилом приложения, а не ограничением Crawlbase. Оплачиваются оба запроса, а при store=true сохраняются обе страницы, поэтому оставляйте повтор только для страниц, которым он нужен.

Мертвые ссылки, soft 404 и редиректы на главную страницу

Большинство плохих цитат отсеивается еще до запуска какой-либо модели. Мертвые ссылки, редиректы на главную страницу и PDF помечаются на этапе загрузки, и к извлечению переходят только страницы с пригодным текстом.

Каждый ответ содержит два статуса. original_status показывает, что ответил цитируемый сайт; cb_status является результатом краулинга на стороне Crawlbase. Верификатор помечает любую цитату, сайт которой ответил 404 или 410, как unreachable с причиной вроде http_404, и такая цитата никогда не доходит до судьи. Этот запрос все равно оплачивается, потому что Crawlbase действительно получил ответ: 404 означает успешный краулинг отсутствующей страницы.

Редиректам нужна отдельная проверка. Если глубокая ссылка приводит на / (главную страницу), она возвращает вполне нормальную страницу, которая ничего не говорит об утверждении, а общий текст главной страницы может выглядеть как подтверждение. Когда запрошенный путь глубже / и при этом итоговый путь равен /, цитата получает метку not_found с причиной redirected_to_homepage, и судья пропускается.

python
# final/citation_verifier/fetch.py
def _normalized_path(url: str) -> str:
    path = urlsplit(url).path or "/"
    if len(path) > 1 and path.endswith("/"):
        path = path[:-1]
    return path or "/"


def is_homepage_redirect(requested: str, final: str | None) -> bool:
    """True when a deep link landed on ``/``.

    The Crawling API returns the post-redirect URL in the ``url`` header.
    A citation that survives only as the site homepage did not survive.
    """
    if not final:
        return False
    return _normalized_path(requested) != "/" and _normalized_path(final) == "/"

Заголовок url содержит итоговый URL после редиректов, которые Crawlbase выполняет при обычном запросе. При запросе с рендерингом JavaScript редирект, который браузер выполняет самостоятельно, может оставить в url запрошенный вами URL, поэтому считайте проверку главной страницы сильным сигналом для обычного пути и проверкой по мере возможности для пути с рендерингом.

Soft 404 обрабатываются отдельно: страница с 200 считается soft 404, только если ее читаемый текст представляет собой короткий шаблон «страница не существует» объемом менее 800 непробельных символов, поэтому длинная статья, в которой просто упоминается 404, не отбрасывается.

Когда переходить на токен JavaScript

Рендеринг стоит дороже, поэтому верификатор начинает каждую цитату с обычного токена и переходит на JavaScript только тогда, когда первая попытка показывает, что странице нужен браузер. Запрос JavaScript тарифицируется вдвое большим количеством кредитов, чем обычный. Перейти можно через второй клиент с токеном JavaScript, как это сделано в репозитории, или оставить один клиент с обычным токеном и добавить javascript=true к повторному запросу; в любом случае запрос оплачивается как запрос JavaScript.

Проверка для перехода на JavaScript в репозитории, в файле fetch.py, срабатывает на значения cb_status 520 или 525. На практике ни один из этих сигналов не приходит. 520 не является значением cb_status вообще: это HTTP-статус, который Crawling API возвращает при неудачном краулинге, а закрепленный SDK crawlbase 1.0.0 возвращает такой ответ с пустым телом и без заголовков, так что прочитать cb_status невозможно. Страница, сервер которой ответил 200 без какого-либо содержимого, возвращается с cb_status 207, а 525 является внутренним кодом, не связанным с проверками защиты от ботов. Переходите на JavaScript по сигналам, которые API действительно отправляет:

python
# Production shape, not from the repository: when to retry on the JavaScript token.
def needs_javascript(response: dict, markdown: str, min_chars: int) -> bool:
    if response.get("status_code") == 520:
        return True  # the crawl failed; failed requests are not billed
    headers = response.get("headers") or {}
    if str(headers.get("pc_status")) == "207":
        return True  # the origin answered 200 with an empty page
    return len("".join(markdown.split())) < min_chars  # still thin after the readability retry

Закрепленная в репозитории версия SDK отдает статус под устаревшим именем pc_status; API отправляет его в поле cb_status тоже, и новые интеграции должны читать cb_status. После попытки с JavaScript выполните ту же интерпретацию повторно, включая запасной вариант без readability, чтобы отрендеренная страница проходила ровно те же проверки, что и статическая.

Извлеките доказательства, прежде чем обращаться к судье

Отправлять судье всю страницу дорого и шумно. Статья в 2 000 слов может содержать два релевантных предложения, а остальное дает модели больше шансов зацепиться за навигацию, дисклеймеры или посторонний текст. Поэтому сначала каждая страница разбивается на фрагменты: текст делится по пустым строкам, короткие абзацы объединяются в блоки до 200 слов, а более длинные разрезаются по границам слов. Лимит в 200 слов считается с помощью split(), поэтому для примерно равных фрагментов токенизатор не нужен.

python
# final/citation_verifier/passages.py (excerpt)
def split_passages(markdown: str, max_words: int = DEFAULT_MAX_WORDS) -> list[str]:
    """Split on blank lines, then pack up to ``max_words`` words.

    A paragraph longer than the cap is cut on word boundaries. Shorter
    paragraphs share a passage until the next one would overflow. Blank
    blocks are dropped.
    """
    if max_words < 1:
        raise ValueError("max_words must be at least 1")
    text = markdown.replace("\r\n", "\n").replace("\r", "\n")
    pieces: list[list[str]] = []
    for paragraph in re.split(r"\n\s*\n", text):
        words = paragraph.split()
        if not words:
            continue
        if len(words) > max_words:
            for start in range(0, len(words), max_words):
                pieces.append(words[start : start + max_words])
        else:
            pieces.append(words)

Затем фрагменты ранжируются относительно утверждения с помощью эмбеддингов all-MiniLM-L6-v2 и косинусного сходства, и сохраняются три лучших. Модель загружается при первом вызове извлечения, поэтому импорт пакета никогда не запускает скачивание.

python
# final/citation_verifier/retrieval.py
def top_k_passages(
    claim: str,
    passages: list[str],
    embedder: Embedder,
    k: int = DEFAULT_TOP_K,
) -> list[str]:
    """Return up to ``k`` passages, highest cosine similarity first."""
    if k < 1 or not passages:
        return []
    vectors = embedder.embed([claim, *passages])
    query = vectors[0]
    ranked = [
        (cosine(query, vectors[index + 1]), index, passage)
        for index, passage in enumerate(passages)
    ]
    ranked.sort(key=lambda item: (-item[0], item[1]))
    return [passage for _, _, passage in ranked[:k]]

Ограничение в три фрагмента выбрано намеренно. С одним фрагментом извлечение становится хрупким, когда лучшее доказательство оказывается на втором месте; десять возвращают большую часть шума, который извлечение должно было убрать. Доказательства за пределами первой тройки заканчиваются меткой not_found, и это честный ответ для доказательств, которые верификатор так и не показал судье.

Пусть судья возвращает доказательство, а не только вердикт

Судья интерпретирует доказательства; он не является источником истины. Модель может уверенно вернуть supported с цитатой, которой никогда не было на странице, а строгий JSON делает вывод предсказуемым, но не честным. Поэтому промпт ограничивает судью тремя метками и требует непрерывную цитату, скопированную из фрагментов, для любой метки, кроме not_found. unreachable никогда не передается модели; ее назначает этап загрузки.

python
# final/citation_verifier/judge.py
SYSTEM_PROMPT = """You verify one claim against the passages in the user message. The passages are the only evidence you may use.

Return JSON with these fields:
- label: supported, contradicted, or not_found
- evidence_quote: a contiguous quote copied from the passages, or null when the label is not_found
- confidence: a number from 0 to 1

supported means the passages state the claim. contradicted means the passages state the opposite. not_found means the passages do not settle the claim. If you cannot copy a quote that appears in the passages, return not_found and null. Do not add words, numbers, or sources that are not in the passages."""


class RawJudgement(BaseModel):
    label: Label
    evidence_quote: str | None = None
    confidence: float = Field(ge=0, le=1)

Судьей по умолчанию является OpenAI, вызов идет через httpx со строгой JSON-схемой в response_format; JUDGE_PROVIDER=anthropic и JUDGE_PROVIDER=ollama запускают тот же промпт у других провайдеров. Провайдер меняет то, как формируется вердикт, но никогда не меняет правила, по которым решается, будет ли он принят.

Проверка цитаты по странице

Проверка цитаты является тем шагом, который превращает судью из оракула в интерпретатора. Она заменяет фигурные кавычки прямыми, схлопывает пробелы и требует, чтобы цитата встречалась как точная подстрока извлеченных фрагментов. Вердикт supported или contradicted без совпадающей цитаты понижается до not_found с уверенностью 0 и причиной evidence_quote_not_in_source.

Вердикт сохраняется, только если его цитата есть на странице. Судья может быть прав насчет утверждения и все равно выдумать доказательство; проверка подстроки ловит это до того, как вердикт будет учтен.
python
# final/citation_verifier/judge.py
def guard_judgement(
    raw: RawJudgement,
    passages: list[str],
) -> tuple[Label, str | None, float, str | None]:
    """Drop a quote the passages do not contain.

    Supported and contradicted both need a real quote. A quote that fails the
    check downgrades the citation to not_found with confidence 0.
    """
    quote = raw.evidence_quote
    if raw.label in {Label.supported, Label.contradicted}:
        if quote and quote_in_passages(quote, passages):
            return raw.label, quote, raw.confidence, None
        return Label.not_found, None, 0.0, REASON_QUOTE
    if quote and not quote_in_passages(quote, passages):
        return Label.not_found, None, 0.0, REASON_QUOTE
    return Label.not_found, None, raw.confidence, None

В примере с температурой кипения утверждение говорит о 50 градусах, а страница о 100, поэтому вердикт contradicted сохраняется: его цитата есть на странице. Уверенность модели сохраняется только тогда, когда доказательство проходит проверку; агрегация ее игнорирует и работает только с метками.

Публикуем эндпоинт /verify как API

У готового сервиса два маршрута: GET /health и POST /verify. Запрос содержит утверждение и один или несколько цитируемых URL; ответ содержит метку на уровне утверждения и вердикт по каждой цитате с выдержкой-доказательством, уверенностью, итоговым URL, original_status, идентификатором хранилища rid и причиной, если верификатор ее назначил.

python
# final/citation_verifier/schema.py
class UrlVerdict(BaseModel):
    url: str
    label: Label
    evidence_quote: str | None = None
    confidence: float = Field(ge=0, le=1)
    final_url: str | None = None
    original_status: int | None = None
    rid: str | None = None
    reason: str | None = None


class VerifyRequest(BaseModel):
    claim: str = Field(min_length=1)
    urls: list[str] = Field(min_length=1)

Агрегация выполняется по фиксированному правилу, а не еще одним вызовом модели. Одна contradicted цитата перевешивает каждую supported цитату; если опровержений нет, любое подтверждение решает исход для утверждения; утверждение получает метку unreachable только тогда, когда такова каждая цитата; во всех остальных случаях результат not_found.

python
# final/citation_verifier/aggregate.py
def aggregate(verdicts: list[UrlVerdict]) -> Label:
    """One contradiction outweighs every supporting citation.

    Otherwise any supported citation carries the claim. The claim is
    unreachable only when every citation is unreachable. Every remaining mix,
    including an empty list, is not_found.
    """
    labels = [verdict.label for verdict in verdicts]
    if any(label == Label.contradicted for label in labels):
        return Label.contradicted
    if any(label == Label.supported for label in labels):
        return Label.supported
    if labels and all(label == Label.unreachable for label in labels):
        return Label.unreachable
    return Label.not_found

HTTP-слой остается тонким: POST /verify передает запрос верификатору и возвращает 503 в случае отсутствия обязательного токена, называя переменную окружения, но не раскрывая ее значение. Пробный запуск из репозитория, python steps/step_07_api/main.py --dry-run, выдает этот ответ. Страницы являются фикстурами на example.com, два запроса к Crawlbase были реальными, а судья использовал фикстурное предложение, потому что ключ OpenAI не был задан, поэтому воспринимайте это как фикстуру, а не как трассировку модели:

json
{
  "claim": "Water boils at 100 degrees Celsius at sea level.",
  "overall": "supported",
  "citations": [
    {
      "url": "https://example.com/articles/water-boiling-point",
      "label": "supported",
      "evidence_quote": "Water boils at 100 degrees Celsius at sea level.",
      "confidence": 0.96,
      "final_url": "https://example.com/articles/water-boiling-point",
      "original_status": 200,
      "rid": "rid-boil",
      "reason": null
    },
    {
      "url": "https://example.com/old-report",
      "label": "unreachable",
      "evidence_quote": null,
      "confidence": 1.0,
      "final_url": "https://example.com/old-report",
      "original_status": 404,
      "rid": "rid-404",
      "reason": "http_404"
    }
  ]
}

Утверждение получает метку supported: одна цитата его подтверждает, а другой просто больше нет. Именно ради этого различия и нужны четыре метки. Мертвый источник не является доказательством против утверждения; доказательством является живой источник, который говорит обратное.

Crawlbase Crawling API

Загружайте страницу, на которую указывает цитата, в виде чистого Markdown, с рендерингом, когда ей нужен браузер, и с реальным кодом статуса сайта. Неудачные запросы не оплачиваются. Для старта доступно до 5 000 бесплатных запросов, карта не нужна.

Оцените точность цитирования на пакете

Одно проверенное утверждение демонстрирует конвейер, но не говорит, насколько надежно модель цитирует источники. Для этого верификатор оценивает пакет: JSONL-файл с записями, содержащими id, утверждение и цитируемые им URL, где каждая пара утверждение-URL считается одной цитатой. Точность цитирования равна числу подтвержденных цитат, деленному на число всех цитат. Точность только по доступным цитатам сохраняет тот же числитель и исключает недоступные цитаты из знаменателя, разделяя ситуации «модель ссылается на мертвые ссылки» и «модель ссылается на живые страницы, которые не говорят того, что она утверждает».

python
# final/citation_verifier/batch.py
@dataclass(frozen=True)
class Precision:
    """Citation precision over claim-URL pairs.

    citation precision is supported citations divided by every citation.
    Reachable-only precision uses the same numerator and drops unreachable
    citations from the denominator. It is None when nothing was reachable.
    """

    supported: int
    total: int
    reachable: int

Число одновременных запусков ограничивает asyncio.Semaphore (BATCH_CONCURRENCY, по умолчанию 4), а поскольку вызов Crawlbase SDK синхронный, он выполняется в рабочем потоке и не блокирует цикл событий. На примерной фикстуре пробный запуск сообщает о девяти цитатах, две из которых подтверждены, а три недоступны:

text
citation_precision=0.2222 (2/9)
reachable_precision=0.3333 (2/6)

Запустите его на собственных результатах командой python -m citation_verifier.batch your_outputs.jsonl --out citation_report.csv после того, как будут заданы токены и ключ судьи. Начните с нескольких десятков утверждений и в первую очередь прочитайте строки contradicted из отчета: одно подтвержденное опровержение может выявить проблему, которую скрывает агрегированная метрика точности.

Сохраняйте доказательства в Cloud Storage

Оценка точности полезна ровно настолько, насколько вы можете проверить ее позже, а цитируемая страница может измениться на следующий день после проверки. Если указать store=true, Crawlbase хранит возвращенный Markdown в Cloud Storage в течение 30 дней, а ответ содержит поле storage_url со строкой запроса, в которой передается идентификатор страницы rid. Верификатор сохраняет только rid, потому что полный URL содержит токен, и ведет небольшой аудиторский индекс в SQLite: rid, утверждение, URL, вердикт, уверенность и время. Сохранение добавляет половину кредита за страницу к стоимости краулинга; чтение сохраненной страницы бесплатно.

python
# final/citation_verifier/audit.py (docstring omitted)
def replay_rid(rid: str, token: str | None = None) -> str:
    if not rid:
        raise ValueError("rid is required")
    settings = get_settings()
    used = token if token else settings.crawlbase_token
    if not used:
        raise MissingTokenError(
            "CRAWLBASE_TOKEN is not set. Replay needs the token that stored the page."
        )
    client = StorageAPI({"token": used})
    response = _storage_get(client, rid)

Сохраненные страницы принадлежат вашему аккаунту, а не токену, которым они были получены, поэтому любой из двух токенов может воспроизвести любой rid; комментарии в репозитории утверждают обратное, и это ограничение можно игнорировать. В закрепленном SDK crawlbase 1.0.0 метод StorageAPI.get принимает rid как позиционный аргумент. Цитата, для которой так и не появилось сохраненной страницы, например пропущенный PDF, все равно получает строку аудита с пустым rid, поэтому решение фиксируется, даже когда воспроизводить нечего.

Ограничения: пейволлы, PDF и утверждения из нескольких источников

Пейволлы. Страница за пейволлом может ответить 200 и показать лишь предложение оформить подписку. Если именно это находит извлечение, правильный вердикт not_found: общедоступная страница не подтверждает утверждение. Это более узкий вывод, чем «утверждение ложно», и более честный, чем эвристики для пейволлов, которые ошибочно срабатывают на статьях, где просто упоминаются подписки.

PDF. URL с окончанием .pdf помечается как not_found с причиной pdf_unsupported. Параметр Crawlbase pdf=true рендерит веб-страницу в PDF; он не извлекает текст из URL, который уже является PDF, поэтому верификатор отказывается от оценки, а не судит о тексте, за который не может поручиться.

Утверждения из нескольких источников. Каждый URL оценивается отдельно. Утверждение, которое следует только из сочетания двух документов, остается not_found, потому что проверка подстроки может доказать, что цитата взята со страницы, но не может подтвердить вывод, сделанный на основе нескольких страниц. Верификатор не устанавливает, что утверждение истинно; он устанавливает, подтверждал ли его цитируемый доступный источник на момент проверки.

Заключение

Проверке цитат место в конвейере в качестве шага валидации данных, а не очередного промпта поверх модели. Загрузите цитируемый источник, сократите его до релевантных доказательств, позвольте модели интерпретировать эти доказательства, а затем примените детерминированные проверки, прежде чем что-либо будет учтено. Тогда у каждого сбоя появятся название и причина, которые можно протестировать и проверить при аудите.

Начните с пробного запуска сценария /verify из репозитория ScraperHub/llm-citation-verification-service-with-crawling-api, затем создайте бесплатный аккаунт Crawlbase, задайте CRAWLBASE_TOKEN, CRAWLBASE_JS_TOKEN и OPENAI_API_KEY, а затем запустите его на цитатах из собственных результатов.

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

Как верификатор проверяет источники LLM?

Он загружает каждый цитируемый URL через Crawling API в формате Markdown, оставляет три фрагмента, наиболее релевантных утверждению, и запрашивает у модели-судьи метку и подтверждающую цитату. Затем детерминированная проверка принимает вердикт, только если цитата является точной подстрокой загруженных фрагментов.

Что происходит, если цитируемой страницы больше нет?

Цитата, сайт которой отвечает 404 или 410, помечается как unreachable и никогда не доходит до судьи. Глубокая ссылка, перенаправляющая на главную страницу, помечается как not_found вместо этого, потому что страницы, на которую она указывала, по этому адресу больше нет.

Зачем загружать страницы в формате Markdown?

Markdown сохраняет структуру страницы без разметки, поэтому он чисто делится на фрагменты и расходует гораздо меньше токенов, чем HTML. Если указать md_readability=true, навигация, боковые панели, подвалы и реклама удаляются до конвертации, поэтому извлечение начинается с самого контента.

Когда цитате нужен токен JavaScript?

Когда краулинг с обычным токеном завершается неудачей или возвращает пустую оболочку, а именно так без рендеринга выглядят страницы, которые собираются в браузере. Повторяйте такие запросы с токеном JavaScript или с javascript=true на обычном токене. Запрос с рендерингом тарифицируется вдвое большим количеством кредитов, поэтому он используется как запасной вариант, а не по умолчанию.

Может ли сервис проверить любое утверждение LLM?

Он проверяет, подтверждает или опровергает утверждение доступная цитируемая страница. Он не проверяет утверждения, для которых нужно объединить несколько документов, не извлекает текст из URL PDF-файлов и не видит контент за пейволлом.

Начать создавать

Обходите любой сайт в масштабе, без борьбы с инфраструктурой.

Crawlbase берёт на себя прокси, отпечатки и CAPTCHA, чтобы ваша команда выпускала конвейеры данных вместо поддержки обвязки краулинга. 1 000 запросов бесплатно, без карты.

Самообслуживание · Звонок отдела продаж не требуется · Доступны корпоративные объёмы краулинга