├── .github ├── workflows │ └── python-package-conda.yml └── 工作流程 │ └── python-app.yml ├── .gitignore ├── LICENSE ├── README.md ├── cloud189 ├── __init__.py ├── api │ ├── __init__.py │ ├── core.py │ ├── models.py │ ├── token.py │ ├── types.py │ └── utils.py └── cli │ ├── __init__.py │ ├── cli.py │ ├── config.py │ ├── downloader.py │ ├── manager.py │ ├── recovery.py │ ├── reprint.py │ └── utils.py ├── main.py └── requirements.txt /.github/workflows/python-package-conda.yml: -------------------------------------------------------------------------------- 1 | name: Python Package using Conda 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build-linux: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | max-parallel: 5 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Set up Python 3.8 14 | uses: actions/setup-python@v2 15 | with: 16 | python-version: 3.8 17 | - name: Add conda to system path 18 | run: | 19 | # $CONDA is an environment variable pointing to the root of the miniconda directory 20 | echo $CONDA/bin >> $GITHUB_PATH 21 | - name: Install dependencies 22 | run: | 23 | conda env update --file environment.yml --name base 24 | - name: Lint with flake8 25 | run: | 26 | conda install flake8 27 | # stop the build if there are Python syntax errors or undefined names 28 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 29 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 30 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 31 | - name: Test with pytest 32 | run: | 33 | conda install pytest 34 | pytest 35 | -------------------------------------------------------------------------------- /.github/工作流程/python-app.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python application 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Set up Python 3.9 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: 3.9 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install flake8 pytest 27 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 28 | - name: Lint with flake8 29 | run: | 30 | # stop the build if there are Python syntax errors or undefined names 31 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 32 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 33 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 34 | - name: Test with pytest 35 | run: | 36 | main 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # other 132 | .config 133 | .directory 134 | .idea/ 135 | *.log 136 | 137 | /downloads 138 | /bak -------------------------------------------------------------------------------- /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 |

- cloud189-cli -

2 |
  3 | #    /$$$$$$  /$$                           /$$   /$$    /$$$$$$   /$$$$$$ 
  4 | #   /$$__  $$| $$                          | $$ /$$$$   /$$__  $$ /$$__  $$
  5 | #  | $$  \__/| $$  /$$$$$$  /$$   /$$  /$$$$$$$|_  $$  | $$  \ $$| $$  \ $$
  6 | #  | $$      | $$ /$$__  $$| $$  | $$ /$$__  $$  | $$  |  $$$$$$/|  $$$$$$$
  7 | #  | $$      | $$| $$  \ $$| $$  | $$| $$  | $$  | $$   >$$__  $$ \____  $$
  8 | #  | $$    $$| $$| $$  | $$| $$  | $$| $$  | $$  | $$  | $$  \ $$ /$$  \ $$
  9 | #  |  $$$$$$/| $$|  $$$$$$/|  $$$$$$/|  $$$$$$$ /$$$$$$|  $$$$$$/|  $$$$$$/
 10 | #   \______/ |__/ \______/  \______/  \_______/|______/ \______/  \______/ 
 11 | #                                                                          
 12 | --------------------------------------------------------------------------
 13 | 
14 |

15 | 16 |

17 | 18 | # 准备 19 | 1. Python 版本 >= 3.8 20 | 21 | 2. 安装依赖 22 | ```sh 23 | pip install -r requirements.txt 24 | ``` 25 | > 注意 `pyreadline` 是专门为 `Windows` 设计的,`*nix` 中 python 标准库中一般默认包含 `readline` 模块,没有请看[这里](#jump)。 26 | 27 | 3. 配置 28 | 运行 ``python main.py``, 输入用户名与密码, 29 | 账号为自己的天翼云盘手机号,密码不会有回显, 30 | 也可以 直接两次回车后,输入 `clogin` 按提示输入 `cookie` 登录。 31 | 所有信息**加密** 保存至 `.config` 文件。 32 | 33 | # 功能 34 | 35 | |命令 |描述 | 36 | |-------------------------------------|-----------------------| 37 | |help |查看帮助文档 | 38 | |login |用户名+密码 登录/添加用户 | 39 | |clogin |cookie 登录/添加用户 | 40 | |refresh |刷新当前目录 | 41 | |setpath |修改下载路径(默认 ./downloads) | 42 | |update |检测软件更新 | 43 | |who/quota |查看账户信息、空间大小 | 44 | |clear |清屏 | 45 | |cdrec |进入回收站 | 46 | |[cdrec] ls |显示回收站目录 | 47 | |[cdrec] rec + `文件名` |恢复文件 | 48 | |[cdrec] clean |清空回收站 | 49 | |[cdrec] cd .. |退出回收站 | 50 | |su + `[-l/用户名]` |列出用户/切换用户 | 51 | |ls + `[-l] [文件夹]` |列出文件与目录 | 52 | |cd + `文件夹名` |切换工作目录 | 53 | |upload + `文件(夹)路径` |上传文件(夹) | 54 | |down + `文件名/分享链接` |下载文件/提取分享链接下载直链 | 55 | |mkdir + `文件夹名` |创建文件夹 | 56 | |rm + `文件/文件夹` |删除文件(夹) | 57 | |share + `文件/文件夹` |分享文件(夹) | 58 | |shared + `[2]` |已经分享文件(夹)信息 | 59 | |jobs + `[-f] [任务id]` |查看后台上传下载任务 | 60 | |rename + `文件(夹)名 [新名]` |重命名 | 61 | |mv + `文件名` |移动文件 | 62 | |sign + `[-a/--all]` |签到抽奖 | 63 | |bye/exit |退出 | 64 | 65 | 详细请移步 [Wiki](https://github.com/Aruelius/cloud189/wiki). 66 | 67 | `ll = ls -l` 表示列出详细列表,`ls` 只显示文件(夹)名,都可以接一个一级子文件夹作为参数。 68 | `down`、`upload`、`rm` 支持多个多个操作文件作为参数,如果文件名中有空格引号,使用 `''`、`""` 包裹文件名,或则在空格引号前使用转义符 `\`。 69 | `jobs -f`、`upload -f`、`down -f`表示实时查看任务状态,类似于 `Linux` 中的 `tail -f`,按任意键 + 回车 退出。 70 | 使用账号密码登录时,上传文件时会**先**进行文件秒传检测,目前使用 cookie 登录无法秒传。 71 | 下载支持断点续传。 72 | 注意:从 **v0.0.4** 起,`.config` 文件与以前版本不兼容! 73 | 74 | # 使用 75 | 1. 不加参数则进入交互模式 76 | ```sh 77 | # 提示符为 > 78 | python3 main.py 79 | > cd '文件夹' 80 | ... 81 | > ls 82 | ... 83 | > bye 84 | ``` 85 | 86 | 2. 带上命令与参数进入单任务模式 87 | ```sh 88 | python3 main.py upload '文件路径' 89 | # 或者 90 | ./main.py upload '文件路径' 91 | ``` 92 | 93 | # 依赖 94 | 如果在 Linux 运行出现 95 | ~~~shell 96 | import readline 97 | ValueError: _type_ 'v' not supported 98 | ~~~ 99 | 需要安装依赖,然后重新编译 Python 100 | Ubuntu 101 | ~~~shell 102 | sudo apt-get install libreadline-dev 103 | ~~~ 104 | CentOS 105 | ~~~shell 106 | yum install readline-devel 107 | ~~~ 108 | # License 109 | 110 | [GPL-3.0](https://github.com/Aruelius/cloud189/blob/master/LICENSE) 111 | 112 | # 致谢 113 | 114 | > [LanZouCloud-CMD](https://github.com/zaxtyson/LanZouCloud-CMD) 115 | > [Dawnnnnnn/Cloud189](https://github.com/Dawnnnnnn/Cloud189) 116 | -------------------------------------------------------------------------------- /cloud189/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ['api', 'cli'] 2 | -------------------------------------------------------------------------------- /cloud189/api/__init__.py: -------------------------------------------------------------------------------- 1 | from cloud189.api.core import Cloud189 2 | 3 | version = '0.0.5' 4 | 5 | __all__ = ['utils', 'Cloud189', 'models', 'token', 'version'] 6 | -------------------------------------------------------------------------------- /cloud189/api/core.py: -------------------------------------------------------------------------------- 1 | """ 2 | 天翼云盘 API,封装了对天翼云的各种操作 3 | """ 4 | 5 | import os 6 | import re 7 | import json 8 | import simplejson 9 | from time import sleep 10 | 11 | from xml.etree import ElementTree 12 | import requests 13 | from requests_toolbelt import MultipartEncoder, MultipartEncoderMonitor 14 | from urllib3 import disable_warnings 15 | from urllib3.exceptions import InsecureRequestWarning 16 | 17 | from cloud189.api.utils import * 18 | from cloud189.api.types import * 19 | from cloud189.api.models import * 20 | 21 | __all__ = ['Cloud189'] 22 | 23 | 24 | class Cloud189(object): 25 | FAILED = -1 26 | SUCCESS = 0 27 | ID_ERROR = 1 28 | PASSWORD_ERROR = 2 29 | LACK_PASSWORD = 3 30 | MKDIR_ERROR = 5 31 | URL_INVALID = 6 32 | FILE_CANCELLED = 7 33 | PATH_ERROR = 8 34 | NETWORK_ERROR = 9 35 | CAPTCHA_ERROR = 10 36 | UP_COMMIT_ERROR = 4 # 上传文件 commit 错误 37 | UP_CREATE_ERROR = 11 # 创建上传任务出错 38 | UP_UNKNOWN_ERROR = 12 # 创建上传任务未知错误 39 | UP_EXHAUSTED_ERROR = 13 # 上传量用完 40 | UP_ILLEGAL_ERROR = 14 # 文件非法 41 | 42 | def __init__(self): 43 | self._session = requests.Session() 44 | self._captcha_handler = None 45 | self._timeout = 15 # 每个请求的超时(不包含下载响应体的用时) 46 | self._host_url = 'https://cloud.189.cn' 47 | self._auth_url = 'https://open.e.189.cn/api/logbox/oauth2/' 48 | self._cookies = None 49 | self._sessionKey = "" 50 | self._sessionSecret = "" 51 | self._accessToken = "" 52 | self._headers = { 53 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:74.0) Gecko/20100101 Firefox/76.0', 54 | 'Referer': 'https://open.e.189.cn/', 55 | 'Accept': 'application/json;charset=UTF-8', 56 | } 57 | disable_warnings(InsecureRequestWarning) # 全局禁用 SSL 警告 58 | 59 | def _get(self, url, **kwargs): 60 | try: 61 | kwargs.setdefault('timeout', self._timeout) 62 | kwargs.setdefault('headers', self._headers) 63 | return self._session.get(url, verify=False, **kwargs) 64 | except requests.Timeout: 65 | logger.warning( 66 | "Encountered timeout error while requesting network!") 67 | raise TimeoutError 68 | except (requests.RequestException, Exception) as e: 69 | logger.error(f"Unexpected error: {e=}") 70 | 71 | def _post(self, url, data, **kwargs): 72 | try: 73 | kwargs.setdefault('timeout', self._timeout) 74 | kwargs.setdefault('headers', self._headers) 75 | return self._session.post(url, data, verify=False, **kwargs) 76 | except requests.Timeout: 77 | logger.warning( 78 | "Encountered timeout error while requesting network!") 79 | raise TimeoutError 80 | except (requests.RequestException, Exception) as e: 81 | logger.error(f"Unexpected error: {e=}") 82 | 83 | def set_session(self, key, secret, token): 84 | self._sessionKey = key 85 | self._sessionSecret = secret 86 | self._accessToken = token 87 | 88 | def set_captcha_handler(self, captcha_handler): 89 | """设置下载验证码处理函数 90 | :param captcha_handler (img_data) -> str 参数为图片二进制数据,需返回验证码字符 91 | """ 92 | self._captcha_handler = captcha_handler 93 | 94 | def get_cookie(self): 95 | return self._session.cookies.get_dict() 96 | 97 | def _needcaptcha(self, captchaToken, username): 98 | """登录验证码处理函数""" 99 | url = self._auth_url + "needcaptcha.do" 100 | post_data = { 101 | "accountType": "01", 102 | "userName": "{RSA}" + b64tohex(encrypt(username)), 103 | "appKey": "cloud" 104 | } 105 | r = self._post(url, data=post_data) 106 | captcha = "" 107 | if r.text != "0": # 需要验证码 108 | if self._captcha_handler: 109 | pic_url = self._auth_url + "picCaptcha.do" 110 | img_data = self._get( 111 | pic_url, params={"token": captchaToken}).content 112 | captcha = self._captcha_handler(img_data) # 用户手动识别验证码 113 | else: 114 | logger.error("No verification code processing function!") 115 | return captcha 116 | 117 | def login_by_cookie(self, config): 118 | """使用 cookie 登录""" 119 | cookies = config if isinstance(config, dict) else config.cookie 120 | try: 121 | for k, v in cookies.items(): 122 | self._session.cookies.set(k, v, domain=".cloud.189.cn") 123 | resp = self._get(self._host_url + "/v2/getUserLevelInfo.action") 124 | if "InvalidSessionKey" not in resp.text: 125 | try: 126 | self.set_session(config.key, config.secret, config.token) 127 | except: 128 | pass 129 | return Cloud189.SUCCESS 130 | except: 131 | pass 132 | return Cloud189.FAILED 133 | 134 | def login(self, username, password): 135 | """使用 用户名+密码 登录""" 136 | url = self._host_url + "/api/portal/loginUrl.action" 137 | params = {"pageId": 1, "redirectURL": "https://cloud.189.cn/main.action"} 138 | resp = self._get(url, params=params) 139 | if not resp: 140 | logger.error("redirect error!") 141 | return Cloud189.NETWORK_ERROR 142 | # captchaToken = re.search(r"captchaToken' value='(.+?)'", resp.text) 143 | captchaToken = re.search(r"captchaToken\W*value=\W*(\w*)", resp.text) 144 | # returnUrl = re.search(r"returnUrl = '(.+?)'", resp.text) 145 | returnUrl = re.search(r"returnUrl =\W*([^'\"]*)", resp.text) 146 | # paramId = re.search(r'paramId *=\W*(\w*)', resp.text) 147 | paramId = re.search(r'paramId =\W*(\w*)', resp.text) 148 | # lt = re.search(r'lt = "(.+?)"', resp.text) 149 | lt = re.search(r'lt =\W+(\w*)', resp.text) 150 | 151 | captchaToken = captchaToken.group(1) if captchaToken else "" 152 | lt = lt.group(1) if lt else "" 153 | returnUrl = returnUrl.group(1) if returnUrl else "" 154 | paramId = paramId.group(1) if paramId else "" 155 | logger.debug(f"Login: {captchaToken=}, {lt=}, {returnUrl=}, {paramId=}") 156 | self._session.headers.update({"lt": lt}) 157 | 158 | validateCode = self._needcaptcha(captchaToken, username) 159 | url = self._auth_url + "loginSubmit.do" 160 | data = { 161 | "appKey": "cloud", 162 | "accountType": '01', 163 | "userName": "{RSA}" + b64tohex(encrypt(username)), 164 | "password": "{RSA}" + b64tohex(encrypt(password)), 165 | "validateCode": validateCode, 166 | "captchaToken": captchaToken, 167 | "returnUrl": returnUrl, 168 | "mailSuffix": "@189.cn", 169 | "paramId": paramId 170 | } 171 | r = self._post(url, data=data) 172 | msg = r.json()["msg"] 173 | if msg == "登录成功": 174 | self._get(r.json()["toUrl"]) 175 | return Cloud189.SUCCESS 176 | print(msg) 177 | return Cloud189.FAILED 178 | 179 | def _get_root_more_page(self, resp: dict, r_path=False) -> (list, bool): 180 | """处理可能需要翻页的请求信息""" 181 | if resp['pageNum'] * resp['pageSize'] >= resp['recordCount']: 182 | done = True # 没有了 183 | else: 184 | done = False 185 | if r_path: 186 | return [resp['data'], resp['path']], done 187 | else: 188 | return resp['data'], done 189 | 190 | def _get_more_page(self, resp: dict, pageNum=1, pageSize=60) -> (bool): 191 | """处理可能需要翻页的请求信息""" 192 | return pageNum * pageSize >= resp['count'] 193 | 194 | def get_rec_file_list(self) -> FileList: 195 | """获取回收站文件夹列表""" 196 | results = FileList() 197 | page = 1 198 | data = [] 199 | url = self._host_url + '/v2/listRecycleBin.action' 200 | 201 | while True: 202 | resp = self._get(url, params={'pageNum': page, 'pageSize': 60}) 203 | if not resp: 204 | logger.error("Rec file list: network error!") 205 | return None 206 | resp = resp.json() 207 | familyId = resp['familyId'] 208 | data_, done = self._get_more_page(resp) 209 | data.extend(data_) 210 | if done: 211 | break 212 | page += 1 213 | sleep(0.5) # 大量请求可能会被限制 214 | 215 | for item in data: 216 | name = item['fileName'] 217 | id_ = item['fileId'] 218 | pid = item['parentId'] 219 | ctime = item['createTime'] 220 | optime = item['lastOpTime'] 221 | size = item['fileSize'] 222 | ftype = item['fileType'] 223 | durl = item['downloadUrl'] 224 | isFolder = item['isFolder'] 225 | isFamily = item['isFamilyFile'] 226 | path = item['pathStr'] 227 | results.append(RecInfo(name=name, id=id_, pid=pid, ctime=ctime, optime=optime, size=size, 228 | ftype=ftype, isFolder=isFolder, durl=durl, isFamily=isFamily, path=path, 229 | fid=familyId)) 230 | return results 231 | 232 | def _batch_task(self, file_info, action: str, target_id: str = '') -> int: 233 | """公共批处理请求 234 | :param file_info: FolderInfo、RecInfo、RecInfo 235 | :param action: RESTORE、DELETE、MOVE、COPY 236 | :param target_id: 移动文件的目标文件夹 id 237 | :return: Cloud189 状态码 238 | """ 239 | task_info = { 240 | "fileId": str(file_info.id), # str 241 | "srcParentId": str(file_info.pid), # str 242 | "fileName": file_info.name, # str 243 | "isFolder": 1 if file_info.isFolder else 0 # int 244 | } 245 | 246 | create_url = self._host_url + "/createBatchTask.action" 247 | post_data = {"type": action, "taskInfos": json.dumps([task_info, ])} 248 | if target_id: 249 | post_data.update({"targetFolderId": target_id}) 250 | resp = self._post(create_url, data=post_data) 251 | task_id = resp.text.strip('"').strip('\'') 252 | logger.debug( 253 | f"Text: {resp.text=}, {task_id=}, {action=}, {target_id=}") 254 | if not task_id: 255 | logger.debug(f"Batch_task: {resp.status_code=}") 256 | return Cloud189.FAILED 257 | 258 | def _check_task(task_id): 259 | check_url = self._host_url + '/checkBatchTask.action' 260 | post_data = {"type": action, "taskId": task_id} 261 | resp = self._post(check_url, data=post_data) 262 | if not resp: 263 | logger.debug("BatchTask[_check] Error!") 264 | resp = resp.json() 265 | if 'taskStatus' in resp: 266 | return resp['taskStatus'] 267 | else: 268 | logger.debug( 269 | f"BatchTask[_check]: {post_data=},{task_id=},{resp=}") 270 | return 5 # 防止无限循环 271 | 272 | task_status = 0 273 | while task_status != 4: 274 | sleep(0.5) 275 | task_status = _check_task(task_id) 276 | return Cloud189.SUCCESS 277 | 278 | def rec_restore(self, file_info): 279 | """还原文件""" 280 | return self._batch_task(file_info, 'RESTORE') 281 | 282 | def rec_delete(self, file_info): 283 | """回收站删除文件""" 284 | url = self._host_url + '/v2/deleteFile.action' 285 | resp = self._get( 286 | url, params={'familyId': file_info.fid, 'fileIdList': file_info.id}) 287 | if resp and resp.json()['success']: 288 | return Cloud189.SUCCESS 289 | else: 290 | return Cloud189.FAILED 291 | 292 | def rec_empty(self, file_info): 293 | """清空回收站""" 294 | url = self._host_url + '/v2/emptyRecycleBin.action' 295 | resp = self._get(url, params={'familyId': file_info.fid}) 296 | if resp and resp.json()['success']: 297 | return Cloud189.SUCCESS 298 | else: 299 | return Cloud189.FAILED 300 | 301 | def share_file(self, fid, et=None, ac=None): 302 | '''分享文件''' 303 | expireTime_dict = {"1": "1", "2": "7", "3": "2099"} 304 | if et and et in ('1', '2', '3'): 305 | expireTime = et 306 | else: 307 | expireTime = input("请选择分享有效期:1、1天,2、7天,3、永久:") 308 | if ac and ac in ('1', '2'): 309 | withAccessCode = ac 310 | else: 311 | withAccessCode = input("请选择分享形式:1、私密分享,2、公开分享:") 312 | if withAccessCode == "1": 313 | url = self._host_url + "/v2/privateLinkShare.action" 314 | params = { 315 | "fileId": str(fid), 316 | "expireTime": expireTime_dict[expireTime], 317 | "withAccessCode": withAccessCode 318 | } 319 | else: 320 | url = self._host_url + "/v2/createOutLinkShare.action" 321 | params = { 322 | "fileId": str(fid), 323 | "expireTime": expireTime_dict[expireTime] 324 | } 325 | resp = self._get(url=url, params=params) 326 | if not resp: 327 | logger.error(f"Share file: {fid=}network error!") 328 | return ShareCode(Cloud189.FAILED) 329 | resp = resp.json() 330 | share_url = resp['shortShareUrl'] 331 | pwd = resp['accessCode'] if 'accessCode' in resp else '' 332 | return ShareCode(Cloud189.SUCCESS, share_url, pwd, expireTime) 333 | 334 | def get_root_file_list(self) -> (FileList, PathList): 335 | """获取根目录下文件列表的方法""" 336 | fid = -11 337 | file_list = FileList() 338 | path_list = PathList() 339 | page = 1 340 | data_path = [] 341 | data = [] 342 | path = [] 343 | url = self._host_url + "/api/portal/listFiles.action" 344 | while True: 345 | params = { 346 | "fileId": str(fid), 347 | "noCache": "0.9551043190321311" 348 | } 349 | resp = self._get(url, params=params) 350 | if not resp: 351 | logger.error(f"File list: {fid=}network error!") 352 | return file_list, path_list 353 | if not resp: 354 | logger.error(f"File list: {fid=}network error!") 355 | return file_list, path_list 356 | try: 357 | resp = resp.json() 358 | except (json.JSONDecodeError, simplejson.errors.JSONDecodeError): 359 | # 如果 fid 文件夹被删掉,resp 是 200 但是无法使用 json 方法 360 | logger.error(f"File list: {fid=} not exit") 361 | return file_list, path_list 362 | if 'errorCode' in resp: 363 | logger.error(f"Get file: {resp}") 364 | return file_list, path_list 365 | data_, done = self._get_root_more_page(resp, r_path=True) 366 | data_path.append(data_) 367 | if done: 368 | break 369 | page += 1 370 | sleep(0.5) # 大量请求可能会被限制 371 | for data_ in data_path: 372 | data.extend(data_[0]) 373 | if not path: 374 | path = data_[1] # 不同 page 路径应该是一样的 375 | for item in data: 376 | name = item['fileName'] 377 | id_ = int(item['fileId']) 378 | pid = int(item['parentId']) 379 | ctime = item['createTime'] 380 | optime = item['lastOpTime'] 381 | size = item['fileSize'] if 'fileSize' in item else '' 382 | ftype = item['fileType'] 383 | durl = item['downloadUrl'] if 'downloadUrl' in item else '' 384 | isFolder = item['isFolder'] 385 | isStarred = item['isStarred'] 386 | file_list.append(FileInfo(name=name, id=id_, pid=pid, ctime=ctime, optime=optime, size=size, 387 | ftype=ftype, durl=durl, isFolder=isFolder, isStarred=isStarred)) 388 | for item in path: 389 | path_list.append(PathInfo(name=item['fileName'], id=int(item['fileId']), 390 | isCoShare=item['isCoShare'])) 391 | 392 | return file_list, path_list 393 | 394 | 395 | def get_file_list(self, fid) -> (FileList): 396 | """获取文件列表""" 397 | file_list = FileList() 398 | path_list = PathList() 399 | page = 1 400 | data = [] 401 | url = self._host_url + "/api/open/file/listFiles.action" 402 | while True: 403 | params = { 404 | "folderId": str(fid), 405 | "orderBy": "lastOpTime", 406 | "descending": "true", 407 | "pageNum": page, 408 | "pageSize": 60, 409 | "iconOption": 5, 410 | "mediaType": 0, 411 | "noCache": "0.10860476256694767" 412 | } 413 | resp = self._get(url, params=params) 414 | if not resp: 415 | logger.error(f"File list: {fid=}network error!") 416 | return file_list, path_list 417 | try: 418 | resp = resp.json() 419 | except (json.JSONDecodeError, simplejson.errors.JSONDecodeError): 420 | # 如果 fid 文件夹被删掉,resp 是 200 但是无法使用 json 方法 421 | logger.error(f"File list: {fid=} not exit") 422 | return file_list, path_list 423 | if 'errorCode' in resp: 424 | logger.error(f"Get file: {resp}") 425 | return file_list, path_list 426 | resp = resp["fileListAO"] 427 | done = self._get_more_page(resp, pageNum=page, pageSize=60) 428 | data.extend(resp["folderList"]) 429 | data.extend(resp["fileList"]) 430 | if done: 431 | break 432 | page += 1 # 继续循环处理翻页 433 | sleep(0.5) # 大量请求可能会被限制 434 | 435 | for item in data: 436 | name = item['name'] 437 | id_ = int(item['id']) 438 | pid = int(item['parentId']) if 'parentId' in item else '' 439 | ctime = item['createDate'] 440 | optime = item['lastOpTime'] 441 | size = item['size'] if 'size' in item else '' 442 | ftype = '' 443 | durl = '' 444 | isFolder = 'fileCount' in item 445 | isStarred = '' 446 | 447 | file_list.append(FileInfo(name=name, id=id_, pid=pid, ctime=ctime, optime=optime, size=size, 448 | ftype=ftype, durl=durl, isFolder=isFolder, isStarred=isStarred)) 449 | 450 | return file_list, self.get_file_path_list(fid) 451 | 452 | def get_file_path_list(self, fid) -> (PathList): 453 | path_list = PathList() 454 | travel_id = fid 455 | while True: 456 | code, cur_file_info = self.get_file_info_by_id(travel_id) 457 | if code != Cloud189.SUCCESS: 458 | logger.error(f"Get File{fid} info error.") 459 | return None 460 | path_list.insert(0, PathInfo(name=cur_file_info.name, id=cur_file_info.id, isCoShare=0)) 461 | travel_id = cur_file_info.pid 462 | if travel_id == -11: 463 | # 根目录无法通过查询文件信息查询,会返回400,因此直接拼装 464 | path_list.insert(0, PathInfo(name='全部文件', id=-11, isCoShare=0)) 465 | break 466 | sleep(0.2) 467 | 468 | return path_list 469 | 470 | def _create_upload_file(self, up_info: UpInfo) -> (int, tuple): 471 | """创建上传任务,包含秒传检查,返回状态码、创建信息""" 472 | infos = tuple() 473 | try: 474 | url = API + "/createUploadFile.action?{SUFFIX_PARAM}" 475 | date = get_time() 476 | headers = { 477 | "SessionKey": self._sessionKey, 478 | "Sign-Type": "1", 479 | "User-Agent": UA, 480 | "Date": date, 481 | "Signature": calculate_hmac_sign(self._sessionSecret, self._sessionKey, 'POST', url, date), 482 | "Accept": "application/json;charset=UTF-8", 483 | "Content-Type": "application/x-www-form-urlencoded", 484 | } 485 | post_data = { 486 | "parentFolderId": up_info.fid, 487 | "baseFileId": "", 488 | "fileName": up_info.name, 489 | "size": up_info.size, 490 | "md5": get_file_md5(up_info.path, up_info.check), 491 | "lastWrite": "", 492 | "localPath": up_info.path, 493 | "opertype": 1, 494 | "flag": 1, 495 | "resumePolicy": 1, 496 | "isLog": 0 497 | } 498 | resp = requests.post(url, headers=headers, data=post_data, verify=False, timeout=10) 499 | if resp: 500 | resp = resp.json() 501 | if resp['res_message'] == "UserDayFlowOverLimited": 502 | _msg = f"{up_info.path=}, The daily transmission of the current login account has been exhausted!" 503 | code = Cloud189.UP_EXHAUSTED_ERROR 504 | elif resp.get('res_code') == 'InfoSecurityErrorCode': 505 | _msg = f"{up_info.path=} Illegal file!" 506 | code = Cloud189.UP_ILLEGAL_ERROR 507 | elif resp.get('uploadFileId'): 508 | upload_file_id = resp['uploadFileId'] 509 | file_upload_url = resp['fileUploadUrl'] 510 | file_commit_url = resp['fileCommitUrl'] 511 | file_data_exists = resp['fileDataExists'] 512 | node = file_upload_url.split('//')[1].split('.')[0] 513 | _msg = f"successfully created upload task, {node=}" 514 | infos = (upload_file_id, file_upload_url, file_commit_url, file_data_exists) 515 | code = Cloud189.SUCCESS 516 | else: 517 | _msg = f"unknown response {resp=}. Please contact the developer!" 518 | code = Cloud189.UP_UNKNOWN_ERROR 519 | logger.debug(f'Upload by client [create]: {_msg}') 520 | else: 521 | code = Cloud189.NETWORK_ERROR 522 | except Exception as e: 523 | code = Cloud189.UP_CREATE_ERROR 524 | logger.error(f'Upload by client [create]: an error occurred! {e=}') 525 | return code, infos 526 | 527 | def _upload_file_data(self, file_upload_url, upload_file_id, up_info: UpInfo): 528 | """客户端接口上传文件数据""" 529 | url = f"{file_upload_url}?{SUFFIX_PARAM}" 530 | date = get_time() 531 | headers = { 532 | "SessionKey": self._sessionKey, 533 | "Edrive-UploadFileId": str(upload_file_id), 534 | "User-Agent": UA, 535 | "Date": date, 536 | "Signature": calculate_hmac_sign(self._sessionSecret, self._sessionKey, 'PUT', url, date), 537 | "Accept": "application/json;charset=UTF-8", 538 | "Content-Type": "application/octet-stream", 539 | "Edrive-UploadFileRange": f"0-{up_info.size}", 540 | "ResumePolicy": "1" 541 | } 542 | 543 | self._upload_finished_flag = False # 上传完成的标志 544 | 545 | def _call_back(it, chunk_size): 546 | for chunk_now, item in enumerate(it): 547 | yield item 548 | if up_info.callback: 549 | now_size = chunk_now * chunk_size 550 | if not self._upload_finished_flag: 551 | up_info.callback(up_info.path, up_info.size, now_size) 552 | if now_size == up_info.size: 553 | self._upload_finished_flag = True 554 | if up_info.callback: # 保证迭代完后,两者大小一样 555 | up_info.callback(up_info.path, up_info.size, up_info.size) 556 | 557 | chunk_size = get_chunk_size(up_info.size) 558 | with open(up_info.path, 'rb') as f: 559 | chunks = get_upload_chunks(f, chunk_size) 560 | post_data = _call_back(chunks, chunk_size) 561 | 562 | resp = requests.put(url, data=post_data, headers=headers, verify=False, timeout=None) 563 | if resp.text != "": 564 | node = ElementTree.XML(resp.text) 565 | if node.text == "error": 566 | if node.findtext('code') != 'UploadFileCompeletedError': 567 | logger.error( 568 | f"Upload by client [data]: an error occurred while uploading data {node.findtext('code')},{node.findtext('message')}") 569 | return Cloud189.FAILED 570 | else: 571 | logger.debug(f"Upload by client [data]: upload {up_info.path} success!") 572 | return Cloud189.SUCCESS 573 | 574 | def _upload_client_commit(self, file_commit_url, upload_file_id): 575 | """客户端接口上传确认""" 576 | fid = '' 577 | try: 578 | url = f"{file_commit_url}?{SUFFIX_PARAM}" 579 | date = get_time() # 时间戳 580 | headers = { 581 | "SessionKey": self._sessionKey, 582 | "User-Agent": UA, 583 | "Date": date, 584 | "Signature": calculate_hmac_sign(self._sessionSecret, self._sessionKey, 'POST', url, date), 585 | "Accept": "application/json;charset=UTF-8", 586 | "Content-Type": "application/x-www-form-urlencoded", 587 | } 588 | post_data = { 589 | "uploadFileId": upload_file_id, 590 | "opertype": 1, 591 | "isLog": 0, 592 | "ResumePolicy": 1 593 | } 594 | resp = requests.post(url, data=post_data, headers=headers, verify=False, timeout=10) 595 | node = ElementTree.XML(resp.text) 596 | if node.text != 'error': 597 | fid = node.findtext('id') 598 | fname = node.findtext('name') 599 | time = node.findtext('createDate') 600 | logger.debug(f"Upload by client [commit]: at[{time}] upload [{fname}], {fid=} success!") 601 | else: 602 | logger.error(f'Upload by client [commit]: unknown error {resp.text=}') 603 | except Exception as e: 604 | logger.error(f'Upload by client [commit]: an error occurred! {e=}') 605 | return fid 606 | 607 | def _upload_file_by_client(self, up_info: UpInfo) -> UpCode: 608 | """使用客户端接口上传单文件,支持秒传功能 609 | :param up_info: UpInfo 610 | :return: UpCode 611 | """ 612 | if up_info.callback and up_info.check: 613 | up_info.callback(up_info.path, 1, 0, 'check') 614 | quick_up = False 615 | fid = '' 616 | code, infos = self._create_upload_file(up_info) 617 | if code == Cloud189.SUCCESS: 618 | upload_file_id, file_upload_url, file_commit_url, file_data_exists = infos 619 | if file_data_exists == 1: # 数据存在,进入秒传流程 620 | logger.debug(f"Upload by client: [{up_info.path}] enter the quick_up process...") 621 | fid = self._upload_client_commit(file_commit_url, upload_file_id) 622 | if fid: 623 | call_back_msg = 'quick_up' 624 | quick_up = True 625 | else: 626 | call_back_msg = 'error' 627 | code = Cloud189.UP_COMMIT_ERROR 628 | else: # 上传文件数据 629 | logger.debug(f"Upload by client: [{up_info.path}] enter the normal upload process...") 630 | code = self._upload_file_data(file_upload_url, upload_file_id, up_info) 631 | if code == Cloud189.SUCCESS: 632 | call_back_msg = None 633 | fid = self._upload_client_commit(file_commit_url, upload_file_id) 634 | else: 635 | call_back_msg = 'error' 636 | logger.debug(f"Upload by client: [{up_info.path}] normal upload failed!") 637 | elif code == Cloud189.UP_ILLEGAL_ERROR: 638 | call_back_msg = 'illegal' 639 | elif code == Cloud189.UP_EXHAUSTED_ERROR: 640 | call_back_msg = 'exhausted' 641 | else: 642 | call_back_msg = 'error' 643 | 644 | if up_info.callback and call_back_msg: 645 | up_info.callback(up_info.path, 1, 1, call_back_msg) 646 | return UpCode(code=code, id=fid, quick_up=quick_up, path=up_info.path) 647 | 648 | def _upload_file_by_web(self, up_info: UpInfo) -> UpCode: 649 | """使用网页接口上传单文件,不支持秒传 650 | :param up_info: UpInfo 651 | :return: UpCode 652 | """ 653 | headers = {'Referer': self._host_url} 654 | url = self._host_url + "/v2/getUserUploadUrl.action" 655 | resp = self._get(url, headers=headers) 656 | if not resp: 657 | logger.error(f"Upload by web: [{up_info.path}] network error(1)!") 658 | if up_info.callback: 659 | up_info.callback(up_info.path, 1, 1, 'error') 660 | return UpCode(code=Cloud189.NETWORK_ERROR, path=up_info.path) 661 | resp = resp.json() 662 | if 'uploadUrl' in resp: 663 | upload_url = "https:" + resp['uploadUrl'] 664 | else: 665 | logger.error(f"Upload by web: [{up_info.path}] failed to obtain upload node!") 666 | upload_url = '' 667 | 668 | self._session.headers["Referer"] = self._host_url # 放到 headers? 669 | 670 | headers.update({"Host": "cloud.189.cn"}) 671 | url = self._host_url + "/main.action" 672 | resp = self._get(url, headers=headers) 673 | if not resp: 674 | logger.error(f"Upload by web: [{up_info.path}] network error(2)!") 675 | if up_info.callback: 676 | up_info.callback(up_info.path, 1, 1, 'error') 677 | return UpCode(code=Cloud189.NETWORK_ERROR, path=up_info.path) 678 | sessionKey = re.findall(r"sessionKey = '(.+?)'", resp.text)[0] 679 | 680 | def _call_back(read_monitor): 681 | if up_info.callback: 682 | if not self._upload_finished_flag: 683 | up_info.callback(up_info.path, read_monitor.len, read_monitor.bytes_read) 684 | if read_monitor.len == read_monitor.bytes_read: 685 | self._upload_finished_flag = True 686 | 687 | with open(up_info.path, 'rb') as file_: 688 | post_data = MultipartEncoder({ 689 | "parentId": up_info.fid, 690 | "fname": up_info.name, 691 | "sessionKey": sessionKey, 692 | "albumId": "undefined", 693 | "opertype": "1", 694 | "upload_file": (up_info.name, file_, 'application/octet-stream') 695 | }) 696 | headers = {"Content-Type": post_data.content_type} 697 | self._upload_finished_flag = False # 上传完成的标志 698 | 699 | monitor = MultipartEncoderMonitor(post_data, _call_back) 700 | result = self._post(upload_url, data=monitor, headers=headers, timeout=None) 701 | fid = '' 702 | if result: 703 | result = result.json() 704 | if 'id' in result: 705 | call_back_msg = '' 706 | fid = result['id'] 707 | code = Cloud189.SUCCESS 708 | else: 709 | call_back_msg = 'error' 710 | code = Cloud189.FAILED 711 | logger.error(f"Upload by web: [{up_info.path}] failed, {result=}") 712 | else: # 网络异常 713 | call_back_msg = 'error' 714 | code = Cloud189.NETWORK_ERROR 715 | logger.error(f"Upload by web: [{up_info.path}] network error(3)!") 716 | if up_info.callback: 717 | up_info.callback(up_info.path, 1, 1, call_back_msg) 718 | return UpCode(code=code, id=fid, path=up_info.path) 719 | 720 | 721 | def _check_up_file_exist(self, up_info: UpInfo) -> UpInfo: 722 | """检查文件是否已经存在""" 723 | if not up_info.force: 724 | files_info, _ = self.get_file_list(up_info.fid) 725 | for file_info in files_info: 726 | if up_info.name == file_info.name and up_info.size == file_info.size: 727 | logger.debug(f"Check file exist: {up_info.path} already exist! {file_info.id}") 728 | up_info = up_info._replace(id=file_info.id, exist=True) 729 | break 730 | return up_info 731 | 732 | def upload_file(self, file_path, folder_id=-11, force=False, callback=None) -> UpCode: 733 | """单个文件上传接口 734 | :param str file_path: 待上传文件路径 735 | :param int folder_id: 上传目录 id 736 | :param bool force: 强制上传已经存在的文件(文件名、大小一致的文件) 737 | :param func callback: 上传进度回调 738 | :return: UpCode 739 | """ 740 | if not os.path.isfile(file_path): 741 | logger.error(f"Upload file: [{file_path}] is not a file!") 742 | return UpCode(code=Cloud189.PATH_ERROR, path=file_path) 743 | 744 | file_name = os.path.basename(file_path) 745 | file_size = os.path.getsize(file_path) # Byte 746 | up_info = self._check_up_file_exist(UpInfo(name=file_name, path=file_path, size=file_size, 747 | fid=str(folder_id), force=force, callback=callback)) 748 | if not force and up_info.exist: 749 | logger.debug(f"Abandon upload because the file is already exist: {file_path=}") 750 | if up_info.callback: 751 | up_info.callback(up_info.path, 1, 1, 'exist') 752 | return UpCode(code=Cloud189.SUCCESS, id=up_info.id, path=file_path) 753 | elif self._sessionKey and self._sessionSecret and self._accessToken: 754 | logger.debug(f"Use the client interface to upload files: {file_path=}, {folder_id=}") 755 | return self._upload_file_by_client(up_info) 756 | else: 757 | logger.debug(f"Use the web interface to upload files: {file_path=}, {folder_id=}") 758 | return self._upload_file_by_web(up_info) 759 | 760 | def upload_dir(self, folder_path, parrent_fid=-11, force=False, mkdir=True, callback=None, 761 | failed_callback=None, up_handler= None): 762 | """文件夹上传接口 763 | :param str file_path: 待上传文件路径 764 | :param int folder_id: 上传目录 id 765 | :param bool force: 强制上传已经存在的文件(文件名、大小一致的文件) 766 | :param bool mkdir: 是否在 parrent_fid 创建父文件夹 767 | :param func callback: 上传进度回调 768 | :param func failed_callback: 错误回调 769 | :param func up_handler: 上传文件数回调 770 | :return: UpCode list or Cloud189 error code(mkdir error) 771 | """ 772 | if not os.path.isdir(folder_path): 773 | logger.error(f"Upload dir: [{folder_path}] is not a file") 774 | return UpCode(Cloud189.PATH_ERROR) 775 | 776 | dir_dict = {} 777 | logger.debug(f'Upload dir: start parsing {folder_path=} structure...') 778 | upload_files = [] 779 | folder_name = get_file_name(folder_path) 780 | if mkdir: 781 | result = self.mkdir(parrent_fid, folder_name) 782 | if result.code != Cloud189.SUCCESS: 783 | return result # MkCode 784 | 785 | dir_dict[folder_name] = result.id 786 | else: 787 | dir_dict[folder_name] = parrent_fid 788 | 789 | for home, dirs, files in os.walk(folder_path): 790 | for _file in files: 791 | f_path = home + os.sep + _file 792 | f_rfolder = get_relative_folder(f_path, folder_path) 793 | logger.debug(f"Upload dir: {f_rfolder=}, {f_path=}, {folder_path=}") 794 | if f_rfolder not in dir_dict: 795 | dir_dict[f_rfolder] = '' 796 | upload_files.append((f_path, dir_dict[f_rfolder])) 797 | for _dir in dirs: 798 | p_rfolder = get_relative_folder( 799 | home, folder_path, is_file=False) 800 | logger.debug(f"Upload dir: {p_rfolder=}, {home=}, {folder_path=}") 801 | dir_rname = p_rfolder + os.sep + _dir # 文件夹相对路径 802 | 803 | result = self.mkdir(dir_dict[p_rfolder], _dir) 804 | if result.code != Cloud189.SUCCESS: 805 | logger.error( 806 | f"Upload dir: create a folder in the upload sub folder{dir_rname=} failed! {folder_name=}, {dir_dict[p_rfolder]=}") 807 | return result # MkCode 808 | logger.debug( 809 | f"Upload dir: folder successfully created {folder_name=}, {dir_dict[p_rfolder]=}, {dir_rname=}, {result.id}") 810 | dir_dict[dir_rname] = result.id 811 | up_codes = [] 812 | total_files = len(upload_files) 813 | for index, upload_file in enumerate(upload_files, start=1): 814 | if up_handler: 815 | up_handler(index, total_files) 816 | logger.debug(f"Upload dir: file [{upload_file[0]}] enter upload process...") 817 | up_code = self.upload_file(upload_file[0], upload_file[1], force=force, callback=callback) 818 | if failed_callback and up_code.code != Cloud189.SUCCESS: 819 | failed_callback(up_code.code, up_code.path) 820 | logger.debug(f"Up Dir Code: {up_code.code=}, {up_code.path=}") 821 | up_codes.append(up_code) 822 | logger.debug(f"Dir: {index=}, {total_files=}") 823 | return up_codes 824 | 825 | def get_file_info_by_id(self, fid) -> (int, FileInfo): 826 | '''获取文件(夹) 详细信息''' 827 | url = self._host_url + "/v2/getFileInfo.action" 828 | resp = self._get(url, params={'fileId': fid}) 829 | if resp: 830 | resp = resp.json() 831 | else: 832 | return Cloud189.NETWORK_ERROR, FileInfo() 833 | # createAccount # createTime 834 | # fileId # fileIdDigest 835 | # fileName # fileSize 836 | # fileType # isFolder 837 | # lastOpTime # parentId 838 | # subFileCount 839 | name = resp['fileName'] 840 | id_ = int(resp['fileId']) 841 | pid = int(resp['parentId']) 842 | ctime = resp['createTime'] 843 | optime = resp['lastOpTime'] 844 | size = resp['fileSize'] if 'fileSize' in resp else '' 845 | ftype = resp['fileType'] 846 | isFolder = resp['isFolder'] 847 | account = resp['createAccount'] 848 | durl = resp['downloadUrl'] if 'downloadUrl' in resp else '' 849 | count = resp['subFileCount'] if 'subFileCount' in resp else '' 850 | return Cloud189.SUCCESS, FileInfo(name=name, id=id_, pid=pid, ctime=ctime, optime=optime, 851 | size=size, ftype=ftype, isFolder=isFolder, account=account, 852 | durl=durl, count=count) 853 | 854 | def _down_one_link(self, durl, save_path, callback=None) -> int: 855 | """下载器""" 856 | if not os.path.exists(save_path): 857 | os.makedirs(save_path) 858 | os.environ['LANG'] = 'enUS.UTF-8' 859 | resp = self._get(durl, stream=True, timeout=None) 860 | if not resp: 861 | logger.error("Download link: network error!") 862 | return Cloud189.FAILED 863 | 864 | content_d = resp.headers['content-disposition'].encode('latin-1').decode('utf-8') 865 | file_name = re.search(r'filename="(.+)"', content_d) 866 | file_name = file_name.group(1) if file_name else '' 867 | if not file_name: 868 | logger.error("Download link: cannot get file name!") 869 | return Cloud189.FAILED 870 | 871 | file_path = save_path + os.sep + file_name 872 | total_size = resp.headers.get('content-length') 873 | if total_size: 874 | total_size = int(total_size) 875 | else: # no content length in headers 876 | total_size = -1 877 | 878 | now_size = 0 879 | if os.path.exists(file_path) and total_size != -1: 880 | now_size = os.path.getsize(file_path) # 本地已经下载的文件大小 881 | if now_size >= total_size: # 已经下载完成 882 | if callback is not None: 883 | callback(file_name, total_size, now_size, 'exist') 884 | logger.debug(f"Download link: the file already exists in the local {file_name=} {durl=}") 885 | return Cloud189.SUCCESS 886 | else: # 断点续传 887 | headers = {**self._headers, 'Range': 'bytes=%d-' % now_size} 888 | resp = self._get(durl, stream=True, headers=headers, timeout=None) 889 | if not resp: 890 | return Cloud189.FAILED 891 | 892 | logger.debug(f'Download link: {file_path=}, {now_size=}, {total_size=}') 893 | chunk_size = get_chunk_size(total_size) 894 | with open(file_path, "ab") as f: 895 | for chunk in resp.iter_content(chunk_size): 896 | if chunk: 897 | f.write(chunk) 898 | f.flush() 899 | now_size += len(chunk) 900 | if callback: 901 | callback(file_name, total_size, now_size) 902 | if total_size == -1 and callback: 903 | callback(file_name, now_size, now_size) 904 | logger.debug(f"Download link: finished {total_size=}, {now_size=}") 905 | return Cloud189.SUCCESS 906 | 907 | def down_file_by_id(self, fid, save_path='./Download', callback=None) -> int: 908 | """通过 fid 下载单个文件""" 909 | code, infos = self.get_file_info_by_id(fid) 910 | if code != Cloud189.SUCCESS: 911 | logger.error(f"Down by id: 获取文件{fid=}详情失败!") 912 | return code 913 | durl = 'https:' + infos.durl 914 | return self._down_one_link(durl, save_path, callback) 915 | 916 | def down_dirzip_by_id(self, fid, save_path='./Download', callback=None) -> int: 917 | """打包下载文件夹""" 918 | url = self._host_url + '/downloadMultiFiles.action' 919 | params = { 920 | 'fileIdS': fid, 921 | 'downloadType': 1, 922 | 'recursive': 1 923 | } 924 | resp = self._get(url, params=params, allow_redirects=False) 925 | if resp.status_code == requests.codes['found']: # 302 926 | durl = resp.headers.get("Location") 927 | else: 928 | logger.debug(f"Down folder failed: {resp.status_code}") 929 | return Cloud189.FAILED 930 | 931 | return self._down_one_link(durl, save_path, callback) 932 | 933 | def delete_by_id(self, fid): 934 | '''删除文件(夹)''' 935 | code, infos = self.get_file_info_by_id(fid) 936 | if code != Cloud189.SUCCESS: 937 | logger.error(f"Delete by id: get file's {fid=} details failed!") 938 | return code 939 | 940 | return self._batch_task(infos, 'DELETE') 941 | 942 | def move_file(self, info, target_id): 943 | '''移动文件(夹)''' 944 | return self._batch_task(info, 'MOVE', str(target_id)) 945 | 946 | def cpoy_file(self, tasks, fid): 947 | '''复制文件(夹)''' 948 | code, infos = self.get_file_info_by_id(fid) 949 | if code != Cloud189.SUCCESS: 950 | logger.error(f"Copy by id: get file's {fid=} details failed!") 951 | return code 952 | 953 | return self._batch_task(infos, 'COPY') 954 | 955 | def mkdir(self, parent_id, fname): 956 | '''新建文件夹, 如果存在该文件夹,会返回存在的文件夹 id''' 957 | url = self._host_url + '/v2/createFolder.action' 958 | result = self._get( 959 | url, params={'parentId': str(parent_id), 'fileName': fname}) 960 | if not result: 961 | logger.error("Mkdir: network error!") 962 | return MkCode(Cloud189.NETWORK_ERROR) 963 | result = result.json() 964 | if 'fileId' in result: 965 | return MkCode(Cloud189.SUCCESS, result['fileId']) 966 | else: 967 | logger.error(f"Mkdir: unknown error {result=}") 968 | return MkCode(Cloud189.MKDIR_ERROR) 969 | 970 | def rename(self, fid, fname): 971 | ''''重命名文件(夹)''' 972 | url = self._host_url + '/v2/renameFile.action' 973 | resp = self._get(url, params={'fileId': str(fid), 'fileName': fname}) 974 | if not resp: 975 | logger.error("Rename: network error!") 976 | return Cloud189.NETWORK_ERROR 977 | resp = resp.json() 978 | if 'success' in resp: 979 | return Cloud189.SUCCESS 980 | logger.error(f"Rename: unknown error {resp=}, {fid=}, {fname=}") 981 | return Cloud189.FAILED 982 | 983 | def get_folder_nodes(self, fid=None, max_deep=5) -> TreeList: 984 | '''获取子文件夹信息 985 | :param fid: 需要获取子文件夹的文件夹id,None 表示获取所有文件夹 986 | :param max_deep: 子文件夹最大递归深度 987 | :return: TreeList 类 988 | ''' 989 | tree = TreeList() 990 | url = self._host_url + "/getObjectFolderNodes.action" 991 | post_data = {"orderBy": '1', 'order': 'ASC'} 992 | deep = 1 993 | 994 | def _get_sub_folder(fid, deep): 995 | if fid: 996 | post_data.update({"id": str(fid)}) 997 | params = {'pageNum': 1, 'pageSize': 500} # 应该没有大于 500 个文件夹的吧? 998 | resp = self._post(url, params=params, data=post_data) 999 | if not resp: 1000 | return 1001 | for folder in resp.json(): 1002 | name = folder['name'] 1003 | id_ = int(folder['id']) 1004 | pid = int(folder['pId']) if 'pId' in folder else '' 1005 | isParent = folder['isParent'] # str 1006 | tree.append(FolderTree(name=name, id=id_, pid=pid, 1007 | isParent=isParent), repeat=False) 1008 | logger.debug( 1009 | f"Sub Folder: {name=}, {id_=}, {pid=}, {isParent=}") 1010 | if deep < max_deep: 1011 | _get_sub_folder(id_, deep + 1) 1012 | 1013 | _get_sub_folder(fid, deep) 1014 | logger.debug(f"Sub Folder Tree len: {len(tree)}") 1015 | return tree 1016 | 1017 | def list_shared_url(self, stype: int, page: int = 1) -> FileList: 1018 | """列出自己的分享文件、转存的文件链接 1019 | :param stype: 1 发出的分享,2 收到的分享 1020 | :param page: 页面,60 条记录一页 1021 | :return: FileList 类 1022 | """ 1023 | get_url = self._host_url + "/v2/listShares.action" 1024 | data = [] 1025 | while True: 1026 | params = {"shareType": stype, "pageNum": page, "pageSize": 60} 1027 | resp = self._get(get_url, params=params) 1028 | if not resp: 1029 | logger.error("List shared: network error!") 1030 | return None 1031 | resp = resp.json() 1032 | data_, done = self._get_more_page(resp) 1033 | data.extend(data_) 1034 | if done: 1035 | break 1036 | page += 1 1037 | sleep(0.5) # 大量请求可能会被限制 1038 | results = FileList() 1039 | for item in data: 1040 | name = item['fileName'] 1041 | id_ = item['fileId'] 1042 | ctime = item['shareTime'] 1043 | size = item['fileSize'] 1044 | ftype = item['fileType'] 1045 | isFolder = item['isFolder'] 1046 | pwd = item['accessCode'] 1047 | copyC = item['accessCount']['copyCount'] 1048 | downC = item['accessCount']['downloadCount'] 1049 | prevC = item['accessCount']['previewCount'] 1050 | url = item['accessURL'] 1051 | durl = item['downloadUrl'] 1052 | path = item['filePath'] 1053 | need_pwd = item['needAccessCode'] 1054 | s_type = item['shareType'] 1055 | s_mode = item['shareMode'] 1056 | r_stat = item['reviewStatus'] 1057 | 1058 | results.append(ShareInfo(name=name, id=id_, ctime=ctime, size=size, ftype=ftype, 1059 | isFolder=isFolder, pwd=pwd, copyC=copyC, downC=downC, prevC=prevC, 1060 | url=url, durl=durl, path=path, need_pwd=need_pwd, s_type=s_type, 1061 | s_mode=s_mode, r_stat=r_stat)) 1062 | 1063 | return results 1064 | 1065 | def get_share_folder_info(self, share_id, verify_code, pwd='undefined'): 1066 | """获取分享的文件夹信息""" 1067 | result = [] 1068 | page = 1 1069 | info_url = self._host_url + '/v2/listShareDir.action' 1070 | while True: 1071 | params = { 1072 | 'shareId': share_id, 1073 | 'verifyCode': verify_code, 1074 | 'accessCode': pwd or 'undefined', 1075 | 'orderBy': 1, 1076 | 'order': 'ASC', 1077 | 'pageNum': page, 1078 | 'pageSize': 60 1079 | } 1080 | resp = requests.get(info_url, params=params, headers=self._headers, verify=False) 1081 | if not resp: 1082 | return None 1083 | resp = resp.json() 1084 | if 'errorVO' in resp: 1085 | print("是文件夹,并且需要密码或者密码错误!") 1086 | logger.debug("Access password is required!") 1087 | return None 1088 | for item in resp['data']: 1089 | durl = 'https:' + item['downloadUrl'] if 'downloadUrl' in item else '' 1090 | print('#', item['fileId'], '文件名', item['fileName'], item['fileSize'], durl) 1091 | if resp['recordCount'] <= resp['pageSize'] * resp['pageNum']: 1092 | break 1093 | 1094 | page += 1 1095 | print(f"共 {resp['recordCount']} 条记录") 1096 | return result 1097 | 1098 | def get_share_file_info(self, share_id, pwd=''): 1099 | """获取分享的文件信息""" 1100 | verify_url = self._host_url + "/shareFileVerifyPass.action" 1101 | params = { 1102 | 'fileVO.id': share_id, 1103 | 'accessCode': pwd 1104 | } 1105 | resp = requests.get(verify_url, params=params, verify=False) 1106 | if not resp: 1107 | return None 1108 | resp = resp.json() 1109 | if not resp: 1110 | print("是文件,并且需要密码或者密码错误!") 1111 | return None 1112 | f_id = resp['fileId'] 1113 | f_name = resp['fileName'] 1114 | f_size = resp['fileSize'] 1115 | f_type = resp['fileType'] 1116 | durl = resp['longDownloadUrl'] 1117 | 1118 | print(f_id, f_name, f_size, f_type, durl) 1119 | 1120 | def get_file_info_by_url(self, share_url, pwd=''): 1121 | """通过分享链接获取信息""" 1122 | 1123 | first_page = requests.get(share_url, headers=self._headers, verify=False) 1124 | if not first_page: 1125 | logger.error("File info: network error!") 1126 | return None 1127 | first_page = first_page.text 1128 | # 抱歉,您访问的页面地址有误,或者该页面不存在 1129 | if '您访问的页面地址有误' in first_page: 1130 | logger.debug(f"The sharing link has been cancelled {share_url}") 1131 | return None 1132 | if 'window.fileName' in first_page: # 文件 1133 | share_id = re.search(r'class="shareId" value="(\w+?)"', first_page).group(1) 1134 | # 没有密码,则直接暴露 durl 1135 | durl = re.search(r'class="downloadUrl" value="(\w+?)"', first_page) 1136 | if durl: 1137 | durl = durl.group(1) 1138 | print('直链:', durl) 1139 | return None 1140 | is_file = True 1141 | else: # 文件夹 1142 | share_id = re.search(r"_shareId = '(\w+?)';", first_page).group(1) 1143 | verify_code = re.search(r"_verifyCode = '(\w+?)';", first_page).group(1) 1144 | is_file = False 1145 | 1146 | if is_file: 1147 | return self.get_share_file_info(share_id, pwd) 1148 | else: 1149 | return self.get_share_folder_info(share_id, verify_code, pwd) 1150 | 1151 | def user_sign(self): 1152 | """签到 + 抽奖""" 1153 | sign_url = API + '//mkt/userSign.action' 1154 | headers = { 1155 | 'SessionKey': self._sessionKey 1156 | } 1157 | resp = requests.get(sign_url, headers=headers, verify=False) 1158 | if not resp: 1159 | logger.error("Sign: network error!") 1160 | if resp.status_code != requests.codes.ok: 1161 | print(f"签到失败 {resp=}, {headers=}") 1162 | else: 1163 | msg = re.search(r'获得.+?空间', resp.text) 1164 | msg = msg.group() if msg else "" 1165 | print(f"签到成功!{msg}。每天签到可领取更多福利哟,记得常来!") 1166 | 1167 | url = 'https://m.cloud.189.cn/v2/drawPrizeMarketDetails.action' 1168 | params = { 1169 | 'taskId': 'TASK_SIGNIN', 1170 | 'activityId': 'ACT_SIGNIN' 1171 | } 1172 | for i in range(1, 3): 1173 | resp = self._get(url, params=params) 1174 | if not resp: 1175 | logger.error("Sign: network error!") 1176 | resp = resp.json() 1177 | if 'errorCode' in resp: 1178 | print(f"今日抽奖({i})次数已用完: {resp['errorCode']}") 1179 | else: 1180 | print(f"今日抽奖({i})次:{resp['prizeName']}") 1181 | params.update({'taskId': 'TASK_SIGNIN_PHOTOS'}) 1182 | 1183 | def get_user_infos(self): 1184 | """获取登录用户信息""" 1185 | url = self._host_url + "/v2/getLoginedInfos.action" 1186 | resp = self._get(url) 1187 | if not resp: 1188 | logger.error("Get user info: network error!") 1189 | return None 1190 | resp = resp.json() 1191 | id_ = resp['userId'] 1192 | account = resp['userAccount'] 1193 | nickname = resp['nickname'] if 'nickname' in resp else '' 1194 | used = resp['usedSize'] 1195 | quota = resp['quota'] 1196 | vip = resp['superVip'] if 'superVip' in resp else '' 1197 | endTime = resp['superEndTime'] if 'superEndTime' in resp else '' 1198 | beginTime = resp['superBeginTime'] if 'superBeginTime' in resp else '' 1199 | domain = resp['domainName'] if 'domainName' in resp else '' 1200 | return UserInfo(id=id_, account=account, nickname=nickname, used=used, quota=quota, 1201 | vip=vip, endTime=endTime, beginTime=beginTime, domain=domain) 1202 | -------------------------------------------------------------------------------- /cloud189/api/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | 容器类,用于储存文件、文件夹,支持 list 的操作,同时支持许多方法方便操作元素 3 | 元素类型为 namedtuple,至少拥有 name id 两个属性才能放入容器 4 | """ 5 | 6 | __all__ = ['FileList', 'PathList', 'TreeList'] 7 | 8 | 9 | class ItemList: 10 | """具有 name, id 属性对象的列表""" 11 | 12 | def __init__(self): 13 | self._items = [] 14 | 15 | def __len__(self): 16 | return len(self._items) 17 | 18 | def __getitem__(self, index): 19 | return self._items[index] 20 | 21 | def __iter__(self): 22 | return iter(self._items) 23 | 24 | def __repr__(self): 25 | return f"" 26 | 27 | def __lt__(self, other): 28 | """用于路径 List 之间排序""" 29 | return '/'.join(i.name for i in self) < '/'.join(i.name for i in other) 30 | 31 | @property 32 | def name_id(self): 33 | """所有 item 的 name-id 列表,兼容旧版""" 34 | return {it.name: it.id for it in self} 35 | 36 | @property 37 | def all_name(self): 38 | """所有 item 的 name 列表""" 39 | return [it.name for it in self] 40 | 41 | def append(self, item, repeat=True): 42 | """在末尾插入元素""" 43 | if (not repeat) and self.find_by_id(item.id): 44 | # logger.debug(f"List: 不插入元素 {item.name}") 45 | return None 46 | self._items.append(item) 47 | # logger.debug(f"List: 插入元素 {item.name}") 48 | 49 | def index(self, item): 50 | """获取索引""" 51 | return self._items.index(item) 52 | 53 | def insert(self, pos, item): 54 | """指定位置插入元素""" 55 | self._items.insert(pos, item) 56 | 57 | def clear(self): 58 | """清空元素""" 59 | self._items.clear() 60 | 61 | def filter(self, condition) -> list: 62 | """筛选出满足条件的 item 63 | condition(item) -> True 64 | """ 65 | return [it for it in self if condition(it)] 66 | 67 | def find_by_name(self, name: str): 68 | """使用文件名搜索(仅返回首个匹配项)""" 69 | for item in self: 70 | if name == item.name: 71 | return item 72 | return None 73 | 74 | def find_by_id(self, fid: int): 75 | """使用 id 搜索(精确)""" 76 | for item in self: 77 | if fid == item.id: 78 | return item 79 | return None 80 | 81 | def pop_by_id(self, fid): 82 | for item in self: 83 | if item.id == fid: 84 | self._items.remove(item) 85 | return item 86 | return None 87 | 88 | def update_by_id(self, fid, **kwargs): 89 | """通过 id 搜索元素并更新""" 90 | item = self.find_by_id(fid) 91 | pos = self.index(item) 92 | data = item._asdict() 93 | data.update(kwargs) 94 | self._items[pos] = item.__class__(**data) 95 | 96 | def get_absolute_path(self, fid) -> str: 97 | res = '' 98 | if item := self.find_by_id(fid): 99 | if item.pid: 100 | res = self.get_absolute_path(item.pid) + '/' + item.name 101 | else: 102 | res = item.name + res 103 | return res 104 | 105 | def get_path_id(self) -> dict: 106 | """获取文件路径-id""" 107 | result = {} 108 | for item in self._items: 109 | _id = item.id 110 | full_path = self.get_absolute_path(_id) 111 | result[full_path] = _id 112 | return result 113 | 114 | 115 | class FileList(ItemList): 116 | """文件列表类""" 117 | pass 118 | 119 | 120 | class PathList(ItemList): 121 | """路径列表类""" 122 | pass 123 | 124 | 125 | class TreeList(ItemList): 126 | """文件夹结构类""" 127 | pass 128 | -------------------------------------------------------------------------------- /cloud189/api/token.py: -------------------------------------------------------------------------------- 1 | """ 2 | 模拟客户端登录,获取 token,用于秒传检查 3 | """ 4 | 5 | import re 6 | import requests 7 | 8 | from cloud189.api.utils import rsa_encode, calculate_md5_sign, API, get_time, UA, logger 9 | from cloud189.api import Cloud189 10 | 11 | 12 | def get_token_pre_params(): 13 | """登录前参数准备""" 14 | url = 'https://cloud.189.cn/unifyLoginForPC.action' 15 | params = { 16 | 'appId': 8025431004, 17 | 'clientType': 10020, 18 | 'returnURL': 'https://m.cloud.189.cn/zhuanti/2020/loginErrorPc/index.html', 19 | 'timeStamp': get_time(stamp=True) 20 | } 21 | resp = requests.get(url, params=params) 22 | if not resp: 23 | return Cloud189.NETWORK_ERROR, None 24 | 25 | param_id = re.search(r'paramId = "(\S+)"', resp.text, re.M) 26 | req_id = re.search(r'reqId = "(\S+)"', resp.text, re.M) 27 | return_url = re.search(r"returnUrl = '(\S+)'", resp.text, re.M) 28 | captcha_token = re.search(r"captchaToken' value='(\S+)'", resp.text, re.M) 29 | j_rsakey = re.search(r'j_rsaKey" value="(\S+)"', resp.text, re.M) 30 | lt = re.search(r'lt = "(\S+)"', resp.text, re.M) 31 | 32 | param_id = param_id.group(1) if param_id else '' 33 | req_id = req_id.group(1) if req_id else '' 34 | return_url = return_url.group(1) if return_url else '' 35 | captcha_token = captcha_token.group(1) if captcha_token else '' 36 | j_rsakey = j_rsakey.group(1) if j_rsakey else '' 37 | lt = lt.group(1) if lt else '' 38 | 39 | return Cloud189.SUCCESS, (param_id, req_id, return_url, captcha_token, j_rsakey, lt) 40 | 41 | 42 | def get_token(username, password): 43 | """获取token""" 44 | code, result = get_token_pre_params() 45 | if code != Cloud189.SUCCESS: 46 | return code, None 47 | 48 | param_id, req_id, return_url, captcha_token, j_rsakey, lt = result 49 | 50 | username = rsa_encode(j_rsakey, username) 51 | password = rsa_encode(j_rsakey, password) 52 | url = "https://open.e.189.cn/api/logbox/oauth2/loginSubmit.do" 53 | headers = { 54 | "User-Agent": UA, 55 | "Referer": "https://open.e.189.cn/api/logbox/oauth2/unifyAccountLogin.do", 56 | "Cookie": f"LT={lt}", 57 | "X-Requested-With": "XMLHttpRequest", 58 | "REQID": req_id, 59 | "lt": lt 60 | } 61 | data = { 62 | "appKey": "8025431004", 63 | "accountType": "02", 64 | "userName": f"{{RSA}}{username}", 65 | "password": f"{{RSA}}{password}", 66 | "validateCode": "", 67 | "captchaToken": captcha_token, 68 | "returnUrl": return_url, 69 | "mailSuffix": "@189.cn", 70 | "dynamicCheck": "FALSE", 71 | "clientType": 10020, 72 | "cb_SaveName": 1, 73 | "isOauth2": 'false', 74 | "state": "", 75 | "paramId": param_id 76 | } 77 | resp = requests.post(url, data=data, headers=headers, timeout=10) 78 | if not resp: 79 | return Cloud189.NETWORK_ERROR, None 80 | resp = resp.json() 81 | if 'toUrl' in resp: 82 | redirect_url = resp['toUrl'] 83 | else: 84 | redirect_url = '' 85 | logger.debug(f"Token: {resp['msg']=}") 86 | url = API + '/getSessionForPC.action' 87 | headers = { 88 | "User-Agent": UA, 89 | "Accept": "application/json;charset=UTF-8" 90 | } 91 | params = { 92 | 'clientType': 'TELEMAC', 93 | 'version': '1.0.0', 94 | 'channelId': 'web_cloud.189.cn', 95 | 'redirectURL': redirect_url 96 | } 97 | resp = requests.get(url, params=params, headers=headers, timeout=10) 98 | if not resp: 99 | return Cloud189.NETWORK_ERROR, None 100 | 101 | sessionKey = resp.json()['sessionKey'] 102 | sessionSecret = resp.json()['sessionSecret'] 103 | accessToken = resp.json()['accessToken'] # 需要再验证一次? 104 | 105 | url = API + '/open/oauth2/getAccessTokenBySsKey.action' 106 | timestamp = get_time(stamp=True) 107 | params = f'AppKey=601102120&Timestamp={timestamp}&sessionKey={sessionKey}' 108 | headers = { 109 | "AppKey": '601102120', 110 | 'Signature': calculate_md5_sign(params), 111 | "Sign-Type": "1", 112 | "Accept": "application/json", 113 | 'Timestamp': timestamp, 114 | } 115 | resp = requests.get(url, params={'sessionKey': sessionKey}, headers=headers, timeout=10) 116 | if not resp: 117 | return Cloud189.NETWORK_ERROR 118 | accessToken = resp.json()['accessToken'] 119 | 120 | return Cloud189.SUCCESS, (sessionKey, sessionSecret, accessToken) 121 | -------------------------------------------------------------------------------- /cloud189/api/types.py: -------------------------------------------------------------------------------- 1 | """ 2 | API 处理后返回的数据类型 3 | """ 4 | 5 | from collections import namedtuple 6 | 7 | 8 | __all__ = ['FileInfo', 'RecInfo', 'PathInfo', 'UpCode', 'MkCode', 'UpInfo', 9 | 'ShareCode', 'FolderTree', 'ShareInfo', 'UserInfo'] 10 | 11 | 12 | _base_info = ['name', 'id', 'pid', 'ctime', 'optime', 'size', 'ftype', 'isFolder', 'durl'] 13 | _file_info = (*_base_info, 'isStarred', 'account', 'count') 14 | _rec_info = [*_base_info, 'isFamily', 'path', 'fid'] 15 | _share_info = ['pwd', 'copyC', 'downC', 'prevC', 'url', 'path', 16 | 'need_pwd', 's_type', 's_mode', 'r_stat', *_base_info] 17 | 18 | # 主文件 19 | FileInfo = namedtuple('FileInfo', _file_info, defaults=('',) * len(_file_info)) 20 | # 回收站文件 21 | RecInfo = namedtuple('RecInfo', _rec_info, defaults=('',) * len(_rec_info)) 22 | # 文件路径 23 | PathInfo = namedtuple('PathInfo', ['name', 'id', 'isCoShare']) 24 | 25 | UpCode = namedtuple('UpCode', ['code', 'id', 'quick_up', 'path'], defaults=(0, '', False, '')) 26 | MkCode = namedtuple('MkCode', ['code', 'id'], defaults=(0, '')) 27 | ShareCode = namedtuple('ShareCode', ['code', 'url', 'pwd', 'et'], defaults=(0, '', '', '')) 28 | 29 | FolderTree = namedtuple('FolderTree', ['name', 'id', 'pid', 'isParent'], defaults=('',) * 4) 30 | 31 | ShareInfo = namedtuple('ShareInfo', _share_info, defaults=('',) * len(_share_info)) 32 | UserInfo = namedtuple('UserInfo', ['id', 'account', 'nickname', 'used', 'quota', 'vip', 'endTime', 33 | 'beginTime', 'domain'], defaults=('',) * 9) 34 | 35 | UpInfo = namedtuple('UpInfo', ['name', 'path', 'id', 'fid', 'size', 'force', 'exist', 'check', 'callback'], 36 | defaults=('', '', '', '-11', 0, False, False, True, None)) 37 | -------------------------------------------------------------------------------- /cloud189/api/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | API 处理网页数据、数据切片时使用的工具 3 | """ 4 | 5 | import os 6 | import logging 7 | import hmac 8 | import hashlib 9 | from datetime import datetime 10 | from base64 import b64encode 11 | import rsa 12 | 13 | __all__ = ['logger', 'encrypt', 'b64tohex', 'calculate_hmac_sign', 14 | 'API', 'UA', 'SUFFIX_PARAM', 'get_time', 'get_file_md5', 15 | 'get_file_name', 'get_relative_folder', 'get_upload_chunks', 16 | 'get_chunk_size'] 17 | 18 | ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) 19 | ROOT_DIR = os.path.dirname(os.path.dirname(ROOT_DIR)) 20 | 21 | # 调试日志设置 22 | logger = logging.getLogger('cloud189') 23 | log_file = ROOT_DIR + os.sep + 'debug-cloud189.log' 24 | fmt_str = "%(asctime)s [%(filename)s:%(lineno)d] %(funcName)s %(levelname)s - %(message)s" 25 | logging.basicConfig(level=logging.DEBUG, 26 | filename=log_file, 27 | filemode="a", 28 | format=fmt_str, 29 | datefmt="%Y-%m-%d %H:%M:%S") 30 | 31 | logging.getLogger("requests").setLevel(logging.WARNING) 32 | logging.getLogger("urllib3").setLevel(logging.WARNING) 33 | 34 | API = 'https://api.cloud.189.cn' 35 | UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) ????/1.0.0 ' \ 36 | 'Chrome/69.0.3497.128 Electron/4.2.12 Safari/537.36 ' 37 | # UA = 'Mozilla/5.0' 38 | SUFFIX_PARAM = 'clientType=TELEMAC&version=1.0.0&channelId=web_cloud.189.cn' 39 | 40 | RSA_KEY = """-----BEGIN PUBLIC KEY----- 41 | MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDY7mpaUysvgQkbp0iIn2ezoUyh 42 | i1zPFn0HCXloLFWT7uoNkqtrphpQ/63LEcPz1VYzmDuDIf3iGxQKzeoHTiVMSmW6 43 | FlhDeqVOG094hFJvZeK4OzA6HVwzwnEW5vIZ7d+u61RV1bsFxmB68+8JXs3ycGcE 44 | 4anY+YzZJcyOcEGKVQIDAQAB 45 | -----END PUBLIC KEY----- 46 | """ 47 | b64map = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" 48 | BI_RM = list("0123456789abcdefghijklmnopqrstuvwxyz") 49 | 50 | 51 | def encrypt(password: str) -> str: 52 | return b64encode( 53 | rsa.encrypt( 54 | (password).encode('utf-8'), 55 | rsa.PublicKey.load_pkcs1_openssl_pem(RSA_KEY.encode()) 56 | ) 57 | ).decode() 58 | 59 | 60 | def int2char(a): 61 | return BI_RM[a] 62 | 63 | 64 | def b64tohex(a): 65 | d = "" 66 | e = 0 67 | for i in range(len(a)): 68 | if list(a)[i] != "=": 69 | v = b64map.index(list(a)[i]) 70 | if 0 == e: 71 | e = 1 72 | d += int2char(v >> 2) 73 | c = 3 & v 74 | elif 1 == e: 75 | e = 2 76 | d += int2char(c << 2 | v >> 4) 77 | c = 15 & v 78 | elif 2 == e: 79 | e = 3 80 | d += int2char(c) 81 | d += int2char(v >> 2) 82 | c = 3 & v 83 | else: 84 | e = 0 85 | d += int2char(c << 2 | v >> 4) 86 | d += int2char(15 & v) 87 | if e == 1: 88 | d += int2char(c << 2) 89 | return d 90 | 91 | 92 | def md5(s): 93 | hl = hashlib.md5() 94 | hl.update(s.encode(encoding='utf-8')) 95 | return hl.hexdigest() 96 | 97 | 98 | def calculate_md5_sign(params): 99 | return hashlib.md5('&'.join(sorted(params.split('&'))).encode('utf-8')).hexdigest() 100 | 101 | 102 | def rsa_encode(j_rsakey, string): 103 | rsa_key = f"-----BEGIN PUBLIC KEY-----\n{j_rsakey}\n-----END PUBLIC KEY-----" 104 | pubkey = rsa.PublicKey.load_pkcs1_openssl_pem(rsa_key.encode()) 105 | result = b64tohex((b64encode(rsa.encrypt(f'{string}'.encode(), pubkey))).decode()) 106 | return result 107 | 108 | 109 | def calculate_hmac_sign(secret_key, session_key, operate, url, date): 110 | request_uri = url.split("?")[0].replace(f"{API}", "") 111 | plain = f'SessionKey={session_key}&Operate={operate}&RequestURI={request_uri}&Date={date}' 112 | return hmac.new(secret_key.encode(), plain.encode(), hashlib.sha1).hexdigest().upper() 113 | 114 | 115 | def get_time(stamp=False): 116 | '''获取当前时间戳''' 117 | if stamp: 118 | return str(int(datetime.utcnow().timestamp() * 1000)) 119 | else: 120 | return datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT') 121 | 122 | 123 | def get_file_md5(file_path, check=True): 124 | if check: 125 | _md5 = hashlib.md5() 126 | with open(file_path, 'rb') as f: 127 | while True: 128 | data = f.read(64 * 1024) 129 | if not data: 130 | break 131 | _md5.update(data) 132 | hash_md5 = _md5.hexdigest() 133 | return hash_md5.upper() 134 | else: 135 | return 'random_md5_value' # TODO: 这里需要返回一个值 136 | 137 | 138 | def get_file_name(file_path): 139 | '''文件路径获取文件名''' 140 | return file_path.strip('/').strip('\\').rsplit('\\', 1)[-1].rsplit('/', 1)[-1] 141 | 142 | 143 | def get_relative_folder(full_path, work_path, is_file=True): 144 | '''文件路径获取文件夹''' 145 | work_name = get_file_name(work_path) 146 | # 有可能 work_name 在父文件夹中有出现, 147 | # 因此 反转路径 以替换最后一个文件(夹)名,最后再倒回来 (〒︿〒) 148 | work_hone = work_path[::-1].strip('/').strip('\\').replace(work_name[::-1], '', 1)[::-1] 149 | relative_path = full_path.strip('/').strip('\\').replace(work_hone, '') 150 | file_name = relative_path.rsplit('\\', 1)[-1].rsplit('/', 1)[-1] if is_file else '' 151 | logger.debug(f"{work_name=},{work_hone=},{relative_path=},{file_name=}") 152 | return relative_path.replace(file_name, '').strip('/').strip('\\') 153 | 154 | 155 | def get_upload_chunks(file, chunk_size=8096): 156 | """文件上传 块生成器""" 157 | while True: 158 | data = file.read(chunk_size) 159 | if not data: break 160 | yield data 161 | 162 | 163 | def get_chunk_size(total_size: int) -> int: 164 | """根据文件大小返回 块大小""" 165 | if total_size >= 1 << 30: # 1 GB 166 | return 10 << 20 # 10 MB 167 | elif total_size >= 100 << 20: # 100 MB 168 | return 4 << 20 # 4 MB 169 | elif total_size == -1: 170 | return 100 << 10 # 100 KB 171 | else: 172 | return 1 << 20 # 1 MB 173 | -------------------------------------------------------------------------------- /cloud189/cli/__init__.py: -------------------------------------------------------------------------------- 1 | from cloud189.cli.config import config 2 | 3 | version = '0.0.5' 4 | 5 | __all__ = ['cli', 'utils', 'version', 'config'] 6 | -------------------------------------------------------------------------------- /cloud189/cli/cli.py: -------------------------------------------------------------------------------- 1 | import os 2 | from time import sleep 3 | from getpass import getpass 4 | from random import choice 5 | from sys import exit as exit_cmd 6 | from webbrowser import open_new_tab 7 | 8 | from cloud189.api import Cloud189 9 | from cloud189.api.models import FileList, PathList 10 | from cloud189.api.token import get_token 11 | from cloud189.api.utils import logger 12 | 13 | from cloud189.cli import config 14 | from cloud189.cli.downloader import Downloader, Uploader 15 | from cloud189.cli.manager import global_task_mgr 16 | from cloud189.cli.recovery import Recovery 17 | from cloud189.cli.utils import * 18 | 19 | 20 | class Commander: 21 | """网盘命令行""" 22 | 23 | def __init__(self): 24 | self._prompt = '> ' 25 | self._disk = Cloud189() 26 | self._task_mgr = global_task_mgr 27 | self._dir_list = '' 28 | self._file_list = FileList() 29 | self._path_list = PathList() 30 | self._parent_id = -11 31 | self._parent_name = '' 32 | self._work_name = '' 33 | self._work_id = -11 34 | self._last_work_id = -11 35 | self._reader_mode = False 36 | self._reader_mode = config.reader_mode 37 | self._default_dir_pwd = '' 38 | self._disk.set_captcha_handler(captcha_handler) 39 | 40 | @staticmethod 41 | def clear(): 42 | clear_screen() 43 | 44 | @staticmethod 45 | def help(): 46 | print_help() 47 | 48 | @staticmethod 49 | def update(): 50 | check_update() 51 | 52 | def bye(self): 53 | if self._task_mgr.has_alive_task(): 54 | info("有任务在后台运行, 退出请直接关闭窗口") 55 | else: 56 | config.work_id = self._work_id 57 | exit_cmd(0) 58 | 59 | def rmode(self): 60 | """适用于屏幕阅读器用户的显示方式""" 61 | # TODO 62 | choice = input("以适宜屏幕阅读器的方式显示(y): ") 63 | if choice and choice.lower() == 'y': 64 | config.reader_mode = True 65 | self._reader_mode = True 66 | info("已启用 Reader Mode") 67 | else: 68 | config.reader_mode = False 69 | self._reader_mode = False 70 | info("已关闭 Reader Mode") 71 | 72 | def cdrec(self): 73 | """进入回收站模式""" 74 | rec = Recovery(self._disk) 75 | rec.run() 76 | self.refresh() 77 | 78 | def refresh(self, dir_id=None, auto=False): 79 | """刷新当前文件夹和路径信息""" 80 | dir_id = self._work_id if dir_id is None else dir_id 81 | 82 | if dir_id == -11: 83 | self._file_list, self._path_list = self._disk.get_root_file_list() 84 | else: 85 | self._file_list, self._path_list = self._disk.get_file_list(dir_id) 86 | if not self._file_list and not self._path_list: 87 | if auto: 88 | error(f"文件夹 id={dir_id} 无效(被删除), 将切换到根目录!") 89 | return self.refresh(-11) 90 | else: 91 | error(f"文件夹 id 无效 {dir_id=}, {self._work_id=}") 92 | return None 93 | self._prompt = '/'.join(self._path_list.all_name) + ' > ' 94 | self._last_work_id = self._work_id 95 | self._work_name = self._path_list[-1].name 96 | self._work_id = self._path_list[-1].id 97 | if dir_id != -11: # 如果存在上级路径 98 | self._parent_name = self._path_list[-2].name 99 | self._parent_id = self._path_list[-2].id 100 | 101 | def login(self, args): 102 | """登录网盘""" 103 | if args: 104 | if '--auto' in args: 105 | if config.cookie and self._disk.login_by_cookie(config) == Cloud189.SUCCESS: 106 | self.refresh(config.work_id, auto=True) 107 | return None 108 | username = input('输入用户名:') 109 | password = getpass('输入密码:') 110 | if not username or not password: 111 | error('没有用户名或密码 :(') 112 | return None 113 | code = self._disk.login(username, password) 114 | if code == Cloud189.NETWORK_ERROR: 115 | error("登录失败,网络连接异常") 116 | return None 117 | elif code == Cloud189.FAILED: 118 | error('登录失败,用户名或密码错误 :(') 119 | os._exit(0) 120 | # 登录成功保存用户 cookie 121 | config.username = username 122 | config.password = password 123 | config.cookie = self._disk.get_cookie() 124 | code, token = get_token(username, password) 125 | if code == Cloud189.SUCCESS: 126 | config.set_token(*token) 127 | self._disk.set_session(*token) 128 | self._work_id = -11 129 | self.refresh(-11) 130 | 131 | def clogin(self): 132 | """使用 cookie 登录""" 133 | if platform() == 'Linux' and not os.environ.get('DISPLAY'): 134 | info("请使用浏览器打开: https://cloud.189.cn 获取 cookie") 135 | else: 136 | open_new_tab('https://cloud.189.cn') 137 | info("请设置 Cookie 内容:") 138 | c_login_user = input("COOKIE_LOGIN_USER=") 139 | if not c_login_user: 140 | error("请输入正确的 Cookie 信息") 141 | return None 142 | cookie = {"COOKIE_LOGIN_USER": str(c_login_user)} 143 | if self._disk.login_by_cookie(cookie) == Cloud189.SUCCESS: 144 | user = self._disk.get_user_infos() 145 | if not user: 146 | error("发生未知错误!") 147 | return None 148 | user_infos = { 149 | 'name': user.account.replace('@189.cn', ''), 150 | 'pwd': '', 151 | 'cookie': cookie, 152 | 'key': '', 153 | 'secret': '', 154 | 'token': '', 155 | 'save_path': './downloads', 156 | 'work_id': -11 157 | } 158 | config.set_infos(user_infos) 159 | self._work_id = config.work_id 160 | self.refresh() 161 | else: 162 | error("登录失败, 请检查 Cookie 是否正确") 163 | 164 | def logout(self, args): 165 | """注销/删除用户""" 166 | if args: # 删除用户 167 | for name in args: 168 | result = config.del_user(name) 169 | if result: 170 | info(f"成功删除用户 {name}") 171 | else: 172 | error(f"删除用户 {name} 失败!") 173 | return None 174 | clear_screen() 175 | self._prompt = '> ' 176 | # self._disk.logout() # TODO(rachpt@126.com): 还没有注销登录的方法 177 | self._file_list.clear() 178 | self._path_list = '' 179 | self._parent_id = -11 180 | self._work_id = -11 181 | self._last_work_id = -11 182 | self._parent_name = '' 183 | self._work_name = '' 184 | config.cookie = None 185 | 186 | def su(self, args): 187 | """列出、切换用户""" 188 | users = config.get_users_name() 189 | def list_user(): 190 | for i, user in enumerate(users): 191 | user_info = config.get_user_info(user) 192 | methord = "用户名+密码 登录" if user_info[2] else "Cookie 登录" 193 | print(f"[{i}] 用户名: {user}, {methord}") 194 | if args: 195 | if args[0] == '-l': 196 | list_user() 197 | return None 198 | elif args[0] in users: 199 | select_user = args[0] 200 | else: 201 | error(f"用户名 {args[0]} 无效") 202 | return None 203 | else: 204 | list_user() 205 | select = input("请输入用户序号, [0、1 ... ]: ") 206 | if select.isnumeric(): 207 | select = int(select) 208 | if select > len(users): 209 | error(f"序号 {select} 无效!") 210 | return None 211 | select_user = users[select] 212 | else: 213 | error(f"序号 {select} 无效!") 214 | return None 215 | config.work_id = self._work_id # 保存旧的工作目录 216 | result = config.change_user(select_user) 217 | if result and self._disk.login_by_cookie(config) == Cloud189.SUCCESS: 218 | info(f"成功切换至用户 {config.username}") 219 | self.refresh(config.work_id) 220 | else: 221 | error("切换用户失败!") 222 | 223 | def ls(self, args): 224 | """列出文件(夹)""" 225 | fid = old_fid = self._work_id 226 | flag_full = False 227 | flag_arg_l = False 228 | if args: 229 | if len(args) >= 2: 230 | if args[0] == '-l': 231 | flag_full = True 232 | fname = args[-1] 233 | elif args[-1] == '-l': 234 | flag_full = True 235 | fname = args[0] 236 | else: 237 | info("暂不支持查看多个文件!") 238 | fname = args[0] 239 | else: 240 | if args[0] == '-l': 241 | flag_full = True 242 | flag_arg_l = True 243 | else: 244 | fname = args[0] 245 | if not flag_arg_l: 246 | if file := self._file_list.find_by_name(fname): 247 | if file.isFolder: 248 | fid = file.id 249 | else: 250 | error(f"{fname} 非文件夹,显示当前目录文件") 251 | else: 252 | error(f"{fname} 不存在,显示当前目录文件") 253 | if fid != old_fid: 254 | self._file_list, _ = self._disk.get_file_list(fid) 255 | if not flag_full: # 只罗列文件名 256 | for file in self._file_list: 257 | if file.isFolder: 258 | print(f"\033[1;34m{handle_name(file.name)}\033[0m", end=' ') 259 | else: 260 | print(f"{handle_name(file.name)}", end=' ') 261 | print() 262 | else: 263 | if self._reader_mode: # 方便屏幕阅读器阅读 264 | for file in self._file_list: 265 | print( 266 | f"{handle_name(file.name)} 大小:{get_file_size_str(file.size)} 上传时间:{file.ctime} ID:{file.id}") 267 | else: # 普通用户显示方式 268 | for file in self._file_list: 269 | star = '✦' if file.isStarred else '✧' # 好像 没什么卵用 270 | file_name = f"\033[1;34m{handle_name(file.name)}\033[0m" if file.isFolder else handle_name(file.name) 271 | print("# {0:<17}{1:<4}{2:<20}{3:>8} {4}".format( 272 | file.id, star, file.ctime, get_file_size_str(file.size), file_name)) 273 | if fid != old_fid: 274 | self._file_list, _ = self._disk.get_file_list(old_fid) 275 | 276 | def cd(self, args): 277 | """切换工作目录""" 278 | dir_name = args[0] 279 | if not dir_name: 280 | info('cd .. 返回上级路径, cd - 返回上次路径, cd / 返回根目录') 281 | elif dir_name in ["..", "../"]: 282 | self.refresh(self._parent_id) 283 | elif dir_name == '/': 284 | self.refresh(-11) 285 | elif dir_name == '-': 286 | self.refresh(self._last_work_id) 287 | elif dir_name == '.': 288 | pass 289 | elif folder := self._file_list.find_by_name(dir_name): 290 | self.refresh(folder.id) 291 | else: 292 | error(f'文件夹不存在: {dir_name}') 293 | 294 | def mkdir(self, args): 295 | """创建文件夹""" 296 | if not args: 297 | info('参数:新建文件夹名') 298 | refresh_flag = False 299 | for name in args: 300 | if self._file_list.find_by_name(name): 301 | error(f'文件夹已存在: {name}') 302 | continue 303 | r = self._disk.mkdir(self._work_id, name) 304 | if r.code == Cloud189.SUCCESS: 305 | print(f"{name} ID: ", r.id) 306 | refresh_flag = True 307 | else: 308 | error(f'创建文件夹 {name} 失败!') 309 | continue 310 | if refresh_flag: 311 | self.refresh() 312 | 313 | def rm(self, args): 314 | """删除文件(夹)""" 315 | if not args: 316 | info('参数:删除文件夹(夹)名') 317 | return None 318 | for name in args: 319 | if file := self._file_list.find_by_name(name): 320 | self._disk.delete_by_id(file.id) 321 | print(f"删除:{name} 成功!") 322 | else: 323 | error(f"无此文件:{name}") 324 | self.refresh() 325 | 326 | def rename(self, args): 327 | """重命名文件(夹)""" 328 | name = args[0].strip(' ') 329 | if not name: 330 | info('参数:原文件名 [新文件名]') 331 | elif file := self._file_list.find_by_name(name): 332 | new = args[1].strip(' ') if len(args) == 2 else input("请输入新文件名:") 333 | logger.debug(f"{new=}, {args=}") 334 | code = self._disk.rename(file.id, new) 335 | if code == Cloud189.SUCCESS: 336 | self.refresh() 337 | elif code == Cloud189.NETWORK_ERROR: 338 | error('网络错误,请重试!') 339 | else: 340 | error('失败,未知错误!') 341 | else: 342 | error(f'没有找到文件(夹): {name}') 343 | 344 | def mv(self, args): 345 | """移动文件或文件夹""" 346 | name = args[0] 347 | if not name: 348 | info('参数:文件(夹)名 [新文件夹名/id]') 349 | folder_name = '' 350 | target_id = None 351 | file_info = self._file_list.find_by_name(name) 352 | if not file_info: 353 | error(f"文件(夹)不存在: {name}") 354 | return None 355 | if len(args) > 1: 356 | if args[-1].isnumeric(): 357 | target_id = args[-1] 358 | else: 359 | folder_name = args[-1] 360 | if not target_id: 361 | info("正在获取所有文件夹信息,请稍后...") 362 | tree_list = self._disk.get_folder_nodes() 363 | if not tree_list: 364 | error("获取文件夹信息出错,请重试.") 365 | return None 366 | if folder_name: 367 | if folder := tree_list.find_by_name(folder_name): 368 | target_id = folder.id 369 | else: 370 | error(f"文件夹 {folder_name} 不存在!") 371 | return None 372 | else: 373 | tree_dict = tree_list.get_path_id() 374 | choice_list = list(tree_dict.keys()) 375 | 376 | def _condition(typed_str, choice_str): 377 | path_depth = len(choice_str.split('/')) 378 | # 没有输入时, 补全 Cloud189,深度 1 379 | if not typed_str and path_depth == 1: 380 | return True 381 | # Cloud189/ 深度为 2,补全同深度的文件夹 Cloud189/test 、Cloud189/txt 382 | # Cloud189/tx 应该补全 Cloud189/txt 383 | if path_depth == len(typed_str.split('/')) and choice_str.startswith(typed_str): 384 | return True 385 | 386 | set_completer(choice_list, condition=_condition) 387 | choice = input('请输入路径(TAB键补全) : ') 388 | if not choice or choice not in choice_list: 389 | error(f"目标路径不存在: {choice}") 390 | return None 391 | target_id = tree_dict.get(choice) 392 | 393 | if self._disk.move_file(file_info, target_id) == Cloud189.SUCCESS: 394 | self._file_list.pop_by_id(file_info.id) 395 | else: 396 | error(f"移动文件(夹)到 {choice} 失败") 397 | 398 | def down(self, args): 399 | """自动选择下载方式""" 400 | task_flag = False 401 | follow = False 402 | for arg in args: 403 | if arg == '-f': 404 | follow = True 405 | args.remove(arg) 406 | # TODO: 通过分享链接下载 407 | i = 0 408 | while i < len(args): 409 | item = args[i] 410 | if item.startswith("http"): 411 | pwd = '' 412 | if i < len(args) - 1 and (not args[i + 1].startswith("http")): 413 | pwd = args[i + 1] 414 | i += 1 # 额外加一 415 | self._disk.get_file_info_by_url(item, pwd) 416 | elif file := self._file_list.find_by_name(item): 417 | downloader = Downloader(self._disk) 418 | f_path = '/'.join(self._path_list.all_name) # 文件在网盘的父路径 419 | if file.isFolder: # 使用 web 接口打包下载文件夹 420 | downloader.set_fid(file.id, is_file=False, f_path=f_path, f_name=item) 421 | task_flag = True 422 | self._task_mgr.add_task(downloader) # 提交下载任务 423 | else: # 下载文件 424 | downloader.set_fid(file.id, is_file=True, f_path=f_path, f_name=item) 425 | task_flag = True 426 | self._task_mgr.add_task(downloader) # 提交下载任务 427 | else: 428 | error(f'文件(夹)不存在: {item}') 429 | i += 1 430 | if follow and task_flag: 431 | self.jobs(['-f', ]) 432 | elif task_flag: 433 | print("开始下载, 输入 jobs 查看下载进度...") 434 | 435 | def jobs(self, args): 436 | """显示后台任务列表""" 437 | follow = False 438 | for arg in args: 439 | if arg == '-f': 440 | print() 441 | follow = True 442 | args.remove(arg) 443 | if not args: 444 | self._task_mgr.show_tasks(follow) 445 | for arg in args: 446 | if arg.isnumeric(): 447 | self._task_mgr.show_detail(int(arg), follow) 448 | else: 449 | self._task_mgr.show_tasks(follow) 450 | 451 | def upload(self, args): 452 | """上传文件(夹)""" 453 | if not args: 454 | info('参数:文件路径') 455 | task_flag = False 456 | follow = False 457 | force = False 458 | mkdir = True 459 | for arg in args: 460 | follow, force, mkdir, match = parsing_up_params(arg, follow, force, mkdir) 461 | if match: 462 | args.remove(arg) 463 | for path in args: 464 | path = path.strip('\"\' ') # 去除直接拖文件到窗口产生的引号 465 | if not os.path.exists(path): 466 | error(f'该路径不存在哦: {path}') 467 | continue 468 | uploader = Uploader(self._disk) 469 | if os.path.isfile(path): 470 | uploader.set_upload_path(path, is_file=True, force=force) 471 | else: 472 | uploader.set_upload_path(path, is_file=False, force=force, mkdir=mkdir) 473 | uploader.set_target(self._work_id, self._work_name) 474 | self._task_mgr.add_task(uploader) 475 | task_flag = True 476 | if follow and task_flag: 477 | self.jobs(['-f', ]) 478 | elif task_flag: 479 | print("开始上传, 输入 jobs 查看上传进度...") 480 | 481 | def share(self, args): 482 | """分享文件""" 483 | name = args[0] 484 | if not name: 485 | info('参数:需要分享的文件 [1/2/3] [1/2]') 486 | return None 487 | if file := self._file_list.find_by_name(name): 488 | et = args[1] if len(args) >= 2 else None 489 | ac = args[2] if len(args) >= 3 else None 490 | result = self._disk.share_file(file.id, et, ac) 491 | if result.code == Cloud189.SUCCESS: 492 | print("-" * 50) 493 | print(f"{'文件夹名' if file.isFolder else '文件名 '} : {name}") 494 | print(f"上传时间 : {file.ctime}") 495 | if not file.isFolder: 496 | print(f"文件大小 : {get_file_size_str(file.size)}") 497 | print(f"分享链接 : {result.url}") 498 | print(f"提取码 : {result.pwd or '无'}") 499 | if result.et == '1': 500 | time = '1天' 501 | elif result.et == '2': 502 | time = '7天' 503 | else: 504 | time = '永久' 505 | print(f"有效期 : {time}") 506 | print("-" * 50) 507 | else: 508 | error('获取文件(夹)信息出错!') 509 | else: 510 | error(f"文件(夹)不存在: {name}") 511 | 512 | def shared(self, args): 513 | """显示分享文件""" 514 | stype = 1 # 默认查看 发出的分享 515 | if args and args[0] == '2': 516 | stype = 2 # 收到的分享 517 | all_file = self._disk.list_shared_url(stype) 518 | if not all_file: 519 | info("失败或者没有数据!") 520 | return None 521 | for item in all_file: 522 | f_name = item.name if item.isFolder else f"\033[1;34m{item.name}\033[0m" # 给你点颜色.. 523 | print("https:{0:<30} 提取码: {1:>4} [转存/下载/浏览: {2}/{3}/{4}] 文件名: {5}".format( 524 | item.url, item.pwd, item.copyC, item.downC, item.prevC, f_name)) 525 | 526 | def sign(self, args): 527 | """签到 + 抽奖""" 528 | if '-a' in args or '--all' in args: 529 | old_user = self.who() 530 | for user in config.get_users_name(): 531 | self.su([user, ]) 532 | sleep(0.5) 533 | self._disk.user_sign() 534 | sleep(0.5) 535 | self.su([old_user, ]) 536 | else: 537 | self._disk.user_sign() 538 | 539 | def who(self): 540 | """打印当前登录账户信息,没有错误则返回用户名""" 541 | user = self._disk.get_user_infos() 542 | if not user: 543 | error("发生未知错误!") 544 | return None 545 | quota = ", 总空间: {:.3f} GB".format(user.quota/1073741824) # GB 546 | used = ", 已使用: {:.3f} GB".format(user.used/1073741824) # GB 547 | nickname = f", 昵称: {user.nickname}" 548 | print(f"账号: {user.account}, UID: {user.id}{nickname}{quota}{used}") 549 | # 99 家庭云黄金会员, 199 家庭云铂金会员 (可能不是这个的值) 550 | if user.vip == 100: 551 | vip = "黄金会员" 552 | elif user.vip == 200: 553 | vip = "铂金会员" 554 | else: # 0 555 | vip = "普通会员" 556 | start_time = f", 开始时间: {user.beginTime}" if user.beginTime else '' 557 | end_time = f", 到期时间: {user.endTime}" if user.endTime else '' 558 | print(f"用户类别: {vip}{start_time}{end_time}") 559 | if user.domain: 560 | print(f"个人主页: https://cloud.189.cn/u/{user.domain}") 561 | return user.account.replace('@189.cn', '') 562 | 563 | def setpath(self): 564 | """设置下载路径""" 565 | print(f"当前下载路径 : {config.save_path}") 566 | path = input('修改为 -> ').strip("\"\' ") 567 | if os.path.isdir(path): 568 | config.save_path = path 569 | else: 570 | error('路径非法,取消修改') 571 | 572 | def ll(self, args): 573 | """列出文件(夹),详细模式""" 574 | if choice((0, 1, 0)): # 1/3 概率刷新 575 | self.refresh() 576 | self.ls(['-l', *args]) 577 | 578 | def quota(self): 579 | self.who() 580 | 581 | def exit(self): 582 | self.bye() 583 | 584 | def b(self): 585 | self.bye() 586 | 587 | def r(self): 588 | self.refresh() 589 | 590 | def c(self): 591 | self.clear() 592 | 593 | def j(self, args): 594 | self.jobs(args) 595 | 596 | def u(self, args): 597 | self.upload(args) 598 | 599 | def d(self, args): 600 | self.down(args) 601 | 602 | def run_one(self, cmd, args): 603 | """运行单任务入口""" 604 | no_arg_cmd = ['help', 'update', 'who', 'quota'] 605 | cmd_with_arg = ['ls', 'll', 'down', 'mkdir', 'su', 'sign', 'logout', 606 | 'mv', 'rename', 'rm', 'share', 'upload'] 607 | 608 | if cmd in ("upload", "down"): 609 | if "-f" not in args: 610 | args.append("-f") 611 | 612 | if cmd in no_arg_cmd: 613 | getattr(self, cmd)() 614 | elif cmd in cmd_with_arg: 615 | getattr(self, cmd)(args) 616 | else: 617 | print(f"命令有误,或者不支持单任务运行 {cmd}") 618 | 619 | def run(self): 620 | """处理交互模式用户命令""" 621 | no_arg_cmd = ['bye', 'exit', 'cdrec', 'clear', 'clogin', 'help', 'r', 'c', 'b', 622 | 'refresh', 'rmode', 'setpath', 'update', 'who', 'quota'] 623 | cmd_with_arg = ['ls', 'll', 'cd', 'down', 'jobs', 'shared', 'su', 'login', 'logout', 624 | 'mkdir', 'mv', 'rename', 'rm', 'share', 'upload', 'sign', 'j', 'u', 'd'] 625 | 626 | choice_list = [handle_name(i) for i in self._file_list.all_name] # 引号包裹空格文件名 627 | cmd_list = no_arg_cmd + cmd_with_arg 628 | set_completer(choice_list, cmd_list=cmd_list) 629 | 630 | try: 631 | args = input(self._prompt).split(' ', 1) 632 | if len(args) == 0: 633 | return None 634 | except KeyboardInterrupt: 635 | print('') 636 | info('退出本程序请输入 bye 或 exit') 637 | return None 638 | 639 | cmd, args = (args[0], []) if len(args) == 1 else ( 640 | args[0], handle_args(args[1])) # 命令, 参数(可带有空格, 没有参数就设为空) 641 | 642 | if cmd in no_arg_cmd: 643 | getattr(self, cmd)() 644 | elif cmd in cmd_with_arg: 645 | getattr(self, cmd)(args) 646 | -------------------------------------------------------------------------------- /cloud189/cli/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pickle import load, dump 3 | from cloud189.api.utils import ROOT_DIR 4 | 5 | 6 | __all__ = ['config'] 7 | KEY = 152 8 | config_file = ROOT_DIR + os.sep + '.config' 9 | 10 | 11 | def encrypt(key, s): 12 | b = bytearray(str(s).encode("utf-8")) 13 | n = len(b) 14 | c = bytearray(n * 2) 15 | j = 0 16 | for i in range(0, n): 17 | b1 = b[i] 18 | b2 = b1 ^ key 19 | c1 = b2 % 19 20 | c2 = b2 // 19 21 | c1 = c1 + 46 22 | c2 = c2 + 46 23 | c[j] = c1 24 | c[j + 1] = c2 25 | j = j + 2 26 | return c.decode("utf-8") 27 | 28 | 29 | def decrypt(ksa, s): 30 | c = bytearray(str(s).encode("utf-8")) 31 | n = len(c) 32 | if n % 2 != 0: 33 | return "" 34 | n = n // 2 35 | b = bytearray(n) 36 | j = 0 37 | for i in range(0, n): 38 | c1 = c[j] 39 | c2 = c[j + 1] 40 | j = j + 2 41 | c1 = c1 - 46 42 | c2 = c2 - 46 43 | b2 = c2 * 19 + c1 44 | b1 = b2 ^ ksa 45 | b[i] = b1 46 | return b.decode("utf-8") 47 | 48 | 49 | def save_config(cf): 50 | with open(config_file, 'wb') as f: 51 | dump(cf, f) 52 | 53 | 54 | class Config: 55 | 56 | def __init__(self): 57 | self._users = {} 58 | self._cookie = {} 59 | self._username = "" 60 | self._password = "" 61 | self._sessionKey = "" 62 | self._sessionSecret = "" 63 | self._accessToken = "" 64 | self._save_path = './downloads' 65 | self._work_id = -11 66 | self._reader_mode = False 67 | 68 | def update_user(self): 69 | if self._username: 70 | self._users[self._username] = (self._cookie, self._username, self._password, 71 | self._sessionKey, self._sessionSecret, self._accessToken, 72 | self._save_path, self._work_id) 73 | 74 | def del_user(self, name): 75 | name = self.encode(name) 76 | if name in self._users: 77 | del self._users[name] 78 | return True 79 | return False 80 | 81 | def change_user(self, name): 82 | name = self.encode(name) 83 | if name in self._users: 84 | user = self._users[name] 85 | self._cookie = user[0] 86 | self._username = user[1] 87 | self._password = user[2] 88 | self._sessionKey = user[3] 89 | self._sessionSecret = user[4] 90 | self._accessToken = user[5] 91 | self._save_path = user[6] 92 | self._work_id = user[7] 93 | save_config(self) 94 | return True 95 | return False 96 | 97 | def get_users_name(self): 98 | return [self.decode(user) for user in self._users] 99 | 100 | def get_user_info(self, name): 101 | name = self.encode(name) 102 | if name in self._users: 103 | return self._users[name] 104 | 105 | def encode(self, var): 106 | if isinstance(var, dict): 107 | for k, v in var.items(): 108 | var[k] = encrypt(KEY, str(v)) 109 | elif var: 110 | var = encrypt(KEY, str(var)) 111 | return var 112 | 113 | def decode(self, var): 114 | try: 115 | if isinstance(var, dict): 116 | dvar = {} # 新开内存,否则会修改原字典 117 | for k, v in var.items(): 118 | dvar[k] = decrypt(KEY, str(v)) 119 | elif var: 120 | dvar = decrypt(KEY, var) 121 | else: 122 | dvar = None 123 | except Exception: 124 | dvar = None 125 | return dvar 126 | 127 | @property 128 | def cookie(self): 129 | return self.decode(self._cookie) 130 | 131 | @cookie.setter 132 | def cookie(self, value): 133 | self._cookie = self.encode(value) 134 | self.update_user() 135 | save_config(self) 136 | 137 | @property 138 | def username(self): 139 | return self.decode(self._username) 140 | 141 | @username.setter 142 | def username(self, value): 143 | self._username = self.encode(value) 144 | self.update_user() 145 | save_config(self) 146 | 147 | @property 148 | def password(self): 149 | return self.decode(self._password) 150 | 151 | @password.setter 152 | def password(self, value): 153 | self._password = self.encode(value) 154 | self.update_user() 155 | save_config(self) 156 | 157 | @property 158 | def key(self): 159 | return self.decode(self._sessionKey) 160 | 161 | @key.setter 162 | def key(self, value): 163 | self._sessionKey = self.encode(value) 164 | self.update_user() 165 | save_config(self) 166 | 167 | @property 168 | def secret(self): 169 | return self.decode(self._sessionSecret) 170 | 171 | @secret.setter 172 | def sectet(self, value): 173 | self._sessionSecret = self.encode(value) 174 | self.update_user() 175 | save_config(self) 176 | 177 | @property 178 | def token(self): 179 | return self.decode(self._accessToken) 180 | 181 | @token.setter 182 | def token(self, value): 183 | self._accessToken = self.encode(value) 184 | self.update_user() 185 | save_config(self) 186 | 187 | def set_token(self, key, secret, token): 188 | '''设置全部''' 189 | self._sessionKey = self.encode(key) 190 | self._sessionSecret = self.encode(secret) 191 | self._accessToken = self.encode(token) 192 | self.update_user() 193 | save_config(self) 194 | 195 | @property 196 | def save_path(self): 197 | return self._save_path 198 | 199 | @save_path.setter 200 | def save_path(self, value): 201 | self._save_path = value 202 | self.update_user() 203 | save_config(self) 204 | 205 | @property 206 | def reader_mode(self): 207 | return self._reader_mode 208 | 209 | @reader_mode.setter 210 | def reader_mode(self, value: bool): 211 | self._reader_mode = value 212 | self.update_user() 213 | save_config(self) 214 | 215 | @property 216 | def work_id(self): 217 | return self._work_id 218 | 219 | @work_id.setter 220 | def work_id(self, value): 221 | self._work_id = value 222 | self.update_user() 223 | save_config(self) 224 | 225 | def set_infos(self, infos: dict): 226 | if "name" in infos: 227 | self._username = self.encode(infos["name"]) 228 | if "pwd" in infos: 229 | self._password = self.encode(infos["pwd"]) 230 | if "cookie" in infos: 231 | self._cookie = self.encode(infos["cookie"]) 232 | if "key" in infos: 233 | self._sessionKey = self.encode(infos["key"]) 234 | if "secret" in infos: 235 | self._sessionSecret = self.encode(infos["secret"]) 236 | if "token" in infos: 237 | self._accessToken = self.encode(infos["token"]) 238 | if "save_path" in infos: 239 | self._save_path = infos["save_path"] 240 | if "work_id" in infos: 241 | self._work_id = infos["work_id"] 242 | save_config(self) 243 | 244 | 245 | # 全局配置对象 246 | try: 247 | with open(config_file, 'rb') as c: 248 | config = load(c) 249 | except: 250 | config = Config() 251 | -------------------------------------------------------------------------------- /cloud189/cli/downloader.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from threading import Thread 3 | from os import sep as os_sep 4 | 5 | from cloud189.api import Cloud189 6 | from cloud189.cli import config 7 | from cloud189.cli.utils import why_error 8 | 9 | 10 | class TaskType(Enum): 11 | """后台任务类型""" 12 | UPLOAD = 0 13 | DOWNLOAD = 1 14 | 15 | 16 | class DownType(Enum): 17 | """下载类型枚举类""" 18 | INVALID_URL = 0 19 | FILE_URL = 1 20 | FOLDER_URL = 2 21 | FILE_ID = 3 22 | FOLDER_ID = 4 23 | 24 | 25 | class Downloader(Thread): 26 | 27 | def __init__(self, disk: Cloud189): 28 | super(Downloader, self).__init__() 29 | self._task_type = TaskType.DOWNLOAD 30 | self._save_path = config.save_path 31 | self._disk = disk 32 | self._pid = -1 33 | self._down_type = None 34 | self._down_args = None 35 | self._f_path = None 36 | self._f_name = '' 37 | self._now_size = 0 38 | self._total_size = 1 39 | self._msg = '' # 备用 40 | self._err_msg = [] 41 | 42 | def _error_msg(self, msg): 43 | """显示错误信息, 后台模式时保存信息而不显示""" 44 | self._err_msg.append(msg) 45 | 46 | def set_task_id(self, pid): 47 | """设置任务 id""" 48 | self._pid = pid 49 | 50 | def get_task_id(self): 51 | """获取当前任务 id""" 52 | return self._pid 53 | 54 | def get_task_type(self): 55 | """获取当前任务类型""" 56 | return self._task_type 57 | 58 | def get_process(self) -> (int, int, str): 59 | """获取下载进度""" 60 | return self._now_size, self._total_size, '' 61 | 62 | def get_count(self) -> (int, int): 63 | """文件夹当前第几个文件(备用)""" 64 | return 1, 0 65 | 66 | def get_cmd_info(self): 67 | """获取命令行的信息""" 68 | return self._down_args, self._f_path + '/' + self._f_name 69 | 70 | def get_err_msg(self) -> list: 71 | """获取后台下载时保存的错误信息""" 72 | return self._err_msg 73 | 74 | def set_url(self, url): 75 | """设置 URL 下载任务""" 76 | pass 77 | ''' 78 | if is_file_url(url): # 如果是文件 79 | self._down_args = url 80 | self._down_type = DownType.FILE_URL 81 | elif is_folder_url(url): 82 | self._down_args = url 83 | self._down_type = DownType.FOLDER_URL 84 | else: 85 | self._down_type = DownType.INVALID_URL 86 | ''' 87 | 88 | def set_fid(self, fid, is_file=True, f_path=None, f_name=None): 89 | """设置 id 下载任务""" 90 | self._down_args = fid 91 | self._f_path = f_path # 文件(夹)名在网盘的父路径 92 | self._f_name = f_name # 文件(夹)名在网盘的名字 93 | self._down_type = DownType.FILE_ID if is_file else DownType.FOLDER_ID 94 | 95 | def _show_progress(self, file_name, total_size, now_size, msg=''): 96 | """更新下载进度的回调函数""" 97 | self._total_size = total_size 98 | self._now_size = now_size 99 | self._msg = msg 100 | 101 | def _show_down_failed(self, code, file): 102 | """文件下载失败时的回调函数""" 103 | if hasattr(file, 'url'): 104 | self._error_msg(f"文件下载失败: {why_error(code)} -> 文件名: {file.name}, URL: {file.url}") 105 | else: 106 | self._error_msg(f"文件下载失败: {why_error(code)} -> 文件名: {file.name}, ID: {file.id}") 107 | 108 | def run(self) -> None: 109 | if self._down_type == DownType.INVALID_URL: 110 | self._error_msg('(。>︿<) 该分享链接无效') 111 | 112 | elif self._down_type == DownType.FILE_URL: 113 | code = self._disk.down_file_by_url(self._down_args, '', self._save_path, self._show_progress) 114 | if code == Cloud189.LACK_PASSWORD: 115 | pwd = input('输入该文件的提取码 : ') or '' 116 | code2 = self._disk.down_file_by_url(self._down_args, str(pwd), self._save_path, self._show_progress) 117 | if code2 != Cloud189.SUCCESS: 118 | self._error_msg(f"文件下载失败: {why_error(code2)} -> {self._down_args}") 119 | elif code != Cloud189.SUCCESS: 120 | self._error_msg(f"文件下载失败: {why_error(code)} -> {self._down_args}") 121 | 122 | elif self._down_type == DownType.FOLDER_URL: 123 | code = self._disk.down_dir_by_url(self._down_args, '', self._save_path, callback=self._show_progress, 124 | mkdir=True, failed_callback=self._show_down_failed) 125 | if code == Cloud189.LACK_PASSWORD: 126 | pwd = input('输入该文件夹的提取码 : ') or '' 127 | code2 = self._disk.down_dir_by_url(self._down_args, str(pwd), self._save_path, 128 | callback=self._show_progress, 129 | mkdir=True, failed_callback=self._show_down_failed) 130 | if code2 != Cloud189.SUCCESS: 131 | self._error_msg(f"文件夹下载失败: {why_error(code2)} -> {self._down_args}") 132 | elif code != Cloud189.SUCCESS: 133 | self._error_msg(f"文件夹下载失败: {why_error(code)} -> {self._down_args}") 134 | 135 | elif self._down_type == DownType.FILE_ID: 136 | save_path = self._save_path + os_sep + self._f_path 137 | code = self._disk.down_file_by_id(self._down_args, save_path, self._show_progress) 138 | if code != Cloud189.SUCCESS: 139 | self._error_msg(f"文件下载失败: {why_error(code)} -> {self._f_path}") 140 | 141 | elif self._down_type == DownType.FOLDER_ID: 142 | save_path = self._save_path + os_sep + self._f_path + os_sep + self._f_name 143 | code = self._disk.down_dirzip_by_id(self._down_args, save_path, callback=self._show_progress) 144 | if code != Cloud189.SUCCESS: 145 | self._error_msg(f"文件夹下载失败: {why_error(code)} -> {self._f_path} ") 146 | 147 | 148 | class UploadType(Enum): 149 | """上传类型枚举类""" 150 | FILE = 0 151 | FOLDER = 1 152 | 153 | 154 | class Uploader(Thread): 155 | 156 | def __init__(self, disk: Cloud189): 157 | super(Uploader, self).__init__() 158 | self._task_type = TaskType.UPLOAD 159 | self._disk = disk 160 | self._pid = -1 161 | self._up_path = None 162 | self._force = False 163 | self._up_type = None 164 | self._folder_id = -11 165 | self._folder_name = '' 166 | self._msg = '' 167 | self._now_size = 0 168 | self._total_size = 1 169 | self._mkdir = True # for dir upload 170 | self._done_files = 0 # for dir upload 171 | self._total_files = 0 # for dir upload 172 | self._err_msg = [] 173 | 174 | def _error_msg(self, msg): 175 | self._err_msg.append(msg) 176 | 177 | def set_task_id(self, pid): 178 | self._pid = pid 179 | 180 | def get_task_id(self): 181 | return self._pid 182 | 183 | def get_task_type(self): 184 | return self._task_type 185 | 186 | def get_process(self) -> (int, int, str): 187 | return self._now_size, self._total_size, self._msg 188 | 189 | def get_count(self) -> (int, int): 190 | """文件夹当前文件数量信息""" 191 | return self._done_files, self._total_files 192 | 193 | def get_cmd_info(self): 194 | return self._up_path, self._folder_name 195 | 196 | def get_err_msg(self) -> list: 197 | return self._err_msg 198 | 199 | def set_upload_path(self, path, is_file=True, force=False, mkdir=True): 200 | """设置上传路径信息""" 201 | self._up_path = path 202 | self._force = force 203 | self._mkdir = mkdir 204 | self._up_type = UploadType.FILE if is_file else UploadType.FOLDER 205 | 206 | def set_target(self, folder_id=-1, folder_name=''): 207 | """设置网盘保存文件夹信息""" 208 | self._folder_id = folder_id 209 | self._folder_name = folder_name 210 | 211 | def _show_progress(self, file_name, total_size, now_size, msg=''): 212 | self._total_size = total_size 213 | self._now_size = now_size 214 | self._msg = msg 215 | 216 | def _show_upload_failed(self, code, filename): 217 | """文件上传失败时的回调函数""" 218 | self._error_msg(f"上传失败: {why_error(code)} -> {filename}") 219 | 220 | def _set_dir_count(self, done_files, total_files): 221 | """文件夹中文件数量""" 222 | self._done_files = done_files 223 | self._total_files = total_files 224 | 225 | def run(self) -> None: 226 | if self._up_type == UploadType.FILE: 227 | info = self._disk.upload_file(self._up_path, self._folder_id, callback=self._show_progress, force=self._force) 228 | if info.code != Cloud189.SUCCESS: 229 | self._error_msg(f"上传失败: {why_error(info.code)} -> {self._up_path}") 230 | 231 | elif self._up_type == UploadType.FOLDER: 232 | infos = self._disk.upload_dir(self._up_path, self._folder_id, self._force, self._mkdir, 233 | callback=self._show_progress, failed_callback=self._show_upload_failed, 234 | up_handler=self._set_dir_count) 235 | if not isinstance(infos, list): # 进入单文件上传之前就已经出错(创建文件夹失败!) UpCode or MkCode 236 | self._error_msg(f"文件夹上传失败: {why_error(infos.code)} -> {self._up_path}") 237 | -------------------------------------------------------------------------------- /cloud189/cli/manager.py: -------------------------------------------------------------------------------- 1 | from threading import Thread 2 | from time import time, sleep, monotonic 3 | 4 | from cloud189.cli.downloader import TaskType 5 | from cloud189.cli.utils import info, error, get_file_size_str, OS_NAME, get_upload_status 6 | from cloud189.cli.reprint import output # 修改了 magic_char 7 | from cloud189.api.utils import logger 8 | 9 | __all__ = ['global_task_mgr'] 10 | 11 | OUTPUT_LIST = output() 12 | TOTAL_TASKS = 0 13 | 14 | 15 | class TimeoutExpired(Exception): 16 | pass 17 | 18 | 19 | def input_with_timeout(timeout=2): 20 | if OS_NAME == 'posix': # *nix 21 | import select 22 | import sys 23 | 24 | ready, _, _ = select.select([sys.stdin], [], [], timeout) 25 | if ready: 26 | try: 27 | return sys.stdin.readline().rstrip('\n') 28 | except OSError: 29 | return None 30 | raise TimeoutExpired 31 | 32 | else: # windows 33 | import msvcrt 34 | 35 | endtime = monotonic() + timeout 36 | result = [] 37 | while monotonic() < endtime: 38 | if msvcrt.kbhit(): 39 | result.append(msvcrt.getwche()) 40 | if result[-1] == '\n' or result[-1] == '\r': 41 | return ''.join(result[:-1]) 42 | sleep(0.05) # 这个值太大会导致丢失按键信息 43 | raise TimeoutExpired 44 | 45 | 46 | def sizeof_fmt(num, suffix='B'): 47 | for unit in ['','Ki','Mi','Gi','Ti','Pi','Ei','Zi']: 48 | if abs(num) < 1024.0: 49 | return "%6.1f%s%s" % (num, unit, suffix) 50 | num /= 1024.0 51 | return "%6.1f%s%s" % (num, 'Yi', suffix) 52 | 53 | 54 | class TaskManager(object): 55 | """下载/上传任务管理器""" 56 | 57 | def __init__(self): 58 | self._tasks = [] 59 | self._last_updated = {} 60 | 61 | def is_empty(self): 62 | """任务列表是否为空""" 63 | return len(self._tasks) == 0 64 | 65 | def has_alive_task(self): 66 | """是否有任务在后台运行""" 67 | for task in self._tasks: 68 | if task.is_alive(): 69 | return True 70 | return False 71 | 72 | def add_task(self, task): 73 | """提交一个上传/下载任务""" 74 | for t in self._tasks: 75 | if task.get_cmd_info() == t.get_cmd_info(): # 操作指令相同,认为是相同的任务 76 | old_pid = t.get_task_id() 77 | if t.is_alive(): # 下载任务正在运行 78 | info(f"任务正在后台运行: PID {old_pid}") 79 | return None 80 | else: # 下载任务为 Finished 或 Error 状态 81 | choice = input(f"任务已完成, PID {old_pid}, 重新下载吗?(y) ") 82 | if choice.lower() == 'y': 83 | task.set_task_id(old_pid) 84 | self._tasks[old_pid] = task 85 | task.start() 86 | return None 87 | # 没有发现重复的任务 88 | task.set_task_id(len(self._tasks)) 89 | self._tasks.append(task) 90 | task.start() 91 | 92 | def _size_to_msg(self, now_size, total_size, msg, pid, task) -> str: 93 | now = time() 94 | if pid not in self._last_updated: 95 | self._last_updated[pid] = now 96 | speed = 0 97 | else: 98 | speed = now_size / (now - self._last_updated[pid]) 99 | 100 | """任务详情可视化""" 101 | if total_size == -1: # zip 打包下载 102 | percent = get_file_size_str(now_size) 103 | else: 104 | percent = "{:8.1%}".format(now_size / total_size) 105 | has_error = len(task.get_err_msg()) != 0 106 | if task.is_alive(): # 任务执行中 107 | if now_size >= total_size: # 有可能 thread 关闭不及时 108 | status = '\033[1;34mFinished\033[0m' 109 | else: 110 | status = '\033[1;32mRunning \033[0m' 111 | elif not task.is_alive() and has_error: # 任务执行完成, 但是有错误信息 112 | status = '\033[1;31mError \033[0m' 113 | else: # 任务正常执行完成 114 | percent = "{:8.1%}".format(1) # 可能更新不及时 115 | status = '\033[1;34mFinished\033[0m' 116 | if task.get_task_type() == TaskType.DOWNLOAD: 117 | d_arg, f_name = task.get_cmd_info() 118 | d_arg = f_name if isinstance(d_arg, int) else d_arg # 显示 id 对应的文件名 119 | result = f"[{pid}] Status: {status} | Process: {percent} | Download: {d_arg}" 120 | else: 121 | up_path, folder_name = task.get_cmd_info() 122 | done_files, total_files = task.get_count() 123 | count = f" ({done_files}/{total_files})" if total_files > 0 else "" 124 | proc = get_upload_status(msg, percent) 125 | result = f"[{pid}] Status: {status} | Process:{proc} | Speed: {sizeof_fmt(speed)}/s | Upload: {up_path}{count} -> {folder_name}" 126 | 127 | return result 128 | 129 | 130 | def _show_task(self, pid, task, follow=False): 131 | TaskManager.running = True # 相当于每次执行 jobs -f 都初始化 132 | # TOTAL_TASKS 用于标记还没完成的任务数量 133 | global OUTPUT_LIST, TOTAL_TASKS 134 | 135 | def stop_show_task(): 136 | """停止显示任务状态""" 137 | stop_signal = None 138 | while TaskManager.running or TOTAL_TASKS > 0: 139 | try: 140 | stop_signal = input_with_timeout(2) 141 | except (TimeoutExpired, OSError): 142 | sleep(0.5) 143 | else: 144 | if stop_signal: 145 | TaskManager.running = False 146 | logger.debug(f"Stop_show_task break by User! {stop_signal=}, {TOTAL_TASKS=}") 147 | break 148 | logger.debug(f"Stop_show_task Exit! {TaskManager.running=}, {TOTAL_TASKS=}") 149 | 150 | if follow: Thread(target=stop_show_task).start() 151 | now_size, total_size, msg = task.get_process() 152 | done_files, total_files = task.get_count() 153 | while total_size == -1 or now_size < total_size or done_files <= total_files: 154 | if not TaskManager.running: 155 | break # 用户中断 156 | if follow: 157 | now_size, total_size, msg = task.get_process() 158 | done_files, total_files = task.get_count() 159 | OUTPUT_LIST[pid] = self._size_to_msg(now_size, total_size, msg, pid, task) 160 | # 文件秒传、出错 没有大小,需要跳过秒传检查 msg 161 | if ((msg and msg != 'check') or now_size >= total_size) and done_files >= total_files: 162 | TOTAL_TASKS -= 1 163 | logger.debug(f"{pid=} While Loop Break! {msg=}, {TOTAL_TASKS=}, {done_files=}, {total_files=}") 164 | while True: 165 | if not task.is_alive(): 166 | OUTPUT_LIST.append(f"[{pid}] finished") 167 | for err_msg in task.get_err_msg(): 168 | OUTPUT_LIST.append(f"[{pid}] Error Messages: {err_msg}") 169 | break 170 | sleep(1) 171 | # 只有还有一个没有完成, 就不能改 TaskManager.running 172 | if TaskManager.running and TOTAL_TASKS < 1: 173 | TaskManager.running = False # 辅助控制 stop_show_task 线程的结束 🤣 174 | logger.debug(f"{pid=} TaskManager changed running value to False") 175 | break 176 | sleep(1) 177 | else: 178 | print(self._size_to_msg(now_size, total_size, msg, pid, task)) 179 | break # 非实时显示模式,直接结束 180 | 181 | def _show_task_bar(self, pid=None, follow=False): 182 | """多行更新状态栏""" 183 | global OUTPUT_LIST, TOTAL_TASKS 184 | with output(output_type="list", initial_len=len(self._tasks), interval=0) as OUTPUT_LIST: 185 | pool = [] 186 | TOTAL_TASKS = len(self._tasks) 187 | logger.debug(f"TaskManager: {TOTAL_TASKS=}") 188 | for _pid, task in enumerate(self._tasks): 189 | if pid is not None and _pid != pid: # 如果指定了 pid 就只更新 pid 这个 task 190 | continue 191 | t = Thread(target=self._show_task, args=(_pid, task, follow)) 192 | t.start() 193 | pool.append(t) 194 | [t.join() for t in pool] 195 | 196 | def show_tasks(self, follow=False): 197 | """显示所有任务""" 198 | if self.is_empty(): 199 | print(f"没有任务在后台运行哦") 200 | else: 201 | if not follow: 202 | print('-' * 100) 203 | if follow: 204 | self._show_task_bar(follow=follow) 205 | else: 206 | for pid, task in enumerate(self._tasks): 207 | self._show_task(pid, task) 208 | if not follow: 209 | print('-' * 100) 210 | 211 | def show_detail(self, pid=-1, follow=False): 212 | """显示指定任务详情""" 213 | if 0 <= pid < len(self._tasks): 214 | task = self._tasks[pid] 215 | self._show_task_bar(pid, follow) 216 | print("Error Messages:") 217 | for msg in task.get_err_msg(): 218 | print(msg) 219 | else: 220 | error(f"进程号不存在: PID {pid}") 221 | 222 | 223 | # 全局任务管理器对象 224 | global_task_mgr = TaskManager() -------------------------------------------------------------------------------- /cloud189/cli/recovery.py: -------------------------------------------------------------------------------- 1 | from cloud189.api import Cloud189 2 | from cloud189.cli.utils import * 3 | from cloud189.cli import config 4 | 5 | 6 | class Recovery: 7 | """回收站命令行模式""" 8 | 9 | def __init__(self, disk: Cloud189): 10 | self._prompt = 'Recovery > ' 11 | self._reader_mode = config.reader_mode 12 | self._disk = disk 13 | 14 | print("回收站数据加载中...") 15 | self._file_list = disk.get_rec_file_list() 16 | 17 | def ls(self): 18 | if self._reader_mode: # 适宜屏幕阅读器的显示方式 19 | for file in self._file_list: 20 | print(f"{file.name} 大小:{get_file_size_str(file.size)} 删除时间:{file.optime} 路径:{file.path}") 21 | print("") 22 | else: # 普通用户的显示方式 23 | for file in self._file_list: 24 | print("#{0:<18}{1:<21} {3:>9} {4}\t{2}".format(file.id, file.optime, file.name, get_file_size_str(file.size), file.path)) 25 | print('总文件数: ', len(self._file_list)) 26 | 27 | def clean(self): 28 | """清空回收站""" 29 | if len(self._file_list) == 0: 30 | print("当前回收站为空!") 31 | else: 32 | choice = input('确认清空回收站?(y) ') 33 | if choice.lower() == 'y': 34 | if self._disk.rec_empty(self._file_list[0]) == Cloud189.SUCCESS: 35 | self._file_list.clear() 36 | info('回收站清空成功!') 37 | else: 38 | error('回收站清空失败!') 39 | 40 | def rm(self, name): 41 | """彻底删除文件(夹)""" 42 | if file := self._file_list.find_by_name(name): # 删除文件 43 | if self._disk.rec_delete(file) == Cloud189.SUCCESS: 44 | self._file_list.pop_by_id(file.id) 45 | else: 46 | error(f'彻底删除文件失败: {name}') 47 | else: 48 | error(f'文件不存在: {name}') 49 | 50 | def rec(self, name): 51 | """恢复文件""" 52 | if file := self._file_list.find_by_name(name): 53 | if self._disk.rec_restore(file) == Cloud189.SUCCESS: 54 | info(f"文件恢复成功: {name}") 55 | self._file_list.pop_by_id(file.id) 56 | else: 57 | error(f'彻底删除文件失败: {name}') 58 | else: 59 | error('(#`O′) 没有这个文件啊喂') 60 | 61 | def run(self): 62 | """在回收站模式下运行""" 63 | choice_list = self._file_list.all_name 64 | cmd_list = ['clean', 'cd', 'rec', 'rm'] 65 | set_completer(choice_list, cmd_list=cmd_list) 66 | 67 | while True: 68 | try: 69 | args = input(self._prompt).split() 70 | if len(args) == 0: 71 | continue 72 | except KeyboardInterrupt: 73 | info('已退出回收站模式') 74 | break 75 | 76 | cmd, arg = args[0], ' '.join(args[1:]) 77 | 78 | if cmd == 'ls': 79 | self.ls() 80 | elif cmd == 'clean': 81 | self.clean() 82 | elif cmd == 'rec': 83 | self.rec(arg) 84 | elif cmd == 'rm': 85 | self.rm(arg) 86 | elif cmd == 'cd' and arg == '..': 87 | print('') 88 | info('已退出回收站模式') 89 | break 90 | else: 91 | info('使用 cd .. 或 Crtl + C 退出回收站') 92 | -------------------------------------------------------------------------------- /cloud189/cli/reprint.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import print_function, division, unicode_literals 3 | 4 | import re 5 | import sys 6 | import time 7 | import threading 8 | from math import ceil 9 | 10 | import six 11 | if six.PY2: 12 | from backports.shutil_get_terminal_size import get_terminal_size 13 | input = raw_input 14 | else: 15 | from shutil import get_terminal_size 16 | from builtins import input 17 | 18 | __all__ = ['output'] 19 | 20 | last_output_lines = 0 21 | overflow_flag = False 22 | is_atty = sys.stdout.isatty() 23 | 24 | # magic_char = "\033[F" 25 | magic_char = "\x1b[1A" 26 | 27 | widths = [ 28 | (126, 1), (159, 0), (687, 1), (710, 0), (711, 1), 29 | (727, 0), (733, 1), (879, 0), (1154, 1), (1161, 0), 30 | (4347, 1), (4447, 2), (7467, 1), (7521, 0), (8369, 1), 31 | (8426, 0), (9000, 1), (9002, 2), (11021, 1), (12350, 2), 32 | (12351, 1), (12438, 2), (12442, 0), (19893, 2), (19967, 1), 33 | (55203, 2), (63743, 1), (64106, 2), (65039, 1), (65059, 0), 34 | (65131, 2), (65279, 1), (65376, 2), (65500, 1), (65510, 2), 35 | (120831, 1), (262141, 2), (1114109, 1), 36 | ] 37 | 38 | def get_char_width(char): 39 | global widths 40 | o = ord(char) 41 | if o == 0xe or o == 0xf: 42 | return 0 43 | for num, wid in widths: 44 | if o <= num: 45 | return wid 46 | return 1 47 | 48 | def width_cal_preprocess(content): 49 | """ 50 | 此函数同时删除 ANSI escape code,避免影响行宽计算 51 | This function also remove ANSI escape code to avoid the influence on line width calculation 52 | """ 53 | ptn = re.compile(r'(\033|\x1b)\[.*?m', re.I) 54 | _content = re.sub(ptn, '', content) # remove ANSI escape code 55 | return _content 56 | 57 | def preprocess(content): 58 | """ 59 | 对输出内容进行预处理,转为str类型 (py3),并替换行内\r\t\n等字符为空格 60 | do pre-process to the content, turn it into str (for py3), and replace \r\t\n with space 61 | """ 62 | 63 | if six.PY2: 64 | if not isinstance(content, unicode): 65 | if isinstance(content, str): 66 | _content = unicode(content, encoding=sys.stdin.encoding) 67 | elif isinstance(content, int): 68 | _content = unicode(content) 69 | else: 70 | _content = content 71 | assert isinstance(_content, unicode) 72 | 73 | elif six.PY3: 74 | _content = str(content) 75 | 76 | _content = re.sub(r'\r|\t|\n', ' ', _content) 77 | return _content 78 | 79 | 80 | def cut_off_at(content, width): 81 | if line_width(content) > width: 82 | now = content[:width] 83 | while line_width(now) > width: 84 | now = now[:-1] 85 | now += "$" * (width - line_width(now)) 86 | return now 87 | else: 88 | return content 89 | 90 | def print_line(content, columns, force_single_line): 91 | 92 | padding = " " * ((columns - line_width(content)) % columns) 93 | output = "{content}{padding}".format(content=content, padding=padding) 94 | if force_single_line: 95 | output = cut_off_at(output, columns) 96 | print(output, end='') 97 | sys.stdout.flush() 98 | 99 | 100 | def line_width(line): 101 | """ 102 | 计算本行在输出到命令行后所占的宽度 103 | calculate the width of output in terminal 104 | """ 105 | if six.PY2: 106 | assert isinstance(line, unicode) 107 | _line = width_cal_preprocess(line) 108 | result = sum(map(get_char_width, _line)) 109 | return result 110 | 111 | 112 | def lines_of_content(content, width): 113 | """ 114 | 计算内容在特定输出宽度下实际显示的行数 115 | calculate the actual rows with specific terminal width 116 | """ 117 | result = 0 118 | if isinstance(content, list): 119 | for line in content: 120 | _line = preprocess(line) 121 | result += ceil(line_width(_line) / width) 122 | elif isinstance(content, dict): 123 | for k, v in content.items(): 124 | # 加2是算上行内冒号和空格的宽度 125 | # adding 2 for the for the colon and space ": " 126 | _k, _v = map(preprocess, (k, v)) 127 | result += ceil((line_width(_k) + line_width(_v) + 2) / width) 128 | return int(result) 129 | 130 | 131 | def print_multi_line(content, force_single_line, sort_key): 132 | """ 133 | 'sort_key' 参数只在 dict 模式时有效 134 | 'sort_key' parameter only available in 'dict' mode 135 | """ 136 | 137 | global last_output_lines 138 | global overflow_flag 139 | global is_atty 140 | 141 | if not is_atty: 142 | if isinstance(content, list): 143 | for line in content: 144 | print(line) 145 | elif isinstance(content, dict): 146 | for k, v in sorted(content.items(), key=sort_key): 147 | print("{}: {}".format(k, v)) 148 | else: 149 | raise TypeError("Excepting types: list, dict. Got: {}".format(type(content))) 150 | return 151 | 152 | columns, rows = get_terminal_size() 153 | lines = lines_of_content(content, columns) 154 | if force_single_line is False and lines > rows: 155 | overflow_flag = True 156 | elif force_single_line is True and len(content) > rows: 157 | overflow_flag = True 158 | 159 | # 确保初始输出位置是位于最左处的 160 | # to make sure the cursor is at the left most 161 | print("\b" * columns, end="") 162 | 163 | if isinstance(content, list): 164 | for line in content: 165 | _line = preprocess(line) 166 | print_line(_line, columns, force_single_line) 167 | elif isinstance(content, dict): 168 | for k, v in sorted(content.items(), key=sort_key): 169 | _k, _v = map(preprocess, (k, v)) 170 | print_line("{}: {}".format(_k, _v), columns, force_single_line) 171 | else: 172 | raise TypeError("Excepting types: list, dict. Got: {}".format(type(content))) 173 | 174 | # 输出额外的空行来清除上一次输出的剩余内容 175 | # do extra blank lines to wipe the remaining of last output 176 | print(" " * columns * (last_output_lines - lines), end="") 177 | 178 | # 回到初始输出位置 179 | # back to the origin pos 180 | print(magic_char * (max(last_output_lines, lines)-1), end="") 181 | sys.stdout.flush() 182 | last_output_lines = lines 183 | 184 | 185 | class output: 186 | 187 | class SignalList(list): 188 | 189 | def __init__(self, parent, obj): 190 | super(output.SignalList, self).__init__(obj) 191 | self.parent = parent 192 | self.lock = threading.Lock() 193 | 194 | def __setitem__(self, key, value): 195 | global is_atty 196 | with self.lock: 197 | super(output.SignalList, self).__setitem__(key, value) 198 | if not is_atty: 199 | print("{}".format(value)) 200 | else: 201 | self.parent.refresh(int(time.time()*1000), forced=False) 202 | 203 | def clear(self): 204 | global is_atty 205 | # with self.lock: In all places you call clear, you actually already have the lock 206 | if six.PY2: 207 | self[:] = [] 208 | elif six.PY3: 209 | super(output.SignalList, self).clear() 210 | 211 | if is_atty: 212 | self.parent.refresh(int(time.time()*1000), forced=False) 213 | 214 | def change(self, newlist): 215 | with self.lock: 216 | self.clear() 217 | self.extend(newlist) 218 | if is_atty: 219 | self.parent.refresh(int(time.time()*1000), forced=False) 220 | 221 | def append(self, x): 222 | global is_atty 223 | with self.lock: 224 | super(output.SignalList, self).append(x) 225 | if not is_atty: 226 | print("{}".format(x)) 227 | else: 228 | self.parent.refresh(int(time.time()*1000), forced=False) 229 | 230 | def insert(self, i, x): 231 | global is_atty 232 | with self.lock: 233 | super(output.SignalList, self).insert(i, x) 234 | if not is_atty: 235 | print("{}".format(x)) 236 | else: 237 | self.parent.refresh(int(time.time()*1000), forced=False) 238 | 239 | def remove(self, x): 240 | global is_atty 241 | with self.lock: 242 | super(output.SignalList, self).remove(x) 243 | if is_atty: 244 | self.parent.refresh(int(time.time()*1000), forced=False) 245 | 246 | def pop(self, i=-1): 247 | global is_atty 248 | with self.lock: 249 | rs = super(output.SignalList, self).pop(i) 250 | if is_atty: 251 | self.parent.refresh(int(time.time()*1000), forced=False) 252 | return rs 253 | 254 | def sort(self, *args, **kwargs): 255 | global is_atty 256 | with self.lock: 257 | super(output.SignalList, self).sort(*args, **kwargs) 258 | if is_atty: 259 | self.parent.refresh(int(time.time()*1000), forced=False) 260 | 261 | 262 | class SignalDict(dict): 263 | 264 | def __init__(self, parent, obj): 265 | super(output.SignalDict, self).__init__(obj) 266 | self.parent = parent 267 | self.lock = threading.Lock() 268 | 269 | def change(self, newlist): 270 | with self.lock: 271 | self.clear() 272 | super(output.SignalDict, self).update(newlist) 273 | self.parent.refresh(int(time.time()*1000), forced=False) 274 | 275 | def __setitem__(self, key, value): 276 | global is_atty 277 | with self.lock: 278 | super(output.SignalDict, self).__setitem__(key, value) 279 | if not is_atty: 280 | print("{}: {}".format(key, value)) 281 | else: 282 | self.parent.refresh(int(time.time()*1000), forced=False) 283 | 284 | def clear(self): 285 | global is_atty 286 | # with self.lock: In all places you call clear, you actually already have the lock 287 | super(output.SignalDict, self).clear() 288 | if is_atty: 289 | self.parent.refresh(int(time.time()*1000), forced=False) 290 | 291 | def pop(self, *args, **kwargs): 292 | global is_atty 293 | with self.lock: 294 | rs = super(output.SignalDict, self).pop(*args, **kwargs) 295 | if is_atty: 296 | self.parent.refresh(int(time.time()*1000), forced=False) 297 | return rs 298 | 299 | def popitem(self, *args, **kwargs): 300 | global is_atty 301 | with self.lock: 302 | rs = super(output.SignalDict, self).popitem(*args, **kwargs) 303 | if is_atty: 304 | self.parent.refresh(int(time.time()*1000), forced=False) 305 | return rs 306 | 307 | def setdefault(self, *args, **kwargs): 308 | global is_atty 309 | with self.lock: 310 | rs = super(output.SignalDict, self).setdefault(*args, **kwargs) 311 | if is_atty: 312 | self.parent.refresh(int(time.time()*1000), forced=False) 313 | return rs 314 | 315 | def update(self, *args, **kwargs): 316 | global is_atty 317 | with self.lock: 318 | super(output.SignalDict, self).update(*args, **kwargs) 319 | if is_atty: 320 | self.parent.refresh(int(time.time()*1000), forced=False) 321 | 322 | 323 | def __init__(self, output_type="list", initial_len=1, interval=0, force_single_line=False, no_warning=False, sort_key=lambda x:x[0]): 324 | self.sort_key = sort_key 325 | self.no_warning = no_warning 326 | no_warning and print("All reprint warning diabled.") 327 | 328 | global is_atty 329 | # reprint does not work in the IDLE terminal, and any other environment that can't get terminal_size 330 | if is_atty and not all(get_terminal_size()): 331 | if not no_warning: 332 | r = input("Fail to get terminal size, we got {}, continue anyway? (y/N)".format(get_terminal_size())) 333 | if not (r and isinstance(r, str) and r.lower()[0] in ['y','t','1']): 334 | sys.exit(0) 335 | 336 | is_atty = False 337 | 338 | if output_type == "list": 339 | self.warped_obj = output.SignalList(self, [''] * initial_len) 340 | elif output_type == "dict": 341 | self.warped_obj = output.SignalDict(self, {}) 342 | 343 | self.interval = interval 344 | self.force_single_line = force_single_line 345 | self._last_update = int(time.time()*1000) 346 | 347 | def refresh(self, new_time=0, forced=True): 348 | if new_time - self._last_update >= self.interval or forced: 349 | print_multi_line(self.warped_obj, self.force_single_line, sort_key=self.sort_key) 350 | self._last_update = new_time 351 | 352 | def __enter__(self): 353 | global is_atty 354 | if not is_atty: 355 | if not self.no_warning: 356 | print("Not in terminal, reprint now using normal build-in print function.") 357 | 358 | return self.warped_obj 359 | 360 | def __exit__(self, exc_type, exc_val, exc_tb): 361 | global is_atty 362 | 363 | self.refresh(forced=True) 364 | if is_atty: 365 | columns, _ = get_terminal_size() 366 | if self.force_single_line: 367 | print('\n' * len(self.warped_obj), end="") 368 | else: 369 | print('\n' * lines_of_content(self.warped_obj, columns), end="") 370 | global last_output_lines 371 | global overflow_flag 372 | last_output_lines = 0 373 | if overflow_flag: 374 | if not self.no_warning: 375 | print("Detected that the lines of output has been exceeded the height of terminal windows, which \ 376 | caused the former output remained and keep adding new lines.") 377 | print("检测到输出过程中, 输出行数曾大于命令行窗口行数, 这会导致输出清除不完整, 而使输出不停增长。请注意控制输出行数。") 378 | -------------------------------------------------------------------------------- /cloud189/cli/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import logging 4 | from platform import system as platform 5 | 6 | import readline 7 | import requests 8 | 9 | from cloud189.api.utils import ROOT_DIR 10 | from cloud189.api import Cloud189 11 | from cloud189.cli import version 12 | 13 | __all__ = ['error', 'info', 'clear_screen', 'get_file_size_str', 'parsing_up_params', 14 | 'check_update', 'handle_name', 'handle_args', 'captcha_handler', 15 | 'set_completer', 'print_help', 'check_update'] 16 | 17 | GIT_REPO = "Aruelius/cloud189" 18 | logging.getLogger("requests").setLevel(logging.WARNING) 19 | logging.getLogger("urllib3").setLevel(logging.WARNING) 20 | M_platform = platform() 21 | OS_NAME = os.name 22 | _ERR = False # 用于标识是否遇到错误 23 | 24 | 25 | def error(msg): 26 | global _ERR 27 | print(f"\033[1;31mError : {msg}\033[0m") 28 | _ERR = True 29 | 30 | 31 | def info(msg): 32 | print(f"\033[1;34mInfo : {msg}\033[0m") 33 | 34 | 35 | def clear_screen(): 36 | """清空屏幕""" 37 | if os.name == 'nt': 38 | os.system('cls') 39 | else: 40 | os.system('clear') 41 | 42 | 43 | def get_file_size_str(filesize) -> str: 44 | if not filesize: 45 | return '' 46 | filesize = int(filesize) 47 | if 0 < filesize < 1 << 20: 48 | return f"{round(filesize/1024, 2)}KB" 49 | elif 1 << 20 < filesize < 1 << 30: 50 | return f"{round(filesize/(1 << 20), 2)}MB" 51 | elif 1 << 30 < filesize < 1 << 40: 52 | return f"{round(filesize/(1 << 30), 2)}GB" 53 | elif 1 << 40 < filesize < 1 << 50: 54 | return f"{round(filesize/(1 << 40), 2)}TB" 55 | else: return f"{filesize}Bytes" 56 | 57 | 58 | def why_error(code): 59 | """错误原因""" 60 | if code == Cloud189.URL_INVALID: 61 | return '分享链接无效' 62 | elif code == Cloud189.LACK_PASSWORD: 63 | return '缺少提取码' 64 | elif code == Cloud189.PASSWORD_ERROR: 65 | return '提取码错误' 66 | elif code == Cloud189.FILE_CANCELLED: 67 | return '分享链接已失效' 68 | elif code == Cloud189.NETWORK_ERROR: 69 | return '网络连接异常' 70 | elif code == Cloud189.CAPTCHA_ERROR: 71 | return '验证码错误' 72 | elif code == Cloud189.UP_COMMIT_ERROR: 73 | return '上传文件 commit 错误' 74 | elif code == Cloud189.UP_CREATE_ERROR: 75 | return '创建上传任务出错' 76 | elif code == Cloud189.UP_EXHAUSTED_ERROR: 77 | return '今日上传量已用完' 78 | elif code == Cloud189.UP_ILLEGAL_ERROR: 79 | return '文件非法' 80 | else: 81 | return '未知错误' 82 | 83 | 84 | def get_upload_status(msg, percent): 85 | """文件上传状态""" 86 | if msg == 'quick_up': 87 | return " \033[1;34m秒传!\033[0m " 88 | elif msg == 'check': 89 | return "\033[1;34m秒传检查\033[0m" 90 | elif msg == 'error': 91 | return "\033[1;31m秒传失败\033[0m" 92 | elif msg == 'exist': 93 | return "\033[1;31m远端存在\033[0m" 94 | elif msg == 'illegal': 95 | return "\033[1;31m非法文件\033[0m" 96 | elif msg == 'exhausted': 97 | return "\033[1;31m流量耗尽\033[0m" 98 | else: 99 | return percent 100 | 101 | 102 | def set_console_style(): 103 | """设置命令行窗口样式""" 104 | if os.name != 'nt': 105 | return None 106 | os.system('mode 120, 40') 107 | os.system(f'title 天翼云盘-cli {version}') 108 | 109 | 110 | def captcha_handler(img_data): 111 | """处理下载时出现的验证码""" 112 | img_path = ROOT_DIR + os.sep + 'captcha.png' 113 | with open(img_path, 'wb') as f: 114 | f.write(img_data) 115 | if M_platform == 'Darwin': 116 | os.system(f'open {img_path}') 117 | elif M_platform == 'Linux': 118 | # 检测是否运行在没有显示屏的 console 上 119 | if os.environ.get('DISPLAY'): 120 | os.system(f'xdg-open {img_path}') 121 | else: 122 | from fabulous import image as fabimg 123 | 124 | print(fabimg.Image(f'{img_path}')) 125 | else: 126 | os.startfile(img_path) # windows 127 | ans = input('\n请输入验证码:') 128 | os.remove(img_path) 129 | return ans 130 | 131 | 132 | def text_align(text, length) -> str: 133 | """中英混合字符串对齐""" 134 | text_len = len(text) 135 | for char in text: 136 | if u'\u4e00' <= char <= u'\u9fff': 137 | text_len += 1 138 | space = length - text_len 139 | return text + ' ' * space 140 | 141 | 142 | def parsing_up_params(arg: str, follow, force, mkdir) -> (bool, bool, bool, bool): 143 | """解析文件上传参数 144 | :param str arg: 解析参数 145 | :param bool follow: 实时任务 146 | :param bool force: 强制上传 147 | :param bool mkdir: 不创建父文件夹 148 | :return: follow, force, mkdir, match(标识是否需要删除 arg) 149 | """ 150 | match = False 151 | if len(arg) > 1: 152 | if arg.startswith('--'): 153 | if arg == '--follow': # 实时任务 154 | follow = True 155 | match = True 156 | elif arg == '--force': # 强制上传 157 | force = True 158 | match = True 159 | elif arg == '--nodir': # 不创建父文件夹 160 | mkdir = False 161 | match = True 162 | elif arg.startswith('-'): 163 | for i in arg[1:]: 164 | if i == 'f': # 实时任务 165 | follow = True 166 | match = True 167 | elif i == 'F': # 强制上传 168 | force = True 169 | match = True 170 | elif i == 'n': # 不创建父文件夹 171 | mkdir = False 172 | match = True 173 | return follow, force, mkdir, match 174 | 175 | 176 | def handle_name(name: str) -> str: 177 | """使用引号包裹有空格的文件名""" 178 | if ' ' in name: 179 | name = "'" + name + "'" 180 | return name 181 | 182 | 183 | def handle_args(args: str) -> list: 184 | '''处理参数列表,返回参数列表''' 185 | result = [] 186 | arg = '' 187 | i = 0 188 | flag_1 = False # 标记 " 189 | flag_2 = False # 标记 ' 190 | while i < len(args): 191 | if flag_1 and args[i] != '"': 192 | arg += args[i] 193 | elif flag_2 and args[i] != '\'': 194 | arg += args[i] 195 | elif args[i] not in (' ', '\\', '"', '\''): 196 | arg += args[i] 197 | elif args[i] == '\\' and i < len(args) and args[i + 1] in (' ', '"', '\''): 198 | arg += args[i + 1] 199 | i += 1 # 额外前进一步 200 | elif args[i] == ' ': 201 | if arg: 202 | result.append(arg) 203 | arg = '' # 新的参数 204 | elif args[i] == '"': 205 | if flag_2: # ' some"s thing ' "other params" 206 | arg += args[i] 207 | else: 208 | flag_1 = not flag_1 209 | elif args[i] == '\'': 210 | if flag_1: # " some's thing " 'other params' 211 | arg += args[i] 212 | else: 213 | flag_2 = not flag_2 214 | i += 1 215 | if arg: 216 | result.append(arg) 217 | return result 218 | 219 | 220 | def set_completer(choice_list, *, cmd_list=None, condition=None): 221 | """设置自动补全""" 222 | if condition is None: 223 | condition = lambda typed, choice: choice.startswith(typed) or choice.startswith("'" + typed) # 默认筛选条件:选项以键入字符开头 224 | 225 | def completer(typed, rank): 226 | tab_list = [] # TAB 补全的选项列表 227 | if cmd_list is not None and not typed: # 内置命令提示 228 | return cmd_list[rank] 229 | 230 | for choice in choice_list: 231 | if condition(typed, choice): 232 | tab_list.append(choice) 233 | return tab_list[rank] 234 | 235 | readline.parse_and_bind("tab: complete") 236 | readline.set_completer(completer) 237 | 238 | 239 | def print_logo(): 240 | """输出logo""" 241 | if _ERR: # 有错误就不清屏不打印 logo 242 | return None 243 | else: 244 | clear_screen() 245 | logo_str = f""" 246 | # /$$$$$$ /$$ /$$ /$$ /$$$$$$ /$$$$$$ 247 | # /$$__ $$| $$ | $$ /$$$$ /$$__ $$ /$$__ $$ 248 | # | $$ \__/| $$ /$$$$$$ /$$ /$$ /$$$$$$$|_ $$ | $$ \ $$| $$ \ $$ 249 | # | $$ | $$ /$$__ $$| $$ | $$ /$$__ $$ | $$ | $$$$$$/| $$$$$$$ 250 | # | $$ | $$| $$ \ $$| $$ | $$| $$ | $$ | $$ >$$__ $$ \____ $$ 251 | # | $$ $$| $$| $$ | $$| $$ | $$| $$ | $$ | $$ | $$ \ $$ /$$ \ $$ 252 | # | $$$$$$/| $$| $$$$$$/| $$$$$$/| $$$$$$$ /$$$$$$| $$$$$$/| $$$$$$/ 253 | # \______/ |__/ \______/ \______/ \_______/|______/ \______/ \______/ 254 | # 255 | -------------------------------------------------------------------------- 256 | Github: https://github.com/{GIT_REPO} (Version: {version}) 257 | -------------------------------------------------------------------------- 258 | """ 259 | print(logo_str) 260 | 261 | 262 | def print_help(): 263 | # clear_screen() 264 | help_text = f""" cloud189-cli | 天翼云盘客户端 for {M_platform} | v{version} 265 | • 支持文件秒传,文件夹保持相对路径上传 266 | • 获取文件分享链接,批量上传下载,断点续传等功能 267 | 268 | 命令帮助 : 269 | help 显示本信息 270 | update 检查更新 271 | *rmode 屏幕阅读器模式 272 | refresh/r 强制刷新文件列表 273 | login 使用账号密码登录网盘/添加用户 274 | clogin 使用 Cookie 登录网盘/添加用户 275 | *logout 删除当前用户 Cookie/删除指定用户 276 | su 列出、切换账户 277 | jobs/j 查看后台任务列表 278 | ls 列出文件(夹),仅文件名 279 | ll 列出文件(夹),详细 280 | cd 切换工作目录 281 | cdrec 进入回收站目录 282 | rm 彻底删除文件 283 | rec 恢复文件 284 | clean 清空回收站 285 | cd .. 退出回收站 286 | rm 删除网盘文件(夹) 287 | rename 重命名文件(夹) 288 | mv 移动文件(夹) 289 | mkdir 创建新文件夹 290 | share 显示文件(夹)分享信息 291 | shared 显示已经分享的文件(夹)信息 292 | clear/c 清空屏幕 293 | upload/u 上传文件(夹) 294 | down/d 下载文件、提取分享链接直链 # TODO: 下载文件夹 295 | setpath 设置文件下载路径 296 | who/quota 查看当前账户信息 297 | sign 签到+抽奖 298 | bye/exit/b 退出本程序 299 | 300 | * 表示目前版本无法使用。 301 | 更详细的介绍请参考本项目的 Github 主页: 302 | https://github.com/{GIT_REPO} 303 | 如有 Bug 反馈或建议请在 GitHub 提 Issue 304 | 感谢您的使用 (●'◡'●) 305 | """ 306 | print(help_text) 307 | 308 | 309 | def check_update(): 310 | """检查更新""" 311 | print("正在检测更新...") 312 | api = f"https://api.github.com/repos/{GIT_REPO}/releases/latest" 313 | tag_name = None 314 | try: 315 | resp = requests.get(api, timeout=3).json() 316 | tag_name, msg = resp['tag_name'], resp['body'] 317 | except (requests.RequestException, AttributeError, KeyError) as err: 318 | error(f"检查更新时发生异常,可能是 GitHub 间歇性无法访问!\n{err=}") 319 | return None 320 | if tag_name: 321 | ver = version.split('.') 322 | ver2 = tag_name.replace('v', '').split('.') 323 | local_version = int(ver[0]) * 100 + int(ver[1]) * 10 + int(ver[2]) 324 | remote_version = int(ver2[0]) * 100 + int(ver2[1]) * 10 + int(ver2[2]) 325 | if remote_version > local_version: 326 | print(f"程序可以更新 v{version} -> {tag_name}") 327 | print(f"\n@更新说明:\n{msg}") 328 | print("\n@Linux 更新:") 329 | input(f"git clone --depth=1 https://github.com/{GIT_REPO}.git") 330 | else: 331 | print("(*/ω\*) 暂无新版本发布~") 332 | print("但项目可能已经更新,建议去项目主页看看") 333 | print("如有 Bug 或建议,请提 Issue") 334 | print(f"Github: https://github.com/{GIT_REPO}") 335 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding=utf-8 -*- 3 | 4 | import sys 5 | from cloud189.cli.cli import Commander 6 | from cloud189.cli.utils import print_logo, check_update, error 7 | 8 | if __name__ == '__main__': 9 | commander = Commander() 10 | commander.login(("--auto", )) 11 | 12 | if len(sys.argv) >= 2: 13 | cmd, args = (sys.argv[1], []) if len(sys.argv) == 2 else (sys.argv[1], [*sys.argv[2:]]) 14 | commander.run_one(cmd, args) 15 | else: 16 | check_update() 17 | print_logo() 18 | while True: 19 | try: 20 | commander.run() 21 | except KeyboardInterrupt: 22 | pass 23 | except Exception as e: 24 | error(e) 25 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | certifi 2 | chardet 3 | idna 4 | # 只有 Windows 用户需要安装 pyreadline 5 | # *nix 系统 Python 包含有 readline 模块 6 | pyreadline 7 | requests 8 | requests_toolbelt 9 | rsa 10 | simplejson 11 | six 12 | #添加可以在Linux headless模式下输入验证码的图形库 13 | fabulous 14 | Pillow 15 | --------------------------------------------------------------------------------