Work the Shell - 简单脚本到复杂 HTML 表单,第二部分

作者:Dave Taylor

正如您所回忆的,在过去的几个月中,我们一直在深入研究 Yahoo Movies 数据库,构建一个名为 findmovie 的命令,该命令将具有以下用法

USAGE: findmovie -g genre -k keywords -nrst title

然而,在上个月最简单的计算中,我们以每小时 100 公里的速度撞到了一堵墙:有多少标题与给定的查询元素组合匹配?

例如,有多少动作片片名中包含“death”? 看起来像findmovie -g act death,但是让计数实际工作起来很棘手,因为 Yahoo Movies 数据库输出取决于是否有零个匹配项、少于一页的匹配项或多于一页的匹配项。 每个输出的示例分别是“抱歉,未找到匹配项”、“(显示所有结果)”和“< 上一页 | 1 - 20 of 143 | 下一页 20 >”。

哦,情况变得更糟。 有时,当结果少于整页时,您会看到类似这样的内容:“< 上一页 | 1 - 3 of 3 | 下一页 >”。

这真是太痛苦了,即使您打开源代码,也没有方便的位置说明“0”或“4”或“143”。 因此,这就是我本月想要关注的重点——解析 HTML 文件以隔离和识别这个特定的数据点。

缓存结果

关于确定解决方案,我的第一个观察是,我们将需要缓存(或保存)结果,以便我们可以多次解析它以查看我们发现了什么。 这就引出了旧的 shell 脚本编写挑战,即选择一个好的、唯一的临时文件名。

我很老派。 我习惯使用.$$使用进程 ID 作为临时文件的基础,但实际上,现代 Linux 系统中有更好的解决方案。 如果您使用的是基于 BSD 的系统,请查看 mktemp。 如果不可用,请巧妙地使用 manman -k temp | grep '(1'将提取您的发行版提供的替换项。 这是 mktemp 的典型用法

appname=$(basename $0)
TMPFILE=$(mktemp /tmp/${appname}.XXXXXX) || exit 1 

它看起来非常相似,但是通过使用那么多 X 字符,程序会使用 PID 和随机字母,使得黑客不可能猜测或预测临时文件。 我在 Mac OS X 系统上开发的这个脚本版本有以下代码片段

if [ $dump -eq 1 ] ; then
  exec /usr/bin/curl --silent "$baseurl${params}\&p=$pattern"
else
  exec open -a safari "$baseurl${params}\&p=$pattern"
fi 

这里的问题是使用exec调用命令会将 shell 脚本替换为有问题的命令,这行不通。 相反,是时候重写它了

if [ $dump -eq 1 ] ; then
   appname=$(basename $0)
   TMPFILE=$(mktemp /tmp/${appname}.XXXXXX) || exit 1
  /usr/bin/curl --silent "$baseurl${params}\&p=$pattern" \
     > $TMPFILE
else
  exec open -a safari "$baseurl${params}\&p=$pattern"
fi 

看起来不错。 如果我们正在转储文件源,它将转到临时文件以供稍后分析。 如果这是一个应该在浏览器中启动搜索结果的请求,它仍然使用 Mac OS X open 命令。

解析结果

为了弄清楚发生了什么,我们需要考虑三种不同的可能性,每种可能性在源文件中都有不同的“指纹”。 这是一个粗略的模板

if [ ! -z "$(grep -i "no matches were found" $TMPFILE)" ]
then
  echo there are zero results for that search.
elif [ ! -z "$(grep -i "Next&nbsp;&gt;" $TMPFILE)" ]
then
  echo got some results with case two.
else
  echo more than a page of results
fi 

在这里,我只显示输出 echo 语句,以便您了解算法,但您可以看到我们只是在测试一个已知的字符串,希望该字符串不会在其他情况下出现。 请注意第三个测试,尽管Next&nbsp;&gt;是一些 HTML 怪异之处。 “nbsp”是不间断空格,“gt”是 > 符号。 将它们包装在“&”和“;”中,您就拥有了 HTML 字符实体。

要确定总匹配计数,还需要对输出进行更多解析。 搜索“death race”,您会发现三个匹配项,最终看起来像这样

<b>3</b> 

不幸的是,它被埋藏在一个更复杂的模式中,因为这是一个典型的匹配

<td align=right><font face=arial size="-2"><nobr>
↪&lt;&nbsp;Prev&nbsp;|&nbsp;<b>1 - 3</b>
↪&nbsp;of&nbsp;<b>3</b>&nbsp;... 

我必须承认,我一度被难住了,这就是为什么拥有像 Martin 和 Lucretia M. Pruitt 这样精通技术的朋友如此有帮助。 我在 Twitter 上提出了这个难题(如果您想关注我,我是 @DaveTaylor),经过一些错误的开始后,他们提出了一个简单而合乎逻辑的解决方案:将 <b> 和 </b> 变成单独的字符分隔符,然后简单地使用 cut 来提取我们需要的字段。 聪明!

以下是它作为简单命令序列的外观

grep -i "1 - " $TMPFILE |
   sed 's/<b>/~/g;s/<\/b>/~/g' |
   cut -d\~ -f4 

有了这个,上面丑陋的 HTML 序列很快就简化为值 3,这正是我们想要的。 不过,有一个细微之处。 事实证明,此数据出现在匹配项之前和之后,因此我们需要插入| head -1以确保我们只解析一行,而不是重复数据条目或混淆新的解析器。 这意味着我们可以创建以下代码

if [ ! -z "$(grep -i "no matches were found" $TMPFILE)" ]
then
  matches=0
elif [ ! -z "$(grep -i "Next&nbsp;&gt;" $TMPFILE)" ]
then
  matches="$(grep -i "1 - " $TMPFILE | head -1 | \
     sed 's/<b>/~/g;s/<\/b>/~/g' | cut -d\~ -f4)"
else
  matches="$(grep -i "1 - " $TMPFILE | head -1 | \
     sed 's/<b>/~/g;s/<\/b>/~/g' | cut -d\~ -f4)"
fi 

您可以看到我是如何区分这三种情况的,以及在第二种和第三种情况下,结果代码是多么相似。 事实上,它们不需要是单独的情况,因此计数更容易像这样计算

if [ ! -z "$(grep -i "no matches were found" $TMPFILE)" ]
then
  matches=0
else
  matches="$(grep -i "1 - " $TMPFILE | head -1 | \
     sed 's/<b>/~/g;s/<\/b>/~/g' | cut -d\~ -f4)"
fi 

如果您初始化了matches为零,您实际上可以翻转第一个条件的逻辑并进一步修剪它

matches=0 
if [ -z "$(grep -i "no matches were found" $TMPFILE)" ]
then
  matches="$(grep -i "1 - " $TMPFILE | head -1 | \
     sed 's/<b>/~/g;s/<\/b>/~/g' | cut -d\~ -f4)"
fi 

不错。 这是一个简单、直接且很好的例子,说明了如果您不断思考您真正通过复杂条件完成什么,它们通常不仅可以简化,而且还可以加速。

下个月

在撰写这些关于使用 Yahoo Movies 的专栏文章时,我发现我的兴趣被拉向了另一个方向:“猜歌名”游戏。 这就是我们下个月将开始研究的内容。 如果您想先睹为快,看看它如何实时演变(而不是在 Linux Journal 中),请在 Twitter 上关注 @SongTitle。 这会很有趣!

Dave Taylor 从事 shell 脚本编写已经很长时间了,30 年。 他是流行的 Wicked Cool Shell Scripts 的作者,您可以在 Twitter 上找到他,账号为 @DaveTaylor,更常见的是在他的网站 www.DaveTaylorOnline.com

加载 Disqus 评论