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 ADDED
@@ -0,0 +1,3 @@
1
+ """bookfetch — agent-friendly ebook finder CLI."""
2
+
3
+ __version__ = "0.3.0"
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
+ )