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 ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "blackboard": {
4
+ "command": "npx",
5
+ "args": ["blackboard-upc", "mcp"]
6
+ }
7
+ }
8
+ }
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
- - [ ] `npx` install sin clonar repo (publicación en npm)
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/aleoroni/blackboard-cli
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: Alejandro Daniel Oroncoy Almeyda
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
- _529630_1 Fundamentos de Arquitectura de Software [Ultra]
65
- _529580_1 Finanzas e Ingeniería Económica [Ultra]
66
- _529533_1 Desarrollo de Soluciones IOT [Ultra]
67
- _529760_1 Diseño de Experimentos de Ingeniería de Software [Ultra]
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 _529760_1
71
+ blackboard assignments list _100004_1
70
72
 
71
- _13890556_1 Tarea 1 [manual]
72
- Nota: sin entregar · Máx: 3 pts · Entrega: 05/03/2026 (vencida hace 23d)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blackboard-upc",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "CLI no oficial para UPC Aula Virtual (Blackboard Learn) — acceso desde la terminal y MCP para Claude",
5
5
  "main": "run.js",
6
6
  "bin": {
@@ -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
- const memberships: UserCourse[] = r.data.results;
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 listAssignments(client, courseId);
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
- // Fetch my grade for each assignment in parallel
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, i) => {
86
- const grade = gradeResults[i].status === 'fulfilled' ? gradeResults[i].value : null;
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
@@ -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
- const r = await client.get(
155
- `/learn/api/public/v1/courses/${courseId}/contents/${contentId}/attachments/${attachmentId}/download`,
156
- { responseType: 'arraybuffer' }
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
- return { content: [{ type: 'text', text: b64 }] };
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}/attachments`
199
+ `/learn/api/public/v1/courses/${courseId}/contents/${contentId}`
175
200
  );
176
- return { content: [{ type: 'text', text: JSON.stringify(r.data, null, 2) }] };
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(/&quot;/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