O'Reilly Book Downloader Skill v2

核心原理(与 v1 的区别)

v1 错误地在搜索页面直接用 fetch 下载 —— 那样下载的文件在远程环境,不会到你电脑。

v2 正确流程:

  1. 先导航到书籍页面(浏览器建立 Cookie 上下文)

  2. 在书籍页面上 fetch 内容和图片

  3. JSZip 打包

  4. a.click() 触发下载 → 文件直接到你电脑

使用方法

方法一:自己在浏览器运行

  1. 打开任意 O'Reilly 书籍页面(需已登录) 例如: https://learning.oreilly.com/library/view/-/9780138214036/

  2. 按 F12 打开开发者工具 → Console 标签

  3. 将 oreilly_downloader_v2.js 全部内容粘贴进去

  4. 按回车,等待下载完成

方法二:交给 Claude 执行

打开书籍页面后告诉 Claude: "帮我用 v2 的方法下载这本书"

Claude 会导航到书籍页面,然后在页面上执行脚本, ZIP 文件会通过浏览器下载到你的电脑。

输出文件结构

 {ISBN}.zip
 ├── {ISBN}.md      ← 完整 Markdown(所有章节)
 └── images/        ← 所有图片
     ├── cover.jpg
     └── ...

注意事项

  • 每批55张图片,超时会自动续传

  • 仅支持有 EPUB 格式的书籍(约占总数 14%)

  • 需在书籍页面运行,不能在搜索页面运行

Skill文件

oreilly_downloader_skill_v2.zip


适用场景

O'Reilly Learning Platform(learning.oreilly.com)上的任意书籍页面,需要登录账号。

核心原理

O'Reilly 的 epub 阅读器会通过以下两个 API 接口加载内容:

  • 章节内容/api/v2/epubs/urn:orm:book:{ISBN}/files/{chapter}.xhtml

  • 图片资源/api/v2/epubs/urn:orm:book:{ISBN}/files/graphics/{image}.jpg

这两个接口复用浏览器的登录 Cookie,可以直接 fetch 获取,无需额外认证。

操作步骤

Step 1:获取 ISBN 和所有章节文件名 从当前页面 URL 中提取 ISBN(如 9780137690442),然后通过 performance API 找到已加载的 epub 资源 URL,或者抓取目录页(bk01-toc.xhtml)中的所有 <a href> 链接,提取所有 .xhtml 文件名。

Step 2:抓取所有章节内容并转为 Markdownfetch 逐章获取 xhtml,用 DOMParser 解析,递归遍历 DOM 节点转成 Markdown,同时收集所有 <img src> 的完整 URL 到队列。

Step 3:分批下载所有图片为 base64 每批约 60 张,避免 CDP 45 秒超时,存入 window.__imageBase64Map

Step 4:用 JSZip 打包下载 从 CDN 加载 JSZip,将 Markdown 文件和所有图片打包成 ZIP 下载。图片存 images/ 子目录,Markdown 中用 images/filename.jpg 相对路径引用。

可复用的完整脚本

javascript

// ===== O'Reilly Book Downloader =====
// 使用方法:在书籍任意页面的浏览器控制台运行,或通过 Claude 执行

(async () => {
  // 1. 从 URL 提取 ISBN
  const isbn = location.href.match(/library\/view\/[^/]+\/(\d+)\//)?.[1];
  if (!isbn) return console.error('无法获取 ISBN,请确认在书籍页面');
  const BASE = `https://learning.oreilly.com/api/v2/epubs/urn:orm:book:${isbn}/files/`;

  // 2. 加载 JSZip
  await new Promise((res, rej) => {
    const s = document.createElement('script');
    s.src = 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js';
    s.onload = res; s.onerror = rej;
    document.head.appendChild(s);
  });

  // 3. HTML → Markdown 转换器(含图片队列收集)
  const imgQueue = [];
  function toMd(html, fileUrl) {
    const doc = new DOMParser().parseFromString(html, 'text/html');
    function walk(node) {
      if (node.nodeType === 3) return node.textContent;
      if (node.nodeType !== 1) return '';
      const t = node.tagName.toLowerCase();
      const c = () => [...node.childNodes].map(walk).join('');
      const map = {
        h1: () => `\n# ${c().trim()}\n\n`, h2: () => `\n## ${c().trim()}\n\n`,
        h3: () => `\n## ${c().trim()}\n\n`, h4: () => `\n### ${c().trim()}\n\n`,
        p: () => { const s = c().trim(); return s ? `\n${s}\n` : ''; },
        strong: () => `**${c()}**`, b: () => `**${c()}**`,
        em: () => `*${c()}*`, i: () => `*${c()}*`,
        code: () => '`' + node.textContent + '`',
        pre: () => `\n\`\`\`\n${node.textContent}\n\`\`\`\n`,
        a: () => { const h = node.getAttribute('href') || ''; const s = c().trim(); return s ? `[${s}](${h})` : ''; },
        img: () => {
          let src = node.getAttribute('src') || '';
          if (src.startsWith('/')) src = 'https://learning.oreilly.com' + src;
          else if (!src.startsWith('http')) src = fileUrl.replace(/[^/]+$/, '') + src;
          if (!imgQueue.includes(src)) imgQueue.push(src);
          return `\n![${node.getAttribute('alt') || 'img'}](__IMG__${src}__IMG__)\n`;
        },
        ul: () => '\n' + [...node.children].map(li => `- ${walk(li).trim()}`).join('\n') + '\n',
        ol: () => '\n' + [...node.children].map((li, i) => `${i+1}. ${walk(li).trim()}`).join('\n') + '\n',
        table: () => {
          const rows = [...node.querySelectorAll('tr')];
          return '\n' + rows.map((row, i) => {
            const cells = [...row.querySelectorAll('th,td')].map(c => c.innerText.trim().replace(/\n/g,' '));
            return '| ' + cells.join(' | ') + ' |\n' + (i===0 ? '| ' + cells.map(()=>'---').join(' | ') + ' |\n' : '');
          }).join('') + '\n';
        },
        hr: () => '\n---\n', br: () => '\n',
        script: () => '', style: () => '', head: () => '',
      };
      return (map[t] || c)();
    }
    return walk(doc.querySelector('#sbo-rt-content') || doc.body).replace(/\n{3,}/g, '\n\n').trim();
  }

  // 4. 获取目录,提取所有章节文件
  const tocRes = await fetch(BASE + 'bk01-toc.xhtml', { credentials: 'include' });
  const tocHtml = await tocRes.text();
  const tocDoc = new DOMParser().parseFromString(tocHtml, 'text/html');
  const seen = new Set();
  const frontMatter = ['cover.xhtml','pref00.xhtml','title.xhtml','copy.xhtml','pref02.xhtml',
    'pref03.xhtml','pref04.xhtml','dedi.xhtml','pref05.xhtml','toc.xhtml','pref06.xhtml',
    'bk01-toc.xhtml','pref07.xhtml','pref08.xhtml'];
  const chapters = [...frontMatter.map(f => ({ title: f.replace('.xhtml',''), file: f }))];
  [...tocDoc.querySelectorAll('a[href]')]
    .map(a => ({ text: a.textContent.trim(), file: a.getAttribute('href').split('#')[0] }))
    .filter(l => l.file.endsWith('.xhtml') && !seen.has(l.file) && seen.add(l.file))
    .forEach(l => { if (!frontMatter.includes(l.file)) chapters.push({ title: l.text, file: l.file }); });

  // 5. 抓取所有章节内容
  console.log(`开始抓取 ${chapters.length} 个章节...`);
  const chapterData = [];
  for (const [i, ch] of chapters.entries()) {
    try {
      const r = await fetch(BASE + ch.file, { credentials: 'include' });
      const html = await r.text();
      const md = toMd(html, BASE + ch.file);
      chapterData.push({ ...ch, md });
      console.log(`[${i+1}/${chapters.length}] ${ch.title} (${md.length} chars)`);
    } catch(e) { chapterData.push({ ...ch, md: `*Error: ${e.message}*` }); }
  }

  // 6. 分批抓取图片(每批60张)
  const imgMap = {};
  console.log(`开始抓取 ${imgQueue.length} 张图片...`);
  for (let start = 0; start < imgQueue.length; start += 60) {
    const batch = imgQueue.slice(start, start + 60);
    for (const url of batch) {
      try {
        const r = await fetch(url, { credentials: 'include' });
        const blob = await r.blob();
        imgMap[url] = await new Promise(res => { const fr = new FileReader(); fr.onloadend = () => res(fr.result); fr.readAsDataURL(blob); });
      } catch(e) { console.warn('图片失败:', url); }
    }
    console.log(`图片进度: ${Math.min(start+60, imgQueue.length)}/${imgQueue.length}`);
  }

  // 7. 构建完整 Markdown
  const bookTitle = document.title.replace(' | Cover Page', '').trim();
  let fullMd = `# ${bookTitle}\n\n---\n\n`;
  for (const ch of chapterData) {
    const md = ch.md.replace(/__IMG__(.*?)__IMG__/g, (_, url) => imgMap[url] ? `images/${url.split('/').pop()}` : url);
    fullMd += `\n\n---\n\n# ${ch.title}\n\n${md}\n`;
  }
  fullMd += '\n\n---\n*Downloaded from O\'Reilly Learning Platform*\n';

  // 8. 打包 ZIP 并下载
  const zip = new JSZip();
  zip.file(`${isbn}.md`, fullMd);
  const imgs = zip.folder('images');
  Object.entries(imgMap).forEach(([url, b64]) => imgs.file(url.split('/').pop(), b64.split(',')[1], { base64: true }));
  const blob = await zip.generateAsync({ type: 'blob', compression: 'DEFLATE', compressionOptions: { level: 6 } });
  const a = Object.assign(document.createElement('a'), { href: URL.createObjectURL(blob), download: `${isbn}.zip` });
  document.body.appendChild(a); a.click(); document.body.removeChild(a);
  console.log(`✅ 下载完成!${chapterData.length} 章节,${Object.keys(imgMap).length} 张图片,${Math.round(blob.size/1024/1024)}MB`);
})();

下次如何复用

你只需要告诉我:

"帮我把这本 O'Reilly 书下载成 Markdown,用上次的方法"

然后把书的页面开着,我会自动执行上面的脚本。整个过程无需任何手动操作,唯一要求是浏览器已登录 O'Reilly 账号

注意事项

  • 每批图片约 60 张,避免 CDP 45秒超时,图片越多批次越多

  • 最终 ZIP 约为实际内容的 1-2 倍大小(图片未压缩前)

  • 仅适用于你账号有权限访问的书籍

Skill文件

oreilly_downloader_skill.zip