A => .gitignore +46 -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
A => .python-version +1 -0
A => LICENSE +674 -0
@@ 1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ 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.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ 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 <https://www.gnu.org/licenses/>.
+
+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:
+
+ <program> Copyright (C) <year> <name of author>
+ 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
+<https://www.gnu.org/licenses/>.
+
+ 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
+<https://www.gnu.org/licenses/why-not-lgpl.html>.
A => README.org +140 -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 <repo-url>
+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]] 发布。
A => pyproject.toml +27 -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"]
A => src/kobo_manga/__init__.py +3 -0
@@ 1,3 @@
+"""Kobo Manga Pipeline - 漫画下载转换导入流水线"""
+
+__version__ = "0.1.0"
A => src/kobo_manga/cli/__init__.py +5 -0
@@ 1,5 @@
+"""CLI 入口"""
+
+from kobo_manga.cli.commands import app
+
+__all__ = ["app"]
A => src/kobo_manga/cli/commands.py +455 -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)
A => src/kobo_manga/config.py +86 -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")),
+ )
A => src/kobo_manga/converter/__init__.py +5 -0
@@ 1,5 @@
+"""KEPUB 转换模块"""
+
+from kobo_manga.converter.kepub import KepubBuilder
+
+__all__ = ["KepubBuilder"]
A => src/kobo_manga/converter/kepub.py +228 -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' <item id="{img_id}" href="Images/{img_name}" '
+ f'media-type="image/jpeg"{props}/>'
+ )
+
+ # XHTML 页面条目
+ for page_id, page_href in page_ids:
+ # href 相对于 OEBPS
+ rel_href = page_href.replace("OEBPS/", "")
+ lines.append(
+ f' <item id="{page_id}" href="{rel_href}" '
+ f'media-type="application/xhtml+xml"/>'
+ )
+
+ 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' <itemref idref="{page_id}"/>')
+ 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' <navPoint id="navPoint-{i}" playOrder="{i}">\n'
+ f" <navLabel><text>{label}</text></navLabel>\n"
+ f' <content src="{rel_href}"/>\n'
+ f" </navPoint>"
+ )
+ 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' <li><a href="{rel_href}">{label}</a></li>')
+ return "\n".join(lines)
+
+
+def _sanitize_filename(name: str) -> str:
+ """清理文件名中的非法字符。"""
+ return "".join(
+ c if c.isalnum() or c in " _-()()【】" else "_" for c in name
+ )
A => src/kobo_manga/converter/templates.py +153 -0
@@ 1,153 @@
+"""KEPUB 文件的 XML/XHTML 模板"""
+
+MIMETYPE = "application/epub+zip"
+
+CONTAINER_XML = """\
+<?xml version="1.0" encoding="UTF-8"?>
+<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
+ <rootfiles>
+ <rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
+ </rootfiles>
+</container>"""
+
+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 <dc:description>{desc_escaped}</dc:description>"
+ if series_name:
+ series_escaped = series_name.replace("&", "&").replace('"', """)
+ meta_extra += f'\n <meta name="calibre:series" content="{series_escaped}"/>'
+ if series_index is not None:
+ meta_extra += (
+ f'\n <meta name="calibre:series_index" content="{series_index}"/>'
+ )
+
+ author_tag = ""
+ if author:
+ author_escaped = author.replace("&", "&").replace("<", "<")
+ author_tag = f"\n <dc:creator>{author_escaped}</dc:creator>"
+
+ return f"""\
+<?xml version="1.0" encoding="UTF-8"?>
+<package xmlns="http://www.idpf.org/2007/opf" version="3.0"
+ unique-identifier="BookId"
+ prefix="rendition: http://www.idpf.org/vocab/rendition/#">
+ <metadata xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:opf="http://www.idpf.org/2007/opf">
+ <dc:identifier id="BookId">urn:uuid:{uuid}</dc:identifier>
+ <dc:title>{title}</dc:title>{author_tag}
+ <dc:language>{language}</dc:language>
+ <meta property="dcterms:modified">{modified}</meta>
+ <meta property="rendition:layout">pre-paginated</meta>
+ <meta property="rendition:orientation">portrait</meta>
+ <meta property="rendition:spread">none</meta>{meta_extra}
+ </metadata>
+ <manifest>
+ <item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
+ <item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
+ <item id="css" href="style.css" media-type="text/css"/>
+{manifest_items}
+ </manifest>
+ <spine toc="ncx" page-progression-direction="rtl">
+{spine_items}
+ </spine>
+</package>"""
+
+
+def toc_ncx(*, uuid: str, title: str, nav_points: str) -> str:
+ """生成 toc.ncx 导航文件。"""
+ title_escaped = title.replace("&", "&").replace("<", "<")
+ return f"""\
+<?xml version="1.0" encoding="UTF-8"?>
+<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
+ <head>
+ <meta name="dtb:uid" content="urn:uuid:{uuid}"/>
+ <meta name="dtb:depth" content="1"/>
+ <meta name="dtb:totalPageCount" content="0"/>
+ <meta name="dtb:maxPageNumber" content="0"/>
+ </head>
+ <docTitle><text>{title_escaped}</text></docTitle>
+ <navMap>
+{nav_points}
+ </navMap>
+</ncx>"""
+
+
+def nav_xhtml(*, title: str, nav_items: str) -> str:
+ """生成 EPUB3 nav.xhtml 导航文档。"""
+ title_escaped = title.replace("&", "&").replace("<", "<")
+ return f"""\
+<?xml version="1.0" encoding="UTF-8"?>
+<html xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:epub="http://www.idpf.org/2007/ops">
+<head>
+ <title>{title_escaped}</title>
+</head>
+<body>
+ <nav epub:type="toc">
+ <ol>
+{nav_items}
+ </ol>
+ </nav>
+</body>
+</html>"""
+
+
+def page_xhtml(
+ *, page_num: int, image_filename: str, viewport_w: int, viewport_h: int
+) -> str:
+ """生成单页 XHTML(固定布局,SVG 包裹图片)。"""
+ return f"""\
+<?xml version="1.0" encoding="UTF-8"?>
+<html xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:epub="http://www.idpf.org/2007/ops">
+<head>
+ <title>Page {page_num}</title>
+ <meta name="viewport" content="width={viewport_w}, height={viewport_h}"/>
+ <link rel="stylesheet" type="text/css" href="../style.css"/>
+</head>
+<body>
+ <div>
+ <svg xmlns="http://www.w3.org/2000/svg" version="1.1"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ width="100%" height="100%"
+ viewBox="0 0 {viewport_w} {viewport_h}">
+ <image width="{viewport_w}" height="{viewport_h}"
+ xlink:href="../Images/{image_filename}"/>
+ </svg>
+ </div>
+ <span id="kobo.{page_num}.1" class="koboSpan"></span>
+</body>
+</html>"""
A => src/kobo_manga/db/__init__.py +5 -0
@@ 1,5 @@
+"""SQLite 状态存储模块"""
+
+from kobo_manga.db.database import Database
+
+__all__ = ["Database"]
A => src/kobo_manga/db/database.py +109 -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()
A => src/kobo_manga/db/queries.py +365 -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()
A => src/kobo_manga/downloader/__init__.py +6 -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"]
A => src/kobo_manga/downloader/basic.py +120 -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
A => src/kobo_manga/downloader/engine.py +271 -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
+ )
A => src/kobo_manga/models.py +60 -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)
A => src/kobo_manga/pipeline.py +265 -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
+ )
A => src/kobo_manga/processor/__init__.py +5 -0
@@ 1,5 @@
+"""图片处理模块"""
+
+from kobo_manga.processor.pipeline import ImageProcessor
+
+__all__ = ["ImageProcessor"]
A => src/kobo_manga/processor/pipeline.py +150 -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)
A => src/kobo_manga/scheduler/__init__.py +1 -0
A => src/kobo_manga/scheduler/daemon.py +98 -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}")
A => src/kobo_manga/sources/__init__.py +71 -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}")
A => src/kobo_manga/sources/base.py +36 -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()
A => src/kobo_manga/transfer/__init__.py +14 -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()
A => src/kobo_manga/transfer/calibre.py +56 -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"
+ )
A => src/kobo_manga/transfer/usb.py +70 -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
A => uv.lock +412 -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" },
+]