├── .github └── workflows │ └── build.yaml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── build.gradle.kts ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── local.properties ├── settings.gradle.kts └── src ├── main ├── kotlin │ └── xyz │ │ └── cssxsh │ │ ├── mirai │ │ └── weibo │ │ │ ├── WeiboFilter.kt │ │ │ ├── WeiboHelperPlugin.kt │ │ │ ├── WeiboHistoryDelegate.kt │ │ │ ├── WeiboListener.kt │ │ │ ├── WeiboPicture.kt │ │ │ ├── WeiboSubscriber.kt │ │ │ ├── WeiboUtils.kt │ │ │ ├── command │ │ │ ├── WeiboCacheCommand.kt │ │ │ ├── WeiboContext.kt │ │ │ ├── WeiboDetailCommand.kt │ │ │ ├── WeiboFollowCommand.kt │ │ │ ├── WeiboGroupCommand.kt │ │ │ ├── WeiboHotCommand.kt │ │ │ ├── WeiboLoginCommand.kt │ │ │ ├── WeiboSuperChatCommand.kt │ │ │ └── WeiboUserCommand.kt │ │ │ └── data │ │ │ ├── OffsetDateTimeSerializer.kt │ │ │ ├── RegexSerializer.kt │ │ │ ├── WeiboEmoticonData.kt │ │ │ ├── WeiboHelperSettings.kt │ │ │ ├── WeiboStatusData.kt │ │ │ ├── WeiboTaskData.kt │ │ │ └── WeiboTaskInfo.kt │ │ └── weibo │ │ ├── AcceptAllCookiesStorage.kt │ │ ├── Load.kt │ │ ├── WeiboClient.kt │ │ ├── api │ │ ├── Api.kt │ │ ├── Feed.kt │ │ ├── Login.kt │ │ ├── Profile.kt │ │ ├── Statuses.kt │ │ └── SuperChat.kt │ │ └── data │ │ ├── Login.kt │ │ ├── MicroBlog.kt │ │ ├── PageInfo.kt │ │ ├── Serializer.kt │ │ ├── SuperChat.kt │ │ ├── UserDetailData.kt │ │ └── UserGroupData.kt └── resources │ ├── META-INF │ └── services │ │ ├── net.mamoe.mirai.console.command.Command │ │ ├── net.mamoe.mirai.console.data.PluginConfig │ │ ├── net.mamoe.mirai.console.data.PluginData │ │ └── net.mamoe.mirai.console.plugin.jvm.JvmPlugin │ └── xyz │ └── cssxsh │ └── mirai │ └── weibo │ └── data │ └── Emoticons.json └── test └── kotlin └── xyz └── cssxsh ├── mirai └── weibo │ └── data │ └── WeiboEmoticonDataTest.kt └── weibo ├── FeedKtTest.kt ├── LoginKtTest.kt ├── ProfileKtTest.kt └── WeiboClientTest.kt /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: "build" 2 | on: 3 | push: 4 | paths-ignore: 5 | - '**/*.md' 6 | pull_request: 7 | paths-ignore: 8 | - '**/*.md' 9 | 10 | jobs: 11 | release: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v3 16 | 17 | - name: Setup JDK 11 18 | uses: actions/setup-java@v3 19 | with: 20 | distribution: 'adopt' 21 | java-version: '11' 22 | 23 | - name: chmod -R 777 * 24 | run: chmod -R 777 * 25 | 26 | - name: BuildPlugin 27 | run: ./gradlew buildPlugin 28 | 29 | - name: Upload 30 | uses: actions/upload-artifact@v3 31 | with: 32 | name: build-${{ github.run_id }} 33 | path: build/mirai/* -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Project exclude paths 2 | **/.gradle/ 3 | **/build/ 4 | 5 | # idea 6 | /.idea/ 7 | 8 | # mirai 9 | /data/ 10 | /plugins/ 11 | /logs/ 12 | /test/ 13 | /run/ 14 | 15 | # temp 16 | /temp/ 17 | 18 | debug-sandbox -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.6.2 (23/04/17) 2 | 3 | * feat: 屏蔽点赞转发设置 4 | 5 | ## 1.6.1 (23/02/20) 6 | 7 | * fix: restore 8 | 9 | 修复刷新 `cookie` 的等待时间 10 | 11 | ## 1.6.0 (23/02/12) 12 | 13 | * style: explicitApi 14 | * feat: 超话指令 15 | * fix: logger level 16 | * fix: auto.parser 17 | 18 | 原 `xyz.cssxsh.mirai.plugin.weibo-helper:quiet.group` 取消 19 | 改为 `xyz.cssxsh.mirai.plugin.weibo-helper:auto.parser` 20 | 持有权限的用户(群员)才会触发自动解析 21 | 22 | MCL 更新通道推荐改为 `maven-stable` 23 | 原 `stable` 今后不再积极更新 -------------------------------------------------------------------------------- /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 | # [Weibo Helper](https://github.com/cssxsh/weibo-helper) 2 | 3 | > 基于 [Mirai Console](https://github.com/mamoe/mirai-console) 的 [微博](https://weibo.com/) 转发插件 4 | 5 | [![Release](https://img.shields.io/github/v/release/cssxsh/weibo-helper)](https://github.com/cssxsh/weibo-helper/releases) 6 | [![Downloads](https://img.shields.io/github/downloads/cssxsh/weibo-helper/total)](https://shields.io/category/downloads) 7 | [![MiraiForum](https://img.shields.io/badge/post-on%20MiraiForum-yellow)](https://mirai.mamoe.net/topic/212) 8 | 9 | **使用前应该查阅的相关文档或项目** 10 | 11 | * [User Manual](https://github.com/mamoe/mirai/blob/dev/docs/UserManual.md) 12 | * [Permission Command](https://github.com/mamoe/mirai/blob/dev/mirai-console/docs/BuiltInCommands.md#permissioncommand) 13 | * [Chat Command](https://github.com/project-mirai/chat-command) 14 | 15 | 插件基于PC网页版微博API,使用插件需要[登录](#登录指令)一个微博账号 16 | 插件初始化时,如果恢复登录状态失败,则会尝试模拟游客 17 | 18 | ## 指令 19 | 20 | 注意: 使用前请确保可以 [在聊天环境执行指令](https://github.com/project-mirai/chat-command) 21 | 带括号的 `/` 前缀是可选的 22 | `<...>` 中的是指令名,由空格隔开表示或,选择其中任一名称都可执行例如 `/微博用户 订阅` 23 | `[...]` 表示参数,当 `[...]` 后面带 `?` 时表示参数可选 24 | `{...}` 表示连续的多个参数 25 | 26 | 本插件指令权限ID 格式为 `xyz.cssxsh.mirai.plugin.weibo-helper:command.*`, `*` 是指令的第一指令名 27 | 例如 `/微博用户 订阅` 的权限ID为 `xyz.cssxsh.mirai.plugin.weibo-helper:command.wuser` 28 | 29 | `[subject]?`订阅的接收对象,可选参数, 默认填充当前聊天环境 30 | 31 | ### 登录指令 32 | 33 | | 指令 | 描述 | 34 | |:-----------------|:---------| 35 | | `/` | 登录一个微博账号 | 36 | 37 | 使用指令后,机器人会发送网页登录的二维码 38 | 使用手机微博APP扫描确认登录后 39 | 如果成功登录,则会回复 `@用户名#ID 登陆成功` 的消息 40 | 41 | ### 用户订阅指令 42 | 43 | | 指令 | 描述 | 44 | |:------------------------------------------------|:-----------| 45 | | `/ [uid] [subject]?` | 订阅一个微博账号 | 46 | | `/ [uid] [subject]?` | 取消订阅一个微博账号 | 47 | | `/ [subject]?` | 查看订阅详情 | 48 | 49 | `uid` 是用户的ID,可以在用户的主页获得, 50 | 例如 的 `1111681197` 51 | 使用订阅指令后,如果成功找到指定用户,则会回复 52 | `对@用户名#ID 的监听任务, 添加完成` 53 | 54 | ### 分组订阅指令 55 | 56 | | 指令 | 描述 | 57 | |:-----------------------------------------------|:------------| 58 | | `/ ` | 列出当前账号的微博分组 | 59 | | `/ [id] [subject]?` | 订阅一个微博分组 | 60 | | `/ [id] [subject]?` | 取消订阅一个微博分组 | 61 | | `/ [subject]?` | 查看订阅详情 | 62 | 63 | `id` 是分组的GID或者TITLE,GID可以在分组的页面获得, 64 | 例如 的 `3893924734832698` 65 | 也可以通过列表指令获得,使用列表指令之后会按行回复 `title -> gid` 66 | 使用订阅指令后,如果成功找到指定分组,则会回复 67 | `对分组标题#ID的监听任务, 添加完成` 68 | 69 | ### 热搜订阅指令 70 | 71 | | 指令 | 描述 | 72 | |:-----------------------------------------------|:-----------| 73 | | `/ [word] [subject]?` | 订阅一个微博热搜 | 74 | | `/ [word] [subject]?` | 取消订阅一个微博热搜 | 75 | | `/ [subject]?` | 查看订阅详情 | 76 | 77 | ### 博文查看指令 78 | 79 | | 指令 | 描述 | 80 | |:-----------------------------|:---------| 81 | | `/ [mid]` | 查看指定微博内容 | 82 | 83 | ### 关注指令 84 | 85 | | 指令 | 描述 | 86 | |:------------------------|:-------| 87 | | `/ [uid]` | 关注指定用户 | 88 | 89 | ## 解析微博链接 90 | 91 | 机器人会将群里中的微博链接捕获,并将微博内容回复给发送微博链接的人 92 | 这个功能默认开启,通过权限 `xyz.cssxsh.mirai.plugin.weibo-helper:quiet.group` 设置不开启的群聊 93 | 94 | ## 配置 95 | 96 | 位于 `Mirai-Console` 运行目录下的 `config/xyz.cssxsh.mirai.plugin.weibo-helper` 文件夹下的 `WeiboHelperSettings` 文件 97 | 98 | * `cache` 图片缓存位置 99 | * `expire` 图片缓存过期时间,单位小时,默认3天,为0时不会过期 100 | * `following` 是否清理收藏的用户的缓存,默认 true 101 | * `fast` 快速轮询间隔,单位分钟 102 | * `slow` 慢速轮询间隔,单位分钟 103 | * `contact` 登录状态失效联系人,当微博的登录状态失效时会向这个QQ号发送消息 104 | * `repost` 微博订阅器,最少转发数过滤器,只对列表订阅生效,默认16 105 | * `users` 微博订阅器,屏蔽用户 106 | * `regexes` 微博订阅器,屏蔽的关键词正则表达式 107 | * `urls` 微博订阅器,屏蔽的URL类型, 屏蔽视频可以尝试填入`39` 108 | * `original` 只接受原创内容,屏蔽转发 109 | * `video` 发送微博视频文件 110 | * `emoticon` 处理微博表情 111 | * `picture` 图片设置 112 | * `cover` 封面设置 113 | * `history` 历史记录保留时间,单位天,默认 7d 114 | * `timeout` Http 超时时间,单位毫秒,默认 60_000 ms 115 | * `forward` 以转发消息的方式发送订阅微博 116 | * `show_url` 是否显示url 117 | * `interval` 自动解析同样内容的间隔,单位毫秒,默认 600_000 ms 118 | 119 | ## 图片设置 120 | 121 | 有四种设置方案 122 | 123 | * 一张也不显示 124 | ```yaml 125 | picture: 126 | type: none 127 | value: {} 128 | ``` 129 | 130 | * 全部显示 131 | ```yaml 132 | picture: 133 | type: all 134 | value: {} 135 | ``` 136 | 137 | * 最多显示total张 138 | ```yaml 139 | picture: 140 | type: limit 141 | value: 142 | total: 3 143 | ``` 144 | 145 | * 超过total张一张也不显示 146 | ```yaml 147 | picture: 148 | type: top 149 | value: 150 | total: 3 151 | ``` 152 | 153 | ### 自动解析 154 | 155 | ~~安静群聊, 不解析URL链接, 通过权限系统配置~~ 156 | `/perm add g12345 xyz.cssxsh.mirai.plugin.weibo-helper:quiet.group` 157 | 改为 `xyz.cssxsh.mirai.plugin.weibo-helper:auto.parser` 158 | 持有权限的用户(群员)才会触发自动解析 159 | 160 | ## 安装 161 | 162 | ### MCL 指令安装 163 | 164 | **请确认 mcl.jar 的版本是 2.1.0+** 165 | `./mcl --update-package xyz.cssxsh:weibo-helper --channel maven-stable --type plugins` 166 | 167 | ### 手动安装 168 | 169 | 1. 从 [Releases](https://github.com/cssxsh/weibo-helper/releases) 或者 [Maven](https://repo1.maven.org/maven2/xyz/cssxsh/weibo-helper/) 下载 `mirai2.jar` 170 | 2. 将其放入 `plugins` 文件夹中 171 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("jvm") version "1.8.22" 3 | kotlin("plugin.serialization") version "1.8.22" 4 | 5 | id("net.mamoe.mirai-console") version "2.16.0-RC" 6 | id("me.him188.maven-central-publish") version "1.0.0-dev-3" 7 | } 8 | 9 | group = "xyz.cssxsh" 10 | version = "1.6.2" 11 | 12 | mavenCentralPublish { 13 | useCentralS01() 14 | singleDevGithubProject("cssxsh", "weibo-helper") 15 | licenseFromGitHubProject("AGPL-3.0") 16 | workingDir = System.getenv("PUBLICATION_TEMP")?.let { file(it).resolve(projectName) } 17 | ?: buildDir.resolve("publishing-tmp") 18 | publication { 19 | artifact(tasks["buildPlugin"]) 20 | } 21 | } 22 | 23 | repositories { 24 | mavenLocal() 25 | mavenCentral() 26 | } 27 | 28 | dependencies { 29 | implementation("org.jclarion:image4j:0.7") 30 | implementation("org.apache.commons:commons-text:1.10.0") 31 | implementation("org.jsoup:jsoup:1.16.1") 32 | testImplementation(kotlin("test")) 33 | testImplementation("net.mamoe.yamlkt:yamlkt-jvm:0.10.2") 34 | // 35 | implementation(platform("net.mamoe:mirai-bom:2.16.0-RC")) 36 | compileOnly("net.mamoe:mirai-console-compiler-common") 37 | testImplementation("net.mamoe:mirai-logging-slf4j") 38 | // 39 | implementation(platform("io.ktor:ktor-bom:2.3.4")) 40 | implementation("io.ktor:ktor-client-okhttp") 41 | implementation("io.ktor:ktor-client-encoding") 42 | // 43 | implementation(platform("org.slf4j:slf4j-parent:2.0.7")) 44 | testImplementation("org.slf4j:slf4j-simple") 45 | } 46 | 47 | kotlin { 48 | explicitApi() 49 | } 50 | 51 | tasks { 52 | test { 53 | useJUnitPlatform() 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official 2 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cssxsh/weibo-helper/27c9066b67facf42f4b3cd79624fcd73cb78b941/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /local.properties: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cssxsh/weibo-helper/27c9066b67facf42f4b3cd79624fcd73cb78b941/local.properties -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | mavenLocal() 4 | mavenCentral() 5 | gradlePluginPortal() 6 | } 7 | } 8 | rootProject.name = "weibo-helper" 9 | 10 | -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/weibo/WeiboFilter.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.weibo 2 | 3 | public interface WeiboFilter { 4 | public val repost: Long 5 | public val users: Set 6 | public val regexes: List 7 | public val urls: Set 8 | public val original: Boolean 9 | public val likes: Boolean 10 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/weibo/WeiboHelperPlugin.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.weibo 2 | 3 | import kotlinx.coroutines.* 4 | import net.mamoe.mirai.console.* 5 | import net.mamoe.mirai.console.command.* 6 | import net.mamoe.mirai.console.command.CommandManager.INSTANCE.register 7 | import net.mamoe.mirai.console.command.CommandManager.INSTANCE.unregister 8 | import net.mamoe.mirai.console.data.* 9 | import net.mamoe.mirai.console.extension.* 10 | import net.mamoe.mirai.console.plugin.jvm.* 11 | import net.mamoe.mirai.console.plugin.* 12 | import net.mamoe.mirai.console.util.* 13 | import net.mamoe.mirai.utils.* 14 | import xyz.cssxsh.mirai.weibo.data.* 15 | import xyz.cssxsh.weibo.* 16 | 17 | @PublishedApi 18 | internal object WeiboHelperPlugin : KotlinPlugin( 19 | JvmPluginDescription("xyz.cssxsh.mirai.plugin.weibo-helper", "1.6.2") { 20 | name("weibo-helper") 21 | author("cssxsh") 22 | } 23 | ) { 24 | private var clear: Job? = null 25 | 26 | private var restore: Job? = null 27 | 28 | override fun PluginComponentStorage.onLoad() { 29 | System.setProperty(SERIALIZATION_EXCEPTION_SAVE, dataFolderPath.toString()) 30 | runAfterStartup { 31 | launch { 32 | client.init() 33 | 34 | clear = this@WeiboHelperPlugin.launch(Dispatchers.IO) { 35 | clear() 36 | } 37 | restore = this@WeiboHelperPlugin.launch(Dispatchers.IO) { 38 | restore() 39 | } 40 | 41 | WeiboListener.start() 42 | WeiboSubscriber.start() 43 | } 44 | } 45 | } 46 | 47 | @Suppress("INVISIBLE_MEMBER") 48 | private inline fun spi(): Lazy> = lazy { 49 | with(net.mamoe.mirai.console.internal.util.PluginServiceHelper) { 50 | jvmPluginClasspath.pluginClassLoader 51 | .findServices() 52 | .loadAllServices() 53 | } 54 | } 55 | 56 | private val commands: List by spi() 57 | private val data: List by spi() 58 | private val config: List by spi() 59 | 60 | override fun onEnable() { 61 | // XXX: mirai console version check 62 | check(SemVersion.parseRangeRequirement(">= 2.12.0-RC").test(MiraiConsole.version)) { 63 | "$name $version 需要 Mirai-Console 版本 >= 2.12.0,目前版本是 ${MiraiConsole.version}" 64 | } 65 | 66 | for (command in commands) command.register() 67 | for (data in data) data.reload() 68 | for (config in config) config.reload() 69 | 70 | logger.info { "图片缓存位置 ${ImageCache.absolutePath}" } 71 | } 72 | 73 | override fun onDisable() { 74 | for (command in commands) command.unregister() 75 | 76 | WeiboSubscriber.stop() 77 | 78 | WeiboListener.stop() 79 | 80 | clear?.cancel() 81 | 82 | restore?.cancel() 83 | } 84 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/weibo/WeiboHistoryDelegate.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.weibo 2 | 3 | import kotlinx.coroutines.* 4 | import kotlinx.serialization.* 5 | import net.mamoe.mirai.utils.* 6 | import xyz.cssxsh.weibo.* 7 | import xyz.cssxsh.weibo.data.* 8 | import java.time.* 9 | import kotlin.properties.* 10 | import kotlin.reflect.* 11 | 12 | public class WeiboHistoryDelegate>(id: K, subscriber: WeiboSubscriber) : 13 | ReadOnlyProperty> { 14 | private val file = DataFolder.resolve(subscriber.type).resolve("$id.json") 15 | 16 | private val cache: MutableMap = HashMap() 17 | 18 | init { 19 | try { 20 | if (file.exists()) { 21 | cache.putAll(WeiboClient.Json.decodeFromString(file.readText().ifBlank { """{}""" })) 22 | } else { 23 | file.parentFile.mkdirs() 24 | file.writeText("{}") 25 | } 26 | } catch (cause: Exception) { 27 | logger.warning({ "${file.absolutePath} 读取失败" }, cause) 28 | } 29 | subscriber.launch(SupervisorJob()) { 30 | while (isActive) { 31 | delay(IntervalSlow.toMillis()) 32 | save() 33 | } 34 | }.invokeOnCompletion { 35 | logger.info { "WeiboHistory ${file.absolutePath} 已保存 " } 36 | save() 37 | } 38 | } 39 | 40 | private fun save() { 41 | try { 42 | val write = if (cache.size > 8196) { 43 | val expire = OffsetDateTime.now().minusDays(HistoryExpire) 44 | cache.filterValues { blog -> blog.created > expire } 45 | } else { 46 | cache 47 | } 48 | file.writeText(WeiboClient.Json.encodeToString(write)) 49 | } catch (cause: Exception) { 50 | logger.warning({ "WeiboHistory ${file.absolutePath} 保存失败" }, cause) 51 | } 52 | } 53 | 54 | override fun getValue(thisRef: Any?, property: KProperty<*>): MutableMap = cache 55 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/weibo/WeiboListener.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.weibo 2 | 3 | import kotlinx.coroutines.* 4 | import kotlinx.serialization.* 5 | import net.mamoe.mirai.console.permission.PermissionService.Companion.testPermission 6 | import net.mamoe.mirai.console.permission.PermitteeId.Companion.permitteeId 7 | import net.mamoe.mirai.console.util.ContactUtils.render 8 | import net.mamoe.mirai.contact.* 9 | import net.mamoe.mirai.event.* 10 | import net.mamoe.mirai.message.data.MessageSource.Key.quote 11 | import net.mamoe.mirai.utils.* 12 | import xyz.cssxsh.mirai.weibo.data.* 13 | import xyz.cssxsh.weibo.api.* 14 | import kotlin.coroutines.* 15 | 16 | internal object WeiboListener : CoroutineScope { 17 | 18 | override val coroutineContext: CoroutineContext = 19 | CoroutineName(name = "WeiboSubscriber") + SupervisorJob() + CoroutineExceptionHandler { context, throwable -> 20 | logger.warning({ "$throwable in $context" }, throwable) 21 | } 22 | 23 | /** 24 | * * [https://m.weibo.cn/status/JFzsgd0CX] 25 | * * [https://m.weibo.cn/status/4585001998353993] 26 | * * [https://weibo.com/5594511989/JzFhZz3fP] 27 | * * [https://weibo.com/detail/JzFhZz3fP] 28 | * * [https://weibo.com/detail/4585001998353993] 29 | * * [https://m.weibo.cn/detail/4585001998353993] 30 | */ 31 | private val WEIBO_REGEX = """(?<=(weibo\.(cn|com)/(\d{1,32}|detail|status)/))[0-9A-z]+""".toRegex() 32 | 33 | private val Parser = WeiboHelperPlugin.registerPermission("auto.parser", "自动解析微博链接") 34 | 35 | private val interval get() = WeiboHelperSettings.interval 36 | 37 | private val cache: MutableMap> = HashMap() 38 | 39 | private fun cache(subject: Contact, match: MatchResult): Boolean { 40 | val history = cache.getOrPut(subject.id) { HashMap() } 41 | val current = System.currentTimeMillis() 42 | return current != history.merge(match.value, current) { old, new -> if (new - old > interval) new else old } 43 | } 44 | 45 | fun start() { 46 | globalEventChannel().subscribeMessages { 47 | WEIBO_REGEX findingReply replier@{ result -> 48 | if (subject is Group) { 49 | val permitteeId = (sender as? NormalMember ?: return@replier null).permitteeId 50 | if (Parser.testPermission(permitteeId).not()) return@replier null 51 | } 52 | 53 | if (cache(subject, result)) return@replier null 54 | 55 | logger.info { "${sender.render()} 匹配WEIBO(${result.value})" } 56 | try { 57 | message.quote() + client.getMicroBlog(mid = result.value).toMessage(contact = subject) 58 | } catch (exception: SerializationException) { 59 | logger.warning({ "构建WEIBO(${result.value})序列化时失败" }, exception) 60 | sendLoginMessage("构建WEIBO(${result.value})任务序列化时失败") 61 | exception.message 62 | } catch (cause: Exception) { 63 | logger.warning({ "构建WEIBO(${result.value})信息失败,尝试重新刷新" }, cause) 64 | try { 65 | client.restore() 66 | null 67 | } catch (cause: Exception) { 68 | logger.warning({ "WEIBO登陆状态失效,需要重新登陆" }, cause) 69 | sendLoginMessage("WEIBO登陆状态失效,需要重新登陆 /wlogin") 70 | cause.message 71 | } ?: cause.message 72 | } 73 | } 74 | } 75 | } 76 | 77 | fun stop() { 78 | coroutineContext.cancelChildren() 79 | } 80 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/weibo/WeiboPicture.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.weibo 2 | 3 | import kotlinx.serialization.* 4 | 5 | @Serializable 6 | public sealed class WeiboPicture { 7 | 8 | @SerialName("none") 9 | @Serializable 10 | public class None : WeiboPicture() 11 | 12 | 13 | @SerialName("all") 14 | @Serializable 15 | public class All : WeiboPicture() 16 | 17 | /** 18 | * 超过limit图片,其余已省略 19 | */ 20 | @SerialName("limit") 21 | @Serializable 22 | public class Limit(public val total: Int = 3) : WeiboPicture() 23 | 24 | /** 25 | * "超过三张图片,全部省略" 26 | */ 27 | @SerialName("top") 28 | @Serializable 29 | public class Top(public val total: Int = 3) : WeiboPicture() 30 | } 31 | -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/weibo/WeiboSubscriber.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.weibo 2 | 3 | import kotlinx.coroutines.* 4 | import kotlinx.serialization.* 5 | import net.mamoe.mirai.contact.* 6 | import net.mamoe.mirai.message.data.* 7 | import net.mamoe.mirai.utils.* 8 | import xyz.cssxsh.mirai.weibo.data.* 9 | import xyz.cssxsh.weibo.* 10 | import xyz.cssxsh.weibo.data.* 11 | import xyz.cssxsh.weibo.api.* 12 | import java.net.* 13 | import java.time.* 14 | import kotlin.coroutines.* 15 | 16 | public abstract class WeiboSubscriber>(public val type: String) : CoroutineScope { 17 | 18 | override val coroutineContext: CoroutineContext = 19 | CoroutineName(name = "WeiboListener-$type") + SupervisorJob() + CoroutineExceptionHandler { context, throwable -> 20 | logger.warning({ "$throwable in $context" }, throwable) 21 | } 22 | 23 | public companion object { 24 | private val all = mutableListOf>() 25 | 26 | public fun start() { 27 | for (subscriber in all) { 28 | subscriber.start() 29 | } 30 | } 31 | 32 | public fun stop() { 33 | for (subscriber in all) { 34 | subscriber.stop() 35 | } 36 | } 37 | } 38 | 39 | init { 40 | let(all::add) 41 | } 42 | 43 | public abstract val load: suspend (id: K) -> List 44 | 45 | protected open val filter: WeiboFilter get() = WeiboHelperSettings 46 | 47 | private val forward get() = UseForwardMessage 48 | 49 | protected abstract val tasks: MutableMap 50 | 51 | private val taskJobs: MutableMap = HashMap() 52 | 53 | private fun infos(id: K) = tasks[id]?.contacts.orEmpty() 54 | 55 | public fun start(): Unit = synchronized(taskJobs) { 56 | for ((id, _) in tasks) { 57 | taskJobs[id] = listen(id) 58 | } 59 | } 60 | 61 | public fun stop(): Unit = synchronized(taskJobs) { 62 | coroutineContext.cancelChildren() 63 | taskJobs.clear() 64 | } 65 | 66 | private suspend fun sendMessageToTaskContacts(id: K, build: suspend (contact: Contact) -> Message) { 67 | for (delegate in infos(id)) { 68 | try { 69 | requireNotNull(findContact(delegate)) { "找不到用户" }.let { contact -> 70 | contact.sendMessage(build(contact)) 71 | } 72 | } catch (cause: Exception) { 73 | logger.warning({ "对[${delegate}]构建消息失败" }, cause) 74 | } 75 | } 76 | } 77 | 78 | private operator fun LocalTime.minus(other: LocalTime): Duration = 79 | Duration.ofSeconds((toSecondOfDay() - other.toSecondOfDay()).toLong()) 80 | 81 | private fun Map.near(time: LocalTime = LocalTime.now()): Boolean { 82 | return values.map { it.created.toLocalTime() - time }.any { it.abs() < IntervalSlow } 83 | } 84 | 85 | protected open val reposts: Boolean = true 86 | 87 | protected open val predicate: (MicroBlog, K) -> Boolean = filter@{ blog, id -> 88 | if (filter.original && blog.retweeted != null) { 89 | logger.debug { "${type}(${id}) 转发屏蔽" } 90 | return@filter false 91 | } 92 | val source = blog.retweeted ?: blog 93 | if (reposts && source.reposts < filter.repost) { 94 | logger.debug { "${type}(${id}) 转发数屏蔽,跳过 ${source.id} ${source.reposts}" } 95 | return@filter false 96 | } 97 | if (source.uid in filter.users) { 98 | logger.info { "${type}(${id}) 用户屏蔽,跳过 ${source.id} ${source.username}" } 99 | return@filter false 100 | } 101 | for (regex in filter.regexes) { 102 | if (regex in source.raw.orEmpty()) { 103 | logger.info { "${type}(${id}) 正则屏蔽,跳过 ${source.id} $regex" } 104 | return@filter false 105 | } 106 | } 107 | if (blog.urls.any { it.type.toIntOrNull() in filter.urls }) { 108 | logger.debug { "${type}(${id}) Url屏蔽,跳过 ${source.id} ${blog.urls}" } 109 | return@filter false 110 | } 111 | if (blog.title != null && "赞过的微博" in blog.title.text && filter.likes.not()) { 112 | logger.info { "${type}(${id}) 赞过的微博屏蔽,跳过 ${source.id} ${source.created}" } 113 | return@filter false 114 | } 115 | true 116 | } 117 | 118 | private fun listen(id: K): Job = launch { 119 | logger.info { "添加对$type(${tasks.getValue(id).name}#${id})的监听任务" } 120 | val history by WeiboHistoryDelegate(id, this@WeiboSubscriber) 121 | val cache: MutableSet = HashSet(history.keys) 122 | for ((_, blog) in history) cache.add(blog.retweeted?.id ?: continue) 123 | logger.debug { "$type(${tasks.getValue(id).name}#${id})的 cache: $cache" } 124 | var init = true 125 | logger.debug { "$type(${tasks.getValue(id).name}#${id})的 target: ${infos(id)}" } 126 | while (isActive && infos(id).isNotEmpty()) { 127 | delay((if (history.near() || init) IntervalFast else IntervalSlow).toMillis()) 128 | try { 129 | if (init) { 130 | // XXX: 加载一次 131 | val list = load(id) 132 | for (blog in list) { 133 | history[blog.id] = blog 134 | cache.add(blog.id) 135 | cache.add(blog.retweeted?.id ?: continue) 136 | } 137 | init = false 138 | logger.debug { "$type(${tasks.getValue(id).name}#${id})的 init: $cache" } 139 | continue 140 | } 141 | val task = tasks.getValue(id) 142 | val list = load(id).asSequence() 143 | .filter { predicate(it, id) } 144 | .filterNot { (it.retweeted?.id ?: it.id) in cache } 145 | .toList() 146 | if (list.isEmpty()) continue 147 | 148 | if (forward) { 149 | val strategy = object : ForwardMessage.DisplayStrategy { 150 | override fun generateTitle(forward: RawForwardMessage): String = "${task.name} 有新微博" 151 | override fun generateSummary(forward: RawForwardMessage): String = "查看${list.size}条微博转发" 152 | } 153 | sendMessageToTaskContacts(id) { contact -> 154 | buildForwardMessage(contact) { 155 | displayStrategy = strategy 156 | for (blog in list) { 157 | contact.bot at blog.created.toEpochSecond().toInt() says blog.toMessage(contact) 158 | } 159 | } 160 | } 161 | } else { 162 | for (blog in list) { 163 | sendMessageToTaskContacts(id) { contact -> 164 | blog.toMessage(contact) 165 | } 166 | } 167 | } 168 | 169 | var last = task.last 170 | for (blog in list) { 171 | history[blog.id] = blog 172 | last = maxOf(blog.created, last) 173 | cache.add(blog.id) 174 | cache.add(blog.retweeted?.id ?: continue) 175 | } 176 | 177 | tasks[id] = task.copy(last = last) 178 | } catch (exception: SerializationException) { 179 | logger.warning({ "$type(${id})监听任务序列化时失败" }, exception) 180 | sendLoginMessage("$type(${id})监听任务序列化时失败") 181 | } catch (exception: UnknownHostException) { 182 | logger.warning({ "$type(${id})监听任务, 网络异常" }, exception) 183 | } catch (exception: SocketException) { 184 | logger.warning({ "$type(${id})监听任务, 网络异常" }, exception) 185 | } catch (exception: Exception) { 186 | logger.warning({ "WEIBO登陆状态将刷新" }, exception) 187 | try { 188 | client.restore() 189 | } catch (_: Exception) { 190 | // 191 | } 192 | } finally { 193 | logger.debug { "$type(${id}): ${tasks[id]}监听任务完成一次, 即将进入延时" } 194 | } 195 | } 196 | } 197 | 198 | public fun add(id: K, name: String, subject: Contact): Unit = synchronized(tasks) { 199 | tasks.compute(id) { _, info -> 200 | with(info ?: WeiboTaskInfo()) { 201 | copy(contacts = contacts + subject.delegate, name = name) 202 | } 203 | } 204 | taskJobs.compute(id) { _, job -> 205 | job?.takeIf { it.isActive } ?: listen(id) 206 | } 207 | } 208 | 209 | public fun remove(id: K, subject: Contact): Unit = synchronized(tasks) { 210 | tasks.compute(id) { _, info -> 211 | info?.run { 212 | copy(contacts = contacts - subject.delegate) 213 | } 214 | } 215 | if (infos(id).isEmpty()) { 216 | tasks.remove(id) 217 | taskJobs.remove(id)?.cancel() 218 | } 219 | } 220 | 221 | public fun detail(subject: Contact): String = buildString { 222 | appendLine("# 订阅列表") 223 | appendLine("| NAME | ID | LAST | ACTIVE |") 224 | appendLine("|------|----|------|--------|") 225 | for ((id, info) in tasks) { 226 | if (subject.delegate !in info.contacts) continue 227 | appendLine("| ${info.name} | $id | ${info.last} | ${taskJobs[id]?.isActive} |") 228 | } 229 | } 230 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/weibo/WeiboUtils.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.weibo 2 | 3 | import io.ktor.client.* 4 | import io.ktor.client.plugins.cookies.* 5 | import io.ktor.http.* 6 | import kotlinx.coroutines.* 7 | import kotlinx.serialization.* 8 | import net.mamoe.mirai.* 9 | import net.mamoe.mirai.console.command.* 10 | import net.mamoe.mirai.console.permission.* 11 | import net.mamoe.mirai.console.plugin.jvm.* 12 | import net.mamoe.mirai.console.util.* 13 | import net.mamoe.mirai.console.util.ContactUtils.getContactOrNull 14 | import net.mamoe.mirai.console.util.ContactUtils.render 15 | import net.mamoe.mirai.contact.* 16 | import net.mamoe.mirai.message.* 17 | import net.mamoe.mirai.message.data.* 18 | import net.mamoe.mirai.message.data.MessageSource.Key.quote 19 | import net.mamoe.mirai.utils.* 20 | import net.mamoe.mirai.utils.ExternalResource.Companion.toExternalResource 21 | import net.mamoe.mirai.utils.ExternalResource.Companion.uploadAsImage 22 | import net.sf.image4j.codec.ico.* 23 | import org.apache.commons.text.* 24 | import xyz.cssxsh.mirai.weibo.data.* 25 | import xyz.cssxsh.weibo.* 26 | import xyz.cssxsh.weibo.api.* 27 | import xyz.cssxsh.weibo.data.* 28 | import java.io.* 29 | import java.net.* 30 | import java.time.* 31 | import javax.imageio.* 32 | 33 | internal const val WEIBO_CACHE_PROPERTY = "xyz.cssxsh.mirai.plugin.weibo.cache" 34 | 35 | internal const val WEIBO_EXPIRE_IMAGE_PROPERTY = "xyz.cssxsh.mirai.plugin.weibo.expire.image" 36 | 37 | internal const val WEIBO_EXPIRE_HISTORY_PROPERTY = "xyz.cssxsh.mirai.plugin.weibo.expire.history" 38 | 39 | internal const val WEIBO_CLEAN_FOLLOWING_PROPERTY = "xyz.cssxsh.mirai.plugin.clean.following" 40 | 41 | internal const val WEIBO_INTERVAL_FAST_PROPERTY = "xyz.cssxsh.mirai.plugin.weibo.interval.fast" 42 | 43 | internal const val WEIBO_INTERVAL_SLOW_PROPERTY = "xyz.cssxsh.mirai.plugin.weibo.interval.slow" 44 | 45 | internal const val WEIBO_CONTACT_PROPERTY = "xyz.cssxsh.mirai.plugin.weibo.contact" 46 | 47 | internal const val WEIBO_FORWARD_PROPERTY = "xyz.cssxsh.mirai.plugin.weibo.forward" 48 | 49 | internal const val WEIBO_URL_PROPERTY = "xyz.cssxsh.mirai.plugin.weibo.url" 50 | 51 | /** 52 | * @see [WeiboHelperPlugin.logger] 53 | */ 54 | internal val logger by lazy { 55 | try { 56 | WeiboHelperPlugin.logger 57 | } catch (_: ExceptionInInitializerError) { 58 | MiraiLogger.Factory.create(WeiboClient::class) 59 | } 60 | } 61 | 62 | internal val client: WeiboClient by lazy { 63 | object : WeiboClient(ignore = ClientIgnore) { 64 | override var info: LoginUserInfo 65 | get() = super.info 66 | set(value) { 67 | WeiboStatusData.status = LoginStatus(value, cookies) 68 | super.info = value 69 | } 70 | 71 | override val timeout: Long get() = WeiboHelperSettings.timeout 72 | 73 | override val client: HttpClient = super.client.config { 74 | install(HttpCookies) { 75 | val delegate = super.storage 76 | storage = object : CookiesStorage by delegate { 77 | override suspend fun addCookie(requestUrl: Url, cookie: Cookie) { 78 | delegate.addCookie(requestUrl, cookie) 79 | WeiboStatusData.status = status() 80 | } 81 | } 82 | } 83 | } 84 | 85 | init { 86 | load(WeiboStatusData.status) 87 | } 88 | } 89 | } 90 | 91 | internal fun AbstractJvmPlugin.registerPermission(name: String, description: String): Permission { 92 | return PermissionService.INSTANCE.register(permissionId(name), description, parentPermission) 93 | } 94 | 95 | internal val DataFolder by WeiboHelperPlugin::dataFolder 96 | 97 | /** 98 | * @see [WEIBO_CACHE_PROPERTY] 99 | * @see [WeiboHelperSettings.cache] 100 | */ 101 | internal val ImageCache: File by lazy { 102 | File(System.getProperty(WEIBO_CACHE_PROPERTY, WeiboHelperSettings.cache)) 103 | } 104 | 105 | /** 106 | * @see [WEIBO_EXPIRE_IMAGE_PROPERTY] 107 | * @see [WeiboHelperSettings.expire] 108 | */ 109 | internal val ImageExpire: Duration by lazy { 110 | Duration.ofHours(System.getProperty(WEIBO_EXPIRE_IMAGE_PROPERTY)?.toLong() ?: WeiboHelperSettings.expire.toLong()) 111 | } 112 | 113 | /** 114 | * @see [WEIBO_CLEAN_FOLLOWING_PROPERTY] 115 | * @see [WeiboHelperSettings.following] 116 | */ 117 | internal val ImageClearFollowing: Boolean by lazy { 118 | System.getProperty(WEIBO_CLEAN_FOLLOWING_PROPERTY)?.toBoolean() ?: WeiboHelperSettings.following 119 | } 120 | 121 | /** 122 | * @see [WEIBO_INTERVAL_FAST_PROPERTY] 123 | * @see [WeiboHelperSettings.fast] 124 | */ 125 | internal val IntervalFast: Duration by lazy { 126 | Duration.ofMinutes(System.getProperty(WEIBO_INTERVAL_FAST_PROPERTY)?.toLong() ?: WeiboHelperSettings.fast.toLong()) 127 | } 128 | 129 | /** 130 | * @see [WEIBO_INTERVAL_SLOW_PROPERTY] 131 | * @see [WeiboHelperSettings.slow] 132 | */ 133 | internal val IntervalSlow: Duration by lazy { 134 | Duration.ofMinutes(System.getProperty(WEIBO_INTERVAL_SLOW_PROPERTY)?.toLong() ?: WeiboHelperSettings.slow.toLong()) 135 | } 136 | 137 | /** 138 | * @see [WEIBO_CONTACT_PROPERTY] 139 | * @see [WeiboHelperSettings.contact] 140 | */ 141 | @OptIn(ConsoleExperimentalApi::class) 142 | internal val LoginContact by lazy { 143 | val id = System.getProperty(WEIBO_CONTACT_PROPERTY)?.toLong() ?: WeiboHelperSettings.contact 144 | if (id == 12345L) throw IllegalArgumentException("没有设置登录失效联系人") 145 | for (bot in Bot.instances) { 146 | return@lazy bot.getContactOrNull(id) ?: continue 147 | } 148 | throw NoSuchElementException("无法联系 $id") 149 | } 150 | 151 | internal fun sendLoginMessage(message: String) { 152 | try { 153 | WeiboHelperPlugin 154 | } catch (_: Throwable) { 155 | CoroutineScope(Dispatchers.IO) + SupervisorJob() 156 | }.launch { 157 | while (isActive) { 158 | try { 159 | LoginContact.sendMessage(message) 160 | break 161 | } catch (cause: Exception) { 162 | logger.warning({ "向 ${LoginContact.render()} 发送消息失败" }, cause) 163 | } 164 | delay(60_000L) 165 | } 166 | } 167 | } 168 | 169 | internal val Emoticons get() = WeiboEmoticonData.emoticons 170 | 171 | internal val EmoticonCache get() = ImageCache.resolve("emoticon") 172 | 173 | internal val VideoCache get() = ImageCache.resolve("video") 174 | 175 | internal val CoverCache get() = ImageCache.resolve("cover") 176 | 177 | /** 178 | * @see [WEIBO_EXPIRE_HISTORY_PROPERTY] 179 | * @see [WeiboHelperSettings.history] 180 | */ 181 | internal val HistoryExpire: Long by lazy { 182 | System.getProperty(WEIBO_EXPIRE_HISTORY_PROPERTY)?.toLong() ?: WeiboHelperSettings.history 183 | } 184 | 185 | /** 186 | * @see [WEIBO_FORWARD_PROPERTY] 187 | * @see [WeiboHelperSettings.forward] 188 | */ 189 | internal val UseForwardMessage: Boolean by lazy { 190 | System.getProperty(WEIBO_FORWARD_PROPERTY)?.toBoolean() ?: WeiboHelperSettings.forward 191 | } 192 | 193 | /** 194 | * @see [WEIBO_URL_PROPERTY] 195 | * @see [WeiboHelperSettings.showUrl] 196 | */ 197 | internal val ShowUrl: Boolean by lazy { 198 | System.getProperty(WEIBO_URL_PROPERTY)?.toBoolean() ?: WeiboHelperSettings.showUrl 199 | } 200 | 201 | internal fun UserBaseInfo.desktop(flush: Boolean = false, dir: File = ImageCache.resolve("$id")): File { 202 | dir.mkdirs() 203 | if (!flush 204 | || dir.resolve("desktop.ini").exists() 205 | || (following && dir.resolve("avatar.ico").exists()) 206 | ) return dir 207 | 208 | dir.resolve("desktop.ini").apply { if (isHidden) dir.deleteRecursively() }.writeText(buildString { 209 | appendLine("[.ShellClassInfo]") 210 | appendLine("LocalizedResourceName=${if (following) '$' else '#'}${id}@${screen}") 211 | if (following) { 212 | try { 213 | ICOEncoder.write(ImageIO.read(URL(avatarLarge)), dir.resolve("avatar.ico")) 214 | } catch (cause: Exception) { 215 | logger.warning({ "头像下载失败" }, cause) 216 | } 217 | appendLine("IconResource=avatar.ico") 218 | } 219 | appendLine("[ViewState]") 220 | appendLine("Mode=") 221 | appendLine("Vid=") 222 | appendLine("FolderType=Pictures") 223 | }, Charsets.GBK) 224 | 225 | if (System.getProperty("os.name").lowercase().startsWith("windows")) { 226 | Runtime.getRuntime().exec("attrib ${dir.absolutePath} +s") 227 | } 228 | 229 | return dir 230 | } 231 | 232 | internal suspend fun Emoticon.file(): File { 233 | return EmoticonCache.resolve(category.ifBlank { "默认" }).resolve("$phrase.${url.substringAfterLast('.')}").apply { 234 | if (exists().not()) { 235 | parentFile.mkdirs() 236 | writeBytes(client.download(url)) 237 | } 238 | } 239 | } 240 | 241 | internal suspend fun MicroBlog.getContent(showUrl: Boolean = true): String = supervisorScope { 242 | var content = raw 243 | var links = urls 244 | if (isLongText) { 245 | try { 246 | val data = client.getLongText(mid) 247 | content = requireNotNull(data.content) { "长文本为空 mid: $mid" } 248 | links = data.urls 249 | } catch (cause: Exception) { 250 | logger.warning({ "获取微博[${id}]长文本失败" }, cause) 251 | } 252 | } 253 | buildString { 254 | append(StringEscapeUtils.unescapeHtml4(content)) 255 | 256 | for (struct in links) { 257 | if (struct.type.isEmpty()) continue 258 | val url = struct.h5 ?: struct.long ?: struct.short 259 | val target = if (showUrl) { 260 | "[${struct.title}]<${struct.type}>(${url})" 261 | } else { 262 | "[${struct.title}]" 263 | } 264 | 265 | var index = 0 266 | while (index < length) { 267 | index = indexOf(struct.short, index) 268 | if (index < 0) break 269 | replace(index, index + struct.short.length, target) 270 | index += struct.short.length.coerceAtLeast(1) 271 | } 272 | } 273 | } 274 | } 275 | 276 | internal suspend fun MicroBlog.getImages(flush: Boolean = false) = supervisorScope { 277 | if (pictures.isEmpty()) return@supervisorScope emptyList() 278 | val user = requireNotNull(user) { "没有用户信息" } 279 | val cache = user.desktop() 280 | val last = created.toEpochSecond() * 1_000 281 | 282 | pictures.mapIndexed { index, pid -> 283 | async { 284 | cache.resolve("${id}-${index}-${pid}.${extension(pid)}").apply { 285 | if (flush || exists().not()) { 286 | writeBytes(runCatching { 287 | // 下载速度更快 288 | client.download(picture(pid, index)) 289 | }.recoverCatching { 290 | client.download(pid, index) 291 | }.onSuccess { 292 | logger.verbose { "[${name}]下载完成, 大小${it.size / 1024}KB" } 293 | }.getOrThrow()) 294 | setLastModified(last) 295 | } 296 | } 297 | } 298 | } 299 | } 300 | 301 | internal suspend fun MicroBlog.getVideo(flush: Boolean = false) = supervisorScope { 302 | val media = requireNotNull(page?.media) { "MicroBlog(${mid}) Not Found Video" } 303 | val title = media.titles.firstOrNull()?.title ?: media.name 304 | val video = media.playbacks.maxOf { it.info } 305 | // TODO: safe file name 306 | VideoCache.resolve("${id}-${title}.mp4").apply { 307 | if (flush || exists().not()) { 308 | parentFile.mkdirs() 309 | client.download(video = video).collect(::appendBytes) 310 | } 311 | } 312 | } 313 | 314 | internal suspend fun MicroBlog.getCover(flush: Boolean = false) = supervisorScope { 315 | val url = requireNotNull(page?.picture) { "MicroBlog(${mid}) Not Found Cover" } 316 | 317 | CoverCache.resolve(url.substringAfterLast("/")).apply { 318 | if (flush || exists().not()) { 319 | parentFile.mkdirs() 320 | writeBytes(client.download(url = url)) 321 | } 322 | } 323 | } 324 | 325 | private suspend fun emoticon(content: String, contact: Contact) = buildMessageChain { 326 | var pos = 0 327 | while (pos < content.length) { 328 | val start = content.indexOf('[', pos) 329 | if (start < 0) break 330 | val emoticon = Emoticons.values.find { content.startsWith(it.phrase, start) } 331 | 332 | if (emoticon == null) { 333 | add(content.substring(pos, start + 1)) 334 | pos = start + 1 335 | continue 336 | } 337 | 338 | runCatching { 339 | emoticon.file().uploadAsImage(contact) 340 | }.onSuccess { 341 | add(content.substring(pos, start)) 342 | add(it) 343 | }.onFailure { 344 | logger.warning({ "获取微博表情${emoticon.phrase}图片失败" }, it) 345 | add(content.substring(pos, start + emoticon.phrase.length)) 346 | } 347 | pos = start + emoticon.phrase.length 348 | } 349 | appendLine(content.substring(pos)) 350 | } 351 | 352 | internal suspend fun MicroBlog.toMessage(contact: Contact): MessageChain = buildMessageChain { 353 | title?.run { appendLine(text) } 354 | appendLine("@${username}#${uid}") 355 | appendLine("时间: $created") 356 | appendLine(if (ShowUrl) "链接: $link" else "MID: $mid") 357 | suffix?.run { appendLine(joinToString(" ") { it.content }) } 358 | appendLine("\uD83D\uDCAC: $comments \uD83D\uDD01: $reposts \uD83D\uDC4D\uD83C\uDFFB: $attitudes") 359 | 360 | // FIXME: Send Video 361 | if (WeiboHelperSettings.video && hasVideo) { 362 | supervisorScope { 363 | launch { 364 | val video = try { 365 | getVideo() 366 | } catch (cause: Throwable) { 367 | logger.warning({ "下载视频失败, $link" }, cause) 368 | return@launch 369 | } 370 | 371 | val cover = getCover().toExternalResource() 372 | try { 373 | val message = video.toExternalResource().use { 374 | contact.uploadShortVideo(thumbnail = cover, video = it, fileName = mid) 375 | } 376 | contact.sendMessage(message) 377 | return@launch 378 | } catch (cause: Throwable) { 379 | logger.warning({ "$contact 无法发送视频" }, cause) 380 | } finally { 381 | cover.close() 382 | } 383 | 384 | try { 385 | contact as FileSupported 386 | video.toExternalResource().use { contact.files.uploadNewFile(video.name, it) } 387 | } catch (cause: Throwable) { 388 | logger.warning({ "$contact 无法发送文件" }, cause) 389 | } 390 | } 391 | } 392 | } 393 | 394 | val content = getContent(showUrl = ShowUrl) 395 | 396 | if (WeiboHelperSettings.emoticon && Emoticons.isNotEmpty()) { 397 | add(emoticon(content, contact)) 398 | } else { 399 | appendLine(content) 400 | } 401 | 402 | when (val picture = WeiboHelperSettings.picture) { 403 | is WeiboPicture.None -> Unit 404 | is WeiboPicture.All -> { 405 | for ((index, deferred) in getImages().withIndex()) { 406 | try { 407 | add(deferred.await().uploadAsImage(contact)) 408 | } catch (cause: Exception) { 409 | logger.warning({ "获取微博[${id}]图片[${pictures[index]}]失败" }, cause) 410 | appendLine("获取微博[${id}]图片[${pictures[index]}]失败") 411 | } 412 | } 413 | } 414 | is WeiboPicture.Limit -> { 415 | for ((index, deferred) in getImages().withIndex()) { 416 | if (picture.total <= index) { 417 | appendLine("超过${picture.total}, 剩余图片省略") 418 | break 419 | } 420 | try { 421 | add(deferred.await().uploadAsImage(contact)) 422 | } catch (cause: Exception) { 423 | logger.warning({ "获取微博[${id}]图片[${pictures[index]}]失败" }, cause) 424 | appendLine("获取微博[${id}]图片[${pictures[index]}]失败") 425 | } 426 | } 427 | } 428 | is WeiboPicture.Top -> { 429 | if (picture.total < pictures.size) { 430 | for ((index, deferred) in getImages().withIndex()) { 431 | try { 432 | add(deferred.await().uploadAsImage(contact)) 433 | } catch (cause: Exception) { 434 | logger.warning({ "获取微博[${id}]图片[${pictures[index]}]失败" }, cause) 435 | appendLine("获取微博[${id}]图片[${pictures[index]}]失败") 436 | } 437 | } 438 | } else { 439 | appendLine("超过${picture.total}, 图片省略") 440 | } 441 | } 442 | } 443 | 444 | if (WeiboHelperSettings.cover && hasPage) { 445 | try { 446 | add(getCover().uploadAsImage(contact)) 447 | } catch (cause: Exception) { 448 | logger.warning({ "获取微博[${id}]封面失败" }, cause) 449 | appendLine("获取微博[${id}]封面失败") 450 | } 451 | } 452 | 453 | retweeted?.let { blog -> 454 | appendLine("======================") 455 | add(blog.copy(urls = urls).toMessage(contact)) 456 | } 457 | } 458 | 459 | private val NoDefault = { group: UserGroup -> group.type != UserGroupType.SYSTEM } 460 | 461 | internal fun UserGroupData.toMessage(predicate: (UserGroup) -> Boolean = NoDefault) = buildMessageChain { 462 | for (group in groups) { 463 | val list = group.list.filter(predicate) 464 | if (list.isNotEmpty()) { 465 | appendLine("===${group.title}===") 466 | } 467 | for (item in list) { 468 | appendLine("${item.title} -> ${item.gid}") 469 | } 470 | } 471 | } 472 | 473 | internal suspend fun UserInfo.toMessage(contact: Contact) = buildMessageChain { 474 | append(client.download(avatarLarge).toExternalResource().use { it.uploadAsImage(contact) }) 475 | appendLine("已关注 @${screen}#${id}") 476 | } 477 | 478 | internal fun File.clean(following: Boolean, num: Int = 0) { 479 | logger.info { "微博图片清理开始" } 480 | val last = System.currentTimeMillis() - ImageExpire.toMillis() 481 | for (dir in listFiles() ?: return) { 482 | val avatar = dir.resolve("avatar.ico").exists() 483 | if (following.not() && avatar) continue 484 | val images = dir.listFiles { file -> file.extension in ImageExtensions.values } ?: continue 485 | if (num > 0 && images.size > num) continue 486 | images.all { file -> file.lastModified() < last && file.delete() } 487 | && dir.apply { for (file in listFiles().orEmpty()) file.delete() }.delete() 488 | } 489 | } 490 | 491 | internal suspend fun clear(interval: Long = 3600_000) = supervisorScope { 492 | if (ImageExpire.isNegative) return@supervisorScope 493 | while (isActive) { 494 | ImageCache.clean(following = ImageClearFollowing) 495 | delay(interval) 496 | } 497 | } 498 | 499 | internal suspend fun restore(interval: Long = 600_000) = supervisorScope { 500 | while (isActive) { 501 | val timestamp = client.wbpsess?.expires?.timestamp 502 | val current = System.currentTimeMillis() 503 | if (timestamp != null && current < timestamp) { 504 | delay((timestamp - current).coerceAtMost(interval)) 505 | continue 506 | } 507 | try { 508 | val result = client.restore() 509 | logger.info { "WEIBO登陆状态已刷新 $result" } 510 | } catch (exception: SerializationException) { 511 | logger.warning({ "WEIBO RESTORE 任务序列化时失败" }, exception) 512 | sendLoginMessage("WEIBO RESTORE 任务序列化时失败") 513 | } catch (cause: Exception) { 514 | logger.warning({ "WEIBO登陆状态失效,需要重新登陆" }, cause) 515 | sendLoginMessage("WEIBO登陆状态失效,需要重新登陆 /wlogin") 516 | } finally { 517 | delay(interval) 518 | } 519 | } 520 | } 521 | 522 | internal suspend fun UserBaseInfo.getRecord(month: YearMonth, interval: Long) = supervisorScope { 523 | with(desktop(true).resolve("$month.json")) { 524 | if (exists() && month != YearMonth.now()) { 525 | WeiboClient.Json.decodeFromString(readText()) 526 | } else { 527 | val blogs = try { 528 | WeiboClient.Json.decodeFromString>(readText()) 529 | .associateByTo(HashMap()) { it.id } 530 | } catch (cause: Exception) { 531 | hashMapOf() 532 | } 533 | var page = 1 534 | var run = true 535 | while (isActive && run) { 536 | delay(interval) 537 | run = (runCatching { 538 | client.getUserMicroBlogs(uid = id, page = page, month = month).list 539 | }.onSuccess { list -> 540 | blogs.putAll(list.associateBy { it.id }) 541 | logger.info { "@${screen}#${id}的${month}第${page}页加载成功" } 542 | page++ 543 | }.onFailure { 544 | logger.warning({ "@${screen}#${id}的${month}第${page}页加载失败" }, it) 545 | }.getOrNull()?.size ?: Int.MAX_VALUE) >= 16 546 | } 547 | val list = blogs.values.toList() 548 | if (list.isNotEmpty()) { 549 | writeText(WeiboClient.Json.encodeToString(list)) 550 | } 551 | list 552 | } 553 | } 554 | } 555 | 556 | internal val ClientIgnore: suspend (Throwable) -> Boolean = { throwable -> 557 | when (throwable) { 558 | is UnknownHostException, 559 | is NoRouteToHostException -> false 560 | is okhttp3.internal.http2.StreamResetException -> true 561 | is SocketException -> { 562 | logger.warning { "Weibo Client Ignore $throwable" } 563 | true 564 | } 565 | else -> false 566 | } 567 | } 568 | 569 | internal suspend fun WeiboClient.init() = supervisorScope { 570 | runCatching { 571 | restore() 572 | }.onSuccess { 573 | logger.info { "登陆成功, $it" } 574 | }.onFailure { 575 | logger.warning { "登陆失败, ${it.message}, 请尝试使用 /wlogin 指令登录" } 576 | runCatching { 577 | incarnate() 578 | }.onSuccess { 579 | logger.info { "模拟游客成功,置信度${it}" } 580 | }.onFailure { 581 | logger.warning { "模拟游客失败, ${it.message}" } 582 | } 583 | }.isSuccess && runCatching { 584 | val data = getEmoticon() 585 | for ((_, map) in data.emoticon) { 586 | for ((category, emoticons) in map) { 587 | for (emoticon in emoticons) { 588 | Emoticons[emoticon.phrase] = emoticon.copy(category = category) 589 | } 590 | } 591 | } 592 | }.onSuccess { 593 | logger.info { "加载表情成功" } 594 | }.onFailure { 595 | logger.warning { "加载表情失败, $it" } 596 | }.isSuccess 597 | } 598 | 599 | internal suspend fun > T.quote(block: suspend T.(Contact) -> Message): Boolean { 600 | return try { 601 | quoteReply(block(fromEvent.subject)) 602 | true 603 | } catch (cause: Exception) { 604 | logger.warning({ "发送消息失败" }, cause) 605 | quoteReply("发送消息失败, ${cause.message}") 606 | false 607 | } 608 | } 609 | 610 | public suspend fun CommandSenderOnMessage<*>.quoteReply(message: Message): MessageReceipt? { 611 | return sendMessage(fromEvent.message.quote() + message) 612 | } 613 | 614 | public suspend fun CommandSenderOnMessage<*>.quoteReply(message: String): MessageReceipt? { 615 | return quoteReply(message.toPlainText()) 616 | } 617 | 618 | /** 619 | * 通过正负号区分群和用户 620 | */ 621 | public val Contact.delegate: Long get() = if (this is Group) id * -1 else id 622 | 623 | /** 624 | * 查找Contact 625 | */ 626 | public fun findContact(delegate: Long): Contact? { 627 | for (bot in Bot.instances) { 628 | if (delegate < 0) { 629 | for (group in bot.groups) { 630 | if (group.id == delegate * -1) return group 631 | } 632 | } else { 633 | for (friend in bot.friends) { 634 | if (friend.id == delegate) return friend 635 | } 636 | for (stranger in bot.strangers) { 637 | if (stranger.id == delegate) return stranger 638 | } 639 | for (friend in bot.friends) { 640 | if (friend.id == delegate) return friend 641 | } 642 | for (group in bot.groups) { 643 | for (member in group.members) { 644 | if (member.id == delegate) return member 645 | } 646 | } 647 | } 648 | } 649 | return null 650 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/weibo/command/WeiboCacheCommand.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.weibo.command 2 | 3 | import kotlinx.coroutines.* 4 | import kotlinx.coroutines.flow.* 5 | import net.mamoe.mirai.console.command.* 6 | import net.mamoe.mirai.message.data.* 7 | import net.mamoe.mirai.utils.* 8 | import xyz.cssxsh.mirai.weibo.* 9 | import xyz.cssxsh.weibo.api.* 10 | import java.time.* 11 | 12 | @PublishedApi 13 | internal object WeiboCacheCommand : CompositeCommand( 14 | owner = WeiboHelperPlugin, 15 | "wcache", "微博缓存", 16 | description = "微博缓存指令", 17 | ) { 18 | 19 | @SubCommand 20 | suspend fun CommandSenderOnMessage<*>.user(uid: Long, second: Int = 10, reposts: Int = 100) = quote { 21 | val interval = second * 1000L 22 | val info = client.getUserInfo(uid).user 23 | val history = client.getUserHistory(uid) 24 | val months = history.flatMap { (year, months) -> months.map { YearMonth.of(year, it)!! } }.sortedDescending() 25 | launch { 26 | var count = 0 27 | for (month in months) { 28 | runCatching { 29 | info.getRecord(month, interval).onEach { blog -> 30 | if (blog.reposts >= reposts) blog.getImages(flush = false).awaitAll() 31 | } 32 | }.onSuccess { record -> 33 | count += record.size 34 | }.onFailure { 35 | logger.warning({ "对@${info.screen}的${month}缓存下载失败" }, it) 36 | } 37 | } 38 | sendMessage("对@${info.screen}的缓存下载完成, ${count}/${info.statuses}") 39 | } 40 | "对@${info.screen}的{${months.first()}~${months.last()}}缓存任务开始".toPlainText() 41 | } 42 | 43 | @SubCommand 44 | suspend fun CommandSenderOnMessage<*>.group(gid: Long, second: Int = 10, reposts: Int = 100) = quote { 45 | val interval = second * 1000L 46 | val members = flow { 47 | var page = 1 48 | while (currentCoroutineContext().isActive) { 49 | runCatching { 50 | client.getGroupMembers(gid = gid, page = page++) 51 | }.onSuccess { 52 | if (it.users.isEmpty()) return@flow 53 | emitAll(it.users.asFlow()) 54 | }.onFailure { 55 | logger.info(it.message) 56 | }.getOrNull() ?: break 57 | } 58 | } 59 | members.collect { info -> 60 | val history = client.getUserHistory(info.id) 61 | val months = history.flatMap { (year, months) -> 62 | months.map { YearMonth.of(year, it)!! } 63 | }.sortedDescending() 64 | var count = 0 65 | sendMessage("对@${info.screen}的{${months.first()}~${months.last()}}缓存任务开始") 66 | for (month in months) { 67 | runCatching { 68 | info.getRecord(month, interval).onEach { blog -> 69 | if (blog.reposts >= reposts) blog.getImages(flush = false).awaitAll() 70 | } 71 | }.onSuccess { record -> 72 | count += record.size 73 | }.onFailure { 74 | logger.warning({ "对@${info.screen}的${month}缓存下载失败" }, it) 75 | } 76 | } 77 | sendMessage("对@${info.screen}的缓存下载完成, ${count}/${info.statuses}") 78 | } 79 | "对Group($gid)的缓存文件夹图标已设置".toPlainText() 80 | } 81 | 82 | @SubCommand 83 | suspend fun CommandSenderOnMessage<*>.clean(following: Boolean, num: Int) = quote { 84 | ImageCache.clean(following, num) 85 | "清理完成".toPlainText() 86 | } 87 | 88 | @SubCommand 89 | suspend fun CommandSenderOnMessage<*>.emoticon() = quote { 90 | Emoticons.values.onEach { emoticon -> 91 | try { 92 | emoticon.file() 93 | } catch (cause: Exception) { 94 | logger.warning({ "表情${emoticon.phrase} 下载失败 ${emoticon.url}" }, cause) 95 | } 96 | }.joinTo(MessageChainBuilder()) { info -> 97 | "${info.category.ifBlank { "默认" }}/${info.phrase}" 98 | }.build() 99 | } 100 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/weibo/command/WeiboContext.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.weibo.command 2 | 3 | import net.mamoe.mirai.console.command.* 4 | import net.mamoe.mirai.console.command.descriptor.* 5 | import net.mamoe.mirai.contact.* 6 | 7 | public fun CommandSender.subject(): Contact = subject ?: throw CommandArgumentParserException("无法从当前环境获取联系人") -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/weibo/command/WeiboDetailCommand.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.weibo.command 2 | 3 | import net.mamoe.mirai.console.command.* 4 | import xyz.cssxsh.mirai.weibo.* 5 | import xyz.cssxsh.weibo.api.* 6 | 7 | @PublishedApi 8 | internal object WeiboDetailCommand : SimpleCommand( 9 | owner = WeiboHelperPlugin, 10 | "wdetail", "blog", "微博详情", 11 | description = "微博详情指令", 12 | ) { 13 | 14 | @Handler 15 | suspend fun CommandSenderOnMessage<*>.hendle(mid: String) = quote { client.getMicroBlog(mid).toMessage(it) } 16 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/weibo/command/WeiboFollowCommand.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.weibo.command 2 | 3 | import net.mamoe.mirai.console.command.* 4 | import xyz.cssxsh.mirai.weibo.* 5 | import xyz.cssxsh.weibo.api.* 6 | 7 | @PublishedApi 8 | internal object WeiboFollowCommand : SimpleCommand( 9 | owner = WeiboHelperPlugin, 10 | "wfollow", "微博关注", 11 | description = "微博关注指令", 12 | ) { 13 | 14 | @Handler 15 | suspend fun CommandSenderOnMessage<*>.hendle(uid: Long) = quote { client.follow(uid).toMessage(subject()) } 16 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/weibo/command/WeiboGroupCommand.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.weibo.command 2 | 3 | import net.mamoe.mirai.console.command.* 4 | import net.mamoe.mirai.contact.* 5 | import net.mamoe.mirai.utils.* 6 | import xyz.cssxsh.mirai.weibo.* 7 | import xyz.cssxsh.mirai.weibo.data.* 8 | import xyz.cssxsh.weibo.* 9 | import xyz.cssxsh.weibo.api.* 10 | import xyz.cssxsh.weibo.data.* 11 | 12 | @PublishedApi 13 | internal object WeiboGroupCommand : CompositeCommand( 14 | owner = WeiboHelperPlugin, 15 | "wgroup", "微博分组", 16 | description = "微博分组指令", 17 | ) { 18 | 19 | private val subscriber = object : WeiboSubscriber(primaryName) { 20 | override val load: suspend (Long) -> List = { id -> 21 | client.getGroupsTimeline(gid = id, count = 100).statuses 22 | } 23 | 24 | override val tasks: MutableMap by WeiboTaskData::groups 25 | } 26 | 27 | @SubCommand("list", "列表") 28 | suspend fun CommandSender.list() = sendMessage(client.getFeedGroups().toMessage()) 29 | 30 | @SubCommand("add", "task", "订阅") 31 | suspend fun CommandSender.task(id: String, subject: Contact = subject()) { 32 | val group = client.getFeedGroups()[id] 33 | subscriber.add(id = group.gid, name = group.title, subject = subject) 34 | sendMessage("对<${group.title}#${group.gid}>的监听任务, 添加完成") 35 | } 36 | 37 | @SubCommand("stop", "停止") 38 | suspend fun CommandSender.stop(id: String, subject: Contact = subject()) { 39 | val gid = try { 40 | client.getFeedGroups()[id].gid 41 | } catch (cause: Exception) { 42 | logger.warning({ "查询群组失败" }, cause) 43 | return 44 | } 45 | subscriber.remove(id = gid, subject = subject) 46 | sendMessage("对Group(${gid})的监听任务, 取消完成") 47 | } 48 | 49 | @SubCommand("detail", "详情") 50 | suspend fun CommandSender.detail(subject: Contact = subject()) { 51 | sendMessage(subscriber.detail(subject = subject)) 52 | } 53 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/weibo/command/WeiboHotCommand.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.weibo.command 2 | 3 | import net.mamoe.mirai.console.command.* 4 | import net.mamoe.mirai.contact.* 5 | import xyz.cssxsh.mirai.weibo.* 6 | import xyz.cssxsh.mirai.weibo.data.* 7 | import xyz.cssxsh.weibo.api.* 8 | import xyz.cssxsh.weibo.data.* 9 | 10 | @PublishedApi 11 | internal object WeiboHotCommand : CompositeCommand( 12 | owner = WeiboHelperPlugin, 13 | "whot", "微博热搜", 14 | description = "微博热搜指令", 15 | ) { 16 | 17 | private val subscriber = object : WeiboSubscriber(primaryName) { 18 | override val load: suspend (String) -> List = { keyword -> 19 | client.search(keyword = keyword, type = ChannelType.HOT).cards.mapNotNull { it.blog } 20 | } 21 | 22 | override val tasks: MutableMap by WeiboTaskData::hots 23 | } 24 | 25 | @SubCommand("add", "task", "订阅") 26 | suspend fun CommandSender.task(word: String, subject: Contact = subject()) { 27 | subscriber.add(id = word, name = word, subject = subject) 28 | sendMessage("对<${word}>的监听任务, 添加完成") 29 | } 30 | 31 | @SubCommand("stop", "停止") 32 | suspend fun CommandSender.stop(word: String, subject: Contact = subject()) { 33 | subscriber.remove(id = word, subject = subject) 34 | sendMessage("对<${word}>的监听任务, 取消完成") 35 | } 36 | 37 | @SubCommand("detail", "详情") 38 | suspend fun CommandSender.detail(subject: Contact = subject()) { 39 | sendMessage(subscriber.detail(subject = subject)) 40 | } 41 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/weibo/command/WeiboLoginCommand.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.weibo.command 2 | 3 | import kotlinx.coroutines.* 4 | import net.mamoe.mirai.console.command.* 5 | import net.mamoe.mirai.message.data.* 6 | import net.mamoe.mirai.utils.ExternalResource.Companion.toExternalResource 7 | import net.mamoe.mirai.utils.ExternalResource.Companion.uploadAsImage 8 | import xyz.cssxsh.mirai.weibo.* 9 | import xyz.cssxsh.weibo.* 10 | import xyz.cssxsh.weibo.api.* 11 | 12 | @PublishedApi 13 | internal object WeiboLoginCommand : SimpleCommand( 14 | owner = WeiboHelperPlugin, 15 | "wlogin", "微博登录", 16 | description = "微博登录指令", 17 | ) { 18 | 19 | @Handler 20 | suspend fun CommandSenderOnMessage<*>.hendle() = quote { contact -> 21 | val result = client.qrcode { url -> 22 | logger.info("qrcode: $url") 23 | launch { 24 | val image = try { 25 | withTimeout(60_000) { 26 | client.download(url).toExternalResource().use { it.uploadAsImage(contact) } 27 | } 28 | } catch (cause: Exception) { 29 | logger.warning("qrcode download or upload fail.", cause) 30 | url.toPlainText() 31 | } 32 | 33 | sendMessage(image) 34 | } 35 | } 36 | "@${result.info.display}#${result.info.uid} 登陆成功".toPlainText() 37 | } 38 | 39 | @Handler 40 | suspend fun ConsoleCommandSender.hendle() { 41 | val message = try { 42 | val result = client.qrcode { url -> 43 | launch { 44 | sendMessage(url) 45 | } 46 | } 47 | "@${result.info.display}#${result.info.uid} 登陆成功" 48 | } catch (cause: Exception) { 49 | cause.message ?: cause.toString() 50 | } 51 | sendMessage(message) 52 | } 53 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/weibo/command/WeiboSuperChatCommand.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.weibo.command 2 | 3 | import net.mamoe.mirai.console.command.* 4 | import net.mamoe.mirai.contact.* 5 | import xyz.cssxsh.mirai.weibo.* 6 | import xyz.cssxsh.mirai.weibo.data.* 7 | import xyz.cssxsh.weibo.api.* 8 | import xyz.cssxsh.weibo.data.* 9 | 10 | @PublishedApi 11 | internal object WeiboSuperChatCommand : CompositeCommand( 12 | owner = WeiboHelperPlugin, 13 | "wsc", "微博超话", 14 | description = "微博超话指令", 15 | ) { 16 | 17 | private val subscriber = object : WeiboSubscriber(primaryName) { 18 | override val load: suspend (String) -> List = { id -> 19 | val data = client.getSuperChatData(id = id) 20 | buildList { 21 | for (card in data.cards) { 22 | for (group in card.group) { 23 | add(group.blog?.toMicroBlog() ?: continue) 24 | } 25 | add(card.blog?.toMicroBlog() ?: continue) 26 | } 27 | } 28 | } 29 | 30 | override val tasks: MutableMap by WeiboTaskData::scs 31 | } 32 | 33 | @SubCommand("add", "task", "订阅") 34 | suspend fun CommandSender.task(id: String, subject: Contact = subject()) { 35 | val chat = if (id.length > 10) { 36 | id 37 | } else { 38 | client.getSuperChatHome(name = id) 39 | } 40 | val title = client.getSuperChatData(id = chat).info.title 41 | subscriber.add(id = chat, name = title, subject = subject) 42 | sendMessage("对<${title}>的监听任务, 添加完成") 43 | } 44 | 45 | @SubCommand("stop", "停止") 46 | suspend fun CommandSender.stop(id: String, subject: Contact = subject()) { 47 | subscriber.remove(id = id, subject = subject) 48 | sendMessage("对<${id}>的监听任务, 取消完成") 49 | } 50 | 51 | @SubCommand("detail", "详情") 52 | suspend fun CommandSender.detail(subject: Contact = subject()) { 53 | sendMessage(subscriber.detail(subject = subject)) 54 | } 55 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/weibo/command/WeiboUserCommand.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.weibo.command 2 | 3 | import net.mamoe.mirai.console.command.* 4 | import net.mamoe.mirai.contact.* 5 | import xyz.cssxsh.mirai.weibo.* 6 | import xyz.cssxsh.mirai.weibo.data.* 7 | import xyz.cssxsh.weibo.api.* 8 | import xyz.cssxsh.weibo.data.* 9 | 10 | @PublishedApi 11 | internal object WeiboUserCommand : CompositeCommand( 12 | owner = WeiboHelperPlugin, 13 | "wuser", "微博用户", 14 | description = "微博用户指令", 15 | ) { 16 | 17 | private val subscriber = object : WeiboSubscriber(primaryName) { 18 | override val load: suspend (Long) -> List = { id -> 19 | client.getUserMicroBlogs(uid = id, page = 1).list 20 | } 21 | 22 | override val reposts: Boolean = false 23 | 24 | override val tasks: MutableMap by WeiboTaskData::users 25 | } 26 | 27 | @SubCommand("add", "task", "订阅") 28 | suspend fun CommandSender.task(uid: Long, subject: Contact = subject()) { 29 | val user = client.getUserInfo(uid = uid).user 30 | subscriber.add(id = user.id, name = user.screen, subject = subject) 31 | sendMessage("对@${user.screen}#${user.id}的监听任务, 添加完成") 32 | } 33 | 34 | @SubCommand("stop", "停止") 35 | suspend fun CommandSender.stop(uid: Long, subject: Contact = subject()) { 36 | subscriber.remove(id = uid, subject = subject) 37 | sendMessage("对User(${uid})的监听任务, 取消完成") 38 | } 39 | 40 | @SubCommand("detail", "详情") 41 | suspend fun CommandSender.detail(subject: Contact = subject()) { 42 | sendMessage(subscriber.detail(subject = subject)) 43 | } 44 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/weibo/data/OffsetDateTimeSerializer.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.weibo.data 2 | 3 | import kotlinx.serialization.* 4 | import kotlinx.serialization.descriptors.* 5 | import kotlinx.serialization.encoding.* 6 | import java.time.* 7 | import java.time.format.* 8 | 9 | @PublishedApi 10 | internal object OffsetDateTimeSerializer : KSerializer { 11 | 12 | private val formatter: DateTimeFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME 13 | 14 | override val descriptor: SerialDescriptor = 15 | PrimitiveSerialDescriptor(OffsetDateTime::class.qualifiedName!!, PrimitiveKind.STRING) 16 | 17 | override fun deserialize(decoder: Decoder): OffsetDateTime = 18 | OffsetDateTime.parse(decoder.decodeString(), formatter) 19 | 20 | override fun serialize(encoder: Encoder, value: OffsetDateTime) = 21 | encoder.encodeString(formatter.format(value)) 22 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/weibo/data/RegexSerializer.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.weibo.data 2 | 3 | import kotlinx.serialization.* 4 | import kotlinx.serialization.descriptors.* 5 | import kotlinx.serialization.encoding.* 6 | 7 | @PublishedApi 8 | internal object RegexSerializer : KSerializer { 9 | 10 | override val descriptor: SerialDescriptor = 11 | PrimitiveSerialDescriptor(Regex::class.qualifiedName!!, PrimitiveKind.STRING) 12 | 13 | override fun deserialize(decoder: Decoder): Regex = Regex(pattern = decoder.decodeString()) 14 | 15 | override fun serialize(encoder: Encoder, value: Regex) = encoder.encodeString(value = value.pattern) 16 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/weibo/data/WeiboEmoticonData.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.weibo.data 2 | 3 | import kotlinx.serialization.* 4 | import net.mamoe.mirai.console.data.* 5 | import net.mamoe.mirai.console.util.* 6 | import xyz.cssxsh.weibo.* 7 | import xyz.cssxsh.weibo.data.* 8 | import java.io.* 9 | 10 | @PublishedApi 11 | internal object WeiboEmoticonData : AutoSavePluginData("WeiboEmoticonData") { 12 | 13 | @PublishedApi 14 | internal fun default(): Map { 15 | val url = this::class.java.getResource("Emoticons.json") ?: throw FileNotFoundException("Emoticons.json") 16 | return WeiboClient.Json.decodeFromString(url.readText()) 17 | } 18 | 19 | @ConsoleExperimentalApi 20 | override fun shouldPerformAutoSaveWheneverChanged(): Boolean = false 21 | 22 | @ValueDescription("表情数据") 23 | val emoticons: MutableMap by value { putAll(default()) } 24 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/weibo/data/WeiboHelperSettings.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.weibo.data 2 | 3 | import kotlinx.serialization.modules.* 4 | import net.mamoe.mirai.console.data.* 5 | import xyz.cssxsh.mirai.weibo.* 6 | 7 | @PublishedApi 8 | internal object WeiboHelperSettings : ReadOnlyPluginConfig("WeiboHelperSettings"), WeiboFilter { 9 | 10 | override val serializersModule: SerializersModule = SerializersModule { 11 | contextual(WeiboPicture.serializer()) 12 | contextual(RegexSerializer) 13 | } 14 | 15 | @ValueDescription("登录状态失效联系人") 16 | val contact by value(12345L) 17 | 18 | @ValueDescription("图片缓存位置") 19 | val cache: String by value("WeiboCache") 20 | 21 | @ValueDescription("图片缓存过期时间,单位小时,默认3天,为0时不会过期") 22 | val expire: Int by value(72) 23 | 24 | @ValueDescription("是否清理收藏的用户") 25 | val following: Boolean by value(true) 26 | 27 | @ValueDescription("快速轮询间隔,单位分钟") 28 | val fast: Int by value(1) 29 | 30 | @ValueDescription("慢速轮询间隔,单位分钟") 31 | val slow: Int by value(10) 32 | 33 | @ValueDescription("微博分组订阅器,转发数过滤器,默认16") 34 | override val repost: Long by value(16L) 35 | 36 | @ValueDescription("屏蔽的微博用户") 37 | override val users: Set by value(setOf(1191220232L)) 38 | 39 | @ValueDescription("屏蔽的关键词正则表达式") 40 | override val regexes: List by value(listOf("女拳".toRegex())) 41 | 42 | @ValueDescription("屏蔽URL类型,填入 39 可以屏蔽微博视频") 43 | override val urls: Set by value() 44 | 45 | @ValueDescription("屏蔽转发") 46 | override val original: Boolean by value(false) 47 | 48 | @ValueDescription("屏蔽点赞转发") 49 | override val likes: Boolean by value(false) 50 | 51 | @ValueDescription("发送微博视频文件") 52 | val video: Boolean by value(true) 53 | 54 | @ValueDescription("处理微博表情") 55 | val emoticon: Boolean by value(true) 56 | 57 | @ValueDescription("显示图片数设置") 58 | val picture: WeiboPicture by value(WeiboPicture.All()) 59 | 60 | @ValueDescription("显示封面设置") 61 | val cover: Boolean by value(true) 62 | 63 | @ValueDescription("历史记录保留时间,单位天,默认 7d") 64 | val history by value(7L) 65 | 66 | @ValueDescription("Http 超时时间") 67 | val timeout by value(60_000L) 68 | 69 | @ValueDescription("以转发消息的方式发送订阅微博") 70 | val forward: Boolean by value(false) 71 | 72 | @ValueName("show_url") 73 | @ValueDescription("是否显示url") 74 | val showUrl: Boolean by value(true) 75 | 76 | @ValueDescription("自动解析同样内容的间隔") 77 | val interval: Long by value(600_000L) 78 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/weibo/data/WeiboStatusData.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.weibo.data 2 | 3 | import net.mamoe.mirai.console.data.* 4 | import xyz.cssxsh.weibo.data.* 5 | 6 | @PublishedApi 7 | internal object WeiboStatusData : AutoSavePluginData("WeiboStatusData") { 8 | 9 | @ValueDescription("登录状态") 10 | var status by value(LoginStatus()) 11 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/weibo/data/WeiboTaskData.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.weibo.data 2 | 3 | import net.mamoe.mirai.console.data.* 4 | 5 | @PublishedApi 6 | internal object WeiboTaskData : AutoSavePluginData("WeiboTaskData") { 7 | 8 | @ValueDescription("微博用户订阅器,KEY是UID") 9 | val users: MutableMap by value() 10 | 11 | @ValueDescription("微博分组订阅器,KEY是GID") 12 | val groups: MutableMap by value() 13 | 14 | @ValueDescription("微博热搜订阅器,KEY是 KEYWORD") 15 | val hots: MutableMap by value() 16 | 17 | @ValueDescription("微博超话订阅器,KEY是 SID") 18 | val scs: MutableMap by value() 19 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/mirai/weibo/data/WeiboTaskInfo.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.weibo.data 2 | 3 | import kotlinx.serialization.* 4 | import java.time.* 5 | 6 | @Serializable 7 | public data class WeiboTaskInfo( 8 | @SerialName("last") 9 | @Serializable(OffsetDateTimeSerializer::class) 10 | val last: OffsetDateTime = OffsetDateTime.now(), 11 | @SerialName("name") 12 | val name: String = "", 13 | @SerialName("contacts") 14 | val contacts: Set = emptySet() 15 | ) -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/weibo/AcceptAllCookiesStorage.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.weibo 2 | 3 | import io.ktor.client.plugins.cookies.* 4 | import io.ktor.http.* 5 | import kotlinx.coroutines.sync.* 6 | import kotlin.properties.* 7 | 8 | private inline fun reflect() = ReadOnlyProperty { thisRef, property -> 9 | thisRef::class.java.getDeclaredField(property.name).apply { isAccessible = true }.get(thisRef) as R 10 | } 11 | 12 | internal val CookiesStorage.mutex: Mutex by reflect() 13 | 14 | internal val CookiesStorage.container: MutableList by reflect() -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/weibo/Load.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.weibo 2 | 3 | import io.ktor.client.call.* 4 | import io.ktor.client.plugins.* 5 | import io.ktor.client.request.* 6 | import io.ktor.http.* 7 | import kotlinx.coroutines.* 8 | import kotlinx.coroutines.flow.* 9 | import kotlinx.serialization.* 10 | import kotlinx.serialization.json.* 11 | import xyz.cssxsh.weibo.api.* 12 | import xyz.cssxsh.weibo.data.* 13 | import java.nio.charset.* 14 | 15 | @PublishedApi 16 | internal fun Boolean.toInt(): Int = if (this) 1 else 0 17 | 18 | @Serializable 19 | public data class TempData( 20 | @SerialName("data") 21 | val `data`: JsonElement? = null, 22 | @SerialName("url") 23 | val url: String? = null, 24 | @SerialName("http_code") 25 | val httpCode: Int = 200, 26 | @SerialName("ok") 27 | @Serializable(NumberToBooleanSerializer::class) 28 | val ok: Boolean = true 29 | ) 30 | 31 | @PublishedApi 32 | internal const val ErrorMessageLength: Int = 32 33 | 34 | @PublishedApi 35 | internal const val SERIALIZATION_EXCEPTION_SAVE: String = "xyz.cssxsh.weibo.json.save" 36 | 37 | public suspend inline fun WeiboClient.text( 38 | url: String, 39 | crossinline block: HttpRequestBuilder.() -> Unit 40 | ): String { 41 | return useHttpClient { client -> client.prepareGet(url, block).body() } 42 | } 43 | 44 | public suspend inline fun WeiboClient.temp( 45 | url: String, 46 | crossinline block: HttpRequestBuilder.() -> Unit 47 | ): T { 48 | val text = text(url, block) 49 | check(text.startsWith("{")) { text.substring(0, minOf(ErrorMessageLength, text.length)) } 50 | val temp = WeiboClient.Json.decodeFromString(text) 51 | val data = requireNotNull(temp.data) { 52 | if (temp.url.orEmpty().startsWith(LOGIN_PAGE)) { 53 | "登陆状态无效,请登录" 54 | } else { 55 | text 56 | } 57 | } 58 | return try { 59 | WeiboClient.Json.decodeFromJsonElement(data) 60 | } catch (cause: SerializationException) { 61 | supervisorScope { 62 | System.getProperty(SERIALIZATION_EXCEPTION_SAVE)?.let { path -> 63 | val folder = java.io.File(path) 64 | folder.mkdirs() 65 | folder.resolve("${System.currentTimeMillis()}.json").writeText(text) 66 | } 67 | } 68 | throw IllegalStateException("${temp.httpCode} - ${temp.url}", cause) 69 | } 70 | } 71 | 72 | public suspend inline fun WeiboClient.callback( 73 | url: String, 74 | crossinline block: HttpRequestBuilder.() -> Unit 75 | ): T { 76 | val json = text(url, block).substringAfter('(').substringBefore(')') 77 | return try { 78 | WeiboClient.Json.decodeFromString(json) 79 | } catch (cause: Exception) { 80 | throw IllegalArgumentException(json, cause) 81 | } 82 | } 83 | 84 | public suspend inline fun WeiboClient.json( 85 | url: String, 86 | crossinline block: HttpRequestBuilder.() -> Unit 87 | ): T { 88 | val text = text(url, block) 89 | check(text.startsWith("{")) { text.substring(0, minOf(ErrorMessageLength, text.length)) } 90 | val temp = WeiboClient.Json.decodeFromString(text) 91 | check(temp.ok) { 92 | if (temp.url.orEmpty().startsWith(LOGIN_PAGE)) { 93 | "登陆状态无效,请登录" 94 | } else { 95 | text 96 | } 97 | } 98 | return try { 99 | WeiboClient.Json.decodeFromString(text) 100 | } catch (cause: SerializationException) { 101 | supervisorScope { 102 | System.getProperty(SERIALIZATION_EXCEPTION_SAVE)?.let { path -> 103 | val folder = java.io.File(path) 104 | folder.mkdirs() 105 | folder.resolve("${System.currentTimeMillis()}.json").writeText(text) 106 | } 107 | } 108 | throw IllegalStateException("${temp.httpCode} - ${temp.url}", cause) 109 | } 110 | } 111 | 112 | public suspend fun WeiboClient.download( 113 | url: String, 114 | min: Long = 1024 115 | ): ByteArray = useHttpClient { client -> 116 | client.prepareGet(url) { 117 | header(HttpHeaders.Referrer, INDEX_PAGE) 118 | }.execute { response -> 119 | // 部分 response 没有 ContentLength, 直接返回,例如验证码 120 | val length = response.contentLength() ?: Long.MAX_VALUE 121 | if (length < min) { 122 | throw ClientRequestException(response, response.body()) 123 | } 124 | response.body() 125 | } 126 | } 127 | 128 | public suspend fun WeiboClient.download( 129 | pid: String, 130 | index: Int 131 | ): ByteArray = useHttpClient { client -> 132 | client.prepareGet(image(pid = pid, server = ImageServer.random(), index = index)) { 133 | header(HttpHeaders.Referrer, INDEX_PAGE) 134 | }.body() 135 | } 136 | 137 | public suspend fun WeiboClient.download( 138 | video: PageInfo.PlayInfo 139 | ): Flow = flow { 140 | for (offset in 0 until video.size step video.buffer) { 141 | val limit = (offset + video.buffer).coerceAtMost(video.size) - 1 142 | emit(useHttpClient { client -> 143 | client.prepareGet(video.url) { 144 | header(HttpHeaders.Referrer, INDEX_PAGE) 145 | header(HttpHeaders.Range, "bytes=${offset}-${limit}") 146 | }.body() 147 | }) 148 | } 149 | } 150 | 151 | /** 152 | * 国标码 153 | * Chinese Internal Code Specification 154 | */ 155 | @Suppress("unused") 156 | @PublishedApi 157 | internal val Charsets.GBK: Charset 158 | get() = Charset.forName("GBK") 159 | 160 | @PublishedApi 161 | internal const val EncodeChars: String = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" 162 | 163 | public fun String.decodeBase62(): Long = fold(0L) { acc, char -> 164 | val index = EncodeChars.indexOf(char) 165 | check(index != -1) { "$char no 62" } 166 | acc * 62 + index 167 | } 168 | 169 | public const val WEIBO_EPOCH: Long = 515483463L 170 | 171 | public fun timestamp(id: Long): Long = (id shr 22) + WEIBO_EPOCH 172 | 173 | public fun id(mid: String): Long { 174 | return mid.substring(0..0).decodeBase62().times(1_0000000_0000000L) + 175 | mid.substring(1..4).decodeBase62().times(1_0000000L) + 176 | mid.substring(5..8).decodeBase62() 177 | } 178 | 179 | public val ImageServer: List = listOf("wx1.sinaimg.cn", "wx2.sinaimg.cn", "wx3.sinaimg.cn", "wx4.sinaimg.cn") 180 | 181 | public val ImageExtensions: Map = mapOf( 182 | ContentType.Image.JPEG to "jpg", 183 | ContentType.Image.GIF to "gif", 184 | ContentType.Image.PNG to "png", 185 | ) 186 | 187 | public fun user(pid: String): Long = with(pid.substring(0..7)) { 188 | if (startsWith("00")) decodeBase62() else toLong(16) 189 | } 190 | 191 | public fun extension(pid: String): String = ImageExtensions.values.first { it.startsWith(pid[21]) } 192 | 193 | public fun image(pid: String, server: String, index: Int): String = "https://${server}/large/${pid}.${extension(pid)}#${index}" 194 | 195 | public fun picture(pid: String, index: Int): String = "https://weibo.com/ajax/common/download?pid=${pid}#${index}" 196 | 197 | public val MicroBlog.link: String get() = "https://weibo.com/${user?.id ?: "detail"}/${mid}" 198 | 199 | public val MicroBlog.username: String get() = user?.screen ?: "[未知用户]" 200 | 201 | public val MicroBlog.uid: Long get() = user?.id ?: 0 202 | 203 | public operator fun UserGroupData.get(id: String): UserGroup { 204 | for (category in groups) { 205 | for (group in category.list) { 206 | if (group.gid == id.toLongOrNull()) return group 207 | if (group.title == id) return group 208 | } 209 | } 210 | throw NoSuchElementException("Group: $id") 211 | } 212 | -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/weibo/WeiboClient.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.weibo 2 | 3 | import io.ktor.client.* 4 | import io.ktor.client.engine.okhttp.* 5 | import io.ktor.client.plugins.* 6 | import io.ktor.client.plugins.compression.* 7 | import io.ktor.client.plugins.cookies.* 8 | import io.ktor.client.request.* 9 | import io.ktor.http.* 10 | import io.ktor.utils.io.core.* 11 | import io.ktor.utils.io.errors.* 12 | import kotlinx.coroutines.* 13 | import kotlinx.coroutines.sync.* 14 | import kotlinx.serialization.json.* 15 | import xyz.cssxsh.weibo.data.* 16 | import kotlin.coroutines.* 17 | 18 | public open class WeiboClient( 19 | public val ignore: suspend (Throwable) -> Boolean = DefaultIgnore 20 | ) : CoroutineScope, Closeable { 21 | override val coroutineContext: CoroutineContext 22 | get() = client.coroutineContext 23 | 24 | override fun close(): Unit = client.close() 25 | 26 | protected val cookies: List 27 | get() = storage.container.filter { it.expires != null }.map(::renderSetCookieHeader) 28 | 29 | public fun status(): LoginStatus = LoginStatus(info, cookies) 30 | 31 | public fun load(status: LoginStatus): Boolean = runBlocking(coroutineContext) { 32 | info = status.info 33 | storage.mutex.withLock { 34 | storage.container.addAll(status.cookies.map(::parseServerSetCookieHeader)) 35 | } 36 | } 37 | 38 | protected open val storage: CookiesStorage = AcceptAllCookiesStorage() 39 | 40 | @PublishedApi 41 | internal open var info: LoginUserInfo = LoginUserInfo("", 0) 42 | 43 | @PublishedApi 44 | internal val xsrf: Cookie? get() = storage.container["XSRF-TOKEN"] 45 | 46 | @PublishedApi 47 | internal val srf: Cookie? get() = storage.container["SRF"] 48 | 49 | @PublishedApi 50 | internal val wbpsess: Cookie? get() = storage.container["WBPSESS"] 51 | 52 | protected open val timeout: Long = 30_000 // attr(open) ok ? 53 | 54 | protected open val client: HttpClient = HttpClient(OkHttp) { 55 | install(HttpTimeout) { 56 | socketTimeoutMillis = timeout 57 | connectTimeoutMillis = timeout 58 | requestTimeoutMillis = null 59 | } 60 | install(HttpCookies) { 61 | storage = this@WeiboClient.storage 62 | } 63 | Charsets { 64 | responseCharsetFallback = Charsets.GBK 65 | } 66 | BrowserUserAgent() 67 | ContentEncoding() 68 | defaultRequest { 69 | header("x-xsrf-token", xsrf?.value) 70 | } 71 | expectSuccess = true 72 | } 73 | 74 | public companion object { 75 | public val Json: Json = Json { 76 | prettyPrint = true 77 | ignoreUnknownKeys = true 78 | isLenient = true 79 | } 80 | 81 | public val DefaultIgnore: suspend (Throwable) -> Boolean = { it is IOException } 82 | } 83 | 84 | protected open val max: Int = 32 85 | 86 | public suspend fun useHttpClient(block: suspend (HttpClient) -> T): T = supervisorScope { 87 | var count = 0 88 | var cause: Throwable? = null 89 | while (isActive) { 90 | try { 91 | return@supervisorScope block(client) 92 | } catch (throwable: Throwable) { 93 | cause = throwable 94 | count++ 95 | if (count > max || ignore(throwable).not()) throw throwable 96 | } 97 | } 98 | throw CancellationException(null, cause) 99 | } 100 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/weibo/api/Api.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.weibo.api 2 | 3 | public const val INDEX_PAGE: String = "https://weibo.com" 4 | public const val LOGIN_PAGE: String = "https://weibo.com/login.php" 5 | 6 | public const val PAGE_SIZE: Int = 20 7 | 8 | // STATUSES 9 | public const val STATUSES_CONFIG: String = "https://weibo.com/ajax/statuses/config" 10 | public const val STATUSES_MY_MICRO_BLOG: String = "https://weibo.com/ajax/statuses/mymblog" 11 | public const val STATUSES_SHOW: String = "https://weibo.com/ajax/statuses/show" 12 | public const val STATUSES_LONGTEXT: String = "https://weibo.com/ajax/statuses/longtext" 13 | public const val STATUSES_MENTIONS: String = "https://weibo.com/ajax/statuses/mentions" 14 | public const val STATUSES_REPOST: String = "https://weibo.com/ajax/statuses/repostTimeline" 15 | public const val STATUSES_LIKE_LIST: String = "https://weibo.com/ajax/statuses/likelist" 16 | public const val STATUSES_LIKE_SHOW: String = "https://weibo.com/ajax/statuses/likeShow" 17 | public const val STATUSES_SET_LIKE: String = "https://weibo.com/ajax/statuses/setLike" 18 | public const val STATUSES_CREATE_FAVORITES: String = "https://weibo.com/ajax/statuses/createFavorites" 19 | public const val STATUSES_FAVORITES: String = "https://weibo.com/ajax/favorites/all_fav" 20 | public const val SEARCH_ALL: String = "https://weibo.com/ajax/search/all" 21 | 22 | // COMMENTS 23 | public const val COMMENTS_MENTIONS: String = "https://weibo.com/ajax/comments/mentions" 24 | 25 | // MESSAGE 26 | public const val MESSAGE_CMT: String = "https://weibo.com/ajax/message/cmt" 27 | public const val MESSAGE_ATTITUDES: String = "https://weibo.com/ajax/message/attitudes" 28 | public const val MESSAGE_WHITELIST: String = "https://weibo.com/ajax/message/whitelist" 29 | 30 | // FEED 31 | public const val FEED_ALL_GROUPS: String = "https://weibo.com/ajax/feed/allGroups" 32 | public const val FEED_UNREAD_FRIENDS_TIMELINE: String = "https://weibo.com/ajax/feed/unreadfriendstimeline" 33 | public const val FEED_FRIENDS_TIMELINE: String = "https://weibo.com/ajax/feed/friendstimeline" 34 | public const val FEED_GROUPS_TIMELINE: String = "https://weibo.com/ajax/feed/groupstimeline" 35 | public const val FEED_HOT_TIMELINE: String = "https://weibo.com/ajax/feed/hottimeline" 36 | 37 | // FRIENDSHIPS 38 | public const val FRIENDSHIPS_FRIENDS: String = "https://weibo.com/ajax/friendships/friends" 39 | public const val FRIENDSHIPS_LIST: String = "https://weibo.com/ajax/friendships/friends" 40 | public const val FRIENDSHIPS_CREATE: String = "https://weibo.com/ajax/friendships/create" 41 | public const val FRIENDSHIPS_DESTROY: String = "https://weibo.com/ajax/friendships/destory" 42 | 43 | // PROFILE 44 | public const val PROFILE_INFO: String = "https://weibo.com/ajax/profile/info" 45 | public const val PROFILE_DETAIL: String = "https://weibo.com/ajax/profile/detail" 46 | public const val PROFILE_HISTORY: String = "https://weibo.com/ajax/profile/mbloghistory" 47 | public const val PROFILE_MY_HOT: String = "https://weibo.com/ajax/profile/myhot" 48 | public const val PROFILE_SEARCH: String = "https://weibo.com/ajax/profile/searchblog" 49 | public const val PROFILE_FEATURE_DETAIL: String = "https://weibo.com/ajax/profile/featuredetail" 50 | public const val PROFILE_VIDEO: String = "https://weibo.com/ajax/profile/getprofilevideolist" 51 | public const val PROFILE_TINY_VIDEO: String = "https://weibo.com/ajax/profile/gettinyvideo" 52 | public const val PROFILE_IMAGE: String = "https://weibo.com/ajax/profile/getImageWall" 53 | public const val PROFILE_TAB_LIST: String = "https://weibo.com/ajax/profile/tablist" 54 | public const val PROFILE_GROUP_MEMBERS: String = "https://weibo.com/ajax/profile/getGroupMembers" 55 | 56 | public const val PROFILE_FOLLOW_CONTENT: String = "https://weibo.com/ajax/profile/followContent" 57 | public const val PROFILE_TOPIC_CONTENT: String = "https://weibo.com/ajax/profile/topicContent" 58 | public const val PROFILE_GROUP_LIST: String = "https://weibo.com/ajax/profile/getGroupList" 59 | public const val PROFILE_GROUP_SET: String = "https://weibo.com/ajax/profile/setGroup" 60 | 61 | public const val PROFILE_GROUPS: String = "https://weibo.com/ajax/profile/getGroups" 62 | 63 | // LOGIN 64 | public const val CROSS_DOMAIN: String = "https://login.sina.com.cn/crossdomain2.php" 65 | public const val WEIBO_SSO_LOGIN: String = "https://passport.weibo.com/wbsso/login" 66 | public const val SSO_LOGIN: String = "https://login.sina.com.cn/sso/login.php" 67 | public const val SSO_QRCODE_IMAGE: String = "https://login.sina.com.cn/sso/qrcode/image" 68 | public const val SSO_QRCODE_CHECK: String = "https://login.sina.com.cn/sso/qrcode/check" 69 | public const val PASSPORT_VISITOR: String = "https://passport.weibo.com/visitor/visitor" 70 | public const val PASSPORT_GEN_VISITOR: String = "https://passport.weibo.com/visitor/genvisitor" 71 | 72 | // SUPER CHAT 73 | public const val SUPER_CHAT_LIST: String = "https://www.weibo.com/p/aj/v6/mblog/mbloglist" -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/weibo/api/Feed.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.weibo.api 2 | 3 | import io.ktor.client.request.* 4 | import io.ktor.http.* 5 | import xyz.cssxsh.weibo.* 6 | import xyz.cssxsh.weibo.data.* 7 | 8 | public suspend fun WeiboClient.getFeedGroups( 9 | isNewSegment: Boolean = true, 10 | fetchHot: Boolean = true 11 | ): UserGroupData = json(FEED_ALL_GROUPS) { 12 | header(HttpHeaders.Referrer, INDEX_PAGE) 13 | 14 | parameter("is_new_segment", isNewSegment.toInt()) 15 | parameter("fetch_hot", fetchHot.toInt()) 16 | } 17 | 18 | public suspend fun WeiboClient.getGroupsTimeline( 19 | gid: Long, 20 | count: Int = PAGE_SIZE, 21 | refresh: Boolean = true, 22 | since: Long? = null, 23 | max: Long? = null, 24 | fast: Boolean? = null 25 | ): TimelineData = json(FEED_GROUPS_TIMELINE) { 26 | header(HttpHeaders.Referrer, "https://weibo.com/mygroups?gid=$gid") 27 | 28 | parameter("list_id", gid) 29 | parameter("since_id", since) 30 | parameter("max_id", max) 31 | parameter("refresh", refresh.toInt()) 32 | parameter("fast_refresh", fast) 33 | parameter("count", count) 34 | } 35 | 36 | public suspend fun WeiboClient.getUnreadTimeline( 37 | gid: Long, 38 | count: Int = PAGE_SIZE, 39 | refresh: Boolean = true, 40 | since: Long? = null, 41 | max: Long? = null, 42 | fast: Boolean? = null 43 | ): TimelineData = json(FEED_UNREAD_FRIENDS_TIMELINE) { 44 | header(HttpHeaders.Referrer, "https://weibo.com/mygroups?gid=$gid") 45 | 46 | parameter("list_id", gid) 47 | parameter("since_id", since) 48 | parameter("max_id", max) 49 | parameter("refresh", refresh.toInt()) 50 | parameter("fast_refresh", fast?.toInt()) 51 | parameter("count", count) 52 | } 53 | 54 | public suspend fun WeiboClient.getFriendsTimeline( 55 | gid: Long, 56 | count: Int = PAGE_SIZE, 57 | refresh: Boolean = true, 58 | since: Long? = null, 59 | max: Long? = null, 60 | fast: Boolean? = null 61 | ): TimelineData = json(FEED_FRIENDS_TIMELINE) { 62 | header(HttpHeaders.Referrer, "https://weibo.com/mygroups?gid=$gid") 63 | 64 | parameter("list_id", gid) 65 | parameter("since_id", since) 66 | parameter("max_id", max) 67 | parameter("refresh", refresh.toInt()) 68 | parameter("fast_refresh", fast?.toInt()) 69 | parameter("count", count) 70 | } 71 | 72 | public suspend fun WeiboClient.getHotTimeline( 73 | gid: Long, 74 | max: Long? = null, 75 | extend: List = listOf("discover", "new_feed"), 76 | count: Int = PAGE_SIZE, 77 | refresh: Boolean = false 78 | ): TimelineData = json(FEED_HOT_TIMELINE) { 79 | header(HttpHeaders.Referrer, "https://weibo.com/hot/list/$gid") 80 | 81 | parameter("group_id", gid) 82 | parameter("containerid", gid) 83 | parameter("max_id", max) 84 | parameter("extparam", extend.joinToString("|")) 85 | parameter("count", count) 86 | parameter("refresh", refresh.toInt()) 87 | } 88 | 89 | public suspend fun WeiboClient.getTimeline(group: UserGroup): TimelineData = when (group.type) { 90 | UserGroupType.USER, UserGroupType.QUIETLY -> { 91 | getGroupsTimeline(group.gid) 92 | } 93 | UserGroupType.ALL -> { 94 | getUnreadTimeline(group.gid) 95 | } 96 | UserGroupType.FILTER, UserGroupType.MUTUAL, UserGroupType.GROUP -> { 97 | getFriendsTimeline(group.gid) 98 | } 99 | UserGroupType.SYSTEM -> { 100 | getHotTimeline(group.gid) 101 | } 102 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/weibo/api/Login.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.weibo.api 2 | 3 | import io.ktor.client.request.* 4 | import io.ktor.http.* 5 | import kotlinx.coroutines.* 6 | import kotlinx.serialization.json.* 7 | import xyz.cssxsh.weibo.* 8 | import xyz.cssxsh.weibo.data.* 9 | import java.lang.* 10 | 11 | public const val SUCCESS_CODE: Int = 20000000 12 | 13 | public const val NO_USE_CODE: Int = 50114001 14 | 15 | public const val USED_CODE: Int = 50114002 16 | 17 | public const val QRCODE_SIZE: Int = 180 18 | 19 | public const val CheckDelay: Long = 3 * 1000L 20 | 21 | public suspend inline fun WeiboClient.data( 22 | url: String, 23 | crossinline block: HttpRequestBuilder.() -> Unit 24 | ): T { 25 | return with(callback(url, block)) { 26 | check(code == SUCCESS_CODE) { toString() } 27 | WeiboClient.Json.decodeFromJsonElement(data) 28 | } 29 | } 30 | 31 | private fun location(html: String): String? { 32 | return html.substringAfter("location.replace(").substringBeforeLast(");") 33 | .removeSurrounding("'").removeSurrounding("\"") 34 | .takeIf { it.startsWith("http") } 35 | } 36 | 37 | private suspend fun WeiboClient.login(urls: List): LoginResult { 38 | val result = callback(urls.first { it.startsWith(WEIBO_SSO_LOGIN) }) { 39 | header(HttpHeaders.Host, url.host) 40 | header(HttpHeaders.Referrer, INDEX_PAGE) 41 | 42 | parameter("action", "login") 43 | parameter("callback", "STK_${System.currentTimeMillis()}") 44 | } 45 | 46 | info = result.info 47 | 48 | return result 49 | } 50 | 51 | public suspend fun WeiboClient.qrcode(send: suspend (qrcode: String) -> Unit): LoginResult { 52 | // Set Cookie 53 | download(PASSPORT_VISITOR) 54 | 55 | val qrcode = data(SSO_QRCODE_IMAGE) { 56 | header(HttpHeaders.Host, url.host) 57 | header(HttpHeaders.Referrer, INDEX_PAGE) 58 | 59 | parameter("entry", "sinawap") 60 | parameter("size", QRCODE_SIZE) 61 | parameter("callback", "STK_${System.currentTimeMillis()}") 62 | } 63 | 64 | send(qrcode.image) 65 | 66 | val token: LoginToken = supervisorScope { 67 | while (isActive) { 68 | val json = callback(SSO_QRCODE_CHECK) { 69 | header(HttpHeaders.Host, url.host) 70 | header(HttpHeaders.Referrer, INDEX_PAGE) 71 | 72 | parameter("entry", "sinawap") 73 | parameter("qrid", qrcode.id) 74 | parameter("callback", "STK_${System.currentTimeMillis()}") 75 | } 76 | // println(json) 77 | when (json.code) { 78 | SUCCESS_CODE -> { 79 | return@supervisorScope WeiboClient.Json.decodeFromJsonElement(json.data) 80 | } 81 | NO_USE_CODE, USED_CODE -> { 82 | delay(CheckDelay) 83 | } 84 | else -> { 85 | throw IllegalStateException(json.message) 86 | } 87 | } 88 | } 89 | throw CancellationException() 90 | } 91 | 92 | val flush = callback(SSO_LOGIN) { 93 | header(HttpHeaders.Host, url.host) 94 | header(HttpHeaders.Referrer, INDEX_PAGE) 95 | 96 | parameter("entry", "weibo") 97 | parameter("returntype", "TEXT") 98 | parameter("crossdomain", 1) 99 | parameter("cdult", 3) 100 | parameter("domain", "weibo.com") 101 | parameter("alt", token.alt) 102 | parameter("savestate", token.state) 103 | parameter("callback", "STK_${System.currentTimeMillis()}") 104 | } 105 | 106 | return login(urls = flush.urls) 107 | } 108 | 109 | public suspend fun WeiboClient.restore(): LoginResult { 110 | // Set Cookie 111 | withTimeoutOrNull(10_000) { 112 | var location: String? = INDEX_PAGE 113 | while (isActive && location != null) { 114 | location = location(text(location) {}) 115 | } 116 | } 117 | 118 | checkNotNull(srf) { "SRF Cookie 为空" } 119 | 120 | val token = data(PASSPORT_VISITOR) { 121 | header(HttpHeaders.Host, url.host) 122 | header(HttpHeaders.Referrer, PASSPORT_VISITOR) 123 | 124 | parameter("a", "restore") 125 | parameter("cb", "restore_back") 126 | parameter("from", "weibo") 127 | parameter("_rand", System.currentTimeMillis()) 128 | } 129 | 130 | val html = text(SSO_LOGIN) { 131 | header(HttpHeaders.Host, url.host) 132 | header(HttpHeaders.Referrer, INDEX_PAGE) 133 | 134 | parameter("entry", "sso") 135 | parameter("returntype", "META") 136 | parameter("gateway", 1) 137 | parameter("alt", token.alt) 138 | parameter("savestate", token.state) 139 | } 140 | 141 | check(location(html).orEmpty().startsWith(CROSS_DOMAIN)) { "CROSS DOMAIN 跳转异常" } 142 | 143 | val flush = callback(CROSS_DOMAIN) { 144 | header(HttpHeaders.Host, url.host) 145 | header(HttpHeaders.Referrer, INDEX_PAGE) 146 | 147 | parameter("action", "login") 148 | parameter("entry", "sso") 149 | parameter("r", INDEX_PAGE) 150 | } 151 | 152 | return login(urls = flush.urls) 153 | } 154 | 155 | public suspend fun WeiboClient.incarnate(): Int { 156 | val visitor = data(PASSPORT_GEN_VISITOR) { 157 | header(HttpHeaders.Host, url.host) 158 | header(HttpHeaders.Referrer, PASSPORT_VISITOR) 159 | 160 | parameter("cb", "restore_back") 161 | parameter("from", "weibo") 162 | parameter("_rand", System.currentTimeMillis()) 163 | } 164 | 165 | val recover = if (visitor.new) 3 else 2 166 | val cookies = data>(PASSPORT_VISITOR) { 167 | header(HttpHeaders.Host, url.host) 168 | header(HttpHeaders.Referrer, PASSPORT_VISITOR) 169 | 170 | parameter("a", "incarnate") 171 | parameter("t", visitor.tid) 172 | parameter("w", recover) 173 | parameter("c", visitor.confidence) 174 | parameter("gc", "") 175 | parameter("cb", "cross_domain") 176 | parameter("from", "weibo") 177 | parameter("_rand", System.currentTimeMillis()) 178 | } 179 | 180 | load(status = LoginStatus(cookies = cookies.map { (name, value) -> 181 | "${name.uppercase()}=${value}; Domain=.weibo.com; Path=/; HttpOnly; \$x-enc=RAW" 182 | })) 183 | 184 | return visitor.confidence 185 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/weibo/api/Profile.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.weibo.api 2 | 3 | import io.ktor.client.request.* 4 | import io.ktor.http.* 5 | import kotlinx.serialization.json.* 6 | import xyz.cssxsh.weibo.* 7 | import xyz.cssxsh.weibo.data.* 8 | 9 | public suspend fun WeiboClient.getUserInfo(uid: Long = info.uid): UserInfoData = temp(PROFILE_INFO) { 10 | header(HttpHeaders.Referrer, "https://www.weibo.com/u/${uid}") 11 | 12 | parameter("uid", uid) 13 | } 14 | 15 | public suspend fun WeiboClient.getUserDetail(uid: Long = info.uid): UserDetail = temp(PROFILE_DETAIL) { 16 | header(HttpHeaders.Referrer, "https://www.weibo.com/u/${uid}") 17 | 18 | parameter("uid", uid) 19 | } 20 | 21 | public suspend fun WeiboClient.getUserHistory(uid: Long = info.uid): HistoryInfo = temp(PROFILE_HISTORY) { 22 | header(HttpHeaders.Referrer, "https://www.weibo.com/u/${uid}") 23 | 24 | parameter("uid", uid) 25 | } 26 | 27 | public suspend fun WeiboClient.getUserFollowers(uid: Long = info.uid, page: Int): UserGroupMembers { 28 | return temp(PROFILE_GROUP_MEMBERS) { 29 | header(HttpHeaders.Referrer, "https://weibo.com/u/page/follow/${uid}/followGroup") 30 | 31 | parameter("uid", uid) 32 | parameter("page", page) 33 | } 34 | } 35 | 36 | public suspend fun WeiboClient.getGroupMembers(gid: Long, page: Int): UserGroupMembers = temp(PROFILE_GROUP_MEMBERS) { 37 | header(HttpHeaders.Referrer, "https://weibo.com/u/page/follow/${info.uid}/followGroup?tabid=${gid}") 38 | 39 | parameter("list_id", gid) 40 | parameter("page", page) 41 | } 42 | 43 | public suspend fun WeiboClient.getGroupList(uid: Long): JsonArray = temp(PROFILE_GROUP_LIST) { 44 | header(HttpHeaders.Referrer, "https://weibo.com/u/${uid}/") 45 | 46 | parameter("uid", uid) 47 | } 48 | 49 | public suspend fun WeiboClient.setGroup(users: List, dest: List, origin: List): SetResult { 50 | return temp(PROFILE_GROUP_SET) { 51 | method = HttpMethod.Post 52 | 53 | header(HttpHeaders.Referrer, "https://weibo.com/u/${info.uid}/") 54 | 55 | setBody(body = buildJsonObject { 56 | put("list_ids", dest.joinToString(",")) 57 | put("origin_list_ids", origin.joinToString(",")) 58 | put("uids", users.joinToString(",")) 59 | }) 60 | contentType(ContentType.Application.Json) 61 | } 62 | } 63 | 64 | public suspend fun WeiboClient.setGroup(user: Long, group: Long): SetResult { 65 | return setGroup(users = listOf(user), dest = listOf(group), origin = emptyList()) 66 | } 67 | 68 | public suspend fun WeiboClient.follow(uid: Long): UserInfo = temp(FRIENDSHIPS_CREATE) { 69 | method = HttpMethod.Post 70 | 71 | header(HttpHeaders.Referrer, "https://weibo.com/u/${info.uid}/") 72 | 73 | setBody(body = buildJsonObject { 74 | put("friend_uid", uid) 75 | put("lpage", "profile") 76 | put("page", "profile") 77 | }) 78 | contentType(ContentType.Application.Json) 79 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/weibo/api/Statuses.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.weibo.api 2 | 3 | import io.ktor.client.request.* 4 | import io.ktor.http.* 5 | import xyz.cssxsh.weibo.* 6 | import xyz.cssxsh.weibo.data.* 7 | import java.time.* 8 | 9 | public enum class FeatureType(private val value: Int) { 10 | ALL(0), 11 | ORIGINAL(1), 12 | HOT(2), 13 | ARTICLE(10); 14 | 15 | override fun toString(): String = value.toString() 16 | } 17 | 18 | public enum class ChannelType(public val id: Int) { 19 | ALL(1), 20 | USER(3), 21 | NOW(61), 22 | FOLLOW(62), 23 | VIDEO(64), 24 | IMAGE(63), 25 | ARTICLE(21), 26 | HOT(60), 27 | TOPIC(32) 28 | ; 29 | } 30 | 31 | public suspend fun WeiboClient.getEmoticon(): EmotionData = temp(STATUSES_CONFIG) { 32 | header(HttpHeaders.Referrer, "https://www.weibo.com/home") 33 | } 34 | 35 | public suspend fun WeiboClient.getUserMicroBlogs( 36 | uid: Long, 37 | page: Int, 38 | feature: FeatureType = FeatureType.ALL, 39 | month: YearMonth? = null 40 | ): UserBlog = temp(STATUSES_MY_MICRO_BLOG) { 41 | header(HttpHeaders.Referrer, "https://www.weibo.com/u/${uid}") 42 | 43 | parameter("uid", uid) 44 | parameter("page", page) 45 | parameter("feature", feature) 46 | parameter("stat_date", month?.run { "%04d%02d".format(year, monthValue) }) 47 | } 48 | 49 | public suspend fun WeiboClient.getUserHot(uid: Long, page: Int): UserBlog = temp(PROFILE_MY_HOT) { 50 | header(HttpHeaders.Referrer, "https://www.weibo.com/u/${uid}") 51 | 52 | parameter("uid", uid) 53 | parameter("page", page) 54 | parameter("feature", FeatureType.HOT) 55 | } 56 | 57 | public suspend fun WeiboClient.getMicroBlog(id: Long): MicroBlog = getMicroBlog(mid = id.toString()) 58 | 59 | public suspend fun WeiboClient.getMicroBlog(mid: String): MicroBlog = json(STATUSES_SHOW) { 60 | header(HttpHeaders.Referrer, "https://www.weibo.com/detail/${mid}") 61 | 62 | parameter("id", mid) 63 | } 64 | 65 | public suspend fun WeiboClient.getLongText(id: Long): LongTextContent = getLongText(mid = id.toString()) 66 | 67 | public suspend fun WeiboClient.getLongText(mid: String): LongTextContent = temp(STATUSES_LONGTEXT) { 68 | header(HttpHeaders.Referrer, "https://www.weibo.com/detail/${mid}") 69 | 70 | parameter("id", mid) 71 | } 72 | 73 | public suspend fun WeiboClient.getMentions(author: Boolean = false, type: Boolean = false): UserMention { 74 | return temp(STATUSES_MENTIONS) { 75 | header(HttpHeaders.Referrer, "https://weibo.com/at/weibo") 76 | 77 | parameter("filter_by_author", author.toInt()) 78 | parameter("filter_by_type", type.toInt()) 79 | } 80 | } 81 | 82 | public suspend fun WeiboClient.search( 83 | keyword: String, 84 | type: ChannelType = ChannelType.ALL, 85 | page: Int = 1, 86 | count: Int = PAGE_SIZE 87 | ): SearchResult = temp(SEARCH_ALL) { 88 | header(HttpHeaders.Referrer, "https://weibo.com/search") 89 | 90 | parameter("containerid", "100103type=${type.id}&q=${keyword}&t=1") 91 | parameter("page", page) 92 | parameter("count", count) 93 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/weibo/api/SuperChat.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.weibo.api 2 | 3 | import io.ktor.client.request.* 4 | import io.ktor.client.statement.* 5 | import io.ktor.http.* 6 | import xyz.cssxsh.weibo.* 7 | import xyz.cssxsh.weibo.data.* 8 | 9 | public suspend fun WeiboClient.getSuperChatData( 10 | id: String, 11 | ): SuperChatData = temp("https://m.weibo.cn/api/container/getIndex") { 12 | header(HttpHeaders.Referrer, "https://m.weibo.cn/p/${id}/super_index") 13 | 14 | parameter("jumpfrom", "weibocom") 15 | parameter("sudaref", "login.sina.com.cn") 16 | parameter("containerid", "${id}_-_feed") 17 | } 18 | 19 | public suspend fun WeiboClient.getSuperChatHome( 20 | name: String, 21 | ): String { 22 | val url = useHttpClient { http -> 23 | http.head("https://huati.weibo.com/k/$name").request.url 24 | } 25 | return url.encodedPath.substringAfterLast('/') 26 | } 27 | 28 | -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/weibo/data/Login.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.weibo.data 2 | 3 | import kotlinx.serialization.* 4 | import kotlinx.serialization.json.* 5 | 6 | @Serializable 7 | public data class LoginData( 8 | @SerialName("data") 9 | val `data`: JsonElement, 10 | @SerialName("msg") 11 | val message: String, 12 | @SerialName("retcode") 13 | val code: Int 14 | ) 15 | 16 | @Serializable 17 | public data class LoginQrcode( 18 | @SerialName("image") 19 | val image: String, 20 | @SerialName("qrid") 21 | val id: String 22 | ) 23 | 24 | @Serializable 25 | public data class LoginToken( 26 | @SerialName("alt") 27 | val alt: String, 28 | @SerialName("savestate") 29 | val state: Int = 30 30 | ) 31 | 32 | @Serializable 33 | public data class LoginVisitor( 34 | @SerialName("tid") 35 | val tid: String, 36 | @SerialName("new_tid") 37 | val new: Boolean = false, 38 | @SerialName("confidence") 39 | val confidence: Int = 100 40 | ) 41 | 42 | @Serializable 43 | public data class LoginResult( 44 | @SerialName("result") 45 | val result: Boolean, 46 | @SerialName("userinfo") 47 | val info: LoginUserInfo 48 | ) 49 | 50 | @Serializable 51 | public data class LoginUserInfo( 52 | @SerialName("displayname") 53 | val display: String = "", 54 | @SerialName("uniqueid") 55 | val uid: Long = 0 56 | ) 57 | 58 | @Serializable 59 | public data class LoginFlush( 60 | @SerialName("crossDomainUrlList") 61 | val urls: List, 62 | @SerialName("nick") 63 | val nick: String, 64 | @SerialName("retcode") 65 | val code: String, 66 | @SerialName("uid") 67 | val uid: String 68 | ) 69 | 70 | @Serializable 71 | public data class LoginCrossFlush( 72 | @SerialName("arrURL") 73 | val urls: List, 74 | @SerialName("retcode") 75 | val code: String 76 | ) 77 | 78 | @Serializable 79 | public data class LoginStatus( 80 | @SerialName("info") 81 | val info: LoginUserInfo = LoginUserInfo(), 82 | @SerialName("cookies") 83 | val cookies: List = emptyList(), 84 | ) -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/weibo/data/MicroBlog.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.weibo.data 2 | 3 | import kotlinx.serialization.* 4 | import kotlinx.serialization.json.* 5 | import java.time.* 6 | 7 | public val MicroBlog.isLongText: Boolean get() = (continueTag != null) 8 | 9 | public val MicroBlog.hasVideo: Boolean get() = (page?.form == "video") 10 | 11 | public val MicroBlog.hasPage: Boolean get() = (page != null) 12 | 13 | @Serializable 14 | public data class MicroBlog( 15 | /** 16 | * 点赞数 17 | */ 18 | @SerialName("attitudes_count") 19 | val attitudes: Int = 0, 20 | /** 21 | * 评论数 22 | */ 23 | @SerialName("comments_count") 24 | val comments: Int = 0, 25 | @SerialName("created_at") 26 | @Serializable(WeiboDateTimeSerializer::class) 27 | val created: OffsetDateTime, 28 | @SerialName("continue_tag") 29 | internal val continueTag: JsonElement? = null, 30 | @SerialName("favorited") 31 | val favorited: Boolean = false, 32 | @SerialName("id") 33 | val id: Long, 34 | @SerialName("mblogid") 35 | val mid: String, 36 | @SerialName("pic_ids") 37 | val pictures: List = emptyList(), 38 | /** 39 | * 转发数 40 | */ 41 | @SerialName("reposts_count") 42 | val reposts: Int = 0, 43 | @SerialName("retweeted_status") 44 | val retweeted: MicroBlog? = null, 45 | @SerialName("text_raw") 46 | val raw: String? = null, 47 | @SerialName("user") 48 | val user: MicroBlogUser? = null, 49 | @SerialName("url_struct") 50 | val urls: List = emptyList(), 51 | @SerialName("title") 52 | val title: TopTitle? = null, 53 | @SerialName("screen_name_suffix_new") 54 | val suffix: List? = null, 55 | @SerialName("page_info") 56 | val page: PageInfo? = null 57 | ) 58 | 59 | @Serializable 60 | public data class UrlStruct( 61 | @SerialName("h5_target_url") 62 | val h5: String? = null, 63 | @SerialName("long_url") 64 | val long: String? = null, 65 | @SerialName("short_url") 66 | val short: String, 67 | @SerialName("url_title") 68 | val title: String, 69 | @SerialName("url_type") 70 | val type: String = "" 71 | ) 72 | 73 | @Serializable 74 | public data class TopTitle( 75 | @SerialName("text") 76 | val text: String 77 | ) 78 | 79 | @Serializable 80 | public data class ScreenSuffix( 81 | @SerialName("content") 82 | val content: String 83 | ) 84 | 85 | @Serializable 86 | public data class MicroBlogUser( 87 | @SerialName("avatar_hd") 88 | override val avatarHighDefinition: String = "", 89 | @SerialName("avatar_large") 90 | override val avatarLarge: String = "", 91 | @SerialName("following") 92 | override val following: Boolean = false, 93 | @SerialName("follow_me") 94 | val followed: Boolean = false, 95 | @SerialName("id") 96 | override val id: Long = 0, 97 | @SerialName("profile_image_url") 98 | val profileImageUrl: String = "", 99 | @SerialName("profile_url") 100 | val profileUrl: String = "", 101 | @SerialName("screen_name") 102 | override val screen: String = "", 103 | @SerialName("verified") 104 | val verified: Boolean = false, 105 | @SerialName("verified_type") 106 | val verifiedType: VerifiedType = VerifiedType.NONE, 107 | ) : UserBaseInfo 108 | 109 | @Serializable 110 | public data class LongTextContent( 111 | @SerialName("longTextContent") 112 | val content: String? = null, 113 | @SerialName("url_struct") 114 | val urls: List = emptyList() 115 | ) 116 | 117 | @Serializable 118 | public data class TimelineData( 119 | @SerialName("max_id") 120 | val maxId: Long = 0, 121 | @SerialName("since_id") 122 | val sinceId: Long = 0, 123 | @SerialName("statuses") 124 | val statuses: List = emptyList(), 125 | ) 126 | 127 | @Serializable 128 | public data class UserBlog( 129 | @SerialName("since_id") 130 | val sinceId: String? = null, 131 | @SerialName("list") 132 | val list: List = emptyList() 133 | ) 134 | 135 | @Serializable 136 | public data class EmotionData( 137 | @SerialName("emoticon") 138 | val emoticon: Map>> 139 | ) 140 | 141 | @Serializable 142 | public data class Emoticon( 143 | @SerialName("category") 144 | val category: String = "null", 145 | @SerialName("phrase") 146 | val phrase: String, 147 | @SerialName("url") 148 | val url: String 149 | ) 150 | 151 | @Serializable 152 | public data class SearchResult( 153 | @SerialName("cardlist_title") 154 | val title: String, 155 | @SerialName("cards") 156 | val cards: List 157 | ) 158 | 159 | @Serializable 160 | public data class SearchResultCard( 161 | @SerialName("card_group") 162 | val group: List = emptyList(), 163 | @SerialName("card_type") 164 | val type: Int, 165 | @SerialName("is_hotweibo") 166 | @Serializable(NumberToBooleanSerializer::class) 167 | val isHot: Boolean = false, 168 | @SerialName("mblog") 169 | val blog: MicroBlog? = null, 170 | @SerialName("user") 171 | val user: MicroBlogUser? = null 172 | ) -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/weibo/data/PageInfo.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.weibo.data 2 | 3 | import kotlinx.serialization.* 4 | 5 | @Serializable 6 | public data class PageInfo( 7 | @SerialName("media_info") 8 | val media: MediaInfo? = null, 9 | @SerialName("object_type") 10 | val form: String = "", 11 | @SerialName("page_id") 12 | val id: String, 13 | @SerialName("page_pic") 14 | val picture: String? = null, 15 | @SerialName("page_title") 16 | val title: String, 17 | @SerialName("type") 18 | val type: Int 19 | ) { 20 | 21 | @Serializable 22 | public data class MediaInfo( 23 | @SerialName("author_info") 24 | val author: UserInfo? = null, 25 | @SerialName("duration") 26 | val duration: Int = 0, 27 | @SerialName("h5_url") 28 | val url: String = "", 29 | @SerialName("media_id") 30 | val id: String = "", 31 | @SerialName("name") 32 | val name: String = "", 33 | @SerialName("next_title") 34 | val title: String = "", 35 | @SerialName("online_users_number") 36 | val online: Int = 0, 37 | @SerialName("playback_list") 38 | val playbacks: List = emptyList(), 39 | @SerialName("titles") 40 | val titles: List = emptyList(), 41 | @SerialName("video_publish_time") 42 | val published: Long = 0, 43 | ) 44 | 45 | @Serializable 46 | public data class PlayBack( 47 | @SerialName("play_info") 48 | val info: PlayInfo 49 | ) 50 | 51 | @Serializable 52 | public data class PlayInfo( 53 | @SerialName("height") 54 | val height: Int = 0, 55 | @SerialName("mime") 56 | val mime: String, 57 | @SerialName("bitrate") 58 | val bitrate: Int = 0, 59 | @SerialName("quality_label") 60 | val quality: String = "", 61 | @SerialName("size") 62 | val size: Long = 0, 63 | @SerialName("tcp_receive_buffer") 64 | val buffer: Long = 1024, 65 | @SerialName("type") 66 | val type: Int, 67 | @SerialName("url") 68 | val url: String = "", 69 | @SerialName("width") 70 | val width: Int = 0 71 | ) : Comparable<PlayInfo> { 72 | 73 | override fun compareTo(other: PlayInfo): Int = bitrate.compareTo(other.bitrate) 74 | } 75 | 76 | @Serializable 77 | public data class Title( 78 | @SerialName("default") 79 | val default: Boolean = false, 80 | @SerialName("title") 81 | val title: String 82 | ) 83 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/weibo/data/Serializer.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.weibo.data 2 | 3 | import kotlinx.serialization.* 4 | import kotlinx.serialization.descriptors.* 5 | import kotlinx.serialization.encoding.* 6 | import kotlinx.serialization.json.* 7 | import java.time.* 8 | import java.time.format.* 9 | import java.util.* 10 | 11 | public typealias HistoryInfo = Map<Int, List<Int>> 12 | 13 | @Serializable 14 | public data class SetResult( 15 | @SerialName("result") 16 | val result: Boolean 17 | ) 18 | 19 | public object WeiboDateTimeSerializer : KSerializer<OffsetDateTime> { 20 | 21 | private val formatter: DateTimeFormatter = 22 | DateTimeFormatter.ofPattern("E MMM d HH:mm:ss Z yyyy", Locale.ENGLISH) 23 | 24 | override val descriptor: SerialDescriptor = 25 | PrimitiveSerialDescriptor(OffsetDateTime::class.qualifiedName!!, PrimitiveKind.STRING) 26 | 27 | override fun deserialize(decoder: Decoder): OffsetDateTime = OffsetDateTime.parse(decoder.decodeString(), formatter) 28 | 29 | override fun serialize(encoder: Encoder, value: OffsetDateTime): Unit = encoder.encodeString(value.format(formatter)) 30 | 31 | } 32 | 33 | public object LocaleSerializer : KSerializer<Locale> { 34 | 35 | override val descriptor: SerialDescriptor = 36 | PrimitiveSerialDescriptor(Locale::class.qualifiedName!!, PrimitiveKind.STRING) 37 | 38 | override fun deserialize(decoder: Decoder): Locale = Locale(decoder.decodeString()) 39 | 40 | override fun serialize(encoder: Encoder, value: Locale): Unit = encoder.encodeString(value.language) 41 | } 42 | 43 | public object NumberToBooleanSerializer : KSerializer<Boolean> { 44 | 45 | override val descriptor: SerialDescriptor = 46 | PrimitiveSerialDescriptor("NumberToBooleanSerializer", PrimitiveKind.BOOLEAN) 47 | 48 | override fun deserialize(decoder: Decoder): Boolean = decoder.decodeLong() != 0L 49 | 50 | override fun serialize(encoder: Encoder, value: Boolean): Unit = encoder.encodeLong(if (value) 1L else 0L) 51 | } 52 | 53 | public interface WeiboValue<T> { 54 | public val value: T 55 | } 56 | 57 | public class WeiboEnumSerializer<E, T>(private val values: Array<E>) : 58 | KSerializer<E> where E : Enum<E>, E : WeiboValue<T> { 59 | 60 | override val descriptor: SerialDescriptor = JsonPrimitive.serializer().descriptor 61 | 62 | override fun serialize(encoder: Encoder, value: E) { 63 | when (val enumValue = value.value) { 64 | is String -> encoder.encodeString(enumValue) 65 | is Int -> encoder.encodeInt(enumValue) 66 | is Long -> encoder.encodeLong(enumValue) 67 | else -> throw IllegalArgumentException("不支持的类型") 68 | } 69 | } 70 | 71 | override fun deserialize(decoder: Decoder): E { 72 | val value = when (values.first().value) { 73 | is String -> decoder.decodeString() 74 | is Int -> decoder.decodeInt() 75 | is Long -> decoder.decodeLong() 76 | else -> throw IllegalArgumentException("不支持的类型") 77 | } 78 | return requireNotNull(values.find { it.value == value }) { decoder.decodeString() } 79 | } 80 | } 81 | 82 | @Suppress("FunctionName") 83 | public inline fun <reified E, T> WeiboEnumSerializer(): WeiboEnumSerializer<E, T> where E : Enum<E>, E : WeiboValue<T> { 84 | return WeiboEnumSerializer(enumValues()) 85 | } 86 | 87 | @Serializable(with = PictureType.Companion::class) 88 | public enum class PictureType(override val value: String) : WeiboValue<String> { 89 | PICTURE(value = "pic"), 90 | GIF(value = "gif"), 91 | LIVE_PHOTO(value = "livephoto"); 92 | 93 | public companion object : KSerializer<PictureType> by WeiboEnumSerializer() 94 | } 95 | 96 | @Serializable(with = GenderType.Companion::class) 97 | public enum class GenderType(override val value: String) : WeiboValue<String> { 98 | MALE(value = "m"), 99 | FEMALE(value = "f"), 100 | NONE(value = "n"); 101 | 102 | public companion object : KSerializer<GenderType> by WeiboEnumSerializer() 103 | } 104 | 105 | @Serializable(with = UserGroupType.Companion::class) 106 | public enum class UserGroupType(override val value: Int) : WeiboValue<Int> { 107 | USER(value = 0), 108 | ALL(value = 1), 109 | QUIETLY(value = 5), 110 | MUTUAL(value = 9), 111 | GROUP(value = 10), 112 | FILTER(value = 20), 113 | SYSTEM(value = 8888); 114 | 115 | public companion object : KSerializer<UserGroupType> by WeiboEnumSerializer() 116 | } 117 | 118 | /** 119 | * verified_type < 8 ? "微博官方认证" : "微博个人认证" 120 | */ 121 | @Serializable(with = VerifiedType.Companion::class) 122 | public enum class VerifiedType(override val value: Int) : WeiboValue<Int> { 123 | NONE(value = -1), 124 | PERSONAL(value = 0), 125 | GOVERNMENT(value = 1), 126 | ENTERPRISE(value = 2), 127 | MEDIA(value = 3), 128 | CAMPUS(value = 4), 129 | WEBSITE(value = 5), 130 | APPLICATION(value = 6), 131 | ORGANIZATION(value = 7), 132 | PENDING_ENTERPRISE(value = 8), 133 | TEMP_9(value = 9), 134 | TEMP_10(value = 10), 135 | JUNIOR(value = 200), 136 | SENIOR(value = 220), 137 | DECEASED(value = 400); 138 | 139 | public companion object : KSerializer<VerifiedType> by WeiboEnumSerializer() 140 | } -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/weibo/data/SuperChat.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.weibo.data 2 | 3 | import kotlinx.serialization.* 4 | import kotlinx.serialization.json.* 5 | import org.jsoup.Jsoup 6 | import java.time.* 7 | 8 | @Serializable 9 | public data class SuperChatData( 10 | @SerialName("cards") 11 | val cards: List<SuperChatCard> = emptyList(), 12 | @SerialName("pageInfo") 13 | val info: SuperChatPageInfo, 14 | @SerialName("scheme") 15 | val scheme: String = "", 16 | @SerialName("showAppTips") 17 | val showAppTips: Int = 0 18 | ) 19 | 20 | @Serializable 21 | public data class SuperChatCard( 22 | @SerialName("card_group") 23 | val group: List<SuperChatCardGroup> = emptyList(), 24 | @SerialName("mblog") 25 | val blog: SuperChatMicroBlog? = null, 26 | @SerialName("card_type") 27 | val type: String = "" 28 | ) 29 | 30 | @Serializable 31 | public data class SuperChatCardGroup( 32 | @SerialName("card_type") 33 | val type: String = "", 34 | @SerialName("card_type_name") 35 | val name: String = "", 36 | @SerialName("mblog") 37 | val blog: SuperChatMicroBlog? = null 38 | ) 39 | 40 | @Serializable 41 | public data class SuperChatMicroBlog( 42 | @SerialName("attitudes_count") 43 | val attitudes: Int = 0, 44 | @SerialName("bid") 45 | val bid: String = "", 46 | @SerialName("comments_count") 47 | val comments: Int = 0, 48 | @SerialName("created_at") 49 | @Serializable(WeiboDateTimeSerializer::class) 50 | val created: OffsetDateTime, 51 | @SerialName("favorited") 52 | val favorited: Boolean = false, 53 | @SerialName("id") 54 | val id: Long, 55 | @SerialName("isLongText") 56 | val isLongText: Boolean = false, 57 | @SerialName("pic_ids") 58 | val pictures: List<String> = emptyList(), 59 | @SerialName("reposts_count") 60 | val reposts: Int = 0, 61 | @SerialName("text") 62 | val html: String = "", 63 | @SerialName("user") 64 | val user: MicroBlogUser? = null 65 | ) { 66 | public fun toMicroBlog(): MicroBlog { 67 | val text = html 68 | .replace("""<img alt="(.+?)".+?/>""".toRegex()) { it.groupValues[1] } 69 | .let { Jsoup.parse(it).wholeText() } 70 | return MicroBlog( 71 | attitudes = attitudes, 72 | comments = comments, 73 | created = created, 74 | favorited = favorited, 75 | id = id, 76 | mid = bid, 77 | pictures = pictures, 78 | reposts = reposts, 79 | raw = text, 80 | user = user, 81 | continueTag = if (isLongText) JsonNull else null 82 | ) 83 | } 84 | } 85 | 86 | @Serializable 87 | public data class SuperChatPageInfo( 88 | @SerialName("containerid") 89 | val containerId: String = "", 90 | @SerialName("desc") 91 | val description: String = "", 92 | @SerialName("detail_desc") 93 | val detail: String = "", 94 | @SerialName("nick") 95 | val nick: String = "", 96 | @SerialName("oid") 97 | val oid: String = "", 98 | @SerialName("page_size") 99 | val size: Int = 0, 100 | @SerialName("page_title") 101 | val title: String = "", 102 | @SerialName("page_url") 103 | val url: String = "", 104 | @SerialName("since_id") 105 | val sinceId: Long = 0, 106 | @SerialName("total") 107 | val total: Int = 0 108 | ) -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/weibo/data/UserDetailData.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.weibo.data 2 | 3 | import kotlinx.serialization.* 4 | import java.time.* 5 | import java.util.* 6 | 7 | @Serializable 8 | public data class UserDetail( 9 | @SerialName("birthday") 10 | val birthday: String, 11 | @SerialName("created_at") 12 | @Serializable(WeiboDateTimeSerializer::class) 13 | val created: OffsetDateTime, 14 | @SerialName("description") 15 | val description: String, 16 | @SerialName("followers") 17 | val followers: UserFollowers, 18 | @SerialName("gender") 19 | val gender: GenderType, 20 | @SerialName("location") 21 | val location: String, 22 | @SerialName("desc_text") 23 | val verifiedText: String, 24 | @SerialName("verified_url") 25 | val verifiedUrl: String, 26 | ) 27 | 28 | @Serializable 29 | public data class UserFollower( 30 | @SerialName("avatar_large") 31 | val avatar: String, 32 | @SerialName("id") 33 | val id: Long, 34 | @SerialName("screen_name") 35 | val screen: String 36 | ) 37 | 38 | @Serializable 39 | public data class UserFollowers( 40 | @SerialName("total_number") 41 | val total: Int, 42 | @SerialName("users") 43 | val users: List<UserFollower> 44 | ) 45 | 46 | @Serializable 47 | public data class UserInfoData( 48 | @SerialName("tabList") 49 | val tabs: List<UserTab> = emptyList(), 50 | @SerialName("user") 51 | val user: UserInfo 52 | ) 53 | 54 | public interface UserBaseInfo { 55 | public val avatarHighDefinition: String 56 | public val avatarLarge: String 57 | public val id: Long 58 | public val screen: String 59 | public val following: Boolean 60 | } 61 | 62 | @Serializable 63 | public data class UserInfo( 64 | @SerialName("avatar_hd") 65 | override val avatarHighDefinition: String, 66 | @SerialName("avatar_large") 67 | override val avatarLarge: String, 68 | @SerialName("description") 69 | val description: String? = null, 70 | @SerialName("favourites_count") 71 | val favourites: Int = 0, 72 | @SerialName("followers_count") 73 | val followers: Int = 0, 74 | @SerialName("following") 75 | override val following: Boolean = false, 76 | @SerialName("follow_me") 77 | val followed: Boolean = false, 78 | @SerialName("friends_count") 79 | val friends: Int = 0, 80 | @SerialName("gender") 81 | val gender: GenderType = GenderType.NONE, 82 | @SerialName("id") 83 | override val id: Long, 84 | @SerialName("lang") 85 | @Serializable(LocaleSerializer::class) 86 | val lang: Locale = Locale.CHINA, 87 | @SerialName("like") 88 | val liking: Boolean = false, 89 | @SerialName("like_me") 90 | val liked: Boolean = false, 91 | @SerialName("location") 92 | val location: String, 93 | @SerialName("bi_followers_count") 94 | val mutualFollowers: Int = 0, 95 | @SerialName("profile_image_url") 96 | val profileImageUrl: String, 97 | @SerialName("profile_url") 98 | val profileUrl: String, 99 | @SerialName("screen_name") 100 | override val screen: String, 101 | @SerialName("special_follow") 102 | val specialFollow: Boolean = false, 103 | @SerialName("statuses_count") 104 | val statuses: Int = 0, 105 | @SerialName("url") 106 | val url: String, 107 | @SerialName("verified") 108 | val verified: Boolean = false, 109 | @SerialName("verified_type") 110 | val verifiedType: VerifiedType = VerifiedType.NONE, 111 | ) : UserBaseInfo 112 | 113 | @Serializable 114 | public data class UserTab( 115 | @SerialName("name") 116 | val name: String, 117 | @SerialName("tabName") 118 | val tab: String 119 | ) -------------------------------------------------------------------------------- /src/main/kotlin/xyz/cssxsh/weibo/data/UserGroupData.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.weibo.data 2 | 3 | import kotlinx.serialization.* 4 | 5 | @Serializable 6 | public data class UserGroupData( 7 | @SerialName("feed_default") 8 | @Serializable(NumberToBooleanSerializer::class) 9 | val feedDefault: Boolean, 10 | @SerialName("fetch_hot") 11 | @Serializable(NumberToBooleanSerializer::class) 12 | val fetchHot: Boolean, 13 | @SerialName("groups") 14 | val groups: List<UserTypeGroup>, 15 | @SerialName("is_new_segment") 16 | @Serializable(NumberToBooleanSerializer::class) 17 | val isNewSegment: Boolean, 18 | @SerialName("total_number") 19 | val total: Int 20 | ) 21 | 22 | @Serializable 23 | public data class UserTypeGroup( 24 | @SerialName("group") 25 | val list: List<UserGroup>, 26 | @SerialName("group_type") 27 | val type: Int, 28 | @SerialName("priority") 29 | @Serializable(NumberToBooleanSerializer::class) 30 | val priority: Boolean = false, 31 | @SerialName("title") 32 | val title: String 33 | ) 34 | 35 | @Serializable 36 | public data class UserGroup( 37 | @SerialName("count") 38 | val count: Int, 39 | @SerialName("frequency") 40 | @Serializable(NumberToBooleanSerializer::class) 41 | val frequency: Boolean, 42 | @SerialName("gid") 43 | val gid: Long, 44 | @SerialName("is_unread") 45 | @Serializable(NumberToBooleanSerializer::class) 46 | val isUnread: Boolean = false, 47 | @SerialName("title") 48 | val title: String, 49 | @SerialName("type") 50 | val type: UserGroupType, 51 | @SerialName("uid") 52 | val uid: Long, 53 | ) 54 | 55 | @Serializable 56 | public data class UserMention( 57 | @SerialName("statuses") 58 | val statuses: List<MicroBlog> = emptyList(), 59 | @SerialName("total_number") 60 | val total: Int 61 | ) 62 | 63 | @Serializable 64 | public data class UserGroupMembers( 65 | @SerialName("id") 66 | val id: Long? = null, 67 | @SerialName("total_number") 68 | val total: Int, 69 | @SerialName("name") 70 | val name: String? = null, 71 | @SerialName("users") 72 | val users: List<UserInfo> 73 | ) -------------------------------------------------------------------------------- /src/main/resources/META-INF/services/net.mamoe.mirai.console.command.Command: -------------------------------------------------------------------------------- 1 | xyz.cssxsh.mirai.weibo.command.WeiboCacheCommand 2 | xyz.cssxsh.mirai.weibo.command.WeiboDetailCommand 3 | xyz.cssxsh.mirai.weibo.command.WeiboFollowCommand 4 | xyz.cssxsh.mirai.weibo.command.WeiboGroupCommand 5 | xyz.cssxsh.mirai.weibo.command.WeiboHotCommand 6 | xyz.cssxsh.mirai.weibo.command.WeiboLoginCommand 7 | xyz.cssxsh.mirai.weibo.command.WeiboUserCommand 8 | xyz.cssxsh.mirai.weibo.command.WeiboSuperChatCommand -------------------------------------------------------------------------------- /src/main/resources/META-INF/services/net.mamoe.mirai.console.data.PluginConfig: -------------------------------------------------------------------------------- 1 | xyz.cssxsh.mirai.weibo.data.WeiboHelperSettings -------------------------------------------------------------------------------- /src/main/resources/META-INF/services/net.mamoe.mirai.console.data.PluginData: -------------------------------------------------------------------------------- 1 | xyz.cssxsh.mirai.weibo.data.WeiboEmoticonData 2 | xyz.cssxsh.mirai.weibo.data.WeiboStatusData 3 | xyz.cssxsh.mirai.weibo.data.WeiboTaskData -------------------------------------------------------------------------------- /src/main/resources/META-INF/services/net.mamoe.mirai.console.plugin.jvm.JvmPlugin: -------------------------------------------------------------------------------- 1 | xyz.cssxsh.mirai.weibo.WeiboHelperPlugin -------------------------------------------------------------------------------- /src/test/kotlin/xyz/cssxsh/mirai/weibo/data/WeiboEmoticonDataTest.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.mirai.weibo.data 2 | 3 | import org.junit.jupiter.api.* 4 | 5 | internal class WeiboEmoticonDataTest { 6 | 7 | @Test 8 | fun default() { 9 | WeiboEmoticonData.default().forEach { 10 | println(it.key) 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /src/test/kotlin/xyz/cssxsh/weibo/FeedKtTest.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.weibo 2 | 3 | import kotlinx.coroutines.* 4 | import org.junit.jupiter.api.* 5 | import xyz.cssxsh.weibo.api.* 6 | import xyz.cssxsh.weibo.data.* 7 | import java.io.* 8 | 9 | internal class FeedKtTest : WeiboClientTest() { 10 | 11 | @BeforeEach 12 | fun flush(): Unit = runBlocking { client.restore() } 13 | 14 | @Test 15 | fun getFeedGroups(): Unit = runBlocking { 16 | client.getFeedGroups(isNewSegment = false, fetchHot = false).groups.forEach { group -> 17 | println("===${group.title}:${group.type}===") 18 | group.list.forEach { item -> 19 | println("${item.title}:${item.type} -> ${item.gid}") 20 | } 21 | } 22 | } 23 | 24 | @Test 25 | fun getTimeline(): Unit = runBlocking { 26 | client.getGroupsTimeline(gid = 4056713441256071).statuses.forEach { blog -> 27 | blog.user?.let { client.getUserInfo(it.id) } 28 | println(blog.toText()) 29 | } 30 | } 31 | 32 | @Test 33 | fun getHot(): Unit = runBlocking { 34 | client.getHotTimeline(gid = 102803L).statuses.forEach { blog -> 35 | blog.user?.let { client.getUserInfo(it.id) } 36 | println(blog.toText()) 37 | } 38 | } 39 | 40 | @Test 41 | fun getUser(): Unit = runBlocking { 42 | val json = folder.resolve("user.json").readText() 43 | WeiboClient.Json.decodeFromString(UserBlog.serializer(), json) 44 | } 45 | 46 | @Test 47 | fun getVideo(): Unit = runBlocking { 48 | val media = client.getMicroBlog(mid = "L4sWWErGL").page!!.media!! 49 | val title = media.titles.firstOrNull()?.title ?: media.title 50 | val video = media.playbacks.first().info 51 | val mp4 = File("./test/${title}.mp4") 52 | 53 | client.download(video = video).collect { 54 | mp4.appendBytes(it) 55 | } 56 | } 57 | } -------------------------------------------------------------------------------- /src/test/kotlin/xyz/cssxsh/weibo/LoginKtTest.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.weibo 2 | 3 | import kotlinx.coroutines.* 4 | import net.mamoe.mirai.console.util.* 5 | import org.junit.jupiter.api.* 6 | import xyz.cssxsh.weibo.api.* 7 | import javax.imageio.* 8 | 9 | internal class LoginKtTest : WeiboClientTest() { 10 | 11 | @Test 12 | fun flush(): Unit = runBlocking { 13 | client.restore() 14 | println(client.status().info) 15 | } 16 | 17 | @Test 18 | fun qrcode(): Unit = runBlocking { 19 | client.qrcode { url -> 20 | println(url) 21 | } 22 | println(client.status().info) 23 | } 24 | 25 | @Test 26 | fun code() { 27 | val image = ImageIO.read(qrcode) 28 | val message = buildAnsiMessage { 29 | for (y in 4 until 175 step 3) { 30 | for (x in 4 until 175 step 3) { 31 | val rgb = image.getRGB(x, y) 32 | if (rgb != -1) ansi("\u001b[40m") 33 | append(' ') 34 | reset() 35 | } 36 | appendLine() 37 | } 38 | } 39 | println(message) 40 | } 41 | 42 | @Test 43 | fun incarnate(): Unit = runBlocking { 44 | client.incarnate() 45 | } 46 | } -------------------------------------------------------------------------------- /src/test/kotlin/xyz/cssxsh/weibo/ProfileKtTest.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.weibo 2 | 3 | import kotlinx.coroutines.* 4 | import kotlinx.coroutines.flow.* 5 | import org.junit.jupiter.api.* 6 | import xyz.cssxsh.mirai.weibo.* 7 | import xyz.cssxsh.weibo.api.* 8 | import xyz.cssxsh.weibo.data.* 9 | import java.io.File 10 | 11 | internal class ProfileKtTest : WeiboClientTest() { 12 | 13 | @BeforeEach 14 | fun flush(): Unit = runBlocking { client.restore() } 15 | 16 | @Test 17 | fun getUserInfo(): Unit = runBlocking { 18 | val uid = 6179286709 19 | client.getUserInfo(uid).let { 20 | println(it) 21 | } 22 | } 23 | 24 | @Test 25 | fun getUserDetail(): Unit = runBlocking { 26 | client.getUserDetail(uid = 6850282182).let { 27 | println(it) 28 | } 29 | } 30 | 31 | @Test 32 | fun getUserHistory(): Unit = runBlocking { 33 | client.getUserHistory(uid = 6850282182).let { 34 | println(it) 35 | } 36 | } 37 | 38 | @Test 39 | fun getUserHot(): Unit = runBlocking { 40 | client.getUserHot(uid = 6850282182, page = 1).let { 41 | println(it) 42 | } 43 | } 44 | 45 | @Test 46 | fun getGroupMembers(): Unit = runBlocking { 47 | client.getGroupMembers(gid = 4056713441256071, page = 1).users.forEach { 48 | println(it) 49 | } 50 | } 51 | 52 | private val ImageCache = File("F:\\WeiboCache") 53 | 54 | @Test 55 | fun cache(): Unit = runBlocking { 56 | val gid = 4056713441256071 57 | val members = flow { 58 | var page = 1 59 | while (currentCoroutineContext().isActive) { 60 | runCatching<UserGroupMembers> { 61 | client.getGroupMembers(gid = gid, page = page++) 62 | }.onSuccess { 63 | if (it.users.isEmpty()) return@flow 64 | emitAll(it.users.asFlow()) 65 | }.onFailure { 66 | println(it.message) 67 | }.getOrNull() ?: break 68 | } 69 | } 70 | members.collect { info -> 71 | println(info) 72 | // val interval = (3).seconds 73 | // val history = client.getUserHistory(info.id) 74 | // val months = history.flatMap { (year, months) -> months.map { YearMonth.of(year, it)!! } }.sortedDescending() 75 | // var count = 0 76 | // months.forEach { month -> 77 | // runCatching { 78 | // client.getRecord(month, interval).onEach { blog -> 79 | // blog.getImages(flush = false) 80 | // } 81 | // }.onSuccess { record -> 82 | // count += record.size 83 | // if (record.isNotEmpty()) println("对@${info.screen}的${month}缓存下载完成, Total: ${record.size}") 84 | // }.onFailure { 85 | // logger.warning("对@${info.screen}的${month}缓存下载失败", it) 86 | // } 87 | // } 88 | // println("对@${info.screen}的缓存下载完成, ${count}/${info.statusesCount}") 89 | } 90 | } 91 | 92 | @Test 93 | fun group(): Unit = runBlocking { 94 | client.getUserInfo() 95 | ImageCache.listFiles().orEmpty().filter { cache -> 96 | cache.resolve("avatar.ico").exists().not() && cache.resolve("desktop.ini").readText().contains("SHELL32") 97 | }.map { 98 | it.name.toLong() 99 | }.onEach { 100 | runCatching { 101 | client.getUserInfo(it).user.apply { 102 | if (following.not()) { 103 | client.follow(it) 104 | } 105 | } 106 | }.onSuccess { 107 | println(it.id) 108 | delay(3 * 1000L) 109 | }.mapCatching { info -> 110 | client.setGroup(user = info.id, group = 4056713441256071) 111 | val cache = ImageCache.resolve("${info.id}") 112 | info.desktop(flush = true, dir = cache) 113 | client.getGroupList(info.id) 114 | }.onFailure { 115 | println(it.message) 116 | }.onSuccess { 117 | println(it) 118 | } 119 | } 120 | } 121 | } -------------------------------------------------------------------------------- /src/test/kotlin/xyz/cssxsh/weibo/WeiboClientTest.kt: -------------------------------------------------------------------------------- 1 | package xyz.cssxsh.weibo 2 | 3 | import kotlinx.coroutines.* 4 | import kotlinx.serialization.* 5 | import net.mamoe.yamlkt.* 6 | import org.junit.jupiter.api.* 7 | import xyz.cssxsh.weibo.data.* 8 | import java.io.* 9 | 10 | internal abstract class WeiboClientTest { 11 | 12 | val list = listOf( 13 | 5174017612L, 14 | 6787924129L 15 | ) 16 | 17 | val client by lazy { WeiboClient().apply { load(status) } } 18 | 19 | val folder = File("./run/") 20 | 21 | val qrcode = folder.resolve("qrcode.jpg") 22 | 23 | val yaml = folder.resolve("status.yaml") 24 | 25 | fun MicroBlog.toText() = buildString { 26 | appendLine("微博 $username 有新动态:") 27 | appendLine("时间: $created") 28 | appendLine("链接: $link") 29 | appendLine(raw) 30 | pictures.forEach { 31 | appendLine(it) 32 | } 33 | retweeted?.let { retweeted -> 34 | appendLine("==============================") 35 | appendLine("@${retweeted.username}") 36 | appendLine("时间: ${retweeted.created}") 37 | appendLine("链接: ${retweeted.link}") 38 | appendLine(retweeted.raw) 39 | retweeted.pictures.forEach { 40 | appendLine(it) 41 | } 42 | } 43 | } 44 | 45 | var status: LoginStatus 46 | get() = runCatching<LoginStatus> { Yaml.decodeFromString(yaml.readText()) }.getOrElse { LoginStatus() } 47 | set(value) { 48 | yaml.writeText(Yaml.encodeToString<LoginStatus>(value)) 49 | } 50 | 51 | @AfterEach 52 | fun save(): Unit = runBlocking { 53 | status = client.status() 54 | } 55 | } --------------------------------------------------------------------------------