opensoma 0.2.0 → 0.3.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 (87) hide show
  1. package/dist/package.json +2 -2
  2. package/dist/src/cli.d.ts.map +1 -1
  3. package/dist/src/cli.js +2 -1
  4. package/dist/src/cli.js.map +1 -1
  5. package/dist/src/client.d.ts +26 -1
  6. package/dist/src/client.d.ts.map +1 -1
  7. package/dist/src/client.js +141 -3
  8. package/dist/src/client.js.map +1 -1
  9. package/dist/src/commands/auth.d.ts +12 -0
  10. package/dist/src/commands/auth.d.ts.map +1 -1
  11. package/dist/src/commands/auth.js +151 -23
  12. package/dist/src/commands/auth.js.map +1 -1
  13. package/dist/src/commands/helpers.d.ts +12 -0
  14. package/dist/src/commands/helpers.d.ts.map +1 -1
  15. package/dist/src/commands/helpers.js +55 -9
  16. package/dist/src/commands/helpers.js.map +1 -1
  17. package/dist/src/commands/index.d.ts +1 -0
  18. package/dist/src/commands/index.d.ts.map +1 -1
  19. package/dist/src/commands/index.js +1 -0
  20. package/dist/src/commands/index.js.map +1 -1
  21. package/dist/src/commands/mentoring.d.ts.map +1 -1
  22. package/dist/src/commands/mentoring.js +37 -1
  23. package/dist/src/commands/mentoring.js.map +1 -1
  24. package/dist/src/commands/report.d.ts +27 -0
  25. package/dist/src/commands/report.d.ts.map +1 -0
  26. package/dist/src/commands/report.js +224 -0
  27. package/dist/src/commands/report.js.map +1 -0
  28. package/dist/src/constants.d.ts +2 -0
  29. package/dist/src/constants.d.ts.map +1 -1
  30. package/dist/src/constants.js +2 -0
  31. package/dist/src/constants.js.map +1 -1
  32. package/dist/src/credential-manager.d.ts +7 -0
  33. package/dist/src/credential-manager.d.ts.map +1 -1
  34. package/dist/src/credential-manager.js +76 -2
  35. package/dist/src/credential-manager.js.map +1 -1
  36. package/dist/src/formatters.d.ts +4 -1
  37. package/dist/src/formatters.d.ts.map +1 -1
  38. package/dist/src/formatters.js +91 -1
  39. package/dist/src/formatters.js.map +1 -1
  40. package/dist/src/http.d.ts +3 -0
  41. package/dist/src/http.d.ts.map +1 -1
  42. package/dist/src/http.js +109 -4
  43. package/dist/src/http.js.map +1 -1
  44. package/dist/src/session-recovery.d.ts +12 -0
  45. package/dist/src/session-recovery.d.ts.map +1 -0
  46. package/dist/src/session-recovery.js +34 -0
  47. package/dist/src/session-recovery.js.map +1 -0
  48. package/dist/src/shared/utils/report-params.d.ts +11 -0
  49. package/dist/src/shared/utils/report-params.d.ts.map +1 -0
  50. package/dist/src/shared/utils/report-params.js +27 -0
  51. package/dist/src/shared/utils/report-params.js.map +1 -0
  52. package/dist/src/shared/utils/swmaestro.d.ts +24 -0
  53. package/dist/src/shared/utils/swmaestro.d.ts.map +1 -1
  54. package/dist/src/shared/utils/swmaestro.js +50 -2
  55. package/dist/src/shared/utils/swmaestro.js.map +1 -1
  56. package/dist/src/token-extractor.d.ts.map +1 -1
  57. package/dist/src/token-extractor.js +38 -8
  58. package/dist/src/token-extractor.js.map +1 -1
  59. package/dist/src/types.d.ts +121 -0
  60. package/dist/src/types.d.ts.map +1 -1
  61. package/dist/src/types.js +72 -0
  62. package/dist/src/types.js.map +1 -1
  63. package/package.json +2 -2
  64. package/src/cli.ts +2 -0
  65. package/src/client.test.ts +95 -6
  66. package/src/client.ts +172 -4
  67. package/src/commands/auth.test.ts +152 -1
  68. package/src/commands/auth.ts +174 -28
  69. package/src/commands/helpers.test.ts +216 -0
  70. package/src/commands/helpers.ts +77 -12
  71. package/src/commands/index.ts +1 -0
  72. package/src/commands/mentoring.ts +55 -0
  73. package/src/commands/report.test.ts +49 -0
  74. package/src/commands/report.ts +322 -0
  75. package/src/constants.ts +2 -0
  76. package/src/credential-manager.test.ts +41 -1
  77. package/src/credential-manager.ts +103 -2
  78. package/src/formatters.test.ts +287 -0
  79. package/src/formatters.ts +105 -0
  80. package/src/http.test.ts +190 -4
  81. package/src/http.ts +132 -4
  82. package/src/session-recovery.ts +56 -0
  83. package/src/shared/utils/report-params.ts +41 -0
  84. package/src/shared/utils/swmaestro.ts +77 -5
  85. package/src/token-extractor.ts +59 -20
  86. package/src/types.test.ts +97 -0
  87. package/src/types.ts +84 -0
package/src/http.test.ts CHANGED
@@ -52,6 +52,163 @@ describe('SomaHttp', () => {
52
52
  expect(html).toBe('<html>ok</html>')
53
53
  })
54
54
 
55
+ test('post surfaces alert errors from script tags with attributes', async () => {
56
+ const fetchMock = mock(async () =>
57
+ createResponse(
58
+ `<html><head><title>빈페이지</title></head><body><script type='text/javascript'>alert('아이디 혹은 비밀번호가 일치 하지 않습니다.');location.href='/login';</script></body></html>`,
59
+ ),
60
+ )
61
+ globalThis.fetch = fetchMock as typeof fetch
62
+
63
+ const http = new SomaHttp({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
64
+
65
+ await expect(http.post('/member/user/toLogin.do', { username: 'neo@example.com' })).rejects.toThrow(
66
+ '아이디 혹은 비밀번호가 일치 하지 않습니다.',
67
+ )
68
+ })
69
+
70
+ test('post surfaces alert errors followed by history.back()', async () => {
71
+ const fetchMock = mock(async () =>
72
+ createResponse(
73
+ `<html><head></head><body><script language='JavaScript'>\nalert('잘못된 접근입니다.');\nhistory.back();\n</script></body></html>`,
74
+ ),
75
+ )
76
+ globalThis.fetch = fetchMock as typeof fetch
77
+
78
+ const http = new SomaHttp({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
79
+
80
+ await expect(http.post('/mypage/test.do', {})).rejects.toThrow('잘못된 접근입니다.')
81
+ })
82
+
83
+ test('post ignores alert() inside function bodies (form validation scripts)', async () => {
84
+ const pageWithValidationScript = `<html><head><title>AI·SW마에스트로 서울</title></head><body>
85
+ <ul class="bbs-reserve"><li class="item">room data</li></ul>
86
+ <script>
87
+ function fn_search() {
88
+ var searchWrd = document.getElementById('searchWrd');
89
+ if (searchWrd.value == '') {
90
+ alert('검색어를 입력하세요.');
91
+ return;
92
+ }
93
+ document.forms[0].submit();
94
+ }
95
+ </script>
96
+ </body></html>`
97
+
98
+ const fetchMock = mock(async () => createResponse(pageWithValidationScript))
99
+ globalThis.fetch = fetchMock as typeof fetch
100
+
101
+ const http = new SomaHttp({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
102
+
103
+ const html = await http.post('/mypage/officeMng/list.do', { menuNo: '200058' })
104
+ expect(html).toContain('room data')
105
+ })
106
+
107
+ describe('postMultipart', () => {
108
+ test('passes FormData to fetch', async () => {
109
+ const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
110
+ expect(init?.body).toBeInstanceOf(FormData)
111
+ return createResponse('<html>ok</html>')
112
+ })
113
+ globalThis.fetch = fetchMock as typeof fetch
114
+
115
+ const http = new SomaHttp({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
116
+
117
+ await expect(http.postMultipart('/test', new FormData())).resolves.toBe('<html>ok</html>')
118
+ })
119
+
120
+ test('does not set Content-Type manually', async () => {
121
+ const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
122
+ expect(init?.headers).toEqual({
123
+ 'Accept-Language': 'ko,en-US;q=0.9,en;q=0.8',
124
+ 'User-Agent':
125
+ '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',
126
+ cookie: 'JSESSIONID=session-1',
127
+ Referer: 'https://www.swmaestro.ai/sw/test',
128
+ })
129
+ const headers = init?.headers as Record<string, string> | undefined
130
+ expect(headers?.['Content-Type']).toBeUndefined()
131
+ expect(headers?.['content-type']).toBeUndefined()
132
+ return createResponse('<html>ok</html>')
133
+ })
134
+ globalThis.fetch = fetchMock as typeof fetch
135
+
136
+ const http = new SomaHttp({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
137
+
138
+ await http.postMultipart('/test', new FormData())
139
+ })
140
+
141
+ test('appends csrf token to FormData', async () => {
142
+ const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
143
+ const body = init?.body
144
+ expect(body).toBeInstanceOf(FormData)
145
+ expect((body as FormData).get('csrfToken')).toBe('csrf-known')
146
+ return createResponse('<html>ok</html>')
147
+ })
148
+ globalThis.fetch = fetchMock as typeof fetch
149
+
150
+ const http = new SomaHttp({ csrfToken: 'csrf-known' })
151
+
152
+ await http.postMultipart('/test', new FormData())
153
+ })
154
+
155
+ test('follows redirects manually', async () => {
156
+ const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => {
157
+ const url = String(input)
158
+
159
+ if (url === 'https://www.swmaestro.ai/sw/test') {
160
+ expect(init).toMatchObject({
161
+ method: 'POST',
162
+ redirect: 'manual',
163
+ })
164
+ return createResponse('', [], 'text/html', {
165
+ status: 302,
166
+ headers: { Location: '/mypage/mentoLec/result.do' },
167
+ })
168
+ }
169
+
170
+ expect(url).toBe('https://www.swmaestro.ai/mypage/mentoLec/result.do')
171
+ expect(init).toEqual({
172
+ method: 'GET',
173
+ redirect: 'manual',
174
+ headers: {
175
+ 'Accept-Language': 'ko,en-US;q=0.9,en;q=0.8',
176
+ 'User-Agent':
177
+ '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',
178
+ cookie: 'JSESSIONID=session-1',
179
+ },
180
+ })
181
+ return createResponse('<html>redirected</html>')
182
+ })
183
+ globalThis.fetch = fetchMock as typeof fetch
184
+
185
+ const http = new SomaHttp({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
186
+
187
+ await expect(http.postMultipart('/test', new FormData())).resolves.toBe('<html>redirected</html>')
188
+ expect(fetchMock).toHaveBeenCalledTimes(2)
189
+ })
190
+
191
+ test('keeps existing post() behavior unchanged', async () => {
192
+ const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
193
+ expect(init?.method).toBe('POST')
194
+ expect(init?.headers).toEqual({
195
+ 'Accept-Language': 'ko,en-US;q=0.9,en;q=0.8',
196
+ 'User-Agent':
197
+ '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',
198
+ cookie: 'JSESSIONID=session-1',
199
+ 'Content-Type': 'application/x-www-form-urlencoded',
200
+ })
201
+ expect(init?.body).toBe('title=%ED%85%8C%EC%8A%A4%ED%8A%B8&csrfToken=csrf-1')
202
+ return createResponse('<html>ok</html>')
203
+ })
204
+ globalThis.fetch = fetchMock as typeof fetch
205
+
206
+ const http = new SomaHttp({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
207
+
208
+ await expect(http.post('/mypage/mentoLec/insert.do', { title: '테스트' })).resolves.toBe('<html>ok</html>')
209
+ })
210
+ })
211
+
55
212
  test('postJson returns parsed json', async () => {
56
213
  const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
57
214
  expect(init?.headers).toEqual({
@@ -117,7 +274,7 @@ describe('SomaHttp', () => {
117
274
  })
118
275
 
119
276
  test('checkLogin returns user identity when logged in, null otherwise', async () => {
120
- const loggedInMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) =>
277
+ const loggedInMock = mock(async (_input: RequestInfo | URL, _init?: RequestInit) =>
121
278
  createResponse(
122
279
  JSON.stringify({
123
280
  resultCode: 'fail',
@@ -135,6 +292,7 @@ describe('SomaHttp', () => {
135
292
  })
136
293
  expect(loggedInMock).toHaveBeenCalledWith('https://www.swmaestro.ai/sw/member/user/checkLogin.json', {
137
294
  method: 'GET',
295
+ redirect: 'manual',
138
296
  headers: {
139
297
  'Accept-Language': 'ko,en-US;q=0.9,en;q=0.8',
140
298
  'User-Agent':
@@ -151,6 +309,29 @@ describe('SomaHttp', () => {
151
309
  await expect(new SomaHttp().checkLogin()).resolves.toBeNull()
152
310
  })
153
311
 
312
+ test('checkLogin returns null when the server redirects to login', async () => {
313
+ const fetchMock = mock(async () =>
314
+ createResponse('', [], 'text/html', {
315
+ status: 302,
316
+ headers: { Location: 'http://www.swmaestro.ai/sw/member/user/loginForward.do' },
317
+ }),
318
+ )
319
+ globalThis.fetch = fetchMock as typeof fetch
320
+
321
+ await expect(new SomaHttp({ sessionCookie: 'session-1' }).checkLogin()).resolves.toBeNull()
322
+ })
323
+
324
+ test('checkLogin returns null when the server serves login html instead of json', async () => {
325
+ const fetchMock = mock(async () =>
326
+ createResponse(
327
+ '<html><head><title>AI·SW마에스트로</title></head><body><form><input name="username"><input name="password"></form></body></html>',
328
+ ),
329
+ )
330
+ globalThis.fetch = fetchMock as typeof fetch
331
+
332
+ await expect(new SomaHttp({ sessionCookie: 'session-1' }).checkLogin()).resolves.toBeNull()
333
+ })
334
+
154
335
  test('logout calls logout endpoint', async () => {
155
336
  const fetchMock = mock(async (input: RequestInfo | URL) => {
156
337
  expect(String(input)).toBe('https://www.swmaestro.ai/sw/member/user/logout.do')
@@ -174,9 +355,14 @@ describe('SomaHttp', () => {
174
355
  })
175
356
  })
176
357
 
177
- function createResponse(body: string, cookies: string[] = [], contentType = 'text/html'): Response {
178
- const headers = new Headers({ 'Content-Type': contentType })
179
- const response = new Response(body, { headers })
358
+ function createResponse(
359
+ body: string,
360
+ cookies: string[] = [],
361
+ contentType = 'text/html',
362
+ options: { status?: number; headers?: Record<string, string> } = {},
363
+ ): Response {
364
+ const headers = new Headers({ 'Content-Type': contentType, ...options.headers })
365
+ const response = new Response(body, { headers, status: options.status })
180
366
  const cookieHeaders = cookies
181
367
 
182
368
  Object.defineProperty(response.headers, 'getSetCookie', {
package/src/http.ts CHANGED
@@ -134,6 +134,75 @@ export class SomaHttp {
134
134
  return finalBody
135
135
  }
136
136
 
137
+ async postMultipart(path: string, formData: FormData): Promise<string> {
138
+ const url = this.buildUrl(path)
139
+
140
+ if (this.csrfToken) {
141
+ formData.append('csrfToken', this.csrfToken)
142
+ }
143
+
144
+ let response = await fetch(url, {
145
+ method: 'POST',
146
+ headers: this.buildMultipartHeaders(url),
147
+ body: formData,
148
+ redirect: 'manual',
149
+ })
150
+
151
+ this.updateFromResponse(response)
152
+
153
+ if (response.status >= 300 && response.status < 400) {
154
+ const location = response.headers.get('location')
155
+ const intermediateBody = await response.clone().text()
156
+ const errorInfo = this.extractErrorFromResponse(intermediateBody, location, path)
157
+ if (errorInfo) {
158
+ if (errorInfo === '__AUTH_ERROR__') {
159
+ throw new AuthenticationError()
160
+ }
161
+ throw new Error(errorInfo)
162
+ }
163
+ }
164
+
165
+ let finalUrl = url
166
+ while (response.status >= 300 && response.status < 400) {
167
+ const location = response.headers.get('location')
168
+ if (!location) break
169
+
170
+ const redirectUrl = location.startsWith('http') ? location : new URL(location, `${BASE_URL}/`).toString()
171
+ finalUrl = redirectUrl
172
+ response = await fetch(redirectUrl, {
173
+ method: 'GET',
174
+ headers: this.buildHeaders(),
175
+ redirect: 'manual',
176
+ })
177
+ this.updateFromResponse(response)
178
+ }
179
+
180
+ if (!response.ok) {
181
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
182
+ }
183
+
184
+ const finalBody = await response.text()
185
+ this.log('POST MULTIPART', path, '-> Final URL:', finalUrl)
186
+ this.log('POST MULTIPART', path, '-> Response body (first 200 chars):', finalBody.slice(0, 200))
187
+
188
+ const finalPath = new URL(finalUrl).pathname
189
+ const errorInfo = this.extractErrorFromResponse(finalBody, null, finalPath)
190
+ if (errorInfo) {
191
+ this.log('POST MULTIPART', path, '-> Error detected:', errorInfo)
192
+ if (errorInfo === '__AUTH_ERROR__') {
193
+ throw new AuthenticationError()
194
+ }
195
+ throw new Error(errorInfo)
196
+ }
197
+
198
+ if (finalPath.includes('insertForm') || finalPath.includes('error') || finalPath.includes('fail')) {
199
+ this.log('POST MULTIPART', path, '-> Suspicious final URL:', finalUrl)
200
+ throw new Error('멘토링 등록에 실패했습니다.')
201
+ }
202
+
203
+ return finalBody
204
+ }
205
+
137
206
  private extractErrorFromResponse(body: string, location: string | null, path?: string): string | null {
138
207
  this.log(
139
208
  'extractErrorFromResponse',
@@ -144,7 +213,7 @@ export class SomaHttp {
144
213
  body.match(/<title>([^<]*)<\/title>/)?.[1],
145
214
  )
146
215
 
147
- const alertMatch = body.match(/<script>\s*alert\(['"](.+?)['"]\)\s*<\/script>/)
216
+ const alertMatch = body.match(/<script\b[^>]*>\s*alert\(['"](.+?)['"]\);?\s*(history\.|location\.)/i)
148
217
  if (alertMatch) {
149
218
  this.log('Found alert match:', alertMatch[1])
150
219
  return alertMatch[1]
@@ -153,6 +222,10 @@ export class SomaHttp {
153
222
  const titleMatch = body.match(/<title>([^<]*)<\/title>/i)
154
223
  const pageTitle = titleMatch?.[1] ?? ''
155
224
 
225
+ if (this.isAuthRedirect(location)) {
226
+ return '__AUTH_ERROR__'
227
+ }
228
+
156
229
  // Check for login page - server returns login page HTML (with login form) when session is invalid
157
230
  // The login page has both the SW마에스트로 title AND a login form with username/password inputs
158
231
  // Skip this check during login flow (forLogin = GET for CSRF, toLogin = POST credentials)
@@ -189,7 +262,7 @@ export class SomaHttp {
189
262
  for (const pattern of errorPatterns) {
190
263
  if (body.includes(pattern)) {
191
264
  this.log('Found error pattern:', pattern)
192
- return '멘토링 등록에 실패했습니다: ' + pattern
265
+ return `멘토링 등록에 실패했습니다: ${pattern}`
193
266
  }
194
267
  }
195
268
  return '에러가 발생했습니다'
@@ -244,16 +317,51 @@ export class SomaHttp {
244
317
  }
245
318
 
246
319
  async checkLogin(): Promise<UserIdentity | null> {
247
- const response = await fetch(this.buildUrl('/member/user/checkLogin.json'), {
320
+ const path = '/member/user/checkLogin.json'
321
+ const response = await fetch(this.buildUrl(path), {
248
322
  method: 'GET',
249
323
  headers: {
250
324
  ...this.buildHeaders(),
251
325
  Accept: 'application/json',
252
326
  },
327
+ redirect: 'manual',
253
328
  })
254
329
 
255
330
  this.updateFromResponse(response)
256
- const json = (await response.json()) as CheckLoginResponse
331
+ const location = response.headers.get('location')
332
+
333
+ if (response.status >= 300 && response.status < 400) {
334
+ if (this.isAuthRedirect(location)) {
335
+ return null
336
+ }
337
+
338
+ throw new Error(`Unexpected redirect while checking login: ${location ?? response.status}`)
339
+ }
340
+
341
+ const body = await response.text()
342
+ const errorInfo = this.extractErrorFromResponse(body, location, path)
343
+ if (errorInfo === '__AUTH_ERROR__') {
344
+ return null
345
+ }
346
+
347
+ if (errorInfo) {
348
+ throw new Error(errorInfo)
349
+ }
350
+
351
+ const contentType = response.headers.get('content-type') ?? ''
352
+ if (contentType.includes('text/html')) {
353
+ return null
354
+ }
355
+
356
+ let json: CheckLoginResponse
357
+ try {
358
+ json = JSON.parse(body) as CheckLoginResponse
359
+ } catch (error) {
360
+ throw new Error(
361
+ `Failed to parse checkLogin response: ${error instanceof Error ? error.message : 'unknown error'}`,
362
+ )
363
+ }
364
+
257
365
  const userId = json.userVO?.userId
258
366
  if (!userId) return null
259
367
  return { userId, userNm: json.userVO?.userNm ?? '' }
@@ -304,6 +412,26 @@ export class SomaHttp {
304
412
  }
305
413
  }
306
414
 
415
+ private buildMultipartHeaders(referer: string): Record<string, string> {
416
+ return {
417
+ ...this.buildHeaders(),
418
+ Referer: referer,
419
+ }
420
+ }
421
+
422
+ private isAuthRedirect(location: string | null): boolean {
423
+ if (!location) {
424
+ return false
425
+ }
426
+
427
+ return [
428
+ '/member/user/loginForward.do',
429
+ '/member/user/forLogin.do',
430
+ '/member/user/toLogin.do',
431
+ '/member/user/logout.do',
432
+ ].some((path) => location.includes(path))
433
+ }
434
+
307
435
  private buildBody(body: Record<string, string>): Record<string, string> {
308
436
  if (this.csrfToken && !('csrfToken' in body)) {
309
437
  return { ...body, csrfToken: this.csrfToken }
@@ -0,0 +1,56 @@
1
+ import { CredentialManager } from './credential-manager'
2
+ import { type UserIdentity, SomaHttp } from './http'
3
+ import type { Credentials } from './types'
4
+
5
+ type CredentialStore = Pick<CredentialManager, 'setCredentials'>
6
+ type ReloginHttp = Pick<SomaHttp, 'checkLogin' | 'getCsrfToken' | 'getSessionCookie' | 'login'>
7
+
8
+ export function canRecoverSession(credentials: Credentials): credentials is Credentials & {
9
+ password: string
10
+ username: string
11
+ } {
12
+ return Boolean(credentials.username && credentials.password)
13
+ }
14
+
15
+ export async function recoverSession(
16
+ credentials: Credentials,
17
+ manager: CredentialStore = new CredentialManager(),
18
+ createHttp: () => ReloginHttp = () => new SomaHttp(),
19
+ ): Promise<Credentials | null> {
20
+ if (!canRecoverSession(credentials)) {
21
+ return null
22
+ }
23
+
24
+ const http = createHttp()
25
+ await http.login(credentials.username, credentials.password)
26
+
27
+ const identity = await http.checkLogin()
28
+ if (!identity) {
29
+ return null
30
+ }
31
+
32
+ const refreshedCredentials = buildRefreshedCredentials(credentials, identity, http)
33
+ await manager.setCredentials(refreshedCredentials)
34
+ return refreshedCredentials
35
+ }
36
+
37
+ function buildRefreshedCredentials(
38
+ credentials: Credentials & { password: string; username: string },
39
+ identity: UserIdentity,
40
+ http: Pick<SomaHttp, 'getCsrfToken' | 'getSessionCookie'>,
41
+ ): Credentials {
42
+ const sessionCookie = http.getSessionCookie()
43
+ const csrfToken = http.getCsrfToken()
44
+
45
+ if (!sessionCookie || !csrfToken) {
46
+ throw new Error('Automatic re-login succeeded but session state is incomplete')
47
+ }
48
+
49
+ return {
50
+ sessionCookie,
51
+ csrfToken,
52
+ username: identity.userId || credentials.username,
53
+ password: credentials.password,
54
+ loggedInAt: new Date().toISOString(),
55
+ }
56
+ }
@@ -0,0 +1,41 @@
1
+ export function buildReportListParams(options?: {
2
+ page?: number
3
+ searchField?: string // '' | '0' | '1' (전체/제목/내용)
4
+ searchKeyword?: string
5
+ }): Record<string, string> {
6
+ const params: Record<string, string> = {
7
+ pageIndex: String(options?.page ?? 1),
8
+ menuNo: '200049',
9
+ }
10
+
11
+ if (options?.searchField !== undefined) {
12
+ params.searchCnd = options.searchField
13
+ }
14
+
15
+ if (options?.searchKeyword) {
16
+ params.searchWrd = options.searchKeyword
17
+ }
18
+
19
+ return params
20
+ }
21
+
22
+ export function buildApprovalListParams(options?: {
23
+ page?: number
24
+ month?: string // '01'-'12' or 'all'
25
+ reportType?: string // '' | 'MRC010' | 'MRC020'
26
+ }): Record<string, string> {
27
+ const params: Record<string, string> = {
28
+ pageIndex: String(options?.page ?? 1),
29
+ menuNo: '200073',
30
+ }
31
+
32
+ if (options?.month) {
33
+ params.searchMonth = options.month
34
+ }
35
+
36
+ if (options?.reportType !== undefined) {
37
+ params.searchReport = options.reportType
38
+ }
39
+
40
+ return params
41
+ }
@@ -1,7 +1,7 @@
1
1
  import { parse } from 'node-html-parser'
2
2
 
3
3
  import { MENU_NO, REPORT_CD, ROOM_IDS, TIME_SLOTS } from '../../constants'
4
- import { ApplicationHistoryItemSchema, type ApplicationHistoryItem } from '../../types'
4
+ import { type ApplicationHistoryItem, ApplicationHistoryItemSchema } from '../../types'
5
5
 
6
6
  export function toReportCd(type: 'public' | 'lecture'): string {
7
7
  return type === 'lecture' ? REPORT_CD.MENTOR_LECTURE : REPORT_CD.PUBLIC_MENTORING
@@ -41,6 +41,16 @@ export function buildMentoringPayload(params: {
41
41
  }
42
42
  }
43
43
 
44
+ export function buildUpdateMentoringPayload(
45
+ id: number,
46
+ params: Parameters<typeof buildMentoringPayload>[0],
47
+ ): Record<string, string> {
48
+ return {
49
+ ...buildMentoringPayload(params),
50
+ qustnrSn: String(id),
51
+ }
52
+ }
53
+
44
54
  export function buildDeleteMentoringPayload(id: number): Record<string, string> {
45
55
  return {
46
56
  menuNo: MENU_NO.MENTORING,
@@ -178,10 +188,8 @@ export function parseEventDetail(html: string): Record<string, unknown> {
178
188
  root.querySelector('.content-body')
179
189
 
180
190
  return {
181
- id: extractNumber(
182
- labels.NO ?? labels['번호'] ?? root.querySelector('[name="bbsId"]')?.getAttribute('value') ?? '0',
183
- ),
184
- title: labels['제목'] ?? cleanText(root.querySelector('h1, h2, .title')?.text),
191
+ id: extractNumber(labels.NO ?? labels.번호 ?? root.querySelector('[name="bbsId"]')?.getAttribute('value') ?? '0'),
192
+ title: labels.제목 ?? cleanText(root.querySelector('h1, h2, .title')?.text),
185
193
  content: contentNode?.innerHTML.trim() ?? '',
186
194
  fields: labels,
187
195
  }
@@ -216,3 +224,67 @@ function extractNumber(value: string): number {
216
224
  const match = value.match(/\d+/)
217
225
  return match ? Number.parseInt(match[0], 10) : 0
218
226
  }
227
+
228
+ export function toRegionCode(region: string): 'S' | 'B' {
229
+ if (region.includes('부산') || region === 'B') return 'B'
230
+ return 'S'
231
+ }
232
+
233
+ export function toReportTypeCd(type: string): 'MRC010' | 'MRC020' {
234
+ if (type.includes('특강') || type === 'MRC020') return 'MRC020'
235
+ return 'MRC010'
236
+ }
237
+
238
+ export function buildReportPayload(options: {
239
+ menteeRegion: 'S' | 'B'
240
+ reportType: 'MRC010' | 'MRC020'
241
+ progressDate: string // yyyy-mm-dd
242
+ teamNames?: string
243
+ venue: string
244
+ attendanceCount: number
245
+ attendanceNames: string
246
+ progressStartTime: string // HH:mm
247
+ progressEndTime: string // HH:mm
248
+ exceptStartTime?: string
249
+ exceptEndTime?: string
250
+ exceptReason?: string
251
+ subject: string
252
+ content: string
253
+ mentorOpinion?: string
254
+ nonAttendanceNames?: string
255
+ etc?: string
256
+ menuNo?: string
257
+ reportId?: number
258
+ }): Record<string, string> {
259
+ const { progressDate, reportType } = options
260
+ const [year, month, day] = progressDate.split('-')
261
+ const typeNames: Record<string, string> = {
262
+ MRC010: '자유 멘토링',
263
+ MRC020: '멘토 특강',
264
+ }
265
+ const typeName = typeNames[reportType] ?? reportType
266
+ const nttSj = `[${typeName}] ${year}년 ${month}월 ${day}일 멘토링 보고`
267
+
268
+ return {
269
+ menuNo: options.menuNo ?? '200049',
270
+ menteeRegionCd: options.menteeRegion,
271
+ reportGubunCd: reportType,
272
+ progressDt: progressDate,
273
+ teamNms: options.teamNames ?? '',
274
+ progressPlace: options.venue,
275
+ attendanceCnt: String(options.attendanceCount),
276
+ attendanceNms: options.attendanceNames,
277
+ progressStime: options.progressStartTime,
278
+ progressEtime: options.progressEndTime,
279
+ exceptStime: options.exceptStartTime ?? '',
280
+ exceptEtime: options.exceptEndTime ?? '',
281
+ exceptReason: options.exceptReason ?? '',
282
+ subject: options.subject,
283
+ nttCn: options.content,
284
+ mentoOpn: options.mentorOpinion ?? '',
285
+ nonAttendanceNms: options.nonAttendanceNames ?? '',
286
+ etc: options.etc ?? '',
287
+ nttSj,
288
+ ...(options.reportId !== undefined ? { reportId: String(options.reportId) } : {}),
289
+ }
290
+ }