├── .gitignore ├── LICENSE ├── README.md ├── build.gradle.kts ├── buildSrc ├── build.gradle.kts ├── settings.gradle.kts └── src │ └── main │ └── java │ └── de │ └── honoka │ └── gradle │ └── buildsrc │ ├── Kotlin.kt │ ├── KotlinDslCopy.kt │ └── Project.kt ├── files ├── bossdd-monitor-service.xml ├── dev-data │ └── various.sql └── startup.bat ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle.kts └── src └── main ├── java └── de │ └── honoka │ └── bossddmonitor │ ├── BossddMonitorApp.kt │ ├── common │ ├── ExtendedExceptionReporter.kt │ ├── ProxyManager.kt │ └── ServiceLauncher.kt │ ├── config │ ├── BrowserProperties.kt │ ├── MainConfig.kt │ └── MonitorProperties.kt │ ├── controller │ └── SubscriptionController.kt │ ├── entity │ ├── JobInfo.kt │ ├── JobPushRecord.kt │ └── Subscription.kt │ ├── mapper │ ├── JobInfoMapper.kt │ ├── JobPushRecordMapper.kt │ └── SubscriptionMapper.kt │ ├── platform │ ├── BossddPlatform.kt │ └── Platform.kt │ └── service │ ├── BrowserService.kt │ ├── JobInfoService.kt │ ├── JobPushRecordService.kt │ ├── MonitorService.kt │ ├── PushService.kt │ └── SubscriptionService.kt └── resources ├── application.yml ├── config └── application-dev.yml ├── flyway └── sql │ └── V1.0.0__update.sql ├── mapper └── JobInfoMapper.xml └── static-data ├── .gitkeep └── bossdd └── city-code.json /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle/ 2 | build/ 3 | !**/src/main/**/build/ 4 | !**/src/test/**/build/ 5 | application-prod*.yml 6 | application-test.yml 7 | /files/dev-data/private.sql 8 | 9 | ### IntelliJ IDEA ### 10 | .idea/ 11 | *.iws 12 | *.iml 13 | *.ipr 14 | out/ 15 | !**/src/main/**/out/ 16 | !**/src/test/**/out/ 17 | 18 | ### Eclipse ### 19 | .apt_generated 20 | .classpath 21 | .factorypath 22 | .project 23 | .settings 24 | .springBeans 25 | .sts4-cache 26 | bin/ 27 | !**/src/main/**/bin/ 28 | !**/src/test/**/bin/ 29 | 30 | ### NetBeans ### 31 | /nbproject/private/ 32 | /nbbuild/ 33 | /dist/ 34 | /nbdist/ 35 | /.nb-gradle/ 36 | 37 | ### VS Code ### 38 | .vscode/ 39 | 40 | ### Mac OS ### 41 | .DS_Store -------------------------------------------------------------------------------- /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 | # BossDD Monitor 2 | 3 | Boss直聘岗位监控与推送服务 -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | import de.honoka.gradle.buildsrc.kotlin 2 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 3 | import java.nio.charset.StandardCharsets 4 | 5 | @Suppress("DSL_SCOPE_VIOLATION") 6 | plugins { 7 | java 8 | alias(libs.plugins.dependency.management) 9 | alias(libs.plugins.spring.boot) 10 | alias(libs.plugins.kotlin) 11 | alias(libs.plugins.kotlin.kapt) 12 | /* 13 | * Lombok Kotlin compiler plugin is an experimental feature. 14 | * See: https://kotlinlang.org/docs/components-stability.html. 15 | */ 16 | alias(libs.plugins.kotlin.lombok) 17 | alias(libs.plugins.kotlin.spring) 18 | } 19 | 20 | group = "de.honoka.bossddmonitor" 21 | version = libs.versions.root.get() 22 | 23 | java { 24 | sourceCompatibility = JavaVersion.VERSION_17 25 | targetCompatibility = sourceCompatibility 26 | } 27 | 28 | dependencyManagement { 29 | imports { 30 | mavenBom(libs.kotlin.bom.get().toString()) 31 | mavenBom(libs.selenium.bom.get().toString()) 32 | } 33 | } 34 | 35 | dependencies { 36 | kotlin(project) 37 | libs.versions.kotlin.coroutines 38 | implementation("org.springframework.boot:spring-boot-starter") 39 | implementation("org.springframework.boot:spring-boot-starter-web") 40 | implementation("org.springframework.boot:spring-boot-starter-validation") 41 | implementation(libs.honoka.spring.boot.starter) 42 | implementation(libs.qqrobot.spring.boot.starter) 43 | implementation("com.baomidou:mybatis-plus-spring-boot3-starter:3.5.5") 44 | runtimeOnly("com.mysql:mysql-connector-j") 45 | implementation("org.flywaydb:flyway-mysql") 46 | implementation("org.seleniumhq.selenium:selenium-java") 47 | implementation("net.lightbody.bmp:browsermob-core:2.1.5") { 48 | exclude("org.slf4j", "slf4j-api") 49 | exclude("org.slf4j", "jcl-over-slf4j") 50 | } 51 | kapt("org.springframework.boot:spring-boot-configuration-processor") 52 | libs.lombok.let { 53 | compileOnly(it) 54 | annotationProcessor(it) 55 | testCompileOnly(it) 56 | testAnnotationProcessor(it) 57 | } 58 | //Test 59 | testImplementation("org.springframework.boot:spring-boot-starter-test") 60 | } 61 | 62 | tasks { 63 | compileJava { 64 | options.run { 65 | encoding = StandardCharsets.UTF_8.name() 66 | val compilerArgs = compilerArgs as MutableCollection 67 | compilerArgs += listOf( 68 | "-parameters" 69 | ) 70 | } 71 | } 72 | 73 | withType { 74 | kotlinOptions { 75 | jvmTarget = java.sourceCompatibility.toString() 76 | freeCompilerArgs += listOf( 77 | "-Xjsr305=strict", 78 | "-Xjvm-default=all" 79 | ) 80 | } 81 | } 82 | 83 | bootJar { 84 | archiveFileName.set("${project.name}.jar") 85 | } 86 | 87 | test { 88 | useJUnitPlatform() 89 | } 90 | } 91 | 92 | kapt { 93 | keepJavacAnnotationProcessors = true 94 | } 95 | -------------------------------------------------------------------------------- /buildSrc/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `kotlin-dsl` 3 | } -------------------------------------------------------------------------------- /buildSrc/settings.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("UnstableApiUsage") 2 | 3 | dependencyResolutionManagement { 4 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 5 | repositories { 6 | mavenLocal() 7 | maven("https://maven.aliyun.com/repository/public") 8 | mavenCentral() 9 | google() 10 | maven("https://mirrors.honoka.de/maven-repo/release") 11 | maven("https://mirrors.honoka.de/maven-repo/development") 12 | } 13 | } 14 | 15 | pluginManagement { 16 | repositories { 17 | maven("https://maven.aliyun.com/repository/gradle-plugin") 18 | mavenCentral() 19 | gradlePluginPortal() 20 | google() 21 | } 22 | } -------------------------------------------------------------------------------- /buildSrc/src/main/java/de/honoka/gradle/buildsrc/Kotlin.kt: -------------------------------------------------------------------------------- 1 | package de.honoka.gradle.buildsrc 2 | 3 | import org.gradle.api.Project 4 | import org.gradle.api.artifacts.dsl.DependencyHandler 5 | import org.gradle.api.internal.catalog.VersionModel 6 | 7 | fun DependencyHandler.kotlin(project: Project) { 8 | val versions: Map = project.libVersions() 9 | implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:${versions.getVersion("kotlin")}") 10 | implementation("org.jetbrains.kotlin:kotlin-reflect:${versions.getVersion("kotlin")}") 11 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:${versions.getVersion("kotlin.coroutines")}") 12 | } -------------------------------------------------------------------------------- /buildSrc/src/main/java/de/honoka/gradle/buildsrc/KotlinDslCopy.kt: -------------------------------------------------------------------------------- 1 | package de.honoka.gradle.buildsrc 2 | 3 | import org.gradle.api.NamedDomainObjectContainer 4 | import org.gradle.api.NamedDomainObjectProvider 5 | import org.gradle.api.artifacts.Configuration 6 | import org.gradle.api.artifacts.Dependency 7 | import org.gradle.api.artifacts.dsl.DependencyHandler 8 | import org.gradle.kotlin.dsl.named 9 | 10 | fun DependencyHandler.implementation(dependencyNotation: Any): Dependency? = run { 11 | add("implementation", dependencyNotation) 12 | } 13 | 14 | val NamedDomainObjectContainer.implementation: NamedDomainObjectProvider 15 | get() = named("implementation") -------------------------------------------------------------------------------- /buildSrc/src/main/java/de/honoka/gradle/buildsrc/Project.kt: -------------------------------------------------------------------------------- 1 | package de.honoka.gradle.buildsrc 2 | 3 | import org.gradle.api.Project 4 | import org.gradle.api.internal.catalog.VersionModel 5 | 6 | @Suppress("UNCHECKED_CAST") 7 | fun Project.libVersions(): Map { 8 | val libs = rootProject.extensions.getByName("libs") 9 | val versions = libs.javaClass.getDeclaredMethod("getVersions").invoke(libs) 10 | val catalog = versions.javaClass.superclass.getDeclaredField("config").run { 11 | isAccessible = true 12 | get(versions) 13 | } 14 | catalog.javaClass.getDeclaredField("versions").run { 15 | isAccessible = true 16 | return get(catalog) as Map 17 | } 18 | } 19 | 20 | fun Map.getVersion(key: String): String = get(key)?.version.toString() -------------------------------------------------------------------------------- /files/bossdd-monitor-service.xml: -------------------------------------------------------------------------------- 1 | 2 | bossdd-monitor-service 3 | bossdd-monitor-service 4 | bossdd-monitor-service 5 | java 6 | -jar -Dfile.encoding=UTF-8 -Dspring.profiles.active=prod bossdd-monitor.jar 7 | Automatic 8 | MySQL80 9 | 10 | %BASE%\service\logs 11 | 12 | -------------------------------------------------------------------------------- /files/dev-data/various.sql: -------------------------------------------------------------------------------- 1 | -- 上海,搜索词为:java,用户地址为:上海虹桥站 2 | insert into subscription 3 | values (null, 12345, 10000, 'java', '101020100', 100, 3, 15, 50, null, null, '121.320081,31.193964', 1); 4 | -------------------------------------------------------------------------------- /files/startup.bat: -------------------------------------------------------------------------------- 1 | chcp 65001 2 | 3 | java -jar -Dfile.encoding=UTF-8 -Dspring.profiles.active=prod bossdd-monitor.jar ^ 4 | --app.browser.default-headless=false ^ 5 | --app.monitor.initial-delay=0s ^ 6 | --app.monitor.weekday-range=1-7 ^ 7 | --app.monitor.hour-range=0-24 8 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | # project modules version 3 | root = "1.0.0-dev" 4 | 5 | # dependencies version 6 | kotlin = "1.8.10" 7 | kotlin-coroutines = "1.6.4" 8 | spring-boot = "3.2.5" 9 | honoka-spring-boot-starter = "1.0.3-dev" 10 | qqrobot-spring-boot-starter = "2.0.1-dev" 11 | selenium = "4.27.0" 12 | lombok = "1.18.26" 13 | 14 | [plugins] 15 | kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } 16 | kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } 17 | kotlin-lombok = { id = "org.jetbrains.kotlin.plugin.lombok", version.ref = "kotlin" } 18 | kotlin-spring = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotlin" } 19 | dependency-management = "io.spring.dependency-management:1.1.6" 20 | spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot" } 21 | 22 | [libraries] 23 | kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin" } 24 | selenium-bom = { module = "org.seleniumhq.selenium:selenium-dependencies-bom", version.ref = "selenium" } 25 | qqrobot-spring-boot-starter = { module = "de.honoka.qqrobot:qqrobot-spring-boot-starter", version.ref = "qqrobot-spring-boot-starter" } 26 | honoka-spring-boot-starter = { module = "de.honoka.sdk:honoka-spring-boot-starter", version.ref = "honoka-spring-boot-starter" } 27 | lombok = { module = "org.projectlombok:lombok", version.ref = "lombok" } 28 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kosaka-bun/bossdd-monitor/744e8860b9b60db976e105a0450a8b0330ba5ed9/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Mon Oct 02 01:37:08 CST 2023 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-all.zip 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("UnstableApiUsage") 2 | 3 | dependencyResolutionManagement { 4 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 5 | repositories { 6 | mavenLocal() 7 | maven("https://maven.aliyun.com/repository/public") 8 | mavenCentral() 9 | google() 10 | maven("https://mirrors.honoka.de/maven-repo/release") 11 | maven("https://mirrors.honoka.de/maven-repo/development") 12 | } 13 | } 14 | 15 | pluginManagement { 16 | repositories { 17 | maven("https://maven.aliyun.com/repository/gradle-plugin") 18 | mavenCentral() 19 | gradlePluginPortal() 20 | google() 21 | } 22 | } 23 | 24 | rootProject.name = "bossdd-monitor" 25 | -------------------------------------------------------------------------------- /src/main/java/de/honoka/bossddmonitor/BossddMonitorApp.kt: -------------------------------------------------------------------------------- 1 | package de.honoka.bossddmonitor 2 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication 4 | import org.springframework.boot.runApplication 5 | 6 | @SpringBootApplication 7 | class BossddMonitorApp 8 | 9 | fun main(args: Array) { 10 | runApplication(*args) 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/de/honoka/bossddmonitor/common/ExtendedExceptionReporter.kt: -------------------------------------------------------------------------------- 1 | package de.honoka.bossddmonitor.common 2 | 3 | import cn.hutool.core.bean.BeanUtil 4 | import cn.hutool.core.exceptions.ExceptionUtil 5 | import cn.hutool.json.JSONUtil 6 | import de.honoka.bossddmonitor.service.BrowserService 7 | import de.honoka.qqrobot.framework.ExtendedRobotFramework 8 | import de.honoka.qqrobot.framework.api.message.RobotMessage 9 | import de.honoka.qqrobot.framework.api.message.RobotMultipartMessage 10 | import de.honoka.qqrobot.starter.component.ExceptionReporter 11 | import de.honoka.sdk.util.kotlin.basic.cast 12 | import de.honoka.sdk.util.kotlin.basic.log 13 | import de.honoka.sdk.util.kotlin.concurrent.ScheduledTask 14 | import de.honoka.sdk.util.various.ImageUtils 15 | import org.openqa.selenium.WebDriverException 16 | import org.springframework.stereotype.Component 17 | import java.util.concurrent.TimeoutException 18 | import java.util.concurrent.atomic.AtomicInteger 19 | 20 | @Component 21 | class ExtendedExceptionReporter( 22 | private val exceptionReporter: ExceptionReporter, 23 | private val robotFramework: ExtendedRobotFramework 24 | ) { 25 | 26 | private data class ExceptionCounts( 27 | 28 | val waitForResponseTimeout: AtomicInteger = AtomicInteger(0), 29 | 30 | val onErrorPage: AtomicInteger = AtomicInteger(0), 31 | 32 | val tunnelConnectionFailed: AtomicInteger = AtomicInteger(0) 33 | ) 34 | 35 | private val scheduledTask = ScheduledTask("1h", "10m", action = ::doTask) 36 | 37 | private var counts = ExceptionCounts() 38 | 39 | init { 40 | scheduledTask.startup() 41 | } 42 | 43 | private fun doTask() { 44 | val map = BeanUtil.beanToMap(counts).apply { 45 | if(values.all { it.cast().get() < 1 }) return 46 | } 47 | val json = JSONUtil.toJsonPrettyStr(map) 48 | counts = ExceptionCounts() 49 | robotFramework.sendMsgToDevelopingGroup(RobotMultipartMessage().apply { 50 | add(RobotMessage.text("过去1小时内受计数异常产生次数:")) 51 | add(RobotMessage.image(ImageUtils.textToImageByLength(json, 50))) 52 | }) 53 | } 54 | 55 | fun report(t: Throwable) { 56 | val cause = ExceptionUtil.getRootCause(t) 57 | val blocked = when(cause) { 58 | is TimeoutException -> checkException(cause) 59 | is BrowserService.OnErrorPageException -> { 60 | counts.onErrorPage.incrementAndGet() 61 | true 62 | } 63 | is WebDriverException -> checkException(cause) 64 | else -> false 65 | } 66 | if(blocked) { 67 | log.error("", cause) 68 | } else { 69 | exceptionReporter.report(cause) 70 | } 71 | } 72 | 73 | private fun checkException(e: TimeoutException): Boolean = run { 74 | val stacktrace = ExceptionUtil.stacktraceToString(e) 75 | when { 76 | stacktrace.contains("BrowserService.waitForResponse") -> { 77 | counts.waitForResponseTimeout.incrementAndGet() 78 | true 79 | } 80 | else -> false 81 | } 82 | } 83 | 84 | private fun checkException(e: WebDriverException): Boolean = run { 85 | val stacktrace = ExceptionUtil.stacktraceToString(e) 86 | when { 87 | stacktrace.contains("ERR_TUNNEL_CONNECTION_FAILED") -> { 88 | counts.tunnelConnectionFailed.incrementAndGet() 89 | true 90 | } 91 | else -> false 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/main/java/de/honoka/bossddmonitor/common/ProxyManager.kt: -------------------------------------------------------------------------------- 1 | package de.honoka.bossddmonitor.common 2 | 3 | import de.honoka.bossddmonitor.config.MainProperties 4 | import de.honoka.sdk.util.kotlin.basic.exception 5 | import de.honoka.sdk.util.kotlin.net.socket.SocketUtils 6 | import jakarta.annotation.PreDestroy 7 | import net.lightbody.bmp.BrowserMobProxyServer 8 | import net.lightbody.bmp.proxy.auth.AuthType 9 | import org.springframework.stereotype.Component 10 | import java.io.Closeable 11 | 12 | @Component 13 | class ProxyManager(private val mainProperties: MainProperties) : Closeable { 14 | 15 | val available = mainProperties.proxy.address != null 16 | 17 | @Volatile 18 | private var proxyOrNull: BrowserMobProxyServer? = null 19 | 20 | val proxy: BrowserMobProxyServer 21 | get() = run { 22 | if(!available || ServiceLauncher.appShutdown) { 23 | exception("Cannot get proxy.") 24 | } 25 | proxyOrNull ?: synchronized(this) { 26 | proxyOrNull ?: newProxy() 27 | proxyOrNull!! 28 | } 29 | } 30 | 31 | @Synchronized 32 | fun newProxy() { 33 | if(!available || ServiceLauncher.appShutdown) return 34 | close() 35 | proxyOrNull = BrowserMobProxyServer().apply { 36 | chainedProxy = SocketUtils.parseInetSocketAddress(mainProperties.proxy.address!!) 37 | mainProperties.proxy.usernameWithSession?.let { 38 | chainedProxyAuthorization(it, mainProperties.proxy.password, AuthType.BASIC) 39 | } 40 | start(mainProperties.proxy.localPort) 41 | } 42 | } 43 | 44 | @PreDestroy 45 | override fun close() { 46 | runCatching { 47 | proxyOrNull?.abort() 48 | } 49 | proxyOrNull = null 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/java/de/honoka/bossddmonitor/common/ServiceLauncher.kt: -------------------------------------------------------------------------------- 1 | package de.honoka.bossddmonitor.common 2 | 3 | import de.honoka.bossddmonitor.service.BrowserService 4 | import de.honoka.bossddmonitor.service.MonitorService 5 | import de.honoka.bossddmonitor.service.PushService 6 | import org.springframework.boot.ApplicationArguments 7 | import org.springframework.boot.ApplicationRunner 8 | import org.springframework.stereotype.Component 9 | 10 | @Component 11 | class ServiceLauncher( 12 | private val monitorService: MonitorService, 13 | private val pushService: PushService, 14 | private val browserService: BrowserService 15 | ) : ApplicationRunner { 16 | 17 | companion object { 18 | 19 | @Volatile 20 | var appShutdown = false 21 | private set 22 | } 23 | 24 | override fun run(args: ApplicationArguments) { 25 | browserService.init() 26 | monitorService.scheduledTask.startup() 27 | pushService.scheduledTask.startup() 28 | } 29 | 30 | fun stop() { 31 | appShutdown = true 32 | browserService.stop() 33 | monitorService.scheduledTask.close() 34 | pushService.scheduledTask.close() 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/de/honoka/bossddmonitor/config/BrowserProperties.kt: -------------------------------------------------------------------------------- 1 | package de.honoka.bossddmonitor.config 2 | 3 | import de.honoka.sdk.util.file.FileUtils 4 | import org.springframework.boot.context.properties.ConfigurationProperties 5 | import java.nio.file.Paths 6 | 7 | @ConfigurationProperties("app.browser") 8 | data class BrowserProperties( 9 | 10 | var executablePath: String? = null, 11 | 12 | var startProcessByApp: Boolean = false, 13 | 14 | var stopProcessByCommand: Boolean = true, 15 | 16 | var userDataDir: UserDataDir = UserDataDir(), 17 | 18 | var defaultHeadless: Boolean = true, 19 | 20 | var blockUrlKeywords: List = listOf(), 21 | 22 | var errorPageDetection: ErrorPageDetection = ErrorPageDetection() 23 | ) { 24 | 25 | data class UserDataDir( 26 | 27 | var path: String = "./selenium/user-data", 28 | 29 | var clearBeforeInit: Boolean = true 30 | ) { 31 | 32 | val absolutePath: String 33 | get() { 34 | val pathObj = if(FileUtils.isAppRunningInJar()) { 35 | Paths.get(FileUtils.getMainClasspath(), path) 36 | } else { 37 | Paths.get(path) 38 | } 39 | return pathObj.toAbsolutePath().normalize().toString() 40 | } 41 | } 42 | 43 | data class ErrorPageDetection( 44 | 45 | var urlKeywords: List = listOf(), 46 | 47 | var selectors: List = listOf() 48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /src/main/java/de/honoka/bossddmonitor/config/MainConfig.kt: -------------------------------------------------------------------------------- 1 | package de.honoka.bossddmonitor.config 2 | 3 | import cn.hutool.core.util.RandomUtil 4 | import de.honoka.bossddmonitor.common.ServiceLauncher 5 | import de.honoka.sdk.spring.starter.core.context.springBean 6 | import de.honoka.sdk.util.kotlin.basic.log 7 | import jakarta.annotation.PostConstruct 8 | import jakarta.annotation.PreDestroy 9 | import org.springframework.boot.context.properties.ConfigurationProperties 10 | import org.springframework.boot.context.properties.EnableConfigurationProperties 11 | import org.springframework.context.annotation.Configuration 12 | 13 | @EnableConfigurationProperties(value = [ 14 | MainProperties::class, 15 | BrowserProperties::class, 16 | MonitorProperties::class 17 | ]) 18 | @Configuration 19 | class MainConfig { 20 | 21 | @PostConstruct 22 | fun onStarting() { 23 | System.setProperty("java.awt.headless", "false") 24 | } 25 | 26 | @PreDestroy 27 | fun beforeExit() { 28 | ServiceLauncher::class.springBean.stop() 29 | log.info("Application has been closed.") 30 | } 31 | } 32 | 33 | @ConfigurationProperties("app") 34 | data class MainProperties( 35 | 36 | var proxy: Proxy = Proxy() 37 | ) { 38 | 39 | data class Proxy( 40 | 41 | var address: String? = null, 42 | 43 | var localPort: Int = 10908, 44 | 45 | var username: String? = null, 46 | 47 | var password: String? = null 48 | ) { 49 | 50 | val usernameWithSession: String? 51 | get() = username?.let { "$it-session-${RandomUtil.randomString(8)}" } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/main/java/de/honoka/bossddmonitor/config/MonitorProperties.kt: -------------------------------------------------------------------------------- 1 | package de.honoka.bossddmonitor.config 2 | 3 | import org.springframework.boot.context.properties.ConfigurationProperties 4 | 5 | @ConfigurationProperties("app.monitor") 6 | data class MonitorProperties( 7 | 8 | /** 9 | * 任务在每次执行完成之后到下一次执行前需等待的时间长度 10 | */ 11 | var delay: String = "5m", 12 | 13 | /** 14 | * 任务启动后,在第一次执行监控任务前需等待的时间长度 15 | */ 16 | var initialDelay: String = "1m", 17 | 18 | /** 19 | * 一周中需要执行监控任务的日期范围(包含左右边界) 20 | */ 21 | var weekdayRange: String = "1-6", 22 | 23 | /** 24 | * 一天内需要执行监控任务的小时范围(在左边界之后,包含左边界,右边界之前) 25 | */ 26 | var hourRange: String = "8-22" 27 | ) { 28 | 29 | val weekdayRangeParts: List 30 | get() = weekdayRange.split("-").map { it.toInt() } 31 | 32 | val hourRangeParts: List 33 | get() = hourRange.split("-").map { it.toInt() } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/de/honoka/bossddmonitor/controller/SubscriptionController.kt: -------------------------------------------------------------------------------- 1 | package de.honoka.bossddmonitor.controller 2 | 3 | import de.honoka.bossddmonitor.service.SubscriptionService 4 | import de.honoka.qqrobot.framework.api.message.RobotMessage 5 | import de.honoka.qqrobot.starter.command.CommandMethodArgs 6 | import de.honoka.qqrobot.starter.common.annotation.Command 7 | import de.honoka.qqrobot.starter.common.annotation.RobotController 8 | import de.honoka.qqrobot.starter.component.session.SessionManager 9 | 10 | @RobotController 11 | class SubscriptionController( 12 | private val subscriptionService: SubscriptionService, 13 | private val sessionManager: SessionManager 14 | ) { 15 | 16 | @Command("我的订阅") 17 | fun getSubscription(args: CommandMethodArgs): String = run { 18 | subscriptionService.getSubscriptionOfUser(args.qq) 19 | } 20 | 21 | @Command("注册") 22 | fun register(args: CommandMethodArgs) { 23 | sessionManager.openSession(args.group, args.qq) { 24 | action = { 25 | subscriptionService.create(this) 26 | } 27 | } 28 | } 29 | 30 | @Command("修改订阅", argsCount = 2) 31 | fun updateSubscription(args: CommandMethodArgs): String = run { 32 | subscriptionService.update(args) 33 | } 34 | 35 | @Command("查询屏蔽词", argsCount = 1) 36 | fun getBlockWordsAndRegexes(args: CommandMethodArgs): RobotMessage<*> = run { 37 | subscriptionService.getBlockWordsAndRegexes(args.qq, args.getString(0)) 38 | } 39 | 40 | @Command("管理屏蔽词", argsCount = 3) 41 | fun manageBlockWordsAndRegexes(args: CommandMethodArgs): String = run { 42 | subscriptionService.manageBlockWordsAndRegexes(args) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/de/honoka/bossddmonitor/entity/JobInfo.kt: -------------------------------------------------------------------------------- 1 | package de.honoka.bossddmonitor.entity 2 | 3 | import com.baomidou.mybatisplus.annotation.IdType 4 | import com.baomidou.mybatisplus.annotation.TableId 5 | import de.honoka.bossddmonitor.platform.PlatformEnum 6 | import de.honoka.sdk.util.kotlin.text.findOne 7 | import java.util.* 8 | 9 | data class JobInfo( 10 | 11 | @TableId(type = IdType.AUTO) 12 | var id: Long? = null, 13 | 14 | /** 15 | * 平台名称 16 | */ 17 | var platform: PlatformEnum? = null, 18 | 19 | /** 20 | * 平台岗位ID 21 | */ 22 | var platformJobId: String? = null, 23 | 24 | /** 25 | * 来源的搜索关键词 26 | */ 27 | var fromSearchWord: String? = null, 28 | 29 | /** 30 | * 岗位标识符(json) 31 | */ 32 | var identifiers: String? = null, 33 | 34 | /** 35 | * 城市代码 36 | */ 37 | var cityCode: String? = null, 38 | 39 | /** 40 | * 岗位标题 41 | */ 42 | var title: String? = null, 43 | 44 | /** 45 | * 公司名(简称) 46 | */ 47 | var company: String? = null, 48 | 49 | /** 50 | * 公司名 51 | */ 52 | var companyFullName: String? = null, 53 | 54 | /** 55 | * 公司规模 56 | */ 57 | var companyScale: String? = null, 58 | 59 | /** 60 | * HR姓名 61 | */ 62 | var hrName: String? = null, 63 | 64 | /** 65 | * HR是否在线 66 | */ 67 | var hrOnline: Boolean? = null, 68 | 69 | /** 70 | * HR活跃度 71 | */ 72 | var hrLiveness: String? = null, 73 | 74 | /** 75 | * 薪资范围 76 | */ 77 | var salary: String? = null, 78 | 79 | /** 80 | * 经验要求 81 | */ 82 | var experience: String? = null, 83 | 84 | /** 85 | * 学历要求 86 | */ 87 | var eduDegree: String? = null, 88 | 89 | /** 90 | * 岗位标签(json) 91 | */ 92 | var tags: String? = null, 93 | 94 | /** 95 | * 岗位详细描述 96 | */ 97 | var details: String? = null, 98 | 99 | /** 100 | * 岗位地址 101 | */ 102 | var address: String? = null, 103 | 104 | /** 105 | * 岗位地址(经纬度) 106 | */ 107 | var gpsLocation: String? = null, 108 | 109 | var createTime: Date? = null, 110 | 111 | var updateTime: Date? = null 112 | ) { 113 | 114 | val minCompanyScale: Int? 115 | get() = companyScale?.let { 116 | when { 117 | it.contains("-") -> it.substring(0, it.indexOf("-")).toInt() 118 | else -> it.findOne("\\d+")?.toInt() 119 | } 120 | } 121 | 122 | val averageSalary: Int? 123 | get() = salary?.let { 124 | val range = it.findOne("\\d+-\\d+") ?: return null 125 | val parts = range.split("-").map { s -> s.toInt() } 126 | when { 127 | it.contains("K") -> (parts[0] + parts[1]) / 2 128 | it.contains("元") -> (parts[0] + parts[1]) / 2000 129 | else -> null 130 | } 131 | } 132 | 133 | val minExperience: Int? 134 | get() = experience?.let { 135 | when { 136 | it.contains("-") -> it.substring(0, it.indexOf("-")).toInt() 137 | else -> it.findOne("\\d+")?.toInt() ?: 0 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/main/java/de/honoka/bossddmonitor/entity/JobPushRecord.kt: -------------------------------------------------------------------------------- 1 | package de.honoka.bossddmonitor.entity 2 | 3 | import com.baomidou.mybatisplus.annotation.IdType 4 | import com.baomidou.mybatisplus.annotation.TableId 5 | 6 | data class JobPushRecord( 7 | 8 | @TableId(type = IdType.AUTO) 9 | var id: Long? = null, 10 | 11 | var jobInfoId: Long? = null, 12 | 13 | /** 14 | * 订阅此岗位的用户ID(默认情况下为QQ号) 15 | */ 16 | var subscribeUserId: Long? = null, 17 | 18 | /** 19 | * 推送记录创建时的用户住址(经纬度) 20 | */ 21 | var userGpsLocation: String? = null, 22 | 23 | /** 24 | * 此岗位通勤时间(分钟) 25 | */ 26 | var commuteDuration: Int? = null, 27 | 28 | /** 29 | * 是否已向用户推送此岗位 30 | */ 31 | var pushed: Boolean? = null, 32 | 33 | /** 34 | * 该记录是否有效(是否符合用户设定的筛选条件) 35 | */ 36 | var valid: Boolean? = null 37 | ) 38 | -------------------------------------------------------------------------------- /src/main/java/de/honoka/bossddmonitor/entity/Subscription.kt: -------------------------------------------------------------------------------- 1 | package de.honoka.bossddmonitor.entity 2 | 3 | import com.baomidou.mybatisplus.annotation.IdType 4 | import com.baomidou.mybatisplus.annotation.TableId 5 | import org.intellij.lang.annotations.Language 6 | 7 | data class Subscription( 8 | 9 | @TableId(type = IdType.AUTO) 10 | var id: Long? = null, 11 | 12 | /** 13 | * 用户ID(默认情况下为QQ号) 14 | */ 15 | var userId: Long? = null, 16 | 17 | /** 18 | * 接收推送消息的群号(若为空则使用私聊进行推送) 19 | */ 20 | var receiverGroupId: Long? = null, 21 | 22 | /** 23 | * 搜索关键词 24 | */ 25 | var searchWord: String? = null, 26 | 27 | /** 28 | * 城市代码 29 | */ 30 | var cityCode: String? = null, 31 | 32 | /** 33 | * 岗位的最小公司规模 34 | */ 35 | var minCompanyScale: Int? = null, 36 | 37 | /** 38 | * 岗位的最大经验要求(年) 39 | */ 40 | var maxExperience: Int? = null, 41 | 42 | /** 43 | * 岗位的最低薪资待遇(千) 44 | */ 45 | var minSalary: Int? = null, 46 | 47 | /** 48 | * 岗位的最大通勤时间(分钟) 49 | */ 50 | var maxCommutingDuration: Int? = null, 51 | 52 | /** 53 | * 岗位信息屏蔽关键词(json) 54 | */ 55 | var blockWords: String? = null, 56 | 57 | /** 58 | * 岗位信息屏蔽正则表达式(json) 59 | */ 60 | var blockRegexes: String? = null, 61 | 62 | /** 63 | * 用户住址(经纬度) 64 | */ 65 | var userGpsLocation: String? = null, 66 | 67 | /** 68 | * 是否启用此订阅 69 | */ 70 | var enabled: Boolean? = null 71 | ) { 72 | 73 | companion object { 74 | 75 | @Language("RegExp") 76 | const val USER_GPS_LOCATION_PATTERN = "\\d+\\.\\d+,\\d+\\.\\d+" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/main/java/de/honoka/bossddmonitor/mapper/JobInfoMapper.kt: -------------------------------------------------------------------------------- 1 | package de.honoka.bossddmonitor.mapper 2 | 3 | import com.baomidou.mybatisplus.core.mapper.BaseMapper 4 | import de.honoka.bossddmonitor.entity.JobInfo 5 | import de.honoka.bossddmonitor.platform.PlatformEnum 6 | import de.honoka.sdk.spring.starter.mybatis.queryChainWrapper 7 | import org.apache.ibatis.annotations.Mapper 8 | import org.apache.ibatis.annotations.Param 9 | 10 | @Mapper 11 | interface JobInfoMapper : BaseMapper { 12 | 13 | fun findByPlatformJobId(platform: PlatformEnum, platformJobId: String): JobInfo? { 14 | queryChainWrapper().run { 15 | eq(JobInfo::platform, platform) 16 | eq(JobInfo::platformJobId, platformJobId) 17 | last("limit 1") 18 | return one() 19 | } 20 | } 21 | 22 | fun getNoRecordsJobInfoList(@Param("userId") userId: Long): List 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/de/honoka/bossddmonitor/mapper/JobPushRecordMapper.kt: -------------------------------------------------------------------------------- 1 | package de.honoka.bossddmonitor.mapper 2 | 3 | import com.baomidou.mybatisplus.core.mapper.BaseMapper 4 | import de.honoka.bossddmonitor.entity.JobPushRecord 5 | import de.honoka.sdk.spring.starter.mybatis.queryChainWrapper 6 | import de.honoka.sdk.spring.starter.mybatis.updateChainWrapper 7 | import org.apache.ibatis.annotations.Mapper 8 | 9 | @Mapper 10 | interface JobPushRecordMapper : BaseMapper { 11 | 12 | fun getFirstNotPushedRecord(userId: Long): JobPushRecord? { 13 | queryChainWrapper().run { 14 | eq(JobPushRecord::subscribeUserId, userId) 15 | eq(JobPushRecord::pushed, false) 16 | eq(JobPushRecord::valid, true) 17 | last("limit 1") 18 | return one() 19 | } 20 | } 21 | 22 | fun hasInvalidRecords(jobInfoId: Long): Boolean { 23 | queryChainWrapper().run { 24 | eq(JobPushRecord::jobInfoId, jobInfoId) 25 | eq(JobPushRecord::valid, false) 26 | last("limit 1") 27 | return exists() 28 | } 29 | } 30 | 31 | fun removeInvalidRecords(jobInfoId: Long) { 32 | updateChainWrapper().run { 33 | eq(JobPushRecord::jobInfoId, jobInfoId) 34 | eq(JobPushRecord::valid, false) 35 | remove() 36 | } 37 | } 38 | 39 | fun userHasRecord(userId: Long, jobInfoId: Long): Boolean { 40 | queryChainWrapper().run { 41 | eq(JobPushRecord::subscribeUserId, userId) 42 | eq(JobPushRecord::jobInfoId, jobInfoId) 43 | last("limit 1") 44 | return exists() 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/de/honoka/bossddmonitor/mapper/SubscriptionMapper.kt: -------------------------------------------------------------------------------- 1 | package de.honoka.bossddmonitor.mapper 2 | 3 | import com.baomidou.mybatisplus.core.mapper.BaseMapper 4 | import de.honoka.bossddmonitor.entity.Subscription 5 | import de.honoka.sdk.spring.starter.mybatis.queryChainWrapper 6 | import org.apache.ibatis.annotations.Mapper 7 | 8 | @Mapper 9 | interface SubscriptionMapper : BaseMapper { 10 | 11 | fun getByUserId(userId: Long): Subscription? { 12 | queryChainWrapper().run { 13 | eq(Subscription::userId, userId) 14 | last("limit 1") 15 | return one() 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/de/honoka/bossddmonitor/platform/BossddPlatform.kt: -------------------------------------------------------------------------------- 1 | package de.honoka.bossddmonitor.platform 2 | 3 | import cn.hutool.json.JSONArray 4 | import cn.hutool.json.JSONObject 5 | import de.honoka.bossddmonitor.common.ExtendedExceptionReporter 6 | import de.honoka.bossddmonitor.common.ServiceLauncher 7 | import de.honoka.bossddmonitor.entity.JobInfo 8 | import de.honoka.bossddmonitor.entity.Subscription 9 | import de.honoka.bossddmonitor.service.BrowserService 10 | import de.honoka.bossddmonitor.service.JobInfoService 11 | import de.honoka.bossddmonitor.service.JobPushRecordService 12 | import de.honoka.sdk.util.kotlin.text.* 13 | import org.jsoup.Jsoup 14 | import org.springframework.stereotype.Component 15 | import java.util.* 16 | import java.util.concurrent.RejectedExecutionException 17 | import java.util.concurrent.TimeUnit 18 | 19 | @Component 20 | class BossddPlatform( 21 | private val browserService: BrowserService, 22 | private val jobInfoService: JobInfoService, 23 | private val jobPushRecordService: JobPushRecordService, 24 | private val exceptionReporter: ExtendedExceptionReporter 25 | ) : Platform { 26 | 27 | companion object { 28 | 29 | private val minScaleToParamMap = mapOf( 30 | 0 to "301", 31 | 20 to "302", 32 | 100 to "303", 33 | 500 to "304", 34 | 1000 to "305", 35 | 10000 to "306" 36 | ) 37 | 38 | private val experienceToParamMap = mapOf( 39 | 0 to "101,103", 40 | 1 to "104", 41 | 3 to "105", 42 | 5 to "106", 43 | 10 to "107" 44 | ) 45 | 46 | private val salaryToParamMap = mapOf( 47 | 0 to "402", 48 | 3 to "403", 49 | 5 to "404", 50 | 10 to "405", 51 | 20 to "406", 52 | 50 to "407" 53 | ) 54 | 55 | val cityCodeMap = HashMap().also { 56 | @Suppress("JAVA_CLASS_ON_COMPANION") 57 | val data = javaClass.getResource("/static-data/bossdd/city-code.json") 58 | data!!.readText().toJsonArray().forEach { jo -> 59 | jo as JSONObject 60 | it[jo["code"]!!.toString()] = jo.getStr("name") 61 | jo.getJSONArray("subLevelModelList")?.forEach { jo2 -> 62 | jo2 as JSONObject 63 | it[jo2["code"]!!.toString()] = jo2.getStr("name") 64 | } 65 | } 66 | HashMap(it).forEach { (k, v) -> 67 | it[v] = k 68 | } 69 | } 70 | } 71 | 72 | override fun doDataExtracting(subscription: Subscription) { 73 | val url = """ 74 | https://www.zhipin.com/web/geek/job?query=${subscription.searchWord}& 75 | city=${subscription.cityCode}&scale=${getScaleParamValue(subscription)}& 76 | experience=${getExperienceParamValue(subscription)}&jobType=1901& 77 | salary=${getSalaryParamValue(subscription)} 78 | """.singleLine() 79 | val apiUrl = "https://www.zhipin.com/wapi/zpgeek/search/joblist.json" 80 | val resultPredicate: (String) -> Boolean = { 81 | it.toJsonWrapper().getInt("code") == 0 82 | } 83 | val jobList = JSONArray() 84 | repeat(3) { i -> 85 | val res = if(i < 1) { 86 | browserService.waitForResponse(url, apiUrl, resultPredicate) 87 | } else { 88 | val jsExpression = "document.querySelector('.job-list-container').scrollBy(0, 5000)" 89 | browserService.waitForResponseByJs(jsExpression, apiUrl, resultPredicate) 90 | } 91 | jobList.addAll(res.toJsonWrapper().getArray("zpData.jobList")) 92 | TimeUnit.SECONDS.sleep(3) 93 | } 94 | jobList.forEachWrapper { 95 | if(ServiceLauncher.appShutdown) return 96 | try { 97 | val platformJobId = it.getStr("encryptJobId") 98 | val existingJobInfo = jobInfoService.baseMapper.findByPlatformJobId( 99 | PlatformEnum.BOSSDD, platformJobId 100 | ) 101 | if(jobInfoService.shouldUpdateIncrement(existingJobInfo)) { 102 | val incrementJobInfo = parseIncrementJobInfo(it) 103 | incrementJobInfo.id = existingJobInfo!!.id 104 | jobInfoService.updateById(incrementJobInfo) 105 | return@forEachWrapper 106 | } 107 | val jobInfo = parseJobInfo(it).apply { 108 | fromSearchWord = subscription.searchWord 109 | } 110 | existingJobInfo ?: run { 111 | runCatching { 112 | jobInfoService.isEligible(jobInfo, subscription) 113 | }.getOrDefault(true).let { b -> 114 | if(!b) return@forEachWrapper 115 | } 116 | } 117 | jobInfo.parseJobInfoDetails(it) 118 | if(existingJobInfo == null) { 119 | jobInfoService.save(jobInfo) 120 | } else { 121 | jobPushRecordService.baseMapper.removeInvalidRecords(existingJobInfo.id!!) 122 | jobInfo.id = existingJobInfo.id 123 | jobInfoService.updateById(jobInfo) 124 | } 125 | jobPushRecordService.checkAndCreate(jobInfo) 126 | } catch(t: Throwable) { 127 | if(t is RejectedExecutionException) return@forEachWrapper 128 | exceptionReporter.report(t) 129 | } 130 | } 131 | } 132 | 133 | private fun parseJobInfo(jsonWrapper: JsonWrapper): JobInfo = JobInfo().apply { 134 | val identifiersMap = mapOf( 135 | "lid" to jsonWrapper.getStr("lid"), 136 | "securityId" to jsonWrapper.getStr("securityId") 137 | ) 138 | jsonWrapper.let { 139 | platform = PlatformEnum.BOSSDD 140 | platformJobId = it.getStr("encryptJobId") 141 | identifiers = identifiersMap.toJsonString() 142 | cityCode = it.getLong("city").toString() 143 | title = it.getStr("jobName") 144 | company = it.getStr("brandName") 145 | companyScale = it.getStr("brandScaleName") 146 | hrName = it.getStr("bossName") 147 | hrOnline = it.getBool("bossOnline") 148 | salary = it.getStr("salaryDesc") 149 | experience = it.getStr("jobExperience") 150 | eduDegree = it.getStr("jobDegree") 151 | tags = it.getArray("skills").toString() 152 | gpsLocation = "${it.getStr("gps.longitude")},${it.getStr("gps.latitude")}" 153 | createTime = Date() 154 | updateTime = createTime 155 | } 156 | } 157 | 158 | private fun JobInfo.parseJobInfoDetails(jsonWrapper: JsonWrapper) { 159 | val identifiersMap = identifiers!!.toJsonObject() 160 | val urlPrefix = "https://www.zhipin.com/job_detail/$platformJobId.html" 161 | val url = "$urlPrefix?lid=${identifiersMap["lid"]}&securityId=${identifiersMap["securityId"]}" 162 | val html = browserService.waitForResponse(url, urlPrefix) 163 | val doc = Jsoup.parse(html) 164 | jsonWrapper.let { 165 | companyFullName = doc.selectFirst("li.company-name")?.run { 166 | getElementsByTag("span").forEach { it.remove() } 167 | text().trim() 168 | } 169 | hrLiveness = run { 170 | if(doc.selectFirst("span.boss-online-tag") != null) { 171 | "在线" 172 | } else { 173 | doc.selectFirst("span.boss-active-time")?.run { 174 | text().trim() 175 | } 176 | } 177 | } 178 | details = doc.selectFirst("div.job-sec-text")?.html()?.process { 179 | replace(Regex("\\s*\\s*"), "\n") 180 | replace(Regex("\n\n\n+"), "\n\n") 181 | trim() 182 | } 183 | address = doc.selectFirst("div.location-address")?.run { 184 | text().trim() 185 | } 186 | } 187 | } 188 | 189 | private fun parseIncrementJobInfo(jsonWrapper: JsonWrapper): JobInfo = JobInfo().apply { 190 | jsonWrapper.let { 191 | hrOnline = it.getBool("bossOnline") 192 | updateTime = Date() 193 | } 194 | } 195 | 196 | private fun getScaleParamValue(subscription: Subscription): String { 197 | val minScale = subscription.minCompanyScale!! 198 | val params = minScaleToParamMap.filter { (k) -> k >= minScale }.values 199 | return params.joinToString(",") 200 | } 201 | 202 | private fun getExperienceParamValue(subscription: Subscription): String { 203 | val maxYears = subscription.maxExperience!! 204 | val params = experienceToParamMap.filter { (k) -> k <= maxYears }.values 205 | return params.joinToString(",") 206 | } 207 | 208 | private fun getSalaryParamValue(subscription: Subscription): String { 209 | val minSalary = subscription.minSalary!! 210 | val param = salaryToParamMap.entries.run { 211 | if(minSalary <= 10) { 212 | firstOrNull { it.key >= minSalary } 213 | } else { 214 | lastOrNull { minSalary >= it.key } 215 | } 216 | } 217 | return param?.value ?: salaryToParamMap.entries.last().value 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/main/java/de/honoka/bossddmonitor/platform/Platform.kt: -------------------------------------------------------------------------------- 1 | package de.honoka.bossddmonitor.platform 2 | 3 | import de.honoka.bossddmonitor.entity.Subscription 4 | 5 | interface Platform { 6 | 7 | fun doDataExtracting(subscription: Subscription) 8 | } 9 | 10 | enum class PlatformEnum { 11 | 12 | BOSSDD 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/de/honoka/bossddmonitor/service/BrowserService.kt: -------------------------------------------------------------------------------- 1 | package de.honoka.bossddmonitor.service 2 | 3 | import cn.hutool.core.exceptions.ExceptionUtil 4 | import cn.hutool.core.util.ArrayUtil 5 | import de.honoka.bossddmonitor.common.ExtendedExceptionReporter 6 | import de.honoka.bossddmonitor.common.ProxyManager 7 | import de.honoka.bossddmonitor.common.ServiceLauncher 8 | import de.honoka.bossddmonitor.config.BrowserProperties 9 | import de.honoka.sdk.util.concurrent.ThreadPoolUtils 10 | import de.honoka.sdk.util.kotlin.basic.exception 11 | import de.honoka.sdk.util.kotlin.basic.forEachCatching 12 | import de.honoka.sdk.util.kotlin.basic.log 13 | import de.honoka.sdk.util.kotlin.basic.tryBlock 14 | import de.honoka.sdk.util.kotlin.concurrent.getOrCancel 15 | import de.honoka.sdk.util.kotlin.concurrent.shutdownNowAndWait 16 | import de.honoka.sdk.util.kotlin.net.socket.SocketUtils 17 | import de.honoka.sdk.util.kotlin.text.singleLine 18 | import de.honoka.sdk.util.kotlin.various.RuntimeUtilsExt 19 | import org.intellij.lang.annotations.Language 20 | import org.openqa.selenium.Dimension 21 | import org.openqa.selenium.Point 22 | import org.openqa.selenium.chrome.ChromeDriver 23 | import org.openqa.selenium.chrome.ChromeOptions 24 | import org.openqa.selenium.devtools.Connection 25 | import org.openqa.selenium.devtools.DevTools 26 | import org.openqa.selenium.devtools.v85.network.Network 27 | import org.openqa.selenium.devtools.v85.network.model.ResponseReceived 28 | import org.openqa.selenium.manager.SeleniumManager 29 | import org.springframework.stereotype.Service 30 | import java.awt.Toolkit 31 | import java.io.File 32 | import java.util.* 33 | import java.util.concurrent.* 34 | import java.util.logging.Level 35 | import java.util.logging.Logger 36 | 37 | @Service 38 | class BrowserService( 39 | private val browserProperties: BrowserProperties, 40 | private val proxyManager: ProxyManager, 41 | private val exceptionReporter: ExtendedExceptionReporter 42 | ) { 43 | 44 | class OnErrorPageException : RuntimeException() 45 | 46 | companion object { 47 | 48 | private val userAgent = """ 49 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 | 50 | (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 51 | """.singleLine() 52 | } 53 | 54 | private var browserProcess: Process? = null 55 | 56 | private var browserOrNull: ChromeDriver? = null 57 | 58 | val browser: ChromeDriver 59 | get() = browserOrNull!! 60 | 61 | private val waiterExecutor = Executors.newFixedThreadPool(1) 62 | 63 | private val responseHandlerExecutor = ThreadPoolUtils.newEagerThreadPool( 64 | 1, 3, 60, TimeUnit.SECONDS 65 | ) 66 | 67 | private val urlPrefixToResponseMap = ConcurrentHashMap>() 68 | 69 | @Volatile 70 | private var hasBeenShutdown = false 71 | 72 | fun init() { 73 | hasBeenShutdown = false 74 | disableSeleniumLog() 75 | } 76 | 77 | fun stop() { 78 | hasBeenShutdown = true 79 | responseHandlerExecutor.shutdownNowAndWait() 80 | waiterExecutor.shutdownNowAndWait() 81 | closeBrowser() 82 | } 83 | 84 | private fun initBrowser(headless: Boolean = browserProperties.defaultHeadless) { 85 | tryBlock(3) { 86 | if(hasBeenShutdown) exception("${javaClass.simpleName} has been shutdown.") 87 | closeBrowser() 88 | if(browserProperties.userDataDir.clearBeforeInit) { 89 | clearUserDataDir() 90 | } 91 | tryBlock(2) { 92 | closeBrowser() 93 | doInitBrowser(headless) 94 | } 95 | } 96 | } 97 | 98 | private fun doInitBrowser(headless: Boolean) { 99 | val chromeArgs = ArrayList().apply { 100 | val userDataDir = browserProperties.userDataDir.absolutePath 101 | this@BrowserService.log.info("Used user data directory of Selenium Chrome driver: $userDataDir") 102 | add("--user-data-dir=$userDataDir") 103 | if(headless) add("--headless") 104 | if(proxyManager.available) { 105 | add("--proxy-server=localhost:${proxyManager.proxy.port}") 106 | add("--ignore-certificate-errors") 107 | } 108 | add("--user-agent=$userAgent") 109 | add("--blink-settings=imagesEnabled=false") 110 | } 111 | val options = ChromeOptions().apply { 112 | if(browserProperties.startProcessByApp) { 113 | val port = startBrowserProcess(chromeArgs) 114 | setExperimentalOption("debuggerAddress", "localhost:$port") 115 | } else { 116 | addArguments(chromeArgs) 117 | } 118 | } 119 | browserOrNull = ChromeDriver(options) 120 | browser.run { 121 | setLogLevel(Level.OFF) 122 | if(!headless) moveBrowserToCenter() 123 | } 124 | browser.devTools.run { 125 | createSession() 126 | send(Network.enable(Optional.empty(), Optional.empty(), Optional.empty())) 127 | val blockUrls = browserProperties.blockUrlKeywords.map { "*$it*" } 128 | send(Network.setBlockedURLs(blockUrls)) 129 | addListener(Network.responseReceived()) { e -> 130 | responseHandlerExecutor.submit { 131 | if(ServiceLauncher.appShutdown) return@submit 132 | runCatching { 133 | handleResponse(this, e) 134 | } 135 | } 136 | } 137 | } 138 | log.info("Selenium Chrome driver has been initialized.") 139 | } 140 | 141 | private fun closeBrowser() { 142 | if(ArrayUtil.isAllNull(browserProcess, browserOrNull)) return 143 | browserOrNull?.runCatching { 144 | tryBlock(3) { 145 | devTools.close() 146 | quit() 147 | RuntimeUtilsExt.exec { 148 | win("taskkill", "/f", "/im", "chromedriver.exe") 149 | } 150 | } 151 | }?.getOrElse { 152 | exceptionReporter.report(it) 153 | } 154 | browserOrNull = null 155 | browserProcess?.runCatching { 156 | tryBlock(3) { 157 | if(!isAlive) return@tryBlock 158 | destroy() 159 | waitFor(5, TimeUnit.SECONDS) 160 | if(isAlive && browserProperties.stopProcessByCommand) { 161 | RuntimeUtilsExt.exec { 162 | win("taskkill", "/f", "/im", "chrome.exe") 163 | } 164 | waitFor(5, TimeUnit.SECONDS) 165 | } 166 | if(isAlive) exception("Browser process is still alive.") 167 | } 168 | }?.getOrElse { 169 | exceptionReporter.report(it) 170 | } 171 | browserProcess = null 172 | log.info("Selenium Chrome driver has been closed.") 173 | } 174 | 175 | private fun clearUserDataDir() { 176 | val dir = File(browserProperties.userDataDir.absolutePath) 177 | if(dir.exists()) dir.deleteRecursively() 178 | } 179 | 180 | private fun startBrowserProcess(args: List): Int { 181 | val executablePath = run { 182 | val path = browserProperties.executablePath ?: run { 183 | SeleniumManager.getInstance().getBinaryPaths(listOf("--browser", "chrome")).browserPath 184 | } ?: return@run null 185 | File(path).run { 186 | if(exists() && !isDirectory) path else null 187 | } 188 | } ?: exception("No executable path is provided and connot find chrome executable automatically.") 189 | val debuggingPort = SocketUtils.findAvailablePort(10010, 10) 190 | browserProcess = ProcessBuilder( 191 | executablePath, 192 | *args.toTypedArray(), 193 | "--remote-debugging-port=$debuggingPort" 194 | ).start() 195 | return debuggingPort 196 | } 197 | 198 | private fun loadBlankPage() { 199 | loadPage("about:blank", 500) 200 | } 201 | 202 | private fun loadPage(url: String, waitMillisAfterLoad: Long = 0) { 203 | ensureIsActive() 204 | tryBlock(3) { 205 | runCatching { 206 | browser.get(url) 207 | }.getOrElse { 208 | val cause = ExceptionUtil.getRootCause(it) 209 | val shouldReinit = cause.message?.contains("ERR_TUNNEL_CONNECTION_FAILED") != true 210 | if(shouldReinit) initBrowser() 211 | throw it 212 | } 213 | } 214 | Thread.sleep(waitMillisAfterLoad) 215 | } 216 | 217 | @Synchronized 218 | fun waitForResponse( 219 | urlToLoad: String, 220 | urlPrefixToWait: String, 221 | resultPredicate: ((String) -> Boolean)? = null 222 | ) = doWaitForResponse(urlToLoad, null, urlPrefixToWait, resultPredicate) 223 | 224 | @Synchronized 225 | fun waitForResponseByJs( 226 | @Language("JavaScript") jsExpression: String, 227 | urlPrefixToWait: String, 228 | resultPredicate: ((String) -> Boolean)? = null 229 | ) = doWaitForResponse(null, jsExpression, urlPrefixToWait, resultPredicate) 230 | 231 | private fun doWaitForResponse( 232 | urlToLoad: String?, 233 | jsExpression: String?, 234 | urlPrefixToWait: String, 235 | resultPredicate: ((String) -> Boolean)? = null 236 | ): String = tryBlock(3) { 237 | val resultList = Collections.synchronizedList(LinkedList()) 238 | val action = Callable { 239 | var result: String? = null 240 | when { 241 | urlToLoad != null -> loadPage(urlToLoad) 242 | jsExpression != null -> browser.executeScript(jsExpression) 243 | } 244 | outer@ 245 | for(i in 1..120) { 246 | Thread.sleep(500) 247 | if(isOnErrorPage()) throw OnErrorPageException() 248 | if(resultList.isEmpty()) continue 249 | for(r in resultList) { 250 | val shouldTake = resultPredicate == null || runCatching { 251 | resultPredicate(r) 252 | }.getOrDefault(false) 253 | if(shouldTake) { 254 | result = r 255 | break@outer 256 | } 257 | } 258 | } 259 | result ?: throw TimeoutException("Cannot get the response of $urlPrefixToWait") 260 | } 261 | try { 262 | urlPrefixToResponseMap[urlPrefixToWait] = resultList 263 | return waiterExecutor.submit(action).getOrCancel(60, TimeUnit.SECONDS) 264 | } catch(t: Throwable) { 265 | runCatching { 266 | when(t) { 267 | is TimeoutException -> refreshEnvironment() 268 | else -> when(ExceptionUtil.getRootCause(t)) { 269 | is OnErrorPageException -> refreshEnvironment() 270 | } 271 | } 272 | } 273 | throw t 274 | } finally { 275 | urlPrefixToResponseMap.remove(urlPrefixToWait) 276 | } 277 | } 278 | 279 | @Suppress("UNCHECKED_CAST") 280 | fun executeJsExpression(@Language("JavaScript") jsExpression: String): T? = run { 281 | browser.executeScript("return $jsExpression") as T? 282 | } 283 | 284 | private fun ensureIsActive() { 285 | runCatching { 286 | browser.run { 287 | //尝试获取以下属性的值,若无法获取将抛出异常,可视为浏览器已关闭 288 | windowHandle 289 | currentUrl 290 | title 291 | } 292 | }.getOrElse { 293 | if(!hasBeenShutdown) initBrowser() 294 | } 295 | } 296 | 297 | private fun disableSeleniumLog() { 298 | val classes = listOf(DevTools::class, Connection::class) 299 | classes.forEach { 300 | val logger = it.java.getDeclaredField("LOG").run { 301 | isAccessible = true 302 | get(null) as Logger 303 | } 304 | logger.level = Level.OFF 305 | } 306 | Thread.setDefaultUncaughtExceptionHandler { t, e -> 307 | if(t.name == "main") e.printStackTrace() 308 | } 309 | } 310 | 311 | private fun moveBrowserToCenter() { 312 | browser.manage().window().run { 313 | size = Dimension(1280, 850) 314 | val screenSize = Toolkit.getDefaultToolkit().screenSize 315 | val left = (screenSize.width - size.width) / 2 316 | val top = (screenSize.height - size.height) / 2 317 | position = Point(left, top) 318 | } 319 | } 320 | 321 | private fun handleResponse(devTools: DevTools, event: ResponseReceived) { 322 | val url = event.response.url 323 | urlPrefixToResponseMap.forEach { (k, v) -> 324 | if(!url.startsWith(k)) return@forEach 325 | val response = run { 326 | repeat(50) { 327 | if(ServiceLauncher.appShutdown) return 328 | try { 329 | return@run devTools.send(Network.getResponseBody(event.requestId)).body 330 | } catch(t: Throwable) { 331 | Thread.sleep(100) 332 | } 333 | } 334 | } 335 | response?.let { v.add(it as String) } 336 | } 337 | } 338 | 339 | private fun isOnErrorPage(): Boolean { 340 | browserProperties.errorPageDetection.run { 341 | urlKeywords.forEach { 342 | if(browser.currentUrl?.contains(it) == true) { 343 | return true 344 | } 345 | } 346 | selectors.forEachCatching { 347 | val selectorObjs = executeJsExpression( 348 | "document.querySelectorAll('$it')" 349 | ) ?: return@forEachCatching 350 | selectorObjs as List<*> 351 | if(selectorObjs.isNotEmpty()) { 352 | return true 353 | } 354 | } 355 | } 356 | return false 357 | } 358 | 359 | private fun refreshEnvironment() { 360 | browser.devTools.send(Network.clearBrowserCookies()) 361 | proxyManager.newProxy() 362 | loadBlankPage() 363 | } 364 | } 365 | -------------------------------------------------------------------------------- /src/main/java/de/honoka/bossddmonitor/service/JobInfoService.kt: -------------------------------------------------------------------------------- 1 | package de.honoka.bossddmonitor.service 2 | 3 | import cn.hutool.core.date.DateTime 4 | import cn.hutool.core.date.DateUnit 5 | import cn.hutool.core.util.ObjectUtil 6 | import cn.hutool.http.HttpUtil 7 | import cn.hutool.json.JSONObject 8 | import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl 9 | import de.honoka.bossddmonitor.common.ExtendedExceptionReporter 10 | import de.honoka.bossddmonitor.common.ProxyManager 11 | import de.honoka.bossddmonitor.entity.JobInfo 12 | import de.honoka.bossddmonitor.entity.Subscription 13 | import de.honoka.bossddmonitor.mapper.JobInfoMapper 14 | import de.honoka.bossddmonitor.mapper.JobPushRecordMapper 15 | import de.honoka.bossddmonitor.platform.PlatformEnum 16 | import de.honoka.sdk.util.kotlin.basic.cast 17 | import de.honoka.sdk.util.kotlin.basic.exception 18 | import de.honoka.sdk.util.kotlin.basic.tryBlockNullable 19 | import de.honoka.sdk.util.kotlin.net.http.browserApiHeaders 20 | import de.honoka.sdk.util.kotlin.text.singleLine 21 | import de.honoka.sdk.util.kotlin.text.toJsonArray 22 | import de.honoka.sdk.util.kotlin.text.toJsonWrapper 23 | import org.springframework.stereotype.Service 24 | 25 | @Service 26 | class JobInfoService( 27 | private val jobPushRecordMapper: JobPushRecordMapper, 28 | private val proxyManager: ProxyManager, 29 | private val exceptionReporter: ExtendedExceptionReporter 30 | ) : ServiceImpl() { 31 | 32 | fun isEligible(jobInfo: JobInfo, subscription: Subscription): Boolean { 33 | if(jobInfo.cityCode != subscription.cityCode) return false 34 | if(!isHrLivenessValid(jobInfo)) return false 35 | val minCompanyScale = jobInfo.minCompanyScale 36 | if(!ObjectUtil.hasNull(minCompanyScale, subscription.minCompanyScale)) { 37 | if(minCompanyScale!! < subscription.minCompanyScale!!) { 38 | return false 39 | } 40 | } 41 | val averageSalary = jobInfo.averageSalary 42 | if(!ObjectUtil.hasNull(averageSalary, subscription.minSalary)) { 43 | if(averageSalary!! < subscription.minSalary!!) { 44 | return false 45 | } 46 | } 47 | val minExperience = jobInfo.minExperience 48 | if(!ObjectUtil.hasNull(minExperience, subscription.maxExperience)) { 49 | if(minExperience!! > subscription.maxExperience!!) { 50 | return false 51 | } 52 | } 53 | return !hasBlockWords(jobInfo, subscription) && isRelatedSearchWord(jobInfo, subscription) 54 | } 55 | 56 | private fun hasBlockWords(jobInfo: JobInfo, subscription: Subscription): Boolean = subscription.run { 57 | val propertiesToCheck = jobInfo.run { 58 | listOf(title, company, companyFullName, tags, details, address) 59 | } 60 | blockWords?.toJsonArray()?.forEach { 61 | propertiesToCheck.firstOrNull { s -> 62 | s?.lowercase()?.contains(it.cast().lowercase()) == true 63 | }?.let { 64 | return true 65 | } 66 | } 67 | blockRegexes?.toJsonArray()?.forEach { 68 | propertiesToCheck.firstOrNull { s -> 69 | s?.contains(Regex(it as String, RegexOption.IGNORE_CASE)) == true 70 | }?.let { 71 | return true 72 | } 73 | } 74 | return false 75 | } 76 | 77 | private fun isRelatedSearchWord(jobInfo: JobInfo, subscription: Subscription): Boolean { 78 | if(ObjectUtil.hasNull(jobInfo.fromSearchWord, subscription.searchWord)) return false 79 | val fromSearchWord = jobInfo.fromSearchWord!!.lowercase() 80 | val lowerSearchWord = subscription.searchWord!!.lowercase() 81 | return fromSearchWord.contains(lowerSearchWord) || lowerSearchWord.contains(fromSearchWord) 82 | } 83 | 84 | private fun isHrLivenessValid(jobInfo: JobInfo): Boolean { 85 | val validLivenessList = when(jobInfo.platform) { 86 | PlatformEnum.BOSSDD -> listOf("在线", "刚刚活跃", "今日活跃", "昨日活跃") 87 | else -> exception("Not support the platform: ${jobInfo.platform}") 88 | } 89 | jobInfo.hrLiveness?.let { 90 | return it in validLivenessList 91 | } 92 | return true 93 | } 94 | 95 | fun getCommutingDuration(jobInfo: JobInfo, subscription: Subscription): Int? = run { 96 | runCatching { 97 | tryBlockNullable(3) { 98 | doGetCommutingDuration(jobInfo, subscription) 99 | } 100 | }.getOrElse { 101 | exceptionReporter.report(it) 102 | throw it 103 | } 104 | } 105 | 106 | private fun doGetCommutingDuration(jobInfo: JobInfo, subscription: Subscription): Int? { 107 | subscription.maxCommutingDuration ?: return null 108 | when(jobInfo.platform) { 109 | PlatformEnum.BOSSDD -> { 110 | val url = """ 111 | https://amap-proxy.zpurl.cn/_AMapService/v3/direction/transit/integrated? 112 | platform=JS&s=rsv3&logversion=2.0&key=6104503ca2f1d66e900a7e7064c5d880& 113 | sdkversion=2.0.6.1&city=%E5%8C%97%E4%BA%AC%E5%B8%82&strategy=&nightflag=0& 114 | appname=https%253A%252F%252Fwww.zhipin.com%252Fweb%252Fgeek%252Fmap%252Fpath& 115 | origin=${subscription.userGpsLocation}&destination=${jobInfo.gpsLocation}& 116 | extensions=&s=rsv3&cityd=NaN 117 | """.singleLine() 118 | val res = HttpUtil.createGet(url).run { 119 | browserApiHeaders() 120 | if(proxyManager.available) { 121 | setHttpProxy("localhost", proxyManager.proxy.port) 122 | } 123 | execute().body().toJsonWrapper() 124 | } 125 | if(res.getStr("status") != "1") { 126 | exception("Response info: ${res.getStrOrNull("info")}") 127 | } 128 | val minDuration = res.getArray("route.transits").minOfOrNull { 129 | it.cast().getStr("duration").toInt() 130 | } 131 | return minDuration?.let { it / 60 } 132 | } 133 | else -> exception("Not support the platform: ${jobInfo.platform}") 134 | } 135 | } 136 | 137 | fun shouldUpdateIncrement(jobInfo: JobInfo?): Boolean = run { 138 | jobInfo ?: return false 139 | isHrLivenessValid(jobInfo) || DateTime.now().between(jobInfo.createTime, DateUnit.HOUR) < 24 || 140 | !jobPushRecordMapper.hasInvalidRecords(jobInfo.id!!) 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/main/java/de/honoka/bossddmonitor/service/JobPushRecordService.kt: -------------------------------------------------------------------------------- 1 | package de.honoka.bossddmonitor.service 2 | 3 | import cn.hutool.core.util.ObjectUtil 4 | import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl 5 | import de.honoka.bossddmonitor.common.ServiceLauncher 6 | import de.honoka.bossddmonitor.entity.JobInfo 7 | import de.honoka.bossddmonitor.entity.JobPushRecord 8 | import de.honoka.bossddmonitor.entity.Subscription 9 | import de.honoka.bossddmonitor.mapper.JobPushRecordMapper 10 | import de.honoka.sdk.util.kotlin.basic.forEachCatching 11 | import org.springframework.stereotype.Service 12 | 13 | @Service 14 | class JobPushRecordService( 15 | private val subscriptionService: SubscriptionService, 16 | private val jobInfoService: JobInfoService 17 | ) : ServiceImpl() { 18 | 19 | fun scanAndCreateMissingRecords(subscription: Subscription) { 20 | if(ServiceLauncher.appShutdown || !subscription.enabled!!) return 21 | jobInfoService.baseMapper.getNoRecordsJobInfoList(subscription.userId!!).forEachCatching { 22 | if(ServiceLauncher.appShutdown) return 23 | checkAndCreate(it, subscription) 24 | } 25 | } 26 | 27 | fun checkAndCreate(jobInfo: JobInfo) { 28 | subscriptionService.list().forEachCatching { 29 | if(!it.enabled!!) return@forEachCatching 30 | checkAndCreate(jobInfo, it) 31 | } 32 | } 33 | 34 | private fun checkAndCreate(jobInfo: JobInfo, subscription: Subscription) { 35 | if(baseMapper.userHasRecord(subscription.userId!!, jobInfo.id!!)) return 36 | val record = JobPushRecord().apply { 37 | jobInfoId = jobInfo.id 38 | subscribeUserId = subscription.userId 39 | userGpsLocation = subscription.userGpsLocation 40 | pushed = false 41 | valid = true 42 | } 43 | jobInfoService.run { 44 | if(!isEligible(jobInfo, subscription)) { 45 | record.valid = false 46 | save(record) 47 | return 48 | } 49 | record.commuteDuration = getCommutingDuration(jobInfo, subscription) 50 | } 51 | if(!ObjectUtil.hasNull(record.commuteDuration, subscription.maxCommutingDuration)) { 52 | if(record.commuteDuration!! > subscription.maxCommutingDuration!!) { 53 | record.valid = false 54 | save(record) 55 | return 56 | } 57 | } 58 | save(record) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/main/java/de/honoka/bossddmonitor/service/MonitorService.kt: -------------------------------------------------------------------------------- 1 | package de.honoka.bossddmonitor.service 2 | 3 | import cn.hutool.core.date.DateTime 4 | import de.honoka.bossddmonitor.common.ExtendedExceptionReporter 5 | import de.honoka.bossddmonitor.common.ServiceLauncher 6 | import de.honoka.bossddmonitor.config.MonitorProperties 7 | import de.honoka.bossddmonitor.entity.Subscription 8 | import de.honoka.bossddmonitor.platform.Platform 9 | import de.honoka.sdk.util.kotlin.basic.weekdayNum 10 | import de.honoka.sdk.util.kotlin.concurrent.ScheduledTask 11 | import org.springframework.stereotype.Service 12 | import java.util.concurrent.RejectedExecutionException 13 | 14 | @Service 15 | class MonitorService( 16 | private val monitorProperties: MonitorProperties, 17 | private val subscriptionService: SubscriptionService, 18 | private val jobPushRecordService: JobPushRecordService, 19 | private val exceptionReporter: ExtendedExceptionReporter, 20 | private val platforms: List 21 | ) { 22 | 23 | val scheduledTask = ScheduledTask( 24 | monitorProperties.delay, 25 | monitorProperties.initialDelay, 26 | action = ::doTask 27 | ).apply { 28 | exceptionCallback = { 29 | exceptionReporter.report(it) 30 | } 31 | } 32 | 33 | private fun doTask() { 34 | if(!isCurrentTimeInRange()) return 35 | subscriptionService.list().forEach { 36 | if(!it.enabled!!) return@forEach 37 | platforms.forEach { p -> 38 | if(ServiceLauncher.appShutdown) return 39 | runCatching { 40 | doDataExtracting(it, p) 41 | jobPushRecordService.scanAndCreateMissingRecords(it) 42 | }.getOrElse { t -> 43 | exceptionReporter.report(t) 44 | } 45 | } 46 | } 47 | } 48 | 49 | private fun isCurrentTimeInRange(): Boolean { 50 | val now = DateTime.now() 51 | now.hour(true).let { 52 | val range = monitorProperties.hourRangeParts 53 | if(it < range[0] || it >= range[1]) return false 54 | } 55 | now.weekdayNum.let { 56 | val range = monitorProperties.weekdayRangeParts 57 | if(it < range[0] || it > range[1]) return false 58 | } 59 | return true 60 | } 61 | 62 | private fun doDataExtracting(subscription: Subscription, platform: Platform) { 63 | runCatching { 64 | platform.doDataExtracting(subscription) 65 | }.getOrElse { 66 | if(it is RejectedExecutionException) return 67 | exceptionReporter.report(it) 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/main/java/de/honoka/bossddmonitor/service/PushService.kt: -------------------------------------------------------------------------------- 1 | package de.honoka.bossddmonitor.service 2 | 3 | import cn.hutool.core.date.DateUtil 4 | import de.honoka.bossddmonitor.common.ServiceLauncher 5 | import de.honoka.bossddmonitor.entity.JobInfo 6 | import de.honoka.bossddmonitor.entity.JobPushRecord 7 | import de.honoka.bossddmonitor.entity.Subscription 8 | import de.honoka.bossddmonitor.platform.PlatformEnum 9 | import de.honoka.qqrobot.framework.api.RobotFramework 10 | import de.honoka.qqrobot.framework.api.message.RobotMessage 11 | import de.honoka.qqrobot.framework.api.message.RobotMultipartMessage 12 | import de.honoka.sdk.util.kotlin.basic.log 13 | import de.honoka.sdk.util.kotlin.concurrent.ScheduledTask 14 | import de.honoka.sdk.util.kotlin.text.singleLine 15 | import de.honoka.sdk.util.kotlin.text.toJsonObject 16 | import de.honoka.sdk.util.kotlin.text.trimAllLines 17 | import de.honoka.sdk.util.various.ImageUtils 18 | import org.springframework.stereotype.Service 19 | import java.io.InputStream 20 | 21 | @Service 22 | class PushService( 23 | private val subscriptionService: SubscriptionService, 24 | private val jobInfoService: JobInfoService, 25 | private val jobPushRecordService: JobPushRecordService, 26 | private val robotFramework: RobotFramework 27 | ) { 28 | 29 | val scheduledTask = ScheduledTask("1m", "1m", action = ::doTask) 30 | 31 | private fun doTask() { 32 | subscriptionService.list().forEach { 33 | if(ServiceLauncher.appShutdown) return 34 | if(!it.enabled!!) return@forEach 35 | runCatching { 36 | pushJobInfo(it) 37 | }.getOrElse { e -> 38 | log.error("", e) 39 | } 40 | } 41 | } 42 | 43 | private fun pushJobInfo(subscription: Subscription) { 44 | val record = jobPushRecordService.baseMapper.getFirstNotPushedRecord(subscription.userId!!) 45 | record ?: return 46 | val jobInfo = jobInfoService.getById(record.jobInfoId) ?: return 47 | if(!jobInfoService.isEligible(jobInfo, subscription)) { 48 | jobPushRecordService.updateById(JobPushRecord().apply { 49 | id = record.id 50 | valid = false 51 | }) 52 | return 53 | } 54 | val message = RobotMultipartMessage().apply { 55 | add(RobotMessage.text("【${jobInfo.company}】${jobInfo.title}")) 56 | add(RobotMessage.image(getImageToPush(jobInfo, record))) 57 | add(RobotMessage.text(getUrlToPush(jobInfo))) 58 | } 59 | robotFramework.run { 60 | subscription.run { 61 | if(receiverGroupId != null) { 62 | sendGroupMsg(receiverGroupId!!, message) 63 | } else { 64 | sendPrivateMsg(userId!!, message) 65 | } 66 | } 67 | } 68 | jobPushRecordService.updateById(JobPushRecord().apply { 69 | id = record.id 70 | pushed = true 71 | }) 72 | } 73 | 74 | private fun getImageToPush(jobInfo: JobInfo, jobPushRecord: JobPushRecord): InputStream { 75 | val text = jobInfo.run { 76 | """ 77 | 【${company}】$title 78 | 薪资:$salary 79 | 公司全名:$companyFullName 80 | 规模:$companyScale 81 | HR:$hrName 82 | HR活跃度:$hrLiveness 83 | 经验要求:$experience 84 | 岗位地址:$address 85 | 通勤时间:${jobPushRecord.commuteDuration}分钟 86 | 信息更新时间:${DateUtil.formatDateTime(updateTime)} 87 | 88 | $details 89 | """ 90 | }.trimAllLines() 91 | return ImageUtils.textToImageByLength(text, 60) 92 | } 93 | 94 | private fun getUrlToPush(jobInfo: JobInfo): String = when(jobInfo.platform) { 95 | PlatformEnum.BOSSDD -> jobInfo.run { 96 | val identifiersMap = identifiers!!.toJsonObject() 97 | """ 98 | https://www.zhipin.com/job_detail/$platformJobId.html? 99 | lid=${identifiersMap["lid"]}& 100 | securityId=${identifiersMap["securityId"]} 101 | """.singleLine() 102 | } 103 | else -> "" 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/main/java/de/honoka/bossddmonitor/service/SubscriptionService.kt: -------------------------------------------------------------------------------- 1 | package de.honoka.bossddmonitor.service 2 | 3 | import cn.hutool.json.JSONArray 4 | import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl 5 | import de.honoka.bossddmonitor.entity.Subscription 6 | import de.honoka.bossddmonitor.mapper.SubscriptionMapper 7 | import de.honoka.bossddmonitor.platform.BossddPlatform 8 | import de.honoka.qqrobot.framework.api.message.RobotMessage 9 | import de.honoka.qqrobot.starter.command.CommandMethodArgs 10 | import de.honoka.qqrobot.starter.component.session.RobotSession 11 | import de.honoka.sdk.util.kotlin.text.singleLine 12 | import de.honoka.sdk.util.kotlin.text.toJsonArray 13 | import de.honoka.sdk.util.kotlin.text.trimAllLines 14 | import de.honoka.sdk.util.various.ImageUtils 15 | import org.springframework.stereotype.Service 16 | 17 | @Service 18 | class SubscriptionService : ServiceImpl() { 19 | 20 | private object ConstMessages { 21 | 22 | const val NO_SUBSCRIPTION = "没有找到对应的订阅,请先注册" 23 | } 24 | 25 | fun getSubscriptionOfUser(userId: Long): String { 26 | val subscription = baseMapper.getByUserId(userId) ?: return ConstMessages.NO_SUBSCRIPTION 27 | val result = subscription.run { 28 | """ 29 | 接收推送消息的群号:${receiverGroupId ?: "无(私聊)"} 30 | 搜索关键词:$searchWord 31 | 城市:${BossddPlatform.cityCodeMap[cityCode]} 32 | 城市代码:$cityCode 33 | 岗位的最小公司规模:$minCompanyScale 34 | 岗位的最大经验要求:${maxExperience}年 35 | 岗位的最低薪资待遇:${minSalary}K 36 | 岗位的最大通勤时间:${maxCommutingDuration}分钟 37 | 用户住址(经纬度):$userGpsLocation 38 | 状态:${if(enabled!!) "已启用" else "未启用"} 39 | """.trimAllLines() 40 | } 41 | return result 42 | } 43 | 44 | fun create(session: RobotSession) { 45 | baseMapper.getByUserId(session.qq)?.let { 46 | session.reply("您已进行过注册,无需重复注册") 47 | return 48 | } 49 | val subscription = parseByRobotSession(session) 50 | save(subscription) 51 | session.reply( 52 | """ 53 | 注册成功,订阅状态默认为关闭状态,此时可直接启用订阅,或在配置完成 54 | 屏蔽词或要屏蔽的正则表达式后再启用 55 | """.singleLine() 56 | ) 57 | } 58 | 59 | private fun parseByRobotSession(session: RobotSession): Subscription = session.run { 60 | Subscription().apply { 61 | userId = qq 62 | receiverGroupId = waitForReply( 63 | "请回复接收推送消息的群号,回复“none”表示使用私聊消息接收", 64 | "提供的群号有误,请回复数字或“none”", 65 | { 66 | @Suppress("USELESS_IS_CHECK") 67 | it.lowercase() == "none" || it.toLong() is Long 68 | } 69 | ).let { if(it.lowercase() == "none") null else it.toLong() } 70 | searchWord = waitForReply( 71 | "请回复要搜索的关键词", 72 | resultPredicate = { it.isNotBlank() } 73 | ) 74 | cityCode = waitForReply( 75 | "请回复要查找的岗位所在的城市名(地级市或直辖市,不带“市”字)", 76 | "未找到对应的城市,请重新输入", 77 | { BossddPlatform.cityCodeMap[it] != null } 78 | ).let { BossddPlatform.cityCodeMap[it] } 79 | minCompanyScale = waitForReply( 80 | "请回复岗位所属公司的最小人数规模(数字)", 81 | resultPredicate = { it.toInt() >= 0 } 82 | ).toInt() 83 | maxExperience = waitForReply( 84 | "请回复岗位的最大经验要求(年)", 85 | resultPredicate = { it.toInt() >= 0 } 86 | ).toInt() 87 | minSalary = waitForReply( 88 | "请回复岗位的最低薪资待遇(千,如10K则回复10)", 89 | resultPredicate = { it.toInt() >= 0 } 90 | ).toInt() 91 | maxCommutingDuration = waitForReply( 92 | "请回复岗位的最大通勤时间(分钟)", 93 | resultPredicate = { it.toInt() >= 0 } 94 | ).toInt() 95 | userGpsLocation = waitForReply( 96 | "请回复用户住址(经纬度,如“121.320081,31.193964”)", 97 | resultPredicate = { 98 | it.matches(Regex(Subscription.USER_GPS_LOCATION_PATTERN)) 99 | } 100 | ) 101 | enabled = false 102 | } 103 | } 104 | 105 | fun update(args: CommandMethodArgs): String { 106 | val subscription = baseMapper.getByUserId(args.qq) ?: return ConstMessages.NO_SUBSCRIPTION 107 | val fields = listOf( 108 | "接收推送群号", "搜索关键词", "城市", "最小公司规模", "最大经验要求", "最低薪资", 109 | "最大通勤时间", "用户住址", "状态" 110 | ) 111 | val contentIndex = 1 112 | var useParams = true 113 | val params = Subscription().apply { 114 | id = subscription.id 115 | when(args.getString(0)) { 116 | fields[0] -> receiverGroupId = run { 117 | if(args.getString(contentIndex).lowercase() == "none") { 118 | ktUpdate().run { 119 | eq(Subscription::id, id) 120 | set(Subscription::receiverGroupId, null) 121 | update() 122 | } 123 | useParams = false 124 | null 125 | } else { 126 | args.getLong(contentIndex) 127 | } 128 | } 129 | fields[1] -> searchWord = args.getString(contentIndex) 130 | fields[2] -> cityCode = BossddPlatform.cityCodeMap[args.getString(contentIndex)] ?: run { 131 | return "未找到对应的城市,请重新提供" 132 | } 133 | fields[3] -> minCompanyScale = args.getInt(contentIndex) 134 | fields[4] -> maxExperience = args.getInt(contentIndex) 135 | fields[5] -> minSalary = args.getInt(contentIndex) 136 | fields[6] -> maxCommutingDuration = args.getInt(contentIndex) 137 | fields[7] -> userGpsLocation = args.getString(contentIndex).also { 138 | if(!it.matches(Regex(Subscription.USER_GPS_LOCATION_PATTERN))) { 139 | return "用户住址经纬度的格式有误,请重新提供(如“121.320081,31.193964”)" 140 | } 141 | } 142 | fields[8] -> enabled = when(args.getString(contentIndex)) { 143 | "开" -> true 144 | "关" -> false 145 | else -> return "状态值有误,请提供“开”或“关”" 146 | } 147 | else -> { 148 | val fieldNames = fields.joinToString("、") { "“$it”" } 149 | return "要修改的字段名有误,请提供其中一个:$fieldNames" 150 | } 151 | } 152 | } 153 | if(useParams) updateById(params) 154 | return "修改成功,当前订阅信息如下:\n${getSubscriptionOfUser(args.qq)}" 155 | } 156 | 157 | fun getBlockWordsAndRegexes(userId: Long, type: String): RobotMessage<*> { 158 | val subscription = baseMapper.getByUserId(userId) 159 | subscription ?: return RobotMessage.text(ConstMessages.NO_SUBSCRIPTION) 160 | val json = when(type) { 161 | "关键词" -> subscription.blockWords 162 | "正则" -> subscription.blockRegexes 163 | else -> return RobotMessage.text("要查询的类型有误,请提供“关键词”或“正则”") 164 | } 165 | val result = json?.toJsonArray().run { 166 | if(isNullOrEmpty()) return RobotMessage.text("暂无屏蔽的$type") 167 | mapIndexed { i, s -> "${i + 1}.【$s】" }.joinToString(",") 168 | } 169 | return RobotMessage.image(ImageUtils.textToImageByLength(result, 40)) 170 | } 171 | 172 | fun manageBlockWordsAndRegexes(args: CommandMethodArgs): String { 173 | val subscription = baseMapper.getByUserId(args.qq) ?: return ConstMessages.NO_SUBSCRIPTION 174 | val type = args.getString(0) 175 | val action = args.getString(1) 176 | var isRegex = false 177 | val json = when(type) { 178 | "关键词" -> subscription.blockWords 179 | "正则" -> { 180 | isRegex = true 181 | subscription.blockRegexes 182 | } 183 | else -> return "要管理的类型有误,请提供“关键词”或“正则”" 184 | }?.toJsonArray() ?: JSONArray() 185 | when(action) { 186 | "添加" -> { 187 | val content = args.getString(2) 188 | if(isRegex) { 189 | runCatching { 190 | Regex(content) 191 | }.getOrElse { 192 | return "正则表达式有误,请重新提供" 193 | } 194 | } 195 | json.add(content) 196 | } 197 | "删除" -> { 198 | val index = (args.getInt(2) - 1) 199 | if(index < 0 || index > json.lastIndex) { 200 | return "要删除的序号不存在,请重新提供" 201 | } 202 | json.removeAt(index) 203 | } 204 | else -> return "要执行的操作有误,请提供“添加”或“删除”" 205 | } 206 | updateById(Subscription().apply { 207 | id = subscription.id 208 | when(type) { 209 | "关键词" -> blockWords = json.toString() 210 | "正则" -> blockRegexes = json.toString() 211 | } 212 | }) 213 | return "${action}成功" 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 8082 3 | 4 | spring: 5 | application: 6 | name: bossdd-monitor 7 | profiles: 8 | active: dev 9 | flyway: 10 | enabled: false 11 | locations: classpath:flyway/sql 12 | baseline-on-migrate: true 13 | baseline-version: 0.0.0 14 | validate-on-migrate: false 15 | clean-disabled: true 16 | clean-on-validation-error: false 17 | jackson: 18 | date-format: yyyy-MM-dd HH:mm:ss 19 | time-zone: GMT+8 20 | 21 | honoka: 22 | starter: 23 | mybatis: 24 | enabled: true 25 | 26 | mybatis-plus: 27 | # classpath*:mapper/**/*.xml为默认值,默认扫描resources/mapper目录及其子目录下所有xml文件 28 | mapper-locations: classpath*:mapper/**/*.xml 29 | configuration: 30 | map-underscore-to-camel-case: true 31 | 32 | app: 33 | browser: 34 | block-url-keywords: 35 | - /zpCommon/data/getCityShowPosition 36 | - /zpgeek/qrcode/generate.json 37 | - /common/data/city/site.json 38 | - /zpweixin/qrcode/getqrcode 39 | - /zpCommon/data/city.json 40 | error-page-detection: 41 | url-keywords: 42 | - /web/user/safe/verify-slider 43 | selectors: 44 | - .wrap-verify-slider 45 | 46 | logging: 47 | level: 48 | org.littleshoot.proxy.impl: off 49 | io.netty.util.concurrent: off 50 | -------------------------------------------------------------------------------- /src/main/resources/config/application-dev.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | datasource: 3 | driver-class-name: com.mysql.cj.jdbc.Driver 4 | url: jdbc:mysql://localhost:3306/bossdd_monitor?serverTimezone=GMT%2B8 5 | username: root 6 | password: root 7 | 8 | honoka: 9 | qqrobot: 10 | admin-qq: 12345 11 | developing-group: 10000 12 | framework: 13 | # 指定一个机器人框架的实现 14 | impl: onebot 15 | onebot: 16 | host: localhost 17 | websocket-port: 3001 18 | http-port: 3101 19 | 20 | app: 21 | proxy: 22 | address: brd.superproxy.io:33335 23 | username: test 24 | password: test 25 | browser: 26 | start-process-by-app: true 27 | stop-process-by-command: false 28 | user-data-dir: 29 | path: ./build/selenium/user-data 30 | default-headless: false 31 | monitor: 32 | delay: 1m 33 | initial-delay: 0s 34 | weekday-range: 1-7 35 | hour-range: 0-24 36 | 37 | logging: 38 | level: 39 | de.honoka.bossddmonitor.mapper: debug 40 | -------------------------------------------------------------------------------- /src/main/resources/flyway/sql/V1.0.0__update.sql: -------------------------------------------------------------------------------- 1 | drop table if exists subscription; 2 | create table subscription 3 | ( 4 | id bigint auto_increment primary key, 5 | user_id bigint comment '用户ID(默认情况下为QQ号)', 6 | receiver_group_id bigint comment '接收推送消息的群号(若为空则使用私聊进行推送)', 7 | search_word varchar(255) comment '搜索关键词', 8 | city_code varchar(255) comment '城市代码', 9 | min_company_scale int comment '岗位的最小公司规模', 10 | max_experience int comment '岗位的最大经验要求(年)', 11 | min_salary int comment '岗位的最低薪资待遇(千)', 12 | max_commuting_duration int comment '岗位的最大通勤时间(分钟)', 13 | block_words text comment '岗位信息屏蔽关键词(json)', 14 | block_regexes text comment '岗位信息屏蔽正则表达式(json)', 15 | user_gps_location varchar(255) comment '用户住址(经纬度)', 16 | enabled tinyint comment '是否启用此订阅' 17 | ) comment '用户订阅配置表'; 18 | create unique index subscription_index_1 on subscription (user_id); 19 | 20 | drop table if exists job_info; 21 | create table job_info 22 | ( 23 | id bigint auto_increment primary key, 24 | platform varchar(255) comment '平台名称', 25 | platform_job_id varchar(255) comment '平台岗位ID', 26 | from_search_word varchar(255) comment '来源的搜索关键词', 27 | identifiers text comment '岗位标识符(json)', 28 | city_code varchar(255) comment '城市代码', 29 | title varchar(255) comment '岗位标题', 30 | company varchar(255) comment '公司名(简称)', 31 | company_full_name varchar(255) comment '公司名', 32 | company_scale varchar(255) comment '公司规模', 33 | hr_name varchar(255) comment 'HR姓名', 34 | hr_online tinyint comment 'HR是否在线', 35 | hr_liveness varchar(255) comment 'HR活跃度', 36 | salary varchar(255) comment '薪资范围', 37 | experience varchar(255) comment '经验要求', 38 | edu_degree varchar(255) comment '学历要求', 39 | tags text comment '岗位标签(json)', 40 | details text comment '岗位详细描述', 41 | address varchar(255) comment '岗位地址', 42 | gps_location varchar(255) comment '岗位地址(经纬度)', 43 | create_time datetime, 44 | update_time datetime 45 | ) comment '岗位信息表'; 46 | create unique index job_info_index_1 on job_info (platform, platform_job_id); 47 | 48 | drop table if exists job_push_record; 49 | create table job_push_record 50 | ( 51 | id bigint auto_increment primary key, 52 | job_info_id bigint, 53 | subscribe_user_id bigint comment '订阅此岗位的用户ID(默认情况下为QQ号)', 54 | user_gps_location varchar(255) comment '推送记录创建时的用户住址(经纬度)', 55 | commute_duration int comment '此岗位通勤时间(分钟)', 56 | pushed tinyint comment '是否已向用户推送此岗位', 57 | valid tinyint comment '该记录是否有效(是否符合用户设定的筛选条件)' 58 | ) comment '岗位推送记录表'; 59 | create unique index job_push_record_index_1 on job_push_record (job_info_id, subscribe_user_id); 60 | -------------------------------------------------------------------------------- /src/main/resources/mapper/JobInfoMapper.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 15 | 16 | -------------------------------------------------------------------------------- /src/main/resources/static-data/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kosaka-bun/bossdd-monitor/744e8860b9b60db976e105a0450a8b0330ba5ed9/src/main/resources/static-data/.gitkeep -------------------------------------------------------------------------------- /src/main/resources/static-data/bossdd/city-code.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "code": 101010000, 4 | "name": "北京", 5 | "url": "", 6 | "subLevelModelList": [ 7 | { 8 | "code": 101010100, 9 | "name": "北京", 10 | "url": "/beijing/", 11 | "subLevelModelList": null 12 | } 13 | ] 14 | }, 15 | { 16 | "code": 101020000, 17 | "name": "上海", 18 | "url": "", 19 | "subLevelModelList": [ 20 | { 21 | "code": 101020100, 22 | "name": "上海", 23 | "url": "/shanghai/", 24 | "subLevelModelList": null 25 | } 26 | ] 27 | }, 28 | { 29 | "code": 101030000, 30 | "name": "天津", 31 | "url": "", 32 | "subLevelModelList": [ 33 | { 34 | "code": 101030100, 35 | "name": "天津", 36 | "url": "/tianjin/", 37 | "subLevelModelList": null 38 | } 39 | ] 40 | }, 41 | { 42 | "code": 101040000, 43 | "name": "重庆", 44 | "url": "", 45 | "subLevelModelList": [ 46 | { 47 | "code": 101040100, 48 | "name": "重庆", 49 | "url": "/chongqing/", 50 | "subLevelModelList": null 51 | } 52 | ] 53 | }, 54 | { 55 | "code": 101050000, 56 | "name": "黑龙江", 57 | "url": "", 58 | "subLevelModelList": [ 59 | { 60 | "code": 101050100, 61 | "name": "哈尔滨", 62 | "url": "/haerbin/", 63 | "subLevelModelList": null 64 | }, 65 | { 66 | "code": 101050200, 67 | "name": "齐齐哈尔", 68 | "url": "/chengshi/c101050200/", 69 | "subLevelModelList": null 70 | }, 71 | { 72 | "code": 101050300, 73 | "name": "牡丹江", 74 | "url": "/chengshi/c101050300/", 75 | "subLevelModelList": null 76 | }, 77 | { 78 | "code": 101050400, 79 | "name": "佳木斯", 80 | "url": "/chengshi/c101050400/", 81 | "subLevelModelList": null 82 | }, 83 | { 84 | "code": 101050500, 85 | "name": "绥化", 86 | "url": "/chengshi/c101050500/", 87 | "subLevelModelList": null 88 | }, 89 | { 90 | "code": 101050600, 91 | "name": "黑河", 92 | "url": "/chengshi/c101050600/", 93 | "subLevelModelList": null 94 | }, 95 | { 96 | "code": 101050700, 97 | "name": "伊春", 98 | "url": "/chengshi/c101050700/", 99 | "subLevelModelList": null 100 | }, 101 | { 102 | "code": 101050800, 103 | "name": "大庆", 104 | "url": "/chengshi/c101050800/", 105 | "subLevelModelList": null 106 | }, 107 | { 108 | "code": 101050900, 109 | "name": "七台河", 110 | "url": "/chengshi/c101050900/", 111 | "subLevelModelList": null 112 | }, 113 | { 114 | "code": 101051000, 115 | "name": "鸡西", 116 | "url": "/chengshi/c101051000/", 117 | "subLevelModelList": null 118 | }, 119 | { 120 | "code": 101051100, 121 | "name": "鹤岗", 122 | "url": "/chengshi/c101051100/", 123 | "subLevelModelList": null 124 | }, 125 | { 126 | "code": 101051200, 127 | "name": "双鸭山", 128 | "url": "/chengshi/c101051200/", 129 | "subLevelModelList": null 130 | }, 131 | { 132 | "code": 101051300, 133 | "name": "大兴安岭地区", 134 | "url": "/chengshi/c101051300/", 135 | "subLevelModelList": null 136 | } 137 | ] 138 | }, 139 | { 140 | "code": 101060000, 141 | "name": "吉林", 142 | "url": "", 143 | "subLevelModelList": [ 144 | { 145 | "code": 101060100, 146 | "name": "长春", 147 | "url": "/changchun/", 148 | "subLevelModelList": null 149 | }, 150 | { 151 | "code": 101060200, 152 | "name": "吉林", 153 | "url": "/chengshi/c101060200/", 154 | "subLevelModelList": null 155 | }, 156 | { 157 | "code": 101060300, 158 | "name": "四平", 159 | "url": "/chengshi/c101060300/", 160 | "subLevelModelList": null 161 | }, 162 | { 163 | "code": 101060400, 164 | "name": "通化", 165 | "url": "/chengshi/c101060400/", 166 | "subLevelModelList": null 167 | }, 168 | { 169 | "code": 101060500, 170 | "name": "白城", 171 | "url": "/chengshi/c101060500/", 172 | "subLevelModelList": null 173 | }, 174 | { 175 | "code": 101060600, 176 | "name": "辽源", 177 | "url": "/chengshi/c101060600/", 178 | "subLevelModelList": null 179 | }, 180 | { 181 | "code": 101060700, 182 | "name": "松原", 183 | "url": "/chengshi/c101060700/", 184 | "subLevelModelList": null 185 | }, 186 | { 187 | "code": 101060800, 188 | "name": "白山", 189 | "url": "/chengshi/c101060800/", 190 | "subLevelModelList": null 191 | }, 192 | { 193 | "code": 101060900, 194 | "name": "延边朝鲜族自治州", 195 | "url": "/chengshi/c101060900/", 196 | "subLevelModelList": null 197 | } 198 | ] 199 | }, 200 | { 201 | "code": 101070000, 202 | "name": "辽宁", 203 | "url": "", 204 | "subLevelModelList": [ 205 | { 206 | "code": 101070100, 207 | "name": "沈阳", 208 | "url": "/shenyang/", 209 | "subLevelModelList": null 210 | }, 211 | { 212 | "code": 101070200, 213 | "name": "大连", 214 | "url": "/dalian/", 215 | "subLevelModelList": null 216 | }, 217 | { 218 | "code": 101070300, 219 | "name": "鞍山", 220 | "url": "/chengshi/c101070300/", 221 | "subLevelModelList": null 222 | }, 223 | { 224 | "code": 101070400, 225 | "name": "抚顺", 226 | "url": "/chengshi/c101070400/", 227 | "subLevelModelList": null 228 | }, 229 | { 230 | "code": 101070500, 231 | "name": "本溪", 232 | "url": "/chengshi/c101070500/", 233 | "subLevelModelList": null 234 | }, 235 | { 236 | "code": 101070600, 237 | "name": "丹东", 238 | "url": "/chengshi/c101070600/", 239 | "subLevelModelList": null 240 | }, 241 | { 242 | "code": 101070700, 243 | "name": "锦州", 244 | "url": "/chengshi/c101070700/", 245 | "subLevelModelList": null 246 | }, 247 | { 248 | "code": 101070800, 249 | "name": "营口", 250 | "url": "/chengshi/c101070800/", 251 | "subLevelModelList": null 252 | }, 253 | { 254 | "code": 101070900, 255 | "name": "阜新", 256 | "url": "/chengshi/c101070900/", 257 | "subLevelModelList": null 258 | }, 259 | { 260 | "code": 101071000, 261 | "name": "辽阳", 262 | "url": "/chengshi/c101071000/", 263 | "subLevelModelList": null 264 | }, 265 | { 266 | "code": 101071100, 267 | "name": "铁岭", 268 | "url": "/chengshi/c101071100/", 269 | "subLevelModelList": null 270 | }, 271 | { 272 | "code": 101071200, 273 | "name": "朝阳", 274 | "url": "/chengshi/c101071200/", 275 | "subLevelModelList": null 276 | }, 277 | { 278 | "code": 101071300, 279 | "name": "盘锦", 280 | "url": "/chengshi/c101071300/", 281 | "subLevelModelList": null 282 | }, 283 | { 284 | "code": 101071400, 285 | "name": "葫芦岛", 286 | "url": "/chengshi/c101071400/", 287 | "subLevelModelList": null 288 | } 289 | ] 290 | }, 291 | { 292 | "code": 101080000, 293 | "name": "内蒙古", 294 | "url": "", 295 | "subLevelModelList": [ 296 | { 297 | "code": 101080100, 298 | "name": "呼和浩特", 299 | "url": "/chengshi/c101080100/", 300 | "subLevelModelList": null 301 | }, 302 | { 303 | "code": 101080200, 304 | "name": "包头", 305 | "url": "/chengshi/c101080200/", 306 | "subLevelModelList": null 307 | }, 308 | { 309 | "code": 101080300, 310 | "name": "乌海", 311 | "url": "/chengshi/c101080300/", 312 | "subLevelModelList": null 313 | }, 314 | { 315 | "code": 101080400, 316 | "name": "通辽", 317 | "url": "/chengshi/c101080400/", 318 | "subLevelModelList": null 319 | }, 320 | { 321 | "code": 101080500, 322 | "name": "赤峰", 323 | "url": "/chengshi/c101080500/", 324 | "subLevelModelList": null 325 | }, 326 | { 327 | "code": 101080600, 328 | "name": "鄂尔多斯", 329 | "url": "/chengshi/c101080600/", 330 | "subLevelModelList": null 331 | }, 332 | { 333 | "code": 101080700, 334 | "name": "呼伦贝尔", 335 | "url": "/chengshi/c101080700/", 336 | "subLevelModelList": null 337 | }, 338 | { 339 | "code": 101080800, 340 | "name": "巴彦淖尔", 341 | "url": "/chengshi/c101080800/", 342 | "subLevelModelList": null 343 | }, 344 | { 345 | "code": 101080900, 346 | "name": "乌兰察布", 347 | "url": "/chengshi/c101080900/", 348 | "subLevelModelList": null 349 | }, 350 | { 351 | "code": 101081000, 352 | "name": "锡林郭勒盟", 353 | "url": "/chengshi/c101081000/", 354 | "subLevelModelList": null 355 | }, 356 | { 357 | "code": 101081100, 358 | "name": "兴安盟", 359 | "url": "/chengshi/c101081100/", 360 | "subLevelModelList": null 361 | }, 362 | { 363 | "code": 101081200, 364 | "name": "阿拉善盟", 365 | "url": "/chengshi/c101081200/", 366 | "subLevelModelList": null 367 | } 368 | ] 369 | }, 370 | { 371 | "code": 101090000, 372 | "name": "河北", 373 | "url": "", 374 | "subLevelModelList": [ 375 | { 376 | "code": 101090100, 377 | "name": "石家庄", 378 | "url": "/shijiazhuang/", 379 | "subLevelModelList": null 380 | }, 381 | { 382 | "code": 101090200, 383 | "name": "保定", 384 | "url": "/baoding/", 385 | "subLevelModelList": null 386 | }, 387 | { 388 | "code": 101090300, 389 | "name": "张家口", 390 | "url": "/chengshi/c101090300/", 391 | "subLevelModelList": null 392 | }, 393 | { 394 | "code": 101090400, 395 | "name": "承德", 396 | "url": "/chengshi/c101090400/", 397 | "subLevelModelList": null 398 | }, 399 | { 400 | "code": 101090500, 401 | "name": "唐山", 402 | "url": "/chengshi/c101090500/", 403 | "subLevelModelList": null 404 | }, 405 | { 406 | "code": 101090600, 407 | "name": "廊坊", 408 | "url": "/chengshi/c101090600/", 409 | "subLevelModelList": null 410 | }, 411 | { 412 | "code": 101090700, 413 | "name": "沧州", 414 | "url": "/chengshi/c101090700/", 415 | "subLevelModelList": null 416 | }, 417 | { 418 | "code": 101090800, 419 | "name": "衡水", 420 | "url": "/chengshi/c101090800/", 421 | "subLevelModelList": null 422 | }, 423 | { 424 | "code": 101090900, 425 | "name": "邢台", 426 | "url": "/chengshi/c101090900/", 427 | "subLevelModelList": null 428 | }, 429 | { 430 | "code": 101091000, 431 | "name": "邯郸", 432 | "url": "/chengshi/c101091000/", 433 | "subLevelModelList": null 434 | }, 435 | { 436 | "code": 101091100, 437 | "name": "秦皇岛", 438 | "url": "/chengshi/c101091100/", 439 | "subLevelModelList": null 440 | } 441 | ] 442 | }, 443 | { 444 | "code": 101100000, 445 | "name": "山西", 446 | "url": "", 447 | "subLevelModelList": [ 448 | { 449 | "code": 101100100, 450 | "name": "太原", 451 | "url": "/taiyuan/", 452 | "subLevelModelList": null 453 | }, 454 | { 455 | "code": 101100200, 456 | "name": "大同", 457 | "url": "/chengshi/c101100200/", 458 | "subLevelModelList": null 459 | }, 460 | { 461 | "code": 101100300, 462 | "name": "阳泉", 463 | "url": "/chengshi/c101100300/", 464 | "subLevelModelList": null 465 | }, 466 | { 467 | "code": 101100400, 468 | "name": "晋中", 469 | "url": "/chengshi/c101100400/", 470 | "subLevelModelList": null 471 | }, 472 | { 473 | "code": 101100500, 474 | "name": "长治", 475 | "url": "/chengshi/c101100500/", 476 | "subLevelModelList": null 477 | }, 478 | { 479 | "code": 101100600, 480 | "name": "晋城", 481 | "url": "/chengshi/c101100600/", 482 | "subLevelModelList": null 483 | }, 484 | { 485 | "code": 101100700, 486 | "name": "临汾", 487 | "url": "/chengshi/c101100700/", 488 | "subLevelModelList": null 489 | }, 490 | { 491 | "code": 101100800, 492 | "name": "运城", 493 | "url": "/chengshi/c101100800/", 494 | "subLevelModelList": null 495 | }, 496 | { 497 | "code": 101100900, 498 | "name": "朔州", 499 | "url": "/chengshi/c101100900/", 500 | "subLevelModelList": null 501 | }, 502 | { 503 | "code": 101101000, 504 | "name": "忻州", 505 | "url": "/chengshi/c101101000/", 506 | "subLevelModelList": null 507 | }, 508 | { 509 | "code": 101101100, 510 | "name": "吕梁", 511 | "url": "/chengshi/c101101100/", 512 | "subLevelModelList": null 513 | } 514 | ] 515 | }, 516 | { 517 | "code": 101110000, 518 | "name": "陕西", 519 | "url": "", 520 | "subLevelModelList": [ 521 | { 522 | "code": 101110100, 523 | "name": "西安", 524 | "url": "/xian/", 525 | "subLevelModelList": null 526 | }, 527 | { 528 | "code": 101110200, 529 | "name": "咸阳", 530 | "url": "/chengshi/c101110200/", 531 | "subLevelModelList": null 532 | }, 533 | { 534 | "code": 101110300, 535 | "name": "延安", 536 | "url": "/chengshi/c101110300/", 537 | "subLevelModelList": null 538 | }, 539 | { 540 | "code": 101110400, 541 | "name": "榆林", 542 | "url": "/chengshi/c101110400/", 543 | "subLevelModelList": null 544 | }, 545 | { 546 | "code": 101110500, 547 | "name": "渭南", 548 | "url": "/chengshi/c101110500/", 549 | "subLevelModelList": null 550 | }, 551 | { 552 | "code": 101110600, 553 | "name": "商洛", 554 | "url": "/chengshi/c101110600/", 555 | "subLevelModelList": null 556 | }, 557 | { 558 | "code": 101110700, 559 | "name": "安康", 560 | "url": "/chengshi/c101110700/", 561 | "subLevelModelList": null 562 | }, 563 | { 564 | "code": 101110800, 565 | "name": "汉中", 566 | "url": "/chengshi/c101110800/", 567 | "subLevelModelList": null 568 | }, 569 | { 570 | "code": 101110900, 571 | "name": "宝鸡", 572 | "url": "/chengshi/c101110900/", 573 | "subLevelModelList": null 574 | }, 575 | { 576 | "code": 101111000, 577 | "name": "铜川", 578 | "url": "/chengshi/c101111000/", 579 | "subLevelModelList": null 580 | } 581 | ] 582 | }, 583 | { 584 | "code": 101120000, 585 | "name": "山东", 586 | "url": "", 587 | "subLevelModelList": [ 588 | { 589 | "code": 101120100, 590 | "name": "济南", 591 | "url": "/jinan/", 592 | "subLevelModelList": null 593 | }, 594 | { 595 | "code": 101120200, 596 | "name": "青岛", 597 | "url": "/qingdao/", 598 | "subLevelModelList": null 599 | }, 600 | { 601 | "code": 101120300, 602 | "name": "淄博", 603 | "url": "/chengshi/c101120300/", 604 | "subLevelModelList": null 605 | }, 606 | { 607 | "code": 101120400, 608 | "name": "德州", 609 | "url": "/chengshi/c101120400/", 610 | "subLevelModelList": null 611 | }, 612 | { 613 | "code": 101120500, 614 | "name": "烟台", 615 | "url": "/yantai/", 616 | "subLevelModelList": null 617 | }, 618 | { 619 | "code": 101120600, 620 | "name": "潍坊", 621 | "url": "/weifang/", 622 | "subLevelModelList": null 623 | }, 624 | { 625 | "code": 101120700, 626 | "name": "济宁", 627 | "url": "/chengshi/c101120700/", 628 | "subLevelModelList": null 629 | }, 630 | { 631 | "code": 101120800, 632 | "name": "泰安", 633 | "url": "/chengshi/c101120800/", 634 | "subLevelModelList": null 635 | }, 636 | { 637 | "code": 101120900, 638 | "name": "临沂", 639 | "url": "/chengshi/c101120900/", 640 | "subLevelModelList": null 641 | }, 642 | { 643 | "code": 101121000, 644 | "name": "菏泽", 645 | "url": "/chengshi/c101121000/", 646 | "subLevelModelList": null 647 | }, 648 | { 649 | "code": 101121100, 650 | "name": "滨州", 651 | "url": "/chengshi/c101121100/", 652 | "subLevelModelList": null 653 | }, 654 | { 655 | "code": 101121200, 656 | "name": "东营", 657 | "url": "/chengshi/c101121200/", 658 | "subLevelModelList": null 659 | }, 660 | { 661 | "code": 101121300, 662 | "name": "威海", 663 | "url": "/chengshi/c101121300/", 664 | "subLevelModelList": null 665 | }, 666 | { 667 | "code": 101121400, 668 | "name": "枣庄", 669 | "url": "/chengshi/c101121400/", 670 | "subLevelModelList": null 671 | }, 672 | { 673 | "code": 101121500, 674 | "name": "日照", 675 | "url": "/chengshi/c101121500/", 676 | "subLevelModelList": null 677 | }, 678 | { 679 | "code": 101121700, 680 | "name": "聊城", 681 | "url": "/chengshi/c101121700/", 682 | "subLevelModelList": null 683 | } 684 | ] 685 | }, 686 | { 687 | "code": 101130000, 688 | "name": "新疆", 689 | "url": "", 690 | "subLevelModelList": [ 691 | { 692 | "code": 101130100, 693 | "name": "乌鲁木齐", 694 | "url": "/wulumuqi/", 695 | "subLevelModelList": null 696 | }, 697 | { 698 | "code": 101130200, 699 | "name": "克拉玛依", 700 | "url": "/chengshi/c101130200/", 701 | "subLevelModelList": null 702 | }, 703 | { 704 | "code": 101130300, 705 | "name": "昌吉回族自治州", 706 | "url": "/chengshi/c101130300/", 707 | "subLevelModelList": null 708 | }, 709 | { 710 | "code": 101130400, 711 | "name": "巴音郭楞蒙古自治州", 712 | "url": "/chengshi/c101130400/", 713 | "subLevelModelList": null 714 | }, 715 | { 716 | "code": 101130500, 717 | "name": "博尔塔拉蒙古自治州", 718 | "url": "/chengshi/c101130500/", 719 | "subLevelModelList": null 720 | }, 721 | { 722 | "code": 101130600, 723 | "name": "伊犁哈萨克自治州", 724 | "url": "/chengshi/c101130600/", 725 | "subLevelModelList": null 726 | }, 727 | { 728 | "code": 101130800, 729 | "name": "吐鲁番", 730 | "url": "/chengshi/c101130800/", 731 | "subLevelModelList": null 732 | }, 733 | { 734 | "code": 101130900, 735 | "name": "哈密", 736 | "url": "/chengshi/c101130900/", 737 | "subLevelModelList": null 738 | }, 739 | { 740 | "code": 101131000, 741 | "name": "阿克苏地区", 742 | "url": "/chengshi/c101131000/", 743 | "subLevelModelList": null 744 | }, 745 | { 746 | "code": 101131100, 747 | "name": "克孜勒苏柯尔克孜自治州", 748 | "url": "/chengshi/c101131100/", 749 | "subLevelModelList": null 750 | }, 751 | { 752 | "code": 101131200, 753 | "name": "喀什地区", 754 | "url": "/chengshi/c101131200/", 755 | "subLevelModelList": null 756 | }, 757 | { 758 | "code": 101131300, 759 | "name": "和田地区", 760 | "url": "/chengshi/c101131300/", 761 | "subLevelModelList": null 762 | }, 763 | { 764 | "code": 101131400, 765 | "name": "塔城地区", 766 | "url": "/chengshi/c101131400/", 767 | "subLevelModelList": null 768 | }, 769 | { 770 | "code": 101131500, 771 | "name": "阿勒泰地区", 772 | "url": "/chengshi/c101131500/", 773 | "subLevelModelList": null 774 | }, 775 | { 776 | "code": 101131600, 777 | "name": "石河子", 778 | "url": "/chengshi/c101131600/", 779 | "subLevelModelList": null 780 | }, 781 | { 782 | "code": 101131700, 783 | "name": "阿拉尔", 784 | "url": "/chengshi/c101131700/", 785 | "subLevelModelList": null 786 | }, 787 | { 788 | "code": 101131800, 789 | "name": "图木舒克", 790 | "url": "/chengshi/c101131800/", 791 | "subLevelModelList": null 792 | }, 793 | { 794 | "code": 101131900, 795 | "name": "五家渠", 796 | "url": "/chengshi/c101131900/", 797 | "subLevelModelList": null 798 | }, 799 | { 800 | "code": 101132000, 801 | "name": "铁门关", 802 | "url": "/chengshi/c101132000/", 803 | "subLevelModelList": null 804 | }, 805 | { 806 | "code": 101132100, 807 | "name": "北屯市", 808 | "url": "/chengshi/c101132100/", 809 | "subLevelModelList": null 810 | }, 811 | { 812 | "code": 101132200, 813 | "name": "可克达拉市", 814 | "url": "/chengshi/c101132200/", 815 | "subLevelModelList": null 816 | }, 817 | { 818 | "code": 101132300, 819 | "name": "昆玉市", 820 | "url": "/chengshi/c101132300/", 821 | "subLevelModelList": null 822 | }, 823 | { 824 | "code": 101132400, 825 | "name": "双河市", 826 | "url": "/chengshi/c101132400/", 827 | "subLevelModelList": null 828 | }, 829 | { 830 | "code": 101132500, 831 | "name": "新星市", 832 | "url": "/chengshi/c101132500/", 833 | "subLevelModelList": null 834 | }, 835 | { 836 | "code": 101132600, 837 | "name": "胡杨河市", 838 | "url": "/chengshi/c101132600/", 839 | "subLevelModelList": null 840 | }, 841 | { 842 | "code": 101132700, 843 | "name": "白杨市", 844 | "url": "/chengshi/c101132700/", 845 | "subLevelModelList": null 846 | } 847 | ] 848 | }, 849 | { 850 | "code": 101140000, 851 | "name": "西藏", 852 | "url": "", 853 | "subLevelModelList": [ 854 | { 855 | "code": 101140100, 856 | "name": "拉萨", 857 | "url": "/chengshi/c101140100/", 858 | "subLevelModelList": null 859 | }, 860 | { 861 | "code": 101140200, 862 | "name": "日喀则", 863 | "url": "/chengshi/c101140200/", 864 | "subLevelModelList": null 865 | }, 866 | { 867 | "code": 101140300, 868 | "name": "昌都", 869 | "url": "/chengshi/c101140300/", 870 | "subLevelModelList": null 871 | }, 872 | { 873 | "code": 101140400, 874 | "name": "林芝", 875 | "url": "/chengshi/c101140400/", 876 | "subLevelModelList": null 877 | }, 878 | { 879 | "code": 101140500, 880 | "name": "山南", 881 | "url": "/chengshi/c101140500/", 882 | "subLevelModelList": null 883 | }, 884 | { 885 | "code": 101140600, 886 | "name": "那曲", 887 | "url": "/chengshi/c101140600/", 888 | "subLevelModelList": null 889 | }, 890 | { 891 | "code": 101140700, 892 | "name": "阿里地区", 893 | "url": "/chengshi/c101140700/", 894 | "subLevelModelList": null 895 | } 896 | ] 897 | }, 898 | { 899 | "code": 101150000, 900 | "name": "青海", 901 | "url": "", 902 | "subLevelModelList": [ 903 | { 904 | "code": 101150100, 905 | "name": "西宁", 906 | "url": "/chengshi/c101150100/", 907 | "subLevelModelList": null 908 | }, 909 | { 910 | "code": 101150200, 911 | "name": "海东", 912 | "url": "/chengshi/c101150200/", 913 | "subLevelModelList": null 914 | }, 915 | { 916 | "code": 101150300, 917 | "name": "海北藏族自治州", 918 | "url": "/chengshi/c101150300/", 919 | "subLevelModelList": null 920 | }, 921 | { 922 | "code": 101150400, 923 | "name": "黄南藏族自治州", 924 | "url": "/chengshi/c101150400/", 925 | "subLevelModelList": null 926 | }, 927 | { 928 | "code": 101150500, 929 | "name": "海南藏族自治州", 930 | "url": "/chengshi/c101150500/", 931 | "subLevelModelList": null 932 | }, 933 | { 934 | "code": 101150600, 935 | "name": "果洛藏族自治州", 936 | "url": "/chengshi/c101150600/", 937 | "subLevelModelList": null 938 | }, 939 | { 940 | "code": 101150700, 941 | "name": "玉树藏族自治州", 942 | "url": "/chengshi/c101150700/", 943 | "subLevelModelList": null 944 | }, 945 | { 946 | "code": 101150800, 947 | "name": "海西蒙古族藏族自治州", 948 | "url": "/chengshi/c101150800/", 949 | "subLevelModelList": null 950 | } 951 | ] 952 | }, 953 | { 954 | "code": 101160000, 955 | "name": "甘肃", 956 | "url": "", 957 | "subLevelModelList": [ 958 | { 959 | "code": 101160100, 960 | "name": "兰州", 961 | "url": "/chengshi/c101160100/", 962 | "subLevelModelList": null 963 | }, 964 | { 965 | "code": 101160200, 966 | "name": "定西", 967 | "url": "/chengshi/c101160200/", 968 | "subLevelModelList": null 969 | }, 970 | { 971 | "code": 101160300, 972 | "name": "平凉", 973 | "url": "/chengshi/c101160300/", 974 | "subLevelModelList": null 975 | }, 976 | { 977 | "code": 101160400, 978 | "name": "庆阳", 979 | "url": "/chengshi/c101160400/", 980 | "subLevelModelList": null 981 | }, 982 | { 983 | "code": 101160500, 984 | "name": "武威", 985 | "url": "/chengshi/c101160500/", 986 | "subLevelModelList": null 987 | }, 988 | { 989 | "code": 101160600, 990 | "name": "金昌", 991 | "url": "/chengshi/c101160600/", 992 | "subLevelModelList": null 993 | }, 994 | { 995 | "code": 101160700, 996 | "name": "张掖", 997 | "url": "/chengshi/c101160700/", 998 | "subLevelModelList": null 999 | }, 1000 | { 1001 | "code": 101160800, 1002 | "name": "酒泉", 1003 | "url": "/chengshi/c101160800/", 1004 | "subLevelModelList": null 1005 | }, 1006 | { 1007 | "code": 101160900, 1008 | "name": "天水", 1009 | "url": "/chengshi/c101160900/", 1010 | "subLevelModelList": null 1011 | }, 1012 | { 1013 | "code": 101161000, 1014 | "name": "白银", 1015 | "url": "/chengshi/c101161000/", 1016 | "subLevelModelList": null 1017 | }, 1018 | { 1019 | "code": 101161100, 1020 | "name": "陇南", 1021 | "url": "/chengshi/c101161100/", 1022 | "subLevelModelList": null 1023 | }, 1024 | { 1025 | "code": 101161200, 1026 | "name": "嘉峪关", 1027 | "url": "/chengshi/c101161200/", 1028 | "subLevelModelList": null 1029 | }, 1030 | { 1031 | "code": 101161300, 1032 | "name": "临夏回族自治州", 1033 | "url": "/chengshi/c101161300/", 1034 | "subLevelModelList": null 1035 | }, 1036 | { 1037 | "code": 101161400, 1038 | "name": "甘南藏族自治州", 1039 | "url": "/chengshi/c101161400/", 1040 | "subLevelModelList": null 1041 | } 1042 | ] 1043 | }, 1044 | { 1045 | "code": 101170000, 1046 | "name": "宁夏", 1047 | "url": "", 1048 | "subLevelModelList": [ 1049 | { 1050 | "code": 101170100, 1051 | "name": "银川", 1052 | "url": "/chengshi/c101170100/", 1053 | "subLevelModelList": null 1054 | }, 1055 | { 1056 | "code": 101170200, 1057 | "name": "石嘴山", 1058 | "url": "/chengshi/c101170200/", 1059 | "subLevelModelList": null 1060 | }, 1061 | { 1062 | "code": 101170300, 1063 | "name": "吴忠", 1064 | "url": "/chengshi/c101170300/", 1065 | "subLevelModelList": null 1066 | }, 1067 | { 1068 | "code": 101170400, 1069 | "name": "固原", 1070 | "url": "/chengshi/c101170400/", 1071 | "subLevelModelList": null 1072 | }, 1073 | { 1074 | "code": 101170500, 1075 | "name": "中卫", 1076 | "url": "/chengshi/c101170500/", 1077 | "subLevelModelList": null 1078 | } 1079 | ] 1080 | }, 1081 | { 1082 | "code": 101180000, 1083 | "name": "河南", 1084 | "url": "", 1085 | "subLevelModelList": [ 1086 | { 1087 | "code": 101180100, 1088 | "name": "郑州", 1089 | "url": "/zhengzhou/", 1090 | "subLevelModelList": null 1091 | }, 1092 | { 1093 | "code": 101180200, 1094 | "name": "安阳", 1095 | "url": "/chengshi/c101180200/", 1096 | "subLevelModelList": null 1097 | }, 1098 | { 1099 | "code": 101180300, 1100 | "name": "新乡", 1101 | "url": "/chengshi/c101180300/", 1102 | "subLevelModelList": null 1103 | }, 1104 | { 1105 | "code": 101180400, 1106 | "name": "许昌", 1107 | "url": "/chengshi/c101180400/", 1108 | "subLevelModelList": null 1109 | }, 1110 | { 1111 | "code": 101180500, 1112 | "name": "平顶山", 1113 | "url": "/chengshi/c101180500/", 1114 | "subLevelModelList": null 1115 | }, 1116 | { 1117 | "code": 101180600, 1118 | "name": "信阳", 1119 | "url": "/chengshi/c101180600/", 1120 | "subLevelModelList": null 1121 | }, 1122 | { 1123 | "code": 101180700, 1124 | "name": "南阳", 1125 | "url": "/chengshi/c101180700/", 1126 | "subLevelModelList": null 1127 | }, 1128 | { 1129 | "code": 101180800, 1130 | "name": "开封", 1131 | "url": "/chengshi/c101180800/", 1132 | "subLevelModelList": null 1133 | }, 1134 | { 1135 | "code": 101180900, 1136 | "name": "洛阳", 1137 | "url": "/chengshi/c101180900/", 1138 | "subLevelModelList": null 1139 | }, 1140 | { 1141 | "code": 101181000, 1142 | "name": "商丘", 1143 | "url": "/chengshi/c101181000/", 1144 | "subLevelModelList": null 1145 | }, 1146 | { 1147 | "code": 101181100, 1148 | "name": "焦作", 1149 | "url": "/chengshi/c101181100/", 1150 | "subLevelModelList": null 1151 | }, 1152 | { 1153 | "code": 101181200, 1154 | "name": "鹤壁", 1155 | "url": "/chengshi/c101181200/", 1156 | "subLevelModelList": null 1157 | }, 1158 | { 1159 | "code": 101181300, 1160 | "name": "濮阳", 1161 | "url": "/chengshi/c101181300/", 1162 | "subLevelModelList": null 1163 | }, 1164 | { 1165 | "code": 101181400, 1166 | "name": "周口", 1167 | "url": "/chengshi/c101181400/", 1168 | "subLevelModelList": null 1169 | }, 1170 | { 1171 | "code": 101181500, 1172 | "name": "漯河", 1173 | "url": "/chengshi/c101181500/", 1174 | "subLevelModelList": null 1175 | }, 1176 | { 1177 | "code": 101181600, 1178 | "name": "驻马店", 1179 | "url": "/chengshi/c101181600/", 1180 | "subLevelModelList": null 1181 | }, 1182 | { 1183 | "code": 101181700, 1184 | "name": "三门峡", 1185 | "url": "/chengshi/c101181700/", 1186 | "subLevelModelList": null 1187 | }, 1188 | { 1189 | "code": 101181800, 1190 | "name": "济源", 1191 | "url": "/chengshi/c101181800/", 1192 | "subLevelModelList": null 1193 | } 1194 | ] 1195 | }, 1196 | { 1197 | "code": 101190000, 1198 | "name": "江苏", 1199 | "url": "", 1200 | "subLevelModelList": [ 1201 | { 1202 | "code": 101190100, 1203 | "name": "南京", 1204 | "url": "/nanjing/", 1205 | "subLevelModelList": null 1206 | }, 1207 | { 1208 | "code": 101190200, 1209 | "name": "无锡", 1210 | "url": "/wuxi/", 1211 | "subLevelModelList": null 1212 | }, 1213 | { 1214 | "code": 101190300, 1215 | "name": "镇江", 1216 | "url": "/chengshi/c101190300/", 1217 | "subLevelModelList": null 1218 | }, 1219 | { 1220 | "code": 101190400, 1221 | "name": "苏州", 1222 | "url": "/suzhou/", 1223 | "subLevelModelList": null 1224 | }, 1225 | { 1226 | "code": 101190500, 1227 | "name": "南通", 1228 | "url": "/nantong/", 1229 | "subLevelModelList": null 1230 | }, 1231 | { 1232 | "code": 101190600, 1233 | "name": "扬州", 1234 | "url": "/chengshi/c101190600/", 1235 | "subLevelModelList": null 1236 | }, 1237 | { 1238 | "code": 101190700, 1239 | "name": "盐城", 1240 | "url": "/chengshi/c101190700/", 1241 | "subLevelModelList": null 1242 | }, 1243 | { 1244 | "code": 101190800, 1245 | "name": "徐州", 1246 | "url": "/xuzhou/", 1247 | "subLevelModelList": null 1248 | }, 1249 | { 1250 | "code": 101190900, 1251 | "name": "淮安", 1252 | "url": "/chengshi/c101190900/", 1253 | "subLevelModelList": null 1254 | }, 1255 | { 1256 | "code": 101191000, 1257 | "name": "连云港", 1258 | "url": "/chengshi/c101191000/", 1259 | "subLevelModelList": null 1260 | }, 1261 | { 1262 | "code": 101191100, 1263 | "name": "常州", 1264 | "url": "/changzhou/", 1265 | "subLevelModelList": null 1266 | }, 1267 | { 1268 | "code": 101191200, 1269 | "name": "泰州", 1270 | "url": "/chengshi/c101191200/", 1271 | "subLevelModelList": null 1272 | }, 1273 | { 1274 | "code": 101191300, 1275 | "name": "宿迁", 1276 | "url": "/chengshi/c101191300/", 1277 | "subLevelModelList": null 1278 | } 1279 | ] 1280 | }, 1281 | { 1282 | "code": 101200000, 1283 | "name": "湖北", 1284 | "url": "", 1285 | "subLevelModelList": [ 1286 | { 1287 | "code": 101200100, 1288 | "name": "武汉", 1289 | "url": "/wuhan/", 1290 | "subLevelModelList": null 1291 | }, 1292 | { 1293 | "code": 101200200, 1294 | "name": "襄阳", 1295 | "url": "/chengshi/c101200200/", 1296 | "subLevelModelList": null 1297 | }, 1298 | { 1299 | "code": 101200300, 1300 | "name": "鄂州", 1301 | "url": "/chengshi/c101200300/", 1302 | "subLevelModelList": null 1303 | }, 1304 | { 1305 | "code": 101200400, 1306 | "name": "孝感", 1307 | "url": "/chengshi/c101200400/", 1308 | "subLevelModelList": null 1309 | }, 1310 | { 1311 | "code": 101200500, 1312 | "name": "黄冈", 1313 | "url": "/chengshi/c101200500/", 1314 | "subLevelModelList": null 1315 | }, 1316 | { 1317 | "code": 101200600, 1318 | "name": "黄石", 1319 | "url": "/chengshi/c101200600/", 1320 | "subLevelModelList": null 1321 | }, 1322 | { 1323 | "code": 101200700, 1324 | "name": "咸宁", 1325 | "url": "/chengshi/c101200700/", 1326 | "subLevelModelList": null 1327 | }, 1328 | { 1329 | "code": 101200800, 1330 | "name": "荆州", 1331 | "url": "/chengshi/c101200800/", 1332 | "subLevelModelList": null 1333 | }, 1334 | { 1335 | "code": 101200900, 1336 | "name": "宜昌", 1337 | "url": "/chengshi/c101200900/", 1338 | "subLevelModelList": null 1339 | }, 1340 | { 1341 | "code": 101201000, 1342 | "name": "十堰", 1343 | "url": "/chengshi/c101201000/", 1344 | "subLevelModelList": null 1345 | }, 1346 | { 1347 | "code": 101201100, 1348 | "name": "随州", 1349 | "url": "/chengshi/c101201100/", 1350 | "subLevelModelList": null 1351 | }, 1352 | { 1353 | "code": 101201200, 1354 | "name": "荆门", 1355 | "url": "/chengshi/c101201200/", 1356 | "subLevelModelList": null 1357 | }, 1358 | { 1359 | "code": 101201300, 1360 | "name": "恩施土家族苗族自治州", 1361 | "url": "/chengshi/c101201300/", 1362 | "subLevelModelList": null 1363 | }, 1364 | { 1365 | "code": 101201400, 1366 | "name": "仙桃", 1367 | "url": "/chengshi/c101201400/", 1368 | "subLevelModelList": null 1369 | }, 1370 | { 1371 | "code": 101201500, 1372 | "name": "潜江", 1373 | "url": "/chengshi/c101201500/", 1374 | "subLevelModelList": null 1375 | }, 1376 | { 1377 | "code": 101201600, 1378 | "name": "天门", 1379 | "url": "/chengshi/c101201600/", 1380 | "subLevelModelList": null 1381 | }, 1382 | { 1383 | "code": 101201700, 1384 | "name": "神农架", 1385 | "url": "/chengshi/c101201700/", 1386 | "subLevelModelList": null 1387 | } 1388 | ] 1389 | }, 1390 | { 1391 | "code": 101210000, 1392 | "name": "浙江", 1393 | "url": "", 1394 | "subLevelModelList": [ 1395 | { 1396 | "code": 101210100, 1397 | "name": "杭州", 1398 | "url": "/hangzhou/", 1399 | "subLevelModelList": null 1400 | }, 1401 | { 1402 | "code": 101210200, 1403 | "name": "湖州", 1404 | "url": "/chengshi/c101210200/", 1405 | "subLevelModelList": null 1406 | }, 1407 | { 1408 | "code": 101210300, 1409 | "name": "嘉兴", 1410 | "url": "/jiaxing/", 1411 | "subLevelModelList": null 1412 | }, 1413 | { 1414 | "code": 101210400, 1415 | "name": "宁波", 1416 | "url": "/ningbo/", 1417 | "subLevelModelList": null 1418 | }, 1419 | { 1420 | "code": 101210500, 1421 | "name": "绍兴", 1422 | "url": "/chengshi/c101210500/", 1423 | "subLevelModelList": null 1424 | }, 1425 | { 1426 | "code": 101210600, 1427 | "name": "台州", 1428 | "url": "/chengshi/c101210600/", 1429 | "subLevelModelList": null 1430 | }, 1431 | { 1432 | "code": 101210700, 1433 | "name": "温州", 1434 | "url": "/wenzhou/", 1435 | "subLevelModelList": null 1436 | }, 1437 | { 1438 | "code": 101210800, 1439 | "name": "丽水", 1440 | "url": "/chengshi/c101210800/", 1441 | "subLevelModelList": null 1442 | }, 1443 | { 1444 | "code": 101210900, 1445 | "name": "金华", 1446 | "url": "/jinhua/", 1447 | "subLevelModelList": null 1448 | }, 1449 | { 1450 | "code": 101211000, 1451 | "name": "衢州", 1452 | "url": "/chengshi/c101211000/", 1453 | "subLevelModelList": null 1454 | }, 1455 | { 1456 | "code": 101211100, 1457 | "name": "舟山", 1458 | "url": "/chengshi/c101211100/", 1459 | "subLevelModelList": null 1460 | } 1461 | ] 1462 | }, 1463 | { 1464 | "code": 101220000, 1465 | "name": "安徽", 1466 | "url": "", 1467 | "subLevelModelList": [ 1468 | { 1469 | "code": 101220100, 1470 | "name": "合肥", 1471 | "url": "/hefei/", 1472 | "subLevelModelList": null 1473 | }, 1474 | { 1475 | "code": 101220200, 1476 | "name": "蚌埠", 1477 | "url": "/chengshi/c101220200/", 1478 | "subLevelModelList": null 1479 | }, 1480 | { 1481 | "code": 101220300, 1482 | "name": "芜湖", 1483 | "url": "/chengshi/c101220300/", 1484 | "subLevelModelList": null 1485 | }, 1486 | { 1487 | "code": 101220400, 1488 | "name": "淮南", 1489 | "url": "/chengshi/c101220400/", 1490 | "subLevelModelList": null 1491 | }, 1492 | { 1493 | "code": 101220500, 1494 | "name": "马鞍山", 1495 | "url": "/chengshi/c101220500/", 1496 | "subLevelModelList": null 1497 | }, 1498 | { 1499 | "code": 101220600, 1500 | "name": "安庆", 1501 | "url": "/chengshi/c101220600/", 1502 | "subLevelModelList": null 1503 | }, 1504 | { 1505 | "code": 101220700, 1506 | "name": "宿州", 1507 | "url": "/chengshi/c101220700/", 1508 | "subLevelModelList": null 1509 | }, 1510 | { 1511 | "code": 101220800, 1512 | "name": "阜阳", 1513 | "url": "/chengshi/c101220800/", 1514 | "subLevelModelList": null 1515 | }, 1516 | { 1517 | "code": 101220900, 1518 | "name": "亳州", 1519 | "url": "/chengshi/c101220900/", 1520 | "subLevelModelList": null 1521 | }, 1522 | { 1523 | "code": 101221000, 1524 | "name": "滁州", 1525 | "url": "/chengshi/c101221000/", 1526 | "subLevelModelList": null 1527 | }, 1528 | { 1529 | "code": 101221100, 1530 | "name": "淮北", 1531 | "url": "/chengshi/c101221100/", 1532 | "subLevelModelList": null 1533 | }, 1534 | { 1535 | "code": 101221200, 1536 | "name": "铜陵", 1537 | "url": "/chengshi/c101221200/", 1538 | "subLevelModelList": null 1539 | }, 1540 | { 1541 | "code": 101221300, 1542 | "name": "宣城", 1543 | "url": "/chengshi/c101221300/", 1544 | "subLevelModelList": null 1545 | }, 1546 | { 1547 | "code": 101221400, 1548 | "name": "六安", 1549 | "url": "/chengshi/c101221400/", 1550 | "subLevelModelList": null 1551 | }, 1552 | { 1553 | "code": 101221500, 1554 | "name": "池州", 1555 | "url": "/chengshi/c101221500/", 1556 | "subLevelModelList": null 1557 | }, 1558 | { 1559 | "code": 101221600, 1560 | "name": "黄山", 1561 | "url": "/chengshi/c101221600/", 1562 | "subLevelModelList": null 1563 | } 1564 | ] 1565 | }, 1566 | { 1567 | "code": 101230000, 1568 | "name": "福建", 1569 | "url": "", 1570 | "subLevelModelList": [ 1571 | { 1572 | "code": 101230100, 1573 | "name": "福州", 1574 | "url": "/fuzhou/", 1575 | "subLevelModelList": null 1576 | }, 1577 | { 1578 | "code": 101230200, 1579 | "name": "厦门", 1580 | "url": "/xiamen/", 1581 | "subLevelModelList": null 1582 | }, 1583 | { 1584 | "code": 101230300, 1585 | "name": "宁德", 1586 | "url": "/chengshi/c101230300/", 1587 | "subLevelModelList": null 1588 | }, 1589 | { 1590 | "code": 101230400, 1591 | "name": "莆田", 1592 | "url": "/chengshi/c101230400/", 1593 | "subLevelModelList": null 1594 | }, 1595 | { 1596 | "code": 101230500, 1597 | "name": "泉州", 1598 | "url": "/quanzhou/", 1599 | "subLevelModelList": null 1600 | }, 1601 | { 1602 | "code": 101230600, 1603 | "name": "漳州", 1604 | "url": "/chengshi/c101230600/", 1605 | "subLevelModelList": null 1606 | }, 1607 | { 1608 | "code": 101230700, 1609 | "name": "龙岩", 1610 | "url": "/chengshi/c101230700/", 1611 | "subLevelModelList": null 1612 | }, 1613 | { 1614 | "code": 101230800, 1615 | "name": "三明", 1616 | "url": "/chengshi/c101230800/", 1617 | "subLevelModelList": null 1618 | }, 1619 | { 1620 | "code": 101230900, 1621 | "name": "南平", 1622 | "url": "/chengshi/c101230900/", 1623 | "subLevelModelList": null 1624 | } 1625 | ] 1626 | }, 1627 | { 1628 | "code": 101240000, 1629 | "name": "江西", 1630 | "url": "", 1631 | "subLevelModelList": [ 1632 | { 1633 | "code": 101240100, 1634 | "name": "南昌", 1635 | "url": "/nanchang/", 1636 | "subLevelModelList": null 1637 | }, 1638 | { 1639 | "code": 101240200, 1640 | "name": "九江", 1641 | "url": "/chengshi/c101240200/", 1642 | "subLevelModelList": null 1643 | }, 1644 | { 1645 | "code": 101240300, 1646 | "name": "上饶", 1647 | "url": "/chengshi/c101240300/", 1648 | "subLevelModelList": null 1649 | }, 1650 | { 1651 | "code": 101240400, 1652 | "name": "抚州", 1653 | "url": "/chengshi/c101240400/", 1654 | "subLevelModelList": null 1655 | }, 1656 | { 1657 | "code": 101240500, 1658 | "name": "宜春", 1659 | "url": "/chengshi/c101240500/", 1660 | "subLevelModelList": null 1661 | }, 1662 | { 1663 | "code": 101240600, 1664 | "name": "吉安", 1665 | "url": "/chengshi/c101240600/", 1666 | "subLevelModelList": null 1667 | }, 1668 | { 1669 | "code": 101240700, 1670 | "name": "赣州", 1671 | "url": "/chengshi/c101240700/", 1672 | "subLevelModelList": null 1673 | }, 1674 | { 1675 | "code": 101240800, 1676 | "name": "景德镇", 1677 | "url": "/chengshi/c101240800/", 1678 | "subLevelModelList": null 1679 | }, 1680 | { 1681 | "code": 101240900, 1682 | "name": "萍乡", 1683 | "url": "/chengshi/c101240900/", 1684 | "subLevelModelList": null 1685 | }, 1686 | { 1687 | "code": 101241000, 1688 | "name": "新余", 1689 | "url": "/chengshi/c101241000/", 1690 | "subLevelModelList": null 1691 | }, 1692 | { 1693 | "code": 101241100, 1694 | "name": "鹰潭", 1695 | "url": "/chengshi/c101241100/", 1696 | "subLevelModelList": null 1697 | } 1698 | ] 1699 | }, 1700 | { 1701 | "code": 101250000, 1702 | "name": "湖南", 1703 | "url": "", 1704 | "subLevelModelList": [ 1705 | { 1706 | "code": 101250100, 1707 | "name": "长沙", 1708 | "url": "/changsha/", 1709 | "subLevelModelList": null 1710 | }, 1711 | { 1712 | "code": 101250200, 1713 | "name": "湘潭", 1714 | "url": "/chengshi/c101250200/", 1715 | "subLevelModelList": null 1716 | }, 1717 | { 1718 | "code": 101250300, 1719 | "name": "株洲", 1720 | "url": "/chengshi/c101250300/", 1721 | "subLevelModelList": null 1722 | }, 1723 | { 1724 | "code": 101250400, 1725 | "name": "衡阳", 1726 | "url": "/chengshi/c101250400/", 1727 | "subLevelModelList": null 1728 | }, 1729 | { 1730 | "code": 101250500, 1731 | "name": "郴州", 1732 | "url": "/chengshi/c101250500/", 1733 | "subLevelModelList": null 1734 | }, 1735 | { 1736 | "code": 101250600, 1737 | "name": "常德", 1738 | "url": "/chengshi/c101250600/", 1739 | "subLevelModelList": null 1740 | }, 1741 | { 1742 | "code": 101250700, 1743 | "name": "益阳", 1744 | "url": "/chengshi/c101250700/", 1745 | "subLevelModelList": null 1746 | }, 1747 | { 1748 | "code": 101250800, 1749 | "name": "娄底", 1750 | "url": "/chengshi/c101250800/", 1751 | "subLevelModelList": null 1752 | }, 1753 | { 1754 | "code": 101250900, 1755 | "name": "邵阳", 1756 | "url": "/chengshi/c101250900/", 1757 | "subLevelModelList": null 1758 | }, 1759 | { 1760 | "code": 101251000, 1761 | "name": "岳阳", 1762 | "url": "/chengshi/c101251000/", 1763 | "subLevelModelList": null 1764 | }, 1765 | { 1766 | "code": 101251100, 1767 | "name": "张家界", 1768 | "url": "/chengshi/c101251100/", 1769 | "subLevelModelList": null 1770 | }, 1771 | { 1772 | "code": 101251200, 1773 | "name": "怀化", 1774 | "url": "/chengshi/c101251200/", 1775 | "subLevelModelList": null 1776 | }, 1777 | { 1778 | "code": 101251300, 1779 | "name": "永州", 1780 | "url": "/chengshi/c101251300/", 1781 | "subLevelModelList": null 1782 | }, 1783 | { 1784 | "code": 101251400, 1785 | "name": "湘西土家族苗族自治州", 1786 | "url": "/chengshi/c101251400/", 1787 | "subLevelModelList": null 1788 | } 1789 | ] 1790 | }, 1791 | { 1792 | "code": 101260000, 1793 | "name": "贵州", 1794 | "url": "", 1795 | "subLevelModelList": [ 1796 | { 1797 | "code": 101260100, 1798 | "name": "贵阳", 1799 | "url": "/guiyang/", 1800 | "subLevelModelList": null 1801 | }, 1802 | { 1803 | "code": 101260200, 1804 | "name": "遵义", 1805 | "url": "/chengshi/c101260200/", 1806 | "subLevelModelList": null 1807 | }, 1808 | { 1809 | "code": 101260300, 1810 | "name": "安顺", 1811 | "url": "/chengshi/c101260300/", 1812 | "subLevelModelList": null 1813 | }, 1814 | { 1815 | "code": 101260400, 1816 | "name": "铜仁", 1817 | "url": "/chengshi/c101260400/", 1818 | "subLevelModelList": null 1819 | }, 1820 | { 1821 | "code": 101260500, 1822 | "name": "毕节", 1823 | "url": "/chengshi/c101260500/", 1824 | "subLevelModelList": null 1825 | }, 1826 | { 1827 | "code": 101260600, 1828 | "name": "六盘水", 1829 | "url": "/chengshi/c101260600/", 1830 | "subLevelModelList": null 1831 | }, 1832 | { 1833 | "code": 101260700, 1834 | "name": "黔东南苗族侗族自治州", 1835 | "url": "/chengshi/c101260700/", 1836 | "subLevelModelList": null 1837 | }, 1838 | { 1839 | "code": 101260800, 1840 | "name": "黔南布依族苗族自治州", 1841 | "url": "/chengshi/c101260800/", 1842 | "subLevelModelList": null 1843 | }, 1844 | { 1845 | "code": 101260900, 1846 | "name": "黔西南布依族苗族自治州", 1847 | "url": "/chengshi/c101260900/", 1848 | "subLevelModelList": null 1849 | } 1850 | ] 1851 | }, 1852 | { 1853 | "code": 101270000, 1854 | "name": "四川", 1855 | "url": "", 1856 | "subLevelModelList": [ 1857 | { 1858 | "code": 101270100, 1859 | "name": "成都", 1860 | "url": "/chengdu/", 1861 | "subLevelModelList": null 1862 | }, 1863 | { 1864 | "code": 101270200, 1865 | "name": "攀枝花", 1866 | "url": "/chengshi/c101270200/", 1867 | "subLevelModelList": null 1868 | }, 1869 | { 1870 | "code": 101270300, 1871 | "name": "自贡", 1872 | "url": "/chengshi/c101270300/", 1873 | "subLevelModelList": null 1874 | }, 1875 | { 1876 | "code": 101270400, 1877 | "name": "绵阳", 1878 | "url": "/chengshi/c101270400/", 1879 | "subLevelModelList": null 1880 | }, 1881 | { 1882 | "code": 101270500, 1883 | "name": "南充", 1884 | "url": "/chengshi/c101270500/", 1885 | "subLevelModelList": null 1886 | }, 1887 | { 1888 | "code": 101270600, 1889 | "name": "达州", 1890 | "url": "/chengshi/c101270600/", 1891 | "subLevelModelList": null 1892 | }, 1893 | { 1894 | "code": 101270700, 1895 | "name": "遂宁", 1896 | "url": "/chengshi/c101270700/", 1897 | "subLevelModelList": null 1898 | }, 1899 | { 1900 | "code": 101270800, 1901 | "name": "广安", 1902 | "url": "/chengshi/c101270800/", 1903 | "subLevelModelList": null 1904 | }, 1905 | { 1906 | "code": 101270900, 1907 | "name": "巴中", 1908 | "url": "/chengshi/c101270900/", 1909 | "subLevelModelList": null 1910 | }, 1911 | { 1912 | "code": 101271000, 1913 | "name": "泸州", 1914 | "url": "/chengshi/c101271000/", 1915 | "subLevelModelList": null 1916 | }, 1917 | { 1918 | "code": 101271100, 1919 | "name": "宜宾", 1920 | "url": "/chengshi/c101271100/", 1921 | "subLevelModelList": null 1922 | }, 1923 | { 1924 | "code": 101271200, 1925 | "name": "内江", 1926 | "url": "/chengshi/c101271200/", 1927 | "subLevelModelList": null 1928 | }, 1929 | { 1930 | "code": 101271300, 1931 | "name": "资阳", 1932 | "url": "/chengshi/c101271300/", 1933 | "subLevelModelList": null 1934 | }, 1935 | { 1936 | "code": 101271400, 1937 | "name": "乐山", 1938 | "url": "/chengshi/c101271400/", 1939 | "subLevelModelList": null 1940 | }, 1941 | { 1942 | "code": 101271500, 1943 | "name": "眉山", 1944 | "url": "/chengshi/c101271500/", 1945 | "subLevelModelList": null 1946 | }, 1947 | { 1948 | "code": 101271600, 1949 | "name": "雅安", 1950 | "url": "/chengshi/c101271600/", 1951 | "subLevelModelList": null 1952 | }, 1953 | { 1954 | "code": 101271700, 1955 | "name": "德阳", 1956 | "url": "/chengshi/c101271700/", 1957 | "subLevelModelList": null 1958 | }, 1959 | { 1960 | "code": 101271800, 1961 | "name": "广元", 1962 | "url": "/chengshi/c101271800/", 1963 | "subLevelModelList": null 1964 | }, 1965 | { 1966 | "code": 101271900, 1967 | "name": "阿坝藏族羌族自治州", 1968 | "url": "/chengshi/c101271900/", 1969 | "subLevelModelList": null 1970 | }, 1971 | { 1972 | "code": 101272000, 1973 | "name": "凉山彝族自治州", 1974 | "url": "/chengshi/c101272000/", 1975 | "subLevelModelList": null 1976 | }, 1977 | { 1978 | "code": 101272100, 1979 | "name": "甘孜藏族自治州", 1980 | "url": "/chengshi/c101272100/", 1981 | "subLevelModelList": null 1982 | } 1983 | ] 1984 | }, 1985 | { 1986 | "code": 101280000, 1987 | "name": "广东", 1988 | "url": "", 1989 | "subLevelModelList": [ 1990 | { 1991 | "code": 101280100, 1992 | "name": "广州", 1993 | "url": "/guangzhou/", 1994 | "subLevelModelList": null 1995 | }, 1996 | { 1997 | "code": 101280200, 1998 | "name": "韶关", 1999 | "url": "/chengshi/c101280200/", 2000 | "subLevelModelList": null 2001 | }, 2002 | { 2003 | "code": 101280300, 2004 | "name": "惠州", 2005 | "url": "/huizhou/", 2006 | "subLevelModelList": null 2007 | }, 2008 | { 2009 | "code": 101280400, 2010 | "name": "梅州", 2011 | "url": "/chengshi/c101280400/", 2012 | "subLevelModelList": null 2013 | }, 2014 | { 2015 | "code": 101280500, 2016 | "name": "汕头", 2017 | "url": "/shantou/", 2018 | "subLevelModelList": null 2019 | }, 2020 | { 2021 | "code": 101280600, 2022 | "name": "深圳", 2023 | "url": "/shenzhen/", 2024 | "subLevelModelList": null 2025 | }, 2026 | { 2027 | "code": 101280700, 2028 | "name": "珠海", 2029 | "url": "/zhuhai/", 2030 | "subLevelModelList": null 2031 | }, 2032 | { 2033 | "code": 101280800, 2034 | "name": "佛山", 2035 | "url": "/foshan/", 2036 | "subLevelModelList": null 2037 | }, 2038 | { 2039 | "code": 101280900, 2040 | "name": "肇庆", 2041 | "url": "/chengshi/c101280900/", 2042 | "subLevelModelList": null 2043 | }, 2044 | { 2045 | "code": 101281000, 2046 | "name": "湛江", 2047 | "url": "/chengshi/c101281000/", 2048 | "subLevelModelList": null 2049 | }, 2050 | { 2051 | "code": 101281100, 2052 | "name": "江门", 2053 | "url": "/jiangmen/", 2054 | "subLevelModelList": null 2055 | }, 2056 | { 2057 | "code": 101281200, 2058 | "name": "河源", 2059 | "url": "/chengshi/c101281200/", 2060 | "subLevelModelList": null 2061 | }, 2062 | { 2063 | "code": 101281300, 2064 | "name": "清远", 2065 | "url": "/chengshi/c101281300/", 2066 | "subLevelModelList": null 2067 | }, 2068 | { 2069 | "code": 101281400, 2070 | "name": "云浮", 2071 | "url": "/chengshi/c101281400/", 2072 | "subLevelModelList": null 2073 | }, 2074 | { 2075 | "code": 101281500, 2076 | "name": "潮州", 2077 | "url": "/chengshi/c101281500/", 2078 | "subLevelModelList": null 2079 | }, 2080 | { 2081 | "code": 101281600, 2082 | "name": "东莞", 2083 | "url": "/dongguan/", 2084 | "subLevelModelList": null 2085 | }, 2086 | { 2087 | "code": 101281700, 2088 | "name": "中山", 2089 | "url": "/zhongshan/", 2090 | "subLevelModelList": null 2091 | }, 2092 | { 2093 | "code": 101281800, 2094 | "name": "阳江", 2095 | "url": "/chengshi/c101281800/", 2096 | "subLevelModelList": null 2097 | }, 2098 | { 2099 | "code": 101281900, 2100 | "name": "揭阳", 2101 | "url": "/chengshi/c101281900/", 2102 | "subLevelModelList": null 2103 | }, 2104 | { 2105 | "code": 101282000, 2106 | "name": "茂名", 2107 | "url": "/chengshi/c101282000/", 2108 | "subLevelModelList": null 2109 | }, 2110 | { 2111 | "code": 101282100, 2112 | "name": "汕尾", 2113 | "url": "/chengshi/c101282100/", 2114 | "subLevelModelList": null 2115 | }, 2116 | { 2117 | "code": 101282200, 2118 | "name": "东沙群岛", 2119 | "url": "/chengshi/c101282200/", 2120 | "subLevelModelList": null 2121 | } 2122 | ] 2123 | }, 2124 | { 2125 | "code": 101290000, 2126 | "name": "云南", 2127 | "url": "", 2128 | "subLevelModelList": [ 2129 | { 2130 | "code": 101290100, 2131 | "name": "昆明", 2132 | "url": "/kunming/", 2133 | "subLevelModelList": null 2134 | }, 2135 | { 2136 | "code": 101290200, 2137 | "name": "曲靖", 2138 | "url": "/chengshi/c101290200/", 2139 | "subLevelModelList": null 2140 | }, 2141 | { 2142 | "code": 101290300, 2143 | "name": "保山", 2144 | "url": "/chengshi/c101290300/", 2145 | "subLevelModelList": null 2146 | }, 2147 | { 2148 | "code": 101290400, 2149 | "name": "玉溪", 2150 | "url": "/chengshi/c101290400/", 2151 | "subLevelModelList": null 2152 | }, 2153 | { 2154 | "code": 101290500, 2155 | "name": "普洱", 2156 | "url": "/chengshi/c101290500/", 2157 | "subLevelModelList": null 2158 | }, 2159 | { 2160 | "code": 101290700, 2161 | "name": "昭通", 2162 | "url": "/chengshi/c101290700/", 2163 | "subLevelModelList": null 2164 | }, 2165 | { 2166 | "code": 101290800, 2167 | "name": "临沧", 2168 | "url": "/chengshi/c101290800/", 2169 | "subLevelModelList": null 2170 | }, 2171 | { 2172 | "code": 101290900, 2173 | "name": "丽江", 2174 | "url": "/chengshi/c101290900/", 2175 | "subLevelModelList": null 2176 | }, 2177 | { 2178 | "code": 101291000, 2179 | "name": "西双版纳傣族自治州", 2180 | "url": "/chengshi/c101291000/", 2181 | "subLevelModelList": null 2182 | }, 2183 | { 2184 | "code": 101291100, 2185 | "name": "文山壮族苗族自治州", 2186 | "url": "/chengshi/c101291100/", 2187 | "subLevelModelList": null 2188 | }, 2189 | { 2190 | "code": 101291200, 2191 | "name": "红河哈尼族彝族自治州", 2192 | "url": "/chengshi/c101291200/", 2193 | "subLevelModelList": null 2194 | }, 2195 | { 2196 | "code": 101291300, 2197 | "name": "德宏傣族景颇族自治州", 2198 | "url": "/chengshi/c101291300/", 2199 | "subLevelModelList": null 2200 | }, 2201 | { 2202 | "code": 101291400, 2203 | "name": "怒江傈僳族自治州", 2204 | "url": "/chengshi/c101291400/", 2205 | "subLevelModelList": null 2206 | }, 2207 | { 2208 | "code": 101291500, 2209 | "name": "迪庆藏族自治州", 2210 | "url": "/chengshi/c101291500/", 2211 | "subLevelModelList": null 2212 | }, 2213 | { 2214 | "code": 101291600, 2215 | "name": "大理白族自治州", 2216 | "url": "/chengshi/c101291600/", 2217 | "subLevelModelList": null 2218 | }, 2219 | { 2220 | "code": 101291700, 2221 | "name": "楚雄彝族自治州", 2222 | "url": "/chengshi/c101291700/", 2223 | "subLevelModelList": null 2224 | } 2225 | ] 2226 | }, 2227 | { 2228 | "code": 101300000, 2229 | "name": "广西", 2230 | "url": "", 2231 | "subLevelModelList": [ 2232 | { 2233 | "code": 101300100, 2234 | "name": "南宁", 2235 | "url": "/nanning/", 2236 | "subLevelModelList": null 2237 | }, 2238 | { 2239 | "code": 101300200, 2240 | "name": "崇左", 2241 | "url": "/chengshi/c101300200/", 2242 | "subLevelModelList": null 2243 | }, 2244 | { 2245 | "code": 101300300, 2246 | "name": "柳州", 2247 | "url": "/chengshi/c101300300/", 2248 | "subLevelModelList": null 2249 | }, 2250 | { 2251 | "code": 101300400, 2252 | "name": "来宾", 2253 | "url": "/chengshi/c101300400/", 2254 | "subLevelModelList": null 2255 | }, 2256 | { 2257 | "code": 101300500, 2258 | "name": "桂林", 2259 | "url": "/chengshi/c101300500/", 2260 | "subLevelModelList": null 2261 | }, 2262 | { 2263 | "code": 101300600, 2264 | "name": "梧州", 2265 | "url": "/chengshi/c101300600/", 2266 | "subLevelModelList": null 2267 | }, 2268 | { 2269 | "code": 101300700, 2270 | "name": "贺州", 2271 | "url": "/chengshi/c101300700/", 2272 | "subLevelModelList": null 2273 | }, 2274 | { 2275 | "code": 101300800, 2276 | "name": "贵港", 2277 | "url": "/chengshi/c101300800/", 2278 | "subLevelModelList": null 2279 | }, 2280 | { 2281 | "code": 101300900, 2282 | "name": "玉林", 2283 | "url": "/chengshi/c101300900/", 2284 | "subLevelModelList": null 2285 | }, 2286 | { 2287 | "code": 101301000, 2288 | "name": "百色", 2289 | "url": "/chengshi/c101301000/", 2290 | "subLevelModelList": null 2291 | }, 2292 | { 2293 | "code": 101301100, 2294 | "name": "钦州", 2295 | "url": "/chengshi/c101301100/", 2296 | "subLevelModelList": null 2297 | }, 2298 | { 2299 | "code": 101301200, 2300 | "name": "河池", 2301 | "url": "/chengshi/c101301200/", 2302 | "subLevelModelList": null 2303 | }, 2304 | { 2305 | "code": 101301300, 2306 | "name": "北海", 2307 | "url": "/chengshi/c101301300/", 2308 | "subLevelModelList": null 2309 | }, 2310 | { 2311 | "code": 101301400, 2312 | "name": "防城港", 2313 | "url": "/chengshi/c101301400/", 2314 | "subLevelModelList": null 2315 | } 2316 | ] 2317 | }, 2318 | { 2319 | "code": 101310000, 2320 | "name": "海南", 2321 | "url": "", 2322 | "subLevelModelList": [ 2323 | { 2324 | "code": 101310100, 2325 | "name": "海口", 2326 | "url": "/haikou/", 2327 | "subLevelModelList": null 2328 | }, 2329 | { 2330 | "code": 101310200, 2331 | "name": "三亚", 2332 | "url": "/chengshi/c101310200/", 2333 | "subLevelModelList": null 2334 | }, 2335 | { 2336 | "code": 101310300, 2337 | "name": "三沙", 2338 | "url": "/chengshi/c101310300/", 2339 | "subLevelModelList": null 2340 | }, 2341 | { 2342 | "code": 101310400, 2343 | "name": "儋州", 2344 | "url": "/chengshi/c101310400/", 2345 | "subLevelModelList": null 2346 | }, 2347 | { 2348 | "code": 101310500, 2349 | "name": "五指山", 2350 | "url": "/chengshi/c101310500/", 2351 | "subLevelModelList": null 2352 | }, 2353 | { 2354 | "code": 101310600, 2355 | "name": "琼海", 2356 | "url": "/chengshi/c101310600/", 2357 | "subLevelModelList": null 2358 | }, 2359 | { 2360 | "code": 101310700, 2361 | "name": "文昌", 2362 | "url": "/chengshi/c101310700/", 2363 | "subLevelModelList": null 2364 | }, 2365 | { 2366 | "code": 101310800, 2367 | "name": "万宁", 2368 | "url": "/chengshi/c101310800/", 2369 | "subLevelModelList": null 2370 | }, 2371 | { 2372 | "code": 101310900, 2373 | "name": "东方", 2374 | "url": "/chengshi/c101310900/", 2375 | "subLevelModelList": null 2376 | }, 2377 | { 2378 | "code": 101311000, 2379 | "name": "定安", 2380 | "url": "/chengshi/c101311000/", 2381 | "subLevelModelList": null 2382 | }, 2383 | { 2384 | "code": 101311100, 2385 | "name": "屯昌", 2386 | "url": "/chengshi/c101311100/", 2387 | "subLevelModelList": null 2388 | }, 2389 | { 2390 | "code": 101311200, 2391 | "name": "澄迈", 2392 | "url": "/chengshi/c101311200/", 2393 | "subLevelModelList": null 2394 | }, 2395 | { 2396 | "code": 101311300, 2397 | "name": "临高", 2398 | "url": "/chengshi/c101311300/", 2399 | "subLevelModelList": null 2400 | }, 2401 | { 2402 | "code": 101311400, 2403 | "name": "白沙黎族自治县", 2404 | "url": "/chengshi/c101311400/", 2405 | "subLevelModelList": null 2406 | }, 2407 | { 2408 | "code": 101311500, 2409 | "name": "昌江黎族自治县", 2410 | "url": "/chengshi/c101311500/", 2411 | "subLevelModelList": null 2412 | }, 2413 | { 2414 | "code": 101311600, 2415 | "name": "乐东黎族自治县", 2416 | "url": "/chengshi/c101311600/", 2417 | "subLevelModelList": null 2418 | }, 2419 | { 2420 | "code": 101311700, 2421 | "name": "陵水黎族自治县", 2422 | "url": "/chengshi/c101311700/", 2423 | "subLevelModelList": null 2424 | }, 2425 | { 2426 | "code": 101311800, 2427 | "name": "保亭黎族苗族自治县", 2428 | "url": "/chengshi/c101311800/", 2429 | "subLevelModelList": null 2430 | }, 2431 | { 2432 | "code": 101311900, 2433 | "name": "琼中黎族苗族自治县", 2434 | "url": "/chengshi/c101311900/", 2435 | "subLevelModelList": null 2436 | } 2437 | ] 2438 | }, 2439 | { 2440 | "code": 101320000, 2441 | "name": "香港", 2442 | "url": "", 2443 | "subLevelModelList": [ 2444 | { 2445 | "code": 101320300, 2446 | "name": "香港", 2447 | "url": "/chengshi/c101320300/", 2448 | "subLevelModelList": null 2449 | } 2450 | ] 2451 | }, 2452 | { 2453 | "code": 101330000, 2454 | "name": "澳门", 2455 | "url": "", 2456 | "subLevelModelList": [ 2457 | { 2458 | "code": 101330100, 2459 | "name": "澳门", 2460 | "url": "/chengshi/c101330100/", 2461 | "subLevelModelList": null 2462 | } 2463 | ] 2464 | }, 2465 | { 2466 | "code": 101340000, 2467 | "name": "台湾", 2468 | "url": "", 2469 | "subLevelModelList": [ 2470 | { 2471 | "code": 101341100, 2472 | "name": "台湾", 2473 | "url": "/chengshi/c101341100/", 2474 | "subLevelModelList": null 2475 | } 2476 | ] 2477 | } 2478 | ] 2479 | --------------------------------------------------------------------------------