opensoma 0.4.0 → 0.5.1

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.
Files changed (75) hide show
  1. package/dist/package.json +1 -1
  2. package/dist/src/client.d.ts +7 -1
  3. package/dist/src/client.d.ts.map +1 -1
  4. package/dist/src/client.js +13 -11
  5. package/dist/src/client.js.map +1 -1
  6. package/dist/src/commands/auth.d.ts +1 -1
  7. package/dist/src/commands/auth.d.ts.map +1 -1
  8. package/dist/src/commands/auth.js +94 -52
  9. package/dist/src/commands/auth.js.map +1 -1
  10. package/dist/src/constants.d.ts +40 -0
  11. package/dist/src/constants.d.ts.map +1 -1
  12. package/dist/src/constants.js +42 -0
  13. package/dist/src/constants.js.map +1 -1
  14. package/dist/src/formatters.d.ts.map +1 -1
  15. package/dist/src/formatters.js +42 -16
  16. package/dist/src/formatters.js.map +1 -1
  17. package/dist/src/index.d.ts +3 -0
  18. package/dist/src/index.d.ts.map +1 -1
  19. package/dist/src/index.js +2 -0
  20. package/dist/src/index.js.map +1 -1
  21. package/dist/src/shared/utils/swmaestro.d.ts.map +1 -1
  22. package/dist/src/shared/utils/swmaestro.js +1 -5
  23. package/dist/src/shared/utils/swmaestro.js.map +1 -1
  24. package/dist/src/shared/utils/toz.d.ts +23 -0
  25. package/dist/src/shared/utils/toz.d.ts.map +1 -0
  26. package/dist/src/shared/utils/toz.js +72 -0
  27. package/dist/src/shared/utils/toz.js.map +1 -0
  28. package/dist/src/token-extractor.d.ts +9 -1
  29. package/dist/src/token-extractor.d.ts.map +1 -1
  30. package/dist/src/token-extractor.js +54 -10
  31. package/dist/src/token-extractor.js.map +1 -1
  32. package/dist/src/toz-formatters.d.ts +9 -0
  33. package/dist/src/toz-formatters.d.ts.map +1 -0
  34. package/dist/src/toz-formatters.js +151 -0
  35. package/dist/src/toz-formatters.js.map +1 -0
  36. package/dist/src/toz-http.d.ts +27 -0
  37. package/dist/src/toz-http.d.ts.map +1 -0
  38. package/dist/src/toz-http.js +154 -0
  39. package/dist/src/toz-http.js.map +1 -0
  40. package/dist/src/types.d.ts +52 -0
  41. package/dist/src/types.d.ts.map +1 -1
  42. package/dist/src/types.js +46 -0
  43. package/dist/src/types.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/__fixtures__/toz/toz_all_branches.json +211 -0
  46. package/src/__fixtures__/toz/toz_booking.html +2190 -0
  47. package/src/__fixtures__/toz/toz_boothes.json +59 -0
  48. package/src/__fixtures__/toz/toz_duration.json +25 -0
  49. package/src/__fixtures__/toz/toz_mypage_response.html +388 -0
  50. package/src/__fixtures__/toz/toz_page.html +211 -0
  51. package/src/client.test.ts +135 -117
  52. package/src/client.ts +16 -12
  53. package/src/commands/auth.test.ts +7 -7
  54. package/src/commands/auth.ts +107 -50
  55. package/src/commands/helpers.test.ts +8 -8
  56. package/src/commands/report.test.ts +7 -7
  57. package/src/constants.ts +50 -0
  58. package/src/credential-manager.test.ts +5 -5
  59. package/src/formatters.test.ts +22 -22
  60. package/src/formatters.ts +44 -16
  61. package/src/http.test.ts +37 -37
  62. package/src/index.ts +3 -0
  63. package/src/shared/utils/mentoring-params.test.ts +16 -16
  64. package/src/shared/utils/swmaestro.test.ts +87 -8
  65. package/src/shared/utils/swmaestro.ts +1 -6
  66. package/src/shared/utils/toz.test.ts +138 -0
  67. package/src/shared/utils/toz.ts +100 -0
  68. package/src/token-extractor.test.ts +40 -15
  69. package/src/token-extractor.ts +65 -13
  70. package/src/toz-formatters.test.ts +197 -0
  71. package/src/toz-formatters.ts +211 -0
  72. package/src/toz-http.test.ts +157 -0
  73. package/src/toz-http.ts +188 -0
  74. package/src/types.test.ts +220 -204
  75. package/src/types.ts +58 -0
@@ -8,7 +8,7 @@ import { basename, dirname, join } from 'node:path'
8
8
  const require = createRequire(import.meta.url)
9
9
 
10
10
  const COOKIE_QUERY =
11
- "SELECT encrypted_value, last_access_utc, value FROM cookies WHERE host_key LIKE '%swmaestro.ai' AND name = 'JSESSIONID' ORDER BY last_access_utc DESC LIMIT 1"
11
+ "SELECT encrypted_value, last_access_utc, value FROM cookies WHERE (host_key LIKE '%swmaestro.ai' OR host_key LIKE '%opensoma.dev') AND name = 'JSESSIONID' ORDER BY last_access_utc DESC LIMIT 1"
12
12
  const CHROMIUM_SALT = 'saltysalt'
13
13
  const CHROMIUM_IV = Buffer.alloc(16, 0x20)
14
14
  const PROFILE_DIR_PATTERN = /^Profile\s+\d+$/
@@ -124,11 +124,36 @@ function queryCookieDb(dbPath: string): CookieRow | undefined {
124
124
  }
125
125
  }
126
126
 
127
+ export type TokenExtractorOptions = {
128
+ platform?: NodeJS.Platform
129
+ homeDirectory?: string
130
+ debug?: boolean
131
+ }
132
+
127
133
  export class TokenExtractor {
128
- constructor(
129
- private readonly platform: NodeJS.Platform = process.platform,
130
- private readonly homeDirectory: string = homedir(),
131
- ) {}
134
+ private readonly platform: NodeJS.Platform
135
+ private readonly homeDirectory: string
136
+ private readonly debugEnabled: boolean
137
+
138
+ constructor(options?: TokenExtractorOptions)
139
+ constructor(platform?: NodeJS.Platform, homeDirectory?: string, debug?: boolean)
140
+ constructor(platformOrOptions?: NodeJS.Platform | TokenExtractorOptions, homeDirectory?: string, debug?: boolean) {
141
+ if (typeof platformOrOptions === 'object' && platformOrOptions !== null) {
142
+ this.platform = platformOrOptions.platform ?? process.platform
143
+ this.homeDirectory = platformOrOptions.homeDirectory ?? homedir()
144
+ this.debugEnabled = platformOrOptions.debug ?? false
145
+ } else {
146
+ this.platform = platformOrOptions ?? process.platform
147
+ this.homeDirectory = homeDirectory ?? homedir()
148
+ this.debugEnabled = debug ?? false
149
+ }
150
+ }
151
+
152
+ private log(...args: unknown[]): void {
153
+ if (!this.debugEnabled) return
154
+ const message = args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')
155
+ process.stderr.write(`[extract] ${message}\n`)
156
+ }
132
157
 
133
158
  async extract(): Promise<{ sessionCookie: string } | null> {
134
159
  const candidates = await this.extractCandidates()
@@ -143,28 +168,35 @@ export class TokenExtractor {
143
168
 
144
169
  async extractCandidates(): Promise<ExtractedSessionCandidate[]> {
145
170
  const candidates = new Map<string, ExtractedSessionCandidate>()
171
+ const cookieDatabases = this.findCookieDatabases()
172
+ this.log(`Found ${cookieDatabases.length} cookie database(s)`)
146
173
 
147
- for (const databasePath of this.findCookieDatabases()) {
174
+ for (const databasePath of cookieDatabases) {
148
175
  const browser = this.getBrowserByPath(databasePath)
149
176
  if (!browser) {
177
+ this.log(`Skipping ${databasePath} (no matching browser config)`)
150
178
  continue
151
179
  }
152
180
 
153
181
  const profile = basename(dirname(databasePath))
182
+ this.log(`Processing ${browser.name} / ${profile}`)
154
183
 
155
184
  const tempDirectory = mkdtempSync(join(tmpdir(), 'opensoma-cookie-db-'))
156
185
  const tempDatabasePath = join(tempDirectory, 'Cookies')
157
186
 
158
187
  try {
159
188
  copySqliteDatabase(databasePath, tempDatabasePath)
189
+ this.log(` Copied DB to ${tempDatabasePath}`)
160
190
 
161
191
  const row = queryCookieDb(tempDatabasePath)
162
192
  if (!row) {
193
+ this.log(' No JSESSIONID cookie found')
163
194
  continue
164
195
  }
165
196
 
166
197
  const plaintextValue = normalizeCookieText(row.value)
167
198
  if (plaintextValue) {
199
+ this.log(` Found plaintext cookie (${plaintextValue.length} chars)`)
168
200
  this.addCandidate(candidates, {
169
201
  browser: browser.name,
170
202
  lastAccessUtc: this.normalizeLastAccessUtc(row.last_access_utc),
@@ -176,29 +208,40 @@ export class TokenExtractor {
176
208
 
177
209
  const encryptedValue = normalizeCookieBytes(row.encrypted_value)
178
210
  if (!encryptedValue || encryptedValue.length === 0) {
211
+ this.log(' No plaintext or encrypted value')
179
212
  continue
180
213
  }
181
214
 
215
+ this.log(` Decrypting encrypted cookie (${encryptedValue.length} bytes)...`)
182
216
  const decryptedValue = await this.decryptCookie(encryptedValue, browser.name)
183
217
  if (decryptedValue) {
218
+ this.log(` Decrypted successfully (${decryptedValue.length} chars)`)
184
219
  this.addCandidate(candidates, {
185
220
  browser: browser.name,
186
221
  lastAccessUtc: this.normalizeLastAccessUtc(row.last_access_utc),
187
222
  profile,
188
223
  sessionCookie: decryptedValue,
189
224
  })
225
+ } else {
226
+ this.log(' Decryption returned empty value')
190
227
  }
191
- } catch {
228
+ } catch (error) {
229
+ this.log(` Error: ${error instanceof Error ? error.message : String(error)}`)
192
230
  } finally {
193
231
  rmSync(tempDirectory, { recursive: true, force: true })
194
232
  }
195
233
  }
196
234
 
235
+ this.log(`Total unique candidates: ${candidates.size}`)
197
236
  return [...candidates.values()].sort((left, right) => right.lastAccessUtc - left.lastAccessUtc)
198
237
  }
199
238
 
200
239
  findCookieDatabases(): string[] {
201
- return BROWSERS.flatMap((browser) => this.findBrowserCookieDatabases(browser))
240
+ const results = BROWSERS.flatMap((browser) => this.findBrowserCookieDatabases(browser))
241
+ if (results.length === 0) {
242
+ this.log('No cookie databases found. Searched browsers:', BROWSERS.map((b) => b.name).join(', '))
243
+ }
244
+ return results
202
245
  }
203
246
 
204
247
  private async decryptCookie(encryptedValue: Buffer, browserName: string): Promise<string> {
@@ -207,14 +250,18 @@ export class TokenExtractor {
207
250
  }
208
251
 
209
252
  if (this.platform === 'linux') {
253
+ this.log(' Using Linux PBKDF2 decryption')
210
254
  return this.decryptChromiumValue(encryptedValue, pbkdf2Sync('peanuts', CHROMIUM_SALT, 1, 16, 'sha1'))
211
255
  }
212
256
 
213
257
  if (this.platform === 'darwin') {
258
+ this.log(` Fetching macOS Keychain key for ${browserName}...`)
214
259
  const key = await this.getMacOSEncryptionKey(browserName)
260
+ this.log(' Keychain key obtained')
215
261
  return this.decryptChromiumValue(encryptedValue, key)
216
262
  }
217
263
 
264
+ this.log(` Unsupported platform for decryption: ${this.platform}`)
218
265
  return ''
219
266
  }
220
267
 
@@ -224,11 +271,10 @@ export class TokenExtractor {
224
271
  throw new Error(`Unsupported browser: ${browserName}`)
225
272
  }
226
273
 
227
- const password = execSync(
228
- `security find-generic-password -s ${JSON.stringify(browser.keychainService)} -a ${JSON.stringify(browser.keychainAccount)} -w`,
229
- { encoding: 'utf8' },
230
- ).trimEnd()
274
+ const command = `security find-generic-password -s ${JSON.stringify(browser.keychainService)} -a ${JSON.stringify(browser.keychainAccount)} -w`
275
+ this.log(` Keychain command: ${command}`)
231
276
 
277
+ const password = execSync(command, { encoding: 'utf8' }).trimEnd()
232
278
  return pbkdf2Sync(password, CHROMIUM_SALT, 1003, 16, 'sha1')
233
279
  }
234
280
 
@@ -266,14 +312,20 @@ export class TokenExtractor {
266
312
  private findBrowserCookieDatabases(browser: BrowserConfig): string[] {
267
313
  const browserRoot = this.getBrowserRoot(browser)
268
314
  if (!browserRoot || !existsSync(browserRoot)) {
315
+ this.log(`${browser.name}: not found at ${browserRoot ?? '(unsupported platform)'}`)
269
316
  return []
270
317
  }
271
318
 
272
- return readdirSync(browserRoot, { withFileTypes: true })
319
+ const profiles = readdirSync(browserRoot, { withFileTypes: true })
273
320
  .filter((entry) => entry.isDirectory() && this.isSupportedProfileDirectory(entry.name))
274
321
  .sort((left, right) => left.name.localeCompare(right.name))
322
+
323
+ const databases = profiles
275
324
  .map((entry) => join(browserRoot, entry.name, 'Cookies'))
276
325
  .filter((databasePath) => existsSync(databasePath))
326
+
327
+ this.log(`${browser.name}: ${profiles.length} profile(s), ${databases.length} cookie DB(s)`)
328
+ return databases
277
329
  }
278
330
 
279
331
  private getBrowserRoot(browser: BrowserConfig): string | null {
@@ -0,0 +1,197 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ import {
6
+ isReservationConfirmSuccess,
7
+ parseBookingPageBranches,
8
+ parseBookingPageMeetings,
9
+ parseMypageReservations,
10
+ parseTozBoothes,
11
+ parseTozDurations,
12
+ parseTozReserved,
13
+ } from './toz-formatters'
14
+
15
+ const FIXTURES_DIR = join(__dirname, '__fixtures__', 'toz')
16
+
17
+ function readFixture(name: string): string {
18
+ return readFileSync(join(FIXTURES_DIR, name), 'utf8')
19
+ }
20
+
21
+ function readJsonFixture<T>(name: string): T {
22
+ return JSON.parse(readFixture(name)) as T
23
+ }
24
+
25
+ describe('parseBookingPageBranches', () => {
26
+ it('extracts all 9 SW마에스트로 partner branches', () => {
27
+ const html = readFixture('toz_booking.html')
28
+ const branches = parseBookingPageBranches(html)
29
+
30
+ expect(branches).toEqual([
31
+ { id: 27, name: '강남토즈타워점' },
32
+ { id: 145, name: '강남컨퍼런스센터' },
33
+ { id: 19, name: '양재점' },
34
+ { id: 20, name: '건대점' },
35
+ { id: 15, name: '선릉점' },
36
+ { id: 139, name: '마이스 역삼센터' },
37
+ { id: 134, name: '마이스 광화문센터' },
38
+ { id: 30, name: '신촌비즈센터' },
39
+ { id: 149, name: '홍대점' },
40
+ ])
41
+ })
42
+ })
43
+
44
+ describe('parseBookingPageMeetings', () => {
45
+ it('extracts meeting options excluding placeholder and 새모임', () => {
46
+ const html = readFixture('toz_booking.html')
47
+ const meetings = parseBookingPageMeetings(html)
48
+
49
+ expect(meetings.length).toBeGreaterThan(50)
50
+ expect(meetings[0]).toEqual({ id: 2305094, name: '멘토특강_김현동 님' })
51
+ expect(meetings.find((m) => m.id === 2322942)).toEqual({
52
+ id: 2322942,
53
+ name: '자유멘토링_SW마에스트로',
54
+ })
55
+
56
+ expect(meetings.find((m) => m.name === '[새 모임]')).toBeUndefined()
57
+ for (const meeting of meetings) {
58
+ expect(Number.isInteger(meeting.id)).toBe(true)
59
+ expect(meeting.id).toBeGreaterThan(0)
60
+ }
61
+ })
62
+ })
63
+
64
+ describe('parseTozDurations', () => {
65
+ it('parses duration JSON with computed minutes', () => {
66
+ const json = readJsonFixture('toz_duration.json')
67
+ const durations = parseTozDurations(json)
68
+
69
+ expect(durations[0]).toEqual({ key: '0200', value: '2시간', minutes: 120 })
70
+ expect(durations[1]).toEqual({ key: '0230', value: '2시간 30분', minutes: 150 })
71
+ expect(durations[2]).toEqual({ key: '0300', value: '3시간', minutes: 180 })
72
+ })
73
+
74
+ it('returns [] for non-array', () => {
75
+ expect(parseTozDurations(null)).toEqual([])
76
+ expect(parseTozDurations({})).toEqual([])
77
+ })
78
+ })
79
+
80
+ describe('parseTozBoothes', () => {
81
+ it('parses booth JSON array', () => {
82
+ const json = readJsonFixture('toz_boothes.json')
83
+ const boothes = parseTozBoothes(json)
84
+
85
+ expect(boothes).toHaveLength(3)
86
+ expect(boothes[0]).toEqual({
87
+ id: 740,
88
+ name: '304 _ A',
89
+ branchName: '토즈타워점',
90
+ branchTel: '02-3454-0116',
91
+ minUseUserCount: 1,
92
+ enableMaxUserCount: 2,
93
+ boothGroupName: '2인부스 A타입',
94
+ boothGroupUrl: 'https://moim.toz.co.kr/branchDetail?branch_id=28&url=&path=',
95
+ boothMemoForUser: '15,000원(기본2시간) / 기업, 학생할인 적용',
96
+ isLargeBooth: false,
97
+ })
98
+ })
99
+
100
+ it('throws on non-SUCCESS error response', () => {
101
+ const error = [{ resultMsg: '예약 가능한 부스가 없습니다.' }]
102
+ expect(() => parseTozBoothes(error)).toThrow('예약 가능한 부스가 없습니다.')
103
+ })
104
+
105
+ it('returns [] for empty', () => {
106
+ expect(parseTozBoothes([])).toEqual([])
107
+ expect(parseTozBoothes(null)).toEqual([])
108
+ })
109
+ })
110
+
111
+ describe('parseTozReserved', () => {
112
+ it('parses successful booth reservation response', () => {
113
+ const reserved = parseTozReserved({
114
+ result: 'SUCCESS',
115
+ resultMsg: 'abc-123',
116
+ branchName: '토즈타워점',
117
+ branchTel: '02-3454-0116',
118
+ boothGroupName: '2인부스 A타입',
119
+ boothIsLarge: false,
120
+ entitys: [],
121
+ })
122
+
123
+ expect(reserved).toEqual({
124
+ reservationId: 'abc-123',
125
+ branchName: '토즈타워점',
126
+ branchTel: '02-3454-0116',
127
+ boothGroupName: '2인부스 A타입',
128
+ isLargeBooth: false,
129
+ })
130
+ })
131
+
132
+ it('throws on failure response', () => {
133
+ expect(() => parseTozReserved({ result: 'FAIL', resultMsg: '이미 예약된 부스입니다.' })).toThrow(
134
+ '이미 예약된 부스입니다.',
135
+ )
136
+ })
137
+ })
138
+
139
+ describe('isReservationConfirmSuccess', () => {
140
+ it('detects standard success', () => {
141
+ expect(isReservationConfirmSuccess('예약 되었습니다.')).toBe(true)
142
+ })
143
+
144
+ it('detects large-booth success', () => {
145
+ expect(isReservationConfirmSuccess('대형부스 예약 신청되었습니다. 지점에서 확인 전화를 드릴 예정입니다.')).toBe(
146
+ true,
147
+ )
148
+ })
149
+
150
+ it('rejects error messages', () => {
151
+ expect(isReservationConfirmSuccess('인증번호가 올바르지 않습니다.')).toBe(false)
152
+ })
153
+ })
154
+
155
+ describe('parseMypageReservations', () => {
156
+ it('returns empty array when no reservations', () => {
157
+ const html = readFixture('toz_mypage_response.html')
158
+ expect(parseMypageReservations(html)).toEqual([])
159
+ })
160
+
161
+ it('parses synthetic reservation row', () => {
162
+ const html = `
163
+ <html><body>
164
+ <table class="reservation"><thead><tr><th>NO</th></tr></thead><tbody>
165
+ <tr><td colspan="7">예약 요청 정보가 없습니다.</td></tr>
166
+ </tbody></table>
167
+ <table class="reservation"><thead></thead><tbody>
168
+ <tr>
169
+ <td>1</td>
170
+ <td>자유멘토링_홍길동</td>
171
+ <td>2026-04-21</td>
172
+ <td>14:00 ~ 16:00</td>
173
+ <td>토즈타워점</td>
174
+ <td>304 _ A</td>
175
+ <td>2026-04-17 18:45</td>
176
+ <td>예약완료 <a href="javascript:destroyReservation(987654)">취소</a></td>
177
+ </tr>
178
+ </tbody></table>
179
+ </body></html>
180
+ `
181
+
182
+ expect(parseMypageReservations(html)).toEqual([
183
+ {
184
+ no: 1,
185
+ reservationId: 987654,
186
+ meetingName: '자유멘토링_홍길동',
187
+ date: '2026-04-21',
188
+ startTime: '14:00',
189
+ endTime: '16:00',
190
+ branchName: '토즈타워점',
191
+ boothName: '304 _ A',
192
+ reservedAt: '2026-04-17 18:45',
193
+ status: '예약완료 취소',
194
+ },
195
+ ])
196
+ })
197
+ })
@@ -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
+ }