yt-liked 0.2.0-alpha.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 +203 -0
- package/bin/ytl.mjs +2 -0
- package/dist/channel-enrich.js +209 -0
- package/dist/chrome-cookies.js +130 -0
- package/dist/classify-setup.js +409 -0
- package/dist/cli.js +625 -0
- package/dist/config.js +41 -0
- package/dist/db.js +28 -0
- package/dist/gemini-classify.js +424 -0
- package/dist/jsonl.js +22 -0
- package/dist/paths.js +26 -0
- package/dist/report.js +24 -0
- package/dist/types.js +1 -0
- package/dist/videos-db.js +534 -0
- package/dist/videos-import.js +122 -0
- package/dist/videos-viz.js +140 -0
- package/dist/youtube-web.js +217 -0
- package/package.json +47 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
const ESC = '\x1b[';
|
|
2
|
+
const RESET = `${ESC}0m`;
|
|
3
|
+
const BOLD = `${ESC}1m`;
|
|
4
|
+
const DIM = `${ESC}2m`;
|
|
5
|
+
const rgb = (r, g, b) => `${ESC}38;2;${r};${g};${b}m`;
|
|
6
|
+
const C = {
|
|
7
|
+
title: rgb(235, 226, 201),
|
|
8
|
+
text: rgb(214, 214, 220),
|
|
9
|
+
dim: rgb(118, 118, 132),
|
|
10
|
+
line: rgb(72, 72, 86),
|
|
11
|
+
accent: rgb(116, 180, 178),
|
|
12
|
+
warm: rgb(230, 176, 102),
|
|
13
|
+
green: rgb(137, 208, 142),
|
|
14
|
+
coral: rgb(226, 119, 119),
|
|
15
|
+
blue: rgb(126, 170, 230),
|
|
16
|
+
gold: rgb(223, 197, 111),
|
|
17
|
+
};
|
|
18
|
+
const BLOCKS = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
|
|
19
|
+
const SPARKS = '▁▂▃▄▅▆▇█';
|
|
20
|
+
function stripAnsi(value) {
|
|
21
|
+
return value.replace(/\x1b\[[0-9;]*m/g, '');
|
|
22
|
+
}
|
|
23
|
+
function bar(value, max, width, color) {
|
|
24
|
+
const ratio = max > 0 ? value / max : 0;
|
|
25
|
+
const filled = ratio * width;
|
|
26
|
+
const full = Math.floor(filled);
|
|
27
|
+
const partial = Math.round((filled - full) * 8);
|
|
28
|
+
return (color +
|
|
29
|
+
'█'.repeat(full) +
|
|
30
|
+
(partial > 0 ? BLOCKS[partial] : '') +
|
|
31
|
+
RESET +
|
|
32
|
+
' '.repeat(Math.max(0, width - full - (partial > 0 ? 1 : 0))));
|
|
33
|
+
}
|
|
34
|
+
function sparkline(data, color) {
|
|
35
|
+
const max = Math.max(...data, 1);
|
|
36
|
+
return color + data.map((value) => SPARKS[Math.round((value / max) * 7)] ?? SPARKS[0]).join('') + RESET;
|
|
37
|
+
}
|
|
38
|
+
function boxTop(width) {
|
|
39
|
+
return C.line + '╭' + '─'.repeat(width - 2) + '╮' + RESET;
|
|
40
|
+
}
|
|
41
|
+
function boxBottom(width) {
|
|
42
|
+
return C.line + '╰' + '─'.repeat(width - 2) + '╯' + RESET;
|
|
43
|
+
}
|
|
44
|
+
function boxDivider(width) {
|
|
45
|
+
return C.line + '├' + '─'.repeat(width - 2) + '┤' + RESET;
|
|
46
|
+
}
|
|
47
|
+
function boxRow(content, width) {
|
|
48
|
+
const pad = Math.max(0, width - 4 - stripAnsi(content).length);
|
|
49
|
+
return `${C.line}│ ${RESET}${content}${' '.repeat(pad)}${C.line} │${RESET}`;
|
|
50
|
+
}
|
|
51
|
+
function pct(part, total) {
|
|
52
|
+
if (total <= 0)
|
|
53
|
+
return '0.0%';
|
|
54
|
+
return `${((part / total) * 100).toFixed(1)}%`;
|
|
55
|
+
}
|
|
56
|
+
function trimLabel(label, width) {
|
|
57
|
+
if (label.length <= width)
|
|
58
|
+
return label;
|
|
59
|
+
return `${label.slice(0, Math.max(1, width - 1))}…`;
|
|
60
|
+
}
|
|
61
|
+
function renderRankedRows(rows, total, width, color) {
|
|
62
|
+
if (rows.length === 0) {
|
|
63
|
+
return [boxRow(`${DIM}none yet${RESET}`, width)];
|
|
64
|
+
}
|
|
65
|
+
const maxCount = Math.max(...rows.map((row) => row.count), 1);
|
|
66
|
+
return rows.map((row) => {
|
|
67
|
+
const label = trimLabel(row.label, 14).padEnd(14);
|
|
68
|
+
const count = row.count.toLocaleString().padStart(5);
|
|
69
|
+
const valuePct = pct(row.count, total).padStart(6);
|
|
70
|
+
return boxRow(`${C.text}${label}${RESET} ${bar(row.count, maxCount, 14, color)} ${C.text}${count}${RESET} ${DIM}${valuePct}${RESET}`, width);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
export function renderVideoViz(view) {
|
|
74
|
+
const width = 78;
|
|
75
|
+
const lines = [];
|
|
76
|
+
const monthCounts = view.monthlyLikes.map((row) => row.count);
|
|
77
|
+
const categoryCoverage = `${view.categorizedCount.toLocaleString()} labeled`;
|
|
78
|
+
const domainCoverage = `${view.domainCount.toLocaleString()} domained`;
|
|
79
|
+
const channelIntegrity = view.channelMetadataLikelyOwnerFallback
|
|
80
|
+
? `${C.coral}suspect source field${RESET}`
|
|
81
|
+
: `${C.green}${view.distinctChannelTitles.toLocaleString()} channel titles${RESET}`;
|
|
82
|
+
const relabelDominantFallback = !view.channelMetadataLikelyOwnerFallback
|
|
83
|
+
&& Boolean(view.dominantFallbackChannelTitle)
|
|
84
|
+
&& view.dominantFallbackChannelCount >= 25
|
|
85
|
+
&& (view.dominantFallbackChannelTitle === 'J' || view.dominantFallbackChannelTitle.length <= 2);
|
|
86
|
+
lines.push(boxTop(width));
|
|
87
|
+
lines.push(boxRow(`${BOLD}${C.title}YTL Archive Atlas${RESET} ${DIM}local likes snapshot${RESET}`, width));
|
|
88
|
+
lines.push(boxRow(`${C.text}${view.importedCount.toLocaleString()} videos${RESET} ${DIM}•${RESET} ${C.accent}${categoryCoverage}${RESET} ${DIM}•${RESET} ${C.blue}${domainCoverage}${RESET}`, width));
|
|
89
|
+
if (monthCounts.length > 0) {
|
|
90
|
+
const left = view.monthlyLikes[0]?.label ?? '';
|
|
91
|
+
const right = view.monthlyLikes.at(-1)?.label ?? '';
|
|
92
|
+
lines.push(boxRow(`${C.warm}${sparkline(monthCounts, C.warm)}${RESET} ${DIM}${left} → ${right}${RESET}`, width));
|
|
93
|
+
}
|
|
94
|
+
lines.push(boxDivider(width));
|
|
95
|
+
const coverageBarWidth = 40;
|
|
96
|
+
const catDone = Math.round((view.categorizedCount / Math.max(1, view.importedCount)) * coverageBarWidth);
|
|
97
|
+
const domDone = Math.round((view.domainCount / Math.max(1, view.importedCount)) * coverageBarWidth);
|
|
98
|
+
lines.push(boxRow(`${C.text}Category coverage${RESET} ${C.green}${'█'.repeat(catDone)}${DIM}${'·'.repeat(Math.max(0, coverageBarWidth - catDone))}${RESET} ${C.text}${pct(view.categorizedCount, view.importedCount)}${RESET}`, width));
|
|
99
|
+
lines.push(boxRow(`${C.text}Domain coverage ${RESET} ${C.blue}${'█'.repeat(domDone)}${DIM}${'·'.repeat(Math.max(0, coverageBarWidth - domDone))}${RESET} ${C.text}${pct(view.domainCount, view.importedCount)}${RESET}`, width));
|
|
100
|
+
const privacy = view.privacyBreakdown
|
|
101
|
+
.slice(0, 3)
|
|
102
|
+
.map((row) => `${row.label} ${row.count.toLocaleString()}`)
|
|
103
|
+
.join(` ${DIM}•${RESET} `);
|
|
104
|
+
lines.push(boxRow(`${C.text}Privacy${RESET} ${DIM}${privacy || 'no privacy data'}${RESET}`, width));
|
|
105
|
+
lines.push(boxBottom(width));
|
|
106
|
+
lines.push('');
|
|
107
|
+
lines.push(boxTop(width));
|
|
108
|
+
lines.push(boxRow(`${BOLD}${C.gold}Top categories${RESET}`, width));
|
|
109
|
+
lines.push(...renderRankedRows(view.topCategories.slice(0, 10), view.importedCount, width, C.gold));
|
|
110
|
+
lines.push(boxBottom(width));
|
|
111
|
+
lines.push('');
|
|
112
|
+
lines.push(boxTop(width));
|
|
113
|
+
lines.push(boxRow(`${BOLD}${C.accent}Top domains${RESET}`, width));
|
|
114
|
+
lines.push(...renderRankedRows(view.topDomains.slice(0, 10), view.importedCount, width, C.accent));
|
|
115
|
+
lines.push(boxBottom(width));
|
|
116
|
+
lines.push('');
|
|
117
|
+
lines.push(boxTop(width));
|
|
118
|
+
lines.push(boxRow(`${BOLD}${C.blue}Uploader signal${RESET}`, width));
|
|
119
|
+
lines.push(boxRow(`${C.text}Distinct channel titles:${RESET} ${view.distinctChannelTitles.toLocaleString()} ${DIM}•${RESET} ${C.text}Distinct channel ids:${RESET} ${view.distinctChannelIds.toLocaleString()}`, width));
|
|
120
|
+
lines.push(boxRow(`${C.text}Importer integrity:${RESET} ${channelIntegrity}`, width));
|
|
121
|
+
if (view.channelMetadataLikelyOwnerFallback) {
|
|
122
|
+
lines.push(boxRow(`${C.coral}Warning:${RESET} imported channel_title/channel_id appear to reflect the likes playlist owner, not each video's uploader.`, width));
|
|
123
|
+
lines.push(boxRow(`${DIM}That is why the current Top channels view collapses to "J". Run ytl enrich-channels to repair it.${RESET}`, width));
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
if (relabelDominantFallback) {
|
|
127
|
+
lines.push(boxRow(`${DIM}Residual fallback rows are grouped below as unresolved uploader metadata rather than attributed to your profile.${RESET}`, width));
|
|
128
|
+
}
|
|
129
|
+
lines.push(...renderRankedRows(view.topChannels.slice(0, 8).map((row) => ({
|
|
130
|
+
label: relabelDominantFallback && row.channelTitle === view.dominantFallbackChannelTitle
|
|
131
|
+
? 'Unresolved upldr'
|
|
132
|
+
: row.channelTitle,
|
|
133
|
+
count: row.count,
|
|
134
|
+
})), view.importedCount, width, C.blue));
|
|
135
|
+
}
|
|
136
|
+
lines.push(boxBottom(width));
|
|
137
|
+
lines.push('');
|
|
138
|
+
lines.push(`${DIM}Next:${RESET} ytl enrich-channels ${DIM}•${RESET} ytl classify-domains ${DIM}•${RESET} ytl status`);
|
|
139
|
+
return lines.join('\n');
|
|
140
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
const YOUTUBE_ORIGIN = 'https://www.youtube.com';
|
|
3
|
+
const LIKES_URL = `${YOUTUBE_ORIGIN}/playlist?list=LL`;
|
|
4
|
+
function sleep(ms) {
|
|
5
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
6
|
+
}
|
|
7
|
+
export function createSapisidAuthHeader(sapisid, dataSyncId, timestampSec, origin = YOUTUBE_ORIGIN) {
|
|
8
|
+
const hashInput = [dataSyncId, timestampSec, sapisid, origin].join(' ');
|
|
9
|
+
const digest = createHash('sha1').update(hashInput).digest('hex');
|
|
10
|
+
const token = `${timestampSec}_${digest}_u`;
|
|
11
|
+
return `SAPISIDHASH ${token} SAPISID1PHASH ${token} SAPISID3PHASH ${token}`;
|
|
12
|
+
}
|
|
13
|
+
function extractBalancedJson(text, marker) {
|
|
14
|
+
const markerIndex = text.indexOf(marker);
|
|
15
|
+
if (markerIndex < 0) {
|
|
16
|
+
throw new Error(`Could not find marker: ${marker}`);
|
|
17
|
+
}
|
|
18
|
+
const start = text.indexOf('{', markerIndex);
|
|
19
|
+
if (start < 0) {
|
|
20
|
+
throw new Error(`Could not find opening brace after marker: ${marker}`);
|
|
21
|
+
}
|
|
22
|
+
let depth = 0;
|
|
23
|
+
let inString = false;
|
|
24
|
+
let escaped = false;
|
|
25
|
+
for (let i = start; i < text.length; i += 1) {
|
|
26
|
+
const char = text[i];
|
|
27
|
+
if (inString) {
|
|
28
|
+
if (escaped) {
|
|
29
|
+
escaped = false;
|
|
30
|
+
}
|
|
31
|
+
else if (char === '\\') {
|
|
32
|
+
escaped = true;
|
|
33
|
+
}
|
|
34
|
+
else if (char === '"') {
|
|
35
|
+
inString = false;
|
|
36
|
+
}
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (char === '"') {
|
|
40
|
+
inString = true;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (char === '{')
|
|
44
|
+
depth += 1;
|
|
45
|
+
if (char === '}') {
|
|
46
|
+
depth -= 1;
|
|
47
|
+
if (depth === 0) {
|
|
48
|
+
return text.slice(start, i + 1);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`Could not extract balanced JSON for marker: ${marker}`);
|
|
53
|
+
}
|
|
54
|
+
function extractContinuationTokenFromHtml(html) {
|
|
55
|
+
const match = html.match(/"continuationCommand":\{"token":"([^"]+)"/);
|
|
56
|
+
return match?.[1] ?? null;
|
|
57
|
+
}
|
|
58
|
+
function parseCount(text) {
|
|
59
|
+
if (!text)
|
|
60
|
+
return null;
|
|
61
|
+
const digits = text.replace(/[^0-9]/g, '');
|
|
62
|
+
if (!digits)
|
|
63
|
+
return null;
|
|
64
|
+
return Number.parseInt(digits, 10);
|
|
65
|
+
}
|
|
66
|
+
function parseBootstrap(html) {
|
|
67
|
+
const cfg = JSON.parse(extractBalancedJson(html, 'ytcfg.set({'));
|
|
68
|
+
const initialData = JSON.parse(extractBalancedJson(html, 'var ytInitialData = '));
|
|
69
|
+
const continuationToken = extractContinuationTokenFromHtml(html);
|
|
70
|
+
if (!continuationToken) {
|
|
71
|
+
throw new Error('Could not find a continuation token in ytInitialData.');
|
|
72
|
+
}
|
|
73
|
+
const statedVideoCount = parseCount(initialData?.header?.playlistHeaderRenderer?.numVideosText?.runs?.map((run) => run.text).join('')
|
|
74
|
+
?? initialData?.sidebar?.playlistSidebarRenderer?.items?.[0]?.playlistSidebarPrimaryInfoRenderer?.stats?.[0]?.runs?.map((run) => run.text).join(''));
|
|
75
|
+
const alerts = (initialData?.alerts ?? [])
|
|
76
|
+
.map((alert) => alert?.alertWithButtonRenderer?.text?.simpleText)
|
|
77
|
+
.filter((value) => typeof value === 'string');
|
|
78
|
+
return {
|
|
79
|
+
apiKey: cfg.INNERTUBE_API_KEY,
|
|
80
|
+
clientVersion: cfg.INNERTUBE_CLIENT_VERSION,
|
|
81
|
+
visitorData: cfg.VISITOR_DATA,
|
|
82
|
+
sessionIndex: String(cfg.SESSION_INDEX ?? 0),
|
|
83
|
+
hl: String(cfg.HL ?? 'en'),
|
|
84
|
+
gl: String(cfg.GL ?? 'US'),
|
|
85
|
+
dataSyncId: String(initialData?.responseContext?.mainAppWebResponseContext?.datasyncId ?? '').split('||')[0],
|
|
86
|
+
continuationToken,
|
|
87
|
+
pageTitle: String(initialData?.metadata?.playlistMetadataRenderer?.title ?? 'Liked videos'),
|
|
88
|
+
statedVideoCount,
|
|
89
|
+
alerts,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function extractVideosFromContinuation(json) {
|
|
93
|
+
const items = json?.onResponseReceivedActions?.[0]?.appendContinuationItemsAction?.continuationItems
|
|
94
|
+
?? json?.onResponseReceivedEndpoints?.[0]?.appendContinuationItemsAction?.continuationItems
|
|
95
|
+
?? [];
|
|
96
|
+
const videos = items
|
|
97
|
+
.map((item) => item?.playlistVideoRenderer)
|
|
98
|
+
.filter(Boolean)
|
|
99
|
+
.map((video) => ({
|
|
100
|
+
index: video?.index?.simpleText ? Number.parseInt(video.index.simpleText, 10) : null,
|
|
101
|
+
}));
|
|
102
|
+
const nextToken = items
|
|
103
|
+
.map((item) => item?.continuationItemRenderer?.continuationEndpoint?.continuationCommand?.token)
|
|
104
|
+
.find((value) => typeof value === 'string')
|
|
105
|
+
?? null;
|
|
106
|
+
return { videos, nextToken };
|
|
107
|
+
}
|
|
108
|
+
export async function fetchLikesBootstrap(cookieHeader) {
|
|
109
|
+
const response = await fetch(LIKES_URL, {
|
|
110
|
+
headers: {
|
|
111
|
+
cookie: cookieHeader,
|
|
112
|
+
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36',
|
|
113
|
+
accept: 'text/html,application/xhtml+xml',
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
if (!response.ok) {
|
|
117
|
+
throw new Error(`Could not load YouTube Likes page (${response.status}).`);
|
|
118
|
+
}
|
|
119
|
+
const html = await response.text();
|
|
120
|
+
return parseBootstrap(html);
|
|
121
|
+
}
|
|
122
|
+
async function fetchContinuationPage(bootstrap, cookieHeader, sapisid, continuationToken) {
|
|
123
|
+
const timestampSec = Math.floor(Date.now() / 1000);
|
|
124
|
+
const response = await fetch(`${YOUTUBE_ORIGIN}/youtubei/v1/browse?prettyPrint=false&key=${bootstrap.apiKey}`, {
|
|
125
|
+
method: 'POST',
|
|
126
|
+
headers: {
|
|
127
|
+
cookie: cookieHeader,
|
|
128
|
+
'content-type': 'application/json',
|
|
129
|
+
authorization: createSapisidAuthHeader(sapisid, bootstrap.dataSyncId, timestampSec),
|
|
130
|
+
'x-youtube-client-name': '1',
|
|
131
|
+
'x-youtube-client-version': bootstrap.clientVersion,
|
|
132
|
+
'x-goog-visitor-id': bootstrap.visitorData,
|
|
133
|
+
'x-goog-authuser': bootstrap.sessionIndex,
|
|
134
|
+
'x-youtube-bootstrap-logged-in': 'true',
|
|
135
|
+
'x-origin': YOUTUBE_ORIGIN,
|
|
136
|
+
origin: YOUTUBE_ORIGIN,
|
|
137
|
+
referer: LIKES_URL,
|
|
138
|
+
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36',
|
|
139
|
+
accept: '*/*',
|
|
140
|
+
},
|
|
141
|
+
body: JSON.stringify({
|
|
142
|
+
context: {
|
|
143
|
+
client: {
|
|
144
|
+
clientName: 'WEB',
|
|
145
|
+
clientVersion: bootstrap.clientVersion,
|
|
146
|
+
visitorData: bootstrap.visitorData,
|
|
147
|
+
hl: bootstrap.hl,
|
|
148
|
+
gl: bootstrap.gl,
|
|
149
|
+
},
|
|
150
|
+
user: { lockedSafetyMode: false },
|
|
151
|
+
request: { useSsl: true },
|
|
152
|
+
},
|
|
153
|
+
continuation: continuationToken,
|
|
154
|
+
}),
|
|
155
|
+
});
|
|
156
|
+
if (!response.ok) {
|
|
157
|
+
throw new Error(`Continuation request failed (${response.status}).`);
|
|
158
|
+
}
|
|
159
|
+
const json = await response.json();
|
|
160
|
+
if (json?.responseContext?.mainAppWebResponseContext?.loggedOut) {
|
|
161
|
+
throw new Error('YouTube continuation request came back logged out.');
|
|
162
|
+
}
|
|
163
|
+
return extractVideosFromContinuation(json);
|
|
164
|
+
}
|
|
165
|
+
export async function probeLikesHistory(options) {
|
|
166
|
+
const startedAt = Date.now();
|
|
167
|
+
const bootstrap = await fetchLikesBootstrap(options.cookieHeader);
|
|
168
|
+
const pages = [];
|
|
169
|
+
let continuationToken = bootstrap.continuationToken;
|
|
170
|
+
let discoveredCount = 100;
|
|
171
|
+
let stopReason = 'end of continuation';
|
|
172
|
+
for (let pageNumber = 1; pageNumber <= options.maxPages; pageNumber += 1) {
|
|
173
|
+
if (!continuationToken)
|
|
174
|
+
break;
|
|
175
|
+
if ((Date.now() - startedAt) / 60000 >= options.maxMinutes) {
|
|
176
|
+
stopReason = 'max runtime reached';
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
const page = await fetchContinuationPage(bootstrap, options.cookieHeader, options.sapisid, continuationToken);
|
|
180
|
+
const firstIndex = page.videos[0]?.index ?? null;
|
|
181
|
+
const lastIndex = page.videos[page.videos.length - 1]?.index ?? null;
|
|
182
|
+
discoveredCount += page.videos.length;
|
|
183
|
+
pages.push({
|
|
184
|
+
page: pageNumber,
|
|
185
|
+
count: page.videos.length,
|
|
186
|
+
firstIndex,
|
|
187
|
+
lastIndex,
|
|
188
|
+
hasNext: Boolean(page.nextToken),
|
|
189
|
+
});
|
|
190
|
+
continuationToken = page.nextToken;
|
|
191
|
+
if (!continuationToken) {
|
|
192
|
+
stopReason = 'continuation ended';
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
if (options.delayMs > 0) {
|
|
196
|
+
await sleep(options.delayMs);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (pages.length >= options.maxPages && continuationToken) {
|
|
200
|
+
stopReason = 'max pages reached';
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
generatedAt: new Date().toISOString(),
|
|
204
|
+
chromeUserDataDir: '',
|
|
205
|
+
chromeProfileDirectory: '',
|
|
206
|
+
pageTitle: bootstrap.pageTitle,
|
|
207
|
+
statedVideoCount: bootstrap.statedVideoCount,
|
|
208
|
+
alertMessages: bootstrap.alerts,
|
|
209
|
+
initialPageCount: 100,
|
|
210
|
+
discoveredCount,
|
|
211
|
+
pageCount: pages.length,
|
|
212
|
+
stopReason,
|
|
213
|
+
browserMethodBeatCeiling: discoveredCount > options.baselineCeiling,
|
|
214
|
+
baselineCeiling: options.baselineCeiling,
|
|
215
|
+
pages,
|
|
216
|
+
};
|
|
217
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "yt-liked",
|
|
3
|
+
"version": "0.2.0-alpha.0",
|
|
4
|
+
"description": "An archive-first local CLI for importing, searching, and classifying your YouTube liked videos.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ytl": "bin/ytl.mjs"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc -p tsconfig.json",
|
|
11
|
+
"dev": "tsx src/cli.ts",
|
|
12
|
+
"start": "node dist/cli.js",
|
|
13
|
+
"test": "tsx --test tests/**/*.test.ts",
|
|
14
|
+
"prepublishOnly": "npm run build && npm test"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"bin/",
|
|
18
|
+
"dist/",
|
|
19
|
+
"LICENSE",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=20"
|
|
24
|
+
},
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"keywords": [
|
|
27
|
+
"youtube",
|
|
28
|
+
"likes",
|
|
29
|
+
"cli",
|
|
30
|
+
"local-first",
|
|
31
|
+
"chrome",
|
|
32
|
+
"self-custody"
|
|
33
|
+
],
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@google/genai": "^1.48.0",
|
|
36
|
+
"commander": "^14.0.3",
|
|
37
|
+
"dotenv": "^17.4.0",
|
|
38
|
+
"sql.js": "^1.14.1",
|
|
39
|
+
"sql.js-fts5": "^1.4.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "^24.0.0",
|
|
43
|
+
"@types/sql.js": "^1.4.11",
|
|
44
|
+
"tsx": "^4.21.0",
|
|
45
|
+
"typescript": "^5.8.3"
|
|
46
|
+
}
|
|
47
|
+
}
|