orlydown 0.1.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.
oreilly_downloader.py ADDED
@@ -0,0 +1,194 @@
1
+ import argparse
2
+ import json
3
+ import posixpath
4
+ import sys
5
+ import time
6
+ import urllib.error
7
+ import urllib.request
8
+ import zipfile
9
+ from concurrent.futures import ThreadPoolExecutor
10
+ from html.parser import HTMLParser
11
+ from xml.etree import ElementTree as ET
12
+
13
+ BASE_URL = 'https://learning.oreilly.com'
14
+
15
+ CONTAINER = """<?xml version="1.0"?>
16
+ <container xmlns="urn:oasis:names:tc:opendocument:xmlns:container" version="1.0">
17
+ <rootfiles>
18
+ <rootfile full-path="{}" media-type="application/oebps-package+xml"/>
19
+ </rootfiles>
20
+ </container>
21
+ """ # noqa
22
+
23
+ VOID_ELEMENTS = {
24
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link',
25
+ 'meta', 'source', 'track', 'wbr',
26
+ }
27
+
28
+
29
+ class TreeBuilder(HTMLParser):
30
+ """Parse (possibly sloppy) HTML into an ElementTree."""
31
+
32
+ def __init__(self):
33
+ super().__init__()
34
+ self.stack = [ET.Element('root')]
35
+
36
+ def handle_starttag(self, tag, attrs):
37
+ el = ET.SubElement(self.stack[-1], tag, {k: v or '' for k, v in attrs})
38
+ if tag not in VOID_ELEMENTS:
39
+ self.stack.append(el)
40
+
41
+ def handle_endtag(self, tag):
42
+ # implicitly close any unclosed children, ignore stray end tags
43
+ if any(el.tag == tag for el in self.stack[1:]):
44
+ while self.stack.pop().tag != tag:
45
+ pass
46
+
47
+ def handle_data(self, data):
48
+ parent = self.stack[-1]
49
+ if len(parent):
50
+ parent[-1].tail = (parent[-1].tail or '') + data
51
+ else:
52
+ parent.text = (parent.text or '') + data
53
+
54
+
55
+ def to_xhtml(s, root_path, path, stylesheets):
56
+ builder = TreeBuilder()
57
+ builder.feed(s.decode('utf-8'))
58
+ builder.close()
59
+ tree = builder.stack[0]
60
+ base = posixpath.dirname(root_path + path)
61
+
62
+ for el in tree.iter():
63
+ for attr in ['href', 'src']:
64
+ if (el.get(attr) or '').startswith(root_path):
65
+ el.set(attr, posixpath.relpath(el.get(attr), base))
66
+
67
+ for el in tree.iter('svg'):
68
+ for attr in ['viewBox', 'preserveAspectRatio']:
69
+ if attr.lower() in el.attrib:
70
+ el.set(attr, el.attrib.pop(attr.lower()))
71
+
72
+ html = tree.find('html')
73
+ if html is None:
74
+ html = ET.Element('html', {
75
+ 'xmlns': 'http://www.w3.org/1999/xhtml',
76
+ 'xmlns:epub': 'http://www.idpf.org/2007/ops',
77
+ })
78
+
79
+ head = ET.SubElement(html, 'head')
80
+ for href in stylesheets:
81
+ ET.SubElement(head, 'link', rel='stylesheet', href=posixpath.relpath(root_path + href, base))
82
+ h1 = tree.find('.//h1')
83
+ if h1 is not None:
84
+ title = ET.SubElement(head, 'title')
85
+ title.text = ''.join(h1.itertext()).strip()
86
+
87
+ body = ET.SubElement(html, 'body')
88
+ body.text = tree.text
89
+ body.extend(tree)
90
+
91
+ return (
92
+ '<?xml version="1.0" encoding="utf-8"?>\n<!DOCTYPE html>\n'
93
+ + ET.tostring(html, encoding='unicode')
94
+ ).encode('utf-8')
95
+
96
+
97
+ def make_getter(jwt):
98
+ headers = {'User-Agent': 'orlydown'}
99
+ if jwt:
100
+ headers['Cookie'] = f'orm-jwt={jwt}'
101
+
102
+ def get(url):
103
+ with urllib.request.urlopen(urllib.request.Request(url, headers=headers)) as r:
104
+ return r.read()
105
+
106
+ return get
107
+
108
+
109
+ def check_auth(get):
110
+ try:
111
+ get(BASE_URL + '/api/v1/user-preferences/')
112
+ return True
113
+ except urllib.error.HTTPError:
114
+ return False
115
+
116
+
117
+ def fetch_book(book_id, zfh, get, *, delay=0):
118
+ root_path = f'/api/v2/epubs/urn:orm:book:{book_id}/files/'
119
+ html = {}
120
+
121
+ def download(result):
122
+ return f'EPUB/{result["full_path"]}', get(result['url'])
123
+
124
+ zfh.writestr('mimetype', b'application/epub+zip', compress_type=zipfile.ZIP_STORED)
125
+
126
+ url = BASE_URL + root_path
127
+ with ThreadPoolExecutor() as pool:
128
+ while url:
129
+ print(f'fetching {url}')
130
+ data = json.loads(get(url))
131
+
132
+ for path, content in pool.map(download, data.get('results', [])):
133
+ if path.endswith(('.html', '.xhtml')):
134
+ html[path] = content
135
+ else:
136
+ zfh.writestr(path, content)
137
+
138
+ url = data.get('next')
139
+ if url:
140
+ time.sleep(delay)
141
+
142
+ css = [p.removeprefix('EPUB/') for p in zfh.namelist() if p.endswith('.css')]
143
+ for path, content in html.items():
144
+ zfh.writestr(path, to_xhtml(content, root_path, path.removeprefix('EPUB/'), css))
145
+ opf = next(p for p in zfh.namelist() if p.endswith('.opf'))
146
+ zfh.writestr('META-INF/container.xml', CONTAINER.format(opf))
147
+
148
+
149
+ def main():
150
+ parser = argparse.ArgumentParser(prog='orlydown')
151
+ parser.add_argument('book_id', help=(
152
+ 'ID of the book, as found in its URL, '
153
+ 'e.g. 9781491958698 in https://learning.oreilly.com/library/view/…/9781491958698/'
154
+ ))
155
+ parser.add_argument('--jwt', metavar='TOKEN', help=(
156
+ 'Value of the orm-jwt cookie from a logged-in browser session. '
157
+ 'Without it, chapters are cut short.'
158
+ ))
159
+ parser.add_argument('--require-auth', action='store_true', help=(
160
+ 'Exit with an error instead of downloading truncated chapters '
161
+ 'when no JWT is given or it is not accepted.'
162
+ ))
163
+ parser.add_argument('--delay', type=int, default=0, help=(
164
+ 'Seconds to wait between batches of file downloads. '
165
+ 'Workaround for 403 errors caused by rate-limiting.'
166
+ ))
167
+ parser.add_argument('--title', action='store_true', help=(
168
+ 'Name the file after the book title instead of its ID.'
169
+ ))
170
+ args = parser.parse_args()
171
+
172
+ filename = f'{args.book_id}.epub'
173
+ get = make_getter(args.jwt)
174
+
175
+ if args.jwt and check_auth(get):
176
+ print('Authentication successful.')
177
+ else:
178
+ problem = 'Authentication failed.' if args.jwt else 'No JWT provided.'
179
+ if args.require_auth:
180
+ sys.exit(problem)
181
+ print(f'{problem} Continuing without…')
182
+
183
+ if args.title:
184
+ data = json.loads(get(f'{BASE_URL}/api/v2/epubs/urn:orm:book:{args.book_id}/'))
185
+ filename = data['title'].replace(':', ' -').replace('/', '-') + '.epub'
186
+
187
+ with zipfile.ZipFile(filename, 'w') as zfh:
188
+ fetch_book(args.book_id, zfh, get, delay=args.delay)
189
+
190
+ print(f'created {filename}')
191
+
192
+
193
+ if __name__ == '__main__':
194
+ main()
@@ -0,0 +1,156 @@
1
+ Metadata-Version: 2.5
2
+ Name: orlydown
3
+ Version: 0.1.0
4
+ Summary: Download O'Reilly books as epub files you can read in any reader
5
+ Project-URL: Homepage, https://github.com/ashleyconnor/orlydown
6
+ Project-URL: Issues, https://github.com/ashleyconnor/orlydown/issues
7
+ Author: Ashley Connor, Tobias Bengfort
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: downloader,ebook,epub,oreilly
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: End Users/Desktop
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Utilities
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+
19
+ # orlydown
20
+
21
+ O'Reilly provides all of their books in epub format, but only through their own
22
+ reader. `orlydown` downloads the individual files and assembles them back into
23
+ a complete epub, so you can read your books in any reader (Apple Books, Calibre,
24
+ KOReader, …), e.g. for accessibility reasons.
25
+
26
+ Before using it, please read the [O'Reilly Terms of
27
+ Service](https://learning.oreilly.com/terms/).
28
+
29
+ This is a fork of [xi/oreilly-downloader](https://github.com/xi/oreilly-downloader)
30
+ that fixes epubs which didn't render properly:
31
+
32
+ - Stylesheets are linked from every chapter, so books keep their formatting
33
+ and fonts.
34
+ - Images and links work in books that keep chapters in a subdirectory
35
+ (e.g. `Text/`, `Images/`).
36
+ - Books whose package file isn't called `content.opf` (e.g. Springer titles)
37
+ open correctly.
38
+ - SVG covers are scaled to fit the page instead of being cropped.
39
+ - A `--title` option names the epub after the book instead of its ID.
40
+ - A `--require-auth` option stops straight away if your token doesn't work.
41
+ - No dependencies: it only uses the Python standard library.
42
+
43
+ ## Installation
44
+
45
+ You need Python 3.9 or newer. The easiest way to install `orlydown` is with
46
+ [pipx](https://pipx.pypa.io/), which puts it in its own environment and adds
47
+ the `orlydown` command to your `PATH`:
48
+
49
+ ```
50
+ pipx install orlydown
51
+ ```
52
+
53
+ Upgrade later with `pipx upgrade orlydown`. To run it once without installing
54
+ anything:
55
+
56
+ ```
57
+ pipx run orlydown 9781491958698 --jwt 'XYZ'
58
+ ```
59
+
60
+ With [uv](https://docs.astral.sh/uv/), the equivalents are
61
+ `uv tool install orlydown` and `uvx orlydown …`.
62
+
63
+ Since there are no dependencies, you can also download
64
+ [`oreilly_downloader.py`](oreilly_downloader.py) and run it directly with
65
+ `python3 oreilly_downloader.py …`.
66
+
67
+ ## Getting your JWT
68
+
69
+ Without a valid JWT every chapter is cut short. To get one:
70
+
71
+ 1. Log in to <https://learning.oreilly.com> in your browser.
72
+ 2. Open the developer tools and find the cookies for `learning.oreilly.com`.
73
+ 3. Copy the value of the `orm-jwt` cookie.
74
+
75
+ If `orlydown` reports `Authentication failed`, log in again and copy a fresh
76
+ cookie.
77
+
78
+ ## Usage
79
+
80
+ Find the book ID in the book's URL, e.g. `9781491958698` in
81
+ `https://learning.oreilly.com/library/view/test-driven-development-with/9781491958698/`.
82
+
83
+ ```
84
+ $ orlydown 9781491958698 --jwt 'XYZ'
85
+ Authentication successful.
86
+ fetching https://learning.oreilly.com/api/v2/epubs/urn:orm:book:9781491958698/files/
87
+ …
88
+ created 9781491958698.epub
89
+ ```
90
+
91
+ To name the file after the book instead of its ID, add `--title`:
92
+
93
+ ```
94
+ $ orlydown 9781491958698 --jwt 'XYZ' --title
95
+ …
96
+ created Test-Driven Development with Python, 2nd Edition.epub
97
+ ```
98
+
99
+ A `:` in the title becomes ` -` and a `/` becomes `-`, so the name is safe to
100
+ use as a filename.
101
+
102
+ By default, a missing or rejected token only prints a warning and the download
103
+ carries on with truncated chapters. Add `--require-auth` to stop with an error
104
+ instead, which is useful in scripts:
105
+
106
+ ```
107
+ $ orlydown 9781491958698 --jwt 'expired' --require-auth
108
+ Authentication failed.
109
+ $ echo $?
110
+ 1
111
+ ```
112
+
113
+ ### Options
114
+
115
+ | Option | Description |
116
+ | ---------------- | ----------------------------------------------------------------------------- |
117
+ | `--jwt TOKEN` | Your `orm-jwt` cookie. Without it, chapters are cut short. |
118
+ | `--require-auth` | Exit with an error if no JWT is given or it isn't accepted. |
119
+ | `--title` | Name the epub after the book title instead of its ID. |
120
+ | `--delay N` | Wait `N` seconds between batches of downloads. Try `5` if you get 403 errors. |
121
+
122
+ ## Development
123
+
124
+ The project is managed with [uv](https://docs.astral.sh/uv/):
125
+
126
+ ```
127
+ uv run orlydown --help
128
+ uv build
129
+ ```
130
+
131
+ ### Releasing
132
+
133
+ Pushing a `v*` tag publishes that version to PyPI. The version is the tag name
134
+ without the `v`, so `v1.0.0` publishes `1.0.0`:
135
+
136
+ ```
137
+ git tag v1.0.0
138
+ git push origin v1.0.0
139
+ ```
140
+
141
+ Publishing uses PyPI [trusted
142
+ publishing](https://docs.pypi.org/trusted-publishers/), so no API token is
143
+ stored in the repository. It needs a one-time setup: on PyPI, add a trusted
144
+ publisher for project `orlydown` with owner `ashleyconnor`, repository
145
+ `orlydown`, workflow `publish.yml` and environment `pypi`.
146
+
147
+ ## Similar projects
148
+
149
+ - <https://github.com/lorenzodifuccia/safaribooks> (python)
150
+ - <https://github.com/hurlenko/orly> (rust)
151
+ - <https://github.com/jenni/obooks> (javascript)
152
+ - <https://github.com/rahulvramesh/oreilly-books-grabber> (go)
153
+
154
+ ## License
155
+
156
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,6 @@
1
+ oreilly_downloader.py,sha256=0qiwHLoD2x65057z9G0W_-H3pf-YXG5exLpSkwj9ysQ,6218
2
+ orlydown-0.1.0.dist-info/METADATA,sha256=W2nmesnW-mzS3k_U85_DMcbIXXmQiLJvjdWqZjNmHDk,5243
3
+ orlydown-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
4
+ orlydown-0.1.0.dist-info/entry_points.txt,sha256=_GuRDyDmP7_F9LDhjYSSSV3BcsPPpxkZlgLBxgk4qTw,53
5
+ orlydown-0.1.0.dist-info/licenses/LICENSE,sha256=NMVEu_s-ucif-YlqfJnUAl541NAlkfjgnpbadpdv2ss,1105
6
+ orlydown-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ orlydown = oreilly_downloader:main
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Tobias Bengfort
4
+ Copyright (c) 2026 Ashley Connor
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.