├── .editorconfig ├── .env.example ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── License.txt ├── Readme.md ├── example_json.py ├── example_pickle.py ├── pyproject.toml ├── tests ├── __init__.py └── test_main.py └── tweepy_authlib ├── CookieSessionUserHandler.py ├── __about__.py └── __init__.py /.editorconfig: -------------------------------------------------------------------------------- 1 | 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_size = 4 9 | indent_style = space 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | 2 | # テストに必要な環境変数を設定する 3 | TWITTER_SCREEN_NAME=elonmusk 4 | TWITTER_PASSWORD=password 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/python 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=python 3 | 4 | ### Python ### 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | cover/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | .pybuilder/ 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | # For a library or package, you might want to ignore these files since the code is 91 | # intended to run in multiple environments; otherwise, check them in: 92 | # .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # poetry 102 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 103 | # This is especially recommended for binary packages to ensure reproducibility, and is more 104 | # commonly ignored for libraries. 105 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 106 | #poetry.lock 107 | 108 | # pdm 109 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 110 | #pdm.lock 111 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 112 | # in version control. 113 | # https://pdm.fming.dev/#use-with-ide 114 | .pdm.toml 115 | 116 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 117 | __pypackages__/ 118 | 119 | # Celery stuff 120 | celerybeat-schedule 121 | celerybeat.pid 122 | 123 | # SageMath parsed files 124 | *.sage.py 125 | 126 | # Environments 127 | .env 128 | .venv 129 | env/ 130 | venv/ 131 | ENV/ 132 | env.bak/ 133 | venv.bak/ 134 | 135 | # Spyder project settings 136 | .spyderproject 137 | .spyproject 138 | 139 | # Rope project settings 140 | .ropeproject 141 | 142 | # mkdocs documentation 143 | /site 144 | 145 | # mypy 146 | .mypy_cache/ 147 | .dmypy.json 148 | dmypy.json 149 | 150 | # Pyre type checker 151 | .pyre/ 152 | 153 | # pytype static type analyzer 154 | .pytype/ 155 | 156 | # Cython debug symbols 157 | cython_debug/ 158 | 159 | # PyCharm 160 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 161 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 162 | # and can be added to the global gitignore or merged into this file. For a more nuclear 163 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 164 | #.idea/ 165 | 166 | ### Python Patch ### 167 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 168 | poetry.toml 169 | 170 | # ruff 171 | .ruff_cache/ 172 | 173 | # End of https://www.toptal.com/developers/gitignore/api/python 174 | 175 | cookie.json 176 | cookie.pickle 177 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "editorconfig.editorconfig", 4 | "ms-python.python", 5 | "ms-python.vscode-pylance", 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Pylance の Type Checking を有効化 3 | "python.languageServer": "Pylance", 4 | "python.analysis.typeCheckingMode": "strict", 5 | // Pylance の Type Checking のうち、いくつかのエラー報告を抑制する 6 | "python.analysis.diagnosticSeverityOverrides": { 7 | "reportConstantRedefinition": "none", 8 | "reportMissingTypeStubs": "none", 9 | "reportPrivateImportUsage": "none", 10 | "reportShadowedImports": "none", 11 | "reportUnnecessaryComparison": "none", 12 | "reportUnknownArgumentType": "none", 13 | "reportUnknownMemberType": "none", 14 | "reportUnknownVariableType": "none", 15 | "reportUnusedFunction": "none", 16 | }, 17 | } 18 | -------------------------------------------------------------------------------- /License.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-2025 tsukumi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | 2 | # tweepy-authlib 3 | 4 | [![PyPI - Version](https://img.shields.io/pypi/v/tweepy-authlib.svg)](https://pypi.org/project/tweepy-authlib) 5 | [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/tweepy-authlib.svg)](https://pypi.org/project/tweepy-authlib) 6 | 7 | > [!WARNING] 8 | > **旧 TweetDeck の完全廃止にともない、2023/09/14 頃から内部的に残存していた Twitter API v1.1 の段階的なシャットダウンが開始されています。** 9 | > **2024/04/30 時点では、下記 API が既に廃止されています ([参考](https://github.com/dimdenGD/OldTweetDeck/blob/main/src/interception.js)) 。** 10 | > 現時点では 2023/09 にサーバー負荷が高い API が一括廃止されて以降の動きはありません。ただし、リストにない API も既に廃止されている可能性があります。 11 | > - `search/tweets` : ツイート検索 12 | > - `search/universal` : ツイート検索 (旧 TweetDeck 独自 API) 13 | > - `statuses/update` : ツイート投稿 14 | > - `statuses/retweet/:id` : リツイート 15 | > - `statuses/unretweet/:id` : リツイート取り消し 16 | > - `statuses/show/:id` : ツイート詳細 17 | > - `statuses/destroy/:id` : ツイート削除 18 | > - `statuses/user_timeline` : ユーザータイムライン 19 | > - `users/search` : ユーザー検索 20 | > 21 | > **現在 tweepy-authlib を利用して上記機能を実装するには、別途 GraphQL API (Twitter Web App の内部 API) クライアントを自作する必要があります。** 22 | > 私が [KonomiTV](https://github.com/tsukumijima/KonomiTV) 向けに開発した GraphQL API クライアントの実装が [こちら](https://github.com/tsukumijima/KonomiTV/blob/master/server/app/utils/TwitterGraphQLAPI.py) ([使用例](https://github.com/tsukumijima/KonomiTV/blob/master/server/app/routers/TwitterRouter.py)) にありますので、参考になれば幸いです。 23 | > また現時点で廃止されていない API を利用したサンプルコードが [example_json.py](example_json.py) と [example_pickle.py](example_pickle.py) にありますので、そちらもご一読ください。 24 | 25 | > [!NOTE] 26 | > **tweepy-authlib v1.4.1 以降では、より厳密に Twitter Web App からの HTTP リクエストに偽装したり、一部の Twitter API v1.1 に再びアクセスできるようになるなど、様々な改善が行われています!** 27 | > 凍結やアカウントロックのリスクを下げるためにも、最新版の tweepy-authlib の利用をおすすめします。 28 | 29 | > [!IMPORTANT] 30 | > **tweepy-authlib v1.5.0 にて、twitter.com の x.com への移行に対応しました。** 31 | > 2024/05/18 時点では twitter.com のままでも API にアクセスできますが、不審がられるリスクが上がるうえ、いつまでアクセスできるかも不透明なためです。 32 | > これにより、`CookieSessionUserHandler.get_graphql_api_headers()` で返される Origin / Referer ヘッダーの値が `twitter.com` から `x.com` に変更されています。 33 | > GraphQL API クライアントを自作されている場合は、更新と同時に GraphQL API クライアントのアクセス先 URL を `twitter.com/i/api/graphql` から `x.com/i/api/graphql` に変更することを推奨します。 34 | 35 | > [!IMPORTANT] 36 | > 2024/05/18 時点では [tweepy-authlib が依存する js2py が Python 3.12 に対応していない](https://github.com/tsukumijima/tweepy-authlib/issues/5) ため、tweepy-authlib は Python 3.12 以降では動作しません。 37 | > [js2py](https://github.com/PiotrDabkowski/Js2Py) の Python 3.12 対応が完了するまで、Python 3.11 以下での利用をおすすめします。 38 | 39 | ----- 40 | 41 | **Table of Contents** 42 | 43 | - [tweepy-authlib](#tweepy-authlib) 44 | - [Description](#description) 45 | - [Installation](#installation) 46 | - [Usage](#usage) 47 | - [With JSON](#with-json) 48 | - [With Pickle](#with-pickle) 49 | - [License](#license) 50 | 51 | ## Description 52 | 53 | Twitter Web App (Web 版公式クライアント) の内部 API を使い、[Tweepy](https://github.com/tweepy/tweepy) でスクリーンネームとパスワードで認証するためのライブラリです。 54 | 55 | スクリーンネーム (ex: `@elonmusk`) とパスワードを指定して認証し、取得した Cookie などの認証情報で Twitter API v1.1 にアクセスできます。 56 | 毎回ログインしていては面倒 & 不審なアクセス扱いされそうなので、Cookie をファイルなどに保存し、次回以降はその Cookie を使ってログインする機能もあります。 57 | 58 | Tweepy を利用しているソースコードのうち、認証部分 (`tweepy.auth.OAuth1UserHandler`) を `tweepy_authlib.CookieSessionUserHandler` に置き換えるだけで、かんたんに Cookie ベースの認証に変更できます! 59 | 認証部分以外は OAuth API のときの実装がそのまま使えるので、ソースコードの変更も最小限に抑えられます。 60 | 61 | > [!NOTE] 62 | > OAuth API と公式クライアント用の内部 API がほぼ共通だった v1.1 とは異なり、v2 では OAuth API と公式クライアント用の内部 API が大きく異なります。 63 | > そのため、`CookieSessionUserHandler` は Twitter API v2 には対応していません。 64 | > また、今のところ2段階認証にも対応していません (2段階認証に関しては技術的には実装可能だが、確認コードの送信周りの実装が面倒…) 。 65 | 66 | 認証フローはブラウザ上で動作する Web 版公式クライアントの API アクセス動作や HTTP リクエストヘッダーを可能な限りエミュレートしています。 67 | ブラウザから抽出した Web 版公式クライアントのログイン済み Cookie を使うことでも認証が可能です。 68 | 69 | > [!NOTE] 70 | > ブラウザから Cookie を抽出する場合、(不審なアクセス扱いされないために) できればすべての Cookie を抽出することが望ましいですが、実装上は Cookie 内の `auth_token` と `ct0` の2つの値だけあれば認証できます。 71 | > なお、ブラウザから取得した Cookie は事前に `requests.cookies.RequestsCookieJar` に変換してください。 72 | 73 | さらに API アクセス時は TweetDeck の HTTP リクエスト (Twitter API v1.1) をエミュレートしているため、レートリミットなどの制限は TweetDeck と同一です。 74 | 75 | > [!NOTE] 76 | > `CookieSessionUserHandler` で取得した認証情報を使うと、TweetDeck でしか利用できない search/universal などの内部 API にもアクセスできるようになります。 77 | > ただし、Tweepy はそうした内部 API をサポートしていないため、アクセスするには独自に `tweepy.API.request()` で HTTP リクエストを送る必要があります。 78 | 79 | > [!WARNING] 80 | > このライブラリは、非公式かつ内部的な API をリバースエンジニアリングし、ブラウザとほぼ同じように API アクセスを行うことで、本来 Web 版公式クライアントでしか利用できない Cookie 認証での Twitter API v1.1 へのアクセスを可能にしています。 81 | > 可能な限りブラウザの挙動を模倣することでできるだけ Twitter 側に怪しまれないような実装を行っていますが、非公式な方法ゆえ、**このライブラリを利用して Twitter API にアクセスすると、最悪アカウント凍結やシャドウバンなどの制限が適用される可能性もあります。** 82 | > また、**Twitter API の仕様変更により、このライブラリが突然動作しなくなることも考えられます。** 83 | > このライブラリを利用して API アクセスを行うことによって生じたいかなる損害についても、著者は一切の責任を負いません。利用にあたっては十分ご注意ください。 84 | 85 | > [!WARNING] 86 | > **スクリーンネームとパスワードを指定して認証する際は、できるだけログイン実績のある IP アドレスでの実行をおすすめします。** 87 | > このライブラリでの認証は、Web 版公式クライアントのログインと同じように行われるため、ログイン実績のない IP アドレスから認証すると、不審なログインとして扱われてしまう可能性があります。 88 | > また、実行毎に毎回認証を行うと、不審なログインとして扱われてしまう可能性が高くなります。 89 | > **初回の認証以降では、以前認証した際に保存した Cookie を使って認証することを強く推奨します。** 90 | 91 | ## Installation 92 | 93 | ```console 94 | pip install tweepy-authlib 95 | ``` 96 | 97 | ## Usage 98 | 99 | ### With JSON 100 | 101 | [example_json.py](example_json.py) 102 | 103 | ```python 104 | import dotenv 105 | import os 106 | import json 107 | import tweepy 108 | from pathlib import Path 109 | from pprint import pprint 110 | from requests.cookies import RequestsCookieJar 111 | from tweepy_authlib import CookieSessionUserHandler 112 | 113 | try: 114 | terminal_size = os.get_terminal_size().columns 115 | except OSError: 116 | terminal_size = 80 117 | 118 | # ユーザー名とパスワードを環境変数から取得 119 | dotenv.load_dotenv() 120 | screen_name = os.environ.get('TWITTER_SCREEN_NAME', 'your_screen_name') 121 | password = os.environ.get('TWITTER_PASSWORD', 'your_password') 122 | 123 | # 保存した Cookie を使って認証 124 | ## 毎回ログインすると不審なログインとして扱われる可能性が高くなるため、 125 | ## できるだけ以前認証した際に保存した Cookie を使って認証することを推奨 126 | if Path('cookie.json').exists(): 127 | 128 | # 保存した Cookie を読み込む 129 | with open('cookie.json', 'r') as f: 130 | cookies_dict = json.load(f) 131 | 132 | # RequestCookieJar オブジェクトに変換 133 | cookies = RequestsCookieJar() 134 | for key, value in cookies_dict.items(): 135 | cookies.set(key, value) 136 | 137 | # 読み込んだ RequestCookieJar オブジェクトを CookieSessionUserHandler に渡す 138 | auth_handler = CookieSessionUserHandler(cookies=cookies) 139 | 140 | # スクリーンネームとパスワードを指定して認証 141 | else: 142 | 143 | # スクリーンネームとパスワードを渡す 144 | ## スクリーンネームとパスワードを指定する場合は初期化時に認証のための API リクエストが多数行われるため、完了まで数秒かかる 145 | try: 146 | auth_handler = CookieSessionUserHandler(screen_name=screen_name, password=password) 147 | except tweepy.HTTPException as ex: 148 | # パスワードが間違っているなどの理由で認証に失敗した場合 149 | if len(ex.api_codes) > 0 and len(ex.api_messages) > 0: 150 | error_message = f'Code: {ex.api_codes[0]}, Message: {ex.api_messages[0]}' 151 | else: 152 | error_message = 'Unknown Error' 153 | raise Exception(f'Failed to authenticate with password ({error_message})') 154 | except tweepy.TweepyException as ex: 155 | # 認証フローの途中で予期せぬエラーが発生し、ログインに失敗した 156 | error_message = f'Message: {ex}' 157 | raise Exception(f'Unexpected error occurred while authenticate with password ({error_message})') 158 | 159 | # 現在のログインセッションの Cookie を取得 160 | cookies_dict = auth_handler.get_cookies_as_dict() 161 | 162 | # Cookie を JSON ファイルに保存 163 | with open('cookie.json', 'w') as f: 164 | json.dump(cookies_dict, f, ensure_ascii=False, indent=4) 165 | 166 | # Tweepy で Twitter API v1.1 にアクセス 167 | api = tweepy.API(auth_handler) 168 | 169 | print('=' * terminal_size) 170 | print('Logged in user:') 171 | print('-' * terminal_size) 172 | user = api.verify_credentials() 173 | assert user.screen_name == os.environ['TWITTER_SCREEN_NAME'] 174 | pprint(user._json) 175 | print('=' * terminal_size) 176 | 177 | print('Followers (3 users):') 178 | print('-' * terminal_size) 179 | followers = user.followers(count=3) 180 | for follower in followers: 181 | pprint(follower._json) 182 | print('-' * terminal_size) 183 | print('=' * terminal_size) 184 | 185 | print('Following (3 users):') 186 | print('-' * terminal_size) 187 | friends = user.friends(count=3) 188 | for friend in friends: 189 | pprint(friend._json) 190 | print('-' * terminal_size) 191 | print('=' * terminal_size) 192 | 193 | print('Home timeline (3 tweets):') 194 | print('-' * terminal_size) 195 | home_timeline = api.home_timeline(count=3) 196 | for status in home_timeline: 197 | pprint(status._json) 198 | print('-' * terminal_size) 199 | print('=' * terminal_size) 200 | 201 | tweet_id = home_timeline[0].id 202 | print('Like tweet:') 203 | print('-' * terminal_size) 204 | pprint(api.create_favorite(tweet_id)._json) 205 | print('=' * terminal_size) 206 | 207 | print('Unlike tweet:') 208 | print('-' * terminal_size) 209 | pprint(api.destroy_favorite(tweet_id)._json) 210 | print('=' * terminal_size) 211 | 212 | # 継続してログインしない場合は明示的にログアウト 213 | ## 単に Cookie を消去するだけだと Twitter にセッションが残り続けてしまう 214 | ## ログアウト後は、取得した Cookie は再利用できなくなる 215 | auth_handler.logout() 216 | os.unlink('cookie.json') 217 | ``` 218 | 219 | ### With Pickle 220 | 221 | [example_pickle.py](example_pickle.py) 222 | 223 | ```python 224 | import dotenv 225 | import os 226 | import pickle 227 | import tweepy 228 | from pathlib import Path 229 | from pprint import pprint 230 | from tweepy_authlib import CookieSessionUserHandler 231 | 232 | try: 233 | terminal_size = os.get_terminal_size().columns 234 | except OSError: 235 | terminal_size = 80 236 | 237 | # ユーザー名とパスワードを環境変数から取得 238 | dotenv.load_dotenv() 239 | screen_name = os.environ.get('TWITTER_SCREEN_NAME', 'your_screen_name') 240 | password = os.environ.get('TWITTER_PASSWORD', 'your_password') 241 | 242 | # 保存した Cookie を使って認証 243 | ## 毎回ログインすると不審なログインとして扱われる可能性が高くなるため、 244 | ## できるだけ以前認証した際に保存した Cookie を使って認証することを推奨 245 | if Path('cookie.pickle').exists(): 246 | 247 | # 保存した Cookie を読み込む 248 | with open('cookie.pickle', 'rb') as f: 249 | cookies = pickle.load(f) 250 | 251 | # 読み込んだ RequestCookieJar オブジェクトを CookieSessionUserHandler に渡す 252 | auth_handler = CookieSessionUserHandler(cookies=cookies) 253 | 254 | # スクリーンネームとパスワードを指定して認証 255 | else: 256 | 257 | # スクリーンネームとパスワードを渡す 258 | ## スクリーンネームとパスワードを指定する場合は初期化時に認証のための API リクエストが多数行われるため、完了まで数秒かかる 259 | try: 260 | auth_handler = CookieSessionUserHandler(screen_name=screen_name, password=password) 261 | except tweepy.HTTPException as ex: 262 | # パスワードが間違っているなどの理由で認証に失敗した場合 263 | if len(ex.api_codes) > 0 and len(ex.api_messages) > 0: 264 | error_message = f'Code: {ex.api_codes[0]}, Message: {ex.api_messages[0]}' 265 | else: 266 | error_message = 'Unknown Error' 267 | raise Exception(f'Failed to authenticate with password ({error_message})') 268 | except tweepy.TweepyException as ex: 269 | # 認証フローの途中で予期せぬエラーが発生し、ログインに失敗した 270 | error_message = f'Message: {ex}' 271 | raise Exception(f'Unexpected error occurred while authenticate with password ({error_message})') 272 | 273 | # 現在のログインセッションの Cookie を取得 274 | cookies = auth_handler.get_cookies() 275 | 276 | # Cookie を pickle 化して保存 277 | with open('cookie.pickle', 'wb') as f: 278 | pickle.dump(cookies, f) 279 | 280 | # Tweepy で Twitter API v1.1 にアクセス 281 | api = tweepy.API(auth_handler) 282 | 283 | print('=' * terminal_size) 284 | print('Logged in user:') 285 | print('-' * terminal_size) 286 | user = api.verify_credentials() 287 | assert user.screen_name == os.environ['TWITTER_SCREEN_NAME'] 288 | pprint(user._json) 289 | print('=' * terminal_size) 290 | 291 | print('Followers (3 users):') 292 | print('-' * terminal_size) 293 | followers = user.followers(count=3) 294 | for follower in followers: 295 | pprint(follower._json) 296 | print('-' * terminal_size) 297 | print('=' * terminal_size) 298 | 299 | print('Following (3 users):') 300 | print('-' * terminal_size) 301 | friends = user.friends(count=3) 302 | for friend in friends: 303 | pprint(friend._json) 304 | print('-' * terminal_size) 305 | print('=' * terminal_size) 306 | 307 | print('Home timeline (3 tweets):') 308 | print('-' * terminal_size) 309 | home_timeline = api.home_timeline(count=3) 310 | for status in home_timeline: 311 | pprint(status._json) 312 | print('-' * terminal_size) 313 | print('=' * terminal_size) 314 | 315 | tweet_id = home_timeline[0].id 316 | print('Like tweet:') 317 | print('-' * terminal_size) 318 | pprint(api.create_favorite(tweet_id)._json) 319 | print('=' * terminal_size) 320 | 321 | print('Unlike tweet:') 322 | print('-' * terminal_size) 323 | pprint(api.destroy_favorite(tweet_id)._json) 324 | print('=' * terminal_size) 325 | 326 | # 継続してログインしない場合は明示的にログアウト 327 | ## 単に Cookie を消去するだけだと Twitter にセッションが残り続けてしまう 328 | ## ログアウト後は、取得した Cookie は再利用できなくなる 329 | auth_handler.logout() 330 | os.unlink('cookie.pickle') 331 | ``` 332 | 333 | ## License 334 | 335 | [MIT License](License.txt) 336 | -------------------------------------------------------------------------------- /example_json.py: -------------------------------------------------------------------------------- 1 | import dotenv 2 | import os 3 | import json 4 | import tweepy 5 | from pathlib import Path 6 | from pprint import pprint 7 | from requests.cookies import RequestsCookieJar 8 | from tweepy_authlib import CookieSessionUserHandler 9 | 10 | 11 | try: 12 | terminal_size = os.get_terminal_size().columns 13 | except OSError: 14 | terminal_size = 80 15 | 16 | # ユーザー名とパスワードを環境変数から取得 17 | dotenv.load_dotenv() 18 | screen_name = os.environ.get('TWITTER_SCREEN_NAME', 'your_screen_name') 19 | password = os.environ.get('TWITTER_PASSWORD', 'your_password') 20 | 21 | # 保存した Cookie を使って認証 22 | ## 毎回ログインすると不審なログインとして扱われる可能性が高くなるため、 23 | ## できるだけ以前認証した際に保存した Cookie を使って認証することを推奨 24 | if Path('cookie.json').exists(): 25 | 26 | # 保存した Cookie を読み込む 27 | with open('cookie.json', 'r') as f: 28 | cookies_dict = json.load(f) 29 | 30 | # RequestCookieJar オブジェクトに変換 31 | cookies = RequestsCookieJar() 32 | for key, value in cookies_dict.items(): 33 | cookies.set(key, value) 34 | 35 | # 読み込んだ RequestCookieJar オブジェクトを CookieSessionUserHandler に渡す 36 | auth_handler = CookieSessionUserHandler(cookies=cookies) 37 | 38 | # スクリーンネームとパスワードを指定して認証 39 | else: 40 | 41 | # スクリーンネームとパスワードを渡す 42 | ## スクリーンネームとパスワードを指定する場合は初期化時に認証のための API リクエストが多数行われるため、完了まで数秒かかる 43 | try: 44 | auth_handler = CookieSessionUserHandler(screen_name=screen_name, password=password) 45 | except tweepy.HTTPException as ex: 46 | # パスワードが間違っているなどの理由で認証に失敗した場合 47 | if len(ex.api_codes) > 0 and len(ex.api_messages) > 0: 48 | error_message = f'Code: {ex.api_codes[0]}, Message: {ex.api_messages[0]}' 49 | else: 50 | error_message = 'Unknown Error' 51 | raise Exception(f'Failed to authenticate with password ({error_message})') 52 | except tweepy.TweepyException as ex: 53 | # 認証フローの途中で予期せぬエラーが発生し、ログインに失敗した 54 | error_message = f'Message: {ex}' 55 | raise Exception(f'Unexpected error occurred while authenticate with password ({error_message})') 56 | 57 | # 現在のログインセッションの Cookie を取得 58 | cookies_dict = auth_handler.get_cookies_as_dict() 59 | 60 | # Cookie を JSON ファイルに保存 61 | with open('cookie.json', 'w') as f: 62 | json.dump(cookies_dict, f, ensure_ascii=False, indent=4) 63 | 64 | # Tweepy で Twitter API v1.1 にアクセス 65 | api = tweepy.API(auth_handler) 66 | 67 | print('=' * terminal_size) 68 | print('Logged in user:') 69 | print('-' * terminal_size) 70 | user = api.verify_credentials() 71 | assert user.screen_name == os.environ['TWITTER_SCREEN_NAME'] 72 | pprint(user._json) 73 | print('=' * terminal_size) 74 | 75 | print('Followers (3 users):') 76 | print('-' * terminal_size) 77 | followers = user.followers(count=3) 78 | for follower in followers: 79 | pprint(follower._json) 80 | print('-' * terminal_size) 81 | print('=' * terminal_size) 82 | 83 | print('Following (3 users):') 84 | print('-' * terminal_size) 85 | friends = user.friends(count=3) 86 | for friend in friends: 87 | pprint(friend._json) 88 | print('-' * terminal_size) 89 | print('=' * terminal_size) 90 | 91 | print('Home timeline (3 tweets):') 92 | print('-' * terminal_size) 93 | home_timeline = api.home_timeline(count=3) 94 | for status in home_timeline: 95 | pprint(status._json) 96 | print('-' * terminal_size) 97 | print('=' * terminal_size) 98 | 99 | tweet_id = home_timeline[0].id 100 | print('Like tweet:') 101 | print('-' * terminal_size) 102 | pprint(api.create_favorite(tweet_id)._json) 103 | print('=' * terminal_size) 104 | 105 | print('Unlike tweet:') 106 | print('-' * terminal_size) 107 | pprint(api.destroy_favorite(tweet_id)._json) 108 | print('=' * terminal_size) 109 | 110 | # 継続してログインしない場合は明示的にログアウト 111 | ## 単に Cookie を消去するだけだと Twitter にセッションが残り続けてしまう 112 | ## ログアウト後は、取得した Cookie は再利用できなくなる 113 | #auth_handler.logout() 114 | #os.unlink('cookie.json') 115 | -------------------------------------------------------------------------------- /example_pickle.py: -------------------------------------------------------------------------------- 1 | import dotenv 2 | import os 3 | import pickle 4 | import tweepy 5 | from pathlib import Path 6 | from pprint import pprint 7 | from tweepy_authlib import CookieSessionUserHandler 8 | 9 | 10 | try: 11 | terminal_size = os.get_terminal_size().columns 12 | except OSError: 13 | terminal_size = 80 14 | 15 | # ユーザー名とパスワードを環境変数から取得 16 | dotenv.load_dotenv() 17 | screen_name = os.environ.get('TWITTER_SCREEN_NAME', 'your_screen_name') 18 | password = os.environ.get('TWITTER_PASSWORD', 'your_password') 19 | 20 | # 保存した Cookie を使って認証 21 | ## 毎回ログインすると不審なログインとして扱われる可能性が高くなるため、 22 | ## できるだけ以前認証した際に保存した Cookie を使って認証することを推奨 23 | if Path('cookie.pickle').exists(): 24 | 25 | # 保存した Cookie を読み込む 26 | with open('cookie.pickle', 'rb') as f: 27 | cookies = pickle.load(f) 28 | 29 | # 読み込んだ RequestCookieJar オブジェクトを CookieSessionUserHandler に渡す 30 | auth_handler = CookieSessionUserHandler(cookies=cookies) 31 | 32 | # スクリーンネームとパスワードを指定して認証 33 | else: 34 | 35 | # スクリーンネームとパスワードを渡す 36 | ## スクリーンネームとパスワードを指定する場合は初期化時に認証のための API リクエストが多数行われるため、完了まで数秒かかる 37 | try: 38 | auth_handler = CookieSessionUserHandler(screen_name=screen_name, password=password) 39 | except tweepy.HTTPException as ex: 40 | # パスワードが間違っているなどの理由で認証に失敗した場合 41 | if len(ex.api_codes) > 0 and len(ex.api_messages) > 0: 42 | error_message = f'Code: {ex.api_codes[0]}, Message: {ex.api_messages[0]}' 43 | else: 44 | error_message = 'Unknown Error' 45 | raise Exception(f'Failed to authenticate with password ({error_message})') 46 | except tweepy.TweepyException as ex: 47 | # 認証フローの途中で予期せぬエラーが発生し、ログインに失敗した 48 | error_message = f'Message: {ex}' 49 | raise Exception(f'Unexpected error occurred while authenticate with password ({error_message})') 50 | 51 | # 現在のログインセッションの Cookie を取得 52 | cookies = auth_handler.get_cookies() 53 | 54 | # Cookie を pickle 化して保存 55 | with open('cookie.pickle', 'wb') as f: 56 | pickle.dump(cookies, f) 57 | 58 | # Tweepy で Twitter API v1.1 にアクセス 59 | api = tweepy.API(auth_handler) 60 | 61 | print('=' * terminal_size) 62 | print('Logged in user:') 63 | print('-' * terminal_size) 64 | user = api.verify_credentials() 65 | assert user.screen_name == os.environ['TWITTER_SCREEN_NAME'] 66 | pprint(user._json) 67 | print('=' * terminal_size) 68 | 69 | print('Followers (3 users):') 70 | print('-' * terminal_size) 71 | followers = user.followers(count=3) 72 | for follower in followers: 73 | pprint(follower._json) 74 | print('-' * terminal_size) 75 | print('=' * terminal_size) 76 | 77 | print('Following (3 users):') 78 | print('-' * terminal_size) 79 | friends = user.friends(count=3) 80 | for friend in friends: 81 | pprint(friend._json) 82 | print('-' * terminal_size) 83 | print('=' * terminal_size) 84 | 85 | print('Home timeline (3 tweets):') 86 | print('-' * terminal_size) 87 | home_timeline = api.home_timeline(count=3) 88 | for status in home_timeline: 89 | pprint(status._json) 90 | print('-' * terminal_size) 91 | print('=' * terminal_size) 92 | 93 | tweet_id = home_timeline[0].id 94 | print('Like tweet:') 95 | print('-' * terminal_size) 96 | pprint(api.create_favorite(tweet_id)._json) 97 | print('=' * terminal_size) 98 | 99 | print('Unlike tweet:') 100 | print('-' * terminal_size) 101 | pprint(api.destroy_favorite(tweet_id)._json) 102 | print('=' * terminal_size) 103 | 104 | # 継続してログインしない場合は明示的にログアウト 105 | ## 単に Cookie を消去するだけだと Twitter にセッションが残り続けてしまう 106 | ## ログアウト後は、取得した Cookie は再利用できなくなる 107 | #auth_handler.logout() 108 | #os.unlink('cookie.pickle') 109 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "tweepy-authlib" 7 | description = 'Twitter Web App (Web 版公式クライアント) の内部 API を使い、Tweepy でスクリーンネームとパスワードで認証するためのライブラリ' 8 | url = "https://github.com/tsukumijima/tweepy-authlib" 9 | readme = "Readme.md" 10 | requires-python = ">=3.8" 11 | license-file = "License.txt" 12 | keywords = ["Twitter", "Tweepy", "Library"] 13 | authors = [ 14 | { name = "tsukumi" }, 15 | ] 16 | classifiers = [ 17 | "Development Status :: 4 - Beta", 18 | "Intended Audience :: Developers", 19 | "Intended Audience :: Information Technology", 20 | "License :: OSI Approved :: MIT License", 21 | "Natural Language :: Japanese", 22 | "Programming Language :: Python", 23 | "Programming Language :: Python :: 3.8", 24 | "Programming Language :: Python :: 3.9", 25 | "Programming Language :: Python :: 3.10", 26 | "Programming Language :: Python :: 3.11", 27 | "Programming Language :: Python :: Implementation :: CPython", 28 | ] 29 | dependencies = [ 30 | "Brotli>=1.0.9", 31 | "js2py>=0.74", 32 | "tweepy>=4.12.1", 33 | ] 34 | dynamic = ["version"] 35 | 36 | [project.urls] 37 | Documentation = "https://github.com/tsukumijima/tweepy-authlib" 38 | Issues = "https://github.com/tsukumijima/tweepy-authlib/issues" 39 | Source = "https://github.com/tsukumijima/tweepy-authlib" 40 | 41 | [tool.hatch.version] 42 | path = "tweepy_authlib/__about__.py" 43 | 44 | [tool.hatch.envs.default] 45 | dependencies = [ 46 | "pytest", 47 | "pytest-cov", 48 | "pytest-dotenv", 49 | ] 50 | [tool.hatch.envs.default.scripts] 51 | cov = "pytest --cov-report=term-missing --cov-config=pyproject.toml --cov=tweepy_authlib --cov=tests {args}" 52 | no-cov = "cov --no-cov {args}" 53 | test = "python tests/test_main.py" 54 | 55 | [[tool.hatch.envs.test.matrix]] 56 | python = ["38", "39", "310", "311"] 57 | 58 | [tool.coverage.run] 59 | branch = true 60 | parallel = true 61 | omit = [ 62 | "tweepy_authlib/__about__.py", 63 | ] 64 | 65 | [tool.coverage.report] 66 | exclude_lines = [ 67 | "no cov", 68 | "if __name__ == .__main__.:", 69 | "if TYPE_CHECKING:", 70 | ] 71 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsukumijima/tweepy-authlib/ab665edfc0062ebe7bec1abb815e7eae0552e498/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | 2 | import dotenv 3 | import os 4 | import pickle 5 | import pytest 6 | import tweepy 7 | from pprint import pprint 8 | from tweepy_authlib import CookieSessionUserHandler 9 | 10 | 11 | try: 12 | terminal_size = os.get_terminal_size().columns 13 | except OSError: 14 | terminal_size = 80 15 | 16 | def test_01(): 17 | with pytest.raises(ValueError): 18 | CookieSessionUserHandler() 19 | 20 | def test_02(): 21 | with pytest.raises(ValueError): 22 | CookieSessionUserHandler(screen_name='', password='password') 23 | 24 | def test_03(): 25 | with pytest.raises(ValueError): 26 | CookieSessionUserHandler(screen_name='elonmusk', password='') 27 | 28 | def test_04(): 29 | with pytest.raises(tweepy.BadRequest, match=r'.*399 - アカウントが見つかりません。.*'): 30 | CookieSessionUserHandler(screen_name='not__found__user', password='password') 31 | 32 | def test_05(): 33 | with pytest.raises(tweepy.BadRequest, match=r'.*399 - パスワードが正しくありません。.*'): 34 | CookieSessionUserHandler(screen_name='elonmusk', password='password') 35 | 36 | def test_06(): 37 | 38 | # 環境変数に TWITTER_SCREEN_NAME と TWITTER_PASSWORD が設定されている場合のみ実行 39 | if 'TWITTER_SCREEN_NAME' in os.environ and 'TWITTER_PASSWORD' in os.environ: 40 | print('=' * terminal_size) 41 | print(f'Logging in as @{os.environ["TWITTER_SCREEN_NAME"]}.') 42 | auth_handler = CookieSessionUserHandler(screen_name=os.environ['TWITTER_SCREEN_NAME'], password=os.environ['TWITTER_PASSWORD']) 43 | print('-' * terminal_size) 44 | print(f'Logged in as @{os.environ["TWITTER_SCREEN_NAME"]}.') 45 | api = tweepy.API(auth_handler) 46 | 47 | print('=' * terminal_size) 48 | print('Logged in user:') 49 | print('-' * terminal_size) 50 | user = api.verify_credentials() 51 | assert user.screen_name == os.environ['TWITTER_SCREEN_NAME'] 52 | pprint(user._json) 53 | print('=' * terminal_size) 54 | 55 | with open('cookie.pickle', 'wb') as f: 56 | pickle.dump(auth_handler.get_cookies(), f) 57 | else: 58 | pytest.skip('TWITTER_SCREEN_NAME or TWITTER_PASSWORD is not set.') 59 | 60 | def test_07(tweet: bool = False): 61 | 62 | # 環境変数に TWITTER_SCREEN_NAME と TWITTER_PASSWORD が設定されている場合のみ実行 63 | if 'TWITTER_SCREEN_NAME' in os.environ and 'TWITTER_PASSWORD' in os.environ: 64 | print('=' * terminal_size) 65 | print(f'Logging in as @{os.environ["TWITTER_SCREEN_NAME"]}.') 66 | print('-' * terminal_size) 67 | with open('cookie.pickle', 'rb') as f: 68 | jar = pickle.load(f) 69 | print('Cookie:') 70 | pprint(jar.get_dict()) 71 | auth_handler = CookieSessionUserHandler(cookies=jar) 72 | print('-' * terminal_size) 73 | print(f'Logged in as @{os.environ["TWITTER_SCREEN_NAME"]}.') 74 | os.unlink('cookie.pickle') 75 | api = tweepy.API(auth_handler) 76 | 77 | print('=' * terminal_size) 78 | print('Logged in user:') 79 | print('-' * terminal_size) 80 | user = api.verify_credentials() 81 | assert user.screen_name == os.environ['TWITTER_SCREEN_NAME'] 82 | pprint(user._json) 83 | print('=' * terminal_size) 84 | 85 | print('Followers (3 users):') 86 | print('-' * terminal_size) 87 | followers = user.followers(count=3) 88 | for follower in followers: 89 | pprint(follower._json) 90 | print('-' * terminal_size) 91 | print('=' * terminal_size) 92 | 93 | print('Following (3 users):') 94 | print('-' * terminal_size) 95 | friends = user.friends(count=3) 96 | for friend in friends: 97 | pprint(friend._json) 98 | print('-' * terminal_size) 99 | print('=' * terminal_size) 100 | 101 | print('Home timeline (3 tweets):') 102 | print('-' * terminal_size) 103 | home_timeline = api.home_timeline(count=3) 104 | for status in home_timeline: 105 | pprint(status._json) 106 | print('-' * terminal_size) 107 | print('=' * terminal_size) 108 | 109 | tweet_id = home_timeline[0].id 110 | print('Like tweet:') 111 | print('-' * terminal_size) 112 | pprint(api.create_favorite(tweet_id)._json) 113 | print('=' * terminal_size) 114 | 115 | print('Unlike tweet:') 116 | print('-' * terminal_size) 117 | pprint(api.destroy_favorite(tweet_id)._json) 118 | print('=' * terminal_size) 119 | 120 | auth_handler.logout() 121 | else: 122 | pytest.skip('TWITTER_SCREEN_NAME or TWITTER_PASSWORD is not set.') 123 | 124 | 125 | if __name__ == '__main__': 126 | dotenv.load_dotenv() 127 | test_06() 128 | test_07(tweet = True) 129 | -------------------------------------------------------------------------------- /tweepy_authlib/CookieSessionUserHandler.py: -------------------------------------------------------------------------------- 1 | 2 | import binascii 3 | import js2py 4 | import json 5 | import random 6 | import re 7 | import requests 8 | import time 9 | import tweepy 10 | from js2py.base import JsObjectWrapper 11 | from requests.auth import AuthBase 12 | from requests.cookies import RequestsCookieJar 13 | from requests.models import PreparedRequest 14 | from typing import Any, cast, Dict, Optional, TypeVar 15 | 16 | 17 | Self = TypeVar("Self", bound="CookieSessionUserHandler") 18 | 19 | class CookieSessionUserHandler(AuthBase): 20 | """ 21 | Twitter Web App の内部 API を使い、Cookie ログインで Twitter API を利用するための認証ハンドラー 22 | 23 | 認証フローは2023年2月現在の Twitter Web App (Chrome Desktop) の挙動に極力合わせたもの 24 | requests.auth.AuthBase を継承しているので、tweepy.API の auth パラメーターに渡すことができる 25 | 26 | ref: https://github.com/mikf/gallery-dl/blob/master/gallery_dl/extractor/twitter.py 27 | ref: https://github.com/fa0311/TwitterFrontendFlow/blob/master/TwitterFrontendFlow/TwitterFrontendFlow.py 28 | """ 29 | 30 | # User-Agent と Sec-CH-UA を Chrome 132 に偽装 31 | USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36' 32 | SEC_CH_UA = '"Not)A;Brand";v="99", "Google Chrome";v="132", "Chromium";v="132"' 33 | 34 | # Twitter Web App (GraphQL API) の Bearer トークン 35 | TWITTER_WEB_APP_BEARER_TOKEN = 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA' 36 | 37 | # 旧 TweetDeck (Twitter API v1.1) の Bearer トークン 38 | TWEETDECK_BEARER_TOKEN = 'Bearer AAAAAAAAAAAAAAAAAAAAAFQODgEAAAAAVHTp76lzh3rFzcHbmHVvQxYYpTw%3DckAlMINMjmCwxUcaXbAN4XqJVdgMJaHqNOFgPMK0zN1qLqLQCF' 39 | 40 | 41 | def __init__(self, cookies: Optional[RequestsCookieJar] = None, screen_name: Optional[str] = None, password: Optional[str] = None) -> None: 42 | """ 43 | CookieSessionUserHandler を初期化する 44 | cookies と screen_name, password のどちらかを指定する必要がある 45 | 46 | Args: 47 | cookies (Optional[RequestsCookieJar], optional): リクエスト時に利用する Cookie. Defaults to None. 48 | screen_name (Optional[str], optional): Twitter のスクリーンネーム (@は含まない). Defaults to None. 49 | password (Optional[str], optional): Twitter のパスワード. Defaults to None. 50 | 51 | Raises: 52 | ValueError: Cookie が指定されていないのに、スクリーンネームまたはパスワードが (もしくはどちらも) 指定されていない 53 | ValueError: スクリーンネームが空文字列 54 | ValueError: パスワードが空文字列 55 | tweepy.BadRequest: スクリーンネームまたはパスワードが間違っている 56 | tweepy.HTTPException: サーバーエラーなどの問題でログインに失敗した 57 | tweepy.TweepyException: 認証フローの途中でエラーが発生し、ログインに失敗した 58 | """ 59 | 60 | self.screen_name = screen_name 61 | self.password = password 62 | 63 | # Cookie が指定されていないのに、スクリーンネームまたはパスワードが (もしくはどちらも) 指定されていない 64 | if cookies is None and (self.screen_name is None or self.password is None): 65 | raise ValueError('Either cookie or screen_name and password must be specified.') 66 | 67 | # スクリーンネームが空文字列 68 | if self.screen_name == '': 69 | raise ValueError('screen_name must not be empty string.') 70 | 71 | # パスワードが空文字列 72 | if self.password == '': 73 | raise ValueError('password must not be empty string.') 74 | 75 | # HTML 取得時の HTTP リクエストヘッダー 76 | self._html_headers = { 77 | 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', 78 | 'accept-encoding': 'gzip, deflate, br', 79 | 'accept-language': 'ja', 80 | 'sec-ch-ua': self.SEC_CH_UA, 81 | 'sec-ch-ua-mobile': '?0', 82 | 'sec-ch-ua-platform': '"Windows"', 83 | 'sec-fetch-dest': 'document', 84 | 'sec-fetch-mode': 'navigate', 85 | 'sec-fetch-site': 'same-origin', 86 | 'sec-fetch-user': '?1', 87 | 'upgrade-insecure-requests': '1', 88 | 'user-agent': self.USER_AGENT, 89 | } 90 | 91 | # JavaScript 取得時の HTTP リクエストヘッダー 92 | self._js_headers = self._html_headers.copy() 93 | self._js_headers['accept'] = '*/*' 94 | self._js_headers['referer'] = 'https://x.com/' 95 | self._js_headers['sec-fetch-dest'] = 'script' 96 | self._js_headers['sec-fetch-mode'] = 'no-cors' 97 | self._js_headers['sec-fetch-site'] = 'cross-site' 98 | del self._js_headers['sec-fetch-user'] 99 | 100 | # 認証フロー API アクセス時の HTTP リクエストヘッダー 101 | self._auth_flow_api_headers = { 102 | 'accept': '*/*', 103 | 'accept-encoding': 'gzip, deflate, br', 104 | 'accept-language': 'ja', 105 | 'authorization': self.TWITTER_WEB_APP_BEARER_TOKEN, 106 | 'content-type': 'application/json', 107 | 'origin': 'https://x.com', 108 | 'referer': 'https://x.com/', 109 | 'sec-ch-ua': self.SEC_CH_UA, 110 | 'sec-ch-ua-mobile': '?0', 111 | 'sec-ch-ua-platform': '"Windows"', 112 | 'sec-fetch-dest': 'empty', 113 | 'sec-fetch-mode': 'cors', 114 | 'sec-fetch-site': 'same-site', 115 | 'user-agent': self.USER_AGENT, 116 | 'x-csrf-token': None, # ここは後でセットする 117 | 'x-guest-token': None, # ここは後でセットする 118 | 'x-twitter-active-user': 'yes', 119 | 'x-twitter-client-language': 'ja', 120 | } 121 | 122 | # GraphQL API (Twitter Web App API) アクセス時の HTTP リクエストヘッダー 123 | ## GraphQL API は https://x.com/i/api/graphql/ 配下にあり同一ドメインのため、origin と referer は意図的に省略している 124 | self._graphql_api_headers = { 125 | 'accept': '*/*', 126 | 'accept-encoding': 'gzip, deflate, br', 127 | 'accept-language': 'ja', 128 | 'authorization': self.TWITTER_WEB_APP_BEARER_TOKEN, 129 | 'content-type': 'application/json', 130 | 'sec-ch-ua': self.SEC_CH_UA, 131 | 'sec-ch-ua-mobile': '?0', 132 | 'sec-ch-ua-platform': '"Windows"', 133 | 'sec-fetch-dest': 'empty', 134 | 'sec-fetch-mode': 'cors', 135 | 'sec-fetch-site': 'same-site', 136 | 'user-agent': self.USER_AGENT, 137 | 'x-csrf-token': None, # ここは後でセットする 138 | 'x-twitter-active-user': 'yes', 139 | 'x-twitter-auth-type': 'OAuth2Session', 140 | 'x-twitter-client-language': 'ja', 141 | } 142 | 143 | # Cookie ログイン用のセッションを作成 144 | ## 実際の Twitter API へのリクエストには tweepy.API 側で作成されたセッションが利用される 145 | ## その際、__call__() で tweepy.API で作成されたセッションのリクエストヘッダーと Cookie を上書きしている 146 | self._session = requests.Session() 147 | 148 | # API からレスポンスが返ってきた際に自動で CSRF トークンを更新する 149 | ## 認証に成功したタイミングで、Cookie の "ct0" 値 (CSRF トークン) がクライアント側で生成したものから、サーバー側で生成したものに更新される 150 | self._session.hooks['response'].append(self._on_response_received) 151 | 152 | # Cookie が指定されている場合は、それをセッションにセット (再ログインを省略する) 153 | if cookies is not None: 154 | self._session.cookies = cookies 155 | 156 | # Cookie が指定されていない場合は、ログインを試みる 157 | else: 158 | self._login() 159 | 160 | # Cookie から auth_token または ct0 が取得できなかった場合 161 | ## auth_token と ct0 はいずれも認証に最低限必要な Cookie のため、取得できなかった場合は認証に失敗したものとみなす 162 | if self._session.cookies.get('auth_token', default=None) is None or self._session.cookies.get('ct0', default=None) is None: 163 | raise tweepy.TweepyException('Failed to get auth_token or ct0 from Cookie') 164 | 165 | # Cookie の "gt" 値 (ゲストトークン) を認証フロー API 用ヘッダーにセット 166 | guest_token = self._session.cookies.get('gt') 167 | if guest_token: 168 | self._auth_flow_api_headers['x-guest-token'] = guest_token 169 | 170 | # Cookie の "ct0" 値 (CSRF トークン) を GraphQL API 用ヘッダーにセット 171 | csrf_token = self._session.cookies.get('ct0') 172 | if csrf_token: 173 | self._auth_flow_api_headers['x-csrf-token'] = csrf_token 174 | self._graphql_api_headers['x-csrf-token'] = csrf_token 175 | 176 | # セッションのヘッダーを GraphQL API 用のものに差し替える 177 | ## 以前は旧 TweetDeck API 用ヘッダーに差し替えていたが、旧 TweetDeck が完全廃止されたことで 178 | ## 逆に怪しまれる可能性があるため GraphQL API 用ヘッダーに変更した 179 | ## cross_origin=True を指定して、x.com から api.x.com にクロスオリジンリクエストを送信した際のヘッダーを模倣する 180 | self._session.headers.clear() 181 | self._session.headers.update(self.get_graphql_api_headers(cross_origin=True)) # type: ignore 182 | 183 | 184 | def __call__(self, request: PreparedRequest) -> PreparedRequest: 185 | """ 186 | requests ライブラリからリクエスト開始時に呼び出されるフック 187 | 188 | Args: 189 | request (PreparedRequest): PreparedRequest オブジェクト 190 | 191 | Returns: 192 | PreparedRequest: 認証情報を追加した PreparedRequest オブジェクト 193 | """ 194 | 195 | # リクエストヘッダーを認証用セッションのものに差し替える 196 | # content-type を上書きしないよう、content-type を控えておいてから差し替える 197 | content_type = request.headers.get('content-type', None) 198 | request.headers.update(self._session.headers) # type: ignore 199 | if content_type is not None: 200 | request.headers['content-type'] = content_type # 元の content-type に戻す 201 | 202 | # リクエストがまだ *.twitter.com に対して行われている場合は、*.x.com に差し替える 203 | ## サードパーティー向け API は互換性のため引き続き api.twitter.com でアクセスできるはずだが、 204 | ## tweepy-authlib でアクセスしている API は内部 API のため、api.twitter.com のままアクセスしていると怪しまれる可能性がある 205 | assert request.url is not None 206 | request.url = request.url.replace('twitter.com/', 'x.com/') 207 | 208 | # Twitter API v1.1 の一部 API には旧 TweetDeck 用の Bearer トークンでないとアクセスできないため、 209 | # 該当の API のみ旧 TweetDeck 用の Bearer トークンに差し替える 210 | # それ以外の API ではそのまま Twitter Web App の Bearer トークンを使い続けることで、不審判定される可能性を下げる 211 | ## OldTweetDeck の interception.js に記載の API のうち、明示的に PUBLIC_TOKENS[1] が設定されている API が対象 212 | ## ref: https://github.com/dimdenGD/OldTweetDeck/blob/main/src/interception.js 213 | TWEETDECK_BEARER_TOKEN_REQUIRED_APIS = [ 214 | '/1.1/statuses/home_timeline.json', 215 | '/1.1/lists/statuses.json', 216 | '/1.1/activity/about_me.json', 217 | '/1.1/statuses/mentions_timeline.json', 218 | '/1.1/favorites/', 219 | '/1.1/collections/', 220 | '/1.1/users/show.json', 221 | '/1.1/account/verify_credentials.json', 222 | ] 223 | if any(api_url in request.url for api_url in TWEETDECK_BEARER_TOKEN_REQUIRED_APIS): 224 | request.headers['authorization'] = self.TWEETDECK_BEARER_TOKEN 225 | 226 | # upload.twitter.com or upload.x.com 以下の API のみ、Twitter Web App の挙動に合わせいくつかのヘッダーを追加削除する 227 | if 'upload.twitter.com' in request.url or 'upload.x.com' in request.url: 228 | # x.com から見て upload.x.com の API リクエストはクロスオリジンになるため、Origin と Referer を追加する 229 | request.headers['origin'] = 'https://x.com' 230 | request.headers['referer'] = 'https://x.com/' 231 | # 以下のヘッダーは upload.x.com への API リクエストではなぜか付与されていない 232 | request.headers.pop('x-client-transaction-id', None) # 未実装だが将来的に実装した時のため 233 | request.headers.pop('x-twitter-active-user', None) 234 | request.headers.pop('x-twitter-client-language', None) 235 | 236 | # Cookie を認証用セッションのものに差し替える 237 | request._cookies.update(self._session.cookies) # type: ignore 238 | cookie_header = '' 239 | for key, value in self._session.cookies.get_dict().items(): 240 | cookie_header += f'{key}={value}; ' 241 | request.headers['cookie'] = cookie_header.rstrip('; ') 242 | 243 | # API からレスポンスが返ってきた際に自動で CSRF トークンを更新する 244 | ## やらなくても大丈夫かもしれないけど、念のため 245 | request.hooks['response'].append(self._on_response_received) 246 | 247 | # 認証情報を追加した PreparedRequest オブジェクトを返す 248 | return request 249 | 250 | 251 | def apply_auth(self: Self) -> Self: 252 | """ 253 | tweepy.API の初期化時に認証ハンドラーを適用するためのメソッド 254 | 自身のインスタンスを認証ハンドラーとして返す 255 | 256 | Args: 257 | self (Self): 自身のインスタンス 258 | 259 | Returns: 260 | Self: 自身のインスタンス 261 | """ 262 | 263 | return self 264 | 265 | 266 | def get_cookies(self) -> RequestsCookieJar: 267 | """ 268 | 現在のログインセッションの Cookie を取得する 269 | 返される RequestsCookieJar を pickle などで保存しておくことで、再ログインせずにセッションを継続できる 270 | 271 | Returns: 272 | RequestsCookieJar: Cookie 273 | """ 274 | 275 | return self._session.cookies 276 | 277 | 278 | def get_cookies_as_dict(self) -> Dict[str, str]: 279 | """ 280 | 現在のログインセッションの Cookie を dict として取得する 281 | 返される dict を保存しておくことで、再ログインせずにセッションを継続できる 282 | 283 | Returns: 284 | Dict[str, str]: Cookie 285 | """ 286 | 287 | return self._session.cookies.get_dict() 288 | 289 | 290 | def get_html_headers(self) -> Dict[str, str]: 291 | """ 292 | Twitter Web App の HTML アクセス用の HTTP リクエストヘッダーを取得する 293 | Cookie やトークン類の取得のために HTML ページに HTTP リクエストを送る際の利用を想定している 294 | 295 | Returns: 296 | Dict[str, str]: HTML アクセス用の HTTP リクエストヘッダー 297 | """ 298 | 299 | return self._html_headers.copy() 300 | 301 | 302 | def get_js_headers(self, cross_origin: bool = False) -> Dict[str, str]: 303 | """ 304 | Twitter Web App の JavaScript アクセス用の HTTP リクエストヘッダーを取得する 305 | Challenge 用コードの取得のために JavaScript ファイルに HTTP リクエストを送る際の利用を想定している 306 | cross_origin=True を指定すると、例えば https://abs.twimg.com/ 以下にある JavaScript ファイルを取得する際のヘッダーを取得できる 307 | 308 | Args: 309 | cross_origin (bool, optional): x.com 以外のオリジンに送信する HTTP リクエストヘッダーかどうか. Defaults to False. 310 | 311 | Returns: 312 | Dict[str, str]: JavaScript アクセス用の HTTP リクエストヘッダー 313 | """ 314 | 315 | headers = self._js_headers.copy() 316 | if cross_origin is True: 317 | headers['sec-fetch-mode'] = 'cors' 318 | return headers 319 | 320 | 321 | def get_graphql_api_headers(self, cross_origin: bool = False) -> Dict[str, Optional[str]]: 322 | """ 323 | GraphQL API (Twitter Web App API) アクセス用の HTTP リクエストヘッダーを取得する 324 | このリクエストヘッダーを使い独自に API リクエストを行う際は、 325 | 必ず x-csrf-token ヘッダーの値を常に Cookie 内の "ct0" と一致させるように実装しなければならない 326 | Twitter API v1.1 に使う場合は cross_origin=True を指定すること (api.x.com が x.com から見て cross-origin になるため) 327 | 逆に GraphQL API に使う場合は cross_origin=False でなければならない (GraphQL API は x.com から見て same-origin になるため) 328 | 329 | Args: 330 | cross_origin (bool, optional): 返すヘッダーを x.com 以外のオリジンに送信するかどうか. Defaults to False. 331 | 332 | Returns: 333 | Dict[str, str]: GraphQL API (Twitter Web App API) アクセス用の HTTP リクエストヘッダー 334 | """ 335 | 336 | headers = self._graphql_api_headers.copy() 337 | 338 | # クロスオリジン用に origin と referer を追加 339 | # Twitter Web App から api.x.com にクロスオリジンリクエストを送信する際のヘッダーを模倣する 340 | if cross_origin is True: 341 | headers['origin'] = 'https://x.com' 342 | headers['referer'] = 'https://x.com/' 343 | 344 | return headers 345 | 346 | 347 | def logout(self) -> None: 348 | """ 349 | ログアウト処理を行い、Twitter からセッションを切断する 350 | 単に Cookie を削除するだけだと Twitter にセッションが残り続けてしまうため、今後ログインしない場合は明示的にこのメソッドを呼び出すこと 351 | このメソッドを呼び出した後は、取得した Cookie では再認証できなくなる 352 | 353 | Raises: 354 | tweepy.HTTPException: サーバーエラーなどの問題でログアウトに失敗した 355 | tweepy.TweepyException: ログアウト処理中にエラーが発生した 356 | """ 357 | 358 | # ログアウト API 専用ヘッダー 359 | ## self._graphql_api_headers と基本共通で、content-type だけ application/x-www-form-urlencoded に変更 360 | logout_headers = self._graphql_api_headers.copy() 361 | logout_headers['content-type'] = 'application/x-www-form-urlencoded' 362 | 363 | # ログアウト API にログアウトすることを伝える 364 | ## この API を実行すると、サーバー側でセッションが切断され、今まで持っていたほとんどの Cookie が消去される 365 | logout_api_response = self._session.post('https://api.x.com/1.1/account/logout.json', headers=logout_headers, data={ 366 | 'redirectAfterLogout': 'https://x.com/account/switch', 367 | }) 368 | if logout_api_response.status_code != 200: 369 | raise self._get_tweepy_exception(logout_api_response) 370 | 371 | # 基本固定値のようなので不要だが、念のためステータスチェック 372 | try: 373 | status = logout_api_response.json()['status'] 374 | except Exception: 375 | raise tweepy.TweepyException('Failed to logout (failed to parse response)') 376 | if status != 'ok': 377 | raise tweepy.TweepyException(f'Failed to logout (status: {status})') 378 | 379 | 380 | def _on_response_received(self, response: requests.Response, *args: Any, **kwargs: Any) -> None: 381 | """ 382 | レスポンスが返ってきた際に自動的に CSRF トークンを更新するコールバック 383 | 384 | Args: 385 | response (requests.Response): レスポンス 386 | """ 387 | 388 | csrf_token = response.cookies.get('ct0') 389 | if csrf_token: 390 | if self._session.cookies.get('ct0') != csrf_token: 391 | self._session.cookies.set('ct0', csrf_token, domain='.x.com') 392 | self._auth_flow_api_headers['x-csrf-token'] = csrf_token 393 | self._graphql_api_headers['x-csrf-token'] = csrf_token 394 | self._session.headers['x-csrf-token'] = csrf_token 395 | 396 | 397 | def _get_tweepy_exception(self, response: requests.Response) -> tweepy.TweepyException: 398 | """ 399 | TweepyException を継承した、ステータスコードと一致する例外クラスを取得する 400 | 401 | Args: 402 | status_code (int): ステータスコード 403 | 404 | Returns: 405 | tweepy.TweepyException: 例外 406 | """ 407 | 408 | if response.status_code == 400: 409 | return tweepy.BadRequest(response) 410 | elif response.status_code == 401: 411 | return tweepy.Unauthorized(response) 412 | elif response.status_code == 403: 413 | return tweepy.Forbidden(response) 414 | elif response.status_code == 404: 415 | return tweepy.NotFound(response) 416 | elif response.status_code == 429: 417 | return tweepy.TooManyRequests(response) 418 | elif 500 <= response.status_code <= 599: 419 | return tweepy.TwitterServerError(response) 420 | else: 421 | return tweepy.TweepyException(response) 422 | 423 | 424 | def _generate_csrf_token(self, size: int = 16) -> str: 425 | """ 426 | Twitter の CSRF トークン (Cookie 内の "ct0" 値) を生成する 427 | 428 | Args: 429 | size (int, optional): トークンサイズ. Defaults to 16. 430 | 431 | Returns: 432 | str: 生成されたトークン 433 | """ 434 | 435 | data = random.getrandbits(size * 8).to_bytes(size, "big") 436 | return binascii.hexlify(data).decode() 437 | 438 | 439 | def _get_guest_token(self) -> str: 440 | """ 441 | ゲストトークン (Cookie 内の "gt" 値) を取得する 442 | 443 | Returns: 444 | str: 取得されたトークン 445 | """ 446 | 447 | # HTTP ヘッダーは基本的に認証用セッションのものを使う 448 | headers = self._auth_flow_api_headers.copy() 449 | headers.pop('x-csrf-token') 450 | headers.pop('x-guest-token') 451 | 452 | # API からゲストトークンを取得する 453 | # ref: https://github.com/fa0311/TwitterFrontendFlow/blob/master/TwitterFrontendFlow/TwitterFrontendFlow.py#L26-L36 454 | guest_token_response = self._session.post('https://api.x.com/1.1/guest/activate.json', headers=headers) 455 | if guest_token_response.status_code != 200: 456 | raise self._get_tweepy_exception(guest_token_response) 457 | try: 458 | guest_token = guest_token_response.json()['guest_token'] 459 | except Exception: 460 | raise tweepy.TweepyException('Failed to get guest token') 461 | 462 | return guest_token 463 | 464 | 465 | def _get_ui_metrics(self, js_inst: str) -> Dict[str, Any]: 466 | """ 467 | https://x.com/i/js_inst?c_name=ui_metrics から出力される難読化された JavaScript から ui_metrics を取得する 468 | ref: https://github.com/hfthair/TweetScraper/blob/master/TweetScraper/spiders/following.py#L50-L94 469 | 470 | Args: 471 | js_inst (str): 難読化された JavaScript 472 | 473 | Returns: 474 | dict[str, Any]: 取得された ui_metrics 475 | """ 476 | 477 | # 難読化された JavaScript の中から ui_metrics を取得する関数を抽出 478 | js_inst_function = js_inst.split('\n')[2] 479 | js_inst_function_name = re.search(re.compile(r'function [a-zA-Z]+'), js_inst_function).group().replace('function ', '') # type: ignore 480 | 481 | # 難読化された JavaScript を実行するために簡易的に DOM API をモックする 482 | ## とりあえず最低限必要そうなものだけ 483 | js_dom_mock = """ 484 | var _element = { 485 | appendChild: function(x) { 486 | // do nothing 487 | }, 488 | removeChild: function(x) { 489 | // do nothing 490 | }, 491 | setAttribute: function(x, y) { 492 | // do nothing 493 | }, 494 | innerText: '', 495 | innerHTML: '', 496 | outerHTML: '', 497 | tagName: '', 498 | textContent: '', 499 | } 500 | _element['children'] = [_element]; 501 | _element['firstElementChild'] = _element; 502 | _element['lastElementChild'] = _element; 503 | _element['nextSibling'] = _element; 504 | _element['nextElementSibling'] = _element; 505 | _element['parentNode'] = _element; 506 | _element['previousSibling'] = _element; 507 | _element['previousElementSibling'] = _element; 508 | document = { 509 | createElement: function(x) { 510 | return _element; 511 | }, 512 | getElementById: function(x) { 513 | return _element; 514 | }, 515 | getElementsByClassName: function(x) { 516 | return [_element]; 517 | }, 518 | getElementsByName: function(x) { 519 | return [_element]; 520 | }, 521 | getElementsByTagName: function(x) { 522 | return [_element]; 523 | }, 524 | getElementsByTagNameNS: function(x, y) { 525 | return [_element]; 526 | }, 527 | querySelector: function(x) { 528 | return _element; 529 | }, 530 | querySelectorAll: function(x) { 531 | return [_element]; 532 | }, 533 | } 534 | """ 535 | 536 | # 難読化された JavaScript を実行 537 | js_context = js2py.EvalJs() 538 | js_context.execute(js_dom_mock) 539 | js_context.execute(js_inst_function) 540 | js_context.execute(f'var ui_metrics = {js_inst_function_name}()') 541 | 542 | # ui_metrics を取得 543 | ui_metrics = cast(JsObjectWrapper, js_context.ui_metrics) 544 | return cast(Dict[str, Any], ui_metrics.to_dict()) 545 | 546 | 547 | def _login(self) -> None: 548 | """ 549 | スクリーンネームとパスワードを使って認証し、ログインする 550 | 551 | Raises: 552 | tweepy.BadRequest: スクリーンネームまたはパスワードが間違っている 553 | tweepy.HTTPException: サーバーエラーなどの問題でログインに失敗した 554 | tweepy.TweepyException: 認証フローの途中でエラーが発生し、ログインに失敗した 555 | """ 556 | 557 | def get_flow_token(response: requests.Response) -> str: 558 | try: 559 | data = response.json() 560 | except Exception: 561 | pass 562 | else: 563 | if response.status_code < 400: 564 | return data['flow_token'] 565 | raise self._get_tweepy_exception(response) 566 | 567 | def get_excepted_subtask(response: requests.Response, subtask_id: str) -> Dict[str, Any]: 568 | try: 569 | data = response.json() 570 | except Exception: 571 | pass 572 | else: 573 | if response.status_code < 400: 574 | for subtask in data['subtasks']: 575 | if subtask['subtask_id'] == subtask_id: 576 | return subtask 577 | raise tweepy.TweepyException(f'{subtask_id} not found in response') 578 | raise self._get_tweepy_exception(response) 579 | 580 | # Cookie をクリア 581 | self._session.cookies.clear() 582 | 583 | # 一度 https://x.com/ にアクセスして Cookie をセットさせる 584 | ## 取得した HTML はゲストトークンを取得するために使う 585 | html_response = self._session.get('https://x.com/i/flow/login', headers=self._html_headers) 586 | if html_response.status_code != 200: 587 | raise self._get_tweepy_exception(html_response) 588 | 589 | # CSRF トークンを生成し、"ct0" としてセッションの Cookie に保存 590 | ## 同時に認証フロー API 用の HTTP リクエストヘッダーにもセット ("ct0" と "x-csrf-token" は同じ値になる) 591 | csrf_token = self._generate_csrf_token() 592 | self._session.cookies.set('ct0', csrf_token, domain='.x.com') 593 | self._auth_flow_api_headers['x-csrf-token'] = csrf_token 594 | 595 | # まだ取得できていない場合のみ、ゲストトークンを取得し、"gt" としてセッションの Cookie に保存 596 | if self._session.cookies.get('gt', default=None) is None: 597 | guest_token = self._get_guest_token() 598 | self._session.cookies.set('gt', guest_token, domain='.x.com') 599 | 600 | ## ゲストトークンを認証フロー API 用の HTTP リクエストヘッダーにもセット ("gt" と "x-guest-token" は同じ値になる) 601 | self._auth_flow_api_headers['x-guest-token'] = self._session.cookies.get('gt') 602 | 603 | # これ以降は基本認証フロー API へのアクセスしか行わないので、セッションのヘッダーを認証フロー API 用のものに差し替える 604 | self._session.headers.clear() 605 | self._session.headers.update(self._auth_flow_api_headers) # type: ignore 606 | 607 | # 極力公式の Twitter Web App に偽装するためのダミーリクエスト 608 | self._session.get('https://api.x.com/1.1/hashflags.json') 609 | 610 | # https://api.x.com/1.1/onboarding/task.json?task=login に POST して認証フローを開始 611 | ## 認証フローを開始するには、Cookie に "ct0" と "gt" がセットされている必要がある 612 | ## 2024年5月時点の Twitter Web App が送信する JSON パラメータを模倣している 613 | flow_01_response = self._session.post('https://api.x.com/1.1/onboarding/task.json?flow_name=login', json={ 614 | 'input_flow_data': { 615 | 'flow_context': { 616 | 'debug_overrides': {}, 617 | 'start_location': { 618 | 'location': 'manual_link', 619 | } 620 | } 621 | }, 622 | 'subtask_versions': { 623 | 'action_list': 2, 624 | 'alert_dialog': 1, 625 | 'app_download_cta': 1, 626 | 'check_logged_in_account': 1, 627 | 'choice_selection': 3, 628 | 'contacts_live_sync_permission_prompt': 0, 629 | 'cta': 7, 630 | 'email_verification': 2, 631 | 'end_flow': 1, 632 | 'enter_date': 1, 633 | 'enter_email': 2, 634 | 'enter_password': 5, 635 | 'enter_phone': 2, 636 | 'enter_recaptcha': 1, 637 | 'enter_text': 5, 638 | 'enter_username': 2, 639 | 'generic_urt': 3, 640 | 'in_app_notification': 1, 641 | 'interest_picker': 3, 642 | 'js_instrumentation': 1, 643 | 'menu_dialog': 1, 644 | 'notifications_permission_prompt': 2, 645 | 'open_account': 2, 646 | 'open_home_timeline': 1, 647 | 'open_link': 1, 648 | 'phone_verification': 4, 649 | 'privacy_options': 1, 650 | 'security_key': 3, 651 | 'select_avatar': 4, 652 | 'select_banner': 2, 653 | 'settings_list': 7, 654 | 'show_code': 1, 655 | 'sign_up': 2, 656 | 'sign_up_review': 4, 657 | 'tweet_selection_urt': 1, 658 | 'update_users': 1, 659 | 'upload_media': 1, 660 | 'user_recommendations_list': 4, 661 | 'user_recommendations_urt': 1, 662 | 'wait_spinner': 3, 663 | 'web_modal': 1, 664 | } 665 | }) 666 | if flow_01_response.status_code != 200: 667 | raise self._get_tweepy_exception(flow_01_response) 668 | 669 | # flow_01 のレスポンスから js_inst の URL を取得 670 | # subtasks の中に LoginJsInstrumentationSubtask が含まれていない場合、例外を送出する 671 | js_inst_subtask = get_excepted_subtask(flow_01_response, 'LoginJsInstrumentationSubtask') 672 | js_inst_url = js_inst_subtask['js_instrumentation']['url'] 673 | 674 | # js_inst (難読化された JavaScript で、これの実行結果を認証フローに送信する必要がある) を取得 675 | js_inst_response = self._session.get(js_inst_url, headers=self._js_headers) 676 | if js_inst_response.status_code != 200: 677 | raise tweepy.TweepyException('Failed to get js_inst') 678 | 679 | # js_inst の JavaScript を実行し、ui_metrics オブジェクトを取得 680 | ui_metrics = self._get_ui_metrics(js_inst_response.text) 681 | 682 | # 取得した ui_metrics を認証フローに送信 683 | flow_02_response = self._session.post('https://api.x.com/1.1/onboarding/task.json', json={ 684 | 'flow_token': get_flow_token(flow_01_response), 685 | 'subtask_inputs': [ 686 | { 687 | 'subtask_id': 'LoginJsInstrumentationSubtask', 688 | 'js_instrumentation': { 689 | 'response': json.dumps(ui_metrics), 690 | 'link': 'next_link', 691 | } 692 | }, 693 | ] 694 | }) 695 | if flow_02_response.status_code != 200: 696 | raise self._get_tweepy_exception(flow_02_response) 697 | 698 | # subtasks の中に LoginEnterUserIdentifierSSO が含まれていない場合、例外を送出する 699 | get_excepted_subtask(flow_02_response, 'LoginEnterUserIdentifierSSO') 700 | 701 | # 極力公式の Twitter Web App に偽装するためのダミーリクエスト 702 | self._session.post('https://api.x.com/1.1/onboarding/sso_init.json', json={'provider': 'apple'}) 703 | 704 | # 怪しまれないように、2秒~4秒の間にランダムな時間待機 705 | time.sleep(random.uniform(2.0, 4.0)) 706 | 707 | # スクリーンネームを認証フローに送信 708 | flow_03_response = self._session.post('https://api.x.com/1.1/onboarding/task.json', json={ 709 | 'flow_token': get_flow_token(flow_02_response), 710 | 'subtask_inputs': [ 711 | { 712 | 'subtask_id': 'LoginEnterUserIdentifierSSO', 713 | 'settings_list': { 714 | 'setting_responses': [ 715 | { 716 | 'key': 'user_identifier', 717 | 'response_data': { 718 | 'text_data': { 719 | 'result': self.screen_name, 720 | } 721 | } 722 | }, 723 | ], 724 | 'link': 'next_link', 725 | } 726 | }, 727 | ] 728 | }) 729 | if flow_03_response.status_code != 200: 730 | raise self._get_tweepy_exception(flow_03_response) 731 | 732 | # subtasks の中に LoginEnterPassword が含まれていない場合、例外を送出する 733 | get_excepted_subtask(flow_03_response, 'LoginEnterPassword') 734 | 735 | # 怪しまれないように、2秒~4秒の間にランダムな時間待機 736 | time.sleep(random.uniform(2.0, 4.0)) 737 | 738 | # パスワードを認証フローに送信 739 | flow_04_response = self._session.post('https://api.x.com/1.1/onboarding/task.json', json={ 740 | 'flow_token': get_flow_token(flow_03_response), 741 | 'subtask_inputs': [ 742 | { 743 | 'subtask_id': 'LoginEnterPassword', 744 | 'enter_password': { 745 | 'password': self.password, 746 | 'link': 'next_link', 747 | } 748 | }, 749 | ] 750 | }) 751 | if flow_04_response.status_code != 200: 752 | raise self._get_tweepy_exception(flow_04_response) 753 | 754 | # ログイン失敗 755 | if flow_04_response.json()['status'] != 'success': 756 | raise tweepy.TweepyException(f'Failed to login (status: {flow_04_response.json()["status"]})') 757 | 758 | # subtasks の中に SuccessExit が含まれていない場合、例外を送出する 759 | get_excepted_subtask(flow_04_response, 'SuccessExit') 760 | 761 | # 最後の最後にファイナライズを行う 762 | ## このリクエストで、Cookie に auth_token がセットされる 763 | ## このタイミングで Cookie の "ct0" 値 (CSRF トークン) がクライアント側で生成したものから、サーバー側で生成したものに更新される 764 | flow_05_response = self._session.post('https://api.x.com/1.1/onboarding/task.json', json={ 765 | 'flow_token': get_flow_token(flow_04_response), 766 | 'subtask_inputs': [], 767 | }) 768 | if flow_05_response.status_code != 200: 769 | raise self._get_tweepy_exception(flow_05_response) 770 | 771 | # ここまで来たら、ログインに成功しているはず 772 | ## Cookie にはログインに必要な情報が入っている 773 | ## 実際に認証に最低限必要な Cookie は "auth_token" と "ct0" のみ (とはいえそれだけだと怪しまれそうなので、それ以外の値も送る) 774 | ## ref: https://qiita.com/SNQ-2001/items/182b278e1e8aaaa21a13 775 | -------------------------------------------------------------------------------- /tweepy_authlib/__about__.py: -------------------------------------------------------------------------------- 1 | 2 | # バージョン情報 3 | __version__ = '1.5.7' 4 | -------------------------------------------------------------------------------- /tweepy_authlib/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | from .CookieSessionUserHandler import CookieSessionUserHandler 3 | --------------------------------------------------------------------------------