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
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
|
+
)
|
stage/domain/filters.py
ADDED
|
@@ -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()
|
stage/domain/matching.py
ADDED
|
@@ -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
|
stage/domain/priority.py
ADDED
|
@@ -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
|