Conectamos mapas on-line ao navegador no smartphone. Parte 2 - cartões de vetor

Estamos escrevendo um aplicativo de servidor que irá gerar blocos de varredura PNG com base em mapas vetoriais online. Use a raspagem da Web com o Puppeteer para obter dados do mapa.


Conteúdo:


1 - Introdução. Mapas de varredura padrão
2 - Continuação. Escrevendo um rasterizador simples para mapas vetoriais
3 - Um caso especial. Conectamos o cartão OverpassTurbo


Continuação


E assim chegamos ao tópico mais interessante. Imagine que encontramos um site com um mapa que realmente queremos adicionar ao nosso navegador. Fazemos tudo de acordo com as instruções da parte anterior . Abrimos a visualização do conteúdo do site, e não há fotos! Absolutamente. Bem, alguns ícones e é isso. E algum outro arquivo de texto com uma lista de coordenadas.


Parabéns, encontramos um mapa vetorial. Grosso modo, é renderizado em tempo real pelo seu navegador. Então ela não precisa de nenhum ladrilho preparado. Por um lado, não existem tantos mapas vetoriais até o momento. Mas essa tecnologia é muito promissora e, com o tempo, elas podem se tornar muitas vezes mais. Bem, nós descobrimos. E, no entanto, o que fazemos agora?


Primeiro, você pode tentar baixar um navegador de uma versão muito, muito antiga. Um que não suporta as funções necessárias para renderizar o mapa. É possível que você mostre uma versão diferente do site. Com mapa raster. Bem, o que você precisa fazer com isso você já sabe.


No entanto, se esse truque não funcionou, mas você ainda deseja obter esse cartão e, além disso, não no navegador do smartphone, ou seja, no navegador, existe uma maneira.


Ideia principal


Vamos prosseguir com o fato de que queremos obter um mapa que possa ser aberto em qualquer um dos navegadores. Então precisamos de um adaptador - um tipo de intermediário que irá gerar blocos para nós no formato PNG.


Acontece que você precisa inventar uma bicicleta desenvolva outro mecanismo para visualizar dados vetoriais. Bem, ou você pode escrever um script que irá para o site, permitindo que ele desenhe seu próprio mapa vetorial por conta própria. E então ele aguardará o download, fará uma captura de tela, recortará e retornará ao usuário. Talvez eu escolha a segunda opção.


Para tirar screenshots, usarei um "navegador de controle remoto" - Chrome sem cabeça. Você pode controlá-lo usando a biblioteca js do nó Puppeteer . Você pode aprender sobre o básico sobre como trabalhar com esta biblioteca neste artigo .


Olá Mundo! Ou crie e personalize um projeto


Se você ainda não instalou o Node.js., vá para esta ou esta página, selecione seu sistema operacional e execute a instalação de acordo com as instruções.


Crie uma nova pasta para o projeto e abra-a no terminal.


$ cd /Mapshoter_habr 

Iniciamos o gerente de criação de um novo projeto


 $ npm init 

Aqui você pode especificar o nome do projeto ( nome do pacote ), o nome do arquivo para inserir o aplicativo ( ponto de entrada ) e o nome do autor ( autor ). Para todas as outras solicitações, concordamos com os parâmetros padrão: não inserimos nada e apenas pressione Enter . No final - pressione y e Enter .


Em seguida, instale as estruturas necessárias para o trabalho. Express para criar um servidor e Puppeteer para trabalhar com um navegador.


 $ npm install express $ npm i puppeteer 

Como resultado, o arquivo de configuração do projeto package.json aparece na pasta do projeto. No meu caso, isso:


 { "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" } } 

Vou adicionar a linha de partida à seção de scripts para iniciar nosso aplicativo de maneira mais conveniente.


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

Agora crie dois arquivos com a implementação da funcionalidade básica. O primeiro arquivo é o ponto de entrada para o aplicativo. No meu caso, router.js . Ele criará um servidor e fará o roteamento.


 //        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 ) }) 

Agora crie um segundo arquivo. Ele controlará o navegador e fará capturas de tela. Eu tenho o nome 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 

Execute nosso script e verifique seu desempenho. Para fazer isso, digite o console:


$ npm start


Uma mensagem aparece dizendo "Servidor criado na porta 5000". Agora abra um navegador no seu computador e vá para o endereço local do nosso servidor. Em vez das coordenadas x, y, z, você pode inserir qualquer número. Eu digitei 1, 2, 3.


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


Se tudo for feito corretamente, uma captura de tela do site do Google será exibida.


imagem


Pressione Ctrl + C no console para interromper nosso script.


Parabéns, a base da nossa aplicação está pronta! Criamos um servidor que aceita nossas solicitações de html, tira uma captura de tela e retorna uma imagem para nós. Agora é hora de avançar para a implementação dos detalhes.


Calcular as coordenadas


A idéia é que o navegador abra um site com um mapa e insira as coordenadas do local que precisamos na barra de pesquisa. Depois de clicar no botão "Localizar", este local estará exatamente no centro da tela. Portanto, será fácil cortar a área que precisamos.


Mas primeiro, você precisa calcular as coordenadas do centro do bloco com base no número de série. Farei isso com base na fórmula para encontrar o canto superior esquerdo. Eu coloquei na função getCoordinates () .


E, como em alguns sites, além do centro do bloco, você também precisa especificar suas bordas, procurarei por eles também. Bem, vamos criar um módulo separado para esses cálculos sob o nome geoTools.js . Aqui está o código dele:


 //   -   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 

Agora estamos prontos para começar a implementar o script para trabalhar com o navegador. Vejamos alguns cenários de como isso pode ser feito.


Cenário 1 - Pesquisa API


Vamos começar com o caso mais simples, quando você pode simplesmente inserir as coordenadas no URL da página do mapa. Por exemplo, assim:


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


Vamos dar uma olhada no script. Apenas substitua, exclua todo o conteúdo do arquivo mapshoter.js e cole o código abaixo.


Nesta versão, ao iniciar o navegador, especificamos parâmetros adicionais que permitirão iniciar e funcionar em servidores Linux, como o Heroku. Agora também reduziremos o tamanho da janela para que o menor número possível de blocos de mapas caiba na tela. Assim, aumentamos a velocidade de carregamento da página.


Em seguida, calculamos as coordenadas do centro do bloco desejado. Nós os colamos no URL e clicamos nele. O bloco aparece exatamente no centro da tela. Corte um pedaço de 256x256 pixels. Este será o bloco que precisamos. Resta apenas devolvê-lo ao usuário.


Antes de passar para o código, observe que, para maior clareza, todo o tratamento de erros foi removido do script.


 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 

Agora execute nosso script e veja o mapa para esta seção.


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


Se tudo for feito corretamente, o servidor retornará esse bloco:



Para garantir que não misturemos nada durante o corte, compare nosso bloco com o original do OpenStreetMaps.org



Cenário 2 - Pesquisa usando a interface do site


No entanto, nem sempre é possível controlar um cartão através de uma linha do navegador. Bem, nesses casos, nosso script se comportará como um usuário real. Ele imprimirá as coordenadas na caixa de pesquisa e clique no botão Pesquisar. Depois disso, ele removerá o marcador do ponto encontrado, que geralmente aparece no centro da tela. E então ele clicará nos botões para aumentar ou diminuir a escala até atingir o desejado. Em seguida, ele fará uma captura de tela e retornará ao usuário.


Observo que geralmente após a pesquisa a mesma escala é definida. 15, por exemplo. No nosso exemplo, isso nem sempre acontece. Portanto, reconheceremos o nível de zoom a partir dos parâmetros dos elementos html na página.


Também neste exemplo, procuraremos elementos da interface usando os seletores XPath. Mas como você os reconhece?


Para fazer isso, abra a página necessária no navegador e abra a barra de ferramentas do desenvolvedor ( Ctll + Alt + I para Google Chrome). Pressione o botão para selecionar itens. Clicamos no elemento de seu interesse (cliquei no campo de pesquisa).



A lista de itens rola até a que você clicou e é destacada em azul. Clique no botão com três pontos à esquerda do nome.


No menu pop-up, selecione Copiar. Em seguida, se você precisar de um seletor regular, clique em Copiar seletor . Mas, para o mesmo exemplo, usaremos o item Copiar XPath .



Agora substitua o conteúdo do arquivo mapshoter.js por este código. Nele, já colecionei seletores para todos os elementos de interface necessários.


 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 

Execute nosso script e siga o link. Se tudo for feito corretamente, o script retornará para nós algo como esse bloco.


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



Otimização


Em princípio, os dois métodos descritos acima são suficientes para conectar-se a muitos sites com mapas vetoriais. Porém, se você precisar acessar repentinamente algum novo mapa, precisará modificar levemente o script no arquivo mapshoter.js. Ou seja, esse método facilita a adição de novos cartões. Isso é de suas vantagens.


Mas também há desvantagens. E o principal é a velocidade do trabalho. Basta comparar. Em média, leva cerca de 0,5 segundos para baixar um bloco raster comum. O recebimento de um bloco de nosso script no momento leva cerca de 8 segundos.


Mas isso não é tudo! Usamos o nó single-threaded js e nossas solicitações longas acabam bloqueando o thread principal, que do lado de fora parecerá com uma fila síncrona regular. E quando tentamos fazer o download do mapa para a tela inteira (na qual, por exemplo, 24 blocos são colocados), ou seja, existe o risco de encontrar um problema.


E mais uma coisa. Alguns navegadores têm um tempo limite: eles param de carregar após 30 segundos. E isso significa que, com a implementação atual, apenas 3-4 blocos terão tempo para carregar. Bem, vamos ver o que podemos fazer sobre isso.


Provavelmente, a maneira mais óbvia é simplesmente aumentar o número de servidores nos quais nosso script será executado. Por exemplo, se tivermos 10 servidores, eles terão tempo para processar os blocos da tela inteira em 30 segundos. (Se você não quiser pagar muito dinheiro, poderá obtê-lo registrando várias contas gratuitas no Heroku)


Em segundo lugar, ainda é possível implementar multithreading no nó js usando o módulo worker_threads . De acordo com minhas observações, em um servidor com um processador de núcleo único em uma conta gratuita Heroku, eu consigo iniciar três threads. Três fluxos com um navegador separado em cada um, que pode funcionar simultaneamente sem bloquear um ao outro. Para ser sincero, observo que, como resultado do aumento da carga no processador, a velocidade de download de um bloco aumentou um pouco. No entanto, se você tentar fazer o download de um mapa para a tela inteira, depois de 30 segundos, mais da metade do mapa terá tempo para carregar. Mais de 12 peças. Já está melhor.


Terceiro. Na implementação atual do script, com cada solicitação, gastamos tempo fazendo o download do navegador Chrome e, em seguida, concluindo-o. Agora, criaremos um navegador com antecedência e transferiremos um link para ele no mapshoter.js. Como resultado, a velocidade não será alterada para a primeira solicitação. Mas, para toda a velocidade de download subsequente de um bloco, é reduzida para 4 segundos. E depois de 30 segundos, todo o mapa tem tempo para carregar - todos os 24 blocos que são colocados na minha tela.


Bem, se você implementar tudo isso, o script poderá se tornar bastante viável. Então, vamos começar. Para um trabalho mais simples com multithreading, usarei o módulo node-worker-threads-pool - um tipo de wrapper sobre worker_threads. Vamos instalá-lo.


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


Corrija o arquivo router.js. Adicione a ele a criação de um pool de threads. As linhas serão 3 partes. Seu código será descrito no arquivo worker.js , veremos mais adiante. Enquanto isso, exclua o lançamento do módulo de captura de tela diretamente. Em vez disso, adicionaremos uma nova tarefa ao pool de threads. Eles começarão a processá-lo quando qualquer um dos threads for liberado.


 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 ) }) 

Agora, dê uma olhada no arquivo worker.js . Cada vez que uma nova tarefa chega, o método parentPort.on () é iniciado. Infelizmente, ele não pode lidar com funções assíncronas / aguardadas. Portanto, usaremos a função do adaptador na forma do método doMyAsyncCode () .


Nele, em um formato legível conveniente, colocaremos a lógica do trabalhador. Ou seja, inicie o navegador (se ainda não estiver em execução) e ative o método para tirar uma captura de tela. Na inicialização, passaremos para esse método um link para o navegador em execução.


 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 ) } } 

Para maior clareza, voltemos à primeira versão do mapshoter.js . Não vai mudar muito. Agora, nos parâmetros de entrada, ele aceitará um link para o navegador e, quando o script terminar, não desligará o navegador, mas simplesmente fechará a guia criada.


 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 

Em princípio, é tudo. Agora você pode enviar o resultado para o servidor de qualquer maneira conveniente para você. Por exemplo, através da janela de encaixe. Se você quiser ver o resultado final, pode clicar neste link . Você também pode encontrar o código completo do projeto no meu GitHub .


Conclusão


Agora vamos avaliar o resultado. Por um lado, apesar de todos os truques, a velocidade do download ainda é muito baixa. Além disso, por causa dos freios, esse cartão é simplesmente desagradável de rolar.


Por outro lado, esse script lida com cartões que antes eram geralmente impossíveis de conectar ao navegador no smartphone. É improvável que essa solução seja aplicada como o principal método de obtenção de dados cartográficos. Mas aqui como um adicional, com a ajuda do qual, se necessário, será possível abrir um cartão exótico - é bem possível.


Além disso, as vantagens desse script incluem o fato de ser fácil trabalhar com ele. É fácil escrever. E, o mais importante, pode ser refeito com extrema facilidade para conectar qualquer outro cartão online.


Bem, no próximo artigo , tratarei exatamente disso. Transformarei o script em um tipo de API para trabalhar com o mapa interativo OverpassTurbo.

Source: https://habr.com/ru/post/pt461053/


All Articles