duck3-create Claude Opus 4.6 commited on
Commit
d2c03ed
ยท
1 Parent(s): c7058b5

Add new features: title display, dark mode, history, URL cleanup

Browse files

- Fetch video title via YouTube oembed API (parallel with transcript)
- Add character count and estimated reading time per result
- Add dark mode toggle with localStorage persistence
- Add keep_newlines option (default: continuous text)
- Support m.youtube.com URLs, strip tracking params (si, utm, etc.)
- Add recent extraction history (localStorage, last 10 videos)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Files changed (2) hide show
  1. main.py +35 -10
  2. static/index.html +328 -18
main.py CHANGED
@@ -1,4 +1,6 @@
1
  import logging
 
 
2
  from fastapi import FastAPI
3
  from fastapi.responses import FileResponse, JSONResponse
4
  from fastapi.middleware.cors import CORSMiddleware
@@ -30,14 +32,17 @@ class TranscriptRequest(BaseModel):
30
  language: str = "ko"
31
  denoise: bool = False
32
  format: str = "text"
 
33
 
34
 
35
  def extract_video_id(url: str) -> str | None:
36
  url = url.strip()
37
  if not url:
38
  return None
 
 
39
  patterns = [
40
- r"(?:youtube\.com/watch\?.*v=)([a-zA-Z0-9_-]{11})",
41
  r"(?:youtu\.be/)([a-zA-Z0-9_-]{11})",
42
  r"(?:youtube\.com/embed/)([a-zA-Z0-9_-]{11})",
43
  r"(?:youtube\.com/shorts/)([a-zA-Z0-9_-]{11})",
@@ -50,6 +55,17 @@ def extract_video_id(url: str) -> str | None:
50
  return None
51
 
52
 
 
 
 
 
 
 
 
 
 
 
 
53
  KOREAN_FILLERS = {
54
  "์–ด", "์Œ", "๊ทธ", "์•„", "๋„ค", "์˜ˆ", "์—", "์œผ", "ํ ",
55
  "์–ด์–ด", "์Œ์Œ", "์•„์•„", "๋„ค๋„ค", "์˜ˆ์˜ˆ",
@@ -80,7 +96,7 @@ def denoise_text(text: str) -> str:
80
  return "\n".join(result)
81
 
82
 
83
- def _fetch_transcript(video_id: str, language: str, denoise: bool, fmt: str) -> dict:
84
  languages = [language]
85
  if language == "ko":
86
  languages.append("en")
@@ -113,9 +129,12 @@ def _fetch_transcript(video_id: str, language: str, denoise: bool, fmt: str) ->
113
  entries = deduped
114
  return {"transcript": entries, "error": None}
115
  else:
116
- text = "\n".join(e.text for e in data)
 
117
  if denoise:
118
  text = denoise_text(text)
 
 
119
  return {"transcript": text, "error": None}
120
  except Exception as e:
121
  last_error = str(e)
@@ -162,22 +181,28 @@ async def get_transcripts(request: TranscriptRequest):
162
  return {
163
  "url": url,
164
  "video_id": None,
 
165
  "transcript": None,
166
  "error": "์œ ํšจํ•˜์ง€ ์•Š์€ YouTube URL์ž…๋‹ˆ๋‹ค.",
167
  }
168
 
169
- result = await loop.run_in_executor(
170
- _executor,
171
- _fetch_transcript,
172
- video_id,
173
- request.language,
174
- request.denoise,
175
- request.format,
 
 
 
 
176
  )
177
 
178
  return {
179
  "url": url,
180
  "video_id": video_id,
 
181
  "transcript": result["transcript"],
182
  "error": result["error"],
183
  }
 
1
  import logging
2
+ import json
3
+ import urllib.request
4
  from fastapi import FastAPI
5
  from fastapi.responses import FileResponse, JSONResponse
6
  from fastapi.middleware.cors import CORSMiddleware
 
32
  language: str = "ko"
33
  denoise: bool = False
34
  format: str = "text"
35
+ keep_newlines: bool = False
36
 
37
 
38
  def extract_video_id(url: str) -> str | None:
39
  url = url.strip()
40
  if not url:
41
  return None
42
+ # Remove tracking parameters
43
+ url = re.sub(r'[&?](si|feature|utm_\w+|fbclid|gclid)=[^&]*', '', url)
44
  patterns = [
45
+ r"(?:(?:m\.)?youtube\.com/watch\?.*v=)([a-zA-Z0-9_-]{11})",
46
  r"(?:youtu\.be/)([a-zA-Z0-9_-]{11})",
47
  r"(?:youtube\.com/embed/)([a-zA-Z0-9_-]{11})",
48
  r"(?:youtube\.com/shorts/)([a-zA-Z0-9_-]{11})",
 
55
  return None
56
 
57
 
58
+ def _fetch_title(video_id: str) -> str | None:
59
+ try:
60
+ oembed_url = f"https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v={video_id}&format=json"
61
+ req = urllib.request.Request(oembed_url)
62
+ with urllib.request.urlopen(req, timeout=5) as response:
63
+ data = json.loads(response.read().decode("utf-8"))
64
+ return data.get("title")
65
+ except Exception:
66
+ return None
67
+
68
+
69
  KOREAN_FILLERS = {
70
  "์–ด", "์Œ", "๊ทธ", "์•„", "๋„ค", "์˜ˆ", "์—", "์œผ", "ํ ",
71
  "์–ด์–ด", "์Œ์Œ", "์•„์•„", "๋„ค๋„ค", "์˜ˆ์˜ˆ",
 
96
  return "\n".join(result)
97
 
98
 
99
+ def _fetch_transcript(video_id: str, language: str, denoise: bool, fmt: str, keep_newlines: bool = False) -> dict:
100
  languages = [language]
101
  if language == "ko":
102
  languages.append("en")
 
129
  entries = deduped
130
  return {"transcript": entries, "error": None}
131
  else:
132
+ separator = "\n" if keep_newlines else " "
133
+ text = separator.join(e.text for e in data)
134
  if denoise:
135
  text = denoise_text(text)
136
+ if not keep_newlines:
137
+ text = " ".join(text.split())
138
  return {"transcript": text, "error": None}
139
  except Exception as e:
140
  last_error = str(e)
 
181
  return {
182
  "url": url,
183
  "video_id": None,
184
+ "title": None,
185
  "transcript": None,
186
  "error": "์œ ํšจํ•˜์ง€ ์•Š์€ YouTube URL์ž…๋‹ˆ๋‹ค.",
187
  }
188
 
189
+ result, title = await asyncio.gather(
190
+ loop.run_in_executor(
191
+ _executor,
192
+ _fetch_transcript,
193
+ video_id,
194
+ request.language,
195
+ request.denoise,
196
+ request.format,
197
+ request.keep_newlines,
198
+ ),
199
+ loop.run_in_executor(_executor, _fetch_title, video_id),
200
  )
201
 
202
  return {
203
  "url": url,
204
  "video_id": video_id,
205
+ "title": title,
206
  "transcript": result["transcript"],
207
  "error": result["error"],
208
  }
static/index.html CHANGED
@@ -37,8 +37,9 @@
37
  --transition: 200ms ease;
38
  }
39
 
 
40
  @media (prefers-color-scheme: dark) {
41
- :root {
42
  --bg-primary: #111113;
43
  --bg-secondary: #1b1b1f;
44
  --bg-surface: #1b1b1f;
@@ -62,6 +63,30 @@
62
  }
63
  }
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  *, *::before, *::after {
66
  box-sizing: border-box;
67
  margin: 0;
@@ -94,11 +119,17 @@
94
  padding: 72px 24px 120px;
95
  }
96
 
97
- /* โ”€โ”€ Header โ”€โ”€ */
98
  header {
99
  margin-bottom: 56px;
100
  }
101
 
 
 
 
 
 
 
102
  header h1 {
103
  font-size: 28px;
104
  font-weight: 700;
@@ -115,7 +146,44 @@
115
  line-height: 1.5;
116
  }
117
 
118
- /* โ”€โ”€ Section Label โ”€โ”€ */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  .section-label {
120
  font-size: 12px;
121
  font-weight: 600;
@@ -125,7 +193,7 @@
125
  margin-bottom: 12px;
126
  }
127
 
128
- /* โ”€โ”€ Input Section โ”€โ”€ */
129
  .input-section {
130
  display: flex;
131
  flex-direction: column;
@@ -184,7 +252,7 @@
184
  color: var(--error);
185
  }
186
 
187
- /* โ”€โ”€ Options Panel โ”€โ”€ */
188
  .options-panel {
189
  background: var(--bg-elevated);
190
  border: 1px solid var(--border);
@@ -221,7 +289,7 @@
221
  white-space: nowrap;
222
  }
223
 
224
- /* โ”€โ”€ Toggle Group โ”€โ”€ */
225
  .toggle-group {
226
  display: flex;
227
  background: var(--bg-surface);
@@ -256,7 +324,7 @@
256
  background: var(--accent-subtle);
257
  }
258
 
259
- /* โ”€โ”€ Custom Checkbox โ”€โ”€ */
260
  .checkbox-wrapper {
261
  display: flex;
262
  align-items: center;
@@ -310,7 +378,7 @@
310
  transform: rotate(45deg);
311
  }
312
 
313
- /* โ”€โ”€ Buttons โ”€โ”€ */
314
  .btn {
315
  font-family: var(--font-sans);
316
  font-size: 14px;
@@ -392,14 +460,14 @@
392
  box-shadow: 0 1px 0 var(--border);
393
  }
394
 
395
- /* โ”€โ”€ Divider โ”€โ”€ */
396
  .section-divider {
397
  height: 1px;
398
  background: var(--border-light);
399
  margin: 48px 0;
400
  }
401
 
402
- /* โ”€โ”€ Loading โ”€โ”€ */
403
  .loading-section {
404
  margin-top: 48px;
405
  display: flex;
@@ -440,7 +508,7 @@
440
  50% { opacity: 0.35; }
441
  }
442
 
443
- /* โ”€โ”€ Results โ”€โ”€ */
444
  .results-section {
445
  margin-top: 48px;
446
  }
@@ -481,7 +549,7 @@
481
  gap: 16px;
482
  }
483
 
484
- /* โ”€โ”€ Result Card โ”€โ”€ */
485
  .result-card {
486
  background: var(--bg-elevated);
487
  border: 1px solid var(--border);
@@ -559,6 +627,17 @@
559
  flex-shrink: 0;
560
  }
561
 
 
 
 
 
 
 
 
 
 
 
 
562
  .result-card-content {
563
  font-family: var(--font-mono);
564
  font-size: 13px;
@@ -574,6 +653,15 @@
574
  border-radius: 6px;
575
  }
576
 
 
 
 
 
 
 
 
 
 
577
  .result-card.is-error {
578
  border-color: var(--error-border);
579
  background: var(--error-light);
@@ -608,7 +696,61 @@
608
  background: var(--text-tertiary);
609
  }
610
 
611
- /* โ”€โ”€ Footer โ”€โ”€ */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
612
  .footer {
613
  margin-top: 64px;
614
  padding-top: 24px;
@@ -622,7 +764,7 @@
622
  line-height: 1.6;
623
  }
624
 
625
- /* โ”€โ”€ Responsive โ”€โ”€ */
626
  @media (max-width: 640px) {
627
  .container {
628
  padding: 40px 16px 80px;
@@ -668,8 +810,26 @@
668
  <body>
669
  <div class="container">
670
  <header>
671
- <h1>YouTube Transcript</h1>
672
- <p class="subtitle">YouTube ์˜์ƒ์˜ ์ž๋ง‰์„ ํ…์ŠคํŠธ๋กœ ์ถ”์ถœํ•ฉ๋‹ˆ๋‹ค</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
673
  </header>
674
 
675
  <section class="input-section">
@@ -713,6 +873,14 @@
713
  </label>
714
  </div>
715
 
 
 
 
 
 
 
 
 
716
  <div class="option-group">
717
  <label class="checkbox-wrapper">
718
  <input type="checkbox" id="metadata" checked>
@@ -748,6 +916,15 @@
748
  <div id="resultsList" class="results-list"></div>
749
  </section>
750
 
 
 
 
 
 
 
 
 
 
751
  <div class="footer" id="footer" style="display: none;">
752
  <p>youtube-transcript-api &middot; ์ž๋ง‰ ์›๋ฌธ๋งŒ ์ถ”์ถœํ•ฉ๋‹ˆ๋‹ค</p>
753
  </div>
@@ -771,10 +948,42 @@
771
  var copyAllBtn = $('#copyAllBtn');
772
  var downloadAllBtn = $('#downloadAllBtn');
773
  var denoiseCheckbox = $('#denoise');
 
774
  var metadataCheckbox = $('#metadata');
775
  var urlCount = $('#urlCount');
776
  var footer = $('#footer');
777
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
778
  // URL count
779
  function updateUrlCount() {
780
  var lines = urlInput.value.split('\n').filter(function (l) { return l.trim(); });
@@ -839,6 +1048,7 @@
839
  urls: urls,
840
  language: currentLanguage,
841
  denoise: denoiseCheckbox.checked,
 
842
  format: currentFormat,
843
  }),
844
  });
@@ -852,6 +1062,7 @@
852
 
853
  currentResults = data;
854
  renderResults(data);
 
855
  } catch (err) {
856
  alert('์š”์ฒญ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: ' + err.message);
857
  } finally {
@@ -901,6 +1112,21 @@
901
  idHtml = '<span class="result-card-id">' + escapeHtml(result.url) + '</span>';
902
  }
903
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
904
  card.innerHTML =
905
  '<div class="result-card-header">' +
906
  '<div class="result-card-meta">' +
@@ -909,7 +1135,9 @@
909
  '</div>' +
910
  actionsHtml +
911
  '</div>' +
912
- '<div class="result-card-content">' + escapeHtml(contentText) + '</div>';
 
 
913
 
914
  resultsList.appendChild(card);
915
  });
@@ -1025,7 +1253,7 @@
1025
 
1026
  function showCopied(btn) {
1027
  var original = btn.textContent;
1028
- btn.textContent = '๋ณต์‚ฌ๋จ โœ“';
1029
  btn.classList.add('copied');
1030
  setTimeout(function () {
1031
  btn.textContent = original;
@@ -1049,6 +1277,88 @@
1049
  div.textContent = text;
1050
  return div.innerHTML;
1051
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1052
  })();
1053
  </script>
1054
  </body>
 
37
  --transition: 200ms ease;
38
  }
39
 
40
+ /* Dark theme: system preference (when no manual override) */
41
  @media (prefers-color-scheme: dark) {
42
+ :root:not([data-theme="light"]) {
43
  --bg-primary: #111113;
44
  --bg-secondary: #1b1b1f;
45
  --bg-surface: #1b1b1f;
 
63
  }
64
  }
65
 
66
+ /* Dark theme: manual override */
67
+ [data-theme="dark"] {
68
+ --bg-primary: #111113;
69
+ --bg-secondary: #1b1b1f;
70
+ --bg-surface: #1b1b1f;
71
+ --bg-elevated: #232328;
72
+ --border: #2e2e35;
73
+ --border-light: #252529;
74
+ --text-primary: #ededef;
75
+ --text-secondary: #8f8f96;
76
+ --text-tertiary: #5c5c63;
77
+ --accent: #6366f1;
78
+ --accent-hover: #818cf8;
79
+ --accent-subtle: rgba(99, 102, 241, 0.06);
80
+ --accent-light: rgba(99, 102, 241, 0.12);
81
+ --error: #f87171;
82
+ --error-light: rgba(248, 113, 113, 0.08);
83
+ --error-border: rgba(248, 113, 113, 0.2);
84
+ --success: #4ade80;
85
+ --success-light: rgba(74, 222, 128, 0.08);
86
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
87
+ --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.3);
88
+ }
89
+
90
  *, *::before, *::after {
91
  box-sizing: border-box;
92
  margin: 0;
 
119
  padding: 72px 24px 120px;
120
  }
121
 
122
+ /* -- Header -- */
123
  header {
124
  margin-bottom: 56px;
125
  }
126
 
127
+ .header-row {
128
+ display: flex;
129
+ justify-content: space-between;
130
+ align-items: center;
131
+ }
132
+
133
  header h1 {
134
  font-size: 28px;
135
  font-weight: 700;
 
146
  line-height: 1.5;
147
  }
148
 
149
+ /* -- Theme Toggle -- */
150
+ .theme-toggle {
151
+ background: var(--bg-surface);
152
+ border: 1px solid var(--border);
153
+ border-radius: 8px;
154
+ width: 36px;
155
+ height: 36px;
156
+ display: flex;
157
+ align-items: center;
158
+ justify-content: center;
159
+ cursor: pointer;
160
+ color: var(--text-secondary);
161
+ transition: all var(--transition);
162
+ flex-shrink: 0;
163
+ }
164
+
165
+ .theme-toggle:hover {
166
+ color: var(--text-primary);
167
+ border-color: var(--text-tertiary);
168
+ background: var(--bg-elevated);
169
+ }
170
+
171
+ /* Show sun icon in dark mode, moon icon in light mode */
172
+ .theme-icon.sun { display: none; }
173
+ .theme-icon.moon { display: block; }
174
+
175
+ @media (prefers-color-scheme: dark) {
176
+ :root:not([data-theme="light"]) .theme-icon.sun { display: block; }
177
+ :root:not([data-theme="light"]) .theme-icon.moon { display: none; }
178
+ }
179
+
180
+ [data-theme="dark"] .theme-icon.sun { display: block; }
181
+ [data-theme="dark"] .theme-icon.moon { display: none; }
182
+
183
+ [data-theme="light"] .theme-icon.sun { display: none; }
184
+ [data-theme="light"] .theme-icon.moon { display: block; }
185
+
186
+ /* -- Section Label -- */
187
  .section-label {
188
  font-size: 12px;
189
  font-weight: 600;
 
193
  margin-bottom: 12px;
194
  }
195
 
196
+ /* -- Input Section -- */
197
  .input-section {
198
  display: flex;
199
  flex-direction: column;
 
252
  color: var(--error);
253
  }
254
 
255
+ /* -- Options Panel -- */
256
  .options-panel {
257
  background: var(--bg-elevated);
258
  border: 1px solid var(--border);
 
289
  white-space: nowrap;
290
  }
291
 
292
+ /* -- Toggle Group -- */
293
  .toggle-group {
294
  display: flex;
295
  background: var(--bg-surface);
 
324
  background: var(--accent-subtle);
325
  }
326
 
327
+ /* -- Custom Checkbox -- */
328
  .checkbox-wrapper {
329
  display: flex;
330
  align-items: center;
 
378
  transform: rotate(45deg);
379
  }
380
 
381
+ /* -- Buttons -- */
382
  .btn {
383
  font-family: var(--font-sans);
384
  font-size: 14px;
 
460
  box-shadow: 0 1px 0 var(--border);
461
  }
462
 
463
+ /* -- Divider -- */
464
  .section-divider {
465
  height: 1px;
466
  background: var(--border-light);
467
  margin: 48px 0;
468
  }
469
 
470
+ /* -- Loading -- */
471
  .loading-section {
472
  margin-top: 48px;
473
  display: flex;
 
508
  50% { opacity: 0.35; }
509
  }
510
 
511
+ /* -- Results -- */
512
  .results-section {
513
  margin-top: 48px;
514
  }
 
549
  gap: 16px;
550
  }
551
 
552
+ /* -- Result Card -- */
553
  .result-card {
554
  background: var(--bg-elevated);
555
  border: 1px solid var(--border);
 
627
  flex-shrink: 0;
628
  }
629
 
630
+ .result-card-title {
631
+ font-size: 14px;
632
+ font-weight: 500;
633
+ color: var(--text-primary);
634
+ margin-bottom: 12px;
635
+ line-height: 1.5;
636
+ overflow: hidden;
637
+ text-overflow: ellipsis;
638
+ white-space: nowrap;
639
+ }
640
+
641
  .result-card-content {
642
  font-family: var(--font-mono);
643
  font-size: 13px;
 
653
  border-radius: 6px;
654
  }
655
 
656
+ .result-card-stats {
657
+ display: flex;
658
+ gap: 8px;
659
+ margin-top: 10px;
660
+ font-family: var(--font-mono);
661
+ font-size: 12px;
662
+ color: var(--text-tertiary);
663
+ }
664
+
665
  .result-card.is-error {
666
  border-color: var(--error-border);
667
  background: var(--error-light);
 
696
  background: var(--text-tertiary);
697
  }
698
 
699
+ /* -- History -- */
700
+ .history-section {
701
+ margin-top: 48px;
702
+ }
703
+
704
+ .history-header {
705
+ display: flex;
706
+ justify-content: space-between;
707
+ align-items: center;
708
+ margin-bottom: 12px;
709
+ }
710
+
711
+ .history-list {
712
+ display: flex;
713
+ flex-direction: column;
714
+ gap: 6px;
715
+ }
716
+
717
+ .history-item {
718
+ display: flex;
719
+ justify-content: space-between;
720
+ align-items: center;
721
+ padding: 10px 14px;
722
+ background: var(--bg-elevated);
723
+ border: 1px solid var(--border-light);
724
+ border-radius: 6px;
725
+ font-size: 13px;
726
+ cursor: pointer;
727
+ transition: all var(--transition);
728
+ }
729
+
730
+ .history-item:hover {
731
+ border-color: var(--border);
732
+ background: var(--bg-surface);
733
+ }
734
+
735
+ .history-item-title {
736
+ color: var(--text-primary);
737
+ font-weight: 500;
738
+ overflow: hidden;
739
+ text-overflow: ellipsis;
740
+ white-space: nowrap;
741
+ min-width: 0;
742
+ flex: 1;
743
+ }
744
+
745
+ .history-item-time {
746
+ font-family: var(--font-mono);
747
+ font-size: 11px;
748
+ color: var(--text-tertiary);
749
+ flex-shrink: 0;
750
+ margin-left: 12px;
751
+ }
752
+
753
+ /* -- Footer -- */
754
  .footer {
755
  margin-top: 64px;
756
  padding-top: 24px;
 
764
  line-height: 1.6;
765
  }
766
 
767
+ /* -- Responsive -- */
768
  @media (max-width: 640px) {
769
  .container {
770
  padding: 40px 16px 80px;
 
810
  <body>
811
  <div class="container">
812
  <header>
813
+ <div class="header-row">
814
+ <h1>YouTube Transcript</h1>
815
+ <button id="themeToggle" class="theme-toggle" title="ํ…Œ๋งˆ ์ „ํ™˜" aria-label="Toggle theme">
816
+ <svg class="theme-icon sun" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
817
+ <circle cx="12" cy="12" r="5"></circle>
818
+ <line x1="12" y1="1" x2="12" y2="3"></line>
819
+ <line x1="12" y1="21" x2="12" y2="23"></line>
820
+ <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
821
+ <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
822
+ <line x1="1" y1="12" x2="3" y2="12"></line>
823
+ <line x1="21" y1="12" x2="23" y2="12"></line>
824
+ <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
825
+ <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
826
+ </svg>
827
+ <svg class="theme-icon moon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
828
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
829
+ </svg>
830
+ </button>
831
+ </div>
832
+ <p class="subtitle">Extract subtitles from YouTube videos</p>
833
  </header>
834
 
835
  <section class="input-section">
 
873
  </label>
874
  </div>
875
 
876
+ <div class="option-group">
877
+ <label class="checkbox-wrapper">
878
+ <input type="checkbox" id="keepNewlines">
879
+ <span class="checkbox-custom"></span>
880
+ <span>์ค„๋ฐ”๊ฟˆ ์œ ์ง€</span>
881
+ </label>
882
+ </div>
883
+
884
  <div class="option-group">
885
  <label class="checkbox-wrapper">
886
  <input type="checkbox" id="metadata" checked>
 
916
  <div id="resultsList" class="results-list"></div>
917
  </section>
918
 
919
+ <section id="history" class="history-section" style="display: none;">
920
+ <div class="section-divider"></div>
921
+ <div class="history-header">
922
+ <p class="section-label">์ตœ๊ทผ ์ถ”์ถœ ๊ธฐ๋ก</p>
923
+ <button class="btn btn-secondary btn-sm" id="clearHistoryBtn">๊ธฐ๋ก ์‚ญ์ œ</button>
924
+ </div>
925
+ <div id="historyList" class="history-list"></div>
926
+ </section>
927
+
928
  <div class="footer" id="footer" style="display: none;">
929
  <p>youtube-transcript-api &middot; ์ž๋ง‰ ์›๋ฌธ๋งŒ ์ถ”์ถœํ•ฉ๋‹ˆ๋‹ค</p>
930
  </div>
 
948
  var copyAllBtn = $('#copyAllBtn');
949
  var downloadAllBtn = $('#downloadAllBtn');
950
  var denoiseCheckbox = $('#denoise');
951
+ var keepNewlinesCheckbox = $('#keepNewlines');
952
  var metadataCheckbox = $('#metadata');
953
  var urlCount = $('#urlCount');
954
  var footer = $('#footer');
955
 
956
+ // Theme toggle
957
+ var themeToggle = $('#themeToggle');
958
+ var THEME_KEY = 'yt-transcript-theme';
959
+
960
+ function getSystemTheme() {
961
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
962
+ }
963
+
964
+ function getCurrentTheme() {
965
+ var saved = localStorage.getItem(THEME_KEY);
966
+ if (saved === 'dark' || saved === 'light') return saved;
967
+ return getSystemTheme();
968
+ }
969
+
970
+ function applyTheme(theme) {
971
+ document.documentElement.setAttribute('data-theme', theme);
972
+ localStorage.setItem(THEME_KEY, theme);
973
+ }
974
+
975
+ // Initialize theme from localStorage
976
+ var savedTheme = localStorage.getItem(THEME_KEY);
977
+ if (savedTheme) {
978
+ applyTheme(savedTheme);
979
+ }
980
+
981
+ themeToggle.addEventListener('click', function () {
982
+ var current = getCurrentTheme();
983
+ var next = current === 'dark' ? 'light' : 'dark';
984
+ applyTheme(next);
985
+ });
986
+
987
  // URL count
988
  function updateUrlCount() {
989
  var lines = urlInput.value.split('\n').filter(function (l) { return l.trim(); });
 
1048
  urls: urls,
1049
  language: currentLanguage,
1050
  denoise: denoiseCheckbox.checked,
1051
+ keep_newlines: keepNewlinesCheckbox.checked,
1052
  format: currentFormat,
1053
  }),
1054
  });
 
1062
 
1063
  currentResults = data;
1064
  renderResults(data);
1065
+ addToHistory(data.results);
1066
  } catch (err) {
1067
  alert('์š”์ฒญ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: ' + err.message);
1068
  } finally {
 
1112
  idHtml = '<span class="result-card-id">' + escapeHtml(result.url) + '</span>';
1113
  }
1114
 
1115
+ var titleHtml = '';
1116
+ if (result.title && !result.error) {
1117
+ titleHtml = '<div class="result-card-title">' + escapeHtml(result.title) + '</div>';
1118
+ }
1119
+
1120
+ var cardStatsHtml = '';
1121
+ if (!result.error && result.transcript) {
1122
+ var text = typeof result.transcript === 'string' ? result.transcript : JSON.stringify(result.transcript);
1123
+ var charCount = text.length;
1124
+ var readingMinutes = Math.max(1, Math.ceil(charCount / 500));
1125
+ cardStatsHtml = '<div class="result-card-stats">' +
1126
+ charCount.toLocaleString() + '\uC790 \u00B7 \uC57D ' + readingMinutes + '\uBD84 \uC77D\uAE30' +
1127
+ '</div>';
1128
+ }
1129
+
1130
  card.innerHTML =
1131
  '<div class="result-card-header">' +
1132
  '<div class="result-card-meta">' +
 
1135
  '</div>' +
1136
  actionsHtml +
1137
  '</div>' +
1138
+ titleHtml +
1139
+ '<div class="result-card-content">' + escapeHtml(contentText) + '</div>' +
1140
+ cardStatsHtml;
1141
 
1142
  resultsList.appendChild(card);
1143
  });
 
1253
 
1254
  function showCopied(btn) {
1255
  var original = btn.textContent;
1256
+ btn.textContent = '\uBCF5\uC0AC\uB428 \u2713';
1257
  btn.classList.add('copied');
1258
  setTimeout(function () {
1259
  btn.textContent = original;
 
1277
  div.textContent = text;
1278
  return div.innerHTML;
1279
  }
1280
+
1281
+ // History
1282
+ var HISTORY_KEY = 'yt-transcript-history';
1283
+ var MAX_HISTORY = 10;
1284
+ var historySection = $('#history');
1285
+ var historyList = $('#historyList');
1286
+ var clearHistoryBtn = $('#clearHistoryBtn');
1287
+
1288
+ function getHistory() {
1289
+ try {
1290
+ return JSON.parse(localStorage.getItem(HISTORY_KEY)) || [];
1291
+ } catch (e) {
1292
+ return [];
1293
+ }
1294
+ }
1295
+
1296
+ function saveHistory(items) {
1297
+ localStorage.setItem(HISTORY_KEY, JSON.stringify(items));
1298
+ }
1299
+
1300
+ function addToHistory(results) {
1301
+ var history = getHistory();
1302
+ results.forEach(function (r) {
1303
+ if (r.error || !r.video_id) return;
1304
+ // Remove duplicate
1305
+ history = history.filter(function (h) { return h.video_id !== r.video_id; });
1306
+ history.unshift({
1307
+ video_id: r.video_id,
1308
+ title: r.title || r.video_id,
1309
+ time: new Date().toISOString(),
1310
+ });
1311
+ });
1312
+ history = history.slice(0, MAX_HISTORY);
1313
+ saveHistory(history);
1314
+ renderHistory();
1315
+ }
1316
+
1317
+ function renderHistory() {
1318
+ var history = getHistory();
1319
+ if (history.length === 0) {
1320
+ historySection.style.display = 'none';
1321
+ return;
1322
+ }
1323
+ historySection.style.display = 'block';
1324
+ historyList.innerHTML = '';
1325
+ history.forEach(function (item) {
1326
+ var div = document.createElement('div');
1327
+ div.className = 'history-item';
1328
+ var timeStr = formatTime(item.time);
1329
+ div.innerHTML =
1330
+ '<span class="history-item-title">' + escapeHtml(item.title) + '</span>' +
1331
+ '<span class="history-item-time">' + timeStr + '</span>';
1332
+ div.addEventListener('click', function () {
1333
+ urlInput.value = 'https://www.youtube.com/watch?v=' + item.video_id;
1334
+ updateUrlCount();
1335
+ window.scrollTo({ top: 0, behavior: 'smooth' });
1336
+ });
1337
+ historyList.appendChild(div);
1338
+ });
1339
+ }
1340
+
1341
+ function formatTime(isoStr) {
1342
+ var d = new Date(isoStr);
1343
+ var now = new Date();
1344
+ var diff = now - d;
1345
+ var minutes = Math.floor(diff / 60000);
1346
+ if (minutes < 1) return '\uBC29\uAE08';
1347
+ if (minutes < 60) return minutes + '\uBD84 \uC804';
1348
+ var hours = Math.floor(minutes / 60);
1349
+ if (hours < 24) return hours + '\uC2DC\uAC04 \uC804';
1350
+ var days = Math.floor(hours / 24);
1351
+ if (days < 7) return days + '\uC77C \uC804';
1352
+ return d.toLocaleDateString('ko-KR');
1353
+ }
1354
+
1355
+ clearHistoryBtn.addEventListener('click', function () {
1356
+ localStorage.removeItem(HISTORY_KEY);
1357
+ renderHistory();
1358
+ });
1359
+
1360
+ // Show history on load
1361
+ renderHistory();
1362
  })();
1363
  </script>
1364
  </body>