以Geohash為概念免費取得plugshare.com的充電站訊息內容-上集(更新sample code,2023.11測試有效)

Retrieve tesla charger point using geohash based method.

geohash、tesla charger、plugshare.com

Reference:https://github.com/jillesvangurp/geogeometryhttps://www.movable-type.co.uk/scripts/geohash.html

剛好遇到User需要取得特斯拉充電站的資訊,請我幫忙從https://www.plugshare.com/tw取得相關資訊,我原本以為這種資訊應該會跟openstreetmap一樣屬於開放資訊,沒想到居然是要收費的https://developer.plugshare.com/docs/,無奈在沒有經費的狀況下只好研究其他做法。

觀察

plugshare這個網頁可以依據當下顯示在畫面上的區域以及地圖放大的程度,吐回充電站資訊。

plugshare畫面檢視

於是心生一計,我何不針對我要的區域顯示在畫面上後,直接進瀏覽器將response的結果複製下來做parsing就好?

但事情往往不是憨人想的這麼簡單,仔細看了chrome開發者模式吐回來的訊息,一次最多只會回傳250個結果與畫面上顯示的數量不符。

F12進行request尋找
完整做法分析

我們先將網頁上實際呼叫的URL找出來,一樣可以從Chrome 按F12取得,就如下面的sample

https://api.plugshare.com/v3/locations/region?access=1&cost=true&count=500&include_coming_soon=true&latitude=25.122517931330474&longitude=121.59993785218444&minimal=0&outlets=[{"connector":6,"power":1},{"connector":13,"power":0},{"connector":3,"power":0},{"connector":2,"power":0},{"connector":6,"power":0},{"connector":4,"power":0},{"connector":7,"power":0},{"connector":5,"power":0}]&spanLat=0.37052687064651124&spanLng=0.350189208984375

其中latitude和longtitude就是你要尋找範圍的中心點。

spanLat和spanLng就是你要從這個中心點分別往左往右往上往下幾度(這兩個數值該用多少我後面會說明)。

API請參考https://developer.plugshare.com/docs/

plugshare API說明

同時我們也可以取得後面爬網時所需要的header資訊。

header資訊

了解API的特性後,我們可以歸納出作法。

要能夠爬回全台灣的充電站,我需要有系統性的找出固定間隔的中心點,並決定向左向右向上向下延伸多少範圍打出去的request可以確保回傳值是在250個充電站以下。

這時我們可以使用geohash的概念(可參考https://www.movable-type.co.uk/scripts/geohash.html),地圖可分為好幾種level大小的區塊。

這裡選用geohash level 6(1.22km × 0.61km),我嘗試過約在1.2公里的範圍內,回傳數量肯定小於250個。

給定一個polygon取得geohash清單並取得每個geohash中心,可以參考https://github.com/jillesvangurp/geogeometry的方法,其他語言例如python也有已開發好的套件。

這裡是本篇的關鍵技術,取得固定間隔的經緯度後,再往周邊延伸固定的距離,使其可以覆蓋到其他經緯度中心涵蓋的範圍

我自己是用上面提到的java套件去生成polygon內的固定間隔經緯度,python的部分可參考下面的sample code取得,polygon的經緯度點位可以自己在google map上點一點就可以記錄經緯度,或是到政府公開資訊尋找全台灣範圍經緯度的資訊。

polygon點位取得方法,依序點一點取得經緯度
# !pip install pygeohash
# !pip install shapely
# !pip install polygon-geohasher ->會遇到visual C++ 14以上版本安裝的問題,請先用conda install -c conda-forge python-geohash執行過一次再安裝

import pygeohash as gh
from polygon_geohasher.polygon_geohasher import polygon_to_geohashes, geohashes_to_polygon
from shapely import geometry

#輸入剛剛依序紀錄的polygon(一定要照你畫polygon的點順序)
polygon = geometry.Polygon([(-99.1795917, 19.432134), (-99.1656847, 19.429034),
                            (-99.1776492, 19.414236), (-99.1795917, 19.432134)])

inner_geohashes = polygon_to_geohashes(polygon, 6, False)#polygon轉geohash6
list(inner_geohashes)
gh.decode(list(inner_geohashes)[0])#解析出框定範圍內geohash區塊的每個中心經緯度(這裡只先解析一個,你可以自行for loop解析全部)

1.2公里是經緯度上大約是多少,可以參考http://alvin-0315.blogspot.com/2011/01/blog-post.html

1公尺約0.00000900900901度,1.2公里約是0.010810810812度。

我們將範圍擴大,spanLat和spanLng使用0.012度,當我們使用geohash中心往外擴大範圍,有中心點,有擴大範圍的數值,如此一來我們就可以產出完整的呼叫URL,如同下面的示意圖(這裡是用geohash 4呈現),每個呼叫的url也會cover到其他呼叫url的範圍,這可以避免漏抓,但最後處理清單時要去重。

以geohash4為範例的取得中心點與涵蓋圖

現在我們有全台灣區域固定間隔的中心點,也有需要延伸多少值後,便可以將前面API Sample中的經緯度與延伸值參數換掉。

中心點很多,因此你會需要打這個API很多次。

https://api.plugshare.com/v3/locations/region?access=1&cost=true&count=500&include_coming_soon=true&latitude=中心點緯度&longitude=中心點經度&minimal=0&outlets=[{"connector":6,"power":1},{"connector":13,"power":0},{"connector":3,"power":0},{"connector":2,"power":0},{"connector":6,"power":0},{"connector":4,"power":0},{"connector":7,"power":0},{"connector":5,"power":0}]&spanLat=0.012&spanLng=0.012

接著我們便可以撰寫python code,用python丟request的方式取得內容,但網頁有做cloudflare的防爬阻擋,需要用cloudscraper套件去爬,code如下:

我將產生出來的request URL清單存成文字檔一行一行讀取(teslaChargerAPI_little.csv),並在打完API後將回傳的字串取回存在另一個文字檔(teslaRequestAPIResult.csv)。

其中有的範圍URL打出去回傳為空,則跳過不處理,有時連續幾個小時太密集的打,還是會被plugshare這個網站回傳Access Denied擋下,這時我就會先停下過幾分鐘在重新打API。

import requests
import cloudscraper
import cfscrape
import time
import re
from datetime import datetime
import codecs

session = requests.Session()
session.headers = {
    'Accept'               : 'application/json, text/plain, /',
    'Accept-Encoding'      : 'gzip, deflate, br',
    'Accept-Language'      : 'zh-TW',
    'Authorization'        : 'Basic d2ViX3YyOkVOanNuUE54NHhXeHVkODU=',

    'Origin'               : 'https://www.plugshare.com',
    'Referer'              : 'https://www.plugshare.com/tw',
    'sec-ch-ua'            : '"Google Chrome";v="107", "Chromium";v="107", "Not=A?Brand";v="24"',
    'sec-ch-ua-mobile'     : '?0',
    'sec-ch-ua-platform'   : '"Windows"', #"Android"
    'Sec-Fetch-Dest'       : 'empty',
    'Sec-Fetch-Mode'       : 'cors',
    'Sec-Fetch-Site'       : 'same-site',
    'User-Agent'           : 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36'}
scraper = cloudscraper.create_scraper(delay=10, sess=session, browser={'browser': 'firefox','platform': 'windows','mobile': False})#, browser={'browser': 'chrome','platform': 'windows','desktop': True}


current_time = datetime.now()
print('開始', current_time)

path = 'D://workSpaceReunion/dataInOutput/plugShareDataParsing/step2_requestAPIResult/teslaRequestAPIResult.csv'#把打完結果存起來
f = codecs.open(path, 'w', 'utf-8')
#f.write('\ufeff')#Utf8-BOM
count = 0
accessDenied = []

try:
    with open('D://workSpaceReunion/dataInOutput/plugShareDataParsing/step1_produceGeohashApi/teslaChargerAPI_little.csv') as fr:#讀取API request字串執行
        for line in fr:
            count = count + 1
            result = scraper.get(line).text#自帶newline
            if result.strip().__eq__('[]'):
                print('skip')
            elif 'Access denied' in result:
                print('第'+str(count)+'筆 failed request')
                accessDenied.append(line)
            else:
                print('第'+str(count)+'筆')
                f.write(result)
            
            time.sleep(0.25)#睡一下避免被block
except:
    a = 0
finally:
    f.close()

current_time = datetime.now()
print('共'+str(count)+'筆')
print('結束',current_time)
爬取畫面

打完後,結果的檔案內容如下圖,每一行都是打出去後回傳有結果的資料:

有了這個資料,接下來就只要做字串的parsing和去重,我是用scala的code去做的,這裡一併附上實作的code:

import codecs

path = 'D://workSpaceReunion/dataInOutput/plugShareDataParsing/step3_requestAPIResultParsing/teslaRequestAPIResultParsing.csv'#把打完結果存起來
ff = codecs.open(path, 'w', 'utf-8')
#ff.write('\ufeff')#Utf8-BOM

try:
    with open('D://workSpaceReunion/dataInOutput/plugShareDataParsing/step2_requestAPIResult/teslaRequestAPIResult.csv') as fr:#讀取API request字串執行
        for line in fr:
            #轉碼
            cost_description = line.encode('utf-8').decode("unicode_escape")#.replace("\"","").replace(",","_").replace("\n","_").replace("\r","_")
            cost_description = re.sub('[\ud800-\udfff]+', '', cost_description)#print(needCost)

            #parsing
            cost_description = cost_description.replace("\n", "").replace("\r", "").replace(" ", "").replace("\"},{\"access\":1", "@@")
            cost_description_split = cost_description.split("@@")
            for chargerPointInfo in cost_description_split:
                info = chargerPointInfo.replace("\",\"icon_type\":\"", "@@").replace("\",\"id\":", "@@").replace(",\"latitude\":", "@@").replace(",\"longitude\":", "@@").replace(",\"name\":\"", "@@").replace(",\"stations\":", "@@").replace(",\"url\":\"", "@@").replace("\"}]", "").replace("\"", "")
                infoSplit        = info.split('@@')
                flag             = infoSplit[1]
                chargerId        = infoSplit[2]
                lat              = infoSplit[3]
                lng              = infoSplit[4]
                chargerName      = infoSplit[5]
                chargerDetailUrl = infoSplit[7]
                print(flag,chargerId,lat,lng,chargerName,chargerDetailUrl)
                ff.write(flag+','+chargerId+','+lat+','+lng+','+chargerName+','+chargerDetailUrl+'\n')
except:
    a = 0
finally:
    ff.close()

最後,mission accomplished~

下一篇,我會介紹如何爬取這些充電站點的細節資訊。