这两天朋友问我,她手上有个重复性繁琐的工作,能不能帮忙搞个程序干掉?! 简言之,根据查询条件,把搜到相应结果的试卷列表都下载下来。 但是步骤有点多:
- 点击查询条件,搜出试卷列表
- 点击第一个试卷,跳到试卷详情页
- 点击详情页的打印试卷按钮
- 页面状态更新,点击立即打印按钮
- 弹框打印框,将选项设置为“另存为PDF”,文件名改成试卷名
- 勾选预览页的答案和解析,点击预览页的立即打印按钮
- 弹框打印框,将选项设置为“另存为PDF”,文件名改成试卷名-答案
我一听感觉还行,这不就是pupeteer的活么,我来揽!
结果写的时候,一堆阻碍,但是最后实现了!写下过程和代码,万一之后用到呢!
先看下最终效果
命令也简单,down-paper
down-paper -c "cookiexx" -g "gradeCodexxxx"
下面记录问题,以便日后也能参考:
问题1:怎么也找不到预览页的立即打印按钮!
奇怪,写的时候,详情页的打印按钮都能找到,但第4步的立即打印按钮找不到,调试了一阵子! 后来才发现,立即打印其实是在一个iframe里,直接的选择器是找不到的,通过iframe找。 好,下次知道了,iframe里的选择器,得通过iframe才行。
问题2:点了立即打印按钮,没反应!
嘎?点了为啥没反应,按钮好不容易找到了! 翻来覆去找了阵子,想着也许是和浏览器的默认打印框有关,然后搜搜怎么弹打印框
智能AI的建议是,模拟按键: Command+P,但不好使。 最后使用的是 window.print()
但紧接着更大的坑来了,弹框出现了,但是控制不了上面的选项,也不能控制点击保存键!!
卡了半天!
后来又想了,没必要非得弹框,我的目标是PDF啊,看有没有生成PDF的方法不就是,我真是个大聪明!page.pdf()就是这么个方法
但是!保存下来的页面没有全乎,只有可视区域,我又来回试了好几遍,这就要不能揽了么!我不信!我不服啊!
我又灵机一动!!
这不是iframe么,我直接用新页面打开试试!
咦!靠谱!这时候再调用pdf,又多了个头部和边上的一点背景!
啊呀呀!没事!难不倒我!设置样式干掉!
靠谱!终于一个满意的pdf生成了!
好,下次知道了,带着iframe的页面pdf生成有问题,单独打开新页面就好了!部分样式有出入的话,设置下就行!
问题3:从列表里挨个点,调整到详情页,这个感觉很费劲!
列表项长度也不太一样,页数也不太一样,直接用选择器有点费劲啊!
咋整?
有了!
直接用查询条件,请求接口,接口返回列表信息,再把单个项信息参数进行拼接,这样就能得到详情地址数组了!
下次就晓得了!批量详情页,就用列表数据拼接成链接好了!
问题3:朋友可不是开发人员啊,怎么运行代码呢!!
功能都写完了,我电脑运行没问题,但是她可能经常用这个功能,她想自己干呐!
这个头秃的!
嗯。。。。
写个命令吧!发布到npm上!回头电脑执行个命令就行!!
还能把查询条件,写成命令参数!这样换条件,也能灵活下载!
我真是个大聪明!
下次就知道了!让非开发人员快速使用功能,就写成命令发布,这样就能用了!
问题4:windows的路径分隔符不支持!
命令写完了! npm包都发完了! 我试了下windows执行,靠,路径报错了! 因为我的是mac! 这个问题还算小问题,咔咔咔,一顿改,将路径分隔符改成动态获取!
下次知道了!mac和windows的路径分隔符不一样,写通用命令,得写活的!
问题5:Chrome的路径不支持!
再次发包!
再次windows执行!
靠!失败了!
找不到Chrome,打不开!
因为对面是windows啊,啊啊啊啊啊啊啊啊啊啊!
好在也有动态获取安装路径的法子! 这就算搞定了!
问题6:怎么也能显示进度和整体情况?
朋友说,我这不知道总共几个链接,也不晓得成功和失败几个啊! 啊! 是! 再加个日志管理器! 这样,每到一个步骤,控制台输出,最终还给一个总的,如果失败的话,是哪个链接失败了,这样手动下载,也是不错的! 下次知道了!命令尽量也加个日志系统,输出也酷,有错误也不怕!
下面说代码了
bin/down-paper.js - CLI命令行工具
这是npm包的CLI(命令行界面)入口文件,让用户可以通过命令行直接使用工具。当用户安装包后,可以通过down-paper命令来运行工具。
🎯 主要功能
- 命令行参数解析: 解析用户输入的各种参数
- 参数验证: 验证必需参数(如cookie)是否提供
- 帮助信息: 提供
--help参数显示使用说明 - 版本信息: 提供
--version参数显示版本号 - 配置构建: 将命令行参数转换为内部配置对象
- 执行调用: 调用核心的
runBatchPDFGeneration函数
📋 支持的参数
| 参数 | 简写 | 必需 | 默认值 | 说明 |
|---|---|---|---|---|
--cookie | -c | ✅ | 无 | Cookie字符串,用于身份验证 |
--subject-id | -s | ❌ | 1574 | 科目ID |
--grade | -g | ❌ | 0557 | 年级代码 |
--quarter | -q | ❌ | 3 | 学期代码 |
--use-scene | -u | ❌ | khlx | 使用场景代码 |
--output-dir | -o | ❌ | ./1-download | 输出目录 |
--help | -h | ❌ | - | 显示帮助信息 |
--version | -v | ❌ | - | 显示版本号 |
💻 核心代码实现
#!/usr/bin/env node
const { runBatchPDFGeneration } = require('../lib/batchProcessor');
const path = require('path');
/**
* CLI 命令行工具
* 支持通过命令行参数配置和运行批量PDF生成任务
*/
// 解析命令行参数
function parseArgs() {
const args = process.argv.slice(2);
const options = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
switch (arg) {
case '--cookie':
case '-c':
options.cookie = args[++i];
break;
case '--subject-id':
case '-s':
options.subjectId = parseInt(args[++i]);
break;
case '--grade':
case '-g':
options.grade = args[++i];
break;
case '--quarter':
case '-q':
options.quarter = parseInt(args[++i]);
break;
case '--use-scene':
case '-u':
options.useScene = args[++i];
break;
case '--output-dir':
case '-o':
options.outputDir = args[++i];
break;
case '--help':
case '-h':
showHelp();
process.exit(0);
break;
case '--version':
case '-v':
console.log(require('../package.json').version);
process.exit(0);
break;
default:
if (arg.startsWith('--')) {
console.error(`未知参数: ${arg}`);
showHelp();
process.exit(1);
}
break;
}
}
return options;
}
// 显示帮助信息
function showHelp() {
console.log(`
📚 批量下载试卷PDF工具
用法:
down-paper [选项]
选项:
-c, --cookie <string> Cookie字符串 (必需)
-s, --subject-id <number> 科目ID (默认: 1574)
-g, --grade <string> 年级 (默认: 0557)
-q, --quarter <number> 学期 (默认: 3)
-u, --use-scene <string> 使用场景 (默认: khlx)
-o, --output-dir <string> 输出目录 (默认: ./1-download)
-h, --help 显示帮助信息
-v, --version 显示版本号
示例:
# 基本用法(使用默认输出目录)
down-paper --cookie "your-cookie-string"
# 指定自定义输出目录(推荐)
# Linux/macOS:
down-paper --cookie "your-cookie-string" --output-dir "./my-papers"
# Windows:
down-paper --cookie "your-cookie-string" --output-dir ".\\my-papers"
# 指定年级和输出目录
down-paper --cookie "your-cookie-string" --grade "0558" --output-dir "./downloads"
# 指定所有参数
down-paper --cookie "your-cookie-string" --subject-id 1574 --grade "0557" --quarter 3 --use-scene "khlx" --output-dir "./downloads"
年级代码:
0555 - S3 0556 - S4 0557 - 一年级
0558 - 二年级 0559 - 三年级 0560 - 四年级
0561 - 五年级 0562 - 六年级 0567 - 不区分
0999 - 小升初
使用场景:
gdk - 功底考 jdcp - 阶段测试 khlx - 课后测试
nlcp - 能力测评 syttl - 素养天天练
学期代码:
1 - 春季 2 - 暑假 3 - 秋季
4 - 寒假 9 - 不区分
`);
}
// 验证必需参数
function validateOptions(options) {
if (!options.cookie) {
console.error('❌ 错误: 必须提供 --cookie 参数');
console.log('使用 --help 查看帮助信息');
process.exit(1);
}
}
// 主函数
async function main() {
try {
console.log('🚀 批量下载试卷PDF工具启动中...\n');
const options = parseArgs();
validateOptions(options);
// 构建配置对象
const config = {
cookie: options.cookie,
queryParams: {
subjectId: options.subjectId || 1574,
useScene: options.useScene || 'khlx',
grade: options.grade || '0557',
quarter: options.quarter || 3
},
outputDir: options.outputDir || './1-download'
};
console.log('📋 配置信息:');
console.log(` 科目ID: ${config.queryParams.subjectId}`);
console.log(` 年级: ${config.queryParams.grade}`);
console.log(` 学期: ${config.queryParams.quarter}`);
console.log(` 使用场景: ${config.queryParams.useScene}`);
console.log(` 输出目录: ${config.outputDir}`);
console.log(` Cookie: ${config.cookie.substring(0, 50)}...`);
console.log('');
// 执行批量PDF生成
const result = await runBatchPDFGeneration(config);
console.log('\n🎉 任务完成!');
console.log(` 总计: ${result.total} 个任务`);
console.log(` 成功: ${result.success} 个`);
console.log(` 失败: ${result.failed} 个`);
console.log(` 耗时: ${(result.duration / 1000).toFixed(2)} 秒`);
if (result.failed > 0) {
console.log('\n❌ 失败的链接:');
result.failedLinks.forEach(link => {
console.log(` ${link.index}. ${link.url}`);
console.log(` 错误: ${link.error}`);
});
}
} catch (error) {
console.error('❌ 执行失败:', error.message);
if (process.env.DEBUG) {
console.error(error.stack);
}
process.exit(1);
}
}
// 如果直接运行此文件,则执行主函数
if (require.main === module) {
main();
}
module.exports = { main, parseArgs, showHelp };
🎯 关键特性
- Shebang行:
#!/usr/bin/env node让系统知道用Node.js执行 - 参数解析: 手动解析
process.argv,支持长参数和短参数 - 参数验证: 检查必需参数(cookie)是否存在
- 帮助系统: 详细的帮助信息,包括参数说明和使用示例
- 配置转换: 将命令行参数转换为内部API格式
- 错误处理: 友好的错误提示和退出码
- 跨平台: 支持不同操作系统的路径格式
- 调试支持: 通过
DEBUG环境变量显示详细错误信息
📦 npm包配置
在package.json中配置:
{
"bin": {
"down-paper": "./bin/down-paper.js"
}
}
这样用户安装包后就可以直接使用down-paper命令了。
#!/usr/bin/env node
const { runBatchPDFGeneration } = require('../lib/batchProcessor');
const path = require('path');
/**
* CLI 命令行工具
* 支持通过命令行参数配置和运行批量PDF生成任务
*/
// 解析命令行参数
function parseArgs() {
const args = process.argv.slice(2);
const options = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
switch (arg) {
case '--cookie':
case '-c':
options.cookie = args[++i];
break;
case '--subject-id':
case '-s':
options.subjectId = parseInt(args[++i]);
break;
case '--grade':
case '-g':
options.grade = args[++i];
break;
case '--quarter':
case '-q':
options.quarter = parseInt(args[++i]);
break;
case '--use-scene':
case '-u':
options.useScene = args[++i];
break;
case '--output-dir':
case '-o':
options.outputDir = args[++i];
break;
case '--headless':
options.headless = true;
break;
case '--help':
case '-h':
showHelp();
process.exit(0);
break;
case '--version':
case '-v':
console.log(require('../package.json').version);
process.exit(0);
break;
default:
if (arg.startsWith('--')) {
console.error(`未知参数: ${arg}`);
showHelp();
process.exit(1);
}
break;
}
}
return options;
}
// 显示帮助信息
function showHelp() {
console.log(`
📚 批量下载试卷PDF工具
用法:
down-paper [选项]
选项:
-c, --cookie <string> Cookie字符串 (必需)
-s, --subject-id <number> 科目ID (默认: 1574)
-g, --grade <string> 年级 (默认: 0557)
-q, --quarter <number> 学期 (默认: 3)
-u, --use-scene <string> 使用场景 (默认: khlx)
-o, --output-dir <string> 输出目录 (默认: ./1-download)
--headless 使用无头模式运行浏览器 (适合服务器环境)
-h, --help 显示帮助信息
-v, --version 显示版本号
示例:
# 基本用法(使用默认输出目录)
down-paper --cookie "your-cookie-string"
# 指定自定义输出目录(推荐)
# Linux/macOS:
down-paper --cookie "your-cookie-string" --output-dir "./my-papers"
# Windows:
down-paper --cookie "your-cookie-string" --output-dir ".\\my-papers"
# 指定年级和输出目录
down-paper --cookie "your-cookie-string" --grade "0558" --output-dir "./downloads"
# 指定所有参数
down-paper --cookie "your-cookie-string" --subject-id 1574 --grade "0557" --quarter 3 --use-scene "khlx" --output-dir "./downloads"
# 使用无头模式(适合服务器环境)
down-paper --cookie "your-cookie-string" --headless --output-dir "./downloads"
年级代码:
0555 - S3 0556 - S4 0557 - 一年级
0558 - 二年级 0559 - 三年级 0560 - 四年级
0561 - 五年级 0562 - 六年级 0567 - 不区分
0999 - 小升初
使用场景:
gdk - 功底考 jdcp - 阶段测试 khlx - 课后测试
nlcp - 能力测评 syttl - 素养天天练
学期代码:
1 - 春季 2 - 暑假 3 - 秋季
4 - 寒假 9 - 不区分
`);
}
// 验证必需参数
function validateOptions(options) {
if (!options.cookie) {
console.error('❌ 错误: 必须提供 --cookie 参数');
console.log('使用 --help 查看帮助信息');
process.exit(1);
}
}
// 主函数
async function main() {
try {
console.log('🚀 批量下载试卷PDF工具启动中...\n');
const options = parseArgs();
validateOptions(options);
// 构建配置对象
const config = {
cookie: options.cookie,
queryParams: {
subjectId: options.subjectId || 1574,
useScene: options.useScene || 'khlx',
grade: options.grade || '0557',
quarter: options.quarter || 3
},
outputDir: options.outputDir || './1-download',
headless: options.headless || false
};
console.log('📋 配置信息:');
console.log(` 科目ID: ${config.queryParams.subjectId}`);
console.log(` 年级: ${config.queryParams.grade}`);
console.log(` 学期: ${config.queryParams.quarter}`);
console.log(` 使用场景: ${config.queryParams.useScene}`);
console.log(` 输出目录: ${config.outputDir}`);
console.log(` 无头模式: ${config.headless ? '是' : '否'}`);
console.log(` Cookie: ${config.cookie.substring(0, 50)}...`);
console.log('');
// 执行批量PDF生成
const result = await runBatchPDFGeneration(config);
console.log('\n🎉 任务完成!');
console.log(` 总计: ${result.total} 个任务`);
console.log(` 成功: ${result.success} 个`);
console.log(` 失败: ${result.failed} 个`);
console.log(` 耗时: ${(result.duration / 1000).toFixed(2)} 秒`);
if (result.failed > 0) {
console.log('\n❌ 失败的链接:');
result.failedLinks.forEach(link => {
console.log(` ${link.index}. ${link.url}`);
console.log(` 错误: ${link.error}`);
});
}
} catch (error) {
console.error('❌ 执行失败:', error.message);
if (process.env.DEBUG) {
console.error(error.stack);
}
process.exit(1);
}
}
// 如果直接运行此文件,则执行主函数
if (require.main === module) {
main();
}
module.exports = { main, parseArgs, showHelp };
📁 lib/ 目录代码逻辑详解
lib目录包含了发布到npm包中的核心代码,这些文件直接作为源代码使用。下面详细分析每个文件的逻辑:
1. lib/index.js - API入口文件
这是npm包的API入口文件,提供了程序化调用的接口:
/**
* 批量下载试卷PDF工具 - 主入口文件
*
* 这个文件提供了两种使用方式:
* 1. 作为CLI工具直接运行
* 2. 作为模块被其他文件引用
*/
const { runBatchPDFGeneration } = require('./batchProcessor');
// 如果直接运行此文件,则执行默认配置
if (require.main === module) {
console.log('🚀 批量下载试卷PDF工具');
console.log('📝 使用默认配置运行...\n');
const params = {
queryParams: {
// 脑力与思维
subjectId: 1574,
// 课后测试
useScene: 'khlx',
// 一年级 0557 二年级 0558 三年级 0559 四年级 0560
grade: '0559',
// 秋季
quarter: 3,
// 每页300条
pageSize: 300
},
}
runBatchPDFGeneration(params)
.then(result => {
console.log('\n🎉 任务完成!');
console.log(` 总计: ${result.total} 个任务`);
console.log(` 成功: ${result.success} 个`);
console.log(` 失败: ${result.failed} 个`);
console.log(` 耗时: ${(result.duration / 1000).toFixed(2)} 秒`);
})
.catch(error => {
console.error('❌ 执行失败:', error.message);
process.exit(1);
});
}
// 导出方法供其他文件调用
module.exports = { runBatchPDFGeneration };
功能说明:
- 作为npm包的API入口点
- 提供
runBatchPDFGeneration函数的导出 - 支持直接运行(使用默认配置)
- 支持作为模块被其他项目引用
2. lib/batchProcessor.js - 批量处理核心逻辑
这是整个工具的核心文件,负责协调整个批量下载流程:
const { processIframeAndGeneratePDF } = require('./iframeProcessor');
const { getPapersList, createLinkArr } = require('./request');
const logger = require('./logger');
/**
* 批量PDF生成任务
* @param {Object} options - 配置选项
* @param {string} options.cookie - Cookie字符串
* @param {Object} options.queryParams - 查询参数
* @param {number} options.queryParams.subjectId - 科目ID(默认:1574)
* @param {string} options.queryParams.useScene - 使用场景(默认:'khlx')
* @param {string} options.queryParams.grade - 年级(默认:'0557')
* @param {number} options.queryParams.quarter - 学期(默认:3)
* @param {string} options.downloadSelector - 下载按钮选择器
* @param {string} options.printSelector - 打印按钮选择器
* @param {string} options.textSelector - 文本选择器
* @param {string} options.checkboxSelector - 复选框选择器
* @param {Array} options.checkboxIndexes - 复选框索引数组
* @param {string} options.outputDir - 输出目录
* @returns {Promise<Object>} 返回执行结果统计
*/
async function runBatchPDFGeneration(options = {}) {
const startTime = Date.now();
let successCount = 0;
let failedCount = 0;
const failedLinks = [];
// 默认配置
const defaultOptions = {
queryParams: {
subjectId: 1574,
useScene: 'khlx',
grade: '0557',
quarter: 3
},
downloadSelector: '#app .source_main_box .title .title-right .top-name-div .print-btn',
printSelector: '.down-info .down-info-btn .print-btn',
textSelector: '.x-text',
checkboxSelector: '.down-type-container-info .el-checkbox',
checkboxIndexes: [1, 2],
outputDir: ''
};
// 合并配置
const config = { ...defaultOptions, ...options };
const { cookie, queryParams, ...otherOptions } = config;
logger.info('开始批量PDF生成任务', {
startTime: new Date().toISOString(),
config: {
...config,
cookie: cookie ? '***已设置***' : '未设置'
}
});
try {
// 1. 获取试卷列表
logger.info('正在获取试卷列表...');
const papersList = await getPapersList(queryParams, cookie);
logger.info(`成功获取到 ${papersList.length} 个试卷`);
// 2. 创建下载链接数组
const linkArr = createLinkArr(papersList);
logger.info(`创建了 ${linkArr.length} 个下载链接`);
// 3. 批量处理PDF生成
logger.info('开始批量处理PDF生成...');
for (let i = 0; i < linkArr.length; i++) {
const link = linkArr[i];
const index = i + 1;
logger.info(`处理第 ${index}/${linkArr.length} 个链接`, {
url: link.url,
title: link.title
});
try {
await processIframeAndGeneratePDF({
...link,
...otherOptions,
index
});
successCount++;
logger.success(`第 ${index} 个链接处理成功`, { url: link.url });
} catch (error) {
failedCount++;
failedLinks.push({
index,
url: link.url,
title: link.title,
error: error.message
});
logger.error(`第 ${index} 个链接处理失败`, {
url: link.url,
error: error.message
});
}
// 添加延迟避免请求过于频繁
if (i < linkArr.length - 1) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
// 4. 生成执行结果
const endTime = Date.now();
const duration = endTime - startTime;
const result = {
total: linkArr.length,
success: successCount,
failed: failedCount,
duration,
failedLinks,
startTime: new Date(startTime).toISOString(),
endTime: new Date(endTime).toISOString()
};
logger.info('批量PDF生成任务完成', result);
return result;
} catch (error) {
logger.error('批量PDF生成任务失败', { error: error.message });
throw error;
}
}
module.exports = { runBatchPDFGeneration };
功能说明:
- 协调整个批量下载流程
- 管理试卷列表获取和链接创建
- 控制并发处理和错误处理
- 生成详细的执行报告和统计信息
3. lib/pdfGenerator.js - PDF生成核心逻辑
负责单个PDF文件的生成,包括页面操作和PDF导出:
const puppeteer = require('puppeteer');
const fs = require('fs');
const path = require('path');
const logger = require('./logger');
const { getBrowserOptions, getPlatformInfo } = require('./browserConfig');
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
/**
* 生成PDF文件的方法
* @param {Object} options - 配置选项
* @param {string} options.url - 要访问的URL
* @param {Array} options.cookies - Cookie数组
* @param {string} options.textSelector - 用于生成文件名的文本选择器
* @param {string} options.checkboxSelector - 复选框选择器
* @param {Array} options.checkboxIndexes - 要点击的复选框索引数组
* @param {string} options.outputDir - 输出目录
* @param {boolean} options.headless - 是否无头模式
* @returns {Promise<Object>} 返回生成的文件信息
*/
async function generatePDF(options) {
let {
url,
cookies = [],
textSelector = '.x-text',
checkboxSelector = '.down-type-container-info .el-checkbox',
checkboxIndexes = [1, 2],
outputDir = './download/',
headless = false
} = options;
// 参数验证
if (!url) {
const error = new Error('URL参数是必需的');
logger.error('generatePDF参数验证失败', { error: error.message });
throw error;
}
// 确保输出目录存在并格式化路径
if (outputDir) {
let normalizedOutputDir = path.normalize(outputDir);
if (!normalizedOutputDir.endsWith(path.sep)) {
normalizedOutputDir += path.sep;
}
if (!fs.existsSync(normalizedOutputDir)) {
fs.mkdirSync(normalizedOutputDir, { recursive: true });
logger.info('创建输出目录', { outputDir: normalizedOutputDir });
}
outputDir = normalizedOutputDir;
}
logger.info('开始PDF生成流程', { url, textSelector, checkboxSelector, checkboxIndexes });
let browser;
try {
// 启动浏览器
logger.info('启动浏览器');
// 获取跨平台浏览器配置
const browserOptions = getBrowserOptions({ headless });
const platformInfo = getPlatformInfo();
// 记录浏览器配置信息
logger.info('浏览器配置', {
platform: platformInfo.platform,
arch: platformInfo.arch,
executablePath: browserOptions.executablePath || '使用默认路径',
headless: browserOptions.headless,
chromeAvailable: platformInfo.chromeAvailable
});
browser = await puppeteer.launch(browserOptions);
logger.success('浏览器启动成功');
const page = await browser.newPage();
// 设置Cookie
if (cookies && cookies.length > 0) {
logger.info('设置Cookie', { count: cookies.length });
await page.setCookie(...cookies);
}
// 访问页面
logger.info('访问页面', { url });
await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });
// 等待页面加载
await sleep(2000);
// 查找文本元素
const textElement = await page.$(textSelector);
if (!textElement) {
throw new Error(`未找到文本元素: ${textSelector}`);
}
// 获取文件名
const fileName = await textElement.evaluate(el => el.textContent.trim());
logger.info('获取到文件名', { fileName });
// 查找复选框
const checkboxes = await page.$$(checkboxSelector);
if (checkboxes.length === 0) {
throw new Error(`未找到复选框: ${checkboxSelector}`);
}
logger.info('找到复选框', { count: checkboxes.length });
// 生成第一个PDF(不包含答案)
const result = { files: [] };
try {
// 取消选择所有复选框
for (let i = 0; i < checkboxes.length; i++) {
const isChecked = await checkboxes[i].evaluate(el => el.checked);
if (isChecked) {
await checkboxes[i].click();
await sleep(500);
}
}
// 生成PDF
const pdfBuffer = await page.pdf({
format: 'A4',
printBackground: false,
displayHeaderFooter: false
});
const filePath1 = path.join(outputDir, `${fileName}.pdf`);
fs.writeFileSync(filePath1, pdfBuffer);
const fileSize = fs.statSync(filePath1).size;
logger.logPDFSuccess(fileName, filePath1, fileSize);
result.files.push(filePath1);
} catch (error) {
logger.error('生成第一个PDF失败', { error: error.message });
}
// 生成第二个PDF(包含答案)
if (checkboxIndexes.length > 0) {
try {
// 选择指定的复选框
for (const index of checkboxIndexes) {
if (index <= checkboxes.length) {
const checkbox = checkboxes[index - 1];
const isChecked = await checkbox.evaluate(el => el.checked);
if (!isChecked) {
await checkbox.click();
await sleep(500);
}
}
}
// 等待页面更新
await sleep(1000);
// 生成包含答案的PDF
const pdfBuffer2 = await page.pdf({
format: 'A4',
printBackground: false,
displayHeaderFooter: false
});
const filePath2 = path.join(outputDir, `${fileName}-答案.pdf`);
fs.writeFileSync(filePath2, pdfBuffer2);
const fileSize2 = fs.statSync(filePath2).size;
logger.logPDFSuccess(`${fileName}-答案`, filePath2, fileSize2);
result.files.push(filePath2);
} else {
logger.warn('未找到答案复选框,跳过答案PDF生成');
}
}
logger.success('PDF生成完成', {
fileName,
files: result.files.length,
totalSize: result.files.reduce((sum, file) => sum + fs.statSync(file).size, 0)
});
return result;
} catch (error) {
logger.error('PDF生成失败', { error: error.message, url });
throw error;
} finally {
if (browser) {
await browser.close();
logger.info('浏览器已关闭');
}
}
}
module.exports = { generatePDF };
功能说明:
- 启动和管理Puppeteer浏览器实例
- 处理页面交互(点击复选框)
- 生成PDF文件(包含答案和不包含答案两个版本)
- 跨平台路径处理
- 错误处理和资源清理
4. lib/browserConfig.js - 跨平台浏览器配置
提供跨平台的浏览器配置和路径检测:
const os = require('os');
const fs = require('fs');
const path = require('path');
/**
* 获取跨平台的 Chrome 浏览器路径
* @returns {string|null} Chrome 可执行文件路径,如果未找到则返回 null
*/
function getChromeExecutablePath() {
const platform = os.platform();
if (platform === 'darwin') {
// macOS
const macPath = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
if (fs.existsSync(macPath)) {
return macPath;
}
} else if (platform === 'win32') {
// Windows - 尝试常见的 Chrome 安装路径
const possiblePaths = [
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
path.join(process.env.LOCALAPPDATA || '', 'Google\\Chrome\\Application\\chrome.exe'),
path.join(process.env.PROGRAMFILES || '', 'Google\\Chrome\\Application\\chrome.exe'),
path.join(process.env['PROGRAMFILES(X86)'] || '', 'Google\\Chrome\\Application\\chrome.exe')
];
for (const chromePath of possiblePaths) {
if (chromePath && fs.existsSync(chromePath)) {
return chromePath;
}
}
} else if (platform === 'linux') {
// Linux - 尝试常见的 Chrome 安装路径
const possiblePaths = [
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
'/usr/bin/chromium-browser',
'/usr/bin/chromium',
'/snap/bin/chromium'
];
for (const chromePath of possiblePaths) {
if (fs.existsSync(chromePath)) {
return chromePath;
}
}
}
return null; // 未找到 Chrome,使用 Puppeteer 默认路径
}
/**
* 获取跨平台的浏览器启动配置
* @param {Object} options - 浏览器配置选项
* @param {boolean} options.headless - 是否无头模式
* @param {Array} options.args - 额外的启动参数
* @returns {Object} 浏览器启动配置
*/
function getBrowserOptions(options = {}) {
const { headless = true, args = [] } = options;
const browserOptions = {
headless,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-accelerated-2d-canvas',
'--no-first-run',
'--no-zygote',
'--disable-gpu',
...args
]
};
// 尝试设置 Chrome 路径
const chromePath = getChromeExecutablePath();
if (chromePath) {
browserOptions.executablePath = chromePath;
}
return browserOptions;
}
/**
* 检查 Chrome 是否可用
* @returns {boolean} Chrome 是否可用
*/
function isChromeAvailable() {
return getChromeExecutablePath() !== null;
}
/**
* 获取当前平台信息
* @returns {Object} 平台信息
*/
function getPlatformInfo() {
return {
platform: os.platform(),
arch: os.arch(),
chromePath: getChromeExecutablePath(),
chromeAvailable: isChromeAvailable()
};
}
module.exports = {
getChromeExecutablePath,
getBrowserOptions,
isChromeAvailable,
getPlatformInfo
};
功能说明:
- 跨平台Chrome路径检测(Windows、macOS、Linux)
- 浏览器启动配置生成
- 平台信息获取
- Chrome可用性检查
5. lib/request.js - HTTP请求处理
负责与API通信,获取试卷列表:
const https = require('https');
const logger = require('./logger');
/**
* 获取试卷列表
* @param {Object} params - 查询参数
* @param {string} cookies - Cookie字符串
* @returns {Promise<Array>} 试卷列表
*/
async function getPapersList(params = {}, cookies) {
if (!cookies) {
throw new Error('cookies参数是必需的');
}
const queryParams = new URLSearchParams({
subjectId: params.subjectId || 1574,
useScene: params.useScene || 'khlx',
grade: params.grade || '0557',
quarter: params.quarter || 3,
pageSize: params.pageSize || 300,
pageNum: 1
});
const url = `https://api.xdf.cn/paper/list?${queryParams.toString()}`;
logger.info('请求试卷列表', { url, params });
return new Promise((resolve, reject) => {
const options = {
method: 'GET',
headers: {
'Cookie': cookies,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Referer': 'https://www.xdf.cn/',
'Origin': 'https://www.xdf.cn'
}
};
const req = https.request(url, options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
const response = JSON.parse(data);
if (response.code === 200 && response.data) {
logger.info('获取试卷列表成功', { count: response.data.length });
resolve(response.data);
} else {
logger.error('获取试卷列表失败', { response });
reject(new Error(`API返回错误: ${response.message || '未知错误'}`));
}
} catch (error) {
logger.error('解析试卷列表响应失败', { error: error.message, data });
reject(error);
}
});
});
req.on('error', (error) => {
logger.error('请求试卷列表失败', { error: error.message });
reject(error);
});
req.setTimeout(30000, () => {
req.destroy();
reject(new Error('请求超时'));
});
req.end();
});
}
/**
* 创建下载链接数组
* @param {Array} papersList - 试卷列表
* @returns {Array} 链接数组
*/
function createLinkArr(papersList) {
const linkArr = [];
papersList.forEach(paper => {
if (paper.id && paper.title) {
linkArr.push({
id: paper.id,
title: paper.title,
url: `https://www.xdf.cn/paper/detail/${paper.id}`
});
}
});
logger.info('创建下载链接数组', { count: linkArr.length });
return linkArr;
}
module.exports = { getPapersList, createLinkArr };
功能说明:
- 与XDF API通信获取试卷列表
- 处理HTTP请求和响应
- 解析JSON数据
- 创建下载链接数组
6. lib/genCookies.js - Cookie处理工具
提供Cookie字符串的解析和格式化功能:
const genCookies = (cookieStr) => {
let cookieArray = [];
if (typeof cookieStr === 'string' && cookieStr.length > 0) {
cookieArray = cookieStr.split('; ').map(cookieItem => {
const [name, value] = cookieItem.split('=');
return { name, value, domain: '.xdf.cn' };
});
} else if (Array.isArray(cookieStr)) {
cookieArray = cookieStr;
}
return cookieArray;
}
module.exports = {
genCookies
}
主要功能:
- 将Cookie字符串解析为对象数组
- 支持字符串和数组两种输入格式
- 自动设置域名为
.xdf.cn - 为Puppeteer提供标准化的Cookie格式
使用场景:
- 在PDF生成过程中设置认证Cookie
- 确保Cookie格式符合Puppeteer要求
- 简化Cookie处理逻辑
7. lib/logger.js - 日志系统
提供统一的日志记录功能:
const fs = require('fs');
const path = require('path');
class Logger {
constructor() {
this.logDir = path.join(process.cwd(), 'logs');
this.ensureLogDir();
this.logFile = path.join(this.logDir, `pdf-generator-${this.getDateString()}.log`);
}
ensureLogDir() {
if (!fs.existsSync(this.logDir)) {
fs.mkdirSync(this.logDir, { recursive: true });
}
}
getDateString() {
const now = new Date();
return now.toISOString().split('T')[0]; // YYYY-MM-DD
}
getTimestamp() {
return new Date().toISOString();
}
formatMessage(level, message, data = {}) {
const timestamp = this.getTimestamp();
const dataStr = Object.keys(data).length > 0 ? ` ${JSON.stringify(data)}` : '';
return `[${timestamp}] [${level.toUpperCase()}] ${message}${dataStr}`;
}
writeToFile(message) {
try {
fs.appendFileSync(this.logFile, message + '\n');
} catch (error) {
console.error('写入日志文件失败:', error.message);
}
}
info(message, data = {}) {
const formattedMessage = this.formatMessage('info', message, data);
console.log(`ℹ️ ${message}`, data);
this.writeToFile(formattedMessage);
}
success(message, data = {}) {
const formattedMessage = this.formatMessage('success', message, data);
console.log(`✅ ${message}`, data);
this.writeToFile(formattedMessage);
}
warn(message, data = {}) {
const formattedMessage = this.formatMessage('warn', message, data);
console.warn(`⚠️ ${message}`, data);
this.writeToFile(formattedMessage);
}
error(message, data = {}) {
const formattedMessage = this.formatMessage('error', message, data);
console.error(`❌ ${message}`, data);
this.writeToFile(formattedMessage);
}
logPDFSuccess(fileName, filePath, fileSize) {
const sizeKB = (fileSize / 1024).toFixed(2);
const message = `PDF生成成功: ${fileName}`;
const data = { fileName, filePath, fileSize, sizeKB: `${sizeKB}KB` };
const formattedMessage = this.formatMessage('success', message, data);
console.log(`📄 ${message} (${sizeKB}KB)`);
this.writeToFile(formattedMessage);
}
}
module.exports = new Logger();
功能说明:
- 统一的日志记录接口
- 文件和控制台双重输出
- 不同级别的日志(info、success、warn、error)
- 时间戳和格式化输出
8. lib/iframeProcessor.js - iframe处理逻辑
处理包含iframe的复杂页面结构:
const puppeteer = require('puppeteer');
const { generatePDF } = require('./pdfGenerator');
const logger = require('./logger');
const { getBrowserOptions, getPlatformInfo } = require('./browserConfig');
/**
* 处理iframe并生成PDF
* @param {Object} options - 配置选项
* @param {string} options.url - 页面URL
* @param {Array} options.cookies - Cookie数组
* @param {string} options.textSelector - 文本选择器
* @param {string} options.checkboxSelector - 复选框选择器
* @param {Array} options.checkboxIndexes - 复选框索引
* @param {string} options.outputDir - 输出目录
* @param {boolean} options.headless - 是否无头模式
* @param {number} options.index - 任务索引
* @returns {Promise<Object>} 处理结果
*/
async function processIframeAndGeneratePDF(options) {
const {
url,
cookies = [],
textSelector = '.x-text',
checkboxSelector = '.down-type-container-info .el-checkbox',
checkboxIndexes = [1, 2],
outputDir = '',
headless = true,
index = 0
} = options;
logger.info('开始处理iframe和PDF生成', {
index,
url,
textSelector,
checkboxSelector
});
let browser;
try {
// 启动浏览器 窗口最大化
logger.info('启动浏览器(iframe处理)');
// 获取跨平台浏览器配置
const browserOptions = getBrowserOptions({
headless: false,
args: [
'--window-size=2800,1200',
'--start-maximized'
]
});
const platformInfo = getPlatformInfo();
// 记录浏览器配置信息
logger.info('浏览器配置(iframe处理)', {
platform: platformInfo.platform,
arch: platformInfo.arch,
executablePath: browserOptions.executablePath || '使用默认路径',
chromeAvailable: platformInfo.chromeAvailable
});
browser = await puppeteer.launch(browserOptions);
logger.success('浏览器启动成功(iframe处理)');
const page = await browser.newPage();
// 设置窗口大小
await page.setViewport({ width: 1500, height: 1200 });
// 设置Cookie
if (cookies && cookies.length > 0) {
logger.info('设置Cookie(iframe处理)', { count: cookies.length });
await page.setCookie(...cookies);
}
// 访问页面
logger.info('访问页面(iframe处理)', { url });
await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });
// 等待页面加载
await sleep(3000);
// 查找iframe
const iframe = await page.$('iframe');
if (!iframe) {
logger.warn('未找到iframe,直接处理页面');
return await generatePDF({
url,
cookies,
textSelector,
checkboxSelector,
checkboxIndexes,
outputDir,
headless
});
}
logger.info('找到iframe,开始处理');
// 获取iframe内容
const frame = await iframe.contentFrame();
if (!frame) {
throw new Error('无法获取iframe内容');
}
// 等待iframe内容加载
await sleep(2000);
// 在iframe中查找文本元素
const textElement = await frame.$(textSelector);
if (!textElement) {
throw new Error(`在iframe中未找到文本元素: ${textSelector}`);
}
// 获取文件名
const fileName = await textElement.evaluate(el => el.textContent.trim());
logger.info('获取到文件名(iframe)', { fileName });
// 在iframe中查找复选框
const checkboxes = await frame.$$(checkboxSelector);
if (checkboxes.length === 0) {
throw new Error(`在iframe中未找到复选框: ${checkboxSelector}`);
}
logger.info('找到复选框(iframe)', { count: checkboxes.length });
// 生成PDF
const result = { files: [] };
try {
// 取消选择所有复选框
for (let i = 0; i < checkboxes.length; i++) {
const isChecked = await checkboxes[i].evaluate(el => el.checked);
if (isChecked) {
await checkboxes[i].click();
await sleep(500);
}
}
// 生成第一个PDF(不包含答案)
const pdfBuffer = await page.pdf({
format: 'A4',
printBackground: false,
displayHeaderFooter: false
});
const filePath1 = path.join(outputDir, `${fileName}.pdf`);
fs.writeFileSync(filePath1, pdfBuffer);
const fileSize = fs.statSync(filePath1).size;
logger.logPDFSuccess(fileName, filePath1, fileSize);
result.files.push(filePath1);
// 生成第二个PDF(包含答案)
if (checkboxIndexes.length > 0) {
// 选择指定的复选框
for (const index of checkboxIndexes) {
if (index <= checkboxes.length) {
const checkbox = checkboxes[index - 1];
const isChecked = await checkbox.evaluate(el => el.checked);
if (!isChecked) {
await checkbox.click();
await sleep(500);
}
}
}
// 等待页面更新
await sleep(1000);
// 生成包含答案的PDF
const pdfBuffer2 = await page.pdf({
format: 'A4',
printBackground: false,
displayHeaderFooter: false
});
const filePath2 = path.join(outputDir, `${fileName}-答案.pdf`);
fs.writeFileSync(filePath2, pdfBuffer2);
const fileSize2 = fs.statSync(filePath2).size;
logger.logPDFSuccess(`${fileName}-答案`, filePath2, fileSize2);
result.files.push(filePath2);
}
logger.success('iframe PDF生成完成', {
fileName,
files: result.files.length,
totalSize: result.files.reduce((sum, file) => sum + fs.statSync(file).size, 0)
});
return result;
} catch (error) {
logger.error('iframe PDF生成失败', { error: error.message });
throw error;
}
} catch (error) {
logger.error('iframe处理失败', { error: error.message, url });
throw error;
} finally {
if (browser) {
await browser.close();
logger.info('浏览器已关闭(iframe处理)');
}
}
}
module.exports = { processIframeAndGeneratePDF };
功能说明:
- 处理包含iframe的复杂页面
- 在iframe内部查找和操作元素
- 处理跨框架的DOM操作
- 生成PDF文件
9. lib/logRunSummary.js - 运行总结
生成运行结果的总结报告:
const logger = require('./logger');
/**
* 记录运行总结
* @param {Object} result - 运行结果
*/
function logRunSummary(result) {
const { total, success, failed, duration, results } = result;
logger.info('=== 运行总结 ===');
logger.info(`总任务数: ${total}`);
logger.info(`成功: ${success}`);
logger.info(`失败: ${failed}`);
logger.info(`成功率: ${((success / total) * 100).toFixed(2)}%`);
logger.info(`总耗时: ${(duration / 1000).toFixed(2)} 秒`);
if (failed > 0) {
logger.warn('失败的任务:');
results.filter(r => !r.success).forEach(r => {
logger.warn(` ${r.index}. ${r.url} - ${r.error}`);
});
}
logger.info('=== 总结结束 ===');
}
module.exports = { logRunSummary };
功能说明:
- 生成运行结果统计
- 计算成功率和耗时
- 记录失败任务详情
📊 lib目录架构总结
lib目录的代码架构遵循以下设计原则:
🏗️ 架构设计原则
- 模块化设计: 每个文件负责特定功能
- 跨平台兼容: 支持Windows、macOS、Linux
- 错误处理: 完善的错误捕获和处理机制
- 日志记录: 统一的日志系统
- 配置管理: 灵活的配置选项
- 资源管理: 自动清理浏览器资源
📁 文件依赖关系
lib/index.js (入口)
↓
lib/batchProcessor.js (核心协调)
↓
lib/request.js (HTTP请求)
↓
lib/iframeProcessor.js (iframe处理)
↓
lib/pdfGenerator.js (PDF生成)
↓
lib/browserConfig.js (浏览器配置)
↓
lib/genCookies.js (Cookie处理)
↓
lib/logger.js (日志系统)
↓
lib/logRunSummary.js (运行总结)
🔄 执行流程
- 入口:
lib/index.js作为API入口点 - 协调:
lib/batchProcessor.js协调整个流程 - 数据获取:
lib/request.js获取试卷列表 - 页面处理:
lib/iframeProcessor.js处理复杂页面 - PDF生成:
lib/pdfGenerator.js生成PDF文件 - 浏览器管理:
lib/browserConfig.js管理浏览器配置 - Cookie处理:
lib/genCookies.js处理认证Cookie - 日志记录:
lib/logger.js记录所有操作 - 结果总结:
lib/logRunSummary.js生成运行报告
🎯 核心特性
- 跨平台支持: 自动检测Chrome路径
- 错误恢复: 单个任务失败不影响整体流程
- 资源管理: 自动清理浏览器实例
- 详细日志: 完整的操作记录
- 灵活配置: 支持多种参数配置
- 批量处理: 高效的并发处理机制
- Cookie处理: 智能的认证Cookie解析和格式化
这个架构确保了代码的可维护性、可扩展性和跨平台兼容性,为用户提供了稳定可靠的PDF批量下载服务。