├── .gitignore ├── LICENSE ├── README.md └── tiktok-dl.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | *.json 132 | 133 | tiktok-dl-pg.py 134 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Dirk Vingerhoeds 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tiktok-dl 2 | 3 | # NOTICE 4 | 5 | **This script is not fully functional. You will have to acquire the following information through either packet sniffing with a rooted phone, emulator or some other method:** 6 | 7 | - API Server Host (e.g. api-t.tiktok.com) 8 | - device_id (e.g. 68062524349203849239) 9 | - iid (e.g. 681418433854095829) 10 | 11 | **As of recently this script will throw an error most of the time. Consider the code only useful as a base to work off.** 12 | 13 | ![Version 1.0](https://img.shields.io/badge/Version-1.0-orange.svg) 14 | ![Python 2.7, 3.5](https://img.shields.io/badge/Python-2.7%2C%203.5%2B-3776ab.svg) 15 | 16 | Python script to download videos from a TikTok profile without any watermarks. Supports Python 2.7 and 3.5. 17 | 18 | Downloads will be saved to `downloaded/username`. Directories are automatically created if they don't exist yet. 19 | 20 | ### Requirements 21 | 22 | This script requires the `requests` module to be installed. 23 | 24 | ### Usage 25 | 26 | Download videos from profile with the following command: 27 | `python tiktok-dl.py justinbieber` 28 | 29 | ##### Example terminal output 30 | 31 | ``` 32 | $ python3 tiktok-dl.py justinbieber 33 | > User: justinbieber 34 | > SecUid: MS4wLjABAAAAIDvnmw4IM9I6Jk7M0up6Fd4JC_OtGgVCwsy0vu51T9CGyxQwGLEmN_QZY1v2TYY 35 | > Id: 6756702871704192005 36 | > Videos: 32 37 | > Room Id: 0 38 | 39 | > Getting page 1. 40 | 41 | > Downloading new video 6812821765211917574.mp4 (1/32) 42 | > Downloading new video 6812815870713220357.mp4 (2/32) 43 | > Downloading new video 6812814770958257413.mp4 (3/32) 44 | > Downloading new video 6812813216117296390.mp4 (4/32) 45 | > Downloading new video 6812808886446230790.mp4 (5/32) 46 | > Downloading new video 6810055635254660357.mp4 (6/32) 47 | > Downloading new video 6808655310224411909.mp4 (7/32) 48 | > Downloading new video 6808652575945035014.mp4 (8/32) 49 | > Downloading new video 6808651710739926278.mp4 (9/32) 50 | > Downloading new video 6808650981153328389.mp4 (10/32) 51 | > Downloading new video 6808641590442200326.mp4 (11/32) 52 | > Downloading new video 6808571208997686533.mp4 (12/32) 53 | > Downloading new video 6808569761513966854.mp4 (13/32) 54 | > Downloading new video 6808363388104740102.mp4 (14/32) 55 | > Downloading new video 6807852937331723525.mp4 (15/32) 56 | > Downloading new video 6807475852838784261.mp4 (16/32) 57 | > Downloading new video 6806376811073735941.mp4 (17/32) 58 | > Downloading new video 6805321224894385413.mp4 (18/32) 59 | > Downloading new video 6795261949773876486.mp4 (19/32) 60 | > Downloading new video 6795034946617511173.mp4 (20/32) 61 | 62 | > Getting page 2. 63 | 64 | ! Unexpected response by API endpoint, retrying (1). 65 | 66 | > Getting page 2. 67 | 68 | > Downloading new video 6794224096830917893.mp4 (21/32) 69 | > Downloading new video 6780460113833528582.mp4 (22/32) 70 | > Downloading new video 6780099674331270405.mp4 (23/32) 71 | > Downloading new video 6780098336016862469.mp4 (24/32) 72 | > Downloading new video 6780097123401682181.mp4 (25/32) 73 | > Downloading new video 6780096407366847750.mp4 (26/32) 74 | > Downloading new video 6779731367304449285.mp4 (27/32) 75 | > Downloading new video 6779709221039574277.mp4 (28/32) 76 | > Downloading new video 6779707075514666245.mp4 (29/32) 77 | > Downloading new video 6777729787864599813.mp4 (30/32) 78 | > Downloading new video 6777728217194286341.mp4 (31/32) 79 | > Downloading new video 6777723295006592261.mp4 (32/32) 80 | 81 | > Finished downloading (32/32) videos. 82 | ``` 83 | -------------------------------------------------------------------------------- /tiktok-dl.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import time 4 | import json 5 | import random 6 | import socket 7 | import struct 8 | import sys 9 | import requests 10 | 11 | class User(object): 12 | userId = None 13 | userName = None 14 | videoCount = None 15 | roomId = None 16 | 17 | def __init__(self, userId, secUid, userName, videoCount, roomId): 18 | self.userId = userId 19 | self.secUid = secUid 20 | self.userName = userName 21 | self.videoCount = videoCount 22 | self.roomId = roomId 23 | 24 | 25 | class Video(object): 26 | awemeId = None 27 | url = None 28 | timestamp = None 29 | createTime = None 30 | author = None 31 | 32 | def __init__(self, awemeId, url, createTime, description, author): 33 | self.awemeId = awemeId 34 | self.url = url 35 | self.createTime = createTime 36 | self.description = description 37 | self.author = author 38 | 39 | def generatePublicIp(): 40 | return str(socket.inet_ntoa(struct.pack('>I', random.randint(1, 0xffffffff)))) 41 | 42 | 43 | def awemeRequest(request_path, type="get"): 44 | headers = { 45 | "User-Agent": "okhttp", 46 | "X-Forwarded-For": generatePublicIp() 47 | } 48 | url = "http://api-t.tiktok.com" \ 49 | + request_path \ 50 | + "&device_id=YOUR_DEVICE_ID" \ 51 | + "&iid=YOUR_INSTALL_ID" \ 52 | + "&version_code=100303" \ 53 | + "&build_number=10.3.3" \ 54 | + "&version_name=10.3.3" \ 55 | + "&aid=1233" \ 56 | + "&app_name=musical_ly" \ 57 | + "&app_language=en" \ 58 | + "&channel=googleplay" \ 59 | + "&device_platform=android" \ 60 | + "&device_brand=Google" \ 61 | + "&device_type=Pixel" \ 62 | + "&os_version=9.0.0" 63 | if type == "get": 64 | resp = requests.get(url, headers=headers) 65 | if type == "post": 66 | resp = requests.post(url, headers=headers) 67 | return resp 68 | 69 | 70 | def awemeDownload(video): 71 | headers = { 72 | "User-Agent": "okhttp" 73 | } 74 | filepath = os.path.join("downloaded", video.author, 75 | (str(video.awemeId) + ".mp4")) 76 | 77 | if not os.path.isfile(filepath): 78 | resp = requests.get(video.url, headers=headers) 79 | if resp.status_code == 200: 80 | open(filepath, 'wb').write(resp.content) 81 | return True 82 | else: 83 | return False 84 | 85 | 86 | def getUserByUsername(username, retry=1): 87 | try: 88 | endpoint = "/aweme/v1/discover/search/" \ 89 | + "?keyword=" + username \ 90 | + "&cursor=0" \ 91 | + "&count=10" \ 92 | + "&type=1" \ 93 | + "&hot_search=0" \ 94 | + "&search_source=discover" 95 | response = awemeRequest(endpoint, type="post").json() 96 | #open('user.json', 'w').write(json.dumps(response)) 97 | for userObj in response.get("user_list"): 98 | userInfo = userObj.get("user_info") 99 | if userInfo.get("unique_id") == username: 100 | userId = userInfo.get('uid') 101 | videoCount = userInfo.get('aweme_count') 102 | roomId = userInfo.get('room_id') 103 | secUid = userInfo.get('sec_uid') 104 | user = User(userId=userId, secUid=secUid, userName=username, 105 | videoCount=videoCount, roomId=roomId) 106 | return user 107 | print("> User not found, exiting.") 108 | sys.exit(0) 109 | except (TypeError, AttributeError): 110 | if retry < 4: 111 | print("! Unexpected response by API endpoint, retrying ({:d}).".format(retry)) 112 | print() 113 | time.sleep(0.5) 114 | retry += 1 115 | return getUserByUsername(username=username, retry=retry) 116 | else: 117 | print("! Maximum retries exceeded, exiting.") 118 | sys.exit(1) 119 | 120 | 121 | def getUserVideos(uid, secUid, cursor=0, hasmore=True, page=1, count=0, total=0, retry=1): 122 | try: 123 | print("> Getting page {:d}.".format(page)) 124 | print() 125 | endpoint = "/aweme/v1/aweme/post/?" \ 126 | + "user_id=" + uid \ 127 | + "&count=20" \ 128 | + "&max_cursor=" + str(cursor) 129 | response = awemeRequest(endpoint, type="get").json() 130 | #open('feed.json', 'w').write(json.dumps(response)) 131 | hasmore = response.get('has_more', True) 132 | cursor = response.get("max_cursor", cursor) 133 | #open('videos.json', 'w').write(json.dumps(response)) 134 | for videoObj in response.get("aweme_list"): 135 | awemeId = videoObj.get("aweme_id") 136 | url = videoObj.get("video").get("play_addr").get('url_list')[0] 137 | createTime = videoObj.get("create_time") 138 | description = videoObj.get("desc") 139 | userName = videoObj.get("author").get('unique_id') 140 | video = Video(awemeId=awemeId, url=url, createTime=createTime, 141 | description=description, author=userName) 142 | count += 1 143 | if awemeDownload(video=video): 144 | print("> Downloading new video {:s}.mp4 ({:d}/{:d})".format(video.awemeId, count, total)) 145 | else: 146 | print("! Skipped existing file {:s}.mp4 ({:d}/{:d})".format(video.awemeId, count, total)) 147 | 148 | if hasmore: 149 | print() 150 | page += 1 151 | getUserVideos(uid=uid, secUid=secUid, cursor=cursor, hasmore=hasmore, 152 | page=page, count=count, total=total, retry=1) 153 | else: 154 | print() 155 | print("> Finished downloading ({:d}/{:d}) videos.".format(count, total)) 156 | if count < total: 157 | print("! {:d} videos are missing, they are either private or have\n! otherwise not been returned by TikTok's API for unknown reasons.".format((total-count))) 158 | except (TypeError, AttributeError): 159 | if retry <= 4: 160 | print("! Unexpected response by API endpoint, retrying ({:d}).".format(retry)) 161 | print() 162 | time.sleep(0.5) 163 | retry += 1 164 | getUserVideos(uid=uid, secUid=secUid, cursor=cursor, 165 | hasmore=hasmore, page=page, count=count, total=total, retry=retry) 166 | else: 167 | print("! Maximum retries exceeded, exiting.") 168 | sys.exit(1) 169 | except KeyboardInterrupt: 170 | print() 171 | print("! Downloading was cancelled by the user, exiting.") 172 | sys.exit(0) 173 | 174 | 175 | def ensureDownloadDir(user): 176 | path = os.path.join(os.getcwd(), "downloaded", user) 177 | if not os.path.exists(path): 178 | os.makedirs(path) 179 | 180 | def main(): 181 | try: 182 | usernameToDownload = sys.argv[1] 183 | except IndexError: 184 | print("> No username argument was given, exiting.") 185 | sys.exit(1) 186 | 187 | if usernameToDownload: 188 | user = getUserByUsername(username=usernameToDownload, retry=1) 189 | if user: 190 | print("> User: {:s}".format(user.userName)) 191 | print("> SecUid: {:s}".format(user.secUid)) 192 | print("> Id: {:s}".format(user.userId)) 193 | print("> Videos: {:d}".format(user.videoCount)) 194 | print("> Room Id: {:d}".format(user.roomId)) 195 | print("") 196 | ensureDownloadDir(user=user.userName) 197 | getUserVideos(uid=user.userId, secUid=user.secUid, cursor=0, hasmore=True, 198 | page=1, count=0, total=user.videoCount, retry=1) 199 | else: 200 | print("> No user object received, exiting.") 201 | 202 | 203 | if __name__ == "__main__": 204 | main() 205 | --------------------------------------------------------------------------------