用本地代理 + ZIP 打包 + Excel 命名,优雅批量下载跨域 PDF

本文提供一套可直接落地的方案:前端一键把大量 PDF 批量下载,自动避开跨域限制,并按照 Excel 中的"名称 + pdf名称"精准命名,最终一次性打包成 ZIP 保存,避免多次"另存为"。示例中不包含任何真实域名、文件名或截图,请将占位符替换为你的实际数据。

你将获得

  • 一次性打包 ZIP,避免多次保存弹窗
  • 跨域无感代理,返回 Content-Disposition: attachment
  • Excel 命名:文件名为"承办校名称-pdf名称.pdf"
  • 默认仅绑定本机 127.0.0.1,安全发布

最小项目结构

复制代码
你的项目/
├─ server.mjs                  # 本地下载/打包代理(Node 18+)
├─ test.html                   # 前端触发页面(也可嵌入你的系统)
└─ 承办校设备清单.xlsx           # Excel(默认读取根目录此文件)

Excel 第一张工作表包含列(列名不必完全一致,程序会智能匹配并兜底):

  • 第1列:承办校名称(用于前缀)
  • 另一列:pdf名称(用于命名)
  • 另一列:pdf地址(URL,如 https://example.org/portal/api/public/view?url=...

示例(CSV 展示意图):

csv 复制代码
承办校名称,pdf名称,pdf地址
示例职业技术学院-中职1组,设备与场地信息清单.pdf,https://example.org/portal/api/public/view?url=...
...

后端:本地代理与 ZIP 打包(更安全默认值)

安装与启动(你执行):

bash 复制代码
npm i archiver xlsx
node server.mjs
  • 仅监听 127.0.0.1,避免局域网可达
  • 固定读取根目录的 承办校设备清单.xlsx,不开放任意路径
  • CORS 仅允许本地来源
js 复制代码
// server.mjs (Node 18+)
import http from 'node:http';
import { Readable } from 'node:stream';
import archiver from 'archiver';
import fs from 'node:fs';
import path from 'node:path';
import xlsx from 'xlsx';

const PORT = 8787;
const HOST = '127.0.0.1'; // 仅本机
const EXCEL_FILE = path.join(process.cwd(), '承办校设备清单.xlsx');

function corsHeaders(req) {
  const origin = req.headers.origin;
  const allow = new Set([undefined, 'null', `http://${HOST}:${PORT}`]);
  return { 'Access-Control-Allow-Origin': allow.has(origin) ? (origin || '*') : 'null' };
}

function inferFilenameFromUrl(u) {
  try {
    const x = new URL(u);
    const qp = x.searchParams.get('url');
    const seg = (qp || x.pathname).split('/').filter(Boolean).pop() || 'file.pdf';
    return seg.toLowerCase().endsWith('.pdf') ? seg : seg + '.pdf';
  } catch { return 'file.pdf'; }
}

function filenameFromHeaders(headers, fallback) {
  const cd = headers.get('content-disposition');
  if (cd) {
    const m1 = cd.match(/filename\*=([^;]+)/i);
    if (m1 && m1[1]) {
      try { return decodeURIComponent(m1[1].replace(/^UTF-8''/,'').replace(/^"|"$/g,'')); } catch {}
    }
    const m2 = cd.match(/filename="?([^";]+)"?/i);
    if (m2 && m2[1]) return m2[1];
  }
  return fallback;
}

function normalizeFilename(name) {
  const base = String(name || '').trim().replace(/[\/:*?"<>|\\]+/g, '_');
  return /\.pdf$/i.test(base) ? base : (base ? `${base}.pdf` : 'file.pdf');
}

function loadNameMapFromExcel(excelPath) {
  const wb = xlsx.readFile(excelPath);
  const sheet = wb.Sheets[wb.SheetNames[0]];
  const rows = xlsx.utils.sheet_to_json(sheet, { defval: '' });
  const map = new Map();
  if (!rows.length) return map;

  const keys = Object.keys(rows[0]);
  const organizerKey = keys[0]; // 第一列:承办校名称
  let urlKey = keys.find(k => /pdf/i.test(k) && /(地址|链接|url)/i.test(k));
  let nameKey = keys.find(k => /pdf/i.test(k) && /(名|名称)/.test(k));
  if (!urlKey) urlKey = keys.find(k => /(地址|链接|url)/i.test(k)) || keys[0];
  if (!nameKey) nameKey = keys.find(k => /(名|名称)/.test(k)) || keys[1] || keys[0];

  for (const r of rows) {
    const url = String(r[urlKey] || '').trim();
    const organizer = String(r[organizerKey] || '').trim();
    const baseName = String(r[nameKey] || '').trim();
    const joined = organizer ? `${organizer}-${baseName}` : baseName;
    const finalName = normalizeFilename(joined);
    if (url) map.set(url, finalName);
  }
  return map;
}

const server = http.createServer(async (req, res) => {
  try {
    const u = new URL(req.url, `http://${HOST}:${PORT}`);

    // 预检
    if (req.method === 'OPTIONS') {
      res.writeHead(204, {
        ...corsHeaders(req),
        'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
        'Access-Control-Allow-Headers': 'Content-Type',
        'Access-Control-Max-Age': '86400',
      });
      return res.end();
    }

    // 单文件代理:GET /dl?url=...
    if (u.pathname === '/dl') {
      const target = u.searchParams.get('url');
      if (!target || !(target.startsWith('http://') || target.startsWith('https://'))) {
        res.writeHead(400, corsHeaders(req)); return res.end('Bad url');
      }
      const upstream = await fetch(target, { redirect: 'follow' });
      if (!upstream.ok) { res.writeHead(502, corsHeaders(req)); return res.end('Upstream ' + upstream.status); }

      const fallback = inferFilenameFromUrl(target);
      const name = filenameFromHeaders(upstream.headers, fallback);
      const type = upstream.headers.get('content-type') || 'application/octet-stream';
      res.writeHead(200, {
        ...corsHeaders(req),
        'Content-Type': type,
        'Content-Disposition': `attachment; filename="${name}"`,
        'Cache-Control': 'no-store',
        'Access-Control-Expose-Headers': 'Content-Disposition',
      });

      if (Readable.fromWeb && upstream.body && typeof upstream.body.getReader === 'function') {
        Readable.fromWeb(upstream.body).pipe(res);
      } else {
        res.end(Buffer.from(await upstream.arrayBuffer()));
      }
      return;
    }

    // ZIP:POST /zip-excel { urls: string[], name?: string }
    if (u.pathname === '/zip-excel' && req.method === 'POST') {
      const body = await readJson(req);
      const inputUrls = Array.isArray(body?.urls) ? body.urls.filter(Boolean) : [];
      const zipName = (body?.name && body.name.trim()) || 'pdf-batch.zip';
      if (!inputUrls.length) { res.writeHead(400, corsHeaders(req)); return res.end('No urls'); }
      if (!fs.existsSync(EXCEL_FILE)) { res.writeHead(404, corsHeaders(req)); return res.end('Excel not found'); }

      const nameMap = loadNameMapFromExcel(EXCEL_FILE);

      res.writeHead(200, {
        ...corsHeaders(req),
        'Content-Type': 'application/zip',
        'Content-Disposition': `attachment; filename="${zipName}"`,
        'Cache-Control': 'no-store',
      });

      const archive = archiver('zip', { zlib: { level: 9 } });
      archive.on('error', e => { try { res.destroy(e); } catch {} });
      archive.pipe(res);

      const used = new Set();
      const ensureUnique = (n) => {
        let base = /\.pdf$/i.test(n) ? n : `${n}.pdf`;
        if (!used.has(base)) { used.add(base); return base; }
        let i = 2; let cur = base.replace(/\.pdf$/i, ` (${i}).pdf`);
        while (used.has(cur)) { i++; cur = base.replace(/\.pdf$/i, ` (${i}).pdf`); }
        used.add(cur); return cur;
      };

      for (const target of inputUrls) {
        try {
          if (!(typeof target === 'string' && (target.startsWith('http://') || target.startsWith('https://')))) continue;
          const upstream = await fetch(target, { redirect: 'follow' }); if (!upstream.ok) continue;
          const fallback = inferFilenameFromUrl(target);
          const preferred = nameMap.get(target) || fallback;
          const name = ensureUnique(preferred);
          const stream = (Readable.fromWeb && upstream.body && typeof upstream.body.getReader === 'function')
            ? Readable.fromWeb(upstream.body)
            : Readable.from(Buffer.from(await upstream.arrayBuffer()));
          archive.append(stream, { name });
        } catch {}
      }

      archive.finalize(); return;
    }

    res.writeHead(404, corsHeaders(req)); res.end('Not Found');
  } catch (e) {
    res.writeHead(500, corsHeaders(req)); res.end('Error: ' + (e?.message || String(e)));
  }
});

server.listen(PORT, HOST, () => {
  console.log(`ZIP by Excel  http://${HOST}:${PORT}/zip-excel  (POST {"urls":[...]})`);
  console.log(`Single file   http://${HOST}:${PORT}/dl?url=<encoded>`);
});

function readJson(req) {
  return new Promise(resolve => {
    let raw = ''; req.setEncoding('utf8');
    req.on('data', c => { raw += c; });
    req.on('end', () => { try { resolve(JSON.parse(raw || '{}')); } catch { resolve({}); } });
    req.on('error', () => resolve({}));
  });
}

前端:一键打包(按 Excel 命名)

你可以在任意页面发起请求,也可用一个独立 HTML 页。核心调用如下:

html 复制代码
<button id="zipExcel">按Excel命名ZIP下载</button>
<script>
  const RAW_URLS = [
    // 用你的真实 PDF URL 列表(示例):
    // 'https://example.org/portal/api/public/view?url=encoded-key-1',
    // 'https://example.org/portal/api/public/view?url=encoded-key-2',
  ];

  async function downloadZipByExcel() {
    const name = `pdf-by-excel-${new Date().toISOString().slice(0,19).replace(/[-:T]/g,'')}.zip`;
    const resp = await fetch('http://127.0.0.1:8787/zip-excel', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ urls: RAW_URLS, name }),
    });
    if (!resp.ok) throw new Error('HTTP ' + resp.status);
    const blob = await resp.blob();
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = name;
    document.body.appendChild(a); a.click(); a.remove();
  }

  document.getElementById('zipExcel').onclick = downloadZipByExcel;
</script>

使用步骤

  1. 在项目根目录放置 承办校设备清单.xlsx(第一列承办校名称,另有 pdf名称、pdf地址列)

  2. 启动代理(你执行):

    bash 复制代码
    npm i archiver xlsx
    node server.mjs
  3. 前端点击"按Excel命名ZIP下载",保存返回的 ZIP


安全与隐私(发布建议)

  • 文章与代码不包含真实域名、URL、Excel 文件名(均为通用占位)
  • 服务仅绑定 127.0.0.1,默认不可被局域网/公网访问
  • Excel 路径固定为根目录 承办校设备清单.xlsx,未开放任意路径
  • 发布时不要附上真实 URL 列表与含真实名称的截图;示例中使用 https://example.org/...

如需进一步强化(可选):

  • 给代理加限流/超时
  • 给 URL 拉取增加并发数控制与失败日志
  • ZIP 中附加 errors.txt 记录失败条目

相关推荐
universe_015 分钟前
day25|学习前端js
前端·笔记
Zuckjet10 分钟前
V8 引擎的性能魔法:JSON 序列化的 2 倍速度提升之路
前端·chrome·v8
MrSkye10 分钟前
🔥React 新手必看!useRef 竟然不能触发 onChange?原来是这个原因!
前端·react.js·面试
wayman_he_何大民17 分钟前
初识机器学习算法 - AUM时间序列分析
前端·人工智能
juejin_cn18 分钟前
前端使用模糊搜索fuse.js和拼音搜索pinyin-match提升搜索体验
前端
....49242 分钟前
Vue3 + Element Plus 实现可搜索、可折叠、可拖拽的部门树组件
前端·javascript·vue.js
teeeeeeemo1 小时前
如何做HTTP优化
前端·网络·笔记·网络协议·http
范范之交1 小时前
JavaScript基础语法two
开发语言·前端·javascript
界面开发小八哥2 小时前
DevExtreme Angular UI控件更新:引入全新严格类型配置组件
前端·ui·界面控件·angular.js·devexpress
bitbitDown2 小时前
重构缓存时踩的坑:注释了三行没用的代码却导致白屏
前端·javascript·vue.js