@tacone/prosey 0.8.1 → 0.9.1

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/bin/prosey CHANGED
@@ -103177,14 +103177,21 @@ var LOGO_DATA_URI = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(LOGO_
103177
103177
  function escapeHtml(text) {
103178
103178
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
103179
103179
  }
103180
- function watchMetaHtml(duration, wordCount, videoId) {
103180
+ function watchMetaHtml(duration, wordCount, videoId, channelName, channelId, channelDescription) {
103181
+ const sep = '<span style="opacity: 0.4">&nbsp;&nbsp;|&nbsp;&nbsp;</span>';
103181
103182
  const h2 = Math.floor(duration / 3600);
103182
103183
  const m3 = Math.round(duration % 3600 / 60);
103183
103184
  const durStr = h2 > 0 ? `${h2} h ${m3} min` : `${m3} min`;
103184
103185
  const readTime = Math.ceil(wordCount / 200);
103185
- const url = `https://youtube.com/watch?v=${videoId}`;
103186
- const separator = '<span style="opacity: 0.4">&nbsp;&nbsp;|&nbsp;&nbsp;</span>';
103187
- return `<a href="${url}" class="watch-link">${durStr} watch</a> ${separator} ${readTime} min read`;
103186
+ const watchUrl = `https://youtube.com/watch?v=${videoId}`;
103187
+ let parts = "";
103188
+ if (channelName) {
103189
+ const chUrl = channelId ? `https://youtube.com/channel/${channelId}` : `https://youtube.com/@${encodeURIComponent(channelName)}`;
103190
+ const chDesc = channelDescription || channelName;
103191
+ parts += `<a href="${chUrl}" class="watch-link" title="${escapeHtml(chDesc)}">${escapeHtml(channelName)}</a>${sep}`;
103192
+ }
103193
+ parts += `<a href="${watchUrl}" class="watch-link">${durStr} watch</a>${sep}${readTime} min read`;
103194
+ return parts;
103188
103195
  }
103189
103196
  async function generateHtml(markdown, title, watchMeta) {
103190
103197
  const [css, body] = await Promise.all([getPicoCss(), g2.parse(markdown)]);
@@ -103275,7 +103282,7 @@ img[alt="Prosey"]:hover, img[alt="Prosey"]:active, img[alt="Prosey"]:focus { fil
103275
103282
  <body>
103276
103283
  <div style="display:flex;align-items:center;justify-content:space-between;padding:1rem 1rem 0">
103277
103284
  <img src="${LOGO_DATA_URI}" alt="Prosey" title="Prosey" style="vertical-align:top;flex-shrink:0">
103278
- <div style="flex:1;text-align:center;font-size:.85rem;color:var(--pico-muted-color)">${watchMeta ? watchMetaHtml(watchMeta.duration, watchMeta.wordCount, watchMeta.videoId) : ""}</div>
103285
+ <div style="flex:1;text-align:center;font-size:.85rem;color:var(--pico-muted-color)">${watchMeta ? watchMetaHtml(watchMeta.duration, watchMeta.wordCount, watchMeta.videoId, watchMeta.channelName, watchMeta.channelId, watchMeta.channelDescription) : ""}</div>
103279
103286
  <button id="theme-btn" type="button" style="background:none;border:none;cursor:pointer;padding:0;line-height:1;opacity:.5;filter:grayscale(100%);transition:all 0.3s;flex-shrink:0">\uD83D\uDCA1</button>
103280
103287
  </div>
103281
103288
  <main class="content">
@@ -103310,10 +103317,30 @@ function openInBrowser(htmlPath) {
103310
103317
  setTimeout(() => resolve(), 5000);
103311
103318
  });
103312
103319
  }
103320
+
103321
+ // src/channel-description.ts
103322
+ async function fetchChannelDescription(channelId) {
103323
+ try {
103324
+ const resp = await fetch(`https://www.youtube.com/youtubei/v1/browse`, {
103325
+ method: "POST",
103326
+ headers: { "Content-Type": "application/json" },
103327
+ body: JSON.stringify({
103328
+ context: {
103329
+ client: { clientName: "WEB", clientVersion: "2.20240101.00.00" }
103330
+ },
103331
+ browseId: channelId
103332
+ })
103333
+ });
103334
+ const data = await resp.json();
103335
+ return data?.metadata?.channelMetadataRenderer?.description;
103336
+ } catch {
103337
+ return;
103338
+ }
103339
+ }
103313
103340
  // package.json
103314
103341
  var package_default = {
103315
103342
  name: "@tacone/prosey",
103316
- version: "0.8.1",
103343
+ version: "0.9.1",
103317
103344
  description: "Download YouTube video transcripts from the CLI",
103318
103345
  module: "src/index.ts",
103319
103346
  type: "module",
@@ -122340,15 +122367,23 @@ try {
122340
122367
  let structuredContent;
122341
122368
  let videoTitle;
122342
122369
  let videoDuration = 0;
122370
+ let channelName;
122371
+ let channelId;
122372
+ let channelDescription;
122343
122373
  if (!segments2) {
122344
122374
  info("Fetching transcript...");
122345
122375
  const opts = lang ? { lang, videoDetails: true } : { videoDetails: true };
122346
122376
  const result = await fetchTranscript(videoId, opts);
122347
122377
  segments2 = result.segments;
122348
122378
  videoDuration = result.videoDetails.lengthSeconds;
122379
+ channelName = result.videoDetails.author;
122380
+ channelId = result.videoDetails.channelId;
122381
+ channelDescription = await fetchChannelDescription(channelId);
122349
122382
  const infoJson = JSON.stringify({
122350
122383
  title: result.videoDetails.title,
122351
- channel: result.videoDetails.author,
122384
+ channel: channelName,
122385
+ channelId,
122386
+ channelDescription,
122352
122387
  description: result.videoDetails.description,
122353
122388
  duration: videoDuration
122354
122389
  });
@@ -122385,9 +122420,14 @@ ${structuredContent}
122385
122420
  const fallbackOpts = lang ? { lang, videoDetails: true } : { videoDetails: true };
122386
122421
  const fallbackResult = await fetchTranscript(videoId, fallbackOpts);
122387
122422
  videoDuration = fallbackResult.videoDetails.lengthSeconds;
122423
+ channelName = fallbackResult.videoDetails.author;
122424
+ channelId = fallbackResult.videoDetails.channelId;
122425
+ channelDescription = await fetchChannelDescription(channelId);
122388
122426
  cachedInfo = JSON.stringify({
122389
122427
  title: fallbackResult.videoDetails.title,
122390
122428
  channel: fallbackResult.videoDetails.author,
122429
+ channelId,
122430
+ channelDescription,
122391
122431
  description: fallbackResult.videoDetails.description,
122392
122432
  duration: videoDuration
122393
122433
  });
@@ -122403,6 +122443,9 @@ ${structuredContent}
122403
122443
  const cachedInfoObj = JSON.parse(cachedInfo);
122404
122444
  videoTitle = cachedInfoObj.title;
122405
122445
  videoDuration = cachedInfoObj.duration ?? 0;
122446
+ channelName = cachedInfoObj.channel;
122447
+ channelId = cachedInfoObj.channelId;
122448
+ channelDescription = cachedInfoObj.channelDescription;
122406
122449
  const truncatedInfo = JSON.stringify({
122407
122450
  title: cachedInfoObj.title,
122408
122451
  channel: cachedInfoObj.channel,
@@ -122439,7 +122482,10 @@ ${structuredContent}
122439
122482
  const htmlContent = await generateHtml(formatted, videoTitle, {
122440
122483
  videoId,
122441
122484
  duration: videoDuration,
122442
- wordCount
122485
+ wordCount,
122486
+ channelName,
122487
+ channelId,
122488
+ channelDescription
122443
122489
  });
122444
122490
  const htmlPath = join5(dir2, "summary.html");
122445
122491
  await writeFile3(htmlPath, htmlContent, "utf8");
@@ -122473,6 +122519,9 @@ ${structuredContent}
122473
122519
  let md = null;
122474
122520
  let videoTitle;
122475
122521
  let videoDuration = 0;
122522
+ let channelName;
122523
+ let channelId;
122524
+ let channelDescription;
122476
122525
  startTimer();
122477
122526
  let cachedInfo = null;
122478
122527
  if (!noCache) {
@@ -122501,9 +122550,14 @@ ${structuredContent}
122501
122550
  const result = await fetchTranscript(videoId, opts);
122502
122551
  segments2 = result.segments;
122503
122552
  videoDuration = result.videoDetails.lengthSeconds;
122553
+ channelName = result.videoDetails.author;
122554
+ channelId = result.videoDetails.channelId;
122555
+ channelDescription = await fetchChannelDescription(channelId);
122504
122556
  const infoJson = JSON.stringify({
122505
122557
  title: result.videoDetails.title,
122506
- channel: result.videoDetails.author,
122558
+ channel: channelName,
122559
+ channelId,
122560
+ channelDescription,
122507
122561
  description: result.videoDetails.description,
122508
122562
  duration: videoDuration
122509
122563
  });
@@ -122555,9 +122609,14 @@ ${structuredContent}
122555
122609
  const fallbackOpts = lang ? { lang, videoDetails: true } : { videoDetails: true };
122556
122610
  const fallbackResult = await fetchTranscript(videoId, fallbackOpts);
122557
122611
  videoDuration = fallbackResult.videoDetails.lengthSeconds;
122612
+ channelName = fallbackResult.videoDetails.author;
122613
+ channelId = fallbackResult.videoDetails.channelId;
122614
+ channelDescription = await fetchChannelDescription(channelId);
122558
122615
  cachedInfo = JSON.stringify({
122559
122616
  title: fallbackResult.videoDetails.title,
122560
122617
  channel: fallbackResult.videoDetails.author,
122618
+ channelId,
122619
+ channelDescription,
122561
122620
  description: fallbackResult.videoDetails.description,
122562
122621
  duration: videoDuration
122563
122622
  });
@@ -122573,6 +122632,9 @@ ${structuredContent}
122573
122632
  const cachedInfoObj = JSON.parse(cachedInfo);
122574
122633
  videoTitle = cachedInfoObj.title;
122575
122634
  videoDuration = cachedInfoObj.duration ?? 0;
122635
+ channelName = cachedInfoObj.channel;
122636
+ channelId = cachedInfoObj.channelId;
122637
+ channelDescription = cachedInfoObj.channelDescription;
122576
122638
  const truncatedInfo = JSON.stringify({
122577
122639
  title: cachedInfoObj.title,
122578
122640
  channel: cachedInfoObj.channel,
@@ -122605,7 +122667,10 @@ ${transcriptText}`;
122605
122667
  const htmlContent = await generateHtml(formatted, videoTitle, {
122606
122668
  videoId,
122607
122669
  duration: videoDuration,
122608
- wordCount
122670
+ wordCount,
122671
+ channelName,
122672
+ channelId,
122673
+ channelDescription
122609
122674
  });
122610
122675
  const htmlPath = join5(dir2, "transcript.html");
122611
122676
  await writeFile3(htmlPath, htmlContent, "utf8");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tacone/prosey",
3
- "version": "0.8.1",
3
+ "version": "0.9.1",
4
4
  "description": "Download YouTube video transcripts from the CLI",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -0,0 +1,50 @@
1
+ import { describe, expect, test, mock } from "bun:test";
2
+ import { fetchChannelDescription } from "./channel-description";
3
+
4
+ function mockFetchResponse(data: unknown): void {
5
+ globalThis.fetch = mock(() =>
6
+ Promise.resolve({
7
+ ok: true,
8
+ json: () => Promise.resolve(data),
9
+ }),
10
+ ) as unknown as typeof fetch;
11
+ }
12
+
13
+ describe("fetchChannelDescription", () => {
14
+ test("calls the browse endpoint without an API key", async () => {
15
+ let capturedUrl = "";
16
+ let capturedInit: RequestInit | undefined;
17
+ globalThis.fetch = ((url: any, init: any) => {
18
+ capturedUrl = String(url);
19
+ capturedInit = init;
20
+ return Promise.resolve({
21
+ ok: true,
22
+ json: () =>
23
+ Promise.resolve({
24
+ metadata: {
25
+ channelMetadataRenderer: { description: "Channel bio" },
26
+ },
27
+ }),
28
+ });
29
+ }) as unknown as typeof fetch;
30
+
31
+ const desc = await fetchChannelDescription("UC123");
32
+ expect(desc).toBe("Channel bio");
33
+
34
+ expect(capturedUrl).toBe("https://www.youtube.com/youtubei/v1/browse");
35
+ expect(capturedUrl).not.toContain("key=");
36
+ expect(capturedInit!.method).toBe("POST");
37
+ const body = JSON.parse(String(capturedInit!.body));
38
+ expect(body.browseId).toBe("UC123");
39
+ });
40
+
41
+ test("returns undefined when response has no description", async () => {
42
+ mockFetchResponse({ metadata: {} });
43
+ expect(await fetchChannelDescription("UC123")).toBeUndefined();
44
+ });
45
+
46
+ test("returns undefined on network error", async () => {
47
+ globalThis.fetch = mock(() => Promise.reject(new Error("boom"))) as unknown as typeof fetch;
48
+ expect(await fetchChannelDescription("UC123")).toBeUndefined();
49
+ });
50
+ });
@@ -0,0 +1,22 @@
1
+ export interface ChannelDescriptionResult {
2
+ description?: string;
3
+ }
4
+
5
+ export async function fetchChannelDescription(channelId: string): Promise<string | undefined> {
6
+ try {
7
+ const resp = await fetch(`https://www.youtube.com/youtubei/v1/browse`, {
8
+ method: "POST",
9
+ headers: { "Content-Type": "application/json" },
10
+ body: JSON.stringify({
11
+ context: {
12
+ client: { clientName: "WEB", clientVersion: "2.20240101.00.00" },
13
+ },
14
+ browseId: channelId,
15
+ }),
16
+ });
17
+ const data: ChannelDescriptionResult = (await resp.json()) as ChannelDescriptionResult;
18
+ return (data as any)?.metadata?.channelMetadataRenderer?.description;
19
+ } catch {
20
+ return undefined;
21
+ }
22
+ }
package/src/html.ts CHANGED
@@ -34,20 +34,43 @@ function escapeHtml(text: string): string {
34
34
  .replace(/"/g, "&quot;");
35
35
  }
36
36
 
37
- function watchMetaHtml(duration: number, wordCount: number, videoId: string): string {
37
+ function watchMetaHtml(
38
+ duration: number,
39
+ wordCount: number,
40
+ videoId: string,
41
+ channelName?: string,
42
+ channelId?: string,
43
+ channelDescription?: string,
44
+ ): string {
45
+ const sep = '<span style="opacity: 0.4">&nbsp;&nbsp;|&nbsp;&nbsp;</span>';
38
46
  const h = Math.floor(duration / 3600);
39
47
  const m = Math.round((duration % 3600) / 60);
40
48
  const durStr = h > 0 ? `${h} h ${m} min` : `${m} min`;
41
49
  const readTime = Math.ceil(wordCount / 200);
42
- const url = `https://youtube.com/watch?v=${videoId}`;
43
- const separator = '<span style="opacity: 0.4">&nbsp;&nbsp;|&nbsp;&nbsp;</span>';
44
- return `<a href="${url}" class="watch-link">${durStr} watch</a> ${separator} ${readTime} min read`;
50
+ const watchUrl = `https://youtube.com/watch?v=${videoId}`;
51
+ let parts = "";
52
+ if (channelName) {
53
+ const chUrl = channelId
54
+ ? `https://youtube.com/channel/${channelId}`
55
+ : `https://youtube.com/@${encodeURIComponent(channelName)}`;
56
+ const chDesc = channelDescription || channelName;
57
+ parts += `<a href="${chUrl}" class="watch-link" title="${escapeHtml(chDesc)}">${escapeHtml(channelName)}</a>${sep}`;
58
+ }
59
+ parts += `<a href="${watchUrl}" class="watch-link">${durStr} watch</a>${sep}${readTime} min read`;
60
+ return parts;
45
61
  }
46
62
 
47
63
  export async function generateHtml(
48
64
  markdown: string,
49
65
  title?: string,
50
- watchMeta?: { videoId: string; duration: number; wordCount: number },
66
+ watchMeta?: {
67
+ videoId: string;
68
+ duration: number;
69
+ wordCount: number;
70
+ channelName?: string;
71
+ channelId?: string;
72
+ channelDescription?: string;
73
+ },
51
74
  ): Promise<string> {
52
75
  const [css, body] = await Promise.all([getPicoCss(), marked.parse(markdown)]);
53
76
 
@@ -138,7 +161,7 @@ img[alt="Prosey"]:hover, img[alt="Prosey"]:active, img[alt="Prosey"]:focus { fil
138
161
  <body>
139
162
  <div style="display:flex;align-items:center;justify-content:space-between;padding:1rem 1rem 0">
140
163
  <img src="${LOGO_DATA_URI}" alt="Prosey" title="Prosey" style="vertical-align:top;flex-shrink:0">
141
- <div style="flex:1;text-align:center;font-size:.85rem;color:var(--pico-muted-color)">${watchMeta ? watchMetaHtml(watchMeta.duration, watchMeta.wordCount, watchMeta.videoId) : ""}</div>
164
+ <div style="flex:1;text-align:center;font-size:.85rem;color:var(--pico-muted-color)">${watchMeta ? watchMetaHtml(watchMeta.duration, watchMeta.wordCount, watchMeta.videoId, watchMeta.channelName, watchMeta.channelId, watchMeta.channelDescription) : ""}</div>
142
165
  <button id="theme-btn" type="button" style="background:none;border:none;cursor:pointer;padding:0;line-height:1;opacity:.5;filter:grayscale(100%);transition:all 0.3s;flex-shrink:0">💡</button>
143
166
  </div>
144
167
  <main class="content">
package/src/index.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  import { cacheDir, readCache, writeCache, extractVideoId } from "./cache";
22
22
  import { extractChapters, formatChaptersAsText, formatChaptersAsJson } from "./extract-chapters";
23
23
  import { generateHtml, openInBrowser } from "./html";
24
+ import { fetchChannelDescription } from "./channel-description";
24
25
  import { checkVersion } from "./version-check";
25
26
  import pkg from "../package.json";
26
27
  import prettier from "prettier";
@@ -484,6 +485,9 @@ try {
484
485
  let structuredContent: string;
485
486
  let videoTitle: string | undefined;
486
487
  let videoDuration = 0;
488
+ let channelName: string | undefined;
489
+ let channelId: string | undefined;
490
+ let channelDescription: string | undefined;
487
491
 
488
492
  if (!segments) {
489
493
  info("Fetching transcript...");
@@ -494,9 +498,14 @@ try {
494
498
  };
495
499
  segments = result.segments;
496
500
  videoDuration = result.videoDetails.lengthSeconds;
501
+ channelName = result.videoDetails.author;
502
+ channelId = result.videoDetails.channelId;
503
+ channelDescription = await fetchChannelDescription(channelId);
497
504
  const infoJson = JSON.stringify({
498
505
  title: result.videoDetails.title,
499
- channel: result.videoDetails.author,
506
+ channel: channelName,
507
+ channelId: channelId,
508
+ channelDescription: channelDescription,
500
509
  description: result.videoDetails.description,
501
510
  duration: videoDuration,
502
511
  });
@@ -533,9 +542,14 @@ try {
533
542
  segments: TranscriptSegment[];
534
543
  };
535
544
  videoDuration = fallbackResult.videoDetails.lengthSeconds;
545
+ channelName = fallbackResult.videoDetails.author;
546
+ channelId = fallbackResult.videoDetails.channelId;
547
+ channelDescription = await fetchChannelDescription(channelId);
536
548
  cachedInfo = JSON.stringify({
537
549
  title: fallbackResult.videoDetails.title,
538
550
  channel: fallbackResult.videoDetails.author,
551
+ channelId: channelId,
552
+ channelDescription: channelDescription,
539
553
  description: fallbackResult.videoDetails.description,
540
554
  duration: videoDuration,
541
555
  });
@@ -553,6 +567,9 @@ try {
553
567
  const cachedInfoObj = JSON.parse(cachedInfo);
554
568
  videoTitle = cachedInfoObj.title;
555
569
  videoDuration = cachedInfoObj.duration ?? 0;
570
+ channelName = cachedInfoObj.channel;
571
+ channelId = cachedInfoObj.channelId;
572
+ channelDescription = cachedInfoObj.channelDescription;
556
573
  const truncatedInfo = JSON.stringify({
557
574
  title: cachedInfoObj.title,
558
575
  channel: cachedInfoObj.channel,
@@ -586,6 +603,9 @@ try {
586
603
  videoId,
587
604
  duration: videoDuration,
588
605
  wordCount,
606
+ channelName,
607
+ channelId,
608
+ channelDescription,
589
609
  });
590
610
  const htmlPath = join(dir, "summary.html");
591
611
  await writeFile(htmlPath, htmlContent, "utf8");
@@ -622,6 +642,9 @@ try {
622
642
  let md: string | null = null;
623
643
  let videoTitle: string | undefined;
624
644
  let videoDuration = 0;
645
+ let channelName: string | undefined;
646
+ let channelId: string | undefined;
647
+ let channelDescription: string | undefined;
625
648
 
626
649
  startTimer();
627
650
 
@@ -660,9 +683,14 @@ try {
660
683
  };
661
684
  segments = result.segments;
662
685
  videoDuration = result.videoDetails.lengthSeconds;
686
+ channelName = result.videoDetails.author;
687
+ channelId = result.videoDetails.channelId;
688
+ channelDescription = await fetchChannelDescription(channelId);
663
689
  const infoJson = JSON.stringify({
664
690
  title: result.videoDetails.title,
665
- channel: result.videoDetails.author,
691
+ channel: channelName,
692
+ channelId: channelId,
693
+ channelDescription: channelDescription,
666
694
  description: result.videoDetails.description,
667
695
  duration: videoDuration,
668
696
  });
@@ -712,9 +740,14 @@ try {
712
740
  segments: TranscriptSegment[];
713
741
  };
714
742
  videoDuration = fallbackResult.videoDetails.lengthSeconds;
743
+ channelName = fallbackResult.videoDetails.author;
744
+ channelId = fallbackResult.videoDetails.channelId;
745
+ channelDescription = await fetchChannelDescription(channelId);
715
746
  cachedInfo = JSON.stringify({
716
747
  title: fallbackResult.videoDetails.title,
717
748
  channel: fallbackResult.videoDetails.author,
749
+ channelId: channelId,
750
+ channelDescription: channelDescription,
718
751
  description: fallbackResult.videoDetails.description,
719
752
  duration: videoDuration,
720
753
  });
@@ -732,6 +765,9 @@ try {
732
765
  const cachedInfoObj = JSON.parse(cachedInfo);
733
766
  videoTitle = cachedInfoObj.title;
734
767
  videoDuration = cachedInfoObj.duration ?? 0;
768
+ channelName = cachedInfoObj.channel;
769
+ channelId = cachedInfoObj.channelId;
770
+ channelDescription = cachedInfoObj.channelDescription;
735
771
  const truncatedInfo = JSON.stringify({
736
772
  title: cachedInfoObj.title,
737
773
  channel: cachedInfoObj.channel,
@@ -760,6 +796,9 @@ try {
760
796
  videoId,
761
797
  duration: videoDuration,
762
798
  wordCount,
799
+ channelName,
800
+ channelId,
801
+ channelDescription,
763
802
  });
764
803
  const htmlPath = join(dir, "transcript.html");
765
804
  await writeFile(htmlPath, htmlContent, "utf8");