v24 第 24 批:选人冲掉已有成员 / 首页改版 / 个人导航 / 微信读书 / 财务图

用户 6 条原话(要点):
  ①「选择名册里面人员的那个弹出框有个问题,就是会把页面里面已有的人员给冲掉。
     比方说电影里面的观影人或者是旅程里面的参与人…这是一个 bug 应该通篇检查。」
    并按「输入拼音 → 空格选中第一个 → 输入框清空回焦 → 再输下一个 → 回车=确定」重做交互。
  ② 首页简要信息改成:问候 + 姓名 + 日期 + 星期 + 农历 + 节气/节日/纪念日/生日,
     下面「今天几条日程 / 几件待办」,天气**彩色图标**放**最右侧**,温度风力,最下面地址。
  ③「家里的镜框这里呃全部不要了」→ 删模块卡片墙 + 未来七天;日程与任务**左右各半**、
     高度固定、内部滚动且**不显滚动条**;日程**掐头去尾** + 当前时刻**红线**;
     任务显示标题+状态+**所在看板名**;最下面往年的今天。
  ④「把个人导航项目完整搬过来,放在读书后面」;免登录展示页 `/nav/?<短语>`;
     右上角给**登录**入口;展示页头像用**用户自己的头像**。
  ⑤ 读书加笔记 + 导入微信读书笔记(用户明确「**用微信读书 api key**」)。
  ⑥ 财务页第二张多线图不随类别标签筛选。

用户拍板(AskUserQuestion):① 首页删「模块卡片墙 + 未来七天」两块;
② 微信读书用官方 API Key;③ 导航短语「自己起名 + 可一键换随机码」都做;
④ 6 条一次做完再上线。

════ 一、 🔴 第 1 条:复现出的真 bug(全站性)════
**根因**:选人清单是**打开弹框时才建 DOM**(懒建,为性能),而承载"已选"的
checkbox 就在那份清单里 → **没打开过弹框就提交**时表单里一个值都没有 →
后端把"表单里没有 attendees"理解成"用户要把人全删掉" → **静默清空**。
实测复现:库里同行人 `2,3,4` → 只改标题保存后变成 **NULL**。

修法:模板加**承运字段**(`.pp-carry` 隐藏 input,承载当前已选);
JS 建清单时**撤掉**它(否则同一个 name 会带两份值)。
⚠️ 裸 POST(完全不带 attendees)**本来就该清空** —— 那是显式地"不选任何人",
所以断言也必须照着"浏览器实际提交的样子"(带承运字段)来测。

**键盘流程**(用户第 1 条后半):空格 = 选**当前可见列表里第一个还没选的**
(⚠️ 必须跳过已选中的 —— 否则会把它**取消**,与"我要选人"正好相反,本轮真机跑出来的缺陷);
选中后清空输入框 + 光标回输入框;回车 = 确定。空格必须 `preventDefault`
(否则输入框里多一个空格字符)。

覆盖范围:4 个多选(`attendees[]` / `members[]` / `shares[]` / 修订 `members[]`)
+ 全部单选(归属人 / 负责人 / 纪念日人等)。

════ 二、 首页改版(第 2、3 条)════
自上而下只有三块:
  ① 信息面板:问候(早/中/晚 + 姓名)→ 日期 / 星期 / 农历 → 日子标记
     (法定节假日 / 节气 / 传统节日 / 纪念日 / 生日,各一色)
     → 今日数字(今天几条日程、几件待办)| **最右**:彩色天气图标 + 温度 + 风力 + 地址
  ② 日程 / 任务 **左右各半**(各 360px 固定高、内部滚动、**不显滚动条**三套写法)
     日程是**日视图形式的刻度**、**掐头去尾**(首末日程前后各 1h)、**当前时刻红线**
     任务是标题 + **状态** + **看板名** + 截止
  ③ 往年的今天

新增 `Home::todayMarks()`(把五类来源合成一句话:"今天是国庆节 · 大暑")与
天气的 `tone` 色调档(彩色图标靠 CSS 上色,**不用 emoji** —— 避免旧系统豆腐块)。
删掉:`mod-cards` 模块卡片墙、`未来七天`。

════ 三、 个人导航(第 4 条,SimpleNav 完整搬运)════
`app/Nav.php`(4 张 `nav_*` 表 + 稀疏排序 + 短语)· `app/NavFetch.php`
(抓取与图标生成,27KB,从原项目 functions.php 机械搬运,**原文那批"线上实测"注释全部保留**)
· `nav_show.php`(**全站唯一免登录页**)· `nav_suggest.php`(联想代理,凭短语门禁)
· `templates/nav/index.php`(管理页:板块/站点/引擎/设置 + 拖拽排序)。

搬运只改两处(不改会坏):① 独立 SQLite → 家书的 MySQL;② **所有查询加 `person_id`**
(原项目是单用户系统,不加这一维全家人会共用同一份导航)。

免登录展示页:`/nav/?<短语>`(也认 `/nav/?<短语>` 不带参数名);
短语用户自起名(4~32 位字母数字`_-`,全局唯一、保留词拦截)或一键换随机码;
右上角**登录**入口(进家书登录页);头像用**他自己**的头像;空态给一句话。

路由三处同步(`router.php` / `.htaccess` / 部署脚本 5c **独立标记块**)——
这是本仓库第三次复用这条规律:**每加一个"站根上的独立入口",就必须是自己的标记块**。
⚠️ 线上重写规则**顺序有语义**:`^nav/suggest\.php$` 必须在 `^nav/?$` 之前
(后者会把前者吞掉)。

════ 四、 微信读书(第 5 条)════
用户提示「用微信读书 api key」是对的 —— **我的知识过时了**:2026 年官方已开放
(网关 `POST https://i.weread.qq.com/api/agent/gateway`、`Bearer wrk-` 认证、
接口 `/user/notebooks`(游标 lastSort 分页)、`/book/bookmarklist`、
`/review/list/mine`、`/book/chapterinfo`)。

`app/WeRead.php`:Key 用 `Crypto::encrypt` **加密落库**、界面只回显掩码
(官方原话:这串 Key 等价于账号凭据);导入走「拉预览 → 勾选 → 导入」,
落 `book_notes`(去重 = kind + 内容 + 位置)。
⚠️ 业务参数必须**平铺在顶层**,不能包进 `params`(官方文档专门列了这个反例)。
另一条路「手动粘贴」保留(`books/notes_import`,早已有)。

════ 五、 财务第二张图(第 6 条,**上一批我引入的 bug**)════
`g.hidden = true` 对 **SVG 的 `<g>` 无效**(它没有 `hidden` 属性)→ 线条一条都没被藏;
**而我的探针也读同一个属性** → 两边一起绿(假绿)。
改为 `style.display`(恢复用 `''` 而不是 `'block'`);探针改读
`getComputedStyle(el).display !== 'none'`(落在**渲染结果**上)。
⚠️ "探针与实现读同一个属性"是**互相掩盖**的典型,比单纯写错断言更危险。

════ 六、 验证 ════
e2e **1218/0**(本批新增 102 条)· 真机探针 **19/0**(选人)+ **24/0**(首页/导航)
+ **34/0**(导航模块)· 桌面几何 0 失败 · 移动端几何 674/0 · 老库升级 149/0
· 部署静态断言空跑 **310/0** · **反向验证 13/13**(逐条改回旧行为,点名断言都变红)
· 线上验收:9 个新文件全在、4 张 nav 表建好(schema_version=24)、
  三条重写规则顺序正确、免登录页 / 空短语 / 错短语 / 联想门禁 全部响应正确。

════ 七、 本批的"夹具与断言"教训(都写进注释了)════
· `calendar_events.all_day` **默认值是 1** → 插夹具不写它就成了全天事件 →
  `time` 被抹成 `''` → "刻度掐头去尾"看着像没做(白查三轮)。
· 日历本要挑 `kind='custom'`(我原以为"普通本没有 kind",实际**普通本就是 custom**)。
· 量"同高/同排"要拿**内容区**(去 padding)比,不能拿 border-box(`.topline` 有 14px 上内边距)。
· 判据不能把**边界上的正确行为**当 bug(22 点有日程时末刻度**就是** 23:00)。
· 「HTML 写了、CSS 没写」e2e 永远查不出来 —— 导航展示页的登录按钮就落到了左边
  (实测按钮右 116px / 整行右 1210px),只有真机量几何才抓得到。
· 反向验证里 e2e 会**重铺副本**把注入覆盖掉 → 给 e2e 加了 `FB_PATCH_FILE` 钩子
  (重铺完自动打补丁),13 例里 8 例"没牙齿"其实是这个假象。

════ 八、 新增/改动文件 ════
新增:`app/Nav.php` · `app/NavFetch.php` · `app/WeRead.php` · `nav_show.php` ·
`nav_suggest.php` · `templates/nav/index.php` · `templates/books/weread.php` ·
`assets/css/nav-show.css` · `assets/js/nav-show.js`
改动:`app/Schema.php`(v24 + 4 张 nav 表)· `app/Module.php`(+nav)· `app/Home.php` ·
`index.php` · `templates/_person_picker.php` · `assets/js/person_picker.js` ·
`templates/home.php` · `templates/ledger/index.php` · `templates/books/_settings_menu.php` ·
`assets/css/app.css` · `router.php` · `.htaccess`
This commit is contained in:
2026-10-07 05:52:40 +08:00
parent ae3060f552
commit 09e30d27d4
21 changed files with 5034 additions and 180 deletions
+158 -18
View File
@@ -76,6 +76,99 @@ final class Home
return '星期' . $w;
}
/**
* 「今天是个什么日子」—— 首页信息面板要的那一串标记(v24 第 24 批 用户第 2 条)。
*
* 用户原话:「如果有节气的话,或者有重要的节日的话,在这里显示。
* 比如说今天是这个国庆节啊,今天是端午节,或者是今天是中秋节,
* 或者是今天是芒种,今天是大寒,今天是小寒,今天是什么纪念日,
* 今天是谁的生日等等。显示在这里。」
*
* 一次把五类来源合成**一行可读的短语**:
* ① 法定节假日(Holiday::range —— 含"补班",那也要说,否则用户以为白放假)
* ② 二十四节气(Holiday::solarTermRange)
* ③ 传统节日(Holiday::traditionalRange —— 春节/中秋/端午…)
* ④ 纪念日(Anniversary::all —— 含自建与派生)
* ⑤ 生日(同上,Anniversary 已经把生日一起派生出来了)
*
* ⚠️ 为什么合成"一条字符串"而不是给模板五个数组:
* 面板上这是**一句话的位置**("今天是……"),模板里再拼一遍会把这五类的
* 优先级、去重、连接符全散到视图层 —— 同一件事写两处,下次加一类必漏一处。
* ("生日"与"纪念日"天然会重复:生日本身就是一条派生的纪念日,
* 所以下面按**名字去重**。)
*
* @param int $viewerId 谁在看(纪念日的称呼按他算,如"儿子"而非"杨小全")
* @return array{tags:array<int,array{word:string,kind:string}>, line:string}
* tags 给模板做彩色小标签,line 给纯文本兜底
*/
public static function todayMarks(int $familyId, int $viewerId, string $date): array
{
$tags = [];
$seen = [];
$add = static function (string $word, string $kind) use (&$tags, &$seen): void {
$word = trim($word);
// 同一件事别出现两次(生日既是"生日"也是一条派生纪念日)
if ($word === '' || isset($seen[$word])) {
return;
}
$seen[$word] = true;
$tags[] = ['word' => $word, 'kind' => $kind];
};
$y = (int)substr($date, 0, 4);
// ① 法定节假日(只认"今天在放假区间里";补班也报,但换一种措辞)
foreach (\App\Holiday::range($y, $date, $date) as $h) {
$nm = (string)($h['name'] ?? '');
if ($nm === '') {
continue;
}
$add(!empty($h['off']) ? $nm : ($nm . '(补班)'), 'holiday');
}
// ② 二十四节气
foreach (\App\Holiday::solarTermRange($date, $date) as $s) {
$add((string)($s['name'] ?? ''), 'term');
}
// ③ 中国传统节日
foreach (\App\Holiday::traditionalRange($date, $date) as $t) {
$add((string)($t['name'] ?? ''), 'festival');
}
// ④⑤ 纪念日 + 生日(Anniversary::all 已把生日派生进来)
foreach (\App\Anniversary::all($familyId, $viewerId) as $a) {
$repeat = (string)($a['repeat_type'] ?? 'solar');
$m = (int)($a['month'] ?? 0);
$d = (int)($a['day'] ?? 0);
if ($m <= 0 || $d <= 0) {
continue;
}
$isToday = false;
if ($repeat === 'lunar') {
// 农历:把"今天的农历月日"拿来比(闰月单列)
$l = \App\Lunar::solarToLunar($y, (int)substr($date, 5, 2), (int)substr($date, 8, 2));
$isToday = $l !== null
&& (int)$l['month'] === $m && (int)$l['day'] === $d
&& (int)($l['is_leap'] ? 1 : 0) === (int)($a['is_leap'] ?? 0);
} elseif ($repeat === 'once') {
// 一次性:年月日全对上才算(这种不是"每年重复")
$born = (string)($a['born'] ?? '');
$isToday = $born !== '' && substr($born, 0, 10) === $date;
} else {
$isToday = $m === (int)substr($date, 5, 2) && $d === (int)substr($date, 8, 2);
}
if (!$isToday) {
continue;
}
$title = (string)($a['title'] ?? '');
$add($title, (string)($a['kind'] ?? '') === '生日' ? 'birthday' : 'anniv');
}
$words = array_map(static fn(array $t): string => $t['word'], $tags);
return [
'tags' => $tags,
'line' => $words ? implode(' · ', $words) : '',
];
}
/**
* 我在哪儿(v24 用户 2026-10-03 第 1 条)。
*
@@ -164,9 +257,19 @@ final class Home
}
}
$url = 'https://api.open-meteo.com/v1/forecast'
$base = getenv('FAMILYBOOK_WEATHER_BASE');
if (!is_string($base) || $base === '') {
$base = 'https://api.open-meteo.com/v1/forecast';
}
// ⚠️ 这个可覆盖的 base 是**为测试留的**(与 Douban 的 FAMILYBOOK_DOUBAN_SEARCH
// 同一套做法):本地/CI 环境取不到 api.open-meteo.com(实测外网直连超时
// 17.8s 后失败),于是"天气那一块"在本地**永远**是空 ——
// 断言只能靠 mock 才验得了。生产环境不设这个变量,走真实地址。
$url = $base
. '?latitude=' . urlencode($lat) . '&longitude=' . urlencode($lon)
. '&current=temperature_2m,weather_code&timezone=Asia%2FShanghai';
. '&current=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m,is_day'
. '&daily=temperature_2m_max,temperature_2m_min&forecast_days=1'
. '&timezone=Asia%2FShanghai';
$body = self::httpGet($url);
if ($body === null) {
return null;
@@ -175,9 +278,19 @@ final class Home
if (!is_array($j) || !isset($j['current'])) {
return null;
}
// v24 第 24 批 用户第 2 条:面板上要显示**温度、风力**(用户原话)。
// 风向、昼夜、今日高低温都顺手取回来 —— 它们只在 current/daily 里各加一个字段,
// 不多打一次接口;以后要在面板上加"最高 20° 最低 12°"直接就有。
$data = [
'temp' => (string)round((float)($j['current']['temperature_2m'] ?? 0)),
'code' => (int)($j['current']['weather_code'] ?? -1),
'wind' => round((float)($j['current']['wind_speed_10m'] ?? 0), 1),
'wdir' => (int)($j['current']['wind_direction_10m'] ?? 0),
'day' => (int)($j['current']['is_day'] ?? 1),
'tmax' => isset($j['daily']['temperature_2m_max'][0])
? round((float)$j['daily']['temperature_2m_max'][0]) : null,
'tmin' => isset($j['daily']['temperature_2m_min'][0])
? round((float)$j['daily']['temperature_2m_min'][0]) : null,
];
// 缓存下来(存在 settings,key 里带 family 0 = 全站级)
Setting::set($familyId, $cacheKey, json_encode(['at' => time(), 'data' => $data], JSON_UNESCAPED_UNICODE));
@@ -190,53 +303,80 @@ final class Home
if (!isset($data['temp'])) {
return null;
}
[$text, $icon] = self::wmo((int)($data['code'] ?? -1));
[$text, $icon, $tone] = self::wmo((int)($data['code'] ?? -1), (int)($data['day'] ?? 1));
$wind = (float)($data['wind'] ?? -1);
$wdir = (int)($data['wdir'] ?? -1);
return [
'temp' => (string)$data['temp'] . '°C',
'text' => $text,
'icon' => $icon,
// v24 第 24 批:面板要的是**彩色图标**(用户原话),
// 所以除了符号本身,再把"该用哪一档颜色"一起给出来 ——
// 颜色由**后端**决定(晴=暖黄、雨=蓝、雪=青…),前端只管贴上去;
// 让 CSS 去猜天气码等于把同一份判断抄两遍。
'tone' => $tone,
'wind' => $wind >= 0 ? (string)round($wind) . ' km/h' : '',
// 风向文字(八向)。用户没点名要,但"风"这件事光看风速不够 ——
// 顺手给出来,面板上放在风力旁边即可(没数据时是空串,不占位)。
'wdir' => $wdir >= 0 ? self::windDir($wdir) : '',
'tmax' => isset($data['tmax']) && $data['tmax'] !== null ? (string)round((float)$data['tmax']) . '°' : '',
'tmin' => isset($data['tmin']) && $data['tmin'] !== null ? (string)round((float)$data['tmin']) . '°' : '',
'place' => trim($place),
];
}
/** 风向角度 → 八向中文(数据缺失时调用方不会调它) */
private static function windDir(int $deg): string
{
$dirs = ['北', '东北', '东', '东南', '南', '西南', '西', '西北'];
$i = (int)round((($deg % 360) + 360) % 360 / 45) % 8;
return $dirs[$i] . '风';
}
/**
* WMO 天气代码 → [中文描述, 一个符号]。
* 符号用**普通字符**(不是 emoji):emoji 在部分旧系统上会变豆腐块,
* 而这是每天都要看的一块,宁可用朴素符号也不要出现方框。
* WMO 天气代码 → [中文描述, 一个符号, 配色档]。
*
* ⚠️ 符号用**普通字符**(不是 emoji):emoji 在部分旧系统上会变豆腐块,
* 而这是每天都要看的一块,宁可用朴素符号也不要出现方框。
* v24 第 24 批 用户第 2 条要"彩色图标"—— 那就用**颜色**把它变彩色,
* 而不是换成 emoji(换 emoji 会把豆腐块的风险直接引进来)。
*
* @param int $day 1=白天 0=夜间(晴天夜间给月亮,不要白天也挂太阳)
* @return array{0:string,1:string,2:string} [描述, 符号, 配色档(sunny/cloud/rain/snow/storm/fog)]
*/
private static function wmo(int $c): array
private static function wmo(int $c, int $day = 1): array
{
if ($c === 0) {
return ['晴', '☀'];
return $day === 1 ? ['晴', '☀', 'sunny'] : ['晴', '☾', 'sunny'];
}
if (in_array($c, [1, 2], true)) {
return ['多云', '⛅'];
return ['多云', '⛅', 'cloud'];
}
if ($c === 3) {
return ['阴', '☁'];
return ['阴', '☁', 'cloud'];
}
if (in_array($c, [45, 48], true)) {
return ['有雾', '≋'];
return ['有雾', '≋', 'fog'];
}
if ($c >= 51 && $c <= 57) {
return ['毛毛雨', '☂'];
return ['毛毛雨', '☂', 'rain'];
}
if ($c >= 61 && $c <= 67) {
return ['下雨', '☂'];
return ['下雨', '☂', 'rain'];
}
if ($c >= 71 && $c <= 77) {
return ['下雪', '❄'];
return ['下雪', '❄', 'snow'];
}
if ($c >= 80 && $c <= 82) {
return ['阵雨', '☂'];
return ['阵雨', '☂', 'rain'];
}
if ($c >= 85 && $c <= 86) {
return ['阵雪', '❄'];
return ['阵雪', '❄', 'snow'];
}
if ($c >= 95) {
return ['雷雨', '⚡'];
return ['雷雨', '⚡', 'storm'];
}
return ['天气', '·'];
return ['天气', '·', 'cloud'];
}
/** 极简 GET(3 秒超时;失败返回 null,绝不抛) */
+5 -1
View File
@@ -47,6 +47,10 @@ final class Module
'ledger' => ['财务', '#5E7A3C', 'ledger'],
// v24 第 14 批:读书(书架 + 上传解析 + 在线读 + 笔记 + 统计)
'books' => ['读书', '#6B5B95', 'books'],
// v24 第 24 批 用户第 4 条:个人导航(从 SimpleNav 搬进来,放在**读书后面**)
// 用户原话:「增加一下个人导航的这个模块就放在读书的后面,每个用户都可以
// 添加自己的导航,所有的功能模块就完全照搬原来的导航的那个系统」。
'nav' => ['导航', '#3E7A9E', 'nav'],
];
/**
@@ -57,7 +61,7 @@ final class Module
* 只是不再占顶栏的位置。
*/
public const DEFAULT_NAV = ['home', 'people', 'movies', 'trips', 'calendar', 'resources',
'tasks', 'days', 'ledger', 'books'];
'tasks', 'days', 'ledger', 'books', 'nav'];
/** 已经做过「家谱撤出导航条」这件事的人,记一个标记(只做一次,之后用户勾回来就尊重他) */
private const NAV_KIN_OFF_FLAG = 'nav_kin_off_v24';
+734
View File
@@ -0,0 +1,734 @@
<?php
/**
* 个人导航(v24 第 24 批 用户第 4 条)—— 从用户自己的 SimpleNav 项目**完整搬运**。
*
* 用户原话:
* 「你去读一下个人导航,就是我之前有一个个人导航的项目,把这个项目完整的搬过来
* 放在导航条里,就是说增加一下个人导航的这个模块就放在读书的后面,每个用户
* 都可以添加自己的导航,所有的功能模块就完全照搬原来的导航的那个系统里面的功能。
* 但是这里面有一个问题需要注意现在的系统,每一个功能模块都是需要登录的。
* 但是导航展示页面却不需要登录。这就意味着我们需要给他一个非常特殊的地址。
* 地址后面可以跟着用户添加的一个短语,就跟类似于一个密钥一样,他可以给自己
* 起一个短语。然后这个地址后面跟着问号带上这个短语的形式去进行展示。这样的话
* 我们是为了区分不同的用户,他的不同的导航页面,用户选了一个短语之后,通过
* 这个短语,我们就能知道要访问的是哪一个用户的面板,这个页面是不需要登录的。
* 在这个页面里面要增加一个功能,右上角增加一个功能,就是登录点击这个页面之后
* 再进入到我们现在这个系统就是家书的这个系统的登录页面。否则的话,那个导航的
* 展示的页面是跟之前的一样,是不需要进行登录的。这样的话,导航的展示页面,
* 导航页的展示页面上面的那个头像就变成用户自己的头像就可以了。」
*
* ────────────────────────────────────────────────────────────────
* **搬运时改了什么、没改什么**(这是唯一需要判断的地方)
*
* 没改(照搬):板块/站点/引擎/设置四张表的字段语义、稀疏排序 SORT_STEP、
* 拖拽整组归一化、图标抓取与 GD 生成站点图标、联想词接口、打开方式、
* 合集 tabs/flat 两种布局 —— 用户要的是「完全照搬」。
*
* 改了两处(不改会坏):
* ① **存储**:原项目是独立 SQLite(data/nav.db)+ 自己的 users 表。
* 搬进来改成家书的 MySQL(`nav_*` 表)+ **家书的 person_id**。
* ② **归属维度**:原项目只有一份全局数据(单用户系统);
* 现在是「每个用户都可以添加自己的导航」→ 所有查询都带上 `person_id`。
* ⚠️ 这正是"照搬"里唯一不能照抄的部分:漏掉这一维,全家人会共用同一份导航,
* 而且一个人加一条链接,全家人的导航页里都会冒出来。
* ③ 原来的 `url()` / `asset()` / `e()` 等与家书重名,**一律改用家书自己的**
* (家书已有 `e()` / `asset()` / `url()`),不再搬一份进来 ——
* 两份同名函数会直接 Fatal(函数重定义)。
*/
declare(strict_types=1);
namespace App;
final class Nav
{
/** 排序步长(与 SimpleNav 一致) */
public const SORT_STEP = 1000;
/** 图标目录(相对站点根,Web 可访问;与 SimpleNav 的 uploads/icons 同构) */
public const ICON_DIR = 'uploads/nav-icons';
public const ICON_URL = 'uploads/nav-icons';
/**
* 导航短语的合法形式:4~32 位,字母/数字/下划线/连字符。
*
* ⚠️ 为什么限这么死:这个短语是**免登录地址**上的唯一凭据
* (`/nav/?yangyq`)。允许中文/符号会让复制粘贴时出现"看起来一样但实际不同"
* 的重码(中文空格、全角下标…),而这种错误排查起来极烦。
* 长度下限 4 是因为 1~3 位太容易被穷举(这页面是公开的)。
*/
public const PHRASE_RE = '/^[A-Za-z0-9_-]{4,32}$/';
/** 保留短语:不能与真实路由/常见词撞(否则地址会有歧义) */
public const PHRASE_RESERVED = [
'nav', 'login', 'logout', 'index', 'admin', 'api', 'static', 'assets',
'help', 'about', 'null', 'undefined', 'true', 'false', 'test',
];
// ================================================================
// 设置(原 SimpleNav 的 settings 表 → 这里按人一份)
// ================================================================
/** 设置的默认值(照搬 SimpleNav 的 seed_settings) */
public const SETTING_DEFAULTS = [
'site_name' => '我的导航',
'subtitle' => '',
'default_engine' => 'bing',
'primary_color' => '#2F6BF7',
// '1' = 站点链接与搜索框都在**新标签页**打开(与引入该设置前的旧行为一致)
'open_blank' => '1',
// 'both' = ↓/↑ 与 , . 两组都认(SimpleNav 的默认,也是"严格超集")
'suggest_keys' => 'both',
// tabs = 合集标签页折叠(默认);flat = 全部平铺
'collections_layout' => 'tabs',
];
public static function setting(int $personId, string $k, ?string $default = null): string
{
if ($default === null) {
$default = self::SETTING_DEFAULTS[$k] ?? '';
}
$st = Database::get()->prepare('SELECT v FROM nav_settings WHERE person_id = ? AND k = ?');
$st->execute([$personId, $k]);
$v = $st->fetchColumn();
return $v === false ? $default : (string)$v;
}
public static function setSetting(int $personId, string $k, string $v): void
{
Database::get()->prepare(
'INSERT INTO nav_settings (person_id, k, v) VALUES (?,?,?)
ON DUPLICATE KEY UPDATE v = VALUES(v)'
)->execute([$personId, $k, $v]);
}
/** 一次取全部设置(渲染页面时省 N 次查询) */
public static function settings(int $personId): array
{
$out = self::SETTING_DEFAULTS;
$st = Database::get()->prepare('SELECT k, v FROM nav_settings WHERE person_id = ?');
$st->execute([$personId]);
foreach ($st->fetchAll() as $r) {
$out[(string)$r['k']] = (string)$r['v'];
}
return $out;
}
// ================================================================
// 板块
// ================================================================
/** 我的板块(可按显示方式筛) */
public static function groups(int $personId, string $display = '', bool $visibleOnly = false): array
{
$sql = 'SELECT * FROM nav_groups WHERE person_id = ?';
$arg = [$personId];
if ($display !== '') {
$sql .= ' AND display = ?';
$arg[] = $display;
}
if ($visibleOnly) {
$sql .= ' AND is_visible = 1';
}
$sql .= ' ORDER BY sort_order, id';
$st = Database::get()->prepare($sql);
$st->execute($arg);
return $st->fetchAll();
}
public static function group(int $personId, int $id): ?array
{
$st = Database::get()->prepare('SELECT * FROM nav_groups WHERE id = ? AND person_id = ?');
$st->execute([$id, $personId]);
$r = $st->fetch();
return $r ?: null;
}
public static function saveGroup(int $personId, array $d, int $id = 0): int
{
$name = trim((string)($d['name'] ?? ''));
$display = ((string)($d['display'] ?? 'grid')) === 'tabs' ? 'tabs' : 'grid';
$vis = !empty($d['is_visible']) ? 1 : 0;
if ($name === '') {
return 0;
}
if ($id > 0) {
// ⚠️ 带 person_id 校验:不能凭一个 id 改别人家的板块
Database::get()->prepare(
'UPDATE nav_groups SET name = ?, display = ?, is_visible = ? WHERE id = ? AND person_id = ?'
)->execute([$name, $display, $vis, $id, $personId]);
return $id;
}
$sort = self::nextSort('nav_groups', $personId);
Database::get()->prepare(
'INSERT INTO nav_groups (person_id, name, slug, display, sort_order, is_visible)
VALUES (?,?,?,?,?,?)'
)->execute([$personId, $name, self::slugify($name), $display, $sort, $vis]);
return (int)Database::get()->lastInsertId();
}
public static function removeGroup(int $personId, int $id): void
{
// 站点靠外键 ON DELETE CASCADE 一起走
Database::get()->prepare('DELETE FROM nav_groups WHERE id = ? AND person_id = ?')
->execute([$id, $personId]);
}
public static function toggleGroup(int $personId, int $id): void
{
Database::get()->prepare(
'UPDATE nav_groups SET is_visible = 1 - is_visible WHERE id = ? AND person_id = ?'
)->execute([$id, $personId]);
}
// ================================================================
// 站点
// ================================================================
public static function sites(int $personId, int $groupId, bool $visibleOnly = true): array
{
$sql = 'SELECT * FROM nav_sites WHERE person_id = ? AND group_id = ?'
. ($visibleOnly ? ' AND is_visible = 1' : '') . ' ORDER BY sort_order, id';
$st = Database::get()->prepare($sql);
$st->execute([$personId, $groupId]);
return $st->fetchAll();
}
/** 我的全部站点(管理页用;按板块归好组) */
public static function sitesByGroup(int $personId): array
{
$st = Database::get()->prepare(
'SELECT * FROM nav_sites WHERE person_id = ? ORDER BY group_id, sort_order, id'
);
$st->execute([$personId]);
$out = [];
foreach ($st->fetchAll() as $r) {
$out[(int)$r['group_id']][] = $r;
}
return $out;
}
public static function site(int $personId, int $id): ?array
{
$st = Database::get()->prepare('SELECT * FROM nav_sites WHERE id = ? AND person_id = ?');
$st->execute([$id, $personId]);
$r = $st->fetch();
return $r ?: null;
}
/**
* 存一条站点(新增或编辑)。
*
* ⚠️ `icon_custom` 的语义照搬 SimpleNav:「全部更新」抓图标时**不动**
* 用户自己传的图标。漏掉这一条,用户精心挑的图标会被下一次批量更新冲掉。
*/
public static function saveSite(int $personId, array $d, int $id = 0): int
{
$url = self::normUrl((string)($d['url'] ?? ''));
$name = trim((string)($d['name'] ?? ''));
$short = trim((string)($d['short_name'] ?? ''));
$gid = (int)($d['group_id'] ?? 0);
$vis = !empty($d['is_visible']) ? 1 : 0;
if ($url === '' || $gid <= 0 || self::group($personId, $gid) === null) {
return 0;
}
if ($name === '') {
$name = self::domainOf($url);
}
$now = date('Y-m-d H:i:s');
if ($id > 0) {
Database::get()->prepare(
'UPDATE nav_sites SET group_id = ?, url = ?, name = ?, short_name = ?, is_visible = ?,
updated_at = ? WHERE id = ? AND person_id = ?'
)->execute([$gid, $url, $name, $short, $vis, $now, $id, $personId]);
return $id;
}
// ⚠️ 新增一律排**最后**(SimpleNav 的明确行为:不插进用户已排好的顺序里)
$sort = self::nextSort('nav_sites', $personId, 'group_id', $gid);
Database::get()->prepare(
'INSERT INTO nav_sites (person_id, group_id, url, name, short_name, icon, icon_custom,
sort_order, is_visible, created_at, updated_at)
VALUES (?,?,?,?,?,\'\',0,?,?,?,?)'
)->execute([$personId, $gid, $url, $name, $short, $sort, $vis, $now, $now]);
return (int)Database::get()->lastInsertId();
}
public static function removeSite(int $personId, int $id): void
{
$s = self::site($personId, $id);
if ($s && (string)$s['icon'] !== '' && empty($s['icon_custom'])) {
self::deleteIconFile((string)$s['icon']);
}
Database::get()->prepare('DELETE FROM nav_sites WHERE id = ? AND person_id = ?')
->execute([$id, $personId]);
}
public static function toggleSite(int $personId, int $id): void
{
Database::get()->prepare(
'UPDATE nav_sites SET is_visible = 1 - is_visible WHERE id = ? AND person_id = ?'
)->execute([$id, $personId]);
}
/** 站点显示名(简称优先 → 抓来的名字 → 域名) */
public static function siteLabel(array $s): string
{
$short = trim((string)($s['short_name'] ?? ''));
if ($short !== '') {
return $short;
}
$n = trim((string)($s['name'] ?? ''));
return $n !== '' ? $n : self::domainOf((string)($s['url'] ?? ''));
}
// ================================================================
// 搜索引擎
// ================================================================
public static function engines(int $personId, bool $activeOnly = false): array
{
$st = Database::get()->prepare(
'SELECT * FROM nav_engines WHERE person_id = ?'
. ($activeOnly ? ' AND is_active = 1' : '') . ' ORDER BY sort_order, id'
);
$st->execute([$personId]);
return $st->fetchAll();
}
public static function saveEngine(int $personId, array $d, int $id = 0): int
{
$name = trim((string)($d['name'] ?? ''));
$slug = trim((string)($d['slug'] ?? ''));
$url = trim((string)($d['search_url'] ?? ''));
$par = trim((string)($d['param'] ?? 'q'));
if ($name === '' || $url === '') {
return 0;
}
if ($slug === '') {
$slug = self::slugify($name);
}
if ($par === '') { $par = 'q'; }
$color = self::safeColor((string)($d['color'] ?? ''), '#2F6BF7');
if ($id > 0) {
Database::get()->prepare(
'UPDATE nav_engines SET name=?, slug=?, search_url=?, param=?, suggest_url=?,
suggest_type=?, color=? WHERE id=? AND person_id=?'
)->execute([$name, $slug, $url, $par,
trim((string)($d['suggest_url'] ?? '')), trim((string)($d['suggest_type'] ?? '')),
$color, $id, $personId]);
return $id;
}
$sort = self::nextSort('nav_engines', $personId);
Database::get()->prepare(
'INSERT INTO nav_engines (person_id, name, slug, search_url, param, suggest_url,
suggest_type, color, is_active, sort_order)
VALUES (?,?,?,?,?,?,?,?,0,?)'
)->execute([$personId, $name, $slug, $url, $par,
trim((string)($d['suggest_url'] ?? '')), trim((string)($d['suggest_type'] ?? '')),
$color, $sort]);
return (int)Database::get()->lastInsertId();
}
public static function removeEngine(int $personId, int $id): void
{
Database::get()->prepare('DELETE FROM nav_engines WHERE id = ? AND person_id = ?')
->execute([$id, $personId]);
}
public static function toggleEngine(int $personId, int $id): void
{
Database::get()->prepare(
'UPDATE nav_engines SET is_active = 1 - is_active WHERE id = ? AND person_id = ?'
)->execute([$id, $personId]);
}
/** 默认引擎(设置里指定的那个;不在就取第一个启用的) */
public static function defaultEngine(int $personId, array $engines): ?array
{
$want = self::setting($personId, 'default_engine');
foreach ($engines as $e) {
if ((string)$e['slug'] === $want) {
return $e;
}
}
return $engines[0] ?? null;
}
// ================================================================
// 排序(稀疏 + 整组归一化)—— 照搬 SimpleNav 的两个函数
// ================================================================
/**
* 取「同组最后一条的 sort_order + SORT_STEP」——**新增条目专用**。
*
* ⚠️ 不要改回 `MAX+1`:SimpleNav 里已经写明过原因 ——
* 那只在"旧数据恰好是连续整数"时才等于"排最后";一旦排序号之间有间隙
* (拖拽后必然如此),MAX+1 会**撞进已有间隙**里,让新条目落到用户排好的
* 中间,正是用户抱怨的"排序被打乱"。
*/
public static function nextSort(string $table, int $personId, string $groupCol = '', int $groupId = 0): int
{
if ($groupCol === '') {
$st = Database::get()->prepare(
"SELECT COALESCE(MAX(sort_order),0) FROM {$table} WHERE person_id = ?");
$st->execute([$personId]);
} else {
$st = Database::get()->prepare(
"SELECT COALESCE(MAX(sort_order),0) FROM {$table} WHERE person_id = ? AND {$groupCol} = ?");
$st->execute([$personId, $groupId]);
}
return (int)$st->fetchColumn() + self::SORT_STEP;
}
/**
* 按给定 id 顺序**整组重写**排序号(第 n 条 = n*SORT_STEP)。
*
* ⚠️ `$groupId` 非空时做**归属校验**:不属于该组(或不属于我)的 id 直接跳过 ——
* 照搬 SimpleNav 的原话:防止一个被篡改的请求把别的板块的条目拖进来。
* 这里额外再叠一层 `person_id`:否则能拖别人家的站点。
*/
public static function applySort(string $table, int $personId, array $ids,
string $groupCol = '', int $groupId = 0): int
{
$db = Database::get();
$upd = $db->prepare("UPDATE {$table} SET sort_order = ? WHERE id = ? AND person_id = ?");
$ck = null;
if ($groupCol !== '') {
$ck = $db->prepare("SELECT COUNT(*) FROM {$table} WHERE id = ? AND person_id = ? AND {$groupCol} = ?");
}
$n = 0;
$i = 0;
foreach ($ids as $rid) {
$rid = (int)$rid;
if ($rid <= 0) {
continue;
}
if ($ck !== null) {
$ck->execute([$rid, $personId, $groupId]);
if ((int)$ck->fetchColumn() === 0) {
continue; // 不属于本组 / 不是我家的 → 忽略
}
}
$i++;
$upd->execute([$i * self::SORT_STEP, $rid, $personId]);
$n++;
}
return $n;
}
/** 跨板块移动(拖到另一个板块) */
public static function moveSite(int $personId, int $id, int $toGroupId): bool
{
if (self::group($personId, $toGroupId) === null) {
return false;
}
$sort = self::nextSort('nav_sites', $personId, 'group_id', $toGroupId);
Database::get()->prepare(
'UPDATE nav_sites SET group_id = ?, sort_order = ?, updated_at = ? WHERE id = ? AND person_id = ?'
)->execute([$toGroupId, $sort, date('Y-m-d H:i:s'), $id, $personId]);
return true;
}
// ================================================================
// 短语(免登录展示页的地址后缀)
// ================================================================
/**
* 我的短语(没设过 → 空串,展示页就不可用)。
*
* ⚠️ 存在 `nav_settings` 的 `link_phrase` 键里,但**必须全局唯一** ——
* 它是免登录地址上的唯一凭据,两个人用同一个短语就等于互相串页。
* 唯一性由 `phraseTaken()` 在写入前校验(用 SQL 查,不靠应用层缓存)。
*/
public static function phrase(int $personId): string
{
return self::setting($personId, 'link_phrase', '');
}
/** 这个短语是否已被**别人**占用($except 传自己,改自己的短语时不算冲突) */
public static function phraseTaken(string $phrase, int $except = 0): bool
{
$st = Database::get()->prepare(
"SELECT COUNT(*) FROM nav_settings WHERE k = 'link_phrase' AND v = ? AND person_id <> ?");
$st->execute([$phrase, $except]);
return (int)$st->fetchColumn() > 0;
}
/**
* 设短语。返回 ['ok'=>bool, 'error'=>string]。
*
* ⚠️ 三条校验缺一不可(少了任何一条都会出问题):
* · 形式合法 → 否则地址复制粘贴时会出现"看着一样其实不同"的重码;
* · 未被占用 → 否则两个用户共用一个展示页;
* · 不是保留词 → 否则 `/nav/?nav` 这类地址会有歧义。
*/
public static function setPhrase(int $personId, string $phrase): array
{
$phrase = trim($phrase);
if ($phrase === '') {
self::setSetting($personId, 'link_phrase', '');
return ['ok' => true, 'error' => ''];
}
if (!preg_match(self::PHRASE_RE, $phrase)) {
return ['ok' => false, 'error' => '短语只能用 4~32 位的字母、数字、下划线或连字符。'];
}
if (in_array(strtolower($phrase), self::PHRASE_RESERVED, true)) {
return ['ok' => false, 'error' => '「' . $phrase . '」是系统保留词,请换一个。'];
}
if (self::phraseTaken($phrase, $personId)) {
return ['ok' => false, 'error' => '这个短语已经被别人用了,换一个吧。'];
}
self::setSetting($personId, 'link_phrase', $phrase);
return ['ok' => true, 'error' => ''];
}
/** 生成一个没被占用的随机短语(用户要的"一键换随机码") */
public static function randomPhrase(): string
{
// 去掉容易读错的 0/O/1/l/I(与 DAV 口令同一套考量)
$abc = 'abcdefghjkmnpqrstuvwxyz23456789';
for ($try = 0; $try < 30; $try++) {
$s = '';
for ($i = 0; $i < 10; $i++) {
$s .= $abc[random_int(0, strlen($abc) - 1)];
}
if (!self::phraseTaken($s)) {
return $s;
}
}
return 'nav' . bin2hex(random_bytes(6));
}
/**
* 按短语找主人(展示页用,**免登录**)。
*
* ⚠️ 返回值只给"这个短语存在吗、属于谁"—— 绝不返回任何其它身份信息。
* 这页面是公开的,多一个字段就是多一处泄露面。
*/
public static function ownerByPhrase(string $phrase): int
{
if (!preg_match(self::PHRASE_RE, $phrase)) {
return 0;
}
$st = Database::get()->prepare(
"SELECT person_id FROM nav_settings WHERE k = 'link_phrase' AND v = ? LIMIT 1");
$st->execute([$phrase]);
return (int)($st->fetchColumn() ?: 0);
}
// ================================================================
// 小工具(照搬 SimpleNav 的同名函数,去掉与家书重名的)
// ================================================================
public static function normUrl(string $url): string
{
$url = trim($url);
if ($url === '') {
return '';
}
if (!preg_match('#^https?://#i', $url)) {
$url = 'http://' . $url;
}
return mb_substr($url, 0, 500, 'UTF-8');
}
/**
* 主域名(展示用途:首字母回退、标题回退)。
*
* ⚠️ SimpleNav 原文写明:**不要拿它当图标文件名**(用 iconKey($url))——
* 同一域名下不同路径是两个不同的站点条目,用域名做键会互相覆盖。
*/
public static function domainOf(string $url): string
{
$host = parse_url(self::normUrl($url), PHP_URL_HOST);
return $host ? (string)preg_replace('/^www\./i', '', $host) : '';
}
/** 图标文件名(按 URL 取 md5,与 SimpleNav 一致) */
public static function iconKey(string $url): string
{
return md5(self::normUrl($url));
}
/** 名称首字配色(无图标时的回退底色)—— 照搬 SimpleNav 的色板 */
public static function letterColor(string $s): string
{
$palette = ['#2F6BF7', '#5B8DEF', '#0EA5E9', '#10B981', '#F59E0B',
'#EF4444', '#8B5CF6', '#EC4899', '#14B8A6', '#F97316'];
return $palette[abs(crc32($s)) % count($palette)];
}
public static function slugify(string $s): string
{
$s = trim($s);
if (preg_match('/^[a-z0-9\-]+$/i', $s)) {
return strtolower($s);
}
return 'g' . substr(md5($s), 0, 6);
}
/** 颜色白名单:只接受 #rrggbb,其余一律回退(防手改表单塞进 CSS 注入) */
public static function safeColor(string $c, string $fallback): string
{
return preg_match('/^#[0-9a-fA-F]{6}$/', trim($c)) ? strtolower(trim($c)) : $fallback;
}
/**
* 图标显示地址(带版本号)。
*
* ⚠️ 版本号不能省:图标被重新抓取后**文件名不变**,没有版本号浏览器会一直用旧图 ——
* 用户点了"更新图标"却看不到变化,会以为功能坏了(SimpleNav 原文记过这一点)。
*/
public static function iconUrl(string $file): string
{
if ($file === '') {
return '';
}
$abs = FB_ROOT . '/' . self::ICON_DIR . '/' . $file;
if (!is_file($abs)) {
return '';
}
return url('/' . self::ICON_URL . '/' . rawurlencode($file)) . '?v=' . filemtime($abs);
}
public static function deleteIconFile(string $file): void
{
if ($file === '' || strpos($file, '/') !== false || strpos($file, '..') !== false) {
return;
}
$abs = FB_ROOT . '/' . self::ICON_DIR . '/' . $file;
if (is_file($abs)) {
@unlink($abs);
}
}
public static function iconDirWritable(): bool
{
$dir = FB_ROOT . '/' . self::ICON_DIR;
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
return is_dir($dir) && is_writable($dir);
}
/** 打开方式:_blank(新标签页,默认)或 _self */
public static function openTarget(int $personId): string
{
return self::setting($personId, 'open_blank', '1') === '1' ? '_blank' : '_self';
}
/** 联想词按键(照搬 SimpleNav 的白名单回退) */
public static function suggestKeys(int $personId): string
{
$v = self::setting($personId, 'suggest_keys', 'both');
return in_array($v, ['arrows', 'punct', 'both'], true) ? $v : 'both';
}
/** 合集布局(tabs / flat) */
public static function collectionsLayout(int $personId): string
{
return self::setting($personId, 'collections_layout', 'tabs') === 'flat' ? 'flat' : 'tabs';
}
/**
* **首次进入时**给这个人一份默认导航(照搬 SimpleNav 的种子数据,精选了一部分)。
*
* ⚠️ 只在"这个人一条板块都没有"时调 —— 否则用户把板块删光后,
* 下一次进页面又会冒出默认的那几块("我删了它怎么又回来了")。
*/
public static function ensureSeed(int $personId): void
{
$st = Database::get()->prepare('SELECT COUNT(*) FROM nav_groups WHERE person_id = ?');
$st->execute([$personId]);
if ((int)$st->fetchColumn() > 0) {
return;
}
$db = Database::get();
$now = date('Y-m-d H:i:s');
$gs = $db->prepare(
'INSERT INTO nav_groups (person_id, name, slug, display, sort_order, is_visible) VALUES (?,?,?,?,?,1)');
$ss = $db->prepare(
'INSERT INTO nav_sites (person_id, group_id, url, name, short_name, icon, icon_custom,
sort_order, is_visible, created_at, updated_at) VALUES (?,?,?,?,?,\'\',0,?,1,?,?)');
$i = 0;
foreach (self::SEED as $row) {
[$gname, $display, $sites] = $row;
$i++;
$gs->execute([$personId, $gname, self::slugify($gname), $display, $i * self::SORT_STEP]);
$gid = (int)$db->lastInsertId();
$j = 0;
foreach ($sites as $site) {
$j++;
$ss->execute([$personId, $gid, $site[1], $site[0], $site[0],
$j * self::SORT_STEP, $now, $now]);
}
}
// 默认搜索引擎(照搬 SimpleNav 的引擎表:必应/百度启用,其余列出但未启用)
$es = $db->prepare(
'INSERT INTO nav_engines (person_id, name, slug, search_url, param, suggest_url,
suggest_type, color, is_active, sort_order) VALUES (?,?,?,?,?,?,?,?,?,?)');
$k = 0;
foreach (self::ENGINE_SEED as $e) {
$k++;
$es->execute([$personId, $e[0], $e[1], $e[2], $e[3], $e[4], $e[5], $e[6], $e[7],
$k * self::SORT_STEP]);
}
}
/** 默认板块(照搬 SimpleNav 的 seed_groups_and_sites,去掉纯广告位,保留通用工具) */
public const SEED = [
['常用', 'grid', [
['GitHub', 'https://github.com/'],
['阿里云', 'https://www.aliyun.com/'],
['知乎', 'https://www.zhihu.com/'],
['豆瓣', 'https://www.douban.com/'],
['少数派', 'https://sspai.com/'],
['bilibili', 'https://www.bilibili.com/'],
]],
['工具', 'grid', [
['腾讯文档', 'https://docs.qq.com/'],
['飞书', 'https://www.feishu.cn/'],
['Gitee', 'https://gitee.com/'],
['百度网盘', 'https://pan.baidu.com/'],
['draw.io', 'https://app.diagrams.net/'],
['Bitwarden', 'https://bitwarden.com/'],
['高德地图', 'https://gaode.com/'],
['微信读书', 'https://weread.qq.com/'],
]],
['常用软件', 'tabs', [
['WPS', 'https://www.wps.cn/'],
['VS Code', 'https://code.visualstudio.com/'],
['7-Zip', 'https://sparanoid.com/lab/7z/'],
['Everything', 'https://www.voidtools.com/zh-cn/'],
['DBeaver', 'https://github.com/dbeaver/dbeaver'],
['VLC', 'https://www.videolan.org/vlc/'],
['Zotero', 'https://www.zotero.org/'],
]],
['图片资源', 'tabs', [
['图标库', 'https://www.iconfont.cn/'],
['插画库', 'https://undraw.co/illustrations'],
['Pixabay', 'https://pixabay.com/zh/'],
['Pexels', 'https://www.pexels.com/'],
]],
['科研论文', 'tabs', [
['DeepL翻译', 'https://www.deepl.com/'],
['Semantic Scholar', 'https://www.semanticscholar.org/'],
['LaTeX Table', 'https://www.tablesgenerator.com/'],
['学堂在线', 'https://www.xuetangx.com/'],
]],
];
/** 默认搜索引擎(照搬 SimpleNav 的 seed_engines) */
public const ENGINE_SEED = [
// 名称, slug, 搜索地址, 参数, 联想地址, 联想类型, 颜色, 是否启用
['必应', 'bing', 'https://cn.bing.com/search', 'q',
'https://api.bing.com/osjson.aspx?query={q}', 'bing_osjson', '#60A504', 1],
['百度', 'baidu', 'https://www.baidu.com/s', 'wd',
'https://www.baidu.com/sugrec?prod=pc&wd={q}', 'baidu_sugrec', '#2100E0', 1],
['谷歌', 'google', 'https://www.google.com/search', 'q', '', '', '#3B83FA', 0],
['搜狗', 'sogou', 'https://www.sogou.com/web', 'query', '', '', '#FE6B14', 0],
['360', 'so360', 'https://www.so.com/s', 'q',
'https://sug.so.360.cn/suggest?encodein=utf-8&encodeout=utf-8&format=json&word={q}',
'so360', '#1BB04A', 0],
['DuckGo', 'ddg', 'https://duckduckgo.com/', 'q', '', '', '#DE5833', 0],
];
}
+894
View File
@@ -0,0 +1,894 @@
<?php
/**
* 导航的「网站元信息与图标抓取」(v24 第 24 批 用户第 4 条)——
* **从用户自己的 SimpleNav 项目整段搬运**,逻辑一行未改,只做了三处机械改写:
*
* ① 自由函数 → `final class NavFetch` 的私有静态方法(家书是类的风格);
* ② 常量 `ICON_PATH` / `ICON_URL` → `Nav::ICON_DIR` / `Nav::ICON_URL`;
* ③ 与家书**重名**的几个(`e` / `norm_url` / `domain_of` / `letter_color`)改调
* `Nav::` 上的同名方法 —— 不搬一份进来,否则函数重定义直接 Fatal。
*
* ⚠️ 原文里那些"线上实测"的注释**全部保留**(例如"哪几个第三方图标服务可达、
* 哪几个不可达")。它们是这个子系统里最贵的部分 —— 重踩一遍要花一整轮。
*
* ⚠️ 这一批方法是**要联网的**(每个站点一次 HTTP + 可能多次图标下载)。
* 所以只在用户**主动点「抓取」/「全部更新」**时调,页面渲染绝不调。
*/
declare(strict_types=1);
namespace App;
final class NavFetch
{
/** 第三方图标聚合服务开关(原项目是后台设置 icon_services) */
private static bool $iconServices = true;
public static function setIconServices(bool $on): void
{
self::$iconServices = $on;
}
private static function iconServices(): string
{
return self::$iconServices ? '1' : '0';
}
/** HTTP 抓取(带 UA / 超时 / 跟随跳转)—— 供外部复用 */
public static function get(string $url, int $maxBytes = 300000, int $timeout = 8): ?array
{
return self::http_get($url, $maxBytes, $timeout);
}
/**
* 抓一个站点的元信息(标题 / 名称 + 图标候选)—— 对外的唯一入口。
*
* 搬进来的原函数叫 `fetch_site_meta()`,这里只是换一个符合家书命名风格的
* public 名字,逻辑一行没动。
*
* @return array{title:string,name:string,icon:string,icons:string[],final_url:string}
*/
public static function meta(string $url): array
{
return self::fetch_site_meta($url);
}
/**
* 抓取并**落盘**一个站点的图标,返回文件名(失败返回 '')。
*
* 这是 SimpleNav「抓图标」那段业务逻辑的落点:按清晰度依次尝试候选
* (manifest 大图 → apple-touch → link[rel=icon] → 第三方聚合 → /favicon.ico),
* 内部就是 `download_best_icon()` —— 逐个下载、挑最清晰的一张、存成统一尺寸。
*
* @param array $meta self::meta() 的返回值
* @param string $url 站点地址(文件名 = md5(url),与 SimpleNav 同规则)
* @param bool $allowThirdParty 允许用第三方图标聚合服务
* (会让这一步多发几次外网请求;哪些可达见
* third_party_icon_urls 里那批实测注释)
*/
public static function fetchIcon(array $meta, string $url, bool $allowThirdParty = true): string
{
self::$iconServices = $allowThirdParty;
$key = self::icon_key($url);
if ($key === '') {
return '';
}
$dir = FB_ROOT . '/' . Nav::ICON_DIR;
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
return self::download_best_icon((array)($meta['icons'] ?? []), $key);
}
/**
* 用户自传的图标二进制落盘,返回文件名。
*
* ⚠️ 落盘后调用方**必须**把 `nav_sites.icon_custom` 置 1 ——
* 否则下一次「全部更新」会把用户精心挑的图标冲掉(SimpleNav 原文明确要求)。
*/
public static function saveUploaded(string $binary, string $url): string
{
return self::save_custom_icon($binary, self::icon_key($url));
}
/** curl 抓取(带 UA、超时、跟随跳转) */
private static function http_get(string $url, int $maxBytes = 300000, int $timeout = 8): ?array
{
if (!function_exists('curl_init')) {
// 兜底:allow_url_fopen
$ctx = stream_context_create(['http' => [
'timeout' => $timeout,
'header' => "User-Agent: Mozilla/5.0 SimpleNav\r\n",
'follow_location' => 1,
]]);
$body = @file_get_contents($url, false, $ctx, 0, $maxBytes);
if ($body === false) {
return null;
}
return ['body' => $body, 'url' => $url, 'type' => ''];
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36',
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => 0,
CURLOPT_ENCODING => '',
CURLOPT_HTTPHEADER => ['Accept-Language: zh-CN,zh;q=0.9,en;q=0.8'],
]);
$body = curl_exec($ch);
$type = (string)curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
$eff = (string)curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
$code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$err = curl_errno($ch);
curl_close($ch);
if ($err || $body === false || $code >= 400) {
return null;
}
return ['body' => $body, 'url' => $eff ?: $url, 'type' => $type];
}
/* =========================================================================
* 元信息抓取:标题 / 名称 / 图标候选
* ========================================================================= */
/**
* 抓取网站元信息。
* icons 为「按清晰度从高到低」排好的候选图标地址列表:
* manifest 大图标(512/192) → apple-touch-icon(180) → link[rel=icon](按 sizes 降序)→ /favicon.ico
*
* @return array{title:string,name:string,icon:string,icons:string[],final_url:string}
*/
/**
* 抓取网站元信息。
* icons 为「按清晰度从高到低」排好的候选图标地址列表:
* manifest 大图标(512/192) → apple-touch-icon(180) → link[rel=icon](按 sizes 降序)→ /favicon.ico
*
* @return array{title:string,name:string,icon:string,icons:string[],final_url:string}
*/
private static function fetch_site_meta(string $url): array
{
$url = Nav::normUrl($url);
$out = ['title' => '', 'name' => '', 'icon' => '', 'icons' => [], 'final_url' => $url];
$resp = http_get($url);
if ($resp === null) {
$out['icons'] = array_merge(third_party_icon_urls($url), default_icon_candidates($url));
$out['icon'] = $out['icons'][0] ?? '';
return $out;
}
$html = ensure_utf8((string)$resp['body']);
$final = $resp['url'] !== '' ? $resp['url'] : $url;
$out['final_url'] = $final;
// ---- 标题 / 名称 ----
if (preg_match('#<title[^>]*>(.*?)</title>#is', $html, $m)) {
$out['title'] = trim(preg_replace('/\s+/u', ' ', html_entity_decode($m[1], ENT_QUOTES, 'UTF-8')));
}
$out['name'] = meta_content($html, 'og:site_name');
if ($out['name'] === '' && $out['title'] !== '') {
// 用标题的首段(按分隔符切分)作为名称
$seg = preg_split('#[\|\-_—·::]#u', $out['title']);
$out['name'] = trim($seg[0] ?? '');
}
// ---- 图标候选(按清晰度从高到低)----
// 1. manifest 里的大图标(常见 512/192)
// 2. apple-touch-icon(常见 180)
// 3. <link rel="icon">,按 sizes 降序(SVG 视为最高清)
// 4. 联网搜索:第三方图标聚合服务(仅当上面都没拿到高清图时才会用到)
// 5. 兜底:站点自身 /favicon.ico
[$links, $manifestUrl] = parse_icon_links($html, $final);
$icons = [];
if ($manifestUrl !== '') {
foreach (manifest_icons($manifestUrl) as $u) {
$icons[] = $u;
}
}
foreach ($links as $u) {
$icons[] = $u;
}
foreach (third_party_icon_urls($final) as $u) {
$icons[] = $u;
}
foreach (default_icon_candidates($final) as $u) {
$icons[] = $u;
}
$out['icons'] = array_values(array_unique(array_filter($icons, static function ($u) {
return is_string($u) && preg_match('#^https?://#i', $u);
})));
$out['icon'] = $out['icons'][0] ?? '';
return $out;
}
/**
* 联网搜索高清图标的第三方聚合服务。
* 服务器实测:icon.horse 可达(返回 256×256 PNG)、favicon.yandex.net 可达(120×120 PNG);
* 而 favicon.im / api.iowen.cn / favicon.cccyun.cc / Google s2 / DuckDuckGo 均不可达。
* 可在后台「系统设置」里用 icon_services 关闭。
*/
/**
* 联网搜索高清图标的第三方聚合服务。
* 服务器实测:icon.horse 可达(返回 256×256 PNG)、favicon.yandex.net 可达(120×120 PNG);
* 而 favicon.im / api.iowen.cn / favicon.cccyun.cc / Google s2 / DuckDuckGo 均不可达。
* 可在后台「系统设置」里用 icon_services 关闭。
*/
private static function third_party_icon_urls(string $url): array
{
if (self::iconServices() !== '1') {
return [];
}
$host = parse_url(Nav::normUrl($url), PHP_URL_HOST);
if (!$host) {
return [];
}
$host = strtolower($host);
return [
'https://favicon.yandex.net/favicon/' . $host . '?size=120', // 实测 100% 可用,120×120
'https://icon.horse/icon/' . $host, // 可达时最高 256×256,偶有超时
];
}
/** 取 <meta property|name|itemprop="key" content="..."> 的值(属性顺序无关) */
/** 取 <meta property|name|itemprop="key" content="..."> 的值(属性顺序无关) */
private static function meta_content(string $html, string $key): string
{
if (!preg_match_all('#<meta\b[^>]*>#i', $html, $tags)) {
return '';
}
$k = preg_quote($key, '#');
foreach ($tags[0] as $t) {
if (!preg_match('#\b(?:property|name|itemprop)\s*=\s*(?:"' . $k . '"|\'' . $k . '\'|' . $k . '[\s/>])#i', $t)) {
continue;
}
if (preg_match('#\bcontent\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+))#i', $t, $cm)) {
$v = $cm[1] !== '' ? $cm[1] : ($cm[2] !== '' ? $cm[2] : ($cm[3] ?? ''));
return trim(html_entity_decode($v, ENT_QUOTES, 'UTF-8'));
}
}
return '';
}
/**
* 解析页面里的 <link> 图标声明。
* @return array{0:string[],1:string} [候选 URL(高优先级在前), manifest URL]
*/
/**
* 解析页面里的 <link> 图标声明。
* @return array{0:string[],1:string} [候选 URL(高优先级在前), manifest URL]
*/
private static function parse_icon_links(string $html, string $base): array
{
$apple = [];
$rel = [];
$manifest = '';
if (preg_match_all('#<link\b[^>]*>#i', $html, $tags)) {
foreach ($tags[0] as $tag) {
$relv = attr_value($tag, 'rel');
if ($relv === '') {
continue;
}
$relv = strtolower($relv);
$href = html_entity_decode(attr_value($tag, 'href'), ENT_QUOTES, 'UTF-8');
if ($href === '') {
continue;
}
if (strpos($relv, 'apple-touch-icon') !== false) {
$apple[] = $href;
continue;
}
if (strpos($relv, 'manifest') !== false) {
if ($manifest === '') { $manifest = $href; }
continue;
}
if (strpos($relv, 'icon') === false) {
continue;
}
$size = 0;
$raw = attr_value($tag, 'sizes');
if ($raw !== '') {
foreach (preg_split('/[\s,]+/', trim($raw)) ?: [] as $one) {
if (stripos($one, 'any') !== false) {
$size = max($size, 256);
} elseif (preg_match('/^(\d+)\s*x\s*(\d+)$/i', $one, $gm)) {
$size = max($size, (int)$gm[1], (int)$gm[2]);
}
}
}
// SVG 是矢量,视为最高清
if (preg_match('#\.svg(\?|$)#i', $href) || stripos(attr_value($tag, 'type'), 'svg') !== false) {
$size = max($size, 512);
}
$rel[] = ['size' => $size, 'url' => $href];
}
}
usort($rel, static function ($a, $b) { return $b['size'] <=> $a['size']; });
$list = [];
foreach ($apple as $h) { $list[] = absolutize($h, $base); }
foreach ($rel as $r) { $list[] = absolutize($r['url'], $base); }
return [$list, $manifest === '' ? '' : absolutize($manifest, $base)];
}
/** 读取 web app manifest,返回其中图标(按尺寸降序) */
/** 读取 web app manifest,返回其中图标(按尺寸降序) */
private static function manifest_icons(string $manifestUrl): array
{
$r = http_get($manifestUrl, 200000, 6);
if ($r === null) {
return [];
}
$json = json_decode(trim((string)$r['body']), true);
if (!is_array($json) || empty($json['icons']) || !is_array($json['icons'])) {
return [];
}
$base = $r['url'] !== '' ? $r['url'] : $manifestUrl;
$list = [];
foreach ($json['icons'] as $ic) {
if (empty($ic['src']) || !is_string($ic['src'])) {
continue;
}
$size = 0;
if (!empty($ic['sizes'])) {
foreach (preg_split('/[\s,]+/', (string)$ic['sizes']) ?: [] as $one) {
if (stripos($one, 'any') !== false) {
$size = max($size, 512);
} elseif (preg_match('/^(\d+)\s*x\s*(\d+)$/i', $one, $gm)) {
$size = max($size, (int)$gm[1], (int)$gm[2]);
}
}
}
$list[] = ['size' => $size, 'url' => absolutize($ic['src'], $base)];
}
usort($list, static function ($a, $b) { return $b['size'] <=> $a['size']; });
return array_values(array_unique(array_column($list, 'url')));
}
/** 兜底候选:站点自身 favicon.ico */
/** 兜底候选:站点自身 favicon.ico */
private static function default_icon_candidates(string $url): array
{
$p = parse_url(Nav::normUrl($url));
if (empty($p['host'])) {
return [];
}
$scheme = !empty($p['scheme']) ? $p['scheme'] : 'https';
return [$scheme . '://' . $p['host'] . '/favicon.ico'];
}
/** 从标签串里取属性值(支持双引号 / 单引号 / 裸值) */
/** 从标签串里取属性值(支持双引号 / 单引号 / 裸值) */
private static function attr_value(string $tag, string $attr): string
{
$a = preg_quote($attr, '#');
if (preg_match('#\b' . $a . '\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+))#i', $tag, $m)) {
$v = $m[1] !== '' ? $m[1] : ($m[2] !== '' ? $m[2] : ($m[3] ?? ''));
return trim($v);
}
return '';
}
/* =========================================================================
* 图标下载与规范化
* ========================================================================= */
/**
* 图标文件的命名键:**按整条网址**算,而不是只按域名。
*
* 为什么不能只用域名(2026-09-28 修的真实 bug):
* 同一域名下不同路径往往是两个独立的网站条目,例如
* https://nas.example.com/calendar 与 https://nas.example.com/
* 若键只取域名,两条记录会指向**同一个图标文件** —— 改其中一个的自定义图标,
* 另一个跟着变;更糟的是保存时会 `unlink` 那个文件名,直接把对方**搞成破图**。
*
* 归一化规则(尽量少,只抹掉「同一个网址的不同写法」带来的差异):
* - 协议与主机名转小写;主机名**不**去 www(`www.a.com` 与 `a.com` 是两个条目)
* - 空路径补成 `/`(`https://a.com` 与 `https://a.com/` 视为同一个)
* - 查询串、锚点(`#/calendar` 这类 SPA 路由)**保留**,它们是区分条目的信息
* 一句话:**不同网址 → 不同图标文件**。
*
* 注意:调用方要用「记录里存的那条 URL」来算,不要用跳转后的 final_url,
* 否则同一个网站在不同次请求里会算出不同的键(每次重下 + 留下孤儿文件)。
*
* 返回的是键(md5 十六进制串);实际文件名会再哈希一次(历史遗留,只是让文件名长度固定,
* 不影响任何行为)。
*/
/**
* 图标文件的命名键:**按整条网址**算,而不是只按域名。
*
* 为什么不能只用域名(2026-09-28 修的真实 bug):
* 同一域名下不同路径往往是两个独立的网站条目,例如
* https://nas.example.com/calendar 与 https://nas.example.com/
* 若键只取域名,两条记录会指向**同一个图标文件** —— 改其中一个的自定义图标,
* 另一个跟着变;更糟的是保存时会 `unlink` 那个文件名,直接把对方**搞成破图**。
*
* 归一化规则(尽量少,只抹掉「同一个网址的不同写法」带来的差异):
* - 协议与主机名转小写;主机名**不**去 www(`www.a.com` 与 `a.com` 是两个条目)
* - 空路径补成 `/`(`https://a.com` 与 `https://a.com/` 视为同一个)
* - 查询串、锚点(`#/calendar` 这类 SPA 路由)**保留**,它们是区分条目的信息
* 一句话:**不同网址 → 不同图标文件**。
*
* 注意:调用方要用「记录里存的那条 URL」来算,不要用跳转后的 final_url,
* 否则同一个网站在不同次请求里会算出不同的键(每次重下 + 留下孤儿文件)。
*
* 返回的是键(md5 十六进制串);实际文件名会再哈希一次(历史遗留,只是让文件名长度固定,
* 不影响任何行为)。
*/
private static function icon_key(string $url): string
{
$u = trim($url);
if ($u === '') {
return '';
}
if (!preg_match('#^https?://#i', $u)) {
$u = 'http://' . $u;
}
$p = @parse_url($u);
if (!is_array($p) || empty($p['host'])) {
return md5($u); // 解析不出来就整串兜底,至少不会跟别人撞
}
$norm = strtolower((string)$p['scheme']) . '://'
. strtolower((string)$p['host'])
. (empty($p['port']) ? '' : ':' . $p['port'])
. (isset($p['path']) && $p['path'] !== '' ? $p['path'] : '/')
. (isset($p['query']) ? '?' . $p['query'] : '')
. (isset($p['fragment']) ? '#' . $p['fragment'] : '');
return md5($norm);
}
/**
* 按候选顺序抓取图标,返回质量最佳的一张的本地文件名(失败返回 '')。
* 规则:min(宽,高) 越大越优;达到 $minGood 即停止;另有总耗时预算,避免批量更新超时。
*
* @param string[] $urls 候选图标地址(已按优先级排序)
* @param string $key 图标文件的命名键,**请传 icon_key($url)**(不要传域名)
*/
/**
* 按候选顺序抓取图标,返回质量最佳的一张的本地文件名(失败返回 '')。
* 规则:min(宽,高) 越大越优;达到 $minGood 即停止;另有总耗时预算,避免批量更新超时。
*
* @param string[] $urls 候选图标地址(已按优先级排序)
* @param string $key 图标文件的命名键,**请传 icon_key($url)**(不要传域名)
*/
private static function download_best_icon(array $urls, string $key, int $minGood = 120, float $budget = 10.0): string
{
if ($key === '') { // 没有键就不写文件,避免全部落到 md5('') 上互相覆盖
return '';
}
if (!is_dir(FB_ROOT . "/" . Nav::ICON_DIR)) {
@mkdir(FB_ROOT . "/" . Nav::ICON_DIR, 0755, true);
}
$best = null;
$bestScore = -1;
$tmps = [];
$tried = 0;
$deadline = microtime(true) + $budget;
foreach ($urls as $u) {
if (!is_string($u) || $u === '' || !preg_match('#^https?://#i', $u)) {
continue;
}
if ($tried >= 8 || microtime(true) > $deadline) { // 候选上限 + 耗时预算
break;
}
$tried++;
$r = grab_icon($u);
if ($r === null) {
continue;
}
// 第三方聚合服务在「查不到图标」时会返回一块浅灰底 + 单个深灰字母的生成图,
// 那还不如本地首字母块好看 —— 识别出来就丢弃,继续回退到站点自己的图标。
if ($r['ext'] === 'png' && is_third_party_icon($u) && looks_generated_tile($r['tmp'])) {
@unlink($r['tmp']);
continue;
}
$tmps[] = $r['tmp'];
$score = min($r['w'], $r['h']);
if ($score > $bestScore) {
$bestScore = $score;
$best = $r;
}
if ($score >= $minGood) {
break;
}
}
// 清理落选临时文件
foreach ($tmps as $t) {
if (!$best || $t !== $best['tmp']) {
@unlink($t);
}
}
if ($best === null) {
return '';
}
$md5 = md5($key);
$file = $md5 . '.' . $best['ext'];
$dest = FB_ROOT . "/" . Nav::ICON_DIR . '/' . $file;
@unlink(FB_ROOT . "/" . Nav::ICON_DIR . '/' . $md5 . '.png');
@unlink(FB_ROOT . "/" . Nav::ICON_DIR . '/' . $md5 . '.svg');
if (!@copy($best['tmp'], $dest)) {
@unlink($best['tmp']);
return '';
}
@unlink($best['tmp']);
return is_file($dest) ? $file : '';
}
/** 是否来自第三方图标聚合服务(这类服务可能返回「生成的占位图」) */
/** 是否来自第三方图标聚合服务(这类服务可能返回「生成的占位图」) */
private static function is_third_party_icon(string $url): bool
{
$host = strtolower((string)parse_url($url, PHP_URL_HOST));
return $host === 'favicon.yandex.net' || $host === 'icon.horse';
}
/**
* 判断图片是否是「浅灰底 + 深灰字母」的自动生成占位图。
* 采样不透明像素:若几乎无色(色度极小)、整体偏亮、且八成以上像素都是同一片浅色底面,
* 就认定它是占位图。彩色 logo 与深色 logo 都不会命中。
*/
/**
* 判断图片是否是「浅灰底 + 深灰字母」的自动生成占位图。
* 采样不透明像素:若几乎无色(色度极小)、整体偏亮、且八成以上像素都是同一片浅色底面,
* 就认定它是占位图。彩色 logo 与深色 logo 都不会命中。
*/
private static function looks_generated_tile(string $file): bool
{
$im = @imagecreatefromstring((string)@file_get_contents($file));
if ($im === false) {
return false;
}
$w = imagesx($im);
$h = imagesy($im);
$sx = max(1, (int)($w / 28));
$sy = max(1, (int)($h / 28));
$maxChroma = 0;
$n = 0;
$light = 0;
$sum = 0.0;
for ($y = 0; $y < $h; $y += $sy) {
for ($x = 0; $x < $w; $x += $sx) {
$rgba = imagecolorat($im, $x, $y);
if ((($rgba >> 24) & 0x7F) > 110) { // 透明像素跳过
continue;
}
$r = ($rgba >> 16) & 0xFF;
$g = ($rgba >> 8) & 0xFF;
$b = $rgba & 0xFF;
$chroma = max($r, $g, $b) - min($r, $g, $b);
if ($chroma > $maxChroma) { $maxChroma = $chroma; }
$v = ($r + $g + $b) / 3;
$sum += $v;
if ($v >= 195 && $chroma < 16) { $light++; }
$n++;
}
}
imagedestroy($im);
if ($n < 24) {
return false;
}
return $maxChroma < 16 && ($sum / $n) > 190 && ($light / $n) >= 0.80;
}
/**
* 下载单个图标到临时文件。
* @return array{tmp:string,ext:string,w:int,h:int}|null w/h 为「原始」尺寸,用于择优
*/
/**
* 下载单个图标到临时文件。
* @return array{tmp:string,ext:string,w:int,h:int}|null w/h 为「原始」尺寸,用于择优
*/
private static function grab_icon(string $iconUrl): ?array
{
$resp = http_get($iconUrl, 2 * 1024 * 1024, 5);
if ($resp === null) {
return null;
}
$bin = (string)$resp['body'];
if (strlen($bin) < 64 || strlen($bin) > 3 * 1024 * 1024) {
return null;
}
$tmp = @tempnam(sys_get_temp_dir(), 'snic');
if ($tmp === false) {
return null;
}
// ---- SVG:直接保存(矢量最清晰),挡掉带脚本的 ----
$head = ltrim(substr($bin, 0, 1024));
$isSvg = stripos((string)$resp['type'], 'svg') !== false
|| stripos($head, '<svg') !== false
|| strpos($head, '<?xml') === 0;
if ($isSvg) {
if (strlen($bin) > 512 * 1024
|| preg_match('#<script|<foreignObject|onload\s*=|javascript:#i', $bin)) {
@unlink($tmp);
return null;
}
if (@file_put_contents($tmp, $bin) === false) {
@unlink($tmp);
return null;
}
return ['tmp' => $tmp, 'ext' => 'svg', 'w' => 512, 'h' => 512];
}
// ---- 位图:GD 归一化为 128×128 PNG(保持比例、透明留白) ----
if (!function_exists('imagecreatefromstring')) {
@unlink($tmp);
return null;
}
$im = @imagecreatefromstring($bin);
if ($im === false) {
@unlink($tmp);
return null;
}
$w = imagesx($im);
$h = imagesy($im);
if ($w < 8 || $h < 8) { // 太小/损坏,判为无效
imagedestroy($im);
@unlink($tmp);
return null;
}
$store = ICON_STORE;
$canvas = imagecreatetruecolor($store, $store);
imagealphablending($canvas, false);
imagesavealpha($canvas, true);
imagefill($canvas, 0, 0, imagecolorallocatealpha($canvas, 0, 0, 0, 127));
$scale = min($store / $w, $store / $h, 1.0); // 只缩不放,避免把小图硬拉大而变糊
$nw = max(1, (int)round($w * $scale));
$nh = max(1, (int)round($h * $scale));
imagecopyresampled(
$canvas, $im,
(int)(($store - $nw) / 2), (int)(($store - $nh) / 2), 0, 0,
$nw, $nh, $w, $h
);
imagealphablending($canvas, true);
$ok = imagepng($canvas, $tmp, 6);
imagedestroy($canvas);
imagedestroy($im);
if ($ok === false) {
@unlink($tmp);
return null;
}
return ['tmp' => $tmp, 'ext' => 'png', 'w' => $w, 'h' => $h];
}
/**
* 保存用户上传/指定的自定义图标(文件内容直接给),返回本地文件名。
*
* @param string $key 图标文件的命名键,**请传 icon_key($url)**(不要传域名)
*/
/**
* 保存用户上传/指定的自定义图标(文件内容直接给),返回本地文件名。
*
* @param string $key 图标文件的命名键,**请传 icon_key($url)**(不要传域名)
*/
private static function save_custom_icon(string $binary, string $key): string
{
if ($key === '') {
return '';
}
if (!is_dir(FB_ROOT . "/" . Nav::ICON_DIR)) {
@mkdir(FB_ROOT . "/" . Nav::ICON_DIR, 0755, true);
}
$mime = '';
if (function_exists('finfo_open')) {
$fi = finfo_open(FILEINFO_MIME_TYPE);
$mime = (string)finfo_buffer($fi, $binary);
finfo_close($fi);
}
if (strpos($mime, 'svg') !== false || stripos(ltrim(substr($binary, 0, 1024)), '<svg') !== false) {
if (preg_match('#<script|<foreignObject|onload\s*=|javascript:#i', $binary)) {
return '';
}
$md5 = md5($key);
$file = $md5 . '.svg';
@unlink(FB_ROOT . "/" . Nav::ICON_DIR . '/' . $md5 . '.png');
@file_put_contents(FB_ROOT . "/" . Nav::ICON_DIR . '/' . $file, $binary);
return is_file(FB_ROOT . "/" . Nav::ICON_DIR . '/' . $file) ? $file : '';
}
if (!function_exists('imagecreatefromstring')) {
return '';
}
$im = @imagecreatefromstring($binary);
if ($im === false) {
return '';
}
$w = imagesx($im);
$h = imagesy($im);
if ($w < 8 || $h < 8) {
imagedestroy($im);
return '';
}
$store = ICON_STORE;
$canvas = imagecreatetruecolor($store, $store);
imagealphablending($canvas, false);
imagesavealpha($canvas, true);
imagefill($canvas, 0, 0, imagecolorallocatealpha($canvas, 0, 0, 0, 127));
$scale = min($store / $w, $store / $h, 1.0); // 只缩不放,避免把小图硬拉大而变糊
$nw = max(1, (int)round($w * $scale));
$nh = max(1, (int)round($h * $scale));
imagecopyresampled($canvas, $im, (int)(($store - $nw) / 2), (int)(($store - $nh) / 2), 0, 0, $nw, $nh, $w, $h);
imagealphablending($canvas, true);
$md5 = md5($key);
$file = $md5 . '.png';
$ok = imagepng($canvas, FB_ROOT . "/" . Nav::ICON_DIR . '/' . $file, 6);
imagedestroy($canvas);
imagedestroy($im);
if ($ok === false) {
return '';
}
@unlink(FB_ROOT . "/" . Nav::ICON_DIR . '/' . $md5 . '.svg');
return $file;
}
/* =========================================================================
* 站点整体图标(浏览器标签页 favicon + 首页品牌图标)
*
* 用户上传一张图 → 服务端生成**一整套**尺寸,前台各处的写法都不变:
* 静态优先(有就直出文件,兼容「部署目录只读」的保守环境)
* → 没有静态再回退到动态输出(新上传立刻生效,无需给站点目录写权限)
* 「一整套」是必须的:标签页 favicon 与「添加到手机主屏」的 PWA 图标读的是不同文件,
* 只换其中一个 = 半生效(用户会看到标签页新图、主屏图标还是旧的)。
* ========================================================================= */
/** 自定义站点图标的存在性(唯一事实来源:上传成功后写的标记文件) */
/** 载入任意位图(png/jpg/gif/webp…)为 GD 资源,失败返回 null。
* ⚠️ 不要写 `?object` 返回类型:PHP 7.4 的 GD 返回的是 **resource**,加类型会直接 TypeError。 */
private static function gd_load(string $binary)
{
if (!function_exists('imagecreatefromstring')) {
return null;
}
$im = @imagecreatefromstring($binary);
return $im === false ? null : $im;
}
/** 把一张已载入的图居中裁剪成正方形并缩到 $size,返回 PNG 二进制(失败返回 '') */
/** 把一张已载入的图居中裁剪成正方形并缩到 $size,返回 PNG 二进制(失败返回 '') */
private static function gd_square_png($im, int $size): string
{
$w = imagesx($im);
$h = imagesy($im);
$side = min($w, $h);
$ox = (int)(($w - $side) / 2);
$oy = (int)(($h - $side) / 2);
$canvas = imagecreatetruecolor($size, $size);
imagealphablending($canvas, false);
imagesavealpha($canvas, true);
imagefill($canvas, 0, 0, imagecolorallocatealpha($canvas, 0, 0, 0, 127));
imagecopyresampled($canvas, $im, 0, 0, $ox, $oy, $size, $size, $side, $side);
imagealphablending($canvas, true);
ob_start();
$ok = imagepng($canvas, null, 9);
$png = (string)ob_get_clean();
imagedestroy($canvas);
return ($ok === false || $png === '') ? '' : $png;
}
/**
* 把任意图**居中裁剪成正方形**并生成整套尺寸。
*
* 为什么是「裁剪」而不是「留白」:站点图标是正方形用途(favicon / PWA 图标),
* 直接拉伸会把 logo 拉变形,留白则四周一圈透明、缩小后主体看不清。
*
* @return array<string,string> 文件基名 => 二进制(基名已含扩展名,如 'logo.png')
*/
/** 用 PNG 二进制拼一个 .ico(微软 BMP 格式,32 位带 alpha);$pngs: 尺寸 => PNG 二进制 */
private static function make_ico(array $pngs): string
{
$frames = [];
foreach ($pngs as $png) {
$im = @imagecreatefromstring($png);
if ($im === false) {
continue;
}
$size = (int)imagesx($im);
if ($size < 1 || $size > 256 || imagesy($im) !== $size) {
imagedestroy($im);
continue;
}
// BITMAPINFOHEADER 40 字节:biSize biWidth biHeight biPlanes biBitCount
// biCompression biSizeImage biXPels biYPels biClrUsed biClrImportant
// (高度写 2 倍:ICO 的 DIB 把 XOR 位图与 AND 掩码叠在一起算高度)
$header = pack('VVVvvVVVVVV', 40, $size, $size * 2, 1, 32, 0, 0, 0, 0, 0, 0);
$xor = '';
for ($y = $size - 1; $y >= 0; $y--) {
for ($x = 0; $x < $size; $x++) {
$rgba = imagecolorat($im, $x, $y);
$a = ($rgba >> 24) & 0x7F;
$xor .= chr($rgba & 0xFF) . chr(($rgba >> 8) & 0xFF)
. chr(($rgba >> 16) & 0xFF) . chr((int)round(255 - $a * 2.00787));
}
}
imagedestroy($im);
$and = str_repeat("\x00", (int)($size / 8) * $size); // 1bpp 掩码,全 0 = 全部可见
$frames[$size] = $header . $xor . $and;
}
if (!$frames) {
return '';
}
ksort($frames);
$n = count($frames);
$ico = pack('vvv', 0, 1, $n);
$offset = 6 + $n * 16;
foreach ($frames as $size => $data) {
// 宽高字节:256 要写成 0
$b = ($size >= 256) ? 0 : $size;
$ico .= pack('CCCCvvVV', $b, $b, 0, 0, 1, 32, strlen($data), $offset);
$offset += strlen($data);
}
foreach ($frames as $data) {
$ico .= $data;
}
return $ico;
}
/** 整套图标清单:文件基名 => 尺寸 */
/** 把可能为相对路径的 URL 补全为绝对 URL */
private static function absolutize(string $href, string $base): string
{
// 有些页面把链接写成 JSON 转义形式(https:\/\/x\/y.ico),先还原
$href = str_replace('\\/', '/', trim($href));
if (preg_match('#^https?://#i', $href)) {
return $href;
}
$p = parse_url($base);
$root = ($p['scheme'] ?? 'http') . '://' . ($p['host'] ?? '');
if (strpos($href, '//') === 0) {
return ($p['scheme'] ?? 'http') . ':' . $href;
}
if (strpos($href, '/') === 0) {
return $root . $href;
}
$dir = rtrim(dirname($p['path'] ?? '/'), '/');
return $root . $dir . '/' . $href;
}
/** 简单编码归一:识别 GBK/GB18030 并转 UTF-8 */
/** 简单编码归一:识别 GBK/GB18030 并转 UTF-8 */
private static function ensure_utf8(string $s): string
{
if (preg_match('#<meta[^>]+charset=["\']?\s*(gbk|gb2312|gb18030)#i', $s)) {
if (function_exists('mb_convert_encoding')) {
return mb_convert_encoding($s, 'UTF-8', 'GB18030');
}
}
if (function_exists('mb_check_encoding') && !mb_check_encoding($s, 'UTF-8')) {
return mb_convert_encoding($s, 'UTF-8', 'GB18030');
}
return $s;
}
/** 图标显示地址(为空时返回 '',前端回退到首字母块) */
}
+76 -1
View File
@@ -104,7 +104,7 @@ final class Schema
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
*/
public const VERSION = '23';
public const VERSION = '24';
/**
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
@@ -840,6 +840,81 @@ CREATE TABLE IF NOT EXISTS `trip_expenses` (
CONSTRAINT `fk_te_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_te_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='行程花销(一笔一行;旅程总花销 = 各行程之和,读时算)'
SQL,
/* ============================================================
v24 第 24 批 用户第 4 条:个人导航(从 SimpleNav 完整搬进来)
------------------------------------------------------------
用户原话:「你去读一下个人导航,就是我之前有一个个人导航的项目,
把这个项目完整的搬过来放在导航条里,就是说增加一下个人导航
的这个模块就放在读书的后面,每个用户都可以添加自己的导航,
所有的功能模块就完全照搬原来的导航的那个系统里面的功能。」
⚠️ 原 SimpleNav 用**独立 SQLite 文件 + 自己的 users 表**。搬进来必须换掉这两样:
· 数据库 → 用家书自己的库(表名 `nav_` 前缀,不会撞)
· 用户 → 用**家书的 person_id**(「每个用户都可以添加自己的导航」)
所以原来所有查询都多了一个 `person_id = ?` 维度 ——
这是"照搬"里**唯一不能照抄**的部分:不加这一维,全家人会共用同一份导航。
============================================================ */
<<<'SQL'
CREATE TABLE IF NOT EXISTS `nav_groups` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`person_id` int(10) unsigned NOT NULL COMMENT '这份导航属于谁(家书 person id)',
`name` VARCHAR(60) NOT NULL,
`slug` VARCHAR(60) NOT NULL DEFAULT '',
`display` ENUM('grid','tabs') NOT NULL DEFAULT 'grid' COMMENT 'grid=普通网格 tabs=合集标签页',
`sort_order` INT NOT NULL DEFAULT 0,
`is_visible` TINYINT(1) NOT NULL DEFAULT 1,
PRIMARY KEY (`id`),
KEY `idx_navg_person` (`person_id`, `sort_order`),
CONSTRAINT `fk_navg_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='导航板块(每人一份)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `nav_sites` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`person_id` int(10) unsigned NOT NULL COMMENT '冗余一份归属:删/校验不必再 JOIN 板块',
`group_id` int(10) unsigned NOT NULL,
`url` VARCHAR(500) NOT NULL,
`name` VARCHAR(120) NOT NULL DEFAULT '' COMMENT '自动抓取的网站名',
`short_name` VARCHAR(60) NOT NULL DEFAULT '' COMMENT '用户自定义简称',
`icon` VARCHAR(120) NOT NULL DEFAULT '' COMMENT '本地图标文件名',
`icon_custom` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1=自定义图标(「全部更新」不覆盖)',
`sort_order` INT NOT NULL DEFAULT 0,
`is_visible` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` DATETIME DEFAULT NULL,
`updated_at` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_navs_person` (`person_id`, `group_id`, `sort_order`),
CONSTRAINT `fk_navs_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_navs_group` FOREIGN KEY (`group_id`) REFERENCES `nav_groups` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='导航站点(每人一份)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `nav_engines` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`person_id` int(10) unsigned NOT NULL,
`name` VARCHAR(40) NOT NULL,
`slug` VARCHAR(40) NOT NULL,
`search_url` VARCHAR(300) NOT NULL,
`param` VARCHAR(20) NOT NULL DEFAULT 'q',
`suggest_url` VARCHAR(300) NOT NULL DEFAULT '',
`suggest_type` VARCHAR(30) NOT NULL DEFAULT '',
`color` VARCHAR(20) NOT NULL DEFAULT '#2F6BF7',
`is_active` TINYINT(1) NOT NULL DEFAULT 0,
`sort_order` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_nave_person` (`person_id`, `sort_order`),
CONSTRAINT `fk_nave_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='导航搜索引擎(每人一份)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `nav_settings` (
`person_id` int(10) unsigned NOT NULL,
`k` VARCHAR(60) NOT NULL,
`v` TEXT NOT NULL,
PRIMARY KEY (`person_id`, `k`),
CONSTRAINT `fk_navset_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='导航设置(每人一份)'
SQL,
];
}
+412
View File
@@ -0,0 +1,412 @@
<?php
/**
* 微信读书笔记导入(v24 第 24 批 用户第 5 条)。
*
* 用户原话:
* 「读书这个模块应该加上读书笔记的功能。另外你看一下能不能导入微信的读书笔记,
* 就是每一个人设置一下,比如说扫一下微信的这个码呀,或者什么之类的授权一下。
* 这样的话,每个人都可以把自己的微信读书的笔记给导入进来,放在我们这个读书的
* 模块里面,看看以什么样的形式去进行展示,比较合适。」
*
* ────────────────────────────────────────────────────────────────
* 用户后续补充「用微信读书 api key」—— 这个方向是对的,而且**2026 年官方已经开了**:
*
* 官方开放平台(微信读书 Skill):
* · 网关:`POST https://i.weread.qq.com/api/agent/gateway`
* · 认证:`Authorization: Bearer wrk-xxxxxxxx`(**API Key 绑定账号身份**)
* · Key 获取:https://weread.qq.com/r/weread-skills(扫码登录后一键生成)
* · 请求体:`{api_name, ...业务参数平铺..., skill_version}`(**不能包在 params 里**)
*
* 本类用到的三个接口(都来自官方 `notes.md`):
* · `/user/notebooks` —— 有笔记的书清单(游标分页,用 lastSort 翻页)
* · `/book/bookmarklist` —— 单本书的**划线**(markText + chapterUid + createTime)
* · `/review/list/mine` —— 单本书的**想法/点评**(content + abstract 原文 + chapterName)
* · `/book/chapterinfo` —— 章节表(把 chapterUid 换成章节标题)
*
* ⚠️ API Key **等价于账号凭据**(官方原话:绑定用户身份,泄露等于账号泄露)。
* 所以:
* ① 一律 **Crypto::encrypt 加密后落库**,明文只在那一次表单提交里存在;
* ② 界面上只回显**掩码**(wrk-****abcd),绝不回显完整 Key;
* ③ 与 DAV 口令同一套思路(那个也是"长期凭据",也是加密/只显示一次)。
*
* ⚠️ 这个类**只负责取数据**,不碰数据库 —— 落库交给 BookNoteImport(已有)。
* 这样"网络"与"存储"分开,测试可以只测解析、不必连外网。
*/
declare(strict_types=1);
namespace App;
final class WeRead
{
/** 官方 Agent 网关(见文件头) */
public const GATEWAY = 'https://i.weread.qq.com/api/agent/gateway';
/** 官方 Skill 版本号(每次请求必须带;对不上官方可能拒) */
public const SKILL_VERSION = '1.0.4';
/** Key 的形式:wrk- 开头 */
public const KEY_RE = '/^wrk-[A-Za-z0-9_-]{8,80}$/';
/** 用户设置里存加密 Key 的键名 */
public const SETTING_KEY = 'weread_api_key';
// ================================================================
// Key 的存取(加密)
// ================================================================
/** 用户填了原始 Key → 校验形式后加密落库。返回 ['ok'=>bool,'error'=>string] */
public static function saveKey(int $personId, string $rawKey): array
{
$rawKey = trim($rawKey);
if ($rawKey === '') {
// 清空 = 断开授权(保留"他曾经授权过"这件事没有必要)
UserSetting::set($personId, self::SETTING_KEY, '');
return ['ok' => true, 'error' => ''];
}
if (!preg_match(self::KEY_RE, $rawKey)) {
return ['ok' => false, 'error' => 'API Key 的格式不对(应该是 wrk- 开头的一串)。'];
}
// ⚠️ 加密后再落库 —— 它等价于账号凭据(见文件头)
UserSetting::set($personId, self::SETTING_KEY, Crypto::encrypt($rawKey));
return ['ok' => true, 'error' => ''];
}
/** 取出明文 Key(仅供本类发请求用;**绝不要**把它回显到页面上) */
public static function key(int $personId): string
{
$blob = trim((string)UserSetting::get($personId, self::SETTING_KEY, ''));
if ($blob === '') {
return '';
}
try {
return (string)Crypto::decrypt($blob);
} catch (\Throwable $e) {
// 换过 db/.settings.key 之后老密文解不开 —— 当成"没配",让用户重填
return '';
}
}
/** 界面上回显用的掩码(wrk-****abcd)—— 绝不回显完整 Key */
public static function maskedKey(int $personId): string
{
$k = self::key($personId);
if ($k === '') {
return '';
}
return substr($k, 0, 4) . str_repeat('*', 8) . substr($k, -4);
}
public static function hasKey(int $personId): bool
{
return self::key($personId) !== '';
}
// ================================================================
// 调官方网关
// ================================================================
/**
* 调一次官方网关。
*
* @return array{ok:bool,data:array,error:string}
*/
public static function call(int $personId, string $apiName, array $params = []): array
{
$key = self::key($personId);
if ($key === '') {
return ['ok' => false, 'data' => [], 'error' => '还没配置微信读书 API Key。'];
}
// ⚠️ 业务参数必须**平铺在顶层**,不能包进 params(官方文档专门列了这个反例)
$body = array_merge(['api_name' => $apiName], $params, ['skill_version' => self::SKILL_VERSION]);
$json = json_encode($body, JSON_UNESCAPED_UNICODE);
$ch = curl_init(self::GATEWAY);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $json,
CURLOPT_TIMEOUT => 20,
CURLOPT_CONNECTTIMEOUT => 8,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Content-Type: application/json',
'Accept: application/json',
],
]);
$raw = curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$err = (string)curl_error($ch);
curl_close($ch);
if ($raw === false || $err !== '') {
return ['ok' => false, 'data' => [], 'error' => '连不上微信读书网关:' . $err];
}
if ($code === 401 || $code === 403) {
return ['ok' => false, 'data' => [],
'error' => 'API Key 无效或已失效,请去微信读书开放平台重新生成一个。'];
}
if ($code >= 400) {
return ['ok' => false, 'data' => [], 'error' => '网关返回 HTTP ' . $code];
}
$j = json_decode((string)$raw, true);
if (!is_array($j)) {
return ['ok' => false, 'data' => [], 'error' => '网关返回的不是 JSON'];
}
// 有些错误是 200 + 业务错误码
if (isset($j['errcode']) && (int)$j['errcode'] !== 0) {
return ['ok' => false, 'data' => [], 'error' => '微信读书返回错误:'
. (string)($j['errmsg'] ?? ('errcode ' . $j['errcode']))];
}
return ['ok' => true, 'data' => $j, 'error' => ''];
}
// ================================================================
// 取数据
// ================================================================
/**
* 有笔记的书清单(**游标分页**:官方不支持 offset/limit)。
*
* @param int $maxBooks 最多取多少本(防止有人几百本书时一次跑太久)
* @return array{ok:bool,books:array,error:string}
* books[] = ['bookId','title','author','reviewCount','noteCount','bookmarkCount']
*/
public static function notebooks(int $personId, int $maxBooks = 200): array
{
$out = [];
$last = 0;
for ($page = 0; $page < 20; $page++) {
$params = ['count' => 50];
if ($last > 0) {
$params['lastSort'] = $last;
}
$r = self::call($personId, '/user/notebooks', $params);
if (!$r['ok']) {
return ['ok' => false, 'books' => $out, 'error' => $r['error']];
}
$books = (array)($r['data']['books'] ?? []);
foreach ($books as $b) {
$bk = (array)($b['book'] ?? []);
$out[] = [
'bookId' => (string)($b['bookId'] ?? ''),
'title' => (string)($bk['title'] ?? ''),
'author' => (string)($bk['author'] ?? ''),
'reviewCount' => (int)($b['reviewCount'] ?? 0),
'noteCount' => (int)($b['noteCount'] ?? 0),
'bookmarkCount' => (int)($b['bookmarkCount'] ?? 0),
'sort' => (int)($b['sort'] ?? 0),
];
if (count($out) >= $maxBooks) {
break 2;
}
}
// hasMore=1 时用**本页最后一项的 sort** 当下一页游标
if ((int)($r['data']['hasMore'] ?? 0) !== 1 || !$books) {
break;
}
$lastIdx = count($books) - 1;
$last = (int)($books[$lastIdx]['sort'] ?? 0);
if ($last <= 0) {
break;
}
}
return ['ok' => true, 'books' => $out, 'error' => ''];
}
/**
* 取一本书的**章节表**(chapterUid → 章节标题)。
*
* 官方把章节信息分散在 bookmarklist / bestbookmarks 的回包里,
* 但单独拉一次 `/book/chapterinfo` 更省事(一次拿全)。
*/
public static function chapters(int $personId, string $bookId): array
{
$r = self::call($personId, '/book/chapterinfo', ['bookId' => $bookId]);
if (!$r['ok']) {
return [];
}
$map = [];
foreach ((array)($r['data']['chapters'] ?? []) as $c) {
$uid = (string)($c['chapterUid'] ?? '');
if ($uid !== '') {
$map[$uid] = (string)($c['title'] ?? '');
}
}
return $map;
}
/**
* 取一本书的**划线与想法**,合成统一结构。
*
* @return array{ok:bool,items:array,error:string}
* items[] = [
* 'kind' => 'quote'(划线)/ 'note'(划线想法)/ 'review'(整本或章节点评),
* 'content' => 想法正文(划线时为空),
* 'quote' => 划线原文(想法对应的原文,可能为空),
* 'chapter' => 章节名(可能为空),
* 'loc' => 位置范围(可能为空),
* 'at' => 'Y-m-d H:i:s',
* ]
*/
public static function notes(int $personId, string $bookId): array
{
$chap = self::chapters($personId, $bookId);
// ---- ① 划线(/book/bookmarklist)----
$r = self::call($personId, '/book/bookmarklist', ['bookId' => $bookId]);
if (!$r['ok']) {
return ['ok' => false, 'items' => [], 'error' => $r['error']];
}
// 这个接口自己也会带 chapters(省一次请求);优先用它补全章节名
foreach ((array)($r['data']['chapters'] ?? []) as $c) {
$uid = (string)($c['chapterUid'] ?? '');
if ($uid !== '' && !isset($chap[$uid])) {
$chap[$uid] = (string)($c['title'] ?? '');
}
}
$items = [];
foreach ((array)($r['data']['updated'] ?? []) as $u) {
$txt = trim((string)($u['markText'] ?? ''));
if ($txt === '') {
continue;
}
$items[] = [
'kind' => 'quote',
'content' => '',
'quote' => $txt,
'chapter' => $chap[(string)($u['chapterUid'] ?? '')] ?? '',
'loc' => (string)($u['range'] ?? ''),
'at' => self::ts((int)($u['createTime'] ?? 0)),
];
}
// ---- ② 想法 / 点评(/review/list/mine,**需翻页**)----
$synckey = 0;
for ($page = 0; $page < 20; $page++) {
$params = ['bookid' => $bookId, 'count' => 50];
if ($synckey > 0) {
$params['synckey'] = $synckey;
}
$rr = self::call($personId, '/review/list/mine', $params);
if (!$rr['ok']) {
// ⚠️ 想法取不到**不**让整次导入失败:划线已经拿到了,
// 为了"少一个想法"把整本书丢掉,是更糟的取舍。
break;
}
foreach ((array)($rr['data']['reviews'] ?? []) as $rv) {
$rvv = (array)($rv['review'] ?? []);
$content = trim((string)($rvv['content'] ?? ''));
$abstract = trim((string)($rvv['abstract'] ?? ''));
if ($content === '' && $abstract === '') {
continue;
}
// 有原文对应 = 划线想法(note);没有 = 整本/章节点评(review)
$items[] = [
'kind' => $abstract !== '' ? 'note' : 'review',
'content' => $content,
'quote' => $abstract,
'chapter' => trim((string)($rvv['chapterName'] ?? ''))
?: ($chap[(string)($rvv['chapterUid'] ?? '')] ?? ''),
'loc' => (string)($rvv['range'] ?? ''),
'at' => self::ts((int)($rvv['createTime'] ?? 0)),
];
}
if ((int)($rr['data']['hasMore'] ?? 0) !== 1) {
break;
}
$next = (int)($rr['data']['synckey'] ?? 0);
if ($next <= 0 || $next === $synckey) {
break;
}
$synckey = $next;
}
return ['ok' => true, 'items' => $items, 'error' => ''];
}
/** Unix 时间戳 → 'Y-m-d H:i:s'(0 或非法时给当前时间,别让库里出现 1970) */
private static function ts(int $t): string
{
if ($t <= 0) {
return date('Y-m-d H:i:s');
}
// 官方有些字段给的是**毫秒**
if ($t > 99999999999) {
$t = (int)($t / 1000);
}
return date('Y-m-d H:i:s', $t);
}
/**
* 把这批 items 落库到某本书(**跳过完全重复的**)。
*
* ⚠️ 去重判据 = (kind + 内容 + 位置)三者相同。为什么不只看内容:
* 同一段原文被划两次(不同时间)在微信读书里是两条,内容是同一个字符串 ——
* 只看内容会误删;而位置(range)不同,正好能把它们区分开。
*
* @return array{added:int,skipped:int}
*/
public static function store(int $bookId, int $personId, array $items): array
{
$db = Database::get();
$find = $db->prepare(
'SELECT id FROM book_notes WHERE book_id = ? AND kind = ? AND content = ? AND COALESCE(loc, \'\') = ?'
);
$ins = $db->prepare(
'INSERT INTO book_notes (book_id, person_id, kind, chapter, loc, content, source, created_at)
VALUES (?,?,?,?,?,?,?,?)'
);
$added = $skipped = 0;
foreach ($items as $it) {
$kind = (string)($it['kind'] ?? 'quote');
if (!in_array($kind, ['quote', 'note', 'review'], true)) {
$kind = 'quote';
}
// 划线:内容就是原文;想法/点评:内容优先用想法正文,没有就用原文
$content = trim((string)($it['content'] ?? ''));
$quote = trim((string)($it['quote'] ?? ''));
if ($kind === 'quote') {
$content = $quote;
} elseif ($content === '') {
$content = $quote;
}
if ($content === '') {
continue;
}
$loc = trim((string)($it['loc'] ?? ''));
$find->execute([$bookId, $kind, $content, $loc]);
if ($find->fetchColumn()) {
$skipped++;
continue;
}
$ins->execute([
$bookId, $personId > 0 ? $personId : null, $kind,
trim((string)($it['chapter'] ?? '')), $loc, $content,
'weread', (string)($it['at'] ?? date('Y-m-d H:i:s')),
]);
$added++;
}
return ['added' => $added, 'skipped' => $skipped];
}
/**
* 按书名找本地的书(**用已有的 Book 表**)。
*
* ⚠️ 只做"去空白后完全相等"的匹配,**不做模糊匹配**:
* 书名相近的两本(《活着》与《活着(余华自选集)》)被合并成一本,
* 用户是看不出来的 —— 而"笔记挂错了书"比"多建一本书"难发现得多。
* 匹配不上时由调用方决定:默认建新书(让用户自己合并)。
*/
public static function matchLocalBook(int $familyId, string $title): int
{
$t = trim($title);
if ($t === '') {
return 0;
}
$st = Database::get()->prepare(
'SELECT id FROM books WHERE family_id = ? AND TRIM(title) = ? ORDER BY id LIMIT 1');
$st->execute([$familyId, $t]);
return (int)($st->fetchColumn() ?: 0);
}
}