用 php 來做 Web Scraping ( 中文內容 )

[anti-both]

Web Scraping,意 思 就 是 用 程 式 技 巧 來 抽 取 網 站 上 的 內 容 。它 有 很 多 的 別 名 ,Web Havesting、Web Data Extraction、Web Screen Scraping、Web Data Mining 等 等 ,但 就 是 好 像 沒 有 一 個 中 文 名 字 。

基 本 上 ,任 何 程 式 語 言 都 可 以 做 Web Scraping,但 無 奈 筆 者 很 多 程 式 語 言 都 不 懂 ,就 唯 有 用 php 來 做 例 子 了 。php 是 server side 的 程 式 語 言 ,所 以 scraping 的 時 候 ,東 西 都 是 先 抓 到 server 上 ,然 後 再 傳 回 給 使 用 者 的 ( 例 如 傳 回 網 頁 / json )。

如 果 你 的 Web Scraping 程 序 是 放 在 Amazon EC2 那 類 Cloud 上 面 運 行 ,很 可 能 在 server side 做 scraping 會 比 較 快 。但 如 果 你 Server 的 網 絡 不 是 特 別 快 ( 例 如 home server ),改 用 jQuery 之 類 在 client side 來 做 scraping 就 可 以 減 輕 server side 的 負 擔 。

php 其 實 本 身 就 已 經 內 建 了 一 系 列 的 Web Scraping 工 具 ,單 用 php 就 能 完 成 頗 多 的 Web Scraping 工 作 。

Web Scraping 其 實 分 開 兩 個 工 序 。第 一 個 工 序 就 是 把 你 需 要 的 網 頁 下 載 ,第 二 個 工 序 就 是 在 下 載 了 的 網 頁 中 找 出 有 用 的 資 料 。正 常 的 網 頁 內 容 ,其 實 都 是 根 據 html 標 準 的 ,下 載 之 後 就 是 一 串 很 長 的 string,就 跟 你 在 瀏 覽 器 裡 面 view source 看 到 的 一 樣 。把 這 一 串 很 長 的 string 變 成 能 夠 容 易 解 讀 、存 取 特 定 資 料 的 過 程 ,就 叫 做 html parsing。

我 們 首 先 看 看 第 一 個 工 序 。要 下 載 一 個 網 頁 ,其 實 有 很 多 工 具 ,在 php 的 眾 多 library 裡 面 ,比 較 多 人 用 的 就 是 cURL (Client URL Library)。cURL 是 用 來 與 server 連 線 的 工 具 ,支 援 http, ftp, telnet 等 等 的 通 訊 協 定 。它 亦 支 持 cookies, https certificates, user authentication 之 類 網 站 常 用 的 東 西 。例 如 你 可 以 先 在 網 站 的 登 入 頁 面 登 入 一 次 ,再 由 網 站 把 驗 證 資 料 寫 入 cookies,再 轉 去 你 要 需 要 的 頁 面 去 抓 取 資 料 。

不 過 cURL 並 不 是 一 個 瀏 覽 器 ,也 有 很 多 東 西 它 是 做 不 了 的 。最 簡 單 的 ,它 不 支 持 Javascript,如 果 你 要 抓 取 的 網 站 的 某 些 內 容 是 使 用 Javascript / ajax 來 取 得 資 料 的 ,那 用 cURL 就 基 本 上 無 效 了 。

1
2
3
4
$ch = curl_init(’http://localhost.example/’);
curl_setopt($ch, CURLOPT_RETURNTRANSER, true);
$response = curl_exec($ch);
curl_close($ch);

cURL 用 起 來 是 十 分 容 易 的 ,所 有 的 設 定 都 是 用 curl_setopt 來 設 定 ,只 要 到  php 官 網  看 一 遍 就 都 知 道 了 。在 上 例 中 的  CURLOPT_RETURNTRANSER,預 設 值 是 false,會 自 動 輸 出 傳 回 的 內 容 到 browser。因 為 我 們 要 將 傳 回 的 內 容 儲 存 到 變 數 $response 在 之 後 再 處 理 ,所 以 我 們 要 把 它 設 定 為 true。

另 一 個 十 分 常 用 的 設 定 是  CURLOPT_USERAGENT,它 會 告 訴 web server 你 是 用 什 麼 browser 來 瀏 覽 。因 為 很 多 網 站 都 會 根 據 不 同 的 browser 傳 回 不 同 的 內 容 ,所 以 設 定 正 確 的 user agent 也 十 分 重 要 。

1
2
3
4
...
$agent = "Mozilla/5.0 (Windows; U; Windows NT 5.1; en-GB; rv:1.8.1.6) Gecko/20070725 Firefox/2.0.0.6";
curl_setopt($ch, CURLOPT_USERAGENT, $agent);
...

另 一 個 很 有 用 的 就 是 curl_getinfo,它 會 傳 回 一 個 array,裡 面 包 含 了 一 些 有 用 的 http header 資 訊 。

1
2
3
4
...
$info = curl_getinfo($ch);
...
var_dump($info);

取 得 了 網 頁 的 內 容 之 後 ( $response ),我 們 就 要 進 行 html parsing,再 提 取 出 有 用 的 資 料 。php 裡 面 ,最 常 用 的 內 建 工 具 就 是 DOMDocument 和 DOMXPath。不 過 筆 者 覺 得 它 們 用 起 來 沒 有 phpQuery 那 麼 人 性 化 。

不 過 ,在 進 一 步 討 論 如 何 使 用 phpQuery 之 前 ,我 們 要 處 理 一 下 內 容 編 碼 (character encoding) 的 問 題 。雖 然 近 幾 年 utf-8 早 就 成 為 網 站 的 主 流 了 ,但 一 些 舊 的 網 站 還 是 使 用 big5 / gbk 等 編 碼 。我 們 抓 取 資 料 之 後 ,不 管 是 展 示 給 使 用 者 、又 或 插 入 資 料 庫 留 待 以 後 分 析 ,都 需 要 把 編 碼 統 一 。按 現 在 的 標 準 來 說 ,當 然 是 統 一 使 用 utf-8 啦 。所 以 我 們 可 以 先 把 $response 都 轉 成 utf-8,然 後 再 用 phpQuery 處 理 。

要 知 道 原 來 網 頁 的 編 碼 ,其 實 在 curl_getinfo 裡 面 就 有 。

1
2
3
4
...
$info = curl_getinfo($ch);
$contenttype = $info['CONTENT_TYPE'];
...

或 者 更 簡 潔 一 點 。

1
2
3
...
$contenttype = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
...

但 不 知 道 為 什 麼 ,筆 者 常 常 遇 到 伺 服 器 只 傳 回 MIME( text/html ),然 後 就 沒 了 charset 的 資 料 。那 就 只 好 在 html source ( $response ) 裡 面 自 己 找 了 。

1
2
3
4
5
6
7
...
$response = curl_exec($ch);
...
preg_match( '@<meta\s+http-equiv="Content-Type"\s+content="([\w/]+)(;\s+charset=([^\s"]+))?@i', $response, $matches );
if ( isset( $matches[1] ) ) $mime = $matches[1];
if ( isset( $matches[3] ) ) $charset = $matches[3];
...

上 面 的 例 子 假 設 了 原 來 網 頁 用 meta 清 楚 declare 了 charset (HTML4 標 準 )。

<meta http-equiv=”Content-Type” content=”text/html; charset=UTF-8″>

有 了 原 來 網 頁 的 charset 就 一 切 好 辦 ,最 簡 單 的 可 以 用 php 的 iconv 轉 一 次 編 碼 。

1
2
3
4
5
6
7
8
...
$response = curl_exec($ch);
...
// somehow get the $charset from http header or page source
...
$response = iconv($charset, "UTF-8", $response);
$response = str_replace($charset, "UTF-8", $response);
...

因 為 以 後 用 phpQuery 讀 入 網 頁 內 容 的 時 候 ,它 也 會 使 用 html source 裡 面 的 meta 資 料 來 判 斷 charset。如 果 沒 有 修 正 source 裡 面 的 meta 資 料 ,就 會 出 現 亂 碼 的 情 況 。所 以 我 們 要 str_replace 一 次 ,更 正 source 裡 面 的 meta tag。

有 時 ,iconv 會 遇 到 不 能 轉 編 碼 的 字 符 ,就 會 出 現 iconv(): Unknown error (84)。這 時 ,我 們 就 可 以 加 入  //TRANSLIT 或 者 //IGNORE 來 解 決 問 題 。不 過 ,正 所 謂 有 備 無 患 ,就 是 沒 有 錯 誤 ,加 了 這 兩 個 參 數 一 般 都 不 會 影 響 正 常 工 作 的 。

1
2
3
...
$response = iconv($charset, "UTF-8//TRANSLIT//IGNORE", $response);
...

處 理 好 編 碼 的 問 題 ,我 們 就 可 以 用 phpQuery 來 取 得 我 們 想 要 的 資 料 了 。要 使 用 phpQuery,首 先 就 是 到 phpQuery 的 官 網 下 載 。然 後 就 要 引 用 。

1
2
3
4
5
require_once 'phpQuery-onefile.php';
...
// somehow get the $response, here the $response is a string
...
$doc = phpQuery::newDocumentHTML($response);

phpQuery 其 實 是 模 擬 jQuery 寫 出 來 的 ,用 法 基 本 上 和 jQuery 非 常 相 似 。最 重 要 的 就 是 那 個 pq(); function ( 等 於 jquery 的 $(); )。它 有 三 大 功 能 ,不 過 用 在 Web Scraping,主 要 就 是 用 其 中 兩 個 功 能 。

第 一 個 是 run queries,讓 我 們 可 以 在 網 頁 找 出 我 們 需 要 的 內 容 。例 如 筆 者 很 喜 歡 看 網 上 連 載 小 說 ,在 其 中 一 個 小 說 網 站 ,每 一 章 的 網 頁 格 式 都 是 這 樣 的 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<html>
<head>...</head>
<body>
....
....
<div id="article" class="main" >
...
<h2>第  n 章  - 標 題 </h2>
...
<p>第 一 段  .... </p>
...
<p>第 二 段  ..... </p>
...
<a class="prev" href="...">上 一 頁 </a>
...
<a class="next" href="...">下 一 頁 </a>
</div>
....
....
</body>
</html>

但 是 因 為 「某 種 原 因 」,網 站 有 很 多 彈 出 式 視 窗 ,騷 擾 非 常 。於 是 筆 者 就 寫 了 個 mybooks.php,打 開 那 一 頁 ,只 抽 取 其 中 有 用 的 東 西 ( 只 是 對 筆 者 而 言 ,並 無 任 何 不 敬 的 意 思 ),再 用 筆 者 喜 歡 的 格 式 展 示 出 來 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
...
$doc = phpQuery::newDocumentHTML($response);
$article = pq('#article',$doc);
$article_title = pq('h2',$article)->text();
$article_content = pq('p',$article)->text();
$last_page = pq('a.prev',$article)->attr('href');
$next_page = pq('a.next',$article)->attr('href');
phpQuery::unloadDocuments();
...
...
// display contents
echo "<div class='my_chapter_class' >";
echo "<h3>".$article_title."</h3>";
echo "<div class='my_content_class' >".$article_content."</div>";
echo "<a class='my_page_class' href='mybooks.php?url=".urlencode($last_page)."' >上 一 頁 </a>";
echo "<a class='my_page_class' href='mybooks.php?url=".urlencode($next_page)."' >下 一 頁 </a>";
echo "</div>";
...

在 上 例 中 ,就 是 pq(); function 關 於 run  queries 的 用 法 。裡 面 的 tag selector 就 跟 jQuery 一 樣 。筆 者 就 用 了 幾 種 常 見 的 selector (tag, id, class) 去 選 取 所 需 要 的 Tag,再 取 得 裡 面 相 關 的 內 容 (text, attribute)。然 後 再 乾 乾 淨 淨 的 顯 示 出 來 ,最 後 就 加 點 讓 自 己 閱 讀 得 更 舒 服 的 css 作 為 點 綴 。就 這 樣 ,惱 人 的 東 西 都 沒 了 ,再 不 會 有 什 麼 東 西 彈 出 來 ,也 再 不 會 有 誤 觸 的 情 況 ( 嘿 嘿 嘿 )。

pq(); function 的 另 一 個 用 途 ,就 是 把 一 個 DOMNodes 變 成 一 個 phpQuery object,令 你 可 以 用 phpQuery 的 方 法 取 得 它 的 內 容 。

1
2
3
4
5
6
...
foreach(pq('img',$doc) as $img)
$img_link = $img-&gt;attr('src');
// do something
}
...

在 上 例 中 ,會 出 現 錯 誤 ,因 為 $img 並 不 是 一 個 phpQuery object。正 確 的 寫 法 應 該 是 :

1
2
3
4
5
6
...
foreach(pq('img',$doc) as $img)
$img_link = pq($img)-&gt;attr('src');
// do something
}
...

至 於 phpQuery 和 那 個 selector 的 更 詳 細 用 法 ,大 家 可 以 到 phpQuery 的 Manual 頁 ,裡 面 有 非 常 詳 細 的 介 紹 。

看 到 這 裡 ,相 信 部 分 讀 者 會 有 疑 問 ,究 竟 Web Scraping 侵 不 侵 權 、犯 不 犯 法 ?其 實 這 主 要 是 版 權 持 有 人 去 決 定 的 。每 一 個 網 站 ,其 實 都 有 所 謂 User Agreements、Terms of Service、Terms of Use 之 類 的 條 文 。那 些 條 文 一 般 都 具 有 法 律 效 力 ,很 多 大 型 網 站 都 會 清 楚 寫 明 關 於 Web Scraping 的 取 向 。

例 如 Facebook 就 有 一 條 :

You will not collect users’ content or information, or otherwise access Facebook, using automated means (such as harvesting bots, robots, spiders, or scrapers) without our prior permission.

不 過 ,現 在 的 大 型 網 站 多 數 都 有 完 善 的 API,以 供 開 發 者 使 用 ,Web Scraping 在 那 些 網 站 未 必 有 用 武 之 處 。而 大 多 數 文 字 內 容 的 提 供 者 ( 報 紙 雜 誌 之 類 ),亦 有 提 供 RSS feed 給 使 用 者 直 接 下 載 內 容 ( 不 一 定 是 完 整 內 容 )。

別 以 為 Web Scraping 只 是 programmer 的 專 利 ,其 實 google drive 的 spreadsheet 裡 面 的 幾 條 function ( ImportHTML, ImportFeed, ImportXML ),做 的 就 是 Web Scraping 了 。

ctleung張先生,男性,肖龍。
職業:I.T. Consultant
簡介:不好好讀書;七尺差五寸,手長過膝,雙耳垂肩;性寬和,寡言語,喜怒不形於色。據說少時曾斬白蛇於鳳凰山下……

This entry was posted in Computer & Network and tagged , , , , . Bookmark the permalink.

Leave a Reply

Your email address will not be published. Required fields are marked *