三条都是「静态检查与 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 与本地一致。
283 lines
11 KiB
PHP
283 lines
11 KiB
PHP
<?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 : [];
|
||
} |