yt-briefing 0.2.0 → 0.3.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/README.md +8 -0
- package/dist/bootstrap.js +7 -81
- package/dist/cli.js +13 -3
- package/dist/lib/channels.js +82 -0
- package/dist/yt-channels.js +74 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -53,6 +53,14 @@ npx yt-briefing init # or: bunx yt-briefing init
|
|
|
53
53
|
|
|
54
54
|
`init` asks for your language, your keys, the channels to follow, and which tool runs `/yt`.
|
|
55
55
|
|
|
56
|
+
Add or remove channels anytime:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npx yt-briefing add @handle https://youtube.com/@another # one or more, handle or URL
|
|
60
|
+
npx yt-briefing remove @handle # also deletes its learned profile
|
|
61
|
+
npx yt-briefing list # show the current list
|
|
62
|
+
```
|
|
63
|
+
|
|
56
64
|
## Run it
|
|
57
65
|
|
|
58
66
|
Open your project in Claude Code or Cursor and run `/yt`. If it's not listed, start a fresh
|
package/dist/bootstrap.js
CHANGED
|
@@ -18,6 +18,7 @@ import { join } from 'node:path';
|
|
|
18
18
|
import { DATA_DIR, BASE_DIR, PKG_ROOT, CHANNELS_DIR, CHANNELS_MD, STATE_MD, CONFIG_JSON, ENV_PATH, profilePath, } from "./lib/paths.js";
|
|
19
19
|
import { AGENTS, installSkill, projectSkillDir, customSkillDirDefault, isPackageDevCwd } from "./lib/skill-install.js";
|
|
20
20
|
import { question } from "./lib/prompt.js";
|
|
21
|
+
import { normalizeHandle, slugify, serializeChannels, serializeState, profileBody, baselineStateRow } from "./lib/channels.js";
|
|
21
22
|
const ask = (q, def = '') => {
|
|
22
23
|
const a = question(def ? `${q} [${def}]:` : `${q}:`).trim();
|
|
23
24
|
return a || def;
|
|
@@ -28,35 +29,6 @@ const askYN = (q, def = true) => {
|
|
|
28
29
|
return def;
|
|
29
30
|
return a.startsWith('y');
|
|
30
31
|
};
|
|
31
|
-
/**
|
|
32
|
-
* Accept any of: `@betterstack`, `betterstack`, `https://www.youtube.com/@betterstack`
|
|
33
|
-
* (with or without a trailing `/videos` etc.) → canonical `@betterstack`. Returns null if
|
|
34
|
-
* no handle can be read (e.g. a bare `/channel/UC…` URL — ask the user for the @handle).
|
|
35
|
-
*/
|
|
36
|
-
function normalizeHandle(input) {
|
|
37
|
-
let s = input.trim();
|
|
38
|
-
if (/youtube\.com/i.test(s) || /^https?:\/\//i.test(s)) {
|
|
39
|
-
const m = s.match(/@[A-Za-z0-9._-]+/); // pull the @handle out of a URL
|
|
40
|
-
s = m ? m[0] : '';
|
|
41
|
-
}
|
|
42
|
-
s = s.replace(/^@+/, '').replace(/[^A-Za-z0-9._-].*$/, ''); // bare token
|
|
43
|
-
return s ? `@${s}` : null;
|
|
44
|
-
}
|
|
45
|
-
/** kebab-case slug that also breaks CamelCase: "BetterStack" → "better-stack". */
|
|
46
|
-
function slugify(s) {
|
|
47
|
-
return s
|
|
48
|
-
.replace(/@/g, '')
|
|
49
|
-
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
50
|
-
.replace(/[^A-Za-z0-9]+/g, '-')
|
|
51
|
-
.replace(/-+/g, '-')
|
|
52
|
-
.replace(/^-|-$/g, '')
|
|
53
|
-
.toLowerCase();
|
|
54
|
-
}
|
|
55
|
-
const today = new Date().toISOString().slice(0, 10);
|
|
56
|
-
// Fresh channels start "baseline": the first sweep surfaces the latest upload per type.
|
|
57
|
-
// updated is set this far back so that window actually contains a recent video.
|
|
58
|
-
const BASELINE_LOOKBACK_DAYS = 60;
|
|
59
|
-
const lookback = new Date(Date.now() - BASELINE_LOOKBACK_DAYS * 864e5).toISOString().slice(0, 10);
|
|
60
32
|
function main() {
|
|
61
33
|
console.log('\n yt-briefing — onboarding\n ' + '─'.repeat(40) + '\n');
|
|
62
34
|
if (existsSync(CHANNELS_MD)) {
|
|
@@ -163,58 +135,12 @@ function main() {
|
|
|
163
135
|
}
|
|
164
136
|
// config.json
|
|
165
137
|
writeFileSync(CONFIG_JSON, JSON.stringify({ output_lang: outputLang }, null, 2) + '\n', 'utf8');
|
|
166
|
-
// channels.md
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
`updated: ${today}`,
|
|
173
|
-
'---',
|
|
174
|
-
'',
|
|
175
|
-
'# Channels',
|
|
176
|
-
'',
|
|
177
|
-
];
|
|
178
|
-
for (const c of channels) {
|
|
179
|
-
chParts.push(`- [${c.handle}](https://www.youtube.com/${c.handle}) → [[channels/${c.slug}]]`);
|
|
180
|
-
}
|
|
181
|
-
writeFileSync(CHANNELS_MD, chParts.join('\n') + '\n', 'utf8');
|
|
182
|
-
// state.md — one flat table (baseline rows: pointers "—", updated = lookback so the first
|
|
183
|
-
// sweep finds the latest upload per type).
|
|
184
|
-
const stParts = [
|
|
185
|
-
'---',
|
|
186
|
-
'type: yt-state',
|
|
187
|
-
'name: yt-state',
|
|
188
|
-
'description: Per-channel per-type cursor. The sweep reads and updates it.',
|
|
189
|
-
`updated: ${today}`,
|
|
190
|
-
'---',
|
|
191
|
-
'',
|
|
192
|
-
'# State',
|
|
193
|
-
'',
|
|
194
|
-
'| Channel | last_longform_id | last_short_id | last_live_id | updated | session |',
|
|
195
|
-
'|---|---|---|---|---|---|',
|
|
196
|
-
];
|
|
197
|
-
for (const c of channels) {
|
|
198
|
-
stParts.push(`| ${c.handle} | — | — | — | ${lookback} | 0 |`);
|
|
199
|
-
}
|
|
200
|
-
writeFileSync(STATE_MD, stParts.join('\n') + '\n', 'utf8');
|
|
201
|
-
// per-channel profiles
|
|
202
|
-
for (const c of channels) {
|
|
203
|
-
const pParts = [
|
|
204
|
-
'---',
|
|
205
|
-
'type: yt-channel-profile',
|
|
206
|
-
`name: ${c.slug}-profile`,
|
|
207
|
-
`channel: ${c.handle}`,
|
|
208
|
-
`channel_url: https://www.youtube.com/${c.handle}`,
|
|
209
|
-
`updated: ${today}`,
|
|
210
|
-
'sessions_observed: 0',
|
|
211
|
-
'---',
|
|
212
|
-
'',
|
|
213
|
-
`# ${c.handle} — Profile`,
|
|
214
|
-
'',
|
|
215
|
-
];
|
|
216
|
-
writeFileSync(profilePath(c.slug), pParts.join('\n') + '\n', 'utf8');
|
|
217
|
-
}
|
|
138
|
+
// channels.md / state.md / per-channel profiles — via the shared serializers, so the
|
|
139
|
+
// on-disk format is identical to what the add/remove command writes (single source).
|
|
140
|
+
writeFileSync(CHANNELS_MD, serializeChannels(channels), 'utf8');
|
|
141
|
+
writeFileSync(STATE_MD, serializeState(channels.map(c => baselineStateRow(c.handle))), 'utf8');
|
|
142
|
+
for (const c of channels)
|
|
143
|
+
writeFileSync(profilePath(c.slug), profileBody(c.handle, c.slug), 'utf8');
|
|
218
144
|
console.log(' ' + '─'.repeat(40));
|
|
219
145
|
console.log(` Done. Wrote:`);
|
|
220
146
|
console.log(` ${ENV_PATH}`);
|
package/dist/cli.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
*
|
|
8
8
|
* yt-briefing init interactive onboarding wizard
|
|
9
9
|
* yt-briefing install-skill install the /yt skill into a coding agent
|
|
10
|
+
* yt-briefing add|remove <@handle|url> add or remove channels (also list)
|
|
11
|
+
* yt-briefing list list the channels you follow
|
|
10
12
|
* yt-briefing sweep [--reset] advance one step; prints a JSON status line
|
|
11
13
|
* yt-briefing rate --rating 0|1 [...] record a rating for the pending video
|
|
12
14
|
* yt-briefing transcribe <url|id> print a single video's transcript
|
|
@@ -14,6 +16,8 @@
|
|
|
14
16
|
import { spawnSync } from 'node:child_process';
|
|
15
17
|
import { script } from "./lib/paths.js";
|
|
16
18
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
19
|
+
// Subcommand → engine script. Channel actions all route to yt-channels with the action
|
|
20
|
+
// passed through as its first arg.
|
|
17
21
|
const TARGETS = {
|
|
18
22
|
init: 'bootstrap',
|
|
19
23
|
'install-skill': 'install-skill',
|
|
@@ -21,9 +25,15 @@ const TARGETS = {
|
|
|
21
25
|
rate: 'yt-rating',
|
|
22
26
|
transcribe: 'yt-transcript',
|
|
23
27
|
};
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
const CHANNEL_ACTIONS = new Set(['add', 'remove', 'list']);
|
|
29
|
+
let argv = null;
|
|
30
|
+
if (cmd && CHANNEL_ACTIONS.has(cmd))
|
|
31
|
+
argv = [script('yt-channels'), cmd, ...rest];
|
|
32
|
+
else if (cmd && TARGETS[cmd])
|
|
33
|
+
argv = [script(TARGETS[cmd]), ...rest];
|
|
34
|
+
if (!argv) {
|
|
35
|
+
console.error('Usage: yt-briefing <init|install-skill|add|remove|list|sweep|rate|transcribe> [args...]');
|
|
26
36
|
process.exit(cmd ? 1 : 0);
|
|
27
37
|
}
|
|
28
|
-
const res = spawnSync(process.execPath,
|
|
38
|
+
const res = spawnSync(process.execPath, argv, { stdio: 'inherit' });
|
|
29
39
|
process.exit(res.status ?? 1);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export const BASELINE_LOOKBACK_DAYS = 60;
|
|
2
|
+
export const today = () => new Date().toISOString().slice(0, 10);
|
|
3
|
+
// Fresh channels start "baseline": updated is set this far back so the first sweep's window
|
|
4
|
+
// still contains a recent upload per type (which becomes the starting point).
|
|
5
|
+
export const lookbackDate = () => new Date(Date.now() - BASELINE_LOOKBACK_DAYS * 864e5).toISOString().slice(0, 10);
|
|
6
|
+
export const channelUrl = (handle) => `https://www.youtube.com/${handle}`;
|
|
7
|
+
/**
|
|
8
|
+
* Accept any of: `@betterstack`, `betterstack`, `https://www.youtube.com/@betterstack`
|
|
9
|
+
* (with or without a trailing `/videos` etc.) → canonical `@betterstack`. Returns null if no
|
|
10
|
+
* handle can be read (e.g. a bare `/channel/UC…` URL — ask the user for the @handle).
|
|
11
|
+
*/
|
|
12
|
+
export function normalizeHandle(input) {
|
|
13
|
+
let s = input.trim();
|
|
14
|
+
if (/youtube\.com/i.test(s) || /^https?:\/\//i.test(s)) {
|
|
15
|
+
const m = s.match(/@[\p{L}\p{N}._-]+/u); // pull the @handle out of a URL (Unicode-aware)
|
|
16
|
+
s = m ? m[0] : '';
|
|
17
|
+
}
|
|
18
|
+
s = s.replace(/^@+/, '');
|
|
19
|
+
// Keep the leading run of handle chars — letters (any script, e.g. ł), digits, . _ - —
|
|
20
|
+
// so non-ASCII handles like @DziałZagraniczny survive instead of truncating at the first ł.
|
|
21
|
+
const m = s.match(/^[\p{L}\p{N}._-]+/u);
|
|
22
|
+
s = m ? m[0] : '';
|
|
23
|
+
return s ? `@${s}` : null;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* kebab-case slug that also breaks CamelCase: "BetterStack" → "better-stack". Diacritics are
|
|
27
|
+
* folded to ASCII so non-Latin handles get readable slugs: "DziałZagraniczny" → "dzial-zagraniczny"
|
|
28
|
+
* (ł has no NFD decomposition, so it's mapped explicitly; the rest go through NFD).
|
|
29
|
+
*/
|
|
30
|
+
export function slugify(s) {
|
|
31
|
+
return s
|
|
32
|
+
.replace(/@/g, '')
|
|
33
|
+
.replace(/ł/g, 'l').replace(/Ł/g, 'L')
|
|
34
|
+
.normalize('NFD').replace(/[̀-ͯ]/g, '') // strip combining diacritics (ą→a, é→e, …)
|
|
35
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
36
|
+
.replace(/[^A-Za-z0-9]+/g, '-')
|
|
37
|
+
.replace(/-+/g, '-')
|
|
38
|
+
.replace(/^-|-$/g, '')
|
|
39
|
+
.toLowerCase();
|
|
40
|
+
}
|
|
41
|
+
/** A fresh state row for a just-added channel: no cursors yet, dated back to the lookback. */
|
|
42
|
+
export const baselineStateRow = (handle) => ({
|
|
43
|
+
handle,
|
|
44
|
+
last_longform_id: null,
|
|
45
|
+
last_short_id: null,
|
|
46
|
+
last_live_id: null,
|
|
47
|
+
updated: lookbackDate(),
|
|
48
|
+
session: 0,
|
|
49
|
+
});
|
|
50
|
+
/** channels.md — a flat list of the channels you follow. */
|
|
51
|
+
export function serializeChannels(entries, date = today()) {
|
|
52
|
+
const parts = [
|
|
53
|
+
'---', 'type: yt-config', 'name: yt-channels',
|
|
54
|
+
'description: The channels you follow. Per-channel learned signal lives in channels/<slug>.md.',
|
|
55
|
+
`updated: ${date}`, '---', '', '# Channels', '',
|
|
56
|
+
];
|
|
57
|
+
for (const c of entries)
|
|
58
|
+
parts.push(`- [${c.handle}](${channelUrl(c.handle)}) → [[channels/${c.slug}]]`);
|
|
59
|
+
return parts.join('\n') + '\n';
|
|
60
|
+
}
|
|
61
|
+
/** state.md — one flat table; baseline rows use "—" pointers and the lookback date. */
|
|
62
|
+
export function serializeState(rows, date = today()) {
|
|
63
|
+
const parts = [
|
|
64
|
+
'---', 'type: yt-state', 'name: yt-state',
|
|
65
|
+
'description: Per-channel per-type cursor. The sweep reads and updates it.',
|
|
66
|
+
`updated: ${date}`, '---', '', '# State', '',
|
|
67
|
+
'| Channel | last_longform_id | last_short_id | last_live_id | updated | session |',
|
|
68
|
+
'|---|---|---|---|---|---|',
|
|
69
|
+
];
|
|
70
|
+
for (const r of rows) {
|
|
71
|
+
parts.push(`| ${r.handle} | ${r.last_longform_id ?? '—'} | ${r.last_short_id ?? '—'} | ${r.last_live_id ?? '—'} | ${r.updated ?? lookbackDate()} | ${r.session} |`);
|
|
72
|
+
}
|
|
73
|
+
return parts.join('\n') + '\n';
|
|
74
|
+
}
|
|
75
|
+
/** A fresh, empty channel profile (learned signal accumulates here as you rate). */
|
|
76
|
+
export function profileBody(handle, slug, date = today()) {
|
|
77
|
+
return [
|
|
78
|
+
'---', 'type: yt-channel-profile', `name: ${slug}-profile`,
|
|
79
|
+
`channel: ${handle}`, `channel_url: ${channelUrl(handle)}`, `updated: ${date}`,
|
|
80
|
+
'sessions_observed: 0', '---', '', `# ${handle} — Profile`, '',
|
|
81
|
+
].join('\n') + '\n';
|
|
82
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Add, remove, or list the channels you follow — keeping channels.md, state.md, and the
|
|
4
|
+
* per-channel profile in sync. Driven by the CLI: `yt-briefing add|remove|list ...`.
|
|
5
|
+
*
|
|
6
|
+
* yt-briefing add @foo https://youtube.com/@bar # add one or more (handle or URL)
|
|
7
|
+
* yt-briefing remove @foo # remove (also deletes its profile)
|
|
8
|
+
* yt-briefing list # show the current list
|
|
9
|
+
*
|
|
10
|
+
* Adding a channel starts it at "baseline" (next sweep surfaces the latest upload per type);
|
|
11
|
+
* removing one drops its row and learned profile. Existing channels' cursors are untouched.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
|
14
|
+
import { CHANNELS_MD, STATE_MD, profilePath } from "./lib/paths.js";
|
|
15
|
+
import { parseChannels, parseState } from "./lib/yt-lib.js";
|
|
16
|
+
import { normalizeHandle, slugify, serializeChannels, serializeState, profileBody, baselineStateRow, } from "./lib/channels.js";
|
|
17
|
+
const [action, ...args] = process.argv.slice(2);
|
|
18
|
+
if (!existsSync(CHANNELS_MD) || !existsSync(STATE_MD)) {
|
|
19
|
+
console.error('No channel list yet — run onboarding first: yt-briefing init');
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
let entries = parseChannels(readFileSync(CHANNELS_MD, 'utf8'));
|
|
23
|
+
if (action === 'list') {
|
|
24
|
+
for (const e of entries)
|
|
25
|
+
console.log(` ${e.handle}`);
|
|
26
|
+
console.log(`\n ${entries.length} channel(s).`);
|
|
27
|
+
process.exit(0);
|
|
28
|
+
}
|
|
29
|
+
if ((action !== 'add' && action !== 'remove') || args.length === 0) {
|
|
30
|
+
console.error('Usage: yt-briefing add|remove <@handle | url> [...] · yt-briefing list');
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
let rows = parseState(readFileSync(STATE_MD, 'utf8'));
|
|
34
|
+
let changed = 0;
|
|
35
|
+
for (const raw of args) {
|
|
36
|
+
const handle = normalizeHandle(raw);
|
|
37
|
+
if (!handle) {
|
|
38
|
+
console.log(` ! couldn't read a handle from "${raw}" — use @name or the channel URL`);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const slug = slugify(handle);
|
|
42
|
+
const exists = entries.some(e => e.slug === slug);
|
|
43
|
+
if (action === 'add') {
|
|
44
|
+
if (exists) {
|
|
45
|
+
console.log(` • ${handle} already on the list — skipping`);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
entries.push({ handle, slug });
|
|
49
|
+
rows.push(baselineStateRow(handle));
|
|
50
|
+
writeFileSync(profilePath(slug), profileBody(handle, slug), 'utf8');
|
|
51
|
+
console.log(` + ${handle}`);
|
|
52
|
+
changed++;
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
if (!exists) {
|
|
56
|
+
console.log(` • ${handle} not on the list — skipping`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const entry = entries.find(e => e.slug === slug);
|
|
60
|
+
entries = entries.filter(e => e.slug !== slug);
|
|
61
|
+
rows = rows.filter(r => r.handle !== entry.handle);
|
|
62
|
+
try {
|
|
63
|
+
rmSync(profilePath(slug));
|
|
64
|
+
}
|
|
65
|
+
catch { /* profile already gone */ }
|
|
66
|
+
console.log(` − ${handle} (profile deleted)`);
|
|
67
|
+
changed++;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (changed) {
|
|
71
|
+
writeFileSync(CHANNELS_MD, serializeChannels(entries), 'utf8');
|
|
72
|
+
writeFileSync(STATE_MD, serializeState(rows), 'utf8');
|
|
73
|
+
}
|
|
74
|
+
console.log(`\n ${entries.length} channel(s) now. The next sweep picks up the change.`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yt-briefing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "A self-learning YouTube briefing engine: it sweeps the channels you follow, filters noise in two stages (title, then transcript), summarizes the rest in your language, and adapts to your ratings — one video at a time.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|