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,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
+ )