opensoma 0.1.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 (146) hide show
  1. package/.claude-plugin/README.md +145 -0
  2. package/.claude-plugin/plugin.json +23 -0
  3. package/.github/workflows/release.yml +86 -0
  4. package/.oxfmtrc.json +9 -0
  5. package/.oxlintrc.json +4 -0
  6. package/AGENTS.md +78 -0
  7. package/README.md +249 -0
  8. package/bun.lock +297 -0
  9. package/bunfig.toml +2 -0
  10. package/dist/package.json +56 -0
  11. package/dist/src/cli.d.ts +5 -0
  12. package/dist/src/cli.d.ts.map +1 -0
  13. package/dist/src/cli.js +39 -0
  14. package/dist/src/cli.js.map +1 -0
  15. package/dist/src/client.d.ts +98 -0
  16. package/dist/src/client.d.ts.map +1 -0
  17. package/dist/src/client.js +141 -0
  18. package/dist/src/client.js.map +1 -0
  19. package/dist/src/commands/auth.d.ts +3 -0
  20. package/dist/src/commands/auth.d.ts.map +1 -0
  21. package/dist/src/commands/auth.js +125 -0
  22. package/dist/src/commands/auth.js.map +1 -0
  23. package/dist/src/commands/dashboard.d.ts +3 -0
  24. package/dist/src/commands/dashboard.d.ts.map +1 -0
  25. package/dist/src/commands/dashboard.js +33 -0
  26. package/dist/src/commands/dashboard.js.map +1 -0
  27. package/dist/src/commands/event.d.ts +3 -0
  28. package/dist/src/commands/event.d.ts.map +1 -0
  29. package/dist/src/commands/event.js +58 -0
  30. package/dist/src/commands/event.js.map +1 -0
  31. package/dist/src/commands/helpers.d.ts +3 -0
  32. package/dist/src/commands/helpers.d.ts.map +1 -0
  33. package/dist/src/commands/helpers.js +12 -0
  34. package/dist/src/commands/helpers.js.map +1 -0
  35. package/dist/src/commands/index.d.ts +9 -0
  36. package/dist/src/commands/index.d.ts.map +1 -0
  37. package/dist/src/commands/index.js +9 -0
  38. package/dist/src/commands/index.js.map +1 -0
  39. package/dist/src/commands/member.d.ts +3 -0
  40. package/dist/src/commands/member.d.ts.map +1 -0
  41. package/dist/src/commands/member.js +23 -0
  42. package/dist/src/commands/member.js.map +1 -0
  43. package/dist/src/commands/mentoring.d.ts +3 -0
  44. package/dist/src/commands/mentoring.d.ts.map +1 -0
  45. package/dist/src/commands/mentoring.js +154 -0
  46. package/dist/src/commands/mentoring.js.map +1 -0
  47. package/dist/src/commands/notice.d.ts +3 -0
  48. package/dist/src/commands/notice.d.ts.map +1 -0
  49. package/dist/src/commands/notice.js +42 -0
  50. package/dist/src/commands/notice.js.map +1 -0
  51. package/dist/src/commands/room.d.ts +3 -0
  52. package/dist/src/commands/room.d.ts.map +1 -0
  53. package/dist/src/commands/room.js +79 -0
  54. package/dist/src/commands/room.js.map +1 -0
  55. package/dist/src/commands/team.d.ts +3 -0
  56. package/dist/src/commands/team.d.ts.map +1 -0
  57. package/dist/src/commands/team.js +20 -0
  58. package/dist/src/commands/team.js.map +1 -0
  59. package/dist/src/constants.d.ts +43 -0
  60. package/dist/src/constants.d.ts.map +1 -0
  61. package/dist/src/constants.js +62 -0
  62. package/dist/src/constants.js.map +1 -0
  63. package/dist/src/credential-manager.d.ts +15 -0
  64. package/dist/src/credential-manager.d.ts.map +1 -0
  65. package/dist/src/credential-manager.js +40 -0
  66. package/dist/src/credential-manager.js.map +1 -0
  67. package/dist/src/formatters.d.ts +15 -0
  68. package/dist/src/formatters.d.ts.map +1 -0
  69. package/dist/src/formatters.js +382 -0
  70. package/dist/src/formatters.js.map +1 -0
  71. package/dist/src/http.d.ts +32 -0
  72. package/dist/src/http.d.ts.map +1 -0
  73. package/dist/src/http.js +143 -0
  74. package/dist/src/http.js.map +1 -0
  75. package/dist/src/index.d.ts +7 -0
  76. package/dist/src/index.d.ts.map +1 -0
  77. package/dist/src/index.js +6 -0
  78. package/dist/src/index.js.map +1 -0
  79. package/dist/src/shared/utils/error-handler.d.ts +2 -0
  80. package/dist/src/shared/utils/error-handler.d.ts.map +1 -0
  81. package/dist/src/shared/utils/error-handler.js +7 -0
  82. package/dist/src/shared/utils/error-handler.js.map +1 -0
  83. package/dist/src/shared/utils/mentoring-params.d.ts +15 -0
  84. package/dist/src/shared/utils/mentoring-params.d.ts.map +1 -0
  85. package/dist/src/shared/utils/mentoring-params.js +39 -0
  86. package/dist/src/shared/utils/mentoring-params.js.map +1 -0
  87. package/dist/src/shared/utils/output.d.ts +2 -0
  88. package/dist/src/shared/utils/output.d.ts.map +1 -0
  89. package/dist/src/shared/utils/output.js +4 -0
  90. package/dist/src/shared/utils/output.js.map +1 -0
  91. package/dist/src/shared/utils/stderr.d.ts +5 -0
  92. package/dist/src/shared/utils/stderr.d.ts.map +1 -0
  93. package/dist/src/shared/utils/stderr.js +19 -0
  94. package/dist/src/shared/utils/stderr.js.map +1 -0
  95. package/dist/src/shared/utils/swmaestro.d.ts +33 -0
  96. package/dist/src/shared/utils/swmaestro.d.ts.map +1 -0
  97. package/dist/src/shared/utils/swmaestro.js +164 -0
  98. package/dist/src/shared/utils/swmaestro.js.map +1 -0
  99. package/dist/src/token-extractor.d.ts +23 -0
  100. package/dist/src/token-extractor.d.ts.map +1 -0
  101. package/dist/src/token-extractor.js +163 -0
  102. package/dist/src/token-extractor.js.map +1 -0
  103. package/dist/src/types.d.ts +176 -0
  104. package/dist/src/types.d.ts.map +1 -0
  105. package/dist/src/types.js +110 -0
  106. package/dist/src/types.js.map +1 -0
  107. package/e2e/.gitkeep +0 -0
  108. package/package.json +56 -0
  109. package/scripts/postbuild.ts +11 -0
  110. package/scripts/prepublish.ts +9 -0
  111. package/scripts/test.ts +82 -0
  112. package/skills/opensoma/SKILL.md +345 -0
  113. package/skills/opensoma/references/common-patterns.md +182 -0
  114. package/skills/opensoma/references/output-format.md +130 -0
  115. package/src/cli.ts +57 -0
  116. package/src/client.test.ts +210 -0
  117. package/src/client.ts +264 -0
  118. package/src/commands/auth.ts +153 -0
  119. package/src/commands/dashboard.ts +39 -0
  120. package/src/commands/event.ts +74 -0
  121. package/src/commands/helpers.ts +12 -0
  122. package/src/commands/index.ts +8 -0
  123. package/src/commands/member.ts +29 -0
  124. package/src/commands/mentoring.ts +209 -0
  125. package/src/commands/notice.ts +56 -0
  126. package/src/commands/room.ts +102 -0
  127. package/src/commands/team.ts +26 -0
  128. package/src/constants.ts +70 -0
  129. package/src/credential-manager.test.ts +66 -0
  130. package/src/credential-manager.ts +52 -0
  131. package/src/formatters.test.ts +382 -0
  132. package/src/formatters.ts +489 -0
  133. package/src/http.test.ts +152 -0
  134. package/src/http.ts +196 -0
  135. package/src/index.ts +6 -0
  136. package/src/shared/utils/error-handler.ts +7 -0
  137. package/src/shared/utils/mentoring-params.test.ts +112 -0
  138. package/src/shared/utils/mentoring-params.ts +57 -0
  139. package/src/shared/utils/output.ts +3 -0
  140. package/src/shared/utils/stderr.ts +23 -0
  141. package/src/shared/utils/swmaestro.ts +218 -0
  142. package/src/token-extractor.test.ts +119 -0
  143. package/src/token-extractor.ts +205 -0
  144. package/src/types.test.ts +172 -0
  145. package/src/types.ts +134 -0
  146. package/tsconfig.json +38 -0
@@ -0,0 +1,489 @@
1
+ import { type HTMLElement, parse } from 'node-html-parser'
2
+
3
+ import {
4
+ ApplicationHistoryItemSchema,
5
+ type ApplicationHistoryItem,
6
+ DashboardSchema,
7
+ EventListItemSchema,
8
+ type Dashboard,
9
+ type EventListItem,
10
+ type MemberInfo,
11
+ MemberInfoSchema,
12
+ type MentoringDetail,
13
+ MentoringDetailSchema,
14
+ type MentoringListItem,
15
+ MentoringListItemSchema,
16
+ type NoticeDetail,
17
+ NoticeDetailSchema,
18
+ type NoticeListItem,
19
+ NoticeListItemSchema,
20
+ type Pagination,
21
+ PaginationSchema,
22
+ type RoomCard,
23
+ RoomCardSchema,
24
+ type TeamInfo,
25
+ TeamInfoSchema,
26
+ } from '@/types'
27
+
28
+ type LabelMap = Record<string, string>
29
+
30
+ export function parseMentoringList(html: string): MentoringListItem[] {
31
+ return findTableRows(html, 9).map((cells) => {
32
+ const titleLink = cells[1]?.querySelector('a')
33
+ const titleText = cleanText(titleLink ?? cells[1])
34
+
35
+ return MentoringListItemSchema.parse({
36
+ id: extractUrlParam(titleLink?.getAttribute('href'), 'qustnrSn'),
37
+ title: stripMentoringStatus(stripMentoringPrefix(titleText)),
38
+ type: extractMentoringType(titleText),
39
+ registrationPeriod: extractDateRange(cleanText(cells[2])),
40
+ sessionDate: extractFirstDate(cleanText(cells[3])),
41
+ sessionTime: extractTimeRange(cleanText(cells[3])),
42
+ attendees: extractAttendees(cleanText(cells[4])),
43
+ approved: /OK/i.test(cleanText(cells[5])),
44
+ status: extractStatus(cleanText(cells[6]) || titleText),
45
+ author: cleanText(cells[7]),
46
+ createdAt: cleanText(cells[8]),
47
+ })
48
+ })
49
+ }
50
+
51
+ export function parseMentoringDetail(html: string, id = 0): MentoringDetail {
52
+ const root = parse(html)
53
+ const labels = { ...extractLabelMap(root), ...extractGroupMap(root) }
54
+ const rawTitle = labels['모집 명'] || labels['제목'] || cleanText(root.querySelector('h1, h2, .title'))
55
+ const dateText = labels['강의날짜'] || labels['진행날짜'] || ''
56
+ const contentNode = root.querySelector('.cont') ?? root.querySelector('.board-view-content') ?? root.querySelector('.view-content') ?? root.querySelector('.content-body') ?? root.querySelector('#contents')
57
+
58
+ return MentoringDetailSchema.parse({
59
+ id: id || extractNumber(root.querySelector('[name="qustnrSn"]')?.getAttribute('value') ?? ''),
60
+ title: stripMentoringStatus(stripMentoringPrefix(rawTitle)),
61
+ type: extractMentoringType(labels['유형'] || root.querySelector('[name="reportCd"]')?.getAttribute('value') || rawTitle),
62
+ registrationPeriod: extractDateRange(labels['접수 기간'] || labels['접수기간'] || ''),
63
+ sessionDate: extractFirstDate(dateText),
64
+ sessionTime: extractTimeRange(dateText),
65
+ attendees: {
66
+ current: extractNumber(labels['신청인원'] || labels['현재인원'] || cleanText(root.querySelector('.total-normal')) || ''),
67
+ max: extractNumber(labels['모집인원'] || labels['수강인원'] || ''),
68
+ },
69
+ approved: /OK/i.test(labels['개설 승인'] || labels['개설승인'] || ''),
70
+ status: extractStatus(labels.상태 || rawTitle),
71
+ author: labels['작성자'] || '',
72
+ createdAt: labels['등록일'] || '',
73
+ content: contentNode?.innerHTML.trim() ?? '',
74
+ venue: labels['장소'] || '',
75
+ })
76
+ }
77
+
78
+ export function parseRoomList(html: string): RoomCard[] {
79
+ const root = parse(html)
80
+ const cards = root.querySelectorAll('ul.bbs-reserve > li.item')
81
+
82
+ return cards.map((card) => {
83
+ const link = card.querySelector('a[onclick]')
84
+ const description = cleanText(card.querySelector('.txt li p'))
85
+
86
+ return RoomCardSchema.parse({
87
+ itemId: extractUrlParam(extractLocationHref(link?.getAttribute('onclick')), 'itemId'),
88
+ name: cleanText(card.querySelector('h4.tit')),
89
+ capacity: extractCapacity(description || cleanText(card)),
90
+ availablePeriod: extractDateRange(findListText(card, '이용기간')),
91
+ description,
92
+ timeSlots: parseTimeSlotsFromRoot(card),
93
+ })
94
+ })
95
+ }
96
+
97
+ export function parseRoomSlots(html: string): RoomCard['timeSlots'] {
98
+ const root = parse(html)
99
+ const slots = root.querySelectorAll('span.ck-st2')
100
+
101
+ if (slots.length === 0) {
102
+ return parseTimeSlotsFromRoot(root)
103
+ }
104
+
105
+ return slots
106
+ .map((slot) => {
107
+ const hour = slot.getAttribute('data-hour') ?? ''
108
+ const minute = slot.getAttribute('data-minute') ?? ''
109
+ const checkbox = slot.querySelector('input[type="checkbox"]')
110
+ const className = slot.getAttribute('class') ?? ''
111
+ const time = hour && minute ? `${hour}:${minute}` : normalizeTime(cleanText(slot.querySelector('label') ?? slot))
112
+
113
+ return {
114
+ time,
115
+ available: !checkbox?.hasAttribute('disabled') && !className.includes('disabled'),
116
+ }
117
+ })
118
+ .filter((slot) => Boolean(slot.time))
119
+ }
120
+
121
+ export function parseDashboard(html: string): Dashboard {
122
+ const root = parse(html)
123
+ const profileCard = root.querySelector('ul.dash-top > li.dash-card')
124
+ const dashEtc = profileCard?.querySelector('.dash-etc')
125
+ const dashState = profileCard?.querySelector('.dash-state')
126
+ const teamItems = dashState?.querySelectorAll('ul.dash-box > li') ?? []
127
+ const team = {
128
+ name: findDashboardValue(teamItems, '팀명'),
129
+ members: findDashboardValue(teamItems, '팀원'),
130
+ mentor: findDashboardValue(teamItems, '멘토'),
131
+ }
132
+
133
+ return DashboardSchema.parse({
134
+ name: cleanText(dashState?.querySelector('.welcome strong')),
135
+ role: cleanText(dashState?.querySelector('.label span')),
136
+ organization: extractDashEtcValue(dashEtc, '소속'),
137
+ position: extractDashEtcValue(dashEtc, '직책'),
138
+ team: team.name || team.members || team.mentor ? team : undefined,
139
+ mentoringSessions: parseDashboardLinks(root, (href) => href.includes('/mentoLec/')),
140
+ roomReservations: parseDashboardLinks(root, (href) => href.includes('/itemRent/') || href.includes('/officeMng/')),
141
+ })
142
+ }
143
+
144
+ export function parseNoticeList(html: string): NoticeListItem[] {
145
+ return findTableRows(html, 4).map((cells) => {
146
+ const link = cells[1]?.querySelector('a')
147
+
148
+ return NoticeListItemSchema.parse({
149
+ id: extractUrlParam(link?.getAttribute('href'), 'nttId'),
150
+ title: cleanText(link ?? cells[1]),
151
+ author: cleanText(cells[2]),
152
+ createdAt: cleanText(cells[3]),
153
+ })
154
+ })
155
+ }
156
+
157
+ export function parseNoticeDetail(html: string, id = 0): NoticeDetail {
158
+ const root = parse(html)
159
+ const labels = extractLabelMap(root)
160
+ const top = root.querySelector('.bbs-view .top')
161
+ const spans = top?.querySelectorAll('.etc span') ?? []
162
+ const author = extractPrefixedValue(spans, '작성자') || labels['작성자'] || ''
163
+ const createdAt = extractPrefixedValue(spans, '등록일') || labels['등록일'] || ''
164
+ const contentNode = root.querySelector('.bbs-view .cont') ?? root.querySelector('.board-view-content') ?? root.querySelector('.view-content') ?? root.querySelector('.content-body') ?? root.querySelector('#contents')
165
+
166
+ return NoticeDetailSchema.parse({
167
+ id: id || extractNumber(root.querySelector('[name="nttId"]')?.getAttribute('value') ?? ''),
168
+ title: cleanText(top?.querySelector('.tit')) || labels['제목'] || cleanText(root.querySelector('h1, h2, .title')),
169
+ author,
170
+ createdAt,
171
+ content: contentNode?.innerHTML.trim() ?? '',
172
+ })
173
+ }
174
+
175
+ export function parseTeamInfo(html: string): TeamInfo {
176
+ const root = parse(html)
177
+ const cards = root.querySelectorAll('ul.bbs-team > li')
178
+ const summaryText = cleanText(root.querySelector('p.ico-team'))
179
+ const summaryNumbers = summaryText.match(/(\d+)\/(\d+)팀/)
180
+
181
+ return TeamInfoSchema.parse({
182
+ teams: cards.map((card) => ({
183
+ name: cleanText(card.querySelector('.top strong.t a')),
184
+ memberCount: extractMemberCount(cleanText(card)),
185
+ joinStatus: extractJoinStatus(card),
186
+ })),
187
+ currentTeams: summaryNumbers ? Number.parseInt(summaryNumbers[1], 10) : 0,
188
+ maxTeams: summaryNumbers ? Number.parseInt(summaryNumbers[2], 10) : 0,
189
+ })
190
+ }
191
+
192
+ export function parseMemberInfo(html: string): MemberInfo {
193
+ const labels = extractDefinitionListMap(parse(html))
194
+
195
+ return MemberInfoSchema.parse({
196
+ email: labels['아이디'] || '',
197
+ name: labels['이름'] || '',
198
+ gender: labels['성별'] || '',
199
+ birthDate: labels['생년월일'] || '',
200
+ phone: labels['연락처'] || '',
201
+ organization: labels['소속'] || '',
202
+ position: labels['직책'] || '',
203
+ })
204
+ }
205
+
206
+ export function parseEventList(html: string): EventListItem[] {
207
+ return findTableRows(html, 7).map((cells) =>
208
+ EventListItemSchema.parse({
209
+ id: extractLinkParam(cells[2], ['qustnrSn', 'bbsId', 'nttId']) || extractNumber(cleanText(cells[0])),
210
+ category: cleanText(cells[1]),
211
+ title: cleanText(cells[2]?.querySelector('a') ?? cells[2]),
212
+ registrationPeriod: extractDateRange(cleanText(cells[3])),
213
+ eventPeriod: extractDateRange(cleanText(cells[4])),
214
+ status: stripWrappingBrackets(cleanText(cells[5])),
215
+ createdAt: cleanText(cells[6]),
216
+ }),
217
+ )
218
+ }
219
+
220
+ export function parseApplicationHistory(html: string): ApplicationHistoryItem[] {
221
+ return findTableRows(html, 10).map((cells) =>
222
+ ApplicationHistoryItemSchema.parse({
223
+ id: extractNumber(cleanText(cells[0])),
224
+ category: cleanText(cells[1]),
225
+ title: cleanText(cells[2]?.querySelector('a') ?? cells[2]),
226
+ author: cleanText(cells[3]),
227
+ sessionDate: normalizeDate(cleanText(cells[4])),
228
+ appliedAt: cleanText(cells[5]),
229
+ applicationStatus: stripWrappingBrackets(cleanText(cells[6])),
230
+ approvalStatus: stripWrappingBrackets(cleanText(cells[7])),
231
+ applicationDetail: cleanText(cells[8]),
232
+ note: cleanText(cells[9]),
233
+ }),
234
+ )
235
+ }
236
+
237
+ export function parsePagination(html: string): Pagination {
238
+ const root = parse(html)
239
+ const items = root.querySelectorAll('ul.bbs-total > li').map((item) => cleanText(item))
240
+ const total = extractNumber(items.find((item) => item.includes('Total')) ?? '')
241
+ const pageMatch = items.find((item) => item.includes('Page'))?.match(/(\d+)\s*\/\s*(\d+)\s*Page/i)
242
+
243
+ return PaginationSchema.parse({
244
+ total,
245
+ currentPage: pageMatch ? Number.parseInt(pageMatch[1], 10) : 1,
246
+ totalPages: pageMatch ? Number.parseInt(pageMatch[2], 10) : 1,
247
+ })
248
+ }
249
+
250
+ export function parseCsrfToken(html: string): string {
251
+ const token = parse(html).querySelector('input[name="csrfToken"]')?.getAttribute('value')
252
+
253
+ if (!token) {
254
+ throw new Error('CSRF token not found')
255
+ }
256
+
257
+ return token
258
+ }
259
+
260
+ function findTableRows(html: string, cellCount: number): HTMLElement[][] {
261
+ return parse(html)
262
+ .querySelectorAll('table tbody tr')
263
+ .map((row) => row.querySelectorAll('td'))
264
+ .filter((cells) => cells.length === cellCount)
265
+ }
266
+
267
+ function extractLabelMap(root: HTMLElement): LabelMap {
268
+ const map: LabelMap = {}
269
+
270
+ for (const row of root.querySelectorAll('tr')) {
271
+ const headers = row.querySelectorAll('th')
272
+ const values = row.querySelectorAll('td')
273
+
274
+ if (headers.length === values.length) {
275
+ headers.forEach((header, index) => {
276
+ map[cleanText(header).replace(/:$/, '')] = cleanText(values[index])
277
+ })
278
+ }
279
+ }
280
+
281
+ return map
282
+ }
283
+
284
+ function extractDefinitionListMap(root: HTMLElement): LabelMap {
285
+ const map: LabelMap = {}
286
+
287
+ for (const definition of root.querySelectorAll('dl')) {
288
+ const label = cleanText(definition.querySelector('dt'))
289
+ if (!label) {
290
+ continue
291
+ }
292
+
293
+ map[label] = cleanText(definition.querySelector('dd'))
294
+ }
295
+
296
+ return map
297
+ }
298
+
299
+ function extractGroupMap(root: HTMLElement): LabelMap {
300
+ const map: LabelMap = {}
301
+
302
+ for (const group of root.querySelectorAll('.group')) {
303
+ const label = cleanText(group.querySelector('strong.t')).replace(/:$/, '')
304
+ if (!label) {
305
+ continue
306
+ }
307
+
308
+ map[label] = cleanText(group.querySelector('.c'))
309
+ }
310
+
311
+ return map
312
+ }
313
+
314
+ function parseDashboardLinks(
315
+ root: HTMLElement,
316
+ predicate: (href: string) => boolean,
317
+ ): Array<{ title: string; url: string; status: string }> {
318
+ return root
319
+ .querySelectorAll('ul.bbs-dash_w a')
320
+ .filter((link) => predicate(link.getAttribute('href') ?? ''))
321
+ .map((link) => {
322
+ const text = cleanText(link)
323
+ return {
324
+ title: stripTrailingStatus(text),
325
+ url: link.getAttribute('href') ?? '',
326
+ status: extractTrailingStatus(text),
327
+ }
328
+ })
329
+ }
330
+
331
+ function parseTimeSlotsFromRoot(root: HTMLElement): RoomCard['timeSlots'] {
332
+ const grid = root.querySelector('.time-grid')
333
+ const spans = grid ? grid.querySelectorAll('span') : root.querySelectorAll('.time-grid span, [class*="time-slot"], .slot')
334
+
335
+ return spans
336
+ .map((slot) => ({
337
+ time: cleanText(slot),
338
+ available: !(slot.getAttribute('class') ?? '').includes('not-reserve') &&
339
+ !(slot.getAttribute('class') ?? '').includes('booked') &&
340
+ !(slot.getAttribute('class') ?? '').includes('disabled'),
341
+ }))
342
+ .filter((slot) => Boolean(slot.time))
343
+ }
344
+
345
+ function findDashboardValue(items: HTMLElement[], label: string): string {
346
+ const item = items.find((candidate) => cleanText(candidate.querySelector('strong.t')) === label)
347
+ return cleanText(item?.querySelector('.c'))
348
+ }
349
+
350
+ function extractDashEtcValue(container: HTMLElement | null | undefined, label: string): string {
351
+ const match = (container?.querySelectorAll('span') ?? []).find((item) => cleanText(item).startsWith(label))
352
+ return cleanText(match).replace(new RegExp(`^${escapeRegex(label)}\\s*:`), '').trim()
353
+ }
354
+
355
+ function findListText(card: HTMLElement, label: string): string {
356
+ const item = card.querySelectorAll('.txt > li').find((entry) => cleanText(entry).startsWith(label))
357
+ return cleanText(item).replace(new RegExp(`^${escapeRegex(label)}\\s*:`), '').trim()
358
+ }
359
+
360
+ function extractLocationHref(onclick: string | undefined): string {
361
+ const match = onclick?.match(/location\.href\s*=\s*['"]([^'"]+)['"]/) ?? onclick?.match(/['"]([^'"]+)['"]/)
362
+ return match?.[1] ?? ''
363
+ }
364
+
365
+ function extractUrlParam(url: string | undefined, key: string): number {
366
+ const normalizedUrl = url?.startsWith('http') ? url : `https://example.com${url ?? ''}`
367
+ if (!normalizedUrl) {
368
+ return 0
369
+ }
370
+
371
+ try {
372
+ const value = new URL(normalizedUrl).searchParams.get(key)
373
+ return extractNumber(value ?? '')
374
+ } catch {
375
+ return 0
376
+ }
377
+ }
378
+
379
+ function extractLinkParam(cell: HTMLElement | undefined, keys: string[]): number {
380
+ const href = cell?.querySelector('a')?.getAttribute('href')
381
+
382
+ for (const key of keys) {
383
+ const value = extractUrlParam(href, key)
384
+ if (value > 0) {
385
+ return value
386
+ }
387
+ }
388
+
389
+ return 0
390
+ }
391
+
392
+ function extractDateRange(text: string): { start: string; end: string } {
393
+ const dates = text.match(/\d{4}[.-]\d{2}[.-]\d{2}/g)?.map(normalizeDate) ?? []
394
+ return { start: dates[0] ?? '', end: dates[1] ?? '' }
395
+ }
396
+
397
+ function extractTimeRange(text: string): { start: string; end: string } {
398
+ const times = text.match(/\d{2}:\d{2}/g) ?? []
399
+ return { start: times[0] ?? '', end: times[1] ?? '' }
400
+ }
401
+
402
+ function extractAttendees(text: string): { current: number; max: number } {
403
+ const numbers = text.match(/\d+/g)?.map((value) => Number.parseInt(value, 10)) ?? []
404
+ return { current: numbers[0] ?? 0, max: numbers[1] ?? 0 }
405
+ }
406
+
407
+ function extractStatus(text: string): '접수중' | '마감' {
408
+ return text.includes('마감') ? '마감' : '접수중'
409
+ }
410
+
411
+ function extractMentoringType(text: string): '자유 멘토링' | '멘토 특강' {
412
+ return /멘토 특강|MRC020/.test(text) ? '멘토 특강' : '자유 멘토링'
413
+ }
414
+
415
+ function extractFirstDate(text: string): string {
416
+ return normalizeDate(text.match(/\d{4}[.-]\d{2}[.-]\d{2}/)?.[0] ?? '')
417
+ }
418
+
419
+ function extractCapacity(text: string): number {
420
+ const match = text.match(/(\d+)\s*인/)
421
+ return match ? Number.parseInt(match[1], 10) : 0
422
+ }
423
+
424
+ function extractMemberCount(text: string): number {
425
+ const match = text.match(/(\d+)\s*명/)
426
+ return match ? Number.parseInt(match[1], 10) : 0
427
+ }
428
+
429
+ function extractJoinStatus(card: HTMLElement): string {
430
+ return (
431
+ cleanText(card.querySelector('button')) ||
432
+ cleanText(card.querySelector('.btn')) ||
433
+ cleanText(card.querySelector('[class*="join"]')) ||
434
+ ''
435
+ )
436
+ }
437
+
438
+ function extractTrailingStatus(text: string): string {
439
+ const match = text.match(/(예약완료|예약중|대기|접수중|마감|승인완료|신청완료)$/)
440
+ return match?.[1] ?? ''
441
+ }
442
+
443
+ function stripTrailingStatus(text: string): string {
444
+ return text.replace(/\s*(예약완료|예약중|대기|접수중|마감|승인완료|신청완료)$/, '').trim()
445
+ }
446
+
447
+ function stripMentoringPrefix(text: string): string {
448
+ return text.replace(/^\[(자유 멘토링|멘토 특강)\]\s*/, '').trim()
449
+ }
450
+
451
+ function stripMentoringStatus(text: string): string {
452
+ return text.replace(/\s*\[(접수중|마감)\]\s*$/, '').trim()
453
+ }
454
+
455
+ function stripWrappingBrackets(text: string): string {
456
+ return text.replace(/^\[/, '').replace(/\]$/, '').trim()
457
+ }
458
+
459
+ function extractNumber(text: string): number {
460
+ const match = text.match(/\d+/)
461
+ return match ? Number.parseInt(match[0], 10) : 0
462
+ }
463
+
464
+ function escapeRegex(text: string): string {
465
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
466
+ }
467
+
468
+ function extractPrefixedValue(nodes: HTMLElement[], label: string): string {
469
+ const match = nodes.find((node) => cleanText(node).includes(label))
470
+ return cleanText(match).replace(new RegExp(`^${escapeRegex(label)}\\s*:`), '').trim()
471
+ }
472
+
473
+ function normalizeDate(value: string): string {
474
+ return value.replace(/\./g, '-')
475
+ }
476
+
477
+ function normalizeTime(value: string): string {
478
+ const match = value.match(/(\d{2}:\d{2})/)
479
+ return match?.[1] ?? ''
480
+ }
481
+
482
+ function cleanText(value: string | HTMLElement | null | undefined): string {
483
+ if (!value) {
484
+ return ''
485
+ }
486
+
487
+ const text = typeof value === 'string' ? value : value.text
488
+ return text.replace(/\s+/g, ' ').trim()
489
+ }
@@ -0,0 +1,152 @@
1
+ import { afterEach, describe, expect, mock, test } from 'bun:test'
2
+
3
+ import { MENU_NO } from '@/constants'
4
+ import { SomaHttp } from '@/http'
5
+
6
+ const originalFetch = globalThis.fetch
7
+
8
+ afterEach(() => {
9
+ globalThis.fetch = originalFetch
10
+ mock.restore()
11
+ })
12
+
13
+ describe('SomaHttp', () => {
14
+ test('get sends query params and stores cookies', async () => {
15
+ const fetchMock = mock(async (input: RequestInfo | URL) => {
16
+ expect(String(input)).toBe(`https://www.swmaestro.ai/sw/member/user/forLogin.do?menuNo=${MENU_NO.LOGIN}`)
17
+ return createResponse('<html></html>', ['JSESSIONID=session-1; Path=/', 'XSRF-TOKEN=csrf-1; Path=/'])
18
+ })
19
+ globalThis.fetch = fetchMock as typeof fetch
20
+
21
+ const http = new SomaHttp()
22
+ const html = await http.get('/member/user/forLogin.do', { menuNo: MENU_NO.LOGIN })
23
+
24
+ expect(html).toBe('<html></html>')
25
+ expect(http.getCookies()).toEqual({ JSESSIONID: 'session-1', 'XSRF-TOKEN': 'csrf-1' })
26
+ expect(fetchMock).toHaveBeenCalledTimes(1)
27
+ })
28
+
29
+ test('post encodes body and injects csrf token', async () => {
30
+ const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
31
+ expect(init?.method).toBe('POST')
32
+ expect(init?.headers).toEqual({
33
+ cookie: 'JSESSIONID=session-1',
34
+ 'Content-Type': 'application/x-www-form-urlencoded',
35
+ })
36
+ expect(init?.body).toBe('title=%ED%85%8C%EC%8A%A4%ED%8A%B8&csrfToken=csrf-1')
37
+ return createResponse('<html>ok</html>')
38
+ })
39
+ globalThis.fetch = fetchMock as typeof fetch
40
+
41
+ const http = new SomaHttp({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
42
+ const html = await http.post('/mypage/mentoLec/insert.do', { title: '테스트' })
43
+
44
+ expect(html).toBe('<html>ok</html>')
45
+ })
46
+
47
+ test('postJson returns parsed json', async () => {
48
+ const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
49
+ expect(init?.headers).toEqual({
50
+ cookie: 'JSESSIONID=session-1',
51
+ Accept: 'application/json',
52
+ 'Content-Type': 'application/x-www-form-urlencoded',
53
+ })
54
+ return createResponse(JSON.stringify({ resultCode: 'SUCCESS' }), [], 'application/json')
55
+ })
56
+ globalThis.fetch = fetchMock as typeof fetch
57
+
58
+ const http = new SomaHttp({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
59
+ const json = await http.postJson<{ resultCode: string }>('/mypage/officeMng/rentTime.do', { itemId: '17' })
60
+
61
+ expect(json).toEqual({ resultCode: 'SUCCESS' })
62
+ })
63
+
64
+ test('login fetches csrf token then posts credentials', async () => {
65
+ const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => {
66
+ const url = String(input)
67
+
68
+ if (url.includes('/forLogin.do')) {
69
+ expect(init?.method).toBe('GET')
70
+ return createResponse('<form><input type="hidden" name="csrfToken" value="csrf-login"></form>', [
71
+ 'JSESSIONID=session-2; Path=/',
72
+ ])
73
+ }
74
+
75
+ expect(url).toBe('https://www.swmaestro.ai/sw/member/user/toLogin.do')
76
+ expect(init?.method).toBe('POST')
77
+ expect(init?.headers).toEqual({
78
+ cookie: 'JSESSIONID=session-2',
79
+ 'Content-Type': 'application/x-www-form-urlencoded',
80
+ })
81
+
82
+ const body = new URLSearchParams(String(init?.body))
83
+ expect(body.get('username')).toBe('neo@example.com')
84
+ expect(body.get('password')).toBe('secret')
85
+ expect(body.get('csrfToken')).toBe('csrf-login')
86
+ expect(body.get('menuNo')).toBe(MENU_NO.LOGIN)
87
+ return createResponse('<html>logged-in</html>', ['JSESSIONID=session-3; Path=/'])
88
+ })
89
+ globalThis.fetch = fetchMock as typeof fetch
90
+
91
+ const http = new SomaHttp()
92
+ await http.login('neo@example.com', 'secret')
93
+
94
+ expect(http.getSessionCookie()).toBe('session-3')
95
+ expect(http.getCsrfToken()).toBe('csrf-login')
96
+ })
97
+
98
+ test('checkLogin returns user identity when logged in, null otherwise', async () => {
99
+ const loggedInMock = mock(async () =>
100
+ createResponse(
101
+ JSON.stringify({ resultCode: 'fail', userVO: { userId: 'user@example.com', userNm: 'Test' } }),
102
+ [],
103
+ 'application/json',
104
+ ),
105
+ )
106
+ globalThis.fetch = loggedInMock as typeof fetch
107
+
108
+ await expect(new SomaHttp().checkLogin()).resolves.toEqual({ userId: 'user@example.com', userNm: 'Test' })
109
+
110
+ const notLoggedInMock = mock(async () =>
111
+ createResponse(JSON.stringify({ resultCode: 'fail', userVO: { userId: '', userSn: 0 } }), [], 'application/json'),
112
+ )
113
+ globalThis.fetch = notLoggedInMock as typeof fetch
114
+
115
+ await expect(new SomaHttp().checkLogin()).resolves.toBeNull()
116
+ })
117
+
118
+ test('logout calls logout endpoint', async () => {
119
+ const fetchMock = mock(async (input: RequestInfo | URL) => {
120
+ expect(String(input)).toBe('https://www.swmaestro.ai/sw/member/user/logout.do')
121
+ return createResponse('<html>bye</html>')
122
+ })
123
+ globalThis.fetch = fetchMock as typeof fetch
124
+
125
+ await new SomaHttp({ sessionCookie: 'session-1' }).logout()
126
+
127
+ expect(fetchMock).toHaveBeenCalledTimes(1)
128
+ })
129
+
130
+ test('extractCsrfToken reads hidden input', async () => {
131
+ const fetchMock = mock(async () => createResponse('<input type="hidden" name="csrfToken" value="csrf-token">'))
132
+ globalThis.fetch = fetchMock as typeof fetch
133
+
134
+ const http = new SomaHttp()
135
+
136
+ await expect(http.extractCsrfToken()).resolves.toBe('csrf-token')
137
+ expect(http.getCsrfToken()).toBe('csrf-token')
138
+ })
139
+ })
140
+
141
+ function createResponse(body: string, cookies: string[] = [], contentType = 'text/html'): Response {
142
+ const headers = new Headers({ 'Content-Type': contentType })
143
+ const response = new Response(body, { headers })
144
+ const cookieHeaders = cookies
145
+
146
+ Object.defineProperty(response.headers, 'getSetCookie', {
147
+ value: () => cookieHeaders,
148
+ configurable: true,
149
+ })
150
+
151
+ return response
152
+ }