tokwise 0.1.0
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/LICENSE +21 -0
- package/README.md +185 -0
- package/dist/ask.js +58 -0
- package/dist/browser-cookies.js +160 -0
- package/dist/classify.js +118 -0
- package/dist/cli.js +894 -0
- package/dist/jsonl.js +51 -0
- package/dist/library.js +138 -0
- package/dist/markdown.js +211 -0
- package/dist/media.js +117 -0
- package/dist/paths.js +87 -0
- package/dist/process.js +68 -0
- package/dist/progress.js +56 -0
- package/dist/render.js +114 -0
- package/dist/search.js +226 -0
- package/dist/skill.js +57 -0
- package/dist/store.js +158 -0
- package/dist/tiktok.js +445 -0
- package/dist/transcribe.js +162 -0
- package/dist/types.js +1 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sebastian Crossa
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# Tokwise CLI
|
|
2
|
+
|
|
3
|
+
Local-first command line tooling for turning saved short-form videos into a searchable, transcript-centered knowledge base.
|
|
4
|
+
|
|
5
|
+
Tokwise syncs clips into local files, builds a search index, downloads media, transcribes audio, classifies themes, exports Markdown, compiles a wiki, answers questions against local evidence, and installs an agent skill.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g tokwise
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or run without installing:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx tokwise status
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Main command:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
tokwise status
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Short alias:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
tw status
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Requires Node.js 20+. Media download requires `yt-dlp` on PATH. Transcription requires either OpenAI Whisper CLI, whisper.cpp, or a custom command.
|
|
32
|
+
|
|
33
|
+
### Develop
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm install
|
|
37
|
+
npm run build
|
|
38
|
+
npm link
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Quick Start
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# Optional: save a browser cookie for private collections or liked videos.
|
|
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
|
|
47
|
+
tokwise auth refresh # re-pull later when the session goes stale
|
|
48
|
+
|
|
49
|
+
# Or paste a cookie manually (works everywhere).
|
|
50
|
+
tokwise auth set --cookie "YOUR_COOKIE"
|
|
51
|
+
|
|
52
|
+
# Tokwise tries to detect the @handle tied to your cookie. Set it manually if needed.
|
|
53
|
+
tokwise auth set-username your-handle
|
|
54
|
+
|
|
55
|
+
# Sync a collection, download audio, transcribe, classify, and index.
|
|
56
|
+
# --collection accepts a full URL, an @user/collection/slug path, or a bare slug.
|
|
57
|
+
tokwise sync --collection "name-123" \
|
|
58
|
+
--limit 200 \
|
|
59
|
+
--download --audio \
|
|
60
|
+
--transcribe --stt-engine whisper --stt-model base \
|
|
61
|
+
--classify
|
|
62
|
+
|
|
63
|
+
# Search and explore.
|
|
64
|
+
tokwise search "how to choose a career"
|
|
65
|
+
tokwise similar <video-id>
|
|
66
|
+
tokwise categories
|
|
67
|
+
tokwise stats
|
|
68
|
+
tokwise md
|
|
69
|
+
tokwise wiki
|
|
70
|
+
tokwise ask "What patterns show up in my saved advice videos?" --engine ollama --model llama3.1
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Every `tokwise` command can also be run with `tw`.
|
|
74
|
+
|
|
75
|
+
## Core Commands
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
tokwise sync Sync URLs, collections, playlists, liked videos, or imports
|
|
79
|
+
tokwise fetch-media Download video/audio with yt-dlp for existing records
|
|
80
|
+
tokwise transcribe Run Whisper, whisper.cpp, or a custom STT command
|
|
81
|
+
tokwise index Rebuild the BM25 search index
|
|
82
|
+
tokwise search <query> Full-text search across descriptions and transcripts
|
|
83
|
+
tokwise list Filter by author, date, category, domain, collection, transcript state
|
|
84
|
+
tokwise show <id> Show one video in detail
|
|
85
|
+
tokwise similar <id> Find transcript-similar videos
|
|
86
|
+
tokwise stats Counts, date range, top authors, transcript coverage
|
|
87
|
+
tokwise viz Terminal dashboard with simple bars
|
|
88
|
+
tokwise categories Category distribution
|
|
89
|
+
tokwise domains Domain distribution
|
|
90
|
+
tokwise collections Collection/source distribution
|
|
91
|
+
tokwise classify Regex or local Ollama classification
|
|
92
|
+
tokwise model View or change default local model preferences
|
|
93
|
+
tokwise md Export one Markdown file per video
|
|
94
|
+
tokwise wiki Compile an interlinked local wiki
|
|
95
|
+
tokwise ask <question> Ask against top local matches, optionally via Ollama
|
|
96
|
+
tokwise lint Check generated wiki links
|
|
97
|
+
tokwise library ... Manage local Markdown library pages
|
|
98
|
+
tokwise commands ... Manage reusable local command notes
|
|
99
|
+
tokwise skill ... Install/show/uninstall an agent skill
|
|
100
|
+
tokwise paths/status/path Show local data locations and health
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Data Layout
|
|
104
|
+
|
|
105
|
+
By default data lives under `~/.tokwise/`.
|
|
106
|
+
|
|
107
|
+
```text
|
|
108
|
+
~/.tokwise/
|
|
109
|
+
videos/
|
|
110
|
+
videos.jsonl # one normalized video record per line
|
|
111
|
+
search-index.json # local BM25 index
|
|
112
|
+
auth.json # optional browser cookie, chmod 600
|
|
113
|
+
media/ # yt-dlp video files
|
|
114
|
+
audio/ # yt-dlp extracted audio files
|
|
115
|
+
transcripts/ # .json and .txt STT outputs
|
|
116
|
+
library/
|
|
117
|
+
index.md # generated wiki entry point
|
|
118
|
+
videos/*.md # one markdown page per video
|
|
119
|
+
categories/*.md
|
|
120
|
+
domains/*.md
|
|
121
|
+
commands/
|
|
122
|
+
*.md # portable command notes for agents
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Override locations with:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
export TOKWISE_DATA_DIR=/path/to/data
|
|
129
|
+
export TOKWISE_LIBRARY_DIR=/path/to/library
|
|
130
|
+
export TOKWISE_COMMANDS_DIR=/path/to/commands
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Legacy `TT_*` environment variables and `~/.tiktoktheory` are still read so existing local archives keep working after the rename.
|
|
134
|
+
|
|
135
|
+
## Sources
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
tokwise sync --collection <url | @user/collection/slug | slug-or-id>
|
|
139
|
+
tokwise sync --playlist <playlist-id-or-url>
|
|
140
|
+
tokwise sync --liked <username>
|
|
141
|
+
tokwise sync --user <username>
|
|
142
|
+
tokwise sync --search-video "life advice"
|
|
143
|
+
tokwise sync --url "https://www.tiktok.com/@user/video/123"
|
|
144
|
+
tokwise sync --urls-file urls.txt
|
|
145
|
+
tokwise sync --input export.jsonl
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`--collection` accepts three forms, from most to least explicit:
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
tokwise sync --collection "https://www.tiktok.com/@user/collection/name-123"
|
|
152
|
+
tokwise sync --collection "@user/collection/name-123"
|
|
153
|
+
tokwise sync --collection "name-123" # uses the @handle saved with your cookie
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
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
|
+
|
|
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).
|
|
159
|
+
|
|
160
|
+
## Transcription
|
|
161
|
+
|
|
162
|
+
Whisper CLI:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
tokwise transcribe --engine whisper --model base --language en
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
whisper.cpp:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
tokwise transcribe --engine whisper-cpp --command whisper-cli --model /path/to/ggml-base.en.bin
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Custom command:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
tokwise transcribe --engine custom \
|
|
178
|
+
--command 'my-stt --input "{input}" --output "{output}" --language "{language}"'
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
The custom command should write JSON, plain text, or print the transcript to stdout.
|
|
182
|
+
|
|
183
|
+
## Attribution
|
|
184
|
+
|
|
185
|
+
Tokwise was inspired by [afar1/fieldtheory-cli](https://github.com/afar1/fieldtheory-cli), especially its local-first approach to syncing personal saved content into searchable files, Markdown, and agent-readable workflows.
|
package/dist/ask.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export async function answerQuestion(question, results, options) {
|
|
2
|
+
if (results.length === 0)
|
|
3
|
+
return "No local evidence matched that question.";
|
|
4
|
+
if (options.engine === "ollama")
|
|
5
|
+
return answerWithOllama(question, results, options);
|
|
6
|
+
return answerExtractively(question, results);
|
|
7
|
+
}
|
|
8
|
+
function answerExtractively(question, results) {
|
|
9
|
+
return [
|
|
10
|
+
`Question: ${question}`,
|
|
11
|
+
"",
|
|
12
|
+
"Top local evidence:",
|
|
13
|
+
"",
|
|
14
|
+
...results.slice(0, 8).map((result, idx) => {
|
|
15
|
+
const video = result.video;
|
|
16
|
+
const author = video.author?.username ? `@${video.author.username}` : "unknown";
|
|
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}`;
|
|
19
|
+
}),
|
|
20
|
+
"",
|
|
21
|
+
"Use --engine ollama --model <model> for a synthesized answer from a local model.",
|
|
22
|
+
].join("\n");
|
|
23
|
+
}
|
|
24
|
+
async function answerWithOllama(question, results, options) {
|
|
25
|
+
const baseUrl = options.ollamaBaseUrl ?? "http://localhost:11434";
|
|
26
|
+
const model = options.model ?? "llama3.1";
|
|
27
|
+
const context = results
|
|
28
|
+
.slice(0, 10)
|
|
29
|
+
.map((result, idx) => {
|
|
30
|
+
const video = result.video;
|
|
31
|
+
return [
|
|
32
|
+
`[${idx + 1}] ${video.id} ${video.canonicalUrl ?? video.url}`,
|
|
33
|
+
`Author: ${video.author?.username ?? "unknown"}`,
|
|
34
|
+
`Category: ${video.classification?.category ?? "unknown"}`,
|
|
35
|
+
`Summary: ${video.classification?.summary ?? ""}`,
|
|
36
|
+
`Transcript: ${(video.transcript?.text ?? video.description ?? "").slice(0, 2500)}`,
|
|
37
|
+
].join("\n");
|
|
38
|
+
})
|
|
39
|
+
.join("\n\n");
|
|
40
|
+
const prompt = [
|
|
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.",
|
|
43
|
+
"",
|
|
44
|
+
`Question: ${question}`,
|
|
45
|
+
"",
|
|
46
|
+
"Evidence:",
|
|
47
|
+
context,
|
|
48
|
+
].join("\n");
|
|
49
|
+
const response = await fetch(`${baseUrl.replace(/\/+$/, "")}/api/generate`, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: { "content-type": "application/json" },
|
|
52
|
+
body: JSON.stringify({ model, prompt, stream: false }),
|
|
53
|
+
});
|
|
54
|
+
if (!response.ok)
|
|
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.";
|
|
58
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { runProcess } from "./process.js";
|
|
6
|
+
export const SUPPORTED_BROWSERS = ["chrome", "brave", "edge", "arc", "chromium"];
|
|
7
|
+
const CHANNELS = {
|
|
8
|
+
chrome: { dir: "Google/Chrome", service: "Chrome Safe Storage", account: "Chrome" },
|
|
9
|
+
brave: { dir: "BraveSoftware/Brave-Browser", service: "Brave Safe Storage", account: "Brave" },
|
|
10
|
+
edge: { dir: "Microsoft Edge", service: "Microsoft Edge Safe Storage", account: "Microsoft Edge" },
|
|
11
|
+
arc: { dir: "Arc/User Data", service: "Arc Safe Storage", account: "Arc" },
|
|
12
|
+
chromium: { dir: "Chromium", service: "Chromium Safe Storage", account: "Chromium" },
|
|
13
|
+
};
|
|
14
|
+
export function isChromiumBrowser(value) {
|
|
15
|
+
return SUPPORTED_BROWSERS.includes(value);
|
|
16
|
+
}
|
|
17
|
+
export function chromiumTargets(browser, profile) {
|
|
18
|
+
const channel = CHANNELS[browser];
|
|
19
|
+
return {
|
|
20
|
+
cookieDbPath: path.join(os.homedir(), "Library", "Application Support", channel.dir, profile, "Cookies"),
|
|
21
|
+
keychainService: channel.service,
|
|
22
|
+
keychainAccount: channel.account,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export function deriveKey(password) {
|
|
26
|
+
return crypto.pbkdf2Sync(password, "saltysalt", 1003, 16, "sha1");
|
|
27
|
+
}
|
|
28
|
+
export function decryptCookieValue(encrypted, key, hostKey) {
|
|
29
|
+
const prefix = encrypted.subarray(0, 3).toString("latin1");
|
|
30
|
+
const body = prefix === "v10" || prefix === "v11" ? encrypted.subarray(3) : encrypted;
|
|
31
|
+
const iv = Buffer.alloc(16, 0x20);
|
|
32
|
+
const decipher = crypto.createDecipheriv("aes-128-cbc", key, iv);
|
|
33
|
+
decipher.setAutoPadding(false);
|
|
34
|
+
const padded = Buffer.concat([decipher.update(body), decipher.final()]);
|
|
35
|
+
const unpadded = removePkcs7Padding(padded);
|
|
36
|
+
const stripped = stripDomainHashPrefix(unpadded, hostKey);
|
|
37
|
+
return stripped.toString("utf8");
|
|
38
|
+
}
|
|
39
|
+
function removePkcs7Padding(buffer) {
|
|
40
|
+
if (buffer.length === 0)
|
|
41
|
+
return buffer;
|
|
42
|
+
const padLength = buffer[buffer.length - 1] ?? 0;
|
|
43
|
+
if (padLength > 0 && padLength <= 16 && padLength <= buffer.length) {
|
|
44
|
+
return buffer.subarray(0, buffer.length - padLength);
|
|
45
|
+
}
|
|
46
|
+
return buffer;
|
|
47
|
+
}
|
|
48
|
+
// Recent Chrome builds prepend a 32-byte SHA-256 hash of the cookie's domain to
|
|
49
|
+
// the decrypted plaintext. When the host key is known and matches that prefix,
|
|
50
|
+
// strip it so the clean cookie value is recovered.
|
|
51
|
+
function stripDomainHashPrefix(buffer, hostKey) {
|
|
52
|
+
if (!hostKey || buffer.length < 32)
|
|
53
|
+
return buffer;
|
|
54
|
+
const domainHash = crypto.createHash("sha256").update(hostKey).digest();
|
|
55
|
+
if (buffer.subarray(0, 32).equals(domainHash)) {
|
|
56
|
+
return buffer.subarray(32);
|
|
57
|
+
}
|
|
58
|
+
return buffer;
|
|
59
|
+
}
|
|
60
|
+
export function buildCookieHeader(rows, key) {
|
|
61
|
+
const pairs = [];
|
|
62
|
+
for (const row of rows) {
|
|
63
|
+
if (!row.name || !row.encrypted_hex)
|
|
64
|
+
continue;
|
|
65
|
+
try {
|
|
66
|
+
const value = decryptCookieValue(Buffer.from(row.encrypted_hex, "hex"), key, row.host_key);
|
|
67
|
+
if (value)
|
|
68
|
+
pairs.push(`${row.name}=${value}`);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return pairs.join("; ");
|
|
75
|
+
}
|
|
76
|
+
export async function readKeychainPassword(service, account) {
|
|
77
|
+
const result = await runProcess("security", ["find-generic-password", "-w", "-a", account, "-s", service]);
|
|
78
|
+
if (result.code !== 0) {
|
|
79
|
+
throw new Error(`Keychain access for "${service}" failed. Re-run and click Allow when macOS asks, or pass --cookie manually.`);
|
|
80
|
+
}
|
|
81
|
+
const password = result.stdout.trim();
|
|
82
|
+
if (!password) {
|
|
83
|
+
throw new Error(`Keychain returned an empty password for "${service}".`);
|
|
84
|
+
}
|
|
85
|
+
return password;
|
|
86
|
+
}
|
|
87
|
+
export async function readTikTokCookieRows(cookieDbPath) {
|
|
88
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "tokwise-cookies-"));
|
|
89
|
+
const tmpDb = path.join(tmpDir, "Cookies");
|
|
90
|
+
try {
|
|
91
|
+
await fs.copyFile(cookieDbPath, tmpDb);
|
|
92
|
+
for (const suffix of ["-wal", "-shm"]) {
|
|
93
|
+
try {
|
|
94
|
+
await fs.copyFile(`${cookieDbPath}${suffix}`, `${tmpDb}${suffix}`);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Sidecar files are optional; ignore when absent.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const sql = "SELECT host_key, name, hex(encrypted_value) AS encrypted_hex FROM cookies WHERE host_key LIKE '%tiktok.com%';";
|
|
101
|
+
const result = await runProcess("/usr/bin/sqlite3", ["-json", tmpDb, sql]);
|
|
102
|
+
if (result.code !== 0) {
|
|
103
|
+
throw new Error(`Could not read cookies database: ${result.stderr || result.stdout || "unknown error"}`);
|
|
104
|
+
}
|
|
105
|
+
return parseSqliteJsonRows(result.stdout);
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
export function parseSqliteJsonRows(stdout) {
|
|
112
|
+
const trimmed = stdout.trim();
|
|
113
|
+
if (!trimmed)
|
|
114
|
+
return [];
|
|
115
|
+
const parsed = JSON.parse(trimmed);
|
|
116
|
+
if (!Array.isArray(parsed))
|
|
117
|
+
return [];
|
|
118
|
+
return parsed.flatMap((entry) => {
|
|
119
|
+
if (typeof entry !== "object" || entry === null)
|
|
120
|
+
return [];
|
|
121
|
+
const record = entry;
|
|
122
|
+
const host_key = typeof record.host_key === "string" ? record.host_key : "";
|
|
123
|
+
const name = typeof record.name === "string" ? record.name : "";
|
|
124
|
+
const encrypted_hex = typeof record.encrypted_hex === "string" ? record.encrypted_hex : "";
|
|
125
|
+
return [{ host_key, name, encrypted_hex }];
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
async function fileExists(filePath) {
|
|
129
|
+
try {
|
|
130
|
+
await fs.access(filePath);
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
export async function extractTikTokCookie(options) {
|
|
138
|
+
const candidates = options.browser ? [options.browser] : SUPPORTED_BROWSERS;
|
|
139
|
+
const detected = [];
|
|
140
|
+
for (const browser of candidates) {
|
|
141
|
+
const target = chromiumTargets(browser, options.profile);
|
|
142
|
+
if (await fileExists(target.cookieDbPath))
|
|
143
|
+
detected.push(browser);
|
|
144
|
+
}
|
|
145
|
+
if (detected.length === 0) {
|
|
146
|
+
throw new Error(`No supported Chromium browser found on macOS for profile "${options.profile}" (looked for: ${candidates.join(", ")}). Use \`tw auth set --cookie\` instead.`);
|
|
147
|
+
}
|
|
148
|
+
for (const browser of detected) {
|
|
149
|
+
const target = chromiumTargets(browser, options.profile);
|
|
150
|
+
const rows = await readTikTokCookieRows(target.cookieDbPath);
|
|
151
|
+
if (rows.length === 0)
|
|
152
|
+
continue;
|
|
153
|
+
const password = await readKeychainPassword(target.keychainService, target.keychainAccount);
|
|
154
|
+
const cookie = buildCookieHeader(rows, deriveKey(password));
|
|
155
|
+
if (!cookie)
|
|
156
|
+
continue;
|
|
157
|
+
return { cookie, browser, profile: options.profile };
|
|
158
|
+
}
|
|
159
|
+
throw new Error(`Found ${detected.join(", ")} but no tiktok.com cookies in profile "${options.profile}". Open tiktok.com in your browser, log in, then retry.`);
|
|
160
|
+
}
|
package/dist/classify.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { searchableText, tokenize } from "./search.js";
|
|
2
|
+
const CATEGORY_RULES = [
|
|
3
|
+
{ label: "career", keywords: ["career", "job", "work", "interview", "manager", "promotion", "resume", "business"] },
|
|
4
|
+
{ label: "relationships", keywords: ["relationship", "friend", "partner", "dating", "marriage", "family", "boundaries"] },
|
|
5
|
+
{ label: "health", keywords: ["health", "sleep", "fitness", "diet", "body", "therapy", "mental", "anxiety", "stress"] },
|
|
6
|
+
{ label: "money", keywords: ["money", "finance", "invest", "budget", "saving", "debt", "wealth", "income"] },
|
|
7
|
+
{ label: "productivity", keywords: ["productivity", "habit", "routine", "focus", "discipline", "calendar", "system"] },
|
|
8
|
+
{ label: "learning", keywords: ["learn", "study", "read", "book", "skill", "practice", "teach"] },
|
|
9
|
+
{ label: "mindset", keywords: ["mindset", "confidence", "fear", "failure", "motivation", "identity", "belief"] },
|
|
10
|
+
{ label: "creativity", keywords: ["create", "writing", "artist", "idea", "taste", "creative", "make"] },
|
|
11
|
+
{ label: "spirituality", keywords: ["meaning", "purpose", "gratitude", "meditation", "spiritual", "presence"] },
|
|
12
|
+
];
|
|
13
|
+
const DOMAIN_RULES = [
|
|
14
|
+
{ label: "decision-making", keywords: ["choice", "decision", "tradeoff", "choose", "option", "clarity"] },
|
|
15
|
+
{ label: "self-regulation", keywords: ["emotion", "calm", "stress", "discipline", "impulse", "nervous"] },
|
|
16
|
+
{ label: "social-dynamics", keywords: ["people", "friend", "relationship", "boundary", "conversation", "trust"] },
|
|
17
|
+
{ label: "work-and-ambition", keywords: ["career", "job", "interview", "promotion", "work", "business", "goal", "ambition", "manager"] },
|
|
18
|
+
{ label: "health-and-energy", keywords: ["sleep", "health", "body", "exercise", "food", "energy"] },
|
|
19
|
+
{ label: "money-and-security", keywords: ["money", "budget", "wealth", "debt", "invest", "rent"] },
|
|
20
|
+
{ label: "meaning-and-values", keywords: ["meaning", "purpose", "values", "life", "death", "legacy"] },
|
|
21
|
+
];
|
|
22
|
+
export function classifyRegex(video) {
|
|
23
|
+
const text = searchableText(video).toLowerCase();
|
|
24
|
+
const tokens = new Set(tokenize(text));
|
|
25
|
+
const category = bestRule(tokens, CATEGORY_RULES) ?? "life-advice";
|
|
26
|
+
const domain = bestRule(tokens, DOMAIN_RULES) ?? "general-life";
|
|
27
|
+
const topics = topTopics(video, tokens);
|
|
28
|
+
return {
|
|
29
|
+
category,
|
|
30
|
+
domain,
|
|
31
|
+
topics,
|
|
32
|
+
summary: summarize(video),
|
|
33
|
+
engine: "regex",
|
|
34
|
+
classifiedAt: new Date().toISOString(),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export async function classifyOllama(video, options) {
|
|
38
|
+
const baseUrl = options.ollamaBaseUrl ?? "http://localhost:11434";
|
|
39
|
+
const model = options.model ?? "llama3.1";
|
|
40
|
+
const prompt = [
|
|
41
|
+
"Classify this saved short-form life-advice video.",
|
|
42
|
+
"Return compact JSON with keys: category, domain, topics, summary.",
|
|
43
|
+
"Categories should be short lowercase labels. Topics should be 3 to 7 short phrases.",
|
|
44
|
+
"",
|
|
45
|
+
`Author: ${video.author?.username ?? "unknown"}`,
|
|
46
|
+
`Description: ${video.description ?? ""}`,
|
|
47
|
+
`Transcript: ${(video.transcript?.text ?? "").slice(0, 6000)}`,
|
|
48
|
+
].join("\n");
|
|
49
|
+
const response = await fetch(`${baseUrl.replace(/\/+$/, "")}/api/generate`, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: { "content-type": "application/json" },
|
|
52
|
+
body: JSON.stringify({ model, prompt, stream: false, format: "json" }),
|
|
53
|
+
});
|
|
54
|
+
if (!response.ok)
|
|
55
|
+
throw new Error(`Ollama classify failed: ${response.status} ${response.statusText}`);
|
|
56
|
+
const body = (await response.json());
|
|
57
|
+
const parsed = parseJsonObject(body.response ?? "{}");
|
|
58
|
+
const fallback = classifyRegex(video);
|
|
59
|
+
return {
|
|
60
|
+
category: stringValue(parsed.category) ?? fallback.category,
|
|
61
|
+
domain: stringValue(parsed.domain) ?? fallback.domain,
|
|
62
|
+
topics: arrayOfStrings(parsed.topics) ?? fallback.topics,
|
|
63
|
+
summary: stringValue(parsed.summary) ?? fallback.summary,
|
|
64
|
+
engine: "ollama",
|
|
65
|
+
model,
|
|
66
|
+
classifiedAt: new Date().toISOString(),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export async function classifyOne(video, options) {
|
|
70
|
+
if (options.engine === "ollama")
|
|
71
|
+
return classifyOllama(video, options);
|
|
72
|
+
return classifyRegex(video);
|
|
73
|
+
}
|
|
74
|
+
function bestRule(tokens, rules) {
|
|
75
|
+
let best;
|
|
76
|
+
for (const rule of rules) {
|
|
77
|
+
const score = rule.keywords.reduce((sum, keyword) => sum + (tokens.has(keyword) ? 1 : 0), 0);
|
|
78
|
+
if (score > 0 && (!best || score > best.score))
|
|
79
|
+
best = { label: rule.label, score };
|
|
80
|
+
}
|
|
81
|
+
return best?.label;
|
|
82
|
+
}
|
|
83
|
+
function topTopics(video, tokens) {
|
|
84
|
+
const hashtags = video.hashtags.slice(0, 8).map((tag) => tag.toLowerCase());
|
|
85
|
+
const meaningful = [...tokens].filter((token) => token.length > 4).slice(0, 8);
|
|
86
|
+
return [...new Set([...hashtags, ...meaningful])].slice(0, 7);
|
|
87
|
+
}
|
|
88
|
+
function summarize(video) {
|
|
89
|
+
const text = video.transcript?.text || video.description || "";
|
|
90
|
+
const firstSentence = text.split(/(?<=[.!?])\s+/)[0]?.replace(/\s+/g, " ").trim();
|
|
91
|
+
return firstSentence?.slice(0, 260) || "No transcript summary available yet.";
|
|
92
|
+
}
|
|
93
|
+
function parseJsonObject(text) {
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(text);
|
|
96
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : {};
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
const match = text.match(/\{[\s\S]*\}/);
|
|
100
|
+
if (!match)
|
|
101
|
+
return {};
|
|
102
|
+
try {
|
|
103
|
+
return JSON.parse(match[0]);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return {};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function stringValue(value) {
|
|
111
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
112
|
+
}
|
|
113
|
+
function arrayOfStrings(value) {
|
|
114
|
+
if (!Array.isArray(value))
|
|
115
|
+
return undefined;
|
|
116
|
+
const strings = value.filter((entry) => typeof entry === "string" && entry.trim().length > 0);
|
|
117
|
+
return strings.length > 0 ? strings : undefined;
|
|
118
|
+
}
|