Как стать автором
Обновить

Подключаем онлайн-карты к навигатору на смартфоне. Часть 2 – векторные карты

Время на прочтение14 мин
Количество просмотров2.4K

Пишем серверное приложение, которое будет генерировать растровые PNG тайлы на основе векторных онлайн-карт. Использование веб-срейпинга с помощью Puppeteer для получения картографических данных.


Содержание:


1 – Вступление. Стандартные растровые карты
2 – Продолжение. Пишем простой растеризатор для векторных карт
3 – Частный случай. Подключаем карту OverpassTurbo


Продолжение


И вот мы добрались до наиболее интересной темы. Представьте, что мы нашли сайт с картой, которую очень хотим добавить в свой навигатор. Делаем все в соответствии с инструкцией из предыдущей части. Открываем просмотр содержимого сайта, а там нет картинок! Совсем. Ну пара иконок и все. И еще какой-то текстовый файлик со списком координат.


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


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


Однако, если же этот фокус не сработал, а получить эту карту все еще очень хочется, и притом, не в браузере смартфона, а именно в своем навигаторе, то способ есть.


Основная идея


Будем исходить из того, что мы хотим получить карту, которую можно будет открыть в любом из навигаторов. Тогда нам потребуется адаптер – этакий посредник, который будет генерировать для нас тайлы в формате PNG.


Выходит, что нужно изобрести велосипед разработать еще один движок для визуализации векторных данных. Ну, или же можно написать скрипт, который будет заходить на сайт, предоставив тому самостоятельно отрисовывать свою векторную карту. А затем он дождется загрузки, сделает скриншот, обрежет и вернет пользователю. Пожалуй, я выберу второй вариант.


Чтобы делать скриншоты я буду использовать “браузер на дистанционном управлении” – Headless Chrome. Управлять им можно при помощи node js библиотеки Puppeteer. Узнать про основы работы с этой библиотекой можно из этой статьи.


Hello, World! Или создаем и настраиваем проект


Если у вас еще не установлен Node.js то зайдите на эту или эту страницу, выберите свою операционную систему и проведите установку согласно инструкции.


Создаем новую папку для проекта и открываем ее в терминале.


$ cd /Mapshoter_habr

Запускаем менеджер создания нового проекта


$ npm init

Здесь вы можете указать имя проекта (package name), названия файла входа в приложение (entry point) и имя автора (author). Для всех остальных запросов соглашаемся на параметры по умолчанию: ничего не вводим и просто нажимаем Enter. В конце – нажимаем y и Enter.


Далее установим необходимые для работы фреймворки. Express для создания сервера и Puppeteer для работы с браузером.


$ npm install express
$ npm i puppeteer

В результате в папке проекта появится файл конфигурации проекта package.json. В моем случае такой:


{
  "name": "mapshoter_habr",
  "version": "1.0.0",
  "description": "",
  "main": "router.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "nnngrach",
  "license": "ISC",
  "dependencies": {
    "express": "^4.17.1",
    "puppeteer": "^1.18.1"
  }
}

Добавлю в раздел scripts строчку start для более удобного запуска нашего приложения.


"scripts": {
    "start": "node router.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },

Теперь создадим два файла с реализацией базового функционала. Первый файл – это точка входа в приложение. В моем случае – router.js. Он будет создавать сервер и заниматься маршрутизацией.


// Подключить необходимые для работы библиотеки и модули
const express = require( 'express' )
const mapshoter = require( './mapshoter' )

// Получить порт, на котором будет работать наш сервер
const PORT = process.env.PORT || 5000

// Создать и запустить сервер
const app = express()

app.listen( PORT, () => {
    console.log( 'Сервер создан на порту ', PORT )
})

// Обработка входящих запрсов по адресу типа
// http://siteName.com/x/y/z 

app.get( '/:x/:y/:z', async ( req, res, next ) => {

    // Получить значения из параметров запроса
    const x = req.params.x
    const y = req.params.y
    const z = req.params.z

    // Запустить метод для получения тайла
    const screenshot = await mapshoter.makeTile( x, y, z )

    // Переобразовать данные в пригодный для передачи формат
    const imageBuffer = Buffer.from( screenshot, 'base64' )

    // Подготовить заголовки ответа
    res.writeHead( 200, {
        'Content-Type': 'image/png',
        'Content-Length': imageBuffer.length
    })

    // Вернуть пользователю изображение
    res.end( imageBuffer )
})

Теперь создадим второй файл. Он будет управлять браузером и делать скриншоты. У меня он называется mapshoter.js.


const puppeteer = require( 'puppeteer' )

async function makeTile( x, y, z ) {

    // Запустить браузер
    const browser = await puppeteer.launch()

    // Открыть новую вкладку и загрузить сайт
    const page = await browser.newPage()
    await page.goto( 'https://www.google.ru/' )

    // Сделать скриншот страницы
    const screenshot = await page.screenshot()

    // Закрыть браузер и вернуть скриншот
    await browser.close()
    return screenshot
}

module.exports.makeTile = makeTile

Запустим наш скрипт и проверим его работоспособность. Для этого наберем в консоли:


$ npm start


Появится сообщение, что “Сервер создан на порту 5000”. Теперь откройте браузер на компьютере и перейдите по локальному адресу нашего сервера. Вместо координат x, y, z можно вводить любые числа. Я ввел 1, 2, 3.


http://localhost:5000/1/2/3


Если все сделано правильно, то появится скриншот сайта Google.


image


Нажмите в консоли Ctrl+C, чтобы остановить наш скрипт.


Поздравляю, основа нашего приложения готова! Мы создали сервер, который принимает наши html запросы, делает скриншот и возвращает нам изображение. Теперь пора переходить к реализации деталей.


Рассчитаем координаты


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


Но сначала нужно рассчитать координаты центра тайла на основе его порядкового номера. Я буду это делать на основе формулы для нахождения левого-верхнего угла. Я переложил ее в функцию getCoordinates().


А так как для некоторых сайтов помимо центра тайла нужно указать еще и его границы, то я буду искать так же и их. Чтож, создадим для этих расчетов отдельный модуль под именем geoTools.js. Вот его код:


// Получить координаты левого-верхнего угла тайла
function getCoordinates( x, y, z ) {
  const n = Math.pow( 2, z )
  const lon = x / n * 360.0 - 180.0
  const lat = 180.0 * ( Math.atan( Math.sinh( Math.PI * ( 1 - 2 * y / n) ) ) ) / Math.PI
  return { lat: lat, lon: lon }
}

// Получить координаты центра тайла на основе координат его границ
function getCenter( left, rigth, top, bottom ) {
  let lat = ( left + rigth ) / 2
  let lon = ( top + bottom ) / 2
  return { lat: lat, lon: lon }
}

// Получить координаты границ тайла и его центра
function getAllCoordinates( stringX, stringY, stringZ ) {

  // Переводим текстовые координаты в числовые
  const x = Number( stringX )
  const y = Number( stringY )
  const z = Number( stringZ )

  // Получаем координаты границ тайла
  // из координат его левого-верхнего и правого-нижнего углов
  const topLeft = getCoordinates( x, y, z )
  const bottomRight = getCoordinates( x+1, y+1, z )

  // Находим центр
  const center = getCenter( topLeft.lat, bottomRight.lat, topLeft.lon, bottomRight.lon )

  // Возвращаем ответ
  const bBox = { latMin: bottomRight.lat, lonMin:  topLeft.lon, latMax:  topLeft.lat, lonMax:  bottomRight.lon }
  return { bBox: bBox, center: center }
}

module.exports.getAllCoordinates = getAllCoordinates

Теперь мы готовы приступить к реализации скрипта для работы с браузером. Рассмотрим несколько сценариев того, как это можно сделать.


Сценарий 1 – Поиск с помощью API


Начнем с наиболее простого случая, когда можно просто ввести координаты в URL страницы с картой. К примеру так:


https://nakarte.me/#m=5/50.28144/89.30666&l=O/Wp


Давайте посмотрим на скрипт. Просто замените удалите все содержимое файла mapshoter.js и вставьте код, который приведен ниже.


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


Далее мы рассчитываем координаты центра искомого тайла. Вставляем их в URL и переходим по нему. Тайл оказывается точно по центру экрана. Вырезаем кусок 256х256 пикселей. Это и будет тайл, который нам нужен. Остается только вернуть его пользователю.


Прежде чем перейти к коду, отмечу, что для наглядности из скрипта убрана вся обработка ошибок.


const puppeteer = require( 'puppeteer' )
const geoTools = require( './geoTools' )

async function makeTile( x, y, z ) {

  // Запустить браузер с настройками, позволяющими работать на Heroku
  const herokuDeploymentParams = {'args' : ['--no-sandbox', '--disable-setuid-sandbox']}
  const browser = await puppeteer.launch( herokuDeploymentParams )

  // Открыть новую вкладку и уменьшить размер окна
  // чтобы не загружалось слишком много тайлов
  const page = await browser.newPage()
  await page.setViewport( { width: 660, height: 400 } )

  // Рассчитать координаты центра тайла и вставить их в URL 
  const coordinates = geoTools.getAllCoordinates( x, y, z )
  const centerCoordinates = `${z}/${coordinates.center.lat}/${coordinates.center.lon}&l=`
  const pageUrl = 'https://nakarte.me/#m=' + centerCoordinates + "O/Wp"

  // Перейти по URL и дождаться, пока страница загрузится
  await page.goto( pageUrl, { waitUntil: 'networkidle0', timeout: 20000 } )

  // Сделать кадрированный скриншот
  const cropOptions = {
    fullPage: false,
    clip: { x: 202, y: 67, width: 256, height: 256 }
  }

  const screenshot = await page.screenshot( cropOptions )

  // Закрыть браузер и вернуть скриншот
  await browser.close()
  return screenshot
}

module.exports.makeTile = makeTile

Теперь запустим наш скрипт и посмотрим карту для этого участка.


http://localhost:5000/24/10/5


Если все сделано правильно, то сервер должен вернуть такой тайл:



Чтобы убедиться, что мы ничего не перепутали при обрезке, сравним наш тайл с оригиналом с сайта OpenStreetMaps.org



Сценарий 2 – Поиск с помощью интерфейса сайта


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


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


Так же в этом примере мы будем искать элементы интерфейса с помощью XPath селекторов. Но как их узнать?


Для этого нужно открыть в браузере требуемую страницу и открыть панель инструментов разработчика (Ctll+Alt+I для Google Chrome). Нажать на кнопку выбора элементов. Кликаем на интересующий вас элемент (я кликнул на поле поиска).



Список элементов промотается до того, на который вы кликнули и он подсветится синим. Нажмите на кнопку с тремя точками слева от названия.


Во всплывающем меню выберите пункт Copy. Далее, если вам нужен обычный селектор, то кликните Copy selector. Но для этого же примера будем использовать пункт Copy XPath.



Теперь замените содержимое файла mapshoter.js на этот код. В нем я уже собрал селекторы для всех необходимых элементов интерфейса.


const puppeteer = require( 'puppeteer' )
const geoTools = require( './geoTools' )

async function makeTile( x, y, z ) {

  // Селекторы для вызова элементов интерфейса
  const searchFieldXPath = '//*[@id="map"]/div[1]/div[1]/div/input'
  const zoomPlusXPath = '//*[@id="map"]/div[2]/div[2]/div[4]/div[1]/a[1]'
  const zoomMinusXPath = '//*[@id="map"]/div[2]/div[2]/div[4]/div[1]/a[2]'
  const directionButonXPath = '//*[@id="gtm-poi-card-get-directions"]'
  const deletePinButonXPatch = '//*[@id="map"]/div[1]/div/div/div[1]/div[2]/div/div[4]/div/div[4]'

  // Рассчитать координаты краев и центра области для загрузки (тайла)
  const coordinates = geoTools.getAllCoordinates( x, y, z )
  const centerCoordinates = `lat=${coordinates.center.lat} lng=${coordinates.center.lon}`

  // Запустить и настроить страницу браузера
  const herokuDeploymentParams = {'args' : ['--no-sandbox', '--disable-setuid-sandbox']}
  const browser = await puppeteer.launch( herokuDeploymentParams )
  const page = await browser.newPage()
  await page.setViewport( { width: 1100, height: 450 } )

  // Перейти на страницу с картой и дождаться загрузки
  const pageUrl = 'https://www.waze.com/en/livemap?utm_campaign=waze_website'
  await page.goto( pageUrl, { waitUntil: 'networkidle2', timeout: 10000 } )

  // Кликнуть на поле поиска, чтобы в нем появился курсор
  await click( searchFieldXPath, page )

  // Напечатать в поле поиска координаты центра тайла
  await page.keyboard.type( centerCoordinates )

  // Нажать Enter для начала поиска
  page.keyboard.press( 'Enter' );

  // Подождать 500 милисекунд для обновления страницы
  await page.waitFor( 500 )

  // Удалить появившийся по центру экрана маркер
  // Для этого нужно закрыть меню поиска
  await click( directionButonXPath, page )
  await page.waitFor( 100 )

  await click( deletePinButonXPatch, page )
  await page.waitFor( 100 )

  // Кликать на кнопки увеличения или уменьшения
  // пока текущий зум не станет соответствовать требуемому
  while( z > await fetchCurrentZoom( page )) {
    await click( zoomPlusXPath, page )
    await page.waitFor( 300 )
  }

  while( z < await fetchCurrentZoom( page )) {
    await click( zoomMinusXPath, page )
    await page.waitFor( 300 )
  }

  // Сделать кадрированный скриншот
  const cropOptions = {
    fullPage: false,
    clip: { x: 422, y: 97, width: 256, height: 256 }
  }

  const screenshot = await page.screenshot( cropOptions )

  // Завершение работы
  await browser.close()
  return screenshot
}

// Вспомогательные функции:

// Дождаться прогрузки элемента и кликнуть по нему
async function click( xPathSelector, page ) {
  await page.waitForXPath( xPathSelector )
  const foundedElements = await page.$x( xPathSelector )

  if ( foundedElements.length > 0 ) {
    await foundedElements[0].click()
  } else {
    throw new Error( "XPath element not found: ", xPathSelector )
  }
}

// Узнать текущий уровень зума из названия одного из html элементов
async function fetchCurrentZoom( page ) {
  const xPathSelector = '//*[@id="map"]/div[2]'

  await page.waitForXPath( xPathSelector )
  const elems = await page.$x(xPathSelector)

  const elementParams = await page.evaluate((...elems) => {
    return elems.map(e => e.className);
  }, ...elems);

  const zoom = elementParams[0].split('--zoom-').pop()
  return zoom
}

module.exports.makeTile = makeTile

Запустим наш скрипт и перейдем по ссылке. Если все сделано верно, то скрипт вернет нам примерно такой тайл.


http://localhost:5000/1237/640/11



Оптимизация


В принципе, двух вышеописанных методов вполне достаточно, чтобы подключиться к многим сайтам с векторными картами. Но если вдруг потребуется доступ к какой-нибудь новой карте, то нужно будет всего лишь незначительно изменить скрипт в файле mapshoter.js. То есть этот способ позволяет очень легко добавлять новые карты. Это из его плюсов.


Но есть и минусы. И главный из них – это скорость работы. Просто сравните. Чтобы скачать один обычный растровый тайл в среднем тратится около 0,5 секунд. В то время, как получение одного тайла от нашего скрипта в данный момент занимает около 8 секунд.


Но это еще не все! Мы же используем однопоточный node js и наши долгие запросы в итоге будут блокировать основной поток, что со стороны будет выглядеть как обычная синхронная очередь. И когда мы попытаемся загрузить карту для целого экрана (на который, к примеру, помещается 24 тайла), то есть риск столкнуться с проблемой.


И еще. У некоторых навигаторов есть таймаут: они будут прекращать загрузку после 30 секунд. А это значит, что при текущей реализации успеет загрузиться только 3-4 тайла. Чтож, давайте посмотрим, что можно с этим сделать.


Наверное, самый очевидный способ – это просто увеличить количество серверов, на которых будет работать наш скрипт. К примеру если у нас будет 10 серверов, то они вполне успеют обработать тайлы для всего экрана за 30 секунд. (Если не хочется платить много денег, то можно получить их зарегистрировав несколько бесплатных аккаунтов на Heroku)


Во вторых, реализовать многопоточнось на node js все таки возможно с помощью модуля worker_threads. По моим наблюдениям на сервере с одноядерным процессором на бесплатном аккаунте Heroku удается запустить три потока. Три потока с отдельным браузером в каждом, которые смогут работать одновременно не блокируя при этом друг друга. Справедливости ради замечу, что в результате возросшей нагрузки на процессор скорость загрузки одного тайла даже немного увеличилась. Однако, если попытаться загрузить карту для всего экрана, то по истечении 30-ти секунд успеет прогрузиться уже больше половины карты. Больше 12-ти тайлов. Уже лучше.


В третьих. В текущей реализации скрипта при каждом запросе мы тратим время на загрузку браузера Chrome, а потом – на его завершение. Теперь же мы создадим браузер заранее и будем передавать в mapshoter.js ссылку на него. В результате для первого запроса скорость не изменится. Но для всех последующих скорость загрузки одного тайла сокращается до 4-х секунд. А по истечению 30-ти секунд успевает загрузиться вся карта – все 24 тайла, которые помещаются у меня на экране.


Чтож, если реализовать это все, то скрипт может стать вполне жизнеспособным. Так что приступим. Для боле простой работы с многопоточностью я воспользуюсь модулем node-worker-threads-pool – своеобразной оберткой над worker_threads. Давайте его установим.


$ npm install node-worker-threads-pool --save


Подкорректируем файл router.js. Добавим в него создание пула потоков. Потоков будет 3 штуки. Их код будет описан в файле worker.js, на него мы посмотрим позже. А пока удалим запуск модуля скриншотов напрямую. Вместо него мы будем добавлять новую задачу в пул потоков. К ее обработке приступят, когда какой-нибудь из потоков освободится.


const express = require( 'express' )

const PORT = process.env.PORT || 5000
const app = express()
app.listen( PORT, () => {
    console.log( 'Сервер создан на порту ', PORT )
})

// Настройка многопоточного режима.
const { StaticPool } = require( 'node-worker-threads-pool' )
const worker = "./worker.js"
const workersPool = new StaticPool({
  size: 3,
  task: worker,
  workerData: "no"
})

app.get( '/:x/:y/:z', async ( req, res, next ) => {

    const x = req.params.x
    const y = req.params.y
    const z = req.params.z

    // Ставим новую задачу в пул потоков
    // и передаем им параметры для работы
    const screenshot = await workersPool.exec( { x, y, z } )

    const imageBuffer = Buffer.from( screenshot, 'base64' )

    res.writeHead( 200, {
        'Content-Type': 'image/png',
        'Content-Length': imageBuffer.length
    })

    res.end( imageBuffer )
})

Теперь взглянем на файл worker.js. При каждом поступлении новой задачи будет запускаться метод parentPort.on(). К сожалению, он не может обрабатывать async/await функции. Так что будем использовать функцию-адаптер в виде метода doMyAsyncCode().


В него в удобном читаемом формате будем помещать логику воркера. То есть, запустить браузер (если он еще не запущен) и активировать метод для снятия скриншота. При запуске передадим в этот метод ссылку на запущенный браузер.


const { parentPort, workerData } = require( 'worker_threads' );
const puppeteer = require( 'puppeteer' )
const mapshoter = require( './mapshoter' )

// Переменная для хранения браузера
var browser = "empty"

// Этот метод забускается при получении каждой новой задачи
// Код будет выполняться ассинхронно, не блокируя основной поток
parentPort.on( "message", ( params ) => {
    doMyAsyncCode( params )
    .then( ( result)  => { parentPort.postMessage( result ) })
})

// Вспомогательная функция, чтобы работать с async/aswit
// Помещаем сюда основную логику
async function doMyAsyncCode( params ) {

    // При первом запуске провести настройку
    await prepareEnviroment()

    // Запустить модуль снятия скриншотов
    const screenshot = await mapshoter.makeTile( params.x, params.y, params.z, browser )
    return screenshot
}

// Настройка среды. Если браузер еще не запущен, то запустить его
async function prepareEnviroment( ) {
    if ( browser === "empty" ) {
        const herokuDeploymentParams = {'args' : ['--no-sandbox', '--disable-setuid-sandbox']}
        browser = await puppeteer.launch( herokuDeploymentParams )
    }
}

Для наглядности вернемся к первому варианту mapshoter.js. Изменится он не сильно. Теперь во входных параметрах он будет принимать ссылку на браузер, а при завершении скрипта он будет не выключать браузер, а просто закрывать созданную вкладку.


const puppeteer = require( 'puppeteer' )
const geoTools = require( './geoTools' )

async function makeTile( x, y, z, browserLink ) {

    // Получаем ссылку на запущенный браузер
    const browser = await browserLink

    // Открываем в нем новую вкладку
    const page = await browser.newPage()
    await page.setViewport( { width: 660, height: 400 } )

    const coordinates = geoTools.getAllCoordinates( x, y, z )
    const centerCoordinates = `${z}/${coordinates.center.lat}/${coordinates.center.lon}&l=`
    const pageUrl = 'https://nakarte.me/#m=' + centerCoordinates + "O/Wp"

    await page.goto( pageUrl, { waitUntil: 'networkidle0', timeout: 20000 } )

    const cropOptions = {
    fullPage: false,
    clip: { x: 202, y: 67, width: 256, height: 256 }
    }

    const screenshot = await page.screenshot( cropOptions )

    // Закрываем только вкладку. Браузер не трогаем.
    await page.close()
    return screenshot
}

module.exports.makeTile = makeTile

В принципе, все. Теперь можно загрузить результат на сервер любым удобным для вас способом. Например, через docker. Если же вы хотите взглянуть на готовый результат, то можете перейти по этой ссылке. Кроме того, полный код проекта вы можете найти у меня на GitHub.


Заключение


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


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


Так же к плюсам этого скрипта можно отнести то, что с ним о легко работать. Его легко написать. И, что самое главное, его можно крайне легко переделать для подключения любой другой онлайн-карты.


Чтож, в следующей статье я займусь как раз этим. Переделаю скрипт в своеобразное API для работы с интерактивной картой OverpassTurbo.

Теги:
Хабы:
Всего голосов 10: ↑10 и ↓0+10
Комментарии4

Публикации

Истории

Работа

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань