stage-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stage/__init__.py +1 -0
- stage/__main__.py +8 -0
- stage/banner.py +32 -0
- stage/bootstrap/__init__.py +0 -0
- stage/bootstrap/openjobs.py +392 -0
- stage/classify/__init__.py +29 -0
- stage/classify/eligibility.py +115 -0
- stage/classify/internship.py +64 -0
- stage/classify/role.py +91 -0
- stage/classify/scope.py +47 -0
- stage/cli/__init__.py +0 -0
- stage/cli/app.py +4 -0
- stage/cli/commands/__init__.py +8 -0
- stage/cli/commands/discovery.py +294 -0
- stage/cli/commands/insight.py +494 -0
- stage/cli/commands/pipeline.py +337 -0
- stage/cli/commands/postings.py +473 -0
- stage/cli/commands/schedule.py +171 -0
- stage/cli/housekeeping.py +64 -0
- stage/cli/logfile.py +56 -0
- stage/cli/notify.py +170 -0
- stage/cli/options.py +678 -0
- stage/cli/render.py +1398 -0
- stage/cli/runlock.py +74 -0
- stage/cli/schedule.py +702 -0
- stage/cli/schedule_state.py +363 -0
- stage/cli/selection.py +83 -0
- stage/cli/serialize.py +196 -0
- stage/companies.py +542 -0
- stage/data/companies/a.yaml +1289 -0
- stage/data/companies/b.yaml +900 -0
- stage/data/companies/c.yaml +1377 -0
- stage/data/companies/d.yaml +497 -0
- stage/data/companies/e.yaml +519 -0
- stage/data/companies/f.yaml +454 -0
- stage/data/companies/g.yaml +601 -0
- stage/data/companies/h.yaml +446 -0
- stage/data/companies/i.yaml +503 -0
- stage/data/companies/j.yaml +138 -0
- stage/data/companies/k.yaml +278 -0
- stage/data/companies/l.yaml +402 -0
- stage/data/companies/m.yaml +937 -0
- stage/data/companies/n.yaml +549 -0
- stage/data/companies/o.yaml +371 -0
- stage/data/companies/other.yaml +58 -0
- stage/data/companies/p.yaml +825 -0
- stage/data/companies/q.yaml +121 -0
- stage/data/companies/r.yaml +583 -0
- stage/data/companies/s.yaml +1140 -0
- stage/data/companies/t.yaml +817 -0
- stage/data/companies/u.yaml +196 -0
- stage/data/companies/v.yaml +325 -0
- stage/data/companies/w.yaml +353 -0
- stage/data/companies/x.yaml +67 -0
- stage/data/companies/y.yaml +36 -0
- stage/data/companies/z.yaml +146 -0
- stage/data/fonts/DejaVuSans.LICENSE.txt +99 -0
- stage/data/fonts/DejaVuSans.ttf +0 -0
- stage/data/lexicon/company_tokens.yaml +228 -0
- stage/data/lexicon/eligibility.yaml +455 -0
- stage/data/lexicon/inclusive_suffixes.yaml +37 -0
- stage/data/lexicon/internship.yaml +187 -0
- stage/data/lexicon/language.yaml +226 -0
- stage/data/lexicon/locations.yaml +1159 -0
- stage/data/lexicon/roles.yaml +2012 -0
- stage/data/lexicon/terms.yaml +76 -0
- stage/data/lexicon/workday_facets.yaml +27 -0
- stage/data/seed_companies.yaml +198 -0
- stage/dedup/__init__.py +19 -0
- stage/dedup/identity.py +113 -0
- stage/dedup/resolve.py +97 -0
- stage/domain/__init__.py +244 -0
- stage/domain/company.py +49 -0
- stage/domain/coverage.py +86 -0
- stage/domain/custom_board.py +92 -0
- stage/domain/discovery.py +94 -0
- stage/domain/enums.py +114 -0
- stage/domain/events.py +204 -0
- stage/domain/filters.py +27 -0
- stage/domain/health.py +169 -0
- stage/domain/ids.py +48 -0
- stage/domain/job.py +47 -0
- stage/domain/matching.py +15 -0
- stage/domain/priority.py +34 -0
- stage/domain/quarantine.py +39 -0
- stage/domain/rate_state.py +78 -0
- stage/domain/retention.py +20 -0
- stage/domain/rotation.py +46 -0
- stage/domain/signals.py +12 -0
- stage/domain/sync_run.py +35 -0
- stage/domain/text.py +113 -0
- stage/domain/validator.py +14 -0
- stage/domain/visits.py +60 -0
- stage/domain/workday.py +38 -0
- stage/http/__init__.py +58 -0
- stage/http/breaker.py +53 -0
- stage/http/cache.py +44 -0
- stage/http/client.py +725 -0
- stage/http/profiles.py +101 -0
- stage/lexicon.py +370 -0
- stage/normalize/__init__.py +16 -0
- stage/normalize/language.py +47 -0
- stage/normalize/location.py +271 -0
- stage/normalize/terms.py +153 -0
- stage/normalize/urls.py +122 -0
- stage/paths.py +86 -0
- stage/py.typed +0 -0
- stage/services/__init__.py +0 -0
- stage/services/canary.py +120 -0
- stage/services/coverage.py +231 -0
- stage/services/discover.py +747 -0
- stage/services/export.py +274 -0
- stage/services/health.py +237 -0
- stage/services/maintenance.py +225 -0
- stage/services/quarantine.py +20 -0
- stage/services/query.py +86 -0
- stage/services/sync.py +1257 -0
- stage/sources/__init__.py +82 -0
- stage/sources/_text.py +79 -0
- stage/sources/ashby.py +93 -0
- stage/sources/bamboohr.py +80 -0
- stage/sources/base.py +225 -0
- stage/sources/breezy.py +90 -0
- stage/sources/collage.py +60 -0
- stage/sources/community_feeds.py +142 -0
- stage/sources/curated_markdown.py +289 -0
- stage/sources/custom_json.py +610 -0
- stage/sources/espresso.py +154 -0
- stage/sources/feed.py +44 -0
- stage/sources/greenhouse.py +104 -0
- stage/sources/jobbank.py +147 -0
- stage/sources/jobvite.py +133 -0
- stage/sources/lever.py +76 -0
- stage/sources/oracle_cloud.py +187 -0
- stage/sources/platforms.py +609 -0
- stage/sources/quebec_emploi.py +146 -0
- stage/sources/recruitee.py +96 -0
- stage/sources/simplify.py +110 -0
- stage/sources/smartrecruiters.py +216 -0
- stage/sources/speedyapply.py +200 -0
- stage/sources/themuse.py +157 -0
- stage/sources/workable.py +83 -0
- stage/sources/workday.py +524 -0
- stage/sources/zshah.py +99 -0
- stage/storage/__init__.py +29 -0
- stage/storage/migrations/0001_initial.sql +239 -0
- stage/storage/migrations/__init__.py +135 -0
- stage/storage/repository.py +213 -0
- stage/storage/search.py +28 -0
- stage/storage/sqlite_repo.py +1586 -0
- stage/storage/writer.py +249 -0
- stage/tui/__init__.py +0 -0
- stage/tui/app.py +82 -0
- stage/tui/help.py +26 -0
- stage/tui/safe.py +21 -0
- stage/tui/screens/__init__.py +0 -0
- stage/tui/screens/boards.py +186 -0
- stage/tui/screens/postings.py +509 -0
- stage/tui/screens/review.py +209 -0
- stage/tui/screens/splash.py +37 -0
- stage/tui/screens/stats.py +124 -0
- stage/tui/screens/sync.py +194 -0
- stage/tui/state.py +160 -0
- stage/tui/theme.tcss +205 -0
- stage/tui/widgets/__init__.py +0 -0
- stage_cli-1.0.0.dist-info/METADATA +379 -0
- stage_cli-1.0.0.dist-info/RECORD +170 -0
- stage_cli-1.0.0.dist-info/WHEEL +4 -0
- stage_cli-1.0.0.dist-info/entry_points.txt +2 -0
- stage_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
stage/sources/collage.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
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, 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, NullableStr
|
|
10
|
+
|
|
11
|
+
HOST = "api.collage.co"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CollagePosition(BaseModel):
|
|
15
|
+
model_config = ConfigDict(extra="ignore")
|
|
16
|
+
|
|
17
|
+
id: int
|
|
18
|
+
title: str
|
|
19
|
+
location: NullableStr = ""
|
|
20
|
+
descriptionPlain: NullableStr = ""
|
|
21
|
+
applyUrl: NullableStr = ""
|
|
22
|
+
hostedUrl: NullableStr = ""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CollageBoard(BaseModel):
|
|
26
|
+
model_config = ConfigDict(extra="ignore")
|
|
27
|
+
|
|
28
|
+
positions: list[Any] = Field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@register
|
|
32
|
+
class CollageAdapter(BoardAdapter):
|
|
33
|
+
name: ClassVar[str] = "collage"
|
|
34
|
+
platform: ClassVar[Platform] = Platform.COLLAGE
|
|
35
|
+
rate_profile: ClassVar[str] = "moderate"
|
|
36
|
+
hosts: ClassVar[frozenset[str]] = frozenset({HOST})
|
|
37
|
+
bucket_key: ClassVar[str] = "collage"
|
|
38
|
+
detail_budget: ClassVar[int] = 0
|
|
39
|
+
max_requests_per_company: ClassVar[int] = 1
|
|
40
|
+
|
|
41
|
+
base_url: ClassVar[str] = "https://api.collage.co/v1/positions/{slug}"
|
|
42
|
+
root_model: ClassVar[type[BaseModel] | None] = CollageBoard
|
|
43
|
+
rows_field: ClassVar[str] = "positions"
|
|
44
|
+
row_model: ClassVar[type[BaseModel]] = CollagePosition
|
|
45
|
+
|
|
46
|
+
def to_job(self, company: Company, row: Any, now: datetime) -> Job:
|
|
47
|
+
apply_url = row.applyUrl or row.hostedUrl
|
|
48
|
+
title = collapse_whitespace(row.title)
|
|
49
|
+
return Job(
|
|
50
|
+
id=job_id(self.name, company.slug, str(row.id)),
|
|
51
|
+
source=self.name,
|
|
52
|
+
company=company.name,
|
|
53
|
+
title_raw=title,
|
|
54
|
+
title_normalized=title.lower(),
|
|
55
|
+
apply_url_raw=apply_url,
|
|
56
|
+
description=collapse_whitespace(strip_html(row.descriptionPlain)),
|
|
57
|
+
location_raw=collapse_whitespace(row.location),
|
|
58
|
+
first_seen=now,
|
|
59
|
+
last_seen=now,
|
|
60
|
+
)
|
|
@@ -0,0 +1,142 @@
|
|
|
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 Job, SourceSignals, job_id
|
|
7
|
+
from stage.http import HttpClient
|
|
8
|
+
from stage.sources._text import collapse_whitespace
|
|
9
|
+
from stage.sources.base import (
|
|
10
|
+
FetchResult,
|
|
11
|
+
NonEmptyStr,
|
|
12
|
+
PayloadValidationError,
|
|
13
|
+
capture_payload,
|
|
14
|
+
convert_rows,
|
|
15
|
+
malformed_note,
|
|
16
|
+
validate_rows,
|
|
17
|
+
)
|
|
18
|
+
from stage.sources.feed import register_feed, upcoming_season_year
|
|
19
|
+
|
|
20
|
+
HOST = "raw.githubusercontent.com"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CommunityListing(BaseModel):
|
|
24
|
+
model_config = ConfigDict(extra="ignore")
|
|
25
|
+
|
|
26
|
+
id: str = ""
|
|
27
|
+
company_name: NonEmptyStr
|
|
28
|
+
title: NonEmptyStr
|
|
29
|
+
url: str = ""
|
|
30
|
+
locations: list[str] = Field(default_factory=list)
|
|
31
|
+
active: bool = True
|
|
32
|
+
is_visible: bool = True
|
|
33
|
+
date_posted: int | None = None
|
|
34
|
+
season: str = ""
|
|
35
|
+
sponsorship: str = ""
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def identity(self) -> str:
|
|
39
|
+
return self.id or f"{self.company_name}:{self.title}:{self.url}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _CommunityFeed:
|
|
43
|
+
name: ClassVar[str] = ""
|
|
44
|
+
rate_profile: ClassVar[str] = "feeds"
|
|
45
|
+
hosts: ClassVar[frozenset[str]] = frozenset({HOST})
|
|
46
|
+
bucket_key: ClassVar[str] = ""
|
|
47
|
+
templates: ClassVar[tuple[str, ...]] = ()
|
|
48
|
+
|
|
49
|
+
def season_year(self, now: datetime) -> int:
|
|
50
|
+
return upcoming_season_year(now)
|
|
51
|
+
|
|
52
|
+
def plan(self, now: datetime) -> tuple[str, ...]:
|
|
53
|
+
year = self.season_year(now)
|
|
54
|
+
return tuple(template.format(year=year) for template in self.templates)
|
|
55
|
+
|
|
56
|
+
async def fetch(self, client: HttpClient, now: datetime) -> FetchResult:
|
|
57
|
+
jobs: list[Job] = []
|
|
58
|
+
modified = False
|
|
59
|
+
failures: list[str] = []
|
|
60
|
+
stale: list[str] = []
|
|
61
|
+
malformed = 0
|
|
62
|
+
for url in self.plan(now):
|
|
63
|
+
try:
|
|
64
|
+
response = await client.get_json(url)
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
failures.append(f"{url}: {type(exc).__name__}")
|
|
67
|
+
stale.append(url)
|
|
68
|
+
continue
|
|
69
|
+
if response.not_modified:
|
|
70
|
+
continue
|
|
71
|
+
modified = True
|
|
72
|
+
listings, dropped = self._validate(response.payload, url, now)
|
|
73
|
+
converted, unconvertible = convert_rows(
|
|
74
|
+
lambda listing: self._to_job(listing, now),
|
|
75
|
+
[listing for listing in listings if listing.active and listing.is_visible],
|
|
76
|
+
source=self.name,
|
|
77
|
+
slug=str(self.season_year(now)),
|
|
78
|
+
)
|
|
79
|
+
malformed += dropped + unconvertible
|
|
80
|
+
jobs.extend(converted)
|
|
81
|
+
if failures and not jobs:
|
|
82
|
+
raise PayloadValidationError(f"{self.name}: every file failed — {'; '.join(failures)}")
|
|
83
|
+
if not modified and not failures:
|
|
84
|
+
return FetchResult(not_modified=True)
|
|
85
|
+
notes = []
|
|
86
|
+
if failures:
|
|
87
|
+
notes.append(f"{len(failures)} of {len(self.plan(now))} file(s) unavailable")
|
|
88
|
+
if malformed:
|
|
89
|
+
notes.append(malformed_note(malformed))
|
|
90
|
+
return FetchResult(
|
|
91
|
+
jobs=tuple(jobs),
|
|
92
|
+
degraded="; ".join(notes),
|
|
93
|
+
authoritative=not (failures or malformed),
|
|
94
|
+
stale_urls=tuple(stale),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def _validate(
|
|
98
|
+
self, payload: Any, url: str, now: datetime
|
|
99
|
+
) -> tuple[list[CommunityListing], int]:
|
|
100
|
+
rows = payload.get("listings") if isinstance(payload, dict) else payload
|
|
101
|
+
if not isinstance(rows, list):
|
|
102
|
+
captured = capture_payload(self.name, str(self.season_year(now)), payload)
|
|
103
|
+
raise PayloadValidationError(
|
|
104
|
+
f"{self.name}/{url}: payload failed validation (expected a JSON list of "
|
|
105
|
+
f"listings); raw payload captured at {captured}"
|
|
106
|
+
)
|
|
107
|
+
return validate_rows(
|
|
108
|
+
CommunityListing, rows, source=self.name, slug=str(self.season_year(now))
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def _to_job(self, listing: CommunityListing, now: datetime) -> Job:
|
|
112
|
+
posted = (
|
|
113
|
+
datetime.fromtimestamp(listing.date_posted, tz=now.tzinfo)
|
|
114
|
+
if listing.date_posted
|
|
115
|
+
else None
|
|
116
|
+
)
|
|
117
|
+
return Job(
|
|
118
|
+
id=job_id(self.name, listing.company_name, listing.identity),
|
|
119
|
+
source=self.name,
|
|
120
|
+
company=collapse_whitespace(listing.company_name),
|
|
121
|
+
title_raw=listing.title,
|
|
122
|
+
title_normalized=collapse_whitespace(listing.title),
|
|
123
|
+
apply_url_raw=listing.url,
|
|
124
|
+
description="",
|
|
125
|
+
location_raw=collapse_whitespace(" / ".join(listing.locations)),
|
|
126
|
+
first_seen=now,
|
|
127
|
+
last_seen=now,
|
|
128
|
+
source_posted_at=posted,
|
|
129
|
+
signals=SourceSignals(
|
|
130
|
+
season=listing.season,
|
|
131
|
+
sponsorship=listing.sponsorship,
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@register_feed
|
|
137
|
+
class VanshFeed(_CommunityFeed):
|
|
138
|
+
name: ClassVar[str] = "vanshb03"
|
|
139
|
+
templates: ClassVar[tuple[str, ...]] = (
|
|
140
|
+
"https://raw.githubusercontent.com/vanshb03/Summer{year}-Internships/dev/"
|
|
141
|
+
".github/scripts/listings.json",
|
|
142
|
+
)
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Any, ClassVar
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict
|
|
11
|
+
|
|
12
|
+
from stage.domain import Job, SourceSignals, job_id
|
|
13
|
+
from stage.http import HttpClient, HttpStatusError, JsonResponse
|
|
14
|
+
from stage.sources._text import collapse_whitespace, strip_html
|
|
15
|
+
from stage.sources.base import FetchResult, NonEmptyStr, PayloadValidationError, capture_payload
|
|
16
|
+
from stage.sources.feed import register_feed, upcoming_season_year
|
|
17
|
+
|
|
18
|
+
HOST = "api.github.com"
|
|
19
|
+
CONTENT_URL = "https://api.github.com/repos/{repository}/contents/{path}?ref=main"
|
|
20
|
+
LINK = re.compile(r"\[([^\]]*)\]\(\s*<?(?P<url>https?://[^)\s<>]+)>?\s*\)")
|
|
21
|
+
URL = re.compile(r"\]\(\s*<?(?P<url>https?://[^)\s<>]+)>?\s*\)")
|
|
22
|
+
COMMENT = re.compile(r"<!--.*?-->", re.DOTALL)
|
|
23
|
+
INTERN = re.compile(r"\b(?:intern(?:ship)?s?|co-?op|stagiaire|stage|alternance|student)\b", re.I)
|
|
24
|
+
DECLARED_EMPTY = re.compile(r"_\s*no\s+(?:roles|positions|openings|jobs)[^_\n]{0,40}_", re.I)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class GitHubContent(BaseModel):
|
|
28
|
+
model_config = ConfigDict(extra="ignore")
|
|
29
|
+
|
|
30
|
+
encoding: NonEmptyStr
|
|
31
|
+
content: NonEmptyStr
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class Listing:
|
|
36
|
+
company: str
|
|
37
|
+
title: str
|
|
38
|
+
location: str
|
|
39
|
+
url: str
|
|
40
|
+
description: str = ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _plain(value: str) -> str:
|
|
44
|
+
text = COMMENT.sub("", value)
|
|
45
|
+
text = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", text)
|
|
46
|
+
text = text.replace("**", "").replace("`", "")
|
|
47
|
+
return collapse_whitespace(strip_html(text)).strip("| ")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _cells(line: str) -> list[str] | None:
|
|
51
|
+
stripped = line.strip()
|
|
52
|
+
if not stripped.startswith("|") or not stripped.endswith("|"):
|
|
53
|
+
return None
|
|
54
|
+
cells = [cell.strip() for cell in stripped[1:-1].split("|")]
|
|
55
|
+
if not cells or all(set(cell) <= {"-", ":", " "} for cell in cells):
|
|
56
|
+
return None
|
|
57
|
+
return cells
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _links(value: str) -> tuple[tuple[str, str], ...]:
|
|
61
|
+
return tuple((match.group(1), match.group("url")) for match in LINK.finditer(value))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _urls(value: str) -> tuple[str, ...]:
|
|
65
|
+
return tuple(match.group("url") for match in URL.finditer(value))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _GitHubMarkdownFeed:
|
|
69
|
+
name: ClassVar[str] = ""
|
|
70
|
+
rate_profile: ClassVar[str] = "feeds"
|
|
71
|
+
hosts: ClassVar[frozenset[str]] = frozenset({HOST})
|
|
72
|
+
bucket_key: ClassVar[str] = ""
|
|
73
|
+
|
|
74
|
+
def season_year(self, now: datetime) -> int:
|
|
75
|
+
return upcoming_season_year(now)
|
|
76
|
+
|
|
77
|
+
def plan(self, now: datetime) -> tuple[str, ...]:
|
|
78
|
+
raise NotImplementedError
|
|
79
|
+
|
|
80
|
+
async def _available(
|
|
81
|
+
self, client: HttpClient, urls: tuple[str, ...]
|
|
82
|
+
) -> tuple[str, JsonResponse]:
|
|
83
|
+
missing: HttpStatusError | None = None
|
|
84
|
+
for url in urls:
|
|
85
|
+
try:
|
|
86
|
+
return url, await client.get_json(url)
|
|
87
|
+
except HttpStatusError as exc:
|
|
88
|
+
if exc.status != 404:
|
|
89
|
+
raise
|
|
90
|
+
missing = exc
|
|
91
|
+
if missing is None:
|
|
92
|
+
raise PayloadValidationError(f"{self.name}: no feed url was planned")
|
|
93
|
+
raise missing
|
|
94
|
+
|
|
95
|
+
async def fetch(self, client: HttpClient, now: datetime) -> FetchResult:
|
|
96
|
+
url, response = await self._available(client, self.plan(now))
|
|
97
|
+
if response.not_modified:
|
|
98
|
+
return FetchResult(not_modified=True)
|
|
99
|
+
text = self._decode(response.payload, url, now)
|
|
100
|
+
listings, malformed = self.rows(text)
|
|
101
|
+
if not listings:
|
|
102
|
+
if not DECLARED_EMPTY.search(text):
|
|
103
|
+
captured = capture_payload(self.name, str(self.season_year(now)), {"text": text})
|
|
104
|
+
raise PayloadValidationError(
|
|
105
|
+
f"{self.name}/{url}: no current internship rows were found "
|
|
106
|
+
f"(captured {captured})"
|
|
107
|
+
)
|
|
108
|
+
return FetchResult(
|
|
109
|
+
degraded=f"{self.name} publishes no roles right now; the feed closes nothing",
|
|
110
|
+
authoritative=False,
|
|
111
|
+
)
|
|
112
|
+
jobs = tuple(self._to_job(listing, now) for listing in listings)
|
|
113
|
+
return FetchResult(
|
|
114
|
+
jobs=jobs,
|
|
115
|
+
degraded=(
|
|
116
|
+
f"{malformed} malformed Markdown row(s) were skipped; the feed closes nothing"
|
|
117
|
+
if malformed
|
|
118
|
+
else ""
|
|
119
|
+
),
|
|
120
|
+
authoritative=not malformed,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def _decode(self, payload: Any, url: str, now: datetime) -> str:
|
|
124
|
+
try:
|
|
125
|
+
content = GitHubContent.model_validate(payload)
|
|
126
|
+
except Exception as exc:
|
|
127
|
+
captured = capture_payload(self.name, str(self.season_year(now)), payload)
|
|
128
|
+
raise PayloadValidationError(
|
|
129
|
+
f"{self.name}/{url}: GitHub content envelope failed validation "
|
|
130
|
+
f"(captured {captured})"
|
|
131
|
+
) from exc
|
|
132
|
+
if content.encoding != "base64":
|
|
133
|
+
captured = capture_payload(self.name, str(self.season_year(now)), payload)
|
|
134
|
+
raise PayloadValidationError(
|
|
135
|
+
f"{self.name}/{url}: unsupported content encoding {content.encoding!r} "
|
|
136
|
+
f"(captured {captured})"
|
|
137
|
+
)
|
|
138
|
+
try:
|
|
139
|
+
return base64.b64decode("".join(content.content.split()), validate=True).decode("utf-8")
|
|
140
|
+
except (UnicodeDecodeError, binascii.Error, ValueError) as exc:
|
|
141
|
+
captured = capture_payload(self.name, str(self.season_year(now)), payload)
|
|
142
|
+
raise PayloadValidationError(
|
|
143
|
+
f"{self.name}/{url}: content is not valid base64 UTF-8 (captured {captured})"
|
|
144
|
+
) from exc
|
|
145
|
+
|
|
146
|
+
def rows(self, text: str) -> tuple[list[Listing], int]:
|
|
147
|
+
raise NotImplementedError
|
|
148
|
+
|
|
149
|
+
def _to_job(self, listing: Listing, now: datetime) -> Job:
|
|
150
|
+
return Job(
|
|
151
|
+
id=job_id(
|
|
152
|
+
self.name, listing.company, f"{listing.title}:{listing.location}:{listing.url}"
|
|
153
|
+
),
|
|
154
|
+
source=self.name,
|
|
155
|
+
company=listing.company,
|
|
156
|
+
title_raw=listing.title,
|
|
157
|
+
title_normalized=listing.title,
|
|
158
|
+
apply_url_raw=listing.url,
|
|
159
|
+
description=listing.description,
|
|
160
|
+
location_raw=listing.location,
|
|
161
|
+
first_seen=now,
|
|
162
|
+
last_seen=now,
|
|
163
|
+
signals=SourceSignals(employment_type="internship"),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@register_feed
|
|
168
|
+
class NegarFeed(_GitHubMarkdownFeed):
|
|
169
|
+
name: ClassVar[str] = "negar"
|
|
170
|
+
|
|
171
|
+
def plan(self, now: datetime) -> tuple[str, ...]:
|
|
172
|
+
repository = "negarprh/Canadian-Tech-Internships-2026"
|
|
173
|
+
return (
|
|
174
|
+
CONTENT_URL.format(repository=repository, path=f"README-{self.season_year(now)}.md"),
|
|
175
|
+
CONTENT_URL.format(repository=repository, path="README.md"),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def rows(self, text: str) -> tuple[list[Listing], int]:
|
|
179
|
+
rows: list[Listing] = []
|
|
180
|
+
malformed = 0
|
|
181
|
+
company = ""
|
|
182
|
+
for line in text.splitlines():
|
|
183
|
+
cells = _cells(line)
|
|
184
|
+
if cells is None or len(cells) != 5 or _plain(cells[0]).casefold() == "company":
|
|
185
|
+
continue
|
|
186
|
+
candidate = _plain(cells[0])
|
|
187
|
+
if candidate and candidate != "↳":
|
|
188
|
+
company = candidate
|
|
189
|
+
urls = _urls(cells[3])
|
|
190
|
+
if not company or not urls:
|
|
191
|
+
if cells[3].strip() and "closed" not in cells[3].casefold():
|
|
192
|
+
malformed += 1
|
|
193
|
+
continue
|
|
194
|
+
rows.append(
|
|
195
|
+
Listing(
|
|
196
|
+
company=company,
|
|
197
|
+
title=_plain(cells[1]),
|
|
198
|
+
location=_plain(cells[2]),
|
|
199
|
+
url=urls[-1],
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
return rows, malformed
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@register_feed
|
|
206
|
+
class HanziliFeed(_GitHubMarkdownFeed):
|
|
207
|
+
name: ClassVar[str] = "hanzili"
|
|
208
|
+
|
|
209
|
+
def plan(self, now: datetime) -> tuple[str, ...]:
|
|
210
|
+
return (
|
|
211
|
+
CONTENT_URL.format(repository="hanzili/canada_sde_intern_position", path="README.md"),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def rows(self, text: str) -> tuple[list[Listing], int]:
|
|
215
|
+
rows: list[Listing] = []
|
|
216
|
+
malformed = 0
|
|
217
|
+
for line in text.splitlines():
|
|
218
|
+
cells = _cells(line)
|
|
219
|
+
if cells is None or len(cells) != 7 or _plain(cells[0]).casefold() == "title":
|
|
220
|
+
continue
|
|
221
|
+
title, company, description, _, details, location, apply = cells
|
|
222
|
+
if not INTERN.search(f"{title} {details}"):
|
|
223
|
+
continue
|
|
224
|
+
urls = _urls(apply)
|
|
225
|
+
if not urls:
|
|
226
|
+
malformed += 1
|
|
227
|
+
continue
|
|
228
|
+
rows.append(
|
|
229
|
+
Listing(
|
|
230
|
+
company=_plain(company),
|
|
231
|
+
title=_plain(title),
|
|
232
|
+
location=_plain(location),
|
|
233
|
+
url=urls[-1],
|
|
234
|
+
description=_plain(description),
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
return rows, malformed
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@register_feed
|
|
241
|
+
class NorthwesternQuantFeed(_GitHubMarkdownFeed):
|
|
242
|
+
name: ClassVar[str] = "northwestern-quant"
|
|
243
|
+
|
|
244
|
+
def plan(self, now: datetime) -> tuple[str, ...]:
|
|
245
|
+
return (
|
|
246
|
+
CONTENT_URL.format(
|
|
247
|
+
repository=f"northwesternfintech/{self.season_year(now)}QuantInternships",
|
|
248
|
+
path="README.md",
|
|
249
|
+
),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def rows(self, text: str) -> tuple[list[Listing], int]:
|
|
253
|
+
rows: list[Listing] = []
|
|
254
|
+
malformed = 0
|
|
255
|
+
company = ""
|
|
256
|
+
location = ""
|
|
257
|
+
titles = {
|
|
258
|
+
"qd": "Quantitative Developer Intern",
|
|
259
|
+
"qr": "Quantitative Research Intern",
|
|
260
|
+
"qt": "Quantitative Trader Intern",
|
|
261
|
+
"swe": "Software Engineer Intern",
|
|
262
|
+
"ml": "Machine Learning Intern",
|
|
263
|
+
"hw": "Hardware Engineer Intern",
|
|
264
|
+
"fpga": "FPGA Engineer Intern",
|
|
265
|
+
"devops/sre": "DevOps/SRE Intern",
|
|
266
|
+
}
|
|
267
|
+
for line in text.splitlines():
|
|
268
|
+
if line.startswith("## "):
|
|
269
|
+
company = _plain(line[3:])
|
|
270
|
+
location = ""
|
|
271
|
+
continue
|
|
272
|
+
if line.startswith("**Locations**:"):
|
|
273
|
+
location = _plain(line.partition(":")[2])
|
|
274
|
+
continue
|
|
275
|
+
cells = _cells(line)
|
|
276
|
+
if cells is None or len(cells) != 2 or _plain(cells[0]).casefold() == "role":
|
|
277
|
+
continue
|
|
278
|
+
role = _plain(cells[0])
|
|
279
|
+
base = titles.get(role.casefold())
|
|
280
|
+
if not company or base is None or "fellowship" in role.casefold():
|
|
281
|
+
continue
|
|
282
|
+
links = _links(cells[1])
|
|
283
|
+
if not links and cells[1].strip():
|
|
284
|
+
malformed += 1
|
|
285
|
+
for label, url in links:
|
|
286
|
+
qualifier = _plain(label).replace("✅", "").strip()
|
|
287
|
+
title = f"{base} — {qualifier}" if qualifier else base
|
|
288
|
+
rows.append(Listing(company=company, title=title, location=location, url=url))
|
|
289
|
+
return rows, malformed
|