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,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.
|