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
stage/domain/events.py ADDED
@@ -0,0 +1,204 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime
3
+
4
+ from stage.domain.enums import SyncOutcome
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class SyncStarted:
9
+ sources: tuple[str, ...]
10
+ companies: int
11
+ started_at: datetime
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class SourceStarted:
16
+ source: str
17
+ companies: int
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class CompanyStarted:
22
+ source: str
23
+ company: str
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class CompanyFinished:
28
+ source: str
29
+ company: str
30
+ fetched: int
31
+ elapsed_ms: float
32
+ degraded: str = ""
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class CompanyFailed:
37
+ source: str
38
+ company: str
39
+ error: str
40
+ elapsed_ms: float
41
+ unreachable: bool = False
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class CompanyDeferred:
46
+ source: str
47
+ company: str
48
+ reason: str
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class CompanyUnchanged:
53
+ source: str
54
+ company: str
55
+ elapsed_ms: float
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class UnroutableCompanies:
60
+ companies: tuple[str, ...]
61
+ platforms: tuple[str, ...]
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class SourceBlocked:
66
+ source: str
67
+ bucket: str
68
+ blocked_until: datetime
69
+ remaining_s: float
70
+ reason: str
71
+ consecutive_failures: int
72
+
73
+
74
+ @dataclass(frozen=True, slots=True)
75
+ class SourceFailed:
76
+ source: str
77
+ error: str
78
+
79
+
80
+ @dataclass(frozen=True, slots=True)
81
+ class SourceRotated:
82
+ source: str
83
+ bucket: str
84
+ selected: int
85
+ deferred: int
86
+ cursor: str
87
+ wrapped: bool
88
+
89
+
90
+ @dataclass(frozen=True, slots=True)
91
+ class SourceFresh:
92
+ source: str
93
+ skipped: int
94
+ remaining: int
95
+ refresh_interval_h: float
96
+
97
+
98
+ @dataclass(frozen=True, slots=True)
99
+ class SourceResting:
100
+ source: str
101
+ skipped: int
102
+ remaining: int
103
+
104
+
105
+ @dataclass(frozen=True, slots=True)
106
+ class SourceCapped:
107
+ source: str
108
+ bucket: str
109
+ spent: int
110
+ allowance: int
111
+ ceiling: int
112
+
113
+
114
+ @dataclass(frozen=True, slots=True)
115
+ class PlannedRequest:
116
+ source: str
117
+ company: str
118
+ url: str
119
+ has_validator: bool
120
+ expectation: str
121
+
122
+
123
+ @dataclass(frozen=True, slots=True)
124
+ class BucketPlan:
125
+ bucket: str
126
+ sources: tuple[str, ...]
127
+ planned: int
128
+ worst_case: int
129
+ ceiling: int
130
+
131
+ @property
132
+ def exceeds_ceiling(self) -> bool:
133
+ return self.worst_case > self.ceiling
134
+
135
+
136
+ @dataclass(frozen=True, slots=True)
137
+ class RequestLogged:
138
+ source: str
139
+ method: str
140
+ url: str
141
+ status: int | None
142
+ elapsed_ms: float
143
+ attempt: int
144
+ error: str = ""
145
+
146
+
147
+ @dataclass(frozen=True, slots=True)
148
+ class SourceFinished:
149
+ source: str
150
+ fetched: int
151
+ added: int
152
+ updated: int
153
+ closed: int
154
+ failed_companies: int
155
+ elapsed_ms: float
156
+ fetch_ms: float = 0.0
157
+ normalize_ms: float = 0.0
158
+ write_ms: float = 0.0
159
+ quarantined: int = 0
160
+ requests: int = 0
161
+ not_modified: int = 0
162
+ retries: int = 0
163
+ tightenings: int = 0
164
+ latency_p50_ms: float = 0.0
165
+ latency_p95_ms: float = 0.0
166
+
167
+
168
+ @dataclass(frozen=True, slots=True)
169
+ class SyncFinished:
170
+ outcome: SyncOutcome
171
+ added: int
172
+ updated: int
173
+ closed: int
174
+ failed_sources: tuple[str, ...]
175
+ elapsed_ms: float
176
+ quarantined: int = 0
177
+ purged: int = 0
178
+ requests: int = 0
179
+ not_modified: int = 0
180
+ dry_run: bool = False
181
+ partial_reason: str = ""
182
+
183
+
184
+ SyncEvent = (
185
+ SyncStarted
186
+ | UnroutableCompanies
187
+ | SourceBlocked
188
+ | SourceRotated
189
+ | SourceFresh
190
+ | SourceResting
191
+ | SourceCapped
192
+ | SourceStarted
193
+ | CompanyStarted
194
+ | CompanyFinished
195
+ | CompanyUnchanged
196
+ | CompanyFailed
197
+ | CompanyDeferred
198
+ | PlannedRequest
199
+ | BucketPlan
200
+ | RequestLogged
201
+ | SourceFailed
202
+ | SourceFinished
203
+ | SyncFinished
204
+ )
@@ -0,0 +1,27 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime
3
+
4
+ from stage.domain.enums import (
5
+ DegreeRequirement,
6
+ JobStatus,
7
+ Language,
8
+ LocationBucket,
9
+ RoleCategory,
10
+ )
11
+
12
+ DEFAULT_WINDOW_DAYS = 14
13
+ DEFAULT_LIMIT = 50
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class JobFilters:
18
+ location: LocationBucket | None = None
19
+ term: str | None = None
20
+ role: RoleCategory | None = None
21
+ degree: DegreeRequirement | None = None
22
+ language: Language | None = None
23
+ source: str | None = None
24
+ company: str | None = None
25
+ status: JobStatus | None = JobStatus.OPEN
26
+ first_seen_after: datetime | None = None
27
+ limit: int | None = DEFAULT_LIMIT
stage/domain/health.py ADDED
@@ -0,0 +1,169 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime, timedelta
3
+ from enum import StrEnum
4
+ from statistics import median
5
+
6
+ MIN_VOLUME_HISTORY = 3
7
+ VOLUME_DROP_RATIO = 0.5
8
+ STALE_AFTER_DAYS = 14
9
+ UNRECORDED_VOLUME = -1
10
+
11
+
12
+ class VolumeVerdict(StrEnum):
13
+ HEALTHY = "healthy"
14
+ DROPPED = "dropped"
15
+ COLLAPSED = "collapsed"
16
+ UNPROVEN = "unproven"
17
+
18
+
19
+ class VisitState(StrEnum):
20
+ HEALTHY = "healthy"
21
+ STALE = "stale"
22
+ FAILING = "failing"
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class VolumePoint:
27
+ stored: int
28
+ deferred: int = 0
29
+ blocked: bool = False
30
+
31
+ @property
32
+ def is_evidence(self) -> bool:
33
+ return self.stored > UNRECORDED_VOLUME and self.deferred == 0 and not self.blocked
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class VolumeSignal:
38
+ source: str
39
+ verdict: VolumeVerdict
40
+ latest: int
41
+ baseline: float
42
+ samples: int
43
+ detail: str = ""
44
+
45
+ @property
46
+ def is_alert(self) -> bool:
47
+ return self.verdict in (VolumeVerdict.DROPPED, VolumeVerdict.COLLAPSED)
48
+
49
+
50
+ def assess_volume(source: str, history: list[VolumePoint]) -> VolumeSignal:
51
+ evidence = [point for point in history if point.is_evidence]
52
+ if not evidence:
53
+ return VolumeSignal(
54
+ source=source,
55
+ verdict=VolumeVerdict.UNPROVEN,
56
+ latest=UNRECORDED_VOLUME,
57
+ baseline=0.0,
58
+ samples=0,
59
+ detail=_unproven_reason(history),
60
+ )
61
+
62
+ latest = evidence[0].stored
63
+ prior = [point.stored for point in evidence[1:]]
64
+ if len(prior) < MIN_VOLUME_HISTORY:
65
+ return VolumeSignal(
66
+ source=source,
67
+ verdict=VolumeVerdict.UNPROVEN,
68
+ latest=latest,
69
+ baseline=0.0,
70
+ samples=len(prior),
71
+ detail=f"{len(prior)} comparable run(s), {MIN_VOLUME_HISTORY} needed",
72
+ )
73
+
74
+ baseline = float(median(prior))
75
+ if baseline <= 0:
76
+ return VolumeSignal(
77
+ source=source,
78
+ verdict=VolumeVerdict.HEALTHY,
79
+ latest=latest,
80
+ baseline=baseline,
81
+ samples=len(prior),
82
+ detail="has never stored anything",
83
+ )
84
+
85
+ if latest == 0:
86
+ return VolumeSignal(
87
+ source=source,
88
+ verdict=VolumeVerdict.COLLAPSED,
89
+ latest=latest,
90
+ baseline=baseline,
91
+ samples=len(prior),
92
+ detail=f"stores nothing after a median of {baseline:.0f} across {len(prior)} runs",
93
+ )
94
+
95
+ if latest < baseline * VOLUME_DROP_RATIO:
96
+ return VolumeSignal(
97
+ source=source,
98
+ verdict=VolumeVerdict.DROPPED,
99
+ latest=latest,
100
+ baseline=baseline,
101
+ samples=len(prior),
102
+ detail=f"stores {latest} against a median of {baseline:.0f}",
103
+ )
104
+
105
+ return VolumeSignal(
106
+ source=source,
107
+ verdict=VolumeVerdict.HEALTHY,
108
+ latest=latest,
109
+ baseline=baseline,
110
+ samples=len(prior),
111
+ )
112
+
113
+
114
+ def _unproven_reason(history: list[VolumePoint]) -> str:
115
+ if not history:
116
+ return "no runs on record"
117
+ if any(point.blocked for point in history):
118
+ return "every run was blocked, which is a throttle not a drop"
119
+ if any(point.deferred for point in history):
120
+ return "every run deferred members, so rotation explains it"
121
+ return "no run has recorded a stored count yet"
122
+
123
+
124
+ def classify_visit(
125
+ last_success_at: datetime | None,
126
+ consecutive_failures: int,
127
+ now: datetime,
128
+ stale_after_days: int = STALE_AFTER_DAYS,
129
+ ) -> VisitState:
130
+ if last_success_at is None:
131
+ return VisitState.FAILING
132
+ if consecutive_failures > 0:
133
+ return VisitState.FAILING
134
+ if now - last_success_at > timedelta(days=stale_after_days):
135
+ return VisitState.STALE
136
+ return VisitState.HEALTHY
137
+
138
+
139
+ @dataclass(frozen=True, slots=True)
140
+ class IntegrityRepair:
141
+ check: str
142
+ repaired: int
143
+ detail: str = ""
144
+
145
+
146
+ @dataclass(frozen=True, slots=True)
147
+ class IntegrityFinding:
148
+ check: str
149
+ count: int
150
+ detail: str
151
+
152
+ @property
153
+ def is_clean(self) -> bool:
154
+ return self.count == 0
155
+
156
+
157
+ __all__ = [
158
+ "MIN_VOLUME_HISTORY",
159
+ "STALE_AFTER_DAYS",
160
+ "UNRECORDED_VOLUME",
161
+ "VOLUME_DROP_RATIO",
162
+ "IntegrityFinding",
163
+ "VisitState",
164
+ "VolumePoint",
165
+ "VolumeSignal",
166
+ "VolumeVerdict",
167
+ "assess_volume",
168
+ "classify_visit",
169
+ ]
stage/domain/ids.py ADDED
@@ -0,0 +1,48 @@
1
+ import re
2
+
3
+ _SEPARATOR = ":"
4
+ _UNSAFE = re.compile(r"[^a-z0-9._-]+")
5
+ _CANONICAL = re.compile(r"^[a-z0-9._-]+$")
6
+
7
+
8
+ def _slugify(value: str) -> str:
9
+ return _UNSAFE.sub("-", value.strip().lower()).strip("-")
10
+
11
+
12
+ def _component(value: str) -> str:
13
+ clean = value.strip()
14
+ if not clean:
15
+ return ""
16
+ normalized = _slugify(clean)
17
+ if _CANONICAL.fullmatch(clean.lower()):
18
+ return normalized
19
+ return f"{normalized or 'id'}~{clean.encode().hex()}"
20
+
21
+
22
+ def board_key(source: str, board_slug: str) -> str:
23
+ parts = (_slugify(source), _slugify(board_slug))
24
+ if not all(parts):
25
+ raise ValueError(f"cannot build a stable board key from {source!r}/{board_slug!r}")
26
+ return _SEPARATOR.join(parts)
27
+
28
+
29
+ def board_of(identifier: str, fallback: str) -> str:
30
+ parts = identifier.split(_SEPARATOR)
31
+ if len(parts) < 3:
32
+ return fallback
33
+ return _SEPARATOR.join(parts[:2])
34
+
35
+
36
+ def job_id(source: str, board_slug: str, native_id: str) -> str:
37
+ native = _component(native_id)
38
+ if not native:
39
+ raise ValueError(
40
+ f"cannot build a stable job id from {source!r}/{board_slug!r}/{native_id!r}"
41
+ )
42
+ try:
43
+ prefix = board_key(source, board_slug)
44
+ except ValueError as exc:
45
+ raise ValueError(
46
+ f"cannot build a stable job id from {source!r}/{board_slug!r}/{native_id!r}"
47
+ ) from exc
48
+ return _SEPARATOR.join((prefix, native))
stage/domain/job.py ADDED
@@ -0,0 +1,47 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime
3
+
4
+ from stage.domain.enums import (
5
+ UNKNOWN_TERM,
6
+ DegreeRequirement,
7
+ JobStatus,
8
+ Language,
9
+ LocationBucket,
10
+ RemoteScope,
11
+ RoleCategory,
12
+ )
13
+ from stage.domain.ids import board_of
14
+ from stage.domain.signals import SourceSignals
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class Job:
19
+ id: str
20
+ source: str
21
+ company: str
22
+ title_raw: str
23
+ title_normalized: str
24
+ apply_url_raw: str
25
+ description: str
26
+ first_seen: datetime
27
+ last_seen: datetime
28
+ location_raw: str = ""
29
+ title_canonical: str = ""
30
+ apply_url_canonical: str = ""
31
+ status: JobStatus = JobStatus.OPEN
32
+ language: Language = Language.UNKNOWN
33
+ location: LocationBucket = LocationBucket.UNKNOWN
34
+ remote_scope: RemoteScope | None = None
35
+ term: str = UNKNOWN_TERM
36
+ role: RoleCategory = RoleCategory.UNKNOWN
37
+ work_auth_flag: bool = False
38
+ degree_requirement: DegreeRequirement = DegreeRequirement.UNKNOWN
39
+ compensation: str | None = None
40
+ source_posted_at: datetime | None = None
41
+ duplicate_of: str | None = None
42
+
43
+ @property
44
+ def board_key(self) -> str:
45
+ return board_of(self.id, self.source)
46
+
47
+ signals: SourceSignals = SourceSignals()
@@ -0,0 +1,15 @@
1
+ from stage.domain.enums import UNKNOWN_TERM, LocationBucket
2
+
3
+ _NON_EVIDENCE_BUCKETS = frozenset({LocationBucket.UNKNOWN, LocationBucket.INTERNATIONAL})
4
+
5
+
6
+ def location_agrees(left: LocationBucket, right: LocationBucket) -> bool:
7
+ if left in _NON_EVIDENCE_BUCKETS or right in _NON_EVIDENCE_BUCKETS:
8
+ return False
9
+ return left is right
10
+
11
+
12
+ def term_agrees(left: str, right: str) -> bool:
13
+ if left == UNKNOWN_TERM or right == UNKNOWN_TERM:
14
+ return False
15
+ return left == right
@@ -0,0 +1,34 @@
1
+ SOURCE_PRIORITY: tuple[str, ...] = (
2
+ "greenhouse",
3
+ "lever",
4
+ "smartrecruiters",
5
+ "ashby",
6
+ "workday",
7
+ "workable",
8
+ "bamboohr",
9
+ "recruitee",
10
+ "breezy",
11
+ "collage",
12
+ "jobvite",
13
+ "oracle_cloud",
14
+ "custom_json",
15
+ "quebec-emploi",
16
+ "jobbank",
17
+ "espresso-jobs",
18
+ "themuse",
19
+ "simplify",
20
+ "vanshb03",
21
+ "speedyapply",
22
+ "zshah101",
23
+ "hanzili",
24
+ "negar",
25
+ "northwestern-quant",
26
+ )
27
+
28
+
29
+ def source_rank(source: str, job_id: str = "") -> tuple[int, str]:
30
+ try:
31
+ index = SOURCE_PRIORITY.index(source)
32
+ except ValueError:
33
+ index = len(SOURCE_PRIORITY)
34
+ return index, job_id
@@ -0,0 +1,39 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime
3
+ from enum import StrEnum
4
+
5
+ from stage.domain.enums import LocationBucket, RemoteScope
6
+
7
+ DEFAULT_QUARANTINE_LIMIT = 50
8
+
9
+
10
+ class RejectionReason(StrEnum):
11
+ OUT_OF_SCOPE_LOCATION = "out-of-scope-location"
12
+ NOT_AN_INTERNSHIP = "not-an-internship"
13
+ OUT_OF_SCOPE_DEGREE = "out-of-scope-degree"
14
+ NOT_A_CS_ROLE = "not-a-cs-role"
15
+ UNKNOWN_CS_ROLE = "unknown-cs-role"
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class QuarantinedJob:
20
+ id: str
21
+ source: str
22
+ company: str
23
+ title_raw: str
24
+ reason: RejectionReason
25
+ first_seen: datetime
26
+ last_seen: datetime
27
+ apply_url_raw: str = ""
28
+ location_raw: str = ""
29
+ location: LocationBucket = LocationBucket.UNKNOWN
30
+ remote_scope: RemoteScope | None = None
31
+ matched_phrase: str = ""
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class QuarantineFilters:
36
+ reason: RejectionReason | None = None
37
+ source: str | None = None
38
+ company: str | None = None
39
+ limit: int | None = DEFAULT_QUARANTINE_LIMIT
@@ -0,0 +1,78 @@
1
+ from dataclasses import dataclass, replace
2
+ from datetime import datetime, timedelta
3
+
4
+ BREAKER_THRESHOLD = 5
5
+ BASE_BLOCK_S = 300.0
6
+ BLOCK_CAP_S = 24 * 3600.0
7
+ DECAY_FACTOR = 0.7
8
+ CLEAR_RATIO = 1.05
9
+
10
+
11
+ def block_duration(consecutive_failures: int) -> float:
12
+ steps = max(0, consecutive_failures - BREAKER_THRESHOLD)
13
+ return min(BLOCK_CAP_S, BASE_BLOCK_S * float(2 ** min(steps, 16)))
14
+
15
+
16
+ def decay(override: float, baseline: float) -> float | None:
17
+ if override <= baseline * CLEAR_RATIO:
18
+ return None
19
+ relaxed = max(baseline, override * DECAY_FACTOR)
20
+ return None if relaxed <= baseline * CLEAR_RATIO else relaxed
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class RateState:
25
+ bucket: str
26
+ updated_at: datetime
27
+ blocked_until: datetime | None = None
28
+ min_interval_override: float | None = None
29
+ consecutive_failures: int = 0
30
+ last_failure_at: datetime | None = None
31
+ reason: str = ""
32
+ rotation_cursor: str = ""
33
+
34
+ def __post_init__(self) -> None:
35
+ if self.blocked_until is not None:
36
+ ceiling = self.updated_at + timedelta(seconds=BLOCK_CAP_S)
37
+ if self.blocked_until > ceiling:
38
+ object.__setattr__(self, "blocked_until", ceiling)
39
+
40
+ def is_blocked(self, now: datetime) -> bool:
41
+ return self.blocked_until is not None and now < self.blocked_until
42
+
43
+ def blocks_remaining_s(self, now: datetime) -> float:
44
+ if self.blocked_until is None:
45
+ return 0.0
46
+ return max(0.0, (self.blocked_until - now).total_seconds())
47
+
48
+ def cleared(self, now: datetime) -> "RateState":
49
+ return RateState(bucket=self.bucket, updated_at=now, rotation_cursor=self.rotation_cursor)
50
+
51
+ def with_cursor(self, cursor: str, now: datetime) -> "RateState":
52
+ return replace(self, rotation_cursor=cursor, updated_at=now)
53
+
54
+
55
+ def blocked(
56
+ state: RateState, *, now: datetime, consecutive_failures: int, reason: str
57
+ ) -> RateState:
58
+ return replace(
59
+ state,
60
+ updated_at=now,
61
+ blocked_until=now + timedelta(seconds=block_duration(consecutive_failures)),
62
+ consecutive_failures=consecutive_failures,
63
+ last_failure_at=now,
64
+ reason=reason,
65
+ )
66
+
67
+
68
+ __all__ = [
69
+ "BASE_BLOCK_S",
70
+ "BLOCK_CAP_S",
71
+ "BREAKER_THRESHOLD",
72
+ "CLEAR_RATIO",
73
+ "DECAY_FACTOR",
74
+ "RateState",
75
+ "block_duration",
76
+ "blocked",
77
+ "decay",
78
+ ]
@@ -0,0 +1,20 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime
3
+
4
+ OPEN_RETENTION_DAYS = 14
5
+ CLOSED_RETENTION_DAYS = 3
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class PurgeResult:
10
+ purged: int = 0
11
+ tombstoned: int = 0
12
+ promoted: int = 0
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class Tombstone:
17
+ id: str
18
+ source: str
19
+ first_seen: datetime
20
+ purged_at: datetime