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
@@ -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)
@@ -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
+ )