@studentsphere/ots-provider-wigor 1.0.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.
- package/README.md +99 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +656 -0
- package/dist/index.js.map +1 -0
- package/package.json +71 -0
- package/src/index.ts +781 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
import * as crypto from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
BaseTimetableProvider,
|
|
4
|
+
type Course,
|
|
5
|
+
type ProviderCredentials,
|
|
6
|
+
type School,
|
|
7
|
+
} from "@studentsphere/ots-core";
|
|
8
|
+
import axios, { type AxiosInstance } from "axios";
|
|
9
|
+
import { wrapper } from "axios-cookiejar-support";
|
|
10
|
+
import * as cheerio from "cheerio";
|
|
11
|
+
import pLimit from "p-limit";
|
|
12
|
+
import { CookieJar } from "tough-cookie";
|
|
13
|
+
|
|
14
|
+
const LOGIN_SERVER = "https://cas-p.wigorservices.net/cas/login";
|
|
15
|
+
|
|
16
|
+
const CD_SCHOOLS = [
|
|
17
|
+
"3a",
|
|
18
|
+
"epsi",
|
|
19
|
+
"esail",
|
|
20
|
+
"icl",
|
|
21
|
+
"idrac-business-school",
|
|
22
|
+
"ieft",
|
|
23
|
+
"iet",
|
|
24
|
+
"ifag",
|
|
25
|
+
"igefi",
|
|
26
|
+
"ihedrea",
|
|
27
|
+
"ileri",
|
|
28
|
+
"sup-de-com",
|
|
29
|
+
"viva-mundi",
|
|
30
|
+
"wis",
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const IGENSIA_SCHOOLS = [
|
|
34
|
+
"american-business-college",
|
|
35
|
+
"business-science-institute",
|
|
36
|
+
"cnva",
|
|
37
|
+
"ecm",
|
|
38
|
+
"emi",
|
|
39
|
+
"esa",
|
|
40
|
+
"esam",
|
|
41
|
+
"icd-business-school",
|
|
42
|
+
"igensia-rh",
|
|
43
|
+
"imis",
|
|
44
|
+
"imsi",
|
|
45
|
+
"ipi",
|
|
46
|
+
"iscpa",
|
|
47
|
+
"ismm",
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const getScheduleServer = (schoolId?: string) => {
|
|
51
|
+
if (schoolId && CD_SCHOOLS.includes(schoolId)) {
|
|
52
|
+
return "https://ws-edt-cd.wigorservices.net/WebPsDyn.aspx";
|
|
53
|
+
}
|
|
54
|
+
if (schoolId && IGENSIA_SCHOOLS.includes(schoolId)) {
|
|
55
|
+
return "https://ws-edt-igs.wigorservices.net/WebPsDyn.aspx";
|
|
56
|
+
}
|
|
57
|
+
return "https://ws-edt-igs.wigorservices.net/WebPsDyn.aspx";
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const FRENCH_MONTHS: Record<string, number> = {
|
|
61
|
+
Janvier: 1,
|
|
62
|
+
Février: 2,
|
|
63
|
+
Mars: 3,
|
|
64
|
+
Avril: 4,
|
|
65
|
+
Mai: 5,
|
|
66
|
+
Juin: 6,
|
|
67
|
+
Juillet: 7,
|
|
68
|
+
Août: 8,
|
|
69
|
+
Septembre: 9,
|
|
70
|
+
Octobre: 10,
|
|
71
|
+
Novembre: 11,
|
|
72
|
+
Décembre: 12,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const FRENCH_DAYS: Record<string, number> = {
|
|
76
|
+
Lundi: 1,
|
|
77
|
+
Mardi: 2,
|
|
78
|
+
Mercredi: 3,
|
|
79
|
+
Jeudi: 4,
|
|
80
|
+
Vendredi: 5,
|
|
81
|
+
Samedi: 6,
|
|
82
|
+
Dimanche: 0,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const WEEK_DAYS = [
|
|
86
|
+
"Sunday",
|
|
87
|
+
"Monday",
|
|
88
|
+
"Tuesday",
|
|
89
|
+
"Wednesday",
|
|
90
|
+
"Thursday",
|
|
91
|
+
"Friday",
|
|
92
|
+
"Saturday",
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
export interface WigorEvent {
|
|
96
|
+
title: string;
|
|
97
|
+
instructor: string;
|
|
98
|
+
program: string;
|
|
99
|
+
startTime: string;
|
|
100
|
+
endTime: string;
|
|
101
|
+
duration: number;
|
|
102
|
+
weekDay: string;
|
|
103
|
+
classroom: string | null;
|
|
104
|
+
campus: string | null;
|
|
105
|
+
deliveryMode: string;
|
|
106
|
+
color: string;
|
|
107
|
+
classGroup: string;
|
|
108
|
+
hash: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export class WigorProvider extends BaseTimetableProvider {
|
|
112
|
+
get id(): string {
|
|
113
|
+
return "wigor";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
get name(): string {
|
|
117
|
+
return "Wigor";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
get logo(): string {
|
|
121
|
+
return "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADUAAAA1CAYAAADh5qNwAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAIvSURBVHgB7Zm/SgNBEMZXCSRoCBaJWAQMxCo2Cip2Wltrrz6JDyDYCLa+g2ArgpUWNiaVwQipTAoJMUQQlDHZsH/mLpfdFZwwv+puMnc3385+e5tk5uzm4FtMGbNiCmFRVGBRVGBRVGBRVGBRVGBRVGBRVEgJB3ZWDsV6cU+LXdwdic+vDyt3f+1EFBcqo/N6+15cPZ1aebnMojjePtdij81rcft8KSbFqVPN95oVUwtXKWRLRt5qorzBc6rCBUdR9sOwYiGWTs1psXRqHhVQzm8gz7EHLwlOomCatboNoRe1aeUVssvo9VhXTaFwf2w6J8F5oTBHMZcp/HZBpZzfQq81BwD8ZIpy7RIQTBRgFotNMyyOda7efhCueIiyfaUWi/lJAh1VPYiJMqf3JDiLgvluClM7FbUaSlS/mYsMzAJXPwFeL99W91U7V30VtXRL5ACAn+A6/b4N4YOXKGzey2LHd6oUmefjJ8CzUw0rBkViXTJzpa9C+wnwEoX5KqpQbLuDDYCvnwDvDS32vqos7WqxTv9tWGxPi8NUDe0nILgowCxU5sBmVgV7j/n6CfAWNdjO9GJzpKgkuwTXTayKt6jBPvAlNkcWOq5gn62RSpAviXHFgJ86/dbwuDU6xu/j3yXgz0WZn8UV/s86VY30lS2qFnufEAT7jSLKV2ahUatbqC4BwURhxap+ksDCgvnKXO59mOE/sonAoqjAoqjAoqjAoqjAoqjAoqjAoqjwAxN68XM4/01cAAAAAElFTkSuQmCC";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
get schools(): School[] {
|
|
125
|
+
return [
|
|
126
|
+
{
|
|
127
|
+
id: "3a",
|
|
128
|
+
name: "3A",
|
|
129
|
+
logo: "https://raw.githubusercontent.com/kaelianbaudelet/WSPS/main/public/schools/3a.png",
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
id: "american-business-college",
|
|
133
|
+
name: "American Business College",
|
|
134
|
+
logo: "https://raw.githubusercontent.com/kaelianbaudelet/WSPS/main/public/schools/american_business_college.png",
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
id: "business-science-institute",
|
|
138
|
+
name: "Business Science Institute",
|
|
139
|
+
logo: "https://raw.githubusercontent.com/kaelianbaudelet/WSPS/main/public/schools/business_science_institute.png",
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: "cnva",
|
|
143
|
+
name: "CNVA",
|
|
144
|
+
logo: "https://raw.githubusercontent.com/kaelianbaudelet/WSPS/main/public/schools/cnva.png",
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: "ecm",
|
|
148
|
+
name: "ECM",
|
|
149
|
+
logo: "https://raw.githubusercontent.com/kaelianbaudelet/WSPS/main/public/schools/ecm.png",
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
id: "emi",
|
|
153
|
+
name: "EMI",
|
|
154
|
+
logo: "https://raw.githubusercontent.com/kaelianbaudelet/WSPS/main/public/schools/emi.png",
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
id: "epsi",
|
|
158
|
+
name: "EPSI",
|
|
159
|
+
logo: "https://raw.githubusercontent.com/kaelianbaudelet/WSPS/main/public/schools/epsi.png",
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
id: "esa",
|
|
163
|
+
name: "ESA",
|
|
164
|
+
logo: "https://raw.githubusercontent.com/kaelianbaudelet/WSPS/main/public/schools/esa.png",
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: "esail",
|
|
168
|
+
name: "ESAIL",
|
|
169
|
+
logo: "https://raw.githubusercontent.com/kaelianbaudelet/WSPS/main/public/schools/esail.png",
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
id: "esam",
|
|
173
|
+
name: "ESAM",
|
|
174
|
+
logo: "https://raw.githubusercontent.com/kaelianbaudelet/WSPS/main/public/schools/esam.png",
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
id: "icd-business-school",
|
|
178
|
+
name: "ICD Business School",
|
|
179
|
+
logo: "https://raw.githubusercontent.com/kaelianbaudelet/WSPS/main/public/schools/icd_business_school.png",
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
id: "icl",
|
|
183
|
+
name: "ICL",
|
|
184
|
+
logo: "https://raw.githubusercontent.com/kaelianbaudelet/WSPS/main/public/schools/icl.png",
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
id: "idrac-business-school",
|
|
188
|
+
name: "IDRAC Business School",
|
|
189
|
+
logo: "https://raw.githubusercontent.com/kaelianbaudelet/WSPS/main/public/schools/idrac_business_school.png",
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
id: "ieft",
|
|
193
|
+
name: "IEFT",
|
|
194
|
+
logo: "https://raw.githubusercontent.com/kaelianbaudelet/WSPS/main/public/schools/ieft.png",
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
id: "iet",
|
|
198
|
+
name: "IET",
|
|
199
|
+
logo: "https://raw.githubusercontent.com/kaelianbaudelet/WSPS/main/public/schools/iet.png",
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
id: "ifag",
|
|
203
|
+
name: "IFAG",
|
|
204
|
+
logo: "https://raw.githubusercontent.com/kaelianbaudelet/WSPS/main/public/schools/ifag.png",
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
id: "igefi",
|
|
208
|
+
name: "IGEFI",
|
|
209
|
+
logo: "https://raw.githubusercontent.com/kaelianbaudelet/WSPS/main/public/schools/igefi.png",
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
id: "igensia-rh",
|
|
213
|
+
name: "IGENSIA RH",
|
|
214
|
+
logo: "https://raw.githubusercontent.com/kaelianbaudelet/WSPS/main/public/schools/igensia_rh.png",
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
id: "ihedrea",
|
|
218
|
+
name: "IHEDREA",
|
|
219
|
+
logo: "https://raw.githubusercontent.com/kaelianbaudelet/WSPS/main/public/schools/ihedrea.png",
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
id: "ileri",
|
|
223
|
+
name: "ILERI",
|
|
224
|
+
logo: "https://raw.githubusercontent.com/kaelianbaudelet/WSPS/main/public/schools/ileri.png",
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
id: "imis",
|
|
228
|
+
name: "IMIS",
|
|
229
|
+
logo: "https://raw.githubusercontent.com/kaelianbaudelet/WSPS/main/public/schools/imis.png",
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
id: "imsi",
|
|
233
|
+
name: "IMSI",
|
|
234
|
+
logo: "https://raw.githubusercontent.com/kaelianbaudelet/WSPS/main/public/schools/imsi.png",
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
id: "ipi",
|
|
238
|
+
name: "IPI",
|
|
239
|
+
logo: "https://raw.githubusercontent.com/kaelianbaudelet/WSPS/main/public/schools/ipi.png",
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
id: "iscpa",
|
|
243
|
+
name: "ISCPA",
|
|
244
|
+
logo: "https://raw.githubusercontent.com/kaelianbaudelet/WSPS/main/public/schools/iscpa.png",
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
id: "ismm",
|
|
248
|
+
name: "ISMM",
|
|
249
|
+
logo: "https://raw.githubusercontent.com/kaelianbaudelet/WSPS/main/public/schools/ismm.png",
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
id: "sup-de-com",
|
|
253
|
+
name: "SUP DE COM",
|
|
254
|
+
logo: "https://raw.githubusercontent.com/kaelianbaudelet/WSPS/main/public/schools/sup_de_com.png",
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
id: "viva-mundi",
|
|
258
|
+
name: "Viva Mundi",
|
|
259
|
+
logo: "https://raw.githubusercontent.com/kaelianbaudelet/WSPS/main/public/schools/viva_mundi.png",
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
id: "wis",
|
|
263
|
+
name: "WIS",
|
|
264
|
+
logo: "https://raw.githubusercontent.com/kaelianbaudelet/WSPS/main/public/schools/wis.png",
|
|
265
|
+
},
|
|
266
|
+
];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async validateCredentials(
|
|
270
|
+
credentials: ProviderCredentials,
|
|
271
|
+
): Promise<boolean> {
|
|
272
|
+
try {
|
|
273
|
+
const isCasAvailable = await this.checkCasAvailability(LOGIN_SERVER);
|
|
274
|
+
|
|
275
|
+
if (!isCasAvailable) {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const jar = new CookieJar();
|
|
280
|
+
const client = wrapper(axios.create({ jar, withCredentials: true }));
|
|
281
|
+
|
|
282
|
+
const getRes = await client.get(LOGIN_SERVER, {
|
|
283
|
+
params: { service: "" },
|
|
284
|
+
headers: { "User-Agent": "nodejs-client", Accept: "text/html" },
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const hidden = await this.extractHiddenFields(getRes.data as string);
|
|
288
|
+
|
|
289
|
+
const form = new URLSearchParams();
|
|
290
|
+
form.append("username", credentials.identifier as string);
|
|
291
|
+
form.append("password", credentials.password || "");
|
|
292
|
+
for (const [k, v] of Object.entries(hidden)) {
|
|
293
|
+
if (k !== "username" && k !== "password") form.append(k, v);
|
|
294
|
+
}
|
|
295
|
+
if (!form.get("_eventId")) form.append("_eventId", "submit");
|
|
296
|
+
|
|
297
|
+
const postRes = await client.post(LOGIN_SERVER, form.toString(), {
|
|
298
|
+
headers: {
|
|
299
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
300
|
+
"User-Agent": "nodejs-client",
|
|
301
|
+
Accept: "text/html",
|
|
302
|
+
},
|
|
303
|
+
maxRedirects: 0,
|
|
304
|
+
validateStatus: () => true,
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
if (postRes.status >= 300 && postRes.status < 400) {
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (postRes.status === 200) {
|
|
311
|
+
// Check if we are actually logged in (CAS usually shows a specific page or redirects)
|
|
312
|
+
if (
|
|
313
|
+
postRes.data.includes("success") ||
|
|
314
|
+
postRes.data.includes("Log Out") ||
|
|
315
|
+
postRes.data.includes("Vous êtes connecté")
|
|
316
|
+
) {
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Note: CAS might return 200 on failure with an error message in the HTML
|
|
321
|
+
if (
|
|
322
|
+
postRes.data.includes("incorrect") ||
|
|
323
|
+
postRes.data.includes("erreur")
|
|
324
|
+
) {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
// If it's a 200 and no error message, it might be a success depending on the CAS config
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return false;
|
|
332
|
+
} catch (err) {
|
|
333
|
+
console.error(`[Wigor] Error during validation:`, err);
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async getSchedule(
|
|
339
|
+
credentials: ProviderCredentials,
|
|
340
|
+
from: Date,
|
|
341
|
+
to: Date,
|
|
342
|
+
): Promise<Course[]> {
|
|
343
|
+
const isCasAvailable = await this.checkCasAvailability(LOGIN_SERVER);
|
|
344
|
+
if (!isCasAvailable) {
|
|
345
|
+
throw new Error("CAS server unavailable");
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const query = {
|
|
349
|
+
action: "posEDTLMS",
|
|
350
|
+
serverID: "C",
|
|
351
|
+
hashURL:
|
|
352
|
+
"3771E093EFD5A0DB1204B280BBC7F09097D3A41521007FAEB9EAC5AD8905F07DBB230E9FFE9D3A6BF40B157D22E842F91708A3D7950855E83F70CF9A8A4A1CF8",
|
|
353
|
+
Tel: credentials.identifier as string,
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const limit = pLimit(10);
|
|
357
|
+
const allEvents: WigorEvent[] = [];
|
|
358
|
+
|
|
359
|
+
const effectiveStart = from;
|
|
360
|
+
const effectiveEnd = to;
|
|
361
|
+
|
|
362
|
+
let currentMonday = this.getMonday(effectiveStart);
|
|
363
|
+
const endDateObj = effectiveEnd;
|
|
364
|
+
const endFriday = this.addDays(this.getMonday(endDateObj), 4);
|
|
365
|
+
|
|
366
|
+
const weeks: string[] = [];
|
|
367
|
+
while (currentMonday <= endFriday) {
|
|
368
|
+
const mondayStr = `${(currentMonday.getUTCMonth() + 1)
|
|
369
|
+
.toString()
|
|
370
|
+
.padStart(2, "0")}/${currentMonday
|
|
371
|
+
.getUTCDate()
|
|
372
|
+
.toString()
|
|
373
|
+
.padStart(2, "0")}/${currentMonday.getUTCFullYear()}`;
|
|
374
|
+
weeks.push(mondayStr);
|
|
375
|
+
currentMonday = this.addDays(currentMonday, 7);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const scheduleServer = getScheduleServer(
|
|
379
|
+
credentials.schoolId as string | undefined,
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
const promises = weeks.map((mondayStr) =>
|
|
383
|
+
limit(async () => {
|
|
384
|
+
const client = await this.createClient(LOGIN_SERVER, credentials);
|
|
385
|
+
let valid = false;
|
|
386
|
+
let events: WigorEvent[] = [];
|
|
387
|
+
for (let attempt = 1; attempt <= 3 && !valid; attempt++) {
|
|
388
|
+
try {
|
|
389
|
+
const html = await this.fetchEDTHtml(client, scheduleServer, {
|
|
390
|
+
...query,
|
|
391
|
+
date: mondayStr,
|
|
392
|
+
});
|
|
393
|
+
events = this.parseEdtHtml(html, mondayStr);
|
|
394
|
+
valid = this.areEventsValid(events);
|
|
395
|
+
} catch (_err) {
|
|
396
|
+
console.warn(
|
|
397
|
+
`[Wigor] Failed to fetch/parse week ${mondayStr} (Attempt ${attempt})`,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return valid ? events : [];
|
|
402
|
+
}),
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
const results = await Promise.allSettled(promises);
|
|
406
|
+
for (const result of results) {
|
|
407
|
+
if (result.status === "fulfilled") {
|
|
408
|
+
allEvents.push(...result.value);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return allEvents.map((event) => ({
|
|
413
|
+
hash: event.hash,
|
|
414
|
+
subject: event.title,
|
|
415
|
+
start: new Date(event.startTime),
|
|
416
|
+
end: new Date(event.endTime),
|
|
417
|
+
location: event.classroom || event.campus || "Inconnu",
|
|
418
|
+
teacher: event.instructor,
|
|
419
|
+
color: event.color,
|
|
420
|
+
}));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Helper methods
|
|
424
|
+
private async checkCasAvailability(loginServer: string): Promise<boolean> {
|
|
425
|
+
try {
|
|
426
|
+
const response = await axios.head(loginServer, {
|
|
427
|
+
headers: { "User-Agent": "nodejs-client", Accept: "text/html" },
|
|
428
|
+
validateStatus: (status) => status === 200,
|
|
429
|
+
});
|
|
430
|
+
return response.status === 200;
|
|
431
|
+
} catch (_err) {
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private async extractHiddenFields(
|
|
437
|
+
html: string,
|
|
438
|
+
): Promise<Record<string, string>> {
|
|
439
|
+
const $ = cheerio.load(html);
|
|
440
|
+
const fields: Record<string, string> = {};
|
|
441
|
+
$('input[type="hidden"]').each((_, el) => {
|
|
442
|
+
const name = $(el).attr("name");
|
|
443
|
+
const value = $(el).attr("value") ?? "";
|
|
444
|
+
if (name) {
|
|
445
|
+
fields[name] = value;
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
return fields;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private async createClient(
|
|
452
|
+
loginServer: string,
|
|
453
|
+
credentials: ProviderCredentials,
|
|
454
|
+
): Promise<AxiosInstance> {
|
|
455
|
+
const jar = new CookieJar();
|
|
456
|
+
const client = wrapper(axios.create({ jar, withCredentials: true }));
|
|
457
|
+
const getRes = await client.get(loginServer, {
|
|
458
|
+
params: { service: "" },
|
|
459
|
+
headers: { "User-Agent": "nodejs-client", Accept: "text/html" },
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const hidden = await this.extractHiddenFields(getRes.data as string);
|
|
463
|
+
|
|
464
|
+
const form = new URLSearchParams();
|
|
465
|
+
form.append("username", credentials.identifier as string);
|
|
466
|
+
form.append("password", credentials.password || "");
|
|
467
|
+
for (const [k, v] of Object.entries(hidden)) {
|
|
468
|
+
if (k !== "username" && k !== "password") form.append(k, v);
|
|
469
|
+
}
|
|
470
|
+
if (!form.get("_eventId")) form.append("_eventId", "submit");
|
|
471
|
+
|
|
472
|
+
await client.post(loginServer, form.toString(), {
|
|
473
|
+
headers: {
|
|
474
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
475
|
+
"User-Agent": "nodejs-client",
|
|
476
|
+
Accept: "text/html",
|
|
477
|
+
},
|
|
478
|
+
maxRedirects: 0,
|
|
479
|
+
validateStatus: (s) => s >= 200 && s < 400,
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
return client;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private isErrorPage(html: string): boolean {
|
|
486
|
+
return (
|
|
487
|
+
html.includes("<title>Error 500</title>") ||
|
|
488
|
+
html.includes("<h1>500</h1>") ||
|
|
489
|
+
html.includes("Unexpected Error")
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
private async fetchEDTHtml(
|
|
494
|
+
client: AxiosInstance,
|
|
495
|
+
scheduleServer: string,
|
|
496
|
+
query: Record<string, string>,
|
|
497
|
+
maxRetries: number = 3,
|
|
498
|
+
): Promise<string> {
|
|
499
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
500
|
+
try {
|
|
501
|
+
const params = new URLSearchParams(query).toString();
|
|
502
|
+
const url = `${scheduleServer}?${params}`;
|
|
503
|
+
|
|
504
|
+
const res = await client.get(url, {
|
|
505
|
+
headers: { "User-Agent": "nodejs-client", Accept: "text/html" },
|
|
506
|
+
});
|
|
507
|
+
const html = res.data as string;
|
|
508
|
+
if (!this.isErrorPage(html)) {
|
|
509
|
+
return html;
|
|
510
|
+
}
|
|
511
|
+
} catch (_err) {
|
|
512
|
+
// Ignore error and retry
|
|
513
|
+
}
|
|
514
|
+
if (attempt < maxRetries) {
|
|
515
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
throw new Error("Failed to fetch EDT");
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
private capitalizeName(name: string): string {
|
|
522
|
+
return name
|
|
523
|
+
.split(" ")
|
|
524
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
525
|
+
.join(" ");
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
private parseFrenchDate(
|
|
529
|
+
dayText: string,
|
|
530
|
+
baseYear: number,
|
|
531
|
+
queryDateObj: Date,
|
|
532
|
+
): string {
|
|
533
|
+
const parts = dayText.trim().split(/\s+/);
|
|
534
|
+
if (parts.length < 3) throw new Error("Invalid date format");
|
|
535
|
+
|
|
536
|
+
const dayName = parts[0];
|
|
537
|
+
if (!dayName || !(dayName in FRENCH_DAYS))
|
|
538
|
+
throw new Error("Invalid day name");
|
|
539
|
+
|
|
540
|
+
const dayNumStr = parts[1];
|
|
541
|
+
if (!dayNumStr) throw new Error("Missing day number");
|
|
542
|
+
const dayNum = parseInt(dayNumStr, 10);
|
|
543
|
+
if (Number.isNaN(dayNum)) throw new Error("Invalid day number");
|
|
544
|
+
|
|
545
|
+
const monthNameRaw = parts[2];
|
|
546
|
+
if (!monthNameRaw) throw new Error("Missing month name");
|
|
547
|
+
const monthName =
|
|
548
|
+
monthNameRaw.charAt(0).toUpperCase() +
|
|
549
|
+
monthNameRaw.slice(1).toLowerCase();
|
|
550
|
+
const month = FRENCH_MONTHS[monthName];
|
|
551
|
+
if (!month) throw new Error("Invalid month name");
|
|
552
|
+
|
|
553
|
+
const weekday = FRENCH_DAYS[dayName];
|
|
554
|
+
|
|
555
|
+
let bestDate: Date | null = null;
|
|
556
|
+
let minDiff = Infinity;
|
|
557
|
+
for (const y of [baseYear - 1, baseYear, baseYear + 1]) {
|
|
558
|
+
const d = new Date(Date.UTC(y, month - 1, dayNum));
|
|
559
|
+
if (d.getUTCDay() === weekday) {
|
|
560
|
+
const diff = Math.abs(d.getTime() - queryDateObj.getTime());
|
|
561
|
+
if (diff < minDiff) {
|
|
562
|
+
minDiff = diff;
|
|
563
|
+
bestDate = d;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (!bestDate) throw new Error("No matching date found");
|
|
569
|
+
const iso = bestDate.toISOString();
|
|
570
|
+
const datePart = iso.split("T")[0];
|
|
571
|
+
if (!datePart) throw new Error("Invalid ISO date format");
|
|
572
|
+
return datePart;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
private getMonday(date: Date): Date {
|
|
576
|
+
const d = new Date(date);
|
|
577
|
+
const day = d.getDay();
|
|
578
|
+
const diff = (day === 0 ? -6 : 1) - day;
|
|
579
|
+
d.setDate(d.getDate() + diff);
|
|
580
|
+
return d;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
private addDays(date: Date, days: number): Date {
|
|
584
|
+
const d = new Date(date);
|
|
585
|
+
d.setDate(d.getDate() + days);
|
|
586
|
+
return d;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
private calculateDuration(
|
|
590
|
+
startTime: string,
|
|
591
|
+
endTime: string,
|
|
592
|
+
date: string,
|
|
593
|
+
): number {
|
|
594
|
+
const start = new Date(`${date}T${startTime}:00Z`);
|
|
595
|
+
const end = new Date(`${date}T${endTime}:00Z`);
|
|
596
|
+
return (end.getTime() - start.getTime()) / (1000 * 60);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
private parseEdtHtml(html: string, queryDate: string): WigorEvent[] {
|
|
600
|
+
const $ = cheerio.load(html);
|
|
601
|
+
const events: WigorEvent[] = [];
|
|
602
|
+
const queryParts = queryDate.split("/");
|
|
603
|
+
if (queryParts.length !== 3) throw new Error("Invalid query date format");
|
|
604
|
+
|
|
605
|
+
const baseYearStr = queryParts[2];
|
|
606
|
+
if (!baseYearStr) throw new Error("Missing year in query date");
|
|
607
|
+
const baseYear = parseInt(baseYearStr, 10);
|
|
608
|
+
if (Number.isNaN(baseYear)) throw new Error("Invalid year in query date");
|
|
609
|
+
|
|
610
|
+
const monthStr = queryParts[0];
|
|
611
|
+
const dayStr = queryParts[1];
|
|
612
|
+
if (!monthStr || !dayStr)
|
|
613
|
+
throw new Error("Missing month or day in query date");
|
|
614
|
+
const month = parseInt(monthStr, 10);
|
|
615
|
+
const day = parseInt(dayStr, 10);
|
|
616
|
+
if (Number.isNaN(month) || Number.isNaN(day))
|
|
617
|
+
throw new Error("Invalid month or day in query date");
|
|
618
|
+
|
|
619
|
+
const queryDateObj = new Date(Date.UTC(baseYear, month - 1, day));
|
|
620
|
+
|
|
621
|
+
const dayMap: Map<number, string> = new Map();
|
|
622
|
+
$(".Jour").each((_, el) => {
|
|
623
|
+
const $el = $(el);
|
|
624
|
+
const style = $el.attr("style") || "";
|
|
625
|
+
const leftMatch = style.match(/left:([\d.]+)%/);
|
|
626
|
+
if (!leftMatch || !leftMatch[1]) {
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
const left = parseFloat(leftMatch[1]);
|
|
630
|
+
if (left < 100 || left >= 200) {
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
const dayText = $el.find(".TCJour").text().trim();
|
|
634
|
+
try {
|
|
635
|
+
const parsedDate = this.parseFrenchDate(
|
|
636
|
+
dayText,
|
|
637
|
+
baseYear,
|
|
638
|
+
queryDateObj,
|
|
639
|
+
);
|
|
640
|
+
dayMap.set(left, parsedDate);
|
|
641
|
+
} catch (_err) {
|
|
642
|
+
// Skip invalid days
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
const sortedDays = Array.from(dayMap.keys()).sort((a, b) => a - b);
|
|
647
|
+
|
|
648
|
+
$(".Case").each((_, element) => {
|
|
649
|
+
const $case = $(element);
|
|
650
|
+
const style = $case.attr("style") || "";
|
|
651
|
+
const leftMatch = style.match(/left:([\d.]+)%/);
|
|
652
|
+
if (!leftMatch || !leftMatch[1]) {
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
const caseLeft = parseFloat(leftMatch[1]);
|
|
656
|
+
if (caseLeft < 100 || caseLeft >= 200) {
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const dayLeft = sortedDays.filter((l) => l <= caseLeft).pop();
|
|
661
|
+
if (dayLeft === undefined) {
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
const eventDate = dayMap.get(dayLeft);
|
|
665
|
+
if (!eventDate) {
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const time = $case.find(".TChdeb").text().trim().split(" - ");
|
|
670
|
+
if (time.length !== 2 || !time[0] || !time[1]) {
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const profContents = $case
|
|
675
|
+
.find(".TCProf")
|
|
676
|
+
.contents()
|
|
677
|
+
.filter(function () {
|
|
678
|
+
return this.type === "text" && $(this).text().trim() !== "";
|
|
679
|
+
})
|
|
680
|
+
.map(function () {
|
|
681
|
+
return $(this).text().trim();
|
|
682
|
+
})
|
|
683
|
+
.get();
|
|
684
|
+
|
|
685
|
+
const classroomInfo = $case.find(".TCSalle").text().trim();
|
|
686
|
+
|
|
687
|
+
const courseName = $case
|
|
688
|
+
.find("td.TCase")
|
|
689
|
+
.contents()
|
|
690
|
+
.filter(function () {
|
|
691
|
+
return this.type === "text" && $(this).text().trim() !== "";
|
|
692
|
+
})
|
|
693
|
+
.text()
|
|
694
|
+
.trim();
|
|
695
|
+
|
|
696
|
+
const borderColor =
|
|
697
|
+
$case
|
|
698
|
+
.find(".innerCase")
|
|
699
|
+
.attr("style")
|
|
700
|
+
?.match(/border:3px solid\s*([^;]+)/)?.[1] || "";
|
|
701
|
+
|
|
702
|
+
if (!courseName) {
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const specParts = profContents[1] ? profContents[1].split(" - ") : [];
|
|
707
|
+
|
|
708
|
+
let classroom: string | null =
|
|
709
|
+
classroomInfo.replace("Salle:", "").split("(")[0]?.trim() || null;
|
|
710
|
+
let campus: string | null =
|
|
711
|
+
classroomInfo.match(/\(([^)]+)\)/)?.[1] || null;
|
|
712
|
+
let sessionType: string = "in_person";
|
|
713
|
+
|
|
714
|
+
if (
|
|
715
|
+
classroomInfo.includes("(DISTANCIEL)") ||
|
|
716
|
+
classroomInfo.includes("Aucune")
|
|
717
|
+
) {
|
|
718
|
+
classroom = null;
|
|
719
|
+
campus = null;
|
|
720
|
+
if (classroomInfo.includes("(DISTANCIEL)")) sessionType = "remote";
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const dateObj = new Date(eventDate);
|
|
724
|
+
if (Number.isNaN(dateObj.getTime()))
|
|
725
|
+
throw new Error("Invalid event date");
|
|
726
|
+
|
|
727
|
+
const weekDay = WEEK_DAYS[dateObj.getUTCDay()];
|
|
728
|
+
if (!weekDay) throw new Error("Invalid weekday");
|
|
729
|
+
|
|
730
|
+
const startTime = `${eventDate}T${time[0]}:00`;
|
|
731
|
+
const endTime = `${eventDate}T${time[1]}:00`;
|
|
732
|
+
const instructor = profContents[0]
|
|
733
|
+
? this.capitalizeName(profContents[0])
|
|
734
|
+
: "";
|
|
735
|
+
|
|
736
|
+
// Generate stable hash from content
|
|
737
|
+
// We use a combination of stable fields to ensure the same course gives the same hash
|
|
738
|
+
const hashContent = `${courseName}|${startTime}|${endTime}|${classroom}|${instructor}`;
|
|
739
|
+
const hash = crypto
|
|
740
|
+
.createHash("sha256")
|
|
741
|
+
.update(hashContent)
|
|
742
|
+
.digest("hex");
|
|
743
|
+
|
|
744
|
+
const event: WigorEvent = {
|
|
745
|
+
title: courseName.replace(/(\n|\t|\s\s+)/g, " ").trim(),
|
|
746
|
+
instructor,
|
|
747
|
+
program: specParts[1]?.trim() || "",
|
|
748
|
+
startTime,
|
|
749
|
+
endTime,
|
|
750
|
+
duration: this.calculateDuration(time[0], time[1], eventDate),
|
|
751
|
+
weekDay,
|
|
752
|
+
classroom,
|
|
753
|
+
campus: campus || "Arras",
|
|
754
|
+
deliveryMode: sessionType,
|
|
755
|
+
color: borderColor || "#808080",
|
|
756
|
+
classGroup: specParts[0]?.trim() || "",
|
|
757
|
+
hash,
|
|
758
|
+
};
|
|
759
|
+
events.push(event);
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
return events;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
private areEventsValid(events: WigorEvent[]): boolean {
|
|
766
|
+
return events.every((event) => {
|
|
767
|
+
return (
|
|
768
|
+
event.title &&
|
|
769
|
+
event.title.trim() !== "" &&
|
|
770
|
+
event.instructor &&
|
|
771
|
+
event.instructor.trim() !== "" &&
|
|
772
|
+
event.startTime &&
|
|
773
|
+
event.startTime.trim() !== "" &&
|
|
774
|
+
event.endTime &&
|
|
775
|
+
event.endTime.trim() !== "" &&
|
|
776
|
+
event.weekDay &&
|
|
777
|
+
event.weekDay.trim() !== ""
|
|
778
|
+
);
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
}
|