swe-scraper-icims 0.1.0__tar.gz
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.
- swe_scraper_icims-0.1.0/LICENSE +21 -0
- swe_scraper_icims-0.1.0/PKG-INFO +19 -0
- swe_scraper_icims-0.1.0/README.md +5 -0
- swe_scraper_icims-0.1.0/pyproject.toml +29 -0
- swe_scraper_icims-0.1.0/setup.cfg +4 -0
- swe_scraper_icims-0.1.0/src/swe_scraper_icims/__init__.py +246 -0
- swe_scraper_icims-0.1.0/src/swe_scraper_icims.egg-info/PKG-INFO +19 -0
- swe_scraper_icims-0.1.0/src/swe_scraper_icims.egg-info/SOURCES.txt +11 -0
- swe_scraper_icims-0.1.0/src/swe_scraper_icims.egg-info/dependency_links.txt +1 -0
- swe_scraper_icims-0.1.0/src/swe_scraper_icims.egg-info/entry_points.txt +2 -0
- swe_scraper_icims-0.1.0/src/swe_scraper_icims.egg-info/requires.txt +1 -0
- swe_scraper_icims-0.1.0/src/swe_scraper_icims.egg-info/top_level.txt +1 -0
- swe_scraper_icims-0.1.0/tests/test_icims_provider.py +186 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Furkan Candar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: swe-scraper-icims
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Experimental public iCIMS provider for swe-internship-scraper.
|
|
5
|
+
Author: Furkan Candar
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: swe-internship-scraper<2,>=1.0.0rc1
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# swe-scraper-icims
|
|
16
|
+
|
|
17
|
+
Experimental public HTML provider for iCIMS job portals. It follows only links on
|
|
18
|
+
the configured portal origin and stops at explicit `rel="next"` pagination. It
|
|
19
|
+
does not authenticate, solve challenges, or bypass access controls.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77", "wheel>=0.43"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "swe-scraper-icims"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Experimental public iCIMS provider for swe-internship-scraper."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{name = "Furkan Candar"}]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
]
|
|
18
|
+
dependencies = [
|
|
19
|
+
"swe-internship-scraper>=1.0.0rc1,<2",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.entry-points."swe_scraper.providers"]
|
|
23
|
+
icims = "swe_scraper_icims:IcimsProvider"
|
|
24
|
+
|
|
25
|
+
[tool.setuptools]
|
|
26
|
+
package-dir = {"" = "src"}
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
where = ["src"]
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Experimental, public-only iCIMS provider plugin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html as html_module
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from html.parser import HTMLParser
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import urljoin, urlsplit
|
|
12
|
+
|
|
13
|
+
from swe_scraper.models import Job
|
|
14
|
+
from swe_scraper.normalize import iso_datetime, normalize_locations
|
|
15
|
+
from swe_scraper.providers.base import HttpClient, Target
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _plain_text(value: object) -> str:
|
|
21
|
+
clean = re.sub(r"<[^>]+>", " ", str(value or ""))
|
|
22
|
+
return " ".join(html_module.unescape(clean).split())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _PortalParser(HTMLParser):
|
|
26
|
+
"""Collect the small, explicit HTML surface used by public iCIMS pages."""
|
|
27
|
+
|
|
28
|
+
def __init__(self) -> None:
|
|
29
|
+
super().__init__(convert_charrefs=True)
|
|
30
|
+
self.links: list[tuple[str, tuple[str, ...]]] = []
|
|
31
|
+
self.json_ld: list[str] = []
|
|
32
|
+
self._script_parts: list[str] | None = None
|
|
33
|
+
self._capture: str = ""
|
|
34
|
+
self._capture_depth = 0
|
|
35
|
+
self.text: dict[str, list[str]] = {
|
|
36
|
+
"title": [],
|
|
37
|
+
"description": [],
|
|
38
|
+
"location": [],
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
42
|
+
values = {key.casefold(): value or "" for key, value in attrs}
|
|
43
|
+
rel = tuple(values.get("rel", "").casefold().split())
|
|
44
|
+
if tag in {"a", "link"} and values.get("href"):
|
|
45
|
+
self.links.append((values["href"], rel))
|
|
46
|
+
if tag == "script" and values.get("type", "").casefold() == "application/ld+json":
|
|
47
|
+
self._script_parts = []
|
|
48
|
+
|
|
49
|
+
class_names = set(values.get("class", "").split())
|
|
50
|
+
itemprop = values.get("itemprop", "")
|
|
51
|
+
if tag == "h1" or class_names & {"iCIMS_Header", "header-text"}:
|
|
52
|
+
self._capture = "title"
|
|
53
|
+
self._capture_depth = 1
|
|
54
|
+
elif itemprop == "description" or class_names & {
|
|
55
|
+
"iCIMS_JobContent",
|
|
56
|
+
"job-description",
|
|
57
|
+
}:
|
|
58
|
+
self._capture = "description"
|
|
59
|
+
self._capture_depth = 1
|
|
60
|
+
elif itemprop == "jobLocation" or "iCIMS_JobHeaderField_location" in class_names:
|
|
61
|
+
self._capture = "location"
|
|
62
|
+
self._capture_depth = 1
|
|
63
|
+
elif self._capture:
|
|
64
|
+
self._capture_depth += 1
|
|
65
|
+
|
|
66
|
+
def handle_endtag(self, tag: str) -> None:
|
|
67
|
+
if tag == "script" and self._script_parts is not None:
|
|
68
|
+
self.json_ld.append("".join(self._script_parts))
|
|
69
|
+
self._script_parts = None
|
|
70
|
+
if self._capture:
|
|
71
|
+
self._capture_depth -= 1
|
|
72
|
+
if self._capture_depth <= 0:
|
|
73
|
+
self._capture = ""
|
|
74
|
+
|
|
75
|
+
def handle_data(self, data: str) -> None:
|
|
76
|
+
if self._script_parts is not None:
|
|
77
|
+
self._script_parts.append(data)
|
|
78
|
+
if self._capture and data.strip():
|
|
79
|
+
self.text[self._capture].append(data)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _parse_html(value: str) -> _PortalParser:
|
|
83
|
+
parser = _PortalParser()
|
|
84
|
+
parser.feed(value)
|
|
85
|
+
parser.close()
|
|
86
|
+
return parser
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _same_origin(left: str, right: str) -> bool:
|
|
90
|
+
first = urlsplit(left)
|
|
91
|
+
second = urlsplit(right)
|
|
92
|
+
return (
|
|
93
|
+
first.scheme.casefold(),
|
|
94
|
+
(first.hostname or "").casefold(),
|
|
95
|
+
first.port,
|
|
96
|
+
) == (
|
|
97
|
+
second.scheme.casefold(),
|
|
98
|
+
(second.hostname or "").casefold(),
|
|
99
|
+
second.port,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _job_id(url: str) -> str:
|
|
104
|
+
match = re.search(r"/jobs/(\d+)(?:/|$)", urlsplit(url).path, re.IGNORECASE)
|
|
105
|
+
return match.group(1) if match else ""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class IcimsProvider:
|
|
109
|
+
"""Read public iCIMS HTML without attempting to bypass access controls."""
|
|
110
|
+
|
|
111
|
+
name = "icims"
|
|
112
|
+
required_options = ("search_url",)
|
|
113
|
+
|
|
114
|
+
def validate_target(self, target: Target) -> None:
|
|
115
|
+
search_url = str(target.options.get("search_url") or "").strip()
|
|
116
|
+
parsed = urlsplit(search_url)
|
|
117
|
+
if parsed.scheme != "https" or not parsed.hostname:
|
|
118
|
+
raise ValueError("iCIMS target requires an HTTPS search_url")
|
|
119
|
+
expected_host = target.slug.casefold().strip()
|
|
120
|
+
if (parsed.hostname or "").casefold() != expected_host:
|
|
121
|
+
raise ValueError("iCIMS search_url must use the same origin as the target slug")
|
|
122
|
+
|
|
123
|
+
def discover_page(self, html: str, current_url: str) -> tuple[tuple[str, ...], str]:
|
|
124
|
+
document = _parse_html(html)
|
|
125
|
+
links: set[str] = set()
|
|
126
|
+
for href, _rel in document.links:
|
|
127
|
+
candidate = urljoin(current_url, href)
|
|
128
|
+
if _same_origin(current_url, candidate) and _job_id(candidate):
|
|
129
|
+
links.add(candidate)
|
|
130
|
+
|
|
131
|
+
next_url = ""
|
|
132
|
+
for href, rel in document.links:
|
|
133
|
+
if "next" not in rel:
|
|
134
|
+
continue
|
|
135
|
+
candidate = urljoin(current_url, href)
|
|
136
|
+
if _same_origin(current_url, candidate):
|
|
137
|
+
next_url = candidate
|
|
138
|
+
break
|
|
139
|
+
return tuple(sorted(links)), next_url
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def _job_posting_json(html: str) -> Mapping[str, Any] | None:
|
|
143
|
+
document = _parse_html(html)
|
|
144
|
+
for script in document.json_ld:
|
|
145
|
+
try:
|
|
146
|
+
value = json.loads(script)
|
|
147
|
+
except (TypeError, json.JSONDecodeError):
|
|
148
|
+
continue
|
|
149
|
+
candidates = value if isinstance(value, list) else [value]
|
|
150
|
+
for candidate in candidates:
|
|
151
|
+
if not isinstance(candidate, Mapping):
|
|
152
|
+
continue
|
|
153
|
+
graph = candidate.get("@graph")
|
|
154
|
+
nested = graph if isinstance(graph, list) else [candidate]
|
|
155
|
+
for item in nested:
|
|
156
|
+
if isinstance(item, Mapping) and item.get("@type") == "JobPosting":
|
|
157
|
+
return item
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
def parse_job_page(self, html: str, url: str, target: Target) -> Job:
|
|
161
|
+
data = self._job_posting_json(html)
|
|
162
|
+
document = _parse_html(html)
|
|
163
|
+
if data is not None:
|
|
164
|
+
title = str(data.get("title") or "").strip()
|
|
165
|
+
company_raw = data.get("hiringOrganization")
|
|
166
|
+
company = (
|
|
167
|
+
str(company_raw.get("name") or "").strip()
|
|
168
|
+
if isinstance(company_raw, Mapping)
|
|
169
|
+
else target.name
|
|
170
|
+
)
|
|
171
|
+
identifier = data.get("identifier")
|
|
172
|
+
source_id = (
|
|
173
|
+
str(identifier.get("value") or "").strip()
|
|
174
|
+
if isinstance(identifier, Mapping)
|
|
175
|
+
else str(identifier or "").strip()
|
|
176
|
+
)
|
|
177
|
+
locations: list[str] = []
|
|
178
|
+
raw_locations = data.get("jobLocation")
|
|
179
|
+
for raw_location in (
|
|
180
|
+
raw_locations if isinstance(raw_locations, list) else [raw_locations]
|
|
181
|
+
):
|
|
182
|
+
if not isinstance(raw_location, Mapping):
|
|
183
|
+
continue
|
|
184
|
+
address = raw_location.get("address")
|
|
185
|
+
if not isinstance(address, Mapping):
|
|
186
|
+
continue
|
|
187
|
+
parts = [
|
|
188
|
+
str(address.get(key) or "").strip()
|
|
189
|
+
for key in ("addressLocality", "addressRegion", "addressCountry")
|
|
190
|
+
]
|
|
191
|
+
locations.append(", ".join(part for part in parts if part))
|
|
192
|
+
description = _plain_text(data.get("description"))
|
|
193
|
+
posted_at = iso_datetime(data.get("datePosted"))
|
|
194
|
+
else:
|
|
195
|
+
title = _plain_text(" ".join(document.text["title"]))
|
|
196
|
+
company = target.name
|
|
197
|
+
source_id = _job_id(url)
|
|
198
|
+
description = _plain_text(" ".join(document.text["description"]))
|
|
199
|
+
location = _plain_text(" ".join(document.text["location"]))
|
|
200
|
+
locations = [location] if location else []
|
|
201
|
+
posted_at = ""
|
|
202
|
+
|
|
203
|
+
source_id = source_id or _job_id(url)
|
|
204
|
+
if not title or not source_id:
|
|
205
|
+
raise ValueError(f"iCIMS job page is missing title or job id: {url}")
|
|
206
|
+
normalized_locations = normalize_locations(locations)
|
|
207
|
+
return Job(
|
|
208
|
+
id=f"icims:{target.slug}:{source_id}",
|
|
209
|
+
company=company or target.name,
|
|
210
|
+
title=title,
|
|
211
|
+
application_url=url,
|
|
212
|
+
provider=self.name,
|
|
213
|
+
source_job_id=source_id,
|
|
214
|
+
locations=normalized_locations,
|
|
215
|
+
posted_at=posted_at,
|
|
216
|
+
description=description,
|
|
217
|
+
remote="remote" in " ".join(normalized_locations).casefold(),
|
|
218
|
+
metadata={"portal": target.slug, "experimental": True},
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def fetch(self, target: Target, client: HttpClient) -> list[Job]:
|
|
222
|
+
self.validate_target(target)
|
|
223
|
+
current_url = str(target.options["search_url"])
|
|
224
|
+
max_pages = int(target.options.get("max_pages", 10))
|
|
225
|
+
if not 1 <= max_pages <= 100:
|
|
226
|
+
raise ValueError("iCIMS max_pages must be between 1 and 100")
|
|
227
|
+
visited_pages: set[str] = set()
|
|
228
|
+
job_urls: set[str] = set()
|
|
229
|
+
for _ in range(max_pages):
|
|
230
|
+
if current_url in visited_pages:
|
|
231
|
+
raise RuntimeError("iCIMS pagination repeated a page")
|
|
232
|
+
visited_pages.add(current_url)
|
|
233
|
+
links, next_url = self.discover_page(client.get_text(current_url), current_url)
|
|
234
|
+
job_urls.update(links)
|
|
235
|
+
if not next_url:
|
|
236
|
+
break
|
|
237
|
+
current_url = next_url
|
|
238
|
+
else:
|
|
239
|
+
raise RuntimeError("iCIMS pagination exceeded max_pages")
|
|
240
|
+
return [
|
|
241
|
+
self.parse_job_page(client.get_text(url), url, target)
|
|
242
|
+
for url in sorted(job_urls)
|
|
243
|
+
]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
__all__ = ["IcimsProvider", "__version__"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: swe-scraper-icims
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Experimental public iCIMS provider for swe-internship-scraper.
|
|
5
|
+
Author: Furkan Candar
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: swe-internship-scraper<2,>=1.0.0rc1
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# swe-scraper-icims
|
|
16
|
+
|
|
17
|
+
Experimental public HTML provider for iCIMS job portals. It follows only links on
|
|
18
|
+
the configured portal origin and stops at explicit `rel="next"` pagination. It
|
|
19
|
+
does not authenticate, solve challenges, or bypass access controls.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/swe_scraper_icims/__init__.py
|
|
5
|
+
src/swe_scraper_icims.egg-info/PKG-INFO
|
|
6
|
+
src/swe_scraper_icims.egg-info/SOURCES.txt
|
|
7
|
+
src/swe_scraper_icims.egg-info/dependency_links.txt
|
|
8
|
+
src/swe_scraper_icims.egg-info/entry_points.txt
|
|
9
|
+
src/swe_scraper_icims.egg-info/requires.txt
|
|
10
|
+
src/swe_scraper_icims.egg-info/top_level.txt
|
|
11
|
+
tests/test_icims_provider.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
swe-internship-scraper<2,>=1.0.0rc1
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
swe_scraper_icims
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import unittest
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
|
|
6
|
+
CORE_SRC = Path(__file__).resolve().parents[3] / "src"
|
|
7
|
+
sys.path.insert(0, str(PLUGIN_ROOT / "src"))
|
|
8
|
+
sys.path.insert(0, str(CORE_SRC))
|
|
9
|
+
|
|
10
|
+
from swe_scraper_icims import IcimsProvider # noqa: E402
|
|
11
|
+
|
|
12
|
+
from swe_scraper.providers.base import Target # noqa: E402
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class IcimsProviderTests(unittest.TestCase):
|
|
16
|
+
def test_discovers_same_origin_jobs_and_explicit_next_page(self):
|
|
17
|
+
html = """
|
|
18
|
+
<html><head><link rel="next" href="/jobs/search?pr=1"></head><body>
|
|
19
|
+
<a href="/jobs/123/software-engineer-intern/job">Role</a>
|
|
20
|
+
<a href="https://evil.example/jobs/999/job">External</a>
|
|
21
|
+
</body></html>
|
|
22
|
+
"""
|
|
23
|
+
links, next_url = IcimsProvider().discover_page(
|
|
24
|
+
html, "https://careers-acme.icims.com/jobs/search"
|
|
25
|
+
)
|
|
26
|
+
self.assertEqual(
|
|
27
|
+
links,
|
|
28
|
+
("https://careers-acme.icims.com/jobs/123/software-engineer-intern/job",),
|
|
29
|
+
)
|
|
30
|
+
self.assertEqual(next_url, "https://careers-acme.icims.com/jobs/search?pr=1")
|
|
31
|
+
|
|
32
|
+
def test_parses_json_ld_job_posting_before_dom_fallback(self):
|
|
33
|
+
html = """
|
|
34
|
+
<script type="application/ld+json">
|
|
35
|
+
{
|
|
36
|
+
"@type": "JobPosting",
|
|
37
|
+
"title": "Software Engineering Intern",
|
|
38
|
+
"description": "<p>Build reliable services.</p>",
|
|
39
|
+
"datePosted": "2026-09-01",
|
|
40
|
+
"jobLocation": {
|
|
41
|
+
"address": {"addressLocality": "New York", "addressRegion": "NY"}
|
|
42
|
+
},
|
|
43
|
+
"hiringOrganization": {"name": "Acme"},
|
|
44
|
+
"identifier": {"value": "123"}
|
|
45
|
+
}
|
|
46
|
+
</script>
|
|
47
|
+
"""
|
|
48
|
+
target = Target(
|
|
49
|
+
"icims",
|
|
50
|
+
"Acme",
|
|
51
|
+
"careers-acme.icims.com",
|
|
52
|
+
{"search_url": "https://careers-acme.icims.com/jobs/search"},
|
|
53
|
+
)
|
|
54
|
+
parsed = IcimsProvider().parse_job_page(
|
|
55
|
+
html,
|
|
56
|
+
"https://careers-acme.icims.com/jobs/123/software-engineer-intern/job",
|
|
57
|
+
target,
|
|
58
|
+
)
|
|
59
|
+
self.assertEqual(parsed.title, "Software Engineering Intern")
|
|
60
|
+
self.assertEqual(parsed.source_job_id, "123")
|
|
61
|
+
self.assertEqual(parsed.locations, ("New York, NY",))
|
|
62
|
+
self.assertIn("Build reliable services", parsed.description)
|
|
63
|
+
|
|
64
|
+
def test_rejects_cross_origin_search_configuration(self):
|
|
65
|
+
target = Target(
|
|
66
|
+
"icims",
|
|
67
|
+
"Acme",
|
|
68
|
+
"careers-acme.icims.com",
|
|
69
|
+
{"search_url": "https://other.example/jobs/search"},
|
|
70
|
+
)
|
|
71
|
+
with self.assertRaisesRegex(ValueError, "same origin"):
|
|
72
|
+
IcimsProvider().validate_target(target)
|
|
73
|
+
|
|
74
|
+
def test_dom_fallback_and_nested_json_ld(self):
|
|
75
|
+
target = Target(
|
|
76
|
+
"icims",
|
|
77
|
+
"Acme",
|
|
78
|
+
"careers-acme.icims.com",
|
|
79
|
+
{"search_url": "https://careers-acme.icims.com/jobs/search"},
|
|
80
|
+
)
|
|
81
|
+
fallback = """
|
|
82
|
+
<h1>Platform Intern</h1>
|
|
83
|
+
<div itemprop="jobLocation">Remote - US</div>
|
|
84
|
+
<div itemprop="description"><p>Build platforms.</p></div>
|
|
85
|
+
"""
|
|
86
|
+
job = IcimsProvider().parse_job_page(
|
|
87
|
+
fallback,
|
|
88
|
+
"https://careers-acme.icims.com/jobs/44/platform-intern/job",
|
|
89
|
+
target,
|
|
90
|
+
)
|
|
91
|
+
self.assertEqual(job.source_job_id, "44")
|
|
92
|
+
self.assertTrue(job.remote)
|
|
93
|
+
self.assertIn("Build platforms", job.description)
|
|
94
|
+
|
|
95
|
+
nested = """
|
|
96
|
+
<script type="application/ld+json">not-json</script>
|
|
97
|
+
<script type="application/ld+json">
|
|
98
|
+
{"@graph": [{"@type": "WebPage"}, {
|
|
99
|
+
"@type": "JobPosting", "title": "Security Intern",
|
|
100
|
+
"description": "Secure systems", "identifier": "45",
|
|
101
|
+
"jobLocation": [{"address": {
|
|
102
|
+
"addressLocality": "Austin", "addressRegion": "TX",
|
|
103
|
+
"addressCountry": "US"
|
|
104
|
+
}}]
|
|
105
|
+
}]}
|
|
106
|
+
</script>
|
|
107
|
+
"""
|
|
108
|
+
job = IcimsProvider().parse_job_page(
|
|
109
|
+
nested,
|
|
110
|
+
"https://careers-acme.icims.com/jobs/45/security-intern/job",
|
|
111
|
+
target,
|
|
112
|
+
)
|
|
113
|
+
self.assertEqual(job.locations, ("Austin, TX, US",))
|
|
114
|
+
|
|
115
|
+
def test_fetch_follows_explicit_pages_and_rejects_incomplete_pagination(self):
|
|
116
|
+
search = "https://careers-acme.icims.com/jobs/search"
|
|
117
|
+
page_two = f"{search}?pr=1"
|
|
118
|
+
job_one = "https://careers-acme.icims.com/jobs/1/one/job"
|
|
119
|
+
job_two = "https://careers-acme.icims.com/jobs/2/two/job"
|
|
120
|
+
pages = {
|
|
121
|
+
search: f'<a href="{job_one}">One</a><a rel="next" href="{page_two}">Next</a>',
|
|
122
|
+
page_two: f'<a href="{job_two}">Two</a>',
|
|
123
|
+
job_one: (
|
|
124
|
+
'<script type="application/ld+json">'
|
|
125
|
+
'{"@type":"JobPosting","title":"Intern One","identifier":"1"}'
|
|
126
|
+
"</script>"
|
|
127
|
+
),
|
|
128
|
+
job_two: (
|
|
129
|
+
'<script type="application/ld+json">'
|
|
130
|
+
'{"@type":"JobPosting","title":"Intern Two","identifier":"2"}'
|
|
131
|
+
"</script>"
|
|
132
|
+
),
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
class Client:
|
|
136
|
+
def get_text(self, url, **kwargs):
|
|
137
|
+
return pages[url]
|
|
138
|
+
|
|
139
|
+
target = Target(
|
|
140
|
+
"icims",
|
|
141
|
+
"Acme",
|
|
142
|
+
"careers-acme.icims.com",
|
|
143
|
+
{"search_url": search},
|
|
144
|
+
)
|
|
145
|
+
jobs = IcimsProvider().fetch(target, Client())
|
|
146
|
+
self.assertEqual([job.source_job_id for job in jobs], ["1", "2"])
|
|
147
|
+
|
|
148
|
+
limited = Target(
|
|
149
|
+
"icims",
|
|
150
|
+
"Acme",
|
|
151
|
+
"careers-acme.icims.com",
|
|
152
|
+
{"search_url": search, "max_pages": 1},
|
|
153
|
+
)
|
|
154
|
+
with self.assertRaisesRegex(RuntimeError, "max_pages"):
|
|
155
|
+
IcimsProvider().fetch(limited, Client())
|
|
156
|
+
|
|
157
|
+
def test_validation_and_malformed_pages_fail_closed(self):
|
|
158
|
+
provider = IcimsProvider()
|
|
159
|
+
with self.assertRaisesRegex(ValueError, "HTTPS"):
|
|
160
|
+
provider.validate_target(
|
|
161
|
+
Target("icims", "Acme", "host", {"search_url": "http://host/jobs"})
|
|
162
|
+
)
|
|
163
|
+
with self.assertRaisesRegex(ValueError, "max_pages"):
|
|
164
|
+
provider.fetch(
|
|
165
|
+
Target(
|
|
166
|
+
"icims",
|
|
167
|
+
"Acme",
|
|
168
|
+
"host.icims.com",
|
|
169
|
+
{"search_url": "https://host.icims.com/jobs", "max_pages": 0},
|
|
170
|
+
),
|
|
171
|
+
object(),
|
|
172
|
+
)
|
|
173
|
+
target = Target(
|
|
174
|
+
"icims",
|
|
175
|
+
"Acme",
|
|
176
|
+
"host.icims.com",
|
|
177
|
+
{"search_url": "https://host.icims.com/jobs"},
|
|
178
|
+
)
|
|
179
|
+
with self.assertRaisesRegex(ValueError, "missing title"):
|
|
180
|
+
provider.parse_job_page(
|
|
181
|
+
"<html></html>", "https://host.icims.com/jobs/1/missing/job", target
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
if __name__ == "__main__":
|
|
186
|
+
unittest.main(verbosity=2)
|