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,747 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
|
|
3
|
+
from dataclasses import dataclass, replace
|
|
4
|
+
from datetime import date
|
|
5
|
+
|
|
6
|
+
from stage.domain import (
|
|
7
|
+
CandidateSkipped,
|
|
8
|
+
Company,
|
|
9
|
+
DiscoveryEvent,
|
|
10
|
+
DiscoveryFinished,
|
|
11
|
+
DiscoveryStarted,
|
|
12
|
+
EmployerSize,
|
|
13
|
+
Platform,
|
|
14
|
+
PlatformCandidate,
|
|
15
|
+
PlatformProbed,
|
|
16
|
+
ProbeResult,
|
|
17
|
+
ProbeVerdict,
|
|
18
|
+
RequestLogged,
|
|
19
|
+
SourceOfRecord,
|
|
20
|
+
UrlResolved,
|
|
21
|
+
UrlUnrecognized,
|
|
22
|
+
first_line,
|
|
23
|
+
)
|
|
24
|
+
from stage.http import (
|
|
25
|
+
HostBudgetExceededError,
|
|
26
|
+
HttpClient,
|
|
27
|
+
RatePosture,
|
|
28
|
+
ValidatorCache,
|
|
29
|
+
resolve,
|
|
30
|
+
)
|
|
31
|
+
from stage.lexicon import company_legal_suffixes, fold, generic_company_tokens, name_root_tokens
|
|
32
|
+
from stage.sources.platforms import (
|
|
33
|
+
PROBES,
|
|
34
|
+
PROBES_BY_PLATFORM,
|
|
35
|
+
URL_ONLY_PLATFORMS,
|
|
36
|
+
PlatformProbe,
|
|
37
|
+
SlugRejectedError,
|
|
38
|
+
first_str,
|
|
39
|
+
identify_url,
|
|
40
|
+
job_count,
|
|
41
|
+
safe_slug,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class NoMatchingCompanyError(Exception):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
MAX_CANDIDATES_PER_COMPANY = 3
|
|
50
|
+
DISCOVERY_PROFILE = "discovery"
|
|
51
|
+
|
|
52
|
+
SIZE_BANDS: dict[EmployerSize, tuple[int, int]] = {
|
|
53
|
+
EmployerSize.STARTUP: (1, 300),
|
|
54
|
+
EmployerSize.MID: (5, 3_000),
|
|
55
|
+
EmployerSize.LARGE: (25, 60_000),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
GENERIC_TOKENS = generic_company_tokens()
|
|
59
|
+
_LEGAL_SUFFIXES = company_legal_suffixes()
|
|
60
|
+
|
|
61
|
+
MIN_DISTINCTIVE_TOKEN = 4
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class SlugPlan:
|
|
66
|
+
accepted: tuple[str, ...]
|
|
67
|
+
skipped: tuple[tuple[str, str], ...]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def slug_candidates(name: str) -> SlugPlan:
|
|
71
|
+
tokens = list(name_root_tokens(name))
|
|
72
|
+
if not tokens:
|
|
73
|
+
return SlugPlan((), ())
|
|
74
|
+
|
|
75
|
+
accepted: list[str] = []
|
|
76
|
+
skipped: list[tuple[str, str]] = []
|
|
77
|
+
|
|
78
|
+
def offer(candidate: str, reason: str | None = None) -> None:
|
|
79
|
+
if reason is not None:
|
|
80
|
+
skipped.append((candidate, reason))
|
|
81
|
+
return
|
|
82
|
+
if candidate in accepted or not candidate:
|
|
83
|
+
return
|
|
84
|
+
try:
|
|
85
|
+
accepted.append(safe_slug(candidate))
|
|
86
|
+
except SlugRejectedError as exc:
|
|
87
|
+
skipped.append((candidate, str(exc)))
|
|
88
|
+
|
|
89
|
+
offer("".join(tokens))
|
|
90
|
+
if len(tokens) > 1:
|
|
91
|
+
offer("-".join(tokens))
|
|
92
|
+
head = tokens[0]
|
|
93
|
+
if head in GENERIC_TOKENS:
|
|
94
|
+
offer(head, f"{head!r} is a generic first token — the confirmed false-positive shape")
|
|
95
|
+
elif len(head) < MIN_DISTINCTIVE_TOKEN:
|
|
96
|
+
offer(head, f"{head!r} is too short to be distinctive on its own")
|
|
97
|
+
else:
|
|
98
|
+
offer(head)
|
|
99
|
+
|
|
100
|
+
return SlugPlan(tuple(accepted[:MAX_CANDIDATES_PER_COMPANY]), tuple(skipped))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def name_tokens(value: str) -> frozenset[str]:
|
|
104
|
+
return frozenset(token for token in fold(value).split() if token not in _LEGAL_SUFFIXES)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _joined(value: str) -> str:
|
|
108
|
+
return "".join(token for token in fold(value).split() if token not in _LEGAL_SUFFIXES)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _acquisition_named(company: Company) -> bool:
|
|
112
|
+
return company.name_gate_exempt
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def name_matches(company: str, board_name: str) -> bool:
|
|
116
|
+
wanted = name_tokens(company)
|
|
117
|
+
found = name_tokens(board_name)
|
|
118
|
+
if not wanted or not found:
|
|
119
|
+
return False
|
|
120
|
+
if _joined(company) == _joined(board_name):
|
|
121
|
+
return True
|
|
122
|
+
if not (wanted <= found or found <= wanted):
|
|
123
|
+
return False
|
|
124
|
+
overlap = wanted & found
|
|
125
|
+
if overlap - GENERIC_TOKENS:
|
|
126
|
+
return True
|
|
127
|
+
return len(overlap) >= 2 or wanted == found
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _count_verdict(count: int | None, size: EmployerSize | None) -> str:
|
|
131
|
+
if count is None or size is None:
|
|
132
|
+
return ""
|
|
133
|
+
low, high = SIZE_BANDS[size]
|
|
134
|
+
if count < low:
|
|
135
|
+
return f"{count} posting(s) is below the plausible floor for a {size.value} employer"
|
|
136
|
+
if count > high:
|
|
137
|
+
return f"{count} posting(s) exceeds the plausible ceiling for a {size.value} employer"
|
|
138
|
+
return ""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def classify(
|
|
142
|
+
company: str,
|
|
143
|
+
candidate: PlatformCandidate,
|
|
144
|
+
url: str,
|
|
145
|
+
payload: object,
|
|
146
|
+
probe: PlatformProbe,
|
|
147
|
+
*,
|
|
148
|
+
board_name: str,
|
|
149
|
+
size: EmployerSize | None,
|
|
150
|
+
) -> ProbeResult:
|
|
151
|
+
count = job_count(payload, probe)
|
|
152
|
+
|
|
153
|
+
def result(verdict: ProbeVerdict, detail: str = "") -> ProbeResult:
|
|
154
|
+
return ProbeResult(
|
|
155
|
+
company=company,
|
|
156
|
+
candidate=candidate,
|
|
157
|
+
verdict=verdict,
|
|
158
|
+
url=url,
|
|
159
|
+
board_name=board_name,
|
|
160
|
+
job_count=count,
|
|
161
|
+
detail=detail,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if count is None:
|
|
165
|
+
return result(ProbeVerdict.MISS, "no job collection in the response")
|
|
166
|
+
if count == 0:
|
|
167
|
+
return result(ProbeVerdict.EMPTY, "board exists but is empty — nothing to verify against")
|
|
168
|
+
|
|
169
|
+
implausible = _count_verdict(count, size)
|
|
170
|
+
if implausible:
|
|
171
|
+
return result(ProbeVerdict.REJECTED, implausible)
|
|
172
|
+
|
|
173
|
+
if not board_name:
|
|
174
|
+
return result(
|
|
175
|
+
ProbeVerdict.UNVERIFIED,
|
|
176
|
+
f"{probe.platform.value} exposes no board name — confirm by hand before adding",
|
|
177
|
+
)
|
|
178
|
+
if not name_matches(company, board_name):
|
|
179
|
+
return result(
|
|
180
|
+
ProbeVerdict.REJECTED,
|
|
181
|
+
f"board is named {board_name!r}, which does not contain {company!r}",
|
|
182
|
+
)
|
|
183
|
+
return result(ProbeVerdict.MATCH)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
ClientFactory = Callable[[frozenset[str], RatePosture], HttpClient]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _default_client(hosts: frozenset[str], posture: RatePosture) -> HttpClient:
|
|
190
|
+
return HttpClient(allowed_hosts=hosts, posture=posture, cache=ValidatorCache())
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def resolve_careers_url(url: str) -> DiscoveryEvent:
|
|
194
|
+
candidate = identify_url(url)
|
|
195
|
+
if candidate is None:
|
|
196
|
+
return UrlUnrecognized(
|
|
197
|
+
url=url,
|
|
198
|
+
detail=(
|
|
199
|
+
"no known ATS in this URL's shape. DevTools -> Network, filter "
|
|
200
|
+
"Fetch/XHR: if a request returns the job list, this is custom_json"
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
detail = ""
|
|
204
|
+
if candidate.platform is Platform.WORKDAY:
|
|
205
|
+
if candidate.workday_site is None:
|
|
206
|
+
detail = "tenant and datacenter resolved, but the site segment is missing from the URL"
|
|
207
|
+
else:
|
|
208
|
+
detail = "workday_facet resolves on first contact, which lands in build step 6"
|
|
209
|
+
elif candidate.platform in URL_ONLY_PLATFORMS:
|
|
210
|
+
detail = f"{candidate.platform.value} has no probeable board endpoint — accepted on shape"
|
|
211
|
+
return UrlResolved(url=url, candidate=candidate, detail=detail)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def direct_companies_from_apply_urls(
|
|
215
|
+
apply_urls: Mapping[str, Sequence[str]],
|
|
216
|
+
*,
|
|
217
|
+
platforms: Sequence[Platform] | None = None,
|
|
218
|
+
excluded: Sequence[Platform] | None = None,
|
|
219
|
+
) -> tuple[Company, ...]:
|
|
220
|
+
allowed = {probe.platform for probe in _selected_probes(platforms, excluded)}
|
|
221
|
+
rank = {probe.platform: index for index, probe in enumerate(PROBES)}
|
|
222
|
+
direct: list[Company] = []
|
|
223
|
+
for name, urls in apply_urls.items():
|
|
224
|
+
candidates = [
|
|
225
|
+
candidate
|
|
226
|
+
for url in urls
|
|
227
|
+
if (candidate := identify_url(url)) is not None and candidate.platform in allowed
|
|
228
|
+
]
|
|
229
|
+
if not candidates:
|
|
230
|
+
continue
|
|
231
|
+
candidate = min(candidates, key=lambda value: (rank[value.platform], value.slug))
|
|
232
|
+
direct.append(to_company(name, candidate))
|
|
233
|
+
return tuple(direct)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def select_unregistered(
|
|
237
|
+
ranked: Sequence[str],
|
|
238
|
+
apply_urls: Mapping[str, Sequence[str]],
|
|
239
|
+
*,
|
|
240
|
+
limit: int,
|
|
241
|
+
direct_only: bool,
|
|
242
|
+
platforms: Sequence[Platform] | None = None,
|
|
243
|
+
excluded: Sequence[Platform] | None = None,
|
|
244
|
+
) -> tuple[tuple[Company, ...], tuple[str, ...]]:
|
|
245
|
+
order = {name: index for index, name in enumerate(ranked)}
|
|
246
|
+
direct = direct_companies_from_apply_urls(apply_urls, platforms=platforms, excluded=excluded)
|
|
247
|
+
ordered = tuple(sorted(direct, key=lambda company: order.get(company.name, len(order))))[:limit]
|
|
248
|
+
if direct_only:
|
|
249
|
+
return ordered, ()
|
|
250
|
+
taken = {company.name for company in ordered}
|
|
251
|
+
return ordered, tuple(name for name in ranked if name not in taken)[:limit]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def is_routable(platform: Platform) -> bool:
|
|
255
|
+
from stage.sources import adapter_for_platform
|
|
256
|
+
|
|
257
|
+
return adapter_for_platform(platform) is not None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def to_company(
|
|
261
|
+
name: str,
|
|
262
|
+
candidate: PlatformCandidate,
|
|
263
|
+
*,
|
|
264
|
+
verified_on: date | None = None,
|
|
265
|
+
) -> Company:
|
|
266
|
+
routable = is_routable(candidate.platform)
|
|
267
|
+
return Company(
|
|
268
|
+
name=name,
|
|
269
|
+
platform=candidate.platform,
|
|
270
|
+
slug=candidate.slug,
|
|
271
|
+
enabled=routable and verified_on is not None,
|
|
272
|
+
source_of_record=SourceOfRecord.DISCOVER,
|
|
273
|
+
last_verified=verified_on,
|
|
274
|
+
workday_tenant=candidate.workday_tenant,
|
|
275
|
+
workday_site=candidate.workday_site,
|
|
276
|
+
workday_dc=candidate.workday_dc,
|
|
277
|
+
oracle_host=candidate.oracle_host,
|
|
278
|
+
oracle_site=candidate.oracle_site,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _selected_probes(
|
|
283
|
+
platforms: Sequence[Platform] | None,
|
|
284
|
+
excluded: Sequence[Platform] | None = None,
|
|
285
|
+
) -> tuple[PlatformProbe, ...]:
|
|
286
|
+
if platforms is None:
|
|
287
|
+
chosen = list(PROBES)
|
|
288
|
+
else:
|
|
289
|
+
chosen = [
|
|
290
|
+
PROBES_BY_PLATFORM[platform] for platform in platforms if platform in PROBES_BY_PLATFORM
|
|
291
|
+
]
|
|
292
|
+
if excluded is None:
|
|
293
|
+
return tuple(chosen)
|
|
294
|
+
skip = set(excluded)
|
|
295
|
+
return tuple(probe for probe in chosen if probe.platform not in skip)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
async def probe_companies(
|
|
299
|
+
names: Sequence[str],
|
|
300
|
+
*,
|
|
301
|
+
platforms: Sequence[Platform] | None = None,
|
|
302
|
+
excluded: Sequence[Platform] | None = None,
|
|
303
|
+
size: EmployerSize | None = None,
|
|
304
|
+
client_factory: ClientFactory = _default_client,
|
|
305
|
+
) -> AsyncIterator[DiscoveryEvent]:
|
|
306
|
+
probes = _selected_probes(platforms, excluded)
|
|
307
|
+
plans = {name: slug_candidates(name) for name in names}
|
|
308
|
+
|
|
309
|
+
started = time.perf_counter()
|
|
310
|
+
yield DiscoveryStarted(
|
|
311
|
+
companies=tuple(names),
|
|
312
|
+
platforms=tuple(probe.platform.value for probe in probes),
|
|
313
|
+
probes_planned=sum(len(plan.accepted) for plan in plans.values()) * len(probes),
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
seen_skips: set[tuple[str, str]] = set()
|
|
317
|
+
for name, plan in plans.items():
|
|
318
|
+
for slug, reason in plan.skipped:
|
|
319
|
+
if (name, slug) in seen_skips:
|
|
320
|
+
continue
|
|
321
|
+
seen_skips.add((name, slug))
|
|
322
|
+
yield CandidateSkipped(company=name, slug=slug, reason=reason)
|
|
323
|
+
|
|
324
|
+
matched: list[ProbeResult] = []
|
|
325
|
+
unverified: list[ProbeResult] = []
|
|
326
|
+
rejected: list[ProbeResult] = []
|
|
327
|
+
missed = 0
|
|
328
|
+
errors = 0
|
|
329
|
+
requests = 0
|
|
330
|
+
ceiling_hit: list[str] = []
|
|
331
|
+
decode_failures: dict[str, int] = {}
|
|
332
|
+
attempts: dict[str, int] = {}
|
|
333
|
+
|
|
334
|
+
for probe in probes:
|
|
335
|
+
posture = resolve(probe.rate_profile, [DISCOVERY_PROFILE])
|
|
336
|
+
hosts = frozenset(probe.host_for(slug) for plan in plans.values() for slug in plan.accepted)
|
|
337
|
+
if not hosts:
|
|
338
|
+
continue
|
|
339
|
+
blocked = False
|
|
340
|
+
async with client_factory(hosts, posture) as client:
|
|
341
|
+
for name, plan in plans.items():
|
|
342
|
+
if blocked:
|
|
343
|
+
break
|
|
344
|
+
for slug in plan.accepted:
|
|
345
|
+
candidate = PlatformCandidate(probe.platform, slug)
|
|
346
|
+
url = probe.url_for(slug)
|
|
347
|
+
key = probe.platform.value
|
|
348
|
+
attempts[key] = attempts.get(key, 0) + 1
|
|
349
|
+
|
|
350
|
+
response = None
|
|
351
|
+
failure: Exception | None = None
|
|
352
|
+
board_name = ""
|
|
353
|
+
try:
|
|
354
|
+
response = await client.get_json(url, params=dict(probe.params))
|
|
355
|
+
except Exception as exc:
|
|
356
|
+
failure = exc
|
|
357
|
+
if response is not None:
|
|
358
|
+
board_name = first_str(response.payload, probe.name_paths)
|
|
359
|
+
if not board_name and probe.verify_url is not None:
|
|
360
|
+
board_name = await _verify_name(client, probe, slug)
|
|
361
|
+
|
|
362
|
+
for record in client.drain_log():
|
|
363
|
+
yield RequestLogged(
|
|
364
|
+
source="discover",
|
|
365
|
+
method=record.method,
|
|
366
|
+
url=record.url,
|
|
367
|
+
status=record.status,
|
|
368
|
+
elapsed_ms=record.elapsed_ms,
|
|
369
|
+
attempt=record.attempt,
|
|
370
|
+
error=record.error,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
if isinstance(failure, HostBudgetExceededError):
|
|
374
|
+
blocked = True
|
|
375
|
+
ceiling_hit.append(f"{probe.platform.value}: {failure}")
|
|
376
|
+
break
|
|
377
|
+
if failure is not None or response is None:
|
|
378
|
+
absent = failure is not None and _is_absent(failure)
|
|
379
|
+
if isinstance(failure, ValueError):
|
|
380
|
+
decode_failures[key] = decode_failures.get(key, 0) + 1
|
|
381
|
+
if absent:
|
|
382
|
+
missed += 1
|
|
383
|
+
else:
|
|
384
|
+
errors += 1
|
|
385
|
+
yield PlatformProbed(
|
|
386
|
+
ProbeResult(
|
|
387
|
+
company=name,
|
|
388
|
+
candidate=candidate,
|
|
389
|
+
verdict=ProbeVerdict.MISS if absent else ProbeVerdict.ERROR,
|
|
390
|
+
url=url,
|
|
391
|
+
detail=f"{type(failure).__name__}: {failure}",
|
|
392
|
+
)
|
|
393
|
+
)
|
|
394
|
+
continue
|
|
395
|
+
|
|
396
|
+
result = classify(
|
|
397
|
+
name,
|
|
398
|
+
candidate,
|
|
399
|
+
url,
|
|
400
|
+
response.payload,
|
|
401
|
+
probe,
|
|
402
|
+
board_name=board_name,
|
|
403
|
+
size=size,
|
|
404
|
+
)
|
|
405
|
+
match result.verdict:
|
|
406
|
+
case ProbeVerdict.MATCH:
|
|
407
|
+
matched.append(result)
|
|
408
|
+
case ProbeVerdict.UNVERIFIED:
|
|
409
|
+
unverified.append(result)
|
|
410
|
+
case ProbeVerdict.REJECTED:
|
|
411
|
+
rejected.append(result)
|
|
412
|
+
case ProbeVerdict.ERROR:
|
|
413
|
+
errors += 1
|
|
414
|
+
case _:
|
|
415
|
+
missed += 1
|
|
416
|
+
yield PlatformProbed(result)
|
|
417
|
+
requests += client.request_count
|
|
418
|
+
|
|
419
|
+
yield DiscoveryFinished(
|
|
420
|
+
matched=tuple(matched),
|
|
421
|
+
unverified=tuple(unverified),
|
|
422
|
+
rejected=tuple(rejected),
|
|
423
|
+
missed=missed,
|
|
424
|
+
errors=errors,
|
|
425
|
+
requests=requests,
|
|
426
|
+
elapsed_ms=(time.perf_counter() - started) * 1000,
|
|
427
|
+
ceiling_hit=tuple(ceiling_hit),
|
|
428
|
+
non_json=tuple(
|
|
429
|
+
(platform, count)
|
|
430
|
+
for platform, count in sorted(decode_failures.items())
|
|
431
|
+
if count == attempts.get(platform, 0) and count >= 2
|
|
432
|
+
),
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
async def _verify_name(client: HttpClient, probe: PlatformProbe, slug: str) -> str:
|
|
437
|
+
target = probe.verify_url_for(slug)
|
|
438
|
+
if target is None:
|
|
439
|
+
return ""
|
|
440
|
+
try:
|
|
441
|
+
response = await client.get_json(target)
|
|
442
|
+
except Exception:
|
|
443
|
+
return ""
|
|
444
|
+
return first_str(response.payload, probe.verify_name_paths)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _is_absent(exc: Exception) -> bool:
|
|
448
|
+
if isinstance(exc, ValueError):
|
|
449
|
+
return True
|
|
450
|
+
status = getattr(getattr(exc, "response", None), "status_code", None)
|
|
451
|
+
return status in (401, 403, 404, 410)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
async def verify_registry(
|
|
455
|
+
companies: Sequence[Company],
|
|
456
|
+
*,
|
|
457
|
+
platforms: Sequence[Platform] | None = None,
|
|
458
|
+
excluded: Sequence[Platform] | None = None,
|
|
459
|
+
only: Sequence[str] | None = None,
|
|
460
|
+
client_factory: ClientFactory = _default_client,
|
|
461
|
+
) -> AsyncIterator[DiscoveryEvent]:
|
|
462
|
+
wanted = None if only is None else {fold(name) for name in only}
|
|
463
|
+
selected = [
|
|
464
|
+
company
|
|
465
|
+
for company in companies
|
|
466
|
+
if company.platform in PROBES_BY_PLATFORM
|
|
467
|
+
and (platforms is None or company.platform in platforms)
|
|
468
|
+
and (excluded is None or company.platform not in excluded)
|
|
469
|
+
and (wanted is None or fold(company.name) in wanted)
|
|
470
|
+
]
|
|
471
|
+
if wanted is not None and not selected:
|
|
472
|
+
raise NoMatchingCompanyError(f"no registry row matches: {', '.join(sorted(only or ()))}")
|
|
473
|
+
grouped: dict[Platform, list[Company]] = {}
|
|
474
|
+
for company in selected:
|
|
475
|
+
grouped.setdefault(company.platform, []).append(company)
|
|
476
|
+
|
|
477
|
+
started = time.perf_counter()
|
|
478
|
+
yield DiscoveryStarted(
|
|
479
|
+
companies=tuple(company.name for company in selected),
|
|
480
|
+
platforms=tuple(platform.value for platform in sorted(grouped, key=lambda p: p.value)),
|
|
481
|
+
probes_planned=len(selected),
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
matched: list[ProbeResult] = []
|
|
485
|
+
unverified: list[ProbeResult] = []
|
|
486
|
+
rejected: list[ProbeResult] = []
|
|
487
|
+
missed = 0
|
|
488
|
+
errors = 0
|
|
489
|
+
requests = 0
|
|
490
|
+
ceiling_hit: list[str] = []
|
|
491
|
+
|
|
492
|
+
for platform in sorted(grouped, key=lambda item: item.value):
|
|
493
|
+
probe = PROBES_BY_PLATFORM[platform]
|
|
494
|
+
rows = grouped[platform]
|
|
495
|
+
posture = resolve(probe.rate_profile, [])
|
|
496
|
+
hosts = frozenset(probe.host_for(company.slug) for company in rows)
|
|
497
|
+
blocked = False
|
|
498
|
+
async with client_factory(hosts, posture) as client:
|
|
499
|
+
for company in rows:
|
|
500
|
+
if blocked:
|
|
501
|
+
break
|
|
502
|
+
candidate = PlatformCandidate(platform, company.slug)
|
|
503
|
+
url = probe.url_for(company.slug)
|
|
504
|
+
response = None
|
|
505
|
+
failure: Exception | None = None
|
|
506
|
+
board_name = ""
|
|
507
|
+
try:
|
|
508
|
+
response = await client.get_json(url, params=dict(probe.params))
|
|
509
|
+
except Exception as exc:
|
|
510
|
+
failure = exc
|
|
511
|
+
if response is not None:
|
|
512
|
+
board_name = first_str(response.payload, probe.name_paths)
|
|
513
|
+
if not board_name and probe.verify_url is not None:
|
|
514
|
+
board_name = await _verify_name(client, probe, company.slug)
|
|
515
|
+
|
|
516
|
+
for record in client.drain_log():
|
|
517
|
+
yield RequestLogged(
|
|
518
|
+
source="verify",
|
|
519
|
+
method=record.method,
|
|
520
|
+
url=record.url,
|
|
521
|
+
status=record.status,
|
|
522
|
+
elapsed_ms=record.elapsed_ms,
|
|
523
|
+
attempt=record.attempt,
|
|
524
|
+
error=record.error,
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
if isinstance(failure, HostBudgetExceededError):
|
|
528
|
+
blocked = True
|
|
529
|
+
ceiling_hit.append(f"{platform.value}: {failure}")
|
|
530
|
+
break
|
|
531
|
+
if failure is not None or response is None:
|
|
532
|
+
absent = failure is not None and _is_absent(failure)
|
|
533
|
+
if absent:
|
|
534
|
+
missed += 1
|
|
535
|
+
else:
|
|
536
|
+
errors += 1
|
|
537
|
+
yield PlatformProbed(
|
|
538
|
+
ProbeResult(
|
|
539
|
+
company=company.name,
|
|
540
|
+
candidate=candidate,
|
|
541
|
+
verdict=ProbeVerdict.MISS if absent else ProbeVerdict.ERROR,
|
|
542
|
+
url=url,
|
|
543
|
+
detail=f"{type(failure).__name__}: {failure}",
|
|
544
|
+
)
|
|
545
|
+
)
|
|
546
|
+
continue
|
|
547
|
+
|
|
548
|
+
result = classify(
|
|
549
|
+
company.name,
|
|
550
|
+
candidate,
|
|
551
|
+
url,
|
|
552
|
+
response.payload,
|
|
553
|
+
probe,
|
|
554
|
+
board_name=board_name,
|
|
555
|
+
size=None,
|
|
556
|
+
)
|
|
557
|
+
if result.verdict is ProbeVerdict.REJECTED and _acquisition_named(company):
|
|
558
|
+
result = replace(result, verdict=ProbeVerdict.UNVERIFIED)
|
|
559
|
+
match result.verdict:
|
|
560
|
+
case ProbeVerdict.MATCH:
|
|
561
|
+
matched.append(result)
|
|
562
|
+
case ProbeVerdict.UNVERIFIED:
|
|
563
|
+
unverified.append(result)
|
|
564
|
+
case ProbeVerdict.REJECTED:
|
|
565
|
+
rejected.append(result)
|
|
566
|
+
case ProbeVerdict.ERROR:
|
|
567
|
+
errors += 1
|
|
568
|
+
case _:
|
|
569
|
+
missed += 1
|
|
570
|
+
yield PlatformProbed(result)
|
|
571
|
+
requests += client.request_count
|
|
572
|
+
|
|
573
|
+
yield DiscoveryFinished(
|
|
574
|
+
matched=tuple(matched),
|
|
575
|
+
unverified=tuple(unverified),
|
|
576
|
+
rejected=tuple(rejected),
|
|
577
|
+
missed=missed,
|
|
578
|
+
errors=errors,
|
|
579
|
+
requests=requests,
|
|
580
|
+
elapsed_ms=(time.perf_counter() - started) * 1000,
|
|
581
|
+
ceiling_hit=tuple(ceiling_hit),
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def apply_verification(
|
|
586
|
+
companies: Sequence[Company], outcome: DiscoveryFinished, today: date
|
|
587
|
+
) -> tuple[tuple[Company, ...], int, int]:
|
|
588
|
+
live = {result.company for result in outcome.matched}
|
|
589
|
+
dead = {result.company: result.detail or result.verdict.value for result in outcome.rejected}
|
|
590
|
+
updated: list[Company] = []
|
|
591
|
+
verified = disabled = 0
|
|
592
|
+
for company in companies:
|
|
593
|
+
if company.name in live:
|
|
594
|
+
if company.last_verified != today or not company.enabled:
|
|
595
|
+
verified += 1
|
|
596
|
+
updated.append(replace(company, enabled=True, last_verified=today, notes=None))
|
|
597
|
+
elif company.name in dead:
|
|
598
|
+
if company.enabled:
|
|
599
|
+
disabled += 1
|
|
600
|
+
updated.append(
|
|
601
|
+
replace(
|
|
602
|
+
company,
|
|
603
|
+
enabled=False,
|
|
604
|
+
last_verified=None,
|
|
605
|
+
notes=f"{today.isoformat()}: {first_line(dead[company.name])[:200]}",
|
|
606
|
+
)
|
|
607
|
+
)
|
|
608
|
+
else:
|
|
609
|
+
updated.append(company)
|
|
610
|
+
return tuple(updated), verified, disabled
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
GENERIC_SLUG_MAX = 6
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
@dataclass(frozen=True, slots=True)
|
|
617
|
+
class AdoptedRow:
|
|
618
|
+
company: Company
|
|
619
|
+
job_count: int
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
@dataclass(frozen=True, slots=True)
|
|
623
|
+
class ReviewCandidate:
|
|
624
|
+
company: str
|
|
625
|
+
platform: Platform
|
|
626
|
+
slug: str
|
|
627
|
+
job_count: int
|
|
628
|
+
distinctive: bool
|
|
629
|
+
|
|
630
|
+
@property
|
|
631
|
+
def label(self) -> str:
|
|
632
|
+
return f"{self.platform.value}/{self.slug}"
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
@dataclass(frozen=True, slots=True)
|
|
636
|
+
class AdoptionReport:
|
|
637
|
+
adopted: tuple[AdoptedRow, ...]
|
|
638
|
+
refused: tuple[tuple[str, str, str], ...]
|
|
639
|
+
already_known: int
|
|
640
|
+
probed: int
|
|
641
|
+
applied: bool
|
|
642
|
+
review: tuple[ReviewCandidate, ...] = ()
|
|
643
|
+
|
|
644
|
+
@property
|
|
645
|
+
def postings(self) -> int:
|
|
646
|
+
return sum(row.job_count for row in self.adopted)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def slug_is_distinctive(company: str, slug: str) -> bool:
|
|
650
|
+
bare = slug.replace("-", "").replace("_", "")
|
|
651
|
+
return len(bare) > GENERIC_SLUG_MAX and name_matches(company, slug)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def adoption_refusal(result: ProbeResult) -> str:
|
|
655
|
+
if result.verdict is not ProbeVerdict.MATCH:
|
|
656
|
+
return f"verdict {result.verdict.value}, only a self-naming board is adopted"
|
|
657
|
+
if (result.job_count or 0) < 1:
|
|
658
|
+
return "board answered with no postings"
|
|
659
|
+
return ""
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def needs_review(result: ProbeResult) -> bool:
|
|
663
|
+
return result.verdict is ProbeVerdict.UNVERIFIED and (result.job_count or 0) > 0
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _adopted_note(today: date, result: ProbeResult) -> str:
|
|
667
|
+
return (
|
|
668
|
+
f"{today}: seen in feed postings but absent from the registry; discover matched "
|
|
669
|
+
f"{result.candidate.platform.value}/{result.candidate.slug} with {result.job_count} "
|
|
670
|
+
"job(s) and the board named itself, so it is polled directly instead of secondhand"
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _unnamed_note(today: date, result: ProbeResult) -> str:
|
|
675
|
+
platform = result.candidate.platform.value
|
|
676
|
+
return (
|
|
677
|
+
f"{today}: token from this employer's own apply URL, not slug guessing; {platform} "
|
|
678
|
+
f"publishes no board name, so provenance is the evidence ({result.job_count} job(s))"
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def adopt_unregistered(
|
|
683
|
+
existing: Sequence[Company],
|
|
684
|
+
results: Sequence[tuple[str, ProbeResult]],
|
|
685
|
+
*,
|
|
686
|
+
today: date,
|
|
687
|
+
adopt_unnamed: bool = False,
|
|
688
|
+
) -> AdoptionReport:
|
|
689
|
+
keys = {(row.platform, row.slug.lower()) for row in existing}
|
|
690
|
+
captions = {row.name.casefold() for row in existing}
|
|
691
|
+
adopted: list[AdoptedRow] = []
|
|
692
|
+
refused: list[tuple[str, str, str]] = []
|
|
693
|
+
review: list[ReviewCandidate] = []
|
|
694
|
+
seen_review: set[tuple[Platform, str]] = set()
|
|
695
|
+
known = 0
|
|
696
|
+
|
|
697
|
+
for company, result in results:
|
|
698
|
+
reason = adoption_refusal(result)
|
|
699
|
+
if reason and not (adopt_unnamed and needs_review(result)):
|
|
700
|
+
key = (result.candidate.platform, result.candidate.slug.lower())
|
|
701
|
+
if result.verdict is ProbeVerdict.MATCH:
|
|
702
|
+
refused.append((company, result.candidate.label, reason))
|
|
703
|
+
elif needs_review(result) and key not in keys and key not in seen_review:
|
|
704
|
+
seen_review.add(key)
|
|
705
|
+
review.append(
|
|
706
|
+
ReviewCandidate(
|
|
707
|
+
company=company,
|
|
708
|
+
platform=result.candidate.platform,
|
|
709
|
+
slug=result.candidate.slug,
|
|
710
|
+
job_count=result.job_count or 0,
|
|
711
|
+
distinctive=slug_is_distinctive(company, result.candidate.slug),
|
|
712
|
+
)
|
|
713
|
+
)
|
|
714
|
+
continue
|
|
715
|
+
key = (result.candidate.platform, result.candidate.slug.lower())
|
|
716
|
+
if key in keys or company.casefold() in captions:
|
|
717
|
+
known += 1
|
|
718
|
+
continue
|
|
719
|
+
keys.add(key)
|
|
720
|
+
captions.add(company.casefold())
|
|
721
|
+
adopted.append(
|
|
722
|
+
AdoptedRow(
|
|
723
|
+
company=Company(
|
|
724
|
+
name=company,
|
|
725
|
+
platform=result.candidate.platform,
|
|
726
|
+
slug=result.candidate.slug,
|
|
727
|
+
enabled=True,
|
|
728
|
+
last_verified=today,
|
|
729
|
+
source_of_record=SourceOfRecord.DISCOVER,
|
|
730
|
+
name_gate_exempt=result.verdict is not ProbeVerdict.MATCH,
|
|
731
|
+
notes=(
|
|
732
|
+
_adopted_note(today, result)
|
|
733
|
+
if result.verdict is ProbeVerdict.MATCH
|
|
734
|
+
else _unnamed_note(today, result)
|
|
735
|
+
),
|
|
736
|
+
),
|
|
737
|
+
job_count=result.job_count or 0,
|
|
738
|
+
)
|
|
739
|
+
)
|
|
740
|
+
return AdoptionReport(
|
|
741
|
+
adopted=tuple(adopted),
|
|
742
|
+
refused=tuple(refused),
|
|
743
|
+
already_known=known,
|
|
744
|
+
probed=len({company for company, _ in results}),
|
|
745
|
+
applied=False,
|
|
746
|
+
review=tuple(review),
|
|
747
|
+
)
|