Implement v2 API for stateful sessions and add engagement metrics support
This commit is contained in:
204
api.js
204
api.js
@@ -3,6 +3,8 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { initPool, renderHtml, POOL_SIZE } = require('./browserPool');
|
||||
const v2Routes = require('./v2Routes');
|
||||
const { cleanupExpiredSessions } = require('./db');
|
||||
|
||||
const app = express();
|
||||
const PORT = 3000;
|
||||
@@ -26,7 +28,7 @@ app.use((req, res, next) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
console.log(`\n[${timestamp}] ${req.method} ${req.url}`);
|
||||
|
||||
if (req.method === 'POST' && req.body) {
|
||||
if ((req.method === 'POST' || req.method === 'PATCH') && req.body) {
|
||||
const logBody = { ...req.body };
|
||||
|
||||
// Truncate long base64 strings for readability
|
||||
@@ -57,6 +59,9 @@ app.use((req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// mount v2 api
|
||||
app.use('/v2', v2Routes);
|
||||
|
||||
function normalizeConfig(config) {
|
||||
// Remove null, undefined, and empty string values
|
||||
const normalized = {};
|
||||
@@ -172,8 +177,34 @@ function formatTimestamp(epoch) {
|
||||
return `${hour12}:${minutes} ${ampm} · ${month} ${day}, ${year}`;
|
||||
}
|
||||
|
||||
const TEMPLATE_PATH = path.join(__dirname, 'template.html');
|
||||
const templateHtml = fs.readFileSync(TEMPLATE_PATH, 'utf8');
|
||||
|
||||
function buildEngagementHtml(engagement) {
|
||||
if (!engagement) return '';
|
||||
return `
|
||||
<div class="engagement-bar" role="group">
|
||||
<div class="engagement-item">
|
||||
<svg viewBox="0 0 24 24" class="engagement-icon"><g><path d="M1.751 10c0-4.42 3.584-8 8.005-8h4.366c4.49 0 8.129 3.64 8.129 8.13 0 2.96-1.607 5.68-4.196 7.11l-8.054 4.46v-3.69h-.067c-4.49.1-8.183-3.51-8.183-8.01zm8.005-6c-3.317 0-6.005 2.69-6.005 6 0 3.37 2.77 6.08 6.138 6.01l.351-.01h1.761v2.3l5.087-2.81c1.951-1.08 3.163-3.13 3.163-5.36 0-3.39-2.744-6.13-6.129-6.13H9.756z"></path></g></svg>
|
||||
<span class="engagement-count">${engagement.replies}</span>
|
||||
</div>
|
||||
<div class="engagement-item">
|
||||
<svg viewBox="0 0 24 24" class="engagement-icon"><g><path d="M4.5 3.88l4.432 4.14-1.364 1.46L5.5 7.55V16c0 1.1.896 2 2 2H13v2H7.5c-2.209 0-4-1.79-4-4V7.55L1.432 9.48.068 8.02 4.5 3.88zM16.5 6H11V4h5.5c2.209 0 4 1.79 4 4v8.45l2.068-1.93 1.364 1.46-4.432 4.14-4.432-4.14 1.364-1.46 2.068 1.93V8c0-1.1-.896-2-2-2z"></path></g></svg>
|
||||
<span class="engagement-count">${engagement.retweets}</span>
|
||||
</div>
|
||||
<div class="engagement-item">
|
||||
<svg viewBox="0 0 24 24" class="engagement-icon"><g><path d="M16.697 5.5c-1.222-.06-2.679.51-3.89 2.16l-.805 1.09-.806-1.09C9.984 6.01 8.526 5.44 7.304 5.5c-1.243.07-2.349.78-2.91 1.91-.552 1.12-.633 2.78.479 4.82 1.074 1.97 3.257 4.27 7.129 6.61 3.87-2.34 6.052-4.64 7.126-6.61 1.111-2.04 1.03-3.7.477-4.82-.561-1.13-1.666-1.84-2.908-1.91zm4.187 7.69c-1.351 2.48-4.001 5.12-8.379 7.67l-.503.3-.504-.3c-4.379-2.55-7.029-5.19-8.382-7.67-1.36-2.5-1.41-4.86-.514-6.67.887-1.79 2.647-2.91 4.601-3.01 1.651-.09 3.368.56 4.798 2.01 1.429-1.45 3.146-2.1 4.796-2.01 1.954.1 3.714 1.22 4.601 3.01.896 1.81.846 4.17-.514 6.67z"></path></g></svg>
|
||||
<span class="engagement-count">${engagement.likes}</span>
|
||||
</div>
|
||||
<div class="engagement-item">
|
||||
<svg viewBox="0 0 24 24" class="engagement-icon"><g><path d="M8.75 21V3h2v18h-2zM18 21V8.5h2V21h-2zM4 21l.004-10h2L6 21H4zm9.248 0v-7h2v7h-2z"></path></g></svg>
|
||||
<span class="engagement-count">${engagement.views}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function generateQuoteBuffer(config) {
|
||||
// Build HTML directly with values injected
|
||||
const avatarHtml = config.avatarUrl
|
||||
? `<img src="${config.avatarUrl}" style="width:100%;height:100%;object-fit:cover;" />`
|
||||
: `<div style="width:100%;height:100%;background:rgb(51,54,57);"></div>`;
|
||||
@@ -182,155 +213,21 @@ async function generateQuoteBuffer(config) {
|
||||
? `<div class="tweet-image-container" style="margin-bottom:12px;border-radius:16px;overflow:hidden;border:1px solid rgb(47,51,54);"><img src="${config.imageUrl}" style="width:100%;display:block;" /></div>`
|
||||
: '';
|
||||
|
||||
// Only show engagement bar if all fields provided
|
||||
const engagementHtml = config.engagement ? `
|
||||
<div class="engagement-bar">
|
||||
<div class="engagement-item">
|
||||
<span class="engagement-count">${config.engagement.replies}</span>
|
||||
<span class="engagement-label">Replies</span>
|
||||
</div>
|
||||
<div class="engagement-item">
|
||||
<span class="engagement-count">${config.engagement.retweets}</span>
|
||||
<span class="engagement-label">Reposts</span>
|
||||
</div>
|
||||
<div class="engagement-item">
|
||||
<span class="engagement-count">${config.engagement.likes}</span>
|
||||
<span class="engagement-label">Likes</span>
|
||||
</div>
|
||||
<div class="engagement-item">
|
||||
<span class="engagement-count">${config.engagement.views}</span>
|
||||
<span class="engagement-label">Views</span>
|
||||
</div>
|
||||
</div>
|
||||
` : '';
|
||||
const engagementHtml = buildEngagementHtml(config.engagement);
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: #000;
|
||||
}
|
||||
.square-container {
|
||||
width: calc(100vmin - 20px);
|
||||
height: calc(100vmin - 20px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tweet-container {
|
||||
width: 450px;
|
||||
background-color: #000;
|
||||
padding: 12px 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.tweet-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
margin-right: 12px;
|
||||
background: rgb(51, 54, 57);
|
||||
}
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.display-name {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: rgb(231, 233, 234);
|
||||
line-height: 20px;
|
||||
}
|
||||
.username {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 15px;
|
||||
color: rgb(113, 118, 123);
|
||||
line-height: 20px;
|
||||
}
|
||||
.tweet-text {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 15px;
|
||||
color: rgb(231, 233, 234);
|
||||
line-height: 20px;
|
||||
margin-bottom: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.tweet-time {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 15px;
|
||||
color: rgb(113, 118, 123);
|
||||
}
|
||||
.engagement-bar {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
padding: 12px 0;
|
||||
margin-top: 12px;
|
||||
border-top: 1px solid rgb(47, 51, 54);
|
||||
}
|
||||
.engagement-item {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.engagement-count {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: rgb(231, 233, 234);
|
||||
}
|
||||
.engagement-label {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
color: rgb(113, 118, 123);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="square-container">
|
||||
<div class="tweet-container">
|
||||
<div class="tweet-header">
|
||||
<div class="avatar">${avatarHtml}</div>
|
||||
<div class="user-info">
|
||||
<span class="display-name">${config.displayName}</span>
|
||||
<span class="username">${config.username}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tweet-text">${config.text}</div>
|
||||
${imageHtml}
|
||||
<div class="tweet-time">${config.timestamp}</div>
|
||||
${engagementHtml}
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function fitToSquare() {
|
||||
const square = document.querySelector('.square-container');
|
||||
const tweet = document.querySelector('.tweet-container');
|
||||
const scaleX = square.offsetWidth / tweet.offsetWidth;
|
||||
const scaleY = square.offsetHeight / tweet.offsetHeight;
|
||||
const scale = Math.min(scaleX, scaleY) * 0.95;
|
||||
tweet.style.transform = 'scale(' + scale + ')';
|
||||
}
|
||||
window.onload = fitToSquare;
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
const verifiedBadge = config.verified ? '<svg viewBox="0 0 22 22" class="verified-badge"><g><path d="M20.396 11c-.018-.646-.215-1.275-.57-1.816-.354-.54-.852-.972-1.438-1.246.223-.607.27-1.264.14-1.897-.131-.634-.437-1.218-.882-1.687-.47-.445-1.053-.75-1.687-.882-.633-.13-1.29-.083-1.897.14-.273-.587-.704-1.086-1.245-1.44S11.647 1.62 11 1.604c-.646.017-1.273.213-1.813.568s-.969.854-1.24 1.44c-.608-.223-1.267-.272-1.902-.14-.635.13-1.22.436-1.69.882-.445.47-.749 1.055-.878 1.688-.13.633-.08 1.29.144 1.896-.587.274-1.087.705-1.443 1.245-.356.54-.555 1.17-.574 1.817.02.647.218 1.276.574 1.817.356.54.856.972 1.443 1.245-.224.606-.274 1.263-.144 1.896.13.634.433 1.218.877 1.688.47.443 1.054.747 1.687.878.633.132 1.29.084 1.897-.136.274.586.705 1.084 1.246 1.439.54.354 1.17.551 1.816.569.647-.016 1.276-.213 1.817-.567s.972-.854 1.245-1.44c.604.239 1.266.296 1.903.164.636-.132 1.22-.447 1.68-.907.46-.46.776-1.044.908-1.681s.075-1.299-.165-1.903c.586-.274 1.084-.705 1.439-1.246.354-.54.551-1.17.569-1.816zM9.662 14.85l-3.429-3.428 1.293-1.302 2.072 2.072 4.4-4.794 1.347 1.246z"></path></g></svg>' : '';
|
||||
|
||||
const image = await renderHtml(html, 1000);
|
||||
return image;
|
||||
const html = templateHtml
|
||||
.replace('{{avatarHtml}}', avatarHtml)
|
||||
.replace('{{displayName}}', config.displayName)
|
||||
.replace('{{verifiedBadge}}', verifiedBadge)
|
||||
.replace('{{username}}', config.username)
|
||||
.replace('{{text}}', config.text)
|
||||
.replace('{{imageHtml}}', imageHtml)
|
||||
.replace('{{timestamp}}', config.timestamp)
|
||||
.replace('{{engagementHtml}}', engagementHtml);
|
||||
|
||||
return await renderHtml(html, 1000);
|
||||
}
|
||||
|
||||
// GET endpoint - use query parameters
|
||||
@@ -345,7 +242,8 @@ app.get('/generate', async (req, res) => {
|
||||
text: req.query.text || "No text provided",
|
||||
imageUrl: fixDataUri(req.query.imageUrl) || null,
|
||||
timestamp: timestamp,
|
||||
engagement: null
|
||||
engagement: null,
|
||||
verified: req.query.verified === 'true' || req.query.verified === '1'
|
||||
};
|
||||
|
||||
// Check cache first
|
||||
@@ -433,7 +331,8 @@ app.post('/generate', async (req, res) => {
|
||||
text: req.body.text || "No text provided",
|
||||
imageUrl: fixDataUri(req.body.imageUrl) || null,
|
||||
timestamp: timestamp,
|
||||
engagement: engagement
|
||||
engagement: engagement,
|
||||
verified: req.body.verified === true || req.body.verified === 'true' || req.body.verified === '1'
|
||||
};
|
||||
|
||||
// Check cache first
|
||||
@@ -463,10 +362,12 @@ app.get('/health', (req, res) => {
|
||||
|
||||
// Clear all cache on startup
|
||||
clearCache();
|
||||
cleanupExpiredSessions();
|
||||
|
||||
// Run cleanup every hour
|
||||
setInterval(() => {
|
||||
cleanupOldCache();
|
||||
cleanupExpiredSessions();
|
||||
}, 60 * 60 * 1000);
|
||||
|
||||
// Initialize browser pool then start server
|
||||
@@ -476,6 +377,7 @@ initPool().then(() => {
|
||||
console.log(`Browser pool size: ${POOL_SIZE} (set BROWSER_POOL_SIZE env var to change)`);
|
||||
console.log(`GET: http://localhost:${PORT}/generate?text=Hello&displayName=Test&username=@test×tamp=1735574400`);
|
||||
console.log(`POST: http://localhost:${PORT}/generate`);
|
||||
console.log(`v2 API: POST/GET/PATCH/DELETE http://localhost:${PORT}/v2/quote`);
|
||||
console.log(`Cache cleared on startup, cleanup runs every hour`);
|
||||
});
|
||||
}).catch(err => {
|
||||
|
||||
Reference in New Issue
Block a user