507 lines
17 KiB
JavaScript
Executable File
507 lines
17 KiB
JavaScript
Executable File
#!/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)))
|