临时文件路径, '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 里的 ,或 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('#]*>(.*?)#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('#]*>(.*?)#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 id => 项 */ private static function opfManifest(string $opf): array { $out = []; if (!preg_match_all('#]*?)/?>#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('#]*>(.*?)#is', $opf, $m)) { if (preg_match_all('#]*?)/?>#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('#]*name\s*=\s*["\']cover["\'][^>]*content\s*=\s*["\']([^"\']+)["\']#is', $opf, $m)) { $id = trim($m[1]); $href = (string)($manifest[$id]['href'] ?? ''); } if ($href === '') { if (preg_match('#]*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 会混进正文); * ② 块级标签换成换行(


),否则整章粘成一团; * ③ 实体还原(  & …)之后再 strip_tags; * ④ 连续空行压成一个 —— 电子书的源文件里空行多到能占一半篇幅。 */ private static function xhtmlToText(string $html): array { $title = ''; if (preg_match('#]*>(.*?)#is', $html, $m)) { $title = trim(html_entity_decode(strip_tags($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8')); } $body = $html; if (preg_match('#]*>(.*)#is', $body, $m2)) { $body = $m2[1]; if ($title === '' && preg_match('#]*>(.*?)#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[^>]*>.*?#is', '', $body) ?? $body; $body = preg_replace('##i', "\n", $body) ?? $body; $body = preg_replace('##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 */ 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); } }