├── .env.example ├── .github └── workflows │ └── python-package.yml ├── .gitignore ├── LICENSE ├── README.md ├── apidance ├── __init__.py ├── client.py ├── exceptions.py ├── models.py └── utils │ ├── __init__.py │ └── markdown.py ├── example.py ├── mcp_server.py ├── pyproject.toml └── tests ├── test_rich.json └── test_richtext.py /.env.example: -------------------------------------------------------------------------------- 1 | APIDANCE_API_KEY= 2 | X_AUTH_TOKEN= -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | name: Python Package 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | release: 9 | types: [created] 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Set up Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: "3.10" 21 | 22 | - name: Install uv 23 | run: | 24 | curl -LsSf https://astral.sh/uv/install.sh | sh 25 | 26 | - name: Install dependencies 27 | run: | 28 | uv pip install --system -e ".[dev]" 29 | 30 | - name: Build package 31 | run: | 32 | uv pip install --system build 33 | python -m build 34 | 35 | - name: Check package structure 36 | run: | 37 | ls -l dist/ 38 | 39 | publish: 40 | needs: build 41 | runs-on: ubuntu-latest 42 | if: github.event_name == 'release' && github.event.action == 'created' 43 | 44 | steps: 45 | - uses: actions/checkout@v4 46 | 47 | - name: Set up Python 48 | uses: actions/setup-python@v5 49 | with: 50 | python-version: "3.10" 51 | 52 | - name: Install uv 53 | run: | 54 | curl -LsSf https://astral.sh/uv/install.sh | sh 55 | 56 | - name: Install build dependencies 57 | run: | 58 | uv pip install --system build twine 59 | 60 | - name: Build package 61 | run: python -m build 62 | 63 | - name: Check distribution 64 | run: twine check dist/* 65 | 66 | - name: Publish to PyPI 67 | env: 68 | TWINE_USERNAME: __token__ 69 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 70 | run: twine upload dist/* 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | #.idea/ 169 | 170 | uv.lock 171 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 script-money 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Apidance SDK 2 | 3 | A Python SDK for interacting with the Apidance API (https://apidance.pro/). 4 | 5 | ## Installation 6 | 7 | ```bash 8 | pip install apidance 9 | ``` 10 | 11 | ## Configuration 12 | 13 | Create a `.env` file in your project root with your API credentials: 14 | 15 | ```env 16 | APIDANCE_API_KEY=your_api_key_here 17 | X_AUTH_TOKEN=your_x_auth_token_here # Required for reply/like actions 18 | ``` 19 | 20 | Get your API key from [https://apidance.pro](https://apidance.pro) 21 | 22 | You can find your `X_AUTH_TOKEN` in your browser cookies when logged into x.com: 23 | 1. Open x.com and log in 24 | 2. Open browser developer tools (F12 or right-click -> Inspect) 25 | 3. Go to Application/Storage -> Cookies -> x.com 26 | 4. Find and copy the value of `auth_token` 27 | 28 | Or provide the credentials directly when initializing the client: 29 | 30 | ```python 31 | client = TwitterClient( 32 | api_key="your_api_key_here", 33 | auth_token="your_auth_token_here" # Required for reply/like actions 34 | ) 35 | ``` 36 | 37 | ## Usage 38 | 39 | > Check out the [examples](https://github.com/script-money/apidance/tree/main/examples) 40 | 41 | ```python 42 | from apidance import TwitterClient 43 | 44 | # Initialize the client 45 | client = TwitterClient() 46 | 47 | # Search tweets 48 | tweets = client.search_timeline( 49 | query="python", 50 | ) 51 | 52 | # Get user information 53 | user = client.get_user_by_screen_name("example") 54 | 55 | users = client.get_following(user_id=user["id"]) 56 | 57 | # Get tweets from a list 58 | list_tweets = client.get_list_latest_tweets( 59 | list_id="your_list_id", 60 | ) 61 | 62 | # Reply to a tweet 63 | client.create_tweet( 64 | text="Your reply text", 65 | reply_to_tweet_id="tweet_id_to_reply_to", 66 | ) 67 | 68 | # Like a tweet 69 | client.favorite_tweet(tweet_id="tweet_id_to_like") 70 | ``` 71 | 72 | ## MCP server 73 | 74 | FillSet config file 75 | ```json 76 | { 77 | "mcpServers": { 78 | "apidance": { 79 | "command": "/path/to/uv", 80 | "args": [ 81 | "--directory", 82 | "/path/to/apidance-sdk", 83 | "run", 84 | "mcp_server.py" 85 | ] 86 | } 87 | } 88 | } 89 | ``` 90 | 91 | ## Features 92 | 93 | - Search Twitter timeline with various filters (Latest, Top, People, Photos, Videos) 94 | - Get detailed user information by screen name 95 | - Fetch tweets from Twitter lists 96 | - Search Following 97 | - Reply to tweets (requires auth_token) 98 | - Like tweets (requires auth_token) 99 | - Create tweets and reply to existing tweets 100 | - Create note tweets (long rich text tweet, requires Premium+) 101 | - Host as mcp server 102 | 103 | ## Models 104 | 105 | The SDK provides two main data models: 106 | 107 | - `Tweet`: Represents a Twitter post with all its metadata 108 | - `User`: Contains detailed user information including profile data, stats, and verification status 109 | 110 | ## License 111 | 112 | MIT License 113 | -------------------------------------------------------------------------------- /apidance/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import TwitterClient 2 | from .models import Tweet, User, Media, URL, UserMention 3 | 4 | __version__ = "0.2.0" 5 | __all__ = ["TwitterClient", "Tweet", "User", "Media", "URL", "UserMention"] 6 | -------------------------------------------------------------------------------- /apidance/client.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import time 4 | import re 5 | from typing import Optional, Dict, Any, List, Union 6 | import httpx 7 | from dotenv import load_dotenv 8 | from .models import Tweet, User 9 | from .exceptions import ( 10 | TwitterPlatformError, 11 | InvalidInputError, 12 | RateLimitError, 13 | InsufficientCreditsError, 14 | TimeoutError, 15 | ApidancePlatformError, 16 | ApiKeyError, 17 | AuthTokenError, 18 | ) 19 | from .utils import parse_markdown_to_richtext 20 | 21 | load_dotenv() 22 | 23 | 24 | class TwitterClient: 25 | """Client for interacting with Twitter API via Apidance.""" 26 | 27 | def __init__( 28 | self, 29 | api_key: Optional[str] = None, 30 | base_url: str = "https://api.apidance.pro", 31 | max_retries: int = 10, 32 | initial_retry_delay: float = 1.0, 33 | max_retry_delay: float = 32.0, 34 | backoff_factor: float = 2.0, 35 | ): 36 | """Initialize the Twitter client. 37 | 38 | Args: 39 | api_key: Optional API key. If not provided, will be read from APIDANCE_API_KEY env var. 40 | base_url: Base URL for API requests 41 | max_retries: Maximum number of retry attempts for failed requests 42 | initial_retry_delay: Initial delay between retries in seconds 43 | max_retry_delay: Maximum delay between retries in seconds 44 | backoff_factor: Multiplicative factor for exponential backoff 45 | """ 46 | self.api_key = api_key or os.getenv("APIDANCE_API_KEY") 47 | if not self.api_key: 48 | raise ApiKeyError( 49 | "API key must be provided either through constructor or APIDANCE_API_KEY environment variable" 50 | ) 51 | 52 | # Check balance 53 | balance = self.check_balance() 54 | if int(balance) < 100: 55 | raise InsufficientCreditsError( 56 | f"Warning: Your API balance is low ({balance}). Please recharge your account." 57 | ) 58 | 59 | self.base_url = base_url 60 | self.client = httpx.Client() 61 | self.headers = { 62 | "apikey": self.api_key, 63 | "Content-Type": "application/json", 64 | } 65 | 66 | # Retry related configuration 67 | self.max_retries = max_retries 68 | self.initial_retry_delay = initial_retry_delay 69 | self.max_retry_delay = max_retry_delay 70 | self.backoff_factor = backoff_factor 71 | 72 | def _calculate_retry_delay(self, attempt: int) -> float: 73 | """Calculate retry delay using exponential backoff algorithm. 74 | 75 | Args: 76 | attempt: Current retry attempt number 77 | 78 | Returns: 79 | Delay time in seconds for the next retry 80 | """ 81 | delay = min( 82 | self.initial_retry_delay * (self.backoff_factor ** (attempt - 1)), 83 | self.max_retry_delay, 84 | ) 85 | return delay 86 | 87 | def _should_retry(self, response: httpx.Response, attempt: int) -> bool: 88 | """Determine if a request should be retried. 89 | 90 | Args: 91 | response: API response 92 | attempt: Current retry attempt number 93 | 94 | Returns: 95 | Whether to retry the request 96 | 97 | Raises: 98 | RateLimitError: When rate limit is exceeded 99 | InsufficientCreditsError: When API credits are depleted 100 | AuthenticationError: When authentication fails 101 | """ 102 | # If we've reached the maximum attempts, don't retry 103 | if attempt >= self.max_retries: 104 | return False 105 | 106 | # Try to parse the response as JSON 107 | response_data = None 108 | try: 109 | response_data = response.json() 110 | except json.JSONDecodeError: 111 | # JSON parse error might be temporary, allow retry 112 | return True 113 | 114 | # Handle Twitter-style errors (with "errors" array) 115 | if response_data is not None and "errors" in response_data: 116 | error = response_data["errors"][0] 117 | error_code = error.get("code") 118 | 119 | # Rate limit error 120 | if error_code == 88: 121 | if attempt == self.max_retries: 122 | raise RateLimitError("Rate limit exceeded. Please try again later.") 123 | return True 124 | # Other Twitter platform errors 125 | elif error_code == 366: 126 | raise InvalidInputError(error.get("message", "")) 127 | elif error_code == 139: # Tweet already favorited 128 | return False 129 | elif ( 130 | error_code == 64 131 | ): # Account is suspended and is not permitted to access this feature, retry 132 | return True 133 | else: 134 | raise TwitterPlatformError(error.get("message", "")) 135 | 136 | # Handle Apidance API style errors 137 | if response_data is not None and response_data.get("code", 0) > 0: 138 | msg = response_data.get("msg", "").lower() 139 | if "insufficient api counts" in msg: 140 | raise InsufficientCreditsError("Insufficient api credits") 141 | else: 142 | raise ApidancePlatformError(msg) 143 | 144 | # Handle local rate limiting 145 | if response.text == "local_rate_limited": 146 | return True 147 | 148 | # Handle specific error cases based on response data 149 | if "data" in response_data: 150 | return False 151 | 152 | return False 153 | 154 | def _make_request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]: 155 | """Make an API request with retry logic. 156 | 157 | Args: 158 | method: HTTP method 159 | endpoint: API endpoint 160 | **kwargs: Additional request parameters 161 | 162 | Returns: 163 | API response data 164 | 165 | Raises: 166 | AuthTokenError: When authentication token is invalid 167 | PremiumRequiredError: When Premium+ is required 168 | RateLimitError: When rate limit is exceeded 169 | InsufficientCreditsError: When API credits are depleted 170 | TimeoutError: When request times out 171 | ApidancePlatformError: When the platform returns other errors 172 | """ 173 | url = f"{self.base_url}{endpoint}" 174 | 175 | # Process request parameters 176 | if "params" in kwargs and "variables" in kwargs["params"]: 177 | kwargs["params"]["variables"] = json.dumps(kwargs["params"]["variables"]) 178 | 179 | # Set request headers 180 | headers = self.headers.copy() 181 | token = os.getenv("X_AUTH_TOKEN") 182 | # These endpoints need X_AUTH_TOKEN regardless of HTTP method 183 | auth_required_endpoints = [ 184 | "/graphql/FollowersYouKnow", 185 | "/graphql/CreateTweet", 186 | "/graphql/CreateNoteTweet", 187 | "/graphql/FavoriteTweet", 188 | ] 189 | if endpoint in auth_required_endpoints: 190 | if token: 191 | if not re.match(r"^[0-9a-f]{40}$", token): 192 | raise AuthTokenError( 193 | f"Invalid AuthToken format. Expected 40 character hexadecimal string, got: {token}" 194 | ) 195 | headers["AuthToken"] = token 196 | 197 | # Specific exceptions that should not be retried 198 | fatal_exceptions = ( 199 | AuthTokenError, 200 | InsufficientCreditsError, 201 | ApidancePlatformError, 202 | ) 203 | 204 | # Error storage 205 | last_error = None 206 | 207 | # Retry loop 208 | for attempt in range(1, self.max_retries + 1): 209 | try: 210 | # Make the actual HTTP request 211 | response = self.client.request( 212 | method, url, headers=headers, timeout=10, **kwargs 213 | ) 214 | 215 | try: 216 | # Check if retry is needed 217 | if not self._should_retry(response, attempt): 218 | return response.json() 219 | except fatal_exceptions: 220 | # These are explicit errors that should not be retried 221 | raise 222 | except RateLimitError: 223 | # Rate limit errors are handled directly in _should_retry 224 | if attempt == self.max_retries: 225 | raise 226 | # If we're not at max retries, _should_retry will return True to continue 227 | except Exception as e: 228 | # Record other errors and continue retrying 229 | last_error = e 230 | break 231 | 232 | # Calculate delay for next retry 233 | delay = self._calculate_retry_delay(attempt) 234 | time.sleep(delay) 235 | 236 | except httpx.ConnectError as e: 237 | if attempt == self.max_retries: 238 | raise TimeoutError(f"Connection error: {str(e)}") from e 239 | # For network errors before max_retries, try again 240 | last_error = e 241 | delay = self._calculate_retry_delay(attempt) 242 | time.sleep(delay) 243 | except Exception as e: 244 | # Catch any other unexpected errors 245 | if attempt == self.max_retries: 246 | raise ApidancePlatformError(f"Unexpected error: {str(e)}") from e 247 | last_error = e 248 | delay = self._calculate_retry_delay(attempt) 249 | time.sleep(delay) 250 | 251 | # If all retries failed, raise the final error 252 | if last_error: 253 | raise last_error 254 | 255 | def check_balance(self) -> int: 256 | """Check the remaining balance for the API key. 257 | 258 | Returns: 259 | int: Remaining balance 260 | 261 | Note: 262 | Returns 0 if connection fails or API returns error 263 | """ 264 | try: 265 | response = httpx.get( 266 | f"https://api.apidance.pro/key/{self.api_key}", timeout=5.0, verify=True 267 | ) 268 | 269 | # Try to parse as JSON 270 | try: 271 | data = response.json() 272 | # If the response is a direct integer 273 | if isinstance(data, int): 274 | return data 275 | # If the response is an error object 276 | if isinstance(data, dict) and data.get("code") == -1: 277 | return 0 278 | # Any other JSON response, try to convert text to int 279 | return int(response.text) 280 | except (json.JSONDecodeError, ValueError): 281 | # If not valid JSON but has text, try parsing as int 282 | if response.text: 283 | try: 284 | return int(response.text) 285 | except ValueError: 286 | return 0 287 | return 0 288 | 289 | except ( 290 | httpx.ConnectError, 291 | httpx.ConnectTimeout, 292 | httpx.ReadTimeout, 293 | httpx.HTTPError, 294 | httpx.HTTPStatusError, 295 | ) as e: 296 | # Log the error if needed 297 | print(f"Warning: Could not connect to API server: {e}") 298 | # Return 0 as default balance when unable to check 299 | return 0 300 | 301 | def search_timeline( 302 | self, 303 | query: str, 304 | product: str = "Latest", 305 | count: int = 40, 306 | include_promoted_content: bool = False, 307 | ) -> List[Tweet]: 308 | """Search Twitter timeline with pagination support. 309 | 310 | Args: 311 | query: Search query string 312 | product: Type of search results. One of: Top, Latest, People, Photos, Videos 313 | count: Number of results to return (default: 40, set to -1 for all available results) 314 | include_promoted_content: Whether to include promoted content (default: False) 315 | 316 | Returns: 317 | List of Tweet objects from search results 318 | """ 319 | all_tweets = [] 320 | cursor = None 321 | batch_size = 20 # Twitter API default batch size for search 322 | 323 | while True: 324 | variables = { 325 | "rawQuery": query, 326 | "count": batch_size, 327 | "cursor": cursor, 328 | "querySource": "typed_query", 329 | "product": product, 330 | "includePromotedContent": include_promoted_content, 331 | } 332 | 333 | response = self._make_request( 334 | "GET", 335 | "/graphql/SearchTimeline", 336 | params={"variables": variables}, 337 | ) 338 | 339 | # Extract tweets from response 340 | timeline = ( 341 | response.get("data", {}) 342 | .get("search_by_raw_query", {}) 343 | .get("search_timeline", {}) 344 | .get("timeline", {}) 345 | .get("instructions", []) 346 | ) 347 | 348 | new_tweets = [] 349 | for instruction in timeline: 350 | if "entries" in instruction: 351 | for entry in instruction["entries"]: 352 | if "content" in entry and "itemContent" in entry["content"]: 353 | tweet_data = entry["content"]["itemContent"] 354 | if tweet_data.get("__typename") == "TimelineTweet": 355 | new_tweets.append(Tweet.from_api_response(tweet_data)) 356 | 357 | # Add new tweets with deduplication 358 | all_tweets.extend( 359 | [ 360 | tweet 361 | for tweet in new_tweets 362 | if tweet and tweet.id not in {t.id for t in all_tweets} 363 | ] 364 | ) 365 | 366 | # Stop if we've reached the desired count 367 | if count != -1 and len(all_tweets) >= count: 368 | all_tweets = all_tweets[:count] 369 | break 370 | 371 | # Get cursor for next page from bottom cursor entry 372 | for instruction in timeline: 373 | if "entry" in instruction and instruction["entry"][ 374 | "entryId" 375 | ].startswith("cursor-bottom-"): 376 | cursor = instruction["entry"]["content"]["value"] 377 | break 378 | elif "entries" in instruction: 379 | for entry in instruction["entries"]: 380 | if entry["entryId"].startswith("cursor-bottom-"): 381 | cursor = entry["content"]["value"] 382 | break 383 | 384 | # Stop if no cursor found or no new tweets 385 | if not cursor or not new_tweets: 386 | break 387 | 388 | return all_tweets 389 | 390 | def get_user_by_screen_name( 391 | self, 392 | screen_name: str, 393 | with_safety_mode_user_fields: bool = True, 394 | with_highlighted_label: bool = True, 395 | ) -> User: 396 | """Get detailed user information by screen name using GraphQL endpoint. 397 | 398 | Args: 399 | screen_name: Twitter screen name/username 400 | with_safety_mode_user_fields: Include safety mode user fields (default: True) 401 | with_highlighted_label: Include highlighted label information (default: True) 402 | 403 | Returns: 404 | User object containing detailed user information including profile data, stats, and verification status 405 | """ 406 | variables = { 407 | "screen_name": screen_name, 408 | "withSafetyModeUserFields": with_safety_mode_user_fields, 409 | "withHighlightedLabel": with_highlighted_label, 410 | } 411 | 412 | response = self._make_request( 413 | "GET", 414 | "/graphql/UserByScreenName", 415 | params={"variables": variables}, 416 | ) 417 | 418 | if ( 419 | response.get("data") 420 | and response["data"].get("user") 421 | and response["data"]["user"].get("result") 422 | ): 423 | return User.from_api_response(response["data"]["user"]["result"]) 424 | 425 | def get_list_latest_tweets( 426 | self, 427 | list_id: Union[int, str], 428 | count: int = 20, 429 | include_promoted_content: bool = False, 430 | ) -> List[Tweet]: 431 | """Get latest tweets from a specific Twitter list using GraphQL endpoint. 432 | 433 | Args: 434 | list_id: ID of the Twitter list 435 | count: Number of tweets to return (default: 20, set to -1 for all available results) 436 | include_promoted_content: Include promoted content in results (default: False) 437 | 438 | Returns: 439 | List of Tweet objects from the specified list 440 | """ 441 | all_tweets = [] 442 | cursor = None 443 | 444 | while True: 445 | variables = { 446 | "listId": str(list_id), 447 | "count": count, 448 | "includePromotedContent": include_promoted_content, 449 | } 450 | if cursor: 451 | variables["cursor"] = cursor 452 | 453 | response = self._make_request( 454 | "GET", 455 | "/graphql/ListLatestTweetsTimeline", 456 | params={"variables": variables}, 457 | ) 458 | 459 | # Extract tweets from current page 460 | timeline = ( 461 | response.get("data", {}) 462 | .get("list", {}) 463 | .get("tweets_timeline", {}) 464 | .get("timeline", {}) 465 | .get("instructions", []) 466 | ) 467 | if not timeline: 468 | break 469 | 470 | entries = timeline[0].get("entries", []) 471 | new_tweets = [] 472 | cursor = None 473 | 474 | for entry in entries: 475 | content = entry.get("content", {}) 476 | 477 | # Handle regular tweets 478 | if content.get("__typename") == "TimelineTimelineItem": 479 | tweet_data = content["itemContent"] 480 | new_tweets.append(Tweet.from_api_response(tweet_data)) 481 | 482 | # Extract cursor for next page 483 | elif ( 484 | content.get("__typename") == "TimelineTimelineCursor" 485 | and content.get("cursorType") == "Bottom" 486 | ): 487 | cursor = content.get("value") 488 | 489 | # Handle tweets in modules 490 | elif content.get("__typename") == "TimelineTimelineModule": 491 | for item in content["items"]: 492 | tweet_data = item["item"]["itemContent"] 493 | new_tweets.append(Tweet.from_api_response(tweet_data)) 494 | 495 | all_tweets.extend(new_tweets) 496 | 497 | # Stop if we've reached the desired count 498 | if count != -1 and len(all_tweets) >= count: 499 | all_tweets = all_tweets[:count] 500 | break 501 | 502 | # Stop if no cursor found or no new tweets 503 | if not cursor or not new_tweets: 504 | break 505 | 506 | return all_tweets 507 | 508 | def _extract_tweets_from_response( 509 | self, response: Dict, include_pins: bool 510 | ) -> List[Tweet]: 511 | """Extract tweets from API response. 512 | 513 | Args: 514 | response: Raw API response 515 | include_pins: Whether to include pinned tweets 516 | 517 | Returns: 518 | List of Tweet objects 519 | """ 520 | tweets = [] 521 | data = response.get("data", {}).get("user", {}).get("result", {}) 522 | timeline = ( 523 | data.get("timeline_v2", {}).get("timeline", {}).get("instructions", []) 524 | ) 525 | 526 | for instruction in timeline: 527 | if "entries" in instruction: 528 | for entry in instruction["entries"]: 529 | content = entry.get("content") 530 | if content.get("__typename") == "TimelineTimelineItem": 531 | tweet_data = content["itemContent"] 532 | tweets.append(Tweet.from_api_response(tweet_data)) 533 | elif content.get("__typename") == "TimelineTimelineModule": 534 | thread_data = content["items"] 535 | for thread_item in thread_data: 536 | tweet_data = thread_item.get("item").get("itemContent") 537 | tweets.append(Tweet.from_api_response(tweet_data)) 538 | elif ( 539 | instruction.get("type") == "TimelinePinEntry" 540 | and "entry" in instruction 541 | and include_pins 542 | ): # Pin tweet 543 | entry = instruction["entry"] 544 | if "content" in entry and "itemContent" in entry["content"]: 545 | tweet_data = entry["content"]["itemContent"] 546 | tweets.append(Tweet.from_api_response(tweet_data)) 547 | 548 | return tweets 549 | 550 | def _get_bottom_cursor(self, response: Dict) -> Optional[str]: 551 | """Extract bottom cursor from API response. 552 | 553 | Args: 554 | response: Raw API response 555 | 556 | Returns: 557 | Cursor value if found, None otherwise 558 | """ 559 | data = response.get("data", {}).get("user", {}).get("result", {}) 560 | timeline = ( 561 | data.get("timeline_v2", {}).get("timeline", {}).get("instructions", []) 562 | ) 563 | 564 | for instruction in timeline: 565 | if "entries" in instruction: 566 | for entry in instruction["entries"]: 567 | content = entry.get("content", {}) 568 | if ( 569 | content.get("__typename") == "TimelineTimelineCursor" 570 | and content.get("cursorType") == "Bottom" 571 | ): 572 | return content.get("value") 573 | return None 574 | 575 | def has_more_tweets(self, response: Dict) -> bool: 576 | """Check if there are more tweets to fetch. 577 | 578 | Args: 579 | response: Raw API response 580 | 581 | Returns: 582 | True if there are more tweets, False otherwise 583 | """ 584 | data = response.get("data", {}).get("user", {}).get("result", {}) 585 | timeline = ( 586 | data.get("timeline_v2", {}).get("timeline", {}).get("instructions", []) 587 | ) 588 | 589 | for instruction in timeline: 590 | if "entries" in instruction: 591 | for entry in instruction["entries"]: 592 | content = entry.get("content") 593 | if content.get("__typename") == "TimelineTimelineItem": 594 | return True 595 | return False 596 | 597 | def get_user_tweets( 598 | self, 599 | user_id: Union[int, str], 600 | count: int = 20, 601 | include_pins: bool = True, 602 | include_promoted_content: bool = False, 603 | with_quick_promote_eligibility_tweet_fields: bool = False, 604 | with_voice: bool = False, 605 | with_v2_timeline: bool = True, 606 | ) -> List[Tweet]: 607 | """Get tweets from a specific user using GraphQL endpoint. 608 | 609 | Args: 610 | user_id: Twitter user ID 611 | count: Number of tweets to return (default: 20, set to -1 for all tweets) 612 | include_promoted_content: Include promoted content in results (default: False) 613 | with_quick_promote_eligibility_tweet_fields: Include quick promote eligibility fields (default: False) 614 | with_voice: Include voice tweet information (default: False) 615 | with_v2_timeline: Include v2 timeline information (default: True) 616 | 617 | Returns: 618 | List of Tweet objects containing tweet content, media, and related information 619 | """ 620 | all_tweets = [] 621 | cursor = None 622 | batch_size = 20 # Twitter API limit 623 | 624 | while True: 625 | variables = { 626 | "userId": str(user_id), 627 | "count": batch_size, 628 | "cursor": cursor, 629 | "includePromotedContent": include_promoted_content, 630 | "withQuickPromoteEligibilityTweetFields": with_quick_promote_eligibility_tweet_fields, 631 | "withVoice": with_voice, 632 | "withV2Timeline": with_v2_timeline, 633 | } 634 | 635 | response = self._make_request( 636 | "GET", 637 | "/graphql/UserTweets", 638 | params={"variables": variables}, 639 | ) 640 | 641 | # Extract tweets from response 642 | new_tweets = self._extract_tweets_from_response( 643 | response, include_pins=include_pins 644 | ) 645 | all_tweets.extend( 646 | [ 647 | tweet 648 | for tweet in new_tweets 649 | if tweet and tweet.id not in {t.id for t in all_tweets} 650 | ] 651 | ) 652 | 653 | # Stop if we've reached the desired count 654 | if count != -1 and len(all_tweets) >= count: 655 | all_tweets = all_tweets[:count] 656 | break 657 | 658 | # Check if there are more tweets to fetch 659 | if not self.has_more_tweets(response): 660 | break 661 | 662 | # Get cursor for next page 663 | cursor = self._get_bottom_cursor(response) 664 | if not cursor: 665 | break 666 | 667 | return all_tweets 668 | 669 | def get_following(self, user_id: Union[int, str]) -> List[User]: 670 | """Get a list of users that the specified user is following. 671 | 672 | Args: 673 | user_id: The user ID to get following for 674 | 675 | Returns: 676 | List of User objects 677 | """ 678 | 679 | variables = { 680 | "userId": str(user_id), 681 | "includePromotedContent": False, 682 | } 683 | 684 | response = self._make_request( 685 | "GET", 686 | "/graphql/Following", 687 | params={"variables": variables}, 688 | ) 689 | 690 | # Extract users from the nested timeline structure 691 | if not isinstance(response, dict): 692 | return [] 693 | data = response.get("data", {}).get("user", {}).get("result", {}) 694 | timeline = data.get("timeline", {}).get("timeline", {}) 695 | instructions = timeline.get("instructions", []) 696 | 697 | users = [] 698 | for instruction in instructions: 699 | # Skip TimelineClearCache type instructions 700 | if instruction.get("type") == "TimelineClearCache": 701 | continue 702 | 703 | # Handle entries if present 704 | entries = instruction.get("entries", []) 705 | if entries: 706 | for entry in entries: 707 | content = entry.get("content", {}) 708 | if ( 709 | content.get("itemContent", {}) 710 | .get("user_results", {}) 711 | .get("result") 712 | ): 713 | user_data = content["itemContent"]["user_results"]["result"] 714 | users.append(User.from_api_response(user_data)) 715 | 716 | return users 717 | 718 | def get_followers( 719 | self, 720 | user_id: Union[int, str], 721 | count: int = 20, 722 | include_promoted_content: bool = False, 723 | ) -> List[User]: 724 | """Get a list of followers for a specific user using GraphQL endpoint. 725 | 726 | Args: 727 | user_id: Twitter user ID 728 | count: Number of followers to return (default: 20, set to -1 for all followers) 729 | include_promoted_content: Include promoted content in results (default: False) 730 | 731 | Returns: 732 | List of User objects containing detailed user information 733 | """ 734 | variables = { 735 | "userId": str(user_id), 736 | "count": min(count, 20) if count > 0 else 20, 737 | "includePromotedContent": include_promoted_content, 738 | } 739 | 740 | all_followers = [] 741 | cursor = None 742 | 743 | while True: 744 | if cursor: 745 | variables["cursor"] = cursor 746 | 747 | response = self._make_request( 748 | "GET", 749 | "/graphql/Followers", 750 | params={"variables": variables}, 751 | ) 752 | 753 | try: 754 | entries = response["data"]["user"]["result"]["timeline"]["timeline"][ 755 | "instructions" 756 | ][-1]["entries"] 757 | except (KeyError, IndexError): 758 | break 759 | 760 | # Extract users from the response 761 | for entry in entries: 762 | if entry["content"].get("__typename") == "TimelineTimelineItem": 763 | try: 764 | user_data = entry["content"]["itemContent"]["user_results"][ 765 | "result" 766 | ] 767 | if user_data["__typename"] == "User": 768 | all_followers.append(User.from_api_response(user_data)) 769 | except (KeyError, TypeError): 770 | continue 771 | 772 | # Check if we need to continue fetching 773 | if count != -1 and len(all_followers) >= count: 774 | all_followers = all_followers[:count] 775 | break 776 | 777 | # Look for the bottom cursor 778 | cursor = None 779 | for entry in entries: 780 | if ( 781 | entry["content"].get("__typename") == "TimelineTimelineCursor" 782 | and entry["content"].get("cursorType") == "Bottom" 783 | ): 784 | cursor = entry["content"].get("value") 785 | break 786 | 787 | if not cursor: 788 | break 789 | 790 | # If we got less than requested in this batch, no need to continue 791 | if count > 0 and len(entries) < variables["count"]: 792 | break 793 | 794 | return all_followers 795 | 796 | def get_followers_you_know( 797 | self, 798 | user_id: Union[int, str], 799 | count: int = 20, 800 | include_promoted_content: bool = False, 801 | ) -> List[User]: 802 | """Get a list of followers that you know for a specific user using GraphQL endpoint. 803 | 804 | Args: 805 | user_id: Twitter user ID 806 | count: Number of followers to return (default: 20, set to -1 for all followers) 807 | include_promoted_content: Include promoted content in results (default: False) 808 | 809 | Returns: 810 | List of User objects containing detailed user information 811 | """ 812 | variables = { 813 | "userId": str(user_id), 814 | "count": min(count, 20) if count > 0 else 20, 815 | "includePromotedContent": include_promoted_content, 816 | } 817 | 818 | all_followers = [] 819 | cursor = None 820 | 821 | while True: 822 | if cursor: 823 | variables["cursor"] = cursor 824 | 825 | response = self._make_request( 826 | "GET", 827 | "/graphql/FollowersYouKnow", 828 | params={"variables": variables}, 829 | ) 830 | 831 | try: 832 | entries = response["data"]["user"]["result"]["timeline"]["timeline"][ 833 | "instructions" 834 | ][-1]["entries"] 835 | except (KeyError, IndexError): 836 | break 837 | 838 | # Extract users from the response 839 | for entry in entries: 840 | if entry["content"].get("__typename") == "TimelineTimelineItem": 841 | try: 842 | user_data = entry["content"]["itemContent"]["user_results"][ 843 | "result" 844 | ] 845 | if user_data["__typename"] == "User": 846 | all_followers.append(User.from_api_response(user_data)) 847 | except (KeyError, TypeError): 848 | continue 849 | 850 | # Check if we need to continue fetching 851 | if count != -1 and len(all_followers) >= count: 852 | all_followers = all_followers[:count] 853 | break 854 | 855 | # Look for the bottom cursor 856 | cursor = None 857 | for entry in entries: 858 | if ( 859 | entry["content"].get("__typename") == "TimelineTimelineCursor" 860 | and entry["content"].get("cursorType") == "Bottom" 861 | ): 862 | cursor = entry["content"].get("value") 863 | break 864 | 865 | if not cursor: 866 | break 867 | 868 | # If we got less than requested in this batch, no need to continue 869 | if count > 0 and len(entries) < variables["count"]: 870 | break 871 | 872 | return all_followers 873 | 874 | def favorite_tweet(self, tweet_id: Union[int, str]) -> bool: 875 | 876 | variables = { 877 | "tweet_id": str(tweet_id), 878 | } 879 | 880 | response = self._make_request( 881 | "POST", 882 | "/graphql/FavoriteTweet", 883 | json={"variables": variables}, 884 | ) 885 | if response == {"data": {"favorite_tweet": "Done"}}: 886 | print(f"Tweet: {tweet_id} favorited") 887 | return True 888 | 889 | if len(response) == 0: 890 | print(f"Tweet: {tweet_id} not favorited") 891 | return False 892 | 893 | if response["errors"][0]["code"] == 139: 894 | print(f"Tweet: {tweet_id} already favorited") 895 | return True 896 | 897 | if response["errors"][0]["code"] == 144: 898 | print(f"Tweet: {tweet_id} not found") 899 | return False 900 | 901 | return False 902 | 903 | def create_tweet(self, text: str, reply_to_tweet_id: Union[int, str] = None) -> str: 904 | """Create a new tweet or reply to an existing tweet. 905 | 906 | Args: 907 | text: The text content of the tweet 908 | reply_to_tweet_id: Optional tweet ID to reply to 909 | 910 | Returns: 911 | str: The ID of the created tweet 912 | """ 913 | variables = { 914 | "tweet_text": text, 915 | "dark_request": False, 916 | "media": {"media_entities": [], "possibly_sensitive": False}, 917 | "semantic_annotation_ids": [], 918 | } 919 | 920 | # Add reply information if replying to a tweet 921 | if reply_to_tweet_id: 922 | variables["reply"] = { 923 | "in_reply_to_tweet_id": str(reply_to_tweet_id), 924 | "exclude_reply_user_ids": [], 925 | } 926 | 927 | response = self._make_request( 928 | "POST", 929 | "/graphql/CreateTweet", 930 | json={"variables": variables}, 931 | ) 932 | 933 | # Extract the tweet ID from the response 934 | try: 935 | tweet_id = response["data"]["create_tweet"]["tweet_results"]["result"][ 936 | "rest_id" 937 | ] 938 | print(f"Tweet created successfully with ID: {tweet_id}") 939 | return tweet_id 940 | except (KeyError, TypeError): 941 | print("Failed to create tweet") 942 | return None 943 | 944 | def create_note_tweet( 945 | self, 946 | text: str, 947 | use_richtext: bool = False, 948 | reply_to_tweet_id: Union[int, str] = None, 949 | ) -> str: 950 | """Create a new note tweet. (Long text tweet, only available in Premium+) 951 | 952 | Args: 953 | text: The text content of the tweet (supports markdown if use_richtext=True) 954 | use_richtext: Whether to parse markdown formatting for rich text 955 | reply_to_tweet_id: Optional tweet ID to reply to 956 | 957 | Returns: 958 | str: The ID of the created tweet 959 | 960 | Raises: 961 | PremiumRequiredError: If the user doesn't have Premium+ plan 962 | """ 963 | richtext_options = {} 964 | 965 | variables = { 966 | "tweet_text": text, 967 | "dark_request": False, 968 | "media": {"media_entities": [], "possibly_sensitive": False}, 969 | "semantic_annotation_ids": [], 970 | "includePromotedContent": False, 971 | } 972 | 973 | if use_richtext: 974 | text, richtext_tags = parse_markdown_to_richtext(text) 975 | if richtext_tags: 976 | richtext_options["richtext_tags"] = richtext_tags 977 | variables["richtext_options"] = richtext_options 978 | 979 | # Add reply information if replying to a tweet 980 | if reply_to_tweet_id: 981 | variables["reply"] = { 982 | "in_reply_to_tweet_id": str(reply_to_tweet_id), 983 | "exclude_reply_user_ids": [], 984 | } 985 | 986 | response = self._make_request( 987 | "POST", 988 | "/graphql/CreateNoteTweet", 989 | json={"variables": variables}, 990 | ) 991 | 992 | # Extract the tweet ID from the response 993 | try: 994 | tweet_id = response["data"]["notetweet_create"]["tweet_results"]["result"][ 995 | "rest_id" 996 | ] 997 | print(f"Note tweet created successfully with ID: {tweet_id}") 998 | return tweet_id 999 | except (KeyError, TypeError): 1000 | print("Failed to create note tweet") 1001 | return None 1002 | 1003 | def tweet_result_by_rest_id(self, tweet_id: Union[int, str]): 1004 | variables = { 1005 | "tweetId": str(tweet_id), 1006 | "withHighlightedLabel": True, 1007 | "withTweetQuoteCount": True, 1008 | "includePromotedContent": True, 1009 | "withBirdwatchPivots": True, 1010 | "withVoice": True, 1011 | "withReactions": True, 1012 | } 1013 | 1014 | response = self._make_request( 1015 | "GET", 1016 | "/graphql/TweetResultByRestId", 1017 | params={"variables": variables}, 1018 | ) 1019 | 1020 | # {'data': {'tweetResult': {}}} 1021 | # response maybe [] 1022 | 1023 | return Tweet.from_api_response(response["data"]) 1024 | -------------------------------------------------------------------------------- /apidance/exceptions.py: -------------------------------------------------------------------------------- 1 | """Exception classes for the Apidance SDK. 2 | 3 | This module contains all the exceptions that can be raised by the Apidance SDK. 4 | Exceptions are categorized into four groups: 5 | 1. Base exceptions (base class for all SDK exceptions) 6 | 2. Configuration exceptions (related to SDK configuration) 7 | 3. Apidance platform exceptions (related to the Apidance API platform) 8 | 4. Twitter platform exceptions (related to Twitter API errors) 9 | """ 10 | 11 | 12 | # ----------------------------------------------------------------------------- 13 | # Base Exceptions 14 | # ----------------------------------------------------------------------------- 15 | class ApiDanceError(Exception): 16 | """Base class for all ApiDance exceptions.""" 17 | 18 | pass 19 | 20 | 21 | class TimeoutError(ApiDanceError): 22 | """Raised when a request times out.""" 23 | 24 | pass 25 | 26 | 27 | # ----------------------------------------------------------------------------- 28 | # Configuration Exceptions 29 | # ----------------------------------------------------------------------------- 30 | class ConfigurationError(ApiDanceError): 31 | """Base class for all configuration-related exceptions.""" 32 | 33 | pass 34 | 35 | 36 | class ApiKeyError(ConfigurationError): 37 | """Raised when the APIDANCE_API_KEY is missing or invalid.""" 38 | 39 | pass 40 | 41 | 42 | class AuthTokenError(ConfigurationError): 43 | """Raised when the X_AUTH_TOKEN is missing or invalid.""" 44 | 45 | pass 46 | 47 | 48 | # ----------------------------------------------------------------------------- 49 | # Apidance Platform Exceptions 50 | # ----------------------------------------------------------------------------- 51 | class ApidancePlatformError(ApiDanceError): 52 | """Base class for all Apidance platform-related exceptions.""" 53 | 54 | pass 55 | 56 | 57 | class RateLimitError(ApidancePlatformError): 58 | """Raised when Apidance platform rate limit is exceeded.""" 59 | 60 | pass 61 | 62 | 63 | class InsufficientCreditsError(ApidancePlatformError): 64 | """Raised when the account has insufficient API credits.""" 65 | 66 | pass 67 | 68 | 69 | # ----------------------------------------------------------------------------- 70 | # Twitter Platform Exceptions 71 | # ----------------------------------------------------------------------------- 72 | class TwitterPlatformError(ApiDanceError): 73 | """Base class for all Twitter platform-related exceptions.""" 74 | 75 | pass 76 | 77 | 78 | class PremiumRequiredError(TwitterPlatformError): 79 | """Raised when a premium feature is requested with a non-premium account.""" 80 | 81 | pass 82 | 83 | 84 | class InvalidInputError(TwitterPlatformError): 85 | """Raised when the provided query is invalid or not found.""" 86 | 87 | pass 88 | 89 | 90 | class AuthenticationError(TwitterPlatformError): 91 | """Raised when authentication fails.""" 92 | 93 | pass 94 | -------------------------------------------------------------------------------- /apidance/models.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, Dict, Any 2 | from datetime import datetime 3 | from pydantic import BaseModel 4 | 5 | 6 | class BaseApiModel(BaseModel): 7 | """Base model for all Apidance models with dict-like access and serialization support.""" 8 | 9 | def __getitem__(self, item): 10 | return getattr(self, item) 11 | 12 | def model_dump(self, **kwargs): 13 | """Override model_dump to handle datetime serialization.""" 14 | kwargs.setdefault("mode", "json") 15 | return super().model_dump(**kwargs) 16 | 17 | 18 | class User(BaseApiModel): 19 | id: str 20 | name: str 21 | username: str 22 | followers_count: int 23 | following_count: int 24 | description: Optional[str] = None 25 | url: Optional[str] = None 26 | is_verified: bool = False 27 | is_business: bool = False 28 | 29 | @classmethod 30 | def from_api_response(cls, data: Dict) -> "User": 31 | legacy = data.get("legacy", {}) 32 | description = legacy.get("description") 33 | 34 | entities = legacy.get("entities", {}) 35 | if description and entities.get("description", {}).get("urls"): 36 | for url_data in entities["description"]["urls"]: 37 | description = description.replace( 38 | url_data["url"], url_data.get("expanded_url", "") 39 | ) 40 | 41 | is_verified = data.get("is_blue_verified", False) 42 | is_business = True if legacy.get("verified_type") == "Business" else False 43 | 44 | profile_url = "" 45 | if entities.get("url", {}).get("urls"): 46 | profile_url = ( 47 | entities.get("url", {}).get("urls", [{}])[0].get("expanded_url", "") 48 | ) 49 | 50 | return cls( 51 | id=data.get("rest_id", ""), 52 | name=legacy.get("name", ""), 53 | username=legacy.get("screen_name", ""), 54 | followers_count=legacy.get("followers_count", 0), 55 | following_count=legacy.get("friends_count", 0), 56 | description=description, 57 | url=profile_url, 58 | is_verified=is_verified, 59 | is_business=is_business, 60 | ) 61 | 62 | 63 | class Media(BaseApiModel): 64 | type: str # photo, video, etc. 65 | url: str 66 | expanded_url: Optional[str] = None 67 | preview_url: Optional[str] = None 68 | 69 | 70 | class URL(BaseApiModel): 71 | url: str 72 | expanded_url: Optional[str] = None 73 | 74 | 75 | class UserMention(BaseApiModel): 76 | id: str 77 | name: str 78 | screen_name: str 79 | 80 | 81 | class Tweet(BaseApiModel): 82 | id: str 83 | text: str 84 | created_at: int # Unix timestamp 85 | userid: str 86 | favorite_count: int 87 | retweet_count: int 88 | reply_count: int 89 | quote_count: int 90 | bookmark_count: int 91 | media: Optional[List[Media]] = None 92 | urls: Optional[List[URL]] = None 93 | user_mentions: Optional[List[UserMention]] = None 94 | is_retweet: bool = False 95 | retweet_status: Optional["Tweet"] = None 96 | 97 | @classmethod 98 | def from_api_response(cls, data: Dict[str, Any]) -> "Tweet": 99 | """Create a Tweet instance from API response data. 100 | 101 | Args: 102 | data: A dictionary containing tweet data from the API response. 103 | 104 | Returns: 105 | A Tweet object constructed from the provided data. 106 | """ 107 | 108 | # Get tweet result from either "tweet_results" or "tweetResult" 109 | if data.get("tweet_results") == {} or data.get("tweetResult") == {}: 110 | return 111 | 112 | tweet_result = data.get("tweet_results", {}).get("result") or data.get( 113 | "tweetResult", {} 114 | ).get("result", {}) 115 | 116 | # Handle visibility results and extract legacy data 117 | legacy = ( 118 | tweet_result["tweet"]["legacy"] 119 | if tweet_result.get("__typename") == "TweetWithVisibilityResults" 120 | else tweet_result.get("legacy", tweet_result) 121 | ) 122 | 123 | note_tweet = tweet_result.get("note_tweet", {}) 124 | 125 | # Get user ID from various possible locations 126 | userid = ( 127 | tweet_result.get("core", {}) 128 | .get("user_results", {}) 129 | .get("result", {}) 130 | .get("rest_id") 131 | or legacy.get("user_id_str") 132 | or tweet_result.get("user_id_str", "") 133 | ) 134 | 135 | # Get text content from various possible locations 136 | text = ( 137 | ( 138 | legacy.get("full_text") 139 | or tweet_result.get("full_text") 140 | or legacy.get("text", "") 141 | ) 142 | if note_tweet == {} 143 | else ( 144 | note_tweet.get("note_tweet_results", {}) 145 | .get("result", {}) 146 | .get("text", "") 147 | ) 148 | ) 149 | 150 | # Parse URL and user mentions 151 | urls = [] 152 | if "entities" in legacy and "urls" in legacy["entities"]: 153 | for url in legacy["entities"]["urls"]: 154 | urls.append( 155 | URL( 156 | url=url["url"], 157 | expanded_url=url.get("expanded_url"), 158 | ) 159 | ) 160 | 161 | user_mentions = [] 162 | if "entities" in legacy and "user_mentions" in legacy["entities"]: 163 | for mention in legacy["entities"]["user_mentions"]: 164 | user_mentions.append( 165 | UserMention( 166 | id=mention["id_str"], 167 | name=mention["name"], 168 | screen_name=mention["screen_name"], 169 | ) 170 | ) 171 | 172 | # Parse media data 173 | media_list = [] 174 | if "extended_entities" in legacy and "media" in legacy["extended_entities"]: 175 | for media_item in legacy["extended_entities"]["media"]: 176 | media_type = media_item.get("type", "photo") 177 | media = Media( 178 | type=media_type, 179 | url=media_item.get("url", ""), 180 | expanded_url=media_item.get("expanded_url", ""), 181 | preview_url=media_item.get("media_url_https", ""), 182 | ) 183 | media_list.append(media) 184 | 185 | # Parse retweet data 186 | is_retweet = "retweeted_status_result" in legacy 187 | retweet_status = None 188 | if is_retweet: 189 | retweet_data = legacy.get("retweeted_status_result", {}) 190 | retweet_status = cls.from_api_response( 191 | {"tweet_results": {"result": retweet_data.get("result", {})}} 192 | ) 193 | 194 | return cls( 195 | id=legacy.get("id_str") or tweet_result.get("id_str", ""), 196 | text=text, 197 | created_at=int( 198 | datetime.strptime( 199 | legacy.get("created_at") or tweet_result.get("created_at", ""), 200 | "%a %b %d %H:%M:%S %z %Y", 201 | ).timestamp() 202 | ), 203 | userid=userid, 204 | favorite_count=legacy.get("favorite_count", 0), 205 | retweet_count=legacy.get("retweet_count", 0), 206 | reply_count=legacy.get("reply_count", 0), 207 | quote_count=legacy.get("quote_count", 0), 208 | bookmark_count=legacy.get("bookmark_count", 0), 209 | media=media_list if media_list else None, 210 | urls=urls if urls else None, 211 | user_mentions=user_mentions if user_mentions else None, 212 | is_retweet=is_retweet, 213 | retweet_status=retweet_status, 214 | ) 215 | -------------------------------------------------------------------------------- /apidance/utils/__init__.py: -------------------------------------------------------------------------------- 1 | """Utility functions for apidance.""" 2 | 3 | from .markdown import parse_markdown_to_richtext 4 | 5 | __all__ = ["parse_markdown_to_richtext"] 6 | -------------------------------------------------------------------------------- /apidance/utils/markdown.py: -------------------------------------------------------------------------------- 1 | """Markdown parsing utilities.""" 2 | 3 | import re 4 | from dataclasses import dataclass 5 | from typing import List, Set, Tuple, Dict, Any 6 | 7 | 8 | @dataclass 9 | class Mark: 10 | start: int # Start position in original text 11 | end: int # End position in original text 12 | types: Set[str] # Formatting types (Bold/Italic) 13 | 14 | 15 | def parse_markdown_to_richtext(text: str) -> Tuple[str, List[Dict[str, Any]]]: 16 | """Parse markdown text to plain text and richtext tags. 17 | 18 | Args: 19 | text: Input text with markdown formatting 20 | 21 | Returns: 22 | Tuple of (plain text without markdown, list of richtext tags) 23 | Each tag contains: 24 | - from_index: start position in plain text 25 | - to_index: end position in plain text 26 | - richtext_types: list of formatting types (Bold/Italic) 27 | """ 28 | 29 | def find_marks(pattern: str, text: str, mark_type: str) -> List[Mark]: 30 | """Find all non-overlapping markdown marks in text. 31 | 32 | Args: 33 | pattern: Regex pattern to match markdown 34 | text: Input text 35 | mark_type: Type of formatting (Bold/Italic) 36 | 37 | Returns: 38 | List of Mark objects with positions and types 39 | """ 40 | marks = [] 41 | last_end = 0 42 | 43 | for match in re.finditer(pattern, text): 44 | if match.start() >= last_end: 45 | marks.append(Mark(match.start(), match.end(), {mark_type})) 46 | last_end = match.end() 47 | return marks 48 | 49 | # Find all markdown marks 50 | marks = [] 51 | 52 | # Bold: **text** or __text__ 53 | marks.extend( 54 | find_marks(r"(? None: 18 | """ 19 | Initialize the Twitter client with API key from environment variables. 20 | This is called once at server startup. 21 | """ 22 | global twitter_client 23 | 24 | # Get API key from environment 25 | api_key = os.getenv("APIDANCE_API_KEY") 26 | if not api_key: 27 | print("Warning: APIDANCE_API_KEY environment variable not set") 28 | return 29 | 30 | # Initialize the client 31 | try: 32 | twitter_client = TwitterClient(api_key=api_key) 33 | print("Twitter client initialized successfully") 34 | except Exception as e: 35 | print(f"Error initializing Twitter client: {str(e)}") 36 | 37 | 38 | @mcp.tool() 39 | async def create_tweet( 40 | text: str, reply_to_tweet_id: Optional[Union[int, str]] = None 41 | ) -> Dict[str, Any]: 42 | """ 43 | Create a new tweet or reply to an existing tweet. 44 | 45 | Args: 46 | text: The text content of the tweet 47 | reply_to_tweet_id: Optional tweet ID to reply to 48 | 49 | Returns: 50 | Dict containing the tweet ID and status 51 | """ 52 | if twitter_client is None: 53 | return { 54 | "success": False, 55 | "message": "Twitter client not initialized. Check APIDANCE_API_KEY environment variable.", 56 | } 57 | 58 | try: 59 | # Create the tweet 60 | tweet_id = twitter_client.create_tweet( 61 | text=text, reply_to_tweet_id=reply_to_tweet_id 62 | ) 63 | 64 | if tweet_id: 65 | return { 66 | "success": True, 67 | "tweet_id": tweet_id, 68 | "message": "Tweet created successfully", 69 | } 70 | else: 71 | return {"success": False, "message": "Failed to create tweet"} 72 | except Exception as e: 73 | return { 74 | "success": False, 75 | "error": str(e), 76 | "message": "An unexpected error occurred", 77 | } 78 | 79 | 80 | @mcp.tool() 81 | async def get_tweet_by_id(tweet_id: Union[int, str]) -> Dict[str, Any]: 82 | """ 83 | Get details about a specific tweet by its ID. 84 | 85 | Args: 86 | tweet_id: The ID of the tweet to retrieve 87 | 88 | Returns: 89 | Dict containing the tweet details 90 | """ 91 | if twitter_client is None: 92 | return { 93 | "success": False, 94 | "message": "Twitter client not initialized. Check APIDANCE_API_KEY environment variable.", 95 | } 96 | 97 | try: 98 | tweet = twitter_client.tweet_result_by_rest_id(tweet_id) 99 | 100 | if tweet: 101 | return {"success": True, "tweet": tweet.text} 102 | else: 103 | return {"success": False, "message": "Tweet not found"} 104 | except Exception as e: 105 | return { 106 | "success": False, 107 | "error": str(e), 108 | "message": "Failed to retrieve tweet", 109 | } 110 | 111 | 112 | @mcp.tool() 113 | async def search_tweets( 114 | query: str, product: str = "Latest", count: int = 20 115 | ) -> Dict[str, Any]: 116 | """ 117 | Search tweets to get information about specific topics or keywords. 118 | 119 | Args: 120 | query: Search query string, supports Twitter advanced search syntax 121 | product: Type of search results. One of: Top, Latest, People, Photos, Videos 122 | count: Number of results to return 123 | 124 | Returns: 125 | Dict containing the search results 126 | """ 127 | if twitter_client is None: 128 | return { 129 | "success": False, 130 | "message": "Twitter client not initialized. Check APIDANCE_API_KEY environment variable.", 131 | } 132 | 133 | try: 134 | # Search tweets 135 | search_results = twitter_client.search_timeline( 136 | query=query, product=product, count=count 137 | ) 138 | 139 | if search_results: 140 | # Extract only the text content from tweets for LLM processing 141 | tweets_text = [tweet.text for tweet in search_results] 142 | return { 143 | "success": True, 144 | "tweets": tweets_text, 145 | "count": len(tweets_text), 146 | "message": f"Found {len(tweets_text)} tweets matching query: {query}", 147 | } 148 | else: 149 | return { 150 | "success": True, 151 | "tweets": [], 152 | "count": 0, 153 | "message": f"No tweets found matching query: {query}", 154 | } 155 | except Exception as e: 156 | return {"success": False, "error": str(e), "message": "Failed to search tweets"} 157 | 158 | 159 | @mcp.tool() 160 | async def get_user_info(screen_name: str) -> Dict[str, Any]: 161 | """ 162 | Get detailed user information by screen name. 163 | 164 | Args: 165 | screen_name: Twitter username (without @ symbol) 166 | 167 | Returns: 168 | Dict containing user information 169 | """ 170 | if twitter_client is None: 171 | return { 172 | "success": False, 173 | "message": "Twitter client not initialized. Check APIDANCE_API_KEY environment variable.", 174 | } 175 | 176 | try: 177 | # Get user information 178 | user_info = twitter_client.get_user_by_screen_name(screen_name=screen_name) 179 | 180 | if user_info: 181 | return { 182 | "success": True, 183 | "user": user_info.model_dump(), 184 | "message": f"Successfully retrieved user information for @{screen_name}", 185 | } 186 | else: 187 | return {"success": False, "message": f"User @{screen_name} not found"} 188 | except Exception as e: 189 | return { 190 | "success": False, 191 | "error": str(e), 192 | "message": f"Failed to retrieve user information for @{screen_name}", 193 | } 194 | 195 | 196 | @mcp.tool() 197 | async def get_user_tweets( 198 | user_id: Union[str, int] = None, screen_name: str = None, count: int = 20 199 | ) -> Dict[str, Any]: 200 | """ 201 | Get tweets from a specific user. 202 | 203 | Args: 204 | user_id: User ID (either user_id or screen_name must be provided) 205 | screen_name: Username (either user_id or screen_name must be provided) 206 | count: Number of tweets to return 207 | 208 | Returns: 209 | Dict containing user tweets 210 | """ 211 | if twitter_client is None: 212 | return { 213 | "success": False, 214 | "message": "Twitter client not initialized. Check APIDANCE_API_KEY environment variable.", 215 | } 216 | 217 | try: 218 | # If screen_name is provided but not user_id, get the user_id first 219 | if screen_name and not user_id: 220 | user_info = twitter_client.get_user_by_screen_name(screen_name=screen_name) 221 | if not user_info: 222 | return {"success": False, "message": f"User @{screen_name} not found"} 223 | user_id = user_info.id 224 | 225 | # Validate we have a user_id 226 | if not user_id: 227 | return { 228 | "success": False, 229 | "message": "Either user_id or screen_name must be provided", 230 | } 231 | 232 | # Get user tweets 233 | tweets = twitter_client.get_user_tweets(user_id=user_id, count=count) 234 | 235 | if tweets: 236 | # Extract only the text content from tweets for LLM processing 237 | tweets_text = [tweet.text for tweet in tweets] 238 | return { 239 | "success": True, 240 | "tweets": tweets_text, 241 | "count": len(tweets_text), 242 | "user_id": user_id, 243 | "message": f"Found {len(tweets_text)} tweets for user ID: {user_id}", 244 | } 245 | else: 246 | return { 247 | "success": True, 248 | "tweets": [], 249 | "count": 0, 250 | "user_id": user_id, 251 | "message": f"No tweets found for user ID: {user_id}", 252 | } 253 | except Exception as e: 254 | return { 255 | "success": False, 256 | "error": str(e), 257 | "message": "Failed to retrieve user tweets", 258 | } 259 | 260 | 261 | @mcp.tool() 262 | async def create_note_tweet( 263 | text: str, 264 | use_richtext: bool = True, 265 | reply_to_tweet_id: Optional[Union[int, str]] = None, 266 | ) -> Dict[str, Any]: 267 | """ 268 | Create a new note tweet (long-form content). 269 | 270 | Args: 271 | text: The text content of the tweet (supports markdown if use_richtext=True) 272 | use_richtext: Whether to enable rich text formatting using markdown 273 | reply_to_tweet_id: Optional tweet ID to reply to 274 | 275 | Returns: 276 | Dict containing the tweet ID and status 277 | """ 278 | if twitter_client is None: 279 | return { 280 | "success": False, 281 | "message": "Twitter client not initialized. Check APIDANCE_API_KEY environment variable.", 282 | } 283 | 284 | try: 285 | # Create the note tweet 286 | tweet_id = twitter_client.create_note_tweet( 287 | text=text, use_richtext=use_richtext, reply_to_tweet_id=reply_to_tweet_id 288 | ) 289 | 290 | if tweet_id: 291 | return { 292 | "success": True, 293 | "tweet_id": tweet_id, 294 | "message": "Note tweet created successfully", 295 | } 296 | else: 297 | return {"success": False, "message": "Failed to create note tweet"} 298 | except Exception as e: 299 | return { 300 | "success": False, 301 | "error": str(e), 302 | "message": "An unexpected error occurred while creating note tweet", 303 | } 304 | 305 | 306 | @mcp.tool() 307 | async def get_list_tweets( 308 | list_id: Union[str, int], count: int = 20, include_promoted_content: bool = False 309 | ) -> Dict[str, Any]: 310 | """ 311 | Get latest tweets from a specific Twitter list. 312 | 313 | Args: 314 | list_id: ID of the Twitter list 315 | count: Number of tweets to return (default: 20) 316 | include_promoted_content: Include promoted content in results (default: False) 317 | 318 | Returns: 319 | Dict containing the list tweets 320 | """ 321 | if twitter_client is None: 322 | return { 323 | "success": False, 324 | "message": "Twitter client not initialized. Check APIDANCE_API_KEY environment variable.", 325 | } 326 | 327 | try: 328 | # Get tweets from the list 329 | list_tweets = twitter_client.get_list_latest_tweets( 330 | list_id=list_id, 331 | count=count, 332 | include_promoted_content=include_promoted_content, 333 | ) 334 | 335 | if list_tweets: 336 | # Extract only the text content from tweets for LLM processing 337 | tweets_text = [tweet.text for tweet in list_tweets] 338 | return { 339 | "success": True, 340 | "tweets": tweets_text, 341 | "count": len(tweets_text), 342 | "message": f"Found {len(tweets_text)} tweets in list {list_id}", 343 | } 344 | else: 345 | return { 346 | "success": True, 347 | "tweets": [], 348 | "count": 0, 349 | "message": f"No tweets found in list {list_id}", 350 | } 351 | except Exception as e: 352 | return { 353 | "success": False, 354 | "error": str(e), 355 | "message": f"Failed to retrieve tweets from list {list_id}", 356 | } 357 | 358 | 359 | if __name__ == "__main__": 360 | # Initialize the Twitter client 361 | initialize_client() 362 | 363 | # Initialize and run the server 364 | print("Starting Twitter MCP server...") 365 | mcp.run(transport="stdio") 366 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "apidance" 3 | version = "0.6.3" 4 | description = "https://apidance.pro/ community python sdk" 5 | authors = [{ name = "script-money", email = "hello@script.money" }] 6 | license = { text = "MIT" } 7 | readme = "README.md" 8 | requires-python = ">=3.10" 9 | 10 | dependencies = [ 11 | "httpx>=0.25.2", 12 | "mcp[cli]>=1.3.0", 13 | "python-dotenv>=1.0.0", 14 | "pydantic>=2.0.0", 15 | ] 16 | 17 | [dependency-groups] 18 | dev = [ 19 | "pytest>=8.3.4", 20 | "ruff>=0.9.9", 21 | ] 22 | -------------------------------------------------------------------------------- /tests/test_rich.json: -------------------------------------------------------------------------------- 1 | { 2 | "variables": { 3 | "tweet_text": "This is a test tweet to check the length, formatting, and overall look of a longer tweet. I want to see how it appears on different devices and if there are any unexpected line breaks or display issues.\n\nI'm also testing the use of hashtags and mentions. Will they work correctly and improve discoverability? Let's find out! #TestTweet @YourAccount\n\nThis tweet includes a question to encourage engagement. Are people more likely to respond or interact with a longer tweet that asks a question?\n\nFinally, I'm adding a few more sentences to reach the desired word count and see how the tweet handles a variety of sentence lengths and structures. What do you think of longer-form content on Twitter? Does it have a place? Let me know your thoughts! Testing, testing, 1, 2, 3! Just a few more words now. This is the end.", 4 | "reply": { 5 | "in_reply_to_tweet_id": "1859428851736248469", 6 | "exclude_reply_user_ids": [] 7 | }, 8 | "dark_request": false, 9 | "media": { 10 | "media_entities": [], 11 | "possibly_sensitive": false 12 | }, 13 | "semantic_annotation_ids": [], 14 | "richtext_options": { 15 | "richtext_tags": [ 16 | { 17 | "from_index": 292, 18 | "to_index": 307, 19 | "richtext_types": [ 20 | "Italic" 21 | ] 22 | }, 23 | { 24 | "from_index": 384, 25 | "to_index": 404, 26 | "richtext_types": [ 27 | "Bold" 28 | ] 29 | } 30 | ] 31 | }, 32 | "disallowed_reply_options": null 33 | } 34 | } -------------------------------------------------------------------------------- /tests/test_richtext.py: -------------------------------------------------------------------------------- 1 | import json 2 | from apidance.utils import parse_markdown_to_richtext 3 | 4 | 5 | def load_test_data(): 6 | """Load test data from json file""" 7 | with open("./tests/test_rich.json", "r") as f: 8 | data = json.load(f) 9 | return data 10 | 11 | 12 | def test_parse_markdown_to_richtext(): 13 | """Test markdown to richtext parsing with real-world examples""" 14 | test_cases = [ 15 | ( 16 | """This is a test tweet to check the length, formatting, and overall look of a longer tweet. I want to see how it appears on different devices and if there are any unexpected line breaks or display issues. 17 | 18 | I'm also testing the use of hashtags and mentions. Will they work correctly and improve *discoverability*? Let's find out! #TestTweet @YourAccount 19 | 20 | This tweet includes a question to encourage engagement. Are people more likely to respond or interact with a longer tweet that asks a question? 21 | 22 | Finally, I'm adding a few more sentences to reach the desired word count and see how the tweet handles a variety of sentence lengths and structures. What do you think of **longer-form content** on Twitter? Does it have a place? Let me know your thoughts! Testing, testing, 1, 2, 3! Just a few more words now. This is the end.""", 23 | [ 24 | {"from_index": 292, "to_index": 307, "richtext_types": ["Italic"]}, 25 | {"from_index": 665, "to_index": 684, "richtext_types": ["Bold"]}, 26 | ], 27 | ), 28 | ] 29 | 30 | for markdown_text, expected_tags in test_cases: 31 | plain_text, actual_tags = parse_markdown_to_richtext(markdown_text) 32 | 33 | # Sort both lists to ensure consistent comparison 34 | expected_tags = sorted( 35 | expected_tags, 36 | key=lambda x: ( 37 | x["from_index"], 38 | x["to_index"], 39 | tuple(sorted(x["richtext_types"])), 40 | ), 41 | ) 42 | actual_tags = sorted( 43 | actual_tags, 44 | key=lambda x: ( 45 | x["from_index"], 46 | x["to_index"], 47 | tuple(sorted(x["richtext_types"])), 48 | ), 49 | ) 50 | 51 | assert len(actual_tags) == len( 52 | expected_tags 53 | ), f"Expected {len(expected_tags)} tags, got {len(actual_tags)} for text: {markdown_text}" 54 | 55 | for actual, expected in zip(actual_tags, expected_tags): 56 | assert actual == expected, f"Expected {expected}, got {actual}" 57 | 58 | 59 | def test_edge_cases(): 60 | """Test edge cases for markdown parsing""" 61 | test_cases = [ 62 | # 63 | ( 64 | "This is *italic* **bold**", 65 | [ 66 | {"from_index": 8, "to_index": 14, "richtext_types": ["Italic"]}, 67 | {"from_index": 15, "to_index": 19, "richtext_types": ["Bold"]}, 68 | ], 69 | ), 70 | # 71 | ( 72 | "This is a dis*cover*ability test", 73 | [ 74 | {"from_index": 13, "to_index": 18, "richtext_types": ["Italic"]}, 75 | ], 76 | ), 77 | ] 78 | 79 | for markdown_text, expected_tags in test_cases: 80 | plain_text, actual_tags = parse_markdown_to_richtext(markdown_text) 81 | 82 | # Sort both lists to ensure consistent comparison 83 | expected_tags = sorted( 84 | expected_tags, 85 | key=lambda x: ( 86 | x["from_index"], 87 | x["to_index"], 88 | tuple(sorted(x["richtext_types"])), 89 | ), 90 | ) 91 | actual_tags = sorted( 92 | actual_tags, 93 | key=lambda x: ( 94 | x["from_index"], 95 | x["to_index"], 96 | tuple(sorted(x["richtext_types"])), 97 | ), 98 | ) 99 | 100 | assert len(actual_tags) == len( 101 | expected_tags 102 | ), f"Expected {len(expected_tags)} tags, got {len(actual_tags)} for text: {markdown_text}" 103 | 104 | for actual, expected in zip(actual_tags, expected_tags): 105 | assert actual == expected, f"Expected {expected}, got {actual}" 106 | --------------------------------------------------------------------------------