如何自动按计划生成并通过电子邮件发送 PDF 报告
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
就这么简单!现在你拥有一个完全自动化、无需无头浏览器的流水线,它可以:
- 按计划获取实时数据。
- 渲染精美的 HTML 报告。
- 通过 PageBolt 的 API 将其转换为 PDF。
- 将 PDF 通过电子邮件发送给相应的收件人。
随意调整 HTML、数据源或邮件布局,以匹配你的品牌。祝报表愉快!
// handler.js — 由 EventBridge 规则触发:cron(0 8 ? * MON *)
export const handler = async () => {
await generateAndSendReport();
return { statusCode: 200 };
};
Lambda 包中不包含浏览器二进制文件。PDF 生成仅是一次外部 HTTP 请求——完全在 Lambda 的内存和超时限制范围内。
免费试用 — 每月 100 次请求,无需信用卡。→ 2 分钟快速上手