fix: 联想建议词改成「统一来源 + 自动兜底」,任何引擎都能出词

问题:除百度/360 外,其它引擎的联想下拉基本是空的。

实测(2026-09-28 在站点服务器上逐个探测):
  百度 sugrec        200 / 0.12s / 1137B  ✅ 最快最稳
  360 sug.so.360.cn  200 / 0.20s /  714B  ✅
  必应 api.bing.com  200 / 0.5~1.6s      ✅ 可用但明显慢
  搜狗 ajajjson      200 / **0 字节**     ❌ 联想域名 w.sugg.sogou.com 已不解析
  谷歌 suggestqueries  连接超时            ❌ 国内不可达
  DuckDuckGo          连接超时            ❌ 国内不可达

按「联想统一取百度、搜索仍走各自引擎」实现:
- 新增设置 suggest_source(设置页可选 百度/必应/360/各引擎自己的接口),默认百度。
- 任何情况下都带自动兜底链:首选来源取不到词(接口挂/超时/返回空)
  就依次回退 百度 → 必应 → 360;单个候选 3 秒超时,绝不让下拉框卡住。
- 候选来源不过滤 is_active —— 作为联想来源的引擎可能在前台被隐藏。
- 响应里带 src 字段标明这次实际用的是谁,便于排查。

顺带修一个更隐蔽的 bug:前台 <form id="search-form"> 原本**没有 data-slug**,
而 JS 只在「点击引擎标签」时才把它写上 → 一进站、没点过引擎时 slug 为空,
suggest.php 查不到引擎,联想**全都不出词**。现在表单自带默认引擎的 slug。

其它:
- 清掉种子里已失效的搜狗联想地址;加一条幂等迁移,只匹配默认种子值,
  用户自己改过的地址不动。谷歌/DDG 也不再预设(前端不可达)。
- app.js:请求加序号,防止先发的慢响应覆盖后发的快响应;切换引擎时
  若输入框已有内容就顺带换一份建议。
- e2e 新增「联想来源」场景(本地桩服务器 + 独立端口,不依赖外网):
  8 条断言覆盖 默认来源 / auto 模式 / 回退 / 空关键词 / 响应头。

验证:本地 e2e 57/57;部署 38 文件哈希逐字节一致、双版本 php -l、37 条
静态断言全过;线上实测 google/sogou/ddg 三个「自家接口不可用」的引擎
均返回 10 条百度建议(src=baidu),中英文都正常。
This commit is contained in:
2026-09-28 17:13:10 +08:00
parent 853e4dd7c6
commit 34473d0120
7 changed files with 184 additions and 44 deletions
+92 -16
View File
@@ -2,7 +2,23 @@
/**
* 简航 SimpleNav —— 搜索联想代理
* 浏览器直接调用各搜索引擎的联想接口会有跨域/JSONP 限制,这里由服务端代理并统一输出 JSON。
*
* ⚠️ 各家联想接口在国内服务器上的可用性差别很大。2026-09-28 在本站服务器实测:
*
* 百度 sugrec?prod=pc&wd= 200 / 0.12s / 1137B ✅ 最快最稳 → 默认来源
* 360 sug.so.360.cn/suggest 200 / 0.20s / 714B ✅
* 必应 api.bing.com/osjson 200 / 0.5~1.6s / 408B ✅ 可用但明显慢
* 搜狗 suggnew/ajajjson 200 / **0 字节** ❌ 联想域名已不解析
* 谷歌 suggestqueries.google 连接超时 ❌ 国内不可达
* DuckDuckGo 连接超时 ❌ 国内不可达
*
* 所以默认策略是「**联想词统一取百度,搜索仍走各自引擎**」:
* 点哪个引擎就用哪个引擎搜索,只是输入框下面的建议来自百度。这样任何一个引擎
* 都不会出现「下拉是空的」。想恢复「各引擎用自己的接口」,把设置里的
* 「联想词来源」改成 auto 即可(见 admin 设置页)。
*
* 用法:suggest.php?slug=bing&q=关键词
* slug = 当前选中的引擎(auto 模式下用它自己的接口;其它模式下只作为兜底候选)
*/
declare(strict_types=1);
require_once __DIR__ . '/functions.php';
@@ -11,36 +27,96 @@ header('Content-Type: application/json; charset=utf-8');
send_no_cache();
$slug = isset($_GET['slug']) ? preg_replace('/[^a-z0-9\-_]/i', '', (string)$_GET['slug']) : '';
$q = isset($_GET['q']) ? trim((string)$_GET['q']) : '';
$q = isset($_GET['q']) ? trim((string)$_GET['q']) : '';
if ($q === '') {
echo json_encode(['q' => '', 'items' => []]);
echo json_encode(['q' => '', 'items' => [], 'src' => ''], JSON_UNESCAPED_UNICODE);
exit;
}
$items = [];
try {
$st = db()->prepare('SELECT * FROM engines WHERE slug=? AND is_active=1 LIMIT 1');
$st->execute([$slug]);
$engine = $st->fetch();
if ($engine && $engine['suggest_url'] !== '') {
$u = str_replace('{q}', rawurlencode($q), $engine['suggest_url']);
$resp = http_get($u, 100000, 5);
if ($resp !== null) {
$items = parse_suggest($engine['suggest_type'], ensure_utf8($resp['body']), $q);
}
$used = '';
foreach (suggest_order($slug) as $engine) {
$items = fetch_suggest($engine, $q);
if ($items) {
$used = (string)$engine['slug'];
break;
}
} catch (Throwable $e) {
$items = [];
}
// 去重、限长、裁剪
$items = array_values(array_unique(array_filter(array_map('trim', $items))));
$items = array_slice($items, 0, 10);
echo json_encode(['q' => $q, 'items' => $items], JSON_UNESCAPED_UNICODE);
echo json_encode(['q' => $q, 'items' => $items, 'src' => $used], JSON_UNESCAPED_UNICODE);
exit;
/* ========================================================================== */
/** 取一个引擎行(**不过滤 is_active** —— 作为联想来源的引擎可能在前台被隐藏了) */
function engine_row(string $slug): ?array
{
static $cache = [];
if ($slug === '') {
return null;
}
if (!array_key_exists($slug, $cache)) {
$st = db()->prepare('SELECT * FROM engines WHERE slug=? LIMIT 1');
$st->execute([$slug]);
$r = $st->fetch();
$cache[$slug] = $r ?: null;
}
return $cache[$slug];
}
/** 抓一次联想并解析;任何异常都退化成空数组,绝不让下拉框卡住 */
function fetch_suggest(array $engine, string $q): array
{
if ((string)($engine['suggest_url'] ?? '') === '') {
return [];
}
$u = str_replace('{q}', rawurlencode($q), (string)$engine['suggest_url']);
// 3 秒。既然有兜底链,宁快速失败也不要让输入框等太久。
$resp = http_get($u, 100000, 3);
if ($resp === null) {
return [];
}
return parse_suggest((string)$engine['suggest_type'], ensure_utf8((string)$resp['body']), $q);
}
/**
* 排出「先试谁、再试谁」的候选顺序。
*
* setting('suggest_source') = 'auto' → 先用当前引擎自己的接口,再走兜底链
* 其它值(默认 'baidu') → 统一用该来源,失败才走兜底链
*
* 兜底链固定为「百度 → 必应 → 360」(实测可用的三家,按快慢排序),
* 且只保留库里真实存在、且配了 suggest_url 的引擎。
*/
function suggest_order(string $slug): array
{
$src = trim(setting('suggest_source', 'baidu'));
$slugs = [];
if ($src === 'auto') {
if ($slug !== '') { $slugs[] = $slug; }
} else {
$slugs[] = $src;
}
foreach (['baidu', 'bing', 'so360'] as $s) {
$slugs[] = $s;
}
$out = [];
foreach (array_unique($slugs) as $s) {
$row = engine_row($s);
if ($row !== null && (string)$row['suggest_url'] !== '') {
$out[] = $row;
}
}
return $out;
}
/** 按引擎类型解析联想结果 */
function parse_suggest(string $type, string $body, string $q): array
{
@@ -112,5 +188,5 @@ function parse_suggest(string $type, string $body, string $q): array
elseif (!empty($j['s']) && is_array($j['s'])) { $out = $j['s']; }
}
}
return $out;
return is_array($out) ? $out : [];
}