Skip to content

Instantly share code, notes, and snippets.

@helloint
Last active January 29, 2026 03:16
Show Gist options
  • Select an option

  • Save helloint/974a3598b19a99db5d251b0025bf1e90 to your computer and use it in GitHub Desktop.

Select an option

Save helloint/974a3598b19a99db5d251b0025bf1e90 to your computer and use it in GitHub Desktop.
人民教育出版社中小学电子教材下载
// ==UserScript==
// @name 人民教育出版社中小学电子教材下载
// @namespace https://helloint.com
// @version 1.1
// @description Download all jpg pages and merge into pdf from book.pep.com.cn by pressing 'ddd'.
// @match https://book.pep.com.cn/*
// @grant GM_xmlhttpRequest
// @connect cdnjs.cloudflare.com
// @connect unpkg.com
// @connect cdn.jsdelivr.net
// ==/UserScript==
(() => {
'use strict';
const PASSWORD = 'ddd';
const TIMEOUT = 5000;
let userInput = '';
let iid = 0;
let running = false;
const resetTimer = () => {
if (iid) clearTimeout(iid);
iid = setTimeout(() => (userInput = ''), TIMEOUT);
};
function ensureProgressEl() {
let box = document.getElementById('__pep_pdf_progress__');
if (box) return box;
box = document.createElement('div');
box.id = '__pep_pdf_progress__';
box.style.cssText = [
'position:fixed',
'top:10px',
'left:50%',
'transform:translateX(-50%)',
'z-index:2147483647',
'background:rgba(0,0,0,.75)',
'color:#fff',
'padding:8px 14px',
'border-radius:18px',
'font:14px/1.35 -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial',
'box-shadow:0 6px 20px rgba(0,0,0,.25)',
'user-select:none',
'white-space:pre-line',
'text-align:center',
'pointer-events:none',
].join(';');
const line1 = document.createElement('div');
line1.id = '__pep_pdf_progress_line1__';
// 初始提示文案
line1.textContent = '按“ddd”下载教材';
const line2 = document.createElement('div');
line2.id = '__pep_pdf_progress_line2__';
line2.style.cssText = 'margin-top:4px;color:#ffd2d2;display:none;text-align:center';
line2.textContent = '';
box.appendChild(line1);
box.appendChild(line2);
document.documentElement.appendChild(box);
return box;
}
function setProgressNumbers(done, total) {
ensureProgressEl();
const line1 = document.getElementById('__pep_pdf_progress_line1__');
const line2 = document.getElementById('__pep_pdf_progress_line2__');
if (line1) line1.textContent = `已下载 ${done} / 总页数 ${total}`;
if (line2) {
line2.textContent = '';
line2.style.display = 'none';
}
}
function showError(messageCn) {
ensureProgressEl();
const line2 = document.getElementById('__pep_pdf_progress_line2__');
if (line2) {
line2.textContent = messageCn;
line2.style.display = 'block';
}
}
function isBookReaderPage() {
return /\/\d+\/mobile\/index\.html(?:$|\?)/.test(location.pathname + location.search);
}
function getBookIdFromPath() {
const m = location.pathname.match(/^\/(\d+)\/mobile\/index\.html$/);
return m?.[1] || null;
}
function sanitizeFileName(name) {
return String(name || 'book')
.trim()
.replace(/[\\/:*?"<>|]/g, '-')
.replace(/\s+/g, ' ')
.slice(0, 180);
}
function gmRequest({ url, responseType = 'text' }) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
responseType,
onload: (res) => {
if (res.status >= 200 && res.status < 300) resolve(res);
else reject(new Error(`HTTP ${res.status} ${url}`));
},
onerror: () => reject(new Error(`Network error: ${url}`)),
ontimeout: () => reject(new Error(`Timeout: ${url}`)),
});
});
}
function pickJsPDF() {
const g = globalThis;
const fromSandbox = (g.jspdf && g.jspdf.jsPDF) || g.jsPDF;
if (fromSandbox) return fromSandbox;
if (typeof unsafeWindow !== 'undefined') {
return (unsafeWindow.jspdf && unsafeWindow.jspdf.jsPDF) || unsafeWindow.jsPDF;
}
return null;
}
async function loadJsPdf() {
let jsPDF = pickJsPDF();
if (jsPDF) return jsPDF;
const cdns = [
'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js',
'https://unpkg.com/jspdf@2.5.1/dist/jspdf.umd.min.js',
'https://cdn.jsdelivr.net/npm/jspdf@2.5.1/dist/jspdf.umd.min.js',
];
let lastErr;
for (const u of cdns) {
try {
const r = await gmRequest({ url: u, responseType: 'text' });
(0, eval)(r.responseText);
const jsPDF = pickJsPDF();
if (jsPDF) return jsPDF;
lastErr = new Error(`Loaded but jsPDF not found (globalThis/unsafeWindow): ${u}`);
} catch (e) {
lastErr = e;
}
}
throw lastErr || new Error('jsPDF not loaded');
}
async function fetchWithCookies(url) {
const f =
typeof unsafeWindow !== 'undefined' && unsafeWindow.fetch
? unsafeWindow.fetch.bind(unsafeWindow)
: fetch;
const res = await f(url, {
method: 'GET',
credentials: 'include',
cache: 'no-store',
headers: { Accept: 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8' },
});
if (!res.ok) throw new Error(`HTTP ${res.status} ${url}`);
return res;
}
async function fetchTextWithCookies(url) {
const res = await fetchWithCookies(url);
return await res.text();
}
async function fetchBlobWithCookies(url) {
const res = await fetchWithCookies(url);
return await res.blob();
}
function parseBookConfig(configJsText) {
// eslint-disable-next-line no-new-func
const fn = new Function(`var bookConfig = {}; ${configJsText}; return bookConfig;`);
const cfg = fn();
if (!cfg || typeof cfg !== 'object') throw new Error('Failed to parse bookConfig from config.js');
return cfg;
}
async function blobToDataURL(blob) {
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(fr.result);
fr.onerror = () => reject(new Error('FileReader failed'));
fr.readAsDataURL(blob);
});
}
function getPngSize(buf) {
const u8 = new Uint8Array(buf);
if (
u8.length < 24 ||
u8[0] !== 0x89 ||
u8[1] !== 0x50 ||
u8[2] !== 0x4e ||
u8[3] !== 0x47 ||
u8[4] !== 0x0d ||
u8[5] !== 0x0a ||
u8[6] !== 0x1a ||
u8[7] !== 0x0a
) {
throw new Error('Not a PNG');
}
const dv = new DataView(buf);
return { width: dv.getUint32(16, false), height: dv.getUint32(20, false) };
}
function getJpegSize(buf) {
const u8 = new Uint8Array(buf);
if (u8.length < 4 || u8[0] !== 0xff || u8[1] !== 0xd8) throw new Error('Not a JPEG');
const dv = new DataView(buf);
let off = 2;
while (off + 4 < dv.byteLength) {
if (dv.getUint8(off) !== 0xff) {
off += 1;
continue;
}
let marker = dv.getUint8(off + 1);
off += 2;
while (marker === 0xff && off < dv.byteLength) marker = dv.getUint8(off++);
if (marker === 0xd8 || marker === 0xd9) continue;
if (off + 2 > dv.byteLength) break;
const len = dv.getUint16(off, false);
if (len < 2 || off + len > dv.byteLength) break;
const isSOF =
(marker >= 0xc0 && marker <= 0xc3) ||
(marker >= 0xc5 && marker <= 0xc7) ||
(marker >= 0xc9 && marker <= 0xcb) ||
(marker >= 0xcd && marker <= 0xcf);
if (isSOF) {
const height = dv.getUint16(off + 3, false);
const width = dv.getUint16(off + 5, false);
return { width, height };
}
off += len;
}
throw new Error('Failed to parse JPEG size');
}
async function getImageInfoFromBlob(blob) {
const buf = await blob.arrayBuffer();
const u8 = new Uint8Array(buf);
if (u8.length >= 2 && u8[0] === 0xff && u8[1] === 0xd8) {
const { width, height } = getJpegSize(buf);
return { width, height, format: 'JPEG' };
}
if (u8.length >= 8 && u8[0] === 0x89 && u8[1] === 0x50 && u8[2] === 0x4e && u8[3] === 0x47) {
const { width, height } = getPngSize(buf);
return { width, height, format: 'PNG' };
}
let headText = '';
try {
headText = new TextDecoder().decode(u8.slice(0, 160)).replace(/\s+/g, ' ').trim();
} catch (_) {}
throw new Error(`Unknown image data. size=${blob.size}, type=${blob.type}, head="${headText}"`);
}
function cnReasonFromError(e) {
const msg = String(e?.message || e || '').trim();
if (/HTTP 403\b/.test(msg)) return '权限不足(403),请确认已登录且能正常翻页阅读';
if (/HTTP 404\b/.test(msg)) return '资源不存在(404)';
if (/Failed to fetch|Network error|Timeout/i.test(msg)) return '网络异常或超时';
return msg ? `原因:${msg}` : '未知原因';
}
async function main() {
if (running) return;
running = true;
let total = 0;
let done = 0;
try {
if (!isBookReaderPage()) {
alert('请在书籍阅读页执行:\nhttps://book.pep.com.cn/<书籍ID>/mobile/index.html');
return;
}
const bookId = getBookIdFromPath();
if (!bookId) throw new Error('Cannot parse bookId from URL path');
const jsPDF = await loadJsPdf();
const configUrl = new URL('./javascript/config.js', location.href).href;
const configText = await fetchTextWithCookies(configUrl);
const bookConfig = parseBookConfig(configText);
total = Number(bookConfig.totalPageCount || 0);
if (!total) throw new Error('Invalid totalPageCount in bookConfig');
const title = sanitizeFileName(bookConfig.bookTitle || 'book');
const fileName = `${title}-${bookId}.pdf`;
done = 0;
setProgressNumbers(done, total);
let pdf = null;
for (let page = 1; page <= total; page++) {
try {
const imgUrl = new URL(`../files/mobile/${page}.jpg`, location.href).href;
const blob = await fetchBlobWithCookies(imgUrl);
const { width: w, height: h, format } = await getImageInfoFromBlob(blob);
const dataUrl = await blobToDataURL(blob);
if (!pdf) pdf = new jsPDF({ unit: 'px', format: [w, h], compress: true });
else pdf.addPage([w, h]);
pdf.addImage(dataUrl, format, 0, 0, w, h, undefined, 'FAST');
done += 1;
setProgressNumbers(done, total);
} catch (e) {
const reason = cnReasonFromError(e);
showError(`下载第 ${page} 页时出错\n${reason}`);
throw e;
}
}
pdf.save(fileName);
} catch (e) {
console.error(e);
if (!total) setProgressNumbers(0, 0);
if (total && done >= 0 && !document.getElementById('__pep_pdf_progress_line2__')?.textContent) {
showError(`发生错误\n${cnReasonFromError(e)}`);
}
alert(`下载失败:${e?.message || e}`);
} finally {
running = false;
}
}
// 初始化提示 UI
ensureProgressEl();
document.addEventListener(
'keyup',
(e) => {
resetTimer();
if (e.keyCode === 68) {
userInput += 'd';
if (userInput.length > 3) userInput = userInput.slice(-3);
} else {
userInput = '';
}
if (userInput === PASSWORD) {
userInput = '';
main();
}
},
false
);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment