帝国CMS SEO插件合集:TDK、伪静态、结构化数据与站点地图一次装完
简介:面向帝国CMS站点管理员与SEO从业者的免费插件合集,围绕内容采集、原创化处理与站内优化展开,适合希望以较低成本完善网站SEO流程、减少重复手工操作的建站人员。合集涵盖全自动采集、伪原创发布、自动内链、关键词插入和自动配图等能力,可分别用于内容获取、降低重复度、串联站内页面、控制关键词布局以及提升图文体验,形成从内容生产到页面优化的完整链路。压缩包约312.51MB,文件总数显示为0,类型明细暂无数据;从描述可知其中包含插件运行所需的依赖组件与SEO工具执行程序,便于在本地环境中部署和调用相关功能。已有275人浏览学习,可作为帝国CMS插件配置与SEO功能整合的参考。读者可据此了解各插件模块的分工、依赖环境组成及工具调用关系,快速搭建采集、伪原创、内链与配图等功能的测试环境,并在此基础上按站点需求筛选组合,节省逐项寻找插件与排查运行依赖的时间。
1. 花三个晚上手改 800 条 TDK 之后,我开始找一套能一次装完的 SEO 插件
一个站刚迁到帝国CMS,栏目 30 多个,历史文章 800 多条,后台一打开就傻眼:keyboard全是空的,smalltext全是复制正文前 200 字,内容页的 title 用的是[!--pagetitle--],也就是全站共用一个站点名。手工改到第 60 条的时候我放弃了,开始翻这套免费帝国CMS插件合集——它把伪静态、TDK 批量生成、结构化数据、内链替换、图片 alt 补齐、站点地图、URL 主动推送、静态化刷新塞进一个扩展目录,后台挂一个菜单进去。这套东西适合谁:手里已经有帝国CMS站点、有数据库权限、能改模板的从业者。不适合谁:指望上传一个 zip 就自动收录的。免费帝国CMS插件不等于零成本,成本全在适配你的表结构和模板命名上,这篇就按我自己装一遍的顺序拆。
2. 插件到底落在哪:帝国CMS 的模板层、插件目录与计划任务三层拆解
拿到一个插件压缩包,先别急着覆盖文件。我一般先把它解压出来的目录结构对着站点目录走一遍,看清楚它往哪几个地方伸手。帝国CMS的扩展能力说实话不弱,但入口分散,插件作者的做法也各不相同,不了解落点,出问题就只能靠猜。
2.1 三层插入点分别管什么
第一层是模板层。帝国CMS的模板不是纯 HTML,是被编译成 PHP 再执行的,模板里能写灵动标签[e:loop={...}]、万能标签[ecmsinfo]...[/ecmsinfo],也能在必要处嵌 PHP。这一层决定的是「页面上看得到什么」:title、description、canonical、JSON-LD、面包屑、图片 alt,全部在模板里出结果。绝大多数所谓 SEO 插件,核心工作量其实是在这一层做模板改造,插件目录只是提供了一个统一的取值函数。
第二层是扩展目录。帝国CMS的插件一般放在e/extend/下面,一个插件一个子目录,通过后台的扩展菜单或者模板里的require引入。这一层负责计算逻辑:怎么从title拼出一个不重复的页面标题、怎么在正文里做关键词替换、怎么遍历数据表生成 sitemap。它不直接面向用户,改错了前台也不报错,这是最容易被忽略的地方。
第三层是计划任务。帝国CMS后台有计划任务功能,能定时执行一段 PHP。SEO 相关的定时活儿就三类:刷新静态页、生成 sitemap、把待推送的 URL 批量提交出去。这一层是「没人看着也得跑」的部分,也是最容易悄悄失败的部分——任务失败了后台不会弹窗,你只会发现半个月后收录没动静。
三层的关系要先想明白:模板层出结果,扩展目录出数据,计划任务保时效。很多人装完插件发现「没效果」,八成是只装了第二层,模板没改,或者只改了模板,定时任务没挂。
2.2 和 SEO 直接相关的几张表与字段
改模板之前,先看清楚数据从哪来。帝国CMS的表前缀默认是phome_,装站时可能改过,先在配置文件里确认一遍。下面这几张表是我每次都要过一眼的。
| 表名 | 关键字段 | 对 SEO 的实际影响 |
|---|---|---|
phome_ecms_news | id、classid、title、keyboard、smalltext、titlepic、newstime、onclick | 内容页 title、description、关键词、列表页摘要的唯一来源 |
phome_ecms_news_data_1 | id、newstext | 正文所在表,内链替换、图片 alt 补齐都在这里动手 |
phome_enewsclass | classid、classname、bname、filename、classpath、islist、tbname | 栏目 URL、面包屑层级、伪静态路径都由它决定 |
phome_enewstags | tagid、tagname、num | TAG 聚合页是否值得留、有没有内容可聚合 |
有两个细节必须提前确认,不然脚本会跑出乱七八糟的结果。
一个是副表分表。newstext不在主表里,而在_data_1、_data_2这样的副表里,具体分了几张、某条数据落在哪张,取决于建站时的设置。写内链替换脚本时,图省事直接UPDATE phome_ecms_news_data_1会漏掉一部分文章。常见做法是先SELECT id FROM phome_ecms_news拿到主键,再按id去对应副表更新,或者干脆用帝国CMS自带的取文接口逐条读。
另一个是smalltext。这个字段在后台叫「简介」,很多站从别的 CMS 迁移过来时被整段塞了正文开头,长度不受控,直接输出到meta description里会被截断成半句话。要么在入库时截断,要么在模板输出时截断,二选一,别两处都做。
-- 先摸清楚数据底子,再决定改哪里 -- 1) 有多少条文章压根没有关键词 SELECT COUNT(*) AS no_keyboard FROM phome_ecms_news WHERE keyboard = '' OR keyboard IS NULL; -- 2) 简介长度超过 120 字的有多少条(超出部分前端会被截断) SELECT COUNT(*) AS long_smalltext FROM phome_ecms_news WHERE CHAR_LENGTH(smalltext) > 120; -- 3) 同一栏目下标题重复的情况,重复标题是收录内耗的重灾区 SELECT classid, title, COUNT(*) AS c FROM phome_ecms_news GROUP BY classid, title HAVING c > 1 ORDER BY c DESC LIMIT 20; -- 4) 确认副表到底有几张,别只更新 _data_1 SHOW TABLES LIKE 'phome_ecms_news_data_%';这几条 SQL 的意义在于「先量化再动手」。第一条告诉你需要补关键词的量级,第二条决定 description 是走截断还是走重写,第三条查出来的重复标题要先人工合并,因为后面批量生成标题的逻辑对重复标题无能为力——两条一模一样的标题,怎么拼装还是撞车。第四条是很多人栽跟头的地方,副表数量不对,改完发现只有一部分文章生效。
参数上没什么可调的,把phome_换成你自己的前缀就行。CHAR_LENGTH和LENGTH的区别要注意:中文在 UTF-8 下LENGTH是字节数,一个字算三个,判断中文长度必须用CHAR_LENGTH。
2.3 装之前先做三件事:备份、影子站、关缓存
插件要动模板文件和数据库,直接在生产站上试,翻车了恢复起来很难看。我现在的固定流程是三件事。
第一件,全量备份,数据库和站点目录都要,而且备份文件要落到另一台机器上。只mysqldump不备份模板,等于只买了一半的保险,因为插件改模板改得比改库狠。
#!/bin/bash # 上线前备份:库 + 站点目录,落到独立备份盘 set -e SITE_DIR="/www/wwwroot/example.com" DB_NAME="example_db" DB_USER="dbuser" DB_PASS="dbpass" STAMP=$(date +%Y%m%d_%H%M) BAK_DIR="/data/backup/${STAMP}" mkdir -p "${BAK_DIR}" # 1) 数据库:单事务导出,避免 InnoDB 表出现半截数据 mysqldump -u"${DB_USER}" -p"${DB_PASS}" \ --single-transaction --quick --default-character-set=utf8mb4 \ "${DB_NAME}" | gzip > "${BAK_DIR}/db.sql.gz" # 2) 站点目录:模板和插件都在里面,排除缓存和上传附件省空间 tar -czf "${BAK_DIR}/site.tar.gz" \ --exclude="${SITE_DIR}/d/file" \ --exclude="${SITE_DIR}/e/data/cache" \ -C "$(dirname "${SITE_DIR}")" "$(basename "${SITE_DIR}")" # 3) 记录当前模板文件的校验值,改完能一眼看出动了哪些 find "${SITE_DIR}/e/data/html" -type f -name "*.html" -exec md5sum {} + \ > "${BAK_DIR}/template.md5" echo "backup done: ${BAK_DIR}"脚本里几个选择说明一下。--single-transaction是给 InnoDB 用的,导出期间不锁表,线上站不至于卡住;如果站点还有 MyISAM 表,加上它意义不大,但也不会有害。--quick让 mysqldump 逐行取数据,大表不会一次性把内存吃满。排除d/file(附件目录)和e/data/cache(缓存目录)是为了让备份包小一点,附件另做增量同步更合适。最后那句md5sum是我自己的习惯,插件覆盖模板之后,拿这份清单md5sum -c跑一遍,改了哪些文件清清楚楚,回滚时不用凭记忆。
第二件,搭影子站。把生产库导一份到测试库,站点目录复制一份,绑个临时域名或者改 hosts。伪静态规则、模板改动、计划任务,全部先在影子站上跑通。这一步花半小时,能省掉后面半天的救火。
第三件,关缓存。帝国CMS有数据缓存和模板缓存,插件改完模板不生效,十有八九是缓存没清。后台的数据更新里可以清,也可以直接删e/data/cache下的缓存文件。
提示:影子站一定要用独立的数据库名和独立的缓存目录,两站共用一个库调试,改坏了生产数据一起没。
3. 伪静态与 URL 结构:规则错一条,后面全站白改
URL 是后面所有工作的地基。TDK 写得再好,URL 里带一串问号参数、同一个内容有四个地址能打开,权重全被自己稀释掉了。这一步做完再谈模板,顺序不能反。
3.1 URL 分层设计:栏目、内容、TAG 各留什么位置
帝国CMS的动态地址形式是/e/action/ListInfo/?classid=1和/e/action/ShowInfo/?classid=1&id=2,直接暴露出来既难看也不好记。伪静态的目标是把它们映射成有层级感的静态形式。
我一般用这套结构:
| 页面类型 | 目标 URL 形式 | 说明 |
|---|---|---|
| 栏目列表首页 | /news/ | 一级栏目用短目录,别带list之类的前缀 |
| 栏目列表分页 | /news/list_2.html | 分页从 2 开始,第一页不出现页码 |
| 内容页 | /news/202405/1234.html | 月份目录 + 文章 ID,避免纯 ID 被遍历 |
| TAG 聚合页 | /tag/关键词.html | 只给num大于阈值的 TAG 开页 |
| 专题页 | /zhuanti/名称/ | 独立目录,和栏目分开 |
内容页用「月份 + ID」而不是「拼音标题」,理由很实在:帝国CMS的标题改起来太随意,编辑改一次标题,拼音 URL 就失效一条,得配 301,配漏了就是死链。用 ID 稳定,标题随便改。至于 ID 不连续暴露发布量,这个在意的话就在生成时加个偏移量,但要在伪静态规则里同步,别只改一边。
TAG 页要克制。帝国CMS的 TAG 是自动积累的,跑一段时间能攒出几千个只有一篇文章的 TAG 页,这些页面内容单薄、互相重复,收进去只会拖后腿。常见做法是在phome_enewstags上加个判断,num小于 3 的不生成页面、不加进 sitemap,或者干脆robots里挡掉/tag/整个目录。
3.2 nginx 与 Apache 两套重写规则
帝国CMS后台有一套「伪静态页面设置」,里面填的是「目标 URL 形式」,真正生效的还得是 Web 服务器上的重写规则。两边必须严格对应,差一个参数顺序就全 404。
nginx 下的规则:
# 帝国CMS伪静态:放到 server { } 块内,location / 之前 # 栏目列表分页:/news/list_2.html -> 动态列表接口 rewrite ^/([a-z0-9_-]+)/list_([0-9]+)\.html$ /e/action/ListInfo/?classid=$1&page=$2 last; # 栏目首页:/news/ -> 列表第一页 rewrite ^/([a-z0-9_-]+)/$ /e/action/ListInfo/?classid=$1 last; # 内容页:/news/202405/1234.html -> 动态内容接口 rewrite ^/([a-z0-9_-]+)/([0-9]{6})/([0-9]+)\.html$ /e/action/ShowInfo/?classid=$1&id=$3 last; # TAG 聚合页 rewrite ^/tag/(.+)\.html$ /e/tags/?tagname=$1 last; # 真实存在的静态文件和目录优先,别被上面的规则抢走 location ~* \.(gif|jpg|jpeg|png|webp|css|js|woff2)$ { expires 30d; access_log off; }Apache 下等价的一套,写进.htaccess:
<IfModule mod_rewrite.c> RewriteEngine On RewriteBase / # 已经存在的文件和目录直接返回,避免重写死循环 RewriteCond %{REQUEST_FILENAME} -f [OR] RewriteCond %{REQUEST_FILENAME} -d RewriteRule ^ - [L] # 列表分页 RewriteRule ^([a-z0-9_-]+)/list_([0-9]+)\.html$ e/action/ListInfo/?classid=$1&page=$2 [L,QSA] # 栏目首页 RewriteRule ^([a-z0-9_-]+)/$ e/action/ListInfo/?classid=$1 [L,QSA] # 内容页 RewriteRule ^([a-z0-9_-]+)/([0-9]{6})/([0-9]+)\.html$ e/action/ShowInfo/?classid=$1&id=$3 [L,QSA] </IfModule>几个容易出问题的地方。
classid在 URL 里用的是栏目 ID 还是栏目别名,取决于后台「栏目管理」里怎么设的。上面这套规则假设 URL 段就是classid数字;如果你用的是bname别名,规则里得先查一次库把别名换成 ID,性能和复杂度都上去了,我一般不用。
last和break的区别:last会重新走一遍 location 匹配,break不会。帝国CMS的动态入口在e/action/下,用last让请求重新进 PHP 处理,break在这里会得到 404。
Apache 的QSA标志不能省,它把原始查询串保留下来,翻页参数page靠它传。少了它,翻页永远停在第一页,这种问题在日志里看不出来,只有真去点分页才发现。
规则写完先nginx -t或者apachectl configtest校验语法,再 reload。直接 reload 一个语法错误的配置,站点当场躺下。
3.3 模板里的链接同步与整站状态码验证
重写规则只是让「旧地址能访问新形式」,页面里输出的链接还得跟着改。帝国CMS的模板里,栏目链接一般来自[!---classurl--]之类的标签或者面包屑标签[!--newsnav--],这些是跟着后台设置走的,规则改完通常自动就对。真正要手动查的是硬编码的链接——列表模板里写死的/e/action/ListInfo/?classid=5、导航里手写的/about/,这些不会自己变。
同步完之后必须验证,而且是全量验证,不是点几个链接看看。
#!/bin/bash # 从 sitemap 里取出全部 URL,批量看状态码 SITEMAP="https://example.com/sitemap.xml" OUT="/tmp/status.tsv" # 抽 URL,逐个请求,只取状态码 grep -oP '(?<=<loc>)[^<]+' "${SITEMAP}" \ | while read -r url; do code=$(curl -s -o /dev/null -w '%{http_code}' -A 'Mozilla/5.0' --max-time 10 "${url}") printf '%s\t%s\n' "${code}" "${url}" done > "${OUT}" # 汇总:非 200 的全部列出来 awk -F'\t' '$1 != 200' "${OUT}" | sort | head -50 echo "---" awk -F'\t' '{print $1}' "${OUT}" | sort | uniq -c | sort -rn这段脚本的价值在于「一次看全」。手工点 20 个链接发现都正常,不代表第 300 个正常——分页到第 47 页、某个特殊栏目、某篇带附件的文章,问题往往藏在这些边角。--max-time 10是防止某个地址卡住把整个循环拖死;-A带一个正常 UA,有些站会对空 UA 直接返回 403,测出来全是假的非 200。
结果里如果出现 301,说明还有旧的动态地址没换干净,找出来改掉;出现 404,先看是规则没覆盖还是数据本身不存在;出现 500,多半是重写把请求打到了一个不存在的 PHP 入口,回头核对e/action/的路径。
注意:验证要在静态化关闭的状态下先做一遍。静态页会掩盖伪静态的问题,等静态页生成好了再测,测的是静态文件,不是规则。
4. 模板层批量改造:TDK、JSON-LD、内链与站点地图一次覆盖
地基铺完,开始改页面本身。这一章是整套插件里最花时间的部分,也是效果最直接的部分。原则只有一条:能在模板里一次算出来的,绝不留到手工。
4.1 标题拼装:别让 800 条页面共用一个 title
帝国CMS里和标题相关的变量有好几个,混淆是常态。[!--pagetitle--]是页面标题,内容页默认取站点名;[!--title--]才是文章标题;[!--classname--]是当前栏目名。把内容模板的<title>写成[!--pagetitle--],结果就是 800 个页面全站同名,这在收录里是硬伤。
我用的拼装规则是三层:文章标题 + 差异化后缀 + 站点名,后缀按栏目轮换。
<!-- 内容页模板头部:三层拼装,后缀按栏目 ID 取模轮换 --> <title>[!--title--] - [e:loop={'select classname from phome_enewsclass where classid=".$GLOBALS['navclassid']."',1,0,0,'',''}]<?=$bqr['classname']?>[/e:loop] - 站点名</title> <meta name="keywords" content="[!--keyboard--]"> <meta name="description" content="[!--smalltext--]">这段写法里,[!--title--]保证唯一性,栏目名保证相关性,站点名放最后。真正的差异化其实靠的是文章标题本身,所以前面那条「查重复标题」的 SQL 必须先跑完,重复的合并掉,否则拼出来还是撞。
列表页的标题要单独处理,分页必须带页码,否则第 2 页和第 1 页标题一样。
<!-- 列表模板:首页与分页用不同标题,避免分页重复 --> <title> [e:loop={'select 1',1,0,0,'',''}] <?php $page = (int)($_GET['page'] ?? 1); echo $page > 1 ? "栏目名 - 第{$page}页 - 站点名" : "栏目名 - 站点名"; ?> [/e:loop] </title>参数说明:页码从查询串里取,做整型转换防注入;首页不加页码,是因为首页要作为栏目主入口,加个「第1页」既难看又和分页地址重复。判断放在模板里做,比在插件里做省事,因为分页参数本来就只在列表页存在。
如果栏目有几十个,硬编码栏目名不现实,把「栏目名」换成[!--classname--]或者从phome_enewsclass里按classid查一次。帝国CMS的模板编译成 PHP 之后可以正常跑查询,但要注意别在循环里查库——列表页一次查一次就够,循环内查库是拖慢整站最快的方式。
4.2 description 生成与长度截断
smalltext的问题前面提过:长度不受控。我在插件里加了一个统一出口,模板只调这个出口,不在模板里写截断逻辑,这样以后改规则只改一处。
<?php /** * 生成 description:优先用简介,空则截正文,统一长度 * 放在 e/extend/seo_tools/helper.php,模板里 require 后调用 */ function seo_description(string $smalltext, string $newstext, int $limit = 110): string { // 1) 去掉 HTML 标签和多余空白,编辑器带出来的 <p> 不能进 meta $text = strip_tags($smalltext !== '' ? $smalltext : $newstext); $text = preg_replace('/[\s\x{3000}]+/u', ' ', $text); $text = trim($text); // 2) 空内容兜底,别输出空的 description if ($text === '') { return '这里填栏目或站点的一句话说明'; } // 3) 按字符截断,不是按字节;中文一个字算一个 if (mb_strlen($text, 'UTF-8') > $limit) { $text = mb_substr($text, 0, $limit, 'UTF-8'); // 4) 不要切在半句话上,回退到最后一个标点 $pos = max( (int)mb_strrpos($text, ',', 'UTF-8'), (int)mb_strrpos($text, '。', 'UTF-8'), (int)mb_strrpos($text, '、', 'UTF-8') ); if ($pos > $limit * 0.6) { $text = mb_substr($text, 0, $pos, 'UTF-8'); } $text .= '…'; } // 5) 引号要转义,否则 meta 标签会被截断 return htmlspecialchars($text, ENT_QUOTES, 'UTF-8'); }逐段说。第一步strip_tags是必须的,编辑器里粘进来的内容带<p><img>,直接塞进content=""里会把标签属性撑破。第二步的空格处理里,\x{3000}是全角空格,中文站里特别常见,只处理半角空格是不够的。
第三步用mb_substr而不是substr,这是最容易翻车的地方:substr按字节切,一个中文字三个字节,切在第 110 个字节上会切出半个字,输出成乱码。第四步回退到标点,是为了不让 description 结尾是半句话,$limit * 0.6这个阈值是防止回退太狠——如果最后一个逗号出现在第 10 个字,回退过去就只剩一句话了,那不如直接硬截断。
第五步的htmlspecialchars经常被漏掉。文章简介里出现英文双引号,输出到<meta content="...">里就把属性闭合了,后面的内容跑到标签外面,页面结构直接坏掉。
模板里调用就一行:
<meta name="description" content="<?=seo_description('[!--smalltext--]', '', 110)?>">长度我给 110 个字符,这是留了余量的做法——不同搜索引擎展示宽度不一样,中文按 1 个字算,110 字差不多是两行到三行的量。想更保守就压到 80。
4.3 JSON-LD 与 canonical 插在哪一行
结构化数据这块,帝国CMS没有原生支持,只能自己在模板里拼。我用的是 JSON-LD 形式,放在<head>里,比微数据好维护。
<!-- 内容页 head 内:Article + BreadcrumbList 两段结构化数据 --> <script type="application/ld+json"> { "@context": "https://schema.org", "@type": "Article", "headline": "[!--title--]", "datePublished": "[!--newstime--]", "dateModified": "[!--lastdotime--]", "image": "[!--titlepic--]", "author": { "@type": "Organization", "name": "站点名" }, "publisher": { "@type": "Organization", "name": "站点名", "logo": { "@type": "ImageObject", "url": "https://example.com/logo.png" } }, "mainEntityOfPage": { "@type": "WebPage", "@id": "当前页规范地址" } } </script>参数上有几个必须对上的地方。datePublished和dateModified要用 ISO 8601 格式,帝国CMS的[!--newstime--]默认输出的是Y-m-d H:i:s,中间带空格,严格校验会报错,需要替换成T,或者在插件里统一格式化好再输出。image必须是绝对地址,相对路径会被判定无效。author用组织而不是个人,是因为大部分站的内容是团队产出,硬写一个人名反而不实。
canonical 的位置更讲究,它必须出现在<head>里,而且要指向「这一篇的唯一正确地址」。
<!-- 内容页:canonical 指向不带任何查询参数的规范地址 --> <link rel="canonical" href="https://example.com/[!--classpath--]/[!--id--].html"> <!-- 列表页分页:第 2 页以后 canonical 指向自己,不要全指第一页 --> <link rel="canonical" href="https://example.com/news/list_[!--page--].html">列表页分页的 canonical 有个常见误用:把第 2 页到第 20 页全部 canonical 到第 1 页。理由是「避免重复内容」,结果是把后面 19 页的内容全部否定了,收录自然上不去。正确的做法是分页各自 canonical 到自己。
内容页的 canonical 要特别注意带参数的访问。帝国CMS内容页可能通过?classid=1&id=2这种动态地址访问,也可能是伪静态地址,还可能有?from=xxx之类的来源参数。canonical 的作用就是告诉搜索引擎「这些都算同一个页面,认这一个」。所以 canonical 里绝对不能出现查询串。
提示:JSON-LD 里的地址和 canonical 里的地址必须一致,两处不一致时,搜索引擎通常以 canonical 为准,结构化数据就被浪费了。
4.4 内链自动替换和图片 alt 补齐
内链这块,插件通常提供一个关键词到 URL 的映射表,然后在保存文章或生成静态页时做正文替换。这里有个必须接受的前提:自动内链一定会出错,所以只做「第一次出现」,每篇最多替换 2 到 3 个词。
<?php /** * 正文内链替换:每个关键词只替换首次出现,且跳过已有链接 * $map 形如 ['关键词' => '/news/123.html', ...] */ function seo_inner_link(string $html, array $map, int $maxLinks = 3): string { $done = 0; foreach ($map as $word => $url) { if ($done >= $maxLinks) { break; } // 1) 先把已有的 <a> 整段挖掉,避免链接套链接 $parts = preg_split('/(<a\b[^>]*>.*?<\/a>)/is', $html, -1, PREG_SPLIT_DELIM_CAPTURE); foreach ($parts as $i => $seg) { // 2) 偶数下标是普通文本,奇数下标是 <a> 段,只在文本段里替换 if ($i % 2 === 1) { continue; } // 3) 跳过 <script> / <style> / 标签属性内部 if (preg_match('/<(script|style)\b/i', $seg)) { continue; } $pos = mb_strpos($seg, $word, 0, 'UTF-8'); if ($pos === false) { continue; } $link = '<a href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '">' . htmlspecialchars($word, ENT_QUOTES, 'UTF-8') . '</a>'; $parts[$i] = mb_substr($seg, 0, $pos, 'UTF-8') . $link . mb_substr($seg, $pos + mb_strlen($word, 'UTF-8'), null, 'UTF-8'); $done++; break; } $html = implode('', $parts); } return $html; }逻辑说明:preg_split带PREG_SPLIT_DELIM_CAPTURE把字符串切成「文本段 + 链接段 + 文本段」交替的数组,偶数下标一定是文本,奇数下标一定是已有的<a>。只在偶数下标里做替换,就天然避开了链接套链接。这是最省事也最稳的做法,比写复杂的负向断言正则可维护得多。
$maxLinks限制每篇 3 条,是因为内链密度过高会被当成作弊,而且读起来也难受。关键词按长度从长到短排一遍再传进来,能避免「数据分析」被「数据」先吃掉。
图片 alt 补齐简单一些,在内容保存时统一处理:
<?php // 给正文中缺 alt 的图片补上「文章标题 + 序号」 function seo_fill_img_alt(string $html, string $title): string { $i = 0; return preg_replace_callback( '/<img\b([^>]*)>/i', function ($m) use ($title, &$i) { $attr = $m[1]; $i++; // 已有非空 alt 的不动,尊重编辑的手工设置 if (preg_match('/\balt\s*=\s*["\'][^"\']+["\']/i', $attr)) { return $m[0]; } $alt = htmlspecialchars($title . ' 配图' . $i, ENT_QUOTES, 'UTF-8'); // 去掉可能存在的空 alt,再补新的 $attr = preg_replace('/\balt\s*=\s*["\']["\']/i', '', $attr); return '<img' . $attr . ' alt="' . $alt . '">'; }, $html ); }已有的 alt 不能覆盖,这点很重要。编辑手工写的 alt 通常比「标题 + 配图1」有意义得多,插件无脑覆盖等于把有用的信息洗掉了。$i用引用传进去,保证同一篇文章里的序号递增,不会出现五个「配图1」。
4.5 sitemap 与主动推送:把生成端和提交端分开
站点地图不要用爬虫去抓整站,那样一定会把登录页、搜索页、分页参数无穷的地址全收进去。正确做法是直接从数据表出。
<?php /** * 生成 sitemap.xml:只从数据表出 URL,白名单控制 * 放到 e/extend/seo_tools/sitemap.php,由计划任务调用 */ require('../class/connect.php'); require('../class/db_sql.php'); $link = db_connect(); $empire = new mysqlquery(); $host = 'https://example.com'; $limit = 30000; // 单文件上限,别超过 5 万 $out = []; // 1) 栏目页:只要开启列表且没被隐藏的栏目 $rows = $empire->query("SELECT classid, classpath FROM phome_enewsclass WHERE islist = 1 AND ishide = 0 ORDER BY classid"); while ($r = $empire->fetch($rows)) { $out[] = ['loc' => $host . '/' . trim($r['classpath'], '/') . '/', 'pri' => '0.8']; } // 2) 内容页:只取已审核的,按更新时间倒序,超量的截断 $rows = $empire->query("SELECT id, classpath, newstime FROM phome_ecms_news WHERE checked = 1 ORDER BY newstime DESC LIMIT {$limit}"); while ($r = $empire->fetch($rows)) { $out[] = [ 'loc' => $host . '/' . trim($r['classpath'], '/') . '/' . $r['id'] . '.html', 'pri' => '0.6', 'mod' => date('c', (int)$r['newstime']), // ISO 8601,lastmod 必须用这个格式 ]; } // 3) 输出,注意 XML 转义 header('Content-Type: application/xml; charset=utf-8'); echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n"; echo '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n"; foreach ($out as $u) { echo " <url>\n"; echo ' <loc>' . htmlspecialchars($u['loc'], ENT_XML1) . "</loc>\n"; if (!empty($u['mod'])) { echo ' <lastmod>' . $u['mod'] . "</lastmod>\n"; } echo ' <priority>' . $u['pri'] . "</priority>\n"; echo " </url>\n"; } echo '</urlset>'; $empire->close();几个关键参数。$limit设 30000 是为了留余量,站点地图协议单个文件上限是 5 万条、未压缩不超过 50MB,压到 3 万条比较安全,超了就拆成索引文件。checked = 1这个条件不能漏,帝国CMS里未审核的信息在后台是草稿状态,前台访问不到,写进 sitemap 就是一堆 404。lastmod用date('c')输出 ISO 8601,格式错了搜索引擎会直接忽略这个字段。
主动推送和生成 sitemap 是两件事,要拆开做。生成端负责「有哪些 URL」,提交端负责「告诉搜索引擎」。中间加一张队列表,好处是失败了能重试,不会因为一次接口超时把当天的推送全丢了。
-- 推送队列:记录每条 URL 的提交状态,失败可重试 CREATE TABLE IF NOT EXISTS `phome_seo_push` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `url` varchar(255) NOT NULL COMMENT '待提交的绝对地址', `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '0待提交 1成功 2失败', `retry` tinyint(3) NOT NULL DEFAULT 0 COMMENT '重试次数', `addtime` int(10) unsigned NOT NULL DEFAULT 0, `lasttime` int(10) unsigned NOT NULL DEFAULT 0, PRIMARY KEY (`id`), UNIQUE KEY `uk_url` (`url`), KEY `idx_status` (`status`,`retry`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;表设计里uk_url唯一索引是关键,同一篇文章改三次标题不会推三遍。idx_status是给「捞待提交任务」用的,WHERE status = 0 AND retry < 3走这个索引,队列几万条也不慢。
推送脚本按批取,一次 50 条左右,失败的回写status = 2并把retry加一,重试 3 次还失败的标成人工处理,不要无限重试——接口持续报错的时候无限重试只会把队列越堵越死。接口地址和密钥从配置里读,别写死在脚本里。
# 计划任务里这样挂:先生成,再推送,最后刷新静态页 # 每天凌晨低峰跑,避免和编辑发布撞在一起 10 3 * * * /usr/bin/php /www/wwwroot/example.com/e/extend/seo_tools/sitemap.php > /dev/null 2>&1 20 3 * * * /usr/bin/php /www/wwwroot/example.com/e/extend/seo_tools/push.php >> /var/log/seo_push.log 2>&1 30 3 * * * /usr/bin/php /www/wwwroot/example.com/e/extend/seo_tools/refresh.php >> /var/log/seo_refresh.log 2>&1三个任务错开 10 分钟,是给前一个跑完留时间。sitemap 那条输出丢到/dev/null,因为它是直接回显 XML 的脚本,跑计划任务时输出没意义;后两条重定向到日志文件,出问题时能翻。refresh.php负责刷新静态页,顺序是先内容页、再列表页、最后首页,反过来的话首页会链到还没刷新的旧列表。
5. 五个翻车现场:从 404 洪水到 sitemap 里全是登录页
这套东西我前后装过好几个站,翻来覆去踩的就是那几个坑。按「现象 → 原因 → 解决」记下来,装的时候照着对一遍。
5.1 列表页正常,内容页全部 404
现象:伪静态改完,栏目页/news/能打开,翻页也正常,唯独内容页/news/202405/1234.html全是 404。
原因:后台「伪静态页面设置」里的内容页格式和 nginx 规则对不上。最常见的是后台配的是/news/2024/1234.html(四位年份),nginx 规则里写的是[0-9]{6}(六位年月),正则压根不匹配;其次是参数顺序反了,规则里classid在前id在后,而后台生成的是id在前。
解决:先把后台的伪静态格式贴出来,和 nginx 规则一个字一个字对,用在线正则工具把实际 URL 丢进去测匹配。确认之后nginx -t再 reload。还有一招更快的定位方式:直接访问动态地址/e/action/ShowInfo/?classid=1&id=1234,如果能打开说明数据没问题,纯粹是重写没匹配上。
5.2 sitemap 里混进了登录页和搜索页
现象:sitemap.xml 打开一看,里面有/e/member/login/、/e/search/?key=xxx、还有一堆带?page=和&from=的参数地址。
原因:生成脚本不是从数据表出的,而是拿爬虫抓了一遍整站,或者直接扫了目录。功能页、搜索结果页、带参数的重复地址全被当成正常页面收进去了。
解决:把生成逻辑改成只从phome_ecms_news和phome_enewsclass出 URL,功能目录一律不进。已经生成错的,重新跑一遍覆盖掉,同时去站长平台把明显无效的地址提交删除。另外 robots.txt 里把/e/整个目录挡掉,双保险。
5.3 批量内链之后正文排版乱了
现象:改完内链,部分文章正文的链接套着链接,点进去跳到别的页面,还有一些段落的加粗、斜体失效了。
原因:替换用的是简单的字符串替换或者str_replace,没有排除已有的<a>标签,也没排除<img alt="...">属性里的文字。关键词刚好出现在图片 alt 里,替换进去就把标签属性撑破了。
解决:换成 4.4 里那段按<a>分段处理的方式,只在纯文本段替换。已经改坏的文章,从备份的newstext恢复,或者写个脚本把<a>嵌套的层剥掉——嵌套的<a>在浏览器里会被自动拆开,恢复起来不算难,但量大就很痛苦。所以替换脚本上生产之前,先拿 20 篇不同类型的文章跑一遍,人工看。
5.4 新文章发布了,前台还是旧的
现象:编辑发了一篇新文章,后台能看到,前台列表和首页都没有,手动去「数据更新」里刷新一下才出来。
原因:计划任务
本文还有配套的精品资源,点击获取