使用 shell 构建多进程的 CommandlineFu 爬虫
CommandlineFu 是一个记录脚本片段的网站，每个片段都有对应的功能说明和对应的标签。我想要做的就是尝试用 shell 写一个多进程的爬虫把这些代码片段记录在一个 org 文件中。
爬虫 CommandlineFu
CommandlineFu 是一个记录脚本片段的网站，每个片段都有对应的功能说明和对应的标签。我想要做的就是尝试用 shell 写一个多进程的爬虫把这些代码片段记录在一个 org 文件中。
![](/data/attachment/album/201903/11/224237ba1adl8jd18mlady.jpg)


[CommandlineFu](https://www.commandlinefu.com/) 是一个记录脚本片段的网站，每个片段都有对应的功能说明和对应的标签。我想要做的就是尝试用 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](https://www.w3.org/Tools/HTML-XML-utils/) 工具集中的 [hxselect](https://www.w3.org/Tools/HTML-XML-utils/man1/hxselect.html) 来根据 CSS 选择器提取相关元素。


这里我们使用 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 文件锁的用法及注意事项](https://github.com/lujun9972/lujun9972.github.com/blob/source/linux%E5%92%8C%E5%AE%83%E7%9A%84%E5%B0%8F%E4%BC%99%E4%BC%B4/linux%20shell%20flock%E6%96%87%E4%BB%B6%E9%94%81%E7%9A%84%E7%94%A8%E6%B3%95%E5%8F%8A%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9.org)。


由于需要在 `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](https://xkcd.com/) 上的漫画：



```
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/" &
```

 

