angeetoile commited on
Commit
193deec
·
1 Parent(s): 3f360a2

feat(collectors): add University of Buea opportunity 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_minesup_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_university_buea_collector && uvicorn app.main:app --host 0.0.0.0 --port 7860"]
app/collectors/providers/university_buea.py CHANGED
@@ -0,0 +1,895 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import re
6
+ import unicodedata
7
+ from datetime import date, datetime, timedelta
8
+ from io import BytesIO
9
+ from pathlib import PurePosixPath
10
+ from urllib.parse import urljoin, urlparse
11
+
12
+ import httpx
13
+ from bs4 import BeautifulSoup
14
+ from pypdf import PdfReader
15
+
16
+ from app.collectors.base import BaseOpportunityCollector
17
+ from app.collectors.types import CollectedOpportunity
18
+
19
+
20
+ logging.getLogger("pypdf").setLevel(logging.ERROR)
21
+
22
+
23
+ class UniversityBueaCollectorError(RuntimeError):
24
+ """Controlled University of Buea collection error."""
25
+
26
+
27
+ class UniversityBueaCollector(BaseOpportunityCollector):
28
+ """Collect open opportunities from official University of Buea pages."""
29
+
30
+ source_slug = "university-buea"
31
+
32
+ BASE_URL = "https://www.ubuea.cm/"
33
+ LISTING_URLS = (
34
+ "https://www.ubuea.cm/",
35
+ "https://www.ubuea.cm/index.php/announcements/",
36
+ "https://www.ubuea.cm/index.php/about-us/job-opportunities/",
37
+ "https://www.ubuea.cm/index.php/news-updates/",
38
+ "https://www.ubuea.cm/index.php/news-updates/page/2/",
39
+ "https://www.ubuea.cm/index.php/news-updates/page/3/",
40
+ )
41
+
42
+ ALLOWED_HOSTS = {
43
+ "www.ubuea.cm",
44
+ "ubuea.cm",
45
+ "www.ubstudent.online",
46
+ "ubstudent.online",
47
+ }
48
+
49
+ REQUEST_TIMEOUT = 45.0
50
+ MAX_CONCURRENT_REQUESTS = 5
51
+ MAX_CANDIDATES = 100
52
+ MAX_DOCUMENTS_PER_PAGE = 8
53
+ MAX_DOCUMENT_SIZE = 20 * 1024 * 1024
54
+ MAX_PDF_PAGES = 40
55
+ MAX_UNDATED_AGE_DAYS = 150
56
+
57
+ OPPORTUNITY_PATTERNS = (
58
+ r"\bcall\s+for\s+applications?\b",
59
+ r"\bapplications?\s+(?:are\s+)?open\b",
60
+ r"\bapply\s+(?:now|for)\b",
61
+ r"\badmissions?\b",
62
+ r"\bentrance\s+exam(?:ination)?s?\b",
63
+ r"\bcompetitive\s+entrance\b",
64
+ r"\bscholarships?\b",
65
+ r"\bfellowships?\b",
66
+ r"\binternships?\b",
67
+ r"\bjob\s+(?:advert|opportunit(?:y|ies)|position)\b",
68
+ r"\bopen\s+positions?\b",
69
+ r"\bresearch\s+assistants?\b",
70
+ r"\bproject\s+(?:manager|assistant)\b",
71
+ r"\bvacanc(?:y|ies)\b",
72
+ r"\brecruitment\s+notice\b",
73
+ r"\btraining\s+(?:programme|program|opportunity)\b",
74
+ r"\berasmus\s+programme\b",
75
+ r"\bgrants?\b",
76
+ )
77
+
78
+ RESULT_PATTERNS = (
79
+ r"\badmission\s+lists?\b",
80
+ r"\blist\s+of\s+(?:admitted|successful)\s+candidates\b",
81
+ r"\bresults?\s+(?:published|released)\b",
82
+ r"\bentrance\s+exam(?:ination)?\s+results?\b",
83
+ r"\bselected\s+candidates\b",
84
+ r"\bshortlisted\s+candidates\b",
85
+ )
86
+
87
+ CLOSED_PATTERNS = (
88
+ r"\bapplications?\s+(?:are\s+)?closed\b",
89
+ r"\bapplication\s+period\s+has\s+ended\b",
90
+ r"\bdeadline\s+has\s+passed\b",
91
+ r"\bposition\s+(?:has\s+been|is)\s+filled\b",
92
+ r"\bexpired\b",
93
+ )
94
+
95
+ CANDIDATE_LINK_PATTERNS = OPPORTUNITY_PATTERNS + (
96
+ r"\bapplication\s+forms?\b",
97
+ r"\bhttc\b",
98
+ r"\bhtttc\b",
99
+ r"\bjob\b",
100
+ )
101
+
102
+ DEADLINE_CONTEXT_PATTERNS = (
103
+ r"application\s+deadline",
104
+ r"deadline",
105
+ r"closing\s+date",
106
+ r"submit(?:ted)?\s+(?:on|by|before)",
107
+ r"submission\s+deadline",
108
+ r"not\s+later\s+than",
109
+ r"on\s+or\s+before",
110
+ r"latest\s+by",
111
+ r"before",
112
+ )
113
+
114
+ MONTHS = {
115
+ "january": 1,
116
+ "february": 2,
117
+ "march": 3,
118
+ "april": 4,
119
+ "may": 5,
120
+ "june": 6,
121
+ "july": 7,
122
+ "august": 8,
123
+ "september": 9,
124
+ "october": 10,
125
+ "november": 11,
126
+ "december": 12,
127
+ }
128
+
129
+ def __init__(self, client: httpx.AsyncClient | None = None) -> None:
130
+ self._external_client = client
131
+
132
+ async def collect(self) -> list[CollectedOpportunity]:
133
+ headers = self._build_headers()
134
+
135
+ if self._external_client is not None:
136
+ return await self._collect_with_client(
137
+ client=self._external_client,
138
+ headers=headers,
139
+ )
140
+
141
+ async with httpx.AsyncClient(
142
+ timeout=self.REQUEST_TIMEOUT,
143
+ follow_redirects=True,
144
+ headers=headers,
145
+ ) as client:
146
+ return await self._collect_with_client(
147
+ client=client,
148
+ headers=headers,
149
+ )
150
+
151
+ async def _collect_with_client(
152
+ self,
153
+ *,
154
+ client: httpx.AsyncClient,
155
+ headers: dict[str, str],
156
+ ) -> list[CollectedOpportunity]:
157
+ listing_results = await asyncio.gather(
158
+ *(
159
+ self._request_html(client=client, url=url, headers=headers)
160
+ for url in self.LISTING_URLS
161
+ ),
162
+ return_exceptions=True,
163
+ )
164
+
165
+ candidates: list[tuple[str, str]] = []
166
+
167
+ for listing_url, result in zip(
168
+ self.LISTING_URLS,
169
+ listing_results,
170
+ strict=True,
171
+ ):
172
+ if isinstance(result, Exception):
173
+ print(
174
+ "[University of Buea] Listing ignored: "
175
+ f"{listing_url} - {type(result).__name__}: {result}",
176
+ flush=True,
177
+ )
178
+ continue
179
+
180
+ candidates.extend(
181
+ self._extract_candidate_links(
182
+ html=result,
183
+ page_url=listing_url,
184
+ )
185
+ )
186
+
187
+ candidates = self._deduplicate_candidates(candidates)[
188
+ : self.MAX_CANDIDATES
189
+ ]
190
+
191
+ if not candidates:
192
+ raise UniversityBueaCollectorError(
193
+ "No University of Buea opportunity candidate was discovered."
194
+ )
195
+
196
+ print(
197
+ f"[University of Buea] {len(candidates)} candidate(s) discovered.",
198
+ flush=True,
199
+ )
200
+
201
+ semaphore = asyncio.Semaphore(self.MAX_CONCURRENT_REQUESTS)
202
+
203
+ async def collect_one(
204
+ candidate_url: str,
205
+ discovered_title: str,
206
+ ) -> CollectedOpportunity | None:
207
+ async with semaphore:
208
+ return await self._collect_candidate(
209
+ client=client,
210
+ url=candidate_url,
211
+ discovered_title=discovered_title,
212
+ headers=headers,
213
+ )
214
+
215
+ results = await asyncio.gather(
216
+ *(
217
+ collect_one(candidate_url, discovered_title)
218
+ for candidate_url, discovered_title in candidates
219
+ ),
220
+ return_exceptions=True,
221
+ )
222
+
223
+ opportunities: list[CollectedOpportunity] = []
224
+
225
+ for (candidate_url, _), result in zip(
226
+ candidates,
227
+ results,
228
+ strict=True,
229
+ ):
230
+ if isinstance(result, CollectedOpportunity):
231
+ opportunities.append(result)
232
+ elif isinstance(result, Exception):
233
+ print(
234
+ "[University of Buea] Candidate ignored: "
235
+ f"{candidate_url} - {type(result).__name__}: {result}",
236
+ flush=True,
237
+ )
238
+
239
+ opportunities = self._deduplicate_opportunities(opportunities)
240
+
241
+ print(
242
+ f"[University of Buea] {len(opportunities)} "
243
+ "open opportunity/opportunities retained.",
244
+ flush=True,
245
+ )
246
+
247
+ return opportunities
248
+
249
+ async def _collect_candidate(
250
+ self,
251
+ *,
252
+ client: httpx.AsyncClient,
253
+ url: str,
254
+ discovered_title: str,
255
+ headers: dict[str, str],
256
+ ) -> CollectedOpportunity | None:
257
+ response = await self._request_resource(
258
+ client=client,
259
+ url=url,
260
+ headers=headers,
261
+ )
262
+ content_type = response.headers.get("content-type", "").lower()
263
+ path = urlparse(str(response.url)).path.lower()
264
+
265
+ if "application/pdf" in content_type or path.endswith(".pdf"):
266
+ return self._collect_pdf_candidate(
267
+ response=response,
268
+ url=str(response.url),
269
+ discovered_title=discovered_title,
270
+ )
271
+
272
+ if "text/html" not in content_type:
273
+ return None
274
+
275
+ return await self._collect_html_candidate(
276
+ client=client,
277
+ html=response.text,
278
+ url=str(response.url),
279
+ discovered_title=discovered_title,
280
+ headers=headers,
281
+ )
282
+
283
+ def _collect_pdf_candidate(
284
+ self,
285
+ *,
286
+ response: httpx.Response,
287
+ url: str,
288
+ discovered_title: str,
289
+ ) -> CollectedOpportunity | None:
290
+ if len(response.content) > self.MAX_DOCUMENT_SIZE:
291
+ return None
292
+
293
+ pdf_text = self._extract_pdf_text(response.content)
294
+ title = self._normalize_text(discovered_title) or self._title_from_url(url)
295
+ full_text = self._normalize_text(f"{title} {pdf_text}")
296
+
297
+ if not self._is_open_candidate(full_text):
298
+ return None
299
+
300
+ publication_date = self._extract_upload_month(url)
301
+
302
+ return self._build_opportunity(
303
+ title=title,
304
+ official_url=url,
305
+ article_url=url,
306
+ full_text=full_text,
307
+ description=pdf_text,
308
+ publication_date=publication_date,
309
+ document_urls=[url],
310
+ application_url=url,
311
+ image_url=None,
312
+ )
313
+
314
+ async def _collect_html_candidate(
315
+ self,
316
+ *,
317
+ client: httpx.AsyncClient,
318
+ html: str,
319
+ url: str,
320
+ discovered_title: str,
321
+ headers: dict[str, str],
322
+ ) -> CollectedOpportunity | None:
323
+ soup = BeautifulSoup(html, "html.parser")
324
+ title = self._extract_title(soup) or discovered_title
325
+ article_text = self._extract_article_text(soup)
326
+
327
+ if not title or not article_text:
328
+ return None
329
+
330
+ document_urls = self._extract_document_urls(soup=soup, page_url=url)
331
+ document_texts = await self._collect_document_texts(
332
+ client=client,
333
+ document_urls=document_urls,
334
+ headers=headers,
335
+ )
336
+ full_text = self._normalize_text(
337
+ " ".join((title, article_text, *document_texts))
338
+ )
339
+
340
+ if not self._is_open_candidate(full_text):
341
+ return None
342
+
343
+ publication_date = self._extract_publication_date(soup=soup, url=url)
344
+ official_url = document_urls[0] if document_urls else url
345
+
346
+ return self._build_opportunity(
347
+ title=title,
348
+ official_url=official_url,
349
+ article_url=url,
350
+ full_text=full_text,
351
+ description=self._normalize_text(
352
+ " ".join((article_text, *document_texts))
353
+ )[:15000],
354
+ publication_date=publication_date,
355
+ document_urls=document_urls,
356
+ application_url=self._extract_application_url(
357
+ soup=soup,
358
+ page_url=url,
359
+ document_urls=document_urls,
360
+ ),
361
+ image_url=self._extract_image_url(soup=soup, page_url=url),
362
+ )
363
+
364
+ def _build_opportunity(
365
+ self,
366
+ *,
367
+ title: str,
368
+ official_url: str,
369
+ article_url: str,
370
+ full_text: str,
371
+ description: str,
372
+ publication_date: date | None,
373
+ document_urls: list[str],
374
+ application_url: str,
375
+ image_url: str | None,
376
+ ) -> CollectedOpportunity | None:
377
+ if self._contains_any(full_text, self.RESULT_PATTERNS):
378
+ print(
379
+ f"[University of Buea] Result ignored: {title}",
380
+ flush=True,
381
+ )
382
+ return None
383
+
384
+ if self._contains_any(full_text, self.CLOSED_PATTERNS):
385
+ print(
386
+ f"[University of Buea] Closed publication ignored: {title}",
387
+ flush=True,
388
+ )
389
+ return None
390
+
391
+ deadline = self._extract_deadline(full_text)
392
+ today = date.today()
393
+
394
+ if deadline is not None and deadline < today:
395
+ return None
396
+
397
+ requires_manual_review = deadline is None
398
+
399
+ if requires_manual_review:
400
+ if publication_date is None:
401
+ print(
402
+ "[University of Buea] Missing publication date and "
403
+ f"deadline: {title}",
404
+ flush=True,
405
+ )
406
+ return None
407
+
408
+ if publication_date < today - timedelta(
409
+ days=self.MAX_UNDATED_AGE_DAYS
410
+ ):
411
+ return None
412
+
413
+ print(
414
+ f"[University of Buea] Retained for manual review: {title}",
415
+ flush=True,
416
+ )
417
+ else:
418
+ print(
419
+ f"[University of Buea] Open: {title} - "
420
+ f"deadline {deadline.isoformat()}",
421
+ flush=True,
422
+ )
423
+
424
+ category = self._classify_category(full_text)
425
+
426
+ return CollectedOpportunity(
427
+ source_slug=self.source_slug,
428
+ title=self._normalize_text(title)[:250],
429
+ official_url=official_url,
430
+ raw_content=full_text,
431
+ summary=self._build_summary(
432
+ title=title,
433
+ text=description,
434
+ category=category,
435
+ deadline=deadline,
436
+ )[:500],
437
+ description=description[:15000],
438
+ organization_name="University of Buea",
439
+ category=category,
440
+ country_code="CM",
441
+ location="Buea, Cameroon",
442
+ target_countries=["CM"],
443
+ study_levels=self._extract_study_levels(full_text),
444
+ eligibility={
445
+ "deadline_status": (
446
+ "confirmed" if deadline is not None else "not_extracted"
447
+ ),
448
+ "requires_manual_review": requires_manual_review,
449
+ "article_url": article_url,
450
+ "official_documents": document_urls,
451
+ },
452
+ language="en",
453
+ publication_date=publication_date,
454
+ deadline=deadline,
455
+ application_url=application_url,
456
+ image_url=image_url,
457
+ )
458
+
459
+ async def _collect_document_texts(
460
+ self,
461
+ *,
462
+ client: httpx.AsyncClient,
463
+ document_urls: list[str],
464
+ headers: dict[str, str],
465
+ ) -> list[str]:
466
+ results = await asyncio.gather(
467
+ *(
468
+ self._request_document_text(
469
+ client=client,
470
+ url=url,
471
+ headers=headers,
472
+ )
473
+ for url in document_urls[: self.MAX_DOCUMENTS_PER_PAGE]
474
+ ),
475
+ return_exceptions=True,
476
+ )
477
+ return [result for result in results if isinstance(result, str) and result]
478
+
479
+ async def _request_document_text(
480
+ self,
481
+ *,
482
+ client: httpx.AsyncClient,
483
+ url: str,
484
+ headers: dict[str, str],
485
+ ) -> str:
486
+ try:
487
+ response = await self._request_resource(
488
+ client=client,
489
+ url=url,
490
+ headers=headers,
491
+ )
492
+ except UniversityBueaCollectorError:
493
+ return ""
494
+
495
+ if len(response.content) > self.MAX_DOCUMENT_SIZE:
496
+ return ""
497
+
498
+ content_type = response.headers.get("content-type", "").lower()
499
+ if "application/pdf" in content_type or urlparse(url).path.lower().endswith(
500
+ ".pdf"
501
+ ):
502
+ return self._extract_pdf_text(response.content)
503
+ return ""
504
+
505
+ async def _request_resource(
506
+ self,
507
+ *,
508
+ client: httpx.AsyncClient,
509
+ url: str,
510
+ headers: dict[str, str],
511
+ ) -> httpx.Response:
512
+ try:
513
+ response = await client.get(url, headers=headers)
514
+ response.raise_for_status()
515
+ return response
516
+ except httpx.HTTPError as error:
517
+ raise UniversityBueaCollectorError(
518
+ f"Unable to fetch University of Buea resource: {url}"
519
+ ) from error
520
+
521
+ async def _request_html(
522
+ self,
523
+ *,
524
+ client: httpx.AsyncClient,
525
+ url: str,
526
+ headers: dict[str, str],
527
+ ) -> str:
528
+ response = await self._request_resource(
529
+ client=client,
530
+ url=url,
531
+ headers=headers,
532
+ )
533
+ if "text/html" not in response.headers.get("content-type", "").lower():
534
+ raise UniversityBueaCollectorError(
535
+ f"University of Buea resource is not HTML: {url}"
536
+ )
537
+ return response.text
538
+
539
+ @classmethod
540
+ def _extract_candidate_links(
541
+ cls,
542
+ *,
543
+ html: str,
544
+ page_url: str,
545
+ ) -> list[tuple[str, str]]:
546
+ soup = BeautifulSoup(html, "html.parser")
547
+ candidates: list[tuple[str, str]] = []
548
+
549
+ for anchor in soup.select("main a[href], article a[href], .gdlr-core-page-builder-body a[href]"):
550
+ href = anchor.get("href")
551
+ if not isinstance(href, str):
552
+ continue
553
+
554
+ label = cls._normalize_text(anchor.get_text(" ", strip=True))
555
+ absolute_url = urljoin(page_url, href.strip())
556
+ parsed = urlparse(absolute_url)
557
+
558
+ if parsed.netloc not in cls.ALLOWED_HOSTS:
559
+ continue
560
+ if not cls._looks_like_candidate(label=label, url=absolute_url):
561
+ continue
562
+
563
+ candidates.append((absolute_url, label or cls._title_from_url(absolute_url)))
564
+
565
+ return candidates
566
+
567
+ @classmethod
568
+ def _looks_like_candidate(cls, *, label: str, url: str) -> bool:
569
+ searchable = cls._fold_text(f"{label} {url}")
570
+ return cls._contains_any(searchable, cls.CANDIDATE_LINK_PATTERNS)
571
+
572
+ @classmethod
573
+ def _extract_document_urls(
574
+ cls,
575
+ *,
576
+ soup: BeautifulSoup,
577
+ page_url: str,
578
+ ) -> list[str]:
579
+ urls: list[str] = []
580
+ for anchor in soup.select("article a[href], .entry-content a[href], main a[href]"):
581
+ href = anchor.get("href")
582
+ if not isinstance(href, str):
583
+ continue
584
+ absolute_url = urljoin(page_url, href.strip())
585
+ path = urlparse(absolute_url).path.lower()
586
+ if path.endswith(".pdf") and urlparse(absolute_url).netloc in cls.ALLOWED_HOSTS:
587
+ urls.append(absolute_url)
588
+ return list(dict.fromkeys(urls))
589
+
590
+ @classmethod
591
+ def _extract_title(cls, soup: BeautifulSoup) -> str:
592
+ for selector in ("h1.entry-title", "article h1", "main h1", "h1"):
593
+ element = soup.select_one(selector)
594
+ if element is not None:
595
+ title = cls._normalize_text(element.get_text(" ", strip=True))
596
+ if title:
597
+ return title
598
+ return ""
599
+
600
+ @classmethod
601
+ def _extract_article_text(cls, soup: BeautifulSoup) -> str:
602
+ for unwanted in soup.select(
603
+ "script, style, nav, footer, header, aside, form, noscript, "
604
+ ".comments-area, #comments"
605
+ ):
606
+ unwanted.decompose()
607
+
608
+ for selector in (
609
+ "article .entry-content",
610
+ ".entry-content",
611
+ ".gdlr-core-page-builder-body",
612
+ "article",
613
+ "main",
614
+ ):
615
+ element = soup.select_one(selector)
616
+ if element is None:
617
+ continue
618
+ text = cls._normalize_text(element.get_text(" ", strip=True))
619
+ if len(text) >= 50:
620
+ return text
621
+ return ""
622
+
623
+ @classmethod
624
+ def _extract_pdf_text(cls, content: bytes) -> str:
625
+ try:
626
+ reader = PdfReader(BytesIO(content))
627
+ pages = [
628
+ text
629
+ for page in reader.pages[: cls.MAX_PDF_PAGES]
630
+ if (text := page.extract_text() or "")
631
+ ]
632
+ return cls._normalize_text(" ".join(pages))
633
+ except Exception:
634
+ return ""
635
+
636
+ @classmethod
637
+ def _is_open_candidate(cls, text: str) -> bool:
638
+ return cls._contains_any(text, cls.OPPORTUNITY_PATTERNS)
639
+
640
+ @classmethod
641
+ def _extract_publication_date(
642
+ cls,
643
+ *,
644
+ soup: BeautifulSoup,
645
+ url: str,
646
+ ) -> date | None:
647
+ match = re.search(r"/index\.php/(20\d{2})/(\d{2})/(\d{2})/", url)
648
+ if match:
649
+ parsed = cls._safe_date(
650
+ year=int(match.group(1)),
651
+ month=int(match.group(2)),
652
+ day=int(match.group(3)),
653
+ )
654
+ if parsed is not None:
655
+ return parsed
656
+
657
+ for selector in (
658
+ 'meta[property="article:published_time"]',
659
+ 'meta[itemprop="datePublished"]',
660
+ "article time[datetime]",
661
+ ):
662
+ element = soup.select_one(selector)
663
+ if element is None:
664
+ continue
665
+ value = element.get("content") or element.get("datetime")
666
+ if isinstance(value, str):
667
+ try:
668
+ return datetime.fromisoformat(
669
+ value.strip().replace("Z", "+00:00")
670
+ ).date()
671
+ except ValueError:
672
+ continue
673
+
674
+ return cls._extract_upload_month(url)
675
+
676
+ @classmethod
677
+ def _extract_upload_month(cls, url: str) -> date | None:
678
+ match = re.search(r"/wp-content/uploads/(20\d{2})/(\d{2})/", url)
679
+ if not match:
680
+ return None
681
+ return cls._safe_date(
682
+ year=int(match.group(1)),
683
+ month=int(match.group(2)),
684
+ day=1,
685
+ )
686
+
687
+ @classmethod
688
+ def _extract_deadline(cls, text: str) -> date | None:
689
+ folded = cls._fold_text(cls._normalize_text(text))
690
+ context_pattern = "|".join(cls.DEADLINE_CONTEXT_PATTERNS)
691
+ windows = re.findall(
692
+ rf"(?:{context_pattern}).{{0,240}}",
693
+ folded,
694
+ flags=re.IGNORECASE,
695
+ )
696
+ candidates: list[date] = []
697
+ for window in windows:
698
+ candidates.extend(cls._extract_dates_from_text(window))
699
+ # Keep expired deadlines too: _build_opportunity will reject them.
700
+ # Returning None for a past deadline would incorrectly make an old
701
+ # publication look like a recent item requiring manual review.
702
+ return max(candidates) if candidates else None
703
+
704
+ @classmethod
705
+ def _extract_dates_from_text(cls, text: str) -> list[date]:
706
+ values: list[date] = []
707
+
708
+ for match in re.finditer(
709
+ r"\b([0-3]?\d)[/.\-]([01]?\d)[/.\-](20\d{2})\b",
710
+ text,
711
+ ):
712
+ parsed = cls._safe_date(
713
+ year=int(match.group(3)),
714
+ month=int(match.group(2)),
715
+ day=int(match.group(1)),
716
+ )
717
+ if parsed is not None:
718
+ values.append(parsed)
719
+
720
+ month_names = "|".join(cls.MONTHS)
721
+ pattern = re.compile(
722
+ rf"\b([0-3]?\d)(?:st|nd|rd|th)?\s+"
723
+ rf"({month_names})\s+(20\d{{2}})\b",
724
+ flags=re.IGNORECASE,
725
+ )
726
+ for match in pattern.finditer(text):
727
+ parsed = cls._safe_date(
728
+ year=int(match.group(3)),
729
+ month=cls.MONTHS[match.group(2).lower()],
730
+ day=int(match.group(1)),
731
+ )
732
+ if parsed is not None:
733
+ values.append(parsed)
734
+
735
+ return values
736
+
737
+ @staticmethod
738
+ def _safe_date(*, year: int, month: int, day: int) -> date | None:
739
+ try:
740
+ return date(year, month, day)
741
+ except ValueError:
742
+ return None
743
+
744
+ @classmethod
745
+ def _classify_category(cls, text: str) -> str:
746
+ folded = cls._fold_text(text)
747
+ if re.search(r"\binternships?\b", folded):
748
+ return "internship"
749
+ if re.search(r"\b(scholarships?|fellowships?|grants?)\b", folded):
750
+ return "scholarship"
751
+ if re.search(r"\b(entrance\s+exam|competitive\s+entrance)\b", folded):
752
+ return "competition"
753
+ if re.search(r"\b(job|position|vacancy|research\s+assistant)\b", folded):
754
+ return "job"
755
+ if re.search(r"\btraining\b", folded):
756
+ return "training"
757
+ return "program"
758
+
759
+ @classmethod
760
+ def _extract_study_levels(cls, text: str) -> list[str]:
761
+ folded = cls._fold_text(text)
762
+ patterns = {
763
+ "bachelor": (r"\bundergraduate\b", r"\bbachelor(?:'s)?\b", r"\bfirst cycle\b"),
764
+ "master": (r"\bmasters?\b", r"\bsecond cycle\b", r"\bgraduate\b"),
765
+ "phd": (r"\bph\.?d\.?\b", r"\bdoctoral\b", r"\bthird cycle\b"),
766
+ "hnd": (r"\bhnd\b", r"\bbts\b"),
767
+ }
768
+ return [
769
+ level
770
+ for level, level_patterns in patterns.items()
771
+ if any(re.search(pattern, folded) for pattern in level_patterns)
772
+ ]
773
+
774
+ @classmethod
775
+ def _extract_application_url(
776
+ cls,
777
+ *,
778
+ soup: BeautifulSoup,
779
+ page_url: str,
780
+ document_urls: list[str],
781
+ ) -> str:
782
+ for anchor in soup.select("article a[href], .entry-content a[href], main a[href]"):
783
+ href = anchor.get("href")
784
+ if not isinstance(href, str):
785
+ continue
786
+ label = cls._fold_text(anchor.get_text(" ", strip=True))
787
+ absolute_url = urljoin(page_url, href.strip())
788
+ if absolute_url in document_urls:
789
+ continue
790
+ if any(
791
+ word in label
792
+ for word in ("apply", "application", "register", "admission", "submit")
793
+ ):
794
+ return absolute_url
795
+ return page_url
796
+
797
+ @staticmethod
798
+ def _extract_image_url(
799
+ *,
800
+ soup: BeautifulSoup,
801
+ page_url: str,
802
+ ) -> str | None:
803
+ for selector in ('meta[property="og:image"]', 'meta[name="twitter:image"]'):
804
+ element = soup.select_one(selector)
805
+ if element is not None:
806
+ content = element.get("content")
807
+ if isinstance(content, str) and content.strip():
808
+ return urljoin(page_url, content.strip())
809
+ return None
810
+
811
+ @classmethod
812
+ def _build_summary(
813
+ cls,
814
+ *,
815
+ title: str,
816
+ text: str,
817
+ category: str,
818
+ deadline: date | None,
819
+ ) -> str:
820
+ sentences = re.split(r"(?<=[.!?])\s+", cls._normalize_text(text))
821
+ selected: list[str] = []
822
+ for sentence in sentences:
823
+ if len(sentence) < 35:
824
+ continue
825
+ selected.append(sentence[:350])
826
+ if len(" ".join(selected)) >= 300:
827
+ break
828
+
829
+ summary = " ".join(selected) or (
830
+ f"Official {category} opportunity published by the "
831
+ f"University of Buea: {title}."
832
+ )
833
+ if deadline is not None:
834
+ summary += f" Application deadline: {deadline.isoformat()}."
835
+ else:
836
+ summary += " Consult the official document for the deadline."
837
+ return cls._normalize_text(summary)[:500]
838
+
839
+ @staticmethod
840
+ def _title_from_url(url: str) -> str:
841
+ stem = PurePosixPath(urlparse(url).path).stem
842
+ return re.sub(r"[-_]+", " ", stem).strip()
843
+
844
+ @staticmethod
845
+ def _normalize_text(value: str) -> str:
846
+ return re.sub(r"\s+", " ", value).strip()
847
+
848
+ @staticmethod
849
+ def _fold_text(value: str) -> str:
850
+ normalized = unicodedata.normalize("NFKD", value)
851
+ return "".join(
852
+ character
853
+ for character in normalized
854
+ if not unicodedata.combining(character)
855
+ ).lower()
856
+
857
+ @classmethod
858
+ def _contains_any(cls, text: str, patterns: tuple[str, ...]) -> bool:
859
+ folded = cls._fold_text(text)
860
+ return any(re.search(pattern, folded) for pattern in patterns)
861
+
862
+ @staticmethod
863
+ def _deduplicate_candidates(
864
+ candidates: list[tuple[str, str]],
865
+ ) -> list[tuple[str, str]]:
866
+ unique: dict[str, tuple[str, str]] = {}
867
+ for url, title in candidates:
868
+ key = url.rstrip("/").lower()
869
+ unique[key] = (url, title)
870
+ return list(unique.values())
871
+
872
+ @staticmethod
873
+ def _deduplicate_opportunities(
874
+ opportunities: list[CollectedOpportunity],
875
+ ) -> list[CollectedOpportunity]:
876
+ unique: dict[str, CollectedOpportunity] = {}
877
+ for opportunity in opportunities:
878
+ key = opportunity.official_url.rstrip("/").lower()
879
+ unique[key] = opportunity
880
+ return list(unique.values())
881
+
882
+ @staticmethod
883
+ def _build_headers() -> dict[str, str]:
884
+ return {
885
+ "User-Agent": (
886
+ "VerifPulseBot/1.0 (official opportunity verification; "
887
+ "contact@verifpulse.org)"
888
+ ),
889
+ "Accept": (
890
+ "text/html,application/xhtml+xml,"
891
+ "application/pdf;q=0.9,*/*;q=0.8"
892
+ ),
893
+ "Accept-Language": "en-GB,en;q=0.9,fr;q=0.7",
894
+ "Cache-Control": "no-cache",
895
+ }
app/services/opportunity_sync.py CHANGED
@@ -59,6 +59,15 @@ SOURCE_CONFIGURATION = {
59
  ),
60
  "source_tier": "A",
61
  },
 
 
 
 
 
 
 
 
 
62
  }
63
 
64
 
 
59
  ),
60
  "source_tier": "A",
61
  },
62
+ "university-buea": {
63
+ "name": "University of Buea",
64
+ "base_url": "https://www.ubuea.cm/",
65
+ "opportunities_url": (
66
+ "https://www.ubuea.cm/"
67
+ "index.php/announcements/"
68
+ ),
69
+ "source_tier": "A",
70
+ },
71
  }
72
 
73
 
scripts/check_university_buea_collector.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.university_buea import (
9
+ UniversityBueaCollector,
10
+ UniversityBueaCollectorError,
11
+ )
12
+
13
+
14
+ def serialize_value(value: Any) -> Any:
15
+ if isinstance(value, date):
16
+ return value.isoformat()
17
+ if isinstance(value, list):
18
+ return [serialize_value(item) for item in value]
19
+ if isinstance(value, tuple):
20
+ return [serialize_value(item) for item in value]
21
+ if isinstance(value, dict):
22
+ return {
23
+ key: serialize_value(item)
24
+ for key, item in value.items()
25
+ }
26
+ return value
27
+
28
+
29
+ async def main() -> None:
30
+ collector = UniversityBueaCollector()
31
+
32
+ print(
33
+ "[collector-check] Starting University of Buea "
34
+ "opportunities check...",
35
+ flush=True,
36
+ )
37
+
38
+ opportunities = await collector.collect()
39
+
40
+ if not opportunities:
41
+ raise RuntimeError(
42
+ "University of Buea returned no current opportunity."
43
+ )
44
+
45
+ for opportunity in opportunities:
46
+ payload = serialize_value(asdict(opportunity))
47
+ raw_content = payload.pop("raw_content", "")
48
+ payload["raw_content_length"] = len(raw_content)
49
+
50
+ print(
51
+ json.dumps(
52
+ payload,
53
+ indent=2,
54
+ ensure_ascii=False,
55
+ ),
56
+ flush=True,
57
+ )
58
+
59
+ print(
60
+ "[collector-check] University of Buea check passed: "
61
+ f"{len(opportunities)} opportunity collected.",
62
+ flush=True,
63
+ )
64
+
65
+
66
+ if __name__ == "__main__":
67
+ try:
68
+ asyncio.run(main())
69
+ except UniversityBueaCollectorError as error:
70
+ print(
71
+ f"[collector-check] Collection failed: {error}",
72
+ file=sys.stderr,
73
+ flush=True,
74
+ )
75
+ raise SystemExit(1) from error
76
+ except Exception as error:
77
+ print(
78
+ "[collector-check] Unexpected failure: "
79
+ f"{type(error).__name__}: {error}",
80
+ file=sys.stderr,
81
+ flush=True,
82
+ )
83
+ raise SystemExit(1) from error