深圳幻海软件技术有限公司 欢迎您!

使用Shell构建多进程的CommandlineFu爬虫

2023-02-26

 CommandlineFu 是一个记录脚本片段的网站,每个片段都有对应的功能说明和对应的标签。我想要做的就是尝试用shell写一个多进程的爬虫把这些代码片段记录在一个org文件中。参数定义这个脚本需要能够通过 -n 参数指定并发的爬虫数(默认为CPU核的数量

 CommandlineFu 是一个记录脚本片段的网站,每个片段都有对应的功能说明和对应的标签。我想要做的就是尝试用 shell 写一个多进程的爬虫把这些代码片段记录在一个 org 文件中。

参数定义

这个脚本需要能够通过 -n 参数指定并发的爬虫数(默认为 CPU 核的数量),还要能通过 -f 指定保存的 org 文件路径(默认输出到 stdout)。

  1. #!/usr/bin/env bash
  2.  
  3. proc_num=$(nproc)
  4. store_file=/dev/stdout
  5. while getopts :n:f: OPT; do
  6. case $OPT in
  7. n|+n)
  8. proc_num="$OPTARG"
  9. ;;
  10. f|+f)
  11. store_file="$OPTARG"
  12. ;;
  13. *)
  14. echo "usage: ${0##*/} [+-n proc_num] [+-f org_file} [--]"
  15. exit 2
  16. esac
  17. done
  18. shift $(( OPTIND - 1 ))
  19. OPTIND=1
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.

解析命令浏览页面

我们需要一个进程从 CommandlineFu 的浏览列表中抽取各个脚本片段的 URL,这个进程将抽取出来的 URL 存放到一个队列中,再由各个爬虫进程从进程中读取 URL 并从中抽取出对应的代码片段、描述说明和标签信息写入 org 文件中。

这里就会遇到三个问题:

  1. 进程之间通讯的队列如何实现
  2. 如何从页面中抽取出 URL、代码片段、描述说明、标签等信息
  3. 多进程对同一文件进行读写时的乱序问题

实现进程之间的通讯队列

这个问题比较好解决,我们可以通过一个命名管道来实现:

  1. queue=$(mktemp --dry-run)
  2. mkfifo ${queue}
  3. exec 99<>${queue}
  4. trap "rm ${queue} 2>/dev/null" EXIT
  • 1.
  • 2.
  • 3.
  • 4.

从页面中抽取想要的信息

从页面中提取元素内容主要有两种方法:

  1. 对于简单的 HTML 页面,我们可以通过 sedgrepawk 等工具通过正则表达式匹配的方式来从 HTML 中抽取信息。
  2. 通过 html-xml-utils 工具集中的 hxselect 来根据 CSS 选择器提取相关元素。

这里我们使用 html-xml-utils 工具来提取:

  1. function extract_views_from_browse_page()
  2. {
  3. if [[ $# -eq 0 ]];then
  4. local html=$(cat -)
  5. else
  6. local html="$*"
  7. fi
  8. 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/@'
  9. }
  10.  
  11. function extract_nextpage_from_browse_page()
  12. {
  13. if [[ $# -eq 0 ]];then
  14. local html=$(cat -)
  15. else
  16. local html="$*"
  17. fi
  18. 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/@'
  19. }
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.

这里需要注意的是:hxselect 对 HTML 解析时要求遵循严格的 XML 规范,因此在用 hxselect 解析之前需要先经过 hxclean 矫正。另外,为了防止 HTML 过大,超过参数列表长度,这里允许通过管道的形式将  HTML 内容传入。

循环读取下一页的浏览页面,不断抽取代码片段 URL 写入队列

这里要解决的是上面提到的第三个问题: 多进程对管道进行读写时如何保障不出现乱序? 为此,我们需要在写入文件时对文件加锁,然后在写完文件后对文件解锁,在 shell 中我们可以使用 flock 来对文件进行枷锁。 关于 flock 的使用方法和注意事项,请参见另一篇博文 Linux shell flock 文件锁的用法及注意事项。

由于需要在 flock 子进程中使用函数 extract_views_from_browse_page,因此需要先导出该函数:

  1. export -f extract_views_from_browse_page
  • 1.

由于网络问题,使用 curl 获取内容可能失败,需要重复获取:

  1. function fetch()
  2. {
  3. local url="$1"
  4. while ! curl -L ${url} 2>/dev/null;do
  5. :
  6. done
  7. }
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.

collector 用来从种子 URL 中抓取待爬的 URL,写入管道文件中,写操作期间管道文件同时作为锁文件:

  1. function collector()
  2. {
  3. url="$*"
  4. while [[ -n ${url} ]];do
  5. echo "从$url中抽取"
  6. html=$(fetch "${url}")
  7. echo "${html}"|flock ${queue} -c "extract_views_from_browse_page >${queue}"
  8. url=$(echo "${html}"|extract_nextpage_from_browse_page)
  9. done
  10. # 让后面解析代码片段的爬虫进程能够正常退出,而不至于被阻塞.
  11. for ((i=0;i<${proc_num};i++))
  12. do
  13. echo >${queue}
  14. done
  15. }
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.

这里要注意的是, 在找不到下一页 URL 后,我们用一个 for 循环往队列里写入了 =proc_num= 个空行,这一步的目的是让后面解析代码片段的爬虫进程能够正常退出,而不至于被阻塞。

解析脚本片段页面

我们需要从脚本片段的页面中抽取标题、代码片段、描述说明以及标签信息,同时将这些内容按 org 模式的格式写入存储文件中。

  1. function view_page_handler()
  2. {
  3. local url="$1"
  4. local html="$(fetch "${url}")"
  5. # headline
  6. local headline="$(echo ${html} |hxclean |hxselect -c -s "\n" ".col-md-8 > h1:nth-child(1)")"
  7. # command
  8. 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)"
  9. # description
  10. local description="$(echo ${html} |hxclean |hxselect -c -s "\n" ".col-md-8 > div.description"|pandoc -f html -t org)"
  11. # tags
  12. local tags="$(echo ${html} |hxclean |hxselect -c -s ":" ".functions > a")"
  13. if [[ -n "${tags}" ]];then
  14. tags=":${tags}"
  15. fi
  16. # build org content
  17. cat <<EOF |flock -x ${store_file} tee -a ${store_file}
  18. * ${headline} ${tags}
  19.  
  20. :PROPERTIES:
  21. :URL: ${url}
  22. :END:
  23.  
  24. ${description}
  25. #+begin_src shell
  26. ${command}
  27. #+end_src
  28.  
  29. EOF
  30. }
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.

这里抽取信息的方法跟上面的类似,不过代码片段和描述说明中可能有一些 HTML 代码,因此通过 pandoc 将之转换为 org 格式的内容。

注意***输出 org 模式的格式并写入存储文件中的代码不要写成下面这样:

  1. flock -x ${store_file} cat <<EOF >${store_file}
  2. * ${headline}\t\t ${tags}
  3. ${description}
  4. #+begin_src shell
  5. ${command}
  6. #+end_src
  7. EOF
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.

它的意思是使用 flock 对 cat 命令进行加锁,再把 flock 整个命令的结果通过重定向输出到存储文件中,而重定向输出的这个过程是没有加锁的。

spider 从管道文件中读取待抓取的 URL,然后实施真正的抓取动作。

  1. function spider()
  2. {
  3. while :
  4. do
  5. if ! url=$(flock ${queue} -c 'read -t 1 -u 99 url && echo $url')
  6. then
  7. sleep 1
  8. continue
  9. fi
  10.  
  11. if [[ -z "$url" ]];then
  12. break
  13. fi
  14. view_page_handler ${url}
  15. done
  16. }
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.

这里要注意的是,为了防止发生死锁,从管道中读取 URL 时设置了超时,当出现超时就意味着生产进程赶不上消费进程的消费速度,因此消费进程休眠一秒后再次检查队列中的 URL。

组合起来

  1. collector "https://www.commandlinefu.com/commands/browse" &
  2.  
  3. for ((i=0;i<${proc_num};i++))
  4. do
  5. spider &
  6. done
  7. wait
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.

抓取其他网站

通过重新定义 extract_views_from_browse_pageextract_nextpage_from-browse_pageview_page_handler 这几个函数, 以及提供一个新的种子 URL,我们可以很容易将其改造成抓取其他网站的多进程爬虫。

例如通过下面这段代码,就可以用来爬取 xkcd 上的漫画:

  1. function extract_views_from_browse_page()
  2. {
  3. if [[ $# -eq 0 ]];then
  4. local html=$(cat -)
  5. else
  6. local html="$*"
  7. fi
  8. max=$(echo "${html}"|hxclean |hxselect -c -s "\n" "#middleContainer"|grep "Permanent link to this comic" |awk -F "/" '{print $4}')
  9. seq 1 ${max}|sed 's@^@https://xkcd.com/@'
  10. }
  11.  
  12. function extract_nextpage_from_browse_page()
  13. {
  14. echo ""
  15. }
  16.  
  17. function view_page_handler()
  18. {
  19. local url="$1"
  20. local html="$(fetch "${url}/")"
  21. local image="https:$(echo ${html} |hxclean |hxselect -c -s "\n" "#comic > img:nth-child(1)::attr(src)")"
  22. echo ${image}
  23. wget ${image}
  24. }
  25.  
  26. collector "https://xkcd.com/" &
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.