Files

546 lines
22 KiB
PHP
Raw Permalink Normal View History

<?php
/**
* 图书解析(v24 第 14 批 用户第 5 条):上传一本书 → 读出元数据与正文。
*
* 用户原话:「可以上传图书,可以解析各种格式的图书」。
*
* 能力边界(**页面上必须如实写出来**,别让人以为传什么都能读):
* · **EPUB** —— 完整解析:元数据(书名/作者/出版社/语言/ISBN)+ 封面 + 按 spine 顺序
* 拆出每一章的正文。这是电子书的事实标准,优先做好。
* · **TXT** —— 解析:自动认编码(UTF-8 / GBK / BIG5),按"第X章"切章;
* 切不出来就按 5000 字一段(否则一本 50 万字的书会变成一整页)。
* · **PDF** —— **不提取正文**(服务端没有 PDF 解析扩展,硬写会错得很自信)。
* 文件照存,阅读页用浏览器内嵌的 PDF 阅读器看(每家浏览器都有)。
* · **MOBI / AZW3 / 其它** —— 不解析正文(亚马逊的私有格式,解析要一本书的成本)。
* 文件照存、元数据照手填,仍然能进书架、能记笔记、能统计。
*
* ⚠️ 解析**只做纯文本**:不保留排版、图片、脚注。
* 这是"能读"与"原样还原"之间的取舍 —— 家庭书架要的是"随时翻两页",
* 不是排版引擎(那是一个独立项目的量)。
*
* @return array{ok:bool,error?:string,meta:array,chapters:array,cover?:array}
* cover = ['tmp'=>临时文件路径, 'mime'=>…](有封面才有;调用方负责上传与删临时文件)
*/
declare(strict_types=1);
namespace App;
final class BookParser
{
/** 单章上限(字符):超过就截断,防止一本巨书把库撑爆 */
private const CHAPTER_MAX = 200000;
/** 一本书最多解析多少章 */
private const CHAPTER_LIMIT = 600;
/**
* @param string $path 临时文件路径
* @param string $ext 扩展名(小写)
* @param string $name 原始文件名(用来兜底猜书名/作者)
*/
public static function parse(string $path, string $ext, string $name = ''): array
{
if (!is_file($path)) {
return ['ok' => false, 'error' => '文件读不到', 'meta' => [], 'chapters' => []];
}
$ext = strtolower($ext);
if ($ext === '' && $name !== '') {
$ext = strtolower((string)pathinfo($name, PATHINFO_EXTENSION));
}
switch ($ext) {
case 'epub':
return self::parseEpub($path);
case 'txt':
case 'text':
return self::parseTxt($path, $name);
case 'pdf':
return ['ok' => true, 'error' => '', 'meta' => self::guessFromName($name),
'chapters' => [], 'note' => 'PDF 只存文件(服务端不提取正文),阅读时用浏览器自带的 PDF 阅读器看。'];
default:
return ['ok' => true, 'error' => '', 'meta' => self::guessFromName($name),
'chapters' => [], 'note' => self::formatWord($ext) . ' 正文解析不了,文件已经存好了,元数据请手填。'];
}
}
// ---------------- EPUB ----------------
/**
* EPUB = 一个 zip 包:META-INF/container.xml 指路 → .opf 里放元数据与清单 →
* spine 决定章节顺序 → 每一章是一个 XHTML 文件。
*
* ⚠️ 路径要**相对 opf 所在目录**拼(很多书的 OEBPS/ 与 opf 不在一起),
* 直接用 opf 的路径当基准去拼会 404(ZipArchive::getFromName 返回 false)。
*/
private static function parseEpub(string $path): array
{
if (!class_exists('ZipArchive')) {
return ['ok' => true, 'error' => '', 'meta' => [], 'chapters' => [],
'note' => '服务器没有 zip 扩展,EPUB 的正文解析不了;文件已保存,元数据请手填。'];
}
$zip = new \ZipArchive();
$rc = $zip->open($path);
if ($rc !== true) {
return ['ok' => false, 'error' => '这个 EPUB 打不开(错误码 ' . (int)$rc . ')',
'meta' => [], 'chapters' => []];
}
// ① 找 OPF(书的"目录清单")
$opfPath = '';
$container = $zip->getFromName('META-INF/container.xml');
if ($container !== false && $container !== '') {
if (preg_match('/full-path\s*=\s*["\']([^"\']+)["\']/i', $container, $m)) {
$opfPath = trim($m[1]);
}
}
if ($opfPath === '') {
// 有些书没写 container.xml(不合规范但确实存在)→ 自己找第一个 .opf
for ($i = 0; $i < $zip->numFiles; $i++) {
$n = (string)$zip->getNameIndex($i);
if (substr(strtolower($n), -4) === '.opf') {
$opfPath = $n;
break;
}
}
}
if ($opfPath === '') {
$zip->close();
return ['ok' => false, 'error' => '这个 EPUB 里找不到目录文件(.opf),可能已损坏',
'meta' => [], 'chapters' => []];
}
$opfDir = self::dirOf($opfPath);
$opf = (string)$zip->getFromName($opfPath);
$zip->close();
if ($opf === '') {
return ['ok' => false, 'error' => '这个 EPUB 的目录文件是空的', 'meta' => [], 'chapters' => []];
}
$meta = self::opfMeta($opf);
// ② 清单:id → 文件
$manifest = self::opfManifest($opf);
// ③ 顺序:spine 里的 idref
$spine = self::opfSpine($opf, $manifest);
// ④ 封面:metadata 里的 <meta name="cover" content="id">,或 manifest 里 properties 带 cover-image
$cover = self::epubCover($path, $opf, $opfDir, $manifest);
$zip2 = new \ZipArchive();
if ($zip2->open($path) !== true) {
return ['ok' => true, 'meta' => $meta, 'chapters' => [], 'cover' => $cover,
'note' => '元数据读到了,正文没读出来(文件打不开第二次)。'];
}
$chapters = [];
$seq = 0;
foreach ($spine as $href) {
if (count($chapters) >= self::CHAPTER_LIMIT) {
break;
}
$full = self::joinPath($opfDir, $href);
$raw = $zip2->getFromName($full);
// ⚠️ 有些书的 spine 里写了 HTML 实体或大小写不一致 → 退一步按名字再试
if ($raw === false) {
$raw = $zip2->getFromName(ltrim($href, '/'));
}
if ($raw === false || $raw === '') {
continue;
}
[$title, $body] = self::xhtmlToText($raw);
if (trim($body) === '') {
continue;
}
$seq++;
$chapters[] = [
'seq' => $seq,
'title' => $title !== '' ? $title : ('第 ' . $seq . ' 章'),
'content' => mb_substr($body, 0, self::CHAPTER_MAX, 'UTF-8'),
];
}
$zip2->close();
return ['ok' => true, 'error' => '', 'meta' => $meta, 'chapters' => $chapters, 'cover' => $cover];
}
/** OPF 的 metadata:书名 / 作者 / 出版社 / 语言 / ISBN / 简介 */
private static function opfMeta(string $opf): array
{
$get = static function (string $tag) use ($opf): string {
if (preg_match_all('#<dc:' . $tag . '[^>]*>(.*?)</dc:' . $tag . '>#is', $opf, $m)) {
foreach ($m[1] as $v) {
$v = trim(html_entity_decode(strip_tags($v), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
if ($v !== '') {
return $v;
}
}
}
return '';
};
$isbn = '';
if (preg_match_all('#<dc:identifier[^>]*>(.*?)</dc:identifier>#is', $opf, $mm)) {
foreach ($mm[0] as $i => $full) {
$v = trim(strip_tags($mm[1][$i]));
// opf:scheme="ISBN" 或 urn:isbn:xxxx
if (stripos($full, 'ISBN') !== false || stripos($v, 'isbn') === 0) {
$isbn = preg_replace('/^urn:isbn:/i', '', $v) ?? '';
break;
}
if ($isbn === '' && preg_match('/^(97[89])?\d{9}[\dXx]$/', $v)) {
$isbn = $v;
}
}
}
$date = $get('date');
return [
'title' => $get('title'),
'subtitle' => '',
'author' => $get('creator'),
'publisher' => $get('publisher'),
'language' => $get('language'),
'isbn' => $isbn,
'published' => $date !== '' ? substr($date, 0, 10) : '',
'summary' => $get('description'),
];
}
/** @return array<string,array{href:string,type:string,props:string}> id => 项 */
private static function opfManifest(string $opf): array
{
$out = [];
if (!preg_match_all('#<item\s+([^>]*?)/?>#is', $opf, $m)) {
return $out;
}
foreach ($m[1] as $attr) {
$a = self::attrs($attr);
$id = (string)($a['id'] ?? '');
if ($id === '') {
continue;
}
$out[$id] = [
'href' => html_entity_decode((string)($a['href'] ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'type' => strtolower((string)($a['media-type'] ?? '')),
'props' => strtolower((string)($a['properties'] ?? '')),
];
}
return $out;
}
/** @return string[] 按阅读顺序排的正文文件(相对 opf 目录) */
private static function opfSpine(string $opf, array $manifest): array
{
$ids = [];
if (preg_match('#<spine\b[^>]*>(.*?)</spine>#is', $opf, $m)) {
if (preg_match_all('#<itemref\s+([^>]*?)/?>#is', $m[1], $mm)) {
foreach ($mm[1] as $attr) {
$a = self::attrs($attr);
$id = (string)($a['idref'] ?? '');
if ($id !== '') {
$ids[] = $id;
}
}
}
}
$out = [];
foreach ($ids as $id) {
$it = $manifest[$id] ?? null;
if (!$it) {
continue;
}
$t = (string)$it['type'];
// 只收正文档(xhtml / html / xml);图片、css、ncx 跳过
if ($t !== '' && strpos($t, 'html') === false && strpos($t, 'xml') === false) {
continue;
}
$out[] = (string)$it['href'];
}
// spine 为空(不合规范)→ 退一步:按 manifest 里所有 html 收
if (!$out) {
foreach ($manifest as $it) {
$t = (string)$it['type'];
if (strpos($t, 'html') !== false) {
$out[] = (string)$it['href'];
}
}
}
return $out;
}
/** 封面:找出图并存到临时文件(调用方再交给 Upload) */
private static function epubCover(string $path, string $opf, string $opfDir, array $manifest): ?array
{
$href = '';
if (preg_match('#<meta\s+[^>]*name\s*=\s*["\']cover["\'][^>]*content\s*=\s*["\']([^"\']+)["\']#is', $opf, $m)) {
$id = trim($m[1]);
$href = (string)($manifest[$id]['href'] ?? '');
}
if ($href === '') {
if (preg_match('#<meta\s+[^>]*content\s*=\s*["\']([^"\']+)["\'][^>]*name\s*=\s*["\']cover["\']#is', $opf, $m2)) {
$id = trim($m2[1]);
$href = (string)($manifest[$id]['href'] ?? '');
}
}
if ($href === '') {
foreach ($manifest as $it) {
if (strpos((string)$it['props'], 'cover-image') !== false) {
$href = (string)$it['href'];
break;
}
}
}
if ($href === '') {
foreach ($manifest as $it) {
if (strpos((string)$it['type'], 'image/') === 0
&& stripos((string)$it['href'], 'cover') !== false) {
$href = (string)$it['href'];
break;
}
}
}
if ($href === '') {
return null;
}
if (!class_exists('ZipArchive')) {
return null;
}
$zip = new \ZipArchive();
if ($zip->open($path) !== true) {
return null;
}
$full = self::joinPath($opfDir, $href);
$bin = $zip->getFromName($full);
if ($bin === false) {
$bin = $zip->getFromName(ltrim($href, '/'));
}
$zip->close();
if ($bin === false || $bin === '') {
return null;
}
$tmp = tempnam(sys_get_temp_dir(), 'fbcov');
if ($tmp === false || @file_put_contents($tmp, $bin) === false) {
return null;
}
$fino = new \finfo(FILEINFO_MIME_TYPE);
$mime = (string)$fino->file($tmp);
if (strpos($mime, 'image/') !== 0) {
@unlink($tmp);
return null;
}
return ['tmp' => $tmp, 'mime' => $mime];
}
/**
* XHTML → 纯文本(保留段落换行)。
*
* ⚠️ 不是"去标签"就完事:
* ① 先砍掉 script / style / head(不然 CSS 与 JS 会混进正文);
* ② 块级标签换成换行(<p><div><br><h*>),否则整章粘成一团;
* ③ 实体还原(&nbsp; &amp; …)之后再 strip_tags;
* ④ 连续空行压成一个 —— 电子书的源文件里空行多到能占一半篇幅。
*/
private static function xhtmlToText(string $html): array
{
$title = '';
if (preg_match('#<title[^>]*>(.*?)</title>#is', $html, $m)) {
$title = trim(html_entity_decode(strip_tags($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
$body = $html;
if (preg_match('#<body\b[^>]*>(.*)</body>#is', $body, $m2)) {
$body = $m2[1];
if ($title === '' && preg_match('#<h[1-4][^>]*>(.*?)</h[1-4]>#is', $body, $m3)) {
$title = trim(html_entity_decode(strip_tags($m3[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
}
$body = preg_replace('#<(script|style|noscript)\b[^>]*>.*?</\1>#is', '', $body) ?? $body;
$body = preg_replace('#<br\s*/?>#i', "\n", $body) ?? $body;
$body = preg_replace('#</(p|div|h[1-6]|li|tr|blockquote)>#i', "\n", $body) ?? $body;
$body = html_entity_decode(strip_tags($body), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$body = str_replace(["\r\n", "\r", "\xC2\xA0"], ["\n", "\n", ' '], $body);
$body = preg_replace('/[ \t]+\n/', "\n", $body) ?? $body;
$body = preg_replace('/\n{3,}/', "\n\n", $body) ?? $body;
$body = preg_replace('/^\n+|\n+$/', '', $body) ?? $body;
return [mb_substr($title, 0, 300, 'UTF-8'), $body];
}
// ---------------- TXT ----------------
private static function parseTxt(string $path, string $name): array
{
$raw = (string)@file_get_contents($path);
if ($raw === '') {
return ['ok' => false, 'error' => '这个文本文件是空的', 'meta' => [], 'chapters' => []];
}
$text = self::toUtf8($raw);
$chapters = self::splitChapters($text);
return ['ok' => true, 'error' => '', 'meta' => self::guessFromName($name), 'chapters' => $chapters];
}
/**
* 编码兜底:UTF-8 → GB18030(中文电子书最常见的老编码)→ BIG5 → Latin1。
* ⚠️ 不认编码就硬读,中文会整页乱码 —— 那是"看起来解析了、其实全是问号"。
*/
private static function toUtf8(string $raw): string
{
if (substr($raw, 0, 3) === "\xEF\xBB\xBF") {
return substr($raw, 3);
}
if (mb_check_encoding($raw, 'UTF-8')) {
return $raw;
}
foreach (['GB18030', 'BIG-5', 'ISO-8859-1'] as $enc) {
$s = @iconv($enc, 'UTF-8//IGNORE', $raw);
if ($s !== false && $s !== '') {
return $s;
}
}
return $raw;
}
/**
* 切章:认"第X章/第X节/序/前言/后记/Chapter N"这类行;
* 认不出来就按 5000 字一段(不然一本长篇会变成一整页,浏览器直接卡死)。
*/
private static function splitChapters(string $text): array
{
$text = str_replace(["\r\n", "\r"], "\n", $text);
$lines = explode("\n", $text);
$re = '/^\s*(第\s*[0-9零一二三四五六七八九十百千万]+\s*[章节回部篇卷集]\s*'
. '|chapter\s+[0-9ivx]+\b'
. '|序章|序言|前言|自序|后记|附录|尾声|楔子)/iu';
$chapters = [];
$curTitle = '';
$buf = '';
$flush = static function () use (&$chapters, &$curTitle, &$buf): void {
$body = trim($buf);
if ($body !== '') {
$chapters[] = ['title' => $curTitle, 'content' => mb_substr($body, 0, self::CHAPTER_MAX, 'UTF-8')];
}
$buf = '';
};
foreach ($lines as $ln) {
$t = trim($ln);
if ($t !== '' && mb_strlen($t, 'UTF-8') <= 60 && preg_match($re, $t)) {
$flush();
$curTitle = mb_substr($t, 0, 200, 'UTF-8');
continue;
}
$buf .= $ln . "\n";
}
$flush();
if (count($chapters) <= 1) {
// 没切出来 → 按字数均分
$chapters = [];
$n = mb_strlen($text, 'UTF-8');
$size = 5000;
$parts = (int)max(1, ceil($n / $size));
for ($i = 0; $i < $parts; $i++) {
$seg = mb_substr($text, $i * $size, $size, 'UTF-8');
if (trim($seg) === '') {
continue;
}
$chapters[] = ['title' => '第 ' . ($i + 1) . ' 段', 'content' => $seg];
}
}
$out = [];
$seq = 0;
foreach ($chapters as $c) {
if (count($out) >= self::CHAPTER_LIMIT) {
break;
}
$seq++;
$out[] = ['seq' => $seq, 'title' => (string)$c['title'], 'content' => (string)$c['content']];
}
return $out;
}
// ---------------- 文件名猜书名 / 作者 ----------------
/**
* 从 "三体(全三册)- 刘慈欣.epub" 这类文件名里猜。
* ⚠️ 只是**兜底**:猜错了用户一眼能改,比空着强(空着要一个字一个字敲)。
*/
private static function guessFromName(string $name): array
{
$base = pathinfo((string)$name, PATHINFO_FILENAME);
$base = trim(preg_replace('/[\[\((【].*?[\]\))】]/u', '', $base) ?? $base);
$author = '';
$title = $base;
foreach ([' - ', '—', '-', '_'] as $sep) {
if (strpos($base, $sep) !== false) {
$parts = explode($sep, $base);
$title = trim((string)array_pop($parts));
$author = trim(implode(' ', $parts));
break;
}
}
// "作者 书名" 反过来也常见:名字很短的那一段更像作者
if ($author === '' && preg_match('/^(.+?)\s{1}(.+)$/u', $base, $m)) {
$author = trim($m[1]);
$title = trim($m[2]);
}
$title = trim(preg_replace('/\.(epub|txt|pdf|mobi|azw3?)$/i', '', $title) ?? $title);
return [
'title' => mb_substr($title, 0, 300, 'UTF-8'),
'subtitle' => '',
'author' => mb_substr($author, 0, 200, 'UTF-8'),
'publisher' => '',
'language' => '',
'isbn' => '',
'published' => '',
'summary' => '',
];
}
public static function formatWord(string $ext): string
{
$map = ['epub' => 'EPUB', 'txt' => 'TXT', 'pdf' => 'PDF', 'mobi' => 'MOBI',
'azw' => 'AZW', 'azw3' => 'AZW3', 'doc' => 'DOC', 'docx' => 'DOCX'];
return $map[strtolower($ext)] ?? strtoupper($ext);
}
/** 这个文件能不能直接放进浏览器内嵌阅读(PDF 就是) */
public static function inlinable(?string $fmt): bool
{
return strtolower((string)$fmt) === 'pdf';
}
// ---------------- 小工具 ----------------
/** @return array<string,string> */
private static function attrs(string $s): array
{
$out = [];
if (preg_match_all('/([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(["\'])(.*?)\2/s', $s, $m, PREG_SET_ORDER)) {
foreach ($m as $x) {
$out[strtolower($x[1])] = $x[3];
}
}
return $out;
}
private static function dirOf(string $p): string
{
$p = str_replace('\\', '/', $p);
$i = strrpos($p, '/');
return $i === false ? '' : substr($p, 0, $i + 1);
}
/** 把 opf 目录与 href 拼成 zip 内的完整路径(顺手去掉 ../ 与锚点) */
private static function joinPath(string $dir, string $href): string
{
$href = preg_replace('/#.*$/', '', $href) ?? $href;
$href = str_replace('\\', '/', trim(urldecode($href)));
$full = $dir . $href;
// 消掉 a/b/../c
$parts = [];
foreach (explode('/', str_replace('\\', '/', $full)) as $seg) {
if ($seg === '' || $seg === '.') {
continue;
}
if ($seg === '..') {
array_pop($parts);
continue;
}
$parts[] = $seg;
}
return implode('/', $parts);
}
}