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.
- package/dist/package.json +1 -1
- package/dist/src/client.d.ts +7 -1
- package/dist/src/client.d.ts.map +1 -1
- package/dist/src/client.js +13 -11
- package/dist/src/client.js.map +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/swmaestro.d.ts.map +1 -1
- package/dist/src/shared/utils/swmaestro.js +1 -5
- package/dist/src/shared/utils/swmaestro.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/client.test.ts +135 -117
- package/src/client.ts +16 -12
- package/src/commands/auth.test.ts +7 -7
- package/src/commands/auth.ts +107 -50
- package/src/commands/helpers.test.ts +8 -8
- package/src/commands/report.test.ts +7 -7
- package/src/constants.ts +50 -0
- package/src/credential-manager.test.ts +5 -5
- package/src/formatters.test.ts +22 -22
- package/src/formatters.ts +44 -16
- package/src/http.test.ts +37 -37
- package/src/index.ts +3 -0
- package/src/shared/utils/mentoring-params.test.ts +16 -16
- package/src/shared/utils/swmaestro.test.ts +87 -8
- package/src/shared/utils/swmaestro.ts +1 -6
- package/src/shared/utils/toz.test.ts +138 -0
- package/src/shared/utils/toz.ts +100 -0
- package/src/token-extractor.test.ts +40 -15
- 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.test.ts +220 -204
- package/src/types.ts +58 -0
package/src/token-extractor.ts
CHANGED
|
@@ -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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
228
|
-
|
|
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
|
-
|
|
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
|
+
}
|