diff --git a/README.md b/README.md index 292de2a..b0e55ac 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,10 @@ Supported outlets: |*The Daily Beast*|`db`|✔️||| |*Daily Pop*|`pop`|✔️|✔️|| |*Der Standard*|`std`|✔️||✔️| +|*Financial Times Cryptic*|`ftc`|✔️|✔️|| +|*Financial Times Polymath*|`ftp`|✔️|✔️|| +|*Financial Times Sunday*|`fts`|✔️|✔️|| +|*Financial Times Weekend*|`ftw`|✔️|✔️|| |*Guardian Cryptic*|`grdc`|✔️||✔️| |*Guardian Everyman*|`grde`|✔️||✔️| |*Guardian Prize*|`grdp`|✔️||✔️| @@ -161,4 +165,4 @@ uv sync --dev uv run pyright uv run ruff check uv run ruff format -``` \ No newline at end of file +``` diff --git a/pyproject.toml b/pyproject.toml index 256bf8a..023c822 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "html2text==2025.4.15", "lxml==6.0.0", "puzpy==0.2.6", + "pycryptodome>=3.23.0", "pyyaml==6.0.2", "requests==2.32.4", "unidecode==1.4.0", diff --git a/src/xword_dl/downloader/ftdownloader.py b/src/xword_dl/downloader/ftdownloader.py new file mode 100644 index 0000000..4e3a4ba --- /dev/null +++ b/src/xword_dl/downloader/ftdownloader.py @@ -0,0 +1,291 @@ +from datetime import datetime, timedelta +import base64 +import json +from Crypto.Cipher import AES +from Crypto.Util.Padding import unpad + +from puz import Puzzle +import requests +from .basedownloader import BaseDownloader +from ..util import XWordDLException + + +class FTBaseDownloader(BaseDownloader): + def __init__(self, **kwargs): + super().__init__(**kwargs) + + # url must be like f"https://app.ft.com/crossword/{xword_id}#{iso_timestamp}" + # https://app.ft.com/crossword/04f9d247-99c8-58ac-9b03-e055e1f54ef8#2025-11-28T00:00:00.000Z + # there seems to be no easy way to get the timestamp from just the uuid/url + # so only find by date and find latest search is possible + def find_solver(self, url: str) -> str: + """Gets the solver_url""" + id_timestamp = url.split("/")[-1] + assert len(id_timestamp.split("#")) == 2, ( + "Unable to download by url, should be in the form: https://app.ft.com/crossword/04f9d247-99c8-58ac-9b03-e055e1f54ef8#2025-11-28T00:00:00.000Z" + ) + return url + + @classmethod + def to_base(cls, i: int, b: int) -> str: + """Converts integer to base <=36""" + alpha = "0123456789abcdefghijklmnopqrstuvwxyz" + ret = "" + + while i > 0: + ret += alpha[i % b] + i //= b + + return ret[::-1] + + @classmethod + def get_key_from_last_modified(cls, last_modified: int) -> str: + dt = datetime.utcfromtimestamp(last_modified / 1000) + + rev_day = str(dt.day)[::-1] + if dt.day < 10: + rev_day += "0" + rev_year = str(dt.year)[::-1] + + part1 = cls.to_base(int(str(last_modified), 16), 36) + part2 = cls.to_base((int(rev_day) + dt.day) * (int(rev_year) + dt.year), 24) + + return "#" + (part1 + part2)[:14] + "$" + + @classmethod + def get_recent_puzzles(cls, from_date: datetime, to_date: datetime) -> dict: + api_url = "https://d3qii0ai0bvcck.cloudfront.net/prod/fetchlatestpuzzles" + api_url += ( + f"?left_window={from_date.isoformat()}&right_window={to_date.isoformat()}" + ) + response = requests.get( + api_url, + headers={ + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + "Accept-Encoding": "gzip", + }, + ) + + json_response = response.json() + if ( + json_response.get("lastModified") is None + or json_response.get("response") is None + ): + raise XWordDLException( + "Unable to extract lastModified and response from json response" + ) + + last_modified = json_response["lastModified"] + response_bytes = base64.b64decode(json_response["response"]) + + key = cls.get_key_from_last_modified(last_modified) + cipher = AES.new(key.encode(), AES.MODE_CBC, iv=key.upper().encode()) + + decrypted = unpad(cipher.decrypt(response_bytes), 16, style="pkcs7").decode() + + recent_puzzles_dict = json.loads(decrypted) + recent_puzzles = recent_puzzles_dict.get("Items") + + if recent_puzzles is None: + raise XWordDLException( + "Malformed data from /fetchlatestpuzzles endpoint: Cannot get 'Items'" + ) + + return recent_puzzles + + def fetch_data(self, solver_url: str): + id_timestamp = solver_url.split("/")[-1] + dt = datetime.fromisoformat(id_timestamp.split("#")[1]) + + recent_puzzles = self.get_recent_puzzles( + dt + timedelta(days=-2), dt + timedelta(days=2) + ) + + for puzzle in recent_puzzles: + if puzzle["id#crossword_timestamp"] == id_timestamp: + return puzzle + + raise XWordDLException(f"Cannot get crossword from '{solver_url}'") + + def parse_xword(self, xw_data) -> Puzzle: + puzzle = Puzzle() + + puzzle.author = xw_data["author"] + puzzle.width, puzzle.height = [int(i) for i in xw_data["dimensions"].split("x")] + puzzle.notes = xw_data["author_message"] + + type_to_title = { + "CRYPTIC": "Cryptic", + "POLYMATH": "Polymath", + "WEEKEND_MAGAZINE": "Weekend", + "OTHER": "Sunday", + } + + puzzle.title = ( + f"{type_to_title[xw_data['crossword_type']]} No. {xw_data['crossword_id']}" + ) + + crossword_json: dict = json.loads(xw_data["crossword"]) + across, down = crossword_json["across"], crossword_json["down"] + + solution = ["."] * (puzzle.width * puzzle.height) + fill = ["."] * (puzzle.width * puzzle.height) + + for clue_obj in across.values(): + col, row = clue_obj["col"], clue_obj["row"] + answer = clue_obj["answer"] + for i in range(len(answer)): + idx = row * puzzle.width + col + i + solution[idx] = answer[i] + fill[idx] = "-" + + for clue_obj in down.values(): + col, row = clue_obj["col"], clue_obj["row"] + answer = clue_obj["answer"] + for i in range(len(answer)): + idx = (row + i) * puzzle.width + col + solution[idx] = answer[i] + fill[idx] = "-" + + puzzle.solution = "".join(solution) + puzzle.fill = "".join(fill) + + clues = [] + max_clue_num = max(max(map(int, across.keys())), max(map(int, down.keys()))) + + for clue_num in range(1, max_clue_num + 1): + if across.get(str(clue_num)): + clue_obj = across[str(clue_num)] + clue = clue_obj["clue"] + if clue_obj.get("format"): + clue += f" ({clue_obj['format']})" + clues.append(clue) + + if down.get(str(clue_num)): + clue_obj = down[str(clue_num)] + clue = clue_obj["clue"] + if clue_obj.get("format"): + clue += f" ({clue_obj['format']})" + clues.append(clue) + + puzzle.clues = clues + + return puzzle + + @classmethod + def find_latest_by_type(cls, crossword_type: str, day_delta: int) -> str: + recent_puzzles = cls.get_recent_puzzles( + datetime.today() + timedelta(days=-day_delta), + datetime.today() + timedelta(days=1), + ) + latest = None + + for puzzle in recent_puzzles: + if ( + puzzle["crossword_status"] == "PUBLISHED" + and puzzle["crossword_type"] == crossword_type + ): + if latest is None or datetime.fromisoformat( + latest["crossword_timestamp"] + ) < datetime.fromisoformat(puzzle["crossword_timestamp"]): + latest = puzzle + + if latest is None: + raise XWordDLException( + f"Cannot find latest puzzle of type {crossword_type}" + ) + else: + return f"https://app.ft.com/crossword/{latest['id#crossword_timestamp']}" + + @classmethod + def find_by_date_by_type(cls, dt: datetime, crossword_type: str) -> str: + if ( + dt.hour != 0 + or dt.minute != 0 + or dt.second != 0 + or (dt.tzname() != "UTC" and dt.tzinfo is not None) + ): + raise XWordDLException( + f"Datetime must be exactly midnight UTC, got {dt.isoformat()}" + ) + + dt_timestamp = dt.strftime("%Y-%m-%dT%H:%M:%S.000Z") + + recent_puzzles = cls.get_recent_puzzles( + dt + timedelta(days=-1), dt + timedelta(days=1) + ) + + for puzzle in recent_puzzles: + if ( + puzzle["crossword_type"] == crossword_type + and puzzle["crossword_status"] == "PUBLISHED" + and puzzle["crossword_timestamp"] == dt_timestamp + ): + return ( + f"https://app.ft.com/crossword/{puzzle['id#crossword_timestamp']}" + ) + + raise XWordDLException( + f"Cannot find puzzle type {crossword_type} on {dt_timestamp}" + ) + + +class FTCrypticDownloader(FTBaseDownloader): + command = "ftc" + outlet = "Financial Times Cryptic" + outlet_prefix = "FT" + + def find_by_date(self, dt: datetime) -> str: + return self.find_by_date_by_type(dt, "CRYPTIC") + + def find_latest(self) -> str: + return self.find_latest_by_type("CRYPTIC", 2) + + +class FTPolymathDownloader(FTBaseDownloader): + command = "ftp" + outlet = "Financial Times Polymath" + outlet_prefix = "FT" + + def find_by_date(self, dt: datetime) -> str: + if dt.weekday() != 5: + raise XWordDLException( + f"Invalid date for FT Polymath: {dt.strftime('%Y/%m/%d is a %A')}, not a Saturday" + ) + + return self.find_by_date_by_type(dt, "POLYMATH") + + def find_latest(self) -> str: + return self.find_latest_by_type("POLYMATH", 8) + + +class FTWeekendDownloader(FTBaseDownloader): + command = "ftw" + outlet = "Financial Times Weekend" + outlet_prefix = "FT" + + def find_by_date(self, dt: datetime) -> str: + if dt.weekday() != 5: + raise XWordDLException( + f"Invalid date for FT Weekend: {dt.strftime('%Y/%m/%d is a %A')}, not a Saturday" + ) + return self.find_by_date_by_type(dt, "WEEKEND_MAGAZINE") + + def find_latest(self) -> str: + return self.find_latest_by_type("WEEKEND_MAGAZINE", 8) + + +class FTSundayDownloader(FTBaseDownloader): + command = "fts" + outlet = "Financial Times Sunday" + outlet_prefix = "FT" + + def find_by_date(self, dt: datetime) -> str: + if dt.weekday() != 6: + raise XWordDLException( + f"Invalid date for FT Sunday: {dt.strftime('%Y/%m/%d is a %A')}, not a Sunday" + ) + return self.find_by_date_by_type(dt, "OTHER") + + def find_latest(self) -> str: + return self.find_latest_by_type("OTHER", 8) diff --git a/uv.lock b/uv.lock index 5173b88..31cb214 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" [[package]] @@ -219,6 +219,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/fe/f31d2bd2de7e74a6826e9f008ca003d00686fd120c3b4c78d23ec3fc87c2/puzpy-0.2.6-py2.py3-none-any.whl", hash = "sha256:90faa0e8b5497ed51b57e15911d66351a6bad9665c7a01218c6acd747a9dcd32", size = 9691, upload-time = "2024-06-13T17:37:46.842Z" }, ] +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" }, + { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" }, + { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" }, + { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/d9/12/e33935a0709c07de084d7d58d330ec3f4daf7910a18e77937affdb728452/pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379", size = 1623886, upload-time = "2025-05-17T17:21:20.614Z" }, + { url = "https://files.pythonhosted.org/packages/22/0b/aa8f9419f25870889bebf0b26b223c6986652bdf071f000623df11212c90/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4", size = 1672151, upload-time = "2025-05-17T17:21:22.666Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5e/63f5cbde2342b7f70a39e591dbe75d9809d6338ce0b07c10406f1a140cdc/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630", size = 1664461, upload-time = "2025-05-17T17:21:25.225Z" }, + { url = "https://files.pythonhosted.org/packages/d6/92/608fbdad566ebe499297a86aae5f2a5263818ceeecd16733006f1600403c/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353", size = 1702440, upload-time = "2025-05-17T17:21:27.991Z" }, + { url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" }, +] + [[package]] name = "pyright" version = "1.1.403" @@ -491,6 +526,7 @@ dependencies = [ { name = "html2text" }, { name = "lxml" }, { name = "puzpy" }, + { name = "pycryptodome" }, { name = "pyyaml" }, { name = "requests" }, { name = "unidecode" }, @@ -511,6 +547,7 @@ requires-dist = [ { name = "html2text", specifier = "==2025.4.15" }, { name = "lxml", specifier = "==6.0.0" }, { name = "puzpy", specifier = "==0.2.6" }, + { name = "pycryptodome", specifier = ">=3.23.0" }, { name = "pyyaml", specifier = "==6.0.2" }, { name = "requests", specifier = "==2.32.4" }, { name = "unidecode", specifier = "==1.4.0" },