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.
@@ -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
@@ -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__}")