#!/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 [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 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 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)))