feat: 支持大湾区球场监控
This commit is contained in:
506
skills/tennis-booking/scripts/pospal.mjs
Executable file
506
skills/tennis-booking/scripts/pospal.mjs
Executable 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)))
|
||||
Reference in New Issue
Block a user