angeetoile commited on
Commit
34f4b41
·
1 Parent(s): 71e5eea

feat(collectors): add Erasmus Mundus collector

Browse files
Dockerfile CHANGED
@@ -19,4 +19,4 @@ USER appuser
19
 
20
  EXPOSE 7860
21
 
22
- CMD ["sh", "-c", "alembic upgrade head && python -m scripts.check_campus_france && uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860}"]
 
19
 
20
  EXPOSE 7860
21
 
22
+ CMD ["sh", "-c", "python -m scripts.check_erasmus_mundus_collector && uvicorn app.main:app --host 0.0.0.0 --port 7860"]
app/collectors/providers/erasmus_mundus.py ADDED
@@ -0,0 +1,1075 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import calendar
3
+ import os
4
+ import re
5
+ from datetime import date, datetime
6
+ from typing import Any
7
+ from urllib.parse import urljoin, urlparse
8
+
9
+ import feedparser
10
+ import httpx
11
+ from bs4 import BeautifulSoup
12
+
13
+ from app.collectors.base import BaseOpportunityCollector
14
+ from app.collectors.types import CollectedOpportunity
15
+
16
+
17
+ class ErasmusMundusCollectorError(RuntimeError):
18
+ """Erreur produite pendant la collecte Erasmus Mundus."""
19
+
20
+
21
+ class ErasmusMundusCollector(
22
+ BaseOpportunityCollector
23
+ ):
24
+ source_slug = "erasmus-mundus"
25
+
26
+ CATALOGUE_URL = (
27
+ "https://www.eacea.ec.europa.eu/"
28
+ "scholarships/"
29
+ "erasmus-mundus-catalogue_en"
30
+ )
31
+
32
+ RSS_URL = (
33
+ "https://www.eacea.ec.europa.eu/"
34
+ "node/253/rss_en"
35
+ )
36
+
37
+ REQUEST_TIMEOUT = 30.0
38
+
39
+ MAX_CONCURRENCY = 5
40
+
41
+ DEFAULT_MAX_PROGRAMMES = 60
42
+
43
+ APPLICATION_KEYWORDS = (
44
+ "apply",
45
+ "application",
46
+ "applications",
47
+ "admission",
48
+ "admissions",
49
+ "scholarship",
50
+ "scholarships",
51
+ "deadline",
52
+ "how-to-apply",
53
+ "how_to_apply",
54
+ )
55
+
56
+ BLOCKED_DOMAINS = (
57
+ "eacea.ec.europa.eu",
58
+ "erasmus-plus.ec.europa.eu",
59
+ "commission.europa.eu",
60
+ "education.ec.europa.eu",
61
+ )
62
+
63
+ MONTHS = {
64
+ "january": 1,
65
+ "february": 2,
66
+ "march": 3,
67
+ "april": 4,
68
+ "may": 5,
69
+ "june": 6,
70
+ "july": 7,
71
+ "august": 8,
72
+ "september": 9,
73
+ "october": 10,
74
+ "november": 11,
75
+ "december": 12,
76
+ }
77
+
78
+ DATE_PATTERNS = (
79
+ (
80
+ r"(?P<day>\d{1,2})(?:st|nd|rd|th)?"
81
+ r"\s+"
82
+ r"(?P<month>"
83
+ r"January|February|March|April|May|June|"
84
+ r"July|August|September|October|November|"
85
+ r"December"
86
+ r")"
87
+ r"\s*,?\s*"
88
+ r"(?P<year>20\d{2})"
89
+ ),
90
+ (
91
+ r"(?P<month>"
92
+ r"January|February|March|April|May|June|"
93
+ r"July|August|September|October|November|"
94
+ r"December"
95
+ r")"
96
+ r"\s+"
97
+ r"(?P<day>\d{1,2})(?:st|nd|rd|th)?"
98
+ r"\s*,?\s*"
99
+ r"(?P<year>20\d{2})"
100
+ ),
101
+ (
102
+ r"(?P<year>20\d{2})"
103
+ r"[-/.]"
104
+ r"(?P<month_number>\d{1,2})"
105
+ r"[-/.]"
106
+ r"(?P<day>\d{1,2})"
107
+ ),
108
+ (
109
+ r"(?P<day>\d{1,2})"
110
+ r"[-/.]"
111
+ r"(?P<month_number>\d{1,2})"
112
+ r"[-/.]"
113
+ r"(?P<year>20\d{2})"
114
+ ),
115
+ )
116
+
117
+ DEADLINE_CONTEXT_PATTERN = re.compile(
118
+ (
119
+ r"(?:"
120
+ r"application deadline|"
121
+ r"deadline for applications?|"
122
+ r"scholarship deadline|"
123
+ r"applications? close(?:s|d)?|"
124
+ r"applications? must be submitted by|"
125
+ r"submit(?:ted)? by|"
126
+ r"apply by|"
127
+ r"closing date"
128
+ r")"
129
+ r".{0,180}"
130
+ ),
131
+ flags=re.IGNORECASE,
132
+ )
133
+
134
+ SCHOLARSHIP_PATTERN = re.compile(
135
+ (
136
+ r"(?:"
137
+ r"erasmus mundus scholarship|"
138
+ r"erasmus\+ scholarship|"
139
+ r"full scholarship|"
140
+ r"fully funded|"
141
+ r"scholarship applications?|"
142
+ r"apply for (?:an? )?scholarship|"
143
+ r"scholarships? available"
144
+ r")"
145
+ ),
146
+ flags=re.IGNORECASE,
147
+ )
148
+
149
+ SCHOLARSHIP_UNAVAILABLE_PATTERN = re.compile(
150
+ (
151
+ r"(?:"
152
+ r"no scholarships? available|"
153
+ r"scholarships? (?:are|is) not available|"
154
+ r"without scholarship only|"
155
+ r"no erasmus mundus funding|"
156
+ r"self-funded students? only"
157
+ r")"
158
+ ),
159
+ flags=re.IGNORECASE,
160
+ )
161
+
162
+ def __init__(
163
+ self,
164
+ client: httpx.AsyncClient | None = None,
165
+ ) -> None:
166
+ self._external_client = client
167
+
168
+ configured_limit = os.getenv(
169
+ "ERASMUS_MUNDUS_MAX_PROGRAMMES"
170
+ )
171
+
172
+ try:
173
+ self.max_programmes = int(
174
+ configured_limit
175
+ or self.DEFAULT_MAX_PROGRAMMES
176
+ )
177
+ except ValueError:
178
+ self.max_programmes = (
179
+ self.DEFAULT_MAX_PROGRAMMES
180
+ )
181
+
182
+ self.max_programmes = max(
183
+ 1,
184
+ min(self.max_programmes, 250),
185
+ )
186
+
187
+ async def collect(
188
+ self,
189
+ ) -> list[CollectedOpportunity]:
190
+ if self._external_client is not None:
191
+ return await self._collect_with_client(
192
+ self._external_client
193
+ )
194
+
195
+ headers = self._build_headers()
196
+
197
+ async with httpx.AsyncClient(
198
+ timeout=self.REQUEST_TIMEOUT,
199
+ follow_redirects=True,
200
+ headers=headers,
201
+ ) as client:
202
+ return await self._collect_with_client(
203
+ client
204
+ )
205
+
206
+ async def _collect_with_client(
207
+ self,
208
+ client: httpx.AsyncClient,
209
+ ) -> list[CollectedOpportunity]:
210
+ entries = await self._fetch_catalogue_entries(
211
+ client
212
+ )
213
+
214
+ semaphore = asyncio.Semaphore(
215
+ self.MAX_CONCURRENCY
216
+ )
217
+
218
+ async def process_entry(
219
+ entry: Any,
220
+ ) -> CollectedOpportunity | None:
221
+ async with semaphore:
222
+ try:
223
+ return await self._process_entry(
224
+ client=client,
225
+ entry=entry,
226
+ )
227
+ except (
228
+ httpx.HTTPError,
229
+ ErasmusMundusCollectorError,
230
+ ValueError,
231
+ ):
232
+ # Une défaillance sur un programme ne doit pas
233
+ # empêcher la vérification des autres.
234
+ return None
235
+
236
+ tasks = [
237
+ process_entry(entry)
238
+ for entry in entries[
239
+ : self.max_programmes
240
+ ]
241
+ ]
242
+
243
+ results = await asyncio.gather(*tasks)
244
+
245
+ opportunities = [
246
+ opportunity
247
+ for opportunity in results
248
+ if opportunity is not None
249
+ ]
250
+
251
+ opportunities.sort(
252
+ key=lambda opportunity: (
253
+ opportunity.deadline
254
+ or date.max
255
+ )
256
+ )
257
+
258
+ return opportunities
259
+
260
+ async def _fetch_catalogue_entries(
261
+ self,
262
+ client: httpx.AsyncClient,
263
+ ) -> list[Any]:
264
+ try:
265
+ response = await client.get(
266
+ self.RSS_URL,
267
+ headers=self._build_headers(),
268
+ )
269
+
270
+ response.raise_for_status()
271
+
272
+ except httpx.HTTPError as error:
273
+ raise ErasmusMundusCollectorError(
274
+ "Impossible de récupérer le flux RSS "
275
+ "officiel Erasmus Mundus."
276
+ ) from error
277
+
278
+ feed = await asyncio.to_thread(
279
+ feedparser.parse,
280
+ response.content,
281
+ )
282
+
283
+ entries = list(
284
+ getattr(feed, "entries", [])
285
+ )
286
+
287
+ if not entries:
288
+ raise ErasmusMundusCollectorError(
289
+ "Le catalogue Erasmus Mundus "
290
+ "n'a retourné aucun programme."
291
+ )
292
+
293
+ return entries
294
+
295
+ async def _process_entry(
296
+ self,
297
+ *,
298
+ client: httpx.AsyncClient,
299
+ entry: Any,
300
+ ) -> CollectedOpportunity | None:
301
+ title = self._clean_text(
302
+ getattr(entry, "title", "")
303
+ )
304
+
305
+ if not title:
306
+ return None
307
+
308
+ catalogue_url = self._extract_entry_url(
309
+ entry
310
+ )
311
+
312
+ if catalogue_url is None:
313
+ return None
314
+
315
+ programme_url = (
316
+ await self._find_programme_url(
317
+ client=client,
318
+ entry=entry,
319
+ catalogue_url=catalogue_url,
320
+ )
321
+ )
322
+
323
+ if programme_url is None:
324
+ return None
325
+
326
+ candidate_pages = (
327
+ await self._discover_application_pages(
328
+ client=client,
329
+ programme_url=programme_url,
330
+ )
331
+ )
332
+
333
+ for candidate_url in candidate_pages:
334
+ page = await self._fetch_page(
335
+ client=client,
336
+ url=candidate_url,
337
+ )
338
+
339
+ if page is None:
340
+ continue
341
+
342
+ html, final_url = page
343
+
344
+ page_text = self._extract_page_text(
345
+ html
346
+ )
347
+
348
+ if not self._scholarship_is_available(
349
+ page_text
350
+ ):
351
+ continue
352
+
353
+ deadline = self._extract_deadline(
354
+ page_text
355
+ )
356
+
357
+ if deadline is None:
358
+ continue
359
+
360
+ if deadline < date.today():
361
+ continue
362
+
363
+ description, image_url = (
364
+ self._extract_metadata(
365
+ html=html,
366
+ page_url=final_url,
367
+ )
368
+ )
369
+
370
+ summary = self._build_summary(
371
+ title=title,
372
+ deadline=deadline,
373
+ description=description,
374
+ )
375
+
376
+ publication_date = (
377
+ self._extract_publication_date(
378
+ entry
379
+ )
380
+ )
381
+
382
+ return CollectedOpportunity(
383
+ source_slug=self.source_slug,
384
+ title=title[:250],
385
+ official_url=programme_url,
386
+ raw_content=page_text,
387
+ summary=summary,
388
+ description=description,
389
+ image_url=image_url,
390
+ organization_name=(
391
+ "Erasmus Mundus"
392
+ ),
393
+ category="scholarship",
394
+ language="en",
395
+ publication_date=(
396
+ publication_date
397
+ ),
398
+ deadline=deadline,
399
+ application_url=final_url,
400
+ )
401
+
402
+ return None
403
+
404
+ async def _find_programme_url(
405
+ self,
406
+ *,
407
+ client: httpx.AsyncClient,
408
+ entry: Any,
409
+ catalogue_url: str,
410
+ ) -> str | None:
411
+ entry_urls = self._extract_entry_urls(
412
+ entry
413
+ )
414
+
415
+ for url in entry_urls:
416
+ if self._is_external_programme_url(
417
+ url
418
+ ):
419
+ return url
420
+
421
+ page = await self._fetch_page(
422
+ client=client,
423
+ url=catalogue_url,
424
+ )
425
+
426
+ if page is None:
427
+ return None
428
+
429
+ html, final_url = page
430
+
431
+ soup = BeautifulSoup(
432
+ html,
433
+ "html.parser",
434
+ )
435
+
436
+ for anchor in soup.find_all(
437
+ "a",
438
+ href=True,
439
+ ):
440
+ href = anchor.get("href")
441
+
442
+ if not isinstance(href, str):
443
+ continue
444
+
445
+ candidate_url = urljoin(
446
+ final_url,
447
+ href.strip(),
448
+ )
449
+
450
+ if self._is_external_programme_url(
451
+ candidate_url
452
+ ):
453
+ return candidate_url
454
+
455
+ return None
456
+
457
+ async def _discover_application_pages(
458
+ self,
459
+ *,
460
+ client: httpx.AsyncClient,
461
+ programme_url: str,
462
+ ) -> list[str]:
463
+ page = await self._fetch_page(
464
+ client=client,
465
+ url=programme_url,
466
+ )
467
+
468
+ if page is None:
469
+ return [programme_url]
470
+
471
+ html, final_url = page
472
+
473
+ urls = [final_url]
474
+
475
+ soup = BeautifulSoup(
476
+ html,
477
+ "html.parser",
478
+ )
479
+
480
+ base_domain = self._normalized_domain(
481
+ final_url
482
+ )
483
+
484
+ for anchor in soup.find_all(
485
+ "a",
486
+ href=True,
487
+ ):
488
+ href = anchor.get("href")
489
+
490
+ if not isinstance(href, str):
491
+ continue
492
+
493
+ anchor_text = self._clean_text(
494
+ anchor.get_text(
495
+ separator=" ",
496
+ strip=True,
497
+ )
498
+ ).lower()
499
+
500
+ normalized_href = href.strip().lower()
501
+
502
+ searchable_value = (
503
+ f"{anchor_text} {normalized_href}"
504
+ )
505
+
506
+ if not any(
507
+ keyword in searchable_value
508
+ for keyword in (
509
+ self.APPLICATION_KEYWORDS
510
+ )
511
+ ):
512
+ continue
513
+
514
+ candidate_url = urljoin(
515
+ final_url,
516
+ href.strip(),
517
+ )
518
+
519
+ parsed = urlparse(candidate_url)
520
+
521
+ if parsed.scheme not in {
522
+ "http",
523
+ "https",
524
+ }:
525
+ continue
526
+
527
+ if (
528
+ self._normalized_domain(
529
+ candidate_url
530
+ )
531
+ != base_domain
532
+ ):
533
+ continue
534
+
535
+ if candidate_url not in urls:
536
+ urls.append(candidate_url)
537
+
538
+ if len(urls) >= 6:
539
+ break
540
+
541
+ return urls
542
+
543
+ async def _fetch_page(
544
+ self,
545
+ *,
546
+ client: httpx.AsyncClient,
547
+ url: str,
548
+ ) -> tuple[str, str] | None:
549
+ try:
550
+ response = await client.get(
551
+ url,
552
+ headers=self._build_headers(),
553
+ )
554
+
555
+ response.raise_for_status()
556
+
557
+ except httpx.HTTPError:
558
+ return None
559
+
560
+ content_type = response.headers.get(
561
+ "content-type",
562
+ "",
563
+ ).lower()
564
+
565
+ if "text/html" not in content_type:
566
+ return None
567
+
568
+ return response.text, str(response.url)
569
+
570
+ @classmethod
571
+ def _extract_deadline(
572
+ cls,
573
+ page_text: str,
574
+ ) -> date | None:
575
+ candidates: list[date] = []
576
+
577
+ for context_match in (
578
+ cls.DEADLINE_CONTEXT_PATTERN
579
+ .finditer(page_text)
580
+ ):
581
+ context_start = max(
582
+ 0,
583
+ context_match.start() - 30,
584
+ )
585
+
586
+ context_end = min(
587
+ len(page_text),
588
+ context_match.end() + 220,
589
+ )
590
+
591
+ context = page_text[
592
+ context_start:context_end
593
+ ]
594
+
595
+ parsed_dates = (
596
+ cls._extract_dates_from_text(
597
+ context
598
+ )
599
+ )
600
+
601
+ candidates.extend(parsed_dates)
602
+
603
+ future_dates = sorted(
604
+ {
605
+ candidate
606
+ for candidate in candidates
607
+ if candidate >= date.today()
608
+ }
609
+ )
610
+
611
+ if not future_dates:
612
+ return None
613
+
614
+ # La première échéance future associée
615
+ # explicitement à une candidature est retenue.
616
+ return future_dates[0]
617
+
618
+ @classmethod
619
+ def _extract_dates_from_text(
620
+ cls,
621
+ text: str,
622
+ ) -> list[date]:
623
+ parsed_dates: list[date] = []
624
+
625
+ for pattern in cls.DATE_PATTERNS:
626
+ for match in re.finditer(
627
+ pattern,
628
+ text,
629
+ flags=re.IGNORECASE,
630
+ ):
631
+ parsed_date = (
632
+ cls._build_date_from_match(
633
+ match
634
+ )
635
+ )
636
+
637
+ if parsed_date is not None:
638
+ parsed_dates.append(
639
+ parsed_date
640
+ )
641
+
642
+ return parsed_dates
643
+
644
+ @classmethod
645
+ def _build_date_from_match(
646
+ cls,
647
+ match: re.Match[str],
648
+ ) -> date | None:
649
+ groups = match.groupdict()
650
+
651
+ try:
652
+ day = int(groups["day"])
653
+ year = int(groups["year"])
654
+
655
+ month_name = groups.get(
656
+ "month"
657
+ )
658
+
659
+ if month_name:
660
+ month = cls.MONTHS[
661
+ month_name.lower()
662
+ ]
663
+ else:
664
+ month = int(
665
+ groups["month_number"]
666
+ )
667
+
668
+ return date(
669
+ year,
670
+ month,
671
+ day,
672
+ )
673
+
674
+ except (
675
+ KeyError,
676
+ TypeError,
677
+ ValueError,
678
+ ):
679
+ return None
680
+
681
+ @classmethod
682
+ def _scholarship_is_available(
683
+ cls,
684
+ page_text: str,
685
+ ) -> bool:
686
+ if cls.SCHOLARSHIP_UNAVAILABLE_PATTERN.search(
687
+ page_text
688
+ ):
689
+ return False
690
+
691
+ return (
692
+ cls.SCHOLARSHIP_PATTERN.search(
693
+ page_text
694
+ )
695
+ is not None
696
+ )
697
+
698
+ @classmethod
699
+ def _extract_metadata(
700
+ cls,
701
+ *,
702
+ html: str,
703
+ page_url: str,
704
+ ) -> tuple[str | None, str | None]:
705
+ soup = BeautifulSoup(
706
+ html,
707
+ "html.parser",
708
+ )
709
+
710
+ description_element = (
711
+ soup.find(
712
+ "meta",
713
+ attrs={
714
+ "property": "og:description"
715
+ },
716
+ )
717
+ or soup.find(
718
+ "meta",
719
+ attrs={"name": "description"},
720
+ )
721
+ or soup.find(
722
+ "meta",
723
+ attrs={
724
+ "name": "twitter:description"
725
+ },
726
+ )
727
+ )
728
+
729
+ image_element = (
730
+ soup.find(
731
+ "meta",
732
+ attrs={"property": "og:image"},
733
+ )
734
+ or soup.find(
735
+ "meta",
736
+ attrs={"name": "twitter:image"},
737
+ )
738
+ )
739
+
740
+ description: str | None = None
741
+ image_url: str | None = None
742
+
743
+ if description_element is not None:
744
+ content = description_element.get(
745
+ "content"
746
+ )
747
+
748
+ if isinstance(content, str):
749
+ normalized_description = (
750
+ cls._clean_text(content)
751
+ )
752
+
753
+ if normalized_description:
754
+ description = (
755
+ normalized_description
756
+ )
757
+
758
+ if image_element is not None:
759
+ content = image_element.get(
760
+ "content"
761
+ )
762
+
763
+ if isinstance(content, str):
764
+ normalized_url = (
765
+ content.strip()
766
+ )
767
+
768
+ if normalized_url:
769
+ image_url = urljoin(
770
+ page_url,
771
+ normalized_url,
772
+ )
773
+
774
+ return description, image_url
775
+
776
+ @classmethod
777
+ def _extract_page_text(
778
+ cls,
779
+ html: str,
780
+ ) -> str:
781
+ soup = BeautifulSoup(
782
+ html,
783
+ "html.parser",
784
+ )
785
+
786
+ for element in soup(
787
+ [
788
+ "script",
789
+ "style",
790
+ "noscript",
791
+ "svg",
792
+ "header",
793
+ "footer",
794
+ "nav",
795
+ ]
796
+ ):
797
+ element.decompose()
798
+
799
+ main_content = (
800
+ soup.find("main")
801
+ or soup.find("article")
802
+ or soup.body
803
+ )
804
+
805
+ if main_content is None:
806
+ raise ErasmusMundusCollectorError(
807
+ "Le contenu principal du programme "
808
+ "Erasmus Mundus est introuvable."
809
+ )
810
+
811
+ page_text = main_content.get_text(
812
+ separator=" ",
813
+ strip=True,
814
+ )
815
+
816
+ normalized_text = cls._clean_text(
817
+ page_text
818
+ )
819
+
820
+ if len(normalized_text) < 100:
821
+ raise ErasmusMundusCollectorError(
822
+ "Le contenu Erasmus Mundus "
823
+ "extrait est anormalement court."
824
+ )
825
+
826
+ return normalized_text
827
+
828
+ @classmethod
829
+ def _build_summary(
830
+ cls,
831
+ *,
832
+ title: str,
833
+ deadline: date,
834
+ description: str | None,
835
+ ) -> str:
836
+ formatted_deadline = deadline.strftime(
837
+ "%d %B %Y"
838
+ )
839
+
840
+ if description:
841
+ summary = description
842
+ else:
843
+ summary = (
844
+ f"{title}. Erasmus Mundus Joint "
845
+ "Master scholarship programme."
846
+ )
847
+
848
+ summary = cls._clean_text(summary)
849
+
850
+ deadline_sentence = (
851
+ f" Application deadline: "
852
+ f"{formatted_deadline}."
853
+ )
854
+
855
+ available_length = (
856
+ 500 - len(deadline_sentence)
857
+ )
858
+
859
+ trimmed_summary = summary[
860
+ :available_length
861
+ ].rstrip(" .")
862
+
863
+ return (
864
+ f"{trimmed_summary}."
865
+ f"{deadline_sentence}"
866
+ )[:500]
867
+
868
+ @staticmethod
869
+ def _extract_publication_date(
870
+ entry: Any,
871
+ ) -> date | None:
872
+ for attribute_name in (
873
+ "published_parsed",
874
+ "updated_parsed",
875
+ ):
876
+ parsed_value = getattr(
877
+ entry,
878
+ attribute_name,
879
+ None,
880
+ )
881
+
882
+ if parsed_value is None:
883
+ continue
884
+
885
+ try:
886
+ timestamp = calendar.timegm(
887
+ parsed_value
888
+ )
889
+
890
+ return datetime.utcfromtimestamp(
891
+ timestamp
892
+ ).date()
893
+
894
+ except (
895
+ TypeError,
896
+ ValueError,
897
+ OverflowError,
898
+ ):
899
+ continue
900
+
901
+ return None
902
+
903
+ @staticmethod
904
+ def _extract_entry_url(
905
+ entry: Any,
906
+ ) -> str | None:
907
+ url = getattr(
908
+ entry,
909
+ "link",
910
+ None,
911
+ )
912
+
913
+ if isinstance(url, str):
914
+ normalized_url = url.strip()
915
+
916
+ if normalized_url:
917
+ return normalized_url
918
+
919
+ return None
920
+
921
+ @classmethod
922
+ def _extract_entry_urls(
923
+ cls,
924
+ entry: Any,
925
+ ) -> list[str]:
926
+ urls: list[str] = []
927
+
928
+ entry_url = cls._extract_entry_url(
929
+ entry
930
+ )
931
+
932
+ if entry_url:
933
+ urls.append(entry_url)
934
+
935
+ links = getattr(
936
+ entry,
937
+ "links",
938
+ [],
939
+ )
940
+
941
+ for link in links:
942
+ href: str | None = None
943
+
944
+ if isinstance(link, dict):
945
+ value = link.get("href")
946
+
947
+ if isinstance(value, str):
948
+ href = value
949
+
950
+ else:
951
+ value = getattr(
952
+ link,
953
+ "href",
954
+ None,
955
+ )
956
+
957
+ if isinstance(value, str):
958
+ href = value
959
+
960
+ if href:
961
+ normalized_url = href.strip()
962
+
963
+ if (
964
+ normalized_url
965
+ and normalized_url not in urls
966
+ ):
967
+ urls.append(
968
+ normalized_url
969
+ )
970
+
971
+ summary = getattr(
972
+ entry,
973
+ "summary",
974
+ "",
975
+ )
976
+
977
+ if isinstance(summary, str):
978
+ soup = BeautifulSoup(
979
+ summary,
980
+ "html.parser",
981
+ )
982
+
983
+ for anchor in soup.find_all(
984
+ "a",
985
+ href=True,
986
+ ):
987
+ href = anchor.get("href")
988
+
989
+ if not isinstance(href, str):
990
+ continue
991
+
992
+ normalized_url = href.strip()
993
+
994
+ if (
995
+ normalized_url
996
+ and normalized_url not in urls
997
+ ):
998
+ urls.append(
999
+ normalized_url
1000
+ )
1001
+
1002
+ return urls
1003
+
1004
+ @classmethod
1005
+ def _is_external_programme_url(
1006
+ cls,
1007
+ url: str,
1008
+ ) -> bool:
1009
+ parsed = urlparse(url)
1010
+
1011
+ if parsed.scheme not in {
1012
+ "http",
1013
+ "https",
1014
+ }:
1015
+ return False
1016
+
1017
+ domain = cls._normalized_domain(
1018
+ url
1019
+ )
1020
+
1021
+ if not domain:
1022
+ return False
1023
+
1024
+ return not any(
1025
+ domain == blocked_domain
1026
+ or domain.endswith(
1027
+ f".{blocked_domain}"
1028
+ )
1029
+ for blocked_domain in (
1030
+ cls.BLOCKED_DOMAINS
1031
+ )
1032
+ )
1033
+
1034
+ @staticmethod
1035
+ def _normalized_domain(
1036
+ url: str,
1037
+ ) -> str:
1038
+ domain = (
1039
+ urlparse(url)
1040
+ .netloc
1041
+ .lower()
1042
+ .split(":")[0]
1043
+ )
1044
+
1045
+ if domain.startswith("www."):
1046
+ domain = domain[4:]
1047
+
1048
+ return domain
1049
+
1050
+ @staticmethod
1051
+ def _clean_text(
1052
+ value: str,
1053
+ ) -> str:
1054
+ return re.sub(
1055
+ r"\s+",
1056
+ " ",
1057
+ value,
1058
+ ).strip()
1059
+
1060
+ @staticmethod
1061
+ def _build_headers() -> dict[str, str]:
1062
+ return {
1063
+ "User-Agent": (
1064
+ "VerifPulseOpportunityCollector/1.0 "
1065
+ "(https://verifpulse-platform-zvit.vercel.app)"
1066
+ ),
1067
+ "Accept": (
1068
+ "text/html,application/xhtml+xml,"
1069
+ "application/rss+xml,"
1070
+ "application/xml;q=0.9,*/*;q=0.8"
1071
+ ),
1072
+ "Accept-Language": (
1073
+ "en-GB,en;q=0.9"
1074
+ ),
1075
+ }
app/collectors/registry.py CHANGED
@@ -12,6 +12,9 @@ from app.collectors.providers.african_union import (
12
  from app.collectors.providers.campus_france import (
13
  CampusFranceCollector,
14
  )
 
 
 
15
  from app.collectors.providers.unesco import (
16
  UnescoCollector,
17
  )
@@ -42,4 +45,5 @@ def get_opportunity_collectors(
42
  MastercardFoundationCollector(),
43
  UnescoCollector(),
44
  CampusFranceCollector(),
 
45
  )
 
12
  from app.collectors.providers.campus_france import (
13
  CampusFranceCollector,
14
  )
15
+ from app.collectors.providers.erasmus_mundus import (
16
+ ErasmusMundusCollector,
17
+ )
18
  from app.collectors.providers.unesco import (
19
  UnescoCollector,
20
  )
 
45
  MastercardFoundationCollector(),
46
  UnescoCollector(),
47
  CampusFranceCollector(),
48
+ ErasmusMundusCollector(),
49
  )
app/services/opportunity_sync.py CHANGED
@@ -108,6 +108,22 @@ SOURCE_CONFIGURATION = {
108
  ),
109
  "source_tier": "A",
110
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  }
112
 
113
 
 
108
  ),
109
  "source_tier": "A",
110
  },
111
+ "erasmus-mundus": {
112
+ "name": "Erasmus Mundus",
113
+ "base_url": (
114
+ "https://erasmus-plus.ec.europa.eu/"
115
+ ),
116
+ "opportunities_url": (
117
+ "https://www.eacea.ec.europa.eu/"
118
+ "scholarships/"
119
+ "erasmus-mundus-catalogue_en"
120
+ ),
121
+ "feed_url": (
122
+ "https://www.eacea.ec.europa.eu/"
123
+ "node/253/rss_en"
124
+ ),
125
+ "source_tier": "A",
126
+ },
127
  }
128
 
129
 
requirements.txt CHANGED
@@ -6,4 +6,5 @@ psycopg[binary]>=3.2,<4.0
6
  alembic>=1.16,<2.0
7
  httpx>=0.28,<1.0
8
  beautifulsoup4>=4.13,<5.0
9
- pypdf>=3.17,<4.0
 
 
6
  alembic>=1.16,<2.0
7
  httpx>=0.28,<1.0
8
  beautifulsoup4>=4.13,<5.0
9
+ pypdf>=3.17,<4.0
10
+ feedparser>=6.0.11,<7.0.0
scripts/check_erasmus_mundus_collector.py ADDED
File without changes