2026-10-07 05:52:40 +08:00
|
|
|
|
<?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';
|
|
|
|
|
|
|
2026-10-07 11:35:31 +08:00
|
|
|
|
/** 上次自动同步的时间戳(存秒;不是字符串,别再解析一次) */
|
|
|
|
|
|
public const SYNC_AT_KEY = 'weread_sync_at';
|
|
|
|
|
|
|
|
|
|
|
|
/** 自动同步总开关('1' 开 / '0' 关) */
|
|
|
|
|
|
public const AUTO_KEY = 'weread_auto_sync';
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 自动同步的最小间隔(秒)——**默认 6 小时**。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 为什么是 6 小时而不是"每天一次":
|
|
|
|
|
|
* 读书是随时发生的。用户下午读完一本,晚上打开家书希望"它已经是读完的"。
|
|
|
|
|
|
* 而 6 小时也足够克制 —— 一天最多 4 次 API 调用,不会触发频率限制。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public const SYNC_TTL = 21600;
|
|
|
|
|
|
|
2026-10-08 02:20:19 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 一次同步的**总时长预算**(秒)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴🔴 v28 用户第 9 条:「读书,同步微信读书后,感觉有时候点击进来很慢,
|
|
|
|
|
|
* 需要加载很长时间,后来就好了,过一段时间又慢。」
|
|
|
|
|
|
*
|
|
|
|
|
|
* 根因(实测推演,症状完全吻合):
|
|
|
|
|
|
* 自动同步挂在**读书列表页**上(见 index.php 的 books 路由),
|
|
|
|
|
|
* 而 `syncNow()` 最多扫 60 本书,**每一本**都要打 1~3 次网关
|
|
|
|
|
|
* (bookInfo / progress / notes,缺一座就补一次封面还要再下图片)。
|
|
|
|
|
|
* 单次 `call()` 的超时是 **20 秒**(见 call() 的 CURLOPT_TIMEOUT)——
|
|
|
|
|
|
* 于是"6 小时到期的那一次"最坏能挂几分钟:用户点进来就是白屏等待,
|
|
|
|
|
|
* 过后(时间戳已写、间隔未到)再进来只读一个时间戳,立刻就快了;
|
|
|
|
|
|
* 再过 6 小时又是那一下 —— 这正是用户描述的"时快时慢、过一阵又慢"。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⇒ 修法:给整轮同步一个**总预算**。到点就**停下来、正常返回**,
|
|
|
|
|
|
* 已处理的部分照常落库,剩下的等下一轮接着做(游标从头再来一遍也无妨:
|
|
|
|
|
|
* `matchLocalBook` 与 `store` 都是幂等的,"已存在的会跳过")。
|
|
|
|
|
|
* ⚠️ 为什么设 8 秒:这是"用户能忍的一次页面等待"的量级。
|
|
|
|
|
|
* 真实耗时取决于网关,宁少勿多 —— 慢一点同步完,比页面打不开好得多。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public const SYNC_BUDGET = 8;
|
|
|
|
|
|
|
|
|
|
|
|
/** 每次同步最多扫几本(与预算双保险:预算防"慢",条数防"多") */
|
|
|
|
|
|
public const SYNC_MAX_BOOKS = 25;
|
|
|
|
|
|
|
2026-10-08 10:54:43 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 快照的**保鲜期**(秒)—— 默认 24 小时。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 v29 用户第 4 条:「读书还是慢。是不是可以做一个同步,然后落盘,
|
|
|
|
|
|
* 这样就不用每次都读取微信读书的数据了。」
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⇒ 落盘之后,"多久算旧"就成了新问题:
|
|
|
|
|
|
* · 太短(比如和 SYNC_TTL 一样 6 小时)→ 老是触发同步,等于没落盘;
|
|
|
|
|
|
* · 太长(比如一年)→ 用户读完了新书、打开还是旧数据,会觉得"同步坏了"。
|
|
|
|
|
|
* 选 24 小时:读书统计本来就不是实时数据(读了多少分钟是慢慢累积的),
|
|
|
|
|
|
* 一天内看到的是昨天的数字完全可接受;且每天最多触发一次拉取。
|
|
|
|
|
|
* ⚠️ 关键:**渲染路径永远不等这次拉取**(拿旧快照先渲染)。
|
|
|
|
|
|
* 过期只意味着"下次访问时顺手把它刷新一下",不意味着"这次要等"。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public const SNAP_TTL = 86400;
|
|
|
|
|
|
|
2026-10-07 05:52:40 +08:00
|
|
|
|
// ================================================================
|
|
|
|
|
|
// 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,
|
2026-10-08 02:20:19 +08:00
|
|
|
|
// 🔴 v28 用户第 9 条:单次调用超时从 20 秒收到 **8 秒**。
|
|
|
|
|
|
// 同步一轮要打好几次网关,20 秒 × N 次可以直接把页面拖垮;
|
|
|
|
|
|
// 而网关正常时是零点几秒就回的(见 nav_suggest.php 里那批实测:
|
|
|
|
|
|
// 同类接口 0.1~1.6 秒)。8 秒足以覆盖"网关偶发慢",又不会让
|
|
|
|
|
|
// 一次页面请求长时间挂着。慢的代价只是"这一轮少同步几本",
|
|
|
|
|
|
// 下一轮会接着做(幂等)。
|
|
|
|
|
|
CURLOPT_TIMEOUT => 8,
|
|
|
|
|
|
CURLOPT_CONNECTTIMEOUT => 5,
|
2026-10-07 05:52:40 +08:00
|
|
|
|
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' => ''];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 11:35:31 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 取一本书的**基本信息**(v24 第 25 批 用户第 8 条:封面看不到)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 用户原话:「微信读书导入,需要能够做到定期完成,自动同步。现在可以导入了,
|
|
|
|
|
|
* 不过**封面图看不到**,另外也缺少一些对微信读书的统计功能,需要加强。」
|
|
|
|
|
|
*
|
|
|
|
|
|
* 根因:导入时只建了 title + author + status,**从来没写过 cover_key** ——
|
|
|
|
|
|
* 而官方 ` /book/info ` 明明给了 `cover`(封面 URL)、`isbn`、`publisher`、
|
|
|
|
|
|
* `category`、`intro`、`newRating`、`wordCount`。
|
|
|
|
|
|
* 所以书架里那一排全是「📖 空封面」。这里把该拿的一次拿齐。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @return array{ok:bool,book:array,error:string}
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function bookInfo(int $personId, string $bookId): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$r = self::call($personId, '/book/info', ['bookId' => $bookId]);
|
|
|
|
|
|
if (!$r['ok']) {
|
|
|
|
|
|
return ['ok' => false, 'book' => [], 'error' => $r['error']];
|
|
|
|
|
|
}
|
|
|
|
|
|
$d = (array)$r['data'];
|
|
|
|
|
|
return ['ok' => true, 'error' => '', 'book' => [
|
|
|
|
|
|
'title' => (string)($d['title'] ?? ''),
|
|
|
|
|
|
'author' => (string)($d['author'] ?? ''),
|
|
|
|
|
|
'translator' => (string)($d['translator'] ?? ''),
|
|
|
|
|
|
'cover' => (string)($d['cover'] ?? ''),
|
|
|
|
|
|
'intro' => (string)($d['intro'] ?? ''),
|
|
|
|
|
|
'category' => (string)($d['category'] ?? ''),
|
|
|
|
|
|
'publisher' => (string)($d['publisher'] ?? ''),
|
|
|
|
|
|
'published' => (string)($d['publishTime'] ?? ''),
|
|
|
|
|
|
'isbn' => (string)($d['isbn'] ?? ''),
|
|
|
|
|
|
'wordCount' => (int)($d['wordCount'] ?? 0),
|
|
|
|
|
|
'rating' => (int)round((int)($d['newRating'] ?? 0) / 10), // 官方是百分制
|
|
|
|
|
|
]];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 阅读进度(`/book/getprogress`)—— 用来判断"这本书读完了没"。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 官方文档专门强调:`progress` 是 **0~100 的整数**,而且
|
|
|
|
|
|
* **只有 100 才代表读完**(1 表示 1%,不是 100%)。
|
|
|
|
|
|
* 这一条极易写错(把 1 当"已读"),所以在这里就把判据封装好。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @return array{ok:bool,progress:int,finished:bool,done_at:string,seconds:int,error:string}
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function progress(int $personId, string $bookId): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$r = self::call($personId, '/book/getprogress', ['bookId' => $bookId]);
|
|
|
|
|
|
if (!$r['ok']) {
|
|
|
|
|
|
return ['ok' => false, 'progress' => 0, 'finished' => false,
|
|
|
|
|
|
'done_at' => '', 'seconds' => 0, 'error' => $r['error']];
|
|
|
|
|
|
}
|
|
|
|
|
|
$b = (array)($r['data']['book'] ?? []);
|
|
|
|
|
|
$p = max(0, min(100, (int)($b['progress'] ?? 0)));
|
|
|
|
|
|
$fin = $p >= 100;
|
|
|
|
|
|
return [
|
|
|
|
|
|
'ok' => true,
|
|
|
|
|
|
'error' => '',
|
|
|
|
|
|
'progress' => $p,
|
|
|
|
|
|
'finished' => $fin,
|
|
|
|
|
|
'done_at' => $fin ? self::ts((int)($b['finishTime'] ?? 0)) : '',
|
|
|
|
|
|
'seconds' => (int)($b['recordReadingTime'] ?? 0),
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 阅读统计(`/readdata/detail`)—— 用户第 8 条要的"统计功能加强"。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 官方给的东西比本项目原来的统计丰富得多,这里只挑**能直接用**的几样:
|
|
|
|
|
|
* · `totalReadTime` —— 该周期总时长(**秒**,官方文档反复强调别当分钟)
|
|
|
|
|
|
* · `readDays` —— 有效阅读天数(官方口径:单日满 1 分钟)
|
|
|
|
|
|
* · `readStat` —— 「读过 N 本 / 读完 N 本 / 阅读 N 天 / 笔记 N 条」
|
|
|
|
|
|
* · `readLongest` —— 读得最多的书(含封面)
|
|
|
|
|
|
* · `preferCategory` —— 偏好分类(带时长)
|
|
|
|
|
|
* · `preferAuthor` —— 偏好作者
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 官方只支持**固定自然周期**(weekly/monthly/annually/overall),
|
|
|
|
|
|
* 不支持任意起止日期。默认给 `overall`("一共读了多久"最常被问)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param string $mode weekly / monthly / annually / overall
|
|
|
|
|
|
* @return array{ok:bool,data:array,error:string}
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function readStats(int $personId, string $mode = 'overall', int $baseTime = 0): array
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!in_array($mode, ['weekly', 'monthly', 'annually', 'overall'], true)) {
|
|
|
|
|
|
$mode = 'overall';
|
|
|
|
|
|
}
|
|
|
|
|
|
$params = ['mode' => $mode];
|
|
|
|
|
|
if ($baseTime > 0) {
|
|
|
|
|
|
$params['baseTime'] = $baseTime;
|
|
|
|
|
|
}
|
|
|
|
|
|
$r = self::call($personId, '/readdata/detail', $params);
|
|
|
|
|
|
if (!$r['ok']) {
|
|
|
|
|
|
return ['ok' => false, 'data' => [], 'error' => $r['error']];
|
|
|
|
|
|
}
|
|
|
|
|
|
$d = (array)$r['data'];
|
|
|
|
|
|
|
|
|
|
|
|
// 读得最多的书(最多 10 条,官方已按 readTime 降序、且过滤掉不足 5 分钟的)
|
|
|
|
|
|
$top = [];
|
|
|
|
|
|
foreach ((array)($d['readLongest'] ?? []) as $it) {
|
|
|
|
|
|
$bk = (array)($it['book'] ?? []);
|
|
|
|
|
|
$top[] = [
|
|
|
|
|
|
'title' => (string)($bk['title'] ?? ''),
|
|
|
|
|
|
'author' => (string)($bk['author'] ?? ''),
|
|
|
|
|
|
'cover' => (string)($bk['cover'] ?? ''),
|
|
|
|
|
|
'bookId' => (string)($bk['bookId'] ?? ''),
|
|
|
|
|
|
'seconds' => (int)($it['readTime'] ?? 0),
|
|
|
|
|
|
'tags' => array_map('strval', (array)($it['tags'] ?? [])),
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
// 偏好分类(带时长与本数)
|
|
|
|
|
|
$cats = [];
|
|
|
|
|
|
foreach ((array)($d['preferCategory'] ?? []) as $c) {
|
|
|
|
|
|
$cats[] = [
|
|
|
|
|
|
'name' => (string)($c['categoryTitle'] ?? ''),
|
|
|
|
|
|
'seconds' => (int)($c['readingTime'] ?? 0),
|
|
|
|
|
|
'books' => (int)($c['readingCount'] ?? 0),
|
|
|
|
|
|
'val' => (int)($c['val'] ?? 0),
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
// 偏好作者(注意:官方的 readTime 这里是**格式化字符串**,不是秒)
|
|
|
|
|
|
$authors = [];
|
|
|
|
|
|
foreach ((array)($d['preferAuthor'] ?? []) as $a) {
|
|
|
|
|
|
$authors[] = [
|
|
|
|
|
|
'name' => (string)($a['name'] ?? ''),
|
|
|
|
|
|
'books' => (int)($a['count'] ?? 0),
|
|
|
|
|
|
'timeWord' => (string)($a['readTime'] ?? ''),
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
// 摘要那几行(读过/读完/阅读/笔记)
|
|
|
|
|
|
$stat = [];
|
|
|
|
|
|
foreach ((array)($d['readStat'] ?? []) as $s) {
|
|
|
|
|
|
$stat[] = ['name' => (string)($s['stat'] ?? ''), 'count' => (string)($s['counts'] ?? '')];
|
|
|
|
|
|
}
|
|
|
|
|
|
return ['ok' => true, 'error' => '', 'data' => [
|
|
|
|
|
|
'mode' => $mode,
|
|
|
|
|
|
'seconds' => (int)($d['totalReadTime'] ?? 0),
|
|
|
|
|
|
'days' => (int)($d['readDays'] ?? 0),
|
|
|
|
|
|
'avgSec' => (int)($d['dayAverageReadTime'] ?? 0),
|
|
|
|
|
|
'compare' => $d['compare'] ?? null,
|
|
|
|
|
|
'stat' => $stat,
|
|
|
|
|
|
'topBooks' => $top,
|
|
|
|
|
|
'categories'=> $cats,
|
|
|
|
|
|
'authors' => $authors,
|
|
|
|
|
|
'preferWord'=> (string)($d['preferCategoryWord'] ?? ''),
|
|
|
|
|
|
'preferTime'=> (string)($d['preferTimeWord'] ?? ''),
|
|
|
|
|
|
'yearReport'=> (array)($d['yearReport'] ?? []),
|
|
|
|
|
|
]];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 把一张封面图**拉下来存进本站存储**(用户第 8 条:封面看不到)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 为什么不直接把官方的 `cover` URL 存进 `books.cover_key`:
|
|
|
|
|
|
* ① 那个 URL 是**第三方 CDN**(微信读书的),可能带防盗链/时效;
|
|
|
|
|
|
* 直接把外链塞进 cover_key,展示时要么不显示、要么哪天忽然全变破图;
|
|
|
|
|
|
* ② 本站所有附件都走 `media.php` 代理(登录态 + 存储驱动),
|
|
|
|
|
|
* 混一个外链进去就破了"所有媒体一个出口"这条规矩;
|
|
|
|
|
|
* ③ 用户可能清理缓存/换账号,外链失效后书架就一排空封面。
|
|
|
|
|
|
* ⇒ 拉下来走**与用户手传封面完全同一条路**(`Upload::image`,
|
|
|
|
|
|
* slot = `bookcover`),这样它不仅存好了,还顺带做了 2:3 居中裁剪 ——
|
|
|
|
|
|
* 与用户自己传的封面在页面上长得**一模一样**(否则两种来源两种比例,
|
|
|
|
|
|
* 书架格子会被撑得参差不齐)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @return string 存好的 cover_key(失败返回 '')
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function fetchCover(int $familyId, string $url, string $title): string
|
|
|
|
|
|
{
|
|
|
|
|
|
$url = trim($url);
|
|
|
|
|
|
if ($url === '' || !preg_match('#^https?://#i', $url)) {
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
$binary = self::httpBinary($url, 4 * 1024 * 1024, 12);
|
|
|
|
|
|
if ($binary === null || strlen($binary) < 64) {
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
$tmp = tempnam(sys_get_temp_dir(), 'fbcv');
|
|
|
|
|
|
if ($tmp === false) {
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (@file_put_contents($tmp, $binary) === false) {
|
|
|
|
|
|
@unlink($tmp);
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
// 伪装成 $_FILES 的形态交给 Upload::image(它只认 tmp_name/error/name/type)
|
|
|
|
|
|
$fake = [
|
|
|
|
|
|
'name' => self::safeBase($title) . '.jpg',
|
|
|
|
|
|
'type' => 'image/jpeg',
|
|
|
|
|
|
'tmp_name' => $tmp,
|
|
|
|
|
|
'error' => UPLOAD_ERR_OK,
|
|
|
|
|
|
'size' => strlen($binary),
|
|
|
|
|
|
];
|
|
|
|
|
|
$up = \App\Upload::image($familyId, $fake, 'bookcover', '');
|
|
|
|
|
|
@unlink($tmp);
|
|
|
|
|
|
return !empty($up['ok']) ? (string)$up['file'] : '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-08 10:54:43 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 标题 → 可做文件名的安全基名(去掉路径符号,限长) */
|
2026-10-07 11:35:31 +08:00
|
|
|
|
private static function safeBase(string $s): string
|
|
|
|
|
|
{
|
|
|
|
|
|
$s = preg_replace('#[\\\\/:*?"<>|\x00-\x1f]#u', '', $s) ?? '';
|
|
|
|
|
|
$s = trim(preg_replace('/\s+/u', '_', $s) ?? '');
|
|
|
|
|
|
return $s === '' ? 'cover' : mb_substr($s, 0, 60, 'UTF-8');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-08 10:54:43 +08:00
|
|
|
|
// ================================================================
|
|
|
|
|
|
// v29 用户第 4 条:**本地快照优先**的取数入口
|
|
|
|
|
|
// ================================================================
|
|
|
|
|
|
//
|
|
|
|
|
|
// 规矩(只此一条,别处照做):
|
|
|
|
|
|
// · **渲染路径**一律调 `*Cached()`,它永不联网;
|
|
|
|
|
|
// · **同步路径**(手动「立即同步」/ 6 小时到期)才调真正的 `notebooks()`/`readStats()`,
|
|
|
|
|
|
// 拿到结果后用 `snapNotebooks()` / `snapStats()` 落盘。
|
|
|
|
|
|
//
|
|
|
|
|
|
// ⚠️ 缓存里**没有**(第一次用、刚换 Key)时怎么办:
|
|
|
|
|
|
// 这时页面没有任何数据可显示,只能真去打一次 —— 但那是"首次",不是常态。
|
|
|
|
|
|
// 之后的每一次访问都命中本地,这就是用户要的"落盘,不用每次去读"。
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 有笔记的书清单(**优先读本地快照**)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param bool $allowFetch 本地没有时,是否允许真的去联网取(默认允许)
|
|
|
|
|
|
* @return array{ok:bool,books:array,error:string,cached:bool,updated_at:int}
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function notebooksCached(int $personId, bool $allowFetch = true): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$snap = WeReadCache::get($personId, WeReadCache::K_NOTEBOOKS);
|
|
|
|
|
|
if ($snap['ok'] && is_array($snap['data'])) {
|
|
|
|
|
|
return ['ok' => true, 'books' => (array)($snap['data']['books'] ?? []),
|
|
|
|
|
|
'error' => '', 'cached' => true, 'updated_at' => (int)$snap['updated_at']];
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!$allowFetch) {
|
|
|
|
|
|
return ['ok' => true, 'books' => [], 'error' => '', 'cached' => true, 'updated_at' => 0];
|
|
|
|
|
|
}
|
|
|
|
|
|
$r = self::notebooks($personId);
|
|
|
|
|
|
if ($r['ok']) {
|
|
|
|
|
|
self::snapNotebooks($personId, (array)$r['books']);
|
|
|
|
|
|
}
|
|
|
|
|
|
return ['ok' => (bool)$r['ok'], 'books' => (array)($r['books'] ?? []),
|
|
|
|
|
|
'error' => (string)($r['error'] ?? ''), 'cached' => false, 'updated_at' => time()];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 把书清单写进快照 */
|
|
|
|
|
|
public static function snapNotebooks(int $personId, array $books): void
|
|
|
|
|
|
{
|
|
|
|
|
|
WeReadCache::put($personId, WeReadCache::K_NOTEBOOKS, ['books' => $books]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 阅读统计(**优先读本地快照**,按 mode 各存一份) */
|
|
|
|
|
|
public static function readStatsCached(int $personId, string $mode = 'overall',
|
|
|
|
|
|
bool $allowFetch = true): array
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!in_array($mode, ['weekly', 'monthly', 'annually', 'overall'], true)) {
|
|
|
|
|
|
$mode = 'overall';
|
|
|
|
|
|
}
|
|
|
|
|
|
$key = WeReadCache::statsKey($mode);
|
|
|
|
|
|
$snap = WeReadCache::get($personId, $key);
|
|
|
|
|
|
if ($snap['ok'] && is_array($snap['data'])) {
|
|
|
|
|
|
return ['ok' => true, 'data' => (array)$snap['data'], 'error' => '',
|
|
|
|
|
|
'cached' => true, 'updated_at' => (int)$snap['updated_at']];
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!$allowFetch) {
|
|
|
|
|
|
return ['ok' => true, 'data' => [], 'error' => '', 'cached' => true, 'updated_at' => 0];
|
|
|
|
|
|
}
|
|
|
|
|
|
$r = self::readStats($personId, $mode);
|
|
|
|
|
|
if ($r['ok']) {
|
|
|
|
|
|
self::snapStats($personId, $mode, (array)$r['data']);
|
|
|
|
|
|
}
|
|
|
|
|
|
return ['ok' => (bool)$r['ok'], 'data' => (array)($r['data'] ?? []),
|
|
|
|
|
|
'error' => (string)($r['error'] ?? ''), 'cached' => false, 'updated_at' => time()];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 把统计写进快照 */
|
|
|
|
|
|
public static function snapStats(int $personId, string $mode, array $data): void
|
|
|
|
|
|
{
|
|
|
|
|
|
WeReadCache::put($personId, WeReadCache::statsKey($mode), $data);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 快照的"数据更新于"时间(页面上用;0 = 从没同步过) */
|
|
|
|
|
|
public static function snapshotAt(int $personId): int
|
|
|
|
|
|
{
|
|
|
|
|
|
return WeReadCache::updatedAt($personId, WeReadCache::K_NOTEBOOKS);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** @var array<int,bool> 本次请求已经排过后台刷新的(避免重复注册 shutdown 钩子) */
|
|
|
|
|
|
private static array $bgQueued = [];
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 排一次"**响应发完之后**再去同步"。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 🔴 v29 用户第 4 条的另一半:光把数据落盘还不够 ——
|
|
|
|
|
|
* 原来那段自动同步挂在**渲染之前**(`maybeSync` 在 `View::render` 上面),
|
|
|
|
|
|
* 所以"6 小时到期的那一次"仍然要等它跑完才出页面。
|
|
|
|
|
|
* 落盘解决了"每次都要去读",但没解决"那一 次要等"。
|
|
|
|
|
|
* ⇒ 照 `Holiday::queueBackgroundFetch()` 的成熟做法:
|
|
|
|
|
|
* `fastcgi_finish_request()` 把响应**立刻发给浏览器**,
|
|
|
|
|
|
* 之后脚本继续跑同步、写快照,下次访问自然就是新的。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ php-fpm 才有 `fastcgi_finish_request`;`php -S`(本地)没有,
|
|
|
|
|
|
* 那就是"同步仍在请求内发生" —— 与改造前一致,不会更差。
|
|
|
|
|
|
* ⚠️ 用 `register_shutdown_function` 而不是在这里直接调:
|
|
|
|
|
|
* 必须等**页面已经渲染完**(输出都发出去)才切断,否则会把页面截断。
|
|
|
|
|
|
* ⚠️ 同一个请求里只排一次(`$bgQueued`):读书页会连着调好几次取数。
|
2026-10-14 01:20:40 +08:00
|
|
|
|
*
|
|
|
|
|
|
* 🔴🔴 v47(用户 2026-10-13:「读书现在还是非常慢,进入这个页面刚开始非常慢」)
|
|
|
|
|
|
* —— **必须把会话锁也放掉,否则"后台化"是假的。**
|
|
|
|
|
|
*
|
|
|
|
|
|
* 现场实测(线上,真公网,同一会话连打 3 次):
|
|
|
|
|
|
* · 第 1 次 TTFB 0.08s ← 触发同步
|
|
|
|
|
|
* · 第 2 次 TTFB **8.73s** ← 就是用户说的"刚开始非常慢"
|
|
|
|
|
|
* · 第 3 次 TTFB 0.07s
|
|
|
|
|
|
* 服务端时间线(插桩取得)把因果钉死了:
|
|
|
|
|
|
* 21:18:05.48 QUEUED ← 第 1 个请求排队
|
|
|
|
|
|
* 21:18:05.50 FINISH 2ms ← 响应确实提前发出去了(这步是好的)
|
|
|
|
|
|
* 21:18:14.67 SYNC 9.2s ← 但它仍**占着这个人的会话文件锁**跑完 9.2 秒
|
|
|
|
|
|
* 21:18:14.68 QUEUED ← 第 2 个请求此刻才真正开始执行
|
|
|
|
|
|
* ⇒ PHP 的文件会话是**独占**的:`fastcgi_finish_request()` 只断开"客户端连接",
|
|
|
|
|
|
* **不会释放会话锁**。同一个人的下一个请求(刷新 / 点进读书 / 点别的页面)
|
|
|
|
|
|
* 会一路等到后台同步跑完 —— 于是"页面秒回、但下一个页面卡 9 秒",
|
|
|
|
|
|
* 用户感觉就是"进读书页非常慢"。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 必须**两处**都放锁:
|
|
|
|
|
|
* ① 关掉的这里(在 `fastcgi_finish_request()` **之前**)——
|
|
|
|
|
|
* 覆盖"本次是排同步的那个请求"。
|
|
|
|
|
|
* ② 页面路由里、渲染**之前**(见 index.php 的 books 路由)——
|
|
|
|
|
|
* 因为后端的 shutdown 回调**不一定真的执行**(用户中断 / 服务器超时),
|
|
|
|
|
|
* 那时锁会一直挂到请求结束;提前放掉最稳妥。
|
|
|
|
|
|
* 放锁前要用的会话数据(familyId / personId)都在上方作为**参数**传进来了,
|
|
|
|
|
|
* 此后不再读 `$_SESSION`,所以放锁是安全的。
|
|
|
|
|
|
* ⚠️ 读操作不受影响:放锁只影响"写",而这里之后只做 HTTP + 写数据库。
|
2026-10-08 10:54:43 +08:00
|
|
|
|
*/
|
|
|
|
|
|
public static function queueBackgroundSync(int $familyId, int $personId): void
|
|
|
|
|
|
{
|
|
|
|
|
|
if ($personId <= 0 || $familyId <= 0) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (isset(self::$bgQueued[$personId])) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
self::$bgQueued[$personId] = true;
|
|
|
|
|
|
register_shutdown_function(static function () use ($familyId, $personId): void {
|
2026-10-14 01:20:40 +08:00
|
|
|
|
// 🔴 v47:先放会话锁,再断连接 —— 顺序反了就没意义
|
|
|
|
|
|
//(`fastcgi_finish_request()` 会让 `session_write_close()` 变成 no-op 的时机
|
|
|
|
|
|
// 更早,所以锁必须在它之前放)。
|
|
|
|
|
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
|
|
|
|
|
session_write_close();
|
|
|
|
|
|
}
|
2026-10-08 10:54:43 +08:00
|
|
|
|
if (function_exists('fastcgi_finish_request')) {
|
|
|
|
|
|
fastcgi_finish_request();
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 走到这里时"间隔到没到"要重新判一次(queue 时不判、执行时判):
|
|
|
|
|
|
// 期间可能已经有别的请求同步过了,那就别再打一次接口。
|
|
|
|
|
|
self::maybeSync($familyId, $personId, false);
|
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
|
// 后台任务绝不能把响应打挂(此时响应早已发完,报错只会进日志)
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 11:35:31 +08:00
|
|
|
|
/** 抓一段二进制(3 秒超时不够,封面有时几 MB;失败返回 null,绝不抛) */
|
|
|
|
|
|
private static function httpBinary(string $url, int $maxBytes = 3145728, int $timeout = 10): ?string
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!function_exists('curl_init')) {
|
|
|
|
|
|
$ctx = stream_context_create(['http' => ['timeout' => $timeout, 'ignore_errors' => true]]);
|
|
|
|
|
|
$r = @file_get_contents($url, false, $ctx, 0, $maxBytes);
|
|
|
|
|
|
return $r === false ? null : (string)$r;
|
|
|
|
|
|
}
|
|
|
|
|
|
$ch = curl_init($url);
|
|
|
|
|
|
curl_setopt_array($ch, [
|
|
|
|
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
|
|
|
|
CURLOPT_TIMEOUT => $timeout,
|
|
|
|
|
|
CURLOPT_CONNECTTIMEOUT => 5,
|
|
|
|
|
|
CURLOPT_FOLLOWLOCATION => true,
|
|
|
|
|
|
CURLOPT_MAXREDIRS => 3,
|
|
|
|
|
|
CURLOPT_SSL_VERIFYPEER => true,
|
|
|
|
|
|
CURLOPT_USERAGENT => 'FamilyBook/1.0',
|
|
|
|
|
|
// ⚠️ 微信读书的 CDN 可能挑 Referer —— 给一个自己站点的 Referer
|
|
|
|
|
|
CURLOPT_HTTPHEADER => ['Referer: https://weread.qq.com/'],
|
|
|
|
|
|
]);
|
|
|
|
|
|
$r = curl_exec($ch);
|
|
|
|
|
|
$code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
|
|
|
|
|
curl_close($ch);
|
|
|
|
|
|
if ($r === false || $code < 200 || $code >= 300) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
return strlen((string)$r) > $maxBytes ? substr((string)$r, 0, $maxBytes) : (string)$r;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 05:52:40 +08:00
|
|
|
|
/** 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);
|
|
|
|
|
|
}
|
2026-10-07 11:35:31 +08:00
|
|
|
|
|
|
|
|
|
|
// ================================================================
|
|
|
|
|
|
// 定期自动同步(v24 第 25 批 用户第 8 条)
|
|
|
|
|
|
// ================================================================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 用户原话:「微信读书导入,需要能够做到定期完成,自动同步。」
|
|
|
|
|
|
*
|
|
|
|
|
|
* ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
* **为什么不用系统 cron**(这是一个刻意的取舍,写清楚免得下一个人来加 crontab)
|
|
|
|
|
|
*
|
|
|
|
|
|
* 本项目其它"定期"的事都是**访问时顺手做**(见 StagedUpload::sweep 的注释:
|
|
|
|
|
|
* "在 business 路由里顺手调一次,不必挂定时任务")。理由:
|
|
|
|
|
|
* ① 这是**家用**系统,可能几周没人访问。挂个 cron 每天凌晨去调微信读书 API,
|
|
|
|
|
|
* 在没人用的时候白烧请求、还可能触发官方的频率限制;
|
|
|
|
|
|
* ② cron 要配服务器(宝塔面板 + 路径 + PHP 版本),用户自己维护不了,
|
|
|
|
|
|
* 而"配错了"是静默的(任务不跑,谁也不知道);
|
|
|
|
|
|
* ③ 访问时同步的**体验完全等价**:用户打开读书页 → 数据是新的。
|
|
|
|
|
|
* 他不在的时候,同步不同步没有任何人能察觉。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 所以判据是「**距上次同步过了多久**」而不是"今天跑过没":
|
|
|
|
|
|
* 间隔到期才真的去打接口(默认 6 小时,见 SYNC_TTL),
|
|
|
|
|
|
* 否则只读一个时间戳。这样"每天开十次书页"也只会同步两次。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 失败**不重试、不阻塞**:网络不通/Key 失效时静默记一下时间(下次再试),
|
|
|
|
|
|
* 绝不让"同步失败"把读书页也打不开 —— 外部依赖不能拖死主流程。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param bool $force true = 忽略间隔,立刻同步(用户点「立即同步」时用)
|
2026-10-08 02:20:19 +08:00
|
|
|
|
* 🔴 v28 用户第 9 条:$force 时给**更大的扫描量**(用户是
|
|
|
|
|
|
* 主动点的,愿意等;而且手动同步本来就该一次做完整)。
|
|
|
|
|
|
* 页面上的自动同步则用小量 —— 它挂在读书列表页上,
|
|
|
|
|
|
* 每多扫一本就多一次"打开页面就等"。
|
2026-10-07 11:35:31 +08:00
|
|
|
|
* @return array{ok:bool,ran:bool,error:string,books:int,notes:int,covers:int,at:string}
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function maybeSync(int $familyId, int $personId, bool $force = false): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$empty = ['ok' => true, 'ran' => false, 'error' => '', 'books' => 0,
|
|
|
|
|
|
'notes' => 0, 'covers' => 0, 'at' => self::lastSync($personId)];
|
|
|
|
|
|
if (!self::hasKey($personId) || $familyId <= 0 || $personId <= 0) {
|
|
|
|
|
|
return $empty;
|
|
|
|
|
|
}
|
|
|
|
|
|
$last = (int)UserSetting::get($personId, self::SYNC_AT_KEY, '0');
|
|
|
|
|
|
if (!$force && $last > 0 && (time() - $last) < self::SYNC_TTL) {
|
|
|
|
|
|
return $empty;
|
|
|
|
|
|
}
|
2026-10-08 02:20:19 +08:00
|
|
|
|
// ⚠️ 先记时间再干活:万一下面某一轮超时/抛错,也不会在同一个页面
|
2026-10-07 11:35:31 +08:00
|
|
|
|
// 反复重试("每次打开读书页都卡 20 秒"是最糟的症状)。
|
2026-10-08 02:20:19 +08:00
|
|
|
|
// 🔴 v28:现在同步**有总预算**(SYNC_BUDGET),不会再长时间卡住;
|
|
|
|
|
|
// 这条"先记时间"依然要留 —— 它是"网关整个挂掉时别每次访问都重试"的兜底。
|
2026-10-07 11:35:31 +08:00
|
|
|
|
UserSetting::set($personId, self::SYNC_AT_KEY, (string)time());
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-10-08 02:20:19 +08:00
|
|
|
|
// 自动同步只扫一小批(页面等待),手动同步扫完整批(用户愿意等)
|
|
|
|
|
|
$limit = $force ? 60 : self::SYNC_MAX_BOOKS;
|
|
|
|
|
|
$res = self::syncNow($familyId, $personId, $limit);
|
2026-10-07 11:35:31 +08:00
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
|
return ['ok' => false, 'ran' => true, 'error' => $e->getMessage(),
|
|
|
|
|
|
'books' => 0, 'notes' => 0, 'covers' => 0, 'at' => date('Y-m-d H:i:s')];
|
|
|
|
|
|
}
|
|
|
|
|
|
return [
|
|
|
|
|
|
'ok' => true, 'ran' => true, 'error' => (string)($res['error'] ?? ''),
|
|
|
|
|
|
'books' => (int)($res['books'] ?? 0), 'notes' => (int)($res['notes'] ?? 0),
|
|
|
|
|
|
'covers' => (int)($res['covers'] ?? 0), 'at' => date('Y-m-d H:i:s'),
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 真的同步一遍:把有笔记的书补进书架、补封面、补进度、补笔记。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 「补」而不是"重建":已经在书架里的书**不覆盖用户改过的东西**
|
|
|
|
|
|
* (用户可能改了分类、打了分、改了状态)—— 只补**空着的**那几个字段。
|
|
|
|
|
|
* ⚠️ 这是"自动同步"能不能长期开着的关键:只要有一次把用户手动填的
|
|
|
|
|
|
* 评分/分类冲掉,他就再也不敢让它自动跑了。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @return array{ok:bool,error:string,books:int,notes:int,covers:int,scanned:int}
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function syncNow(int $familyId, int $personId, int $maxBooks = 60): array
|
|
|
|
|
|
{
|
2026-10-08 02:20:19 +08:00
|
|
|
|
/* 🔴 v28 用户第 9 条:**整轮的总时长预算**(见 SYNC_BUDGET 的注释)。
|
|
|
|
|
|
* 到点就停、正常返回已处理的部分,剩下的下一轮继续。
|
|
|
|
|
|
* ⚠️ 用"开始时刻 + 每轮循环都检查"而不是"包一个超时异常":
|
|
|
|
|
|
* PHP 的 curl 超时是按**单次调用**算的,没有"整个函数超时"这种机制;
|
|
|
|
|
|
* 要在中途刹车,只能在循环里自己判。
|
|
|
|
|
|
* ⚠️ 判据放在**每本书的开头**(不是每本书之间某处):一本书内部还会打
|
|
|
|
|
|
* 2~3 次网关,若在书中途判断就会留下半本书(字段补一半)。
|
|
|
|
|
|
* 整本为单位地停,重来时那本书会被幂等地重做一遍。 */
|
|
|
|
|
|
$deadline = microtime(true) + self::SYNC_BUDGET;
|
|
|
|
|
|
|
2026-10-07 11:35:31 +08:00
|
|
|
|
$nb = self::notebooks($personId);
|
|
|
|
|
|
if (!$nb['ok']) {
|
|
|
|
|
|
return ['ok' => false, 'error' => (string)$nb['error'], 'books' => 0,
|
|
|
|
|
|
'notes' => 0, 'covers' => 0, 'scanned' => 0];
|
|
|
|
|
|
}
|
|
|
|
|
|
$newBooks = $newNotes = $newCovers = 0;
|
|
|
|
|
|
$scanned = 0;
|
|
|
|
|
|
foreach ($nb['books'] as $b) {
|
|
|
|
|
|
if ($scanned >= $maxBooks) {
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-10-08 02:20:19 +08:00
|
|
|
|
// 总预算到点 → 停在这里(已处理的都已落库;剩下的下一轮再做)
|
|
|
|
|
|
if (microtime(true) >= $deadline) {
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-10-07 11:35:31 +08:00
|
|
|
|
$title = trim((string)$b['title']);
|
|
|
|
|
|
if ($title === '') {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
$scanned++;
|
|
|
|
|
|
$bid = self::matchLocalBook($familyId, $title);
|
|
|
|
|
|
|
|
|
|
|
|
// ---- ① 书不在书架 → 建一本(顺便把该拿的元数据一次拿齐)----
|
|
|
|
|
|
if ($bid <= 0) {
|
|
|
|
|
|
$info = self::bookInfo($personId, (string)$b['bookId']);
|
|
|
|
|
|
$meta = $info['ok'] ? $info['book'] : [];
|
|
|
|
|
|
$coverKey = '';
|
|
|
|
|
|
if (!empty($meta['cover'])) {
|
|
|
|
|
|
$coverKey = self::fetchCover($familyId, (string)$meta["cover"], $title);
|
|
|
|
|
|
if ($coverKey !== '') { $newCovers++; }
|
|
|
|
|
|
}
|
|
|
|
|
|
$bid = Book::create($familyId, $personId, [
|
|
|
|
|
|
'title' => $title,
|
|
|
|
|
|
'author' => (string)($meta['author'] ?? $b['author']),
|
|
|
|
|
|
'translator' => (string)($meta['translator'] ?? ''),
|
|
|
|
|
|
'publisher' => (string)($meta['publisher'] ?? ''),
|
|
|
|
|
|
'published' => (string)($meta['published'] ?? ''),
|
|
|
|
|
|
'isbn' => (string)($meta['isbn'] ?? ''),
|
|
|
|
|
|
'category' => (string)($meta['category'] ?? ''),
|
|
|
|
|
|
'summary' => (string)($meta['intro'] ?? ''),
|
|
|
|
|
|
'words' => (int)($meta['wordCount'] ?? 0),
|
|
|
|
|
|
'rating' => (int)($meta['rating'] ?? 0) ?: null,
|
|
|
|
|
|
'cover_key' => $coverKey,
|
|
|
|
|
|
'status' => 'reading',
|
|
|
|
|
|
]);
|
|
|
|
|
|
if ($bid > 0) {
|
|
|
|
|
|
$newBooks++;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// ---- ② 已在书架 → **只补空的**(绝不覆盖用户填过的)----
|
|
|
|
|
|
$row = Book::find($familyId, $bid);
|
|
|
|
|
|
if ($row) {
|
|
|
|
|
|
$patch = [];
|
|
|
|
|
|
// 封面空着就补(用户第 8 条:封面看不到)
|
|
|
|
|
|
if (trim((string)($row['cover_key'] ?? '')) === '') {
|
|
|
|
|
|
$info = self::bookInfo($personId, (string)$b['bookId']);
|
|
|
|
|
|
if ($info['ok'] && !empty($info['book']['cover'])) {
|
|
|
|
|
|
$ck = self::fetchCover($familyId,
|
|
|
|
|
|
(string)$info['book']['cover'], $title);
|
|
|
|
|
|
if ($ck !== '') { $patch['cover_key'] = $ck; $newCovers++; }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// 简介/ISBN/出版社/字数空着就补
|
|
|
|
|
|
$needMore = trim((string)($row['summary'] ?? '')) === ''
|
|
|
|
|
|
|| trim((string)($row['publisher'] ?? '')) === ''
|
|
|
|
|
|
|| empty($row['words']);
|
|
|
|
|
|
if ($needMore) {
|
|
|
|
|
|
$info = self::bookInfo($personId, (string)$b['bookId']);
|
|
|
|
|
|
if ($info['ok']) {
|
|
|
|
|
|
$m = $info['book'];
|
|
|
|
|
|
if (trim((string)($row['summary'] ?? '')) === '' && $m['intro'] !== '') {
|
|
|
|
|
|
$patch['summary'] = (string)$m['intro'];
|
|
|
|
|
|
}
|
|
|
|
|
|
if (trim((string)($row['publisher'] ?? '')) === '' && $m['publisher'] !== '') {
|
|
|
|
|
|
$patch['publisher'] = (string)$m['publisher'];
|
|
|
|
|
|
}
|
|
|
|
|
|
if (trim((string)($row['isbn'] ?? '')) === '' && $m['isbn'] !== '') {
|
|
|
|
|
|
$patch['isbn'] = (string)$m['isbn'];
|
|
|
|
|
|
}
|
|
|
|
|
|
if (empty($row['words']) && (int)$m['wordCount'] > 0) {
|
|
|
|
|
|
$patch['words'] = (int)$m['wordCount'];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($patch) {
|
|
|
|
|
|
self::patchBook($familyId, $bid, $patch);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($bid <= 0) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- ③ 进度:读完了就把本地状态也标成读完(用户第 8 条"统计"要它)----
|
|
|
|
|
|
$pg = self::progress($personId, (string)$b['bookId']);
|
|
|
|
|
|
if ($pg['ok'] && $pg['finished']) {
|
|
|
|
|
|
$cur = Book::find($familyId, $bid);
|
|
|
|
|
|
if ($cur && (string)$cur['status'] !== 'done') {
|
|
|
|
|
|
Book::setStatus($familyId, $bid, 'done');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- ④ 笔记(已有的会自动跳过,见 store 的去重判据)----
|
|
|
|
|
|
$nt = self::notes($personId, (string)$b['bookId']);
|
|
|
|
|
|
if ($nt['ok']) {
|
|
|
|
|
|
$r = self::store($bid, $personId, (array)$nt['items']);
|
|
|
|
|
|
$newNotes += (int)$r['added'];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-08 10:54:43 +08:00
|
|
|
|
/* 🔴 v29 用户第 4 条:同步完把**书清单与统计**一起落盘。
|
|
|
|
|
|
这一步是"落盘"方案的核心收益所在 —— 之后页面渲染只读这张快照,
|
|
|
|
|
|
不再每次去问官方要(用户原话:「不用每次都读取微信读书的数据了」)。
|
|
|
|
|
|
⚠️ 放在这里(同步的末尾)而不是"每次保存某本书时":
|
|
|
|
|
|
书清单是一份**整体**,逐本保存会让"某几次成功、某一次失败"
|
|
|
|
|
|
留下一份拼凑的清单;这里存的是"这一轮真实扫到的完整清单"。
|
|
|
|
|
|
⚠️ 即使本轮因预算中途停下,也要落盘:清单本身就是 notebooks() 的
|
|
|
|
|
|
原始返回(完整、未被裁剪),"扫了几本"是另一件事(那是书架的补全进度)。
|
|
|
|
|
|
⚠️ 统计与清单都**只在自己成功时**才覆盖:拉失败就保留旧快照,
|
|
|
|
|
|
宁可是旧数据,也不要把页面变成空的。 */
|
|
|
|
|
|
if (!empty($nb['books'])) {
|
|
|
|
|
|
self::snapNotebooks($personId, (array)$nb['books']);
|
|
|
|
|
|
}
|
|
|
|
|
|
$st = self::readStats($personId, 'overall');
|
|
|
|
|
|
if (!empty($st['ok'])) {
|
|
|
|
|
|
self::snapStats($personId, 'overall', (array)$st['data']);
|
|
|
|
|
|
}
|
2026-10-07 11:35:31 +08:00
|
|
|
|
return ['ok' => true, 'error' => '', 'books' => $newBooks, 'notes' => $newNotes,
|
|
|
|
|
|
'covers' => $newCovers, 'scanned' => $scanned];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 只更新**指定几列**(自动同步补空专用;不走 Book::update —— 那会把其它列一起写) */
|
|
|
|
|
|
public static function patchBook(int $familyId, int $bookId, array $fields): void
|
|
|
|
|
|
{
|
|
|
|
|
|
$allow = ['cover_key', 'summary', 'publisher', 'isbn', 'words', 'translator',
|
|
|
|
|
|
'published', 'category', 'rating', 'author'];
|
|
|
|
|
|
$sets = [];
|
|
|
|
|
|
$args = [];
|
|
|
|
|
|
foreach ($fields as $k => $v) {
|
|
|
|
|
|
if (!in_array($k, $allow, true)) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
$sets[] = "`{$k}` = ?";
|
|
|
|
|
|
$args[] = $v === '' ? null : $v;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!$sets) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
$args[] = $bookId;
|
|
|
|
|
|
$args[] = $familyId;
|
|
|
|
|
|
Database::get()->prepare(
|
|
|
|
|
|
'UPDATE books SET ' . implode(', ', $sets) . ' WHERE id = ? AND family_id = ?'
|
|
|
|
|
|
)->execute($args);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 上次自动同步的时间('Y-m-d H:i:s';从没同步过返回空串) */
|
|
|
|
|
|
public static function lastSync(int $personId): string
|
|
|
|
|
|
{
|
|
|
|
|
|
$t = (int)UserSetting::get($personId, self::SYNC_AT_KEY, '0');
|
|
|
|
|
|
return $t > 0 ? date('Y-m-d H:i:s', $t) : '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 下次该自动同步的时间('Y-m-d H:i:s';没配 Key 返回空串) */
|
|
|
|
|
|
public static function nextSync(int $personId): string
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!self::hasKey($personId)) {
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
$t = (int)UserSetting::get($personId, self::SYNC_AT_KEY, '0');
|
|
|
|
|
|
return date('Y-m-d H:i:s', ($t > 0 ? $t : time()) + self::SYNC_TTL);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 是否打开了自动同步(默认开;用户可在导入页关掉) */
|
|
|
|
|
|
public static function autoSyncOn(int $personId): bool
|
|
|
|
|
|
{
|
|
|
|
|
|
return (string)UserSetting::get($personId, self::AUTO_KEY, '1') === '1';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public static function setAutoSync(int $personId, bool $on): void
|
|
|
|
|
|
{
|
|
|
|
|
|
UserSetting::set($personId, self::AUTO_KEY, $on ? '1' : '0');
|
|
|
|
|
|
}
|
2026-10-07 05:52:40 +08:00
|
|
|
|
}
|