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.
Files changed (89) hide show
  1. package/dist/package.json +1 -1
  2. package/dist/src/client.d.ts +3 -3
  3. package/dist/src/client.d.ts.map +1 -1
  4. package/dist/src/client.js +37 -5
  5. package/dist/src/client.js.map +1 -1
  6. package/dist/src/commands/auth.d.ts +1 -1
  7. package/dist/src/commands/auth.d.ts.map +1 -1
  8. package/dist/src/commands/auth.js +94 -52
  9. package/dist/src/commands/auth.js.map +1 -1
  10. package/dist/src/commands/mentoring.d.ts.map +1 -1
  11. package/dist/src/commands/mentoring.js +26 -20
  12. package/dist/src/commands/mentoring.js.map +1 -1
  13. package/dist/src/commands/room.d.ts.map +1 -1
  14. package/dist/src/commands/room.js +25 -2
  15. package/dist/src/commands/room.js.map +1 -1
  16. package/dist/src/constants.d.ts +52 -9
  17. package/dist/src/constants.d.ts.map +1 -1
  18. package/dist/src/constants.js +65 -9
  19. package/dist/src/constants.js.map +1 -1
  20. package/dist/src/formatters.d.ts.map +1 -1
  21. package/dist/src/formatters.js +79 -39
  22. package/dist/src/formatters.js.map +1 -1
  23. package/dist/src/http.d.ts +3 -0
  24. package/dist/src/http.d.ts.map +1 -1
  25. package/dist/src/http.js +42 -1
  26. package/dist/src/http.js.map +1 -1
  27. package/dist/src/index.d.ts +3 -0
  28. package/dist/src/index.d.ts.map +1 -1
  29. package/dist/src/index.js +2 -0
  30. package/dist/src/index.js.map +1 -1
  31. package/dist/src/shared/utils/html.d.ts +3 -0
  32. package/dist/src/shared/utils/html.d.ts.map +1 -0
  33. package/dist/src/shared/utils/html.js +12 -0
  34. package/dist/src/shared/utils/html.js.map +1 -0
  35. package/dist/src/shared/utils/swmaestro.d.ts +2 -0
  36. package/dist/src/shared/utils/swmaestro.d.ts.map +1 -1
  37. package/dist/src/shared/utils/swmaestro.js +28 -5
  38. package/dist/src/shared/utils/swmaestro.js.map +1 -1
  39. package/dist/src/shared/utils/toz.d.ts +23 -0
  40. package/dist/src/shared/utils/toz.d.ts.map +1 -0
  41. package/dist/src/shared/utils/toz.js +72 -0
  42. package/dist/src/shared/utils/toz.js.map +1 -0
  43. package/dist/src/token-extractor.d.ts +9 -1
  44. package/dist/src/token-extractor.d.ts.map +1 -1
  45. package/dist/src/token-extractor.js +54 -10
  46. package/dist/src/token-extractor.js.map +1 -1
  47. package/dist/src/toz-formatters.d.ts +9 -0
  48. package/dist/src/toz-formatters.d.ts.map +1 -0
  49. package/dist/src/toz-formatters.js +151 -0
  50. package/dist/src/toz-formatters.js.map +1 -0
  51. package/dist/src/toz-http.d.ts +27 -0
  52. package/dist/src/toz-http.d.ts.map +1 -0
  53. package/dist/src/toz-http.js +154 -0
  54. package/dist/src/toz-http.js.map +1 -0
  55. package/dist/src/types.d.ts +88 -0
  56. package/dist/src/types.d.ts.map +1 -1
  57. package/dist/src/types.js +65 -1
  58. package/dist/src/types.js.map +1 -1
  59. package/package.json +1 -1
  60. package/src/__fixtures__/toz/toz_all_branches.json +211 -0
  61. package/src/__fixtures__/toz/toz_booking.html +2190 -0
  62. package/src/__fixtures__/toz/toz_boothes.json +59 -0
  63. package/src/__fixtures__/toz/toz_duration.json +25 -0
  64. package/src/__fixtures__/toz/toz_mypage_response.html +388 -0
  65. package/src/__fixtures__/toz/toz_page.html +211 -0
  66. package/src/client.test.ts +63 -16
  67. package/src/client.ts +43 -6
  68. package/src/commands/auth.ts +107 -50
  69. package/src/commands/mentoring.ts +34 -26
  70. package/src/commands/room.ts +31 -3
  71. package/src/constants.ts +74 -9
  72. package/src/formatters.test.ts +6 -2
  73. package/src/formatters.ts +92 -45
  74. package/src/http.test.ts +215 -0
  75. package/src/http.ts +45 -1
  76. package/src/index.ts +3 -0
  77. package/src/shared/utils/html.ts +12 -0
  78. package/src/shared/utils/swmaestro.test.ts +44 -0
  79. package/src/shared/utils/swmaestro.ts +30 -5
  80. package/src/shared/utils/toz.test.ts +133 -0
  81. package/src/shared/utils/toz.ts +100 -0
  82. package/src/token-extractor.test.ts +30 -5
  83. package/src/token-extractor.ts +65 -13
  84. package/src/toz-formatters.test.ts +197 -0
  85. package/src/toz-formatters.ts +211 -0
  86. package/src/toz-http.test.ts +157 -0
  87. package/src/toz-http.ts +188 -0
  88. package/src/types.test.ts +4 -1
  89. 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
- ApprovalListItemSchema,
6
+ ApplicationHistoryItemSchema,
7
7
  type ApprovalListItem,
8
- DashboardSchema,
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 progressTimes = progressTimeText.split(/\s*~\s*/)
319
- const exceptTimes = exceptTimeText.split(/\s*~\s*/)
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: labels['주제'] || '',
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: progressTimes[0] || '',
340
- progressEndTime: progressTimes[1] || '',
341
- exceptStartTime: exceptTimes[0] || '',
342
- exceptEndTime: exceptTimes[1] || '',
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
- .map((slot) => ({
469
- time: cleanText(slot),
470
- available:
471
- !(slot.getAttribute('class') ?? '').includes('not-reserve') &&
472
- !(slot.getAttribute('class') ?? '').includes('booked') &&
473
- !(slot.getAttribute('class') ?? '').includes('disabled'),
474
- }))
475
- .filter((slot) => Boolean(slot.time))
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
- return (await response.json()) as T
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(/&lt;/g, '<')
4
+ .replace(/&gt;/g, '>')
5
+ .replace(/&quot;/g, '"')
6
+ .replace(/&#39;/g, "'")
7
+ .replace(/&amp;/g, '&') // must be last to avoid double-decoding
8
+ }
9
+
10
+ export function escapeHtml(text: string): string {
11
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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) || '&nbsp;'}</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,