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

Когда в форме нужно проверить ИНН, подсказать банк по БИК или показать оператора по номеру телефона, обычно подключают внешний 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
Ссылки:
phoneoperator: https://github.com/MrProLopstar/phoneoperator, песочница
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.