我们将在线地图连接到智能手机上的导航器。 第2部分-矢量卡

我们正在编写一个服务器应用程序,它将基于在线矢量地图生成PNG栅格图块。 与Puppeteer一起使用网络抓取功能来获取地图数据。


内容:


1- 简介。 标准栅格图
2-继续。 为矢量地图编写一个简单的光栅化器
3- 特例。 我们连接了OverpassTurbo卡


延续性


因此,我们来到了最有趣的话题。 想象一下,我们发现了一个带有地图的站点,我们确实要将其添加到导航器中。 我们按照上一部分的说明进行所有操作。 我们打开了对该网站内容的查看,没有图片! 绝对是 好吧,几个图标就可以了。 和其他一些带有坐标列表的文本文件。


恭喜,我们找到了矢量图。 粗略地说,它是由浏览器实时呈现的。 因此,她根本不需要任何准备好的瓷砖。 一方面,到目前为止,没有太多的矢量地图。 但是这项技术非常有前途,随着时间的流逝,它们的数量可能会增加很多倍。 好吧,我们知道了。 但是,我们现在该怎么办?


首先,您可以尝试下载非常老版本的浏览器。 一种不支持渲染地图所需的功能。 可能会向您显示该网站的其他版本。 带有栅格图。 好吧,您已经知道该怎么做。


但是,如果这种方法不起作用,但是您仍然真的想获得此卡,而且不是在智能手机的浏览器中(即在您的导航器中),那么有一种方法。


主要思想


我们将从想要获得可以在任何导航器中打开的地图的事实出发。 然后,我们需要一个适配器-一种中介,它将为我们生成PNG格式的图块。


原来你需要 发明自行车 开发另一个用于可视化矢量数据的引擎。 好吧,或者您可以编写一个脚本,该脚本将转到该站点,从而允许他自己绘制自己的矢量地图。 然后他将等待下载,截屏,裁剪并返回给用户。 也许我会选择第二个选项。


要获取屏幕截图,我将使用“遥控浏览器”-无头Chrome。 您可以使用node js库Puppeteer对其进行控制。 您可以从本文了解使用此库的基础知识。


世界您好! 或创建和自定义项目


如果尚未安装Node.js,请转至此页面或页面,选择操作系统,然后根据说明执行安装。


为项目创建一个新文件夹,然后在终端中打开它。


$ cd /Mapshoter_habr 

我们开始创建新项目的经理


 $ npm init 

在这里,您可以指定项目的名称( 包名称 ),用于输入应用程序的文件的名称( 入口点 )和作者的名称( 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": "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网站的屏幕截图。


图片


在控制台中按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中并单击。 该图块恰好出现在屏幕中央。 切一块256x256像素。 这将是我们需要的瓷砖。 它仅保留将其返回给用户。


在继续进行代码之前,我注意到为清楚起见,已从脚本中删除了所有错误处理。


 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选择器查找接口元素。 但是您如何识别它们?


为此,请在浏览器中打开所需的页面,然后打开开发人员工具栏(对于Chrome浏览器,请单击Ctll + Alt + I )。 按下按钮选择项目。 我们单击您感兴趣的元素(我单击了搜索字段)。



项目列表滚动到您单击的项目,并以蓝色突出显示。 单击名称左侧的三个点的按钮。


从弹出菜单中,选择“复制”。 接下来,如果您需要常规选择器,请点击复制选择器 。 但是对于同一示例,我们将使用“ 复制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秒钟。


但这还不是全部! 我们使用单线程节点js,我们的长请求最终将阻塞主线程,从外部看,它看起来像是常规同步队列。 而且,当我们尝试下载整个屏幕的地图时(例如,在该地图上放置了24个图块),即存在遇到问题的风险。


还有一件事。 一些导航器超时:它们将在30秒后停止加载。 这意味着在当前实施中,只有3-4个图块将有时间加载。 好吧,让我们看看我们能做些什么。


可能最明显的方法是简单地增加运行脚本的服务器数量。 例如,如果我们有10台服务器,那么他们将有时间在30秒内处理整个屏幕的图块。 (如果您不想支付很多钱,可以通过在Heroku上注册多个免费帐户来获得)


其次,仍然可以使用worker_threads模块在节点js上实现多线程。 根据我的观察,在具有免费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()方法。 不幸的是,它无法处理异步/等待功能。 因此,我们将以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上找到完整的项目代码。


结论


现在让我们评估结果。 一方面,尽管已完成所有技巧,但下载速度仍然非常低。 而且,由于制动,这种卡根本不容易滚动。


另一方面,此脚本仍然可以处理以前无法连接到智能手机上的导航器的卡片。 这种解决方案不可能用作获取制图数据的主要方法。 但是这里是另外一个,如果有必要的话,有可能在其中帮助下打开一些奇特的卡片-这是完全可能的。


另外,此脚本的优点包括易于使用的事实。 这很容易写。 而且,最重要的是,可以非常轻松地重做连接任何其他在线卡的操作。


好吧, 在下一篇文章中,我将讨论这一点。 我将脚本转换为一种与OverpassTurbo交互式地图配合使用的API。

Source: https://habr.com/ru/post/zh-CN461053/


All Articles