├── .github └── workflows │ └── docker-publish.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── app └── api_server.py ├── notify.py ├── requirements.txt ├── telecom_class.py └── telecom_monitor.py /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker Publish 2 | 3 | permissions: 4 | contents: read 5 | packages: write 6 | 7 | on: 8 | workflow_dispatch: 9 | push: 10 | branches: 11 | - main 12 | tags: 13 | - "v*" 14 | 15 | env: 16 | IMAGE_NAME: ${{ github.repository }} 17 | 18 | jobs: 19 | build-and-push: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v4 24 | 25 | - name: Extract metadata (tags, labels) for Docker 26 | id: meta 27 | uses: docker/metadata-action@v5 28 | with: 29 | images: | 30 | ${{ env.IMAGE_NAME }} 31 | ghcr.io/${{ env.IMAGE_NAME }} 32 | tags: | 33 | type=ref,event=branch 34 | type=ref,event=tag 35 | 36 | - name: Get version 37 | id: get_version 38 | run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//} 39 | 40 | - name: Set up QEMU 41 | uses: docker/setup-qemu-action@v3 42 | 43 | - name: Set up Docker Buildx 44 | uses: docker/setup-buildx-action@v3 45 | 46 | - name: Login to GitHub Container Registry 47 | uses: docker/login-action@v3 48 | with: 49 | registry: ghcr.io 50 | username: ${{ github.repository_owner }} 51 | password: ${{ secrets.GITHUB_TOKEN }} 52 | 53 | - name: Login to Docker Hub 54 | uses: docker/login-action@v3 55 | with: 56 | username: ${{ secrets.DOCKERHUB_USERNAME }} 57 | password: ${{ secrets.DOCKERHUB_TOKEN }} 58 | 59 | - name: Build and push Docker image 60 | uses: docker/build-push-action@v5 61 | with: 62 | build-args: | 63 | MAINTAINER=${{ github.repository_owner }} 64 | BRANCH=${{ github.ref_name }} 65 | BUILD_SHA=${{ github.sha }} 66 | BUILD_TAG=${{ steps.get_version.outputs.VERSION }} 67 | context: . 68 | platforms: | 69 | linux/amd64 70 | linux/arm64 71 | file: ./Dockerfile 72 | push: true 73 | tags: ${{ steps.meta.outputs.tags }} 74 | labels: ${{ steps.meta.outputs.labels }} 75 | 76 | - name: Update repo description 77 | uses: peter-evans/dockerhub-description@v4 78 | with: 79 | username: ${{ secrets.DOCKERHUB_USERNAME }} 80 | password: ${{ secrets.DOCKERHUB_TOKEN }} 81 | repository: ${{ env.IMAGE_NAME }} 82 | short-description: ${{ github.event.repository.description }} 83 | enable-url-completion: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | telecom_config* 3 | config -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 使用官方 Python 镜像作为基础镜像 2 | FROM python:3.13-alpine 3 | 4 | # 设置工作目录 5 | WORKDIR /app 6 | 7 | # 将当前目录中的文件添加到工作目录中 8 | COPY . /app 9 | 10 | # 安装依赖 11 | RUN pip install --no-cache-dir -r requirements.txt \ 12 | && pip install --no-cache-dir flask 13 | 14 | # 时区 15 | ENV TZ="Asia/Shanghai" 16 | 17 | # 构建版本 18 | ARG BUILD_SHA 19 | ARG BUILD_TAG 20 | ENV BUILD_SHA=$BUILD_SHA 21 | ENV BUILD_TAG=$BUILD_TAG 22 | 23 | ENV WHITELIST_NUM= 24 | 25 | # 端口 26 | EXPOSE 10000 27 | 28 | # 运行应用程序 29 | CMD ["python", "./app/api_server.py"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published 637 | by the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ChinaTelecomMonitor 2 | 3 | 中国电信 话费、通话、流量 套餐用量监控。 4 | 5 | 本项目是部署在服务器(或x86软路由等设备)使用接口模拟登录,定时获取电信手机话费、通话、流量使用情况,推送到各种通知渠道提醒。 6 | 7 | ## 特性 8 | 9 | - [x] 支持青龙 10 | - [x] 支持通过 json push_config 字段独立配置通知渠道 11 | - [x] 本地保存登录 token ,有效期内不重复登录 12 | - [x] Docker 独立部署 API 查询服务 13 | 14 | ## 使用案例 15 | 16 | - [提供一个ios的自制UI面板](https://github.com/Cp0204/ChinaTelecomMonitor/issues/18) --- By: LRZ9712 17 | 18 | ## 部署 19 | 20 | ### 青龙监控 21 | 22 | 拉库命令: 23 | 24 | ``` 25 | ql repo https://github.com/Cp0204/ChinaTelecomMonitor.git "telecom_monitor" "" "telecom_class|notify" 26 | ``` 27 | 28 | | 环境变量 | 示例 | 备注 | 29 | | -------------- | --------------------- | ------------------------------ | 30 | | `TELECOM_USER` | `18912345678password` | 手机号密码直接拼接,会自动截取 | 31 | 32 | ### Docker API 服务 33 | 34 | 注意:Docker 部署的是 API 服务,没有监控提醒功能,主要是用于第三方(如 HomeAssistant 等)获取信息,数据原样返回。 35 | 36 | ```shell 37 | docker run -d \ 38 | --name china-telecom-monitor \ 39 | -p 10000:10000 \ 40 | -v ./china-telecom-monitor/config:/app/config \ 41 | -v /etc/localtime:/etc/localtime \ 42 | -e WHITELIST_NUM= \ 43 | --network bridge \ 44 | --restart unless-stopped \ 45 | cp0204/chinatelecommonitor:main 46 | ``` 47 | 48 | | 环境变量 | 示例 | 备注 | 49 | | --------------- | ------------------------- | ------------ | 50 | | `WHITELIST_NUM` | `18912345678,13312345678` | 手机号白名单 | 51 | 52 | #### 接口URL 53 | 54 | - `http://127.0.0.1:10000/login` 55 | 56 | 登录,返回用户信息,token长期有效,用以下次请求数据 57 | 58 | - `http://127.0.0.1:10000/qryImportantData` 59 | 60 | 返回主要信息,总用量 话费、通话、流量 等 61 | 62 | - `http://127.0.0.1:10000/userFluxPackage` 63 | 64 | 返回流量包明细 65 | 66 | - `http://127.0.0.1:10000/qryShareUsage` 67 | 68 | 返回共享套餐各号码用量 69 | 70 | - `http://127.0.0.1:10000/summary` 71 | 72 | `/qryImportantData` 的数据简化接口,非原样返回,简化后返回格式: 73 | 74 | ```json 75 | { 76 | "phonenum": "18912345678", // 手机号码 77 | "balance": 0, // 账户余额(分) 78 | "voiceUsage": 39, // 语音通话已使用时长(分钟) 79 | "voiceTotal": 2250, // 语音通话总时长(分钟) 80 | "flowUse": 7366923, // 总流量已使用量(KB) 81 | "flowTotal": 7366923, // 总流量总量(KB) 82 | "flowOver": 222222, // 总流量超量(KB) 83 | "commonUse": 7273962, // 通用流量已使用量(KB) 84 | "commonTotal": 25550446, // 通用流量总量(KB) 85 | "commonOver": 222222, // 通用流量超量(KB) 86 | "specialUse": 92961, // 专用流量已使用量(KB) 87 | "specialTotal": 215265280, // 专用流量总量(KB) 88 | "createTime": "2024-05-12 14:13:28", // 数据创建时间 89 | "flowItems": [ // 流量类型列表 90 | { 91 | "name": "国内通用流量(达量降速)", // 流量类型名称 92 | "use": 10241024, // 流量包已使用量(KB) 93 | "balance": 0, // 流量包剩余量(KB),当为负值时则是超流量 94 | "total": 10241024 // 流量包总量(KB) 95 | }, 96 | { 97 | "name": "国内通用流量(非畅享)", 98 | "use": 1, 99 | "balance": 10241023, 100 | "total": 10241024 101 | }, 102 | { 103 | "name": "专用流量", 104 | "use": 1, 105 | "balance": 10241023, 106 | "total": 10241024 107 | } 108 | ] 109 | } 110 | ``` 111 | 112 | 接口均支持 POST 和 GET 方法,如 GET : 113 | 114 | ``` 115 | http://127.0.0.1:10000/summary?phonenum=18912345678&password=123456 116 | ``` 117 | 118 | POST 时 Body 须为 json 数据,如: 119 | 120 | ```bash 121 | curl --request POST \ 122 | --url http://127.0.0.1:10000/summary \ 123 | --header 'Content-Type: application/json' \ 124 | --data '{"phonenum": "18912345678","password": "123456"}' 125 | ``` 126 | 127 | > [!NOTE] 128 | > 登录成功后,会在 config/login_info.json 文件**记录账号敏感信息**。程序请求数据将先尝试用记录的 token 获取,避免重复登录。 129 | 130 | ## 感谢 131 | 132 | 本项目大量参考其他项目的代码,在此表示感谢! 133 | 134 | - [ChinaTelecomMonitor](https://github.com/LambdaExpression/ChinaTelecomMonitor) : go 语言的实现 135 | - [boxjs](https://github.com/gsons/boxjs) : 感谢开源提供的电信接口 136 | -------------------------------------------------------------------------------- /app/api_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # _*_ coding:utf-8 _*_ 3 | 4 | import os 5 | import sys 6 | import json 7 | from datetime import datetime 8 | from flask import Flask, request, jsonify 9 | 10 | # 导入父目录的依赖 11 | current_dir = os.path.dirname(os.path.abspath(__file__)) 12 | parent_dir = os.path.dirname(current_dir) 13 | sys.path.append(parent_dir) 14 | from telecom_class import Telecom 15 | 16 | telecom = Telecom() 17 | 18 | app = Flask(__name__) 19 | app.json.ensure_ascii = False 20 | app.json.sort_keys = False 21 | 22 | # 登录信息存储文件 23 | LOGIN_INFO_FILE = os.environ.get("CONFIG_PATH", "./config/login_info.json") 24 | 25 | 26 | def load_login_info(): 27 | """加载本地登录信息""" 28 | try: 29 | with open(LOGIN_INFO_FILE, "r", encoding="utf-8") as f: 30 | return json.load(f) 31 | except FileNotFoundError: 32 | return {} 33 | 34 | 35 | def save_login_info(login_info): 36 | """保存登录信息到本地""" 37 | with open(LOGIN_INFO_FILE, "w", encoding="utf-8") as f: 38 | json.dump(login_info, f, ensure_ascii=False, indent=2) 39 | 40 | 41 | @app.route("/login", methods=["POST", "GET"]) 42 | def login(): 43 | """登录接口""" 44 | if request.method == "POST": 45 | data = request.get_json() or {} 46 | else: 47 | data = request.args 48 | phonenum = data.get("phonenum") 49 | password = data.get("password") 50 | if not phonenum or not password: 51 | return jsonify({"message": "手机号和密码不能为空"}), 400 52 | elif whitelist_num := os.environ.get("WHITELIST_NUM"): 53 | if not phonenum in whitelist_num: 54 | return jsonify({"message": "手机号不在白名单"}), 400 55 | 56 | login_info = load_login_info() 57 | data = telecom.do_login(phonenum, password) 58 | if data.get("responseData").get("resultCode") == "0000": 59 | login_info[phonenum] = data["responseData"]["data"]["loginSuccessResult"] 60 | login_info[phonenum]["password"] = password 61 | login_info[phonenum]["createTime"] = datetime.now().strftime( 62 | "%Y-%m-%d %H:%M:%S" 63 | ) 64 | save_login_info(login_info) 65 | return jsonify(data), 200 66 | else: 67 | return jsonify(data), 400 68 | 69 | 70 | def query_data(query_func, **kwargs): 71 | """ 72 | 查询数据,如果本地没有登录信息或密码不匹配,则尝试登录后再查询 73 | """ 74 | if request.method == "POST": 75 | data = request.get_json() or {} 76 | else: 77 | data = request.args 78 | phonenum = data.get("phonenum") 79 | password = data.get("password") 80 | 81 | login_info = load_login_info() 82 | if phonenum in login_info and login_info[phonenum]["password"] == password: 83 | telecom.set_login_info(login_info[phonenum]) 84 | data = query_func(**kwargs) 85 | if data.get("responseData"): 86 | return jsonify(data), 200 87 | # 重新登录 88 | login_data, status_code = login() 89 | login_data = json.loads(login_data.data) 90 | if status_code == 200: 91 | telecom.set_login_info(login_data["responseData"]["data"]["loginSuccessResult"]) 92 | data = query_func(**kwargs) 93 | if data: 94 | return jsonify(data), 200 95 | else: 96 | return jsonify(data), 400 97 | else: 98 | return jsonify(login_data), 400 99 | 100 | 101 | @app.route("/qryImportantData", methods=["POST", "GET"]) 102 | def qry_important_data(): 103 | """查询重要数据接口""" 104 | return query_data(telecom.qry_important_data) 105 | 106 | 107 | @app.route("/userFluxPackage", methods=["POST", "GET"]) 108 | def user_flux_package(): 109 | """查询流量包接口""" 110 | return query_data(telecom.user_flux_package) 111 | 112 | 113 | @app.route("/qryShareUsage", methods=["POST", "GET"]) 114 | def qry_share_usage(): 115 | """查询共享用量接口""" 116 | if request.method == "POST": 117 | data = request.get_json() or {} 118 | else: 119 | data = request.args 120 | return query_data(telecom.qry_share_usage, billing_cycle=data.get("billing_cycle")) 121 | 122 | 123 | @app.route("/summary", methods=["POST", "GET"]) 124 | def summary(): 125 | """查询重要数据简化接口""" 126 | important_data, status_code = query_data(telecom.qry_important_data) 127 | print(important_data.data) 128 | if status_code == 200: 129 | data = telecom.to_summary( 130 | json.loads(important_data.data)["responseData"]["data"] 131 | ) 132 | return jsonify(data), 200 133 | 134 | 135 | if __name__ == "__main__": 136 | app.run(debug=os.environ.get("DEBUG", False), host="0.0.0.0", port=10000) 137 | -------------------------------------------------------------------------------- /notify.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # _*_ coding:utf-8 _*_ 3 | import base64 4 | import hashlib 5 | import hmac 6 | import json 7 | import os 8 | import re 9 | import threading 10 | import time 11 | import urllib.parse 12 | import smtplib 13 | from email.mime.text import MIMEText 14 | from email.header import Header 15 | from email.utils import formataddr 16 | 17 | import requests 18 | 19 | # 原先的 print 函数和主线程的锁 20 | _print = print 21 | mutex = threading.Lock() 22 | 23 | 24 | # 定义新的 print 函数 25 | def print(text, *args, **kw): 26 | """ 27 | 使输出有序进行,不出现多线程同一时间输出导致错乱的问题。 28 | """ 29 | with mutex: 30 | _print(text, *args, **kw) 31 | 32 | 33 | # 通知服务 34 | # fmt: off 35 | push_config = { 36 | 'HITOKOTO': False, # 启用一言(随机句子) 37 | 38 | 'BARK_PUSH': '', # bark IP 或设备码,例:https://api.day.app/DxHcxxxxxRxxxxxxcm/ 39 | 'BARK_ARCHIVE': '', # bark 推送是否存档 40 | 'BARK_GROUP': '', # bark 推送分组 41 | 'BARK_SOUND': '', # bark 推送声音 42 | 'BARK_ICON': '', # bark 推送图标 43 | 'BARK_LEVEL': '', # bark 推送时效性 44 | 'BARK_URL': '', # bark 推送跳转URL 45 | 46 | 'CONSOLE': False, # 控制台输出 47 | 48 | 'DD_BOT_SECRET': '', # 钉钉机器人的 DD_BOT_SECRET 49 | 'DD_BOT_TOKEN': '', # 钉钉机器人的 DD_BOT_TOKEN 50 | 51 | 'FSKEY': '', # 飞书机器人的 FSKEY 52 | 53 | 'GOBOT_URL': '', # go-cqhttp 54 | # 推送到个人QQ:http://127.0.0.1/send_private_msg 55 | # 群:http://127.0.0.1/send_group_msg 56 | 'GOBOT_QQ': '', # go-cqhttp 的推送群或用户 57 | # GOBOT_URL 设置 /send_private_msg 时填入 user_id=个人QQ 58 | # /send_group_msg 时填入 group_id=QQ群 59 | 'GOBOT_TOKEN': '', # go-cqhttp 的 access_token 60 | 61 | 'GOTIFY_URL': '', # gotify地址,如https://push.example.de:8080 62 | 'GOTIFY_TOKEN': '', # gotify的消息应用token 63 | 'GOTIFY_PRIORITY': 0, # 推送消息优先级,默认为0 64 | 65 | 'IGOT_PUSH_KEY': '', # iGot 聚合推送的 IGOT_PUSH_KEY 66 | 67 | 'PUSH_KEY': '', # server 酱的 PUSH_KEY,兼容旧版与 Turbo 版 68 | 69 | 'DEER_KEY': '', # PushDeer 的 PUSHDEER_KEY 70 | 'DEER_URL': '', # PushDeer 的 PUSHDEER_URL 71 | 72 | 'CHAT_URL': '', # synology chat url 73 | 'CHAT_TOKEN': '', # synology chat token 74 | 75 | 'PUSH_PLUS_TOKEN': '', # push+ 微信推送的用户令牌 76 | 'PUSH_PLUS_USER': '', # push+ 微信推送的群组编码 77 | 78 | 'WE_PLUS_BOT_TOKEN': '', # 微加机器人的用户令牌 79 | 'WE_PLUS_BOT_RECEIVER': '', # 微加机器人的消息接收者 80 | 'WE_PLUS_BOT_VERSION': 'pro', # 微加机器人的调用版本 81 | 82 | 'QMSG_KEY': '', # qmsg 酱的 QMSG_KEY 83 | 'QMSG_TYPE': '', # qmsg 酱的 QMSG_TYPE 84 | 85 | 'QYWX_ORIGIN': '', # 企业微信代理地址 86 | 87 | 'QYWX_AM': '', # 企业微信应用 88 | 89 | 'QYWX_KEY': '', # 企业微信机器人 90 | 91 | 'TG_BOT_TOKEN': '', # tg 机器人的 TG_BOT_TOKEN,例:1407203283:AAG9rt-6RDaaX0HBLZQq0laNOh898iFYaRQ 92 | 'TG_USER_ID': '', # tg 机器人的 TG_USER_ID,例:1434078534 93 | 'TG_API_HOST': '', # tg 代理 api 94 | 'TG_PROXY_AUTH': '', # tg 代理认证参数 95 | 'TG_PROXY_HOST': '', # tg 机器人的 TG_PROXY_HOST 96 | 'TG_PROXY_PORT': '', # tg 机器人的 TG_PROXY_PORT 97 | 98 | 'AIBOTK_KEY': '', # 智能微秘书 个人中心的apikey 文档地址:http://wechat.aibotk.com/docs/about 99 | 'AIBOTK_TYPE': '', # 智能微秘书 发送目标 room 或 contact 100 | 'AIBOTK_NAME': '', # 智能微秘书 发送群名 或者好友昵称和type要对应好 101 | 102 | 'SMTP_SERVER': '', # SMTP 发送邮件服务器,形如 smtp.exmail.qq.com:465 103 | 'SMTP_SSL': 'false', # SMTP 发送邮件服务器是否使用 SSL,填写 true 或 false 104 | 'SMTP_EMAIL': '', # SMTP 发件邮箱 105 | 'SMTP_PASSWORD': '', # SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定 106 | 'SMTP_NAME': '', # SMTP 发件人姓名,可随意填写 107 | 'SMTP_EMAIL_TO': '', # SMTP 收件邮箱,可选,缺省时将自己发给自己,多个收件邮箱逗号间隔 108 | 'SMTP_NAME_TO': '', # SMTP 收件人姓名,可选,可随意填写,多个收件人逗号间隔,顺序与 SMTP_EMAIL_TO 保持一致 109 | 110 | 'PUSHME_KEY': '', # PushMe 的 PUSHME_KEY 111 | 'PUSHME_URL': '', # PushMe 的 PUSHME_URL 112 | 113 | 'CHRONOCAT_QQ': '', # qq号 114 | 'CHRONOCAT_TOKEN': '', # CHRONOCAT 的token 115 | 'CHRONOCAT_URL': '', # CHRONOCAT的url地址 116 | 117 | 'WEBHOOK_URL': '', # 自定义通知 请求地址 118 | 'WEBHOOK_BODY': '', # 自定义通知 请求体 119 | 'WEBHOOK_HEADERS': '', # 自定义通知 请求头 120 | 'WEBHOOK_METHOD': '', # 自定义通知 请求方法 121 | 'WEBHOOK_CONTENT_TYPE': '', # 自定义通知 content-type 122 | 123 | 'NTFY_URL': '', # ntfy地址,如https://ntfy.sh 124 | 'NTFY_TOPIC': '', # ntfy的消息应用topic 125 | 'NTFY_PRIORITY':'3', # 推送消息优先级,默认为3 126 | } 127 | # fmt: on 128 | 129 | for k in push_config: 130 | if os.getenv(k): 131 | v = os.getenv(k) 132 | push_config[k] = v 133 | 134 | 135 | def bark(title: str, content: str) -> None: 136 | """ 137 | 使用 bark 推送消息。 138 | """ 139 | if not push_config.get("BARK_PUSH"): 140 | print("bark 服务的 BARK_PUSH 未设置!!\n取消推送") 141 | return 142 | print("bark 服务启动") 143 | 144 | if push_config.get("BARK_PUSH").startswith("http"): 145 | url = f'{push_config.get("BARK_PUSH")}' 146 | else: 147 | url = f'https://api.day.app/{push_config.get("BARK_PUSH")}' 148 | 149 | bark_params = { 150 | "BARK_ARCHIVE": "isArchive", 151 | "BARK_GROUP": "group", 152 | "BARK_SOUND": "sound", 153 | "BARK_ICON": "icon", 154 | "BARK_LEVEL": "level", 155 | "BARK_URL": "url", 156 | } 157 | data = { 158 | "title": title, 159 | "body": content, 160 | } 161 | for pair in filter( 162 | lambda pairs: pairs[0].startswith("BARK_") 163 | and pairs[0] != "BARK_PUSH" 164 | and pairs[1] 165 | and bark_params.get(pairs[0]), 166 | push_config.items(), 167 | ): 168 | data[bark_params.get(pair[0])] = pair[1] 169 | headers = {"Content-Type": "application/json;charset=utf-8"} 170 | response = requests.post( 171 | url=url, data=json.dumps(data), headers=headers, timeout=15 172 | ).json() 173 | 174 | if response["code"] == 200: 175 | print("bark 推送成功!") 176 | else: 177 | print("bark 推送失败!") 178 | 179 | 180 | def console(title: str, content: str) -> None: 181 | """ 182 | 使用 控制台 推送消息。 183 | """ 184 | if str(push_config.get("CONSOLE")).lower() != "false": 185 | print(f"{title}\n\n{content}") 186 | 187 | 188 | def dingding_bot(title: str, content: str) -> None: 189 | """ 190 | 使用 钉钉机器人 推送消息。 191 | """ 192 | if not push_config.get("DD_BOT_SECRET") or not push_config.get("DD_BOT_TOKEN"): 193 | print("钉钉机器人 服务的 DD_BOT_SECRET 或者 DD_BOT_TOKEN 未设置!!\n取消推送") 194 | return 195 | print("钉钉机器人 服务启动") 196 | 197 | timestamp = str(round(time.time() * 1000)) 198 | secret_enc = push_config.get("DD_BOT_SECRET").encode("utf-8") 199 | string_to_sign = "{}\n{}".format(timestamp, push_config.get("DD_BOT_SECRET")) 200 | string_to_sign_enc = string_to_sign.encode("utf-8") 201 | hmac_code = hmac.new( 202 | secret_enc, string_to_sign_enc, digestmod=hashlib.sha256 203 | ).digest() 204 | sign = urllib.parse.quote_plus(base64.b64encode(hmac_code)) 205 | url = f'https://oapi.dingtalk.com/robot/send?access_token={push_config.get("DD_BOT_TOKEN")}×tamp={timestamp}&sign={sign}' 206 | headers = {"Content-Type": "application/json;charset=utf-8"} 207 | data = {"msgtype": "text", "text": {"content": f"{title}\n\n{content}"}} 208 | response = requests.post( 209 | url=url, data=json.dumps(data), headers=headers, timeout=15 210 | ).json() 211 | 212 | if not response["errcode"]: 213 | print("钉钉机器人 推送成功!") 214 | else: 215 | print("钉钉机器人 推送失败!") 216 | 217 | 218 | def feishu_bot(title: str, content: str) -> None: 219 | """ 220 | 使用 飞书机器人 推送消息。 221 | """ 222 | if not push_config.get("FSKEY"): 223 | print("飞书 服务的 FSKEY 未设置!!\n取消推送") 224 | return 225 | print("飞书 服务启动") 226 | 227 | url = f'https://open.feishu.cn/open-apis/bot/v2/hook/{push_config.get("FSKEY")}' 228 | data = {"msg_type": "text", "content": {"text": f"{title}\n\n{content}"}} 229 | response = requests.post(url, data=json.dumps(data)).json() 230 | 231 | if response.get("StatusCode") == 0 or response.get("code") == 0: 232 | print("飞书 推送成功!") 233 | else: 234 | print("飞书 推送失败!错误信息如下:\n", response) 235 | 236 | 237 | def go_cqhttp(title: str, content: str) -> None: 238 | """ 239 | 使用 go_cqhttp 推送消息。 240 | """ 241 | if not push_config.get("GOBOT_URL") or not push_config.get("GOBOT_QQ"): 242 | print("go-cqhttp 服务的 GOBOT_URL 或 GOBOT_QQ 未设置!!\n取消推送") 243 | return 244 | print("go-cqhttp 服务启动") 245 | 246 | url = f'{push_config.get("GOBOT_URL")}?access_token={push_config.get("GOBOT_TOKEN")}&{push_config.get("GOBOT_QQ")}&message=标题:{title}\n内容:{content}' 247 | response = requests.get(url).json() 248 | 249 | if response["status"] == "ok": 250 | print("go-cqhttp 推送成功!") 251 | else: 252 | print("go-cqhttp 推送失败!") 253 | 254 | 255 | def gotify(title: str, content: str) -> None: 256 | """ 257 | 使用 gotify 推送消息。 258 | """ 259 | if not push_config.get("GOTIFY_URL") or not push_config.get("GOTIFY_TOKEN"): 260 | print("gotify 服务的 GOTIFY_URL 或 GOTIFY_TOKEN 未设置!!\n取消推送") 261 | return 262 | print("gotify 服务启动") 263 | 264 | url = f'{push_config.get("GOTIFY_URL")}/message?token={push_config.get("GOTIFY_TOKEN")}' 265 | data = { 266 | "title": title, 267 | "message": content, 268 | "priority": push_config.get("GOTIFY_PRIORITY"), 269 | } 270 | response = requests.post(url, data=data).json() 271 | 272 | if response.get("id"): 273 | print("gotify 推送成功!") 274 | else: 275 | print("gotify 推送失败!") 276 | 277 | 278 | def iGot(title: str, content: str) -> None: 279 | """ 280 | 使用 iGot 推送消息。 281 | """ 282 | if not push_config.get("IGOT_PUSH_KEY"): 283 | print("iGot 服务的 IGOT_PUSH_KEY 未设置!!\n取消推送") 284 | return 285 | print("iGot 服务启动") 286 | 287 | url = f'https://push.hellyw.com/{push_config.get("IGOT_PUSH_KEY")}' 288 | data = {"title": title, "content": content} 289 | headers = {"Content-Type": "application/x-www-form-urlencoded"} 290 | response = requests.post(url, data=data, headers=headers).json() 291 | 292 | if response["ret"] == 0: 293 | print("iGot 推送成功!") 294 | else: 295 | print(f'iGot 推送失败!{response["errMsg"]}') 296 | 297 | 298 | def serverJ(title: str, content: str) -> None: 299 | """ 300 | 通过 serverJ 推送消息。 301 | """ 302 | if not push_config.get("PUSH_KEY"): 303 | print("serverJ 服务的 PUSH_KEY 未设置!!\n取消推送") 304 | return 305 | print("serverJ 服务启动") 306 | 307 | data = {"text": title, "desp": content.replace("\n", "\n\n")} 308 | 309 | match = re.match(r'sctp(\d+)t', push_config.get("PUSH_KEY")) 310 | if match: 311 | num = match.group(1) 312 | url = f'https://{num}.push.ft07.com/send/{push_config.get("PUSH_KEY")}.send' 313 | else: 314 | url = f'https://sctapi.ftqq.com/{push_config.get("PUSH_KEY")}.send' 315 | 316 | response = requests.post(url, data=data).json() 317 | 318 | if response.get("errno") == 0 or response.get("code") == 0: 319 | print("serverJ 推送成功!") 320 | else: 321 | print(f'serverJ 推送失败!错误码:{response["message"]}') 322 | 323 | 324 | def pushdeer(title: str, content: str) -> None: 325 | """ 326 | 通过PushDeer 推送消息 327 | """ 328 | if not push_config.get("DEER_KEY"): 329 | print("PushDeer 服务的 DEER_KEY 未设置!!\n取消推送") 330 | return 331 | print("PushDeer 服务启动") 332 | data = { 333 | "text": title, 334 | "desp": content, 335 | "type": "markdown", 336 | "pushkey": push_config.get("DEER_KEY"), 337 | } 338 | url = "https://api2.pushdeer.com/message/push" 339 | if push_config.get("DEER_URL"): 340 | url = push_config.get("DEER_URL") 341 | 342 | response = requests.post(url, data=data).json() 343 | 344 | if len(response.get("content").get("result")) > 0: 345 | print("PushDeer 推送成功!") 346 | else: 347 | print("PushDeer 推送失败!错误信息:", response) 348 | 349 | 350 | def chat(title: str, content: str) -> None: 351 | """ 352 | 通过Chat 推送消息 353 | """ 354 | if not push_config.get("CHAT_URL") or not push_config.get("CHAT_TOKEN"): 355 | print("chat 服务的 CHAT_URL或CHAT_TOKEN 未设置!!\n取消推送") 356 | return 357 | print("chat 服务启动") 358 | data = "payload=" + json.dumps({"text": title + "\n" + content}) 359 | url = push_config.get("CHAT_URL") + push_config.get("CHAT_TOKEN") 360 | response = requests.post(url, data=data) 361 | 362 | if response.status_code == 200: 363 | print("Chat 推送成功!") 364 | else: 365 | print("Chat 推送失败!错误信息:", response) 366 | 367 | 368 | def pushplus_bot(title: str, content: str) -> None: 369 | """ 370 | 通过 push+ 推送消息。 371 | """ 372 | if not push_config.get("PUSH_PLUS_TOKEN"): 373 | print("PUSHPLUS 服务的 PUSH_PLUS_TOKEN 未设置!!\n取消推送") 374 | return 375 | print("PUSHPLUS 服务启动") 376 | 377 | url = "http://www.pushplus.plus/send" 378 | data = { 379 | "token": push_config.get("PUSH_PLUS_TOKEN"), 380 | "title": title, 381 | "content": content, 382 | "topic": push_config.get("PUSH_PLUS_USER"), 383 | } 384 | body = json.dumps(data).encode(encoding="utf-8") 385 | headers = {"Content-Type": "application/json"} 386 | response = requests.post(url=url, data=body, headers=headers).json() 387 | 388 | if response["code"] == 200: 389 | print("PUSHPLUS 推送成功!") 390 | 391 | else: 392 | url_old = "http://pushplus.hxtrip.com/send" 393 | headers["Accept"] = "application/json" 394 | response = requests.post(url=url_old, data=body, headers=headers).json() 395 | 396 | if response["code"] == 200: 397 | print("PUSHPLUS(hxtrip) 推送成功!") 398 | 399 | else: 400 | print("PUSHPLUS 推送失败!") 401 | 402 | 403 | def weplus_bot(title: str, content: str) -> None: 404 | """ 405 | 通过 微加机器人 推送消息。 406 | """ 407 | if not push_config.get("WE_PLUS_BOT_TOKEN"): 408 | print("微加机器人 服务的 WE_PLUS_BOT_TOKEN 未设置!!\n取消推送") 409 | return 410 | print("微加机器人 服务启动") 411 | 412 | template = "txt" 413 | if len(content) > 800: 414 | template = "html" 415 | 416 | url = "https://www.weplusbot.com/send" 417 | data = { 418 | "token": push_config.get("WE_PLUS_BOT_TOKEN"), 419 | "title": title, 420 | "content": content, 421 | "template": template, 422 | "receiver": push_config.get("WE_PLUS_BOT_RECEIVER"), 423 | "version": push_config.get("WE_PLUS_BOT_VERSION"), 424 | } 425 | body = json.dumps(data).encode(encoding="utf-8") 426 | headers = {"Content-Type": "application/json"} 427 | response = requests.post(url=url, data=body, headers=headers).json() 428 | 429 | if response["code"] == 200: 430 | print("微加机器人 推送成功!") 431 | else: 432 | print("微加机器人 推送失败!") 433 | 434 | 435 | def qmsg_bot(title: str, content: str) -> None: 436 | """ 437 | 使用 qmsg 推送消息。 438 | """ 439 | if not push_config.get("QMSG_KEY") or not push_config.get("QMSG_TYPE"): 440 | print("qmsg 的 QMSG_KEY 或者 QMSG_TYPE 未设置!!\n取消推送") 441 | return 442 | print("qmsg 服务启动") 443 | 444 | url = f'https://qmsg.zendee.cn/{push_config.get("QMSG_TYPE")}/{push_config.get("QMSG_KEY")}' 445 | payload = {"msg": f'{title}\n\n{content.replace("----", "-")}'.encode("utf-8")} 446 | response = requests.post(url=url, params=payload).json() 447 | 448 | if response["code"] == 0: 449 | print("qmsg 推送成功!") 450 | else: 451 | print(f'qmsg 推送失败!{response["reason"]}') 452 | 453 | 454 | def wecom_app(title: str, content: str) -> None: 455 | """ 456 | 通过 企业微信 APP 推送消息。 457 | """ 458 | if not push_config.get("QYWX_AM"): 459 | print("QYWX_AM 未设置!!\n取消推送") 460 | return 461 | QYWX_AM_AY = re.split(",", push_config.get("QYWX_AM")) 462 | if 4 < len(QYWX_AM_AY) > 5: 463 | print("QYWX_AM 设置错误!!\n取消推送") 464 | return 465 | print("企业微信 APP 服务启动") 466 | 467 | corpid = QYWX_AM_AY[0] 468 | corpsecret = QYWX_AM_AY[1] 469 | touser = QYWX_AM_AY[2] 470 | agentid = QYWX_AM_AY[3] 471 | try: 472 | media_id = QYWX_AM_AY[4] 473 | except IndexError: 474 | media_id = "" 475 | wx = WeCom(corpid, corpsecret, agentid) 476 | # 如果没有配置 media_id 默认就以 text 方式发送 477 | if not media_id: 478 | message = title + "\n\n" + content 479 | response = wx.send_text(message, touser) 480 | else: 481 | response = wx.send_mpnews(title, content, media_id, touser) 482 | 483 | if response == "ok": 484 | print("企业微信推送成功!") 485 | else: 486 | print("企业微信推送失败!错误信息如下:\n", response) 487 | 488 | 489 | class WeCom: 490 | def __init__(self, corpid, corpsecret, agentid): 491 | self.CORPID = corpid 492 | self.CORPSECRET = corpsecret 493 | self.AGENTID = agentid 494 | self.ORIGIN = "https://qyapi.weixin.qq.com" 495 | if push_config.get("QYWX_ORIGIN"): 496 | self.ORIGIN = push_config.get("QYWX_ORIGIN") 497 | 498 | def get_access_token(self): 499 | url = f"{self.ORIGIN}/cgi-bin/gettoken" 500 | values = { 501 | "corpid": self.CORPID, 502 | "corpsecret": self.CORPSECRET, 503 | } 504 | req = requests.post(url, params=values) 505 | data = json.loads(req.text) 506 | return data["access_token"] 507 | 508 | def send_text(self, message, touser="@all"): 509 | send_url = ( 510 | f"{self.ORIGIN}/cgi-bin/message/send?access_token={self.get_access_token()}" 511 | ) 512 | send_values = { 513 | "touser": touser, 514 | "msgtype": "text", 515 | "agentid": self.AGENTID, 516 | "text": {"content": message}, 517 | "safe": "0", 518 | } 519 | send_msges = bytes(json.dumps(send_values), "utf-8") 520 | respone = requests.post(send_url, send_msges) 521 | respone = respone.json() 522 | return respone["errmsg"] 523 | 524 | def send_mpnews(self, title, message, media_id, touser="@all"): 525 | send_url = ( 526 | f"{self.ORIGIN}/cgi-bin/message/send?access_token={self.get_access_token()}" 527 | ) 528 | send_values = { 529 | "touser": touser, 530 | "msgtype": "mpnews", 531 | "agentid": self.AGENTID, 532 | "mpnews": { 533 | "articles": [ 534 | { 535 | "title": title, 536 | "thumb_media_id": media_id, 537 | "author": "Author", 538 | "content_source_url": "", 539 | "content": message.replace("\n", "
"), 540 | "digest": message, 541 | } 542 | ] 543 | }, 544 | } 545 | send_msges = bytes(json.dumps(send_values), "utf-8") 546 | respone = requests.post(send_url, send_msges) 547 | respone = respone.json() 548 | return respone["errmsg"] 549 | 550 | 551 | def wecom_bot(title: str, content: str) -> None: 552 | """ 553 | 通过 企业微信机器人 推送消息。 554 | """ 555 | if not push_config.get("QYWX_KEY"): 556 | print("企业微信机器人 服务的 QYWX_KEY 未设置!!\n取消推送") 557 | return 558 | print("企业微信机器人服务启动") 559 | 560 | origin = "https://qyapi.weixin.qq.com" 561 | if push_config.get("QYWX_ORIGIN"): 562 | origin = push_config.get("QYWX_ORIGIN") 563 | 564 | url = f"{origin}/cgi-bin/webhook/send?key={push_config.get('QYWX_KEY')}" 565 | headers = {"Content-Type": "application/json;charset=utf-8"} 566 | data = {"msgtype": "text", "text": {"content": f"{title}\n\n{content}"}} 567 | response = requests.post( 568 | url=url, data=json.dumps(data), headers=headers, timeout=15 569 | ).json() 570 | 571 | if response["errcode"] == 0: 572 | print("企业微信机器人推送成功!") 573 | else: 574 | print("企业微信机器人推送失败!") 575 | 576 | 577 | def telegram_bot(title: str, content: str) -> None: 578 | """ 579 | 使用 telegram 机器人 推送消息。 580 | """ 581 | if not push_config.get("TG_BOT_TOKEN") or not push_config.get("TG_USER_ID"): 582 | print("tg 服务的 bot_token 或者 user_id 未设置!!\n取消推送") 583 | return 584 | print("tg 服务启动") 585 | 586 | if push_config.get("TG_API_HOST"): 587 | url = f"{push_config.get('TG_API_HOST')}/bot{push_config.get('TG_BOT_TOKEN')}/sendMessage" 588 | else: 589 | url = ( 590 | f"https://api.telegram.org/bot{push_config.get('TG_BOT_TOKEN')}/sendMessage" 591 | ) 592 | headers = {"Content-Type": "application/x-www-form-urlencoded"} 593 | payload = { 594 | "chat_id": str(push_config.get("TG_USER_ID")), 595 | "text": f"{title}\n\n{content}", 596 | "disable_web_page_preview": "true", 597 | } 598 | proxies = None 599 | if push_config.get("TG_PROXY_HOST") and push_config.get("TG_PROXY_PORT"): 600 | if push_config.get("TG_PROXY_AUTH") is not None and "@" not in push_config.get( 601 | "TG_PROXY_HOST" 602 | ): 603 | push_config["TG_PROXY_HOST"] = ( 604 | push_config.get("TG_PROXY_AUTH") 605 | + "@" 606 | + push_config.get("TG_PROXY_HOST") 607 | ) 608 | proxyStr = "http://{}:{}".format( 609 | push_config.get("TG_PROXY_HOST"), push_config.get("TG_PROXY_PORT") 610 | ) 611 | proxies = {"http": proxyStr, "https": proxyStr} 612 | response = requests.post( 613 | url=url, headers=headers, params=payload, proxies=proxies 614 | ).json() 615 | 616 | if response["ok"]: 617 | print("tg 推送成功!") 618 | else: 619 | print("tg 推送失败!") 620 | 621 | 622 | def aibotk(title: str, content: str) -> None: 623 | """ 624 | 使用 智能微秘书 推送消息。 625 | """ 626 | if ( 627 | not push_config.get("AIBOTK_KEY") 628 | or not push_config.get("AIBOTK_TYPE") 629 | or not push_config.get("AIBOTK_NAME") 630 | ): 631 | print( 632 | "智能微秘书 的 AIBOTK_KEY 或者 AIBOTK_TYPE 或者 AIBOTK_NAME 未设置!!\n取消推送" 633 | ) 634 | return 635 | print("智能微秘书 服务启动") 636 | 637 | if push_config.get("AIBOTK_TYPE") == "room": 638 | url = "https://api-bot.aibotk.com/openapi/v1/chat/room" 639 | data = { 640 | "apiKey": push_config.get("AIBOTK_KEY"), 641 | "roomName": push_config.get("AIBOTK_NAME"), 642 | "message": {"type": 1, "content": f"【青龙快讯】\n\n{title}\n{content}"}, 643 | } 644 | else: 645 | url = "https://api-bot.aibotk.com/openapi/v1/chat/contact" 646 | data = { 647 | "apiKey": push_config.get("AIBOTK_KEY"), 648 | "name": push_config.get("AIBOTK_NAME"), 649 | "message": {"type": 1, "content": f"【青龙快讯】\n\n{title}\n{content}"}, 650 | } 651 | body = json.dumps(data).encode(encoding="utf-8") 652 | headers = {"Content-Type": "application/json"} 653 | response = requests.post(url=url, data=body, headers=headers).json() 654 | print(response) 655 | if response["code"] == 0: 656 | print("智能微秘书 推送成功!") 657 | else: 658 | print(f'智能微秘书 推送失败!{response["error"]}') 659 | 660 | 661 | def smtp(title: str, content: str) -> None: 662 | """ 663 | 使用 SMTP 邮件 推送消息。 664 | """ 665 | if ( 666 | not push_config.get("SMTP_SERVER") 667 | or not push_config.get("SMTP_SSL") 668 | or not push_config.get("SMTP_EMAIL") 669 | or not push_config.get("SMTP_PASSWORD") 670 | or not push_config.get("SMTP_NAME") 671 | ): 672 | print( 673 | "SMTP 邮件 的 SMTP_SERVER 或者 SMTP_SSL 或者 SMTP_EMAIL 或者 SMTP_PASSWORD 或者 SMTP_NAME 未设置!!\n取消推送" 674 | ) 675 | return 676 | print("SMTP 邮件 服务启动") 677 | 678 | message = MIMEText(content, "plain", "utf-8") 679 | message["From"] = formataddr( 680 | ( 681 | Header(push_config.get("SMTP_NAME"), "utf-8").encode(), 682 | push_config.get("SMTP_EMAIL"), 683 | ) 684 | ) 685 | if not push_config.get("SMTP_EMAIL_TO"): 686 | smtp_email_to = push_config.get("SMTP_EMAIL") 687 | message["To"] = formataddr( 688 | ( 689 | Header(push_config.get("SMTP_NAME"), "utf-8").encode(), 690 | push_config.get("SMTP_EMAIL"), 691 | ) 692 | ) 693 | else: 694 | smtp_email_to = push_config.get("SMTP_EMAIL_TO").split(",") 695 | smtp_name_to = push_config.get("SMTP_NAME_TO","").split(",") 696 | message["To"] = ",".join([formataddr( 697 | ( 698 | Header(smtp_name_to[i] if len(smtp_name_to) > i else "", "utf-8").encode(), 699 | email_to, 700 | ) 701 | ) for i, email_to in enumerate(smtp_email_to)]) 702 | message["Subject"] = Header(title, "utf-8") 703 | 704 | try: 705 | smtp_server = ( 706 | smtplib.SMTP_SSL(push_config.get("SMTP_SERVER")) 707 | if push_config.get("SMTP_SSL") == "true" 708 | else smtplib.SMTP(push_config.get("SMTP_SERVER")) 709 | ) 710 | smtp_server.login( 711 | push_config.get("SMTP_EMAIL"), push_config.get("SMTP_PASSWORD") 712 | ) 713 | smtp_server.sendmail( 714 | push_config.get("SMTP_EMAIL"), 715 | smtp_email_to, 716 | message.as_bytes(), 717 | ) 718 | smtp_server.close() 719 | print("SMTP 邮件 推送成功!") 720 | except Exception as e: 721 | print(f"SMTP 邮件 推送失败!{e}") 722 | 723 | 724 | def pushme(title: str, content: str) -> None: 725 | """ 726 | 使用 PushMe 推送消息。 727 | """ 728 | if not push_config.get("PUSHME_KEY"): 729 | print("PushMe 服务的 PUSHME_KEY 未设置!!\n取消推送") 730 | return 731 | print("PushMe 服务启动") 732 | 733 | url = ( 734 | push_config.get("PUSHME_URL") 735 | if push_config.get("PUSHME_URL") 736 | else "https://push.i-i.me/" 737 | ) 738 | data = { 739 | "push_key": push_config.get("PUSHME_KEY"), 740 | "title": title, 741 | "content": content, 742 | "date": push_config.get("date") if push_config.get("date") else "", 743 | "type": push_config.get("type") if push_config.get("type") else "", 744 | } 745 | response = requests.post(url, data=data) 746 | 747 | if response.status_code == 200 and response.text == "success": 748 | print("PushMe 推送成功!") 749 | else: 750 | print(f"PushMe 推送失败!{response.status_code} {response.text}") 751 | 752 | 753 | def chronocat(title: str, content: str) -> None: 754 | """ 755 | 使用 CHRONOCAT 推送消息。 756 | """ 757 | if ( 758 | not push_config.get("CHRONOCAT_URL") 759 | or not push_config.get("CHRONOCAT_QQ") 760 | or not push_config.get("CHRONOCAT_TOKEN") 761 | ): 762 | print("CHRONOCAT 服务的 CHRONOCAT_URL 或 CHRONOCAT_QQ 未设置!!\n取消推送") 763 | return 764 | 765 | print("CHRONOCAT 服务启动") 766 | 767 | user_ids = re.findall(r"user_id=(\d+)", push_config.get("CHRONOCAT_QQ")) 768 | group_ids = re.findall(r"group_id=(\d+)", push_config.get("CHRONOCAT_QQ")) 769 | 770 | url = f'{push_config.get("CHRONOCAT_URL")}/api/message/send' 771 | headers = { 772 | "Content-Type": "application/json", 773 | "Authorization": f'Bearer {push_config.get("CHRONOCAT_TOKEN")}', 774 | } 775 | 776 | for chat_type, ids in [(1, user_ids), (2, group_ids)]: 777 | if not ids: 778 | continue 779 | for chat_id in ids: 780 | data = { 781 | "peer": {"chatType": chat_type, "peerUin": chat_id}, 782 | "elements": [ 783 | { 784 | "elementType": 1, 785 | "textElement": {"content": f"{title}\n\n{content}"}, 786 | } 787 | ], 788 | } 789 | response = requests.post(url, headers=headers, data=json.dumps(data)) 790 | if response.status_code == 200: 791 | if chat_type == 1: 792 | print(f"QQ个人消息:{ids}推送成功!") 793 | else: 794 | print(f"QQ群消息:{ids}推送成功!") 795 | else: 796 | if chat_type == 1: 797 | print(f"QQ个人消息:{ids}推送失败!") 798 | else: 799 | print(f"QQ群消息:{ids}推送失败!") 800 | 801 | 802 | def ntfy(title: str, content: str) -> None: 803 | """ 804 | 通过 Ntfy 推送消息 805 | """ 806 | def encode_rfc2047(text: str) -> str: 807 | """将文本编码为符合 RFC 2047 标准的格式""" 808 | encoded_bytes = base64.b64encode(text.encode('utf-8')) 809 | encoded_str = encoded_bytes.decode('utf-8') 810 | return f'=?utf-8?B?{encoded_str}?=' 811 | 812 | if not push_config.get("NTFY_TOPIC"): 813 | print("ntfy 服务的 NTFY_TOPIC 未设置!!\n取消推送") 814 | return 815 | print("ntfy 服务启动") 816 | priority = '3' 817 | if not push_config.get("NTFY_PRIORITY"): 818 | print("ntfy 服务的NTFY_PRIORITY 未设置!!默认设置为3") 819 | else: 820 | priority = push_config.get("NTFY_PRIORITY") 821 | 822 | # 使用 RFC 2047 编码 title 823 | encoded_title = encode_rfc2047(title) 824 | 825 | data = content.encode(encoding='utf-8') 826 | headers = { 827 | "Title": encoded_title, # 使用编码后的 title 828 | "Priority": priority 829 | } 830 | 831 | url = push_config.get("NTFY_URL") + "/" + push_config.get("NTFY_TOPIC") 832 | response = requests.post(url, data=data, headers=headers) 833 | if response.status_code == 200: # 使用 response.status_code 进行检查 834 | print("Ntfy 推送成功!") 835 | else: 836 | print("Ntfy 推送失败!错误信息:", response.text) 837 | 838 | def parse_headers(headers): 839 | if not headers: 840 | return {} 841 | 842 | parsed = {} 843 | lines = headers.split("\n") 844 | 845 | for line in lines: 846 | i = line.find(":") 847 | if i == -1: 848 | continue 849 | 850 | key = line[:i].strip().lower() 851 | val = line[i + 1 :].strip() 852 | parsed[key] = parsed.get(key, "") + ", " + val if key in parsed else val 853 | 854 | return parsed 855 | 856 | 857 | def parse_string(input_string, value_format_fn=None): 858 | matches = {} 859 | pattern = r"(\w+):\s*((?:(?!\n\w+:).)*)" 860 | regex = re.compile(pattern) 861 | for match in regex.finditer(input_string): 862 | key, value = match.group(1).strip(), match.group(2).strip() 863 | try: 864 | value = value_format_fn(value) if value_format_fn else value 865 | json_value = json.loads(value) 866 | matches[key] = json_value 867 | except: 868 | matches[key] = value 869 | return matches 870 | 871 | 872 | def parse_body(body, content_type, value_format_fn=None): 873 | if not body or content_type == "text/plain": 874 | return value_format_fn(body) if value_format_fn and body else body 875 | 876 | parsed = parse_string(body, value_format_fn) 877 | 878 | if content_type == "application/x-www-form-urlencoded": 879 | data = urllib.parse.urlencode(parsed, doseq=True) 880 | return data 881 | 882 | if content_type == "application/json": 883 | data = json.dumps(parsed) 884 | return data 885 | 886 | return parsed 887 | 888 | 889 | def custom_notify(title: str, content: str) -> None: 890 | """ 891 | 通过 自定义通知 推送消息。 892 | """ 893 | if not push_config.get("WEBHOOK_URL") or not push_config.get("WEBHOOK_METHOD"): 894 | print("自定义通知的 WEBHOOK_URL 或 WEBHOOK_METHOD 未设置!!\n取消推送") 895 | return 896 | 897 | print("自定义通知服务启动") 898 | 899 | WEBHOOK_URL = push_config.get("WEBHOOK_URL") 900 | WEBHOOK_METHOD = push_config.get("WEBHOOK_METHOD") 901 | WEBHOOK_CONTENT_TYPE = push_config.get("WEBHOOK_CONTENT_TYPE") 902 | WEBHOOK_BODY = push_config.get("WEBHOOK_BODY") 903 | WEBHOOK_HEADERS = push_config.get("WEBHOOK_HEADERS") 904 | 905 | if "$title" not in WEBHOOK_URL and "$title" not in WEBHOOK_BODY: 906 | print("请求头或者请求体中必须包含 $title 和 $content") 907 | return 908 | 909 | headers = parse_headers(WEBHOOK_HEADERS) 910 | body = parse_body( 911 | WEBHOOK_BODY, 912 | WEBHOOK_CONTENT_TYPE, 913 | lambda v: v.replace("$title", title.replace("\n", "\\n")).replace( 914 | "$content", content.replace("\n", "\\n") 915 | ), 916 | ) 917 | formatted_url = WEBHOOK_URL.replace( 918 | "$title", urllib.parse.quote_plus(title) 919 | ).replace("$content", urllib.parse.quote_plus(content)) 920 | response = requests.request( 921 | method=WEBHOOK_METHOD, url=formatted_url, headers=headers, timeout=15, data=body 922 | ) 923 | 924 | if response.status_code == 200: 925 | print("自定义通知推送成功!") 926 | else: 927 | print(f"自定义通知推送失败!{response.status_code} {response.text}") 928 | 929 | 930 | def one() -> str: 931 | """ 932 | 获取一条一言。 933 | :return: 934 | """ 935 | url = "https://v1.hitokoto.cn/" 936 | res = requests.get(url).json() 937 | return res["hitokoto"] + " ----" + res["from"] 938 | 939 | 940 | def add_notify_function(): 941 | notify_function = [] 942 | if push_config.get("BARK_PUSH"): 943 | notify_function.append(bark) 944 | if push_config.get("CONSOLE"): 945 | notify_function.append(console) 946 | if push_config.get("DD_BOT_TOKEN") and push_config.get("DD_BOT_SECRET"): 947 | notify_function.append(dingding_bot) 948 | if push_config.get("FSKEY"): 949 | notify_function.append(feishu_bot) 950 | if push_config.get("GOBOT_URL") and push_config.get("GOBOT_QQ"): 951 | notify_function.append(go_cqhttp) 952 | if push_config.get("GOTIFY_URL") and push_config.get("GOTIFY_TOKEN"): 953 | notify_function.append(gotify) 954 | if push_config.get("IGOT_PUSH_KEY"): 955 | notify_function.append(iGot) 956 | if push_config.get("PUSH_KEY"): 957 | notify_function.append(serverJ) 958 | if push_config.get("DEER_KEY"): 959 | notify_function.append(pushdeer) 960 | if push_config.get("CHAT_URL") and push_config.get("CHAT_TOKEN"): 961 | notify_function.append(chat) 962 | if push_config.get("PUSH_PLUS_TOKEN"): 963 | notify_function.append(pushplus_bot) 964 | if push_config.get("WE_PLUS_BOT_TOKEN"): 965 | notify_function.append(weplus_bot) 966 | if push_config.get("QMSG_KEY") and push_config.get("QMSG_TYPE"): 967 | notify_function.append(qmsg_bot) 968 | if push_config.get("QYWX_AM"): 969 | notify_function.append(wecom_app) 970 | if push_config.get("QYWX_KEY"): 971 | notify_function.append(wecom_bot) 972 | if push_config.get("TG_BOT_TOKEN") and push_config.get("TG_USER_ID"): 973 | notify_function.append(telegram_bot) 974 | if ( 975 | push_config.get("AIBOTK_KEY") 976 | and push_config.get("AIBOTK_TYPE") 977 | and push_config.get("AIBOTK_NAME") 978 | ): 979 | notify_function.append(aibotk) 980 | if ( 981 | push_config.get("SMTP_SERVER") 982 | and push_config.get("SMTP_SSL") 983 | and push_config.get("SMTP_EMAIL") 984 | and push_config.get("SMTP_PASSWORD") 985 | and push_config.get("SMTP_NAME") 986 | ): 987 | notify_function.append(smtp) 988 | if push_config.get("PUSHME_KEY"): 989 | notify_function.append(pushme) 990 | if ( 991 | push_config.get("CHRONOCAT_URL") 992 | and push_config.get("CHRONOCAT_QQ") 993 | and push_config.get("CHRONOCAT_TOKEN") 994 | ): 995 | notify_function.append(chronocat) 996 | if push_config.get("WEBHOOK_URL") and push_config.get("WEBHOOK_METHOD"): 997 | notify_function.append(custom_notify) 998 | if push_config.get("NTFY_TOPIC"): 999 | notify_function.append(ntfy) 1000 | if not notify_function: 1001 | print(f"无推送渠道,请检查通知变量是否正确") 1002 | return notify_function 1003 | 1004 | 1005 | def send(title: str, content: str, ignore_default_config: bool = False, **kwargs): 1006 | if kwargs: 1007 | global push_config 1008 | if ignore_default_config: 1009 | push_config = kwargs # 清空从环境变量获取的配置 1010 | else: 1011 | push_config.update(kwargs) 1012 | 1013 | if not content: 1014 | print(f"{title} 推送内容为空!") 1015 | return 1016 | 1017 | # 根据标题跳过一些消息推送,环境变量:SKIP_PUSH_TITLE 用回车分隔 1018 | skipTitle = os.getenv("SKIP_PUSH_TITLE") 1019 | if skipTitle: 1020 | if title in re.split("\n", skipTitle): 1021 | print(f"{title} 在SKIP_PUSH_TITLE环境变量内,跳过推送!") 1022 | return 1023 | 1024 | hitokoto = push_config.get("HITOKOTO") 1025 | if hitokoto and str(hitokoto).lower() != "false": 1026 | content += "\n\n" + one() 1027 | 1028 | notify_function = add_notify_function() 1029 | ts = [ 1030 | threading.Thread(target=mode, args=(title, content), name=mode.__name__) 1031 | for mode in notify_function 1032 | ] 1033 | [t.start() for t in ts] 1034 | [t.join() for t in ts] 1035 | 1036 | 1037 | def main(): 1038 | send("title", "content") 1039 | 1040 | 1041 | if __name__ == "__main__": 1042 | main() -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pycryptodome 2 | requests -------------------------------------------------------------------------------- /telecom_class.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # _*_ coding:utf-8 _*_ 3 | 4 | import re 5 | import base64 6 | import random 7 | import requests 8 | from datetime import datetime 9 | from Crypto.PublicKey import RSA 10 | from Crypto.Cipher import PKCS1_v1_5 11 | 12 | 13 | class Telecom: 14 | def __init__(self): 15 | self.login_info = {} 16 | self.phonenum = None 17 | self.password = None 18 | self.token = None 19 | self.client_type = "#9.7.0#channel50#iPhone 14 Pro#" 20 | self.headers = { 21 | "Accept": "application/json", 22 | "Content-Type": "application/json; charset=UTF-8", 23 | "Connection": "Keep-Alive", 24 | "Accept-Encoding": "gzip", 25 | "user-agent": "iPhone 14 Pro/9.7.0", 26 | } 27 | 28 | def set_login_info(self, login_info): 29 | self.login_info = login_info 30 | self.phonenum = login_info.get("phoneNbr", None) 31 | self.password = login_info.get("password", None) 32 | self.token = login_info.get("token", None) 33 | 34 | def trans_number(self, phonenum, encode=True): 35 | result = "" 36 | caesar_size = 2 if encode else -2 37 | for char in phonenum: 38 | result += chr(ord(char) + caesar_size & 65535) 39 | return result 40 | 41 | def encrypt(self, str): 42 | public_key_pem = """-----BEGIN PUBLIC KEY----- 43 | MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDBkLT15ThVgz6/NOl6s8GNPofd 44 | WzWbCkWnkaAm7O2LjkM1H7dMvzkiqdxU02jamGRHLX/ZNMCXHnPcW/sDhiFCBN18 45 | qFvy8g6VYb9QtroI09e176s+ZCtiv7hbin2cCTj99iUpnEloZm19lwHyo69u5UMi 46 | PMpq0/XKBO8lYhN/gwIDAQAB 47 | -----END PUBLIC KEY-----""" 48 | public_key = RSA.import_key(public_key_pem.encode()) 49 | cipher = PKCS1_v1_5.new(public_key) 50 | ciphertext = cipher.encrypt(str.encode()) 51 | encoded_ciphertext = base64.b64encode(ciphertext).decode() 52 | return encoded_ciphertext 53 | 54 | def get_fee_flow_limit(self, fee_remain_flow): 55 | today = datetime.today() 56 | days_in_month = ( 57 | datetime(today.year, today.month + 1, 1) 58 | - datetime(today.year, today.month, 1) 59 | ).days 60 | return int((fee_remain_flow / days_in_month)) 61 | 62 | def do_login(self, phonenum, password): 63 | phonenum = phonenum or self.phonenum 64 | password = password or self.password 65 | uuid = str(random.randint(1000000000000000, 9999999999999999)) 66 | ts = datetime.now().strftime("%Y%m%d%H%M%S") 67 | enc_str = f"iPhone 14 13.2.{uuid[:12]}{phonenum}{ts}{password}0$$$0." 68 | body = { 69 | "content": { 70 | "fieldData": { 71 | "accountType": "", 72 | "authentication": self.trans_number(password), 73 | "deviceUid": uuid[:16], 74 | "isChinatelecom": "0", 75 | "loginAuthCipherAsymmertric": self.encrypt(enc_str), 76 | "loginType": "4", 77 | "phoneNum": self.trans_number(phonenum), 78 | "systemVersion": "13.2.3", 79 | }, 80 | "attach": "test", 81 | }, 82 | "headerInfos": { 83 | "code": "userLoginNormal", 84 | "clientType": self.client_type, 85 | "timestamp": ts, 86 | "shopId": "20002", 87 | "source": "110003", 88 | "sourcePassword": "Sid98s", 89 | "userLoginName": phonenum, 90 | }, 91 | } 92 | response = requests.post( 93 | "https://appgologin.189.cn:9031/login/client/userLoginNormal", 94 | headers=self.headers, 95 | json=body, 96 | ) 97 | return response.json() 98 | 99 | def qry_important_data(self, **kwargs): 100 | ts = datetime.now().strftime("%Y%m%d%H%M00") 101 | body = { 102 | "content": { 103 | "fieldData": { 104 | "provinceCode": self.login_info["provinceCode"] or "600101", 105 | "cityCode": self.login_info["cityCode"] or "8441900", 106 | "shopId": "20002", 107 | "isChinatelecom": "0", 108 | "account": self.trans_number(self.phonenum), 109 | }, 110 | "attach": "test", 111 | }, 112 | "headerInfos": { 113 | "code": "userFluxPackage", 114 | "clientType": self.client_type, 115 | "timestamp": ts, 116 | "shopId": "20002", 117 | "source": "110003", 118 | "sourcePassword": "Sid98s", 119 | "userLoginName": self.phonenum, 120 | "token": kwargs.get("token") or self.token, 121 | }, 122 | } 123 | response = requests.post( 124 | "https://appfuwu.189.cn:9021/query/qryImportantData", 125 | headers=self.headers, 126 | json=body, 127 | ) 128 | # print(response.text) 129 | return response.json() 130 | 131 | def user_flux_package(self, **kwargs): 132 | ts = datetime.now().strftime("%Y%m%d%H%M00") 133 | body = { 134 | "content": { 135 | "fieldData": { 136 | "queryFlag": "0", 137 | "accessAuth": "1", 138 | "account": self.trans_number(self.phonenum), 139 | }, 140 | "attach": "test", 141 | }, 142 | "headerInfos": { 143 | "code": "userFluxPackage", 144 | "clientType": self.client_type, 145 | "timestamp": ts, 146 | "shopId": "20002", 147 | "source": "110003", 148 | "sourcePassword": "Sid98s", 149 | "userLoginName": self.phonenum, 150 | "token": kwargs.get("token") or self.token, 151 | }, 152 | } 153 | response = requests.post( 154 | "https://appfuwu.189.cn:9021/query/userFluxPackage", 155 | headers=self.headers, 156 | json=body, 157 | ) 158 | # print(response.text) 159 | return response.json() 160 | 161 | def qry_share_usage(self, **kwargs): 162 | billing_cycle = kwargs.get("billing_cycle") or datetime.now().strftime("%Y%m") 163 | ts = datetime.now().strftime("%Y%m%d%H%M00") 164 | body = { 165 | "content": { 166 | "attach": "test", 167 | "fieldData": { 168 | "billingCycle": billing_cycle, 169 | "account": self.trans_number(self.phonenum), 170 | }, 171 | }, 172 | "headerInfos": { 173 | "code": "qryShareUsage", 174 | "clientType": self.client_type, 175 | "timestamp": ts, 176 | "shopId": "20002", 177 | "source": "110003", 178 | "sourcePassword": "Sid98s", 179 | "userLoginName": self.phonenum, 180 | "token": kwargs.get("token") or self.token, 181 | }, 182 | } 183 | response = requests.post( 184 | "https://appfuwu.189.cn:9021/query/qryShareUsage", 185 | headers=self.headers, 186 | json=body, 187 | ) 188 | data = response.json() 189 | # 返回的号码字段加密,需做解密转换 190 | if data.get("responseData").get("data").get("sharePhoneBeans"): 191 | for item in data["responseData"]["data"]["sharePhoneBeans"]: 192 | item["sharePhoneNum"] = self.trans_number(item["sharePhoneNum"], False) 193 | for share_type in data["responseData"]["data"]["shareTypeBeans"]: 194 | for share_info in share_type["shareUsageInfos"]: 195 | for share_amount in share_info["shareUsageAmounts"]: 196 | share_amount["phoneNum"] = self.trans_number( 197 | share_amount["phoneNum"], False 198 | ) 199 | return data 200 | 201 | def to_summary(self, data, phonenum=""): 202 | if not data: 203 | return {} 204 | phonenum = phonenum or self.phonenum 205 | # 总流量 206 | flow_use = int(data["flowInfo"]["totalAmount"]["used"] or 0) 207 | flow_balance = int(data["flowInfo"]["totalAmount"]["balance"] or 0) 208 | flow_total = flow_use + flow_balance 209 | flow_over = int(data["flowInfo"]["totalAmount"]["over"] or 0) 210 | # 通用流量 211 | common_use = int(data["flowInfo"]["commonFlow"]["used"] or 0) 212 | common_balance = int(data["flowInfo"]["commonFlow"]["balance"] or 0) 213 | common_total = common_use + common_balance 214 | common_over = int(data["flowInfo"]["commonFlow"]["over"] or 0) 215 | # 专用流量 216 | special_use = ( 217 | int(data["flowInfo"]["specialAmount"]["used"] or 0) 218 | if data["flowInfo"].get("specialAmount") 219 | else 0 220 | ) 221 | special_balance = ( 222 | int(data["flowInfo"]["specialAmount"]["balance"] or 0) 223 | if data["flowInfo"].get("specialAmount") 224 | else 0 225 | ) 226 | special_total = special_use + special_balance 227 | # 语音通话 228 | voice_usage = int(data["voiceInfo"]["voiceDataInfo"]["used"] or 0) 229 | voice_balance = int(data["voiceInfo"]["voiceDataInfo"]["balance"] or 0) 230 | voice_total = int(data["voiceInfo"]["voiceDataInfo"]["total"] or 0) 231 | # 余额 232 | balance = int( 233 | float(data["balanceInfo"]["indexBalanceDataInfo"]["balance"] or 0) * 100 234 | ) 235 | # 流量包列表 236 | flowItems = [] 237 | flow_lists = data.get("flowInfo", {}).get("flowList", []) 238 | for item in flow_lists: 239 | if "流量" not in item["title"]: 240 | continue 241 | # 常规流量 242 | if "已用" in item["leftTitle"] and "剩余" in item["rightTitle"]: 243 | item_use = self.convert_flow(item["leftTitleHh"], "KB") 244 | item_balance = self.convert_flow(item["rightTitleHh"], "KB") 245 | item_total = item_use + item_balance 246 | # 常规流量,超流量 247 | elif "超出" in item["leftTitle"] and "/" in item["rightTitleEnd"]: 248 | item_balance = -self.convert_flow(item["leftTitleHh"], "KB") 249 | item_use = ( 250 | self.convert_flow(item["rightTitleEnd"].split("/")[1], "KB") 251 | - item_balance 252 | ) 253 | item_total = item_use + item_balance 254 | # 无限流量,达量降速 255 | elif "已用" in item["leftTitle"] and "降速" in item["rightTitle"]: 256 | item_total = self.convert_flow( 257 | re.search(r"(\d+[KMGT]B)", item["rightTitle"]).group(1), "KB" 258 | ) 259 | item_use = self.convert_flow(item["leftTitleHh"], "KB") 260 | item_balance = item_total - item_use 261 | flowItems.append( 262 | { 263 | "name": item["title"], 264 | "use": item_use, 265 | "balance": item_balance, 266 | "total": item_total, 267 | } 268 | ) 269 | summary = { 270 | "phonenum": phonenum, 271 | "balance": balance, 272 | "voiceUsage": voice_usage, 273 | "voiceTotal": voice_total, 274 | "flowUse": flow_use, 275 | "flowTotal": flow_total, 276 | "flowOver": flow_over, 277 | "commonUse": common_use, 278 | "commonTotal": common_total, 279 | "commonOver": common_over, 280 | "specialUse": special_use, 281 | "specialTotal": special_total, 282 | "createTime": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), 283 | "flowItems": flowItems, 284 | } 285 | return summary 286 | 287 | def convert_flow(self, size_str, target_unit="KB", decimal=0): 288 | unit_dict = {"KB": 1024, "MB": 1024**2, "GB": 1024**3, "TB": 1024**4} 289 | if not size_str: 290 | return 0 291 | if isinstance(size_str, str): 292 | size, unit = float(size_str[:-2]), size_str[-2:] 293 | elif isinstance(size_str, (int, float)): 294 | size, unit = size_str, "KB" 295 | if unit in unit_dict or target_unit in unit_dict: 296 | return ( 297 | int(size * unit_dict[unit] / unit_dict[target_unit]) 298 | if decimal == 0 299 | else round(size * unit_dict[unit] / unit_dict[target_unit], decimal) 300 | ) 301 | else: 302 | raise ValueError("Invalid unit") 303 | -------------------------------------------------------------------------------- /telecom_monitor.py: -------------------------------------------------------------------------------- 1 | # !/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # Repo: https://github.com/Cp0204/ChinaTelecomMonitor 4 | # ConfigFile: telecom_config.json 5 | # Modify: 2024-05-11 6 | 7 | """ 8 | 任务名称 9 | name: 电信套餐用量监控 10 | 定时规则 11 | cron: 0 20 * * * 12 | """ 13 | 14 | import os 15 | import sys 16 | import json 17 | from datetime import datetime 18 | 19 | # 兼容青龙 20 | try: 21 | from telecom_class import Telecom 22 | except: 23 | print("正在尝试自动安装依赖...") 24 | os.system("pip3 install pycryptodome requests &> /dev/null") 25 | from telecom_class import Telecom 26 | 27 | 28 | CONFIG_DATA = {} 29 | NOTIFYS = [] 30 | CONFIG_PATH = sys.argv[1] if len(sys.argv) > 1 else "telecom_config.json" 31 | 32 | 33 | # 发送通知消息 34 | def send_notify(title, body): 35 | try: 36 | # 导入通知模块 37 | import notify 38 | 39 | # 如未配置 push_config 则使用青龙环境通知设置 40 | if CONFIG_DATA.get("push_config"): 41 | notify.push_config = CONFIG_DATA["push_config"].copy() 42 | notify.push_config["CONSOLE"] = notify.push_config.get("CONSOLE", True) 43 | notify.send(title, body) 44 | except Exception as e: 45 | if e: 46 | print("发送通知消息失败!") 47 | 48 | 49 | # 添加消息 50 | def add_notify(text): 51 | global NOTIFYS 52 | NOTIFYS.append(text) 53 | print("📢", text) 54 | return text 55 | 56 | 57 | def main(): 58 | global CONFIG_DATA 59 | start_time = datetime.now() 60 | print(f"===============程序开始===============") 61 | print(f"⏰ 执行时间: {start_time.strftime('%Y-%m-%d %H:%M:%S')}") 62 | print() 63 | # 读取配置 64 | if os.path.exists(CONFIG_PATH): 65 | print(f"⚙️ 正从 {CONFIG_PATH} 文件中读取配置") 66 | with open(CONFIG_PATH, "r", encoding="utf-8") as file: 67 | CONFIG_DATA = json.load(file) 68 | if not CONFIG_DATA.get("user"): 69 | CONFIG_DATA["user"] = {} 70 | 71 | telecom = Telecom() 72 | 73 | def auto_login(): 74 | if TELECOM_USER := os.environ.get("TELECOM_USER"): 75 | phonenum, password = ( 76 | TELECOM_USER[:11], 77 | TELECOM_USER[11:], 78 | ) 79 | elif TELECOM_USER := CONFIG_DATA.get("user", {}): 80 | phonenum, password = ( 81 | TELECOM_USER.get("phonenum", ""), 82 | TELECOM_USER.get("password", ""), 83 | ) 84 | else: 85 | exit("自动登录:未设置账号密码,退出") 86 | if not phonenum.isdigit(): 87 | exit("自动登录:手机号设置错误,退出") 88 | else: 89 | print(f"自动登录:{phonenum}") 90 | login_failure_count = CONFIG_DATA.get("user", {}).get("loginFailureCount", 0) 91 | if login_failure_count < 5: 92 | data = telecom.do_login(phonenum, password) 93 | if data.get("responseData").get("resultCode") == "0000": 94 | print(f"自动登录:成功") 95 | login_info = data["responseData"]["data"]["loginSuccessResult"] 96 | login_info["createTime"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") 97 | CONFIG_DATA["login_info"] = login_info 98 | telecom.set_login_info(login_info) 99 | else: 100 | login_failure_count += 1 101 | CONFIG_DATA["user"]["loginFailureCount"] = login_failure_count 102 | update_config() 103 | add_notify(f"自动登录:记录失败{login_failure_count}次,程序退出") 104 | exit(data) 105 | else: 106 | print(f"自动登录:记录失败{login_failure_count}次,跳过执行") 107 | exit() 108 | 109 | # 读取缓存Token 110 | login_info = CONFIG_DATA.get("login_info", {}) 111 | if login_info: 112 | print(f"尝试使用缓存登录:{login_info['phoneNbr']}") 113 | telecom.set_login_info(login_info) 114 | else: 115 | auto_login() 116 | 117 | # 获取主要信息 118 | important_data = telecom.qry_important_data() 119 | if important_data.get("responseData"): 120 | print(f"获取主要信息:成功") 121 | elif important_data["headerInfos"]["code"] == "X201": 122 | print(f"获取主要信息:失败 {important_data['headerInfos']['reason']}") 123 | auto_login() 124 | important_data = telecom.qry_important_data() 125 | 126 | # 简化主要信息 127 | try: 128 | summary = telecom.to_summary(important_data["responseData"]["data"]) 129 | except Exception as e: 130 | exit( 131 | f"简化主要信息出错,提 Issue 请提供以下信息(隐私打码):\n\n{json.dumps(important_data['responseData']['data'], ensure_ascii=False)}\n\n{e}" 132 | ) 133 | if summary: 134 | print(f"简化主要信息:{summary}") 135 | CONFIG_DATA["summary"] = summary 136 | 137 | # 获取流量包明细 138 | flux_package_str = "" 139 | user_flux_package = telecom.user_flux_package() 140 | if user_flux_package: 141 | print("获取流量包明细:成功") 142 | packages = user_flux_package["responseData"]["data"]["productOFFRatable"][ 143 | "ratableResourcePackages" 144 | ] 145 | for package in packages: 146 | package_icon = ( 147 | "🇨🇳" 148 | if "国内" in package["title"] 149 | else "📺" if "专用" in package["title"] else "🌎" 150 | ) 151 | flux_package_str += f"\n{package_icon}{package['title']}\n" 152 | for product in package["productInfos"]: 153 | if product["infiniteTitle"]: 154 | # 无限流量 155 | flux_package_str += f"""🔹[{product['title']}]{product['infiniteTitle']}{product['infiniteValue']}{product['infiniteUnit']}/无限\n""" 156 | else: 157 | flux_package_str += f"""🔹[{product['title']}]{product['leftTitle']}{product['leftHighlight']}{product['rightCommon']}\n""" 158 | # 流量字符串 159 | common_str = ( 160 | f"{telecom.convert_flow(summary['commonUse'],'GB',2)} / {telecom.convert_flow(summary['commonTotal'],'GB',2)} GB 🟢" 161 | if summary["flowOver"] == 0 162 | else f"-{telecom.convert_flow(summary['flowOver'],'GB',2)} / {telecom.convert_flow(summary['commonTotal'],'GB',2)} GB 🔴" 163 | ) 164 | special_str = ( 165 | f"{telecom.convert_flow(summary['specialUse'], 'GB', 2)} / {telecom.convert_flow(summary['specialTotal'], 'GB', 2)} GB" 166 | if summary["specialTotal"] > 0 167 | else "" 168 | ) 169 | # 添加通知 170 | add_notify( 171 | f""" 172 | 📱 手机:{summary['phonenum']} 173 | 💰 余额:{round(summary['balance']/100,2)} 174 | 📞 通话:{summary['voiceUsage']}{f" / {summary['voiceTotal']}" if summary['voiceTotal']>0 else ""} min 175 | 🌐 总流量 176 | - 通用:{common_str}{f"{chr(10)} - 专用:{special_str}" if special_str else ""} 177 | 178 | 【流量包明细】 179 | 180 | {flux_package_str.strip()} 181 | 182 | 查询时间:{summary['createTime']} 183 | """.strip() 184 | ) 185 | 186 | # 通知 187 | if NOTIFYS: 188 | notify_body = "\n".join(NOTIFYS) 189 | print(f"===============推送通知===============") 190 | send_notify("【电信套餐用量监控】", notify_body) 191 | print() 192 | 193 | update_config() 194 | 195 | 196 | def update_config(): 197 | # 更新配置 198 | with open(CONFIG_PATH, "w", encoding="utf-8") as file: 199 | json.dump(CONFIG_DATA, file, ensure_ascii=False, indent=2) 200 | 201 | 202 | if __name__ == "__main__": 203 | main() 204 | --------------------------------------------------------------------------------