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.
- package/dist/package.json +18 -2
- package/dist/src/client.d.ts +10 -0
- package/dist/src/client.d.ts.map +1 -1
- package/dist/src/client.js +139 -22
- package/dist/src/client.js.map +1 -1
- package/dist/src/commands/auth.d.ts +16 -0
- package/dist/src/commands/auth.d.ts.map +1 -1
- package/dist/src/commands/auth.js +105 -44
- package/dist/src/commands/auth.js.map +1 -1
- package/dist/src/commands/dashboard.d.ts.map +1 -1
- package/dist/src/commands/dashboard.js +1 -1
- package/dist/src/commands/dashboard.js.map +1 -1
- package/dist/src/commands/event.d.ts.map +1 -1
- package/dist/src/commands/event.js.map +1 -1
- package/dist/src/commands/helpers.d.ts +8 -0
- package/dist/src/commands/helpers.d.ts.map +1 -1
- package/dist/src/commands/helpers.js +35 -4
- package/dist/src/commands/helpers.js.map +1 -1
- package/dist/src/commands/member.d.ts.map +1 -1
- package/dist/src/commands/member.js.map +1 -1
- package/dist/src/commands/mentoring.d.ts.map +1 -1
- package/dist/src/commands/mentoring.js +14 -5
- package/dist/src/commands/mentoring.js.map +1 -1
- package/dist/src/commands/notice.d.ts.map +1 -1
- package/dist/src/commands/notice.js.map +1 -1
- package/dist/src/commands/room.d.ts.map +1 -1
- package/dist/src/commands/room.js.map +1 -1
- package/dist/src/commands/team.d.ts.map +1 -1
- package/dist/src/commands/team.js.map +1 -1
- package/dist/src/credential-manager.d.ts +7 -0
- package/dist/src/credential-manager.d.ts.map +1 -1
- package/dist/src/credential-manager.js +76 -2
- package/dist/src/credential-manager.js.map +1 -1
- package/dist/src/errors.d.ts +8 -0
- package/dist/src/errors.d.ts.map +1 -0
- package/dist/src/errors.js +11 -0
- package/dist/src/errors.js.map +1 -0
- package/dist/src/formatters.d.ts.map +1 -1
- package/dist/src/formatters.js +54 -7
- package/dist/src/formatters.js.map +1 -1
- package/dist/src/http.d.ts +6 -0
- package/dist/src/http.d.ts.map +1 -1
- package/dist/src/http.js +183 -8
- package/dist/src/http.js.map +1 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/session-recovery.d.ts +12 -0
- package/dist/src/session-recovery.d.ts.map +1 -0
- package/dist/src/session-recovery.js +34 -0
- package/dist/src/session-recovery.js.map +1 -0
- package/dist/src/shared/utils/mentoring-params.d.ts.map +1 -1
- package/dist/src/shared/utils/mentoring-params.js +4 -1
- package/dist/src/shared/utils/mentoring-params.js.map +1 -1
- package/dist/src/shared/utils/swmaestro.d.ts.map +1 -1
- package/dist/src/shared/utils/swmaestro.js.map +1 -1
- package/dist/src/token-extractor.d.ts +12 -0
- package/dist/src/token-extractor.d.ts.map +1 -1
- package/dist/src/token-extractor.js +81 -18
- package/dist/src/token-extractor.js.map +1 -1
- package/dist/src/types.d.ts +18 -0
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js +7 -0
- package/dist/src/types.js.map +1 -1
- package/package.json +17 -1
- package/src/client.test.ts +176 -12
- package/src/client.ts +154 -37
- package/src/commands/auth.test.ts +167 -0
- package/src/commands/auth.ts +140 -58
- package/src/commands/dashboard.ts +5 -6
- package/src/commands/event.ts +5 -6
- package/src/commands/helpers.test.ts +112 -0
- package/src/commands/helpers.ts +61 -6
- package/src/commands/member.ts +4 -5
- package/src/commands/mentoring.ts +36 -19
- package/src/commands/notice.ts +4 -5
- package/src/commands/room.ts +4 -5
- package/src/commands/team.ts +4 -5
- package/src/credential-manager.test.ts +42 -2
- package/src/credential-manager.ts +104 -3
- package/src/errors.ts +10 -0
- package/src/formatters.test.ts +1 -1
- package/src/formatters.ts +91 -18
- package/src/http.test.ts +75 -10
- package/src/http.ts +228 -10
- package/src/index.ts +1 -0
- package/src/session-recovery.ts +56 -0
- package/src/shared/utils/mentoring-params.test.ts +9 -4
- package/src/shared/utils/mentoring-params.ts +6 -3
- package/src/shared/utils/swmaestro.ts +2 -2
- package/src/token-extractor.test.ts +46 -8
- package/src/token-extractor.ts +115 -22
- package/src/types.test.ts +4 -2
- package/src/types.ts +7 -0
- package/.claude-plugin/README.md +0 -145
- package/.claude-plugin/plugin.json +0 -23
- package/.github/workflows/release.yml +0 -86
- package/.oxfmtrc.json +0 -9
- package/.oxlintrc.json +0 -4
- package/AGENTS.md +0 -78
- package/README.md +0 -252
- package/bun.lock +0 -297
- package/bunfig.toml +0 -2
- package/e2e/.gitkeep +0 -0
- package/skills/opensoma/SKILL.md +0 -345
- package/skills/opensoma/references/common-patterns.md +0 -182
- 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 '
|
|
4
|
-
import * as formatters from '
|
|
5
|
-
import { handleError } from '
|
|
6
|
-
import {
|
|
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 '
|
|
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 = {
|
|
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(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
{
|
|
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', {
|
|
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
|
-
{
|
|
151
|
+
{
|
|
152
|
+
items: formatters.parseApplicationHistory(html),
|
|
153
|
+
pagination: formatters.parsePagination(html),
|
|
154
|
+
},
|
|
138
155
|
options.pretty,
|
|
139
156
|
),
|
|
140
157
|
)
|
package/src/commands/notice.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { Command } from 'commander'
|
|
2
2
|
|
|
3
|
-
import { MENU_NO } from '
|
|
4
|
-
import * as formatters from '
|
|
5
|
-
import { handleError } from '
|
|
6
|
-
import { formatOutput } from '
|
|
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 }
|
package/src/commands/room.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { Command } from 'commander'
|
|
2
2
|
|
|
3
|
-
import * as formatters from '
|
|
4
|
-
import { handleError } from '
|
|
5
|
-
import { formatOutput } from '
|
|
6
|
-
import { buildRoomReservationPayload, resolveRoomId } from '
|
|
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 }
|
package/src/commands/team.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { Command } from 'commander'
|
|
2
2
|
|
|
3
|
-
import { MENU_NO } from '
|
|
4
|
-
import * as formatters from '
|
|
5
|
-
import { handleError } from '
|
|
6
|
-
import { formatOutput } from '
|
|
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 '
|
|
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 '
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|
package/src/formatters.test.ts
CHANGED
package/src/formatters.ts
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
RoomCardSchema,
|
|
24
24
|
type TeamInfo,
|
|
25
25
|
TeamInfoSchema,
|
|
26
|
-
} from '
|
|
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 =
|
|
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(
|
|
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(
|
|
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 =
|
|
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<{
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
|
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:
|
|
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)
|
|
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)
|
|
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)
|
|
541
|
+
return cleanText(match)
|
|
542
|
+
.replace(new RegExp(`^${escapeRegex(label)}\\s*:`), '')
|
|
543
|
+
.trim()
|
|
471
544
|
}
|
|
472
545
|
|
|
473
546
|
function normalizeDate(value: string): string {
|