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,609 @@
1
+ import re
2
+ from collections.abc import Callable, Mapping
3
+ from dataclasses import dataclass, field
4
+ from types import MappingProxyType
5
+ from typing import Any
6
+
7
+ from stage.domain import Platform, PlatformCandidate
8
+ from stage.lexicon import fold
9
+
10
+ SAFE_SLUG = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
11
+ SAFE_PATH_SLUG = re.compile(r"^[a-z0-9](?:[a-z0-9.-]{0,61}[a-z0-9])?$")
12
+ SAFE_CASED_SLUG = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]{0,62}$")
13
+ _LOCALE = r"[a-z]{2}(?:-[A-Za-z]{2})?"
14
+ _WORKDAY_HOST = re.compile(r"^(?P<tenant>[a-z0-9][a-z0-9-]*)\.(?P<dc>wd\d+)\.myworkdayjobs\.com$")
15
+
16
+
17
+ class SlugRejectedError(ValueError):
18
+ pass
19
+
20
+
21
+ def safe_slug(slug: str) -> str:
22
+ lowered = slug.strip().lower()
23
+ if not SAFE_SLUG.match(lowered):
24
+ raise SlugRejectedError(
25
+ f"{slug!r} is not a usable board token — it interpolates into a "
26
+ "hostname, so lowercase letters, digits and hyphens only"
27
+ )
28
+ return lowered
29
+
30
+
31
+ def safe_cased_slug(slug: str) -> str:
32
+ stripped = slug.strip()
33
+ if not SAFE_CASED_SLUG.match(stripped):
34
+ raise SlugRejectedError(
35
+ f"{slug!r} is not a usable board token — it interpolates into a "
36
+ "request path, so letters, digits and hyphens only"
37
+ )
38
+ return stripped
39
+
40
+
41
+ def safe_path_slug(slug: str) -> str:
42
+ lowered = slug.strip().lower()
43
+ if not SAFE_PATH_SLUG.match(lowered):
44
+ raise SlugRejectedError(
45
+ f"{slug!r} is not a usable path token — lowercase letters, digits, dots and "
46
+ "hyphens only"
47
+ )
48
+ return lowered
49
+
50
+
51
+ SAFE_WORKDAY_DC = re.compile(r"^wd\d{1,3}$")
52
+ SAFE_WORKDAY_SITE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
53
+ SAFE_ORACLE_HOST = re.compile(r"^(?:[a-z0-9](?:[a-z0-9-]{0,62})\.)+[a-z]{2,24}$")
54
+ SAFE_ORACLE_SITE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
55
+
56
+
57
+ def workday_target(tenant: str, site: str, dc: str) -> tuple[str, str]:
58
+ missing = [
59
+ field
60
+ for field, value in (
61
+ ("workday_tenant", tenant),
62
+ ("workday_site", site),
63
+ ("workday_dc", dc),
64
+ )
65
+ if not value.strip()
66
+ ]
67
+ if missing:
68
+ raise SlugRejectedError(
69
+ f"registry row is missing {', '.join(missing)} — a Workday tenant, site "
70
+ "and datacenter cannot be guessed. Use `stage discover --url`"
71
+ )
72
+
73
+ safe_tenant = safe_slug(tenant)
74
+ stripped_dc = dc.strip().lower()
75
+ if not SAFE_WORKDAY_DC.match(stripped_dc):
76
+ raise SlugRejectedError(
77
+ f"{dc!r} is not a Workday datacenter — expected wd followed by digits "
78
+ "(it interpolates into the hostname)"
79
+ )
80
+ stripped_site = site.strip()
81
+ if not SAFE_WORKDAY_SITE.match(stripped_site):
82
+ raise SlugRejectedError(
83
+ f"{site!r} is not a usable Workday site — it interpolates into the "
84
+ "request path, so letters, digits, underscores and hyphens only"
85
+ )
86
+ host = f"{safe_tenant}.{stripped_dc}.myworkdayjobs.com"
87
+ return host, f"/wday/cxs/{safe_tenant}/{stripped_site}/jobs"
88
+
89
+
90
+ def oracle_target(host: str, site: str) -> tuple[str, str]:
91
+ missing = [
92
+ field
93
+ for field, value in (("oracle_host", host), ("oracle_site", site))
94
+ if not value.strip()
95
+ ]
96
+ if missing:
97
+ raise SlugRejectedError(
98
+ f"registry row is missing {', '.join(missing)} — an Oracle candidate site "
99
+ "cannot be guessed. Use `stage discover --url`"
100
+ )
101
+ safe_host = host.strip().lower()
102
+ if not SAFE_ORACLE_HOST.match(safe_host):
103
+ raise SlugRejectedError(
104
+ f"{host!r} is not a usable Oracle Cloud host — it interpolates into the request "
105
+ "URL, so it must be a dotted public hostname"
106
+ )
107
+ safe_site = site.strip()
108
+ if not SAFE_ORACLE_SITE.match(safe_site):
109
+ raise SlugRejectedError(
110
+ f"{site!r} is not a usable Oracle candidate site — it interpolates into the "
111
+ "request path, so letters, digits, underscores and hyphens only"
112
+ )
113
+ return safe_host, safe_site
114
+
115
+
116
+ @dataclass(frozen=True, slots=True)
117
+ class PlatformProbe:
118
+ platform: Platform
119
+ host: str
120
+ probe_url: str
121
+ rate_profile: str
122
+ jobs_paths: tuple[str, ...]
123
+ name_paths: tuple[str, ...] = ()
124
+ count_paths: tuple[str, ...] = ()
125
+ params: Mapping[str, str] = field(default_factory=lambda: MappingProxyType({}))
126
+ verify_url: str | None = None
127
+ verify_name_paths: tuple[str, ...] = ()
128
+ slug_validator: Callable[[str], str] = safe_slug
129
+
130
+ def host_for(self, slug: str) -> str:
131
+ return self.host.format(slug=self.slug_validator(slug))
132
+
133
+ def url_for(self, slug: str) -> str:
134
+ return self.probe_url.format(slug=self.slug_validator(slug))
135
+
136
+ def verify_url_for(self, slug: str) -> str | None:
137
+ if self.verify_url is None:
138
+ return None
139
+ return self.verify_url.format(slug=self.slug_validator(slug))
140
+
141
+
142
+ PROBES: tuple[PlatformProbe, ...] = (
143
+ PlatformProbe(
144
+ platform=Platform.GREENHOUSE,
145
+ host="boards-api.greenhouse.io",
146
+ probe_url="https://boards-api.greenhouse.io/v1/boards/{slug}/jobs",
147
+ rate_profile="standard",
148
+ jobs_paths=("jobs",),
149
+ count_paths=("meta.total",),
150
+ verify_url="https://boards-api.greenhouse.io/v1/boards/{slug}",
151
+ verify_name_paths=("name",),
152
+ ),
153
+ PlatformProbe(
154
+ platform=Platform.LEVER,
155
+ host="api.lever.co",
156
+ probe_url="https://api.lever.co/v0/postings/{slug}",
157
+ rate_profile="standard",
158
+ jobs_paths=("",),
159
+ params=MappingProxyType({"mode": "json"}),
160
+ ),
161
+ PlatformProbe(
162
+ platform=Platform.ASHBY,
163
+ host="api.ashbyhq.com",
164
+ probe_url="https://api.ashbyhq.com/posting-api/job-board/{slug}",
165
+ rate_profile="standard",
166
+ jobs_paths=("jobs",),
167
+ name_paths=("organizationName", "jobs.0.organizationName"),
168
+ slug_validator=safe_path_slug,
169
+ ),
170
+ PlatformProbe(
171
+ platform=Platform.SMARTRECRUITERS,
172
+ host="api.smartrecruiters.com",
173
+ probe_url="https://api.smartrecruiters.com/v1/companies/{slug}/postings",
174
+ rate_profile="moderate",
175
+ jobs_paths=("content",),
176
+ count_paths=("totalFound",),
177
+ name_paths=("content.0.company.name",),
178
+ ),
179
+ PlatformProbe(
180
+ platform=Platform.BAMBOOHR,
181
+ host="{slug}.bamboohr.com",
182
+ probe_url="https://{slug}.bamboohr.com/careers/list",
183
+ rate_profile="moderate",
184
+ jobs_paths=("result",),
185
+ name_paths=("meta.companyName", "companyName"),
186
+ ),
187
+ PlatformProbe(
188
+ platform=Platform.RECRUITEE,
189
+ host="{slug}.recruitee.com",
190
+ probe_url="https://{slug}.recruitee.com/api/offers/",
191
+ rate_profile="moderate",
192
+ jobs_paths=("offers",),
193
+ name_paths=("offers.0.company_name",),
194
+ ),
195
+ PlatformProbe(
196
+ platform=Platform.BREEZY,
197
+ host="{slug}.breezy.hr",
198
+ probe_url="https://{slug}.breezy.hr/json",
199
+ rate_profile="moderate",
200
+ jobs_paths=("",),
201
+ ),
202
+ PlatformProbe(
203
+ platform=Platform.WORKABLE,
204
+ host="apply.workable.com",
205
+ probe_url="https://apply.workable.com/api/v1/widget/accounts/{slug}",
206
+ rate_profile="conservative",
207
+ jobs_paths=("jobs",),
208
+ name_paths=("name",),
209
+ params=MappingProxyType({"details": "true"}),
210
+ ),
211
+ PlatformProbe(
212
+ platform=Platform.COLLAGE,
213
+ host="api.collage.co",
214
+ probe_url="https://api.collage.co/v1/positions/{slug}",
215
+ rate_profile="moderate",
216
+ jobs_paths=("positions",),
217
+ ),
218
+ )
219
+
220
+ PROBES_BY_PLATFORM: MappingProxyType[Platform, PlatformProbe] = MappingProxyType(
221
+ {probe.platform: probe for probe in PROBES}
222
+ )
223
+
224
+ URL_ONLY_PLATFORMS: frozenset[Platform] = frozenset(
225
+ {
226
+ Platform.WORKDAY,
227
+ Platform.PERSONIO,
228
+ Platform.TEAMTAILOR,
229
+ Platform.JOBVITE,
230
+ Platform.JOIN,
231
+ Platform.EIGHTFOLD,
232
+ }
233
+ )
234
+
235
+
236
+ @dataclass(frozen=True, slots=True)
237
+ class UrlPattern:
238
+ platform: Platform
239
+ hosts: tuple[str, ...] = ()
240
+ host_pattern: re.Pattern[str] | None = None
241
+ path_pattern: re.Pattern[str] | None = None
242
+ slug_validator: Callable[[str], str] = safe_slug
243
+
244
+
245
+ def _path(expr: str) -> re.Pattern[str]:
246
+ return re.compile(expr)
247
+
248
+
249
+ URL_PATTERNS: tuple[UrlPattern, ...] = (
250
+ UrlPattern(
251
+ platform=Platform.GREENHOUSE,
252
+ hosts=(
253
+ "boards.greenhouse.io",
254
+ "job-boards.greenhouse.io",
255
+ "boards.eu.greenhouse.io",
256
+ "job-boards.eu.greenhouse.io",
257
+ "boards-api.greenhouse.io",
258
+ "api.greenhouse.io",
259
+ ),
260
+ path_pattern=_path(rf"^/(?:v1/boards/)?(?:{_LOCALE}/)?(?P<slug>[^/?#]+)"),
261
+ ),
262
+ UrlPattern(
263
+ platform=Platform.LEVER,
264
+ hosts=("jobs.lever.co", "api.lever.co", "jobs.eu.lever.co"),
265
+ path_pattern=_path(r"^/(?:v0/postings/)?(?P<slug>[^/?#]+)"),
266
+ slug_validator=safe_cased_slug,
267
+ ),
268
+ UrlPattern(
269
+ platform=Platform.ASHBY,
270
+ hosts=("jobs.ashbyhq.com", "api.ashbyhq.com"),
271
+ path_pattern=_path(r"^/(?:posting-api/job-board/)?(?P<slug>[^/?#]+)"),
272
+ slug_validator=safe_path_slug,
273
+ ),
274
+ UrlPattern(
275
+ platform=Platform.SMARTRECRUITERS,
276
+ hosts=(
277
+ "careers.smartrecruiters.com",
278
+ "jobs.smartrecruiters.com",
279
+ "api.smartrecruiters.com",
280
+ ),
281
+ path_pattern=_path(r"^/(?:v1/companies/)?(?P<slug>[^/?#]+)"),
282
+ ),
283
+ UrlPattern(
284
+ platform=Platform.WORKABLE,
285
+ hosts=("apply.workable.com",),
286
+ path_pattern=_path(r"^/(?P<slug>[^/?#]+)"),
287
+ ),
288
+ UrlPattern(
289
+ platform=Platform.JOBVITE,
290
+ hosts=("jobs.jobvite.com",),
291
+ path_pattern=_path(r"^/(?P<slug>[^/?#]+)"),
292
+ ),
293
+ UrlPattern(
294
+ platform=Platform.JOIN,
295
+ hosts=("join.com", "www.join.com"),
296
+ path_pattern=_path(r"^/companies/(?P<slug>[^/?#]+)"),
297
+ ),
298
+ UrlPattern(
299
+ platform=Platform.BAMBOOHR,
300
+ host_pattern=re.compile(r"^(?P<slug>[a-z0-9][a-z0-9-]*)\.bamboohr\.com$"),
301
+ ),
302
+ UrlPattern(
303
+ platform=Platform.RECRUITEE,
304
+ host_pattern=re.compile(r"^(?P<slug>[a-z0-9][a-z0-9-]*)\.recruitee\.com$"),
305
+ ),
306
+ UrlPattern(
307
+ platform=Platform.BREEZY,
308
+ host_pattern=re.compile(r"^(?P<slug>[a-z0-9][a-z0-9-]*)\.breezy\.hr$"),
309
+ ),
310
+ UrlPattern(
311
+ platform=Platform.COLLAGE,
312
+ hosts=("secure.collage.co", "api.collage.co"),
313
+ path_pattern=_path(r"^/(?:jobs|v1/positions)/(?P<slug>[^/?#]+)"),
314
+ ),
315
+ UrlPattern(
316
+ platform=Platform.TEAMTAILOR,
317
+ host_pattern=re.compile(r"^(?P<slug>[a-z0-9][a-z0-9-]*)\.teamtailor\.com$"),
318
+ ),
319
+ UrlPattern(
320
+ platform=Platform.WORKABLE,
321
+ host_pattern=re.compile(r"^(?P<slug>[a-z0-9][a-z0-9-]*)\.workable\.com$"),
322
+ ),
323
+ UrlPattern(
324
+ platform=Platform.PERSONIO,
325
+ host_pattern=re.compile(r"^(?P<slug>[a-z0-9][a-z0-9-]*)\.jobs\.personio\.(?:de|com)$"),
326
+ ),
327
+ UrlPattern(
328
+ platform=Platform.TALEO,
329
+ host_pattern=re.compile(r"^(?P<slug>[a-z0-9][a-z0-9-]*)\.taleo\.net$"),
330
+ ),
331
+ UrlPattern(
332
+ platform=Platform.ICIMS,
333
+ host_pattern=re.compile(r"^careers-(?P<slug>[a-z0-9][a-z0-9-]*)\.icims\.com$"),
334
+ ),
335
+ UrlPattern(
336
+ platform=Platform.NJOYN,
337
+ host_pattern=re.compile(r"^(?P<slug>[a-z0-9][a-z0-9-]*)\.njoyn\.com$"),
338
+ ),
339
+ UrlPattern(
340
+ platform=Platform.EIGHTFOLD,
341
+ host_pattern=re.compile(r"^(?P<slug>[a-z0-9][a-z0-9-]*)\.eightfold\.ai$"),
342
+ ),
343
+ UrlPattern(
344
+ platform=Platform.CORNERSTONE,
345
+ host_pattern=re.compile(r"^(?P<slug>[a-z0-9][a-z0-9-]*)\.csod\.com$"),
346
+ ),
347
+ )
348
+
349
+ _RESERVED_PATH_SEGMENTS = frozenset({"jobs", "job", "careers", "search", "embed", "api", "v1"})
350
+
351
+
352
+ def _split(url: str) -> tuple[str, str] | None:
353
+ from urllib.parse import urlsplit
354
+
355
+ try:
356
+ parts = urlsplit(url if "//" in url else f"https://{url}")
357
+ except ValueError:
358
+ return None
359
+ if parts.scheme not in ("https", "http", ""):
360
+ return None
361
+ host = (parts.hostname or "").lower()
362
+ if not host:
363
+ return None
364
+ return host, parts.path or "/"
365
+
366
+
367
+ def _workday(host: str, path: str) -> PlatformCandidate | None:
368
+ matched = _WORKDAY_HOST.match(host)
369
+ if matched is None:
370
+ return None
371
+ tenant = matched.group("tenant")
372
+ trimmed = re.sub(rf"^/wday/cxs/{re.escape(tenant)}/", "/", path, flags=re.IGNORECASE)
373
+ trimmed = re.sub(rf"^/{_LOCALE}/", "/", trimmed)
374
+ segments = [segment for segment in trimmed.split("/") if segment]
375
+ site = segments[0] if segments else None
376
+ if site is not None and site.lower() in {"jobs", "job"}:
377
+ site = None
378
+ return PlatformCandidate(
379
+ platform=Platform.WORKDAY,
380
+ slug=tenant,
381
+ workday_tenant=tenant,
382
+ workday_site=site,
383
+ workday_dc=matched.group("dc"),
384
+ )
385
+
386
+
387
+ def _successfactors(host: str, path: str) -> PlatformCandidate | None:
388
+ if not re.match(r"^/(?:go/|content/(?:\?|$))", path, flags=re.IGNORECASE):
389
+ return None
390
+ slug = _vanity_slug(host)
391
+ if slug is None:
392
+ return None
393
+ return PlatformCandidate(platform=Platform.SUCCESSFACTORS, slug=slug)
394
+
395
+
396
+ _CAREERS_SUBDOMAINS = frozenset(
397
+ {
398
+ "emploi",
399
+ "emplois",
400
+ "career",
401
+ "careers",
402
+ "carriere",
403
+ "carrieres",
404
+ "job",
405
+ "jobs",
406
+ "recruiting",
407
+ "recrutement",
408
+ "talent",
409
+ "www",
410
+ }
411
+ )
412
+
413
+
414
+ def _vanity_slug(host: str) -> str | None:
415
+ labels = [fold(label) for label in host.split(".") if label]
416
+ while labels and labels[0] in _CAREERS_SUBDOMAINS:
417
+ labels.pop(0)
418
+ if len(labels) < 2:
419
+ return None
420
+ slug = labels[0]
421
+ if slug in _CAREERS_SUBDOMAINS or not SAFE_SLUG.match(slug):
422
+ return None
423
+ return slug
424
+
425
+
426
+ _EXPERIENCE_PATH = re.compile(r"^/[a-z]{2}_[A-Za-z]{2}/[A-Za-z0-9_-]+/[A-Za-z0-9_-]+", re.I)
427
+
428
+
429
+ def _eightfold(url: str, host: str, path: str) -> PlatformCandidate | None:
430
+ from urllib.parse import parse_qs, urlsplit
431
+
432
+ if host.endswith(".eightfold.ai"):
433
+ return PlatformCandidate(platform=Platform.EIGHTFOLD, slug=host.split(".")[0])
434
+ if not path.lower().startswith("/careers"):
435
+ return None
436
+ try:
437
+ query = parse_qs(urlsplit(url if "//" in url else f"https://{url}").query)
438
+ except ValueError:
439
+ return None
440
+ domains = query.get("domain") or ()
441
+ if not domains:
442
+ return None
443
+ slug = _vanity_slug(domains[0].lower())
444
+ if slug is None:
445
+ return None
446
+ return PlatformCandidate(platform=Platform.EIGHTFOLD, slug=slug)
447
+
448
+
449
+ def _experience_layer(host: str, path: str) -> PlatformCandidate | None:
450
+ if not _EXPERIENCE_PATH.match(path):
451
+ return None
452
+ slug = _vanity_slug(host)
453
+ if slug is None:
454
+ return None
455
+ return PlatformCandidate(platform=Platform.AVATURE, slug=slug, resolves_board=False)
456
+
457
+
458
+ def _oracle_cloud(host: str, path: str) -> PlatformCandidate | None:
459
+ if not host.endswith(".oraclecloud.com"):
460
+ return None
461
+ if "/hcmui/candidateexperience" not in path.lower():
462
+ return None
463
+ slug = host.split(".")[0]
464
+ if not SAFE_SLUG.match(slug):
465
+ return None
466
+ matched = re.search(r"/sites/([A-Za-z0-9][A-Za-z0-9_-]{0,63})(?:/|$)", path, re.I)
467
+ site = matched.group(1) if matched is not None else None
468
+ return PlatformCandidate(
469
+ platform=Platform.ORACLE_CLOUD,
470
+ slug=slug,
471
+ oracle_host=host,
472
+ oracle_site=site,
473
+ resolves_board=site is not None,
474
+ )
475
+
476
+
477
+ def identify_url(url: str) -> PlatformCandidate | None:
478
+ split = _split(url)
479
+ if split is None:
480
+ return None
481
+ host, path = split
482
+
483
+ eightfold = _eightfold(url, host, path)
484
+ if eightfold is not None:
485
+ return eightfold
486
+
487
+ workday = _workday(host, path)
488
+ if workday is not None:
489
+ return workday
490
+
491
+ oracle = _oracle_cloud(host, path)
492
+ if oracle is not None:
493
+ return oracle
494
+
495
+ for pattern in URL_PATTERNS:
496
+ if pattern.host_pattern is not None:
497
+ host_match = pattern.host_pattern.match(host)
498
+ if host_match is not None:
499
+ return PlatformCandidate(pattern.platform, host_match.group("slug"))
500
+ continue
501
+ if host not in pattern.hosts or pattern.path_pattern is None:
502
+ continue
503
+ path_match = pattern.path_pattern.match(path)
504
+ if path_match is None:
505
+ continue
506
+ try:
507
+ slug = pattern.slug_validator(path_match.group("slug"))
508
+ except SlugRejectedError:
509
+ continue
510
+ if slug in _RESERVED_PATH_SEGMENTS:
511
+ continue
512
+ return PlatformCandidate(pattern.platform, slug)
513
+
514
+ return _successfactors(host, path) or _experience_layer(host, path)
515
+
516
+
517
+ MAX_PREDICATE_SCAN = 500
518
+
519
+
520
+ def _path_segments(path: str) -> list[str]:
521
+ segments: list[str] = []
522
+ buffer = ""
523
+ depth = 0
524
+ for char in path:
525
+ if char == "[":
526
+ depth += 1
527
+ buffer += char
528
+ elif char == "]":
529
+ depth = max(0, depth - 1)
530
+ buffer += char
531
+ elif char == "." and depth == 0:
532
+ segments.append(buffer)
533
+ buffer = ""
534
+ else:
535
+ buffer += char
536
+ segments.append(buffer)
537
+ return segments
538
+
539
+
540
+ def _matching_entry(current: Any, predicate: str) -> Any:
541
+ if not isinstance(current, list):
542
+ return None
543
+ field, sep, wanted = predicate.partition("=")
544
+ if not sep:
545
+ return None
546
+ field = field.strip()
547
+ wanted = wanted.strip()
548
+ for entry in current[:MAX_PREDICATE_SCAN]:
549
+ if dig(entry, field) == wanted:
550
+ return entry
551
+ return None
552
+
553
+
554
+ def _step(current: Any, part: str) -> Any:
555
+ if isinstance(current, list):
556
+ if not part.isdigit():
557
+ return None
558
+ index = int(part)
559
+ return None if index >= len(current) else current[index]
560
+ if isinstance(current, dict):
561
+ return current.get(part) if part in current else None
562
+ return None
563
+
564
+
565
+ def dig(payload: Any, path: str) -> Any:
566
+ if path == "":
567
+ return payload
568
+ current = payload
569
+ for part in _path_segments(path):
570
+ predicate = ""
571
+ if part.endswith("]") and "[" in part:
572
+ part, _, rest = part.partition("[")
573
+ predicate = rest[:-1]
574
+ if part:
575
+ current = _step(current, part)
576
+ if predicate:
577
+ current = _matching_entry(current, predicate)
578
+ if current is None:
579
+ return None
580
+ return current
581
+
582
+
583
+ def first_str(payload: Any, paths: tuple[str, ...]) -> str:
584
+ for path in paths:
585
+ value = dig(payload, path)
586
+ if isinstance(value, str) and value.strip():
587
+ return value.strip()
588
+ return ""
589
+
590
+
591
+ def first_int(payload: Any, paths: tuple[str, ...]) -> int | None:
592
+ for path in paths:
593
+ value = dig(payload, path)
594
+ if isinstance(value, bool):
595
+ continue
596
+ if isinstance(value, int):
597
+ return value
598
+ return None
599
+
600
+
601
+ def job_count(payload: Any, probe: PlatformProbe) -> int | None:
602
+ counted = first_int(payload, probe.count_paths)
603
+ if counted is not None:
604
+ return counted
605
+ for path in probe.jobs_paths:
606
+ value = dig(payload, path)
607
+ if isinstance(value, list):
608
+ return len(value)
609
+ return None