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.
- package/dist/package.json +5 -1
- package/dist/src/agent-browser-launcher.d.ts +43 -0
- package/dist/src/agent-browser-launcher.d.ts.map +1 -0
- package/dist/src/agent-browser-launcher.js +97 -0
- package/dist/src/agent-browser-launcher.js.map +1 -0
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +3 -2
- package/dist/src/cli.js.map +1 -1
- package/dist/src/client.d.ts +30 -7
- package/dist/src/client.d.ts.map +1 -1
- package/dist/src/client.js +218 -52
- package/dist/src/client.js.map +1 -1
- package/dist/src/commands/agent-browser.d.ts +3 -0
- package/dist/src/commands/agent-browser.d.ts.map +1 -0
- package/dist/src/commands/agent-browser.js +27 -0
- package/dist/src/commands/agent-browser.js.map +1 -0
- 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 +4 -2
- package/dist/src/commands/auth.js.map +1 -1
- package/dist/src/commands/dashboard.d.ts +13 -0
- package/dist/src/commands/dashboard.d.ts.map +1 -1
- package/dist/src/commands/dashboard.js +10 -18
- package/dist/src/commands/dashboard.js.map +1 -1
- package/dist/src/commands/helpers.d.ts +1 -1
- package/dist/src/commands/helpers.d.ts.map +1 -1
- package/dist/src/commands/helpers.js +2 -2
- package/dist/src/commands/helpers.js.map +1 -1
- package/dist/src/commands/index.d.ts +2 -1
- package/dist/src/commands/index.d.ts.map +1 -1
- package/dist/src/commands/index.js +2 -1
- package/dist/src/commands/index.js.map +1 -1
- package/dist/src/commands/mentoring.d.ts.map +1 -1
- package/dist/src/commands/mentoring.js +54 -29
- package/dist/src/commands/mentoring.js.map +1 -1
- package/dist/src/commands/notice.d.ts.map +1 -1
- package/dist/src/commands/notice.js +2 -1
- package/dist/src/commands/notice.js.map +1 -1
- package/dist/src/commands/report.d.ts.map +1 -1
- package/dist/src/commands/report.js +4 -2
- package/dist/src/commands/report.js.map +1 -1
- package/dist/src/commands/room.d.ts.map +1 -1
- package/dist/src/commands/room.js +125 -2
- package/dist/src/commands/room.js.map +1 -1
- package/dist/src/commands/schedule.d.ts +3 -0
- package/dist/src/commands/schedule.d.ts.map +1 -0
- package/dist/src/commands/schedule.js +27 -0
- package/dist/src/commands/schedule.js.map +1 -0
- package/dist/src/commands/team.d.ts.map +1 -1
- package/dist/src/commands/team.js +55 -4
- package/dist/src/commands/team.js.map +1 -1
- package/dist/src/constants.d.ts +5 -5
- package/dist/src/constants.d.ts.map +1 -1
- package/dist/src/constants.js +20 -8
- package/dist/src/constants.js.map +1 -1
- package/dist/src/credential-manager.d.ts +9 -0
- package/dist/src/credential-manager.d.ts.map +1 -1
- package/dist/src/credential-manager.js +24 -0
- package/dist/src/credential-manager.js.map +1 -1
- package/dist/src/formatters.d.ts +11 -3
- package/dist/src/formatters.d.ts.map +1 -1
- package/dist/src/formatters.js +281 -52
- package/dist/src/formatters.js.map +1 -1
- package/dist/src/http.d.ts +8 -0
- package/dist/src/http.d.ts.map +1 -1
- package/dist/src/http.js +29 -1
- package/dist/src/http.js.map +1 -1
- package/dist/src/index.d.ts +4 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/shared/utils/swmaestro.d.ts +34 -1
- package/dist/src/shared/utils/swmaestro.d.ts.map +1 -1
- package/dist/src/shared/utils/swmaestro.js +102 -39
- package/dist/src/shared/utils/swmaestro.js.map +1 -1
- package/dist/src/shared/utils/team-action-params.d.ts +3 -0
- package/dist/src/shared/utils/team-action-params.d.ts.map +1 -0
- package/dist/src/shared/utils/team-action-params.js +10 -0
- package/dist/src/shared/utils/team-action-params.js.map +1 -0
- package/dist/src/shared/utils/team-params.d.ts +12 -0
- package/dist/src/shared/utils/team-params.d.ts.map +1 -0
- package/dist/src/shared/utils/team-params.js +38 -0
- package/dist/src/shared/utils/team-params.js.map +1 -0
- package/dist/src/types.d.ts +147 -10
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js +74 -6
- package/dist/src/types.js.map +1 -1
- package/package.json +5 -1
- package/src/agent-browser-launcher.test.ts +263 -0
- package/src/agent-browser-launcher.ts +159 -0
- package/src/cli.ts +4 -2
- package/src/client.test.ts +673 -30
- package/src/client.ts +277 -67
- package/src/commands/agent-browser.ts +33 -0
- package/src/commands/auth.test.ts +77 -26
- package/src/commands/auth.ts +5 -3
- package/src/commands/dashboard.test.ts +57 -0
- package/src/commands/dashboard.ts +22 -19
- package/src/commands/helpers.test.ts +72 -25
- package/src/commands/helpers.ts +3 -3
- package/src/commands/index.ts +2 -1
- package/src/commands/mentoring.ts +60 -29
- package/src/commands/notice.ts +2 -1
- package/src/commands/report.ts +4 -2
- package/src/commands/room.ts +160 -1
- package/src/commands/schedule.ts +32 -0
- package/src/commands/team.ts +73 -5
- package/src/constants.ts +20 -8
- package/src/credential-manager.test.ts +44 -0
- package/src/credential-manager.ts +27 -0
- package/src/formatters.test.ts +528 -33
- package/src/formatters.ts +309 -55
- package/src/http.test.ts +71 -2
- package/src/http.ts +41 -2
- package/src/index.ts +10 -1
- package/src/shared/utils/swmaestro.test.ts +245 -9
- package/src/shared/utils/swmaestro.ts +150 -47
- package/src/shared/utils/team-action-params.test.ts +32 -0
- package/src/shared/utils/team-action-params.ts +10 -0
- package/src/shared/utils/team-params.test.ts +141 -0
- package/src/shared/utils/team-params.ts +53 -0
- package/src/types.test.ts +26 -13
- package/src/types.ts +87 -7
- package/dist/src/commands/event.d.ts +0 -3
- package/dist/src/commands/event.d.ts.map +0 -1
- package/dist/src/commands/event.js +0 -58
- package/dist/src/commands/event.js.map +0 -1
- 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
|
|
366
|
+
const summary = parseTeamSummary(summaryText)
|
|
202
367
|
|
|
203
368
|
return TeamInfoSchema.parse({
|
|
204
|
-
teams: cards.map((card) => (
|
|
205
|
-
|
|
206
|
-
|
|
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
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
268
|
-
totalPages:
|
|
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
|
-
|
|
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(
|
|
491
|
+
id: extractUrlParam(titleLink?.getAttribute('href'), 'reportId'),
|
|
288
492
|
category: cleanText(cells[1]),
|
|
289
|
-
title: cleanText(
|
|
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
|
|
609
|
-
const
|
|
610
|
-
|
|
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: {
|
|
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?: {
|
|
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 {
|
|
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'
|