├── .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 | --------------------------------------------------------------------------------