├── .gitignore ├── LICENSE ├── README.md ├── docker-compose.yml ├── docker ├── README.md └── services │ └── solver │ ├── entrypoint.sh │ └── solver.Dockerfile ├── images ├── CapSolver_banner.jpg ├── browser.png ├── help_menu.png └── server_console.png ├── pyproject.toml ├── requirements.txt ├── src └── turnstile_solver │ ├── __init__.py │ ├── __main__.py │ ├── browser_context_pool.py │ ├── constants.py │ ├── custom_rich_help_formatter.py │ ├── enums.py │ ├── main.py │ ├── page_pool.py │ ├── pool.py │ ├── proxy.py │ ├── proxy_provider.py │ ├── solver.py │ ├── solver_console.py │ ├── solver_console_highlighter.py │ ├── turnstile_result.py │ ├── turnstile_site.py │ ├── turnstile_solver_server.py │ └── utils.py └── tests ├── __init__.py ├── conftest.py ├── proxy_provider.py ├── pytest.ini └── test_solver.py /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | site-keys.txt 3 | *.env 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 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 General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Turnstile Solver 2 | 3 | Python server to automatically solve Cloudflare Turnstile CAPTCHA with an average solving time of two seconds (counting with a fast internet connection). 4 | 5 | PD: This repository was initially created for personal use. I've adjusted it for sharing, but it might still be slightly disorganized. Feel free to contribute, open issues, and request new features. 6 | 7 | --- 8 | 9 | ## Table of Content 10 | 11 | - [Screenshots](#screenshots) 12 | - [Install](#install) 13 | - [Patchright](#install-patchright-patched-chrome-browser) 14 | - [How to use](#how-to-use) 15 | - [Run server](#run-server) 16 | - [Global proxy](#use-global-browser-proxy) 17 | - [Proxy parameters](#load-proxy-parameters-from-environment-variables-all-caps) 18 | - [Proxy file](#use-a-proxy-from-file-per-browser-context) 19 | - [Get Token](#get-token) 20 | - [Curl](#curl) 21 | - [Python](#python) 22 | - [Docker](./docker/README.md) 23 | - [Disclaimer](#disclaimer) 24 | - [Sponsor](#sponsor) 25 | - [Donate](#donate) 26 | 27 | --- 28 | 29 | ## Screenshots 30 | 31 | TODO: Update 32 | ![Help Menu](images/help_menu.png) 33 | 34 | ![Server Console](images/server_console.png) 35 | 36 | ![Browser](images/browser.png) 37 | 38 | --- 39 | 40 | ## Install 41 | 42 | ```bash 43 | pip install git+https://github.com/odell0111/turnstile_solver@main 44 | ``` 45 | 46 | ### Install Patchright patched chrome browser 47 | 48 | ```bash 49 | patchright install chrome 50 | ``` 51 | 52 | Note: You can also install chromium but chrome is [recommended by Patchright](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright-python?tab=readme-ov-file#best-practice----use-chrome-without-fingerprint-injection) 53 | 54 | --- 55 | 56 | ## How to use 57 | 58 | ### Run server 59 | 60 | ```bash 61 | solver 62 | ``` 63 | 64 | ```bash 65 | solver --port 8088 --secret jWRN7DH6 --browser-position --max-attempts 3 --captcha-timeout 30 --page-load-timeout 30 --reload-on-overrun 66 | ``` 67 | 68 | #### Use global browser proxy 69 | 70 | ```bash 71 | solver --proxy-server http://myproxy.com:3128 --proxy-username user --proxy-password pass 72 | ``` 73 | 74 | ##### Load proxy parameters from environment variables (all caps) 75 | 76 | ```bash 77 | solver --proxy-server MY_PROXY_SERVER --proxy-username MY_PROXY_USERNAME --proxy-password MY_PROXY_PASSWORD 78 | ``` 79 | 80 | ##### Use a proxy from file per browser context 81 | 82 | ```bash 83 | solver --proxies myproxies.txt 84 | ``` 85 | 86 | ### Get token 87 | 88 | #### cURL 89 | 90 | ```bash 91 | curl --location --request GET 'http://127.0.0.1:8088/solve' \ 92 | --header 'ngrok-skip-browser-warning: _' \ 93 | --header 'secret: jWRN7DH6' \ 94 | --header 'Content-Type: application/json' \ 95 | --data '{ 96 | "site_url": "https://spotifydown.com", 97 | "site_key": "0x4AAAAAAAByvC31sFG0MSlp" 98 | }' 99 | ``` 100 | 101 | #### Python 102 | 103 | ```python 104 | import requests 105 | 106 | SERVER_URL = "http://127.0.0.1:8088" 107 | 108 | url = f"{SERVER_URL}/solve" 109 | 110 | headers = { 111 | 'ngrok-skip-browser-warning': '_', 112 | 'secret': 'jWRN7DH6', 113 | 'Content-Type': 'application/json' 114 | } 115 | 116 | json_data = { 117 | "site_url": "https://spotifydown.com", 118 | "site_key": "0x4AAAAAAAByvC31sFG0MSlp" 119 | } 120 | 121 | response = requests.get( 122 | url=url, 123 | headers=headers, 124 | json=json_data, 125 | ) 126 | 127 | response.raise_for_status() 128 | data = response.json() 129 | 130 | # { 131 | # "elapsed": "2.641519", 132 | # "message": null, 133 | # "status": "OK", 134 | # "token": "0.MwOLQ3dg..." 135 | # } 136 | 137 | token = data['token'] 138 | print("Token:", token) 139 | 140 | ``` 141 | 142 | --- 143 | 144 | ## 🐳 Container Management Guide 145 | goto [./docker/README.md](./docker/README.md) 146 | 147 | --- 148 | 149 | ## Disclaimer 150 | 151 | Use this project entirely at your own risk. I hold no responsibility for any negative outcomes, including but not limited to API blocking and IP bans 152 | 153 | --- 154 | 155 | ## Sponsor 156 | **Need to elevate your project?** 157 | Try **[CapSolver](https://www.capsolver.com/)**, an industry-leading RPA (Robotic Process Automation) service. From Cloudflare's anti-bot systems to hCaptcha, reCAPTCHA, and more, CapSolver delivers unmatched accuracy and speed to tackle even the toughest challenges. Trusted by developers worldwide for seamless automation. 158 | 159 | ![CapSolver](images/CapSolver_banner.jpg) 160 | 161 | 181 | 182 | --- 183 | 184 | ## Donate 185 | 186 | If you find my work useful and want to encourage further development, you can do so by donating 187 | 188 | ### [OxaPay](https://oxapay.com/donate/42319117) 189 | 190 | ### USDT (BEP20 - BSC) 191 | `0x88046e6d0f2bf8629cd7fbd754e4e275083fc993` 192 | 193 | ### USDT (SOL - Solana) 194 | `BL3QX5GtfXp8qha8PMLVwyud7gxB1aPE4Vsqotwscxsv` 195 | 196 | ### USDT (TRC20 - Tron) 197 | `TMpXigKBghRQmgYD53KyuxS38FH516ermu` 198 | 199 | ### BTC 200 | `1E9kw3FuaahfeproboNL7uvyBdjP9wY6CR` 201 | 202 | ### BTC (BEP20) 203 | `0x88046e6d0f2bf8629cd7fbd754e4e275083fc993` 204 | 205 | ### TON 206 | `UQCyCnWVYOmv97idVFZ4tIewToZacRhYVwfGNU658fN5w3Kl` 207 | 208 | ### Speed Lightning Address username 209 | `bytechanger@speed.app` -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | solver: 3 | container_name: solver 4 | hostname: solver 5 | build: 6 | context: ./docker/services/solver 7 | dockerfile: ./solver.Dockerfile 8 | platforms: 9 | - linux/amd64 10 | - linux/arm64 11 | args: 12 | BASE_IMAGE: ${BASE_IMAGE:-ubuntu:latest} 13 | restart: always 14 | ports: 15 | - ${SOLVER_SERVER_PORT:-8088}:${SOLVER_SERVER_PORT:-8088} 16 | - ${VNC_PORT:-5901}:${VNC_PORT:-5901} 17 | - ${XRDP_PORT:-3389}:${XRDP_PORT:-3389} 18 | networks: 19 | - solver 20 | environment: 21 | - DEBIAN_FRONTEND=noninteractive 22 | - LANG=en_US.UTF-8 23 | - LANGUAGE=en_US:en 24 | - LC_ALL=en_US.UTF-8 25 | - TZ=${TZ:-America/New_York} 26 | 27 | - SOLVER_SERVER_PORT=${SOLVER_SERVER_PORT:-8088} 28 | - SOLVER_BROWSER=${SOLVER_BROWSER:-chrome} 29 | - START_SERVER=${START_SERVER:-false} 30 | 31 | - REMOTE_DESKTOP_PROTOCOL=${REMOTE_DESKTOP_PROTOCOL:-VNC} 32 | - USER=${USER:-Perico} # Needed by VNC Server 33 | # VNC Server 34 | - VNC_PORT=${VNC_PORT:-5901} 35 | - VNC_PASSWORD=${VNC_PASSWORD:-123456789} 36 | - VNC_GEOMETRY=${VNC_GEOMETRY:-1280x720} 37 | - VNC_DPI=${VNC_DPI:-70} 38 | - VNC_DEPTH=${VNC_DEPTH:-24} 39 | - VNC_DISPLAY=${VNC_DISPLAY:-:1} 40 | # Xrdp 41 | - XRDP_PORT=${XRDP_PORT:-3389} 42 | networks: 43 | solver: 44 | # name: solver # Explicit name declaration can cause conflicts, let docker manage names unless required 45 | driver: bridge 46 | -------------------------------------------------------------------------------- /docker/README.md: -------------------------------------------------------------------------------- 1 | ## 🐳 Docker 2 | 3 | ### 🚧 Pre-Release Developer Notice 4 | 5 | **Important Context** 6 | This release has been marked as experimental because: 7 | ⚠️ **I have been unable to personally validate the build** due to: 8 | 9 | - Unstable power infrastructure 10 | - Unreliable internet connectivity 11 | 12 | **Your Contribution is needed !!** 13 | 14 | ### 🛠️ Build Instructions 15 | 16 | #### 1. Clone Repository 17 | 18 | ```bash 19 | git clone https://github.com/odell0111/turnstile_solver.git 20 | cd turnstile_solver 21 | ``` 22 | 23 | #### 2. Set-Up Custom Builder - MultiArch Build 24 | 25 | ```bash 26 | # 1. Initialize Buildx 27 | docker buildx create --use --name multiarch-builder 28 | docker buildx inspect --bootstrap 29 | 30 | # 2. Build and push multi-arch images 31 | docker buildx bake --set "*.platform=linux/amd64,linux/arm64" --set "*.tags=turnstile_solver/solver:latest" --push 32 | 33 | # AMD64 Build 34 | docker buildx build --platform linux/amd64 -t solver:amd64-latest . 35 | # ARM64 Build 36 | # docker buildx build --platform linux/arm64 -t solver:arm64-latest . 37 | 38 | # Multi-Arch Manifest 39 | docker buildx imagetools create -t solver:multiarch-latest solver:amd64-latest solver:arm64-latest 40 | ``` 41 | 42 | ##### Set-up optional .env file at `docker-compose.yml` level: 43 | 44 | - `TZ` - Set your [IANA timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). Example: Europe/London, Asia/Dubai). Default: **_America/New_York_** 45 | - `START_SERVER`: 46 | - **false** - Manual start required (default) 47 | - **true** - Auto-start with default config 48 | - `SOLVER_SERVER_PORT` - Turnstile Solver server port. Default: **_8088_** 49 | - `SOLVER_BROWSER` - Patchright browser to install and use on auto-start build: 50 | - **chrome** (default and recommended) 51 | - **chromium** 52 | - `USER` - Needed by VNC Server. Default: **_Perico_** 53 | - `REMOTE_DESKTOP_PROTOCOL`: 54 | - **RDP** - Xrdp 55 | - **VNC** - VNC/TightVNC 56 | - [any] - No start remote desktop server 57 | - TightVNC Server: 58 | - `VNC_PASSWORD` - Password. Default: **_12312312_** 59 | - `VNC_PORT` - Port. Default: **_5901_** 60 | - `VNC_GEOMETRY` - Geometry. Default: **_1280x720_** 61 | - `VNC_DPI` - DPI. Default: **_70_** 62 | - `VNC_DEPTH` - Depth. Default: **_24_** 63 | - Xrdp: 64 | - `XRDP_PORT` - Xrdp port. Default: **_3389_** 65 | 66 | #### 3. Platform-Specific Deployment 67 | 68 | ```bash 69 | # For AMD64 hosts 70 | docker compose --profile amd64 up 71 | 72 | # For ARM64 hosts 73 | #docker compose --profile arm64 up 74 | ``` 75 | 76 | ### 🔌 Remote Access Configuration 77 | 78 | **RDP**: 79 | 80 | 1. **Client Software**: 81 | - Windows: Built-in Remote Desktop Connection 82 | - Linux: `Remmina` or `FreeRDP` 83 | - macOS: Microsoft Remote Desktop 84 | 85 | 2. **Connection Details**: 86 | - Address (default): `localhost:3389` 87 | - Credentials: 88 | - Username: `root` 89 | - Password: `root` (❗Change after first login) 90 | 91 | ⚠️ **Security Notice**: Default credentials pose significant risk - change immediately after initial setup! 92 | 93 | **VNC/TightVNC**: 94 | 95 | 1. **Client Software**: 96 | - Windows: `RealVNC Viewer`, `TightVNC` (tvnviewer.exe) 97 | - Linux: `xtightvncviewer` (vncviewer/xtightvncviewer) 98 | - macOS: `?` # TODO 99 | 2. **Connection Details**: 100 | - Address (default): `localhost:5901` 101 | 102 | **Example connection with TightVNC Viewer on Windows**: 103 | 104 | ```cmd 105 | tvnviewer.exe 2.tcp.ngrok.io:17774 -password=12345678 -useclipboard=yes -mousecursor=no -jpegimagequality=2 -compressionlevel=2 106 | ``` 107 | 108 | 3. **Post-Connection (Start server with desired parameters)**: 109 | 110 | ```bash 111 | python3 solver 112 | ``` 113 | 114 | --- 115 | 116 | ### 🤔 VNC vs RDP 117 | 118 | **Protocol Comparison**: 119 | 120 | | Feature | RDP | VNC | 121 | |-----------------------|---------------------|---------------------| 122 | | Performance | ✅ Optimized | ⚠️ Bandwidth-heavy | 123 | | Security | ✅ Native encryption | 🔄 Depends on setup | 124 | | Cross-Platform | ✅ Excellent | ✅ Universal | 125 | | File Transfer | ✅ Built-in | ❌ Requires add-ons | 126 | | Multi-Monitor Support | ✅ Native | ✅ Possible | 127 | -------------------------------------------------------------------------------- /docker/services/solver/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | declare -r TARGET_USER="root" 4 | #declare -r WORKSPACE="/${TARGET_USER}/Desktop" 5 | declare -r REPO_URL="https://github.com/odell0111/turnstile_solver.git" 6 | 7 | start_vnc_server() { 8 | # Kill any previous previous session 9 | vncserver -kill "$VNC_DISPLAY" > /dev/null 2>&1 10 | 11 | # Build the base command arguments 12 | CMD="vncserver $VNC_DISPLAY \ 13 | -httpport $VNC_PORT \ 14 | -geometry $VNC_GEOMETRY \ 15 | -dpi $VNC_DPI \ 16 | -depth $VNC_DEPTH" 17 | echo "Starting VNC server with command: \"$CMD\"" 18 | 19 | # Generate input sequence (password x2 + confirm) 20 | INPUT_FEED=$(printf "%s\r\n%s\r\n\ry\r\n" "$VNC_PASSWORD" "$VNC_PASSWORD") 21 | 22 | # Execute and capture output 23 | eval "$CMD" <&1 24 | $INPUT_FEED 25 | EOF 26 | } 27 | 28 | start_xrdp() { 29 | # Clean PIDs 30 | find /var/run -name '*xrdp*.pid' -delete 31 | # Update port 32 | sed -E -i "s/port=[0-9]+/port=${XRDP_PORT}/g" /etc/xrdp/xrdp.ini 33 | xrdp-sesman & xrdp -n & 34 | } 35 | 36 | service_init() { 37 | 38 | if [ "$REMOTE_DESKTOP_PROTOCOL" = "RDP" ]; then 39 | (start_xrdp && echo "Xrdp running on port: ${XRDP_PORT}") || { 40 | echo "Xrdp failed to start" 41 | return 2 42 | } 43 | elif [ "$REMOTE_DESKTOP_PROTOCOL" = "VNC" ]; then 44 | (start_vnc_server && echo "TightVNC server running on port: ${VNC_SERVER_PORT}") || { 45 | echo "TightVNC server setup failed" 46 | return 3 47 | } 48 | fi 49 | 50 | # Wait up to 20s fot Xorg to start 51 | local timeout=20 52 | 53 | while ((timeout-- > 0)); do 54 | pgrep -x Xorg && { 55 | echo "Xorg started" 56 | return 0 57 | } 58 | sleep 1 59 | done 60 | echo "Xorg not started after 20s" 61 | return 1 62 | } 63 | 64 | user_setup() { 65 | if ! id "${TARGET_USER}" &>/dev/null; then 66 | getent group "${TARGET_USER}" || groupadd "${TARGET_USER}" 67 | useradd -mUs "/bin/bash" -G "${TARGET_USER},sudo" "${TARGET_USER}" || return 1 68 | fi 69 | echo "${TARGET_USER}:${TARGET_USER}" | chpasswd || return 1 70 | } 71 | 72 | env_config() { 73 | [[ -n "${TZ}" ]] && { 74 | ln -sf "/usr/share/zoneinfo/${TZ}" /etc/localtime 75 | echo "${TZ}" >/etc/timezone 76 | } 77 | # mkdir -p "${WORKSPACE}" 78 | } 79 | 80 | repo_setup() { 81 | # ( cd "${WORKSPACE}" && git clone -q "${REPO_URL}" && cd turnstile_solver || exit 2 82 | # pip3 install -r requirements.txt --break-system-packages ) 83 | pip3 install "git+${REPO_URL}@main" --no-cache-dir --break-system-packages || { 84 | echo "Repo setup failed" 85 | return 2 86 | } 87 | echo "repo set-up done" 88 | } 89 | 90 | install_patchright() { 91 | # Install patchright (with PEP 668 workaround) 92 | pip3 install --no-cache-dir --break-system-packages patchright || { 93 | echo "Failed to install patchright" 94 | return 3 95 | } 96 | 97 | # Install browser 98 | # patchright install --force "$SOLVER_BROWSER" || { 99 | # echo "Failed to install Patchright browser: $SOLVER_BROWSER" 100 | # return 4 101 | # } 102 | echo "Patchright installed" 103 | } 104 | 105 | # Execution flow 106 | user_setup || { echo "User config failed"; exit 1; } 107 | env_config || exit 2 108 | repo_setup || exit 3 109 | install_patchright || exit 4 110 | service_init || exit 6 111 | 112 | if [ "$START_SERVER" = "true" ]; then 113 | echo "Starting server in headful mode..." 114 | xvfb-run -a python3 solver --browser "${SOLVER_BROWSER}" --port "${SOLVER_SERVER_PORT}" 115 | # xvfb-run -a python3 "${WORKSPACE}/turnstile_solver/main.py" 116 | fi 117 | -------------------------------------------------------------------------------- /docker/services/solver/solver.Dockerfile: -------------------------------------------------------------------------------- 1 | ARG BASE_IMAGE=ubuntu:latest 2 | FROM --platform=$BUILDPLATFORM ${BASE_IMAGE} AS builder 3 | 4 | # Multi-stage build setup 5 | FROM --platform=$TARGETPLATFORM ${BASE_IMAGE} 6 | 7 | # Copy architecture-specific binaries 8 | COPY --from=builder /build-output /app 9 | 10 | # Configure locale and timezone 11 | RUN apt-get update && \ 12 | apt-get upgrade -y && \ 13 | apt-get install --no-install-recommends -y tzdata locales 14 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen 15 | RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone 16 | RUN echo "LC_ALL=en_US.UTF-8" >> /etc/environment 17 | RUN echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen 18 | RUN echo "LANG=en_US.UTF-8" > /etc/locale.conf 19 | RUN locale-gen en_US.UTF-8 20 | 21 | # Install system dependencies 22 | RUN apt-get update && \ 23 | apt-get install -y --no-install-recommends \ 24 | ca-certificates \ 25 | curl \ 26 | git \ 27 | python3-pip \ 28 | xorgxrdp \ 29 | xrdp \ 30 | tightvncserver \ 31 | xfonts-75dpi \ 32 | xfonts-100dpi \ 33 | xfonts-base \ 34 | xvfb \ 35 | wget \ 36 | screen \ 37 | sudo \ 38 | xfce4 \ 39 | xfce4-goodies \ 40 | dbus-x11 \ 41 | xfce4-terminal 42 | 43 | # Clean up 44 | RUN apt remove -y light-locker xscreensaver && \ 45 | apt autoremove -y && \ 46 | rm -rf /var/cache/apt /var/lib/apt/lists 47 | 48 | # Copy entrypoint script 49 | COPY ./entrypoint.sh /usr/local/bin/ 50 | RUN chmod +x /usr/local/bin/entrypoint.sh 51 | 52 | # Healthcheck (adjust as needed) 53 | #HEALTHCHECK --interval=30s --timeout=10s --retries=3 \ 54 | # CMD netstat -an | grep $XRDP_PORT >/dev/null || exit 1 55 | 56 | # Set entrypoint 57 | ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] 58 | -------------------------------------------------------------------------------- /images/CapSolver_banner.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odell0111/turnstile_solver/488a6b84a8cb80efed9468fa688b89c33f2bea33/images/CapSolver_banner.jpg -------------------------------------------------------------------------------- /images/browser.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odell0111/turnstile_solver/488a6b84a8cb80efed9468fa688b89c33f2bea33/images/browser.png -------------------------------------------------------------------------------- /images/help_menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odell0111/turnstile_solver/488a6b84a8cb80efed9468fa688b89c33f2bea33/images/help_menu.png -------------------------------------------------------------------------------- /images/server_console.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odell0111/turnstile_solver/488a6b84a8cb80efed9468fa688b89c33f2bea33/images/server_console.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 75.6.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | #dynamic = ["version"] 7 | dynamic = ["dependencies"] 8 | name = "turnstile_solver" 9 | version = "3.16" 10 | description = "Python server to automatically solve Cloudflare Turnstile CAPTCHA with an average solving time of two seconds" 11 | readme = "README.md" 12 | authors = [{ name = "OGM" }] 13 | license = { file = "LICENSE" } 14 | classifiers = [ 15 | "License :: OSI Approved :: MIT License", 16 | "Programming Language :: Python", 17 | "Programming Language :: Python :: 3", 18 | "Operating System :: OS Independent" 19 | ] 20 | # keywords = [ ] 21 | 22 | requires-python = ">=3.10" 23 | 24 | [project.urls] 25 | Repository = "https://github.com/odell0111/turnstile_solver" 26 | 27 | [tool.setuptools.dynamic] 28 | dependencies = { file = ["requirements.txt"] } 29 | 30 | #[tool.setuptools] 31 | #py-modules = [] 32 | 33 | [project.scripts] 34 | solver = "turnstile_solver:main_cli" 35 | 36 | # [tool.setuptools] 37 | # ... 38 | # By default, include-package-data is true in pyproject.toml, so you do 39 | # NOT have to specify this line. 40 | # include-package-data = true 41 | 42 | [tool.setuptools.packages.find] 43 | where = ["src"] 44 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | rich 3 | asyncio 4 | argparse 5 | rich_argparse 6 | patchright 7 | Quart 8 | Faker 9 | pyngrok 10 | requests 11 | python-dotenv 12 | -------------------------------------------------------------------------------- /src/turnstile_solver/__init__.py: -------------------------------------------------------------------------------- 1 | from turnstile_solver.solver import TurnstileSolver 2 | from turnstile_solver.turnstile_solver_server import TurnstileSolverServer 3 | from turnstile_solver.constants import HOST, PORT 4 | from turnstile_solver.main import main, run_server, main_cli 5 | -------------------------------------------------------------------------------- /src/turnstile_solver/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from turnstile_solver.main import main 4 | 5 | if __name__ == '__main__': 6 | asyncio.run(main()) 7 | -------------------------------------------------------------------------------- /src/turnstile_solver/browser_context_pool.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from typing import TYPE_CHECKING 4 | 5 | from patchright.async_api import Browser 6 | 7 | from turnstile_solver.constants import MAX_PAGES_PER_CONTEXT, MAX_CONTEXTS 8 | from turnstile_solver.page_pool import PagePool 9 | from turnstile_solver.pool import Pool 10 | from turnstile_solver.proxy_provider import ProxyProvider 11 | 12 | if TYPE_CHECKING: 13 | from turnstile_solver.solver import TurnstileSolver 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class BrowserContextPool(Pool): 19 | def __init__(self, 20 | solver: "TurnstileSolver", 21 | max_contexts: int = MAX_CONTEXTS, 22 | max_pages_per_context: int = MAX_PAGES_PER_CONTEXT, 23 | single_instance: bool = False, 24 | proxy_provider: ProxyProvider | None = None, 25 | ): 26 | self._solver = solver 27 | self._browser: Browser | None = None 28 | self._max_pages_per_context = max_pages_per_context 29 | self._get_lock = asyncio.Lock() 30 | self._playwright = None 31 | self._proxy_provider = proxy_provider 32 | self._single_instance = single_instance 33 | 34 | super().__init__( 35 | size=max_contexts, 36 | item_getter=self._page_pool_getter, 37 | ) 38 | 39 | @property 40 | def browser(self) -> Browser | None: 41 | return self._browser 42 | 43 | async def init(self): 44 | self._browser, _ = await self._solver.get_browser(None) 45 | 46 | async def get(self) -> PagePool: 47 | 48 | if not self._browser: 49 | raise RuntimeError("'self._browser' instance has not been assigned. Make sure to call init() method at least once") 50 | 51 | # logger.debug('Acquiring lock to fetch PagePool') 52 | async with self._get_lock: 53 | # logger.debug('Fetching PagePool') 54 | for pool in self.in_use: 55 | if not pool.is_full: 56 | # logger.debug(f"Reusing PagePool (size = {pool.size})") 57 | return pool 58 | # logger.debug("Getting PagePool from pool manager") 59 | return await super().get() 60 | 61 | async def _page_pool_getter(self): 62 | proxy = self._proxy_provider.get() if self._proxy_provider else None 63 | proxy and logger.debug(f"Using proxy: '{proxy.server}'") 64 | logger.debug(f"Getting browser context for browser: '{self._browser}'") 65 | context, self._playwright = await self._solver.get_browser_context( 66 | browser=self._browser if self._single_instance else None, 67 | playwright=self._playwright, 68 | proxy=proxy, 69 | ) 70 | pool = PagePool(context, self._max_pages_per_context) 71 | return pool 72 | -------------------------------------------------------------------------------- /src/turnstile_solver/constants.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | from dotenv import load_dotenv 5 | 6 | load_dotenv() 7 | 8 | HTML_TEMPLATE = ''' 9 | 10 | 11 | 12 | 13 | 14 | Turnstile Solver 15 | 33 | 37 | 38 | 39 |
40 | 41 | 42 | ''' 43 | 44 | TOKEN_JS_SELECTOR = "document.querySelector('[name=cf-turnstile-response]')?.value" 45 | 46 | PROJECT_HOME_DIR = Path.home() / '.turnstile_solver' 47 | 48 | HOST = "0.0.0.0" 49 | PORT = 8088 50 | CAPTCHA_EVENT_CALLBACK_ENDPOINT = '/api_js_message_callback' 51 | 52 | SECRET = "jWRN7DH6" 53 | 54 | MAX_ATTEMPTS_TO_SOLVE_CAPTCHA = 3 55 | CAPTCHA_ATTEMPT_TIMEOUT = 15 56 | MAX_CONTEXTS = 40 57 | MAX_PAGES_PER_CONTEXT = 2 58 | PAGE_LOAD_TIMEOUT = 20 59 | BROWSER_POSITION = 2000, 2000 60 | BROWSER = "chrome" 61 | BROWSERS = [ 62 | "chrome", 63 | "chromium", 64 | # "msedge", 65 | ] 66 | 67 | CONSOLE_THEME_STYLES = { 68 | # Overrides 69 | "json.key": "#FFFFFF", 70 | "json.null": "#BCBEC4", 71 | "json.bool_true": "#00FF00", 72 | "json.bool_false": "#FF0000", 73 | "repr.url": "not bold not italic #64B5F6", 74 | 'log.time': 'magenta', 75 | 'logging.keyword': 'bold yellow', 76 | 'logging.level.critical': 'bold reverse red', 77 | 'logging.level.debug': 'green', 78 | 'logging.level.error': 'bold red', 79 | 'logging.level.info': 'cyan', 80 | 'logging.level.notset': 'dim', 81 | 'logging.level.warning': '#FFE600', 82 | 83 | "repr.author": "bold #FFFFFF", 84 | "repr.version": "bold italic", 85 | "repr.projectname": "bold italic blink #FFFFFF", 86 | } 87 | 88 | # Environment 89 | NGROK_TOKEN = os.environ.get('NGROK_TOKEN') 90 | -------------------------------------------------------------------------------- /src/turnstile_solver/custom_rich_help_formatter.py: -------------------------------------------------------------------------------- 1 | from rich.console import RenderableType 2 | from rich_argparse import RichHelpFormatter 3 | 4 | 5 | class CustomRichHelpFormatter(RichHelpFormatter): 6 | def add_renderable(self, renderable: RenderableType) -> None: 7 | # from rich.padding import Padding 8 | # renderable = Padding.indent(renderable, self._current_indent) 9 | self._current_section.rich_items.append(renderable) 10 | -------------------------------------------------------------------------------- /src/turnstile_solver/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class CaptchaApiMessageEvent(Enum): 5 | INIT = "init" 6 | TRANSLATION_INIT = "translationInit" 7 | LANGUAGE_UNSUPPORTED = "languageUnsupported" 8 | REJECT = "reject" 9 | FOOD = "food" 10 | OVERRUN_BEGIN = "overrunBegin" 11 | OVERRUN_END = "overrunEnd" 12 | COMPLETE = "complete" 13 | FAIL = "fail" 14 | FEEDBACK_INIT = "feedbackInit" 15 | REQUEST_FEEDBACK_DATA = "requestFeedbackData" 16 | TURNSTILE_RESULTS = "turnstileResults" 17 | CLOSE_FEEDBACK_REPORT_IFRAME = "closeFeedbackReportIframe" 18 | TOKEN_EXPIRED = "tokenExpired" 19 | INTERACTIVE_TIMEOUT = "interactiveTimeout" 20 | REFRESH_REQUEST = "refreshRequest" 21 | RELOAD_REQUEST = "reloadRequest" 22 | INTERACTIVE_BEGIN = "interactiveBegin" 23 | INTERACTIVE_END = "interactiveEnd" 24 | WIDGET_STALE = "widgetStale" 25 | REQUEST_EXTRA_PARAMS = "requestExtraParams" 26 | -------------------------------------------------------------------------------- /src/turnstile_solver/main.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | import argparse 5 | import random 6 | import time 7 | 8 | import dotenv 9 | from pathlib import Path 10 | from threading import Thread 11 | 12 | import requests 13 | from pyngrok import ngrok 14 | from pyngrok.ngrok import NgrokTunnel 15 | from rich import traceback 16 | from rich.align import Align 17 | from rich.console import Group 18 | from rich.text import Text 19 | from multiprocessing import Process 20 | 21 | import turnstile_solver.constants as c 22 | from turnstile_solver.proxy import Proxy 23 | from turnstile_solver.proxy_provider import ProxyProvider 24 | from turnstile_solver.custom_rich_help_formatter import CustomRichHelpFormatter 25 | from turnstile_solver.solver_console import SolverConsole 26 | from turnstile_solver.solver_console_highlighter import SolverConsoleHighlighter 27 | from turnstile_solver.solver import TurnstileSolver 28 | from turnstile_solver.utils import init_logger, simulate_intensive_task, get_file_handler, load_proxy_param 29 | from turnstile_solver.turnstile_solver_server import TurnstileSolverServer 30 | 31 | _console = SolverConsole() 32 | 33 | __pname__ = "Turnstile Solver" 34 | 35 | # mdata = metadata.metadata(__pname__) 36 | # __version__ = mdata['Version'] 37 | __version__ = "3.16b" 38 | __homepage__ = "https://github.com/odell0111/turnstile_solver" # mdata['Home-page'] 39 | __author__ = "OGM" # mdata['Author'] 40 | __summary__ = "Automatically solve Cloudflare Turnstile captcha" # mdata['Summary'] 41 | 42 | logger = logging.getLogger(__name__) 43 | 44 | 45 | def _parse_arguments(): 46 | def conditionalTitleCase(s): 47 | return ' '.join(word if word.isupper() else word.title() for word in s.split()) 48 | 49 | CustomRichHelpFormatter.group_name_formatter = conditionalTitleCase 50 | 51 | description = [ 52 | f"{__pname__}", 53 | f"v{__version__}", 54 | f'by {__author__}', 55 | '2024-2025' 56 | ] 57 | 58 | # noinspection PyTypeChecker 59 | parser = argparse.ArgumentParser( 60 | usage="\n %(prog)s [options]", 61 | description=Group(*[Align(line, align='center') for line in description]), 62 | epilog=Align(Text(__homepage__, style=c.CONSOLE_THEME_STYLES['repr.url']), align='center'), 63 | add_help=False, 64 | formatter_class=CustomRichHelpFormatter 65 | ) 66 | 67 | def positive_integer(value): 68 | try: 69 | value = int(value) 70 | if value <= 0: 71 | raise argparse.ArgumentTypeError(f'{value} is not a positive integer') 72 | except ValueError: 73 | raise argparse.ArgumentTypeError(f'"{value}" is not an integer') 74 | return value 75 | 76 | def positive_float(value): 77 | try: 78 | value = float(value) 79 | if value <= 0: 80 | raise argparse.ArgumentTypeError(f"{value} is not a positive number") 81 | except ValueError: 82 | raise argparse.ArgumentTypeError(f'"{value}" is not a number') 83 | return value 84 | 85 | def positive_float_exclusive(value): 86 | try: 87 | value = float(value) 88 | if value != -1 and value <= 0: 89 | raise argparse.ArgumentTypeError(f"{value} is not a positive number") 90 | except ValueError: 91 | raise argparse.ArgumentTypeError(f'"{value}" is not a number') 92 | return value 93 | 94 | def positive_integer_exclusive(value): 95 | try: 96 | value = int(value) 97 | if value != -1 and value <= 0: 98 | raise argparse.ArgumentTypeError(f"{value} is not a positive integer") 99 | except ValueError: 100 | raise argparse.ArgumentTypeError(f'"{value}" is not an integer') 101 | return value 102 | 103 | parser.add_argument("-bep", "--browser-executable-path", help=f"Chromium-based browser executable path. If not specified, Patchright (Playwright) will attempt to use its bundled version. Ensure you are using a Chromium-based browser installed with the command `patchright install chromium`. Other browsers may be detected by Cloudflare, which could result in the CAPTCHA not being solved.") 104 | parser.add_argument("-b", "--browser", default="chrome", choices=c.BROWSERS, help=f"Either {c.BROWSER}. Use this argument to autodetect chrome executable path. Default: {c.BROWSER}.") 105 | parser.add_argument("-bp", "--browser-position", type=int, nargs='*', metavar="x|y", default=c.BROWSER_POSITION, help=f"Browser position x, y. Default: {c.BROWSER_POSITION}. If the browser window is positioned beyond the screen's resolution, it will be inaccessible, behaving similar to headless mode.") 106 | parser.add_argument("-mbi", "--multiple-browser-instances", action='store_true', help=f"Whether to use a new browser instance for each context or not. This is not recommended since it can occupy a lot more memory. Also the initialization process for each instance can take a little more time so when running for production it's recommended to make some requests to initialize some instances. See '--max-contexts'.") 107 | parser.add_argument("-mc", "--max-contexts", type=int, metavar="N", default=c.MAX_CONTEXTS, help=f"Max browser contexts. Default: {c.MAX_CONTEXTS}. Memory consumption increases proportionally with the number of browser contexts, specially if a new browser instance is created for each browser context.") 108 | parser.add_argument("-mp", "--max-pages", type=int, metavar="N", default=c.MAX_PAGES_PER_CONTEXT, help=f"Max pages per browser. Default: {c.MAX_PAGES_PER_CONTEXT}. CAPTCHA-solving speed is impacted by the number of active pages (tabs) within a browser context.") 109 | parser.add_argument("-ba", "--browser-args", nargs='+', help=f"Additional browser command line arguments.") 110 | 111 | parser.add_argument("-ps", "--proxy-server", help=f"Global browser proxy server in the format: 'scheme://server:port'. Ex: http://myproxy.com:3128") 112 | parser.add_argument("-pun", "--proxy-username", help=f"Global browser proxy username: Use all caps to load from environment variables.") 113 | parser.add_argument("-pp", "--proxy-password", help=f"Global browser proxy password: Use all caps to load from environment variables.") 114 | parser.add_argument("--proxies", metavar='PROXIES.txt', help=f"Path to a .txt file containing proxies in the format: 'scheme://server:port@username:password'. Each browser context will be assigned a unique proxy from this list.") 115 | 116 | parser.add_argument("-nfl", "--no-file-logs", action="store_true", help=f"Do not log to file '$HOME.turnstile_solver/logs.log'.") 117 | parser.add_argument("-ll", "--log-level", type=int, default=logging.INFO, metavar="N", help=f"Global logger log level. Default: {logging.INFO}. CRITICAL = 50, FATAL = CRITICAL, ERROR = 40, WARNING = 30, INFO = 20, DEBUG = 10, NOTSET = 0") 118 | 119 | # Solver 120 | solver = parser.add_argument_group("Solver") 121 | solver.add_argument("-ma", "--max-attempts", type=positive_integer, metavar="N", default=c.MAX_ATTEMPTS_TO_SOLVE_CAPTCHA, help=f"Max attempts to perform to solve captcha. Default: {c.MAX_ATTEMPTS_TO_SOLVE_CAPTCHA}.") 122 | solver.add_argument("-cto", "--captcha-timeout", type=positive_float, metavar="N.", default=c.CAPTCHA_ATTEMPT_TIMEOUT, help=f"Max time to wait for captcha to solve before reloading page. Default: {c.CAPTCHA_ATTEMPT_TIMEOUT} seconds.") 123 | solver.add_argument("-plto", "--page-load-timeout", type=positive_float, metavar="N.", default=c.CAPTCHA_ATTEMPT_TIMEOUT, help=f"Page load timeout. Default: {c.PAGE_LOAD_TIMEOUT} seconds.") 124 | solver.add_argument("-roo", "--reload-on-overrun", action="store_true", help=f"Reload page on captcha overrun event.") 125 | solver.add_argument("-sll", "--solver-log-level", type=int, default=logging.INFO, metavar="N", help=f"TurnstileSolver log level. Default: {logging.INFO}. CRITICAL = 50, FATAL = CRITICAL, ERROR = 40, WARNING = 30, INFO = 20, DEBUG = 10, NOTSET = 0") 126 | 127 | # Server 128 | server = parser.add_argument_group("Server") 129 | server.add_argument("--host", default=c.HOST, help=f"Local host address. Default: {c.HOST}.") 130 | server.add_argument("--port", type=positive_integer, metavar="N", default=c.PORT, help=f"Local port. Default: {c.PORT}.") 131 | server.add_argument("-s", "--secret", default=c.SECRET, help=f"Server secret. Default: {c.SECRET}.") 132 | server.add_argument("-lal", "--log-access-logs", action="store_true", help=f"Log server access logs.") 133 | server.add_argument("-svll", "--server-log-level", type=int, default=logging.INFO, metavar="N", help=f"TurnstileSolverServer log level. Default: {logging.INFO}") 134 | server.add_argument("-ife", "--ignore-food-events", action="store_true", help=f"Do not log CAPTCHA foot events when server log level is DEBUG or below.") 135 | 136 | parser.add_argument("-p", "--production", action="store_true", help=f"Whether the project is running in a production environment or on a resource-constrained server, such as one that spins down during periods of inactivity.") 137 | parser.add_argument("-nn", "--no-ngrok", action="store_true", help=f"Do not use ngrok for keeping server alive on production.") 138 | parser.add_argument("-ncomp", "--no-computations", action="store_true", help=f"Do not simulate intensive computations for keeping server alive on production.") 139 | parser.add_argument("--headless", action="store_true", help=f"Open browser in headless mode. [#ffc800]WARNING[/]: This feature has never worked so far, captcha always fail! It's here only in case it works on future version of Playwright.") 140 | 141 | # Miscellaneous Options 142 | misc = parser.add_argument_group("Miscellaneous") 143 | misc.add_argument("-h", "--help", action="help", default=argparse.SUPPRESS, help='Show this help message.') 144 | misc.add_argument("-v", "--version", action='version', version=f'{__pname__} v{__version__}', help="Show version and exit.") 145 | 146 | return parser.parse_args() 147 | 148 | 149 | def _add_help_custom_highlights(): 150 | from rich.default_styles import DEFAULT_STYLES 151 | CustomRichHelpFormatter.styles["argparse.args"] = "#00FFFF" 152 | # Highlight rich.highlighter.ReprHighlighter 153 | CustomRichHelpFormatter.styles |= {f"argparse.{key.split('.')[1]}": val for key, val in DEFAULT_STYLES.items() if key.startswith('repr.')} 154 | CustomRichHelpFormatter.console = _console 155 | CustomRichHelpFormatter.highlights.extend(SolverConsoleHighlighter.highlights) 156 | 157 | 158 | def _start_ngrok_tunnel() -> NgrokTunnel: 159 | # Set auth-token 160 | ngrok.set_auth_token(c.NGROK_TOKEN) 161 | 162 | # Kill active sessions if any 163 | ngrok.kill() 164 | 165 | tunnel: NgrokTunnel = ngrok.connect(str(c.PORT), "http") 166 | logger.info(tunnel) 167 | return tunnel 168 | 169 | 170 | def _keep_server_alive( 171 | use_ngrok: bool = True, 172 | perform_computations: bool = True, 173 | ngrok_url: str | None = None, 174 | secret: str = c.SECRET, 175 | ): 176 | 177 | if not (use_ngrok or perform_computations): 178 | raise RuntimeError("You must either use Ngrok, perform computations, or both to keep server alive") 179 | 180 | def withNgrok(): 181 | time.sleep(10) 182 | while True: 183 | response = requests.get(ngrok_url, headers={"ngrok-skip-browser-warning": "whatever", "secret": secret}) 184 | response.raise_for_status() 185 | time.sleep(random.uniform(10, 30)) 186 | 187 | def withComputations(): 188 | while True: 189 | simulate_intensive_task(5000, 10000) 190 | time.sleep(random.uniform(0.01, 0.5)) 191 | 192 | try: 193 | threads = [] 194 | if use_ngrok: 195 | threads.append(t := Thread(target=withNgrok, daemon=True)) 196 | t.start() 197 | if perform_computations: 198 | threads.append(t := Thread(target=withComputations, daemon=True)) 199 | t.start() 200 | for t in threads: 201 | t.join() 202 | except (SystemExit, KeyboardInterrupt): 203 | pass 204 | except Exception as e: 205 | logger.error(f"keep_it_breathing interrupted with exception: {e}") 206 | 207 | 208 | async def run_server( 209 | # Production 210 | production: bool = False, 211 | use_ngrok: bool = True, 212 | perform_computations: bool = True, 213 | max_contexts: int = c.MAX_CONTEXTS, 214 | max_pages_per_context: int = c.MAX_PAGES_PER_CONTEXT, 215 | single_browser_instance: bool = False, 216 | proxy_provider: ProxyProvider | None = None, 217 | 218 | console: SolverConsole | None = SolverConsole(), 219 | 220 | # TurnstileSolverServer 221 | host: str = c.HOST, 222 | port: int = c.PORT, 223 | disable_access_logs: bool = True, 224 | ignore_food_events: bool = False, 225 | server_log_level: int | str = logging.INFO, 226 | secret: str = c.SECRET, 227 | 228 | # TurnstileSolver 229 | page_load_timeout: float = c.PAGE_LOAD_TIMEOUT, 230 | browser_position: tuple[int, int] = c.BROWSER_POSITION, 231 | browser_executable_path: str | Path | None = None, 232 | browser: str = c.BROWSER, 233 | reload_page_on_captcha_overrun_event: bool = False, 234 | max_attempts: int = c.MAX_ATTEMPTS_TO_SOLVE_CAPTCHA, 235 | attempt_timeout: int = c.CAPTCHA_ATTEMPT_TIMEOUT, 236 | headless: bool = False, 237 | solver_log_level: int | str = logging.INFO, 238 | proxy: Proxy | None = None, 239 | browser_args: list[str] | None = None, 240 | ): 241 | server = TurnstileSolverServer( 242 | host=host, 243 | port=port, 244 | secret=secret, 245 | disable_access_logs=disable_access_logs, 246 | turnstile_solver=None, 247 | on_shutting_down=None, 248 | console=console, 249 | log_level=server_log_level, 250 | ignore_food_events=ignore_food_events, 251 | ) 252 | 253 | solver = TurnstileSolver( 254 | server=server, 255 | page_load_timeout=page_load_timeout, 256 | browser_position=browser_position, 257 | browser_executable_path=browser_executable_path, 258 | browser=browser, 259 | reload_page_on_captcha_overrun_event=reload_page_on_captcha_overrun_event, 260 | max_attempts=max_attempts, 261 | attempt_timeout=attempt_timeout, 262 | headless=headless, 263 | console=console, 264 | log_level=solver_log_level, 265 | proxy=proxy, 266 | browser_args=browser_args, 267 | ) 268 | server.solver = solver 269 | await solver.server.create_browser_context_pool( 270 | max_contexts=max_contexts, 271 | max_pages_per_context=max_pages_per_context, 272 | single_instance=single_browser_instance, 273 | proxy_provider=proxy_provider, 274 | ) 275 | 276 | try: 277 | # Keep it breathing 278 | if production: 279 | ngrok_url = _start_ngrok_tunnel().public_url if use_ngrok else None 280 | t = Process( 281 | target=_keep_server_alive, 282 | args=(use_ngrok, perform_computations, ngrok_url, secret), 283 | daemon=True, 284 | ) 285 | t.start() 286 | 287 | # Start server 288 | await solver.server.run(debug=True) 289 | except (SystemExit, KeyboardInterrupt, asyncio.CancelledError): 290 | pass 291 | 292 | 293 | async def main(): 294 | 295 | dotenv.load_dotenv() 296 | 297 | traceback.install( 298 | show_locals=True, 299 | console=_console, 300 | ) 301 | 302 | # Route hypercorn.error logs to __main__ logger 303 | logging.getLogger("hypercorn.error")._log = logger._log 304 | logging.getLogger("faker").setLevel(logging.WARNING) 305 | 306 | init_logger( 307 | console=_console, 308 | level=logging.INFO, 309 | handler_level=logging.NOTSET, 310 | force=True, 311 | ) 312 | 313 | _add_help_custom_highlights() 314 | 315 | args = _parse_arguments() 316 | 317 | logging.root.setLevel(args.log_level) 318 | 319 | if not c.PROJECT_HOME_DIR.exists(): 320 | c.PROJECT_HOME_DIR.mkdir(parents=True) 321 | 322 | # Register file logger 323 | if not args.no_file_logs: 324 | logging.root.addHandler(get_file_handler(c.PROJECT_HOME_DIR / 'logs.log')) 325 | 326 | if args.production and (args.no_ngrok and args.no_computations): 327 | logger.error("For keeping it alive you must either use Ngrok, perform computations, or both") 328 | return 329 | 330 | # Load global proxy 331 | if args.proxy_server: 332 | username = load_proxy_param(args.proxy_username) 333 | password = load_proxy_param(args.proxy_password) 334 | proxy = Proxy(args.proxy_server, username, password) 335 | logger.debug(f"Global proxy server loaded. Server: '{proxy.server}'") 336 | else: 337 | proxy = None 338 | 339 | if args.proxies: 340 | if not Path(args.proxies).is_file(): 341 | logger.error(f"File: '{args.proxies}', does not exist") 342 | return 343 | proxyProvider = ProxyProvider(args.proxies) 344 | proxyProvider.load() 345 | else: 346 | proxyProvider = None 347 | 348 | await run_server( 349 | # Production 350 | production=args.production, 351 | use_ngrok=not args.no_ngrok, 352 | perform_computations=not args.no_computations, 353 | max_contexts=args.max_contexts, 354 | max_pages_per_context=args.max_pages, 355 | single_browser_instance=not args.multiple_browser_instances, 356 | proxy_provider=proxyProvider, 357 | 358 | # TurnstileSolverServer 359 | host=args.host, 360 | port=args.port, 361 | disable_access_logs=not args.log_access_logs, 362 | ignore_food_events=args.ignore_food_events, 363 | console=_console, 364 | server_log_level=args.server_log_level, 365 | secret=args.secret, 366 | 367 | # TurnstileSolver 368 | page_load_timeout=args.page_load_timeout, 369 | browser_position=args.browser_position, 370 | browser_executable_path=args.browser_executable_path, 371 | browser=args.browser, 372 | reload_page_on_captcha_overrun_event=args.reload_on_overrun, 373 | max_attempts=args.max_attempts, 374 | attempt_timeout=args.captcha_timeout, 375 | headless=args.headless, 376 | solver_log_level=args.solver_log_level, 377 | proxy=proxy, 378 | browser_args=args.browser_args, 379 | ) 380 | 381 | 382 | def main_cli(): 383 | asyncio.run(main()) 384 | 385 | 386 | if __name__ == '__main__': 387 | asyncio.run(main()) 388 | -------------------------------------------------------------------------------- /src/turnstile_solver/page_pool.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from patchright.async_api import BrowserContext, Page, Route 4 | from turnstile_solver.pool import Pool 5 | 6 | from turnstile_solver.constants import MAX_PAGES_PER_CONTEXT 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class PagePool(Pool): 12 | def __init__(self, 13 | context: BrowserContext, 14 | max_pages: int = MAX_PAGES_PER_CONTEXT, 15 | ): 16 | self.context = context 17 | 18 | super().__init__( 19 | size=max_pages, 20 | item_getter=self._page_getter, 21 | ) 22 | 23 | async def _page_getter(self): 24 | page = await self.context.new_page() 25 | return page 26 | -------------------------------------------------------------------------------- /src/turnstile_solver/pool.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from collections import deque 4 | from inspect import isawaitable 5 | 6 | from typing import Callable, Any, Awaitable 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class Pool: 12 | def __init__(self, 13 | size: int, 14 | item_getter: Callable[[], Any | Awaitable[Any]], 15 | ): 16 | self.size = size 17 | 18 | self._item_getter = item_getter 19 | self.in_use = [] 20 | self._available: deque = deque() 21 | self._lock = asyncio.Lock() 22 | 23 | @property 24 | def is_full(self) -> bool: 25 | return len(self.in_use) == self.size 26 | 27 | async def get(self) -> Any: 28 | async with self._lock: 29 | # Check possible runtime error 30 | if len(self.in_use) > self.size: 31 | raise RuntimeError("len(self._in_use) is supposed to be less than self.size") 32 | # Wait for any item to be available 33 | if self.is_full: 34 | logger.debug("Waiting for a new item to be available") 35 | while self.is_full: 36 | await asyncio.sleep(0.1) 37 | # Get an item from available ones if any and put it in self._in_use list 38 | if len(self._available) > 0: 39 | self.in_use.append(item := self._available.popleft()) 40 | return item 41 | # Then len(self._in_use) is less than self.max_size, so safely create a item page and put it in self._in_use list 42 | item = await self._get_item() 43 | self.in_use.append(item) 44 | logger.debug(f"New item '{item}' added to pool") 45 | return item 46 | 47 | async def put_back(self, item: Any): 48 | # Do nothing if item is available 49 | if item in self._available: 50 | return 51 | 52 | # Check possible runtime error 53 | if len(self._available) >= self.size: 54 | raise RuntimeError("len(self._available) is supposed to be less than self.size. Make sure to always call put_back() method only if get() method have been called previously") 55 | 56 | # Check possible runtime error again in which provided page may not have been fetched via get() method 57 | try: 58 | index = self.in_use.index(item) 59 | except ValueError: 60 | raise RuntimeError("The item provided seems not have been fetched via the get() method, and it is supposed to be this way. Make sure to always call put_back() method only if get() method have been called previously") 61 | logger.debug(f"Item '{item}' back on pool") 62 | self._available.append(self.in_use.pop(index)) 63 | 64 | async def _get_item(self): 65 | item = self._item_getter() 66 | if isawaitable(item): 67 | item = await item 68 | return item 69 | -------------------------------------------------------------------------------- /src/turnstile_solver/proxy.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import re 4 | 5 | _PORT_RE = re.compile(r':\d+$') 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class Proxy: 11 | def __init__(self, 12 | server: str, 13 | username: str | None, 14 | password: str | None, 15 | ): 16 | self.server = server 17 | 18 | if bool(username) ^ bool(password): 19 | raise ValueError(f'Username and password both must be specified. Username: {username}. Password: {password}') 20 | 21 | self.username = username 22 | self.password = password 23 | 24 | if not _PORT_RE.search(server): 25 | logger.warning("No proxy port specified") 26 | 27 | def dict(self): 28 | p = { 29 | 'server': self.server, 30 | 'bypass': '127.0.0.1, localhost', 31 | } 32 | if self.username: 33 | p['username'] = self.username 34 | p['password'] = self.password 35 | return p 36 | 37 | def __repr__(self) -> str: 38 | return json.dumps(self.dict(), indent=2) 39 | -------------------------------------------------------------------------------- /src/turnstile_solver/proxy_provider.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | 4 | from turnstile_solver.proxy import Proxy 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class ProxyProvider: 10 | def __init__(self, proxies_fp: str | Path): 11 | self.proxies_fp = proxies_fp 12 | self._index = 0 13 | self.proxies: list[Proxy] = [] 14 | 15 | def get(self) -> Proxy | None: 16 | if not self.proxies: 17 | return 18 | proxy = self.proxies[self._index] 19 | self._index = (self._index + 1) % len(self.proxies) 20 | return proxy 21 | 22 | def load(self): 23 | with open(self.proxies_fp, 'rt') as f: 24 | proxyCount = 0 25 | for line in f.readlines(): 26 | if not (line := line.strip()) or line.startswith('#'): 27 | continue 28 | parts = line.split('@') 29 | server = parts[0] 30 | if len(parts) > 1: 31 | username, password = parts[1].split(':') 32 | else: 33 | username = password = None 34 | self.proxies.append(Proxy(server, username, password)) 35 | proxyCount += 1 36 | logger.info(f"{proxyCount} proxies loaded from '{self.proxies_fp}'") 37 | 38 | def __repr__(self) -> str: 39 | return str(self.proxies) 40 | -------------------------------------------------------------------------------- /src/turnstile_solver/solver.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | import time 4 | from pathlib import Path 5 | from typing import Callable, Awaitable 6 | from patchright.async_api import async_playwright, Page, BrowserContext, Browser, Playwright 7 | 8 | import turnstile_solver.constants as c 9 | from turnstile_solver.enums import CaptchaApiMessageEvent 10 | from turnstile_solver.proxy import Proxy 11 | from turnstile_solver.solver_console import SolverConsole 12 | from turnstile_solver.turnstile_result import TurnstileResult 13 | from turnstile_solver.turnstile_solver_server import TurnstileSolverServer, CAPTCHA_EVENT_CALLBACK_ENDPOINT 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | BROWSER_ARGS = { 18 | 19 | "--no-sandbox", 20 | "--disable-dev-shm-usage", 21 | "--disable-setuid-sandbox", 22 | "--disable-software-rasterizer", 23 | 24 | "--disable-blink-features=AutomationControlled", # avoid navigator.webdriver detection 25 | "--disable-background-networking", 26 | "--disable-background-timer-throttling", 27 | "--disable-backgrounding-occluded-windows", 28 | "--disable-renderer-backgrounding", 29 | '--disable-application-cache', 30 | '--disable-field-trial-config', 31 | '--export-tagged-pdf', 32 | '--force-color-profile=srgb', 33 | '--safebrowsing-disable-download-protection', 34 | '--disable-search-engine-choice-screen', 35 | '--disable-browser-side-navigation', 36 | '--disable-save-password-bubble', 37 | '--disable-single-click-autofill', 38 | '--allow-file-access-from-files', 39 | '--disable-prompt-on-repost', 40 | '--dns-prefetch-disable', 41 | '--disable-translate', 42 | '--disable-client-side-phishing-detection', 43 | '--disable-oopr-debug-crash-dump', 44 | '--disable-top-sites', 45 | '--ash-no-nudges', 46 | '--no-crash-upload', 47 | '--deny-permission-prompts', 48 | '--simulate-outdated-no-au="Tue, 31 Dec 2099 23:59:59 GMT"', 49 | '--disable-ipc-flooding-protection', 50 | '--disable-password-generation', 51 | '--disable-domain-reliability', 52 | '--disable-breakpad', 53 | # Allow Manifest V2 extensions 54 | # --disable-features=ExtensionManifestV2DeprecationWarning,ExtensionManifestV2Disabled,ExtensionManifestV2Unsupported 55 | '--disable-features=OptimizationHints,OptimizationHintsFetching,Translate,OptimizationTargetPrediction,OptimizationGuideModelDownloading,DownloadBubble,DownloadBubbleV2,InsecureDownloadWarnings,InterestFeedContentSuggestions,PrivacySandboxSettings4,SidePanelPinning,UserAgentClientHint', 56 | '--no-pings', 57 | # '--homepage=chrome://version/', 58 | '--animation-duration-scale=0', 59 | '--wm-window-animations-disabled', 60 | '--enable-privacy-sandbox-ads-apis', 61 | # '--disable-popup-blocking', 62 | '--lang=en-US', 63 | '--no-default-browser-check', 64 | '--no-first-run', 65 | '--no-service-autorun', 66 | '--password-store=basic', 67 | '--log-level=3', 68 | '--proxy-bypass-list=<-loopback>;localhost;127.0.0.1;*.local', 69 | 70 | # Not needed, here just for reference 71 | # Network/Connection Tuning 72 | # '--enable-features=NetworkService,ParallelDownloading', 73 | # '--max-connections=255', # Total active connections 74 | # '--max-parallel-downloads=50', # Concurrent downloads 75 | # '--socket-reuse-policy=2', # Aggressive socket reuse 76 | 77 | # Thread/Process Management 78 | # '--renderer-process-limit=0', # Unlimited renderers 79 | # '--in-process-gpu', # Reduce process count # NO 80 | # '--disable-site-isolation-trials', # Prevent tab grouping # NO 81 | 82 | # Protocol-Specific 83 | # '--http2-no-coalesce-host', # Bypass HTTP/2 coalescing 84 | # '--force-http2-hpack-huffman=off', # Reduce HPACK overhead 85 | } 86 | 87 | 88 | class TurnstileSolver: 89 | 90 | def __init__(self, 91 | server: TurnstileSolverServer | None, 92 | page_load_timeout: float = c.PAGE_LOAD_TIMEOUT, 93 | browser_position: tuple[int, int] | None = c.BROWSER_POSITION, 94 | browser_executable_path: str | Path | None = None, 95 | browser: str = c.BROWSER, 96 | reload_page_on_captcha_overrun_event: bool = False, 97 | max_attempts: int = c.MAX_ATTEMPTS_TO_SOLVE_CAPTCHA, 98 | attempt_timeout: int = c.CAPTCHA_ATTEMPT_TIMEOUT, 99 | headless: bool = False, 100 | console: SolverConsole = SolverConsole(), 101 | log_level: int | str = logging.INFO, 102 | proxy: Proxy | None = None, 103 | browser_args: list[str] | None = None, 104 | ): 105 | 106 | logger.setLevel(log_level) 107 | self.console = console 108 | self.page_load_timeout = page_load_timeout 109 | self.reload_page_on_captcha_overrun_event = reload_page_on_captcha_overrun_event 110 | self.browser_executable_path = browser_executable_path 111 | self.browser = browser 112 | self.headless = headless 113 | 114 | self.server: TurnstileSolverServer | None = server 115 | 116 | self.browser_args = list(BROWSER_ARGS) + (browser_args or []) 117 | if browser_position: 118 | self.browser_args.append(f'--window-position={browser_position[0]},{browser_position[1]}') 119 | self._error: str | None = None 120 | self.max_attempts = max_attempts 121 | self.attempt_timeout = attempt_timeout 122 | 123 | self.proxy = proxy 124 | 125 | @property 126 | def _server_down(self) -> bool: 127 | if self.server.down: 128 | self._error = "Server down" 129 | logger.warning("Captcha can't be solved because server is down") 130 | return True 131 | return False 132 | 133 | @property 134 | def error(self) -> str: 135 | return self._error or 'Unknown' 136 | 137 | async def solve(self, 138 | site_url: str, 139 | site_key: str, 140 | attempts: int | None = None, 141 | timeout: float | None = None, 142 | page: Page | bool = False, 143 | about_blank_on_finish: bool = False, 144 | ) -> TurnstileResult | None: 145 | """ 146 | If page is a Page instance, this instance will be reused, else a new BrowserContext instance will be created and destroyed upon finish if browser_context is False, else the created instance will be returned along with the Browser instance 147 | """ 148 | 149 | if not self.server: 150 | raise RuntimeError("self.server instance has not been assigned") 151 | 152 | if self.server.down: 153 | raise RuntimeError("Server is down. Make sure to run server and wait fot it to be up. Use method .wait_for_server_up()") 154 | 155 | if not attempts: 156 | attempts = self.max_attempts 157 | if not timeout: 158 | timeout = self.attempt_timeout 159 | self._error = None 160 | site_url = site_url.rstrip('/') + "/" 161 | 162 | startTime = time.time() 163 | 164 | result = TurnstileResult() 165 | self.server.subscribe_captcha_message_event_handler(result.id, result.captcha_api_message_event_handler) 166 | 167 | onFinishCallbacks: list[Callable[[], Awaitable[None]]] = [] 168 | 169 | if isinstance(page, bool): 170 | pageOrContext, playwright = await self.get_browser_context() 171 | if page is True: 172 | result.browser_context = pageOrContext 173 | else: 174 | async def _closeBrowserAndConnection(): 175 | result.page = None 176 | await pageOrContext.close() 177 | await pageOrContext.browser.close() 178 | await playwright.stop() 179 | logging.debug("Browser closed") 180 | 181 | onFinishCallbacks.append(_closeBrowserAndConnection) 182 | else: # elif isinstance(page, Page): 183 | pageOrContext = page 184 | 185 | try: 186 | for a in range(1, attempts + 1): 187 | logger.info(f"Attempt: {a}/{attempts}") 188 | 189 | result.reset_captcha_fields() 190 | 191 | # 1. Route and load page, reset captcha fields 192 | if not (page := await self._setup_page( 193 | page_or_context=result.page or pageOrContext, 194 | site_url=site_url, 195 | site_key=site_key, 196 | id=result.id, 197 | )): 198 | return 199 | 200 | result.page = page 201 | 202 | # 2. Wait for init event 203 | logger.debug(f"Waiting for '{CaptchaApiMessageEvent.INIT.value}' event") 204 | try: 205 | if await result.wait_for_captcha_event(evt=CaptchaApiMessageEvent.INIT, timeout=timeout) is False: 206 | return 207 | except TimeoutError as te: 208 | self._error = te.args[0] 209 | logger.warning(f"Captcha API message '{CaptchaApiMessageEvent.INIT.value}' event not received within {timeout} seconds") 210 | continue 211 | 212 | if self._server_down: 213 | return 214 | 215 | # 3. Wait for 'complete' event 216 | try: 217 | cancellingEvents = [CaptchaApiMessageEvent.REJECT, CaptchaApiMessageEvent.FAIL, CaptchaApiMessageEvent.RELOAD_REQUEST] 218 | if self.reload_page_on_captcha_overrun_event: 219 | cancellingEvents.append(CaptchaApiMessageEvent.OVERRUN_BEGIN) 220 | if (cancellingEvent := await result.wait_for_captcha_event( 221 | *cancellingEvents, 222 | evt=CaptchaApiMessageEvent.COMPLETE, 223 | timeout=timeout, 224 | )) is False: 225 | return 226 | elif isinstance(cancellingEvent, CaptchaApiMessageEvent): 227 | logger.warning(f"'{cancellingEvent.value}' event received") 228 | continue 229 | except TimeoutError as te: 230 | self._error = te.args[0] 231 | logger.warning(f"Captcha not solved within {timeout} seconds") 232 | continue 233 | 234 | if result.token is None: 235 | raise RuntimeError("'result.token' is not supposed to be None at this point") 236 | 237 | elapsed = datetime.timedelta(seconds=time.time() - startTime) 238 | logger.info(f"Captcha solved. Elapsed: {str(elapsed).split('.')[0]}") 239 | logger.debug(f"TOKEN: {result.token}") 240 | result.elapsed = elapsed 241 | break 242 | 243 | if about_blank_on_finish: 244 | await page.goto("about:blank") 245 | if result.token: 246 | return result 247 | self._error = f"Captcha failed to solve in {attempts} attempts :(" 248 | logger.error(self._error) 249 | except Exception as ex: 250 | self._error = str(ex) 251 | raise 252 | # logger.error(ex) 253 | finally: 254 | self.server.unsubscribe_captcha_message_event_handler(result.id) 255 | for callback in onFinishCallbacks: 256 | await callback() 257 | 258 | async def _setup_page( 259 | self, 260 | page_or_context: BrowserContext | Page, 261 | site_url: str, 262 | site_key: str, 263 | id: str, 264 | ) -> Page | None: 265 | 266 | if self._server_down: 267 | return 268 | 269 | page = await page_or_context.new_page() if isinstance(page_or_context, BrowserContext) else page_or_context 270 | 271 | pageContent = c.HTML_TEMPLATE.format( 272 | local_server_port=self.server.port, 273 | local_callback_endpoint=CAPTCHA_EVENT_CALLBACK_ENDPOINT.lstrip('/'), 274 | site_key=site_key, 275 | id=id, 276 | secret=self.server.secret, 277 | ) 278 | await page.route(site_url, lambda r: r.fulfill(body=pageContent, status=200)) 279 | 280 | if page.url != site_url: 281 | logger.debug(f"Navigating to URL: {site_url}") 282 | await page.goto(site_url, timeout=self.page_load_timeout * 1000) 283 | else: 284 | logger.debug("Reloading page") 285 | await page.reload(timeout=self.page_load_timeout * 1000) 286 | 287 | page.window_width = await page.evaluate("window.innerWidth") 288 | page.window_height = await page.evaluate("window.innerHeight") 289 | 290 | return page 291 | 292 | async def get_browser(self, 293 | playwright: Playwright | None = None, 294 | proxy: Proxy | None = None, 295 | ) -> tuple[Browser, Playwright]: 296 | 297 | proxy = proxy or self.proxy 298 | 299 | if not playwright: 300 | playwright = await async_playwright().start() 301 | 302 | # ? 303 | # browser: Browser | None = await playwright.chromium.launch_persistent_context(no_viewport=True) 304 | browser: Browser | None = await playwright.chromium.launch( 305 | executable_path=self.browser_executable_path, 306 | channel=self.browser, 307 | args=self.browser_args, 308 | headless=self.headless, 309 | proxy=proxy.dict() if proxy else None, 310 | ) 311 | return browser, playwright 312 | 313 | async def get_browser_context(self, 314 | browser: Browser | None = None, 315 | playwright: Playwright | None = None, 316 | proxy: Proxy | None = None, 317 | ) -> tuple[BrowserContext, Playwright]: 318 | 319 | if not browser: 320 | browser, _ = await self.get_browser(playwright) 321 | 322 | context = await browser.new_context( 323 | proxy=proxy.dict() if proxy else None, 324 | no_viewport=True, 325 | ) 326 | 327 | # await context.route('**', lambda route: route.continue_()) 328 | # await context.set_extra_http_headers({'HTTP2-Settings': 'MAX_CONCURRENT_STREAMS=100'}) 329 | 330 | return context, playwright 331 | -------------------------------------------------------------------------------- /src/turnstile_solver/solver_console.py: -------------------------------------------------------------------------------- 1 | from rich.console import Console 2 | from rich.theme import Theme 3 | 4 | from turnstile_solver.constants import CONSOLE_THEME_STYLES 5 | from turnstile_solver.solver_console_highlighter import SolverConsoleHighlighter 6 | 7 | MAX_CONSOLE_WIDTH = 200 8 | 9 | 10 | class SolverConsole(Console): 11 | def __init__(self, *args, **kwargs): 12 | kwargs['theme'] = Theme(CONSOLE_THEME_STYLES) 13 | kwargs['highlighter'] = SolverConsoleHighlighter() 14 | super().__init__(*args, **kwargs) 15 | if isinstance(self.width, int) and self.width > MAX_CONSOLE_WIDTH: 16 | self.width = MAX_CONSOLE_WIDTH 17 | -------------------------------------------------------------------------------- /src/turnstile_solver/solver_console_highlighter.py: -------------------------------------------------------------------------------- 1 | from rich.highlighter import ReprHighlighter 2 | 3 | _ORIGINAL_NUMBER_HIGHLIGHTER = r"(?P(?v\d+\.\d+(?:\.\d*)*)\b)" 8 | ReprHighlighter.highlights[-1] = ReprHighlighter.highlights[-1] + '|' + _version_numbering_highlight 9 | 10 | highlights = ReprHighlighter.highlights + [ 11 | r"\b(?POGM)\b", 12 | r"\b(?PTurnstile Solver)\b", 13 | r"\b(?Pheadless)\b", # highlight `text in backquotes` as syntax 14 | ] 15 | -------------------------------------------------------------------------------- /src/turnstile_solver/turnstile_result.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | import time 4 | from typing import Any 5 | 6 | import asyncio 7 | 8 | from patchright.async_api import BrowserContext, Page 9 | 10 | from turnstile_solver.enums import CaptchaApiMessageEvent 11 | from turnstile_solver.utils import password 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | _CLICK_CHECKBOX_SCRIPT = """ 16 | let containerWidth = document.querySelector('.cf-turnstile').width; 17 | let width = containerWidth * 0.2; 18 | document.querySelector('.cf-turnstile').width = width; 19 | """ 20 | 21 | 22 | class TurnstileResult: 23 | def __init__(self, 24 | token: str | None = None, 25 | elapsed: datetime.timedelta | None = None, 26 | browser_context: BrowserContext | None = None, 27 | page: Page | None = None, 28 | ): 29 | self.token = token 30 | self.elapsed = elapsed 31 | self.browser_context = browser_context 32 | self.page = page 33 | self._id = password(10) 34 | self._received_captcha_events: set[CaptchaApiMessageEvent] = set() 35 | 36 | @property 37 | def id(self) -> str: 38 | return self._id 39 | 40 | async def captcha_api_message_event_handler(self, evt: CaptchaApiMessageEvent, data: dict[str, Any]): 41 | if evt == CaptchaApiMessageEvent.COMPLETE: 42 | self.token = data['token'] 43 | elif evt == CaptchaApiMessageEvent.INTERACTIVE_BEGIN: 44 | # Wait some time? 45 | # import random 46 | # await asyncio.sleep(random.uniform(0.1, 0.5)) 47 | await self.click_checkbox() 48 | self._received_captcha_events.add(evt) 49 | 50 | def reset_captcha_fields(self): 51 | self._received_captcha_events.clear() 52 | self.token = None 53 | 54 | async def wait_for_captcha_event(self, 55 | *cancelling_evts: CaptchaApiMessageEvent, 56 | evt: CaptchaApiMessageEvent, 57 | timeout: float, 58 | sleep_time: float = 0.05, 59 | ) -> CaptchaApiMessageEvent | bool: 60 | endTime = time.time() + timeout 61 | while True: 62 | if evt in self._received_captcha_events: 63 | return True 64 | elif cancelling_evts: 65 | for e in cancelling_evts: 66 | if e in self._received_captcha_events: 67 | return e 68 | if time.time() >= endTime: 69 | raise TimeoutError(f"Captcha event '{evt.value}' not received within {timeout} seconds") 70 | await asyncio.sleep(sleep_time) 71 | 72 | async def click_checkbox(self, page: Page | None = None): 73 | page = page or self.page 74 | # Uncomment these lines if you think CAPTCHA solving process is failing because of the absence of a delay 75 | # import random 76 | # await asyncio.sleep(random.uniform(2, 3)) 77 | await page.evaluate(_CLICK_CHECKBOX_SCRIPT) 78 | # TODO: For some sites this click approach seems to be detected by Cloudflare causing the CAPTCHA solving process to fail (Example site • https://chat.deepseek.com/ 0x4AAAAAAA1jQEh8YFk064tz) 79 | # await page.click(".cf-turnstile") 80 | # await page.locator("//div[@class='cf-turnstile']").click(timeout=1000) 81 | try: 82 | await page.locator('.cf-turnstile').click(timeout=1000) 83 | logger.debug("Attempt to click checkbox performed") 84 | except TimeoutError: 85 | logger.error("Captcha widget click timed-out") 86 | -------------------------------------------------------------------------------- /src/turnstile_solver/turnstile_site.py: -------------------------------------------------------------------------------- 1 | 2 | class TurnstileSite: 3 | def __init__(self, 4 | site_key: str, 5 | site_url: str, 6 | ): 7 | self.site_key = site_key 8 | self.site_url = site_url 9 | -------------------------------------------------------------------------------- /src/turnstile_solver/turnstile_solver_server.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from inspect import isawaitable 4 | from types import NoneType 5 | from typing import Callable, Any, Awaitable 6 | 7 | from typing import TYPE_CHECKING 8 | 9 | import asyncio 10 | 11 | from quart import Quart, Response, request 12 | 13 | from turnstile_solver.enums import CaptchaApiMessageEvent 14 | from turnstile_solver.constants import PORT, HOST, CAPTCHA_EVENT_CALLBACK_ENDPOINT, MAX_CONTEXTS, MAX_PAGES_PER_CONTEXT 15 | from turnstile_solver.proxy_provider import ProxyProvider 16 | from turnstile_solver.solver_console import SolverConsole 17 | from turnstile_solver.constants import SECRET 18 | from turnstile_solver.browser_context_pool import BrowserContextPool 19 | 20 | if TYPE_CHECKING: 21 | from turnstile_solver.solver import TurnstileSolver 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | 26 | class TurnstileSolverServer: 27 | 28 | MessageEventHandler = Callable[[CaptchaApiMessageEvent, dict[str, Any]], NoneType | Awaitable[None]] 29 | 30 | def __init__(self, 31 | host: str = HOST, 32 | port: int = PORT, 33 | disable_access_logs: bool = True, 34 | ignore_food_events: bool = False, 35 | turnstile_solver: "TurnstileSolver" = None, 36 | on_shutting_down: Callable[..., Awaitable[None]] | None = None, 37 | console: SolverConsole = SolverConsole(), 38 | log_level: str | int = logging.INFO, 39 | secret: str = SECRET, 40 | ): 41 | logger.setLevel(log_level) 42 | if disable_access_logs: 43 | logging.getLogger('hypercorn.access').disabled = True 44 | self.app = _Quart(__name__) 45 | self.host = host 46 | self.port = port 47 | self.console = console 48 | self.solver: "TurnstileSolver" = turnstile_solver 49 | self.down: bool = True 50 | self._captcha_message_event_handlers: dict[str, TurnstileSolverServer.MessageEventHandler] = {} 51 | self.on_shutting_down = on_shutting_down 52 | # /solve endpoint intended fields 53 | # self.browser_context: BrowserContext | None = None 54 | self.browser_context_pool: BrowserContextPool | None = None 55 | # deprecated 56 | # self.page_pool: PagePool | None = None 57 | self.secret = secret 58 | self.ignore_food_events = ignore_food_events 59 | 60 | self._lock = asyncio.Lock() 61 | self._setup_routes() 62 | 63 | def _setup_routes(self) -> None: 64 | """Set up the application routes.""" 65 | self.app.before_request(self._before_request) 66 | self.app.after_request(self._after_request) 67 | self.app.post(CAPTCHA_EVENT_CALLBACK_ENDPOINT)(self._handle_captcha_message_event) 68 | self.app.get('/solve')(self._solve) 69 | self.app.get('/')(self._index) 70 | 71 | def subscribe_captcha_message_event_handler(self, id: str, handler: MessageEventHandler): 72 | logger.debug(f"Captcha message event handler with id '{id}' subscribed") 73 | self._captcha_message_event_handlers[id] = handler 74 | 75 | def unsubscribe_captcha_message_event_handler(self, id: str): 76 | logger.debug(f"Captcha message event handler with id '{id}' unsubscribed") 77 | self._captcha_message_event_handlers.pop(id, None) 78 | 79 | async def create_browser_context_pool(self, 80 | max_contexts: int = MAX_CONTEXTS, 81 | max_pages_per_context: int = MAX_PAGES_PER_CONTEXT, 82 | single_instance: bool = False, 83 | proxy_provider: ProxyProvider | None = None, 84 | ): 85 | assert self.solver is not None 86 | self.browser_context_pool = BrowserContextPool( 87 | solver=self.solver, 88 | max_contexts=max_contexts, 89 | max_pages_per_context=max_pages_per_context, 90 | single_instance=single_instance, 91 | proxy_provider=proxy_provider, 92 | ) 93 | await self.browser_context_pool.init() 94 | 95 | # deprecated 96 | # async def create_page_pool(self): 97 | # """Create PagePool instance to be used in /solve endpoint requests""" 98 | # browser_context, _ = await self.solver._get_browser_context() 99 | # self.page_pool = PagePool(browser_context) 100 | 101 | async def wait_for_server(self, timeout: float = 5): 102 | endTime = time.time() + timeout 103 | while self.down: 104 | await asyncio.sleep(0.01) 105 | if time.time() >= endTime: 106 | raise TimeoutError(f"Server didn't start after {timeout} seconds") 107 | 108 | async def run(self, debug: bool = False): 109 | 110 | async def beforeServing(): 111 | self.down = False 112 | logger.info("Server up and running") 113 | 114 | async def afterServing(): 115 | self.down = True 116 | logger.info("Server is down") 117 | if callable(self.on_shutting_down): 118 | await self.on_shutting_down() 119 | 120 | self.app.before_serving(beforeServing) 121 | self.app.after_serving(afterServing) 122 | await self.app.run_task( 123 | host=self.host, 124 | port=self.port, 125 | debug=debug, 126 | ) 127 | 128 | async def _handle_captcha_message_event(self): 129 | try: 130 | logger.debug('Handling captcha message event') 131 | data: dict[str, Any] = await request.get_json(force=True) 132 | evt: CaptchaApiMessageEvent | str | None = data.pop('event', None) 133 | if not evt: 134 | return self._bad(f"message has no event entry. Data: {data}", log=True) 135 | try: 136 | evt = CaptchaApiMessageEvent(evt) 137 | except ValueError: 138 | return self._bad(f"Unknown event: '{evt}'") 139 | 140 | if not (id := request.args.get("id")): 141 | return self._bad("id parameter not specified") 142 | 143 | if not self._captcha_message_event_handlers: 144 | return self._error("There's no handlers for handling captcha event", warning=True) 145 | else: 146 | handler = self._captcha_message_event_handlers.get(id) 147 | if not handler: 148 | return self._error(f"There's no handler for handling event with ID: {id}", warning=True) 149 | if evt != CaptchaApiMessageEvent.FOOD or not self.ignore_food_events: 150 | logger.debug(f"Dispatching '{evt.value}' event") 151 | if isawaitable(a := handler(evt, data)): 152 | await a 153 | 154 | except Exception: 155 | self.console.print_exception() 156 | return self._error(self.solver.error, log=False) 157 | return self._ok() 158 | 159 | async def _solve(self): 160 | try: 161 | if self.solver is None: 162 | return self._error("No TurnstileSolver instance has been assigned") 163 | 164 | if not self.browser_context_pool: 165 | return self._error("No BrowserContextPool instance has been assigned") 166 | 167 | data: dict[str, str] = await request.get_json(force=True) 168 | if not (site_url := data.get('site_url')): 169 | return self._bad("site_url required") 170 | if not (site_key := data.get('site_key')): 171 | return self._bad("site_key required") 172 | 173 | async with self._lock: 174 | pagePool = await self.browser_context_pool.get() 175 | page = await pagePool.get() 176 | 177 | try: 178 | if not (result := await self.solver.solve( 179 | site_url=site_url, 180 | site_key=site_key, 181 | page=page, 182 | about_blank_on_finish=True, 183 | )): 184 | return self._error(self.solver.error) 185 | finally: 186 | await self.browser_context_pool.put_back(pagePool) 187 | await pagePool.put_back(page) 188 | 189 | self._page = result.page 190 | return self._ok({ 191 | "token": result.token, 192 | "elapsed": str(result.elapsed.total_seconds()), 193 | }) 194 | except Exception as ex: 195 | self.console.print_exception() 196 | return self._error(str(ex)) 197 | 198 | async def _before_request(self): 199 | if request.headers.get('secret') != self.secret: 200 | logging.error("Forbidden") 201 | return self._error("Who are you?", 403, "Forbidden") 202 | 203 | async def _after_request(self, res: Response): 204 | res.headers.update({"Access-Control-Allow-Origin": "*"}) 205 | return res 206 | 207 | async def _index(self): 208 | return """ 209 | 210 | 211 | 212 | 213 | 214 | Turnstile Solver 215 | 216 | 217 |

Turnstile Solver

218 | 219 | """ 220 | 221 | def _bad(self, message: str, log: bool = False, warning: bool = False) -> tuple[dict[str, str], int]: 222 | return self._error(message, 400, log=log, warning=warning) 223 | 224 | def _error(self, message: str, status_code: int = 500, status="error", log: bool = True, warning: bool = False) -> tuple[dict[str, str], int]: 225 | if log: 226 | logger.log(logging.WARNING if warning else logging.ERROR, message) 227 | return self._json(status, message, status_code) 228 | 229 | def _ok(self, additional_data: dict | None = None) -> tuple[dict[str, str], int]: 230 | return self._json("OK", None, 200, additional_data) 231 | 232 | def _json(self, status: str, message: str | None, status_code: int, additional_data: dict | None = None) -> tuple[dict[str, str], int]: 233 | """JSON response template""" 234 | data = {"status": status, "message": message} 235 | if additional_data: 236 | data |= additional_data 237 | return data, status_code 238 | 239 | 240 | class _Quart(Quart): 241 | def __init__(self, *args, **kwargs): 242 | super().__init__(*args, **kwargs) 243 | 244 | async def make_default_options_response(self) -> Response: 245 | res = await super().make_default_options_response() 246 | res.headers |= { 247 | "Access-Control-Allow-Origin": "*", 248 | "Access-Control-Allow-Headers": "*", 249 | "Access-Control-Allow-Private-Network": "true", 250 | } 251 | return res 252 | -------------------------------------------------------------------------------- /src/turnstile_solver/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import random 4 | import time 5 | 6 | from faker import Faker 7 | from rich.logging import RichHandler 8 | 9 | from turnstile_solver.solver_console import SolverConsole 10 | 11 | _faker = Faker(locale='en_US') 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | def init_logger( 17 | console=SolverConsole(), 18 | level=logging.INFO, 19 | handler_level=logging.NOTSET, 20 | force=False, 21 | ): 22 | richHandler = RichHandler(level=handler_level, 23 | markup=True, 24 | show_path=False, 25 | log_time_format='[%X]', 26 | console=console, 27 | rich_tracebacks=True) 28 | # logging.root.addHandler(richHandler) 29 | logging.basicConfig(level=level, 30 | format="%(message)s", 31 | datefmt="[%X]", 32 | force=force, 33 | handlers=[richHandler]) 34 | 35 | 36 | def password(length: int | tuple[int, int] = (10, 15), special_chars: bool = False): 37 | return _faker.password( 38 | length=length if isinstance(length, int) else random.randint(*length), 39 | special_chars=special_chars 40 | ) 41 | 42 | 43 | def simulate_intensive_task(iterations=2500, complexity=10000): 44 | startTime = time.time() 45 | for _ in range(iterations): 46 | for _ in range(complexity): 47 | value = random.random() * complexity 48 | value **= 3 49 | return time.time() - startTime 50 | 51 | 52 | def get_file_handler( 53 | path: str, 54 | level: int | str = logging.DEBUG, 55 | ): 56 | handler = logging.FileHandler(path) 57 | handler.setLevel(level) 58 | formatter = logging.Formatter('%(asctime)s::%(levelname)s::%(name)s %(message)s, line %(lineno)d') 59 | handler.setFormatter(formatter) 60 | return handler 61 | 62 | 63 | def is_all_caps(word: str) -> bool: 64 | if not word: 65 | return False 66 | filtered = [c.isupper() for c in word if c.isalpha()] 67 | return filtered and all(filtered) 68 | 69 | 70 | def load_proxy_param(param) -> str | None: 71 | if is_all_caps(param): 72 | if p := os.environ.get(param): 73 | logger.debug("Proxy parameter loaded from environment variables") 74 | return p 75 | else: 76 | logger.warning("Proxy parameter intended to be loaded from environment variables was not found") 77 | return param 78 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/odell0111/turnstile_solver/488a6b84a8cb80efed9468fa688b89c33f2bea33/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | sys.path.append(str(Path(__file__).parent.parent / 'src')) 4 | 5 | import logging 6 | import pytest 7 | import dotenv 8 | 9 | from turnstile_solver.solver_console import SolverConsole 10 | from turnstile_solver.utils import init_logger, get_file_handler 11 | from turnstile_solver.constants import PROJECT_HOME_DIR 12 | 13 | _console = SolverConsole() 14 | 15 | _logger = logging.getLogger(__name__) 16 | _logger.addHandler(get_file_handler(PROJECT_HOME_DIR / 'test_logs.log')) 17 | 18 | 19 | @pytest.fixture 20 | def logger() -> logging.Logger: 21 | return _logger 22 | 23 | 24 | @pytest.fixture 25 | def console() -> SolverConsole: 26 | return SolverConsole() 27 | 28 | 29 | # noinspection PyUnusedLocal 30 | @pytest.hookimpl 31 | def pytest_configure(config: pytest.Config): 32 | print() 33 | 34 | dotenv.load_dotenv() 35 | 36 | if not PROJECT_HOME_DIR.exists(): 37 | PROJECT_HOME_DIR.mkdir(parents=True) 38 | 39 | init_logger( 40 | console=_console, 41 | level=logging.DEBUG, 42 | handler_level=logging.DEBUG, 43 | force=True, 44 | ) 45 | # Route hypercorn.error logs to __main__ logger 46 | logging.getLogger("hypercorn.error")._log = _logger._log 47 | logging.getLogger('faker').setLevel(logging.WARNING) 48 | # logging.getLogger("werkzeug").setLevel(logging.WARNING) # flask 49 | -------------------------------------------------------------------------------- /tests/proxy_provider.py: -------------------------------------------------------------------------------- 1 | from turnstile_solver.proxy_provider import ProxyProvider 2 | from pytest import fixture 3 | from rich.pretty import pprint 4 | 5 | 6 | @fixture 7 | def provider() -> ProxyProvider: 8 | return ProxyProvider(r'D:\cmd\proxies.txt') 9 | 10 | 11 | def test_proxy_provider(provider: ProxyProvider): 12 | provider.load() 13 | pprint(provider.proxies, indent_guides=False) 14 | -------------------------------------------------------------------------------- /tests/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | asyncio_mode=auto 3 | asyncio_default_fixture_loop_scope=module 4 | -------------------------------------------------------------------------------- /tests/test_solver.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import asyncio 3 | import os 4 | 5 | import pytest 6 | import requests 7 | 8 | from concurrent.futures import ThreadPoolExecutor, as_completed 9 | from pathlib import Path 10 | 11 | from turnstile_solver.constants import HOST, PORT, SECRET 12 | from turnstile_solver.solver import TurnstileSolver 13 | from turnstile_solver.solver_console import SolverConsole 14 | from turnstile_solver.turnstile_solver_server import TurnstileSolverServer 15 | 16 | host = HOST 17 | port = PORT 18 | 19 | 20 | @pytest.fixture 21 | def server(console: SolverConsole) -> TurnstileSolverServer: 22 | 23 | server = TurnstileSolverServer( 24 | host=HOST, 25 | port=PORT, 26 | secret=SECRET, 27 | console=console, 28 | log_level=logging.DEBUG, 29 | disable_access_logs=True, 30 | ignore_food_events=True, 31 | turnstile_solver=None, 32 | on_shutting_down=None, 33 | ) 34 | 35 | return server 36 | 37 | 38 | @pytest.fixture 39 | def solver(server: TurnstileSolverServer) -> TurnstileSolver: 40 | EXECUTABLE_PATH = Path.home() / "AppData/Local/ms-playwright/chromium-1155/chrome-win/chrome.exe" 41 | 42 | proxyServer = os.environ.get('PROXY_SERVER') 43 | proxyUsername = os.environ.get('PROXY_USERNAME') 44 | proxyPassword = os.environ.get('PROXY_PASSWORD') 45 | 46 | s = TurnstileSolver( 47 | console=server.console, 48 | log_level=logging.DEBUG, 49 | page_load_timeout=60 * 1.5, 50 | reload_page_on_captcha_overrun_event=False, 51 | server=server, 52 | browser_position=None, 53 | browser_executable_path=EXECUTABLE_PATH, 54 | headless=False, 55 | proxy_server=proxyServer, 56 | proxy_username=proxyUsername, 57 | proxy_password=proxyPassword, 58 | ) 59 | server.solver = s 60 | return s 61 | 62 | 63 | async def test_solve(solver: TurnstileSolver): 64 | 65 | # siteUrl, siteKey = "https://2captcha.com/demo/cloudflare-turnstile", "0x1AAAAAAAAkg0s2VIOD34y5" 66 | siteUrl, siteKey = "https://spotifydown.com/", "0x4AAAAAAAByvC31sFG0MSlp" 67 | 68 | async with asyncio.TaskGroup() as tg: 69 | serverTask = tg.create_task(solver.server.run(debug=True), name="server_task") 70 | 71 | async def _solve(): 72 | await solver.server.wait_for_server() 73 | result = await solver.solve( 74 | site_url=siteUrl, 75 | site_key=siteKey, 76 | attempts=5, 77 | timeout=30, 78 | page=False, 79 | about_blank_on_finish=False, 80 | ) 81 | serverTask.cancel() 82 | return result 83 | 84 | tokenTask = tg.create_task(_solve(), name="solve_task") 85 | if r := tokenTask.result(): 86 | print("TOKEN", r.token) 87 | 88 | 89 | # TODO: Update 90 | # async def test_server(solver: TurnstileSolver): 91 | # await solver.server.create_page_pool() 92 | # solver.max_attempts = 5 93 | # solver.attempt_timeout = 30 94 | # await solver.server.run(debug=True) 95 | 96 | 97 | def _get_token( 98 | server_url: str, 99 | site_url: str, 100 | site_key: str, 101 | ): 102 | 103 | url = f"{server_url}/solve" 104 | 105 | headers = { 106 | 'ngrok-skip-browser-warning': '_', 107 | 'secret': 'jWRN7DH6', 108 | 'Content-Type': 'application/json' 109 | } 110 | 111 | json_data = { 112 | "site_url": site_url, 113 | "site_key": site_key 114 | } 115 | 116 | response = requests.get( 117 | url=url, 118 | headers=headers, 119 | json=json_data, 120 | ) 121 | 122 | response.raise_for_status() 123 | 124 | data = response.json() 125 | token = data['token'] 126 | elapsed = data['elapsed'] 127 | print(f"Token: {token}\n" 128 | f"Elapsed: {elapsed}") 129 | 130 | 131 | def test_get_token(logger: logging.Logger): 132 | 133 | server_url = "http://127.0.0.1:8088" 134 | # server_url = "https://d8e6-35-185-23-87.ngrok-free.app" 135 | 136 | site_url, site_key = "https://spotifydown.com", "0x4AAAAAAAByvC31sFG0MSlp" 137 | # site_url, site_key = "https://bypass.city/", "0x4AAAAAAAGzw6rXeQWJ_y2P" 138 | 139 | requestCount = 1 140 | 141 | with ThreadPoolExecutor(max_workers=min(32, requestCount)) as executor: 142 | futures = [] 143 | for _ in range(requestCount): 144 | future = executor.submit( 145 | _get_token, 146 | server_url, 147 | site_url, 148 | site_key, 149 | ) 150 | futures.append(future) 151 | 152 | for future in as_completed(futures): 153 | try: 154 | future.result() 155 | except Exception as e: 156 | logging.error(f"Thread failed with error: {str(e)}") 157 | --------------------------------------------------------------------------------