2018年6月22日 星期五

Python 學習筆記 : 網頁擷取 (一) 使用 urllib 與 HTMLParser

上個月測試完 Python 的 SQLite 與 MySQL 資料庫操作後, 本來想要繼續測試 NoSQL 資料庫 (例如 MongoDB), 但想想有 SQLite 與 MySQL 應該就夠用了. 我比較急著想要測試的是 Python 的網頁擷取功能, 也就是撰寫網路爬蟲程式.

所謂網頁擷取 (Web scraping) 是指利用程式下載網頁並從中擷取資訊的技術, 這種技術極具變化性與挑戰性, 因為每一個目標網頁呈現內容的方式都不同, 因此沒有一個通用的資料擷取函數可用, 必須依要探索之目標網頁量身訂做. 這種專門設計用來擷取網頁內容的程式稱為網路爬蟲 (Web crawler/scrapper), 例如 Google 便是利用網路爬蟲取得 Internet 上的網頁內容以便製作搜尋引擎所需之索引.

理想上, 如果每一個網站都有提供 API 讓使用者下載網頁內容之結構化格式 (如 JSON 或 XML 等) 資料給使用者, 那就不需要網頁擷取這種技術了; 但事實上只有部分網站例如臉書或推特有提供 API 或 RSS, 絕大部分的網站都是從資料庫中提取資料後將其嵌在 HTML 網頁中輸出, 因此若要從目標網頁中撈取這些資訊就只能利用網路爬蟲了.

任何可用瀏覽器閱讀的內容都能用網路爬蟲程式抓下來進行剖析以擷取其中有價值之資訊, 然後透過程序排程器 (例如 crontab) 即可將原本需要手動經由瀏覽器查詢才能獲取資料進行分析預測的作業全部自動化, 因此網路爬蟲可說是現代的網路煉金術.

Python 有豐富的函式庫支援網頁擷取功能, 例如內建的 urllib, HTMLParser, 以及功能強大的第三方套件如 requests, ButterflySoup, Scrapy, 與 Sellenium 等方便好用工具, 因此 Python 可說是撰寫網路爬蟲的最佳語言.

不過利用網路爬蟲擷取公開網頁雖然基本上合法, 但實務上須注意下列事項 :
  1. 向網站伺服器提出網頁要求 (Request) 時須修改 HTTP 標頭 (Headers) 中的 User-Agent 選項偽裝成一般瀏覽器, 因為有些伺服器會阻擋非瀏覽器取得網頁.
  2. 下載同一網站網頁的頻率不可太高, 某些網站會封鎖高頻存取之 IP, 甚至以頻繁擷取影響伺服器穩定為由提起法律訴訟.  
  3. 擷取網頁內容作為私人用途沒有問題; 但若將其重新公開發布於自己的網站則可能有法律問題. 特別是有版權之網頁, 須注意勿違反其服務條款.
以下的測試參考了下列 Python 網頁擷取相關書籍 :
  1.  Python 網路爬蟲實戰 (松崗, 胡松濤)
  2.  Python 自動化的樂趣 (碁峰, AL Sweigart)
  3.  Python 程式設計實務 (博碩, 何敏煌)
  4.  Python 初學特訓班 (碁峰, 文淵閣工作室)
  5.  網站擷取 : 使用 Python (碁峰, Ryan Mitchell)
  6.  Python 入門邁向高手之路-王者歸來 (深石, 洪錦魁)
  7.  Python 程式設計入門指南 (碁峰, 蔡明志譯)
  8.  Web Scraping with Python (Packt, Richard Lawson)
  9.  Python Web Scraping Cookbook (Packt, Michael Heydt)
  10.  Automate the Boring Stuff with Python (Starch, AL Sweigart)
  11.  Introduction to Programming Using Python (Pearson, Y. Daniel Liang)
  12.  Python Web Scraping : Fetching data from the web (Packt, Katharine Jarmul)
  13.  Data Wrangling with Python (O'Reilly, Jacqueline Kazil & Katharine Jarmul)
  14.  Web Scraping with Python : Collecting Data from the Modern Web (O'Reilly, Ryan Mitchell) 
本系列之前的文章參考 :

Python 學習筆記 : 安裝執行環境與 IDLE 基本操作
Python 學習筆記 : 檔案處理
Python 學習筆記 : 日誌 (logging) 模組測試
Python 學習筆記 : 資料庫存取測試 (一) SQLite
Python 學習筆記 : 資料庫存取測試 (二) MySQL


Pyhton 2.x 版在網頁擷取方面內建了 urllib 與 urllib2 兩種套件, 都是用來處理 URL 要求用的, 但其功能不同, 主要差異有兩點 :
  1. urllib2 可傳入 Request 物件來設定 URL 要求之標頭; 而 urllib 則只能傳入 URL 字串, 無法操縱標頭資訊, 例如利用竄改 USER AGENT 來進行偽裝.
  2. urllib 有一個 urlencode() 方法可用來產生 GET 查詢字串, 但 urllib2 卻沒有此方法. 
由於有這些差異, 使得用 Python 2.x 版擷取網頁時通常需要同時使用 urllib 與 urllib2. 參考 :

Python: difference between urllib and urllib2
What are the differences between the urllib, urllib2, and requests module?

但是在 Python 3 版已經將這兩個模組整合為 urllib 一個套件, 並將整個套件重組為 urllib.request, urllib.parse, urllib.error, urllib.robotparser 等四個模組 :

 模組 說明
 urllib.request 開啟並讀取 URL
 urllib.error 捕捉 urllib.requests 引起之例外
 urllib.parse 剖析 URL
 urllib.robotparser 剖析 robot.txt 檔

參考 :

https://docs.python.org/3/library/urllib.html
https://docs.python.org/2/library/urllib2.html

以下將以 Python 3 內建的 urllib 模組來擷取並剖析網頁.  


一. 使用 urllib.prase 解析 URL :

urllib 模組中的 urllib.parse 類別用來解析與組合 URL 字串, 此類別常用方法如下 :

 方法 說明
 urlparse(url) 解析傳入之 url, 傳回 ParseResult 物件
 urlunparse(parts) 將 urlparse() 拆解出來的 URL 部件組合回 URL 
 urljoin(base, url) 將 base 位址與 url 位址組合成絕對 URL 網址

urllib.parse.urlparse(url) 方法會解析傳入之 url 字串, 並將結果以 ParseResult 物件 (是一個 tuple) 傳回, 此物件包含下列屬性與方法 :
  1. sheme (協定)
  2. netloc (網址)
  3. path (路徑)
  4. params (參數)
  5. query (查詢)
  6. fragment (頁內錨點)
  7. geturl() 方法 
其中 scheme 是指 http, https 等協定; netloc 是網址或網域名稱; path 是資源檔案之路徑, query 是 ? 後面的查詢字串 fragment 則是 # 後面的頁內錨點 (params 不知是啥?), 參考 :

https://en.wikipedia.org/wiki/Fragment_identifier 

urlunparse() 方法功能與 urlparse() 相反, 將 urlparse() 解析出來的 ParseResult 物件 (即 URL 的各成分組成之 Tuple) 傳入, 此方法會將其組合回完整之 URL. urljoin() 方法則是將傳入之 base 網址與 url 資源檔名兩部分組合為完整之 URL 網址, 若 base 含有資源檔名, 則將被第二參數 url 檔名取代 .

測試範例如下 :

>>> from urllib.parse import urlparse
>>> from urllib.parse import urlunparse
>>> from urllib.parse import urljoin
>>> urla="http://www.abc.com/abc.htm?a=1&b=2&c=3"  #有 query 字串
>>> result=urlparse(urla)
>>> type(result)
<class 'urllib.parse.ParseResult'>
>>> result
ParseResult(scheme='http', netloc='www.abc.com', path='/a/b/c/abc.php', params='', query='a=1&b=2&c=3', fragment='')
>>> result.scheme
'http'
>>> result.netloc
'www.abc.com'
>>> result.path
'/a/b/c/abc.php'
>>> result.params
''
>>> result.query
'a=1&b=2&c=3'
>>> result.fragment
''
>>> result.geturl()
'http://www.abc.com/a/b/c/abc.php?a=1&b=2&c=3'
>>> urlunparse(result) 
'http://www.abc.com/a/b/c/abc.php?a=1&b=2&c=3'
>>> urlb="http://www.abc.com/abc.htm#home"     #有 fragment
>>> urlparse(urlb)          #將 ParseResult 部件組合回完整 URL
ParseResult(scheme='http', netloc='www.abc.com', path='/abc.htm', params='', query='', fragment='home') 
>>> urljoin('http://www.abc.com/abc.htm', 'xyz.htm')     #替換 base 中的資源檔
'http://www.abc.com/xyz.htm'
>>> urljoin('http://www.abc.com', 'xyz.htm')       #串接 base 與資源檔
'http://www.abc.com/xyz.htm'


二. 使用 url.request 下載網頁 : 

urllib.request 是 Python 內建的網頁擷取模組, 其 urlopen() 方法是基於檔案處理的 open() 方法, 以 Connection: Close 標頭向指定之 URL, 向伺服器提出 HTTP/1.1 要求以下載並開啟網頁, 功能相當於 Python 2.x 版的 urllib2.urlopen(), 其 API 如下 :

urllib.request.urlopen(url, data=None, [timeout, ]*, cafile=None, capath=None, cadefault=False, context=None)

其中必要傳入參數 url 為欲開啟之 URL 字串或 Request 物件; data 為一個 urllib.request.Request 物件, 主要用來設定標頭資訊. 備選參數 timeout 用來指定 HTTP, HTTS, 以及 FTP 要求之逾時時間 (單位為秒), 未傳入時以全域預設值為準.

urllib.request.urlopen() 會傳回一個 http.client.HTTPResponse 物件, 可呼叫其 read() 方法來取得伺服器回應之訊息. 我之前在寫 PHP 爬蟲時所參考的 "Webbots, Spiders, And Screen Scrapers (No Starch Press, Michael Shrenk)" 一書中, 作者在其網站中提供了一個簡單的網頁可做為測試的標的 :

http://www.webbotsspidersscreenscrapers.com/hello_world.html

這網頁很簡單, 就是顯示兩行文字而已. 其 HTML 原始碼如下 (Chrome 按 F12) :

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">

<html>
<head>
<title>Hello, world!</title>
</head>

<body>
Congratulations! If you can read this, <br>
you successfully downloaded this file.
</body>
</html>

將此網頁網址傳入 urllib.request.open() 即可下載此網頁並傳回 HTTPResponse 物件 : 

>>> import urllib.request    
>>> url="http://www.webbotsspidersscreenscrapers.com/hello_world.html"   
>>> response=urllib.request.urlopen(url)    
>>> type(respone)   
<class 'http.client.HTTPResponse'>       #傳回 HTTPResponse 物件
>>> response.read().decode()         #讀取網頁內容並以預設 utf-8 格式解碼
'<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">\r\n\r\n<html>\r\n<head>\r\n\t<title>Hello, world!</title>\r\n</head>\r\n\r\n<body>\r\nCongratulations! If you can read this, <br>\r\nyou successfully downloaded this file.\r\n</body>\r\n</html>\r\n'

此處 response.read() 預設是讀取下載檔案之全部內容, 但也可以傳入整數指定要讀取幾個 bytes. response.read() 讀回來的是 Byte 類型資料, 需呼叫 decode() 來解碼, 可傳入明確之編碼類型例如 "big5" 或 "utf-8" (預設), 也可以呼叫 Response 物件的 headers 物件之 get_content_charset() 方法從回應訊息中取得回應網頁之編碼方式. 一般正常網頁都會在 meta 標籤中設定 charset, 例如 Yahoo 股市中台積電 2330 的股利政策網頁編碼是 big5 :

>>> url="http://tw.stock.yahoo.com/d/s/dividend_2330.html"   
>>> response=urllib.request.urlopen(url)   
>>> response.headers.get_content_charset()     #編碼為 big5
'big5'     
>>> content=response.read().decode(response.headers.get_content_charset())
>>> type(content)
<class 'str'>

要注意的是, decode() 之傳入參數為字串 (可以不傳參數), 不可傳入 None, 否則會出現錯誤訊息. 下面的範例中因原始網頁的 head 中沒有設定 charset, 回應訊息標頭中也不會有 charset, 因此呼叫 get_content_charset() 時回傳值為 None, 使得 decode() 出現錯誤.

>>> url="http://www.webbotsspidersscreenscrapers.com/hello_world.html" 
>>> response=urllib.request.urlopen(url)
>>> response.headers.get_content_charset()     #沒有設定編碼方式傳回 None
>>> content=response.read().decode(response.headers.get_content_charset())
Traceback (most recent call last):
  File "<pyshell>", line 1, in <module>
TypeError: decode() argument 1 must be str, not None 

如果要用 get_content_charset() 自動取得編碼, 最好加上 if else 來判別是否會傳回 None :

>>> response=urllib.request.urlopen(url)
>>> charset=response.headers.get_content_charset()
>>> if charset:
>>>      content=response.read().decode(chasrset)   #有傳回編碼
>>> else:
>>>      content=response.read().decode()      #沒有傳回編碼

總之, decode() 要嘛不傳參數進去, 否則一定要有字串傳給它.

下載的網頁經過讀取解碼後得到純文字的 HTML 碼, 必須利用剖析技術才能從 HTML 代碼中擷取出有價值的資訊.


三. 使用 HTMLParser 剖析網頁 :

Python 內建的 HTML 剖析器為 html.parser 模組中的 HTMLParser 類別, 它具有容錯能力可處理網路上各種怪異或不對稱的 HTML 代碼. 使用 html.parser 模組剖析網頁首先必須先從 html.parser 匯入 HTMLParser 類別 :

from html.parser import HTMLParser

此 HTMLParser 類別提供下列剖析工具 :

 HTMLParser 方法 說明
 feed(data) 將字串資料 data 餵給剖析器處理
 close() 關閉剖析器, 並強迫處理緩衝區資料
 reset() 重新啟始剖析器, 緩衝區資料將喪失
 getpos() 傳回剖析器目前處理的列與 offset
 get_starttag_text() 傳回最近一次開啟標籤之文字內容

然後定義一個繼承 HTMLParser 的自訂類別例如 MyHTMLParser, 並至少覆寫常用之  handle_starttag(), handle_endtag(), handle_startendtag(), 以及 handle_data() 等方法 :

class MyHTMLParser(HTMLParser):         #繼承 HTMLParser 類別
    def handle_starttag(self, tag, attrs):            #處理起始標籤
        print("Encountered a start tag:", tag)
    def handle_endtag(self, tag):                      #處理結束標籤
        print("Encountered an end tag :", tag)
    def handle_startendtag(self, tag):               #處理起始兼結束標籤 (單一)
        print("Encountered an end tag :", tag)
    def handle_data(self, data):                         #處理資料
        print("Encountered some data  :", data)

然後建立 HTMLParser 物件, 並呼叫此物件之 feed() 方法將 HTML 代碼餵給物件去剖析. 參考 :

https://docs.python.org/3/library/html.parser.html
HTML Parser: How to scrap HTML content
Python urllib.parse.parse_qs() Examples

例如 :

import urllib.request
from html.parser import HTMLParser

class MyHTMLParser(HTMLParser):
    def handle_starttag(self, tag, attrs):
        print("Encountered a start tag:", tag)
    def handle_endtag(self, tag):
        print("Encountered an end tag :", tag)
    def handle_data(self, data):
        print("Encountered some data  :", data)

parser=MyHTMLParser()
url="http://www.webbotsspidersscreenscrapers.com/hello_world.html"
response=urllib.request.urlopen(url)
charset=response.headers.get_content_charset()
if charset:
    content=response.read().decode(chasrset)   #有傳回編碼
else:
    content=response.read().decode()
parser=MyHTMLParser()
parser.feed(content)

執行結果如下 :

Encountered some data  :

Encountered a start tag: html
Encountered some data  :

Encountered a start tag: head
Encountered some data  :

Encountered a start tag: title
Encountered some data  : Hello, world!
Encountered an end tag : title
Encountered some data  :

Encountered an end tag : head
Encountered some data  :

Encountered a start tag: body
Encountered some data  :

Congratulations! If you can read this,
Encountered a start tag: br
Encountered some data  :
you successfully downloaded this file.

Encountered an end tag : body
Encountered some data  :

Encountered an end tag : html
Encountered some data  :

參考 :

[第 16 天] 網頁解析
python 3 筆記 - 利用urllib來存取網頁
https://docs.python.org/3/library/html.parser.html
html.parser — Simple HTML and XHTML parser
How to handle response encoding from urllib.request.urlopen()
網路機器人、網路蜘蛛與網路爬蟲 PHP/CURL程式設計指南
讀後心得 : 一輩子餓不死的技能
Top 10 Best Web Scraping Books
Web Scraping in Python using Scrapy
How To Crawl A Web Page with Scrapy and Python 3
Web Scraping Tutorial with Python: Tips and Tricks
Data Analytics with Python by Web scraping: Illustration with CIA World Factbook
Apress 新書 : Practical Web Scraping for Data Science
新書 : Web Scraping for Data Science with Python (絕版)
Intro to Web Scraping with Python and Beautiful Soup
Python Web Scraping & Sentiment Analysis Tutorial For Beginners
Web-Scraping for Data Science – Part 1
Web Scraping for Data Science — Part 2
Should Data Scientists Learn Web Scraping?
5 Tasty Python Web Scraping Libraries
Web Scraping And Analytics With Python
Coursera : Using Python to Access Web Data
General Tips for Web Scraping with Python
Tutorial: How to do web scraping in Python?
Web Crawling - UCI (PDF)
Python programming — text and web mining (PDF)
Web Crawling and Scraping using Python, Selenium, and PhantomJS
Coursera : Capstone: Retrieving, Processing, and Visualizing Data with Python
How to handle response encoding from urllib.request.urlopen()
Python爬虫入门:Urllib parse库使用详解(二)
# HTML Parser: How to scrap HTML content

沒有留言 :