opensoma 0.1.3 → 0.2.1

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 (108) hide show
  1. package/dist/package.json +18 -2
  2. package/dist/src/client.d.ts +10 -0
  3. package/dist/src/client.d.ts.map +1 -1
  4. package/dist/src/client.js +139 -22
  5. package/dist/src/client.js.map +1 -1
  6. package/dist/src/commands/auth.d.ts +16 -0
  7. package/dist/src/commands/auth.d.ts.map +1 -1
  8. package/dist/src/commands/auth.js +105 -44
  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 +8 -0
  16. package/dist/src/commands/helpers.d.ts.map +1 -1
  17. package/dist/src/commands/helpers.js +35 -4
  18. package/dist/src/commands/helpers.js.map +1 -1
  19. package/dist/src/commands/member.d.ts.map +1 -1
  20. package/dist/src/commands/member.js.map +1 -1
  21. package/dist/src/commands/mentoring.d.ts.map +1 -1
  22. package/dist/src/commands/mentoring.js +14 -5
  23. package/dist/src/commands/mentoring.js.map +1 -1
  24. package/dist/src/commands/notice.d.ts.map +1 -1
  25. package/dist/src/commands/notice.js.map +1 -1
  26. package/dist/src/commands/room.d.ts.map +1 -1
  27. package/dist/src/commands/room.js.map +1 -1
  28. package/dist/src/commands/team.d.ts.map +1 -1
  29. package/dist/src/commands/team.js.map +1 -1
  30. package/dist/src/credential-manager.d.ts +7 -0
  31. package/dist/src/credential-manager.d.ts.map +1 -1
  32. package/dist/src/credential-manager.js +76 -2
  33. package/dist/src/credential-manager.js.map +1 -1
  34. package/dist/src/errors.d.ts +8 -0
  35. package/dist/src/errors.d.ts.map +1 -0
  36. package/dist/src/errors.js +11 -0
  37. package/dist/src/errors.js.map +1 -0
  38. package/dist/src/formatters.d.ts.map +1 -1
  39. package/dist/src/formatters.js +54 -7
  40. package/dist/src/formatters.js.map +1 -1
  41. package/dist/src/http.d.ts +6 -0
  42. package/dist/src/http.d.ts.map +1 -1
  43. package/dist/src/http.js +183 -8
  44. package/dist/src/http.js.map +1 -1
  45. package/dist/src/index.d.ts +1 -0
  46. package/dist/src/index.d.ts.map +1 -1
  47. package/dist/src/index.js +1 -0
  48. package/dist/src/index.js.map +1 -1
  49. package/dist/src/session-recovery.d.ts +12 -0
  50. package/dist/src/session-recovery.d.ts.map +1 -0
  51. package/dist/src/session-recovery.js +34 -0
  52. package/dist/src/session-recovery.js.map +1 -0
  53. package/dist/src/shared/utils/mentoring-params.d.ts.map +1 -1
  54. package/dist/src/shared/utils/mentoring-params.js +4 -1
  55. package/dist/src/shared/utils/mentoring-params.js.map +1 -1
  56. package/dist/src/shared/utils/swmaestro.d.ts.map +1 -1
  57. package/dist/src/shared/utils/swmaestro.js.map +1 -1
  58. package/dist/src/token-extractor.d.ts +12 -0
  59. package/dist/src/token-extractor.d.ts.map +1 -1
  60. package/dist/src/token-extractor.js +81 -18
  61. package/dist/src/token-extractor.js.map +1 -1
  62. package/dist/src/types.d.ts +18 -0
  63. package/dist/src/types.d.ts.map +1 -1
  64. package/dist/src/types.js +7 -0
  65. package/dist/src/types.js.map +1 -1
  66. package/package.json +17 -1
  67. package/src/client.test.ts +176 -12
  68. package/src/client.ts +154 -37
  69. package/src/commands/auth.test.ts +167 -0
  70. package/src/commands/auth.ts +140 -58
  71. package/src/commands/dashboard.ts +5 -6
  72. package/src/commands/event.ts +5 -6
  73. package/src/commands/helpers.test.ts +112 -0
  74. package/src/commands/helpers.ts +61 -6
  75. package/src/commands/member.ts +4 -5
  76. package/src/commands/mentoring.ts +36 -19
  77. package/src/commands/notice.ts +4 -5
  78. package/src/commands/room.ts +4 -5
  79. package/src/commands/team.ts +4 -5
  80. package/src/credential-manager.test.ts +42 -2
  81. package/src/credential-manager.ts +104 -3
  82. package/src/errors.ts +10 -0
  83. package/src/formatters.test.ts +1 -1
  84. package/src/formatters.ts +91 -18
  85. package/src/http.test.ts +75 -10
  86. package/src/http.ts +228 -10
  87. package/src/index.ts +1 -0
  88. package/src/session-recovery.ts +56 -0
  89. package/src/shared/utils/mentoring-params.test.ts +9 -4
  90. package/src/shared/utils/mentoring-params.ts +6 -3
  91. package/src/shared/utils/swmaestro.ts +2 -2
  92. package/src/token-extractor.test.ts +46 -8
  93. package/src/token-extractor.ts +115 -22
  94. package/src/types.test.ts +4 -2
  95. package/src/types.ts +7 -0
  96. package/.claude-plugin/README.md +0 -145
  97. package/.claude-plugin/plugin.json +0 -23
  98. package/.github/workflows/release.yml +0 -86
  99. package/.oxfmtrc.json +0 -9
  100. package/.oxlintrc.json +0 -4
  101. package/AGENTS.md +0 -78
  102. package/README.md +0 -252
  103. package/bun.lock +0 -297
  104. package/bunfig.toml +0 -2
  105. package/e2e/.gitkeep +0 -0
  106. package/skills/opensoma/SKILL.md +0 -345
  107. package/skills/opensoma/references/common-patterns.md +0 -182
  108. package/skills/opensoma/references/output-format.md +0 -130
@@ -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 }
@@ -1,9 +1,9 @@
1
1
  import { afterEach, describe, expect, test } from 'bun:test'
2
- import { mkdtemp, stat } from 'node:fs/promises'
2
+ import { mkdtemp, readFile, rm, 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
 
@@ -31,6 +31,7 @@ describe('CredentialManager', () => {
31
31
  sessionCookie: 'session-value',
32
32
  csrfToken: 'csrf-value',
33
33
  username: 'neo@example.com',
34
+ password: 'secret-password',
34
35
  loggedInAt: '2026-04-09T00:00:00.000Z',
35
36
  })
36
37
 
@@ -38,11 +39,18 @@ describe('CredentialManager', () => {
38
39
  sessionCookie: 'session-value',
39
40
  csrfToken: 'csrf-value',
40
41
  username: 'neo@example.com',
42
+ password: 'secret-password',
41
43
  loggedInAt: '2026-04-09T00:00:00.000Z',
42
44
  })
43
45
 
46
+ const rawContent = await readFile(join(dir, 'credentials.json'), 'utf8')
47
+ expect(rawContent).not.toContain('secret-password')
48
+
44
49
  const fileStat = await stat(join(dir, 'credentials.json'))
45
50
  expect(fileStat.mode & 0o777).toBe(0o600)
51
+
52
+ const keyFileStat = await stat(join(dir, 'credentials.key'))
53
+ expect(keyFileStat.mode & 0o777).toBe(0o600)
46
54
  })
47
55
 
48
56
  test('removes credentials file', async () => {
@@ -57,6 +65,38 @@ describe('CredentialManager', () => {
57
65
 
58
66
  await expect(manager.getCredentials()).resolves.toBeNull()
59
67
  })
68
+
69
+ test('preserves session credentials when the encryption key is missing', async () => {
70
+ const dir = await makeTempDir()
71
+ const manager = new CredentialManager(dir)
72
+
73
+ await manager.setCredentials({
74
+ sessionCookie: 'session-value',
75
+ csrfToken: 'csrf-value',
76
+ username: 'neo@example.com',
77
+ password: 'secret-password',
78
+ loggedInAt: '2026-04-09T00:00:00.000Z',
79
+ })
80
+
81
+ await new CredentialManager(dir).remove()
82
+ await manager.save({
83
+ credentials: {
84
+ sessionCookie: 'session-value',
85
+ csrfToken: 'csrf-value',
86
+ username: 'neo@example.com',
87
+ password: 'secret-password',
88
+ loggedInAt: '2026-04-09T00:00:00.000Z',
89
+ },
90
+ })
91
+ await rm(join(dir, 'credentials.key'))
92
+
93
+ await expect(manager.getCredentials()).resolves.toEqual({
94
+ sessionCookie: 'session-value',
95
+ csrfToken: 'csrf-value',
96
+ username: 'neo@example.com',
97
+ loggedInAt: '2026-04-09T00:00:00.000Z',
98
+ })
99
+ })
60
100
  })
61
101
 
62
102
  async function makeTempDir(): Promise<string> {
@@ -1,9 +1,20 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
1
2
  import { existsSync } from 'node:fs'
2
3
  import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
3
4
  import { homedir } from 'node:os'
4
5
  import { join } from 'node:path'
5
6
 
6
- import type { Credentials } from '@/types'
7
+ import type { Credentials } from './types'
8
+
9
+ interface EncryptedSecret {
10
+ ciphertext: string
11
+ iv: string
12
+ tag: string
13
+ }
14
+
15
+ interface StoredCredentials extends Omit<Credentials, 'password'> {
16
+ encryptedPassword?: EncryptedSecret
17
+ }
7
18
 
8
19
  export interface CredentialConfig {
9
20
  credentials: Credentials | null
@@ -12,10 +23,12 @@ export interface CredentialConfig {
12
23
  export class CredentialManager {
13
24
  private configDir: string
14
25
  private credentialsPath: string
26
+ private encryptionKeyPath: string
15
27
 
16
28
  constructor(configDir?: string) {
17
29
  this.configDir = configDir ?? join(homedir(), '.config', 'opensoma')
18
30
  this.credentialsPath = join(this.configDir, 'credentials.json')
31
+ this.encryptionKeyPath = join(this.configDir, 'credentials.key')
19
32
  }
20
33
 
21
34
  async load(): Promise<CredentialConfig> {
@@ -25,7 +38,10 @@ export class CredentialManager {
25
38
 
26
39
  try {
27
40
  const content = await readFile(this.credentialsPath, 'utf8')
28
- return JSON.parse(content) as CredentialConfig
41
+ const parsed = JSON.parse(content) as { credentials: StoredCredentials | null }
42
+ return {
43
+ credentials: parsed.credentials ? await this.hydrateCredentials(parsed.credentials) : null,
44
+ }
29
45
  } catch {
30
46
  return { credentials: null }
31
47
  }
@@ -33,7 +49,16 @@ export class CredentialManager {
33
49
 
34
50
  async save(config: CredentialConfig): Promise<void> {
35
51
  await mkdir(this.configDir, { recursive: true })
36
- await writeFile(this.credentialsPath, JSON.stringify(config, null, 2))
52
+ await writeFile(
53
+ this.credentialsPath,
54
+ JSON.stringify(
55
+ {
56
+ credentials: config.credentials ? await this.serializeCredentials(config.credentials) : null,
57
+ },
58
+ null,
59
+ 2,
60
+ ),
61
+ )
37
62
  await chmod(this.credentialsPath, 0o600)
38
63
  }
39
64
 
@@ -48,5 +73,81 @@ export class CredentialManager {
48
73
 
49
74
  async remove(): Promise<void> {
50
75
  await rm(this.credentialsPath, { force: true })
76
+ await rm(this.encryptionKeyPath, { force: true })
77
+ }
78
+
79
+ private async hydrateCredentials(credentials: StoredCredentials): Promise<Credentials> {
80
+ if (!credentials.encryptedPassword) {
81
+ return credentials
82
+ }
83
+
84
+ const { encryptedPassword, ...rest } = credentials
85
+ const password = await this.decryptSecret(encryptedPassword)
86
+ return password ? { ...rest, password } : rest
87
+ }
88
+
89
+ private async serializeCredentials(credentials: Credentials): Promise<StoredCredentials> {
90
+ if (!credentials.password) {
91
+ return credentials
92
+ }
93
+
94
+ const { password, ...rest } = credentials
95
+ return {
96
+ ...rest,
97
+ encryptedPassword: await this.encryptSecret(password),
98
+ }
99
+ }
100
+
101
+ private async encryptSecret(secret: string): Promise<EncryptedSecret> {
102
+ const key = await this.getOrCreateEncryptionKey()
103
+ const iv = randomBytes(12)
104
+ const cipher = createCipheriv('aes-256-gcm', key, iv)
105
+ const ciphertext = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()])
106
+
107
+ return {
108
+ ciphertext: ciphertext.toString('base64'),
109
+ iv: iv.toString('base64'),
110
+ tag: cipher.getAuthTag().toString('base64'),
111
+ }
112
+ }
113
+
114
+ private async decryptSecret(secret: EncryptedSecret): Promise<string> {
115
+ const key = await this.getExistingEncryptionKey()
116
+ if (!key) {
117
+ return ''
118
+ }
119
+
120
+ try {
121
+ const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(secret.iv, 'base64'))
122
+ decipher.setAuthTag(Buffer.from(secret.tag, 'base64'))
123
+ const decrypted = Buffer.concat([decipher.update(Buffer.from(secret.ciphertext, 'base64')), decipher.final()])
124
+ return decrypted.toString('utf8')
125
+ } catch {
126
+ return ''
127
+ }
128
+ }
129
+
130
+ private async getOrCreateEncryptionKey(): Promise<Buffer> {
131
+ if (existsSync(this.encryptionKeyPath)) {
132
+ return Buffer.from(await readFile(this.encryptionKeyPath, 'utf8'), 'base64')
133
+ }
134
+
135
+ await mkdir(this.configDir, { recursive: true })
136
+ const key = randomBytes(32)
137
+ await writeFile(this.encryptionKeyPath, key.toString('base64'))
138
+ await chmod(this.encryptionKeyPath, 0o600)
139
+ return key
140
+ }
141
+
142
+ private async getExistingEncryptionKey(): Promise<Buffer | null> {
143
+ if (!existsSync(this.encryptionKeyPath)) {
144
+ return null
145
+ }
146
+
147
+ try {
148
+ return Buffer.from(await readFile(this.encryptionKeyPath, 'utf8'), 'base64')
149
+ } catch {
150
+ return null
151
+ }
51
152
  }
52
153
  }
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 {