如何自动按计划生成并通过电子邮件发送 PDF 报告

发布: (2026年2月26日 GMT+8 16:18)
6 分钟阅读
原文: Dev.to

Source: Dev.to

手动生成 PDF 报告很容易。自动化完成——获取实时数据,将其渲染到品牌化的 HTML 模板中,捕获为像素级完美的 PDF,并发送邮件给相关方——正是大多数方案会出现问题的地方。

常见阻碍: 在 cron 任务中使用 Puppeteer 或 Playwright。计划任务中的无头浏览器会悄然失败,消耗内存,并且在容器重启后无法存活。

以下是稳健的流水线

cron → fetch data → render HTML → PageBolt PDF → email

无需无头浏览器依赖。 PageBolt 负责 PDF 渲染。

npm install node-cron nodemailer

1. 调度器 (node‑cron)

import cron from "node-cron";
import nodemailer from "nodemailer";

// Runs every Monday at 8 am
cron.schedule("0 8 * * 1", async () => {
  console.log("Generating weekly report...");
  await generateAndSendReport();
});

2. 主工作流

async function generateAndSendReport() {
  // 1️⃣ Fetch your data
  const data = await fetchReportData();

  // 2️⃣ Render HTML
  const html = renderReportHtml(data);

  // 3️⃣ Capture as PDF (PageBolt)
  const pdfRes = await fetch("https://pagebolt.dev/api/v1/pdf", {
    method: "POST",
    headers: {
      "x-api-key": process.env.PAGEBOLT_API_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ html }),
  });

  const pdfBuffer = Buffer.from(await pdfRes.arrayBuffer());

  // 4️⃣ Email it
  await emailReport(pdfBuffer, data.weekLabel);
}

3. 数据获取

import { db } from "./db.js"; // your DB client

async function fetchReportData() {
  const weekStart = new Date();
  weekStart.setDate(weekStart.getDate() - 7);

  const [revenue, signups, churn] = await Promise.all([
    db.payments.sum({ where: { createdAt: { gte: weekStart } } }),
    db.users.count({ where: { createdAt: { gte: weekStart } } }),
    db.subscriptions.count({
      where: { canceledAt: { gte: weekStart }, status: "canceled" },
    }),
  ]);

  const topPages = await db.pageViews.groupBy({
    by: ["path"],
    _sum: { views: true },
    orderBy: { _sum: { views: "desc" } },
    take: 5,
    where: { createdAt: { gte: weekStart } },
  });

  return {
    weekLabel: `Week of ${weekStart.toLocaleDateString()}`,
    revenue: (revenue._sum.amount / 100).toFixed(2),
    signups,
    churn,
    topPages: topPages.map((p) => ({
      path: p.path,
      views: p._sum.views,
    })),
  };
}

4. HTML 渲染

function renderReportHtml(data) {
  const topPagesRows = data.topPages
    .map(
      (p, i) => `
        ${i + 1}
        ${p.path}
        ${p.views.toLocaleString()}
      `
    )
    .join("");

  return `
    <style>
      body { font-family: -apple-system, sans-serif; color: #111; max-width: 720px; margin: 40px auto; padding: 0 24px; }
      h1 { font-size: 22px; margin-bottom: 4px; }
      .subtitle { color: #666; margin-bottom: 32px; }
      .metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 40px; }
      .metric { background: #f5f5f5; border-radius: 8px; padding: 20px; }
      .metric-value { font-size: 28px; font-weight: 700; }
      .metric-label { color: #666; font-size: 13px; margin-top: 4px; }
      table { width: 100%; border-collapse: collapse; }
      th { text-align: left; border-bottom: 2px solid #111; padding: 8px 0; font-size: 13px; }
      td { padding: 10px 0; border-bottom: 1px solid #eee; font-size: 14px; }
      h2 { font-size: 16px; margin: 32px 0 12px; }
    </style>

    <h2>Weekly Report</h2>
    <p>${data.weekLabel}</p>

    <div class="metrics">
      <div class="metric">
        <div class="metric-value">$${data.revenue}</div>
        <div class="metric-label">Revenue</div>
      </div>
      <div class="metric">
        <div class="metric-value">${data.signups}</div>
        <div class="metric-label">New signups</div>
      </div>
      <div class="metric">
        <div class="metric-value">${data.churn}</div>
        <div class="metric-label">Cancellations</div>
      </div>
    </div>

    <h2>Top pages</h2>
    <table>
      <thead>
        <tr><th>#</th><th>Page</th><th>Views</th></tr>
      </thead>
      <tbody>
        ${topPagesRows}
      </tbody>
    </table>
  `;
}

5. 通过电子邮件发送 PDF

const transporter = nodemailer.createTransport({
  host: process.env.SMTP_HOST,
  port: 587,
  auth: {
    user: process.env.SMTP_USER,
    pass: process.env.SMTP_PASS,
  },
});

async function emailReport(pdfBuffer, weekLabel) {
  const recipients = process.env.REPORT_RECIPIENTS.split(",");

  await transporter.sendMail({
    from: "reports@yourapp.com",
    to: recipients,
    subject: `Weekly Report — ${weekLabel}`,
    text: "Your weekly report is attached.",
    attachments: [
      {
        filename: `weekly-report-${Date.now()}.pdf`,
        content: pdfBuffer,
        contentType: "application/pdf",
      },
    ],
  });

  console.log(`Report sent to ${recipients.join(", ")}`);
}

6. 针对每个用户计费周期的个性化报告

// Called by a daily cron at midnight
cron.schedule("0 0 * * *", async () => {
  const today = new Date().getDate();

  // Find users whose billing day matches today
  const users = await db.users.findMany({
    where: { billingDay: today, reportEnabled: true },
  });

  await Promise.allSettled(
    users.map(async (user) => {
      const data = await fetchUserReportData(user.id);
      const html = renderUserReportHtml(user, data);

      const pdfRes = await fetch("https://pagebolt.dev/api/v1/pdf", {
        method: "POST",
        headers: {
          "x-api-key": process.env.PAGEBOLT_API_KEY,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ html }),
      });

      const pdf = Buffer.from(await pdfRes.arrayBuffer());
      await emailReport(pdf, user.email, "Your monthly summary");
    })
  );
});

Promise.allSettled 确保一次失败不会中止整个批处理——每个用户的报告相互独立。

7. 在生产环境运行调度器

# 使用 PM2(或任何进程管理器)
pm2 start report-scheduler.js --name weekly-reports

就这么简单!现在你拥有一个完全自动化、无需无头浏览器的流水线,它可以:

  1. 按计划获取实时数据
  2. 渲染精美的 HTML 报告
  3. 通过 PageBolt 的 API 将其转换为 PDF
  4. 将 PDF 通过电子邮件发送给相应的收件人

随意调整 HTML、数据源或邮件布局,以匹配你的品牌。祝报表愉快!

// handler.js — 由 EventBridge 规则触发:cron(0 8 ? * MON *)
export const handler = async () => {
  await generateAndSendReport();
  return { statusCode: 200 };
};

Lambda 包中不包含浏览器二进制文件。PDF 生成仅是一次外部 HTTP 请求——完全在 Lambda 的内存和超时限制范围内。

免费试用 — 每月 100 次请求,无需信用卡。→ 2 分钟快速上手

0 浏览
Back to Blog

相关文章

阅读更多 »