253 lines
9.9 KiB
PHP
253 lines
9.9 KiB
PHP
<?php
|
||||
|
|
/**
|
|||
|
|
* 读书笔记导入(v24 第 14 批 用户第 5 条:「可以导入微信读书等各种读书软件的读书笔记」)。
|
|||
|
|
*
|
|||
|
|
* 为什么自己写解析而不是要求用户填 CSV:
|
|||
|
|
* 各家读书 App 的导出都是**给人看的文本**(微信读书复制出来就是一大段 Markdown),
|
|||
|
|
* 没有两家格式一样,也没有稳定的机器格式。让人先去"整理成 CSV"等于没做这个功能。
|
|||
|
|
* 所以这里做的是**宽容解析**:认得出书名的分行、认得出划线/想法的标记,
|
|||
|
|
* 认不出的部分按上下文归类 —— 宁可归类得不完美,也不要丢字。
|
|||
|
|
*
|
|||
|
|
* 认得的标记(都来自真机复制出来的样本):
|
|||
|
|
* · 书名 —— `## 活着` / `# 活着` / `《活着》` / `活着(余华)`
|
|||
|
|
* · 作者 —— 书名行里的括号、或 `作者:余华`
|
|||
|
|
* · 章节 —— `◆ 第一章 xxx`
|
|||
|
|
* · 划线 —— `> 原文`、`### 划线` 之后的裸行
|
|||
|
|
* · 想法 —— `>> 我的想法`、`### 笔记` / `### 想法` 之后的裸行
|
|||
|
|
*
|
|||
|
|
* ⚠️ 只做**纯文本**:不解析 HTML(微信读书导出还有 HTML 版,但那种各家模板不同,
|
|||
|
|
* 而且要防注入 —— 用户贴进来的东西一律当纯文本存,页面展示时转义)。
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
declare(strict_types=1);
|
|||
|
|
|
|||
|
|
namespace App;
|
|||
|
|
|
|||
|
|
final class BookNoteImport
|
|||
|
|
{
|
|||
|
|
/** 解析一大段文本 → 若干本书 + 各自的笔记 */
|
|||
|
|
public static function parse(string $raw): array
|
|||
|
|
{
|
|||
|
|
$raw = str_replace(["\r\n", "\r"], "\n", trim($raw));
|
|||
|
|
$lines = explode("\n", $raw);
|
|||
|
|
$books = [];
|
|||
|
|
$cur = -1; // 当前书在 $books 里的下标
|
|||
|
|
$mode = 'quote'; // 当前归类:quote(划线)/ note(想法)
|
|||
|
|
$chapter = '';
|
|||
|
|
|
|||
|
|
foreach ($lines as $ln) {
|
|||
|
|
$t = trim($ln);
|
|||
|
|
if ($t === '') {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---- 书名:Markdown 标题 / 《》 / 单独一行且很短 ----
|
|||
|
|
$heading = self::headingText($t);
|
|||
|
|
$isSection = $heading !== null && self::isSectionWord($heading);
|
|||
|
|
if ($heading !== null && !$isSection) {
|
|||
|
|
[$title, $author] = self::splitTitleAuthor($heading);
|
|||
|
|
if ($title !== '') {
|
|||
|
|
$books[] = ['title' => $title, 'author' => $author, 'notes' => []];
|
|||
|
|
$cur = count($books) - 1;
|
|||
|
|
$mode = 'quote';
|
|||
|
|
$chapter = '';
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (preg_match('/^《(.+?)》/u', $t, $m)) {
|
|||
|
|
[$title, $author] = self::splitTitleAuthor(trim($m[1]) . mb_substr($t, mb_strlen($m[0], 'UTF-8'), null, 'UTF-8'));
|
|||
|
|
$books[] = ['title' => $title, 'author' => $author, 'notes' => []];
|
|||
|
|
$cur = count($books) - 1;
|
|||
|
|
$mode = 'quote';
|
|||
|
|
$chapter = '';
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
// ---- 分节标记:划线 / 笔记 / 想法 ----
|
|||
|
|
if ($isSection) {
|
|||
|
|
$mode = self::modeOfWord($heading);
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
// ---- 作者行 ----
|
|||
|
|
if ($cur >= 0 && preg_match('/^(作者|作者名)\s*[::]\s*(.+)$/u', $t, $m)) {
|
|||
|
|
if ((string)$books[$cur]['author'] === '') {
|
|||
|
|
$books[$cur]['author'] = trim($m[2]);
|
|||
|
|
}
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
// ---- 章节:◆ / ● 开头 ----
|
|||
|
|
if (preg_match('/^[◆●]\s*(.+)$/u', $t, $m)) {
|
|||
|
|
$chapter = mb_substr(trim($m[1]), 0, 200, 'UTF-8');
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
// ---- 时间行(2023-05-12 / 2023年5月12日)→ 丢掉,它不是笔记内容 ----
|
|||
|
|
if (preg_match('/^\d{4}[-\/年]\d{1,2}[-\/月]\d{1,2}/u', $t)
|
|||
|
|
&& mb_strlen($t, 'UTF-8') <= 30) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
// ---- 想法:`>>` 开头 ----
|
|||
|
|
if (strpos($t, '>>') === 0) {
|
|||
|
|
$c = trim(mb_substr($t, 2, null, 'UTF-8'));
|
|||
|
|
if ($c !== '') {
|
|||
|
|
self::push($books, $cur, 'note', $chapter, $c);
|
|||
|
|
}
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
// ---- 划线:`>` 开头 ----
|
|||
|
|
if (strpos($t, '>') === 0) {
|
|||
|
|
$c = trim(mb_substr($t, 1, null, 'UTF-8'));
|
|||
|
|
if ($c !== '') {
|
|||
|
|
self::push($books, $cur, 'quote', $chapter, $c);
|
|||
|
|
}
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
// ---- 裸行:按当前归类 ----
|
|||
|
|
self::push($books, $cur, $mode, $chapter, $t);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 一条笔记都没归到任何书(用户贴了一段没有书名的话)→ 归到"未命名的一本"
|
|||
|
|
return array_values(array_filter($books, static function (array $b): bool {
|
|||
|
|
return count($b['notes']) > 0 || $b['title'] !== '';
|
|||
|
|
}));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 把解析结果落库:能匹配到已有书就挂上去,匹配不到就新建一本 */
|
|||
|
|
public static function import(int $familyId, int $personId, array $books, string $source): array
|
|||
|
|
{
|
|||
|
|
$created = 0;
|
|||
|
|
$added = 0;
|
|||
|
|
$skipped = 0;
|
|||
|
|
$names = [];
|
|||
|
|
foreach ($books as $b) {
|
|||
|
|
$title = trim((string)($b['title'] ?? ''));
|
|||
|
|
if ($title === '') {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
$notes = $b['notes'] ?? [];
|
|||
|
|
if (!$notes) {
|
|||
|
|
$skipped++;
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
$bookId = self::matchBook($familyId, $title);
|
|||
|
|
if ($bookId === 0) {
|
|||
|
|
$bookId = Book::create($familyId, $personId, [
|
|||
|
|
'title' => $title,
|
|||
|
|
'author' => (string)($b['author'] ?? ''),
|
|||
|
|
'status' => 'done', // 有笔记 = 至少读过;标成"读完"比"想读"贴近事实
|
|||
|
|
'owner_id' => $personId,
|
|||
|
|
]);
|
|||
|
|
$created++;
|
|||
|
|
}
|
|||
|
|
foreach ($notes as $n) {
|
|||
|
|
$id = Book::addNote($familyId, $bookId, $personId, [
|
|||
|
|
'kind' => (string)($n['kind'] ?? 'note'),
|
|||
|
|
'chapter' => (string)($n['chapter'] ?? ''),
|
|||
|
|
'content' => (string)($n['content'] ?? ''),
|
|||
|
|
'source' => $source,
|
|||
|
|
]);
|
|||
|
|
if ($id > 0) {
|
|||
|
|
$added++;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
$names[] = $title;
|
|||
|
|
}
|
|||
|
|
return ['created' => $created, 'added' => $added, 'skipped' => $skipped, 'titles' => $names];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 按书名找已有的一本(去空格、去大小写;找不到返回 0) */
|
|||
|
|
public static function matchBook(int $familyId, string $title): int
|
|||
|
|
{
|
|||
|
|
$key = self::key($title);
|
|||
|
|
if ($key === '') {
|
|||
|
|
return 0;
|
|||
|
|
}
|
|||
|
|
$st = Database::get()->prepare('SELECT id, title FROM books WHERE family_id = ?');
|
|||
|
|
$st->execute([$familyId]);
|
|||
|
|
foreach ($st->fetchAll() as $r) {
|
|||
|
|
if (self::key((string)$r['title']) === $key) {
|
|||
|
|
return (int)$r['id'];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public static function sourceOf(?string $raw): string
|
|||
|
|
{
|
|||
|
|
$s = strtolower((string)$raw);
|
|||
|
|
if (strpos($s, 'weread') !== false || strpos($s, '微信读书') !== false) {
|
|||
|
|
return 'weread';
|
|||
|
|
}
|
|||
|
|
if (strpos($s, 'douban') !== false || strpos($s, '豆瓣') !== false) {
|
|||
|
|
return 'douban';
|
|||
|
|
}
|
|||
|
|
return 'other';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------------- 内部 ----------------
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* @param int $cur **按引用传**:第一次遇到"没有书名就先来了一段笔记"时,
|
|||
|
|
* 这里会建一本「未命名的一本」,而外面的 $cur 必须跟着指向它 ——
|
|||
|
|
* 否则后面每一段都会各自新建一本(实测:一段话变出 7 本书)。
|
|||
|
|
*/
|
|||
|
|
private static function push(array &$books, int &$cur, string $kind, string $chapter, string $content): void
|
|||
|
|
{
|
|||
|
|
if ($cur < 0) {
|
|||
|
|
// 第一段文字之前没有书名 → 先建一本"未命名",用户导入后可以改
|
|||
|
|
$books[] = ['title' => '未命名的一本', 'author' => '', 'notes' => []];
|
|||
|
|
$cur = count($books) - 1;
|
|||
|
|
}
|
|||
|
|
$books[$cur]['notes'][] = [
|
|||
|
|
'kind' => $kind,
|
|||
|
|
'chapter' => $chapter,
|
|||
|
|
'content' => mb_substr($content, 0, 8000, 'UTF-8'),
|
|||
|
|
];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** `# xxx` / `## xxx` → xxx;不是标题返回 null */
|
|||
|
|
private static function headingText(string $t): ?string
|
|||
|
|
{
|
|||
|
|
if (!preg_match('/^#{1,6}\s*(.+)$/u', $t, $m)) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
return trim($m[1]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 这个标题是"划线/笔记/想法"这类**分节**标记,还是书名 */
|
|||
|
|
private static function isSectionWord(?string $h): bool
|
|||
|
|
{
|
|||
|
|
$h = (string)$h;
|
|||
|
|
return (bool)preg_match('/^(划线|笔记|想法|感想|书评|摘录|原文|批注)/u', $h);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static function modeOfWord(?string $h): string
|
|||
|
|
{
|
|||
|
|
$h = (string)$h;
|
|||
|
|
if (preg_match('/^(笔记|想法|感想|书评|批注)/u', $h)) {
|
|||
|
|
return 'note';
|
|||
|
|
}
|
|||
|
|
return 'quote';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 「活着(余华)」/「活着 - 刘慈欣」→ [书名, 作者] */
|
|||
|
|
private static function splitTitleAuthor(string $s): array
|
|||
|
|
{
|
|||
|
|
$s = trim($s);
|
|||
|
|
$author = '';
|
|||
|
|
if (preg_match('/^(.*?)[((【]([^))】]+)[))】]\s*$/u', $s, $m)) {
|
|||
|
|
$s = trim($m[1]);
|
|||
|
|
$author = trim($m[2]);
|
|||
|
|
} elseif (preg_match('/^(.*?)\s+-\s+(.+)$/u', $s, $m)) {
|
|||
|
|
$s = trim($m[1]);
|
|||
|
|
$author = trim($m[2]);
|
|||
|
|
}
|
|||
|
|
$s = trim(preg_replace('/^[《](.+?)[》]/u', '$1', $s) ?? $s);
|
|||
|
|
return [mb_substr($s, 0, 300, 'UTF-8'), mb_substr($author, 0, 200, 'UTF-8')];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static function key(string $s): string
|
|||
|
|
{
|
|||
|
|
$s = preg_replace('/\s+/u', '', $s) ?? $s;
|
|||
|
|
return mb_strtolower(trim($s), 'UTF-8');
|
|||
|
|
}
|
|||
|
|
}
|