coursera-transcripts 0.1.0__py3-none-any.whl
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.
- coursera_transcripts/__init__.py +0 -0
- coursera_transcripts/api.py +85 -0
- coursera_transcripts/cli.py +200 -0
- coursera_transcripts/downloader.py +238 -0
- coursera_transcripts-0.1.0.dist-info/METADATA +150 -0
- coursera_transcripts-0.1.0.dist-info/RECORD +10 -0
- coursera_transcripts-0.1.0.dist-info/WHEEL +5 -0
- coursera_transcripts-0.1.0.dist-info/entry_points.txt +2 -0
- coursera_transcripts-0.1.0.dist-info/licenses/LICENSE +21 -0
- coursera_transcripts-0.1.0.dist-info/top_level.txt +1 -0
|
File without changes
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import requests
|
|
3
|
+
from urllib.parse import urljoin
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
COURSERA_BASE = "https://www.coursera.org"
|
|
9
|
+
COURSERA_VERSION = "e184c443bbe09b70cbcebf2ba22b3b1067d7e119"
|
|
10
|
+
|
|
11
|
+
HEADERS = {
|
|
12
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0",
|
|
13
|
+
"Accept": "*/*",
|
|
14
|
+
"Accept-Language": "en",
|
|
15
|
+
"X-Coursera-Application": "ondemand",
|
|
16
|
+
"X-Coursera-Version": COURSERA_VERSION,
|
|
17
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _build_headers(cookie: str, referer: str | None = None) -> dict:
|
|
22
|
+
headers = HEADERS.copy()
|
|
23
|
+
headers["Cookie"] = cookie
|
|
24
|
+
if referer:
|
|
25
|
+
headers["Referer"] = referer
|
|
26
|
+
return headers
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CourseAPI:
|
|
30
|
+
def __init__(self, cookie: str, console: Console | None = None):
|
|
31
|
+
self.cookie = cookie
|
|
32
|
+
self.session = requests.Session()
|
|
33
|
+
self.console = console or Console()
|
|
34
|
+
|
|
35
|
+
def _get(self, url: str, referer: str | None = None, max_retries: int = 3) -> requests.Response:
|
|
36
|
+
headers = _build_headers(self.cookie, referer)
|
|
37
|
+
last_exception: Exception | None = None
|
|
38
|
+
for attempt in range(max_retries):
|
|
39
|
+
try:
|
|
40
|
+
response = self.session.get(url, headers=headers, timeout=30)
|
|
41
|
+
response.raise_for_status()
|
|
42
|
+
return response
|
|
43
|
+
except requests.exceptions.RequestException as e:
|
|
44
|
+
last_exception = e
|
|
45
|
+
if attempt == max_retries - 1:
|
|
46
|
+
break
|
|
47
|
+
wait = 2 ** attempt
|
|
48
|
+
self.console.print(
|
|
49
|
+
f" [warning]⟳ Request failed, retrying in {wait}s…[/warning] [muted]({e})[/muted]"
|
|
50
|
+
)
|
|
51
|
+
time.sleep(wait)
|
|
52
|
+
raise last_exception # type: ignore[misc]
|
|
53
|
+
|
|
54
|
+
def get_course_materials(self, slug: str) -> dict:
|
|
55
|
+
url = (
|
|
56
|
+
f"{COURSERA_BASE}/api/onDemandCourseMaterials.v2/"
|
|
57
|
+
f"?q=slug&slug={slug}"
|
|
58
|
+
f"&includes=modules%2Clessons%2CpassableItemGroups%2CpassableItemGroupChoices%2CpassableLessonElements%2Citems%2Ctracks%2CgradePolicy%2CgradingParameters%2CembeddedContentMapping"
|
|
59
|
+
f"&fields=moduleIds%2ConDemandCourseMaterialModules.v1(name%2Cslug%2Cdescription%2CtimeCommitment%2ClessonIds%2Coptional%2ClearningObjectives)%2ConDemandCourseMaterialLessons.v1(name%2Cslug%2CtimeCommitment%2CelementIds%2Coptional%2CtrackId)%2ConDemandCourseMaterialPassableItemGroups.v1(requiredPassedCount%2CpassableItemGroupChoiceIds%2CtrackId)%2ConDemandCourseMaterialPassableItemGroupChoices.v1(name%2Cdescription%2CitemIds)%2ConDemandCourseMaterialPassableLessonElements.v1(gradingWeight%2CisRequiredForPassing)%2ConDemandCourseMaterialItems.v2(name%2CoriginalName%2Cslug%2CtimeCommitment%2CcontentSummary%2CisLocked%2ClockableByItem%2CitemLockedReasonCode%2CtrackId%2ClockedStatus%2CitemLockSummary%2CcustomDisplayTypenameOverride)%2ConDemandCourseMaterialTracks.v1(passablesCount)%2ConDemandGradingParameters.v1(gradedAssignmentGroups)%2CcontentAtomRelations.v1(embeddedContentSourceCourseId%2CsubContainerId)"
|
|
60
|
+
f"&showLockedItems=true"
|
|
61
|
+
)
|
|
62
|
+
referer = f"{COURSERA_BASE}/learn/{slug}/home/module/1"
|
|
63
|
+
response = self._get(url, referer)
|
|
64
|
+
data = response.json()
|
|
65
|
+
|
|
66
|
+
if not data.get("elements"):
|
|
67
|
+
raise ValueError(f"Course '{slug}' not found or no data returned")
|
|
68
|
+
|
|
69
|
+
return data
|
|
70
|
+
|
|
71
|
+
def get_lecture_video(self, course_id: str, item_id: str) -> dict:
|
|
72
|
+
url = (
|
|
73
|
+
f"{COURSERA_BASE}/api/onDemandLectureVideos.v1/"
|
|
74
|
+
f"{course_id}~{item_id}"
|
|
75
|
+
f"?includes=video"
|
|
76
|
+
f"&fields=onDemandVideos.v1(sources%2Csubtitles%2CsubtitlesTxt%2CsubtitlesAssetTags%2CdubbedSources%2CdubbedSubtitlesVtt%2CaudioDescriptionVideoSources)"
|
|
77
|
+
f"%2CdisableSkippingForward%2CstartMs%2CendMs"
|
|
78
|
+
)
|
|
79
|
+
response = self._get(url)
|
|
80
|
+
return response.json()
|
|
81
|
+
|
|
82
|
+
def download_subtitle(self, relative_url: str) -> str:
|
|
83
|
+
url = urljoin(COURSERA_BASE, relative_url)
|
|
84
|
+
response = self._get(url)
|
|
85
|
+
return response.text
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.prompt import Prompt
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
from rich.theme import Theme
|
|
10
|
+
|
|
11
|
+
from .api import CourseAPI
|
|
12
|
+
from .downloader import TranscriptDownloader
|
|
13
|
+
|
|
14
|
+
# ── Custom theme ──────────────────────────────────────────────────────
|
|
15
|
+
custom_theme = Theme({
|
|
16
|
+
"brand": "bold bright_cyan",
|
|
17
|
+
"accent": "bright_magenta",
|
|
18
|
+
"success": "bold bright_green",
|
|
19
|
+
"warning": "bold yellow",
|
|
20
|
+
"error": "bold red",
|
|
21
|
+
"muted": "dim white",
|
|
22
|
+
"info": "bright_blue",
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
console = Console(theme=custom_theme)
|
|
26
|
+
|
|
27
|
+
BANNER = r"""[bright_cyan]
|
|
28
|
+
██████╗ ██████╗ ██╗ ██╗██████╗ ███████╗███████╗██████╗ █████╗
|
|
29
|
+
██╔════╝██╔═══██╗██║ ██║██╔══██╗██╔════╝██╔════╝██╔══██╗██╔══██╗
|
|
30
|
+
██║ ██║ ██║██║ ██║██████╔╝███████╗█████╗ ██████╔╝███████║
|
|
31
|
+
██║ ██║ ██║██║ ██║██╔══██╗╚════██║██╔══╝ ██╔══██╗██╔══██║
|
|
32
|
+
╚██████╗╚██████╔╝╚██████╔╝██║ ██║███████║███████╗██║ ██║██║ ██║
|
|
33
|
+
╚═════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝
|
|
34
|
+
[/bright_cyan]"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _show_banner() -> None:
|
|
38
|
+
console.print(BANNER)
|
|
39
|
+
subtitle = Text("Transcript Generator", style="bold bright_magenta")
|
|
40
|
+
subtitle.append(" • ", style="dim")
|
|
41
|
+
subtitle.append("v0.1.0", style="dim bright_cyan")
|
|
42
|
+
console.print(subtitle, justify="center")
|
|
43
|
+
console.print()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _normalize_cookie(raw: str) -> str:
|
|
47
|
+
"""Accept a raw CAUTH token, CAUTH=value, or a full Cookie header string."""
|
|
48
|
+
raw = raw.strip()
|
|
49
|
+
if "CAUTH=" in raw:
|
|
50
|
+
# Already contains the CAUTH key — treat as a full cookie string
|
|
51
|
+
return raw
|
|
52
|
+
# Bare token value — wrap it
|
|
53
|
+
return f"CAUTH={raw}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _prompt_cookie() -> str:
|
|
57
|
+
console.print(
|
|
58
|
+
Panel(
|
|
59
|
+
"[muted]Paste your [bold bright_cyan]Cookie[/bold bright_cyan] header or just the [bold bright_cyan]CAUTH[/bold bright_cyan] value.\n"
|
|
60
|
+
"You can copy the full Cookie header from DevTools → Network → any request → Headers.[/muted]",
|
|
61
|
+
title="[brand]🔑 Authentication[/brand]",
|
|
62
|
+
border_style="bright_cyan",
|
|
63
|
+
padding=(1, 2),
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
cookie = Prompt.ask("[bright_cyan] ›[/bright_cyan] [bold]Cookie / CAUTH[/bold]")
|
|
67
|
+
if not cookie.strip():
|
|
68
|
+
console.print("[error] ✖ Cookie cannot be empty.[/error]")
|
|
69
|
+
raise SystemExit(1)
|
|
70
|
+
return _normalize_cookie(cookie)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _prompt_slug() -> str:
|
|
74
|
+
console.print()
|
|
75
|
+
console.print(
|
|
76
|
+
Panel(
|
|
77
|
+
"[muted]Enter the course slug from the URL.\n"
|
|
78
|
+
"Example: [bold bright_cyan]coursera.org/learn/[underline]unreal-engine-fundamentals[/underline][/bold bright_cyan][/muted]",
|
|
79
|
+
title="[brand]📚 Course[/brand]",
|
|
80
|
+
border_style="bright_cyan",
|
|
81
|
+
padding=(1, 2),
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
slug = Prompt.ask("[bright_cyan] ›[/bright_cyan] [bold]Course slug[/bold]")
|
|
85
|
+
if not slug.strip():
|
|
86
|
+
console.print("[error] ✖ Slug cannot be empty.[/error]")
|
|
87
|
+
raise SystemExit(1)
|
|
88
|
+
return slug.strip()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _prompt_options() -> tuple[str, str, Path]:
|
|
92
|
+
console.print()
|
|
93
|
+
console.print(
|
|
94
|
+
Panel(
|
|
95
|
+
"[muted]Configure optional settings (press Enter for defaults).[/muted]",
|
|
96
|
+
title="[brand]⚙ Options[/brand]",
|
|
97
|
+
border_style="bright_cyan",
|
|
98
|
+
padding=(1, 2),
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
language = Prompt.ask(
|
|
102
|
+
"[bright_cyan] ›[/bright_cyan] [bold]Language[/bold]",
|
|
103
|
+
default="en",
|
|
104
|
+
)
|
|
105
|
+
fmt = Prompt.ask(
|
|
106
|
+
"[bright_cyan] ›[/bright_cyan] [bold]Format[/bold] [muted](srt/txt)[/muted]",
|
|
107
|
+
choices=["srt", "txt"],
|
|
108
|
+
default="txt",
|
|
109
|
+
)
|
|
110
|
+
output = Prompt.ask(
|
|
111
|
+
"[bright_cyan] ›[/bright_cyan] [bold]Output directory[/bold]",
|
|
112
|
+
default="./output",
|
|
113
|
+
)
|
|
114
|
+
return language, fmt, Path(output).resolve()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def parse_args():
|
|
118
|
+
parser = argparse.ArgumentParser(
|
|
119
|
+
description="Download transcripts/subtitles from a Coursera course",
|
|
120
|
+
)
|
|
121
|
+
parser.add_argument(
|
|
122
|
+
"--cookie", "-c",
|
|
123
|
+
help="Coursera authentication cookie (CAUTH value). If omitted, you will be prompted.",
|
|
124
|
+
)
|
|
125
|
+
parser.add_argument(
|
|
126
|
+
"--slug", "-s",
|
|
127
|
+
help="Course slug (e.g. 'unreal-engine-fundamentals'). If omitted, you will be prompted.",
|
|
128
|
+
)
|
|
129
|
+
parser.add_argument(
|
|
130
|
+
"--output", "-o",
|
|
131
|
+
default=None,
|
|
132
|
+
help="Parent output directory (default: ./output). Transcripts saved to {output}/{slug}/",
|
|
133
|
+
)
|
|
134
|
+
parser.add_argument(
|
|
135
|
+
"--language", "-l",
|
|
136
|
+
default=None,
|
|
137
|
+
help="Subtitle language code (default: en)",
|
|
138
|
+
)
|
|
139
|
+
parser.add_argument(
|
|
140
|
+
"--format",
|
|
141
|
+
choices=["srt", "txt"],
|
|
142
|
+
default=None,
|
|
143
|
+
help="Subtitle format (default: txt)",
|
|
144
|
+
)
|
|
145
|
+
return parser.parse_args()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def main():
|
|
149
|
+
args = parse_args()
|
|
150
|
+
|
|
151
|
+
# ── Interactive mode when cookie/slug not provided ────────────────
|
|
152
|
+
interactive = args.cookie is None or args.slug is None
|
|
153
|
+
|
|
154
|
+
if interactive:
|
|
155
|
+
_show_banner()
|
|
156
|
+
|
|
157
|
+
# Cookie
|
|
158
|
+
if args.cookie:
|
|
159
|
+
cookie = _normalize_cookie(args.cookie)
|
|
160
|
+
else:
|
|
161
|
+
cookie = _prompt_cookie()
|
|
162
|
+
|
|
163
|
+
# Slug
|
|
164
|
+
slug = args.slug if args.slug else _prompt_slug()
|
|
165
|
+
|
|
166
|
+
# Options
|
|
167
|
+
if interactive and (args.language is None and args.format is None and args.output is None):
|
|
168
|
+
language, fmt, output_dir = _prompt_options()
|
|
169
|
+
else:
|
|
170
|
+
language = args.language or "en"
|
|
171
|
+
fmt = args.format or "txt"
|
|
172
|
+
output_dir = Path(args.output or "./output").resolve()
|
|
173
|
+
|
|
174
|
+
console.print()
|
|
175
|
+
|
|
176
|
+
# ── Run ───────────────────────────────────────────────────────────
|
|
177
|
+
api = CourseAPI(cookie, console)
|
|
178
|
+
downloader = TranscriptDownloader(
|
|
179
|
+
api=api,
|
|
180
|
+
output_dir=output_dir,
|
|
181
|
+
language=language,
|
|
182
|
+
fmt=fmt,
|
|
183
|
+
console=console,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
downloader.fetch_all_transcripts(slug)
|
|
188
|
+
except ValueError as e:
|
|
189
|
+
console.print(f"\n[error] ✖ {e}[/error]")
|
|
190
|
+
raise SystemExit(1)
|
|
191
|
+
except KeyboardInterrupt:
|
|
192
|
+
console.print("\n[warning] ⚠ Interrupted by user.[/warning]")
|
|
193
|
+
raise SystemExit(130)
|
|
194
|
+
except Exception as e:
|
|
195
|
+
console.print(f"\n[error] ✖ Unexpected error: {e}[/error]")
|
|
196
|
+
raise SystemExit(1)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
if __name__ == "__main__":
|
|
200
|
+
main()
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import time
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.progress import (
|
|
8
|
+
Progress,
|
|
9
|
+
SpinnerColumn,
|
|
10
|
+
TextColumn,
|
|
11
|
+
BarColumn,
|
|
12
|
+
MofNCompleteColumn,
|
|
13
|
+
TimeElapsedColumn,
|
|
14
|
+
)
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
from rich.tree import Tree
|
|
18
|
+
|
|
19
|
+
from .api import CourseAPI
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _sanitize_filename(name: str) -> str:
|
|
23
|
+
name = re.sub(r'[<>:"/\\|?*]', '', name)
|
|
24
|
+
name = name.strip()
|
|
25
|
+
name = name[:200]
|
|
26
|
+
return name
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _extract_course_id(materials_data: dict) -> str:
|
|
30
|
+
return materials_data["elements"][0]["id"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _build_module_lookup(materials_data: dict) -> dict:
|
|
34
|
+
lookup = {}
|
|
35
|
+
for module in materials_data.get("linked", {}).get("onDemandCourseMaterialModules.v1", []):
|
|
36
|
+
lookup[module["id"]] = {
|
|
37
|
+
"name": module["name"],
|
|
38
|
+
"slug": module.get("slug", module["id"]),
|
|
39
|
+
}
|
|
40
|
+
return lookup
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _build_lesson_lookup(materials_data: dict) -> dict:
|
|
44
|
+
lookup = {}
|
|
45
|
+
for lesson in materials_data.get("linked", {}).get("onDemandCourseMaterialLessons.v1", []):
|
|
46
|
+
lookup[lesson["id"]] = {
|
|
47
|
+
"name": lesson["name"],
|
|
48
|
+
"slug": lesson.get("slug", lesson["id"]),
|
|
49
|
+
}
|
|
50
|
+
return lookup
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _get_lecture_items(materials_data: dict) -> list:
|
|
54
|
+
items = materials_data.get("linked", {}).get("onDemandCourseMaterialItems.v2", [])
|
|
55
|
+
lectures = []
|
|
56
|
+
for item in items:
|
|
57
|
+
content_type = item.get("contentSummary", {}).get("typeName", "")
|
|
58
|
+
if content_type != "lecture":
|
|
59
|
+
continue
|
|
60
|
+
if item.get("isLocked", False):
|
|
61
|
+
continue
|
|
62
|
+
lectures.append(item)
|
|
63
|
+
return lectures
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TranscriptDownloader:
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
api: CourseAPI,
|
|
70
|
+
output_dir: Path,
|
|
71
|
+
language: str = "en",
|
|
72
|
+
fmt: str = "txt",
|
|
73
|
+
console: Console | None = None,
|
|
74
|
+
):
|
|
75
|
+
self.api = api
|
|
76
|
+
self.output_dir = output_dir
|
|
77
|
+
self.language = language
|
|
78
|
+
self.fmt = fmt
|
|
79
|
+
self.console = console or Console()
|
|
80
|
+
|
|
81
|
+
def _get_subtitle_url(self, video_data: dict) -> str | None:
|
|
82
|
+
videos = video_data.get("linked", {}).get("onDemandVideos.v1", [])
|
|
83
|
+
if not videos:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
video = videos[0]
|
|
87
|
+
|
|
88
|
+
if self.fmt == "txt":
|
|
89
|
+
subtitles = video.get("subtitlesTxt", {})
|
|
90
|
+
else:
|
|
91
|
+
subtitles = video.get("subtitles", {})
|
|
92
|
+
|
|
93
|
+
return subtitles.get(self.language)
|
|
94
|
+
|
|
95
|
+
def fetch_all_transcripts(self, course_slug: str) -> dict:
|
|
96
|
+
c = self.console
|
|
97
|
+
|
|
98
|
+
# ── Fetch course data ─────────────────────────────────────────
|
|
99
|
+
with c.status("[bright_cyan] Fetching course materials…[/bright_cyan]", spinner="dots"):
|
|
100
|
+
materials = self.api.get_course_materials(course_slug)
|
|
101
|
+
|
|
102
|
+
course_id = _extract_course_id(materials)
|
|
103
|
+
module_lookup = _build_module_lookup(materials)
|
|
104
|
+
lesson_lookup = _build_lesson_lookup(materials)
|
|
105
|
+
lecture_items = _get_lecture_items(materials)
|
|
106
|
+
|
|
107
|
+
course_dir = self.output_dir / course_slug
|
|
108
|
+
course_dir.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
|
|
110
|
+
# ── Course overview panel ─────────────────────────────────────
|
|
111
|
+
info_table = Table.grid(padding=(0, 2))
|
|
112
|
+
info_table.add_column(style="muted", justify="right")
|
|
113
|
+
info_table.add_column(style="bold white")
|
|
114
|
+
info_table.add_row("Course", course_slug)
|
|
115
|
+
info_table.add_row("Lectures", str(len(lecture_items)))
|
|
116
|
+
info_table.add_row("Language", self.language.upper())
|
|
117
|
+
info_table.add_row("Format", self.fmt.upper())
|
|
118
|
+
info_table.add_row("Output", str(course_dir))
|
|
119
|
+
|
|
120
|
+
c.print(
|
|
121
|
+
Panel(
|
|
122
|
+
info_table,
|
|
123
|
+
title="[brand]📋 Course Overview[/brand]",
|
|
124
|
+
border_style="bright_cyan",
|
|
125
|
+
padding=(1, 2),
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
c.print()
|
|
129
|
+
|
|
130
|
+
if not lecture_items:
|
|
131
|
+
c.print("[warning] ⚠ No lecture videos found in this course.[/warning]")
|
|
132
|
+
return {"success": 0, "skipped": 0, "failed": 0, "total": 0}
|
|
133
|
+
|
|
134
|
+
# ── Download with progress bar ────────────────────────────────
|
|
135
|
+
stats = {"success": 0, "skipped": 0, "failed": 0, "total": len(lecture_items)}
|
|
136
|
+
results: list[tuple[str, str, str]] = [] # (status_icon, name, detail)
|
|
137
|
+
|
|
138
|
+
with Progress(
|
|
139
|
+
SpinnerColumn(style="bright_cyan"),
|
|
140
|
+
TextColumn("[bold]{task.description}[/bold]"),
|
|
141
|
+
BarColumn(
|
|
142
|
+
bar_width=30,
|
|
143
|
+
style="dim white",
|
|
144
|
+
complete_style="bright_cyan",
|
|
145
|
+
finished_style="bright_green",
|
|
146
|
+
),
|
|
147
|
+
MofNCompleteColumn(),
|
|
148
|
+
TextColumn("•"),
|
|
149
|
+
TimeElapsedColumn(),
|
|
150
|
+
console=c,
|
|
151
|
+
transient=False,
|
|
152
|
+
) as progress:
|
|
153
|
+
task = progress.add_task(" Downloading transcripts", total=stats["total"])
|
|
154
|
+
|
|
155
|
+
for idx, item in enumerate(lecture_items, 1):
|
|
156
|
+
item_id = item["id"]
|
|
157
|
+
item_name = _sanitize_filename(item["name"])
|
|
158
|
+
module_id = item.get("moduleId", "")
|
|
159
|
+
|
|
160
|
+
module_info = module_lookup.get(module_id, {})
|
|
161
|
+
module_slug = module_info.get("slug", f"module-{module_id}")
|
|
162
|
+
|
|
163
|
+
module_dir = course_dir / module_slug
|
|
164
|
+
module_dir.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
|
|
166
|
+
# Fetch video data
|
|
167
|
+
try:
|
|
168
|
+
video_data = self.api.get_lecture_video(course_id, item_id)
|
|
169
|
+
except Exception as e:
|
|
170
|
+
results.append(("❌", item_name, f"[error]Failed to fetch video data: {e}[/error]"))
|
|
171
|
+
stats["failed"] += 1
|
|
172
|
+
progress.advance(task)
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
subtitle_url = self._get_subtitle_url(video_data)
|
|
176
|
+
|
|
177
|
+
if not subtitle_url:
|
|
178
|
+
results.append(("⊘", item_name, f"[warning]No {self.language} {self.fmt} subtitles[/warning]"))
|
|
179
|
+
stats["skipped"] += 1
|
|
180
|
+
progress.advance(task)
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
# Download subtitle
|
|
184
|
+
try:
|
|
185
|
+
subtitle_text = self.api.download_subtitle(subtitle_url)
|
|
186
|
+
except Exception as e:
|
|
187
|
+
results.append(("❌", item_name, f"[error]Download failed: {e}[/error]"))
|
|
188
|
+
stats["failed"] += 1
|
|
189
|
+
progress.advance(task)
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
filename = f"{item_name}.{self.fmt}"
|
|
193
|
+
filepath = module_dir / filename
|
|
194
|
+
filepath.write_text(subtitle_text, encoding="utf-8")
|
|
195
|
+
|
|
196
|
+
rel_path = filepath.relative_to(self.output_dir)
|
|
197
|
+
results.append(("✔", item_name, f"[muted]{rel_path}[/muted]"))
|
|
198
|
+
stats["success"] += 1
|
|
199
|
+
progress.advance(task)
|
|
200
|
+
|
|
201
|
+
# ── Results tree ──────────────────────────────────────────────
|
|
202
|
+
c.print()
|
|
203
|
+
|
|
204
|
+
tree = Tree("[bold bright_cyan]📂 Results[/bold bright_cyan]")
|
|
205
|
+
for icon, name, detail in results:
|
|
206
|
+
if icon == "✔":
|
|
207
|
+
style_icon = f"[bright_green]{icon}[/bright_green]"
|
|
208
|
+
elif icon == "⊘":
|
|
209
|
+
style_icon = f"[yellow]{icon}[/yellow]"
|
|
210
|
+
else:
|
|
211
|
+
style_icon = f"[red]{icon}[/red]"
|
|
212
|
+
tree.add(f"{style_icon} [bold]{name}[/bold] {detail}")
|
|
213
|
+
c.print(tree)
|
|
214
|
+
|
|
215
|
+
# ── Summary panel ─────────────────────────────────────────────
|
|
216
|
+
c.print()
|
|
217
|
+
|
|
218
|
+
summary_parts = []
|
|
219
|
+
if stats["success"]:
|
|
220
|
+
summary_parts.append(f"[bright_green]✔ {stats['success']} downloaded[/bright_green]")
|
|
221
|
+
if stats["skipped"]:
|
|
222
|
+
summary_parts.append(f"[yellow]⊘ {stats['skipped']} skipped[/yellow]")
|
|
223
|
+
if stats["failed"]:
|
|
224
|
+
summary_parts.append(f"[red]✖ {stats['failed']} failed[/red]")
|
|
225
|
+
|
|
226
|
+
summary_text = " ".join(summary_parts)
|
|
227
|
+
summary_text += f"\n\n[muted]Files saved to [bold]{course_dir}[/bold][/muted]"
|
|
228
|
+
|
|
229
|
+
c.print(
|
|
230
|
+
Panel(
|
|
231
|
+
summary_text,
|
|
232
|
+
title="[brand]✨ Summary[/brand]",
|
|
233
|
+
border_style="bright_green" if not stats["failed"] else "yellow",
|
|
234
|
+
padding=(1, 2),
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
return stats
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: coursera-transcripts
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI tool to extract Coursera course transcripts/subtitles
|
|
5
|
+
Author-email: Kavin <your-email@example.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: requests>=2.31.0
|
|
18
|
+
Requires-Dist: rich>=13.0.0
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# 📚 Coursera Transcript Generator
|
|
22
|
+
|
|
23
|
+
A beautiful CLI tool to bulk-download transcripts and subtitles from any Coursera course you're enrolled in.
|
|
24
|
+
|
|
25
|
+

|
|
26
|
+
|
|
27
|
+

|
|
28
|
+

|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## ✨ Features
|
|
33
|
+
|
|
34
|
+
- **Interactive prompts** — guided step-by-step experience, no need to memorize flags
|
|
35
|
+
- **Bulk download** — grabs every lecture transcript in a course at once
|
|
36
|
+
- **Organized output** — files are neatly sorted into module folders
|
|
37
|
+
- **Progress tracking** — real-time progress bar with download status
|
|
38
|
+
- **Retry logic** — automatic retries with exponential backoff on failures
|
|
39
|
+
- **Multiple formats** — supports both `.txt` (plain text) and `.srt` (subtitle) formats
|
|
40
|
+
- **Multi-language** — download transcripts in any available language
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 📦 Installation
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# Clone the repo
|
|
48
|
+
git clone https://github.com/your-username/coursera-transcript-generator.git
|
|
49
|
+
cd coursera-transcript-generator
|
|
50
|
+
|
|
51
|
+
# Install in editable mode
|
|
52
|
+
pip install -e .
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## 🚀 Usage
|
|
58
|
+
|
|
59
|
+
### Interactive Mode (recommended)
|
|
60
|
+
|
|
61
|
+
Just run the command with no arguments — it will guide you through everything:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
coursera-transcripts
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
You'll be prompted for:
|
|
68
|
+
|
|
69
|
+
1. **CAUTH cookie** — your Coursera authentication token
|
|
70
|
+
2. **Course slug** — the identifier from the course URL
|
|
71
|
+
3. **Options** — language, format, and output directory
|
|
72
|
+
|
|
73
|
+
### CLI Mode
|
|
74
|
+
|
|
75
|
+
Pass everything as flags for scripting / automation:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
coursera-transcripts \
|
|
79
|
+
--cookie "YOUR_CAUTH_VALUE" \
|
|
80
|
+
--slug "machine-learning" \
|
|
81
|
+
--language en \
|
|
82
|
+
--format txt \
|
|
83
|
+
--output ./transcripts
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### All Options
|
|
87
|
+
|
|
88
|
+
| Flag | Short | Default | Description |
|
|
89
|
+
| ------------ | ----- | ------------ | ------------------------------ |
|
|
90
|
+
| `--cookie` | `-c` | _(prompted)_ | CAUTH cookie value |
|
|
91
|
+
| `--slug` | `-s` | _(prompted)_ | Course slug from URL |
|
|
92
|
+
| `--language` | `-l` | `en` | Subtitle language code |
|
|
93
|
+
| `--format` | | `txt` | Output format (`txt` or `srt`) |
|
|
94
|
+
| `--output` | `-o` | `./output` | Parent output directory |
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## 🔑 Getting Your CAUTH Cookie
|
|
99
|
+
|
|
100
|
+
1. Open [coursera.org](https://www.coursera.org) and **log in**
|
|
101
|
+
2. Open **DevTools** (`F12` or `Ctrl+Shift+I`)
|
|
102
|
+
3. Go to **Application** → **Cookies** → `https://www.coursera.org`
|
|
103
|
+
4. Find the cookie named **`CAUTH`**
|
|
104
|
+
5. Copy its **Value**
|
|
105
|
+
|
|
106
|
+
> [!IMPORTANT]
|
|
107
|
+
> You must be **enrolled** in the course to download its transcripts.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## 📁 Output Structure
|
|
112
|
+
|
|
113
|
+
Transcripts are organized by module:
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
output/
|
|
117
|
+
└── machine-learning/
|
|
118
|
+
├── introduction-to-ml/
|
|
119
|
+
│ ├── Welcome to Machine Learning.txt
|
|
120
|
+
│ ├── What is Machine Learning.txt
|
|
121
|
+
│ └── Supervised Learning.txt
|
|
122
|
+
├── linear-regression/
|
|
123
|
+
│ ├── Model Representation.txt
|
|
124
|
+
│ └── Cost Function.txt
|
|
125
|
+
└── ...
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## 🔧 Finding the Course Slug
|
|
131
|
+
|
|
132
|
+
The slug is the part of the URL after `/learn/`:
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
https://www.coursera.org/learn/machine-learning
|
|
136
|
+
└── this is the slug
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## 📋 Requirements
|
|
142
|
+
|
|
143
|
+
- Python **3.10+**
|
|
144
|
+
- A Coursera account with enrollment in the target course
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## 📄 License
|
|
149
|
+
|
|
150
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
coursera_transcripts/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
coursera_transcripts/api.py,sha256=w7EVhrrzpfRSvBxBXmKbqm6zv3hK84_eCfhcN4DMVf4,4082
|
|
3
|
+
coursera_transcripts/cli.py,sha256=p8lcJSlxdFwYs3U4i_CLwx_J3IEk4nHzi1bbrQkkC44,7251
|
|
4
|
+
coursera_transcripts/downloader.py,sha256=W-bl65pN4-kL5gmhuM3MuOt-XjXUlxaE3Um1N1GKEtc,8749
|
|
5
|
+
coursera_transcripts-0.1.0.dist-info/licenses/LICENSE,sha256=RxFCWxR6ldkv6-NQ2e2_QUKPNAWMI655NJId368n4h8,1062
|
|
6
|
+
coursera_transcripts-0.1.0.dist-info/METADATA,sha256=RWUH7cD2QbxYsCPwRNi0IIfvT51RICfu5bZXvP7Hea8,4215
|
|
7
|
+
coursera_transcripts-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
coursera_transcripts-0.1.0.dist-info/entry_points.txt,sha256=wUeXKAdo0b22bPj1qGTiOmLhYnfb-V_q1EmzkbiAdmw,71
|
|
9
|
+
coursera_transcripts-0.1.0.dist-info/top_level.txt,sha256=G5NQQPwCdnej7uphIszABuHvRNWW1INkETZqtu96eTE,21
|
|
10
|
+
coursera_transcripts-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Kavin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
coursera_transcripts
|