feat: 支持大湾区球场监控

This commit is contained in:
richarjiang
2026-09-23 19:49:59 +08:00
parent 9adc665ef4
commit d4f55a97dc
32 changed files with 3888 additions and 46 deletions

View File

@@ -0,0 +1,506 @@
#!/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)))