news 2026/10/2 6:23:32

从零构建 Google Maps POI 采集系统:协议逆向、瓦片切分与规模化落库

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零构建 Google Maps POI 采集系统:协议逆向、瓦片切分与规模化落库

从零构建 Google Maps POI 采集系统:协议逆向、瓦片切分与规模化落库

目录

  1. 为什么不用官方 API
  2. 接口逆向:tbm=map与pb私有协议
  3. 响应体双层清洗与索引式字段映射
  4. 区域切分:瓦片网格 + GeoJSON 边界裁剪
  5. 抓取运行时:Cookie 预热、退避重试、并发队列、断点续爬
  6. 迭代加密:用两阶段 refine 逼近结果上限
  7. 聚合与入库:去重键、精准 Upsert 与导入加速
  8. 二轮增强与成果导出
  9. 工程复盘:那些踩过的坑

一、为什么不用官方 API

Google Places API 有三个绕不过去的问题:

维度官方 Places API本文方案(tbm=map内嵌接口)
计费按请求计费,Text Search 单价高无
返回上限单查询硬顶(约 60 条)靠网格切分突破
字段受 SKU 分层限制原始响应里有大量未公开字段
区域约束有无

真正的痛点在于单查询结果上限。Google Maps 的搜索接口对任意关键词都会截断到固定条数,想拿到一个城市的全量 POI,唯一办法就是把大区域切成足够小的格子,让每个格子的结果数低于截断阈值。

于是整体架构就定下来了:

查询词表 + 边界 GeoJSON │ ▼ 瓦片网格生成 │ ▼ 并发抓取(Cookie + 重试 + 断点续爬) │ ▼ 单瓦片 JSON 落盘 ←── 迭代加密 refine │ ▼ 聚合去重 aggregate │ ▼ SQLite 入库(dedupe_key + upsert) │ ▼ 二轮增强(按 source_url 回查) │ ▼ CSV → GeoJSON 导出

下面逐层拆。


二、接口逆向:tbm=map与pb私有协议

2.1 端点形态

搜索请求打的是搜索页本体,而不是某个 REST 接口

2.2pb是关键

pb是一串!分隔的"位置-值"编码,形如:

!4m12!1m3!1d581011.4!2d100.28!3d16.44!2m3!1f0!2f0!3f0!3m2!1i436!2i911...

可以粗略理解成 protobuf 的文本化编码:!{字段号}{类型}{值},嵌套结构用m{长度}声明。这一串里有几百个字段,我们完全不需要理解其中大部分,只需要能定位并替换两小段。

视口段—— 决定"以哪个点为中心、覆盖多大范围",对应!1m3!1d{对角线米数}!2d{中心经度}!3d{中心纬度}。

分页段—— 决定"从第几条开始、拿多少条",对应!7i{pageSize}!8i{offset}。

2.3 参数注入式 URL 改写

关键设计是:不重新拼pb,而是在原串上做定点正则替换。这样能保留原串里那几百个看不懂但服务端需要的字段——重新拼串等于把所有未知字段丢掉,接口会直接给你空结果。

视口改写,然后整体换成当前格子的中心点与计算出的对角线长度。

分页改写则要处理一个容易踩的细节:第一页的模板通常不带。此时不能简单追加到串尾,而要插到!7i的后面——因为pb是有序的,字段顺序会影响服务端解析。这个结论没什么文档可查,是拿同一批坐标反复对比响应差异试出来的。

2.4 那个 4.2 系数是什么

视口对角线的取值逻辑是:以格子自身对角线为基准,取一个下限值,再乘上一个放大系数。

  • 下限:防止极小的格子导致视口退化,服务端会按"你在我看不见的地方搜什么"处理。
  • 系数 4.2:视口要明显大于格子本身。

这是全篇最重要的一个反直觉判断。如果视口刚好等于格子,边缘 POI 会因为落在视口外而被裁掉,产生系统性的"格线空洞"——数据看起来完整,但整张图上会出现规律的空白条纹,且极难排查。

放大 4 倍多让相邻格子充分重叠,再靠后续按坐标去重兜底。这是一个明确的工程权衡:宁可重复抓,不可漏抓。重复数据在入库阶段有dedupe_key兜底,漏掉的数据没有任何补救手段。代价是抓取量大约变成 4 倍,但换来的是覆盖率可控。


三、响应体双层清洗与索引式字段映射

3.1 第一层:)]}'前缀

3.2 第二层:嵌套{"d": "..."}解包

字符串状态机还要处理转义:遇到\时下一个字符无条件跳过,否则\"会被当成字符串结束。这几行是整个解析链路里最容易写错、也最容易在特定数据集上才暴雷的地方。

3.3 索引式字段映射

解出来的顶层是纯数组,没有 key。organic 结果在root[64],每项又是一个数组,真实行数据在item[1]。也就是说一条 POI 的取值路径是root[64][i][1][n]。

逆向出来的字段表(挑重点):

路径字段
row[11]名称
row[10]dataId(0x...:0x...形式)
row[78]placeId(ChIJ...形式)
row[89]providerId(/g/...)
row[9][2]/row[9][3]纬度 / 经度
row[4][7]/row[4][8]评分 / 评论数
row[4][2]、row[4][9][3]价格档位
row[7][0]官网
row[39]地址全串
row[13]分类数组
row[178]电话
row[203]营业时间
row[183][1]地址分段(省/市/邮编/国家码)
row[227][0][*]备用 ID 来源

这类映射没法"推理"出来,只能靠同一批数据换不同关键词反复请求、逐字段对比得到。推荐的做法是:固定 3 个已知 POI(比如一家便利店、一个车站、一个景点),把响应存下来,逐个索引位置打日志,肉眼比对哪个位置是"东京"、哪个是"〒100-0001"。这个过程大概要花一个下午,但一旦建表完成就是一劳永逸的资产。

另外,解析时最好同时记录query、page、offset,以及一个全局限定排名(当前页内序号 + offset)。排名在评估"某个关键词下谁更靠前"时很有用,而错过后没法补。

3.4 GEO_TYPE 属性树

行数据里有一段/geo/type/...形式的属性列表,这是这类采集里最有价值的部分之一(官方 API 基本不给你)。解析后形如:

{"id":"/geo/type/establishment_poi/has_wheelchair_accessible_parking","label":"Wheelchair-accessible car park","groupId":"accessibility","groupLabel":"Accessibility","enabled":true,"evidenceCount":2}

解析要点三条:

  • 按/geo/type/前缀剥离,剩下的路径最后一段就是属性 slug。
  • groupId/groupLabel是分组(accessibility、amenities、pets 等),必须单独抽出来,否则一千多个属性平铺在一列里根本没法检索。
  • enabled为布尔,但有些属性是enabled: false也返回的,代表"明确不具备"——这本身是信息,不要过滤掉。同理evidenceCount可以当置信度用,1 和 10 显然不是一回事。

3.5 一条真实记录

从captures\google_jp_001\apartment_building_002_tile_100289.json里取出的实际行(已裁剪):

{"query":"apartment_building","rank":1,"name":"K-Rosso","dataId":"0x353fb93767f2b3bf:0x3e82d4a9b56fdf18","placeId":"ChIJv7PyZze5PzURGN9vtanUgj4","providerId":"/g/11lgmh497b","latitude":32.127114299999995,"longitude":130.3501478,"address":"Shimosabacho, Izumi, Kagoshima 899-0123","addrCity":"Izumi","addrZip":"899-0123","addrRegion":"Kagoshima","addrCountryCode":"JP","categories":["Apartment building"]}

三个 ID 各有用途,这个设计后面入库时会反复用到:

  • placeId—— Google 全局唯一、最稳的去重键,形如ChIJ...。
  • dataId—— 经纬度编码的十六进制对,可做兜底去重键,也可用于详情页回查。
  • providerId—— 指向知识图谱实体(/g/...),跨语言一致,适合做外部关联。

四、区域切分:瓦片网格 + GeoJSON 边界裁剪

4.1 网格生成

给定一个 bbox 和步长,按经纬度双重循环生成等距小格。每格记录索引、bbox、中心点。日本那种跨二十多个纬度带的国家,瓦片数能到几十万。

这里有个容易忽略但影响很大的细节:格子 ID 要用固定宽度补零(tile_0016而不是tile_16),并且循环边界一律用Math.min夹到 bbox 上(最后一列/行的格子宽度通常不足一个步长,不夹的话会溢出到边界外,多抓一片无关区域)。

补零的意义在于:序列化成文件名后,按字典序排序就等于按索引排序,聚合阶段不用额外写排序逻辑,断点续爬的"已完成集合"也能直接靠文件名字符串比对。几十万个文件,文件名排序错乱会直接毁掉这两件事。

4.2 边界的两条判据

直接按 bbox 切会产生大量"海里/邻国"的空格子——比如抓泰国却把缅甸边境、暹罗湾水面全抓了一遍。用边界 GeoJSON 过滤,有两种模式:

  • intersects(默认)—— 格子与边界多边形有任何交集就保留。
  • center—— 只有格子中心点落在边界内部才保留。

选哪个取决于网格密度,这是个需要想清楚的取舍:

步长小于 0.02° 时用center,格子足够碎,边界处误判影响小,还省请求。
步长大时用intersects,否则沿海城市的格子会因为中心点落在海里被整格丢掉,损失惨重——而且丢的是人口最密集的沿海地带。

4.3 射线法 + 洞处理

点在多边形内的判定用经典射线法:从待判点向右发一条水平射线,统计与多边形边的交点数,奇数在内、偶数在外。实现上用一个"前一个顶点"的滚动索引遍历所有边即可。

带洞的多边形(印尼的飞地、各种湖区)要先判外环再排洞:先确认点在外环内,再遍历所有内环,只要落在任意一个洞里就判定为"不在域内"。

4.4 格子与多边形的相交判定(三条缺一不可)

这是几何部分最容易写错的地方。判定"格子与多边形是否相交"不能只看格子四角是否在多边形内——多边形完全落在格子内部时,四角都在外面,会误判为不相交,整片数据白丢。

正确做法是双向检测,三条判据任意一条成立即为相交:

  1. 格子的任一角点在多边形内
  2. 多边形的任一顶点在格子内(覆盖"多边形小、格子大"的情况)
  3. 格子四条边与多边形任一条边相交(覆盖"细长多边形穿过格子"的情况)

第 3 条最容易被忽略:一个狭长的国土(想想智利、越南)斜穿过格子时,可能没有任何一个角点落在对方内部,但确实相交。漏掉这条会让这些国家出现成片的空洞。

线段相交用标准的取向判定:用叉积符号判断两条线段的端点相对位置,若双向交叉则相交。还要处理共线退化情况——四个点共线时叉积全为 0,此时需要用"点是否落在线段包围盒内"来补判,否则会漏掉水平/垂直贴边的格子。

浮点误差方面,叉积判零要留容差(比如1e-12),直接和 0 比较在经纬度这种量级下会不稳定。

4.5 边界几何的递归收集

GeoJSON 边界文件的形态五花八门,得先统一成"一组多边形"再喂给上面的判定函数。需要覆盖的type至少有:

type处理
Polygon直接取coordinates
MultiPolygon展开每一项
Feature递归它的geometry
FeatureCollection遍历features递归
GeometryCollection遍历geometries递归

不能只处理Polygon。从geoBoundaries下载下来的 ADM0 文件基本都是MultiPolygon或FeatureCollection,只认Polygon会导致边界静默失效——不报错,只是过滤条件永远返回 true,然后你抓了半天才发现数据里混进了马来西亚。

4.6 实战做法:先出瓦片清单再抓

流程上刻意拆成两步:

# 第一步:只生成瓦片清单,不抓取nodescripts/google_maps_search_crawler.js\--boundary-geojson data/thailand-adm0-simplified.geojson\--grid-step-lng0.05--grid-step-lat0.05\--generate-tiles-only\--tiles-out captures/google_th_tiles/thailand_005.json

先把tiles.json和boundary.json落盘、用地图工具看一眼覆盖范围对不对,再开抓。在几万个请求之前先花十秒确认网格没歪,这是能省下大量时间的习惯。同理,boundarys.txt那种"一行一个 bbox"的清单配合批量脚本,可以一次生成多个区域的合并瓦片表。


五、抓取运行时:Ck 预热、退避重试、并发队列、断点续爬

5.1 Ck 预热

5.2 可重试错误的判定

重试策略要区分"值得重试"和"别浪费时间":

  • 值得重试:429、403、5xx,以及fetch failed/ECONNRESET/ETIMEDOUT/socket hang up这类网络层错误。
  • 不值得重试:解析错误(说明协议变了,重试一万次也一样)。

重试要有退避(递增等待)而不是死循环硬顶,否则只会加速被限流。

5.3 软封禁检测(这条必须有)

比状态码更隐蔽的是软封禁:状态码 200,内容却是一个拦截页。Google 的"抱歉页"、unusual traffic提示、CAPTCHA 页面都属于这一类。

只判 HTTP 状态会以为请求成功,然后把一整页 HTML 当 JSON 解析——结果是整个批次全部报解析错误,真正的封禁原因被彻底掩盖。

所以每次响应都要做一次内容特征检查,命中就按可重试错误处理(刷新 cookie + 退避重试)。

5.4 并发队列

不需要引外部依赖,一个共享计数器的并发模型就够:

维护一个公共的任务索引,启动 N 个 worker 协程,每个 worker 在while循环里"抢"下一个索引并处理,直到索引越界。外面用Promise.all等所有 worker 收工。

这个模型的好处:

  • 无锁。JS 单线程下,index += 1之间没有 await,天然不会竞争,比维护一个真正的队列数据结构简单得多。
  • 异常隔离。单个任务失败只记录到 errors 数组并继续,不会打断整个池。
  • 可观测。每个任务完成时都能回调,用于更新进度状态与落盘。

并发度的选择是实测折中:太低吞吐上不去,太高会被限流。同时每个请求之间要有节流延迟(约 1 秒级别),这是"稳定跑几十万次请求"和"跑两万次就被封"的分界线。

5.5 断点续爬

核心判断极其简单:目录里已存在的 tile 文件,跳过。

启动时扫一遍原始目录,用文件名正则匹配出已完成的瓦片 ID 集合;生成任务列表时过滤掉这些瓦片。配合一个progress.json记录整体状态(阶段、总瓦片数、已完成数、总行数、起止时间、错误列表)。

这里有个刻意的设计:状态机的 stage 取值里留了done_with_errors。几万个瓦片的任务里,因为几个网络错误整批重跑是不可接受的——部分失败也必须能正常收尾并保留成果,把失败清单交给下一轮补跑。

5.6 为什么按瓦片落盘而不是直接汇总

每个瓦片单独写一个 JSON 文件,内容结构是:tile(该格子的索引与 bbox)+source+query+queryKey+pageSize+pages(每页的 offset 与条数)+rowCount+rows。

分文件的三个理由:

  1. 断点粒度细。一个文件 = 一个瓦片 = 一个可跳过的单位。汇总成一个大文件的话,中断就意味着从头再来。
  2. 不占内存。最终数据接近 9 GB,如果全在内存里聚合,Node 早就 OOM 了。
  3. 可审计。rowCount: 0的空瓦片留着,能证明"这块区域确实没东西",而不是"这块没抓到"。这在事后排查覆盖度时是唯一的凭据——删了它,就再也无法区分这两种情况。

另外,pages数组不只是日志。当某个瓦片返回条数正好等于pageSize时,说明结果可能被截断——这正是下一节 refine 的触发条件。这个字段是链接"抓取"和"加密"两个阶段的关键信号。


六、迭代加密:用两阶段 refine 逼近结果上限

6.1 问题从哪来

步长 0.05° 大概对应 5 公里。东京市中心一个 5 公里格子里,"restaurant"能有好几千家,远超接口单次返回上限。第一轮粗网格跑完,那些rowCount接近上限的瓦片就是数据黑洞——你拿到了结果,但只是冰山一角,而且看不出缺了多少。

6.2 两阶段策略

加密脚本做两件事:

Pass 1 —— 筛选。按一个最小行数阈值(比如 5)过滤出"结果够多、值得细分"的瓦片。同时按 bbox 字符串去重父瓦片——因为之前的视口放大导致相邻格子重叠,同一个格子可能出现在多个来源里,不去重会重复生成子任务。

Pass 2 —— 细分。把每个父瓦片按更小的子步长(比如 0.01°)切成子格,逐个写出。0.05° → 0.01° 意味着格子数变成 25 倍。

真实验证过的命令:

nodescripts/google_maps_refine_tiles.js\--input-dir .\captures\google_jp_001\--min-rowcount5\--child-step-lng0.01--child-step-lat0.01\--outcaptures\google_jp_001\tiles001.json

6.3 这是个自适应收敛过程

加密不是一次性的,而是可以反复迭代:0.05° → 0.01° → 0.002°……直到密集格子不再触顶为止。

这样做的本质是让数据密度自己决定细分粒度:数据密集的老城区自动细分到很细,人烟稀少的山区保持粗粒度。用最少的请求量逼近全量结果,而不是对所有区域一视同仁地细切(那样请求数会爆炸)。

判断"是否还要再加密一轮"的依据很直接:看新一轮里还有多少瓦片的rowCount等于pageSize。这个数字掉到零,就说明网格已经细到接口不再截断了。

6.4 避开 V8 的 512MB 字符串上限

Pass 2 的输出可能是一个上百 MB 的 JSON 数组。如果先JSON.stringify(整个数组)再写盘,会直接炸:

RangeError: Invalid string length

这是 V8 的硬限制——字符串最大长度约 512MB(2^29 - 24个字符),超过就抛这个错。这不是内存不够,是语言层面的天花板。

解法是流式写出:打开文件句柄,先写一个[,然后每写一行数据就在前面补一个逗号(除第一行外),最后补一个]并关闭。整个过程内存里只存在单行数据。

记住这个模式:大 JSON 数组永远流式写,永远不要拼成一个字符串。
这个 bug 在本地用几百条数据开发时永远复现不出来,一上生产就炸——而且报错信息(Invalid string length)对新手来说完全看不出是"数据量太大"。

同一个文件里的 CSV 输出也走了流式路径(先扫一遍确定表头,再逐行写),理由完全一样。


七、聚合与入库:去重键、精准 Upsert 与导入加速

7.1 聚合

聚合脚本把几万个瓦片文件按文件名排序读入,合并所有rows,然后去重。

因为视口故意放大了 4.2 倍,相邻瓦片天然重叠,所以去重键的稳定性比去重效率重要得多。优先级设计为:

placeId > dataId > name|latitude|longitude
  • placeId是 Google 侧的稳定主键,最可靠。
  • dataId是坐标编码,同一个 POI 在不同语言/地区的响应里一致,可兜底。
  • 最后那个三元组只用于极少数两边都缺失的脏数据。

7.2 SQLite 去重键

入库脚本在 Python 侧重新计算一遍去重键,逻辑与 JS 侧严格对齐(不能复用 JS 的输出,因为入库的输入可能来自 CSV 等其它渠道)。规则同上,但有两个细节:

键里带前缀(placeId:/dataId:/name:)。这不是为了好看——三种键的取值空间完全不同,理论上存在碰撞可能(比如某个dataId恰好等于某个placeId字符串),带前缀彻底消除歧义。

坐标保留 7 位小数(约 1 厘米精度)。再高会把浮点误差变成不同的键,同一条数据因为末位抖动被判成两条;再低则会误合并相邻店铺。

dedupe_key列建 UNIQUE 索引,它就是后续ON CONFLICT的冲突目标。

7.3 只更新该更新的列

重复抓取的意义在于字段会变:评论数会涨、价格档位会变、营业时间会调。但其他字段(名称、地址、分类)一般不变,全量覆盖会带来风险——比如某一轮响应里地址字段格式回退、分类短暂缺失,覆盖就把好数据写坏了。

所以冲突处理只针对三个"时效性字段":reviews、price_level、opening_hours。三个设计点:

  • COALESCE保护:新数据是NULL时不覆盖旧值,只在新数据有值时才更新。
  • WHERE ... IS NOT ...条件更新:值没变就不写。SQLite 的IS NOT能做NULL 安全比较(普通的!=遇到 NULL 全部返回 NULL,条件永远不成立),这一点很关键。
  • 这个 WHERE 不只是省 I/O,更重要的是避免让更新时间戳无意义地跳动——否则每次全量重跑都会把所有行的updated_at刷成现在,时间戳就失去了"数据何时真正变化"的含义。

7.4 索引先删后建

大批量导入时的关键加速手段:导入前 DROP 掉所有非必要索引,导入完成后再 CREATE 回来。

原理很朴素:每插入一行都要维护所有索引(B 树分裂、页分裂),几十万行下索引维护的开销远超写入本身。UNIQUE 索引(dedupe_key)必须保留,因为冲突检测依赖它;国家、查询词、瓦片 ID 这些纯查询用的索引全部先删。

配合两条 PRAGMA:

  • journal_mode=WAL—— 读写不互斥,配合多进程/多批次时明显更顺。
  • synchronous=NORMAL—— 在 WAL 模式下是安全的(只在 checkpoint 时 fsync),写入速度能有数量级提升。

批量提交用executemany,批次大小默认 5000。表结构是三十多列的宽表,直接对应前面逆出来的字段表。


八、二轮增强与成果导出

8.1 按 source_url 回查

第一轮抓的是列表页,某些字段(营业时间、价格档)在列表里经常缺失。补数策略是用已有记录里存的source_url回查——这条 URL 就是当初抓到该 POI 的搜索请求地址,直接重放即可。

两个关键细节:

幂等性靠一张记录表。每处理完一条就往一张..._attempts表写一行(URL + 时间 + 结果状态)。重跑时先查这张表,跳过已尝试过的 URL。

没有这一步,任何中断都意味着从零开始。补数任务的单条耗时远高于列表抓取(要下载完整详情页),一个跑几小时的批次因为断网重头来,是不可接受的成本。

线程数有上限。Python 的 GIL 加 requests 阻塞 IO 的组合下,线程开太多只会加剧上下文切换和连接抖动,实测并发 16 左右是甜点。用ThreadPoolExecutor+as_completed按完成顺序回收结果。

8.2 详情页端点

除了搜索接口,还有一个详情端点可用于补数:/maps/preview/place?...&pb=...。它的pb模板里有一个占位 ID,把目标 POI 的dataId做 URL 编码后替换进去即可(dataId里含:和%3A,必须正确编码)。

这条链路最值得说的一点是:详情响应里的数据结构位置和搜索接口不同——搜索接口在root[64],详情接口在root[6]。但再往里一层的行结构是完全一致的。

所以响应清洗和行解析这两块逻辑被抽成了公共模块,两个端点共用。这就是把逆向成果模块化的价值:协议逆向的思想(双层清洗、括号配平、索引式映射)跨端点通用,新增一个端点只需要改"从哪里取行"这一个数字。

8.3 结果正确性校验

补数最大的风险是对错了人——dataId拼错一位,拿回来的是隔壁店的数据,然后被写成当前 POI 的营业时间。这种错误一旦入库,从数据本身完全看不出来,是典型的静默污染。

所以代码里强制做一次显式 ID 校验:解析出结果后,用它自带的placeId和任务的placeId比对,只有相等才写入;不匹配的记为place_id_mismatch状态供事后分析。

永远不要信任跨接口的隐式对应关系,永远用显式 ID 校验。
“我按 ID X 请求的,拿回来的肯定是 X 的数据”——这个假设在服务端有缓存、有降级、有 URL 重写的情况下并不成立。

8.4 CSV → GeoJSON

导出脚本有两个实用设计,都是被脏数据逼出来的:

编码嗅探。中文/日文的表格经常是 GBK 或 Shift-JIS,硬按 UTF-8 读会得到一屏乱码。做法是按顺序尝试一组候选编码(utf-8-sig→utf-8→gbk→gb18030→big5→shift_jis→cp932→latin-1),第一个能成功解码整份文件的即采用。utf-8-sig排第一是因为带 BOM 的 UTF-8 文件用普通utf-8读会在首行首列混进一个不可见字符,导致列名匹配失败。

字段名自动识别。不同来源的 CSV 列名千奇百怪(lon/lng/longitude/x/经度/wgs84_lon),硬编码列名会让脚本只能处理一种输入。做法是维护几组候选名列表,按顺序在表头里找第一个命中的——让一个脚本能吃下十几种不同格式的输入表。

输出时带上 CRS 声明:urn:ogc:def:crs:OGC:1.3:CRS84。

这行看起来是装饰,实际上是必需的。CRS84明确声明了坐标系是 WGS84 经纬度、经度在前。不加这行,QGIS / ArcGIS 打开时可能按投影坐标或其他顺序解释,点位会飞到地球另一边——而且不报错,就是画出来不对。


九、工程复盘:那些踩过的坑

9.1 分页参数必须插在正确位置

原模板没有!8i时,追加到末尾和插到!7i后面,服务端行为不一致。pb是有序的。

9.2 视口要大于格子

视口 = 格子 → 边缘系统性丢失,表现为格线空洞。4.2 倍重叠是拿重复数据换覆盖率的划算买卖。

9.3)]}'+d字段是两层

只剥前缀不够,d里还是字符串,还得配平括号扫。必须跟踪字符串状态——POI 名字里的花括号会让天真的计数器出错。

9.4 软封禁是 200

/sorry/、unusual traffic、captcha必须单独检测,否则整批数据的解析错误会掩盖真实的封禁原因。

9.5 大 JSON 数组必须流式写

512MB 字符串上限(RangeError: Invalid string length)是 V8 硬限制,本地小数据永远复现不了。

9.6 SQLite 大批量导入要删索引

索引维护开销 > 写入本身。先删后建,配合 WAL +synchronous=NORMAL。

9.7 补数必须校验 placeId

跨接口的隐式对应关系不可信,显式 ID 比对是唯一可靠手段。这类错误一旦入库完全看不出来。

9.8 空结果也要留档

rowCount: 0的瓦片文件是有价值的证据。删了它,就再也无法区分"这块区域真的没有 POI"和"这块区域抓取失败了"。

9.9 几何判定必须双向

只判"格子在多边形内"会漏掉"多边形在格子内";不判线段相交会漏掉细长国土斜穿的情况。三个判据缺一不可。


结语

这套系统的核心思路可以概括成一件事:把一个"看起来没有 API"的服务,用网格切分 + 边界裁剪 + 协议改写,硬生生拆成可并发、可断点、可去重的标准采集任务。

真正难的不是某一段代码,而是几个非直觉的判断:

  • 视口要大于目标区域(用重复换覆盖率);
  • 分页参数要插在正确位置而不是追加;
  • 几何相交要双向判定,还要管线段相交;
  • 中间产物必须按瓦片落盘而不是攒在内存;
  • 大 JSON 数组必须流式写;
  • 补数必须显式校验 ID;
  • 大批量入库要先删索引。

这些都不是看文档能学到的,只能在一个跑得足够久、数据量足够大的系统里慢慢碰出来。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 6:23:24

STM32嵌入式C++实战:从空main到寄存器点灯与Led类封装

这一篇&#xff0c;我们终于动手写代码了别急&#xff0c;老朋友。这句"看了三篇了&#xff0c;一行都没让我写呢"&#xff0c;我从评论区看到的时候真的笑了——因为这正是我刻意安排的节奏。嵌入式C和纯软件不一样&#xff1a;你在电脑上写个std::cout << &q…

作者头像 李华
网站建设 2026/10/2 6:22:57

统信UOS与麒麟Kylin下用pyenv实现Python多版本管理实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/2 6:22:32

ASIL等级不是产品勋章:ISO 26262功能安全核心概念与工程落地详解

搞功能安全的同事聚在一起&#xff0c;聊不了几句一定会碰到一个问题&#xff1a;你们那个项目里的控制器到了哪一级&#xff1f;有人回答说ASIL D&#xff0c;旁边的人就会心一笑&#xff0c;仿佛拿到了什么了不起的认证。但真到了评审会上&#xff0c;被问问“这个D是怎么评出…

作者头像 李华
网站建设 2026/10/2 6:21:29

STM32F429实战:LVGL 9.2移植与DMA2D性能优化全指南

在嵌入式GUI这个圈子里&#xff0c;LVGL的名字现在已经不新鲜了。9.2版本出来之后&#xff0c;渲染架构和API风格比8.x改动不小&#xff0c;很多从老版本迁移过来的朋友在移植时踩了不少坑。这次我拿正点原子的阿波罗STM32F429开发板做底子&#xff0c;把LVGL 9.2完整跑起来&am…

作者头像 李华