其中 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**),
实时预览走服务端(前后端只一份规则);底部附件上传;清单多级;
列表改**定尺寸卡片网格**(溢出显示摘要、图片缩放下方);按用户要求**去掉标签侧栏**。
266 lines
10 KiB
PHP
266 lines
10 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'] ?? ''));
|
||
$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 : [];
|
||
} |