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
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import pkgutil
|
|
3
|
+
from types import MappingProxyType
|
|
4
|
+
|
|
5
|
+
from stage.domain import Platform
|
|
6
|
+
from stage.sources.base import (
|
|
7
|
+
Adapter,
|
|
8
|
+
AdapterError,
|
|
9
|
+
FetchResult,
|
|
10
|
+
PayloadValidationError,
|
|
11
|
+
capture_payload,
|
|
12
|
+
convert_rows,
|
|
13
|
+
)
|
|
14
|
+
from stage.sources.feed import (
|
|
15
|
+
FeedAdapter,
|
|
16
|
+
get_feeds,
|
|
17
|
+
register_feed,
|
|
18
|
+
upcoming_season_year,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_ADAPTERS: dict[str, Adapter] = {}
|
|
22
|
+
_LOADED = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def register[A: Adapter](cls: type[A]) -> type[A]:
|
|
26
|
+
adapter = cls()
|
|
27
|
+
existing = _ADAPTERS.get(adapter.name)
|
|
28
|
+
if existing is not None and type(existing) is not cls:
|
|
29
|
+
raise AdapterError(f"two adapters claim the name {adapter.name!r}")
|
|
30
|
+
_ADAPTERS[adapter.name] = adapter
|
|
31
|
+
return cls
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_builtins() -> None:
|
|
35
|
+
global _LOADED
|
|
36
|
+
if _LOADED:
|
|
37
|
+
return
|
|
38
|
+
for module in pkgutil.iter_modules(__path__):
|
|
39
|
+
if module.name.startswith("_") or module.name in ("base", "feed", "platforms"):
|
|
40
|
+
continue
|
|
41
|
+
importlib.import_module(f"{__name__}.{module.name}")
|
|
42
|
+
_LOADED = True
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_adapters() -> MappingProxyType[str, Adapter]:
|
|
46
|
+
load_builtins()
|
|
47
|
+
return MappingProxyType(_ADAPTERS)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_adapter(name: str) -> Adapter:
|
|
51
|
+
adapters = get_adapters()
|
|
52
|
+
try:
|
|
53
|
+
return adapters[name]
|
|
54
|
+
except KeyError as exc:
|
|
55
|
+
known = ", ".join(sorted(adapters)) or "none"
|
|
56
|
+
raise AdapterError(f"no adapter named {name!r} (known: {known})") from exc
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def adapter_for_platform(platform: Platform) -> Adapter | None:
|
|
60
|
+
for adapter in get_adapters().values():
|
|
61
|
+
if adapter.platform is platform:
|
|
62
|
+
return adapter
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
__all__ = [
|
|
67
|
+
"Adapter",
|
|
68
|
+
"AdapterError",
|
|
69
|
+
"FeedAdapter",
|
|
70
|
+
"FetchResult",
|
|
71
|
+
"PayloadValidationError",
|
|
72
|
+
"adapter_for_platform",
|
|
73
|
+
"capture_payload",
|
|
74
|
+
"convert_rows",
|
|
75
|
+
"get_feeds",
|
|
76
|
+
"get_adapter",
|
|
77
|
+
"get_adapters",
|
|
78
|
+
"load_builtins",
|
|
79
|
+
"register",
|
|
80
|
+
"register_feed",
|
|
81
|
+
"upcoming_season_year",
|
|
82
|
+
]
|
stage/sources/_text.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import html
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
LONGEST_TAG = 4096
|
|
5
|
+
|
|
6
|
+
_RAW_OPEN = re.compile(rf"<(script|style)\b[^>]{{0,{LONGEST_TAG}}}>", re.IGNORECASE)
|
|
7
|
+
_RAW_CLOSE = {
|
|
8
|
+
"script": re.compile(r"</script\s*>", re.IGNORECASE),
|
|
9
|
+
"style": re.compile(r"</style\s*>", re.IGNORECASE),
|
|
10
|
+
}
|
|
11
|
+
_BLOCK_BREAKS = re.compile(r"</(p|div|li|tr|h[1-6])>|<br\s*/?>", re.IGNORECASE)
|
|
12
|
+
_CONTROL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
|
|
13
|
+
_PICTOGRAPH = "\U0001f000-\U0001faff\u2600-\u27bf\u2b00-\u2bff"
|
|
14
|
+
_JOINER = "\ufe0f\u200d"
|
|
15
|
+
_PICTOGRAPHS = re.compile(
|
|
16
|
+
f"[{_PICTOGRAPH}{_JOINER}]{{0,64}}[{_PICTOGRAPH}][{_PICTOGRAPH}{_JOINER}]{{0,64}}"
|
|
17
|
+
)
|
|
18
|
+
_BLANK_LINES = re.compile(r"\n{3,}")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _drop_raw_text_elements(text: str) -> str:
|
|
22
|
+
kept: list[str] = []
|
|
23
|
+
index = 0
|
|
24
|
+
unclosed: set[str] = set()
|
|
25
|
+
while (opened := _RAW_OPEN.search(text, index)) is not None:
|
|
26
|
+
name = opened.group(1).lower()
|
|
27
|
+
closed = None if name in unclosed else _RAW_CLOSE[name].search(text, opened.end())
|
|
28
|
+
if closed is None:
|
|
29
|
+
unclosed.add(name)
|
|
30
|
+
kept.append(text[index : opened.end()])
|
|
31
|
+
index = opened.end()
|
|
32
|
+
continue
|
|
33
|
+
kept.append(text[index : opened.start()])
|
|
34
|
+
kept.append(" ")
|
|
35
|
+
index = closed.end()
|
|
36
|
+
kept.append(text[index:])
|
|
37
|
+
return "".join(kept)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _drop_tags(text: str) -> str:
|
|
41
|
+
kept: list[str] = []
|
|
42
|
+
index = 0
|
|
43
|
+
closing = text.find(">")
|
|
44
|
+
while (opening := text.find("<", index)) != -1:
|
|
45
|
+
if closing < opening:
|
|
46
|
+
closing = text.find(">", opening + 1)
|
|
47
|
+
if closing == -1:
|
|
48
|
+
break
|
|
49
|
+
span = closing - opening - 1
|
|
50
|
+
if 1 <= span <= LONGEST_TAG:
|
|
51
|
+
kept.append(text[index:opening])
|
|
52
|
+
index = closing + 1
|
|
53
|
+
closing = text.find(">", index)
|
|
54
|
+
else:
|
|
55
|
+
kept.append(text[index : opening + 1])
|
|
56
|
+
index = opening + 1
|
|
57
|
+
kept.append(text[index:])
|
|
58
|
+
return "".join(kept)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _strip_line_ends(text: str) -> str:
|
|
62
|
+
return "\n".join(line.rstrip(" \t") for line in text.split("\n"))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def strip_html(raw: str) -> str:
|
|
66
|
+
text = html.unescape(raw)
|
|
67
|
+
text = _drop_raw_text_elements(text)
|
|
68
|
+
text = _BLOCK_BREAKS.sub("\n", text)
|
|
69
|
+
text = _drop_tags(text)
|
|
70
|
+
text = html.unescape(text)
|
|
71
|
+
text = _CONTROL.sub("", text)
|
|
72
|
+
text = _strip_line_ends(text)
|
|
73
|
+
return _BLANK_LINES.sub("\n\n", text).strip()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def collapse_whitespace(raw: str) -> str:
|
|
77
|
+
stripped = _CONTROL.sub("", raw)
|
|
78
|
+
cleaned = " ".join(_PICTOGRAPHS.sub(" ", stripped).split())
|
|
79
|
+
return cleaned or " ".join(stripped.split())
|
stage/sources/ashby.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Any, ClassVar
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
5
|
+
|
|
6
|
+
from stage.domain import Company, Job, Platform, SourceSignals, job_id
|
|
7
|
+
from stage.sources import register
|
|
8
|
+
from stage.sources._text import collapse_whitespace, strip_html
|
|
9
|
+
from stage.sources.base import BoardAdapter, NullableBool, NullableStr
|
|
10
|
+
from stage.sources.platforms import safe_path_slug
|
|
11
|
+
|
|
12
|
+
BASE_URL = "https://api.ashbyhq.com/posting-api/job-board/{slug}"
|
|
13
|
+
HOST = "api.ashbyhq.com"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AshbyLocation(BaseModel):
|
|
17
|
+
model_config = ConfigDict(extra="ignore")
|
|
18
|
+
|
|
19
|
+
locationName: NullableStr = ""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AshbyPosting(BaseModel):
|
|
23
|
+
model_config = ConfigDict(extra="ignore")
|
|
24
|
+
|
|
25
|
+
id: str
|
|
26
|
+
title: str
|
|
27
|
+
location: NullableStr = ""
|
|
28
|
+
secondaryLocations: list[AshbyLocation] = Field(default_factory=list)
|
|
29
|
+
department: NullableStr = ""
|
|
30
|
+
team: NullableStr = ""
|
|
31
|
+
employmentType: NullableStr = ""
|
|
32
|
+
isListed: NullableBool = True
|
|
33
|
+
isRemote: NullableBool = False
|
|
34
|
+
publishedAt: datetime | None = None
|
|
35
|
+
jobUrl: NullableStr = ""
|
|
36
|
+
applyUrl: NullableStr = ""
|
|
37
|
+
descriptionPlain: NullableStr = ""
|
|
38
|
+
descriptionHtml: NullableStr = ""
|
|
39
|
+
|
|
40
|
+
def where(self) -> str:
|
|
41
|
+
names = [self.location] if self.location else []
|
|
42
|
+
names.extend(entry.locationName for entry in self.secondaryLocations)
|
|
43
|
+
seen = [name for name in dict.fromkeys(names) if name]
|
|
44
|
+
if not seen and self.isRemote:
|
|
45
|
+
return "Remote"
|
|
46
|
+
return " / ".join(seen)
|
|
47
|
+
|
|
48
|
+
def body(self) -> str:
|
|
49
|
+
if self.descriptionPlain:
|
|
50
|
+
return collapse_whitespace(self.descriptionPlain)
|
|
51
|
+
return collapse_whitespace(strip_html(self.descriptionHtml))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AshbyBoard(BaseModel):
|
|
55
|
+
model_config = ConfigDict(extra="ignore")
|
|
56
|
+
|
|
57
|
+
jobs: list[Any]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@register
|
|
61
|
+
class AshbyAdapter(BoardAdapter):
|
|
62
|
+
name: ClassVar[str] = "ashby"
|
|
63
|
+
platform: ClassVar[Platform] = Platform.ASHBY
|
|
64
|
+
rate_profile: ClassVar[str] = "standard"
|
|
65
|
+
hosts: ClassVar[frozenset[str]] = frozenset({HOST})
|
|
66
|
+
detail_budget: ClassVar[int] = 0
|
|
67
|
+
max_requests_per_company: ClassVar[int] = 1
|
|
68
|
+
|
|
69
|
+
base_url: ClassVar[str] = BASE_URL
|
|
70
|
+
slug_validator = safe_path_slug
|
|
71
|
+
root_model: ClassVar[type[BaseModel] | None] = AshbyBoard
|
|
72
|
+
rows_field: ClassVar[str] = "jobs"
|
|
73
|
+
row_model: ClassVar[type[BaseModel]] = AshbyPosting
|
|
74
|
+
|
|
75
|
+
def keep(self, row: Any) -> bool:
|
|
76
|
+
return bool(row.isListed)
|
|
77
|
+
|
|
78
|
+
def to_job(self, company: Company, row: Any, now: datetime) -> Job:
|
|
79
|
+
title = collapse_whitespace(row.title)
|
|
80
|
+
return Job(
|
|
81
|
+
id=job_id(self.name, company.slug, row.id),
|
|
82
|
+
source=self.name,
|
|
83
|
+
company=company.name,
|
|
84
|
+
title_raw=title,
|
|
85
|
+
title_normalized=title.lower(),
|
|
86
|
+
apply_url_raw=row.jobUrl or row.applyUrl,
|
|
87
|
+
description=row.body(),
|
|
88
|
+
location_raw=collapse_whitespace(row.where()),
|
|
89
|
+
first_seen=now,
|
|
90
|
+
last_seen=now,
|
|
91
|
+
source_posted_at=row.publishedAt,
|
|
92
|
+
signals=SourceSignals(employment_type=row.employmentType),
|
|
93
|
+
)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Any, ClassVar
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, ConfigDict
|
|
5
|
+
|
|
6
|
+
from stage.domain import Company, Job, Platform, job_id
|
|
7
|
+
from stage.sources import register
|
|
8
|
+
from stage.sources._text import collapse_whitespace
|
|
9
|
+
from stage.sources.base import BoardAdapter, NullableBool, NullableStr
|
|
10
|
+
|
|
11
|
+
HOST_TEMPLATE = "{slug}.bamboohr.com"
|
|
12
|
+
PATH = "/careers/list"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BambooLocation(BaseModel):
|
|
16
|
+
model_config = ConfigDict(extra="ignore")
|
|
17
|
+
|
|
18
|
+
city: NullableStr = ""
|
|
19
|
+
state: NullableStr = ""
|
|
20
|
+
country: NullableStr = ""
|
|
21
|
+
|
|
22
|
+
def label(self) -> str:
|
|
23
|
+
return ", ".join(part for part in (self.city, self.state, self.country) if part)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BambooPosting(BaseModel):
|
|
27
|
+
model_config = ConfigDict(extra="ignore")
|
|
28
|
+
|
|
29
|
+
id: int
|
|
30
|
+
jobOpeningName: str
|
|
31
|
+
departmentLabel: NullableStr = ""
|
|
32
|
+
employmentStatusLabel: NullableStr = ""
|
|
33
|
+
location: BambooLocation | None = None
|
|
34
|
+
atsLocation: BambooLocation | None = None
|
|
35
|
+
isRemote: NullableBool = False
|
|
36
|
+
|
|
37
|
+
def where(self) -> str:
|
|
38
|
+
for candidate in (self.location, self.atsLocation):
|
|
39
|
+
if candidate is not None:
|
|
40
|
+
label = candidate.label()
|
|
41
|
+
if label:
|
|
42
|
+
return label
|
|
43
|
+
return "Remote" if self.isRemote else ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class BambooBoard(BaseModel):
|
|
47
|
+
model_config = ConfigDict(extra="ignore")
|
|
48
|
+
|
|
49
|
+
result: list[Any]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@register
|
|
53
|
+
class BambooHrAdapter(BoardAdapter):
|
|
54
|
+
name: ClassVar[str] = "bamboohr"
|
|
55
|
+
platform: ClassVar[Platform] = Platform.BAMBOOHR
|
|
56
|
+
rate_profile: ClassVar[str] = "moderate"
|
|
57
|
+
bucket_key: ClassVar[str] = "bamboohr"
|
|
58
|
+
detail_budget: ClassVar[int] = 0
|
|
59
|
+
max_requests_per_company: ClassVar[int] = 1
|
|
60
|
+
|
|
61
|
+
host_template: ClassVar[str] = HOST_TEMPLATE
|
|
62
|
+
path: ClassVar[str] = PATH
|
|
63
|
+
root_model: ClassVar[type[BaseModel] | None] = BambooBoard
|
|
64
|
+
rows_field: ClassVar[str] = "result"
|
|
65
|
+
row_model: ClassVar[type[BaseModel]] = BambooPosting
|
|
66
|
+
|
|
67
|
+
def to_job(self, company: Company, row: Any, now: datetime) -> Job:
|
|
68
|
+
title = collapse_whitespace(row.jobOpeningName)
|
|
69
|
+
return Job(
|
|
70
|
+
id=job_id(self.name, company.slug, str(row.id)),
|
|
71
|
+
source=self.name,
|
|
72
|
+
company=company.name,
|
|
73
|
+
title_raw=title,
|
|
74
|
+
title_normalized=title.lower(),
|
|
75
|
+
apply_url_raw=f"https://{self.host_for(company)}/careers/{row.id}",
|
|
76
|
+
description="",
|
|
77
|
+
location_raw=collapse_whitespace(row.where()),
|
|
78
|
+
first_seen=now,
|
|
79
|
+
last_seen=now,
|
|
80
|
+
)
|
stage/sources/base.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from collections.abc import Callable, Sequence
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from typing import Annotated, Any, ClassVar, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, BeforeValidator, StringConstraints, ValidationError
|
|
8
|
+
|
|
9
|
+
from stage.domain import Company, Job, Platform, WorkdayCrawlStep, board_key
|
|
10
|
+
from stage.http import HttpClient
|
|
11
|
+
from stage.paths import capture_dir
|
|
12
|
+
from stage.sources.platforms import SlugRejectedError, safe_slug
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AdapterError(Exception):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PayloadValidationError(AdapterError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
NullableStr = Annotated[str, BeforeValidator(lambda value: "" if value is None else value)]
|
|
24
|
+
NonEmptyStr = Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)]
|
|
25
|
+
NullableBool = Annotated[bool, BeforeValidator(lambda value: False if value is None else value)]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def validate_rows[ModelT: BaseModel](
|
|
29
|
+
model: type[ModelT], rows: Sequence[Any], *, source: str, slug: str
|
|
30
|
+
) -> tuple[list[ModelT], int]:
|
|
31
|
+
kept: list[ModelT] = []
|
|
32
|
+
dropped = 0
|
|
33
|
+
for row in rows:
|
|
34
|
+
try:
|
|
35
|
+
kept.append(model.model_validate(row))
|
|
36
|
+
except ValidationError:
|
|
37
|
+
dropped += 1
|
|
38
|
+
capture_payload(f"{source}-posting", slug, row)
|
|
39
|
+
return kept, dropped
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def convert_rows[ModelT: BaseModel](
|
|
43
|
+
build: Callable[[ModelT], Job], rows: Sequence[ModelT], *, source: str, slug: str
|
|
44
|
+
) -> tuple[list[Job], int]:
|
|
45
|
+
kept: list[Job] = []
|
|
46
|
+
dropped = 0
|
|
47
|
+
for row in rows:
|
|
48
|
+
try:
|
|
49
|
+
kept.append(build(row))
|
|
50
|
+
except (ValueError, OverflowError, OSError):
|
|
51
|
+
dropped += 1
|
|
52
|
+
capture_payload(f"{source}-posting", slug, row.model_dump(mode="json"))
|
|
53
|
+
return kept, dropped
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def malformed_note(dropped: int) -> str:
|
|
57
|
+
if not dropped:
|
|
58
|
+
return ""
|
|
59
|
+
return (
|
|
60
|
+
f"{dropped} posting(s) failed validation and were dropped, raw rows "
|
|
61
|
+
"captured; the listing is incomplete so it closes nothing"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True, slots=True)
|
|
66
|
+
class FetchResult:
|
|
67
|
+
jobs: tuple[Job, ...] = field(default_factory=tuple)
|
|
68
|
+
not_modified: bool = False
|
|
69
|
+
authoritative: bool = True
|
|
70
|
+
degraded: str = ""
|
|
71
|
+
stale_urls: tuple[str, ...] = field(default_factory=tuple)
|
|
72
|
+
detail_fetches: tuple[object, ...] = field(default_factory=tuple)
|
|
73
|
+
facets: tuple[object, ...] = field(default_factory=tuple)
|
|
74
|
+
forgotten_facets: tuple[object, ...] = field(default_factory=tuple)
|
|
75
|
+
workday_crawl: WorkdayCrawlStep | None = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@runtime_checkable
|
|
79
|
+
class Adapter(Protocol):
|
|
80
|
+
name: ClassVar[str]
|
|
81
|
+
platform: ClassVar[Platform]
|
|
82
|
+
rate_profile: ClassVar[str]
|
|
83
|
+
hosts: ClassVar[frozenset[str]]
|
|
84
|
+
|
|
85
|
+
bucket_key: ClassVar[str]
|
|
86
|
+
|
|
87
|
+
detail_budget: ClassVar[int]
|
|
88
|
+
|
|
89
|
+
rotation_slice: ClassVar[int]
|
|
90
|
+
|
|
91
|
+
max_requests_per_company: ClassVar[int]
|
|
92
|
+
|
|
93
|
+
def board_key(self, company: Company) -> str:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
def hosts_for(self, companies: Sequence[Company]) -> frozenset[str]:
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
def plan(self, company: Company) -> tuple[str, ...]:
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
async def fetch(
|
|
103
|
+
self,
|
|
104
|
+
company: Company,
|
|
105
|
+
client: HttpClient,
|
|
106
|
+
now: datetime,
|
|
107
|
+
facets: Any = None,
|
|
108
|
+
details: Sequence[str] = (),
|
|
109
|
+
) -> FetchResult:
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class BoardAdapter:
|
|
114
|
+
name: ClassVar[str]
|
|
115
|
+
platform: ClassVar[Platform]
|
|
116
|
+
rate_profile: ClassVar[str]
|
|
117
|
+
hosts: ClassVar[frozenset[str]] = frozenset()
|
|
118
|
+
bucket_key: ClassVar[str] = ""
|
|
119
|
+
detail_budget: ClassVar[int]
|
|
120
|
+
rotation_slice: ClassVar[int] = 0
|
|
121
|
+
max_requests_per_company: ClassVar[int]
|
|
122
|
+
|
|
123
|
+
row_model: ClassVar[type[BaseModel]]
|
|
124
|
+
root_model: ClassVar[type[BaseModel] | None] = None
|
|
125
|
+
rows_field: ClassVar[str] = ""
|
|
126
|
+
base_url: ClassVar[str] = ""
|
|
127
|
+
host_template: ClassVar[str] = ""
|
|
128
|
+
path: ClassVar[str] = ""
|
|
129
|
+
query: ClassVar[tuple[tuple[str, str], ...]] = ()
|
|
130
|
+
slug_validator: ClassVar[Callable[[str], str]] = safe_slug
|
|
131
|
+
|
|
132
|
+
def host_for(self, company: Company) -> str:
|
|
133
|
+
return self.host_template.format(slug=type(self).slug_validator(company.slug))
|
|
134
|
+
|
|
135
|
+
def url_for(self, company: Company) -> str:
|
|
136
|
+
if self.host_template:
|
|
137
|
+
return f"https://{self.host_for(company)}{self.path}"
|
|
138
|
+
return self.base_url.format(slug=type(self).slug_validator(company.slug))
|
|
139
|
+
|
|
140
|
+
def hosts_for(self, companies: Sequence[Company]) -> frozenset[str]:
|
|
141
|
+
if not self.host_template:
|
|
142
|
+
return self.hosts
|
|
143
|
+
allowed: set[str] = set()
|
|
144
|
+
for company in companies:
|
|
145
|
+
try:
|
|
146
|
+
allowed.add(self.host_for(company))
|
|
147
|
+
except SlugRejectedError:
|
|
148
|
+
continue
|
|
149
|
+
return frozenset(allowed)
|
|
150
|
+
|
|
151
|
+
def board_key(self, company: Company) -> str:
|
|
152
|
+
return board_key(self.name, company.slug)
|
|
153
|
+
|
|
154
|
+
def plan(self, company: Company) -> tuple[str, ...]:
|
|
155
|
+
url = self.url_for(company)
|
|
156
|
+
if self.query:
|
|
157
|
+
url = f"{url}?" + "&".join(f"{key}={value}" for key, value in self.query)
|
|
158
|
+
return (url,)
|
|
159
|
+
|
|
160
|
+
def params(self) -> dict[str, str] | None:
|
|
161
|
+
return dict(self.query) or None
|
|
162
|
+
|
|
163
|
+
async def fetch(
|
|
164
|
+
self,
|
|
165
|
+
company: Company,
|
|
166
|
+
client: HttpClient,
|
|
167
|
+
now: datetime,
|
|
168
|
+
facets: object = None,
|
|
169
|
+
details: Sequence[str] = (),
|
|
170
|
+
) -> FetchResult:
|
|
171
|
+
response = await client.get_json(self.url_for(company), params=self.params())
|
|
172
|
+
if response.not_modified:
|
|
173
|
+
return FetchResult(not_modified=True)
|
|
174
|
+
return self.result(company, response.payload, now)
|
|
175
|
+
|
|
176
|
+
def result(self, company: Company, payload: Any, now: datetime) -> FetchResult:
|
|
177
|
+
rows, dropped = self.validate(company, payload)
|
|
178
|
+
kept = [row for row in rows if self.keep(row)]
|
|
179
|
+
return FetchResult(
|
|
180
|
+
jobs=tuple(self.to_job(company, row, now) for row in kept),
|
|
181
|
+
degraded=malformed_note(dropped),
|
|
182
|
+
authoritative=not dropped,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def keep(self, row: Any) -> bool:
|
|
186
|
+
return True
|
|
187
|
+
|
|
188
|
+
def validate(self, company: Company, payload: Any) -> tuple[list[Any], int]:
|
|
189
|
+
return validate_rows(
|
|
190
|
+
self.row_model, self.rows(company, payload), source=self.name, slug=company.slug
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def rows(self, company: Company, payload: Any) -> Sequence[Any]:
|
|
194
|
+
if self.root_model is None:
|
|
195
|
+
if not isinstance(payload, list):
|
|
196
|
+
captured = capture_payload(self.name, company.slug, payload)
|
|
197
|
+
raise PayloadValidationError(
|
|
198
|
+
f"{self.name}/{company.slug}: field '<root>' failed validation (expected "
|
|
199
|
+
f"a JSON list of postings); raw payload captured at {captured}"
|
|
200
|
+
)
|
|
201
|
+
return payload
|
|
202
|
+
try:
|
|
203
|
+
root = self.root_model.model_validate(payload)
|
|
204
|
+
except ValidationError as exc:
|
|
205
|
+
captured = capture_payload(self.name, company.slug, payload)
|
|
206
|
+
first = exc.errors()[0]
|
|
207
|
+
field_name = ".".join(str(part) for part in first["loc"]) or "<root>"
|
|
208
|
+
raise PayloadValidationError(
|
|
209
|
+
f"{self.name}/{company.slug}: field {field_name!r} failed validation "
|
|
210
|
+
f"({first['msg']}); raw payload captured at {captured}"
|
|
211
|
+
) from exc
|
|
212
|
+
listed = getattr(root, self.rows_field)
|
|
213
|
+
return listed if isinstance(listed, list) else []
|
|
214
|
+
|
|
215
|
+
def to_job(self, company: Company, row: Any, now: datetime) -> Job:
|
|
216
|
+
raise NotImplementedError
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def capture_payload(source: str, slug: str, payload: Any) -> str:
|
|
220
|
+
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S%f")
|
|
221
|
+
target = capture_dir() / f"{source}-{slug}-{stamp}.json"
|
|
222
|
+
target.write_text(
|
|
223
|
+
json.dumps(payload, indent=2, ensure_ascii=False, default=str), encoding="utf-8"
|
|
224
|
+
)
|
|
225
|
+
return str(target)
|
stage/sources/breezy.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Any, ClassVar
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, ConfigDict
|
|
5
|
+
|
|
6
|
+
from stage.domain import Company, Job, Platform, job_id
|
|
7
|
+
from stage.sources import register
|
|
8
|
+
from stage.sources._text import collapse_whitespace, strip_html
|
|
9
|
+
from stage.sources.base import BoardAdapter, NullableBool, NullableStr
|
|
10
|
+
|
|
11
|
+
HOST_TEMPLATE = "{slug}.breezy.hr"
|
|
12
|
+
PATH = "/json"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BreezyCountry(BaseModel):
|
|
16
|
+
model_config = ConfigDict(extra="ignore")
|
|
17
|
+
|
|
18
|
+
name: NullableStr = ""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BreezyLocation(BaseModel):
|
|
22
|
+
model_config = ConfigDict(extra="ignore")
|
|
23
|
+
|
|
24
|
+
name: NullableStr = ""
|
|
25
|
+
city: NullableStr = ""
|
|
26
|
+
country: BreezyCountry | None = None
|
|
27
|
+
is_remote: NullableBool = False
|
|
28
|
+
|
|
29
|
+
def label(self) -> str:
|
|
30
|
+
if self.name:
|
|
31
|
+
return self.name
|
|
32
|
+
parts = [self.city, self.country.name if self.country else ""]
|
|
33
|
+
seen = [part for part in parts if part]
|
|
34
|
+
if not seen and self.is_remote:
|
|
35
|
+
return "Remote"
|
|
36
|
+
return ", ".join(seen)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class BreezyType(BaseModel):
|
|
40
|
+
model_config = ConfigDict(extra="ignore")
|
|
41
|
+
|
|
42
|
+
name: str = ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class BreezyPosting(BaseModel):
|
|
46
|
+
model_config = ConfigDict(extra="ignore")
|
|
47
|
+
|
|
48
|
+
id: str
|
|
49
|
+
name: str
|
|
50
|
+
friendly_id: NullableStr = ""
|
|
51
|
+
type: BreezyType | None = None
|
|
52
|
+
education: NullableStr = ""
|
|
53
|
+
department: NullableStr = ""
|
|
54
|
+
description: NullableStr = ""
|
|
55
|
+
location: BreezyLocation | None = None
|
|
56
|
+
url: NullableStr = ""
|
|
57
|
+
published_date: datetime | None = None
|
|
58
|
+
|
|
59
|
+
def where(self) -> str:
|
|
60
|
+
return self.location.label() if self.location else ""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@register
|
|
64
|
+
class BreezyAdapter(BoardAdapter):
|
|
65
|
+
name: ClassVar[str] = "breezy"
|
|
66
|
+
platform: ClassVar[Platform] = Platform.BREEZY
|
|
67
|
+
rate_profile: ClassVar[str] = "moderate"
|
|
68
|
+
bucket_key: ClassVar[str] = "breezy"
|
|
69
|
+
detail_budget: ClassVar[int] = 0
|
|
70
|
+
max_requests_per_company: ClassVar[int] = 1
|
|
71
|
+
|
|
72
|
+
host_template: ClassVar[str] = HOST_TEMPLATE
|
|
73
|
+
path: ClassVar[str] = PATH
|
|
74
|
+
row_model: ClassVar[type[BaseModel]] = BreezyPosting
|
|
75
|
+
|
|
76
|
+
def to_job(self, company: Company, row: Any, now: datetime) -> Job:
|
|
77
|
+
title = collapse_whitespace(row.name)
|
|
78
|
+
return Job(
|
|
79
|
+
id=job_id(self.name, company.slug, row.id),
|
|
80
|
+
source=self.name,
|
|
81
|
+
company=company.name,
|
|
82
|
+
title_raw=title,
|
|
83
|
+
title_normalized=title.lower(),
|
|
84
|
+
apply_url_raw=row.url,
|
|
85
|
+
description=collapse_whitespace(strip_html(row.description)),
|
|
86
|
+
location_raw=collapse_whitespace(row.where()),
|
|
87
|
+
first_seen=now,
|
|
88
|
+
last_seen=now,
|
|
89
|
+
source_posted_at=row.published_date,
|
|
90
|
+
)
|