Files
FamilyBook/nav_suggest.php
T
laoyang 6852b4ee2c v28 第 28 批(12 条):名册分组改标签 / 资源合并为两按钮 / 首页定位与主题色 / 笔记富文本双模式
其中 4 条是**上批遗漏的真 bug**(1/4/8/9),根因已逐条定位:

1. 首页日程没定位到红线 —— 真根因:刻度区间**不含当前时刻** → $showNow=false
   → 红线**整个不渲染**,而定位脚本用同一个越界比例算目标,被 clamp 到底部。
   修:把当前小时并进区间端点(现在必然落在区间内)。
2. 「看整月」→「更多日程」+ 日程主题色;3. 「手头在做」→「近期任务」、
   「任务看板」→「全部任务」+ 任务主题色(注入 --hd-c,不再写死白底)。
4. 任务复选框提示还在 —— 真根因:**JS 在点击后**给状态徽标挂 title
   (`badge.setAttribute('title',…)`),上批只去了静态渲染,静态断言永远抓不到。
5. 名册「分组」→「标签」(用户:一个人可能既是小学同学又是初中同学,没法分组):
   新增 person_tags(每人一份、一人多标签),名册改**平铺 + 顶部标签筛选**;
   🔴 迁移用**一次性哨兵**(第一版用 NOT EXISTS,实测被"用户清空标签"绕过 → 旧分组名被灌回)。
6. 名册卡片按钮加语义底色。
7. 资源四按钮 → **编辑 / 删除**两颗:三块内容进一个带页签的弹框(信息/附件/共享)。
8. 财务多选数字不变 —— 真根因:前端发 `kinds=a,b`(逗号串),服务端 `(array)` 收成
   单元素 → 求交为空 → **静默回落整本账**。修:两种形状都收。
9. 微信读书偶发很慢 —— 真根因:syncNow 扫 60 本 × 每本 1~3 次网关调用(单次超时 20s),
   全同步阻塞在列表页请求里。修:总时长预算 + 单次超时 8s + 自动/手动分批。
10. 新增全局可见的联想接口库(6 个实测可用来源)+ 三种新解析类型。
11. 导航右上角「设置」:默认进"添加网址 + 全部网址",四类设置收进 ?set=1 视图。
12. 笔记:段落/清单支持 Markdown(自研渲染器,**不允许原始 HTML**),
    实时预览走服务端(前后端只一份规则);底部附件上传;清单多级;
    列表改**定尺寸卡片网格**(溢出显示摘要、图片缩放下方);按用户要求**去掉标签侧栏**。
2026-10-08 02:20:19 +08:00

266 lines
10 KiB
PHP
Raw 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'] ?? ''));
$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 : [];
}