├── twitter_cli ├── __init__.py ├── notification.py ├── counter.py ├── downloader.py ├── config.py └── models.py ├── .vscode └── settings.json ├── pyproject.toml ├── .envrc ├── Pipfile ├── justfile ├── setup.py ├── .gitignore ├── README.md ├── main.py └── Pipfile.lock /twitter_cli/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.pythonPath": "~/.pyenv/versions/venv27/bin/python" 3 | } 4 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel"] 3 | build-backend = "setuptools.build_meta:__legacy__" -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | # Environment variables loaded by direnv. 2 | # https://direnv.net/ 3 | 4 | export PIPENV_VENV_IN_PROJECT="./.venv" 5 | 6 | # https://pipenv.pypa.io/en/latest/advanced/#working-with-platform-provided-python-components 7 | export PIP_IGNORE_INSTALLED=1 8 | 9 | # https://pipenv.pypa.io/en/latest/advanced/#changing-default-python-versions 10 | export PIPENV_DEFAULT_PYTHON_VERSION=3.7 11 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | coloredlogs = ">=10.0" 8 | configparser = ">=4.0.2" 9 | peewee = ">=3.11.2" 10 | python-twitter = ">=3.5" 11 | click = "~=8.0" 12 | futures = ">=3" 13 | twitter-cli = {editable = true, path = "."} 14 | 15 | [dev-packages] 16 | pipenv-setup = "~=3.1.1" 17 | 18 | [requires] 19 | python_version = "3.7" 20 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | 2 | default: 3 | @just --choose 4 | 5 | active-env: 6 | @pipenv shell 7 | 8 | install: 9 | @pipenv install -e . 10 | # pipenv run pip install -e . 11 | 12 | # setup: 13 | # @python setup.py install 14 | 15 | cli *options: 16 | @pipenv run twitter-cli {{options}} 17 | @#@$(pipenv --venv)/bin/twitter-cli {{options}} # works good, too. 18 | 19 | help: 20 | @just cli --help 21 | 22 | timeline: 23 | @just cli timeline --download-media 24 | 25 | favorite: 26 | @just cli favorites --download-media --destroy 27 | 28 | list: 29 | @just cli list --download-media 30 | 31 | publish: 32 | rsync -avr --progress . gcp-vps:~/projects/twitter-cli/ 33 | 34 | cleanup: 35 | find . -type f -name "*.pyc" -delete 36 | find . -type d -name "__pycache__" -delete 37 | -------------------------------------------------------------------------------- /twitter_cli/notification.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import requests 3 | 4 | from .config import get_bark_key 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | def send_notification(title, message=None): 9 | key = get_bark_key() 10 | 11 | if not (key and len(key) > 0): 12 | logger.warning('Empty key configuration for Bark!') 13 | return False 14 | 15 | url = 'https://api.day.app/%s' % key 16 | 17 | if title: 18 | url += '/%s' % title 19 | 20 | if message: 21 | url += '/%s' % message 22 | 23 | r = requests.get(url) 24 | 25 | if r.status_code != requests.codes.ok: 26 | logger.error('Failed to send request with error: %s' % r.text) 27 | return 28 | 29 | json_data = r.json() 30 | 31 | if json_data.get('code', 0) != 200: 32 | logger.error(json_data.get('message')) 33 | return False 34 | 35 | return True 36 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setup( 7 | name='twitter-cli', 8 | version='0.2', 9 | author='Will Han', 10 | author_email='xingheng.hax@qq.com', 11 | license='MIT', 12 | keywords='twitter cli media downloader', 13 | url='https://github.com/xingheng/twitter-cli', 14 | description='twitter crawler', 15 | long_description=long_description, 16 | long_description_content_type='text/markdown', 17 | packages=['twitter_cli'], 18 | include_package_data=True, 19 | install_requires=[ 20 | 'coloredlogs>=10.0', 21 | 'configparser>=4.0.2', 22 | 'peewee>=3.11.2', 23 | 'python-twitter>=3.5', 24 | 'click>=7.0', 25 | 'futures>=3', 26 | ], 27 | entry_points=''' 28 | [console_scripts] 29 | twitter-cli=main:cli 30 | ''', 31 | classifiers=[ 32 | 'Development Status :: 1 - Beta', 33 | 'Environment :: Console', 34 | 'License :: OSI Approved :: MIT License', 35 | 'Programming Language :: Python :: 3.7', 36 | 'Programming Language :: Unix Shell', 37 | 'Topic :: System :: Shells', 38 | 'Topic :: Terminals', 39 | 'Topic :: Text Processing :: Linguistic', 40 | ], 41 | ) 42 | -------------------------------------------------------------------------------- /twitter_cli/counter.py: -------------------------------------------------------------------------------- 1 | 2 | import logging 3 | from datetime import datetime 4 | from .notification import * 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | class Counter(object): 9 | _success = 0 10 | _failure = 0 11 | _reset_func = None 12 | _reset_time = datetime.now() 13 | 14 | @classmethod 15 | def success(cls, count=1): 16 | cls._success += count 17 | cls._try_reset() 18 | 19 | @classmethod 20 | def failure(cls, count=1): 21 | cls._failure += count 22 | cls._try_reset() 23 | 24 | @classmethod 25 | def set_reset_handler(cls, func): 26 | cls._reset_func = func 27 | 28 | @classmethod 29 | def _try_reset(cls): 30 | reset_func = cls._reset_func 31 | 32 | if reset_func and callable(reset_func): 33 | if reset_func(cls._success, cls._failure, cls._reset_time): 34 | logger.debug('resetting counter') 35 | cls._success = 0 36 | cls._failure = 0 37 | cls._reset_time = datetime.now() 38 | 39 | # --------------------------------------------- 40 | 41 | @staticmethod 42 | def handler(success, failure, time): 43 | if (datetime.now() - time).days >= 1: 44 | title = '[twitter-cli] Found %d new tweet(s)' % (success + failure) 45 | message = 'Including %d success and %d failure.' % (success, failure) 46 | send_notification(title, message) 47 | return True 48 | 49 | return False 50 | 51 | Counter.set_reset_handler(handler) 52 | -------------------------------------------------------------------------------- /twitter_cli/downloader.py: -------------------------------------------------------------------------------- 1 | 2 | import logging 3 | import requests 4 | 5 | try: 6 | from urlparse import urlparse 7 | except: 8 | from urllib.parse import urlparse 9 | 10 | from os.path import join, split, exists 11 | from concurrent.futures import ThreadPoolExecutor, as_completed 12 | from .config import * 13 | 14 | logger = logging.getLogger(__name__) 15 | executor = ThreadPoolExecutor() 16 | 17 | def _download(url, output_dir, prefix, extension): 18 | try: 19 | paths = split(urlparse(url).path) 20 | filename = paths[-1] 21 | 22 | if '.' in filename: 23 | filename = '%s-%s' % (prefix, filename) 24 | else: 25 | filename = '%s-%s.%s' % (prefix, filename, extension) 26 | 27 | filepath = join(output_dir, filename) 28 | 29 | if exists(filepath): 30 | logger.info('Found existing file here, reusing it...') 31 | return filepath 32 | 33 | def task(url): 34 | logger.info('Downloading %s' % url) 35 | response = requests.get(url, proxies=get_proxy()) 36 | logger.info('Downloaded to %s' % filepath) 37 | 38 | with open(filepath, 'wb') as f: 39 | f.write(response.content) 40 | 41 | executor.submit(task, (url)) 42 | except requests.RequestException as e: 43 | logger.exception(e) 44 | filepath = None 45 | 46 | return filepath 47 | 48 | def download_video(url, output_dir, prefix='', extension='mp4'): 49 | return _download(url, output_dir, prefix, extension) 50 | 51 | def download_photo(url, output_dir, prefix='', extension='jpg'): 52 | return _download(url, output_dir, prefix, extension) 53 | 54 | def wait_for_download_completed(): 55 | executor.shutdown() 56 | -------------------------------------------------------------------------------- /.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 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | .static_storage/ 56 | .media/ 57 | local_settings.py 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # custom 107 | *.log.* 108 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # twitter-cli 2 | 3 | > Parse and download the tweets from home timeline, specified user timeline, favorites list. 4 | 5 | 6 | 7 | #### Environment 8 | 9 | * Python 3 10 | * [Pipenv]() 11 | 12 | 13 | 14 | #### Setup 15 | 16 | ```bash 17 | # [Optional] Prepare to inject the environments from `.envrc`, it makes the virtualenv activated under the current project directory. 18 | direnv allow 19 | 20 | # Enter the virtualenv. 21 | pipenv shell 22 | 23 | # Install dependency and application itself. 24 | pipenv sync 25 | 26 | # Run 27 | twitter-cli 28 | # or via pipenv explicitly: 29 | pipenv run twitter-cli 30 | # or without activating venv after the first time: 31 | $(pipenv --venv)/bin/twitter-cli 32 | ``` 33 | 34 | 35 | 36 | #### Usage 37 | 38 | ```shell 39 | ➜ ~ twitter-cli --help 40 | Usage: twitter-cli [OPTIONS] COMMAND [ARGS]... 41 | 42 | Options: 43 | --debug / --no-debug Enable logger level to DEBUG 44 | --help Show this message and exit. 45 | 46 | Commands: 47 | configure Show the current configurations. 48 | credential Verify the current user credential. 49 | favorites Fetch the user's favorite statuses. 50 | timeline Fetch the specified users' timeline. 51 | ➜ ~ twitter-cli credential --help 52 | Usage: twitter-cli credential [OPTIONS] 53 | 54 | Verify the current user credential. 55 | 56 | Options: 57 | --help Show this message and exit. 58 | ➜ ~ twitter-cli timeline --help 59 | Usage: twitter-cli timeline [OPTIONS] [USERNAME] 60 | 61 | Fetch the specified user's timeline. 62 | 63 | Options: 64 | --download-media / --no-download-media 65 | Download status's media files or not 66 | --help Show this message and exit. 67 | ➜ ~ twitter-cli favorites --help 68 | Usage: twitter-cli favorites [OPTIONS] 69 | 70 | Fetch the user's favorite statuses. 71 | 72 | Options: 73 | --from-latest / --from-last Fetch statuses from latest or last saved one 74 | --download-media / --no-download-media 75 | Download status's media files or not 76 | --destroy / --no-destroy Destroy the favorite statuses 77 | --schedule INTEGER Run as scheduler with specified hours 78 | --help Show this message and exit. 79 | ➜ ~ 80 | ``` 81 | 82 | 83 | 84 | #### Author 85 | 86 | [Will Han](https://xingheng.github.io) -------------------------------------------------------------------------------- /twitter_cli/config.py: -------------------------------------------------------------------------------- 1 | from os import makedirs 2 | from os.path import join, exists, expanduser 3 | import configparser 4 | import json 5 | 6 | _root = expanduser('~/.config/twitter-cli') 7 | exists(_root) or makedirs(_root) 8 | 9 | _config = None 10 | 11 | CONFIG_FILE = join(_root, 'config') 12 | DATABASE_FILE = join(_root, 'data.sqlite3') 13 | 14 | _SECTION_PROXY = 'PROXY' 15 | _SECTION_KEYS = 'KEYS' 16 | _SECTION_STORAGE = 'STORAGE' 17 | _SECTION_USERS = 'USERS' 18 | 19 | def _load_config(): 20 | global _config 21 | 22 | if _config is None: 23 | _config = configparser.ConfigParser() 24 | 25 | if exists(CONFIG_FILE): 26 | _config.read(CONFIG_FILE) 27 | else: 28 | _config.add_section(_SECTION_PROXY) 29 | _config.set(_SECTION_PROXY, 'http', '') 30 | _config.set(_SECTION_PROXY, 'https', '') 31 | 32 | _config.add_section(_SECTION_KEYS) 33 | _config.set(_SECTION_KEYS, 'consumer_key', '') 34 | _config.set(_SECTION_KEYS, 'consumer_secret', '') 35 | _config.set(_SECTION_KEYS, 'access_token_key', '') 36 | _config.set(_SECTION_KEYS, 'access_token_secret', '') 37 | _config.set(_SECTION_KEYS, 'bark_key', '') 38 | 39 | _config.add_section(_SECTION_STORAGE) 40 | _config.set(_SECTION_STORAGE, 'root_path', '~/Downloads/twitter-cli') 41 | _config.set(_SECTION_STORAGE, 'timeline', 'timeline') 42 | _config.set(_SECTION_STORAGE, 'favorite', 'favorite') 43 | _config.set(_SECTION_STORAGE, 'pinned', 'pinned') 44 | 45 | _config.add_section(_SECTION_USERS) 46 | _config.set(_SECTION_USERS, 'names', '["me", ]') 47 | 48 | with open(CONFIG_FILE, 'wb') as f: 49 | _config.write(f) 50 | 51 | return _config 52 | 53 | def pretty_json_string(dic): 54 | return json.dumps(dic, sort_keys=True, indent=4) 55 | 56 | def get_raw_config(): 57 | output = '' 58 | config = _load_config() 59 | 60 | for section in config.sections(): 61 | output += '%s: \n' % section 62 | output += pretty_json_string(dict(config.items(section))) 63 | output += '\n\n' 64 | 65 | output += 'PATH: %s' % CONFIG_FILE 66 | 67 | return output 68 | 69 | def get_proxy(): 70 | return dict(_load_config().items(_SECTION_PROXY)) 71 | 72 | def get_keys(): 73 | return dict(_load_config().items(_SECTION_KEYS)) 74 | 75 | def get_bark_key(): 76 | return _load_config().get(_SECTION_KEYS, 'bark_key') 77 | 78 | def _get_storage_path(is_favorite=False, timeline=None): 79 | config = _load_config() 80 | path = config.get(_SECTION_STORAGE, 'root_path') 81 | 82 | if is_favorite: 83 | path = join(path, config.get(_SECTION_STORAGE, 'favorite', fallback='favorite')) 84 | elif timeline in get_pinned_users(): 85 | path = join(path, config.get(_SECTION_STORAGE, 'pinned', fallback='pinned')) 86 | elif timeline: 87 | path = join(path, config.get(_SECTION_STORAGE, 'timeline', fallback='timeline'), timeline) 88 | 89 | return expanduser(path) 90 | 91 | def get_video_storage_path(is_favorite=False, timeline=None): 92 | path = _get_storage_path(is_favorite, timeline) 93 | exists(path) or makedirs(path) 94 | return path 95 | 96 | def get_photo_storage_path(is_favorite=False, timeline=None): 97 | path = join(_get_storage_path(is_favorite, timeline), 'photos') 98 | exists(path) or makedirs(path) 99 | return path 100 | 101 | def get_pinned_users(): 102 | return json.loads(_load_config().get(_SECTION_USERS, 'names')) 103 | -------------------------------------------------------------------------------- /twitter_cli/models.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from peewee import * 4 | from twitter.models import * 5 | 6 | from .config import pretty_json_string, DATABASE_FILE 7 | 8 | _db = SqliteDatabase(DATABASE_FILE) 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | def get(self, attr, default=None): 13 | if hasattr(self, attr): 14 | return getattr(self, attr) 15 | else: 16 | return default 17 | 18 | # Inject this safe getter method to TwitterModel class. 19 | setattr(TwitterModel, 'get', get) 20 | 21 | class PeeweeModel(Model): 22 | class Meta: 23 | database = _db 24 | 25 | @classmethod 26 | def initialize(cls, model): 27 | assert(isinstance(model, TwitterModel)) 28 | 29 | class User(PeeweeModel): 30 | id = BigIntegerField(primary_key=True) 31 | name = CharField(null=True) 32 | created_at = CharField(null=True) 33 | followers = IntegerField(null=True) 34 | statuses_count = IntegerField(null=True) 35 | location = CharField(null=True) 36 | screen_name = CharField(null=True) 37 | profile_image_url = CharField(null=True) 38 | description = CharField(null=True) 39 | 40 | def __str__(self): 41 | return 'id: %s, username: %s' % (self.id, self.name) 42 | 43 | @classmethod 44 | def initialize(cls, user): 45 | super(User, cls).initialize(user) 46 | 47 | u, _ = cls.get_or_create(id=user.id) 48 | u.name = user.get('name') 49 | u.created_at = user.get('created_at') 50 | u.followers = user.get('followers_count', 0) 51 | u.statuses_count = user.get('statuses_count', 0) 52 | u.location = user.get('location') 53 | u.screen_name = user.get('screen_name') 54 | u.profile_image_url = user.get('profile_image_url') 55 | u.description = user.get('description') 56 | 57 | u.save() 58 | return u 59 | 60 | class Video(PeeweeModel): 61 | id = BigIntegerField(primary_key=True) 62 | display_url = CharField(null=True) 63 | expanded_url = CharField(null=True) 64 | media_url = CharField(null=True) 65 | media_url_https = CharField(null=True) 66 | size = CharField(null=True) 67 | url = CharField(null=True) 68 | content_type = CharField(null=True) 69 | video_url = CharField(null=True) 70 | bitrate = IntegerField(null=True) 71 | duration_millis = IntegerField(null=True) 72 | aspect_ratio = CharField(null=True) 73 | downloaded_path = CharField(null=True) 74 | 75 | def __str__(self): 76 | return 'id: %s, url: %s' % (self.id, self.media_url_https) 77 | 78 | @classmethod 79 | def initialize(cls, video): 80 | super(Video, cls).initialize(video) 81 | 82 | v, _ = cls.get_or_create(id=video.id) 83 | v.display_url = video.get('display_url') 84 | v.expanded_url = video.get('expanded_url') 85 | v.media_url = video.get('media_url') 86 | v.media_url_https = video.get('media_url_https') 87 | v.size = pretty_json_string(video.get('sizes')) 88 | v.url = video.get('url') 89 | 90 | info_dict = video.get('video_info') 91 | v.duration_millis = info_dict.get('duration_millis') 92 | v.aspect_ratio = str(info_dict.get('aspect_ratio')) 93 | 94 | prefer_variant = None 95 | 96 | for variant in info_dict['variants']: 97 | if 'bitrate' not in variant: 98 | continue 99 | 100 | if prefer_variant is None: 101 | prefer_variant = variant 102 | else: 103 | if variant['bitrate'] > prefer_variant['bitrate']: 104 | prefer_variant = variant 105 | 106 | v.content_type = prefer_variant['content_type'] 107 | v.video_url = prefer_variant['url'] 108 | v.bitrate = prefer_variant['bitrate'] 109 | 110 | v.save() 111 | return v 112 | 113 | class Photo(PeeweeModel): 114 | id = BigIntegerField(primary_key=True) 115 | display_url = CharField(null=True) 116 | expanded_url = CharField(null=True) 117 | media_url = CharField(null=True) 118 | media_url_https = CharField(null=True) 119 | size = CharField(null=True) 120 | url = CharField(null=True) 121 | downloaded_path = CharField(null=True) 122 | 123 | def __str__(self): 124 | return 'id: %s, url: %s' % (self.id, self.media_url_https) 125 | 126 | @classmethod 127 | def initialize(cls, photo): 128 | super(Photo, cls).initialize(photo) 129 | 130 | p, _ = cls.get_or_create(id=photo.id) 131 | p.display_url = photo.get('display_url') 132 | p.expanded_url = photo.get('expanded_url') 133 | p.media_url = photo.get('media_url') 134 | p.media_url_https = photo.get('media_url_https') 135 | p.size = pretty_json_string(photo.get('sizes')) 136 | p.url = photo.get('url') 137 | 138 | p.save() 139 | return p 140 | 141 | class Status(PeeweeModel): 142 | id = BigIntegerField(primary_key=True) 143 | text = CharField(null=True) 144 | lang = CharField(null=True) 145 | possibly_sensitive = BooleanField(null=True) 146 | favorited = BooleanField(null=True) 147 | retweet_count = IntegerField(null=True) 148 | favorite_count = IntegerField(null=True) 149 | source = CharField(null=True) 150 | created_at = CharField(null=True) 151 | 152 | user = ForeignKeyField(User, backref='statuses', null=True) 153 | video = ForeignKeyField(Video, backref='statuses', null=True) 154 | photos = ManyToManyField(Photo, backref='statuses') 155 | 156 | # http://docs.peewee-orm.com/en/latest/peewee/models.html#self-referential-foreign-keys 157 | quoted_status = ForeignKeyField('self', backref='quotes', null=True) 158 | retweeted_status = ForeignKeyField('self', backref='retweets', null=True) 159 | 160 | def __str__(self): 161 | return 'id: %s, user: %s, text: %s' % (self.id, self.user.name, self.text) 162 | 163 | @classmethod 164 | def initialize(cls, status): 165 | super(Status, cls).initialize(status) 166 | 167 | s, _ = cls.get_or_create(id=status.id) 168 | s.text = status.get('text') 169 | s.lang = status.get('lang') 170 | s.possibly_sensitive = status.get('possibly_sensitive') 171 | s.favorited = status.get('favorited') 172 | s.retweet_count = status.get('retweet_count', 0) 173 | s.favorite_count = status.get('favorite_count', 0) 174 | s.source = status.get('source') 175 | s.created_at = status.get('created_at') 176 | s.user = User.initialize(status.get('user')) 177 | 178 | if status.get('media'): 179 | s.photos.clear() 180 | 181 | for media in status.media: 182 | if media.type == 'video' or media.type == 'animated_gif': 183 | s.video = Video.initialize(media) 184 | elif media.type == 'photo': 185 | s.photos.add(Photo.initialize(media)) 186 | else: 187 | logger.warning('Unexpected media type: %s! Details: %s' % (media.type, media.AsDict())) 188 | 189 | if status.get('quoted_status'): 190 | s.quoted_status = cls.initialize(status.quoted_status) 191 | 192 | if status.get('retweeted_status'): 193 | s.retweeted_status = cls.initialize(status.retweeted_status) 194 | 195 | s.save() 196 | return s 197 | 198 | def is_video(self): 199 | return self.video != None 200 | 201 | def is_photo(self): 202 | return self.photos.count() > 0 203 | 204 | _db.connect() 205 | _db.create_tables([ 206 | User, 207 | Video, 208 | Photo, 209 | Status, 210 | Status.photos.get_through_model(), 211 | ]) 212 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import coloredlogs, logging, logging.config 4 | import twitter 5 | import click 6 | from os.path import split 7 | from time import sleep 8 | 9 | from twitter_cli.config import * 10 | from twitter_cli.models import * 11 | from twitter_cli.downloader import * 12 | from twitter_cli.counter import * 13 | 14 | from requests.exceptions import SSLError 15 | 16 | # Refer to 17 | # 1. https://stackoverflow.com/a/7507842/1677041 18 | # 2. https://stackoverflow.com/a/49400680/1677041 19 | # 3. https://docs.python.org/2/library/logging.config.html 20 | LOGGING_CONFIG = { 21 | 'version': 1, 22 | 'disable_existing_loggers': True, 23 | 'formatters': { 24 | 'standard': { 25 | 'format': '%(asctime)s - %(name)s - %(levelname)s - %(message)s', 26 | 'datefmt': '%Y-%m-%d %H:%M:%S', 27 | }, 28 | 'colored': { 29 | '()': 'coloredlogs.ColoredFormatter', 30 | 'format': "%(asctime)s - %(name)s - %(levelname)s - %(message)s", 31 | 'datefmt': '%H:%M:%S', 32 | } 33 | }, 34 | 'handlers': { 35 | 'default': { 36 | 'level': 'DEBUG' if __debug__ else 'INFO', 37 | 'formatter': 'standard', 38 | 'class': 'logging.StreamHandler', 39 | 'stream': 'ext://sys.stdout', # Default is stderr 40 | }, 41 | 'console': { 42 | 'level': 'DEBUG' if __debug__ else 'INFO', 43 | 'class': 'logging.StreamHandler', 44 | 'formatter': 'colored', 45 | 'stream': 'ext://sys.stdout' 46 | }, 47 | 'file': { 48 | 'level': 'DEBUG', 49 | 'class': 'logging.handlers.RotatingFileHandler', 50 | 'formatter': 'standard', 51 | 'filename': 'main.log', 52 | 'maxBytes': 1024 * 1024, 53 | 'backupCount': 10 54 | }, 55 | }, 56 | 'loggers': { 57 | '': { # root logger 58 | 'handlers': ['console', 'file'], 59 | 'level': 'DEBUG', 60 | 'propagate': False 61 | }, 62 | '__main__': { # if __name__ == '__main__' 63 | 'handlers': ['console', 'file'], 64 | 'level': 'DEBUG', 65 | 'propagate': False 66 | }, 67 | 'twitter_cli.downloader': { 68 | 'handlers': ['console', 'file'], 69 | 'level': 'DEBUG', 70 | 'propagate': False 71 | }, 72 | 'twitter': { 73 | 'handlers': ['console', 'file'], 74 | 'level': 'DEBUG', 75 | 'propagate': False 76 | }, 77 | 'twitter_cli': { 78 | 'handlers': ['console', 'file'], 79 | 'level': 'DEBUG', 80 | 'propagate': False 81 | }, 82 | } 83 | } 84 | 85 | logging.config.dictConfig(LOGGING_CONFIG) 86 | logger = logging.getLogger(__name__) 87 | 88 | api = twitter.Api(proxies=get_proxy(), **get_keys()) 89 | 90 | PAGE_SIZE = 100 91 | MYSELF = 'me' 92 | 93 | def print_sample_entity(entities, prefix=''): 94 | if not entities: 95 | entities = 'Empty' 96 | 97 | if type(entities) in (type, list): 98 | entity = entities[0] if len(entities) > 0 else '' 99 | logger.info('\nCount of %s: %d' % (prefix, len(entities) if entities else 0)) 100 | else: 101 | entity = entities 102 | 103 | if isinstance(entity, twitter.models.TwitterModel): 104 | content = pretty_json_string(entity.AsDict()) 105 | elif isinstance(entity, dict): 106 | content = pretty_json_string(entity) 107 | else: 108 | content = str(entity) 109 | 110 | logger.info(prefix + content) 111 | 112 | def destroy_favorited_status(status, retry=10): 113 | if not status or not status.id: 114 | return False 115 | 116 | try: 117 | logger.info('Destroying the favorited status %s' % status.id) 118 | result = api.DestroyFavorite(status=status) 119 | logger.info('Destroy finished!') 120 | return result != None 121 | except twitter.TwitterError as e: 122 | logger.exception(e) 123 | 124 | if e.args[0][0]['code'] == 144: 125 | # TwitterError: No status found with that ID 126 | # Let's just pretend it as suceed. 127 | return True 128 | except SSLError as e: 129 | if retry > 0: 130 | return destroy_favorited_status(status, retry=retry-1) 131 | else: 132 | raise e 133 | 134 | return False 135 | 136 | def save_status(status, destroy=False, timeline=None): 137 | if isinstance(status, TwitterModel): 138 | s = Status.initialize(status) 139 | elif isinstance(status, PeeweeModel): 140 | s = status 141 | else: 142 | assert('Unexpected status type! %s' % status) 143 | 144 | logger.debug('Status: %s\n%s:[\n%s\n]' % (s.id, s.user.name, s.text)) 145 | 146 | should_destory = False 147 | video = s.video 148 | 149 | if video: 150 | logger.debug('Found video: %s' % video) 151 | path = get_video_storage_path(is_favorite=s.favorited, timeline=timeline) 152 | extension = split(video.content_type)[-1] 153 | filepath = download_video(video.video_url, path, prefix=str(status.id), extension=extension) 154 | 155 | if filepath: 156 | video.downloaded_path = filepath 157 | video.save() 158 | 159 | should_destory = True 160 | Counter.success() 161 | else: 162 | logger.error('Download video failed?') 163 | Counter.failure() 164 | 165 | if s.is_photo: 166 | for p in s.photos: 167 | logger.debug('Found photo: %s' % p) 168 | path = get_photo_storage_path(is_favorite=s.favorited, timeline=timeline) 169 | filepath = download_photo(p.media_url_https or p.media_url, path, prefix=str(status.id)) 170 | 171 | if filepath: 172 | p.downloaded_path = filepath 173 | p.save() 174 | 175 | should_destory = True 176 | Counter.success() 177 | else: 178 | logger.error('Download photo failed?') 179 | Counter.failure() 180 | 181 | if s.quoted_status: 182 | logger.debug('Found quoted status: %s\n%s:[\n%s\n]' % (s.quoted_status.id, s.quoted_status.user.name, s.quoted_status.text)) 183 | save_status(s.quoted_status) 184 | 185 | should_destory = True 186 | 187 | if s.retweeted_status: 188 | logger.debug('Found retweeted status: %s\n%s:[\n%s\n]' % (s.retweeted_status.id, s.retweeted_status.user.name, s.retweeted_status.text)) 189 | save_status(s.retweeted_status) 190 | 191 | should_destory = True 192 | 193 | if destroy: 194 | if not should_destory: 195 | logger.warning('Destroying this status even no media found!%s', print_sample_entity(s, 'Details:\n')) 196 | 197 | destroy_favorited_status(status) 198 | 199 | def fetch_iteriable_statuses(pfunc): 200 | max_id = None 201 | statuses = None 202 | 203 | while True: 204 | try: 205 | logger.info('Request starts with %s' % (max_id or 'latest')) 206 | statuses = pfunc(max_id) 207 | logger.info('Request completed with %d status(es)', len(statuses) if statuses else 0) 208 | except twitter.TwitterError as e: 209 | logger.exception(e) 210 | statuses = None 211 | code = e.args[0][0]['code'] 212 | 213 | if code == 88: 214 | # TwitterError: Rate limit exceeded. 215 | # https://developer.twitter.com/en/docs/basics/rate-limiting 216 | 217 | # Let's try it in next hour later. 218 | logger.info('Start sleeping because rate limit exceeded') 219 | sleep(2 * 60 * 60) 220 | logger.info('End sleeping') 221 | 222 | continue 223 | elif code == 34: 224 | # "Sorry, that page does not exist." 225 | break 226 | except Exception as e: 227 | logger.exception(e) 228 | statuses = None 229 | 230 | if statuses and len(statuses) > 0: 231 | for status in statuses: 232 | yield status 233 | 234 | new_max_id = statuses[-1].id 235 | 236 | if new_max_id == max_id: 237 | logger.info('This should be the normal ending.') 238 | break 239 | 240 | max_id = new_max_id 241 | else: 242 | break 243 | 244 | @click.group() 245 | @click.option('--debug/--no-debug', default=False, help='Enable logger level to DEBUG') 246 | @click.pass_context 247 | def cli(ctx, debug): 248 | logger.setLevel(logging.DEBUG if debug else logging.INFO) 249 | debug and click.echo('Debug mode is on') 250 | 251 | @cli.command() 252 | @click.pass_context 253 | def configure(ctx): 254 | ''' 255 | Show the current configurations. 256 | ''' 257 | logger.info('\n%s' % get_raw_config()) 258 | 259 | @cli.command() 260 | @click.pass_context 261 | def credential(ctx): 262 | ''' 263 | Verify the current user credential. 264 | ''' 265 | try: 266 | userinfo = api.VerifyCredentials() 267 | except twitter.TwitterError as e: 268 | logger.exception(e) 269 | logger.error('Make sure you\'ve configured the twitter keys right in file %s.' % CONFIG_FILE) 270 | 271 | if userinfo: 272 | print_sample_entity(userinfo, 'User info:') 273 | logger.info('Verification pass!') 274 | else: 275 | logger.error('Verification Failed') 276 | 277 | @cli.command() 278 | @click.argument('usernames', nargs=-1, type=click.STRING) 279 | @click.option('--pinned/--no-pinned', default=False, help='Append the pinned users to current usernames') 280 | @click.option('--download-media/--no-download-media', default=True, help='Download status\'s media files or not') 281 | @click.option('--schedule', type=click.INT, default=0, help='Run as scheduler with specified hours') 282 | @click.pass_context 283 | def timeline(ctx, usernames, pinned, download_media, schedule): 284 | ''' 285 | Fetch the specified users' timeline. 286 | ''' 287 | # Convert the tuple to list 288 | usernames = list(usernames) 289 | 290 | if pinned: 291 | usernames.extend(get_pinned_users()) 292 | if len(usernames) <= 0: 293 | usernames = [MYSELF] 294 | 295 | def job(): 296 | generators = {} 297 | 298 | for username in usernames: 299 | if username == MYSELF: 300 | pfunc = lambda max_id: api.GetHomeTimeline(count=PAGE_SIZE, max_id=max_id) 301 | elif len(username) > 0: 302 | pfunc = lambda max_id: api.GetUserTimeline(screen_name=username, count=PAGE_SIZE, max_id=max_id) 303 | else: 304 | pfunc = None 305 | 306 | if pfunc: 307 | generators[username] = fetch_iteriable_statuses(pfunc) 308 | 309 | for username, generator in generators.items(): 310 | logger.info('Fetching timeline of pinned user [%s]' % username) 311 | 312 | for status in generator: 313 | shall_download = status.favorite_count * 1.0 / status.user.followers_count > 0.05 314 | shall_download |= status.user.retweet_count * 1.0 / status.user.followers_count > 0.01 315 | 316 | if shall_download or download_media: 317 | save_status(status, timeline=username) 318 | else: 319 | print_sample_entity(status, prefix='Info:') 320 | 321 | if schedule <= 0: 322 | job() 323 | else: 324 | while True: 325 | try: 326 | job() 327 | except (KeyboardInterrupt, SystemExit): 328 | logger.warning('Interrupt by keyboard, stopping') 329 | break 330 | except Exception as e: 331 | logger.exception(e) 332 | 333 | logger.info('Start sleeping, waiting for next schedule...') 334 | sleep(schedule * 60 * 60) 335 | logger.info('End sleeping') 336 | 337 | logger.info('Done!') 338 | 339 | @cli.command() 340 | @click.option('--from-latest/--from-last', default=False, help='Fetch statuses from latest or last saved one') 341 | @click.option('--download-media/--no-download-media', default=True, help='Download status\'s media files or not') 342 | @click.option('--destroy/--no-destroy', default=False, help='Destroy the favorite statuses') 343 | @click.option('--schedule', type=click.INT, default=0, help='Run as scheduler with specified hours') 344 | @click.pass_context 345 | def favorites(ctx, from_latest, download_media, destroy, schedule): 346 | ''' 347 | Fetch the user's favorite statuses. 348 | ''' 349 | 350 | def job(): 351 | if from_latest: 352 | max_id = None 353 | else: 354 | status = Status.select().order_by(Status.id).first() 355 | max_id = status.id if status else None 356 | 357 | for status in fetch_iteriable_statuses(lambda max_id: api.GetFavorites(count=PAGE_SIZE, max_id=max_id)): 358 | if download_media: 359 | save_status(status, destroy=destroy) 360 | else: 361 | print_sample_entity(status, prefix='Info: ') 362 | 363 | if schedule <= 0: 364 | job() 365 | else: 366 | while True: 367 | try: 368 | job() 369 | except (KeyboardInterrupt, SystemExit): 370 | logger.warning('Interrupt by keyboard, stopping') 371 | break 372 | except Exception as e: 373 | logger.exception(e) 374 | 375 | logger.info('Start sleeping, waiting for next schedule...') 376 | sleep(schedule * 60 * 60) 377 | logger.info('End sleeping') 378 | 379 | wait_for_download_completed() 380 | logger.info('Done!') 381 | 382 | @cli.command() 383 | @click.argument('names', nargs=-1, type=click.STRING) 384 | @click.option('--username', type=click.STRING, default=None, help='List owner\'s screen name') 385 | @click.option('--download-media/--no-download-media', default=True, help='Download status\'s media files or not') 386 | @click.pass_context 387 | def list(ctx, names, username, download_media,): 388 | ''' 389 | Fetch the statuses of specified list. 390 | ''' 391 | tweet_lists = api.GetLists(screen_name=username) 392 | 393 | if len(names) > 0: 394 | tweet_lists = tweet_lists.filter(lambda x: x.name in names) 395 | 396 | if len(tweet_lists) <= 0: 397 | logger.warn("Not found expected list!") 398 | return 399 | 400 | for tweet_list in tweet_lists: 401 | for status in fetch_iteriable_statuses(lambda max_id: api.GetListTimeline(list_id=tweet_list.id, count=PAGE_SIZE, max_id=max_id)): 402 | if download_media: 403 | save_status(status, destroy=False) 404 | else: 405 | print_sample_entity(status, prefix='Info: ') 406 | 407 | if __name__ == '__main__': 408 | cli() 409 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "214707f4014b2b97e8d118831f2811df5681baf15c325c3dde0e32a646693243" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.7" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "certifi": { 20 | "hashes": [ 21 | "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3", 22 | "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18" 23 | ], 24 | "index": "pypi", 25 | "version": "==2022.12.7" 26 | }, 27 | "charset-normalizer": { 28 | "hashes": [ 29 | "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845", 30 | "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f" 31 | ], 32 | "markers": "python_version >= '3.6'", 33 | "version": "==2.1.1" 34 | }, 35 | "click": { 36 | "hashes": [ 37 | "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a", 38 | "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6" 39 | ], 40 | "index": "pypi", 41 | "version": "==8.0.1" 42 | }, 43 | "coloredlogs": { 44 | "hashes": [ 45 | "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", 46 | "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0" 47 | ], 48 | "index": "pypi", 49 | "version": "==15.0.1" 50 | }, 51 | "configparser": { 52 | "hashes": [ 53 | "sha256:85d5de102cfe6d14a5172676f09d19c465ce63d6019cf0a4ef13385fc535e828", 54 | "sha256:af59f2cdd7efbdd5d111c1976ecd0b82db9066653362f0962d7bf1d3ab89a1fa" 55 | ], 56 | "index": "pypi", 57 | "version": "==5.0.2" 58 | }, 59 | "future": { 60 | "hashes": [ 61 | "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d" 62 | ], 63 | "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", 64 | "version": "==0.18.2" 65 | }, 66 | "futures": { 67 | "hashes": [ 68 | "sha256:3a44f286998ae64f0cc083682fcfec16c406134a81a589a5de445d7bb7c2751b", 69 | "sha256:51ecb45f0add83c806c68e4b06106f90db260585b25ef2abfcda0bd95c0132fd", 70 | "sha256:c4884a65654a7c45435063e14ae85280eb1f111d94e542396717ba9828c4337f" 71 | ], 72 | "index": "pypi", 73 | "version": "==3.1.1" 74 | }, 75 | "humanfriendly": { 76 | "hashes": [ 77 | "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", 78 | "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc" 79 | ], 80 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 81 | "version": "==10.0" 82 | }, 83 | "idna": { 84 | "hashes": [ 85 | "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", 86 | "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2" 87 | ], 88 | "markers": "python_version >= '3.5'", 89 | "version": "==3.4" 90 | }, 91 | "importlib-metadata": { 92 | "hashes": [ 93 | "sha256:d5059f9f1e8e41f80e9c56c2ee58811450c31984dfa625329ffd7c0dad88a73b", 94 | "sha256:d84d17e21670ec07990e1044a99efe8d615d860fd176fc29ef5c306068fda313" 95 | ], 96 | "markers": "python_version < '3.8'", 97 | "version": "==5.1.0" 98 | }, 99 | "oauthlib": { 100 | "hashes": [ 101 | "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca", 102 | "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918" 103 | ], 104 | "markers": "python_version >= '3.6'", 105 | "version": "==3.2.2" 106 | }, 107 | "peewee": { 108 | "hashes": [ 109 | "sha256:9e356b327c2eaec6dd42ecea6f4ddded025793dba906a3d065a0452e726c51a2" 110 | ], 111 | "index": "pypi", 112 | "version": "==3.14.4" 113 | }, 114 | "python-twitter": { 115 | "hashes": [ 116 | "sha256:45855742f1095aa0c8c57b2983eee3b6b7f527462b50a2fa8437a8b398544d90", 117 | "sha256:4a420a6cb6ee9d0c8da457c8a8573f709c2ff2e1a7542e2d38807ebbfe8ebd1d" 118 | ], 119 | "index": "pypi", 120 | "version": "==3.5" 121 | }, 122 | "requests": { 123 | "hashes": [ 124 | "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983", 125 | "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349" 126 | ], 127 | "markers": "python_version >= '3.7' and python_version < '4'", 128 | "version": "==2.28.1" 129 | }, 130 | "requests-oauthlib": { 131 | "hashes": [ 132 | "sha256:2577c501a2fb8d05a304c09d090d6e47c306fef15809d102b327cf8364bddab5", 133 | "sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a" 134 | ], 135 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 136 | "version": "==1.3.1" 137 | }, 138 | "sanitized-package": { 139 | "editable": true, 140 | "path": "." 141 | }, 142 | "twitter-cli": { 143 | "editable": true, 144 | "path": "." 145 | }, 146 | "typing-extensions": { 147 | "hashes": [ 148 | "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa", 149 | "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e" 150 | ], 151 | "markers": "python_version < '3.8'", 152 | "version": "==4.4.0" 153 | }, 154 | "urllib3": { 155 | "hashes": [ 156 | "sha256:47cc05d99aaa09c9e72ed5809b60e7ba354e64b59c9c173ac3018642d8bb41fc", 157 | "sha256:c083dd0dce68dbfbe1129d5271cb90f9447dea7d52097c6e0126120c521ddea8" 158 | ], 159 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", 160 | "version": "==1.26.13" 161 | }, 162 | "zipp": { 163 | "hashes": [ 164 | "sha256:83a28fcb75844b5c0cdaf5aa4003c2d728c77e05f5aeabe8e95e56727005fbaa", 165 | "sha256:a7a22e05929290a67401440b39690ae6563279bced5f314609d9d03798f56766" 166 | ], 167 | "markers": "python_version >= '3.7'", 168 | "version": "==3.11.0" 169 | } 170 | }, 171 | "develop": { 172 | "appdirs": { 173 | "hashes": [ 174 | "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", 175 | "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" 176 | ], 177 | "version": "==1.4.4" 178 | }, 179 | "attrs": { 180 | "hashes": [ 181 | "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6", 182 | "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c" 183 | ], 184 | "markers": "python_version >= '3.5'", 185 | "version": "==22.1.0" 186 | }, 187 | "black": { 188 | "hashes": [ 189 | "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b", 190 | "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539" 191 | ], 192 | "markers": "python_full_version >= '3.6.0'", 193 | "version": "==19.10b0" 194 | }, 195 | "cached-property": { 196 | "hashes": [ 197 | "sha256:9fa5755838eecbb2d234c3aa390bd80fbd3ac6b6869109bfc1b499f7bd89a130", 198 | "sha256:df4f613cf7ad9a588cc381aaf4a512d26265ecebd5eb9e1ba12f1319eb85a6a0" 199 | ], 200 | "version": "==1.5.2" 201 | }, 202 | "cerberus": { 203 | "hashes": [ 204 | "sha256:d1b21b3954b2498d9a79edf16b3170a3ac1021df88d197dc2ce5928ba519237c" 205 | ], 206 | "version": "==1.3.4" 207 | }, 208 | "certifi": { 209 | "hashes": [ 210 | "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3", 211 | "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18" 212 | ], 213 | "index": "pypi", 214 | "version": "==2022.12.7" 215 | }, 216 | "charset-normalizer": { 217 | "hashes": [ 218 | "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845", 219 | "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f" 220 | ], 221 | "markers": "python_version >= '3.6'", 222 | "version": "==2.1.1" 223 | }, 224 | "click": { 225 | "hashes": [ 226 | "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a", 227 | "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6" 228 | ], 229 | "index": "pypi", 230 | "version": "==8.0.1" 231 | }, 232 | "colorama": { 233 | "hashes": [ 234 | "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", 235 | "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" 236 | ], 237 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", 238 | "version": "==0.4.6" 239 | }, 240 | "distlib": { 241 | "hashes": [ 242 | "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46", 243 | "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e" 244 | ], 245 | "version": "==0.3.6" 246 | }, 247 | "idna": { 248 | "hashes": [ 249 | "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", 250 | "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2" 251 | ], 252 | "markers": "python_version >= '3.5'", 253 | "version": "==3.4" 254 | }, 255 | "importlib-metadata": { 256 | "hashes": [ 257 | "sha256:d5059f9f1e8e41f80e9c56c2ee58811450c31984dfa625329ffd7c0dad88a73b", 258 | "sha256:d84d17e21670ec07990e1044a99efe8d615d860fd176fc29ef5c306068fda313" 259 | ], 260 | "markers": "python_version < '3.8'", 261 | "version": "==5.1.0" 262 | }, 263 | "orderedmultidict": { 264 | "hashes": [ 265 | "sha256:04070bbb5e87291cc9bfa51df413677faf2141c73c61d2a5f7b26bea3cd882ad", 266 | "sha256:43c839a17ee3cdd62234c47deca1a8508a3f2ca1d0678a3bf791c87cf84adbf3" 267 | ], 268 | "version": "==1.0.1" 269 | }, 270 | "packaging": { 271 | "hashes": [ 272 | "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5", 273 | "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a" 274 | ], 275 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 276 | "version": "==20.9" 277 | }, 278 | "pathspec": { 279 | "hashes": [ 280 | "sha256:88c2606f2c1e818b978540f73ecc908e13999c6c3a383daf3705652ae79807a5", 281 | "sha256:8f6bf73e5758fd365ef5d58ce09ac7c27d2833a8d7da51712eac6e27e35141b0" 282 | ], 283 | "markers": "python_version >= '3.7'", 284 | "version": "==0.10.2" 285 | }, 286 | "pep517": { 287 | "hashes": [ 288 | "sha256:4ba4446d80aed5b5eac6509ade100bff3e7943a8489de249654a5ae9b33ee35b", 289 | "sha256:ae69927c5c172be1add9203726d4b84cf3ebad1edcd5f71fcdc746e66e829f59" 290 | ], 291 | "markers": "python_full_version >= '3.6.0'", 292 | "version": "==0.13.0" 293 | }, 294 | "pip": { 295 | "hashes": [ 296 | "sha256:65fd48317359f3af8e593943e6ae1506b66325085ea64b706a998c6e83eeaf38", 297 | "sha256:908c78e6bc29b676ede1c4d57981d490cb892eb45cd8c214ab6298125119e077" 298 | ], 299 | "markers": "python_version >= '3.7'", 300 | "version": "==22.3.1" 301 | }, 302 | "pip-shims": { 303 | "hashes": [ 304 | "sha256:089e3586a92b1b8dbbc16b2d2859331dc1c412d3e3dbcd91d80e6b30d73db96c", 305 | "sha256:2ae9f21c0155ca5c37d2734eb5f9a7d98c4c42a122d1ba3eddbacc9d9ea9fbae" 306 | ], 307 | "markers": "python_full_version >= '3.6.0'", 308 | "version": "==0.7.3" 309 | }, 310 | "pipenv-setup": { 311 | "hashes": [ 312 | "sha256:8a439aff7b16e18d7e07702c9186fc5fe86156679eace90e10c2578a43bd7af1", 313 | "sha256:e1bfd55c1152024e762f1c17f6189fcb073166509e7c0228870f7ea160355648" 314 | ], 315 | "index": "pypi", 316 | "version": "==3.1.1" 317 | }, 318 | "pipfile": { 319 | "hashes": [ 320 | "sha256:f7d9f15de8b660986557eb3cc5391aa1a16207ac41bc378d03f414762d36c984" 321 | ], 322 | "version": "==0.0.2" 323 | }, 324 | "platformdirs": { 325 | "hashes": [ 326 | "sha256:1a89a12377800c81983db6be069ec068eee989748799b946cce2a6e80dcc54ca", 327 | "sha256:b46ffafa316e6b83b47489d240ce17173f123a9b9c83282141c3daf26ad9ac2e" 328 | ], 329 | "markers": "python_version >= '3.7'", 330 | "version": "==2.6.0" 331 | }, 332 | "plette": { 333 | "extras": [ 334 | "validation" 335 | ], 336 | "hashes": [ 337 | "sha256:7bf014ff695d8badf5a058227db0f0bd1fa7ffd6e54ad8b851bc36c20a4a7894", 338 | "sha256:e4dc4a05bfce68d6f6b8d1ccd6e8957ecf4ed6707e8d32f7188b6e628526644e" 339 | ], 340 | "markers": "python_version >= '3.7'", 341 | "version": "==0.4.2" 342 | }, 343 | "pyparsing": { 344 | "hashes": [ 345 | "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", 346 | "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" 347 | ], 348 | "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", 349 | "version": "==2.4.7" 350 | }, 351 | "python-dateutil": { 352 | "hashes": [ 353 | "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", 354 | "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" 355 | ], 356 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 357 | "version": "==2.8.2" 358 | }, 359 | "regex": { 360 | "hashes": [ 361 | "sha256:052b670fafbe30966bbe5d025e90b2a491f85dfe5b2583a163b5e60a85a321ad", 362 | "sha256:0653d012b3bf45f194e5e6a41df9258811ac8fc395579fa82958a8b76286bea4", 363 | "sha256:0a069c8483466806ab94ea9068c34b200b8bfc66b6762f45a831c4baaa9e8cdd", 364 | "sha256:0cf0da36a212978be2c2e2e2d04bdff46f850108fccc1851332bcae51c8907cc", 365 | "sha256:131d4be09bea7ce2577f9623e415cab287a3c8e0624f778c1d955ec7c281bd4d", 366 | "sha256:144486e029793a733e43b2e37df16a16df4ceb62102636ff3db6033994711066", 367 | "sha256:1ddf14031a3882f684b8642cb74eea3af93a2be68893901b2b387c5fd92a03ec", 368 | "sha256:1eba476b1b242620c266edf6325b443a2e22b633217a9835a52d8da2b5c051f9", 369 | "sha256:20f61c9944f0be2dc2b75689ba409938c14876c19d02f7585af4460b6a21403e", 370 | "sha256:22960019a842777a9fa5134c2364efaed5fbf9610ddc5c904bd3a400973b0eb8", 371 | "sha256:22e7ebc231d28393dfdc19b185d97e14a0f178bedd78e85aad660e93b646604e", 372 | "sha256:23cbb932cc53a86ebde0fb72e7e645f9a5eec1a5af7aa9ce333e46286caef783", 373 | "sha256:29c04741b9ae13d1e94cf93fca257730b97ce6ea64cfe1eba11cf9ac4e85afb6", 374 | "sha256:2bde29cc44fa81c0a0c8686992c3080b37c488df167a371500b2a43ce9f026d1", 375 | "sha256:2cdc55ca07b4e70dda898d2ab7150ecf17c990076d3acd7a5f3b25cb23a69f1c", 376 | "sha256:370f6e97d02bf2dd20d7468ce4f38e173a124e769762d00beadec3bc2f4b3bc4", 377 | "sha256:395161bbdbd04a8333b9ff9763a05e9ceb4fe210e3c7690f5e68cedd3d65d8e1", 378 | "sha256:44136355e2f5e06bf6b23d337a75386371ba742ffa771440b85bed367c1318d1", 379 | "sha256:44a6c2f6374e0033873e9ed577a54a3602b4f609867794c1a3ebba65e4c93ee7", 380 | "sha256:4919899577ba37f505aaebdf6e7dc812d55e8f097331312db7f1aab18767cce8", 381 | "sha256:4b4b1fe58cd102d75ef0552cf17242705ce0759f9695334a56644ad2d83903fe", 382 | "sha256:4bdd56ee719a8f751cf5a593476a441c4e56c9b64dc1f0f30902858c4ef8771d", 383 | "sha256:4bf41b8b0a80708f7e0384519795e80dcb44d7199a35d52c15cc674d10b3081b", 384 | "sha256:4cac3405d8dda8bc6ed499557625585544dd5cbf32072dcc72b5a176cb1271c8", 385 | "sha256:4fe7fda2fe7c8890d454f2cbc91d6c01baf206fbc96d89a80241a02985118c0c", 386 | "sha256:50921c140561d3db2ab9f5b11c5184846cde686bb5a9dc64cae442926e86f3af", 387 | "sha256:5217c25229b6a85049416a5c1e6451e9060a1edcf988641e309dbe3ab26d3e49", 388 | "sha256:5352bea8a8f84b89d45ccc503f390a6be77917932b1c98c4cdc3565137acc714", 389 | "sha256:542e3e306d1669b25936b64917285cdffcd4f5c6f0247636fec037187bd93542", 390 | "sha256:543883e3496c8b6d58bd036c99486c3c8387c2fc01f7a342b760c1ea3158a318", 391 | "sha256:586b36ebda81e6c1a9c5a5d0bfdc236399ba6595e1397842fd4a45648c30f35e", 392 | "sha256:597f899f4ed42a38df7b0e46714880fb4e19a25c2f66e5c908805466721760f5", 393 | "sha256:5a260758454580f11dd8743fa98319bb046037dfab4f7828008909d0aa5292bc", 394 | "sha256:5aefb84a301327ad115e9d346c8e2760009131d9d4b4c6b213648d02e2abe144", 395 | "sha256:5e6a5567078b3eaed93558842346c9d678e116ab0135e22eb72db8325e90b453", 396 | "sha256:5ff525698de226c0ca743bfa71fc6b378cda2ddcf0d22d7c37b1cc925c9650a5", 397 | "sha256:61edbca89aa3f5ef7ecac8c23d975fe7261c12665f1d90a6b1af527bba86ce61", 398 | "sha256:659175b2144d199560d99a8d13b2228b85e6019b6e09e556209dfb8c37b78a11", 399 | "sha256:6a9a19bea8495bb419dc5d38c4519567781cd8d571c72efc6aa959473d10221a", 400 | "sha256:6b30bddd61d2a3261f025ad0f9ee2586988c6a00c780a2fb0a92cea2aa702c54", 401 | "sha256:6ffd55b5aedc6f25fd8d9f905c9376ca44fcf768673ffb9d160dd6f409bfda73", 402 | "sha256:702d8fc6f25bbf412ee706bd73019da5e44a8400861dfff7ff31eb5b4a1276dc", 403 | "sha256:74bcab50a13960f2a610cdcd066e25f1fd59e23b69637c92ad470784a51b1347", 404 | "sha256:75f591b2055523fc02a4bbe598aa867df9e953255f0b7f7715d2a36a9c30065c", 405 | "sha256:763b64853b0a8f4f9cfb41a76a4a85a9bcda7fdda5cb057016e7706fde928e66", 406 | "sha256:76c598ca73ec73a2f568e2a72ba46c3b6c8690ad9a07092b18e48ceb936e9f0c", 407 | "sha256:78d680ef3e4d405f36f0d6d1ea54e740366f061645930072d39bca16a10d8c93", 408 | "sha256:7b280948d00bd3973c1998f92e22aa3ecb76682e3a4255f33e1020bd32adf443", 409 | "sha256:7db345956ecce0c99b97b042b4ca7326feeec6b75facd8390af73b18e2650ffc", 410 | "sha256:7dbdce0c534bbf52274b94768b3498abdf675a691fec5f751b6057b3030f34c1", 411 | "sha256:7ef6b5942e6bfc5706301a18a62300c60db9af7f6368042227ccb7eeb22d0892", 412 | "sha256:7f5a3ffc731494f1a57bd91c47dc483a1e10048131ffb52d901bfe2beb6102e8", 413 | "sha256:8a45b6514861916c429e6059a55cf7db74670eaed2052a648e3e4d04f070e001", 414 | "sha256:8ad241da7fac963d7573cc67a064c57c58766b62a9a20c452ca1f21050868dfa", 415 | "sha256:8b0886885f7323beea6f552c28bff62cbe0983b9fbb94126531693ea6c5ebb90", 416 | "sha256:8ca88da1bd78990b536c4a7765f719803eb4f8f9971cc22d6ca965c10a7f2c4c", 417 | "sha256:8e0caeff18b96ea90fc0eb6e3bdb2b10ab5b01a95128dfeccb64a7238decf5f0", 418 | "sha256:957403a978e10fb3ca42572a23e6f7badff39aa1ce2f4ade68ee452dc6807692", 419 | "sha256:9af69f6746120998cd9c355e9c3c6aec7dff70d47247188feb4f829502be8ab4", 420 | "sha256:9c94f7cc91ab16b36ba5ce476f1904c91d6c92441f01cd61a8e2729442d6fcf5", 421 | "sha256:a37d51fa9a00d265cf73f3de3930fa9c41548177ba4f0faf76e61d512c774690", 422 | "sha256:a3a98921da9a1bf8457aeee6a551948a83601689e5ecdd736894ea9bbec77e83", 423 | "sha256:a3c1ebd4ed8e76e886507c9eddb1a891673686c813adf889b864a17fafcf6d66", 424 | "sha256:a5f9505efd574d1e5b4a76ac9dd92a12acb2b309551e9aa874c13c11caefbe4f", 425 | "sha256:a8ff454ef0bb061e37df03557afda9d785c905dab15584860f982e88be73015f", 426 | "sha256:a9d0b68ac1743964755ae2d89772c7e6fb0118acd4d0b7464eaf3921c6b49dd4", 427 | "sha256:aa62a07ac93b7cb6b7d0389d8ef57ffc321d78f60c037b19dfa78d6b17c928ee", 428 | "sha256:ac741bf78b9bb432e2d314439275235f41656e189856b11fb4e774d9f7246d81", 429 | "sha256:ae1e96785696b543394a4e3f15f3f225d44f3c55dafe3f206493031419fedf95", 430 | "sha256:b683e5fd7f74fb66e89a1ed16076dbab3f8e9f34c18b1979ded614fe10cdc4d9", 431 | "sha256:b7a8b43ee64ca8f4befa2bea4083f7c52c92864d8518244bfa6e88c751fa8fff", 432 | "sha256:b8e38472739028e5f2c3a4aded0ab7eadc447f0d84f310c7a8bb697ec417229e", 433 | "sha256:bfff48c7bd23c6e2aec6454aaf6edc44444b229e94743b34bdcdda2e35126cf5", 434 | "sha256:c14b63c9d7bab795d17392c7c1f9aaabbffd4cf4387725a0ac69109fb3b550c6", 435 | "sha256:c27cc1e4b197092e50ddbf0118c788d9977f3f8f35bfbbd3e76c1846a3443df7", 436 | "sha256:c28d3309ebd6d6b2cf82969b5179bed5fefe6142c70f354ece94324fa11bf6a1", 437 | "sha256:c670f4773f2f6f1957ff8a3962c7dd12e4be54d05839b216cb7fd70b5a1df394", 438 | "sha256:ce6910b56b700bea7be82c54ddf2e0ed792a577dfaa4a76b9af07d550af435c6", 439 | "sha256:d0213671691e341f6849bf33cd9fad21f7b1cb88b89e024f33370733fec58742", 440 | "sha256:d03fe67b2325cb3f09be029fd5da8df9e6974f0cde2c2ac6a79d2634e791dd57", 441 | "sha256:d0e5af9a9effb88535a472e19169e09ce750c3d442fb222254a276d77808620b", 442 | "sha256:d243b36fbf3d73c25e48014961e83c19c9cc92530516ce3c43050ea6276a2ab7", 443 | "sha256:d26166acf62f731f50bdd885b04b38828436d74e8e362bfcb8df221d868b5d9b", 444 | "sha256:d403d781b0e06d2922435ce3b8d2376579f0c217ae491e273bab8d092727d244", 445 | "sha256:d8716f82502997b3d0895d1c64c3b834181b1eaca28f3f6336a71777e437c2af", 446 | "sha256:e4f781ffedd17b0b834c8731b75cce2639d5a8afe961c1e58ee7f1f20b3af185", 447 | "sha256:e613a98ead2005c4ce037c7b061f2409a1a4e45099edb0ef3200ee26ed2a69a8", 448 | "sha256:ef4163770525257876f10e8ece1cf25b71468316f61451ded1a6f44273eedeb5" 449 | ], 450 | "markers": "python_full_version >= '3.6.0'", 451 | "version": "==2022.10.31" 452 | }, 453 | "requests": { 454 | "hashes": [ 455 | "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983", 456 | "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349" 457 | ], 458 | "markers": "python_version >= '3.7' and python_version < '4'", 459 | "version": "==2.28.1" 460 | }, 461 | "requirementslib": { 462 | "hashes": [ 463 | "sha256:28924cf11a2fa91adb03f8431d80c2a8c3dc386f1c48fb2be9a58e4c39072354", 464 | "sha256:d26ec6ad45e1ffce9532303543996c9c71a99dc65f783908f112e3f2aae7e49c" 465 | ], 466 | "markers": "python_version >= '3.7'", 467 | "version": "==1.6.9" 468 | }, 469 | "setuptools": { 470 | "hashes": [ 471 | "sha256:57f6f22bde4e042978bcd50176fdb381d7c21a9efa4041202288d3737a0c6a54", 472 | "sha256:a7620757bf984b58deaf32fc8a4577a9bbc0850cf92c20e1ce41c38c19e5fb75" 473 | ], 474 | "markers": "python_version >= '3.7'", 475 | "version": "==65.6.3" 476 | }, 477 | "six": { 478 | "hashes": [ 479 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 480 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 481 | ], 482 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 483 | "version": "==1.16.0" 484 | }, 485 | "toml": { 486 | "hashes": [ 487 | "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", 488 | "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" 489 | ], 490 | "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", 491 | "version": "==0.10.2" 492 | }, 493 | "tomli": { 494 | "hashes": [ 495 | "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", 496 | "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" 497 | ], 498 | "markers": "python_version < '3.11'", 499 | "version": "==2.0.1" 500 | }, 501 | "tomlkit": { 502 | "hashes": [ 503 | "sha256:07de26b0d8cfc18f871aec595fda24d95b08fef89d147caa861939f37230bf4b", 504 | "sha256:71b952e5721688937fb02cf9d354dbcf0785066149d2855e44531ebdd2b65d73" 505 | ], 506 | "markers": "python_full_version >= '3.6.0'", 507 | "version": "==0.11.6" 508 | }, 509 | "typed-ast": { 510 | "hashes": [ 511 | "sha256:0261195c2062caf107831e92a76764c81227dae162c4f75192c0d489faf751a2", 512 | "sha256:0fdbcf2fef0ca421a3f5912555804296f0b0960f0418c440f5d6d3abb549f3e1", 513 | "sha256:183afdf0ec5b1b211724dfef3d2cad2d767cbefac291f24d69b00546c1837fb6", 514 | "sha256:211260621ab1cd7324e0798d6be953d00b74e0428382991adfddb352252f1d62", 515 | "sha256:267e3f78697a6c00c689c03db4876dd1efdfea2f251a5ad6555e82a26847b4ac", 516 | "sha256:2efae9db7a8c05ad5547d522e7dbe62c83d838d3906a3716d1478b6c1d61388d", 517 | "sha256:370788a63915e82fd6f212865a596a0fefcbb7d408bbbb13dea723d971ed8bdc", 518 | "sha256:39e21ceb7388e4bb37f4c679d72707ed46c2fbf2a5609b8b8ebc4b067d977df2", 519 | "sha256:3e123d878ba170397916557d31c8f589951e353cc95fb7f24f6bb69adc1a8a97", 520 | "sha256:4879da6c9b73443f97e731b617184a596ac1235fe91f98d279a7af36c796da35", 521 | "sha256:4e964b4ff86550a7a7d56345c7864b18f403f5bd7380edf44a3c1fb4ee7ac6c6", 522 | "sha256:639c5f0b21776605dd6c9dbe592d5228f021404dafd377e2b7ac046b0349b1a1", 523 | "sha256:669dd0c4167f6f2cd9f57041e03c3c2ebf9063d0757dc89f79ba1daa2bfca9d4", 524 | "sha256:6778e1b2f81dfc7bc58e4b259363b83d2e509a65198e85d5700dfae4c6c8ff1c", 525 | "sha256:683407d92dc953c8a7347119596f0b0e6c55eb98ebebd9b23437501b28dcbb8e", 526 | "sha256:79b1e0869db7c830ba6a981d58711c88b6677506e648496b1f64ac7d15633aec", 527 | "sha256:7d5d014b7daa8b0bf2eaef684295acae12b036d79f54178b92a2b6a56f92278f", 528 | "sha256:98f80dee3c03455e92796b58b98ff6ca0b2a6f652120c263efdba4d6c5e58f72", 529 | "sha256:a94d55d142c9265f4ea46fab70977a1944ecae359ae867397757d836ea5a3f47", 530 | "sha256:a9916d2bb8865f973824fb47436fa45e1ebf2efd920f2b9f99342cb7fab93f72", 531 | "sha256:c542eeda69212fa10a7ada75e668876fdec5f856cd3d06829e6aa64ad17c8dfe", 532 | "sha256:cf4afcfac006ece570e32d6fa90ab74a17245b83dfd6655a6f68568098345ff6", 533 | "sha256:ebd9d7f80ccf7a82ac5f88c521115cc55d84e35bf8b446fcd7836eb6b98929a3", 534 | "sha256:ed855bbe3eb3715fca349c80174cfcfd699c2f9de574d40527b8429acae23a66" 535 | ], 536 | "markers": "python_full_version >= '3.6.0'", 537 | "version": "==1.5.4" 538 | }, 539 | "typing-extensions": { 540 | "hashes": [ 541 | "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa", 542 | "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e" 543 | ], 544 | "markers": "python_version < '3.8'", 545 | "version": "==4.4.0" 546 | }, 547 | "urllib3": { 548 | "hashes": [ 549 | "sha256:47cc05d99aaa09c9e72ed5809b60e7ba354e64b59c9c173ac3018642d8bb41fc", 550 | "sha256:c083dd0dce68dbfbe1129d5271cb90f9447dea7d52097c6e0126120c521ddea8" 551 | ], 552 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", 553 | "version": "==1.26.13" 554 | }, 555 | "vistir": { 556 | "hashes": [ 557 | "sha256:116bf6e9b6a3cf72100565ee303483abd821b7f67bba25d57df01a0f49e46bec", 558 | "sha256:2ea487172e10ecbb6445870bb1f36ee8e2e7e46f39d743cbc995e1a15ba831b9" 559 | ], 560 | "markers": "python_version >= '3.7'", 561 | "version": "==0.7.5" 562 | }, 563 | "wheel": { 564 | "hashes": [ 565 | "sha256:965f5259b566725405b05e7cf774052044b1ed30119b5d586b2703aafe8719ac", 566 | "sha256:b60533f3f5d530e971d6737ca6d58681ee434818fab630c83a734bb10c083ce8" 567 | ], 568 | "markers": "python_version >= '3.7'", 569 | "version": "==0.38.4" 570 | }, 571 | "zipp": { 572 | "hashes": [ 573 | "sha256:83a28fcb75844b5c0cdaf5aa4003c2d728c77e05f5aeabe8e95e56727005fbaa", 574 | "sha256:a7a22e05929290a67401440b39690ae6563279bced5f314609d9d03798f56766" 575 | ], 576 | "markers": "python_version >= '3.7'", 577 | "version": "==3.11.0" 578 | } 579 | } 580 | } 581 | --------------------------------------------------------------------------------