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/http/profiles.py ADDED
@@ -0,0 +1,101 @@
1
+ from collections.abc import Iterable
2
+ from dataclasses import dataclass, replace
3
+ from types import MappingProxyType
4
+
5
+ CEILING_BACKSTOP = 2000
6
+ NORMAL_REFRESH_H = 4.0
7
+ CONSERVATIVE_REFRESH_H = 10.0
8
+
9
+
10
+ class UnknownProfileError(Exception):
11
+ pass
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class RatePosture:
16
+ concurrency: int = 3
17
+ min_interval_s: float = 0.25
18
+ max_requests_per_run: int = 300
19
+ refresh_interval_h: float = NORMAL_REFRESH_H
20
+ requests_per_board: int = 2
21
+
22
+ def strictest(self, other: "RatePosture") -> "RatePosture":
23
+ return RatePosture(
24
+ concurrency=min(self.concurrency, other.concurrency),
25
+ min_interval_s=max(self.min_interval_s, other.min_interval_s),
26
+ max_requests_per_run=min(self.max_requests_per_run, other.max_requests_per_run),
27
+ refresh_interval_h=max(self.refresh_interval_h, other.refresh_interval_h),
28
+ requests_per_board=min(self.requests_per_board, other.requests_per_board),
29
+ )
30
+
31
+ def sized_for(self, boards: int, reserve: int = 0) -> "RatePosture":
32
+ if boards < 1:
33
+ return self
34
+ derived = boards * self.requests_per_board + reserve
35
+ return replace(
36
+ self,
37
+ max_requests_per_run=min(CEILING_BACKSTOP, max(self.max_requests_per_run, derived)),
38
+ )
39
+
40
+
41
+ STANDARD = RatePosture(concurrency=3, min_interval_s=0.25, max_requests_per_run=300)
42
+ BROAD = RatePosture(concurrency=3, min_interval_s=0.25, max_requests_per_run=450)
43
+ MODERATE = RatePosture(concurrency=2, min_interval_s=0.4, max_requests_per_run=150)
44
+ PAGINATED = RatePosture(
45
+ concurrency=2, min_interval_s=0.4, max_requests_per_run=250, requests_per_board=3
46
+ )
47
+ CONSERVATIVE = RatePosture(
48
+ concurrency=1,
49
+ min_interval_s=1.0,
50
+ max_requests_per_run=80,
51
+ refresh_interval_h=CONSERVATIVE_REFRESH_H,
52
+ requests_per_board=1,
53
+ )
54
+ WORKDAY = RatePosture(
55
+ concurrency=2, min_interval_s=1.5, max_requests_per_run=500, requests_per_board=2
56
+ )
57
+ FEEDS = RatePosture(
58
+ concurrency=2, min_interval_s=0.5, max_requests_per_run=20, refresh_interval_h=0.0
59
+ )
60
+
61
+ JOBBANK = RatePosture(
62
+ concurrency=1,
63
+ min_interval_s=5.0,
64
+ max_requests_per_run=120,
65
+ refresh_interval_h=0.0,
66
+ requests_per_board=1,
67
+ )
68
+
69
+ DISCOVERY = RatePosture(
70
+ concurrency=1, min_interval_s=1.0, max_requests_per_run=60, refresh_interval_h=0.0
71
+ )
72
+
73
+ PROFILES: MappingProxyType[str, RatePosture] = MappingProxyType(
74
+ {
75
+ "standard": STANDARD,
76
+ "broad": BROAD,
77
+ "moderate": MODERATE,
78
+ "paginated": PAGINATED,
79
+ "conservative": CONSERVATIVE,
80
+ "workday": WORKDAY,
81
+ "feeds": FEEDS,
82
+ "jobbank": JOBBANK,
83
+ "discovery": DISCOVERY,
84
+ }
85
+ )
86
+
87
+
88
+ def profile(name: str) -> RatePosture:
89
+ try:
90
+ return PROFILES[name]
91
+ except KeyError as exc:
92
+ known = ", ".join(sorted(PROFILES))
93
+ raise UnknownProfileError(f"unknown rate profile {name!r} (known: {known})") from exc
94
+
95
+
96
+ def resolve(default: str, overrides: Iterable[str | None]) -> RatePosture:
97
+ posture = profile(default)
98
+ for name in overrides:
99
+ if name is not None:
100
+ posture = posture.strictest(profile(name))
101
+ return posture
stage/lexicon.py ADDED
@@ -0,0 +1,370 @@
1
+ import re
2
+ import unicodedata
3
+ from dataclasses import dataclass
4
+ from functools import lru_cache
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+ from stage.paths import lexicon_dir
10
+
11
+ COMPANY_TOKENS_FILE = "company_tokens.yaml"
12
+ LOCATIONS_FILE = "locations.yaml"
13
+ INCLUSIVE_SUFFIXES_FILE = "inclusive_suffixes.yaml"
14
+ TERMS_FILE = "terms.yaml"
15
+ INTERNSHIP_FILE = "internship.yaml"
16
+ WORKDAY_FACETS_FILE = "workday_facets.yaml"
17
+ ROLES_FILE = "roles.yaml"
18
+ ELIGIBILITY_FILE = "eligibility.yaml"
19
+ LANGUAGE_FILE = "language.yaml"
20
+
21
+
22
+ def _bare(raw: str) -> str:
23
+ decomposed = unicodedata.normalize("NFKD", raw)
24
+ stripped = "".join(char for char in decomposed if not unicodedata.combining(char))
25
+ return re.sub(r"[^a-z]+", "", stripped.casefold())
26
+
27
+
28
+ @lru_cache(maxsize=1)
29
+ def feminine_suffixes() -> frozenset[str]:
30
+ payload, source = _load(INCLUSIVE_SUFFIXES_FILE)
31
+ raw = payload.get("feminine_suffixes")
32
+ if not isinstance(raw, list) or not raw:
33
+ raise LexiconError(f"{source}: 'feminine_suffixes' must be a non-empty list")
34
+ suffixes: set[str] = set()
35
+ for entry in raw:
36
+ if not isinstance(entry, str):
37
+ raise LexiconError(f"{source}: every suffix must be a string")
38
+ bare = _bare(entry)
39
+ if not bare:
40
+ raise LexiconError(f"{source}: {entry!r} normalizes to nothing")
41
+ if bare == "s":
42
+ raise LexiconError(
43
+ f"{source}: 's' cannot be a feminine suffix — it would strip the plural "
44
+ "from 'Engineer(s)'"
45
+ )
46
+ suffixes.add(bare)
47
+ return frozenset(suffixes)
48
+
49
+
50
+ LONGEST_WORD_STEM = 30
51
+
52
+ _INCLUSIVE_MIDDOT = re.compile(rf"([a-z]{{3,{LONGEST_WORD_STEM}}})\s*·\s*([a-z]{{1,6}})")
53
+ _INCLUSIVE_TIGHT = re.compile(rf"([a-z]{{3,{LONGEST_WORD_STEM}}})[.(]([a-z]{{1,6}})\)?")
54
+
55
+
56
+ def _collapse_inclusive(text: str) -> str:
57
+
58
+ suffixes = feminine_suffixes()
59
+
60
+ def replace(match: re.Match[str]) -> str:
61
+ if match.group(2) in suffixes:
62
+ return match.group(1)
63
+ return match.group(0)
64
+
65
+ return _INCLUSIVE_TIGHT.sub(replace, _INCLUSIVE_MIDDOT.sub(replace, text))
66
+
67
+
68
+ _COMBINING = dict.fromkeys(range(0x0300, 0x0370))
69
+
70
+
71
+ @lru_cache(maxsize=8)
72
+ def fold(raw: str) -> str:
73
+ decomposed = unicodedata.normalize("NFKD", raw.replace("&", " and "))
74
+ collapsed = _collapse_inclusive(decomposed.translate(_COMBINING).casefold())
75
+ return " ".join(re.sub(r"[^0-9a-z]+", " ", collapsed).split())
76
+
77
+
78
+ class LexiconError(Exception):
79
+ pass
80
+
81
+
82
+ def _not_a_string(source: str, key: str, entry: object) -> str:
83
+ if isinstance(entry, bool):
84
+ word = "on/off" if entry else "no/off"
85
+ return (
86
+ f"{source}: {key!r} contains the boolean {entry!r}, which is YAML 1.1 reading a "
87
+ f"bareword like {word} as a truth value — quote it in the YAML"
88
+ )
89
+ return f"{source}: every {key!r} entry must be a string, found {entry!r}"
90
+
91
+
92
+ def _folded_set(payload: dict[str, Any], key: str, source: str) -> frozenset[str]:
93
+ raw = payload.get(key)
94
+ if not isinstance(raw, list) or not raw:
95
+ raise LexiconError(f"{source}: {key!r} must be a non-empty list")
96
+ tokens: set[str] = set()
97
+ for entry in raw:
98
+ if not isinstance(entry, str):
99
+ raise LexiconError(_not_a_string(source, key, entry))
100
+ folded = fold(entry)
101
+ if not folded:
102
+ raise LexiconError(f"{source}: {entry!r} folds to nothing")
103
+ tokens.update(folded.split())
104
+ return frozenset(tokens)
105
+
106
+
107
+ def _folded_aliases(payload: dict[str, Any], key: str, source: str) -> dict[str, str]:
108
+ raw = payload.get(key)
109
+ if not isinstance(raw, dict) or not raw:
110
+ raise LexiconError(f"{source}: {key!r} must be a non-empty mapping")
111
+ aliases: dict[str, str] = {}
112
+ for canonical, spellings in raw.items():
113
+ if not isinstance(canonical, str) or not canonical.strip():
114
+ raise LexiconError(_not_a_string(source, key, canonical))
115
+ if not isinstance(spellings, list) or not spellings:
116
+ raise LexiconError(f"{source}: {canonical!r} must list at least one spelling")
117
+ for entry in spellings:
118
+ if not isinstance(entry, str):
119
+ raise LexiconError(_not_a_string(source, key, entry))
120
+ folded = fold(entry)
121
+ if not folded:
122
+ raise LexiconError(f"{source}: {entry!r} folds to nothing")
123
+ aliases[folded] = canonical
124
+ return aliases
125
+
126
+
127
+ def _folded_phrases(payload: dict[str, Any], key: str, source: str) -> frozenset[str]:
128
+ raw = payload.get(key)
129
+ if not isinstance(raw, list) or not raw:
130
+ raise LexiconError(f"{source}: {key!r} must be a non-empty list")
131
+ phrases: set[str] = set()
132
+ for entry in raw:
133
+ if not isinstance(entry, str):
134
+ raise LexiconError(_not_a_string(source, key, entry))
135
+ folded = fold(entry)
136
+ if not folded:
137
+ raise LexiconError(f"{source}: {entry!r} folds to nothing")
138
+ phrases.add(folded)
139
+ return frozenset(phrases)
140
+
141
+
142
+ @dataclass(frozen=True, slots=True)
143
+ class LocationLexicon:
144
+ montreal: frozenset[str]
145
+ montreal_ambiguous: frozenset[str]
146
+ canada_cities: frozenset[str]
147
+ canada_ambiguous: frozenset[str]
148
+ canada_regions: frozenset[str]
149
+ canada_codes: frozenset[str]
150
+ canada_country: frozenset[str]
151
+ canada_overrides: frozenset[str]
152
+ usa_ambiguous: frozenset[str]
153
+ usa_cities: frozenset[str]
154
+ usa_regions: frozenset[str]
155
+ usa_codes: frozenset[str]
156
+ usa_country: frozenset[str]
157
+ international: frozenset[str]
158
+ international_cities: frozenset[str]
159
+ city_aliases: dict[str, str]
160
+ remote: frozenset[str]
161
+ hybrid: frozenset[str]
162
+
163
+
164
+ def _load(filename: str) -> tuple[dict[str, Any], str]:
165
+ path = lexicon_dir() / filename
166
+ if not path.exists():
167
+ raise LexiconError(f"lexicon not found at {path}")
168
+ payload = yaml.safe_load(path.read_text(encoding="utf-8"))
169
+ if not isinstance(payload, dict):
170
+ raise LexiconError(f"{path}: expected a mapping")
171
+ return payload, str(path)
172
+
173
+
174
+ SEASONS: tuple[str, ...] = ("summer", "fall", "winter", "spring")
175
+ ROLE_CATEGORIES: tuple[str, ...] = (
176
+ "swe",
177
+ "security",
178
+ "data",
179
+ "ml-ai",
180
+ "quant",
181
+ "infra",
182
+ "embedded",
183
+ "general-cs",
184
+ )
185
+
186
+
187
+ @dataclass(frozen=True, slots=True)
188
+ class LanguageLexicon:
189
+ french: frozenset[str]
190
+ english: frozenset[str]
191
+ loanwords: frozenset[str]
192
+
193
+
194
+ @lru_cache(maxsize=1)
195
+ def language_lexicon() -> LanguageLexicon:
196
+ payload, source = _load(LANGUAGE_FILE)
197
+ loanwords = _folded_set(payload, "loanwords", source)
198
+ french = _folded_set(payload, "french", source)
199
+ english = _folded_set(payload, "english", source)
200
+ overlap = (french | english) & loanwords
201
+ if overlap:
202
+ raise LexiconError(
203
+ f"{source}: {sorted(overlap)} are listed as loanwords and also as language "
204
+ "evidence — a loanword counts for neither side"
205
+ )
206
+ return LanguageLexicon(french=french, english=english, loanwords=loanwords)
207
+
208
+
209
+ @dataclass(frozen=True, slots=True)
210
+ class InternshipLexicon:
211
+ markers: frozenset[str]
212
+ blocked_bigrams: frozenset[str]
213
+ disqualifiers: frozenset[str]
214
+ structured_internship: frozenset[str]
215
+ structured_excluded: frozenset[str]
216
+ structured_only_blocked: frozenset[str]
217
+
218
+
219
+ @lru_cache(maxsize=1)
220
+ def internship_lexicon() -> InternshipLexicon:
221
+ payload, source = _load(INTERNSHIP_FILE)
222
+ return InternshipLexicon(
223
+ markers=_folded_phrases(payload, "markers", source),
224
+ blocked_bigrams=_folded_phrases(payload, "blocked_bigrams", source),
225
+ disqualifiers=_folded_phrases(payload, "disqualifiers", source),
226
+ structured_internship=_folded_phrases(payload, "structured_internship", source),
227
+ structured_excluded=_folded_phrases(payload, "structured_excluded", source),
228
+ structured_only_blocked=_folded_phrases(payload, "structured_only_blocked", source),
229
+ )
230
+
231
+
232
+ @dataclass(frozen=True, slots=True)
233
+ class EligibilityLexicon:
234
+ degree_required: dict[str, frozenset[str]]
235
+ work_auth_excluded: frozenset[str]
236
+ non_cs: frozenset[str]
237
+ excluded_titles: frozenset[str]
238
+ technical_title_exceptions: frozenset[str]
239
+ phd_required: frozenset[str]
240
+ phd_title_tokens: frozenset[str]
241
+ degree_list_tokens: frozenset[str]
242
+ undergraduate_tokens: frozenset[str]
243
+ graduate_title_tokens: frozenset[str]
244
+
245
+
246
+ @lru_cache(maxsize=1)
247
+ def eligibility_lexicon() -> EligibilityLexicon:
248
+ payload, source = _load(ELIGIBILITY_FILE)
249
+ raw = payload.get("degree_required")
250
+ if not isinstance(raw, dict):
251
+ raise LexiconError(f"{source}: 'degree_required' must be a mapping")
252
+ degrees: dict[str, frozenset[str]] = {}
253
+ for level, phrases in raw.items():
254
+ if not isinstance(phrases, list) or not all(isinstance(p, str) for p in phrases):
255
+ raise LexiconError(f"{source}: degree_required.{level} must be a list of strings")
256
+ degrees[str(level)] = frozenset(fold(phrase) for phrase in phrases)
257
+ return EligibilityLexicon(
258
+ degree_required=degrees,
259
+ work_auth_excluded=_folded_phrases(payload, "work_auth_excluded", source),
260
+ non_cs=_folded_phrases(payload, "non_cs", source),
261
+ excluded_titles=_folded_phrases(payload, "excluded_titles", source),
262
+ technical_title_exceptions=_folded_phrases(payload, "technical_title_exceptions", source),
263
+ phd_required=_folded_phrases(payload, "phd_required", source),
264
+ phd_title_tokens=_folded_phrases(payload, "phd_title_tokens", source),
265
+ degree_list_tokens=_folded_phrases(payload, "degree_list_tokens", source),
266
+ undergraduate_tokens=_folded_phrases(payload, "undergraduate_tokens", source),
267
+ graduate_title_tokens=_folded_phrases(payload, "graduate_title_tokens", source),
268
+ )
269
+
270
+
271
+ @lru_cache(maxsize=1)
272
+ def workday_facet_lexicon() -> tuple[tuple[str, ...], frozenset[str]]:
273
+ payload, source = _load(WORKDAY_FACETS_FILE)
274
+ raw = payload.get("facet_parameters")
275
+ if not isinstance(raw, list) or not all(isinstance(item, str) for item in raw):
276
+ raise LexiconError(f"{source}: 'facet_parameters' must be a list of strings")
277
+ return tuple(raw), frozenset(_folded_phrases(payload, "intern_descriptors", source))
278
+
279
+
280
+ @lru_cache(maxsize=1)
281
+ def source_role_categories() -> dict[str, str]:
282
+ payload, source = _load(ROLES_FILE)
283
+ raw = payload.get("source_categories")
284
+ if not isinstance(raw, dict):
285
+ raise LexiconError(f"{source}: 'source_categories' must be a mapping")
286
+ mapping: dict[str, str] = {}
287
+ for category, labels in raw.items():
288
+ if category not in ROLE_CATEGORIES:
289
+ raise LexiconError(f"{source}: {category!r} is not a role category")
290
+ for label in labels:
291
+ mapping[fold(str(label))] = category
292
+ return mapping
293
+
294
+
295
+ @lru_cache(maxsize=1)
296
+ def role_lexicon() -> dict[str, frozenset[str]]:
297
+ payload, source = _load(ROLES_FILE)
298
+ return {name: _folded_phrases(payload, name, source) for name in ROLE_CATEGORIES}
299
+
300
+
301
+ @dataclass(frozen=True, slots=True)
302
+ class TermLexicon:
303
+ seasons: dict[str, frozenset[str]]
304
+ fillers: frozenset[str]
305
+ blocked_bigrams: frozenset[str]
306
+
307
+
308
+ @lru_cache(maxsize=1)
309
+ def term_lexicon() -> TermLexicon:
310
+ payload, source = _load(TERMS_FILE)
311
+ return TermLexicon(
312
+ seasons={season: _folded_phrases(payload, season, source) for season in SEASONS},
313
+ fillers=_folded_phrases(payload, "fillers", source),
314
+ blocked_bigrams=_folded_phrases(payload, "blocked_bigrams", source),
315
+ )
316
+
317
+
318
+ @lru_cache(maxsize=1)
319
+ def location_lexicon() -> LocationLexicon:
320
+ payload, source = _load(LOCATIONS_FILE)
321
+ return LocationLexicon(
322
+ montreal=_folded_phrases(payload, "montreal", source),
323
+ montreal_ambiguous=_folded_phrases(payload, "montreal_ambiguous", source),
324
+ canada_cities=_folded_phrases(payload, "canada_cities", source),
325
+ canada_ambiguous=_folded_phrases(payload, "canada_ambiguous", source),
326
+ usa_ambiguous=_folded_phrases(payload, "usa_ambiguous", source),
327
+ canada_regions=_folded_phrases(payload, "canada_regions", source),
328
+ canada_codes=_folded_phrases(payload, "canada_codes", source),
329
+ canada_country=_folded_phrases(payload, "canada_country", source),
330
+ canada_overrides=_folded_phrases(payload, "canada_overrides", source),
331
+ usa_cities=_folded_phrases(payload, "usa_cities", source),
332
+ usa_regions=_folded_phrases(payload, "usa_regions", source),
333
+ usa_codes=_folded_phrases(payload, "usa_codes", source),
334
+ usa_country=_folded_phrases(payload, "usa_country", source),
335
+ international=_folded_phrases(payload, "international", source),
336
+ international_cities=_folded_phrases(payload, "international_cities", source),
337
+ city_aliases=_folded_aliases(payload, "city_aliases", source),
338
+ remote=_folded_phrases(payload, "remote", source),
339
+ hybrid=_folded_phrases(payload, "hybrid", source),
340
+ )
341
+
342
+
343
+ @lru_cache(maxsize=1)
344
+ def _company_tokens() -> tuple[frozenset[str], frozenset[str], frozenset[str]]:
345
+ payload, source = _load(COMPANY_TOKENS_FILE)
346
+ return (
347
+ _folded_set(payload, "generic", source),
348
+ _folded_set(payload, "legal_suffixes", source),
349
+ _folded_set(payload, "division_qualifiers", source),
350
+ )
351
+
352
+
353
+ def generic_company_tokens() -> frozenset[str]:
354
+ return _company_tokens()[0]
355
+
356
+
357
+ def company_legal_suffixes() -> frozenset[str]:
358
+ return _company_tokens()[1]
359
+
360
+
361
+ def name_root_tokens(name: str) -> tuple[str, ...]:
362
+ suffixes = company_legal_suffixes()
363
+ tokens = [token for token in fold(name).split() if token]
364
+ while len(tokens) > 1 and tokens[-1] in suffixes:
365
+ tokens = tokens[:-1]
366
+ return tuple(tokens)
367
+
368
+
369
+ def division_qualifiers() -> frozenset[str]:
370
+ return _company_tokens()[2]
@@ -0,0 +1,16 @@
1
+ from stage.normalize.language import DetectedLanguage, detect_language
2
+ from stage.normalize.location import ResolvedLocation, resolve_location
3
+ from stage.normalize.terms import ResolvedTerm, resolve_term
4
+ from stage.normalize.urls import TRACKER_DOMAINS, canonical_apply_url, is_tracker_url
5
+
6
+ __all__ = [
7
+ "TRACKER_DOMAINS",
8
+ "DetectedLanguage",
9
+ "ResolvedLocation",
10
+ "ResolvedTerm",
11
+ "canonical_apply_url",
12
+ "detect_language",
13
+ "is_tracker_url",
14
+ "resolve_location",
15
+ "resolve_term",
16
+ ]
@@ -0,0 +1,47 @@
1
+ import unicodedata
2
+ from dataclasses import dataclass
3
+
4
+ from stage.domain import Language
5
+ from stage.lexicon import fold, language_lexicon
6
+
7
+ _MIN_EVIDENCE = 2
8
+
9
+ _DOMINANCE = 2
10
+
11
+ _ACCENTED = frozenset("àâäçéèêëîïôöùûüÿœæ")
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class DetectedLanguage:
16
+ language: Language = Language.UNKNOWN
17
+ french_hits: tuple[str, ...] = ()
18
+ english_hits: tuple[str, ...] = ()
19
+
20
+
21
+ def _accent_count(raw: str) -> int:
22
+ lowered = unicodedata.normalize("NFC", raw).casefold()
23
+ return sum(1 for char in lowered if char in _ACCENTED)
24
+
25
+
26
+ def detect_language(title: str, description: str = "") -> DetectedLanguage:
27
+ lexicon = language_lexicon()
28
+ tokens = fold(title).split()
29
+ if len(tokens) < 3 and description:
30
+ tokens = tokens + fold(description).split()[:40]
31
+
32
+ french = tuple(sorted({token for token in tokens if token in lexicon.french}))
33
+ english = tuple(sorted({token for token in tokens if token in lexicon.english}))
34
+ fr_score = len(french) + (1 if _accent_count(title) and french else 0)
35
+ en_score = len(english)
36
+
37
+ if fr_score >= _MIN_EVIDENCE and en_score >= _MIN_EVIDENCE:
38
+ if fr_score >= en_score * _DOMINANCE:
39
+ return DetectedLanguage(Language.FR, french, english)
40
+ if en_score >= fr_score * _DOMINANCE:
41
+ return DetectedLanguage(Language.EN, french, english)
42
+ return DetectedLanguage(Language.BILINGUAL, french, english)
43
+ if fr_score >= _MIN_EVIDENCE and en_score < _MIN_EVIDENCE:
44
+ return DetectedLanguage(Language.FR, french, english)
45
+ if en_score >= _MIN_EVIDENCE and fr_score < _MIN_EVIDENCE:
46
+ return DetectedLanguage(Language.EN, french, english)
47
+ return DetectedLanguage(Language.UNKNOWN, french, english)