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.
Files changed (56) hide show
  1. package/dist/package.json +1 -1
  2. package/dist/src/commands/auth.d.ts +1 -1
  3. package/dist/src/commands/auth.d.ts.map +1 -1
  4. package/dist/src/commands/auth.js +94 -52
  5. package/dist/src/commands/auth.js.map +1 -1
  6. package/dist/src/constants.d.ts +40 -0
  7. package/dist/src/constants.d.ts.map +1 -1
  8. package/dist/src/constants.js +42 -0
  9. package/dist/src/constants.js.map +1 -1
  10. package/dist/src/formatters.d.ts.map +1 -1
  11. package/dist/src/formatters.js +42 -16
  12. package/dist/src/formatters.js.map +1 -1
  13. package/dist/src/index.d.ts +3 -0
  14. package/dist/src/index.d.ts.map +1 -1
  15. package/dist/src/index.js +2 -0
  16. package/dist/src/index.js.map +1 -1
  17. package/dist/src/shared/utils/toz.d.ts +23 -0
  18. package/dist/src/shared/utils/toz.d.ts.map +1 -0
  19. package/dist/src/shared/utils/toz.js +72 -0
  20. package/dist/src/shared/utils/toz.js.map +1 -0
  21. package/dist/src/token-extractor.d.ts +9 -1
  22. package/dist/src/token-extractor.d.ts.map +1 -1
  23. package/dist/src/token-extractor.js +54 -10
  24. package/dist/src/token-extractor.js.map +1 -1
  25. package/dist/src/toz-formatters.d.ts +9 -0
  26. package/dist/src/toz-formatters.d.ts.map +1 -0
  27. package/dist/src/toz-formatters.js +151 -0
  28. package/dist/src/toz-formatters.js.map +1 -0
  29. package/dist/src/toz-http.d.ts +27 -0
  30. package/dist/src/toz-http.d.ts.map +1 -0
  31. package/dist/src/toz-http.js +154 -0
  32. package/dist/src/toz-http.js.map +1 -0
  33. package/dist/src/types.d.ts +52 -0
  34. package/dist/src/types.d.ts.map +1 -1
  35. package/dist/src/types.js +46 -0
  36. package/dist/src/types.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/__fixtures__/toz/toz_all_branches.json +211 -0
  39. package/src/__fixtures__/toz/toz_booking.html +2190 -0
  40. package/src/__fixtures__/toz/toz_boothes.json +59 -0
  41. package/src/__fixtures__/toz/toz_duration.json +25 -0
  42. package/src/__fixtures__/toz/toz_mypage_response.html +388 -0
  43. package/src/__fixtures__/toz/toz_page.html +211 -0
  44. package/src/commands/auth.ts +107 -50
  45. package/src/constants.ts +50 -0
  46. package/src/formatters.ts +44 -16
  47. package/src/index.ts +3 -0
  48. package/src/shared/utils/toz.test.ts +133 -0
  49. package/src/shared/utils/toz.ts +100 -0
  50. package/src/token-extractor.test.ts +30 -5
  51. package/src/token-extractor.ts +65 -13
  52. package/src/toz-formatters.test.ts +197 -0
  53. package/src/toz-formatters.ts +211 -0
  54. package/src/toz-http.test.ts +157 -0
  55. package/src/toz-http.ts +188 -0
  56. package/src/types.ts +58 -0
@@ -0,0 +1,211 @@
1
+ <!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
2
+ <html>
3
+ <head>
4
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
5
+ <title>토즈</title>
6
+
7
+ <script type="text/javascript" src="/js/jquery-1.4.4.min.js"></script>
8
+ <script type="text/javascript" src="/js/jquery-ui-1.8.9.custom.min.js"></script>
9
+ <script type="text/javascript" src="/js/jquery.ui.datepicker-ko.js"></script>
10
+ <link type="text/css" href="/css/blitzer/jquery-ui-1.8.13.custom.css" rel="stylesheet" />
11
+ <link type="text/css" href="/css/partnerReservation.css" rel="stylesheet" />
12
+ <style>
13
+ .header {
14
+ background: #2c2c2c;
15
+ height: 80px;
16
+ border-bottom: 5px solid #ccc;
17
+ }
18
+
19
+ .header .logo {
20
+ position: relative;
21
+ width: 950px;
22
+ margin: 0 auto;
23
+ padding: 40px 0 0 0;
24
+ height: 40px;
25
+ }
26
+ .header .partner_logo {
27
+ color: white;
28
+ font-weight: bold;
29
+ font-size: 17px;
30
+ letter-spacing: -1px;
31
+ position: absolute;
32
+ top: 0;
33
+ right: 0;
34
+ padding: 50px 0 0 0;
35
+ height: 40px;
36
+ }
37
+ ul.btn-list {
38
+ position: relative;
39
+ list-style: none;
40
+ margin: 0;
41
+ padding: 0;
42
+ height: 100%;
43
+ }
44
+
45
+ ul.btn-list li {
46
+ position: relative;
47
+ margin: 0;
48
+ text-align: left;
49
+ width: 100%;
50
+ }
51
+
52
+ ul.btn-list li.btn-list-1 {
53
+ padding: 42px 0px;
54
+ }
55
+ ul.btn-list li.btn-list-3 {
56
+ padding: 42px 0px;
57
+ }
58
+ ul.btn-list li.btn-list-4 {
59
+ padding: 49px 0px;
60
+ }
61
+ </style>
62
+ </head>
63
+ <body>
64
+ <!-- top -->
65
+ <div class="header" style="padding-bottom: 6px">
66
+ <div class="logo" style="padding-bottom: 6px">
67
+ <div>
68
+ <a href="/partner/reservation/fkii3/swmaestro/index.htm?key=&projectSeq=&addedInfo=&tozApplyType="
69
+ ><img src="/images/partner/toz_logo.png"
70
+ /></a>
71
+ </div>
72
+ <div class="partner_logo">
73
+ 기업전용 문의접수: <a href="mailto: b2bsb@toz.co.kr" style="color: white">b2bsb@toz.co.kr</a>
74
+ </div>
75
+ </div>
76
+ </div>
77
+ <div style="display: flex; flex-direction: row; justify-content: center; margin: -5px 0 0 0">
78
+ <div
79
+ class="layoutBody"
80
+ style="
81
+ position: relative;
82
+ display: flex;
83
+ flex-direction: column;
84
+ justify-content: center;
85
+ border-top: 5px solid #b00e33;
86
+ "
87
+ >
88
+ <div style="display: flex; flex-direction: row; width: 950px; border-bottom: 2px solid #d03f5f">
89
+ <div style="position: relative; width: 700px; z-index: 1">
90
+ <div style="padding-left: 30px; width: 380px; word-break: keep-all">
91
+ <p style="font-weight: bold; color: #890624; font-size: 18px; margin: 20px 0 10px 0">
92
+ (필독) 외부 회의실 예약 및 이용시 주의사항
93
+ </p>
94
+ <p></p>
95
+ <p style="font-size: 13px; color: #939393; font-weight: bold; line-height: 25px">
96
+ · 최소 2시간 부터 사용 가능하며 최대 3시간까지 사용합니다.<br />
97
+ · 예약 후 멘토링/특강 일정 변경시 반드시 예약 취소합니다.<br />
98
+ · 본인의 예약 미취소로 인해 발생한 비용은 개인에게<br />
99
+ <span style="display: inline-block; padding-left: 10px">직접 청구 될 수 있습니다.</span><br />
100
+ · 이용자(연수생/엑스퍼트/멘토) 경고에 해당하는 경우<br />
101
+ <span style="display: inline-block; padding-left: 14px">(1) 예약 후 예약 취소 없이 일방적인 노쇼</span
102
+ ><br />
103
+ <span style="display: inline-block; padding-left: 14px"
104
+ >(2) 멘토-연수생, 엑스퍼트-연수생과의 활동이 아닌</span
105
+ ><br />
106
+ <span style="display: inline-block; padding-left: 36px">목적 외 활동을 한 경우</span><br />
107
+ <span style="display: inline-block; padding-left: 14px">(3) 기타 부적합한 용도로 사용한 경우</span>
108
+ </p>
109
+ </div>
110
+
111
+ <div style="position: absolute; bottom: 10px; left: 40px">
112
+ <div class="companyName" style="padding: 5px 0">
113
+ <span style="font-weight: bold; color: #2c2c2c; border-bottom: 1px solid #939393; padding-bottom: 2px"
114
+ >AI‧SW마에스트로</span
115
+ >
116
+ </div>
117
+ <div
118
+ class="text"
119
+ style="padding: 5px 0; font-size: 14px; font-weight: bold; color: #939393; letter-spacing: -1px"
120
+ >
121
+ 제휴내용은
122
+ </div>
123
+ <div style="padding: 5px 0">
124
+ <span class="desc" style="color: #890624; border-bottom: 1px solid #939393; padding-bottom: 2px"
125
+ >20% 할인</span
126
+ ><span class="text"> 입니다.</span>
127
+ </div>
128
+ </div>
129
+ </div>
130
+ <div style="position: relative; width: 250px; height: 388px">
131
+ <img src="/images/partner/deco_toz_main_1.png" style="position: absolute; bottom: 0px; right: 250px" />
132
+ <ul class="btn-list">
133
+ <li class="btn-list-1" style="background: #890624">
134
+ <a href="/partner/reservation/fkii3/swmaestro/booking.htm?key=&projectSeq=&addedInfo=&tozApplyType="
135
+ ><img alt="예약하기" src="/images/partner/btn_toz_main_1.png" style="margin-left: 30px"
136
+ /></a>
137
+ </li>
138
+
139
+ <li class="btn-list-3" style="background: #c42649">
140
+ <a href="/partner/reservation/fkii3/swmaestro/startmypage.htm?key=&projectSeq=&addedInfo=&tozApplyType="
141
+ ><img alt="예약확인하기" src="/images/partner/btn_toz_main_3.png" style="margin-left: 30px"
142
+ /></a>
143
+ </li>
144
+ <li class="btn-list-4" style="background: #d03f5f">
145
+ <a href="https://work.toz.co.kr/branchSearch?page=1&onesBranchType=TMC" target="_blank"
146
+ ><img alt="토즈모임센터 지점안내" src="/images/partner/btn_toz_main_4.png" style="margin-left: 30px"
147
+ /></a>
148
+ </li>
149
+ </ul>
150
+ </div>
151
+ </div>
152
+
153
+ <div
154
+ style="
155
+ display: flex;
156
+ flex-direction: row;
157
+ justify-content: center;
158
+ position: relative;
159
+ width: 950px;
160
+ margin: 0 auto;
161
+ text-align: center;
162
+ border-bottom: 2px solid #d03f5f;
163
+ "
164
+ >
165
+ <img src="/images/partner/btn_link_map.png" usemap="#map_link" style="width: 808px; height: 168px" />
166
+ <map name="map_link">
167
+ <area
168
+ shape="rect"
169
+ coords="10,30,210,160"
170
+ href="https://moim.toz.co.kr"
171
+ title="토즈 서비스 그룹"
172
+ target="_blank"
173
+ />
174
+ <area
175
+ shape="rect"
176
+ coords="280,30,510,160"
177
+ href="https://moim.toz.co.kr/customerCenter/posts/all"
178
+ title="토즈 고객센터"
179
+ target="_blank"
180
+ />
181
+ <area
182
+ shape="rect"
183
+ coords="570,25,810,160"
184
+ href="https://moim.toz.co.kr/customerCenter/posts/event"
185
+ title="토즈 이벤트"
186
+ target="_blank"
187
+ />
188
+ </map>
189
+ </div>
190
+ </div>
191
+ </div>
192
+
193
+ <!-- google analytics for (http)partner.toz.co.kr -->
194
+ <script type="text/javascript">
195
+ var _gaq = _gaq || []
196
+ _gaq.push(['_setAccount', 'UA-23075111-3'])
197
+ _gaq.push(['_trackPageview'])
198
+
199
+ ;(function () {
200
+ var ga = document.createElement('script')
201
+ ga.type = 'text/javascript'
202
+ ga.async = true
203
+ ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js'
204
+ var s = document.getElementsByTagName('script')[0]
205
+ s.parentNode.insertBefore(ga, s)
206
+ })()
207
+ </script>
208
+ <!-- // google analytics for (http)partner.toz.co.kr -->
209
+ <!-- // bottom -->
210
+ </body>
211
+ </html>
@@ -1,3 +1,5 @@
1
+ import { createInterface, type Interface as ReadlineInterface } from 'node:readline'
2
+
1
3
  import { Command } from 'commander'
2
4
 
3
5
  import { CredentialManager } from '../credential-manager'
@@ -7,63 +9,99 @@ import { handleError } from '../shared/utils/error-handler'
7
9
  import { formatOutput } from '../shared/utils/output'
8
10
  import type { ExtractedSessionCandidate } from '../token-extractor'
9
11
 
10
- async function promptInput(message: string): Promise<string> {
11
- process.stdout.write(message)
12
- const input = await Bun.stdin.text()
13
- return input.trim()
12
+ function ask(rl: ReadlineInterface, message: string): Promise<string> {
13
+ return new Promise((resolve) => {
14
+ rl.question(message, (answer) => {
15
+ resolve(answer.trim())
16
+ })
17
+ })
14
18
  }
15
19
 
16
- async function promptPassword(message: string): Promise<string> {
20
+ async function promptPasswordTTY(message: string): Promise<string> {
17
21
  process.stdout.write(message)
18
22
 
19
- const originalStdin = process.stdin.isTTY
20
- ? (process.stdin as typeof process.stdin & { setRawMode?: (mode: boolean) => void })
21
- : null
22
-
23
- if (originalStdin?.setRawMode) {
24
- originalStdin.setRawMode(true)
23
+ const stdin = process.stdin as typeof process.stdin & { setRawMode?: (mode: boolean) => void }
24
+ if (stdin.setRawMode) {
25
+ stdin.setRawMode(true)
25
26
  }
27
+ stdin.resume()
26
28
 
27
29
  let password = ''
28
- const decoder = new TextDecoder()
29
30
 
30
31
  try {
31
- for await (const chunk of Bun.stdin.stream()) {
32
- const text = decoder.decode(chunk)
33
- for (const char of text) {
34
- const code = char.charCodeAt(0)
35
- if (code === 13 || code === 10) {
36
- // Enter key
37
- process.stdout.write('\n')
38
- return password
39
- } else if (code === 3) {
40
- // Ctrl+C
41
- process.exit(1)
42
- } else if (code === 127) {
43
- // Backspace
44
- if (password.length > 0) {
45
- password = password.slice(0, -1)
46
- process.stdout.write('\b \b')
32
+ return await new Promise<string>((resolve) => {
33
+ const onData = (chunk: Buffer | string): void => {
34
+ const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8')
35
+ for (const char of text) {
36
+ const code = char.charCodeAt(0)
37
+ if (code === 13 || code === 10) {
38
+ // Enter key
39
+ stdin.removeListener('data', onData)
40
+ process.stdout.write('\n')
41
+ resolve(password)
42
+ return
43
+ } else if (code === 3) {
44
+ // Ctrl+C
45
+ stdin.removeListener('data', onData)
46
+ process.exit(1)
47
+ } else if (code === 127 || code === 8) {
48
+ // Backspace / Delete
49
+ if (password.length > 0) {
50
+ password = password.slice(0, -1)
51
+ process.stdout.write('\b \b')
52
+ }
53
+ } else if (code >= 32 && code <= 126) {
54
+ // Printable characters
55
+ password += char
56
+ process.stdout.write('*')
47
57
  }
48
- } else if (code >= 32 && code <= 126) {
49
- // Printable characters
50
- password += char
51
- process.stdout.write('*')
52
58
  }
53
59
  }
54
- }
60
+
61
+ stdin.on('data', onData)
62
+ })
55
63
  } finally {
56
- if (originalStdin?.setRawMode) {
57
- originalStdin.setRawMode(false)
64
+ if (stdin.setRawMode) {
65
+ stdin.setRawMode(false)
58
66
  }
67
+ stdin.pause()
59
68
  }
69
+ }
70
+
71
+ async function promptCredentials(
72
+ needUsername: boolean,
73
+ needPassword: boolean,
74
+ ): Promise<{ username?: string; password?: string }> {
75
+ const result: { username?: string; password?: string } = {}
60
76
 
61
- return password
77
+ if (needUsername) {
78
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: false })
79
+ try {
80
+ result.username = await ask(rl, 'Username: ')
81
+ } finally {
82
+ rl.close()
83
+ }
84
+ }
85
+
86
+ if (needPassword) {
87
+ if (process.stdin.isTTY) {
88
+ result.password = await promptPasswordTTY('Password: ')
89
+ } else {
90
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: false })
91
+ try {
92
+ result.password = await ask(rl, 'Password: ')
93
+ } finally {
94
+ rl.close()
95
+ }
96
+ }
97
+ }
98
+
99
+ return result
62
100
  }
63
101
 
64
102
  type LoginOptions = { username?: string; password?: string; pretty?: boolean }
65
103
  type StatusOptions = { pretty?: boolean }
66
- type ExtractOptions = { pretty?: boolean }
104
+ type ExtractOptions = { debug?: boolean; pretty?: boolean }
67
105
  type ExtractedSessionValidator = Pick<SomaHttp, 'checkLogin' | 'extractCsrfToken'>
68
106
  type CredentialStore = Pick<CredentialManager, 'getCredentials' | 'remove' | 'setCredentials'>
69
107
  type StatusValidator = Pick<SomaHttp, 'checkLogin'>
@@ -87,21 +125,34 @@ export async function resolveExtractedCredentials(
87
125
  candidates: ExtractedSessionCandidate[],
88
126
  createValidator: (sessionCookie: string) => ExtractedSessionValidator = (sessionCookie) =>
89
127
  new SomaHttp({ sessionCookie }),
128
+ debug?: (message: string) => void,
90
129
  ): Promise<{ csrfToken: string; sessionCookie: string } | null> {
130
+ debug?.(`Validating ${candidates.length} candidate(s) against server...`)
131
+
91
132
  for (const candidate of candidates) {
92
133
  const http = createValidator(candidate.sessionCookie)
134
+ debug?.(` ${candidate.browser} / ${candidate.profile}: checkLogin...`)
93
135
 
94
136
  try {
95
137
  const valid = Boolean(await http.checkLogin())
96
138
  if (!valid) {
139
+ debug?.(` ${candidate.browser} / ${candidate.profile}: session invalid`)
97
140
  continue
98
141
  }
99
142
 
143
+ debug?.(` ${candidate.browser} / ${candidate.profile}: valid! Extracting CSRF token...`)
144
+ const csrfToken = await http.extractCsrfToken()
145
+ debug?.(` CSRF token obtained (${csrfToken.length} chars)`)
146
+
100
147
  return {
101
148
  sessionCookie: candidate.sessionCookie,
102
- csrfToken: await http.extractCsrfToken(),
149
+ csrfToken,
103
150
  }
104
- } catch {}
151
+ } catch (error) {
152
+ debug?.(
153
+ ` ${candidate.browser} / ${candidate.profile}: error: ${error instanceof Error ? error.message : String(error)}`,
154
+ )
155
+ }
105
156
  }
106
157
 
107
158
  return null
@@ -112,12 +163,9 @@ async function loginAction(options: LoginOptions): Promise<void> {
112
163
  let username = options.username ?? process.env.OPENSOMA_USERNAME
113
164
  let password = options.password ?? process.env.OPENSOMA_PASSWORD
114
165
 
115
- if (!username) {
116
- username = await promptInput('Username: ')
117
- }
118
- if (!password) {
119
- password = await promptPassword('Password: ')
120
- }
166
+ const prompted = await promptCredentials(!username, !password)
167
+ username ??= prompted.username
168
+ password ??= prompted.password
121
169
 
122
170
  if (!username || !password) {
123
171
  throw new Error('Username and password are required')
@@ -264,22 +312,30 @@ async function statusAction(options: StatusOptions): Promise<void> {
264
312
  }
265
313
 
266
314
  async function extractAction(options: ExtractOptions): Promise<void> {
315
+ const log = options.debug ? (message: string) => process.stderr.write(`[extract] ${message}\n`) : undefined
316
+
267
317
  try {
268
318
  const { TokenExtractor } = (await import('../token-extractor')) as {
269
- TokenExtractor: new () => { extractCandidates: () => Promise<ExtractedSessionCandidate[]> }
319
+ TokenExtractor: new (options?: { debug?: boolean }) => {
320
+ extractCandidates: () => Promise<ExtractedSessionCandidate[]>
321
+ }
270
322
  }
271
- const extractor = new TokenExtractor()
323
+ const extractor = new TokenExtractor({ debug: options.debug })
272
324
  const candidates = await extractor.extractCandidates()
273
325
  if (candidates.length === 0) {
274
326
  throw new Error(
275
- 'No SWMaestro session found in any browser. Login to swmaestro.ai in a supported Chromium browser (Chrome, Edge, Brave, Arc, Vivaldi) first.',
327
+ 'No SWMaestro session found in any browser. Login to swmaestro.ai or opensoma.dev in a supported Chromium browser (Chrome, Edge, Brave, Arc, Vivaldi) first.',
276
328
  )
277
329
  }
278
330
 
279
- const credentials = await resolveExtractedCredentials(candidates)
331
+ log?.(
332
+ `Extracted ${candidates.length} candidate(s): ${candidates.map((c) => `${c.browser}/${c.profile}`).join(', ')}`,
333
+ )
334
+
335
+ const credentials = await resolveExtractedCredentials(candidates, undefined, log)
280
336
  if (!credentials) {
281
337
  throw new Error(
282
- 'Found SWMaestro session cookies in supported browsers, but none are valid. Refresh your swmaestro.ai login in a supported Chromium browser and try again.',
338
+ 'Found SWMaestro session cookies in supported browsers, but none are valid. Refresh your swmaestro.ai or opensoma.dev login in a supported Chromium browser and try again.',
283
339
  )
284
340
  }
285
341
 
@@ -319,6 +375,7 @@ export const authCommand = new Command('auth')
319
375
  .addCommand(
320
376
  new Command('extract')
321
377
  .description('Extract browser credentials')
378
+ .option('--debug', 'Show debug output')
322
379
  .option('--pretty', 'Pretty print JSON output')
323
380
  .action(extractAction),
324
381
  )
package/src/constants.ts CHANGED
@@ -85,3 +85,53 @@ function createTimeSlots(): string[] {
85
85
  function formatTime(hour: number, minute: number): string {
86
86
  return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
87
87
  }
88
+
89
+ export const TOZ_BASE_URL = 'http://partner.toz.co.kr/partner/reservation/fkii3/swmaestro'
90
+ export const TOZ_PARTNER = 'fkii3'
91
+ export const TOZ_COMPANY = 'swmaestro'
92
+ export const TOZ_MEMBER_COMPANY_ID = '25408'
93
+
94
+ // Static fallback list extracted from booking.htm. Use TozClient.branches() at runtime
95
+ // to fetch the live list — the SW마에스트로 partnership branch set may change.
96
+ export const TOZ_BRANCHES = [
97
+ { id: 27, name: '강남토즈타워점' },
98
+ { id: 145, name: '강남컨퍼런스센터' },
99
+ { id: 19, name: '양재점' },
100
+ { id: 20, name: '건대점' },
101
+ { id: 15, name: '선릉점' },
102
+ { id: 139, name: '마이스 역삼센터' },
103
+ { id: 134, name: '마이스 광화문센터' },
104
+ { id: 30, name: '신촌비즈센터' },
105
+ { id: 149, name: '홍대점' },
106
+ ] as const
107
+
108
+ export const TOZ_PHONE_PREFIXES = ['010', '011', '016', '017', '018', '019'] as const
109
+
110
+ export const TOZ_EMAIL_DOMAINS = [
111
+ 'hanmail.net',
112
+ 'gmail.com',
113
+ 'nate.com',
114
+ 'naver.com',
115
+ 'daum.net',
116
+ 'dreamwiz.com',
117
+ 'yahoo.com',
118
+ 'yahoo.co.kr',
119
+ 'msn.com',
120
+ 'paran.com',
121
+ 'korea.com',
122
+ 'freechal.com',
123
+ 'lycos.co.kr',
124
+ 'msn.co.kr',
125
+ 'empal.com',
126
+ 'hotmail.com',
127
+ ] as const
128
+
129
+ export const TOZ_EMAIL_DOMAIN_CUSTOM = '직접입력'
130
+
131
+ export const TOZ_NEW_MEETING_VALUE = '새모임'
132
+
133
+ export const TOZ_MIN_DURATION_MINUTES = 120
134
+ export const TOZ_MAX_DURATION_MINUTES = 180
135
+ export const TOZ_SESSION_HOLD_SECONDS = 300
136
+
137
+ export const TOZ_MAX_CHECK_TIMES = 6
package/src/formatters.ts CHANGED
@@ -299,12 +299,40 @@ export function parseReportList(html: string): ReportListItem[] {
299
299
 
300
300
  export function parseReportDetail(html: string, id = 0): ReportDetail {
301
301
  const root = parse(html)
302
- const labels = extractLabelMap(root)
302
+ const labels = { ...extractLabelMap(root), ...extractGroupMap(root) }
303
303
 
304
304
  const progressTimeText = labels['진행시간'] || ''
305
305
  const exceptTimeText = labels['제외시간'] || ''
306
- const progressTimes = progressTimeText.split(/\s*~\s*/)
307
- const exceptTimes = exceptTimeText.split(/\s*~\s*/)
306
+ const progressTimeMatch = progressTimeText.match(/(\d{2}:\d{2})\s*~\s*(\d{2}:\d{2})/)
307
+ const exceptTimeMatch = exceptTimeText.match(/(\d{2}:\d{2})\s*~\s*(\d{2}:\d{2})/)
308
+
309
+ let subject = labels['주제'] || ''
310
+ if (!subject) {
311
+ for (const group of root.querySelectorAll('.group')) {
312
+ if (cleanText(group.querySelector('strong.t')) === '주제') {
313
+ subject = group.querySelector('input')?.getAttribute('value')?.trim() || ''
314
+ break
315
+ }
316
+ }
317
+ }
318
+
319
+ const findGroupTextarea = (...names: string[]): string => {
320
+ for (const group of root.querySelectorAll('.group')) {
321
+ const label = cleanText(group.querySelector('strong.t')).replace(/:$/, '')
322
+ if (names.includes(label)) {
323
+ const textarea = group.querySelector('textarea')
324
+ if (textarea) return textarea.text.trim()
325
+ return cleanText(group.querySelector('.c'))
326
+ }
327
+ }
328
+ return ''
329
+ }
330
+
331
+ const files = root
332
+ .querySelectorAll('.file_list_new a')
333
+ .map((a) => a.getAttribute('href') || '')
334
+ .filter(Boolean)
335
+ .map((href) => (href.startsWith('http') ? href : `https://www.swmaestro.ai${href}`))
308
336
 
309
337
  return ReportDetailSchema.parse({
310
338
  id,
@@ -312,27 +340,27 @@ export function parseReportDetail(html: string, id = 0): ReportDetail {
312
340
  title: labels['제목'] || '',
313
341
  progressDate: labels['진행 날짜'] || '',
314
342
  status: labels['상태'] || '',
315
- author: labels['작성자'] || '',
343
+ author: labels['작성자'] || labels['진행 멘토 명'] || '',
316
344
  createdAt: labels['등록일'] || '',
317
345
  acceptedTime: labels['인정시간'] || '',
318
346
  payAmount: labels['지급액'] || '',
319
- content: labels['추진 내용'] || '',
320
- subject: labels['주제'] || '',
321
- menteeRegion: labels['멘토링 대상'] || '',
347
+ content: findGroupTextarea('추진내용', '추진 내용') || labels['추진내용'] || labels['추진 내용'] || '',
348
+ subject,
349
+ menteeRegion: labels['멘토링대상'] || labels['멘토링 대상'] || '',
322
350
  reportType: labels['구분'] || '',
323
351
  teamNames: labels['팀명'] || '',
324
352
  venue: labels['진행 장소'] || '',
325
- attendanceCount: extractNumber(labels['참석 연수생'] || ''),
353
+ attendanceCount: extractNumber(labels['참석자 인원'] || labels['참석 연수생'] || ''),
326
354
  attendanceNames: labels['참석자 이름'] || '',
327
- progressStartTime: progressTimes[0] || '',
328
- progressEndTime: progressTimes[1] || '',
329
- exceptStartTime: exceptTimes[0] || '',
330
- exceptEndTime: exceptTimes[1] || '',
331
- exceptReason: labels['제외 사유'] || labels['제외이유'] || '',
332
- mentorOpinion: labels['멘토 의견'] || '',
355
+ progressStartTime: progressTimeMatch?.[1] || '',
356
+ progressEndTime: progressTimeMatch?.[2] || '',
357
+ exceptStartTime: exceptTimeMatch?.[1] || '',
358
+ exceptEndTime: exceptTimeMatch?.[2] || '',
359
+ exceptReason: labels['제외사유'] || labels['제외 사유'] || labels['제외이유'] || '',
360
+ mentorOpinion: findGroupTextarea('멘토의견', '멘토 의견') || labels['멘토의견'] || labels['멘토 의견'] || '',
333
361
  nonAttendanceNames: labels['무단불참자'] || '',
334
- etc: labels['특이사항'] || '',
335
- files: [],
362
+ etc: findGroupTextarea('기타', '특이사항') || labels['기타'] || labels['특이사항'] || '',
363
+ files,
336
364
  })
337
365
  }
338
366
 
package/src/index.ts CHANGED
@@ -3,5 +3,8 @@ export type { SomaClientOptions } from './client'
3
3
  export { AuthenticationError } from './errors'
4
4
  export { SomaHttp } from './http'
5
5
  export { CredentialManager } from './credential-manager'
6
+ export { TozHttp } from './toz-http'
7
+ export type { TozHttpOptions, TozHttpState } from './toz-http'
8
+ export * from './toz-formatters'
6
9
  export * from './types'
7
10
  export * from './constants'