Work the Shell - 简单脚本到复杂 HTML 表单,第二部分
正如您所回忆的,在过去的几个月中,我们一直在深入研究 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 >" $TMPFILE)" ] then echo got some results with case two. else echo more than a page of results fi
在这里,我只显示输出 echo 语句,以便您了解算法,但您可以看到我们只是在测试一个已知的字符串,希望该字符串不会在其他情况下出现。 请注意第三个测试,尽管Next >是一些 HTML 怪异之处。 “nbsp”是不间断空格,“gt”是 > 符号。 将它们包装在“&”和“;”中,您就拥有了 HTML 字符实体。
要确定总匹配计数,还需要对输出进行更多解析。 搜索“death race”,您会发现三个匹配项,最终看起来像这样
<b>3</b>
不幸的是,它被埋藏在一个更复杂的模式中,因为这是一个典型的匹配
<td align=right><font face=arial size="-2"><nobr> ↪< Prev | <b>1 - 3</b> ↪ of <b>3</b> ...
我必须承认,我一度被难住了,这就是为什么拥有像 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 >" $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
不错。 这是一个简单、直接且很好的例子,说明了如果您不断思考您真正通过复杂条件完成什么,它们通常不仅可以简化,而且还可以加速。
Dave Taylor 从事 shell 脚本编写已经很长时间了,30 年。 他是流行的 Wicked Cool Shell Scripts 的作者,您可以在 Twitter 上找到他,账号为 @DaveTaylor,更常见的是在他的网站 www.DaveTaylorOnline.com。