Download Udacity nanodegree transcripts
Rendered 08 Sep 2026
Values used
nd_id- nd900
out_folder- nd900-agentic-ai
Rendered prompt
The task
Using the Claude in Chrome plugin, extract the full content of the Udacity nanodegree at
https://www.udacity.com/enrollment/nd900intond900-agentic-ai/transcripts/.Follow the recipe in
prompts/01-download-transcripts.md: pull the content from the classroom GraphQL API rather than scraping concept pages, keep full video transcripts, verify every concept came back, and write one.txtper course.
Why this approach
The classroom is a Next.js SPA. Clicking through concepts one at a time would mean ~300+ page navigations at ~5s each. Instead, the app's own content API returns whole lessons in one request:
POST https://learn.udacity.com/api/classroom-content/v1/graphql
It requires an authorization header (a JWT). Rather than reading that token out — don't, and the
tool output filter blocks it anyway — patch window.fetch to stash it in a page variable and make
all subsequent calls from inside the page.
Step-by-step
1. Set up the browser
Skill: claude-in-chrome
Load every tool in ONE ToolSearch call:
select:mcp__claude-in-chrome__tabs_context_mcp,mcp__claude-in-chrome__navigate,
mcp__claude-in-chrome__computer,mcp__claude-in-chrome__read_page,
mcp__claude-in-chrome__tabs_create_mcp,mcp__claude-in-chrome__tabs_close_mcp,
mcp__claude-in-chrome__get_page_text,mcp__claude-in-chrome__javascript_tool,
mcp__claude-in-chrome__find,mcp__claude-in-chrome__list_connected_browsers,
mcp__claude-in-chrome__select_browser,mcp__claude-in-chrome__browser_batch
Then list_connected_browsers → ask the user which browser via AskUserQuestion (required, even
when only one is connected) → select_browser → tabs_context_mcp {createIfEmpty: true}.
2. Confirm login and get the course list
Navigate to https://www.udacity.com/enrollment/nd900. If it renders the curriculum, the session
is already authenticated.
The accordions are collapsed and their content is hidden from innerText, so expand them first:
const btns = [...document.querySelectorAll('button[aria-expanded]')];
// the course toggles are the run of buttons whose ancestor card contains a learn.udacity.com link
btns.forEach(b => { if (b.getAttribute('aria-expanded') === 'false') b.click(); });
Collect the part keys:
[...new Set([...document.querySelectorAll('a[href*="learn.udacity.com"]')]
.map(a => a.getAttribute('href').split('/parts/')[1]?.split('/')[0]))].join(',')
3. Capture the auth header
Navigate to any lesson: https://learn.udacity.com/nanodegrees/nd900/parts/<PART>/lessons/<LESSON>
Note the version in the resulting URL (e.g. 6.0.30) — you need it for the tree query.
Install the interceptor and a GraphQL helper:
window.__AUTH = null;
const of = window.fetch;
window.fetch = function (...a) {
try {
const u = (typeof a[0] === 'string') ? a[0] : a[0].url;
if (/classroom-content/.test(u) && a[1] && a[1].headers) {
const h = a[1].headers;
window.__AUTH = h.authorization || h.Authorization || null;
}
} catch (e) {}
return of.apply(this, a);
};
window.__sleep = ms => new Promise(r => setTimeout(r, ms));
'ok'
The app only calls that endpoint on SPA navigation, so trigger one — find the "Next" button and
click it. Then verify with window.__AUTH ? 'captured' : 'none'. Never print the token itself.
4. Fetch the program tree
window.__GQLR = async (q, v) => {
for (let a = 0; a < 10; a++) {
try {
const r = await fetch('/api/classroom-content/v1/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json', authorization: window.__AUTH },
body: JSON.stringify({ query: q, variables: v || {} })
});
if (r.ok) { const j = await r.json(); if (j && j.data) return j; }
await window.__sleep(2000 + a * 1000);
} catch (e) { await window.__sleep(2000 + a * 1000); }
}
return null;
};
const q = `{ nanodegree(key:"nd900", version:"<VERSION>"){
id key title summary
parts { id key title summary part_type
modules { id key title
lessons { id key title summary
concepts { id key title } } } } } }`;
window.__TREE = (await window.__GQLR(q)).data.nanodegree;
window.__TREE.parts.map(p => p.key + ' | ' + p.title).join('\n')
5. Fetch every concept's atoms
window.__CQ = `query C($id:Int){ node(id:$id){ id key title
... on Concept { atoms { __typename
... on TextAtom { title text }
... on VideoAtom { title video { transcript duration } }
... on RadioQuizAtom { title }
... on CheckboxQuizAtom { title } } } } }`;
window.__clean = t => String(t || '')
.replace(/<v [^>]*>/g, '').replace(/<\/v>/g, '').replace(/\s+/g, ' ').trim();
window.__PROG = { part: null, done: 0, total: 0, fails: 0 };
window.__fetchPart = async (pi) => {
const p = window.__TREE.parts[pi];
let out = ['===== COURSE: ' + p.title + ' (' + p.key + ') =====',
'SUMMARY: ' + window.__clean(p.summary)];
window.__PROG = { part: pi, done: 0,
total: p.modules.reduce((a,m)=>a+m.lessons.reduce((b,l)=>b+l.concepts.length,0),0), fails: 0 };
for (const m of p.modules) for (const [li, l] of m.lessons.entries()) {
out.push('', '----- LESSON ' + (li+1) + ': ' + l.title + ' -----',
'LESSON SUMMARY: ' + window.__clean(l.summary));
for (const [ci, c] of l.concepts.entries()) {
const j = await window.__GQLR(window.__CQ, { id: c.id });
const n = j && j.data && j.data.node;
out.push('', '## Concept ' + (ci+1) + ': ' + (n ? n.title : c.title));
if (!n) { window.__PROG.fails++; out.push('[FETCH_FAILED]'); }
for (const a of (n && n.atoms || [])) {
if (a.__typename === 'TextAtom')
out.push('[TEXT' + (a.title ? ' - ' + a.title : '') + '] ' + window.__clean(a.text));
else if (a.__typename === 'VideoAtom')
out.push('[VIDEO' + (a.title ? ' - ' + a.title : '') + '] ' +
window.__clean(a.video && a.video.transcript));
else out.push('[' + a.__typename + (a.title ? ' - ' + a.title : '') + ']');
}
window.__PROG.done++;
await window.__sleep(500); // throttle — see gotchas
}
}
return out.join('\n');
};
Run it fire-and-forget, because javascript_tool times out at 45s:
window.__JOB = { done: false };
(async () => {
for (const pi of [0,1,2,3,4,5]) { window['__P'+pi] = await window.__fetchPart(pi); }
window.__JOB.done = true;
})();
'started'
Poll with JSON.stringify(window.__PROG) + ' done=' + window.__JOB.done. Budget roughly
0.6s per concept (~3–4 min for 330 concepts).
6. Verify completeness — do not skip this
const chk = s => {
const b = s.split(/\n## Concept /).slice(1);
return b.length + ' concepts, empty=' +
b.filter(x => !x.split('\n').slice(1).join('').trim()).length +
', failed=' + (s.match(/FETCH_FAILED/g) || []).length;
};
[0,1,2,3,4,5].map(i => 'P' + i + ': ' + chk(window['__P'+i])).join('\n')
Every part must report empty=0, failed=0 and a concept count matching the tree. Re-fetch any part
that doesn't.
7. Get the text to disk
The trick: get_page_text with a large max_chars persists oversized output to a file and puts
only a 2 KB preview in context. So render the text into the DOM and read it back.
window.__render = s => {
document.body.innerHTML = '<article><pre id="dump"></pre></article>';
document.getElementById('dump').textContent = s;
return s.length;
};
window.__render([window.__P0, window.__P1, window.__P2].join('\n\n'))
Then get_page_text {tabId, max_chars: 600000}. Batch a couple of courses per dump; keep each dump
comfortably over ~50 KB so it persists rather than landing inline.
Split the persisted JSON into per-course files:
import json, sys, re, os
src, out = sys.argv[1], sys.argv[2]
d = json.load(open(src))
t = "".join(b.get("text", "") for b in d if isinstance(b, dict))
t = t[t.find("===== COURSE:"):] # drop the tool preamble
i = t.find("\nTab Context:") # drop the trailing tool footer
if i >= 0: t = t[:i]
names = { "cd1859": "01-welcome", ... } # part key -> filename
for p in re.split(r'(?m)^(?====== COURSE: )', t):
if not p.strip(): continue
m = re.match(r'===== COURSE: (.*) \((\S+)\) =====', p)
fn = names.get(m.group(2), m.group(2))
open(os.path.join(out, fn + ".txt"), "w").write(p.rstrip() + "\n")
Finally sanity-check on disk:
for f in *.txt; do echo "$f: concepts=$(grep -c '^## Concept ' $f) \
lessons=$(grep -c '^----- LESSON ' $f) failed=$(grep -c FETCH_FAILED $f)"; done
8. Clean up
Close the tab with tabs_close_mcp.
Output format
===== COURSE: <title> (<part key>) =====
SUMMARY: <course blurb>
----- LESSON n: <title> -----
LESSON SUMMARY: <lesson blurb>
## Concept n: <title>
[TEXT - <atom title>] <written content, inline HTML preserved>
[VIDEO - <atom title>] <full transcript>
[ImageAtom] / [RadioQuizAtom] / [CheckboxQuizAtom] / [MatchingQuizAtom]
[ReflectAtom] / [TaskListAtom] / [ValidatedQuizAtom]
Gotchas
- Rate limiting is the main hazard. A parallel
Promise.allpass returnsToo Many Requestsas plain text (not JSON) and silently produces empty concepts. Go sequential with ~500ms spacing and retry with backoff. Always run the step-6 verification. javascript_tooltimes out at 45s but the JS keeps running in the page. Use fire-and-forget jobs plus a polled progress variable. If a runaway job needs stopping, redefine the global helper it calls (e.g. make__GQLRreturnnullwhile an__ABORTflag is set) — the running loop picks up the new definition on its next call.javascript_toolreturn values truncate at ~1000 chars. Never try to page content out through it; use the render +get_page_textroute.get_page_textdefaults tomax_chars: 50000and returns inline below the persistence threshold. Pass a largemax_charsexplicitly.read_pagetruncates node text, so it can't substitute forget_page_texthere.- Output containing the words cookie/query-string can trip the tool's data filter. Strip or reshape the string rather than fighting it.
- The persisted JSON carries a
Tab Context:footer and, in abrowser_batch, a preceding[javascript_tool:...]block — trim both. window.*state survives DOM replacement but not navigation. Do all fetching before rendering, and don't navigate mid-job.- Video transcripts arrive wrapped in
<v English>…</v>caption tags —__cleanstrips them.
## The task
> Using the Claude in Chrome plugin, extract the full content of the Udacity nanodegree at
> `https://www.udacity.com/enrollment/nd900` into `nd900-agentic-ai/transcripts/`.
>
> Follow the recipe in `prompts/01-download-transcripts.md`: pull the content from the classroom
> GraphQL API rather than scraping concept pages, keep **full video transcripts**, verify every
> concept came back, and write one `.txt` per course.
---
## Why this approach
The classroom is a Next.js SPA. Clicking through concepts one at a time would mean ~300+ page
navigations at ~5s each. Instead, the app's own content API returns whole lessons in one request:
```
POST https://learn.udacity.com/api/classroom-content/v1/graphql
```
It requires an `authorization` header (a JWT). Rather than reading that token out — don't, and the
tool output filter blocks it anyway — patch `window.fetch` to stash it in a page variable and make
all subsequent calls from inside the page.
---
## Step-by-step
### 1. Set up the browser
```
Skill: claude-in-chrome
```
Load every tool in ONE ToolSearch call:
```
select:mcp__claude-in-chrome__tabs_context_mcp,mcp__claude-in-chrome__navigate,
mcp__claude-in-chrome__computer,mcp__claude-in-chrome__read_page,
mcp__claude-in-chrome__tabs_create_mcp,mcp__claude-in-chrome__tabs_close_mcp,
mcp__claude-in-chrome__get_page_text,mcp__claude-in-chrome__javascript_tool,
mcp__claude-in-chrome__find,mcp__claude-in-chrome__list_connected_browsers,
mcp__claude-in-chrome__select_browser,mcp__claude-in-chrome__browser_batch
```
Then `list_connected_browsers` → **ask the user which browser** via AskUserQuestion (required, even
when only one is connected) → `select_browser` → `tabs_context_mcp {createIfEmpty: true}`.
### 2. Confirm login and get the course list
Navigate to `https://www.udacity.com/enrollment/nd900`. If it renders the curriculum, the session
is already authenticated.
The accordions are collapsed and their content is hidden from `innerText`, so expand them first:
```js
const btns = [...document.querySelectorAll('button[aria-expanded]')];
// the course toggles are the run of buttons whose ancestor card contains a learn.udacity.com link
btns.forEach(b => { if (b.getAttribute('aria-expanded') === 'false') b.click(); });
```
Collect the part keys:
```js
[...new Set([...document.querySelectorAll('a[href*="learn.udacity.com"]')]
.map(a => a.getAttribute('href').split('/parts/')[1]?.split('/')[0]))].join(',')
```
### 3. Capture the auth header
Navigate to any lesson: `https://learn.udacity.com/nanodegrees/nd900/parts/<PART>/lessons/<LESSON>`
Note the `version` in the resulting URL (e.g. `6.0.30`) — you need it for the tree query.
Install the interceptor and a GraphQL helper:
```js
window.__AUTH = null;
const of = window.fetch;
window.fetch = function (...a) {
try {
const u = (typeof a[0] === 'string') ? a[0] : a[0].url;
if (/classroom-content/.test(u) && a[1] && a[1].headers) {
const h = a[1].headers;
window.__AUTH = h.authorization || h.Authorization || null;
}
} catch (e) {}
return of.apply(this, a);
};
window.__sleep = ms => new Promise(r => setTimeout(r, ms));
'ok'
```
The app only calls that endpoint on **SPA navigation**, so trigger one — `find` the "Next" button and
click it. Then verify with `window.__AUTH ? 'captured' : 'none'`. **Never print the token itself.**
### 4. Fetch the program tree
```js
window.__GQLR = async (q, v) => {
for (let a = 0; a < 10; a++) {
try {
const r = await fetch('/api/classroom-content/v1/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json', authorization: window.__AUTH },
body: JSON.stringify({ query: q, variables: v || {} })
});
if (r.ok) { const j = await r.json(); if (j && j.data) return j; }
await window.__sleep(2000 + a * 1000);
} catch (e) { await window.__sleep(2000 + a * 1000); }
}
return null;
};
const q = `{ nanodegree(key:"nd900", version:"<VERSION>"){
id key title summary
parts { id key title summary part_type
modules { id key title
lessons { id key title summary
concepts { id key title } } } } } }`;
window.__TREE = (await window.__GQLR(q)).data.nanodegree;
window.__TREE.parts.map(p => p.key + ' | ' + p.title).join('\n')
```
### 5. Fetch every concept's atoms
```js
window.__CQ = `query C($id:Int){ node(id:$id){ id key title
... on Concept { atoms { __typename
... on TextAtom { title text }
... on VideoAtom { title video { transcript duration } }
... on RadioQuizAtom { title }
... on CheckboxQuizAtom { title } } } } }`;
window.__clean = t => String(t || '')
.replace(/<v [^>]*>/g, '').replace(/<\/v>/g, '').replace(/\s+/g, ' ').trim();
window.__PROG = { part: null, done: 0, total: 0, fails: 0 };
window.__fetchPart = async (pi) => {
const p = window.__TREE.parts[pi];
let out = ['===== COURSE: ' + p.title + ' (' + p.key + ') =====',
'SUMMARY: ' + window.__clean(p.summary)];
window.__PROG = { part: pi, done: 0,
total: p.modules.reduce((a,m)=>a+m.lessons.reduce((b,l)=>b+l.concepts.length,0),0), fails: 0 };
for (const m of p.modules) for (const [li, l] of m.lessons.entries()) {
out.push('', '----- LESSON ' + (li+1) + ': ' + l.title + ' -----',
'LESSON SUMMARY: ' + window.__clean(l.summary));
for (const [ci, c] of l.concepts.entries()) {
const j = await window.__GQLR(window.__CQ, { id: c.id });
const n = j && j.data && j.data.node;
out.push('', '## Concept ' + (ci+1) + ': ' + (n ? n.title : c.title));
if (!n) { window.__PROG.fails++; out.push('[FETCH_FAILED]'); }
for (const a of (n && n.atoms || [])) {
if (a.__typename === 'TextAtom')
out.push('[TEXT' + (a.title ? ' - ' + a.title : '') + '] ' + window.__clean(a.text));
else if (a.__typename === 'VideoAtom')
out.push('[VIDEO' + (a.title ? ' - ' + a.title : '') + '] ' +
window.__clean(a.video && a.video.transcript));
else out.push('[' + a.__typename + (a.title ? ' - ' + a.title : '') + ']');
}
window.__PROG.done++;
await window.__sleep(500); // throttle — see gotchas
}
}
return out.join('\n');
};
```
**Run it fire-and-forget**, because `javascript_tool` times out at 45s:
```js
window.__JOB = { done: false };
(async () => {
for (const pi of [0,1,2,3,4,5]) { window['__P'+pi] = await window.__fetchPart(pi); }
window.__JOB.done = true;
})();
'started'
```
Poll with `JSON.stringify(window.__PROG) + ' done=' + window.__JOB.done`. Budget roughly
**0.6s per concept** (~3–4 min for 330 concepts).
### 6. Verify completeness — do not skip this
```js
const chk = s => {
const b = s.split(/\n## Concept /).slice(1);
return b.length + ' concepts, empty=' +
b.filter(x => !x.split('\n').slice(1).join('').trim()).length +
', failed=' + (s.match(/FETCH_FAILED/g) || []).length;
};
[0,1,2,3,4,5].map(i => 'P' + i + ': ' + chk(window['__P'+i])).join('\n')
```
Every part must report `empty=0, failed=0` and a concept count matching the tree. Re-fetch any part
that doesn't.
### 7. Get the text to disk
The trick: **`get_page_text` with a large `max_chars` persists oversized output to a file** and puts
only a 2 KB preview in context. So render the text into the DOM and read it back.
```js
window.__render = s => {
document.body.innerHTML = '<article><pre id="dump"></pre></article>';
document.getElementById('dump').textContent = s;
return s.length;
};
window.__render([window.__P0, window.__P1, window.__P2].join('\n\n'))
```
Then `get_page_text {tabId, max_chars: 600000}`. Batch a couple of courses per dump; keep each dump
comfortably over ~50 KB so it persists rather than landing inline.
Split the persisted JSON into per-course files:
```python
import json, sys, re, os
src, out = sys.argv[1], sys.argv[2]
d = json.load(open(src))
t = "".join(b.get("text", "") for b in d if isinstance(b, dict))
t = t[t.find("===== COURSE:"):] # drop the tool preamble
i = t.find("\nTab Context:") # drop the trailing tool footer
if i >= 0: t = t[:i]
names = { "cd1859": "01-welcome", ... } # part key -> filename
for p in re.split(r'(?m)^(?====== COURSE: )', t):
if not p.strip(): continue
m = re.match(r'===== COURSE: (.*) \((\S+)\) =====', p)
fn = names.get(m.group(2), m.group(2))
open(os.path.join(out, fn + ".txt"), "w").write(p.rstrip() + "\n")
```
Finally sanity-check on disk:
```bash
for f in *.txt; do echo "$f: concepts=$(grep -c '^## Concept ' $f) \
lessons=$(grep -c '^----- LESSON ' $f) failed=$(grep -c FETCH_FAILED $f)"; done
```
### 8. Clean up
Close the tab with `tabs_close_mcp`.
---
## Output format
```
===== COURSE: <title> (<part key>) =====
SUMMARY: <course blurb>
----- LESSON n: <title> -----
LESSON SUMMARY: <lesson blurb>
## Concept n: <title>
[TEXT - <atom title>] <written content, inline HTML preserved>
[VIDEO - <atom title>] <full transcript>
[ImageAtom] / [RadioQuizAtom] / [CheckboxQuizAtom] / [MatchingQuizAtom]
[ReflectAtom] / [TaskListAtom] / [ValidatedQuizAtom]
```
---
## Gotchas
- **Rate limiting is the main hazard.** A parallel `Promise.all` pass returns `Too Many Requests` as
plain text (not JSON) and silently produces empty concepts. Go sequential with ~500ms spacing and
retry with backoff. Always run the step-6 verification.
- **`javascript_tool` times out at 45s** but the JS keeps running in the page. Use fire-and-forget
jobs plus a polled progress variable. If a runaway job needs stopping, redefine the global helper
it calls (e.g. make `__GQLR` return `null` while an `__ABORT` flag is set) — the running loop picks
up the new definition on its next call.
- **`javascript_tool` return values truncate at ~1000 chars.** Never try to page content out through
it; use the render + `get_page_text` route.
- **`get_page_text` defaults to `max_chars: 50000`** and returns inline below the persistence
threshold. Pass a large `max_chars` explicitly.
- **`read_page` truncates node text**, so it can't substitute for `get_page_text` here.
- Output containing the words cookie/query-string can trip the tool's data filter. Strip or reshape
the string rather than fighting it.
- The persisted JSON carries a `Tab Context:` footer and, in a `browser_batch`, a preceding
`[javascript_tool:...]` block — trim both.
- `window.*` state survives DOM replacement but **not** navigation. Do all fetching before rendering,
and don't navigate mid-job.
- Video transcripts arrive wrapped in `<v English>…</v>` caption tags — `__clean` strips them.