搜索摘要提取:从整页HTML到正文纯文本

一、一句「我看不了正文」,让我多花了两个小时推演

我的站点上有个搜索框,最近一直有问题:搜索「nginx 缓存」,出来九条结果,但摘要里显示的全是「首页 关于 归档 友情链接 这一栏…」这种导航栏文字,正文一个字都没有。换个关键词,比如「mysql」,摘要干脆只剩半句就断了。站内搜索本身在正常工作,命中数、分页、排序都对,就是摘要没法看。

真正麻烦的地方不是修,而是我看不到出问题的那段代码。这套搜索是几年前从一个开源主题里抄来的片段,写在一个我后来很少改的模板文件里,而且它引入了一个第三方分词库。上线之前没人怀疑过它,出问题之后我对着它推演了一个下午,把「可能是索引没更新」「可能是编码问题」「可能是分词库版本不对」这几条都试了一遍,改了两轮,都没用。

直到我下定决心把整条链路拆开,一部分一部分地打印出来看,才发现问题出在最开始的地方:我在第一步就把整页 HTML 截了一段当摘要的来源。这篇文章讲的就是这个排查过程,也就是「HTML 提取摘要」这件事该怎么做才是对的,以及为什么自己写正则一定会踩坑。

二、先把问题拆成四段,逐段打印

任何「搜索摘要不对」的问题,都可以拆成四段:从数据库取出的原文是什么、原文里有哪些 HTML 结构、提取函数输出了什么、以及最终被截断和转义成什么样。四段各有各的失败方式,混在一起看就永远分不清责任在哪。

我的做法是写一个一分钟就能跑完的脚本,把同一条记录的四个中间态全部打印出来:

<?php
$row = $db->query("SELECT cid, text FROM contents WHERE cid=1955")->fetch();

// 第一段:原始长度与开头 300 字符
echo "raw_len=", strlen($row["text"]), chr(10);
echo "raw_head=", mb_substr($row["text"], 0, 300), chr(10)*2;

// 第二段:HTML 标签出现次数
echo "h2=", substr_count($row["text"], "<h2"), " p=", substr_count($row["text"], "<p>"),
     " pre=", substr_count($row["text"], "<pre>"), chr(10)*2;

// 第三段:当前提取函数的输出
$plain = strip_tags($row["text"]);
echo "plain_len=", mb_strlen($plain), chr(10);
echo "plain_head=", mb_substr($plain, 0, 300), chr(10);

这段跑完我就看见了第一层问题的轮廓:raw_head 开头是 <!DOCTYPE html> 和一堆 <head> 里的 meta,也就是库里存的根本不是文章正文,而是整页渲染好的 HTML。第二段打印出的 h2 计数给了我 43 个,而文章本身只有 9 个小节标题,多出来的全是页面导航和其它文章的引用。

结论很明确:摘要的来源从第一步就选错了。它取的是「整页输出」,而我要的是「文章正文」。这一步不解决,后面无论怎么优化正则都是在给错误的数据做加工。

三、第一件必须做的事:把页面切成「这一篇」和「其它」

整页 HTML 里最少混杂着五类内容:页面骨架(html、head、body)、页头导航、正文、侧边栏(热门文章、标签云)、页脚。它们对搜索全都无意义,其中侧边栏尤其恶劣,因为它会把其它文章的标题混进来,让你的搜索结果命中一篇完全不相干的文章。

切分的方式有两种。第一种是结构化的:如果正文外层有稳定的容器,比如 <div class="entry-content">,那就在这个容器上做边界提取。第二种是暴力删除:把所有已知的噪音块删掉,剩下的就是正文。两种做法我建议先尝试第一种,失败再退到第二种,因为结构化切分不会误伤正文里的内容。

<?php
function extract_body(string $html): string
{
    // 优先:找正文容器(注意这里用的是宽松的空白匹配)
    if (preg_match('/<div[^>]*class="[^"]*entry-content[^"]*"[^>]*>(.*)<\/div>\s*<\/article>/s',
                   $html, $m)) {
        return $m[1];
    }

    // 退化:删掉明确的噪音块
    $noise = [
        '/<head\b.*?<\/head>/is',
        '/<nav\b.*?<\/nav>/is',
        '/<aside\b.*?<\/aside>/is',
        '/<footer\b.*?<\/footer>/is',
        '/<script\b.*?<\/script>/is',
        '/<style\b.*?<\/style>/is',
        '/<!--.*?-->/s',
    ];
    $body = preg_replace($noise, '', $html);

    // 页面骨架本身不删,只做标记,方便日志比对
    if (stripos($body, '<body') !== false) {
        error_log('extract_body: 走了降级路径,仍在整页结构上处理');
    }
    return $body;
}

这里有两个细节值得单独说。一是正则里的 /s 修饰符,没有它,.*? 遇到换行就停,而整页 HTML 一定带换行,这是「正则永远只匹配到第一行」的经典成因。二是「不闭合的 div」,真实的页面里 <div class="entry-content"> 内部常常嵌着别的 div,用非贪婪匹配再加上 </div>\s*</article> 这种尾部锚点,正是为了绕开「第一个 </div> 就当成结束」的误判。如果主题连 </article> 都没有,这条路就走不通,老老实实走降级路径。

四、为什么你不该用正则剥离 HTML 标签

退回正文之后,下一步是「去掉所有标签,只要文字」。绝大多数人的第一反应和我当时一样:preg_replace('/<[^>]+>/', '', $html)。这条正则看起来很合理,但它有五个我自己都踩到过的问题。

第一个问题是 > 出现在属性值里。比如 <div data-tip="a > b">内容</div>,正则会把 <div data-tip="a > 当成完整标签吃掉,剩下的 b">内容 就成了正文的一部分,摘要里凭空多出一段乱七八糟的字符。这在主题里其实很常见——title="HTML 用法说明" 之类的属性一旦带上尖括号就会翻车。

第二个问题是没有闭合的标签会把整段吞掉。正文里如果有用户上传的富文本、或者一段粘贴进来的不完整 HTML,比如「小于号开头的比较式」a < b 被当成标签开始、后面直到下一个 > 的所有内容都被删除,而那个 > 可能在几百字之后——于是你在摘要里看到的是一大段被整齐地吃掉。

第三个问题是实体不还原。strip_tags 和上面的正则都只删标签,不处理实体。正文里的 &lt;、&amp;、&nbsp;、&#8212; 会原样留在摘要里,前台看起来就是「&nbsp;」这几个字符。这类问题在我的旧文章里非常多,因为早年我用编辑器写过大量带实体的内容。

第四个问题是行内样式和不可见内容。<style> 块里的 CSS 文字、<script> 里的 JS 代码、<!-- 注释 --> 的内容,如果不在前面的噪音清理里删掉,都会被当成正文。我曾经见过一条搜索结果的摘要变成了半段 JavaScript 变量声明,原因就是这个。

第五个问题最隐蔽:<br> 和块级元素被直接删掉之后,两段话黏在一起。「第一段结尾第二段开头」这样合成一句话,字号看起来毫无异常,但读起来完全不通。正确的处理是先把块级标签替换成空格或换行,再做去标签。

把这五条合起来,结论就是:能用 strip_tags 就别写正则,能用 DOM 就别用 strip_tags。 strip_tags 虽然也不完美(它不处理实体、不处理脚本内容),但它有 PHP 解析器在背后帮忙判断标签边界,至少不会把 > 弄错。

五、正确的做法:DOM 解析加白名单遍历

我最后用的是 PHP 内置的 DOMDocument,把正文片段加载进来,按节点类型遍历,只取文本节点,块级元素前后补空白。这条路比正则长一些,但从此再没出过错。

<?php
function html_to_text(string $fragment): string
{
    $html = '<?xml encoding="UTF-8"><div id="root">' . $fragment . '</div>';
    $doc  = new DOMDocument();
    $prev = libxml_use_internal_errors(true);
    $ok   = $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'),
                           LIBXML_NONET | LIBXML_NOENT);
    libxml_clear_errors();
    libxml_use_internal_errors($prev);
    if (!$ok) { return ''; }

    $block = ['p','div','h1','h2','h3','h4','h5','h6','li','tr','blockquote','pre','section'];
    $skip  = ['script','style','noscript','iframe','svg'];
    $out   = '';

    $walk = function (DOMNode $node) use (&$walk, &$out, $block, $skip) {
        if ($node->nodeType === XML_TEXT_NODE) {
            $out .= $node->nodeValue;
            return;
        }
        if ($node->nodeType !== XML_ELEMENT_NODE) { return; }
        $tag = strtolower($node->nodeName);
        if (in_array($tag, $skip, true)) { return; }

        if (in_array($tag, $block, true)) { $out .= chr(10); }
        if ($tag === 'br') { $out .= chr(10); }

        foreach ($node->childNodes as $child) { $walk($child); }

        if (in_array($tag, $block, true)) { $out .= chr(10); }
    };
    $walk($doc->getElementById('root'));

    $out = html_entity_decode($out, ENT_QUOTES | ENT_HTML5, 'UTF-8');
    $out = preg_replace('/[ \t\x{00A0}]+/u', ' ', $out);   // 各种空白压成一个
    $out = preg_replace('/chr(10){2,}/', chr(10), $out);
    return trim($out);
}

这段代码里有五个关键点,每一个都是踩过坑之后加的。

① 必须给 UTF-8 一个明示。DOMDocument::loadHTML 默认把输入当 ISO-8859-1 处理,中文会变成一堆乱码。常见的补丁是在前面拼一段 <?xml encoding="UTF-8"> 声明,或者用 mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8') 把中文转成实体再喂进去。后者对 PHP 5.6 更稳,我实际用的是后者。

② LIBXML_NONET 不能省。默认情况下 loadHTML 会尝试加载 DTD 和外部实体,遇到带外部引用的正文片段时会去访问网络,慢且不安全。加上 LIBXML_NONET,配合 libxml_disable_entity_loader 才是完整的防护。

③ 一定要有外层容器。直接 loadHTML 一个片段,DOMDocument 会自动包上 html 和 body,遍历时就分不清哪些是你给的、哪些是它补的。先包一个 div id="root" 再 getElementById,边界就干净了。

④ 块级元素前后都补换行,行内元素不补。这是「两段话黏在一起」的正解。若 <p> 前后不加换行,<p>第一段</p><p>第二段</p> 会输出「第一段第二段」;若给 <span> 也加换行,<span>Nginx</span>的缓存 会分成两行,摘要里出现莫名断句。块级和行内的区分必须做对。

⑤ 空白压缩要用 /u 修饰符,并且把 U+00A0 一起处理。从网页复制来的正文里,最常见的「看不见的字符」就是不换行空格 &nbsp;,它不会被普通的 \s 匹配到,于是摘要里会出现一堆看起来正常但占位的空格,前端 text-overflow: ellipsis 截断的位置也会算错。

六、摘要不只是「截前 200 字」

文字提取对了,下一步是截取。这里面有三个我实测下来必须处理的点。

第一,以「词」还是「字」为单位截。中文按字截没问题,但英文按字截会出现「ngin」这种半个单词。稳妥做法是先按显示宽度估算,中文算 2、英文数字算 1,累加到目标宽度为止;或者更简单——中文场景统一按字符截,遇到中英文混排时把截断点向左回退到最近的一个空白或标点。

第二,不要在标签中间截断 HTML。这条是旧方案出事的地方。如果先截 HTML 再 strip_tags,截断点很可能落在 <pre> 的中间,导致这段 HTML 结构不完整,后面的清理正则行为不可预测。正确顺序永远是先转纯文本、再截断。

第三,高亮关键词要做「先转义再替换」。搜索摘要里要标出命中的词,常见写法是 str_replace($kw, '<em>'.$kw.'</em>', $text),隐患是如果 $text 里本身含有 <,替换之后 HTML 结构就被破坏了,或者用户提交的关键词里带 HTML 时被直接注入。正确的顺序是:先把纯文本 htmlspecialchars,再把同样转义过的关键词替换成 <em> 包裹的形式。

<?php
function make_snippet(string $text, string $kw, int $width = 160): string
{
    $text = mb_substr($text, 0, $width * 3);          // 中文按 3 倍预留
    $text = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');

    $kw = trim($kw);
    if ($kw === '') { return $text; }
    $safe = htmlspecialchars($kw, ENT_QUOTES, 'UTF-8');
    $out  = str_ireplace($safe, '<em>' . $safe . '</em>', $text);

    // 只保留我们自己插进去的 em,其它标签一律清掉
    return strip_tags($out, '<em>');
}

这里 strip_tags($out, '<em>') 的白名单参数很关键:它保证输出里只可能有 <em> 这一个标签,其它任何东西——包括用户提交的关键词里夹带的标签——都被剥掉。这是我在做站内搜索安全时最常用的一招。

七、一个真实的对比:改前改后各是什么样

为了确认修改真的有效,我把同一批 20 篇旧文章用新旧两套函数各跑了一遍,把摘要并排打出来对比。最典型的几条差异是这样的:

修改前,搜索「缓存」命中的某篇,摘要开头是「首页 关于 归档 友情链接 本站由 Typecho 强力驱动 热门文章 Nginx 超时参数分层…」。修改后,同一篇的摘要是「一、先说清楚 conntrack 到底在干什么 上一篇文章写完…」。这不是算法变聪明了,只是终于从正确的容器里取文字了。

修改前,还有几篇的摘要里混着 &nbsp; 和 &#8212; 的原始实体;修改后实体全部还原成正常字符。有一篇摘要里的 &lt;pre&gt; 变成了「&lt;pre&gt;」,也就是「小于号 pre 大于号」这几个字符——这是库里本来就存着被转义过的代码示例,属于正常现象,不是新的 bug。

最有说服力的是长度统计:改前 20 篇的摘要平均字符数是 41,改后是 148。改前之所以这么短,是因为截断经常落在页面骨架的某个标签中间,后面的内容被清理正则一起删掉了。这个数字比任何肉眼检查都能说明问题。

八、几个只在生产环境才会遇到的问题

第一个是性能。DOM 解析比正则慢,粗略测下来大文章上大约慢三到五倍。站内搜索如果每次请求都要对几十篇正文做提取,页面会明显变卡。解决办法只有一个:把摘要提前算好存起来,在文章保存时生成一列 snippet,搜索时直接查这一列。这也顺带解决了一个隐藏问题——搜索时的提取逻辑一旦改了,历史文章不会自动更新,必须有「批量重算」这个动作。

第二个是 pre 块里的代码。把代码块当成普通段落处理,摘要里会出现「nginx -t && nginx -s reload」这种半句命令,对用户毫无意义。我的做法是:提取时给 <pre> 单独打标记,生成摘要时优先从 <p> 里取文字,如果整篇都是代码(比如配置类教程),再退回用代码里的注释行当摘要。

第三个是 HTML 里混着 Markdown。我的站上有很多文章正文是「纯 HTML + 少量 Markdown 习惯」,比如三个星号包起来的强调、或者用 Markdown 写的表格。这些在页面上本来就不解析,转成纯文本之后会原样出现在摘要里,看起来像乱码。这类只能靠生成摘要时顺带做一次轻量清洗,把明显不可能出现在正文里的符号(成对的星号、连续的反引号)删掉。

九、小结

搜索摘要不好看,十次里有八次不是「截断算法不对」,而是「源数据取错了」。正确的顺序只有一条:先切出正文容器、再删掉噪音块、然后用 DOM 遍历取文本并给块级元素补空白、最后才做转义和截断。凡是把顺序反过来、或者用一条正则试图一次剥干净的做法,都会在某篇内容上出问题,而且出问题时你看不出原因,只能像我一样花一下午去猜。真正的收益不在代码漂亮,而在于这套流程是可解释的——每一段输出都能打印出来对照,出了问题三十秒就能定位到是哪一段。

Last modification:November 7th, 2026 at 10:26 pm

Leave a Comment