phpquery or domdocument can’t parse html with special character like ‘<' not escaped

最近在写一个web数据通用采集器。简单讲,就是对某个目标网站编写特定的规则,然后采集器可以根据这个规则去目标网站采集相应的数据。

比如我有条规则就是针对http://xiaohua.zol.com.cn/这个网站的。

然而在遇到一个特定的页面时,采集器报错。这个页面的地址是:http://xiaohua.zol.com.cn/lengxiaohua/34.html

其实这个页面和其他页面除了内容有所不同外,结构是一样的。而且这个页面的代码也没有语法错误。但奇怪的是,其他的页面,比如http://xiaohua.zol.com.cn/lengxiaohua/33.html或者http://xiaohua.zol.com.cn/lengxiaohua/35.html都不会报错,正常采集。

由于我用的laravel框架,采用了querylist这个专门针对数据采集的包。通过调试发现只要我用到setHtml()方法,得到的html代码就会被破坏。html代码结构被破坏后,就无法得到正常的domdocument,就无法进行解析了。

...
$listurl = 'http://xiaohua.zol.com.cn/lengxiaohua/34.html';
$listtaskpid = $v['id'];
echo "采集列表页任务::".$listurl."\n";
$listql = $ql->myGet($listurl,[],$options);
$listql->use(AbsoluteUrl::class);
if($charset != 'utf-8'){
    $listhtml = Http::get($listurl,[],$options)->body();
    //dump($listhtml);
    $listhtml = mb_convert_encoding($listhtml,'UTF-8','GBK');
    //dump($listhtml);
    $listhtml = preg_replace('/<meta[^<>]*charset[^<>]*?>/i', '', $listhtml);
    $listhtml = preg_replace('/<!--.*?-->/i', '', $listhtml);
    //dump($listhtml);//这里打印的html内容还是正常的
    $listql->setHtml($listhtml);
}
if ($listql->getHtml() == ''){
    continue;
}
//dump($listql);//在这一步,可以发现打印出的结果就和预期的html内容不一样了,也就是被破坏了
//由于html结构已经被破坏,下一步已经无法解析出正确的数据了
$listdata = $listql->absoluteUrl($listurl)->rules($listrule)->range($listrange)->query()->getData();

进一步研究发现querylist用的是phpquery的包。于是我采用原生php编写了专门的代码进行测试,发现phpquery也同样会遇到问题。

<?php
ini_set('display_errors',1); //错误信息
ini_set('display_startup_errors',1); //php启动错误信息
error_reporting(-1);
require('phpQuery/phpQuery.php');
$listurl = 'http://xiaohua.zol.com.cn/lengxiaohua/34.html';
$listhtml = file_get_contents($listurl);
$listhtml = mb_convert_encoding($listhtml,'UTF-8','GBK');
$listhtml = preg_replace('/<meta[^<>]*charset[^<>]*?>/i', '<meta http-equiv="Content-Type" content="text/html;charset=utf-8">', $listhtml);
$listhtml = preg_replace('/<!--.*?-->/i', '', $listhtml);
//以下是为方便直接照搬的phpQueryCli的代码
$argv = array("./cli/phpquery","--find",".article-list","--contents");
phpQueryCli($listhtml, array_slice($argv, 1));
function phpQueryCli($markup, $callQueue) {
    $pq = phpQuery::newDocument($markup);
    $method = null;
    $params = array();
    foreach($callQueue as $param) {
        if (strpos($param, '--') === 0) {
            if ($method) {
                $pq = call_user_func_array(array($pq, $method), $params);
            }
            $method = substr($param, 2);   // delete --
            $params = array();
        } else {
            $param = str_replace('\n', "\n", $param);
            $params[] = strtolower($param) == 'null'
                ? null
                : $param;
        }
    }
    if ($method)
        $pq = call_user_func_array(array($pq, $method), $params);
    if (is_array($pq))
        foreach($pq as $v)
            print $v;
    else
        print $pq."\n";
    //var_dump($pq);
}

再进一步,phpquery是利用了php domdocument来处理html代码的。于是我直接用原生的PHP写了代码,发现domdocument也无法解决问题。

<?php
ini_set('display_errors',1); //错误信息
ini_set('display_startup_errors',1); //php启动错误信息
error_reporting(-1);
require('phpQuery/phpQuery.php');
$listurl = 'http://xiaohua.zol.com.cn/lengxiaohua/34.html';
$listhtml = file_get_contents($listurl);
$listhtml = mb_convert_encoding($listhtml,'UTF-8','GBK');
$listhtml = preg_replace('/<meta[^<>]*charset[^<>]*?>/i', '<meta http-equiv="Content-Type" content="text/html;charset=utf-8">', $listhtml);
$listhtml = preg_replace('/<!--.*?-->/i', '', $listhtml);
$doc = new DOMDocument();
$doc->validateOnParse = true;
$doc->loadHTML($listhtml,LIBXML_HTML_NODEFDTD);
$doc->encoding = 'UTF-8';
echo $doc->saveHTML();
//这里echo的结果和预期的html内容不一致

最后问题指向这里:即DOMDocument的loadHtml()方法。这个方法,只能在PHP的源码里找了。

同时我验证了一下是什么原因导致DOMDocument出现异常的。我把http://xiaohua.zol.com.cn/lengxiaohua/34.html这个页面的html代码存在本地,将第378行的一个尖括号删除,再测试就正常了。如图:

为方便,我直接用phpQuery的客户端模式进行测试,如下:

cat 34utf8.html | php ./cli/phpquery –find ‘.article-list’ –contents
34utf8.html是我将html内容转换成utf-8格式后保存的html文件。这个测试结果可以看到提取出的html结构就是正常的了。

我用的PHP版本是7.3.9,解压源码,vim ext/dom/document.c,看看具体的实现:

staticvoid dom_load_html(INTERNAL_FUNCTION_PARAMETERS, int mode) /* {{{ */
{
zval *id;
xmlDoc *docp = NULL, *newdoc;
dom_object *intern;
dom_doc_propsptr doc_prop;
char *source;
size_t source_len;
int refcount, ret;
zend_long options = 0;
htmlParserCtxtPtr ctxt;
id = getThis();
if (zend_parse_parameters(ZEND_NUM_ARGS(), “s|l”, &source, &source_len, &options) == FAILURE) {
return;
}
if (!source_len) {
php_error_docref(NULL, E_WARNING, “Empty string supplied as input”);
RETURN_FALSE;
}
if (ZEND_LONG_EXCEEDS_INT(options)) {
php_error_docref(NULL, E_WARNING, “Invalid options”);
RETURN_FALSE;
}
if (mode == DOM_LOAD_FILE) {
if (CHECK_NULL_PATH(source, source_len)) {
php_error_docref(NULL, E_WARNING, “Invalid file source”);
RETURN_FALSE;
}
ctxt = htmlCreateFileParserCtxt(source, NULL);
} else {
source_len = xmlStrlen((xmlChar *) source);
if (ZEND_SIZE_T_INT_OVFL(source_len)) {
php_error_docref(NULL, E_WARNING, “Input string is too long”);
RETURN_FALSE;
}
ctxt = htmlCreateMemoryParserCtxt(source, (int)source_len);
}

虽然C语言这种太底层的语言理解起来有点麻烦,但是基本看得出,loadHtml主要是由这一段代码来执行的:

source_len = xmlStrlen((xmlChar *) source);
if (ZEND_SIZE_T_INT_OVFL(source_len)) {
php_error_docref(NULL, E_WARNING, “Input string is too long”);
RETURN_FALSE;
}
ctxt = htmlCreateMemoryParserCtxt(source, (int)source_len);
而这里,又要得去看libxml2的代码才能明白具体是怎么回事。。。
暂时先放在这里不管吧。。。
那个有问题的url,只好先作为特例排除了。
我提问的相关链接:
https://segmentfault.com/q/1010000023035809
https://github.com/jae-jae/QueryList/issues/114
展示下采集器的工作过程:
效率还算可以吧。后续还会再完善,尽量做成适配所有采集类型的情况,尽量通用。
大概的想法是这样的:
1.会做一个管理后台,所有人都可以注册进入;
2.每个人都可以在后台创建自己的采集规则,测试规则,分享或出售规则;
3.用户可以免费采集100条数据;如果想采集更多,必须填写自己的代理IP。
由于这种爬虫类的工具很容易被滥用,所以未来可能要么直接用另一种语言重写成客户端的形式,让想用的人自己去用,从而让用户的使用尽量不给软件作者带来风险。
当然,最主要的是我自己未来需要用这个软件来做一些提高效率的工作。

Posted

in

by

Tags:

Comments

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注