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.
@@ -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,5 @@
1
+ # swe-scraper-icims
2
+
3
+ Experimental public HTML provider for iCIMS job portals. It follows only links on
4
+ the configured portal origin and stops at explicit `rel="next"` pagination. It
5
+ 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ [swe_scraper.providers]
2
+ icims = swe_scraper_icims:IcimsProvider
@@ -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)