ESPN DeportesFederación Portuguesa suspende a Cristiano Ronaldo tras abandonar la selecciónThe Jerusalem PostWWII air-raid shelter buried beneath German playground rediscovered during construction workESPNThe WNBA Finals are set! First look at how Valkyries, Dream match upPunchRonaldo will return to Portugal squad in November — OfficialComplete SportsArsenal Make 27-Year History During Leeds Comeback WinZDF heuteAktuelle Pressemitteilungen des ZDFRTP DesportoMarco Silva reconhece que Benfica não tem outra solução além de ganharFootball ItaliaItaly doctor hits back at Fabregas’ Kean comments: ‘We constantly keep clubs updated’DW EnglishTrump slams Zelenskyy, says Ukraine should elect new presidentAntara NewsPolice foils illegal trade of protected mobula ray species in BaliMyJoyOnlineHincapie injury adds to Arsenal issues after Guimaraes rescue actVariety‘Children of Blood and Bone’ Author Tomi Adeyemi Quietly Cancels New York Comic Con Panel
The Daily Newsstand · Free, Always
Saturday, October 10, 2026

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

Translate

Когда в форме нужно проверить ИНН, подсказать банк по БИК или показать оператора по номеру телефона, обычно подключают внешний 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

Ссылки:

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.