網路爬蟲簡介

網路爬蟲(web crawler)是一個自動化擷取網頁資訊的技術,透過編寫程式進而模擬使用者進行網頁的檢索,點擊,甚至滑動等行為,因此,爬蟲便可以取代重複性高的網頁操作,同時也可以擷取頁面上的內容。一般來說爬蟲技術依據網站的設計可分為靜態網站爬蟲與動態網站爬蟲兩種,兩者的差異在於客戶端與伺服器之間同步與非同步的交流:靜態網站完成一個請求與回應後,客戶端即不再與伺服器有任何的交流;而動態網站則不同,當客戶端完成第一次請求後,會透過非同步的方式依照使用者的行為不斷的與伺服器進行交流。舉例來說以Facebook為例,會因為每個人的帳號密碼等資訊,登入頁面後呈現不同的結果,這便是兩者最主要的差別。

一般來說爬取靜態網頁以HTML的解析模組BeautifulSoup或 lxml為主,主要是透過request的方式取得目標網站的頁面,最後利用前面提倒的套件進行解析,分離出需要的資料。而動態網頁資訊則以python selenium 的套件為大宗,它主要是控制 webdriver(瀏覽器)進行目標網站的渲染,同時可以以程式碼自動化完成許多動作,包括移動鼠標、點擊、滾動滾輪使頁面繼續向下面分頁繼續渲染、自動化輸入資訊等等,簡單來說selenium套件就是模擬真人實際操作的流程,每次執行時很像在看影片相當療癒XDD。

前面介紹了網路爬蟲的兩種形式,那我今天要爬取的591網站呢??嗯他就只是一般的靜態網頁,所以只需要以靜態網站的爬取方式進行就可以了!!至於對動態爬蟲有興趣的朋友可以參考其他網路上的教學XDD

591網站爬蟲實作

爬蟲範本

開發爬蟲的基本流程不外乎以下:

  1. 確認目標資訊:是要爬取什麼網站中的什麼資料,如googdinfo網站上的股票價格,591網站上房價的資訊等等。
  2. 初步觀察網頁:觀察網頁是動態網站或是靜態網站,初步評估可能會用到什麼技術。
  3. 分析網頁架構:透過網頁瀏覽器檢查網頁內容,如資料在HTML元素的什麼位置等。
  4. 解析內容:透過程式取得網頁,並取出所需的資訊。
    (取得資料後要存檔案還是資料庫就看個人的需求另行調整)

那在確認591網站上面展示的資料後,發現這是複雜度比較低的靜態網頁,比較好解決。我上網果然找到幾個前輩分享自己的做法,在這裡推薦Luca Chuang大大寫的這篇591售屋網-Python爬蟲。裡面提供了一個Colab版本的程式碼可以直接執行,但似乎是因為經歷591網站的改版,所以無法正確執行。我稍微check程式碼發現應該是舊有的程式碼無法正確爬取改版後的網站資訊,因此下面展示的程式碼會依照Luca Chuang大大的範本為架構,改成功能正常且符合個人需求的程式碼。

若想要完整的專案的程式碼,附上github的連結,以下挑幾個重要地方進行說明。

反爬蟲技術

一般來說,各種商業網站都會建置基礎的反爬蟲機制,來避免被惡意的爬蟲攻擊伺服器進而造成網案癱瘓。以下提供幾個簡單的破解反爬蟲的技術。
免責聲明一下:以下僅提供研究用途,請做個有品的工程師,切勿爬取資訊作為商業用途,也切勿惡意攻擊或癱瘓網站。

  1. header 資訊
    加入hearder資訊是要讓爬蟲程式戴上一般瀏覽器的外皮,否則在request時很容易被網站發現是爬蟲程式進而阻擋下來。

  2. random sleep
    爬蟲程式畢竟是機器進行執行,很容易出現不合理的行為,例如程式利用迴圈相當容易做到一秒request 50次的情況,但換作人類的手速怎麼可能一秒點擊50下進行50次的request呢~況且短時間進行太多次的requests 很容易癱瘓對方網站的伺服器,這是新手很容易忽略的問題,因此一個有品的工程師會適當的讓程式休息(sleep)不固定(random)的秒數,然而到底要如何設置random sleep的秒數就是門藝術,這個部分只能靠部分的測試和經驗而定了,睡得太少會變得沒有效果進而又被反爬蟲ban掉,睡太多又會造成曠日費時。

  3. 替換proxy 代理
    其實上面的兩個技術就可以爬取許多靜態的網頁,然而在程式執行的過程中還是用同一個來源IP進行很多次的查詢,看各個網站的反爬蟲設置,偶爾還是因為單一來源IP進行不合理次數的request而被偵測到。因此透過替換proxy 代理IP就可以就可以解決這件事,但proxy也不是隨便都有,這裡提供一個Scraper API的服務,免費註冊一個帳號,就可以擁有一定額度的credit,透過API的串接,會在request自動替換不同的proxy讓網站的反爬蟲機制更難被偵測到,此外這個網站也提供各種爬蟲的服務,但似乎需要訂閱方案,就看各位的需要了~~

實作程式碼

  • import相關函式庫

    import requests
      import time
      from bs4 import BeautifulSoup
      import pandas as pd
      import warnings
      import datetime
      import requests
      import pandas as pd
      import time
      import csv
      import io
      import json
      import random
      import math
      import datetime
    
  • 設置header與proxy

    headers={
          'user-agent' : 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.125 Safari/537.36'
          }
      payload = { 'api_key': 'your_API_key', 'url': 'http://newhouse.591.com.tw' }
      request_url='http://newhouse.591.com.tw/home/housing/info?hid=119282'
      res=requests.get(request_url, headers = headers,params=payload)
      bs=BeautifulSoup(res.text,'html.parser')
      print(bs)
    

    登入Scraper可以取得自己的API Key,可以利用parameter的設置方式將資訊嵌入request中。此外,提供一個我夢裡家的樣子XDD吉美大安花園,展示了591建案物件的頁面,可以測試看看是否可以正確取得並利用BeautifulSoup解析頁面資訊。

  • 解析資訊

    # input 建案網址 return 建案建案詳情欄位
      # 產出為建案名與16個建案資訊
    def getData(url):
        request_url='https://newhouse.591.com.tw/home/housing/info?hid='+str(url).strip()
        headers={
        'user-agent' : 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.125 Safari/537.36'
        }
        res=requests.get(request_url, headers = headers,params=payload)
        #bs=BeautifulSoup(res.text,'html.parser')
        request_url_detail='https://newhouse.591.com.tw/home/housing/info?hid='+str(url).strip()+"/detail"
        res_detail=requests.get(request_url_detail, headers = headers,params=payload)
        if res.status_code == 200 & res_detail.status_code==200:
            bs=BeautifulSoup(res.text,'html.parser')
            bs_detail=BeautifulSoup(res_detail.text,'html.parser')
            #先宣告變數為NULL 若無撈到資料則寫入NULL
            title="NULL"
            # 利用 beautfiulsoup 的 find function 利用 css selector 定位 並撈出指定資料
            # 利用 try except 若無撈到資料則寫入NULL
            title=bs.find('h1').text
            try:
              tag = ', '.join([span.text.strip() for span in bs.find("p", {'class':"build-tag"}).find_all("span")]) if bs.find("p", {'class':"build-tag"}) else "未找到相應標籤" #建案標籤
            except:
              tag = "NULL"
            try:
              unit_price=bs.find("span", {'class':"price"}).text #單價
            except:
              unit_price= "NULL"
            try:
              unit = bs.find("span", {'class':"unit"}).text #單位
            except:
              unit = "NULL"
            try:
              material = bs.find("h4",text="建材說明").findNext("p").text #建材說明
            except:
              material= "NULL"
            try:
              htype=bs_detail.find("span", text="建案類別").findNext("p").string.strip() #建案類別
            except:
              htype= "NULL"
            try:
              htype2=bs_detail.find("span", text="建案型態").findNext("p").string.strip().replace(' ', '').replace('\n', '、') #建案型態
            except:
              htype2= "NULL"
            try:
              htype3=bs_detail.find("span", text="公開銷售").findNext("p").string.strip() #公開銷售
            except:
              htype3= "NULL"
            try:
              htype4=bs_detail.find("p", {'class':"address"}).findNext("span").text #基地地址
            except:
              htype4= "NULL"
            try:
              htype5=bs_detail.find("span", text="交屋屋況").findNext("p").string.strip() #交屋屋況
            except:
              htype5= "NULL"
            try:
              htype6=bs_detail.find("span",text="格局規劃").findNext("p").text.strip().replace(' ', '') #格局規劃
            except:
              htype6= "NULL"
            try:
              htype7=bs_detail.find("span", text="投資建設").findNext("p").string.strip() #投資建設
            except:
              htype7= "NULL"
            try:
              htype8=bs_detail.find("span", text="營造公司").findNext("p").string.strip() #營造公司
            except:
              htype8= "NULL"
            try:
              htype9=bs_detail.find("span", text="棟戶規劃").findNext("p").string.strip() #棟戶規劃
            except:
              htype9= "NULL"
            try:
              htype10=bs_detail.find("span", text="樓層規劃").findNext("p").string.strip() #樓層規劃
            except:
              htype10= "NULL"
            try:
              htype11=bs_detail.find("span", text="用途規劃").findNext("p").string.strip() #用途規劃
            except:
              htype11= "NULL"
            return title,tag,unit_price,unit,material,htype, htype2, htype3, htype4, htype5, htype6, htype7, htype8, htype9, htype10, htype11
        else:
            print('link expired:', url)
            return 404, 404, 404, 404, 404, 404, 404, 404, 404, 404, 404, 404, 404, 404, 404, 404
    

    這裡我主要修改了擷取資料的方式,有別於Luca Chuang大大當時591的網站設計,這個版本有很多資訊躲進/detail頁面,因此當初花了一點時間確認自己要的資訊分別在什麼頁面,並加上一些try和except的邏輯。但還是要再次強調,以上的資訊只是符合我個人的需求,不可能符合每一個人的資訊擷取需求,仍須按照不同需求進行調整。

  • 整理成DataFrame並儲存成excel

    def main(outputfile, rid, sid, totalpages):
          totalpages = totalpages
          print('Total pages: ', totalpages)
          columns = [ "建案名稱", "建案標籤","單價","單位","建材說明" ,"建案類別", "建物形態", "公開銷售", "基地地址", "交屋屋況"\
                              ,"格局規劃", "投資建設", "營造公司", "棟戶規劃", "樓層規劃", "用途規劃", "網址"]
          df = pd.DataFrame(columns=columns)
          for i in range(1, totalpages+1):
              request_url = "https://newhouse.591.com.tw/home/housing/search?rid="+str(rid)+"&sid="+str(sid)+"&page="+str(i)
              response = requests.get(request_url, headers = headers,params=payload)
              response = response.json()
              items = response["data"]["items"]
    
              house_url_list=[] #存放網址list
              for key in items:
                  id = key["hid"] # 每個物件的 id
                  house_url_list.append(id)
              time.sleep(random.randint(5,15)) #反爬蟲
    
              for url in house_url_list:
                  title,tag,unit_price,unit,material,htype, htype2, htype3, htype4, htype5, htype6, htype7, htype8, htype9, htype10, htype11 = getData(url)
                  # 創建新的DataFrame
                  new_row = pd.DataFrame([[title, tag, unit_price, unit, material, htype, htype2, htype3, htype4, htype5, htype6, htype7, htype8, htype9, htype10, htype11, str('https://newhouse.591.com.tw/home/housing/info?hid='+str(url))]],
                                          columns=columns)
                  # 將新的DataFrame添加到原有的df中
                  df = df.append(new_row, ignore_index=True)
              # ------------------------------------------ #
              print(i/totalpages*100, '%') # print out 完成 %數
              if round(i/totalpages*100)%5==0:
                  df.to_excel(outputfile)
          df.to_excel(outputfile)
    

    這裡就只是加入先前提到的反爬蟲技術“time.sleep(random.randint(5,15)) #反爬蟲”已回圈方式進頁面request,並針對先前取得的資訊依序整理成DataFrame,最後儲存成excel檔案。

  • Main Function

    if __name__ == '__main__':
          # -------- configurable parameter -------- #
          # 以台北市不限區舉例(預設網址可能沒寫rid&sid, 點選縣市或區往只會顯示如下)
          # link:https://newhouse.591.com.tw/housing-list.html?rid=1&sid=0
    
          request_url = "https://newhouse.591.com.tw/home/housing/search?rid=0&sid=0"
          headers={
              'user-agent' : 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.125 Safari/537.36'
              }
          response = requests.get(request_url, headers = headers)
          response = response.json()
          total = response["data"]["total"]
          per_page = response["data"]["per_page"]
          year = datetime.datetime.now().year
          month = datetime.datetime.now().month
          day = datetime.datetime.now().day
    
          output_file_name = f'newhouse591_data_{year}{month:02d}{day:02d}.xlsx' #設定存放位置與檔名
          rid = 0           # 設定縣市 (台北市 rid = 1)
          sid = 0           # 設定地區 (不限區 sid = 0)
          totalpages = math.ceil(total/per_page)    # 設定抓取頁數
          # ---------------------------------------- #
    
          main(output_file_name, rid, sid, totalpages)                                          #匯出excel檔
          print('\nfinish!')
    

    以主程式作為整個爬蟲程式的入口,設置存檔的日期方式,並加入欲爬取的縣市區域參數可供大家調整。

Stremlit 視覺化

關於透過streamlit儀表板將資料視覺化先前已經提過許多次,有興趣看這篇,在這裡我只是想補充另一個streamlit運用plotly繪製choropleth地圖的方式而已,完整的程式碼。若想看完整的網頁專案,591新建案房價與建材分析

台灣直轄市地圖

當初會想要繪製分級著色圖(choropleth)是希望可以藉由顏色顯示不同城市之間的價格,但需要繪製choropleth就需要事件下載台灣的地圖資料,所幸政府資料開放平台直轄市、縣市界線(TWD97經緯度)可以供大家下載。

plotly繪製choropleth圖

這裡介紹plotly如何繪製choropleth圖,首先要將下載好的shapefile轉成geojson檔

import geopandas as gp
#read shapefile
countryshp = gp.read_file(r"asset/mapdata202301070205/COUNTY_MOI_1090820.shp"
                         ,encoding='UTF-8')#without encoding is ok! 
# countryshp.index = countryshp['COUNTYNAME']
# 將shp檔案格式轉為geoJson檔案格式
countryshp.to_file('asset/mapdata202301070205/COUNTY_MOI_1090820.geojson', driver='GeoJSON', epsg=4326)  # 經緯度由epsg3826(TWD97)轉換為epsg4326(WGS84)

接者,利用plotly的choropleth_mapbox繪製地圖,各項的參數以註解標示。

# 利用json模組,讀入geojson檔案:
    # 讀取geojson
    with open('asset/mapdata202301070205/COUNTY_MOI_1090820.geojson', encoding='utf8') as response:
        mapGeo = json.load(response)

    NON_AST_country_Price_table = Choropleth_Data(NON_AST_data)
    # 繪製地坪單價地圖
    NON_AST_choropleth_map = px.choropleth_mapbox                      (NON_AST_country_Price_table,           # 資料表
        geojson=mapGeo,                                    # 地圖資訊
        locations='COUNTYCODE',                           # df要對應geojson的id名稱
        featureidkey='properties.COUNTYCODE',             # geojson對應df的id名稱
        color='單價',                                      # 顏色區分對象
        color_continuous_scale='YlOrRd',                  # 設定呈現的顏色 (RdPu)
        range_color=(round(np.nanmin(NON_AST_country_Price_table['單價'])),    # 顏色的值域範圍
                        round(np.nanmax(NON_AST_country_Price_table['單價']))),   
        mapbox_style='carto-positron',                    # mapbox地圖格式
        zoom=6,                                           # 地圖縮放大小: 數字愈大放大程度愈大
        center={'lat': 23.5832, 'lon': 120.5825},         # 地圖中心位置: 此處設定台灣地理中心碑經緯度
        opacity=0.7,                                      # 設定顏色區塊的透明度 數值愈大愈不透明
        hover_data=['縣市', "單價"]  # 設定游標指向資訊
                            )
    # 在地圖上加上標題
    NON_AST_choropleth_map.update_layout(title='全台非制震宅新建案地坪單價圖',title_x=0.02 ,title_y=0.95)  # 調整 title_x 和 title_y
    NON_AST_choropleth_map.update_layout(margin={'r':0, 't':0, 'l':0, 'b':0},coloraxis_colorbar=dict(title='單價(萬/坪)'))
    st.plotly_chart(NON_AST_choropleth_map,use_container_width=True)

最後成果如下圖 image.png

boba-icon
請我喝珍奶!