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.
- 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 +26 -1
- package/dist/src/client.d.ts.map +1 -1
- package/dist/src/client.js +141 -3
- package/dist/src/client.js.map +1 -1
- package/dist/src/commands/auth.d.ts +12 -0
- package/dist/src/commands/auth.d.ts.map +1 -1
- package/dist/src/commands/auth.js +151 -23
- package/dist/src/commands/auth.js.map +1 -1
- package/dist/src/commands/helpers.d.ts +12 -0
- package/dist/src/commands/helpers.d.ts.map +1 -1
- package/dist/src/commands/helpers.js +55 -9
- 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/credential-manager.d.ts +7 -0
- package/dist/src/credential-manager.d.ts.map +1 -1
- package/dist/src/credential-manager.js +76 -2
- package/dist/src/credential-manager.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 +3 -0
- package/dist/src/http.d.ts.map +1 -1
- package/dist/src/http.js +109 -4
- package/dist/src/http.js.map +1 -1
- package/dist/src/session-recovery.d.ts +12 -0
- package/dist/src/session-recovery.d.ts.map +1 -0
- package/dist/src/session-recovery.js +34 -0
- package/dist/src/session-recovery.js.map +1 -0
- 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 +121 -0
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js +72 -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 +95 -6
- package/src/client.ts +172 -4
- package/src/commands/auth.test.ts +152 -1
- package/src/commands/auth.ts +174 -28
- package/src/commands/helpers.test.ts +216 -0
- package/src/commands/helpers.ts +77 -12
- 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/credential-manager.test.ts +41 -1
- package/src/credential-manager.ts +103 -2
- package/src/formatters.test.ts +287 -0
- package/src/formatters.ts +105 -0
- package/src/http.test.ts +190 -4
- package/src/http.ts +132 -4
- package/src/session-recovery.ts +56 -0
- 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 +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,
|
|
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(
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
+
}
|