Files
FamilyBook/app/WeRead.php
T

412 lines
17 KiB
PHP
Raw Normal View History

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