拆分兑换凭证渲染与截图逻辑

This commit is contained in:
yml
2026-05-04 13:27:55 +08:00
parent 37c9dfd119
commit 899d898a8e
5 changed files with 326 additions and 298 deletions
@@ -0,0 +1,31 @@
// @ts-nocheck
import {
BAIDU_BEIJING_TIME_URL,
BEIJING_TIME_PROOF_CLIP,
BEIJING_TIME_PROOF_VIEWPORT,
} from './session-proof-constants.js'
export async function captureBeijingTimeProof(browserContext, { screenshotPath }) {
const proofPage = await browserContext.newPage()
try {
await proofPage.setViewportSize(BEIJING_TIME_PROOF_VIEWPORT)
await proofPage.goto(BAIDU_BEIJING_TIME_URL, { waitUntil: 'domcontentloaded' })
await proofPage.waitForTimeout(3_000)
await proofPage.evaluate(() => {
window.scrollTo(0, 0)
})
await proofPage.screenshot({
path: screenshotPath,
clip: BEIJING_TIME_PROOF_CLIP,
})
return {
screenshotPath,
pageUrl: BAIDU_BEIJING_TIME_URL,
}
} finally {
await proofPage.close().catch(() => null)
}
}
@@ -0,0 +1,168 @@
export function buildProofHtml({ redeemImageBase64, timeImageBase64, redeemMessage, timePageUrl }) {
const capturedAt = new Date().toLocaleString('zh-CN', {
hour12: false,
timeZone: 'Asia/Shanghai',
})
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>兑换与北京时间凭证</title>
<style>
:root {
color-scheme: light;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
background:
radial-gradient(circle at top left, rgba(33, 150, 243, 0.16), transparent 28%),
linear-gradient(180deg, #edf4ff 0%, #f6f8fc 52%, #eef1f7 100%);
color: #142033;
}
.sheet {
width: 1480px;
margin: 0 auto;
padding: 30px 24px 28px;
}
.hero {
display: flex;
justify-content: space-between;
gap: 20px;
align-items: flex-end;
padding: 0 6px 18px;
}
.eyebrow {
margin: 0 0 12px;
color: #1c78d0;
font-size: 13px;
letter-spacing: 0.2em;
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: 44px;
line-height: 1.08;
}
.summary {
margin: 14px 0 0;
font-size: 18px;
line-height: 1.7;
color: #52627a;
}
.meta {
min-width: 320px;
padding: 18px 20px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.76);
border: 1px solid rgba(20, 32, 51, 0.08);
box-shadow: 0 18px 48px rgba(29, 55, 90, 0.08);
}
.meta strong,
.meta span {
display: block;
}
.meta strong {
font-size: 13px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #60748d;
}
.meta span {
margin-top: 8px;
font-size: 20px;
color: #142033;
line-height: 1.5;
overflow-wrap: anywhere;
}
.card {
margin-top: 16px;
padding: 20px;
border-radius: 28px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(20, 32, 51, 0.08);
box-shadow: 0 24px 80px rgba(24, 41, 72, 0.1);
}
.card h2 {
margin: 0;
font-size: 30px;
}
.card p {
margin: 8px 0 0;
color: #5a6b83;
line-height: 1.65;
font-size: 16px;
}
.preview {
margin-top: 14px;
overflow: hidden;
border-radius: 20px;
border: 1px solid rgba(20, 32, 51, 0.08);
background: #dfe7f3;
}
.preview img {
display: block;
width: 100%;
height: auto;
}
.preview--time {
max-height: 500px;
}
.preview--time img {
object-fit: cover;
object-position: top center;
}
.url {
margin-top: 10px;
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
font-size: 14px;
color: #58708f;
overflow-wrap: anywhere;
}
</style>
</head>
<body>
<main class="sheet">
<section class="hero">
<div>
<p class="eyebrow">Tencent Redeem Proof</p>
<h1>兑换截图与北京时间截图</h1>
<p class="summary">用于留存兑换结果与北京时间检索页的组合凭证。</p>
</div>
<div class="meta">
<strong>Captured At</strong>
<span>${escapeHtml(capturedAt)}</span>
</div>
</section>
<section class="card">
<h2>兑换结果截图</h2>
<p>${escapeHtml(redeemMessage || '兑换完成')}</p>
<div class="preview">
<img src="data:image/png;base64,${redeemImageBase64}" alt="兑换结果截图" />
</div>
</section>
<section class="card">
<h2>百度检索“北京时间”截图</h2>
<p>单独标签页打开百度搜索结果并截图。</p>
<div class="url">${escapeHtml(timePageUrl)}</div>
<div class="preview preview--time">
<img src="data:image/png;base64,${timeImageBase64}" alt="北京时间截图" />
</div>
</section>
</main>
</body>
</html>`
}
export function escapeHtml(value) {
return String(value || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
}
@@ -0,0 +1,108 @@
// @ts-nocheck
import fs from 'node:fs/promises'
import { buildProofHtml } from './session-proof-html.js'
export async function showResultDialog(page, message) {
await page.evaluate((text) => {
document.querySelectorAll('iframe').forEach((element) => element.remove())
document.querySelectorAll('.pop').forEach((element) => {
element.style.display = 'none'
})
let card = document.getElementById('codex-redeem-result')
if (!card) {
card = document.createElement('div')
card.id = 'codex-redeem-result'
card.innerHTML = `
<div class="codex-redeem-result__eyebrow">Tencent Browser Session</div>
<div class="codex-redeem-result__title">兑换结果</div>
<div class="codex-redeem-result__message"></div>
`
document.body.appendChild(card)
}
const messageNode = card.querySelector('.codex-redeem-result__message')
if (messageNode) {
messageNode.textContent = text
}
Object.assign(card.style, {
position: 'fixed',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
zIndex: '99999',
width: 'min(560px, calc(100vw - 80px))',
padding: '32px 36px',
borderRadius: '24px',
background: 'rgba(10, 18, 28, 0.92)',
boxShadow: '0 24px 80px rgba(0, 0, 0, 0.35)',
border: '1px solid rgba(109, 241, 202, 0.25)',
color: '#f4fbff',
fontFamily: '"PingFang SC", "Microsoft YaHei", sans-serif',
})
const styleId = 'codex-redeem-result-style'
if (!document.getElementById(styleId)) {
const style = document.createElement('style')
style.id = styleId
style.textContent = `
#codex-redeem-result .codex-redeem-result__eyebrow {
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #6df1ca;
margin-bottom: 12px;
}
#codex-redeem-result .codex-redeem-result__title {
font-size: 34px;
font-weight: 700;
margin-bottom: 16px;
}
#codex-redeem-result .codex-redeem-result__message {
font-size: 24px;
line-height: 1.5;
color: #ffffff;
}
`
document.head.appendChild(style)
}
window.scrollTo(0, 0)
}, message)
await page.waitForTimeout(600)
}
export async function composeProofScreenshot(
browserContext,
{ outputPath, redeemImagePath, timeImagePath, redeemMessage, timePageUrl },
) {
const [redeemImageBase64, timeImageBase64] = await Promise.all([
fs.readFile(redeemImagePath, 'base64'),
fs.readFile(timeImagePath, 'base64'),
])
const composePage = await browserContext.newPage()
try {
await composePage.setViewportSize({ width: 1520, height: 1900 })
await composePage.setContent(
buildProofHtml({
redeemImageBase64,
timeImageBase64,
redeemMessage,
timePageUrl,
}),
{ waitUntil: 'domcontentloaded' },
)
await composePage.screenshot({ path: outputPath, fullPage: true })
} finally {
await composePage.close().catch(() => null)
}
}
@@ -4,10 +4,10 @@ import fs from 'node:fs/promises'
import {
BAIDU_BEIJING_TIME_URL,
BEIJING_TIME_PROOF_CLIP,
BEIJING_TIME_PROOF_VIEWPORT,
DEFAULT_REDEEM_PROOF_MODE,
} from './session-proof-constants.js'
import { captureBeijingTimeProof } from './session-proof-beijing-time.js'
import { showResultDialog, composeProofScreenshot } from './session-proof-renderer.js'
import { resolveRedeemProofMode, shouldCaptureBeijingTimeProof } from './session-proof-mode.js'
import { buildArtifactPaths } from './session-proof-paths.js'
import { writeRedeemResultFile } from './session-proof-result-writer.js'
@@ -143,299 +143,3 @@ export async function writeRedeemArtifactFiles({
}
export { resolveRedeemProofMode, shouldCaptureBeijingTimeProof }
async function showResultDialog(page, message) {
await page.evaluate((text) => {
document.querySelectorAll('iframe').forEach((element) => element.remove())
document.querySelectorAll('.pop').forEach((element) => {
element.style.display = 'none'
})
let card = document.getElementById('codex-redeem-result')
if (!card) {
card = document.createElement('div')
card.id = 'codex-redeem-result'
card.innerHTML = `
<div class="codex-redeem-result__eyebrow">Tencent Browser Session</div>
<div class="codex-redeem-result__title">兑换结果</div>
<div class="codex-redeem-result__message"></div>
`
document.body.appendChild(card)
}
const messageNode = card.querySelector('.codex-redeem-result__message')
if (messageNode) {
messageNode.textContent = text
}
Object.assign(card.style, {
position: 'fixed',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
zIndex: '99999',
width: 'min(560px, calc(100vw - 80px))',
padding: '32px 36px',
borderRadius: '24px',
background: 'rgba(10, 18, 28, 0.92)',
boxShadow: '0 24px 80px rgba(0, 0, 0, 0.35)',
border: '1px solid rgba(109, 241, 202, 0.25)',
color: '#f4fbff',
fontFamily: '"PingFang SC", "Microsoft YaHei", sans-serif',
})
const styleId = 'codex-redeem-result-style'
if (!document.getElementById(styleId)) {
const style = document.createElement('style')
style.id = styleId
style.textContent = `
#codex-redeem-result .codex-redeem-result__eyebrow {
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #6df1ca;
margin-bottom: 12px;
}
#codex-redeem-result .codex-redeem-result__title {
font-size: 34px;
font-weight: 700;
margin-bottom: 16px;
}
#codex-redeem-result .codex-redeem-result__message {
font-size: 24px;
line-height: 1.5;
color: #ffffff;
}
`
document.head.appendChild(style)
}
window.scrollTo(0, 0)
}, message)
await page.waitForTimeout(600)
}
async function captureBeijingTimeProof(browserContext, { screenshotPath }) {
const proofPage = await browserContext.newPage()
try {
await proofPage.setViewportSize(BEIJING_TIME_PROOF_VIEWPORT)
await proofPage.goto(BAIDU_BEIJING_TIME_URL, { waitUntil: 'domcontentloaded' })
await proofPage.waitForTimeout(3_000)
await proofPage.evaluate(() => {
window.scrollTo(0, 0)
})
await proofPage.screenshot({
path: screenshotPath,
clip: BEIJING_TIME_PROOF_CLIP,
})
return {
screenshotPath,
pageUrl: BAIDU_BEIJING_TIME_URL,
}
} finally {
await proofPage.close().catch(() => null)
}
}
async function composeProofScreenshot(
browserContext,
{ outputPath, redeemImagePath, timeImagePath, redeemMessage, timePageUrl },
) {
const [redeemImageBase64, timeImageBase64] = await Promise.all([
fs.readFile(redeemImagePath, 'base64'),
fs.readFile(timeImagePath, 'base64'),
])
const composePage = await browserContext.newPage()
try {
await composePage.setViewportSize({ width: 1520, height: 1900 })
await composePage.setContent(
buildProofHtml({
redeemImageBase64,
timeImageBase64,
redeemMessage,
timePageUrl,
}),
{ waitUntil: 'domcontentloaded' },
)
await composePage.screenshot({ path: outputPath, fullPage: true })
} finally {
await composePage.close().catch(() => null)
}
}
function buildProofHtml({ redeemImageBase64, timeImageBase64, redeemMessage, timePageUrl }) {
const capturedAt = new Date().toLocaleString('zh-CN', {
hour12: false,
timeZone: 'Asia/Shanghai',
})
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>兑换与北京时间凭证</title>
<style>
:root {
color-scheme: light;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
background:
radial-gradient(circle at top left, rgba(33, 150, 243, 0.16), transparent 28%),
linear-gradient(180deg, #edf4ff 0%, #f6f8fc 52%, #eef1f7 100%);
color: #142033;
}
.sheet {
width: 1480px;
margin: 0 auto;
padding: 30px 24px 28px;
}
.hero {
display: flex;
justify-content: space-between;
gap: 20px;
align-items: flex-end;
padding: 0 6px 18px;
}
.eyebrow {
margin: 0 0 12px;
color: #1c78d0;
font-size: 13px;
letter-spacing: 0.2em;
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: 44px;
line-height: 1.08;
}
.summary {
margin: 14px 0 0;
font-size: 18px;
line-height: 1.7;
color: #52627a;
}
.meta {
min-width: 320px;
padding: 18px 20px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.76);
border: 1px solid rgba(20, 32, 51, 0.08);
box-shadow: 0 18px 48px rgba(29, 55, 90, 0.08);
}
.meta strong,
.meta span {
display: block;
}
.meta strong {
font-size: 13px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #60748d;
}
.meta span {
margin-top: 8px;
font-size: 20px;
color: #142033;
line-height: 1.5;
overflow-wrap: anywhere;
}
.card {
margin-top: 16px;
padding: 20px;
border-radius: 28px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(20, 32, 51, 0.08);
box-shadow: 0 24px 80px rgba(24, 41, 72, 0.1);
}
.card h2 {
margin: 0;
font-size: 30px;
}
.card p {
margin: 8px 0 0;
color: #5a6b83;
line-height: 1.65;
font-size: 16px;
}
.preview {
margin-top: 14px;
overflow: hidden;
border-radius: 20px;
border: 1px solid rgba(20, 32, 51, 0.08);
background: #dfe7f3;
}
.preview img {
display: block;
width: 100%;
height: auto;
}
.preview--time {
max-height: 500px;
}
.preview--time img {
object-fit: cover;
object-position: top center;
}
.url {
margin-top: 10px;
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
font-size: 14px;
color: #58708f;
overflow-wrap: anywhere;
}
</style>
</head>
<body>
<main class="sheet">
<section class="hero">
<div>
<p class="eyebrow">Tencent Redeem Proof</p>
<h1>兑换截图与北京时间截图</h1>
<p class="summary">用于留存兑换结果与北京时间检索页的组合凭证。</p>
</div>
<div class="meta">
<strong>Captured At</strong>
<span>${escapeHtml(capturedAt)}</span>
</div>
</section>
<section class="card">
<h2>兑换结果截图</h2>
<p>${escapeHtml(redeemMessage || '兑换完成')}</p>
<div class="preview">
<img src="data:image/png;base64,${redeemImageBase64}" alt="兑换结果截图" />
</div>
</section>
<section class="card">
<h2>百度检索“北京时间”截图</h2>
<p>单独标签页打开百度搜索结果并截图。</p>
<div class="url">${escapeHtml(timePageUrl)}</div>
<div class="preview preview--time">
<img src="data:image/png;base64,${timeImageBase64}" alt="北京时间截图" />
</div>
</section>
</main>
</body>
</html>`
}
function escapeHtml(value) {
return String(value || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
}
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict'
import path from 'node:path'
import { resolveRedeemProofMode, shouldCaptureBeijingTimeProof } from './session-proof.js'
import { buildProofHtml } from './session-proof-html.js'
import { buildArtifactPaths } from './session-proof-paths.js'
test('resolveRedeemProofMode normalizes configured modes and falls back to full', () => {
@@ -62,3 +63,19 @@ test('buildArtifactPaths keeps redeem proof artifact filenames stable', () => {
assert.equal(artifactPaths.htmlPath, path.join('/tmp/redeem-proof', 'page.html'))
assert.equal(artifactPaths.resultPath, path.join('/tmp/redeem-proof', 'result.json'))
})
test('buildProofHtml escapes injected text while keeping proof sections stable', () => {
const html = buildProofHtml({
redeemImageBase64: 'redeem-base64',
timeImageBase64: 'time-base64',
redeemMessage: '<b>兑换成功 & 已到账</b>',
timePageUrl: 'https://example.com/?q=<北京时间>',
})
assert.match(html, /Tencent Redeem Proof/)
assert.match(html, /兑换截图与北京时间截图/)
assert.match(html, /&lt;b&gt;兑换成功 &amp; 已到账&lt;\/b&gt;/)
assert.match(html, /https:\/\/example\.com\/\?q=&lt;北京时间&gt;/)
assert.match(html, /data:image\/png;base64,redeem-base64/)
assert.match(html, /data:image\/png;base64,time-base64/)
})