angeetoile commited on
Commit
782fae9
·
1 Parent(s): a7b91b8

feat: add Chevening opportunity collector

Browse files
Dockerfile CHANGED
@@ -19,4 +19,4 @@ USER appuser
19
 
20
  EXPOSE 7860
21
 
22
- CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860}"]
 
19
 
20
  EXPOSE 7860
21
 
22
+ CMD ["sh", "-c", "alembic upgrade head && python -m scripts.check_chevening_collector && uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860}"]
app/collectors/providers/chevening.py ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ from datetime import date, datetime
3
+
4
+ import httpx
5
+ from bs4 import BeautifulSoup
6
+
7
+ from app.collectors.base import BaseOpportunityCollector
8
+ from app.collectors.types import CollectedOpportunity
9
+
10
+
11
+ class CheveningCollectorError(RuntimeError):
12
+ """Erreur produite pendant la collecte de Chevening."""
13
+
14
+
15
+ class CheveningCollector(BaseOpportunityCollector):
16
+ source_slug = "chevening"
17
+
18
+ TIMELINE_URL = (
19
+ "https://www.chevening.org/"
20
+ "scholarships/application-timeline/"
21
+ )
22
+
23
+ SCHOLARSHIPS_URL = (
24
+ "https://www.chevening.org/scholarships/"
25
+ )
26
+
27
+ REQUEST_TIMEOUT = 30.0
28
+
29
+ def __init__(
30
+ self,
31
+ client: httpx.AsyncClient | None = None,
32
+ ) -> None:
33
+ self._external_client = client
34
+
35
+ async def collect(
36
+ self,
37
+ ) -> list[CollectedOpportunity]:
38
+ html = await self._fetch_timeline_page()
39
+ page_text = self._extract_page_text(html)
40
+
41
+ opening_date = self._extract_opening_date(
42
+ page_text
43
+ )
44
+ deadline = self._extract_deadline(page_text)
45
+
46
+ if deadline is None:
47
+ raise CheveningCollectorError(
48
+ "La date limite Chevening n'a pas "
49
+ "été trouvée sur la page officielle."
50
+ )
51
+
52
+ academic_year = self._build_academic_year(
53
+ opening_date=opening_date,
54
+ deadline=deadline,
55
+ )
56
+
57
+ title = (
58
+ f"Chevening Scholarships {academic_year}"
59
+ )
60
+
61
+ return [
62
+ CollectedOpportunity(
63
+ source_slug=self.source_slug,
64
+ title=title,
65
+ official_url=self.TIMELINE_URL,
66
+ raw_content=page_text,
67
+ organization_name="Chevening",
68
+ category="scholarship",
69
+ language="en",
70
+ publication_date=opening_date,
71
+ deadline=deadline,
72
+ application_url=self.SCHOLARSHIPS_URL,
73
+ )
74
+ ]
75
+
76
+ async def _fetch_timeline_page(self) -> str:
77
+ headers = {
78
+ "User-Agent": (
79
+ "VerifPulseOpportunityCollector/1.0 "
80
+ "(https://verifpulse-platform.vercel.app)"
81
+ ),
82
+ "Accept": (
83
+ "text/html,application/xhtml+xml,"
84
+ "application/xml;q=0.9,*/*;q=0.8"
85
+ ),
86
+ "Accept-Language": "en-GB,en;q=0.9",
87
+ }
88
+
89
+ if self._external_client is not None:
90
+ return await self._request_html(
91
+ self._external_client,
92
+ headers,
93
+ )
94
+
95
+ async with httpx.AsyncClient(
96
+ timeout=self.REQUEST_TIMEOUT,
97
+ follow_redirects=True,
98
+ headers=headers,
99
+ ) as client:
100
+ return await self._request_html(
101
+ client,
102
+ headers,
103
+ )
104
+
105
+ async def _request_html(
106
+ self,
107
+ client: httpx.AsyncClient,
108
+ headers: dict[str, str],
109
+ ) -> str:
110
+ try:
111
+ response = await client.get(
112
+ self.TIMELINE_URL,
113
+ headers=headers,
114
+ )
115
+ response.raise_for_status()
116
+
117
+ except httpx.HTTPError as error:
118
+ raise CheveningCollectorError(
119
+ "Impossible de récupérer la page "
120
+ "officielle Chevening."
121
+ ) from error
122
+
123
+ content_type = response.headers.get(
124
+ "content-type",
125
+ "",
126
+ )
127
+
128
+ if "text/html" not in content_type.lower():
129
+ raise CheveningCollectorError(
130
+ "Chevening n'a pas retourné une page HTML."
131
+ )
132
+
133
+ return response.text
134
+
135
+ @staticmethod
136
+ def _extract_page_text(html: str) -> str:
137
+ soup = BeautifulSoup(html, "html.parser")
138
+
139
+ for element in soup(
140
+ [
141
+ "script",
142
+ "style",
143
+ "noscript",
144
+ "svg",
145
+ "header",
146
+ "footer",
147
+ "nav",
148
+ ]
149
+ ):
150
+ element.decompose()
151
+
152
+ main_content = (
153
+ soup.find("main")
154
+ or soup.find("article")
155
+ or soup.body
156
+ )
157
+
158
+ if main_content is None:
159
+ raise CheveningCollectorError(
160
+ "Le contenu principal de la page "
161
+ "Chevening est introuvable."
162
+ )
163
+
164
+ text = main_content.get_text(
165
+ separator=" ",
166
+ strip=True,
167
+ )
168
+
169
+ normalized_text = re.sub(
170
+ r"\s+",
171
+ " ",
172
+ text,
173
+ ).strip()
174
+
175
+ if len(normalized_text) < 200:
176
+ raise CheveningCollectorError(
177
+ "Le contenu Chevening extrait est "
178
+ "anormalement court."
179
+ )
180
+
181
+ return normalized_text
182
+
183
+ @classmethod
184
+ def _extract_opening_date(
185
+ cls,
186
+ page_text: str,
187
+ ) -> date | None:
188
+ patterns = (
189
+ (
190
+ r"(\d{1,2}\s+[A-Za-z]+\s+\d{4})"
191
+ r".{0,100}?Applications open"
192
+ ),
193
+ (
194
+ r"Applications open.{0,100}?"
195
+ r"(\d{1,2}\s+[A-Za-z]+\s+\d{4})"
196
+ ),
197
+ )
198
+
199
+ return cls._find_date(
200
+ page_text,
201
+ patterns,
202
+ )
203
+
204
+ @classmethod
205
+ def _extract_deadline(
206
+ cls,
207
+ page_text: str,
208
+ ) -> date | None:
209
+ patterns = (
210
+ (
211
+ r"deadline for applications is\s+"
212
+ r"(\d{1,2}\s+[A-Za-z]+\s+\d{4})"
213
+ ),
214
+ (
215
+ r"(\d{1,2}\s+[A-Za-z]+\s+\d{4})"
216
+ r".{0,100}?Applications close"
217
+ ),
218
+ (
219
+ r"Applications close.{0,100}?"
220
+ r"(\d{1,2}\s+[A-Za-z]+\s+\d{4})"
221
+ ),
222
+ )
223
+
224
+ return cls._find_date(
225
+ page_text,
226
+ patterns,
227
+ )
228
+
229
+ @staticmethod
230
+ def _find_date(
231
+ page_text: str,
232
+ patterns: tuple[str, ...],
233
+ ) -> date | None:
234
+ for pattern in patterns:
235
+ match = re.search(
236
+ pattern,
237
+ page_text,
238
+ flags=re.IGNORECASE,
239
+ )
240
+
241
+ if match is None:
242
+ continue
243
+
244
+ try:
245
+ return datetime.strptime(
246
+ match.group(1),
247
+ "%d %B %Y",
248
+ ).date()
249
+
250
+ except ValueError:
251
+ continue
252
+
253
+ return None
254
+
255
+ @staticmethod
256
+ def _build_academic_year(
257
+ *,
258
+ opening_date: date | None,
259
+ deadline: date,
260
+ ) -> str:
261
+ starting_year = (
262
+ opening_date.year
263
+ if opening_date is not None
264
+ else deadline.year
265
+ )
266
+
267
+ return (
268
+ f"{starting_year + 1}/"
269
+ f"{str(starting_year + 2)[-2:]}"
270
+ )
requirements.txt CHANGED
@@ -3,4 +3,6 @@ uvicorn[standard]>=0.35,<1
3
  pydantic-settings>=2.10,<3
4
  SQLAlchemy[asyncio]>=2.0,<3.0
5
  psycopg[binary]>=3.2,<4.0
6
- alembic>=1.16,<2.0
 
 
 
3
  pydantic-settings>=2.10,<3
4
  SQLAlchemy[asyncio]>=2.0,<3.0
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
scripts/__init__.py ADDED
File without changes
scripts/check_chevening_collector.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.chevening import (
9
+ CheveningCollector,
10
+ CheveningCollectorError,
11
+ )
12
+
13
+
14
+ def serialize_value(value: Any) -> Any:
15
+ if isinstance(value, date):
16
+ return value.isoformat()
17
+
18
+ if isinstance(value, list):
19
+ return [
20
+ serialize_value(item)
21
+ for item in value
22
+ ]
23
+
24
+ if isinstance(value, dict):
25
+ return {
26
+ key: serialize_value(item)
27
+ for key, item in value.items()
28
+ }
29
+
30
+ return value
31
+
32
+
33
+ async def main() -> None:
34
+ collector = CheveningCollector()
35
+
36
+ print(
37
+ "[collector-check] Starting Chevening check...",
38
+ flush=True,
39
+ )
40
+
41
+ opportunities = await collector.collect()
42
+
43
+ if not opportunities:
44
+ raise RuntimeError(
45
+ "Chevening returned no opportunity."
46
+ )
47
+
48
+ for opportunity in opportunities:
49
+ payload = serialize_value(
50
+ asdict(opportunity)
51
+ )
52
+
53
+ # Le contenu complet est volontairement exclu des logs.
54
+ raw_content = payload.pop(
55
+ "raw_content",
56
+ "",
57
+ )
58
+
59
+ payload["raw_content_length"] = len(
60
+ raw_content
61
+ )
62
+
63
+ print(
64
+ json.dumps(
65
+ payload,
66
+ indent=2,
67
+ ensure_ascii=False,
68
+ ),
69
+ flush=True,
70
+ )
71
+
72
+ print(
73
+ (
74
+ "[collector-check] Chevening check passed: "
75
+ f"{len(opportunities)} opportunity collected."
76
+ ),
77
+ flush=True,
78
+ )
79
+
80
+
81
+ if __name__ == "__main__":
82
+ try:
83
+ asyncio.run(main())
84
+
85
+ except CheveningCollectorError as error:
86
+ print(
87
+ f"[collector-check] Collection failed: {error}",
88
+ file=sys.stderr,
89
+ flush=True,
90
+ )
91
+ raise SystemExit(1) from error
92
+
93
+ except Exception as error:
94
+ print(
95
+ (
96
+ "[collector-check] Unexpected failure: "
97
+ f"{type(error).__name__}: {error}"
98
+ ),
99
+ file=sys.stderr,
100
+ flush=True,
101
+ )
102
+ raise SystemExit(1) from error