yt-briefing 1.3.2 → 1.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/paths.js +1 -0
- package/dist/lib/video-dates.js +45 -0
- package/dist/lib/yt-lib.js +18 -0
- package/dist/yt-channel-pending.js +9 -2
- package/dist/yt-rating.js +10 -2
- package/dist/yt-sweep.js +17 -12
- package/package.json +1 -1
package/dist/lib/paths.js
CHANGED
|
@@ -54,6 +54,7 @@ export const REST_FILE = join(CACHE_DIR, 'queue-rest.json');
|
|
|
54
54
|
export const PENDING_FILE = join(CACHE_DIR, 'pending.json');
|
|
55
55
|
export const PREFETCH_FILE = join(CACHE_DIR, 'prefetch.json');
|
|
56
56
|
export const LOG_FILE = join(CACHE_DIR, 'sweep.log');
|
|
57
|
+
export const VIDEO_DATES_FILE = join(CACHE_DIR, 'video-dates.json'); // videoId → publishedAt, for the monotonic pointer
|
|
57
58
|
// /yt-search (ad-hoc topic search → lazy triage → compare). All throwaway, under .cache/.
|
|
58
59
|
export const SEARCH_QUEUE_FILE = join(CACHE_DIR, 'search-queue.json'); // ranked candidates + cursor
|
|
59
60
|
export const SEARCH_PENDING_FILE = join(CACHE_DIR, 'search-pending.json'); // current candidate awaiting keep/skip
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publish dates of every video the sweep has listed, so a state pointer bump can refuse to
|
|
3
|
+
* move back in time (see pointerWouldRegress). Kept across --reset: the pointer outlives the
|
|
4
|
+
* queue. Best-effort cache — a missing or corrupt file only turns the guard off.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { dirname } from 'node:path';
|
|
8
|
+
import { VIDEO_DATES_FILE } from "./paths.js";
|
|
9
|
+
/** Entries older than this are dropped on write; the channel listing looks back 45 days. */
|
|
10
|
+
const KEEP_DAYS = 120;
|
|
11
|
+
export function loadVideoDates() {
|
|
12
|
+
if (!existsSync(VIDEO_DATES_FILE))
|
|
13
|
+
return {};
|
|
14
|
+
try {
|
|
15
|
+
const d = JSON.parse(readFileSync(VIDEO_DATES_FILE, 'utf8'));
|
|
16
|
+
return d && typeof d === 'object' ? d : {};
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Merge newly seen videos into the cache (atomic write); no-op when nothing is new. */
|
|
23
|
+
export function rememberVideoDates(videos) {
|
|
24
|
+
const dates = loadVideoDates();
|
|
25
|
+
let changed = false;
|
|
26
|
+
for (const v of videos) {
|
|
27
|
+
if (!v.publishedAt || !Number.isFinite(Date.parse(v.publishedAt)) || dates[v.videoId] === v.publishedAt)
|
|
28
|
+
continue;
|
|
29
|
+
dates[v.videoId] = v.publishedAt;
|
|
30
|
+
changed = true;
|
|
31
|
+
}
|
|
32
|
+
if (!changed)
|
|
33
|
+
return;
|
|
34
|
+
const cutoff = Date.now() - KEEP_DAYS * 24 * 60 * 60 * 1000;
|
|
35
|
+
for (const [id, at] of Object.entries(dates))
|
|
36
|
+
if (Date.parse(at) < cutoff)
|
|
37
|
+
delete dates[id];
|
|
38
|
+
try {
|
|
39
|
+
mkdirSync(dirname(VIDEO_DATES_FILE), { recursive: true });
|
|
40
|
+
const tmp = `${VIDEO_DATES_FILE}.${process.pid}.tmp`;
|
|
41
|
+
writeFileSync(tmp, JSON.stringify(dates));
|
|
42
|
+
renameSync(tmp, VIDEO_DATES_FILE);
|
|
43
|
+
}
|
|
44
|
+
catch { /* best-effort: the guard is only off for dates not written */ }
|
|
45
|
+
}
|
package/dist/lib/yt-lib.js
CHANGED
|
@@ -46,6 +46,24 @@ export function parseState(content) {
|
|
|
46
46
|
export function bumpStateFrontmatterDate(content, date) {
|
|
47
47
|
return content.replace(/^updated:\s*\d{4}-\d{2}-\d{2}/m, `updated: ${date}`);
|
|
48
48
|
}
|
|
49
|
+
/** The state pointer of one type for a channel row (null when unset or the row is missing). */
|
|
50
|
+
export function pointerFor(row, type) {
|
|
51
|
+
if (!row)
|
|
52
|
+
return null;
|
|
53
|
+
return type === 'longform' ? row.last_longform_id : type === 'short' ? row.last_short_id : row.last_live_id;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Would moving the pointer from `pointerId` to `videoId` move it back in time? `dates` maps
|
|
57
|
+
* videoIds to publishedAt. Unknown dates answer false (bump as before): only a known older
|
|
58
|
+
* video is refused. A stale skip applied after the pointer moved on used to rewind it, and
|
|
59
|
+
* the next queue listed already-rated videos again.
|
|
60
|
+
*/
|
|
61
|
+
export function pointerWouldRegress(pointerId, videoId, publishedAt, dates) {
|
|
62
|
+
if (!pointerId || pointerId === videoId || !publishedAt)
|
|
63
|
+
return false;
|
|
64
|
+
const at = dates[pointerId];
|
|
65
|
+
return !!at && Date.parse(at) > Date.parse(publishedAt);
|
|
66
|
+
}
|
|
49
67
|
/**
|
|
50
68
|
* Per-video pointer bump. Updates one type's last_id for one channel.
|
|
51
69
|
* Session counter increments only on first touch of the day (updated != date).
|
|
@@ -19,6 +19,7 @@ import { loadEnv } from "./lib/env.js";
|
|
|
19
19
|
import { parseState } from "./lib/yt-lib.js";
|
|
20
20
|
import { STATE_MD } from "./lib/paths.js";
|
|
21
21
|
import { fetchChannelVideos } from "./lib/yt-api.js";
|
|
22
|
+
import { loadVideoDates, rememberVideoDates } from "./lib/video-dates.js";
|
|
22
23
|
loadEnv();
|
|
23
24
|
const handle = process.argv[2];
|
|
24
25
|
if (!handle) {
|
|
@@ -47,6 +48,10 @@ catch (e) {
|
|
|
47
48
|
console.error(e.message);
|
|
48
49
|
process.exit(1);
|
|
49
50
|
}
|
|
51
|
+
// Date cache read before this listing is added, so a pointer that left the window still has its
|
|
52
|
+
// old date; then every listed video (the pointer too) is remembered for the monotonic bump.
|
|
53
|
+
const videoDates = loadVideoDates();
|
|
54
|
+
rememberVideoDates(videos);
|
|
50
55
|
const pointerByType = {
|
|
51
56
|
longform: row.last_longform_id,
|
|
52
57
|
short: row.last_short_id,
|
|
@@ -74,10 +79,12 @@ for (const type of ['longform', 'short', 'live']) {
|
|
|
74
79
|
}
|
|
75
80
|
// normal: find pointer's publishedAt, take everything strictly newer
|
|
76
81
|
const pointerVideo = ofType.find(v => v.videoId === pointerId);
|
|
82
|
+
// Pointer not in the fetched window: older than the lookback, or deleted/unlisted. Its date
|
|
83
|
+
// from the sweep's cache keeps already-seen videos out; without one, take the whole window.
|
|
84
|
+
const cachedTs = Date.parse(videoDates[pointerId] ?? '');
|
|
77
85
|
const cutoffTs = pointerVideo
|
|
78
86
|
? new Date(pointerVideo.publishedAt).getTime()
|
|
79
|
-
:
|
|
80
|
-
-Infinity;
|
|
87
|
+
: Number.isFinite(cachedTs) ? cachedTs : -Infinity;
|
|
81
88
|
for (const v of ofType) {
|
|
82
89
|
if (new Date(v.publishedAt).getTime() <= cutoffTs)
|
|
83
90
|
continue;
|
package/dist/yt-rating.js
CHANGED
|
@@ -27,7 +27,8 @@ import { spawn } from 'node:child_process';
|
|
|
27
27
|
import { loadEnv } from "./lib/env.js";
|
|
28
28
|
import { loadConfig } from "./lib/config.js";
|
|
29
29
|
import { distillComment } from "./lib/distill.js";
|
|
30
|
-
import { parseChannels, appendSkipTitle, appendNote, bumpStatePointer } from "./lib/yt-lib.js";
|
|
30
|
+
import { parseChannels, parseState, appendSkipTitle, appendNote, bumpStatePointer, pointerFor, pointerWouldRegress } from "./lib/yt-lib.js";
|
|
31
|
+
import { loadVideoDates } from "./lib/video-dates.js";
|
|
31
32
|
import { CHANNELS_MD, STATE_MD, PENDING_FILE, QUEUE_FILE, profilePath } from "./lib/paths.js";
|
|
32
33
|
loadEnv();
|
|
33
34
|
function getArg(args, name) {
|
|
@@ -123,7 +124,14 @@ if (profileAfter !== profileBefore) {
|
|
|
123
124
|
let stateBumped = false;
|
|
124
125
|
if (!args.noState) {
|
|
125
126
|
const stateBefore = readFileSync(STATE_MD, 'utf8');
|
|
126
|
-
|
|
127
|
+
// An older video rated after a newer one of the same channel was title-skipped must not
|
|
128
|
+
// rewind the pointer; the date comes from the sweep's cache of listed videos.
|
|
129
|
+
const pointer = pointerFor(parseState(stateBefore).find(r => r.handle === args.channel), args.type);
|
|
130
|
+
const dates = loadVideoDates();
|
|
131
|
+
const publishedAt = dates[args.id] ?? (() => { const p = loadPending(); return p.videoId === args.id ? p.publishedAt : undefined; })();
|
|
132
|
+
// Never rewind: re-bump the current pointer instead, so the day's session still counts.
|
|
133
|
+
const regress = pointer !== null && pointerWouldRegress(pointer, args.id, publishedAt, dates);
|
|
134
|
+
const stateAfter = bumpStatePointer(stateBefore, args.channel, args.type, regress ? pointer : args.id, date);
|
|
127
135
|
if (stateAfter !== stateBefore) {
|
|
128
136
|
writeFileSync(STATE_MD, stateAfter, 'utf8');
|
|
129
137
|
stateBumped = true;
|
package/dist/yt-sweep.js
CHANGED
|
@@ -51,7 +51,8 @@
|
|
|
51
51
|
import { readFileSync, writeFileSync, existsSync, rmSync, mkdirSync, renameSync, appendFileSync } from 'node:fs';
|
|
52
52
|
import { spawn } from 'node:child_process';
|
|
53
53
|
import { loadEnv, missingEnv, missingEnvMessage, REQUIRED_YOUTUBE } from "./lib/env.js";
|
|
54
|
-
import { parseChannels, parseState, bumpStatePointer, isResolved } from "./lib/yt-lib.js";
|
|
54
|
+
import { parseChannels, parseState, bumpStatePointer, isResolved, pointerFor, pointerWouldRegress } from "./lib/yt-lib.js";
|
|
55
|
+
import { loadVideoDates, rememberVideoDates } from "./lib/video-dates.js";
|
|
55
56
|
import { chat, claudeMissing } from "./lib/llm.js";
|
|
56
57
|
import { outputLang } from "./lib/config.js";
|
|
57
58
|
import { PKG_ROOT, CHANNELS_MD, STATE_MD, CACHE_DIR, QUEUE_FILE, REST_FILE, PENDING_FILE, PREFETCH_FILE, LOG_FILE, profilePath, script, } from "./lib/paths.js";
|
|
@@ -105,9 +106,14 @@ function run(cmd) {
|
|
|
105
106
|
});
|
|
106
107
|
}
|
|
107
108
|
// ---------- state mutation (inline, crash-safe per write) ----------
|
|
108
|
-
function bumpState(handle, type, videoId) {
|
|
109
|
+
function bumpState(handle, type, videoId, publishedAt) {
|
|
109
110
|
const before = readFileSync(STATE_MD, 'utf8');
|
|
110
|
-
const
|
|
111
|
+
const pointer = pointerFor(parseState(before).find(r => r.handle === handle), type);
|
|
112
|
+
// Never rewind: re-bump the current pointer instead, so the day's session still counts.
|
|
113
|
+
const regress = pointer !== null && pointerWouldRegress(pointer, videoId, publishedAt, loadVideoDates());
|
|
114
|
+
if (regress)
|
|
115
|
+
log(`pointer kept ${handle} ${type} ${pointer} (not rewound to older ${videoId})`);
|
|
116
|
+
const after = bumpStatePointer(before, handle, type, regress ? pointer : videoId, today);
|
|
111
117
|
if (after !== before)
|
|
112
118
|
writeFileSync(STATE_MD, after, 'utf8');
|
|
113
119
|
}
|
|
@@ -117,20 +123,23 @@ function bumpState(handle, type, videoId) {
|
|
|
117
123
|
*/
|
|
118
124
|
function persistSkip(item, status) {
|
|
119
125
|
if (status !== 'no_transcript')
|
|
120
|
-
bumpState(item.channel, item.type, item.videoId);
|
|
126
|
+
bumpState(item.channel, item.type, item.videoId, item.publishedAt);
|
|
121
127
|
}
|
|
122
128
|
/**
|
|
123
|
-
* Apply title-skips to state.md.
|
|
129
|
+
* Apply title-skips to state.md. Bumps the pointer (a title-skip is final) unless that would
|
|
130
|
+
* rewind it: a background fill's skips are listed against the pointer it saw when it started.
|
|
124
131
|
* Idempotent — re-applying the same skip is a no-op. ONLY the foreground calls this.
|
|
125
132
|
*/
|
|
126
133
|
function applyTitleSkips(skips) {
|
|
134
|
+
rememberVideoDates(skips);
|
|
127
135
|
for (const s of skips) {
|
|
128
|
-
bumpState(s.channel, s.type, s.videoId);
|
|
136
|
+
bumpState(s.channel, s.type, s.videoId, s.publishedAt);
|
|
129
137
|
recordSkip({ stage: 'title', channel: s.channel, videoId: s.videoId, title: s.title, reason: s.reason || 'title filter' });
|
|
130
138
|
}
|
|
131
139
|
}
|
|
132
140
|
/** Append items, skipping any videoId already queued OR already resolved (seen). */
|
|
133
141
|
function enqueue(queue, add) {
|
|
142
|
+
rememberVideoDates(add);
|
|
134
143
|
const have = new Set([...queue.items.map(i => i.videoId), ...queue.seen]);
|
|
135
144
|
for (const it of add)
|
|
136
145
|
if (!have.has(it.videoId)) {
|
|
@@ -341,7 +350,7 @@ async function expandChannel(ref) {
|
|
|
341
350
|
const skips = [];
|
|
342
351
|
for (const it of candidates) {
|
|
343
352
|
if (titleSkip.has(it.videoId))
|
|
344
|
-
skips.push({ channel: it.channel, type: it.type, videoId: it.videoId, title: it.title, reason: titleSkip.get(it.videoId) || 'title filter' });
|
|
353
|
+
skips.push({ channel: it.channel, type: it.type, videoId: it.videoId, title: it.title, reason: titleSkip.get(it.videoId) || 'title filter', publishedAt: it.publishedAt });
|
|
345
354
|
else
|
|
346
355
|
items.push(it);
|
|
347
356
|
}
|
|
@@ -421,11 +430,7 @@ function loadQueue() {
|
|
|
421
430
|
// ---------- advance (the lazy step) ----------
|
|
422
431
|
/** state.md pointer for this item's type, or null if the channel has no row yet. */
|
|
423
432
|
function statePointer(item) {
|
|
424
|
-
|
|
425
|
-
if (!row)
|
|
426
|
-
return null;
|
|
427
|
-
return item.type === 'longform' ? row.last_longform_id
|
|
428
|
-
: item.type === 'short' ? row.last_short_id : row.last_live_id;
|
|
433
|
+
return pointerFor(parseState(readFileSync(STATE_MD, 'utf8')).find(r => r.handle === item.channel), item.type);
|
|
429
434
|
}
|
|
430
435
|
/**
|
|
431
436
|
* Pure-ish pipeline for one queue item: fetch transcript → substance check + summary.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yt-briefing",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.3",
|
|
4
4
|
"description": "A self-learning YouTube briefing for Claude Code: it sweeps the channels you follow, filters noise in two stages (title, then transcript), gives you a short briefing of the rest in your language in the chat, and adapts to your ratings — one video at a time.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|