tapline 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tapline-0.1.0/.gitignore +15 -0
- tapline-0.1.0/LICENSE +21 -0
- tapline-0.1.0/PKG-INFO +513 -0
- tapline-0.1.0/README.md +483 -0
- tapline-0.1.0/examples/__init__.py +0 -0
- tapline-0.1.0/examples/youtube/__init__.py +0 -0
- tapline-0.1.0/examples/youtube/download_channel_transcripts.py +74 -0
- tapline-0.1.0/examples/youtube/download_video_comments.py +69 -0
- tapline-0.1.0/pyproject.toml +143 -0
- tapline-0.1.0/scripts/capture_fixtures.py +337 -0
- tapline-0.1.0/scripts/validate_live.py +245 -0
- tapline-0.1.0/src/tapline/__init__.py +95 -0
- tapline-0.1.0/src/tapline/_base_client.py +534 -0
- tapline-0.1.0/src/tapline/_client.py +163 -0
- tapline-0.1.0/src/tapline/_constants.py +33 -0
- tapline-0.1.0/src/tapline/_exceptions.py +356 -0
- tapline-0.1.0/src/tapline/_models.py +32 -0
- tapline-0.1.0/src/tapline/_pagination.py +34 -0
- tapline-0.1.0/src/tapline/_resource.py +153 -0
- tapline-0.1.0/src/tapline/_types.py +47 -0
- tapline-0.1.0/src/tapline/_version.py +2 -0
- tapline-0.1.0/src/tapline/py.typed +0 -0
- tapline-0.1.0/src/tapline/resources/__init__.py +7 -0
- tapline-0.1.0/src/tapline/resources/youtube.py +1027 -0
- tapline-0.1.0/src/tapline/youtube/__init__.py +147 -0
- tapline-0.1.0/src/tapline/youtube/channel.py +51 -0
- tapline-0.1.0/src/tapline/youtube/comment.py +86 -0
- tapline-0.1.0/src/tapline/youtube/format.py +108 -0
- tapline-0.1.0/src/tapline/youtube/heatmap.py +27 -0
- tapline-0.1.0/src/tapline/youtube/json3.py +79 -0
- tapline-0.1.0/src/tapline/youtube/playlist.py +49 -0
- tapline-0.1.0/src/tapline/youtube/request_enums.py +185 -0
- tapline-0.1.0/src/tapline/youtube/response_enums.py +166 -0
- tapline-0.1.0/src/tapline/youtube/search.py +58 -0
- tapline-0.1.0/src/tapline/youtube/subtitle.py +76 -0
- tapline-0.1.0/src/tapline/youtube/thumbnail.py +22 -0
- tapline-0.1.0/src/tapline/youtube/video.py +161 -0
- tapline-0.1.0/tests/conftest.py +186 -0
- tapline-0.1.0/tests/fixtures/openapi/youtube.json +779 -0
- tapline-0.1.0/tests/fixtures/youtube/channel_by_handle.json +64 -0
- tapline-0.1.0/tests/fixtures/youtube/channel_by_id.json +74 -0
- tapline-0.1.0/tests/fixtures/youtube/channel_videos.json +185 -0
- tapline-0.1.0/tests/fixtures/youtube/channel_videos_shorts.json +130 -0
- tapline-0.1.0/tests/fixtures/youtube/channel_videos_streams.json +185 -0
- tapline-0.1.0/tests/fixtures/youtube/comment_replies.json +164 -0
- tapline-0.1.0/tests/fixtures/youtube/comments.json +210 -0
- tapline-0.1.0/tests/fixtures/youtube/formats.json +1126 -0
- tapline-0.1.0/tests/fixtures/youtube/formats_live.json +198 -0
- tapline-0.1.0/tests/fixtures/youtube/heatmap.json +505 -0
- tapline-0.1.0/tests/fixtures/youtube/manifest.json +156 -0
- tapline-0.1.0/tests/fixtures/youtube/metadata.json +331 -0
- tapline-0.1.0/tests/fixtures/youtube/metadata_fields.json +57 -0
- tapline-0.1.0/tests/fixtures/youtube/metadata_heatmap.json +783 -0
- tapline-0.1.0/tests/fixtures/youtube/metadata_live.json +301 -0
- tapline-0.1.0/tests/fixtures/youtube/playlist.json +215 -0
- tapline-0.1.0/tests/fixtures/youtube/search.json +217 -0
- tapline-0.1.0/tests/fixtures/youtube/search_filtered.json +141 -0
- tapline-0.1.0/tests/fixtures/youtube/search_movies.json +141 -0
- tapline-0.1.0/tests/fixtures/youtube/subtitle_tracks.json +1747 -0
- tapline-0.1.0/tests/fixtures/youtube/subtitles_json3.json +78 -0
- tapline-0.1.0/tests/fixtures/youtube/subtitles_json3_auto.json +1235 -0
- tapline-0.1.0/tests/fixtures/youtube/subtitles_srt.json +11 -0
- tapline-0.1.0/tests/spec.py +135 -0
- tapline-0.1.0/tests/test_channel_transcripts_example.py +116 -0
- tapline-0.1.0/tests/test_client.py +462 -0
- tapline-0.1.0/tests/test_errors.py +358 -0
- tapline-0.1.0/tests/test_example_logging.py +27 -0
- tapline-0.1.0/tests/test_fixture_payloads.py +99 -0
- tapline-0.1.0/tests/test_live.py +352 -0
- tapline-0.1.0/tests/test_models.py +491 -0
- tapline-0.1.0/tests/test_pagination.py +156 -0
- tapline-0.1.0/tests/test_retries.py +368 -0
- tapline-0.1.0/tests/test_video_comments_example.py +109 -0
- tapline-0.1.0/tests/test_youtube_params.py +656 -0
tapline-0.1.0/.gitignore
ADDED
tapline-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tapline
|
|
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.
|
tapline-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tapline
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The official Python client for the Tapline API
|
|
5
|
+
Project-URL: Homepage, https://tapline.sh
|
|
6
|
+
Project-URL: Documentation, https://tapline.sh/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/TaplineData/tapline-python
|
|
8
|
+
Project-URL: Issues, https://github.com/TaplineData/tapline-python/issues
|
|
9
|
+
Author-email: Tapline <support@tapline.sh>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: api,client,scraping,sdk,tapline,youtube
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Requires-Dist: httpx<1,>=0.25
|
|
27
|
+
Requires-Dist: pydantic<3,>=2.7
|
|
28
|
+
Requires-Dist: typing-extensions<5,>=4.10
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
[](https://tapline.sh)
|
|
32
|
+
|
|
33
|
+
# tapline
|
|
34
|
+
|
|
35
|
+
[](https://pypi.org/project/tapline/)
|
|
36
|
+
[](https://pypi.org/project/tapline/)
|
|
37
|
+
|
|
38
|
+
Tapline is a Python package for working with public YouTube data. It downloads
|
|
39
|
+
transcripts and provides data about videos, channels, searches, comments, and
|
|
40
|
+
playlists.
|
|
41
|
+
|
|
42
|
+
You will need Python 3.10 or newer and a Tapline API key. You can get a key at
|
|
43
|
+
[tapline.sh](https://tapline.sh).
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
pip install tapline
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Set your API key as an environment variable:
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
export TAPLINE_API_KEY="your-api-key"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The examples below read the key from that variable automatically.
|
|
58
|
+
|
|
59
|
+
You can also pass the key when you create the client:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from tapline import SyncTaplineClient
|
|
63
|
+
|
|
64
|
+
tapline = SyncTaplineClient(api_key="your-api-key")
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`SyncTaplineClient` blocks. `TaplineClient` is its awaitable twin, for an async
|
|
68
|
+
application:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from tapline import TaplineClient
|
|
72
|
+
|
|
73
|
+
tapline = TaplineClient(api_key="your-api-key")
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Client methods
|
|
77
|
+
|
|
78
|
+
| Method | What you get | Credits |
|
|
79
|
+
| --- | --- | --- |
|
|
80
|
+
| `search(query=...)` | Search results | 2 |
|
|
81
|
+
| `metadata(video)` | Title, counts, dates, chapters, thumbnails | 2 |
|
|
82
|
+
| `subtitles(video)` | A transcript document | 2 |
|
|
83
|
+
| `subtitle_tracks(video)` | The languages a video has captions in | 2 |
|
|
84
|
+
| `comments(video)` | One page of comment threads | 2 |
|
|
85
|
+
| `comment_replies(video, comment_id, cursor=...)` | One page of replies | 2 |
|
|
86
|
+
| `channel(channel)` | A channel's profile and subscriber count | 3 |
|
|
87
|
+
| `channel_videos(channel)` | One page of a channel's uploads | 2 |
|
|
88
|
+
| `playlist(playlist_id)` | A playlist and its videos | 2 |
|
|
89
|
+
| `heatmap(video)` | Which parts of a video get replayed | 2 |
|
|
90
|
+
| `formats(video)` | The streams YouTube offers for a video | 2 |
|
|
91
|
+
|
|
92
|
+
Every method is on both the sync and async clients, and takes an optional
|
|
93
|
+
`timeout` that overrides the client's own for that one call.
|
|
94
|
+
|
|
95
|
+
## Get a transcript
|
|
96
|
+
|
|
97
|
+
Use `SyncTaplineClient` for a regular Python script:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from tapline import SyncTaplineClient
|
|
101
|
+
|
|
102
|
+
with SyncTaplineClient() as tapline:
|
|
103
|
+
subtitles = tapline.youtube.subtitles(
|
|
104
|
+
"https://www.youtube.com/watch?v=jNQXAC9IVRw",
|
|
105
|
+
subtitle_format="txt",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
print(subtitles.transcript)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The default language is English. Tapline accepts BCP 47 language tags such as
|
|
112
|
+
`en`, `pt-BR`, `es-419`, and `zh-Hans`:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
subtitles = tapline.youtube.subtitles(
|
|
116
|
+
"jNQXAC9IVRw",
|
|
117
|
+
language="pt-BR",
|
|
118
|
+
subtitle_format="txt",
|
|
119
|
+
)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Tapline can also return `srt`, `vtt`, `ttml`, or `json3` transcripts.
|
|
123
|
+
|
|
124
|
+
The default `source="any"` prefers a human-authored track and falls back to a
|
|
125
|
+
machine-generated one. Pass `source="manual"` or `source="auto"` to insist on
|
|
126
|
+
one or the other.
|
|
127
|
+
|
|
128
|
+
## List subtitle languages
|
|
129
|
+
|
|
130
|
+
If you do not know which languages a video has, check its subtitle tracks:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from tapline import SyncTaplineClient
|
|
134
|
+
|
|
135
|
+
with SyncTaplineClient() as tapline:
|
|
136
|
+
tracks = tapline.youtube.subtitle_tracks("jNQXAC9IVRw")
|
|
137
|
+
|
|
138
|
+
for track in tracks.manual + tracks.auto:
|
|
139
|
+
print(track.language, track.language_name)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Each track's `language` and `formats` feed straight back into `subtitles`.
|
|
143
|
+
|
|
144
|
+
## Get every transcript from a channel
|
|
145
|
+
|
|
146
|
+
`pages` walks a channel's uploads for you, so a whole channel is one loop over
|
|
147
|
+
another — see [walking pages](#walk-every-page):
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
from tapline import NotFoundError, SyncTaplineClient
|
|
151
|
+
|
|
152
|
+
with SyncTaplineClient() as tapline:
|
|
153
|
+
pages = tapline.youtube.pages(
|
|
154
|
+
lambda cursor: tapline.youtube.channel_videos("@Computerphile2", cursor=cursor)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
for page in pages:
|
|
158
|
+
for video in page.videos:
|
|
159
|
+
try:
|
|
160
|
+
subtitles = tapline.youtube.subtitles(video.video_id, subtitle_format="txt")
|
|
161
|
+
except NotFoundError:
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
print(video.title)
|
|
165
|
+
print(subtitles.transcript)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
A video with no English track raises `NotFoundError`, which is why the loop
|
|
169
|
+
skips it rather than stopping. Pass `content_type="shorts"` or
|
|
170
|
+
`content_type="streams"` to walk those tabs instead.
|
|
171
|
+
|
|
172
|
+
## Look up a video
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
from tapline import SyncTaplineClient
|
|
176
|
+
|
|
177
|
+
with SyncTaplineClient() as tapline:
|
|
178
|
+
video = tapline.youtube.metadata("https://youtu.be/dQw4w9WgXcQ")
|
|
179
|
+
|
|
180
|
+
print(video.title)
|
|
181
|
+
print(video.view_count)
|
|
182
|
+
print(video.duration)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
The full record also carries the description, tags, chapters, thumbnails,
|
|
186
|
+
upload date, channel details, and live status. Ask for a subset with `fields`
|
|
187
|
+
when you only need part of it:
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
video = tapline.youtube.metadata("dQw4w9WgXcQ", fields=["title", "view_count"])
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Under a projection the keys you did not ask for are absent rather than null, so
|
|
194
|
+
use `video.model_fields_set` to tell them apart from fields the server sent as
|
|
195
|
+
`None`. `video_id` always comes back.
|
|
196
|
+
|
|
197
|
+
## Search YouTube
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
from tapline import SyncTaplineClient
|
|
201
|
+
|
|
202
|
+
with SyncTaplineClient() as tapline:
|
|
203
|
+
results = tapline.youtube.search(
|
|
204
|
+
query="learn python",
|
|
205
|
+
limit=5,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
for result in results.results:
|
|
209
|
+
print(result.title)
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Searches can be narrowed by upload date, duration, result type, country, and
|
|
213
|
+
features such as subtitles or 4K video:
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
results = tapline.youtube.search(
|
|
217
|
+
query="learn python",
|
|
218
|
+
sort="view_count",
|
|
219
|
+
upload_date="this_year",
|
|
220
|
+
search_type="video",
|
|
221
|
+
duration="over_20_min",
|
|
222
|
+
features=["hd", "subtitles"],
|
|
223
|
+
country="BR",
|
|
224
|
+
)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## Get comments
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
from tapline import SyncTaplineClient
|
|
231
|
+
|
|
232
|
+
with SyncTaplineClient() as tapline:
|
|
233
|
+
comments = tapline.youtube.comments("dQw4w9WgXcQ", sort="new")
|
|
234
|
+
|
|
235
|
+
for thread in comments.threads:
|
|
236
|
+
print(thread.comment.author, thread.comment.text, thread.comment.like_count)
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Comments come back one page at a time, twenty threads at most. Sort by `top`
|
|
240
|
+
(the default) or `new`; pinned threads surface first either way. To read every
|
|
241
|
+
page, see [walking pages](#walk-every-page).
|
|
242
|
+
|
|
243
|
+
## Get replies to a comment
|
|
244
|
+
|
|
245
|
+
Each thread carries a `replies_cursor`, which is where its replies start. It is
|
|
246
|
+
`None` when the comment has no replies:
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
from tapline import SyncTaplineClient
|
|
250
|
+
|
|
251
|
+
with SyncTaplineClient() as tapline:
|
|
252
|
+
comments = tapline.youtube.comments("dQw4w9WgXcQ")
|
|
253
|
+
thread = comments.threads[0]
|
|
254
|
+
|
|
255
|
+
if thread.replies_cursor:
|
|
256
|
+
replies = tapline.youtube.comment_replies(
|
|
257
|
+
"dQw4w9WgXcQ",
|
|
258
|
+
thread.comment.comment_id,
|
|
259
|
+
cursor=thread.replies_cursor,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
for reply in replies.replies:
|
|
263
|
+
print(reply.author, reply.text)
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
## List a channel's videos
|
|
267
|
+
|
|
268
|
+
```python
|
|
269
|
+
from tapline import SyncTaplineClient
|
|
270
|
+
|
|
271
|
+
with SyncTaplineClient() as tapline:
|
|
272
|
+
uploads = tapline.youtube.channel_videos("@Computerphile2")
|
|
273
|
+
|
|
274
|
+
for video in uploads.videos:
|
|
275
|
+
print(video.title, video.view_count, video.url)
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Pass `content_type="shorts"` or `content_type="streams"` for the other two
|
|
279
|
+
tabs. Like comments, uploads arrive one page at a time.
|
|
280
|
+
|
|
281
|
+
## Look up a channel
|
|
282
|
+
|
|
283
|
+
```python
|
|
284
|
+
from tapline import SyncTaplineClient
|
|
285
|
+
|
|
286
|
+
with SyncTaplineClient() as tapline:
|
|
287
|
+
channel = tapline.youtube.channel("@Computerphile2")
|
|
288
|
+
|
|
289
|
+
print(channel.channel)
|
|
290
|
+
print(channel.handle)
|
|
291
|
+
print(channel.channel_follower_count)
|
|
292
|
+
print(channel.description)
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
## Get a playlist
|
|
296
|
+
|
|
297
|
+
Playlists are looked up by ID — the `list=` parameter of a playlist URL, not the
|
|
298
|
+
URL itself:
|
|
299
|
+
|
|
300
|
+
```python
|
|
301
|
+
from tapline import SyncTaplineClient
|
|
302
|
+
|
|
303
|
+
with SyncTaplineClient() as tapline:
|
|
304
|
+
playlist = tapline.youtube.playlist("PLbpi6ZahtOH6Blw3RGYpWkSByi_T7Rygb", limit=100)
|
|
305
|
+
|
|
306
|
+
print(playlist.title, playlist.total_count)
|
|
307
|
+
|
|
308
|
+
for entry in playlist.entries:
|
|
309
|
+
print(entry.title, entry.url)
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
`limit` caps how many entries come back, up to 100. `total_count` reports how
|
|
313
|
+
many the playlist actually holds.
|
|
314
|
+
|
|
315
|
+
## Find the most replayed moments
|
|
316
|
+
|
|
317
|
+
YouTube's heatmap scores a hundred equal slices of the timeline by replay
|
|
318
|
+
intensity, from `0.0` to `1.0`:
|
|
319
|
+
|
|
320
|
+
```python
|
|
321
|
+
from tapline import SyncTaplineClient
|
|
322
|
+
|
|
323
|
+
with SyncTaplineClient() as tapline:
|
|
324
|
+
heatmap = tapline.youtube.heatmap("dQw4w9WgXcQ")
|
|
325
|
+
|
|
326
|
+
if heatmap.heatmap:
|
|
327
|
+
peak = max(heatmap.heatmap, key=lambda point: point.value)
|
|
328
|
+
print(f"Most replayed at {peak.start_time:.0f}s")
|
|
329
|
+
|
|
330
|
+
for point in heatmap.heatmap:
|
|
331
|
+
print(point.start_time, point.end_time, point.value)
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
`heatmap` is `None` for the many videos YouTube publishes no heatmap for, which
|
|
335
|
+
is not an error. The same points also arrive on `metadata` under `heatmap`.
|
|
336
|
+
|
|
337
|
+
## List stream formats
|
|
338
|
+
|
|
339
|
+
```python
|
|
340
|
+
from tapline import SyncTaplineClient
|
|
341
|
+
|
|
342
|
+
with SyncTaplineClient() as tapline:
|
|
343
|
+
formats = tapline.youtube.formats("dQw4w9WgXcQ")
|
|
344
|
+
|
|
345
|
+
for stream in formats.formats:
|
|
346
|
+
print(stream.format_id, stream.resolution, stream.ext, stream.filesize)
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Each `url` is signed by YouTube and expires within hours, so fetch it when you
|
|
350
|
+
are ready to use it. Storyboard formats are left out.
|
|
351
|
+
|
|
352
|
+
## Walk every page
|
|
353
|
+
|
|
354
|
+
`comments`, `comment_replies`, and `channel_videos` return one page per call.
|
|
355
|
+
`pages` walks one of them to its end, requesting the next page only as you ask
|
|
356
|
+
for it:
|
|
357
|
+
|
|
358
|
+
```python
|
|
359
|
+
from tapline import SyncTaplineClient
|
|
360
|
+
|
|
361
|
+
with SyncTaplineClient() as tapline:
|
|
362
|
+
pages = tapline.youtube.pages(
|
|
363
|
+
lambda cursor: tapline.youtube.comments("dQw4w9WgXcQ", cursor=cursor)
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
for page in pages:
|
|
367
|
+
for thread in page.threads:
|
|
368
|
+
print(thread.comment.text)
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
Each page is its own request, so a walk is billed per page rather than once.
|
|
372
|
+
Replies have no first page without a cursor, so start that walk from a thread's
|
|
373
|
+
`replies_cursor`:
|
|
374
|
+
|
|
375
|
+
```python
|
|
376
|
+
reply_pages = tapline.youtube.pages(
|
|
377
|
+
lambda cursor: tapline.youtube.comment_replies(video_id, comment_id, cursor=cursor),
|
|
378
|
+
cursor=thread.replies_cursor,
|
|
379
|
+
)
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
A walk ends when a page's `pagination.next_cursor` is `None`. That page's
|
|
383
|
+
`pagination.completion` says why: `exhausted` when YouTube ran out, or
|
|
384
|
+
`depth_limit` when Tapline hit its paging cap — sorting comments by `top` stops
|
|
385
|
+
at roughly 1,200 of them.
|
|
386
|
+
|
|
387
|
+
## Use the async client
|
|
388
|
+
|
|
389
|
+
For an async application, use `TaplineClient` and `await`:
|
|
390
|
+
|
|
391
|
+
```python
|
|
392
|
+
import asyncio
|
|
393
|
+
|
|
394
|
+
from tapline import TaplineClient
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
async def main() -> None:
|
|
398
|
+
async with TaplineClient() as tapline:
|
|
399
|
+
video = await tapline.youtube.metadata("dQw4w9WgXcQ")
|
|
400
|
+
print(video.title)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
asyncio.run(main())
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
The sync and async clients have the same YouTube methods. `pages` becomes an
|
|
407
|
+
async generator, driven with `async for`, and calls can be in flight together:
|
|
408
|
+
|
|
409
|
+
```python
|
|
410
|
+
async with TaplineClient() as tapline:
|
|
411
|
+
video, comments = await asyncio.gather(
|
|
412
|
+
tapline.youtube.metadata("dQw4w9WgXcQ"),
|
|
413
|
+
tapline.youtube.comments("dQw4w9WgXcQ"),
|
|
414
|
+
)
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
## IDs, handles, and URLs
|
|
418
|
+
|
|
419
|
+
You can pass a video ID or a common YouTube video URL wherever a video is
|
|
420
|
+
needed:
|
|
421
|
+
|
|
422
|
+
```python
|
|
423
|
+
"dQw4w9WgXcQ"
|
|
424
|
+
|
|
425
|
+
"https://youtu.be/dQw4w9WgXcQ"
|
|
426
|
+
"https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
For channels, you can use a channel ID, handle, or URL:
|
|
430
|
+
|
|
431
|
+
```python
|
|
432
|
+
"UCXuqSBlHAE6Xw-yeJA0Tunw"
|
|
433
|
+
|
|
434
|
+
"@LinusTechTips"
|
|
435
|
+
"https://www.youtube.com/@LinusTechTips"
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
## Enums and responses
|
|
439
|
+
|
|
440
|
+
Anywhere a method takes an enum it also takes that enum's value as a plain
|
|
441
|
+
string, so these are the same call:
|
|
442
|
+
|
|
443
|
+
```python
|
|
444
|
+
from tapline.youtube import SearchSort
|
|
445
|
+
|
|
446
|
+
tapline.youtube.search(query="learn python", sort=SearchSort.VIEW_COUNT)
|
|
447
|
+
tapline.youtube.search(query="learn python", sort="view_count")
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
Every response is a pydantic model, so `model_dump()` gives you a dict and
|
|
451
|
+
`model_dump_json()` gives you JSON. Fields a newer Tapline release adds are
|
|
452
|
+
kept rather than dropped — read them from `model_extra`.
|
|
453
|
+
|
|
454
|
+
## Handle errors
|
|
455
|
+
|
|
456
|
+
```python
|
|
457
|
+
from tapline import NotFoundError, PermissionDeniedError, SyncTaplineClient
|
|
458
|
+
|
|
459
|
+
with SyncTaplineClient() as tapline:
|
|
460
|
+
try:
|
|
461
|
+
video = tapline.youtube.metadata("dQw4w9WgXcQ")
|
|
462
|
+
except NotFoundError:
|
|
463
|
+
print("no such video")
|
|
464
|
+
except PermissionDeniedError as error:
|
|
465
|
+
print("not public:", error.message, error.request_id)
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
Everything the library raises is a `TaplineError`. A failed request is an
|
|
469
|
+
`APIError`; a non-2xx answer is an `APIStatusError` carrying `status_code`,
|
|
470
|
+
`code`, `message`, `request_id`, and `details`. The ones you are most likely to
|
|
471
|
+
catch by name:
|
|
472
|
+
|
|
473
|
+
| Exception | When |
|
|
474
|
+
| --- | --- |
|
|
475
|
+
| `NotFoundError` | No such video, channel, playlist, or subtitle track |
|
|
476
|
+
| `PermissionDeniedError` | Private, age-gated, members-only, or region-blocked |
|
|
477
|
+
| `InvalidCursorError` | A cursor is malformed, expired, or from another walk |
|
|
478
|
+
| `UnprocessableEntityError` | A parameter failed server-side validation |
|
|
479
|
+
| `AuthenticationError` | The API key is missing or invalid |
|
|
480
|
+
| `InsufficientCreditsError` | The account cannot pay for the request |
|
|
481
|
+
| `RateLimitError` | Too many requests, after retries were exhausted |
|
|
482
|
+
| `APITimeoutError` | The request outlived its timeout, including retries |
|
|
483
|
+
|
|
484
|
+
## Client settings
|
|
485
|
+
|
|
486
|
+
```python
|
|
487
|
+
from tapline import SyncTaplineClient
|
|
488
|
+
|
|
489
|
+
tapline = SyncTaplineClient(
|
|
490
|
+
api_key="your-api-key",
|
|
491
|
+
timeout=120.0,
|
|
492
|
+
max_retries=5,
|
|
493
|
+
default_headers={"X-Trace-Id": "trace-1"},
|
|
494
|
+
)
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
The default timeout is 60 seconds, 10 of them for connecting, because every
|
|
498
|
+
endpoint proxies YouTube live. Rate limits, 5xx answers, and connection
|
|
499
|
+
failures are retried twice by default, honoring `Retry-After`; pass
|
|
500
|
+
`max_retries=0` to disable that.
|
|
501
|
+
|
|
502
|
+
The client owns a connection pool, so build one and keep it. Close it with
|
|
503
|
+
`tapline.close()`, or scope it to a `with` block as the examples do. To send
|
|
504
|
+
through your own proxies, transports, or connection limits, pass an
|
|
505
|
+
`http_client` — an `httpx.Client` for the sync client, an `httpx.AsyncClient`
|
|
506
|
+
for the async one.
|
|
507
|
+
|
|
508
|
+
`base_url` points the client at a proxy or a local server, and can also be set
|
|
509
|
+
with the `TAPLINE_BASE_URL` environment variable.
|
|
510
|
+
|
|
511
|
+
## License
|
|
512
|
+
|
|
513
|
+
MIT. See [LICENSE](./LICENSE).
|