opensoma 0.2.1 → 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 (76) 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 +24 -1
  6. package/dist/src/client.d.ts.map +1 -1
  7. package/dist/src/client.js +124 -1
  8. package/dist/src/client.js.map +1 -1
  9. package/dist/src/commands/auth.d.ts +5 -1
  10. package/dist/src/commands/auth.d.ts.map +1 -1
  11. package/dist/src/commands/auth.js +83 -4
  12. package/dist/src/commands/auth.js.map +1 -1
  13. package/dist/src/commands/helpers.d.ts +5 -1
  14. package/dist/src/commands/helpers.d.ts.map +1 -1
  15. package/dist/src/commands/helpers.js +28 -3
  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/formatters.d.ts +4 -1
  33. package/dist/src/formatters.d.ts.map +1 -1
  34. package/dist/src/formatters.js +91 -1
  35. package/dist/src/formatters.js.map +1 -1
  36. package/dist/src/http.d.ts +2 -0
  37. package/dist/src/http.d.ts.map +1 -1
  38. package/dist/src/http.js +65 -1
  39. package/dist/src/http.js.map +1 -1
  40. package/dist/src/shared/utils/report-params.d.ts +11 -0
  41. package/dist/src/shared/utils/report-params.d.ts.map +1 -0
  42. package/dist/src/shared/utils/report-params.js +27 -0
  43. package/dist/src/shared/utils/report-params.js.map +1 -0
  44. package/dist/src/shared/utils/swmaestro.d.ts +24 -0
  45. package/dist/src/shared/utils/swmaestro.d.ts.map +1 -1
  46. package/dist/src/shared/utils/swmaestro.js +50 -2
  47. package/dist/src/shared/utils/swmaestro.js.map +1 -1
  48. package/dist/src/token-extractor.d.ts.map +1 -1
  49. package/dist/src/token-extractor.js +38 -8
  50. package/dist/src/token-extractor.js.map +1 -1
  51. package/dist/src/types.d.ts +120 -0
  52. package/dist/src/types.d.ts.map +1 -1
  53. package/dist/src/types.js +71 -0
  54. package/dist/src/types.js.map +1 -1
  55. package/package.json +2 -2
  56. package/src/cli.ts +2 -0
  57. package/src/client.test.ts +30 -5
  58. package/src/client.ts +151 -0
  59. package/src/commands/auth.test.ts +40 -1
  60. package/src/commands/auth.ts +91 -3
  61. package/src/commands/helpers.test.ts +108 -4
  62. package/src/commands/helpers.ts +30 -3
  63. package/src/commands/index.ts +1 -0
  64. package/src/commands/mentoring.ts +55 -0
  65. package/src/commands/report.test.ts +49 -0
  66. package/src/commands/report.ts +322 -0
  67. package/src/constants.ts +2 -0
  68. package/src/formatters.test.ts +287 -0
  69. package/src/formatters.ts +105 -0
  70. package/src/http.test.ts +158 -1
  71. package/src/http.ts +77 -1
  72. package/src/shared/utils/report-params.ts +41 -0
  73. package/src/shared/utils/swmaestro.ts +77 -5
  74. package/src/token-extractor.ts +59 -20
  75. package/src/types.test.ts +97 -0
  76. package/src/types.ts +83 -0
package/src/formatters.ts CHANGED
@@ -3,6 +3,8 @@ import { type HTMLElement, parse } from 'node-html-parser'
3
3
  import {
4
4
  ApplicationHistoryItemSchema,
5
5
  type ApplicationHistoryItem,
6
+ ApprovalListItemSchema,
7
+ type ApprovalListItem,
6
8
  DashboardSchema,
7
9
  EventListItemSchema,
8
10
  type Dashboard,
@@ -19,6 +21,10 @@ import {
19
21
  NoticeListItemSchema,
20
22
  type Pagination,
21
23
  PaginationSchema,
24
+ type ReportDetail,
25
+ ReportDetailSchema,
26
+ type ReportListItem,
27
+ ReportListItemSchema,
22
28
  type RoomCard,
23
29
  RoomCardSchema,
24
30
  type TeamInfo,
@@ -59,6 +65,19 @@ export function parseMentoringDetail(html: string, id = 0): MentoringDetail {
59
65
  root.querySelector('.view-content') ??
60
66
  root.querySelector('.content-body') ??
61
67
  root.querySelector('#contents')
68
+ const applicantTable = root.querySelectorAll('table').find((table) => {
69
+ const headers = table.querySelectorAll('thead th')
70
+ return headers.length === 5 && cleanText(headers[1]) === '연수생'
71
+ })
72
+ const applicants = (applicantTable?.querySelectorAll('tbody tr') ?? [])
73
+ .map((row) => row.querySelectorAll('td'))
74
+ .filter((cells) => cells.length === 5)
75
+ .map((cells) => ({
76
+ name: cleanText(cells[1]),
77
+ appliedAt: cleanText(cells[2]),
78
+ cancelledAt: cleanText(cells[3]),
79
+ status: stripWrappingBrackets(cleanText(cells[4])),
80
+ }))
62
81
 
63
82
  return MentoringDetailSchema.parse({
64
83
  id: id || extractNumber(root.querySelector('[name="qustnrSn"]')?.getAttribute('value') ?? ''),
@@ -81,6 +100,7 @@ export function parseMentoringDetail(html: string, id = 0): MentoringDetail {
81
100
  createdAt: labels['등록일'] || '',
82
101
  content: contentNode?.innerHTML.trim() ?? '',
83
102
  venue: labels['장소'] || '',
103
+ applicants,
84
104
  })
85
105
  }
86
106
 
@@ -271,6 +291,91 @@ export function parseCsrfToken(html: string): string {
271
291
  return token
272
292
  }
273
293
 
294
+ export function parseReportList(html: string): ReportListItem[] {
295
+ return findTableRows(html, 9).map((cells) => {
296
+ const link = cells[2]?.querySelector('a')
297
+
298
+ return ReportListItemSchema.parse({
299
+ id: extractUrlParam(link?.getAttribute('href'), 'reportId'),
300
+ category: cleanText(cells[1]),
301
+ title: cleanText(link ?? cells[2]),
302
+ progressDate: cleanText(cells[3]),
303
+ status: cleanText(cells[4]),
304
+ author: cleanText(cells[5]),
305
+ createdAt: cleanText(cells[6]),
306
+ acceptedTime: cleanText(cells[7]),
307
+ payAmount: cleanText(cells[8]),
308
+ })
309
+ })
310
+ }
311
+
312
+ export function parseReportDetail(html: string, id = 0): ReportDetail {
313
+ const root = parse(html)
314
+ const labels = extractLabelMap(root)
315
+
316
+ const progressTimeText = labels['진행시간'] || ''
317
+ const exceptTimeText = labels['제외시간'] || ''
318
+ const progressTimes = progressTimeText.split(/\s*~\s*/)
319
+ const exceptTimes = exceptTimeText.split(/\s*~\s*/)
320
+
321
+ return ReportDetailSchema.parse({
322
+ id,
323
+ category: labels['구분'] || '',
324
+ title: labels['제목'] || '',
325
+ progressDate: labels['진행 날짜'] || '',
326
+ status: labels['상태'] || '',
327
+ author: labels['작성자'] || '',
328
+ createdAt: labels['등록일'] || '',
329
+ acceptedTime: labels['인정시간'] || '',
330
+ payAmount: labels['지급액'] || '',
331
+ content: labels['추진 내용'] || '',
332
+ subject: labels['주제'] || '',
333
+ menteeRegion: labels['멘토링 대상'] || '',
334
+ reportType: labels['구분'] || '',
335
+ teamNames: labels['팀명'] || '',
336
+ venue: labels['진행 장소'] || '',
337
+ attendanceCount: extractNumber(labels['참석 연수생'] || ''),
338
+ attendanceNames: labels['참석자 이름'] || '',
339
+ progressStartTime: progressTimes[0] || '',
340
+ progressEndTime: progressTimes[1] || '',
341
+ exceptStartTime: exceptTimes[0] || '',
342
+ exceptEndTime: exceptTimes[1] || '',
343
+ exceptReason: labels['제외 사유'] || labels['제외이유'] || '',
344
+ mentorOpinion: labels['멘토 의견'] || '',
345
+ nonAttendanceNames: labels['무단불참자'] || '',
346
+ etc: labels['특이사항'] || '',
347
+ files: [],
348
+ })
349
+ }
350
+
351
+ export function parseApprovalList(html: string): ApprovalListItem[] {
352
+ const rows = findTableRows(html, 10)
353
+
354
+ if (rows.length === 1) {
355
+ const firstRow = rows[0]
356
+ if (firstRow[0]?.getAttribute('colspan') || cleanText(firstRow[0]).includes('데이터가 없습니다')) {
357
+ return []
358
+ }
359
+ }
360
+
361
+ return rows.map((cells) => {
362
+ const link = cells[2]?.querySelector('a')
363
+
364
+ return ApprovalListItemSchema.parse({
365
+ id: extractUrlParam(link?.getAttribute('href'), 'reportId'),
366
+ category: cleanText(cells[1]),
367
+ title: cleanText(link ?? cells[2]),
368
+ progressDate: cleanText(cells[3]),
369
+ status: cleanText(cells[4]),
370
+ author: cleanText(cells[5]),
371
+ createdAt: cleanText(cells[6]),
372
+ acceptedTime: cleanText(cells[7]),
373
+ travelExpense: cleanText(cells[8]),
374
+ mentoringAllowance: cleanText(cells[9]),
375
+ })
376
+ })
377
+ }
378
+
274
379
  function findTableRows(html: string, cellCount: number): HTMLElement[][] {
275
380
  return parse(html)
276
381
  .querySelectorAll('table tbody tr')
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({
@@ -204,7 +361,7 @@ function createResponse(
204
361
  contentType = 'text/html',
205
362
  options: { status?: number; headers?: Record<string, string> } = {},
206
363
  ): Response {
207
- const headers = new Headers({ 'Content-Type': contentType, ...(options.headers ?? {}) })
364
+ const headers = new Headers({ 'Content-Type': contentType, ...options.headers })
208
365
  const response = new Response(body, { headers, status: options.status })
209
366
  const cookieHeaders = cookies
210
367
 
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]
@@ -343,6 +412,13 @@ export class SomaHttp {
343
412
  }
344
413
  }
345
414
 
415
+ private buildMultipartHeaders(referer: string): Record<string, string> {
416
+ return {
417
+ ...this.buildHeaders(),
418
+ Referer: referer,
419
+ }
420
+ }
421
+
346
422
  private isAuthRedirect(location: string | null): boolean {
347
423
  if (!location) {
348
424
  return false
@@ -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
+ }
@@ -22,9 +22,9 @@ type BrowserConfig = {
22
22
  }
23
23
 
24
24
  type CookieRow = {
25
- encrypted_value: Uint8Array | Buffer | null
25
+ encrypted_value: ArrayBuffer | Uint8Array | Buffer | null
26
26
  last_access_utc?: number | bigint | null
27
- value: string | null
27
+ value: ArrayBuffer | Uint8Array | Buffer | string | null
28
28
  }
29
29
 
30
30
  export interface ExtractedSessionCandidate {
@@ -92,6 +92,20 @@ function queryCookieDb(dbPath: string): CookieRow | undefined {
92
92
  }
93
93
 
94
94
  try {
95
+ const Database = require('better-sqlite3') as new (
96
+ path: string,
97
+ options?: { readonly?: boolean },
98
+ ) => {
99
+ close: () => void
100
+ prepare: (query: string) => { get: () => CookieRow | undefined }
101
+ }
102
+ const db = new Database(dbPath, { readonly: true })
103
+ try {
104
+ return db.prepare(COOKIE_QUERY).get() ?? undefined
105
+ } finally {
106
+ db.close()
107
+ }
108
+ } catch {
95
109
  const { DatabaseSync } = require('node:sqlite') as {
96
110
  DatabaseSync: new (
97
111
  path: string,
@@ -107,20 +121,6 @@ function queryCookieDb(dbPath: string): CookieRow | undefined {
107
121
  } finally {
108
122
  db.close()
109
123
  }
110
- } catch {
111
- const Database = require('better-sqlite3') as new (
112
- path: string,
113
- options?: { readonly?: boolean },
114
- ) => {
115
- close: () => void
116
- prepare: (query: string) => { get: () => CookieRow | undefined }
117
- }
118
- const db = new Database(dbPath, { readonly: true })
119
- try {
120
- return db.prepare(COOKIE_QUERY).get() ?? undefined
121
- } finally {
122
- db.close()
123
- }
124
124
  }
125
125
  }
126
126
 
@@ -156,14 +156,14 @@ export class TokenExtractor {
156
156
  const tempDatabasePath = join(tempDirectory, 'Cookies')
157
157
 
158
158
  try {
159
- copyFileSync(databasePath, tempDatabasePath)
159
+ copySqliteDatabase(databasePath, tempDatabasePath)
160
160
 
161
161
  const row = queryCookieDb(tempDatabasePath)
162
162
  if (!row) {
163
163
  continue
164
164
  }
165
165
 
166
- const plaintextValue = typeof row.value === 'string' ? row.value.trim() : ''
166
+ const plaintextValue = normalizeCookieText(row.value)
167
167
  if (plaintextValue) {
168
168
  this.addCandidate(candidates, {
169
169
  browser: browser.name,
@@ -174,11 +174,12 @@ export class TokenExtractor {
174
174
  continue
175
175
  }
176
176
 
177
- if (!row.encrypted_value || row.encrypted_value.length === 0) {
177
+ const encryptedValue = normalizeCookieBytes(row.encrypted_value)
178
+ if (!encryptedValue || encryptedValue.length === 0) {
178
179
  continue
179
180
  }
180
181
 
181
- const decryptedValue = await this.decryptCookie(Buffer.from(row.encrypted_value), browser.name)
182
+ const decryptedValue = await this.decryptCookie(encryptedValue, browser.name)
182
183
  if (decryptedValue) {
183
184
  this.addCandidate(candidates, {
184
185
  browser: browser.name,
@@ -299,3 +300,41 @@ export class TokenExtractor {
299
300
  return typeof lastAccessUtc === 'number' ? lastAccessUtc : 0
300
301
  }
301
302
  }
303
+
304
+ function copySqliteDatabase(sourcePath: string, targetPath: string): void {
305
+ copyFileSync(sourcePath, targetPath)
306
+
307
+ for (const suffix of ['-wal', '-shm']) {
308
+ const sidecarSourcePath = `${sourcePath}${suffix}`
309
+ if (!existsSync(sidecarSourcePath)) {
310
+ continue
311
+ }
312
+
313
+ copyFileSync(sidecarSourcePath, `${targetPath}${suffix}`)
314
+ }
315
+ }
316
+
317
+ function normalizeCookieBytes(value: ArrayBuffer | Uint8Array | Buffer | null): Buffer | null {
318
+ if (!value) {
319
+ return null
320
+ }
321
+
322
+ if (Buffer.isBuffer(value)) {
323
+ return value
324
+ }
325
+
326
+ if (value instanceof Uint8Array) {
327
+ return Buffer.from(value)
328
+ }
329
+
330
+ return Buffer.from(value)
331
+ }
332
+
333
+ function normalizeCookieText(value: ArrayBuffer | Uint8Array | Buffer | string | null): string {
334
+ if (typeof value === 'string') {
335
+ return value.trim()
336
+ }
337
+
338
+ const bytes = normalizeCookieBytes(value)
339
+ return bytes ? bytes.toString('utf8').trim() : ''
340
+ }