opensoma 0.1.2 → 0.2.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 (97) hide show
  1. package/dist/package.json +18 -2
  2. package/dist/src/client.d.ts +8 -0
  3. package/dist/src/client.d.ts.map +1 -1
  4. package/dist/src/client.js +123 -21
  5. package/dist/src/client.js.map +1 -1
  6. package/dist/src/commands/auth.d.ts +8 -0
  7. package/dist/src/commands/auth.d.ts.map +1 -1
  8. package/dist/src/commands/auth.js +35 -23
  9. package/dist/src/commands/auth.js.map +1 -1
  10. package/dist/src/commands/dashboard.d.ts.map +1 -1
  11. package/dist/src/commands/dashboard.js +1 -1
  12. package/dist/src/commands/dashboard.js.map +1 -1
  13. package/dist/src/commands/event.d.ts.map +1 -1
  14. package/dist/src/commands/event.js.map +1 -1
  15. package/dist/src/commands/helpers.d.ts.map +1 -1
  16. package/dist/src/commands/helpers.js +12 -2
  17. package/dist/src/commands/helpers.js.map +1 -1
  18. package/dist/src/commands/member.d.ts.map +1 -1
  19. package/dist/src/commands/member.js.map +1 -1
  20. package/dist/src/commands/mentoring.d.ts.map +1 -1
  21. package/dist/src/commands/mentoring.js +14 -5
  22. package/dist/src/commands/mentoring.js.map +1 -1
  23. package/dist/src/commands/notice.d.ts.map +1 -1
  24. package/dist/src/commands/notice.js.map +1 -1
  25. package/dist/src/commands/room.d.ts.map +1 -1
  26. package/dist/src/commands/room.js.map +1 -1
  27. package/dist/src/commands/team.d.ts.map +1 -1
  28. package/dist/src/commands/team.js.map +1 -1
  29. package/dist/src/errors.d.ts +8 -0
  30. package/dist/src/errors.d.ts.map +1 -0
  31. package/dist/src/errors.js +11 -0
  32. package/dist/src/errors.js.map +1 -0
  33. package/dist/src/formatters.d.ts.map +1 -1
  34. package/dist/src/formatters.js +54 -7
  35. package/dist/src/formatters.js.map +1 -1
  36. package/dist/src/http.d.ts +5 -0
  37. package/dist/src/http.d.ts.map +1 -1
  38. package/dist/src/http.js +140 -6
  39. package/dist/src/http.js.map +1 -1
  40. package/dist/src/index.d.ts +1 -0
  41. package/dist/src/index.d.ts.map +1 -1
  42. package/dist/src/index.js +1 -0
  43. package/dist/src/index.js.map +1 -1
  44. package/dist/src/shared/utils/mentoring-params.d.ts.map +1 -1
  45. package/dist/src/shared/utils/mentoring-params.js +4 -1
  46. package/dist/src/shared/utils/mentoring-params.js.map +1 -1
  47. package/dist/src/shared/utils/swmaestro.d.ts.map +1 -1
  48. package/dist/src/shared/utils/swmaestro.js.map +1 -1
  49. package/dist/src/token-extractor.d.ts +12 -0
  50. package/dist/src/token-extractor.d.ts.map +1 -1
  51. package/dist/src/token-extractor.js +83 -18
  52. package/dist/src/token-extractor.js.map +1 -1
  53. package/dist/src/types.d.ts +17 -0
  54. package/dist/src/types.d.ts.map +1 -1
  55. package/dist/src/types.js +6 -0
  56. package/dist/src/types.js.map +1 -1
  57. package/package.json +17 -1
  58. package/src/client.test.ts +112 -12
  59. package/src/client.ts +136 -36
  60. package/src/commands/auth.test.ts +55 -0
  61. package/src/commands/auth.ts +57 -33
  62. package/src/commands/dashboard.ts +5 -6
  63. package/src/commands/event.ts +5 -6
  64. package/src/commands/helpers.ts +21 -4
  65. package/src/commands/member.ts +4 -5
  66. package/src/commands/mentoring.ts +36 -19
  67. package/src/commands/notice.ts +4 -5
  68. package/src/commands/room.ts +4 -5
  69. package/src/commands/team.ts +4 -5
  70. package/src/credential-manager.test.ts +1 -1
  71. package/src/credential-manager.ts +1 -1
  72. package/src/errors.ts +10 -0
  73. package/src/formatters.test.ts +1 -1
  74. package/src/formatters.ts +91 -18
  75. package/src/http.test.ts +43 -7
  76. package/src/http.ts +174 -8
  77. package/src/index.ts +1 -0
  78. package/src/shared/utils/mentoring-params.test.ts +9 -4
  79. package/src/shared/utils/mentoring-params.ts +6 -3
  80. package/src/shared/utils/swmaestro.ts +2 -2
  81. package/src/token-extractor.test.ts +84 -8
  82. package/src/token-extractor.ts +118 -22
  83. package/src/types.test.ts +4 -2
  84. package/src/types.ts +6 -0
  85. package/.claude-plugin/README.md +0 -145
  86. package/.claude-plugin/plugin.json +0 -23
  87. package/.github/workflows/release.yml +0 -86
  88. package/.oxfmtrc.json +0 -9
  89. package/.oxlintrc.json +0 -4
  90. package/AGENTS.md +0 -78
  91. package/README.md +0 -252
  92. package/bun.lock +0 -297
  93. package/bunfig.toml +0 -2
  94. package/e2e/.gitkeep +0 -0
  95. package/skills/opensoma/SKILL.md +0 -345
  96. package/skills/opensoma/references/common-patterns.md +0 -182
  97. package/skills/opensoma/references/output-format.md +0 -130
@@ -1,12 +1,11 @@
1
1
  import { Command } from 'commander'
2
2
 
3
- import { MENU_NO } from '@/constants'
4
- import * as formatters from '@/formatters'
5
- import { handleError } from '@/shared/utils/error-handler'
6
- import { formatOutput } from '@/shared/utils/output'
7
-
3
+ import { MENU_NO } from '../constants'
4
+ import * as formatters from '../formatters'
5
+ import { handleError } from '../shared/utils/error-handler'
6
+ import { buildMentoringListParams } from '../shared/utils/mentoring-params'
7
+ import { formatOutput } from '../shared/utils/output'
8
8
  import { getHttpOrExit } from './helpers'
9
- import { buildMentoringListParams } from '@/shared/utils/mentoring-params'
10
9
 
11
10
  type ShowOptions = { pretty?: boolean }
12
11
 
@@ -1,11 +1,10 @@
1
1
  import { Command } from 'commander'
2
2
 
3
- import { MENU_NO } from '@/constants'
4
- import * as formatters from '@/formatters'
5
- import { handleError } from '@/shared/utils/error-handler'
6
- import { formatOutput } from '@/shared/utils/output'
7
- import { buildApplicationPayload, parseEventDetail } from '@/shared/utils/swmaestro'
8
-
3
+ import { MENU_NO } from '../constants'
4
+ import * as formatters from '../formatters'
5
+ import { handleError } from '../shared/utils/error-handler'
6
+ import { formatOutput } from '../shared/utils/output'
7
+ import { buildApplicationPayload, parseEventDetail } from '../shared/utils/swmaestro'
9
8
  import { getHttpOrExit } from './helpers'
10
9
 
11
10
  type ListOptions = { page?: string; pretty?: boolean }
@@ -1,12 +1,29 @@
1
- import { CredentialManager } from '@/credential-manager'
2
- import { SomaHttp } from '@/http'
1
+ import { CredentialManager } from '../credential-manager'
2
+ import { SomaHttp } from '../http'
3
3
 
4
4
  export async function getHttpOrExit(): Promise<SomaHttp> {
5
5
  const manager = new CredentialManager()
6
6
  const creds = await manager.getCredentials()
7
7
  if (!creds) {
8
- console.error(JSON.stringify({ error: 'Not logged in. Run: opensoma auth login' }))
8
+ console.error(
9
+ JSON.stringify({
10
+ error: 'Not logged in. Run: opensoma auth login or opensoma auth extract',
11
+ }),
12
+ )
9
13
  process.exit(1)
10
14
  }
11
- return new SomaHttp({ sessionCookie: creds.sessionCookie, csrfToken: creds.csrfToken })
15
+
16
+ const http = new SomaHttp({ sessionCookie: creds.sessionCookie, csrfToken: creds.csrfToken })
17
+
18
+ const identity = await http.checkLogin()
19
+ if (!identity) {
20
+ console.error(
21
+ JSON.stringify({
22
+ error: 'Session expired. Run: opensoma auth login or opensoma auth extract',
23
+ }),
24
+ )
25
+ process.exit(1)
26
+ }
27
+
28
+ return http
12
29
  }
@@ -1,10 +1,9 @@
1
1
  import { Command } from 'commander'
2
2
 
3
- import { MENU_NO } from '@/constants'
4
- import * as formatters from '@/formatters'
5
- import { handleError } from '@/shared/utils/error-handler'
6
- import { formatOutput } from '@/shared/utils/output'
7
-
3
+ import { MENU_NO } from '../constants'
4
+ import * as formatters from '../formatters'
5
+ import { handleError } from '../shared/utils/error-handler'
6
+ import { formatOutput } from '../shared/utils/output'
8
7
  import { getHttpOrExit } from './helpers'
9
8
 
10
9
  type ShowOptions = { pretty?: boolean }
@@ -1,20 +1,25 @@
1
1
  import { Command } from 'commander'
2
2
 
3
- import { MENU_NO } from '@/constants'
4
- import * as formatters from '@/formatters'
5
- import { handleError } from '@/shared/utils/error-handler'
6
- import { formatOutput } from '@/shared/utils/output'
3
+ import { MENU_NO } from '../constants'
4
+ import * as formatters from '../formatters'
5
+ import { handleError } from '../shared/utils/error-handler'
6
+ import { buildMentoringListParams, parseSearchQuery } from '../shared/utils/mentoring-params'
7
+ import { formatOutput } from '../shared/utils/output'
7
8
  import {
8
9
  buildApplicationPayload,
9
10
  buildCancelApplicationPayload,
10
11
  buildDeleteMentoringPayload,
11
12
  buildMentoringPayload,
12
- } from '@/shared/utils/swmaestro'
13
-
13
+ } from '../shared/utils/swmaestro'
14
14
  import { getHttpOrExit } from './helpers'
15
- import { buildMentoringListParams, parseSearchQuery } from '@/shared/utils/mentoring-params'
16
15
 
17
- type ListOptions = { status?: string; type?: string; search?: string; page?: string; pretty?: boolean }
16
+ type ListOptions = {
17
+ status?: string
18
+ type?: string
19
+ search?: string
20
+ page?: string
21
+ pretty?: boolean
22
+ }
18
23
  type GetOptions = { pretty?: boolean }
19
24
  type CreateOptions = {
20
25
  title: string
@@ -36,17 +41,23 @@ async function listAction(options: ListOptions): Promise<void> {
36
41
  try {
37
42
  const http = await getHttpOrExit()
38
43
  const search = options.search ? parseSearchQuery(options.search) : undefined
39
- const user = search?.me ? (await http.checkLogin()) ?? undefined : undefined
40
- const html = await http.get('/mypage/mentoLec/list.do', buildMentoringListParams({
41
- status: options.status,
42
- type: options.type,
43
- page: options.page,
44
- search,
45
- user,
46
- }))
44
+ const user = search?.me ? ((await http.checkLogin()) ?? undefined) : undefined
45
+ const html = await http.get(
46
+ '/mypage/mentoLec/list.do',
47
+ buildMentoringListParams({
48
+ status: options.status,
49
+ type: options.type,
50
+ page: options.page,
51
+ search,
52
+ user,
53
+ }),
54
+ )
47
55
  console.log(
48
56
  formatOutput(
49
- { items: formatters.parseMentoringList(html), pagination: formatters.parsePagination(html) },
57
+ {
58
+ items: formatters.parseMentoringList(html),
59
+ pagination: formatters.parsePagination(html),
60
+ },
50
61
  options.pretty,
51
62
  ),
52
63
  )
@@ -58,7 +69,10 @@ async function listAction(options: ListOptions): Promise<void> {
58
69
  async function getAction(id: string, options: GetOptions): Promise<void> {
59
70
  try {
60
71
  const http = await getHttpOrExit()
61
- const html = await http.get('/mypage/mentoLec/view.do', { menuNo: MENU_NO.MENTORING, qustnrSn: id })
72
+ const html = await http.get('/mypage/mentoLec/view.do', {
73
+ menuNo: MENU_NO.MENTORING,
74
+ qustnrSn: id,
75
+ })
62
76
  console.log(formatOutput(formatters.parseMentoringDetail(html, Number.parseInt(id, 10)), options.pretty))
63
77
  } catch (error) {
64
78
  handleError(error)
@@ -134,7 +148,10 @@ async function historyAction(options: HistoryOptions): Promise<void> {
134
148
  })
135
149
  console.log(
136
150
  formatOutput(
137
- { items: formatters.parseApplicationHistory(html), pagination: formatters.parsePagination(html) },
151
+ {
152
+ items: formatters.parseApplicationHistory(html),
153
+ pagination: formatters.parsePagination(html),
154
+ },
138
155
  options.pretty,
139
156
  ),
140
157
  )
@@ -1,10 +1,9 @@
1
1
  import { Command } from 'commander'
2
2
 
3
- import { MENU_NO } from '@/constants'
4
- import * as formatters from '@/formatters'
5
- import { handleError } from '@/shared/utils/error-handler'
6
- import { formatOutput } from '@/shared/utils/output'
7
-
3
+ import { MENU_NO } from '../constants'
4
+ import * as formatters from '../formatters'
5
+ import { handleError } from '../shared/utils/error-handler'
6
+ import { formatOutput } from '../shared/utils/output'
8
7
  import { getHttpOrExit } from './helpers'
9
8
 
10
9
  type ListOptions = { page?: string; pretty?: boolean }
@@ -1,10 +1,9 @@
1
1
  import { Command } from 'commander'
2
2
 
3
- import * as formatters from '@/formatters'
4
- import { handleError } from '@/shared/utils/error-handler'
5
- import { formatOutput } from '@/shared/utils/output'
6
- import { buildRoomReservationPayload, resolveRoomId } from '@/shared/utils/swmaestro'
7
-
3
+ import * as formatters from '../formatters'
4
+ import { handleError } from '../shared/utils/error-handler'
5
+ import { formatOutput } from '../shared/utils/output'
6
+ import { buildRoomReservationPayload, resolveRoomId } from '../shared/utils/swmaestro'
8
7
  import { getHttpOrExit } from './helpers'
9
8
 
10
9
  type ListOptions = { date?: string; room?: string; pretty?: boolean }
@@ -1,10 +1,9 @@
1
1
  import { Command } from 'commander'
2
2
 
3
- import { MENU_NO } from '@/constants'
4
- import * as formatters from '@/formatters'
5
- import { handleError } from '@/shared/utils/error-handler'
6
- import { formatOutput } from '@/shared/utils/output'
7
-
3
+ import { MENU_NO } from '../constants'
4
+ import * as formatters from '../formatters'
5
+ import { handleError } from '../shared/utils/error-handler'
6
+ import { formatOutput } from '../shared/utils/output'
8
7
  import { getHttpOrExit } from './helpers'
9
8
 
10
9
  type ShowOptions = { pretty?: boolean }
@@ -3,7 +3,7 @@ import { mkdtemp, stat } from 'node:fs/promises'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
 
6
- import { CredentialManager } from '@/credential-manager'
6
+ import { CredentialManager } from './credential-manager'
7
7
 
8
8
  let createdDirs: string[] = []
9
9
 
@@ -3,7 +3,7 @@ import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
3
3
  import { homedir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
 
6
- import type { Credentials } from '@/types'
6
+ import type { Credentials } from './types'
7
7
 
8
8
  export interface CredentialConfig {
9
9
  credentials: Credentials | null
package/src/errors.ts ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Error thrown when authentication is required but not valid.
3
+ * Provides a clear message indicating the need to authenticate.
4
+ */
5
+ export class AuthenticationError extends Error {
6
+ constructor(message = 'Authentication required. Please login with: opensoma auth login or opensoma auth extract') {
7
+ super(message)
8
+ this.name = 'AuthenticationError'
9
+ }
10
+ }
@@ -14,7 +14,7 @@ import {
14
14
  parseRoomList,
15
15
  parseRoomSlots,
16
16
  parseTeamInfo,
17
- } from '@/formatters'
17
+ } from './formatters'
18
18
 
19
19
  describe('formatters', () => {
20
20
  test('parseMentoringList parses real list rows', () => {
package/src/formatters.ts CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  RoomCardSchema,
24
24
  type TeamInfo,
25
25
  TeamInfoSchema,
26
- } from '@/types'
26
+ } from './types'
27
27
 
28
28
  type LabelMap = Record<string, string>
29
29
 
@@ -53,17 +53,26 @@ export function parseMentoringDetail(html: string, id = 0): MentoringDetail {
53
53
  const labels = { ...extractLabelMap(root), ...extractGroupMap(root) }
54
54
  const rawTitle = labels['모집 명'] || labels['제목'] || cleanText(root.querySelector('h1, h2, .title'))
55
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')
56
+ const contentNode =
57
+ root.querySelector('.cont') ??
58
+ root.querySelector('.board-view-content') ??
59
+ root.querySelector('.view-content') ??
60
+ root.querySelector('.content-body') ??
61
+ root.querySelector('#contents')
57
62
 
58
63
  return MentoringDetailSchema.parse({
59
64
  id: id || extractNumber(root.querySelector('[name="qustnrSn"]')?.getAttribute('value') ?? ''),
60
65
  title: stripMentoringStatus(stripMentoringPrefix(rawTitle)),
61
- type: extractMentoringType(labels['유형'] || root.querySelector('[name="reportCd"]')?.getAttribute('value') || rawTitle),
66
+ type: extractMentoringType(
67
+ labels['유형'] || root.querySelector('[name="reportCd"]')?.getAttribute('value') || rawTitle,
68
+ ),
62
69
  registrationPeriod: extractDateRange(labels['접수 기간'] || labels['접수기간'] || ''),
63
70
  sessionDate: extractFirstDate(dateText),
64
71
  sessionTime: extractTimeRange(dateText),
65
72
  attendees: {
66
- current: extractNumber(labels['신청인원'] || labels['현재인원'] || cleanText(root.querySelector('.total-normal')) || ''),
73
+ current: extractNumber(
74
+ labels['신청인원'] || labels['현재인원'] || cleanText(root.querySelector('.total-normal')) || '',
75
+ ),
67
76
  max: extractNumber(labels['모집인원'] || labels['수강인원'] || ''),
68
77
  },
69
78
  approved: /OK/i.test(labels['개설 승인'] || labels['개설승인'] || ''),
@@ -161,7 +170,12 @@ export function parseNoticeDetail(html: string, id = 0): NoticeDetail {
161
170
  const spans = top?.querySelectorAll('.etc span') ?? []
162
171
  const author = extractPrefixedValue(spans, '작성자') || labels['작성자'] || ''
163
172
  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')
173
+ const contentNode =
174
+ root.querySelector('.bbs-view .cont') ??
175
+ root.querySelector('.board-view-content') ??
176
+ root.querySelector('.view-content') ??
177
+ root.querySelector('.content-body') ??
178
+ root.querySelector('#contents')
165
179
 
166
180
  return NoticeDetailSchema.parse({
167
181
  id: id || extractNumber(root.querySelector('[name="nttId"]')?.getAttribute('value') ?? ''),
@@ -314,28 +328,42 @@ function extractGroupMap(root: HTMLElement): LabelMap {
314
328
  function parseDashboardLinks(
315
329
  root: HTMLElement,
316
330
  predicate: (href: string) => boolean,
317
- ): Array<{ title: string; url: string; status: string }> {
331
+ ): Array<{
332
+ title: string
333
+ url: string
334
+ status: string
335
+ date?: string
336
+ time?: string
337
+ venue?: string
338
+ }> {
318
339
  return root
319
340
  .querySelectorAll('ul.bbs-dash_w a')
320
341
  .filter((link) => predicate(link.getAttribute('href') ?? ''))
321
342
  .map((link) => {
322
- const text = cleanText(link)
323
- return {
324
- title: stripTrailingStatus(text),
325
- url: link.getAttribute('href') ?? '',
326
- status: extractTrailingStatus(text),
327
- }
328
- })
343
+ const text = cleanText(link)
344
+ const { cleanTitle, date, time, venue } = extractDateTimeFromTitle(text)
345
+ return {
346
+ title: stripTrailingStatus(cleanTitle),
347
+ url: link.getAttribute('href') ?? '',
348
+ status: extractTrailingStatus(text),
349
+ date,
350
+ time,
351
+ venue,
352
+ }
353
+ })
329
354
  }
330
355
 
331
356
  function parseTimeSlotsFromRoot(root: HTMLElement): RoomCard['timeSlots'] {
332
357
  const grid = root.querySelector('.time-grid')
333
- const spans = grid ? grid.querySelectorAll('span') : root.querySelectorAll('.time-grid span, [class*="time-slot"], .slot')
358
+ const spans = grid
359
+ ? grid.querySelectorAll('span')
360
+ : root.querySelectorAll('.time-grid span, [class*="time-slot"], .slot')
334
361
 
335
362
  return spans
336
363
  .map((slot) => ({
337
364
  time: cleanText(slot),
338
- available: !(slot.getAttribute('class') ?? '').includes('not-reserve') &&
365
+ available:
366
+ !(slot.getAttribute('class') ?? '').includes('not-reserve') &&
339
367
  !(slot.getAttribute('class') ?? '').includes('booked') &&
340
368
  !(slot.getAttribute('class') ?? '').includes('disabled'),
341
369
  }))
@@ -349,12 +377,16 @@ function findDashboardValue(items: HTMLElement[], label: string): string {
349
377
 
350
378
  function extractDashEtcValue(container: HTMLElement | null | undefined, label: string): string {
351
379
  const match = (container?.querySelectorAll('span') ?? []).find((item) => cleanText(item).startsWith(label))
352
- return cleanText(match).replace(new RegExp(`^${escapeRegex(label)}\\s*:`), '').trim()
380
+ return cleanText(match)
381
+ .replace(new RegExp(`^${escapeRegex(label)}\\s*:`), '')
382
+ .trim()
353
383
  }
354
384
 
355
385
  function findListText(card: HTMLElement, label: string): string {
356
386
  const item = card.querySelectorAll('.txt > li').find((entry) => cleanText(entry).startsWith(label))
357
- return cleanText(item).replace(new RegExp(`^${escapeRegex(label)}\\s*:`), '').trim()
387
+ return cleanText(item)
388
+ .replace(new RegExp(`^${escapeRegex(label)}\\s*:`), '')
389
+ .trim()
358
390
  }
359
391
 
360
392
  function extractLocationHref(onclick: string | undefined): string {
@@ -440,6 +472,45 @@ function extractTrailingStatus(text: string): string {
440
472
  return match?.[1] ?? ''
441
473
  }
442
474
 
475
+ function extractDateTimeFromTitle(text: string): {
476
+ cleanTitle: string
477
+ date?: string
478
+ time?: string
479
+ venue?: string
480
+ } {
481
+ // Match date patterns: 2025-04-15 or 2025.04.15
482
+ const dateMatch = text.match(/(\d{4}[.-]\d{2}[.-]\d{2})/)
483
+ // Match time patterns: 14:00~16:00 or 14:00
484
+ const timeMatch = text.match(/(\d{2}:\d{2}(?:~\d{2}:\d{2})?)/)
485
+ // Match venue patterns: 스페이스 A1, A1, 강의실, etc.
486
+ const venueMatch = text.match(/(스페이스\s*[A-Z]\d+|강의실\s*\d+|회의실\s*[A-Z]?\d+|A\d|B\d|C\d)/i)
487
+
488
+ let cleanTitle = text
489
+ let date: string | undefined
490
+ let time: string | undefined
491
+ let venue: string | undefined
492
+
493
+ if (dateMatch) {
494
+ date = dateMatch[1].replace(/\./g, '-')
495
+ cleanTitle = cleanTitle.replace(dateMatch[0], '')
496
+ }
497
+
498
+ if (timeMatch) {
499
+ time = timeMatch[1]
500
+ cleanTitle = cleanTitle.replace(timeMatch[0], '')
501
+ }
502
+
503
+ if (venueMatch) {
504
+ venue = venueMatch[1]
505
+ cleanTitle = cleanTitle.replace(venueMatch[0], '')
506
+ }
507
+
508
+ // Clean up remaining whitespace and punctuation
509
+ cleanTitle = cleanTitle.replace(/^[\s\-~]+|[\s\-~]+$/g, '').trim()
510
+
511
+ return { cleanTitle, date, time, venue }
512
+ }
513
+
443
514
  function stripTrailingStatus(text: string): string {
444
515
  return text.replace(/\s*(예약완료|예약중|대기|접수중|마감|승인완료|신청완료)$/, '').trim()
445
516
  }
@@ -467,7 +538,9 @@ function escapeRegex(text: string): string {
467
538
 
468
539
  function extractPrefixedValue(nodes: HTMLElement[], label: string): string {
469
540
  const match = nodes.find((node) => cleanText(node).includes(label))
470
- return cleanText(match).replace(new RegExp(`^${escapeRegex(label)}\\s*:`), '').trim()
541
+ return cleanText(match)
542
+ .replace(new RegExp(`^${escapeRegex(label)}\\s*:`), '')
543
+ .trim()
471
544
  }
472
545
 
473
546
  function normalizeDate(value: string): string {
package/src/http.test.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { afterEach, describe, expect, mock, test } from 'bun:test'
2
2
 
3
- import { MENU_NO } from '@/constants'
4
- import { SomaHttp } from '@/http'
3
+ import { MENU_NO } from './constants'
4
+ import { SomaHttp } from './http'
5
5
 
6
6
  const originalFetch = globalThis.fetch
7
7
 
@@ -12,8 +12,13 @@ afterEach(() => {
12
12
 
13
13
  describe('SomaHttp', () => {
14
14
  test('get sends query params and stores cookies', async () => {
15
- const fetchMock = mock(async (input: RequestInfo | URL) => {
15
+ const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => {
16
16
  expect(String(input)).toBe(`https://www.swmaestro.ai/sw/member/user/forLogin.do?menuNo=${MENU_NO.LOGIN}`)
17
+ expect(init?.headers).toEqual({
18
+ 'Accept-Language': 'ko,en-US;q=0.9,en;q=0.8',
19
+ 'User-Agent':
20
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36',
21
+ })
17
22
  return createResponse('<html></html>', ['JSESSIONID=session-1; Path=/', 'XSRF-TOKEN=csrf-1; Path=/'])
18
23
  })
19
24
  globalThis.fetch = fetchMock as typeof fetch
@@ -30,6 +35,9 @@ describe('SomaHttp', () => {
30
35
  const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
31
36
  expect(init?.method).toBe('POST')
32
37
  expect(init?.headers).toEqual({
38
+ 'Accept-Language': 'ko,en-US;q=0.9,en;q=0.8',
39
+ 'User-Agent':
40
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36',
33
41
  cookie: 'JSESSIONID=session-1',
34
42
  'Content-Type': 'application/x-www-form-urlencoded',
35
43
  })
@@ -47,6 +55,9 @@ describe('SomaHttp', () => {
47
55
  test('postJson returns parsed json', async () => {
48
56
  const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
49
57
  expect(init?.headers).toEqual({
58
+ 'Accept-Language': 'ko,en-US;q=0.9,en;q=0.8',
59
+ 'User-Agent':
60
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36',
50
61
  cookie: 'JSESSIONID=session-1',
51
62
  Accept: 'application/json',
52
63
  'Content-Type': 'application/x-www-form-urlencoded',
@@ -56,7 +67,9 @@ describe('SomaHttp', () => {
56
67
  globalThis.fetch = fetchMock as typeof fetch
57
68
 
58
69
  const http = new SomaHttp({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
59
- const json = await http.postJson<{ resultCode: string }>('/mypage/officeMng/rentTime.do', { itemId: '17' })
70
+ const json = await http.postJson<{ resultCode: string }>('/mypage/officeMng/rentTime.do', {
71
+ itemId: '17',
72
+ })
60
73
 
61
74
  expect(json).toEqual({ resultCode: 'SUCCESS' })
62
75
  })
@@ -67,6 +80,11 @@ describe('SomaHttp', () => {
67
80
 
68
81
  if (url.includes('/forLogin.do')) {
69
82
  expect(init?.method).toBe('GET')
83
+ expect(init?.headers).toEqual({
84
+ 'Accept-Language': 'ko,en-US;q=0.9,en;q=0.8',
85
+ 'User-Agent':
86
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36',
87
+ })
70
88
  return createResponse('<form><input type="hidden" name="csrfToken" value="csrf-login"></form>', [
71
89
  'JSESSIONID=session-2; Path=/',
72
90
  ])
@@ -75,6 +93,9 @@ describe('SomaHttp', () => {
75
93
  expect(url).toBe('https://www.swmaestro.ai/sw/member/user/toLogin.do')
76
94
  expect(init?.method).toBe('POST')
77
95
  expect(init?.headers).toEqual({
96
+ 'Accept-Language': 'ko,en-US;q=0.9,en;q=0.8',
97
+ 'User-Agent':
98
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36',
78
99
  cookie: 'JSESSIONID=session-2',
79
100
  'Content-Type': 'application/x-www-form-urlencoded',
80
101
  })
@@ -96,16 +117,31 @@ describe('SomaHttp', () => {
96
117
  })
97
118
 
98
119
  test('checkLogin returns user identity when logged in, null otherwise', async () => {
99
- const loggedInMock = mock(async () =>
120
+ const loggedInMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) =>
100
121
  createResponse(
101
- JSON.stringify({ resultCode: 'fail', userVO: { userId: 'user@example.com', userNm: 'Test' } }),
122
+ JSON.stringify({
123
+ resultCode: 'fail',
124
+ userVO: { userId: 'user@example.com', userNm: 'Test' },
125
+ }),
102
126
  [],
103
127
  'application/json',
104
128
  ),
105
129
  )
106
130
  globalThis.fetch = loggedInMock as typeof fetch
107
131
 
108
- await expect(new SomaHttp().checkLogin()).resolves.toEqual({ userId: 'user@example.com', userNm: 'Test' })
132
+ await expect(new SomaHttp().checkLogin()).resolves.toEqual({
133
+ userId: 'user@example.com',
134
+ userNm: 'Test',
135
+ })
136
+ expect(loggedInMock).toHaveBeenCalledWith('https://www.swmaestro.ai/sw/member/user/checkLogin.json', {
137
+ method: 'GET',
138
+ headers: {
139
+ 'Accept-Language': 'ko,en-US;q=0.9,en;q=0.8',
140
+ 'User-Agent':
141
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36',
142
+ Accept: 'application/json',
143
+ },
144
+ })
109
145
 
110
146
  const notLoggedInMock = mock(async () =>
111
147
  createResponse(JSON.stringify({ resultCode: 'fail', userVO: { userId: '', userSn: 0 } }), [], 'application/json'),