Files
jiapuapp/scripts/capture-chrome-page.js
T
2026-07-20 17:23:13 +08:00

540 lines
22 KiB
JavaScript

const fs = require('fs')
const path = require('path')
const [url, selector, output, width = '360', height = '800', action] = process.argv.slice(2)
const renderSettleMilliseconds = 900
const chromeDebuggingPort = process.env.CHROME_DEBUGGING_PORT || '9222'
if (!url || !selector || !output) {
throw new Error('Usage: node scripts/capture-chrome-page.js <url> <selector> <output> [width] [height] [password-tab|sms-tab|invalid-login|verification-dialog|g06-results|g06-empty|g06-invite|g03-*|fresh-navigation]')
}
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
const connect = async () => {
const pages = await (await fetch(`http://127.0.0.1:${chromeDebuggingPort}/json/list`)).json()
const page = pages.find((item) => item.type === 'page' && item.url.startsWith('http://localhost:5173'))
if (!page) throw new Error('Chrome debugging has no localhost:5173 page')
const socket = new WebSocket(page.webSocketDebuggerUrl)
await new Promise((resolve, reject) => {
socket.addEventListener('open', resolve, { once: true })
socket.addEventListener('error', reject, { once: true })
})
let id = 0
const pending = new Map()
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
if (message.error) request.reject(new Error(message.error.message))
else request.resolve(message.result)
})
const send = (method, params = {}) => new Promise((resolve, reject) => {
id += 1
pending.set(id, { resolve, reject })
socket.send(JSON.stringify({ id, method, params }))
})
return { socket, send }
}
const waitForLoadedImages = async (send) => {
for (let attempt = 0; attempt < 30; attempt += 1) {
const result = await send('Runtime.evaluate', {
expression: 'Array.from(document.images).every((image) => image.complete && image.naturalWidth > 0)',
returnByValue: true
})
if (result.result?.value) return
if (attempt === 29) throw new Error('Page images did not finish loading before screenshot')
await sleep(100)
}
}
const prepareA01LoginState = async (send, action) => {
if (action !== 'password-tab' && action !== 'sms-tab' && action !== 'invalid-login' && action !== 'verification-dialog') return
const tabIndex = action === 'sms-tab' ? 1 : 0
for (let attempt = 0; attempt < 30; attempt += 1) {
const prepared = await send('Runtime.evaluate', {
expression: `(() => {
const tabs = document.querySelectorAll('.login-tab')
if (tabs.length < 2) return false
tabs[${tabIndex}].click()
return true
})()`,
returnByValue: true
})
if (prepared.result?.value) break
if (attempt === 29) throw new Error(`A01 login tabs did not render for ${action}`)
await sleep(100)
}
for (let attempt = 0; attempt < 30; attempt += 1) {
const active = await send('Runtime.evaluate', {
expression: `document.querySelectorAll('.login-tab')[${tabIndex}]?.classList.contains('active') === true`,
returnByValue: true
})
if (active.result?.value) break
if (attempt === 29) throw new Error(`A01 did not enter the requested ${action} state`)
await sleep(100)
}
if (action === 'invalid-login') {
await send('Runtime.evaluate', { expression: "document.querySelector('.login-submit').click()" })
for (let attempt = 0; attempt < 30; attempt += 1) {
const visible = await send('Runtime.evaluate', {
expression: "Boolean(document.querySelector('.feedback-toast'))",
returnByValue: true
})
if (visible.result?.value) return
if (attempt === 29) throw new Error('A01 invalid-login Toast did not render')
await sleep(100)
}
}
if (action === 'verification-dialog') {
const prepared = await send('Runtime.evaluate', {
expression: `(() => {
const inputs = document.querySelectorAll('.auth-input input')
if (inputs.length < 2) return false
inputs[0].value = '13800138000'
inputs[0].dispatchEvent(new Event('input', { bubbles: true }))
inputs[1].value = 'demo-password'
inputs[1].dispatchEvent(new Event('input', { bubbles: true }))
document.querySelector('.agreement-row').click()
document.querySelector('.login-submit').click()
return true
})()`,
returnByValue: true
})
if (!prepared.result?.value) throw new Error('Could not prepare A01 verification dialog state')
for (let attempt = 0; attempt < 30; attempt += 1) {
const visible = await send('Runtime.evaluate', {
expression: "Boolean(document.querySelector('.verification-dialog'))",
returnByValue: true
})
if (visible.result?.value) return
if (attempt === 29) throw new Error('A01 verification dialog did not render')
await sleep(100)
}
}
}
const waitForRequestedUrl = async (send, url) => {
for (let attempt = 0; attempt < 30; attempt += 1) {
const result = await send('Runtime.evaluate', {
expression: `location.href === ${JSON.stringify(url)}`,
returnByValue: true
})
if (result.result?.value) return
if (attempt === 29) throw new Error(`Browser did not navigate to the requested URL: ${url}`)
await sleep(100)
}
}
const waitForFreshDocument = async (send, documentTimeOrigin) => {
for (let attempt = 0; attempt < 30; attempt += 1) {
const result = await send('Runtime.evaluate', {
expression: 'performance.timeOrigin',
returnByValue: true
})
if (result.result?.value !== documentTimeOrigin) return
if (attempt === 29) throw new Error('Page did not reload into a fresh document before screenshot')
await sleep(100)
}
}
const prepareG06State = async (send, action) => {
if (action !== 'g06-results' && action !== 'g06-empty' && action !== 'g06-error' && action !== 'g06-invite' && action !== 'g06-invite-invalid') return
if (action === 'g06-invite' || action === 'g06-invite-invalid') {
const switched = await send('Runtime.evaluate', {
expression: `(() => {
const tab = document.querySelectorAll('.mode-tab')[1]
if (!tab) return false
tab.click()
return true
})()`,
returnByValue: true
})
if (!switched.result?.value) throw new Error('Could not switch G06 to invite mode')
await sleep(100)
const prepared = await send('Runtime.evaluate', {
expression: `(() => {
const input = document.querySelector('.invite-input input')
const button = document.querySelector('.invite-controls .search-action')
if (!input || !button) return false
input.value = ${JSON.stringify(action === 'g06-invite' ? 'JP2026' : 'INVALID')}
input.dispatchEvent(new Event('input', { bubbles: true }))
return true
})()`,
returnByValue: true
})
if (!prepared.result?.value) throw new Error('Could not prepare G06 invite code')
await sleep(100)
await send('Runtime.evaluate', { expression: "document.querySelector('.invite-controls .search-action').click()" })
} else {
const keyword = action === 'g06-results' ? '汤' : action === 'g06-error' ? '失败' : '不存在的家谱'
const prepared = await send('Runtime.evaluate', {
expression: `(() => {
const input = document.querySelector('.search-input input')
if (!input) return false
input.value = ${JSON.stringify(keyword)}
input.dispatchEvent(new Event('input', { bubbles: true }))
return true
})()`,
returnByValue: true
})
if (!prepared.result?.value) throw new Error(`Could not prepare ${action}`)
await sleep(100)
await send('Runtime.evaluate', { expression: "document.querySelector('.search-action').click()" })
}
const stateSelector = action === 'g06-results' ? '.search-results .genealogy-card' : action === 'g06-empty' ? '.search-empty' : action === 'g06-error' ? '.search-error' : action === 'g06-invite-invalid' ? '.invite-invalid' : '.invite-result .genealogy-card'
for (let attempt = 0; attempt < 30; attempt += 1) {
const result = await send('Runtime.evaluate', {
expression: `Boolean(document.querySelector(${JSON.stringify(stateSelector)}))`,
returnByValue: true
})
if (result.result?.value) return
if (attempt === 29) throw new Error(`Expected G06 state did not render: ${action}`)
await sleep(100)
}
}
const waitForPageLoad = async (socket, trigger) => new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
socket.removeEventListener('message', onMessage)
reject(new Error('Page reload did not emit Page.loadEventFired'))
}, 4000)
const onMessage = (event) => {
const message = JSON.parse(event.data)
if (message.method !== 'Page.loadEventFired') return
clearTimeout(timeout)
socket.removeEventListener('message', onMessage)
resolve()
}
socket.addEventListener('message', onMessage)
Promise.resolve().then(trigger).catch((error) => {
clearTimeout(timeout)
socket.removeEventListener('message', onMessage)
reject(error)
})
})
const waitForSelector = async (send, selector, message) => {
for (let attempt = 0; attempt < 30; attempt += 1) {
const result = await send('Runtime.evaluate', {
expression: `Boolean(document.querySelector(${JSON.stringify(selector)}))`,
returnByValue: true
})
if (result.result?.value) return
if (attempt === 29) throw new Error(message)
await sleep(100)
}
}
const waitForText = async (send, selector, text, message) => {
for (let attempt = 0; attempt < 30; attempt += 1) {
const result = await send('Runtime.evaluate', {
expression: `document.querySelector(${JSON.stringify(selector)})?.textContent.includes(${JSON.stringify(text)}) === true`,
returnByValue: true
})
if (result.result?.value) return
if (attempt === 29) throw new Error(message)
await sleep(50)
}
}
const prepareG03State = async (send, action) => {
const actions = new Set([
'g03-create-validation',
'g03-duplicate-reminder',
'g03-create-submitting',
'g03-create-error',
'g03-ancestor-validation',
'g03-ancestor-submitting',
'g03-ancestor-error',
'g03-ancestor-success'
])
if (!actions.has(action)) return
if (action === 'g03-create-validation') {
await send('Runtime.evaluate', { expression: "document.querySelector('.flow-primary-action').click()" })
await waitForSelector(send, '.field-error', 'G03 create validation did not render')
return
}
if (action.startsWith('g03-create') || action === 'g03-duplicate-reminder') {
const name = action === 'g03-create-error' ? '失败' : '汤氏家谱'
const prepared = await send('Runtime.evaluate', {
expression: `(() => {
const inputs = document.querySelectorAll('.field-row input')
if (inputs.length < 4) return false
const values = ['汤', ${JSON.stringify(name)}, '', '河南·洛阳']
inputs.forEach((input, index) => {
input.value = values[index]
input.dispatchEvent(new Event('input', { bubbles: true }))
})
document.querySelector('.flow-primary-action').click()
return true
})()`,
returnByValue: true
})
if (!prepared.result?.value) throw new Error(`Could not prepare ${action}`)
await waitForSelector(send, '.duplicate-reminder', `G03 duplicate reminder did not render for ${action}`)
if (action === 'g03-duplicate-reminder') return
await send('Runtime.evaluate', { expression: "document.querySelector('.duplicate-reminder__confirm').click()" })
if (action === 'g03-create-submitting') {
await waitForText(send, '.flow-primary-action__copy', '正在创建', 'G03 create submitting state did not render')
return
}
await waitForSelector(send, '.flow-error', 'G03 create error state did not render')
return
}
if (action === 'g03-ancestor-validation') {
await send('Runtime.evaluate', { expression: "document.querySelector('.flow-primary-action').click()" })
await waitForSelector(send, '.field-error', 'G03 ancestor validation did not render')
return
}
const personName = action === 'g03-ancestor-error' ? '失败' : '汤远'
const prepared = await send('Runtime.evaluate', {
expression: `(() => {
const input = document.querySelector('.field-row input')
if (!input) return false
input.value = ${JSON.stringify(personName)}
input.dispatchEvent(new Event('input', { bubbles: true }))
document.querySelector('.flow-primary-action').click()
return true
})()`,
returnByValue: true
})
if (!prepared.result?.value) throw new Error(`Could not prepare ${action}`)
if (action === 'g03-ancestor-submitting') {
await waitForText(send, '.flow-primary-action__copy', '正在保存', 'G03 ancestor submitting state did not render')
} else if (action === 'g03-ancestor-error') {
await waitForSelector(send, '.flow-error', 'G03 ancestor error state did not render')
} else {
await waitForSelector(send, '.flow-success-layer', 'G03 ancestor success state did not render')
}
}
const prepareG08State = async (send, action) => {
if (!['g08-validation', 'g08-submitting', 'g08-error', 'g08-success'].includes(action)) return
if (action === 'g08-validation') {
await send('Runtime.evaluate', { expression: "document.querySelector('.join-action').click()" })
await waitForSelector(send, '.join-field-error', 'G08 validation errors did not render')
return
}
const realName = action === 'g08-error' ? '失败' : '汤明'
const prepared = await send('Runtime.evaluate', {
expression: `(() => {
const inputs = document.querySelectorAll('.join-field input')
if (inputs.length < 2) return false
const values = [${JSON.stringify(realName)}, '堂侄']
inputs.forEach((input, index) => {
input.value = values[index]
input.dispatchEvent(new Event('input', { bubbles: true }))
})
return true
})()`,
returnByValue: true
})
if (!prepared.result?.value) throw new Error(`Could not prepare ${action}`)
await sleep(50)
await send('Runtime.evaluate', { expression: "document.querySelector('.join-action').click()" })
if (action === 'g08-submitting') {
await waitForText(send, '.join-action', '正在', 'G08 submitting state did not render')
} else if (action === 'g08-error') {
await waitForSelector(send, '.join-state--error', 'G08 error state did not render')
} else {
await waitForSelector(send, '.join-state--success', 'G08 success state did not render')
}
}
const prepareG09State = async (send, action) => {
if (!['g09-withdraw-dialog', 'g09-withdrawn'].includes(action)) return
await send('Runtime.evaluate', { expression: "document.querySelector('.application-card__action')?.click()" })
await waitForSelector(send, '.app-dialog-layer', 'G09 withdrawal dialog did not render')
if (action === 'g09-withdrawn') {
await send('Runtime.evaluate', { expression: "document.querySelector('.app-dialog__actions .app-button:last-child')?.click()" })
await waitForText(send, '.application-card__status', '已撤回', 'G09 withdrawn state did not render')
}
}
const prepareG10State = async (send, action) => {
const actions = ['g10-help', 'g10-approve-dialog', 'g10-approved', 'g10-reject-dialog', 'g10-rejected']
if (!actions.includes(action)) return
if (action === 'g10-help') {
await send('Runtime.evaluate', { expression: "document.querySelector('.header-action')?.click()" })
await waitForText(send, '.app-dialog__title', '审核说明', 'G10 help dialog did not render')
return
}
const isApprove = action === 'g10-approve-dialog' || action === 'g10-approved'
const selector = isApprove ? '.review-action:last-child' : '.review-action:first-child'
await send('Runtime.evaluate', { expression: `document.querySelector(${JSON.stringify(selector)})?.click()` })
await waitForSelector(send, '.app-dialog-layer', 'G10 audit confirmation did not render')
if (action === 'g10-approved' || action === 'g10-rejected') {
await send('Runtime.evaluate', { expression: "document.querySelector('.app-dialog__actions .app-button:last-child')?.click()" })
await waitForText(send, '.application-card__status', isApprove ? '已通过' : '已拒绝', 'G10 audit result did not render')
}
}
const prepareG11State = async (send, action) => {
if (!['g11-validation', 'g11-saved'].includes(action)) return
if (action === 'g11-validation') {
await send('Runtime.evaluate', {
expression: `(() => {
const input = document.querySelector('.settings-field input')
if (!input) return false
input.value = ''
input.dispatchEvent(new Event('input', { bubbles: true }))
document.querySelector('.settings-action')?.click()
return true
})()`,
returnByValue: true
})
await waitForSelector(send, '.settings-field-error', 'G11 validation state did not render')
return
}
await send('Runtime.evaluate', { expression: "document.querySelector('.settings-action')?.click()" })
await waitForSelector(send, '.settings-state--success', 'G11 saved state did not render')
await waitForSelector(send, '.settings-feedback', 'G11 saved feedback did not render')
}
const prepareG12State = async (send, action) => {
if (!['g12-validation', 'g12-save-error', 'g12-saved'].includes(action)) return
const value = action === 'g12-validation' ? '' : action === 'g12-save-error' ? '失败' : '启宗敦本继世传芳'
await send('Runtime.evaluate', {
expression: `(() => {
const textarea = document.querySelector('.poem-field textarea')
if (!textarea) return false
textarea.value = ${JSON.stringify(value)}
textarea.dispatchEvent(new Event('input', { bubbles: true }))
document.querySelector('.poem-editor__actions .poem-action:last-child')?.click()
return true
})()`,
returnByValue: true
})
if (action === 'g12-saved') {
await waitForSelector(send, '.poem-state--list', 'G12 saved list did not render')
await waitForSelector(send, '.poem-feedback', 'G12 saved feedback did not render')
} else {
await waitForSelector(send, '.poem-field-error', 'G12 validation or save error did not render')
}
}
const prepareT03T06State = async (send, action) => {
if (action !== 't06-conflict-help') return
await send('Runtime.evaluate', { expression: "document.querySelector('.conflict-actions .member-form-action:last-child')?.click()" })
await waitForSelector(send, '.app-dialog-layer', 'T06 conflict help dialog did not render')
}
const prepareFSeriesState = async (send, action) => {
if (action === 'f02-validation') {
await send('Runtime.evaluate', { expression: "document.querySelector('.publish-form .app-button')?.click()" })
await waitForSelector(send, '.app-toast', 'F02 empty-content toast did not render')
return
}
if (action !== 'module-preview-toast') return
await send('Runtime.evaluate', { expression: "(document.querySelector('.list-card') || document.querySelector('.settings-row'))?.click()" })
await waitForSelector(send, '.app-toast', 'ModulePage preview toast did not render')
}
const prepareNSeriesState = async (send, action) => {
if (action === 'n01-read-first') {
await send('Runtime.evaluate', { expression: "document.querySelector('.notice-card')?.click()" })
for (let attempt = 0; attempt < 30; attempt += 1) {
const result = await send('Runtime.evaluate', {
expression: "document.querySelector('.notice-card')?.textContent.includes('未读提醒') === false",
returnByValue: true
})
if (result.result?.value) return
if (attempt === 29) throw new Error('N01 first notice did not enter the read state')
await sleep(50)
}
return
}
if (action !== 'n01-mark-all') return
await send('Runtime.evaluate', { expression: "document.querySelector('.header-action')?.click()" })
await waitForSelector(send, '.app-toast', 'N01 mark-all-read toast did not render')
}
const capture = async () => {
const { socket, send } = await connect()
const viewportWidth = Number(width)
const viewportHeight = Number(height)
try {
await send('Page.enable')
await send('Runtime.enable')
await send('Emulation.setDeviceMetricsOverride', {
width: viewportWidth,
height: viewportHeight,
deviceScaleFactor: 1,
mobile: true,
screenWidth: viewportWidth,
screenHeight: viewportHeight
})
const timeOriginResult = await send('Runtime.evaluate', {
expression: 'performance.timeOrigin',
returnByValue: true
})
const documentTimeOrigin = timeOriginResult.result?.value
await send('Page.navigate', { url })
await waitForRequestedUrl(send, url)
if (action !== 'fresh-navigation') {
await waitForPageLoad(socket, () => send('Page.reload'))
await waitForFreshDocument(send, documentTimeOrigin)
}
for (let attempt = 0; attempt < 30; attempt += 1) {
const result = await send('Runtime.evaluate', {
expression: `Boolean(document.querySelector(${JSON.stringify(selector)}))`,
returnByValue: true
})
if (result.result?.value) break
if (attempt === 29) throw new Error(`Expected page selector did not render: ${selector}`)
await sleep(100)
}
await prepareA01LoginState(send, action)
await prepareG06State(send, action)
await prepareG03State(send, action)
await prepareG08State(send, action)
await prepareG09State(send, action)
await prepareG10State(send, action)
await prepareG11State(send, action)
await prepareG12State(send, action)
await prepareT03T06State(send, action)
await prepareFSeriesState(send, action)
await prepareNSeriesState(send, action)
await waitForLoadedImages(send)
await sleep(action?.endsWith('-submitting') ? 80 : renderSettleMilliseconds)
await send('Page.bringToFront')
const screenshot = await send('Page.captureScreenshot', { format: 'png', fromSurface: action !== 'from-view', captureBeyondViewport: false })
fs.mkdirSync(path.dirname(output), { recursive: true })
fs.writeFileSync(output, Buffer.from(screenshot.data, 'base64'))
process.stdout.write(`CAPTURED ${output}\n`)
} finally {
if (action !== 'keep-view') {
try { await send('Emulation.clearDeviceMetricsOverride') } catch (_) {}
}
socket.close()
}
}
capture().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})