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.
- package/dist/package.json +2 -2
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +2 -1
- package/dist/src/cli.js.map +1 -1
- package/dist/src/client.d.ts +24 -1
- package/dist/src/client.d.ts.map +1 -1
- package/dist/src/client.js +124 -1
- package/dist/src/client.js.map +1 -1
- package/dist/src/commands/auth.d.ts +5 -1
- package/dist/src/commands/auth.d.ts.map +1 -1
- package/dist/src/commands/auth.js +83 -4
- package/dist/src/commands/auth.js.map +1 -1
- package/dist/src/commands/helpers.d.ts +5 -1
- package/dist/src/commands/helpers.d.ts.map +1 -1
- package/dist/src/commands/helpers.js +28 -3
- package/dist/src/commands/helpers.js.map +1 -1
- package/dist/src/commands/index.d.ts +1 -0
- package/dist/src/commands/index.d.ts.map +1 -1
- package/dist/src/commands/index.js +1 -0
- package/dist/src/commands/index.js.map +1 -1
- package/dist/src/commands/mentoring.d.ts.map +1 -1
- package/dist/src/commands/mentoring.js +37 -1
- package/dist/src/commands/mentoring.js.map +1 -1
- package/dist/src/commands/report.d.ts +27 -0
- package/dist/src/commands/report.d.ts.map +1 -0
- package/dist/src/commands/report.js +224 -0
- package/dist/src/commands/report.js.map +1 -0
- package/dist/src/constants.d.ts +2 -0
- package/dist/src/constants.d.ts.map +1 -1
- package/dist/src/constants.js +2 -0
- package/dist/src/constants.js.map +1 -1
- package/dist/src/formatters.d.ts +4 -1
- package/dist/src/formatters.d.ts.map +1 -1
- package/dist/src/formatters.js +91 -1
- package/dist/src/formatters.js.map +1 -1
- package/dist/src/http.d.ts +2 -0
- package/dist/src/http.d.ts.map +1 -1
- package/dist/src/http.js +65 -1
- package/dist/src/http.js.map +1 -1
- package/dist/src/shared/utils/report-params.d.ts +11 -0
- package/dist/src/shared/utils/report-params.d.ts.map +1 -0
- package/dist/src/shared/utils/report-params.js +27 -0
- package/dist/src/shared/utils/report-params.js.map +1 -0
- package/dist/src/shared/utils/swmaestro.d.ts +24 -0
- package/dist/src/shared/utils/swmaestro.d.ts.map +1 -1
- package/dist/src/shared/utils/swmaestro.js +50 -2
- package/dist/src/shared/utils/swmaestro.js.map +1 -1
- package/dist/src/token-extractor.d.ts.map +1 -1
- package/dist/src/token-extractor.js +38 -8
- package/dist/src/token-extractor.js.map +1 -1
- package/dist/src/types.d.ts +120 -0
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js +71 -0
- package/dist/src/types.js.map +1 -1
- package/package.json +2 -2
- package/src/cli.ts +2 -0
- package/src/client.test.ts +30 -5
- package/src/client.ts +151 -0
- package/src/commands/auth.test.ts +40 -1
- package/src/commands/auth.ts +91 -3
- package/src/commands/helpers.test.ts +108 -4
- package/src/commands/helpers.ts +30 -3
- package/src/commands/index.ts +1 -0
- package/src/commands/mentoring.ts +55 -0
- package/src/commands/report.test.ts +49 -0
- package/src/commands/report.ts +322 -0
- package/src/constants.ts +2 -0
- package/src/formatters.test.ts +287 -0
- package/src/formatters.ts +105 -0
- package/src/http.test.ts +158 -1
- package/src/http.ts +77 -1
- package/src/shared/utils/report-params.ts +41 -0
- package/src/shared/utils/swmaestro.ts +77 -5
- package/src/token-extractor.ts +59 -20
- package/src/types.test.ts +97 -0
- 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, ...
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
+
}
|
package/src/token-extractor.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|