├── .gitignore
├── syncCrosswords.py
├── launchd-example.plist
├── README.md
└── nyt2rM.py
/.gitignore:
--------------------------------------------------------------------------------
1 | /nyt-cookies.txt
2 | /scratch.py
3 | *Log.txt
4 |
--------------------------------------------------------------------------------
/syncCrosswords.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | import argparse
4 | import nyt2rM
5 | import datetime
6 | import sys
7 |
8 | parser = argparse.ArgumentParser(description="Syncs a copy of the New York Times Crossword to your Remarkable")
9 | parser.add_argument("start", help="Start Date in ISO format (YYYY-MM-DD)", default=None, nargs='?')
10 | parser.add_argument("end", help="End Date in ISO format (YYYY-MM-DD)", default=None, nargs='?')
11 | parser.add_argument("-t", "--today", help="Just get today's crossword. NOTE: Will ignore start/end arguments.", action='store_true')
12 | args = parser.parse_args()
13 |
14 | if not args.today:
15 | dateStart = datetime.date.fromisoformat(args.start)
16 | dateEnd = datetime.date.fromisoformat(args.end)
17 | else:
18 | dateStart = datetime.date.today()
19 | dateEnd = None
20 |
21 | nyt2rM.downloadNytCrosswords(dateStart,dateEnd)
22 |
--------------------------------------------------------------------------------
/launchd-example.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Label
6 | crossword-dl
7 | Program
8 | /ABSOLUTE-PATH-TO-PROJECT/remarkable-crossword-dl/syncCrosswords.py
9 |
10 | StandardOutPath
11 | /ABSOLUTE-PATH-TO-PROJECT/remarkable-crossword-dl/out-log.txt
12 | StandardErrorPath
13 | /ABSOLUTE-PATH-TO-PROJECT/remarkable-crossword-dl/err-log.txt
14 |
15 | RunAtLoad
16 |
17 | StartCalendarInterval
18 |
19 |
20 | Weekday
21 | 1
22 | Hour
23 | 21
24 | Minute
25 | 5
26 |
27 |
28 | Weekday
29 | 2
30 | Hour
31 | 21
32 | Minute
33 | 5
34 |
35 |
36 | Weekday
37 | 3
38 | Hour
39 | 21
40 | Minute
41 | 5
42 |
43 |
44 | Weekday
45 | 4
46 | Hour
47 | 21
48 | Minute
49 | 5
50 |
51 |
52 | Weekday
53 | 5
54 | Hour
55 | 21
56 | Minute
57 | 5
58 |
59 |
60 | Weekday
61 | 6
62 | Hour
63 | 17
64 | Minute
65 | 5
66 |
67 |
68 | Weekday
69 | 7
70 | Hour
71 | 17
72 | Minute
73 | 5
74 |
75 |
76 |
77 |
78 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # remarkable-crossword-dl
2 |
3 | NOTE 2/1/2025: This project now archived. It's been broken due to changes in reMarkable's cloud API. If it's going to be re-done it will probably have to be through the USB web interface, as the cloud API has gotten less accessible to developers.
4 |
5 | This is a script for syncing crosswords to a reMarkable device. It doesn't need to be run on the reMarkable itself, since it sends PDFs of the crosswords to the cloud. It requires already having a subscription to the crosswords you want to download.
6 |
7 | ## Crosswords supported
8 | - New York Times
9 |
10 | ## Setup
11 | 0. Use pip to install `rmapy` and `requests`. Follow the instructions at [rmapy](https://github.com/subutux/rmapy) for getting a reMarkable cloud token for use with this script.
12 | 1. Download the repository
13 | 2. Use a Chrome or Firefox extension to get a copy of your cookies from the NYT website as a .txt file
14 | 3. Copy that file into `remarkable-crossword-dl/` and name it `nyt-cookies.txt`
15 | 4. To run the executable manually, while in the `remarkable-crossword-dl/` folder, enter `./syncCrosswords.py `. The dates should be in ISO format (YYYY-MM-DD), and if not provided have defaults. `end_date` defaults to tomorrow, since NYT crosswords are published the evening before, and `start_date` defaults to the last undownloaded puzzle on your reMarkable cloud, with a max of 10 days ago.
16 | 5. You can also schedule it with your choice of script scheduler. An example plist for use with launchd is included in this project.
17 |
18 | ## Usage
19 |
20 | ```
21 | $ ./syncCrosswords.py --help
22 | usage: syncCrosswords.py [-h] [-t] [start] [end]
23 |
24 | Syncs a copy of the New York Times Crossword to your Remarkable
25 |
26 | positional arguments:
27 | start Start Date in ISO format (YYYY-MM-DD)
28 | end End Date in ISO format (YYYY-MM-DD)
29 |
30 | optional arguments:
31 | -h, --help show this help message and exit
32 | -t, --today Just get today's crossword. NOTE: Will ignore start/end arguments.
33 | ```
34 |
35 | ## Sources
36 | [HTTP requests for NYT Crossword](https://www.reddit.com/r/crossword/comments/dqtnca/my_automatic_nyt_crossword_downloading_script/)
37 |
38 | [rmapy, Python API for accessing reMarkable cloud](https://github.com/subutux/rmapy)
39 |
--------------------------------------------------------------------------------
/nyt2rM.py:
--------------------------------------------------------------------------------
1 | import os
2 | import http.cookiejar
3 | import datetime
4 | import time
5 | import io
6 | import requests
7 | import rmapy.api as rmapi
8 |
9 | XWFOLDERNAME = "Crosswords"
10 |
11 |
12 | def getNytInfo(dateStart: datetime.date, dateEnd: datetime.date):
13 | params = {'publish_type': 'daily',
14 | 'sort_order': 'asc',
15 | 'sort_by': 'print_date',
16 | 'date_start': str(dateStart),
17 | 'date_end': str(dateEnd),
18 | 'limit': '100', }
19 | url = 'https://nyt-games-prd.appspot.com/svc/crosswords/v3/36569100/puzzles.json'
20 | req = requests.get(url, params)
21 | req.raise_for_status()
22 | return req.json()['results']
23 |
24 |
25 | def downloadNytPdf(puzzId: str) -> requests.Response:
26 | filePath = os.path.join(os.path.dirname(__file__), "nyt-cookies.txt")
27 | cookieJar = http.cookiejar.MozillaCookieJar(filePath)
28 | cookieJar.load()
29 |
30 | url = 'https://www.nytimes.com/svc/crosswords/v2/puzzle/' + puzzId + '.pdf'
31 | pdfResponse = requests.get(url,cookies=cookieJar)
32 | pdfResponse.raise_for_status()
33 |
34 | return pdfResponse
35 |
36 |
37 | def findOrCreateXwSubFolder(rmClient,folderName) -> rmapi.Folder:
38 | crosswordFolder = findOrCreateXwFolder(rmClient)
39 | folders = [f for f in rmClient.get_meta_items()
40 | if f.VissibleName == folderName
41 | and f.Parent == crosswordFolder.ID
42 | and isinstance(f,rmapi.Folder)]
43 | destFolder = None
44 | if len(folders) == 0:
45 | destFolder = rmapi.Folder(folderName)
46 | destFolder.Parent = findOrCreateXwFolder(rmClient).ID
47 | rmClient.create_folder(destFolder)
48 | rmClient.get_meta_items(True)
49 | else:
50 | destFolder = folders[0]
51 | return destFolder
52 |
53 |
54 | def findOrCreateXwFolder(rmClient) -> rmapi.Folder:
55 | folders = [f for f in rmClient.get_meta_items()
56 | if f.VissibleName == XWFOLDERNAME
57 | and f.Parent != "trash"
58 | and isinstance(f,rmapi.Folder)]
59 | crosswordFolder = None
60 | if len(folders)== 0:
61 | crosswordFolder = rmapi.Folder(XWFOLDERNAME)
62 | rmClient.create_folder(crosswordFolder)
63 | rmClient.get_meta_items(True)
64 | else:
65 | crosswordFolder = folders[0]
66 | return crosswordFolder
67 |
68 |
69 | def docExists(rmClient, docName, folder) -> bool:
70 | docs = [d for d in rmClient.get_meta_items()
71 | if d.VissibleName == docName
72 | and d.Parent == folder.ID
73 | and isinstance(d, rmapi.Document)]
74 | return len(docs) > 0
75 |
76 |
77 | class ZipDocFromBytesIO(rmapi.ZipDocument):
78 | def __init__(self, name: str, IO: io.BytesIO, fileType: str):
79 | super().__init__()
80 | if fileType == "pdf":
81 | self.content["fileType"] = "pdf"
82 | self.pdf = IO
83 | if fileType == "epub":
84 | self.content["fileType"] = "epub"
85 | self.epub = IO
86 | self.metadata["VissibleName"] = name
87 |
88 |
89 | class CachedRmClient(rmapi.Client):
90 | def __init__(self):
91 | super().__init__()
92 | self.__cached_meta_items = None
93 | def get_meta_items(self, refresh=False):
94 | if refresh or (self.__cached_meta_items is None):
95 | self.__cached_meta_items = super().get_meta_items()
96 | return self.__cached_meta_items
97 |
98 | def mostRecentDownloadDate(rmClient: rmapi.Client, folder: rmapi.Folder) -> datetime.date:
99 | docs = [d for d in rmClient.get_meta_items()
100 | if d.Parent == folder.ID
101 | and isinstance(d, rmapi.Document)]
102 | return max([datetime.date.fromisoformat(d.VissibleName) for d in docs])
103 |
104 |
105 | def downloadNytCrosswords(dateStart: datetime.date, dateEnd: datetime.date):
106 | '''
107 | Download a range of NYT crossword puzzles for the specified
108 | date range.
109 |
110 | :param datetime.date dateStart: defaults to last unimported puzzle, up to ten days
111 | :param datetime.date dateEnd: defaults to the most recent released NYT puzzle
112 | '''
113 | rmClient = CachedRmClient()
114 |
115 | # Try a few times, this will sometimes excute on wakeup so internet might not be connected yet.
116 | success = False
117 | for i in range(5):
118 | try:
119 | rmClient.renew_token()
120 | success = True
121 | break
122 | except(requests.exceptions.ConnectionError):
123 | time.sleep(60)
124 | if not success:
125 | print("Failed to renew token from reMarkable after five" +
126 | " minutes. Probably internet is down.")
127 | return
128 |
129 | destFolder = findOrCreateXwSubFolder(rmClient, "New York Times")
130 |
131 | today = datetime.date.today()
132 | if dateEnd is None:
133 | dateEnd = today + datetime.timedelta(1)
134 | if dateStart is None:
135 | dateStart = mostRecentDownloadDate(rmClient, destFolder) + datetime.timedelta(1)
136 | dateStart = max(dateStart, today - datetime.timedelta(10))
137 | if dateStart > dateEnd:
138 | print("No new puzzles to download")
139 | return
140 |
141 | nytInfo = getNytInfo(dateStart, dateEnd)
142 |
143 | for metadata in nytInfo:
144 | puzzId = str(metadata['puzzle_id'])
145 | printDate = str(metadata['print_date'])
146 |
147 | name = printDate
148 | if docExists(rmClient, name, destFolder):
149 | print("Already downloaded " + name)
150 | continue
151 | pdfResponse = downloadNytPdf(puzzId)
152 | doc = ZipDocFromBytesIO(name, io.BytesIO(pdfResponse.content), "pdf")
153 | rmClient.upload(doc, destFolder)
154 | print("Successfully downloaded " + name)
155 |
--------------------------------------------------------------------------------