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.
- stage/__init__.py +1 -0
- stage/__main__.py +8 -0
- stage/banner.py +32 -0
- stage/bootstrap/__init__.py +0 -0
- stage/bootstrap/openjobs.py +392 -0
- stage/classify/__init__.py +29 -0
- stage/classify/eligibility.py +115 -0
- stage/classify/internship.py +64 -0
- stage/classify/role.py +91 -0
- stage/classify/scope.py +47 -0
- stage/cli/__init__.py +0 -0
- stage/cli/app.py +4 -0
- stage/cli/commands/__init__.py +8 -0
- stage/cli/commands/discovery.py +294 -0
- stage/cli/commands/insight.py +494 -0
- stage/cli/commands/pipeline.py +337 -0
- stage/cli/commands/postings.py +473 -0
- stage/cli/commands/schedule.py +171 -0
- stage/cli/housekeeping.py +64 -0
- stage/cli/logfile.py +56 -0
- stage/cli/notify.py +170 -0
- stage/cli/options.py +678 -0
- stage/cli/render.py +1398 -0
- stage/cli/runlock.py +74 -0
- stage/cli/schedule.py +702 -0
- stage/cli/schedule_state.py +363 -0
- stage/cli/selection.py +83 -0
- stage/cli/serialize.py +196 -0
- stage/companies.py +542 -0
- stage/data/companies/a.yaml +1289 -0
- stage/data/companies/b.yaml +900 -0
- stage/data/companies/c.yaml +1377 -0
- stage/data/companies/d.yaml +497 -0
- stage/data/companies/e.yaml +519 -0
- stage/data/companies/f.yaml +454 -0
- stage/data/companies/g.yaml +601 -0
- stage/data/companies/h.yaml +446 -0
- stage/data/companies/i.yaml +503 -0
- stage/data/companies/j.yaml +138 -0
- stage/data/companies/k.yaml +278 -0
- stage/data/companies/l.yaml +402 -0
- stage/data/companies/m.yaml +937 -0
- stage/data/companies/n.yaml +549 -0
- stage/data/companies/o.yaml +371 -0
- stage/data/companies/other.yaml +58 -0
- stage/data/companies/p.yaml +825 -0
- stage/data/companies/q.yaml +121 -0
- stage/data/companies/r.yaml +583 -0
- stage/data/companies/s.yaml +1140 -0
- stage/data/companies/t.yaml +817 -0
- stage/data/companies/u.yaml +196 -0
- stage/data/companies/v.yaml +325 -0
- stage/data/companies/w.yaml +353 -0
- stage/data/companies/x.yaml +67 -0
- stage/data/companies/y.yaml +36 -0
- stage/data/companies/z.yaml +146 -0
- stage/data/fonts/DejaVuSans.LICENSE.txt +99 -0
- stage/data/fonts/DejaVuSans.ttf +0 -0
- stage/data/lexicon/company_tokens.yaml +228 -0
- stage/data/lexicon/eligibility.yaml +455 -0
- stage/data/lexicon/inclusive_suffixes.yaml +37 -0
- stage/data/lexicon/internship.yaml +187 -0
- stage/data/lexicon/language.yaml +226 -0
- stage/data/lexicon/locations.yaml +1159 -0
- stage/data/lexicon/roles.yaml +2012 -0
- stage/data/lexicon/terms.yaml +76 -0
- stage/data/lexicon/workday_facets.yaml +27 -0
- stage/data/seed_companies.yaml +198 -0
- stage/dedup/__init__.py +19 -0
- stage/dedup/identity.py +113 -0
- stage/dedup/resolve.py +97 -0
- stage/domain/__init__.py +244 -0
- stage/domain/company.py +49 -0
- stage/domain/coverage.py +86 -0
- stage/domain/custom_board.py +92 -0
- stage/domain/discovery.py +94 -0
- stage/domain/enums.py +114 -0
- stage/domain/events.py +204 -0
- stage/domain/filters.py +27 -0
- stage/domain/health.py +169 -0
- stage/domain/ids.py +48 -0
- stage/domain/job.py +47 -0
- stage/domain/matching.py +15 -0
- stage/domain/priority.py +34 -0
- stage/domain/quarantine.py +39 -0
- stage/domain/rate_state.py +78 -0
- stage/domain/retention.py +20 -0
- stage/domain/rotation.py +46 -0
- stage/domain/signals.py +12 -0
- stage/domain/sync_run.py +35 -0
- stage/domain/text.py +113 -0
- stage/domain/validator.py +14 -0
- stage/domain/visits.py +60 -0
- stage/domain/workday.py +38 -0
- stage/http/__init__.py +58 -0
- stage/http/breaker.py +53 -0
- stage/http/cache.py +44 -0
- stage/http/client.py +725 -0
- stage/http/profiles.py +101 -0
- stage/lexicon.py +370 -0
- stage/normalize/__init__.py +16 -0
- stage/normalize/language.py +47 -0
- stage/normalize/location.py +271 -0
- stage/normalize/terms.py +153 -0
- stage/normalize/urls.py +122 -0
- stage/paths.py +86 -0
- stage/py.typed +0 -0
- stage/services/__init__.py +0 -0
- stage/services/canary.py +120 -0
- stage/services/coverage.py +231 -0
- stage/services/discover.py +747 -0
- stage/services/export.py +274 -0
- stage/services/health.py +237 -0
- stage/services/maintenance.py +225 -0
- stage/services/quarantine.py +20 -0
- stage/services/query.py +86 -0
- stage/services/sync.py +1257 -0
- stage/sources/__init__.py +82 -0
- stage/sources/_text.py +79 -0
- stage/sources/ashby.py +93 -0
- stage/sources/bamboohr.py +80 -0
- stage/sources/base.py +225 -0
- stage/sources/breezy.py +90 -0
- stage/sources/collage.py +60 -0
- stage/sources/community_feeds.py +142 -0
- stage/sources/curated_markdown.py +289 -0
- stage/sources/custom_json.py +610 -0
- stage/sources/espresso.py +154 -0
- stage/sources/feed.py +44 -0
- stage/sources/greenhouse.py +104 -0
- stage/sources/jobbank.py +147 -0
- stage/sources/jobvite.py +133 -0
- stage/sources/lever.py +76 -0
- stage/sources/oracle_cloud.py +187 -0
- stage/sources/platforms.py +609 -0
- stage/sources/quebec_emploi.py +146 -0
- stage/sources/recruitee.py +96 -0
- stage/sources/simplify.py +110 -0
- stage/sources/smartrecruiters.py +216 -0
- stage/sources/speedyapply.py +200 -0
- stage/sources/themuse.py +157 -0
- stage/sources/workable.py +83 -0
- stage/sources/workday.py +524 -0
- stage/sources/zshah.py +99 -0
- stage/storage/__init__.py +29 -0
- stage/storage/migrations/0001_initial.sql +239 -0
- stage/storage/migrations/__init__.py +135 -0
- stage/storage/repository.py +213 -0
- stage/storage/search.py +28 -0
- stage/storage/sqlite_repo.py +1586 -0
- stage/storage/writer.py +249 -0
- stage/tui/__init__.py +0 -0
- stage/tui/app.py +82 -0
- stage/tui/help.py +26 -0
- stage/tui/safe.py +21 -0
- stage/tui/screens/__init__.py +0 -0
- stage/tui/screens/boards.py +186 -0
- stage/tui/screens/postings.py +509 -0
- stage/tui/screens/review.py +209 -0
- stage/tui/screens/splash.py +37 -0
- stage/tui/screens/stats.py +124 -0
- stage/tui/screens/sync.py +194 -0
- stage/tui/state.py +160 -0
- stage/tui/theme.tcss +205 -0
- stage/tui/widgets/__init__.py +0 -0
- stage_cli-1.0.0.dist-info/METADATA +379 -0
- stage_cli-1.0.0.dist-info/RECORD +170 -0
- stage_cli-1.0.0.dist-info/WHEEL +4 -0
- stage_cli-1.0.0.dist-info/entry_points.txt +2 -0
- 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
|