Files
tennis-book/skills/tennis-booking/scripts/pospal.mjs
2026-09-23 19:49:59 +08:00

507 lines
17 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// pospal.mjs — Pospal helper for tennis-booking skill.
// Pure data transformation; never holds network state; never persists secrets.
// Spec: see references/protocol.md in the same skill directory.
import { createHash } from 'node:crypto'
// ---------- CLI plumbing ----------
function usage() {
process.stderr.write(`Usage: pospal.mjs <subcommand> [options]
Subcommands:
parse-availability Read raw list JSON from stdin, write structured slots JSON
--date YYYY-MM-DD (required)
--pretty (indent output)
find-continuous Read parse-availability output from stdin, write matching sequence
--start HH (required, e.g. 19)
--end HH (required, e.g. 21)
build-booking-body Read find-continuous (found: true) output from stdin, write request body
--pretty
parse-booking-response Read raw booking response JSON from stdin, write sanitized booking summary
--slots <path> (path to the same find-continuous output used to book)
--pretty
fingerprint Read visitor ID from --visitor or POSPAL_VISITOR_ID env, write 12-char hex
redact Read any JSON from stdin, write redacted JSON (no paySign, no PII, no PSPL*)
--pretty
All subcommands read JSON from stdin unless noted.
Env:
POSPAL_VISITOR_ID Read by 'fingerprint' if --visitor is not given
`)
}
function die(msg, code = 1) {
process.stderr.write(`error: ${msg}\n`)
process.exit(code)
}
function parseArgs(argv) {
const flags = {}
const positional = []
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (a.startsWith('--')) {
const key = a.slice(2)
const next = argv[i + 1]
if (next === undefined || next.startsWith('--')) {
flags[key] = true
} else {
flags[key] = next
i++
}
} else {
positional.push(a)
}
}
return { flags, positional }
}
async function readStdin() {
const chunks = []
for await (const chunk of process.stdin) chunks.push(chunk)
return Buffer.concat(chunks).toString('utf8')
}
function isRecord(v) {
return typeof v === 'object' && v !== null && !Array.isArray(v)
}
function isInt(n) {
return typeof n === 'number' && Number.isFinite(n)
}
const writeJson = (obj, pretty) => {
process.stdout.write(pretty ? JSON.stringify(obj, null, 2) : JSON.stringify(obj))
process.stdout.write('\n')
}
// ---------- shared helpers ----------
const TIME_RE = /(\d{2}):(\d{2})/
const DATE_TIME_RE = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}):\d{2}$/
function extractTime(value) {
if (typeof value !== 'string') return null
const m = TIME_RE.exec(value)
return m ? m[0] : null
}
function extractHour(time) {
if (typeof time !== 'string') return null
const m = TIME_RE.exec(time)
return m ? Number(m[1]) : null
}
function extractDate(dateTime) {
if (typeof dateTime !== 'string') return null
const m = DATE_TIME_RE.exec(dateTime)
return m ? m[1] : null
}
// ---------- parse-availability ----------
function parseEnrollment(raw) {
if (!isRecord(raw) || !isRecord(raw.enrollmentRule)) {
die('球场接口返回了无法识别的活动数据')
}
const rule = raw.enrollmentRule
const startTime = extractTime(rule.beginDate)
const endTime = extractTime(rule.endDate)
const deadline = extractTime(rule.enrollmentEndDate)
if (
typeof raw.txtClassroomUid !== 'string' ||
typeof raw.beginDatetime !== 'string' ||
typeof raw.endDatetime !== 'string' ||
typeof rule.uidTxt !== 'string' ||
typeof rule.title !== 'string' ||
startTime === null ||
endTime === null ||
!isInt(rule.enrollmentFee) ||
!isInt(rule.enrollmentLimitNum)
) {
die('球场接口返回了无法识别的活动数据')
}
const enrolledCount = isInt(rule.enrollmentCount)
? rule.enrollmentCount
: isInt(rule.ParticipateCount)
? rule.ParticipateCount
: 0
const participantNames = []
if (Array.isArray(rule.customerModels)) {
for (const c of rule.customerModels) {
if (isRecord(c) && typeof c.name === 'string' && c.name.trim()) {
participantNames.push(c.name.trim())
}
}
}
return {
txtClassroomUid: raw.txtClassroomUid,
beginDatetime: raw.beginDatetime,
endDatetime: raw.endDatetime,
activity: {
id: rule.uidTxt,
title: rule.title,
startTime,
endTime,
feeCents: Math.round(rule.enrollmentFee * 100),
enrolledCount,
capacity: rule.enrollmentLimitNum,
participantNames: [...new Set(participantNames)],
...(deadline !== null ? { enrollmentDeadline: deadline } : {})
}
}
}
function parseSlot(raw, enrollments) {
if (!isRecord(raw)) die('球场接口返回了无法识别的时段数据')
const startTime = extractTime(raw.beginDatetime)
const endTime = extractTime(raw.endDatetime)
const appt = isRecord(raw.apptInfo) ? raw.apptInfo : null
const canBook = appt ? appt.canApptOrNot : undefined
if (
typeof raw.txtClassroomUid !== 'string' ||
raw.txtClassroomUid.trim() === '' ||
typeof raw.classRoomName !== 'string' ||
raw.classRoomName.trim() === '' ||
typeof raw.beginDatetime !== 'string' ||
typeof raw.endDatetime !== 'string' ||
startTime === null ||
endTime === null ||
!isInt(raw.cost) ||
!isInt(raw.status) ||
typeof canBook !== 'boolean'
) {
die('球场接口返回了无法识别的时段数据')
}
const enrollment = enrollments.find((e) =>
e.txtClassroomUid === raw.txtClassroomUid &&
raw.beginDatetime <= e.endDatetime &&
raw.endDatetime >= e.beginDatetime
)
let status
let unavailableLabel
if (enrollment) {
status = 'booked'
unavailableLabel = '已订'
} else if (canBook) {
status = 'available'
} else if (raw.status === 1) {
status = 'booked'
unavailableLabel = '已订'
} else {
status = 'blocked'
unavailableLabel = '排课'
}
let bookedBy
if (status === 'booked' && appt) {
const contacts = []
if (Array.isArray(appt.hadVenueAppts)) {
for (const v of appt.hadVenueAppts) {
if (!isRecord(v)) continue
const name = typeof v.customerName === 'string' ? v.customerName.trim() : ''
const phone = typeof v.customerTel === 'string' ? v.customerTel.trim() : ''
if (name || phone) contacts.push({ name: name || undefined, phone: phone || undefined })
}
}
if (contacts.length === 0 && Array.isArray(appt.customerNames)) {
for (const v of appt.customerNames) {
if (typeof v === 'string' && v.trim()) contacts.push({ name: v.trim() })
}
}
const unique = []
for (const c of contacts) {
if (!unique.some((u) => u.name === c.name && u.phone === c.phone)) unique.push(c)
}
if (unique.length > 0) bookedBy = unique
}
return {
id: `${raw.txtClassroomUid}:${raw.beginDatetime}:${raw.endDatetime}`,
courtLabel: raw.classRoomName,
startTime,
endTime,
priceCents: Math.round(raw.cost * 100),
status,
...(unavailableLabel ? { unavailableLabel } : {}),
...(bookedBy ? { bookedBy } : {}),
...(enrollment ? { enrollment: enrollment.activity } : {}),
providerReference: {
classroomUid: raw.txtClassroomUid,
beginDatetime: raw.beginDatetime,
endDatetime: raw.endDatetime
},
sourceLabel: '范思伯特实时接口'
}
}
async function cmdParseAvailability({ flags }) {
const date = flags.date
if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
die('--date YYYY-MM-DD is required')
}
const raw = await readStdin()
let payload
try { payload = JSON.parse(raw) } catch { die('stdin is not valid JSON') }
if (!isRecord(payload)) die('球场接口响应格式错误')
if (payload.successed !== true || !isRecord(payload.result)) {
die('球场接口拒绝了可订时段查询')
}
const slots = payload.result.slots
if (!Array.isArray(slots)) die('球场接口响应缺少时段列表')
const enrollRaw = payload.result.enrollSlots
if (enrollRaw !== undefined && !Array.isArray(enrollRaw)) {
die('球场接口响应中的活动列表格式错误')
}
const enrollments = []
if (Array.isArray(enrollRaw)) {
for (const e of enrollRaw) enrollments.push(parseEnrollment(e))
}
const parsedSlots = slots.map((s) => parseSlot(s, enrollments))
writeJson({
courtId: 'fansibote-fuzhong',
date,
updatedAt: new Date().toISOString(),
slots: parsedSlots
}, !!flags.pretty)
}
// ---------- find-continuous ----------
function isContinuousSingleCourt(slots) {
if (slots.length === 0) return false
const firstUid = slots[0].providerReference?.classroomUid
if (!firstUid) return false
for (const s of slots) {
if (s.providerReference?.classroomUid !== firstUid) return false
}
for (let i = 1; i < slots.length; i++) {
const prevEnd = extractHour(slots[i - 1].endTime)
const curStart = extractHour(slots[i].startTime)
if (prevEnd === null || curStart === null || prevEnd + 1 !== curStart) return false
}
return true
}
async function cmdFindContinuous({ flags }) {
const start = flags.start
const end = flags.end
if (typeof start !== 'string' || typeof end !== 'string') {
die('--start HH and --end HH are required')
}
const startHour = Number(start)
const endHour = Number(end)
if (!Number.isInteger(startHour) || !Number.isInteger(endHour) ||
startHour < 0 || endHour > 24 || startHour >= endHour) {
die('invalid --start/--end (must be integers, 0 <= start < end <= 24)')
}
const raw = await readStdin()
let payload
try { payload = JSON.parse(raw) } catch { die('stdin is not valid JSON') }
if (!isRecord(payload) || !Array.isArray(payload.slots)) {
die('stdin is not a parse-availability output (missing slots array)')
}
const bookable = payload.slots.filter((s) =>
(s.status === 'available' || s.status === 'limited') &&
!s.enrollment
)
const sorted = [...bookable].sort((a, b) => {
if (a.providerReference.classroomUid !== b.providerReference.classroomUid) {
return a.providerReference.classroomUid.localeCompare(b.providerReference.classroomUid)
}
return a.startTime.localeCompare(b.startTime)
})
const groups = new Map()
for (const s of sorted) {
const uid = s.providerReference.classroomUid
if (!groups.has(uid)) groups.set(uid, [])
groups.get(uid).push(s)
}
const matches = []
const span = endHour - startHour
for (const [uid, slots] of groups) {
for (let i = 0; i + span <= slots.length; i++) {
const window = slots.slice(i, i + span)
const firstHour = extractHour(window[0].startTime)
const lastEndHour = extractHour(window[window.length - 1].endTime)
if (firstHour !== startHour || lastEndHour + 1 !== endHour) continue
if (!isContinuousSingleCourt(window)) continue
matches.push({
txtClassroomUid: uid,
courtLabel: window[0].courtLabel,
startTime: window[0].startTime,
endTime: window[window.length - 1].endTime,
slots: window
})
}
}
if (matches.length === 0) {
writeJson({ found: false }, false)
return
}
matches.sort((a, b) => {
if (a.startTime !== b.startTime) return a.startTime.localeCompare(b.startTime)
const aCost = a.slots.reduce((s, x) => s + x.priceCents, 0)
const bCost = b.slots.reduce((s, x) => s + x.priceCents, 0)
if (aCost !== bCost) return aCost - bCost
return a.courtLabel.localeCompare(b.courtLabel)
})
writeJson({
found: true,
match: matches[0],
alternatives: matches.slice(1)
}, false)
}
// ---------- build-booking-body ----------
const DEFAULT_VENUE_ID_NUM = 6038652
async function cmdBuildBookingBody({ flags }) {
const raw = await readStdin()
let payload
try { payload = JSON.parse(raw) } catch { die('stdin is not valid JSON') }
if (!isRecord(payload) || payload.found !== true || !isRecord(payload.match)) {
die('stdin is not a find-continuous output (found: true required)')
}
const slots = payload.match.slots
if (!Array.isArray(slots) || slots.length === 0) die('no slots in match')
if (!isContinuousSingleCourt(slots)) die('match is not a single-court continuous sequence')
const body = {
userId: DEFAULT_VENUE_ID_NUM,
projectType: 0,
classroomItems: slots.map((s) => ({
classroomUid: s.providerReference.classroomUid,
beginDatetime: s.providerReference.beginDatetime,
endDatetime: s.providerReference.endDatetime,
peopleNum: 1
})),
remark: '',
combinationPayments: [
{
paymentMethod: 900,
cost: slots.reduce((sum, s) => sum + s.priceCents, 0) / 100
}
]
}
writeJson(body, !!flags.pretty)
}
// ---------- parse-booking-response ----------
async function cmdParseBookingResponse({ flags }) {
const slotsPath = flags.slots
if (typeof slotsPath !== 'string') die('--slots <path> is required')
const fs = await import('node:fs/promises')
let slotsDoc
try {
slotsDoc = JSON.parse(await fs.readFile(slotsPath, 'utf8'))
} catch (err) {
die(`cannot read --slots: ${err.message}`)
}
const sl = slotsDoc?.match?.slots ?? slotsDoc?.slots
if (!Array.isArray(sl) || sl.length === 0) {
die('--slots must point to a find-continuous (with .match.slots) or parse-availability (with .slots) output')
}
const respRaw = await readStdin()
let payload
try { payload = JSON.parse(respRaw) } catch { die('stdin is not valid JSON') }
if (!isRecord(payload)) die('球场下单接口响应格式错误')
if (payload.successed !== true) {
const detail = []
for (const k of ['message', 'msg', 'errorMessage', 'errorMsg', 'status', 'errorCode']) {
const v = payload[k]
if (typeof v === 'string' || typeof v === 'number') {
detail.push(`${k}=${String(v).slice(0, 200)}`)
}
}
die(`球场下单接口拒绝了锁场请求${detail.length ? `(${detail.join(', ')})` : ''}`)
}
if (!isRecord(payload.result)) die('球场下单接口响应格式错误')
const result = payload.result
if (
typeof result.apptUid !== 'string' ||
!isRecord(result.script) ||
typeof result.script.expireTime !== 'string' ||
!Array.isArray(result.venueAppointUids) ||
!result.venueAppointUids.every((v) => typeof v === 'string')
) {
die('球场下单接口响应格式错误')
}
const sorted = [...sl].sort((a, b) => a.startTime.localeCompare(b.startTime))
const amountCents = sorted.reduce((s, x) => s + x.priceCents, 0)
const summary = {
courtId: 'fansibote-fuzhong',
orderId: result.apptUid,
venueAppointmentIds: result.venueAppointUids,
amountCents,
courtLabel: sorted[0].courtLabel,
startTime: sorted[0].startTime,
endTime: sorted[sorted.length - 1].endTime,
expiresAt: result.script.expireTime,
status: 'pending-payment',
_note: 'script 对象(paySign 等)已剥离;请到小程序完成支付。'
}
writeJson(summary, !!flags.pretty)
}
// ---------- fingerprint ----------
async function cmdFingerprint({ flags }) {
let visitor = typeof flags.visitor === 'string' ? flags.visitor : ''
if (!visitor) visitor = process.env.POSPAL_VISITOR_ID ?? ''
if (!visitor) die('fingerprint needs --visitor or POSPAL_VISITOR_ID env')
const fp = createHash('sha256').update(visitor).digest('hex').slice(0, 12)
process.stdout.write(fp)
process.stdout.write('\n')
}
// ---------- redact ----------
const SENSITIVE_KEY = /^(?:visitor|paysign|prepay|nonce|package|token|cookie|authorization)$|^(?:pspl|customer|phone|tel)/i
function redactValue(value, key) {
if (typeof key === 'string' && SENSITIVE_KEY.test(key)) return '[REDACTED]'
if (Array.isArray(value)) return value.map((v) => redactValue(v, ''))
if (isRecord(value)) {
const out = {}
for (const [k, v] of Object.entries(value)) out[k] = redactValue(v, k)
return out
}
return value
}
async function cmdRedact({ flags }) {
const raw = await readStdin()
let payload
try { payload = JSON.parse(raw) } catch { die('stdin is not valid JSON') }
writeJson(redactValue(payload, ''), !!flags.pretty)
}
// ---------- main ----------
async function main() {
const argv = process.argv.slice(2)
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
usage()
process.exit(argv.length === 0 ? 1 : 0)
}
const sub = argv[0]
const parsed = parseArgs(argv.slice(1))
switch (sub) {
case 'parse-availability': return cmdParseAvailability(parsed)
case 'find-continuous': return cmdFindContinuous(parsed)
case 'build-booking-body': return cmdBuildBookingBody(parsed)
case 'parse-booking-response': return cmdParseBookingResponse(parsed)
case 'fingerprint': return cmdFingerprint(parsed)
case 'redact': return cmdRedact(parsed)
default: {
process.stderr.write(`unknown subcommand: ${sub}\n\n`)
usage()
process.exit(1)
}
}
}
main().catch((err) => die(err?.stack ?? String(err)))