From 4e504823f4bf8d2b5f4279da3f4d4ebe98fc97ad Mon Sep 17 00:00:00 2001 From: HallowDem <75336799+Cytrogen@users.noreply.github.com> Date: Wed, 8 Apr 2026 01:52:36 -0400 Subject: [PATCH] Initial commit: kobo-manga pipeline Automated manga download-convert-import pipeline for Kobo e-reader. Plugin-based source architecture, concurrent download engine, image processing, KEPUB conversion, and device transfer. --- .gitignore | 46 ++ .python-version | 1 + LICENSE | 674 ++++++++++++++++++++++++++ README.org | 140 ++++++ pyproject.toml | 27 ++ src/kobo_manga/__init__.py | 3 + src/kobo_manga/cli/__init__.py | 5 + src/kobo_manga/cli/commands.py | 455 +++++++++++++++++ src/kobo_manga/config.py | 86 ++++ src/kobo_manga/converter/__init__.py | 5 + src/kobo_manga/converter/kepub.py | 228 +++++++++ src/kobo_manga/converter/templates.py | 153 ++++++ src/kobo_manga/db/__init__.py | 5 + src/kobo_manga/db/database.py | 109 +++++ src/kobo_manga/db/queries.py | 365 ++++++++++++++ src/kobo_manga/downloader/__init__.py | 6 + src/kobo_manga/downloader/basic.py | 120 +++++ src/kobo_manga/downloader/engine.py | 271 +++++++++++ src/kobo_manga/models.py | 60 +++ src/kobo_manga/pipeline.py | 265 ++++++++++ src/kobo_manga/processor/__init__.py | 5 + src/kobo_manga/processor/pipeline.py | 150 ++++++ src/kobo_manga/scheduler/__init__.py | 1 + src/kobo_manga/scheduler/daemon.py | 98 ++++ src/kobo_manga/sources/__init__.py | 71 +++ src/kobo_manga/sources/base.py | 36 ++ src/kobo_manga/transfer/__init__.py | 14 + src/kobo_manga/transfer/calibre.py | 56 +++ src/kobo_manga/transfer/usb.py | 70 +++ uv.lock | 412 ++++++++++++++++ 30 files changed, 3937 insertions(+) create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 LICENSE create mode 100644 README.org create mode 100644 pyproject.toml create mode 100644 src/kobo_manga/__init__.py create mode 100644 src/kobo_manga/cli/__init__.py create mode 100644 src/kobo_manga/cli/commands.py create mode 100644 src/kobo_manga/config.py create mode 100644 src/kobo_manga/converter/__init__.py create mode 100644 src/kobo_manga/converter/kepub.py create mode 100644 src/kobo_manga/converter/templates.py create mode 100644 src/kobo_manga/db/__init__.py create mode 100644 src/kobo_manga/db/database.py create mode 100644 src/kobo_manga/db/queries.py create mode 100644 src/kobo_manga/downloader/__init__.py create mode 100644 src/kobo_manga/downloader/basic.py create mode 100644 src/kobo_manga/downloader/engine.py create mode 100644 src/kobo_manga/models.py create mode 100644 src/kobo_manga/pipeline.py create mode 100644 src/kobo_manga/processor/__init__.py create mode 100644 src/kobo_manga/processor/pipeline.py create mode 100644 src/kobo_manga/scheduler/__init__.py create mode 100644 src/kobo_manga/scheduler/daemon.py create mode 100644 src/kobo_manga/sources/__init__.py create mode 100644 src/kobo_manga/sources/base.py create mode 100644 src/kobo_manga/transfer/__init__.py create mode 100644 src/kobo_manga/transfer/calibre.py create mode 100644 src/kobo_manga/transfer/usb.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..0c7bc36aa949bbc8321432cdd6d2c7276cbf8142 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv + +# Downloads & output +downloads/ +processed/ +output/ + +# Database +*.db +*.db-shm +*.db-wal + +# IDE +.idea/ +.vscode/ + +# Config (用户自建) +config.yaml + +# Source plugins (用户自行开发) +src/kobo_manga/sources/*.py +!src/kobo_manga/sources/__init__.py +!src/kobo_manga/sources/base.py + +# Scripts & tests +scripts/ +tests/ + +# Certificates +certs/ + +# AI tools +.claude/ +.gstack/ +CLAUDE.md +PROJECT.md +TODOS.md diff --git a/.python-version b/.python-version new file mode 100644 index 0000000000000000000000000000000000000000..e4fba2183587225f216eeada4c78dfab6b2e65f5 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..f288702d2fa16d3cdf0035b15a9fcbc552cd88e7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.org b/README.org new file mode 100644 index 0000000000000000000000000000000000000000..3d34b3037a36936b478b00db9870b11a0f30738e --- /dev/null +++ b/README.org @@ -0,0 +1,140 @@ +#+TITLE: Kobo Manga Pipeline +#+AUTHOR: Cytrogen +#+OPTIONS: toc:2 + +自动化漫画下载-转换-导入流水线,从漫画源下载漫画,转换为 KEPUB 推送到 Kobo e-reader。 + +* 功能 + +- 插件化漫画源架构,自行开发源适配器 +- 并发下载引擎,支持重试与断点续传 +- 图片处理(裁边、缩放、灰度转换等,可配置) +- KEPUB 打包(固定布局 EPUB3,RTL 日漫阅读顺序) +- 传输到 Kobo(USB 直传 / Calibre) +- 订阅追更与调度 + +* 安装 + +需要 Python 3.12+ 和 [[https://docs.astral.sh/uv/][uv]]。 + +#+begin_src bash +git clone +cd kobo-manga +uv sync +#+end_src + +* 使用 + +** 搜索漫画 + +#+begin_src bash +uv run kobo-manga search "漫画名" +uv run kobo-manga search "漫画名" -s <源名称> +#+end_src + +** 下载 + +#+begin_src bash +uv run kobo-manga download "漫画名" -c 1-10 +#+end_src + +** 订阅追更 + +#+begin_src bash +uv run kobo-manga subscribe "漫画名" +uv run kobo-manga subscriptions +uv run kobo-manga update +#+end_src + +** 本地库 + +#+begin_src bash +uv run kobo-manga list +uv run kobo-manga config +#+end_src + +* 源插件开发 + +本项目采用插件化架构,不内置任何漫画源。用户需自行开发源适配器。 + +** 接口 + +所有源需继承 =BaseSource= 并实现以下方法: + +#+begin_src python +from kobo_manga.sources.base import BaseSource +from kobo_manga.sources import register_source +from kobo_manga.models import MangaInfo, Chapter, PageImage + + +class MySource(BaseSource): + name = "my_source" + URL_PATTERNS = ["example.com"] # 可选,用于从 URL 自动推断源 + + async def search(self, keyword: str) -> list[MangaInfo]: + """搜索漫画,返回结果列表。""" + ... + + async def get_manga_info(self, manga_url: str) -> MangaInfo: + """获取漫画详情,包含完整章节列表。""" + ... + + async def get_chapter_images(self, chapter: Chapter) -> list[PageImage]: + """获取章节的所有图片 URL。""" + ... + + async def close(self) -> None: + """释放资源(如 HTTP 客户端)。""" + ... + + +register_source(MySource) +#+end_src + +** 数据模型 + +| 类型 | 字段 | +|-------------+--------------------------------------------------------------| +| =MangaInfo= | id, title, source, url, author, cover_url, description, tags, chapters | +| =Chapter= | id, title, chapter_number, url, page_count, chapter_type | +| =PageImage= | chapter_id, page_number, url, local_path | + +** 安装插件 + +将插件 =.py= 文件放入 =src/kobo_manga/sources/= 目录,程序启动时会自动扫描并加载。 + +* TODO + +- [ ] Web UI 管理界面 +- [ ] Kobo Sync Protocol(通过 WiFi 无线推送到 Kobo 设备) + +* 项目结构 + +#+begin_example +src/kobo_manga/ + config.py - YAML 配置加载 + models.py - 数据模型 + utils.py - 公用工具函数 + sources/ + base.py - 源适配器抽象基类 + __init__.py - 插件注册与自动发现 + downloader/ - 并发下载引擎 + 重试 + 断点续传 + processor/ - 图片处理流水线 + converter/ - KEPUB 打包 + transfer/ - 设备传输(USB / Calibre) + scheduler/ - 追更调度 + db/ - SQLite 状态存储 + cli/ - CLI 入口 +#+end_example + +* 技术栈 + +- [[https://www.python.org/][Python]] 3.12+,[[https://docs.astral.sh/uv/][uv]] 包管理 +- [[https://www.python-httpx.org/][httpx]] 异步 HTTP 客户端 +- [[https://python-pillow.org/][Pillow]] 图片处理 +- [[https://pyyaml.org/][PyYAML]] 配置 +- [[https://www.sqlite.org/][SQLite]](WAL 模式)状态存储 + +* 许可证 + +本项目基于 [[./LICENSE][GNU General Public License v3.0]] 发布。 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..a1c666ce34b9dfba7dd08942e2c7d3473d33b758 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[project] +name = "kobo-manga" +version = "0.1.0" +description = "Automated manga download-convert-import pipeline for Kobo e-reader" +readme = "README.org" +license = "GPL-3.0-or-later" +requires-python = ">=3.12" +dependencies = [ + "httpx>=0.27", + "pillow>=10.0", + "pyyaml>=6.0", + "lzstring>=1.0", + "typer>=0.12", +] + +[project.optional-dependencies] +browser = ["playwright>=1.40"] + +[project.scripts] +kobo-manga = "kobo_manga.cli:app" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/kobo_manga"] diff --git a/src/kobo_manga/__init__.py b/src/kobo_manga/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..305bc25d7bede5624a83c2e98de2d88ec152cbc8 --- /dev/null +++ b/src/kobo_manga/__init__.py @@ -0,0 +1,3 @@ +"""Kobo Manga Pipeline - 漫画下载转换导入流水线""" + +__version__ = "0.1.0" diff --git a/src/kobo_manga/cli/__init__.py b/src/kobo_manga/cli/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ac169dbb791ccfc53df2768d00cf08813fdaff5a --- /dev/null +++ b/src/kobo_manga/cli/__init__.py @@ -0,0 +1,5 @@ +"""CLI 入口""" + +from kobo_manga.cli.commands import app + +__all__ = ["app"] diff --git a/src/kobo_manga/cli/commands.py b/src/kobo_manga/cli/commands.py new file mode 100644 index 0000000000000000000000000000000000000000..8bab270996440cf4ac92727969920ae37fed90e4 --- /dev/null +++ b/src/kobo_manga/cli/commands.py @@ -0,0 +1,455 @@ +"""CLI 命令定义""" + +import asyncio +import sys +from pathlib import Path +from typing import Optional + +import typer + +from kobo_manga.config import load_config +from kobo_manga.db.database import Database +from kobo_manga.db.queries import ( + find_manga_by_title, + get_manga_chapter_stats, + list_all_manga, + list_subscriptions, + subscribe_manga, + unsubscribe_manga, +) +from kobo_manga.models import MangaInfo +from kobo_manga.pipeline import MangaPipeline +from kobo_manga.sources import infer_source_from_url +from kobo_manga.transfer import get_transfer + +# Windows GBK 兼容 +sys.stdout.reconfigure(encoding="utf-8", errors="replace") +sys.stderr.reconfigure(encoding="utf-8", errors="replace") + +app = typer.Typer( + name="kobo-manga", + help="Manga download-convert-transfer pipeline for Kobo e-reader", + no_args_is_help=True, +) + + +def _run(coro): + """运行异步协程。""" + return asyncio.run(coro) + + +def _parse_chapter_range(chapters: str) -> tuple[float, float]: + """解析章节范围字符串。 + + 支持格式: "1-10", "5", "0-2.5" + """ + if "-" in chapters: + parts = chapters.split("-", 1) + return float(parts[0]), float(parts[1]) + num = float(chapters) + return num, num + + +def _interactive_select(results: list[MangaInfo]) -> MangaInfo | None: + """交互式选择漫画。""" + if not results: + print("未找到结果") + return None + + print(f"\n找到 {len(results)} 个结果:\n") + for i, manga in enumerate(results, 1): + author = f" [{manga.author}]" if manga.author else "" + source_label = f" ({manga.source})" if manga.source else "" + print(f" {i}. {manga.title}{author}{source_label}") + if manga.description: + desc = manga.description[:60] + if len(manga.description) > 60: + desc += "..." + print(f" {desc}") + + print() + while True: + choice = input("选择 (序号, q 退出): ").strip() + if choice.lower() == "q": + return None + try: + idx = int(choice) - 1 + if 0 <= idx < len(results): + return results[idx] + except ValueError: + pass + print("无效输入,请重试") + + +def _sanitize_filename(name: str) -> str: + """清理文件名中的非法字符。""" + return "".join( + c if c.isalnum() or c in " _-()()【】" else "_" for c in name + ) + + +# ── 命令 ───────────────────────────────────────────────── + + +@app.command() +def search( + keyword: str = typer.Argument(help="搜索关键词"), + source: Optional[str] = typer.Option( + None, "--source", "-s", help="指定漫画源 (如 manhuagui, mangadex)" + ), +): + """搜索漫画。""" + config = load_config() + with Database() as db: + pipeline = MangaPipeline(config, db) + results = _run(pipeline.search(keyword, source_name=source)) + + selected = _interactive_select(results) + if selected is None: + raise typer.Exit() + + # 获取详情 + with Database() as db: + pipeline = MangaPipeline(config, db) + manga = _run(pipeline.get_manga_info( + selected.url, source_name=selected.source + )) + + print(f"\n{'='*50}") + print(f"标题: {manga.title}") + if manga.author: + print(f"作者: {manga.author}") + if manga.tags: + print(f"标签: {', '.join(manga.tags)}") + print(f"章节: {len(manga.chapters)} 个") + if manga.chapters: + first = manga.chapters[0] + last = manga.chapters[-1] + print(f"范围: {first.title} ~ {last.title}") + print(f"URL: {manga.url}") + + +@app.command() +def download( + target: str = typer.Argument(help="漫画名或 URL"), + chapters: Optional[str] = typer.Option( + None, "--chapters", "-c", help="章节范围 (如 1-10, 5)" + ), + source: Optional[str] = typer.Option( + None, "--source", "-s", help="指定漫画源 (如 manhuagui, mangadex)" + ), + chapter_type: Optional[str] = typer.Option( + None, "--type", "-t", help="章节类型筛选 (volume/chapter/extra)" + ), + push: bool = typer.Option( + False, "--push", "-p", help="下载后推送到设备" + ), +): + """下载漫画并转换为 KEPUB。""" + config = load_config() + chapter_range = _parse_chapter_range(chapters) if chapters else None + + with Database() as db: + pipeline = MangaPipeline(config, db) + + # 判断是 URL 还是名字 + if target.startswith("http"): + manga_url = target + src = source or infer_source_from_url(target) + else: + # 搜索并选择 + results = _run(pipeline.search(target, source_name=source)) + selected = _interactive_select(results) + if selected is None: + raise typer.Exit() + manga_url = selected.url + src = selected.source + + # 全流程: 下载 → 处理 → KEPUB + kepub_paths = _run( + pipeline.download_and_convert( + manga_url, + source_name=src, + chapter_range=chapter_range, + chapter_type=chapter_type, + ) + ) + + if not kepub_paths: + print("\n没有新的 KEPUB 文件生成") + raise typer.Exit() + + print(f"\n生成 {len(kepub_paths)} 个 KEPUB 文件:") + for p in kepub_paths: + size_mb = p.stat().st_size / 1024 / 1024 + print(f" {p.name} ({size_mb:.1f}MB)") + + # 推送到设备 + if push: + _do_push(config, kepub_paths) + + +@app.command(name="list") +def list_manga(): + """查看本地漫画库。""" + with Database() as db: + mangas = list_all_manga(db) + + if not mangas: + print("本地库为空,使用 download 命令下载漫画") + raise typer.Exit() + + print(f"\n本地漫画库 ({len(mangas)} 部):\n") + for manga in mangas: + stats = get_manga_chapter_stats(db, manga.id, manga.source) + author = f" [{manga.author}]" if manga.author else "" + downloaded = stats["downloaded"] + total = stats["total"] + print(f" {manga.title}{author}") + print(f" 章节: {downloaded}/{total} 已下载 来源: {manga.source}") + + +@app.command() +def push( + target: str = typer.Argument(help="漫画名"), +): + """推送 KEPUB 到设备。""" + config = load_config() + + # 查找 output 目录下的 KEPUB 文件 + output_dir = Path("output") / _sanitize_filename(target) + if not output_dir.exists(): + # 尝试模糊匹配 output 下的目录 + output_base = Path("output") + if output_base.exists(): + matches = [ + d for d in output_base.iterdir() + if d.is_dir() and target.lower() in d.name.lower() + ] + if len(matches) == 1: + output_dir = matches[0] + elif len(matches) > 1: + print("匹配到多个目录:") + for d in matches: + print(f" {d.name}") + print("请使用更精确的名称") + raise typer.Exit(1) + + if not output_dir.exists(): + print(f"未找到 KEPUB 文件目录: {output_dir}") + print("请先使用 download 命令下载并转换") + raise typer.Exit(1) + + kepub_paths = sorted(output_dir.glob("*.kepub.epub")) + if not kepub_paths: + print(f"目录中无 .kepub.epub 文件: {output_dir}") + raise typer.Exit(1) + + print(f"找到 {len(kepub_paths)} 个 KEPUB 文件") + _do_push(config, kepub_paths, manga_title=output_dir.name) + + +@app.command() +def config(): + """查看当前配置。""" + cfg = load_config() + print("\n当前配置:\n") + print(f"[设备]") + print(f" 型号: {cfg.device.model}") + print(f" 分辨率: {cfg.device.width}x{cfg.device.height}") + print(f" 彩色: {'是' if cfg.device.color else '否'}") + print() + print(f"[漫画源]") + print(f" 启用: {', '.join(cfg.sources)}") + print() + print(f"[图片处理]") + print(f" 双页拆分: {'开' if cfg.processing.split_double_page else '关'}") + print(f" 裁白边: {'开' if cfg.processing.crop_whitespace else '关'}") + print(f" 缩放: {'开' if cfg.processing.resize else '关'}") + print(f" 灰度: {'开' if cfg.processing.grayscale else '关'}") + print(f" 对比度增强: {'开' if cfg.processing.enhance_contrast else '关'}") + print(f" 对比度系数: {cfg.processing.contrast_factor}") + print() + print(f"[下载]") + print(f" 并发数: {cfg.download.concurrent}") + print(f" 重试次数: {cfg.download.retry}") + print(f" 延迟: {cfg.download.delay}s") + print() + print(f"[传输]") + print(f" 方式: {cfg.transfer.method}") + if cfg.transfer.method == "calibre": + print(f" Calibre: {cfg.transfer.calibre_host}:{cfg.transfer.calibre_port}") + + +@app.command() +def subscribe( + target: str = typer.Argument(help="漫画名或 URL"), + source: Optional[str] = typer.Option( + None, "--source", "-s", help="指定漫画源 (如 manhuagui, mangadex)" + ), + auto_push: bool = typer.Option( + False, "--auto-push", help="新章节自动推送到设备" + ), +): + """订阅漫画追更。""" + config = load_config() + + with Database() as db: + pipeline = MangaPipeline(config, db) + + # 获取漫画信息 + if target.startswith("http"): + src = source or infer_source_from_url(target) + manga = _run(pipeline.get_manga_info(target, source_name=src)) + else: + results = _run(pipeline.search(target, source_name=source)) + selected = _interactive_select(results) + if selected is None: + raise typer.Exit() + manga = _run(pipeline.get_manga_info( + selected.url, source_name=selected.source + )) + + # 订阅 + subscribe_manga(db, manga.id, manga.source, auto_push) + + print(f"\n[OK] 已订阅: {manga.title}") + print(f" 来源: {manga.source}") + print(f" 章节数: {len(manga.chapters)}") + if auto_push: + print(" 自动推送: 开") + + +@app.command() +def unsubscribe( + target: str = typer.Argument(help="漫画名"), +): + """取消订阅。""" + with Database() as db: + # 在本地库搜索 + mangas = find_manga_by_title(db, target) + if not mangas: + print(f"未找到匹配的漫画: {target}") + raise typer.Exit(1) + + if len(mangas) == 1: + manga = mangas[0] + else: + manga = _interactive_select(mangas) + if manga is None: + raise typer.Exit() + + removed = unsubscribe_manga(db, manga.id, manga.source) + + if removed: + print(f"[OK] 已取消订阅: {manga.title}") + else: + print(f"未订阅该漫画: {manga.title}") + + +@app.command() +def subscriptions(): + """查看订阅列表。""" + with Database() as db: + subs = list_subscriptions(db) + + if not subs: + print("暂无订阅,使用 subscribe 命令添加") + raise typer.Exit() + + print(f"\n订阅列表 ({len(subs)} 部):\n") + for sub in subs: + title = sub["title"] + author = f" [{sub['author']}]" if sub.get("author") else "" + downloaded = sub["downloaded_chapters"] + total = sub["total_chapters"] + last_checked = sub["last_checked"] or "从未" + auto_push = "开" if sub["auto_push"] else "关" + + print(f" {title}{author}") + print(f" 来源: {sub['source']} 章节: {downloaded}/{total}") + print(f" 最后检查: {last_checked} 自动推送: {auto_push}") + + +@app.command() +def update( + push: bool = typer.Option( + False, "--push", "-p", help="下载后推送到设备" + ), +): + """检查所有订阅的更新。""" + config = load_config() + + with Database() as db: + subs = list_subscriptions(db) + if not subs: + print("暂无订阅,使用 subscribe 命令添加") + raise typer.Exit() + + print(f"检查 {len(subs)} 个订阅的更新...\n") + + pipeline = MangaPipeline(config, db) + all_kepubs: list[Path] = [] + + for sub in subs: + title = sub["title"] + print(f"[{title}]") + kepubs = _run( + pipeline.check_and_download_updates( + sub["manga_id"], sub["source"] + ) + ) + all_kepubs.extend(kepubs) + + if all_kepubs: + print(f"\n共 {len(all_kepubs)} 个新 KEPUB:") + for p in all_kepubs: + size_mb = p.stat().st_size / 1024 / 1024 + print(f" {p.name} ({size_mb:.1f}MB)") + + if push: + _do_push(config, all_kepubs) + else: + print("\n无新章节") + + +@app.command() +def daemon(): + """启动守护进程,定期检查订阅更新。""" + from kobo_manga.scheduler.daemon import UpdateScheduler + + config = load_config() + + with Database() as db: + subs = list_subscriptions(db) + if not subs: + print("暂无订阅,使用 subscribe 命令添加") + raise typer.Exit() + + interval = config.scheduler.interval + print(f"守护进程启动: {len(subs)} 个订阅,间隔 {interval}s") + print("按 Ctrl+C 退出\n") + + scheduler = UpdateScheduler(config, db) + try: + asyncio.run(scheduler.run_forever()) + except KeyboardInterrupt: + print("\n\n[OK] 守护进程已停止") + + +def _do_push(config, kepub_paths: list[Path], manga_title: str = ""): + """执行传输。""" + print(f"\n传输 {len(kepub_paths)} 个文件 (方式: {config.transfer.method})...") + try: + transfer = get_transfer(config.transfer) + if hasattr(transfer, "transfer"): + if config.transfer.method == "usb": + dest_paths = transfer.transfer(kepub_paths, manga_title) + else: + dest_paths = transfer.transfer(kepub_paths) + print(f"[OK] 传输完成: {len(dest_paths)} 个文件") + for p in dest_paths: + print(f" -> {p}") + except RuntimeError as e: + print(f"[!] 传输失败: {e}") + raise typer.Exit(1) diff --git a/src/kobo_manga/config.py b/src/kobo_manga/config.py new file mode 100644 index 0000000000000000000000000000000000000000..60fc9a06cc9f67d3c20d81c921810039d2c8705a --- /dev/null +++ b/src/kobo_manga/config.py @@ -0,0 +1,86 @@ +"""配置加载模块""" + +from dataclasses import dataclass, field +from pathlib import Path + +import yaml + + +@dataclass +class DeviceConfig: + model: str = "kobo-clara-bw" + width: int = 1072 + height: int = 1448 + color: bool = False + + +@dataclass +class ProcessingConfig: + split_double_page: bool = True + crop_whitespace: bool = True + resize: bool = True + grayscale: bool = True + enhance_contrast: bool = True + contrast_factor: float = 1.2 + + +@dataclass +class DownloadConfig: + concurrent: int = 3 + retry: int = 3 + delay: float = 1.0 + + +@dataclass +class TransferConfig: + method: str = "usb" + calibre_host: str = "localhost" + calibre_port: int = 8080 + + +@dataclass +class SchedulerConfig: + interval: int = 3600 + auto_push: bool = False + + +@dataclass +class AppConfig: + device: DeviceConfig = field(default_factory=DeviceConfig) + sources: list[str] = field(default_factory=list) + processing: ProcessingConfig = field(default_factory=ProcessingConfig) + download: DownloadConfig = field(default_factory=DownloadConfig) + transfer: TransferConfig = field(default_factory=TransferConfig) + scheduler: SchedulerConfig = field(default_factory=SchedulerConfig) + + +def _dict_to_dataclass(cls, data: dict): + """将字典映射到 dataclass,忽略多余字段。""" + if data is None: + return cls() + valid_fields = {f.name for f in cls.__dataclass_fields__.values()} + filtered = {k: v for k, v in data.items() if k in valid_fields} + return cls(**filtered) + + +def load_config(config_path: str | Path | None = None) -> AppConfig: + """加载配置文件,未指定时查找项目根目录的 config.yaml。""" + if config_path is None: + config_path = Path("config.yaml") + else: + config_path = Path(config_path) + + if not config_path.exists(): + return AppConfig() + + with open(config_path, encoding="utf-8") as f: + raw = yaml.safe_load(f) or {} + + return AppConfig( + device=_dict_to_dataclass(DeviceConfig, raw.get("device")), + sources=raw.get("sources", []), + processing=_dict_to_dataclass(ProcessingConfig, raw.get("processing")), + download=_dict_to_dataclass(DownloadConfig, raw.get("download")), + transfer=_dict_to_dataclass(TransferConfig, raw.get("transfer")), + scheduler=_dict_to_dataclass(SchedulerConfig, raw.get("scheduler")), + ) diff --git a/src/kobo_manga/converter/__init__.py b/src/kobo_manga/converter/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..640dbc2b21eb29ab24c8226131e50974e94f54f2 --- /dev/null +++ b/src/kobo_manga/converter/__init__.py @@ -0,0 +1,5 @@ +"""KEPUB 转换模块""" + +from kobo_manga.converter.kepub import KepubBuilder + +__all__ = ["KepubBuilder"] diff --git a/src/kobo_manga/converter/kepub.py b/src/kobo_manga/converter/kepub.py new file mode 100644 index 0000000000000000000000000000000000000000..7863a52f08cf1391abf49f97001e01cdbca4dcc6 --- /dev/null +++ b/src/kobo_manga/converter/kepub.py @@ -0,0 +1,228 @@ +"""KEPUB 打包器 + +将处理后的图片打包为 .kepub.epub 文件,可直接在 Kobo 设备上阅读。 +""" + +import uuid as uuid_mod +import zipfile +from datetime import datetime, timezone +from pathlib import Path + +from kobo_manga.config import DeviceConfig +from kobo_manga.converter.templates import ( + CONTAINER_XML, + MIMETYPE, + STYLE_CSS, + content_opf, + nav_xhtml, + page_xhtml, + toc_ncx, +) +from kobo_manga.models import Chapter, MangaInfo + + +class KepubBuilder: + """将漫画图片打包为 KEPUB 格式。""" + + def __init__( + self, manga: MangaInfo, chapter: Chapter, device: DeviceConfig + ): + self.manga = manga + self.chapter = chapter + self.device = device + self.uuid = str(uuid_mod.uuid4()) + + def build( + self, + image_paths: list[Path], + output_dir: Path, + cover_path: Path | None = None, + ) -> Path: + """从处理后的图片构建 .kepub.epub 文件。 + + Args: + image_paths: 按页码排序的图片文件列表 + output_dir: 输出目录 + cover_path: 封面图路径,为 None 则用第一页 + + Returns: + 生成的 .kepub.epub 文件路径 + """ + output_dir.mkdir(parents=True, exist_ok=True) + + # 文件名 sanitize(含章节 ID 避免同名章节冲突) + safe_title = _sanitize_filename(self.manga.title) + safe_chapter = _sanitize_filename(self.chapter.title) + filename = f"{safe_title} - {self.chapter.id} {safe_chapter}.kepub.epub" + output_path = output_dir / filename + + # 封面:有指定用指定的,否则用第一页 + if cover_path is None and image_paths: + cover_path = image_paths[0] + + with zipfile.ZipFile(output_path, "w") as zf: + # 1. mimetype 必须是第一个条目,不压缩 + info = zipfile.ZipInfo("mimetype") + info.compress_type = zipfile.ZIP_STORED + zf.writestr(info, MIMETYPE) + + # 2. META-INF/container.xml + zf.writestr("META-INF/container.xml", CONTAINER_XML) + + # 3. 样式表 + zf.writestr("OEBPS/style.css", STYLE_CSS) + + # 4. 图片 + image_filenames = [] + if cover_path: + cover_name = "cover.jpg" + info = zipfile.ZipInfo(f"OEBPS/Images/{cover_name}") + info.compress_type = zipfile.ZIP_STORED + zf.writestr(info, cover_path.read_bytes()) + image_filenames.append(cover_name) + + for i, img_path in enumerate(image_paths, 1): + img_name = f"page_{i:03d}.jpg" + info = zipfile.ZipInfo(f"OEBPS/Images/{img_name}") + info.compress_type = zipfile.ZIP_STORED + zf.writestr(info, img_path.read_bytes()) + image_filenames.append(img_name) + + # 5. 页面 XHTML + page_ids = [] + vw = self.device.width + vh = self.device.height + + # 封面页 + if cover_path: + cover_xhtml = page_xhtml( + page_num=0, + image_filename="cover.jpg", + viewport_w=vw, + viewport_h=vh, + ) + zf.writestr("OEBPS/Text/cover.xhtml", cover_xhtml) + page_ids.append(("cover", "OEBPS/Text/cover.xhtml")) + + for i in range(1, len(image_paths) + 1): + p_xhtml = page_xhtml( + page_num=i, + image_filename=f"page_{i:03d}.jpg", + viewport_w=vw, + viewport_h=vh, + ) + zf.writestr(f"OEBPS/Text/page_{i:03d}.xhtml", p_xhtml) + page_ids.append( + (f"page_{i:03d}", f"OEBPS/Text/page_{i:03d}.xhtml") + ) + + # 6. content.opf + manifest_items = self._build_manifest( + image_filenames, page_ids, cover_path is not None + ) + spine_items = self._build_spine(page_ids) + + title = f"{self.manga.title} - {self.chapter.title}" + modified = datetime.now(timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + + opf = content_opf( + uuid=self.uuid, + title=title, + author=self.manga.author, + language="zh", + description=self.manga.description, + series_name=self.manga.title, + series_index=self.chapter.chapter_number, + modified=modified, + manifest_items=manifest_items, + spine_items=spine_items, + viewport_w=vw, + viewport_h=vh, + ) + zf.writestr("OEBPS/content.opf", opf) + + # 7. toc.ncx + nav_points = self._build_ncx_nav_points(page_ids) + ncx = toc_ncx( + uuid=self.uuid, title=title, nav_points=nav_points + ) + zf.writestr("OEBPS/toc.ncx", ncx) + + # 8. nav.xhtml + nav_items = self._build_nav_items(page_ids) + nav = nav_xhtml(title=title, nav_items=nav_items) + zf.writestr("OEBPS/nav.xhtml", nav) + + return output_path + + def _build_manifest( + self, + image_filenames: list[str], + page_ids: list[tuple[str, str]], + has_cover: bool, + ) -> str: + """生成 manifest 条目。""" + lines = [] + + # 图片条目 + for img_name in image_filenames: + img_id = img_name.replace(".", "_") + props = "" + if img_name == "cover.jpg": + props = ' properties="cover-image"' + lines.append( + f' ' + ) + + # XHTML 页面条目 + for page_id, page_href in page_ids: + # href 相对于 OEBPS + rel_href = page_href.replace("OEBPS/", "") + lines.append( + f' ' + ) + + return "\n".join(lines) + + def _build_spine(self, page_ids: list[tuple[str, str]]) -> str: + """生成 spine 条目(阅读顺序)。""" + lines = [] + for page_id, _ in page_ids: + lines.append(f' ') + return "\n".join(lines) + + def _build_ncx_nav_points( + self, page_ids: list[tuple[str, str]] + ) -> str: + """生成 NCX navPoint 条目。""" + lines = [] + for i, (page_id, page_href) in enumerate(page_ids, 1): + rel_href = page_href.replace("OEBPS/", "") + label = "Cover" if page_id == "cover" else f"Page {i}" + lines.append( + f' \n' + f" {label}\n" + f' \n' + f" " + ) + return "\n".join(lines) + + def _build_nav_items(self, page_ids: list[tuple[str, str]]) -> str: + """生成 EPUB3 nav 条目。""" + lines = [] + for i, (page_id, page_href) in enumerate(page_ids, 1): + rel_href = page_href.replace("OEBPS/", "") + label = "Cover" if page_id == "cover" else f"Page {i}" + lines.append(f'
  • {label}
  • ') + return "\n".join(lines) + + +def _sanitize_filename(name: str) -> str: + """清理文件名中的非法字符。""" + return "".join( + c if c.isalnum() or c in " _-()()【】" else "_" for c in name + ) diff --git a/src/kobo_manga/converter/templates.py b/src/kobo_manga/converter/templates.py new file mode 100644 index 0000000000000000000000000000000000000000..ef1be69e522660c964dfcabed3dd45a092067949 --- /dev/null +++ b/src/kobo_manga/converter/templates.py @@ -0,0 +1,153 @@ +"""KEPUB 文件的 XML/XHTML 模板""" + +MIMETYPE = "application/epub+zip" + +CONTAINER_XML = """\ + + + + + +""" + +STYLE_CSS = """\ +body { + margin: 0; + padding: 0; +} +svg { + width: 100%; + height: 100%; +}""" + + +def content_opf( + *, + uuid: str, + title: str, + author: str | None, + language: str, + description: str | None, + series_name: str | None, + series_index: float | None, + modified: str, + manifest_items: str, + spine_items: str, + viewport_w: int, + viewport_h: int, +) -> str: + """生成 content.opf 包文档。""" + meta_extra = "" + if description: + # 转义 XML 特殊字符 + desc_escaped = ( + description.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + ) + meta_extra += f"\n {desc_escaped}" + if series_name: + series_escaped = series_name.replace("&", "&").replace('"', """) + meta_extra += f'\n ' + if series_index is not None: + meta_extra += ( + f'\n ' + ) + + author_tag = "" + if author: + author_escaped = author.replace("&", "&").replace("<", "<") + author_tag = f"\n {author_escaped}" + + return f"""\ + + + + urn:uuid:{uuid} + {title}{author_tag} + {language} + {modified} + pre-paginated + portrait + none{meta_extra} + + + + + +{manifest_items} + + +{spine_items} + +""" + + +def toc_ncx(*, uuid: str, title: str, nav_points: str) -> str: + """生成 toc.ncx 导航文件。""" + title_escaped = title.replace("&", "&").replace("<", "<") + return f"""\ + + + + + + + + + {title_escaped} + +{nav_points} + +""" + + +def nav_xhtml(*, title: str, nav_items: str) -> str: + """生成 EPUB3 nav.xhtml 导航文档。""" + title_escaped = title.replace("&", "&").replace("<", "<") + return f"""\ + + + + {title_escaped} + + + + +""" + + +def page_xhtml( + *, page_num: int, image_filename: str, viewport_w: int, viewport_h: int +) -> str: + """生成单页 XHTML(固定布局,SVG 包裹图片)。""" + return f"""\ + + + + Page {page_num} + + + + +
    + + + +
    + + +""" diff --git a/src/kobo_manga/db/__init__.py b/src/kobo_manga/db/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ecdfeee24ee38d1c0c3241914438406df72c56b4 --- /dev/null +++ b/src/kobo_manga/db/__init__.py @@ -0,0 +1,5 @@ +"""SQLite 状态存储模块""" + +from kobo_manga.db.database import Database + +__all__ = ["Database"] diff --git a/src/kobo_manga/db/database.py b/src/kobo_manga/db/database.py new file mode 100644 index 0000000000000000000000000000000000000000..51e870294a1092d4c4daea90a28103f0b60c10c7 --- /dev/null +++ b/src/kobo_manga/db/database.py @@ -0,0 +1,109 @@ +"""SQLite 数据库管理 + +管理漫画、章节、页面的下载状态,支持断点续传和去重。 +""" + +import sqlite3 +from pathlib import Path + +SCHEMA_SQL = """\ +CREATE TABLE IF NOT EXISTS manga ( + id TEXT NOT NULL, + source TEXT NOT NULL, + title TEXT NOT NULL, + alt_title TEXT, + author TEXT, + cover_url TEXT, + description TEXT, + tags TEXT, + url TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (id, source) +); + +CREATE TABLE IF NOT EXISTS chapter ( + id TEXT NOT NULL, + manga_id TEXT NOT NULL, + manga_source TEXT NOT NULL, + title TEXT NOT NULL, + chapter_number REAL NOT NULL, + chapter_type TEXT NOT NULL DEFAULT 'chapter', + url TEXT NOT NULL, + page_count INTEGER, + status TEXT NOT NULL DEFAULT 'pending', + download_path TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (id, manga_source), + FOREIGN KEY (manga_id, manga_source) REFERENCES manga(id, source) +); + +CREATE INDEX IF NOT EXISTS idx_chapter_manga ON chapter(manga_id, manga_source); +CREATE INDEX IF NOT EXISTS idx_chapter_status ON chapter(status); + +CREATE TABLE IF NOT EXISTS page ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chapter_id TEXT NOT NULL, + chapter_source TEXT NOT NULL, + page_number INTEGER NOT NULL, + url TEXT NOT NULL, + local_path TEXT, + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(chapter_id, chapter_source, page_number), + FOREIGN KEY (chapter_id, chapter_source) REFERENCES chapter(id, manga_source) +); + +CREATE INDEX IF NOT EXISTS idx_page_chapter ON page(chapter_id, chapter_source); + +CREATE TABLE IF NOT EXISTS subscription ( + manga_id TEXT NOT NULL, + source TEXT NOT NULL, + subscribed_at TEXT NOT NULL DEFAULT (datetime('now')), + last_checked TEXT, + last_chapter_id TEXT, + auto_push INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (manga_id, source), + FOREIGN KEY (manga_id, source) REFERENCES manga(id, source) +); +""" + + +class Database: + """SQLite 状态存储。同步操作,在 async 下载批次之间调用。""" + + def __init__(self, db_path: str | Path = "kobo_manga.db"): + self.db_path = Path(db_path) + self.conn: sqlite3.Connection | None = None + + def initialize(self) -> None: + """创建连接并初始化表结构。""" + self.conn = sqlite3.connect(str(self.db_path)) + self.conn.row_factory = sqlite3.Row + self.conn.execute("PRAGMA journal_mode=WAL") + self.conn.execute("PRAGMA foreign_keys=ON") + self.conn.executescript(SCHEMA_SQL) + self._migrate() + self.conn.commit() + + def _migrate(self) -> None: + """增量 schema 迁移。""" + cursor = self.conn.execute("PRAGMA table_info(chapter)") + columns = {row[1] for row in cursor.fetchall()} + if "chapter_type" not in columns: + self.conn.execute( + "ALTER TABLE chapter ADD COLUMN chapter_type TEXT NOT NULL DEFAULT 'chapter'" + ) + + def close(self) -> None: + if self.conn: + self.conn.close() + self.conn = None + + def __enter__(self) -> "Database": + self.initialize() + return self + + def __exit__(self, *args) -> None: + self.close() diff --git a/src/kobo_manga/db/queries.py b/src/kobo_manga/db/queries.py new file mode 100644 index 0000000000000000000000000000000000000000..6d4ac830c3407d2bd7aa3da2ddf5c1751e615309 --- /dev/null +++ b/src/kobo_manga/db/queries.py @@ -0,0 +1,365 @@ +"""SQL 查询操作 + +所有数据库读写封装为函数,接收 Database 实例作为第一个参数。 +""" + +import json + +from kobo_manga.db.database import Database +from kobo_manga.models import Chapter, MangaInfo, PageImage + + +# ── Manga ───────────────────────────────────────────────── + +def upsert_manga(db: Database, manga: MangaInfo) -> None: + """插入或更新漫画元数据。""" + tags_json = json.dumps(manga.tags, ensure_ascii=False) if manga.tags else "[]" + db.conn.execute( + """\ + INSERT INTO manga (id, source, title, alt_title, author, cover_url, + description, tags, url) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id, source) DO UPDATE SET + title=excluded.title, alt_title=excluded.alt_title, + author=excluded.author, cover_url=excluded.cover_url, + description=excluded.description, tags=excluded.tags, + url=excluded.url, updated_at=datetime('now') + """, + (manga.id, manga.source, manga.title, manga.alt_title, + manga.author, manga.cover_url, manga.description, + tags_json, manga.url), + ) + db.conn.commit() + + +def get_manga(db: Database, manga_id: str, source: str) -> MangaInfo | None: + """按 ID 和来源获取漫画。""" + row = db.conn.execute( + "SELECT * FROM manga WHERE id=? AND source=?", + (manga_id, source), + ).fetchone() + if not row: + return None + return MangaInfo( + id=row["id"], + title=row["title"], + source=row["source"], + url=row["url"], + alt_title=row["alt_title"], + author=row["author"], + cover_url=row["cover_url"], + description=row["description"], + tags=json.loads(row["tags"]) if row["tags"] else [], + ) + + +# ── Chapter ─────────────────────────────────────────────── + +def upsert_chapters( + db: Database, manga_id: str, source: str, chapters: list[Chapter] +) -> None: + """批量插入/更新章节。已下载的章节不覆盖状态。""" + for ch in chapters: + db.conn.execute( + """\ + INSERT INTO chapter (id, manga_id, manga_source, title, + chapter_number, chapter_type, url, page_count) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id, manga_source) DO UPDATE SET + title=excluded.title, chapter_number=excluded.chapter_number, + chapter_type=excluded.chapter_type, + url=excluded.url, page_count=excluded.page_count, + updated_at=datetime('now') + WHERE chapter.status != 'downloaded' + """, + (ch.id, manga_id, source, ch.title, + ch.chapter_number, ch.chapter_type, ch.url, ch.page_count), + ) + db.conn.commit() + + +def get_downloaded_chapter_ids( + db: Database, manga_id: str, source: str +) -> set[str]: + """返回已下载章节的 ID 集合。""" + rows = db.conn.execute( + "SELECT id FROM chapter WHERE manga_id=? AND manga_source=? AND status='downloaded'", + (manga_id, source), + ).fetchall() + return {row["id"] for row in rows} + + +def set_chapter_status( + db: Database, + chapter_id: str, + source: str, + status: str, + download_path: str | None = None, +) -> None: + """更新章节状态。""" + if download_path is not None: + db.conn.execute( + "UPDATE chapter SET status=?, download_path=?, updated_at=datetime('now') " + "WHERE id=? AND manga_source=?", + (status, download_path, chapter_id, source), + ) + else: + db.conn.execute( + "UPDATE chapter SET status=?, updated_at=datetime('now') " + "WHERE id=? AND manga_source=?", + (status, chapter_id, source), + ) + db.conn.commit() + + +def get_chapters( + db: Database, manga_id: str, source: str +) -> list[Chapter]: + """获取漫画的所有章节,按章节号排序。""" + rows = db.conn.execute( + "SELECT * FROM chapter WHERE manga_id=? AND manga_source=? " + "ORDER BY chapter_number", + (manga_id, source), + ).fetchall() + return [ + Chapter( + id=row["id"], + title=row["title"], + chapter_number=row["chapter_number"], + url=row["url"], + page_count=row["page_count"], + ) + for row in rows + ] + + +# ── Page ────────────────────────────────────────────────── + +def upsert_pages( + db: Database, chapter_id: str, source: str, pages: list[PageImage] +) -> None: + """批量插入页面记录。已存在的不覆盖(INSERT OR IGNORE)。""" + for page in pages: + db.conn.execute( + """\ + INSERT OR IGNORE INTO page (chapter_id, chapter_source, + page_number, url) + VALUES (?, ?, ?, ?) + """, + (chapter_id, source, page.page_number, page.url), + ) + db.conn.commit() + + +def get_pending_pages( + db: Database, chapter_id: str, source: str +) -> list[PageImage]: + """获取未下载的页面(status 为 pending 或 failed)。""" + rows = db.conn.execute( + "SELECT * FROM page WHERE chapter_id=? AND chapter_source=? " + "AND status IN ('pending', 'failed') ORDER BY page_number", + (chapter_id, source), + ).fetchall() + return [ + PageImage( + chapter_id=row["chapter_id"], + page_number=row["page_number"], + url=row["url"], + ) + for row in rows + ] + + +def mark_page_downloaded( + db: Database, + chapter_id: str, + source: str, + page_number: int, + local_path: str, +) -> None: + """标记页面已下载。""" + db.conn.execute( + "UPDATE page SET status='downloaded', local_path=? " + "WHERE chapter_id=? AND chapter_source=? AND page_number=?", + (local_path, chapter_id, source, page_number), + ) + db.conn.commit() + + +def mark_page_failed( + db: Database, chapter_id: str, source: str, page_number: int +) -> None: + """标记页面下载失败。""" + db.conn.execute( + "UPDATE page SET status='failed' " + "WHERE chapter_id=? AND chapter_source=? AND page_number=?", + (chapter_id, source, page_number), + ) + db.conn.commit() + + +def are_all_pages_downloaded( + db: Database, chapter_id: str, source: str +) -> bool: + """检查章节的所有页面是否都已下载。""" + row = db.conn.execute( + "SELECT COUNT(*) as total, " + "SUM(CASE WHEN status='downloaded' THEN 1 ELSE 0 END) as done " + "FROM page WHERE chapter_id=? AND chapter_source=?", + (chapter_id, source), + ).fetchone() + return row["total"] > 0 and row["total"] == row["done"] + + +# ── CLI 查询 ───────────────────────────────────────────── + +def list_all_manga(db: Database) -> list[MangaInfo]: + """列出所有已入库的漫画。""" + rows = db.conn.execute( + "SELECT * FROM manga ORDER BY updated_at DESC" + ).fetchall() + return [ + MangaInfo( + id=row["id"], + title=row["title"], + source=row["source"], + url=row["url"], + alt_title=row["alt_title"], + author=row["author"], + cover_url=row["cover_url"], + description=row["description"], + tags=json.loads(row["tags"]) if row["tags"] else [], + ) + for row in rows + ] + + +def find_manga_by_title( + db: Database, keyword: str +) -> list[MangaInfo]: + """按标题模糊搜索本地漫画库。""" + rows = db.conn.execute( + "SELECT * FROM manga WHERE title LIKE ? ORDER BY updated_at DESC", + (f"%{keyword}%",), + ).fetchall() + return [ + MangaInfo( + id=row["id"], + title=row["title"], + source=row["source"], + url=row["url"], + alt_title=row["alt_title"], + author=row["author"], + cover_url=row["cover_url"], + description=row["description"], + tags=json.loads(row["tags"]) if row["tags"] else [], + ) + for row in rows + ] + + +def get_manga_chapter_stats( + db: Database, manga_id: str, source: str +) -> dict: + """获取漫画的章节统计信息。""" + row = db.conn.execute( + "SELECT " + " COUNT(*) as total, " + " SUM(CASE WHEN status='downloaded' THEN 1 ELSE 0 END) as downloaded, " + " SUM(CASE WHEN status='failed' THEN 1 ELSE 0 END) as failed, " + " SUM(CASE WHEN status='pending' THEN 1 ELSE 0 END) as pending " + "FROM chapter WHERE manga_id=? AND manga_source=?", + (manga_id, source), + ).fetchone() + return { + "total": row["total"] or 0, + "downloaded": row["downloaded"] or 0, + "failed": row["failed"] or 0, + "pending": row["pending"] or 0, + } + + +# ── Subscription ───────────────────────────────────────── + + +def subscribe_manga( + db: Database, manga_id: str, source: str, auto_push: bool = False +) -> None: + """订阅漫画。漫画必须已在 manga 表中。""" + db.conn.execute( + """\ + INSERT INTO subscription (manga_id, source, auto_push) + VALUES (?, ?, ?) + ON CONFLICT(manga_id, source) DO UPDATE SET + auto_push=excluded.auto_push + """, + (manga_id, source, int(auto_push)), + ) + db.conn.commit() + + +def unsubscribe_manga( + db: Database, manga_id: str, source: str +) -> bool: + """取消订阅。返回是否删除了记录。""" + cursor = db.conn.execute( + "DELETE FROM subscription WHERE manga_id=? AND source=?", + (manga_id, source), + ) + db.conn.commit() + return cursor.rowcount > 0 + + +def get_subscription( + db: Database, manga_id: str, source: str +) -> dict | None: + """获取单个订阅记录。""" + row = db.conn.execute( + "SELECT * FROM subscription WHERE manga_id=? AND source=?", + (manga_id, source), + ).fetchone() + if not row: + return None + return dict(row) + + +def list_subscriptions(db: Database) -> list[dict]: + """列出所有订阅,关联漫画信息和章节统计。""" + rows = db.conn.execute( + """\ + SELECT s.manga_id, s.source, s.subscribed_at, s.last_checked, + s.last_chapter_id, s.auto_push, + m.title, m.author, m.url, + (SELECT COUNT(*) FROM chapter c + WHERE c.manga_id=s.manga_id AND c.manga_source=s.source) as total_chapters, + (SELECT COUNT(*) FROM chapter c + WHERE c.manga_id=s.manga_id AND c.manga_source=s.source + AND c.status='downloaded') as downloaded_chapters + FROM subscription s + JOIN manga m ON s.manga_id = m.id AND s.source = m.source + ORDER BY s.subscribed_at DESC + """ + ).fetchall() + return [dict(row) for row in rows] + + +def update_subscription_checked( + db: Database, + manga_id: str, + source: str, + last_chapter_id: str | None = None, +) -> None: + """更新订阅的最后检查时间(及最新章节 ID)。""" + if last_chapter_id is not None: + db.conn.execute( + "UPDATE subscription SET last_checked=datetime('now'), " + "last_chapter_id=? WHERE manga_id=? AND source=?", + (last_chapter_id, manga_id, source), + ) + else: + db.conn.execute( + "UPDATE subscription SET last_checked=datetime('now') " + "WHERE manga_id=? AND source=?", + (manga_id, source), + ) + db.conn.commit() diff --git a/src/kobo_manga/downloader/__init__.py b/src/kobo_manga/downloader/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a7d355cc964a2a55c504771cee42f49bf19c5cb8 --- /dev/null +++ b/src/kobo_manga/downloader/__init__.py @@ -0,0 +1,6 @@ +"""下载模块""" + +from kobo_manga.downloader.basic import download_chapter, download_image +from kobo_manga.downloader.engine import DownloadEngine + +__all__ = ["download_image", "download_chapter", "DownloadEngine"] diff --git a/src/kobo_manga/downloader/basic.py b/src/kobo_manga/downloader/basic.py new file mode 100644 index 0000000000000000000000000000000000000000..bb64c592ae51c42784882f5590bee28833eb30cf --- /dev/null +++ b/src/kobo_manga/downloader/basic.py @@ -0,0 +1,120 @@ +"""基础图片下载器""" + +import asyncio +from pathlib import Path + +import httpx + +from kobo_manga.config import AppConfig +from kobo_manga.models import PageImage + +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ), +} + + +async def download_image( + client: httpx.AsyncClient, + image: PageImage, + output_dir: Path, + referer: str, + max_retry: int = 3, +) -> PageImage: + """下载单张图片,返回更新了 local_path 的 PageImage。""" + # 从 URL 推断扩展名 + ext = Path(image.url.split("?")[0]).suffix or ".jpg" + filename = f"{image.page_number:03d}{ext}" + filepath = output_dir / filename + + if filepath.exists(): + image.local_path = str(filepath) + return image + + for attempt in range(max_retry): + try: + resp = await client.get( + image.url, + headers={"Referer": referer}, + ) + resp.raise_for_status() + + filepath.parent.mkdir(parents=True, exist_ok=True) + filepath.write_bytes(resp.content) + image.local_path = str(filepath) + return image + + except (httpx.HTTPError, OSError) as e: + if attempt == max_retry - 1: + raise RuntimeError( + f"下载失败 (第{image.page_number}页): {e}" + ) from e + await asyncio.sleep(1.0 * (attempt + 1)) + + return image + + +async def download_chapter( + images: list[PageImage], + output_dir: Path, + referer: str, + config: AppConfig | None = None, +) -> list[PageImage]: + """下载一个章节的所有图片。 + + Args: + images: 图片列表(含 URL) + output_dir: 输出目录 + referer: Referer header + config: 应用配置(用于并发数、重试数等) + + Returns: + 更新了 local_path 的图片列表 + """ + concurrent = 3 + max_retry = 3 + delay = 1.0 + + if config: + concurrent = config.download.concurrent + max_retry = config.download.retry + delay = config.download.delay + + output_dir.mkdir(parents=True, exist_ok=True) + + semaphore = asyncio.Semaphore(concurrent) + + async with httpx.AsyncClient( + headers=HEADERS, + follow_redirects=True, + timeout=60.0, + ) as client: + + async def _download_with_limit(img: PageImage) -> PageImage: + async with semaphore: + result = await download_image( + client, img, output_dir, referer, max_retry + ) + await asyncio.sleep(delay) + return result + + tasks = [_download_with_limit(img) for img in images] + results = await asyncio.gather(*tasks, return_exceptions=True) + + downloaded = [] + errors = [] + for r in results: + if isinstance(r, Exception): + errors.append(r) + else: + downloaded.append(r) + + if errors: + print(f" ⚠ {len(errors)} 张图片下载失败:") + for e in errors: + print(f" - {e}") + + return downloaded diff --git a/src/kobo_manga/downloader/engine.py b/src/kobo_manga/downloader/engine.py new file mode 100644 index 0000000000000000000000000000000000000000..ca504970c3ff3564d3e25d5c6f7efa23d5630461 --- /dev/null +++ b/src/kobo_manga/downloader/engine.py @@ -0,0 +1,271 @@ +"""多章节下载引擎 + +在 basic.py 的单章节下载基础上,增加: +- 多章节批量下载编排 +- SQLite 状态追踪(去重、断点续传) +- 章节范围选择 +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from kobo_manga.sources.base import BaseSource + +from kobo_manga.config import AppConfig +from kobo_manga.db.database import Database +from kobo_manga.db.queries import ( + are_all_pages_downloaded, + get_downloaded_chapter_ids, + get_pending_pages, + mark_page_downloaded, + mark_page_failed, + set_chapter_status, + upsert_chapters, + upsert_manga, + upsert_pages, +) +from kobo_manga.downloader.basic import download_chapter as _download_chapter_basic +from kobo_manga.models import ( + Chapter, + ChapterResult, + DownloadResult, + MangaInfo, +) + + +class DownloadEngine: + """多章节下载编排器,带 SQLite 状态追踪。""" + + def __init__( + self, + db: Database, + source: "BaseSource", # 漫画源适配器 + config: AppConfig, + base_dir: Path = Path("downloads"), + ): + self.db = db + self.source = source + self.config = config + self.base_dir = base_dir + + async def download_manga( + self, + manga_id_or_url: str, + chapter_range: tuple[float, float] | None = None, + chapter_ids: list[str] | None = None, + chapter_type: str | None = None, + ) -> DownloadResult: + """下载漫画的多个章节。 + + Args: + manga_id_or_url: 漫画 URL 或 ID + chapter_range: (起始章节号, 结束章节号),闭区间 + chapter_ids: 指定章节 ID 列表(与 chapter_range 二选一) + chapter_type: 章节类型筛选 (volume/chapter/extra) + + Returns: + DownloadResult 汇总结果 + """ + # 1. 获取漫画信息和章节列表 + print(f"获取漫画信息: {manga_id_or_url}") + manga = await self.source.get_manga_info(manga_id_or_url) + print(f" {manga.title} - 共 {len(manga.chapters)} 个章节") + + # 2. 持久化到数据库 + upsert_manga(self.db, manga) + upsert_chapters(self.db, manga.id, manga.source, manga.chapters) + + # 3. 去重:获取已下载的章节 + done_ids = get_downloaded_chapter_ids(self.db, manga.id, manga.source) + + # 4. 筛选要下载的章节 + selected = self._select_chapters( + manga.chapters, chapter_range, chapter_ids, chapter_type + ) + to_download = [ch for ch in selected if ch.id not in done_ids] + skipped = len(selected) - len(to_download) + + print( + f" 选中 {len(selected)} 个章节," + f"跳过 {skipped} 个已下载," + f"待下载 {len(to_download)} 个" + ) + + # 5. 逐章下载 + result = DownloadResult( + manga=manga, + chapters_total=len(selected), + chapters_skipped=skipped, + ) + + for i, chapter in enumerate(to_download, 1): + print(f"\n[{i}/{len(to_download)}] {chapter.title}") + ch_result = await self.download_chapter(manga, chapter) + result.chapter_results.append(ch_result) + + if ch_result.status == "downloaded": + result.chapters_downloaded += 1 + else: + result.chapters_failed += 1 + + # 汇总 + print(f"\n{'='*50}") + print( + f"完成: {result.chapters_downloaded} 下载 / " + f"{result.chapters_skipped} 跳过 / " + f"{result.chapters_failed} 失败" + ) + + return result + + async def download_chapter( + self, manga: MangaInfo, chapter: Chapter + ) -> ChapterResult: + """下载单个章节,带状态追踪。""" + source_name = manga.source + + # 标记为下载中 + set_chapter_status(self.db, chapter.id, source_name, "downloading") + + try: + # 获取图片列表 + images = await self.source.get_chapter_images(chapter) + print(f" 共 {len(images)} 页") + + # 记录页面到数据库 + upsert_pages(self.db, chapter.id, source_name, images) + + # 断点续传:只下载未完成的页 + pending = get_pending_pages(self.db, chapter.id, source_name) + total_pages = len(images) + + if not pending: + # 所有页面已下载 + output_dir = self._chapter_dir(manga, chapter) + set_chapter_status( + self.db, chapter.id, source_name, + "downloaded", str(output_dir), + ) + print(f" 所有页面已存在,跳过") + return ChapterResult( + chapter=chapter, + status="downloaded", + pages_total=total_pages, + pages_downloaded=total_pages, + download_path=output_dir, + ) + + already_done = total_pages - len(pending) + if already_done > 0: + print(f" 续传: {already_done} 页已存在,下载剩余 {len(pending)} 页") + + # 调用基础下载器 + output_dir = self._chapter_dir(manga, chapter) + downloaded = await _download_chapter_basic( + pending, output_dir, chapter.url, self.config + ) + + # 更新页面状态 + downloaded_nums = set() + for page in downloaded: + if page.local_path: + mark_page_downloaded( + self.db, chapter.id, source_name, + page.page_number, page.local_path, + ) + downloaded_nums.add(page.page_number) + + # 标记失败的页面 + for page in pending: + if page.page_number not in downloaded_nums: + mark_page_failed( + self.db, chapter.id, source_name, page.page_number + ) + + # 检查是否全部完成 + all_done = are_all_pages_downloaded( + self.db, chapter.id, source_name + ) + pages_downloaded = already_done + len(downloaded_nums) + + if all_done: + set_chapter_status( + self.db, chapter.id, source_name, + "downloaded", str(output_dir), + ) + status = "downloaded" + print(f" 完成: {pages_downloaded}/{total_pages} 页") + else: + set_chapter_status( + self.db, chapter.id, source_name, "failed" + ) + status = "partial" + print( + f" 部分失败: {pages_downloaded}/{total_pages} 页" + ) + + return ChapterResult( + chapter=chapter, + status=status, + pages_total=total_pages, + pages_downloaded=pages_downloaded, + download_path=output_dir, + ) + + except Exception as e: + set_chapter_status( + self.db, chapter.id, source_name, "failed" + ) + print(f" 下载失败: {e}") + return ChapterResult( + chapter=chapter, + status="failed", + pages_total=0, + pages_downloaded=0, + ) + + async def resume_incomplete(self, manga_id_or_url: str) -> DownloadResult: + """恢复未完成的下载。重新运行 download_manga 即可,去重逻辑会自动跳过已完成的。""" + return await self.download_manga(manga_id_or_url) + + def _select_chapters( + self, + chapters: list[Chapter], + chapter_range: tuple[float, float] | None, + chapter_ids: list[str] | None, + chapter_type: str | None = None, + ) -> list[Chapter]: + """按范围、ID 或类型筛选章节。""" + # 先按类型过滤 + if chapter_type is not None: + chapters = [ch for ch in chapters if ch.chapter_type == chapter_type] + + if chapter_ids is not None: + id_set = set(chapter_ids) + return [ch for ch in chapters if ch.id in id_set] + + if chapter_range is not None: + start, end = chapter_range + return [ + ch for ch in chapters + if start <= ch.chapter_number <= end + ] + + # 未指定范围则选择全部(已按类型过滤) + return list(chapters) + + def _chapter_dir(self, manga: MangaInfo, chapter: Chapter) -> Path: + """计算章节下载目录。""" + safe_manga = _sanitize_filename(manga.title) + safe_chapter = _sanitize_filename(chapter.title) + return self.base_dir / safe_manga / safe_chapter + + +def _sanitize_filename(name: str) -> str: + """清理文件名中的非法字符。""" + return "".join( + c if c.isalnum() or c in " _-()()【】" else "_" for c in name + ) diff --git a/src/kobo_manga/models.py b/src/kobo_manga/models.py new file mode 100644 index 0000000000000000000000000000000000000000..3d8cc6a60694d51c1f92d22014cba8113de4fdcb --- /dev/null +++ b/src/kobo_manga/models.py @@ -0,0 +1,60 @@ +"""核心数据模型""" + +from dataclasses import dataclass, field +from pathlib import Path + + +@dataclass +class PageImage: + """单页图片""" + chapter_id: str + page_number: int + url: str + local_path: str | None = None + + +@dataclass +class Chapter: + """漫画章节""" + id: str + title: str + chapter_number: float + url: str + page_count: int | None = None + chapter_type: str = "chapter" # volume | chapter | extra + + +@dataclass +class MangaInfo: + """漫画信息""" + id: str + title: str + source: str + url: str + alt_title: str | None = None + author: str | None = None + cover_url: str | None = None + description: str | None = None + tags: list[str] = field(default_factory=list) + chapters: list[Chapter] = field(default_factory=list) + + +@dataclass +class ChapterResult: + """单章节下载结果""" + chapter: Chapter + status: str # downloaded | partial | failed | skipped + pages_total: int + pages_downloaded: int + download_path: Path | None = None + + +@dataclass +class DownloadResult: + """多章节下载结果""" + manga: MangaInfo + chapters_total: int + chapters_downloaded: int = 0 + chapters_skipped: int = 0 + chapters_failed: int = 0 + chapter_results: list[ChapterResult] = field(default_factory=list) diff --git a/src/kobo_manga/pipeline.py b/src/kobo_manga/pipeline.py new file mode 100644 index 0000000000000000000000000000000000000000..bb6a87c7084f948de1da63b28ce9fb7866ae3899 --- /dev/null +++ b/src/kobo_manga/pipeline.py @@ -0,0 +1,265 @@ +"""全流程编排器 + +串联 搜索 → 下载 → 图片处理 → KEPUB 打包 的完整流水线。 +""" + +from pathlib import Path + +from kobo_manga.config import AppConfig +from kobo_manga.converter.kepub import KepubBuilder +from kobo_manga.db.database import Database +from kobo_manga.db.queries import ( + get_downloaded_chapter_ids, + get_manga, + get_manga_chapter_stats, + update_subscription_checked, + upsert_chapters, + upsert_manga, +) +from kobo_manga.downloader.engine import DownloadEngine +from kobo_manga.models import Chapter, DownloadResult, MangaInfo +from kobo_manga.processor.pipeline import ImageProcessor +from kobo_manga.sources import get_source + + +class MangaPipeline: + """全流程编排:搜索 → 下载 → 处理 → 打包。""" + + def __init__( + self, + config: AppConfig, + db: Database, + base_dir: Path = Path("."), + ): + self.config = config + self.db = db + self.downloads_dir = base_dir / "downloads" + self.output_dir = base_dir / "output" + + def _source_name(self) -> str: + """获取当前配置的源名称。""" + return self.config.sources[0] if self.config.sources else "manhuagui" + + async def search( + self, keyword: str, source_name: str | None = None + ) -> list[MangaInfo]: + """搜索漫画。source_name=None 时搜索所有已配置的源。""" + if source_name: + async with get_source(source_name) as source: + return await source.search(keyword) + + # 多源聚合搜索 + results: list[MangaInfo] = [] + for name in self.config.sources or ["manhuagui"]: + try: + async with get_source(name) as source: + results.extend(await source.search(keyword)) + except Exception as e: + print(f" [!] {name} 搜索失败: {e}") + return results + + async def get_manga_info( + self, manga_url: str, source_name: str | None = None + ) -> MangaInfo: + """获取漫画详情(含章节列表)。""" + name = source_name or self._source_name() + async with get_source(name) as source: + manga = await source.get_manga_info(manga_url) + upsert_manga(self.db, manga) + return manga + + async def download_and_convert( + self, + manga_url: str, + source_name: str | None = None, + chapter_range: tuple[float, float] | None = None, + chapter_ids: list[str] | None = None, + chapter_type: str | None = None, + ) -> list[Path]: + """全流程:下载 → 图片处理 → KEPUB 打包。 + + Args: + manga_url: 漫画 URL + source_name: 源名称,None 则用默认源 + chapter_range: 章节号范围 (start, end),闭区间 + chapter_ids: 指定章节 ID 列表 + chapter_type: 章节类型筛选 (volume/chapter/extra) + + Returns: + 生成的 .kepub.epub 文件路径列表 + """ + name = source_name or self._source_name() + async with get_source(name) as source: + engine = DownloadEngine( + db=self.db, + source=source, + config=self.config, + base_dir=self.downloads_dir, + ) + result = await engine.download_manga( + manga_url, + chapter_range=chapter_range, + chapter_ids=chapter_ids, + chapter_type=chapter_type, + ) + + return self._process_results(result) + + def _process_results(self, result: DownloadResult) -> list[Path]: + """对下载成功的章节执行图片处理和 KEPUB 打包。""" + kepub_paths = [] + processor = ImageProcessor( + self.config.processing, self.config.device + ) + + successful = [ + cr for cr in result.chapter_results + if cr.status == "downloaded" and cr.download_path + ] + + if not successful: + return kepub_paths + + manga_output = self.output_dir / _sanitize_filename(result.manga.title) + + for i, cr in enumerate(successful, 1): + # 检查是否已有 KEPUB(用章节 ID 避免同名冲突) + kepub_name = ( + f"{_sanitize_filename(result.manga.title)} - " + f"{cr.chapter.id} {_sanitize_filename(cr.chapter.title)}.kepub.epub" + ) + kepub_path = manga_output / kepub_name + if kepub_path.exists(): + print(f" [SKIP] 已存在: {cr.chapter.title}") + kepub_paths.append(kepub_path) + continue + + print( + f"\n[{i}/{len(successful)}] " + f"处理+打包: {cr.chapter.title}" + ) + + try: + path = self._process_and_convert( + result.manga, cr.chapter, cr.download_path + ) + kepub_paths.append(path) + print(f" [OK] {path.name}") + except Exception as e: + print(f" [!] 处理失败: {e}") + + return kepub_paths + + def _process_and_convert( + self, + manga: MangaInfo, + chapter: Chapter, + download_path: Path, + ) -> Path: + """处理图片 + 打包 KEPUB(单章节)。""" + processor = ImageProcessor( + self.config.processing, self.config.device + ) + + # 收集下载的图片文件 + image_paths = sorted( + p for p in download_path.iterdir() + if p.suffix.lower() in (".jpg", ".jpeg", ".png", ".webp") + ) + + if not image_paths: + raise ValueError(f"目录中无图片文件: {download_path}") + + # 图片处理 + processed_dir = ( + self.downloads_dir.parent + / "processed" + / _sanitize_filename(manga.title) + / _sanitize_filename(chapter.title) + ) + processed_paths = processor.process_chapter(image_paths, processed_dir) + print(f" 处理: {len(image_paths)} -> {len(processed_paths)} 张") + + # KEPUB 打包 + output_dir = self.output_dir / _sanitize_filename(manga.title) + builder = KepubBuilder(manga, chapter, self.config.device) + kepub_path = builder.build(processed_paths, output_dir) + print(f" 打包: {kepub_path.name}") + + return kepub_path + + async def check_and_download_updates( + self, manga_id: str, source_name: str + ) -> list[Path]: + """检查订阅漫画的新章节并下载转换。 + + Returns: + 新生成的 .kepub.epub 路径列表 + """ + # 1. 从 DB 获取漫画记录 + manga = get_manga(self.db, manga_id, source_name) + if not manga: + print(f" [!] 漫画不存在: {manga_id} ({source_name})") + return [] + + # 2. 拉取最新章节列表 + async with get_source(source_name) as source: + fresh_manga = await source.get_manga_info(manga.url) + + # 3. 更新 DB + upsert_manga(self.db, fresh_manga) + upsert_chapters( + self.db, fresh_manga.id, fresh_manga.source, fresh_manga.chapters + ) + + # 4. 找出新章节(未下载的) + downloaded_ids = get_downloaded_chapter_ids( + self.db, fresh_manga.id, fresh_manga.source + ) + new_chapters = [ + ch for ch in fresh_manga.chapters + if ch.id not in downloaded_ids + ] + + if not new_chapters: + print(f" [OK] {fresh_manga.title}: 无新章节") + update_subscription_checked(self.db, manga_id, source_name) + return [] + + print(f" 发现 {len(new_chapters)} 个新章节") + new_chapter_ids = [ch.id for ch in new_chapters] + + # 5. 下载新章节 + async with get_source(source_name) as source: + engine = DownloadEngine( + db=self.db, + source=source, + config=self.config, + base_dir=self.downloads_dir, + ) + result = await engine.download_manga( + fresh_manga.url, + chapter_ids=new_chapter_ids, + ) + + # 6. 处理+打包 + kepub_paths = self._process_results(result) + + # 7. 更新订阅状态 + last_ch_id = new_chapters[-1].id if new_chapters else None + update_subscription_checked( + self.db, manga_id, source_name, last_chapter_id=last_ch_id + ) + + return kepub_paths + + def get_stats(self, manga: MangaInfo) -> dict: + """获取漫画的章节统计。""" + return get_manga_chapter_stats(self.db, manga.id, manga.source) + + +def _sanitize_filename(name: str) -> str: + """清理文件名中的非法字符。""" + return "".join( + c if c.isalnum() or c in " _-()()【】" else "_" for c in name + ) diff --git a/src/kobo_manga/processor/__init__.py b/src/kobo_manga/processor/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..00f667110178f116b913241e77fdd0da251272c5 --- /dev/null +++ b/src/kobo_manga/processor/__init__.py @@ -0,0 +1,5 @@ +"""图片处理模块""" + +from kobo_manga.processor.pipeline import ImageProcessor + +__all__ = ["ImageProcessor"] diff --git a/src/kobo_manga/processor/pipeline.py b/src/kobo_manga/processor/pipeline.py new file mode 100644 index 0000000000000000000000000000000000000000..b33b8355ce58a62bfca44d2db161ce225d76c22d --- /dev/null +++ b/src/kobo_manga/processor/pipeline.py @@ -0,0 +1,150 @@ +"""图片处理流水线 + +处理流程:双页拆分 → 裁白边 → 缩放 → 灰度 → 对比度增强 +各步骤均可通过 config 开关控制。 +""" + +from pathlib import Path + +from PIL import Image, ImageEnhance, ImageOps + +from kobo_manga.config import DeviceConfig, ProcessingConfig + + +class ImageProcessor: + """图片处理器,将漫画原图优化为适合 e-ink 设备阅读的格式。""" + + def __init__(self, processing: ProcessingConfig, device: DeviceConfig): + self.processing = processing + self.device = device + + def process_image(self, img: Image.Image) -> list[Image.Image]: + """执行完整处理流水线。 + + 返回列表是因为双页拆分可能产生 2 张图片。 + """ + images = [img] + + if self.processing.split_double_page: + images = self._split_double_pages(images) + + result = [] + for im in images: + if self.processing.crop_whitespace: + im = self._crop_whitespace(im) + if self.processing.resize: + im = self._resize(im) + if self.processing.grayscale and not self.device.color: + im = self._grayscale(im) + if self.processing.enhance_contrast: + im = self._enhance_contrast(im) + result.append(im) + + return result + + def process_chapter( + self, image_paths: list[Path], output_dir: Path + ) -> list[Path]: + """处理整个章节的图片,返回输出文件路径列表。 + + 逐张处理以控制内存占用。 + """ + output_dir.mkdir(parents=True, exist_ok=True) + output_paths = [] + page_num = 1 + + for path in image_paths: + img = Image.open(path) + processed = self.process_image(img) + img.close() + + for p_img in processed: + out_path = output_dir / f"page_{page_num:03d}.jpg" + # 灰度图保存为 L 模式 JPEG + if p_img.mode == "L": + p_img.save(out_path, "JPEG", quality=85) + else: + p_img.save(out_path, "JPEG", quality=85) + p_img.close() + output_paths.append(out_path) + page_num += 1 + + return output_paths + + # ── 流水线各步骤 ────────────────────────────────────── + + def _split_double_pages( + self, images: list[Image.Image] + ) -> list[Image.Image]: + """检测并拆分双页。宽高比 > 1.2 视为双页。 + + 日漫从右往左读,所以右半页在前。 + """ + result = [] + for img in images: + w, h = img.size + if w > h * 1.2: + mid = w // 2 + right = img.crop((mid, 0, w, h)) + left = img.crop((0, 0, mid, h)) + result.extend([right, left]) + else: + result.append(img) + return result + + def _crop_whitespace(self, img: Image.Image) -> Image.Image: + """裁剪图片周围的白色/浅色边框。""" + # 转灰度检测边界 + if img.mode == "RGBA": + gray = img.convert("RGB").convert("L") + else: + gray = img.convert("L") + + # 反转后白边变黑(0),getbbox() 找非零区域 + inverted = ImageOps.invert(gray) + bbox = inverted.getbbox() + + if bbox is None: + # 全白页,不裁剪 + return img + + # 安全检查:裁剪后面积不应小于原图 50% + orig_area = img.size[0] * img.size[1] + crop_w = bbox[2] - bbox[0] + crop_h = bbox[3] - bbox[1] + if crop_w * crop_h < orig_area * 0.5: + return img + + # 加 2px margin + margin = 2 + x0 = max(0, bbox[0] - margin) + y0 = max(0, bbox[1] - margin) + x1 = min(img.size[0], bbox[2] + margin) + y1 = min(img.size[1], bbox[3] + margin) + + return img.crop((x0, y0, x1, y1)) + + def _resize(self, img: Image.Image) -> Image.Image: + """缩放到设备分辨率内,保持宽高比,只缩小不放大。""" + w, h = img.size + target_w = self.device.width + target_h = self.device.height + + # 已经小于等于目标尺寸,不处理 + if w <= target_w and h <= target_h: + return img + + scale = min(target_w / w, target_h / h) + new_w = int(w * scale) + new_h = int(h * scale) + + return img.resize((new_w, new_h), Image.Resampling.LANCZOS) + + def _grayscale(self, img: Image.Image) -> Image.Image: + """转灰度。""" + return img.convert("L") + + def _enhance_contrast(self, img: Image.Image) -> Image.Image: + """增强对比度。""" + enhancer = ImageEnhance.Contrast(img) + return enhancer.enhance(self.processing.contrast_factor) diff --git a/src/kobo_manga/scheduler/__init__.py b/src/kobo_manga/scheduler/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..4edaf0d4bd327617d44aece8b054ad65ab15291a --- /dev/null +++ b/src/kobo_manga/scheduler/__init__.py @@ -0,0 +1 @@ +"""追更调度器""" diff --git a/src/kobo_manga/scheduler/daemon.py b/src/kobo_manga/scheduler/daemon.py new file mode 100644 index 0000000000000000000000000000000000000000..ae222bf494d3562c926769c57bfe32126229a84a --- /dev/null +++ b/src/kobo_manga/scheduler/daemon.py @@ -0,0 +1,98 @@ +"""守护进程模式 + +定期检查订阅漫画的更新,下载新章节并可选推送到设备。 +""" + +import asyncio +from datetime import datetime +from pathlib import Path + +from kobo_manga.config import AppConfig +from kobo_manga.db.database import Database +from kobo_manga.db.queries import list_subscriptions +from kobo_manga.pipeline import MangaPipeline +from kobo_manga.transfer import get_transfer + + +class UpdateScheduler: + """订阅更新调度器。""" + + def __init__(self, config: AppConfig, db: Database): + self.config = config + self.db = db + self.running = False + + async def run_once(self) -> dict: + """执行一轮更新检查。 + + Returns: + {checked, new_chapters, errors, kepubs} + """ + pipeline = MangaPipeline(self.config, self.db) + subs = list_subscriptions(self.db) + results: dict = { + "checked": 0, + "new_chapters": 0, + "errors": 0, + "kepubs": [], + } + + for sub in subs: + title = sub.get("title", sub["manga_id"]) + print(f"\n 检查: {title}") + try: + kepubs = await pipeline.check_and_download_updates( + sub["manga_id"], sub["source"] + ) + results["checked"] += 1 + results["new_chapters"] += len(kepubs) + results["kepubs"].extend(kepubs) + + # 自动推送 + if kepubs and ( + sub["auto_push"] or self.config.scheduler.auto_push + ): + self._push(kepubs) + + except Exception as e: + results["errors"] += 1 + print(f" [!] {title}: {e}") + + return results + + async def run_forever(self): + """守护循环:定期执行 run_once。Ctrl+C 退出。""" + self.running = True + interval = self.config.scheduler.interval + + while self.running: + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + print(f"\n[{now}] 开始检查订阅更新...") + + results = await self.run_once() + + print( + f"\n 检查 {results['checked']} 个订阅," + f"{results['new_chapters']} 个新章节," + f"{results['errors']} 个错误" + ) + + if self.running: + next_time = datetime.now().timestamp() + interval + next_str = datetime.fromtimestamp(next_time).strftime( + "%H:%M:%S" + ) + print(f" 下次检查: {next_str}") + try: + await asyncio.sleep(interval) + except asyncio.CancelledError: + break + + def _push(self, kepubs: list[Path]): + """推送新章节到设备。""" + try: + transfer = get_transfer(self.config.transfer) + dest_paths = transfer.transfer(kepubs) + print(f" [OK] 推送 {len(dest_paths)} 个文件") + except Exception as e: + print(f" [!] 推送失败: {e}") diff --git a/src/kobo_manga/sources/__init__.py b/src/kobo_manga/sources/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..faf7ce3a93e3b07a665cb188527e1f3649f4b282 --- /dev/null +++ b/src/kobo_manga/sources/__init__.py @@ -0,0 +1,71 @@ +"""漫画源注册表(插件化) + +源适配器为插件,需用户自行实现并放置于 sources/ 目录下。 +插件模块应继承 BaseSource 并在模块顶层调用 register_source() 注册。 +启动时自动扫描 sources/ 下所有 .py 文件(排除 base.py)并尝试加载。 +""" + +import importlib +import logging +from pathlib import Path + +from kobo_manga.sources.base import BaseSource + +logger = logging.getLogger(__name__) + +_registry: dict[str, type[BaseSource]] = {} + + +def register_source(cls: type[BaseSource]) -> type[BaseSource]: + """注册源适配器类。""" + _registry[cls.name] = cls + return cls + + +def get_source(name: str) -> BaseSource: + """按名字实例化源。""" + _ensure_registered() + if name not in _registry: + available = ", ".join(_registry.keys()) or "(无已安装的源插件)" + raise ValueError(f"未知源 '{name}',可用: {available}") + return _registry[name]() + + +def list_sources() -> list[str]: + """返回所有已注册的源名称。""" + _ensure_registered() + return list(_registry.keys()) + + +def infer_source_from_url(url: str) -> str | None: + """从 URL 推断源名称。""" + _ensure_registered() + for name, cls in _registry.items(): + if hasattr(cls, "URL_PATTERNS"): + for pattern in cls.URL_PATTERNS: + if pattern in url: + return name + return None + + +_registered = False + + +def _ensure_registered(): + """自动扫描 sources/ 目录下的插件模块并注册。""" + global _registered + if _registered: + return + _registered = True + + sources_dir = Path(__file__).parent + skip = {"__init__.py", "base.py"} + + for py_file in sorted(sources_dir.glob("*.py")): + if py_file.name in skip: + continue + module_name = f"kobo_manga.sources.{py_file.stem}" + try: + importlib.import_module(module_name) + except Exception as e: + logger.debug(f"跳过源插件 {py_file.name}: {e}") diff --git a/src/kobo_manga/sources/base.py b/src/kobo_manga/sources/base.py new file mode 100644 index 0000000000000000000000000000000000000000..e6584efb442d66df11069569ccb78041ddae8c1d --- /dev/null +++ b/src/kobo_manga/sources/base.py @@ -0,0 +1,36 @@ +"""漫画源适配器基类 + +定义所有漫画源必须实现的接口。 +""" + +from abc import ABC, abstractmethod + +from kobo_manga.models import Chapter, MangaInfo, PageImage + + +class BaseSource(ABC): + """漫画源适配器抽象基类。""" + + name: str # 子类必须声明,如 "manhuagui" + URL_PATTERNS: list[str] = [] # 可选,用于从 URL 推断源,如 ["mangadex.org"] + + @abstractmethod + async def search(self, keyword: str) -> list[MangaInfo]: + """搜索漫画。""" + + @abstractmethod + async def get_manga_info(self, manga_url: str) -> MangaInfo: + """获取漫画详情(含章节列表)。""" + + @abstractmethod + async def get_chapter_images(self, chapter: Chapter) -> list[PageImage]: + """获取章节的图片列表。""" + + async def close(self) -> None: + """释放资源(如 HTTP 客户端)。""" + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + await self.close() diff --git a/src/kobo_manga/transfer/__init__.py b/src/kobo_manga/transfer/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..f0b722f36ac7aa72725a9170a40f9b16e587d525 --- /dev/null +++ b/src/kobo_manga/transfer/__init__.py @@ -0,0 +1,14 @@ +"""设备传输模块""" + +from kobo_manga.config import TransferConfig +from kobo_manga.transfer.calibre import CalibreTransfer +from kobo_manga.transfer.usb import USBTransfer + +__all__ = ["USBTransfer", "CalibreTransfer", "get_transfer"] + + +def get_transfer(config: TransferConfig) -> USBTransfer | CalibreTransfer: + """根据配置返回对应的传输器。""" + if config.method == "calibre": + return CalibreTransfer() + return USBTransfer() diff --git a/src/kobo_manga/transfer/calibre.py b/src/kobo_manga/transfer/calibre.py new file mode 100644 index 0000000000000000000000000000000000000000..679be1c828834492fcefea85362ddfe034bae37c --- /dev/null +++ b/src/kobo_manga/transfer/calibre.py @@ -0,0 +1,56 @@ +"""Calibre 传输 + +通过 calibredb 命令行工具将 KEPUB 导入 Calibre 书库。 +""" + +import subprocess +from pathlib import Path + + +class CalibreTransfer: + """通过 calibredb CLI 将 KEPUB 导入 Calibre。""" + + def transfer(self, kepub_paths: list[Path]) -> list[Path]: + """将 .kepub.epub 文件导入 Calibre 书库。 + + Args: + kepub_paths: 要导入的文件列表 + + Returns: + 成功导入的文件列表 + + Raises: + RuntimeError: calibredb 不可用 + """ + self._check_calibredb() + + imported = [] + for path in kepub_paths: + result = subprocess.run( + ["calibredb", "add", str(path)], + capture_output=True, + text=True, + ) + if result.returncode == 0: + imported.append(path) + else: + print(f" [!] 导入失败: {path.name}") + if result.stderr: + print(f" {result.stderr.strip()}") + + return imported + + def _check_calibredb(self) -> None: + """检查 calibredb 是否可用。""" + try: + subprocess.run( + ["calibredb", "--version"], + capture_output=True, + timeout=10, + ) + except FileNotFoundError: + raise RuntimeError( + "未找到 calibredb 命令。" + "请安装 Calibre 并确认 calibredb 在系统 PATH 中。" + "下载地址: https://calibre-ebook.com/download" + ) diff --git a/src/kobo_manga/transfer/usb.py b/src/kobo_manga/transfer/usb.py new file mode 100644 index 0000000000000000000000000000000000000000..5cde8d83c63ee93ae739217dd240962b728f1a93 --- /dev/null +++ b/src/kobo_manga/transfer/usb.py @@ -0,0 +1,70 @@ +"""Kobo USB 传输 + +检测通过 USB 连接的 Kobo 设备(Windows 盘符扫描), +将 .kepub.epub 文件复制到设备上。 +""" + +import ctypes +import shutil +import string +from pathlib import Path + + +class USBTransfer: + """通过 USB 将 KEPUB 文件传输到 Kobo 设备。""" + + def detect_kobo(self) -> Path | None: + """扫描 Windows 可用盘符,查找含 .kobo 目录的 Kobo 设备。 + + Returns: + Kobo 设备根路径,未检测到返回 None + """ + bitmask = ctypes.windll.kernel32.GetLogicalDrives() + for i, letter in enumerate(string.ascii_uppercase): + if not (bitmask & (1 << i)): + continue + kobo_dir = Path(f"{letter}:/.kobo") + try: + if kobo_dir.is_dir(): + return Path(f"{letter}:/") + except OSError: + continue + return None + + def transfer( + self, + kepub_paths: list[Path], + manga_title: str = "", + ) -> list[Path]: + """将 .kepub.epub 文件复制到 Kobo 设备。 + + Args: + kepub_paths: 要传输的文件列表 + manga_title: 漫画标题,用于在设备上创建子目录 + + Returns: + 设备上的目标路径列表 + + Raises: + RuntimeError: 未检测到 Kobo 设备 + """ + kobo_root = self.detect_kobo() + if kobo_root is None: + raise RuntimeError( + "未检测到 Kobo 设备,请确认设备已通过 USB 连接并挂载" + ) + + # 目标目录 + if manga_title: + dest_dir = kobo_root / manga_title + else: + dest_dir = kobo_root + dest_dir.mkdir(parents=True, exist_ok=True) + + results = [] + for src in kepub_paths: + dest = dest_dir / src.name + shutil.copy2(src, dest) + results.append(dest) + + return results diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000000000000000000000000000000000000..8ea2a8afd7d68073a6009542ad82c75c592d1511 --- /dev/null +++ b/uv.lock @@ -0,0 +1,412 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "future" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/b2/4140c69c6a66432916b26158687e821ba631a4c9273c474343badf84d3ba/future-1.0.0.tar.gz", hash = "sha256:bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05", size = 1228490, upload-time = "2024-02-21T11:52:38.461Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326, upload-time = "2024-02-21T11:52:35.956Z" }, +] + +[[package]] +name = "greenlet" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, + { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, + { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, + { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, + { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081, upload-time = "2026-02-20T20:17:28.121Z" }, + { url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" }, + { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, + { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, + { url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" }, + { url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, + { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "kobo-manga" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "lzstring" }, + { name = "pillow" }, + { name = "pyyaml" }, + { name = "typer" }, +] + +[package.optional-dependencies] +browser = [ + { name = "playwright" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27" }, + { name = "lzstring", specifier = ">=1.0" }, + { name = "pillow", specifier = ">=10.0" }, + { name = "playwright", marker = "extra == 'browser'", specifier = ">=1.40" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "typer", specifier = ">=0.12" }, +] +provides-extras = ["browser"] + +[[package]] +name = "lzstring" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "future" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/0c/28347673b45e5f0975cdf1f6d69ede6ad049be873194c4e164d79aecd34c/lzstring-1.0.4.tar.gz", hash = "sha256:1afa61e598193fbcc211e0899f09a9679e33f9102bccc37fbfda0b7fef4d9ea2", size = 4256, upload-time = "2018-06-01T02:32:12.639Z" } + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "pillow" +version = "12.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, + { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, + { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, + { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, + { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, + { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, + { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, + { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, + { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, + { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, + { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, + { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, + { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, + { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, + { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, + { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, + { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, +] + +[[package]] +name = "playwright" +version = "1.58.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/c9/9c6061d5703267f1baae6a4647bfd1862e386fbfdb97d889f6f6ae9e3f64/playwright-1.58.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:96e3204aac292ee639edbfdef6298b4be2ea0a55a16b7068df91adac077cc606", size = 42251098, upload-time = "2026-01-30T15:09:24.028Z" }, + { url = "https://files.pythonhosted.org/packages/e0/40/59d34a756e02f8c670f0fee987d46f7ee53d05447d43cd114ca015cb168c/playwright-1.58.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:70c763694739d28df71ed578b9c8202bb83e8fe8fb9268c04dd13afe36301f71", size = 41039625, upload-time = "2026-01-30T15:09:27.558Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ee/3ce6209c9c74a650aac9028c621f357a34ea5cd4d950700f8e2c4b7fe2c4/playwright-1.58.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:185e0132578733d02802dfddfbbc35f42be23a45ff49ccae5081f25952238117", size = 42251098, upload-time = "2026-01-30T15:09:30.461Z" }, + { url = "https://files.pythonhosted.org/packages/f1/af/009958cbf23fac551a940d34e3206e6c7eed2b8c940d0c3afd1feb0b0589/playwright-1.58.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c95568ba1eda83812598c1dc9be60b4406dffd60b149bc1536180ad108723d6b", size = 46235268, upload-time = "2026-01-30T15:09:33.787Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a6/0e66ad04b6d3440dae73efb39540c5685c5fc95b17c8b29340b62abbd952/playwright-1.58.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f9999948f1ab541d98812de25e3a8c410776aa516d948807140aff797b4bffa", size = 45964214, upload-time = "2026-01-30T15:09:36.751Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/236e60ab9f6d62ed0fd32150d61f1f494cefbf02304c0061e78ed80c1c32/playwright-1.58.0-py3-none-win32.whl", hash = "sha256:1e03be090e75a0fabbdaeab65ce17c308c425d879fa48bb1d7986f96bfad0b99", size = 36815998, upload-time = "2026-01-30T15:09:39.627Z" }, + { url = "https://files.pythonhosted.org/packages/41/f8/5ec599c5e59d2f2f336a05b4f318e733077cd5044f24adb6f86900c3e6a7/playwright-1.58.0-py3-none-win_amd64.whl", hash = "sha256:a2bf639d0ce33b3ba38de777e08697b0d8f3dc07ab6802e4ac53fb65e3907af8", size = 36816005, upload-time = "2026-01-30T15:09:42.449Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c4/cc0229fea55c87d6c9c67fe44a21e2cd28d1d558a5478ed4d617e9fb0c93/playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b", size = 33085919, upload-time = "2026-01-30T15:09:45.71Z" }, +] + +[[package]] +name = "pyee" +version = "13.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "typer" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +]