Last active
January 29, 2026 03:16
-
-
Save helloint/974a3598b19a99db5d251b0025bf1e90 to your computer and use it in GitHub Desktop.
人民教育出版社中小学电子教材下载
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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