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,610 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from html import unescape
|
|
6
|
+
from typing import Any, ClassVar
|
|
7
|
+
from urllib.parse import parse_qsl, urlencode, urljoin, urlsplit, urlunsplit
|
|
8
|
+
|
|
9
|
+
from bs4 import BeautifulSoup
|
|
10
|
+
from bs4.element import Tag
|
|
11
|
+
from pydantic import BaseModel, ConfigDict
|
|
12
|
+
|
|
13
|
+
from stage.domain import Company, CustomBoard, Job, Platform, SourceSignals, board_key, job_id
|
|
14
|
+
from stage.http import HttpClient
|
|
15
|
+
from stage.http.client import HostBudgetExceededError
|
|
16
|
+
from stage.lexicon import fold, location_lexicon
|
|
17
|
+
from stage.sources import register
|
|
18
|
+
from stage.sources._text import collapse_whitespace, strip_html
|
|
19
|
+
from stage.sources.base import (
|
|
20
|
+
FetchResult,
|
|
21
|
+
NonEmptyStr,
|
|
22
|
+
PayloadValidationError,
|
|
23
|
+
capture_payload,
|
|
24
|
+
convert_rows,
|
|
25
|
+
malformed_note,
|
|
26
|
+
validate_rows,
|
|
27
|
+
)
|
|
28
|
+
from stage.sources.platforms import dig
|
|
29
|
+
|
|
30
|
+
MAX_ROWS = 5000
|
|
31
|
+
MAX_EXTRACT_BYTES = 8 * 1024 * 1024
|
|
32
|
+
MAX_RSS_ITEMS = 20000
|
|
33
|
+
MAX_TOKEN_LEN = 4096
|
|
34
|
+
MAX_HTML_ROWS = 2000
|
|
35
|
+
|
|
36
|
+
MAX_PATH_SEGMENTS = 8
|
|
37
|
+
MAX_PAGES = 20
|
|
38
|
+
PAGE_CEILING = 200
|
|
39
|
+
_JSON_PARSE = 'JSON.parse("'
|
|
40
|
+
_UNCHANGED = object()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CustomListing(BaseModel):
|
|
44
|
+
model_config = ConfigDict(extra="ignore")
|
|
45
|
+
|
|
46
|
+
id: NonEmptyStr
|
|
47
|
+
title: NonEmptyStr
|
|
48
|
+
location: str = ""
|
|
49
|
+
url: str = ""
|
|
50
|
+
description: str = ""
|
|
51
|
+
department: str = ""
|
|
52
|
+
employment_type: str = ""
|
|
53
|
+
category: str = ""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _host(url: str) -> str:
|
|
57
|
+
try:
|
|
58
|
+
return (urlsplit(url).hostname or "").lower()
|
|
59
|
+
except ValueError:
|
|
60
|
+
return ""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _rows(payload: Any, board: CustomBoard) -> Any:
|
|
64
|
+
return dig(payload, board.jobs_path) if board.jobs_path else payload
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _page_body(board: CustomBoard, index: int) -> dict[str, Any]:
|
|
68
|
+
body = dict(board.body)
|
|
69
|
+
if not board.paginated:
|
|
70
|
+
return body
|
|
71
|
+
parts = board.page_param.split(".")
|
|
72
|
+
branch: dict[str, Any] = body
|
|
73
|
+
for part in parts[:-1]:
|
|
74
|
+
nested = branch.get(part)
|
|
75
|
+
branch[part] = dict(nested) if isinstance(nested, Mapping) else {}
|
|
76
|
+
branch = branch[part]
|
|
77
|
+
branch[parts[-1]] = board.page_value(index)
|
|
78
|
+
return body
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _page_url(board: CustomBoard, index: int) -> str:
|
|
82
|
+
if not board.paginated or board.posts:
|
|
83
|
+
return board.url
|
|
84
|
+
parts = urlsplit(board.url)
|
|
85
|
+
query = [(key, value) for key, value in parse_qsl(parts.query, keep_blank_values=True)]
|
|
86
|
+
query = [(key, value) for key, value in query if key != board.page_param]
|
|
87
|
+
query.append((board.page_param, str(board.page_value(index))))
|
|
88
|
+
return urlunsplit(parts._replace(query=urlencode(query)))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _js_string_object(text: str, quote: int) -> Any:
|
|
92
|
+
limit = min(len(text), quote + MAX_EXTRACT_BYTES)
|
|
93
|
+
index = quote + 1
|
|
94
|
+
while index < limit:
|
|
95
|
+
char = text[index]
|
|
96
|
+
if char == "\\":
|
|
97
|
+
index += 2
|
|
98
|
+
continue
|
|
99
|
+
if char == '"':
|
|
100
|
+
try:
|
|
101
|
+
return json.loads(json.loads(text[quote : index + 1]))
|
|
102
|
+
except ValueError:
|
|
103
|
+
return None
|
|
104
|
+
index += 1
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def extract_object(text: str, marker: str) -> Any:
|
|
109
|
+
start = text.find(marker)
|
|
110
|
+
if start < 0:
|
|
111
|
+
return None
|
|
112
|
+
brace = text.find("{", start + len(marker))
|
|
113
|
+
wrapped = text.find(_JSON_PARSE, start + len(marker))
|
|
114
|
+
if wrapped >= 0 and (brace < 0 or wrapped < brace):
|
|
115
|
+
return _js_string_object(text, wrapped + len(_JSON_PARSE) - 1)
|
|
116
|
+
if brace < 0 or len(text) - brace > MAX_EXTRACT_BYTES:
|
|
117
|
+
return None
|
|
118
|
+
depth = 0
|
|
119
|
+
in_string = False
|
|
120
|
+
escaped = False
|
|
121
|
+
for index in range(brace, min(len(text), brace + MAX_EXTRACT_BYTES)):
|
|
122
|
+
char = text[index]
|
|
123
|
+
if in_string:
|
|
124
|
+
if escaped:
|
|
125
|
+
escaped = False
|
|
126
|
+
elif char == "\\":
|
|
127
|
+
escaped = True
|
|
128
|
+
elif char == '"':
|
|
129
|
+
in_string = False
|
|
130
|
+
continue
|
|
131
|
+
if char == '"':
|
|
132
|
+
in_string = True
|
|
133
|
+
elif char == "{":
|
|
134
|
+
depth += 1
|
|
135
|
+
elif char == "}":
|
|
136
|
+
depth -= 1
|
|
137
|
+
if depth == 0:
|
|
138
|
+
try:
|
|
139
|
+
return json.loads(text[brace : index + 1])
|
|
140
|
+
except ValueError:
|
|
141
|
+
return None
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _tag_text(item: str, start: int) -> tuple[str, str, int] | None:
|
|
146
|
+
open_at = item.find("<", start)
|
|
147
|
+
if open_at < 0:
|
|
148
|
+
return None
|
|
149
|
+
close_at = item.find(">", open_at)
|
|
150
|
+
if close_at < 0:
|
|
151
|
+
return None
|
|
152
|
+
name = item[open_at + 1 : close_at].split()[0] if close_at > open_at + 1 else ""
|
|
153
|
+
if not name or name.startswith(("/", "?", "!")):
|
|
154
|
+
return "", "", close_at + 1
|
|
155
|
+
end = item.find(f"</{name}>", close_at)
|
|
156
|
+
if end < 0:
|
|
157
|
+
return "", "", close_at + 1
|
|
158
|
+
body = item[close_at + 1 : end]
|
|
159
|
+
if body.startswith("<![CDATA["):
|
|
160
|
+
return name, body[9:].removesuffix("]]>").strip(), end + len(name) + 3
|
|
161
|
+
return name, unescape(body).strip(), end + len(name) + 3
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def rss_items(text: str, tag: str = "item") -> list[dict[str, str]]:
|
|
165
|
+
opening, closing = f"<{tag}>", f"</{tag}>"
|
|
166
|
+
rows: list[dict[str, str]] = []
|
|
167
|
+
cursor = 0
|
|
168
|
+
while len(rows) < MAX_RSS_ITEMS:
|
|
169
|
+
start = text.find(opening, cursor)
|
|
170
|
+
if start < 0:
|
|
171
|
+
break
|
|
172
|
+
end = text.find(closing, start)
|
|
173
|
+
if end < 0:
|
|
174
|
+
break
|
|
175
|
+
item = text[start + len(opening) : end]
|
|
176
|
+
cursor = end + len(closing)
|
|
177
|
+
row: dict[str, str] = {}
|
|
178
|
+
at = 0
|
|
179
|
+
while True:
|
|
180
|
+
step = _tag_text(item, at)
|
|
181
|
+
if step is None:
|
|
182
|
+
break
|
|
183
|
+
name, body, at = step
|
|
184
|
+
if name and body:
|
|
185
|
+
row.setdefault(name, body)
|
|
186
|
+
if row:
|
|
187
|
+
rows.append(row)
|
|
188
|
+
return rows
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _job_postings(block: str) -> list[dict[str, Any]]:
|
|
192
|
+
try:
|
|
193
|
+
payload = json.loads(block)
|
|
194
|
+
except ValueError:
|
|
195
|
+
return []
|
|
196
|
+
pending = [payload]
|
|
197
|
+
found: list[dict[str, Any]] = []
|
|
198
|
+
while pending and len(found) < MAX_HTML_ROWS:
|
|
199
|
+
entry = pending.pop()
|
|
200
|
+
if isinstance(entry, list):
|
|
201
|
+
pending.extend(entry)
|
|
202
|
+
elif isinstance(entry, dict):
|
|
203
|
+
graph = entry.get("@graph")
|
|
204
|
+
if isinstance(graph, list):
|
|
205
|
+
pending.extend(graph)
|
|
206
|
+
if entry.get("@type") == "JobPosting":
|
|
207
|
+
found.append(entry)
|
|
208
|
+
return found
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def jsonld_rows(text: str) -> list[dict[str, Any]]:
|
|
212
|
+
rows: list[dict[str, Any]] = []
|
|
213
|
+
cursor = 0
|
|
214
|
+
while len(rows) < MAX_HTML_ROWS:
|
|
215
|
+
start = text.find("<script", cursor)
|
|
216
|
+
if start < 0:
|
|
217
|
+
break
|
|
218
|
+
opened = text.find(">", start)
|
|
219
|
+
if opened < 0:
|
|
220
|
+
break
|
|
221
|
+
head = text[start:opened]
|
|
222
|
+
closed = text.find("</script>", opened)
|
|
223
|
+
if closed < 0:
|
|
224
|
+
break
|
|
225
|
+
if "application/ld+json" in head:
|
|
226
|
+
rows.extend(_job_postings(text[opened + 1 : min(closed, opened + MAX_EXTRACT_BYTES)]))
|
|
227
|
+
cursor = closed + 9
|
|
228
|
+
return rows
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _segment_title(part: str) -> str:
|
|
232
|
+
words = part.replace("-", " ").split()
|
|
233
|
+
if not words:
|
|
234
|
+
return ""
|
|
235
|
+
lexicon = location_lexicon()
|
|
236
|
+
titled = [word.title() for word in words]
|
|
237
|
+
tail = fold(words[-1])
|
|
238
|
+
if len(tail) == 2 and (tail in lexicon.usa_codes or tail in lexicon.canada_codes):
|
|
239
|
+
titled[-1] = tail.upper()
|
|
240
|
+
return " ".join(titled).strip()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def sitemap_rows(text: str) -> list[dict[str, str]]:
|
|
244
|
+
rows: list[dict[str, str]] = []
|
|
245
|
+
cursor = 0
|
|
246
|
+
while len(rows) < MAX_RSS_ITEMS:
|
|
247
|
+
start = text.find("<url>", cursor)
|
|
248
|
+
if start < 0:
|
|
249
|
+
break
|
|
250
|
+
end = text.find("</url>", start)
|
|
251
|
+
if end < 0:
|
|
252
|
+
break
|
|
253
|
+
entry = text[start + 5 : end]
|
|
254
|
+
cursor = end + 6
|
|
255
|
+
loc = ""
|
|
256
|
+
at = 0
|
|
257
|
+
while True:
|
|
258
|
+
step = _tag_text(entry, at)
|
|
259
|
+
if step is None:
|
|
260
|
+
break
|
|
261
|
+
name, body, at = step
|
|
262
|
+
if name.endswith("loc") and body:
|
|
263
|
+
loc = body
|
|
264
|
+
break
|
|
265
|
+
if not loc:
|
|
266
|
+
continue
|
|
267
|
+
tail = loc.rstrip("/").rsplit("/", 1)[-1]
|
|
268
|
+
slug, _, ident = tail.rpartition("_")
|
|
269
|
+
if not slug:
|
|
270
|
+
slug, ident = tail, tail
|
|
271
|
+
row = {
|
|
272
|
+
"loc": loc,
|
|
273
|
+
"id": ident,
|
|
274
|
+
"slug": slug,
|
|
275
|
+
"title": slug.replace("-", " ").strip().title(),
|
|
276
|
+
}
|
|
277
|
+
segments = [
|
|
278
|
+
part for part in loc.split("?", 1)[0].split("#", 1)[0].rstrip("/").split("/") if part
|
|
279
|
+
]
|
|
280
|
+
for offset, part in enumerate(reversed(segments[-MAX_PATH_SEGMENTS:]), start=1):
|
|
281
|
+
row[f"path{offset}"] = part
|
|
282
|
+
row[f"path{offset}_title"] = _segment_title(part)
|
|
283
|
+
rows.append(row)
|
|
284
|
+
return rows
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _fingerprint(page: list[Any], board: CustomBoard) -> set[str]:
|
|
288
|
+
marks = set()
|
|
289
|
+
for entry in page:
|
|
290
|
+
projected = _project(entry, board)
|
|
291
|
+
mark = projected.get("id") or projected.get("title", "")
|
|
292
|
+
if mark:
|
|
293
|
+
marks.add(mark)
|
|
294
|
+
return marks
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _slug_parts(url: str) -> tuple[str, str]:
|
|
298
|
+
tail = url.split("?", 1)[0].split("#", 1)[0].rstrip("/").rsplit("/", 1)[-1]
|
|
299
|
+
slug, _, ident = tail.rpartition("_")
|
|
300
|
+
if not slug:
|
|
301
|
+
slug, ident = tail, tail
|
|
302
|
+
return slug, ident
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _html_value(block: Tag, selector: str, *, base: str = "") -> str:
|
|
306
|
+
mode = "text"
|
|
307
|
+
attr = ""
|
|
308
|
+
css = selector
|
|
309
|
+
for token, kind in (("::attr(", "attr"), ("::slug(", "slug"), ("::slugid(", "slugid")):
|
|
310
|
+
if token in selector:
|
|
311
|
+
css, _, rest = selector.partition(token)
|
|
312
|
+
attr = rest.rstrip(")")
|
|
313
|
+
mode = kind
|
|
314
|
+
break
|
|
315
|
+
if not css:
|
|
316
|
+
target: Tag | None = block
|
|
317
|
+
else:
|
|
318
|
+
target = block.select_one(css)
|
|
319
|
+
if target is None:
|
|
320
|
+
return ""
|
|
321
|
+
if mode == "text":
|
|
322
|
+
return collapse_whitespace(target.get_text(" ", strip=True))
|
|
323
|
+
raw = target.get(attr, "")
|
|
324
|
+
value = " ".join(raw) if isinstance(raw, list) else str(raw)
|
|
325
|
+
if not value:
|
|
326
|
+
return ""
|
|
327
|
+
if mode == "attr":
|
|
328
|
+
return urljoin(base, value) if base else value
|
|
329
|
+
slug, ident = _slug_parts(value)
|
|
330
|
+
return ident if mode == "slugid" else slug.replace("-", " ").strip().title()
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def html_rows(text: str, selector: str) -> list[Tag]:
|
|
334
|
+
soup = BeautifulSoup(text, "html.parser")
|
|
335
|
+
return soup.select(selector)[:MAX_HTML_ROWS]
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _scalar(value: Any) -> str:
|
|
339
|
+
if isinstance(value, Mapping):
|
|
340
|
+
parts = [str(item) for item in value.values() if isinstance(item, str | int | float)]
|
|
341
|
+
return ", ".join(part for part in parts if part)
|
|
342
|
+
return str(value)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _project(entry: Any, board: CustomBoard) -> dict[str, str]:
|
|
346
|
+
projected: dict[str, str] = {}
|
|
347
|
+
for name in (
|
|
348
|
+
"title",
|
|
349
|
+
"id",
|
|
350
|
+
"location",
|
|
351
|
+
"url",
|
|
352
|
+
"description",
|
|
353
|
+
"department",
|
|
354
|
+
"employment_type",
|
|
355
|
+
"category",
|
|
356
|
+
):
|
|
357
|
+
path = board.mapped(name)
|
|
358
|
+
if not path:
|
|
359
|
+
continue
|
|
360
|
+
if board.html and isinstance(entry, Tag):
|
|
361
|
+
base = board.url if name == "url" else ""
|
|
362
|
+
projected[name] = _html_value(entry, path, base=base)
|
|
363
|
+
continue
|
|
364
|
+
value = dig(entry, path)
|
|
365
|
+
if isinstance(value, list):
|
|
366
|
+
value = " / ".join(_scalar(item) for item in value if item)
|
|
367
|
+
projected[name] = "" if value is None else _scalar(value)
|
|
368
|
+
if not projected.get("id"):
|
|
369
|
+
projected["id"] = projected.get("url") or projected.get("title", "")
|
|
370
|
+
return projected
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@register
|
|
374
|
+
class CustomJsonAdapter:
|
|
375
|
+
name: ClassVar[str] = "custom_json"
|
|
376
|
+
platform: ClassVar[Platform] = Platform.CUSTOM_JSON
|
|
377
|
+
rate_profile: ClassVar[str] = "paginated"
|
|
378
|
+
hosts: ClassVar[frozenset[str]] = frozenset()
|
|
379
|
+
bucket_key: ClassVar[str] = ""
|
|
380
|
+
detail_budget: ClassVar[int] = 0
|
|
381
|
+
rotation_slice: ClassVar[int] = 0
|
|
382
|
+
max_requests_per_company: ClassVar[int] = PAGE_CEILING
|
|
383
|
+
|
|
384
|
+
def board_key(self, company: Company) -> str:
|
|
385
|
+
return board_key(self.name, company.slug)
|
|
386
|
+
|
|
387
|
+
def hosts_for(self, companies: Sequence[Company]) -> frozenset[str]:
|
|
388
|
+
found = set()
|
|
389
|
+
for company in companies:
|
|
390
|
+
if company.custom is not None:
|
|
391
|
+
for candidate in (company.custom.url, company.custom.handshake_url):
|
|
392
|
+
host = _host(candidate)
|
|
393
|
+
if host:
|
|
394
|
+
found.add(host)
|
|
395
|
+
return frozenset(found)
|
|
396
|
+
|
|
397
|
+
def plan(self, company: Company) -> tuple[str, ...]:
|
|
398
|
+
board = self._board(company)
|
|
399
|
+
return (_page_url(board, 0),)
|
|
400
|
+
|
|
401
|
+
@staticmethod
|
|
402
|
+
def _board(company: Company) -> CustomBoard:
|
|
403
|
+
if company.custom is None:
|
|
404
|
+
raise PayloadValidationError(
|
|
405
|
+
f"{company.name}: platform custom_json needs a 'custom' block — "
|
|
406
|
+
"add url and a title field mapping to the registry row"
|
|
407
|
+
)
|
|
408
|
+
return company.custom
|
|
409
|
+
|
|
410
|
+
async def fetch(
|
|
411
|
+
self,
|
|
412
|
+
company: Company,
|
|
413
|
+
client: HttpClient,
|
|
414
|
+
now: datetime,
|
|
415
|
+
facets: object = None,
|
|
416
|
+
details: Sequence[str] = (),
|
|
417
|
+
) -> FetchResult:
|
|
418
|
+
board = self._board(company)
|
|
419
|
+
headers = {**board.headers, **await self._handshake(board, client)}
|
|
420
|
+
entries: list[Any] = []
|
|
421
|
+
truncated = False
|
|
422
|
+
stale_page = False
|
|
423
|
+
ignored_param = False
|
|
424
|
+
seen: set[str] = set()
|
|
425
|
+
pages = board.page_budget(MAX_PAGES, PAGE_CEILING)
|
|
426
|
+
for index in range(pages):
|
|
427
|
+
try:
|
|
428
|
+
payload = await self._page(company, board, client, index, headers)
|
|
429
|
+
except HostBudgetExceededError:
|
|
430
|
+
if index == 0:
|
|
431
|
+
raise
|
|
432
|
+
truncated = True
|
|
433
|
+
break
|
|
434
|
+
if payload is _UNCHANGED:
|
|
435
|
+
if index == 0:
|
|
436
|
+
return FetchResult(not_modified=True)
|
|
437
|
+
stale_page = True
|
|
438
|
+
break
|
|
439
|
+
page = _rows(payload, board)
|
|
440
|
+
if not isinstance(page, list):
|
|
441
|
+
if index:
|
|
442
|
+
break
|
|
443
|
+
captured = capture_payload(self.name, company.slug, payload)
|
|
444
|
+
raise PayloadValidationError(
|
|
445
|
+
f"{self.name}/{company.slug}: jobs_path "
|
|
446
|
+
f"{board.jobs_path or '<root>'!r} is not a list; captured at {captured}"
|
|
447
|
+
)
|
|
448
|
+
fingerprint = _fingerprint(page, board)
|
|
449
|
+
if index and fingerprint and fingerprint <= seen:
|
|
450
|
+
ignored_param = True
|
|
451
|
+
break
|
|
452
|
+
seen |= fingerprint
|
|
453
|
+
entries.extend(page)
|
|
454
|
+
if not board.paginated or len(page) < board.page_size:
|
|
455
|
+
break
|
|
456
|
+
if len(entries) > MAX_ROWS:
|
|
457
|
+
truncated = True
|
|
458
|
+
break
|
|
459
|
+
else:
|
|
460
|
+
truncated = board.paginated
|
|
461
|
+
truncated = truncated or len(entries) > MAX_ROWS
|
|
462
|
+
listings, dropped = validate_rows(
|
|
463
|
+
CustomListing,
|
|
464
|
+
[_project(entry, board) for entry in entries[:MAX_ROWS]],
|
|
465
|
+
source=self.name,
|
|
466
|
+
slug=company.slug,
|
|
467
|
+
)
|
|
468
|
+
jobs, unconvertible = convert_rows(
|
|
469
|
+
lambda listing: self._to_job(company, board, listing, now),
|
|
470
|
+
listings,
|
|
471
|
+
source=self.name,
|
|
472
|
+
slug=company.slug,
|
|
473
|
+
)
|
|
474
|
+
dropped += unconvertible
|
|
475
|
+
notes = [malformed_note(dropped)] if dropped else []
|
|
476
|
+
if truncated:
|
|
477
|
+
notes.append(f"stopped at the {pages}-page or {MAX_ROWS}-row cap; incomplete")
|
|
478
|
+
if stale_page:
|
|
479
|
+
notes.append("a later page answered 304, so the walk ended on an unchanged page")
|
|
480
|
+
if ignored_param:
|
|
481
|
+
notes.append(
|
|
482
|
+
f"page {board.page_param!r} repeated the previous page, so the server ignores it; "
|
|
483
|
+
"the walk stopped rather than looping"
|
|
484
|
+
)
|
|
485
|
+
return FetchResult(
|
|
486
|
+
jobs=tuple(jobs),
|
|
487
|
+
degraded="; ".join(note for note in notes if note),
|
|
488
|
+
authoritative=board.authoritative
|
|
489
|
+
and not (dropped or truncated or stale_page or ignored_param),
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
async def _page(
|
|
493
|
+
self,
|
|
494
|
+
company: Company,
|
|
495
|
+
board: CustomBoard,
|
|
496
|
+
client: HttpClient,
|
|
497
|
+
index: int,
|
|
498
|
+
headers: Mapping[str, str],
|
|
499
|
+
) -> Any:
|
|
500
|
+
if board.rss:
|
|
501
|
+
text = await client.get_text(_page_url(board, index), revalidate=index > 0)
|
|
502
|
+
if text.not_modified:
|
|
503
|
+
return _UNCHANGED
|
|
504
|
+
rows = rss_items(text.text, board.item_tag)
|
|
505
|
+
if not rows:
|
|
506
|
+
captured = capture_payload(self.name, company.slug, {"head": text.text[:4000]})
|
|
507
|
+
raise PayloadValidationError(
|
|
508
|
+
f"{self.name}/{company.slug}: no <item> elements in the feed; "
|
|
509
|
+
f"captured at {captured}"
|
|
510
|
+
)
|
|
511
|
+
return rows
|
|
512
|
+
if board.embedded:
|
|
513
|
+
text = await client.get_text(_page_url(board, index), revalidate=index > 0)
|
|
514
|
+
if text.not_modified:
|
|
515
|
+
return _UNCHANGED
|
|
516
|
+
payload = extract_object(text.text, board.extract)
|
|
517
|
+
if payload is None:
|
|
518
|
+
captured = capture_payload(self.name, company.slug, {"head": text.text[:4000]})
|
|
519
|
+
raise PayloadValidationError(
|
|
520
|
+
f"{self.name}/{company.slug}: no {board.extract!r} object in the page; "
|
|
521
|
+
f"captured at {captured}"
|
|
522
|
+
)
|
|
523
|
+
return payload
|
|
524
|
+
if board.jsonld:
|
|
525
|
+
text = await client.get_text(_page_url(board, index), revalidate=index > 0)
|
|
526
|
+
if text.not_modified:
|
|
527
|
+
return _UNCHANGED
|
|
528
|
+
rows = jsonld_rows(text.text)
|
|
529
|
+
if not rows and index == 0:
|
|
530
|
+
captured = capture_payload(self.name, company.slug, {"head": text.text[:4000]})
|
|
531
|
+
raise PayloadValidationError(
|
|
532
|
+
f"{self.name}/{company.slug}: no JobPosting in any ld+json block; "
|
|
533
|
+
f"captured at {captured}"
|
|
534
|
+
)
|
|
535
|
+
return rows
|
|
536
|
+
if board.sitemap:
|
|
537
|
+
text = await client.get_text(_page_url(board, index), revalidate=index > 0)
|
|
538
|
+
if text.not_modified:
|
|
539
|
+
return _UNCHANGED
|
|
540
|
+
rows = sitemap_rows(text.text)
|
|
541
|
+
if board.row_filter:
|
|
542
|
+
keep = re.compile(board.row_filter)
|
|
543
|
+
rows = [row for row in rows if keep.search(row.get("loc", ""))]
|
|
544
|
+
if not rows:
|
|
545
|
+
captured = capture_payload(self.name, company.slug, {"head": text.text[:4000]})
|
|
546
|
+
raise PayloadValidationError(
|
|
547
|
+
f"{self.name}/{company.slug}: no <url> entries in the sitemap; "
|
|
548
|
+
f"captured at {captured}"
|
|
549
|
+
)
|
|
550
|
+
return rows
|
|
551
|
+
if board.html:
|
|
552
|
+
text = await client.get_text(_page_url(board, index), revalidate=index > 0)
|
|
553
|
+
if text.not_modified:
|
|
554
|
+
return _UNCHANGED
|
|
555
|
+
blocks = html_rows(text.text, board.row_selector)
|
|
556
|
+
if not blocks and index == 0:
|
|
557
|
+
captured = capture_payload(self.name, company.slug, {"head": text.text[:4000]})
|
|
558
|
+
raise PayloadValidationError(
|
|
559
|
+
f"{self.name}/{company.slug}: selector {board.row_selector!r} matched no "
|
|
560
|
+
f"rows; captured at {captured}"
|
|
561
|
+
)
|
|
562
|
+
return blocks
|
|
563
|
+
response = (
|
|
564
|
+
await client.post_json(board.url, body=_page_body(board, index), extra_headers=headers)
|
|
565
|
+
if board.posts
|
|
566
|
+
else await client.get_json(_page_url(board, index), revalidate=index > 0)
|
|
567
|
+
)
|
|
568
|
+
return _UNCHANGED if response.not_modified else response.payload
|
|
569
|
+
|
|
570
|
+
@staticmethod
|
|
571
|
+
async def _handshake(board: CustomBoard, client: HttpClient) -> dict[str, str]:
|
|
572
|
+
if not board.handshakes:
|
|
573
|
+
return {}
|
|
574
|
+
page = await client.get_text(board.handshake_url)
|
|
575
|
+
found = re.search(board.token_pattern, page.text)
|
|
576
|
+
if found is None or not found.groups():
|
|
577
|
+
raise PayloadValidationError(
|
|
578
|
+
f"custom_json: no token matched {board.token_pattern!r} at {board.handshake_url}"
|
|
579
|
+
)
|
|
580
|
+
token = found.group(1)[:MAX_TOKEN_LEN]
|
|
581
|
+
parts = urlsplit(board.handshake_url)
|
|
582
|
+
return {
|
|
583
|
+
board.token_header: f"{board.token_prefix}{token}",
|
|
584
|
+
"Referer": board.handshake_url,
|
|
585
|
+
"Origin": f"{parts.scheme}://{parts.netloc}",
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
def _to_job(
|
|
589
|
+
self, company: Company, board: CustomBoard, listing: CustomListing, now: datetime
|
|
590
|
+
) -> Job:
|
|
591
|
+
apply_url = listing.url
|
|
592
|
+
if not apply_url and board.url_template:
|
|
593
|
+
apply_url = board.url_template.format(id=listing.id)
|
|
594
|
+
title = collapse_whitespace(strip_html(listing.title)) or listing.title
|
|
595
|
+
return Job(
|
|
596
|
+
id=job_id(self.name, company.slug, listing.id),
|
|
597
|
+
source=self.name,
|
|
598
|
+
company=company.name,
|
|
599
|
+
title_raw=title,
|
|
600
|
+
title_normalized=collapse_whitespace(title),
|
|
601
|
+
apply_url_raw=apply_url or board.url,
|
|
602
|
+
description=collapse_whitespace(strip_html(listing.description)),
|
|
603
|
+
location_raw=collapse_whitespace(strip_html(listing.location)),
|
|
604
|
+
first_seen=now,
|
|
605
|
+
last_seen=now,
|
|
606
|
+
signals=SourceSignals(
|
|
607
|
+
employment_type=listing.employment_type,
|
|
608
|
+
category=listing.category or listing.department,
|
|
609
|
+
),
|
|
610
|
+
)
|