javascript

Чей номер и чей БИК: две офлайн-библиотеки на открытых реестрах Минцифры и ЦБ

  • воскресенье, 11 октября 2026 г. в 00:00:03
https://habr.com/ru/articles/1092758/

Когда в форме нужно проверить ИНН, подсказать банк по БИК или показать оператора по номеру телефона, обычно подключают внешний API. Мне захотелось обойтись без него: все эти данные государство и так публикует открыто, и они достаточно маленькие, чтобы положить их прямо в npm-пакет. Так появились две библиотеки, которые работают без сети и обновляются сами. Это продолжение истории с cronsense и производственным календарём.

Выглядит это так:

import { lookup } from 'phoneoperator';

lookup('+7 916 123-45-67')?.operator.brand?.name; // 'МТС'
lookup('+7 916 123-45-67')?.region;               // 'Город Москва, Московская область'
import { bank, validateAccount, validateInn } from 'bankbik';

bank('044525225')?.correspondentAccount;               // '30101810400000000225'
validateAccount('40702810938000000001', '044525225');  // { valid: true, ... }
validateInn('7707083894');                             // { valid: false, error: 'CHECKSUM', ... }

Обе можно попробовать в браузере: phoneoperator и bankbik. Ниже про то, откуда данные, как они ужимаются и какие сюрпризы встретились.

Зачем это офлайн

Определить оператора по номеру и найти банк по БИК умеют DaData и десяток похожих сервисов. Но для такой простой задачи это лишний внешний вызов: нужен ключ, есть лимиты, данные пользователя уходят третьей стороне, а форма регистрации начинает зависеть от чужого аптайма. При этом сами данные открытые и небольшие, их можно просто держать у себя.

На npm я ничего подходящего не нашёл. По запросам вроде «Россвязь», «номерная ёмкость» и «оператор по номеру» поиск возвращает только libphonenumber, а он разбирает формат номера, но оператора по российскому реестру не знает. Для БИК есть обёртки над онлайн-API. Валидаторы ИНН и СНИЛС существуют, но самый популярный из них не обновлялся с 2022 года.

phoneoperator: оператор и регион по номеру

Минцифры публикует реестр российской системы нумерации, бывший реестр Россвязи. Это четыре CSV-файла: мобильные коды 9xx и стационарные 3xx, 4xx и 8xx. В каждой строке диапазон номеров, его ёмкость, оператор, регион и ИНН оператора:

900;0000000;0061999;62000;ООО "Т2 МОБАЙЛ";-;Краснодарский край;7743895280

Всего 450 тысяч строк и около 60 МБ. В пакет столько тащить нельзя, поэтому данные ужимаются в три шага.

Первый шаг: соседние диапазоны с тем же оператором и регионом склеиваются. Второй: операторы и регионы уходят в отдельные таблицы, а в диапазонах остаются только их номера. Третий: диапазоны пишутся не абсолютными числами, а разницей с концом предыдущего, в base36. Десятизначный номер превращается в пару символов. В итоге мобильные номера занимают около 45 КБ в gzip, а стационарные около 640 КБ.

Стационарные нужны далеко не всем, поэтому они вынесены в отдельный импорт с тем же API:

import { lookup } from 'phoneoperator/full';
lookup('8 800 555 35 35'); // { type: 'landline', region: 'Российская Федерация', ... }

Если импортировать только phoneoperator, стационарные данные не попадут ни в бандл, ни в память. Сама таблица разворачивается в типизированные массивы при первом вызове, дальше поиск идёт бинарным поиском по началам диапазонов.

У МТС два названия

Первое, на что я наткнулся в данных: один и тот же оператор записан по-разному. У МТС часть диапазонов оформлена на «ПАО "МТС"», часть на «ПАО "Мобильные ТелеСистемы"». У МегаФона встречаются «ПАО "МЕГАФОН"» и «ПАО "МегаФон"». Поэтому операторы группируются по ИНН, а название берётся то, на которое приходится больше всего номеров. Бренды тоже привязаны к ИНН: так ООО "Т-МОБ" превращается в Т-Мобайл, а ООО "Скартел" в Yota.

Чего реестр не знает

С 2013 года номер можно перенести к другому оператору. Реестр говорит только о том, кому выделен диапазон, а база перенесённых номеров закрыта. Поэтому для перенесённого номера библиотека вернёт исходного оператора. Регион при переносе не меняется, так что он остаётся верным. Это ограничение я вынес прямо в README и в песочницу, чтобы никто не удивлялся.

Госсайт, который не пускает GitHub

Для производственного календаря данные обновляет GitHub Actions по расписанию, и здесь я собирался сделать так же. Перед тем как писать код, проверил, отдаёт ли сайт Минцифры файлы серверам GitHub. Для этого запустил тестовый workflow, который пробует скачать реестр:

probe (opendata.digital.gov.ru/.../DEF-9xx.csv)  failure
probe (opendata.digital.gov.ru/.../ABC-3xx.csv)  failure
probe (www.cbr.ru/s/newbik)                      success

С моего компьютера файлы скачиваются, а с серверов GitHub в США нет. Сайт ЦБ при этом отдаёт данные без проблем, это пригодилось для второй библиотеки.

Как проверить 450 тысяч строк

Сжатие с дельтами и общими таблицами легко сломать незаметно: сдвиг на единицу, и половина номеров уедет к соседнему оператору. Поэтому главный тест проходит по всем 450 тысячам строк исходного реестра. Для каждой строки он берёт первый, последний и средний номер диапазона и проверяет, что библиотека возвращает тот же ИНН и тот же регион. Это полтора миллиона проверок, и они выполняются за несколько секунд.

Сам скрипт загрузки тоже недоверчивый. Он не запишет данные, если у файла другой заголовок, если ёмкость диапазона не сходится с его границами, если диапазоны пересекаются или если в реестре пропал кто-то из большой четвёрки. Если на сайте что-то сломается, лучше пропустить неделю, чем выпустить пакет с половиной операторов.

bankbik: справочник БИК и проверка реквизитов

ЦБ публикует справочник участников платёжной системы в формате ED807: ZIP-архив с XML в кодировке windows-1251. В нём 1384 записи: банки, филиалы, подразделения самого ЦБ, казначейство и конкурсные управляющие. Ужатый справочник занимает около 80 КБ в gzip.

bank('044525974');
// { name: 'АО "ТБанк"', englishName: 'TBANK', kind: 'bank', swift: 'TICSRUMMXXX',
//   correspondentAccount: '30101810145250000974', ... }

Чтобы обойтись без зависимостей, ZIP распаковывается вручную. Первая попытка взять размер сжатых данных из локального заголовка упала с unexpected end of file: архив ЦБ записан с дескриптором данных, и в локальном заголовке размер равен нулю. Правильный размер лежит в центральном каталоге в конце архива. Дальше стандартный inflateRawSync из node:zlib и TextDecoder('windows-1251').

Поиск, который нашёл не тот банк

Поиск по названию нужен для автодополнения: пользователь пишет «т-банк» и ждёт увидеть Т-Банк. В справочнике он записан как АО "ТБанк", поэтому я убирал из запроса и из названия всё, кроме букв и цифр. Первый вариант склеивал название, английское название, БИК и SWIFT в одну строку и удалял пробелы. На запрос «т-банк» он первым выдал «КРАСНОДАРСКИЙ ФИЛИАЛ АО ЮНИКРЕДИТ БАНКА». После удаления пробелов в «юникредитбанка» нашлось «тбанк».

Теперь поиск идёт по каждому полю отдельно, пробелы между словами сохраняются, а результаты сортируются: сначала совпадения с начала слова, затем банки раньше филиалов. На «т-банк» первым идёт Т-Банк, на «сбер» сам Сбербанк, а не одно из его отделений.

Как устроен ключ счёта

Расчётный счёт и БИК проверяются вместе. К 20 цифрам счёта слева приписываются три цифры, зависящие от БИК, и получившиеся 23 цифры умножаются на веса 7, 1, 3, 7, 1, 3 и так далее. Сумма должна делиться на 10.

Какие три цифры приписывать, зависит от того, где открыт счёт. В обычном банке это последние три цифры БИК. Если счёт открыт в подразделении ЦБ, то ноль и пятая-шестая цифры БИК. Эту часть я не стал брать на веру из статей, а проверил на самом справочнике: в нём лежат корреспондентские счета всех банков, и все 951 проходят именно по второму правилу со своим БИК. Тест с этой проверкой выполняется при каждом обновлении данных.

Справочник позволяет поймать ещё одну частую ошибку. Если в платёжке корсчёт одного банка, а БИК другого, контрольная сумма может случайно сойтись: у Т-Банка и Сбербанка одинаковые пятая и шестая цифры БИК. Поэтому validateCorrespondentAccount сверяет счёт со справочником:

validateCorrespondentAccount('30101810145250000974', '044525225');
// { valid: false, error: 'MISMATCH', message: 'ПАО Сбербанк has correspondent account 30101810400000000225' }

С казначейскими счетами, которые начинаются на 03, честного решения я не нашёл. Ни одно из двух правил на известном мне примере не сошлось, поэтому такие счета проверяются только по формату, и это написано в документации. Выдавать догадку за проверку не хотелось.

Остальные реквизиты

ИНН, ОГРН, ОГРНИП и СНИЛС проверяются по контрольным цифрам, у КПП проверяется формат. Все функции возвращают результат одной формы, поэтому их удобно подключать к формам:

validateSnils('112-233-445 95'); // { valid: true, value: '11223344595' }
validateOgrn('1027700132196');   // { valid: false, error: 'CHECKSUM', message: 'OGRN has a wrong check digit' }

У СНИЛС есть исторический нюанс: номера до 001-001-998 выдавались ещё до появления контрольного числа, поэтому для них проверяется только формат. Тесты, помимо известных реквизитов, генерируют по 2000 случайных ИНН, ОГРН и ОГРНИП и проверяют, что правильная контрольная цифра принимается, а любая из девяти других отклоняется.

Кто такие «КУ ... ГК АСВ»

Пятая часть справочника выглядит странно: записи вида «КУ АКБ "Бенифит-банк" (ЗАО) - ГК "АСВ"». Это конкурсные управляющие банков, у которых отозвана лицензия. Через них идут расчёты с кредиторами, поэтому у них сохраняются БИК и корсчёт. В библиотеке у таких записей kind: 'liquidation', чтобы их можно было отфильтровать, а в поиске они идут после действующих банков.

С сайтом ЦБ проблем, как у Минцифры, не было: раз в неделю Actions скачивает справочник и, если он изменился, выпускает новую версию.

Автопубликация, которая сработала не с первого раза

Пакеты публикуются из GitHub Actions без токенов: npm и JSR доверяют самому workflow через OIDC. Сначала схема была такой: workflow с данными находит изменения, поднимает версию, ставит тег и запускает отдельный workflow публикации командой gh workflow run.

В npm версии уходили, а JSR отвечал actorNotScopeMember. Публикацию запускал уже не я, а бот GitHub Actions, и JSR справедливо отказывался принимать пакет от того, кто не состоит в моём scope. Хуже всего, что ровно так же сломался бы ежегодный выпуск производственного календаря, и узнал бы я об этом только в момент, когда календарь на новый год реально выйдет.

Теперь выпуск релиза, обновление данных и публикация живут в одном workflow. Его запускает расписание, мой коммит с новыми данными или кнопка в Actions, и для npm с JSR это один и тот же файл и один и тот же автор. Вывод простой: автоматизацию, которая срабатывает раз в год, надо проверять сразу, а не ждать, пока она понадобится.

Без сборщика

В комментариях к прошлой статье попросили .min.js. Собирать его не пришлось: jsDelivr сам делает минифицированный ES-модуль из npm-пакета, так что любую из библиотек можно подключить прямо на страницу:

<script type="module">
  import { lookup } from 'https://cdn.jsdelivr.net/npm/phoneoperator@0/+esm';
  import { bank } from 'https://cdn.jsdelivr.net/npm/bankbik@0/+esm';
  console.log(lookup('+7 916 123-45-67'), bank('044525225'));
</script>

Что в итоге

Все без зависимостей, работают в Node, Deno, Bun и браузере, есть в npm и JSR:

npm install phoneoperator

npm install bankbik

Ссылки: