papir-kindle 1.0__tar.gz
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.
- papir_kindle-1.0/LICENSE +27 -0
- papir_kindle-1.0/PKG-INFO +136 -0
- papir_kindle-1.0/README.md +118 -0
- papir_kindle-1.0/papir/__init__.py +29 -0
- papir_kindle-1.0/papir/api.py +300 -0
- papir_kindle-1.0/papir/cli.py +362 -0
- papir_kindle-1.0/papir/desktop.py +90 -0
- papir_kindle-1.0/papir/papir.py +263 -0
- papir_kindle-1.0/papir/pick.py +186 -0
- papir_kindle-1.0/papir/progress.py +99 -0
- papir_kindle-1.0/papir/signer.py +119 -0
- papir_kindle-1.0/papir_kindle.egg-info/PKG-INFO +136 -0
- papir_kindle-1.0/papir_kindle.egg-info/SOURCES.txt +18 -0
- papir_kindle-1.0/papir_kindle.egg-info/dependency_links.txt +1 -0
- papir_kindle-1.0/papir_kindle.egg-info/entry_points.txt +2 -0
- papir_kindle-1.0/papir_kindle.egg-info/requires.txt +1 -0
- papir_kindle-1.0/papir_kindle.egg-info/top_level.txt +1 -0
- papir_kindle-1.0/pyproject.toml +28 -0
- papir_kindle-1.0/setup.cfg +4 -0
- papir_kindle-1.0/setup.py +5 -0
papir_kindle-1.0/LICENSE
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Benjamin M. Duivesteyn
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
Protocol lineage: this project re-implements the private Send to Kindle
|
|
26
|
+
protocol following maxdjohnson/stkclient (MIT, (c) 2022 Max Johnson) and
|
|
27
|
+
cyrgim/stk. No Amazon code is included. Unofficial, unaffiliated with Amazon.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: papir-kindle
|
|
3
|
+
Version: 1.0
|
|
4
|
+
Summary: Beam reading material to your e-reader via API, with correct Author + Title.
|
|
5
|
+
Author-email: "Benjamin M. Duivesteyn" <duivesteyn@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/duivesteyn/Papir
|
|
8
|
+
Keywords: kindle,e-reader,send-to-kindle,epub,pdf
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Topic :: Utilities
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: requests
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
<img src="_screenshots/Papir-256.png" width="128" alt="Papir icon — folded ivory paper P on forest green">
|
|
20
|
+
|
|
21
|
+
# Papir
|
|
22
|
+
|
|
23
|
+
A lightweight utility to beam reading material directly to your e-reader via API.
|
|
24
|
+
|
|
25
|
+
> **Python CLI + native Mac companion.** `papir` remains the Python CLI.
|
|
26
|
+
> A self-contained, on-demand SwiftUI `Papir.app` lives in `macos/`, with bundled
|
|
27
|
+
> Python and browser sign-in; see [Mac setup](macos/README.md).
|
|
28
|
+
|
|
29
|
+
<img src="_screenshots/Papir-app-window.png" width="480" alt="Papir Mac app: forest-green window with serif papir header inline with the traffic lights, destination pill and document drop zone">
|
|
30
|
+
|
|
31
|
+
`papir` is Norwegian for paper. p-[api]-r hides API in the middle.
|
|
32
|
+
|
|
33
|
+
Unofficial. Not affiliated with Amazon / Kindle. Speaks the private protocol used by
|
|
34
|
+
Amazon's Send to Kindle desktop app (following `stkclient` by Max Johnson, MIT, and
|
|
35
|
+
`cyrgim/stk`). Amazon can break it without notice — use on an account you own.
|
|
36
|
+
|
|
37
|
+
# Downloads and project layout
|
|
38
|
+
|
|
39
|
+
[Mac releases](https://github.com/duivesteyn/Papir/releases) ·
|
|
40
|
+
[Mac installation](macos/INSTALL.md) · [Local builds](macos/README.md)
|
|
41
|
+
|
|
42
|
+
The Mac download includes Python and browser sign-in. Releases are unnotarized;
|
|
43
|
+
see the install instructions for macOS approval. Early releases are marked as
|
|
44
|
+
pre-releases while fresh-account/device-delivery and older-OS testing continues.
|
|
45
|
+
|
|
46
|
+
Everything for this mini project lives in this repository:
|
|
47
|
+
|
|
48
|
+
- `papir/`: Python CLI, protocol and shared sending engine.
|
|
49
|
+
- `macos/`: native Mac UI, bundling, verification and packaging.
|
|
50
|
+
- `planning/`: current plan and retained protocol/package research.
|
|
51
|
+
- `art/`: original icon master, fixed exports, asset catalog and style spec.
|
|
52
|
+
- `.github/workflows/`: offline CI and tagged Mac release builds.
|
|
53
|
+
|
|
54
|
+
Build environments and generated releases stay local and are ignored by Git.
|
|
55
|
+
Downloadable binaries belong in GitHub Releases, not in source commits.
|
|
56
|
+
|
|
57
|
+
# Example Usage
|
|
58
|
+
|
|
59
|
+
An example usage file is included called main.py. It contains the basics. In essence:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from papir import papirSend
|
|
63
|
+
|
|
64
|
+
sku = papirSend("report.pdf", author="deCapital", title="Imperial Investment Case", target="all")
|
|
65
|
+
print(sku)
|
|
66
|
+
|
|
67
|
+
# author/title optional — author falls back to your stored default, title to the filename:
|
|
68
|
+
sku = papirSend("report.pdf")
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
CLI:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
pip install .
|
|
75
|
+
papir login # sign in, set default author + Kindle
|
|
76
|
+
papir login --url URL --verifier VERIFIER # non-interactive (or --code CODE for a bare pasted code)
|
|
77
|
+
papir devices # SERIAL: Name (* marks the default)
|
|
78
|
+
papir devices --json # machine-readable devices + default
|
|
79
|
+
papir set-default # arrow-key/number picker (or `papir --set-default`)
|
|
80
|
+
papir set-default SERIAL # non-interactive (or `all` to clear)
|
|
81
|
+
papir set-default --author "deCapital"
|
|
82
|
+
papir send report.pdf
|
|
83
|
+
papir send report.pdf --author "deCapital" --title "Imperial Investment Case" --to DEVICE_SERIAL
|
|
84
|
+
papir send report.pdf --convert # convert PDF to Kindle format (default keeps native PDF)
|
|
85
|
+
papir document.pdf --title "Design Systems" # shorthand, uses defaults
|
|
86
|
+
papir doctor [--live]
|
|
87
|
+
papir logout
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`papir send` without `--to` uses your default Kindle (else all devices).
|
|
91
|
+
`--to` accepts a serial, `all`, or `library` (cloud-library-only, no device needed).
|
|
92
|
+
`--format` overrides the detected file type (default `auto`); `--no-archive` skips saving to your Kindle library.
|
|
93
|
+
`--client PATH` overrides the credential file (default `~/.config/papir/client.json`).
|
|
94
|
+
|
|
95
|
+
# Example Output Data
|
|
96
|
+
|
|
97
|
+
`papirSend` returns the sku Amazon assigns:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
8A47909664E246F0BAAB1738D7E9A154
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
# Brand
|
|
104
|
+
|
|
105
|
+
Folded ivory paper P on forest green (`#123325` / ivory `#F3EADB`).
|
|
106
|
+
Full spec: [art/Papir-art-style-spec.md](art/Papir-art-style-spec.md). The Mac app uses an on-demand forest-green window and the retained app icon.
|
|
107
|
+
The CLI's only artwork is 📤/✅ in terminal output.
|
|
108
|
+
|
|
109
|
+
# Changelog
|
|
110
|
+
|
|
111
|
+
- v1.0 2026-10-17 Stable 1.0. Python package `papir-kindle` (`pip install papir-kindle`, CLI stays `papir`), Mac app + CLI.
|
|
112
|
+
- v0.2.2 2026-10-06 Forest-green native title bars across app windows.
|
|
113
|
+
|
|
114
|
+
- v0.2.0 2026-10-06 Self-contained native Mac app, browser sign-in/preferences, JSON desktop bridge, shared library-only default fix, consolidated planning/art and release builds.
|
|
115
|
+
|
|
116
|
+
- v0.1.1 2026-10-06 Native-PDF fix: dropped stkclient's invented `outputFormat=MOBI`/`deliveryMechanism` (backend converted PDFs), send `forceConvert:false` like the official app. Added `--convert` opt-in.
|
|
117
|
+
- v0.1.0 2026-10-06 Initial revision. login/devices/send/logout/doctor, correct Author+Title via DocumentMetadata.
|
|
118
|
+
|
|
119
|
+
# Python package
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
pip install papir-kindle
|
|
123
|
+
papir --help
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Releases: pushing tag `v*` runs `.github/workflows/pypi.yml` — offline tests, `python -m build`, Trusted Publishing to PyPI as `papir-kindle`, then attaches `dist/*` to the GitHub Release. First publish needs a PyPI Trusted Publisher for this repo + workflow.
|
|
127
|
+
|
|
128
|
+
# Inspirations
|
|
129
|
+
|
|
130
|
+
- https://github.com/maxdjohnson/stkclient
|
|
131
|
+
- https://github.com/cyrgim/stk
|
|
132
|
+
|
|
133
|
+
# Credits
|
|
134
|
+
|
|
135
|
+
designed in 2026 by bmd. Protocol work: `maxdjohnson/stkclient` (MIT) + `cyrgim/stk`
|
|
136
|
+
(bare-digest signing quirk, per-install serials).
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
<img src="_screenshots/Papir-256.png" width="128" alt="Papir icon — folded ivory paper P on forest green">
|
|
2
|
+
|
|
3
|
+
# Papir
|
|
4
|
+
|
|
5
|
+
A lightweight utility to beam reading material directly to your e-reader via API.
|
|
6
|
+
|
|
7
|
+
> **Python CLI + native Mac companion.** `papir` remains the Python CLI.
|
|
8
|
+
> A self-contained, on-demand SwiftUI `Papir.app` lives in `macos/`, with bundled
|
|
9
|
+
> Python and browser sign-in; see [Mac setup](macos/README.md).
|
|
10
|
+
|
|
11
|
+
<img src="_screenshots/Papir-app-window.png" width="480" alt="Papir Mac app: forest-green window with serif papir header inline with the traffic lights, destination pill and document drop zone">
|
|
12
|
+
|
|
13
|
+
`papir` is Norwegian for paper. p-[api]-r hides API in the middle.
|
|
14
|
+
|
|
15
|
+
Unofficial. Not affiliated with Amazon / Kindle. Speaks the private protocol used by
|
|
16
|
+
Amazon's Send to Kindle desktop app (following `stkclient` by Max Johnson, MIT, and
|
|
17
|
+
`cyrgim/stk`). Amazon can break it without notice — use on an account you own.
|
|
18
|
+
|
|
19
|
+
# Downloads and project layout
|
|
20
|
+
|
|
21
|
+
[Mac releases](https://github.com/duivesteyn/Papir/releases) ·
|
|
22
|
+
[Mac installation](macos/INSTALL.md) · [Local builds](macos/README.md)
|
|
23
|
+
|
|
24
|
+
The Mac download includes Python and browser sign-in. Releases are unnotarized;
|
|
25
|
+
see the install instructions for macOS approval. Early releases are marked as
|
|
26
|
+
pre-releases while fresh-account/device-delivery and older-OS testing continues.
|
|
27
|
+
|
|
28
|
+
Everything for this mini project lives in this repository:
|
|
29
|
+
|
|
30
|
+
- `papir/`: Python CLI, protocol and shared sending engine.
|
|
31
|
+
- `macos/`: native Mac UI, bundling, verification and packaging.
|
|
32
|
+
- `planning/`: current plan and retained protocol/package research.
|
|
33
|
+
- `art/`: original icon master, fixed exports, asset catalog and style spec.
|
|
34
|
+
- `.github/workflows/`: offline CI and tagged Mac release builds.
|
|
35
|
+
|
|
36
|
+
Build environments and generated releases stay local and are ignored by Git.
|
|
37
|
+
Downloadable binaries belong in GitHub Releases, not in source commits.
|
|
38
|
+
|
|
39
|
+
# Example Usage
|
|
40
|
+
|
|
41
|
+
An example usage file is included called main.py. It contains the basics. In essence:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from papir import papirSend
|
|
45
|
+
|
|
46
|
+
sku = papirSend("report.pdf", author="deCapital", title="Imperial Investment Case", target="all")
|
|
47
|
+
print(sku)
|
|
48
|
+
|
|
49
|
+
# author/title optional — author falls back to your stored default, title to the filename:
|
|
50
|
+
sku = papirSend("report.pdf")
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
CLI:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
pip install .
|
|
57
|
+
papir login # sign in, set default author + Kindle
|
|
58
|
+
papir login --url URL --verifier VERIFIER # non-interactive (or --code CODE for a bare pasted code)
|
|
59
|
+
papir devices # SERIAL: Name (* marks the default)
|
|
60
|
+
papir devices --json # machine-readable devices + default
|
|
61
|
+
papir set-default # arrow-key/number picker (or `papir --set-default`)
|
|
62
|
+
papir set-default SERIAL # non-interactive (or `all` to clear)
|
|
63
|
+
papir set-default --author "deCapital"
|
|
64
|
+
papir send report.pdf
|
|
65
|
+
papir send report.pdf --author "deCapital" --title "Imperial Investment Case" --to DEVICE_SERIAL
|
|
66
|
+
papir send report.pdf --convert # convert PDF to Kindle format (default keeps native PDF)
|
|
67
|
+
papir document.pdf --title "Design Systems" # shorthand, uses defaults
|
|
68
|
+
papir doctor [--live]
|
|
69
|
+
papir logout
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`papir send` without `--to` uses your default Kindle (else all devices).
|
|
73
|
+
`--to` accepts a serial, `all`, or `library` (cloud-library-only, no device needed).
|
|
74
|
+
`--format` overrides the detected file type (default `auto`); `--no-archive` skips saving to your Kindle library.
|
|
75
|
+
`--client PATH` overrides the credential file (default `~/.config/papir/client.json`).
|
|
76
|
+
|
|
77
|
+
# Example Output Data
|
|
78
|
+
|
|
79
|
+
`papirSend` returns the sku Amazon assigns:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
8A47909664E246F0BAAB1738D7E9A154
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
# Brand
|
|
86
|
+
|
|
87
|
+
Folded ivory paper P on forest green (`#123325` / ivory `#F3EADB`).
|
|
88
|
+
Full spec: [art/Papir-art-style-spec.md](art/Papir-art-style-spec.md). The Mac app uses an on-demand forest-green window and the retained app icon.
|
|
89
|
+
The CLI's only artwork is 📤/✅ in terminal output.
|
|
90
|
+
|
|
91
|
+
# Changelog
|
|
92
|
+
|
|
93
|
+
- v1.0 2026-10-17 Stable 1.0. Python package `papir-kindle` (`pip install papir-kindle`, CLI stays `papir`), Mac app + CLI.
|
|
94
|
+
- v0.2.2 2026-10-06 Forest-green native title bars across app windows.
|
|
95
|
+
|
|
96
|
+
- v0.2.0 2026-10-06 Self-contained native Mac app, browser sign-in/preferences, JSON desktop bridge, shared library-only default fix, consolidated planning/art and release builds.
|
|
97
|
+
|
|
98
|
+
- v0.1.1 2026-10-06 Native-PDF fix: dropped stkclient's invented `outputFormat=MOBI`/`deliveryMechanism` (backend converted PDFs), send `forceConvert:false` like the official app. Added `--convert` opt-in.
|
|
99
|
+
- v0.1.0 2026-10-06 Initial revision. login/devices/send/logout/doctor, correct Author+Title via DocumentMetadata.
|
|
100
|
+
|
|
101
|
+
# Python package
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
pip install papir-kindle
|
|
105
|
+
papir --help
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Releases: pushing tag `v*` runs `.github/workflows/pypi.yml` — offline tests, `python -m build`, Trusted Publishing to PyPI as `papir-kindle`, then attaches `dist/*` to the GitHub Release. First publish needs a PyPI Trusted Publisher for this repo + workflow.
|
|
109
|
+
|
|
110
|
+
# Inspirations
|
|
111
|
+
|
|
112
|
+
- https://github.com/maxdjohnson/stkclient
|
|
113
|
+
- https://github.com/cyrgim/stk
|
|
114
|
+
|
|
115
|
+
# Credits
|
|
116
|
+
|
|
117
|
+
designed in 2026 by bmd. Protocol work: `maxdjohnson/stkclient` (MIT) + `cyrgim/stk`
|
|
118
|
+
(bare-digest signing quirk, per-install serials).
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# coding: utf-8
|
|
3
|
+
#
|
|
4
|
+
# duivesteyn // Python // papir
|
|
5
|
+
# https://github.com/duivesteyn/papir
|
|
6
|
+
#
|
|
7
|
+
# # bmd 2026
|
|
8
|
+
|
|
9
|
+
"""Papir — beam reading material to your e-reader via API."""
|
|
10
|
+
|
|
11
|
+
from papir.papir import (
|
|
12
|
+
clean_title,
|
|
13
|
+
get_default_author,
|
|
14
|
+
get_default_target,
|
|
15
|
+
papirDevices,
|
|
16
|
+
papirLogin,
|
|
17
|
+
papirLogout,
|
|
18
|
+
papirSend,
|
|
19
|
+
resolve_author,
|
|
20
|
+
resolve_title,
|
|
21
|
+
set_default_author,
|
|
22
|
+
set_default_target,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = ["papirSend", "papirLogin", "papirDevices", "papirLogout",
|
|
26
|
+
"clean_title", "resolve_author", "resolve_title",
|
|
27
|
+
"get_default_target", "set_default_target",
|
|
28
|
+
"get_default_author", "set_default_author"]
|
|
29
|
+
__version__ = "1.0"
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# coding: utf-8
|
|
3
|
+
#
|
|
4
|
+
# duivesteyn // Python // papir
|
|
5
|
+
# https://github.com/duivesteyn/papir
|
|
6
|
+
#
|
|
7
|
+
# Typed wrappers for the Amazon auth + STK APIs. Uses requests (duivesteyn style).
|
|
8
|
+
# Protocol is reverse-engineered from Amazon's Send to Kindle desktop app,
|
|
9
|
+
# following stkclient (Max Johnson, MIT) + cyrgim/stk. Unofficial.
|
|
10
|
+
#
|
|
11
|
+
# # bmd 2026
|
|
12
|
+
|
|
13
|
+
"""Amazon auth + Send-to-Kindle HTTP calls."""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import urllib.parse
|
|
17
|
+
|
|
18
|
+
import requests
|
|
19
|
+
|
|
20
|
+
from papir import signer
|
|
21
|
+
|
|
22
|
+
CLIENT_ID = "658490dfb190e494030082836775981fa23be0c2425441860352ba0f55915b43002d"
|
|
23
|
+
DEVICE_TYPE = "A1K6D1WRW0MALS"
|
|
24
|
+
PID = "D21NN3GG"
|
|
25
|
+
SOFTWARE_VER = "253"
|
|
26
|
+
OS_VERSION = "MacOSX_10.14.6_x64"
|
|
27
|
+
|
|
28
|
+
TOKEN_HOST = "api.amazon.com"
|
|
29
|
+
FIRS_HOST = "firs-ta-g7g.amazon.com"
|
|
30
|
+
STK_HOST = "stkservice.amazon.com"
|
|
31
|
+
|
|
32
|
+
CLIENT_INFO = {
|
|
33
|
+
"appName": "ShellExtension",
|
|
34
|
+
"appVersion": "1.1.1.253",
|
|
35
|
+
"os": OS_VERSION,
|
|
36
|
+
"osArchitecture": "x64",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
TIMEOUT = 30
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class APIError(ValueError):
|
|
43
|
+
"""Amazon returned an HTTP error. .body holds the decoded server message."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, msg, body=None):
|
|
46
|
+
self.body = body
|
|
47
|
+
if body is not None:
|
|
48
|
+
try:
|
|
49
|
+
body = json.loads(body) if isinstance(body, (bytes, str)) else body
|
|
50
|
+
except (json.JSONDecodeError, UnicodeDecodeError, ValueError):
|
|
51
|
+
pass
|
|
52
|
+
try:
|
|
53
|
+
msg += f" {json.dumps(body)[:2000]}"
|
|
54
|
+
except (TypeError, ValueError):
|
|
55
|
+
msg += f" {str(body)[:2000]}"
|
|
56
|
+
super().__init__(msg)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_signin_url(verifier):
|
|
60
|
+
"""Build the Amazon sign-in URL for a PKCE verifier. Open in a browser."""
|
|
61
|
+
import base64
|
|
62
|
+
import hashlib
|
|
63
|
+
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
|
64
|
+
q = {
|
|
65
|
+
"openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select",
|
|
66
|
+
"openid.ns.oa2": "http://www.amazon.com/ap/ext/oauth/2",
|
|
67
|
+
"openid.ns": "http://specs.openid.net/auth/2.0",
|
|
68
|
+
"openid.identity": "http://specs.openid.net/auth/2.0/identifier_select",
|
|
69
|
+
"openid.oa2.client_id": "device:" + CLIENT_ID,
|
|
70
|
+
"openid.mode": "checkid_setup",
|
|
71
|
+
"openid.oa2.scope": "device_auth_access",
|
|
72
|
+
"openid.oa2.response_type": "code",
|
|
73
|
+
"openid.oa2.code_challenge": challenge,
|
|
74
|
+
"openid.oa2.code_challenge_method": "S256",
|
|
75
|
+
"openid.return_to": "https://www.amazon.com/gp/sendtokindle",
|
|
76
|
+
"openid.ns.pape": "http://specs.openid.net/extensions/pape/1.0",
|
|
77
|
+
"openid.pape.max_auth_age": "0",
|
|
78
|
+
"accountStatusPolicy": "P1",
|
|
79
|
+
"openid.assoc_handle": "amzn_device_na",
|
|
80
|
+
"pageId": "amzn_device_common_dark",
|
|
81
|
+
"disableLoginPrepopulate": "1",
|
|
82
|
+
}
|
|
83
|
+
return "https://www.amazon.com/ap/signin?" + urllib.parse.urlencode(q)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def parse_authorization_code(redirect_url):
|
|
87
|
+
"""Pull openid.oa2.authorization_code out of the final redirect URL.
|
|
88
|
+
|
|
89
|
+
Also accepts a bare code (e.g. ANprBJrehznaOHilnEIHemzX) for when the
|
|
90
|
+
terminal truncates the paste — the code is the only part we need.
|
|
91
|
+
"""
|
|
92
|
+
s = redirect_url.strip().strip("'\"")
|
|
93
|
+
if s and "://" not in s and "?" not in s and "=" not in s and " " not in s and len(s) < 200:
|
|
94
|
+
return s # bare authorization code, no URL parsing needed
|
|
95
|
+
u = urllib.parse.urlparse(redirect_url.strip())
|
|
96
|
+
q = urllib.parse.parse_qs(u.query)
|
|
97
|
+
try:
|
|
98
|
+
return q["openid.oa2.authorization_code"][0]
|
|
99
|
+
except (KeyError, IndexError):
|
|
100
|
+
raise ValueError("no openid.oa2.authorization_code in redirect URL — sign-in did not complete")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def token_exchange(authorization_code, code_verifier):
|
|
104
|
+
"""Exchange authorization_code for an access_token. Returns the token string."""
|
|
105
|
+
body = {
|
|
106
|
+
"app_name": "Unknown",
|
|
107
|
+
"client_domain": "DeviceLegacy",
|
|
108
|
+
"client_id": CLIENT_ID,
|
|
109
|
+
"code_algorithm": "SHA-256",
|
|
110
|
+
"code_verifier": code_verifier,
|
|
111
|
+
"requested_token_type": "access_token",
|
|
112
|
+
"source_token": authorization_code,
|
|
113
|
+
"source_token_type": "authorization_code",
|
|
114
|
+
}
|
|
115
|
+
try:
|
|
116
|
+
r = requests.post(
|
|
117
|
+
f"https://{TOKEN_HOST}/auth/token",
|
|
118
|
+
json=body,
|
|
119
|
+
headers={"Accept-Language": "en-US", "x-amzn-identity-auth-domain": TOKEN_HOST,
|
|
120
|
+
"Content-Type": "application/json", "User-Agent": "Mozilla/5.0"},
|
|
121
|
+
timeout=TIMEOUT,
|
|
122
|
+
)
|
|
123
|
+
r.raise_for_status()
|
|
124
|
+
return r.json()["access_token"]
|
|
125
|
+
except requests.HTTPError as e:
|
|
126
|
+
raise APIError(str(e), e.response.content if e.response is not None else None) from e
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def register_device_with_token(access_token, serial, device_name):
|
|
130
|
+
"""Register a new device. Returns dict device_info (persist as client.json)."""
|
|
131
|
+
import xml.etree.ElementTree as ET
|
|
132
|
+
|
|
133
|
+
def esc(s):
|
|
134
|
+
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
135
|
+
|
|
136
|
+
xml_body = (
|
|
137
|
+
"<?xml version='1.0' encoding='UTF-8'?><request><parameters>"
|
|
138
|
+
+ "".join(f"<{t}>{esc(v)}</{t}>" for t, v in [
|
|
139
|
+
("deviceType", DEVICE_TYPE),
|
|
140
|
+
("deviceSerialNumber", serial),
|
|
141
|
+
("pid", PID),
|
|
142
|
+
("authToken", access_token),
|
|
143
|
+
("authTokenType", "AccessToken"),
|
|
144
|
+
("softwareVersion", SOFTWARE_VER),
|
|
145
|
+
("os_version", OS_VERSION),
|
|
146
|
+
("device_model", device_name),
|
|
147
|
+
])
|
|
148
|
+
+ "</parameters></request>"
|
|
149
|
+
)
|
|
150
|
+
try:
|
|
151
|
+
r = requests.post(
|
|
152
|
+
f"https://{FIRS_HOST}/FirsProxy/registerDeviceWithToken",
|
|
153
|
+
data=xml_body.encode(),
|
|
154
|
+
headers={"Content-Type": "text/xml", "Accept-Language": "en-US,*", "User-Agent": "Mozilla/5.0"},
|
|
155
|
+
timeout=TIMEOUT,
|
|
156
|
+
)
|
|
157
|
+
r.raise_for_status()
|
|
158
|
+
root = ET.fromstring(r.content)
|
|
159
|
+
info = {el.tag: el.text for el in root}
|
|
160
|
+
# keep only known fields, require the two secrets
|
|
161
|
+
out = {
|
|
162
|
+
"device_private_key": info.get("device_private_key"),
|
|
163
|
+
"adp_token": info.get("adp_token"),
|
|
164
|
+
"device_type": info.get("device_type", DEVICE_TYPE),
|
|
165
|
+
"device_serial_number": serial,
|
|
166
|
+
"device_name": device_name,
|
|
167
|
+
"account_name": info.get("name", ""),
|
|
168
|
+
"home_region": info.get("home_region", ""),
|
|
169
|
+
}
|
|
170
|
+
if not out["device_private_key"] or not out["adp_token"]:
|
|
171
|
+
raise APIError("incomplete registration response", r.content[:2000])
|
|
172
|
+
return out
|
|
173
|
+
except requests.HTTPError as e:
|
|
174
|
+
raise APIError(str(e), e.response.content if e.response is not None else None) from e
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _signed_post(path, device_info, body):
|
|
178
|
+
"""POST JSON to stkservice with ADP headers. Returns decoded dict."""
|
|
179
|
+
data = json.dumps({"ClientInfo": CLIENT_INFO, **body}, indent=4)
|
|
180
|
+
digest = signer.digest_header_for_request(
|
|
181
|
+
device_info["device_private_key"], device_info["adp_token"], "POST", path, data)
|
|
182
|
+
try:
|
|
183
|
+
r = requests.post(
|
|
184
|
+
f"https://{STK_HOST}{path}",
|
|
185
|
+
data=data.encode(),
|
|
186
|
+
headers={
|
|
187
|
+
"Accept": "application/json",
|
|
188
|
+
"Accept-Encoding": "gzip, deflate",
|
|
189
|
+
"Content-Type": "application/json",
|
|
190
|
+
"X-ADP-Request-Digest": digest,
|
|
191
|
+
"X-ADP-Authentication-Token": device_info["adp_token"],
|
|
192
|
+
"Accept-Language": "en-US,*",
|
|
193
|
+
"User-Agent": "Mozilla/5.0",
|
|
194
|
+
},
|
|
195
|
+
timeout=TIMEOUT,
|
|
196
|
+
)
|
|
197
|
+
r.raise_for_status()
|
|
198
|
+
return r.json()
|
|
199
|
+
except requests.HTTPError as e:
|
|
200
|
+
raise APIError(str(e), e.response.content if e.response is not None else None) from e
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def get_owned_devices(device_info):
|
|
204
|
+
"""Return list of {deviceName, deviceSerialNumber, deviceCapabilities}."""
|
|
205
|
+
res = _signed_post("/GetListOfOwnedDevices", device_info, {})
|
|
206
|
+
return res.get("ownedDevices", [])
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def get_upload_url(device_info, file_size):
|
|
210
|
+
"""Return (upload_url, stk_token)."""
|
|
211
|
+
res = _signed_post("/GetUploadUrl", device_info, {"fileSize": file_size})
|
|
212
|
+
return res["uploadUrl"], res["stkToken"]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class _ProgressReader:
|
|
216
|
+
"""File-like wrapper reporting bytes read. Lets requests stream with Content-Length."""
|
|
217
|
+
|
|
218
|
+
def __init__(self, fp, total, on_progress):
|
|
219
|
+
self._fp = fp
|
|
220
|
+
self._total = total
|
|
221
|
+
self._sent = 0
|
|
222
|
+
self._cb = on_progress
|
|
223
|
+
|
|
224
|
+
def __len__(self):
|
|
225
|
+
return self._total
|
|
226
|
+
|
|
227
|
+
def read(self, n=-1):
|
|
228
|
+
chunk = self._fp.read(n)
|
|
229
|
+
if chunk:
|
|
230
|
+
self._sent += len(chunk)
|
|
231
|
+
try:
|
|
232
|
+
self._cb(self._sent, self._total)
|
|
233
|
+
except Exception: # noqa: BLE001 — progress must never break the upload
|
|
234
|
+
pass
|
|
235
|
+
return chunk
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def upload_file(url, file_size, fp, on_progress=None):
|
|
239
|
+
"""Stream fp to the presigned S3 URL via PUT. Raises APIError on non-200."""
|
|
240
|
+
body = _ProgressReader(fp, file_size, on_progress) if on_progress else fp
|
|
241
|
+
try:
|
|
242
|
+
r = requests.put(
|
|
243
|
+
url,
|
|
244
|
+
data=body,
|
|
245
|
+
headers={
|
|
246
|
+
"Content-Length": str(file_size),
|
|
247
|
+
"Accept-Language": "en-US,*",
|
|
248
|
+
"User-Agent": "Mozilla/5.0",
|
|
249
|
+
},
|
|
250
|
+
timeout=120,
|
|
251
|
+
)
|
|
252
|
+
if r.status_code != 200:
|
|
253
|
+
raise APIError(f"upload HTTP {r.status_code} {r.reason}", r.content[:2000])
|
|
254
|
+
except requests.RequestException as e:
|
|
255
|
+
raise APIError(str(e)) from e
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def build_send_body(stk_token, targets, author, title, fmt, archive=True, force_convert=False):
|
|
259
|
+
"""Build the /SendToKindle JSON body mirroring the official desktop app.
|
|
260
|
+
|
|
261
|
+
Keys observed in the app binary: archive, targetDevices, title/author/
|
|
262
|
+
inputFormat/crc32 under DocumentMetadata, forceConvert, stkToken.
|
|
263
|
+
Deliberately NOT sent: outputFormat/deliveryMechanism — the app never
|
|
264
|
+
sends them, and outputFormat=MOBI makes the backend convert PDFs.
|
|
265
|
+
forceConvert=False keeps the native PDF (the app's unchecked checkbox).
|
|
266
|
+
"""
|
|
267
|
+
return {
|
|
268
|
+
"DocumentMetadata": {"author": author, "crc32": 0, "inputFormat": fmt, "title": title},
|
|
269
|
+
"archive": archive,
|
|
270
|
+
"forceConvert": force_convert,
|
|
271
|
+
"stkToken": stk_token,
|
|
272
|
+
"targetDevices": targets,
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def send_to_kindle(device_info, stk_token, targets, author, title, fmt, archive=True, force_convert=False):
|
|
277
|
+
"""Final delivery call. Returns sku string."""
|
|
278
|
+
body = build_send_body(stk_token, targets, author, title, fmt, archive, force_convert)
|
|
279
|
+
res = _signed_post("/SendToKindle", device_info, body)
|
|
280
|
+
return res.get("sku", "")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def logout(device_info):
|
|
284
|
+
"""Disown the device server-side. Best-effort; always delete local creds after."""
|
|
285
|
+
path = "/FirsProxy/disownFiona?contentDeleted=false"
|
|
286
|
+
digest = signer.digest_header_for_request(
|
|
287
|
+
device_info["device_private_key"], device_info["adp_token"], "GET", path, "")
|
|
288
|
+
try:
|
|
289
|
+
requests.get(
|
|
290
|
+
f"https://{FIRS_HOST}{path}",
|
|
291
|
+
headers={
|
|
292
|
+
"X-ADP-Request-Digest": digest,
|
|
293
|
+
"X-ADP-Authentication-Token": device_info["adp_token"],
|
|
294
|
+
"Accept-Language": "en-US,*",
|
|
295
|
+
"User-Agent": "Mozilla/5.0",
|
|
296
|
+
},
|
|
297
|
+
timeout=TIMEOUT,
|
|
298
|
+
)
|
|
299
|
+
except requests.RequestException:
|
|
300
|
+
pass
|