使用shell构建多进程的commandlinefu爬虫
commandlinefu是一个记录脚本片段的网站,每个片段都有对应的功能说明和对应的标签。我想要做的就是尝试用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文件中。
这里就会遇到三个问题:
- 进程之间通讯的队列如何实现
- 如何从页面中抽取出URL、代码片段、描述说明、标签等信息
- 多进程对同一文件进行读写时的乱序问题
实现进程之间的通讯队列
这个问题比较好解决,我们可以通过一个命名管道来实现
queue=$(mktemp --dry-run) mkfifo ${queue} exec 99<>${queue} trap "rm ${queue} 2>/dev/null" EXIT
从页面中抽取想要的信息
从页面中提取元素内容主要有两种方法:
- 对于简单的HTML页面,我们可以通过
sed
,grep
,awk
等工具通过正则表达式匹配的方式来从HTML中抽取信息。 - 通过 html-xml-utils 工具集中的 hxselect 来根据CSS selector提取相关元素
这里我们使用 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文件锁的用法及注意事项
由于需要在 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-mode的格式写入存储文件中.
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-mode的格式并写入存储文件中的代码不要写成下面这样
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 上的漫画
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/" &