opensoma 0.5.0 → 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 (133) 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 +36 -7
  10. package/dist/src/client.d.ts.map +1 -1
  11. package/dist/src/client.js +231 -63
  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 -43
  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 +801 -140
  93. package/src/client.ts +293 -79
  94. package/src/commands/agent-browser.ts +33 -0
  95. package/src/commands/auth.test.ts +83 -32
  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 +79 -32
  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.test.ts +7 -7
  105. package/src/commands/report.ts +4 -2
  106. package/src/commands/room.ts +160 -1
  107. package/src/commands/schedule.ts +32 -0
  108. package/src/commands/team.ts +73 -5
  109. package/src/constants.ts +20 -8
  110. package/src/credential-manager.test.ts +49 -5
  111. package/src/credential-manager.ts +27 -0
  112. package/src/formatters.test.ts +548 -53
  113. package/src/formatters.ts +309 -55
  114. package/src/http.test.ts +108 -39
  115. package/src/http.ts +41 -2
  116. package/src/index.ts +10 -1
  117. package/src/shared/utils/mentoring-params.test.ts +16 -16
  118. package/src/shared/utils/swmaestro.test.ts +326 -11
  119. package/src/shared/utils/swmaestro.ts +150 -52
  120. package/src/shared/utils/team-action-params.test.ts +32 -0
  121. package/src/shared/utils/team-action-params.ts +10 -0
  122. package/src/shared/utils/team-params.test.ts +141 -0
  123. package/src/shared/utils/team-params.ts +53 -0
  124. package/src/shared/utils/toz.test.ts +12 -7
  125. package/src/token-extractor.test.ts +12 -12
  126. package/src/toz-http.test.ts +11 -11
  127. package/src/types.test.ts +235 -206
  128. package/src/types.ts +87 -7
  129. package/dist/src/commands/event.d.ts +0 -3
  130. package/dist/src/commands/event.d.ts.map +0 -1
  131. package/dist/src/commands/event.js +0 -58
  132. package/dist/src/commands/event.js.map +0 -1
  133. 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 {