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,200 @@
1
+ import base64
2
+ import binascii
3
+ import re
4
+ from datetime import datetime, timedelta
5
+ from typing import Any, ClassVar
6
+
7
+ from pydantic import BaseModel, ConfigDict
8
+
9
+ from stage.domain import Job, SourceSignals, job_id
10
+ from stage.http import HttpClient
11
+ from stage.sources._text import collapse_whitespace, strip_html
12
+ from stage.sources.base import (
13
+ FetchResult,
14
+ NonEmptyStr,
15
+ PayloadValidationError,
16
+ capture_payload,
17
+ convert_rows,
18
+ malformed_note,
19
+ validate_rows,
20
+ )
21
+ from stage.sources.feed import register_feed, upcoming_season_year
22
+
23
+ HOST = "api.github.com"
24
+ CONTENT_URL = "https://api.github.com/repos/speedyapply/{repository}/contents/{path}?ref=main"
25
+ HREF = re.compile(r'<a\s+href="(?P<url>[^"]+)"', re.IGNORECASE)
26
+ STRONG = re.compile(r"<strong>(?P<text>.*?)</strong>", re.IGNORECASE)
27
+ AGE = re.compile(r"(?P<days>\d+)d")
28
+
29
+
30
+ class GitHubContent(BaseModel):
31
+ model_config = ConfigDict(extra="ignore")
32
+
33
+ encoding: NonEmptyStr
34
+ content: NonEmptyStr
35
+
36
+
37
+ class SpeedyApplyListing(BaseModel):
38
+ model_config = ConfigDict(extra="ignore")
39
+
40
+ company: NonEmptyStr
41
+ title: NonEmptyStr
42
+ location: str = ""
43
+ url: NonEmptyStr
44
+ age: str = ""
45
+
46
+ @property
47
+ def identity(self) -> str:
48
+ return f"{self.company}:{self.title}:{self.location}:{self.url}"
49
+
50
+
51
+ @register_feed
52
+ class SpeedyApplyFeed:
53
+ name: ClassVar[str] = "speedyapply"
54
+ rate_profile: ClassVar[str] = "feeds"
55
+ hosts: ClassVar[frozenset[str]] = frozenset({HOST})
56
+ bucket_key: ClassVar[str] = ""
57
+
58
+ def season_year(self, now: datetime) -> int:
59
+ return upcoming_season_year(now)
60
+
61
+ def plan(self, now: datetime) -> tuple[str, ...]:
62
+ year = self.season_year(now)
63
+ sources = (
64
+ (f"{year}-SWE-College-Jobs", "README.md"),
65
+ (f"{year}-SWE-College-Jobs", "INTERN_INTL.md"),
66
+ (f"{year}-AI-College-Jobs", "README.md"),
67
+ (f"{year}-AI-College-Jobs", "INTERN_INTL.md"),
68
+ )
69
+ return tuple(
70
+ CONTENT_URL.format(repository=repository, path=path) for repository, path in sources
71
+ )
72
+
73
+ async def fetch(self, client: HttpClient, now: datetime) -> FetchResult:
74
+ jobs: list[Job] = []
75
+ modified = False
76
+ failures: list[str] = []
77
+ stale: list[str] = []
78
+ malformed = 0
79
+ for url in self.plan(now):
80
+ try:
81
+ response = await client.get_json(url)
82
+ except Exception as exc:
83
+ failures.append(f"{url}: {type(exc).__name__}")
84
+ stale.append(url)
85
+ continue
86
+ if response.not_modified:
87
+ continue
88
+ modified = True
89
+ listings, dropped = self._validate(response.payload, url, now)
90
+ converted, unconvertible = convert_rows(
91
+ lambda listing: self._to_job(listing, now),
92
+ listings,
93
+ source=self.name,
94
+ slug=str(self.season_year(now)),
95
+ )
96
+ malformed += dropped + unconvertible
97
+ jobs.extend(converted)
98
+ if failures and not jobs:
99
+ raise PayloadValidationError(f"{self.name}: every file failed — {'; '.join(failures)}")
100
+ if not modified and not failures:
101
+ return FetchResult(not_modified=True)
102
+ unique = {job.id: job for job in jobs}
103
+ notes = []
104
+ if failures:
105
+ notes.append(f"{len(failures)} of {len(self.plan(now))} file(s) unavailable")
106
+ if malformed:
107
+ notes.append(malformed_note(malformed))
108
+ return FetchResult(
109
+ jobs=tuple(unique.values()),
110
+ degraded="; ".join(notes),
111
+ authoritative=not (failures or malformed),
112
+ stale_urls=tuple(stale),
113
+ )
114
+
115
+ def _validate(
116
+ self, payload: Any, url: str, now: datetime
117
+ ) -> tuple[list[SpeedyApplyListing], int]:
118
+ try:
119
+ content = GitHubContent.model_validate(payload)
120
+ except Exception as exc:
121
+ captured = capture_payload(self.name, str(self.season_year(now)), payload)
122
+ raise PayloadValidationError(
123
+ f"{self.name}/{url}: content envelope failed validation (captured {captured})"
124
+ ) from exc
125
+ if content.encoding != "base64":
126
+ captured = capture_payload(self.name, str(self.season_year(now)), payload)
127
+ raise PayloadValidationError(
128
+ f"{self.name}/{url}: unsupported content encoding {content.encoding!r} "
129
+ f"(captured {captured})"
130
+ )
131
+ try:
132
+ text = base64.b64decode("".join(content.content.split()), validate=True).decode("utf-8")
133
+ except (UnicodeDecodeError, binascii.Error, ValueError) as exc:
134
+ captured = capture_payload(self.name, str(self.season_year(now)), payload)
135
+ raise PayloadValidationError(
136
+ f"{self.name}/{url}: content is not valid base64 UTF-8 (captured {captured})"
137
+ ) from exc
138
+ rows, malformed = self._rows(text, str(self.season_year(now)))
139
+ if not rows:
140
+ captured = capture_payload(self.name, str(self.season_year(now)), payload)
141
+ raise PayloadValidationError(
142
+ f"{self.name}/{url}: no internship table rows were found (captured {captured})"
143
+ )
144
+ listings, dropped = validate_rows(
145
+ SpeedyApplyListing, rows, source=self.name, slug=str(self.season_year(now))
146
+ )
147
+ return listings, malformed + dropped
148
+
149
+ def _rows(self, text: str, slug: str) -> tuple[list[dict[str, str]], int]:
150
+ rows: list[dict[str, str]] = []
151
+ malformed = 0
152
+ for line in text.splitlines():
153
+ stripped = line.strip()
154
+ if not stripped.startswith("|"):
155
+ continue
156
+ cells = [cell.strip() for cell in stripped.strip("|").split("|")]
157
+ if not cells or not cells[0].lower().startswith("<a href="):
158
+ continue
159
+ if len(cells) == 6:
160
+ apply_cell, age_cell = cells[4], cells[5]
161
+ elif len(cells) == 5:
162
+ apply_cell, age_cell = cells[3], cells[4]
163
+ else:
164
+ malformed += 1
165
+ capture_payload(f"{self.name}-posting", slug, {"line": line})
166
+ continue
167
+ company = STRONG.search(cells[0])
168
+ apply = HREF.search(apply_cell)
169
+ if company is None or apply is None:
170
+ malformed += 1
171
+ capture_payload(f"{self.name}-posting", slug, {"line": line})
172
+ continue
173
+ rows.append(
174
+ {
175
+ "company": collapse_whitespace(strip_html(company.group("text"))),
176
+ "title": collapse_whitespace(strip_html(cells[1])),
177
+ "location": collapse_whitespace(strip_html(cells[2])),
178
+ "url": apply.group("url"),
179
+ "age": collapse_whitespace(strip_html(age_cell)),
180
+ }
181
+ )
182
+ return rows, malformed
183
+
184
+ def _to_job(self, listing: SpeedyApplyListing, now: datetime) -> Job:
185
+ age = AGE.fullmatch(listing.age)
186
+ posted = now - timedelta(days=int(age.group("days"))) if age is not None else None
187
+ return Job(
188
+ id=job_id(self.name, listing.company, listing.identity),
189
+ source=self.name,
190
+ company=collapse_whitespace(listing.company),
191
+ title_raw=collapse_whitespace(listing.title),
192
+ title_normalized=collapse_whitespace(listing.title),
193
+ apply_url_raw=listing.url,
194
+ description="",
195
+ location_raw=collapse_whitespace(listing.location),
196
+ first_seen=now,
197
+ last_seen=now,
198
+ source_posted_at=posted,
199
+ signals=SourceSignals(employment_type="internship"),
200
+ )
@@ -0,0 +1,157 @@
1
+ from datetime import datetime
2
+ from typing import Any, ClassVar
3
+ from urllib.parse import quote
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+ from stage.domain import Job, SourceSignals, job_id
8
+ from stage.http import HttpClient
9
+ from stage.sources import register_feed
10
+ from stage.sources._text import collapse_whitespace
11
+ from stage.sources.base import (
12
+ FetchResult,
13
+ NonEmptyStr,
14
+ PayloadValidationError,
15
+ capture_payload,
16
+ malformed_note,
17
+ validate_rows,
18
+ )
19
+
20
+ HOST = "www.themuse.com"
21
+ SEARCH = f"https://{HOST}/api/public/jobs"
22
+ LOCATIONS = (
23
+ "Toronto, Canada",
24
+ "Montreal, Canada",
25
+ "Vancouver, Canada",
26
+ "Ottawa, Canada",
27
+ "Calgary, Canada",
28
+ "Edmonton, Canada",
29
+ "Waterloo, Canada",
30
+ "Quebec City, Canada",
31
+ "Halifax, Canada",
32
+ "Winnipeg, Canada",
33
+ "New York City, NY",
34
+ "San Francisco, CA",
35
+ )
36
+ PAGE_CAP = 8
37
+
38
+
39
+ class MuseCompany(BaseModel):
40
+ model_config = ConfigDict(extra="ignore")
41
+
42
+ name: str = ""
43
+
44
+
45
+ class MuseLocation(BaseModel):
46
+ model_config = ConfigDict(extra="ignore")
47
+
48
+ name: str = ""
49
+
50
+
51
+ class MuseRefs(BaseModel):
52
+ model_config = ConfigDict(extra="ignore")
53
+
54
+ landing_page: str = ""
55
+
56
+
57
+ class MuseListing(BaseModel):
58
+ model_config = ConfigDict(extra="ignore")
59
+
60
+ id: int = Field(gt=0)
61
+ name: NonEmptyStr
62
+ company: MuseCompany | None = None
63
+ locations: list[MuseLocation] = Field(default_factory=list)
64
+ refs: MuseRefs | None = None
65
+ contents: str = ""
66
+ categories: list[Any] = Field(default_factory=list)
67
+
68
+
69
+ class MusePage(BaseModel):
70
+ model_config = ConfigDict(extra="ignore")
71
+
72
+ results: list[Any]
73
+ page_count: int = Field(ge=0)
74
+
75
+
76
+ @register_feed
77
+ class TheMuseFeed:
78
+ name: ClassVar[str] = "themuse"
79
+ rate_profile: ClassVar[str] = "feeds"
80
+ hosts: ClassVar[frozenset[str]] = frozenset({HOST})
81
+ bucket_key: ClassVar[str] = "themuse"
82
+
83
+ def season_year(self, now: datetime) -> int:
84
+ return now.year
85
+
86
+ def plan(self, now: datetime) -> tuple[str, ...]:
87
+ return (self._url(1),)
88
+
89
+ async def fetch(self, client: HttpClient, now: datetime) -> FetchResult:
90
+ seen: dict[str, Job] = {}
91
+ malformed = 0
92
+ truncated = False
93
+ pages = PAGE_CAP
94
+ for page in range(1, PAGE_CAP + 1):
95
+ if page > pages:
96
+ break
97
+ response = await client.get_json(self._url(page))
98
+ rows, dropped, pages = self._validate(response.payload, page)
99
+ malformed += dropped
100
+ for listing in rows:
101
+ job = self._to_job(listing, now)
102
+ seen.setdefault(job.id, job)
103
+ else:
104
+ truncated = pages > PAGE_CAP
105
+
106
+ notes = []
107
+ if truncated:
108
+ notes.append(f"stopped at the {PAGE_CAP}-page cap with {pages} pages reported")
109
+ if malformed:
110
+ notes.append(malformed_note(malformed))
111
+ return FetchResult(
112
+ jobs=tuple(seen.values()),
113
+ authoritative=False,
114
+ degraded="; ".join(notes),
115
+ )
116
+
117
+ def _url(self, page: int) -> str:
118
+ locations = "&".join(f"location={quote(name)}" for name in LOCATIONS)
119
+ return f"{SEARCH}?page={page}&level={quote('Internship')}&{locations}"
120
+
121
+ def _validate(self, payload: Any, page: int) -> tuple[list[MuseListing], int, int]:
122
+ if payload is None:
123
+ return [], 0, page - 1
124
+ try:
125
+ parsed = MusePage.model_validate(payload)
126
+ except Exception as exc:
127
+ captured = capture_payload(self.name, f"page-{page}", payload)
128
+ raise PayloadValidationError(
129
+ f"{self.name}: search response failed validation; raw payload captured "
130
+ f"at {captured}"
131
+ ) from exc
132
+ rows, dropped = validate_rows(
133
+ MuseListing, parsed.results, source=self.name, slug=f"page-{page}"
134
+ )
135
+ return rows, dropped, parsed.page_count
136
+
137
+ def _to_job(self, listing: MuseListing, now: datetime) -> Job:
138
+ title = collapse_whitespace(listing.name)
139
+ company = collapse_whitespace(listing.company.name if listing.company else "") or "The Muse"
140
+ places = [collapse_whitespace(place.name) for place in listing.locations if place.name]
141
+ landing = listing.refs.landing_page if listing.refs else ""
142
+ category = ""
143
+ if listing.categories and isinstance(listing.categories[0], dict):
144
+ category = collapse_whitespace(str(listing.categories[0].get("name") or ""))
145
+ return Job(
146
+ id=job_id(self.name, "internships", str(listing.id)),
147
+ source=self.name,
148
+ company=company,
149
+ title_raw=title,
150
+ title_normalized=title.lower(),
151
+ apply_url_raw=landing or f"https://{HOST}/jobs/{listing.id}",
152
+ description=listing.contents,
153
+ location_raw=" / ".join(places) or "Remote",
154
+ first_seen=now,
155
+ last_seen=now,
156
+ signals=SourceSignals(employment_type="internship", category=category),
157
+ )
@@ -0,0 +1,83 @@
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, 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
+
11
+ BASE_URL = "https://apply.workable.com/api/v1/widget/accounts/{slug}"
12
+ HOST = "apply.workable.com"
13
+
14
+
15
+ class WorkablePosting(BaseModel):
16
+ model_config = ConfigDict(extra="ignore")
17
+
18
+ shortcode: str
19
+ title: str
20
+ code: NullableStr = ""
21
+ department: NullableStr = ""
22
+ url: NullableStr = ""
23
+ application_url: NullableStr = ""
24
+ shortlink: NullableStr = ""
25
+ country: NullableStr = ""
26
+ city: NullableStr = ""
27
+ state: NullableStr = ""
28
+ employment_type: NullableStr = ""
29
+ telecommuting: NullableBool = False
30
+ published_on: NullableStr = ""
31
+ description: NullableStr = ""
32
+ requirements: NullableStr = ""
33
+ benefits: NullableStr = ""
34
+
35
+ def where(self) -> str:
36
+ parts = [part for part in (self.city, self.state, self.country) if part]
37
+ if not parts and self.telecommuting:
38
+ return "Remote"
39
+ return ", ".join(parts)
40
+
41
+ def body(self) -> str:
42
+ sections = (self.description, self.requirements, self.benefits)
43
+ joined = "\n\n".join(section for section in sections if section)
44
+ return collapse_whitespace(strip_html(joined))
45
+
46
+
47
+ class WorkableBoard(BaseModel):
48
+ model_config = ConfigDict(extra="ignore")
49
+
50
+ name: str = ""
51
+ jobs: list[Any]
52
+
53
+
54
+ @register
55
+ class WorkableAdapter(BoardAdapter):
56
+ name: ClassVar[str] = "workable"
57
+ platform: ClassVar[Platform] = Platform.WORKABLE
58
+ rate_profile: ClassVar[str] = "conservative"
59
+ hosts: ClassVar[frozenset[str]] = frozenset({HOST})
60
+ detail_budget: ClassVar[int] = 0
61
+ max_requests_per_company: ClassVar[int] = 1
62
+
63
+ base_url: ClassVar[str] = BASE_URL
64
+ query: ClassVar[tuple[tuple[str, str], ...]] = (("details", "true"),)
65
+ root_model: ClassVar[type[BaseModel] | None] = WorkableBoard
66
+ rows_field: ClassVar[str] = "jobs"
67
+ row_model: ClassVar[type[BaseModel]] = WorkablePosting
68
+
69
+ def to_job(self, company: Company, row: Any, now: datetime) -> Job:
70
+ title = collapse_whitespace(row.title)
71
+ return Job(
72
+ id=job_id(self.name, company.slug, row.shortcode),
73
+ source=self.name,
74
+ company=company.name,
75
+ title_raw=title,
76
+ title_normalized=title.lower(),
77
+ apply_url_raw=row.url or row.application_url or row.shortlink,
78
+ description=row.body(),
79
+ location_raw=collapse_whitespace(row.where()),
80
+ first_seen=now,
81
+ last_seen=now,
82
+ signals=SourceSignals(employment_type=row.employment_type),
83
+ )