stage-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. stage/__init__.py +1 -0
  2. stage/__main__.py +8 -0
  3. stage/banner.py +32 -0
  4. stage/bootstrap/__init__.py +0 -0
  5. stage/bootstrap/openjobs.py +392 -0
  6. stage/classify/__init__.py +29 -0
  7. stage/classify/eligibility.py +115 -0
  8. stage/classify/internship.py +64 -0
  9. stage/classify/role.py +91 -0
  10. stage/classify/scope.py +47 -0
  11. stage/cli/__init__.py +0 -0
  12. stage/cli/app.py +4 -0
  13. stage/cli/commands/__init__.py +8 -0
  14. stage/cli/commands/discovery.py +294 -0
  15. stage/cli/commands/insight.py +494 -0
  16. stage/cli/commands/pipeline.py +337 -0
  17. stage/cli/commands/postings.py +473 -0
  18. stage/cli/commands/schedule.py +171 -0
  19. stage/cli/housekeeping.py +64 -0
  20. stage/cli/logfile.py +56 -0
  21. stage/cli/notify.py +170 -0
  22. stage/cli/options.py +678 -0
  23. stage/cli/render.py +1398 -0
  24. stage/cli/runlock.py +74 -0
  25. stage/cli/schedule.py +702 -0
  26. stage/cli/schedule_state.py +363 -0
  27. stage/cli/selection.py +83 -0
  28. stage/cli/serialize.py +196 -0
  29. stage/companies.py +542 -0
  30. stage/data/companies/a.yaml +1289 -0
  31. stage/data/companies/b.yaml +900 -0
  32. stage/data/companies/c.yaml +1377 -0
  33. stage/data/companies/d.yaml +497 -0
  34. stage/data/companies/e.yaml +519 -0
  35. stage/data/companies/f.yaml +454 -0
  36. stage/data/companies/g.yaml +601 -0
  37. stage/data/companies/h.yaml +446 -0
  38. stage/data/companies/i.yaml +503 -0
  39. stage/data/companies/j.yaml +138 -0
  40. stage/data/companies/k.yaml +278 -0
  41. stage/data/companies/l.yaml +402 -0
  42. stage/data/companies/m.yaml +937 -0
  43. stage/data/companies/n.yaml +549 -0
  44. stage/data/companies/o.yaml +371 -0
  45. stage/data/companies/other.yaml +58 -0
  46. stage/data/companies/p.yaml +825 -0
  47. stage/data/companies/q.yaml +121 -0
  48. stage/data/companies/r.yaml +583 -0
  49. stage/data/companies/s.yaml +1140 -0
  50. stage/data/companies/t.yaml +817 -0
  51. stage/data/companies/u.yaml +196 -0
  52. stage/data/companies/v.yaml +325 -0
  53. stage/data/companies/w.yaml +353 -0
  54. stage/data/companies/x.yaml +67 -0
  55. stage/data/companies/y.yaml +36 -0
  56. stage/data/companies/z.yaml +146 -0
  57. stage/data/fonts/DejaVuSans.LICENSE.txt +99 -0
  58. stage/data/fonts/DejaVuSans.ttf +0 -0
  59. stage/data/lexicon/company_tokens.yaml +228 -0
  60. stage/data/lexicon/eligibility.yaml +455 -0
  61. stage/data/lexicon/inclusive_suffixes.yaml +37 -0
  62. stage/data/lexicon/internship.yaml +187 -0
  63. stage/data/lexicon/language.yaml +226 -0
  64. stage/data/lexicon/locations.yaml +1159 -0
  65. stage/data/lexicon/roles.yaml +2012 -0
  66. stage/data/lexicon/terms.yaml +76 -0
  67. stage/data/lexicon/workday_facets.yaml +27 -0
  68. stage/data/seed_companies.yaml +198 -0
  69. stage/dedup/__init__.py +19 -0
  70. stage/dedup/identity.py +113 -0
  71. stage/dedup/resolve.py +97 -0
  72. stage/domain/__init__.py +244 -0
  73. stage/domain/company.py +49 -0
  74. stage/domain/coverage.py +86 -0
  75. stage/domain/custom_board.py +92 -0
  76. stage/domain/discovery.py +94 -0
  77. stage/domain/enums.py +114 -0
  78. stage/domain/events.py +204 -0
  79. stage/domain/filters.py +27 -0
  80. stage/domain/health.py +169 -0
  81. stage/domain/ids.py +48 -0
  82. stage/domain/job.py +47 -0
  83. stage/domain/matching.py +15 -0
  84. stage/domain/priority.py +34 -0
  85. stage/domain/quarantine.py +39 -0
  86. stage/domain/rate_state.py +78 -0
  87. stage/domain/retention.py +20 -0
  88. stage/domain/rotation.py +46 -0
  89. stage/domain/signals.py +12 -0
  90. stage/domain/sync_run.py +35 -0
  91. stage/domain/text.py +113 -0
  92. stage/domain/validator.py +14 -0
  93. stage/domain/visits.py +60 -0
  94. stage/domain/workday.py +38 -0
  95. stage/http/__init__.py +58 -0
  96. stage/http/breaker.py +53 -0
  97. stage/http/cache.py +44 -0
  98. stage/http/client.py +725 -0
  99. stage/http/profiles.py +101 -0
  100. stage/lexicon.py +370 -0
  101. stage/normalize/__init__.py +16 -0
  102. stage/normalize/language.py +47 -0
  103. stage/normalize/location.py +271 -0
  104. stage/normalize/terms.py +153 -0
  105. stage/normalize/urls.py +122 -0
  106. stage/paths.py +86 -0
  107. stage/py.typed +0 -0
  108. stage/services/__init__.py +0 -0
  109. stage/services/canary.py +120 -0
  110. stage/services/coverage.py +231 -0
  111. stage/services/discover.py +747 -0
  112. stage/services/export.py +274 -0
  113. stage/services/health.py +237 -0
  114. stage/services/maintenance.py +225 -0
  115. stage/services/quarantine.py +20 -0
  116. stage/services/query.py +86 -0
  117. stage/services/sync.py +1257 -0
  118. stage/sources/__init__.py +82 -0
  119. stage/sources/_text.py +79 -0
  120. stage/sources/ashby.py +93 -0
  121. stage/sources/bamboohr.py +80 -0
  122. stage/sources/base.py +225 -0
  123. stage/sources/breezy.py +90 -0
  124. stage/sources/collage.py +60 -0
  125. stage/sources/community_feeds.py +142 -0
  126. stage/sources/curated_markdown.py +289 -0
  127. stage/sources/custom_json.py +610 -0
  128. stage/sources/espresso.py +154 -0
  129. stage/sources/feed.py +44 -0
  130. stage/sources/greenhouse.py +104 -0
  131. stage/sources/jobbank.py +147 -0
  132. stage/sources/jobvite.py +133 -0
  133. stage/sources/lever.py +76 -0
  134. stage/sources/oracle_cloud.py +187 -0
  135. stage/sources/platforms.py +609 -0
  136. stage/sources/quebec_emploi.py +146 -0
  137. stage/sources/recruitee.py +96 -0
  138. stage/sources/simplify.py +110 -0
  139. stage/sources/smartrecruiters.py +216 -0
  140. stage/sources/speedyapply.py +200 -0
  141. stage/sources/themuse.py +157 -0
  142. stage/sources/workable.py +83 -0
  143. stage/sources/workday.py +524 -0
  144. stage/sources/zshah.py +99 -0
  145. stage/storage/__init__.py +29 -0
  146. stage/storage/migrations/0001_initial.sql +239 -0
  147. stage/storage/migrations/__init__.py +135 -0
  148. stage/storage/repository.py +213 -0
  149. stage/storage/search.py +28 -0
  150. stage/storage/sqlite_repo.py +1586 -0
  151. stage/storage/writer.py +249 -0
  152. stage/tui/__init__.py +0 -0
  153. stage/tui/app.py +82 -0
  154. stage/tui/help.py +26 -0
  155. stage/tui/safe.py +21 -0
  156. stage/tui/screens/__init__.py +0 -0
  157. stage/tui/screens/boards.py +186 -0
  158. stage/tui/screens/postings.py +509 -0
  159. stage/tui/screens/review.py +209 -0
  160. stage/tui/screens/splash.py +37 -0
  161. stage/tui/screens/stats.py +124 -0
  162. stage/tui/screens/sync.py +194 -0
  163. stage/tui/state.py +160 -0
  164. stage/tui/theme.tcss +205 -0
  165. stage/tui/widgets/__init__.py +0 -0
  166. stage_cli-1.0.0.dist-info/METADATA +379 -0
  167. stage_cli-1.0.0.dist-info/RECORD +170 -0
  168. stage_cli-1.0.0.dist-info/WHEEL +4 -0
  169. stage_cli-1.0.0.dist-info/entry_points.txt +2 -0
  170. stage_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,610 @@
1
+ import json
2
+ import re
3
+ from collections.abc import Mapping, Sequence
4
+ from datetime import datetime
5
+ from html import unescape
6
+ from typing import Any, ClassVar
7
+ from urllib.parse import parse_qsl, urlencode, urljoin, urlsplit, urlunsplit
8
+
9
+ from bs4 import BeautifulSoup
10
+ from bs4.element import Tag
11
+ from pydantic import BaseModel, ConfigDict
12
+
13
+ from stage.domain import Company, CustomBoard, Job, Platform, SourceSignals, board_key, job_id
14
+ from stage.http import HttpClient
15
+ from stage.http.client import HostBudgetExceededError
16
+ from stage.lexicon import fold, location_lexicon
17
+ from stage.sources import register
18
+ from stage.sources._text import collapse_whitespace, strip_html
19
+ from stage.sources.base import (
20
+ FetchResult,
21
+ NonEmptyStr,
22
+ PayloadValidationError,
23
+ capture_payload,
24
+ convert_rows,
25
+ malformed_note,
26
+ validate_rows,
27
+ )
28
+ from stage.sources.platforms import dig
29
+
30
+ MAX_ROWS = 5000
31
+ MAX_EXTRACT_BYTES = 8 * 1024 * 1024
32
+ MAX_RSS_ITEMS = 20000
33
+ MAX_TOKEN_LEN = 4096
34
+ MAX_HTML_ROWS = 2000
35
+
36
+ MAX_PATH_SEGMENTS = 8
37
+ MAX_PAGES = 20
38
+ PAGE_CEILING = 200
39
+ _JSON_PARSE = 'JSON.parse("'
40
+ _UNCHANGED = object()
41
+
42
+
43
+ class CustomListing(BaseModel):
44
+ model_config = ConfigDict(extra="ignore")
45
+
46
+ id: NonEmptyStr
47
+ title: NonEmptyStr
48
+ location: str = ""
49
+ url: str = ""
50
+ description: str = ""
51
+ department: str = ""
52
+ employment_type: str = ""
53
+ category: str = ""
54
+
55
+
56
+ def _host(url: str) -> str:
57
+ try:
58
+ return (urlsplit(url).hostname or "").lower()
59
+ except ValueError:
60
+ return ""
61
+
62
+
63
+ def _rows(payload: Any, board: CustomBoard) -> Any:
64
+ return dig(payload, board.jobs_path) if board.jobs_path else payload
65
+
66
+
67
+ def _page_body(board: CustomBoard, index: int) -> dict[str, Any]:
68
+ body = dict(board.body)
69
+ if not board.paginated:
70
+ return body
71
+ parts = board.page_param.split(".")
72
+ branch: dict[str, Any] = body
73
+ for part in parts[:-1]:
74
+ nested = branch.get(part)
75
+ branch[part] = dict(nested) if isinstance(nested, Mapping) else {}
76
+ branch = branch[part]
77
+ branch[parts[-1]] = board.page_value(index)
78
+ return body
79
+
80
+
81
+ def _page_url(board: CustomBoard, index: int) -> str:
82
+ if not board.paginated or board.posts:
83
+ return board.url
84
+ parts = urlsplit(board.url)
85
+ query = [(key, value) for key, value in parse_qsl(parts.query, keep_blank_values=True)]
86
+ query = [(key, value) for key, value in query if key != board.page_param]
87
+ query.append((board.page_param, str(board.page_value(index))))
88
+ return urlunsplit(parts._replace(query=urlencode(query)))
89
+
90
+
91
+ def _js_string_object(text: str, quote: int) -> Any:
92
+ limit = min(len(text), quote + MAX_EXTRACT_BYTES)
93
+ index = quote + 1
94
+ while index < limit:
95
+ char = text[index]
96
+ if char == "\\":
97
+ index += 2
98
+ continue
99
+ if char == '"':
100
+ try:
101
+ return json.loads(json.loads(text[quote : index + 1]))
102
+ except ValueError:
103
+ return None
104
+ index += 1
105
+ return None
106
+
107
+
108
+ def extract_object(text: str, marker: str) -> Any:
109
+ start = text.find(marker)
110
+ if start < 0:
111
+ return None
112
+ brace = text.find("{", start + len(marker))
113
+ wrapped = text.find(_JSON_PARSE, start + len(marker))
114
+ if wrapped >= 0 and (brace < 0 or wrapped < brace):
115
+ return _js_string_object(text, wrapped + len(_JSON_PARSE) - 1)
116
+ if brace < 0 or len(text) - brace > MAX_EXTRACT_BYTES:
117
+ return None
118
+ depth = 0
119
+ in_string = False
120
+ escaped = False
121
+ for index in range(brace, min(len(text), brace + MAX_EXTRACT_BYTES)):
122
+ char = text[index]
123
+ if in_string:
124
+ if escaped:
125
+ escaped = False
126
+ elif char == "\\":
127
+ escaped = True
128
+ elif char == '"':
129
+ in_string = False
130
+ continue
131
+ if char == '"':
132
+ in_string = True
133
+ elif char == "{":
134
+ depth += 1
135
+ elif char == "}":
136
+ depth -= 1
137
+ if depth == 0:
138
+ try:
139
+ return json.loads(text[brace : index + 1])
140
+ except ValueError:
141
+ return None
142
+ return None
143
+
144
+
145
+ def _tag_text(item: str, start: int) -> tuple[str, str, int] | None:
146
+ open_at = item.find("<", start)
147
+ if open_at < 0:
148
+ return None
149
+ close_at = item.find(">", open_at)
150
+ if close_at < 0:
151
+ return None
152
+ name = item[open_at + 1 : close_at].split()[0] if close_at > open_at + 1 else ""
153
+ if not name or name.startswith(("/", "?", "!")):
154
+ return "", "", close_at + 1
155
+ end = item.find(f"</{name}>", close_at)
156
+ if end < 0:
157
+ return "", "", close_at + 1
158
+ body = item[close_at + 1 : end]
159
+ if body.startswith("<![CDATA["):
160
+ return name, body[9:].removesuffix("]]>").strip(), end + len(name) + 3
161
+ return name, unescape(body).strip(), end + len(name) + 3
162
+
163
+
164
+ def rss_items(text: str, tag: str = "item") -> list[dict[str, str]]:
165
+ opening, closing = f"<{tag}>", f"</{tag}>"
166
+ rows: list[dict[str, str]] = []
167
+ cursor = 0
168
+ while len(rows) < MAX_RSS_ITEMS:
169
+ start = text.find(opening, cursor)
170
+ if start < 0:
171
+ break
172
+ end = text.find(closing, start)
173
+ if end < 0:
174
+ break
175
+ item = text[start + len(opening) : end]
176
+ cursor = end + len(closing)
177
+ row: dict[str, str] = {}
178
+ at = 0
179
+ while True:
180
+ step = _tag_text(item, at)
181
+ if step is None:
182
+ break
183
+ name, body, at = step
184
+ if name and body:
185
+ row.setdefault(name, body)
186
+ if row:
187
+ rows.append(row)
188
+ return rows
189
+
190
+
191
+ def _job_postings(block: str) -> list[dict[str, Any]]:
192
+ try:
193
+ payload = json.loads(block)
194
+ except ValueError:
195
+ return []
196
+ pending = [payload]
197
+ found: list[dict[str, Any]] = []
198
+ while pending and len(found) < MAX_HTML_ROWS:
199
+ entry = pending.pop()
200
+ if isinstance(entry, list):
201
+ pending.extend(entry)
202
+ elif isinstance(entry, dict):
203
+ graph = entry.get("@graph")
204
+ if isinstance(graph, list):
205
+ pending.extend(graph)
206
+ if entry.get("@type") == "JobPosting":
207
+ found.append(entry)
208
+ return found
209
+
210
+
211
+ def jsonld_rows(text: str) -> list[dict[str, Any]]:
212
+ rows: list[dict[str, Any]] = []
213
+ cursor = 0
214
+ while len(rows) < MAX_HTML_ROWS:
215
+ start = text.find("<script", cursor)
216
+ if start < 0:
217
+ break
218
+ opened = text.find(">", start)
219
+ if opened < 0:
220
+ break
221
+ head = text[start:opened]
222
+ closed = text.find("</script>", opened)
223
+ if closed < 0:
224
+ break
225
+ if "application/ld+json" in head:
226
+ rows.extend(_job_postings(text[opened + 1 : min(closed, opened + MAX_EXTRACT_BYTES)]))
227
+ cursor = closed + 9
228
+ return rows
229
+
230
+
231
+ def _segment_title(part: str) -> str:
232
+ words = part.replace("-", " ").split()
233
+ if not words:
234
+ return ""
235
+ lexicon = location_lexicon()
236
+ titled = [word.title() for word in words]
237
+ tail = fold(words[-1])
238
+ if len(tail) == 2 and (tail in lexicon.usa_codes or tail in lexicon.canada_codes):
239
+ titled[-1] = tail.upper()
240
+ return " ".join(titled).strip()
241
+
242
+
243
+ def sitemap_rows(text: str) -> list[dict[str, str]]:
244
+ rows: list[dict[str, str]] = []
245
+ cursor = 0
246
+ while len(rows) < MAX_RSS_ITEMS:
247
+ start = text.find("<url>", cursor)
248
+ if start < 0:
249
+ break
250
+ end = text.find("</url>", start)
251
+ if end < 0:
252
+ break
253
+ entry = text[start + 5 : end]
254
+ cursor = end + 6
255
+ loc = ""
256
+ at = 0
257
+ while True:
258
+ step = _tag_text(entry, at)
259
+ if step is None:
260
+ break
261
+ name, body, at = step
262
+ if name.endswith("loc") and body:
263
+ loc = body
264
+ break
265
+ if not loc:
266
+ continue
267
+ tail = loc.rstrip("/").rsplit("/", 1)[-1]
268
+ slug, _, ident = tail.rpartition("_")
269
+ if not slug:
270
+ slug, ident = tail, tail
271
+ row = {
272
+ "loc": loc,
273
+ "id": ident,
274
+ "slug": slug,
275
+ "title": slug.replace("-", " ").strip().title(),
276
+ }
277
+ segments = [
278
+ part for part in loc.split("?", 1)[0].split("#", 1)[0].rstrip("/").split("/") if part
279
+ ]
280
+ for offset, part in enumerate(reversed(segments[-MAX_PATH_SEGMENTS:]), start=1):
281
+ row[f"path{offset}"] = part
282
+ row[f"path{offset}_title"] = _segment_title(part)
283
+ rows.append(row)
284
+ return rows
285
+
286
+
287
+ def _fingerprint(page: list[Any], board: CustomBoard) -> set[str]:
288
+ marks = set()
289
+ for entry in page:
290
+ projected = _project(entry, board)
291
+ mark = projected.get("id") or projected.get("title", "")
292
+ if mark:
293
+ marks.add(mark)
294
+ return marks
295
+
296
+
297
+ def _slug_parts(url: str) -> tuple[str, str]:
298
+ tail = url.split("?", 1)[0].split("#", 1)[0].rstrip("/").rsplit("/", 1)[-1]
299
+ slug, _, ident = tail.rpartition("_")
300
+ if not slug:
301
+ slug, ident = tail, tail
302
+ return slug, ident
303
+
304
+
305
+ def _html_value(block: Tag, selector: str, *, base: str = "") -> str:
306
+ mode = "text"
307
+ attr = ""
308
+ css = selector
309
+ for token, kind in (("::attr(", "attr"), ("::slug(", "slug"), ("::slugid(", "slugid")):
310
+ if token in selector:
311
+ css, _, rest = selector.partition(token)
312
+ attr = rest.rstrip(")")
313
+ mode = kind
314
+ break
315
+ if not css:
316
+ target: Tag | None = block
317
+ else:
318
+ target = block.select_one(css)
319
+ if target is None:
320
+ return ""
321
+ if mode == "text":
322
+ return collapse_whitespace(target.get_text(" ", strip=True))
323
+ raw = target.get(attr, "")
324
+ value = " ".join(raw) if isinstance(raw, list) else str(raw)
325
+ if not value:
326
+ return ""
327
+ if mode == "attr":
328
+ return urljoin(base, value) if base else value
329
+ slug, ident = _slug_parts(value)
330
+ return ident if mode == "slugid" else slug.replace("-", " ").strip().title()
331
+
332
+
333
+ def html_rows(text: str, selector: str) -> list[Tag]:
334
+ soup = BeautifulSoup(text, "html.parser")
335
+ return soup.select(selector)[:MAX_HTML_ROWS]
336
+
337
+
338
+ def _scalar(value: Any) -> str:
339
+ if isinstance(value, Mapping):
340
+ parts = [str(item) for item in value.values() if isinstance(item, str | int | float)]
341
+ return ", ".join(part for part in parts if part)
342
+ return str(value)
343
+
344
+
345
+ def _project(entry: Any, board: CustomBoard) -> dict[str, str]:
346
+ projected: dict[str, str] = {}
347
+ for name in (
348
+ "title",
349
+ "id",
350
+ "location",
351
+ "url",
352
+ "description",
353
+ "department",
354
+ "employment_type",
355
+ "category",
356
+ ):
357
+ path = board.mapped(name)
358
+ if not path:
359
+ continue
360
+ if board.html and isinstance(entry, Tag):
361
+ base = board.url if name == "url" else ""
362
+ projected[name] = _html_value(entry, path, base=base)
363
+ continue
364
+ value = dig(entry, path)
365
+ if isinstance(value, list):
366
+ value = " / ".join(_scalar(item) for item in value if item)
367
+ projected[name] = "" if value is None else _scalar(value)
368
+ if not projected.get("id"):
369
+ projected["id"] = projected.get("url") or projected.get("title", "")
370
+ return projected
371
+
372
+
373
+ @register
374
+ class CustomJsonAdapter:
375
+ name: ClassVar[str] = "custom_json"
376
+ platform: ClassVar[Platform] = Platform.CUSTOM_JSON
377
+ rate_profile: ClassVar[str] = "paginated"
378
+ hosts: ClassVar[frozenset[str]] = frozenset()
379
+ bucket_key: ClassVar[str] = ""
380
+ detail_budget: ClassVar[int] = 0
381
+ rotation_slice: ClassVar[int] = 0
382
+ max_requests_per_company: ClassVar[int] = PAGE_CEILING
383
+
384
+ def board_key(self, company: Company) -> str:
385
+ return board_key(self.name, company.slug)
386
+
387
+ def hosts_for(self, companies: Sequence[Company]) -> frozenset[str]:
388
+ found = set()
389
+ for company in companies:
390
+ if company.custom is not None:
391
+ for candidate in (company.custom.url, company.custom.handshake_url):
392
+ host = _host(candidate)
393
+ if host:
394
+ found.add(host)
395
+ return frozenset(found)
396
+
397
+ def plan(self, company: Company) -> tuple[str, ...]:
398
+ board = self._board(company)
399
+ return (_page_url(board, 0),)
400
+
401
+ @staticmethod
402
+ def _board(company: Company) -> CustomBoard:
403
+ if company.custom is None:
404
+ raise PayloadValidationError(
405
+ f"{company.name}: platform custom_json needs a 'custom' block — "
406
+ "add url and a title field mapping to the registry row"
407
+ )
408
+ return company.custom
409
+
410
+ async def fetch(
411
+ self,
412
+ company: Company,
413
+ client: HttpClient,
414
+ now: datetime,
415
+ facets: object = None,
416
+ details: Sequence[str] = (),
417
+ ) -> FetchResult:
418
+ board = self._board(company)
419
+ headers = {**board.headers, **await self._handshake(board, client)}
420
+ entries: list[Any] = []
421
+ truncated = False
422
+ stale_page = False
423
+ ignored_param = False
424
+ seen: set[str] = set()
425
+ pages = board.page_budget(MAX_PAGES, PAGE_CEILING)
426
+ for index in range(pages):
427
+ try:
428
+ payload = await self._page(company, board, client, index, headers)
429
+ except HostBudgetExceededError:
430
+ if index == 0:
431
+ raise
432
+ truncated = True
433
+ break
434
+ if payload is _UNCHANGED:
435
+ if index == 0:
436
+ return FetchResult(not_modified=True)
437
+ stale_page = True
438
+ break
439
+ page = _rows(payload, board)
440
+ if not isinstance(page, list):
441
+ if index:
442
+ break
443
+ captured = capture_payload(self.name, company.slug, payload)
444
+ raise PayloadValidationError(
445
+ f"{self.name}/{company.slug}: jobs_path "
446
+ f"{board.jobs_path or '<root>'!r} is not a list; captured at {captured}"
447
+ )
448
+ fingerprint = _fingerprint(page, board)
449
+ if index and fingerprint and fingerprint <= seen:
450
+ ignored_param = True
451
+ break
452
+ seen |= fingerprint
453
+ entries.extend(page)
454
+ if not board.paginated or len(page) < board.page_size:
455
+ break
456
+ if len(entries) > MAX_ROWS:
457
+ truncated = True
458
+ break
459
+ else:
460
+ truncated = board.paginated
461
+ truncated = truncated or len(entries) > MAX_ROWS
462
+ listings, dropped = validate_rows(
463
+ CustomListing,
464
+ [_project(entry, board) for entry in entries[:MAX_ROWS]],
465
+ source=self.name,
466
+ slug=company.slug,
467
+ )
468
+ jobs, unconvertible = convert_rows(
469
+ lambda listing: self._to_job(company, board, listing, now),
470
+ listings,
471
+ source=self.name,
472
+ slug=company.slug,
473
+ )
474
+ dropped += unconvertible
475
+ notes = [malformed_note(dropped)] if dropped else []
476
+ if truncated:
477
+ notes.append(f"stopped at the {pages}-page or {MAX_ROWS}-row cap; incomplete")
478
+ if stale_page:
479
+ notes.append("a later page answered 304, so the walk ended on an unchanged page")
480
+ if ignored_param:
481
+ notes.append(
482
+ f"page {board.page_param!r} repeated the previous page, so the server ignores it; "
483
+ "the walk stopped rather than looping"
484
+ )
485
+ return FetchResult(
486
+ jobs=tuple(jobs),
487
+ degraded="; ".join(note for note in notes if note),
488
+ authoritative=board.authoritative
489
+ and not (dropped or truncated or stale_page or ignored_param),
490
+ )
491
+
492
+ async def _page(
493
+ self,
494
+ company: Company,
495
+ board: CustomBoard,
496
+ client: HttpClient,
497
+ index: int,
498
+ headers: Mapping[str, str],
499
+ ) -> Any:
500
+ if board.rss:
501
+ text = await client.get_text(_page_url(board, index), revalidate=index > 0)
502
+ if text.not_modified:
503
+ return _UNCHANGED
504
+ rows = rss_items(text.text, board.item_tag)
505
+ if not rows:
506
+ captured = capture_payload(self.name, company.slug, {"head": text.text[:4000]})
507
+ raise PayloadValidationError(
508
+ f"{self.name}/{company.slug}: no <item> elements in the feed; "
509
+ f"captured at {captured}"
510
+ )
511
+ return rows
512
+ if board.embedded:
513
+ text = await client.get_text(_page_url(board, index), revalidate=index > 0)
514
+ if text.not_modified:
515
+ return _UNCHANGED
516
+ payload = extract_object(text.text, board.extract)
517
+ if payload is None:
518
+ captured = capture_payload(self.name, company.slug, {"head": text.text[:4000]})
519
+ raise PayloadValidationError(
520
+ f"{self.name}/{company.slug}: no {board.extract!r} object in the page; "
521
+ f"captured at {captured}"
522
+ )
523
+ return payload
524
+ if board.jsonld:
525
+ text = await client.get_text(_page_url(board, index), revalidate=index > 0)
526
+ if text.not_modified:
527
+ return _UNCHANGED
528
+ rows = jsonld_rows(text.text)
529
+ if not rows and index == 0:
530
+ captured = capture_payload(self.name, company.slug, {"head": text.text[:4000]})
531
+ raise PayloadValidationError(
532
+ f"{self.name}/{company.slug}: no JobPosting in any ld+json block; "
533
+ f"captured at {captured}"
534
+ )
535
+ return rows
536
+ if board.sitemap:
537
+ text = await client.get_text(_page_url(board, index), revalidate=index > 0)
538
+ if text.not_modified:
539
+ return _UNCHANGED
540
+ rows = sitemap_rows(text.text)
541
+ if board.row_filter:
542
+ keep = re.compile(board.row_filter)
543
+ rows = [row for row in rows if keep.search(row.get("loc", ""))]
544
+ if not rows:
545
+ captured = capture_payload(self.name, company.slug, {"head": text.text[:4000]})
546
+ raise PayloadValidationError(
547
+ f"{self.name}/{company.slug}: no <url> entries in the sitemap; "
548
+ f"captured at {captured}"
549
+ )
550
+ return rows
551
+ if board.html:
552
+ text = await client.get_text(_page_url(board, index), revalidate=index > 0)
553
+ if text.not_modified:
554
+ return _UNCHANGED
555
+ blocks = html_rows(text.text, board.row_selector)
556
+ if not blocks and index == 0:
557
+ captured = capture_payload(self.name, company.slug, {"head": text.text[:4000]})
558
+ raise PayloadValidationError(
559
+ f"{self.name}/{company.slug}: selector {board.row_selector!r} matched no "
560
+ f"rows; captured at {captured}"
561
+ )
562
+ return blocks
563
+ response = (
564
+ await client.post_json(board.url, body=_page_body(board, index), extra_headers=headers)
565
+ if board.posts
566
+ else await client.get_json(_page_url(board, index), revalidate=index > 0)
567
+ )
568
+ return _UNCHANGED if response.not_modified else response.payload
569
+
570
+ @staticmethod
571
+ async def _handshake(board: CustomBoard, client: HttpClient) -> dict[str, str]:
572
+ if not board.handshakes:
573
+ return {}
574
+ page = await client.get_text(board.handshake_url)
575
+ found = re.search(board.token_pattern, page.text)
576
+ if found is None or not found.groups():
577
+ raise PayloadValidationError(
578
+ f"custom_json: no token matched {board.token_pattern!r} at {board.handshake_url}"
579
+ )
580
+ token = found.group(1)[:MAX_TOKEN_LEN]
581
+ parts = urlsplit(board.handshake_url)
582
+ return {
583
+ board.token_header: f"{board.token_prefix}{token}",
584
+ "Referer": board.handshake_url,
585
+ "Origin": f"{parts.scheme}://{parts.netloc}",
586
+ }
587
+
588
+ def _to_job(
589
+ self, company: Company, board: CustomBoard, listing: CustomListing, now: datetime
590
+ ) -> Job:
591
+ apply_url = listing.url
592
+ if not apply_url and board.url_template:
593
+ apply_url = board.url_template.format(id=listing.id)
594
+ title = collapse_whitespace(strip_html(listing.title)) or listing.title
595
+ return Job(
596
+ id=job_id(self.name, company.slug, listing.id),
597
+ source=self.name,
598
+ company=company.name,
599
+ title_raw=title,
600
+ title_normalized=collapse_whitespace(title),
601
+ apply_url_raw=apply_url or board.url,
602
+ description=collapse_whitespace(strip_html(listing.description)),
603
+ location_raw=collapse_whitespace(strip_html(listing.location)),
604
+ first_seen=now,
605
+ last_seen=now,
606
+ signals=SourceSignals(
607
+ employment_type=listing.employment_type,
608
+ category=listing.category or listing.department,
609
+ ),
610
+ )