angeetoile commited on
Commit
45ab3ad
·
1 Parent(s): fef2543

feat(collectors): add African Union opportunities 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_university_bamenda_collector && uvicorn app.main:app --host 0.0.0.0 --port 7860"]
 
19
 
20
  EXPOSE 7860
21
 
22
+ CMD ["sh", "-c", "alembic upgrade head && python -m scripts.check_african_union_collector && uvicorn app.main:app --host 0.0.0.0 --port 7860"]
app/collectors/providers/african_union.py ADDED
@@ -0,0 +1,807 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import re
5
+ from dataclasses import dataclass
6
+ from datetime import date, datetime, timedelta
7
+ from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse
8
+
9
+ import httpx
10
+ from bs4 import BeautifulSoup
11
+
12
+ from app.collectors.base import BaseOpportunityCollector
13
+ from app.collectors.types import CollectedOpportunity
14
+
15
+
16
+ class AfricanUnionCollectorError(RuntimeError):
17
+ """Raised when an official African Union resource cannot be read."""
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class Candidate:
22
+ title: str
23
+ url: str
24
+ channel: str
25
+ publication_date: date | None = None
26
+
27
+
28
+ class AfricanUnionCollector(BaseOpportunityCollector):
29
+ """Collect current jobs, internships and programme opportunities."""
30
+
31
+ source_slug = "african-union"
32
+
33
+ BASE_URL = "https://au.int/"
34
+ JOBS_URL = "https://jobs.au.int/"
35
+ JOB_SEARCH_URLS = (
36
+ "https://jobs.au.int/",
37
+ "https://jobs.au.int/search/",
38
+ (
39
+ "https://jobs.au.int/search/"
40
+ "?createNewAlert=false&q=&locationsearch="
41
+ ),
42
+ )
43
+ ANNOUNCEMENT_URLS = (
44
+ "https://au.int/en/announcements",
45
+ "https://au.int/en/pressreleases",
46
+ )
47
+
48
+ ALLOWED_HOSTS = {
49
+ "au.int",
50
+ "www.au.int",
51
+ "jobs.au.int",
52
+ "careers.au.int",
53
+ "career2.successfactors.eu",
54
+ }
55
+
56
+ OPPORTUNITY_PATTERNS = (
57
+ r"\binternship(?:s)?\b",
58
+ r"\bfellowship(?:s)?\b",
59
+ r"\bscholarship(?:s)?\b",
60
+ r"\bvacanc(?:y|ies)\b",
61
+ r"\bjob(?:s)?\b",
62
+ r"\bcareer(?:s)?\b",
63
+ r"\bcall\s+for\s+(?:applications?|candidates?|proposals?)\b",
64
+ r"\bapplications?\s+(?:are\s+)?open\b",
65
+ r"\byouth\s+volunteer\b",
66
+ r"\bvolunteer(?:ing|s)?\b",
67
+ r"\btraineeship(?:s)?\b",
68
+ r"\bcompetition(?:s)?\b",
69
+ r"\bprogramme?\b",
70
+ )
71
+
72
+ CLOSED_PATTERNS = (
73
+ r"\bapplications?\s+(?:are\s+)?closed\b",
74
+ r"\bcall\s+(?:is\s+)?closed\b",
75
+ r"\bdeadline\s+has\s+passed\b",
76
+ r"\bno\s+longer\s+accepting\b",
77
+ r"\bexpired\b",
78
+ )
79
+
80
+ RESULT_PATTERNS = (
81
+ r"\bselected\s+candidates?\b",
82
+ r"\bsuccessful\s+candidates?\b",
83
+ r"\bselection\s+results?\b",
84
+ r"\bresults?\s+of\s+the\s+call\b",
85
+ r"\bcohort\s+announced\b",
86
+ )
87
+
88
+ MONTHS = {
89
+ "january": 1,
90
+ "february": 2,
91
+ "march": 3,
92
+ "april": 4,
93
+ "may": 5,
94
+ "june": 6,
95
+ "july": 7,
96
+ "august": 8,
97
+ "september": 9,
98
+ "october": 10,
99
+ "november": 11,
100
+ "december": 12,
101
+ "jan": 1,
102
+ "feb": 2,
103
+ "mar": 3,
104
+ "apr": 4,
105
+ "jun": 6,
106
+ "jul": 7,
107
+ "aug": 8,
108
+ "sep": 9,
109
+ "sept": 9,
110
+ "oct": 10,
111
+ "nov": 11,
112
+ "dec": 12,
113
+ }
114
+
115
+ COUNTRY_CODES = {
116
+ "algeria": "DZ",
117
+ "cameroon": "CM",
118
+ "ethiopia": "ET",
119
+ "gambia": "GM",
120
+ "ghana": "GH",
121
+ "kenya": "KE",
122
+ "morocco": "MA",
123
+ "nigeria": "NG",
124
+ "rwanda": "RW",
125
+ "senegal": "SN",
126
+ "south africa": "ZA",
127
+ "tanzania": "TZ",
128
+ "tunisia": "TN",
129
+ "uganda": "UG",
130
+ "zambia": "ZM",
131
+ "zimbabwe": "ZW",
132
+ }
133
+
134
+ MAX_ARTICLE_AGE = timedelta(days=180)
135
+
136
+ def __init__(self) -> None:
137
+ self._client = httpx.AsyncClient(
138
+ timeout=httpx.Timeout(45.0, connect=15.0),
139
+ follow_redirects=True,
140
+ headers={
141
+ "User-Agent": (
142
+ "Mozilla/5.0 (compatible; VerifPulseBot/1.0; "
143
+ "+https://verifpulse-platform.vercel.app)"
144
+ ),
145
+ "Accept-Language": "en,fr;q=0.8",
146
+ },
147
+ )
148
+ self._semaphore = asyncio.Semaphore(3)
149
+
150
+ async def collect(self) -> list[CollectedOpportunity]:
151
+ try:
152
+ candidates = await self._discover_candidates()
153
+ print(
154
+ f"[African Union] {len(candidates)} unique candidate(s) "
155
+ "discovered.",
156
+ flush=True,
157
+ )
158
+
159
+ results = await asyncio.gather(
160
+ *(self._inspect_candidate(candidate) for candidate in candidates),
161
+ return_exceptions=True,
162
+ )
163
+
164
+ opportunities: list[CollectedOpportunity] = []
165
+ for candidate, result in zip(candidates, results):
166
+ if isinstance(result, Exception):
167
+ print(
168
+ "[African Union] Candidate ignored: "
169
+ f"{candidate.url} - {type(result).__name__}: {result}",
170
+ flush=True,
171
+ )
172
+ elif result is not None:
173
+ opportunities.append(result)
174
+
175
+ opportunities = self._deduplicate(opportunities)
176
+ opportunities.sort(
177
+ key=lambda item: (
178
+ item.deadline is not None,
179
+ item.deadline or date.max,
180
+ item.title.casefold(),
181
+ )
182
+ )
183
+
184
+ print(
185
+ f"[African Union] {len(opportunities)} current "
186
+ "opportunity/opportunities retained.",
187
+ flush=True,
188
+ )
189
+ return opportunities
190
+ finally:
191
+ await self._client.aclose()
192
+
193
+ async def _discover_candidates(self) -> list[Candidate]:
194
+ jobs, announcements = await asyncio.gather(
195
+ self._discover_job_candidates(),
196
+ self._discover_announcement_candidates(),
197
+ )
198
+
199
+ unique: dict[str, Candidate] = {}
200
+ for candidate in (*jobs, *announcements):
201
+ key = self._candidate_key(candidate)
202
+ unique.setdefault(key, candidate)
203
+ return list(unique.values())
204
+
205
+ async def _discover_job_candidates(self) -> list[Candidate]:
206
+ discovered: list[Candidate] = []
207
+
208
+ for listing_url in self.JOB_SEARCH_URLS:
209
+ try:
210
+ response = await self._get(listing_url)
211
+ except AfricanUnionCollectorError as error:
212
+ print(
213
+ f"[African Union] Jobs listing ignored: {listing_url} - {error}",
214
+ flush=True,
215
+ )
216
+ continue
217
+
218
+ soup = BeautifulSoup(response.text, "html.parser")
219
+ count_before = len(discovered)
220
+
221
+ for link in soup.select("a[href]"):
222
+ url = self._canonical_url(urljoin(str(response.url), link["href"]))
223
+ if urlparse(url).hostname != "jobs.au.int" or "/job/" not in url:
224
+ continue
225
+
226
+ title = self._clean_text(link.get_text(" ", strip=True))
227
+ if not title or title.casefold() in {"view job", "view details"}:
228
+ title = self._title_from_job_url(url)
229
+
230
+ discovered.append(Candidate(title=title, url=url, channel="jobs"))
231
+
232
+ print(
233
+ "[African Union] "
234
+ f"{len(discovered) - count_before} job candidate(s) found on "
235
+ f"{listing_url}",
236
+ flush=True,
237
+ )
238
+
239
+ return discovered
240
+
241
+ async def _discover_announcement_candidates(self) -> list[Candidate]:
242
+ discovered: list[Candidate] = []
243
+
244
+ for listing_url in self.ANNOUNCEMENT_URLS:
245
+ try:
246
+ response = await self._get(listing_url)
247
+ except AfricanUnionCollectorError as error:
248
+ print(
249
+ "[African Union] Announcement listing ignored: "
250
+ f"{listing_url} - {error}",
251
+ flush=True,
252
+ )
253
+ continue
254
+
255
+ soup = BeautifulSoup(response.text, "html.parser")
256
+ count_before = len(discovered)
257
+
258
+ for link in soup.select("a[href]"):
259
+ title = self._clean_text(link.get_text(" ", strip=True))
260
+ if not title or not self._matches(title, self.OPPORTUNITY_PATTERNS):
261
+ continue
262
+ if self._matches(title, self.RESULT_PATTERNS):
263
+ continue
264
+
265
+ url = self._canonical_url(urljoin(str(response.url), link["href"]))
266
+ parsed = urlparse(url)
267
+ if parsed.hostname not in {"au.int", "www.au.int"}:
268
+ continue
269
+ if not any(
270
+ section in parsed.path
271
+ for section in ("/announcements/", "/pressreleases/")
272
+ ):
273
+ continue
274
+
275
+ publication_date = self._extract_date_from_context(link)
276
+ discovered.append(
277
+ Candidate(
278
+ title=title,
279
+ url=url,
280
+ channel="announcement",
281
+ publication_date=publication_date,
282
+ )
283
+ )
284
+
285
+ print(
286
+ "[African Union] "
287
+ f"{len(discovered) - count_before} programme candidate(s) found "
288
+ f"on {listing_url}",
289
+ flush=True,
290
+ )
291
+
292
+ return discovered
293
+
294
+ async def _inspect_candidate(
295
+ self,
296
+ candidate: Candidate,
297
+ ) -> CollectedOpportunity | None:
298
+ response = await self._get(candidate.url)
299
+ soup = BeautifulSoup(response.text, "html.parser")
300
+
301
+ if candidate.channel == "jobs":
302
+ return self._parse_job(candidate, soup, str(response.url))
303
+ return self._parse_announcement(candidate, soup, str(response.url))
304
+
305
+ def _parse_job(
306
+ self,
307
+ candidate: Candidate,
308
+ soup: BeautifulSoup,
309
+ final_url: str,
310
+ ) -> CollectedOpportunity | None:
311
+ text = self._main_text(soup)
312
+ normalized = text.casefold()
313
+
314
+ if self._matches(normalized, self.CLOSED_PATTERNS):
315
+ return None
316
+
317
+ req_id = self._field(text, "Req Id")
318
+ title = self._field(text, "Job Title") or candidate.title
319
+ employee_class = self._field(text, "Employee Class")
320
+ location = self._field(text, "Job Location (country)")
321
+ department = self._field(text, "Department")
322
+ au_organ = self._field(text, "AU Organ")
323
+
324
+ publication_date = self._parse_date(
325
+ self._field(text, "Posting Start Date")
326
+ )
327
+ deadline = self._parse_date(self._field(text, "End Date"))
328
+ if deadline is None:
329
+ deadline = self._extract_deadline(text)
330
+
331
+ if deadline is not None and deadline < date.today():
332
+ return None
333
+
334
+ category = self._job_category(title, employee_class)
335
+ summary = self._job_summary(soup, text, title)
336
+ application_url = self._extract_apply_url(soup, final_url)
337
+ country_code = self.COUNTRY_CODES.get((location or "").casefold())
338
+
339
+ requires_manual_review = deadline is None
340
+ if requires_manual_review and publication_date:
341
+ if date.today() - publication_date > self.MAX_ARTICLE_AGE:
342
+ return None
343
+
344
+ eligibility_text = self._extract_section(
345
+ text,
346
+ ("Who can Apply?", "Eligibility"),
347
+ ("How to apply?", "Application process", "Selection process"),
348
+ )
349
+
350
+ return CollectedOpportunity(
351
+ source_slug=self.source_slug,
352
+ title=title,
353
+ official_url=self._canonical_url(final_url),
354
+ summary=summary,
355
+ description=self._truncate(text, 8000),
356
+ organization_name=au_organ or "African Union Commission",
357
+ category=category,
358
+ country_code=country_code,
359
+ location=location,
360
+ language="en",
361
+ publication_date=publication_date,
362
+ deadline=deadline,
363
+ application_url=application_url,
364
+ image_url=None,
365
+ target_countries=(),
366
+ study_levels=self._study_levels(text),
367
+ eligibility={
368
+ "deadline_status": (
369
+ "confirmed" if deadline else "not_extracted"
370
+ ),
371
+ "requires_manual_review": requires_manual_review,
372
+ "scope": "African Union member states",
373
+ "req_id": req_id,
374
+ "employee_class": employee_class,
375
+ "department": department,
376
+ "au_organ": au_organ,
377
+ "eligibility_text": self._truncate(eligibility_text, 2000),
378
+ },
379
+ raw_content=text,
380
+ )
381
+
382
+ def _parse_announcement(
383
+ self,
384
+ candidate: Candidate,
385
+ soup: BeautifulSoup,
386
+ final_url: str,
387
+ ) -> CollectedOpportunity | None:
388
+ title = self._article_title(soup) or candidate.title
389
+ text = self._article_text(soup)
390
+ normalized = f"{title} {text}".casefold()
391
+
392
+ if not self._matches(normalized, self.OPPORTUNITY_PATTERNS):
393
+ return None
394
+ if self._matches(normalized, self.CLOSED_PATTERNS):
395
+ return None
396
+ if self._matches(normalized, self.RESULT_PATTERNS):
397
+ return None
398
+
399
+ publication_date = (
400
+ self._extract_publication_date(soup)
401
+ or candidate.publication_date
402
+ )
403
+ deadline = self._extract_deadline(text)
404
+ if deadline is not None and deadline < date.today():
405
+ return None
406
+
407
+ requires_manual_review = deadline is None
408
+ if requires_manual_review:
409
+ if publication_date is None:
410
+ return None
411
+ if date.today() - publication_date > self.MAX_ARTICLE_AGE:
412
+ return None
413
+
414
+ documents = self._official_documents(soup, final_url)
415
+ application_url = self._extract_application_url(
416
+ soup,
417
+ final_url,
418
+ documents,
419
+ )
420
+ category = self._programme_category(normalized)
421
+
422
+ return CollectedOpportunity(
423
+ source_slug=self.source_slug,
424
+ title=title,
425
+ official_url=self._canonical_url(final_url),
426
+ summary=self._summary(text, title, deadline),
427
+ description=self._truncate(text, 8000),
428
+ organization_name="African Union Commission",
429
+ category=category,
430
+ country_code=None,
431
+ location=None,
432
+ language=self._detect_language(text),
433
+ publication_date=publication_date,
434
+ deadline=deadline,
435
+ application_url=application_url,
436
+ image_url=self._article_image(soup, final_url),
437
+ target_countries=(),
438
+ study_levels=self._study_levels(text),
439
+ eligibility={
440
+ "deadline_status": (
441
+ "confirmed" if deadline else "not_extracted"
442
+ ),
443
+ "requires_manual_review": requires_manual_review,
444
+ "scope": "African Union member states",
445
+ "article_url": self._canonical_url(final_url),
446
+ "official_documents": documents,
447
+ },
448
+ raw_content=text,
449
+ )
450
+
451
+ async def _get(self, url: str) -> httpx.Response:
452
+ self._assert_allowed_url(url)
453
+ async with self._semaphore:
454
+ try:
455
+ response = await self._client.get(url)
456
+ response.raise_for_status()
457
+ except httpx.HTTPError as error:
458
+ raise AfricanUnionCollectorError(
459
+ f"Unable to fetch African Union resource: {url}"
460
+ ) from error
461
+
462
+ self._assert_allowed_url(str(response.url))
463
+ return response
464
+
465
+ def _assert_allowed_url(self, url: str) -> None:
466
+ parsed = urlparse(url)
467
+ if parsed.scheme != "https" or parsed.hostname not in self.ALLOWED_HOSTS:
468
+ raise AfricanUnionCollectorError(
469
+ f"Non-official African Union URL rejected: {url}"
470
+ )
471
+
472
+ @staticmethod
473
+ def _clean_text(value: str) -> str:
474
+ return re.sub(r"\s+", " ", value).strip()
475
+
476
+ @classmethod
477
+ def _main_text(cls, soup: BeautifulSoup) -> str:
478
+ root = soup.select_one("main, #job-content, .job, article") or soup.body
479
+ return cls._clean_text(root.get_text(" ", strip=True) if root else "")
480
+
481
+ @classmethod
482
+ def _article_text(cls, soup: BeautifulSoup) -> str:
483
+ root = soup.select_one(
484
+ "article .field--name-body, article .content, article, main"
485
+ ) or soup.body
486
+ if root is None:
487
+ return ""
488
+ clone = BeautifulSoup(str(root), "html.parser")
489
+ for node in clone.select(
490
+ "nav, header, footer, script, style, .social-share, .related-content"
491
+ ):
492
+ node.decompose()
493
+ return cls._clean_text(clone.get_text(" ", strip=True))
494
+
495
+ @classmethod
496
+ def _article_title(cls, soup: BeautifulSoup) -> str | None:
497
+ node = soup.select_one("h1, article h1, .page-title")
498
+ return cls._clean_text(node.get_text(" ", strip=True)) if node else None
499
+
500
+ @classmethod
501
+ def _field(cls, text: str, label: str) -> str | None:
502
+ labels = (
503
+ "Req Id",
504
+ "Job Title",
505
+ "Posting Start Date",
506
+ "Employee Class",
507
+ "End Date",
508
+ "Job Location (country)",
509
+ "Department",
510
+ "AU Organ",
511
+ )
512
+ next_labels = "|".join(re.escape(item) for item in labels if item != label)
513
+ match = re.search(
514
+ rf"{re.escape(label)}\s*:\s*(.*?)\s*(?=(?:{next_labels})\s*:|$)",
515
+ text,
516
+ flags=re.IGNORECASE,
517
+ )
518
+ value = cls._clean_text(match.group(1)) if match else ""
519
+ return value or None
520
+
521
+ @classmethod
522
+ def _parse_date(cls, value: str | None) -> date | None:
523
+ if not value:
524
+ return None
525
+ cleaned = cls._clean_text(value).replace(",", " ")
526
+
527
+ numeric = re.search(r"\b(\d{1,2})/(\d{1,2})/(\d{2,4})\b", cleaned)
528
+ if numeric:
529
+ month, day, year = (int(part) for part in numeric.groups())
530
+ year += 2000 if year < 100 else 0
531
+ try:
532
+ return date(year, month, day)
533
+ except ValueError:
534
+ return None
535
+
536
+ textual = re.search(
537
+ r"\b([A-Za-z]+)\s+(\d{1,2})(?:st|nd|rd|th)?\s+(\d{4})\b",
538
+ cleaned,
539
+ flags=re.IGNORECASE,
540
+ )
541
+ if textual:
542
+ month_name, day, year = textual.groups()
543
+ month = cls.MONTHS.get(month_name.casefold())
544
+ if month:
545
+ try:
546
+ return date(int(year), month, int(day))
547
+ except ValueError:
548
+ return None
549
+
550
+ reverse = re.search(
551
+ r"\b(\d{1,2})(?:st|nd|rd|th)?\s+([A-Za-z]+)\s+(\d{4})\b",
552
+ cleaned,
553
+ flags=re.IGNORECASE,
554
+ )
555
+ if reverse:
556
+ day, month_name, year = reverse.groups()
557
+ month = cls.MONTHS.get(month_name.casefold())
558
+ if month:
559
+ try:
560
+ return date(int(year), month, int(day))
561
+ except ValueError:
562
+ return None
563
+ return None
564
+
565
+ @classmethod
566
+ def _extract_deadline(cls, text: str) -> date | None:
567
+ patterns = (
568
+ r"(?:applications? must be submitted no later than|"
569
+ r"application deadline|deadline for applications?|closing date|"
570
+ r"deadline)\s*(?:is|:|-)?\s*"
571
+ r"([A-Za-z]+\s+\d{1,2}(?:st|nd|rd|th)?[,]?\s+\d{4})",
572
+ r"(?:applications? must be submitted no later than|"
573
+ r"application deadline|deadline for applications?|closing date|"
574
+ r"deadline)\s*(?:is|:|-)?\s*"
575
+ r"(\d{1,2}\s+[A-Za-z]+\s+\d{4})",
576
+ r"(?:end date)\s*(?:is|:|-)?\s*"
577
+ r"([A-Za-z]+\s+\d{1,2}[,]?\s+\d{4})",
578
+ )
579
+ for pattern in patterns:
580
+ match = re.search(pattern, text, flags=re.IGNORECASE)
581
+ if match:
582
+ parsed = cls._parse_date(match.group(1))
583
+ if parsed:
584
+ return parsed
585
+ return None
586
+
587
+ @classmethod
588
+ def _extract_publication_date(cls, soup: BeautifulSoup) -> date | None:
589
+ for selector in (
590
+ "meta[property='article:published_time']",
591
+ "meta[name='date']",
592
+ "time[datetime]",
593
+ ):
594
+ node = soup.select_one(selector)
595
+ if not node:
596
+ continue
597
+ value = node.get("content") or node.get("datetime") or node.get_text(" ")
598
+ if not value:
599
+ continue
600
+ try:
601
+ return datetime.fromisoformat(str(value).replace("Z", "+00:00")).date()
602
+ except ValueError:
603
+ parsed = cls._parse_date(str(value))
604
+ if parsed:
605
+ return parsed
606
+ return None
607
+
608
+ @classmethod
609
+ def _extract_date_from_context(cls, link) -> date | None:
610
+ parent = link.find_parent(["article", "li", "div"])
611
+ text = parent.get_text(" ", strip=True) if parent else ""
612
+ return cls._parse_date(text)
613
+
614
+ @classmethod
615
+ def _job_category(cls, title: str, employee_class: str | None) -> str:
616
+ value = f"{title} {employee_class or ''}".casefold()
617
+ if "intern" in value:
618
+ return "internship"
619
+ if "fellow" in value:
620
+ return "fellowship"
621
+ return "job"
622
+
623
+ @classmethod
624
+ def _programme_category(cls, text: str) -> str:
625
+ if "internship" in text or "traineeship" in text:
626
+ return "internship"
627
+ if "scholarship" in text:
628
+ return "scholarship"
629
+ if "fellowship" in text:
630
+ return "fellowship"
631
+ if "volunteer" in text:
632
+ return "program"
633
+ if "competition" in text:
634
+ return "competition"
635
+ if "vacancy" in text or re.search(r"\bjob\b", text):
636
+ return "job"
637
+ return "program"
638
+
639
+ @classmethod
640
+ def _study_levels(cls, text: str) -> tuple[str, ...]:
641
+ normalized = text.casefold()
642
+ levels: list[str] = []
643
+ mappings = (
644
+ ("bachelor", ("bachelor", "undergraduate", "first degree")),
645
+ ("master", ("master", "postgraduate")),
646
+ ("phd", ("phd", "doctoral", "doctorate")),
647
+ )
648
+ for level, markers in mappings:
649
+ if any(marker in normalized for marker in markers):
650
+ levels.append(level)
651
+ return tuple(levels)
652
+
653
+ @classmethod
654
+ def _job_summary(cls, soup: BeautifulSoup, text: str, title: str) -> str:
655
+ section = cls._extract_section(
656
+ text,
657
+ ("Job Description", "Background", "Purpose of Job"),
658
+ ("Context", "Main Functions", "Who can Apply?", "Eligibility"),
659
+ )
660
+ if not section:
661
+ section = text
662
+ return cls._summary(section, title, None)
663
+
664
+ @classmethod
665
+ def _summary(cls, text: str, title: str, deadline: date | None) -> str:
666
+ cleaned = cls._clean_text(text)
667
+ if cleaned.casefold().startswith(title.casefold()):
668
+ cleaned = cleaned[len(title):].lstrip(" :-")
669
+ sentence_match = re.search(r"^(.{80,460}?[.!?])(?:\s|$)", cleaned)
670
+ summary = sentence_match.group(1) if sentence_match else cleaned[:460]
671
+ if not summary:
672
+ summary = f"Official African Union opportunity: {title}."
673
+ if deadline is None:
674
+ suffix = " Consult the official page for the closing date."
675
+ summary = summary[: 500 - len(suffix)].rstrip() + suffix
676
+ return summary[:500]
677
+
678
+ @classmethod
679
+ def _extract_section(
680
+ cls,
681
+ text: str,
682
+ starts: tuple[str, ...],
683
+ ends: tuple[str, ...],
684
+ ) -> str:
685
+ start_positions = [
686
+ position
687
+ for marker in starts
688
+ if (position := text.casefold().find(marker.casefold())) >= 0
689
+ ]
690
+ if not start_positions:
691
+ return ""
692
+ start = min(start_positions)
693
+ end_positions = [
694
+ position
695
+ for marker in ends
696
+ if (position := text.casefold().find(marker.casefold(), start + 1)) >= 0
697
+ ]
698
+ end = min(end_positions) if end_positions else min(len(text), start + 2500)
699
+ return cls._clean_text(text[start:end])
700
+
701
+ @classmethod
702
+ def _extract_apply_url(cls, soup: BeautifulSoup, fallback: str) -> str:
703
+ for link in soup.select("a[href]"):
704
+ label = cls._clean_text(link.get_text(" ", strip=True)).casefold()
705
+ if label in {"i'm interested", "apply", "apply now"}:
706
+ return cls._canonical_url(urljoin(fallback, link["href"]))
707
+ return cls._canonical_url(fallback)
708
+
709
+ @classmethod
710
+ def _extract_application_url(
711
+ cls,
712
+ soup: BeautifulSoup,
713
+ fallback: str,
714
+ documents: list[str],
715
+ ) -> str:
716
+ for link in soup.select("a[href]"):
717
+ label = cls._clean_text(link.get_text(" ", strip=True)).casefold()
718
+ if re.search(r"\b(apply|application form|register|submit)\b", label):
719
+ url = cls._canonical_url(urljoin(fallback, link["href"]))
720
+ if urlparse(url).scheme == "https":
721
+ return url
722
+ return documents[0] if documents else cls._canonical_url(fallback)
723
+
724
+ @classmethod
725
+ def _official_documents(cls, soup: BeautifulSoup, base_url: str) -> list[str]:
726
+ documents: list[str] = []
727
+ for link in soup.select("a[href]"):
728
+ url = cls._canonical_url(urljoin(base_url, link["href"]))
729
+ if urlparse(url).path.casefold().endswith((".pdf", ".doc", ".docx")):
730
+ documents.append(url)
731
+ return list(dict.fromkeys(documents))
732
+
733
+ @classmethod
734
+ def _article_image(cls, soup: BeautifulSoup, base_url: str) -> str | None:
735
+ node = soup.select_one("meta[property='og:image'], article img[src]")
736
+ if not node:
737
+ return None
738
+ value = node.get("content") or node.get("src")
739
+ return urljoin(base_url, str(value)) if value else None
740
+
741
+ @staticmethod
742
+ def _detect_language(text: str) -> str:
743
+ french_markers = ("appel à", "candidature", "date limite", "bourse")
744
+ normalized = text.casefold()
745
+ return "fr" if sum(marker in normalized for marker in french_markers) >= 2 else "en"
746
+
747
+ @staticmethod
748
+ def _matches(text: str, patterns: tuple[str, ...]) -> bool:
749
+ return any(re.search(pattern, text, flags=re.IGNORECASE) for pattern in patterns)
750
+
751
+ @classmethod
752
+ def _title_from_job_url(cls, url: str) -> str:
753
+ path = urlparse(url).path
754
+ match = re.search(r"/job/([^/]+)/", path)
755
+ value = match.group(1) if match else "African Union opportunity"
756
+ return cls._clean_text(value.replace("%2C", ",").replace("-", " "))
757
+
758
+ @staticmethod
759
+ def _truncate(value: str, limit: int) -> str:
760
+ return value if len(value) <= limit else value[: limit - 1].rstrip() + "…"
761
+
762
+ @classmethod
763
+ def _canonical_url(cls, url: str) -> str:
764
+ parsed = urlparse(url)
765
+ query = [
766
+ (key, value)
767
+ for key, value in parse_qsl(parsed.query, keep_blank_values=True)
768
+ if not key.casefold().startswith("utm_")
769
+ and key.casefold() not in {"locale", "source", "ref"}
770
+ ]
771
+ return urlunparse(
772
+ (
773
+ parsed.scheme.casefold(),
774
+ parsed.netloc.casefold(),
775
+ re.sub(r"/{2,}", "/", parsed.path).rstrip("/") or "/",
776
+ "",
777
+ urlencode(query),
778
+ "",
779
+ )
780
+ )
781
+
782
+ @classmethod
783
+ def _candidate_key(cls, candidate: Candidate) -> str:
784
+ req_id = re.search(r"/(\d+)(?:-[^/]*)?$", urlparse(candidate.url).path)
785
+ if candidate.channel == "jobs" and req_id:
786
+ return f"job:{req_id.group(1)}"
787
+ return cls._canonical_url(candidate.url)
788
+
789
+ @classmethod
790
+ def _deduplicate(
791
+ cls,
792
+ opportunities: list[CollectedOpportunity],
793
+ ) -> list[CollectedOpportunity]:
794
+ unique: dict[str, CollectedOpportunity] = {}
795
+ for opportunity in opportunities:
796
+ req_id = opportunity.eligibility.get("req_id")
797
+ key = (
798
+ f"job:{req_id}"
799
+ if req_id
800
+ else cls._canonical_url(opportunity.official_url)
801
+ )
802
+ existing = unique.get(key)
803
+ if existing is None or (
804
+ existing.deadline is None and opportunity.deadline is not None
805
+ ):
806
+ unique[key] = opportunity
807
+ return list(unique.values())
app/services/opportunity_sync.py CHANGED
@@ -93,6 +93,12 @@ SOURCE_CONFIGURATION = {
93
  ),
94
  "source_tier": "A",
95
  },
 
 
 
 
 
 
96
  }
97
 
98
 
 
93
  ),
94
  "source_tier": "A",
95
  },
96
+ "african-union": {
97
+ "name": "African Union",
98
+ "base_url": "https://au.int/",
99
+ "opportunities_url": "https://jobs.au.int/",
100
+ "source_tier": "A",
101
+ },
102
  }
103
 
104
 
scripts/check_african_union_collector.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import json
3
+ import sys
4
+ from dataclasses import asdict
5
+ from datetime import date
6
+ from typing import Any
7
+
8
+ from app.collectors.providers.african_union import (
9
+ AfricanUnionCollector,
10
+ AfricanUnionCollectorError,
11
+ )
12
+
13
+
14
+ def serialize_value(value: Any) -> Any:
15
+ if isinstance(value, date):
16
+ return value.isoformat()
17
+ if isinstance(value, (list, tuple)):
18
+ return [serialize_value(item) for item in value]
19
+ if isinstance(value, dict):
20
+ return {key: serialize_value(item) for key, item in value.items()}
21
+ return value
22
+
23
+
24
+ async def main() -> None:
25
+ collector = AfricanUnionCollector()
26
+ print(
27
+ "[collector-check] Starting African Union opportunities check...",
28
+ flush=True,
29
+ )
30
+
31
+ opportunities = await collector.collect()
32
+ if not opportunities:
33
+ raise RuntimeError("African Union returned no current opportunity.")
34
+
35
+ for opportunity in opportunities:
36
+ payload = serialize_value(asdict(opportunity))
37
+ raw_content = payload.pop("raw_content", "")
38
+ payload["raw_content_length"] = len(raw_content)
39
+ print(json.dumps(payload, indent=2, ensure_ascii=False), flush=True)
40
+
41
+ print(
42
+ "[collector-check] African Union check passed: "
43
+ f"{len(opportunities)} opportunity/opportunities collected.",
44
+ flush=True,
45
+ )
46
+
47
+
48
+ if __name__ == "__main__":
49
+ try:
50
+ asyncio.run(main())
51
+ except AfricanUnionCollectorError as error:
52
+ print(
53
+ f"[collector-check] Collection failed: {error}",
54
+ file=sys.stderr,
55
+ flush=True,
56
+ )
57
+ raise SystemExit(1) from error
58
+ except Exception as error:
59
+ print(
60
+ "[collector-check] Unexpected failure: "
61
+ f"{type(error).__name__}: {error}",
62
+ file=sys.stderr,
63
+ flush=True,
64
+ )
65
+ raise SystemExit(1) from error