""" 人教社电子教材 (https://jc.pep.com.cn/) 完整下载与爬取工具 修复与增强特性: 1. 强制携带 Referer: https://jc.pep.com.cn/ 防盗链标头(解决直接访问返回 403 / 404.html 问题) 2. 增加图片二进制特征校验 (JPEG 0xFFD8 魔数),杜绝误存 WAF 拦截的 HTML 页面 3. 增加中途触发 WAF 验证码时的动态拦截与自动破解重试机制 4. 修复控制台中文输出与 meta 标签教材名称提取 5. 自动批量下载并合成为标准高清 PDF 文件 """ import os import sys import re import time import json import random import base64 import binascii import urllib.request from typing import List, Dict, Optional from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives import padding from PIL import Image from playwright.sync_api import sync_playwright # 保证 Windows 控制台 UTF-8 中文正常输出 if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") class PepTextbookManager: """负责解析与解密人教社全量教材目录数据""" BASE_URL = "https://jc.pep.com.cn/" KEY = b"1234123412ABCDEF" IV = b"ABCDEF1234123412" @classmethod def get_all_textbooks(cls) -> List[Dict]: """从网站前端 JS 中提取并解密全量教材列表""" headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", "Referer": cls.BASE_URL } # 1. 获取首页 HTML,定位 chunk-bfbdf2c4 JS 文件 req = urllib.request.Request(cls.BASE_URL, headers=headers) with urllib.request.urlopen(req) as resp: html = resp.read().decode("utf-8", errors="ignore") chunk_match = re.search(r'src="(/js/chunk-bfbdf2c4\.[a-f0-9]+\.js)"', html) if not chunk_match: chunk_match = re.search(r'href="(/js/chunk-bfbdf2c4\.[a-f0-9]+\.js)"', html) chunk_path = chunk_match.group(1) if chunk_match else "/js/chunk-bfbdf2c4.3782cce3.js" chunk_url = urllib.parse.urljoin(cls.BASE_URL, chunk_path) # 2. 下载 JS 文件 js_req = urllib.request.Request(chunk_url, headers=headers) with urllib.request.urlopen(js_req) as resp: js_content = resp.read().decode("utf-8", errors="ignore") # 3. 提取十六进制密文字符串 c_match = re.search(r'var\s+o,\s*c\s*=\s*"([A-F0-9]+)"', js_content) if not c_match: raise ValueError("未能从前端 JS 中匹配到教材数据密文!") hex_ciphertext = c_match.group(1) cipher_bytes = binascii.unhexlify(hex_ciphertext) # 4. AES-128-CBC 解密 cipher = Cipher(algorithms.AES(cls.KEY), modes.CBC(cls.IV)) decryptor = cipher.decryptor() padded_plain = decryptor.update(cipher_bytes) + decryptor.finalize() unpadder = padding.PKCS7(128).unpadder() plaintext = unpadder.update(padded_plain) + unpadder.finalize() data_json = json.loads(plaintext.decode("utf-8", errors="ignore")) return data_json.get("data", []) class PepBookDownloader: """基于 Playwright 的无头浏览器,负责过防盗链与 WAF 滑块验证、抓取页面并合成 PDF""" def __init__(self, headless: bool = True): self.headless = headless def _solve_slider(self, page) -> bool: """自动检测并滑动通过阿里云 WAF NoCaptcha 滑块""" time.sleep(1) slider = page.query_selector(".btn_slide") if not slider: return True print("[*] 检测到阿里云 WAF 滑块验证码,正在自动滑动破解...") box = slider.bounding_box() scale = page.query_selector(".nc_scale") scale_box = scale.bounding_box() if scale else None if not (box and scale_box): print("[-] 未能获取滑块坐标") return False start_x = box["x"] + box["width"] / 2 start_y = box["y"] + box["height"] / 2 target_x = scale_box["x"] + scale_box["width"] + 20 # 模拟人类拖动轨迹 page.mouse.move(start_x, start_y) page.mouse.down() curr_x = start_x while curr_x < target_x: curr_x += random.randint(12, 28) curr_y = start_y + random.randint(-2, 2) page.mouse.move(curr_x, curr_y) time.sleep(random.uniform(0.01, 0.025)) page.mouse.move(target_x, start_y) page.mouse.up() time.sleep(3) if page.query_selector(".btn_slide"): print("[-] 滑块验证未通过,尝试二次滑动...") return False print("[+] 滑块验证通过!已成功获取访问权限。") return True def download_book(self, book_id: str, output_pdf: Optional[str] = None) -> Optional[str]: """下载指定 ID 的电子教材并转换为 PDF""" book_url = f"https://book.pep.com.cn/{book_id}/" print(f"[*] 开始准备下载教材: {book_url}") with sync_playwright() as p: browser = p.chromium.launch( headless=self.headless, args=[ "--disable-blink-features=AutomationControlled", "--no-sandbox", "--disable-infobars" ] ) context = browser.new_context( user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", viewport={"width": 1366, "height": 768}, extra_http_headers={ "Referer": "https://jc.pep.com.cn/" } ) # 抹除 WebDriver 特征以规避反爬 context.add_init_script(""" Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); window.navigator.chrome = { runtime: {} }; """) page = context.new_page() # 打开教材阅读页面(必须附带 Referer 防 403 拦截) page.goto(book_url, referer="https://jc.pep.com.cn/", wait_until="networkidle") # 处理可能的滑块验证 if page.query_selector(".btn_slide"): self._solve_slider(page) time.sleep(3) # 等待阅读器页面及配置加载 for _ in range(15): has_config = page.evaluate("""() => { return typeof window.bookConfig !== 'undefined' || typeof window.totalPageCount !== 'undefined' || document.querySelector('#bookContainer') !== null; }""") if has_config: break time.sleep(1) # 获取教材配置与总页数 config_info = page.evaluate("""() => { const descMeta = document.querySelector('meta[name="Description"]'); const ogMeta = document.querySelector('meta[property="og:title"]'); const title = (descMeta ? descMeta.content : '') || (ogMeta ? ogMeta.content : '') || (window.bookConfig && window.bookConfig.bookTitle) || document.title || '教材'; const total = window.totalPageCount || (window.bookConfig && window.bookConfig.totalPageCount) || 0; return { title, total }; }""") raw_title = config_info.get("title", f"book_{book_id}") book_title = re.sub(r'[\/:*?"<>|]', '_', raw_title).strip() total_pages = config_info.get("total", 0) if total_pages == 0: print("[-] 未能获取教材总页数,可能页面被重定向或未完全加载!") print(f" 当前页面 URL: {page.url}") print(f" 当前页面 Title: {page.title()}") browser.close() return None print(f"[+] 教材名称: 《{book_title}》 | 总页数: {total_pages} 页") # 准备临时图片保存目录 temp_dir = os.path.join(".", "temp_pages", book_id) os.makedirs(temp_dir, exist_ok=True) image_files = [] # 遍历下载每一页高清图片 for page_num in range(1, total_pages + 1): img_url = f"https://book.pep.com.cn/{book_id}/files/mobile/{page_num}.jpg" img_path = os.path.join(temp_dir, f"{page_num}.jpg") # 检查本地已有文件是否为有效 JPEG if os.path.exists(img_path) and os.path.getsize(img_path) > 15000: with open(img_path, "rb") as f: header = f.read(2) if header == b"\xff\xd8": image_files.append(img_path) continue # 在浏览器会话内下载 download_success = False for retry in range(3): res = page.evaluate("""async (url) => { try { const resp = await fetch(url); const ctype = resp.headers.get('content-type') || ''; const blob = await resp.blob(); return new Promise((resolve) => { const reader = new FileReader(); reader.onloadend = () => resolve({ status: resp.status, ctype: ctype, data: reader.result }); reader.readAsDataURL(blob); }); } catch (e) { return { status: 500, error: e.toString() }; } }""", img_url) data_uri = res.get("data", "") if res.get("status") == 200 and data_uri and ("image" in res.get("ctype", "") or data_uri.startswith("data:image")): raw_data = base64.b64decode(data_uri.split(",")[1]) # 校验 JPEG 魔数 0xFF 0xD8 if raw_data[:2] == b"\xff\xd8": with open(img_path, "wb") as f: f.write(raw_data) image_files.append(img_path) print(f" -> [{page_num}/{total_pages}] 下载成功 ({len(raw_data) // 1024} KB)") download_success = True break # 如果返回的是 HTML 页面,说明触发了 WAF 验证码拦截 print(f" [!] 第 {page_num} 页触发 WAF 验证,尝试刷新并过验证...") page.goto(book_url, referer="https://jc.pep.com.cn/", wait_until="networkidle") self._solve_slider(page) time.sleep(2) if not download_success: print(f" -> [{page_num}/{total_pages}] 下载失败!") time.sleep(random.uniform(0.15, 0.35)) browser.close() # 校验并合成 PDF if not image_files: print("[-] 未下载到任何有效图片!") return None if not output_pdf: output_pdf = f"{book_title}.pdf" print(f"[*] 正在将 {len(image_files)} 页图片合并为 PDF: {output_pdf} ...") pil_images = [] for img_p in image_files: try: im = Image.open(img_p) if im.mode != "RGB": im = im.convert("RGB") pil_images.append(im) except Exception as e: print(f"[-] 图片损坏跳过: {img_p}, 错误: {e}") if not pil_images: print("[-] 无有效图片可供合成!") return None first_im = pil_images[0] rest_images = pil_images[1:] first_im.save(output_pdf, "PDF", resolution=100.0, save_all=True, append_images=rest_images) print(f"[✔] 下载并生成 PDF 成功: {os.path.abspath(output_pdf)}") return output_pdf if __name__ == "__main__": print("=" * 60) print("1. 正在获取并解密人教社全量教材目录...") textbooks = PepTextbookManager.get_all_textbooks() print(f"[+] 成功解析出 {len(textbooks)} 本教材!") # 保存全部目录到本地 json with open("pep_catalog.json", "w", encoding="utf-8") as f: json.dump(textbooks, f, ensure_ascii=False, indent=2) print("[+] 全量教材目录已保存至: pep_catalog.json") print("\n" + "=" * 60) print("2. 演示下载一本教材(以 ID: 1284001101241 为例)...") downloader = PepBookDownloader(headless=True) downloader.download_book("1284001101241")