Download Udacity nanodegree transcripts

Rendered 08 Sep 2026

Values used

nd_id
nd7426
out_folder
nd7426-ai-engineering-claude

Rendered prompt

The task

Using the Claude in Chrome plugin, extract the full content of the Udacity nanodegree at https://www.udacity.com/enrollment/nd7426 into nd7426-ai-engineering-claude/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/nd7426. 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/nd7426/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:"nd7426", 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.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.