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,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