Files
FamilyBook/nav_suggest.php
laoyang 91c55038c9 v30 第 30 批(3 条):ICS 导入读完即弃 / 导航联想词根因修复 / 导航设置改纯图标
三条都是「静态检查与 e2e 恒绿、只有真机才现形」的,每条先写真机诊断探针
拿到真根因再改,并逐条反向验证(改回旧行为必须被抓住)。

1. 日程 ICS 导入:文件已上传仍提示「请选择一个文本」
   真根因:_stage_drop.php 是"拖进来即刻上传到暂存区"的唯一形态——
   拖放时文件走 upload_stage.php 落到 uploads/_staging/,而 <input>.files
   始终是空的;两个导入框(ics/通讯录)都带 required → 浏览器原生校验把提交
   拦下,弹它自己的文案。就算绕过校验,路由只读 $_FILES['ics']、从不看
   stage_token[],暂存文件永远没人认领。
   改:片段新增 $sdStage(默认 true)。导入型传 false → 框上不带
   data-drop-stage → app.js 走"写回 input.files、随表单提交"的老路,文件走
   PHP 临时文件,路由读完即弃;不落存储、不建附件行、不进 OSS。
   同时把 app.js 找框的选择器从 [data-drop-stage][data-sd-for=…] 放宽成
   [data-sd-for=…]——否则这些框点不开也接不住拖动。

2. 导航联想词"还是不好用"
   真根因:nav_show.php 给出的 suggest 地址已带 ?p=<短语>,而 nav-show.js 又
   硬拼 '?slug=…' → 实际是 …?p=abc?slug=bing&q=…;PHP 认为第一个 ? 之后整串
   都是查询值 → p 匹配不上短语 → 403 → 下拉永远空。服务端一直是对的(直连
   200 + 10 条词),e2e 探针自己拼对了参数,所以 HTTP 层恒绿、只有真页面能抓。
   改:nav-show.js 新增 withQuery(),按 base 里有没有 ? 决定分隔符;
   nav_suggest.php 加短语容错(切掉第一个 ?/& 之后的内容,救缓存里的旧 JS)。

3. 导航设置按钮改纯图标 ☰
   v29 只改成了下拉菜单,summary 仍是 btn-ghost btn-sm + 文字「设置」+ 一个
   向下箭头。改:换成与其他七个模块逐字节相同的 btn-icon + 三条横线。

验证:e2e 1464/0(新增 2 条)、升级 153/0、vCard 40 项、城市 32/0、
桌面几何全过、移动 680/0、交互 92/0、窄屏 30/0、b24_nav 41/0、b24_ui 26/0、
b28 39/0、b29 38/0、b27 34/0;新真机探针 _b30 22/0、反向验证 11/0;
部署静态断言新增 8 条守卫并逐条证伪通过(本轮抓到 2 条恒真守卫 + 1 处
_code_only 的 locale 差异)。线上 7 个文件 SHA256 与本地一致。
2026-10-08 14:46:16 +08:00

283 lines
11 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 家书 FamilyBook · 导航搜索联想代理(v24 第 24 批 用户第 4 条,从 SimpleNav 搬来)
*
* ⚠️ 浏览器直接调各家联想接口会撞跨域 / JSONP 限制,所以由服务端代理并统一输出 JSON。
* SimpleNav 原文这里有一段**线上实测结论**,照抄保留(它是最贵的部分):
*
* 百度 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 连接超时 ❌ 国内不可达
*
* 所以默认策略是「**联想词统一取百度,搜索仍走各自引擎**」——
* 点哪个引擎就用哪个引擎搜,只是下拉建议来自百度,任何一个引擎都不会出现"下拉是空的"。
*
* 用法:`/nav/suggest.php?p=<短语>&slug=bing&q=关键词`
*
* ⚠️⚠️ **这个接口也是免登录的**(展示页本身免登录,它的联想接口必须一起免登录,
* 否则展示页的搜索框就没法联想)。因此它必须有自己的门禁 —— 见下面"门禁"那段:
* **必须带一个有效的短语**才放行,否则任何人都能拿它当免费代理刷外网请求。
*/
declare(strict_types=1);
require __DIR__ . '/app/bootstrap.php';
use App\Nav;
use App\NavFetch;
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('X-Robots-Tag: noindex');
// ---- 门禁:必须凭一个有效的短语(见文件头说明)----
//
// ⚠️ 这**不是**身份校验(短语本来就是公开的),它挡的是"随便谁来都能刷":
// 没有它,这个 URL 就是一个匿名的外网请求放大器(每次 GET 都会去打百度/必应)。
$phrase = trim((string)($_GET['p'] ?? ''));
/* 🔴 v30 用户第 2 条:容错 —— 短语里**不允许**出现 `?` 或 `&`(短语本身是
`[A-Za-z0-9_-]{4,32}`,见 Nav::PHRASE_RE,永远不含这两个字符)。
为什么要有这一段:前端曾经把 suggest 地址硬拼成 `…?p=abc?slug=bing&q=…`
(suggest 地址本身已带 `?`,旧代码又补了一个 `?`)。PHP 对重复 `?` 的处理是
"第一个 `?` 之后整串都算查询值" → `p` 变成了 `abc?slug=bing` → 403 →
展示页下拉**永远是空的**,而服务端单测是好的(探针自己拼对了)→ 极难查。
修法在前端(`withQuery()`,已改)。这里再做一层"把第一个 `?` 之后的垃圾切掉",
是为了让**任何**老前端 / 缓存住的旧 JS 都不至于整条联想失效 —— 属于廉价兜底:
合法短语不可能含 `?`,切掉不会误伤。 */
if ($phrase !== '') {
$cut = strcspn($phrase, '?&');
if ($cut < strlen($phrase)) {
$phrase = substr($phrase, 0, $cut);
}
}
$owner = $phrase === '' ? 0 : Nav::ownerByPhrase($phrase);
if ($owner <= 0) {
http_response_code(403);
echo json_encode(['q' => '', 'items' => [], 'src' => '', 'error' => 'forbidden'],
JSON_UNESCAPED_UNICODE);
exit;
}
$slug = isset($_GET['slug']) ? (string)preg_replace('/[^a-z0-9\-_]/i', '', (string)$_GET['slug']) : '';
$q = isset($_GET['q']) ? trim((string)$_GET['q']) : '';
// 联想词是给人看着点的短词,限长免得被拿去当搜索接口刷
$q = mb_substr($q, 0, 60, 'UTF-8');
if ($q === '') {
echo json_encode(['q' => '', 'items' => [], 'src' => ''], JSON_UNESCAPED_UNICODE);
exit;
}
// ---- 取这个人的引擎表(联想来源也按**他的**引擎配置走)----
$engines = Nav::engines($owner);
/** 按 slug 找他的某个引擎 */
$engineBy = static function (string $s) use ($engines): ?array {
if ($s === '') {
return null;
}
foreach ($engines as $e) {
if ((string)$e['slug'] === $s) {
return $e;
}
}
return null;
};
/**
* 排出「先试谁、再试谁」的候选顺序 —— 照搬 SimpleNav 的 suggest_order()。
*
* 兜底链固定「百度 → 必应 → 360」(实测可用的三家,按快慢排序),
* 只保留库里真实存在、且配了 suggest_url 的引擎。
*
* ⚠️ SimpleNav 那个 setting('suggest_source') 开关这里**没搬** ——
* 它依赖项目级设置,而本模块是**每人一份**的;为了不给用户多一个看不懂的开关,
* 直接用最有用的那一种(即 auto 的兜底链行为)。这是本批唯一有意"少搬"的一处,
* 在此记明,免得下一个人以为是漏了。
*/
$order = [];
if ($slug !== '') {
$own = $engineBy($slug);
if ($own !== null && (string)$own['suggest_url'] !== '') {
$order[] = $own;
}
}
foreach (['baidu', 'bing', 'so360'] as $s) {
$row = $engineBy($s);
if ($row !== null && (string)$row['suggest_url'] !== '') {
$order[] = $row;
}
}
$items = [];
$used = '';
foreach ($order as $en) {
$items = nav_fetch_suggest($en, $q);
if ($items) {
$used = (string)$en['slug'];
break;
}
}
$items = array_values(array_unique(array_filter(array_map('trim', $items))));
$items = array_slice($items, 0, 10);
echo json_encode(['q' => $q, 'items' => $items, 'src' => $used], JSON_UNESCAPED_UNICODE);
exit;
/* ========================================================================== */
/** 抓一次联想并解析;任何异常都退化成空数组,绝不让下拉框卡住(照搬原函数) */
function nav_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 = NavFetch::get($u, 100000, 3);
if ($resp === null) {
return [];
}
return nav_parse_suggest((string)$engine['suggest_type'], (string)$resp['body'], $q);
}
/** 按引擎类型解析联想结果(照搬原函数,逻辑一行未改) */
function nav_parse_suggest(string $type, string $body, string $q): array
{
$body = trim($body);
$out = [];
unset($q);
switch ($type) {
case 'bing_osjson':
case 'google_firefox':
// ["query", ["s1","s2",...]]
$j = json_decode($body, true);
if (is_array($j) && isset($j[1]) && is_array($j[1])) {
$out = $j[1];
}
break;
case 'baidu_sugrec':
// {"q":"x","g":[{"type":"..","q":".."}], ...}
$j = json_decode($body, true);
if (is_array($j)) {
if (!empty($j['g']) && is_array($j['g'])) {
foreach ($j['g'] as $it) {
if (isset($it['q'])) { $out[] = $it['q']; }
}
} elseif (!empty($j['s']) && is_array($j['s'])) {
$out = $j['s'];
}
}
break;
case 'so360':
// {"s":[".."]} 或 {"result":[{"word":".."}]}
$j = json_decode($body, true);
if (is_array($j)) {
if (!empty($j['s']) && is_array($j['s'])) {
$out = $j['s'];
} elseif (!empty($j['result']) && is_array($j['result'])) {
foreach ($j['result'] as $it) {
if (isset($it['word'])) { $out[] = $it['word']; }
}
}
}
break;
case 'sogou_ajaj':
// {"query":"x","items":["a","b"]}(可能 GBK,已被 NavFetch 转码)
$j = json_decode($body, true);
if (is_array($j) && !empty($j['items']) && is_array($j['items'])) {
$out = $j['items'];
} elseif (preg_match_all('#"([^"]{1,40})"#u', $body, $m)) {
$out = array_slice($m[1], 1); // 跳过 query 自身
}
break;
case 'ddg':
// [{"phrase":".."},...]
$j = json_decode($body, true);
if (is_array($j)) {
foreach ($j as $it) {
if (is_array($it) && isset($it['phrase'])) {
$out[] = $it['phrase'];
} elseif (is_string($it)) {
$out[] = $it;
}
}
}
break;
// ---- v28 用户第 10 条新增的三个联想来源(见 Nav::SUGGEST_LIBRARY)----
case 'sogou':
// {"query":"x","items":["a","b"]}
// ⚠️ 与旧的 `sogou_ajaj` 分开:那条是 GBK 老接口(现已不解析),
// 这条是新的 suggestion.sogou.com,返回 utf-8 JSON。
$j = json_decode($body, true);
if (is_array($j) && !empty($j['items']) && is_array($j['items'])) {
$out = $j['items'];
}
break;
case 'zhihu':
// {"data":[{"query":".."}]} 或 {"suggest":[{"query":".."}]}
$j = json_decode($body, true);
if (is_array($j)) {
foreach (['data', 'suggest'] as $k) {
if (!empty($j[$k]) && is_array($j[$k])) {
foreach ($j[$k] as $it) {
if (is_array($it)) {
foreach (['query', 'title', 'name'] as $f) {
if (isset($it[$f]) && is_string($it[$f])) { $out[] = $it[$f]; break; }
}
} elseif (is_string($it)) {
$out[] = $it;
}
}
break;
}
}
}
break;
case 'bili':
// {"code":0,"data":{"tag":[{"value":".."}]}}
// 也兼容 {"result":[{"term":".."}]}
$j = json_decode($body, true);
if (is_array($j)) {
if (!empty($j['data']['tag']) && is_array($j['data']['tag'])) {
foreach ($j['data']['tag'] as $it) {
if (is_array($it) && isset($it['value'])) { $out[] = $it['value']; }
}
} elseif (!empty($j['result']) && is_array($j['result'])) {
foreach ($j['result'] as $it) {
if (is_array($it)) {
foreach (['term', 'name', 'value'] as $f) {
if (isset($it[$f]) && is_string($it[$f])) { $out[] = $it[$f]; break; }
}
}
}
}
}
break;
default:
// 通用兜底:先试 JSON,再退化正则(照搬)
$j = json_decode($body, true);
if (is_array($j)) {
if (isset($j[1]) && is_array($j[1])) {
$out = $j[1];
} elseif (!empty($j['s']) && is_array($j['s'])) {
$out = $j['s'];
}
}
}
return is_array($out) ? $out : [];
}