tokwise 0.1.0 → 0.1.2
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 +49 -3
- package/dist/ask.js +5 -5
- package/dist/browser-cookies.js +2 -1
- package/dist/cli.js +215 -45
- package/dist/engines.js +36 -0
- package/dist/http.js +42 -0
- package/dist/interactive.js +60 -0
- package/dist/markdown.js +2 -4
- package/dist/reference.js +59 -0
- package/dist/search.js +2 -2
- package/dist/skill.js +1 -1
- package/dist/store.js +2 -1
- package/dist/tiktok.js +178 -14
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -43,7 +43,7 @@ npm link
|
|
|
43
43
|
```bash
|
|
44
44
|
# Optional: save a browser cookie for private collections or liked videos.
|
|
45
45
|
# Easiest on macOS: pull it straight from a logged-in Chromium browser.
|
|
46
|
-
tokwise auth from-browser # auto-detects Chrome, Brave, Edge, Arc, or Chromium
|
|
46
|
+
tokwise auth from-browser # auto-detects Chrome, Brave, Edge, Arc, Dia, or Chromium
|
|
47
47
|
tokwise auth refresh # re-pull later when the session goes stale
|
|
48
48
|
|
|
49
49
|
# Or paste a cookie manually (works everywhere).
|
|
@@ -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
|
-
#
|
|
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>
|
|
@@ -155,7 +201,7 @@ tokwise sync --collection "name-123" # uses the @handle saved with your cookie
|
|
|
155
201
|
|
|
156
202
|
The bare-slug form needs the username tied to your cookie. Tokwise tries to detect it automatically when you run `tokwise auth set` or `tokwise auth from-browser`; you can also set it explicitly with `tokwise auth set-username <handle>` or `--username` on those commands. `tokwise auth show` reports the saved handle.
|
|
157
203
|
|
|
158
|
-
Private collections usually require a fresh browser cookie from a logged-in session. On macOS, `tokwise auth from-browser` reads and decrypts it straight from a logged-in Chromium browser (Chrome, Brave, Edge, Arc, or Chromium) via the macOS Keychain, and `tokwise auth refresh` re-pulls it when the session goes stale. On other platforms or browsers, paste it manually with `tokwise auth set`. Cookies are stored locally only (`auth.json`, chmod 600).
|
|
204
|
+
Private collections usually require a fresh browser cookie from a logged-in session. On macOS, `tokwise auth from-browser` reads and decrypts it straight from a logged-in Chromium browser (Chrome, Brave, Edge, Arc, Dia, or Chromium) via the macOS Keychain, and `tokwise auth refresh` re-pulls it when the session goes stale. On other platforms or browsers, paste it manually with `tokwise auth set`. Cookies are stored locally only (`auth.json`, chmod 600).
|
|
159
205
|
|
|
160
206
|
## Transcription
|
|
161
207
|
|
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
|
|
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
|
|
33
|
-
`
|
|
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
|
|
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
|
"",
|
package/dist/browser-cookies.js
CHANGED
|
@@ -3,12 +3,13 @@ import fs from "node:fs/promises";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { runProcess } from "./process.js";
|
|
6
|
-
export const SUPPORTED_BROWSERS = ["chrome", "brave", "edge", "arc", "chromium"];
|
|
6
|
+
export const SUPPORTED_BROWSERS = ["chrome", "brave", "edge", "arc", "dia", "chromium"];
|
|
7
7
|
const CHANNELS = {
|
|
8
8
|
chrome: { dir: "Google/Chrome", service: "Chrome Safe Storage", account: "Chrome" },
|
|
9
9
|
brave: { dir: "BraveSoftware/Brave-Browser", service: "Brave Safe Storage", account: "Brave" },
|
|
10
10
|
edge: { dir: "Microsoft Edge", service: "Microsoft Edge Safe Storage", account: "Microsoft Edge" },
|
|
11
11
|
arc: { dir: "Arc/User Data", service: "Arc Safe Storage", account: "Arc" },
|
|
12
|
+
dia: { dir: "Dia/User Data", service: "Dia Safe Storage", account: "Dia" },
|
|
12
13
|
chromium: { dir: "Chromium", service: "Chromium Safe Storage", account: "Chromium" },
|
|
13
14
|
};
|
|
14
15
|
export function isChromiumBrowser(value) {
|
package/dist/cli.js
CHANGED
|
@@ -13,11 +13,15 @@ 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";
|
|
23
|
+
import { barChart, box, c, kvList, setColorEnabled, table, truncate } from "./render.js";
|
|
24
|
+
import { formatReference } from "./reference.js";
|
|
21
25
|
import { createProgress } from "./progress.js";
|
|
22
26
|
const require = createRequire(import.meta.url);
|
|
23
27
|
function version() {
|
|
@@ -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
|
-
|
|
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")
|
|
@@ -548,10 +535,13 @@ export function buildCli() {
|
|
|
548
535
|
}));
|
|
549
536
|
return program;
|
|
550
537
|
}
|
|
551
|
-
|
|
538
|
+
export function progressLabel(base, label) {
|
|
539
|
+
return label ? `${base} \u00b7 ${label}` : base;
|
|
540
|
+
}
|
|
541
|
+
async function runDownloads(videos, touched, options, label) {
|
|
552
542
|
const next = [...videos];
|
|
553
543
|
const total = next.filter((video) => touched.has(video.id)).length;
|
|
554
|
-
const progress = createProgress({ total, label: "media" });
|
|
544
|
+
const progress = createProgress({ total, label: progressLabel("media", label) });
|
|
555
545
|
let downloaded = 0;
|
|
556
546
|
let present = 0;
|
|
557
547
|
const failed = [];
|
|
@@ -574,13 +564,14 @@ async function runDownloads(videos, touched, options) {
|
|
|
574
564
|
}
|
|
575
565
|
progress.done();
|
|
576
566
|
await saveVideos(next);
|
|
577
|
-
|
|
567
|
+
const indent = label ? " " : "";
|
|
568
|
+
console.log(`${indent}Media: ${downloaded} downloaded, ${present} already present${failed.length ? `, ${failed.length} failed (${failed.join(", ")})` : ""} (${total} total).`);
|
|
578
569
|
return next;
|
|
579
570
|
}
|
|
580
|
-
async function runTranscription(videos, touched, options) {
|
|
571
|
+
async function runTranscription(videos, touched, options, label) {
|
|
581
572
|
const next = [...videos];
|
|
582
573
|
const total = next.filter((video) => touched.has(video.id)).length;
|
|
583
|
-
const progress = createProgress({ total, label: "transcribe" });
|
|
574
|
+
const progress = createProgress({ total, label: progressLabel("transcribe", label) });
|
|
584
575
|
let transcribed = 0;
|
|
585
576
|
let present = 0;
|
|
586
577
|
const failed = [];
|
|
@@ -604,13 +595,14 @@ async function runTranscription(videos, touched, options) {
|
|
|
604
595
|
}
|
|
605
596
|
progress.done();
|
|
606
597
|
await saveVideos(next);
|
|
607
|
-
|
|
598
|
+
const indent = label ? " " : "";
|
|
599
|
+
console.log(`${indent}Transcripts: ${transcribed} transcribed, ${present} already present${failed.length ? `, ${failed.length} failed (${failed.join(", ")})` : ""} (${total} total).`);
|
|
608
600
|
return next;
|
|
609
601
|
}
|
|
610
|
-
async function runClassification(videos, touched, options) {
|
|
602
|
+
async function runClassification(videos, touched, options, label) {
|
|
611
603
|
const next = [...videos];
|
|
612
604
|
const total = next.filter((video) => touched.has(video.id)).length;
|
|
613
|
-
const progress = createProgress({ total, label: "classify" });
|
|
605
|
+
const progress = createProgress({ total, label: progressLabel("classify", label) });
|
|
614
606
|
let classified = 0;
|
|
615
607
|
const failed = [];
|
|
616
608
|
for (const [idx, video] of next.entries()) {
|
|
@@ -629,9 +621,188 @@ async function runClassification(videos, touched, options) {
|
|
|
629
621
|
}
|
|
630
622
|
progress.done();
|
|
631
623
|
await saveVideos(next);
|
|
632
|
-
|
|
624
|
+
const indent = label ? " " : "";
|
|
625
|
+
console.log(`${indent}Classified ${classified} videos${failed.length ? `, ${failed.length} failed (${failed.join(", ")})` : ""} (${total} total).`);
|
|
633
626
|
return next;
|
|
634
627
|
}
|
|
628
|
+
export function hasExplicitSource(options) {
|
|
629
|
+
return (options.collection.length > 0 ||
|
|
630
|
+
options.playlist.length > 0 ||
|
|
631
|
+
options.liked.length > 0 ||
|
|
632
|
+
options.user.length > 0 ||
|
|
633
|
+
options.searchVideo.length > 0 ||
|
|
634
|
+
options.url.length > 0 ||
|
|
635
|
+
Boolean(options.urlsFile) ||
|
|
636
|
+
Boolean(options.input));
|
|
637
|
+
}
|
|
638
|
+
async function runSyncPipeline(groups, options) {
|
|
639
|
+
const showPerGroup = groups.length > 1;
|
|
640
|
+
let videos = await loadVideos();
|
|
641
|
+
for (const [index, group] of groups.entries()) {
|
|
642
|
+
const groupLabel = showPerGroup ? group.label : undefined;
|
|
643
|
+
if (showPerGroup)
|
|
644
|
+
console.log(c.heading(group.label));
|
|
645
|
+
const sync = await mergeVideos(group.videos, { rebuild: Boolean(options.rebuild) && index === 0 });
|
|
646
|
+
console.log(showPerGroup
|
|
647
|
+
? ` Synced ${sync.added} new, ${sync.updated} updated, ${sync.unchanged} unchanged`
|
|
648
|
+
: `Synced ${sync.added} new, ${sync.updated} updated, ${sync.unchanged} unchanged (${sync.total} total).`);
|
|
649
|
+
videos = await loadVideos();
|
|
650
|
+
const touched = new Set(sync.ids);
|
|
651
|
+
if (options.download) {
|
|
652
|
+
videos = await runDownloads(videos, touched, {
|
|
653
|
+
ytDlp: options.ytDlp,
|
|
654
|
+
proxy: options.proxy,
|
|
655
|
+
cookiesFile: options.ytDlpCookies,
|
|
656
|
+
cookiesFromBrowser: options.cookiesFromBrowser,
|
|
657
|
+
audioOnly: Boolean(options.audio),
|
|
658
|
+
}, groupLabel);
|
|
659
|
+
}
|
|
660
|
+
if (options.transcribe) {
|
|
661
|
+
videos = await runTranscription(videos, touched, {
|
|
662
|
+
engine: options.sttEngine,
|
|
663
|
+
command: options.sttCommand,
|
|
664
|
+
model: options.sttModel,
|
|
665
|
+
language: options.language,
|
|
666
|
+
}, groupLabel);
|
|
667
|
+
}
|
|
668
|
+
if (options.classify) {
|
|
669
|
+
videos = await runClassification(videos, touched, {
|
|
670
|
+
engine: options.engine,
|
|
671
|
+
model: options.model,
|
|
672
|
+
ollamaBaseUrl: options.ollamaUrl,
|
|
673
|
+
}, groupLabel);
|
|
674
|
+
}
|
|
675
|
+
if (showPerGroup)
|
|
676
|
+
console.log("");
|
|
677
|
+
}
|
|
678
|
+
const index = await saveSearchIndex(videos);
|
|
679
|
+
console.log(`Indexed ${index.recordCount} videos at ${toDisplayPath(searchIndexPath())}.`);
|
|
680
|
+
}
|
|
681
|
+
function printBookmarkFolders(folders, options) {
|
|
682
|
+
if (options.json) {
|
|
683
|
+
console.log(JSON.stringify(folders.map((folder) => ({
|
|
684
|
+
id: folder.id,
|
|
685
|
+
name: folder.name,
|
|
686
|
+
itemCount: folder.itemCount ?? null,
|
|
687
|
+
url: folder.url ?? null,
|
|
688
|
+
kind: folder.kind,
|
|
689
|
+
})), null, 2));
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
const rows = folders.map((folder) => [
|
|
693
|
+
folder.name,
|
|
694
|
+
folder.kind,
|
|
695
|
+
typeof folder.itemCount === "number" ? String(folder.itemCount) : "",
|
|
696
|
+
c.muted(folder.id),
|
|
697
|
+
]);
|
|
698
|
+
console.log(table(["Name", "Kind", "Videos", "ID"], rows));
|
|
699
|
+
}
|
|
700
|
+
async function runInteractiveSync(options, cookie, auth) {
|
|
701
|
+
if (options.json)
|
|
702
|
+
setColorEnabled(false);
|
|
703
|
+
if (!cookie) {
|
|
704
|
+
throw new Error("No cookie saved. Run `tw auth from-browser` (or `tw auth set --cookie ...`) to enable bookmark sync.");
|
|
705
|
+
}
|
|
706
|
+
let secUid = auth.secUid;
|
|
707
|
+
if (!secUid) {
|
|
708
|
+
secUid = await resolveSecUid(cookie, auth.username);
|
|
709
|
+
if (secUid)
|
|
710
|
+
await saveAuth({ ...auth, secUid, updatedAt: new Date().toISOString() });
|
|
711
|
+
}
|
|
712
|
+
const folders = await fetchBookmarkFolders({
|
|
713
|
+
cookie,
|
|
714
|
+
username: auth.username,
|
|
715
|
+
secUid,
|
|
716
|
+
proxy: options.proxy,
|
|
717
|
+
limit: Number(options.limit),
|
|
718
|
+
});
|
|
719
|
+
if (folders.length === 0) {
|
|
720
|
+
console.log("No bookmarks found. Make sure your cookie is fresh (try `tw auth refresh`).");
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
if (options.list) {
|
|
724
|
+
printBookmarkFolders(folders, { json: Boolean(options.json) });
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
let selected;
|
|
728
|
+
if (options.all) {
|
|
729
|
+
selected = folders;
|
|
730
|
+
}
|
|
731
|
+
else if (isInteractive()) {
|
|
732
|
+
intro(`Found ${folders.length} bookmarks${auth.username ? ` for @${auth.username}` : ""}`);
|
|
733
|
+
const picked = await promptFolderSelection(folders);
|
|
734
|
+
if (!picked) {
|
|
735
|
+
cancelled("Sync cancelled.");
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
selected = picked;
|
|
739
|
+
}
|
|
740
|
+
else {
|
|
741
|
+
printBookmarkFolders(folders, { json: false });
|
|
742
|
+
console.log("");
|
|
743
|
+
console.log(`${c.muted("Hint")} run \`tokwise sync --all\` to sync everything, \`--collection <id>\` for one, or \`--list --json\` for machine output.`);
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
if (selected.length === 0) {
|
|
747
|
+
console.log("Nothing selected.");
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
const pipelineFlagsGiven = Boolean(options.download || options.transcribe || options.classify);
|
|
751
|
+
if (isInteractive() && !options.all && !pipelineFlagsGiven) {
|
|
752
|
+
const engines = await detectEngines({ ytDlp: options.ytDlp });
|
|
753
|
+
const pipeline = await promptPipelineSelection({
|
|
754
|
+
download: engines.ytDlp,
|
|
755
|
+
transcribe: engines.ytDlp && engines.whisper,
|
|
756
|
+
classify: true,
|
|
757
|
+
});
|
|
758
|
+
if (!pipeline) {
|
|
759
|
+
cancelled("Sync cancelled.");
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
options.download = pipeline.download;
|
|
763
|
+
options.transcribe = pipeline.transcribe;
|
|
764
|
+
options.classify = pipeline.classify;
|
|
765
|
+
if (pipeline.download && !options.audio)
|
|
766
|
+
options.audio = true;
|
|
767
|
+
}
|
|
768
|
+
const requestDelayMs = Number(options.requestDelay);
|
|
769
|
+
const fetchOptions = {
|
|
770
|
+
cookie,
|
|
771
|
+
username: auth.username,
|
|
772
|
+
secUid,
|
|
773
|
+
proxy: options.proxy,
|
|
774
|
+
limit: Number(options.limit),
|
|
775
|
+
page: Number(options.page),
|
|
776
|
+
pages: options.pages == null ? undefined : Number(options.pages),
|
|
777
|
+
requestDelayMs,
|
|
778
|
+
maxRetries: Number(options.maxRetries),
|
|
779
|
+
};
|
|
780
|
+
const groups = [];
|
|
781
|
+
const failed = [];
|
|
782
|
+
for (const [index, folder] of selected.entries()) {
|
|
783
|
+
if (index > 0 && requestDelayMs > 0)
|
|
784
|
+
await sleep(requestDelayMs);
|
|
785
|
+
try {
|
|
786
|
+
const videos = await fetchCollection(folder.url ?? folder.id, fetchOptions);
|
|
787
|
+
if (videos.length > 0)
|
|
788
|
+
groups.push({ label: folder.name, videos });
|
|
789
|
+
}
|
|
790
|
+
catch (error) {
|
|
791
|
+
failed.push(folder.name);
|
|
792
|
+
console.error(c.warn(`Skipped "${folder.name}": ${error.message}`));
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
if (failed.length > 0) {
|
|
796
|
+
const names = failed.map((name) => `"${name}"`).join(", ");
|
|
797
|
+
const suggestion = Math.max(1500, requestDelayMs * 2);
|
|
798
|
+
console.error(c.warn(`${failed.length} collection${failed.length === 1 ? "" : "s"} skipped after retries: ${names}. Re-run or try --request-delay ${suggestion}.`));
|
|
799
|
+
}
|
|
800
|
+
if (groups.length === 0) {
|
|
801
|
+
console.log("No videos found in the selected bookmarks.");
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
await runSyncPipeline(groups, options);
|
|
805
|
+
}
|
|
635
806
|
async function readImport(filePath) {
|
|
636
807
|
const text = await readTextInput(filePath);
|
|
637
808
|
const trimmed = text.trim();
|
|
@@ -677,12 +848,11 @@ function formatList(videos) {
|
|
|
677
848
|
return c.muted("No videos.");
|
|
678
849
|
return videos
|
|
679
850
|
.map((video) => {
|
|
680
|
-
const author = video.author?.username ? c.accent(`@${video.author.username}`) : c.muted("unknown");
|
|
681
851
|
const category = video.classification?.category ? ` ${c.warn(`[${video.classification.category}]`)}` : "";
|
|
682
852
|
const transcript = video.transcript?.text ? ` ${c.success("transcript")}` : "";
|
|
683
853
|
const desc = truncate((video.description ?? "").replace(/\s+/g, " "), 160);
|
|
684
854
|
return [
|
|
685
|
-
`${
|
|
855
|
+
`${formatReference(video)}${category}${transcript}`,
|
|
686
856
|
` ${desc}`,
|
|
687
857
|
` ${c.muted(video.canonicalUrl ?? video.url)}`,
|
|
688
858
|
].join("\n");
|
|
@@ -691,7 +861,7 @@ function formatList(videos) {
|
|
|
691
861
|
}
|
|
692
862
|
function formatVideo(video) {
|
|
693
863
|
return [
|
|
694
|
-
|
|
864
|
+
formatReference(video),
|
|
695
865
|
c.muted(video.canonicalUrl ?? video.url),
|
|
696
866
|
"",
|
|
697
867
|
video.description ?? "",
|
|
@@ -869,7 +1039,7 @@ async function showDashboard() {
|
|
|
869
1039
|
indexExists: await fileExists(searchIndexPath()),
|
|
870
1040
|
}),
|
|
871
1041
|
"",
|
|
872
|
-
`${c.muted("Next")} tokwise sync --collection <url> --download --audio --transcribe --classify`,
|
|
1042
|
+
`${c.muted("Next")} tokwise sync ${c.muted("(pick your bookmarks)")} ${c.muted("|")} tokwise sync --collection <url> --download --audio --transcribe --classify`,
|
|
873
1043
|
`${c.muted("Explore")} tokwise search "life advice" ${c.muted("|")} tokwise viz ${c.muted("|")} tokwise wiki`,
|
|
874
1044
|
].join("\n"));
|
|
875
1045
|
}
|
package/dist/engines.js
ADDED
|
@@ -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
|
|
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) {
|
|
@@ -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}.`)} ${
|
|
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
|
|
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
|
|
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":
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
199
|
-
|
|
200
|
-
|
|
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(
|
|
221
|
+
const response = await fetch(url, {
|
|
203
222
|
method: "GET",
|
|
204
223
|
headers: {
|
|
205
|
-
"user-agent":
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.1.2",
|
|
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"
|