blackboard-upc 1.0.0 → 1.0.2
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/.mcp.json +8 -0
- package/CHANGELOG.md +13 -1
- package/README.md +11 -9
- package/package.json +1 -1
- package/src/api/courses.ts +2 -13
- package/src/commands/assignments.ts +13 -12
- package/src/commands/courses.ts +57 -0
- package/src/mcp/server.ts +89 -10
package/.mcp.json
ADDED
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,18 @@ All notable changes to `blackboard-upc` will be documented here.
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
## [1.0.1] — 2026-03-30
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- `courses members <courseId>` — lista compañeros e instructor de un curso (con `--role` y `--json`)
|
|
11
|
+
|
|
12
|
+
### Improved
|
|
13
|
+
- `courses list` — usa `expand=course` en una sola llamada en vez de 1+N (antes: 1 llamada por curso)
|
|
14
|
+
- `assignments list` — usa bulk grades (`/gradebook/users/{id}`) en paralelo con columns, eliminando N llamadas individuales
|
|
15
|
+
- `courses members` — usa `expand=user` para traer nombres en una sola llamada
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
7
19
|
## [1.0.0] — 2026-03-30
|
|
8
20
|
|
|
9
21
|
### Added
|
|
@@ -53,7 +65,7 @@ All notable changes to `blackboard-upc` will be documented here.
|
|
|
53
65
|
|
|
54
66
|
## Roadmap
|
|
55
67
|
|
|
56
|
-
- [
|
|
68
|
+
- [x] `npx` install sin clonar repo (publicación en npm)
|
|
57
69
|
- [ ] Refresh automático de sesión antes de expirar
|
|
58
70
|
- [ ] Notificaciones de entregas próximas (`assignments due`)
|
|
59
71
|
- [ ] Descarga de videos de grabaciones de clase
|
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ npm install -g blackboard-upc
|
|
|
21
21
|
blackboard login
|
|
22
22
|
|
|
23
23
|
# Opción 3 — clonar el repo
|
|
24
|
-
git clone https://github.com/
|
|
24
|
+
git clone https://github.com/alejooroncoy/blackboard-cli
|
|
25
25
|
cd blackboard-cli
|
|
26
26
|
npm install
|
|
27
27
|
npx playwright install chromium
|
|
@@ -40,13 +40,15 @@ blackboard login
|
|
|
40
40
|
|
|
41
41
|
Se abre una ventana del navegador con el login de Microsoft UPC. Inicia sesión con tu cuenta `u20XXXXXXX@upc.edu.pe` (incluye MFA si lo tienes). La ventana se cierra sola y la sesión queda guardada 8 horas.
|
|
42
42
|
|
|
43
|
+
> **Importante:** durante el login, Microsoft mostrará el mensaje **"Stay signed in?"** con un checkbox **"Don't show this again"**. Activa ese checkbox y haz clic en **Yes** — esto le indica a Microsoft que mantenga la sesión activa y es necesario para que el CLI pueda guardar las cookies correctamente.
|
|
44
|
+
|
|
43
45
|
```
|
|
44
46
|
██████ ██ █████ ██████ ██ ██ ██████ ██████ █████ ██████ ██████
|
|
45
47
|
...
|
|
46
48
|
CLI no oficial para UPC Aula Virtual · Blackboard Learn
|
|
47
49
|
|
|
48
50
|
✓ Sesión guardada — expira en 8 horas
|
|
49
|
-
Usuario:
|
|
51
|
+
Usuario: Juan Pérez García
|
|
50
52
|
|
|
51
53
|
¿Qué puedo hacer ahora?
|
|
52
54
|
|
|
@@ -61,15 +63,15 @@ Se abre una ventana del navegador con el login de Microsoft UPC. Inicia sesión
|
|
|
61
63
|
```bash
|
|
62
64
|
blackboard courses list
|
|
63
65
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
_100001_1 Cálculo Diferencial e Integral [Ultra]
|
|
67
|
+
_100002_1 Programación Orientada a Objetos [Ultra]
|
|
68
|
+
_100003_1 Bases de Datos [Ultra]
|
|
69
|
+
_100004_1 Algoritmos y Estructuras de Datos [Ultra]
|
|
68
70
|
|
|
69
|
-
blackboard assignments list
|
|
71
|
+
blackboard assignments list _100004_1
|
|
70
72
|
|
|
71
|
-
|
|
72
|
-
Nota: sin entregar · Máx:
|
|
73
|
+
_200001_1 Tarea 1 [manual]
|
|
74
|
+
Nota: sin entregar · Máx: 5 pts · Entrega: 15/04/2026 (vence en 17d)
|
|
73
75
|
```
|
|
74
76
|
|
|
75
77
|
---
|
package/package.json
CHANGED
package/src/api/courses.ts
CHANGED
|
@@ -14,20 +14,9 @@ export async function getMyCourses(
|
|
|
14
14
|
const params: Record<string, any> = { limit: opts.limit ?? 50 };
|
|
15
15
|
if (opts.offset) params.offset = opts.offset;
|
|
16
16
|
|
|
17
|
+
params.expand = 'course';
|
|
17
18
|
const r = await client.get(`/learn/api/public/v1/users/${userId}/courses`, { params });
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
// Resolve course names in parallel
|
|
21
|
-
const courseDetails = await Promise.allSettled(
|
|
22
|
-
memberships.map((m) => getCourse(client, m.courseId))
|
|
23
|
-
);
|
|
24
|
-
|
|
25
|
-
const results = memberships.map((m, i) => ({
|
|
26
|
-
...m,
|
|
27
|
-
course: courseDetails[i].status === 'fulfilled' ? courseDetails[i].value : undefined,
|
|
28
|
-
}));
|
|
29
|
-
|
|
30
|
-
return { results, paging: r.data.paging };
|
|
19
|
+
return { results: r.data.results, paging: r.data.paging };
|
|
31
20
|
}
|
|
32
21
|
|
|
33
22
|
export async function getCourse(client: AxiosInstance, courseId: string): Promise<Course> {
|
|
@@ -61,7 +61,16 @@ export function assignmentsCommand(program: Command) {
|
|
|
61
61
|
let userId = session.userId;
|
|
62
62
|
if (!userId) { const me = await getMe(client); userId = me.id; }
|
|
63
63
|
|
|
64
|
-
const columns = await
|
|
64
|
+
const [columns, gradesRes] = await Promise.all([
|
|
65
|
+
listAssignments(client, courseId),
|
|
66
|
+
client
|
|
67
|
+
.get(`/learn/api/public/v1/courses/${courseId}/gradebook/users/${userId}`, {
|
|
68
|
+
params: { limit: 200 },
|
|
69
|
+
})
|
|
70
|
+
.then((r) => r.data.results as any[])
|
|
71
|
+
.catch(() => [] as any[]),
|
|
72
|
+
]);
|
|
73
|
+
|
|
65
74
|
spinner.succeed(`${columns.length} assignments found`);
|
|
66
75
|
|
|
67
76
|
if (opts.json) { console.log(JSON.stringify(columns, null, 2)); return; }
|
|
@@ -71,19 +80,11 @@ export function assignmentsCommand(program: Command) {
|
|
|
71
80
|
return;
|
|
72
81
|
}
|
|
73
82
|
|
|
74
|
-
|
|
75
|
-
const gradeResults = await Promise.allSettled(
|
|
76
|
-
columns.map((c) =>
|
|
77
|
-
client
|
|
78
|
-
.get(`/learn/api/public/v1/courses/${courseId}/gradebook/users/${userId}/columns/${c.id}`)
|
|
79
|
-
.then((r) => r.data)
|
|
80
|
-
.catch(() => null)
|
|
81
|
-
)
|
|
82
|
-
);
|
|
83
|
+
const gradeMap = new Map(gradesRes.map((g: any) => [g.columnId, g]));
|
|
83
84
|
|
|
84
85
|
console.log('');
|
|
85
|
-
columns.forEach((col
|
|
86
|
-
const grade =
|
|
86
|
+
columns.forEach((col) => {
|
|
87
|
+
const grade = gradeMap.get(col.id) ?? null;
|
|
87
88
|
const possible = col.score?.possible ?? '?';
|
|
88
89
|
const due = col.grading?.due;
|
|
89
90
|
const attemptsAllowed = col.grading?.attemptsAllowed === 0
|
package/src/commands/courses.ts
CHANGED
|
@@ -231,6 +231,63 @@ export function coursesCommand(program: Command) {
|
|
|
231
231
|
}
|
|
232
232
|
});
|
|
233
233
|
|
|
234
|
+
// Members
|
|
235
|
+
courses
|
|
236
|
+
.command('members <courseId>')
|
|
237
|
+
.description('List students and instructors in a course')
|
|
238
|
+
.option('--json', 'Output raw JSON')
|
|
239
|
+
.option('--role <role>', 'Filter by role: Student, Instructor')
|
|
240
|
+
.action(async (courseId, opts) => {
|
|
241
|
+
const session = requireSession();
|
|
242
|
+
const client = createClient(session);
|
|
243
|
+
const spinner = ora({ text: 'Fetching course members...', stream: process.stderr }).start();
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
const res = await client.get(
|
|
247
|
+
`/learn/api/public/v1/courses/${courseId}/users?expand=user&limit=200`
|
|
248
|
+
);
|
|
249
|
+
let members: any[] = res.data.results ?? [];
|
|
250
|
+
|
|
251
|
+
if (opts.role) {
|
|
252
|
+
const roleFilter = opts.role.toLowerCase();
|
|
253
|
+
members = members.filter((m: any) => m.courseRoleId?.toLowerCase() === roleFilter);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
spinner.succeed(`${members.length} members`);
|
|
257
|
+
|
|
258
|
+
if (opts.json) {
|
|
259
|
+
console.log(JSON.stringify({ results: members }, null, 2));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const students = members.filter((m: any) => m.courseRoleId === 'Student');
|
|
264
|
+
const instructors = members.filter((m: any) => m.courseRoleId !== 'Student');
|
|
265
|
+
|
|
266
|
+
console.log('');
|
|
267
|
+
if (instructors.length > 0) {
|
|
268
|
+
console.log(chalk.bold(' Instructores'));
|
|
269
|
+
for (const m of instructors) {
|
|
270
|
+
const name = m.user ? `${m.user.name.given} ${m.user.name.family}` : m.userId;
|
|
271
|
+
console.log(` ${chalk.yellow(name)}`);
|
|
272
|
+
}
|
|
273
|
+
console.log('');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (students.length > 0) {
|
|
277
|
+
console.log(chalk.bold(` Estudiantes (${students.length})`));
|
|
278
|
+
students.forEach((m: any, i: number) => {
|
|
279
|
+
const name = m.user ? `${m.user.name.given} ${m.user.name.family}` : m.userId;
|
|
280
|
+
const num = chalk.gray(`${String(i + 1).padStart(2, ' ')}.`);
|
|
281
|
+
console.log(` ${num} ${name}`);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
console.log('');
|
|
285
|
+
} catch (err: any) {
|
|
286
|
+
spinner.fail(err.message);
|
|
287
|
+
process.exit(1);
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
234
291
|
// Grades
|
|
235
292
|
courses
|
|
236
293
|
.command('grades <courseId>')
|
package/src/mcp/server.ts
CHANGED
|
@@ -143,37 +143,116 @@ export async function startMcpServer() {
|
|
|
143
143
|
// ── download_attachment ─────────────────────────────────────────────────────
|
|
144
144
|
server.tool(
|
|
145
145
|
'download_attachment',
|
|
146
|
-
'Download a file from a course content item. Returns base64-encoded content.',
|
|
146
|
+
'Download a file from a course content item. attachmentId can be a Blackboard attachment ID (for x-bb-file) or a full bbcswebdav URL (for x-bb-document embedded files). Returns base64-encoded content.',
|
|
147
147
|
{
|
|
148
148
|
courseId: z.string().describe('Blackboard course ID'),
|
|
149
149
|
contentId: z.string().describe('Content item ID'),
|
|
150
|
-
attachmentId: z.string().describe('Attachment ID from list_attachments'),
|
|
150
|
+
attachmentId: z.string().describe('Attachment ID from list_attachments, or a full bbcswebdav URL for embedded files'),
|
|
151
151
|
},
|
|
152
152
|
async ({ courseId, contentId, attachmentId }) => {
|
|
153
153
|
const { client } = getClient();
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
154
|
+
|
|
155
|
+
// If attachmentId is a full URL (embedded file), download directly
|
|
156
|
+
const url = attachmentId.startsWith('http')
|
|
157
|
+
? attachmentId
|
|
158
|
+
: `/learn/api/public/v1/courses/${courseId}/contents/${contentId}/attachments/${attachmentId}/download`;
|
|
159
|
+
|
|
160
|
+
const r = await client.get(url, { responseType: 'arraybuffer', headers: { Accept: '*/*' } });
|
|
158
161
|
const b64 = Buffer.from(r.data).toString('base64');
|
|
159
|
-
|
|
162
|
+
const contentDisposition = r.headers['content-disposition'] as string | undefined;
|
|
163
|
+
const filename = contentDisposition
|
|
164
|
+
? (contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/))?.[1]?.replace(/['"]/g, '').trim()
|
|
165
|
+
: undefined;
|
|
166
|
+
const mimeType = (r.headers['content-type'] as string | undefined) ?? 'application/octet-stream';
|
|
167
|
+
return {
|
|
168
|
+
content: [{
|
|
169
|
+
type: 'text',
|
|
170
|
+
text: JSON.stringify({ filename: filename ?? 'file', mimeType, size: r.data.byteLength, base64: b64 }, null, 2),
|
|
171
|
+
}],
|
|
172
|
+
};
|
|
160
173
|
}
|
|
161
174
|
);
|
|
162
175
|
|
|
163
176
|
// ── list_attachments ────────────────────────────────────────────────────────
|
|
164
177
|
server.tool(
|
|
165
178
|
'list_attachments',
|
|
166
|
-
'List file attachments for a course content item',
|
|
179
|
+
'List file attachments for a course content item. Works for x-bb-file (REST API) and x-bb-document (embedded files in body HTML).',
|
|
167
180
|
{
|
|
168
181
|
courseId: z.string().describe('Blackboard course ID'),
|
|
169
182
|
contentId: z.string().describe('Content item ID'),
|
|
170
183
|
},
|
|
171
184
|
async ({ courseId, contentId }) => {
|
|
172
185
|
const { client } = getClient();
|
|
186
|
+
|
|
187
|
+
// Try standard REST attachments endpoint first (works for x-bb-file)
|
|
188
|
+
try {
|
|
189
|
+
const r = await client.get(
|
|
190
|
+
`/learn/api/public/v1/courses/${courseId}/contents/${contentId}/attachments`
|
|
191
|
+
);
|
|
192
|
+
return { content: [{ type: 'text', text: JSON.stringify(r.data, null, 2) }] };
|
|
193
|
+
} catch (err: any) {
|
|
194
|
+
if (err.response?.status !== 400 && err.response?.status !== 404) throw err;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Fallback: fetch content and parse embedded files from body HTML (x-bb-document, x-bb-lesson)
|
|
173
198
|
const r = await client.get(
|
|
174
|
-
`/learn/api/public/v1/courses/${courseId}/contents/${contentId}
|
|
199
|
+
`/learn/api/public/v1/courses/${courseId}/contents/${contentId}`
|
|
175
200
|
);
|
|
176
|
-
|
|
201
|
+
const body: string = r.data?.body ?? '';
|
|
202
|
+
const matches = [...body.matchAll(/data-bbfile="([^"]+)"/g)];
|
|
203
|
+
const files = matches.map((m) => {
|
|
204
|
+
try {
|
|
205
|
+
const meta = JSON.parse(m[1].replace(/"/g, '"'));
|
|
206
|
+
return {
|
|
207
|
+
type: 'embedded',
|
|
208
|
+
displayName: meta.displayName ?? meta.linkName ?? 'unknown',
|
|
209
|
+
mimeType: meta.mimeType ?? 'application/octet-stream',
|
|
210
|
+
resourceUrl: meta.resourceUrl ?? null,
|
|
211
|
+
};
|
|
212
|
+
} catch {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}).filter(Boolean);
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
content: [{
|
|
219
|
+
type: 'text',
|
|
220
|
+
text: JSON.stringify(
|
|
221
|
+
{ type: 'embedded_files', note: 'Use download_file_url with resourceUrl to download', results: files },
|
|
222
|
+
null, 2
|
|
223
|
+
),
|
|
224
|
+
}],
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
// ── download_file_url ───────────────────────────────────────────────────────
|
|
230
|
+
server.tool(
|
|
231
|
+
'download_file_url',
|
|
232
|
+
'Download a file directly from a Blackboard bbcswebdav URL (for x-bb-document embedded files). Returns base64-encoded content and filename.',
|
|
233
|
+
{
|
|
234
|
+
url: z.string().describe('Direct file URL from bbcswebdav (resourceUrl from content body)'),
|
|
235
|
+
filename: z.string().optional().describe('Desired filename for saving the file'),
|
|
236
|
+
},
|
|
237
|
+
async ({ url, filename }) => {
|
|
238
|
+
const { client } = getClient();
|
|
239
|
+
const r = await client.get(url, {
|
|
240
|
+
responseType: 'arraybuffer',
|
|
241
|
+
headers: { Accept: '*/*' },
|
|
242
|
+
});
|
|
243
|
+
const b64 = Buffer.from(r.data).toString('base64');
|
|
244
|
+
const contentDisposition = r.headers['content-disposition'] as string | undefined;
|
|
245
|
+
const detectedName = contentDisposition
|
|
246
|
+
? (contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/))?.[1]?.replace(/['"]/g, '').trim()
|
|
247
|
+
: undefined;
|
|
248
|
+
const finalName = filename ?? detectedName ?? 'file';
|
|
249
|
+
const mimeType = (r.headers['content-type'] as string | undefined) ?? 'application/octet-stream';
|
|
250
|
+
return {
|
|
251
|
+
content: [{
|
|
252
|
+
type: 'text',
|
|
253
|
+
text: JSON.stringify({ filename: finalName, mimeType, size: r.data.byteLength, base64: b64 }, null, 2),
|
|
254
|
+
}],
|
|
255
|
+
};
|
|
177
256
|
}
|
|
178
257
|
);
|
|
179
258
|
|