邊界數(shù)據(jù)爬取實(shí)戰(zhàn):從POI錨點(diǎn)到GeoJSON)
做小區(qū)邊界數(shù)據(jù)的時(shí)候很多朋友第一反應(yīng)是“直接去百度地圖上把邊界摳下來(lái)”。真操作起來(lái)你會(huì)發(fā)現(xiàn)這事兒沒(méi)有想象中那么簡(jiǎn)單。百度地圖的地圖瓦片是圖片格式想要得到矢量邊界就得換一套思路繞過(guò)去。我最早是被一個(gè)做房產(chǎn)數(shù)據(jù)分析的項(xiàng)目推到這條路上的需求很明確把目標(biāo)城市所有住宅小區(qū)的輪廓坐標(biāo)落進(jìn)數(shù)據(jù)庫(kù)供后續(xù)做空間關(guān)聯(lián)和可視化。折騰了幾個(gè)晚上最終跑通了一條還算穩(wěn)定的鏈路今天把完整過(guò)程拆開(kāi)講包括方案怎么選、接口怎么調(diào)、坐標(biāo)怎么處理、數(shù)據(jù)怎么驗(yàn)證以及我踩過(guò)的那些坑。這篇內(nèi)容適合已經(jīng)會(huì)用Python發(fā)HTTP請(qǐng)求、想拿真實(shí)地圖數(shù)據(jù)做分析的開(kāi)發(fā)者也適合剛接觸地理數(shù)據(jù)爬取、對(duì)百度地圖開(kāi)放平臺(tái)不太熟的新手。核心思路不復(fù)雜用百度地圖Web服務(wù)API的Place檢索接口拿小區(qū)POI錨點(diǎn)再用JS API的Boundary接口或者前端頁(yè)面內(nèi)部的數(shù)據(jù)接口拿邊界輪廓最后統(tǒng)一做坐標(biāo)糾偏、數(shù)據(jù)清洗和格式轉(zhuǎn)換。整個(gè)過(guò)程里最容易翻車(chē)的地方往往不是代碼而是對(duì)官方API的能力邊界理解不到位。1. 需求拆解與總體思路1.1 “爬小區(qū)邊界”到底在爬什么先明確一個(gè)概念百度地圖上的“小區(qū)邊界”在數(shù)據(jù)層面上不是一個(gè)整體文件而是分層存在的。第一層是POI點(diǎn)數(shù)據(jù)代表小區(qū)入口或中心點(diǎn)的經(jīng)緯度坐標(biāo)附帶名稱(chēng)、地址、類(lèi)型等屬性第二層是面數(shù)據(jù)也就是邊界的閉合多邊形由一串有序坐標(biāo)點(diǎn)組成。POI數(shù)據(jù)通過(guò)官方API就能拿到但面數(shù)據(jù)并沒(méi)有一個(gè)公開(kāi)的、一次性下載全量的接口。很多人一開(kāi)始會(huì)想到用爬蟲(chóng)直接抓百度地圖Web頁(yè)面的XHR請(qǐng)求比如拖動(dòng)地圖時(shí)network面板里出現(xiàn)的那一堆帶boundary字樣的JSON。這個(gè)思路理論上可行但實(shí)際做起來(lái)有門(mén)檻接口帶簽名參數(shù)、有訪(fǎng)問(wèn)頻率限制、返回的數(shù)據(jù)結(jié)構(gòu)會(huì)隨版本變化。另一個(gè)常見(jiàn)思路是用Selenium模擬操作去畫(huà)邊界但性能太差跑幾百個(gè)小區(qū)就讓人崩潰。我最終采用的方案是“官方API為主、前端接口為輔”的組合拳。先用Place檢索API把小區(qū)POI撈下來(lái)拿到每個(gè)小區(qū)的名稱(chēng)和中心點(diǎn)坐標(biāo)再以中心點(diǎn)為線(xiàn)索去請(qǐng)求邊界相關(guān)的數(shù)據(jù)接口。這個(gè)順序很重要因?yàn)榇蟛糠诌吔缃涌诙夹枰扔妹Q(chēng)或坐標(biāo)定位到具體小區(qū)才能返回對(duì)應(yīng)的圍欄坐標(biāo)。1.2 三種主流方案的優(yōu)劣對(duì)比在我動(dòng)手之前花了點(diǎn)時(shí)間把網(wǎng)上能找到的方式都過(guò)了一遍列個(gè)對(duì)比表供你參考方案數(shù)據(jù)來(lái)源優(yōu)點(diǎn)缺點(diǎn)適用場(chǎng)景Web服務(wù)APIPlace檢索官方穩(wěn)定、有配額、返回結(jié)構(gòu)化數(shù)據(jù)只有POI點(diǎn)沒(méi)有邊界多邊形批量位置篩查、作為錨點(diǎn)數(shù)據(jù)JS API Boundary接口官方能拿到部分邊界輪廓主要是行政區(qū)劃邊界小區(qū)級(jí)支持有限城市、區(qū)縣邊界獲取前端頁(yè)面XHR接口非官方數(shù)據(jù)全、直接有邊界坐標(biāo)接口會(huì)變、有風(fēng)控、需解析簽名小批量、研究性質(zhì)第三方數(shù)據(jù)商商業(yè)開(kāi)箱即用、數(shù)據(jù)完整收費(fèi)、更新周期不確定生產(chǎn)環(huán)境、預(yù)算充足我個(gè)人的建議是如果是正式項(xiàng)目、要長(zhǎng)期更新數(shù)據(jù)直接考慮采購(gòu)合規(guī)的商業(yè)數(shù)據(jù)如果是個(gè)人研究、原型驗(yàn)證或者數(shù)據(jù)量在幾千條量級(jí)以?xún)?nèi)官方API加前端接口的組合足夠用。下面我講的實(shí)操就是基于這個(gè)組合展開(kāi)的。1.3 為什么不能直接依賴(lài)HTML解析這里要專(zhuān)門(mén)說(shuō)一句因?yàn)楹笈_(tái)經(jīng)常有人問(wèn)“為什么不用BeautifulSoup去解析百度地圖的HTML頁(yè)面”。原因是百度地圖的前端頁(yè)面是重度異步渲染的頁(yè)面里的地圖數(shù)據(jù)是通過(guò)一堆加密的JavaScript動(dòng)態(tài)加載的HTML源碼里幾乎沒(méi)有POI和邊界信息。即便你用Selenium渲染完整頁(yè)面拿到的也只是canvas畫(huà)出來(lái)的像素不是結(jié)構(gòu)化坐標(biāo)數(shù)據(jù)。所以“爬”的落點(diǎn)不是HTML解析而是數(shù)據(jù)接口。這一點(diǎn)想通了后面就順了。用requests直接請(qǐng)求接口、處理JSON響應(yīng)比模擬瀏覽器操作要輕量得多也更容易控制頻率和出錯(cuò)重試。2. 關(guān)鍵技術(shù)方案選型2.1 官方Place檢索API怎么用百度地圖開(kāi)放平臺(tái)的Place檢索API是拿POI數(shù)據(jù)的正道。它支持區(qū)域檢索、圓形區(qū)域檢索和矩形區(qū)域檢索三種方式。對(duì)于“小區(qū)邊界爬取”這個(gè)需求我推薦用區(qū)域檢索按城市和關(guān)鍵詞“小區(qū)”來(lái)拉取POI列表。接口地址是https://api.map.baidu.com/place/v2/search關(guān)鍵參數(shù)包括query檢索關(guān)鍵詞填“小區(qū)”region行政區(qū)劃名稱(chēng)比如“北京市”output返回格式填jsonak你在百度地圖開(kāi)放平臺(tái)申請(qǐng)的密鑰scope填2這樣可以返回更詳細(xì)的POI信息包括uid和locationpage_size每頁(yè)返回?cái)?shù)量最大是20page_num頁(yè)碼從0開(kāi)始需要注意普通開(kāi)發(fā)者賬號(hào)的單日配額有限而且每個(gè)請(qǐng)求返回的total并不是全量數(shù)據(jù)量API存在一定的“最多返回前幾百條”的隱性限制。這個(gè)問(wèn)題我放在后面“常見(jiàn)問(wèn)題”里細(xì)講這里先按下不表。我拿到的返回結(jié)構(gòu)大概是這樣的{ status: 0, results: [ { name: 某某花園, location: { lat: 39.908823, lng: 116.397470 }, uid: e1f2c9d8a0b1c2d3e4f5, area: 朝陽(yáng)區(qū), detail_info: { tag: 住宅區(qū);小區(qū) } } ] }這里的lat和lng是百度坐標(biāo)系BD-09下的坐標(biāo)不是標(biāo)準(zhǔn)經(jīng)緯度后面要轉(zhuǎn)換先記住這一點(diǎn)。2.2 邊界數(shù)據(jù)從哪里來(lái)拿到POI錨點(diǎn)后下一步就是拿邊界。我試過(guò)三條路第一條是直接用JS API里的Boundary方法。但實(shí)測(cè)下來(lái)這個(gè)接口對(duì)行政區(qū)劃邊界支持得很好對(duì)小區(qū)級(jí)別的支持有限很多小區(qū)根本查不到對(duì)應(yīng)邊界。原因是百度并沒(méi)有把小區(qū)邊界做成一個(gè)公開(kāi)的行政邊界數(shù)據(jù)源開(kāi)放出來(lái)。第二條是抓百度地圖Web版頁(yè)面內(nèi)部使用的接口。在新版百度地圖的頁(yè)面里搜索某個(gè)小區(qū)后地圖上會(huì)畫(huà)出一個(gè)圍欄這個(gè)圍欄的坐標(biāo)就是從某個(gè)內(nèi)部接口返回的。這個(gè)接口的URL和參數(shù)會(huì)隨前端版本變化而且返回的數(shù)據(jù)里帶一些校驗(yàn)字段。這條路能走通但需要花時(shí)間逆向分析風(fēng)險(xiǎn)是接口隨時(shí)可能調(diào)整。第三條是我最后用的主力方案通過(guò)百度地圖拾取坐標(biāo)系統(tǒng)頁(yè)面的關(guān)聯(lián)接口來(lái)獲取。這個(gè)頁(yè)面本身是百度官方提供給開(kāi)發(fā)者用的工具用來(lái)查詢(xún)某個(gè)地點(diǎn)對(duì)應(yīng)的經(jīng)緯度和行政區(qū)劃信息。在查詢(xún)小區(qū)名稱(chēng)后頁(yè)面會(huì)調(diào)用一個(gè)內(nèi)部的geocoder接口返回的JSON里包含了地點(diǎn)詳情和經(jīng)緯度但依然沒(méi)有完整的邊界多邊形。說(shuō)到這里我得坦白一個(gè)現(xiàn)實(shí)百度地圖官方渠道并沒(méi)有一個(gè)完全公開(kāi)的小區(qū)邊界矢量數(shù)據(jù)接口。我最終的落地方案是把POI錨點(diǎn)拿到后結(jié)合高德地圖的行政區(qū)劃接口和第三方開(kāi)放數(shù)據(jù)比如某些城市開(kāi)放平臺(tái)發(fā)布的住宅小區(qū)輪廓數(shù)據(jù)做補(bǔ)充再用百度地圖的坐標(biāo)拾取器做交叉驗(yàn)證。這樣雖然不能保證每個(gè)小區(qū)都有精確的圍欄但錨點(diǎn)準(zhǔn)確性、屬性完整度都能達(dá)到分析需求。2.3 坐標(biāo)系的坑BD-09與GCJ-02百度地圖的坐標(biāo)系是BD-09高德地圖是GCJ-02兩者的坐標(biāo)值會(huì)有一個(gè)偏移。這個(gè)偏移足夠讓你在地圖上展示時(shí)錯(cuò)位幾十米到幾百米不等。如果你后面要做跨平臺(tái)的數(shù)據(jù)對(duì)比或者想用高德地圖做可視化就必須做坐標(biāo)轉(zhuǎn)換。百度官方提供了一個(gè)坐標(biāo)轉(zhuǎn)換API可以把百度坐標(biāo)轉(zhuǎn)成其他坐標(biāo)系https://api.map.baidu.com/geoconv/v1/?coords116.397470,39.908823from5to3ak你的AK這里from5代表源坐標(biāo)系是百度坐標(biāo)to3代表目標(biāo)坐標(biāo)系是GCJ-02高德坐標(biāo)。實(shí)際使用中我也試過(guò)在本地用算法做轉(zhuǎn)換網(wǎng)上有很多公開(kāi)的坐標(biāo)偏移算法代碼。但官方API的精度更好而且不容易因?yàn)樗惴ò姹静町惓霈F(xiàn)批次性偏移。建議優(yōu)先用API轉(zhuǎn)換如果請(qǐng)求量太大再用本地算法兜底。3. 實(shí)操完整流程與代碼實(shí)現(xiàn)3.1 環(huán)境準(zhǔn)備與密鑰申請(qǐng)動(dòng)手之前先把環(huán)境備齊。我用的Python 3.9依賴(lài)庫(kù)只有requests和json后面做數(shù)據(jù)保存時(shí)用geopandas寫(xiě)GeoJSON但這一步不是必須的單機(jī)小規(guī)模用普通JSON文件足夠。百度地圖開(kāi)放平臺(tái)的密鑰申請(qǐng)流程不復(fù)雜打開(kāi)百度地圖開(kāi)放平臺(tái)官網(wǎng)注冊(cè)并登錄賬號(hào)。進(jìn)入控制臺(tái)創(chuàng)建一個(gè)“服務(wù)端”類(lèi)型的應(yīng)用。應(yīng)用名稱(chēng)隨意填I(lǐng)P白名單留空表示不限制。創(chuàng)建完成后頁(yè)面會(huì)給你一個(gè)AK訪(fǎng)問(wèn)密鑰這就是后面所有接口請(qǐng)求都需要帶的參數(shù)。提示如果你在瀏覽器里看到請(qǐng)求返回APP Referer校驗(yàn)失敗之類(lèi)的錯(cuò)誤多半是因?yàn)榘褢?yīng)用類(lèi)型選成了“瀏覽器端”。做服務(wù)端爬取一定要選“服務(wù)端”。3.2 批量獲取小區(qū)POI錨點(diǎn)先寫(xiě)一個(gè)最基礎(chǔ)的請(qǐng)求函數(shù)用來(lái)翻頁(yè)拉取某個(gè)城市的小區(qū)POI數(shù)據(jù)。import requests import json import time AK 你的AK def search_community(city, page_num): url https://api.map.baidu.com/place/v2/search params { query: 小區(qū), region: city, output: json, scope: 2, page_size: 20, page_num: page_num, ak: AK } resp requests.get(url, paramsparams, timeout10) data resp.json() return data請(qǐng)求返回后把results里的POI字段抽出來(lái)保存成列表。這里有個(gè)細(xì)節(jié)百度Place API對(duì)同一關(guān)鍵詞的搜索最多返回幾百條POI不能像想象中那樣把整個(gè)城市所有小區(qū)都拉完。針對(duì)這個(gè)問(wèn)題我的折中策略是把城市切成多個(gè)檢索區(qū)域用矩形區(qū)域檢索去分塊拉取。矩形區(qū)域檢索的用法是換上bounds參數(shù)比如bounds39.80,116.20;40.10,116.50表示經(jīng)緯度范圍左下角和右上角的坐標(biāo)用分號(hào)分隔。然后在循環(huán)里按固定步長(zhǎng)滑動(dòng)這個(gè)矩形窗口把整個(gè)城市覆蓋一遍。這樣能拿到比單純區(qū)域檢索多不少的數(shù)據(jù)。def search_by_bounds(bounds_str, page_num): url https://api.map.baidu.com/place/v2/search params { query: 小區(qū), bounds: bounds_str, output: json, scope: 2, page_size: 20, page_num: page_num, ak: AK } resp requests.get(url, paramsparams, timeout10) return resp.json()這里要注意矩形檢索模式下region參數(shù)不能同時(shí)使用。另外分割矩形時(shí)步長(zhǎng)不要太大我建議每個(gè)窗口覆蓋大約0.1度乘以0.1度的范圍這樣既能控制POI密度又不會(huì)讓單個(gè)窗口返回超過(guò)20條的POI把數(shù)據(jù)沖掉。3.3 爬取邊界輪廓的推薦路徑拿到POI錨點(diǎn)后就進(jìn)入最核心的環(huán)節(jié)為每個(gè)小區(qū)匹配邊界輪廓。這里我嘗試過(guò)多條路徑最終穩(wěn)定可復(fù)現(xiàn)的方案是方案A通過(guò)百度頁(yè)面接口獲取指定小區(qū)邊界在百度地圖網(wǎng)頁(yè)版中搜索一個(gè)小區(qū)后界面會(huì)調(diào)用的接口大概形如https://map.baidu.com/?qtextuid你的小區(qū)UIDak你的AK這里的uid是Place檢索結(jié)果里POI自帶的那一串字符。這個(gè)接口的返回值里有時(shí)會(huì)包含content字段底下有g(shù)eo之類(lèi)的邊界信息。但實(shí)測(cè)下來(lái)這個(gè)接口的返回結(jié)構(gòu)和可用性非常不穩(wěn)定有的小區(qū)有有的小區(qū)沒(méi)有而且字段經(jīng)常改。方案B用高德地圖API補(bǔ)邊界如果目標(biāo)城市的小區(qū)邊界以高德數(shù)據(jù)為主那可以考慮用高德的行政區(qū)劃查詢(xún)接口。高德的接口對(duì)小區(qū)的支持比百度好一些https://restapi.amap.com/v3/geocode/geo?address小區(qū)名city城市名key你的高德Key返回的經(jīng)緯度是GCJ-02坐標(biāo)可以作為輔助錨點(diǎn)。但高德同樣沒(méi)有直接暴露小區(qū)多邊形邊界的接口。方案C結(jié)合開(kāi)源邊界數(shù)據(jù)做兜底真正能讓批量數(shù)據(jù)落地的方式是把上面拿到的錨點(diǎn)坐標(biāo)和網(wǎng)絡(luò)上已有的開(kāi)源小區(qū)輪廓數(shù)據(jù)做匹配。比如很多城市開(kāi)放數(shù)據(jù)平臺(tái)會(huì)發(fā)布住宅小區(qū)的矢量輪廓格式通常是GeoJSON或Shapefile。把POI錨點(diǎn)與這些數(shù)據(jù)按名稱(chēng)和空間位置做JOIN能匹配上的就保留輪廓匹配不上的就只保留錨點(diǎn)。跑完整個(gè)流程后我的數(shù)據(jù)表基本是這樣的結(jié)構(gòu)字段示例說(shuō)明name某某花園小區(qū)名稱(chēng)bd_lat39.908823百度坐標(biāo)緯度bd_lng116.397470百度坐標(biāo)經(jīng)度gcj_lat39.903824高德坐標(biāo)緯度gcj_lng116.391234高德坐標(biāo)經(jīng)度boundary[[116.3912,39.9038],...]多邊形坐標(biāo)可為空uide1f2c9d8a0b1百度POI唯一標(biāo)識(shí)3.4 批量坐標(biāo)轉(zhuǎn)換拿到百度坐標(biāo)后批量轉(zhuǎn)換我用的是官方geoconv接口一次最多轉(zhuǎn)換100個(gè)坐標(biāo)點(diǎn)。寫(xiě)個(gè)簡(jiǎn)單的批量處理函數(shù)def batch_convert(coords_list): result [] for i in range(0, len(coords_list), 100): batch coords_list[i:i100] coords_str ;.join([f{lng},{lat} for lat, lng in batch]) url https://api.map.baidu.com/geoconv/v1/ params { coords: coords_str, from: 5, to: 3, ak: AK } resp requests.get(url, paramsparams, timeout10) data resp.json() if data.get(status) 0: for item in data[result]: result.append((item[y], item[x])) time.sleep(0.3) return result注意我代碼里的sleep(0.3)這是必須的。雖然單個(gè)請(qǐng)求很輕但如果不限速阿克很快就會(huì)觸發(fā)并發(fā)限制然后整個(gè)IP段都會(huì)被臨時(shí)封掉。爬數(shù)據(jù)這件事穩(wěn)比快重要。3.5 輸出GeoJSON最后一步把數(shù)據(jù)結(jié)構(gòu)化成GeoJSON方便在QGIS、Mapbox或者任意地理可視化工具里打開(kāi)。import json geojson { type: FeatureCollection, features: [] } for item in data_list: feature { type: Feature, properties: { name: item[name], source: baidu }, geometry: { type: Point, coordinates: [item[gcj_lng], item[gcj_lat]] } } geojson[features].append(feature) with open(communities.geojson, w, encodingutf-8) as f: json.dump(geojson, f, ensure_asciiFalse, indent2)如果你拿到了邊界輪廓把Point換成Polygon即可geometry: { type: Polygon, coordinates: [[[lng1, lat1], [lng2, lat2], ...]] }4. 數(shù)據(jù)驗(yàn)證與常見(jiàn)問(wèn)題4.1 坐標(biāo)偏移導(dǎo)致的劃區(qū)錯(cuò)位我第一次把爬下來(lái)的POI數(shù)據(jù)疊加到高德底圖上時(shí)發(fā)現(xiàn)點(diǎn)位全部往東南方向偏移了幾百米。排查完后確認(rèn)就是坐標(biāo)系不一致的問(wèn)題。這個(gè)問(wèn)題的典型表現(xiàn)是百度坐標(biāo)在高德地圖上顯示時(shí)點(diǎn)位會(huì)偏向東北方向而高德坐標(biāo)在百度地圖上會(huì)偏向西南方向。解決辦法就是上面提到的geoconv轉(zhuǎn)換一定要做不要省。4.2 接口請(qǐng)求頻率限制百度地圖開(kāi)放平臺(tái)的默認(rèn)并發(fā)限制是每秒不超過(guò)10次超過(guò)后接口會(huì)返回APP并發(fā)超限。我連續(xù)踩了兩次之后在代碼里加了一個(gè)限速器也就是統(tǒng)一在每次請(qǐng)求之間sleep 0.2到0.5秒同時(shí)增加了請(qǐng)求失敗指數(shù)退避重試邏輯。import time import random def safe_request(url, params, retries3): for i in range(retries): try: resp requests.get(url, paramsparams, timeout10) data resp.json() if data.get(status) 0: return data elif data.get(status) 302: # IP被臨時(shí)限制等待較長(zhǎng)時(shí)間再重試 time.sleep(60) else: time.sleep(1) except Exception as e: print(e) time.sleep(2 * (i 1)) return None這個(gè)函數(shù)看著簡(jiǎn)單但在整個(gè)流程里幫我節(jié)省了大量時(shí)間。尤其是status302這個(gè)是百度專(zhuān)門(mén)用來(lái)限流的返回碼遇到它就別再瘋狂重試了直接等。4.3 Place API返回結(jié)果不全前面說(shuō)過(guò)Place API對(duì)同一個(gè)關(guān)鍵詞檢索有隱性結(jié)果截?cái)?。?shí)際表現(xiàn)是無(wú)論你怎么翻頁(yè)總POI數(shù)超過(guò)一定數(shù)量后頁(yè)數(shù)就不變了。這個(gè)問(wèn)題沒(méi)有完美的繞過(guò)方案但用矩形分塊可以極大緩解。分塊時(shí)注意兩個(gè)坑每個(gè)矩形塊返回的POI數(shù)量若達(dá)到20條上限說(shuō)明這個(gè)塊太小了可以適當(dāng)擴(kuò)大范圍再跑一次。若某個(gè)矩形塊返回的POI數(shù)量為0先別急著跳過(guò)有可能是這個(gè)塊內(nèi)確實(shí)沒(méi)有POI也有可能是接口在你請(qǐng)求時(shí)臨時(shí)抽風(fēng)。穩(wěn)妥起見(jiàn)可以重試一次。4.4 邊界輪廓經(jīng)常缺數(shù)據(jù)對(duì)于邊界缺失我的處理原則是不硬湊。有些小區(qū)在百度地圖上本身就沒(méi)有獨(dú)立的圍欄數(shù)據(jù)比如一些老式筒子樓或者新建未交付的樓盤(pán)。對(duì)于這類(lèi)情況保留錨點(diǎn)和屬性信息邊界字段留空即可。硬湊一個(gè)正方形輪廓出來(lái)的做法做出來(lái)的地圖一眼假分析結(jié)果也沒(méi)有意義。如果項(xiàng)目真的需要完整邊界建議轉(zhuǎn)向開(kāi)源的OpenStreetMap數(shù)據(jù)。從OSM上可以拿到部分住宅區(qū)面數(shù)據(jù)但覆蓋率和更新時(shí)效跟百度地圖比差不少適合做補(bǔ)充不適合做主力數(shù)據(jù)源。4.5 數(shù)據(jù)清洗與去重爬完數(shù)據(jù)后去重是必備步驟。同一個(gè)小區(qū)可能被多個(gè)矩形窗口重復(fù)采集到這時(shí)候需要按uid去重。如果換用了不同接口導(dǎo)致uid缺失就用“名稱(chēng)坐標(biāo)距離”雙重條件去重先按名稱(chēng)拼音聚簇再計(jì)算簇內(nèi)坐標(biāo)兩兩距離距離小于100米的視為重復(fù)。from math import radians, cos, sin, asin, sqrt def haversine(lng1, lat1, lng2, lat2): lng1, lat1, lng2, lat2 map(radians, [lng1, lat1, lng2, lat2]) dlng lng2 - lng1 dlat lat2 - lat1 a sin(dlat/2)**2 cos(lat1) * cos(lat2) * sin(dlng/2)**2 return 2 * 6371 * asin(sqrt(a))這是最常用的距離計(jì)算公式我在去重時(shí)會(huì)配合它做一個(gè)簡(jiǎn)單的循環(huán)判斷把距離過(guò)近的POI合并掉。4.6 數(shù)據(jù)可視化結(jié)果異常處理完的數(shù)據(jù)用QGIS打開(kāi)后如果發(fā)現(xiàn)點(diǎn)位分布非常奇怪比如大量POI聚集在同一條街上那大概率是矩形檢索窗口設(shè)計(jì)得有問(wèn)題。我遇到過(guò)一種情況某個(gè)窗口把城市里的主干道整個(gè)圈了進(jìn)去結(jié)果拉回來(lái)的POI全是沿街商鋪而不是小區(qū)。解決方法是增加POI標(biāo)簽過(guò)濾只保留detail_info.tag里包含“住宅區(qū)”或“小區(qū)”的記錄。5. 從爬蟲(chóng)到數(shù)據(jù)服務(wù)的實(shí)用心得5.1 先想清楚數(shù)據(jù)要拿來(lái)干什么爬數(shù)據(jù)的過(guò)程很有意思但我現(xiàn)在回頭看最關(guān)鍵的決策其實(shí)是在動(dòng)手之前做的——你到底要拿這些數(shù)據(jù)干嘛。如果只是想在地圖上點(diǎn)幾個(gè)點(diǎn)看分布那POI錨點(diǎn)就夠用了如果是想做覆蓋范圍分析、計(jì)算每個(gè)小區(qū)到地鐵站的距離那沒(méi)有邊界也能做用中心點(diǎn)代替如果是要做樓盤(pán)輪廓對(duì)比、拿多邊形做空間拓?fù)溥\(yùn)算那就必須找到可靠的邊界數(shù)據(jù)源這決定了你的技術(shù)方案完全不同。很多人一上來(lái)就盯著“邊界”兩個(gè)字忽略了業(yè)務(wù)本身對(duì)精度的要求。比如我之前做的項(xiàng)目實(shí)際上用中心點(diǎn)坐標(biāo)加一個(gè)預(yù)設(shè)半徑就能滿(mǎn)足需求根本不需要去死磕邊界輪廓白白浪費(fèi)了幾天時(shí)間。這個(gè)教訓(xùn)還挺深刻的。5.2 數(shù)據(jù)更新的節(jié)奏把握地圖POI數(shù)據(jù)是會(huì)變化的新樓盤(pán)入市、舊小區(qū)改造、物業(yè)更名都會(huì)影響數(shù)據(jù)質(zhì)量。我做數(shù)據(jù)同步時(shí)采用“全量周更、增量日更”的策略每周用Place API把所有目標(biāo)城市全量掃一遍保證基礎(chǔ)數(shù)據(jù)不丟每天對(duì)重點(diǎn)關(guān)注的高頻區(qū)域做一次小范圍增量更新。這樣既控制了API配額消耗又能保證關(guān)鍵數(shù)據(jù)的新鮮度。增量更新的做法不復(fù)雜就是拿已有的POI名稱(chēng)列表和新爬到的數(shù)據(jù)做比對(duì)凡是新出現(xiàn)的名稱(chēng)或者UID就標(biāo)記為新增凡是在舊數(shù)據(jù)里有但新數(shù)據(jù)里消失的就標(biāo)記為下架。5.3 不要忽視數(shù)據(jù)脫敏與合規(guī)最后提醒一下爬到的數(shù)據(jù)里包含小區(qū)名稱(chēng)和精確坐標(biāo)這類(lèi)數(shù)據(jù)屬于敏感地理信息。如果只是本地研究用途問(wèn)題不大但如果你要把數(shù)據(jù)發(fā)布出來(lái)、做展示、或者提供給第三方使用一定要謹(jǐn)慎。我個(gè)人的做法是對(duì)外展示時(shí)只保留到城市或區(qū)縣級(jí)別的聚合數(shù)據(jù)不給到具體小區(qū)坐標(biāo)也不做單點(diǎn)精確查詢(xún)的接口。合規(guī)這根弦比技術(shù)實(shí)現(xiàn)更重要?jiǎng)e等出了事再后悔。6. 代碼封裝做一個(gè)可復(fù)用的爬取工具6.1 完整工具類(lèi)結(jié)構(gòu)如果只是跑一次性的腳本上面那些零散的函數(shù)足夠用了。但要長(zhǎng)期維護(hù)數(shù)據(jù)更新我建議把代碼封裝成一個(gè)工具類(lèi)把API調(diào)用、數(shù)據(jù)存儲(chǔ)、日志記錄都統(tǒng)一起來(lái)。下面是一個(gè)簡(jiǎn)化版的類(lèi)結(jié)構(gòu)class BaiduCommunityCrawler: def __init__(self, ak): self.ak ak self.session requests.Session() self.base_url https://api.map.baidu.com def search_communities(self, region, page_num0, page_size20): pass def convert_coords(self, coords_list): pass def get_community_detail(self, uid): pass def save_to_geojson(self, file_path): pass6.2 增加持久化存儲(chǔ)數(shù)據(jù)量小的時(shí)候存在JSON文件里方便但數(shù)據(jù)量一旦上萬(wàn)條建議上SQLite。SQLite不需要額外部署服務(wù)Python標(biāo)準(zhǔn)庫(kù)自帶非常適合這種單機(jī)爬蟲(chóng)場(chǎng)景。import sqlite3 conn sqlite3.connect(communities.db) cursor conn.cursor() cursor.execute( CREATE TABLE IF NOT EXISTS communities ( id INTEGER PRIMARY KEY AUTOINCREMENT, uid TEXT UNIQUE, name TEXT, bd_lat REAL, bd_lng REAL, gcj_lat REAL, gcj_lng REAL, boundary TEXT, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ) conn.commit()數(shù)據(jù)庫(kù)表建好后每次爬取開(kāi)始先查一遍已有數(shù)據(jù)把已經(jīng)存在的UID過(guò)濾掉這樣能省下大量重復(fù)請(qǐng)求的配額。6.3 斷點(diǎn)續(xù)爬的實(shí)現(xiàn)爬蟲(chóng)最怕跑了一半崩了。為了應(yīng)對(duì)中斷我在代碼里加了斷點(diǎn)續(xù)爬的邏輯每處理完一個(gè)矩形窗口就把進(jìn)度寫(xiě)入一個(gè)單獨(dú)的progress.json文件里下次啟動(dòng)時(shí)讀取這個(gè)文件跳過(guò)已經(jīng)完成的窗口。這樣即使程序因?yàn)榫W(wǎng)絡(luò)波動(dòng)、斷電等原因中斷也不需要從頭再來(lái)。import os def save_progress(completed_windows): with open(progress.json, w, encodingutf-8) as f: json.dump({completed: completed_windows}, f) def load_progress(): if os.path.exists(progress.json): with open(progress.json, r, encodingutf-8) as f: return json.load(f).get(completed, []) return []6.4 多線(xiàn)程是否必要我的答案是個(gè)人項(xiàng)目完全沒(méi)必要。百度地圖API的并發(fā)限制擺在那里多線(xiàn)程不能幫你突破配額反而容易觸發(fā)封禁。與其花時(shí)間優(yōu)化并發(fā)不如把單線(xiàn)程跑穩(wěn)、做好重試和斷點(diǎn)續(xù)爬。如果你的數(shù)據(jù)量大到單線(xiàn)程跑不完更合適的方案是申請(qǐng)更高的配額或者換用商業(yè)數(shù)據(jù)源而不是在爬蟲(chóng)層面無(wú)限壓榨接口。7. 邊界數(shù)據(jù)缺失時(shí)應(yīng)考慮的替代方向7.1 城市開(kāi)放數(shù)據(jù)平臺(tái)國(guó)內(nèi)不少城市的數(shù)據(jù)開(kāi)放平臺(tái)會(huì)公布住宅小區(qū)的基礎(chǔ)信息部分城市甚至直接提供GeoJSON格式的小區(qū)邊界數(shù)據(jù)。這類(lèi)數(shù)據(jù)是政府發(fā)布的權(quán)威性和準(zhǔn)確性都不錯(cuò)但問(wèn)題是格式不統(tǒng)一、更新不及時(shí)而且不是所有城市都有。爬取百度地圖的同時(shí)可以順手把目標(biāo)城市的開(kāi)放數(shù)據(jù)平臺(tái)過(guò)一遍能補(bǔ)多少算多少。7.2 OpenStreetMap的building數(shù)據(jù)OSM上有一部分住宅區(qū)residential area的面數(shù)據(jù)雖然在國(guó)內(nèi)覆蓋有限但在一些城市的新區(qū)、開(kāi)發(fā)區(qū)OSM的數(shù)據(jù)反而比商業(yè)地圖更新更勤。通過(guò)Overpass API可以按區(qū)域條件查詢(xún)返回的JSON直接就是經(jīng)緯度坐標(biāo)。7.3 技術(shù)之外的替代思路如果最終拿不到精確邊界還有一個(gè)“曲線(xiàn)救國(guó)”的思路用小區(qū)POI錨點(diǎn)做緩沖區(qū)分析。具體做法是以POI為中心、按小區(qū)規(guī)模生成一個(gè)圓形或者橢圓的緩沖區(qū)作為邊界的近似替代。對(duì)大多數(shù)統(tǒng)計(jì)分析場(chǎng)景這種近似已經(jīng)夠用。如果再講究一點(diǎn)可以把緩沖區(qū)半徑和小區(qū)屬性做關(guān)聯(lián)比如戶(hù)數(shù)多的小區(qū)半徑大一點(diǎn)戶(hù)數(shù)少的小區(qū)半徑小一點(diǎn)。這個(gè)方法雖然學(xué)術(shù)上不夠嚴(yán)謹(jǐn)?shù)龀鰜?lái)的可視化效果和空間分析結(jié)果在業(yè)務(wù)層面是說(shuō)得通的。8. 最后的一些經(jīng)驗(yàn)提醒8.1 不要把接口文檔當(dāng)唯一參考百度地圖開(kāi)放平臺(tái)的文檔更新速度跟不上接口的實(shí)際變更速度。文檔里寫(xiě)的參數(shù)實(shí)際調(diào)用時(shí)可能會(huì)多出幾個(gè)默認(rèn)字段文檔里沒(méi)寫(xiě)的限制實(shí)際跑到某個(gè)量級(jí)就會(huì)冒出來(lái)。我的習(xí)慣是每次調(diào)用前把resp.url打出來(lái)看一眼確認(rèn)最終發(fā)出去的請(qǐng)求長(zhǎng)什么樣很多莫名其妙的錯(cuò)誤都是參數(shù)沒(méi)有被正確拼接導(dǎo)致的。8.2 一定要做好日志記錄寫(xiě)日志不是給機(jī)器看的是給你自己看的。我踩過(guò)的最大一個(gè)坑是數(shù)據(jù)爬到一半報(bào)錯(cuò)但錯(cuò)誤信息被吞掉了導(dǎo)致我花了一個(gè)小時(shí)排查才知道是AK過(guò)期了。從那以后我每個(gè)關(guān)鍵步驟都會(huì)加一行print或者logging把當(dāng)前頁(yè)碼、窗口范圍、返回狀態(tài)碼記錄下來(lái)。數(shù)據(jù)出問(wèn)題時(shí)翻日志比猜原因高效太多了。8.3 這個(gè)事深入下去還能做什么說(shuō)實(shí)話(huà)百度地圖小區(qū)邊界爬取這件事技術(shù)難點(diǎn)不算高真正有價(jià)值的是后面那一層拿到坐標(biāo)之后你能不能結(jié)合其他數(shù)據(jù)做出有用的分析。比如把小區(qū)POI和房?jī)r(jià)數(shù)據(jù)進(jìn)行空間關(guān)聯(lián)分析不同區(qū)域的小區(qū)密度與均價(jià)的關(guān)系或者把小區(qū)邊界和公交站點(diǎn)數(shù)據(jù)疊加計(jì)算每個(gè)小區(qū)的公共交通覆蓋率甚至可以把多個(gè)時(shí)間點(diǎn)的POI數(shù)據(jù)做對(duì)比觀察城市擴(kuò)張方向和新區(qū)開(kāi)發(fā)節(jié)奏。數(shù)據(jù)本身不會(huì)說(shuō)話(huà)但你整理好、分析好、可視化好它就能講出不少有意思的故事。