tokwise 0.1.1 → 0.1.3

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.
package/README.md CHANGED
@@ -52,7 +52,12 @@ tokwise auth set --cookie "YOUR_COOKIE"
52
52
  # Tokwise tries to detect the @handle tied to your cookie. Set it manually if needed.
53
53
  tokwise auth set-username your-handle
54
54
 
55
- # Sync a collection, download audio, transcribe, classify, and index.
55
+ # Easiest start: run sync with no source. Tokwise pulls your TikTok bookmarks
56
+ # (Collections + Favorites), shows an interactive checklist, then asks what to do
57
+ # with the ones you pick (download / transcribe / classify).
58
+ tokwise sync
59
+
60
+ # Or target a specific collection directly.
56
61
  # --collection accepts a full URL, an @user/collection/slug path, or a bare slug.
57
62
  tokwise sync --collection "name-123" \
58
63
  --limit 200 \
@@ -132,9 +137,50 @@ export TOKWISE_COMMANDS_DIR=/path/to/commands
132
137
 
133
138
  Legacy `TT_*` environment variables and `~/.tiktoktheory` are still read so existing local archives keep working after the rename.
134
139
 
140
+ ## Interactive sync
141
+
142
+ Run `tokwise sync` with no source flags to discover and pick your bookmarks:
143
+
144
+ ```bash
145
+ tokwise sync # checklist of your Collections + Favorites, then a pipeline checklist
146
+ tokwise sync --download --classify # skip the pipeline prompt; apply these to whatever you pick
147
+ ```
148
+
149
+ Requires a saved cookie (`tokwise auth from-browser`). Tokwise resolves your
150
+ `secUid` once and caches it. The pipeline checklist pre-checks Download and
151
+ Transcribe only when `yt-dlp` / Whisper are installed; Classify is always on.
152
+
153
+ When you pick more than one bookmark, sync runs and reports progress per
154
+ collection (each with its own download / transcribe / classify status) and
155
+ builds the search index once at the end.
156
+
157
+ For scripts and agents, the same discovery is non-interactive:
158
+
159
+ ```bash
160
+ tokwise sync --list # print discovered bookmarks as a table
161
+ tokwise sync --list --json # machine-readable: id, name, itemCount, url, kind
162
+ tokwise sync --all # sync every bookmark without prompting
163
+ ```
164
+
165
+ When `tokwise sync` runs without an interactive terminal and no flags, it prints
166
+ the bookmark list plus a hint and exits without syncing.
167
+
168
+ ### Rate limits
169
+
170
+ TikTok rate-limits rapid requests. Tokwise paces calls and retries on HTTP 429
171
+ (and 5xx) with exponential backoff, honoring any `Retry-After` header. If a
172
+ collection still fails after retries, it is skipped and reported at the end so
173
+ the collections that did sync are kept.
174
+
175
+ ```bash
176
+ tokwise sync --request-delay 1500 # ms between requests (default 500); raise if you still hit 429s
177
+ tokwise sync --max-retries 5 # retries per request on 429/5xx (default 3)
178
+ ```
179
+
135
180
  ## Sources
136
181
 
137
182
  ```bash
183
+ tokwise sync # interactive: pick from your bookmarks
138
184
  tokwise sync --collection <url | @user/collection/slug | slug-or-id>
139
185
  tokwise sync --playlist <playlist-id-or-url>
140
186
  tokwise sync --liked <username>
package/dist/ask.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { videoReference } from "./reference.js";
1
2
  export async function answerQuestion(question, results, options) {
2
3
  if (results.length === 0)
3
4
  return "No local evidence matched that question.";
@@ -13,9 +14,8 @@ function answerExtractively(question, results) {
13
14
  "",
14
15
  ...results.slice(0, 8).map((result, idx) => {
15
16
  const video = result.video;
16
- const author = video.author?.username ? `@${video.author.username}` : "unknown";
17
17
  const summary = video.classification?.summary ?? result.highlights[0] ?? video.description ?? "No text.";
18
- return `${idx + 1}. ${video.id} ${author}\n ${summary}\n ${video.canonicalUrl ?? video.url}`;
18
+ return `${idx + 1}. ${videoReference(video)}\n ${summary}\n ${video.canonicalUrl ?? video.url}`;
19
19
  }),
20
20
  "",
21
21
  "Use --engine ollama --model <model> for a synthesized answer from a local model.",
@@ -29,8 +29,8 @@ async function answerWithOllama(question, results, options) {
29
29
  .map((result, idx) => {
30
30
  const video = result.video;
31
31
  return [
32
- `[${idx + 1}] ${video.id} ${video.canonicalUrl ?? video.url}`,
33
- `Author: ${video.author?.username ?? "unknown"}`,
32
+ `[${idx + 1}] ${videoReference(video)}`,
33
+ `Source: ${video.canonicalUrl ?? video.url}`,
34
34
  `Category: ${video.classification?.category ?? "unknown"}`,
35
35
  `Summary: ${video.classification?.summary ?? ""}`,
36
36
  `Transcript: ${(video.transcript?.text ?? video.description ?? "").slice(0, 2500)}`,
@@ -39,7 +39,7 @@ async function answerWithOllama(question, results, options) {
39
39
  .join("\n\n");
40
40
  const prompt = [
41
41
  "Answer the user's question using only the saved clip evidence below.",
42
- "Cite video ids in brackets when making claims. If evidence is thin, say so.",
42
+ "Cite clips by their readable reference (e.g. @author \u00b7 Mon YYYY \u2014 \"title\") when making claims. If evidence is thin, say so.",
43
43
  "",
44
44
  `Question: ${question}`,
45
45
  "",
@@ -49,10 +49,56 @@ async function answerWithOllama(question, results, options) {
49
49
  const response = await fetch(`${baseUrl.replace(/\/+$/, "")}/api/generate`, {
50
50
  method: "POST",
51
51
  headers: { "content-type": "application/json" },
52
- body: JSON.stringify({ model, prompt, stream: false }),
52
+ body: JSON.stringify({ model, prompt, stream: true }),
53
53
  });
54
54
  if (!response.ok)
55
55
  throw new Error(`Ollama ask failed: ${response.status} ${response.statusText}`);
56
- const body = (await response.json());
57
- return body.response?.trim() || "Ollama returned an empty answer.";
56
+ if (!response.body)
57
+ throw new Error("Ollama ask failed: empty response body");
58
+ let full = "";
59
+ let firstToken = false;
60
+ for await (const chunk of iterateNdjson(response.body)) {
61
+ const piece = chunk.response ?? "";
62
+ if (!piece) {
63
+ if (chunk.done)
64
+ break;
65
+ continue;
66
+ }
67
+ if (!firstToken) {
68
+ firstToken = true;
69
+ options.onFirstToken?.();
70
+ }
71
+ full += piece;
72
+ options.onToken?.(piece);
73
+ if (chunk.done)
74
+ break;
75
+ }
76
+ return full.trim() || "Ollama returned an empty answer.";
77
+ }
78
+ async function* iterateNdjson(body) {
79
+ const reader = body.getReader();
80
+ const decoder = new TextDecoder();
81
+ let buffer = "";
82
+ try {
83
+ for (;;) {
84
+ const { done, value } = await reader.read();
85
+ if (done)
86
+ break;
87
+ buffer += decoder.decode(value, { stream: true });
88
+ const lines = buffer.split("\n");
89
+ buffer = lines.pop() ?? "";
90
+ for (const line of lines) {
91
+ const trimmed = line.trim();
92
+ if (!trimmed)
93
+ continue;
94
+ yield JSON.parse(trimmed);
95
+ }
96
+ }
97
+ const trailing = buffer.trim();
98
+ if (trailing)
99
+ yield JSON.parse(trailing);
100
+ }
101
+ finally {
102
+ reader.releaseLock();
103
+ }
58
104
  }
package/dist/cli.js CHANGED
@@ -13,12 +13,16 @@ import { commandsDir, dataDir, ensureDataDirs, libraryDir, searchIndexPath, toDi
13
13
  import { compileWiki, exportMarkdown, lintWiki } from "./markdown.js";
14
14
  import { downloadMedia } from "./media.js";
15
15
  import { formatSearchResults, loadSearchIndex, saveSearchIndex, searchWithIndex } from "./search.js";
16
- import { detectLoggedInUsername, fetchCollection, fetchLiked, fetchPlaylist, fetchSingleUrl, fetchUserPosts, fetchVideoSearch, videosFromImport, videosFromUrls } from "./tiktok.js";
16
+ import { detectLoggedInUsername, fetchBookmarkFolders, fetchCollection, fetchLiked, fetchPlaylist, fetchSingleUrl, fetchUserPosts, fetchVideoSearch, resolveSecUid, videosFromImport, videosFromUrls } from "./tiktok.js";
17
17
  import { transcribeVideo } from "./transcribe.js";
18
+ import { detectEngines } from "./engines.js";
19
+ import { sleep } from "./http.js";
20
+ import { cancelled, intro, isInteractive, promptFolderSelection, promptPipelineSelection } from "./interactive.js";
18
21
  import { createCommand, createLibraryPage, deleteLibraryPage, listCommands, searchLibrary, showLibraryPage, updateLibraryPage, validateCommands } from "./library.js";
19
22
  import { installSkill, skillContent, uninstallSkill } from "./skill.js";
20
- import { barChart, box, c, kvList, setColorEnabled, truncate } from "./render.js";
21
- import { createProgress } from "./progress.js";
23
+ import { barChart, box, c, kvList, setColorEnabled, table, truncate } from "./render.js";
24
+ import { formatReference } from "./reference.js";
25
+ import { createProgress, createSpinner } from "./progress.js";
22
26
  const require = createRequire(import.meta.url);
23
27
  function version() {
24
28
  try {
@@ -184,6 +188,9 @@ export function buildCli() {
184
188
  program
185
189
  .command("sync")
186
190
  .description("Sync short-form video sources into the local archive")
191
+ .option("--list", "List discovered bookmarks instead of syncing", false)
192
+ .option("--json", "With --list, print bookmarks as JSON", false)
193
+ .option("--all", "Sync every discovered bookmark without prompting", false)
187
194
  .option("--collection <idOrUrl>", "Collection URL, @user/collection/slug, or bare slug/id", collect, [])
188
195
  .option("--playlist <idOrUrl>", "Playlist id or URL", collect, [])
189
196
  .option("--liked <username>", "Sync a user's liked videos; usually requires cookie", collect, [])
@@ -198,6 +205,8 @@ export function buildCli() {
198
205
  .option("--limit <n>", "Max items per source", parseNumber, 30)
199
206
  .option("--page <n>", "Start page", parseNumber, 1)
200
207
  .option("--pages <n>", "Max pages per paged source", parseNumber)
208
+ .option("--request-delay <ms>", "Delay between API requests to avoid rate limits", parseNumber, 500)
209
+ .option("--max-retries <n>", "Retries per request on rate limit (429) or server errors", parseNumber, 3)
201
210
  .option("--rebuild", "Replace archive with this sync result", false)
202
211
  .option("--download", "Download media after syncing", false)
203
212
  .option("--audio", "When downloading, extract audio only", false)
@@ -217,14 +226,21 @@ export function buildCli() {
217
226
  ensureDataDirs();
218
227
  const cookie = await loadCookie({ cookie: options.cookie, cookieFile: options.cookieFile });
219
228
  const auth = await loadAuth();
229
+ if (!hasExplicitSource(options)) {
230
+ await runInteractiveSync(options, cookie, auth);
231
+ return;
232
+ }
220
233
  const discovered = [];
221
234
  const fetchOptions = {
222
235
  cookie,
223
236
  username: auth.username,
237
+ secUid: auth.secUid,
224
238
  proxy: options.proxy,
225
239
  limit: Number(options.limit),
226
240
  page: Number(options.page),
227
241
  pages: options.pages == null ? undefined : Number(options.pages),
242
+ requestDelayMs: Number(options.requestDelay),
243
+ maxRetries: Number(options.maxRetries),
228
244
  };
229
245
  for (const value of options.collection)
230
246
  discovered.push(...(await fetchCollection(value, fetchOptions)));
@@ -250,36 +266,7 @@ export function buildCli() {
250
266
  }
251
267
  if (discovered.length === 0)
252
268
  throw new Error("No source supplied. Try --collection, --url, --urls-file, or --input.");
253
- const sync = await mergeVideos(discovered, { rebuild: Boolean(options.rebuild) });
254
- console.log(`Synced ${sync.added} new, ${sync.updated} updated, ${sync.unchanged} unchanged (${sync.total} total).`);
255
- let videos = await loadVideos();
256
- const touched = new Set(sync.ids);
257
- if (options.download) {
258
- videos = await runDownloads(videos, touched, {
259
- ytDlp: options.ytDlp,
260
- proxy: options.proxy,
261
- cookiesFile: options.ytDlpCookies,
262
- cookiesFromBrowser: options.cookiesFromBrowser,
263
- audioOnly: Boolean(options.audio),
264
- });
265
- }
266
- if (options.transcribe) {
267
- videos = await runTranscription(videos, touched, {
268
- engine: options.sttEngine,
269
- command: options.sttCommand,
270
- model: options.sttModel,
271
- language: options.language,
272
- });
273
- }
274
- if (options.classify) {
275
- videos = await runClassification(videos, touched, {
276
- engine: options.engine,
277
- model: options.model,
278
- ollamaBaseUrl: options.ollamaUrl,
279
- });
280
- }
281
- const index = await saveSearchIndex(videos);
282
- console.log(`Indexed ${index.recordCount} videos at ${toDisplayPath(searchIndexPath())}.`);
269
+ await runSyncPipeline([{ label: "sync", videos: discovered }], options);
283
270
  }));
284
271
  program
285
272
  .command("fetch-media")
@@ -495,12 +482,30 @@ export function buildCli() {
495
482
  const prefs = await loadPreferences();
496
483
  const { videos, index } = await requireIndex();
497
484
  const results = searchWithIndex(videos, index, { query: question, limit: Number(options.limit) });
498
- const answer = await answerQuestion(question, results, {
499
- engine: options.engine ?? prefs.askEngine ?? "extractive",
500
- model: options.model ?? prefs.model,
501
- ollamaBaseUrl: options.ollamaUrl ?? prefs.ollamaBaseUrl,
502
- });
503
- console.log(answer);
485
+ const engine = options.engine ?? prefs.askEngine ?? "extractive";
486
+ const spinner = engine === "ollama" ? createSpinner() : undefined;
487
+ spinner?.start();
488
+ let streamed = false;
489
+ let answer;
490
+ try {
491
+ answer = await answerQuestion(question, results, {
492
+ engine,
493
+ model: options.model ?? prefs.model,
494
+ ollamaBaseUrl: options.ollamaUrl ?? prefs.ollamaBaseUrl,
495
+ onFirstToken: () => spinner?.stop(),
496
+ onToken: (chunk) => {
497
+ streamed = true;
498
+ process.stdout.write(chunk);
499
+ },
500
+ });
501
+ }
502
+ finally {
503
+ spinner?.stop();
504
+ }
505
+ if (streamed)
506
+ process.stdout.write("\n");
507
+ else
508
+ console.log(answer);
504
509
  if (options.save) {
505
510
  const file = path.join(libraryDir(), "answers", `${Date.now()}-${slug(question)}.md`);
506
511
  await fs.mkdir(path.dirname(file), { recursive: true });
@@ -548,10 +553,13 @@ export function buildCli() {
548
553
  }));
549
554
  return program;
550
555
  }
551
- async function runDownloads(videos, touched, options) {
556
+ export function progressLabel(base, label) {
557
+ return label ? `${base} \u00b7 ${label}` : base;
558
+ }
559
+ async function runDownloads(videos, touched, options, label) {
552
560
  const next = [...videos];
553
561
  const total = next.filter((video) => touched.has(video.id)).length;
554
- const progress = createProgress({ total, label: "media" });
562
+ const progress = createProgress({ total, label: progressLabel("media", label) });
555
563
  let downloaded = 0;
556
564
  let present = 0;
557
565
  const failed = [];
@@ -574,13 +582,14 @@ async function runDownloads(videos, touched, options) {
574
582
  }
575
583
  progress.done();
576
584
  await saveVideos(next);
577
- console.log(`Media: ${downloaded} downloaded, ${present} already present${failed.length ? `, ${failed.length} failed (${failed.join(", ")})` : ""} (${total} total).`);
585
+ const indent = label ? " " : "";
586
+ console.log(`${indent}Media: ${downloaded} downloaded, ${present} already present${failed.length ? `, ${failed.length} failed (${failed.join(", ")})` : ""} (${total} total).`);
578
587
  return next;
579
588
  }
580
- async function runTranscription(videos, touched, options) {
589
+ async function runTranscription(videos, touched, options, label) {
581
590
  const next = [...videos];
582
591
  const total = next.filter((video) => touched.has(video.id)).length;
583
- const progress = createProgress({ total, label: "transcribe" });
592
+ const progress = createProgress({ total, label: progressLabel("transcribe", label) });
584
593
  let transcribed = 0;
585
594
  let present = 0;
586
595
  const failed = [];
@@ -604,13 +613,14 @@ async function runTranscription(videos, touched, options) {
604
613
  }
605
614
  progress.done();
606
615
  await saveVideos(next);
607
- console.log(`Transcripts: ${transcribed} transcribed, ${present} already present${failed.length ? `, ${failed.length} failed (${failed.join(", ")})` : ""} (${total} total).`);
616
+ const indent = label ? " " : "";
617
+ console.log(`${indent}Transcripts: ${transcribed} transcribed, ${present} already present${failed.length ? `, ${failed.length} failed (${failed.join(", ")})` : ""} (${total} total).`);
608
618
  return next;
609
619
  }
610
- async function runClassification(videos, touched, options) {
620
+ async function runClassification(videos, touched, options, label) {
611
621
  const next = [...videos];
612
622
  const total = next.filter((video) => touched.has(video.id)).length;
613
- const progress = createProgress({ total, label: "classify" });
623
+ const progress = createProgress({ total, label: progressLabel("classify", label) });
614
624
  let classified = 0;
615
625
  const failed = [];
616
626
  for (const [idx, video] of next.entries()) {
@@ -629,9 +639,188 @@ async function runClassification(videos, touched, options) {
629
639
  }
630
640
  progress.done();
631
641
  await saveVideos(next);
632
- console.log(`Classified ${classified} videos${failed.length ? `, ${failed.length} failed (${failed.join(", ")})` : ""} (${total} total).`);
642
+ const indent = label ? " " : "";
643
+ console.log(`${indent}Classified ${classified} videos${failed.length ? `, ${failed.length} failed (${failed.join(", ")})` : ""} (${total} total).`);
633
644
  return next;
634
645
  }
646
+ export function hasExplicitSource(options) {
647
+ return (options.collection.length > 0 ||
648
+ options.playlist.length > 0 ||
649
+ options.liked.length > 0 ||
650
+ options.user.length > 0 ||
651
+ options.searchVideo.length > 0 ||
652
+ options.url.length > 0 ||
653
+ Boolean(options.urlsFile) ||
654
+ Boolean(options.input));
655
+ }
656
+ async function runSyncPipeline(groups, options) {
657
+ const showPerGroup = groups.length > 1;
658
+ let videos = await loadVideos();
659
+ for (const [index, group] of groups.entries()) {
660
+ const groupLabel = showPerGroup ? group.label : undefined;
661
+ if (showPerGroup)
662
+ console.log(c.heading(group.label));
663
+ const sync = await mergeVideos(group.videos, { rebuild: Boolean(options.rebuild) && index === 0 });
664
+ console.log(showPerGroup
665
+ ? ` Synced ${sync.added} new, ${sync.updated} updated, ${sync.unchanged} unchanged`
666
+ : `Synced ${sync.added} new, ${sync.updated} updated, ${sync.unchanged} unchanged (${sync.total} total).`);
667
+ videos = await loadVideos();
668
+ const touched = new Set(sync.ids);
669
+ if (options.download) {
670
+ videos = await runDownloads(videos, touched, {
671
+ ytDlp: options.ytDlp,
672
+ proxy: options.proxy,
673
+ cookiesFile: options.ytDlpCookies,
674
+ cookiesFromBrowser: options.cookiesFromBrowser,
675
+ audioOnly: Boolean(options.audio),
676
+ }, groupLabel);
677
+ }
678
+ if (options.transcribe) {
679
+ videos = await runTranscription(videos, touched, {
680
+ engine: options.sttEngine,
681
+ command: options.sttCommand,
682
+ model: options.sttModel,
683
+ language: options.language,
684
+ }, groupLabel);
685
+ }
686
+ if (options.classify) {
687
+ videos = await runClassification(videos, touched, {
688
+ engine: options.engine,
689
+ model: options.model,
690
+ ollamaBaseUrl: options.ollamaUrl,
691
+ }, groupLabel);
692
+ }
693
+ if (showPerGroup)
694
+ console.log("");
695
+ }
696
+ const index = await saveSearchIndex(videos);
697
+ console.log(`Indexed ${index.recordCount} videos at ${toDisplayPath(searchIndexPath())}.`);
698
+ }
699
+ function printBookmarkFolders(folders, options) {
700
+ if (options.json) {
701
+ console.log(JSON.stringify(folders.map((folder) => ({
702
+ id: folder.id,
703
+ name: folder.name,
704
+ itemCount: folder.itemCount ?? null,
705
+ url: folder.url ?? null,
706
+ kind: folder.kind,
707
+ })), null, 2));
708
+ return;
709
+ }
710
+ const rows = folders.map((folder) => [
711
+ folder.name,
712
+ folder.kind,
713
+ typeof folder.itemCount === "number" ? String(folder.itemCount) : "",
714
+ c.muted(folder.id),
715
+ ]);
716
+ console.log(table(["Name", "Kind", "Videos", "ID"], rows));
717
+ }
718
+ async function runInteractiveSync(options, cookie, auth) {
719
+ if (options.json)
720
+ setColorEnabled(false);
721
+ if (!cookie) {
722
+ throw new Error("No cookie saved. Run `tw auth from-browser` (or `tw auth set --cookie ...`) to enable bookmark sync.");
723
+ }
724
+ let secUid = auth.secUid;
725
+ if (!secUid) {
726
+ secUid = await resolveSecUid(cookie, auth.username);
727
+ if (secUid)
728
+ await saveAuth({ ...auth, secUid, updatedAt: new Date().toISOString() });
729
+ }
730
+ const folders = await fetchBookmarkFolders({
731
+ cookie,
732
+ username: auth.username,
733
+ secUid,
734
+ proxy: options.proxy,
735
+ limit: Number(options.limit),
736
+ });
737
+ if (folders.length === 0) {
738
+ console.log("No bookmarks found. Make sure your cookie is fresh (try `tw auth refresh`).");
739
+ return;
740
+ }
741
+ if (options.list) {
742
+ printBookmarkFolders(folders, { json: Boolean(options.json) });
743
+ return;
744
+ }
745
+ let selected;
746
+ if (options.all) {
747
+ selected = folders;
748
+ }
749
+ else if (isInteractive()) {
750
+ intro(`Found ${folders.length} bookmarks${auth.username ? ` for @${auth.username}` : ""}`);
751
+ const picked = await promptFolderSelection(folders);
752
+ if (!picked) {
753
+ cancelled("Sync cancelled.");
754
+ return;
755
+ }
756
+ selected = picked;
757
+ }
758
+ else {
759
+ printBookmarkFolders(folders, { json: false });
760
+ console.log("");
761
+ console.log(`${c.muted("Hint")} run \`tokwise sync --all\` to sync everything, \`--collection <id>\` for one, or \`--list --json\` for machine output.`);
762
+ return;
763
+ }
764
+ if (selected.length === 0) {
765
+ console.log("Nothing selected.");
766
+ return;
767
+ }
768
+ const pipelineFlagsGiven = Boolean(options.download || options.transcribe || options.classify);
769
+ if (isInteractive() && !options.all && !pipelineFlagsGiven) {
770
+ const engines = await detectEngines({ ytDlp: options.ytDlp });
771
+ const pipeline = await promptPipelineSelection({
772
+ download: engines.ytDlp,
773
+ transcribe: engines.ytDlp && engines.whisper,
774
+ classify: true,
775
+ });
776
+ if (!pipeline) {
777
+ cancelled("Sync cancelled.");
778
+ return;
779
+ }
780
+ options.download = pipeline.download;
781
+ options.transcribe = pipeline.transcribe;
782
+ options.classify = pipeline.classify;
783
+ if (pipeline.download && !options.audio)
784
+ options.audio = true;
785
+ }
786
+ const requestDelayMs = Number(options.requestDelay);
787
+ const fetchOptions = {
788
+ cookie,
789
+ username: auth.username,
790
+ secUid,
791
+ proxy: options.proxy,
792
+ limit: Number(options.limit),
793
+ page: Number(options.page),
794
+ pages: options.pages == null ? undefined : Number(options.pages),
795
+ requestDelayMs,
796
+ maxRetries: Number(options.maxRetries),
797
+ };
798
+ const groups = [];
799
+ const failed = [];
800
+ for (const [index, folder] of selected.entries()) {
801
+ if (index > 0 && requestDelayMs > 0)
802
+ await sleep(requestDelayMs);
803
+ try {
804
+ const videos = await fetchCollection(folder.url ?? folder.id, fetchOptions);
805
+ if (videos.length > 0)
806
+ groups.push({ label: folder.name, videos });
807
+ }
808
+ catch (error) {
809
+ failed.push(folder.name);
810
+ console.error(c.warn(`Skipped "${folder.name}": ${error.message}`));
811
+ }
812
+ }
813
+ if (failed.length > 0) {
814
+ const names = failed.map((name) => `"${name}"`).join(", ");
815
+ const suggestion = Math.max(1500, requestDelayMs * 2);
816
+ console.error(c.warn(`${failed.length} collection${failed.length === 1 ? "" : "s"} skipped after retries: ${names}. Re-run or try --request-delay ${suggestion}.`));
817
+ }
818
+ if (groups.length === 0) {
819
+ console.log("No videos found in the selected bookmarks.");
820
+ return;
821
+ }
822
+ await runSyncPipeline(groups, options);
823
+ }
635
824
  async function readImport(filePath) {
636
825
  const text = await readTextInput(filePath);
637
826
  const trimmed = text.trim();
@@ -677,12 +866,11 @@ function formatList(videos) {
677
866
  return c.muted("No videos.");
678
867
  return videos
679
868
  .map((video) => {
680
- const author = video.author?.username ? c.accent(`@${video.author.username}`) : c.muted("unknown");
681
869
  const category = video.classification?.category ? ` ${c.warn(`[${video.classification.category}]`)}` : "";
682
870
  const transcript = video.transcript?.text ? ` ${c.success("transcript")}` : "";
683
871
  const desc = truncate((video.description ?? "").replace(/\s+/g, " "), 160);
684
872
  return [
685
- `${c.value(video.id)} ${author}${category}${transcript}`,
873
+ `${formatReference(video)}${category}${transcript}`,
686
874
  ` ${desc}`,
687
875
  ` ${c.muted(video.canonicalUrl ?? video.url)}`,
688
876
  ].join("\n");
@@ -691,7 +879,7 @@ function formatList(videos) {
691
879
  }
692
880
  function formatVideo(video) {
693
881
  return [
694
- `${c.value(video.id)} ${video.author?.username ? c.accent(`@${video.author.username}`) : ""}`.trimEnd(),
882
+ formatReference(video),
695
883
  c.muted(video.canonicalUrl ?? video.url),
696
884
  "",
697
885
  video.description ?? "",
@@ -869,7 +1057,7 @@ async function showDashboard() {
869
1057
  indexExists: await fileExists(searchIndexPath()),
870
1058
  }),
871
1059
  "",
872
- `${c.muted("Next")} tokwise sync --collection <url> --download --audio --transcribe --classify`,
1060
+ `${c.muted("Next")} tokwise sync ${c.muted("(pick your bookmarks)")} ${c.muted("|")} tokwise sync --collection <url> --download --audio --transcribe --classify`,
873
1061
  `${c.muted("Explore")} tokwise search "life advice" ${c.muted("|")} tokwise viz ${c.muted("|")} tokwise wiki`,
874
1062
  ].join("\n"));
875
1063
  }
@@ -0,0 +1,36 @@
1
+ import { access, constants } from "node:fs/promises";
2
+ import path from "node:path";
3
+ async function canExecute(file) {
4
+ try {
5
+ await access(file, constants.X_OK);
6
+ return true;
7
+ }
8
+ catch {
9
+ return false;
10
+ }
11
+ }
12
+ // Detect a command by resolving it on PATH rather than executing it with a
13
+ // flag: tools disagree on whether `--version`/`--help` exit 0 (OpenAI whisper
14
+ // errors on `--version`), so a PATH lookup is the only reliable presence check.
15
+ async function isOnPath(command) {
16
+ if (command.includes(path.sep))
17
+ return canExecute(command);
18
+ const dirs = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean);
19
+ const exts = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
20
+ for (const dir of dirs) {
21
+ for (const ext of exts) {
22
+ if (await canExecute(path.join(dir, command + ext)))
23
+ return true;
24
+ }
25
+ }
26
+ return false;
27
+ }
28
+ export async function detectEngines(options) {
29
+ const ytDlpCommand = options?.ytDlp ?? "yt-dlp";
30
+ const [ytDlp, whisper, whisperCpp] = await Promise.all([
31
+ isOnPath(ytDlpCommand),
32
+ isOnPath("whisper"),
33
+ isOnPath("whisper-cli"),
34
+ ]);
35
+ return { ytDlp, whisper: whisper || whisperCpp };
36
+ }
package/dist/http.js ADDED
@@ -0,0 +1,42 @@
1
+ const MAX_BACKOFF_MS = 30_000;
2
+ const MAX_RETRY_AFTER_MS = 60_000;
3
+ export function sleep(ms) {
4
+ return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
5
+ }
6
+ export function shouldRetry(status) {
7
+ return status === 429 || (status >= 500 && status <= 599);
8
+ }
9
+ function parseRetryAfterMs(retryAfter) {
10
+ if (!retryAfter)
11
+ return undefined;
12
+ const trimmed = retryAfter.trim();
13
+ const seconds = Number(trimmed);
14
+ if (Number.isFinite(seconds))
15
+ return Math.max(0, seconds * 1000);
16
+ const date = Date.parse(trimmed);
17
+ if (!Number.isNaN(date))
18
+ return Math.max(0, date - Date.now());
19
+ return undefined;
20
+ }
21
+ export function retryDelayMs(status, retryAfter, attempt, baseDelayMs = 1000) {
22
+ const backoff = Math.min(baseDelayMs * 2 ** Math.max(0, attempt), MAX_BACKOFF_MS);
23
+ const jitter = Math.random() * baseDelayMs;
24
+ const computed = Math.min(backoff + jitter, MAX_BACKOFF_MS);
25
+ const headerMs = parseRetryAfterMs(retryAfter);
26
+ if (headerMs != null)
27
+ return Math.min(Math.max(headerMs, computed), MAX_RETRY_AFTER_MS);
28
+ return computed;
29
+ }
30
+ export async function fetchWithRetry(input, init, options = {}) {
31
+ const maxRetries = options.maxRetries ?? 3;
32
+ const baseDelayMs = options.baseDelayMs ?? 1000;
33
+ const doFetch = options.fetchImpl ?? fetch;
34
+ let attempt = 0;
35
+ for (;;) {
36
+ const response = await doFetch(input, init);
37
+ if (!shouldRetry(response.status) || attempt >= maxRetries)
38
+ return response;
39
+ await sleep(retryDelayMs(response.status, response.headers.get("retry-after"), attempt, baseDelayMs));
40
+ attempt += 1;
41
+ }
42
+ }
@@ -0,0 +1,60 @@
1
+ import * as p from "@clack/prompts";
2
+ export function isInteractive() {
3
+ return Boolean(process.stdout.isTTY && process.stdin.isTTY);
4
+ }
5
+ export function intro(message) {
6
+ p.intro(message);
7
+ }
8
+ export function cancelled(message) {
9
+ p.cancel(message);
10
+ }
11
+ function folderHint(folder) {
12
+ const parts = [];
13
+ if (folder.kind === "favorites")
14
+ parts.push("saved videos");
15
+ if (typeof folder.itemCount === "number")
16
+ parts.push(`${folder.itemCount} videos`);
17
+ return parts.length ? parts.join(" \u00b7 ") : undefined;
18
+ }
19
+ export async function promptFolderSelection(folders) {
20
+ const selection = await p.multiselect({
21
+ message: "Select bookmarks to sync",
22
+ options: folders.map((folder) => ({
23
+ value: folder.id,
24
+ label: folder.name,
25
+ hint: folderHint(folder),
26
+ })),
27
+ required: false,
28
+ });
29
+ if (p.isCancel(selection))
30
+ return undefined;
31
+ const ids = new Set(selection);
32
+ return folders.filter((folder) => ids.has(folder.id));
33
+ }
34
+ export async function promptPipelineSelection(defaults) {
35
+ const initialValues = [];
36
+ if (defaults.download)
37
+ initialValues.push("download");
38
+ if (defaults.transcribe)
39
+ initialValues.push("transcribe");
40
+ if (defaults.classify)
41
+ initialValues.push("classify");
42
+ const selection = await p.multiselect({
43
+ message: "What should we do with these?",
44
+ options: [
45
+ { value: "download", label: "Download audio" },
46
+ { value: "transcribe", label: "Transcribe" },
47
+ { value: "classify", label: "Classify" },
48
+ ],
49
+ initialValues,
50
+ required: false,
51
+ });
52
+ if (p.isCancel(selection))
53
+ return undefined;
54
+ const chosen = new Set(selection);
55
+ return {
56
+ download: chosen.has("download"),
57
+ transcribe: chosen.has("transcribe"),
58
+ classify: chosen.has("classify"),
59
+ };
60
+ }
package/dist/markdown.js CHANGED
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { ensureDataDirs, libraryDir, markdownCategoriesDir, markdownDomainsDir, markdownVideosDir, } from "./paths.js";
5
5
  import { sanitizeFilePart } from "./store.js";
6
+ import { videoTitle } from "./reference.js";
6
7
  export async function exportMarkdown(videos, options) {
7
8
  ensureDataDirs();
8
9
  let written = 0;
@@ -48,7 +49,7 @@ export function videoMarkdownPath(video) {
48
49
  return path.join(markdownVideosDir(), `${sanitizeFilePart(video.id)}.md`);
49
50
  }
50
51
  export function renderVideoMarkdown(video) {
51
- const title = video.description?.split(/\r?\n/)[0]?.slice(0, 80) || `Clip ${video.id}`;
52
+ const title = videoTitle(video);
52
53
  const category = video.classification?.category ?? "uncategorized";
53
54
  const domain = video.classification?.domain ?? "general";
54
55
  const topics = video.classification?.topics ?? [];
@@ -160,9 +161,6 @@ function renderGroupPage(kind, name, videos) {
160
161
  "",
161
162
  ].join("\n");
162
163
  }
163
- function videoTitle(video) {
164
- return video.description?.replace(/\s+/g, " ").slice(0, 80) || video.id;
165
- }
166
164
  function groupBy(items, keyFn) {
167
165
  const groups = new Map();
168
166
  for (const item of items) {
package/dist/progress.js CHANGED
@@ -54,3 +54,72 @@ export function createProgress(options) {
54
54
  },
55
55
  };
56
56
  }
57
+ const DEFAULT_SPINNER_MESSAGES = [
58
+ "Thinking",
59
+ "Pondering",
60
+ "Rummaging through your bookmarks",
61
+ "Connecting the dots",
62
+ "Cogitating",
63
+ "Synthesizing",
64
+ "Consulting the archive",
65
+ "Musing",
66
+ "Distilling insights",
67
+ ];
68
+ const MESSAGE_INTERVAL_MS = 2500;
69
+ export function createSpinner(options = {}) {
70
+ const messages = options.messages ?? DEFAULT_SPINNER_MESSAGES;
71
+ const messageIntervalMs = options.messageIntervalMs ?? MESSAGE_INTERVAL_MS;
72
+ const stream = process.stderr;
73
+ const tty = Boolean(stream.isTTY);
74
+ let running = false;
75
+ let frame = 0;
76
+ let messageIndex = 0;
77
+ let startedAt = 0;
78
+ let frameTimer;
79
+ let messageTimer;
80
+ function draw() {
81
+ const elapsedSec = Math.floor((Date.now() - startedAt) / 1000);
82
+ const spinner = c.accent(FRAMES[frame % FRAMES.length] ?? "");
83
+ const message = messages[messageIndex % messages.length] ?? "Thinking";
84
+ stream.write(`${CLEAR_LINE}${spinner} ${message}\u2026 ${c.muted(`(${elapsedSec}s)`)}`);
85
+ }
86
+ function clearTimers() {
87
+ if (frameTimer) {
88
+ clearInterval(frameTimer);
89
+ frameTimer = undefined;
90
+ }
91
+ if (messageTimer) {
92
+ clearInterval(messageTimer);
93
+ messageTimer = undefined;
94
+ }
95
+ }
96
+ return {
97
+ start() {
98
+ if (running || !tty)
99
+ return;
100
+ running = true;
101
+ startedAt = Date.now();
102
+ frame = 0;
103
+ messageIndex = 0;
104
+ draw();
105
+ frameTimer = setInterval(() => {
106
+ frame += 1;
107
+ draw();
108
+ }, FRAME_INTERVAL_MS);
109
+ frameTimer.unref();
110
+ messageTimer = setInterval(() => {
111
+ messageIndex += 1;
112
+ draw();
113
+ }, messageIntervalMs);
114
+ messageTimer.unref();
115
+ },
116
+ stop() {
117
+ if (!running)
118
+ return;
119
+ running = false;
120
+ clearTimers();
121
+ if (tty)
122
+ stream.write(CLEAR_LINE);
123
+ },
124
+ };
125
+ }
@@ -0,0 +1,59 @@
1
+ import { c } from "./render.js";
2
+ const TITLE_MAX = 60;
3
+ const SHORT_ID_LENGTH = 8;
4
+ export function videoTitle(video) {
5
+ const candidates = [
6
+ video.description?.split(/\r?\n/)[0],
7
+ video.classification?.summary,
8
+ video.classification?.topics?.[0],
9
+ video.classification?.category,
10
+ ];
11
+ for (const candidate of candidates) {
12
+ const cleaned = cleanTitle(candidate);
13
+ if (cleaned)
14
+ return cleaned;
15
+ }
16
+ return "Untitled clip";
17
+ }
18
+ export function shortId(video) {
19
+ return video.id.slice(-SHORT_ID_LENGTH);
20
+ }
21
+ export function videoReference(video) {
22
+ const author = video.author?.username
23
+ ? `@${video.author.username}`
24
+ : video.author?.displayName || "unknown";
25
+ const date = referenceDate(video);
26
+ const title = videoTitle(video);
27
+ const head = [author, date].filter(Boolean).join(" \u00b7 ");
28
+ return `${head} \u2014 "${title}" #${shortId(video)}`;
29
+ }
30
+ export function formatReference(video) {
31
+ const author = video.author?.username
32
+ ? c.accent(`@${video.author.username}`)
33
+ : c.muted(video.author?.displayName || "unknown");
34
+ const date = referenceDate(video);
35
+ const datePart = date ? ` ${c.muted("\u00b7")} ${c.muted(date)}` : "";
36
+ const title = c.value(`"${videoTitle(video)}"`);
37
+ return `${author}${datePart} ${c.muted("\u2014")} ${title} ${c.muted(`#${shortId(video)}`)}`;
38
+ }
39
+ function referenceDate(video) {
40
+ const iso = video.createdAt ?? video.savedAt;
41
+ if (!iso)
42
+ return "";
43
+ const date = new Date(iso);
44
+ if (Number.isNaN(date.getTime()))
45
+ return "";
46
+ return date.toLocaleDateString("en-US", { year: "numeric", month: "short" });
47
+ }
48
+ function cleanTitle(value) {
49
+ if (!value)
50
+ return "";
51
+ const collapsed = value.replace(/\s+/g, " ").trim();
52
+ const withoutTrailingTags = collapsed.replace(/(?:\s+#[\p{L}\p{N}_]+)+\s*$/u, "").trim();
53
+ const base = withoutTrailingTags || collapsed;
54
+ if (!base)
55
+ return "";
56
+ if (base.length <= TITLE_MAX)
57
+ return base;
58
+ return `${base.slice(0, TITLE_MAX - 1).trimEnd()}\u2026`;
59
+ }
package/dist/search.js CHANGED
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
2
2
  import { searchIndexPath } from "./paths.js";
3
3
  import { readJsonFile, writeJsonFile } from "./jsonl.js";
4
4
  import { c } from "./render.js";
5
+ import { formatReference } from "./reference.js";
5
6
  const STOP_WORDS = new Set([
6
7
  "a",
7
8
  "an",
@@ -214,10 +215,9 @@ export function formatSearchResults(results, options) {
214
215
  return results
215
216
  .map((result, idx) => {
216
217
  const video = result.video;
217
- const author = video.author?.username ? c.accent(`@${video.author.username}`) : c.muted("unknown");
218
218
  const category = video.classification?.category ? ` ${c.warn(`[${video.classification.category}]`)}` : "";
219
219
  const score = result.score > 0 ? ` ${c.muted(`score ${result.score.toFixed(2)}`)}` : "";
220
- const line = `${c.muted(`${idx + 1}.`)} ${c.value(video.id)} ${author}${category}${score}`;
220
+ const line = `${c.muted(`${idx + 1}.`)} ${formatReference(video)}${category}${score}`;
221
221
  const desc = video.description ? ` ${video.description.replace(/\s+/g, " ").slice(0, 160)}` : "";
222
222
  const hit = result.highlights[0] ? ` ${c.muted(">")} ${c.success(result.highlights[0])}` : "";
223
223
  return [line, desc, hit, ` ${c.muted(video.canonicalUrl ?? video.url)}`].filter(Boolean).join("\n");
package/dist/skill.js CHANGED
@@ -24,7 +24,7 @@ export function skillContent() {
24
24
  "",
25
25
  "## Grounding",
26
26
  "",
27
- "Cite video ids or Markdown page paths when drawing conclusions. Treat transcripts as user-owned local context and do not assume videos are public.",
27
+ "Cite clips by their readable reference (`@author \u00b7 Mon YYYY \u2014 \"title\"`, optionally with the trailing short id like `#49952278`) or by Markdown page path when drawing conclusions. Run `tokwise show <short-id-or-url>` to pull a clip back up. Treat transcripts as user-owned local context and do not assume videos are public.",
28
28
  "",
29
29
  ].join("\n");
30
30
  }
package/dist/store.js CHANGED
@@ -94,7 +94,8 @@ export function findVideo(videos, query) {
94
94
  const normalized = query.trim();
95
95
  return (videos.find((video) => video.id === normalized) ??
96
96
  videos.find((video) => video.id.startsWith(normalized)) ??
97
- videos.find((video) => video.url === normalized || video.canonicalUrl === normalized));
97
+ videos.find((video) => video.url === normalized || video.canonicalUrl === normalized) ??
98
+ videos.find((video) => video.id.endsWith(normalized)));
98
99
  }
99
100
  export async function updateVideosById(updates) {
100
101
  const videos = await loadVideos();
package/dist/tiktok.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { stableHash, uniqueStrings } from "./store.js";
2
+ import { fetchWithRetry, sleep } from "./http.js";
3
+ const WEB_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36";
2
4
  async function loadApi() {
3
5
  const mod = (await import("@tobyg74/tiktok-api-dl"));
4
6
  return mod.default ?? mod;
@@ -97,15 +99,19 @@ async function fetchPaged(fn, idOrUrl, options, context) {
97
99
  const limit = options.limit ?? 30;
98
100
  const pageStart = options.page ?? 1;
99
101
  const maxPages = options.pages ?? Math.ceil(limit / 30);
102
+ const requestDelayMs = options.requestDelayMs ?? 0;
100
103
  const videos = [];
101
104
  let page = pageStart;
102
105
  for (let i = 0; i < maxPages && videos.length < limit; i += 1) {
106
+ if (i > 0 && requestDelayMs > 0)
107
+ await sleep(requestDelayMs);
103
108
  const response = await fn(idOrUrl, {
104
109
  page,
105
110
  count: Math.min(30, limit - videos.length),
106
111
  postLimit: Math.min(30, limit - videos.length),
107
112
  cookie: options.cookie,
108
113
  proxy: options.proxy,
114
+ maxRetries: options.maxRetries,
109
115
  });
110
116
  const items = extractItemsFromSuccessfulResponse(response, context.source);
111
117
  videos.push(...items.map((item) => normalizeVideo(item, context)));
@@ -162,16 +168,16 @@ async function fetchCollectionWithCookie(idOrUrl, options) {
162
168
  user_is_login: "true",
163
169
  webcast_language: "en",
164
170
  });
165
- const response = await fetch(`https://www.tiktok.com/api/collection/item_list/?${params.toString()}`, {
171
+ const response = await fetchWithRetry(`https://www.tiktok.com/api/collection/item_list/?${params.toString()}`, {
166
172
  method: "GET",
167
173
  headers: {
168
- "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
174
+ "user-agent": WEB_USER_AGENT,
169
175
  accept: "application/json, text/plain, */*",
170
176
  "accept-language": "en-US,en;q=0.9",
171
177
  cookie: String(options.cookie ?? ""),
172
178
  referer: looksLikeUrl(idOrUrl) ? idOrUrl : "https://www.tiktok.com/",
173
179
  },
174
- });
180
+ }, { maxRetries: numberValue(options.maxRetries) });
175
181
  if (!response.ok) {
176
182
  return {
177
183
  status: "error",
@@ -180,29 +186,42 @@ async function fetchCollectionWithCookie(idOrUrl, options) {
180
186
  }
181
187
  return response.json();
182
188
  }
183
- export function extractUsernameFromRehydrationHtml(html) {
189
+ function parseRehydrationScope(html) {
184
190
  const match = html.match(/__UNIVERSAL_DATA_FOR_REHYDRATION__"[^>]*>(.*?)<\/script>/s);
185
191
  if (!match || !match[1])
186
192
  return undefined;
187
193
  try {
188
194
  const data = asRecord(JSON.parse(match[1]));
189
- const scope = asRecord(data?.["__DEFAULT_SCOPE__"]);
190
- const appContext = asRecord(scope?.["webapp.app-context"]);
191
- const user = asRecord(appContext?.user);
192
- return stringValue(user?.uniqueId);
195
+ return asRecord(data?.["__DEFAULT_SCOPE__"]);
193
196
  }
194
197
  catch {
195
198
  return undefined;
196
199
  }
197
200
  }
198
- export async function detectLoggedInUsername(cookie, proxy) {
199
- if (!cookie)
200
- return undefined;
201
+ export function extractUsernameFromRehydrationHtml(html) {
202
+ const scope = parseRehydrationScope(html);
203
+ const appContext = asRecord(scope?.["webapp.app-context"]);
204
+ const user = asRecord(appContext?.user);
205
+ return stringValue(user?.uniqueId);
206
+ }
207
+ export function extractSecUidFromRehydrationHtml(html) {
208
+ const scope = parseRehydrationScope(html);
209
+ const appContext = asRecord(scope?.["webapp.app-context"]);
210
+ const contextUser = asRecord(appContext?.user);
211
+ const fromContext = stringValue(contextUser?.secUid);
212
+ if (fromContext)
213
+ return fromContext;
214
+ const userDetail = asRecord(scope?.["webapp.user-detail"]);
215
+ const userInfo = asRecord(userDetail?.userInfo);
216
+ const detailUser = asRecord(userInfo?.user);
217
+ return stringValue(detailUser?.secUid);
218
+ }
219
+ async function fetchRehydrationHtml(url, cookie) {
201
220
  try {
202
- const response = await fetch("https://www.tiktok.com/", {
221
+ const response = await fetch(url, {
203
222
  method: "GET",
204
223
  headers: {
205
- "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
224
+ "user-agent": WEB_USER_AGENT,
206
225
  accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
207
226
  "accept-language": "en-US,en;q=0.9",
208
227
  cookie,
@@ -210,12 +229,157 @@ export async function detectLoggedInUsername(cookie, proxy) {
210
229
  });
211
230
  if (!response.ok)
212
231
  return undefined;
213
- return extractUsernameFromRehydrationHtml(await response.text());
232
+ return await response.text();
214
233
  }
215
234
  catch {
216
235
  return undefined;
217
236
  }
218
237
  }
238
+ export async function detectLoggedInUsername(cookie, proxy) {
239
+ if (!cookie)
240
+ return undefined;
241
+ const html = await fetchRehydrationHtml("https://www.tiktok.com/", cookie);
242
+ return html ? extractUsernameFromRehydrationHtml(html) : undefined;
243
+ }
244
+ export async function resolveSecUid(cookie, username, proxy) {
245
+ if (!cookie)
246
+ return undefined;
247
+ const home = await fetchRehydrationHtml("https://www.tiktok.com/", cookie);
248
+ const fromHome = home ? extractSecUidFromRehydrationHtml(home) : undefined;
249
+ if (fromHome)
250
+ return fromHome;
251
+ if (username) {
252
+ const profile = await fetchRehydrationHtml(`https://www.tiktok.com/@${username}`, cookie);
253
+ const fromProfile = profile ? extractSecUidFromRehydrationHtml(profile) : undefined;
254
+ if (fromProfile)
255
+ return fromProfile;
256
+ }
257
+ return undefined;
258
+ }
259
+ const COLLECTION_LIST_PAGE_SIZE = 30;
260
+ const MAX_BOOKMARK_FOLDERS = 500;
261
+ const FAVORITES_NAMES = new Set(["favorites", "favorite", "favourites", "favourite"]);
262
+ export async function fetchBookmarkFolders(options) {
263
+ const { cookie } = options;
264
+ if (!cookie) {
265
+ throw new Error("A browser cookie is required to list bookmarks. Run `tw auth from-browser` first.");
266
+ }
267
+ const secUid = options.secUid ?? (await resolveSecUid(cookie, options.username, options.proxy));
268
+ if (!secUid) {
269
+ throw new Error("Could not resolve your TikTok secUid. Run `tw auth from-browser` (or `tw auth set-username <handle>`) and try again.");
270
+ }
271
+ const folders = [];
272
+ const seen = new Set();
273
+ let cursor = "0";
274
+ const requestDelayMs = options.requestDelayMs ?? 0;
275
+ let firstPage = true;
276
+ while (folders.length < MAX_BOOKMARK_FOLDERS) {
277
+ if (!firstPage && requestDelayMs > 0)
278
+ await sleep(requestDelayMs);
279
+ firstPage = false;
280
+ const response = await fetchCollectionList(secUid, cursor, COLLECTION_LIST_PAGE_SIZE, cookie, options.maxRetries);
281
+ assertSuccessfulResponse(response, "collection");
282
+ const entries = extractCollectionListEntries(response);
283
+ for (const entry of entries) {
284
+ const folder = normalizeBookmarkFolder(entry, options.username);
285
+ if (!folder || seen.has(folder.id))
286
+ continue;
287
+ seen.add(folder.id);
288
+ folders.push(folder);
289
+ }
290
+ if (!hasMore(response) || entries.length === 0)
291
+ break;
292
+ const next = readCursor(response);
293
+ if (!next || next === cursor)
294
+ break;
295
+ cursor = next;
296
+ }
297
+ return folders;
298
+ }
299
+ async function fetchCollectionList(secUid, cursor, count, cookie, maxRetries) {
300
+ const params = new URLSearchParams({
301
+ WebIdLastTime: String(Date.now()),
302
+ aid: "1988",
303
+ app_language: "en",
304
+ app_name: "tiktok_web",
305
+ browser_language: "en-US",
306
+ browser_name: "Mozilla",
307
+ browser_online: "true",
308
+ browser_platform: "MacIntel",
309
+ browser_version: "5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
310
+ channel: "tiktok_web",
311
+ cookie_enabled: "true",
312
+ count: String(count),
313
+ cursor,
314
+ device_platform: "web_pc",
315
+ focus_state: "true",
316
+ from_page: "user",
317
+ history_len: "3",
318
+ is_fullscreen: "false",
319
+ is_page_visible: "true",
320
+ language: "en",
321
+ needPinnedItemIds: "true",
322
+ os: "mac",
323
+ secUid,
324
+ tz_name: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
325
+ user_is_login: "true",
326
+ webcast_language: "en",
327
+ });
328
+ const response = await fetchWithRetry(`https://www.tiktok.com/api/user/collection_list/?${params.toString()}`, {
329
+ method: "GET",
330
+ headers: {
331
+ "user-agent": WEB_USER_AGENT,
332
+ accept: "application/json, text/plain, */*",
333
+ "accept-language": "en-US,en;q=0.9",
334
+ cookie,
335
+ referer: "https://www.tiktok.com/",
336
+ },
337
+ }, { maxRetries });
338
+ if (!response.ok) {
339
+ return {
340
+ status: "error",
341
+ message: `Source returned HTTP ${response.status} ${response.statusText}`,
342
+ };
343
+ }
344
+ return response.json();
345
+ }
346
+ export function extractCollectionListEntries(response) {
347
+ const root = asRecord(response);
348
+ const result = asRecord(root?.result);
349
+ const candidates = [root?.collectionList, root?.collection_list, result?.collectionList, result?.collection_list];
350
+ for (const candidate of candidates) {
351
+ if (Array.isArray(candidate)) {
352
+ return candidate.flatMap((item) => (asRecord(item) ? [asRecord(item)] : []));
353
+ }
354
+ }
355
+ return [];
356
+ }
357
+ export function normalizeBookmarkFolder(entry, username) {
358
+ const id = stringValue(entry.collectionId) ?? stringValue(entry.id) ?? stringValue(entry.collection_id);
359
+ if (!id)
360
+ return undefined;
361
+ const name = stringValue(entry.name) ?? stringValue(entry.collectionName) ?? "Untitled";
362
+ const itemCount = numberValue(entry.total) ??
363
+ numberValue(entry.itemCount) ??
364
+ numberValue(entry.itemTotal) ??
365
+ numberValue(entry.videoCount);
366
+ const cover = firstString(entry.cover) ?? stringValue(entry.cover) ?? firstString(entry.coverUrl) ?? stringValue(entry.coverUrl);
367
+ const kind = FAVORITES_NAMES.has(name.trim().toLowerCase()) ? "favorites" : "collection";
368
+ const url = username ? `https://www.tiktok.com/@${username}/collection/${slugifyCollection(name, id)}` : undefined;
369
+ return { id, name, url, itemCount, cover, kind };
370
+ }
371
+ function slugifyCollection(name, id) {
372
+ const slug = name
373
+ .toLowerCase()
374
+ .replace(/[^a-z0-9]+/g, "-")
375
+ .replace(/^-+|-+$/g, "");
376
+ return slug ? `${slug}-${id}` : id;
377
+ }
378
+ function readCursor(response) {
379
+ const root = asRecord(response);
380
+ const result = asRecord(root?.result);
381
+ return stringValue(result?.cursor) ?? stringValue(root?.cursor);
382
+ }
219
383
  export function extractItemsFromSuccessfulResponse(response, source) {
220
384
  assertSuccessfulResponse(response, source);
221
385
  return extractItems(response).filter(isLikelyVideoItem);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokwise",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Local-first CLI for syncing, downloading, transcribing, searching, and analyzing saved short-form videos.",
5
5
  "type": "module",
6
6
  "author": "Sebastian Crossa",
@@ -45,6 +45,7 @@
45
45
  "whisper"
46
46
  ],
47
47
  "dependencies": {
48
+ "@clack/prompts": "^1.6.0",
48
49
  "@tobyg74/tiktok-api-dl": "^1.3.7",
49
50
  "commander": "^15.0.0",
50
51
  "picocolors": "^1.1.1"