bookfetch 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bookfetch/__init__.py +3 -0
- bookfetch/cli.py +169 -0
- bookfetch/model.py +64 -0
- bookfetch/sources/__init__.py +49 -0
- bookfetch/sources/base.py +24 -0
- bookfetch/sources/ctext.py +151 -0
- bookfetch/sources/github.py +152 -0
- bookfetch/sources/libgen.py +147 -0
- bookfetch/sources/wikisource.py +239 -0
- bookfetch/util/__init__.py +92 -0
- bookfetch/util/epub.py +156 -0
- bookfetch/util/simplify.py +19 -0
- bookfetch/util/splitters.py +72 -0
- bookfetch-0.3.0.dist-info/METADATA +146 -0
- bookfetch-0.3.0.dist-info/RECORD +17 -0
- bookfetch-0.3.0.dist-info/WHEEL +4 -0
- bookfetch-0.3.0.dist-info/entry_points.txt +2 -0
bookfetch/__init__.py
ADDED
bookfetch/cli.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""bookfetch CLI — JSON-first output for agents, --human for people."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .model import Book, Chapter, FetchResult
|
|
12
|
+
from .sources import get_source, search_all, source_names
|
|
13
|
+
from .util import FetchError, sanitize_filename
|
|
14
|
+
from .util.epub import build_epub
|
|
15
|
+
from .util.simplify import to_simplified
|
|
16
|
+
from .util.splitters import split_headings
|
|
17
|
+
|
|
18
|
+
DESC = "Agent-friendly ebook finder: routes book queries to working sources."
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _human_search(obj: dict) -> None:
|
|
22
|
+
for i, b in enumerate(obj["results"], 1):
|
|
23
|
+
extra = f" [{b['subtitle']}]" if b.get("subtitle") else ""
|
|
24
|
+
print(f"{i}. [{b['source']}] {b['title']} (id={b['id']}){extra}")
|
|
25
|
+
if obj.get("errors"):
|
|
26
|
+
for src, err in obj["errors"].items():
|
|
27
|
+
print(f" ! {src}: {err}", file=sys.stderr)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _human_get(obj: dict) -> None:
|
|
31
|
+
r = obj["result"]
|
|
32
|
+
print(f"Saved: {r['out_path']}")
|
|
33
|
+
extra = f" | {len(r['chapters'])} chapters" if r.get("chapters") else ""
|
|
34
|
+
print(f" {r['title']} | {r['lines']} paragraphs | {r['chars']} chars | {r['format']}{extra}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _ensure_chapters(fr: FetchResult) -> list[Chapter]:
|
|
38
|
+
"""Chapters from the source, or heading-split, or one whole-text chapter."""
|
|
39
|
+
if fr.chapters:
|
|
40
|
+
return list(fr.chapters)
|
|
41
|
+
chs = split_headings(fr.content.splitlines())
|
|
42
|
+
if chs:
|
|
43
|
+
return chs
|
|
44
|
+
return [Chapter(title=fr.title or fr.id, text=fr.content)]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _render_get(src, book: Book, args) -> FetchResult:
|
|
48
|
+
"""Fetch -> optional simplify -> render txt/epub under --out.
|
|
49
|
+
|
|
50
|
+
Binary sources (libgen etc.) return FetchResult.raw and are saved
|
|
51
|
+
byte-for-byte; text-only flags (--simplify/--split/--format) reject them.
|
|
52
|
+
"""
|
|
53
|
+
fr = src.fetch(book)
|
|
54
|
+
|
|
55
|
+
if fr.raw is not None: # binary passthrough: save the original file
|
|
56
|
+
if args.simplify or args.split or args.format != "txt":
|
|
57
|
+
raise ValueError(f"源 {fr.source} 是二进制原文件(.{fr.format}),不支持 --simplify/--split/--format")
|
|
58
|
+
fname = sanitize_filename(fr.title) or fr.id
|
|
59
|
+
out_dir = Path(args.out)
|
|
60
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
path = out_dir / f"{fname}.{fr.format}"
|
|
62
|
+
path.write_bytes(fr.raw)
|
|
63
|
+
fr.out_path = str(path)
|
|
64
|
+
return fr
|
|
65
|
+
|
|
66
|
+
chapters = _ensure_chapters(fr)
|
|
67
|
+
|
|
68
|
+
if args.simplify:
|
|
69
|
+
chapters = [Chapter(title=to_simplified(c.title), text=to_simplified(c.text)) for c in chapters]
|
|
70
|
+
fr.title = to_simplified(fr.title)
|
|
71
|
+
|
|
72
|
+
fname = sanitize_filename(fr.title) or fr.id
|
|
73
|
+
out_dir = Path(args.out)
|
|
74
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
|
|
76
|
+
if args.format == "epub":
|
|
77
|
+
path = build_epub(fr.title, chapters, out_dir / f"{fname}.epub")
|
|
78
|
+
text_chars = sum(len(c.text) for c in chapters)
|
|
79
|
+
n_lines = sum(len(c.text.splitlines()) for c in chapters)
|
|
80
|
+
else: # txt
|
|
81
|
+
if args.split and len(chapters) > 1:
|
|
82
|
+
parts = []
|
|
83
|
+
for i, c in enumerate(chapters, 1):
|
|
84
|
+
head = c.title or f"第{i}部分"
|
|
85
|
+
parts.append(f"=== {head} ===\n{c.text}")
|
|
86
|
+
text = "\n\n".join(parts) + "\n"
|
|
87
|
+
elif args.simplify:
|
|
88
|
+
text = "\n".join(c.text for c in chapters) + "\n"
|
|
89
|
+
else:
|
|
90
|
+
text = fr.content # byte-identical to the fetched text
|
|
91
|
+
path = out_dir / f"{fname}.txt"
|
|
92
|
+
path.write_text(text, encoding="utf-8")
|
|
93
|
+
text_chars = len(text)
|
|
94
|
+
n_lines = len(text.splitlines())
|
|
95
|
+
|
|
96
|
+
fr.out_path = str(path)
|
|
97
|
+
fr.format = args.format
|
|
98
|
+
fr.chars = text_chars
|
|
99
|
+
fr.lines = n_lines
|
|
100
|
+
return fr
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def main(argv: list[str] | None = None) -> int:
|
|
104
|
+
p = argparse.ArgumentParser(prog="bookfetch", description=DESC)
|
|
105
|
+
p.add_argument("--version", action="version", version=f"bookfetch {__version__}")
|
|
106
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
107
|
+
|
|
108
|
+
sp = sub.add_parser("search", help="search sources for a book")
|
|
109
|
+
sp.add_argument("query", help="book title or keywords (Chinese OK)")
|
|
110
|
+
sp.add_argument("--source", action="append", default=None, help="only search this source (repeatable)")
|
|
111
|
+
sp.add_argument("--limit", type=int, default=20, help="max results (default 20)")
|
|
112
|
+
sp.add_argument("--human", action="store_true", help="human-readable output")
|
|
113
|
+
|
|
114
|
+
gp = sub.add_parser("get", help="download a book edition (id from search)")
|
|
115
|
+
gp.add_argument("source", help="source name, e.g. ctext")
|
|
116
|
+
gp.add_argument("id", help="edition id from search results")
|
|
117
|
+
gp.add_argument("--title", default="", help="optional title override for the output filename")
|
|
118
|
+
gp.add_argument("--out", default=".", help="output directory (default: current dir)")
|
|
119
|
+
gp.add_argument(
|
|
120
|
+
"--format",
|
|
121
|
+
choices=["txt", "epub"],
|
|
122
|
+
default="txt",
|
|
123
|
+
help="output format (default: txt; epub needs no extra deps)",
|
|
124
|
+
)
|
|
125
|
+
gp.add_argument(
|
|
126
|
+
"--split",
|
|
127
|
+
action="store_true",
|
|
128
|
+
help="insert '=== 章节 ===' separators into txt output (epub is always split)",
|
|
129
|
+
)
|
|
130
|
+
gp.add_argument(
|
|
131
|
+
"--simplify",
|
|
132
|
+
action="store_true",
|
|
133
|
+
help="convert Traditional Chinese to Simplified (requires the [simp] extra: OpenCC)",
|
|
134
|
+
)
|
|
135
|
+
gp.add_argument("--human", action="store_true", help="human-readable output")
|
|
136
|
+
|
|
137
|
+
args = p.parse_args(argv)
|
|
138
|
+
try:
|
|
139
|
+
if args.cmd == "search":
|
|
140
|
+
results, errors = search_all(args.query, args.source, args.limit)
|
|
141
|
+
obj = {
|
|
142
|
+
"cmd": "search",
|
|
143
|
+
"query": args.query,
|
|
144
|
+
"results": [b.to_dict() for b in results],
|
|
145
|
+
"count": len(results),
|
|
146
|
+
"errors": errors,
|
|
147
|
+
}
|
|
148
|
+
else: # get
|
|
149
|
+
src = get_source(args.source)
|
|
150
|
+
if src is None:
|
|
151
|
+
raise ValueError(f"unknown source {args.source!r} (known: {', '.join(source_names())})")
|
|
152
|
+
book = Book(source=args.source, id=args.id, title=args.title)
|
|
153
|
+
fr = _render_get(src, book, args)
|
|
154
|
+
obj = {"cmd": "get", "result": fr.to_dict()}
|
|
155
|
+
|
|
156
|
+
if getattr(args, "human", False) and args.cmd == "search":
|
|
157
|
+
_human_search(obj)
|
|
158
|
+
elif getattr(args, "human", False) and args.cmd == "get":
|
|
159
|
+
_human_get(obj)
|
|
160
|
+
else:
|
|
161
|
+
print(json.dumps(obj, ensure_ascii=False))
|
|
162
|
+
return 0
|
|
163
|
+
except (ValueError, FetchError) as e:
|
|
164
|
+
print(json.dumps({"cmd": getattr(args, "cmd", None), "error": str(e)}, ensure_ascii=False))
|
|
165
|
+
return 1
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
if __name__ == "__main__":
|
|
169
|
+
sys.exit(main())
|
bookfetch/model.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Shared result models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class Book:
|
|
10
|
+
"""One downloadable edition found by a source."""
|
|
11
|
+
|
|
12
|
+
source: str
|
|
13
|
+
id: str
|
|
14
|
+
title: str
|
|
15
|
+
url: str = ""
|
|
16
|
+
subtitle: str = ""
|
|
17
|
+
format_hint: str = "txt"
|
|
18
|
+
extra: dict = field(default_factory=dict)
|
|
19
|
+
|
|
20
|
+
def to_dict(self) -> dict:
|
|
21
|
+
return asdict(self)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Chapter:
|
|
26
|
+
"""A titled section of a fetched book (source pages, 《》 headings, ...)."""
|
|
27
|
+
|
|
28
|
+
title: str
|
|
29
|
+
text: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class FetchResult:
|
|
34
|
+
"""Parsed content of a downloaded Book.
|
|
35
|
+
|
|
36
|
+
Sources return content + optional chapter structure WITHOUT writing files;
|
|
37
|
+
the CLI renders the requested format (txt/epub) and sets ``out_path``.
|
|
38
|
+
``content`` is the plain merged text: joining each chapter's ``text`` with
|
|
39
|
+
``"\\n"`` reproduces it exactly (chapters are ordered slices of lines), so
|
|
40
|
+
format rendering never loses or reorders anything.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
source: str
|
|
44
|
+
id: str
|
|
45
|
+
title: str
|
|
46
|
+
out_path: str = ""
|
|
47
|
+
chars: int = 0
|
|
48
|
+
lines: int = 0
|
|
49
|
+
format: str = "txt"
|
|
50
|
+
content: str = ""
|
|
51
|
+
chapters: list[Chapter] | None = None
|
|
52
|
+
raw: bytes | None = None # binary sources (libgen): file bytes, no text pipeline
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict:
|
|
55
|
+
return {
|
|
56
|
+
"source": self.source,
|
|
57
|
+
"id": self.id,
|
|
58
|
+
"title": self.title,
|
|
59
|
+
"out_path": self.out_path,
|
|
60
|
+
"chars": self.chars,
|
|
61
|
+
"lines": self.lines,
|
|
62
|
+
"format": self.format,
|
|
63
|
+
"chapters": [c.title for c in self.chapters] if self.chapters else None,
|
|
64
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Source registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..model import Book
|
|
6
|
+
from .base import Source
|
|
7
|
+
from .ctext import Ctext
|
|
8
|
+
from .github import GithubBooks
|
|
9
|
+
from .libgen import Libgen
|
|
10
|
+
from .wikisource import Wikisource
|
|
11
|
+
|
|
12
|
+
_REGISTRY: dict[str, Source] = {}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _register(src: Source) -> Source:
|
|
16
|
+
_REGISTRY[src.name] = src
|
|
17
|
+
return src
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_register(Ctext())
|
|
21
|
+
_register(GithubBooks())
|
|
22
|
+
_register(Wikisource("zh"))
|
|
23
|
+
_register(Wikisource("en"))
|
|
24
|
+
_register(Libgen())
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_source(name: str) -> Source | None:
|
|
28
|
+
return _REGISTRY.get(name)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def source_names() -> list[str]:
|
|
32
|
+
return list(_REGISTRY)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def search_all(query: str, names: list[str] | None = None, limit: int = 20):
|
|
36
|
+
"""Search across sources. Returns (results, errors_by_source)."""
|
|
37
|
+
names = names or source_names()
|
|
38
|
+
results: list[Book] = []
|
|
39
|
+
errors: dict[str, str] = {}
|
|
40
|
+
for n in names:
|
|
41
|
+
src = _REGISTRY.get(n)
|
|
42
|
+
if src is None:
|
|
43
|
+
errors[n] = f"unknown source (known: {', '.join(source_names())})"
|
|
44
|
+
continue
|
|
45
|
+
try:
|
|
46
|
+
results.extend(src.search(query))
|
|
47
|
+
except Exception as e: # source failure must not kill the whole search
|
|
48
|
+
errors[n] = f"{type(e).__name__}: {e}"
|
|
49
|
+
return results[:limit], errors
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Source adapter interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
from ..model import Book, FetchResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Source(ABC):
|
|
11
|
+
"""One book source (ctext, github, ...). Implementations must be stateless
|
|
12
|
+
except for util's built-in rate limiting."""
|
|
13
|
+
|
|
14
|
+
name: str = "base"
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def search(self, query: str) -> list[Book]:
|
|
18
|
+
"""Return editions matching query. Never raises for network issues —
|
|
19
|
+
callers surface errors via the errors dict instead."""
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def fetch(self, book: Book) -> FetchResult:
|
|
23
|
+
"""Fetch and parse one edition into content + optional chapter
|
|
24
|
+
structure. Never writes files — the CLI renders txt/epub."""
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""ctext.org source adapter (Chinese classics, punctuated full text)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import re
|
|
7
|
+
from urllib.parse import parse_qs, quote, urlparse
|
|
8
|
+
|
|
9
|
+
from ..model import Book, Chapter, FetchResult
|
|
10
|
+
from ..util import FetchError, fetch
|
|
11
|
+
from ..util.splitters import split_headings
|
|
12
|
+
from .base import Source
|
|
13
|
+
|
|
14
|
+
SEARCH_URL = "https://ctext.org/searchbooks.pl?if=gb&searchu={q}"
|
|
15
|
+
RES_URL = "https://ctext.org/wiki.pl?if=gb&res={rid}"
|
|
16
|
+
CHAPTER_URL = "https://ctext.org/wiki.pl?if=gb&chapter={cid}"
|
|
17
|
+
|
|
18
|
+
# A paragraph row is: <tr class="result" id="pN">
|
|
19
|
+
# <td class="ctext" style="width: 60px;" ...>N <a onclick=showDic>...</td> <- line number
|
|
20
|
+
# <td class="ctext">正文…</td> <- the text
|
|
21
|
+
_TD_RE = re.compile(r'<td class="ctext"([^>]*)>(.*?)</td>', re.S)
|
|
22
|
+
_NUM_CELL = re.compile(r"width:\s*60px")
|
|
23
|
+
_ANCHOR_RE = re.compile(r'<a[^>]+href="([^"]+)"[^>]*>(.*?)</a>', re.S)
|
|
24
|
+
_AUTHOR_RE = re.compile(r'<span style="font-weight: bold;">(.*?)</span>', re.S)
|
|
25
|
+
_CHAPTER_RE = re.compile(r'href="[^"]*chapter=(\d+)"[^>]*>(.*?)</a>', re.S)
|
|
26
|
+
|
|
27
|
+
_TAG_RE = re.compile(r"<[^>]+>")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _strip_tags(s: str) -> str:
|
|
31
|
+
return re.sub(r"\s+", " ", html.unescape(_TAG_RE.sub("", s))).strip()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _parse_search_page(page: str) -> list[Book]:
|
|
35
|
+
"""Parse a ctext 書名檢索 result page into Book records."""
|
|
36
|
+
books: list[Book] = []
|
|
37
|
+
for li in re.split(r"<li", page)[1:]:
|
|
38
|
+
m = _ANCHOR_RE.search(li)
|
|
39
|
+
if not m:
|
|
40
|
+
continue
|
|
41
|
+
href = html.unescape(m.group(1))
|
|
42
|
+
title = _strip_tags(m.group(2))
|
|
43
|
+
if "wiki.pl" not in href or "res=" not in href:
|
|
44
|
+
continue # only wiki text editions are downloadable in V1
|
|
45
|
+
rid = parse_qs(urlparse(href).query).get("res", [""])[0]
|
|
46
|
+
if not rid:
|
|
47
|
+
continue
|
|
48
|
+
author = ""
|
|
49
|
+
am = _AUTHOR_RE.search(li)
|
|
50
|
+
if am:
|
|
51
|
+
author = _strip_tags(am.group(1))
|
|
52
|
+
# "(宋)徐子平" -> "徐子平" (drop the era wrapper)
|
|
53
|
+
author = re.sub(r"^[((][^))]*[))]", "", author).strip()
|
|
54
|
+
note = ""
|
|
55
|
+
# note text sits after the bold author span (usually after a <br>),
|
|
56
|
+
# up to the end of the <li> record
|
|
57
|
+
if am:
|
|
58
|
+
tail = li[am.end() :]
|
|
59
|
+
end = tail.find("</li>")
|
|
60
|
+
if end >= 0:
|
|
61
|
+
tail = tail[:end]
|
|
62
|
+
note = _strip_tags(tail)
|
|
63
|
+
books.append(
|
|
64
|
+
Book(
|
|
65
|
+
source="ctext",
|
|
66
|
+
id=rid,
|
|
67
|
+
title=title,
|
|
68
|
+
url=href,
|
|
69
|
+
subtitle=note,
|
|
70
|
+
format_hint="txt",
|
|
71
|
+
extra={"author": author} if author else {},
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
return books
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _parse_text_cells(page: str) -> list[str]:
|
|
78
|
+
"""Extract paragraph text from a ctext wiki text page (td.ctext cells)."""
|
|
79
|
+
lines: list[str] = []
|
|
80
|
+
for attrs, body in _TD_RE.findall(page):
|
|
81
|
+
if _NUM_CELL.search(attrs or ""):
|
|
82
|
+
continue # line-number cell
|
|
83
|
+
txt = _strip_tags(body)
|
|
84
|
+
if txt:
|
|
85
|
+
lines.append(txt)
|
|
86
|
+
return lines
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _chapter_title(anchor: str, idx: int) -> str:
|
|
90
|
+
"""Wiki-page anchor text, or a neutral ordinal for unnamed pages
|
|
91
|
+
(most wiki pages of one book share the book title as anchor)."""
|
|
92
|
+
t = _strip_tags(anchor)
|
|
93
|
+
return t or f"第{idx}部分"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Ctext(Source):
|
|
97
|
+
name = "ctext"
|
|
98
|
+
|
|
99
|
+
def search(self, query: str) -> list[Book]:
|
|
100
|
+
"""Search ctext 書名檢索. Network errors propagate to the CLI errors dict."""
|
|
101
|
+
page = fetch(SEARCH_URL.format(q=quote(query)))
|
|
102
|
+
return _parse_search_page(page)
|
|
103
|
+
|
|
104
|
+
def fetch(self, book: Book) -> FetchResult:
|
|
105
|
+
"""Fetch a whole book: res page -> ordered wiki pages -> chapters.
|
|
106
|
+
|
|
107
|
+
Each wiki page becomes one or more chapters: standalone 《》/卷
|
|
108
|
+
heading lines split it into titled sections; a page without such
|
|
109
|
+
structure stays one chapter named by its anchor.
|
|
110
|
+
"""
|
|
111
|
+
rid = book.id
|
|
112
|
+
if not rid.isdigit():
|
|
113
|
+
raise ValueError(f"ctext id must be a res/chapter number, got: {book.id!r}")
|
|
114
|
+
|
|
115
|
+
res_page = fetch(RES_URL.format(rid=rid))
|
|
116
|
+
anchors = list(dict.fromkeys(_CHAPTER_RE.findall(res_page)))
|
|
117
|
+
if not anchors:
|
|
118
|
+
raise FetchError(f"ctext res page {rid} lists no text chapters")
|
|
119
|
+
|
|
120
|
+
title = book.title or _strip_tags(anchors[0][1])
|
|
121
|
+
chapters: list[Chapter] = []
|
|
122
|
+
all_lines: list[str] = []
|
|
123
|
+
for idx, (cid, anchor) in enumerate(anchors, 1):
|
|
124
|
+
try:
|
|
125
|
+
page = fetch(CHAPTER_URL.format(cid=cid))
|
|
126
|
+
except FetchError as e:
|
|
127
|
+
raise FetchError(f"chapter {cid} failed: {e}") from e
|
|
128
|
+
lines = _parse_text_cells(page)
|
|
129
|
+
if not lines:
|
|
130
|
+
continue
|
|
131
|
+
all_lines.extend(lines)
|
|
132
|
+
page_chs = split_headings(lines)
|
|
133
|
+
if page_chs:
|
|
134
|
+
chapters.extend(page_chs)
|
|
135
|
+
else:
|
|
136
|
+
chapters.append(Chapter(title=_chapter_title(anchor, idx), text="\n".join(lines)))
|
|
137
|
+
|
|
138
|
+
if not all_lines:
|
|
139
|
+
raise FetchError(f"no text extracted from chapters of res {rid}")
|
|
140
|
+
|
|
141
|
+
content = "\n".join(c.text for c in chapters) + "\n"
|
|
142
|
+
return FetchResult(
|
|
143
|
+
source=self.name,
|
|
144
|
+
id=rid,
|
|
145
|
+
title=title,
|
|
146
|
+
chars=len(content),
|
|
147
|
+
lines=len(all_lines),
|
|
148
|
+
format="txt",
|
|
149
|
+
content=content,
|
|
150
|
+
chapters=chapters,
|
|
151
|
+
)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""GitHub-hosted public-domain Chinese classic text collections.
|
|
2
|
+
|
|
3
|
+
Design: each curated repo's blob list is fetched once via the git trees API
|
|
4
|
+
and cached on disk for 7 days (unauthenticated GitHub API quota is 60/hr —
|
|
5
|
+
this keeps usage at ~2 calls per repo per week). Searches match filenames
|
|
6
|
+
locally; downloads go straight to raw.githubusercontent.com (not rate-limited
|
|
7
|
+
the same way).
|
|
8
|
+
|
|
9
|
+
The curated repo list is deliberately small and reviewed: only public-domain
|
|
10
|
+
text collections.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from urllib.parse import quote
|
|
20
|
+
|
|
21
|
+
from ..model import Book, FetchResult
|
|
22
|
+
from ..util import FetchError, fetch
|
|
23
|
+
from ..util.splitters import split_headings
|
|
24
|
+
from .base import Source
|
|
25
|
+
|
|
26
|
+
_REPOS: list[dict] = [
|
|
27
|
+
{
|
|
28
|
+
"repo": "mymmsc/books",
|
|
29
|
+
"branch": "master",
|
|
30
|
+
"note": "综合资料库,含《国学/八字 - 渊海子平.txt》等公版古籍文本",
|
|
31
|
+
},
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
_TREE_URL = "https://api.github.com/repos/{repo}/git/trees/HEAD?recursive=1"
|
|
35
|
+
_RAW_URL = "https://raw.githubusercontent.com/{repo}/{branch}/{path}"
|
|
36
|
+
_BLOB_URL = "https://github.com/{repo}/blob/{branch}/{path}"
|
|
37
|
+
_CACHE_TTL = 7 * 24 * 3600
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _cache_dir() -> Path:
|
|
41
|
+
base = os.environ.get("BOOKFETCH_CACHE") or Path.home() / ".cache" / "bookfetch"
|
|
42
|
+
d = Path(base)
|
|
43
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
return d
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _paths_from_tree(tree: dict) -> list[str]:
|
|
48
|
+
return [t["path"] for t in tree.get("tree", []) if t.get("type") == "blob"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _title_matches(query: str, path: str) -> bool:
|
|
52
|
+
"""Loose filename match: query contained in basename or vice versa."""
|
|
53
|
+
if not path.lower().endswith(".txt"):
|
|
54
|
+
return False
|
|
55
|
+
base = path.rsplit("/", 1)[-1][:-4].strip()
|
|
56
|
+
q = query.strip()
|
|
57
|
+
if not q or not base:
|
|
58
|
+
return False
|
|
59
|
+
return q.lower() in base.lower() or base.lower() in q.lower()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _search_paths(paths: list[str], cfg: dict, query: str) -> list[Book]:
|
|
63
|
+
books: list[Book] = []
|
|
64
|
+
repo = cfg["repo"]
|
|
65
|
+
branch = cfg["branch"]
|
|
66
|
+
for path in paths:
|
|
67
|
+
if not _title_matches(query, path):
|
|
68
|
+
continue
|
|
69
|
+
base = path.rsplit("/", 1)[-1][:-4].strip()
|
|
70
|
+
folder = path.rsplit("/", 1)[0] if "/" in path else ""
|
|
71
|
+
books.append(
|
|
72
|
+
Book(
|
|
73
|
+
source="github",
|
|
74
|
+
id=f"{repo}:{path}",
|
|
75
|
+
title=base,
|
|
76
|
+
url=_BLOB_URL.format(repo=repo, branch=branch, path=quote(path, safe="/")),
|
|
77
|
+
subtitle=cfg.get("note", ""),
|
|
78
|
+
format_hint="txt",
|
|
79
|
+
extra={"repo": repo, "folder": folder},
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
return books
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _repo_paths(cfg: dict) -> list[str]:
|
|
86
|
+
"""Blob paths for a repo, refreshed from the trees API at most every TTL.
|
|
87
|
+
Falls back to a stale cache when the API is rate-limited or down."""
|
|
88
|
+
repo = cfg["repo"]
|
|
89
|
+
cache = _cache_dir() / f"github_tree_{repo.replace('/', '_')}.json"
|
|
90
|
+
stale: list[str] | None = None
|
|
91
|
+
if cache.exists():
|
|
92
|
+
try:
|
|
93
|
+
data = json.loads(cache.read_text(encoding="utf-8"))
|
|
94
|
+
if time.time() - data.get("ts", 0) < _CACHE_TTL:
|
|
95
|
+
return data["paths"]
|
|
96
|
+
stale = data["paths"]
|
|
97
|
+
except Exception:
|
|
98
|
+
stale = None
|
|
99
|
+
try:
|
|
100
|
+
tree = json.loads(fetch(_TREE_URL.format(repo=repo)))
|
|
101
|
+
paths = _paths_from_tree(tree)
|
|
102
|
+
cache.write_text(json.dumps({"ts": time.time(), "paths": paths}), encoding="utf-8")
|
|
103
|
+
return paths
|
|
104
|
+
except FetchError:
|
|
105
|
+
if stale is not None:
|
|
106
|
+
return stale
|
|
107
|
+
raise
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class GithubBooks(Source):
|
|
111
|
+
name = "github"
|
|
112
|
+
|
|
113
|
+
def search(self, query: str) -> list[Book]:
|
|
114
|
+
books: list[Book] = []
|
|
115
|
+
for cfg in _REPOS:
|
|
116
|
+
try:
|
|
117
|
+
paths = _repo_paths(cfg)
|
|
118
|
+
except FetchError:
|
|
119
|
+
raise # surfaced via registry errors dict
|
|
120
|
+
books.extend(_search_paths(paths, cfg, query))
|
|
121
|
+
return books
|
|
122
|
+
|
|
123
|
+
def fetch(self, book: Book) -> FetchResult:
|
|
124
|
+
"""Fetch one raw txt file; chapter structure comes from headings."""
|
|
125
|
+
repo, sep, path = book.id.partition(":")
|
|
126
|
+
if not sep or not repo or not path:
|
|
127
|
+
raise ValueError("github id must look like 'owner/repo:path/to/book.txt'")
|
|
128
|
+
cfg = next((c for c in _REPOS if c["repo"] == repo), None)
|
|
129
|
+
if cfg is None:
|
|
130
|
+
raise ValueError(f"repo {repo!r} not in curated list: {[c['repo'] for c in _REPOS]}")
|
|
131
|
+
raw_url = _RAW_URL.format(
|
|
132
|
+
repo=repo, branch=cfg["branch"], path=quote(path, safe="/")
|
|
133
|
+
)
|
|
134
|
+
text = fetch(raw_url) # decode handles UTF-8 / GB18030 / Big5
|
|
135
|
+
title = book.title or path.rsplit("/", 1)[-1][:-4] or repo
|
|
136
|
+
lines = text.splitlines()
|
|
137
|
+
chapters = split_headings(lines)
|
|
138
|
+
if chapters:
|
|
139
|
+
content = "\n".join(c.text for c in chapters)
|
|
140
|
+
else:
|
|
141
|
+
chapters = None
|
|
142
|
+
content = text
|
|
143
|
+
return FetchResult(
|
|
144
|
+
source=self.name,
|
|
145
|
+
id=book.id,
|
|
146
|
+
title=title,
|
|
147
|
+
chars=len(content),
|
|
148
|
+
lines=len(lines),
|
|
149
|
+
format="txt",
|
|
150
|
+
content=content,
|
|
151
|
+
chapters=chapters,
|
|
152
|
+
)
|