opensoma 0.5.1 → 0.6.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 (128) hide show
  1. package/dist/package.json +5 -1
  2. package/dist/src/agent-browser-launcher.d.ts +43 -0
  3. package/dist/src/agent-browser-launcher.d.ts.map +1 -0
  4. package/dist/src/agent-browser-launcher.js +97 -0
  5. package/dist/src/agent-browser-launcher.js.map +1 -0
  6. package/dist/src/cli.d.ts.map +1 -1
  7. package/dist/src/cli.js +3 -2
  8. package/dist/src/cli.js.map +1 -1
  9. package/dist/src/client.d.ts +30 -7
  10. package/dist/src/client.d.ts.map +1 -1
  11. package/dist/src/client.js +218 -52
  12. package/dist/src/client.js.map +1 -1
  13. package/dist/src/commands/agent-browser.d.ts +3 -0
  14. package/dist/src/commands/agent-browser.d.ts.map +1 -0
  15. package/dist/src/commands/agent-browser.js +27 -0
  16. package/dist/src/commands/agent-browser.js.map +1 -0
  17. package/dist/src/commands/auth.d.ts +1 -1
  18. package/dist/src/commands/auth.d.ts.map +1 -1
  19. package/dist/src/commands/auth.js +4 -2
  20. package/dist/src/commands/auth.js.map +1 -1
  21. package/dist/src/commands/dashboard.d.ts +13 -0
  22. package/dist/src/commands/dashboard.d.ts.map +1 -1
  23. package/dist/src/commands/dashboard.js +10 -18
  24. package/dist/src/commands/dashboard.js.map +1 -1
  25. package/dist/src/commands/helpers.d.ts +1 -1
  26. package/dist/src/commands/helpers.d.ts.map +1 -1
  27. package/dist/src/commands/helpers.js +2 -2
  28. package/dist/src/commands/helpers.js.map +1 -1
  29. package/dist/src/commands/index.d.ts +2 -1
  30. package/dist/src/commands/index.d.ts.map +1 -1
  31. package/dist/src/commands/index.js +2 -1
  32. package/dist/src/commands/index.js.map +1 -1
  33. package/dist/src/commands/mentoring.d.ts.map +1 -1
  34. package/dist/src/commands/mentoring.js +54 -29
  35. package/dist/src/commands/mentoring.js.map +1 -1
  36. package/dist/src/commands/notice.d.ts.map +1 -1
  37. package/dist/src/commands/notice.js +2 -1
  38. package/dist/src/commands/notice.js.map +1 -1
  39. package/dist/src/commands/report.d.ts.map +1 -1
  40. package/dist/src/commands/report.js +4 -2
  41. package/dist/src/commands/report.js.map +1 -1
  42. package/dist/src/commands/room.d.ts.map +1 -1
  43. package/dist/src/commands/room.js +125 -2
  44. package/dist/src/commands/room.js.map +1 -1
  45. package/dist/src/commands/schedule.d.ts +3 -0
  46. package/dist/src/commands/schedule.d.ts.map +1 -0
  47. package/dist/src/commands/schedule.js +27 -0
  48. package/dist/src/commands/schedule.js.map +1 -0
  49. package/dist/src/commands/team.d.ts.map +1 -1
  50. package/dist/src/commands/team.js +55 -4
  51. package/dist/src/commands/team.js.map +1 -1
  52. package/dist/src/constants.d.ts +5 -5
  53. package/dist/src/constants.d.ts.map +1 -1
  54. package/dist/src/constants.js +20 -8
  55. package/dist/src/constants.js.map +1 -1
  56. package/dist/src/credential-manager.d.ts +9 -0
  57. package/dist/src/credential-manager.d.ts.map +1 -1
  58. package/dist/src/credential-manager.js +24 -0
  59. package/dist/src/credential-manager.js.map +1 -1
  60. package/dist/src/formatters.d.ts +11 -3
  61. package/dist/src/formatters.d.ts.map +1 -1
  62. package/dist/src/formatters.js +281 -52
  63. package/dist/src/formatters.js.map +1 -1
  64. package/dist/src/http.d.ts +8 -0
  65. package/dist/src/http.d.ts.map +1 -1
  66. package/dist/src/http.js +29 -1
  67. package/dist/src/http.js.map +1 -1
  68. package/dist/src/index.d.ts +4 -1
  69. package/dist/src/index.d.ts.map +1 -1
  70. package/dist/src/index.js +2 -1
  71. package/dist/src/index.js.map +1 -1
  72. package/dist/src/shared/utils/swmaestro.d.ts +34 -1
  73. package/dist/src/shared/utils/swmaestro.d.ts.map +1 -1
  74. package/dist/src/shared/utils/swmaestro.js +102 -39
  75. package/dist/src/shared/utils/swmaestro.js.map +1 -1
  76. package/dist/src/shared/utils/team-action-params.d.ts +3 -0
  77. package/dist/src/shared/utils/team-action-params.d.ts.map +1 -0
  78. package/dist/src/shared/utils/team-action-params.js +10 -0
  79. package/dist/src/shared/utils/team-action-params.js.map +1 -0
  80. package/dist/src/shared/utils/team-params.d.ts +12 -0
  81. package/dist/src/shared/utils/team-params.d.ts.map +1 -0
  82. package/dist/src/shared/utils/team-params.js +38 -0
  83. package/dist/src/shared/utils/team-params.js.map +1 -0
  84. package/dist/src/types.d.ts +147 -10
  85. package/dist/src/types.d.ts.map +1 -1
  86. package/dist/src/types.js +74 -6
  87. package/dist/src/types.js.map +1 -1
  88. package/package.json +5 -1
  89. package/src/agent-browser-launcher.test.ts +263 -0
  90. package/src/agent-browser-launcher.ts +159 -0
  91. package/src/cli.ts +4 -2
  92. package/src/client.test.ts +673 -30
  93. package/src/client.ts +277 -67
  94. package/src/commands/agent-browser.ts +33 -0
  95. package/src/commands/auth.test.ts +77 -26
  96. package/src/commands/auth.ts +5 -3
  97. package/src/commands/dashboard.test.ts +57 -0
  98. package/src/commands/dashboard.ts +22 -19
  99. package/src/commands/helpers.test.ts +72 -25
  100. package/src/commands/helpers.ts +3 -3
  101. package/src/commands/index.ts +2 -1
  102. package/src/commands/mentoring.ts +60 -29
  103. package/src/commands/notice.ts +2 -1
  104. package/src/commands/report.ts +4 -2
  105. package/src/commands/room.ts +160 -1
  106. package/src/commands/schedule.ts +32 -0
  107. package/src/commands/team.ts +73 -5
  108. package/src/constants.ts +20 -8
  109. package/src/credential-manager.test.ts +44 -0
  110. package/src/credential-manager.ts +27 -0
  111. package/src/formatters.test.ts +528 -33
  112. package/src/formatters.ts +309 -55
  113. package/src/http.test.ts +71 -2
  114. package/src/http.ts +41 -2
  115. package/src/index.ts +10 -1
  116. package/src/shared/utils/swmaestro.test.ts +245 -9
  117. package/src/shared/utils/swmaestro.ts +150 -47
  118. package/src/shared/utils/team-action-params.test.ts +32 -0
  119. package/src/shared/utils/team-action-params.ts +10 -0
  120. package/src/shared/utils/team-params.test.ts +141 -0
  121. package/src/shared/utils/team-params.ts +53 -0
  122. package/src/types.test.ts +26 -13
  123. package/src/types.ts +87 -7
  124. package/dist/src/commands/event.d.ts +0 -3
  125. package/dist/src/commands/event.d.ts.map +0 -1
  126. package/dist/src/commands/event.js +0 -58
  127. package/dist/src/commands/event.js.map +0 -1
  128. package/src/commands/event.ts +0 -73
package/src/formatters.ts CHANGED
@@ -8,12 +8,12 @@ import {
8
8
  ApprovalListItemSchema,
9
9
  type Dashboard,
10
10
  DashboardSchema,
11
- type EventListItem,
12
- EventListItemSchema,
13
11
  type MemberInfo,
14
12
  MemberInfoSchema,
15
13
  type MentoringDetail,
16
14
  MentoringDetailSchema,
15
+ type MentoringEditForm,
16
+ MentoringEditFormSchema,
17
17
  type MentoringListItem,
18
18
  MentoringListItemSchema,
19
19
  type NoticeDetail,
@@ -28,6 +28,13 @@ import {
28
28
  ReportListItemSchema,
29
29
  type RoomCard,
30
30
  RoomCardSchema,
31
+ type RoomReservationDetail,
32
+ RoomReservationDetailSchema,
33
+ type RoomReservationListItem,
34
+ RoomReservationListItemSchema,
35
+ type RoomReservationStatus,
36
+ type ScheduleListItem,
37
+ ScheduleListItemSchema,
31
38
  type TeamInfo,
32
39
  TeamInfoSchema,
33
40
  } from './types'
@@ -105,6 +112,83 @@ export function parseMentoringDetail(html: string, id = 0): MentoringDetail {
105
112
  })
106
113
  }
107
114
 
115
+ export function parseMentoringEditForm(html: string, id = 0): MentoringEditForm {
116
+ const root = parse(html)
117
+ const form = root.querySelector('form#board') ?? root.querySelector('form')
118
+ const fields = readFormFields(form)
119
+ const initial = parseInitialDataBlock(html)
120
+
121
+ const pick = (key: string): string => {
122
+ const formValue = fields[key]
123
+ if (formValue && formValue.trim() !== '') return formValue
124
+ return initial[key] ?? ''
125
+ }
126
+ const receiptType = pick('receiptType') === 'DIRECT' ? 'DIRECT' : 'UNTIL_LECTURE'
127
+
128
+ return MentoringEditFormSchema.parse({
129
+ id: id || extractNumber(fields.qustnrSn ?? ''),
130
+ title: fields.qustnrSj ?? '',
131
+ reportCd: pick('reportCd'),
132
+ receiptType,
133
+ bgndeDate: pick('bgndeDate'),
134
+ bgndeTime: pick('bgndeTime'),
135
+ enddeDate: pick('enddeDate'),
136
+ enddeTime: pick('enddeTime'),
137
+ eventDt: pick('eventDt'),
138
+ eventStime: pick('eventStime'),
139
+ eventEtime: pick('eventEtime'),
140
+ applyCnt: extractNumber(fields.applyCnt ?? ''),
141
+ place: fields.place ?? '',
142
+ })
143
+ }
144
+
145
+ function parseInitialDataBlock(html: string): Record<string, string> {
146
+ const block = html.match(/var\s+INITIAL_DATA\s*=\s*\{([\s\S]*?)\};/)?.[1]
147
+ if (!block) return {}
148
+ const fields: Record<string, string> = {}
149
+ for (const match of block.matchAll(/(\w+)\s*:\s*'([^']*)'/g)) {
150
+ fields[match[1]] = match[2]
151
+ }
152
+ return fields
153
+ }
154
+
155
+ function readFormFields(form: HTMLElement | null): Record<string, string> {
156
+ if (!form) return {}
157
+ const fields: Record<string, string> = {}
158
+
159
+ for (const input of form.querySelectorAll('input')) {
160
+ const name = input.getAttribute('name')
161
+ if (!name) continue
162
+ const type = (input.getAttribute('type') ?? '').toLowerCase()
163
+ if (type === 'radio' || type === 'checkbox') {
164
+ const rawAttrs = input.rawAttrs ?? ''
165
+ const isChecked = /\bchecked\b/.test(rawAttrs)
166
+ if (isChecked) {
167
+ fields[name] = input.getAttribute('value') ?? ''
168
+ } else if (!(name in fields)) {
169
+ fields[name] = ''
170
+ }
171
+ } else {
172
+ fields[name] = input.getAttribute('value') ?? ''
173
+ }
174
+ }
175
+
176
+ for (const select of form.querySelectorAll('select')) {
177
+ const name = select.getAttribute('name')
178
+ if (!name) continue
179
+ const selected = select.querySelector('option[selected]')
180
+ fields[name] = selected?.getAttribute('value') ?? ''
181
+ }
182
+
183
+ for (const textarea of form.querySelectorAll('textarea')) {
184
+ const name = textarea.getAttribute('name')
185
+ if (!name) continue
186
+ fields[name] = textarea.text ?? ''
187
+ }
188
+
189
+ return fields
190
+ }
191
+
108
192
  export function parseRoomList(html: string): RoomCard[] {
109
193
  const root = parse(html)
110
194
  const cards = root.querySelectorAll('ul.bbs-reserve > li.item')
@@ -135,6 +219,86 @@ export function parseRoomSlots(html: string): RoomCard['timeSlots'] {
135
219
  return slots.map(parseRoomTimeSlot).filter((slot) => Boolean(slot.time))
136
220
  }
137
221
 
222
+ export function parseRoomReservationDetail(html: string): RoomReservationDetail {
223
+ const root = parse(html)
224
+ const form = root.querySelector('form#frm') ?? root.querySelector('form')
225
+ const fields: Record<string, string> = {}
226
+ for (const input of form?.querySelectorAll('input, select, textarea') ?? []) {
227
+ const name = input.getAttribute('name')
228
+ if (!name) continue
229
+ fields[name] = input.getAttribute('value') ?? input.text ?? ''
230
+ }
231
+
232
+ const statusCode = fields.receiptStatCd ?? ''
233
+
234
+ return RoomReservationDetailSchema.parse({
235
+ rentId: extractNumber(fields.rentId ?? ''),
236
+ itemId: extractNumber(fields.itemId ?? ''),
237
+ title: fields.title ?? '',
238
+ date: fields.rentDt ?? extractReservationDate(fields.rentBgnde),
239
+ startTime: extractReservationTime(fields.rentBgnde),
240
+ endTime: extractReservationTime(fields.rentEndde),
241
+ attendees: extractNumber(fields.rentNum ?? '') || 1,
242
+ notes: fields.infoCn ?? '',
243
+ status: resolveReservationStatus(statusCode),
244
+ statusCode,
245
+ })
246
+ }
247
+
248
+ function extractReservationDate(value: string | undefined): string {
249
+ if (!value) return ''
250
+ return value.slice(0, 10)
251
+ }
252
+
253
+ function extractReservationTime(value: string | undefined): string {
254
+ if (!value) return ''
255
+ const match = value.match(/\d{2}:\d{2}/)
256
+ return match?.[0] ?? ''
257
+ }
258
+
259
+ function resolveReservationStatus(code: string): RoomReservationStatus {
260
+ if (code === 'RS001') return 'confirmed'
261
+ if (code === 'RS002') return 'cancelled'
262
+ return 'unknown'
263
+ }
264
+
265
+ export function parseRoomReservationList(html: string): RoomReservationListItem[] {
266
+ return findTableRows(html, 7).map((cells) => {
267
+ const venueLink = cells[1]?.querySelector('a')
268
+ const rentId = extractUrlParam(venueLink?.getAttribute('href'), 'rentId')
269
+ const titleLink = cells[2]?.querySelector('.rel a')
270
+ const periodText = cleanText(cells[3])
271
+ const statusLabel = cleanText(cells[5])
272
+
273
+ return RoomReservationListItemSchema.parse({
274
+ rentId,
275
+ venue: cleanText(venueLink ?? cells[1]),
276
+ title: cleanText(titleLink),
277
+ date: extractFirstDate(periodText),
278
+ startTime: extractReservationStartTime(periodText),
279
+ endTime: extractReservationEndTime(periodText),
280
+ author: cleanText(cells[4]),
281
+ status: statusLabelToStatus(statusLabel),
282
+ statusLabel,
283
+ registeredAt: cleanText(cells[6]),
284
+ })
285
+ })
286
+ }
287
+
288
+ function statusLabelToStatus(label: string): RoomReservationStatus {
289
+ if (label.includes('예약완료')) return 'confirmed'
290
+ if (label.includes('예약취소') || label.includes('취소')) return 'cancelled'
291
+ return 'unknown'
292
+ }
293
+
294
+ function extractReservationStartTime(text: string): string {
295
+ return text.match(/\d{2}:\d{2}/)?.[0] ?? ''
296
+ }
297
+
298
+ function extractReservationEndTime(text: string): string {
299
+ return text.match(/\d{2}:\d{2}/g)?.[1] ?? ''
300
+ }
301
+
138
302
  export function parseDashboard(html: string): Dashboard {
139
303
  const root = parse(html)
140
304
  const profileCard = root.querySelector('ul.dash-top > li.dash-card')
@@ -153,6 +317,7 @@ export function parseDashboard(html: string): Dashboard {
153
317
  organization: extractDashEtcValue(dashEtc, '소속'),
154
318
  position: extractDashEtcValue(dashEtc, '직책'),
155
319
  team: team.name || team.members || team.mentor ? team : undefined,
320
+ teams: [],
156
321
  mentoringSessions: parseDashboardLinks(root, (href) => href.includes('/mentoLec/')),
157
322
  roomReservations: parseDashboardLinks(root, (href) => href.includes('/itemRent/') || href.includes('/officeMng/')),
158
323
  })
@@ -198,16 +363,12 @@ export function parseTeamInfo(html: string): TeamInfo {
198
363
  const root = parse(html)
199
364
  const cards = root.querySelectorAll('ul.bbs-team > li')
200
365
  const summaryText = cleanText(root.querySelector('p.ico-team'))
201
- const summaryNumbers = summaryText.match(/(\d+)\/(\d+)팀/)
366
+ const summary = parseTeamSummary(summaryText)
202
367
 
203
368
  return TeamInfoSchema.parse({
204
- teams: cards.map((card) => ({
205
- name: cleanText(card.querySelector('.top strong.t a')),
206
- memberCount: extractMemberCount(cleanText(card)),
207
- joinStatus: extractJoinStatus(card),
208
- })),
209
- currentTeams: summaryNumbers ? Number.parseInt(summaryNumbers[1], 10) : 0,
210
- maxTeams: summaryNumbers ? Number.parseInt(summaryNumbers[2], 10) : 0,
369
+ teams: cards.map((card) => parseTeamCard(card)),
370
+ currentTeams: summary.current,
371
+ maxTeams: summary.max,
211
372
  })
212
373
  }
213
374
 
@@ -225,26 +386,42 @@ export function parseMemberInfo(html: string): MemberInfo {
225
386
  })
226
387
  }
227
388
 
228
- export function parseEventList(html: string): EventListItem[] {
229
- return findTableRows(html, 7).map((cells) =>
230
- EventListItemSchema.parse({
231
- id: extractLinkParam(cells[2], ['qustnrSn', 'bbsId', 'nttId']) || extractNumber(cleanText(cells[0])),
232
- category: cleanText(cells[1]),
233
- title: cleanText(cells[2]?.querySelector('a') ?? cells[2]),
234
- registrationPeriod: extractDateRange(cleanText(cells[3])),
235
- eventPeriod: extractDateRange(cleanText(cells[4])),
236
- status: stripWrappingBrackets(cleanText(cells[5])),
237
- createdAt: cleanText(cells[6]),
238
- }),
239
- )
389
+ export function parseScheduleList(html: string): { items: ScheduleListItem[]; pagination: Pagination } {
390
+ const root = parse(html)
391
+ const monthlyScheduleTable = findTableByHeaders(root, ['날짜', '구분', '제목'])
392
+ const scheduleRows = monthlyScheduleTable?.querySelectorAll('tbody tr') ?? []
393
+ const items = scheduleRows
394
+ .map((row) => row.querySelectorAll('td'))
395
+ .filter((cells) => cells.length === 3)
396
+ .map((cells, index) =>
397
+ ScheduleListItemSchema.parse({
398
+ id: index + 1,
399
+ category: cleanText(cells[1]),
400
+ title: cleanText(cells[2]),
401
+ period: extractDateRange(cleanText(cells[0])),
402
+ }),
403
+ )
404
+
405
+ // SWMaestro's monthly schedule page does not render the bbs-total pagination block,
406
+ // so synthesize a single-page response when items exist but parsePagination found none.
407
+ const parsedPagination = parsePagination(html, { itemCount: items.length })
408
+ const pagination =
409
+ parsedPagination.total === 0 && items.length > 0
410
+ ? PaginationSchema.parse({ total: items.length, currentPage: 1, totalPages: 1 })
411
+ : parsedPagination
412
+
413
+ return { items, pagination }
240
414
  }
241
415
 
242
416
  export function parseApplicationHistory(html: string): ApplicationHistoryItem[] {
243
- return findTableRows(html, 10).map((cells) =>
244
- ApplicationHistoryItemSchema.parse({
417
+ return findTableRows(html, 10).map((cells) => {
418
+ const link = cells[2]?.querySelector('a')
419
+ const url = link?.getAttribute('href')
420
+ return ApplicationHistoryItemSchema.parse({
245
421
  id: extractNumber(cleanText(cells[0])),
246
422
  category: cleanText(cells[1]),
247
- title: cleanText(cells[2]?.querySelector('a') ?? cells[2]),
423
+ title: cleanText(link ?? cells[2]),
424
+ ...(url ? { url } : {}),
248
425
  author: cleanText(cells[3]),
249
426
  sessionDate: normalizeDate(cleanText(cells[4])),
250
427
  appliedAt: cleanText(cells[5]),
@@ -252,23 +429,46 @@ export function parseApplicationHistory(html: string): ApplicationHistoryItem[]
252
429
  approvalStatus: stripWrappingBrackets(cleanText(cells[7])),
253
430
  applicationDetail: cleanText(cells[8]),
254
431
  note: cleanText(cells[9]),
255
- }),
256
- )
432
+ })
433
+ })
257
434
  }
258
435
 
259
- export function parsePagination(html: string): Pagination {
436
+ export function parsePagination(html: string, options?: { itemCount?: number }): Pagination {
260
437
  const root = parse(html)
261
438
  const items = root.querySelectorAll('ul.bbs-total > li').map((item) => cleanText(item))
262
439
  const total = extractNumber(items.find((item) => item.includes('Total')) ?? '')
263
440
  const pageMatch = items.find((item) => item.includes('Page'))?.match(/(\d+)\s*\/\s*(\d+)\s*Page/i)
441
+ const currentPage = pageMatch ? Number.parseInt(pageMatch[1], 10) : 1
442
+ const parsedTotalPages = pageMatch ? Number.parseInt(pageMatch[2], 10) : 1
264
443
 
265
444
  return PaginationSchema.parse({
266
445
  total,
267
- currentPage: pageMatch ? Number.parseInt(pageMatch[1], 10) : 1,
268
- totalPages: pageMatch ? Number.parseInt(pageMatch[2], 10) : 1,
446
+ currentPage,
447
+ totalPages: deriveTotalPages({ total, currentPage, parsedTotalPages, itemCount: options?.itemCount }),
269
448
  })
270
449
  }
271
450
 
451
+ function deriveTotalPages({
452
+ total,
453
+ currentPage,
454
+ parsedTotalPages,
455
+ itemCount,
456
+ }: {
457
+ total: number
458
+ currentPage: number
459
+ parsedTotalPages: number
460
+ itemCount: number | undefined
461
+ }): number {
462
+ if (total <= 0) return 1
463
+ const fallback = Math.max(1, parsedTotalPages, currentPage)
464
+ if (itemCount === undefined) return fallback
465
+ // SWMaestro's `1/50 Page` text is unreliable on some lists (e.g. report) — the server emits a
466
+ // hardcoded cap regardless of actual data. When the visible items already cover the total,
467
+ // there is only one page; correct the bogus value rather than echo it.
468
+ if (currentPage === 1 && total <= itemCount) return 1
469
+ return fallback
470
+ }
471
+
272
472
  export function parseCsrfToken(html: string): string {
273
473
  const token = parse(html).querySelector('input[name="csrfToken"]')?.getAttribute('value')
274
474
 
@@ -281,12 +481,16 @@ export function parseCsrfToken(html: string): string {
281
481
 
282
482
  export function parseReportList(html: string): ReportListItem[] {
283
483
  return findTableRows(html, 9).map((cells) => {
284
- const link = cells[2]?.querySelector('a')
484
+ // Native list cell layout: `td.tit > div.date_m > span.l > a` holds the
485
+ // category, and `td.tit > div.rel > a` holds the actual title that matches
486
+ // the `제목` label on the detail page (e.g. "[자유 멘토링] 2026년 04월 30일
487
+ // 멘토링 보고"). Prefer the `.rel a` so list and detail titles agree.
488
+ const titleLink = cells[2]?.querySelector('div.rel a') ?? cells[2]?.querySelector('a')
285
489
 
286
490
  return ReportListItemSchema.parse({
287
- id: extractUrlParam(link?.getAttribute('href'), 'reportId'),
491
+ id: extractUrlParam(titleLink?.getAttribute('href'), 'reportId'),
288
492
  category: cleanText(cells[1]),
289
- title: cleanText(link ?? cells[2]),
493
+ title: cleanText(titleLink ?? cells[2]),
290
494
  progressDate: cleanText(cells[3]),
291
495
  status: cleanText(cells[4]),
292
496
  author: cleanText(cells[5]),
@@ -399,6 +603,14 @@ function findTableRows(html: string, cellCount: number): HTMLElement[][] {
399
603
  .filter((cells) => cells.length === cellCount)
400
604
  }
401
605
 
606
+ function findTableByHeaders(root: HTMLElement, headers: string[]): HTMLElement | undefined {
607
+ return root.querySelectorAll('table').find((table) => {
608
+ const tableHeaders = table.querySelectorAll('thead th').map((header) => cleanText(header))
609
+
610
+ return headers.every((header, index) => tableHeaders[index] === header)
611
+ })
612
+ }
613
+
402
614
  function extractLabelMap(root: HTMLElement): LabelMap {
403
615
  const map: LabelMap = {}
404
616
 
@@ -560,19 +772,6 @@ function extractUrlParam(url: string | undefined, key: string): number {
560
772
  }
561
773
  }
562
774
 
563
- function extractLinkParam(cell: HTMLElement | undefined, keys: string[]): number {
564
- const href = cell?.querySelector('a')?.getAttribute('href')
565
-
566
- for (const key of keys) {
567
- const value = extractUrlParam(href, key)
568
- if (value > 0) {
569
- return value
570
- }
571
- }
572
-
573
- return 0
574
- }
575
-
576
775
  function extractDateRange(text: string): { start: string; end: string } {
577
776
  const dates = text.match(/\d{4}[.-]\d{2}[.-]\d{2}/g)?.map(normalizeDate) ?? []
578
777
  return { start: dates[0] ?? '', end: dates[1] ?? '' }
@@ -605,18 +804,73 @@ function extractCapacity(text: string): number {
605
804
  return match ? Number.parseInt(match[1], 10) : 0
606
805
  }
607
806
 
608
- function extractMemberCount(text: string): number {
609
- const match = text.match(/(\d+)\s*명/)
610
- return match ? Number.parseInt(match[1], 10) : 0
807
+ function parseTeamCard(card: HTMLElement) {
808
+ const titleAnchor = card.querySelector('.top strong.t a')
809
+ const teamPageGoArgs = parseTeamPageGoArgs(titleAnchor?.getAttribute('onclick') ?? '')
810
+ const infoItems = card.querySelectorAll('.top ul.info > li')
811
+ const ictItems = card.querySelectorAll('.bot ul.ict > li')
812
+
813
+ return {
814
+ name: cleanText(titleAnchor),
815
+ projectName: cleanText(card.querySelector('.top .add-txt')),
816
+ ownerId: teamPageGoArgs[1] ?? '',
817
+ teamId: teamPageGoArgs[2] ?? '',
818
+ leader: extractInfoLeader(infoItems),
819
+ members: extractInfoMembers(infoItems, '팀원'),
820
+ mentors: extractInfoMembers(infoItems, '멘토'),
821
+ ictCategoryMajor: extractIctCategory(ictItems, '대'),
822
+ ictCategoryMinor: extractIctCategory(ictItems, '중'),
823
+ teamCompleted: card.querySelector('.team-com .t1') !== null,
824
+ mentorCompleted: card.querySelector('.team-com .t2') !== null,
825
+ joinStatus: extractJoinStatus(card),
826
+ }
827
+ }
828
+
829
+ function parseTeamSummary(text: string): { current: number; max: number } {
830
+ const match = text.match(/(\d+)\s*(?:\/\s*(\d+))?\s*팀/)
831
+ return {
832
+ current: match ? Number.parseInt(match[1], 10) : 0,
833
+ max: match?.[2] ? Number.parseInt(match[2], 10) : 0,
834
+ }
835
+ }
836
+
837
+ function parseTeamPageGoArgs(onclick: string): string[] {
838
+ // teamPageGo('전수열','f6d192...','ef9d3b...');
839
+ const match = onclick.match(/teamPageGo\(\s*'([^']*)'\s*,\s*'([^']*)'\s*,\s*'([^']*)'\s*\)/)
840
+ return match ? [match[1], match[2], match[3]] : []
841
+ }
842
+
843
+ function findInfoItemByLabel(items: HTMLElement[], label: string): HTMLElement | undefined {
844
+ return items.find((item) => cleanText(item.querySelector('strong')).startsWith(label))
845
+ }
846
+
847
+ function extractInfoLeader(items: HTMLElement[]): string {
848
+ const leaderItem = findInfoItemByLabel(items, '팀장')
849
+ return cleanText(leaderItem?.querySelector('span a')) || cleanText(leaderItem?.querySelector('span'))
850
+ }
851
+
852
+ function extractInfoMembers(items: HTMLElement[], label: string): { name: string; userId: string }[] {
853
+ const item = findInfoItemByLabel(items, label)
854
+ if (!item) return []
855
+ return item.querySelectorAll('span a').map((anchor) => ({
856
+ name: cleanText(anchor),
857
+ userId: extractPopuserUserId(anchor.getAttribute('href') ?? ''),
858
+ }))
859
+ }
860
+
861
+ function extractPopuserUserId(href: string): string {
862
+ // javascript: popuser('113889210806408397ea7db2ea855f71')
863
+ const match = href.match(/popuser\(\s*'([^']*)'/)
864
+ return match ? match[1] : ''
865
+ }
866
+
867
+ function extractIctCategory(items: HTMLElement[], group: '대' | '중'): string {
868
+ const item = items.find((node) => cleanText(node).includes(`(${group})`))
869
+ return cleanText(item?.querySelector('span'))
611
870
  }
612
871
 
613
872
  function extractJoinStatus(card: HTMLElement): string {
614
- return (
615
- cleanText(card.querySelector('button')) ||
616
- cleanText(card.querySelector('.btn')) ||
617
- cleanText(card.querySelector('[class*="join"]')) ||
618
- ''
619
- )
873
+ return cleanText(card.querySelector('.bot .btn_w .btn-team'))
620
874
  }
621
875
 
622
876
  function extractTrailingStatus(text: string): string {
package/src/http.test.ts CHANGED
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, mock } from 'bun:test'
2
2
 
3
3
  import { MENU_NO } from './constants'
4
4
  import { AuthenticationError } from './errors'
5
- import { SomaHttp } from './http'
5
+ import { SomaHttp, UserGb } from './http'
6
6
 
7
7
  const originalFetch = globalThis.fetch
8
8
 
@@ -81,6 +81,46 @@ describe('SomaHttp', () => {
81
81
  await expect(http.post('/mypage/test.do', {})).rejects.toThrow('잘못된 접근입니다.')
82
82
  })
83
83
 
84
+ it('treats success-indicator alerts (정상적으로 등록하였습니다) as non-errors', async () => {
85
+ const fetchMock = mock(async () =>
86
+ createResponse(
87
+ `<html><body><script type='text/javascript'>alert('정상적으로 등록하였습니다.');location.href='/sw/mypage/itemRent/list.do';</script></body></html>`,
88
+ ),
89
+ )
90
+ globalThis.fetch = fetchMock as typeof fetch
91
+
92
+ const http = new SomaHttp({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
93
+
94
+ const html = await http.post('/mypage/itemRent/insert.do', {})
95
+ expect(html).toContain('정상적으로 등록하였습니다')
96
+ })
97
+
98
+ it('treats success-indicator alerts (완료되었습니다) as non-errors', async () => {
99
+ const fetchMock = mock(async () =>
100
+ createResponse(
101
+ `<html><body><script>alert('예약이 완료되었습니다.');location.href='/sw/mypage/itemRent/list.do';</script></body></html>`,
102
+ ),
103
+ )
104
+ globalThis.fetch = fetchMock as typeof fetch
105
+
106
+ const http = new SomaHttp({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
107
+
108
+ await expect(http.post('/mypage/itemRent/insert.do', {})).resolves.toBeDefined()
109
+ })
110
+
111
+ it('still surfaces non-success alerts when followed by location.href', async () => {
112
+ const fetchMock = mock(async () =>
113
+ createResponse(
114
+ `<html><body><script>alert('이미 예약된 시간입니다.');location.href='/sw/mypage/itemRent/list.do';</script></body></html>`,
115
+ ),
116
+ )
117
+ globalThis.fetch = fetchMock as typeof fetch
118
+
119
+ const http = new SomaHttp({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
120
+
121
+ await expect(http.post('/mypage/itemRent/insert.do', {})).rejects.toThrow('이미 예약된 시간입니다.')
122
+ })
123
+
84
124
  it('ignores alert() calls nested inside function bodies (form validation scripts)', async () => {
85
125
  const pageWithValidationScript = `<html><head><title>AI·SW마에스트로 서울</title></head><body>
86
126
  <ul class="bbs-reserve"><li class="item">room data</li></ul>
@@ -314,7 +354,12 @@ describe('SomaHttp', () => {
314
354
  createResponse(
315
355
  JSON.stringify({
316
356
  resultCode: 'fail',
317
- userVO: { userId: 'user@example.com', userNm: 'Test' },
357
+ userVO: {
358
+ userId: 'user@example.com',
359
+ userNm: 'Test',
360
+ userNo: 'a1b2c3',
361
+ userGb: 'T',
362
+ },
318
363
  }),
319
364
  [],
320
365
  'application/json',
@@ -325,6 +370,8 @@ describe('SomaHttp', () => {
325
370
  await expect(new SomaHttp().checkLogin()).resolves.toEqual({
326
371
  userId: 'user@example.com',
327
372
  userNm: 'Test',
373
+ userNo: 'a1b2c3',
374
+ userGb: UserGb.Mentor,
328
375
  })
329
376
  expect(loggedInMock).toHaveBeenCalledWith('https://www.swmaestro.ai/sw/member/user/checkLogin.json', {
330
377
  method: 'GET',
@@ -345,6 +392,28 @@ describe('SomaHttp', () => {
345
392
  await expect(new SomaHttp().checkLogin()).resolves.toBeNull()
346
393
  })
347
394
 
395
+ it('maps trainee userGb responses to the UserGb enum', async () => {
396
+ globalThis.fetch = mock(async () =>
397
+ createResponse(
398
+ JSON.stringify({
399
+ resultCode: 'fail',
400
+ userVO: {
401
+ userId: 'trainee@example.com',
402
+ userNm: 'Trainee',
403
+ userNo: 'u-1',
404
+ userGb: 'C',
405
+ },
406
+ }),
407
+ [],
408
+ 'application/json',
409
+ ),
410
+ ) as typeof fetch
411
+
412
+ await expect(new SomaHttp().checkLogin()).resolves.toMatchObject({
413
+ userGb: UserGb.Trainee,
414
+ })
415
+ })
416
+
348
417
  it('returns null from checkLogin when the server redirects to the login page', async () => {
349
418
  const fetchMock = mock(async () =>
350
419
  createResponse('', [], 'text/html', {
package/src/http.ts CHANGED
@@ -15,12 +15,26 @@ interface RequestOptions {
15
15
 
16
16
  interface CheckLoginResponse {
17
17
  resultCode?: string
18
- userVO?: { userId?: string; userNm?: string; userSn?: number }
18
+ userVO?: {
19
+ userId?: string
20
+ userNm?: string
21
+ userSn?: number
22
+ userNo?: string
23
+ userGb?: string
24
+ }
19
25
  }
20
26
 
21
27
  export interface UserIdentity {
22
28
  userId: string
23
29
  userNm: string
30
+ userNo: string
31
+ userGb: UserGb
32
+ }
33
+
34
+ export enum UserGb {
35
+ Unknown = '',
36
+ Trainee = 'C',
37
+ Mentor = 'T',
24
38
  }
25
39
 
26
40
  interface HeadersWithCookieHelpers extends Omit<Headers, 'getSetCookie'> {
@@ -231,6 +245,17 @@ export class SomaHttp {
231
245
  return message.includes('세션') && /초기화|만료|유효하지/.test(message)
232
246
  }
233
247
 
248
+ private isSuccessAlert(message: string): boolean {
249
+ // SWMaestro returns alert() scripts for both errors and successes (e.g. after room
250
+ // reservation the server responds with alert('정상적으로 등록하였습니다.');location.href=...).
251
+ // These success alerts must not be surfaced as errors to callers. The server emits
252
+ // variants with or without whitespace between the verb and 되었습니다 (e.g. "수정 되었습니다"
253
+ // vs "수정되었습니다"), so match both spellings.
254
+ return /정상적으로|등록\s?하였습니다|등록\s?되었습니다|수정\s?되었습니다|저장\s?되었습니다|완료\s?되었습니다|삭제\s?되었습니다/.test(
255
+ message,
256
+ )
257
+ }
258
+
234
259
  private extractErrorFromResponse(body: string, location: string | null, path?: string): string | null {
235
260
  this.log(
236
261
  'extractErrorFromResponse',
@@ -250,6 +275,10 @@ export class SomaHttp {
250
275
  if (this.isSessionExpiredError(alertMatch[1])) {
251
276
  return '__AUTH_ERROR__'
252
277
  }
278
+ if (this.isSuccessAlert(alertMatch[1])) {
279
+ this.log('Alert is a success message, ignoring:', alertMatch[1])
280
+ return null
281
+ }
253
282
  return alertMatch[1]
254
283
  }
255
284
 
@@ -408,7 +437,12 @@ export class SomaHttp {
408
437
 
409
438
  const userId = json.userVO?.userId
410
439
  if (!userId) return null
411
- return { userId, userNm: json.userVO?.userNm ?? '' }
440
+ return {
441
+ userId,
442
+ userNm: json.userVO?.userNm ?? '',
443
+ userNo: json.userVO?.userNo ?? '',
444
+ userGb: parseUserGb(json.userVO?.userGb),
445
+ }
412
446
  }
413
447
 
414
448
  async logout(): Promise<void> {
@@ -532,3 +566,8 @@ export class SomaHttp {
532
566
  }
533
567
  }
534
568
  }
569
+
570
+ function parseUserGb(value: string | undefined): UserGb {
571
+ if (value === UserGb.Trainee || value === UserGb.Mentor) return value
572
+ return UserGb.Unknown
573
+ }
package/src/index.ts CHANGED
@@ -1,7 +1,16 @@
1
+ export { AgentBrowserLauncher, assertSwmaestroUrl, buildStorageState } from './agent-browser-launcher'
2
+ export type {
3
+ AgentBrowserLauncherOptions,
4
+ AgentBrowserLaunchInput,
5
+ LaunchResult,
6
+ Spawner,
7
+ SpawnedProcess,
8
+ } from './agent-browser-launcher'
1
9
  export { SomaClient } from './client'
2
10
  export type { SomaClientOptions } from './client'
3
11
  export { AuthenticationError } from './errors'
4
- export { SomaHttp } from './http'
12
+ export { SomaHttp, UserGb } from './http'
13
+ export type { UserIdentity } from './http'
5
14
  export { CredentialManager } from './credential-manager'
6
15
  export { TozHttp } from './toz-http'
7
16
  export type { TozHttpOptions, TozHttpState } from './toz-http'