Files

253 lines
9.9 KiB
PHP
Raw Permalink Normal View History

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