├── .devcontainer └── devcontainer.json ├── .gitattributes ├── .github ├── .documentation.yml ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── installation-help.md ├── dependabot.yml ├── stale.yml └── workflows │ ├── docs.yml │ ├── package-test.yml │ └── python-publish.yml ├── .gitignore ├── .sphinx ├── .gitignore ├── Makefile ├── README.md ├── TikTokApi.api.rst ├── TikTokApi.rst ├── TikTokApi.stealth.rst ├── api │ ├── comment.rst │ ├── hashtag.rst │ ├── search.rst │ ├── sound.rst │ ├── trending.rst │ ├── user.rst │ └── video.rst ├── conf.py ├── index.rst ├── make.bat └── modules.rst ├── CITATION.cff ├── Dockerfile ├── LICENSE ├── LICENSE.txt ├── MANIFEST ├── README.md ├── TikTokApi ├── __init__.py ├── api │ ├── __init__.py │ ├── comment.py │ ├── hashtag.py │ ├── playlist.py │ ├── search.py │ ├── sound.py │ ├── trending.py │ ├── user.py │ └── video.py ├── exceptions.py ├── helpers.py ├── stealth │ ├── __init__.py │ ├── js │ │ ├── __init__.py │ │ ├── chrome_app.py │ │ ├── chrome_csi.py │ │ ├── chrome_hairline.py │ │ ├── chrome_load_times.py │ │ ├── chrome_runtime.py │ │ ├── generate_magic_arrays.py │ │ ├── iframe_contentWindow.py │ │ ├── media_codecs.py │ │ ├── navigator_hardwareConcurrency.py │ │ ├── navigator_languages.py │ │ ├── navigator_permissions.py │ │ ├── navigator_platform.py │ │ ├── navigator_plugins.py │ │ ├── navigator_userAgent.py │ │ ├── navigator_vendor.py │ │ ├── utils.py │ │ ├── webgl_vendor.py │ │ └── window_outerdimensions.py │ └── stealth.py └── tiktok.py ├── _config.yml ├── examples ├── comment_example.py ├── hashtag_example.py ├── playlist_example.py ├── search_example.py ├── sound_example.py ├── trending_example.py ├── user_example.py └── video_example.py ├── imgs ├── EnsembleData.png ├── tikapi.png ├── tiktok_captcha_solver.png └── webshare.png ├── requirements.txt ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── test_comments.py ├── test_hashtag.py ├── test_integration.py ├── test_playlist.py ├── test_search.py ├── test_sound.py ├── test_trending.py ├── test_user.py └── test_video.py /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "TikTokAPI", 3 | "image": "mcr.microsoft.com/devcontainers/universal", 4 | "postStartCommand": "python3 -m pip install -r requirements.txt && python3 -m playwright install", 5 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/.documentation.yml: -------------------------------------------------------------------------------- 1 | - .sphinx -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: ['https://www.paypal.me/dteather'] 2 | github: davidteather -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG] - Your Error Here" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | Fill Out the template :) 10 | 11 | **Describe the bug** 12 | 13 | A clear and concise description of what the bug is. 14 | 15 | **The buggy code** 16 | 17 | Please add any relevant code that is giving you unexpected results. 18 | 19 | Preferably the smallest amount of code to reproduce the issue. 20 | 21 | 22 | **SET LOGGING LEVEL TO INFO BEFORE POSTING CODE OUTPUT** 23 | ```py 24 | import logging 25 | TikTokApi(logging_level=logging.INFO) # SETS LOGGING_LEVEL TO INFO 26 | # Hopefully the info level will help you debug or at least someone else on the issue 27 | ``` 28 | 29 | ```py 30 | # Code Goes Here 31 | ``` 32 | 33 | **Expected behavior** 34 | 35 | A clear and concise description of what you expected to happen. 36 | 37 | **Error Trace (if any)** 38 | 39 | Put the error trace below if there's any error thrown. 40 | ``` 41 | # Error Trace Here 42 | ``` 43 | 44 | **Desktop (please complete the following information):** 45 | - OS: [e.g. Windows 10] 46 | - TikTokApi Version [e.g. 5.0.0] - if out of date upgrade before posting an issue 47 | 48 | **Additional context** 49 | 50 | Add any other context about the problem here. 51 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE_REQUEST] - What you want here" 5 | labels: feature_request 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/installation-help.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Installation Help 3 | about: If you're having trouble getting this to run for the first time please use 4 | this template 5 | title: "[INSTALLATION] - Your error here" 6 | labels: installation_help 7 | assignees: '' 8 | 9 | --- 10 | 11 | Please first check the closed issues on GitHub for people with similar problems to you. 12 | If you'd like more instant help from the community consider joining the [discord](https://discord.gg/yyPhbfma6f) 13 | 14 | **Describe the error** 15 | 16 | Put the error trace here. 17 | 18 | **The buggy code** 19 | 20 | Please insert the code that is throwing errors or is giving you weird unexpected results. 21 | 22 | **Error Trace (if any)** 23 | 24 | Put the error trace below if there's any error thrown. 25 | ``` 26 | # Error Trace Here 27 | ``` 28 | 29 | **Desktop (please complete the following information):** 30 | - OS: [e.g. Windows 10] 31 | - TikTokApi Version [e.g. 3.3.1] - if out of date upgrade before posting an issue 32 | 33 | **Additional context** 34 | 35 | Put what you have already tried. Your problem is probably in the closed issues tab already. 36 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | - severe 10 | # Label to use when marking an issue as stale 11 | staleLabel: stale 12 | # Comment to post when marking an issue as stale. Set to `false` to disable 13 | markComment: > 14 | This issue has been automatically marked as stale because it has not had 15 | recent activity. It will be closed if no further activity occurs. Thank you 16 | for your contributions. 17 | # Comment to post when closing a stale issue. Set to `false` to disable 18 | closeComment: false 19 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy Sphinx Documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build-and-deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout Code 13 | uses: actions/checkout@v2 14 | 15 | - name: Set up Python 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: '3.x' 19 | 20 | - name: Install Dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install -r requirements.txt 24 | pip install sphinx sphinx_rtd_theme myst-parser 25 | 26 | - name: Build Documentation 27 | run: | 28 | cd .sphinx 29 | make html 30 | 31 | - name: Deploy to GitHub Pages 32 | uses: peaceiris/actions-gh-pages@v3 33 | with: 34 | github_token: ${{ secrets.GITHUB_TOKEN }} 35 | publish_dir: ./.sphinx/docs/html 36 | -------------------------------------------------------------------------------- /.github/workflows/package-test.yml: -------------------------------------------------------------------------------- 1 | name: TikTokApi CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | - nightly 10 | - "releases/*" 11 | 12 | jobs: 13 | Unit-Tests: 14 | timeout-minutes: 30 15 | runs-on: ${{ matrix.os }} 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | os: [macos-latest] 20 | python-version: ["3.9", "3.11"] 21 | steps: 22 | - uses: actions/checkout@v2 23 | - uses: microsoft/playwright-github-action@v1 24 | - name: Install Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v2 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | - name: Setup dependencies 29 | run: | 30 | python -m pip install --upgrade pip 31 | pip install -r requirements.txt 32 | pip install pytest 33 | pip install pytest-asyncio 34 | python -m playwright install 35 | 36 | - name: Run Tests 37 | env: 38 | ms_token: ${{ secrets.ms_token }} 39 | run: pytest tests 40 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | workflow_dispatch: 10 | 11 | jobs: 12 | deploy: 13 | 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Set up Python 19 | uses: actions/setup-python@v2 20 | with: 21 | python-version: '3.x' 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install setuptools wheel twine 26 | 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install setuptools wheel twine 31 | 32 | - name: Build and publish 33 | env: 34 | TWINE_USERNAME: __token__ 35 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 36 | run: | 37 | python setup.py sdist bdist_wheel 38 | twine upload dist/* 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/tiktok.cpython-37.pyc 2 | bmp.log 3 | geckodriver.log 4 | server.log 5 | TikTok-Api/bmp.log 6 | TikTok-Api/geckodriver.log 7 | TikTok-Api/server.log 8 | browsermob-proxy/* 9 | myScripts/* 10 | test.py 11 | debug.log 12 | res.html 13 | tmp/* 14 | dist/* 15 | *.egg-info 16 | tmp/ 17 | tmp 18 | .pytest_cache/* 19 | test.mp4 20 | test.txt 21 | .pytest_cache/* 22 | tests/__pycache__/* 23 | *.pyc 24 | acrawl.js 25 | test2.py 26 | build 27 | MANIFEST 28 | src 29 | .vscode 30 | .env 31 | /.idea/ 32 | /TikTok-Api.iml 33 | -------------------------------------------------------------------------------- /.sphinx/.gitignore: -------------------------------------------------------------------------------- 1 | docs -------------------------------------------------------------------------------- /.sphinx/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = docs 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | 22 | serve: 23 | @sphinx-autobuild . docs -------------------------------------------------------------------------------- /.sphinx/README.md: -------------------------------------------------------------------------------- 1 | The docs for TikTokAPI 2 | 3 | Run locally with: make serve 4 | 5 | Build with: make html -------------------------------------------------------------------------------- /.sphinx/TikTokApi.api.rst: -------------------------------------------------------------------------------- 1 | .. _tiktok-api-docs: 2 | TikTokApi.api package 3 | ===================== 4 | 5 | This package wraps each entity from TikTok into a class with high-level methods to interact with the TikTok object. 6 | 7 | Comment 8 | =============================== 9 | 10 | .. include:: api/comment.rst 11 | 12 | User 13 | ================ 14 | 15 | .. include:: api/user.rst 16 | 17 | Trending 18 | ================= 19 | 20 | .. include:: api/trending.rst 21 | 22 | Search 23 | ====== 24 | 25 | .. include:: api/search.rst 26 | 27 | Hashtags 28 | =================== 29 | 30 | .. include:: api/hashtag.rst 31 | 32 | Sound 33 | ================ 34 | 35 | .. include:: api/sound.rst 36 | 37 | Video 38 | ================ 39 | 40 | .. include:: api/video.rst -------------------------------------------------------------------------------- /.sphinx/TikTokApi.rst: -------------------------------------------------------------------------------- 1 | .. _tiktok-api-main: 2 | TikTokApi package 3 | ================= 4 | 5 | Subpackages 6 | =========== 7 | 8 | .. toctree:: 9 | :maxdepth: 1 10 | 11 | TikTokApi.api 12 | 13 | TikTokApi Main Class 14 | =========================== 15 | 16 | This is the main TikTokApi module. 17 | It contains the TikTokApi class which is the main class of the package. 18 | 19 | .. automodule:: TikTokApi.tiktok 20 | :members: 21 | :undoc-members: 22 | :show-inheritance: 23 | 24 | TikTokApi.exceptions module 25 | =========================== 26 | 27 | .. automodule:: TikTokApi.exceptions 28 | :members: 29 | :undoc-members: 30 | :show-inheritance: 31 | 32 | TikTokApi.helpers module 33 | =========================== 34 | 35 | .. automodule:: TikTokApi.helpers 36 | :members: 37 | :undoc-members: 38 | :show-inheritance: -------------------------------------------------------------------------------- /.sphinx/TikTokApi.stealth.rst: -------------------------------------------------------------------------------- 1 | TikTokApi.stealth package 2 | ========================= 3 | 4 | This package is a modified version of `playwright_stealth <https://github.com/AtuboDad/playwright_stealth>`_, used to mask the browser as a real browser. 5 | 6 | This package is used to prevent TikTok from detecting that you are using a bot. 7 | 8 | You probably shouldn't be interacting with this package directly, but rather through the :class:`TikTokApi` class. 9 | 10 | Here's some stealth resources that I always keep forgetting, so I'm putting them here: 11 | 12 | * `SannySoft Bot Detector <https://bot.sannysoft.com/>`_ 13 | * `Are you headless? <https://arh.antoinevastel.com/bots/areyouheadless>`_ 14 | * `DataDome Detector <https://antoinevastel.com/bots/datadome>`_ 15 | * `Am I Unique? <https://amiunique.org/>`_ (cool because they split up the distribution of each field, I don't really use much) 16 | 17 | Bot Detection Resources: 18 | 19 | * `FingerprintJS on GitHub <https://github.com/fingerprintjs/fingerprintjs>`_ 20 | 21 | TikTokApi.stealth.stealth 22 | ================================ 23 | 24 | .. automodule:: TikTokApi.stealth.stealth 25 | :members: 26 | :undoc-members: 27 | :show-inheritance: -------------------------------------------------------------------------------- /.sphinx/api/comment.rst: -------------------------------------------------------------------------------- 1 | TikTokApi.api.comment module 2 | ---------------------------- 3 | 4 | .. automodule:: TikTokApi.api.comment 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /.sphinx/api/hashtag.rst: -------------------------------------------------------------------------------- 1 | TikTokApi.api.hashtag module 2 | ---------------------------- 3 | 4 | .. automodule:: TikTokApi.api.hashtag 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /.sphinx/api/search.rst: -------------------------------------------------------------------------------- 1 | TikTokApi.api.search module 2 | ---------------------------- 3 | 4 | .. automodule:: TikTokApi.api.search 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /.sphinx/api/sound.rst: -------------------------------------------------------------------------------- 1 | TikTokApi.api.sound module 2 | ---------------------------- 3 | 4 | .. automodule:: TikTokApi.api.sound 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /.sphinx/api/trending.rst: -------------------------------------------------------------------------------- 1 | TikTokApi.api.trending module 2 | ----------------------------- 3 | 4 | .. automodule:: TikTokApi.api.trending 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /.sphinx/api/user.rst: -------------------------------------------------------------------------------- 1 | TikTokApi.api.user module 2 | ---------------------------- 3 | 4 | .. automodule:: TikTokApi.api.user 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /.sphinx/api/video.rst: -------------------------------------------------------------------------------- 1 | TikTokApi.api.video module 2 | ---------------------------- 3 | 4 | .. automodule:: TikTokApi.api.video 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /.sphinx/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/main/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/main/usage/configuration.html#project-information 8 | 9 | import sys 10 | import os 11 | 12 | sys.path.insert(0, os.path.abspath("../.")) 13 | sys.path.insert(0, os.path.abspath("..")) 14 | sys.path.insert(0, os.path.abspath("../..")) 15 | 16 | project = "TikTokAPI" 17 | copyright = "2023, David Teather" 18 | author = "David Teather" 19 | release = "v7.1.0" 20 | 21 | # -- General configuration --------------------------------------------------- 22 | # https://www.sphinx-doc.org/en/main/usage/configuration.html#general-configuration 23 | 24 | extensions = [ 25 | "sphinx.ext.autodoc", 26 | "sphinx.ext.viewcode", 27 | "sphinx.ext.todo", 28 | "sphinx.ext.githubpages", 29 | "sphinx.ext.napoleon", 30 | "myst_parser", 31 | ] 32 | 33 | templates_path = ["_templates"] 34 | exclude_patterns = ["docs", "Thumbs.db", ".DS_Store"] 35 | 36 | napoleon_google_docstring = True 37 | 38 | 39 | # -- Options for HTML output ------------------------------------------------- 40 | # https://www.sphinx-doc.org/en/main/usage/configuration.html#options-for-html-output 41 | 42 | html_theme = "sphinx_rtd_theme" 43 | html_baseurl = "https://davidteather.github.io/TikTok-Api/" 44 | 45 | source_suffix = {".rst": "restructuredtext", ".md": "markdown"} 46 | -------------------------------------------------------------------------------- /.sphinx/index.rst: -------------------------------------------------------------------------------- 1 | TikTokAPI Quick Start 2 | ===================================== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: Contents: 7 | 8 | View the above tree for detailed documentation on the package. 9 | 10 | .. MD -> .. mdinclude:: ../README.md 11 | 12 | .. include:: ../README.md 13 | :parser: myst_parser.sphinx_ 14 | 15 | TikTokAPI Full Documentation 16 | ============================ 17 | * :ref:`tiktok-api-main` (main parent class) 18 | * :ref:`tiktok-api-docs` (classes for each TikTok entity) 19 | 20 | * :ref:`modindex` 21 | * :ref:`search` 22 | * :ref:`genindex` 23 | -------------------------------------------------------------------------------- /.sphinx/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=docs 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /.sphinx/modules.rst: -------------------------------------------------------------------------------- 1 | TikTokApi 2 | ========= 3 | .. toctree:: 4 | :maxdepth: 4 5 | 6 | TikTokApi 7 | -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | cff-version: 1.2.0 2 | authors: 3 | - family-names: "Teather" 4 | given-names: "David" 5 | orcid: "https://orcid.org/0000-0002-9467-4676" 6 | title: "TikTokAPI" 7 | url: "https://github.com/davidteather/tiktok-api" 8 | version: 7.1.0 9 | date-released: 2025-04-13 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/playwright:focal 2 | 3 | RUN apt-get update && apt-get install -y python3-pip 4 | COPY . . 5 | RUN pip3 install TikTokApi 6 | RUN python3 -m playwright install 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 David Teather 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. -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 David Teather 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. -------------------------------------------------------------------------------- /MANIFEST: -------------------------------------------------------------------------------- 1 | # file GENERATED by distutils, do NOT edit 2 | setup.cfg 3 | setup.py 4 | TikTokApi\__init__.py 5 | TikTokApi\tiktok.py 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Unofficial TikTok API in Python 2 | 3 | This is an unofficial api wrapper for TikTok.com in python. With this api you are able to call most trending and fetch specific user information as well as much more. 4 | 5 | [](https://zenodo.org/badge/latestdoi/188710490) [](https://www.linkedin.com/in/davidteather/) [](https://github.com/sponsors/davidteather) [](https://github.com/davidteather/TikTok-Api/releases) [](https://github.com/davidteather/TikTok-Api/blob/main/LICENSE) [](https://pypi.org/project/TikTokApi/)  [](https://discord.gg/yyPhbfma6f) 6 | 7 | This api is designed to **retrieve data** TikTok. It **can not be used post or upload** content to TikTok on the behalf of a user. It has **no support for any user-authenticated routes**, if you can't access it while being logged out on their website you can't access it here. 8 | 9 | ## Sponsors 10 | 11 | These sponsors have paid to be placed here or are my own affiliate links which I may earn a commission from, and beyond that I do not have any affiliation with them. The TikTokAPI package will always be free and open-source. If you wish to be a sponsor of this project check out my [GitHub sponsors page](https://github.com/sponsors/davidteather). 12 | 13 | <div align="center"> 14 | <a href="https://tikapi.io/?ref=davidteather" target="_blank"> 15 | <img src="https://raw.githubusercontent.com/davidteather/TikTok-Api/main/imgs/tikapi.png" width="100" alt="TikApi"> 16 | <div> 17 | <b>TikAPI</b> is a paid TikTok API service providing a full out-of-the-box solution, making life easier for developers — trusted by 500+ companies. 18 | </div> 19 | </a> 20 | <br> 21 | <a href="https://www.ensembledata.com/?utm_source=github&utm_medium=githubpage&utm_campaign=david_thea_github&utm_id=david_thea_github" target="_blank"> 22 | <img src="https://raw.githubusercontent.com/davidteather/TikTok-Api/main/imgs/EnsembleData.png" width="100" alt="Ensemble Data"> 23 | <b></b> 24 | <div> 25 | <b>EnsembleData</b> is the leading API provider for scraping Tiktok, Instagram, Youtube, and more. <br> Trusted by the major influencer marketing and social media listening platforms. 26 | </div> 27 | </a> 28 | <br> 29 | <a href="https://www.sadcaptcha.com?ref=davidteather" target="_blank"> 30 | <img src="https://raw.githubusercontent.com/davidteather/TikTok-Api/main/imgs/tiktok_captcha_solver.png" width="100" alt="TikTok Captcha Solver"> 31 | <b></b> 32 | <div> 33 | <b>TikTok Captcha Solver: </b> Bypass any TikTok captcha in just two lines of code.<br> Scale your TikTok automation and get unblocked with SadCaptcha. 34 | </div> 35 | </a> 36 | <br> 37 | <a href="https://www.webshare.io/?referral_code=3x5812idzzzp" target="_blank"> 38 | <img src="https://raw.githubusercontent.com/davidteather/TikTok-Api/main/imgs/webshare.png" width="100" alt="TikTok Captcha Solver"> 39 | <b></b> 40 | <div> 41 | <b>Cheap, Reliable Proxies: </b> Supercharge your web scraping with fast, reliable proxies. Try 10 free datacenter proxies today! 42 | </div> 43 | </a> 44 | </div> 45 | 46 | ## Table of Contents 47 | 48 | - [Documentation](#documentation) 49 | - [Getting Started](#getting-started) 50 | - [How to Support The Project](#how-to-support-the-project) 51 | - [Installing](#installing) 52 | - [Common Issues](#common-issues) 53 | - [Quick Start Guide](#quick-start-guide) 54 | - [Examples](https://github.com/davidteather/TikTok-Api/tree/main/examples) 55 | 56 | [**Upgrading from V5 to V6**](#upgrading-from-v5-to-v6) 57 | 58 | ## Documentation 59 | 60 | You can find the full documentation [here](https://davidteather.github.io/TikTok-Api) 61 | 62 | ## Getting Started 63 | 64 | To get started using this API follow the instructions below. 65 | 66 | **Note:** If you want to learn how to web scrape websites check my [free and open-source course for learning everything web scraping](https://github.com/davidteather/everything-web-scraping) 67 | 68 | ### How to Support The Project 69 | 70 | - Star the repo 😎 71 | - Consider [sponsoring](https://github.com/sponsors/davidteather) me on GitHub 72 | - Send me an email or a [LinkedIn](https://www.linkedin.com/in/davidteather/) message telling me what you're using the API for, I really like hearing what people are using it for. 73 | - Submit PRs for issues :) 74 | 75 | ### Installing 76 | 77 | **Note:** Installation requires python3.9+ 78 | 79 | If you run into an issue please check the closed issues on the github, although feel free to re-open a new issue if you find an issue that's been closed for a few months. The codebase can and does run into similar issues as it has before, because TikTok changes things up. 80 | 81 | ```sh 82 | pip install TikTokApi 83 | python -m playwright install 84 | ``` 85 | 86 | If you would prefer a video walk through of setting up this package [YouTube video](https://www.youtube.com/watch?v=-uCt1x8kINQ) just for that. (is a version out of date, installation is the same though) 87 | 88 | If you want a quick video to listen for [TikTok Live](https://www.youtube.com/watch?v=307ijmA3_lc) events in python. 89 | 90 | #### Docker Installation 91 | 92 | Clone this repository onto a local machine (or just the Dockerfile since it installs TikTokApi from pip) then run the following commands. 93 | 94 | ```sh 95 | docker pull mcr.microsoft.com/playwright:focal 96 | docker build . -t tiktokapi:latest 97 | docker run -v TikTokApi --rm tiktokapi:latest python3 your_script.py 98 | ``` 99 | 100 | **Note** this assumes your script is named your_script.py and lives in the root of this directory. 101 | 102 | ### Common Issues 103 | 104 | - **EmptyResponseException** - this means TikTok is blocking the request and detects you're a bot. This can be a problem with your setup or the library itself 105 | - you may need a proxy to successfuly scrape TikTok, I've made a [web scraping lesson](https://github.com/davidteather/everything-web-scraping/tree/main/002-proxies) explaining the differences of "tiers" of proxies, I've personally had success with [webshare's residential proxies](https://www.webshare.io/?referral_code=3x5812idzzzp) (affiliate link), but you might have success on their free data center IPs or a cheaper competitor. 106 | 107 | - **Browser Has no Attribute** - make sure you ran `python3 -m playwright install`, if your error persists try the [playwright-python](https://github.com/microsoft/playwright-python) quickstart guide and diagnose issues from there. 108 | 109 | - **API methods returning Coroutine** - many of the API's methods are async so make sure your program awaits them for proper functionality 110 | 111 | ## Quick Start Guide 112 | 113 | Here's a quick bit of code to get the most recent trending videos on TikTok. There's more examples in the [examples](https://github.com/davidteather/TikTok-Api/tree/main/examples) directory. 114 | 115 | **Note:** If you want to learn how to web scrape websites check my [free and open-source course for web scraping](https://github.com/davidteather/web-scraping-with-reverse-engineering) 116 | 117 | ```py 118 | from TikTokApi import TikTokApi 119 | import asyncio 120 | import os 121 | 122 | ms_token = os.environ.get("ms_token", None) # get your own ms_token from your cookies on tiktok.com 123 | 124 | async def trending_videos(): 125 | async with TikTokApi() as api: 126 | await api.create_sessions(ms_tokens=[ms_token], num_sessions=1, sleep_after=3, browser=os.getenv("TIKTOK_BROWSER", "chromium")) 127 | async for video in api.trending.videos(count=30): 128 | print(video) 129 | print(video.as_dict) 130 | 131 | if __name__ == "__main__": 132 | asyncio.run(trending_videos()) 133 | ``` 134 | 135 | To directly run the example scripts from the repository root, use the `-m` option on python. 136 | 137 | ```sh 138 | python -m examples.trending_example 139 | ``` 140 | 141 | You can access the full data dictionary the object was created from with `.as_dict`. On a video this may look like 142 | [this](https://gist.github.com/davidteather/7c30780bbc30772ba11ec9e0b909e99d). TikTok changes their structure from time to time so it's worth investigating the structure of the dictionary when you use this package. -------------------------------------------------------------------------------- /TikTokApi/__init__.py: -------------------------------------------------------------------------------- 1 | from TikTokApi.tiktok import TikTokApi, TikTokPlaywrightSession 2 | -------------------------------------------------------------------------------- /TikTokApi/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidteather/TikTok-Api/62a8cfa8ab7bb5bbdd0f8c8b13e84731fff7ac75/TikTokApi/api/__init__.py -------------------------------------------------------------------------------- /TikTokApi/api/comment.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import ClassVar, AsyncIterator, Optional 4 | from typing import TYPE_CHECKING, ClassVar, Optional 5 | 6 | from TikTokApi.exceptions import InvalidResponseException 7 | 8 | if TYPE_CHECKING: 9 | from ..tiktok import TikTokApi 10 | from .user import User 11 | 12 | 13 | class Comment: 14 | """ 15 | A TikTok Comment. 16 | 17 | Example Usage 18 | .. code-block:: python 19 | 20 | for comment in video.comments: 21 | print(comment.text) 22 | print(comment.as_dict) 23 | """ 24 | 25 | parent: ClassVar[TikTokApi] 26 | 27 | id: str 28 | """The id of the comment""" 29 | author: ClassVar[User] 30 | """The author of the comment""" 31 | text: str 32 | """The contents of the comment""" 33 | likes_count: int 34 | """The amount of likes of the comment""" 35 | as_dict: dict 36 | """The raw data associated with this comment""" 37 | 38 | def __init__(self, data: Optional[dict] = None): 39 | if data is not None: 40 | self.as_dict = data 41 | self.__extract_from_data() 42 | 43 | def __extract_from_data(self): 44 | data = self.as_dict 45 | self.id = self.as_dict["cid"] 46 | self.text = self.as_dict["text"] 47 | 48 | usr = self.as_dict["user"] 49 | self.author = self.parent.user( 50 | user_id=usr["uid"], username=usr["unique_id"], sec_uid=usr["sec_uid"] 51 | ) 52 | self.likes_count = self.as_dict["digg_count"] 53 | 54 | async def replies(self, count=20, cursor=0, **kwargs) -> AsyncIterator[Comment]: 55 | found = 0 56 | 57 | while found < count: 58 | params = { 59 | "count": 20, 60 | "cursor": cursor, 61 | "item_id": self.author.user_id, 62 | "comment_id": self.id, 63 | } 64 | 65 | resp = await self.parent.make_request( 66 | url="https://www.tiktok.com/api/comment/list/reply/", 67 | params=params, 68 | headers=kwargs.get("headers"), 69 | session_index=kwargs.get("session_index"), 70 | ) 71 | 72 | if resp is None: 73 | raise InvalidResponseException( 74 | resp, "TikTok returned an invalid response." 75 | ) 76 | 77 | for comment in resp.get("comments", []): 78 | yield self.parent.comment(data=comment) 79 | found += 1 80 | 81 | if not resp.get("has_more", False): 82 | return 83 | 84 | cursor = resp.get("cursor") 85 | 86 | def __repr__(self): 87 | return self.__str__() 88 | 89 | def __str__(self): 90 | id = getattr(self, "id", None) 91 | text = getattr(self, "text", None) 92 | return f"TikTokApi.comment(comment_id='{id}', text='{text}')" 93 | -------------------------------------------------------------------------------- /TikTokApi/api/hashtag.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from ..exceptions import * 3 | 4 | from typing import TYPE_CHECKING, ClassVar, AsyncIterator, Optional 5 | 6 | if TYPE_CHECKING: 7 | from ..tiktok import TikTokApi 8 | from .video import Video 9 | 10 | 11 | class Hashtag: 12 | """ 13 | A TikTok Hashtag/Challenge. 14 | 15 | Example Usage 16 | .. code-block:: python 17 | 18 | hashtag = api.hashtag(name='funny') 19 | async for video in hashtag.videos(): 20 | print(video.id) 21 | """ 22 | 23 | parent: ClassVar[TikTokApi] 24 | 25 | id: Optional[str] 26 | """The ID of the hashtag""" 27 | name: Optional[str] 28 | """The name of the hashtag (omiting the #)""" 29 | as_dict: dict 30 | """The raw data associated with this hashtag.""" 31 | 32 | def __init__( 33 | self, 34 | name: Optional[str] = None, 35 | id: Optional[str] = None, 36 | data: Optional[dict] = None, 37 | ): 38 | """ 39 | You must provide the name or id of the hashtag. 40 | """ 41 | 42 | if name is not None: 43 | self.name = name 44 | if id is not None: 45 | self.id = id 46 | 47 | if data is not None: 48 | self.as_dict = data 49 | self.__extract_from_data() 50 | 51 | async def info(self, **kwargs) -> dict: 52 | """ 53 | Returns all information sent by TikTok related to this hashtag. 54 | 55 | Example Usage 56 | .. code-block:: python 57 | 58 | hashtag = api.hashtag(name='funny') 59 | hashtag_data = await hashtag.info() 60 | """ 61 | if not self.name: 62 | raise TypeError( 63 | "You must provide the name when creating this class to use this method." 64 | ) 65 | 66 | url_params = { 67 | "challengeName": self.name, 68 | "msToken": kwargs.get("ms_token"), 69 | } 70 | 71 | resp = await self.parent.make_request( 72 | url="https://www.tiktok.com/api/challenge/detail/", 73 | params=url_params, 74 | headers=kwargs.get("headers"), 75 | session_index=kwargs.get("session_index"), 76 | ) 77 | 78 | if resp is None: 79 | raise InvalidResponseException(resp, "TikTok returned an invalid response.") 80 | 81 | self.as_dict = resp 82 | self.__extract_from_data() 83 | return resp 84 | 85 | async def videos(self, count=30, cursor=0, **kwargs) -> AsyncIterator[Video]: 86 | """ 87 | Returns TikTok videos that have this hashtag in the caption. 88 | 89 | Args: 90 | count (int): The amount of videos you want returned. 91 | cursor (int): The the offset of videos from 0 you want to get. 92 | 93 | Returns: 94 | async iterator/generator: Yields TikTokApi.video objects. 95 | 96 | Raises: 97 | InvalidResponseException: If TikTok returns an invalid response, or one we don't understand. 98 | 99 | Example Usage: 100 | .. code-block:: python 101 | 102 | async for video in api.hashtag(name='funny').videos(): 103 | # do something 104 | """ 105 | 106 | id = getattr(self, "id", None) 107 | if id is None: 108 | await self.info(**kwargs) 109 | 110 | found = 0 111 | while found < count: 112 | params = { 113 | "challengeID": self.id, 114 | "count": 35, 115 | "cursor": cursor, 116 | } 117 | 118 | resp = await self.parent.make_request( 119 | url="https://www.tiktok.com/api/challenge/item_list/", 120 | params=params, 121 | headers=kwargs.get("headers"), 122 | session_index=kwargs.get("session_index"), 123 | ) 124 | 125 | if resp is None: 126 | raise InvalidResponseException( 127 | resp, "TikTok returned an invalid response." 128 | ) 129 | 130 | for video in resp.get("itemList", []): 131 | yield self.parent.video(data=video) 132 | found += 1 133 | 134 | if not resp.get("hasMore", False): 135 | return 136 | 137 | cursor = resp.get("cursor") 138 | 139 | def __extract_from_data(self): 140 | data = self.as_dict 141 | keys = data.keys() 142 | 143 | if "title" in keys: 144 | self.id = data["id"] 145 | self.name = data["title"] 146 | 147 | if "challengeInfo" in keys: 148 | if "challenge" in data["challengeInfo"]: 149 | self.id = data["challengeInfo"]["challenge"]["id"] 150 | self.name = data["challengeInfo"]["challenge"]["title"] 151 | self.split_name = data["challengeInfo"]["challenge"].get("splitTitle") 152 | 153 | if "stats" in data["challengeInfo"]: 154 | self.stats = data["challengeInfo"]["stats"] 155 | 156 | id = getattr(self, "id", None) 157 | name = getattr(self, "name", None) 158 | if None in (id, name): 159 | Hashtag.parent.logger.error( 160 | f"Failed to create Hashtag with data: {data}\nwhich has keys {data.keys()}" 161 | ) 162 | 163 | def __repr__(self): 164 | return self.__str__() 165 | 166 | def __str__(self): 167 | return f"TikTokApi.hashtag(id='{getattr(self, 'id', None)}', name='{getattr(self, 'name', None)}')" 168 | -------------------------------------------------------------------------------- /TikTokApi/api/playlist.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import TYPE_CHECKING, ClassVar, AsyncIterator, Optional 3 | from ..exceptions import InvalidResponseException 4 | 5 | if TYPE_CHECKING: 6 | from ..tiktok import TikTokApi 7 | from .video import Video 8 | from .user import User 9 | 10 | 11 | class Playlist: 12 | """ 13 | A TikTok video playlist. 14 | 15 | Example Usage: 16 | .. code-block:: python 17 | 18 | playlist = api.playlist(id='7426714779919797038') 19 | """ 20 | 21 | parent: ClassVar[TikTokApi] 22 | 23 | id: Optional[str] 24 | """The ID of the playlist.""" 25 | name: Optional[str] 26 | """The name of the playlist.""" 27 | video_count: Optional[int] 28 | """The video count of the playlist.""" 29 | creator: Optional[User] 30 | """The creator of the playlist.""" 31 | cover_url: Optional[str] 32 | """The cover URL of the playlist.""" 33 | as_dict: dict 34 | """The raw data associated with this Playlist.""" 35 | 36 | def __init__( 37 | self, 38 | id: Optional[str] = None, 39 | data: Optional[dict] = None, 40 | ): 41 | """ 42 | You must provide the playlist id or playlist data otherwise this 43 | will not function correctly. 44 | """ 45 | 46 | if id is None and data.get("id") is None: 47 | raise TypeError("You must provide id parameter.") 48 | 49 | self.id = id 50 | 51 | if data is not None: 52 | self.as_dict = data 53 | self.__extract_from_data() 54 | 55 | async def info(self, **kwargs) -> dict: 56 | """ 57 | Returns a dictionary of information associated with this Playlist. 58 | 59 | Returns: 60 | dict: A dictionary of information associated with this Playlist. 61 | 62 | Raises: 63 | InvalidResponseException: If TikTok returns an invalid response, or one we don't understand. 64 | 65 | Example Usage: 66 | .. code-block:: python 67 | 68 | user_data = await api.playlist(id='7426714779919797038').info() 69 | """ 70 | 71 | id = getattr(self, "id", None) 72 | if not id: 73 | raise TypeError( 74 | "You must provide the playlist id when creating this class to use this method." 75 | ) 76 | 77 | url_params = { 78 | "mixId": id, 79 | "msToken": kwargs.get("ms_token"), 80 | } 81 | 82 | resp = await self.parent.make_request( 83 | url="https://www.tiktok.com/api/mix/detail/", 84 | params=url_params, 85 | headers=kwargs.get("headers"), 86 | session_index=kwargs.get("session_index"), 87 | ) 88 | 89 | if resp is None: 90 | raise InvalidResponseException(resp, "TikTok returned an invalid response.") 91 | 92 | self.as_dict = resp["mixInfo"] 93 | self.__extract_from_data() 94 | return resp 95 | 96 | async def videos(self, count=30, cursor=0, **kwargs) -> AsyncIterator[Video]: 97 | """ 98 | Returns an iterator of videos in this User's playlist. 99 | 100 | Returns: 101 | Iterator[dict]: An iterator of videos in this User's playlist. 102 | 103 | Raises: 104 | InvalidResponseException: If TikTok returns an invalid response, or one we don't understand. 105 | 106 | Example Usage: 107 | .. code-block:: python 108 | 109 | playlist_videos = await api.playlist(id='7426714779919797038').videos() 110 | """ 111 | id = getattr(self, "id", None) 112 | if id is None or id == "": 113 | await self.info(**kwargs) 114 | 115 | found = 0 116 | while found < count: 117 | params = { 118 | "mixId": id, 119 | "count": min(count, 30), 120 | "cursor": cursor, 121 | } 122 | 123 | resp = await self.parent.make_request( 124 | url="https://www.tiktok.com/api/mix/item_list/", 125 | params=params, 126 | headers=kwargs.get("headers"), 127 | session_index=kwargs.get("session_index"), 128 | ) 129 | 130 | if resp is None: 131 | raise InvalidResponseException( 132 | resp, "TikTok returned an invalid response." 133 | ) 134 | 135 | for video in resp.get("itemList", []): 136 | yield self.parent.video(data=video) 137 | found += 1 138 | 139 | if not resp.get("hasMore", False): 140 | return 141 | 142 | cursor = resp.get("cursor") 143 | 144 | def __extract_from_data(self): 145 | data = self.as_dict 146 | keys = data.keys() 147 | 148 | if "mixInfo" in keys: 149 | data = data["mixInfo"] 150 | 151 | self.id = data.get("id", None) or data.get("mixId", None) 152 | self.name = data.get("name", None) or data.get("mixName", None) 153 | self.video_count = data.get("videoCount", None) 154 | self.creator = self.parent.user(data=data.get("creator", {})) 155 | self.cover_url = data.get("cover", None) 156 | 157 | if None in [self.id, self.name, self.video_count, self.creator, self.cover_url]: 158 | User.parent.logger.error( 159 | f"Failed to create Playlist with data: {data}\nwhich has keys {data.keys()}" 160 | ) 161 | 162 | def __repr__(self): 163 | return self.__str__() 164 | 165 | def __str__(self): 166 | id = getattr(self, "id", None) 167 | return f"TikTokApi.playlist(id='{id}'')" 168 | -------------------------------------------------------------------------------- /TikTokApi/api/search.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from urllib.parse import urlencode 3 | from typing import TYPE_CHECKING, AsyncIterator 4 | from .user import User 5 | from ..exceptions import InvalidResponseException 6 | 7 | if TYPE_CHECKING: 8 | from ..tiktok import TikTokApi 9 | 10 | 11 | class Search: 12 | """Contains static methods about searching TikTok for a phrase.""" 13 | 14 | parent: TikTokApi 15 | 16 | @staticmethod 17 | async def users(search_term, count=10, cursor=0, **kwargs) -> AsyncIterator[User]: 18 | """ 19 | Searches for users. 20 | 21 | Note: Your ms_token needs to have done a search before for this to work. 22 | 23 | Args: 24 | search_term (str): The phrase you want to search for. 25 | count (int): The amount of users you want returned. 26 | 27 | Returns: 28 | async iterator/generator: Yields TikTokApi.user objects. 29 | 30 | Raises: 31 | InvalidResponseException: If TikTok returns an invalid response, or one we don't understand. 32 | 33 | Example Usage: 34 | .. code-block:: python 35 | 36 | async for user in api.search.users('david teather'): 37 | # do something 38 | """ 39 | async for user in Search.search_type( 40 | search_term, "user", count=count, cursor=cursor, **kwargs 41 | ): 42 | yield user 43 | 44 | @staticmethod 45 | async def search_type( 46 | search_term, obj_type, count=10, cursor=0, **kwargs 47 | ) -> AsyncIterator: 48 | """ 49 | Searches for a specific type of object. But you shouldn't use this directly, use the other methods. 50 | 51 | Note: Your ms_token needs to have done a search before for this to work. 52 | Note: Currently only supports searching for users, other endpoints require auth. 53 | 54 | Args: 55 | search_term (str): The phrase you want to search for. 56 | obj_type (str): The type of object you want to search for (user) 57 | count (int): The amount of users you want returned. 58 | cursor (int): The the offset of users from 0 you want to get. 59 | 60 | Returns: 61 | async iterator/generator: Yields TikTokApi.video objects. 62 | 63 | Raises: 64 | InvalidResponseException: If TikTok returns an invalid response, or one we don't understand. 65 | 66 | Example Usage: 67 | .. code-block:: python 68 | 69 | async for user in api.search.search_type('david teather', 'user'): 70 | # do something 71 | """ 72 | found = 0 73 | while found < count: 74 | params = { 75 | "keyword": search_term, 76 | "cursor": cursor, 77 | "from_page": "search", 78 | "web_search_code": """{"tiktok":{"client_params_x":{"search_engine":{"ies_mt_user_live_video_card_use_libra":1,"mt_search_general_user_live_card":1}},"search_server":{}}}""", 79 | } 80 | 81 | resp = await Search.parent.make_request( 82 | url=f"https://www.tiktok.com/api/search/{obj_type}/full/", 83 | params=params, 84 | headers=kwargs.get("headers"), 85 | session_index=kwargs.get("session_index"), 86 | ) 87 | 88 | if resp is None: 89 | raise InvalidResponseException( 90 | resp, "TikTok returned an invalid response." 91 | ) 92 | 93 | if obj_type == "user": 94 | for user in resp.get("user_list", []): 95 | sec_uid = user.get("user_info").get("sec_uid") 96 | uid = user.get("user_info").get("user_id") 97 | username = user.get("user_info").get("unique_id") 98 | yield Search.parent.user( 99 | sec_uid=sec_uid, user_id=uid, username=username 100 | ) 101 | found += 1 102 | 103 | if not resp.get("has_more", False): 104 | return 105 | 106 | cursor = resp.get("cursor") 107 | -------------------------------------------------------------------------------- /TikTokApi/api/sound.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from ..exceptions import * 3 | from typing import TYPE_CHECKING, ClassVar, Iterator, Optional 4 | 5 | if TYPE_CHECKING: 6 | from ..tiktok import TikTokApi 7 | from .user import User 8 | from .video import Video 9 | 10 | 11 | class Sound: 12 | """ 13 | A TikTok Sound/Music/Song. 14 | 15 | Example Usage 16 | .. code-block:: python 17 | 18 | song = api.song(id='7016547803243022337') 19 | """ 20 | 21 | parent: ClassVar[TikTokApi] 22 | 23 | id: str 24 | """TikTok's ID for the sound""" 25 | title: Optional[str] 26 | """The title of the song.""" 27 | author: Optional[User] 28 | """The author of the song (if it exists)""" 29 | duration: Optional[int] 30 | """The duration of the song in seconds.""" 31 | original: Optional[bool] 32 | """Whether the song is original or not.""" 33 | 34 | def __init__(self, id: Optional[str] = None, data: Optional[str] = None): 35 | """ 36 | You must provide the id of the sound or it will not work. 37 | """ 38 | if data is not None: 39 | self.as_dict = data 40 | self.__extract_from_data() 41 | elif id is None: 42 | raise TypeError("You must provide id parameter.") 43 | else: 44 | self.id = id 45 | 46 | async def info(self, **kwargs) -> dict: 47 | """ 48 | Returns all information sent by TikTok related to this sound. 49 | 50 | Returns: 51 | dict: The raw data returned by TikTok. 52 | 53 | Raises: 54 | InvalidResponseException: If TikTok returns an invalid response, or one we don't understand. 55 | 56 | Example Usage: 57 | .. code-block:: python 58 | 59 | sound_info = await api.sound(id='7016547803243022337').info() 60 | """ 61 | 62 | id = getattr(self, "id", None) 63 | if not id: 64 | raise TypeError( 65 | "You must provide the id when creating this class to use this method." 66 | ) 67 | 68 | url_params = { 69 | "msToken": kwargs.get("ms_token"), 70 | "musicId": id, 71 | } 72 | 73 | resp = await self.parent.make_request( 74 | url="https://www.tiktok.com/api/music/detail/", 75 | params=url_params, 76 | headers=kwargs.get("headers"), 77 | session_index=kwargs.get("session_index"), 78 | ) 79 | 80 | if resp is None: 81 | raise InvalidResponseException(resp, "TikTok returned an invalid response.") 82 | 83 | self.as_dict = resp 84 | self.__extract_from_data() 85 | return resp 86 | 87 | async def videos(self, count=30, cursor=0, **kwargs) -> AsyncIterator[Video]: 88 | """ 89 | Returns Video objects of videos created with this sound. 90 | 91 | Args: 92 | count (int): The amount of videos you want returned. 93 | cursor (int): The the offset of videos from 0 you want to get. 94 | 95 | Returns: 96 | async iterator/generator: Yields TikTokApi.video objects. 97 | 98 | Raises: 99 | InvalidResponseException: If TikTok returns an invalid response, or one we don't understand. 100 | 101 | Example Usage: 102 | .. code-block:: python 103 | 104 | async for video in api.sound(id='7016547803243022337').videos(): 105 | # do something 106 | """ 107 | id = getattr(self, "id", None) 108 | if id is None: 109 | raise TypeError( 110 | "You must provide the id when creating this class to use this method." 111 | ) 112 | 113 | found = 0 114 | while found < count: 115 | params = { 116 | "musicID": id, 117 | "count": 30, 118 | "cursor": cursor, 119 | } 120 | 121 | resp = await self.parent.make_request( 122 | url="https://www.tiktok.com/api/music/item_list/", 123 | params=params, 124 | headers=kwargs.get("headers"), 125 | session_index=kwargs.get("session_index"), 126 | ) 127 | 128 | if resp is None: 129 | raise InvalidResponseException( 130 | resp, "TikTok returned an invalid response." 131 | ) 132 | 133 | for video in resp.get("itemList", []): 134 | yield self.parent.video(data=video) 135 | found += 1 136 | 137 | if not resp.get("hasMore", False): 138 | return 139 | 140 | cursor = resp.get("cursor") 141 | 142 | def __extract_from_data(self): 143 | data = self.as_dict 144 | keys = data.keys() 145 | 146 | if "musicInfo" in keys: 147 | author = data.get("musicInfo").get("author") 148 | if isinstance(author, dict): 149 | self.author = self.parent.user(data=author) 150 | elif isinstance(author, str): 151 | self.author = self.parent.user(username=author) 152 | 153 | if data.get("musicInfo").get("music"): 154 | self.title = data.get("musicInfo").get("music").get("title") 155 | self.id = data.get("musicInfo").get("music").get("id") 156 | self.original = data.get("musicInfo").get("music").get("original") 157 | self.play_url = data.get("musicInfo").get("music").get("playUrl") 158 | self.cover_large = data.get("musicInfo").get("music").get("coverLarge") 159 | self.duration = data.get("musicInfo").get("music").get("duration") 160 | 161 | if "music" in keys: 162 | self.title = data.get("music").get("title") 163 | self.id = data.get("music").get("id") 164 | self.original = data.get("music").get("original") 165 | self.play_url = data.get("music").get("playUrl") 166 | self.cover_large = data.get("music").get("coverLarge") 167 | self.duration = data.get("music").get("duration") 168 | 169 | if "stats" in keys: 170 | self.stats = data.get("stats") 171 | 172 | if getattr(self, "id", None) is None: 173 | Sound.parent.logger.error(f"Failed to create Sound with data: {data}\n") 174 | 175 | def __repr__(self): 176 | return self.__str__() 177 | 178 | def __str__(self): 179 | return f"TikTokApi.sound(id='{getattr(self, 'id', None)}')" 180 | -------------------------------------------------------------------------------- /TikTokApi/api/trending.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from ..exceptions import InvalidResponseException 3 | from .video import Video 4 | 5 | from typing import TYPE_CHECKING, AsyncIterator 6 | 7 | if TYPE_CHECKING: 8 | from ..tiktok import TikTokApi 9 | 10 | 11 | class Trending: 12 | """Contains static methods related to trending objects on TikTok.""" 13 | 14 | parent: TikTokApi 15 | 16 | @staticmethod 17 | async def videos(count=30, **kwargs) -> AsyncIterator[Video]: 18 | """ 19 | Returns Videos that are trending on TikTok. 20 | 21 | Args: 22 | count (int): The amount of videos you want returned. 23 | 24 | Returns: 25 | async iterator/generator: Yields TikTokApi.video objects. 26 | 27 | Raises: 28 | InvalidResponseException: If TikTok returns an invalid response, or one we don't understand. 29 | 30 | Example Usage: 31 | .. code-block:: python 32 | 33 | async for video in api.trending.videos(): 34 | # do something 35 | """ 36 | found = 0 37 | while found < count: 38 | params = { 39 | "from_page": "fyp", 40 | "count": count, 41 | } 42 | 43 | resp = await Trending.parent.make_request( 44 | url="https://www.tiktok.com/api/recommend/item_list/", 45 | params=params, 46 | headers=kwargs.get("headers"), 47 | session_index=kwargs.get("session_index"), 48 | ) 49 | 50 | if resp is None: 51 | raise InvalidResponseException( 52 | resp, "TikTok returned an invalid response." 53 | ) 54 | 55 | for video in resp.get("itemList", []): 56 | yield Trending.parent.video(data=video) 57 | found += 1 58 | 59 | if not resp.get("hasMore", False): 60 | return 61 | -------------------------------------------------------------------------------- /TikTokApi/api/user.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import TYPE_CHECKING, ClassVar, AsyncIterator, Optional 3 | from ..exceptions import InvalidResponseException 4 | 5 | if TYPE_CHECKING: 6 | from ..tiktok import TikTokApi 7 | from .video import Video 8 | from .playlist import Playlist 9 | 10 | 11 | class User: 12 | """ 13 | A TikTok User. 14 | 15 | Example Usage: 16 | .. code-block:: python 17 | 18 | user = api.user(username='therock') 19 | """ 20 | 21 | parent: ClassVar[TikTokApi] 22 | 23 | user_id: str 24 | """The ID of the user.""" 25 | sec_uid: str 26 | """The sec UID of the user.""" 27 | username: str 28 | """The username of the user.""" 29 | as_dict: dict 30 | """The raw data associated with this user.""" 31 | 32 | def __init__( 33 | self, 34 | username: Optional[str] = None, 35 | user_id: Optional[str] = None, 36 | sec_uid: Optional[str] = None, 37 | data: Optional[dict] = None, 38 | ): 39 | """ 40 | You must provide the username or (user_id and sec_uid) otherwise this 41 | will not function correctly. 42 | """ 43 | self.__update_id_sec_uid_username(user_id, sec_uid, username) 44 | if data is not None: 45 | self.as_dict = data 46 | self.__extract_from_data() 47 | 48 | async def info(self, **kwargs) -> dict: 49 | """ 50 | Returns a dictionary of information associated with this User. 51 | 52 | Returns: 53 | dict: A dictionary of information associated with this User. 54 | 55 | Raises: 56 | InvalidResponseException: If TikTok returns an invalid response, or one we don't understand. 57 | 58 | Example Usage: 59 | .. code-block:: python 60 | 61 | user_data = await api.user(username='therock').info() 62 | """ 63 | 64 | username = getattr(self, "username", None) 65 | if not username: 66 | raise TypeError( 67 | "You must provide the username when creating this class to use this method." 68 | ) 69 | 70 | sec_uid = getattr(self, "sec_uid", None) 71 | url_params = { 72 | "secUid": sec_uid if sec_uid is not None else "", 73 | "uniqueId": username, 74 | "msToken": kwargs.get("ms_token"), 75 | } 76 | 77 | resp = await self.parent.make_request( 78 | url="https://www.tiktok.com/api/user/detail/", 79 | params=url_params, 80 | headers=kwargs.get("headers"), 81 | session_index=kwargs.get("session_index"), 82 | ) 83 | 84 | if resp is None: 85 | raise InvalidResponseException(resp, "TikTok returned an invalid response.") 86 | 87 | self.as_dict = resp 88 | self.__extract_from_data() 89 | return resp 90 | 91 | async def playlists(self, count=20, cursor=0, **kwargs) -> AsyncIterator[Playlist]: 92 | """ 93 | Returns a user's playlists. 94 | 95 | Returns: 96 | async iterator/generator: Yields TikTokApi.playlist objects. 97 | 98 | Raises: 99 | InvalidResponseException: If TikTok returns an invalid response, or one we don't understand. 100 | 101 | Example Usage: 102 | .. code-block:: python 103 | 104 | async for playlist in await api.user(username='therock').playlists(): 105 | # do something 106 | """ 107 | 108 | sec_uid = getattr(self, "sec_uid", None) 109 | if sec_uid is None or sec_uid == "": 110 | await self.info(**kwargs) 111 | found = 0 112 | 113 | while found < count: 114 | params = { 115 | "secUid": self.sec_uid, 116 | "count": min(count, 20), 117 | "cursor": cursor, 118 | } 119 | 120 | resp = await self.parent.make_request( 121 | url="https://www.tiktok.com/api/user/playlist", 122 | params=params, 123 | headers=kwargs.get("headers"), 124 | session_index=kwargs.get("session_index"), 125 | ) 126 | 127 | if resp is None: 128 | raise InvalidResponseException(resp, "TikTok returned an invalid response.") 129 | 130 | for playlist in resp.get("playList", []): 131 | yield self.parent.playlist(data=playlist) 132 | found += 1 133 | 134 | if not resp.get("hasMore", False): 135 | return 136 | 137 | cursor = resp.get("cursor") 138 | 139 | 140 | async def videos(self, count=30, cursor=0, **kwargs) -> AsyncIterator[Video]: 141 | """ 142 | Returns a user's videos. 143 | 144 | Args: 145 | count (int): The amount of videos you want returned. 146 | cursor (int): The the offset of videos from 0 you want to get. 147 | 148 | Returns: 149 | async iterator/generator: Yields TikTokApi.video objects. 150 | 151 | Raises: 152 | InvalidResponseException: If TikTok returns an invalid response, or one we don't understand. 153 | 154 | Example Usage: 155 | .. code-block:: python 156 | 157 | async for video in api.user(username="davidteathercodes").videos(): 158 | # do something 159 | """ 160 | sec_uid = getattr(self, "sec_uid", None) 161 | if sec_uid is None or sec_uid == "": 162 | await self.info(**kwargs) 163 | 164 | found = 0 165 | while found < count: 166 | params = { 167 | "secUid": self.sec_uid, 168 | "count": 35, 169 | "cursor": cursor, 170 | } 171 | 172 | resp = await self.parent.make_request( 173 | url="https://www.tiktok.com/api/post/item_list/", 174 | params=params, 175 | headers=kwargs.get("headers"), 176 | session_index=kwargs.get("session_index"), 177 | ) 178 | 179 | if resp is None: 180 | raise InvalidResponseException( 181 | resp, "TikTok returned an invalid response." 182 | ) 183 | 184 | for video in resp.get("itemList", []): 185 | yield self.parent.video(data=video) 186 | found += 1 187 | 188 | if not resp.get("hasMore", False): 189 | return 190 | 191 | cursor = resp.get("cursor") 192 | 193 | async def liked( 194 | self, count: int = 30, cursor: int = 0, **kwargs 195 | ) -> AsyncIterator[Video]: 196 | """ 197 | Returns a user's liked posts if public. 198 | 199 | Args: 200 | count (int): The amount of recent likes you want returned. 201 | cursor (int): The the offset of likes from 0 you want to get. 202 | 203 | Returns: 204 | async iterator/generator: Yields TikTokApi.video objects. 205 | 206 | Raises: 207 | InvalidResponseException: If TikTok returns an invalid response, the user's likes are private, or one we don't understand. 208 | 209 | Example Usage: 210 | .. code-block:: python 211 | 212 | async for like in api.user(username="davidteathercodes").liked(): 213 | # do something 214 | """ 215 | sec_uid = getattr(self, "sec_uid", None) 216 | if sec_uid is None or sec_uid == "": 217 | await self.info(**kwargs) 218 | 219 | found = 0 220 | while found < count: 221 | params = { 222 | "secUid": self.sec_uid, 223 | "count": 35, 224 | "cursor": cursor, 225 | } 226 | 227 | resp = await self.parent.make_request( 228 | url="https://www.tiktok.com/api/favorite/item_list", 229 | params=params, 230 | headers=kwargs.get("headers"), 231 | session_index=kwargs.get("session_index"), 232 | ) 233 | 234 | if resp is None: 235 | raise InvalidResponseException( 236 | resp, "TikTok returned an invalid response." 237 | ) 238 | 239 | for video in resp.get("itemList", []): 240 | yield self.parent.video(data=video) 241 | found += 1 242 | 243 | if not resp.get("hasMore", False): 244 | return 245 | 246 | cursor = resp.get("cursor") 247 | 248 | def __extract_from_data(self): 249 | data = self.as_dict 250 | keys = data.keys() 251 | 252 | if "userInfo" in keys: 253 | self.__update_id_sec_uid_username( 254 | data["userInfo"]["user"]["id"], 255 | data["userInfo"]["user"]["secUid"], 256 | data["userInfo"]["user"]["uniqueId"], 257 | ) 258 | else: 259 | self.__update_id_sec_uid_username( 260 | data["id"], 261 | data["secUid"], 262 | data["uniqueId"], 263 | ) 264 | 265 | if None in (self.username, self.user_id, self.sec_uid): 266 | User.parent.logger.error( 267 | f"Failed to create User with data: {data}\nwhich has keys {data.keys()}" 268 | ) 269 | 270 | def __update_id_sec_uid_username(self, id, sec_uid, username): 271 | self.user_id = id 272 | self.sec_uid = sec_uid 273 | self.username = username 274 | 275 | def __repr__(self): 276 | return self.__str__() 277 | 278 | def __str__(self): 279 | username = getattr(self, "username", None) 280 | user_id = getattr(self, "user_id", None) 281 | sec_uid = getattr(self, "sec_uid", None) 282 | return f"TikTokApi.user(username='{username}', user_id='{user_id}', sec_uid='{sec_uid}')" 283 | -------------------------------------------------------------------------------- /TikTokApi/api/video.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from ..helpers import extract_video_id_from_url, requests_cookie_to_playwright_cookie 3 | from typing import TYPE_CHECKING, ClassVar, AsyncIterator, Optional 4 | from datetime import datetime 5 | import requests 6 | from ..exceptions import InvalidResponseException 7 | import json 8 | import httpx 9 | from typing import Union, AsyncIterator 10 | 11 | if TYPE_CHECKING: 12 | from ..tiktok import TikTokApi 13 | from .user import User 14 | from .sound import Sound 15 | from .hashtag import Hashtag 16 | from .comment import Comment 17 | 18 | 19 | class Video: 20 | """ 21 | A TikTok Video class 22 | 23 | Example Usage 24 | ```py 25 | video = api.video(id='7041997751718137094') 26 | ``` 27 | """ 28 | 29 | parent: ClassVar[TikTokApi] 30 | 31 | id: Optional[str] 32 | """TikTok's ID of the Video""" 33 | url: Optional[str] 34 | """The URL of the Video""" 35 | create_time: Optional[datetime] 36 | """The creation time of the Video""" 37 | stats: Optional[dict] 38 | """TikTok's stats of the Video""" 39 | author: Optional[User] 40 | """The User who created the Video""" 41 | sound: Optional[Sound] 42 | """The Sound that is associated with the Video""" 43 | hashtags: Optional[list[Hashtag]] 44 | """A List of Hashtags on the Video""" 45 | as_dict: dict 46 | """The raw data associated with this Video.""" 47 | 48 | def __init__( 49 | self, 50 | id: Optional[str] = None, 51 | url: Optional[str] = None, 52 | data: Optional[dict] = None, 53 | **kwargs, 54 | ): 55 | """ 56 | You must provide the id or a valid url, else this will fail. 57 | """ 58 | self.id = id 59 | self.url = url 60 | if data is not None: 61 | self.as_dict = data 62 | self.__extract_from_data() 63 | elif url is not None: 64 | i, session = self.parent._get_session(**kwargs) 65 | self.id = extract_video_id_from_url( 66 | url, 67 | headers=session.headers, 68 | proxy=kwargs.get("proxy") 69 | if kwargs.get("proxy") is not None 70 | else session.proxy, 71 | ) 72 | 73 | if getattr(self, "id", None) is None: 74 | raise TypeError("You must provide id or url parameter.") 75 | 76 | async def info(self, **kwargs) -> dict: 77 | """ 78 | Returns a dictionary of all data associated with a TikTok Video. 79 | 80 | Note: This is slow since it requires an HTTP request, avoid using this if possible. 81 | 82 | Returns: 83 | dict: A dictionary of all data associated with a TikTok Video. 84 | 85 | Raises: 86 | InvalidResponseException: If TikTok returns an invalid response, or one we don't understand. 87 | 88 | Example Usage: 89 | .. code-block:: python 90 | 91 | url = "https://www.tiktok.com/@davidteathercodes/video/7106686413101468970" 92 | video_info = await api.video(url=url).info() 93 | """ 94 | i, session = self.parent._get_session(**kwargs) 95 | proxy = ( 96 | kwargs.get("proxy") if kwargs.get("proxy") is not None else session.proxy 97 | ) 98 | if self.url is None: 99 | raise TypeError("To call video.info() you need to set the video's url.") 100 | 101 | r = requests.get(self.url, headers=session.headers, proxies=proxy) 102 | if r.status_code != 200: 103 | raise InvalidResponseException( 104 | r.text, "TikTok returned an invalid response.", error_code=r.status_code 105 | ) 106 | 107 | # Try SIGI_STATE first 108 | # extract tag <script id="SIGI_STATE" type="application/json">{..}</script> 109 | # extract json in the middle 110 | 111 | start = r.text.find('<script id="SIGI_STATE" type="application/json">') 112 | if start != -1: 113 | start += len('<script id="SIGI_STATE" type="application/json">') 114 | end = r.text.find("</script>", start) 115 | 116 | if end == -1: 117 | raise InvalidResponseException( 118 | r.text, "TikTok returned an invalid response.", error_code=r.status_code 119 | ) 120 | 121 | data = json.loads(r.text[start:end]) 122 | video_info = data["ItemModule"][self.id] 123 | else: 124 | # Try __UNIVERSAL_DATA_FOR_REHYDRATION__ next 125 | 126 | # extract tag <script id="__UNIVERSAL_DATA_FOR_REHYDRATION__" type="application/json">{..}</script> 127 | # extract json in the middle 128 | 129 | start = r.text.find('<script id="__UNIVERSAL_DATA_FOR_REHYDRATION__" type="application/json">') 130 | if start == -1: 131 | raise InvalidResponseException( 132 | r.text, "TikTok returned an invalid response.", error_code=r.status_code 133 | ) 134 | 135 | start += len('<script id="__UNIVERSAL_DATA_FOR_REHYDRATION__" type="application/json">') 136 | end = r.text.find("</script>", start) 137 | 138 | if end == -1: 139 | raise InvalidResponseException( 140 | r.text, "TikTok returned an invalid response.", error_code=r.status_code 141 | ) 142 | 143 | data = json.loads(r.text[start:end]) 144 | default_scope = data.get("__DEFAULT_SCOPE__", {}) 145 | video_detail = default_scope.get("webapp.video-detail", {}) 146 | if video_detail.get("statusCode", 0) != 0: # assume 0 if not present 147 | raise InvalidResponseException( 148 | r.text, "TikTok returned an invalid response structure.", error_code=r.status_code 149 | ) 150 | video_info = video_detail.get("itemInfo", {}).get("itemStruct") 151 | if video_info is None: 152 | raise InvalidResponseException( 153 | r.text, "TikTok returned an invalid response structure.", error_code=r.status_code 154 | ) 155 | 156 | self.as_dict = video_info 157 | self.__extract_from_data() 158 | 159 | cookies = [requests_cookie_to_playwright_cookie(c) for c in r.cookies] 160 | 161 | await self.parent.set_session_cookies( 162 | session, 163 | cookies 164 | ) 165 | return video_info 166 | 167 | async def bytes(self, stream: bool = False, **kwargs) -> Union[bytes, AsyncIterator[bytes]]: 168 | """ 169 | Returns the bytes of a TikTok Video. 170 | 171 | TODO: 172 | Not implemented yet. 173 | 174 | Example Usage: 175 | .. code-block:: python 176 | 177 | video_bytes = await api.video(id='7041997751718137094').bytes() 178 | 179 | # Saving The Video 180 | with open('saved_video.mp4', 'wb') as output: 181 | output.write(video_bytes) 182 | 183 | # Streaming (if stream=True) 184 | async for chunk in api.video(id='7041997751718137094').bytes(stream=True): 185 | # Process or upload chunk 186 | """ 187 | i, session = self.parent._get_session(**kwargs) 188 | downloadAddr = self.as_dict["video"]["downloadAddr"] 189 | 190 | cookies = await self.parent.get_session_cookies(session) 191 | 192 | h = session.headers 193 | h["range"] = 'bytes=0-' 194 | h["accept-encoding"] = 'identity;q=1, *;q=0' 195 | h["referer"] = 'https://www.tiktok.com/' 196 | 197 | if stream: 198 | async def stream_bytes(): 199 | async with httpx.AsyncClient() as client: 200 | async with client.stream('GET', downloadAddr, headers=h, cookies=cookies) as response: 201 | async for chunk in response.aiter_bytes(): 202 | yield chunk 203 | return stream_bytes() 204 | else: 205 | resp = requests.get(downloadAddr, headers=h, cookies=cookies) 206 | return resp.content 207 | 208 | def __extract_from_data(self) -> None: 209 | data = self.as_dict 210 | self.id = data["id"] 211 | 212 | timestamp = data.get("createTime", None) 213 | if timestamp is not None: 214 | try: 215 | timestamp = int(timestamp) 216 | except ValueError: 217 | pass 218 | 219 | self.create_time = datetime.fromtimestamp(timestamp) 220 | self.stats = data.get('statsV2') or data.get('stats') 221 | 222 | author = data.get("author") 223 | if isinstance(author, str): 224 | self.author = self.parent.user(username=author) 225 | else: 226 | self.author = self.parent.user(data=author) 227 | self.sound = self.parent.sound(data=data) 228 | 229 | self.hashtags = [ 230 | self.parent.hashtag(data=hashtag) for hashtag in data.get("challenges", []) 231 | ] 232 | 233 | if getattr(self, "id", None) is None: 234 | Video.parent.logger.error( 235 | f"Failed to create Video with data: {data}\nwhich has keys {data.keys()}" 236 | ) 237 | 238 | async def comments(self, count=20, cursor=0, **kwargs) -> AsyncIterator[Comment]: 239 | """ 240 | Returns the comments of a TikTok Video. 241 | 242 | Parameters: 243 | count (int): The amount of comments you want returned. 244 | cursor (int): The the offset of comments from 0 you want to get. 245 | 246 | Returns: 247 | async iterator/generator: Yields TikTokApi.comment objects. 248 | 249 | Example Usage 250 | .. code-block:: python 251 | 252 | async for comment in api.video(id='7041997751718137094').comments(): 253 | # do something 254 | ``` 255 | """ 256 | found = 0 257 | while found < count: 258 | params = { 259 | "aweme_id": self.id, 260 | "count": 20, 261 | "cursor": cursor, 262 | } 263 | 264 | resp = await self.parent.make_request( 265 | url="https://www.tiktok.com/api/comment/list/", 266 | params=params, 267 | headers=kwargs.get("headers"), 268 | session_index=kwargs.get("session_index"), 269 | ) 270 | 271 | if resp is None: 272 | raise InvalidResponseException( 273 | resp, "TikTok returned an invalid response." 274 | ) 275 | 276 | for video in resp.get("comments", []): 277 | yield self.parent.comment(data=video) 278 | found += 1 279 | 280 | if not resp.get("has_more", False): 281 | return 282 | 283 | cursor = resp.get("cursor") 284 | 285 | async def related_videos( 286 | self, count: int = 30, cursor: int = 0, **kwargs 287 | ) -> AsyncIterator[Video]: 288 | """ 289 | Returns related videos of a TikTok Video. 290 | 291 | Parameters: 292 | count (int): The amount of comments you want returned. 293 | cursor (int): The the offset of comments from 0 you want to get. 294 | 295 | Returns: 296 | async iterator/generator: Yields TikTokApi.video objects. 297 | 298 | Example Usage 299 | .. code-block:: python 300 | 301 | async for related_videos in api.video(id='7041997751718137094').related_videos(): 302 | # do something 303 | ``` 304 | """ 305 | found = 0 306 | while found < count: 307 | params = { 308 | "itemID": self.id, 309 | "count": 16, 310 | } 311 | 312 | resp = await self.parent.make_request( 313 | url="https://www.tiktok.com/api/related/item_list/", 314 | params=params, 315 | headers=kwargs.get("headers"), 316 | session_index=kwargs.get("session_index"), 317 | ) 318 | 319 | if resp is None: 320 | raise InvalidResponseException( 321 | resp, "TikTok returned an invalid response." 322 | ) 323 | 324 | for video in resp.get("itemList", []): 325 | yield self.parent.video(data=video) 326 | found += 1 327 | 328 | def __repr__(self): 329 | return self.__str__() 330 | 331 | def __str__(self): 332 | return f"TikTokApi.video(id='{getattr(self, 'id', None)}')" 333 | -------------------------------------------------------------------------------- /TikTokApi/exceptions.py: -------------------------------------------------------------------------------- 1 | class TikTokException(Exception): 2 | """Generic exception that all other TikTok errors are children of.""" 3 | 4 | def __init__(self, raw_response, message, error_code=None): 5 | self.error_code = error_code 6 | self.raw_response = raw_response 7 | self.message = message 8 | super().__init__(self.message) 9 | 10 | def __str__(self): 11 | return f"{self.error_code} -> {self.message}" 12 | 13 | 14 | class CaptchaException(TikTokException): 15 | """TikTok is showing captcha""" 16 | 17 | 18 | class NotFoundException(TikTokException): 19 | """TikTok indicated that this object does not exist.""" 20 | 21 | 22 | class EmptyResponseException(TikTokException): 23 | """TikTok sent back an empty response.""" 24 | 25 | 26 | class SoundRemovedException(TikTokException): 27 | """This TikTok sound has no id from being removed by TikTok.""" 28 | 29 | 30 | class InvalidJSONException(TikTokException): 31 | """TikTok returned invalid JSON.""" 32 | 33 | 34 | class InvalidResponseException(TikTokException): 35 | """The response from TikTok was invalid.""" 36 | -------------------------------------------------------------------------------- /TikTokApi/helpers.py: -------------------------------------------------------------------------------- 1 | from .exceptions import * 2 | 3 | import requests 4 | import random 5 | 6 | 7 | def extract_video_id_from_url(url, headers={}, proxy=None): 8 | url = requests.head( 9 | url=url, allow_redirects=True, headers=headers, proxies=proxy 10 | ).url 11 | if "@" in url and "/video/" in url: 12 | return url.split("/video/")[1].split("?")[0] 13 | else: 14 | raise TypeError( 15 | "URL format not supported. Below is an example of a supported url.\n" 16 | "https://www.tiktok.com/@therock/video/6829267836783971589" 17 | ) 18 | 19 | 20 | def random_choice(choices: list): 21 | """Return a random choice from a list, or None if the list is empty""" 22 | if choices is None or len(choices) == 0: 23 | return None 24 | return random.choice(choices) 25 | 26 | def requests_cookie_to_playwright_cookie(req_c): 27 | c = { 28 | 'name': req_c.name, 29 | 'value': req_c.value, 30 | 'domain': req_c.domain, 31 | 'path': req_c.path, 32 | 'secure': req_c.secure 33 | } 34 | if req_c.expires: 35 | c['expires'] = req_c.expires 36 | return c 37 | -------------------------------------------------------------------------------- /TikTokApi/stealth/__init__.py: -------------------------------------------------------------------------------- 1 | from .stealth import stealth_async 2 | -------------------------------------------------------------------------------- /TikTokApi/stealth/js/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidteather/TikTok-Api/62a8cfa8ab7bb5bbdd0f8c8b13e84731fff7ac75/TikTokApi/stealth/js/__init__.py -------------------------------------------------------------------------------- /TikTokApi/stealth/js/chrome_app.py: -------------------------------------------------------------------------------- 1 | chrome_app = """ 2 | if (!window.chrome) { 3 | // Use the exact property descriptor found in headful Chrome 4 | // fetch it via `Object.getOwnPropertyDescriptor(window, 'chrome')` 5 | Object.defineProperty(window, 'chrome', { 6 | writable: true, 7 | enumerable: true, 8 | configurable: false, // note! 9 | value: {} // We'll extend that later 10 | }) 11 | } 12 | 13 | // app in window.chrome means we're running headful and don't need to mock anything 14 | if (!('app' in window.chrome)) { 15 | const makeError = { 16 | ErrorInInvocation: fn => { 17 | const err = new TypeError(`Error in invocation of app.${fn}()`) 18 | return utils.stripErrorWithAnchor( 19 | err, 20 | `at ${fn} (eval at <anonymous>` 21 | ) 22 | } 23 | } 24 | 25 | // There's a some static data in that property which doesn't seem to change, 26 | // we should periodically check for updates: `JSON.stringify(window.app, null, 2)` 27 | const APP_STATIC_DATA = JSON.parse( 28 | ` 29 | { 30 | "isInstalled": false, 31 | "InstallState": { 32 | "DISABLED": "disabled", 33 | "INSTALLED": "installed", 34 | "NOT_INSTALLED": "not_installed" 35 | }, 36 | "RunningState": { 37 | "CANNOT_RUN": "cannot_run", 38 | "READY_TO_RUN": "ready_to_run", 39 | "RUNNING": "running" 40 | } 41 | } 42 | `.trim() 43 | ) 44 | 45 | window.chrome.app = { 46 | ...APP_STATIC_DATA, 47 | 48 | get isInstalled() { 49 | return false 50 | }, 51 | 52 | getDetails: function getDetails() { 53 | if (arguments.length) { 54 | throw makeError.ErrorInInvocation(`getDetails`) 55 | } 56 | return null 57 | }, 58 | getIsInstalled: function getDetails() { 59 | if (arguments.length) { 60 | throw makeError.ErrorInInvocation(`getIsInstalled`) 61 | } 62 | return false 63 | }, 64 | runningState: function getDetails() { 65 | if (arguments.length) { 66 | throw makeError.ErrorInInvocation(`runningState`) 67 | } 68 | return 'cannot_run' 69 | } 70 | } 71 | utils.patchToStringNested(window.chrome.app) 72 | } 73 | """ 74 | -------------------------------------------------------------------------------- /TikTokApi/stealth/js/chrome_csi.py: -------------------------------------------------------------------------------- 1 | chrome_csi = """ 2 | if (!window.chrome) { 3 | // Use the exact property descriptor found in headful Chrome 4 | // fetch it via `Object.getOwnPropertyDescriptor(window, 'chrome')` 5 | Object.defineProperty(window, 'chrome', { 6 | writable: true, 7 | enumerable: true, 8 | configurable: false, // note! 9 | value: {} // We'll extend that later 10 | }) 11 | } 12 | 13 | // Check if we're running headful and don't need to mock anything 14 | // Check that the Navigation Timing API v1 is available, we need that 15 | if (!('csi' in window.chrome) && (window.performance || window.performance.timing)) { 16 | const {csi_timing} = window.performance 17 | 18 | log.info('loading chrome.csi.js') 19 | window.chrome.csi = function () { 20 | return { 21 | onloadT: csi_timing.domContentLoadedEventEnd, 22 | startE: csi_timing.navigationStart, 23 | pageT: Date.now() - csi_timing.navigationStart, 24 | tran: 15 // Transition type or something 25 | } 26 | } 27 | utils.patchToString(window.chrome.csi) 28 | } 29 | """ 30 | -------------------------------------------------------------------------------- /TikTokApi/stealth/js/chrome_hairline.py: -------------------------------------------------------------------------------- 1 | chrome_hairline = """ 2 | // https://intoli.com/blog/making-chrome-headless-undetectable/ 3 | // store the existing descriptor 4 | const elementDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight'); 5 | 6 | // redefine the property with a patched descriptor 7 | Object.defineProperty(HTMLDivElement.prototype, 'offsetHeight', { 8 | ...elementDescriptor, 9 | get: function() { 10 | if (this.id === 'modernizr') { 11 | return 1; 12 | } 13 | return elementDescriptor.get.apply(this); 14 | }, 15 | }); 16 | """ 17 | -------------------------------------------------------------------------------- /TikTokApi/stealth/js/chrome_load_times.py: -------------------------------------------------------------------------------- 1 | chrome_load_times = """ 2 | if (!window.chrome) { 3 | // Use the exact property descriptor found in headful Chrome 4 | // fetch it via `Object.getOwnPropertyDescriptor(window, 'chrome')` 5 | Object.defineProperty(window, 'chrome', { 6 | writable: true, 7 | enumerable: true, 8 | configurable: false, // note! 9 | value: {} // We'll extend that later 10 | }) 11 | } 12 | 13 | // That means we're running headful and don't need to mock anything 14 | if ('loadTimes' in window.chrome) { 15 | throw new Error('skipping chrome loadtimes update, running in headfull mode') 16 | } 17 | 18 | // Check that the Navigation Timing API v1 + v2 is available, we need that 19 | if ( 20 | window.performance || 21 | window.performance.timing || 22 | window.PerformancePaintTiming 23 | ) { 24 | 25 | const {performance} = window 26 | 27 | // Some stuff is not available on about:blank as it requires a navigation to occur, 28 | // let's harden the code to not fail then: 29 | const ntEntryFallback = { 30 | nextHopProtocol: 'h2', 31 | type: 'other' 32 | } 33 | 34 | // The API exposes some funky info regarding the connection 35 | const protocolInfo = { 36 | get connectionInfo() { 37 | const ntEntry = 38 | performance.getEntriesByType('navigation')[0] || ntEntryFallback 39 | return ntEntry.nextHopProtocol 40 | }, 41 | get npnNegotiatedProtocol() { 42 | // NPN is deprecated in favor of ALPN, but this implementation returns the 43 | // HTTP/2 or HTTP2+QUIC/39 requests negotiated via ALPN. 44 | const ntEntry = 45 | performance.getEntriesByType('navigation')[0] || ntEntryFallback 46 | return ['h2', 'hq'].includes(ntEntry.nextHopProtocol) 47 | ? ntEntry.nextHopProtocol 48 | : 'unknown' 49 | }, 50 | get navigationType() { 51 | const ntEntry = 52 | performance.getEntriesByType('navigation')[0] || ntEntryFallback 53 | return ntEntry.type 54 | }, 55 | get wasAlternateProtocolAvailable() { 56 | // The Alternate-Protocol header is deprecated in favor of Alt-Svc 57 | // (https://www.mnot.net/blog/2016/03/09/alt-svc), so technically this 58 | // should always return false. 59 | return false 60 | }, 61 | get wasFetchedViaSpdy() { 62 | // SPDY is deprecated in favor of HTTP/2, but this implementation returns 63 | // true for HTTP/2 or HTTP2+QUIC/39 as well. 64 | const ntEntry = 65 | performance.getEntriesByType('navigation')[0] || ntEntryFallback 66 | return ['h2', 'hq'].includes(ntEntry.nextHopProtocol) 67 | }, 68 | get wasNpnNegotiated() { 69 | // NPN is deprecated in favor of ALPN, but this implementation returns true 70 | // for HTTP/2 or HTTP2+QUIC/39 requests negotiated via ALPN. 71 | const ntEntry = 72 | performance.getEntriesByType('navigation')[0] || ntEntryFallback 73 | return ['h2', 'hq'].includes(ntEntry.nextHopProtocol) 74 | } 75 | } 76 | 77 | const {timing} = window.performance 78 | 79 | // Truncate number to specific number of decimals, most of the `loadTimes` stuff has 3 80 | function toFixed(num, fixed) { 81 | var re = new RegExp('^-?\\d+(?:.\\d{0,' + (fixed || -1) + '})?') 82 | return num.toString().match(re)[0] 83 | } 84 | 85 | const timingInfo = { 86 | get firstPaintAfterLoadTime() { 87 | // This was never actually implemented and always returns 0. 88 | return 0 89 | }, 90 | get requestTime() { 91 | return timing.navigationStart / 1000 92 | }, 93 | get startLoadTime() { 94 | return timing.navigationStart / 1000 95 | }, 96 | get commitLoadTime() { 97 | return timing.responseStart / 1000 98 | }, 99 | get finishDocumentLoadTime() { 100 | return timing.domContentLoadedEventEnd / 1000 101 | }, 102 | get finishLoadTime() { 103 | return timing.loadEventEnd / 1000 104 | }, 105 | get firstPaintTime() { 106 | const fpEntry = performance.getEntriesByType('paint')[0] || { 107 | startTime: timing.loadEventEnd / 1000 // Fallback if no navigation occured (`about:blank`) 108 | } 109 | return toFixed( 110 | (fpEntry.startTime + performance.timeOrigin) / 1000, 111 | 3 112 | ) 113 | } 114 | } 115 | 116 | window.chrome.loadTimes = function () { 117 | return { 118 | ...protocolInfo, 119 | ...timingInfo 120 | } 121 | } 122 | utils.patchToString(window.chrome.loadTimes) 123 | } 124 | """ 125 | -------------------------------------------------------------------------------- /TikTokApi/stealth/js/chrome_runtime.py: -------------------------------------------------------------------------------- 1 | chrome_runtime = """ 2 | const STATIC_DATA = { 3 | "OnInstalledReason": { 4 | "CHROME_UPDATE": "chrome_update", 5 | "INSTALL": "install", 6 | "SHARED_MODULE_UPDATE": "shared_module_update", 7 | "UPDATE": "update" 8 | }, 9 | "OnRestartRequiredReason": { 10 | "APP_UPDATE": "app_update", 11 | "OS_UPDATE": "os_update", 12 | "PERIODIC": "periodic" 13 | }, 14 | "PlatformArch": { 15 | "ARM": "arm", 16 | "ARM64": "arm64", 17 | "MIPS": "mips", 18 | "MIPS64": "mips64", 19 | "X86_32": "x86-32", 20 | "X86_64": "x86-64" 21 | }, 22 | "PlatformNaclArch": { 23 | "ARM": "arm", 24 | "MIPS": "mips", 25 | "MIPS64": "mips64", 26 | "X86_32": "x86-32", 27 | "X86_64": "x86-64" 28 | }, 29 | "PlatformOs": { 30 | "ANDROID": "android", 31 | "CROS": "cros", 32 | "LINUX": "linux", 33 | "MAC": "mac", 34 | "OPENBSD": "openbsd", 35 | "WIN": "win" 36 | }, 37 | "RequestUpdateCheckStatus": { 38 | "NO_UPDATE": "no_update", 39 | "THROTTLED": "throttled", 40 | "UPDATE_AVAILABLE": "update_available" 41 | } 42 | } 43 | 44 | if (!window.chrome) { 45 | // Use the exact property descriptor found in headful Chrome 46 | // fetch it via `Object.getOwnPropertyDescriptor(window, 'chrome')` 47 | Object.defineProperty(window, 'chrome', { 48 | writable: true, 49 | enumerable: true, 50 | configurable: false, // note! 51 | value: {} // We'll extend that later 52 | }) 53 | } 54 | 55 | // That means we're running headfull and don't need to mock anything 56 | const existsAlready = 'runtime' in window.chrome 57 | // `chrome.runtime` is only exposed on secure origins 58 | const isNotSecure = !window.location.protocol.startsWith('https') 59 | if (!(existsAlready || (isNotSecure && !opts.runOnInsecureOrigins))) { 60 | window.chrome.runtime = { 61 | // There's a bunch of static data in that property which doesn't seem to change, 62 | // we should periodically check for updates: `JSON.stringify(window.chrome.runtime, null, 2)` 63 | ...STATIC_DATA, 64 | // `chrome.runtime.id` is extension related and returns undefined in Chrome 65 | get id() { 66 | return undefined 67 | }, 68 | // These two require more sophisticated mocks 69 | connect: null, 70 | sendMessage: null 71 | } 72 | 73 | const makeCustomRuntimeErrors = (preamble, method, extensionId) => ({ 74 | NoMatchingSignature: new TypeError( 75 | preamble + `No matching signature.` 76 | ), 77 | MustSpecifyExtensionID: new TypeError( 78 | preamble + 79 | `${method} called from a webpage must specify an Extension ID (string) for its first argument.` 80 | ), 81 | InvalidExtensionID: new TypeError( 82 | preamble + `Invalid extension id: '${extensionId}'` 83 | ) 84 | }) 85 | 86 | // Valid Extension IDs are 32 characters in length and use the letter `a` to `p`: 87 | // https://source.chromium.org/chromium/chromium/src/+/main:components/crx_file/id_util.cc;drc=14a055ccb17e8c8d5d437fe080faba4c6f07beac;l=90 88 | const isValidExtensionID = str => 89 | str.length === 32 && str.toLowerCase().match(/^[a-p]+$/) 90 | 91 | /** Mock `chrome.runtime.sendMessage` */ 92 | const sendMessageHandler = { 93 | apply: function (target, ctx, args) { 94 | const [extensionId, options, responseCallback] = args || [] 95 | 96 | // Define custom errors 97 | const errorPreamble = `Error in invocation of runtime.sendMessage(optional string extensionId, any message, optional object options, optional function responseCallback): ` 98 | const Errors = makeCustomRuntimeErrors( 99 | errorPreamble, 100 | `chrome.runtime.sendMessage()`, 101 | extensionId 102 | ) 103 | 104 | // Check if the call signature looks ok 105 | const noArguments = args.length === 0 106 | const tooManyArguments = args.length > 4 107 | const incorrectOptions = options && typeof options !== 'object' 108 | const incorrectResponseCallback = 109 | responseCallback && typeof responseCallback !== 'function' 110 | if ( 111 | noArguments || 112 | tooManyArguments || 113 | incorrectOptions || 114 | incorrectResponseCallback 115 | ) { 116 | throw Errors.NoMatchingSignature 117 | } 118 | 119 | // At least 2 arguments are required before we even validate the extension ID 120 | if (args.length < 2) { 121 | throw Errors.MustSpecifyExtensionID 122 | } 123 | 124 | // Now let's make sure we got a string as extension ID 125 | if (typeof extensionId !== 'string') { 126 | throw Errors.NoMatchingSignature 127 | } 128 | 129 | if (!isValidExtensionID(extensionId)) { 130 | throw Errors.InvalidExtensionID 131 | } 132 | 133 | return undefined // Normal behavior 134 | } 135 | } 136 | utils.mockWithProxy( 137 | window.chrome.runtime, 138 | 'sendMessage', 139 | function sendMessage() { 140 | }, 141 | sendMessageHandler 142 | ) 143 | 144 | /** 145 | * Mock `chrome.runtime.connect` 146 | * 147 | * @see https://developer.chrome.com/apps/runtime#method-connect 148 | */ 149 | const connectHandler = { 150 | apply: function (target, ctx, args) { 151 | const [extensionId, connectInfo] = args || [] 152 | 153 | // Define custom errors 154 | const errorPreamble = `Error in invocation of runtime.connect(optional string extensionId, optional object connectInfo): ` 155 | const Errors = makeCustomRuntimeErrors( 156 | errorPreamble, 157 | `chrome.runtime.connect()`, 158 | extensionId 159 | ) 160 | 161 | // Behavior differs a bit from sendMessage: 162 | const noArguments = args.length === 0 163 | const emptyStringArgument = args.length === 1 && extensionId === '' 164 | if (noArguments || emptyStringArgument) { 165 | throw Errors.MustSpecifyExtensionID 166 | } 167 | 168 | const tooManyArguments = args.length > 2 169 | const incorrectConnectInfoType = 170 | connectInfo && typeof connectInfo !== 'object' 171 | 172 | if (tooManyArguments || incorrectConnectInfoType) { 173 | throw Errors.NoMatchingSignature 174 | } 175 | 176 | const extensionIdIsString = typeof extensionId === 'string' 177 | if (extensionIdIsString && extensionId === '') { 178 | throw Errors.MustSpecifyExtensionID 179 | } 180 | if (extensionIdIsString && !isValidExtensionID(extensionId)) { 181 | throw Errors.InvalidExtensionID 182 | } 183 | 184 | // There's another edge-case here: extensionId is optional so we might find a connectInfo object as first param, which we need to validate 185 | const validateConnectInfo = ci => { 186 | // More than a first param connectInfo as been provided 187 | if (args.length > 1) { 188 | throw Errors.NoMatchingSignature 189 | } 190 | // An empty connectInfo has been provided 191 | if (Object.keys(ci).length === 0) { 192 | throw Errors.MustSpecifyExtensionID 193 | } 194 | // Loop over all connectInfo props an check them 195 | Object.entries(ci).forEach(([k, v]) => { 196 | const isExpected = ['name', 'includeTlsChannelId'].includes(k) 197 | if (!isExpected) { 198 | throw new TypeError( 199 | errorPreamble + `Unexpected property: '${k}'.` 200 | ) 201 | } 202 | const MismatchError = (propName, expected, found) => 203 | TypeError( 204 | errorPreamble + 205 | `Error at property '${propName}': Invalid type: expected ${expected}, found ${found}.` 206 | ) 207 | if (k === 'name' && typeof v !== 'string') { 208 | throw MismatchError(k, 'string', typeof v) 209 | } 210 | if (k === 'includeTlsChannelId' && typeof v !== 'boolean') { 211 | throw MismatchError(k, 'boolean', typeof v) 212 | } 213 | }) 214 | } 215 | if (typeof extensionId === 'object') { 216 | validateConnectInfo(extensionId) 217 | throw Errors.MustSpecifyExtensionID 218 | } 219 | 220 | // Unfortunately even when the connect fails Chrome will return an object with methods we need to mock as well 221 | return utils.patchToStringNested(makeConnectResponse()) 222 | } 223 | } 224 | utils.mockWithProxy( 225 | window.chrome.runtime, 226 | 'connect', 227 | function connect() { 228 | }, 229 | connectHandler 230 | ) 231 | 232 | function makeConnectResponse() { 233 | const onSomething = () => ({ 234 | addListener: function addListener() { 235 | }, 236 | dispatch: function dispatch() { 237 | }, 238 | hasListener: function hasListener() { 239 | }, 240 | hasListeners: function hasListeners() { 241 | return false 242 | }, 243 | removeListener: function removeListener() { 244 | } 245 | }) 246 | 247 | const response = { 248 | name: '', 249 | sender: undefined, 250 | disconnect: function disconnect() { 251 | }, 252 | onDisconnect: onSomething(), 253 | onMessage: onSomething(), 254 | postMessage: function postMessage() { 255 | if (!arguments.length) { 256 | throw new TypeError(`Insufficient number of arguments.`) 257 | } 258 | throw new Error(`Attempting to use a disconnected port object`) 259 | } 260 | } 261 | return response 262 | } 263 | } 264 | 265 | """ 266 | -------------------------------------------------------------------------------- /TikTokApi/stealth/js/generate_magic_arrays.py: -------------------------------------------------------------------------------- 1 | generate_magic_arrays = """ 2 | generateFunctionMocks = ( 3 | proto, 4 | itemMainProp, 5 | dataArray 6 | ) => ({ 7 | item: utils.createProxy(proto.item, { 8 | apply(target, ctx, args) { 9 | if (!args.length) { 10 | throw new TypeError( 11 | `Failed to execute 'item' on '${ 12 | proto[Symbol.toStringTag] 13 | }': 1 argument required, but only 0 present.` 14 | ) 15 | } 16 | // Special behavior alert: 17 | // - Vanilla tries to cast strings to Numbers (only integers!) and use them as property index lookup 18 | // - If anything else than an integer (including as string) is provided it will return the first entry 19 | const isInteger = args[0] && Number.isInteger(Number(args[0])) // Cast potential string to number first, then check for integer 20 | // Note: Vanilla never returns `undefined` 21 | return (isInteger ? dataArray[Number(args[0])] : dataArray[0]) || null 22 | } 23 | }), 24 | /** Returns the MimeType object with the specified name. */ 25 | namedItem: utils.createProxy(proto.namedItem, { 26 | apply(target, ctx, args) { 27 | if (!args.length) { 28 | throw new TypeError( 29 | `Failed to execute 'namedItem' on '${ 30 | proto[Symbol.toStringTag] 31 | }': 1 argument required, but only 0 present.` 32 | ) 33 | } 34 | return dataArray.find(mt => mt[itemMainProp] === args[0]) || null // Not `undefined`! 35 | } 36 | }), 37 | /** Does nothing and shall return nothing */ 38 | refresh: proto.refresh 39 | ? utils.createProxy(proto.refresh, { 40 | apply(target, ctx, args) { 41 | return undefined 42 | } 43 | }) 44 | : undefined 45 | }) 46 | 47 | function generateMagicArray( 48 | dataArray = [], 49 | proto = MimeTypeArray.prototype, 50 | itemProto = MimeType.prototype, 51 | itemMainProp = 'type' 52 | ) { 53 | // Quick helper to set props with the same descriptors vanilla is using 54 | const defineProp = (obj, prop, value) => 55 | Object.defineProperty(obj, prop, { 56 | value, 57 | writable: false, 58 | enumerable: false, // Important for mimeTypes & plugins: `JSON.stringify(navigator.mimeTypes)` 59 | configurable: false 60 | }) 61 | 62 | // Loop over our fake data and construct items 63 | const makeItem = data => { 64 | const item = {} 65 | for (const prop of Object.keys(data)) { 66 | if (prop.startsWith('__')) { 67 | continue 68 | } 69 | defineProp(item, prop, data[prop]) 70 | } 71 | // navigator.plugins[i].length should always be 1 72 | if (itemProto === Plugin.prototype) { 73 | defineProp(item, 'length', 1) 74 | } 75 | // We need to spoof a specific `MimeType` or `Plugin` object 76 | return Object.create(itemProto, Object.getOwnPropertyDescriptors(item)) 77 | } 78 | 79 | const magicArray = [] 80 | 81 | // Loop through our fake data and use that to create convincing entities 82 | dataArray.forEach(data => { 83 | magicArray.push(makeItem(data)) 84 | }) 85 | 86 | // Add direct property access based on types (e.g. `obj['application/pdf']`) afterwards 87 | magicArray.forEach(entry => { 88 | defineProp(magicArray, entry[itemMainProp], entry) 89 | }) 90 | 91 | // This is the best way to fake the type to make sure this is false: `Array.isArray(navigator.mimeTypes)` 92 | const magicArrayObj = Object.create(proto, { 93 | ...Object.getOwnPropertyDescriptors(magicArray), 94 | 95 | // There's one ugly quirk we unfortunately need to take care of: 96 | // The `MimeTypeArray` prototype has an enumerable `length` property, 97 | // but headful Chrome will still skip it when running `Object.getOwnPropertyNames(navigator.mimeTypes)`. 98 | // To strip it we need to make it first `configurable` and can then overlay a Proxy with an `ownKeys` trap. 99 | length: { 100 | value: magicArray.length, 101 | writable: false, 102 | enumerable: false, 103 | configurable: true // Important to be able to use the ownKeys trap in a Proxy to strip `length` 104 | } 105 | }) 106 | 107 | // Generate our functional function mocks :-) 108 | const functionMocks = generateFunctionMocks( 109 | proto, 110 | itemMainProp, 111 | magicArray 112 | ) 113 | 114 | // Override custom object with proxy 115 | return new Proxy(magicArrayObj, { 116 | get(target, key = '') { 117 | // Redirect function calls to our custom proxied versions mocking the vanilla behavior 118 | if (key === 'item') { 119 | return functionMocks.item 120 | } 121 | if (key === 'namedItem') { 122 | return functionMocks.namedItem 123 | } 124 | if (proto === PluginArray.prototype && key === 'refresh') { 125 | return functionMocks.refresh 126 | } 127 | // Everything else can pass through as normal 128 | return utils.cache.Reflect.get(...arguments) 129 | }, 130 | ownKeys(target) { 131 | // There are a couple of quirks where the original property demonstrates "magical" behavior that makes no sense 132 | // This can be witnessed when calling `Object.getOwnPropertyNames(navigator.mimeTypes)` and the absense of `length` 133 | // My guess is that it has to do with the recent change of not allowing data enumeration and this being implemented weirdly 134 | // For that reason we just completely fake the available property names based on our data to match what regular Chrome is doing 135 | // Specific issues when not patching this: `length` property is available, direct `types` props (e.g. `obj['application/pdf']`) are missing 136 | const keys = [] 137 | const typeProps = magicArray.map(mt => mt[itemMainProp]) 138 | typeProps.forEach((_, i) => keys.push(`${i}`)) 139 | typeProps.forEach(propName => keys.push(propName)) 140 | return keys 141 | } 142 | }) 143 | } 144 | """ 145 | -------------------------------------------------------------------------------- /TikTokApi/stealth/js/iframe_contentWindow.py: -------------------------------------------------------------------------------- 1 | iframe_contentWindow = """ 2 | try { 3 | // Adds a contentWindow proxy to the provided iframe element 4 | const addContentWindowProxy = iframe => { 5 | const contentWindowProxy = { 6 | get(target, key) { 7 | // Now to the interesting part: 8 | // We actually make this thing behave like a regular iframe window, 9 | // by intercepting calls to e.g. `.self` and redirect it to the correct thing. :) 10 | // That makes it possible for these assertions to be correct: 11 | // iframe.contentWindow.self === window.top // must be false 12 | if (key === 'self') { 13 | return this 14 | } 15 | // iframe.contentWindow.frameElement === iframe // must be true 16 | if (key === 'frameElement') { 17 | return iframe 18 | } 19 | return Reflect.get(target, key) 20 | } 21 | } 22 | 23 | if (!iframe.contentWindow) { 24 | const proxy = new Proxy(window, contentWindowProxy) 25 | Object.defineProperty(iframe, 'contentWindow', { 26 | get() { 27 | return proxy 28 | }, 29 | set(newValue) { 30 | return newValue // contentWindow is immutable 31 | }, 32 | enumerable: true, 33 | configurable: false 34 | }) 35 | } 36 | } 37 | 38 | // Handles iframe element creation, augments `srcdoc` property so we can intercept further 39 | const handleIframeCreation = (target, thisArg, args) => { 40 | const iframe = target.apply(thisArg, args) 41 | 42 | // We need to keep the originals around 43 | const _iframe = iframe 44 | const _srcdoc = _iframe.srcdoc 45 | 46 | // Add hook for the srcdoc property 47 | // We need to be very surgical here to not break other iframes by accident 48 | Object.defineProperty(iframe, 'srcdoc', { 49 | configurable: true, // Important, so we can reset this later 50 | get: function () { 51 | return _iframe.srcdoc 52 | }, 53 | set: function (newValue) { 54 | addContentWindowProxy(this) 55 | // Reset property, the hook is only needed once 56 | Object.defineProperty(iframe, 'srcdoc', { 57 | configurable: false, 58 | writable: false, 59 | value: _srcdoc 60 | }) 61 | _iframe.srcdoc = newValue 62 | } 63 | }) 64 | return iframe 65 | } 66 | 67 | // Adds a hook to intercept iframe creation events 68 | const addIframeCreationSniffer = () => { 69 | /* global document */ 70 | const createElementHandler = { 71 | // Make toString() native 72 | get(target, key) { 73 | return Reflect.get(target, key) 74 | }, 75 | apply: function (target, thisArg, args) { 76 | const isIframe = 77 | args && args.length && `${args[0]}`.toLowerCase() === 'iframe' 78 | if (!isIframe) { 79 | // Everything as usual 80 | return target.apply(thisArg, args) 81 | } else { 82 | return handleIframeCreation(target, thisArg, args) 83 | } 84 | } 85 | } 86 | // All this just due to iframes with srcdoc bug 87 | utils.replaceWithProxy( 88 | document, 89 | 'createElement', 90 | createElementHandler 91 | ) 92 | } 93 | 94 | // Let's go 95 | addIframeCreationSniffer() 96 | } catch (err) { 97 | // console.warn(err) 98 | } 99 | """ 100 | -------------------------------------------------------------------------------- /TikTokApi/stealth/js/media_codecs.py: -------------------------------------------------------------------------------- 1 | media_codecs = """ 2 | /** 3 | * Input might look funky, we need to normalize it so e.g. whitespace isn't an issue for our spoofing. 4 | * 5 | * @example 6 | * video/webm; codecs="vp8, vorbis" 7 | * video/mp4; codecs="avc1.42E01E" 8 | * audio/x-m4a; 9 | * audio/ogg; codecs="vorbis" 10 | * @param {String} arg 11 | */ 12 | const parseInput = arg => { 13 | const [mime, codecStr] = arg.trim().split(';') 14 | let codecs = [] 15 | if (codecStr && codecStr.includes('codecs="')) { 16 | codecs = codecStr 17 | .trim() 18 | .replace(`codecs="`, '') 19 | .replace(`"`, '') 20 | .trim() 21 | .split(',') 22 | .filter(x => !!x) 23 | .map(x => x.trim()) 24 | } 25 | return { 26 | mime, 27 | codecStr, 28 | codecs 29 | } 30 | } 31 | 32 | const canPlayType = { 33 | // Intercept certain requests 34 | apply: function (target, ctx, args) { 35 | if (!args || !args.length) { 36 | return target.apply(ctx, args) 37 | } 38 | const {mime, codecs} = parseInput(args[0]) 39 | // This specific mp4 codec is missing in Chromium 40 | if (mime === 'video/mp4') { 41 | if (codecs.includes('avc1.42E01E')) { 42 | return 'probably' 43 | } 44 | } 45 | // This mimetype is only supported if no codecs are specified 46 | if (mime === 'audio/x-m4a' && !codecs.length) { 47 | return 'maybe' 48 | } 49 | 50 | // This mimetype is only supported if no codecs are specified 51 | if (mime === 'audio/aac' && !codecs.length) { 52 | return 'probably' 53 | } 54 | // Everything else as usual 55 | return target.apply(ctx, args) 56 | } 57 | } 58 | 59 | /* global HTMLMediaElement */ 60 | utils.replaceWithProxy( 61 | HTMLMediaElement.prototype, 62 | 'canPlayType', 63 | canPlayType 64 | ) 65 | """ 66 | -------------------------------------------------------------------------------- /TikTokApi/stealth/js/navigator_hardwareConcurrency.py: -------------------------------------------------------------------------------- 1 | navigator_hardwareConcurrency = """ 2 | const patchNavigator = (name, value) => 3 | utils.replaceProperty(Object.getPrototypeOf(navigator), name, { 4 | get() { 5 | return value 6 | } 7 | }) 8 | 9 | patchNavigator('hardwareConcurrency', opts.navigator_hardware_concurrency || 4); 10 | """ 11 | -------------------------------------------------------------------------------- /TikTokApi/stealth/js/navigator_languages.py: -------------------------------------------------------------------------------- 1 | navigator_languages = """ 2 | Object.defineProperty(Object.getPrototypeOf(navigator), 'languages', { 3 | get: () => opts.languages || ['en-US', 'en'] 4 | }) 5 | 6 | """ 7 | -------------------------------------------------------------------------------- /TikTokApi/stealth/js/navigator_permissions.py: -------------------------------------------------------------------------------- 1 | navigator_permissions = """ 2 | const handler = { 3 | apply: function (target, ctx, args) { 4 | const param = (args || [])[0] 5 | 6 | if (param && param.name && param.name === 'notifications') { 7 | const result = {state: Notification.permission} 8 | Object.setPrototypeOf(result, PermissionStatus.prototype) 9 | return Promise.resolve(result) 10 | } 11 | 12 | return utils.cache.Reflect.apply(...arguments) 13 | } 14 | } 15 | 16 | utils.replaceWithProxy( 17 | window.navigator.permissions.__proto__, // eslint-disable-line no-proto 18 | 'query', 19 | handler 20 | ) 21 | 22 | """ 23 | -------------------------------------------------------------------------------- /TikTokApi/stealth/js/navigator_platform.py: -------------------------------------------------------------------------------- 1 | navigator_platform = """ 2 | if (opts.navigator_platform) { 3 | Object.defineProperty(Object.getPrototypeOf(navigator), 'platform', { 4 | get: () => opts.navigator_plaftorm, 5 | }) 6 | } 7 | """ 8 | -------------------------------------------------------------------------------- /TikTokApi/stealth/js/navigator_plugins.py: -------------------------------------------------------------------------------- 1 | navigator_plugins = """ 2 | data = { 3 | "mimeTypes": [ 4 | { 5 | "type": "application/pdf", 6 | "suffixes": "pdf", 7 | "description": "", 8 | "__pluginName": "Chrome PDF Viewer" 9 | }, 10 | { 11 | "type": "application/x-google-chrome-pdf", 12 | "suffixes": "pdf", 13 | "description": "Portable Document Format", 14 | "__pluginName": "Chrome PDF Plugin" 15 | }, 16 | { 17 | "type": "application/x-nacl", 18 | "suffixes": "", 19 | "description": "Native Client Executable", 20 | "__pluginName": "Native Client" 21 | }, 22 | { 23 | "type": "application/x-pnacl", 24 | "suffixes": "", 25 | "description": "Portable Native Client Executable", 26 | "__pluginName": "Native Client" 27 | } 28 | ], 29 | "plugins": [ 30 | { 31 | "name": "Chrome PDF Plugin", 32 | "filename": "internal-pdf-viewer", 33 | "description": "Portable Document Format", 34 | "__mimeTypes": ["application/x-google-chrome-pdf"] 35 | }, 36 | { 37 | "name": "Chrome PDF Viewer", 38 | "filename": "mhjfbmdgcfjbbpaeojofohoefgiehjai", 39 | "description": "", 40 | "__mimeTypes": ["application/pdf"] 41 | }, 42 | { 43 | "name": "Native Client", 44 | "filename": "internal-nacl-plugin", 45 | "description": "", 46 | "__mimeTypes": ["application/x-nacl", "application/x-pnacl"] 47 | } 48 | ] 49 | } 50 | 51 | 52 | // That means we're running headful 53 | const hasPlugins = 'plugins' in navigator && navigator.plugins.length 54 | if (!(hasPlugins)) { 55 | 56 | const mimeTypes = generateMagicArray( 57 | data.mimeTypes, 58 | MimeTypeArray.prototype, 59 | MimeType.prototype, 60 | 'type' 61 | ) 62 | const plugins = generateMagicArray( 63 | data.plugins, 64 | PluginArray.prototype, 65 | Plugin.prototype, 66 | 'name' 67 | ) 68 | 69 | // Plugin and MimeType cross-reference each other, let's do that now 70 | // Note: We're looping through `data.plugins` here, not the generated `plugins` 71 | for (const pluginData of data.plugins) { 72 | pluginData.__mimeTypes.forEach((type, index) => { 73 | plugins[pluginData.name][index] = mimeTypes[type] 74 | plugins[type] = mimeTypes[type] 75 | Object.defineProperty(mimeTypes[type], 'enabledPlugin', { 76 | value: JSON.parse(JSON.stringify(plugins[pluginData.name])), 77 | writable: false, 78 | enumerable: false, // Important: `JSON.stringify(navigator.plugins)` 79 | configurable: false 80 | }) 81 | }) 82 | } 83 | 84 | const patchNavigator = (name, value) => 85 | utils.replaceProperty(Object.getPrototypeOf(navigator), name, { 86 | get() { 87 | return value 88 | } 89 | }) 90 | 91 | patchNavigator('mimeTypes', mimeTypes) 92 | patchNavigator('plugins', plugins) 93 | } 94 | """ 95 | -------------------------------------------------------------------------------- /TikTokApi/stealth/js/navigator_userAgent.py: -------------------------------------------------------------------------------- 1 | navigator_userAgent = """ 2 | // replace Headless references in default useragent 3 | const current_ua = navigator.userAgent; 4 | Object.defineProperty(Object.getPrototypeOf(navigator), 'userAgent', { 5 | get: () => { 6 | try { 7 | if (typeof opts !== 'undefined' && opts.navigator_user_agent) { 8 | return opts.navigator_user_agent; 9 | } 10 | } catch (error) { 11 | console.warn('Error accessing opts:', error); 12 | } 13 | return current_ua.replace('HeadlessChrome/', 'Chrome/'); 14 | } 15 | }); 16 | """ 17 | -------------------------------------------------------------------------------- /TikTokApi/stealth/js/navigator_vendor.py: -------------------------------------------------------------------------------- 1 | navigator_vendor = """ 2 | Object.defineProperty(Object.getPrototypeOf(navigator), 'vendor', { 3 | get: () => opts.navigator_vendor || 'Google Inc.', 4 | }) 5 | 6 | """ 7 | -------------------------------------------------------------------------------- /TikTokApi/stealth/js/utils.py: -------------------------------------------------------------------------------- 1 | utils = """ 2 | /** 3 | * A set of shared utility functions specifically for the purpose of modifying native browser APIs without leaving traces. 4 | * 5 | * Meant to be passed down in puppeteer and used in the context of the page (everything in here runs in NodeJS as well as a browser). 6 | * 7 | * Note: If for whatever reason you need to use this outside of `puppeteer-extra`: 8 | * Just remove the `module.exports` statement at the very bottom, the rest can be copy pasted into any browser context. 9 | * 10 | * Alternatively take a look at the `extract-stealth-evasions` package to create a finished bundle which includes these utilities. 11 | * 12 | */ 13 | const utils = {} 14 | 15 | /** 16 | * Wraps a JS Proxy Handler and strips it's presence from error stacks, in case the traps throw. 17 | * 18 | * The presence of a JS Proxy can be revealed as it shows up in error stack traces. 19 | * 20 | * @param {object} handler - The JS Proxy handler to wrap 21 | */ 22 | utils.stripProxyFromErrors = (handler = {}) => { 23 | const newHandler = {} 24 | // We wrap each trap in the handler in a try/catch and modify the error stack if they throw 25 | const traps = Object.getOwnPropertyNames(handler) 26 | traps.forEach(trap => { 27 | newHandler[trap] = function() { 28 | try { 29 | // Forward the call to the defined proxy handler 30 | return handler[trap].apply(this, arguments || []) 31 | } catch (err) { 32 | // Stack traces differ per browser, we only support chromium based ones currently 33 | if (!err || !err.stack || !err.stack.includes(`at `)) { 34 | throw err 35 | } 36 | 37 | // When something throws within one of our traps the Proxy will show up in error stacks 38 | // An earlier implementation of this code would simply strip lines with a blacklist, 39 | // but it makes sense to be more surgical here and only remove lines related to our Proxy. 40 | // We try to use a known "anchor" line for that and strip it with everything above it. 41 | // If the anchor line cannot be found for some reason we fall back to our blacklist approach. 42 | 43 | const stripWithBlacklist = stack => { 44 | const blacklist = [ 45 | `at Reflect.${trap} `, // e.g. Reflect.get or Reflect.apply 46 | `at Object.${trap} `, // e.g. Object.get or Object.apply 47 | `at Object.newHandler.<computed> [as ${trap}] ` // caused by this very wrapper :-) 48 | ] 49 | return ( 50 | err.stack 51 | .split('\n') 52 | // Always remove the first (file) line in the stack (guaranteed to be our proxy) 53 | .filter((line, index) => index !== 1) 54 | // Check if the line starts with one of our blacklisted strings 55 | .filter(line => !blacklist.some(bl => line.trim().startsWith(bl))) 56 | .join('\n') 57 | ) 58 | } 59 | 60 | const stripWithAnchor = stack => { 61 | const stackArr = stack.split('\n') 62 | const anchor = `at Object.newHandler.<computed> [as ${trap}] ` // Known first Proxy line in chromium 63 | const anchorIndex = stackArr.findIndex(line => 64 | line.trim().startsWith(anchor) 65 | ) 66 | if (anchorIndex === -1) { 67 | return false // 404, anchor not found 68 | } 69 | // Strip everything from the top until we reach the anchor line 70 | // Note: We're keeping the 1st line (zero index) as it's unrelated (e.g. `TypeError`) 71 | stackArr.splice(1, anchorIndex) 72 | return stackArr.join('\n') 73 | } 74 | 75 | // Try using the anchor method, fallback to blacklist if necessary 76 | err.stack = stripWithAnchor(err.stack) || stripWithBlacklist(err.stack) 77 | 78 | throw err // Re-throw our now sanitized error 79 | } 80 | } 81 | }) 82 | return newHandler 83 | } 84 | 85 | /** 86 | * Strip error lines from stack traces until (and including) a known line the stack. 87 | * 88 | * @param {object} err - The error to sanitize 89 | * @param {string} anchor - The string the anchor line starts with 90 | */ 91 | utils.stripErrorWithAnchor = (err, anchor) => { 92 | const stackArr = err.stack.split('\n') 93 | const anchorIndex = stackArr.findIndex(line => line.trim().startsWith(anchor)) 94 | if (anchorIndex === -1) { 95 | return err // 404, anchor not found 96 | } 97 | // Strip everything from the top until we reach the anchor line (remove anchor line as well) 98 | // Note: We're keeping the 1st line (zero index) as it's unrelated (e.g. `TypeError`) 99 | stackArr.splice(1, anchorIndex) 100 | err.stack = stackArr.join('\n') 101 | return err 102 | } 103 | 104 | /** 105 | * Replace the property of an object in a stealthy way. 106 | * 107 | * Note: You also want to work on the prototype of an object most often, 108 | * as you'd otherwise leave traces (e.g. showing up in Object.getOwnPropertyNames(obj)). 109 | * 110 | * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty 111 | * 112 | * @example 113 | * replaceProperty(WebGLRenderingContext.prototype, 'getParameter', { value: "alice" }) 114 | * // or 115 | * replaceProperty(Object.getPrototypeOf(navigator), 'languages', { get: () => ['en-US', 'en'] }) 116 | * 117 | * @param {object} obj - The object which has the property to replace 118 | * @param {string} propName - The property name to replace 119 | * @param {object} descriptorOverrides - e.g. { value: "alice" } 120 | */ 121 | utils.replaceProperty = (obj, propName, descriptorOverrides = {}) => { 122 | return Object.defineProperty(obj, propName, { 123 | // Copy over the existing descriptors (writable, enumerable, configurable, etc) 124 | ...(Object.getOwnPropertyDescriptor(obj, propName) || {}), 125 | // Add our overrides (e.g. value, get()) 126 | ...descriptorOverrides 127 | }) 128 | } 129 | 130 | /** 131 | * Preload a cache of function copies and data. 132 | * 133 | * For a determined enough observer it would be possible to overwrite and sniff usage of functions 134 | * we use in our internal Proxies, to combat that we use a cached copy of those functions. 135 | * 136 | * This is evaluated once per execution context (e.g. window) 137 | */ 138 | utils.preloadCache = () => { 139 | if (utils.cache) { 140 | return 141 | } 142 | utils.cache = { 143 | // Used in our proxies 144 | Reflect: { 145 | get: Reflect.get.bind(Reflect), 146 | apply: Reflect.apply.bind(Reflect) 147 | }, 148 | // Used in `makeNativeString` 149 | nativeToStringStr: Function.toString + '' // => `function toString() { [native code] }` 150 | } 151 | } 152 | 153 | /** 154 | * Utility function to generate a cross-browser `toString` result representing native code. 155 | * 156 | * There's small differences: Chromium uses a single line, whereas FF & Webkit uses multiline strings. 157 | * To future-proof this we use an existing native toString result as the basis. 158 | * 159 | * The only advantage we have over the other team is that our JS runs first, hence we cache the result 160 | * of the native toString result once, so they cannot spoof it afterwards and reveal that we're using it. 161 | * 162 | * Note: Whenever we add a `Function.prototype.toString` proxy we should preload the cache before, 163 | * by executing `utils.preloadCache()` before the proxy is applied (so we don't cause recursive lookups). 164 | * 165 | * @example 166 | * makeNativeString('foobar') // => `function foobar() { [native code] }` 167 | * 168 | * @param {string} [name] - Optional function name 169 | */ 170 | utils.makeNativeString = (name = '') => { 171 | // Cache (per-window) the original native toString or use that if available 172 | utils.preloadCache() 173 | return utils.cache.nativeToStringStr.replace('toString', name || '') 174 | } 175 | 176 | /** 177 | * Helper function to modify the `toString()` result of the provided object. 178 | * 179 | * Note: Use `utils.redirectToString` instead when possible. 180 | * 181 | * There's a quirk in JS Proxies that will cause the `toString()` result to differ from the vanilla Object. 182 | * If no string is provided we will generate a `[native code]` thing based on the name of the property object. 183 | * 184 | * @example 185 | * patchToString(WebGLRenderingContext.prototype.getParameter, 'function getParameter() { [native code] }') 186 | * 187 | * @param {object} obj - The object for which to modify the `toString()` representation 188 | * @param {string} str - Optional string used as a return value 189 | */ 190 | utils.patchToString = (obj, str = '') => { 191 | utils.preloadCache() 192 | 193 | const toStringProxy = new Proxy(Function.prototype.toString, { 194 | apply: function(target, ctx) { 195 | // This fixes e.g. `HTMLMediaElement.prototype.canPlayType.toString + ""` 196 | if (ctx === Function.prototype.toString) { 197 | return utils.makeNativeString('toString') 198 | } 199 | // `toString` targeted at our proxied Object detected 200 | if (ctx === obj) { 201 | // We either return the optional string verbatim or derive the most desired result automatically 202 | return str || utils.makeNativeString(obj.name) 203 | } 204 | // Check if the toString protype of the context is the same as the global prototype, 205 | // if not indicates that we are doing a check across different windows., e.g. the iframeWithdirect` test case 206 | const hasSameProto = Object.getPrototypeOf( 207 | Function.prototype.toString 208 | ).isPrototypeOf(ctx.toString) // eslint-disable-line no-prototype-builtins 209 | if (!hasSameProto) { 210 | // Pass the call on to the local Function.prototype.toString instead 211 | return ctx.toString() 212 | } 213 | return target.call(ctx) 214 | } 215 | }) 216 | utils.replaceProperty(Function.prototype, 'toString', { 217 | value: toStringProxy 218 | }) 219 | } 220 | 221 | /** 222 | * Make all nested functions of an object native. 223 | * 224 | * @param {object} obj 225 | */ 226 | utils.patchToStringNested = (obj = {}) => { 227 | return utils.execRecursively(obj, ['function'], utils.patchToString) 228 | } 229 | 230 | /** 231 | * Redirect toString requests from one object to another. 232 | * 233 | * @param {object} proxyObj - The object that toString will be called on 234 | * @param {object} originalObj - The object which toString result we wan to return 235 | */ 236 | utils.redirectToString = (proxyObj, originalObj) => { 237 | utils.preloadCache() 238 | 239 | const toStringProxy = new Proxy(Function.prototype.toString, { 240 | apply: function(target, ctx) { 241 | // This fixes e.g. `HTMLMediaElement.prototype.canPlayType.toString + ""` 242 | if (ctx === Function.prototype.toString) { 243 | return utils.makeNativeString('toString') 244 | } 245 | 246 | // `toString` targeted at our proxied Object detected 247 | if (ctx === proxyObj) { 248 | const fallback = () => 249 | originalObj && originalObj.name 250 | ? utils.makeNativeString(originalObj.name) 251 | : utils.makeNativeString(proxyObj.name) 252 | 253 | // Return the toString representation of our original object if possible 254 | return originalObj + '' || fallback() 255 | } 256 | 257 | // Check if the toString protype of the context is the same as the global prototype, 258 | // if not indicates that we are doing a check across different windows., e.g. the iframeWithdirect` test case 259 | const hasSameProto = Object.getPrototypeOf( 260 | Function.prototype.toString 261 | ).isPrototypeOf(ctx.toString) // eslint-disable-line no-prototype-builtins 262 | if (!hasSameProto) { 263 | // Pass the call on to the local Function.prototype.toString instead 264 | return ctx.toString() 265 | } 266 | 267 | return target.call(ctx) 268 | } 269 | }) 270 | utils.replaceProperty(Function.prototype, 'toString', { 271 | value: toStringProxy 272 | }) 273 | } 274 | 275 | /** 276 | * All-in-one method to replace a property with a JS Proxy using the provided Proxy handler with traps. 277 | * 278 | * Will stealthify these aspects (strip error stack traces, redirect toString, etc). 279 | * Note: This is meant to modify native Browser APIs and works best with prototype objects. 280 | * 281 | * @example 282 | * replaceWithProxy(WebGLRenderingContext.prototype, 'getParameter', proxyHandler) 283 | * 284 | * @param {object} obj - The object which has the property to replace 285 | * @param {string} propName - The name of the property to replace 286 | * @param {object} handler - The JS Proxy handler to use 287 | */ 288 | utils.replaceWithProxy = (obj, propName, handler) => { 289 | utils.preloadCache() 290 | const originalObj = obj[propName] 291 | const proxyObj = new Proxy(obj[propName], utils.stripProxyFromErrors(handler)) 292 | 293 | utils.replaceProperty(obj, propName, { value: proxyObj }) 294 | utils.redirectToString(proxyObj, originalObj) 295 | 296 | return true 297 | } 298 | 299 | /** 300 | * All-in-one method to mock a non-existing property with a JS Proxy using the provided Proxy handler with traps. 301 | * 302 | * Will stealthify these aspects (strip error stack traces, redirect toString, etc). 303 | * 304 | * @example 305 | * mockWithProxy(chrome.runtime, 'sendMessage', function sendMessage() {}, proxyHandler) 306 | * 307 | * @param {object} obj - The object which has the property to replace 308 | * @param {string} propName - The name of the property to replace or create 309 | * @param {object} pseudoTarget - The JS Proxy target to use as a basis 310 | * @param {object} handler - The JS Proxy handler to use 311 | */ 312 | utils.mockWithProxy = (obj, propName, pseudoTarget, handler) => { 313 | utils.preloadCache() 314 | const proxyObj = new Proxy(pseudoTarget, utils.stripProxyFromErrors(handler)) 315 | 316 | utils.replaceProperty(obj, propName, { value: proxyObj }) 317 | utils.patchToString(proxyObj) 318 | 319 | return true 320 | } 321 | 322 | /** 323 | * All-in-one method to create a new JS Proxy with stealth tweaks. 324 | * 325 | * This is meant to be used whenever we need a JS Proxy but don't want to replace or mock an existing known property. 326 | * 327 | * Will stealthify certain aspects of the Proxy (strip error stack traces, redirect toString, etc). 328 | * 329 | * @example 330 | * createProxy(navigator.mimeTypes.__proto__.namedItem, proxyHandler) // => Proxy 331 | * 332 | * @param {object} pseudoTarget - The JS Proxy target to use as a basis 333 | * @param {object} handler - The JS Proxy handler to use 334 | */ 335 | utils.createProxy = (pseudoTarget, handler) => { 336 | utils.preloadCache() 337 | const proxyObj = new Proxy(pseudoTarget, utils.stripProxyFromErrors(handler)) 338 | utils.patchToString(proxyObj) 339 | 340 | return proxyObj 341 | } 342 | 343 | /** 344 | * Helper function to split a full path to an Object into the first part and property. 345 | * 346 | * @example 347 | * splitObjPath(`HTMLMediaElement.prototype.canPlayType`) 348 | * // => {objName: "HTMLMediaElement.prototype", propName: "canPlayType"} 349 | * 350 | * @param {string} objPath - The full path to an object as dot notation string 351 | */ 352 | utils.splitObjPath = objPath => ({ 353 | // Remove last dot entry (property) ==> `HTMLMediaElement.prototype` 354 | objName: objPath 355 | .split('.') 356 | .slice(0, -1) 357 | .join('.'), 358 | // Extract last dot entry ==> `canPlayType` 359 | propName: objPath.split('.').slice(-1)[0] 360 | }) 361 | 362 | /** 363 | * Convenience method to replace a property with a JS Proxy using the provided objPath. 364 | * 365 | * Supports a full path (dot notation) to the object as string here, in case that makes it easier. 366 | * 367 | * @example 368 | * replaceObjPathWithProxy('WebGLRenderingContext.prototype.getParameter', proxyHandler) 369 | * 370 | * @param {string} objPath - The full path to an object (dot notation string) to replace 371 | * @param {object} handler - The JS Proxy handler to use 372 | */ 373 | utils.replaceObjPathWithProxy = (objPath, handler) => { 374 | const { objName, propName } = utils.splitObjPath(objPath) 375 | const obj = eval(objName) // eslint-disable-line no-eval 376 | return utils.replaceWithProxy(obj, propName, handler) 377 | } 378 | 379 | /** 380 | * Traverse nested properties of an object recursively and apply the given function on a whitelist of value types. 381 | * 382 | * @param {object} obj 383 | * @param {array} typeFilter - e.g. `['function']` 384 | * @param {Function} fn - e.g. `utils.patchToString` 385 | */ 386 | utils.execRecursively = (obj = {}, typeFilter = [], fn) => { 387 | function recurse(obj) { 388 | for (const key in obj) { 389 | if (obj[key] === undefined) { 390 | continue 391 | } 392 | if (obj[key] && typeof obj[key] === 'object') { 393 | recurse(obj[key]) 394 | } else { 395 | if (obj[key] && typeFilter.includes(typeof obj[key])) { 396 | fn.call(this, obj[key]) 397 | } 398 | } 399 | } 400 | } 401 | recurse(obj) 402 | return obj 403 | } 404 | 405 | /** 406 | * Everything we run through e.g. `page.evaluate` runs in the browser context, not the NodeJS one. 407 | * That means we cannot just use reference variables and functions from outside code, we need to pass everything as a parameter. 408 | * 409 | * Unfortunately the data we can pass is only allowed to be of primitive types, regular functions don't survive the built-in serialization process. 410 | * This utility function will take an object with functions and stringify them, so we can pass them down unharmed as strings. 411 | * 412 | * We use this to pass down our utility functions as well as any other functions (to be able to split up code better). 413 | * 414 | * @see utils.materializeFns 415 | * 416 | * @param {object} fnObj - An object containing functions as properties 417 | */ 418 | utils.stringifyFns = (fnObj = { hello: () => 'world' }) => { 419 | // Object.fromEntries() ponyfill (in 6 lines) - supported only in Node v12+, modern browsers are fine 420 | // https://github.com/feross/fromentries 421 | function fromEntries(iterable) { 422 | return [...iterable].reduce((obj, [key, val]) => { 423 | obj[key] = val 424 | return obj 425 | }, {}) 426 | } 427 | return (Object.fromEntries || fromEntries)( 428 | Object.entries(fnObj) 429 | .filter(([key, value]) => typeof value === 'function') 430 | .map(([key, value]) => [key, value.toString()]) // eslint-disable-line no-eval 431 | ) 432 | } 433 | 434 | /** 435 | * Utility function to reverse the process of `utils.stringifyFns`. 436 | * Will materialize an object with stringified functions (supports classic and fat arrow functions). 437 | * 438 | * @param {object} fnStrObj - An object containing stringified functions as properties 439 | */ 440 | utils.materializeFns = (fnStrObj = { hello: "() => 'world'" }) => { 441 | return Object.fromEntries( 442 | Object.entries(fnStrObj).map(([key, value]) => { 443 | if (value.startsWith('function')) { 444 | // some trickery is needed to make oldschool functions work :-) 445 | return [key, eval(`() => ${value}`)()] // eslint-disable-line no-eval 446 | } else { 447 | // arrow functions just work 448 | return [key, eval(value)] // eslint-disable-line no-eval 449 | } 450 | }) 451 | ) 452 | } 453 | 454 | // -- 455 | // Stuff starting below this line is NodeJS specific. 456 | // -- 457 | // module.exports = utils 458 | """ 459 | -------------------------------------------------------------------------------- /TikTokApi/stealth/js/webgl_vendor.py: -------------------------------------------------------------------------------- 1 | webgl_vendor = """ 2 | console.log(opts) 3 | const getParameterProxyHandler = { 4 | apply: function (target, ctx, args) { 5 | const param = (args || [])[0] 6 | // UNMASKED_VENDOR_WEBGL 7 | if (param === 37445) { 8 | return opts.webgl_vendor || 'Intel Inc.' // default in headless: Google Inc. 9 | } 10 | // UNMASKED_RENDERER_WEBGL 11 | if (param === 37446) { 12 | return opts.webgl_renderer || 'Intel Iris OpenGL Engine' // default in headless: Google SwiftShader 13 | } 14 | return utils.cache.Reflect.apply(target, ctx, args) 15 | } 16 | } 17 | 18 | // There's more than one WebGL rendering context 19 | // https://developer.mozilla.org/en-US/docs/Web/API/WebGL2RenderingContext#Browser_compatibility 20 | // To find out the original values here: Object.getOwnPropertyDescriptors(WebGLRenderingContext.prototype.getParameter) 21 | const addProxy = (obj, propName) => { 22 | utils.replaceWithProxy(obj, propName, getParameterProxyHandler) 23 | } 24 | // For whatever weird reason loops don't play nice with Object.defineProperty, here's the next best thing: 25 | addProxy(WebGLRenderingContext.prototype, 'getParameter') 26 | addProxy(WebGL2RenderingContext.prototype, 'getParameter') 27 | """ 28 | -------------------------------------------------------------------------------- /TikTokApi/stealth/js/window_outerdimensions.py: -------------------------------------------------------------------------------- 1 | window_outerdimensions = """ 2 | 'use strict' 3 | 4 | try { 5 | if (!!window.outerWidth && !!window.outerHeight) { 6 | const windowFrame = 85 // probably OS and WM dependent 7 | window.outerWidth = window.innerWidth 8 | console.log(`current window outer height ${window.outerHeight}`) 9 | window.outerHeight = window.innerHeight + windowFrame 10 | console.log(`new window outer height ${window.outerHeight}`) 11 | } 12 | } catch (err) { 13 | } 14 | 15 | """ 16 | -------------------------------------------------------------------------------- /TikTokApi/stealth/stealth.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | from dataclasses import dataclass 4 | from typing import Tuple, Optional, Dict 5 | 6 | from playwright.async_api import Page as AsyncPage 7 | 8 | from .js.chrome_app import chrome_app 9 | from .js.chrome_csi import chrome_csi 10 | from .js.chrome_hairline import chrome_hairline 11 | from .js.chrome_load_times import chrome_load_times 12 | from .js.chrome_runtime import chrome_runtime 13 | from .js.generate_magic_arrays import generate_magic_arrays 14 | from .js.iframe_contentWindow import iframe_contentWindow 15 | from .js.media_codecs import media_codecs 16 | from .js.navigator_hardwareConcurrency import navigator_hardwareConcurrency 17 | from .js.navigator_languages import navigator_languages 18 | from .js.navigator_permissions import navigator_permissions 19 | from .js.navigator_platform import navigator_platform 20 | from .js.navigator_plugins import navigator_plugins 21 | from .js.navigator_userAgent import navigator_userAgent 22 | from .js.navigator_vendor import navigator_vendor 23 | from .js.webgl_vendor import webgl_vendor 24 | from .js.window_outerdimensions import window_outerdimensions 25 | from .js.utils import utils 26 | 27 | SCRIPTS: Dict[str, str] = { 28 | "chrome_csi": chrome_csi, 29 | "chrome_app": chrome_app, 30 | "chrome_runtime": chrome_runtime, 31 | "chrome_load_times": chrome_load_times, 32 | "chrome_hairline": chrome_hairline, 33 | "generate_magic_arrays": generate_magic_arrays, 34 | "iframe_content_window": iframe_contentWindow, 35 | "media_codecs": media_codecs, 36 | "navigator_vendor": navigator_vendor, 37 | "navigator_plugins": navigator_plugins, 38 | "navigator_permissions": navigator_permissions, 39 | "navigator_languages": navigator_languages, 40 | "navigator_platform": navigator_platform, 41 | "navigator_user_agent": navigator_userAgent, 42 | "navigator_hardware_concurrency": navigator_hardwareConcurrency, 43 | "outerdimensions": window_outerdimensions, 44 | "utils": utils, 45 | "webdriver": "delete Object.getPrototypeOf(navigator).webdriver", 46 | "webgl_vendor": webgl_vendor, 47 | } 48 | 49 | 50 | @dataclass 51 | class StealthConfig: 52 | """ 53 | Playwright stealth configuration that applies stealth strategies to playwright page objects. 54 | The stealth strategies are contained in ./js package and are basic javascript scripts that are executed 55 | on every page.goto() called. 56 | Note: 57 | All init scripts are combined by playwright into one script and then executed this means 58 | the scripts should not have conflicting constants/variables etc. ! 59 | This also means scripts can be extended by overriding enabled_scripts generator: 60 | ``` 61 | @property 62 | def enabled_scripts(): 63 | yield 'console.log("first script")' 64 | yield from super().enabled_scripts() 65 | yield 'console.log("last script")' 66 | ``` 67 | """ 68 | 69 | # load script options 70 | webdriver: bool = True 71 | webgl_vendor: bool = True 72 | chrome_app: bool = True 73 | chrome_csi: bool = True 74 | chrome_load_times: bool = True 75 | chrome_runtime: bool = True 76 | iframe_content_window: bool = True 77 | media_codecs: bool = True 78 | navigator_hardware_concurrency: int = 4 79 | navigator_languages: bool = False 80 | navigator_permissions: bool = True 81 | navigator_platform: bool = True 82 | navigator_plugins: bool = True 83 | navigator_user_agent: bool = False 84 | navigator_vendor: bool = False 85 | outerdimensions: bool = True 86 | hairline: bool = True 87 | 88 | # options 89 | vendor: str = "Intel Inc." 90 | renderer: str = "Intel Iris OpenGL Engine" 91 | nav_vendor: str = "Google Inc." 92 | nav_user_agent: str = None 93 | nav_platform: str = None 94 | languages: Tuple[str] = ("en-US", "en") 95 | runOnInsecureOrigins: Optional[bool] = None 96 | 97 | @property 98 | def enabled_scripts(self): 99 | opts = json.dumps( 100 | { 101 | "webgl_vendor": self.vendor, 102 | "webgl_renderer": self.renderer, 103 | "navigator_vendor": self.nav_vendor, 104 | "navigator_platform": self.nav_platform, 105 | "navigator_user_agent": self.nav_user_agent, 106 | "languages": list(self.languages), 107 | "runOnInsecureOrigins": self.runOnInsecureOrigins, 108 | } 109 | ) 110 | # defined options constant 111 | yield f"const opts = {opts}" 112 | # init utils and generate_magic_arrays helper 113 | yield SCRIPTS["utils"] 114 | yield SCRIPTS["generate_magic_arrays"] 115 | 116 | if self.chrome_app: 117 | yield SCRIPTS["chrome_app"] 118 | if self.chrome_csi: 119 | yield SCRIPTS["chrome_csi"] 120 | if self.hairline: 121 | yield SCRIPTS["chrome_hairline"] 122 | if self.chrome_load_times: 123 | yield SCRIPTS["chrome_load_times"] 124 | if self.chrome_runtime: 125 | yield SCRIPTS["chrome_runtime"] 126 | if self.iframe_content_window: 127 | yield SCRIPTS["iframe_content_window"] 128 | if self.media_codecs: 129 | yield SCRIPTS["media_codecs"] 130 | if self.navigator_languages: 131 | yield SCRIPTS["navigator_languages"] 132 | if self.navigator_permissions: 133 | yield SCRIPTS["navigator_permissions"] 134 | if self.navigator_platform: 135 | yield SCRIPTS["navigator_platform"] 136 | if self.navigator_plugins: 137 | yield SCRIPTS["navigator_plugins"] 138 | if self.navigator_user_agent: 139 | yield SCRIPTS["navigator_user_agent"] 140 | if self.navigator_vendor: 141 | yield SCRIPTS["navigator_vendor"] 142 | if self.webdriver: 143 | yield SCRIPTS["webdriver"] 144 | if self.outerdimensions: 145 | yield SCRIPTS["outerdimensions"] 146 | if self.webgl_vendor: 147 | yield SCRIPTS["webgl_vendor"] 148 | 149 | 150 | async def stealth_async(page: AsyncPage, config: StealthConfig = None): 151 | """stealth the page""" 152 | for script in (config or StealthConfig()).enabled_scripts: 153 | await page.add_init_script(script) 154 | -------------------------------------------------------------------------------- /TikTokApi/tiktok.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import dataclasses 4 | from typing import Any 5 | import random 6 | import time 7 | import json 8 | 9 | from playwright.async_api import async_playwright, TimeoutError 10 | from urllib.parse import urlencode, quote, urlparse 11 | from .stealth import stealth_async 12 | from .helpers import random_choice 13 | 14 | from .api.user import User 15 | from .api.video import Video 16 | from .api.sound import Sound 17 | from .api.hashtag import Hashtag 18 | from .api.comment import Comment 19 | from .api.trending import Trending 20 | from .api.search import Search 21 | from .api.playlist import Playlist 22 | 23 | from .exceptions import ( 24 | InvalidJSONException, 25 | EmptyResponseException, 26 | ) 27 | 28 | 29 | @dataclasses.dataclass 30 | class TikTokPlaywrightSession: 31 | """A TikTok session using Playwright""" 32 | 33 | context: Any 34 | page: Any 35 | proxy: str = None 36 | params: dict = None 37 | headers: dict = None 38 | ms_token: str = None 39 | base_url: str = "https://www.tiktok.com" 40 | 41 | 42 | class TikTokApi: 43 | """The main TikTokApi class that contains all the endpoints. 44 | 45 | Import With: 46 | .. code-block:: python 47 | 48 | from TikTokApi import TikTokApi 49 | api = TikTokApi() 50 | """ 51 | 52 | user = User 53 | video = Video 54 | sound = Sound 55 | hashtag = Hashtag 56 | comment = Comment 57 | trending = Trending 58 | search = Search 59 | playlist = Playlist 60 | 61 | def __init__(self, logging_level: int = logging.WARN, logger_name: str = None): 62 | """ 63 | Create a TikTokApi object. 64 | 65 | Args: 66 | logging_level (int): The logging level you want to use. 67 | logger_name (str): The name of the logger you want to use. 68 | """ 69 | self.sessions = [] 70 | 71 | if logger_name is None: 72 | logger_name = __name__ 73 | self.__create_logger(logger_name, logging_level) 74 | 75 | User.parent = self 76 | Video.parent = self 77 | Sound.parent = self 78 | Hashtag.parent = self 79 | Comment.parent = self 80 | Trending.parent = self 81 | Search.parent = self 82 | Playlist.parent = self 83 | 84 | def __create_logger(self, name: str, level: int = logging.DEBUG): 85 | """Create a logger for the class.""" 86 | self.logger: logging.Logger = logging.getLogger(name) 87 | self.logger.setLevel(level) 88 | handler = logging.StreamHandler() 89 | formatter = logging.Formatter( 90 | "%(asctime)s - %(name)s - %(levelname)s - %(message)s" 91 | ) 92 | handler.setFormatter(formatter) 93 | self.logger.addHandler(handler) 94 | 95 | async def __set_session_params(self, session: TikTokPlaywrightSession): 96 | """Set the session params for a TikTokPlaywrightSession""" 97 | user_agent = await session.page.evaluate("() => navigator.userAgent") 98 | language = await session.page.evaluate( 99 | "() => navigator.language || navigator.userLanguage" 100 | ) 101 | platform = await session.page.evaluate("() => navigator.platform") 102 | device_id = str(random.randint(10**18, 10**19 - 1)) # Random device id 103 | history_len = str(random.randint(1, 10)) # Random history length 104 | screen_height = str(random.randint(600, 1080)) # Random screen height 105 | screen_width = str(random.randint(800, 1920)) # Random screen width 106 | timezone = await session.page.evaluate( 107 | "() => Intl.DateTimeFormat().resolvedOptions().timeZone" 108 | ) 109 | 110 | session_params = { 111 | "aid": "1988", 112 | "app_language": language, 113 | "app_name": "tiktok_web", 114 | "browser_language": language, 115 | "browser_name": "Mozilla", 116 | "browser_online": "true", 117 | "browser_platform": platform, 118 | "browser_version": user_agent, 119 | "channel": "tiktok_web", 120 | "cookie_enabled": "true", 121 | "device_id": device_id, 122 | "device_platform": "web_pc", 123 | "focus_state": "true", 124 | "from_page": "user", 125 | "history_len": history_len, 126 | "is_fullscreen": "false", 127 | "is_page_visible": "true", 128 | "language": language, 129 | "os": platform, 130 | "priority_region": "", 131 | "referer": "", 132 | "region": "US", # TODO: TikTokAPI option 133 | "screen_height": screen_height, 134 | "screen_width": screen_width, 135 | "tz_name": timezone, 136 | "webcast_language": language, 137 | } 138 | session.params = session_params 139 | 140 | async def __create_session( 141 | self, 142 | url: str = "https://www.tiktok.com", 143 | ms_token: str = None, 144 | proxy: str = None, 145 | context_options: dict = {}, 146 | sleep_after: int = 1, 147 | cookies: dict = None, 148 | suppress_resource_load_types: list[str] = None, 149 | timeout: int = 30000, 150 | ): 151 | try: 152 | """Create a TikTokPlaywrightSession""" 153 | if ms_token is not None: 154 | if cookies is None: 155 | cookies = {} 156 | cookies["msToken"] = ms_token 157 | 158 | context = await self.browser.new_context(proxy=proxy, **context_options) 159 | if cookies is not None: 160 | formatted_cookies = [ 161 | {"name": k, "value": v, "domain": urlparse(url).netloc, "path": "/"} 162 | for k, v in cookies.items() 163 | if v is not None 164 | ] 165 | await context.add_cookies(formatted_cookies) 166 | page = await context.new_page() 167 | await stealth_async(page) 168 | 169 | # Get the request headers to the url 170 | request_headers = None 171 | 172 | def handle_request(request): 173 | nonlocal request_headers 174 | request_headers = request.headers 175 | 176 | page.once("request", handle_request) 177 | 178 | if suppress_resource_load_types is not None: 179 | await page.route( 180 | "**/*", 181 | lambda route, request: route.abort() 182 | if request.resource_type in suppress_resource_load_types 183 | else route.continue_(), 184 | ) 185 | 186 | # Set the navigation timeout 187 | page.set_default_navigation_timeout(timeout) 188 | 189 | await page.goto(url) 190 | await page.goto(url) # hack: tiktok blocks first request not sure why, likely bot detection 191 | 192 | # by doing this, we are simulate scroll event using mouse to `avoid` bot detection 193 | x, y = random.randint(0, 50), random.randint(0, 50) 194 | a, b = random.randint(1, 50), random.randint(100, 200) 195 | 196 | await page.mouse.move(x, y) 197 | await page.wait_for_load_state("networkidle") 198 | await page.mouse.move(a, b) 199 | 200 | session = TikTokPlaywrightSession( 201 | context, 202 | page, 203 | ms_token=ms_token, 204 | proxy=proxy, 205 | headers=request_headers, 206 | base_url=url, 207 | ) 208 | 209 | if ms_token is None: 210 | await asyncio.sleep(sleep_after) # TODO: Find a better way to wait for msToken 211 | cookies = await self.get_session_cookies(session) 212 | ms_token = cookies.get("msToken") 213 | session.ms_token = ms_token 214 | if ms_token is None: 215 | self.logger.info( 216 | f"Failed to get msToken on session index {len(self.sessions)}, you should consider specifying ms_tokens" 217 | ) 218 | self.sessions.append(session) 219 | await self.__set_session_params(session) 220 | except Exception as e: 221 | # clean up 222 | self.logger.error(f"Failed to create session: {e}") 223 | # Cleanup resources if they were partially created 224 | if 'page' in locals(): 225 | await page.close() 226 | if 'context' in locals(): 227 | await context.close() 228 | raise # Re-raise the exception after cleanup 229 | 230 | async def create_sessions( 231 | self, 232 | num_sessions=5, 233 | headless=True, 234 | ms_tokens: list[str] = None, 235 | proxies: list = None, 236 | sleep_after=1, 237 | starting_url="https://www.tiktok.com", 238 | context_options: dict = {}, 239 | override_browser_args: list[dict] = None, 240 | cookies: list[dict] = None, 241 | suppress_resource_load_types: list[str] = None, 242 | browser: str = "chromium", 243 | executable_path: str = None, 244 | timeout: int = 30000, 245 | ): 246 | """ 247 | Create sessions for use within the TikTokApi class. 248 | 249 | These sessions are what will carry out requesting your data from TikTok. 250 | 251 | Args: 252 | num_sessions (int): The amount of sessions you want to create. 253 | headless (bool): Whether or not you want the browser to be headless. 254 | ms_tokens (list[str]): A list of msTokens to use for the sessions, you can get these from your cookies after visiting TikTok. 255 | If you don't provide any, the sessions will try to get them themselves, but this is not guaranteed to work. 256 | proxies (list): A list of proxies to use for the sessions 257 | sleep_after (int): The amount of time to sleep after creating a session, this is to allow the msToken to be generated. 258 | starting_url (str): The url to start the sessions on, this is usually https://www.tiktok.com. 259 | context_options (dict): Options to pass to the playwright context. 260 | override_browser_args (list[dict]): A list of dictionaries containing arguments to pass to the browser. 261 | cookies (list[dict]): A list of cookies to use for the sessions, you can get these from your cookies after visiting TikTok. 262 | suppress_resource_load_types (list[str]): Types of resources to suppress playwright from loading, excluding more types will make playwright faster.. Types: document, stylesheet, image, media, font, script, textrack, xhr, fetch, eventsource, websocket, manifest, other. 263 | browser (str): firefox, chromium, or webkit; default is chromium 264 | executable_path (str): Path to the browser executable 265 | timeout (int): The timeout in milliseconds for page navigation 266 | 267 | Example Usage: 268 | .. code-block:: python 269 | 270 | from TikTokApi import TikTokApi 271 | with TikTokApi() as api: 272 | await api.create_sessions(num_sessions=5, ms_tokens=['msToken1', 'msToken2']) 273 | """ 274 | self.playwright = await async_playwright().start() 275 | if browser == "chromium": 276 | if headless and override_browser_args is None: 277 | override_browser_args = ["--headless=new"] 278 | headless = False # managed by the arg 279 | self.browser = await self.playwright.chromium.launch( 280 | headless=headless, args=override_browser_args, proxy=random_choice(proxies), executable_path=executable_path 281 | ) 282 | elif browser == "firefox": 283 | self.browser = await self.playwright.firefox.launch( 284 | headless=headless, args=override_browser_args, proxy=random_choice(proxies), executable_path=executable_path 285 | ) 286 | elif browser == "webkit": 287 | self.browser = await self.playwright.webkit.launch( 288 | headless=headless, args=override_browser_args, proxy=random_choice(proxies), executable_path=executable_path 289 | ) 290 | else: 291 | raise ValueError("Invalid browser argument passed") 292 | 293 | await asyncio.gather( 294 | *( 295 | self.__create_session( 296 | proxy=random_choice(proxies), 297 | ms_token=random_choice(ms_tokens), 298 | url=starting_url, 299 | context_options=context_options, 300 | sleep_after=sleep_after, 301 | cookies=random_choice(cookies), 302 | suppress_resource_load_types=suppress_resource_load_types, 303 | timeout=timeout, 304 | ) 305 | for _ in range(num_sessions) 306 | ) 307 | ) 308 | 309 | async def close_sessions(self): 310 | """ 311 | Close all the sessions. Should be called when you're done with the TikTokApi object 312 | 313 | This is called automatically when using the TikTokApi with "with" 314 | """ 315 | for session in self.sessions: 316 | await session.page.close() 317 | await session.context.close() 318 | self.sessions.clear() 319 | 320 | await self.browser.close() 321 | await self.playwright.stop() 322 | 323 | def generate_js_fetch(self, method: str, url: str, headers: dict) -> str: 324 | """Generate a javascript fetch function for use in playwright""" 325 | headers_js = json.dumps(headers) 326 | return f""" 327 | () => {{ 328 | return new Promise((resolve, reject) => {{ 329 | fetch('{url}', {{ method: '{method}', headers: {headers_js} }}) 330 | .then(response => response.text()) 331 | .then(data => resolve(data)) 332 | .catch(error => reject(error.message)); 333 | }}); 334 | }} 335 | """ 336 | 337 | def _get_session(self, **kwargs): 338 | """Get a random session 339 | 340 | Args: 341 | session_index (int): The index of the session you want to use, if not provided a random session will be used. 342 | 343 | Returns: 344 | int: The index of the session. 345 | TikTokPlaywrightSession: The session. 346 | """ 347 | if len(self.sessions) == 0: 348 | raise Exception("No sessions created, please create sessions first") 349 | 350 | if kwargs.get("session_index") is not None: 351 | i = kwargs["session_index"] 352 | else: 353 | i = random.randint(0, len(self.sessions) - 1) 354 | return i, self.sessions[i] 355 | 356 | async def set_session_cookies(self, session, cookies): 357 | """ 358 | Set the cookies for a session 359 | 360 | Args: 361 | session (TikTokPlaywrightSession): The session to set the cookies for. 362 | cookies (dict): The cookies to set for the session. 363 | """ 364 | await session.context.add_cookies(cookies) 365 | 366 | async def get_session_cookies(self, session): 367 | """ 368 | Get the cookies for a session 369 | 370 | Args: 371 | session (TikTokPlaywrightSession): The session to get the cookies for. 372 | 373 | Returns: 374 | dict: The cookies for the session. 375 | """ 376 | cookies = await session.context.cookies() 377 | return {cookie["name"]: cookie["value"] for cookie in cookies} 378 | 379 | async def run_fetch_script(self, url: str, headers: dict, **kwargs): 380 | """ 381 | Execute a javascript fetch function in a session 382 | 383 | Args: 384 | url (str): The url to fetch. 385 | headers (dict): The headers to use for the fetch. 386 | 387 | Returns: 388 | any: The result of the fetch. Seems to be a string or dict 389 | """ 390 | js_script = self.generate_js_fetch("GET", url, headers) 391 | _, session = self._get_session(**kwargs) 392 | result = await session.page.evaluate(js_script) 393 | return result 394 | 395 | async def generate_x_bogus(self, url: str, **kwargs): 396 | """Generate the X-Bogus header for a url""" 397 | _, session = self._get_session(**kwargs) 398 | 399 | max_attempts = 5 400 | attempts = 0 401 | while attempts < max_attempts: 402 | attempts += 1 403 | try: 404 | timeout_time = random.randint(5000, 20000) 405 | await session.page.wait_for_function("window.byted_acrawler !== undefined", timeout=timeout_time) 406 | break 407 | except TimeoutError as e: 408 | if attempts == max_attempts: 409 | raise TimeoutError(f"Failed to load tiktok after {max_attempts} attempts, consider using a proxy") 410 | 411 | try_urls = ["https://www.tiktok.com/foryou", "https://www.tiktok.com", "https://www.tiktok.com/@tiktok", "https://www.tiktok.com/foryou"] 412 | 413 | await session.page.goto(random.choice(try_urls)) 414 | 415 | result = await session.page.evaluate( 416 | f'() => {{ return window.byted_acrawler.frontierSign("{url}") }}' 417 | ) 418 | return result 419 | 420 | async def sign_url(self, url: str, **kwargs): 421 | """Sign a url""" 422 | i, session = self._get_session(**kwargs) 423 | 424 | # TODO: Would be nice to generate msToken here 425 | 426 | # Add X-Bogus to url 427 | x_bogus = (await self.generate_x_bogus(url, session_index=i)).get("X-Bogus") 428 | if x_bogus is None: 429 | raise Exception("Failed to generate X-Bogus") 430 | 431 | if "?" in url: 432 | url += "&" 433 | else: 434 | url += "?" 435 | url += f"X-Bogus={x_bogus}" 436 | 437 | return url 438 | 439 | async def make_request( 440 | self, 441 | url: str, 442 | headers: dict = None, 443 | params: dict = None, 444 | retries: int = 3, 445 | exponential_backoff: bool = True, 446 | **kwargs, 447 | ): 448 | """ 449 | Makes a request to TikTok through a session. 450 | 451 | Args: 452 | url (str): The url to make the request to. 453 | headers (dict): The headers to use for the request. 454 | params (dict): The params to use for the request. 455 | retries (int): The amount of times to retry the request if it fails. 456 | exponential_backoff (bool): Whether or not to use exponential backoff when retrying the request. 457 | session_index (int): The index of the session you want to use, if not provided a random session will be used. 458 | 459 | Returns: 460 | dict: The json response from TikTok. 461 | 462 | Raises: 463 | Exception: If the request fails. 464 | """ 465 | i, session = self._get_session(**kwargs) 466 | if session.params is not None: 467 | params = {**session.params, **params} 468 | 469 | if headers is not None: 470 | headers = {**session.headers, **headers} 471 | else: 472 | headers = session.headers 473 | 474 | # get msToken 475 | if params.get("msToken") is None: 476 | # try to get msToken from session 477 | if session.ms_token is not None: 478 | params["msToken"] = session.ms_token 479 | else: 480 | # we'll try to read it from cookies 481 | cookies = await self.get_session_cookies(session) 482 | ms_token = cookies.get("msToken") 483 | if ms_token is None: 484 | self.logger.warn( 485 | "Failed to get msToken from cookies, trying to make the request anyway (probably will fail)" 486 | ) 487 | params["msToken"] = ms_token 488 | 489 | encoded_params = f"{url}?{urlencode(params, safe='=', quote_via=quote)}" 490 | signed_url = await self.sign_url(encoded_params, session_index=i) 491 | 492 | retry_count = 0 493 | while retry_count < retries: 494 | retry_count += 1 495 | result = await self.run_fetch_script( 496 | signed_url, headers=headers, session_index=i 497 | ) 498 | 499 | if result is None: 500 | raise Exception("TikTokApi.run_fetch_script returned None") 501 | 502 | if result == "": 503 | raise EmptyResponseException(result, "TikTok returned an empty response. They are detecting you're a bot, try some of these: headless=False, browser='webkit', consider using a proxy") 504 | 505 | try: 506 | data = json.loads(result) 507 | if data.get("status_code") != 0: 508 | self.logger.error(f"Got an unexpected status code: {data}") 509 | return data 510 | except json.decoder.JSONDecodeError: 511 | if retry_count == retries: 512 | self.logger.error(f"Failed to decode json response: {result}") 513 | raise InvalidJSONException() 514 | 515 | self.logger.info( 516 | f"Failed a request, retrying ({retry_count}/{retries})" 517 | ) 518 | if exponential_backoff: 519 | await asyncio.sleep(2**retry_count) 520 | else: 521 | await asyncio.sleep(1) 522 | 523 | async def close_sessions(self): 524 | """Close all the sessions. Should be called when you're done with the TikTokApi object""" 525 | for session in self.sessions: 526 | await session.page.close() 527 | await session.context.close() 528 | self.sessions.clear() 529 | 530 | async def stop_playwright(self): 531 | """Stop the playwright browser""" 532 | await self.browser.close() 533 | await self.playwright.stop() 534 | 535 | async def get_session_content(self, url: str, **kwargs): 536 | """Get the content of a url""" 537 | _, session = self._get_session(**kwargs) 538 | return await session.page.content() 539 | 540 | async def __aenter__(self): 541 | return self 542 | 543 | async def __aexit__(self, exc_type, exc, tb): 544 | await self.close_sessions() 545 | await self.stop_playwright() 546 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman -------------------------------------------------------------------------------- /examples/comment_example.py: -------------------------------------------------------------------------------- 1 | from TikTokApi import TikTokApi 2 | import asyncio 3 | import os 4 | 5 | video_id = 7248300636498890011 6 | ms_token = os.environ.get("ms_token", None) # set your own ms_token 7 | 8 | 9 | async def get_comments(): 10 | async with TikTokApi() as api: 11 | await api.create_sessions(ms_tokens=[ms_token], num_sessions=1, sleep_after=3, browser=os.getenv("TIKTOK_BROWSER", "chromium")) 12 | video = api.video(id=video_id) 13 | count = 0 14 | async for comment in video.comments(count=30): 15 | print(comment) 16 | print(comment.as_dict) 17 | 18 | 19 | if __name__ == "__main__": 20 | asyncio.run(get_comments()) 21 | -------------------------------------------------------------------------------- /examples/hashtag_example.py: -------------------------------------------------------------------------------- 1 | from TikTokApi import TikTokApi 2 | import asyncio 3 | import os 4 | 5 | ms_token = os.environ.get("ms_token", None) # set your own ms_token 6 | 7 | 8 | async def get_hashtag_videos(): 9 | async with TikTokApi() as api: 10 | await api.create_sessions(ms_tokens=[ms_token], num_sessions=1, sleep_after=3, browser=os.getenv("TIKTOK_BROWSER", "chromium")) 11 | tag = api.hashtag(name="funny") 12 | async for video in tag.videos(count=30): 13 | print(video) 14 | print(video.as_dict) 15 | 16 | 17 | if __name__ == "__main__": 18 | asyncio.run(get_hashtag_videos()) 19 | -------------------------------------------------------------------------------- /examples/playlist_example.py: -------------------------------------------------------------------------------- 1 | from TikTokApi import TikTokApi 2 | import asyncio 3 | import os 4 | 5 | ms_token = os.environ.get( 6 | "ms_token", None 7 | ) # set your own ms_token, think it might need to have visited a profile 8 | 9 | 10 | async def user_example(): 11 | async with TikTokApi() as api: 12 | await api.create_sessions(ms_tokens=[ms_token], num_sessions=1, sleep_after=3) 13 | user = api.user("therock") 14 | 15 | async for playlist in user.playlists(count=3): 16 | print(playlist) 17 | print(playlist.name) 18 | 19 | async for video in playlist.videos(count=3): 20 | print(video) 21 | print(video.url) 22 | 23 | if __name__ == "__main__": 24 | asyncio.run(user_example()) 25 | -------------------------------------------------------------------------------- /examples/search_example.py: -------------------------------------------------------------------------------- 1 | from TikTokApi import TikTokApi 2 | import asyncio 3 | import os 4 | 5 | ms_token = os.environ.get( 6 | "ms_token", None 7 | ) # set your own ms_token, needs to have done a search before for this to work 8 | 9 | 10 | async def search_users(): 11 | async with TikTokApi() as api: 12 | await api.create_sessions(ms_tokens=[ms_token], num_sessions=1, sleep_after=3, browser=os.getenv("TIKTOK_BROWSER", "chromium")) 13 | async for user in api.search.users("david teather", count=10): 14 | print(user) 15 | 16 | 17 | if __name__ == "__main__": 18 | asyncio.run(search_users()) 19 | -------------------------------------------------------------------------------- /examples/sound_example.py: -------------------------------------------------------------------------------- 1 | from TikTokApi import TikTokApi 2 | import asyncio 3 | import os 4 | 5 | ms_token = os.environ.get("ms_token", None) # set your own ms_token 6 | sound_id = "7016547803243022337" 7 | 8 | 9 | async def sound_videos(): 10 | async with TikTokApi() as api: 11 | await api.create_sessions(ms_tokens=[ms_token], num_sessions=1, sleep_after=3, browser=os.getenv("TIKTOK_BROWSER", "chromium")) 12 | async for sound in api.sound(id=sound_id).videos(count=30): 13 | print(sound) 14 | print(sound.as_dict) 15 | 16 | 17 | if __name__ == "__main__": 18 | asyncio.run(sound_videos()) 19 | -------------------------------------------------------------------------------- /examples/trending_example.py: -------------------------------------------------------------------------------- 1 | from TikTokApi import TikTokApi 2 | import asyncio 3 | import os 4 | 5 | ms_token = os.environ.get("ms_token", None) # set your own ms_token 6 | 7 | 8 | async def trending_videos(): 9 | async with TikTokApi() as api: 10 | await api.create_sessions(ms_tokens=[ms_token], num_sessions=1, sleep_after=3, browser=os.getenv("TIKTOK_BROWSER", "chromium")) 11 | async for video in api.trending.videos(count=30): 12 | print(video) 13 | print(video.as_dict) 14 | 15 | 16 | if __name__ == "__main__": 17 | asyncio.run(trending_videos()) 18 | -------------------------------------------------------------------------------- /examples/user_example.py: -------------------------------------------------------------------------------- 1 | from TikTokApi import TikTokApi 2 | import asyncio 3 | import os 4 | 5 | ms_token = os.environ.get( 6 | "ms_token", None 7 | ) # set your own ms_token, think it might need to have visited a profile 8 | 9 | 10 | async def user_example(): 11 | async with TikTokApi() as api: 12 | await api.create_sessions(ms_tokens=[ms_token], num_sessions=1, sleep_after=3, browser=os.getenv("TIKTOK_BROWSER", "chromium")) 13 | user = api.user("therock") 14 | user_data = await user.info() 15 | print(user_data) 16 | 17 | async for video in user.videos(count=30): 18 | print(video) 19 | print(video.as_dict) 20 | 21 | async for playlist in user.playlists(): 22 | print(playlist) 23 | 24 | 25 | if __name__ == "__main__": 26 | asyncio.run(user_example()) 27 | -------------------------------------------------------------------------------- /examples/video_example.py: -------------------------------------------------------------------------------- 1 | from TikTokApi import TikTokApi 2 | import asyncio 3 | import os 4 | 5 | ms_token = os.environ.get( 6 | "ms_token", None 7 | ) # set your own ms_token, think it might need to have visited a profile 8 | 9 | 10 | async def get_video_example(): 11 | async with TikTokApi() as api: 12 | await api.create_sessions(ms_tokens=[ms_token], num_sessions=1, sleep_after=3, browser=os.getenv("TIKTOK_BROWSER", "chromium")) 13 | video = api.video( 14 | url="https://www.tiktok.com/@davidteathercodes/video/7074717081563942186" 15 | ) 16 | 17 | async for related_video in video.related_videos(count=10): 18 | print(related_video) 19 | print(related_video.as_dict) 20 | 21 | video_info = await video.info() # is HTML request, so avoid using this too much 22 | print(video_info) 23 | video_bytes = await video.bytes() 24 | with open("video.mp4", "wb") as f: 25 | f.write(video_bytes) 26 | 27 | 28 | if __name__ == "__main__": 29 | asyncio.run(get_video_example()) 30 | -------------------------------------------------------------------------------- /imgs/EnsembleData.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidteather/TikTok-Api/62a8cfa8ab7bb5bbdd0f8c8b13e84731fff7ac75/imgs/EnsembleData.png -------------------------------------------------------------------------------- /imgs/tikapi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidteather/TikTok-Api/62a8cfa8ab7bb5bbdd0f8c8b13e84731fff7ac75/imgs/tikapi.png -------------------------------------------------------------------------------- /imgs/tiktok_captcha_solver.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidteather/TikTok-Api/62a8cfa8ab7bb5bbdd0f8c8b13e84731fff7ac75/imgs/tiktok_captcha_solver.png -------------------------------------------------------------------------------- /imgs/webshare.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidteather/TikTok-Api/62a8cfa8ab7bb5bbdd0f8c8b13e84731fff7ac75/imgs/webshare.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests>=2.31.0,<3.0 2 | playwright>=1.36.0,<2.0 3 | httpx>=0.27.0,<1.0 -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | 4 | [flake8] 5 | max-line-length = 120 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | import os.path 3 | import setuptools 4 | 5 | with open("README.md", "r", encoding="utf-8") as fh: 6 | long_description = fh.read() 7 | 8 | setuptools.setup( 9 | name="TikTokApi", 10 | packages=setuptools.find_packages(), 11 | version="7.1.0", 12 | license="MIT", 13 | description="The Unofficial TikTok API Wrapper in Python 3.", 14 | author="David Teather", 15 | author_email="contact.davidteather@gmail.com", 16 | url="https://github.com/davidteather/tiktok-api", 17 | long_description=long_description, 18 | long_description_content_type="text/markdown", 19 | download_url="https://github.com/davidteather/TikTok-Api/tarball/main", 20 | keywords=["tiktok", "python3", "api", "unofficial", "tiktok-api", "tiktok api"], 21 | install_requires=["requests", "playwright", "httpx"], 22 | classifiers=[ 23 | "Development Status :: 4 - Beta", 24 | "Intended Audience :: Developers", 25 | "Topic :: Software Development :: Build Tools", 26 | "License :: OSI Approved :: MIT License", 27 | "Programming Language :: Python :: 3.9", 28 | "Programming Language :: Python :: 3.10", 29 | "Programming Language :: Python :: 3.11", 30 | "Programming Language :: Python :: 3.12", 31 | "Programming Language :: Python :: 3.13", 32 | ], 33 | python_requires=">=3.9", 34 | ) 35 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidteather/TikTok-Api/62a8cfa8ab7bb5bbdd0f8c8b13e84731fff7ac75/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_comments.py: -------------------------------------------------------------------------------- 1 | from TikTokApi import TikTokApi 2 | import os 3 | import pytest 4 | 5 | video_id = 7248300636498890011 6 | ms_token = os.environ.get("ms_token", None) 7 | headless = os.environ.get("headless", "True").lower() == "true" 8 | 9 | 10 | @pytest.mark.asyncio 11 | async def test_comment_page(): 12 | api = TikTokApi() 13 | async with api: 14 | await api.create_sessions(ms_tokens=[ms_token], num_sessions=1, sleep_after=3, browser=os.getenv("TIKTOK_BROWSER", "chromium"), headless=headless) 15 | video = api.video(id=video_id) 16 | count = 0 17 | async for comment in video.comments(count=100): 18 | count += 1 19 | 20 | assert count >= 100 21 | -------------------------------------------------------------------------------- /tests/test_hashtag.py: -------------------------------------------------------------------------------- 1 | from TikTokApi import TikTokApi 2 | import os 3 | import logging 4 | import pytest 5 | 6 | ms_token = os.environ.get("ms_token", None) 7 | headless = os.environ.get("headless", "True").lower() == "true" 8 | 9 | 10 | @pytest.mark.asyncio 11 | async def test_hashtag_videos(): 12 | api = TikTokApi(logging_level=logging.INFO) 13 | async with api: 14 | await api.create_sessions(ms_tokens=[ms_token], num_sessions=1, sleep_after=3, browser=os.getenv("TIKTOK_BROWSER", "chromium"), headless=headless) 15 | tag = api.hashtag(name="funny") 16 | video_count = 0 17 | async for video in tag.videos(count=30): 18 | video_count += 1 19 | 20 | assert video_count >= 30 21 | 22 | 23 | @pytest.mark.asyncio 24 | async def test_hashtag_videos_multi_page(): 25 | api = TikTokApi(logging_level=logging.INFO) 26 | async with api: 27 | await api.create_sessions(ms_tokens=[ms_token], num_sessions=1, sleep_after=3, browser=os.getenv("TIKTOK_BROWSER", "chromium"), headless=headless) 28 | tag = api.hashtag(name="funny", id="5424") 29 | video_count = 0 30 | async for video in tag.videos(count=100): 31 | video_count += 1 32 | 33 | assert video_count >= 30 34 | 35 | 36 | @pytest.mark.asyncio 37 | async def test_hashtag_info(): 38 | api = TikTokApi(logging_level=logging.INFO) 39 | async with api: 40 | await api.create_sessions(ms_tokens=[ms_token], num_sessions=1, sleep_after=3, browser=os.getenv("TIKTOK_BROWSER", "chromium"), headless=headless) 41 | tag = api.hashtag(name="funny") 42 | await tag.info() 43 | 44 | assert tag.id == "5424" 45 | assert tag.name == "funny" 46 | 47 | 48 | @pytest.mark.asyncio 49 | async def test_non_latin1(): 50 | api = TikTokApi(logging_level=logging.INFO) 51 | async with api: 52 | await api.create_sessions(ms_tokens=[ms_token], num_sessions=1, sleep_after=3, browser=os.getenv("TIKTOK_BROWSER", "chromium"), headless=headless) 53 | tag = api.hashtag(name="селфи") 54 | await tag.info() 55 | 56 | assert tag.name == "селфи" 57 | assert tag.id == "4385126" 58 | -------------------------------------------------------------------------------- /tests/test_integration.py: -------------------------------------------------------------------------------- 1 | from TikTokApi import TikTokApi 2 | import os 3 | import pytest 4 | 5 | ms_token = os.environ.get("ms_token", None) 6 | headless = os.environ.get("headless", "True").lower() == "true" 7 | 8 | 9 | @pytest.mark.asyncio 10 | async def test_hashtag_videos(): 11 | async with TikTokApi() as api: 12 | await api.create_sessions(ms_tokens=[ms_token], num_sessions=1, sleep_after=3, browser=os.getenv("TIKTOK_BROWSER", "chromium"), headless=headless) 13 | tag_name = "funny" 14 | count = 0 15 | async for video in api.hashtag(name=tag_name).videos(count=1): 16 | count += 1 17 | tag_included = False 18 | for tag in video.hashtags: 19 | if tag.name == tag_name: 20 | tag_included = True 21 | 22 | assert tag_included 23 | 24 | # Test sound on video. 25 | assert video.sound is not None 26 | assert video.sound.id is not None 27 | 28 | # Test author. 29 | assert video.author is not None 30 | assert video.author.user_id is not None 31 | assert video.author.sec_uid is not None 32 | 33 | assert count > 0 34 | -------------------------------------------------------------------------------- /tests/test_playlist.py: -------------------------------------------------------------------------------- 1 | from TikTokApi import TikTokApi 2 | import os 3 | import pytest 4 | 5 | playlist_id="7281443725770476321" 6 | playlist_name="Doctor Who" 7 | playlist_creator="bbc" 8 | 9 | ms_token = os.environ.get("ms_token", None) 10 | headless = os.environ.get("headless", "True").lower() == "true" 11 | 12 | 13 | @pytest.mark.asyncio 14 | async def test_playlist_info(): 15 | api = TikTokApi() 16 | async with api: 17 | await api.create_sessions(ms_tokens=[ms_token], num_sessions=1, sleep_after=3, headless=headless) 18 | playlist = api.playlist(id=playlist_id) 19 | await playlist.info() 20 | 21 | assert playlist.id == playlist_id 22 | assert playlist.name == playlist_name 23 | assert playlist.creator.username == playlist_creator 24 | assert playlist.video_count > 0 25 | assert playlist.cover_url is not None 26 | assert playlist.as_dict is not None 27 | 28 | @pytest.mark.asyncio 29 | async def test_playlist_videos(): 30 | api = TikTokApi() 31 | async with api: 32 | await api.create_sessions(ms_tokens=[ms_token], num_sessions=1, sleep_after=3, headless=headless) 33 | playlist = api.playlist(id=playlist_id) 34 | 35 | count = 0 36 | async for video in playlist.videos(count=30): 37 | count += 1 38 | 39 | assert count >= 30 40 | -------------------------------------------------------------------------------- /tests/test_search.py: -------------------------------------------------------------------------------- 1 | from TikTokApi import TikTokApi 2 | import os 3 | import pytest 4 | 5 | ms_token = os.environ.get("ms_token", None) 6 | headless = os.environ.get("headless", "True").lower() == "true" 7 | 8 | @pytest.mark.asyncio 9 | async def test_users_single_page(): 10 | api = TikTokApi() 11 | async with api: 12 | await api.create_sessions(ms_tokens=[ms_token], num_sessions=1, sleep_after=3, browser=os.getenv("TIKTOK_BROWSER", "chromium"), headless=headless) 13 | count = 0 14 | async for user in api.search.users("therock", count=10): 15 | count += 1 16 | 17 | assert count >= 10 18 | 19 | #@pytest.mark.asyncio 20 | @pytest.mark.skip(reason="Known issue, see #1088 (https://github.com/davidteather/TikTok-Api/issues/1088)") 21 | async def test_users_multi_page(): 22 | api = TikTokApi() 23 | async with api: 24 | await api.create_sessions(ms_tokens=[ms_token], num_sessions=1, sleep_after=3, browser=os.getenv("TIKTOK_BROWSER", "chromium"), headless=headless) 25 | count = 0 26 | async for user in api.search.users("therock", count=50): 27 | count += 1 28 | 29 | assert count >= 50 30 | -------------------------------------------------------------------------------- /tests/test_sound.py: -------------------------------------------------------------------------------- 1 | from TikTokApi import TikTokApi 2 | import os 3 | import pytest 4 | 5 | ms_token = os.environ.get("ms_token", None) 6 | headless = os.environ.get("headless", "True").lower() == "true" 7 | song_id = "7016547803243022337" 8 | 9 | 10 | @pytest.mark.asyncio 11 | async def test_sound_videos(): 12 | api = TikTokApi() 13 | async with api: 14 | await api.create_sessions(ms_tokens=[ms_token], num_sessions=1, sleep_after=3, browser=os.getenv("TIKTOK_BROWSER", "chromium"), headless=headless) 15 | sound = api.sound(id=song_id) 16 | video_count = 0 17 | async for video in sound.videos(count=100): 18 | video_count += 1 19 | 20 | assert video_count >= 100 21 | 22 | 23 | @pytest.mark.asyncio 24 | async def test_sound_info(): 25 | api = TikTokApi() 26 | async with api: 27 | await api.create_sessions(ms_tokens=[ms_token], num_sessions=1, sleep_after=3, browser=os.getenv("TIKTOK_BROWSER", "chromium"), headless=headless) 28 | sound = api.sound(id=song_id) 29 | await sound.info() 30 | assert sound.id == song_id 31 | assert sound.title == "Face Off - Dwayne Johnson" 32 | assert sound.duration == 60 33 | -------------------------------------------------------------------------------- /tests/test_trending.py: -------------------------------------------------------------------------------- 1 | from TikTokApi import TikTokApi 2 | import os 3 | import pytest 4 | 5 | ms_token = os.environ.get("ms_token", None) 6 | headless = os.environ.get("headless", "True").lower() == "true" 7 | 8 | 9 | @pytest.mark.asyncio 10 | async def test_trending(): 11 | api = TikTokApi() 12 | async with api: 13 | await api.create_sessions(ms_tokens=[ms_token], num_sessions=1, sleep_after=3, browser=os.getenv("TIKTOK_BROWSER", "chromium"), headless=headless) 14 | count = 0 15 | async for video in api.trending.videos(count=100): 16 | count += 1 17 | 18 | assert count >= 100 19 | -------------------------------------------------------------------------------- /tests/test_user.py: -------------------------------------------------------------------------------- 1 | from TikTokApi import TikTokApi 2 | import os 3 | import pytest 4 | 5 | username = "charlidamelio" 6 | user_id = "5831967" 7 | sec_uid = "MS4wLjABAAAA-VASjiXTh7wDDyXvjk10VFhMWUAoxr8bgfO1kAL1-9s" 8 | 9 | ms_token = os.environ.get("ms_token", None) 10 | headless = os.environ.get("headless", "True").lower() == "true" 11 | 12 | 13 | @pytest.mark.asyncio 14 | async def test_user_info(): 15 | api = TikTokApi() 16 | async with api: 17 | await api.create_sessions(ms_tokens=[ms_token], num_sessions=1, sleep_after=3, browser=os.getenv("TIKTOK_BROWSER", "chromium"), headless=headless) 18 | user = api.user(username=username) 19 | await user.info() 20 | 21 | assert user.username == username 22 | assert user.user_id == user_id 23 | assert user.sec_uid == sec_uid 24 | 25 | @pytest.mark.asyncio 26 | async def test_user_videos(): 27 | api = TikTokApi() 28 | async with api: 29 | await api.create_sessions(ms_tokens=[ms_token], num_sessions=1, sleep_after=3, browser=os.getenv("TIKTOK_BROWSER", "chromium"), headless=headless) 30 | user = api.user(username=username, sec_uid=sec_uid, user_id=user_id) 31 | 32 | count = 0 33 | async for video in user.videos(count=30): 34 | count += 1 35 | 36 | assert count >= 30 37 | 38 | @pytest.mark.asyncio 39 | async def test_user_likes(): 40 | api = TikTokApi() 41 | async with api: 42 | await api.create_sessions(ms_tokens=[ms_token], num_sessions=1, sleep_after=3, browser=os.getenv("TIKTOK_BROWSER", "chromium"), headless=headless) 43 | user = api.user( 44 | username="publicliketest", 45 | sec_uid="MS4wLjABAAAAHjhwCIwmvzVZfRrDAZ2aZy74LciLnoyaPfM2rrX9N7bwbWMFuwTFG4YrByYvsH5c", 46 | ) 47 | 48 | count = 0 49 | async for video in user.liked(count=30): 50 | count += 1 51 | 52 | assert count >= 30 53 | 54 | @pytest.mark.asyncio 55 | async def test_user_playlists(): 56 | api = TikTokApi() 57 | async with api: 58 | await api.create_sessions(ms_tokens=[ms_token], num_sessions=1, sleep_after=3, browser=os.getenv("TIKTOK_BROWSER", "chromium"), headless=headless) 59 | user = api.user(username="mrbeast") 60 | 61 | count = 0 62 | async for playlist in user.playlists(count=5): 63 | count += 1 64 | 65 | assert count >= 5 66 | -------------------------------------------------------------------------------- /tests/test_video.py: -------------------------------------------------------------------------------- 1 | from TikTokApi import TikTokApi 2 | import os 3 | import pytest 4 | 5 | ms_token = os.environ.get("ms_token", None) 6 | headless = os.environ.get("headless", "True").lower() == "true" 7 | 8 | 9 | @pytest.mark.asyncio 10 | async def test_video_id_from_url(): 11 | api = TikTokApi() 12 | async with api: 13 | await api.create_sessions(ms_tokens=[ms_token], num_sessions=1, sleep_after=3, browser=os.getenv("TIKTOK_BROWSER", "chromium"), headless=headless) 14 | 15 | expected_id = "7074717081563942186" 16 | video = api.video( 17 | url="https://www.tiktok.com/@davidteathercodes/video/7074717081563942186" 18 | ) 19 | 20 | assert video.id == expected_id 21 | 22 | # mobile_url = "https://www.tiktok.com/t/ZT8LCfcUC/" 23 | # video = api.video(url=mobile_url) 24 | 25 | # assert video.id == expected_id 26 | 27 | 28 | @pytest.mark.asyncio 29 | async def test_video_info(): 30 | api = TikTokApi() 31 | async with api: 32 | await api.create_sessions(ms_tokens=[ms_token], num_sessions=1, sleep_after=3, browser=os.getenv("TIKTOK_BROWSER", "chromium"), headless=headless) 33 | video_id = "7074717081563942186" 34 | video = api.video( 35 | url="https://www.tiktok.com/@davidteathercodes/video/7074717081563942186" 36 | ) 37 | 38 | data = await video.info() 39 | 40 | assert data["id"] == video_id 41 | video.author.username = "davidteathercodes" 42 | 43 | 44 | @pytest.mark.asyncio 45 | async def test_video_bytes(): 46 | pytest.skip("Not implemented yet") 47 | api = TikTokApi() 48 | async with api: 49 | await api.create_sessions(ms_tokens=[ms_token], num_sessions=1, sleep_after=3, browser=os.getenv("TIKTOK_BROWSER", "chromium"), headless=headless) 50 | video_id = "7107272719166901550" 51 | video = api.video(id=video_id) 52 | 53 | data = await video.bytes() 54 | assert len(data) > 10000 55 | 56 | 57 | @pytest.mark.asyncio 58 | async def test_related_videos(): 59 | api = TikTokApi() 60 | async with api: 61 | await api.create_sessions(ms_tokens=[ms_token], num_sessions=1, sleep_after=3, browser=os.getenv("TIKTOK_BROWSER", "chromium"), headless=headless) 62 | video_id = "7107272719166901550" 63 | video = api.video(id=video_id) 64 | count = 0 65 | async for related_video in video.related_videos(count=10): 66 | print(related_video) 67 | count += 1 68 | 69 | assert count >= 10 70 | --------------------------------------------------------------------------------