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/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)
|