ttgrep 0.5.1__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.
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .DS_Store
8
+ uv.lock
ttgrep-0.5.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Louis de Benoist
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.
ttgrep-0.5.1/PKG-INFO ADDED
@@ -0,0 +1,241 @@
1
+ Metadata-Version: 2.5
2
+ Name: ttgrep
3
+ Version: 0.5.1
4
+ Summary: grep for TikTok. Your AI agent syncs whole accounts, gets every word as text, and searches it. Free, local, cached forever.
5
+ Project-URL: Repository, https://github.com/Louis2B2G/ttgrep
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: faster-whisper>=1.0.0; sys_platform != 'darwin' or platform_machine != 'arm64'
10
+ Requires-Dist: mcp>=1.2
11
+ Requires-Dist: mlx-whisper>=0.4.0; sys_platform == 'darwin' and platform_machine == 'arm64'
12
+ Requires-Dist: yt-dlp[curl-cffi]>=2026.8.19
13
+ Description-Content-Type: text/markdown
14
+
15
+ # ttgrep
16
+
17
+ grep for TikTok.
18
+
19
+ TikTok has no public API, no transcripts, and no text search. ttgrep fixes that. It downloads everything an account has posted, turns every video into text, and lets you search all of it. Captions come from TikTok when they exist. When they don't, Whisper runs on your machine. Everything is saved to disk and never fetched twice. Nothing costs money.
20
+
21
+ Here is an AI agent using it. Real recording: one question in, receipts out ([full-speed video](docs/agent-demo.mp4), [second example](docs/agent-demo-2.mp4)):
22
+
23
+ ![An AI agent answers "what's the worst product MKBHD talked about?" using ttgrep](docs/agent-demo.gif)
24
+
25
+ It answers three questions:
26
+
27
+ 1. **What has this account posted?** Use `ttgrep videos`.
28
+ 2. **What was said in this video?** Use `ttgrep transcript`.
29
+ 3. **In which videos do they talk about X?** Use `ttgrep search`.
30
+
31
+ It only retrieves. Deciding what the words mean is your job, or your AI agent's. ttgrep is built for agents: the output is plain text that reads well in a context window, it ships an MCP server (`ttgrep mcp`), and there is a ready-made CLAUDE.md snippet at the bottom of this page so your agent knows when to reach for it.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ uv tool install ttgrep
37
+ ```
38
+
39
+ `pipx install ttgrep` works too, and so does installing from source with `uv tool install git+https://github.com/Louis2B2G/ttgrep.git`. You get a `ttgrep` command with everything bundled: a current yt-dlp with `curl_cffi` (TikTok blocks plain clients) and a local Whisper. On Apple Silicon the Whisper backend is `mlx-whisper`, which also needs `ffmpeg` on your PATH (`brew install ffmpeg`). Everywhere else it is `faster-whisper`, which needs nothing extra. Run `ttgrep doctor` to check all of it.
40
+
41
+ ## How to use it
42
+
43
+ One slow command, then everything is instant:
44
+
45
+ ```bash
46
+ ttgrep sync @handle --limit 50 # fetch listing + transcripts (slow once, resumable)
47
+ ttgrep videos @handle # instant, from cache
48
+ ttgrep search @handle "topic" # instant, from cache
49
+ ttgrep transcript <url-or-id> # cached, or fetched on demand
50
+ ttgrep cost # what all of this would have cost elsewhere
51
+ ```
52
+
53
+ `sync` is the only command that does real network work: one request for the post list, then one page per video (about 2-3 seconds each, politely spaced). You can interrupt it any time. It saves as it goes and picks up where it left off. Run it again later and it only fetches new posts. `--limit N` covers just the N most recent posts; leave it off for full history.
54
+
55
+ ### `sync ACCOUNT`
56
+
57
+ ```
58
+ $ ttgrep sync @hankgreen1 --limit 40
59
+ listing @hankgreen1 (top 40) ... # progress goes to stderr
60
+ fetching captions for 25 videos ... # 15 of the 40 were already cached
61
+ [1/25] 7653172464536538382 ok eng-US
62
+ [2/25] 7652512813184781581 ok eng-US
63
+ [15/25] 7626519681100188958 none # no captions, whisper handles it below
64
+ ...
65
+ transcribing 1 captionless videos locally (whisper-small on mlx; first ever run downloads the model)
66
+ [1/1] 7626519681100188958 asr en (3.6s)
67
+ account: hankgreen1 # summary goes to stdout
68
+ videos: 40
69
+ listing: partial
70
+ transcripts_ok: 39
71
+ transcripts_asr: 1
72
+ no_captions: 0
73
+ pending: 0
74
+ errors: 0
75
+ fetched_this_run: 25
76
+ transcribed_this_run: 1
77
+ ```
78
+
79
+ Videos without TikTok captions get their audio transcribed on your machine with Whisper (the model downloads once, about 500MB for `small`). If Whisper finds no speech at all, like music-only clips, the video is marked and never tried again. That is an answer, not an error.
80
+
81
+ Flags: `--limit N` (N most recent only), `--sleep SECS` (pause between fetches, default 1.0), `--retry-errors` (retry failed videos), `--listing-only` (post list only), `--no-listing` (skip the list, just fill missing transcripts), `--no-asr` (skip Whisper), `--asr-model MODEL` (default `small`).
82
+
83
+ ### `videos ACCOUNT`
84
+
85
+ What they posted. TSV, newest first. The title on TikTok is the post description, hashtags and all.
86
+
87
+ ```
88
+ $ ttgrep videos @hankgreen1 --limit 3
89
+ id date dur views likes comments transcript title
90
+ 7678870202494340383 2026-08-28 173 24700 2182 53 ok Longer video getting into the very very weird details of how the immune system ...
91
+ 7678484247363030302 2026-08-26 58 53300 4438 90 ok #stitch with @kyro_frenchbulldogs #greenscreen
92
+ 7676649513565703437 2026-08-22 67 15700 337 22 ok How do you get a voter’s attention? Listen to the full conversation wherever y...
93
+ ```
94
+
95
+ The `transcript` column: `ok` means TikTok captions, `asr` means Whisper, `none` means no captions and no speech, `pending` means not fetched yet, `error` means the fetch failed, `photo` means an image post (no audio exists). Flags: `--limit`, `--since YYYY-MM-DD`, `--full` (don't shorten titles), `--json`.
96
+
97
+ ### `transcript VIDEO`
98
+
99
+ What was said in one video. Takes a URL or a bare id. Cached videos print instantly. Unknown videos are fetched on the spot, even from accounts you never synced.
100
+
101
+ ```
102
+ $ ttgrep transcript 7678484247363030302
103
+ id: 7678484247363030302
104
+ account: hankgreen1
105
+ date: 2026-08-26
106
+ duration: 58
107
+ views: 53300
108
+ url: https://www.tiktok.com/@hankgreen1/video/7678484247363030302
109
+ langs: eng-US
110
+ lang: eng-US
111
+ title: #stitch with @kyro_frenchbulldogs #greenscreen
112
+ ---
113
+ The butterflies do this thing called puddling, where they drink water specifically from muddy puddles, because their food source, nectar, doesn't contain a lot ...
114
+ ```
115
+
116
+ Flags: `--timestamps` (one `[m:ss]` line per caption), `--lang CODE` (`en` works as a prefix for `eng-US`), `--json` (every language track, with timing), `--no-asr`, `--asr-model`. No captions? It transcribes the audio right there, once, in 5 to 30 seconds. Only a video with no speech at all prints `transcript: none`, and it exits 0, because that is an answer.
117
+
118
+ ### `search [ACCOUNT] QUERY`
119
+
120
+ Which videos say X. Searches every cached transcript and title. Case doesn't matter. `--regex` for patterns like `'solar|renewable'`. `--all` searches every account you have cached.
121
+
122
+ ```
123
+ $ ttgrep search @hankgreen1 "immune"
124
+ # query='immune' accounts=1 posts=40 transcripts_searched=40 matched=2 skipped: no_captions=0 pending=0 error=0
125
+ == 7678870202494340383 2026-08-28 @hankgreen1 views=24700 lang=eng-US
126
+ Longer video getting into the very very weird details of how the immune system ...
127
+ [0:42] like, wake your immune system up to the fact that you might get this cancer. And that would decrease your risk of getting that cancer
128
+ [2:05] and then training with a vaccine, the immune system, to go after it. Like, be on the lookout for those specific weird proteins.
129
+ ...
130
+ ```
131
+
132
+ The first line always tells you how much was searchable, so you know what a "no" means. Matches are quoted with timestamps. Phrases that cross caption boundaries still match. Exit code 1 means "searched fine, found nothing". Check the `pending` count before you claim someone never said something.
133
+
134
+ ### `cost`
135
+
136
+ What your cache would have cost if you had bought it.
137
+
138
+ ![ttgrep cost](docs/demo.gif)
139
+
140
+ ```
141
+ $ ttgrep cost
142
+ your cache: 8 accounts · 2,103 videos · 1,892 transcripts · 35.5 hours of speech
143
+ how: TikTok captions (1,557) + whisper on this machine (335)
144
+ the same thing, bought (published 2026 prices):
145
+ Supadata "TikTok Transcript API" ....... $17/month (Pro plan), metered
146
+ OpenAI transcription, 35.5 h of audio ... $12.77, plus you build the rest
147
+ social listening tools ................. can't search speech at all
148
+ your bill .............................. $0.00
149
+ your next 10,000 searches .............. $0.00
150
+ ```
151
+
152
+ The last line is the point. When every question costs money, an agent learns to stop asking. When questions are free, it can grep everything, twice.
153
+
154
+ Prices, checked August 2026: [Supadata](https://supadata.ai/pricing) sells TikTok transcripts at 1 credit each ($17/month for 3,000, $47/month for 30,000). [OpenAI transcription](https://platform.openai.com/pricing) is $0.006 per minute. [ScrapeCreators](https://scrapecreators.com/tiktok-transcript-api) is similar ($10 per 5,000). The numbers live in `ttgrep/cost.py`; fix them when they drift. `--per-account` shows a per-account breakdown. `--json` gives raw numbers.
155
+
156
+ ### `status`, `doctor`, `mcp`
157
+
158
+ `ttgrep status` shows what you have cached. `ttgrep doctor` checks your install and does one live probe against TikTok. Run it first when something breaks.
159
+
160
+ `ttgrep mcp` runs the whole thing as an MCP server over stdio. Same cache, same output, six tools (`tiktok_sync`, `tiktok_videos`, `tiktok_transcript`, `tiktok_search`, `tiktok_status`, `tiktok_cost`):
161
+
162
+ ```json
163
+ { "mcpServers": { "ttgrep": { "command": "ttgrep", "args": ["mcp"] } } }
164
+ ```
165
+
166
+ ## The test
167
+
168
+ The acceptance test for this tool was not a test suite. It was handing a fresh AI agent one sentence ("ttgrep is installed, figure it out with --help") plus a question about a synced account. No docs, no hints. The agent found the commands, searched, quoted timestamped evidence, ranked its findings, and correctly refused to guess about one music-only video because the coverage line told it there was nothing to search. If your agent can read `--help`, it can use this.
169
+
170
+ That test shaped the output: coverage before results, "no captions" kept separate from "not fetched yet", exit 1 for "found nothing", and stderr kept out of the data.
171
+
172
+ ## Output rules
173
+
174
+ - stdout is data. stderr is progress and hints. Pipe stdout safely.
175
+ - Exit codes: 0 ok, 1 search found nothing, 2 real error.
176
+ - `--json` everywhere it matters. Default output is compact text, to save tokens.
177
+ - Dates are UTC `YYYY-MM-DD`. Durations are seconds. Counts are plain integers.
178
+
179
+ ## Where things live
180
+
181
+ ```
182
+ ~/.ttgrep/ # change with $TTGREP_HOME
183
+ accounts/<handle>/index.json # post list + per-video status
184
+ accounts/<handle>/transcripts/<id>.json # all language tracks, with timing
185
+ ```
186
+
187
+ Plain JSON. You can grep it directly. Writes are atomic, so an interrupted sync never corrupts anything. `ok` and `none` are final and never re-fetched. Videos deleted from a profile stay in your cache, marked `listed: false`. Knowledge is not deleted. Full reset for one account: `rm -r ~/.ttgrep/accounts/<handle>`.
188
+
189
+ ## Languages
190
+
191
+ TikTok uses its own caption codes: `eng-US` and `fra-FR`, not `en` and `fr`. Some videos have several tracks (the original plus translations). `sync` stores all of them; caption files are tiny. When you read, ttgrep picks the account's main language (the one on most of their videos). Override with `--lang` anywhere. Whisper tracks use short codes (`en`, `fr`) and carry a `source` field, so you can always tell captions from local transcription.
192
+
193
+ One honest warning: Whisper writes down whatever is audible. On a video with a song and no talking, that can be accurately transcribed song lyrics. Hallucinated noise and repeated-word loops are filtered out using Whisper's own confidence numbers, but real lyrics stay in, on purpose. Check whether an `asr` match is speech before you quote it as something the creator said.
194
+
195
+ ## When it breaks
196
+
197
+ TikTok changes things. yt-dlp chases them. If fetching stops working:
198
+
199
+ 1. `ttgrep doctor` tells you whether it's your install or TikTok.
200
+ 2. `uv tool upgrade ttgrep` pulls the latest yt-dlp, which is the fix most of the time.
201
+ 3. Many `error` statuses mid-sync usually means rate limiting. Wait, then re-run with a higher `--sleep` and `--retry-errors`.
202
+
203
+ ### Notes for whoever maintains this (learned the hard way)
204
+
205
+ - yt-dlp only fills in `subtitles` when the `writesubtitles` (or `listsubtitles`) option is set. A plain `extract_info()` reports no captions for videos that have them.
206
+ - TikTok captions appear under `subtitles`, not `automatic_captions`, even though they are auto-generated.
207
+ - Fetching caption URLs yourself can get a 403. Going through yt-dlp's own subtitle download machinery works. ttgrep does the latter.
208
+ - One extractor path returns SRT data instead of VTT files. The parser accepts both.
209
+ - TikTok serves many image posts under `/video/` URLs. They fail with "No video formats found". That means "image post", not "error".
210
+ - Whisper on music produces two failure modes: hallucinated text (avg_logprob below about -3, versus above -0.9 for real speech) and repetition loops (compression_ratio over 2.4). Both are filtered in `asr.py`.
211
+ - A flat-playlist extraction of a profile returns full metadata per video. Listing an account never needs per-video fetches.
212
+ - A TikTok video id encodes its post time: `id >> 32` is unix seconds.
213
+ - `tiktok.com/@_/video/<id>` resolves for any bare video id. The handle in the URL is ignored.
214
+
215
+ ## CLAUDE.md snippet
216
+
217
+ Paste this into your global `~/.claude/CLAUDE.md` so your agent knows when to use ttgrep:
218
+
219
+ ```markdown
220
+ ## TikTok (`ttgrep`)
221
+
222
+ When a task involves what a TikTok account posts or says (analyzing a creator,
223
+ checking if or when they covered a topic, quoting a video), use `ttgrep`. Do
224
+ not use ad-hoc yt-dlp or web scraping. Everything is cached in ~/.ttgrep;
225
+ never re-fetch what it already has.
226
+
227
+ - New account: `ttgrep sync @handle --limit 50` first (slow, 2-3s per video,
228
+ resumable). Videos without captions get whisper-transcribed automatically.
229
+ - `ttgrep videos @handle` shows what they posted (TSV; `--json` for full records).
230
+ - `ttgrep transcript <url-or-id>` shows what was said (fetches if uncached).
231
+ `transcript: none` means the video has no speech. That is an answer.
232
+ - `ttgrep search @handle "term"` finds which videos mention it (`--regex 'a|b'`,
233
+ `--all` for every cached account). Exit 1 means no matches. Read the `#`
234
+ coverage line first: if `pending` > 0, sync before claiming they never said it.
235
+ - Caption languages are TikTok codes (`eng-US`); default is the account's main
236
+ language, `--lang en` to override. Whisper tracks on music-only videos can be
237
+ song lyrics. Check before quoting them as the creator's words.
238
+ - If fetching fails: `ttgrep doctor`, then `uv tool upgrade ttgrep`.
239
+ - Missing? `uv tool install ttgrep`
240
+ - MCP clients: `ttgrep mcp` serves the same commands as tools over stdio.
241
+ ```
ttgrep-0.5.1/README.md ADDED
@@ -0,0 +1,227 @@
1
+ # ttgrep
2
+
3
+ grep for TikTok.
4
+
5
+ TikTok has no public API, no transcripts, and no text search. ttgrep fixes that. It downloads everything an account has posted, turns every video into text, and lets you search all of it. Captions come from TikTok when they exist. When they don't, Whisper runs on your machine. Everything is saved to disk and never fetched twice. Nothing costs money.
6
+
7
+ Here is an AI agent using it. Real recording: one question in, receipts out ([full-speed video](docs/agent-demo.mp4), [second example](docs/agent-demo-2.mp4)):
8
+
9
+ ![An AI agent answers "what's the worst product MKBHD talked about?" using ttgrep](docs/agent-demo.gif)
10
+
11
+ It answers three questions:
12
+
13
+ 1. **What has this account posted?** Use `ttgrep videos`.
14
+ 2. **What was said in this video?** Use `ttgrep transcript`.
15
+ 3. **In which videos do they talk about X?** Use `ttgrep search`.
16
+
17
+ It only retrieves. Deciding what the words mean is your job, or your AI agent's. ttgrep is built for agents: the output is plain text that reads well in a context window, it ships an MCP server (`ttgrep mcp`), and there is a ready-made CLAUDE.md snippet at the bottom of this page so your agent knows when to reach for it.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ uv tool install ttgrep
23
+ ```
24
+
25
+ `pipx install ttgrep` works too, and so does installing from source with `uv tool install git+https://github.com/Louis2B2G/ttgrep.git`. You get a `ttgrep` command with everything bundled: a current yt-dlp with `curl_cffi` (TikTok blocks plain clients) and a local Whisper. On Apple Silicon the Whisper backend is `mlx-whisper`, which also needs `ffmpeg` on your PATH (`brew install ffmpeg`). Everywhere else it is `faster-whisper`, which needs nothing extra. Run `ttgrep doctor` to check all of it.
26
+
27
+ ## How to use it
28
+
29
+ One slow command, then everything is instant:
30
+
31
+ ```bash
32
+ ttgrep sync @handle --limit 50 # fetch listing + transcripts (slow once, resumable)
33
+ ttgrep videos @handle # instant, from cache
34
+ ttgrep search @handle "topic" # instant, from cache
35
+ ttgrep transcript <url-or-id> # cached, or fetched on demand
36
+ ttgrep cost # what all of this would have cost elsewhere
37
+ ```
38
+
39
+ `sync` is the only command that does real network work: one request for the post list, then one page per video (about 2-3 seconds each, politely spaced). You can interrupt it any time. It saves as it goes and picks up where it left off. Run it again later and it only fetches new posts. `--limit N` covers just the N most recent posts; leave it off for full history.
40
+
41
+ ### `sync ACCOUNT`
42
+
43
+ ```
44
+ $ ttgrep sync @hankgreen1 --limit 40
45
+ listing @hankgreen1 (top 40) ... # progress goes to stderr
46
+ fetching captions for 25 videos ... # 15 of the 40 were already cached
47
+ [1/25] 7653172464536538382 ok eng-US
48
+ [2/25] 7652512813184781581 ok eng-US
49
+ [15/25] 7626519681100188958 none # no captions, whisper handles it below
50
+ ...
51
+ transcribing 1 captionless videos locally (whisper-small on mlx; first ever run downloads the model)
52
+ [1/1] 7626519681100188958 asr en (3.6s)
53
+ account: hankgreen1 # summary goes to stdout
54
+ videos: 40
55
+ listing: partial
56
+ transcripts_ok: 39
57
+ transcripts_asr: 1
58
+ no_captions: 0
59
+ pending: 0
60
+ errors: 0
61
+ fetched_this_run: 25
62
+ transcribed_this_run: 1
63
+ ```
64
+
65
+ Videos without TikTok captions get their audio transcribed on your machine with Whisper (the model downloads once, about 500MB for `small`). If Whisper finds no speech at all, like music-only clips, the video is marked and never tried again. That is an answer, not an error.
66
+
67
+ Flags: `--limit N` (N most recent only), `--sleep SECS` (pause between fetches, default 1.0), `--retry-errors` (retry failed videos), `--listing-only` (post list only), `--no-listing` (skip the list, just fill missing transcripts), `--no-asr` (skip Whisper), `--asr-model MODEL` (default `small`).
68
+
69
+ ### `videos ACCOUNT`
70
+
71
+ What they posted. TSV, newest first. The title on TikTok is the post description, hashtags and all.
72
+
73
+ ```
74
+ $ ttgrep videos @hankgreen1 --limit 3
75
+ id date dur views likes comments transcript title
76
+ 7678870202494340383 2026-08-28 173 24700 2182 53 ok Longer video getting into the very very weird details of how the immune system ...
77
+ 7678484247363030302 2026-08-26 58 53300 4438 90 ok #stitch with @kyro_frenchbulldogs #greenscreen
78
+ 7676649513565703437 2026-08-22 67 15700 337 22 ok How do you get a voter’s attention? Listen to the full conversation wherever y...
79
+ ```
80
+
81
+ The `transcript` column: `ok` means TikTok captions, `asr` means Whisper, `none` means no captions and no speech, `pending` means not fetched yet, `error` means the fetch failed, `photo` means an image post (no audio exists). Flags: `--limit`, `--since YYYY-MM-DD`, `--full` (don't shorten titles), `--json`.
82
+
83
+ ### `transcript VIDEO`
84
+
85
+ What was said in one video. Takes a URL or a bare id. Cached videos print instantly. Unknown videos are fetched on the spot, even from accounts you never synced.
86
+
87
+ ```
88
+ $ ttgrep transcript 7678484247363030302
89
+ id: 7678484247363030302
90
+ account: hankgreen1
91
+ date: 2026-08-26
92
+ duration: 58
93
+ views: 53300
94
+ url: https://www.tiktok.com/@hankgreen1/video/7678484247363030302
95
+ langs: eng-US
96
+ lang: eng-US
97
+ title: #stitch with @kyro_frenchbulldogs #greenscreen
98
+ ---
99
+ The butterflies do this thing called puddling, where they drink water specifically from muddy puddles, because their food source, nectar, doesn't contain a lot ...
100
+ ```
101
+
102
+ Flags: `--timestamps` (one `[m:ss]` line per caption), `--lang CODE` (`en` works as a prefix for `eng-US`), `--json` (every language track, with timing), `--no-asr`, `--asr-model`. No captions? It transcribes the audio right there, once, in 5 to 30 seconds. Only a video with no speech at all prints `transcript: none`, and it exits 0, because that is an answer.
103
+
104
+ ### `search [ACCOUNT] QUERY`
105
+
106
+ Which videos say X. Searches every cached transcript and title. Case doesn't matter. `--regex` for patterns like `'solar|renewable'`. `--all` searches every account you have cached.
107
+
108
+ ```
109
+ $ ttgrep search @hankgreen1 "immune"
110
+ # query='immune' accounts=1 posts=40 transcripts_searched=40 matched=2 skipped: no_captions=0 pending=0 error=0
111
+ == 7678870202494340383 2026-08-28 @hankgreen1 views=24700 lang=eng-US
112
+ Longer video getting into the very very weird details of how the immune system ...
113
+ [0:42] like, wake your immune system up to the fact that you might get this cancer. And that would decrease your risk of getting that cancer
114
+ [2:05] and then training with a vaccine, the immune system, to go after it. Like, be on the lookout for those specific weird proteins.
115
+ ...
116
+ ```
117
+
118
+ The first line always tells you how much was searchable, so you know what a "no" means. Matches are quoted with timestamps. Phrases that cross caption boundaries still match. Exit code 1 means "searched fine, found nothing". Check the `pending` count before you claim someone never said something.
119
+
120
+ ### `cost`
121
+
122
+ What your cache would have cost if you had bought it.
123
+
124
+ ![ttgrep cost](docs/demo.gif)
125
+
126
+ ```
127
+ $ ttgrep cost
128
+ your cache: 8 accounts · 2,103 videos · 1,892 transcripts · 35.5 hours of speech
129
+ how: TikTok captions (1,557) + whisper on this machine (335)
130
+ the same thing, bought (published 2026 prices):
131
+ Supadata "TikTok Transcript API" ....... $17/month (Pro plan), metered
132
+ OpenAI transcription, 35.5 h of audio ... $12.77, plus you build the rest
133
+ social listening tools ................. can't search speech at all
134
+ your bill .............................. $0.00
135
+ your next 10,000 searches .............. $0.00
136
+ ```
137
+
138
+ The last line is the point. When every question costs money, an agent learns to stop asking. When questions are free, it can grep everything, twice.
139
+
140
+ Prices, checked August 2026: [Supadata](https://supadata.ai/pricing) sells TikTok transcripts at 1 credit each ($17/month for 3,000, $47/month for 30,000). [OpenAI transcription](https://platform.openai.com/pricing) is $0.006 per minute. [ScrapeCreators](https://scrapecreators.com/tiktok-transcript-api) is similar ($10 per 5,000). The numbers live in `ttgrep/cost.py`; fix them when they drift. `--per-account` shows a per-account breakdown. `--json` gives raw numbers.
141
+
142
+ ### `status`, `doctor`, `mcp`
143
+
144
+ `ttgrep status` shows what you have cached. `ttgrep doctor` checks your install and does one live probe against TikTok. Run it first when something breaks.
145
+
146
+ `ttgrep mcp` runs the whole thing as an MCP server over stdio. Same cache, same output, six tools (`tiktok_sync`, `tiktok_videos`, `tiktok_transcript`, `tiktok_search`, `tiktok_status`, `tiktok_cost`):
147
+
148
+ ```json
149
+ { "mcpServers": { "ttgrep": { "command": "ttgrep", "args": ["mcp"] } } }
150
+ ```
151
+
152
+ ## The test
153
+
154
+ The acceptance test for this tool was not a test suite. It was handing a fresh AI agent one sentence ("ttgrep is installed, figure it out with --help") plus a question about a synced account. No docs, no hints. The agent found the commands, searched, quoted timestamped evidence, ranked its findings, and correctly refused to guess about one music-only video because the coverage line told it there was nothing to search. If your agent can read `--help`, it can use this.
155
+
156
+ That test shaped the output: coverage before results, "no captions" kept separate from "not fetched yet", exit 1 for "found nothing", and stderr kept out of the data.
157
+
158
+ ## Output rules
159
+
160
+ - stdout is data. stderr is progress and hints. Pipe stdout safely.
161
+ - Exit codes: 0 ok, 1 search found nothing, 2 real error.
162
+ - `--json` everywhere it matters. Default output is compact text, to save tokens.
163
+ - Dates are UTC `YYYY-MM-DD`. Durations are seconds. Counts are plain integers.
164
+
165
+ ## Where things live
166
+
167
+ ```
168
+ ~/.ttgrep/ # change with $TTGREP_HOME
169
+ accounts/<handle>/index.json # post list + per-video status
170
+ accounts/<handle>/transcripts/<id>.json # all language tracks, with timing
171
+ ```
172
+
173
+ Plain JSON. You can grep it directly. Writes are atomic, so an interrupted sync never corrupts anything. `ok` and `none` are final and never re-fetched. Videos deleted from a profile stay in your cache, marked `listed: false`. Knowledge is not deleted. Full reset for one account: `rm -r ~/.ttgrep/accounts/<handle>`.
174
+
175
+ ## Languages
176
+
177
+ TikTok uses its own caption codes: `eng-US` and `fra-FR`, not `en` and `fr`. Some videos have several tracks (the original plus translations). `sync` stores all of them; caption files are tiny. When you read, ttgrep picks the account's main language (the one on most of their videos). Override with `--lang` anywhere. Whisper tracks use short codes (`en`, `fr`) and carry a `source` field, so you can always tell captions from local transcription.
178
+
179
+ One honest warning: Whisper writes down whatever is audible. On a video with a song and no talking, that can be accurately transcribed song lyrics. Hallucinated noise and repeated-word loops are filtered out using Whisper's own confidence numbers, but real lyrics stay in, on purpose. Check whether an `asr` match is speech before you quote it as something the creator said.
180
+
181
+ ## When it breaks
182
+
183
+ TikTok changes things. yt-dlp chases them. If fetching stops working:
184
+
185
+ 1. `ttgrep doctor` tells you whether it's your install or TikTok.
186
+ 2. `uv tool upgrade ttgrep` pulls the latest yt-dlp, which is the fix most of the time.
187
+ 3. Many `error` statuses mid-sync usually means rate limiting. Wait, then re-run with a higher `--sleep` and `--retry-errors`.
188
+
189
+ ### Notes for whoever maintains this (learned the hard way)
190
+
191
+ - yt-dlp only fills in `subtitles` when the `writesubtitles` (or `listsubtitles`) option is set. A plain `extract_info()` reports no captions for videos that have them.
192
+ - TikTok captions appear under `subtitles`, not `automatic_captions`, even though they are auto-generated.
193
+ - Fetching caption URLs yourself can get a 403. Going through yt-dlp's own subtitle download machinery works. ttgrep does the latter.
194
+ - One extractor path returns SRT data instead of VTT files. The parser accepts both.
195
+ - TikTok serves many image posts under `/video/` URLs. They fail with "No video formats found". That means "image post", not "error".
196
+ - Whisper on music produces two failure modes: hallucinated text (avg_logprob below about -3, versus above -0.9 for real speech) and repetition loops (compression_ratio over 2.4). Both are filtered in `asr.py`.
197
+ - A flat-playlist extraction of a profile returns full metadata per video. Listing an account never needs per-video fetches.
198
+ - A TikTok video id encodes its post time: `id >> 32` is unix seconds.
199
+ - `tiktok.com/@_/video/<id>` resolves for any bare video id. The handle in the URL is ignored.
200
+
201
+ ## CLAUDE.md snippet
202
+
203
+ Paste this into your global `~/.claude/CLAUDE.md` so your agent knows when to use ttgrep:
204
+
205
+ ```markdown
206
+ ## TikTok (`ttgrep`)
207
+
208
+ When a task involves what a TikTok account posts or says (analyzing a creator,
209
+ checking if or when they covered a topic, quoting a video), use `ttgrep`. Do
210
+ not use ad-hoc yt-dlp or web scraping. Everything is cached in ~/.ttgrep;
211
+ never re-fetch what it already has.
212
+
213
+ - New account: `ttgrep sync @handle --limit 50` first (slow, 2-3s per video,
214
+ resumable). Videos without captions get whisper-transcribed automatically.
215
+ - `ttgrep videos @handle` shows what they posted (TSV; `--json` for full records).
216
+ - `ttgrep transcript <url-or-id>` shows what was said (fetches if uncached).
217
+ `transcript: none` means the video has no speech. That is an answer.
218
+ - `ttgrep search @handle "term"` finds which videos mention it (`--regex 'a|b'`,
219
+ `--all` for every cached account). Exit 1 means no matches. Read the `#`
220
+ coverage line first: if `pending` > 0, sync before claiming they never said it.
221
+ - Caption languages are TikTok codes (`eng-US`); default is the account's main
222
+ language, `--lang en` to override. Whisper tracks on music-only videos can be
223
+ song lyrics. Check before quoting them as the creator's words.
224
+ - If fetching fails: `ttgrep doctor`, then `uv tool upgrade ttgrep`.
225
+ - Missing? `uv tool install ttgrep`
226
+ - MCP clients: `ttgrep mcp` serves the same commands as tools over stdio.
227
+ ```
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ttgrep"
7
+ version = "0.5.1"
8
+ description = "grep for TikTok. Your AI agent syncs whole accounts, gets every word as text, and searches it. Free, local, cached forever."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.11"
12
+ dependencies = [
13
+ "yt-dlp[curl-cffi]>=2026.8.19",
14
+ # Local whisper for videos without TikTok captions (free, on-device).
15
+ "mlx-whisper>=0.4.0; sys_platform == 'darwin' and platform_machine == 'arm64'",
16
+ "faster-whisper>=1.0.0; sys_platform != 'darwin' or platform_machine != 'arm64'",
17
+ # `ttgrep mcp` — serve the same commands to MCP clients over stdio.
18
+ "mcp>=1.2",
19
+ ]
20
+
21
+ [project.urls]
22
+ Repository = "https://github.com/Louis2B2G/ttgrep"
23
+
24
+ [project.scripts]
25
+ ttgrep = "ttgrep.cli:main"
26
+
27
+ [tool.hatch.build.targets.wheel]
28
+ packages = ["ttgrep"]
29
+
30
+ [tool.hatch.build.targets.sdist]
31
+ # Ship code, not demo videos or local editor config.
32
+ only-include = ["ttgrep", "README.md", "LICENSE", "pyproject.toml"]
@@ -0,0 +1 @@
1
+ __version__ = "0.5.1"
@@ -0,0 +1,4 @@
1
+ from ttgrep.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,120 @@
1
+ """Local speech-to-text for videos that have no TikTok captions.
2
+
3
+ Backend preference: mlx-whisper (Apple Silicon; shells out to the ffmpeg CLI)
4
+ then faster-whisper (portable; decodes audio itself, no ffmpeg needed).
5
+ Models download from Hugging Face on first use (~500MB for `small`) into the
6
+ HF cache and are reused forever after.
7
+
8
+ Everything runs on-device: no API, no cost, no audio leaves the machine.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import shutil
15
+
16
+ os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
17
+
18
+ MODELS = ("tiny", "base", "small", "medium", "large-v3")
19
+ DEFAULT_MODEL = "small" # smallest model that handles non-English speech well
20
+
21
+ _state: dict = {"checked": False, "kind": None, "why": None}
22
+ _faster_models: dict = {}
23
+
24
+
25
+ def backend() -> str | None:
26
+ """'mlx' | 'faster' | None, decided once per process."""
27
+ if not _state["checked"]:
28
+ _state["checked"] = True
29
+ try:
30
+ import mlx_whisper # noqa: F401
31
+
32
+ if shutil.which("ffmpeg"):
33
+ _state["kind"] = "mlx"
34
+ else:
35
+ _state["why"] = "mlx-whisper installed but ffmpeg not on PATH"
36
+ except ImportError:
37
+ pass
38
+ if _state["kind"] is None:
39
+ try:
40
+ import faster_whisper # noqa: F401
41
+
42
+ _state["kind"] = "faster"
43
+ except ImportError:
44
+ _state["why"] = _state["why"] or "no whisper backend installed"
45
+ return _state["kind"]
46
+
47
+
48
+ def unavailable_reason() -> str:
49
+ backend()
50
+ return _state["why"] or "unknown"
51
+
52
+
53
+ def _mlx_repo(model: str) -> str:
54
+ if "/" in model: # full HF repo id passed through
55
+ return model
56
+ return f"mlx-community/whisper-{model}-mlx"
57
+
58
+
59
+ def transcribe(path, model: str = DEFAULT_MODEL) -> tuple[str | None, list[dict]]:
60
+ """Transcribe an audio/video file. Returns (language_code, segments).
61
+
62
+ Segments use the same {start, end, text} shape as caption cues. The
63
+ language code is whisper-style ('fr', 'en'), distinct from TikTok's
64
+ caption codes ('fra-FR'), which keeps the two kinds of track apart.
65
+ """
66
+ kind = backend()
67
+ if kind is None:
68
+ raise RuntimeError(f"no ASR backend: {unavailable_reason()}")
69
+
70
+ if kind == "mlx":
71
+ import mlx_whisper
72
+
73
+ result = mlx_whisper.transcribe(str(path), path_or_hf_repo=_mlx_repo(model), verbose=None)
74
+ lang = result.get("language")
75
+ raw = [(float(s["start"]), float(s["end"]), str(s.get("text", "")),
76
+ s.get("no_speech_prob"), s.get("avg_logprob"), s.get("compression_ratio"))
77
+ for s in result.get("segments", [])]
78
+ else:
79
+ from faster_whisper import WhisperModel
80
+
81
+ m = _faster_models.get(model)
82
+ if m is None:
83
+ m = _faster_models[model] = WhisperModel(model, compute_type="int8")
84
+ segs, info = m.transcribe(str(path), vad_filter=True)
85
+ lang = info.language
86
+ raw = [(s.start, s.end, s.text, s.no_speech_prob, s.avg_logprob, s.compression_ratio)
87
+ for s in segs]
88
+
89
+ segments: list[dict] = []
90
+ for start, end, text, nsp, alp, cr in raw:
91
+ text = text.strip()
92
+ if not text or not _looks_like_speech(nsp, alp, cr):
93
+ continue
94
+ if segments and segments[-1]["text"] == text: # whisper stutter
95
+ segments[-1]["end"] = round(end, 3)
96
+ else:
97
+ segments.append({"start": round(start, 3), "end": round(end, 3), "text": text})
98
+ return lang, segments
99
+
100
+
101
+ def _looks_like_speech(no_speech_prob, avg_logprob, compression_ratio) -> bool:
102
+ """Reject whisper hallucinations on music/ambient audio.
103
+
104
+ Calibrated on TikTok audio: real speech (even over loud music) scores
105
+ avg_logprob ≳ -0.9; hallucinated noise scores ≲ -3. compression_ratio
106
+ > 2.4 (openai-whisper's own threshold) kills repetition loops ("a little
107
+ bit of a little bit of ..."). The classic silence rule (no_speech > 0.6
108
+ and logprob < -1) is kept as a final net. Accurately-heard song lyrics
109
+ pass all three on purpose: they are real audio content — the caller can
110
+ judge them by the track's whisper provenance.
111
+ """
112
+ if compression_ratio is not None and compression_ratio > 2.4:
113
+ return False
114
+ if avg_logprob is None:
115
+ return True
116
+ if avg_logprob < -2.0:
117
+ return False
118
+ if no_speech_prob is not None and no_speech_prob > 0.6 and avg_logprob < -1.0:
119
+ return False
120
+ return True