3

使用shell构建多进程的commandlinefu爬虫

 3 years ago
source link: https://www.lujun9972.win/blog/2019/02/13/%E4%BD%BF%E7%94%A8shell%E6%9E%84%E5%BB%BA%E5%A4%9A%E8%BF%9B%E7%A8%8B%E7%9A%84commandlinefu%E7%88%AC%E8%99%AB/index.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

使用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文件中。

这里就会遇到三个问题:

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

实现进程之间的通讯队列

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

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

从页面中抽取想要的信息

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

  1. 对于简单的HTML页面,我们可以通过 sed, grep, awk 等工具通过正则表达式匹配的方式来从HTML中抽取信息。
  2. 通过 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

它的意思是使用 flockcat 命令进行加锁,再把 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/" &

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK