從網頁產生書單

有時候我們需要的資料, 其實已經以網頁格式存在, 但想進一步去蕪存菁, 略過 html 的標籤等等, 只抓出關鍵欄位放入試算表 (建議用 .csv 格式), 資料庫 (變成一串 insert 指令), 或是程式中陣列的初始值。 具有 web 2.0 概念的 AJAX 程式, 特別有機會需要做這類事情。 當然, 這篇講義不談 javascript; 甚至 perl 也不是重點; regexp 才是重點。

請用 wget 指令抓下 Astronomy Book List 網頁 (或稍微處理過的 本地映射, 下詳), 並用 less 或 vim 檢視其原始碼。 以下試著抓出列表裡面所有書籍的:

  1. 頁數 (例如: "265 Pages", "288 Pages", ...)
  2. 年月 (例如: "Nov-94", "Sep-92", ...)
  3. 價格 (例如: "$23.07", "$12.00", "$11.96", ...)
  4. 作者 (例如: "Peter L. Manly", "Chet Raymo", "Stephen W. Hawking", "Peter J. Duffett-Smith", ...)
  5. 作者的姓 (例如: "Manly", "Raymo", "Hawking", "Duffett-Smith", ...)
  6. 代號 (例如: "0521433606", "0671766066", "0553346148", ...)
  7. 圖檔檔名 (例如: "astro13.gif", "astro2.gif", "astro28.gif", ...)
  8. 書名 (例如: "The 20-Cm Schmidt-Cassegrain Telescope", "365 Starry Nights: An Introduction to Astronomy for Every Night of the Year", "A Brief History of Time: From the Big Bang to Black Holes", ...)

頁數可以這樣抓: perl -ne 'print "$1\n" if /(\d+\s+Pages)/' astrobooks.htm 請專注在底色強調的部分; 其他部分都是固定的句型, 不需要理解, 只要照抄就可以了。 以下其他的例子將省略完整指令, 只列出底色強調部分, 也就是 regexp 的部分。 至於列印字串 print "..." 的內容, 當然也可以視需要自行修改。 其中的 $1 表示 「從 regexp 當中, 取出第一對小括弧所比對到的內容」。 又, 從這個例子看到: 遇到空格處, 建議用 \s+ (「1 個, 2 個, ... 任意個空白類字元」) 因為有時候用眼睛看, 很難判斷到底有幾個空格, 到底是空格還是 tab。

年月可以這樣抓: \b([A-Z][a-z][a-z]-\d\d)\b

價格可以這樣抓: (\$\d+\.\d+) 因為 $ 「一列的結尾處」 和 . 「任何一字元」 都是 regexp 的特殊字元, 所以需要用 "\" 取消它們的特殊意義。

要抓作者, 第一步先想到這樣: 「by 之後的一整串, 一直到逗點為止」, 於是寫成: \bby\s+(.*), 所謂 "一整串", 就是 「任意字元出現任意次」, 也就是 .*。 可是這樣會抓到太多。 在這裡, 我們看到 regexp 的一個特性: 貪婪 (greedy) -- 只要還有機會滿足比對條件, 就盡量吃, 用力吃。 這時需要用 「不貪婪的 *?」 像這樣: \bby\s+(.*?), 這裡的 *? 意思和 * 一樣, 都是 「前面的東西可以出現 0 次, 1 次, 2 次, ... 任意次」; 唯一的差別是: * 很貪婪, 吃越多越好; *? 很客氣, 很知足, 越早結束越好。

同樣地, 要抓作者的姓, 本來也希望用 .* 來略過名字的部分, 但又不希望略得過頭了, 所以改用 [客氣的, 知足的] .*?: \bby\s+.*?([-\w]+),

當然, regexp 通常沒有唯一的答案。 以 「作者完整姓名」 為例, 也可以這樣寫: \bby\s+([^,]*), 其中 [^,]* 可以翻譯成: 「到第一個逗點為止」。 只有少數 regexp 引擎像 perl 一樣支援 *? 在其他不支援此符號的 regexp 引擎中, 「除了...之外」 這一招很好用。

代號可以這樣抓: ASIN\/(\d+)\/sbsoftware 因為 / 是搜尋的分隔符號, 所以必須用 \ 取消它的特殊意義。 另一個方法是改用其他的分隔符號, 例如改用 #, 不過這時候就必須加上個 "m" 表示搜尋, 像這樣: perl -ne 'print "$1\n" if m#ASIN/(\d+)/sbsoftware#' astrobooks.htm

圖檔檔名可以這樣抓: img\s+src="(.*?)"

書名可以這樣抓: font size=\+1>(.*?)<

每一題做出來後, 也請將輸出 pipe 給 wc, 像這樣: perl -ne ... | wc 數數看各抓到幾筆資料。 總共有 42 本書; 每本書都有作者, 年月, 價格; 其中 30 本有圖檔, 33 本有頁數, 39 本有代號。 怎麼會只有 39 本有代號? 沒代號怎麼讓消費者下訂單啊? 怪怪的... 進入 vim, 先設定 「高亮度顯示搜尋結果」 :set hlsearch 然後稍微修改抓代號的 regexp, 並搜尋: ASIN\/[0-9]\+\/sbsoftware 會發現有些書的代號以 X 結尾, 如下圖。 用 less 搜尋也可以, 搜尋指令變成 ASIN/[0-9]+/sbsoftware; 不過 vim 的圖比較漂亮, 所以這裡貼 vim 的圖 :-) 因此, 抓代號的 regexp 改成才對: ASIN/(\d+\w?)/sbsoftware

在 vim 裡面 set hlsearch 顯示搜尋到的東西


(以下較進階, 可略過, 不影響其他章節的理解)

如果想要一次抓足所有欄位, 把它變成一個 csv 檔呢? (comma separated values, 用逗點分隔的純文字檔, 任何試算表軟體可讀)

第一步, 先認識 perl 的 paragraph mode: 忽略純文字檔的換列, 把好幾列當作一個單位來處理, 直到一個空白列才結束, 當做下一個單位。 不過對於 windows/dos 格式的檔案, paragraph mode 好像不太靈光。 所以我已事先用 perl -pe 's/\015//' astrobooks.htm > x; mv x astrobooks.htm 把原檔案每列尾巴的 \n\r 改成只剩 \n 變成標準的 unix 格式純文字檔。 請拿這個修改過的 本地映射 來認識何謂 paragraph mode: perl -000 -ne 'print "$1\n" if /(.{10})/' astrobooks.htm 效果是: 以空白列分隔的區塊為單位 (總共只有八塊) 印出每塊最前面十個字元。 關於 perl 的 paragraph mode 及更多相關資訊, 詳見 perlrun 手冊。

如果把 (.{10}) 改成 (.*) 會發現每個區塊只剩最前面一列。 因為 . 無法比對換列字元。 沒關係, 改用 ... if /(.*)/s ... 這個結尾的 s 意思是: 令 . 可以比對到探列字元。 可以這樣檢驗結果: perl -000 -ne 'print "$1\n" if /(.*)/s' astrobooks.htm | diff - astrobooks.htm 應該看出: 處理完的結果與原檔案幾乎一模一樣, 只差多了八個換列。 關於 s 的用法, 詳見 perlre 手冊, 並在手冊內搜尋 "single"。

所以我們沿著 <tr> 出現的地方 (表格的換列) 插入換列字元, 像這樣: perl -pe 's#</tr><tr>#</tr>\n\n<tr>#' astrobooks.htm | less 每本書之間是不是有空白列分開了呢? 接下來就可以把一本書的所有資料視為一筆資料, 一口氣把圖片, 代號, 書名, 作者寫入同一個 regexp。 注意其中好幾處都用 .*? [客氣地, 知足地] 擷取或略過不特定長像的一段字串: perl -pe 's#</tr><tr>#</tr>\n\n<tr>#' astrobooks.htm | perl -000 -ne 'print "$2, $1, $4, $3\n" if m#<img\s+src="(.*?)".*?ASIN/(\d+\w?)/.*?font size=\+1>(.*?)<.*?\bby\s+(.*?),#s'

且慢, 請把輸出 pipe 給 wc, 會發現原先的 42 筆資料, 現在只剩 30 筆。 那是因為並非每筆資料都有圖片。 所以改成這樣: perl -pe 's#</tr><tr>#</tr>\n\n<tr>#' astrobooks.htm | perl -000 -ne 'print "$3, $2, $5, $4\n" if m#(<img\s+src="(.*?)"|no picture).*?ASIN/(\d+\w?)/.*?font size=\+1>(.*?)<.*?\bby\s+(.*?),#s' 就是在原本的抓圖檔名部分外面包上 (...|no picture), 並且記得調整列印時的 regexp 代號 (因為多了一對小括弧)。

其他欄位就當作作業囉。