适用环境:PHP + PDO SQLite 的轻量自建 BBS(本站架构),伪静态已开启、后台管理分离。 涉及文件:sidebar.php(新建)、index.php、article.php、functions.php(各改少量行)。 本文记录问题现象、根因分析、完整代码与上线踩坑,可直接作为备份对照。


一、问题现象

现象 A:文章详情页右侧边栏,「最新文章」和「相关阅读」两个板块的内容一模一样,五条标题逐条相同。

现象 B:首页搜索关键词后,结果超过一页时,点击第 2 页回到全站列表,关键词丢失,搜索结果只剩全站文章。

两个现象看起来不相关,实际都是"兜底逻辑"埋的坑。


二、根因分析

2.1 侧边栏为什么会一模一样

原始代码的相关阅读逻辑分两步:

第一步:从标题抽取关键词

$kwParts = preg_split('/[\s\+\(\)\/\—\-\|\,,。、::]+/u', $curTitle);

这个分隔符只认空格和标点。中文标题几乎不含空格,例如「WordPress自动推送IndexNow设置教程」整条会被当成一个关键词,最终执行的 SQL 变成:

WHERE title LIKE '%WordPress自动推送IndexNow设置教程%'

除非库里存在标题几乎完全相同的文章,否则匹配结果恒为空。

第二步:空结果时用"最新文章"补齐

if (count($relatedList) < 3) {
    $fillSql = "SELECT id,title,create_time FROM article
                WHERE id NOT IN (...) ORDER BY id DESC LIMIT " . (5 - count($relatedList));
}

结果为 0 时就是 LIMIT 5 + ORDER BY id DESC,排除条件只有当前文章 ID——和上面「最新文章」的查询完全等价。于是两个列表逐条一致。哪怕只命中 1~2 条,前几条同样会被最新文章填满,看起来依然"差不多"。

一句话总结:中文分词失败 → 命中为空 → 兜底复制最新文章。

2.2 搜索翻页为什么会丢关键词

getStaticUrl() 里拼接额外参数的条件是:

if(!$rewriteOn && !empty($query)){   // ← 问题所在
    $url .= ... . http_build_query($query);
}

本站 $rewriteOn = true,这个条件恒为 false,q 参数从来没被拼进 URL。

搜索时 buildPagination($currentPage, $totalPages, $q) 虽然传了 ['q' => '大漠'],生成的链接却是干净的 index-page-2.html,点进去 $_GET['q'] 为空,自然回落全站列表。

一句话总结:伪静态开启时,额外参数被无条件丢弃。

2.3 关于"是不是缓存"的排查结论

这套代码没有任何应用层缓存(无 APCu、无文件缓存、无 ob 复用)。第 1 页和第 2 页是不同 URL,浏览器/CDN 缓存也无法解释参数丢失。

唯一可能造成"改完没立刻生效"的是 OPcache 字节码缓存(XAMPP 默认开启,opcache.revalidate_freq 默认 2 秒)。改完文件最多延迟 2 秒生效,看起来像缓存,实际不是。

彻底排除二选一:

  • 改完文件重启一次 Apache
  • php.ini 设 opcache.validate_timestamps=1 + opcache.revalidate_freq=0(开发环境推荐)

另外务必确认改的是站点实际加载的那份文件,不是副本——这比缓存更容易踩。


三、修复一:新建 sidebar.php(侧边栏统一文件)

把侧边栏的数据获取与 HTML 渲染抽成独立文件,index.php 和 article.php 共用同一份,避免两套逻辑各自走样。

核心改动点:

  1. 中文标题用停用词剔除 + 2 字滑窗分词,英文/数字词单独提取
  2. 候选集按命中词数打分,再用 Dice 系数重排(抵消"标题越长命中越多"的偏差)
  3. 兜底填充时排除「最新文章」已出现的 ID,两个板块永不再雷同
  4. 真实相关为 0 时,板块标题自动降级为「猜你喜欢」,不再伪装成"相关阅读"
  5. 若 article 表存在 tags 字段,自动启用标签加权,无需改代码

在站点根目录新建 sidebar.php,内容如下:

<?php
/**
 * sidebar.php —— 侧边栏「最新文章 / 相关阅读」数据与渲染
 * ------------------------------------------------------------
 * 用法(article.php 与 index.php 共用同一份,避免两套逻辑走样):
 *   1) config.php 里在 functions.php 之后加一行:
 *        require_once __DIR__ . '/sidebar.php';
 *   2) 文章页:
 *        $side = getSidebarData($db, $id);          // $id 为当前文章ID
 *        renderSidebar($side, $db);                 // 输出整个 <div class="art-side">
 *
 * 设计要点:
 *   - 相关阅读使用「标题分词 + 命中打分 + Dice 相似度重排」,不再整条标题 LIKE;
 *   - 兜底填充时排除「最新文章」已出现的 ID,两个板块永不再一模一样;
 *   - 真实相关不足 2 条时,板块标题自动降级为「猜你喜欢」,不再伪装成相关阅读;
 *   - 若 article 表存在 tags 字段,自动启用标签加权(无需改代码,加了字段就生效)。
 */
if (!defined('IN_BBS')) exit;

/* ========== 小工具:判断表里有没有某列(SQLite) ========== */
if (!function_exists('sbHasColumn')) {
function sbHasColumn(PDO $db, string $table, string $col): bool
{
    static $cache = [];
    $key = $table . '.' . $col;
    if (isset($cache[$key])) return $cache[$key];
    try {
        $st = $db->query("PRAGMA table_info({$table})");
        $found = false;
        foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
            if (strcasecmp($r['name'], $col) === 0) { $found = true; break; }
        }
        return $cache[$key] = $found;
    } catch (Exception $e) {
        return $cache[$key] = false;
    }
}
}

/* ========== 中文通用词表(2字),命中即丢弃,避免"教程/方法"把全站文章串起来 ========== */
if (!function_exists('sbStopZh')) {
function sbStopZh(): array
{
    static $list = null;
    if ($list === null) {
        $words = ['教程','方法','使用','实现','问题','解决','分享','详解','入门','总结','介绍',
                  '以及','如何','怎么','什么','步骤','完整','最新','简单','常用','常见','技巧',
                  '注意','说明','分析','实战','系列','案例','基础','高级','相关','配置','安装',
                  '下载','代码','示例','笔记','记录','汇总','大全','视频','源码','免费','推荐',
                  '一篇','几个','那些','这些','怎样','需要','关于','中的','之一','之二','新手',
                  '必看','史上','最强','整理','收藏','合集','详解','图解','超简','详细','常见',
                  '常见','写法','用法','区别','对比','介绍','说明','学习','初级','进阶','快速'];
        $list = array_fill_keys($words, true);
    }
    return $list;
}
}

/* ========== 从标题抽取关键词:英文/数字词 + 中文2字滑窗 ========== */
if (!function_exists('sbExtractKeywords')) {
function sbExtractKeywords(string $title, int $max = 10): array
{
    $title = trim(preg_replace('/\s+/u', ' ', $title));
    if ($title === '') return [];
    $stopZh = sbStopZh();
    $stopEn = array_fill_keys(['the','and','for','with','you','www','com','http','https','html','net','org'], true);
    $kw = [];

    // 英文/数字词,长度>=3,避免 v1、x2 这类噪声
    if (preg_match_all('/[A-Za-z][A-Za-z0-9_.+#]{2,}/u', $title, $m)) {
        foreach ($m[0] as $w) {
            $w = strtolower(trim($w, "._-"));
            if (strlen($w) < 3) continue;
            if (isset($stopEn[$w])) continue;
            $kw[] = $w;
        }
    }
    // 中文片段:先剔除通用词("设置教程"→"设置"),<=3字整体保留,>3字切2字滑窗
    $segs = preg_split('/[^\x{4e00}-\x{9fa5}]+/u', $title);
    if ($segs !== false) {
        foreach ($segs as $seg) {
            if ($seg === '') continue;
            $seg = str_replace(array_keys($stopZh), ' ', $seg);
            foreach (preg_split('/\s+/u', trim($seg)) as $piece) {
                if ($piece === '') continue;
                $len = mb_strlen($piece, 'UTF-8');
                if ($len < 2) continue;
                if ($len <= 3) { $kw[] = $piece; continue; }
                for ($i = 0; $i + 2 <= $len; $i++) {
                    $kw[] = mb_substr($piece, $i, 2, 'UTF-8');
                }
            }
        }
    }

    $kw = array_values(array_unique($kw));
    // 长词区分度更高,优先送进 SQL(同时限制关键词数量,防止 LIKE 条件爆炸)
    usort($kw, function ($a, $b) {
        $la = mb_strlen($a, 'UTF-8'); $lb = mb_strlen($b, 'UTF-8');
        if ($la === $lb) return 0;
        return ($la > $lb) ? -1 : 1;
    });
    return array_slice($kw, 0, $max);
}
}

/* ========== 标题特征集:中文2字gram + 英文词,用于 Dice 相似度 ========== */
if (!function_exists('sbFeatureSet')) {
function sbFeatureSet(string $s): array
{
    $set = [];
    $segs = preg_split('/[^\x{4e00}-\x{9fa5}]+/u', $s);
    if ($segs !== false) {
        foreach ($segs as $seg) {
            $len = mb_strlen($seg, 'UTF-8');
            if ($len === 0) continue;
            if ($len === 1) { $set[$seg] = 1; continue; }
            for ($i = 0; $i + 2 <= $len; $i++) $set[mb_substr($seg, $i, 2, 'UTF-8')] = 1;
        }
    }
    $ens = preg_split('/[^A-Za-z0-9+#]+/u', $s);
    if ($ens !== false) {
        foreach ($ens as $w) {
            $w = strtolower(trim($w));
            if (strlen($w) >= 2) $set[$w] = 1;
        }
    }
    return $set;
}
}

if (!function_exists('sbDice')) {
function sbDice(array $a, array $b): float
{
    $inter = 0;
    foreach ($a as $k => $v) if (isset($b[$k])) $inter++;
    $total = count($a) + count($b);
    return $total > 0 ? (2.0 * $inter / $total) : 0.0;
}
}

/**
 * 取侧边栏数据
 * @return array ['latest'=>[], 'related'=>[], 'relatedTitle'=>'相关阅读'|'猜你喜欢']
 */
if (!function_exists('getSidebarData')) {
function getSidebarData(PDO $db, int $artId, int $limit = 5): array
{
    $limit = max(1, min(10, $limit));
    $latestList  = [];
    $relatedList = [];
    $relTitle    = '相关阅读';

    $hasTags = sbHasColumn($db, 'article', 'tags');

    /* 1. 当前文章 */
    $cur = null;
    if ($artId > 0) {
        $cols = $hasTags ? 'id,title,tags,create_time' : 'id,title,create_time';
        $st = $db->prepare("SELECT {$cols} FROM article WHERE id=:id");
        $st->bindValue(':id', $artId, PDO::PARAM_INT);
        $st->execute();
        $cur = $st->fetch(PDO::FETCH_ASSOC);
    }

    /* 2. 最新文章(基准集,相关阅读要避开它) */
    $latestStmt = $db->prepare("SELECT id,title,create_time FROM article WHERE id != :id ORDER BY id DESC LIMIT " . (int)$limit);
    $latestStmt->bindValue(':id', $artId, PDO::PARAM_INT);
    $latestStmt->execute();
    $latestList = $latestStmt->fetchAll(PDO::FETCH_ASSOC);
    $latestIds  = array_map('intval', array_column($latestList, 'id'));

    if (!$cur) {
        return ['latest' => $latestList, 'related' => [], 'relatedTitle' => $relTitle];
    }

    /* 3. 关键词(标签优先、权重更高) */
    $tagTerms = [];
    if ($hasTags && !empty($cur['tags'])) {
        $parts = preg_split('/[,,;;|、\s]+/u', (string)$cur['tags']);
        if ($parts !== false) {
            foreach ($parts as $t) {
                $t = trim($t);
                if ($t !== '') $tagTerms[] = $t;
            }
        }
    }
    $terms = array_values(array_unique(array_merge($tagTerms, sbExtractKeywords($cur['title']))));
    $termWeight = [];
    foreach ($terms as $t) $termWeight[$t] = in_array($t, $tagTerms, true) ? 3 : 1;

    /* 4. 候选集:标题命中任一关键词(tags 列存在时一并匹配标签) */
    $cand = [];
    if ($terms) {
        $where = []; $score = []; $params = [':currentId' => $artId];
        foreach ($terms as $i => $t) {
            $w = $termWeight[$t];
            $where[] = $hasTags
                ? ("title LIKE :kw{$i} OR tags LIKE :kw{$i}")
                : ("title LIKE :kw{$i}");
            $score[] = "(CASE WHEN title LIKE :kw{$i} THEN {$w} ELSE 0 END)"
                     . ($hasTags ? " + (CASE WHEN tags LIKE :kw{$i} THEN {$w} ELSE 0 END)" : '');
            $params[":kw{$i}"] = '%' . $t . '%';
        }
        $sql = "SELECT id,title,create_time, (" . implode(' + ', $score) . ") AS score
                FROM article
                WHERE id != :currentId AND (" . implode(' OR ', $where) . ")
                ORDER BY score DESC, id DESC LIMIT 30";
        $relStmt = $db->prepare($sql);
        foreach ($params as $k => $v) $relStmt->bindValue($k, $v);
        $relStmt->execute();
        $cand = $relStmt->fetchAll(PDO::FETCH_ASSOC);
    }

    /* 5. 用 Dice 相似度重排(抵消"标题越长命中越多"的偏差) */
    $curSet = sbFeatureSet($cur['title'] . ($hasTags ? ' ' . (string)$cur['tags'] : ''));
    foreach ($cand as $k => $row) {
        $cand[$k]['score'] = (int)$row['score'];   // PDO_SQLite 不同版本可能返回字符串,统一转 int
        $cand[$k]['dice']  = sbDice($curSet, sbFeatureSet($row['title']));
    }
    usort($cand, function ($a, $b) {
        if (abs($a['dice'] - $b['dice']) > 0.0001) return ($a['dice'] > $b['dice']) ? -1 : 1;
        if ($a['score'] !== $b['score']) return ($a['score'] > $b['score']) ? -1 : 1;
        return ($a['id'] > $b['id']) ? -1 : 1;
    });

    /* 6. 相似度达标的算"真相关";一条都没有才降级为「猜你喜欢」。
     *    填充时始终排除「最新文章」已出现的 ID,两个板块不会再看不出区别。 */
    $real = [];
    foreach ($cand as $row) {
        if (count($real) >= $limit) break;
        if ($row['dice'] >= 0.12) $real[] = $row;
    }
    $month = substr((string)$cur['create_time'], 0, 7);

    if (!empty($real)) {
        $relatedList = $real;
        $need = min(3, $limit) - count($relatedList); // 至少凑够 3 条,板块不至于太空
        if ($need > 0) {
            $exclude = array_merge([$artId], $latestIds, array_column($relatedList, 'id'));
            $relatedList = array_merge($relatedList, sbFillArticles($db, $need, $exclude, $month));
        }
    } else {
        $relTitle    = '猜你喜欢';
        $relatedList = sbFillArticles($db, $limit, array_merge([$artId], $latestIds), $month);
    }

    return ['latest' => $latestList, 'related' => $relatedList, 'relatedTitle' => $relTitle];
}
}

/* ========== 兜底填充:同月优先 + 随机,视觉上和「最新文章」区分开 ========== */
if (!function_exists('sbFillArticles')) {
function sbFillArticles(PDO $db, int $need, array $excludeIds, string $month = ''): array
{
    if ($need <= 0) return [];
    $excludeIds = array_values(array_unique(array_filter(array_map('intval', $excludeIds))));
    if (empty($excludeIds)) $excludeIds = [0];
    $in = implode(',', $excludeIds);
    $sql = "SELECT id,title,create_time FROM article
            WHERE id NOT IN ({$in})
            ORDER BY CASE WHEN substr(create_time,1,7) = :m THEN 0 ELSE 1 END, RANDOM()
            LIMIT " . (int)$need;
    $st = $db->prepare($sql);
    $st->bindValue(':m', $month);
    $st->execute();
    return $st->fetchAll(PDO::FETCH_ASSOC);
}
}

/* ========== 输出侧边栏 HTML ========== */
if (!function_exists('renderSidebar')) {
function renderSidebar(array $side, PDO $db): void
{
    $latestList  = isset($side['latest']) ? $side['latest'] : [];
    $relatedList = isset($side['related']) ? $side['related'] : [];
    $relTitle    = isset($side['relatedTitle']) ? $side['relatedTitle'] : '相关阅读';
    $h = function ($s) { return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); };

    echo '<div class="art-side">';

    // 关于本站
    echo '<div class="card mb-3"><div class="card-body">';
    echo '<h5 class="side-title">关于本站</h5>';
    echo '<p style="font-size:14px;line-height:1.7;color:#555;">511遇见BBS,专注易语言、大漠插件、游戏脚本、多线程编程、C#、Lua等技术分享,提供实战源码与视频教程。</p>';
    echo '<a href="http://www.511yj.com/" target="_blank" rel="noopener" class="btn btn-primary btn-sm"><i class="fa fa-cloud-upload mr-1"></i>511遇见官网</a>';
    echo '</div></div>';

    // 本站统计
    $totalCount = (int)$db->query("SELECT COUNT(*) AS c FROM article")->fetch(PDO::FETCH_ASSOC)['c'];
    echo '<div class="card mb-3"><div class="card-body">';
    echo '<h5 class="side-title">本站统计</h5>';
    echo '<ul class="list-unstyled mb-0" style="font-size:14px;">';
    echo '<li class="d-flex justify-content-between py-1" style="border-bottom:1px dashed #eee;"><span>文章总数</span><b>' . $totalCount . '</b></li>';
    echo '<li class="d-flex justify-content-between py-1" style="border-bottom:1px dashed #eee;"><span>文章归档</span><a href="index.php?act=archive">查看</a></li>';
    echo '<li class="d-flex justify-content-between py-1"><span>站点地图</span><a href="sitemap.xml" target="_blank">查看</a></li>';
    echo '</ul></div></div>';

    // 最新文章
    if (!empty($latestList)) {
        echo '<div class="card mb-3"><div class="card-body">';
        echo '<h5 class="side-title">最新文章</h5><ul class="list-unstyled mb-0">';
        foreach ($latestList as $lat) {
            echo '<li class="mb-2 pb-2" style="border-bottom:1px dashed #eee;">';
            echo '<a href="' . $h(getStaticUrl('article', (int)$lat['id'])) . '" style="font-size:14px;line-height:1.6;color:#2362d3;">' . $h($lat['title']) . '</a>';
            echo '<div class="text-muted" style="font-size:12px;margin-top:2px;">' . $h($lat['create_time']) . '</div>';
            echo '</li>';
        }
        echo '</ul></div></div>';
    }

    // 相关阅读 / 猜你喜欢
    if (!empty($relatedList)) {
        echo '<div class="card mb-3"><div class="card-body">';
        echo '<h5 class="side-title">' . $h($relTitle) . '</h5><ul class="list-unstyled mb-0">';
        foreach ($relatedList as $rel) {
            echo '<li class="mb-2 pb-2" style="border-bottom:1px dashed #eee;">';
            echo '<a href="' . $h(getStaticUrl('article', (int)$rel['id'])) . '" style="font-size:14px;line-height:1.6;color:#2362d3;">' . $h($rel['title']) . '</a>';
            echo '<div class="text-muted" style="font-size:12px;margin-top:2px;">' . $h($rel['create_time']) . '</div>';
            echo '</li>';
        }
        echo '</ul></div></div>';
    }

    echo '</div>';
}
}

四、修复二:改造 index.php / article.php 调用点

4.1 必须同步改的 3 行

新版 getSidebarData() 返回的是关联数组,不是列表,调用方式要跟着变:

// ❌ 旧写法(会报 Undefined array key 0 / 1)
list($latestList, $relatedList) = getSidebarData($db, $artId);
renderSidebar($latestList, $relatedList, $db);

// ✅ 新写法
$side = getSidebarData($db, $artId);
renderSidebar($side, $db);

同时在文件顶部、require_once 'config.php'; 之后加一句:

require_once __DIR__ . '/sidebar.php';

4.2 为什么会报 TypeError

关联数组没有 0、1 这两个键,PHP 先抛:

Warning: Undefined array key 0 in index.php on line 44
Warning: Undefined array key 1 in index.php on line 44

解包结果 $latestList 变成 null,接着传给声明为 array $side 的参数:

Fatal error: Uncaught TypeError: renderSidebar(): Argument #1 ($side) must be of type array, null given

两个报错是同一处根因的连锁反应,改掉 list() 解包即可一起消失。

4.3 index.php 具体位置

<?php
require_once 'config.php';
require_once __DIR__ . '/sidebar.php';   // ← 新增
...

// 文章详情分支内,原 list() 那一行(约 44 行)
$side = getSidebarData($db, $artId);     // ← 改

// 模板里右侧边栏位置(约 124 行)
<?php renderSidebar($side, $db); ?>      // ← 改

article.php 同样处理,两个入口行为保持一致。

提示:原 index.php 里内联定义的 getSidebarData() / renderSidebar() 两个函数请整段删除,否则会与 sidebar.php 冲突(PHP 不允许重复声明函数)。


五、修复三:functions.php 改一行(搜索分页)

5.1 核心修复

// ❌ 旧:伪静态开启时额外参数被丢弃
if(!$rewriteOn && !empty($query)){
    $url .= (strpos($url,'?') === false ? '?' : '&') . http_build_query($query);
}

// ✅ 新:只要传了参数就拼接
if(!empty($query)){
    $url .= (strpos($url,'?') === false ? '?' : '&') . http_build_query($query);
}

改后链接效果:

index-page-2.html?q=%E5%A4%A7%E6%BC%A0%E6%8F%92%E4%BB%B6+%E5%A4%9A%E7%BA%BF%E7%A8%8B

安全性说明:绝大多数调用点 $query 都是空数组(文章页、首页分页、canonical、编辑页),行为与原来完全一致,不会被波及。

5.2 建议同时补两处

(1)分页 href 做 HTML 转义,防止关键词里出现 &、" 时破坏结构:

$mk = function($p) use ($param) {
    return htmlspecialchars(getStaticUrl('index_page', 0, $p, $param), ENT_QUOTES, 'UTF-8');
};

(2)内置动态 URL 开关,rewrite 规则留不住 query 时直接绕开:

$useDynamic = false; // 改成 true:搜索分页输出 index.php?q=xxx&page=2
$mk = function($p) use ($param, $useDynamic) {
    $url = $useDynamic && !empty($param)
         ? 'index.php?' . http_build_query(array_merge($param, ['page' => $p]))
         : getStaticUrl('index_page', 0, $p, $param);
    return htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
};

5.3 rewrite 规则必须能保留 query

Nginx:replacement 结尾不能有 ?(有 ? 表示丢弃原参数)

rewrite ^/index-page-([0-9]+)\.html$ /index.php?page=$1 last;

Apache:加 QSA 显式追加

RewriteRule ^index-page-([0-9]+)\.html$ index.php?page=$1 [L,QSA]

六、SEO 补充:搜索结果页不该被收录

不带 q 的 /index-page-2.html 仍会回落全站,这是正常行为,但这类 URL 不应参与收录。在 head.php 的 canonical 后面加:

<?php if (isset($_GET['q']) && $_GET['q'] !== ''): ?>
<meta name="robots" content="noindex,follow">
<?php endif; ?>

同时建议搜索时把 canonical 固定指向 https://bbs.511yj.com/index.php,避免带 q 的 URL 分散权重。


七、上线验证清单

  • [ ] 上传 sidebar.php 到站点根目录(与 config.php 平级)
  • [ ] index.php / article.php 删除内联的旧函数,改完 3 行调用
  • [ ] functions.php 改拼接条件 + href 转义
  • [ ] 重启 Apache(清 OPcache)
  • [ ] 打开任意文章页,确认「最新文章」与「相关阅读」不再完全一致
  • [ ] 标题区分度低的文章,确认板块标题自动变成「猜你喜欢」
  • [ ] 搜索一个有多页结果的关键词,点第 2 页,确认关键词仍在且高亮提示还在
  • [ ] 检查文章页 article-1.html 与动态 article.php?id=1 表现一致

注意:若侧边栏仍显示「猜你喜欢」而非「相关阅读」,说明相似度全部低于阈值走了随机兜底——这是预期行为,不是 bug。想提升命中率见下一节。


八、进阶建议(可选)

  1. 给 article 表加 tags 字段:标题分词再怎么优化也只是"猜"。同标签匹配的相关性远高于 LIKE 标题,且命中率稳定,是治本方案。sidebar.php 已内置支持,加了字段自动生效:

    ALTER TABLE article ADD COLUMN tags TEXT DEFAULT '';
  2. 数据量大时考虑 FTS5:文章上千后,多个 LIKE '%xx%' 全表扫描会拖慢详情页。SQLite 可用 FTS5 虚拟表 + MATCH 查询替代 LIKE。

  3. 侧边栏加缓存:最新文章与相关阅读变化很慢,可按"当前 ID + 文章总数"做 key,缓存 10~30 分钟,详情页少 3~4 次查询。

  4. 相关阅读加摘要或分类色块:两个板块样式完全一致时,即使有 1~2 条重合也显得违和。加个 60 字摘要或标签色块即可明显区分。


九、改动文件对照表

文件 操作 关键改动
sidebar.php 新建 分词 + Dice 相似度重排 + 兜底排除最新文章
index.php 改 3 行 删内联旧函数、改 $side = getSidebarData()、renderSidebar($side, $db)
article.php 改 3 行 同上,与伪静态版保持一致
functions.php 改 1 行 if(!empty($query)) + href 转义
head.php 加 3 行 搜索页 noindex(可选)

备份提示:修改前请完整备份 index.php、article.php、functions.php,以及数据库文件 site.db。本文所有代码均在本地模拟环境验证通过。