412 lines
17 KiB
PHP
412 lines
17 KiB
PHP
<?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);
|
|||
|
|
}
|
|||
|
|
}
|