Batch-Generating WeChat Read's World Book Day Medals with Puppeteer
Background
As everyone knows, April 23 is World Book Day, and WeChat Read ran another campaign this year: generating a unique medal for every user.
How unique? Each medal carries two numbers. The one in the top-left is how many years the user has been on WeChat Read, and the one in the bottom-right is how many books they’ve read over those years.
For example, I’ve been on it for 8 years and read 230 books, so my medal looks like this 👇 (embarrassing, I really haven’t read much

The WeChat Read app has been around for 10 years in total, so the top-left number ranges from 1 to 10.
Of course, a person can read a lot of books, so we set a cap: anyone who has read more than 1000 books gets 999+.
So the most prestigious medal looks like this:

The medals are needed in 2 sizes, so the possible combinations are:
10 years * 1000 books * 2 sizes = 20000 medals
At that scale, if a designer drew them by hand, they’d still be at it by next year’s April 23.
So I threw together a tool to mass-produce them and free up the humans.
The main tools:
- Node.js: the runtime
- Puppeteer: page rendering and screenshots
- EJS: templating
- Sharp: image processing and optimization
- Yargs: building the command-line interface
1. Expressing the Design Structure the Right Way
2. Template Rendering
With the EJS template engine, we can turn the medal design into a template:
<div class="diagram">
<div class="circle">
<div class="rectangle">
<div class="number-image-container">
<!-- Numbers rendered dynamically -->
</div>
</div>
</div>
</div>
3. Taking Screenshots with Puppeteer
Puppeteer is a Node.js library that provides a high-level API for controlling Chrome/Chromium over the DevTools Protocol. It plays a key role in this project:
- Launch a headless browser instance
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
- Set up the canvas
const page = await browser.newPage();
await page.setViewport({
width: w,
height: h,
deviceScaleFactor: scale
});
- Load the HTML
await page.setContent(html, {
waitUntil: 'networkidle0',
timeout: waitTime
});
- Take the screenshot
const screenshotBuffer = await page.screenshot({
type: 'png',
omitBackground: true,
encoding: 'binary',
fromSurface: true
});
4. Generating Medals Faster
To speed up generation, we built parallelism in at several levels:
- Worker pool
const workers = Array.from({ length: parallelCount }, async () => {
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
const page = await browser.newPage();
await page.setViewport({ width: w, height: h, deviceScaleFactor });
return { browser, page, busy: false };
});
- Task dispatch
const processTask = async (worker, number) => {
worker.busy = true;
try {
const result = await createMedal(
worker.page,
year,
number,
worker.index,
quality,
compressionLevel,
waitTime
);
return result;
} finally {
worker.busy = false;
}
};
- Dynamic load balancing
const getIdleWorker = () => workers.find(w => !w.busy);
const allWorkersIdle = () => workers.every(w => !w.busy);
- Progress tracking
const updateProgress = (success = true) => {
if (success) {
completed++;
} else {
failed++;
}
if ((completed + failed) % 10 === 0 || (completed + failed) === total) {
console.log(`Progress: ${completed}/${total} (${Math.floor(completed/total*100)}%), failed: ${failed}`);
}
};
5. Performance Optimizations
-
Resource management
- Reuse browser instances
- Adjust parallelism dynamically
- Free memory promptly
-
Rendering optimizations
- Use the
networkidle0wait strategy - Tune the page load timeout
- Keep memory usage in check
- Use the
-
Concurrency control
- Avoid resource contention
- Dispatch tasks dynamically
- Retry on errors
We chose networkidle0 for this project because it:
- Makes sure every image asset is fully loaded
- Avoids capturing half-loaded content in screenshots
- Guarantees the quality of the generated medal images
6. Image Compression
The medals were generated, but each one turned out to be 200-400 KB.
A medal weighing a few hundred KB is still too big to show in an H5 page, so we needed to shrink it.
Sharp is a Node.js image processing library, so we put it to work.
- Basic configuration
const sharp = require('sharp');
// Default options
const defaultOptions = {
quality: 80, // Image quality (0-100)
compressionLevel: 9 // Compression level (0-9)
};
- Image processing pipeline
// Process and save the image
await sharp(screenshotBuffer)
.png({
quality: quality, // Controls image quality
compressionLevel: compressionLevel, // Controls compression level
palette: false, // We don't want to reduce colors, so skip palette optimization
effort: 10 // Compression effort (1-10)
})
.toFile(outputPath);
- Compression strategy in detail
-
Quality (quality)
- Range: 0-100
- Higher values mean better quality and larger files
- Lower values mean worse quality and smaller files
- Recommended: 80-90, to balance quality and size
-
Compression level (compressionLevel)
- Range: 0-9
- 0: fastest, but lowest compression ratio
- 9: slowest, but highest compression ratio
- Recommended: 6-9, depending on your needs
-
Batch processing
// Process multiple images in parallel
const processImage = async (buffer, options) => {
return sharp(buffer)
.png(options)
.toBuffer();
};
const results = await Promise.all(
images.map(img => processImage(img, options))
);
- Error handling
try {
await sharp(buffer)
.png(options)
.toFile(outputPath);
} catch (error) {
console.error('Image processing failed:', error);
// Fall back or retry
}
By tuning these parameters, we got:
- 40-60% smaller files
- Images that stay sharp
- 30-50% faster processing
7. Usage Examples
# Generate a single medal
node index.js --year 2023 --number 42
# Generate all medals for a given year
node index.js --year 2023 --all
# Generate in parallel mode
node index.js --year 2023 --all --parallel 4
8. Key Technical Takeaways
-
Template-driven design
- Dynamic medal rendering with the EJS template engine
- Support for custom background images and number styles
- Flexible handling of numbers with different digit counts
-
Automated generation
- Batch-generate every medal for a given year
- Parallel processing for better throughput
- Automatic image compression and optimization
-
Command-line tool
- A friendly command-line interface
- Many configurable options
- Fine-grained control over the generation process
9. Closing Thoughts
This project shows how modern web technology can be applied to image generation. With a sensible architecture and some performance tuning, we built an efficient, reliable medal generator. It saved a lot of work, and it’s a reusable reference for similar needs.