起因是问了一句 EMlog 的插件会不会写,然后就成了这个把文章链接定时推给百度的东西。这篇记详细一点,把关键代码贴出来,免得以后自己还得翻仓库。
还没动手就撞上一个拦路的
百度给的接口调用地址长这样:http://data.zz.baidu.com/urls?site=https://www.binliyou.com&token=xxx,里面的 site 是带 www 的。而站点实际用的是裸域,www 根本没做解析,curl 一试就露了:
https://binliyou.com/ 200
https://www.binliyou.com/ 连不上
麻烦的地方在于,域名不一致时百度不会报错。接口照样返回 200、照样给你 success 字段,只是提交的链接全部落在 not_same_site 数组里,一条都不入队。要是先装上跑一轮,当日配额花了、日志看着还挺正常,回头再查域名就晚了。
所以插件里第一个功能不是推送,是比对:
$want = parse_url($query['site'], PHP_URL_HOST);
if (!$want) {
$want = $query['site'];
}
$mine = parse_url(BLOG_URL, PHP_URL_HOST);
if ($mine && strcasecmp($want, $mine) !== 0) {
return sprintf(
'接口地址里的 site 域名是 %s,站点当前域名是 %s。两者不一致时百度会把链接全部退回 not_same_site,一条都不会入队;请先在资源平台改用站点真实域名的接口地址。',
$want,
$mine
);
}
不一致就在设置页顶上挂黄条,推送按钮点了也不执行。整件事里花的时间最少、价值最大的一处是这里。
接口调用本身没什么花样
POST 纯文本,一行一个 URL,Content-Type: text/plain:
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body, // implode("\n", $urls)
CURLOPT_HTTPHEADER => ['Content-Type: text/plain', 'Expect:'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 30,
]);
成功返回 remain(当天剩余配额)、success(成功条数)、not_same_site、not_valid;失败是 4xx 带 error 和 message。两个处理原则:remain <= 0 立刻停止,配额当日有效不累计,继续打只是浪费;接口报错也停止,不要在下一次重试时把同一批反复推。
链接用 EMlog 自己的 Url::log($gid) 生成,这样站点哪天开了伪静态或改了 URL 模式,推出去的地址跟着变,不用插件操心。
EMlog 插件的机制是翻源码确认的
几条硬约定,名字写错一个就加载不上:
- 入口
baidu_push/baidu_push.php,头部注释块是插件元信息(Plugin Name/Version/Description)。 - 配置读写走
Storage::getInstance('baidu_push'),数据落在storage表。插件名和键名都限 16 个字符、只能[\w_],超了会被静默替换成DefaultPlugin/DefaultName,这个坑够阴的。 - 设置页文件名必须是
baidu_push_setting.php,里面要有plugin_setting_view()和plugin_setting()两个函数,路由是admin/plugin.php?plugin=baidu_push。 - 启用/升级/删除的回调在
baidu_push_callback.php,可选。我用了删除回调,卸载时把配置清掉,因为里面存着百度准入密钥:
function callback_rm()
{
$storage = Storage::getInstance(BaiduPush::PLUGIN);
$storage->deleteAllName('YES');
}
后台那条路由过了 loginAuth::checkLogin() 和角色校验,确认只有管理员能进,才敢把密钥存这儿。
定时怎么触发
EMlog 没有常驻进程,"定时"得借外力。三条路:crontab 直接调 php、crontab curl 一个带密钥的 URL、前台访问时顺带触发。
选了第一条为主、第二条为辅。理由是密钥进 URL 就会进 nginx 访问日志,等于天天在日志里写一遍。第三条没做,因为没人访问就永远不跑,跑起来还拖慢那一次请求。
一个文件里分两种入口:
if (PHP_SAPI === 'cli') {
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['HTTP_USER_AGENT'] = 'emlog-baidu-push-cron';
}
require_once dirname(__DIR__, 2) . '/init.php';
require_once __DIR__ . '/baidu_push_core.php';
// init.php 已经把 Content-Type 设成 text/html,这里必须在其之后覆盖
if (PHP_SAPI !== 'cli') {
header('Content-Type: text/plain; charset=UTF-8');
}
那个注释是踩出来的:EMlog 的 init.php 第二行就 header('Content-Type: text/html'),你要是引导之前就设自己的头,会被它盖掉。CLI 那几行赋值是给 getIp() / getUA() 用的,虽然它们内部有 isset 兜底不会炸,但补上更干净。
后台那几个按钮限了最多五批(BaiduPush::run($mode, 5)),那是同步请求,让一次点击把全站扫完会把请求挂死。完整推送交给 crontab:
30 8 * * * php /app/emlog/content/plugins/baidu_push/run.php increment >> /var/log/emlog-baidu-push.log 2>&1
0 4 * * 0 php /app/emlog/content/plugins/baidu_push/run.php full >> /var/log/emlog-baidu-push.log 2>&1
站点在 Docker 里就得走 docker exec <php容器> php ...。
增量为什么扫 date,不挂钩子
本来想在 save_log 钩子上记一笔"这篇待推",后来改成直接扫数据库:
-- 增量:水位之后发布或更新过的可见文章
SELECT gid, date FROM blog
WHERE type='blog' AND hide='n' AND checked='y' AND date > <水位>
ORDER BY date ASC, gid ASC;
-- 全量:按主键游标分批,可断点续推
SELECT gid FROM blog
WHERE type='blog' AND hide='n' AND checked='y' AND gid > <游标>
ORDER BY gid ASC LIMIT <批大小>;
关键前提是 EMlog 更新文章时会刷新 blog.date,所以"改过的"和"新增的"都会被这条查询带出来,不需要额外记账。好处是插件在正常请求链路上一点开销都不占——入口文件除了 require_once 什么都不做,不挂任何前台钩子。
草稿和待审核的推不出去,靠 hide='n' AND checked='y' 挡。
人工读代码抓出的一个真 bug
游标推进原来是这么写的,在拼 URL 的循环里顺手往前推:
foreach ($rows as $row) {
$cursor = $row['gid']; // 错:还没推就先走了
$urls[] = Url::log($row['gid']);
}
self::pushBatch($api, $urls, $stat);
这样一批推送失败、百度没收下,游标照样走过了那批 gid,那些链接就永久漏推了,除非手动把游标归零重扫。改成先记在局部变量里,确认这批真送出去了才推进:
$lastGid = $cursor;
foreach ($rows as $row) {
$urls[] = Url::log($row['gid']);
$lastGid = $row['gid'];
}
$before = $stat['batches'];
self::pushBatch($api, $urls, $stat);
// 只有这一批真正送出去了才推进游标,失败的那批下次仍会重试
if ($stat['batches'] > $before) {
$cursor = $lastGid;
}
增量水位同理。这类问题不在任何报错里,跑起来一切正常,只有读代码能看见——写完通读一遍这个动作,值。
并发就一个 flock
$file = sys_get_temp_dir() . '/emlog_baidu_push.lock';
$fp = @fopen($file, 'c');
if (!flock($fp, LOCK_EX | LOCK_NB)) {
$age = time() - (int)@filemtime($file);
fclose($fp);
return $age > self::LOCK_TTL ? self::forceLock($file) : false;
}
@touch($file);
拿不到锁就这次跳过并记录原因,不排队不重试。锁文件超过三十分钟视为异常残留会尝试强制接管,但接管用的还是非阻塞 flock,原来那个进程活着的时候抢不到,所以最坏情况只是多跳过一次,不会出现两个任务同时往百度推。
打包传后台也有坑
后台「安装插件」要求上传 zip,解压前的校验逻辑是这样的:
$r = explode('/', $zip->getNameIndex(0), 2);
$dir = isset($r[0]) ? $r[0] . '/' : '';
// type == 'plugin' 时
$plugin_name = substr($dir, 0, -1);
$re = $zip->getFromName($dir . $plugin_name . '.php');
if (false === $re) {
return -1; // 前端提示"插件包格式不正确"
}
它拿 zip 第一个条目的顶层目录名当插件名,再要求那个目录下有同名 php。所以文件平铺在压缩包根目录会直接失败,必须是一层目录包着。
另一个坑在打包工具:Windows PowerShell 5.1 的 Compress-Archive 会把条目名写成反斜杠(baidu_push\baidu_push.php),上面那句 explode('/') 就切不出目录了,报错还只告诉你"格式不正确"。改用 bsdtar:
C:\Windows\System32\tar.exe -a -cf baidu_push.zip baidu_push
打完用 tar -tf 回读确认条目是正斜杠:
baidu_push/
baidu_push/baidu_push.php
baidu_push/baidu_push_core.php
baidu_push/baidu_push_setting.php
baidu_push/baidu_push_callback.php
baidu_push/run.php
baidu_push/README.md