opensoma 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/package.json +1 -1
- package/dist/src/commands/auth.d.ts +1 -1
- package/dist/src/commands/auth.d.ts.map +1 -1
- package/dist/src/commands/auth.js +94 -52
- package/dist/src/commands/auth.js.map +1 -1
- package/dist/src/constants.d.ts +40 -0
- package/dist/src/constants.d.ts.map +1 -1
- package/dist/src/constants.js +42 -0
- package/dist/src/constants.js.map +1 -1
- package/dist/src/formatters.d.ts.map +1 -1
- package/dist/src/formatters.js +42 -16
- package/dist/src/formatters.js.map +1 -1
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/shared/utils/toz.d.ts +23 -0
- package/dist/src/shared/utils/toz.d.ts.map +1 -0
- package/dist/src/shared/utils/toz.js +72 -0
- package/dist/src/shared/utils/toz.js.map +1 -0
- package/dist/src/token-extractor.d.ts +9 -1
- package/dist/src/token-extractor.d.ts.map +1 -1
- package/dist/src/token-extractor.js +54 -10
- package/dist/src/token-extractor.js.map +1 -1
- package/dist/src/toz-formatters.d.ts +9 -0
- package/dist/src/toz-formatters.d.ts.map +1 -0
- package/dist/src/toz-formatters.js +151 -0
- package/dist/src/toz-formatters.js.map +1 -0
- package/dist/src/toz-http.d.ts +27 -0
- package/dist/src/toz-http.d.ts.map +1 -0
- package/dist/src/toz-http.js +154 -0
- package/dist/src/toz-http.js.map +1 -0
- package/dist/src/types.d.ts +52 -0
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js +46 -0
- package/dist/src/types.js.map +1 -1
- package/package.json +1 -1
- package/src/__fixtures__/toz/toz_all_branches.json +211 -0
- package/src/__fixtures__/toz/toz_booking.html +2190 -0
- package/src/__fixtures__/toz/toz_boothes.json +59 -0
- package/src/__fixtures__/toz/toz_duration.json +25 -0
- package/src/__fixtures__/toz/toz_mypage_response.html +388 -0
- package/src/__fixtures__/toz/toz_page.html +211 -0
- package/src/commands/auth.ts +107 -50
- package/src/constants.ts +50 -0
- package/src/formatters.ts +44 -16
- package/src/index.ts +3 -0
- package/src/shared/utils/toz.test.ts +133 -0
- package/src/shared/utils/toz.ts +100 -0
- package/src/token-extractor.test.ts +30 -5
- package/src/token-extractor.ts +65 -13
- package/src/toz-formatters.test.ts +197 -0
- package/src/toz-formatters.ts +211 -0
- package/src/toz-http.test.ts +157 -0
- package/src/toz-http.ts +188 -0
- package/src/types.ts +58 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { parse } from 'node-html-parser'
|
|
2
|
+
|
|
3
|
+
import { decodeHtmlEntities } from './shared/utils/html'
|
|
4
|
+
import { parseDurationKey } from './shared/utils/toz'
|
|
5
|
+
import {
|
|
6
|
+
type TozBooth,
|
|
7
|
+
TozBoothSchema,
|
|
8
|
+
type TozBranch,
|
|
9
|
+
TozBranchSchema,
|
|
10
|
+
type TozDuration,
|
|
11
|
+
TozDurationSchema,
|
|
12
|
+
type TozMeeting,
|
|
13
|
+
TozMeetingSchema,
|
|
14
|
+
type TozReservation,
|
|
15
|
+
TozReservationSchema,
|
|
16
|
+
type TozReserved,
|
|
17
|
+
TozReservedSchema,
|
|
18
|
+
} from './types'
|
|
19
|
+
|
|
20
|
+
interface RawBooth {
|
|
21
|
+
resultMsg?: string
|
|
22
|
+
id?: number
|
|
23
|
+
name?: string
|
|
24
|
+
branchName?: string
|
|
25
|
+
branchTel?: string
|
|
26
|
+
minUseUserCount?: number
|
|
27
|
+
enableMaxUserCount?: number
|
|
28
|
+
boothGroupName?: string
|
|
29
|
+
boothGroupUrl?: string | null
|
|
30
|
+
boothMemoForUser?: string
|
|
31
|
+
isLargeBooth?: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface RawReserved {
|
|
35
|
+
result?: string
|
|
36
|
+
resultMsg?: string
|
|
37
|
+
branchName?: string
|
|
38
|
+
branchTel?: string
|
|
39
|
+
boothGroupName?: string
|
|
40
|
+
boothIsLarge?: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface RawDuration {
|
|
44
|
+
key: string
|
|
45
|
+
value: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function parseBookingPageBranches(html: string): TozBranch[] {
|
|
49
|
+
const root = parse(html)
|
|
50
|
+
const inputs = root.querySelectorAll('input[name="branch_id"]')
|
|
51
|
+
const branches: TozBranch[] = []
|
|
52
|
+
|
|
53
|
+
for (const input of inputs) {
|
|
54
|
+
const value = input.getAttribute('value')
|
|
55
|
+
if (!value) continue
|
|
56
|
+
const id = Number.parseInt(value, 10)
|
|
57
|
+
if (!Number.isFinite(id)) continue
|
|
58
|
+
|
|
59
|
+
const label = input.parentNode
|
|
60
|
+
const name = label ? cleanText(label.text) : ''
|
|
61
|
+
if (!name) continue
|
|
62
|
+
|
|
63
|
+
branches.push(TozBranchSchema.parse({ id, name }))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return branches
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function parseBookingPageMeetings(html: string): TozMeeting[] {
|
|
70
|
+
const root = parse(html)
|
|
71
|
+
const select = root.querySelector('select[name="meeting_id"]')
|
|
72
|
+
if (!select) return []
|
|
73
|
+
|
|
74
|
+
const meetings: TozMeeting[] = []
|
|
75
|
+
for (const option of select.querySelectorAll('option')) {
|
|
76
|
+
const value = option.getAttribute('value')
|
|
77
|
+
if (!value || value === '새모임') continue
|
|
78
|
+
const id = Number.parseInt(value, 10)
|
|
79
|
+
if (!Number.isFinite(id)) continue
|
|
80
|
+
|
|
81
|
+
const name = cleanText(option.text)
|
|
82
|
+
if (!name) continue
|
|
83
|
+
|
|
84
|
+
meetings.push(TozMeetingSchema.parse({ id, name }))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return meetings
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function parseTozDurations(json: unknown): TozDuration[] {
|
|
91
|
+
if (!Array.isArray(json)) return []
|
|
92
|
+
const seen = new Set<string>()
|
|
93
|
+
const durations: TozDuration[] = []
|
|
94
|
+
|
|
95
|
+
for (const raw of json as RawDuration[]) {
|
|
96
|
+
if (!raw?.key || !raw?.value || seen.has(raw.key)) continue
|
|
97
|
+
seen.add(raw.key)
|
|
98
|
+
durations.push(
|
|
99
|
+
TozDurationSchema.parse({
|
|
100
|
+
key: raw.key,
|
|
101
|
+
value: raw.value,
|
|
102
|
+
minutes: parseDurationKey(raw.key),
|
|
103
|
+
}),
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return durations
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function parseTozBoothes(json: unknown): TozBooth[] {
|
|
111
|
+
if (!Array.isArray(json)) return []
|
|
112
|
+
const items = json as RawBooth[]
|
|
113
|
+
if (items.length === 0) return []
|
|
114
|
+
|
|
115
|
+
const first = items[0]
|
|
116
|
+
if (first?.resultMsg && first.resultMsg !== 'SUCCESS') {
|
|
117
|
+
throw new Error(first.resultMsg)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return items
|
|
121
|
+
.filter((raw) => raw.id !== undefined && raw.id !== null)
|
|
122
|
+
.map((raw) =>
|
|
123
|
+
TozBoothSchema.parse({
|
|
124
|
+
id: raw.id,
|
|
125
|
+
name: raw.name ?? '',
|
|
126
|
+
branchName: raw.branchName ?? '',
|
|
127
|
+
branchTel: raw.branchTel ?? '',
|
|
128
|
+
minUseUserCount: raw.minUseUserCount ?? 0,
|
|
129
|
+
enableMaxUserCount: raw.enableMaxUserCount ?? 0,
|
|
130
|
+
boothGroupName: raw.boothGroupName ?? '',
|
|
131
|
+
boothGroupUrl: raw.boothGroupUrl ?? null,
|
|
132
|
+
boothMemoForUser: raw.boothMemoForUser ?? '',
|
|
133
|
+
isLargeBooth: Boolean(raw.isLargeBooth),
|
|
134
|
+
}),
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function parseTozReserved(json: unknown): TozReserved {
|
|
139
|
+
const raw = json as RawReserved | null
|
|
140
|
+
if (!raw || raw.result !== 'SUCCESS' || !raw.resultMsg) {
|
|
141
|
+
throw new Error(raw?.resultMsg || '부스 예약에 실패했습니다.')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return TozReservedSchema.parse({
|
|
145
|
+
reservationId: raw.resultMsg,
|
|
146
|
+
branchName: raw.branchName ?? '',
|
|
147
|
+
branchTel: raw.branchTel ?? '',
|
|
148
|
+
boothGroupName: raw.boothGroupName ?? '',
|
|
149
|
+
isLargeBooth: Boolean(raw.boothIsLarge),
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const SUCCESS_PATTERNS = ['예약 되었습니다', '대형부스 예약 신청되었습니다']
|
|
154
|
+
|
|
155
|
+
export function isReservationConfirmSuccess(resultMsg: string): boolean {
|
|
156
|
+
return SUCCESS_PATTERNS.some((p) => resultMsg.includes(p))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function parseMypageReservations(html: string): TozReservation[] {
|
|
160
|
+
const root = parse(html)
|
|
161
|
+
// The 실시간예약 table is the second table.reservation in the page.
|
|
162
|
+
// The 상담요청 table (first) lives inside a parent <table style="display:none">.
|
|
163
|
+
const tables = root.querySelectorAll('table.reservation')
|
|
164
|
+
if (tables.length === 0) return []
|
|
165
|
+
|
|
166
|
+
const target = tables[tables.length - 1]
|
|
167
|
+
const rows = target.querySelectorAll('tbody tr')
|
|
168
|
+
const reservations: TozReservation[] = []
|
|
169
|
+
|
|
170
|
+
for (const row of rows) {
|
|
171
|
+
const cells = row.querySelectorAll('td')
|
|
172
|
+
if (cells.length !== 8) continue
|
|
173
|
+
|
|
174
|
+
const [no, meetingName, date, time, branch, booth, reservedAt, statusCell] = cells
|
|
175
|
+
const cancelLink = statusCell.querySelector('a')
|
|
176
|
+
const reservationId = extractReservationIdFromOnclick(cancelLink?.getAttribute('href'))
|
|
177
|
+
|
|
178
|
+
reservations.push(
|
|
179
|
+
TozReservationSchema.parse({
|
|
180
|
+
no: Number.parseInt(cleanText(no.text), 10) || 0,
|
|
181
|
+
reservationId,
|
|
182
|
+
meetingName: cleanText(meetingName.text),
|
|
183
|
+
date: cleanText(date.text),
|
|
184
|
+
...splitTimeRange(cleanText(time.text)),
|
|
185
|
+
branchName: cleanText(branch.text),
|
|
186
|
+
boothName: cleanText(booth.text),
|
|
187
|
+
reservedAt: cleanText(reservedAt.text),
|
|
188
|
+
status: cleanText(statusCell.text),
|
|
189
|
+
}),
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return reservations
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function splitTimeRange(text: string): { startTime: string; endTime: string } {
|
|
197
|
+
const match = /(\d{1,2}:\d{2})\s*[~-]\s*(\d{1,2}:\d{2})/.exec(text)
|
|
198
|
+
if (!match) return { startTime: text, endTime: '' }
|
|
199
|
+
return { startTime: match[1], endTime: match[2] }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function extractReservationIdFromOnclick(href: string | undefined): number | null {
|
|
203
|
+
if (!href) return null
|
|
204
|
+
const match = /destroyReservation\(\s*(\d+)\s*\)/.exec(href)
|
|
205
|
+
if (!match) return null
|
|
206
|
+
return Number.parseInt(match[1], 10)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function cleanText(text: string): string {
|
|
210
|
+
return decodeHtmlEntities(text).replace(/\s+/g, ' ').trim()
|
|
211
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { afterEach, describe, expect, mock, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { TOZ_BASE_URL } from './constants'
|
|
4
|
+
import { TozHttp } from './toz-http'
|
|
5
|
+
|
|
6
|
+
const originalFetch = globalThis.fetch
|
|
7
|
+
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
globalThis.fetch = originalFetch
|
|
10
|
+
mock.restore()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
describe('TozHttp', () => {
|
|
14
|
+
test('bootstrap GETs index.htm to seed JSESSIONID', async () => {
|
|
15
|
+
const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
16
|
+
expect(String(input)).toBe(`${TOZ_BASE_URL}/index.htm`)
|
|
17
|
+
expect(init?.method).toBe('GET')
|
|
18
|
+
return createResponse('ok', ['JSESSIONID=ABC123; Path=/; HttpOnly'])
|
|
19
|
+
})
|
|
20
|
+
globalThis.fetch = fetchMock as typeof fetch
|
|
21
|
+
|
|
22
|
+
const http = new TozHttp()
|
|
23
|
+
await http.bootstrap()
|
|
24
|
+
|
|
25
|
+
expect(http.getSessionCookie()).toBe('ABC123')
|
|
26
|
+
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('bootstrap is no-op when JSESSIONID already set', async () => {
|
|
30
|
+
const fetchMock = mock(async () => createResponse('ok'))
|
|
31
|
+
globalThis.fetch = fetchMock as typeof fetch
|
|
32
|
+
|
|
33
|
+
const http = new TozHttp({ sessionCookie: 'JSESSIONID=EXISTING' })
|
|
34
|
+
await http.bootstrap()
|
|
35
|
+
|
|
36
|
+
expect(fetchMock).not.toHaveBeenCalled()
|
|
37
|
+
expect(http.getSessionCookie()).toBe('EXISTING')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('post sends URL-encoded body with required headers and cookie', async () => {
|
|
41
|
+
const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
42
|
+
expect(String(input)).toBe(`${TOZ_BASE_URL}/ajaxGetEnableBoothes.htm`)
|
|
43
|
+
expect(init?.method).toBe('POST')
|
|
44
|
+
|
|
45
|
+
const headers = init?.headers as Record<string, string>
|
|
46
|
+
expect(headers['Content-Type']).toBe('application/x-www-form-urlencoded; charset=UTF-8')
|
|
47
|
+
expect(headers['X-Requested-With']).toBe('XMLHttpRequest')
|
|
48
|
+
expect(headers.Referer).toBe(`${TOZ_BASE_URL}/booking.htm`)
|
|
49
|
+
expect(headers.cookie).toBe('JSESSIONID=ABC123')
|
|
50
|
+
|
|
51
|
+
expect(init?.body).toBe('basedate=2026-04-21&starttime=1400&durationTime=0200&userCount=2&branchIds=27%2C')
|
|
52
|
+
return createResponse('[]', [], 'application/json')
|
|
53
|
+
})
|
|
54
|
+
globalThis.fetch = fetchMock as typeof fetch
|
|
55
|
+
|
|
56
|
+
const http = new TozHttp({ sessionCookie: 'ABC123' })
|
|
57
|
+
const text = await http.post('/ajaxGetEnableBoothes.htm', {
|
|
58
|
+
basedate: '2026-04-21',
|
|
59
|
+
starttime: '1400',
|
|
60
|
+
durationTime: '0200',
|
|
61
|
+
userCount: '2',
|
|
62
|
+
branchIds: '27,',
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
expect(text).toBe('[]')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('postJson parses JSON response', async () => {
|
|
69
|
+
globalThis.fetch = mock(async () => createResponse('{"result":"SUCCESS","resultMsg":"abc"}')) as typeof fetch
|
|
70
|
+
|
|
71
|
+
const http = new TozHttp({ sessionCookie: 'ABC' })
|
|
72
|
+
const json = await http.postJson<{ result: string; resultMsg: string }>('/ajaxReservationBooth.htm', {})
|
|
73
|
+
|
|
74
|
+
expect(json).toEqual({ result: 'SUCCESS', resultMsg: 'abc' })
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('postText trims plain-text response', async () => {
|
|
78
|
+
globalThis.fetch = mock(async () => createResponse('SUCCESS\n')) as typeof fetch
|
|
79
|
+
|
|
80
|
+
const http = new TozHttp({ sessionCookie: 'ABC' })
|
|
81
|
+
expect(await http.postText('/ajaxHpVerify.htm', {})).toBe('SUCCESS')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('strips JSESSIONID= prefix when constructed with full cookie string', async () => {
|
|
85
|
+
const http = new TozHttp({ sessionCookie: 'JSESSIONID=XYZ789' })
|
|
86
|
+
expect(http.getSessionCookie()).toBe('XYZ789')
|
|
87
|
+
expect(http.getCookies()).toEqual({ JSESSIONID: 'XYZ789' })
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('constructor with cookies map preserves all cookies', async () => {
|
|
91
|
+
const http = new TozHttp({ cookies: { JSESSIONID: 'A', other: 'B' } })
|
|
92
|
+
expect(http.getCookies()).toEqual({ JSESSIONID: 'A', other: 'B' })
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('throws on non-2xx response', async () => {
|
|
96
|
+
globalThis.fetch = mock(async () =>
|
|
97
|
+
createResponse('Server Error', [], 'text/html', { status: 500 }),
|
|
98
|
+
) as typeof fetch
|
|
99
|
+
|
|
100
|
+
const http = new TozHttp({ sessionCookie: 'ABC' })
|
|
101
|
+
await expect(http.get('/index.htm')).rejects.toThrow(/HTTP 500/)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('refuses to follow cross-origin redirects (no cookie leak)', async () => {
|
|
105
|
+
globalThis.fetch = mock(async (input: RequestInfo | URL) => {
|
|
106
|
+
const url = String(input)
|
|
107
|
+
if (url.endsWith('/index.htm')) {
|
|
108
|
+
return redirectResponse('https://evil.example.com/steal')
|
|
109
|
+
}
|
|
110
|
+
throw new Error(`Should not fetch ${url} — cross-origin redirect must be blocked`)
|
|
111
|
+
}) as typeof fetch
|
|
112
|
+
|
|
113
|
+
const http = new TozHttp({ sessionCookie: 'ABC' })
|
|
114
|
+
await expect(http.get('/index.htm')).rejects.toThrow(/cross-origin redirect/)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('follows same-origin redirects normally', async () => {
|
|
118
|
+
let hit = 0
|
|
119
|
+
globalThis.fetch = mock(async (input: RequestInfo | URL) => {
|
|
120
|
+
const url = String(input)
|
|
121
|
+
hit += 1
|
|
122
|
+
if (hit === 1) return redirectResponse(`${TOZ_BASE_URL}/mypage.htm`)
|
|
123
|
+
expect(url).toBe(`${TOZ_BASE_URL}/mypage.htm`)
|
|
124
|
+
return createResponse('mypage content')
|
|
125
|
+
}) as typeof fetch
|
|
126
|
+
|
|
127
|
+
const http = new TozHttp({ sessionCookie: 'ABC' })
|
|
128
|
+
const body = await http.get('/mypage_login_.htm')
|
|
129
|
+
expect(body).toBe('mypage content')
|
|
130
|
+
expect(hit).toBe(2)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
function redirectResponse(location: string): Response {
|
|
135
|
+
const headers = new Headers({ location })
|
|
136
|
+
const response = new Response(null, { status: 302, headers })
|
|
137
|
+
Object.defineProperty(response.headers, 'getSetCookie', {
|
|
138
|
+
value: () => [],
|
|
139
|
+
configurable: true,
|
|
140
|
+
})
|
|
141
|
+
return response
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function createResponse(
|
|
145
|
+
body: string,
|
|
146
|
+
cookies: string[] = [],
|
|
147
|
+
contentType = 'text/html',
|
|
148
|
+
options: { status?: number } = {},
|
|
149
|
+
): Response {
|
|
150
|
+
const headers = new Headers({ 'Content-Type': contentType })
|
|
151
|
+
const response = new Response(body, { headers, status: options.status ?? 200 })
|
|
152
|
+
Object.defineProperty(response.headers, 'getSetCookie', {
|
|
153
|
+
value: () => cookies,
|
|
154
|
+
configurable: true,
|
|
155
|
+
})
|
|
156
|
+
return response
|
|
157
|
+
}
|
package/src/toz-http.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { TOZ_BASE_URL } from './constants'
|
|
2
|
+
|
|
3
|
+
const DEFAULT_USER_AGENT =
|
|
4
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36'
|
|
5
|
+
const DEFAULT_ACCEPT_LANGUAGE = 'ko,en-US;q=0.9,en;q=0.8'
|
|
6
|
+
|
|
7
|
+
interface HeadersWithCookieHelpers extends Omit<Headers, 'getSetCookie'> {
|
|
8
|
+
getSetCookie?: () => string[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface TozHttpOptions {
|
|
12
|
+
sessionCookie?: string
|
|
13
|
+
cookies?: Record<string, string>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface TozHttpState {
|
|
17
|
+
cookies: Record<string, string>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class TozHttp {
|
|
21
|
+
private cookies = new Map<string, string>()
|
|
22
|
+
|
|
23
|
+
constructor(options: TozHttpOptions = {}) {
|
|
24
|
+
if (options.cookies) {
|
|
25
|
+
for (const [name, value] of Object.entries(options.cookies)) {
|
|
26
|
+
this.cookies.set(name, value)
|
|
27
|
+
}
|
|
28
|
+
} else if (options.sessionCookie) {
|
|
29
|
+
this.cookies.set('JSESSIONID', stripJsessionPrefix(options.sessionCookie))
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async bootstrap(): Promise<void> {
|
|
34
|
+
if (this.cookies.has('JSESSIONID')) return
|
|
35
|
+
await this.get('/index.htm')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async get(path: string, params?: Record<string, string>): Promise<string> {
|
|
39
|
+
const response = await fetch(this.buildUrl(path, params), {
|
|
40
|
+
method: 'GET',
|
|
41
|
+
headers: this.buildHeaders(),
|
|
42
|
+
redirect: 'manual',
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
this.updateFromResponse(response)
|
|
46
|
+
return this.readFollowingRedirects(response, 'GET')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async post(path: string, body: Record<string, string>): Promise<string> {
|
|
50
|
+
const response = await this.rawPost(path, body)
|
|
51
|
+
return this.readFollowingRedirects(response, 'POST')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async postJson<T>(path: string, body: Record<string, string>): Promise<T> {
|
|
55
|
+
const text = await this.post(path, body)
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(text) as T
|
|
58
|
+
} catch (error) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Failed to parse JSON response from ${path}: ${error instanceof Error ? error.message : 'unknown'}`,
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async postText(path: string, body: Record<string, string>): Promise<string> {
|
|
66
|
+
return (await this.post(path, body)).trim()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
getCookies(): Record<string, string> {
|
|
70
|
+
return Object.fromEntries(this.cookies)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
getSessionCookie(): string | undefined {
|
|
74
|
+
return this.cookies.get('JSESSIONID')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
getState(): TozHttpState {
|
|
78
|
+
return { cookies: this.getCookies() }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private async rawPost(path: string, body: Record<string, string>): Promise<Response> {
|
|
82
|
+
const url = this.buildUrl(path)
|
|
83
|
+
const formBody = new URLSearchParams(body).toString()
|
|
84
|
+
|
|
85
|
+
const response = await fetch(url, {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
headers: {
|
|
88
|
+
...this.buildHeaders(),
|
|
89
|
+
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
|
90
|
+
'X-Requested-With': 'XMLHttpRequest',
|
|
91
|
+
Referer: this.buildUrl('/booking.htm'),
|
|
92
|
+
},
|
|
93
|
+
body: formBody,
|
|
94
|
+
redirect: 'manual',
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
this.updateFromResponse(response)
|
|
98
|
+
return response
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private async readFollowingRedirects(initial: Response, method: string): Promise<string> {
|
|
102
|
+
let response = initial
|
|
103
|
+
let hops = 0
|
|
104
|
+
|
|
105
|
+
while (response.status >= 300 && response.status < 400 && hops < 5) {
|
|
106
|
+
const location = response.headers.get('location')
|
|
107
|
+
if (!location) break
|
|
108
|
+
const redirectUrl = location.startsWith('http') ? location : new URL(location, `${TOZ_BASE_URL}/`).toString()
|
|
109
|
+
if (!isSameOrigin(redirectUrl, TOZ_BASE_URL)) {
|
|
110
|
+
throw new Error(`Refusing to follow cross-origin redirect to ${new URL(redirectUrl).origin}`)
|
|
111
|
+
}
|
|
112
|
+
response = await fetch(redirectUrl, {
|
|
113
|
+
method: 'GET',
|
|
114
|
+
headers: this.buildHeaders(),
|
|
115
|
+
redirect: 'manual',
|
|
116
|
+
})
|
|
117
|
+
this.updateFromResponse(response)
|
|
118
|
+
hops += 1
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!response.ok) {
|
|
122
|
+
throw new Error(`Toz ${method} ${initial.url} failed: HTTP ${response.status} ${response.statusText}`)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return response.text()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private buildUrl(path: string, params?: Record<string, string>): string {
|
|
129
|
+
const normalized = path.startsWith('/') ? path.slice(1) : path
|
|
130
|
+
const url = new URL(normalized, `${TOZ_BASE_URL}/`)
|
|
131
|
+
if (params) {
|
|
132
|
+
for (const [key, value] of Object.entries(params)) {
|
|
133
|
+
url.searchParams.set(key, value)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return url.toString()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private buildHeaders(): Record<string, string> {
|
|
140
|
+
const cookieHeader = this.serializeCookies()
|
|
141
|
+
return {
|
|
142
|
+
'Accept-Language': DEFAULT_ACCEPT_LANGUAGE,
|
|
143
|
+
'User-Agent': DEFAULT_USER_AGENT,
|
|
144
|
+
...(cookieHeader ? { cookie: cookieHeader } : {}),
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private updateFromResponse(response: Response): void {
|
|
149
|
+
for (const cookie of readSetCookies(response.headers)) {
|
|
150
|
+
this.setCookie(cookie)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private setCookie(cookieHeader: string): void {
|
|
155
|
+
const pair = cookieHeader.split(';')[0]?.trim()
|
|
156
|
+
if (!pair) return
|
|
157
|
+
const idx = pair.indexOf('=')
|
|
158
|
+
if (idx === -1) return
|
|
159
|
+
const name = pair.slice(0, idx).trim()
|
|
160
|
+
const value = pair.slice(idx + 1).trim()
|
|
161
|
+
if (name) this.cookies.set(name, value)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private serializeCookies(): string {
|
|
165
|
+
return [...this.cookies.entries()].map(([name, value]) => `${name}=${value}`).join('; ')
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function stripJsessionPrefix(cookie: string): string {
|
|
170
|
+
return cookie.includes('=') ? (cookie.split('=', 2)[1] ?? cookie) : cookie
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isSameOrigin(url: string, baseUrl: string): boolean {
|
|
174
|
+
try {
|
|
175
|
+
return new URL(url).origin === new URL(baseUrl).origin
|
|
176
|
+
} catch {
|
|
177
|
+
return false
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function readSetCookies(headers: Headers): string[] {
|
|
182
|
+
const enhanced = headers as HeadersWithCookieHelpers
|
|
183
|
+
if (typeof enhanced.getSetCookie === 'function') {
|
|
184
|
+
return enhanced.getSetCookie()
|
|
185
|
+
}
|
|
186
|
+
const single = headers.get('set-cookie')
|
|
187
|
+
return single ? [single] : []
|
|
188
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -151,9 +151,67 @@ export const CredentialsSchema = z.object({
|
|
|
151
151
|
username: z.string().optional(),
|
|
152
152
|
password: z.string().optional(),
|
|
153
153
|
loggedInAt: z.string().optional(),
|
|
154
|
+
tozName: z.string().optional(),
|
|
155
|
+
tozPhone: z.string().optional(),
|
|
154
156
|
})
|
|
155
157
|
export type Credentials = z.infer<typeof CredentialsSchema>
|
|
156
158
|
|
|
159
|
+
export const TozBranchSchema = z.object({
|
|
160
|
+
id: z.number(),
|
|
161
|
+
name: z.string(),
|
|
162
|
+
})
|
|
163
|
+
export type TozBranch = z.infer<typeof TozBranchSchema>
|
|
164
|
+
|
|
165
|
+
export const TozMeetingSchema = z.object({
|
|
166
|
+
id: z.number(),
|
|
167
|
+
name: z.string(),
|
|
168
|
+
})
|
|
169
|
+
export type TozMeeting = z.infer<typeof TozMeetingSchema>
|
|
170
|
+
|
|
171
|
+
export const TozDurationSchema = z.object({
|
|
172
|
+
key: z.string(),
|
|
173
|
+
value: z.string(),
|
|
174
|
+
minutes: z.number(),
|
|
175
|
+
})
|
|
176
|
+
export type TozDuration = z.infer<typeof TozDurationSchema>
|
|
177
|
+
|
|
178
|
+
export const TozBoothSchema = z.object({
|
|
179
|
+
id: z.number(),
|
|
180
|
+
name: z.string(),
|
|
181
|
+
branchName: z.string(),
|
|
182
|
+
branchTel: z.string(),
|
|
183
|
+
minUseUserCount: z.number(),
|
|
184
|
+
enableMaxUserCount: z.number(),
|
|
185
|
+
boothGroupName: z.string(),
|
|
186
|
+
boothGroupUrl: z.string().nullable(),
|
|
187
|
+
boothMemoForUser: z.string(),
|
|
188
|
+
isLargeBooth: z.boolean(),
|
|
189
|
+
})
|
|
190
|
+
export type TozBooth = z.infer<typeof TozBoothSchema>
|
|
191
|
+
|
|
192
|
+
export const TozReservedSchema = z.object({
|
|
193
|
+
reservationId: z.string(),
|
|
194
|
+
branchName: z.string(),
|
|
195
|
+
branchTel: z.string(),
|
|
196
|
+
boothGroupName: z.string(),
|
|
197
|
+
isLargeBooth: z.boolean(),
|
|
198
|
+
})
|
|
199
|
+
export type TozReserved = z.infer<typeof TozReservedSchema>
|
|
200
|
+
|
|
201
|
+
export const TozReservationSchema = z.object({
|
|
202
|
+
no: z.number(),
|
|
203
|
+
reservationId: z.number().nullable(),
|
|
204
|
+
meetingName: z.string(),
|
|
205
|
+
date: z.string(),
|
|
206
|
+
startTime: z.string(),
|
|
207
|
+
endTime: z.string(),
|
|
208
|
+
branchName: z.string(),
|
|
209
|
+
boothName: z.string(),
|
|
210
|
+
reservedAt: z.string(),
|
|
211
|
+
status: z.string(),
|
|
212
|
+
})
|
|
213
|
+
export type TozReservation = z.infer<typeof TozReservationSchema>
|
|
214
|
+
|
|
157
215
|
export const ReportListItemSchema = z.object({
|
|
158
216
|
id: z.number(),
|
|
159
217
|
category: z.string(),
|