Битрикс: своя геолокация

Не так давно (с 17 версии) в битриксе появилась штатная возможность управления геолокацией. Это когда нужно определить географическое положение клиента (по его IP адресу). Например, чтобы переключить интерфейс сайта на нужный язык или показать цены товаров для нужного региона. Так вот битрикс дал не только возможность пользоваться тем что есть, но и возможность прикрутить к этому функционалу собственные обработчики. Кому и зачем могут понадобиться собственные обработчики геолокации? Да всё просто - всем, кому вообще нужна геолокация. Дело в том, что провайдеры геоданных, как показывает практика, долго не живут. Одни закрываются, другие появляются. И так постоянно. Конечно, битрикс не сможет оперативно предлагать новые и новые сервисы в своей поставке. Хотя сейчас из коробки доступны самые популярные сервисы геоданных на данный момент. Есть даже заметка в официальной документации на эту тему. Но, конечно, гораздо подробнее тема раскрывается в неофициальных источниках.

Принцип работы

Текущий функционал провайдеров, который битрикс предлагает из коробки - это взаимодействие с сервисами через их API. Чтобы этим воспользоваться, надо зарегистрироваться на предложенных сайтах, получить ключ API и наслаждаться тем, как всё само работает... ну, когда работает. Ведь это API, т.е. внешние сервисы, которые никому ничего не должны. Они, естественно, могут падать в процессе. Могут упасть и вообще больше не подняться. Поэтому не стоит на хите делать обращение к внешнему API, уж точно не стоит это делать без объявления лимита таймаута, когда соединение разорвётся, устав ждать ответа. Лучшим вариантом было бы держать базу поиска по IP адресам поближе к собственному сайту. Например, в собственной базе данных. Но тогда придётся самому следить за актуальностью и как-то обновлять базу. И это основной плюс внешних сервисов - они там сами следят за актуальностью своих данных и даже дают отчёт об актуальности (сколько IP адресов в их базе не учтены, сколько неправильно отнесены и пр.). Сервисы предлагают несколько вариантов взаимодействия: попроще - бесплатно; поточнее - за платную подписку. Но в любом случае, нужно совершенно чётко понимать (и доносить до бизнеса), что нет и не может быть в принципе всеобъемлющей базы соответствия IP адресов и географических координат. Это всё весьма условно и далеко не стопроцентно.
В битриксе есть собственные сервисы (Настройки / Настройки продукта / Геолокация), плюс вы добавите какие-то свои в коде сайта. Определение клиента будет по очереди по каждому источнику, пока не найдётся ответ. Поэтому важно правильно расставить сортировку (в данном случае, чем меньше параметр сортировки - тем более приоритетный будет источник).

Добавляем свой

Покажу на примере, как можно добавить свой сервис геолокации. Например, на проекте есть отдельный файл, где регистрируются все обработчики событий /bitrix/php_interface/events.php. Подписываемся на событие битрикса:
<?php // события Битрикса

$eventManager = \Bitrix\Main\EventManager::getInstance();

// Геолокация локальной базы maxmind.com
$eventManager->addEventHandler(
    'main',
    'onMainGeoIpHandlersBuildList',
    [
        'Gazmyas\Geo\LocalMaxMind',
        'getGeoIpHandlers'
    ]
);

// Геолокация локальной базы sypexgeo.net
$eventManager->addEventHandler(
    'main',
    'onMainGeoIpHandlersBuildList'
    [
        'Gazmyas\Geo\LocalSypexGeo',
        'getGeoIpHandlers'
    ]
);
Этот код означает, что каждый раз как в битриксе будет запрашиваться информация по геоданным, будут вызываться эти два самописных источника геоданных. Классы самописных геоданных размещаем в пространстве имён, каждый в своём файлике. В моё случае это файлы /bitrix/php_interface/classes/Gazmyas/Geo/LocalMaxMind.php и /bitrix/php_interface/classes/Gazmyas/Geo/LocalSypexGeo.php. Эти два обработчика будут дублями существующих битрисковых обработчиков с той лишь разницей, что данные находятся на локальном сайте и никаких внешних вызовов через API не производится.
Класс геолокации должен расширять битриксовый класс Bitrix\Main\Service\GeoIp\Base - там и нужно почерпнуть, какие методы нужно реализовать в собственном классе.
Первый необходимый метод мы уже обозначили, когда делали подключение к обработке битриксовых событий - getGeoIpHandlers() - вот как должен выглядеть этот метод:
/**
 * Подключение собственного обработчика геолокации в битриксе
 *
 * @return EventResult
 */
public static function getGeoIpHandlers()
{
    return new EventResult(
        EventResult::SUCCESS,
        [
            __CLASS__ => 'bitrix/php_interface/classes/Gazmyas/Geo/LocalSypexGeo.php',
        ]
    );
}
Метод сообщит коду битрикса, что такой-то класс есть в таком-то файле. А дальше битрикс подключит этот класс и будет с ним работать.
Битрикс создаст экземпляр объекта класса. Поэтому надо позаботиться, чтобы задать сразу нужные параметры в этом объекте. Я выделяю необходимость установки параметра сортировки - вы смотрите сами, какие параметры сортировки стоят у вас в стандартных битриксовых обработчиках в админке, какие параметры сортировки у вас есть в конкурирующих самописных источниках геоданных - и выставляйте сортировку, как вам надо. С моём примере для локальной базы сервиса sypexgeo.net - я выставил самый низкий приоритет - 200 (битриксовые источники в моём примере имеют сортировки 90, 100 и 110). Итак конструктор:
/**
 * Base constructor.
 *
 * @param array $arFields DB fields of handlers settings.
 */
public function __construct(array $arFields = [])
{
    $this->errors = new ErrorCollection();
    if (!isset($arFields['SORT'])) {
        $arFields['SORT'] = $this->sort;
    }
    if (!isset($arFields['ACTIVE'])) {
        $arFields['ACTIVE'] = $this->active ? 'Y' : 'N';
    }

    parent::__construct($arFields);
}
Тут видно, что флаги сортировки и активности берутся прямо из атрибутов класса. В вашем случае может быть какая-то логика их вычисления.
Класс подключили, конструктор класса есть, - переходим к функционалу. Во-первых, надо определить некоторые служебные методы, которые описывают свойства текущего сервиса геолокации: getSort(), isInstalled(), isActive(), getSupportedLanguages(), getTitle(), getDescription(). Метод getSupportedLanguages() возвращает массив кодов языков, - данные на этих языках могут быть получины из этого источника геолокации. В примере я показываю, как можно черпать данные из предлагаемого бесплатно бинарного файла SxGeoCity.dat. В этом файле можно получить данные на английском и на русском языках, - такие языки и возвращаю в методе.

Локализация

В методах может возникнуть потребность в каких-то языковых фразах. Если на сайте порядок в коде и есть необходимость локализации сообщений на несколько языков - надо добавить переводы. В моём примере файлы переводов - /bitrix/php_interface/lang/ru/classes/Gazmyas/Geo/LocalMaxMind.php и /bitrix/php_interface/lang/ru/classes/Gazmyas/Geo/LocalSypexGeo.php. Содержимое файла локализации обычное для битрикса:
<?php
$MESS['GAZMYAS_SRV_GEOIP_LOCAL_SYPEX_TITLE'] = 'Локальная база sypexgeo.net';
$MESS['GAZMYAS_SRV_GEOIP_LOCAL_SYPEX_DESCRIPTION'] = 'Локальная база sypexgeo.net. Хранится в файле /upload/SxGeoCity.dat';
$MESS['GAZMYAS_SRV_GEOIP_EMPTY_IP'] = 'Не задан IP адрес';
$MESS['GAZMYAS_SRV_GEOIP_BASE_OUTDATED'] = 'Файл данных Sypex устарел';
$MESS['GAZMYAS_SRV_GEOIP_NO_BASE'] = 'Файл данных Sypex не найден';
$MESS['GAZMYAS_SRV_GEOIP_LOAD_BASE_ERROR'] = 'Не удалось сформировать объект: ';

Основной метод обработки

Это всё была подготовка. Основным же методом, где происходит вся магия, является getDataResult(). Этот метод на вход получает IP адрес, а на выходе выдёт набор данных геоинформации. Хвала богам, битрикс отошёл от практики возврата результата в виде произольного массива ($arResult не пройдёт). Тут надо вернуть данные в виде объекта Bitrix\Main\Service\GeoIp\Data (тут чисто гео-данные: название региона, кординаты и пр.), но не в чистом виде, а обёрнутым в объект Bitrix\Main\Service\GeoIp\Result (тут флаг результата, ошибки и прочее вспомогальное). Строгий перечень возможных данных позволяет всё множество различных сервисов геоданных привести к единому виду. Все данные не обязательны. Заполняйте то, что можете или то, что требуется на вашем сайте. Если вам нужны только географические координаты по IP - не обязательно стараться вписать в код возврат всех возможных данных, достаточно будет только нужных вам координат и всё.
Код моего метода смотри ниже.

Локальная база sypexgeo.net

Основная идея этого сервиса геолокации - уйти от взаимодействия с сервисом через API в пользу локальных данных (убрать межсайтовое сетевое взаимодействие). С сайта можно бесплатно скачать базу в виде бинарного файла. Файл можно куда-то разместить на сайте (в примере - в каталоге /upload). Но файл довольно большой (37 Mb). На каждого нового клиента на сервере делать операцию чтения на несколько десятков мегабайт - так себе идея. Но распарсить этот бинарный файл и перекачать его в БД я не пытался (это довольно трудно, хотя и есть официальное описание формата). Поэтому использовать напрямую этот пример на боевом сайте я не рекомендую. Можно улучшить ситуацию, разместив, например, этот файл в оперативной памяти (например, в разделе tmpfs сервера), но эта магия тут не освещена.
Таким образом, для обновления локальной базы будет достаточно закачать себе на сервер обновлённый файлик базы sypexgeo. Главное не забывать это делать хотя бы каждые полгода.
Для работы с бинарным форматом базы пришлось использовать и допилить библиотеку, которую предлагают на сайте самого sypexgeo. Доработка заключалась в том, чтобы это впихнуть в пространство имён на сайте (файл /bitrix/php_interface/classes/Gazmyas/Partners/SxGeo.php). Таже добавлена строгая типизация и добавлено выбрасывание исключения при ошибках обработки файла (а не die() - как было в родном варианте).

Локальная база maxmind.com

В этом самописном сервисе такая же цель - уйти от сетевых издержек. Базу этого сервиса вполне реально закачать себе в СУБД. Тут не будет показано, как это можно сделать. Но тут всё гораздо проще по сравнению с предыдущим сервисом. Тут данные идут в простых CSV файлах. Чтобы их скачать бесплатно - придётся зарегистрироваться на сервисе. В моём коде в локальную СУБД были перекачаны только данные по названиям населённых пунктов России и географические координаты с пулами IP адресов. На мой субъективный взгляд, бестплатная база этого сервиса значительно хуже качеством, чем бесплатная база sypexgeo.

Как этим пользоваться

Вот так можно получить по IP название населённого пункта на русском языке и географические координаты (результат даст первый из подключенных в битриксе сервис, где найдутся все запрашиваемые данные).
<?php
if (empty($_SERVER['DOCUMENT_ROOT'])) {
    $_SERVER['DOCUMENT_ROOT'] = __DIR__;
}
require $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php';

use \Bitrix\Main\Service\GeoIp;

$ipAddress = '185.193.198.34';

echo '<pre>$ipAddress = ';
print_r($ipAddress);
echo '</pre>';

$result = GeoIp\Manager::getDataResult(
    $ipAddress,
    'ru',
    [
        'cityName',
        'latitude',
        'longitude',
    ]
);
echo '<pre>$result = ';
print_r($result);
echo '</pre>';

Полный файл LocalSypexGeo.php

Привожу тут полный вид примера самописной реализации геолокации для локальной базы сервиса sypexgeo.net. Внимание! В битриксе это работает нормально только на PHP 7.4 (видимо, тут что-то кардинально изменилось в обработке mbstring.func_overload).
<?php
declare(strict_types=1);
namespace Gazmyas\Geo;

use Bitrix\Main\Application;
use Bitrix\Main\Error;
use Bitrix\Main\EventResult;
use Bitrix\Main\Service\GeoIp\Base;
use Bitrix\Main\Type\DateTime;
use Bitrix\Main\ErrorCollection;
use Bitrix\Main\IO\File;
use Bitrix\Main\Localization\Loc;
use Bitrix\Main\Service\GeoIp\Data;
use Bitrix\Main\Service\GeoIp\Result;
use Gazmyas\Partners\SxGeo;

Loc::loadMessages(__FILE__);

/**
 * Класс для работы с данными геолокации
 * от сервиса sypexgeo.net
 * из локального файла данных
 */
class LocalSypexGeo extends Base
{
    /**
     * Параметр сортировки этого метода геолокации
     * по отношению к другим аналогичным методам.
     * Чем меньше значение, тем больше приоритет.
     *
     * @var integer
     */
    protected $sort = 200;

    /**
     * Флаг, активен ли обработчик геолокации
     *
     * @var boolean
     */
    protected $active = true;

    /** @var ErrorCollection */
    protected $errors;

    /**
     * Base constructor.
     *
     * @param array $arFields DB fields of handlers settings.
     */
    public function __construct(array $arFields = [])
    {
        $this->errors = new ErrorCollection();
        if (!isset($arFields['SORT'])) {
            $arFields['SORT'] = $this->sort;
        }
        if (!isset($arFields['ACTIVE'])) {
            $arFields['ACTIVE'] = $this->active ? 'Y' : 'N';
        }

        parent::__construct($arFields);
    }

    /**
     * @return integer field sorting.
     */
    public function getSort()
    {
        return $this->sort;
    }

    /**
     * Is this handler installed and ready for using.
     * @return bool
     */
    public function isInstalled()
    {
        return true;
    }

    /**
     * @return bool Is handler active, or not.
     */
    public function isActive()
    {
        return $this->active;
    }

    /**
     * Languages supported by handler ISO 639-1
     * @return array
     */
    public function getSupportedLanguages()
    {
        return ['en', 'ru'];
    }

    /**
     * @return string Title of handler.
     */
    public function getTitle()
    {
        return Loc::getMessage('GAZMYAS_SRV_GEOIP_LOCAL_SYPEX_TITLE');
    }

    /**
     * @return string Handler description.
     */
    public function getDescription()
    {
        return Loc::getMessage('GAZMYAS_SRV_GEOIP_LOCAL_SYPEX_DESCRIPTION');
    }

    /**
     * Метод возвращает результат определения гео-данных
     *
     * @param string $ip   Ip address
     * @param string $lang Language identifier
     *
     * @return Result|null
     */
    public function getDataResult($ip, $lang = '')
    {
        $dataResult = new Result();
        $geoData = new Data();

        $geoData->ip = $ip;
        $lang = empty($lang) ? 'ru' : $lang;
        $geoData->lang = $lang;

        if ($ip) {
            $obFile = new File(Application::getDocumentRoot() . '/upload/SxGeoCity.dat');
            if ($obFile->isExists()) {
                $modifiedAt = $obFile->getModificationTime();
                $obDateTime = new DateTime();
                $obDateTime->add('-3 years');
                if ($modifiedAt > $obDateTime->getTimestamp()) {
                    $obSxGeo = null;
                    try {
                        $obSxGeo = new SxGeo($obFile->getPath());
                    } catch (\Exception $exception) {
                        $log = new \Gazmyas\Log\Error();
                        $log->add(
                            [
                                'id'       => 'SxGeo',
                                'old'      => Loc::getMessage('GAZMYAS_SRV_GEOIP_LOAD_BASE_ERROR')
                                    . $exception->getMessage(),
                                'new'      => debug_backtrace(),
                                'logLevel' => 'ERROR'
                            ]
                        );
                        $this->addError(
                            Loc::getMessage('GAZMYAS_SRV_GEOIP_LOAD_BASE_ERROR')
                        );
                    }

                    if (null !== $obSxGeo
                        && ($data = $obSxGeo->getCityFull($ip))
                    ) {
                        $name = 'name_' . $lang;
                        if (isset($data['country'][$name])) {
                            $geoData->countryName = $data['country'][$name];
                        }
                        if (isset($data['country']['iso'])) {
                            $geoData->countryCode = $data['country']['iso'];
                        }
                        if (isset($data['region'][$name])) {
                            $geoData->regionName = $data['region'][$name];
                        }
                        if (isset($data['region']['iso'])) {
                            $geoData->regionCode = $data['region']['iso'];
                        }
                        if (isset($data['city'][$name])) {
                            $geoData->cityName = $data['city'][$name];
                        }
                        if (isset($data['city']['lat'])) {
                            $geoData->latitude = $data['city']['lat'];
                        }
                        if (isset($data['city']['lon'])) {
                            $geoData->longitude = $data['city']['lon'];
                        }
                        if (isset($data['region']['timezone'])) {
                            $geoData->timezone = $data['region']['timezone'];
                        }
                    }
                } else {
                    $this->addError(Loc::getMessage('GAZMYAS_SRV_GEOIP_BASE_OUTDATED'));
                }
            } else {
                $this->addError(Loc::getMessage('GAZMYAS_SRV_GEOIP_NO_BASE'));
            }
        } else {
            $this->addError(Loc::getMessage('GAZMYAS_SRV_GEOIP_EMPTY_IP'));
        }

        if ($this->errors->toArray()) {
            $dataResult->addErrors(
                $this->getErrors()
            );
        }

        $dataResult->setGeoData($geoData);

        return $dataResult;
    }

    /**
     * Returns an array of Error objects.
     *
     * @return Error[]
     */
    public function getErrors(): array
    {
        return $this->errors->toArray();
    }

    /**
     * Метод добавляет ошибку в стек ошибок объекта
     *
     * @param string $error Текст ошибки
     */
    public function addError(string $error)
    {
        $this->errors->add([new Error($error)]);
    }

    /**
     * Подключение собственного обработчика геолокации в битриксе
     *
     * @return EventResult
     */     public static function getGeoIpHandlers()     {         return new EventResult(             EventResult::SUCCESS,             [                 __CLASS__ => 'bitrix/php_interface/classes/Gazmyas/Geo/LocalSypexGeo.php',             ]         );     } }

Допиленная библиотека для SxGeoCity.dat

<?php
declare(strict_types=1);

/**
 * Скрипт скопирован из примера на сайте sypexgeo.net
 * Внесено в пространство имён для общей автозагрузки класса.
 * Приведены типы данных к строгому виду.
 * Добавлено выбрасывание исключения вместо завершения работы -
 * в случае ошибок чтения файла базы
 */

namespace Gazmyas\Partners;

/***************************************************************************\
| Sypex Geo                  version 2.2.3                                  |
| (c)2006-2014 zapimir       zapimir@zapimir.net       http://sypex.net/    |
| (c)2006-2014 BINOVATOR     info@sypex.net                                 |
|---------------------------------------------------------------------------|
|     created: 2006.10.17 18:33              modified: 2014.06.20 18:57     |
|---------------------------------------------------------------------------|
| Sypex Geo is released under the terms of the BSD license                  |
|   http://sypex.net/bsd_license.txt                                        |
\***************************************************************************/

define('SXGEO_FILE', 0);
define('SXGEO_MEMORY', 1);
define('SXGEO_BATCH', 2);
class SxGeo
{
    protected $fh;
    protected $ip1c;
    protected $info;
    protected $range;
    protected $db_begin;
    protected $b_idx_str;
    protected $m_idx_str;
    protected $b_idx_arr;
    protected $m_idx_arr;
    protected $m_idx_len;
    protected $db_items;
    protected $country_size;
    protected $db;
    protected $regions_db;
    protected $cities_db;

    public $id2iso = array(
        '', 'AP', 'EU', 'AD', 'AE', 'AF', 'AG', 'AI', 'AL', 'AM', 'CW', 'AO', 'AQ', 'AR', 'AS', 'AT', 'AU',
        'AW', 'AZ', 'BA', 'BB', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BM', 'BN', 'BO', 'BR', 'BS',
        'BT', 'BV', 'BW', 'BY', 'BZ', 'CA', 'CC', 'CD', 'CF', 'CG', 'CH', 'CI', 'CK', 'CL', 'CM', 'CN',
        'CO', 'CR', 'CU', 'CV', 'CX', 'CY', 'CZ', 'DE', 'DJ', 'DK', 'DM', 'DO', 'DZ', 'EC', 'EE', 'EG',
        'EH', 'ER', 'ES', 'ET', 'FI', 'FJ', 'FK', 'FM', 'FO', 'FR', 'SX', 'GA', 'GB', 'GD', 'GE', 'GF',
        'GH', 'GI', 'GL', 'GM', 'GN', 'GP', 'GQ', 'GR', 'GS', 'GT', 'GU', 'GW', 'GY', 'HK', 'HM', 'HN',
        'HR', 'HT', 'HU', 'ID', 'IE', 'IL', 'IN', 'IO', 'IQ', 'IR', 'IS', 'IT', 'JM', 'JO', 'JP', 'KE',
        'KG', 'KH', 'KI', 'KM', 'KN', 'KP', 'KR', 'KW', 'KY', 'KZ', 'LA', 'LB', 'LC', 'LI', 'LK', 'LR',
        'LS', 'LT', 'LU', 'LV', 'LY', 'MA', 'MC', 'MD', 'MG', 'MH', 'MK', 'ML', 'MM', 'MN', 'MO', 'MP',
        'MQ', 'MR', 'MS', 'MT', 'MU', 'MV', 'MW', 'MX', 'MY', 'MZ', 'NA', 'NC', 'NE', 'NF', 'NG', 'NI',
        'NL', 'NO', 'NP', 'NR', 'NU', 'NZ', 'OM', 'PA', 'PE', 'PF', 'PG', 'PH', 'PK', 'PL', 'PM', 'PN',
        'PR', 'PS', 'PT', 'PW', 'PY', 'QA', 'RE', 'RO', 'RU', 'RW', 'SA', 'SB', 'SC', 'SD', 'SE', 'SG',
        'SH', 'SI', 'SJ', 'SK', 'SL', 'SM', 'SN', 'SO', 'SR', 'ST', 'SV', 'SY', 'SZ', 'TC', 'TD', 'TF',
        'TG', 'TH', 'TJ', 'TK', 'TM', 'TN', 'TO', 'TL', 'TR', 'TT', 'TV', 'TW', 'TZ', 'UA', 'UG', 'UM',
        'US', 'UY', 'UZ', 'VA', 'VC', 'VE', 'VG', 'VI', 'VN', 'VU', 'WF', 'WS', 'YE', 'YT', 'RS', 'ZA',
        'ZM', 'ME', 'ZW', 'A1', 'XK', 'O1', 'AX', 'GG', 'IM', 'JE', 'BL', 'MF', 'BQ', 'SS'
    );

    public $batch_mode  = false;
    public $memory_mode = false;

    /**
     * SxGeo constructor.
     *
     * @param string|null $db_file
     * @param int|null $type
     *
     * @throws
     */
    public function __construct(
        ?string $db_file = 'SxGeo.dat',
        ?int $type = SXGEO_FILE
    ) {
        $resOpen = fopen($db_file, 'rb');
        if (false !== $resOpen) {
            $this->fh = $resOpen;
            // Сначала убеждаемся, что есть файл базы данных
            $header = fread($this->fh, 40);
            if (false !== $header) {
                // В версии 2.2 заголовок увеличился на 8 байт
                if (strpos($header, 'SxG') !== 0) {
                    throw new \RuntimeException('Wrong format of file', 2001);
                }
                $info = unpack(
                    'Cver/Ntime/Ctype/Ccharset/Cb_idx_len/nm_idx_len/nrange/Ndb_items/Cid_len/nmax_region/nmax_city/Nregion_size/Ncity_size/nmax_country/Ncountry_size/npack_size',
                    substr($header, 3)
                );
                if (isset($info['b_idx_len'], $info['m_idx_len'], $info['range'], $info['db_items'], $info['time'], $info['id_len'])
                ) {
                    if ($info['b_idx_len'] * $info['m_idx_len'] * $info['range'] * $info['db_items'] * $info['time'] * $info['id_len'] === 0) {
                        throw new \RuntimeException('Wrong file format', 2003);
                    }
                } else {
                    throw new \RuntimeException('Wrong file format', 2002);
                }
            } else {
                throw new \RuntimeException('Can not read file');
            }
        } else {
            throw new \RuntimeException('Can not open file');
        }
        $this->range       = $info['range'];
        $this->b_idx_len   = $info['b_idx_len'];
        $this->m_idx_len   = $info['m_idx_len'];
        $this->db_items    = $info['db_items'];
        $this->id_len      = $info['id_len'];
        $this->block_len   = 3 + $this->id_len;
        $this->max_region  = $info['max_region'];
        $this->max_city    = $info['max_city'];
        $this->max_country = $info['max_country'];
        $this->country_size= $info['country_size'];
        $this->batch_mode  = $type & SXGEO_BATCH;
        $this->memory_mode = $type & SXGEO_MEMORY;
        $this->pack        = $info['pack_size'] ? explode("\0", fread($this->fh, $info['pack_size'])) : '';
        $this->b_idx_str   = fread($this->fh, $info['b_idx_len'] * 4);
        $this->m_idx_str   = fread($this->fh, $info['m_idx_len'] * 4);

        $this->db_begin = ftell($this->fh);
        if ($this->batch_mode) {
            $this->b_idx_arr = array_values(unpack("N*", $this->b_idx_str)); // Быстрее в 5 раз, чем с циклом
            unset($this->b_idx_str);
            $this->m_idx_arr = str_split($this->m_idx_str, 4); // Быстрее в 5 раз чем с циклом
            unset($this->m_idx_str);
        }

        if ($this->memory_mode) {
            if (false !== ($resRead = fread($this->fh, $this->db_items * $this->block_len))) {
                $this->db = $resRead;
            } else {
                throw new \RuntimeException('Can not read block of file');
            }
            $this->regions_db = $info['region_size'] > 0 ? fread($this->fh, $info['region_size']) : '';
            $this->cities_db  = $info['city_size'] > 0 ? fread($this->fh, $info['city_size']) : '';
        }
        $this->info = $info;
        $this->info['regions_begin'] = $this->db_begin + $this->db_items * $this->block_len;
        $this->info['cities_begin']  = $this->info['regions_begin'] + $info['region_size'];
    }

    /**
     * @param string $ipn
     * @param float $min
     * @param float $max
     * @return float|int
     */
    protected function search_idx(string $ipn, float $min, float $max)
    {
        if ($this->batch_mode) {
            while ($max - $min > 8) {
                $offset = ($min + $max) >> 1;
                if ($ipn > $this->m_idx_arr[$offset]) {
                    $min = $offset;
                } else {
                    $max = $offset;
                }
            }
            while ($ipn > $this->m_idx_arr[$min] && $min++ < $max) {
            }
        } else {
            while ($max - $min > 8) {
                $offset = ($min + $max) >> 1;
                if ($ipn > substr($this->m_idx_str, $offset*4, 4)) {
                    $min = $offset;
                } else {
                    $max = $offset;
                }
            }
            while ($ipn > substr($this->m_idx_str, (int) $min * 4, 4) && $min++ < $max) {
            }
        }
        return  $min;
    }

    /**
     * @param string $str
     * @param string $ipn
     * @param float $min
     * @param float $max
     *
     * @return bool|float|int
     */
    protected function search_db(
        string $str,
        string $ipn,
        float $min,
        float $max
    ) {
        $result = false;

        if ($max - $min > 1) {
            $ipn = substr($ipn, 1);
            while ($max - $min > 8) {
                $offset = ($min + $max) >> 1;
                if ($ipn > substr($str, $offset * $this->block_len, 3)) {
                    $min = $offset;
                } else {
                    $max = $offset;
                }
            }
            while ($ipn >= substr($str, (int) $min * $this->block_len, 3) && ++$min < $max) {
            }
        } else {
            $min++;
        }

        $res = substr(
            $str,
            (int) ($min * $this->block_len - $this->id_len),
            $this->id_len
        );
        if (false !== $res) {
            return hexdec(bin2hex($res));
        }

        return $result;
    }

    /**
     * @param string $ip
     *
     * @return false|float|int
     */
    public function get_num(string $ip)
    {
        $ip1n = (int)$ip; // Первый байт
        if ($ip1n === 0
            || $ip1n === 10
            || $ip1n === 127
            || $ip1n >= $this->b_idx_len
            || false === ($ipn = ip2long($ip))
        ) {
            return false;
        }

        $ipn = pack('N', $ipn);
        $this->ip1c = chr($ip1n);
        // Находим блок данных в индексе первых байт
        if ($this->batch_mode) {
            $blocks = [
                'min' => $this->b_idx_arr[$ip1n-1],
                'max' => $this->b_idx_arr[$ip1n]
            ];
        } else {
            $resSubstring = substr($this->b_idx_str, ($ip1n - 1) * 4, 8);
            $blocks = [];
            if (false !== $resSubstring) {
                $blocks = unpack(
                    "Nmin/Nmax",
                    substr($this->b_idx_str, ($ip1n - 1) * 4, 8)
                );
            }
        }
        if ($blocks['max'] - $blocks['min'] > $this->range) {
            // Ищем блок в основном индексе
            $part = $this->search_idx(
                $ipn,
                floor($blocks['min'] / $this->range),
                floor($blocks['max'] / $this->range) - 1
            );
            // Нашли номер блока в котором нужно искать IP, теперь находим нужный блок в БД
            $min = $part > 0 ? $part * $this->range : 0;
            $max = $part > $this->m_idx_len ? $this->db_items : ($part+1) * $this->range;
            // Нужно проверить чтобы блок не выходил за пределы блока первого байта
            if ($min < $blocks['min']) {
                $min = $blocks['min'];
            }
            if ($max > $blocks['max']) {
                $max = $blocks['max'];
            }
        } else {
            $min = $blocks['min'];
            $max = $blocks['max'];
        }
        $len = $max - $min;
        // Находим нужный диапазон в БД
        if ($this->memory_mode) {
            return $this->search_db(
                $this->db,
                $ipn,
                $min,
                $max
            );
        } else {
            fseek(
                $this->fh,
                (int) ($this->db_begin + $min * $this->block_len)
            );

            $resRead = fread(
                $this->fh,
                (int) ($len * $this->block_len)
            );
            if (false !== $resRead) {
                return $this->search_db(
                    $resRead,
                    $ipn,
                    0,
                    $len
                );
            }
        }

        return false;
    }

    protected function readData($seek, $max, int $type)
    {
        $raw = '';
        if ($seek && $max) {
            if ($this->memory_mode) {
                $raw = substr($type === 1 ? $this->regions_db : $this->cities_db, $seek, $max);
            } else {
                fseek($this->fh, $this->info[$type === 1 ? 'regions_begin' : 'cities_begin'] + $seek);
                $raw = fread($this->fh, $max);
            }
        }
        return $this->unpack($this->pack[$type], $raw);
    }

    protected function parseCity($seek, ?bool $full = false)
    {
        if (!$this->pack) {
            return false;
        }
        $only_country = false;
        if ($seek < $this->country_size) {
            $country = $this->readData($seek, $this->max_country, 0);
            $city = $this->unpack($this->pack[2]);
            $city['lat'] = $country['lat'];
            $city['lon'] = $country['lon'];
            $only_country = true;
        } else {
            $city = $this->readData($seek, $this->max_city, 2);
            $country = array('id' => $city['country_id'], 'iso' => $this->id2iso[$city['country_id']]);
            unset($city['country_id']);
        }
        if ($full) {
            $region = $this->readData($city['region_seek'], $this->max_region, 1);
            if (!$only_country) {
                $country = $this->readData($region['country_seek'], $this->max_country, 0);
            }
            unset($city['region_seek']);
            unset($region['country_seek']);
            return array('city' => $city, 'region' => $region, 'country' => $country);
        } else {
            unset($city['region_seek']);
            return array('city' => $city, 'country' => array('id' => $country['id'], 'iso' => $country['iso']));
        }
    }

    protected function unpack($pack, $item = '')
    {
        $unpacked = array();
        $empty = empty($item);
        $pack = explode('/', $pack);
        $pos = 0;
        foreach ($pack as $p) {
            [$type, $name] = explode(':', $p);
            $type0 = $type{0};
            if ($empty) {
                $unpacked[$name] = $type0 == 'b' || $type0 == 'c' ? '' : 0;
                continue;
            }
            switch ($type0) {
                case 't':
                case 'T': $l = 1; break;
                case 's':
                case 'n':
                case 'S': $l = 2; break;
                case 'm':
                case 'M': $l = 3; break;
                case 'd': $l = 8; break;
                case 'c': $l = (int)substr($type, 1); break;
                case 'b': $l = strpos($item, "\0", $pos)-$pos; break;
                default: $l = 4;
            }
            $val = substr($item, $pos, $l);
            switch ($type0) {
                case 't': $v = unpack('c', $val); break;
                case 'T': $v = unpack('C', $val); break;
                case 's': $v = unpack('s', $val); break;
                case 'S': $v = unpack('S', $val); break;
                case 'm': $v = unpack('l', $val . (ord($val{2}) >> 7 ? "\xff" : "\0")); break;
                case 'M': $v = unpack('L', $val . "\0"); break;
                case 'i': $v = unpack('l', $val); break;
                case 'I': $v = unpack('L', $val); break;
                case 'f': $v = unpack('f', $val); break;
                case 'd': $v = unpack('d', $val); break;

                case 'n': $v = current(unpack('s', $val)) / pow(10, $type{1}); break;
                case 'N': $v = current(unpack('l', $val)) / pow(10, $type{1}); break;

                case 'c': $v = rtrim($val, ' '); break;
                case 'b': $v = $val; $l++; break;
            }
            $pos += $l;
            $unpacked[$name] = is_array($v) ? current($v) : $v;
        }
        return $unpacked;
    }

    public function get(string $ip)
    {
        return $this->max_city ? $this->getCity($ip) : $this->getCountry($ip);
    }
    public function getCountry(string $ip)
    {
        if ($this->max_city) {
            $tmp = $this->parseCity($this->get_num($ip));
            return $tmp['country']['iso'];
        } else {
            return $this->id2iso[$this->get_num($ip)];
        }
    }

    public function getCountryId(string $ip)
    {
        if ($this->max_city) {
            $tmp = $this->parseCity($this->get_num($ip));
            return $tmp['country']['id'];
        } else {
            return $this->get_num($ip);
        }
    }

    public function getCity(string $ip)
    {
        $seek = $this->get_num($ip);
        return $seek ? $this->parseCity($seek) : false;
    }

    public function getCityFull(string $ip)
    {
        $seek = $this->get_num($ip);
        return $seek ? $this->parseCity($seek, true) : false;
    }

    public function about()
    {
        $charset = array('utf-8', 'latin1', 'cp1251');
        $types   = array('n/a', 'SxGeo Country', 'SxGeo City RU', 'SxGeo City EN', 'SxGeo City', 'SxGeo City Max RU', 'SxGeo City Max EN', 'SxGeo City Max');
        return array(
            'Created' => date('Y.m.d', $this->info['time']),
            'Timestamp' => $this->info['time'],
            'Charset' => $charset[$this->info['charset']],
            'Type' => $types[$this->info['type']],
            'Byte Index' => $this->b_idx_len,
            'Main Index' => $this->m_idx_len,
            'Blocks In Index Item' => $this->range,
            'IP Blocks' => $this->db_items,
            'Block Size' => $this->block_len,
            'City' => array(
                'Max Length' => $this->max_city,
                'Total Size' => $this->info['city_size'],
            ),
            'Region' => array(
                'Max Length' => $this->max_region,
                'Total Size' => $this->info['region_size'],             ),             'Country' => array(                 'Max Length' => $this->max_country,                 'Total Size' => $this->info['country_size'],             ),         );     } }

Полный файл LocalMaxMind.php

<?php
declare(strict_types=1);
namespace Gazmyas\Geo;

use Bitrix\Main\Application;
use Bitrix\Main\Error;
use Bitrix\Main\ErrorCollection;
use Bitrix\Main\EventResult;
use Bitrix\Main\Localization\Loc;
use Bitrix\Main\Service\GeoIp\Base;
use Bitrix\Main\Service\GeoIp\Data;
use Bitrix\Main\Service\GeoIp\Result;

Loc::loadMessages(__FILE__);

/**
 * Класс для работы с данными геолокации
 * от сервиса maxmind.com
 * из локальной базы данных
 */
class LocalMaxMind extends Base
{
    /**
     * Параметр сортировки этого метода геолокации
     * по отношению к другим аналогичным методам.
     * Чем меньше значение, тем больше приоритет
     *
     * @var integer
     */
    protected $sort = 30;

    /**
     * Флаг, активен ли обработчик геолокации
     *
     * @var boolean
     */
    protected $active = true;

    /** @var ErrorCollection */
    protected $errors;

    /**
     * Base constructor.
     *
     * @param array $arFields DB fields of handlers settings.
     */
    public function __construct(array $arFields = [])
    {
        $this->errors = new ErrorCollection();
        if (!isset($arFields['SORT'])) {
            $arFields['SORT'] = $this->sort;
        }
        if (!isset($arFields['ACTIVE'])) {
            $arFields['ACTIVE'] = $this->active ? 'Y' : 'N';
        }

        parent::__construct($arFields);
    }

    /**
     * @return integer field sorting.
     */
    public function getSort()
    {
        return $this->sort;
    }

    /*
     * Is this handler installed and ready for using.
     * @return bool
     */
    public function isInstalled()
    {
        return true;
    }

    /**
     * @return bool Is handler active, or not.
     */
    public function isActive()
    {
        return $this->active;
    }

    /**
     * Languages supported by handler ISO 639-1
     *      * @return array
     */
    public function getSupportedLanguages()
    {
        return ['ru'];
    }

    /**
     * @return string Title of handler.
     */
    public function getTitle()
    {
        return Loc::getMessage('GAZMYAS_SRV_GEOIP_LOCAL_MAXMIND_TITLE');
    }

    /**      * @return string Handler description.
     */
    public function getDescription()
    {
        return Loc::getMessage('GAZMYAS_SRV_GEOIP_LOCAL_MAXMIND_DESCRIPTION');
    }

    /**
     * @param string $ip   Ip address      * @param string $lang Language identifier
     *
     * @return Result|null
     */
    public function getDataResult($ip, $lang = '')     {         $dataResult = new Result();         $geoData = new Data();         $geoData->ip = $ip;         $lang = empty($lang) ? 'ru' : $lang;         $geoData->lang = $lang;         if ($ip) {             $ipNumber = ip2long($ip);             $connection = Application::getConnection();             // выбираем запись, в которую попадает ip             // и с минимальным диапазоном на случай, если их будет несколько             $query = 'SELECT ip_start, ip_end, city, longitude, latitude             FROM ip2geo             WHERE ip_start <= ' . $ipNumber . ' && ip_end >= ' . $ipNumber . '             ORDER BY ip_end - ip_start, id DESC LIMIT 1';             try {                 $obResult = $connection->query($query);                 if ($row = $obResult->fetch()) {                     $geoData->cityName = $row['city'];                     $geoData->latitude = (float)$row['latitude'];                     $geoData->longitude = (float)$row['longitude'];                 }             } catch (\Exception $exception) {                 $this->addError(                     Loc::getMessage('GAZMYAS_SRV_GEOIP_SQL_ERROR')                     . $exception->getMessage()                 );             }         } else {             $this->addError(Loc::getMessage('GAZMYAS_SRV_GEOIP_EMPTY_IP'));         }         if ($this->errors->toArray()) {             $dataResult->addErrors(                 $this->getErrors()             );         }         $dataResult->setGeoData($geoData);         return $dataResult;     }     /**      * Returns an array of Error objects.      *      * @return \Bitrix\Main\Error[]      */     public function getErrors()     {         return $this->errors->toArray();     }     /**      * Метод добавляет ошибку в стек ошибок объекта      *      * @param string $error Текст ошибки      */     public function addError(string $error)     {         $this->errors->add([new Error($error)]);     }     /**      * Подключение собственного обработчика геолокации в битриксе      *      * @return EventResult      */     public function getGeoIpHandlers()     {         return new EventResult(             EventResult::SUCCESS,             [                 __CLASS__ => 'bitrix/php_interface/classes/Gazmyas/Geo/LocalMaxMind.php',             ]         );     } }

Комментарии

  1. На мобильных устройствах используя базы SypexGeo и MaxMind не корректно определяется город по IP. Я нахожусь в Тюмени, а у меня выводится Пермь. Более точный способ это использовать Geolocation API и обратное геокодирование от Яндекса. Вот пример для Гугла https://intuit.ru/studies/curriculums/4108/courses/535/lecture/23142?page=1
    Если сможете сделать новый обработчик в виде решения на marketplace было бы здорово.

    ОтветитьУдалить
    Ответы
    1. На самом деле точной геолокации по IP не существует в принципе. IP выделяются клиентам динамически. Всем этим сервисам остаётся только соревноваться, кто быстрее обновит собственную базу актуальными данными. Поэтому в реальных проектах лучше делать поэтапное определение региона пользователя. От более точных методов к менее точным. Ваш вариант - спросить у браузера пользователя - более точный. А если пользователь не разрешил отслеживание местоположения, или его браузер этого не поддерживает - тогда уже пытаться угадать по IP, как в статье. Как это можно было бы оформить в виде готового решения для битрикса - пока не знаю. Определение координат в статье - это серверный код, а ваш вариант - клиентский код. Они не пересекаются. Если только на клиенте из JS получать данные, отправлять через AJAX на сервер, а там уже вызывать свой обработчик геолокации, который не будет по IP ничего вычислять, а только сформирует объект полученными из браузера данными и запишет координаты в битриксовую cookie BX_MAIN_GEO_IP_DATA_XXX_XXX_XXX_XXX Подумаю

      Удалить

Отправить комментарий

Популярные сообщения из этого блога

Пропорциональное распределение суммы

Bitrix24 API - разбор демо приложения третьего типа