From 3973da62a683b34548d07acb48e334ac5b4cfe6d Mon Sep 17 00:00:00 2001 From: David Teather <34144122+davidteather@users.noreply.github.com> Date: Sun, 13 Feb 2022 20:19:59 -0600 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=8A=20V5.0.0=20=F0=9F=8E=8A=20(#803)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial changes for v5 of the project. * Thanks @Daan-Grashoff for some of the helpers.extract_tag_contents code from a comment on #787 * High Level Modeling of Classes * update tests & auto call object * Fix package-test * simplify info_full * Logging Improvements * Logging changes from #804 by @zokalo * Improve docs * Fix some mypy typing issues * Move old search functions to trending & fix process_kwargs * Private self variables Co-authored-by: davidteather Co-authored-by: Daan Grashoff <9222025+Daan-Grashoff@users.noreply.github.com> Co-authored-by: Dmitriy <16061619+zokalo@users.noreply.github.com> --- .github/ISSUE_TEMPLATE/bug_report.md | 10 +- .github/ISSUE_TEMPLATE/installation-help.md | 3 + .github/workflows/package-test.yml | 4 +- CITATION.cff | 10 +- README.md | 122 +- TikTokApi/__init__.py | 1 - TikTokApi/api/__init__.py | 19 + TikTokApi/api/hashtag.py | 156 + TikTokApi/api/search.py | 122 + TikTokApi/api/sound.py | 181 + TikTokApi/api/trending.py | 70 + TikTokApi/api/user.py | 287 + TikTokApi/api/video.py | 145 + TikTokApi/browser_utilities/browser.py | 47 +- .../browser_utilities/browser_interface.py | 2 +- .../browser_utilities/browser_selenium.py | 224 - TikTokApi/browser_utilities/get_acrawler.py | 4 +- TikTokApi/browser_utilities/stealth.py | 502 -- TikTokApi/exceptions.py | 2 +- TikTokApi/helpers.py | 41 + TikTokApi/tiktok.py | 1549 +--- TikTokApi/tiktokuser.py | 101 - TikTokApi/utilities.py | 3 +- docs/TikTokApi.html | 506 +- docs/TikTokApi/api.html | 301 + docs/TikTokApi/api/hashtag.html | 816 ++ docs/TikTokApi/api/search.html | 721 ++ docs/TikTokApi/api/sound.html | 890 ++ docs/TikTokApi/api/trending.html | 477 ++ docs/TikTokApi/api/user.html | 1234 +++ docs/TikTokApi/api/video.html | 790 ++ docs/TikTokApi/browser_utilities.html | 360 +- docs/TikTokApi/browser_utilities/browser.html | 575 +- .../browser_utilities/browser_interface.html | 386 +- .../browser_utilities/browser_selenium.html | 1037 --- .../browser_utilities/get_acrawler.html | 406 +- docs/TikTokApi/browser_utilities/stealth.html | 356 +- docs/TikTokApi/exceptions.html | 368 +- docs/TikTokApi/helpers.html | 348 + docs/TikTokApi/tiktok.html | 7389 +++++------------ docs/TikTokApi/tiktokuser.html | 590 -- docs/TikTokApi/utilities.html | 373 +- docs/index.html | 258 +- docs/search.js | 46 + docs/search.json | 1 - examples/demo_user_pager.py | 49 - examples/discover.py | 15 - examples/download_tiktok.py | 19 - examples/external_signer.py | 57 - examples/get_a_users_videos.py | 20 - examples/get_tiktoks_by_hashtag.py | 10 - examples/get_tiktoks_by_sound.py | 13 - examples/get_tiktoks_by_username.py | 10 - examples/get_trending.py | 17 - examples/hashtag_example.py | 11 + examples/search_example.py | 14 + examples/sound_example.py | 9 + examples/trending_example.py | 7 + examples/user_example.py | 12 + examples/user_id_crawler.py | 12 - examples/video_example.py | 12 + setup.py | 7 +- tests/test_by_hashtag.py | 26 - tests/test_by_sound.py | 12 - tests/test_by_username.py | 12 - tests/test_get_music_object_full_by_api.py | 12 - tests/test_get_object_routes.py | 30 - tests/test_hashtag.py | 28 + tests/test_integration.py | 25 + tests/test_search.py | 24 + tests/test_search_for.py | 12 - tests/test_sound.py | 22 + tests/test_trending.py | 14 +- tests/test_user.py | 87 +- tests/test_user_pager.py | 55 - tests/test_video.py | 36 + 76 files changed, 11047 insertions(+), 11475 deletions(-) create mode 100644 TikTokApi/api/__init__.py create mode 100644 TikTokApi/api/hashtag.py create mode 100644 TikTokApi/api/search.py create mode 100644 TikTokApi/api/sound.py create mode 100644 TikTokApi/api/trending.py create mode 100644 TikTokApi/api/user.py create mode 100644 TikTokApi/api/video.py delete mode 100644 TikTokApi/browser_utilities/browser_selenium.py delete mode 100644 TikTokApi/browser_utilities/stealth.py create mode 100644 TikTokApi/helpers.py delete mode 100644 TikTokApi/tiktokuser.py create mode 100644 docs/TikTokApi/api.html create mode 100644 docs/TikTokApi/api/hashtag.html create mode 100644 docs/TikTokApi/api/search.html create mode 100644 docs/TikTokApi/api/sound.html create mode 100644 docs/TikTokApi/api/trending.html create mode 100644 docs/TikTokApi/api/user.html create mode 100644 docs/TikTokApi/api/video.html delete mode 100644 docs/TikTokApi/browser_utilities/browser_selenium.html create mode 100644 docs/TikTokApi/helpers.html delete mode 100644 docs/TikTokApi/tiktokuser.html create mode 100644 docs/search.js delete mode 100644 docs/search.json delete mode 100644 examples/demo_user_pager.py delete mode 100644 examples/discover.py delete mode 100644 examples/download_tiktok.py delete mode 100644 examples/external_signer.py delete mode 100644 examples/get_a_users_videos.py delete mode 100644 examples/get_tiktoks_by_hashtag.py delete mode 100644 examples/get_tiktoks_by_sound.py delete mode 100644 examples/get_tiktoks_by_username.py delete mode 100644 examples/get_trending.py create mode 100644 examples/hashtag_example.py create mode 100644 examples/search_example.py create mode 100644 examples/sound_example.py create mode 100644 examples/trending_example.py create mode 100644 examples/user_example.py delete mode 100644 examples/user_id_crawler.py create mode 100644 examples/video_example.py delete mode 100644 tests/test_by_hashtag.py delete mode 100644 tests/test_by_sound.py delete mode 100644 tests/test_by_username.py delete mode 100644 tests/test_get_music_object_full_by_api.py delete mode 100644 tests/test_get_object_routes.py create mode 100644 tests/test_hashtag.py create mode 100644 tests/test_integration.py create mode 100644 tests/test_search.py delete mode 100644 tests/test_search_for.py create mode 100644 tests/test_sound.py delete mode 100644 tests/test_user_pager.py create mode 100644 tests/test_video.py diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 10de3c52..efaded62 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -6,9 +6,7 @@ labels: bug assignees: '' --- - -# Read Below!!! If this doesn't fix your issue delete these two lines -**You may need to install chromedriver for your machine globally. Download it [here](https://sites.google.com/a/chromium.org/chromedriver/) and add it to your path.** +Fill Out the template :) **Describe the bug** @@ -16,7 +14,9 @@ A clear and concise description of what the bug is. **The buggy code** -Please insert the code that is throwing errors or is giving you weird unexpected results. +Please add any relevant code that is giving you unexpected results. + +Preferably the smallest amount of code to reproduce the issue. ``` # Code Goes Here @@ -35,7 +35,7 @@ Put the error trace below if there's any error thrown. **Desktop (please complete the following information):** - OS: [e.g. Windows 10] - - TikTokApi Version [e.g. 3.3.1] - if out of date upgrade before posting an issue + - TikTokApi Version [e.g. 5.0.0] - if out of date upgrade before posting an issue **Additional context** diff --git a/.github/ISSUE_TEMPLATE/installation-help.md b/.github/ISSUE_TEMPLATE/installation-help.md index 182b201f..caeea644 100644 --- a/.github/ISSUE_TEMPLATE/installation-help.md +++ b/.github/ISSUE_TEMPLATE/installation-help.md @@ -8,6 +8,9 @@ assignees: '' --- +Please first check the closed issues on GitHub for people with similar problems to you. +If you'd like more instant help from the community consider joining the [discord](https://discord.gg/yyPhbfma6f) + **Describe the error** Put the error trace here. diff --git a/.github/workflows/package-test.yml b/.github/workflows/package-test.yml index 9eb1f3a1..27ff8ca3 100644 --- a/.github/workflows/package-test.yml +++ b/.github/workflows/package-test.yml @@ -7,7 +7,7 @@ on: branches: - master - nightly - - 'releases/*' + - "releases/*" jobs: Unit-Tests: @@ -17,7 +17,7 @@ jobs: fail-fast: false matrix: os: [macos-latest] - python-version: [3.7, 3.9] + python-version: [3.7, "3.10"] steps: - uses: actions/checkout@v2 - uses: microsoft/playwright-github-action@v1 diff --git a/CITATION.cff b/CITATION.cff index e21b1c8f..79e7ddea 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -1,9 +1,9 @@ cff-version: 1.2.0 authors: -- family-names: "Teather" - given-names: "David" - orcid: "https://orcid.org/0000-0002-9467-4676" + - family-names: "Teather" + given-names: "David" + orcid: "https://orcid.org/0000-0002-9467-4676" title: "TikTokAPI" url: "https://github.com/davidteather/tiktok-api" -version: 4.1.0 -date-released: 2021-12-11 \ No newline at end of file +version: 5.0.0 +date-released: 2022-2-11 diff --git a/README.md b/README.md index c6713c05..4409116e 100644 --- a/README.md +++ b/README.md @@ -8,46 +8,46 @@ This is an unofficial api wrapper for TikTok.com in python. With this api you ar ## Sponsors These sponsors have paid to be placed here 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). -[![TikAPI](imgs/logo128.png)](https://tikapi.io/?ref=davidteather) | **[TikAPI](https://tikapi.io/?ref=davidteather)** is a paid TikTok API service providing an full out-of-the-box solution for developers, trusted by 100+ companies. [Learn more](https://tikapi.io/?ref=davidteather) +[![TikAPI](https://raw.githubusercontent.com/davidteather/TikTok-Api/master/imgs/logo128.png)](https://tikapi.io/?ref=davidteather) | **[TikAPI](https://tikapi.io/?ref=davidteather)** is a paid TikTok API service providing an full out-of-the-box solution for developers, trusted by 100+ companies. [Learn more](https://tikapi.io/?ref=davidteather) :-------------------------:|:-------------------------: ## Table of Contents +- [Documentation](#documentation) - [Getting Started](#getting-started) + - [How to Support The Project](#how-to-support-the-project) - [Installing](#installing) - [Common Issues](#common-issues) - [Quick Start Guide](#quick-start-guide) - [Examples](https://github.com/davidteather/TikTok-Api/tree/master/examples) -- [Documentation](#documentation) -- [Built With](#built-with) -- [Authors](#authors) -- [License](#license) +[**Upgrading from V4 to V5**](#upgrading-from-v4-to-v5) + +## Documentation + +You can find the full documentation [here](https://davidteather.github.io/TikTok-Api/docs/TikTokApi.html), the [TikTokApi Class](https://davidteather.github.io/TikTok-Api/docs/TikTokApi/tiktok.html) is where you'll probably spend most of your time. ## Getting Started To get started using this api follow the instructions below. -#### How to support the project -* Feel free to sponsor me on GitHub -* Feel free to tip the project using the brave browser +### How to Support The Project +* Star the repo 😎 +* Consider [sponsoring](https://github.com/sponsors/davidteather) me on GitHub +* 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. * Submit PRs for issues :) ### Installing -If you run into an issue please check the closed issues on the github. You're most likely not the first person to experience this issue. If nothing works feel free to open an issue. +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. ```sh pip install TikTokApi python -m playwright install ``` -If you would prefer a video walk through of setting up this package I created a [YouTube video](https://www.youtube.com/watch?v=zwLmLfVI-VQ) just for that. - - - -If you're on MacOS you may need to install [XCode Developer Tools](https://webkit.org/build-tools/) +If you would prefer a video walk through of setting up this package I created a currently semi-outdated (TODO: new one for v5 coming soon) [YouTube video](https://www.youtube.com/watch?v=zwLmLfVI-VQ) just for that. #### Docker Installation -Clone this repository onto a local machine then run the following commands. +Clone this repository onto a local machine (or just the Dockerfile since it installs TikTokApi from pip) then run the following commands. ```sh docker pull mcr.microsoft.com/playwright:focal @@ -61,47 +61,93 @@ docker run -v TikTokApi --rm tiktokapi:latest python3 your_script.py Please don't open an issue if you're experiencing one of these just comment if the provided solution do not work for you. -* **Browser Has no Attribute** - make sure you ran `python3 -m playwright install`, if your error persists try the [playwright](https://github.com/microsoft/playwright-python) quickstart guide and diagnose issues from there. +* **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. ## Quick Start Guide -Here's a quick bit of code to get the most recent trending on TikTok. There's more examples in the examples directory. +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/master/examples) directory. ```py from TikTokApi import TikTokApi -api = TikTokApi.get_instance() -results = 10 -# Since TikTok changed their API you need to use the custom_verifyFp option. -# In your web browser you will need to go to TikTok, Log in and get the s_v_web_id value. -trending = api.by_trending(count=results, custom_verifyFp="") +# In your web browser you will need to go to TikTok, check the cookies +# and under www.tiktok.com s_v_web_id should exist, and use that value +# as input to custom_verify_fp +# Or watch https://www.youtube.com/watch?v=zwLmLfVI-VQ for a visual +# TODO: Update link +api = TikTokApi(custom_verify_fp="") -for tiktok in trending: - # Prints the id of the tiktok - print(tiktok['id']) - -print(len(trending)) +for trending_video in api.trending.videos(count=50): + # Prints the author's username of the trending video. + print(trending_video.author.username) ``` -To run the example scripts from the repository root, make sure you use the -module form of python the interpreter - +To run the example scripts from the repository root, make sure you use the `-m` option on python. ```sh python -m examples.get_trending ``` -[Here's](https://gist.github.com/davidteather/7c30780bbc30772ba11ec9e0b909e99d) an example of what a TikTok dictionary looks like. +You can access the dictionary type of an object using `.as_dict`. On a video this may look like +[this](https://gist.github.com/davidteather/7c30780bbc30772ba11ec9e0b909e99d), although TikTok changes their structure from time to time so it's worth investigating the structure of the dictionary when you use this package. -## Documentation +## Upgrading from V4 to V5 + +All changes will be noted on [#803](https://github.com/davidteather/TikTok-Api/pull/803) if you want more information. + +### Motivation -You can find the documentation [here](https://davidteather.github.io/TikTok-Api/docs/TikTokApi.html) (you'll likely just need the TikTokApi section of the docs), I will be making this documentation more complete overtime as it's not super great right now, but better than just having it in the readme! +This package has been difficult to maintain due to it's structure, difficult to work with since the user of the package must write parsing methods to extract information from dictionaries, more memory intensive than it needs to be (although this can be further improved), and in general just difficult to work with for new users. -## Authors +As a result, I've decided to at least attempt to remedy some of these issues, the biggest changes are that +1. The package has shifted to using classes for different TikTok objects resulting in an easier, higher-level programming experience. +2. All methods that used to return a list of objects have been switched to using iterators, to hopefully decrease memory utilization for most users. -* **David Teather** - *Initial work* - [davidteather](https://github.com/davidteather) -See also the list of [contributors](https://github.com/davidteather/TikTok-Api/contributors) who participated in this project. +### Upgrading Examples -## License -This project is licensed under the MIT License +#### Accessing Dictionary on Objects (similar to V4) + +TODO: Make video upgrading from V4-V5? + +You'll probably need to use this beyond just for legacy support, since not all attributes are parsed out and attached +to the different objects. + +You may want to use this as a workaround for legacy applications while you upgrade the rest of the app. I'd suggest that you do eventually upgrade to using the higher-level approach fully. +```py +user = api.user(username='therock') +user.as_dict # -> dict of the user_object +for video in user.videos(): + video.as_dict # -> dict of TikTok's video object as found when requesting the videos endpoint +``` + +Here's a few more examples that help illustrate the differences in the flow of the usage of the package with V5. + +```py +# V4 +api = TikTokApi.get_instance() +trending_videos = api.by_trending() + +#V5 +api = TikTokApi() # .get_instance no longer exists +for trending_video in api.trending.videos(): + # do something +``` + +Where in V4 you had to extract information yourself, the package now handles that for you. So it's much easier to do chained related function calls. +```py +# V4 +trending_videos = api.by_trending() +for video in trending_videos: + # The dictionary responses are also different depending on what endpoint you got them from + # So, it's usually more painful than this to deal with + trending_user = api.get_user(id=video['author']['id'], secUid=video['author']['secUid']) + + +# V5 +# This is more complicated than above, but it illustrates the simplified approach +for trending_video in api.trending.videos(): + user_stats = trending_video.author.info_full['stats'] + if user_stats['followerCount'] >= 10000: + # maybe save the user in a database +``` diff --git a/TikTokApi/__init__.py b/TikTokApi/__init__.py index fa9e85e0..af368e66 100644 --- a/TikTokApi/__init__.py +++ b/TikTokApi/__init__.py @@ -4,4 +4,3 @@ __docformat__ = "restructuredtext" from TikTokApi.tiktok import TikTokApi -from TikTokApi.tiktokuser import TikTokUser diff --git a/TikTokApi/api/__init__.py b/TikTokApi/api/__init__.py new file mode 100644 index 00000000..d75df9bd --- /dev/null +++ b/TikTokApi/api/__init__.py @@ -0,0 +1,19 @@ +""" +This module contains classes that all represent different types of data sent back by the TikTok servers. + +The files within in module correspond to what type of object is described and all have different methods associated with them. + + +### How To Interpret TikTok Data +There are quite a few ambigious keys in the JSON that TikTok returns so here's a section that tries to document some of them. + +**Note**: These are incomplete, if you get confused about something feel free to add it here as a PR once you figure it out. + +| JSON Key | Description | +|------------------|-------------| +| createTime | The [unix epoch](https://docs.python.org/3/library/datetime.html#datetime.date.fromtimestamp) of creation, all other time fields are also unix epochs. | +| secUid & (userId or id) | Two different unique attributes that are used in conjunction to reference a specific account, so if you're storing users somewhere in a database, you should store both secUid & userId. | +| id | A unique attribute used to reference a non-user object like video, hashtag, etc | +| diggCount | The likes for a specific video. | +| digged | Used to check if the current user has liked/digged a video, this will always be false since this package doesn't support logged-in user functions. | +""" diff --git a/TikTokApi/api/hashtag.py b/TikTokApi/api/hashtag.py new file mode 100644 index 00000000..aba2fad4 --- /dev/null +++ b/TikTokApi/api/hashtag.py @@ -0,0 +1,156 @@ +from __future__ import annotations +import logging + +from urllib.parse import urlencode +from ..exceptions import * + +from typing import TYPE_CHECKING, ClassVar, Iterator, Optional + +if TYPE_CHECKING: + from ..tiktok import TikTokApi + from .video import Video + + +class Hashtag: + """ + A TikTok Hashtag/Challenge. + + Example Usage + ```py + hashtag = api.hashtag(name='funny') + ``` + """ + + parent: ClassVar[TikTokApi] + + id: Optional[str] + """The ID of the hashtag""" + name: Optional[str] + """The name of the hashtag (omiting the #)""" + as_dict: dict + """The raw data associated with this hashtag.""" + + def __init__( + self, + name: Optional[str] = None, + id: Optional[str] = None, + data: Optional[dict] = None, + ): + """ + You must provide the name or id of the hashtag. + """ + self.name = name + self.id = id + + if data is not None: + self.as_dict = data + self.__extract_from_data() + + def info(self, **kwargs) -> dict: + """ + Returns TikTok's dictionary representation of the hashtag object. + """ + return self.info_full(**kwargs)["challengeInfo"]["challenge"] + + def info_full(self, **kwargs) -> dict: + """ + Returns all information sent by TikTok related to this hashtag. + + Example Usage + ```py + hashtag_data = api.hashtag(name='funny').info_full() + ``` + """ + processed = self.parent._process_kwargs(kwargs) + kwargs["custom_device_id"] = processed.device_id + + if self.name is not None: + query = {"challengeName": self.name} + elif self.id is not None: + query = {"challengeId": self.id} + else: + self.parent.logger.warning("Malformed Hashtag Object") + return {} + + path = "api/challenge/detail/?{}&{}".format( + self.parent._add_url_params(), urlencode(query) + ) + + data = self.parent.get_data(path, **kwargs) + + if data["challengeInfo"].get("challenge") is None: + raise TikTokNotFoundError("Challenge {} does not exist".format(self.name)) + + return data + + def videos(self, count=30, offset=0, **kwargs) -> Iterator[Video]: + """Returns a dictionary listing TikToks with a specific hashtag. + + - Parameters: + - count (int): The amount of videos you want returned. + - offset (int): The the offset of videos from 0 you want to get. + + Example Usage + ```py + for video in api.hashtag(name='funny').videos(): + # do something + ``` + """ + processed = self.parent._process_kwargs(kwargs) + kwargs["custom_device_id"] = processed.device_id + + if self.id is None: + self.id = self.info()["id"] + + cursor = offset + page_size = 30 + + while cursor - offset < count: + query = { + "count": page_size, + "challengeID": self.id, + "cursor": cursor, + } + path = "api/challenge/item_list/?{}&{}".format( + self.parent._add_url_params(), urlencode(query) + ) + res = self.parent.get_data(path, **kwargs) + + for result in res.get("itemList", []): + yield self.parent.video(data=result) + + if not res.get("hasMore", False): + self.parent.logger.info( + "TikTok isn't sending more TikToks beyond this point." + ) + return + + cursor = int(res["cursor"]) + + def __extract_from_data(self): + data = self.as_dict + keys = data.keys() + + if "title" in keys: + self.id = data["id"] + self.name = data["title"] + + if None in (self.name, self.id): + Hashtag.parent.logger.error( + f"Failed to create Hashtag with data: {data}\nwhich has keys {data.keys()}" + ) + + def __repr__(self): + return self.__str__() + + def __str__(self): + return f"TikTokApi.hashtag(id='{self.id}', name='{self.name}')" + + def __getattr__(self, name): + # TODO: Maybe switch to using @property instead + if name in ["id", "name", "as_dict"]: + self.as_dict = self.info() + self.__extract_from_data() + return self.__getattribute__(name) + + raise AttributeError(f"{name} doesn't exist on TikTokApi.api.Hashtag") diff --git a/TikTokApi/api/search.py b/TikTokApi/api/search.py new file mode 100644 index 00000000..6a70f219 --- /dev/null +++ b/TikTokApi/api/search.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from urllib.parse import urlencode + +from typing import TYPE_CHECKING, Iterator, Type + +from .user import User +from .sound import Sound +from .hashtag import Hashtag +from .video import Video + +if TYPE_CHECKING: + from ..tiktok import TikTokApi + +import requests + + +class Search: + """Contains static methods about searching.""" + + parent: TikTokApi + + @staticmethod + def videos(search_term, count=28, offset=0, **kwargs) -> Iterator[Video]: + """ + Searches for Videos + + - Parameters: + - search_term (str): The phrase you want to search for. + - count (int): The amount of videos you want returned. + - offset (int): The offset of videos from your data you want returned. + + Example Usage + ```py + for video in api.search.videos('therock'): + # do something + ``` + """ + return Search.search_type( + search_term, "item", count=count, offset=offset, **kwargs + ) + + @staticmethod + def users(search_term, count=28, offset=0, **kwargs) -> Iterator[User]: + """ + Searches for users using an alternate endpoint than Search.users + + - Parameters: + - search_term (str): The phrase you want to search for. + - count (int): The amount of videos you want returned. + + Example Usage + ```py + for user in api.search.users_alternate('therock'): + # do something + ``` + """ + return Search.search_type( + search_term, "user", count=count, offset=offset, **kwargs + ) + + @staticmethod + def search_type(search_term, obj_type, count=28, offset=0, **kwargs) -> Iterator: + """ + Searches for users using an alternate endpoint than Search.users + + - Parameters: + - search_term (str): The phrase you want to search for. + - count (int): The amount of videos you want returned. + - obj_type (str): user | item + + Just use .video & .users + ``` + """ + processed = Search.parent._process_kwargs(kwargs) + kwargs["custom_device_id"] = processed.device_id + + cursor = offset + + spawn = requests.head( + "https://www.tiktok.com", + proxies=Search.parent._format_proxy(processed.proxy), + **Search.parent._requests_extra_kwargs + ) + ttwid = spawn.cookies["ttwid"] + + # For some reason when <= it can be off by one. + while cursor - offset <= count: + query = { + "keyword": search_term, + "cursor": cursor, + "app_language": Search.parent._language, + } + path = "api/search/{}/full/?{}&{}".format( + obj_type, Search.parent._add_url_params(), urlencode(query) + ) + + if obj_type == "user": + subdomain = "www" + elif obj_type == "item": + subdomain = "us" + else: + raise TypeError("invalid obj_type") + + api_response = Search.parent.get_data( + path, subdomain=subdomain, ttwid=ttwid, **kwargs + ) + + # When I move to 3.10+ support make this a match switch. + for result in api_response.get("user_list", []): + yield User(data=result) + + for result in api_response.get("item_list", []): + yield Video(data=result) + + if api_response.get("has_more", 0) == 0: + Search.parent.logger.info( + "TikTok is not sending videos beyond this point." + ) + return + + cursor = int(api_response.get("cursor", cursor)) diff --git a/TikTokApi/api/sound.py b/TikTokApi/api/sound.py new file mode 100644 index 00000000..69770c25 --- /dev/null +++ b/TikTokApi/api/sound.py @@ -0,0 +1,181 @@ +from __future__ import annotations +from os import path + +import requests +import json + +from urllib.parse import quote, urlencode + +from ..helpers import extract_tag_contents +from ..exceptions import * + +from typing import TYPE_CHECKING, ClassVar, Iterator, Optional + +if TYPE_CHECKING: + from ..tiktok import TikTokApi + from .user import User + from .video import Video + + +class Sound: + """ + A TikTok Sound/Music/Song. + + Example Usage + ```py + song = api.song(id='7016547803243022337') + ``` + """ + + parent: ClassVar[TikTokApi] + + id: str + """TikTok's ID for the sound""" + title: Optional[str] + """The title of the song.""" + author: Optional[User] + """The author of the song (if it exists)""" + + def __init__(self, id: Optional[str] = None, data: Optional[str] = None): + """ + You must provide the id of the sound or it will not work. + """ + if data is not None: + self.as_dict = data + self.__extract_from_data() + elif id is None: + raise TypeError("You must provide id parameter.") + else: + self.id = id + + def info(self, use_html=False, **kwargs) -> dict: + """ + Returns a dictionary of TikTok's Sound/Music object. + + - Parameters: + - use_html (bool): If you want to perform an HTML request or not. + Defaults to False to use an API call, which shouldn't get detected + as often as an HTML request. + + + Example Usage + ```py + sound_data = api.sound(id='7016547803243022337').info() + ``` + """ + if use_html: + return self.info_full(**kwargs)["musicInfo"] + + processed = self.parent._process_kwargs(kwargs) + kwargs["custom_device_id"] = processed.device_id + + path = "node/share/music/-{}?{}".format(self.id, self.parent._add_url_params()) + res = self.parent.get_data(path, **kwargs) + + if res.get("statusCode", 200) == 10203: + raise TikTokNotFoundError() + + return res["musicInfo"]["music"] + + def info_full(self, **kwargs) -> dict: + """ + Returns all the data associated with a TikTok Sound. + + This makes an API request, there is no HTML request option, as such + with Sound.info() + + Example Usage + ```py + sound_data = api.sound(id='7016547803243022337').info_full() + ``` + """ + r = requests.get( + "https://www.tiktok.com/music/-{}".format(self.id), + headers={ + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", + "Accept-Encoding": "gzip, deflate", + "Connection": "keep-alive", + "User-Agent": self.parent._user_agent, + }, + proxies=self.parent._format_proxy(kwargs.get("proxy", None)), + cookies=self.parent._get_cookies(**kwargs), + **self.parent._requests_extra_kwargs, + ) + + data = extract_tag_contents(r.text) + return json.loads(data)["props"]["pageProps"]["musicInfo"] + + def videos(self, count=30, offset=0, **kwargs) -> Iterator[Video]: + """ + Returns Video objects of videos created with this sound. + + - Parameters: + - count (int): The amount of videos you want returned. + - offset (int): The offset of videos you want returned. + + Example Usage + ```py + for video in api.sound(id='7016547803243022337').videos(): + # do something + ``` + """ + processed = self.parent._process_kwargs(kwargs) + kwargs["custom_device_id"] = processed.device_id + + cursor = offset + page_size = 30 + + while cursor - offset < count: + query = { + "secUid": "", + "musicID": self.id, + "cursor": cursor, + "shareUid": "", + "count": page_size, + } + path = "api/music/item_list/?{}&{}".format( + self.parent._add_url_params(), urlencode(query) + ) + + res = self.parent.get_data(path, send_tt_params=True, **kwargs) + + for result in res.get("itemList", []): + yield self.parent.video(data=result) + + if not res.get("hasMore", False): + self.parent.logger.info( + "TikTok isn't sending more TikToks beyond this point." + ) + return + + cursor = int(res["cursor"]) + + def __extract_from_data(self): + data = self.as_dict + keys = data.keys() + + if "authorName" in keys: + self.id = data["id"] + self.title = data["title"] + + if data.get("authorName") is not None: + self.author = self.parent.user(username=data["authorName"]) + + if self.id is None: + Sound.parent.logger.error( + f"Failed to create Sound with data: {data}\nwhich has keys {data.keys()}" + ) + + def __repr__(self): + return self.__str__() + + def __str__(self): + return f"TikTokApi.sound(id='{self.id}')" + + def __getattr__(self, name): + if name in ["title", "author", "as_dict"]: + self.as_dict = self.info() + self.__extract_from_data() + return self.__getattribute__(name) + + raise AttributeError(f"{name} doesn't exist on TikTokApi.api.Sound") diff --git a/TikTokApi/api/trending.py b/TikTokApi/api/trending.py new file mode 100644 index 00000000..7841a5b5 --- /dev/null +++ b/TikTokApi/api/trending.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import logging +import requests +from urllib.parse import urlencode + +from .video import Video +from .sound import Sound +from .user import User +from .hashtag import Hashtag + +from typing import TYPE_CHECKING, Iterator + +if TYPE_CHECKING: + from ..tiktok import TikTokApi + + +class Trending: + """Contains static methods related to trending.""" + + parent: TikTokApi + + @staticmethod + def videos(count=30, **kwargs) -> Iterator[Video]: + """ + Returns Videos that are trending on TikTok. + + - Parameters: + - count (int): The amount of videos you want returned. + """ + + processed = Trending.parent._process_kwargs(kwargs) + kwargs["custom_device_id"] = processed.device_id + + spawn = requests.head( + "https://www.tiktok.com", + proxies=Trending.parent._format_proxy(processed.proxy), + **Trending.parent._requests_extra_kwargs, + ) + ttwid = spawn.cookies["ttwid"] + + first = True + amount_yielded = 0 + + while amount_yielded < count: + query = { + "count": 30, + "id": 1, + "sourceType": 12, + "itemID": 1, + "insertedItemID": "", + "region": processed.region, + "priority_region": processed.region, + "language": processed.language, + } + path = "api/recommend/item_list/?{}&{}".format( + Trending.parent._add_url_params(), urlencode(query) + ) + res = Trending.parent.get_data(path, ttwid=ttwid, **kwargs) + for result in res.get("itemList", []): + yield Video(data=result) + amount_yielded += len(res.get("itemList", [])) + + if not res.get("hasMore", False) and not first: + Trending.parent.logger.info( + "TikTok isn't sending more TikToks beyond this point." + ) + return + + first = False diff --git a/TikTokApi/api/user.py b/TikTokApi/api/user.py new file mode 100644 index 00000000..35a09565 --- /dev/null +++ b/TikTokApi/api/user.py @@ -0,0 +1,287 @@ +from __future__ import annotations + +import json +import requests + +from urllib.parse import quote, urlencode + +from ..exceptions import * +from ..helpers import extract_tag_contents + +from typing import TYPE_CHECKING, ClassVar, Iterator, Optional + +if TYPE_CHECKING: + from ..tiktok import TikTokApi + from .video import Video + + +class User: + """ + A TikTok User. + + Example Usage + ```py + user = api.user(username='therock') + # or + user_id = '5831967' + sec_uid = 'MS4wLjABAAAA-VASjiXTh7wDDyXvjk10VFhMWUAoxr8bgfO1kAL1-9s' + user = api.user(user_id=user_id, sec_uid=sec_uid) + ``` + + """ + + parent: ClassVar[TikTokApi] + + user_id: str + """The user ID of the user.""" + sec_uid: str + """The sec UID of the user.""" + username: str + """The username of the user.""" + as_dict: dict + """The raw data associated with this user.""" + + def __init__( + self, + username: Optional[str] = None, + user_id: Optional[str] = None, + sec_uid: Optional[str] = None, + data: Optional[dict] = None, + ): + """ + You must provide the username or (user_id and sec_uid) otherwise this + will not function correctly. + """ + self.__update_id_sec_uid_username(user_id, sec_uid, username) + if data is not None: + self.as_dict = data + self.__extract_from_data() + + def info(self, **kwargs): + """ + Returns a dictionary of TikTok's User object + + Example Usage + ```py + user_data = api.user(username='therock').info() + ``` + """ + return self.info_full(**kwargs)["user"] + + def info_full(self, **kwargs) -> dict: + """ + Returns a dictionary of information associated with this User. + Includes statistics about this user. + + Example Usage + ```py + user_data = api.user(username='therock').info_full() + ``` + """ + + # TODO: Find the one using only user_id & sec_uid + if not self.username: + raise TypeError( + "You must provide the username when creating this class to use this method." + ) + + quoted_username = quote(self.username) + r = requests.get( + "https://tiktok.com/@{}?lang=en".format(quoted_username), + headers={ + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", + "path": "/@{}".format(quoted_username), + "Accept-Encoding": "gzip, deflate", + "Connection": "keep-alive", + "User-Agent": self.parent._user_agent, + }, + proxies=User.parent._format_proxy(kwargs.get("proxy", None)), + cookies=User.parent._get_cookies(**kwargs), + **User.parent._requests_extra_kwargs, + ) + + data = extract_tag_contents(r.text) + user = json.loads(data) + + user_props = user["props"]["pageProps"] + if user_props["statusCode"] == 404: + raise TikTokNotFoundError( + "TikTok user with username {} does not exist".format(self.username) + ) + + return user_props["userInfo"] + + def videos(self, count=30, cursor=0, **kwargs) -> Iterator[Video]: + """ + Returns an iterator yielding Video objects. + + - Parameters: + - count (int): The amount of videos you want returned. + - cursor (int): The unix epoch to get uploaded videos since. + + Example Usage + ```py + user = api.user(username='therock') + for video in user.videos(count=100): + # do something + ``` + """ + processed = User.parent._process_kwargs(kwargs) + kwargs["custom_device_id"] = processed.device_id + + if not self.user_id and not self.sec_uid: + self.__find_attributes() + + first = True + amount_yielded = 0 + + while amount_yielded < count: + query = { + "count": 30, + "id": self.user_id, + "cursor": cursor, + "type": 1, + "secUid": self.sec_uid, + "sourceType": 8, + "appId": 1233, + "region": processed.region, + "priority_region": processed.region, + "language": processed.language, + } + path = "api/post/item_list/?{}&{}".format( + User.parent._add_url_params(), urlencode(query) + ) + + res = User.parent.get_data(path, send_tt_params=True, **kwargs) + + videos = res.get("itemList", []) + amount_yielded += len(videos) + for video in videos: + yield self.parent.video(data=video) + + if not res.get("hasMore", False) and not first: + User.parent.logger.info( + "TikTok isn't sending more TikToks beyond this point." + ) + return + + cursor = res["cursor"] + first = False + + def liked(self, count: int = 30, cursor: int = 0, **kwargs) -> Iterator[Video]: + """ + Returns a dictionary listing TikToks that a given a user has liked. + + **Note**: The user's likes must be **public** (which is not the default option) + + - Parameters: + - count (int): The amount of videos you want returned. + - cursor (int): The unix epoch to get uploaded videos since. + + Example Usage + ```py + for liked_video in api.user(username='public_likes'): + # do something + ``` + """ + processed = User.parent._process_kwargs(kwargs) + kwargs["custom_device_id"] = processed.device_id + + amount_yielded = 0 + first = True + + if self.user_id is None and self.sec_uid is None: + self.__find_attributes() + + while amount_yielded < count: + query = { + "count": 30, + "id": self.user_id, + "type": 2, + "secUid": self.sec_uid, + "cursor": cursor, + "sourceType": 9, + "appId": 1233, + "region": processed.region, + "priority_region": processed.region, + "language": processed.language, + } + path = "api/favorite/item_list/?{}&{}".format( + User.parent._add_url_params(), urlencode(query) + ) + + res = self.parent.get_data(path, **kwargs) + + if "itemList" not in res.keys(): + if first: + User.parent.logger.error("User's likes are most likely private") + return + + videos = res.get("itemList", []) + amount_yielded += len(videos) + for video in videos: + amount_yielded += 1 + yield self.parent.video(data=video) + + if not res.get("hasMore", False) and not first: + User.parent.logger.info( + "TikTok isn't sending more TikToks beyond this point." + ) + return + + cursor = res["cursor"] + first = False + + def __extract_from_data(self): + data = self.as_dict + keys = data.keys() + + if "user_info" in keys: + self.__update_id_sec_uid_username( + data["user_info"]["uid"], + data["user_info"]["sec_uid"], + data["user_info"]["unique_id"], + ) + elif "uniqueId" in keys: + self.__update_id_sec_uid_username( + data["id"], data["secUid"], data["uniqueId"] + ) + + if None in (self.username, self.user_id, self.sec_uid): + User.parent.logger.error( + f"Failed to create User with data: {data}\nwhich has keys {data.keys()}" + ) + + def __update_id_sec_uid_username(self, id, sec_uid, username): + self.user_id = id + self.sec_uid = sec_uid + self.username = username + + def __find_attributes(self) -> None: + # It is more efficient to check search first, since self.user_object() makes HTML request. + found = False + for u in self.parent.search.users(self.username): + if u.username == self.username: + found = True + self.__update_id_sec_uid_username(u.user_id, u.sec_uid, u.username) + break + + if not found: + user_object = self.info() + self.__update_id_sec_uid_username( + user_object["id"], user_object["secUid"], user_object["uniqueId"] + ) + + def __repr__(self): + return self.__str__() + + def __str__(self): + return f"TikTokApi.user(username='{self.username}', user_id='{self.user_id}', sec_uid='{self.sec_uid}')" + + def __getattr__(self, name): + if name in ["as_dict"]: + self.as_dict = self.info() + self.__extract_from_data() + return self.__getattribute__(name) + + raise AttributeError(f"{name} doesn't exist on TikTokApi.api.User") diff --git a/TikTokApi/api/video.py b/TikTokApi/api/video.py new file mode 100644 index 00000000..7285e79c --- /dev/null +++ b/TikTokApi/api/video.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from urllib.parse import urlencode + +from ..helpers import extract_video_id_from_url + +import logging +from typing import TYPE_CHECKING, ClassVar, Optional + +if TYPE_CHECKING: + from ..tiktok import TikTokApi + from .user import User + from .sound import Sound + from .hashtag import Hashtag + + +class Video: + """ + A TikTok Video class + + Example Usage + ```py + video = api.video(id='7041997751718137094') + ``` + """ + + parent: ClassVar[TikTokApi] + + id: Optional[str] + """TikTok's ID of the Video""" + author: Optional[User] + """The User who created the Video""" + sound: Optional[Sound] + """The Sound that is associated with the Video""" + hashtags: Optional[list[Hashtag]] + """A List of Hashtags on the Video""" + as_dict: dict + """The raw data associated with this Video.""" + + def __init__( + self, + id: Optional[str] = None, + url: Optional[str] = None, + data: Optional[dict] = None, + ): + """ + You must provide the id or a valid url, else this will fail. + """ + self.id = id + if data is not None: + self.as_dict = data + self.__extract_from_data() + elif url is not None: + self.id = extract_video_id_from_url(url) + + if self.id is None: + raise TypeError("You must provide id or url parameter.") + + def info(self, **kwargs) -> dict: + """ + Returns a dictionary of TikTok's Video object. + + Example Usage + ```py + video_data = api.video(id='7041997751718137094').info() + ``` + """ + return self.info_full(**kwargs)["itemInfo"]["itemStruct"] + + def info_full(self, **kwargs) -> dict: + """ + Returns a dictionary of all data associated with a TikTok Video. + + Example Usage + ```py + video_data = api.video(id='7041997751718137094').info_full() + ``` + """ + processed = self.parent._process_kwargs(kwargs) + kwargs["custom_device_id"] = processed.device_id + + device_id = kwargs.get("custom_device_id", None) + query = { + "itemId": self.id, + } + path = "api/item/detail/?{}&{}".format( + self.parent._add_url_params(), urlencode(query) + ) + + return self.parent.get_data(path, **kwargs) + + def bytes(self, **kwargs) -> bytes: + """ + Returns the bytes of a TikTok Video. + + Example Usage + ```py + video_bytes = api.video(id='7041997751718137094').bytes() + + # Saving The Video + with open('saved_video.mp4', 'wb') as output: + output.write(video_bytes) + ``` + """ + processed = self.parent._process_kwargs(kwargs) + kwargs["custom_device_id"] = processed.device_id + + video_data = self.info(**kwargs) + download_url = video_data["video"]["playAddr"] + + return self.parent.get_bytes(url=download_url, **kwargs) + + def __extract_from_data(self) -> None: + data = self.as_dict + keys = data.keys() + + if "author" in keys: + self.id = data["id"] + self.author = self.parent.user(data=data["author"]) + self.sound = self.parent.sound(data=data["music"]) + + self.hashtags = [ + self.parent.hashtag(data=hashtag) + for hashtag in data.get("challenges", []) + ] + + if self.id is None: + Video.parent.logger.error( + f"Failed to create Video with data: {data}\nwhich has keys {data.keys()}" + ) + + def __repr__(self): + return self.__str__() + + def __str__(self): + return f"TikTokApi.video(id='{self.id}')" + + def __getattr__(self, name): + # Handle author, sound, hashtags, as_dict + if name in ["author", "sound", "hashtags", "as_dict"]: + self.as_dict = self.info() + self.__extract_from_data() + return self.__getattribute__(name) + + raise AttributeError(f"{name} doesn't exist on TikTokApi.api.Video") diff --git a/TikTokApi/browser_utilities/browser.py b/TikTokApi/browser_utilities/browser.py index 8e3b44ef..b0982c7e 100644 --- a/TikTokApi/browser_utilities/browser.py +++ b/TikTokApi/browser_utilities/browser.py @@ -8,15 +8,16 @@ import json import re from .browser_interface import BrowserInterface -from urllib.parse import splitquery, parse_qs, parse_qsl +from urllib.parse import parse_qsl, urlparse - -# Import Detection From Stealth -from .get_acrawler import get_acrawler, get_tt_params_script +from ..utilities import LOGGER_NAME +from .get_acrawler import _get_acrawler, _get_tt_params_script from playwright.sync_api import sync_playwright playwright = None +logger = logging.getLogger(LOGGER_NAME) + def get_playwright(): global playwright @@ -40,7 +41,7 @@ def __init__( self.api_url = kwargs.get("api_url", None) self.referrer = kwargs.get("referer", "https://www.tiktok.com/") self.language = kwargs.get("language", "en") - self.executablePath = kwargs.get("executablePath", None) + self.executable_path = kwargs.get("executable_path", None) self.device_id = kwargs.get("custom_device_id", None) args = kwargs.get("browser_args", []) @@ -72,18 +73,18 @@ def __init__( self.options.update(options) - if self.executablePath is not None: - self.options["executablePath"] = self.executablePath + if self.executable_path is not None: + self.options["executable_path"] = self.executable_path try: self.browser = get_playwright().webkit.launch( args=self.args, **self.options ) except Exception as e: - logging.critical(e) + logger.critical("Webkit launch failed", exc_info=True) raise e - context = self.create_context(set_useragent=True) + context = self._create_context(set_useragent=True) page = context.new_page() self.get_params(page) context.close() @@ -118,7 +119,7 @@ def get_params(self, page) -> None: self.width = page.evaluate("""() => { return screen.width; }""") self.height = page.evaluate("""() => { return screen.height; }""") - def create_context(self, set_useragent=False): + def _create_context(self, set_useragent=False): iphone = playwright.devices["iPhone 11 Pro"] iphone["viewport"] = { "width": random.randint(320, 1920), @@ -132,11 +133,11 @@ def create_context(self, set_useragent=False): context = self.browser.new_context(**iphone) if set_useragent: - self.userAgent = iphone["user_agent"] + self.user_agent = iphone["user_agent"] return context - def base36encode(self, number, alphabet="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"): + def _base36encode(self, number, alphabet="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"): """Converts an integer to a base36 string.""" base36 = "" sign = "" @@ -157,7 +158,7 @@ def base36encode(self, number, alphabet="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"): def gen_verifyFp(self): chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"[:] chars_len = len(chars) - scenario_title = self.base36encode(int(time.time() * 1000)) + scenario_title = self._base36encode(int(time.time() * 1000)) uuid = [0] * 36 uuid[8] = "_" uuid[13] = "_" @@ -173,16 +174,12 @@ def gen_verifyFp(self): return f'verify_{scenario_title.lower()}_{"".join(uuid)}' - def sign_url(self, calc_tt_params=False, **kwargs): + def sign_url(self, url, calc_tt_params=False, **kwargs): def process(route): route.abort() - url = kwargs.get("url", None) - if url is None: - raise Exception("sign_url required a url parameter") - tt_params = None - context = self.create_context() + context = self._create_context() page = context.new_page() if calc_tt_params: @@ -202,7 +199,7 @@ def process(route): verifyFp = self.gen_verifyFp() else: verifyFp = kwargs.get( - "custom_verifyFp", + "custom_verify_fp", "verify_khgp4f49_V12d4mRX_MdCO_4Wzt_Ar0k_z4RCQC9pUDpX", ) @@ -215,7 +212,7 @@ def process(route): url = "{}&verifyFp={}&device_id={}".format(url, verifyFp, device_id) - page.add_script_tag(content=get_acrawler()) + page.add_script_tag(content=_get_acrawler()) evaluatedPage = page.evaluate( '''() => { var url = "''' @@ -230,12 +227,12 @@ def process(route): url = "{}&_signature={}".format(url, evaluatedPage) if calc_tt_params: - page.add_script_tag(content=get_tt_params_script()) + page.add_script_tag(content=_get_tt_params_script()) tt_params = page.evaluate( """() => { return window.genXTTParams(""" - + json.dumps(dict(parse_qsl(splitquery(url)[1]))) + + json.dumps(dict(parse_qsl(urlparse(url).query))) + """); }""" @@ -244,11 +241,11 @@ def process(route): context.close() return (verifyFp, device_id, evaluatedPage, tt_params) - def clean_up(self): + def _clean_up(self): try: self.browser.close() except Exception: - logging.info("cleanup failed") + logger.exception("cleanup failed") # playwright.stop() def find_redirect(self, url): diff --git a/TikTokApi/browser_utilities/browser_interface.py b/TikTokApi/browser_utilities/browser_interface.py index a453c61a..fb4e9332 100644 --- a/TikTokApi/browser_utilities/browser_interface.py +++ b/TikTokApi/browser_utilities/browser_interface.py @@ -16,5 +16,5 @@ def sign_url(self, calc_tt_params=False, **kwargs): pass @abc.abstractmethod - def clean_up(self) -> None: + def _clean_up(self) -> None: pass diff --git a/TikTokApi/browser_utilities/browser_selenium.py b/TikTokApi/browser_utilities/browser_selenium.py deleted file mode 100644 index 1b79df91..00000000 --- a/TikTokApi/browser_utilities/browser_selenium.py +++ /dev/null @@ -1,224 +0,0 @@ -import random -import time -import requests -import logging -from threading import Thread -import time -import re -import random -import json -from .browser_interface import BrowserInterface -from selenium_stealth import stealth -from selenium import webdriver -from .get_acrawler import get_acrawler, get_tt_params_script -from urllib.parse import splitquery, parse_qs, parse_qsl - - -class browser(BrowserInterface): - def __init__( - self, - **kwargs, - ): - self.kwargs = kwargs - self.debug = kwargs.get("debug", False) - self.proxy = kwargs.get("proxy", None) - self.api_url = kwargs.get("api_url", None) - self.referrer = kwargs.get("referer", "https://www.tiktok.com/") - self.language = kwargs.get("language", "en") - self.executablePath = kwargs.get("executablePath", "chromedriver") - self.device_id = kwargs.get("custom_device_id", None) - - args = kwargs.get("browser_args", []) - options = kwargs.get("browser_options", {}) - - if len(args) == 0: - self.args = [] - else: - self.args = args - - options = webdriver.ChromeOptions() - options.add_argument("--headless") - options.add_argument("log-level=2") - self.options = { - "headless": True, - "handleSIGINT": True, - "handleSIGTERM": True, - "handleSIGHUP": True, - } - - if self.proxy is not None: - if "@" in self.proxy: - server_prefix = self.proxy.split("://")[0] - address = self.proxy.split("@")[1] - self.options["proxy"] = { - "server": server_prefix + "://" + address, - "username": self.proxy.split("://")[1].split(":")[0], - "password": self.proxy.split("://")[1].split("@")[0].split(":")[1], - } - else: - self.options["proxy"] = {"server": self.proxy} - - # self.options.update(options) - - if self.executablePath is not None: - self.options["executablePath"] = self.executablePath - - try: - self.browser = webdriver.Chrome( - executable_path=self.executablePath, chrome_options=options - ) - except Exception as e: - raise e - - # Page avoidance - self.setup_browser() - # page.close() - - def setup_browser(self): - stealth( - self.browser, - languages=["en-US", "en"], - vendor="Google Inc.", - platform="Win32", - webgl_vendor="Intel Inc.", - renderer="Intel Iris OpenGL Engine", - fix_hairline=True, - ) - - self.get_params(self.browser) - # NOTE: Slower than playwright at loading this because playwright can ignore unneeded files. - self.browser.get("https://www.tiktok.com/@redbull") - self.browser.execute_script(get_acrawler()) - self.browser.execute_script(get_tt_params_script()) - - def get_params(self, page) -> None: - self.userAgent = page.execute_script("""return navigator.userAgent""") - self.browser_language = self.kwargs.get( - "browser_language", ("""return navigator.language""") - ) - self.browser_version = """return window.navigator.appVersion""" - - if len(self.browser_language.split("-")) == 0: - self.region = self.kwargs.get("region", "US") - self.language = self.kwargs.get("language", "en") - elif len(self.browser_language.split("-")) == 1: - self.region = self.kwargs.get("region", "US") - self.language = self.browser_language.split("-")[0] - else: - self.region = self.kwargs.get("region", self.browser_language.split("-")[1]) - self.language = self.kwargs.get( - "language", self.browser_language.split("-")[0] - ) - - self.timezone_name = self.kwargs.get( - "timezone_name", - ("""return Intl.DateTimeFormat().resolvedOptions().timeZone"""), - ) - self.width = """return screen.width""" - self.height = """return screen.height""" - - def base36encode(self, number, alphabet="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"): - """Converts an integer to a base36 string.""" - base36 = "" - sign = "" - - if number < 0: - sign = "-" - number = -number - - if 0 <= number < len(alphabet): - return sign + alphabet[number] - - while number != 0: - number, i = divmod(number, len(alphabet)) - base36 = alphabet[i] + base36 - - return sign + base36 - - def gen_verifyFp(self): - chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"[:] - chars_len = len(chars) - scenario_title = self.base36encode(int(time.time() * 1000)) - uuid = [0] * 36 - uuid[8] = "_" - uuid[13] = "_" - uuid[18] = "_" - uuid[23] = "_" - uuid[14] = "4" - - for i in range(36): - if uuid[i] != 0: - continue - r = int(random.random() * chars_len) - uuid[i] = chars[int((3 & r) | 8 if i == 19 else r)] - - return f'verify_{scenario_title.lower()}_{"".join(uuid)}' - - def sign_url(self, calc_tt_params=False, **kwargs): - url = kwargs.get("url", None) - if url is None: - raise Exception("sign_url required a url parameter") - - tt_params = None - if kwargs.get("gen_new_verifyFp", False): - verifyFp = self.gen_verifyFp() - else: - verifyFp = kwargs.get( - "custom_verifyFp", - "verify_khgp4f49_V12d4mRX_MdCO_4Wzt_Ar0k_z4RCQC9pUDpX", - ) - - if kwargs.get("custom_device_id") is not None: - device_id = kwargs.get("custom_device_id", None) - elif self.device_id is None: - device_id = str(random.randint(10000, 999999999)) - else: - device_id = self.device_id - - url = "{}&verifyFp={}&device_id={}".format(url, verifyFp, device_id) - # self.browser.execute_script(content=get_acrawler()) - # Should be covered by an earlier addition of get_acrawler. - evaluatedPage = ( - self.browser.execute_script( - ''' - var url = "''' - + url - + """" - var token = window.byted_acrawler.sign({url: url}); - return token; - """ - ), - ) - - url = "{}&_signature={}".format(url, evaluatedPage) - # self.browser.execute_script(content=get_tt_params_script()) - # Should be covered by an earlier addition of get_acrawler. - - tt_params = self.browser.execute_script( - """() => { - return window.genXTTParams(""" - + json.dumps(dict(parse_qsl(splitquery(url)[1]))) - + """); - - }""" - ) - - return (verifyFp, device_id, evaluatedPage, tt_params) - - def clean_up(self): - try: - self.browser.close() - except: - logging.warning("cleanup of browser failed") - - def __format_proxy(self, proxy): - if proxy is not None: - return {"http": proxy, "https": proxy} - else: - return None - - def __get_js(self): - return requests.get( - "https://sf16-muse-va.ibytedtos.com/obj/rc-web-sdk-gcs/acrawler.js", - proxies=self.__format_proxy(self.proxy), - ).text diff --git a/TikTokApi/browser_utilities/get_acrawler.py b/TikTokApi/browser_utilities/get_acrawler.py index f1a51f16..7996dcfe 100644 --- a/TikTokApi/browser_utilities/get_acrawler.py +++ b/TikTokApi/browser_utilities/get_acrawler.py @@ -1,6 +1,6 @@ -def get_tt_params_script(): +def _get_tt_params_script(): return """var CryptoJS=CryptoJS||function(e,t){var r={},n=r.lib={},i=n.Base=function(){function e(){}return{extend:function(t){e.prototype=this;var r=new e;return t&&r.mixIn(t),r.hasOwnProperty("init")&&this.init!==r.init||(r.init=function(){r.$super.init.apply(this,arguments)}),r.init.prototype=r,r.$super=this,r},create:function(){var e=this.extend();return e.init.apply(e,arguments),e},init:function(){},mixIn:function(e){for(var t in e)e.hasOwnProperty(t)&&(this[t]=e[t]);e.hasOwnProperty("toString")&&(this.toString=e.toString)},clone:function(){return this.init.prototype.extend(this)}}}(),c=n.WordArray=i.extend({init:function(e,t){e=this.words=e||[],this.sigBytes=null!=t?t:4*e.length},toString:function(e){return(e||f).stringify(this)},concat:function(e){var t=this.words,r=e.words,n=this.sigBytes,i=e.sigBytes;if(this.clamp(),n%4)for(var c=0;c>>2]>>>24-c%4*8&255;t[n+c>>>2]|=o<<24-(n+c)%4*8}else if(r.length>65535)for(c=0;c>>2]=r[c>>>2];else t.push.apply(t,r);return this.sigBytes+=i,this},clamp:function(){var t=this.words,r=this.sigBytes;t[r>>>2]&=4294967295<<32-r%4*8,t.length=e.ceil(r/4)},clone:function(){var e=i.clone.call(this);return e.words=this.words.slice(0),e},random:function(t){for(var r,n=[],i=function(t){t=t;var r=987654321,n=4294967295;return function(){var i=((r=36969*(65535&r)+(r>>16)&n)<<16)+(t=18e3*(65535&t)+(t>>16)&n)&n;return i/=4294967296,(i+=.5)*(e.random()>.5?1:-1)}},o=0;o>>2]>>>24-i%4*8&255;n.push((c>>>4).toString(16)),n.push((15&c).toString(16))}return n.join("")},parse:function(e){for(var t=e.length,r=[],n=0;n>>3]|=parseInt(e.substr(n,2),16)<<24-n%8*4;return new c.init(r,t/2)}},a=o.Latin1={stringify:function(e){for(var t=e.words,r=e.sigBytes,n=[],i=0;i>>2]>>>24-i%4*8&255;n.push(String.fromCharCode(c))}return n.join("")},parse:function(e){for(var t=e.length,r=[],n=0;n>>2]|=(255&e.charCodeAt(n))<<24-n%4*8;return new c.init(r,t)}},s=o.Utf8={stringify:function(e){try{return decodeURIComponent(escape(a.stringify(e)))}catch(e){throw new Error("Malformed UTF-8 data")}},parse:function(e){return a.parse(unescape(encodeURIComponent(e)))}},u=n.BufferedBlockAlgorithm=i.extend({reset:function(){this._data=new c.init,this._nDataBytes=0},_append:function(e){"string"==typeof e&&(e=s.parse(e)),this._data.concat(e),this._nDataBytes+=e.sigBytes},_process:function(t){var r=this._data,n=r.words,i=r.sigBytes,o=this.blockSize,f=i/(4*o),a=(f=t?e.ceil(f):e.max((0|f)-this._minBufferSize,0))*o,s=e.min(4*a,i);if(a){for(var u=0;u>>2]>>>24-c%4*8&255)<<16|(t[c+1>>>2]>>>24-(c+1)%4*8&255)<<8|t[c+2>>>2]>>>24-(c+2)%4*8&255,f=0;f<4&&c+.75*f>>6*(3-f)&63));var a=n.charAt(64);if(a)for(;i.length%4;)i.push(a);return i.join("")},parse:function(e){var r=e.length,n=this._map,i=this._reverseMap;if(!i){i=this._reverseMap=[];for(var c=0;c>>6-o%4*2;i[c>>>2]|=(f|a)<<24-c%4*8,c++}return t.create(i,c)}(e,r,i)},_map:"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="}}(),CryptoJS.lib.Cipher||function(e){var t=CryptoJS,r=t.lib,n=r.Base,i=r.WordArray,c=r.BufferedBlockAlgorithm,o=t.enc,f=(o.Utf8,o.Base64),a=t.algo.EvpKDF,s=r.Cipher=c.extend({cfg:n.extend(),createEncryptor:function(e,t){return this.create(this._ENC_XFORM_MODE,e,t)},createDecryptor:function(e,t){return this.create(this._DEC_XFORM_MODE,e,t)},init:function(e,t,r){this.cfg=this.cfg.extend(r),this._xformMode=e,this._key=t,this.reset()},reset:function(){c.reset.call(this),this._doReset()},process:function(e){return this._append(e),this._process()},finalize:function(e){return e&&this._append(e),this._doFinalize()},keySize:4,ivSize:4,_ENC_XFORM_MODE:1,_DEC_XFORM_MODE:2,_createHelper:function(){function e(e){return"string"==typeof e?_:v}return function(t){return{encrypt:function(r,n,i){return e(n).encrypt(t,r,n,i)},decrypt:function(r,n,i){return e(n).decrypt(t,r,n,i)}}}}()}),u=(r.StreamCipher=s.extend({_doFinalize:function(){return this._process(!0)},blockSize:1}),t.mode={}),d=r.BlockCipherMode=n.extend({createEncryptor:function(e,t){return this.Encryptor.create(e,t)},createDecryptor:function(e,t){return this.Decryptor.create(e,t)},init:function(e,t){this._cipher=e,this._iv=t}}),l=u.CBC=function(){var t=d.extend();function r(t,r,n){var i=this._iv;if(i){var c=i;this._iv=e}else c=this._prevBlock;for(var o=0;o>>2];e.sigBytes-=t}},h=(r.BlockCipher=s.extend({cfg:s.cfg.extend({mode:l,padding:p}),reset:function(){s.reset.call(this);var e=this.cfg,t=e.iv,r=e.mode;if(this._xformMode==this._ENC_XFORM_MODE)var n=r.createEncryptor;else{n=r.createDecryptor;this._minBufferSize=1}this._mode&&this._mode.__creator==n?this._mode.init(this,t&&t.words):(this._mode=n.call(r,this,t&&t.words),this._mode.__creator=n)},_doProcessBlock:function(e,t){this._mode.processBlock(e,t)},_doFinalize:function(){var e=this.cfg.padding;if(this._xformMode==this._ENC_XFORM_MODE){e.pad(this._data,this.blockSize);var t=this._process(!0)}else{t=this._process(!0);e.unpad(t)}return t},blockSize:4}),r.CipherParams=n.extend({init:function(e){this.mixIn(e)},toString:function(e){return(e||this.formatter).stringify(this)}})),y=(t.format={}).OpenSSL={stringify:function(e){var t=e.ciphertext,r=e.salt;if(r)var n=i.create([1398893684,1701076831]).concat(r).concat(t);else n=t;return n.toString(f)},parse:function(e){var t=f.parse(e),r=t.words;if(1398893684==r[0]&&1701076831==r[1]){var n=i.create(r.slice(2,4));r.splice(0,4),t.sigBytes-=16}return h.create({ciphertext:t,salt:n})}},v=r.SerializableCipher=n.extend({cfg:n.extend({format:y}),encrypt:function(e,t,r,n){n=this.cfg.extend(n);var i=e.createEncryptor(r,n),c=i.finalize(t),o=i.cfg;return h.create({ciphertext:c,key:r,iv:o.iv,algorithm:e,mode:o.mode,padding:o.padding,blockSize:e.blockSize,formatter:n.format})},decrypt:function(e,t,r,n){return n=this.cfg.extend(n),t=this._parse(t,n.format),e.createDecryptor(r,n).finalize(t.ciphertext)},_parse:function(e,t){return"string"==typeof e?t.parse(e,this):e}}),b=(t.kdf={}).OpenSSL={execute:function(e,t,r,n){n||(n=i.random(8));var c=a.create({keySize:t+r}).compute(e,n),o=i.create(c.words.slice(t),4*r);return c.sigBytes=4*t,h.create({key:c,iv:o,salt:n})}},_=r.PasswordBasedCipher=v.extend({cfg:v.cfg.extend({kdf:b}),encrypt:function(e,t,r,n){var i=(n=this.cfg.extend(n)).kdf.execute(r,e.keySize,e.ivSize);n.iv=i.iv;var c=v.encrypt.call(this,e,t,i.key,n);return c.mixIn(i),c},decrypt:function(e,t,r,n){n=this.cfg.extend(n),t=this._parse(t,n.format);var i=n.kdf.execute(r,e.keySize,e.ivSize,t.salt);return n.iv=i.iv,v.decrypt.call(this,e,t,i.key,n)}})}(),CryptoJS.mode.ECB=function(){var e=CryptoJS.lib.BlockCipherMode.extend();return e.Encryptor=e.extend({processBlock:function(e,t){this._cipher.encryptBlock(e,t)}}),e.Decryptor=e.extend({processBlock:function(e,t){this._cipher.decryptBlock(e,t)}}),e}(),function(){var e=CryptoJS,t=e.lib.BlockCipher,r=e.algo,n=[],i=[],c=[],o=[],f=[],a=[],s=[],u=[],d=[],l=[];!function(){for(var e=[],t=0;t<256;t++)e[t]=t<128?t<<1:t<<1^283;var r=0,p=0;for(t=0;t<256;t++){var h=p^p<<1^p<<2^p<<3^p<<4;h=h>>>8^255&h^99,n[r]=h,i[h]=r;var y=e[r],v=e[y],b=e[v],_=257*e[h]^16843008*h;c[r]=_<<24|_>>>8,o[r]=_<<16|_>>>16,f[r]=_<<8|_>>>24,a[r]=_;_=16843009*b^65537*v^257*y^16843008*r;s[h]=_<<24|_>>>8,u[h]=_<<16|_>>>16,d[h]=_<<8|_>>>24,l[h]=_,r?(r=y^e[e[e[b^y]]],p^=e[e[p]]):r=p=1}}();var p=[0,1,2,4,8,16,32,64,128,27,54],h=r.AES=t.extend({_doReset:function(){if(!this._nRounds||this._keyPriorReset!==this._key){for(var e=this._keyPriorReset=this._key,t=e.words,r=e.sigBytes/4,i=4*((this._nRounds=r+6)+1),c=this._keySchedule=[],o=0;o6&&o%r==4&&(f=n[f>>>24]<<24|n[f>>>16&255]<<16|n[f>>>8&255]<<8|n[255&f]):(f=n[(f=f<<8|f>>>24)>>>24]<<24|n[f>>>16&255]<<16|n[f>>>8&255]<<8|n[255&f],f^=p[o/r|0]<<24),c[o]=c[o-r]^f}for(var a=this._invKeySchedule=[],h=0;h>>24]]^u[n[f>>>16&255]]^d[n[f>>>8&255]]^l[n[255&f]]}}},encryptBlock:function(e,t){this._doCryptBlock(e,t,this._keySchedule,c,o,f,a,n)},decryptBlock:function(e,t){var r=e[t+1];e[t+1]=e[t+3],e[t+3]=r,this._doCryptBlock(e,t,this._invKeySchedule,s,u,d,l,i);r=e[t+1];e[t+1]=e[t+3],e[t+3]=r},_doCryptBlock:function(e,t,r,n,i,c,o,f){for(var a=this._nRounds,s=e[t]^r[0],u=e[t+1]^r[1],d=e[t+2]^r[2],l=e[t+3]^r[3],p=4,h=1;h>>24]^i[u>>>16&255]^c[d>>>8&255]^o[255&l]^r[p++],v=n[u>>>24]^i[d>>>16&255]^c[l>>>8&255]^o[255&s]^r[p++],b=n[d>>>24]^i[l>>>16&255]^c[s>>>8&255]^o[255&u]^r[p++],_=n[l>>>24]^i[s>>>16&255]^c[u>>>8&255]^o[255&d]^r[p++];s=y,u=v,d=b,l=_}y=(f[s>>>24]<<24|f[u>>>16&255]<<16|f[d>>>8&255]<<8|f[255&l])^r[p++],v=(f[u>>>24]<<24|f[d>>>16&255]<<16|f[l>>>8&255]<<8|f[255&s])^r[p++],b=(f[d>>>24]<<24|f[l>>>16&255]<<16|f[s>>>8&255]<<8|f[255&u])^r[p++],_=(f[l>>>24]<<24|f[s>>>16&255]<<16|f[u>>>8&255]<<8|f[255&d])^r[p++];e[t]=y,e[t+1]=v,e[t+2]=b,e[t+3]=_},keySize:8});e.AES=t._createHelper(h)}();var a,i={};i.CryptoJS=CryptoJS,window._$jsvmprt=function(e,t,r){function n(e,t,r){return(n=function(){if("undefined"==typeof Reflect||!Reflect.construct)return!1;if(Reflect.construct.sham)return!1;if("function"==typeof Proxy)return!0;try{return Date.prototype.toString.call(Reflect.construct(Date,[],function(){})),!0}catch(e){return!1}}()?Reflect.construct:function(e,t,r){var n=[null];n.push.apply(n,t);var i=new(Function.bind.apply(e,n));return r&&function(e,t){(Object.setPrototypeOf||function(e,t){return e.__proto__=t,e})(e,t)}(i,r.prototype),i}).apply(null,arguments)}function i(e){return function(e){if(Array.isArray(e)){for(var t=0,r=new Array(e.length);t>7==0)return[1,i];if(i>>6==2){var c=parseInt(""+e[++t]+e[++t],16);return i&=63,[2,c=(i<<=8)+c]}if(i>>6==3){var o=parseInt(""+e[++t]+e[++t],16),f=parseInt(""+e[++t]+e[++t],16);return i&=63,[3,f=(i<<=16)+(o<<=8)+f]}},u=function(e,t){var r=parseInt(""+e[t]+e[t+1],16);return r>127?-256+r:r},d=function(e,t){var r=parseInt(""+e[t]+e[t+1]+e[t+2]+e[t+3],16);return r>32767?-65536+r:r},l=function(e,t){var r=parseInt(""+e[t]+e[t+1]+e[t+2]+e[t+3]+e[t+4]+e[t+5]+e[t+6]+e[t+7],16);return r>2147483647?0+r:r},p=function(e,t){return parseInt(""+e[t]+e[t+1],16)},h=function(e,t){return parseInt(""+e[t]+e[t+1]+e[t+2]+e[t+3],16)},y=y||this||window,v=(Object.keys,e.length,0),b="",_=v;_>=2,O>2)O=3&B,B>>=2,O<1?(O=B)<4?(g=k[C--],k[C]=k[C]-g):O<6?(g=k[C--],k[C]=k[C]===g):O<15&&(g=k[C],k[C]=k[C-1],k[C-1]=g):O<2?(O=B)<5&&(x=p(e,w),w+=2,g=l[x],k[++C]=g):O<3?(O=B)<6||(O<8?g=k[C--]:O<12&&(x=d(e,w),f[++a]=[[w+4,x-3],0,0],w+=2*x-2)):(O=B)<2?(g=k[C--],k[C]=k[C]1)if(O=3&B,B>>=2,O>2)(O=B)>5?(x=p(e,w),w+=2,k[++C]=l["$"+x]):O>3&&(x=d(e,w),f[a][0]&&!f[a][2]?f[a][1]=[w+4,x-3]:f[a++]=[0,[w+4,x-3],0],w+=2*x-2);else if(O>1){if((O=B)>2)if(k[C--])w+=4;else{if((x=d(e,w))<0){_=1,X(e,t,2*r),w+=2*x-2;break}w+=2*x-2}else if(O>0){for(x=h(e,w),g="",A=c.q[x][0];A0?(O=B)>1?(g=k[C--],k[C]=k[C]+g):O>-1&&(k[++C]=y):(O=B)>9?(x=p(e,w),w+=2,g=k[C--],l[x]=g):O>7?(x=h(e,w),w+=4,m=C+1,k[C-=x-1]=x?k.slice(C,m):[]):O>0&&(g=k[C--],k[C]=k[C]>g);else if(O>0){if(O=3&B,B>>=2,O<1){if((O=B)>9);else if(O>5)x=p(e,w),w+=2,k[C-=x]=0===x?new k[C]:n(k[C],i(k.slice(C+1,C+x+1)));else if(O>3){x=d(e,w);try{if(f[a][2]=1,1==(g=N(e,w+4,x-3,[],l,v,null,0))[0])return g}catch(b){if(f[a]&&f[a][1]&&1==(g=N(e,f[a][1][0],f[a][1][1],[],l,v,b,0))[0])return g}finally{if(f[a]&&f[a][0]&&1==(g=N(e,f[a][0][0],f[a][0][1],[],l,v,null,0))[0])return g;f[a]=0,a--}w+=2*x-2}}else if(O<2){if((O=B)>12)k[++C]=u(e,w),w+=2;else if(O>8){for(x=h(e,w),O="",A=c.q[x][0];A11?(g=k[C],k[++C]=g):O>0&&(k[++C]=g);else if((O=B)<1)k[C]=!k[C];else if(O<3){if((x=d(e,w))<0){_=1,X(e,t,2*r),w+=2*x-2;break}w+=2*x-2}}else if(O=3&B,B>>=2,O>2)(O=B)<1&&(k[++C]=null);else if(O>1){if((O=B)<9){for(g=k[C--],x=h(e,w),O="",A=c.q[x][0];A0)(O=B)<4?(m=k[C--],(O=k[C]).x===N?O.y>=1?k[C]=U(e,O.c,O.l,[m],O.z,S,null,1):(k[C]=U(e,O.c,O.l,[m],O.z,S,null,0),O.y++):k[C]=O(m)):O<6&&(k[C-=1]=k[C][k[C+1]]);else{if((O=B)<1)return[1,k[C--]];O<14?(m=k[C--],S=k[C--],(O=k[C--]).x===N?O.y>=1?k[++C]=U(e,O.c,O.l,m,O.z,S,null,1):(k[++C]=U(e,O.c,O.l,m,O.z,S,null,0),O.y++):k[++C]=O.apply(S,m)):O<16&&(x=d(e,w),(I=function t(){var r=arguments;return t.y>0||t.y++,U(e,t.c,t.l,r,t.z,this,null,0)}).c=w+4,I.l=x-2,I.x=N,I.y=0,I.z=l,k[C]=I,w+=2*x-2)}}if(_)for(;w>=2,O<1)if(O=3&B,B>>=2,O>2)(O=B)<1&&(k[++C]=null);else if(O>1){if((O=B)<9){for(g=k[C--],x=$[w],O="",A=c.q[x][0];A0)(O=B)<4?(m=k[C--],(O=k[C]).x===N?O.y>=1?k[C]=U(e,O.c,O.l,[m],O.z,S,null,1):(k[C]=U(e,O.c,O.l,[m],O.z,S,null,0),O.y++):k[C]=O(m)):O<6&&(k[C-=1]=k[C][k[C+1]]);else{var I;if((O=B)>14)x=$[w],(I=function t(){var r=arguments;return t.y>0||t.y++,U(e,t.c,t.l,r,t.z,this,null,0)}).c=w+4,I.l=x-2,I.x=N,I.y=0,I.z=l,k[C]=I,w+=2*x-2;else if(O>12)m=k[C--],S=k[C--],(O=k[C--]).x===N?O.y>=1?k[++C]=U(e,O.c,O.l,m,O.z,S,null,1):(k[++C]=U(e,O.c,O.l,m,O.z,S,null,0),O.y++):k[++C]=O.apply(S,m);else if(O>-1)return[1,k[C--]]}else if(O<2)if(O=3&B,B>>=2,O>2)(O=B)<1?k[C]=!k[C]:O<3&&(w+=2*(x=$[w])-2);else if(O>1)(O=B)<2?k[++C]=g:O<13&&(g=k[C],k[++C]=g);else if(O>0)if((O=B)<10){for(x=$[w],O="",A=c.q[x][0];A>=2,O<1)(O=B)>9?(x=$[w],w+=2,g=k[C--],l[x]=g):O>7?(x=$[w],w+=4,m=C+1,k[C-=x-1]=x?k.slice(C,m):[]):O>0&&(g=k[C--],k[C]=k[C]>g);else if(O<2)(O=B)>1?(g=k[C--],k[C]=k[C]+g):O>-1&&(k[++C]=y);else if(O<3)if((O=B)<2){for(x=$[w],g="",A=c.q[x][0];A5?(x=$[w],w+=2,k[++C]=l["$"+x]):O>3&&(x=$[w],f[a][0]&&!f[a][2]?f[a][1]=[w+4,x-3]:f[a++]=[0,[w+4,x-3],0],w+=2*x-2);else O=3&B,B>>=2,O<1?(O=B)<4?(g=k[C--],k[C]=k[C]-g):O<6?(g=k[C--],k[C]=k[C]===g):O<15&&(g=k[C],k[C]=k[C-1],k[C-1]=g):O<2?(O=B)<5&&(x=$[w],w+=2,g=l[x],k[++C]=g):O<3?(O=B)>10?(x=$[w],f[++a]=[[w+4,x-3],0,0],w+=2*x-2):O>6&&(g=k[C--]):(O=B)<2?(g=k[C--],k[C]=k[C]>=1,0==z.position&&(z.position=a,z.val=y(z.index,o|=(T>0?1:0)*r,r<<=1;switch(o){case 0:for(o=0,l=Math.pow(2,8),r=1;r!=l;)T=z.val&z.position,z.position>>=1,0==z.position&&(z.position=a,z.val=y(z.index,o|=(T>0?1:0)*r,r<<=1;k=K(o)1:for(o=0,l=Math.pow(2,16),r=1;r!=l;)T=z.val&z.position,z.position>>=1,0==z.position&&(z.position=a,z.val=y(z.index,o|=(T>0?1:0)*r,r<<=1;k=K(o)2:""}for(i[3]=k,m=k,b.push(k);;){if(z.index>S)"";for(o=0,l=Math.pow(2,j),r=1;r!=l;)T=z.val&z.position,z.position>>=1,0==z.position&&(z.position=a,z.val=y(z.index,o|=(T>0?1:0)*r,r<<=1;switch(k=o){case 0:for(o=0,l=Math.pow(2,8),r=1;r!=l;)T=z.val&z.position,z.position>>=1,0==z.position&&(z.position=a,z.val=y(z.index,o|=(T>0?1:0)*r,r<<=1;i[q]=K(o),k=q-1,J--1:for(o=0,l=Math.pow(2,16),r=1;r!=l;)T=z.val&z.position,z.position>>=1,0==z.position&&(z.position=a,z.val=y(z.index,o|=(T>0?1:0)*r,r<<=1;i[q]=K(o),k=q-1,J--2:b.join("")}if(0==J&&(J=Math.pow(2,j),j),i[k])I=i[k]if(k!==q)null;I=m+m.charAt(0)}b.push(I),i[q]=m+I.charAt(0),m=I,0==--J&&(J=Math.pow(2,j),j)}}};y};""==typeof define&&define.amd?define({w}):"undefined"!=typeof module&&null!=module?module.exports=w:"undefined"!=typeof angular&&null!=angular&&angular.module("w",[]).factory("w",{w}),eval(w.x("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";'.replace(/[-]/g,function(m){return t[m.charCodeAt(0)&15]})}("var function ().length++return ));break;case ;else{".split("")))();""" diff --git a/TikTokApi/browser_utilities/stealth.py b/TikTokApi/browser_utilities/stealth.py deleted file mode 100644 index a9dc9e89..00000000 --- a/TikTokApi/browser_utilities/stealth.py +++ /dev/null @@ -1,502 +0,0 @@ -import re - - -def chrome_runtime(page) -> None: - page.evaluateOnNewDocument( - """ -() => { - window.chrome = { - runtime: {} - } -} -""" - ) - - -def console_debug(page) -> None: - page.evaluateOnNewDocument( - """ -() => { - window.console.debug = () => { - return null - } -} -""" - ) - - -def iframe_content_window(page) -> None: - page.evaluateOnNewDocument( - """ -() => { - try { - // Adds a contentWindow proxy to the provided iframe element - const addContentWindowProxy = iframe => { - const contentWindowProxy = { - get(target, key) { - // Now to the interesting part: - // We actually make this thing behave like a regular iframe window, - // by intercepting calls to e.g. `.self` and redirect it to the correct thing. :) - // That makes it possible for these assertions to be correct: - // iframe.contentWindow.self === window.top // must be false - if (key === 'self') { - return this - } - // iframe.contentWindow.frameElement === iframe // must be true - if (key === 'frameElement') { - return iframe - } - return Reflect.get(target, key) - } - } - if (!iframe.contentWindow) { - const proxy = new Proxy(window, contentWindowProxy) - Object.defineProperty(iframe, 'contentWindow', { - get() { - return proxy - }, - set(newValue) { - return newValue // contentWindow is immutable - }, - enumerable: true, - configurable: false - }) - } - } - // Handles iframe element creation, augments `srcdoc` property so we can intercept further - const handleIframeCreation = (target, thisArg, args) => { - const iframe = target.apply(thisArg, args) - // We need to keep the originals around - const _iframe = iframe - const _srcdoc = _iframe.srcdoc - // Add hook for the srcdoc property - // We need to be very surgical here to not break other iframes by accident - Object.defineProperty(iframe, 'srcdoc', { - configurable: true, // Important, so we can reset this later - get: function() { - return _iframe.srcdoc - }, - set: function(newValue) { - addContentWindowProxy(this) - // Reset property, the hook is only needed once - Object.defineProperty(iframe, 'srcdoc', { - configurable: false, - writable: false, - value: _srcdoc - }) - _iframe.srcdoc = newValue - } - }) - return iframe - } - // Adds a hook to intercept iframe creation events - const addIframeCreationSniffer = () => { - /* global document */ - const createElement = { - // Make toString() native - get(target, key) { - return Reflect.get(target, key) - }, - apply: function(target, thisArg, args) { - const isIframe = - args && args.length && `${args[0]}`.toLowerCase() === 'iframe' - if (!isIframe) { - // Everything as usual - return target.apply(thisArg, args) - } else { - return handleIframeCreation(target, thisArg, args) - } - } - } - // All this just due to iframes with srcdoc bug - document.createElement = new Proxy( - document.createElement, - createElement - ) - } - // Let's go - addIframeCreationSniffer() - } catch (err) { - // console.warn(err) - } -} -""" - ) - - -def media_codecs(page) -> None: - page.evaluateOnNewDocument( - """ - () => { - try { - /** - * Input might look funky, we need to normalize it so e.g. whitespace isn't an issue for our spoofing. - * - * @example - * video/webm; codecs="vp8, vorbis" - * video/mp4; codecs="avc1.42E01E" - * audio/x-m4a; - * audio/ogg; codecs="vorbis" - * @param {String} arg - */ - const parseInput = arg => { - const [mime, codecStr] = arg.trim().split(';') - let codecs = [] - if (codecStr && codecStr.includes('codecs="')) { - codecs = codecStr - .trim() - .replace(`codecs="`, '') - .replace(`"`, '') - .trim() - .split(',') - .filter(x => !!x) - .map(x => x.trim()) - } - return { mime, codecStr, codecs } - } - /* global HTMLMediaElement */ - const canPlayType = { - // Make toString() native - get(target, key) { - // Mitigate Chromium bug (#130) - if (typeof target[key] === 'function') { - return target[key].bind(target) - } - return Reflect.get(target, key) - }, - // Intercept certain requests - apply: function(target, ctx, args) { - if (!args || !args.length) { - return target.apply(ctx, args) - } - const { mime, codecs } = parseInput(args[0]) - // This specific mp4 codec is missing in Chromium - if (mime === 'video/mp4') { - if (codecs.includes('avc1.42E01E')) { - return 'probably' - } - } - // This mimetype is only supported if no codecs are specified - if (mime === 'audio/x-m4a' && !codecs.length) { - return 'maybe' - } - // This mimetype is only supported if no codecs are specified - if (mime === 'audio/aac' && !codecs.length) { - return 'probably' - } - // Everything else as usual - return target.apply(ctx, args) - } - } - HTMLMediaElement.prototype.canPlayType = new Proxy( - HTMLMediaElement.prototype.canPlayType, - canPlayType - ) - } catch (err) {} -} -""" - ) - - -def navigator_languages(page) -> None: - page.evaluateOnNewDocument( - """ -() => { - Object.defineProperty(navigator, 'languages', { - get: () => ['en-US', 'en'] - }) -} - """ - ) - - -def navigator_permissions(page) -> None: - page.evaluateOnNewDocument( - """ -() => { - const originalQuery = window.navigator.permissions.query - window.navigator.permissions.__proto__.query = parameters => - parameters.name === 'notifications' - ? Promise.resolve({ state: Notification.permission }) - : originalQuery(parameters) - const oldCall = Function.prototype.call - function call () { - return oldCall.apply(this, arguments) - } - Function.prototype.call = call - const nativeToStringFunctionString = Error.toString().replace( - /Error/g, - 'toString' - ) - const oldToString = Function.prototype.toString - function functionToString () { - if (this === window.navigator.permissions.query) { - return 'function query() { [native code] }' - } - if (this === functionToString) { - return nativeToStringFunctionString - } - return oldCall.call(oldToString, this) - } - Function.prototype.toString = functionToString -} - """ - ) - - -def navigator_plugins(page) -> None: - page.evaluateOnNewDocument( - """ -() => { - function mockPluginsAndMimeTypes() { - const makeFnsNative = (fns = []) => { - const oldCall = Function.prototype.call - function call() { - return oldCall.apply(this, arguments) - } - Function.prototype.call = call - const nativeToStringFunctionString = Error.toString().replace( - /Error/g, - 'toString' - ) - const oldToString = Function.prototype.toString - function functionToString() { - for (const fn of fns) { - if (this === fn.ref) { - return `function ${fn.name}() { [native code] }` - } - } - if (this === functionToString) { - return nativeToStringFunctionString - } - return oldCall.call(oldToString, this) - } - Function.prototype.toString = functionToString - } - const mockedFns = [] - const fakeData = { - mimeTypes: [ - { - type: 'application/pdf', - suffixes: 'pdf', - description: '', - __pluginName: 'Chrome PDF Viewer' - }, - { - type: 'application/x-google-chrome-pdf', - suffixes: 'pdf', - description: 'Portable Document Format', - __pluginName: 'Chrome PDF Plugin' - }, - { - type: 'application/x-nacl', - suffixes: '', - description: 'Native Client Executable', - enabledPlugin: Plugin, - __pluginName: 'Native Client' - }, - { - type: 'application/x-pnacl', - suffixes: '', - description: 'Portable Native Client Executable', - __pluginName: 'Native Client' - } - ], - plugins: [ - { - name: 'Chrome PDF Plugin', - filename: 'internal-pdf-viewer', - description: 'Portable Document Format' - }, - { - name: 'Chrome PDF Viewer', - filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', - description: '' - }, - { - name: 'Native Client', - filename: 'internal-nacl-plugin', - description: '' - } - ], - fns: { - namedItem: instanceName => { - const fn = function (name) { - if (!arguments.length) { - throw new TypeError( - `Failed to execute 'namedItem' on '${instanceName}': 1 argument required, but only 0 present.` - ) - } - return this[name] || null - } - mockedFns.push({ ref: fn, name: 'namedItem' }) - return fn - }, - item: instanceName => { - const fn = function (index) { - if (!arguments.length) { - throw new TypeError( - `Failed to execute 'namedItem' on '${instanceName}': 1 argument required, but only 0 present.` - ) - } - return this[index] || null - } - mockedFns.push({ ref: fn, name: 'item' }) - return fn - }, - refresh: instanceName => { - const fn = function () { - return undefined - } - mockedFns.push({ ref: fn, name: 'refresh' }) - return fn - } - } - } - const getSubset = (keys, obj) => - keys.reduce((a, c) => ({ ...a, [c]: obj[c] }), {}) - function generateMimeTypeArray() { - const arr = fakeData.mimeTypes - .map(obj => getSubset(['type', 'suffixes', 'description'], obj)) - .map(obj => Object.setPrototypeOf(obj, MimeType.prototype)) - arr.forEach(obj => { - arr[obj.type] = obj - }) - arr.namedItem = fakeData.fns.namedItem('MimeTypeArray') - arr.item = fakeData.fns.item('MimeTypeArray') - return Object.setPrototypeOf(arr, MimeTypeArray.prototype) - } - const mimeTypeArray = generateMimeTypeArray() - Object.defineProperty(navigator, 'mimeTypes', { - get: () => mimeTypeArray - }) - function generatePluginArray() { - const arr = fakeData.plugins - .map(obj => getSubset(['name', 'filename', 'description'], obj)) - .map(obj => { - const mimes = fakeData.mimeTypes.filter( - m => m.__pluginName === obj.name - ) - mimes.forEach((mime, index) => { - navigator.mimeTypes[mime.type].enabledPlugin = obj - obj[mime.type] = navigator.mimeTypes[mime.type] - obj[index] = navigator.mimeTypes[mime.type] - }) - obj.length = mimes.length - return obj - }) - .map(obj => { - obj.namedItem = fakeData.fns.namedItem('Plugin') - obj.item = fakeData.fns.item('Plugin') - return obj - }) - .map(obj => Object.setPrototypeOf(obj, Plugin.prototype)) - arr.forEach(obj => { - arr[obj.name] = obj - }) - arr.namedItem = fakeData.fns.namedItem('PluginArray') - arr.item = fakeData.fns.item('PluginArray') - arr.refresh = fakeData.fns.refresh('PluginArray') - return Object.setPrototypeOf(arr, PluginArray.prototype) - } - const pluginArray = generatePluginArray() - Object.defineProperty(navigator, 'plugins', { - get: () => pluginArray - }) - makeFnsNative(mockedFns) - } - try { - const isPluginArray = navigator.plugins instanceof PluginArray - const hasPlugins = isPluginArray && navigator.plugins.length > 0 - if (isPluginArray && hasPlugins) { - return - } - mockPluginsAndMimeTypes() - } catch (err) { } -} -""" - ) - - -def navigator_webdriver(page) -> None: - page.evaluateOnNewDocument( - """ -() => { - Object.defineProperty(window, 'navigator', { - value: new Proxy(navigator, { - has: (target, key) => (key === 'webdriver' ? false : key in target), - get: (target, key) => - key === 'webdriver' - ? undefined - : typeof target[key] === 'function' - ? target[key].bind(target) - : target[key] - }) - }) -} - """ - ) - - -def user_agent(page) -> None: - return - ua = page.browser.userAgent() - ua = ua.replace("HeadlessChrome", "Chrome") # hide headless nature - ua = re.sub( - r"\(([^)]+)\)", "(Windows NT 10.0; Win64; x64)", ua, 1 - ) # ensure windows - - page.setUserAgent(ua) - - -def webgl_vendor(page) -> None: - page.evaluateOnNewDocument( - """ -() => { - try { - const getParameter = WebGLRenderingContext.prototype.getParameter - WebGLRenderingContext.prototype.getParameter = function (parameter) { - if (parameter === 37445) { - return 'Intel Inc.' - } - if (parameter === 37446) { - return 'Intel Iris OpenGL Engine' - } - return getParameter.apply(this, [parameter]) - } - } catch (err) {} -} -""" - ) - - -def window_outerdimensions(page) -> None: - page.evaluateOnNewDocument( - """ -() => { - try { - if (window.outerWidth && window.outerHeight) { - return - } - const windowFrame = 85 - window.outerWidth = window.innerWidth - window.outerHeight = window.innerHeight + windowFrame - } catch (err) { } -} -""" - ) - - -def stealth(page) -> None: - # chrome_runtime(page) - console_debug(page) - iframe_content_window(page) - # navigator_languages(page) - navigator_permissions(page) - navigator_plugins(page) - navigator_webdriver(page) - # navigator_vendor(page) - user_agent(page) - webgl_vendor(page) - window_outerdimensions(page) - media_codecs(page) diff --git a/TikTokApi/exceptions.py b/TikTokApi/exceptions.py index 8c7c6460..8f0d79bb 100644 --- a/TikTokApi/exceptions.py +++ b/TikTokApi/exceptions.py @@ -1,7 +1,7 @@ class TikTokCaptchaError(Exception): def __init__( self, - message="TikTok blocks this request displaying a Captcha \nTip: Consider using a proxy or a custom_verifyFp as method parameters", + message="TikTok blocks this request displaying a Captcha \nTip: Consider using a proxy or a custom_verify_fp as method parameters", ): self.message = message super().__init__(self.message) diff --git a/TikTokApi/helpers.py b/TikTokApi/helpers.py new file mode 100644 index 00000000..048c40fe --- /dev/null +++ b/TikTokApi/helpers.py @@ -0,0 +1,41 @@ +from TikTokApi.browser_utilities.browser import browser +from urllib.parse import quote, urlencode +from .exceptions import * + +import re +import requests + + +def extract_tag_contents(html): + next_json = re.search( + r"id=\"__NEXT_DATA__\"\s+type=\"application\/json\"\s*[^>]+>\s*(?P[^<]+)", + html, + ) + if next_json: + nonce_start = '' + nonce = html.split(nonce_start)[1].split(nonce_end)[0] + j_raw = html.split( + '")[0] + return j_raw + else: + sigi_json = re.search( + r'>\s*window\[[\'"]SIGI_STATE[\'"]\]\s*=\s*(?P{.+});', html + ) + if sigi_json: + return sigi_json.group(1) + else: + raise TikTokCaptchaError() + + +def extract_video_id_from_url(url): + url = requests.head(url=url, allow_redirects=True).url + if "@" in url and "/video/" in url: + return url.split("/video/")[1].split("?")[0] + else: + raise TypeError( + "URL format not supported. Below is an example of a supported url.\n" + "https://www.tiktok.com/@therock/video/6829267836783971589" + ) diff --git a/TikTokApi/tiktok.py b/TikTokApi/tiktok.py index 100ce1ce..3b3460f2 100644 --- a/TikTokApi/tiktok.py +++ b/TikTokApi/tiktok.py @@ -4,91 +4,56 @@ import random import string import time +from typing import ClassVar, Optional +from urllib import request from urllib.parse import quote, urlencode import requests +from .api.sound import Sound +from .api.user import User +from .api.search import Search +from .api.hashtag import Hashtag +from .api.video import Video +from .api.trending import Trending + from playwright.sync_api import sync_playwright from .exceptions import * -from .utilities import update_messager +from .utilities import LOGGER_NAME, update_messager +from .browser_utilities.browser import browser +from dataclasses import dataclass os.environ["no_proxy"] = "127.0.0.1,localhost" BASE_URL = "https://m.tiktok.com/" +DESKTOP_BASE_URL = "https://www.tiktok.com/" class TikTokApi: - __instance = None - - def __init__(self, **kwargs): - """The TikTokApi class. Used to interact with TikTok, use get_instance NOT this.""" - # Forces Singleton - if TikTokApi.__instance is None: - TikTokApi.__instance = self - else: - raise Exception("Only one TikTokApi object is allowed") - logging.basicConfig(level=kwargs.get("logging_level", logging.WARNING)) - logging.info("Class initalized") - - # Some Instance Vars - self.executablePath = kwargs.get("executablePath", None) + _instance = None + logger: ClassVar[logging.Logger] = logging.getLogger(LOGGER_NAME) - if kwargs.get("custom_did") != None: - raise Exception("Please use custom_device_id instead of custom_device_id") - self.custom_device_id = kwargs.get("custom_device_id", None) - self.userAgent = ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/86.0.4240.111 Safari/537.36" - ) - self.proxy = kwargs.get("proxy", None) - self.custom_verifyFp = kwargs.get("custom_verifyFp") - self.signer_url = kwargs.get("external_signer", None) - self.request_delay = kwargs.get("request_delay", None) - self.requests_extra_kwargs = kwargs.get("requests_extra_kwargs", {}) - - if kwargs.get("use_test_endpoints", False): - global BASE_URL - BASE_URL = "https://t.tiktok.com/" - if kwargs.get("use_selenium", False): - from .browser_utilities.browser_selenium import browser - else: - from .browser_utilities.browser import browser - - if kwargs.get("generate_static_device_id", False): - self.custom_device_id = "".join( - random.choice(string.digits) for num in range(19) - ) - - if self.signer_url is None: - self.browser = browser(**kwargs) - self.userAgent = self.browser.userAgent - - try: - self.timezone_name = self.__format_new_params__(self.browser.timezone_name) - self.browser_language = self.__format_new_params__( - self.browser.browser_language - ) - self.width = self.browser.width - self.height = self.browser.height - self.region = self.browser.region - self.language = self.browser.language - except Exception as e: - logging.exception(e) - logging.warning( - "An error ocurred while opening your browser but it was ignored." - ) - logging.warning("Are you sure you ran python -m playwright install") - - self.timezone_name = "" - self.browser_language = "" - self.width = "1920" - self.height = "1080" - self.region = "US" - self.language = "en" + user = User + search = Search + sound = Sound + hashtag = Hashtag + video = Video + trending = Trending @staticmethod - def get_instance(**kwargs): + def __new__( + cls, + logging_level=logging.WARNING, + request_delay: Optional[int] = None, + custom_device_id: Optional[str] = None, + generate_static_device_id: Optional[bool] = False, + custom_verify_fp: Optional[str] = None, + use_test_endpoints: Optional[bool] = False, + proxy: Optional[str] = None, + executable_path: Optional[str] = None, + *args, + **kwargs, + ): """The TikTokApi class. Used to interact with TikTok. This is a singleton class to prevent issues from arising with playwright @@ -111,7 +76,7 @@ class to prevent issues from arising with playwright Use this if you want to download videos from a script but don't want to generate your own custom_device_id parameter. - * custom_verifyFp: A TikTok parameter needed to work most of the time, optional + * custom_verify_fp: A TikTok parameter needed to work most of the time, optional To get this parameter look at [this video](https://youtu.be/zwLmLfVI-VQ?t=117) I recommend watching the entire thing, as it will help setup this package. All the methods take this as a optional parameter, however it's cleaner code @@ -124,7 +89,7 @@ class to prevent issues from arising with playwright * use_test_endpoints: Send requests to TikTok's test endpoints, optional This parameter when set to true will make requests to TikTok's testing endpoints instead of the live site. I can't guarantee this will work - in the future, however currently basically any custom_verifyFp will + in the future, however currently basically any custom_verify_fp will work here which is helpful. * proxy: A string containing your proxy address, optional @@ -137,12 +102,7 @@ class to prevent issues from arising with playwright to store this at the instance level. You can override this at the specific methods. - * use_selenium: Option to use selenium over playwright, optional - Playwright is selected by default and is the one that I'm designing the - package to be compatable for, however if playwright doesn't work on - your machine feel free to set this to True. - - * executablePath: The location of the driver, optional + * executable_path: The location of the driver, optional This shouldn't be needed if you're using playwright * **kwargs @@ -150,125 +110,145 @@ class to prevent issues from arising with playwright that interact with this main class. These may or may not be documented in other places. """ - if not TikTokApi.__instance: - TikTokApi(**kwargs) - return TikTokApi.__instance - def clean_up(self): - """A basic cleanup method, called automatically from the code""" - self.__del__() + if cls._instance is None: + cls._instance = super(TikTokApi, cls).__new__(cls) + cls._instance._initialize( + logging_level=logging_level, + request_delay=request_delay, + custom_device_id=custom_device_id, + generate_static_device_id=generate_static_device_id, + custom_verify_fp=custom_verify_fp, + use_test_endpoints=use_test_endpoints, + proxy=proxy, + executable_path=executable_path, + *args, + **kwargs, + ) + return cls._instance - def __del__(self): - """A basic cleanup method, called automatically from the code""" - try: - self.browser.clean_up() - except Exception: - pass - try: - get_playwright().stop() - except Exception: - pass - TikTokApi.__instance = None + def _initialize(self, logging_level=logging.WARNING, **kwargs): + # Add classes from the api folder + User.parent = self + Search.parent = self + Sound.parent = self + Hashtag.parent = self + Video.parent = self + Trending.parent = self - def external_signer(self, url, custom_device_id=None, verifyFp=None): - """Makes requests to an external signer instead of using a browser. + self.logger.setLevel(level=logging_level) - ##### Parameters - * url: The server to make requests to - This server is designed to sign requests. You can find an example - of this signature server in the examples folder. + # Some Instance Vars + self._executable_path = kwargs.get("executable_path", None) - * custom_device_id: A TikTok parameter needed to download videos - The code generates these and handles these pretty well itself, however - for some things such as video download you will need to set a consistent - one of these. + if kwargs.get("custom_did") != None: + raise Exception("Please use 'custom_device_id' instead of 'custom_did'") + self._custom_device_id = kwargs.get("custom_device_id", None) + self._user_agent = "5.0+(iPhone%3B+CPU+iPhone+OS+14_8+like+Mac+OS+X)+AppleWebKit%2F605.1.15+(KHTML,+like+Gecko)+Version%2F14.1.2+Mobile%2F15E148+Safari%2F604.1" + self._proxy = kwargs.get("proxy", None) + self._custom_verify_fp = kwargs.get("custom_verify_fp") + self._signer_url = kwargs.get("external_signer", None) + self._request_delay = kwargs.get("request_delay", None) + self._requests_extra_kwargs = kwargs.get("requests_extra_kwargs", {}) - * custom_verifyFp: A TikTok parameter needed to work most of the time, - To get this parameter look at [this video](https://youtu.be/zwLmLfVI-VQ?t=117) - I recommend watching the entire thing, as it will help setup this package. - """ - if custom_device_id is not None: - query = { - "url": url, - "custom_device_id": custom_device_id, - "verifyFp": verifyFp, - } - else: - query = {"url": url, "verifyFp": verifyFp} - data = requests.get( - self.signer_url + "?{}".format(urlencode(query)), - **self.requests_extra_kwargs, - ) - parsed_data = data.json() + if kwargs.get("use_test_endpoints", False): + global BASE_URL + BASE_URL = "https://t.tiktok.com/" - return ( - parsed_data["verifyFp"], - parsed_data["device_id"], - parsed_data["_signature"], - parsed_data["userAgent"], - parsed_data["referrer"], - ) + if kwargs.get("generate_static_device_id", False): + self._custom_device_id = "".join( + random.choice(string.digits) for num in range(19) + ) + + if self._signer_url is None: + self._browser = browser(**kwargs) + self._user_agent = self._browser.user_agent + + try: + self._timezone_name = self._browser.timezone_name + self._browser_language = self._browser.browser_language + self._width = self._browser.width + self._height = self._browser.height + self._region = self._browser.region + self._language = self._browser.language + except Exception as e: + self.logger.exception( + "An error occurred while opening your browser, but it was ignored\n", + "Are you sure you ran python -m playwright install?", + ) + + self._timezone_name = "" + self._browser_language = "" + self._width = "1920" + self._height = "1080" + self._region = "US" + self._language = "en" - def get_data(self, **kwargs) -> dict: + def get_data(self, path, subdomain="m", **kwargs) -> dict: """Makes requests to TikTok and returns their JSON. This is all handled by the package so it's unlikely you will need to use this. """ - ( - region, - language, - proxy, - maxCount, - device_id, - ) = self.__process_kwargs__(kwargs) - kwargs["custom_device_id"] = device_id - if self.request_delay is not None: - time.sleep(self.request_delay) - - if self.proxy is not None: - proxy = self.proxy - - if kwargs.get("custom_verifyFp") == None: - if self.custom_verifyFp != None: - verifyFp = self.custom_verifyFp + processed = self._process_kwargs(kwargs) + kwargs["custom_device_id"] = processed.device_id + if self._request_delay is not None: + time.sleep(self._request_delay) + + if self._proxy is not None: + proxy = self._proxy + + if kwargs.get("custom_verify_fp") == None: + if self._custom_verify_fp != None: + verifyFp = self._custom_verify_fp else: verifyFp = "verify_khr3jabg_V7ucdslq_Vrw9_4KPb_AJ1b_Ks706M8zIJTq" else: - verifyFp = kwargs.get("custom_verifyFp") + verifyFp = kwargs.get("custom_verify_fp") tt_params = None send_tt_params = kwargs.get("send_tt_params", False) - if self.signer_url is None: - kwargs["custom_verifyFp"] = verifyFp - verify_fp, device_id, signature, tt_params = self.browser.sign_url( - calc_tt_params=send_tt_params, **kwargs + full_url = f"https://{subdomain}.tiktok.com/" + path + + if self._signer_url is None: + kwargs["custom_verify_fp"] = verifyFp + verify_fp, device_id, signature, tt_params = self._browser.sign_url( + full_url, calc_tt_params=send_tt_params, **kwargs ) - userAgent = self.browser.userAgent - referrer = self.browser.referrer + user_agent = self._browser.user_agent + referrer = self._browser.referrer else: - verify_fp, device_id, signature, userAgent, referrer = self.external_signer( - kwargs["url"], + ( + verify_fp, + device_id, + signature, + user_agent, + referrer, + ) = self.external_signer( + full_url, custom_device_id=kwargs.get("custom_device_id"), - verifyFp=kwargs.get("custom_verifyFp", verifyFp), + verifyFp=kwargs.get("custom_verify_fp", verifyFp), ) if not kwargs.get("send_tt_params", False): tt_params = None query = {"verifyFp": verify_fp, "device_id": device_id, "_signature": signature} - url = "{}&{}".format(kwargs["url"], urlencode(query)) + url = "{}&{}".format(full_url, urlencode(query)) h = requests.head( url, headers={"x-secsdk-csrf-version": "1.2.5", "x-secsdk-csrf-request": "1"}, - proxies=self.__format_proxy(proxy), - **self.requests_extra_kwargs, + proxies=self._format_proxy(processed.proxy), + **self._requests_extra_kwargs, ) - csrf_session_id = h.cookies["csrf_session_id"] - csrf_token = h.headers["X-Ware-Csrf-Token"].split(",")[1] - kwargs["csrf_session_id"] = csrf_session_id + + csrf_token = None + if subdomain == "m": + csrf_session_id = h.cookies["csrf_session_id"] + csrf_token = h.headers["X-Ware-Csrf-Token"].split(",")[1] + kwargs["csrf_session_id"] = csrf_session_id headers = { "authority": "m.tiktok.com", @@ -276,37 +256,40 @@ def get_data(self, **kwargs) -> dict: "path": url.split("tiktok.com")[1], "scheme": "https", "accept": "application/json, text/plain, */*", - "accept-encoding": "gzip, deflate, br", + "accept-encoding": "gzip", "accept-language": "en-US,en;q=0.9", "origin": referrer, "referer": referrer, "sec-fetch-dest": "empty", "sec-fetch-mode": "cors", - "sec-fetch-site": "same-site", + "sec-fetch-site": "none", "sec-gpc": "1", - "user-agent": userAgent, + "user-agent": user_agent, "x-secsdk-csrf-token": csrf_token, "x-tt-params": tt_params, } - logging.info(f"GET: {url}\n\theaders: {headers}") + self.logger.info(f"GET: %s\n\theaders: %s", url, headers) r = requests.get( url, headers=headers, - cookies=self.get_cookies(**kwargs), - proxies=self.__format_proxy(proxy), - **self.requests_extra_kwargs, + cookies=self._get_cookies(**kwargs), + proxies=self._format_proxy(processed.proxy), + **self._requests_extra_kwargs, ) + try: - json = r.json() + parsed_data = r.json() if ( - json.get("type") == "verify" - or json.get("verifyConfig", {}).get("type", "") == "verify" + parsed_data.get("type") == "verify" + or parsed_data.get("verifyConfig", {}).get("type", "") == "verify" ): - logging.error( - "Tiktok wants to display a catcha. Response is:\n" + r.text + self.logger.error( + "Tiktok wants to display a captcha.\nResponse:\n%s\nCookies:\n%s\nURL:\n%s", + r.text, + self._get_cookies(**kwargs), + url, ) - logging.error(self.get_cookies(**kwargs)) raise TikTokCaptchaError() # statusCode from props->pageProps->statusCode thanks @adiantek on #403 @@ -350,8 +333,8 @@ def get_data(self, **kwargs) -> dict: "10404": "FYP_VIDEO_LIST_LIMIT", "undefined": "MEDIA_ERROR", } - statusCode = json.get("statusCode", 0) - logging.info(f"TikTok Returned: {json}") + statusCode = parsed_data.get("statusCode", 0) + self.logger.info(f"TikTok Returned: %s", json) if statusCode == 10201: # Invalid Entity raise TikTokNotFoundError( @@ -370,29 +353,79 @@ def get_data(self, **kwargs) -> dict: return r.json() except ValueError as e: text = r.text - logging.error("TikTok response: " + text) + self.logger.info("TikTok response: %s", text) if len(text) == 0: raise EmptyResponseError( "Empty response from Tiktok to " + url ) from None else: - logging.error("Converting response to JSON failed") - logging.error(e) + self.logger.exception("Converting response to JSON failed") raise JSONDecodeFailure() from e - def get_cookies(self, **kwargs): + def __del__(self): + """A basic cleanup method, called automatically from the code""" + try: + self._browser._clean_up() + except Exception: + pass + try: + get_playwright().stop() + except Exception: + pass + TikTokApi._instance = None + + def external_signer(self, url, custom_device_id=None, verifyFp=None): + """Makes requests to an external signer instead of using a browser. + + ##### Parameters + * url: The server to make requests to + This server is designed to sign requests. You can find an example + of this signature server in the examples folder. + + * custom_device_id: A TikTok parameter needed to download videos + The code generates these and handles these pretty well itself, however + for some things such as video download you will need to set a consistent + one of these. + + * custom_verify_fp: A TikTok parameter needed to work most of the time, + To get this parameter look at [this video](https://youtu.be/zwLmLfVI-VQ?t=117) + I recommend watching the entire thing, as it will help setup this package. + """ + if custom_device_id is not None: + query = { + "url": url, + "custom_device_id": custom_device_id, + "verifyFp": verifyFp, + } + else: + query = {"url": url, "verifyFp": verifyFp} + data = requests.get( + self._signer_url + "?{}".format(urlencode(query)), + **self._requests_extra_kwargs, + ) + parsed_data = data.json() + + return ( + parsed_data["verifyFp"], + parsed_data["device_id"], + parsed_data["_signature"], + parsed_data["user_agent"], + parsed_data["referrer"], + ) + + def _get_cookies(self, **kwargs): """Extracts cookies from the kwargs passed to the function for get_data""" device_id = kwargs.get( "custom_device_id", "".join(random.choice(string.digits) for num in range(19)), ) - if kwargs.get("custom_verifyFp") == None: - if self.custom_verifyFp != None: - verifyFp = self.custom_verifyFp + if kwargs.get("custom_verify_fp") is None: + if self._custom_verify_fp is not None: + verifyFp = self._custom_verify_fp else: - verifyFp = "verify_khr3jabg_V7ucdslq_Vrw9_4KPb_AJ1b_Ks706M8zIJTq" + verifyFp = None else: - verifyFp = kwargs.get("custom_verifyFp") + verifyFp = kwargs.get("custom_verify_fp") if kwargs.get("force_verify_fp_on_cookie_header", False): return { @@ -420,22 +453,22 @@ def get_cookies(self, **kwargs): def get_bytes(self, **kwargs) -> bytes: """Returns TikTok's response as bytes, similar to get_data""" - ( - region, - language, - proxy, - maxCount, - device_id, - ) = self.__process_kwargs__(kwargs) - kwargs["custom_device_id"] = device_id - if self.signer_url is None: - verify_fp, device_id, signature, _ = self.browser.sign_url( + processed = self._process_kwargs(kwargs) + kwargs["custom_device_id"] = processed.device_id + if self._signer_url is None: + verify_fp, device_id, signature, _ = self._browser.sign_url( calc_tt_params=False, **kwargs ) - userAgent = self.browser.userAgent - referrer = self.browser.referrer + user_agent = self._browser.user_agent + referrer = self._browser.referrer else: - verify_fp, device_id, signature, userAgent, referrer = self.external_signer( + ( + verify_fp, + device_id, + signature, + user_agent, + referrer, + ) = self.external_signer( kwargs["url"], custom_device_id=kwargs.get("custom_device_id", None) ) query = {"verifyFp": verify_fp, "_signature": signature} @@ -452,1043 +485,13 @@ def get_bytes(self, **kwargs) -> bytes: "Pragma": "no-cache", "Range": "bytes=0-", "Referer": "https://www.tiktok.com/", - "User-Agent": userAgent, + "User-Agent": user_agent, }, - proxies=self.__format_proxy(proxy), - cookies=self.get_cookies(**kwargs), + proxies=self._format_proxy(processed.proxy), + cookies=self._get_cookies(**kwargs), ) return r.content - def by_trending(self, count=30, **kwargs) -> dict: - """ - Gets trending TikToks - - ##### Parameters - * count: The amount of TikToks you want returned, optional - - Note: TikTok seems to only support at MOST ~2000 TikToks - from a single endpoint. - """ - ( - region, - language, - proxy, - maxCount, - device_id, - ) = self.__process_kwargs__(kwargs) - kwargs["custom_device_id"] = device_id - - spawn = requests.head( - "https://www.tiktok.com", - proxies=self.__format_proxy(proxy), - **self.requests_extra_kwargs, - ) - ttwid = spawn.cookies["ttwid"] - - response = [] - first = True - - while len(response) < count: - if count < maxCount: - realCount = count - else: - realCount = maxCount - - query = { - "count": realCount, - "id": 1, - "sourceType": 12, - "itemID": 1, - "insertedItemID": "", - "region": region, - "priority_region": region, - "language": language, - } - api_url = "{}api/recommend/item_list/?{}&{}".format( - BASE_URL, self.__add_url_params__(), urlencode(query) - ) - res = self.get_data(url=api_url, ttwid=ttwid, **kwargs) - for t in res.get("itemList", []): - response.append(t) - - if not res.get("hasMore", False) and not first: - logging.info("TikTok isn't sending more TikToks beyond this point.") - return response[:count] - - realCount = count - len(response) - - first = False - - return response[:count] - - def search_for_users(self, search_term, count=28, **kwargs) -> list: - """Returns a list of users that match the search_term - - ##### Parameters - * search_term: The string to search for users by - This string is the term you want to search for users by. - - * count: The number of users to return - Note: maximum is around 28 for this type of endpoint. - """ - return self.discover_type(search_term, prefix="user", count=count, **kwargs) - - def search_for_music(self, search_term, count=28, **kwargs) -> list: - """Returns a list of music that match the search_term - - ##### Parameters - * search_term: The string to search for music by - This string is the term you want to search for music by. - - * count: The number of music to return - Note: maximum is around 28 for this type of endpoint. - """ - return self.discover_type(search_term, prefix="music", count=count, **kwargs) - - def search_for_hashtags(self, search_term, count=28, **kwargs) -> list: - """Returns a list of hashtags that match the search_term - - ##### Parameters - * search_term: The string to search for music by - This string is the term you want to search for music by. - - * count: The number of music to return - Note: maximum is around 28 for this type of endpoint. - """ - return self.discover_type( - search_term, prefix="challenge", count=count, **kwargs - ) - - def discover_type(self, search_term, prefix, count=28, offset=0, **kwargs) -> list: - """Returns a list of whatever the prefix type you pass in - - ##### Parameters - * search_term: The string to search by - - * prefix: The prefix of what to search for - - * count: The number search results to return - Note: maximum is around 28 for this type of endpoint. - """ - ( - region, - language, - proxy, - maxCount, - device_id, - ) = self.__process_kwargs__(kwargs) - kwargs["custom_device_id"] = device_id - - response = [] - while len(response) < count: - query = { - "discoverType": 0, - "needItemList": False, - "keyWord": search_term, - "offset": offset, - "count": count, - "useRecommend": False, - "language": "en", - } - api_url = "{}api/discover/{}/?{}&{}".format( - BASE_URL, prefix, self.__add_url_params__(), urlencode(query) - ) - data = self.get_data(url=api_url, **kwargs) - - if "userInfoList" in data.keys(): - for x in data["userInfoList"]: - response.append(x) - elif "musicInfoList" in data.keys(): - for x in data["musicInfoList"]: - response.append(x) - elif "challengeInfoList" in data.keys(): - for x in data["challengeInfoList"]: - response.append(x) - else: - logging.info("TikTok is not sending videos beyond this point.") - break - - offset += maxCount - - return response[:count] - - def user_posts(self, userID, secUID, count=30, cursor=0, **kwargs) -> dict: - """Returns an array of dictionaries representing TikToks for a user. - - ##### Parameters - * userID: The userID of the user, which TikTok assigns - - You can find this from utilizing other methods or - just use by_username to find it. - * secUID: The secUID of the user, which TikTok assigns - - You can find this from utilizing other methods or - just use by_username to find it. - * count: The number of posts to return - - Note: seems to only support up to ~2,000 - """ - ( - region, - language, - proxy, - maxCount, - device_id, - ) = self.__process_kwargs__(kwargs) - kwargs["custom_device_id"] = device_id - - response = [] - first = True - - while len(response) < count: - if count < maxCount: - realCount = count - else: - realCount = maxCount - - query = { - "count": realCount, - "id": userID, - "cursor": cursor, - "type": 1, - "secUid": secUID, - "sourceType": 8, - "appId": 1233, - "region": region, - "priority_region": region, - "language": language, - } - api_url = "{}api/post/item_list/?{}&{}".format( - BASE_URL, self.__add_url_params__(), urlencode(query) - ) - - res = self.get_data(url=api_url, send_tt_params=True, **kwargs) - - if "itemList" in res.keys(): - for t in res.get("itemList", []): - response.append(t) - - if not res.get("hasMore", False) and not first: - logging.info("TikTok isn't sending more TikToks beyond this point.") - return response - - realCount = count - len(response) - cursor = res["cursor"] - - first = False - - return response[:count] - - def by_username(self, username, count=30, **kwargs) -> dict: - """Returns a dictionary listing TikToks given a user's username. - - ##### Parameters - * username: The username of the TikTok user to get TikToks from - - * count: The number of posts to return - - Note: seems to only support up to ~2,000 - """ - ( - region, - language, - proxy, - maxCount, - device_id, - ) = self.__process_kwargs__(kwargs) - kwargs["custom_device_id"] = device_id - data = self.get_user_object(username, **kwargs) - return self.user_posts( - data["id"], - data["secUid"], - count=count, - **kwargs, - ) - - def user_page(self, userID, secUID, page_size=30, cursor=0, **kwargs) -> dict: - """Returns a dictionary listing of one page of TikToks given a user's ID and secUID - - ##### Parameters - * userID: The userID of the user, which TikTok assigns - - You can find this from utilizing other methods or - just use by_username to find it. - * secUID: The secUID of the user, which TikTok assigns - - You can find this from utilizing other methods or - just use by_username to find it. - * page_size: The number of posts to return per page - - Gets a specific page of a user, doesn't iterate. - * cursor: The offset of a page - - The offset to return new videos from - """ - ( - region, - language, - proxy, - maxCount, - device_id, - ) = self.__process_kwargs__(kwargs) - kwargs["custom_device_id"] = device_id - - api_url = ( - BASE_URL + "api/post/item_list/?{}&count={}&id={}&type=1&secUid={}" - "&cursor={}&sourceType=8&appId=1233®ion={}&language={}".format( - self.__add_url_params__(), - page_size, - str(userID), - str(secUID), - cursor, - region, - language, - ) - ) - - return self.get_data(url=api_url, send_tt_params=True, **kwargs) - - def get_user_pager(self, username, page_size=30, cursor=0, **kwargs): - """Returns a generator to page through a user's feed - - ##### Parameters - * username: The username of the user - - * page_size: The number of posts to return in a page - - * cursor: The offset of a page - - The offset to return new videos from - """ - ( - region, - language, - proxy, - maxCount, - device_id, - ) = self.__process_kwargs__(kwargs) - kwargs["custom_device_id"] = device_id - data = self.get_user_object(username, **kwargs) - - while True: - resp = self.user_page( - data["id"], - data["secUid"], - page_size=page_size, - cursor=cursor, - **kwargs, - ) - - try: - page = resp["itemList"] - except KeyError: - # No mo results - return - - cursor = resp["cursor"] - - yield page - - if not resp["hasMore"]: - return # all done - - def user_liked(self, userID, secUID, count=30, cursor=0, **kwargs) -> dict: - """Returns a dictionary listing TikToks that a given a user has liked. - Note: The user's likes must be public - - ##### Parameters - * userID: The userID of the user, which TikTok assigns - - * secUID: The secUID of the user, which TikTok assigns - - * count: The number of posts to return - - Note: seems to only support up to ~2,000 - * cursor: The offset of a page - - The offset to return new videos from - """ - ( - region, - language, - proxy, - maxCount, - device_id, - ) = self.__process_kwargs__(kwargs) - kwargs["custom_device_id"] = device_id - response = [] - first = True - - while len(response) < count: - if count < maxCount: - realCount = count - else: - realCount = maxCount - - query = { - "count": realCount, - "id": userID, - "type": 2, - "secUid": secUID, - "cursor": cursor, - "sourceType": 9, - "appId": 1233, - "region": region, - "priority_region": region, - "language": language, - } - api_url = "{}api/favorite/item_list/?{}&{}".format( - BASE_URL, self.__add_url_params__(), urlencode(query) - ) - - res = self.get_data(url=api_url, **kwargs) - - try: - res["itemList"] - except Exception: - logging.error("User's likes are most likely private") - return [] - - for t in res.get("itemList", []): - response.append(t) - - if not res.get("hasMore", False) and not first: - logging.info("TikTok isn't sending more TikToks beyond this point.") - return response - - realCount = count - len(response) - cursor = res["cursor"] - - first = False - - return response[:count] - - def user_liked_by_username(self, username, count=30, **kwargs) -> dict: - """Returns a dictionary listing TikToks a user has liked by username. - Note: The user's likes must be public - - ##### Parameters - * username: The username of the user - - * count: The number of posts to return - - Note: seems to only support up to ~2,000 - """ - ( - region, - language, - proxy, - maxCount, - device_id, - ) = self.__process_kwargs__(kwargs) - kwargs["custom_device_id"] = device_id - data = self.get_user_object(username, **kwargs) - return self.user_liked( - data["id"], - data["secUid"], - count=count, - **kwargs, - ) - - def by_sound(self, id, count=30, offset=0, **kwargs) -> dict: - """Returns a dictionary listing TikToks with a specific sound. - - ##### Parameters - * id: The sound id to search by - - Note: Can be found in the URL of the sound specific page or with other methods. - * count: The number of posts to return - - Note: seems to only support up to ~2,000 - """ - ( - region, - language, - proxy, - maxCount, - device_id, - ) = self.__process_kwargs__(kwargs) - kwargs["custom_device_id"] = device_id - response = [] - - while len(response) < count: - if count < maxCount: - realCount = count - else: - realCount = maxCount - - query = { - "secUid": "", - "musicID": str(id), - "count": str(realCount), - "cursor": offset, - "shareUid": "", - "language": language, - } - api_url = "{}api/music/item_list/?{}&{}".format( - BASE_URL, self.__add_url_params__(), urlencode(query) - ) - - res = self.get_data(url=api_url, send_tt_params=True, **kwargs) - - try: - for t in res["items"]: - response.append(t) - except KeyError: - for t in res.get("itemList", []): - response.append(t) - - if not res.get("hasMore", False): - logging.info("TikTok isn't sending more TikToks beyond this point.") - return response - - realCount = count - len(response) - offset = res["cursor"] - - return response[:count] - - def by_sound_page(self, id, page_size=30, cursor=0, **kwargs) -> dict: - """Returns a page of tiktoks with a specific sound. - - Parameters - ---------- - id: The sound id to search by - Note: Can be found in the URL of the sound specific page or with other methods. - cursor: offset for pagination - page_size: The number of posts to return - """ - ( - region, - language, - proxy, - maxCount, - device_id, - ) = self.__process_kwargs__(kwargs) - kwargs["custom_device_id"] = device_id - - query = { - "musicID": str(id), - "count": str(page_size), - "cursor": cursor, - "language": language, - } - api_url = "{}api/music/item_list/?{}&{}".format( - BASE_URL, self.__add_url_params__(), urlencode(query) - ) - - return self.get_data(url=api_url, send_tt_params=True, **kwargs) - - def get_music_object(self, id, **kwargs) -> dict: - """Returns a music object for a specific sound id. - - ##### Parameters - * id: The sound id to get the object for - - This can be found by using other methods. - """ - return self.get_music_object_full(id, **kwargs)["music"] - - def get_music_object_full(self, id, **kwargs): - """Returns a music object for a specific sound id. - - ##### Parameters - * id: The sound id to get the object for - - This can be found by using other methods. - """ - ( - region, - language, - proxy, - maxCount, - device_id, - ) = self.__process_kwargs__(kwargs) - r = requests.get( - "https://www.tiktok.com/music/-{}".format(id), - headers={ - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", - "Accept-Encoding": "gzip, deflate", - "Connection": "keep-alive", - "User-Agent": self.userAgent, - }, - proxies=self.__format_proxy(kwargs.get("proxy", None)), - cookies=self.get_cookies(**kwargs), - **self.requests_extra_kwargs, - ) - - j_raw = self.__extract_tag_contents(r.text) - return json.loads(j_raw)["props"]["pageProps"]["musicInfo"] - - def get_music_object_full_by_api(self, id, **kwargs): - """Returns a music object for a specific sound id, but using the API rather than HTML requests. - - ##### Parameters - * id: The sound id to get the object for - - This can be found by using other methods. - """ - ( - region, - language, - proxy, - maxCount, - device_id, - ) = self.__process_kwargs__(kwargs) - kwargs["custom_device_id"] = device_id - - api_url = "{}node/share/music/-{}?{}".format( - BASE_URL, id, self.__add_url_params__() - ) - res = self.get_data(url=api_url, **kwargs) - - if res.get("statusCode", 200) == 10203: - raise TikTokNotFoundError() - - return res["musicInfo"] - - def by_hashtag(self, hashtag, count=30, offset=0, **kwargs) -> dict: - """Returns a dictionary listing TikToks with a specific hashtag. - - ##### Parameters - * hashtag: The hashtag to search by - - Without the # symbol - - A valid string is "funny" - * count: The number of posts to return - Note: seems to only support up to ~2,000 - """ - ( - region, - language, - proxy, - maxCount, - device_id, - ) = self.__process_kwargs__(kwargs) - kwargs["custom_device_id"] = device_id - id = self.get_hashtag_object(hashtag)["challengeInfo"]["challenge"]["id"] - response = [] - - required_count = count - - while len(response) < required_count: - if count > maxCount: - count = maxCount - query = { - "count": count, - "challengeID": id, - "type": 3, - "secUid": "", - "cursor": offset, - "priority_region": "", - } - api_url = "{}api/challenge/item_list/?{}&{}".format( - BASE_URL, self.__add_url_params__(), urlencode(query) - ) - res = self.get_data(url=api_url, **kwargs) - - for t in res.get("itemList", []): - response.append(t) - - if not res.get("hasMore", False): - logging.info("TikTok isn't sending more TikToks beyond this point.") - return response - - offset += maxCount - - return response[:required_count] - - def get_hashtag_object(self, hashtag, **kwargs) -> dict: - """Returns a hashtag object. - - ##### Parameters - * hashtag: The hashtag to search by - - Without the # symbol - """ - ( - region, - language, - proxy, - maxCount, - device_id, - ) = self.__process_kwargs__(kwargs) - kwargs["custom_device_id"] = device_id - query = {"name": hashtag, "isName": True, "lang": language} - api_url = "{}node/share/tag/{}?{}&{}".format( - BASE_URL, quote(hashtag), self.__add_url_params__(), urlencode(query) - ) - data = self.get_data(url=api_url, **kwargs) - if data["challengeInfo"].get("challenge") is None: - raise TikTokNotFoundError("Challenge {} does not exist".format(hashtag)) - return data - - def get_recommended_tiktoks_by_video_id(self, id, count=30, **kwargs) -> dict: - """Returns a dictionary listing reccomended TikToks for a specific TikTok video. - - - ##### Parameters - * id: The id of the video to get suggestions for - - Can be found using other methods - * count: The count of results you want to return - """ - ( - region, - language, - proxy, - maxCount, - device_id, - ) = self.__process_kwargs__(kwargs) - kwargs["custom_device_id"] = device_id - - response = [] - first = True - - while len(response) < count: - if count < maxCount: - realCount = count - else: - realCount = maxCount - - query = { - "count": realCount, - "id": 1, - "secUid": "", - "sourceType": 12, - "appId": 1233, - "region": region, - "priority_region": region, - "language": language, - } - api_url = "{}api/recommend/item_list/?{}&{}".format( - BASE_URL, self.__add_url_params__(), urlencode(query) - ) - - res = self.get_data(url=api_url, **kwargs) - - for t in res.get("itemList", []): - response.append(t) - - if not res.get("hasMore", False) and not first: - logging.info("TikTok isn't sending more TikToks beyond this point.") - return response[:count] - - realCount = count - len(response) - - first = False - - return response[:count] - - def get_tiktok_by_id(self, id, **kwargs) -> dict: - """Returns a dictionary of a specific TikTok. - - ##### Parameters - * id: The id of the TikTok you want to get the object for - """ - ( - region, - language, - proxy, - maxCount, - device_id, - ) = self.__process_kwargs__(kwargs) - kwargs["custom_device_id"] = device_id - device_id = kwargs.get("custom_device_id", None) - query = { - "itemId": id, - "language": language, - } - api_url = "{}api/item/detail/?{}&{}".format( - BASE_URL, self.__add_url_params__(), urlencode(query) - ) - - return self.get_data(url=api_url, **kwargs) - - def get_tiktok_by_url(self, url, **kwargs) -> dict: - """Returns a dictionary of a TikTok object by url. - - - ##### Parameters - * url: The TikTok url you want to retrieve - - """ - - url = requests.head(url=url, allow_redirects=True).url - - ( - region, - language, - proxy, - maxCount, - device_id, - ) = self.__process_kwargs__(kwargs) - kwargs["custom_device_id"] = device_id - custom_device_id = kwargs.get("custom_device_id", None) - if "@" in url and "/video/" in url: - post_id = url.split("/video/")[1].split("?")[0] - else: - raise Exception( - "URL format not supported. Below is an example of a supported url.\n" - "https://www.tiktok.com/@therock/video/6829267836783971589" - ) - - return self.get_tiktok_by_id( - post_id, - **kwargs, - ) - - def get_tiktok_by_html(self, url, **kwargs) -> dict: - """This method retrieves a TikTok using the html - endpoints rather than the API based ones. - - ##### Parameters - * url: The url of the TikTok to get - """ - ( - region, - language, - proxy, - maxCount, - device_id, - ) = self.__process_kwargs__(kwargs) - - r = requests.get( - url, - headers={ - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", - "path": url.split("tiktok.com")[1], - "Accept-Encoding": "gzip, deflate", - "Connection": "keep-alive", - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.111 Safari/537.36", - }, - proxies=self.__format_proxy(kwargs.get("proxy", None)), - cookies=self.get_cookies(**kwargs), - **self.requests_extra_kwargs, - ) - - t = r.text - try: - j_raw = self.__extract_tag_contents(r.text) - except IndexError: - if not t: - logging.error("TikTok response is empty") - else: - logging.error("TikTok response: \n " + t) - raise TikTokCaptchaError() - - data = json.loads(j_raw)["props"]["pageProps"] - - if data["serverCode"] == 404: - raise TikTokNotFoundError("TikTok with that url doesn't exist") - - return data - - def get_user_object(self, username, **kwargs) -> dict: - """Gets a user object (dictionary) - - ##### Parameters - * username: The username of the user - """ - ( - region, - language, - proxy, - maxCount, - device_id, - ) = self.__process_kwargs__(kwargs) - kwargs["custom_device_id"] = device_id - return self.get_user(username, **kwargs)["userInfo"]["user"] - - def get_user(self, username, **kwargs) -> dict: - """Gets the full exposed user object - - ##### Parameters - * username: The username of the user - """ - ( - region, - language, - proxy, - maxCount, - device_id, - ) = self.__process_kwargs__(kwargs) - r = requests.get( - "https://tiktok.com/@{}?lang=en".format(quote(username)), - headers={ - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", - "path": "/@{}".format(quote(username)), - "Accept-Encoding": "gzip, deflate", - "Connection": "keep-alive", - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.111 Safari/537.36", - }, - proxies=self.__format_proxy(kwargs.get("proxy", None)), - cookies=self.get_cookies(**kwargs), - **self.requests_extra_kwargs, - ) - - t = r.text - - try: - j_raw = self.__extract_tag_contents(r.text) - except IndexError: - if not t: - logging.error("Tiktok response is empty") - else: - logging.error("Tiktok response: \n " + t) - raise TikTokCaptchaError() - - user = json.loads(j_raw)["props"]["pageProps"] - - if user["serverCode"] == 404: - raise TikTokNotFoundError( - "TikTok user with username {} does not exist".format(username) - ) - - return user - - def get_video_by_tiktok(self, data, **kwargs) -> bytes: - """Downloads video from TikTok using a TikTok object. - - You will need to set a custom_device_id to do this for anything but trending. - To do this, this is pretty simple you can either generate one yourself or, - you can pass the generate_static_device_id=True into the constructor of the - TikTokApi class. - - ##### Parameters - * data: A TikTok object - - A TikTok JSON object from any other method. - """ - ( - region, - language, - proxy, - maxCount, - device_id, - ) = self.__process_kwargs__(kwargs) - kwargs["custom_device_id"] = device_id - try: - api_url = data["video"]["downloadAddr"] - except Exception: - try: - api_url = data["itemInfos"]["video"]["urls"][0] - except Exception: - api_url = data["itemInfo"]["itemStruct"]["video"]["playAddr"] - return self.get_video_by_download_url(api_url, **kwargs) - - def get_video_by_download_url(self, download_url, **kwargs) -> bytes: - """Downloads video from TikTok using download url in a TikTok object - - ##### Parameters - * download_url: The download url key value in a TikTok object - """ - ( - region, - language, - proxy, - maxCount, - device_id, - ) = self.__process_kwargs__(kwargs) - kwargs["custom_device_id"] = device_id - return self.get_bytes(url=download_url, **kwargs) - - def get_video_by_url(self, video_url, **kwargs) -> bytes: - """Downloads a TikTok video by a URL - - ##### Parameters - * video_url: The TikTok url to download the video from - """ - ( - region, - language, - proxy, - maxCount, - device_id, - ) = self.__process_kwargs__(kwargs) - kwargs["custom_device_id"] = device_id - - tiktok_schema = self.get_tiktok_by_url(video_url, **kwargs) - download_url = tiktok_schema["itemInfo"]["itemStruct"]["video"]["downloadAddr"] - - return self.get_bytes(url=download_url, **kwargs) - - def get_video_no_watermark(self, video_url, return_bytes=1, **kwargs) -> bytes: - """Gets the video with no watermark - .. deprecated:: - - Deprecated due to TikTok fixing this - - ##### Parameters - * video_url: The url of the video you want to download - - * return_bytes: Set this to 0 if you want url, 1 if you want bytes - """ - ( - region, - language, - proxy, - maxCount, - device_id, - ) = self.__process_kwargs__(kwargs) - raise Exception("Deprecated method, TikTok fixed this.") - - def get_music_title(self, id, **kwargs): - """Retrieves a music title given an ID - - ##### Parameters - * id: The music id to get the title for - """ - r = requests.get( - "https://www.tiktok.com/music/-{}".format(id), - headers={ - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", - "Accept-Encoding": "gzip, deflate", - "Connection": "keep-alive", - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.111 Safari/537.36", - }, - proxies=self.__format_proxy(kwargs.get("proxy", None)), - cookies=self.get_cookies(**kwargs), - **self.requests_extra_kwargs, - ) - t = r.text - j_raw = self.__extract_tag_contents(r.text) - - music_object = json.loads(j_raw)["props"]["pageProps"]["musicInfo"] - if not music_object.get("title", None): - raise TikTokNotFoundError("Song of {} id does not exist".format(str(id))) - - return music_object["title"] - - def get_secuid(self, username, **kwargs): - """Gets the secUid for a specific username - - ##### Parameters - * username: The username to get the secUid for - """ - r = requests.get( - "https://tiktok.com/@{}?lang=en".format(quote(username)), - headers={ - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", - "path": "/@{}".format(quote(username)), - "Accept-Encoding": "gzip, deflate", - "Connection": "keep-alive", - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.111 Safari/537.36", - }, - proxies=self.__format_proxy( - kwargs.get("proxy", None), cookies=self.get_cookies(**kwargs) - ), - **self.requests_extra_kwargs, - ) - try: - return r.text.split('"secUid":"')[1].split('","secret":')[0] - except IndexError as e: - logging.info(r.text) - logging.error(e) - raise Exception( - "Retrieving the user secUid failed. Likely due to TikTok wanting captcha validation. Try to use a proxy." - ) - @staticmethod def generate_device_id(): """Generates a valid device_id for other methods. Pass this as the custom_device_id field to download videos""" @@ -1498,77 +501,65 @@ def generate_device_id(): # PRIVATE METHODS # - def __format_proxy(self, proxy) -> dict: + def _format_proxy(self, proxy) -> Optional[dict]: """ Formats the proxy object """ - if proxy is None and self.proxy is not None: - proxy = self.proxy + if proxy is None and self._proxy is not None: + proxy = self._proxy if proxy is not None: return {"http": proxy, "https": proxy} else: return None - def __get_js(self, proxy=None) -> str: - return requests.get( - "https://sf16-muse-va.ibytedtos.com/obj/rc-web-sdk-gcs/acrawler.js", - proxies=self.__format_proxy(proxy), - **self.requests_extra_kwargs, - ).text + # Process the kwargs + def _process_kwargs(self, kwargs): + region = kwargs.get("region", "US") + language = kwargs.get("language", "en") + proxy = kwargs.get("proxy", None) + + if kwargs.get("custom_device_id", None) != None: + device_id = kwargs.get("custom_device_id") + else: + if self._custom_device_id != None: + device_id = self._custom_device_id + else: + device_id = "".join(random.choice(string.digits) for num in range(19)) + + @dataclass + class ProcessedKwargs: + region: str + language: str + proxy: str + device_id: str - def __format_new_params__(self, parm) -> str: - # TODO: Maybe try not doing this? It should be handled by the urlencode. - return parm.replace("/", "%2F").replace(" ", "+").replace(";", "%3B") + return ProcessedKwargs( + region=region, language=language, proxy=proxy, device_id=device_id + ) - def __add_url_params__(self) -> str: + def _add_url_params(self) -> str: query = { "aid": 1988, "app_name": "tiktok_web", "device_platform": "web_mobile", - "region": self.region or "US", + "region": self._region or "US", "priority_region": "", "os": "ios", "referer": "", - "root_referer": "", "cookie_enabled": "true", - "screen_width": self.width, - "screen_height": self.height, - "browser_language": self.browser_language.lower() or "en-us", + "screen_width": self._width, + "screen_height": self._height, + "browser_language": self._browser_language.lower() or "en-us", "browser_platform": "iPhone", "browser_name": "Mozilla", - "browser_version": self.__format_new_params__(self.userAgent), + "browser_version": self._user_agent, "browser_online": "true", - "timezone_name": self.timezone_name or "America/Chicago", + "timezone_name": self._timezone_name or "America/Chicago", "is_page_visible": "true", "focus_state": "true", "is_fullscreen": "false", "history_len": random.randint(0, 30), - "language": self.language or "en", + "language": self._language or "en", } - return urlencode(query) - - def __extract_tag_contents(self, html): - nonce_start = '' - nonce = html.split(nonce_start)[1].split(nonce_end)[0] - j_raw = html.split( - '")[0] - return j_raw - - # Process the kwargs - def __process_kwargs__(self, kwargs): - region = kwargs.get("region", "US") - language = kwargs.get("language", "en") - proxy = kwargs.get("proxy", None) - maxCount = kwargs.get("maxCount", 35) - if kwargs.get("custom_device_id", None) != None: - device_id = kwargs.get("custom_device_id") - else: - if self.custom_device_id != None: - device_id = self.custom_device_id - else: - device_id = "".join(random.choice(string.digits) for num in range(19)) - return region, language, proxy, maxCount, device_id + return urlencode(query) diff --git a/TikTokApi/tiktokuser.py b/TikTokApi/tiktokuser.py deleted file mode 100644 index 87be56be..00000000 --- a/TikTokApi/tiktokuser.py +++ /dev/null @@ -1,101 +0,0 @@ -import requests - - -class TikTokUser: - def __init__(self, user_cookie, debug=False, proxy=None): - """A TikTok User Class. Represents a single user that is logged in. - - :param user_cookie: The cookies from a signed in session of TikTok. - Sign into TikTok.com and run document.cookie in the javascript console - and then copy the string and place it into this parameter. - """ - self.cookies = user_cookie - self.debug = debug - self.proxy = proxy - - def get_insights(self, videoID, username=None, proxy=None) -> dict: - """Get insights/analytics for a video. - - :param videoID: The TikTok ID to look up the insights for. - """ - api_url = "https://api.tiktok.com/aweme/v1/data/insighs/?tz_offset=-25200&aid=1233&carrier_region=US" - if username is not None: - referrer = "https://www.tiktok.com/@" + username + "/video/" + videoID - else: - referrer = "https://www.tiktok.com/" - insights = [ - "video_info", - "video_page_percent", - "video_region_percent", - "video_total_duration", - "video_per_duration", - ] - # Note: this list of parameters has to be in exactly this order with exactly this format - # or else you will get "Invalid parameters" - - def build_insight(insight, videoID): - return '{"insigh_type":"' + insight + '","aweme_id":"' + videoID + '"}' - - insight_string = ",".join([build_insight(i, videoID) for i in insights]) - insight_string = ( - insight_string - + ',{"insigh_type": "user_info"}' - + ',{"insigh_type":"video_uv","aweme_id":"' - + videoID - + '"}' - + ',{"insigh_type":"vv_history","days":8}' - + ',{"insigh_type":"follower_num_history","days":9}' - + ',{"insigh_type":"follower_num"}' - + ',{"insigh_type":"user_info"}' - ) - r = requests.post( - api_url, - headers={ - "accept": "*/*", - "accept-language": "en-US,en;q=0.9", - "content-type": "application/x-www-form-urlencoded", - "sec-fetch-dest": "empty", - "sec-fetch-mode": "cors", - "sec-fetch-site": "same-site", - "referrer": referrer, - "referrerPolicy": "no-referrer-when-downgrade", - "method": "POST", - "mode": "cors", - "credentials": "include", - }, - data="type_requests=[" + insight_string + "]", - proxies=self.__format_proxy(proxy), - cookies=self.__cookies_to_json(self.cookies), - ) - try: - return r.json() - except Exception: - if debug: - print(f"Failed converting following to JSON\n{r.text}") - raise Exception("Invalid Response (from TikTok)") - - # - # PRIVATE METHODS - # - def __format_proxy(self, proxy) -> dict: - """ - Formats the proxy object - """ - if proxy is not None: - return {"http": proxy, "https": proxy} - else: - return None - - def __cookies_to_json(self, cookie_string) -> dict: - """ - Turns a cookie string into a dict for - use in the requests module - """ - if isinstance(cookie_string, dict): - return cookie_string - - cookie_dict = {} - for cookie in cookie_string.split("; "): - cookie_dict[cookie.split("=")[0]] = cookie.split("=")[1] - - return cookie_dict diff --git a/TikTokApi/utilities.py b/TikTokApi/utilities.py index 337b94f9..4e3315df 100644 --- a/TikTokApi/utilities.py +++ b/TikTokApi/utilities.py @@ -1,6 +1,7 @@ import subprocess import sys -import pkg_resources + +LOGGER_NAME: str = "TikTokApi" def update_messager(): diff --git a/docs/TikTokApi.html b/docs/TikTokApi.html index c7ca002e..1bd7472b 100644 --- a/docs/TikTokApi.html +++ b/docs/TikTokApi.html @@ -3,64 +3,50 @@ - + TikTokApi API documentation - +