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); } }