yt-briefing 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/.claude/skills/yt/SKILL.md +54 -0
- package/LICENSE +21 -0
- package/README.md +106 -0
- package/data.example/.gitattributes +7 -0
- package/data.example/README.md +19 -0
- package/data.example/channels/_template.md +45 -0
- package/dist/bootstrap.js +243 -0
- package/dist/cli.js +29 -0
- package/dist/install-skill.js +51 -0
- package/dist/lib/config.js +23 -0
- package/dist/lib/llm.js +57 -0
- package/dist/lib/paths.js +56 -0
- package/dist/lib/prompt.js +39 -0
- package/dist/lib/skill-install.js +66 -0
- package/dist/lib/yt-api.js +122 -0
- package/dist/lib/yt-lib.js +157 -0
- package/dist/yt-channel-pending.js +85 -0
- package/dist/yt-channel-videos.js +43 -0
- package/dist/yt-rating.js +110 -0
- package/dist/yt-sweep.js +546 -0
- package/dist/yt-transcript.js +156 -0
- package/docs/sync-across-machines.md +127 -0
- package/docs/warp-proxy.md +81 -0
- package/package.json +56 -0
package/dist/lib/llm.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal OpenAI-compatible chat client — the only LLM dependency in yt-briefing.
|
|
3
|
+
*
|
|
4
|
+
* Provider-agnostic: point LLM_BASE_URL at any OpenAI-compatible endpoint —
|
|
5
|
+
* OpenRouter (default, "any model, one key"), Google Gemini's OpenAI-compat
|
|
6
|
+
* endpoint, OpenAI itself, a local Ollama, etc. The tool depends only on an API key
|
|
7
|
+
* here — not on any specific vendor and not on a coding agent being installed.
|
|
8
|
+
*
|
|
9
|
+
* One model does both stages (title classification + summaries). Gemini 2.5 Flash is
|
|
10
|
+
* the default — cheap and fast enough for the batch title filter, capable enough for
|
|
11
|
+
* the summaries.
|
|
12
|
+
*
|
|
13
|
+
* Env (see .env.example):
|
|
14
|
+
* LLM_BASE_URL default https://openrouter.ai/api/v1
|
|
15
|
+
* LLM_API_KEY required
|
|
16
|
+
* LLM_MODEL default google/gemini-2.5-flash
|
|
17
|
+
*/
|
|
18
|
+
const DEFAULT_BASE_URL = "https://openrouter.ai/api/v1";
|
|
19
|
+
const DEFAULT_MODEL = "google/gemini-2.5-flash";
|
|
20
|
+
export function getModel() {
|
|
21
|
+
return process.env.LLM_MODEL || DEFAULT_MODEL;
|
|
22
|
+
}
|
|
23
|
+
export async function chat(prompt, opts = {}) {
|
|
24
|
+
const baseUrl = (process.env.LLM_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
25
|
+
const apiKey = process.env.LLM_API_KEY;
|
|
26
|
+
if (!apiKey)
|
|
27
|
+
throw new Error("LLM_API_KEY not set (see .env.example)");
|
|
28
|
+
const model = opts.model || getModel();
|
|
29
|
+
const messages = [];
|
|
30
|
+
if (opts.system)
|
|
31
|
+
messages.push({ role: "system", content: opts.system });
|
|
32
|
+
messages.push({ role: "user", content: prompt });
|
|
33
|
+
const headers = {
|
|
34
|
+
"Content-Type": "application/json",
|
|
35
|
+
Authorization: `Bearer ${apiKey}`,
|
|
36
|
+
"X-Title": "yt-briefing",
|
|
37
|
+
};
|
|
38
|
+
const res = await fetch(`${baseUrl}/chat/completions`, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers,
|
|
41
|
+
body: JSON.stringify({
|
|
42
|
+
model,
|
|
43
|
+
messages,
|
|
44
|
+
temperature: opts.temperature ?? 0.3,
|
|
45
|
+
}),
|
|
46
|
+
});
|
|
47
|
+
if (!res.ok) {
|
|
48
|
+
const body = await res.text().catch(() => "");
|
|
49
|
+
throw new Error(`LLM ${res.status} ${res.statusText}: ${body.slice(0, 300)}`);
|
|
50
|
+
}
|
|
51
|
+
const data = await res.json();
|
|
52
|
+
const text = data?.choices?.[0]?.message?.content;
|
|
53
|
+
if (typeof text !== "string") {
|
|
54
|
+
throw new Error(`LLM: no content in response: ${JSON.stringify(data).slice(0, 300)}`);
|
|
55
|
+
}
|
|
56
|
+
return text.trim();
|
|
57
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem layout for yt-briefing — the single source of truth for every path.
|
|
3
|
+
*
|
|
4
|
+
* Three roots:
|
|
5
|
+
* PKG_ROOT the installed package (code + the compiled dist/).
|
|
6
|
+
* BASE_DIR where the user's secrets + state live. In a dev clone (you run the package
|
|
7
|
+
* itself) this IS the package root, so the repo layout (`.env`, `data/`) is
|
|
8
|
+
* unchanged. When the package is *consumed as a dependency* — PKG_ROOT sits
|
|
9
|
+
* inside node_modules — that would be wiped on reinstall, so BASE_DIR moves to
|
|
10
|
+
* `<your project>/.yt-briefing/` instead. Override explicitly with YT_BASE_DIR.
|
|
11
|
+
* DATA_DIR the mutable state (subscriptions, profiles, cursor, throwaway cache).
|
|
12
|
+
* Defaults to <BASE_DIR>/data; override with YT_DATA_DIR to keep it anywhere
|
|
13
|
+
* (e.g. a synced git folder, separate from secrets).
|
|
14
|
+
*
|
|
15
|
+
* BASE_DIR and DATA_DIR are pinned back into the environment so detached child processes —
|
|
16
|
+
* which we spawn with `cwd: PKG_ROOT`, a *different* cwd — resolve to the exact same folders.
|
|
17
|
+
*
|
|
18
|
+
* Nothing here reads the disk — it only resolves paths, so it is safe to import from every
|
|
19
|
+
* script and from the bootstrap wizard before any data exists.
|
|
20
|
+
*/
|
|
21
|
+
import { join, dirname, resolve, extname, sep } from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
const SELF = fileURLToPath(import.meta.url); // …/lib/paths.ts (dev) or …/lib/paths.js (built)
|
|
24
|
+
const HERE = dirname(SELF); // src/lib or dist/lib
|
|
25
|
+
export const SRC_DIR = resolve(HERE, '..'); // src or dist
|
|
26
|
+
export const PKG_ROOT = resolve(HERE, '../..'); // package root
|
|
27
|
+
/** This build's script extension: '.ts' running from source under Bun, '.js' when compiled. */
|
|
28
|
+
const SCRIPT_EXT = extname(SELF) || '.ts';
|
|
29
|
+
// Consumed as a dependency? Then PKG_ROOT lives under node_modules and must not hold user state.
|
|
30
|
+
const CONSUMED = PKG_ROOT.split(sep).includes('node_modules');
|
|
31
|
+
export const BASE_DIR = process.env.YT_BASE_DIR
|
|
32
|
+
? resolve(process.env.YT_BASE_DIR)
|
|
33
|
+
: CONSUMED ? join(process.cwd(), '.yt-briefing') : PKG_ROOT;
|
|
34
|
+
process.env.YT_BASE_DIR = BASE_DIR; // pin for children (their cwd differs)
|
|
35
|
+
export const ENV_PATH = join(BASE_DIR, '.env');
|
|
36
|
+
export const DATA_DIR = process.env.YT_DATA_DIR
|
|
37
|
+
? resolve(process.env.YT_DATA_DIR)
|
|
38
|
+
: join(BASE_DIR, 'data');
|
|
39
|
+
process.env.YT_DATA_DIR = DATA_DIR; // pin for children (their cwd differs)
|
|
40
|
+
export const CHANNELS_MD = join(DATA_DIR, 'channels.md');
|
|
41
|
+
export const STATE_MD = join(DATA_DIR, 'state.md');
|
|
42
|
+
export const CONFIG_JSON = join(DATA_DIR, 'config.json');
|
|
43
|
+
export const CHANNELS_DIR = join(DATA_DIR, 'channels');
|
|
44
|
+
export const CACHE_DIR = join(DATA_DIR, '.cache');
|
|
45
|
+
export const QUEUE_FILE = join(CACHE_DIR, 'queue.json');
|
|
46
|
+
export const REST_FILE = join(CACHE_DIR, 'queue-rest.json');
|
|
47
|
+
export const PENDING_FILE = join(CACHE_DIR, 'pending.json');
|
|
48
|
+
export const PREFETCH_FILE = join(CACHE_DIR, 'prefetch.json');
|
|
49
|
+
export const LOG_FILE = join(CACHE_DIR, 'sweep.log');
|
|
50
|
+
/** Absolute path to a channel profile from its slug. */
|
|
51
|
+
export const profilePath = (slug) => join(CHANNELS_DIR, `${slug}.md`);
|
|
52
|
+
/**
|
|
53
|
+
* Absolute path to a sibling engine script by its base name (no extension), so subprocess
|
|
54
|
+
* calls never depend on cwd and resolve to `.ts` in dev or `.js` once compiled to dist/.
|
|
55
|
+
*/
|
|
56
|
+
export const script = (base) => join(SRC_DIR, base + SCRIPT_EXT);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portable synchronous line reader — works under Node and Bun, for an interactive TTY and
|
|
3
|
+
* for piped stdin alike.
|
|
4
|
+
*
|
|
5
|
+
* Why not just `prompt()`: that global exists in Bun but NOT in Node. And Node's
|
|
6
|
+
* readline/promises stalls on a pipe under Bun. Reading file descriptor 0 directly sidesteps
|
|
7
|
+
* both runtimes' quirks. Bytes are accumulated and decoded as UTF-8 only at the end, so
|
|
8
|
+
* non-ASCII input (e.g. Polish characters) is preserved.
|
|
9
|
+
*/
|
|
10
|
+
import { readSync } from 'node:fs';
|
|
11
|
+
/** Print `text` and block until the user types a line; returns it trimmed of the newline. */
|
|
12
|
+
export function question(text) {
|
|
13
|
+
process.stdout.write(text.endsWith(' ') ? text : text + ' ');
|
|
14
|
+
const bytes = [];
|
|
15
|
+
const buf = Buffer.alloc(1);
|
|
16
|
+
while (true) {
|
|
17
|
+
let n = 0;
|
|
18
|
+
try {
|
|
19
|
+
n = readSync(0, buf, 0, 1, null);
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
const code = e.code;
|
|
23
|
+
if (code === 'EAGAIN')
|
|
24
|
+
continue; // stdin momentarily not ready — retry
|
|
25
|
+
if (code === 'EOF')
|
|
26
|
+
break; // some platforms surface EOF as an error
|
|
27
|
+
throw e;
|
|
28
|
+
}
|
|
29
|
+
if (n === 0)
|
|
30
|
+
break; // EOF (e.g. end of a pipe)
|
|
31
|
+
const b = buf[0];
|
|
32
|
+
if (b === 0x0a)
|
|
33
|
+
break; // \n — end of line
|
|
34
|
+
if (b === 0x0d)
|
|
35
|
+
continue; // \r — ignore (CRLF)
|
|
36
|
+
bytes.push(b);
|
|
37
|
+
}
|
|
38
|
+
return Buffer.from(bytes).toString('utf8');
|
|
39
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* skill-install — place the `/yt` SKILL.md into a coding agent's skills directory.
|
|
3
|
+
*
|
|
4
|
+
* Shared by the onboarding wizard (`bootstrap.ts`, final step) and the standalone
|
|
5
|
+
* `install-skill.ts` command, so both write the skill identically.
|
|
6
|
+
*
|
|
7
|
+
* The shipped SKILL.md uses `bun run src/X.ts` — the dev shortcut: it works when the agent's
|
|
8
|
+
* cwd IS the package folder AND the runtime is Bun (which runs TypeScript directly). That's
|
|
9
|
+
* true for the publisher's own day-to-day use, so it stays the default.
|
|
10
|
+
*
|
|
11
|
+
* For everyone else — a Node user, or any install whose cwd won't be the package — we bake an
|
|
12
|
+
* absolute, runtime-correct command instead: `"<this runtime>" "<abs>/dist/X.js"`. The runtime
|
|
13
|
+
* is `process.execPath` of whoever runs the installer (so `init` under Node bakes node, under
|
|
14
|
+
* Bun bakes bun), and it points at the compiled build, so it runs from any cwd. The engine
|
|
15
|
+
* resolves data/.env from its own location regardless. (Requires `dist/` — build once with
|
|
16
|
+
* `bun run build` / `npm run build`; that's exactly how a Node user already got here.)
|
|
17
|
+
*/
|
|
18
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
19
|
+
import { join, resolve } from 'node:path';
|
|
20
|
+
import { PKG_ROOT } from "./paths.js";
|
|
21
|
+
/** Compiled output dir — what a baked (dist) skill command points the runtime at. */
|
|
22
|
+
const DIST_DIR = join(PKG_ROOT, 'dist');
|
|
23
|
+
/** True when the installer itself is running under Bun (vs plain Node). */
|
|
24
|
+
export const isBun = process.versions.bun != null;
|
|
25
|
+
/**
|
|
26
|
+
* The shipped `bun run src/X.ts` command is only correct in ONE situation: the publisher
|
|
27
|
+
* developing *inside the package clone* under Bun (cwd === package, TypeScript runs directly,
|
|
28
|
+
* no build). Everywhere else — and crucially when the package is consumed as a dependency, so
|
|
29
|
+
* the engine lives in node_modules while the user works in their own project — we must bake the
|
|
30
|
+
* compiled `dist/` command instead. This detects that one dev-in-clone case.
|
|
31
|
+
*/
|
|
32
|
+
export const isPackageDevCwd = () => isBun && resolve(process.cwd()) === PKG_ROOT;
|
|
33
|
+
export const SOURCE = join(PKG_ROOT, '.claude/skills/yt/SKILL.md');
|
|
34
|
+
/** Agent key → display name + the skills subdirectory it scans. */
|
|
35
|
+
export const AGENTS = {
|
|
36
|
+
'1': { name: 'Claude Code', sub: join('.claude', 'skills', 'yt') },
|
|
37
|
+
'2': { name: 'Cursor', sub: join('.cursor', 'skills', 'yt') },
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* The shipped SKILL.md. `dist=false` (default) returns it verbatim — the `bun run src/X.ts`
|
|
41
|
+
* dev form, correct only when cwd is the package AND the runtime is Bun. `dist=true` rewrites
|
|
42
|
+
* the engine commands to `"<process.execPath>" "<abs>/dist/X.js"` — this machine's runtime
|
|
43
|
+
* (Node or Bun) against the compiled build, so it runs from any cwd.
|
|
44
|
+
*/
|
|
45
|
+
export function skillBody(dist = false) {
|
|
46
|
+
const raw = readFileSync(SOURCE, 'utf8');
|
|
47
|
+
if (!dist)
|
|
48
|
+
return raw;
|
|
49
|
+
const exe = process.execPath;
|
|
50
|
+
const cmd = (base) => `"${exe}" "${join(DIST_DIR, base + '.js')}"`;
|
|
51
|
+
return raw
|
|
52
|
+
.replace(/bun run src\/yt-sweep\.ts/g, cmd('yt-sweep'))
|
|
53
|
+
.replace(/bun run src\/yt-rating\.ts/g, cmd('yt-rating'));
|
|
54
|
+
}
|
|
55
|
+
/** Write the skill into `dir` (created if needed). Returns the SKILL.md path written. */
|
|
56
|
+
export function installSkill(dir, dist = false) {
|
|
57
|
+
mkdirSync(dir, { recursive: true });
|
|
58
|
+
const target = join(dir, 'SKILL.md');
|
|
59
|
+
writeFileSync(target, skillBody(dist), 'utf8');
|
|
60
|
+
return target;
|
|
61
|
+
}
|
|
62
|
+
/** The agent's skills directory inside a project folder (the project you open in the agent). */
|
|
63
|
+
export const projectSkillDir = (agentKey, projectDir) => join(projectDir, AGENTS[agentKey].sub);
|
|
64
|
+
/** Suggested target for a "custom" (any other agent) install — the open `.agents` convention,
|
|
65
|
+
* rooted at the user's current project (not the package, which may be in node_modules). */
|
|
66
|
+
export const customSkillDirDefault = () => join(process.cwd(), '.agents', 'skills', 'yt');
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* yt-api.ts — YouTube Data API v3 client over plain `fetch` (no SDK).
|
|
3
|
+
*
|
|
4
|
+
* Replaces the monolithic `googleapis` import, whose multi-thousand-file module tree
|
|
5
|
+
* cost ~7s to load from a cold FS cache on every fresh process. The Data API is a
|
|
6
|
+
* trivial REST surface, so direct fetch keeps cold-start near the runtime's own startup.
|
|
7
|
+
*
|
|
8
|
+
* Auth: YOUTUBE_API_KEY — the entrypoint loads it (dotenv.config from ENV_PATH) before
|
|
9
|
+
* calling; this module only reads process.env at call time.
|
|
10
|
+
*/
|
|
11
|
+
const API = 'https://www.googleapis.com/youtube/v3';
|
|
12
|
+
function apiKey() {
|
|
13
|
+
const k = process.env.YOUTUBE_API_KEY;
|
|
14
|
+
if (!k)
|
|
15
|
+
throw new Error('YOUTUBE_API_KEY env var not set (see .env.example)');
|
|
16
|
+
return k;
|
|
17
|
+
}
|
|
18
|
+
async function get(path, params) {
|
|
19
|
+
const qs = new URLSearchParams({ ...params, key: apiKey() }).toString();
|
|
20
|
+
const res = await fetch(`${API}/${path}?${qs}`);
|
|
21
|
+
if (!res.ok) {
|
|
22
|
+
const body = await res.text().catch(() => '');
|
|
23
|
+
throw new Error(`YouTube API ${path} ${res.status}: ${body.slice(0, 200)}`);
|
|
24
|
+
}
|
|
25
|
+
return res.json();
|
|
26
|
+
}
|
|
27
|
+
/** handle (@x) or channelId (UC…) → uploads playlist id. */
|
|
28
|
+
async function resolveUploadsPlaylist(handleOrId) {
|
|
29
|
+
const params = { part: 'contentDetails' };
|
|
30
|
+
if (handleOrId.startsWith('@'))
|
|
31
|
+
params.forHandle = handleOrId.slice(1);
|
|
32
|
+
else
|
|
33
|
+
params.id = handleOrId;
|
|
34
|
+
const data = await get('channels', params);
|
|
35
|
+
const items = data.items;
|
|
36
|
+
if (!items?.length)
|
|
37
|
+
throw new Error(`Channel not found: ${handleOrId}`);
|
|
38
|
+
return items[0].contentDetails.relatedPlaylists.uploads;
|
|
39
|
+
}
|
|
40
|
+
async function listUploads(playlistId, since, maxCount) {
|
|
41
|
+
const sinceTs = since ? new Date(since).getTime() : 0;
|
|
42
|
+
const videos = [];
|
|
43
|
+
let pageToken;
|
|
44
|
+
while (true) {
|
|
45
|
+
const params = {
|
|
46
|
+
part: 'snippet',
|
|
47
|
+
playlistId,
|
|
48
|
+
maxResults: String(maxCount !== null ? Math.min(maxCount, 50) : 50),
|
|
49
|
+
};
|
|
50
|
+
if (pageToken)
|
|
51
|
+
params.pageToken = pageToken;
|
|
52
|
+
const data = await get('playlistItems', params);
|
|
53
|
+
const items = data.items || [];
|
|
54
|
+
let hitOld = false;
|
|
55
|
+
for (const item of items) {
|
|
56
|
+
const publishedAt = item.snippet.publishedAt;
|
|
57
|
+
if (since && new Date(publishedAt).getTime() < sinceTs) {
|
|
58
|
+
hitOld = true;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
videos.push({ videoId: item.snippet.resourceId.videoId, title: item.snippet.title, publishedAt });
|
|
62
|
+
if (maxCount !== null && videos.length >= maxCount)
|
|
63
|
+
return videos;
|
|
64
|
+
}
|
|
65
|
+
if (hitOld || !data.nextPageToken)
|
|
66
|
+
break;
|
|
67
|
+
pageToken = data.nextPageToken;
|
|
68
|
+
}
|
|
69
|
+
return videos;
|
|
70
|
+
}
|
|
71
|
+
function parseDurationISO8601(iso) {
|
|
72
|
+
// PT#H#M#S — any segment may be absent. Examples: PT45S, PT3M12S, PT1H2M3S.
|
|
73
|
+
const m = iso.match(/^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?$/);
|
|
74
|
+
if (!m)
|
|
75
|
+
return 0;
|
|
76
|
+
const h = m[1] ? parseInt(m[1], 10) : 0;
|
|
77
|
+
const min = m[2] ? parseInt(m[2], 10) : 0;
|
|
78
|
+
const s = m[3] ? parseFloat(m[3]) : 0;
|
|
79
|
+
return h * 3600 + min * 60 + Math.round(s);
|
|
80
|
+
}
|
|
81
|
+
async function enrichWithTypes(videos) {
|
|
82
|
+
const out = [];
|
|
83
|
+
for (let i = 0; i < videos.length; i += 50) {
|
|
84
|
+
const chunk = videos.slice(i, i + 50);
|
|
85
|
+
const data = await get('videos', {
|
|
86
|
+
part: 'contentDetails,snippet,liveStreamingDetails',
|
|
87
|
+
id: chunk.map(v => v.videoId).join(','),
|
|
88
|
+
});
|
|
89
|
+
const byId = new Map();
|
|
90
|
+
for (const item of data.items || [])
|
|
91
|
+
byId.set(item.id, item);
|
|
92
|
+
for (const v of chunk) {
|
|
93
|
+
const item = byId.get(v.videoId);
|
|
94
|
+
if (!item) {
|
|
95
|
+
// metadata fetch failed for this id — treat as longform with unknown duration
|
|
96
|
+
v.type = 'longform';
|
|
97
|
+
v.durationSeconds = 0;
|
|
98
|
+
out.push(v);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const liveFlag = item.snippet?.liveBroadcastContent;
|
|
102
|
+
if (liveFlag === 'live' || liveFlag === 'upcoming')
|
|
103
|
+
continue; // not consumable yet
|
|
104
|
+
const duration = parseDurationISO8601(item.contentDetails?.duration || 'PT0S');
|
|
105
|
+
v.durationSeconds = duration;
|
|
106
|
+
const isPastLive = !!item.liveStreamingDetails?.actualEndTime;
|
|
107
|
+
v.type = isPastLive ? 'live' : duration <= 180 ? 'short' : 'longform';
|
|
108
|
+
out.push(v);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* List a channel's uploads (newest first). With `enrich` (default true) each video is
|
|
115
|
+
* typed (short/live/longform) and current/upcoming live broadcasts are filtered out.
|
|
116
|
+
*/
|
|
117
|
+
export async function fetchChannelVideos(handleOrId, opts = {}) {
|
|
118
|
+
const { since = null, all = false, limit = null, enrich = true } = opts;
|
|
119
|
+
const uploads = await resolveUploadsPlaylist(handleOrId);
|
|
120
|
+
const videos = await listUploads(uploads, all ? null : since, limit);
|
|
121
|
+
return enrich ? enrichWithTypes(videos) : videos;
|
|
122
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for yt-briefing: channels.md / state.md parsing and channel-profile
|
|
3
|
+
* editing. Pure string functions where possible — the caller does the I/O.
|
|
4
|
+
*
|
|
5
|
+
* The data files are human-editable Markdown on purpose: a channel profile is meant to
|
|
6
|
+
* be read and tweaked by hand, and ratings append to it as plain bullet lists.
|
|
7
|
+
*/
|
|
8
|
+
/** Canonical profile section headings (single source — used by the engine and rating writer). */
|
|
9
|
+
export const SKIP_TITLES_HEADING = '## Skip titles';
|
|
10
|
+
export const NOTES_HEADING = '## Notes';
|
|
11
|
+
const NULL_CELL = /^[—\-]$/;
|
|
12
|
+
const ENTRY_RE = /^-\s*\[(@[^\]]+)\]\([^)]+\)\s*→\s*\[\[channels\/([^\]]+)\]\]/;
|
|
13
|
+
// ---------- channels.md ----------
|
|
14
|
+
/** A flat list of `- [@Handle](url) → [[channels/slug]]` lines under `# Channels`. */
|
|
15
|
+
export function parseChannels(content) {
|
|
16
|
+
const entries = [];
|
|
17
|
+
for (const line of content.split('\n')) {
|
|
18
|
+
const m = line.match(ENTRY_RE);
|
|
19
|
+
if (m)
|
|
20
|
+
entries.push({ handle: m[1], slug: m[2] });
|
|
21
|
+
}
|
|
22
|
+
return entries;
|
|
23
|
+
}
|
|
24
|
+
// ---------- state.md ----------
|
|
25
|
+
/** One flat table; rows `| @Handle | id | id | id | date | int |`. */
|
|
26
|
+
export function parseState(content) {
|
|
27
|
+
const rows = [];
|
|
28
|
+
for (const line of content.split('\n')) {
|
|
29
|
+
if (!line.startsWith('| @'))
|
|
30
|
+
continue;
|
|
31
|
+
const cells = line.split('|').map(c => c.trim()).filter((_, idx, arr) => idx > 0 && idx < arr.length - 1);
|
|
32
|
+
if (cells.length !== 6)
|
|
33
|
+
continue;
|
|
34
|
+
const [handle, lf, sh, lv, updated, session] = cells;
|
|
35
|
+
rows.push({
|
|
36
|
+
handle,
|
|
37
|
+
last_longform_id: NULL_CELL.test(lf) ? null : lf,
|
|
38
|
+
last_short_id: NULL_CELL.test(sh) ? null : sh,
|
|
39
|
+
last_live_id: NULL_CELL.test(lv) ? null : lv,
|
|
40
|
+
updated: NULL_CELL.test(updated) ? null : updated,
|
|
41
|
+
session: NULL_CELL.test(session) ? 0 : parseInt(session, 10) || 0,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return rows;
|
|
45
|
+
}
|
|
46
|
+
export function bumpStateFrontmatterDate(content, date) {
|
|
47
|
+
return content.replace(/^updated:\s*\d{4}-\d{2}-\d{2}/m, `updated: ${date}`);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Per-video pointer bump. Updates one type's last_id for one channel.
|
|
51
|
+
* Session counter increments only on first touch of the day (updated != date).
|
|
52
|
+
* Also bumps frontmatter `updated`.
|
|
53
|
+
*/
|
|
54
|
+
export function bumpStatePointer(content, handle, type, videoId, date) {
|
|
55
|
+
const lines = content.split('\n');
|
|
56
|
+
let touched = false;
|
|
57
|
+
for (let i = 0; i < lines.length; i++) {
|
|
58
|
+
const line = lines[i];
|
|
59
|
+
if (!line.startsWith(`| ${handle} |`))
|
|
60
|
+
continue;
|
|
61
|
+
const cells = line.split('|');
|
|
62
|
+
if (cells.length < 8)
|
|
63
|
+
continue;
|
|
64
|
+
const cur = {
|
|
65
|
+
lf: cells[2].trim(),
|
|
66
|
+
sh: cells[3].trim(),
|
|
67
|
+
lv: cells[4].trim(),
|
|
68
|
+
updated: cells[5].trim(),
|
|
69
|
+
session: cells[6].trim(),
|
|
70
|
+
};
|
|
71
|
+
if (type === 'longform')
|
|
72
|
+
cur.lf = videoId;
|
|
73
|
+
else if (type === 'short')
|
|
74
|
+
cur.sh = videoId;
|
|
75
|
+
else if (type === 'live')
|
|
76
|
+
cur.lv = videoId;
|
|
77
|
+
const sessionNum = NULL_CELL.test(cur.session) ? 0 : parseInt(cur.session, 10) || 0;
|
|
78
|
+
if (cur.updated !== date) {
|
|
79
|
+
cur.session = String(sessionNum + 1);
|
|
80
|
+
cur.updated = date;
|
|
81
|
+
}
|
|
82
|
+
lines[i] = `| ${handle} | ${cur.lf} | ${cur.sh} | ${cur.lv} | ${cur.updated} | ${cur.session} |`;
|
|
83
|
+
touched = true;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
if (!touched)
|
|
87
|
+
throw new Error(`bumpStatePointer: row for ${handle} not found`);
|
|
88
|
+
return bumpStateFrontmatterDate(lines.join('\n'), date);
|
|
89
|
+
}
|
|
90
|
+
// ---------- channel profile: durable signal writes ----------
|
|
91
|
+
/**
|
|
92
|
+
* Append `line` under `## <section>`, creating the section if missing (before `## Notes`
|
|
93
|
+
* when present, unless we ARE Notes — then at EOF), FIFO-capping the section's bullets to
|
|
94
|
+
* `cap`. Dedup: no-op if an identical bullet already exists. The profile is the durable
|
|
95
|
+
* store — there is no rolling buffer / consolidation; a rating writes straight here.
|
|
96
|
+
*/
|
|
97
|
+
export function appendToSection(profileContent, heading, line, cap = Infinity) {
|
|
98
|
+
const lines = profileContent.split('\n');
|
|
99
|
+
let hi = -1;
|
|
100
|
+
for (let i = 0; i < lines.length; i++)
|
|
101
|
+
if (lines[i].trim() === heading) {
|
|
102
|
+
hi = i;
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
if (hi === -1) {
|
|
106
|
+
let insertAt = lines.length;
|
|
107
|
+
if (heading !== NOTES_HEADING) {
|
|
108
|
+
for (let i = 0; i < lines.length; i++)
|
|
109
|
+
if (lines[i].trim() === NOTES_HEADING) {
|
|
110
|
+
insertAt = i;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const before = lines.slice(0, insertAt);
|
|
115
|
+
const after = lines.slice(insertAt);
|
|
116
|
+
while (before.length && before[before.length - 1].trim() === '')
|
|
117
|
+
before.pop();
|
|
118
|
+
const section = [heading, '', line, ''].join('\n');
|
|
119
|
+
const segments = [before.join('\n'), '', section];
|
|
120
|
+
if (after.length)
|
|
121
|
+
segments.push(after.join('\n'));
|
|
122
|
+
return segments.join('\n').replace(/\n{3,}/g, '\n\n');
|
|
123
|
+
}
|
|
124
|
+
let end = lines.length;
|
|
125
|
+
for (let i = hi + 1; i < lines.length; i++)
|
|
126
|
+
if (lines[i].startsWith('## ')) {
|
|
127
|
+
end = i;
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
const body = lines.slice(hi + 1, end).filter(l => l.trim().startsWith('- '));
|
|
131
|
+
if (body.some(l => l.trim() === line.trim()))
|
|
132
|
+
return profileContent; // dedup
|
|
133
|
+
body.push(line);
|
|
134
|
+
while (body.length > cap)
|
|
135
|
+
body.shift(); // FIFO
|
|
136
|
+
const before = lines.slice(0, hi);
|
|
137
|
+
const after = lines.slice(end);
|
|
138
|
+
while (before.length && before[before.length - 1].trim() === '')
|
|
139
|
+
before.pop();
|
|
140
|
+
while (after.length && after[0].trim() === '')
|
|
141
|
+
after.shift();
|
|
142
|
+
const section = [heading, '', ...body, ''].join('\n');
|
|
143
|
+
const segments = [before.join('\n'), '', section];
|
|
144
|
+
if (after.length)
|
|
145
|
+
segments.push(after.join('\n'));
|
|
146
|
+
return segments.join('\n').replace(/\n{3,}/g, '\n\n');
|
|
147
|
+
}
|
|
148
|
+
/** A `rating=0` writes a compact negative few-shot to `## Skip titles` (FIFO cap, default 10). */
|
|
149
|
+
export function appendSkipTitle(profileContent, entry, cap = 10) {
|
|
150
|
+
const why = entry.comment && entry.comment.trim() ? ` · ${entry.comment.trim()}` : '';
|
|
151
|
+
const line = `- "${entry.title.replace(/"/g, "'")}" — ${entry.type}${why}`;
|
|
152
|
+
return appendToSection(profileContent, SKIP_TITLES_HEADING, line, cap);
|
|
153
|
+
}
|
|
154
|
+
/** A comment writes a durable rule to `## Notes` (uncapped — manual curation territory). */
|
|
155
|
+
export function appendNote(profileContent, rule) {
|
|
156
|
+
return appendToSection(profileContent, NOTES_HEADING, `- ${rule.trim()}`);
|
|
157
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Usage: bun src/yt-channel-pending.ts @HANDLE
|
|
4
|
+
*
|
|
5
|
+
* For one channel: reads state.md pointers + last-updated date,
|
|
6
|
+
* fetches the channel's videos in-process via lib/yt-api.ts (--since updated),
|
|
7
|
+
* filters to videos NEWER than each type's pointer (or baseline if pointer null),
|
|
8
|
+
* sorts ASC by publishedAt (process oldest first → state pointer advances monotonically),
|
|
9
|
+
* outputs JSON array: [{videoId, title, publishedAt, type, is_baseline}].
|
|
10
|
+
*
|
|
11
|
+
* Baseline (null pointer): emits ONLY the newest video of that type (others discarded).
|
|
12
|
+
* Empty result is valid (channel has no new content).
|
|
13
|
+
*/
|
|
14
|
+
import { readFileSync } from 'fs';
|
|
15
|
+
import dotenv from 'dotenv';
|
|
16
|
+
import { parseState } from "./lib/yt-lib.js";
|
|
17
|
+
import { STATE_MD, ENV_PATH } from "./lib/paths.js";
|
|
18
|
+
import { fetchChannelVideos } from "./lib/yt-api.js";
|
|
19
|
+
dotenv.config({ path: ENV_PATH });
|
|
20
|
+
const handle = process.argv[2];
|
|
21
|
+
if (!handle) {
|
|
22
|
+
console.error('Usage: yt-channel-pending @HANDLE (internal helper)');
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
const state = parseState(readFileSync(STATE_MD, 'utf8'));
|
|
26
|
+
const row = state.find(r => r.handle === handle);
|
|
27
|
+
if (!row) {
|
|
28
|
+
console.error(`Channel ${handle} not found in state.md`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
const since = row.updated ?? '2020-01-01';
|
|
32
|
+
let videos;
|
|
33
|
+
try {
|
|
34
|
+
videos = await fetchChannelVideos(handle, { since });
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
console.error(e.message);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
const pointerByType = {
|
|
41
|
+
longform: row.last_longform_id,
|
|
42
|
+
short: row.last_short_id,
|
|
43
|
+
live: row.last_live_id,
|
|
44
|
+
};
|
|
45
|
+
const pending = [];
|
|
46
|
+
for (const type of ['longform', 'short', 'live']) {
|
|
47
|
+
const pointerId = pointerByType[type];
|
|
48
|
+
const ofType = videos.filter(v => v.type === type);
|
|
49
|
+
if (ofType.length === 0)
|
|
50
|
+
continue;
|
|
51
|
+
// Sort by publishedAt asc; oldest first
|
|
52
|
+
ofType.sort((a, b) => new Date(a.publishedAt).getTime() - new Date(b.publishedAt).getTime());
|
|
53
|
+
if (pointerId === null) {
|
|
54
|
+
// baseline: only emit newest of type
|
|
55
|
+
const newest = ofType[ofType.length - 1];
|
|
56
|
+
pending.push({
|
|
57
|
+
videoId: newest.videoId,
|
|
58
|
+
title: newest.title,
|
|
59
|
+
publishedAt: newest.publishedAt,
|
|
60
|
+
type,
|
|
61
|
+
is_baseline: true,
|
|
62
|
+
});
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
// normal: find pointer's publishedAt, take everything strictly newer
|
|
66
|
+
const pointerVideo = ofType.find(v => v.videoId === pointerId);
|
|
67
|
+
const cutoffTs = pointerVideo
|
|
68
|
+
? new Date(pointerVideo.publishedAt).getTime()
|
|
69
|
+
: // pointer not in fetched window (older than --since cutoff) — take all videos of this type from response
|
|
70
|
+
-Infinity;
|
|
71
|
+
for (const v of ofType) {
|
|
72
|
+
if (new Date(v.publishedAt).getTime() <= cutoffTs)
|
|
73
|
+
continue;
|
|
74
|
+
pending.push({
|
|
75
|
+
videoId: v.videoId,
|
|
76
|
+
title: v.title,
|
|
77
|
+
publishedAt: v.publishedAt,
|
|
78
|
+
type,
|
|
79
|
+
is_baseline: false,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Merge sort ASC across all types for stable iteration order
|
|
84
|
+
pending.sort((a, b) => new Date(a.publishedAt).getTime() - new Date(b.publishedAt).getTime());
|
|
85
|
+
console.log(JSON.stringify(pending, null, 2));
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Usage:
|
|
4
|
+
* bun src/yt-channel-videos.ts @HANDLE_OR_CHANNEL_ID --since YYYY-MM-DD
|
|
5
|
+
* bun src/yt-channel-videos.ts @HANDLE_OR_CHANNEL_ID --all
|
|
6
|
+
* ... [--limit N] [--no-enrich]
|
|
7
|
+
*
|
|
8
|
+
* Output: JSON array [{videoId, title, publishedAt, type, durationSeconds}] on stdout (newest first)
|
|
9
|
+
* type ∈ {'short', 'live', 'longform'}:
|
|
10
|
+
* - 'short': durationSeconds ≤ 180 (YT Shorts limit since Oct 2024), not live
|
|
11
|
+
* - 'live': past livestream/premiere — liveStreamingDetails.actualEndTime present
|
|
12
|
+
* - 'longform': default (>180s, not live)
|
|
13
|
+
* Filtered OUT: current and upcoming live broadcasts (no transcript yet, never consumable).
|
|
14
|
+
* --no-enrich → skip the videos.list pass; type/durationSeconds omitted; no filtering.
|
|
15
|
+
*
|
|
16
|
+
* Requires: YOUTUBE_API_KEY (see .env.example). Thin CLI wrapper over lib/yt-api.ts.
|
|
17
|
+
*
|
|
18
|
+
* Quota: ~1 unit per page (playlistItems.list) + 1 unit per 50 videos (videos.list).
|
|
19
|
+
*/
|
|
20
|
+
import dotenv from 'dotenv';
|
|
21
|
+
import { ENV_PATH } from "./lib/paths.js";
|
|
22
|
+
import { fetchChannelVideos } from "./lib/yt-api.js";
|
|
23
|
+
dotenv.config({ path: ENV_PATH });
|
|
24
|
+
const args = process.argv.slice(2);
|
|
25
|
+
const handleOrId = args[0];
|
|
26
|
+
const sinceIdx = args.indexOf('--since');
|
|
27
|
+
const since = sinceIdx !== -1 && args[sinceIdx + 1] ? args[sinceIdx + 1] : null;
|
|
28
|
+
const all = args.includes('--all');
|
|
29
|
+
const limitIdx = args.indexOf('--limit');
|
|
30
|
+
const limit = limitIdx !== -1 && args[limitIdx + 1] ? parseInt(args[limitIdx + 1], 10) : null;
|
|
31
|
+
const noEnrich = args.includes('--no-enrich');
|
|
32
|
+
if (!handleOrId || (!since && !all)) {
|
|
33
|
+
console.error('Usage: yt-channel-videos @HANDLE_OR_ID (--since YYYY-MM-DD | --all) [--limit N] [--no-enrich] (internal helper)');
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const out = await fetchChannelVideos(handleOrId, { since, all, limit, enrich: !noEnrich });
|
|
38
|
+
console.log(JSON.stringify(out, null, 2));
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
console.error(`Error: ${e.message}`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|