Initial commit

This commit is contained in:
洛水居室 2022-07-26 18:07:31 +08:00
commit 27eea58352
No known key found for this signature in database
GPG Key ID: C9DE87DA724B88FC
292 changed files with 9786 additions and 0 deletions

8
.deepsource.toml Normal file
View File

@ -0,0 +1,8 @@
version = 1
[[analyzers]]
name = "python"
enabled = true
[analyzers.meta]
runtime_version = "3.x.x"

34
.gitignore vendored Normal file
View File

@ -0,0 +1,34 @@
### Code Editor ###
# VSCode
.vscode/
*.code-workspace
.history
.ionide
# Pycharm
*.iws
.idea/
out/
### Python ###
# Environments
env/
venv/
cache/
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
*.pyo
*.pyc
### Customize ###
config/config.json
**_test.html
test_**.html
logs/
/resources/*/*/test/

661
LICENSE Normal file
View File

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 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 Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are 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.
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.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
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 Affero 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. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
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 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 work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero 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 Affero 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 Affero 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 Affero 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 Affero 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 Affero General Public License for more details.
You should have received a copy of the GNU Affero 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 your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
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 AGPL, see
<https://www.gnu.org/licenses/>.

55
README.md Normal file
View File

@ -0,0 +1,55 @@
<h1 align="center">TGPaimonBot</h1>
<div align="center">
<img src="https://img.shields.io/badge/python-3.8%2B-blue">
<img src="https://img.shields.io/badge/works%20on-my%20machine-brightgreen">
<img src="https://img.shields.io/badge/status-%E5%92%95%E5%92%95%E5%92%95-blue">
<a href="https://www.codacy.com/gh/luoshuijs/TGPaimonBot/dashboard?utm_source=github.com&amp;utm_medium=referral&amp;utm_content=luoshuijs/TGPaimonBot&amp;utm_campaign=Badge_Grade"><img src="https://app.codacy.com/project/badge/Grade/810a80be4cbe4b7284ab7634941423c4"/></a>
</div>
## 简介
基于 [python-telegram-bot](https://github.com/python-telegram-bot/python-telegram-bot) 的 Paimon BOT
项目仅供学习交流使用,严禁用于任何商业用途和非法行为
## 需求
### 环境需求
- Python 3.8+
- MySQL
- Redis
#### 环境需求需要的注意事项
因为上游 `genshin.py` 的原因 本项目 python 最低版本为 3.8
### 模块需求
#### 模块需求需要的注意事项
`python-telegram-bot` 需要预览版本 即 `20.0a2`
出现相关的 `telegram` 模块导入的 `ImportError` 错误需要你手动执行 `pip install python-telegram-bot==20.0a2`
请注意你的 python 是否安装 `aiohttp` `genshin.py` 的依赖)
如果 `aiohttp` 版本大于 `4.0.0a1`
会导致 `redis``aiohttp` 的依赖 `async-timeout` 版本冲突进而运行代码导致 `TypeError` 异常
解决上面版本冲突导致的错误需要你手动执行 `pip install aiohttp==3.8.1`
如果出现模块导入错误请打开 issue 联系开发者 这可能由于上游为预览版本 部分类名称改变导致的问题
## 其他说明
这个项目目前正在扩展,加入更多原神相关娱乐和信息查询功能,敬请期待。
## Thanks
| Nickname | Introduce |
| :----------------------------------------------------------: | -------------------------------- |
| [原神抽卡全机制总结](https://www.bilibili.com/read/cv10468091) | 本项目抽卡模拟器使用的逻辑 |
| [西风驿站](https://bbs.mihoyo.com/ys/collection/307224) | 本项目攻略图图源 |
| [Yunzai-Bot](https://github.com/Le-niao/Yunzai-Bot) | 本项使用的抽卡图片和前端资源来源 |

14
app/admin/__init__.py Normal file
View File

@ -0,0 +1,14 @@
from app.admin.cache import BotAdminCache
from app.admin.repositories import BotAdminRepository
from app.admin.service import BotAdminService
from utils.app.manager import listener_service
from utils.mysql import MySQL
from utils.redisdb import RedisDB
@listener_service()
def create_bot_admin_service(mysql: MySQL, redis: RedisDB):
_cache = BotAdminCache(redis)
_repository = BotAdminRepository(mysql)
_service = BotAdminService(_repository, _cache)
return _service

38
app/admin/cache.py Normal file
View File

@ -0,0 +1,38 @@
from typing import List
from utils.redisdb import RedisDB
class BotAdminCache:
def __init__(self, redis: RedisDB):
self.client = redis.client
self.qname = "bot:admin"
async def get_list(self):
return [int(str_data) for str_data in await self.client.lrange(self.qname, 0, -1)]
async def set_list(self, str_list: List[int], ttl: int = -1):
await self.client.ltrim(self.qname, 1, 0)
await self.client.lpush(self.qname, *str_list)
if ttl != -1:
await self.client.expire(self.qname, ttl)
count = await self.client.llen(self.qname)
return count
class GroupAdminCache:
def __init__(self, redis: RedisDB):
self.client = redis.client
self.qname = "group:admin_list"
async def get_chat_admin(self, chat_id: int):
qname = f"{self.qname}:{chat_id}"
return [int(str_id) for str_id in await self.client.lrange(qname, 0, -1)]
async def set_chat_admin(self, chat_id: int, admin_list: List[int]):
qname = f"{self.qname}:{chat_id}"
await self.client.ltrim(qname, 1, 0)
await self.client.lpush(qname, *admin_list)
await self.client.expire(qname, 60)
count = await self.client.llen(qname)
return count

37
app/admin/repositories.py Normal file
View File

@ -0,0 +1,37 @@
from typing import List
from utils.mysql import MySQL
class BotAdminRepository:
def __init__(self, mysql: MySQL):
self.mysql = mysql
async def delete_by_user_id(self, user_id: int):
query = """
DELETE FROM `admin`
WHERE user_id=%s;
"""
query_args = (user_id,)
await self.mysql.execute_and_fetchall(query, query_args)
async def add_by_user_id(self, user_id: int):
query = """
INSERT INTO `admin`
(user_id)
VALUES
(%s)
"""
query_args = (user_id,)
await self.mysql.execute_and_fetchall(query, query_args)
async def get_by_user_id(self) -> List[int]:
query = """
SELECT user_id
FROM `admin`
"""
query_args = ()
data = await self.mysql.execute_and_fetchall(query, query_args)
if len(data) == 0:
return []
return [i[0] for i in data]

60
app/admin/service.py Normal file
View File

@ -0,0 +1,60 @@
from typing import List
from pymysql import IntegrityError
from telegram import Bot
from app.admin.cache import BotAdminCache, GroupAdminCache
from app.admin.repositories import BotAdminRepository
from config import config
from logger import Log
class BotAdminService:
def __init__(self, repository: BotAdminRepository, cache: BotAdminCache):
self._repository = repository
self._cache = cache
async def get_admin_list(self) -> List[int]:
admin_list = await self._cache.get_list()
if len(admin_list) == 0:
admin_list = await self._repository.get_by_user_id()
for config_admin in config.ADMINISTRATORS:
admin_list.append(config_admin["user_id"])
await self._cache.set_list(admin_list)
return admin_list
async def add_admin(self, user_id: int) -> bool:
try:
await self._repository.add_by_user_id(user_id)
except IntegrityError as error:
Log.warning(f"{user_id} 已经存在数据库 \n", error)
admin_list = await self._repository.get_by_user_id()
for config_admin in config.ADMINISTRATORS:
admin_list.append(config_admin["user_id"])
await self._cache.set_list(admin_list)
return True
async def delete_admin(self, user_id: int) -> bool:
try:
await self._repository.delete_by_user_id(user_id)
except ValueError:
return False
admin_list = await self._repository.get_by_user_id()
for config_admin in config.ADMINISTRATORS:
admin_list.append(config_admin["user_id"])
await self._cache.set_list(admin_list)
return True
class GroupAdminService:
def __init__(self, cache: GroupAdminCache):
self._cache = cache
async def get_admins(self, bot: Bot, chat_id: int, extra_user: List[int]) -> List[int]:
admin_id_list = await self._cache.get_chat_admin(chat_id)
if len(admin_id_list) == 0:
admin_list = await bot.get_chat_administrators(chat_id)
admin_id_list = [admin.user.id for admin in admin_list]
await self._cache.set_chat_admin(chat_id, admin_id_list)
admin_id_list += extra_user
return admin_id_list

0
app/cookies/__init__.py Normal file
View File

View File

@ -0,0 +1,79 @@
import ujson
from model.base import ServiceEnum
from utils.error import NotFoundError
from utils.mysql import MySQL
class CookiesRepository:
def __init__(self, mysql: MySQL):
self.mysql = mysql
async def update_cookie(self, user_id: int, cookies: str, default_service: ServiceEnum):
if default_service == ServiceEnum.HYPERION:
query = """
UPDATE `mihoyo_cookie`
SET cookie=%s
WHERE user_id=%s;
"""
elif default_service == ServiceEnum.HOYOLAB:
query = """
UPDATE `hoyoverse_cookie`
SET cookie=%s
WHERE user_id=%s;
"""
else:
raise DefaultServiceNotFoundError(default_service.name)
query_args = (cookies, user_id)
await self.mysql.execute_and_fetchall(query, query_args)
async def set_cookie(self, user_id: int, cookies: str, default_service: ServiceEnum):
if default_service == ServiceEnum.HYPERION:
query = """
INSERT INTO `mihoyo_cookie`
(user_id,cookie)
VALUES
(%s,%s)
ON DUPLICATE KEY UPDATE
cookie=VALUES(cookie);
"""
elif default_service == ServiceEnum.HOYOLAB:
query = """
INSERT INTO `hoyoverse_cookie`
(user_id,cookie)
VALUES
(%s,%s)
ON DUPLICATE KEY UPDATE
cookie=VALUES(cookie);
"""
else:
raise DefaultServiceNotFoundError(default_service.name)
query_args = (user_id, cookies)
await self.mysql.execute_and_fetchall(query, query_args)
async def read_cookies(self, user_id, default_service: ServiceEnum) -> dict:
if default_service == ServiceEnum.HYPERION:
query = """
SELECT cookie
FROM `mihoyo_cookie`
WHERE user_id=%s;
"""
elif default_service == ServiceEnum.HOYOLAB:
query = """
SELECT cookie
FROM `hoyoverse_cookie`
WHERE user_id=%s;;
"""
else:
raise DefaultServiceNotFoundError(default_service.name)
query_args = (user_id,)
data = await self.mysql.execute_and_fetchall(query, query_args)
if len(data) == 0:
return {}
(cookies,) = data
return ujson.loads(cookies)
class DefaultServiceNotFoundError(NotFoundError):
entity_name: str = "ServiceEnum"
entity_value_name: str = "default_service"

16
app/cookies/service.py Normal file
View File

@ -0,0 +1,16 @@
from app.cookies.repositories import CookiesRepository
from model.base import ServiceEnum
class CookiesService:
def __init__(self, user_repository: CookiesRepository) -> None:
self._repository: CookiesRepository = user_repository
async def update_cookie(self, user_id: int, cookies: str, default_service: ServiceEnum):
await self._repository.update_cookie(user_id, cookies, default_service)
async def set_cookie(self, user_id: int, cookies: str, default_service: ServiceEnum):
await self._repository.set_cookie(user_id, cookies, default_service)
async def read_cookies(self, user_id: int, default_service: ServiceEnum):
return await self._repository.read_cookies(user_id, default_service)

0
app/game/__init__.py Normal file
View File

22
app/game/cache.py Normal file
View File

@ -0,0 +1,22 @@
from typing import List
from utils.redisdb import RedisDB
class GameStrategyCache:
def __init__(self, redis: RedisDB, ttl: int = 3600):
self.client = redis.client
self.qname = "game:strategy"
self.ttl = ttl
async def get_url_list(self, character_name: str):
qname = f"{self.qname}:{character_name}"
return [str(str_data, encoding="utf-8") for str_data in await self.client.lrange(qname, 0, -1)]
async def set_url_list(self, character_name: str, str_list: List[str]):
qname = f"{self.qname}:{character_name}"
await self.client.ltrim(qname, 1, 0)
await self.client.lpush(qname, *str_list)
await self.client.expire(qname, self.ttl)
count = await self.client.llen(qname)
return count

46
app/game/service.py Normal file
View File

@ -0,0 +1,46 @@
from typing import List, Optional
from app.game.cache import GameStrategyCache
from model.apihelper.hyperion import Hyperion
class GameStrategyService:
def __init__(self, cache: GameStrategyCache, collections: Optional[List[int]] = None):
self._cache = cache
self._hyperion = Hyperion()
if collections is None:
self._collections = [839176, 839179, 839181]
else:
self._collections = collections
async def _get_strategy_from_hyperion(self, collection_id: int, character_name: str) -> int:
post_id: int = -1
post_full_in_collection = await self._hyperion.get_post_full_in_collection(collection_id)
if post_full_in_collection.error:
return post_id
for post_data in post_full_in_collection.data["posts"]:
topics = post_data["topics"]
for topic in topics:
if character_name == topic["name"]:
post_id = int(post_data["post"]["post_id"])
break
if post_id != -1:
break
return post_id
async def get_strategy(self, character_name: str) -> str:
cache = await self._cache.get_url_list(character_name)
if len(cache) >= 1:
return cache[-1]
for collection_id in self._collections:
post_id = await self._get_strategy_from_hyperion(collection_id, character_name)
if post_id != -1:
break
else:
return ""
artwork_info = await self._hyperion.get_artwork_info(2, post_id)
await self._cache.set_url_list(character_name, artwork_info.results.image_url_list)
return artwork_info.results.image_url_list[0]

0
app/quiz/__init__.py Normal file
View File

18
app/quiz/base.py Normal file
View File

@ -0,0 +1,18 @@
from typing import List
from app.quiz.models import Question, Answer
def CreatQuestionFromSQLData(data: tuple) -> List[Question]:
temp_list = []
for temp_data in data:
(question_id, text) = temp_data
temp_list.append(Question(question_id, text))
return temp_list
def CreatAnswerFromSQLData(data: tuple) -> List[Answer]:
temp_list = []
for temp_data in data:
(answer_id, question_id, is_correct, text) = temp_data
temp_list.append(Answer(answer_id, question_id, is_correct, text))
return temp_list

63
app/quiz/cache.py Normal file
View File

@ -0,0 +1,63 @@
from typing import List
import ujson
from app.quiz.models import Question, Answer
from utils.redisdb import RedisDB
class QuizCache:
def __init__(self, redis: RedisDB):
self.client = redis.client
self.question_qname = "quiz:question"
self.answer_qname = "quiz:answer"
async def get_all_question(self) -> List[Question]:
temp_list = []
qname = self.question_qname + "id_list"
data_list = [self.question_qname + f":{question_id}" for question_id in
await self.client.lrange(qname, 0, -1)]
data = await self.client.mget(data_list)
for i in data:
temp_list.append(Question.de_json(ujson.loads(i)))
return temp_list
async def get_all_question_id_list(self) -> List[str]:
qname = self.question_qname + ":id_list"
return await self.client.lrange(qname, 0, -1)
async def get_one_question(self, question_id: int) -> Question:
qname = f"{self.question_qname}:{question_id}"
data = await self.client.get(qname)
return Question.de_json(ujson.loads(data))
async def get_one_answer(self, answer_id: int) -> str:
qname = f"{self.answer_qname}:{answer_id}"
return await self.client.get(qname)
async def add_question(self, question_list: List[Question] = None):
for question in question_list:
await self.client.set(f"{self.question_qname}:{question.question_id}", str(question))
question_id_list = [question.question_id for question in question_list]
await self.client.lpush(f"{self.question_qname}:id_list", *question_id_list)
return await self.client.llen(f"{self.question_qname}:id_list")
async def del_all_question(self):
keys = await self.client.keys(f"{self.question_qname}*")
if keys is not None:
for key in keys:
await self.client.delete(key)
async def del_all_answer(self):
keys = await self.client.keys(f"{self.answer_qname}*")
if keys is not None:
for key in keys:
await self.client.delete(key)
async def add_answer(self, answer_list: List[Answer] = None):
for answer in answer_list:
await self.client.set(f"{self.answer_qname}:{answer.answer_id}", str(answer))
answer_id_list = [answer.answer_id for answer in answer_list]
await self.client.lpush(f"{self.answer_qname}:id_list", *answer_id_list)
return await self.client.llen(f"{self.answer_qname}:id_list")

50
app/quiz/models.py Normal file
View File

@ -0,0 +1,50 @@
from typing import List, Optional
from model.baseobject import BaseObject
from model.types import JSONDict
class Answer(BaseObject):
def __init__(self, answer_id: int = 0, question_id: int = 0, is_correct: bool = True, text: str = ""):
"""Answer类
:param answer_id: 答案ID
:param question_id: 与之对应的问题ID
:param is_correct: 该答案是否正确
:param text: 答案文本
"""
self.answer_id = answer_id
self.question_id = question_id
self.text = text
self.is_correct = is_correct
__slots__ = ("answer_id", "question_id", "text", "is_correct")
class Question(BaseObject):
def __init__(self, question_id: int = 0, text: str = "", answers: List[Answer] = None):
"""Question类
:param question_id: 问题ID
:param text: 问题文本
:param answers: 答案列表
"""
self.question_id = question_id
self.text = text
self.answers = [] if answers is None else answers
def to_dict(self) -> JSONDict:
data = super().to_dict()
if self.answers:
data["sub_item"] = [e.to_dict() for e in self.answers]
return data
@classmethod
def de_json(cls, data: Optional[JSONDict]) -> Optional["Question"]:
data = cls._parse_data(data)
if not data:
return None
data["sub_item"] = Answer.de_list(data.get("sub_item"))
return cls(**data)
__slots__ = ("question_id", "text", "answers")

83
app/quiz/repositories.py Normal file
View File

@ -0,0 +1,83 @@
from typing import List
from app.quiz.base import CreatQuestionFromSQLData, CreatAnswerFromSQLData
from app.quiz.models import Question, Answer
from utils.mysql import MySQL
class QuizRepository:
def __init__(self, mysql: MySQL):
self.mysql = mysql
async def get_question_list(self) -> List[Question]:
query = """
SELECT id,question
FROM `question`
"""
query_args = ()
data = await self.mysql.execute_and_fetchall(query, query_args)
return CreatQuestionFromSQLData(data)
async def get_answer_form_question_id(self, question_id: int) -> List[Answer]:
query = """
SELECT id,question_id,is_correct,answer
FROM `answer`
WHERE question_id=%s;
"""
query_args = (question_id,)
data = await self.mysql.execute_and_fetchall(query, query_args)
return CreatAnswerFromSQLData(data)
async def add_question(self, question: str):
query = """
INSERT INTO `question`
(question)
VALUES
(%s)
"""
query_args = (question,)
await self.mysql.execute_and_fetchall(query, query_args)
async def get_question(self, question: str) -> Question:
query = """
SELECT id,question
FROM `question`
WHERE question=%s;
"""
query_args = (question,)
data = await self.mysql.execute_and_fetchall(query, query_args)
return CreatQuestionFromSQLData(data)[0]
async def add_answer(self, question_id: int, is_correct: int, answer: str):
query = """
INSERT INTO `answer`
(question_id,is_correct,answer)
VALUES
(%s,%s,%s)
"""
query_args = (question_id, is_correct, answer)
await self.mysql.execute_and_fetchall(query, query_args)
async def delete_question(self, question_id: int):
query = """
DELETE FROM `question`
WHERE id=%s;
"""
query_args = (question_id,)
await self.mysql.execute_and_fetchall(query, query_args)
async def delete_answer(self, answer_id: int):
query = """
DELETE FROM `answer`
WHERE id=%s;
"""
query_args = (answer_id,)
await self.mysql.execute_and_fetchall(query, query_args)
async def delete_admin(self, user_id: int):
query = """
DELETE FROM `admin`
WHERE user_id=%s;
"""
query_args = (user_id,)
await self.mysql.execute_and_fetchall(query, query_args)

49
app/quiz/service.py Normal file
View File

@ -0,0 +1,49 @@
from typing import List
import ujson
from app.quiz.cache import QuizCache
from app.quiz.models import Question
from app.quiz.repositories import QuizRepository
class QuizService:
def __init__(self, repository: QuizRepository, cache: QuizCache):
self._repository = repository
self._cache = cache
async def get_quiz(self) -> List[Question]:
"""从数据库获取问题列表
:return:
"""
question_list = await self._repository.get_question_list()
for question in question_list:
question_id = question.question_id
answers = await self._repository.get_answer_form_question_id(question_id)
question.answers = answers
return question_list
async def save_quiz(self, data: Question):
await self._repository.get_question(data.text)
question = await self._repository.get_question(data.text)
for answers in data.answers:
await self._repository.add_answer(question.question_id, answers.is_correct, answers.text)
async def refresh_quiz(self) -> int:
question_list = await self.get_quiz()
await self._cache.del_all_question()
question_count = await self._cache.add_question(question_list)
await self._cache.del_all_answer()
for question in question_list:
await self._cache.add_answer(question.answers)
return question_count
async def get_question_id_list(self) -> List[int]:
return [int(question_id) for question_id in await self._cache.get_all_question_id_list()]
async def get_answer(self, answer_id: int):
data = await self._cache.get_one_answer(answer_id)
return ujson.loads(data)
async def get_question(self, question_id: int) -> Question:
return await self._cache.get_one_question(question_id)

9
app/template/__init__.py Normal file
View File

@ -0,0 +1,9 @@
from app.template.service import TemplateService
from utils.aiobrowser import AioBrowser
from utils.app.manager import listener_service
@listener_service()
def create_template_service(browser: AioBrowser):
_service = TemplateService(browser)
return _service

70
app/template/service.py Normal file
View File

@ -0,0 +1,70 @@
import os
import time
from typing import Optional
from jinja2 import PackageLoader, Environment, Template
from playwright.async_api import ViewportSize
from config import config
from logger import Log
from utils.aiobrowser import AioBrowser
class TemplateService:
def __init__(self, browser: AioBrowser, template_package_name: str = "resources", cache_dir_name: str = "cache"):
self._browser = browser
self._template_package_name = template_package_name
self._current_dir = os.getcwd()
self._output_dir = os.path.join(self._current_dir, cache_dir_name)
if not os.path.exists(self._output_dir):
os.mkdir(self._output_dir)
self._jinja2_env = {}
self._jinja2_template = {}
def get_template(self, package_path: str, template_name: str, auto_escape: bool = True) -> Template:
if config.DEBUG:
# DEBUG下 禁止复用 方便查看和修改模板
loader = PackageLoader(self._template_package_name, package_path)
jinja2_env = Environment(loader=loader, enable_async=True, autoescape=auto_escape)
jinja2_template = jinja2_env.get_template(template_name)
else:
jinja2_env: Environment = self._jinja2_env.get(package_path)
jinja2_template: Template = self._jinja2_template.get(package_path + template_name)
if jinja2_env is None:
loader = PackageLoader(self._template_package_name, package_path)
jinja2_env = Environment(loader=loader, enable_async=True, autoescape=auto_escape)
jinja2_template = jinja2_env.get_template(template_name)
self._jinja2_env[package_path] = jinja2_env
self._jinja2_template[package_path + template_name] = jinja2_template
return jinja2_template
async def render(self, template_path: str, template_name: str, template_data: dict,
viewport: ViewportSize, full_page: bool = True, auto_escape: bool = True,
evaluate: Optional[str] = None) -> bytes:
"""
模板渲染成图片
:param template_path: 模板目录
:param template_name: 模板文件名
:param template_data: 模板数据
:param viewport: 截图大小
:param full_page: 是否长截图
:param auto_escape: 是否自动转义
:param evaluate: 页面加载后运行的 js
:return:
"""
start_time = time.time()
template = self.get_template(template_path, template_name, auto_escape)
template_data["res_path"] = f"file://{self._current_dir}"
html = await template.render_async(**template_data)
Log.debug(f"{template_name} 模板渲染使用了 {str(time.time() - start_time)}")
browser = await self._browser.get_browser()
start_time = time.time()
page = await browser.new_page(viewport=viewport)
await page.goto(f"file://{template.filename}")
await page.set_content(html, wait_until="networkidle")
if evaluate:
await page.evaluate(evaluate)
png_data = await page.screenshot(full_page=full_page)
await page.close()
Log.debug(f"{template_name} 图片渲染使用了 {str(time.time() - start_time)}")
return png_data

11
app/user/__init__.py Normal file
View File

@ -0,0 +1,11 @@
from app.user.repositories import UserRepository
from app.user.services import UserService
from utils.app.manager import listener_service
from utils.mysql import MySQL
@listener_service()
def create_user_service(mysql: MySQL):
_repository = UserRepository(mysql)
_service = UserService(_repository)
return _service

11
app/user/models.py Normal file
View File

@ -0,0 +1,11 @@
from model.base import ServiceEnum
from model.baseobject import BaseObject
class User(BaseObject):
def __init__(self, user_id: int = 0, yuanshen_game_uid: int = 0, genshin_game_uid: int = 0,
default_service: ServiceEnum = ServiceEnum.NULL):
self.user_id = user_id
self.yuanshen_game_uid = yuanshen_game_uid
self.genshin_game_uid = genshin_game_uid
self.default_service = default_service

26
app/user/repositories.py Normal file
View File

@ -0,0 +1,26 @@
from app.user.models import User
from model.base import ServiceEnum
from utils.error import NotFoundError
from utils.mysql import MySQL
class UserRepository:
def __init__(self, mysql: MySQL):
self.mysql = mysql
async def get_by_user_id(self, user_id: int) -> User:
query = """
SELECT user_id,mihoyo_game_uid,hoyoverse_game_uid,service
FROM `user`
WHERE user_id=%s;"""
query_args = (user_id,)
data = await self.mysql.execute_and_fetchall(query, query_args)
if len(data) == 0:
raise UserNotFoundError(user_id)
(user_id, yuanshen_game_uid, genshin_game_uid, default_service) = data
return User(user_id, yuanshen_game_uid, genshin_game_uid, ServiceEnum(default_service))
class UserNotFoundError(NotFoundError):
entity_name: str = "User"
entity_value_name: str = "id"

15
app/user/services.py Normal file
View File

@ -0,0 +1,15 @@
from app.user.models import User
from app.user.repositories import UserRepository
class UserService:
def __init__(self, user_repository: UserRepository) -> None:
self._repository: UserRepository = user_repository
async def get_user_by_id(self, user_id: int) -> User:
"""从数据库获取用户信息
:param user_id:用户ID
:return:
"""
return await self._repository.get_by_user_id(user_id)

0
app/wiki/__init__.py Normal file
View File

21
app/wiki/cache.py Normal file
View File

@ -0,0 +1,21 @@
import ujson
from utils.redisdb import RedisDB
class WikiCache:
def __init__(self, redis: RedisDB):
self.client = redis.client
self.qname = "wiki"
async def refresh_info_cache(self, key_name: str, info):
qname = f"{self.qname}:{key_name}"
await self.client.set(qname, ujson.dumps(info))
async def del_one(self, key_name: str):
qname = f"{self.qname}:{key_name}"
await self.client.delete(qname)
async def get_one(self, key_name: str) -> str:
qname = f"{self.qname}:{key_name}"
return await self.client.get(qname)

117
app/wiki/service.py Normal file
View File

@ -0,0 +1,117 @@
import asyncio
import ujson
from app.wiki.cache import WikiCache
from logger import Log
from model.wiki.characters import Characters
from model.wiki.weapons import Weapons
class WikiService:
def __init__(self, cache: WikiCache):
self._cache = cache
"""
Redis 在这里的作用是作为持久化
"""
self.weapons = Weapons()
self.characters = Characters()
self._characters_list = []
self._characters_name_list = []
self._weapons_name_list = []
self._weapons_list = []
self.first_run = True
async def refresh_weapon(self):
weapon_url_list = await self.weapons.get_all_weapon_url()
Log.info(f"一共找到 {len(weapon_url_list)} 把武器信息")
weapons_list = []
task_list = []
for index, weapon_url in enumerate(weapon_url_list):
task_list.append(self.weapons.get_weapon_info(weapon_url))
# weapon_info = await self.weapons.get_weapon_info(weapon_url)
if index % 5 == 0:
result_list = await asyncio.gather(*task_list)
weapons_list.extend(result for result in result_list if isinstance(result, dict))
task_list.clear()
if index % 10 == 0 and index != 0:
Log.info(f"现在已经获取到 {index} 把武器信息")
result_list = await asyncio.gather(*task_list)
weapons_list.extend(result for result in result_list if isinstance(result, dict))
Log.info("写入武器信息到Redis")
self._weapons_list = weapons_list
await self._cache.del_one("weapon")
await self._cache.refresh_info_cache("weapon", weapons_list)
async def refresh_characters(self):
characters_url_list = await self.characters.get_all_characters_url()
Log.info(f"一共找到 {len(characters_url_list)} 个角色信息")
characters_list = []
task_list = []
for index, characters_url in enumerate(characters_url_list):
task_list.append(self.characters.get_characters(characters_url))
if index % 5 == 0:
result_list = await asyncio.gather(*task_list)
characters_list.extend(result for result in result_list if isinstance(result, dict))
task_list.clear()
if index % 10 == 0 and index != 0:
Log.info(f"现在已经获取到 {index} 个角色信息")
result_list = await asyncio.gather(*task_list)
characters_list.extend(result for result in result_list if isinstance(result, dict))
Log.info("写入角色信息到Redis")
self._characters_list = characters_list
await self._cache.del_one("characters")
await self._cache.refresh_info_cache("characters", characters_list)
async def refresh_wiki(self):
"""
用于把Redis的缓存全部加载进Python
:return:
"""
Log.info("正在重新获取Wiki")
Log.info("正在重新获取武器信息")
await self.refresh_weapon()
Log.info("正在重新获取角色信息")
await self.refresh_characters()
Log.info("刷新成功")
async def init(self):
"""
用于把Redis的缓存全部加载进Python
:return:
"""
if self.first_run:
weapon_dict = await self._cache.get_one("weapon")
self._weapons_list = ujson.loads(weapon_dict)
for weapon in self._weapons_list:
self._weapons_name_list.append(weapon["name"])
characters_dict = await self._cache.get_one("characters")
self._characters_list = ujson.loads(characters_dict)
for characters in self._characters_list:
self._characters_name_list.append(characters["name"])
self.first_run = False
async def get_weapons(self, name: str):
await self.init()
if len(self._weapons_list) == 0:
return {}
return next((weapon for weapon in self._weapons_list if weapon["name"] == name), {})
async def get_weapons_name_list(self) -> list:
await self.init()
return self._weapons_name_list
async def get_weapons_list(self) -> list:
await self.init()
return self._weapons_list
async def get_characters_list(self) -> list:
await self.init()
return self._characters_list
async def get_characters_name_list(self) -> list:
await self.init()
return self._characters_name_list

29
config.py Normal file
View File

@ -0,0 +1,29 @@
import os
import ujson
class Config:
def __init__(self):
project_path = os.path.dirname(__file__)
config_file = os.path.join(project_path, './config', 'config.json')
if not os.path.exists(config_file):
config_file = os.path.join(project_path, './config', 'config.example.json')
with open(config_file, 'r', encoding='utf-8') as f:
self._config_json: dict = ujson.load(f)
self.DEBUG = self.get_config("debug")
if not isinstance(self.DEBUG, bool):
self.DEBUG = False
self.ADMINISTRATORS = self.get_config("administrators")
self.MYSQL = self.get_config("mysql")
self.REDIS = self.get_config("redis")
self.TELEGRAM = self.get_config("telegram")
self.FUNCTION = self.get_config("function")
def get_config(self, name: str):
return self._config_json.get(name, {})
config = Config()

View File

@ -0,0 +1,38 @@
{
"mysql": {
"host": "127.0.0.1",
"port": 3306,
"user": "",
"password": "",
"database": ""
},
"redis": {
"host": "127.0.0.1",
"port": 6379,
"database": 0
},
"telegram": {
"token": "",
"notice": {
"ERROR": {
"name": "",
"chat_id":
}
},
"channel": {
"POST": [
{
"name": "",
"chat_id":
}
]
}
},
"administrators":
[
{
"username": "",
"user_id":
}
]
}

38
jobs/README.md Normal file
View File

@ -0,0 +1,38 @@
# jobs 目录
## 说明
改目录存放 BOT 的工作队列、注册和具体实现
## 基础代码
``` python
import datetime
from telegram.ext import CallbackContext
from jobs.base import RunDailyHandler
from logger import Log
from utils.job.manager import listener_jobs_class
@listener_jobs_class()
class JobTest:
@classmethod
def build_jobs(cls) -> list:
test = cls()
# 注册每日执行任务
# 执行时间为21点45分
return [
RunDailyHandler(test.test, datetime.time(21, 45, 00), name="测试Job")
]
async def test(self, context: CallbackContext):
Log.info("测试Job[OK]")
```
### 注意
jobs 模块下的类必须提供 `build_jobs` 类方法作为构建相应处理程序给 `handle.py`
只需在构建的类前加上 `@listener_jobs_class()` 修饰器即可

202
jobs/base.py Normal file
View File

@ -0,0 +1,202 @@
import datetime
from typing import Union, Tuple
from telegram.ext import CallbackContext
from model.types import JSONDict, Func
class BaseJobHandler:
pass
class RunDailyHandler:
def __init__(self, callback: Func, time: datetime.time, days: Tuple[int, ...] = tuple(range(7)),
data: object = None, name: str = None, chat_id: int = None, user_id: int = None,
job_kwargs: JSONDict = None, ):
"""Creates a new :class:`Job` that runs on a daily basis and adds it to the queue.
Note:
For a note about DST, please see the documentation of `APScheduler`_.
.. _`APScheduler`: https://apscheduler.readthedocs.io/en/stable/modules/triggers/cron.html
#daylight-saving-time-behavior
Args:
callback (:term:`coroutine function`): The callback function that should be executed by
the new job. Callback signature::
async def callback(context: CallbackContext)
time (:obj:`datetime.time`): Time of day at which the job should run. If the timezone
(:obj:`datetime.time.tzinfo`) is :obj:`None`, the default timezone of the bot will
be used, which is UTC unless :attr:`telegram.ext.Defaults.tzinfo` is used.
days (Tuple[:obj:`int`], optional): Defines on which days of the week the job should
run (where ``0-6`` correspond to sunday - saturday). By default, the job will run
every day.
.. versionchanged:: 20.0
Changed day of the week mapping of 0-6 from monday-sunday to sunday-saturday.
data (:obj:`object`, optional): Additional data needed for the callback function.
Can be accessed through :attr:`Job.data` in the callback. Defaults to
:obj:`None`.
.. versionchanged:: 20.0
Renamed the parameter ``context`` to :paramref:`data`.
name (:obj:`str`, optional): The name of the new job. Defaults to
:external:attr:`callback.__name__ <definition.__name__>`.
chat_id (:obj:`int`, optional): Chat id of the chat associated with this job. If
passed, the corresponding :attr:`~telegram.ext.CallbackContext.chat_data` will
be available in the callback.
.. versionadded:: 20.0
user_id (:obj:`int`, optional): User id of the user associated with this job. If
passed, the corresponding :attr:`~telegram.ext.CallbackContext.user_data` will
be available in the callback.
.. versionadded:: 20.0
job_kwargs (:obj:`dict`, optional): Arbitrary keyword arguments to pass to the
:meth:`apscheduler.schedulers.base.BaseScheduler.add_job()`.
"""
# 复制文档
self.job_kwargs = job_kwargs
self.user_id = user_id
self.chat_id = chat_id
self.name = name
self.data = data
self.days = days
self.time = time
self.callback = callback
@property
def get_kwargs(self) -> dict:
kwargs = {
"callback": self.callback,
"time": self.time,
"days": self.days,
"data": self.data,
"name": self.name,
"chat_id": self.chat_id,
"user_id": self.callback,
"job_kwargs": self.job_kwargs,
}
return kwargs
class RunRepeatingHandler:
def __init__(self, callback: Func, interval: Union[float, datetime.timedelta],
first: Union[float, datetime.timedelta, datetime.datetime, datetime.time] = None,
last: Union[float, datetime.timedelta, datetime.datetime, datetime.time] = None,
context: object = None, name: str = None, chat_id: int = None, user_id: int = None,
job_kwargs: JSONDict = None):
"""Creates a new :class:`Job` instance that runs at specified intervals and adds it to the
queue.
Note:
For a note about DST, please see the documentation of `APScheduler`_.
.. _`APScheduler`: https://apscheduler.readthedocs.io/en/stable/modules/triggers/cron.html
#daylight-saving-time-behavior
Args:
callback (:term:`coroutine function`): The callback function that should be executed by
the new job. Callback signature::
async def callback(context: CallbackContext)
interval (:obj:`int` | :obj:`float` | :obj:`datetime.timedelta`): The interval in which
the job will run. If it is an :obj:`int` or a :obj:`float`, it will be interpreted
as seconds.
first (:obj:`int` | :obj:`float` | :obj:`datetime.timedelta` | \
:obj:`datetime.datetime` | :obj:`datetime.time`, optional):
Time in or at which the job should run. This parameter will be interpreted
depending on its type.
* :obj:`int` or :obj:`float` will be interpreted as "seconds from now" in which the
job should run.
* :obj:`datetime.timedelta` will be interpreted as "time from now" in which the
job should run.
* :obj:`datetime.datetime` will be interpreted as a specific date and time at
which the job should run. If the timezone (:attr:`datetime.datetime.tzinfo`) is
:obj:`None`, the default timezone of the bot will be used.
* :obj:`datetime.time` will be interpreted as a specific time of day at which the
job should run. This could be either today or, if the time has already passed,
tomorrow. If the timezone (:attr:`datetime.time.tzinfo`) is :obj:`None`, the
default timezone of the bot will be used, which is UTC unless
:attr:`telegram.ext.Defaults.tzinfo` is used.
Defaults to :paramref:`interval`
last (:obj:`int` | :obj:`float` | :obj:`datetime.timedelta` | \
:obj:`datetime.datetime` | :obj:`datetime.time`, optional):
Latest possible time for the job to run. This parameter will be interpreted
depending on its type. See :paramref:`first` for details.
If :paramref:`last` is :obj:`datetime.datetime` or :obj:`datetime.time` type
and ``last.tzinfo`` is :obj:`None`, the default timezone of the bot will be
assumed, which is UTC unless :attr:`telegram.ext.Defaults.tzinfo` is used.
Defaults to :obj:`None`.
data (:obj:`object`, optional): Additional data needed for the callback function.
Can be accessed through :attr:`Job.data` in the callback. Defaults to
:obj:`None`.
.. versionchanged:: 20.0
Renamed the parameter ``context`` to :paramref:`data`.
name (:obj:`str`, optional): The name of the new job. Defaults to
:external:attr:`callback.__name__ <definition.__name__>`.
chat_id (:obj:`int`, optional): Chat id of the chat associated with this job. If
passed, the corresponding :attr:`~telegram.ext.CallbackContext.chat_data` will
be available in the callback.
.. versionadded:: 20.0
user_id (:obj:`int`, optional): User id of the user associated with this job. If
passed, the corresponding :attr:`~telegram.ext.CallbackContext.user_data` will
be available in the callback.
.. versionadded:: 20.0
job_kwargs (:obj:`dict`, optional): Arbitrary keyword arguments to pass to the
:meth:`apscheduler.schedulers.base.BaseScheduler.add_job()`.
"""
# 复制文档
self.callback = callback
self.interval = interval
self.first = first
self.last = last
self.context = context
self.name = name
self.chat_id = chat_id
self.user_id = user_id
self.job_kwargs = job_kwargs
@property
def get_kwargs(self) -> dict:
kwargs = {
"callback": self.callback,
"interval": self.interval,
"first": self.first,
"last": self.last,
"context": self.context,
"name": self.name,
"chat_id": self.chat_id,
"user_id": self.callback,
"job_kwargs": self.job_kwargs,
}
return kwargs
class BaseJob:
@staticmethod
def remove_job_if_exists(name: str, context: CallbackContext) -> bool:
current_jobs = context.job_queue.get_jobs_by_name(name)
context.job_queue.run_repeating()
if not current_jobs:
return False
for job in current_jobs:
job.schedule_removal()
return True

61
logger.py Normal file
View File

@ -0,0 +1,61 @@
import logging
import os
from logging.handlers import RotatingFileHandler
import colorlog
from config import config
current_path = os.path.realpath(os.getcwd())
log_path = os.path.join(current_path, "logs")
if not os.path.exists(log_path):
os.mkdir(log_path)
log_file_name = os.path.join(log_path, "log.log")
log_colors_config = {
"DEBUG": "cyan",
"INFO": "green",
"WARNING": "yellow",
"ERROR": "red",
"CRITICAL": "red",
}
class Logger:
def __init__(self):
self.logger = logging.getLogger("TGPaimonBot")
root_logger = logging.getLogger()
root_logger.setLevel(logging.CRITICAL)
if config.DEBUG:
self.logger.setLevel(logging.DEBUG)
else:
self.logger.setLevel(logging.INFO)
self.formatter = colorlog.ColoredFormatter(
"%(log_color)s[%(asctime)s] [%(levelname)s] - %(message)s", log_colors=log_colors_config)
self.formatter2 = logging.Formatter("[%(asctime)s] [%(levelname)s] - %(message)s")
fh = RotatingFileHandler(filename=log_file_name, maxBytes=1024 * 1024 * 5, backupCount=5,
encoding="utf-8")
fh.setFormatter(self.formatter2)
root_logger.addHandler(fh)
ch = colorlog.StreamHandler()
ch.setFormatter(self.formatter)
root_logger.addHandler(ch)
def getLogger(self):
return self.logger
def debug(self, msg, exc_info=None):
self.logger.debug(msg=msg, exc_info=exc_info)
def info(self, msg, exc_info=None):
self.logger.info(msg=msg, exc_info=exc_info)
def warning(self, msg, exc_info=None):
self.logger.warning(msg=msg, exc_info=exc_info)
def error(self, msg, exc_info=None):
self.logger.error(msg=msg, exc_info=exc_info)
Log = Logger()

94
main.py Normal file
View File

@ -0,0 +1,94 @@
import asyncio
from warnings import filterwarnings
import pytz
from telegram.ext import Application, Defaults
from telegram.warnings import PTBUserWarning
from config import config
from logger import Log
from utils.aiobrowser import AioBrowser
from utils.app.manager import AppsManager
from utils.job.register import register_job
from utils.mysql import MySQL
from utils.plugins.register import register_plugin_handlers
from utils.redisdb import RedisDB
# 无视相关警告
# 该警告说明在官方GITHUB的WIKI中Frequently Asked Questions里的What do the per_* settings in ConversationHandler do?
filterwarnings(action="ignore", message=r".*CallbackQueryHandler", category=PTBUserWarning)
def main() -> None:
Log.info("正在启动项目")
# 初始化数据库
Log.info("初始化数据库")
mysql = MySQL(host=config.MYSQL["host"], user=config.MYSQL["user"], password=config.MYSQL["password"],
port=config.MYSQL["port"], database=config.MYSQL["database"])
# 初始化Redis缓存
Log.info("初始化Redis缓存")
redis = RedisDB(host=config.REDIS["host"], port=config.REDIS["port"], db=config.REDIS["database"])
# 初始化Playwright
Log.info("初始化Playwright")
browser = AioBrowser()
# 传入服务并启动
Log.info("正在启动服务")
apps = AppsManager(mysql, redis, browser)
apps.refresh_list("./app/*")
apps.import_module()
apps.add_service()
# 构建BOT
Log.info("构建BOT")
defaults = Defaults(tzinfo=pytz.timezone("Asia/Shanghai"))
application = Application\
.builder()\
.token(config.TELEGRAM["token"])\
.defaults(defaults)\
.build()
register_plugin_handlers(application)
register_job(application)
# 启动BOT
try:
Log.info("BOT已经启动 开始处理命令")
# BOT 在退出后默认关闭LOOP 这时候得让LOOP不要关闭
application.run_polling(close_loop=False)
except (KeyboardInterrupt, SystemExit):
pass
except Exception as exc:
Log.info("BOT执行过程中出现错误")
raise exc
finally:
Log.info("项目收到退出命令 BOT停止处理并退出")
loop = asyncio.get_event_loop()
try:
# 需要关闭数据库连接
Log.info("正在关闭数据库连接")
loop.run_until_complete(mysql.wait_closed())
# 关闭Redis连接
Log.info("正在关闭Redis连接")
loop.run_until_complete(redis.close())
# 关闭playwright
Log.info("正在关闭Playwright")
loop.run_until_complete(browser.close())
except (KeyboardInterrupt, SystemExit):
pass
except Exception as exc:
Log.error("关闭必要连接时出现错误 \n", exc)
Log.info("正在关闭loop")
# 关闭LOOP
loop.close()
Log.info("项目已经已结束")
if __name__ == '__main__':
main()

5
metadata/README.md Normal file
View File

@ -0,0 +1,5 @@
# metadata 目录说明
| FileName | Introduce |
| :----------: | ------------- |
| shortname.py | 记录短名称MAP |

158
metadata/shortname.py Normal file
View File

@ -0,0 +1,158 @@
roles = {
10000003: ["", "Jean", "jean", "团长", "代理团长", "琴团长", "蒲公英骑士"],
10000006: ["丽莎", "Lisa", "lisa", "图书管理员", "图书馆管理员", "蔷薇魔女"],
10000005: ["", "男主", "男主角", "龙哥", "空哥", "旅行者", "卑鄙的外乡人", "荣誉骑士", "", "风主", "岩主", "雷主", "履刑者", "抽卡不歪真君"],
10000007: ["", "女主", "女主角", "", "", "黄毛阿姨", "荧妹"],
10000014: ["芭芭拉", "Barbara", "barbara", "巴巴拉", "拉粑粑", "拉巴巴", "内鬼", "加湿器", "闪耀偶像", "偶像"],
10000015: ["凯亚", "Kaeya", "kaeya", "盖亚", "凯子哥", "凯鸭", "矿工", "矿工头子", "骑兵队长", "凯子", "凝冰渡海真君"],
10000016: ["迪卢克", "diluc", "Diluc", "卢姥爷", "姥爷", "卢老爷", "卢锅巴", "正义人", "正e人", "正E人", "卢本伟", "暗夜英雄", "卢卢伯爵",
"落魄了", "落魄了家人们"],
10000020: ["雷泽", "razor", "Razor", "狼少年", "狼崽子", "狼崽", "卢皮卡", "小狼", "小狼狗"],
10000021: ["安柏", "Amber", "amber", "安伯", "兔兔伯爵", "飞行冠军", "侦查骑士", "点火姬", "点火机", "打火机", "打火姬", ],
10000022: ["温迪", "Venti", "venti", "温蒂", "风神", "卖唱的", "巴巴托斯", "巴巴脱丝", "芭芭托斯", "芭芭脱丝", "干点正事", "不干正事", "吟游诗人",
"诶嘿", "唉嘿", "摸鱼", ],
10000023: ["香菱", "Xiangling", "xiangling", "香玲", "锅巴", "厨师", "万民堂厨师", "香师傅"],
10000024: ["北斗", "Beidou", "beidou", "大姐头", "大姐", "无冕的龙王", "龙王"],
10000025: ["行秋", "Xingqiu", "xingqiu", "秋秋人", "秋妹妹", "书呆子", "水神", "飞云商会二少爷"],
10000026: ["", "Xiao", "xiao", "杏仁豆腐", "打桩机", "插秧", "三眼五显仙人", "三眼五显真人", "降魔大圣", "护法夜叉", "快乐风男", "无聊", "靖妖傩舞",
"矮子仙人", "三点五尺仙人", "跳跳虎"],
10000027: ["凝光", "Ningguang", "ningguang", "富婆", "天权星"],
10000029: ["可莉", "Klee", "klee", "嘟嘟可", "火花骑士", "蹦蹦炸弹", "炸鱼", "放火烧山", "放火烧山真君", "蒙德最强战力", "逃跑的太阳", "啦啦啦", "哒哒哒",
"炸弹人", "禁闭室", ],
10000030: ["钟离", "Zhongli", "zhongli", "摩拉克斯", "岩王爷", "岩神", "钟师傅", "天动万象", "岩王帝君", "未来可期", "帝君", "拒收病婿"],
10000031: ["菲谢尔", "Fischl", "fischl", "皇女", "小艾米", "小艾咪", "奥兹", "断罪皇女", "中二病", "中二少女", "中二皇女", "奥兹发射器"],
10000032: ["班尼特", "Bennett", "bennett", "点赞哥", "点赞", "倒霉少年", "倒霉蛋", "霹雳闪雷真君", "班神", "班爷", "倒霉", "火神", "六星真神"],
10000033: ["达达利亚", "Tartaglia", "tartaglia", "Childe", "childe", "Ajax", "ajax", "达达鸭", "达达利鸭", "公子", "玩具销售员",
"玩具推销员", "钱包", "鸭鸭", "愚人众末席"],
10000034: ["诺艾尔", "Noelle", "noelle", "女仆", "高达", "岩王帝姬"],
10000035: ["七七", "Qiqi", "qiqi", "僵尸", "肚饿真君", "度厄真君"],
10000036: ["重云", "Chongyun", "chongyun", "纯阳之体", "冰棍"],
10000037: ["甘雨", "Ganyu", "ganyu", "椰羊", "椰奶", "王小美"],
10000038: ["阿贝多", "Albedo", "albedo", "可莉哥哥", "升降机", "升降台", "电梯", "白垩之子", "贝爷", "白垩", "阿贝少", "花呗多", "阿贝夕",
"abd", "阿师傅"],
10000039: ["迪奥娜", "Diona", "diona", "迪欧娜", "dio", "dio娜", "冰猫", "猫猫", "猫娘", "喵喵", "调酒师"],
10000041: ["莫娜", "Mona", "mona", "穷鬼", "穷光蛋", "", "莫纳", "占星术士", "占星师", "讨龙真君", "半部讨龙真君", "阿斯托洛吉斯·莫娜·梅姬斯图斯"],
10000042: ["刻晴", "Keqing", "keqing", "刻情", "氪晴", "刻师傅", "刻师父", "牛杂", "牛杂师傅", "斩尽牛杂", "免疫", "免疫免疫", "屁斜剑法",
"玉衡星", "阿晴", "啊晴"],
10000043: ["砂糖", "Sucrose", "sucrose", "雷莹术士", "雷萤术士", "雷荧术士"],
10000044: ["辛焱", "Xinyan", "xinyan", "辛炎", "黑妹", "摇滚"],
10000045: ["罗莎莉亚", "Rosaria", "rosaria", "罗莎莉娅", "白色史莱姆", "白史莱姆", "修女", "罗莎利亚", "罗莎利娅", "罗沙莉亚", "罗沙莉娅", "罗沙利亚",
"罗沙利娅", "萝莎莉亚", "萝莎莉娅", "萝莎利亚", "萝莎利娅", "萝沙莉亚", "萝沙莉娅", "萝沙利亚", "萝沙利娅"],
10000046: ["胡桃", "Hu Tao", "hu tao", "HuTao", "hutao", "Hutao", "胡淘", "往生堂堂主", "火化", "抬棺的", "蝴蝶", "核桃", "堂主",
"胡堂主", "雪霁梅香"],
10000047: ["枫原万叶", "Kaedehara Kazuha", "Kazuha", "kazuha", "万叶", "叶天帝", "天帝", "叶师傅"],
10000048: ["烟绯", "Yanfei", "yanfei", "烟老师", "律师", "罗翔"],
10000051: ["优菈", "Eula", "eula", "优拉", "尤拉", "尤菈", "浪花骑士", "记仇", "劳伦斯"],
10000002: ["神里绫华", "Kamisato Ayaka", "Ayaka", "ayaka", "神里", "绫华", "神里凌华", "凌华", "白鹭公主", "神里大小姐"],
10000049: ["宵宫", "Yoimiya", "yoimiya", "霄宫", "烟花", "肖宫", "肖工", "绷带女孩"],
10000052: ["雷电将军", "Raiden Shogun", "Raiden", "raiden", "雷神", "将军", "雷军", "巴尔", "阿影", "", "巴尔泽布", "煮饭婆", "奶香一刀",
"无想一刀", "宅女"],
10000053: ["早柚", "Sayu", "sayu", "小狸猫", "狸猫", "忍者"],
10000054: ["珊瑚宫心海", "Sangonomiya Kokomi", "Kokomi", "kokomi", "心海", "军师", "珊瑚宫", "书记", "观赏鱼", "水母", "", "美人鱼"],
10000056: ["九条裟罗", "Kujou Sara", "Sara", "sara", "九条", "九条沙罗", "裟罗", "沙罗", "天狗"],
10000062: ["埃洛伊", "Aloy", "aloy"],
10000050: ["托马", "Thoma", "thoma", "家政官", "太郎丸", "地头蛇", "男仆", "拖马"],
10000055: ["五郎", "Gorou", "gorou", "柴犬", "土狗", "希娜", "希娜小姐"],
10000057: ["荒泷一斗", "Arataki Itto", "Itto", "itto", "荒龙一斗", "荒泷天下第一斗", "一斗", "一抖", "荒泷", "1斗", "牛牛", "斗子哥", "牛子哥",
"牛子", "孩子王", "斗虫", "巧乐兹", "放牛的"],
10000058: ["八重神子", "Yae Miko", "Miko", "miko", "八重", "神子", "狐狸", "想得美哦", "巫女", "屑狐狸", "骚狐狸", "八重宫司", "婶子", "小八"],
10000059: ["鹿野院平藏", "shikanoin heizou", "Heizou", "heizou", "heizo", "鹿野苑", "鹿野院", "平藏", "鹿野苑平藏"],
10000060: ["夜兰", "Yelan", "yelan", "夜阑", "叶澜", "腋兰", "夜天后"],
10000063: ["申鹤", "Shenhe", "shenhe", "神鹤", "小姨", "小姨子", "审鹤"],
10000064: ["云堇", "Yun Jin", "yunjin", "yun jin", "云瑾", "云先生", "云锦", "神女劈观"],
10000065: ["久岐忍", "Kuki Shinobu", "Kuki", "kuki", "Shinobu", "shinobu", "97忍", "小忍", "久歧忍", "97", "茄忍", "阿忍", "忍姐"],
10000066: ["神里绫人", "Kamisato Ayato", "Ayato", "ayato", "绫人", "神里凌人", "凌人", "0人", "神人", "零人", "大舅哥"],
}
weapons = {
"磐岩结绿": ["绿箭", "绿剑"],
"斫峰之刃": ["斫峰", "盾剑"],
"无工之剑": ["蜈蚣", "蜈蚣大剑", "无工大剑", "盾大剑", "无工"],
"贯虹之槊": ["贯虹", "岩枪", "盾枪"],
"赤角石溃杵": ["赤角", "石溃杵"],
"尘世之锁": ["尘世锁", "尘世", "盾书", ""],
"终末嗟叹之诗": ["终末", "终末弓", "叹气弓", "乐团弓"],
"松籁响起之时": ["松籁", "乐团大剑", "松剑"],
"苍古自由之誓": ["苍古", "乐团剑"],
"渔获": ["鱼叉"],
"衔珠海皇": ["海皇", "咸鱼剑", "咸鱼大剑"],
"匣里日月": ["日月"],
"匣里灭辰": ["灭辰"],
"匣里龙吟": ["龙吟"],
"天空之翼": ["天空弓"],
"天空之刃": ["天空剑"],
"天空之卷": ["天空书", "厕纸"],
"天空之脊": ["天空枪", "薄荷枪"],
"天空之傲": ["天空大剑"],
"四风原典": ["四风"],
"试作斩岩": ["斩岩"],
"试作星镰": ["星镰"],
"试作金珀": ["金珀"],
"试作古华": ["古华"],
"试作澹月": ["澹月"],
"千岩长枪": ["千岩枪"],
"千岩古剑": ["千岩剑", "千岩大剑"],
"暗巷闪光": ["暗巷剑"],
"暗巷猎手": ["暗巷弓"],
"阿莫斯之弓": ["阿莫斯", "ams", "痛苦弓"],
"雾切之回光": ["雾切"],
"飞雷之弦振": ["飞雷", "飞雷弓"],
"薙草之稻光": ["薙草", "稻光", "薙草稻光", "马尾枪", "马尾", "薙刀"],
"神乐之真意": ["神乐", "真意"],
"狼的末路": ["狼末"],
"护摩之杖": ["护摩", "护摩枪", "护膜"],
"和璞鸢": ["鸟枪", "绿枪"],
"风鹰剑": ["风鹰"],
"冬极白星": ["冬极"],
"不灭月华": ["月华"],
"波乱月白经津": ["波乱", "月白", "波乱月白", "经津", "波波津"],
"若水": ["麒麟弓", "Aqua", "aqua"],
"昭心": ["糟心"],
"幽夜华尔兹": ["幽夜", "幽夜弓", "华尔兹", "皇女弓"],
"雪葬的星银": ["雪葬", "星银", "雪葬星银", "雪山大剑"],
"喜多院十文字": ["喜多院", "十文字"],
"万国诸海图谱": ["万国", "万国诸海"],
"天目影打刀": ["天目刀", "天目"],
"破魔之弓": ["破魔弓"],
"曚云之月": ["曚云弓"],
"流月针": [""],
"流浪乐章": ["赌狗书", "赌狗乐章", "赌狗"],
"桂木斩长正": ["桂木", "斩长正"],
"腐殖之剑": ["腐殖", "腐殖剑"],
"风花之颂": ["风花弓"],
"证誓之明瞳": ["证誓", "明瞳", "证誓明瞳"],
"嘟嘟可故事集": ["嘟嘟可"],
"辰砂之纺锤": ["辰砂", "辰砂纺锤", "纺锤"],
"白辰之环": ["白辰", "白辰环"],
"决斗之枪": ["决斗枪", "决斗", "月卡枪"],
"螭骨剑": ["螭骨", "丈育剑", "离骨剑", "月卡大剑"],
"黑剑": ["月卡剑"],
"苍翠猎弓": ["绿弓", "月卡弓"],
"讨龙英杰谭": ["讨龙"],
"神射手之誓": ["脚气弓", "神射手"],
"黑缨枪": ["史莱姆枪"]
}
def roleToName(shortname: str) -> str:
if not shortname:
return shortname
for value in roles.values():
for i in value:
if i == shortname:
return value[0]
return shortname
def weaponToName(shortname: str) -> str:
return next((key for key, value in weapons.items() if shortname == key or shortname in value), shortname)

27
model/README.md Normal file
View File

@ -0,0 +1,27 @@
# model 目录说明
## apihelpe 模块
用于获取米忽悠BBS的数据写的请求模块
## wiki 模块
### 计划
当成设计之初为考虑摆脱并消除第三方 `metadata` 数据结发生变化的影响
### 选择
关于选择那个Wiki数据获取我考虑了很久还是选择了国外的蜜蜂网原因还是有两个方面
页面形式几乎固定 未来不会发生变化
最主要还是有参考代码 极大减少了我工作量
毕竟如果从零开始真的头顶很凉 〒▽〒
### 感谢
| Nickname | Contribution |
| :--------------------------------------------------------: | -------------------- |
| [Crawler-ghhw](https://github.com/DGP-Studio/Crawler-ghhw) | 本项目参考的爬虫代码 |

View File

@ -0,0 +1,54 @@
from base64 import b64encode
from random import choice
import httpx
def get_format_sub_item(artifact_attr: dict):
return "".join(f'{i["name"]:\u3000<6} | {i["value"]}\n' for i in artifact_attr["sub_item"])
def get_comment(get_rate_num):
data = {"1": ["破玩意谁能用啊,谁都用不了吧", "喂了吧,这东西做狗粮还能有点用", "抽卡有保底,圣遗物没有下限",
"未来可期呢(笑)", "你出门一定很安全", "你是不是得罪米哈游了?", "……宁就是班尼特本特?",
"丢人!你给我退出提瓦特(", "不能说很糟糕,只能说特别不好"],
"2": ["淡如清泉镇的圣水,莫得提升", "你怎么不强化啊?", "嗯嗯嗯好好好可以可以可以挺好挺好(敷衍)",
"这就是日常,下一个", "洗洗还能吃bushi", "下次一定行……?", "派蒙平静地点了个赞",
"不知道该说什么,就当留个纪念吧"],
"3": ["不能说有质变,只能说有提升", "过渡用的话没啥问题,大概", "再努努力吧", "嗯,差不多能用",
"这很合理", "达成成就“合格圣遗物”", "嗯,及格了,过渡用挺好的", "中规中矩,有待提升"],
"4": ["以普遍理性而论,很好", "算是个很不戳的圣遗物了!", "很好,很有精神!", "再努努力,超越一下自己",
"感觉可以戴着它大杀四方了", "这就是大佬背包里的平均水平吧", "先锁上呗,这波不亏", "达成成就“高分圣遗物”",
"这波对输出有很大提升啊(认真)", "我也想拥有这种分数的圣遗物(切实)"],
"5": ["多吃点好的,出门注意安全", "晒吧,欧不可耻,只是可恨", "没啥好说的,让我自闭一会", "达成成就“高分圣遗物”",
"怕不是以后开宝箱只能开出卷心菜", "吃了吗?没吃的话,吃我一拳", "我觉得这个游戏有问题", "这合理吗",
"这东西没啥用,给我吧(柠檬)", " "]}
try:
data_ = int(float(get_rate_num))
except ValueError:
data_ = 0
if data_ == 100:
return choice(data["5"])
return choice(data[str(data_ // 20 + 1)])
class ArtifactOcrRate:
OCR_URL = "https://api.genshin.pub/api/v1/app/ocr"
RATE_URL = "https://api.genshin.pub/api/v1/relic/rate"
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/76.0.3809.132 Safari/537.36",
"Content-Type": "application/json; charset=utf-8",
}
def __init__(self):
self.client = httpx.AsyncClient(headers=self.HEADERS)
async def get_artifact_attr(self, photo_byte):
b64_str = b64encode(photo_byte).decode()
req = await self.client.post(self.OCR_URL, json={"image": b64_str}, timeout=8)
return req
async def rate_artifact(self, artifact_attr: dict):
req = await self.client.post(self.RATE_URL, json=artifact_attr, timeout=8)
return req

158
model/apihelper/base.py Normal file
View File

@ -0,0 +1,158 @@
import imghdr
from enum import Enum
class ArtworkImage:
def __init__(self, art_id: int, page: int = 0, is_error: bool = False, data: bytes = b""):
"""
:param art_id: 插画ID
:param page: 页数
:param is_error: 插画是否有问题
:param data: 插画数据
"""
self.art_id = art_id
self.data = data
self.is_error = is_error
if not is_error:
self.format: str = imghdr.what(None, self.data)
self.page = page
class BaseResponseData:
def __init__(self, response=None, error_message: str = ""):
"""
:param response: 相应
:param error_message: 错误信息
"""
if response is None:
self.error: bool = True
self.message: str = error_message
return
self.response: dict = response
self.code = response["retcode"]
if self.code == 0:
self.error = False
else:
self.error = True
self.message = response["message"]
self.data = response["data"]
class Stat:
def __init__(self, view_num: int = 0, reply_num: int = 0, like_num: int = 0, bookmark_num: int = 0,
forward_num: int = 0):
self.forward_num = forward_num # 关注数
self.bookmark_num = bookmark_num # 收藏数
self.like_num = like_num # 喜欢数
self.reply_num = reply_num # 回复数
self.view_num = view_num # 观看数
class ArtworkInfo:
def __init__(self, post_id: int = 0, subject: str = "", tags=None,
image_url_list=None, stat: Stat = None, uid: int = 0, created_at: int = 0):
"""
:param post_id: post_id
:param subject: 标题
:param tags: 标签
:param image_url_list: 图片URL列表
:param stat: 统计
:param uid: 用户UID
:param created_at: 创建时间
"""
if tags is None:
self.tags = []
else:
self.tags = tags
if image_url_list is None:
self.image_url_list = []
else:
self.image_url_list = image_url_list
self.Stat = stat
self.created_at = created_at
self.uid = uid
self.subject = subject
self.post_id = post_id
class HyperionResponse:
"""
:param response: 相应
:param error_message: 错误信息
"""
def __init__(self, response=None, error_message: str = ""):
if response is None:
self.error: bool = True
self.message: str = error_message
return
self.response: dict = response
self.code = response["retcode"]
if self.code == 0:
self.error = False
else:
if self.code == 1102:
self.message = "作品不存在"
self.error = True
return
if response["data"] is None:
self.error = True
self.message: str = response["message"]
if self.error:
return
try:
self._data_post = response["data"]["post"]
post = self._data_post["post"] # 投稿信息
post_id = post["post_id"]
subject = post["subject"] # 介绍类似title标题
created_at = post["created_at"] # 创建时间
user = self._data_post["user"] # 用户数据
uid = user["uid"] # 用户ID
topics = self._data_post["topics"] # 存放 Tag
image_list = self._data_post["image_list"] # image_list
except (AttributeError, TypeError) as err:
self.error: bool = True
self.message: str = err
return
topics_list = []
image_url_list = []
for topic in topics:
topics_list.append(topic["name"])
for image in image_list:
image_url_list.append(image["url"])
self.post_id = post["post_id"]
self.user_id = user["uid"]
self.created_at = post["created_at"]
stat = Stat(view_num=self._data_post["stat"]["view_num"],
reply_num=self._data_post["stat"]["reply_num"],
like_num=self._data_post["stat"]["like_num"],
bookmark_num=self._data_post["stat"]["bookmark_num"],
forward_num=self._data_post["stat"]["forward_num"],
)
self.results = ArtworkInfo(
subject=subject,
created_at=created_at,
uid=uid,
stat=stat,
tags=topics_list,
post_id=post_id,
image_url_list=image_url_list
)
def __bool__(self):
"""
:return: 是否错误
"""
return self.error
def __len__(self):
"""
:return: 插画连接数量
"""
return len(self.results.image_url_list)
class ServiceEnum(Enum):
HYPERION = 1
HOYOLAB = 2

29
model/apihelper/gacha.py Normal file
View File

@ -0,0 +1,29 @@
import httpx
from .base import BaseResponseData
class GachaInfo:
GACHA_LIST_URL = "https://webstatic.mihoyo.com/hk4e/gacha_info/cn_gf01/gacha/list.json"
GACHA_INFO_URL = "https://webstatic.mihoyo.com/hk4e/gacha_info/cn_gf01/%s/zh-cn.json"
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " \
"Chrome/90.0.4430.72 Safari/537.36"
def __init__(self):
self.headers = {
'User-Agent': self.USER_AGENT,
}
self.client = httpx.AsyncClient(headers=self.headers)
async def get_gacha_list_info(self) -> BaseResponseData:
req = await self.client.get(self.GACHA_LIST_URL)
if req.is_error:
return BaseResponseData(error_message="请求错误")
return BaseResponseData(req.json())
async def get_gacha_info(self, gacha_id: str) -> dict:
req = await self.client.get(self.GACHA_INFO_URL % gacha_id)
if req.is_error:
return {}
return req.json()

View File

@ -0,0 +1,63 @@
import hashlib
import random
import string
import time
import uuid
RECOGNIZE_SERVER = {
"1": "cn_gf01",
"2": "cn_gf01",
"5": "cn_qd01",
"6": "os_usa",
"7": "os_euro",
"8": "os_asia",
"9": "os_cht",
}
def get_device_id(name: str) -> str:
return str(uuid.uuid3(uuid.NAMESPACE_URL, name)).replace('-', '').upper()
def md5(text: str) -> str:
_md5 = hashlib.md5()
_md5.update(text.encode())
return _md5.hexdigest()
def random_text(num: int) -> str:
return ''.join(random.sample(string.ascii_lowercase + string.digits, num))
def timestamp() -> int:
return int(time.time())
def get_ds(salt: str = "", web: int = 1) -> str:
if salt == "":
if web == 1:
salt = "h8w582wxwgqvahcdkpvdhbh2w9casgfl"
elif web == 2:
salt = "h8w582wxwgqvahcdkpvdhbh2w9casgfl"
elif web == 3:
salt = "fd3ykrh7o1j54g581upo1tvpam0dsgtf"
i = str(timestamp())
r = random_text(6)
c = md5("salt=" + salt + "&t=" + i + "&r=" + r)
return f"{i},{r},{c}"
def get_recognize_server(uid: int) -> str:
server = RECOGNIZE_SERVER.get(str(uid)[0])
if server:
return server
else:
raise TypeError(f"UID {uid} isn't associated with any recognize server")
def get_headers():
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.63 Safari/537.36",
}
return headers

View File

@ -0,0 +1,96 @@
from httpx import AsyncClient
from .base import BaseResponseData
from .helpers import get_ds, get_device_id, get_recognize_server
class Genshin:
SIGN_INFO_URL = "https://hk4e-api-os.hoyoverse.com/event/sol/info"
SIGN_URL = "https://hk4e-api-os.hoyoverse.com/event/sol/sign"
SIGN_HOME_URL = "https://hk4e-api-os.hoyoverse.com/event/sol/home"
APP_VERSION = "2.11.1"
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " \
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36"
REFERER = "https://webstatic.hoyoverse.com"
ORIGIN = "https://webstatic.hoyoverse.com"
ACT_ID = "e202102251931481"
DS_SALT = "6cqshh5dhw73bzxn20oexa9k516chk7s"
def __init__(self):
self.headers = {
"Origin": self.ORIGIN,
'DS': get_ds(self.DS_SALT),
'x-rpc-app_version': self.APP_VERSION,
'User-Agent': self.USER_AGENT,
'x-rpc-client_type': '5', # 1为ios 2为安卓 4为pc_web 5为mobile_web
'Referer': self.REFERER,
'x-rpc-device_id': get_device_id(self.USER_AGENT)}
self.client = AsyncClient(headers=self.headers)
async def is_sign(self, uid: int, region: str = "", cookies: dict = None, lang: str = 'zh-cn'):
"""
检查是否签到
:param lang: 语言
:param uid: 游戏UID
:param region: 服务器
:param cookies: cookie
:return:
"""
if region == "":
region = get_recognize_server(uid)
params = {
"act_id": self.ACT_ID,
"region": region,
"uid": uid,
"lang": lang
}
req = await self.client.get(self.SIGN_INFO_URL, params=params, cookies=cookies)
if req.is_error:
return BaseResponseData(error_message="请求错误")
return BaseResponseData(req.json())
async def sign(self, uid: int, region: str = "", cookies: dict = None, lang: str = 'zh-cn'):
"""
执行签到
:param lang:
:param uid: 游戏UID
:param region: 服务器
:param cookies: cookie
:return:
"""
if region == "":
region = get_recognize_server(uid)
data = {
"act_id": self.ACT_ID,
"region": region,
"uid": uid,
"lang": lang
}
req = await self.client.post(self.SIGN_URL, json=data, cookies=cookies)
if req.is_error:
return BaseResponseData(error_message="签到失败")
return BaseResponseData(req.json())
async def get_sign_give(self, cookies: dict = None, lang: str = 'zh-cn'):
"""
返回今日签到信息
:param lang:
:param cookies:
:return:
"""
params = {
"act_id": self.ACT_ID,
"lang": lang
}
req = await self.client.get(self.SIGN_HOME_URL, params=params, cookies=cookies)
if req.is_error:
return
return BaseResponseData(req.json())
async def __aenter__(self):
pass
async def __aexit__(self, exc_type, exc, tb):
await self.client.aclose()

251
model/apihelper/hyperion.py Normal file
View File

@ -0,0 +1,251 @@
import asyncio
import re
from typing import List
import httpx
from httpx import AsyncClient
from .base import HyperionResponse, ArtworkImage, BaseResponseData
from .helpers import get_ds, get_device_id
class Hyperion:
"""
米忽悠bbs相关API请求
该名称来源于米忽悠的安卓BBS包名结尾考虑到大部分重要的功能确实是在移动端实现了
"""
POST_FULL_URL = "https://bbs-api.mihoyo.com/post/wapi/getPostFull"
POST_FULL_IN_COLLECTION_URL = "https://bbs-api.mihoyo.com/post/wapi/getPostFullInCollection"
GET_NEW_LIST_URL = "https://bbs-api.mihoyo.com/post/wapi/getNewsList"
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " \
"Chrome/90.0.4430.72 Safari/537.36"
def __init__(self):
self.client = httpx.AsyncClient(headers=self.get_headers())
@staticmethod
def extract_post_id(text: str) -> int:
"""
:param text:
# https://bbs.mihoyo.com/ys/article/8808224
# https://m.bbs.mihoyo.com/ys/article/8808224
:return: post_id
"""
rgx = re.compile(r"(?:bbs\.)?mihoyo\.com/[^.]+/article/(?P<article_id>\d+)")
matches = rgx.search(text)
if matches is None:
return -1
entries = matches.groupdict()
if entries is None:
return -1
try:
art_id = int(entries.get('article_id'))
except (IndexError, ValueError, TypeError):
return -1
return art_id
def get_headers(self, referer: str = "https://bbs.mihoyo.com/"):
return {
"User-Agent": self.USER_AGENT,
"Referer": referer
}
@staticmethod
def get_list_url_params(forum_id: int, is_good: bool = False, is_hot: bool = False,
page_size: int = 20) -> dict:
params = {
"forum_id": forum_id,
"gids": 2,
"is_good": is_good,
"is_hot": is_hot,
"page_size": page_size,
"sort_type": 1
}
return params
@staticmethod
def get_images_params(resize: int = 600, quality: int = 80, auto_orient: int = 0, interlace: int = 1,
images_format: str = "jpg"):
"""
image/resize,s_600/quality,q_80/auto-orient,0/interlace,1/format,jpg
:param resize: 图片大小
:param quality: 图片质量
:param auto_orient: 自适应
:param interlace: 未知
:param images_format: 图片格式
:return:
"""
params = f"image/resize,s_{resize}/quality,q_{quality}/auto-orient," \
f"{auto_orient}/interlace,{interlace}/format,{images_format}"
return {"x-oss-process": params}
async def get_post_full_in_collection(self, collection_id: int, gids: int = 2, order_type=1) -> BaseResponseData:
params = {
"collection_id": collection_id,
"gids": gids,
"order_type": order_type
}
response = await self.client.get(url=self.POST_FULL_IN_COLLECTION_URL, params=params)
if response.is_error:
return BaseResponseData(error_message="请求错误")
return BaseResponseData(response.json())
async def get_artwork_info(self, gids: int, post_id: int, read: int = 1) -> HyperionResponse:
params = {
"gids": gids,
"post_id": post_id,
"read": read
}
response = await self.client.get(self.POST_FULL_URL, params=params)
if response.is_error:
return HyperionResponse(error_message="请求错误")
return HyperionResponse(response.json())
async def get_post_full_info(self, gids: int, post_id: int, read: int = 1) -> BaseResponseData:
params = {
"gids": gids,
"post_id": post_id,
"read": read
}
response = await self.client.get(self.POST_FULL_URL, params=params)
if response.is_error:
return BaseResponseData(error_message="请求错误")
return BaseResponseData(response.json())
async def get_images_by_post_id(self, gids: int, post_id: int) -> List[ArtworkImage]:
artwork_info = await self.get_artwork_info(gids, post_id)
if artwork_info.error:
return []
urls = artwork_info.results.image_url_list
art_list = []
task_list = [
self.download_image(artwork_info.post_id, urls[page], page) for page in range(len(urls))
]
result_list = await asyncio.gather(*task_list)
for result in result_list:
if isinstance(result, ArtworkImage):
art_list.append(result)
def take_page(elem: ArtworkImage):
return elem.page
art_list.sort(key=take_page)
return art_list
async def download_image(self, art_id: int, url: str, page: int = 0) -> ArtworkImage:
response = await self.client.get(url, params=self.get_images_params(resize=2000), timeout=5)
if response.is_error:
return ArtworkImage(art_id, page, True)
return ArtworkImage(art_id, page, data=response.content)
async def get_new_list(self, gids: int, type_id: int, page_size: int = 20):
"""
?gids=2&page_size=20&type=3
:return:
"""
params = {
"gids": gids,
"page_size": page_size,
"type": type_id
}
response = await self.client.get(url=self.GET_NEW_LIST_URL, params=params)
if response.is_error:
return BaseResponseData(error_message="请求错误")
return BaseResponseData(response.json())
async def close(self):
await self.client.aclose()
class YuanShen:
SIGN_INFO_URL = "https://api-takumi.mihoyo.com/event/bbs_sign_reward/info"
SIGN_URL = "https://api-takumi.mihoyo.com/event/bbs_sign_reward/sign"
SIGN_HOME_URL = "https://api-takumi.mihoyo.com/event/bbs_sign_reward/home"
APP_VERSION = "2.3.0"
USER_AGENT = "Mozilla/5.0 (Linux; Android 9; Unspecified Device) AppleWebKit/537.36 (KHTML, like Gecko) " \
"Version/4.0 Chrome/39.0.0.0 Mobile Safari/537.36 miHoYoBBS/2.3.0"
REFERER = "https://webstatic.mihoyo.com/bbs/event/signin-ys/index.html?" \
"bbs_auth_required=true&act_id=e202009291139501&utm_source=hyperion&utm_medium=mys&utm_campaign=icon"
ORIGIN = "https://webstatic.mihoyo.com"
ACT_ID = "e202009291139501"
DS_SALT = "h8w582wxwgqvahcdkpvdhbh2w9casgfl"
def __init__(self):
self.headers = {
"Origin": self.ORIGIN,
'DS': get_ds(self.DS_SALT),
'x-rpc-app_version': self.APP_VERSION,
'User-Agent': self.USER_AGENT,
'x-rpc-client_type': '5', # 1为ios 2为安卓 4为pc_web 5为mobile_web
'Referer': self.REFERER,
'x-rpc-device_id': get_device_id(self.USER_AGENT)}
self.client = AsyncClient(headers=self.headers)
async def is_sign(self, uid: int, region: str = "cn_gf01", cookies: dict = None):
"""
检查是否签到
:param uid: 游戏UID
:param region: 服务器
:param cookies: cookie
:return:
"""
params = {
"act_id": self.ACT_ID,
"region": region,
"uid": uid
}
req = await self.client.get(self.SIGN_INFO_URL, params=params, cookies=cookies)
if req.is_error:
return BaseResponseData(error_message="请求错误")
return BaseResponseData(req.json())
async def sign(self, uid: int, region: str = "cn_gf01", cookies: dict = None):
"""
执行签到
:param uid: 游戏UID
:param region: 服务器
:param cookies: cookie
:return:
"""
data = {
"act_id": self.ACT_ID,
"region": region,
"uid": uid
}
req = await self.client.post(self.SIGN_URL, json=data, cookies=cookies)
if req.is_error:
return BaseResponseData(error_message="签到失败")
return BaseResponseData(req.json())
async def get_sign_give(self, cookies: dict = None):
"""
返回今日签到信息
:param cookies:
:return:
"""
params = {
"act_id": self.ACT_ID
}
req = await self.client.get(self.SIGN_HOME_URL, params=params, cookies=cookies)
if req.is_error:
return
return BaseResponseData(req.json())
async def __aenter__(self):
"""
:return:
"""
pass
async def __aexit__(self, exc_type, exc, tb):
"""
:param exc_type:
:param exc:
:param tb:
:return:
"""
await self.client.aclose()

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,387 @@
{
"20848859": "黑岩斩刀",
"33330467": "元素熟练",
"37147251": "匣里日月",
"43015699": "待定",
"54857595": "止水息雷",
"83115355": "被怜爱的少女",
"85795635": "专注",
"88505754": "枫原万叶",
"135182203": "止水息雷",
"147298547": "流浪大地的乐团",
"156294403": "沉沦之心",
"160493219": "暗铁剑",
"168956722": "七七",
"197755235": "贯虹之槊",
"212557731": "祭雷之人",
"240385755": "破浪",
"246984427": "踏火息雷",
"262428003": "祭冰之人",
"270124867": "护国的无垢之心",
"287454963": "祭风之人",
"288666635": "无垢之心",
"302691299": "琥珀玥",
"303155515": "离簇不归",
"310247243": "神乐之真意",
"334242634": "申鹤",
"339931171": "乘胜追击",
"342097547": "辰砂之纺锤",
"346510395": "衔珠海皇",
"368014203": "斩裂晴空的龙脊",
"391273955": "斫断黑翼的利齿",
"411685275": "钢轮弓",
"479076483": "冷刃",
"481755219": "黑岩刺枪",
"486287579": "余热",
"500612819": "「旗杆」",
"500987603": "(test)穿模测试",
"506630267": "顺风而行",
"514784907": "踏火止水",
"521221323": "护国的无垢之心",
"540938627": "掠食者",
"566772267": "御伽大王御伽话",
"577103787": "能量沐浴",
"578575283": "流月针",
"597991835": "白夜皓月",
"613846163": "降世",
"618786571": "钺矛",
"623494555": "摧坚",
"623534363": "西风秘典",
"630452219": "樱之斋宫",
"646100491": "千岩诀·同心",
"650049651": "风花之颂",
"655825874": "云堇",
"656120259": "神射手之誓",
"680510411": "白影剑",
"688991243": "息灾",
"693354267": "尘世之锁",
"697277554": "烟绯",
"716252627": "千岩长枪",
"729851187": "冰之川与雪之砂",
"735056795": "西风大剑",
"807607555": "天空之卷",
"824949859": "嘟嘟!大冒险",
"828711395": "阿莫斯之弓",
"836208539": "炊金",
"850802171": "白铁大剑",
"855894507": "战狂",
"862591315": "苍白之火",
"877751435": "宗室大剑",
"902264035": "风鹰剑",
"902282051": "收割",
"909145139": "护国的无垢之心",
"930640955": "钟剑",
"933076627": "冰风迷途的勇士",
"942758755": "专注",
"944332883": "斫峰之刃",
"949506483": "海洋的胜利",
"968378595": "西风之鹰的抗争",
"968893378": "班尼特",
"991968139": "非时之梦·常世灶食",
"1006042610": "神里绫华",
"1021898539": "弹弓",
"1021947690": "魈",
"1028735635": "抗争的践行之歌",
"1053433018": "砂糖",
"1072884907": "万国诸海图谱",
"1075647299": "松籁响起之时",
"1082448331": "微光的海渊民",
"1089950259": "天空之傲",
"1097898243": "沉重",
"1103732675": "幸运儿",
"1113306282": "莫娜",
"1114777131": "和弦",
"1119368259": "旅程",
"1130996346": "香菱",
"1133599347": "矢志不忘",
"1148024603": "「渔获」",
"1154009435": "试作星镰",
"1163263227": "流浪乐章",
"1163616891": "霜葬",
"1182966603": "佣兵重剑",
"1186209435": "赌徒",
"1212345779": "角斗士的终幕礼",
"1217552947": "白刃流转",
"1240067179": "西风猎弓",
"1319974859": "激励",
"1321135667": "匣里龙吟",
"1337666507": "千岩牢固",
"1344953075": "顺风而行",
"1345343763": "磐岩结绿",
"1383639611": "奇迹",
"1388004931": "飞天御剑",
"1390797107": "白缨枪",
"1404688115": "别离的思念之歌",
"1406746947": "异世界行记",
"1414366819": "金璋皇极",
"1437658243": "螭骨剑",
"1438974835": "逆飞的流星",
"1455107995": "四风原典",
"1468367538": "迪奥娜",
"1479961579": "铁影阔剑",
"1483922610": "九条裟罗",
"1485303435": "注能之刺",
"1492752155": "气定神闲",
"1499235563": "乘胜追击",
"1499817443": "苍翠之风",
"1516554699": "石英大剑",
"1522029867": "踏火息雷",
"1524173875": "炽烈的炎之魔女",
"1533656818": "旅行者",
"1541919827": "染血的骑士道",
"1545992315": "「正义」",
"1558036915": "辰砂往生录",
"1562601179": "翠绿之影",
"1588620330": "神里绫人",
"1595734083": "(test)穿模测试",
"1600275315": "波乱月白经津",
"1608953539": "黎明神剑",
"1610242915": "传承",
"1628928163": "风花之愿",
"1632377563": "渡过烈火的贤人",
"1651985379": "极昼的先兆者",
"1660598451": "岩藏之胤",
"1675686363": "祭礼大剑",
"1706534267": "有话直说",
"1722706579": "止水融冰",
"1745286795": "名士振舞",
"1745712907": "驭浪的海祇民",
"1751039235": "昔日宗室之仪",
"1756609915": "海染砗磲",
"1771603299": "金璋皇极",
"1773425155": "降临之剑",
"1789612403": "回响",
"1820235315": "无矢之歌",
"1836628747": "叛逆的守护者",
"1860795787": "曚云之月",
"1864015138": "刻晴",
"1873342283": "平息鸣雷的尊者",
"1890163363": "不灭月华",
"1901973075": "冬极白星",
"1921418842": "诺艾尔",
"1932742643": "灭却之戒法",
"1934830979": "无尽的渴慕",
"1940821986": "久岐忍",
"1940919994": "胡桃",
"1966438658": "安柏",
"1982136171": "专注",
"1990641987": "祭礼剑",
"1990820123": "天目影打刀",
"1991707099": "试作斩岩",
"1997709467": "和璞鸢",
"2006422931": "千岩古剑",
"2009975571": "(test)穿模测试",
"2017033267": "气定神闲",
"2025598051": "顺风而行",
"2040573235": "悠古的磐岩",
"2060049099": "祭火之人",
"2108574027": "碎石",
"2109571443": "专注",
"2125206395": "银剑",
"2149411851": "金璋皇极",
"2172529947": "乘胜追击",
"2176134843": "专注",
"2190368347": "决",
"2191797987": "冒险家",
"2195665683": "祭礼残章",
"2242027395": "黑檀弓",
"2276480763": "绝缘之旗印",
"2279290283": "魔导绪论",
"2297485451": "速射弓斗",
"2312640651": "气定神闲",
"2317820211": "注能之针",
"2322648115": "粉碎",
"2324146259": "白辰之环",
"2340970067": "历练的猎弓",
"2359799475": "恶王丸",
"2364208851": "行者之心",
"2365025043": "街巷游侠",
"2375993851": "宗室长剑",
"2383998915": "驭浪的海祇民",
"2384519283": "弹弓",
"2388785242": "早柚",
"2400012995": "祭礼弓",
"2410593283": "无锋剑",
"2417717595": "暗巷猎手",
"2425414923": "落霞",
"2433755451": "揭旗的叛逆之歌",
"2440850563": "回响长天的诗歌",
"2466140362": "温迪",
"2469300579": "乘胜追击",
"2470306939": "飞雷御执",
"2474354867": "西风剑",
"2476346187": "踏火止水",
"2491797315": "喜多院十文字",
"2504399314": "宵宫",
"2512309395": "如雷的盛怒",
"2521338131": "试作金珀",
"2534304035": "雾切御腰物",
"2539208459": "证誓之明瞳",
"2546254811": "华馆梦醒形骸记",
"2556914683": "绝弦",
"2587614459": "忍冬之果",
"2614170427": "飞天大御剑",
"2646367730": "北斗",
"2664629131": "匣里灭辰",
"2666951267": "训练大剑",
"2673337443": "注能之矢",
"2679781122": "甘雨",
"2684365579": "登场乐",
"2705029563": "口袋魔导书",
"2713453234": "八重神子",
"2719832059": "(test)穿模测试",
"2743659331": "激流",
"2749508387": "金璋皇极",
"2749853923": "腐殖之剑",
"2753539619": "雪葬的星银",
"2764598579": "流放者",
"2792766467": "无工之剑",
"2796697027": "新手长枪",
"2832648187": "宗室长弓",
"2834803571": "金璋皇极",
"2848374378": "夜兰",
"2853296811": "穿刺高天的利齿",
"2871793795": "锐利",
"2876340530": "重云",
"2890909531": "武人",
"2893964243": "飞矢传书",
"2915865819": "渊中霞彩",
"2918525947": "飞雷之弦振",
"2935286715": "宗室猎枪",
"2947140987": "暗巷闪光",
"2949448555": "苍古自由之誓",
"2963220587": "翡玉法球",
"3001782875": "气定神闲",
"3018479371": "船歌",
"3024507506": "雷电将军",
"3063488107": "强力攻击",
"3068316954": "荒泷一斗",
"3070169307": "铁尖枪",
"3079462611": "驭浪的海祇民",
"3090373787": "暗巷的酒与诗",
"3097441915": "以理服人",
"3112448011": "决心",
"3112679155": "终末嗟叹之诗",
"3156385731": "昭心",
"3169209451": "弓藏",
"3192689683": "霜葬",
"3221566250": "琴",
"3235324891": "护摩之杖",
"3252085691": "顺风而行",
"3258658763": "嗜魔",
"3265161211": "注能之锋",
"3273999011": "黑岩绯玉",
"3277782506": "菲谢尔",
"3302787771": "霜葬",
"3305772819": "奔袭战术",
"3314157803": "克柔",
"3337185491": "浅濑之弭",
"3337249451": "过载",
"3339083250": "可莉",
"3344622722": "丽莎",
"3364338659": "无边际的眷顾",
"3371922315": "神樱神游神乐舞",
"3378007475": "黑岩长剑",
"3400133546": "五郎",
"3406113971": "顺风而行",
"3421967235": "吃虎鱼刀",
"3439749859": "苍翠猎弓",
"3443142923": "龙脊长枪",
"3447737235": "黑岩战弓",
"3456986819": "嘟嘟可故事集",
"3465493459": "精准",
"3500935003": "讨龙英杰谭",
"3535784755": "勇士之心",
"3541083923": "角斗士",
"3555115602": "托马",
"3584825427": "学徒笔记",
"3587062891": "千岩诀·同心",
"3587621259": "笛剑",
"3600623979": "猎弓",
"3608180322": "迪卢克",
"3618167299": "学士",
"3625393819": "试作澹月",
"3626268211": "来歆余响",
"3673792067": "旅行剑",
"3684723963": "雨裁",
"3689108098": "埃洛伊",
"3717667418": "优菈",
"3717849275": "薙草之稻光",
"3719372715": "甲级宝珏",
"3722933411": "试作古华",
"3755004051": "西风长枪",
"3762437019": "(test)穿模测试",
"3775299170": "芭芭拉",
"3782508715": "游医",
"3796702635": "变化万端",
"3796905611": "黑剑",
"3816664530": "旅行者",
"3827789435": "宗室秘法录",
"3832443723": "不屈",
"3836188467": "无羁的朱赤之蝶",
"3847143266": "达达利亚",
"3862787418": "钟离",
"3890292467": "教官",
"3898539027": "浮游四方的灵云",
"3914045794": "珊瑚宫心海",
"3914951691": "赤角石溃杵",
"3933622347": "天空之翼",
"3949653579": "幽夜华尔兹",
"3966753539": "洗濯诸类之形",
"3975746731": "鸦羽弓",
"3995710363": "狼的末路",
"3996017211": "收割",
"3999792907": "祭水之人",
"4000770243": "街巷伏击",
"4022012131": "乘胜追击",
"4049410651": "决斗之枪",
"4055003299": "天空之刃",
"4060235987": "日月辉",
"4080317355": "勇气",
"4082302819": "守护之心",
"4090429643": "沐浴龙血的剑",
"4103022435": "铁蜂刺",
"4103766499": "黑缨枪",
"4108620722": "阿贝多",
"4113638323": "昭理的鸢之枪",
"4119663210": "凯亚",
"4122509083": "断浪长鳍",
"4124851547": "雾切之回光",
"4127888970": "凝光",
"4137694339": "(test)竿测试",
"4139294531": "信使",
"4144069251": "追忆之注连",
"4158505619": "天空之脊",
"4160147242": "雷泽",
"4162981171": "(test)穿模测试",
"4186179883": "破魔之弓",
"4193089947": "桂木斩长正",
"4197635682": "行秋",
"4226083179": "名士振舞",
"4230231107": "若水",
"4245213187": "注能之卷",
"4258047555": "极夜二重奏",
"4260733330": "罗莎莉亚",
"4267718859": "反曲弓",
"4273845410": "辛焱",
"4275754179": "如狼般狩猎者",
"FIGHT_PROP_MAX_HP": "生命值上限",
"FIGHT_PROP_ATTACK": "攻击力",
"FIGHT_PROP_DEFENSE": "防御力",
"FIGHT_PROP_ELEMENT_MASTERY": "元素精通",
"FIGHT_PROP_CRITICAL": "暴击率",
"FIGHT_PROP_CRITICAL_HURT": "暴击伤害",
"FIGHT_PROP_HEAL_ADD": "治疗加成",
"FIGHT_PROP_HEALED_ADD": "受治疗加成",
"FIGHT_PROP_CHARGE_EFFICIENCY": "元素充能效率",
"FIGHT_PROP_SHIELD_COST_MINUS_RATIO": "护盾强效",
"FIGHT_PROP_FIRE_ADD_HURT": "火元素伤害加成",
"FIGHT_PROP_WATER_ADD_HURT": "水元素伤害加成",
"FIGHT_PROP_GRASS_ADD_HURT": "草元素伤害加成",
"FIGHT_PROP_ELEC_ADD_HURT": "雷元素伤害加成",
"FIGHT_PROP_WIND_ADD_HURT": "风元素伤害加成",
"FIGHT_PROP_ICE_ADD_HURT": "冰元素伤害加成",
"FIGHT_PROP_ROCK_ADD_HURT": "岩元素伤害加成",
"FIGHT_PROP_PHYSICAL_ADD_HURT": "物理伤害加成",
"level": "等级"
}

View File

@ -0,0 +1,31 @@
{
"FIGHT_PROP_BASE_ATTACK": "基础攻击力",
"FIGHT_PROP_BASE_DEFENSE": "基础防御力",
"FIGHT_PROP_BASE_HP": "基础血量",
"FIGHT_PROP_ATTACK": "攻击力",
"FIGHT_PROP_ATTACK_PERCENT": "百分比攻击力",
"FIGHT_PROP_HP": "血量",
"FIGHT_PROP_HP_PERCENT": "百分比血量",
"FIGHT_PROP_DEFENSE": "防御力",
"FIGHT_PROP_DEFENSE_PERCENT": "百分比防御力",
"FIGHT_PROP_ELEMENT_MASTERY": "元素精通",
"FIGHT_PROP_CRITICAL": "暴击率",
"FIGHT_PROP_CRITICAL_HURT": "暴击伤害",
"FIGHT_PROP_CHARGE_EFFICIENCY": "元素充能效率",
"FIGHT_PROP_FIRE_SUB_HURT": "火元素抗性",
"FIGHT_PROP_ELEC_SUB_HURT": "雷元素抗性",
"FIGHT_PROP_ICE_SUB_HURT": "冰元素抗性",
"FIGHT_PROP_WATER_SUB_HURT": "水元素抗性",
"FIGHT_PROP_WIND_SUB_HURT": "风元素抗性",
"FIGHT_PROP_ROCK_SUB_HURT": "岩元素抗性",
"FIGHT_PROP_GRASS_SUB_HURT": "草元素抗性",
"FIGHT_PROP_FIRE_ADD_HURT": "火元素伤害加成",
"FIGHT_PROP_ELEC_ADD_HURT": "雷元素伤害加成",
"FIGHT_PROP_ICE_ADD_HURT": "冰元素伤害加成",
"FIGHT_PROP_WATER_ADD_HURT": "水元素伤害加成",
"FIGHT_PROP_WIND_ADD_HURT": "风元素伤害加成",
"FIGHT_PROP_ROCK_ADD_HURT": "岩元素伤害加成",
"FIGHT_PROP_GRASS_ADD_HURT": "草元素伤害加成",
"FIGHT_PROP_PHYSICAL_ADD_HURT": "物理伤害加成",
"FIGHT_PROP_HEAL_ADD": "治疗加成"
}

View File

@ -0,0 +1,156 @@
import os
from typing import Union, Optional
import httpx
import ujson
from model.base import GameItem
from model.game.artifact import ArtifactInfo
from model.game.character import CharacterInfo, CharacterValueInfo
from model.game.fetter import FetterInfo
from model.game.skill import Skill
from model.game.talent import Talent
from model.game.weapon import WeaponInfo
from .helpers import get_headers
class PlayerCardsAPI:
UI_URL = "https://enka.shinshin.moe/ui/"
def __init__(self):
self.client = httpx.AsyncClient(headers=get_headers())
project_path = os.path.dirname(__file__)
characters_map_file = os.path.join(project_path, "metadata", "CharactersMap.json")
name_text_map_hash_file = os.path.join(project_path, "metadata", "NameTextMapHash.json")
reliquary_name_map_file = os.path.join(project_path, "metadata", "ReliquaryNameMap.json")
with open(characters_map_file, "r", encoding="utf-8") as f:
self._characters_map_json: dict = ujson.load(f)
with open(name_text_map_hash_file, "r", encoding="utf-8") as f:
self._name_text_map_hash_json: dict = ujson.load(f)
with open(reliquary_name_map_file, "r", encoding="utf-8") as f:
self._reliquary_name_map_json: dict = ujson.load(f)
def get_characters_name(self, item_id: Union[int, str]) -> str:
if isinstance(item_id, int):
item_id = str(item_id)
characters = self.get_characters(item_id)
name_text_map_hash = characters.get("NameTextMapHash", "-1")
return self.get_text(str(name_text_map_hash))
def get_characters(self, item_id: Union[int, str]) -> dict:
if isinstance(item_id, int):
item_id = str(item_id)
return self._characters_map_json.get(item_id, {})
def get_text(self, hash_value: Union[int, str]) -> str:
if isinstance(hash_value, int):
hash_value = str(hash_value)
return self._name_text_map_hash_json.get(hash_value, "")
def get_reliquary_name(self, reliquary: str) -> str:
return self._reliquary_name_map_json[reliquary]
async def get_data(self, uid: Union[str, int]):
url = f"https://enka.shinshin.moe/u/{uid}/__data.json"
response = await self.client.get(url)
return response
def data_handler(self, avatar_data: dict, avatar_id: int) -> CharacterInfo:
artifact_list = []
weapon_info: Optional[WeaponInfo] = None
equip_list = avatar_data["equipList"] # 圣遗物和武器相关
fetter_info = avatar_data["fetterInfo"] # 好感等级
fight_prop_map = avatar_data["fightPropMap"] # 属性
# inherent_proud_skill_list = avatar_data["inherentProudSkillList"] # 不知道
prop_map = avatar_data["propMap"] # 角色等级 其他信息
# proud_skill_extra_level_map = avatar_data["proudSkillExtraLevelMap"] # 不知道
# skill_depot_id = avatar_data["skillDepotId"] # 不知道
skill_level_map = avatar_data["skillLevelMap"] # 技能等级
# 角色等级
character_level = prop_map['4001']['val']
# 角色姓名
character_name = self.get_characters_name(avatar_id)
characters_data = self.get_characters(avatar_id)
# 圣遗物和武器
for equip in equip_list:
if "reliquary" in equip: # 圣遗物
flat = equip["flat"]
reliquary = equip["reliquary"]
reliquary_main_stat = flat["reliquaryMainstat"]
reliquary_sub_stats = flat['reliquarySubstats']
sub_item = []
for reliquary_sub in reliquary_sub_stats:
sub_item.append(GameItem(name=self.get_reliquary_name(reliquary_sub["appendPropId"]),
item_type=reliquary_sub["appendPropId"], value=reliquary_sub["statValue"]))
main_item = GameItem(name=self.get_reliquary_name(reliquary_main_stat["mainPropId"]),
item_type=reliquary_main_stat["mainPropId"],
value=reliquary_main_stat["statValue"])
name = self.get_text(flat["nameTextMapHash"])
artifact_list.append(ArtifactInfo(item_id=equip["itemId"], name=name, star=flat["rankLevel"],
level=reliquary["level"] - 1, main_item=main_item, sub_item=sub_item))
if "weapon" in equip: # 武器
flat = equip["flat"]
weapon_data = equip["weapon"]
# 防止未精炼
if 'promoteLevel' in weapon_data:
weapon_level = weapon_data['promoteLevel'] - 1
else:
weapon_level = 0
if 'affixMap' in weapon_data:
affix = list(weapon_data['affixMap'].values())[0] + 1
else:
affix = 1
reliquary_main_stat = flat["weaponStats"][0]
reliquary_sub_stats = flat['weaponStats'][1]
sub_item = GameItem(name=self.get_reliquary_name(reliquary_main_stat["appendPropId"]),
item_type=reliquary_sub_stats["appendPropId"],
value=reliquary_sub_stats["statValue"])
main_item = GameItem(name=self.get_reliquary_name(reliquary_main_stat["appendPropId"]),
item_type=reliquary_main_stat["appendPropId"],
value=reliquary_main_stat["statValue"])
weapon_name = self.get_text(flat["nameTextMapHash"])
weapon_info = WeaponInfo(item_id=equip["itemId"], name=weapon_name, star=flat["rankLevel"],
level=weapon_level, main_item=main_item, sub_item=sub_item, affix=affix)
# 好感度
fetter = FetterInfo(fetter_info["expLevel"])
# 基础数值处理
for i in range(40, 47):
if fight_prop_map[str(i)] > 0:
dmg_bonus = fight_prop_map[str(i)]
break
else:
dmg_bonus = 0
base_value = CharacterValueInfo(fight_prop_map["2000"], fight_prop_map["1"], fight_prop_map["2001"],
fight_prop_map["4"], fight_prop_map["2002"], fight_prop_map["7"],
fight_prop_map["28"], fight_prop_map["20"], fight_prop_map["22"],
fight_prop_map["23"], fight_prop_map["26"], fight_prop_map["27"],
fight_prop_map["29"], fight_prop_map["30"], dmg_bonus)
# 技能处理
skill_list = []
skills = characters_data["Skills"]
for skill_id in skill_level_map:
skill_list.append(Skill(skill_id, name=skill_level_map[skill_id], icon=skills[skill_id]))
# 命座处理
talent_list = []
consts = characters_data["Consts"]
if 'talentIdList' in avatar_data:
talent_id_list = avatar_data["talentIdList"]
for index, value in enumerate(talent_id_list):
talent_list.append(Talent(talent_id_list[index], icon=consts[index]))
element = characters_data["Element"]
icon = characters_data["SideIconName"]
character_info = CharacterInfo(character_name, element, character_level, fetter, base_value, weapon_info,
artifact_list, skill_list, talent_list, icon)
return character_info

62
model/base.py Normal file
View File

@ -0,0 +1,62 @@
import imghdr
from enum import Enum
from typing import Union
from model.baseobject import BaseObject
class Stat:
def __init__(self, view_num: int = 0, reply_num: int = 0, like_num: int = 0, bookmark_num: int = 0,
forward_num: int = 0):
self.forward_num = forward_num # 关注数
self.bookmark_num = bookmark_num # 收藏数
self.like_num = like_num # 喜欢数
self.reply_num = reply_num # 回复数
self.view_num = view_num # 观看数
class ArtworkInfo:
def __init__(self):
self.user_id: int = 0
self.artwork_id: int = 0 # 作品ID
self.site = ""
self.title: str = "" # 标题
self.origin_url: str = ""
self.site_name: str = ""
self.tags: list = []
self.stat: Stat = Stat()
self.create_timestamp: int = 0
self.info = None
class ArtworkImage:
def __init__(self, art_id: int, page: int = 0, is_error: bool = False, data: bytes = b""):
self.art_id = art_id
self.data = data
self.is_error = is_error
if not is_error:
self.format: str = imghdr.what(None, self.data)
self.page = page
class ServiceEnum(Enum):
"""
该名称来源于米忽悠的安卓BBS包名结尾考虑到大部分重要的功能确实是在移动端实现了
"""
NULL = None
HYPERION = 1 # 米忽悠国服
HOYOLAB = 2 # 米忽悠国际服
class GameItem(BaseObject):
def __init__(self, item_id: int = 0, name: str = "", item_type: Union[Enum, str, int] = "",
value: Union[Enum, str, int, bool, float] = 0):
self.item_id = item_id
self.name = name # 名称
self.type = item_type # 类型
self.value = value # 数值
__slots__ = ("name", "type", "value", "item_id")

103
model/baseobject.py Normal file
View File

@ -0,0 +1,103 @@
import json
from copy import deepcopy
from typing import Dict, Union, Optional, List
from model.types import JSONDict
class BaseObject:
"""
大多数数据对象的基础类型
"""
def __new__(cls, *args: object, **kwargs: object) -> "BaseObject":
instance = super().__new__(cls)
return instance
def __str__(self) -> str:
return str(self.to_dict())
def __getitem__(self, item: str) -> object:
try:
return getattr(self, item)
except AttributeError as exc:
raise KeyError(
f"Objects of type {self.__class__.__name__} don't have an attribute called "
f"`{item}`."
) from exc
def __getstate__(self) -> Dict[str, Union[str, object]]:
return self._get_attrs(include_private=True, recursive=False)
def __setstate__(self, state: dict) -> None:
for key, val in state.items():
setattr(self, key, val)
def __deepcopy__(self, memodict: dict = None):
if memodict is None:
memodict = {}
cls = self.__class__
result = cls.__new__(cls) # 创建新实例
attrs = self._get_attrs(include_private=True) # 获取其所有属性
for k in attrs: # 在DeepCopy对象中设置属性
setattr(result, k, deepcopy(getattr(self, k), memodict))
return result
# 添加插槽可减少内存使用,并允许更快的属性访问
__slots__ = ()
def _get_attrs(self, include_private: bool = False, recursive: bool = False, ) -> Dict[str, Union[str, object]]:
data = {}
if not recursive:
try:
# __dict__ 具有来自超类的属性因此无需在下面的for循环中输入
data.update(self.__dict__)
except AttributeError:
pass
# 我们希望使用self获取类的所有属性但如果使用 self.__slots__ ,仅包括该类本身使用的属性,而不是它的超类
# 因此我们得到它的MRO然后再得到它们的属性
# 使用“[-1]”切片排除了“object”类
for cls in self.__class__.__mro__[:-1]:
for key in cls.__slots__: # 忽略 属性已定义
if not include_private and key.startswith("_"):
continue
value = getattr(self, key, None)
if value is not None:
if recursive and hasattr(value, "to_dict"):
data[key] = value.to_dict()
else:
data[key] = value
elif not recursive:
data[key] = value
return data
@staticmethod
def _parse_data(data: Optional[JSONDict]) -> Optional[JSONDict]:
return None if data is None else data.copy()
@classmethod
def de_json(cls, data: Optional[JSONDict]):
data = cls._parse_data(data)
if data is None:
return None
if cls == BaseObject:
return cls()
return cls(**data)
@classmethod
def de_list(cls, data: Optional[List[JSONDict]]) -> List:
if not data:
return []
return [cls.de_json(d) for d in data]
def to_json(self) -> str:
return json.dumps(self.to_dict())
def to_dict(self) -> JSONDict:
return self._get_attrs(recursive=True)

67
model/gacha/banner.py Normal file
View File

@ -0,0 +1,67 @@
from enum import Enum
from model.gacha.common import ItemParamData
class BannerType(Enum):
STANDARD = 1
EVENT = 2
WEAPON = 3
class GachaBanner:
def __init__(self):
self.gachaType: int = 0
self.scheduleId: int = 0
self.prefabPath: str = ""
self.previewPrefabPath: str = ""
self.titlePath: str = ""
self.costItemId = 0
self.costItemAmount = 1
self.costItemId10 = 0
self.costItemAmount10 = 10
self.beginTime: int = 0
self.endTime: int = 0
self.sortId: int = 0
self.rateUpItems4 = {}
self.rateUpItems5 = {}
self.fallbackItems3 = {11301, 11302, 11306, 12301, 12302, 12305, 13303, 14301, 14302, 14304, 15301, 15302,
15304}
self.fallbackItems4Pool1 = {1014, 1020, 1023, 1024, 1025, 1027, 1031, 1032, 1034, 1036, 1039, 1043, 1044, 1045,
1048, 1053, 1055, 1056, 1064}
self.fallbackItems4Pool2 = {11401, 11402, 11403, 11405, 12401, 12402, 12403, 12405, 13401, 13407, 14401, 14402,
14403, 14409, 15401, 15402, 15403, 15405}
self.fallbackItems5Pool1 = {1003, 1016, 1042, 1035, 1041}
self.fallbackItems5Pool2 = {11501, 11502, 12501, 12502, 13502, 13505, 14501, 14502, 15501, 15502}
self.removeC6FromPool = False
self.autoStripRateUpFromFallback = True
self.weights4 = {{1, 510}, {8, 510}, {10, 10000}}
self.weights5 = {{1, 75}, {73, 150}, {90, 10000}}
self.poolBalanceWeights4 = {{1, 255}, {17, 255}, {21, 10455}}
self.poolBalanceWeights5 = {{1, 30}, {147, 150}, {181, 10230}}
self.eventChance4 = 50
self.eventChance5 = 50
self.bannerType = BannerType.STANDARD
self.rateUpItems1 = {}
self.rateUpItems2 = {}
self.eventChance = -1
self.costItem = 0
def getGachaType(self):
return self.gachaType
def getCost(self, numRolls: int):
"""
获取消耗的Item
:param numRolls:
:return:
"""
if numRolls == 1:
return ItemParamData()
elif numRolls == 10:
return ItemParamData(self.costItemId10 if self.costItemId10 > 0 else self.getCostItem(),
self.costItemAmount10)
return ItemParamData()
def getCostItem(self):
return self.costItem if self.costItem > 0 else self.costItemId

4
model/gacha/common.py Normal file
View File

@ -0,0 +1,4 @@
class ItemParamData:
def __init__(self, item_id: int = 0, count: int = 0):
self.id: int = item_id
self.count: int = count

7
model/gacha/manager.py Normal file
View File

@ -0,0 +1,7 @@
class GachaManager:
def __init__(self):
pass
def DoPulls(self, player_id: int, gacha_type: int, times: int):
pass

51
model/game/artifact.py Normal file
View File

@ -0,0 +1,51 @@
from enum import Enum
from typing import Union, Optional, List
from model.base import GameItem
from model.baseobject import BaseObject
from model.types import JSONDict
class ArtifactInfo(BaseObject):
"""
圣遗物信息
"""
def __init__(self, item_id: int = 0, name: str = "", level: int = 0, main_item: Optional[GameItem] = None,
pos: Union[Enum, str] = "", star: int = 1, sub_item: Optional[List[GameItem]] = None, icon: str = ""):
"""
:param item_id: item_id
:param name: 圣遗物名字
:param level: 圣遗物等级
:param main_item: 主词条
:param pos: 圣遗物类型
:param star: 星级
:param sub_item: 副词条
:param icon: 图片
"""
self.icon = icon
self.item_id = item_id
self.level = level
self.main_item = main_item
self.name = name
self.pos = pos
self.star = star
self.sub_item: List[GameItem] = []
if sub_item is not None:
self.sub_item = sub_item
def to_dict(self) -> JSONDict:
data = super().to_dict()
if self.sub_item:
data["sub_item"] = [e.to_dict() for e in self.sub_item]
return data
@classmethod
def de_json(cls, data: Optional[JSONDict]) -> Optional["ArtifactInfo"]:
data = cls._parse_data(data)
if not data:
return None
data["sub_item"] = GameItem.de_list(data.get("sub_item"))
return cls(**data)
__slots__ = ("name", "type", "value", "pos", "star", "sub_item", "main_item", "level", "item_id", "icon")

123
model/game/character.py Normal file
View File

@ -0,0 +1,123 @@
from typing import Optional, List
from model.baseobject import BaseObject
from model.game.artifact import ArtifactInfo
from model.game.fetter import FetterInfo
from model.game.skill import Skill
from model.game.talent import Talent
from model.game.weapon import WeaponInfo
from model.types import JSONDict
class CharacterValueInfo(BaseObject):
"""角色数值信息
"""
def __init__(self, hp: float = 0, base_hp: float = 0, atk: float = 0, base_atk: float = 0,
def_value: float = 0, base_def: float = 0, elemental_mastery: float = 0, crit_rate: float = 0,
crit_dmg: float = 0, energy_recharge: float = 0, heal_bonus: float = 0, healed_bonus: float = 0,
physical_dmg_sub: float = 0, physical_dmg_bonus: float = 0, dmg_bonus: float = 0):
"""
:param hp: 生命值
:param base_hp: 基础生命值
:param atk: 攻击力
:param base_atk: 基础攻击力
:param def_value: 防御力
:param base_def: 基础防御力
:param elemental_mastery: 元素精通
:param crit_rate: 暴击率
:param crit_dmg: 暴击伤害
:param energy_recharge: 充能效率
:param heal_bonus: 治疗
:param healed_bonus: 受治疗
:param physical_dmg_sub: 物理伤害加成
:param physical_dmg_bonus: 物理伤害抗性
:param dmg_bonus: 伤害加成
"""
self.dmg_bonus = dmg_bonus
self.physical_dmg_bonus = physical_dmg_bonus
self.physical_dmg_sub = physical_dmg_sub
self.healed_bonus = healed_bonus
self.heal_bonus = heal_bonus
self.energy_recharge = energy_recharge
self.crit_dmg = crit_dmg
self.crit_rate = crit_rate
self.elemental_mastery = elemental_mastery
self.base_def = base_def
self.def_value = def_value
self.base_atk = base_atk
self.atk = atk
self.base_hp = base_hp
self.hp = hp
@property
def add_hp(self) -> float:
return self.hp - self.base_hp
@property
def add_atk(self) -> float:
return self.atk - self.base_atk
@property
def add_def(self) -> float:
return self.def_value - self.base_def
__slots__ = (
"hp", "base_hp", "atk", "base_atk", "def_value", "base_def", "elemental_mastery", "crit_rate", "crit_dmg",
"energy_recharge", "dmg_bonus", "physical_dmg_bonus", "physical_dmg_sub", "healed_bonus",
"heal_bonus")
class CharacterInfo(BaseObject):
"""角色信息
"""
def __init__(self, name: str = "", elementl: str = 0, level: int = 0, fetter: Optional[FetterInfo] = None,
base_value: Optional[CharacterValueInfo] = None, weapon: Optional[WeaponInfo] = None,
artifact: Optional[List[ArtifactInfo]] = None, skill: Optional[List[Skill]] = None,
talent: Optional[List[Talent]] = None, icon: str = ""):
"""
:param name: 角色名字
:param level: 角色等级
:param elementl: 属性
:param fetter: 好感度
:param base_value: 基础数值
:param weapon: 武器
:param artifact: 圣遗物
:param skill: 技能
:param talent: 命座
:param icon: 角色图片
"""
self.icon = icon
self.elementl = elementl
self.talent = talent
self.skill = skill
self.artifact = artifact
self.weapon = weapon
self.base_value = base_value
self.fetter = fetter
self.level = level
self.name = name
def to_dict(self) -> JSONDict:
data = super().to_dict()
if self.artifact:
data["artifact"] = [e.to_dict() for e in self.artifact]
if self.artifact:
data["skill"] = [e.to_dict() for e in self.skill]
if self.artifact:
data["talent"] = [e.to_dict() for e in self.talent]
return data
@classmethod
def de_json(cls, data: Optional[JSONDict]) -> Optional["CharacterInfo"]:
data = cls._parse_data(data)
if not data:
return None
data["artifact"] = ArtifactInfo.de_list(data.get("sub_item"))
data["skill"] = Skill.de_list(data.get("sub_item"))
data["talent"] = Talent.de_list(data.get("sub_item"))
return cls(**data)
__slots__ = (
"name", "level", "level", "fetter", "base_value", "weapon", "artifact", "skill", "talent", "elementl", "icon")

15
model/game/fetter.py Normal file
View File

@ -0,0 +1,15 @@
from model.baseobject import BaseObject
class FetterInfo(BaseObject):
"""
好感度信息
"""
def __init__(self, level: int = 0):
"""
:param level: 等级
"""
self.level = level
__slots__ = ("level",)

21
model/game/skill.py Normal file
View File

@ -0,0 +1,21 @@
from model.baseobject import BaseObject
class Skill(BaseObject):
"""
技能信息
"""
def __init__(self, skill_id: int = 0, name: str = "", level: int = 0, icon: str = ""):
"""
:param skill_id: 技能ID
:param name: 技能名称
:param level: 技能等级
:param icon: 技能图标
"""
self.icon = icon
self.level = level
self.name = name
self.skill_id = skill_id
__slots__ = ("skill_id", "name", "level", "icon")

19
model/game/talent.py Normal file
View File

@ -0,0 +1,19 @@
from model.baseobject import BaseObject
class Talent(BaseObject):
"""
命座
"""
def __init__(self, talent_id: int = 0, name: str = "", icon: str = ""):
"""
:param talent_id: 命座ID
:param name: 命座名字
:param icon: 图标
"""
self.icon = icon
self.name = name
self.talent_id = talent_id
__slots__ = ("talent_id", "name", "icon")

36
model/game/weapon.py Normal file
View File

@ -0,0 +1,36 @@
from enum import Enum
from typing import Union, Optional
from model.base import GameItem
from model.baseobject import BaseObject
class WeaponInfo(BaseObject):
"""武器信息
"""
def __init__(self, item_id: int = 0, name: str = "", level: int = 0, main_item: Optional[GameItem] = None,
affix: int = 0, pos: Union[Enum, str] = "", star: int = 1, sub_item: Optional[GameItem] = None,
icon: str = ""):
"""
:param item_id: item_id
:param name: 武器名字
:param level: 武器等级
:param main_item: 主词条
:param affix: 精炼等级
:param pos: 武器类型
:param star: 星级
:param sub_item: 副词条
:param icon: 图片
"""
self.affix = affix
self.icon = icon
self.item_id = item_id
self.level = level
self.main_item = main_item
self.name = name
self.pos = pos
self.star = star
self.sub_item = sub_item
__slots__ = ("name", "type", "value", "pos", "star", "sub_item", "main_item", "level", "item_id", "icon", "affix")

5
model/types.py Normal file
View File

@ -0,0 +1,5 @@
from typing import Dict, Any, Callable, TypeVar
JSONDict = Dict[str, Any]
Func = TypeVar("Func", bound=Callable[..., Any])

189
model/wiki/characters.py Normal file
View File

@ -0,0 +1,189 @@
import re
from typing import Optional
import httpx
from bs4 import BeautifulSoup
from .helpers import get_headers
class Characters:
CHARACTERS_LIST_URL = "https://genshin.honeyhunterworld.com/db/char/characters/?lang=CHS"
ROOT_URL = "https://genshin.honeyhunterworld.com"
def __init__(self):
self.client = httpx.AsyncClient(headers=get_headers())
async def _get_soup(self, url: str) -> Optional[BeautifulSoup]:
request = await self.client.get(url)
return BeautifulSoup(request.text, "lxml")
async def get_all_characters_url(self):
url_list = []
soup = await self._get_soup(self.CHARACTERS_LIST_URL)
character_list = soup.find_all('div', {'class': 'char_sea_cont'})
for character in character_list:
name = character.find("span", {"class": "sea_charname"}).text
if "旅行者" in name:
continue
character_link = self.ROOT_URL + character.a['href']
url_list.append(character_link)
return url_list
def get_characters_info_template(self):
characters_info_dict = {
"name": "",
"title": "",
"rarity": 0,
"element": {"name": "", "icon": ""},
"description": "",
"constellations": {},
"skills": {
"normal_attack": self.get_skills_info_template(),
"skill_e": self.get_skills_info_template(),
"skill_q": self.get_skills_info_template(),
"skill_replace": self.get_skills_info_template(),
},
"gacha_splash": ""
}
return characters_info_dict
@staticmethod
def get_skills_info_template():
skills_info_dict = {
"icon": "",
"name": "",
"description": ""
}
return skills_info_dict
async def get_characters(self, url: str):
characters_info_dict = self.get_characters_info_template()
soup = await self._get_soup(url)
main_content = soup.find("div", {'class': 'wrappercont'})
char_name = main_content.find('div', {'class': 'custom_title'}).text
characters_info_dict["name"] = char_name
# 基础信息
char_info_table = main_content.find('table', {'class': 'item_main_table'}).find_all('tr')
for char_info_item in char_info_table:
content = char_info_item.find_all('td')
if content[0].text == "Title":
char_title = content[1].text
characters_info_dict["title"] = char_title
if content[0].text == "Allegiance":
char_allegiance = content[1].text
characters_info_dict["allegiance"] = char_allegiance
if content[0].text == "Rarity":
char_rarity = len(content[1].find_all('div', {'class': 'sea_char_stars_wrap'}))
characters_info_dict["rarity"] = char_rarity
if content[0].text == "Element":
char_element_icon_url = self.ROOT_URL + content[1].find('img')['data-src'].replace("_35", "")
characters_info_dict["element"]["icon"] = char_element_icon_url
if content[0].text == "Astrolabe Name":
char_astrolabe_name = content[1].text
if content[0].text == "In-game Description":
char_description = content[1].text
characters_info_dict["description"] = char_description
# 角色属性表格 咕咕咕
skill_dmg_wrapper = main_content.find('div', {'class': 'skilldmgwrapper'}).find_all('tr')
# 命之座
constellations_title = main_content.find('span', {'class': 'item_secondary_title'}, string="Constellations")
constellations_table = constellations_title.findNext('table', {'class': 'item_main_table'}).find_all('tr')
constellations_list = []
constellations_list_index = 0
for index, value in enumerate(constellations_table):
# 判断第一行
if index % 2 == 0:
constellations_dict = {
"icon": "",
"name": "",
"description": ""
}
constellations_list.append(constellations_dict)
icon_url = self.ROOT_URL + value.find_all('img', {'class': 'itempic'})[-1]['data-src']
constellations_name = value.find_all('a', href=re.compile("/db/skill"))[-1].text
constellations_list[constellations_list_index]["icon"] = icon_url
constellations_list[constellations_list_index]["name"] = constellations_name
if index % 2 == 1:
constellations_description = value.find('div', {'class': 'skill_desc_layout'}).text
constellations_list[constellations_list_index]["description"] = constellations_description
constellations_list_index += 1
characters_info_dict["constellations"] = constellations_list
# 技能
skills_title = main_content.find('span', string='Attack Talents')
# 普攻
normal_attack_area = skills_title.find_next_sibling()
normal_attack_info = normal_attack_area.find_all('tr')
normal_attack_icon = self.ROOT_URL + normal_attack_info[0].find('img', {'class': 'itempic'})['data-src']
normal_attack_name = normal_attack_info[0].find('a', href=re.compile('/db/skill/')).text
normal_attack_desc = normal_attack_info[1].find('div', {'class': 'skill_desc_layout'}).text.replace(" ", "\n")
normal_attack = characters_info_dict["skills"]["normal_attack"]
normal_attack["icon"] = normal_attack_icon
normal_attack["name"] = normal_attack_name
normal_attack["description"] = normal_attack_desc
normal_attack_table_area = normal_attack_area.find_next_sibling()
# normal_attack_table = normal_attack_table_area.find_all('tr')
skill_e_area = normal_attack_table_area.find_next_sibling()
skill_e_info = skill_e_area.find_all('tr')
skill_e_icon = self.ROOT_URL + skill_e_info[0].find('img', {'class': 'itempic'})['data-src']
skill_e_name = skill_e_info[0].find('a', href=re.compile('/db/skill/')).text
skill_e_desc = skill_e_info[1].find('div', {'class': 'skill_desc_layout'}).text.replace(" ", "\n")
skill_e = characters_info_dict["skills"]["skill_e"]
skill_e["icon"] = skill_e_icon
skill_e["name"] = skill_e_name
skill_e["description"] = skill_e_desc
skill_e_table_area = skill_e_area.find_next_sibling()
# skillE_table = skillE_table_area.find_all('tr')
load_another_talent_q: bool = False
if char_name in ("神里绫华", "莫娜"):
load_another_talent_q = True
skill_q_area = skill_e_table_area.find_next_sibling()
skill_q_info = skill_q_area.find_all('tr')
skill_q_icon = self.ROOT_URL + skill_q_info[0].find('img', {'class': 'itempic'})['data-src']
skill_q_name = skill_q_info[0].find('a', href=re.compile('/db/skill/')).text
skill_q_desc = skill_q_info[1].find('div', {'class': 'skill_desc_layout'}).text.replace(" ", "\n")
skill_q_table_area = skill_q_area.find_next_sibling()
# skill_q_table = skill_q_table_area.find_all('tr')
if load_another_talent_q:
skill_replace = characters_info_dict["skills"]["skill_replace"]
skill_replace["icon"] = skill_q_icon
skill_replace["name"] = skill_q_name
skill_replace["description"] = skill_q_desc
else:
skill_q = characters_info_dict["skills"]["skill_q"]
skill_q["icon"] = skill_q_icon
skill_q["name"] = skill_q_name
skill_q["description"] = skill_q_desc
if load_another_talent_q:
skill_q2_area = skill_q_table_area.find_next_sibling()
skill_q2_info = skill_q2_area.find_all('tr')
skill_q2_icon = self.ROOT_URL + skill_q2_info[0].find('img', {'class': 'itempic'})['data-src']
skill_q2_name = skill_q2_info[0].find('a', href=re.compile('/db/skill/')).text
skill_q2_desc = skill_q2_info[1].find('div', {'class': 'skill_desc_layout'}).text.replace(" ", "\n")
skill_q2 = characters_info_dict["skills"]["skill_q"]
skill_q2["icon"] = skill_q2_icon
skill_q2["name"] = skill_q2_name
skill_q2["description"] = skill_q2_desc
# 角色图片
char_pic_area = main_content.find('span', string='Character Gallery').find_next_sibling()
all_char_pic = char_pic_area.find("div", {"class": "gallery_cont"})
gacha_splash_text = all_char_pic.find("span", {"class": "gallery_cont_span"}, string="Gacha Splash")
gacha_splash_pic_url = self.ROOT_URL + gacha_splash_text.previous_element.previous_element["data-src"].replace(
"_70", "")
characters_info_dict["gacha_splash"] = gacha_splash_pic_url
return characters_info_dict

27
model/wiki/helpers.py Normal file
View File

@ -0,0 +1,27 @@
import re
ID_RGX = re.compile(r"/db/[^.]+_(?P<id>\d+)")
def get_headers():
headers = {
"accept": "text/html,application/xhtml+xml,application/xml;"
"q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.63 Safari/537.36",
"referer": "https://genshin.honeyhunterworld.com/db/char/hutao/?lang=CHS",
}
return headers
def get_id_form_url(url: str):
matches = ID_RGX.search(url)
if matches is None:
return -1
entries = matches.groupdict()
if entries is None:
return -1
try:
return int(entries.get('id'))
except (IndexError, ValueError, TypeError):
return -1

View File

@ -0,0 +1,85 @@
{
"504": {
"city": "Mondstadt",
"name": "高塔孤王",
"star": {
"value": 5,
"icon": "https://genshin.honeyhunterworld.com/img/back/item/5star.png"
},
"icon": "https://genshin.honeyhunterworld.com/img/upgrade/weapon/i_504.png"
},
"524": {
"city": "Mondstadt",
"name": "凛风奔狼",
"star": {
"value": 5,
"icon": "https://genshin.honeyhunterworld.com/img/back/item/5star.png"
},
"icon": "https://genshin.honeyhunterworld.com/img/upgrade/weapon/i_524.png"
},
"544": {
"city": "Mondstadt",
"name": "狮牙斗士",
"star": {
"value": 5,
"icon": "https://genshin.honeyhunterworld.com/img/back/item/5star.png"
},
"icon": "https://genshin.honeyhunterworld.com/img/upgrade/weapon/i_544.png"
},
"514": {
"city": "Liyue",
"name": "孤云寒林",
"star": {
"value": 5,
"icon": "https://genshin.honeyhunterworld.com/img/back/item/5star.png"
},
"Key": "Weapon_Guyun",
"icon": "https://genshin.honeyhunterworld.com/img/upgrade/weapon/i_514.png"
},
"534": {
"city": "Liyue",
"name": "雾海云间",
"star": {
"value": 5,
"icon": "https://genshin.honeyhunterworld.com/img/back/item/5star.png"
},
"icon": "https://genshin.honeyhunterworld.com/img/upgrade/weapon/i_534.png"
},
"554": {
"city": "Liyue",
"name": "漆黑陨铁",
"star": {
"value": 5,
"icon": "https://genshin.honeyhunterworld.com/img/back/item/5star.png"
},
"icon": "https://genshin.honeyhunterworld.com/img/upgrade/weapon/i_554.png"
},
"564": {
"city": "Inazuma",
"name": "远海夷地",
"star": {
"value": 5,
"icon": "https://genshin.honeyhunterworld.com/img/back/item/5star.png"
},
"Key": "Weapon_DistantSea",
"icon": "https://genshin.honeyhunterworld.com/img/upgrade/weapon/i_564.png"
},
"574": {
"city": "Inazuma",
"name": "鸣神御灵",
"star": {
"value": 5,
"icon": "https://genshin.honeyhunterworld.com/img/back/item/5star.png"
},
"icon": "https://genshin.honeyhunterworld.com/img/upgrade/weapon/i_574.png"
},
"584": {
"city": "Inazuma",
"name": "今昔剧画",
"star": {
"value": 5,
"icon": "https://genshin.honeyhunterworld.com/img/back/item/5star.png"
},
"icon": "https://genshin.honeyhunterworld.com/img/upgrade/weapon/i_584.png"
}
}

View File

@ -0,0 +1,92 @@
{
"63": {
"name": "号角",
"star": {
"value": 4,
"icon": "https://genshin.honeyhunterworld.com/img/back/item/4star.png"
},
"key": "Elite_Horn",
"icon": "https://genshin.honeyhunterworld.com/img/upgrade/material/i_63.png"
},
"73": {
"name": "地脉",
"star": {
"value": 4,
"icon": "https://genshin.honeyhunterworld.com/img/back/item/4star.png"
},
"key": "Elite_LeyLine",
"icon": "https://genshin.honeyhunterworld.com/img/upgrade/material/i_73.png"
},
"83": {
"name": "混沌",
"star": {
"value": 4,
"icon": "https://genshin.honeyhunterworld.com/img/back/item/4star.png"
},
"key": "Elite_Chaos",
"icon": "https://genshin.honeyhunterworld.com/img/upgrade/material/i_83.png"
},
"93": {
"name": "雾虚",
"star": {
"value": 4,
"icon": "https://genshin.honeyhunterworld.com/img/back/item/4star.png"
},
"key": "Elite_MistGrass",
"icon": "https://genshin.honeyhunterworld.com/img/upgrade/material/i_93.png"
},
"103": {
"name": "祭刀",
"star": {
"value": 4,
"icon": "https://genshin.honeyhunterworld.com/img/back/item/4star.png"
},
"key": "Elite_SacrificialKnife",
"icon": "https://genshin.honeyhunterworld.com/img/upgrade/material/i_103.png"
},
"143": {
"name": "骨片",
"star": {
"value": 4,
"icon": "https://genshin.honeyhunterworld.com/img/back/item/4star.png"
},
"key": "Elite_BoneShard",
"icon": "https://genshin.honeyhunterworld.com/img/upgrade/material/i_143.png"
},
"153": {
"name": "刻像",
"star": {
"value": 4,
"icon": "https://genshin.honeyhunterworld.com/img/back/item/4star.png"
},
"key": "Elite_Statuette",
"icon": "https://genshin.honeyhunterworld.com/img/upgrade/material/i_153.png"
},
"173": {
"name": "混沌2",
"star": {
"value": 4,
"icon": "https://genshin.honeyhunterworld.com/img/back/item/4star.png"
},
"key": "Elite_Chaos2",
"icon": "https://genshin.honeyhunterworld.com/img/upgrade/material/i_173.png"
},
"176": {
"name": "隐兽",
"star": {
"value": 4,
"icon": "https://genshin.honeyhunterworld.com/img/back/item/4star.png"
},
"key": "Elite_Concealed",
"icon": "https://genshin.honeyhunterworld.com/img/upgrade/material/i_176.png"
},
"183": {
"name": "棱镜",
"star": {
"value": 4,
"icon": "https://genshin.honeyhunterworld.com/img/back/item/4star.png"
},
"key": "Elite_Prism",
"icon": "https://genshin.honeyhunterworld.com/img/upgrade/material/i_183.png"
}
}

View File

@ -0,0 +1,83 @@
{
"23": {
"name": "史莱姆",
"star": {
"value": 3,
"icon": "https://genshin.honeyhunterworld.com/img/back/item/3star.png"
},
"key": "Monster_Slime",
"icon": "https://genshin.honeyhunterworld.com/img/upgrade/material/i_23.png"
},
"33": {
"name": "面具",
"star": {
"value": 3,
"icon": "https://genshin.honeyhunterworld.com/img/back/item/3star.png"
},
"Key": "Monster_Mask",
"icon": "https://genshin.honeyhunterworld.com/img/upgrade/material/i_33.png"
},
"43": {
"name": "绘卷",
"star": {
"value": 3,
"icon": "https://genshin.honeyhunterworld.com/img/back/item/3star.png"
},
"Key": "Monster_Scroll",
"icon": "https://genshin.honeyhunterworld.com/img/upgrade/material/i_43.png"
},
"53": {
"name": "箭簇",
"star": {
"value": 3,
"icon": "https://genshin.honeyhunterworld.com/img/back/item/3star.png"
},
"Key": "Monster_Scroll",
"icon": "https://genshin.honeyhunterworld.com/img/upgrade/material/i_43.png"
},
"113": {
"name": "徽记",
"star": {
"value": 3,
"icon": "https://genshin.honeyhunterworld.com/img/back/item/3star.png"
},
"Key": "Monster_Insignia",
"icon": "https://genshin.honeyhunterworld.com/img/upgrade/material/i_113.png"
},
"123": {
"name": "鸦印",
"star": {
"value": 3,
"icon": "https://genshin.honeyhunterworld.com/img/back/item/3star.png"
},
"Key": "Monster_RavenInsignia",
"icon": "https://genshin.honeyhunterworld.com/img/upgrade/material/i_123.png"
},
"133": {
"name": "花蜜",
"star": {
"value": 3,
"icon": "https://genshin.honeyhunterworld.com/img/back/item/3star.png"
},
"Key": "Monster_Nectar",
"icon": "https://genshin.honeyhunterworld.com/img/upgrade/material/i_133.png"
},
"163": {
"name": "刀镡",
"star": {
"value": 3,
"icon": "https://genshin.honeyhunterworld.com/img/back/item/3star.png"
},
"Key": "Monster_Handguard",
"icon": "https://genshin.honeyhunterworld.com/img/upgrade/material/i_163.png"
},
"187": {
"name": "浮游",
"star": {
"value": 3,
"icon": "https://genshin.honeyhunterworld.com/img/back/item/3star.png"
},
"Key": "Monster_Spectral",
"icon": "https://genshin.honeyhunterworld.com/img/upgrade/material/i_187.png"
}
}

210
model/wiki/weapons.py Normal file
View File

@ -0,0 +1,210 @@
import os
from enum import Enum
from typing import Optional
import httpx
import ujson
from bs4 import BeautifulSoup
from .helpers import get_headers, get_id_form_url
class WeaponType(Enum):
Sword = "sword" # 单手剑
Claymore = "claymore" # 双手剑
PoleArm = "polearm" # 长柄武器
Bow = "bow" # 弓
Catalyst = "catalyst" # 法器
class Weapons:
IGNORE_WEAPONS_ID = [
"1001", "1101", "1406",
"2001", "2101", "2204", "2406", "2407",
"3001", "3101", "3204", "3404",
"4001", "4101", "4201", "4403", "4405", "4406",
"5001", "5101", "5201", "5404", "5404", "5405",
] # 忽略的武器包括一星、二星武器beta表格内无名武器未上架到正服的武器
# 根地址
ROOT_URL = "https://genshin.honeyhunterworld.com"
TEXT_MAPPING = {
"Type": "类型",
"Rarity": "Rarity",
"Base Attack": "基础攻击力"
}
WEAPON_TYPE_MAPPING = {
"Sword": "https://genshin.honeyhunterworld.com/img/skills/s_33101.png", # 单手剑
"Claymore": "https://genshin.honeyhunterworld.com/img/skills/s_163101.png", # 双手剑
"Polearm": "https://genshin.honeyhunterworld.com/img/skills/s_233101.png", # 长枪
"Bow": "https://genshin.honeyhunterworld.com/img/skills/s_213101.png", # 弓箭
"Catalyst": "https://genshin.honeyhunterworld.com/img/skills/s_43101.png", # 法器
}
def __init__(self):
self.client = httpx.AsyncClient(headers=get_headers())
project_path = os.path.dirname(__file__)
characters_file = os.path.join(project_path, "metadata", "ascension.json")
monster_file = os.path.join(project_path, "metadata", "monster.json")
elite_file = os.path.join(project_path, "metadata", "elite.json")
with open(characters_file, "r", encoding="utf-8") as f:
self._ascension_json: dict = ujson.load(f)
with open(monster_file, "r", encoding="utf-8") as f:
self._monster_json: dict = ujson.load(f)
with open(elite_file, "r", encoding="utf-8") as f:
self._elite_json: dict = ujson.load(f)
async def _get_soup(self, url: str) -> Optional[BeautifulSoup]:
request = await self.client.get(url)
return BeautifulSoup(request.text, "lxml")
async def get_weapon_url_list(self, weapon_type: WeaponType):
weapon_url_list = []
url = self.ROOT_URL + f"/db/weapon/{weapon_type.value}/?lang=CHS"
soup = await self._get_soup(url)
weapon_table = soup.find("span", {"class": "item_secondary_title"},
string="Released (Codex) Weapons").find_next_sibling()
weapon_table_rows = weapon_table.find_all("tr")
for weapon_table_row in weapon_table_rows:
content = weapon_table_row.find_all("td")[2]
if content.find("a") is not None:
weapon_url = self.ROOT_URL + content.find("a")["href"]
weapon_id = str(get_id_form_url(weapon_url))
if weapon_id not in self.IGNORE_WEAPONS_ID:
weapon_url_list.append(weapon_url)
return weapon_url_list
async def get_all_weapon_url(self):
all_weapon_url = []
temp_data = await self.get_weapon_url_list(WeaponType.Bow)
all_weapon_url.extend(temp_data)
temp_data = await self.get_weapon_url_list(WeaponType.Sword)
all_weapon_url.extend(temp_data)
temp_data = await self.get_weapon_url_list(WeaponType.PoleArm)
all_weapon_url.extend(temp_data)
temp_data = await self.get_weapon_url_list(WeaponType.Catalyst)
all_weapon_url.extend(temp_data)
temp_data = await self.get_weapon_url_list(WeaponType.Claymore)
all_weapon_url.extend(temp_data)
return all_weapon_url
@staticmethod
def get_weapon_info_template():
weapon_info_dict = {
"name": "",
"description": "",
"source_img": "",
"atk":
{
"min": 0,
"max": 999999,
"name": "基础攻击力"
},
"secondary":
{
"min": 0.1,
"max": 999999.9,
"name": ""
},
"star":
{
"value": -1,
"icon": ""
},
"type":
{
"name": "",
"icon": ""
},
"passive_ability":
{
"name": "",
"description": ""
}
}
materials_dict = {
"name": "",
"star": {
"value": 0,
"icon": ""
},
"city": "",
"icon": ""
}
weapon_info_dict["materials"] = {
"ascension": materials_dict,
"elite": materials_dict,
"monster": materials_dict,
}
return weapon_info_dict
async def get_weapon_info(self, url: str):
weapon_info_dict = self.get_weapon_info_template()
soup = await self._get_soup(url)
weapon_content = soup.find("div", {"class": "wrappercont"})
data = weapon_content.find("div", {"class": "data_cont_wrapper", "style": "display: block"})
weapon_info = data.find("table", {"class": "item_main_table"})
weapon_name = weapon_content.find("div", {"class": "custom_title"}).text.replace("-", "").replace(" ", "")
weapon_info_dict["name"] = weapon_name
weapon_info_row = weapon_info.find_all("tr")
for weapon_info_ in weapon_info_row:
content = weapon_info_.find_all("td")
if len(content) == 3: # 第一行会有三个td其中一个td是武器图片
weapon_info_dict["source_img"] = self.ROOT_URL + content[0].find("img",
{"class": "itempic lazy"})["data-src"]
weapon_info_dict["type"]["name"] = content[2].text
weapon_info_dict["type"]["icon"] = self.get_weapon_type(content[2].text)
elif len(content) == 2:
if content[0].text == "Rarity":
weapon_info_dict["star"]["value"] = len(
content[1].find_all("div", {"class": "sea_char_stars_wrap"}))
elif content[0].text == "Special (passive) Ability":
weapon_info_dict["passive_ability"]["name"] = content[1].text
elif content[0].text == "Special (passive) Ability Description":
weapon_info_dict["passive_ability"]["description"] = content[1].text
elif content[0].text == "In-game Description":
weapon_info_dict["description"] = content[1].text
elif content[0].text == "Secondary Stat":
weapon_info_dict["secondary"]["name"] = content[1].text
stat_table = data.find("span", {"class": "item_secondary_title"},
string=" Stat Progression ").find_next_sibling()
stat_table_row = stat_table.find_all("tr")
for stat_table_ in stat_table_row:
content = stat_table_.find_all("td")
# 通过等级判断
if content[0].text == "1":
weapon_info_dict["atk"]["min"] = int(content[1].text)
weapon_info_dict["secondary"]["min"] = float(content[2].text)
elif content[0].text == "80+":
item_hrefs = content[3].find_all("a")
for item_href in item_hrefs:
item_id = get_id_form_url(item_href["href"])
ascension = self.get_ascension(str(item_id))
if ascension.get("name") is not None:
weapon_info_dict["materials"]["ascension"] = ascension
monster = self.get_monster(str(item_id))
if monster.get("name") is not None:
weapon_info_dict["materials"]["monster"] = monster
elite = self.get_elite(str(item_id))
if elite.get("name") is not None:
weapon_info_dict["materials"]["elite"] = elite
elif content[0].text == "90":
weapon_info_dict["atk"]["max"] = int(content[1].text)
weapon_info_dict["secondary"]["max"] = float(content[2].text)
return weapon_info_dict
def get_ascension(self, item_id: str):
return self._ascension_json.get(item_id, {})
def get_monster(self, item_id: str):
return self._monster_json.get(item_id, {})
def get_elite(self, item_id: str):
return self._elite_json.get(item_id, {})
def get_weapon_type(self, weapon_type: str):
return self.WEAPON_TYPE_MAPPING.get(weapon_type, "")

52
plugins/README.md Normal file
View File

@ -0,0 +1,52 @@
# plugins 目录
## 说明
该目录仅限处理交互层和业务层数据交换的任务
如有任何新业务接口,请转到 `service` 目录添加
如有任何API请求接口请转到 `model` 目录添加
## 基础代码
``` python
from telegram.ext import CommandHandler, CallbackContext
from logger import Log
from utils.decorators.error import error_callable
from utils.decorators.restricts import restricts
from utils.plugins.manager import listener_plugins_class
@listener_plugins_class()
class Example:
@classmethod
def create_handlers(cls):
example = cls()
return [CommandHandler('example', example.command_start)]
@error_callable
@restricts()
async def command_start(self, update: Update, context: CallbackContext) -> None:
user = update.effective_user
Log.info(f"用户 {user.full_name}[{user.id}] 发出example命令")
await message.reply_text("Example")
```
### 注意
plugins 模块下的类必须提供 `create_handlers` 类方法作为构建相应处理程序给 `handle.py`
在函数注册为命令处理过程(如 `CommandHandler` )需要添加 `error_callable` 修饰器作为错误统一处理
如果套引用服务,参数需要声明需要引用服务的类型,并且添加 `inject` 修饰器
必要的函数必须捕获异常后通知用户或者直接抛出异常
入口函数必须使用 `@restricts()` 修饰器 预防洪水攻击
只需在构建的类前加上 `@listener_plugins_class()` 修饰器即可向程序注册插件
**注意:`@restricts()` 修饰器带参,必须带括号,否则会出现调用错误**

112
plugins/abyss.py Normal file
View File

@ -0,0 +1,112 @@
from genshin import Client
from telegram import Update
from telegram.constants import ChatAction
from telegram.ext import CommandHandler, MessageHandler, filters, CallbackContext
from app.cookies.service import CookiesService
from app.template.service import TemplateService
from app.user import UserService
from app.user.repositories import UserNotFoundError
from logger import Log
from plugins.base import BasePlugins
from utils.app.inject import inject
from utils.helpers import get_genshin_client, url_to_file
from utils.plugins.manager import listener_plugins_class
@listener_plugins_class()
class Abyss(BasePlugins):
@classmethod
def create_handlers(cls) -> list:
abyss = cls()
return [
CommandHandler("abyss", abyss.command_start, block=False),
MessageHandler(filters.Regex(r"^深渊数据查询(.*)"), abyss.command_start, block=True)
]
@staticmethod
def _get_role_star_bg(value: int):
if value == 4:
return "./background/roleStarBg4.png"
elif value == 5:
return "./background/roleStarBg5.png"
else:
raise ValueError("错误的数据")
async def _get_abyss_data(self, client: Client) -> dict:
uid = client.uid
spiral_abyss_info = await client.get_spiral_abyss(uid)
if not spiral_abyss_info.unlocked:
raise ValueError("unlocked is false")
ranks = spiral_abyss_info.ranks
if len(spiral_abyss_info.ranks.most_kills) == 0:
raise ValueError("本次深渊旅行者还没挑战呢")
abyss_data = {
"uid": uid,
"max_floor": spiral_abyss_info.max_floor,
"total_battles": spiral_abyss_info.total_battles,
"total_stars": spiral_abyss_info.total_stars,
"most_played_list": [],
"most_kills": {
"icon": await url_to_file(ranks.most_kills[0].icon),
"value": ranks.most_kills[0].value,
},
"strongest_strike": {
"icon": await url_to_file(ranks.strongest_strike[0].icon),
"value": ranks.strongest_strike[0].value
},
"most_damage_taken": {
"icon": await url_to_file(ranks.most_damage_taken[0].icon),
"value": ranks.most_damage_taken[0].value
},
"most_bursts_used": {
"icon": await url_to_file(ranks.most_bursts_used[0].icon),
"value": ranks.most_bursts_used[0].value
},
"most_skills_used": {
"icon": await url_to_file(ranks.most_skills_used[0].icon),
"value": ranks.most_skills_used[0].value
}
}
# most_kills
most_played_list = ranks.most_played
for most_played in most_played_list:
temp = {
"icon": await url_to_file(most_played.icon),
"value": most_played.value,
"background": self._get_role_star_bg(most_played.rarity)
}
abyss_data["most_played_list"].append(temp)
return abyss_data
@inject
async def command_start(self, update: Update, context: CallbackContext, user_service: UserService,
cookies_service: CookiesService, template_service: TemplateService) -> None:
user = update.effective_user
message = update.message
Log.info(f"用户 {user.full_name}[{user.id}] 查深渊挑战命令请求")
await message.reply_chat_action(ChatAction.TYPING)
try:
client = await get_genshin_client(user.id, user_service, cookies_service)
abyss_data = await self._get_abyss_data(client)
except UserNotFoundError:
reply_message = await message.reply_text("未查询到账号信息,请先私聊派蒙绑定账号")
if filters.ChatType.GROUPS.filter(message):
self._add_delete_message_job(context, reply_message.chat_id, reply_message.message_id, 30)
self._add_delete_message_job(context, message.chat_id, message.message_id, 30)
return
except ValueError as exc:
if "unlocked is false" in str(exc):
await message.reply_text("本次深渊旅行者还没挑战呢,咕咕咕~~~")
return
if "本次深渊旅行者还没挑战呢" in str(exc):
await message.reply_text("本次深渊旅行者还没挑战呢,咕咕咕~~~")
return
raise exc
await message.reply_chat_action(ChatAction.UPLOAD_PHOTO)
png_data = await template_service.render('genshin/abyss', "abyss.html", abyss_data,
{"width": 690, "height": 504}, full_page=False)
await message.reply_photo(png_data, filename=f"abyss_{user.id}.png",
allow_sending_without_reply=True)
return

80
plugins/admin.py Normal file
View File

@ -0,0 +1,80 @@
from telegram import Update
from telegram.error import BadRequest, Forbidden
from telegram.ext import CallbackContext, CommandHandler
from app.admin import BotAdminService
from logger import Log
from utils.app.inject import inject
from utils.decorators.admins import bot_admins_rights_check
from utils.plugins.manager import listener_plugins_class
@listener_plugins_class()
class Admin:
"""有关BOT ADMIN处理"""
@classmethod
def create_handlers(cls) -> list:
admin = cls()
return [
CommandHandler("add_admin", admin.add_admin, block=False),
CommandHandler("del_admin", admin.del_admin, block=False),
CommandHandler("leave_chat", admin.leave_chat, block=False),
]
@bot_admins_rights_check
@inject
async def add_admin(self, update: Update, _: CallbackContext, bot_admin_service: BotAdminService):
message = update.message
reply_to_message = message.reply_to_message
if reply_to_message is None:
await message.reply_text("请回复对应消息")
else:
admin_list = await bot_admin_service.get_admin_list()
if reply_to_message.from_user.id in admin_list:
await message.reply_text("该用户已经存在管理员列表")
else:
await bot_admin_service.add_admin(reply_to_message.from_user.id)
await message.reply_text("添加成功")
@bot_admins_rights_check
@inject
async def del_admin(self, update: Update, _: CallbackContext, bot_admin_service: BotAdminService):
message = update.message
reply_to_message = message.reply_to_message
admin_list = await bot_admin_service.get_admin_list()
if reply_to_message is None:
await message.reply_text("请回复对应消息")
else:
if reply_to_message.from_user.id in admin_list:
await bot_admin_service.delete_admin(reply_to_message.from_user.id)
await message.reply_text("删除成功")
else:
await message.reply_text("该用户不存在管理员列表")
@bot_admins_rights_check
async def leave_chat(self, update: Update, context: CallbackContext):
message = update.message
try:
args = message.text.split()
if len(args) >= 2:
chat_id = int(args[1])
else:
await message.reply_text("输入错误")
return
except ValueError as error:
Log.error("获取 chat_id 发生错误! 错误信息为 \n", error)
await message.reply_text("输入错误")
return
try:
try:
char = await context.bot.get_chat(chat_id)
await message.reply_text(f"正在尝试退出群 {char.title}[{char.id}]")
except (BadRequest, Forbidden):
pass
await context.bot.leave_chat(chat_id)
except (BadRequest, Forbidden) as error:
Log.error(f"退出 chat_id[{chat_id}] 发生错误! 错误信息为 \n", error)
await message.reply_text(f"退出 chat_id[{chat_id}] 发生错误! 错误信息为 {str(error)}")
return
await message.reply_text(f"退出 chat_id[{chat_id}] 成功!")

83
plugins/base.py Normal file
View File

@ -0,0 +1,83 @@
import datetime
from typing import Callable
from telegram import Update, ReplyKeyboardRemove
from telegram.error import BadRequest
from telegram.ext import CallbackContext, ConversationHandler, filters
from app.admin import BotAdminService
from logger import Log
from utils.app.inject import inject
async def clean_message(context: CallbackContext, chat_id: int, message_id: int) -> bool:
try:
await context.bot.delete_message(chat_id=chat_id, message_id=message_id)
return True
except BadRequest as error:
if "not found" in str(error):
Log.warning(f"定时删除消息 chat_id[{chat_id}] message_id[{message_id}]失败 消息不存在")
elif "Message can't be deleted" in str(error):
Log.warning(f"定时删除消息 chat_id[{chat_id}] message_id[{message_id}]失败 消息无法删除 可能是没有授权")
else:
Log.warning(f"定时删除消息 chat_id[{chat_id}] message_id[{message_id}]失败 \n", error)
return False
def add_delete_message_job(context: CallbackContext, chat_id: int, message_id: int,
delete_seconds: int = 60):
context.job_queue.scheduler.add_job(clean_message, "date",
id=f"{chat_id}|{message_id}|auto_clean_message",
name=f"{chat_id}|{message_id}|auto_clean_message",
args=[context, chat_id, message_id],
run_date=context.job_queue._tz_now() + datetime.timedelta(
seconds=delete_seconds), replace_existing=True)
class BasePlugins:
@staticmethod
async def cancel(update: Update, _: CallbackContext) -> int:
await update.message.reply_text("退出命令", reply_markup=ReplyKeyboardRemove())
return ConversationHandler.END
@staticmethod
async def _clean(context: CallbackContext, chat_id: int, message_id: int) -> bool:
return await clean_message(context, chat_id, message_id)
@staticmethod
def _add_delete_message_job(context: CallbackContext, chat_id: int, message_id: int,
delete_seconds: int = 60):
return add_delete_message_job(context, chat_id, message_id, delete_seconds)
class NewChatMembersHandler:
def __init__(self, auth_callback: Callable):
self.auth_callback = auth_callback
@inject
async def new_member(self, update: Update, context: CallbackContext, bot_admin_service: BotAdminService) -> None:
message = update.message
chat = message.chat
from_user = message.from_user
quit_status = False
if filters.ChatType.GROUPS.filter(message):
for user in message.new_chat_members:
if user.id == context.bot.id:
if from_user is not None:
Log.info(f"用户 {from_user.full_name}[{from_user.id}] 在群 {chat.title}[{chat.id}] 邀请BOT")
admin_list = await bot_admin_service.get_admin_list()
if from_user.id in admin_list:
await context.bot.send_message(message.chat_id,
'感谢邀请小派蒙到本群!请使用 /help 查看咱已经学会的功能。')
else:
quit_status = True
else:
Log.info(f"未知用户 在群 {chat.title}[{chat.id}] 邀请BOT")
quit_status = True
if quit_status:
Log.warning("不是管理员邀请!退出群聊。")
await context.bot.send_message(message.chat_id, "派蒙不想进去!不是旅行者的邀请!")
await context.bot.leave_chat(chat.id)
await self.auth_callback(update, context)

88
plugins/errorhandler.py Normal file
View File

@ -0,0 +1,88 @@
import html
import traceback
from typing import Optional
import ujson
from telegram import Update, ReplyKeyboardRemove, Message
from telegram.constants import ParseMode
from telegram.error import BadRequest
from telegram.ext import CallbackContext
from config import config
from logger import Log
try:
notice_chat_id = config.TELEGRAM["notice"]["ERROR"]["chat_id"]
except KeyError as error:
Log.warning("错误通知Chat_id获取失败或未配置BOT发生致命错误时不会收到通知 错误信息为\n", error)
notice_chat_id = None
async def error_handler(update: object, context: CallbackContext) -> None:
"""
记录错误并发送消息通知开发人员
Log the error and send a telegram message to notify the developer.
"""
Log.error(msg="处理函数时发生异常:", exc_info=context.error)
if notice_chat_id is None:
return
tb_list = traceback.format_exception(None, context.error, context.error.__traceback__)
tb_string = ''.join(tb_list)
update_str = update.to_dict() if isinstance(update, Update) else str(update)
text_1 = (
f'<b>处理函数时发生异常</b> \n'
f'Exception while handling an update \n'
f'<pre>update = {html.escape(ujson.dumps(update_str, indent=2, ensure_ascii=False))}'
'</pre>\n\n'
f'<pre>context.chat_data = {html.escape(str(context.chat_data))}</pre>\n\n'
f'<pre>context.user_data = {html.escape(str(context.user_data))}</pre>\n\n'
)
text_2 = (
f'<pre>{html.escape(tb_string)}</pre>'
)
try:
if 'make sure that only one bot instance is running' in tb_string:
Log.error("其他机器人在运行,请停止!")
return
await context.bot.send_message(notice_chat_id, text_1, parse_mode=ParseMode.HTML)
await context.bot.send_message(notice_chat_id, text_2, parse_mode=ParseMode.HTML)
except BadRequest as exc:
if 'too long' in str(exc):
text = (
f'<b>处理函数时发生异常traceback太长导致无法发送但已写入日志</b> \n'
f'<code>{html.escape(str(context.error))}</code>'
)
try:
await context.bot.send_message(notice_chat_id, text, parse_mode=ParseMode.HTML)
except BadRequest:
text = (
'<b>处理函数时发生异常traceback太长导致无法发送但已写入日志</b> \n')
try:
await context.bot.send_message(notice_chat_id, text, parse_mode=ParseMode.HTML)
except BadRequest as exc_1:
Log.error("处理函数时发生异常", exc_1)
effective_user = update.effective_user
try:
message: Optional[Message] = None
if update.callback_query is not None:
message = update.callback_query.message
if update.message is not None:
message = update.message
if update.edited_message is not None:
message = update.edited_message
if message is not None:
chat = message.chat
Log.info(f"尝试通知用户 {effective_user.full_name}[{effective_user.id}] "
f"{chat.full_name}[{chat.id}]"
f"的 update_id[{update.update_id}] 错误信息")
text = f"派蒙这边发生了点问题无法处理!\n" \
f"如果当前有对话请发送 /cancel 退出对话。\n" \
f"错误信息为 <code>{html.escape(str(context.error))}</code>"
await context.bot.send_message(message.chat_id, text, reply_markup=ReplyKeyboardRemove(),
parse_mode=ParseMode.HTML)
except BadRequest as exc:
Log.error(f"发送 update_id[{update.update_id}] 错误信息失败 错误信息为 {str(exc)}")
pass

53
plugins/help.py Normal file
View File

@ -0,0 +1,53 @@
from telegram import Update
from telegram.constants import ChatAction
from telegram.error import BadRequest
from telegram.ext import CommandHandler, CallbackContext
from app.template.service import TemplateService
from config import config
from logger import Log
from utils.app.inject import inject
from utils.decorators.error import error_callable
from utils.decorators.restricts import restricts
from utils.plugins.manager import listener_plugins_class
@listener_plugins_class()
class Help:
"""
帮助
"""
def __init__(self):
self.help_png = None
self.file_id = None
@classmethod
def create_handlers(cls) -> list:
_help = cls()
return [
CommandHandler("help", _help.command_start, block=False),
]
@error_callable
@restricts()
@inject
async def command_start(self, update: Update, _: CallbackContext, template_service: TemplateService) -> None:
message = update.message
user = update.effective_user
Log.info(f"用户 {user.full_name}[{user.id}] 发出help命令")
if self.file_id is None or config.DEBUG:
await message.reply_chat_action(ChatAction.TYPING)
help_png = await template_service.render('bot/help', "help.html", {}, {"width": 768, "height": 768})
await message.reply_chat_action(ChatAction.UPLOAD_PHOTO)
reply_photo = await message.reply_photo(help_png, filename="help.png", allow_sending_without_reply=True)
photo = reply_photo.photo[0]
self.file_id = photo.file_id
else:
try:
await message.reply_chat_action(ChatAction.UPLOAD_PHOTO)
await message.reply_photo(self.file_id, allow_sending_without_reply=True)
except BadRequest as error:
self.file_id = None
Log.error("发送图片失败尝试清空已经保存的file_id错误信息为", error)
await message.reply_text("发送图片失败", allow_sending_without_reply=True)

44
plugins/start.py Normal file
View File

@ -0,0 +1,44 @@
from telegram import Update, ReplyKeyboardRemove
from telegram.ext import CallbackContext
from telegram.helpers import escape_markdown
from utils.decorators.restricts import restricts
@restricts()
async def start(update: Update, context: CallbackContext) -> None:
user = update.effective_user
message = update.message
args = context.args
if args is not None:
if len(args) >= 1:
if args[0] == "inline_message":
await message.reply_markdown_v2(f"你好 {user.mention_markdown_v2()} {escape_markdown('!我是派蒙 ')}\n"
f"{escape_markdown('发送 /help 命令即可查看命令帮助')}")
return
await update.message.reply_markdown_v2(f"你好 {user.mention_markdown_v2()} {escape_markdown('!我是派蒙 ')}")
@restricts()
async def help_command(update: Update, _: CallbackContext) -> None:
await update.message.reply_text("前面的区域,以后再来探索吧!")
@restricts()
async def unknown_command(update: Update, _: CallbackContext) -> None:
await update.message.reply_text("前面的区域,以后再来探索吧!")
@restricts()
async def emergency_food(update: Update, _: CallbackContext) -> None:
await update.message.reply_text("派蒙才不是应急食品!")
@restricts()
async def ping(update: Update, _: CallbackContext) -> None:
await update.message.reply_text("online! ヾ(✿゚▽゚)")
@restricts()
async def reply_keyboard_remove(update: Update, _: CallbackContext) -> None:
await update.message.reply_text("移除远程键盘成功", reply_markup=ReplyKeyboardRemove())

20
requirements.txt Normal file
View File

@ -0,0 +1,20 @@
redis>=4.3.3
ujson>=5.3.0
genshin>=1.2.1
aiomysql>=0.1.1
colorlog~=6.6.0
numpy~=1.22.3
httpx==0.23.0
asyncio>=3.4.3
jinja2>=3.1.2
aiofiles>=0.8.0
playwright==1.22.0
pymysql>=0.9.3
beautifulsoup4>=4.11.1
pyppeteer~=1.0.2
lxml>=4.9.0
fakeredis>=1.8.1
aiohttp<=3.8.1
python-telegram-bot==20.0a2
pytz>=2021.3
Pillow

15
resources/README.md Normal file
View File

@ -0,0 +1,15 @@
# resource 目录说明
用于给用户交互的前端界面,使用 `Jinja2` 渲染模板后,
再使用无头浏览器 `playwright` 截图发送给用户
## background 来源
原神官方
## 使用的styles
| ProjectName | Contribution |
|:-------------------------------------------------:|------------------|
| [tailwindcss](https://tailwindcss.com/) | 本项目使用的CSS框架 |
| [fontawesome](https://fontawesome.dashgame.com/) | 一套绝佳的图标字体库和CSS框架 |

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@ -0,0 +1,90 @@
body {
background-color: rgba(253, 253, 253, 0.75);
}
#container {
max-width: 768px;
}
.info {
background: url(background/2020021114213984258.png) no-repeat center;
box-shadow: 0 2px 4px rgb(0 0 0 / 10%);
background-size: 780px 120px;
}
.command-title {
text-align: center;
line-height: 40px;
border: 1px solid #e0dad3;
}
.command-title h1 {
margin: 2px;
background-color: #e0dad3;
}
.command {
flex: 1 1 0;
border-radius: 0.75rem;
padding: 5px;
margin: 10px;
background: #f5f5f5;
border: 1px solid #e0dad3;
box-shadow: 0 2px 4px rgb(0 0 0 / 10%);
}
.command i {
margin-left: 0.5rem;
}
.base-command {
border: 1px solid #e0dad3;
background-color: #f0ece8;
box-shadow: 0 2px 4px rgb(0 0 0 / 10%);
}
.command-background {
opacity: 0.1;
z-index: -1;
}
.command-background img {
margin-left: 400px;
margin-top: -10px;
position: absolute;
}
.genshin-background {
opacity: 0.3;
z-index: -1;
}
.genshin-background img {
margin-left: 150px;
margin-top: 100px;
position: absolute;
}
.command-name {
font-size: 1.25rem;
line-height: 1.75rem;
margin: 4px;
}
.command-description {
font-size: 1rem;
line-height: 1.5rem;
margin: 4px;
}
.about {
text-align: center;
line-height: 40px;
border: 1px solid #e0dad3;
}
.about h1 {
margin: 2px;
background-color: #e0dad3;
}

View File

@ -0,0 +1,144 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<link href="../../styles/tailwind.min.css" rel="stylesheet">
<link href="../../styles/font-awesome.min.css" rel="stylesheet">
<link href="help.css" rel="stylesheet">
</head>
<body>
<div class="container mx-auto px-5 py-10" id="container">
<div class="info p-6 flex flex-wrap mb-8 rounded-xl">
<div class="info-name">
<h1 class="text-4xl italic">TGPaimonBot</h1>
<h1 class="text-2xl">使用说明</h1>
</div>
<div class="info-name text-1xl ml-60 pt-1">
<p><i class="fa fa-address-card-o mr-2"></i>需要绑定Cookie</p>
</div>
</div>
<div class="base-command pt-4 rounded-xl">
<div class="command-background">
<img src="background/2015.png">
</div>
<div class="genshin-background">
<img src="background/genshin.png">
</div>
<div class="command-type px-1">
<div class="command-title text-2xl">
<h1>查询命令</h1>
</div>
</div>
<div class="command-list py-4 px-2">
<div class="flex">
<div class="command">
<div class="command-name text-xl">/weapon</div>
<div class="command-description text-base ">查询武器</div>
</div>
<div class="command rounded-xl flex-1">
<div class="command-name text-xl">/strategy</div>
<div class="command-description text-base ">查询角色攻略</div>
</div>
</div>
<div class="flex">
<div class="command">
<div class="command-name text-xl ">
/uid
<i class="fa fa-address-card-o ml-2"></i>
</div>
<div class="name text-base ">查询玩家信息</div>
</div>
<div class="command">
<div class="command-name text-xl">
/sign
<i class="fa fa-address-card-o ml-2"></i></div>
<div class="command-description text-base ">每日签到 | 查询</div>
</div>
</div>
<div class="flex">
<div class="command">
<div class="command-name text-xl">
/dailynote
<i class="fa fa-address-card-o ml-2"></i>
</div>
<div class="name text-base1">查询角色当前状态</div>
</div>
<div class="command">
<div class="command-name text-xl">
/artifact_rate
<div class="command-description text-base ">圣遗物评分</div>
</div>
</div>
</div>
<div class="flex">
<div class="command">
<div class="command-name text-xl">
/ledger
<i class="fa fa-address-card-o ml-2"></i>
</div>
<div class="name text-base1">查询角色当月旅行扎记</div>
</div>
<div class="command">
<div class="command-name text-xl">
/abyss
<i class="fa fa-address-card-o ml-2"></i>
</div>
<div class="name text-base1">查询当期深渊螺旋战绩</div>
</div>
</div>
</div>
<div class="base-command pt-4 mb-8 rounded-xl">
<div class="command-background">
<img src="background/1006.png">
</div>
<div class="command-type px-1">
<div class="command-title text-2xl">
<h1>其他命令</h1>
</div>
</div>
<div class="command-list py-4 px-2">
<div class="flex">
<div class="command">
<div class="command-name text-xl">/gacha</div>
<div class="name text-base1">抽卡模拟器(非洲人模拟器)</div>
</div>
<div class="command">
<div class="command-name text-xl">/quiz</div>
<div class="command-description text-bas">派蒙的十万个为什么</div>
</div>
</div>
<div class="flex">
<div class="command">
<div class="command-name text-xl">/adduser</div>
<div class="command-description text-base ">添加账号请私聊BOT</div>
</div>
<div class="command rounded-xl flex-1">
<div class="command-name text-xl">/cancel</div>
<div class="command-description text-base ">取消操作(解决一切玄学问题)</div>
</div>
</div>
</div>
</div>
<div class="base-command pt-4 mb-8 rounded-xl">
<div class="command-type px-1">
<div class="command-title text-2xl">
<h1>inline 模式关键词</h1>
</div>
</div>
<div class="command-list py-4 px-2">
<div class="flex">
<div class="command">
<div class="command-name text-xl">角色名</div>
<div class="name text-base1">查询角色攻略</div>
</div>
</div>
</div>
</div>
<div class="about">
<h1>更多功能,还在咕咕咕!咕咕咕!</h1>
</div>
</div>
</div>
</body>
</html>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="zh-ch">
<head>
<meta charset="UTF-8">
<title>abyss</title>
<link type="text/css" href="../../styles/tailwind.min.css" rel="stylesheet">
<link type="text/css" href="../../styles/public.css" rel="stylesheet">
<style>
#container {
background: url("./background/lookback-bg.png");
}
.character-icon {
width: 128px;
height: 128px;
}
.character-side-icon {
width: 32px;
height: 32px;
}
</style>
</head>
<body>
<div class="mx-auto flex flex-col h-full bg-no-repeat bg-cover" id="container">
<div class="title text-2xl my-4 text-yellow-500 mx-auto">深境螺旋</div>
<div class="base-info flex flex-row px-20 py-1 my-1 text-white bg-white bg-opacity-10">
<div class="uid text-center mx-auto">UID {{uid}}</div>
<div class="text-center mx-auto">最深抵达 {{max_floor}}</div>
<div class="text-center mx-auto">战斗次数 {{total_battles}}</div>
<div class="text-center mx-auto">获得星级 {{total_stars}}</div>
</div>
<div class="base-info flex flex-col px-20 py-1 text-black my-1">
<div class="text-center mr-auto text-yellow-500">出战次数</div>
<div class="mx-auto flex my-2">
{% for most_played in most_played_list %}
<div class="bg-white rounded-lg mx-2">
<div class="character-icon rounded-lg bg-cover"
style="background: url({{most_played.background}});background-size: cover;">
<img src="{{most_played.icon}}" alt=""></div>
<div class="text-center">{{most_played.value}}次</div>
</div>
{% endfor %}
</div>
</div>
<div class="flex flex-col my-1">
<div class="flex flex-col px-20 py-1 text-black my-1">
<div class="text-center mr-auto text-yellow-500">出战次数</div>
</div>
<div>
<div class="base-info flex flex-row px-20 py-1 my-1 text-white bg-black bg-opacity-10 ">
<div class="text-center flex flex-row flex-1 mr-6">
<div class="my-auto">最多击破数:{{most_kills.value}}</div>
<img class="character-side-icon ml-auto" src="{{most_kills.icon}}" alt="">
</div>
<div class="text-center flex flex-row flex-1 mr-6">
<div class="my-auto">最强一击:{{strongest_strike.value}}</div>
<img class="character-side-icon ml-auto" src="{{strongest_strike.icon}}" alt="">
</div>
</div>
<div class="base-info flex flex-row px-20 py-1 my-1 text-white">
<div class="text-center flex flex-row flex-1 mr-6">
<div class="my-auto">承受最多伤害:{{most_damage_taken.value}}</div>
<img class="character-side-icon ml-auto" src="{{most_damage_taken.icon}}" alt="">
</div>
<div class="text-center flex flex-row flex-1 mr-6">
<div class="my-auto">元素爆发数:{{most_bursts_used.value}}</div>
<img class="character-side-icon ml-auto" src="{{most_bursts_used.icon}}" alt="">
</div>
</div>
<div class="base-info flex flex-row px-20 py-1 my-1 text-white bg-black bg-opacity-10 ">
<div class="text-center flex flex-row flex-1 mr-6">
<div class="my-auto">元素战技释放次数:{{most_skills_used.value}}</div>
<img class="character-side-icon ml-auto" src="{{most_skills_used.icon}}" alt="">
</div>
<div class="text-center flex flex-row flex-1 mr-6"></div>
</div>
</div>
</div>
<div class="my-2"></div>
</div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="zh-ch">
<head>
<meta charset="UTF-8">
<title>abyss</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
#container {
background: url("./background/lookback-bg.png");
}
.character-icon {
width: 128px;
height: 128px;
}
.character-side-icon {
width: 32px;
height: 32px;
}
</style>
</head>
<body>
<div class="mx-auto flex flex-col h-full bg-no-repeat bg-cover" id="container">
<div class="title text-2xl my-4 text-yellow-500 mx-auto">深境螺旋</div>
<div class="base-info flex flex-row px-20 py-1 my-1 text-white bg-white bg-opacity-10">
<div class="uid text-center mx-auto">UID 1414514</div>
<div class="text-center mx-auto">最深抵达 12-3</div>
<div class="text-center mx-auto">战斗次数 12</div>
<div class="text-center mx-auto">获得星级 36</div>
</div>
<div class="base-info flex flex-col px-20 py-1 text-black my-1">
<div class="text-center mr-auto text-yellow-500">出战次数</div>
<div class="mx-auto flex my-2">
<div class="bg-white rounded-lg mx-2">
<div class="character-icon rounded-lg bg-cover"
style="background: url('./background/roleStarBg4.png');background-size: cover;">
<img src="./../../img/example/256x256.png" alt=""></div>
<div class="text-center">12次</div>
</div>
<div class="bg-white rounded-lg mx-2">
<div class="character-icon rounded-lg bg-cover"
style="background: url('./background/roleStarBg4.png');background-size: cover;">
<img src="./../../img/example/256x256.png" alt=""></div>
<div class="text-center">12次</div>
</div>
<div class="bg-white rounded-lg mx-2">
<div class="character-icon rounded-lg bg-cover"
style="background: url('./background/roleStarBg5.png');background-size: cover;">
<img src="./../../img/example/256x256.png" alt=""></div>
<div class="text-center">12次</div>
</div>
<div class="bg-white rounded-lg mx-2">
<div class="character-icon rounded-lg bg-cover"
style="background: url('./background/roleStarBg5.png');background-size: cover;">
<img src="./../../img/example/256x256.png" alt=""></div>
<div class="text-center">12次</div>
</div>
</div>
</div>
<div class="flex flex-col my-1">
<div class="flex flex-col px-20 py-1 text-black my-1">
<div class="text-center mr-auto text-yellow-500">出战次数</div>
</div>
<div>
<div class="base-info flex flex-row px-20 py-1 my-1 text-white bg-black bg-opacity-10 ">
<div class="text-center flex flex-row flex-1 mr-6">
<div class="my-auto">最多击破数21</div>
<img class="character-side-icon ml-auto" src="./../../img/example/256x256.png" alt="">
</div>
<div class="text-center flex flex-row flex-1 mr-6">
<div class="my-auto">最强一击21</div>
<img class="character-side-icon ml-auto" src="./../../img/example/256x256.png" alt="">
</div>
</div>
<div class="base-info flex flex-row px-20 py-1 my-1 text-white">
<div class="text-center flex flex-row flex-1 mr-6">
<div class="my-auto">承受最多伤害21</div>
<img class="character-side-icon ml-auto" src="./../../img/example/256x256.png" alt="">
</div>
<div class="text-center flex flex-row flex-1 mr-6">
<div class="my-auto">元素爆发数21</div>
<img class="character-side-icon ml-auto" src="./../../img/example/256x256.png" alt="">
</div>
</div>
<div class="base-info flex flex-row px-20 py-1 my-1 text-white bg-black bg-opacity-10 ">
<div class="text-center flex flex-row flex-1 mr-6">
<div class="my-auto">元素战技释放次数21</div>
<img class="character-side-icon ml-auto" src="./../../img/example/256x256.png" alt="">
</div>
<div class="text-center flex flex-row flex-1 mr-6"></div>
</div>
</div>
</div>
<div class="my-2"></div>
</div>
</body>
</html>

View File

@ -0,0 +1,121 @@
@font-face {
font-family: "tttgbnumber";
src: url("../../fonts/tttgbnumber.ttf");
font-weight: normal;
font-style: normal;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
user-select: none;
}
body {
font-size: 16px;
font-family: "tttgbnumber", system-ui;
transform: scale(1.5);
transform-origin: 0 0;
color: #1e1f20;
}
.container {
width: 400px;
height: 365px;
background: #f0eae3;
padding: 8px 16px;
}
.title {
display: flex;
position: relative;
margin-bottom: 9px;
color: #504c49;
}
.title .id {
flex: 1;
line-height: 18px;
padding-left: 10px;
}
.title .id:before {
content: " ";
position: absolute;
width: 5px;
height: 24px;
border-radius: 1px;
left: 0px;
top: -3px;
background: #d3bc8d;
}
.title .day {
line-height: 18px;
}
.item {
border: 1px solid #dfd8d1;
display: flex;
height: 49px;
margin-top: 5px;
}
.item .main {
display: flex;
flex: 1;
background-color: #f5f1eb;
position: relative;
/* font-weight: bold; */
}
.item .main .bg {
width: 100px;
height: 100%;
position: absolute;
left: 0;
background-size: 100% auto;
background-image: url(./items/bg.png);
}
.item .main .icon {
width: 25px;
height: 25px;
margin: 11px 8px 0 8px;
}
.item .main .info {
padding-top: 7px;
}
.item .main .info .name {
font-size: 14px;
/* color: #5f5f5d; */
line-height: 1;
margin-bottom: 7px;
}
.item .main .info .time {
font-size: 12px;
/* font-weight: 400; */
color: #5f5f5d;
line-height: 1;
}
.item .right {
display: flex;
align-items: center;
justify-content: center;
width: 96px;
height: 100%;
background-color: #ece3d8;
font-size: 16px;
color: #504c49;
line-height: 55px;
}
.item .right .red {
color: #f24e4c;
}

View File

@ -0,0 +1,129 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="content-type" content="text/html;charset=utf-8" />
<link rel="shortcut icon" href="#" />
<link rel="stylesheet" type="text/css" href="./daily_note.css" />
</head>
<body>
<div class="container" id="container">
<div class="title">
<div class="id">
<span>ID{{ uid }}</span>
</div>
<div class="day">
<span>{{ day }}</span>
</div>
</div>
<div class="item">
<div class="main">
<div class="bg"></div>
<img class="icon" src="items/树脂.png" />
<div class="info">
<div class="name">原粹树脂</div>
<div class="time">
{% if resin_recovery_time %}
将于{{ resin_recovery_time }} 全部恢复
{% else %}
树脂已完全恢复
{% endif %}
</div>
</div>
</div>
<div class="right">
<span>{{ current_resin }}/{{ max_resin }}</span>
</div>
</div>
<div class="item">
<div class="main">
<div class="bg"></div>
<img class="icon" src="items/洞天宝钱.png" />
<div class="info">
<div class="name">洞天宝钱</div>
<div class="time">
{% if realm_recovery_time %}
预计{{ realm_recovery_time }}后达到上限
{% else %}
存储已满
{% endif %}
</div>
</div>
</div>
<div class="right">
<span class="{% if current_realm_currency/(max_realm_currency or 1) > 0.9 %}red{% endif %}">
{{ current_realm_currency }}/{{ max_realm_currency }}
</span>
</div>
</div>
<div class="item">
<div class="main">
<div class="bg"></div>
<img class="icon" src="items/委托.png" />
<div class="info">
<div class="name">每日委托任务</div>
<div class="time">今日委托奖励{% if claimed_commission_reward %}已{% else %}未{% endif %}领取</div>
</div>
</div>
<div class="right">
<span>{{ completed_commissions }}/{{ max_commissions }}</span>
</div>
</div>
<div class="item">
<div class="main">
<div class="bg"></div>
<img class="icon" src="items/派遣.png" />
<div class="info">
<div class="name">探索派遣</div>
<div class="time">
{% if not expeditions %}尚未进行派遣
{% elif remained_time %}将于{{ remained_time }}完成
{% else %}派遣已完成{% endif %}
</div>
</div>
</div>
<div class="right">
<span>{{ current_expeditions }}/{{ max_expeditions }}</span>
</div>
</div>
<div class="item">
<div class="main">
<div class="bg"></div>
<img class="icon" src="items/周本.png" />
<div class="info">
<div class="name">值得铭记的强敌</div>
<div class="time">
{% if remaining_resin_discounts<=0 %}周本已完成
{% else %}周本树脂减半次数已用{% endif %}
</div>
</div>
</div>
<div class="right">
<span>{{ remaining_resin_discounts }}/{{ max_resin_discounts }}</span>
</div>
</div>
<div class="item">
<div class="main">
<div class="bg"></div>
<img class="icon" src="items/参量质变仪.png" />
<div class="info">
<div class="name">参量质变仪</div>
<div class="time">
{% if transformer %}
{% if transformer_ready %}已准备完成
{% else %}{{ transformer_recovery_time }}后可使用{% endif %}
{% else %}
尚未获得
{% endif %}
</div>
</div>
</div>
<div class="right">
<span class="{% if (transformer and transformer_ready) %}red{% endif %}">
{% if transformer %}{% if transformer_ready %}可使用{% else %}冷却中{% endif %}{% else %}尚未获得{% endif %}
</span>
</div>
</div>
</div>
</body>
<script type="text/javascript"></script>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Some files were not shown because too many files have changed in this diff Show More