CommandlineFu[1] 是一個記錄指令碼片段的網站,每個片段都有對應的功能說明和對應的標簽。我想要做的就是嘗試用 shell 寫一個多行程的爬蟲把這些程式碼片段記錄在一個 org 檔案中。
引數定義
這個指令碼需要能夠透過 -n 引數指定併發的爬蟲數(預設為 CPU 核的數量),還要能透過 -f 指定儲存的 org 檔案路徑(預設輸出到 stdout)。
-
#!/usr/bin/env bash -
-
proc_num=$(nproc) -
store_file=/dev/stdout -
while getopts :n:f: OPT; do -
case $OPT in -
n|+n) -
proc_num="$OPTARG" -
;; -
f|+f) -
store_file="$OPTARG" -
;; -
*) -
echo "usage: ${0##*/} [+-n proc_num] [+-f org_file} [--]" -
exit 2 -
esac -
done -
shift $(( OPTIND - 1 )) -
OPTIND=1
解析命令瀏覽頁面
我們需要一個行程從 CommandlineFu 的瀏覽串列中抽取各個指令碼片段的 URL,這個行程將抽取出來的 URL 存放到一個佇列中,再由各個爬蟲行程從行程中讀取 URL 並從中抽取出對應的程式碼片段、描述說明和標簽資訊寫入 org 檔案中。
這裡就會遇到三個問題:
實現行程之間的通訊佇列
這個問題比較好解決,我們可以透過一個命名管道來實現:
-
queue=$(mktemp --dry-run) -
mkfifo ${queue} -
exec 99<>${queue} -
trap "rm ${queue} 2>/dev/null" EXIT
從頁面中抽取想要的資訊
從頁面中提取元素內容主要有兩種方法:
sed、grep、awk 等工具透過正則運算式匹配的方式來從 HTML 中抽取資訊。這裡我們使用 html-xml-utils 工具來提取:
-
function extract_views_from_browse_page() -
{ -
if [[ $# -eq 0 ]];then -
local html=$(cat -) -
else -
local html="$*" -
fi -
echo ${html} |hxclean |hxselect -c -s "\n" "li.list-group-item > div:nth-child(1) > div:nth-child(1) > a:nth-child(1)::attr(href)"|sed 's@^@https://www.commandlinefu.com/@' -
} -
-
function extract_nextpage_from_browse_page() -
{ -
if [[ $# -eq 0 ]];then -
local html=$(cat -) -
else -
local html="$*" -
fi -
echo ${html} |hxclean |hxselect -s "\n" "li.list-group-item:nth-child(26) > a"|grep '>'|hxselect -c "::attr(href)"|sed 's@^@https://www.commandlinefu.com/@' -
}
這裡需要註意的是:hxselect 對 HTML 解析時要求遵循嚴格的 XML 規範,因此在用 hxselect 解析之前需要先經過 hxclean 矯正。另外,為了防止 HTML 過大,超過引數串列長度,這裡允許透過管道的形式將 HTML 內容傳入。
迴圈讀取下一頁的瀏覽頁面,不斷抽取程式碼片段 URL 寫入佇列
這裡要解決的是上面提到的第三個問題: 多行程對管道進行讀寫時如何保障不出現亂序? 為此,我們需要在寫入檔案時對檔案加鎖,然後在寫完檔案後對檔案解鎖,在 shell 中我們可以使用 flock 來對檔案進行枷鎖。 關於 flock 的使用方法和註意事項,請參見另一篇博文 Linux shell flock 檔案鎖的用法及註意事項[4]。
由於需要在 flock 子行程中使用函式 extract_views_from_browse_page,因此需要先匯出該函式:
-
export -f extract_views_from_browse_page
由於網路問題,使用 curl 獲取內容可能失敗,需要重覆獲取:
-
function fetch() -
{ -
local url="$1" -
while ! curl -L ${url} 2>/dev/null;do -
: -
done -
}
collector 用來從種子 URL 中抓取待爬的 URL,寫入管道檔案中,寫操作期間管道檔案同時作為鎖檔案:
-
function collector() -
{ -
url="$*" -
while [[ -n ${url} ]];do -
echo "從$url中抽取" -
html=$(fetch "${url}") -
echo "${html}"|flock ${queue} -c "extract_views_from_browse_page >${queue}" -
url=$(echo "${html}"|extract_nextpage_from_browse_page) -
done -
# 讓後面解析程式碼片段的爬蟲行程能夠正常退出,而不至於被阻塞. -
for ((i=0;i<${proc_num};i++)) -
do -
echo >${queue} -
done -
}
這裡要註意的是, 在找不到下一頁 URL 後,我們用一個 for 迴圈往佇列裡寫入了 =proc_num= 個空行,這一步的目的是讓後面解析程式碼片段的爬蟲行程能夠正常退出,而不至於被阻塞。
解析指令碼片段頁面
我們需要從指令碼片段的頁面中抽取標題、程式碼片段、描述說明以及標簽資訊,同時將這些內容按 org 樣式的格式寫入儲存檔案中。
-
function view_page_handler() -
{ -
local url="$1" -
local html="$(fetch "${url}")" -
# headline -
local headline="$(echo ${html} |hxclean |hxselect -c -s "\n" ".col-md-8 > h1:nth-child(1)")" -
# command -
local command="$(echo ${html} |hxclean |hxselect -c -s "\n" ".col-md-8 > div:nth-child(2) > span:nth-child(2)"|pandoc -f html -t org)" -
# description -
local description="$(echo ${html} |hxclean |hxselect -c -s "\n" ".col-md-8 > div.description"|pandoc -f html -t org)" -
# tags -
local tags="$(echo ${html} |hxclean |hxselect -c -s ":" ".functions > a")" -
if [[ -n "${tags}" ]];then -
tags=":${tags}" -
fi -
# build org content -
cat <<EOF |flock -x ${store_file} tee -a ${store_file} -
* ${headline} ${tags} -
-
:PROPERTIES: -
:URL: ${url} -
:END: -
-
${description} -
#+begin_src shell -
${command} -
#+end_src -
-
EOF -
}
這裡抽取資訊的方法跟上面的類似,不過程式碼片段和描述說明中可能有一些 HTML 程式碼,因此透過 pandoc 將之轉換為 org 格式的內容。
註意最後輸出 org 樣式的格式並寫入儲存檔案中的程式碼不要寫成下麵這樣:
-
flock -x ${store_file} cat <<EOF >${store_file} -
* ${headline}\t\t ${tags} -
${description} -
#+begin_src shell -
${command} -
#+end_src -
EOF
它的意思是使用 flock 對 cat 命令進行加鎖,再把 flock 整個命令的結果透過重定向輸出到儲存檔案中,而重定向輸出的這個過程是沒有加鎖的。
spider 從管道檔案中讀取待抓取的 URL,然後實施真正的抓取動作。
-
function spider() -
{ -
while : -
do -
if ! url=$(flock ${queue} -c 'read -t 1 -u 99 url && echo $url') -
then -
sleep 1 -
continue -
fi -
-
if [[ -z "$url" ]];then -
break -
fi -
view_page_handler ${url} -
done -
}
這裡要註意的是,為了防止發生死鎖,從管道中讀取 URL 時設定了超時,當出現超時就意味著生產行程趕不上消費行程的消費速度,因此消費行程休眠一秒後再次檢查佇列中的 URL。
組合起來
-
collector "https://www.commandlinefu.com/commands/browse" & -
-
for ((i=0;i<${proc_num};i++)) -
do -
spider & -
done -
wait
抓取其他網站
透過重新定義 extract_views_from_browse_page、 extract_nextpage_from-browse_page、 view_page_handler 這幾個函式, 以及提供一個新的種子 URL,我們可以很容易將其改造成抓取其他網站的多行程爬蟲。
例如透過下麵這段程式碼,就可以用來爬取 xkcd[5] 上的漫畫:
-
function extract_views_from_browse_page() -
{ -
if [[ $# -eq 0 ]];then -
local html=$(cat -) -
else -
local html="$*" -
fi -
max=$(echo "${html}"|hxclean |hxselect -c -s "\n" "#middleContainer"|grep "Permanent link to this comic" |awk -F "/" '{print $4}') -
seq 1 ${max}|sed 's@^@https://xkcd.com/@' -
} -
-
function extract_nextpage_from_browse_page() -
{ -
echo "" -
} -
-
function view_page_handler() -
{ -
local url="$1" -
local html="$(fetch "${url}/")" -
local image="https:$(echo ${html} |hxclean |hxselect -c -s "\n" "#comic > img:nth-child(1)::attr(src)")" -
echo ${image} -
wget ${image} -
} -
-
collector "https://xkcd.com/" &
知識星球