O'Reilly Book Downloader Skill v2
核心原理(与 v1 的区别)
v1 错误地在搜索页面直接用 fetch 下载 —— 那样下载的文件在远程环境,不会到你电脑。
v2 正确流程:
先导航到书籍页面(浏览器建立 Cookie 上下文)
在书籍页面上 fetch 内容和图片
JSZip 打包
a.click() 触发下载 → 文件直接到你电脑
使用方法
方法一:自己在浏览器运行
打开任意 O'Reilly 书籍页面(需已登录) 例如: https://learning.oreilly.com/library/view/-/9780138214036/
按 F12 打开开发者工具 → Console 标签
将 oreilly_downloader_v2.js 全部内容粘贴进去
按回车,等待下载完成
方法二:交给 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:抓取所有章节内容并转为 Markdown 用 fetch 逐章获取 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\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 倍大小(图片未压缩前)
仅适用于你账号有权限访问的书籍