opensoma 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/package.json +1 -1
- package/dist/src/client.d.ts +3 -3
- package/dist/src/client.d.ts.map +1 -1
- package/dist/src/client.js +37 -5
- package/dist/src/client.js.map +1 -1
- package/dist/src/commands/auth.d.ts +1 -1
- package/dist/src/commands/auth.d.ts.map +1 -1
- package/dist/src/commands/auth.js +94 -52
- package/dist/src/commands/auth.js.map +1 -1
- package/dist/src/commands/mentoring.d.ts.map +1 -1
- package/dist/src/commands/mentoring.js +26 -20
- package/dist/src/commands/mentoring.js.map +1 -1
- package/dist/src/commands/room.d.ts.map +1 -1
- package/dist/src/commands/room.js +25 -2
- package/dist/src/commands/room.js.map +1 -1
- package/dist/src/constants.d.ts +52 -9
- package/dist/src/constants.d.ts.map +1 -1
- package/dist/src/constants.js +65 -9
- package/dist/src/constants.js.map +1 -1
- package/dist/src/formatters.d.ts.map +1 -1
- package/dist/src/formatters.js +79 -39
- 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 +42 -1
- package/dist/src/http.js.map +1 -1
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/shared/utils/html.d.ts +3 -0
- package/dist/src/shared/utils/html.d.ts.map +1 -0
- package/dist/src/shared/utils/html.js +12 -0
- package/dist/src/shared/utils/html.js.map +1 -0
- package/dist/src/shared/utils/swmaestro.d.ts +2 -0
- package/dist/src/shared/utils/swmaestro.d.ts.map +1 -1
- package/dist/src/shared/utils/swmaestro.js +28 -5
- package/dist/src/shared/utils/swmaestro.js.map +1 -1
- package/dist/src/shared/utils/toz.d.ts +23 -0
- package/dist/src/shared/utils/toz.d.ts.map +1 -0
- package/dist/src/shared/utils/toz.js +72 -0
- package/dist/src/shared/utils/toz.js.map +1 -0
- package/dist/src/token-extractor.d.ts +9 -1
- package/dist/src/token-extractor.d.ts.map +1 -1
- package/dist/src/token-extractor.js +54 -10
- package/dist/src/token-extractor.js.map +1 -1
- package/dist/src/toz-formatters.d.ts +9 -0
- package/dist/src/toz-formatters.d.ts.map +1 -0
- package/dist/src/toz-formatters.js +151 -0
- package/dist/src/toz-formatters.js.map +1 -0
- package/dist/src/toz-http.d.ts +27 -0
- package/dist/src/toz-http.d.ts.map +1 -0
- package/dist/src/toz-http.js +154 -0
- package/dist/src/toz-http.js.map +1 -0
- package/dist/src/types.d.ts +88 -0
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js +65 -1
- package/dist/src/types.js.map +1 -1
- package/package.json +1 -1
- package/src/__fixtures__/toz/toz_all_branches.json +211 -0
- package/src/__fixtures__/toz/toz_booking.html +2190 -0
- package/src/__fixtures__/toz/toz_boothes.json +59 -0
- package/src/__fixtures__/toz/toz_duration.json +25 -0
- package/src/__fixtures__/toz/toz_mypage_response.html +388 -0
- package/src/__fixtures__/toz/toz_page.html +211 -0
- package/src/client.test.ts +63 -16
- package/src/client.ts +43 -6
- package/src/commands/auth.ts +107 -50
- package/src/commands/mentoring.ts +34 -26
- package/src/commands/room.ts +31 -3
- package/src/constants.ts +74 -9
- package/src/formatters.test.ts +6 -2
- package/src/formatters.ts +92 -45
- package/src/http.test.ts +215 -0
- package/src/http.ts +45 -1
- package/src/index.ts +3 -0
- package/src/shared/utils/html.ts +12 -0
- package/src/shared/utils/swmaestro.test.ts +44 -0
- package/src/shared/utils/swmaestro.ts +30 -5
- package/src/shared/utils/toz.test.ts +133 -0
- package/src/shared/utils/toz.ts +100 -0
- package/src/token-extractor.test.ts +30 -5
- package/src/token-extractor.ts +65 -13
- package/src/toz-formatters.test.ts +197 -0
- package/src/toz-formatters.ts +211 -0
- package/src/toz-http.test.ts +157 -0
- package/src/toz-http.ts +188 -0
- package/src/types.test.ts +4 -1
- package/src/types.ts +81 -1
package/src/formatters.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { type HTMLElement, parse } from 'node-html-parser'
|
|
2
2
|
|
|
3
|
+
import { decodeHtmlEntities } from './shared/utils/html'
|
|
3
4
|
import {
|
|
4
|
-
ApplicationHistoryItemSchema,
|
|
5
5
|
type ApplicationHistoryItem,
|
|
6
|
-
|
|
6
|
+
ApplicationHistoryItemSchema,
|
|
7
7
|
type ApprovalListItem,
|
|
8
|
-
|
|
9
|
-
EventListItemSchema,
|
|
8
|
+
ApprovalListItemSchema,
|
|
10
9
|
type Dashboard,
|
|
10
|
+
DashboardSchema,
|
|
11
11
|
type EventListItem,
|
|
12
|
+
EventListItemSchema,
|
|
12
13
|
type MemberInfo,
|
|
13
14
|
MemberInfoSchema,
|
|
14
15
|
type MentoringDetail,
|
|
@@ -98,7 +99,7 @@ export function parseMentoringDetail(html: string, id = 0): MentoringDetail {
|
|
|
98
99
|
status: extractStatus(labels.상태 || rawTitle),
|
|
99
100
|
author: labels['작성자'] || '',
|
|
100
101
|
createdAt: labels['등록일'] || '',
|
|
101
|
-
content: contentNode?.innerHTML.trim() ?? '',
|
|
102
|
+
content: decodeHtmlEntities(contentNode?.innerHTML.trim() ?? ''),
|
|
102
103
|
venue: labels['장소'] || '',
|
|
103
104
|
applicants,
|
|
104
105
|
})
|
|
@@ -131,20 +132,7 @@ export function parseRoomSlots(html: string): RoomCard['timeSlots'] {
|
|
|
131
132
|
return parseTimeSlotsFromRoot(root)
|
|
132
133
|
}
|
|
133
134
|
|
|
134
|
-
return slots
|
|
135
|
-
.map((slot) => {
|
|
136
|
-
const hour = slot.getAttribute('data-hour') ?? ''
|
|
137
|
-
const minute = slot.getAttribute('data-minute') ?? ''
|
|
138
|
-
const checkbox = slot.querySelector('input[type="checkbox"]')
|
|
139
|
-
const className = slot.getAttribute('class') ?? ''
|
|
140
|
-
const time = hour && minute ? `${hour}:${minute}` : normalizeTime(cleanText(slot.querySelector('label') ?? slot))
|
|
141
|
-
|
|
142
|
-
return {
|
|
143
|
-
time,
|
|
144
|
-
available: !checkbox?.hasAttribute('disabled') && !className.includes('disabled'),
|
|
145
|
-
}
|
|
146
|
-
})
|
|
147
|
-
.filter((slot) => Boolean(slot.time))
|
|
135
|
+
return slots.map(parseRoomTimeSlot).filter((slot) => Boolean(slot.time))
|
|
148
136
|
}
|
|
149
137
|
|
|
150
138
|
export function parseDashboard(html: string): Dashboard {
|
|
@@ -202,7 +190,7 @@ export function parseNoticeDetail(html: string, id = 0): NoticeDetail {
|
|
|
202
190
|
title: cleanText(top?.querySelector('.tit')) || labels['제목'] || cleanText(root.querySelector('h1, h2, .title')),
|
|
203
191
|
author,
|
|
204
192
|
createdAt,
|
|
205
|
-
content: contentNode?.innerHTML.trim() ?? '',
|
|
193
|
+
content: decodeHtmlEntities(contentNode?.innerHTML.trim() ?? ''),
|
|
206
194
|
})
|
|
207
195
|
}
|
|
208
196
|
|
|
@@ -311,12 +299,40 @@ export function parseReportList(html: string): ReportListItem[] {
|
|
|
311
299
|
|
|
312
300
|
export function parseReportDetail(html: string, id = 0): ReportDetail {
|
|
313
301
|
const root = parse(html)
|
|
314
|
-
const labels = extractLabelMap(root)
|
|
302
|
+
const labels = { ...extractLabelMap(root), ...extractGroupMap(root) }
|
|
315
303
|
|
|
316
304
|
const progressTimeText = labels['진행시간'] || ''
|
|
317
305
|
const exceptTimeText = labels['제외시간'] || ''
|
|
318
|
-
const
|
|
319
|
-
const
|
|
306
|
+
const progressTimeMatch = progressTimeText.match(/(\d{2}:\d{2})\s*~\s*(\d{2}:\d{2})/)
|
|
307
|
+
const exceptTimeMatch = exceptTimeText.match(/(\d{2}:\d{2})\s*~\s*(\d{2}:\d{2})/)
|
|
308
|
+
|
|
309
|
+
let subject = labels['주제'] || ''
|
|
310
|
+
if (!subject) {
|
|
311
|
+
for (const group of root.querySelectorAll('.group')) {
|
|
312
|
+
if (cleanText(group.querySelector('strong.t')) === '주제') {
|
|
313
|
+
subject = group.querySelector('input')?.getAttribute('value')?.trim() || ''
|
|
314
|
+
break
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const findGroupTextarea = (...names: string[]): string => {
|
|
320
|
+
for (const group of root.querySelectorAll('.group')) {
|
|
321
|
+
const label = cleanText(group.querySelector('strong.t')).replace(/:$/, '')
|
|
322
|
+
if (names.includes(label)) {
|
|
323
|
+
const textarea = group.querySelector('textarea')
|
|
324
|
+
if (textarea) return textarea.text.trim()
|
|
325
|
+
return cleanText(group.querySelector('.c'))
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return ''
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const files = root
|
|
332
|
+
.querySelectorAll('.file_list_new a')
|
|
333
|
+
.map((a) => a.getAttribute('href') || '')
|
|
334
|
+
.filter(Boolean)
|
|
335
|
+
.map((href) => (href.startsWith('http') ? href : `https://www.swmaestro.ai${href}`))
|
|
320
336
|
|
|
321
337
|
return ReportDetailSchema.parse({
|
|
322
338
|
id,
|
|
@@ -324,27 +340,27 @@ export function parseReportDetail(html: string, id = 0): ReportDetail {
|
|
|
324
340
|
title: labels['제목'] || '',
|
|
325
341
|
progressDate: labels['진행 날짜'] || '',
|
|
326
342
|
status: labels['상태'] || '',
|
|
327
|
-
author: labels['작성자'] || '',
|
|
343
|
+
author: labels['작성자'] || labels['진행 멘토 명'] || '',
|
|
328
344
|
createdAt: labels['등록일'] || '',
|
|
329
345
|
acceptedTime: labels['인정시간'] || '',
|
|
330
346
|
payAmount: labels['지급액'] || '',
|
|
331
|
-
content: labels['추진 내용'] || '',
|
|
332
|
-
subject
|
|
333
|
-
menteeRegion: labels['멘토링 대상'] || '',
|
|
347
|
+
content: findGroupTextarea('추진내용', '추진 내용') || labels['추진내용'] || labels['추진 내용'] || '',
|
|
348
|
+
subject,
|
|
349
|
+
menteeRegion: labels['멘토링대상'] || labels['멘토링 대상'] || '',
|
|
334
350
|
reportType: labels['구분'] || '',
|
|
335
351
|
teamNames: labels['팀명'] || '',
|
|
336
352
|
venue: labels['진행 장소'] || '',
|
|
337
|
-
attendanceCount: extractNumber(labels['참석 연수생'] || ''),
|
|
353
|
+
attendanceCount: extractNumber(labels['참석자 인원'] || labels['참석 연수생'] || ''),
|
|
338
354
|
attendanceNames: labels['참석자 이름'] || '',
|
|
339
|
-
progressStartTime:
|
|
340
|
-
progressEndTime:
|
|
341
|
-
exceptStartTime:
|
|
342
|
-
exceptEndTime:
|
|
343
|
-
exceptReason: labels['제외 사유'] || labels['제외이유'] || '',
|
|
344
|
-
mentorOpinion: labels['멘토 의견'] || '',
|
|
355
|
+
progressStartTime: progressTimeMatch?.[1] || '',
|
|
356
|
+
progressEndTime: progressTimeMatch?.[2] || '',
|
|
357
|
+
exceptStartTime: exceptTimeMatch?.[1] || '',
|
|
358
|
+
exceptEndTime: exceptTimeMatch?.[2] || '',
|
|
359
|
+
exceptReason: labels['제외사유'] || labels['제외 사유'] || labels['제외이유'] || '',
|
|
360
|
+
mentorOpinion: findGroupTextarea('멘토의견', '멘토 의견') || labels['멘토의견'] || labels['멘토 의견'] || '',
|
|
345
361
|
nonAttendanceNames: labels['무단불참자'] || '',
|
|
346
|
-
etc: labels['특이사항'] || '',
|
|
347
|
-
files
|
|
362
|
+
etc: findGroupTextarea('기타', '특이사항') || labels['기타'] || labels['특이사항'] || '',
|
|
363
|
+
files,
|
|
348
364
|
})
|
|
349
365
|
}
|
|
350
366
|
|
|
@@ -464,15 +480,46 @@ function parseTimeSlotsFromRoot(root: HTMLElement): RoomCard['timeSlots'] {
|
|
|
464
480
|
? grid.querySelectorAll('span')
|
|
465
481
|
: root.querySelectorAll('.time-grid span, [class*="time-slot"], .slot')
|
|
466
482
|
|
|
467
|
-
return spans
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
483
|
+
return spans.map(parseRoomTimeSlot).filter((slot) => Boolean(slot.time))
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function parseRoomTimeSlot(slot: HTMLElement): RoomCard['timeSlots'][number] {
|
|
487
|
+
const label = slot.querySelector('label')
|
|
488
|
+
const hour = slot.getAttribute('data-hour') ?? ''
|
|
489
|
+
const minute = slot.getAttribute('data-minute') ?? ''
|
|
490
|
+
const checkbox = slot.querySelector('input[type="checkbox"]')
|
|
491
|
+
const className = slot.getAttribute('class') ?? ''
|
|
492
|
+
const available =
|
|
493
|
+
!checkbox?.hasAttribute('disabled') &&
|
|
494
|
+
!className.includes('not-reserve') &&
|
|
495
|
+
!className.includes('booked') &&
|
|
496
|
+
!className.includes('disabled')
|
|
497
|
+
const time = hour && minute ? `${hour}:${minute}` : normalizeTime(cleanText(label ?? slot))
|
|
498
|
+
const reservation = !available ? extractReservation(label) : undefined
|
|
499
|
+
|
|
500
|
+
return {
|
|
501
|
+
time,
|
|
502
|
+
available,
|
|
503
|
+
...(reservation ? { reservation } : {}),
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function extractReservation(label: HTMLElement | null): { title: string; bookedBy: string } | undefined {
|
|
508
|
+
const rawTitle = label?.getAttribute('title')
|
|
509
|
+
if (!rawTitle) {
|
|
510
|
+
return undefined
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const [title = '', bookedByLine = ''] = decodeHtmlEntities(rawTitle)
|
|
514
|
+
.split(/<br\s*\/?>/i)
|
|
515
|
+
.map((part) => part.trim())
|
|
516
|
+
const bookedBy = bookedByLine.replace(/^예약자\s*:\s*/, '').trim()
|
|
517
|
+
|
|
518
|
+
if (!title || !bookedBy) {
|
|
519
|
+
return undefined
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return { title, bookedBy }
|
|
476
523
|
}
|
|
477
524
|
|
|
478
525
|
function findDashboardValue(items: HTMLElement[], label: string): string {
|
package/src/http.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { afterEach, describe, expect, mock, test } from 'bun:test'
|
|
2
2
|
|
|
3
3
|
import { MENU_NO } from './constants'
|
|
4
|
+
import { AuthenticationError } from './errors'
|
|
4
5
|
import { SomaHttp } from './http'
|
|
5
6
|
|
|
6
7
|
const originalFetch = globalThis.fetch
|
|
@@ -209,6 +210,41 @@ describe('SomaHttp', () => {
|
|
|
209
210
|
})
|
|
210
211
|
})
|
|
211
212
|
|
|
213
|
+
describe('postForm', () => {
|
|
214
|
+
test('converts Record to FormData and delegates to postMultipart', async () => {
|
|
215
|
+
const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
|
216
|
+
const body = init?.body
|
|
217
|
+
expect(body).toBeInstanceOf(FormData)
|
|
218
|
+
const fd = body as FormData
|
|
219
|
+
expect(fd.get('title')).toBe('테스트')
|
|
220
|
+
expect(fd.get('qestnarCn')).toBe('<p>멘토링 내용</p>')
|
|
221
|
+
expect(fd.get('csrfToken')).toBe('csrf-1')
|
|
222
|
+
return createResponse('<html>ok</html>')
|
|
223
|
+
})
|
|
224
|
+
globalThis.fetch = fetchMock as typeof fetch
|
|
225
|
+
|
|
226
|
+
const http = new SomaHttp({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
|
|
227
|
+
|
|
228
|
+
await expect(
|
|
229
|
+
http.postForm('/mypage/mentoLec/insert.do', { title: '테스트', qestnarCn: '<p>멘토링 내용</p>' }),
|
|
230
|
+
).resolves.toBe('<html>ok</html>')
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
test('does not set Content-Type header (lets FormData set multipart boundary)', async () => {
|
|
234
|
+
const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
|
235
|
+
const headers = init?.headers as Record<string, string> | undefined
|
|
236
|
+
expect(headers?.['Content-Type']).toBeUndefined()
|
|
237
|
+
expect(headers?.['content-type']).toBeUndefined()
|
|
238
|
+
return createResponse('<html>ok</html>')
|
|
239
|
+
})
|
|
240
|
+
globalThis.fetch = fetchMock as typeof fetch
|
|
241
|
+
|
|
242
|
+
const http = new SomaHttp({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
|
|
243
|
+
|
|
244
|
+
await http.postForm('/test', { key: 'value' })
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
|
|
212
248
|
test('postJson returns parsed json', async () => {
|
|
213
249
|
const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
|
214
250
|
expect(init?.headers).toEqual({
|
|
@@ -353,6 +389,185 @@ describe('SomaHttp', () => {
|
|
|
353
389
|
await expect(http.extractCsrfToken()).resolves.toBe('csrf-token')
|
|
354
390
|
expect(http.getCsrfToken()).toBe('csrf-token')
|
|
355
391
|
})
|
|
392
|
+
|
|
393
|
+
describe('session-expired alert errors', () => {
|
|
394
|
+
function sessionExpiredAlert(message: string): string {
|
|
395
|
+
return `<html><head></head><body><script language='JavaScript'>\nalert('${message}');\nhistory.back();\n</script></body></html>`
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const expiredMessage = '잘못된 접근입니다. 해당 세션을 전체 초기화 하였습니다.'
|
|
399
|
+
|
|
400
|
+
test('post throws AuthenticationError for session-expired alert', async () => {
|
|
401
|
+
const fetchMock = mock(async () => createResponse(sessionExpiredAlert(expiredMessage)))
|
|
402
|
+
globalThis.fetch = fetchMock as typeof fetch
|
|
403
|
+
|
|
404
|
+
const http = new SomaHttp({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
|
|
405
|
+
|
|
406
|
+
await expect(http.post('/mypage/officeMng/list.do', { menuNo: '200058' })).rejects.toThrow(AuthenticationError)
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
test('get throws AuthenticationError for session-expired alert', async () => {
|
|
410
|
+
const fetchMock = mock(async () => createResponse(sessionExpiredAlert(expiredMessage)))
|
|
411
|
+
globalThis.fetch = fetchMock as typeof fetch
|
|
412
|
+
|
|
413
|
+
const http = new SomaHttp({ sessionCookie: 'session-1' })
|
|
414
|
+
|
|
415
|
+
await expect(http.get('/mypage/officeMng/list.do')).rejects.toThrow(AuthenticationError)
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
test('postMultipart throws AuthenticationError for session-expired alert', async () => {
|
|
419
|
+
const fetchMock = mock(async () => createResponse(sessionExpiredAlert(expiredMessage)))
|
|
420
|
+
globalThis.fetch = fetchMock as typeof fetch
|
|
421
|
+
|
|
422
|
+
const http = new SomaHttp({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
|
|
423
|
+
|
|
424
|
+
await expect(http.postMultipart('/mypage/test.do', new FormData())).rejects.toThrow(AuthenticationError)
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
test('alert with 세션 + invalidation keyword variants are treated as auth error', async () => {
|
|
428
|
+
const variants = ['세션이 만료되었습니다.', '해당 세션을 초기화하였습니다.', '세션 정보가 유효하지 않습니다.']
|
|
429
|
+
|
|
430
|
+
for (const message of variants) {
|
|
431
|
+
const fetchMock = mock(async () => createResponse(sessionExpiredAlert(message)))
|
|
432
|
+
globalThis.fetch = fetchMock as typeof fetch
|
|
433
|
+
|
|
434
|
+
const http = new SomaHttp({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
|
|
435
|
+
await expect(http.post('/test', {})).rejects.toThrow(AuthenticationError)
|
|
436
|
+
}
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
test('alert containing 세션 without invalidation keyword is not treated as auth error', async () => {
|
|
440
|
+
const fetchMock = mock(async () => createResponse(sessionExpiredAlert('멘토링 세션이 이미 마감되었습니다.')))
|
|
441
|
+
globalThis.fetch = fetchMock as typeof fetch
|
|
442
|
+
|
|
443
|
+
const http = new SomaHttp({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
|
|
444
|
+
|
|
445
|
+
await expect(http.post('/mypage/test.do', {})).rejects.toThrow('멘토링 세션이 이미 마감되었습니다.')
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
test('non-session alert still throws regular Error', async () => {
|
|
449
|
+
const fetchMock = mock(async () => createResponse(sessionExpiredAlert('잘못된 접근입니다.')))
|
|
450
|
+
globalThis.fetch = fetchMock as typeof fetch
|
|
451
|
+
|
|
452
|
+
const http = new SomaHttp({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
|
|
453
|
+
|
|
454
|
+
await expect(http.post('/mypage/test.do', {})).rejects.toThrow('잘못된 접근입니다.')
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
test('get throws regular Error for non-session alert', async () => {
|
|
458
|
+
const fetchMock = mock(async () => createResponse(sessionExpiredAlert('잘못된 접근입니다.')))
|
|
459
|
+
globalThis.fetch = fetchMock as typeof fetch
|
|
460
|
+
|
|
461
|
+
const http = new SomaHttp({ sessionCookie: 'session-1' })
|
|
462
|
+
|
|
463
|
+
await expect(http.get('/mypage/test.do')).rejects.toThrow('잘못된 접근입니다.')
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
test('session-expired alert with double quotes is detected', async () => {
|
|
467
|
+
const doubleQuoteAlert = `<html><body><script language='JavaScript'>\nalert("${expiredMessage}");\nhistory.back();\n</script></body></html>`
|
|
468
|
+
const fetchMock = mock(async () => createResponse(doubleQuoteAlert))
|
|
469
|
+
globalThis.fetch = fetchMock as typeof fetch
|
|
470
|
+
|
|
471
|
+
const http = new SomaHttp({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
|
|
472
|
+
|
|
473
|
+
await expect(http.post('/mypage/test.do', {})).rejects.toThrow(AuthenticationError)
|
|
474
|
+
})
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
describe('JSON error responses', () => {
|
|
478
|
+
const sessionExpiredJson = JSON.stringify({
|
|
479
|
+
error: '잘못된 접근입니다. 해당 세션을 전체 초기화 하였습니다.',
|
|
480
|
+
})
|
|
481
|
+
const genericErrorJson = JSON.stringify({ error: '처리 중 오류가 발생했습니다.' })
|
|
482
|
+
|
|
483
|
+
test('post throws AuthenticationError for session-expired JSON', async () => {
|
|
484
|
+
const fetchMock = mock(async () => createResponse(sessionExpiredJson, [], 'application/json'))
|
|
485
|
+
globalThis.fetch = fetchMock as typeof fetch
|
|
486
|
+
|
|
487
|
+
const http = new SomaHttp({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
|
|
488
|
+
|
|
489
|
+
await expect(http.post('/mypage/officeMng/list.do', { menuNo: '200058' })).rejects.toThrow(AuthenticationError)
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
test('post throws Error for non-session JSON error', async () => {
|
|
493
|
+
const fetchMock = mock(async () => createResponse(genericErrorJson, [], 'application/json'))
|
|
494
|
+
globalThis.fetch = fetchMock as typeof fetch
|
|
495
|
+
|
|
496
|
+
const http = new SomaHttp({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
|
|
497
|
+
|
|
498
|
+
await expect(http.post('/mypage/test.do', {})).rejects.toThrow('처리 중 오류가 발생했습니다.')
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
test('postJson throws AuthenticationError for session-expired JSON', async () => {
|
|
502
|
+
const fetchMock = mock(async () => createResponse(sessionExpiredJson, [], 'application/json'))
|
|
503
|
+
globalThis.fetch = fetchMock as typeof fetch
|
|
504
|
+
|
|
505
|
+
const http = new SomaHttp({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
|
|
506
|
+
|
|
507
|
+
await expect(http.postJson('/mypage/officeMng/rentTime.do', { itemId: '17' })).rejects.toThrow(
|
|
508
|
+
AuthenticationError,
|
|
509
|
+
)
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
test('postJson throws Error for non-session JSON error', async () => {
|
|
513
|
+
const fetchMock = mock(async () => createResponse(genericErrorJson, [], 'application/json'))
|
|
514
|
+
globalThis.fetch = fetchMock as typeof fetch
|
|
515
|
+
|
|
516
|
+
const http = new SomaHttp({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
|
|
517
|
+
|
|
518
|
+
await expect(http.postJson('/mypage/test.do', {})).rejects.toThrow('처리 중 오류가 발생했습니다.')
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
test('postJson still parses valid JSON when no error field', async () => {
|
|
522
|
+
const fetchMock = mock(async () =>
|
|
523
|
+
createResponse(JSON.stringify({ resultCode: 'SUCCESS' }), [], 'application/json'),
|
|
524
|
+
)
|
|
525
|
+
globalThis.fetch = fetchMock as typeof fetch
|
|
526
|
+
|
|
527
|
+
const http = new SomaHttp({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
|
|
528
|
+
|
|
529
|
+
await expect(http.postJson('/mypage/test.do', {})).resolves.toEqual({ resultCode: 'SUCCESS' })
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
test('non-JSON body starting with { does not false-positive', async () => {
|
|
533
|
+
const fetchMock = mock(async () => createResponse('{malformed json', [], 'text/html'))
|
|
534
|
+
globalThis.fetch = fetchMock as typeof fetch
|
|
535
|
+
|
|
536
|
+
const http = new SomaHttp({ sessionCookie: 'session-1' })
|
|
537
|
+
|
|
538
|
+
await expect(http.get('/test')).resolves.toBe('{malformed json')
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
test('JSON error with leading whitespace is still detected', async () => {
|
|
542
|
+
const paddedJson = ` \n ${sessionExpiredJson}`
|
|
543
|
+
const fetchMock = mock(async () => createResponse(paddedJson, [], 'application/json'))
|
|
544
|
+
globalThis.fetch = fetchMock as typeof fetch
|
|
545
|
+
|
|
546
|
+
const http = new SomaHttp({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
|
|
547
|
+
|
|
548
|
+
await expect(http.post('/mypage/test.do', {})).rejects.toThrow(AuthenticationError)
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
test('get throws Error for non-session JSON error', async () => {
|
|
552
|
+
const fetchMock = mock(async () => createResponse(genericErrorJson, [], 'application/json'))
|
|
553
|
+
globalThis.fetch = fetchMock as typeof fetch
|
|
554
|
+
|
|
555
|
+
const http = new SomaHttp({ sessionCookie: 'session-1' })
|
|
556
|
+
|
|
557
|
+
await expect(http.get('/mypage/test.do')).rejects.toThrow('처리 중 오류가 발생했습니다.')
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
test('postJson throws AuthenticationError for HTML login page response', async () => {
|
|
561
|
+
const loginPageHtml =
|
|
562
|
+
'<html><head><title>AI·SW마에스트로</title></head><body><form><input name="username"><input name="password"></form></body></html>'
|
|
563
|
+
const fetchMock = mock(async () => createResponse(loginPageHtml))
|
|
564
|
+
globalThis.fetch = fetchMock as typeof fetch
|
|
565
|
+
|
|
566
|
+
const http = new SomaHttp({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
|
|
567
|
+
|
|
568
|
+
await expect(http.postJson('/mypage/officeMng/rentTime.do', {})).rejects.toThrow(AuthenticationError)
|
|
569
|
+
})
|
|
570
|
+
})
|
|
356
571
|
})
|
|
357
572
|
|
|
358
573
|
function createResponse(
|
package/src/http.ts
CHANGED
|
@@ -60,6 +60,9 @@ export class SomaHttp {
|
|
|
60
60
|
if (errorInfo === '__AUTH_ERROR__') {
|
|
61
61
|
throw new AuthenticationError()
|
|
62
62
|
}
|
|
63
|
+
if (errorInfo) {
|
|
64
|
+
throw new Error(errorInfo)
|
|
65
|
+
}
|
|
63
66
|
|
|
64
67
|
return body
|
|
65
68
|
}
|
|
@@ -134,6 +137,14 @@ export class SomaHttp {
|
|
|
134
137
|
return finalBody
|
|
135
138
|
}
|
|
136
139
|
|
|
140
|
+
async postForm(path: string, body: Record<string, string>): Promise<string> {
|
|
141
|
+
const formData = new FormData()
|
|
142
|
+
for (const [key, value] of Object.entries(body)) {
|
|
143
|
+
formData.append(key, value)
|
|
144
|
+
}
|
|
145
|
+
return this.postMultipart(path, formData)
|
|
146
|
+
}
|
|
147
|
+
|
|
137
148
|
async postMultipart(path: string, formData: FormData): Promise<string> {
|
|
138
149
|
const url = this.buildUrl(path)
|
|
139
150
|
|
|
@@ -203,6 +214,23 @@ export class SomaHttp {
|
|
|
203
214
|
return finalBody
|
|
204
215
|
}
|
|
205
216
|
|
|
217
|
+
private extractJsonError(body: string): string | null {
|
|
218
|
+
if (!body.trimStart().startsWith('{')) return null
|
|
219
|
+
try {
|
|
220
|
+
const json = JSON.parse(body) as Record<string, unknown>
|
|
221
|
+
if (typeof json.error === 'string') {
|
|
222
|
+
return this.isSessionExpiredError(json.error) ? '__AUTH_ERROR__' : json.error
|
|
223
|
+
}
|
|
224
|
+
} catch {
|
|
225
|
+
// Not valid JSON
|
|
226
|
+
}
|
|
227
|
+
return null
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private isSessionExpiredError(message: string): boolean {
|
|
231
|
+
return message.includes('세션') && /초기화|만료|유효하지/.test(message)
|
|
232
|
+
}
|
|
233
|
+
|
|
206
234
|
private extractErrorFromResponse(body: string, location: string | null, path?: string): string | null {
|
|
207
235
|
this.log(
|
|
208
236
|
'extractErrorFromResponse',
|
|
@@ -213,9 +241,15 @@ export class SomaHttp {
|
|
|
213
241
|
body.match(/<title>([^<]*)<\/title>/)?.[1],
|
|
214
242
|
)
|
|
215
243
|
|
|
244
|
+
const jsonError = this.extractJsonError(body)
|
|
245
|
+
if (jsonError) return jsonError
|
|
246
|
+
|
|
216
247
|
const alertMatch = body.match(/<script\b[^>]*>\s*alert\(['"](.+?)['"]\);?\s*(history\.|location\.)/i)
|
|
217
248
|
if (alertMatch) {
|
|
218
249
|
this.log('Found alert match:', alertMatch[1])
|
|
250
|
+
if (this.isSessionExpiredError(alertMatch[1])) {
|
|
251
|
+
return '__AUTH_ERROR__'
|
|
252
|
+
}
|
|
219
253
|
return alertMatch[1]
|
|
220
254
|
}
|
|
221
255
|
|
|
@@ -284,7 +318,17 @@ export class SomaHttp {
|
|
|
284
318
|
})
|
|
285
319
|
|
|
286
320
|
this.updateFromResponse(response)
|
|
287
|
-
|
|
321
|
+
const text = await response.text()
|
|
322
|
+
|
|
323
|
+
const errorInfo = this.extractErrorFromResponse(text, null, path)
|
|
324
|
+
if (errorInfo === '__AUTH_ERROR__') {
|
|
325
|
+
throw new AuthenticationError()
|
|
326
|
+
}
|
|
327
|
+
if (errorInfo) {
|
|
328
|
+
throw new Error(errorInfo)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return JSON.parse(text) as T
|
|
288
332
|
}
|
|
289
333
|
|
|
290
334
|
async login(username: string, password: string): Promise<void> {
|
package/src/index.ts
CHANGED
|
@@ -3,5 +3,8 @@ export type { SomaClientOptions } from './client'
|
|
|
3
3
|
export { AuthenticationError } from './errors'
|
|
4
4
|
export { SomaHttp } from './http'
|
|
5
5
|
export { CredentialManager } from './credential-manager'
|
|
6
|
+
export { TozHttp } from './toz-http'
|
|
7
|
+
export type { TozHttpOptions, TozHttpState } from './toz-http'
|
|
8
|
+
export * from './toz-formatters'
|
|
6
9
|
export * from './types'
|
|
7
10
|
export * from './constants'
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function decodeHtmlEntities(html: string): string {
|
|
2
|
+
return html
|
|
3
|
+
.replace(/</g, '<')
|
|
4
|
+
.replace(/>/g, '>')
|
|
5
|
+
.replace(/"/g, '"')
|
|
6
|
+
.replace(/'/g, "'")
|
|
7
|
+
.replace(/&/g, '&') // must be last to avoid double-decoding
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function escapeHtml(text: string): string {
|
|
11
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
12
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { resolveVenue } from './swmaestro'
|
|
4
|
+
|
|
5
|
+
describe('resolveVenue', () => {
|
|
6
|
+
test('adds 토즈- prefix to bare toz location names', () => {
|
|
7
|
+
expect(resolveVenue('광화문점')).toBe('토즈-광화문점')
|
|
8
|
+
expect(resolveVenue('양재점')).toBe('토즈-양재점')
|
|
9
|
+
expect(resolveVenue('강남컨퍼런스센터점')).toBe('토즈-강남컨퍼런스센터점')
|
|
10
|
+
expect(resolveVenue('건대점')).toBe('토즈-건대점')
|
|
11
|
+
expect(resolveVenue('강남역토즈타워점')).toBe('토즈-강남역토즈타워점')
|
|
12
|
+
expect(resolveVenue('선릉점')).toBe('토즈-선릉점')
|
|
13
|
+
expect(resolveVenue('역삼점')).toBe('토즈-역삼점')
|
|
14
|
+
expect(resolveVenue('홍대점')).toBe('토즈-홍대점')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('passes through already-prefixed toz locations', () => {
|
|
18
|
+
expect(resolveVenue('토즈-광화문점')).toBe('토즈-광화문점')
|
|
19
|
+
expect(resolveVenue('토즈-강남역토즈타워점')).toBe('토즈-강남역토즈타워점')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('resolves 신촌비즈니스센터점 to 연수센터-7', () => {
|
|
23
|
+
expect(resolveVenue('신촌비즈니스센터점')).toBe('연수센터-7')
|
|
24
|
+
expect(resolveVenue('토즈-신촌비즈니스센터점')).toBe('연수센터-7')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('passes through non-toz venues unchanged', () => {
|
|
28
|
+
expect(resolveVenue('온라인(Webex)')).toBe('온라인(Webex)')
|
|
29
|
+
expect(resolveVenue('스페이스 A1')).toBe('스페이스 A1')
|
|
30
|
+
expect(resolveVenue('스페이스 M1')).toBe('스페이스 M1')
|
|
31
|
+
expect(resolveVenue('스페이스 S')).toBe('스페이스 S')
|
|
32
|
+
expect(resolveVenue('(엑스퍼트) 연수센터_라운지')).toBe('(엑스퍼트) 연수센터_라운지')
|
|
33
|
+
expect(resolveVenue('(엑스퍼트) 외부_카페')).toBe('(엑스퍼트) 외부_카페')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('trims whitespace from input', () => {
|
|
37
|
+
expect(resolveVenue(' 강남역토즈타워점 ')).toBe('토즈-강남역토즈타워점')
|
|
38
|
+
expect(resolveVenue(' 스페이스 A1 ')).toBe('스페이스 A1')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('passes through unknown venues unchanged', () => {
|
|
42
|
+
expect(resolveVenue('기타 장소')).toBe('기타 장소')
|
|
43
|
+
})
|
|
44
|
+
})
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import { parse } from 'node-html-parser'
|
|
2
2
|
|
|
3
|
-
import { MENU_NO, REPORT_CD, ROOM_IDS, TIME_SLOTS } from '../../constants'
|
|
3
|
+
import { MENU_NO, REPORT_CD, ROOM_IDS, TIME_SLOTS, VENUE_ALIASES } from '../../constants'
|
|
4
4
|
import { type ApplicationHistoryItem, ApplicationHistoryItemSchema } from '../../types'
|
|
5
|
+
import { decodeHtmlEntities, escapeHtml } from './html'
|
|
5
6
|
|
|
6
7
|
export function toReportCd(type: 'public' | 'lecture'): string {
|
|
7
8
|
return type === 'lecture' ? REPORT_CD.MENTOR_LECTURE : REPORT_CD.PUBLIC_MENTORING
|
|
8
9
|
}
|
|
9
10
|
|
|
11
|
+
export function toMentoringType(type: string): 'public' | 'lecture' {
|
|
12
|
+
if (type.includes('특강') || type === 'lecture') return 'lecture'
|
|
13
|
+
return 'public'
|
|
14
|
+
}
|
|
15
|
+
|
|
10
16
|
export function buildMentoringPayload(params: {
|
|
11
17
|
title: string
|
|
12
18
|
type: 'public' | 'lecture'
|
|
@@ -29,8 +35,8 @@ export function buildMentoringPayload(params: {
|
|
|
29
35
|
eventDt: params.date,
|
|
30
36
|
eventStime: params.startTime,
|
|
31
37
|
eventEtime: params.endTime,
|
|
32
|
-
place: params.venue,
|
|
33
|
-
qestnarCn: params.content ?? '',
|
|
38
|
+
place: resolveVenue(params.venue),
|
|
39
|
+
qestnarCn: formatEditorContent(params.content ?? ''),
|
|
34
40
|
atchFileId: '',
|
|
35
41
|
fileFieldNm_1: '',
|
|
36
42
|
stateCd: 'QST020',
|
|
@@ -76,6 +82,11 @@ export function buildCancelApplicationPayload(params: { applySn: number; qustnrS
|
|
|
76
82
|
}
|
|
77
83
|
}
|
|
78
84
|
|
|
85
|
+
export function resolveVenue(venue: string): string {
|
|
86
|
+
const trimmed = venue.trim()
|
|
87
|
+
return VENUE_ALIASES[trimmed] ?? trimmed
|
|
88
|
+
}
|
|
89
|
+
|
|
79
90
|
export function resolveRoomId(room: string | number): number {
|
|
80
91
|
if (typeof room === 'number') {
|
|
81
92
|
return room
|
|
@@ -190,7 +201,7 @@ export function parseEventDetail(html: string): Record<string, unknown> {
|
|
|
190
201
|
return {
|
|
191
202
|
id: extractNumber(labels.NO ?? labels.번호 ?? root.querySelector('[name="bbsId"]')?.getAttribute('value') ?? '0'),
|
|
192
203
|
title: labels.제목 ?? cleanText(root.querySelector('h1, h2, .title')?.text),
|
|
193
|
-
content: contentNode?.innerHTML.trim() ?? '',
|
|
204
|
+
content: decodeHtmlEntities(contentNode?.innerHTML.trim() ?? ''),
|
|
194
205
|
fields: labels,
|
|
195
206
|
}
|
|
196
207
|
}
|
|
@@ -216,6 +227,20 @@ function extractLabelMap(root: ReturnType<typeof parse>): Record<string, string>
|
|
|
216
227
|
return map
|
|
217
228
|
}
|
|
218
229
|
|
|
230
|
+
const EDITOR_P_STYLE = 'font-family: 굴림; font-size: 12pt; line-height: 1.2; margin-top: 0px; margin-bottom: 0px;'
|
|
231
|
+
|
|
232
|
+
function formatEditorContent(content: string): string {
|
|
233
|
+
if (!content) return ''
|
|
234
|
+
const decoded = decodeHtmlEntities(content)
|
|
235
|
+
if (/<(?:p|div|h[1-6]|ul|ol|table|br)\b/i.test(decoded)) {
|
|
236
|
+
return decoded
|
|
237
|
+
}
|
|
238
|
+
return decoded
|
|
239
|
+
.split(/\n/)
|
|
240
|
+
.map((line) => `<p style="${EDITOR_P_STYLE}">${escapeHtml(line) || ' '}</p>`)
|
|
241
|
+
.join('')
|
|
242
|
+
}
|
|
243
|
+
|
|
219
244
|
function cleanText(value: string | null | undefined): string {
|
|
220
245
|
return (value ?? '').replace(/\s+/g, ' ').replace(/\[|\]/g, '').trim()
|
|
221
246
|
}
|
|
@@ -271,7 +296,7 @@ export function buildReportPayload(options: {
|
|
|
271
296
|
reportGubunCd: reportType,
|
|
272
297
|
progressDt: progressDate,
|
|
273
298
|
teamNms: options.teamNames ?? '',
|
|
274
|
-
progressPlace: options.venue,
|
|
299
|
+
progressPlace: resolveVenue(options.venue),
|
|
275
300
|
attendanceCnt: String(options.attendanceCount),
|
|
276
301
|
attendanceNms: options.attendanceNames,
|
|
277
302
|
progressStime: options.progressStartTime,
|