hiringcafe-cli 0.1.4__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.
- hiringcafe_cli/__init__.py +152 -0
- hiringcafe_cli/auth.py +95 -0
- hiringcafe_cli/cache.py +98 -0
- hiringcafe_cli/count.py +37 -0
- hiringcafe_cli/firebase.py +93 -0
- hiringcafe_cli/http_client.py +245 -0
- hiringcafe_cli/io_limits.py +23 -0
- hiringcafe_cli/job.py +63 -0
- hiringcafe_cli/probe.py +140 -0
- hiringcafe_cli/saved_jobs.py +101 -0
- hiringcafe_cli/saved_jobs_list.py +161 -0
- hiringcafe_cli/saved_jobs_remove.py +51 -0
- hiringcafe_cli/saved_jobs_save.py +59 -0
- hiringcafe_cli/saved_jobs_stage.py +64 -0
- hiringcafe_cli/search.py +140 -0
- hiringcafe_cli/search_state.py +36 -0
- hiringcafe_cli/session.py +82 -0
- hiringcafe_cli-0.1.4.dist-info/METADATA +145 -0
- hiringcafe_cli-0.1.4.dist-info/RECORD +22 -0
- hiringcafe_cli-0.1.4.dist-info/WHEEL +4 -0
- hiringcafe_cli-0.1.4.dist-info/entry_points.txt +3 -0
- hiringcafe_cli-0.1.4.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Anonymous HiringCafe web and SSR HTTP client."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import random
|
|
6
|
+
import time
|
|
7
|
+
import urllib.parse
|
|
8
|
+
from urllib.error import HTTPError, URLError
|
|
9
|
+
from urllib.request import Request, urlopen
|
|
10
|
+
|
|
11
|
+
from hiringcafe_cli.io_limits import read_limited
|
|
12
|
+
|
|
13
|
+
BROWSER_HEADERS = {
|
|
14
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
15
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
16
|
+
"User-Agent": (
|
|
17
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
|
18
|
+
"(KHTML, like Gecko) Chrome/128.0 Safari/537.36 hiringcafe-cli/0.1"
|
|
19
|
+
),
|
|
20
|
+
}
|
|
21
|
+
REQUIRED_SSR_KEYS = ("ssrHits", "ssrPage", "ssrTotalCount", "ssrPageSize", "ssrIsLastPage")
|
|
22
|
+
RETRYABLE_STATUSES = {408, 429, *range(500, 600)}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class HireCafeHTTPError(RuntimeError):
|
|
26
|
+
"""A non-success HTTP response from a HiringCafe endpoint."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, endpoint: str, status: int, reason: str = "") -> None:
|
|
29
|
+
"""Initialize the HTTP error.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
endpoint: Stable endpoint name for diagnostics.
|
|
33
|
+
status: HTTP response status.
|
|
34
|
+
reason: Optional response reason.
|
|
35
|
+
"""
|
|
36
|
+
self.endpoint = endpoint
|
|
37
|
+
self.status = status
|
|
38
|
+
self.reason = reason
|
|
39
|
+
super().__init__(f"{endpoint} returned HTTP {status}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class HireCafeTransportError(RuntimeError):
|
|
43
|
+
"""A transport failure before an HTTP response completed."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, endpoint: str, cause: Exception) -> None:
|
|
46
|
+
"""Initialize the transport error.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
endpoint: Stable endpoint name for diagnostics.
|
|
50
|
+
cause: Original exception.
|
|
51
|
+
"""
|
|
52
|
+
self.endpoint = endpoint
|
|
53
|
+
self.cause = cause
|
|
54
|
+
super().__init__(f"{endpoint} transport failed: {cause}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def configured_base_url() -> str:
|
|
58
|
+
"""Return the configured HTTPS anonymous web base URL without a trailing slash.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
ValueError: If the configured URL is not HTTPS.
|
|
62
|
+
"""
|
|
63
|
+
base_url = os.environ.get("HIRINGCAFE_BASE_URL", "https://hiringcafe.com").rstrip("/")
|
|
64
|
+
parsed = urllib.parse.urlsplit(base_url)
|
|
65
|
+
if parsed.scheme != "https" or not parsed.netloc or parsed.username is not None or parsed.password is not None:
|
|
66
|
+
raise ValueError("HIRINGCAFE_BASE_URL must use HTTPS")
|
|
67
|
+
return base_url
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def retryable(error: Exception) -> bool:
|
|
71
|
+
"""Classify whether an anonymous GET error may be retried."""
|
|
72
|
+
return isinstance(error, HireCafeTransportError) or (
|
|
73
|
+
isinstance(error, HireCafeHTTPError) and error.status in RETRYABLE_STATUSES
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class RetryPolicy:
|
|
78
|
+
"""Bounded exponential backoff with full jitter."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, count: int = 2, base: float = 0.5, timeout: float = 30) -> None:
|
|
81
|
+
self.count = count
|
|
82
|
+
self.base = base
|
|
83
|
+
self.timeout = timeout
|
|
84
|
+
self.sleep = time.sleep
|
|
85
|
+
|
|
86
|
+
def delay(self, attempt: int) -> float:
|
|
87
|
+
"""Return the bounded backoff for a zero-based attempt."""
|
|
88
|
+
return random.uniform(0, self.base * (2**attempt))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def parse_ssr_page(html: str) -> dict:
|
|
92
|
+
"""Parse and validate pageProps from a HiringCafe SSR response.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
html: Complete SSR HTML document.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
The validated pageProps object.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
ValueError: If __NEXT_DATA__ is missing, malformed, or incomplete.
|
|
102
|
+
"""
|
|
103
|
+
marker_start = '<script id="__NEXT_DATA__" type="application/json">'
|
|
104
|
+
fallback_start = '<script id="__NEXT_DATA__">'
|
|
105
|
+
starts = [index for index in (html.find(marker_start), html.find(fallback_start)) if index >= 0]
|
|
106
|
+
if not starts:
|
|
107
|
+
raise ValueError("missing __NEXT_DATA__")
|
|
108
|
+
start = min(starts)
|
|
109
|
+
content_start = html.index(">", start) + 1
|
|
110
|
+
end = html.find("</script>", content_start)
|
|
111
|
+
if end < 0:
|
|
112
|
+
raise ValueError("unterminated __NEXT_DATA__")
|
|
113
|
+
try:
|
|
114
|
+
payload = json.loads(html[content_start:end])
|
|
115
|
+
except json.JSONDecodeError as error:
|
|
116
|
+
raise ValueError("invalid __NEXT_DATA__ JSON") from error
|
|
117
|
+
properties = payload.get("props", {}).get("pageProps", {})
|
|
118
|
+
if not isinstance(properties, dict):
|
|
119
|
+
raise ValueError("__NEXT_DATA__ pageProps is not an object")
|
|
120
|
+
for key in REQUIRED_SSR_KEYS:
|
|
121
|
+
if key not in properties:
|
|
122
|
+
raise ValueError(f"missing SSR key: {key}")
|
|
123
|
+
return properties
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class WebClient:
|
|
127
|
+
"""Client that owns every anonymous HiringCafe web URL."""
|
|
128
|
+
|
|
129
|
+
urlopen = staticmethod(urlopen)
|
|
130
|
+
|
|
131
|
+
def __init__(self, base_url: str | None = None, timeout: float = 30) -> None:
|
|
132
|
+
"""Initialize the client.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
base_url: HTTPS base URL; defaults to ``HIRINGCAFE_BASE_URL`` or production.
|
|
136
|
+
timeout: Per-request timeout in seconds.
|
|
137
|
+
"""
|
|
138
|
+
self.base_url = base_url.rstrip("/") if base_url else configured_base_url()
|
|
139
|
+
self.timeout = timeout
|
|
140
|
+
self.retry_policy = RetryPolicy(timeout=timeout)
|
|
141
|
+
|
|
142
|
+
def search_page(self, search_state: dict, page: int) -> dict:
|
|
143
|
+
"""Fetch one SSR search page.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
search_state: Complete search state object.
|
|
147
|
+
page: Nonnegative page number.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Validated ``props.pageProps``.
|
|
151
|
+
"""
|
|
152
|
+
query = urllib.parse.urlencode({"searchState": json.dumps(search_state), "page": page})
|
|
153
|
+
return parse_ssr_page(self._get(f"{self.base_url}/?{query}", "search"))
|
|
154
|
+
|
|
155
|
+
def job_description(self, object_id: str) -> dict:
|
|
156
|
+
"""Fetch a job-description JSON object.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
object_id: Nonempty HiringCafe object ID.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
The response's ``job`` object.
|
|
163
|
+
"""
|
|
164
|
+
query = urllib.parse.urlencode({"id": object_id})
|
|
165
|
+
payload = self._json(self._get(f"{self.base_url}/api/job-description?{query}", "job-description"))
|
|
166
|
+
job = payload.get("job")
|
|
167
|
+
if not isinstance(job, dict):
|
|
168
|
+
raise ValueError("job-description response has no job object")
|
|
169
|
+
return job
|
|
170
|
+
|
|
171
|
+
def marketplace_stage(self, object_id: str, id_token: str, stage: str) -> None:
|
|
172
|
+
"""Sync a saved-job stage through the verified marketplace endpoint."""
|
|
173
|
+
body = json.dumps(
|
|
174
|
+
{"job_id": object_id, "user_token": id_token, "stage": stage},
|
|
175
|
+
separators=(",", ":"),
|
|
176
|
+
).encode()
|
|
177
|
+
request = urllib.request.Request(
|
|
178
|
+
f"{self.base_url}/api/updateMarketplaceJobStageForUser",
|
|
179
|
+
data=body,
|
|
180
|
+
headers={**BROWSER_HEADERS, "Content-Type": "application/json", "Accept": "application/json"},
|
|
181
|
+
method="POST",
|
|
182
|
+
)
|
|
183
|
+
try:
|
|
184
|
+
with self.urlopen(request, timeout=self.timeout) as response:
|
|
185
|
+
read_limited(response, "marketplace-stage")
|
|
186
|
+
except HTTPError as error:
|
|
187
|
+
raise HireCafeHTTPError("marketplace-stage", error.code, error.reason) from error
|
|
188
|
+
except (URLError, TimeoutError, OSError) as error:
|
|
189
|
+
raise HireCafeTransportError("marketplace-stage", error) from error
|
|
190
|
+
|
|
191
|
+
def marketplace_mutation(self, endpoint: str, body: dict) -> None:
|
|
192
|
+
"""POST a marketplace saved-job mutation and require success."""
|
|
193
|
+
request = urllib.request.Request(
|
|
194
|
+
f"{self.base_url}/api/{endpoint}",
|
|
195
|
+
data=json.dumps(body, separators=(",", ":")).encode(),
|
|
196
|
+
headers={**BROWSER_HEADERS, "Content-Type": "application/json", "Accept": "application/json"},
|
|
197
|
+
method="POST",
|
|
198
|
+
)
|
|
199
|
+
try:
|
|
200
|
+
with self.urlopen(request, timeout=self.timeout) as response:
|
|
201
|
+
read_limited(response, endpoint)
|
|
202
|
+
except HTTPError as error:
|
|
203
|
+
raise HireCafeHTTPError(endpoint, error.code, error.reason) from error
|
|
204
|
+
except (URLError, TimeoutError, OSError) as error:
|
|
205
|
+
raise HireCafeTransportError(endpoint, error) from error
|
|
206
|
+
|
|
207
|
+
def remove_from_list(self, object_id: str, id_token: str, stage: str) -> None:
|
|
208
|
+
"""Remove a job from its old marketplace stage list."""
|
|
209
|
+
self.marketplace_mutation(
|
|
210
|
+
"removeJobFromList",
|
|
211
|
+
{"job_id": object_id, "user_token": id_token, "stage": stage, "should_delete": False},
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def remove_job(self, object_id: str, id_token: str, stage: str) -> None:
|
|
215
|
+
"""Delete a job through the marketplace owner endpoint."""
|
|
216
|
+
self.marketplace_mutation(
|
|
217
|
+
"removeJobFromList",
|
|
218
|
+
{"job_id": object_id, "user_token": id_token, "stage": stage, "should_delete": True},
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def _get(self, url: str, endpoint: str) -> str:
|
|
222
|
+
"""GET a URL and return its decoded body."""
|
|
223
|
+
request = Request(url, headers=BROWSER_HEADERS)
|
|
224
|
+
for attempt in range(self.retry_policy.count + 1):
|
|
225
|
+
try:
|
|
226
|
+
with self.urlopen(request, timeout=self.timeout) as response:
|
|
227
|
+
return read_limited(response, endpoint).decode("utf-8")
|
|
228
|
+
except HTTPError as error:
|
|
229
|
+
wrapped = HireCafeHTTPError(endpoint, error.code, error.reason)
|
|
230
|
+
except (URLError, TimeoutError, OSError) as error:
|
|
231
|
+
wrapped = HireCafeTransportError(endpoint, error)
|
|
232
|
+
if attempt == self.retry_policy.count or not retryable(wrapped):
|
|
233
|
+
raise wrapped
|
|
234
|
+
self.retry_policy.sleep(self.retry_policy.delay(attempt))
|
|
235
|
+
raise AssertionError("unreachable")
|
|
236
|
+
|
|
237
|
+
def _json(self, body: str) -> dict:
|
|
238
|
+
"""Parse a JSON object response."""
|
|
239
|
+
try:
|
|
240
|
+
value = json.loads(body)
|
|
241
|
+
except json.JSONDecodeError as error:
|
|
242
|
+
raise ValueError("invalid JSON response") from error
|
|
243
|
+
if not isinstance(value, dict):
|
|
244
|
+
raise ValueError("JSON response is not an object")
|
|
245
|
+
return value
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Bounded reads for external HTTP responses."""
|
|
2
|
+
|
|
3
|
+
RESPONSE_LIMIT_BYTES = 8 * 1024 * 1024
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def read_limited(response, endpoint: str, limit: int = RESPONSE_LIMIT_BYTES) -> bytes:
|
|
7
|
+
"""Read an HTTP response without trusting its size.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
response: An open ``http.client.HTTPResponse``-compatible object.
|
|
11
|
+
endpoint: Stable endpoint name used in diagnostics.
|
|
12
|
+
limit: Maximum accepted response size in bytes.
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
The complete response body when it is within ``limit``.
|
|
16
|
+
|
|
17
|
+
Raises:
|
|
18
|
+
ValueError: If the response exceeds ``limit``.
|
|
19
|
+
"""
|
|
20
|
+
body = response.read(limit + 1)
|
|
21
|
+
if len(body) > limit:
|
|
22
|
+
raise ValueError(f"{endpoint} response exceeds {limit} bytes")
|
|
23
|
+
return body
|
hiringcafe_cli/job.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Anonymous job-description command."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from hiringcafe_cli.cache import active_cache_root, cache_read, cache_write
|
|
8
|
+
from hiringcafe_cli.http_client import WebClient, configured_base_url
|
|
9
|
+
|
|
10
|
+
JOB_TTL = 86400
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def show(object_id: str, no_cache: bool = False, cache_ttl: int | None = None) -> dict:
|
|
14
|
+
"""Fetch and normalize a job description.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
object_id: Nonempty HiringCafe object ID.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Stable version-1 job-detail envelope.
|
|
21
|
+
"""
|
|
22
|
+
cache_root = active_cache_root()
|
|
23
|
+
client = WebClient()
|
|
24
|
+
key = ("job", configured_base_url(), object_id)
|
|
25
|
+
ttl = JOB_TTL if cache_ttl is None else cache_ttl
|
|
26
|
+
enabled = no_cache is False and not os.environ.get("HIRINGCAFE_NO_CACHE")
|
|
27
|
+
raw = cache_read(key, ttl, cache_root, enabled=enabled)
|
|
28
|
+
if raw is not None:
|
|
29
|
+
job = raw
|
|
30
|
+
else:
|
|
31
|
+
job = client.job_description(object_id)
|
|
32
|
+
cache_write(key, job, ttl, cache_root, enabled=enabled)
|
|
33
|
+
if not job:
|
|
34
|
+
raise ValueError("job-description response has no job object")
|
|
35
|
+
information = job.get("job_information") or {}
|
|
36
|
+
description = information.get("description")
|
|
37
|
+
return {
|
|
38
|
+
"version": 1,
|
|
39
|
+
"job": {
|
|
40
|
+
"id": job.get("objectID", object_id),
|
|
41
|
+
"title": information.get("title"),
|
|
42
|
+
"description": " ".join(str(description).split()) if description is not None else None,
|
|
43
|
+
"url": job.get("apply_url"),
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def run(argv: object, json_output: bool) -> int:
|
|
49
|
+
"""Execute job show with CLI arguments."""
|
|
50
|
+
if not argv.object_id:
|
|
51
|
+
print("hiringcafe: object ID must not be empty", file=sys.stderr)
|
|
52
|
+
return 2
|
|
53
|
+
envelope = show(
|
|
54
|
+
argv.object_id,
|
|
55
|
+
no_cache=getattr(argv, "no_cache", False),
|
|
56
|
+
cache_ttl=getattr(argv, "cache_ttl", None),
|
|
57
|
+
)
|
|
58
|
+
if json_output:
|
|
59
|
+
print(json.dumps(envelope, separators=(",", ":")))
|
|
60
|
+
else:
|
|
61
|
+
job = envelope["job"]
|
|
62
|
+
print(f"{job['title'] or 'Untitled'}\n{job['url'] or ''}\n\n{job['description'] or ''}")
|
|
63
|
+
return 0
|
hiringcafe_cli/probe.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Opt-in endpoint regression probe for verified anonymous HiringCafe routes."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import urllib.parse
|
|
6
|
+
from html.parser import HTMLParser
|
|
7
|
+
from urllib.request import urlopen
|
|
8
|
+
|
|
9
|
+
from hiringcafe_cli.http_client import configured_base_url
|
|
10
|
+
from hiringcafe_cli.io_limits import read_limited
|
|
11
|
+
|
|
12
|
+
BASE_URL = configured_base_url()
|
|
13
|
+
SEARCH_STATE = {"searchQuery": "python developer"}
|
|
14
|
+
TIMEOUT_SECONDS = 30
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _NextDataParser(HTMLParser):
|
|
18
|
+
"""HTML parser that captures the ``__NEXT_DATA__`` script body."""
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
"""Initialize parser state."""
|
|
22
|
+
super().__init__()
|
|
23
|
+
self.payload: str | None = None
|
|
24
|
+
self._capture = False
|
|
25
|
+
|
|
26
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
27
|
+
"""Begin capturing the sole Next.js data script.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
tag: Current HTML tag.
|
|
31
|
+
attrs: Tag attributes.
|
|
32
|
+
"""
|
|
33
|
+
if tag == "script" and dict(attrs).get("id") == "__NEXT_DATA__":
|
|
34
|
+
self._capture = True
|
|
35
|
+
|
|
36
|
+
def handle_data(self, data: str) -> None:
|
|
37
|
+
"""Collect script text.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
data: Current text node.
|
|
41
|
+
"""
|
|
42
|
+
if self._capture:
|
|
43
|
+
self.payload = (self.payload or "") + data
|
|
44
|
+
|
|
45
|
+
def handle_endtag(self, tag: str) -> None:
|
|
46
|
+
"""Stop capturing at the closing script tag.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
tag: Current HTML tag.
|
|
50
|
+
"""
|
|
51
|
+
if tag == "script" and self._capture:
|
|
52
|
+
self._capture = False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def extract_next_data(html: str) -> dict:
|
|
56
|
+
"""Extract the Next.js payload from search HTML.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
html: Complete SSR HTML response.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
The parsed ``__NEXT_DATA__`` object.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
ValueError: If the script is missing or contains invalid JSON.
|
|
66
|
+
"""
|
|
67
|
+
parser = _NextDataParser()
|
|
68
|
+
parser.feed(html)
|
|
69
|
+
if parser.payload is None:
|
|
70
|
+
raise ValueError("missing __NEXT_DATA__")
|
|
71
|
+
try:
|
|
72
|
+
payload = json.loads(parser.payload)
|
|
73
|
+
except json.JSONDecodeError as error:
|
|
74
|
+
raise ValueError("invalid __NEXT_DATA__ JSON") from error
|
|
75
|
+
if not isinstance(payload, dict):
|
|
76
|
+
raise ValueError("__NEXT_DATA__ is not an object")
|
|
77
|
+
return payload
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _get(url: str) -> dict | str:
|
|
81
|
+
"""GET a verified anonymous endpoint and return decoded JSON or HTML."""
|
|
82
|
+
request = urllib.request.Request(
|
|
83
|
+
url,
|
|
84
|
+
headers={
|
|
85
|
+
"Accept": "text/html,application/json;q=0.9,*/*;q=0.8",
|
|
86
|
+
"User-Agent": "Mozilla/5.0 hiringcafe-cli/0.1 endpoint-probe",
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
with urlopen(request, timeout=TIMEOUT_SECONDS) as response:
|
|
90
|
+
body = read_limited(response, "endpoint probe").decode("utf-8")
|
|
91
|
+
try:
|
|
92
|
+
return json.loads(body)
|
|
93
|
+
except json.JSONDecodeError:
|
|
94
|
+
return body
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _page(page: int) -> dict:
|
|
98
|
+
"""Fetch and validate one SSR search page."""
|
|
99
|
+
query = urllib.parse.urlencode({"searchState": json.dumps(SEARCH_STATE), "page": page})
|
|
100
|
+
result = _get(f"{BASE_URL}/?{query}")
|
|
101
|
+
html = result if isinstance(result, str) else ""
|
|
102
|
+
properties = extract_next_data(html).get("props", {}).get("pageProps", {})
|
|
103
|
+
required = ("ssrHits", "ssrTotalCount", "ssrPage", "ssrIsLastPage")
|
|
104
|
+
for key in required:
|
|
105
|
+
if key not in properties:
|
|
106
|
+
raise ValueError(f"missing SSR key: {key}")
|
|
107
|
+
initial_state = properties.get("initialSearchState", {})
|
|
108
|
+
if initial_state.get("searchQuery") != SEARCH_STATE["searchQuery"]:
|
|
109
|
+
raise ValueError("page metadata lost search state")
|
|
110
|
+
return properties
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def main(argv: object | None = None, json_output: bool = False) -> int:
|
|
114
|
+
"""Run the endpoint probe only when explicitly enabled.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
argv: Reserved for future CLI options.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Process exit code.
|
|
121
|
+
"""
|
|
122
|
+
del argv
|
|
123
|
+
if os.environ.get("LIVE") != "1":
|
|
124
|
+
print("skipped: set LIVE=1 to probe HiringCafe")
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
pages = [_page(0), _page(1)]
|
|
128
|
+
hits = pages[0]["ssrHits"]
|
|
129
|
+
if not hits or not isinstance(hits[0].get("objectID"), str):
|
|
130
|
+
raise ValueError("search page 0 has no usable objectID")
|
|
131
|
+
object_id = hits[0]["objectID"]
|
|
132
|
+
detail_query = urllib.parse.urlencode({"id": object_id})
|
|
133
|
+
detail = _get(f"{BASE_URL}/api/job-description?{detail_query}")
|
|
134
|
+
if not isinstance(detail, dict) or not isinstance(detail.get("job"), dict):
|
|
135
|
+
raise ValueError("job response has no job object")
|
|
136
|
+
|
|
137
|
+
for index in range(len(pages)):
|
|
138
|
+
print(f"search page {index}: ok")
|
|
139
|
+
print(f"job description {object_id}: ok")
|
|
140
|
+
return 0
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Saved-job domain models and Firestore value conversion."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
VALID_STAGES = ("saved", "applied", "interviewing", "offer", "rejected", "ghosted", "hidden")
|
|
7
|
+
PIPELINE_STAGES = tuple(stage for stage in VALID_STAGES if stage != "hidden")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SavedJobError(ValueError):
|
|
11
|
+
"""A saved-job document violates the verified contract."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _document_id(document: dict) -> str:
|
|
15
|
+
"""Extract the final Firestore document ID."""
|
|
16
|
+
name = document.get("name", "")
|
|
17
|
+
return name.rsplit("/", 1)[-1]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _value(value: Any) -> Any:
|
|
21
|
+
"""Convert one Firestore typed value recursively."""
|
|
22
|
+
if not isinstance(value, dict):
|
|
23
|
+
return value
|
|
24
|
+
for key in ("stringValue", "integerValue", "doubleValue", "booleanValue"):
|
|
25
|
+
if key in value:
|
|
26
|
+
return value[key]
|
|
27
|
+
if "timestampValue" in value:
|
|
28
|
+
timestamp = datetime.fromisoformat(value["timestampValue"].replace("Z", "+00:00"))
|
|
29
|
+
return timestamp.isoformat().replace("+00:00", "Z")
|
|
30
|
+
if "mapValue" in value:
|
|
31
|
+
return decode_firestore_fields(value["mapValue"].get("fields", {}))
|
|
32
|
+
if "arrayValue" in value:
|
|
33
|
+
return [_value(item) for item in value["arrayValue"].get("values", [])]
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def decode_firestore_fields(fields: dict) -> dict:
|
|
38
|
+
"""Decode Firestore typed fields to plain Python values."""
|
|
39
|
+
return {key: _value(value) for key, value in fields.items()}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def normalize_saved_job(document: dict) -> dict:
|
|
43
|
+
"""Normalize a Firestore savedJobs document.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
document: Firestore REST document.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Stable saved-job representation.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
SavedJobError: If identity, ownership, or stage is invalid.
|
|
53
|
+
"""
|
|
54
|
+
fields = decode_firestore_fields(document.get("fields", {}))
|
|
55
|
+
object_id = fields.get("objectID")
|
|
56
|
+
owner = fields.get("owner")
|
|
57
|
+
if not isinstance(object_id, str) or not object_id:
|
|
58
|
+
raise SavedJobError("saved job has no objectID")
|
|
59
|
+
if not isinstance(owner, str) or not owner:
|
|
60
|
+
raise SavedJobError("saved job has no owner")
|
|
61
|
+
stage = fields.get("stage")
|
|
62
|
+
if stage not in VALID_STAGES:
|
|
63
|
+
raise SavedJobError(f"saved job has invalid stage: {stage}")
|
|
64
|
+
job_information = fields.get("job_information") or {}
|
|
65
|
+
company = fields.get("enriched_company_data") or {}
|
|
66
|
+
processed = fields.get("v5_processed_job_data") or {}
|
|
67
|
+
return {
|
|
68
|
+
"id": object_id,
|
|
69
|
+
"savedAt": fields.get("dateSaved"),
|
|
70
|
+
"stage": stage,
|
|
71
|
+
"stageHistory": fields.get("stageHistory", []),
|
|
72
|
+
"hidden": bool(fields.get("isHidden", False)),
|
|
73
|
+
"documentId": fields.get("docID") or _document_id(document),
|
|
74
|
+
"title": job_information.get("title"),
|
|
75
|
+
"company": company.get("name", processed.get("company_name")),
|
|
76
|
+
"location": processed.get("formatted_workplace_location"),
|
|
77
|
+
"url": fields.get("apply_url"),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def encode_firestore_value(value):
|
|
82
|
+
"""Encode one plain Python value in Firestore REST representation."""
|
|
83
|
+
if value is None:
|
|
84
|
+
return {"nullValue": None}
|
|
85
|
+
if isinstance(value, bool):
|
|
86
|
+
return {"booleanValue": value}
|
|
87
|
+
if isinstance(value, int):
|
|
88
|
+
return {"integerValue": str(value)}
|
|
89
|
+
if isinstance(value, float):
|
|
90
|
+
return {"doubleValue": value}
|
|
91
|
+
if isinstance(value, str):
|
|
92
|
+
return {"stringValue": value}
|
|
93
|
+
if isinstance(value, list):
|
|
94
|
+
return {"arrayValue": {"values": [encode_firestore_value(item) for item in value]}}
|
|
95
|
+
if isinstance(value, dict):
|
|
96
|
+
if set(value) == {"arrayValue"}:
|
|
97
|
+
return value
|
|
98
|
+
if set(value) == {"appendMissingElements"}:
|
|
99
|
+
return {"arrayValue": value}
|
|
100
|
+
return {"mapValue": {"fields": {key: encode_firestore_value(item) for key, item in value.items()}}}
|
|
101
|
+
raise SavedJobError(f"unsupported Firestore value: {type(value).__name__}")
|