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,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
|
+
)
|
stage/sources/themuse.py
ADDED
|
@@ -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
|
+
)
|