openroles 0.1.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.
- openroles/__init__.py +84 -0
- openroles/__main__.py +5 -0
- openroles/_http.py +130 -0
- openroles/cli.py +86 -0
- openroles/discover.py +101 -0
- openroles/mcp.py +145 -0
- openroles/models.py +80 -0
- openroles/providers/__init__.py +6 -0
- openroles/providers/ashby.py +117 -0
- openroles/providers/greenhouse.py +48 -0
- openroles/providers/lever.py +100 -0
- openroles/providers/workday.py +326 -0
- openroles/py.typed +0 -0
- openroles/robots.py +68 -0
- openroles-0.1.0.dist-info/METADATA +191 -0
- openroles-0.1.0.dist-info/RECORD +19 -0
- openroles-0.1.0.dist-info/WHEEL +4 -0
- openroles-0.1.0.dist-info/entry_points.txt +2 -0
- openroles-0.1.0.dist-info/licenses/LICENSE +21 -0
openroles/__init__.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""openroles: every open role, straight from the employer's own applicant tracking system.
|
|
2
|
+
|
|
3
|
+
>>> import openroles
|
|
4
|
+
>>> openroles.detect("https://jobs.lever.co/acme")
|
|
5
|
+
[Board(ats='lever', token='acme')]
|
|
6
|
+
>>> jobs = openroles.fetch("greenhouse:acme", query="data analyst", location="London")
|
|
7
|
+
|
|
8
|
+
Greenhouse, Lever, Ashby and Workday, read from the same public JSON their own
|
|
9
|
+
careers pages use: no key, no account, no scraping service, and nothing but the
|
|
10
|
+
Python standard library.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
|
|
16
|
+
import datetime as _dt
|
|
17
|
+
from typing import Callable, Iterable, List, Optional, Union
|
|
18
|
+
|
|
19
|
+
from .models import ATS, Board, BoardError, Job, RobotsDisallowed
|
|
20
|
+
|
|
21
|
+
__all__ = ["ATS", "Board", "BoardError", "Job", "RobotsDisallowed", "__version__",
|
|
22
|
+
"detect", "fetch"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def detect(target: str) -> List[Board]:
|
|
26
|
+
"""The job boards behind a board URL, "ats:token", a careers page or a company name."""
|
|
27
|
+
from .discover import detect as _detect
|
|
28
|
+
return _detect(target)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _boards(target: Union[str, Board]) -> List[Board]:
|
|
32
|
+
if isinstance(target, Board):
|
|
33
|
+
return [target]
|
|
34
|
+
found = detect(target)
|
|
35
|
+
if not found:
|
|
36
|
+
raise BoardError(f"no Greenhouse, Lever, Ashby or Workday board found for {target!r}")
|
|
37
|
+
return found
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _matches(job: Job, query: Optional[str], location: Optional[str],
|
|
41
|
+
remote: Optional[bool], since: Optional[_dt.date]) -> bool:
|
|
42
|
+
if query and not all(w in job.title.lower() for w in query.lower().split()):
|
|
43
|
+
return False
|
|
44
|
+
if location:
|
|
45
|
+
where = " ".join([job.location] + job.locations).lower()
|
|
46
|
+
if location.lower() not in where:
|
|
47
|
+
return False
|
|
48
|
+
if remote is not None and bool(job.remote) != remote:
|
|
49
|
+
return False
|
|
50
|
+
if since and job.posted and job.posted < since.isoformat():
|
|
51
|
+
return False
|
|
52
|
+
return True
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def fetch(target: Union[str, Board], query: Optional[str] = None,
|
|
56
|
+
location: Optional[str] = None, remote: Optional[bool] = None,
|
|
57
|
+
since_days: Optional[int] = None, limit: Optional[int] = None,
|
|
58
|
+
details: Union[None, bool, Callable[[Job], bool]] = None,
|
|
59
|
+
country: str = "", workday_queries: Optional[Iterable[str]] = None) -> List[Job]:
|
|
60
|
+
"""Open roles from one employer's board (or every board `detect` finds for a name).
|
|
61
|
+
|
|
62
|
+
query words that must all appear in the title (Workday also searches with it)
|
|
63
|
+
location text that must appear in a location ("London", "Remote")
|
|
64
|
+
remote True for remote roles only, False for on-site and hybrid only
|
|
65
|
+
since_days posted within this many days (roles with no date are kept)
|
|
66
|
+
limit at most this many roles, newest first
|
|
67
|
+
details Workday only: fetch each posting's page for its description (one
|
|
68
|
+
request each). Defaults to True when `query` narrows the board.
|
|
69
|
+
country Workday only: narrow the board to one country by name ("Canada")
|
|
70
|
+
"""
|
|
71
|
+
from .providers import READERS
|
|
72
|
+
since = (_dt.date.today() - _dt.timedelta(days=since_days)) if since_days else None
|
|
73
|
+
jobs: List[Job] = []
|
|
74
|
+
for board in _boards(target):
|
|
75
|
+
reader = READERS[board.ats]
|
|
76
|
+
if board.ats == "workday":
|
|
77
|
+
queries = list(workday_queries) if workday_queries else (query or None)
|
|
78
|
+
got = reader.fetch(board.token, query=queries, country=country,
|
|
79
|
+
details=bool(query) if details is None else details)
|
|
80
|
+
else:
|
|
81
|
+
got = reader.fetch(board.token)
|
|
82
|
+
jobs.extend(j for j in got if _matches(j, query, location, remote, since))
|
|
83
|
+
jobs.sort(key=lambda j: j.posted or "", reverse=True)
|
|
84
|
+
return jobs[:limit] if limit else jobs
|
openroles/__main__.py
ADDED
openroles/_http.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""The one place openroles talks to the network.
|
|
2
|
+
|
|
3
|
+
Polite by construction: at most PER_HOST requests in flight to any one host, a timeout
|
|
4
|
+
on every call, an honest User-Agent, and robots.txt read once per host per process.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import http.client
|
|
9
|
+
import json
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.parse
|
|
14
|
+
import urllib.request
|
|
15
|
+
from typing import Optional, Tuple
|
|
16
|
+
|
|
17
|
+
from . import __version__
|
|
18
|
+
from .robots import allowed, is_robots_body
|
|
19
|
+
|
|
20
|
+
USER_AGENT = f"openroles/{__version__} (+https://github.com/aranculleton/openroles)"
|
|
21
|
+
TIMEOUT = 20
|
|
22
|
+
PER_HOST = 2
|
|
23
|
+
RETRY_PAUSE = 2.0
|
|
24
|
+
|
|
25
|
+
_SLOTS: dict = {}
|
|
26
|
+
_SLOTS_LOCK = threading.Lock()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _slot(host: str) -> threading.BoundedSemaphore:
|
|
30
|
+
with _SLOTS_LOCK:
|
|
31
|
+
return _SLOTS.setdefault(host.lower(), threading.BoundedSemaphore(PER_HOST))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def request(url: str, payload: Optional[dict] = None,
|
|
35
|
+
accept: str = "application/json") -> Tuple[Optional[object], Optional[str]]:
|
|
36
|
+
"""(parsed JSON, None) or (None, why). A GET, or a JSON POST when `payload` is given."""
|
|
37
|
+
raw, err = request_raw(url, payload, accept)
|
|
38
|
+
if err:
|
|
39
|
+
return None, err
|
|
40
|
+
try:
|
|
41
|
+
return json.loads(raw), None
|
|
42
|
+
except ValueError:
|
|
43
|
+
return None, "the response was not JSON"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def request_raw(url: str, payload: Optional[dict] = None,
|
|
47
|
+
accept: str = "*/*", limit: int = 100_000_000) -> Tuple[str, Optional[str]]:
|
|
48
|
+
"""(body as text, None) or ("", why)."""
|
|
49
|
+
headers = {"User-Agent": USER_AGENT, "Accept": accept}
|
|
50
|
+
data = None
|
|
51
|
+
if payload is not None:
|
|
52
|
+
data = json.dumps(payload).encode("utf-8")
|
|
53
|
+
headers["Content-Type"] = "application/json"
|
|
54
|
+
host = urllib.parse.urlsplit(url).netloc
|
|
55
|
+
err = None
|
|
56
|
+
# One retry, after a pause, for a dropped connection or a gateway error. Never for
|
|
57
|
+
# 429 or 503: those mean slow down, and the caller decides what that means.
|
|
58
|
+
for attempt in range(2):
|
|
59
|
+
if attempt:
|
|
60
|
+
time.sleep(RETRY_PAUSE)
|
|
61
|
+
try:
|
|
62
|
+
req = urllib.request.Request(url, data=data, headers=headers,
|
|
63
|
+
method="POST" if data is not None else "GET")
|
|
64
|
+
with _slot(host):
|
|
65
|
+
with urllib.request.urlopen(req, timeout=TIMEOUT) as fh:
|
|
66
|
+
return fh.read(limit).decode("utf-8", "replace"), None
|
|
67
|
+
except urllib.error.HTTPError as e:
|
|
68
|
+
err = f"HTTP {e.code}"
|
|
69
|
+
if e.code not in (502, 504):
|
|
70
|
+
break
|
|
71
|
+
# HTTPException covers a body cut off mid-read, which is not an OSError.
|
|
72
|
+
except (urllib.error.URLError, http.client.HTTPException, OSError, ValueError) as e:
|
|
73
|
+
err = type(e).__name__
|
|
74
|
+
return "", err
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ------------------------------------------------------------------ robots.txt
|
|
78
|
+
|
|
79
|
+
# Statuses that mean no policy is published. RFC 9309 section 2.3.1.3 reads an
|
|
80
|
+
# unavailable robots.txt as no restriction. A 403 is left out on purpose: on
|
|
81
|
+
# robots.txt it is usually a firewall refusing an unknown client. A 429, a 5xx or
|
|
82
|
+
# no answer means the policy could not be read, which the RFC treats as disallow.
|
|
83
|
+
NO_POLICY = {401, 404, 410}
|
|
84
|
+
AGENTS = ("*", "openroles")
|
|
85
|
+
|
|
86
|
+
_ROBOTS: dict = {}
|
|
87
|
+
_ROBOTS_LOCK = threading.Lock()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def robots_txt(host: str) -> Tuple[int, str]:
|
|
91
|
+
"""(HTTP status, body) for https://<host>/robots.txt, fetched once per process.
|
|
92
|
+
|
|
93
|
+
Status 0 means no answer at all.
|
|
94
|
+
"""
|
|
95
|
+
key = host.lower()
|
|
96
|
+
with _ROBOTS_LOCK:
|
|
97
|
+
if key in _ROBOTS:
|
|
98
|
+
return _ROBOTS[key]
|
|
99
|
+
req = urllib.request.Request(f"https://{key}/robots.txt",
|
|
100
|
+
headers={"User-Agent": USER_AGENT,
|
|
101
|
+
"Accept": "text/plain,*/*"})
|
|
102
|
+
try:
|
|
103
|
+
with _slot(key):
|
|
104
|
+
with urllib.request.urlopen(req, timeout=TIMEOUT) as fh:
|
|
105
|
+
got = (fh.status, fh.read(512_000).decode("utf-8", "replace"))
|
|
106
|
+
except urllib.error.HTTPError as e:
|
|
107
|
+
got = (e.code, "")
|
|
108
|
+
except (urllib.error.URLError, http.client.HTTPException, OSError, ValueError):
|
|
109
|
+
got = (0, "")
|
|
110
|
+
with _ROBOTS_LOCK:
|
|
111
|
+
_ROBOTS[key] = got
|
|
112
|
+
return got
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def robots_allows(host: str, paths) -> Tuple[bool, str]:
|
|
116
|
+
"""(may these paths be read, why), from the host's robots.txt."""
|
|
117
|
+
status, body = robots_txt(host)
|
|
118
|
+
if status == 200:
|
|
119
|
+
if not is_robots_body(body):
|
|
120
|
+
return False, ("robots.txt answered 200 with something that is not a robots "
|
|
121
|
+
"file, so permission is unconfirmed")
|
|
122
|
+
for path in paths:
|
|
123
|
+
for agent in AGENTS:
|
|
124
|
+
if not allowed(body, agent, path):
|
|
125
|
+
return False, f"robots.txt on {host} disallows {path}"
|
|
126
|
+
return True, "robots.txt allows it"
|
|
127
|
+
if status in NO_POLICY:
|
|
128
|
+
return True, f"no robots.txt (HTTP {status}), which RFC 9309 reads as no restriction"
|
|
129
|
+
seen = f"HTTP {status}" if status else "no answer"
|
|
130
|
+
return False, f"robots.txt on {host} could not be read ({seen}), so permission is unconfirmed"
|
openroles/cli.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""openroles on the command line.
|
|
2
|
+
|
|
3
|
+
openroles <company, careers URL, board URL or ats:token> [filters]
|
|
4
|
+
openroles detect <company or URL>
|
|
5
|
+
openroles mcp # serve the tools to an MCP client over stdio
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import sys
|
|
13
|
+
from typing import List, Optional
|
|
14
|
+
|
|
15
|
+
from . import BoardError, Job, __version__, detect, fetch
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _table(jobs: List[Job]) -> str:
|
|
19
|
+
if not jobs:
|
|
20
|
+
return "no open roles matched"
|
|
21
|
+
rows = [(j.posted or "-", j.title, j.location or "-", j.url) for j in jobs]
|
|
22
|
+
w0 = max(len(r[0]) for r in rows)
|
|
23
|
+
w1 = min(60, max(len(r[1]) for r in rows))
|
|
24
|
+
w2 = min(40, max(len(r[2]) for r in rows))
|
|
25
|
+
out = [f"{r[0]:<{w0}} {r[1][:w1]:<{w1}} {r[2][:w2]:<{w2}} {r[3]}" for r in rows]
|
|
26
|
+
return "\n".join(out) + f"\n\n{len(jobs)} open role(s)"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
30
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
31
|
+
if argv and argv[0] == "mcp":
|
|
32
|
+
from .mcp import serve
|
|
33
|
+
return serve()
|
|
34
|
+
if argv and argv[0] == "detect":
|
|
35
|
+
ap = argparse.ArgumentParser(prog="openroles detect",
|
|
36
|
+
description="Find the job boards behind a company or URL.")
|
|
37
|
+
ap.add_argument("target")
|
|
38
|
+
a = ap.parse_args(argv[1:])
|
|
39
|
+
boards = detect(a.target)
|
|
40
|
+
for b in boards:
|
|
41
|
+
print(b)
|
|
42
|
+
if not boards:
|
|
43
|
+
print(f"no Greenhouse, Lever, Ashby or Workday board found for {a.target!r}",
|
|
44
|
+
file=sys.stderr)
|
|
45
|
+
return 0 if boards else 1
|
|
46
|
+
|
|
47
|
+
ap = argparse.ArgumentParser(
|
|
48
|
+
prog="openroles",
|
|
49
|
+
description="Every open role, straight from the employer's own ATS "
|
|
50
|
+
"(Greenhouse, Lever, Ashby, Workday).",
|
|
51
|
+
epilog="Also: `openroles detect <target>` and `openroles mcp`.")
|
|
52
|
+
ap.add_argument("target", help="a company name, a careers or board URL, or ats:token")
|
|
53
|
+
ap.add_argument("-q", "--query", help="words that must all appear in the title")
|
|
54
|
+
ap.add_argument("-l", "--location", help="text that must appear in a location")
|
|
55
|
+
ap.add_argument("--remote", action="store_true", help="remote roles only")
|
|
56
|
+
ap.add_argument("--since", type=int, metavar="DAYS", help="posted within DAYS days")
|
|
57
|
+
ap.add_argument("-n", "--limit", type=int, help="at most N roles, newest first")
|
|
58
|
+
ap.add_argument("--country", default="", help="Workday: narrow to one country by name")
|
|
59
|
+
ap.add_argument("--details", action="store_true",
|
|
60
|
+
help="Workday: fetch every posting's description (one request each)")
|
|
61
|
+
ap.add_argument("--json", action="store_true", help="JSON array (the default when piped)")
|
|
62
|
+
ap.add_argument("--jsonl", action="store_true", help="one JSON object per line")
|
|
63
|
+
ap.add_argument("-v", "--verbose", action="store_true")
|
|
64
|
+
ap.add_argument("--version", action="version", version=f"openroles {__version__}")
|
|
65
|
+
a = ap.parse_args(argv)
|
|
66
|
+
logging.basicConfig(level=logging.INFO if a.verbose else logging.WARNING,
|
|
67
|
+
format="%(name)s: %(message)s")
|
|
68
|
+
try:
|
|
69
|
+
jobs = fetch(a.target, query=a.query, location=a.location,
|
|
70
|
+
remote=True if a.remote else None, since_days=a.since, limit=a.limit,
|
|
71
|
+
details=True if a.details else None, country=a.country)
|
|
72
|
+
except BoardError as e:
|
|
73
|
+
print(f"openroles: {e}", file=sys.stderr)
|
|
74
|
+
return 1
|
|
75
|
+
if a.jsonl:
|
|
76
|
+
for j in jobs:
|
|
77
|
+
print(json.dumps(j.to_dict(), ensure_ascii=False))
|
|
78
|
+
elif a.json or not sys.stdout.isatty():
|
|
79
|
+
print(json.dumps([j.to_dict() for j in jobs], ensure_ascii=False, indent=2))
|
|
80
|
+
else:
|
|
81
|
+
print(_table(jobs))
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
sys.exit(main())
|
openroles/discover.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Which ATS an employer's jobs live on, from a URL, a careers page or a company name."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
from urllib.parse import parse_qs, urlsplit
|
|
7
|
+
|
|
8
|
+
from . import _http
|
|
9
|
+
from .models import ATS, Board
|
|
10
|
+
from .providers import ashby, greenhouse, lever, workday
|
|
11
|
+
|
|
12
|
+
_SLUG = r"([A-Za-z0-9][A-Za-z0-9_.-]*)"
|
|
13
|
+
PATTERNS = [
|
|
14
|
+
("greenhouse", re.compile(r"(?:job-boards|boards)(?:\.eu)?\.greenhouse\.io/embed/job_board(?:/js)?\?for=" + _SLUG, re.I)),
|
|
15
|
+
("greenhouse", re.compile(r"boards-api\.greenhouse\.io/v1/boards/" + _SLUG, re.I)),
|
|
16
|
+
("greenhouse", re.compile(r"(?:job-boards|boards)(?:\.eu)?\.greenhouse\.io/(?!embed\b)" + _SLUG, re.I)),
|
|
17
|
+
("lever", re.compile(r"jobs(?:\.eu)?\.lever\.co/" + _SLUG, re.I)),
|
|
18
|
+
("lever", re.compile(r"api(?:\.eu)?\.lever\.co/v0/postings/" + _SLUG, re.I)),
|
|
19
|
+
("ashby", re.compile(r"jobs\.ashbyhq\.com/" + _SLUG, re.I)),
|
|
20
|
+
("ashby", re.compile(r"api\.ashbyhq\.com/posting-api/job-board/" + _SLUG, re.I)),
|
|
21
|
+
]
|
|
22
|
+
_NOT_A_BOARD = {"embed", "js", "v1", "api", "static", "assets", "favicon.ico", "robots.txt"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse(text: str) -> Optional[Board]:
|
|
26
|
+
"""A Board from "ats:token" or from any board URL. None when it names no board."""
|
|
27
|
+
text = (text or "").strip()
|
|
28
|
+
head, sep, tail = text.partition(":")
|
|
29
|
+
if sep and head.lower() in ATS and not tail.startswith("//"):
|
|
30
|
+
token = tail.strip()
|
|
31
|
+
if head.lower() == "workday":
|
|
32
|
+
token = workday.token_from(token) or token
|
|
33
|
+
return Board(head.lower(), token) if token else None
|
|
34
|
+
wd = workday.token_from(text)
|
|
35
|
+
if wd:
|
|
36
|
+
return Board("workday", wd)
|
|
37
|
+
if "greenhouse.io" in text and "for=" in text:
|
|
38
|
+
q = parse_qs(urlsplit(text if "//" in text else "https://" + text).query)
|
|
39
|
+
if q.get("for"):
|
|
40
|
+
return Board("greenhouse", q["for"][0])
|
|
41
|
+
for ats, rx in PATTERNS:
|
|
42
|
+
m = rx.search(text)
|
|
43
|
+
if m and m.group(1).lower() not in _NOT_A_BOARD:
|
|
44
|
+
return Board(ats, m.group(1).rstrip("."))
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def in_page(url: str) -> List[Board]:
|
|
49
|
+
"""Every board a careers page links to or embeds, if its robots.txt allows reading it."""
|
|
50
|
+
parts = urlsplit(url if "//" in url else "https://" + url)
|
|
51
|
+
path = parts.path or "/"
|
|
52
|
+
ok, _why = _http.robots_allows(parts.netloc, [path])
|
|
53
|
+
if not ok:
|
|
54
|
+
return []
|
|
55
|
+
body, err = _http.request_raw(parts.geturl(), accept="text/html,*/*", limit=3_000_000)
|
|
56
|
+
if err:
|
|
57
|
+
return []
|
|
58
|
+
found = []
|
|
59
|
+
for m in re.finditer(r"https?://[^\s\"'<>)]+", body):
|
|
60
|
+
board = parse(m.group(0))
|
|
61
|
+
if board and board not in found:
|
|
62
|
+
found.append(board)
|
|
63
|
+
return found
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def slugs(company: str) -> List[str]:
|
|
67
|
+
"""The board names an employer is likely to use: "Acme Data Ltd" -> acmedata, acme-data, acme."""
|
|
68
|
+
words = re.findall(r"[a-z0-9]+", company.lower().replace("&", " and "))
|
|
69
|
+
suffixes = {"inc", "ltd", "llc", "plc", "limited", "corp", "corporation", "co", "group",
|
|
70
|
+
"gmbh", "ag", "sa", "bv", "the"}
|
|
71
|
+
core = [w for w in words if w not in suffixes] or words
|
|
72
|
+
out = ["".join(core), "-".join(core), core[0] if core else ""]
|
|
73
|
+
return [s for s in dict.fromkeys(out) if s]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def guess(company: str) -> List[Board]:
|
|
77
|
+
"""Boards found by trying the employer's likely board names on Greenhouse, Lever and Ashby.
|
|
78
|
+
|
|
79
|
+
Workday cannot be guessed from a name (its address needs a tenant, a data centre
|
|
80
|
+
and a site name): give its careers URL instead.
|
|
81
|
+
"""
|
|
82
|
+
found = []
|
|
83
|
+
for slug in slugs(company):
|
|
84
|
+
for ats, reader in (("greenhouse", greenhouse), ("lever", lever), ("ashby", ashby)):
|
|
85
|
+
if reader.exists(slug):
|
|
86
|
+
board = Board(ats, slug)
|
|
87
|
+
if board not in found:
|
|
88
|
+
found.append(board)
|
|
89
|
+
if found:
|
|
90
|
+
break
|
|
91
|
+
return found
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def detect(target: str) -> List[Board]:
|
|
95
|
+
"""The boards behind a board URL, an "ats:token", a careers page or a company name."""
|
|
96
|
+
board = parse(target)
|
|
97
|
+
if board:
|
|
98
|
+
return [board]
|
|
99
|
+
if re.match(r"^(https?://|www\.)|^[a-z0-9-]+(\.[a-z0-9-]+)+(/|$)", target.strip(), re.I):
|
|
100
|
+
return in_page(target.strip())
|
|
101
|
+
return guess(target)
|
openroles/mcp.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""A Model Context Protocol server over stdio, with no dependencies.
|
|
2
|
+
|
|
3
|
+
openroles mcp
|
|
4
|
+
|
|
5
|
+
Add it to an MCP client (Claude Code, Claude Desktop, Cursor and others) as a stdio
|
|
6
|
+
server whose command is `openroles` with the argument `mcp`, or `uvx openroles mcp`.
|
|
7
|
+
It offers two tools: find_job_board and list_open_roles.
|
|
8
|
+
|
|
9
|
+
The protocol is JSON-RPC 2.0, one message per line on stdin and stdout. Only what a
|
|
10
|
+
tools server needs is implemented: initialize, ping, tools/list and tools/call.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
from . import BoardError, __version__, detect, fetch
|
|
19
|
+
|
|
20
|
+
PROTOCOL_VERSIONS = ("2025-06-18", "2025-03-26", "2024-11-05")
|
|
21
|
+
DESCRIPTION_CHARS = 4000
|
|
22
|
+
|
|
23
|
+
TOOLS = [
|
|
24
|
+
{
|
|
25
|
+
"name": "find_job_board",
|
|
26
|
+
"description": ("Find which applicant tracking system (Greenhouse, Lever, Ashby or "
|
|
27
|
+
"Workday) an employer posts its jobs on. Give a company name, its "
|
|
28
|
+
"careers page URL or a job board URL. Returns boards as ats:token, "
|
|
29
|
+
"which list_open_roles accepts."),
|
|
30
|
+
"inputSchema": {
|
|
31
|
+
"type": "object",
|
|
32
|
+
"properties": {"target": {"type": "string",
|
|
33
|
+
"description": "company name, careers page URL or board URL"}},
|
|
34
|
+
"required": ["target"],
|
|
35
|
+
},
|
|
36
|
+
"annotations": {"readOnlyHint": True, "openWorldHint": True},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"name": "list_open_roles",
|
|
40
|
+
"description": ("List an employer's open roles straight from its own job board, newest "
|
|
41
|
+
"first: title, location, remote, posted date, department, pay where "
|
|
42
|
+
"posted, and the URL to apply. Filter by title words, location and "
|
|
43
|
+
"remote. Descriptions are left out unless asked for, to keep the "
|
|
44
|
+
"answer short."),
|
|
45
|
+
"inputSchema": {
|
|
46
|
+
"type": "object",
|
|
47
|
+
"properties": {
|
|
48
|
+
"target": {"type": "string", "description": "ats:token, a board or careers URL, or a company name"},
|
|
49
|
+
"query": {"type": "string", "description": "words that must all appear in the title"},
|
|
50
|
+
"location": {"type": "string", "description": "text that must appear in a location, e.g. London or Remote"},
|
|
51
|
+
"remote_only": {"type": "boolean", "default": False},
|
|
52
|
+
"since_days": {"type": "integer", "minimum": 1},
|
|
53
|
+
"limit": {"type": "integer", "minimum": 1, "maximum": 200, "default": 25},
|
|
54
|
+
"include_descriptions": {"type": "boolean", "default": False},
|
|
55
|
+
"country": {"type": "string", "description": "Workday only: narrow to one country by name"},
|
|
56
|
+
},
|
|
57
|
+
"required": ["target"],
|
|
58
|
+
},
|
|
59
|
+
"annotations": {"readOnlyHint": True, "openWorldHint": True},
|
|
60
|
+
},
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _result(payload, is_error: bool = False) -> dict:
|
|
65
|
+
text = payload if isinstance(payload, str) else json.dumps(payload, ensure_ascii=False)
|
|
66
|
+
out = {"content": [{"type": "text", "text": text}], "isError": is_error}
|
|
67
|
+
if not is_error and not isinstance(payload, str):
|
|
68
|
+
out["structuredContent"] = {"result": payload}
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def call_tool(name: str, args: dict) -> dict:
|
|
73
|
+
try:
|
|
74
|
+
if name == "find_job_board":
|
|
75
|
+
boards = detect(str(args.get("target") or ""))
|
|
76
|
+
return _result([str(b) for b in boards] if boards else
|
|
77
|
+
"no Greenhouse, Lever, Ashby or Workday board found")
|
|
78
|
+
if name == "list_open_roles":
|
|
79
|
+
jobs = fetch(str(args.get("target") or ""), query=args.get("query") or None,
|
|
80
|
+
location=args.get("location") or None,
|
|
81
|
+
remote=True if args.get("remote_only") else None,
|
|
82
|
+
since_days=args.get("since_days"),
|
|
83
|
+
limit=int(args.get("limit") or 25),
|
|
84
|
+
details=bool(args.get("include_descriptions")) or None,
|
|
85
|
+
country=str(args.get("country") or ""))
|
|
86
|
+
rows = []
|
|
87
|
+
for j in jobs:
|
|
88
|
+
d = j.to_dict()
|
|
89
|
+
if args.get("include_descriptions"):
|
|
90
|
+
d["description"] = (d.get("description") or "")[:DESCRIPTION_CHARS] or None
|
|
91
|
+
else:
|
|
92
|
+
d.pop("description", None)
|
|
93
|
+
rows.append(d)
|
|
94
|
+
return _result(rows)
|
|
95
|
+
return _result(f"unknown tool {name!r}", is_error=True)
|
|
96
|
+
except BoardError as e:
|
|
97
|
+
return _result(str(e), is_error=True)
|
|
98
|
+
except Exception as e: # noqa: BLE001 a tool error, never a dead server
|
|
99
|
+
return _result(f"{type(e).__name__}: {e}", is_error=True)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def handle(msg: dict) -> Optional[dict]:
|
|
103
|
+
"""The response to one JSON-RPC message, or None for a notification."""
|
|
104
|
+
method, mid = msg.get("method"), msg.get("id")
|
|
105
|
+
if mid is None:
|
|
106
|
+
return None
|
|
107
|
+
if method == "initialize":
|
|
108
|
+
asked = (msg.get("params") or {}).get("protocolVersion")
|
|
109
|
+
result = {
|
|
110
|
+
"protocolVersion": asked if asked in PROTOCOL_VERSIONS else PROTOCOL_VERSIONS[0],
|
|
111
|
+
"capabilities": {"tools": {"listChanged": False}},
|
|
112
|
+
"serverInfo": {"name": "openroles", "version": __version__},
|
|
113
|
+
"instructions": ("Use find_job_board to locate an employer's board, then "
|
|
114
|
+
"list_open_roles to read its open roles."),
|
|
115
|
+
}
|
|
116
|
+
elif method == "ping":
|
|
117
|
+
result = {}
|
|
118
|
+
elif method == "tools/list":
|
|
119
|
+
result = {"tools": TOOLS}
|
|
120
|
+
elif method == "tools/call":
|
|
121
|
+
params = msg.get("params") or {}
|
|
122
|
+
result = call_tool(str(params.get("name")), params.get("arguments") or {})
|
|
123
|
+
else:
|
|
124
|
+
return {"jsonrpc": "2.0", "id": mid,
|
|
125
|
+
"error": {"code": -32601, "message": f"method not found: {method}"}}
|
|
126
|
+
return {"jsonrpc": "2.0", "id": mid, "result": result}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def serve(stdin=None, stdout=None) -> int:
|
|
130
|
+
stdin, stdout = stdin or sys.stdin, stdout or sys.stdout
|
|
131
|
+
for line in stdin:
|
|
132
|
+
line = line.strip()
|
|
133
|
+
if not line:
|
|
134
|
+
continue
|
|
135
|
+
try:
|
|
136
|
+
msg = json.loads(line)
|
|
137
|
+
except ValueError:
|
|
138
|
+
reply = {"jsonrpc": "2.0", "id": None,
|
|
139
|
+
"error": {"code": -32700, "message": "parse error"}}
|
|
140
|
+
else:
|
|
141
|
+
reply = handle(msg) if isinstance(msg, dict) else None
|
|
142
|
+
if reply is not None:
|
|
143
|
+
stdout.write(json.dumps(reply, ensure_ascii=False) + "\n")
|
|
144
|
+
stdout.flush()
|
|
145
|
+
return 0
|
openroles/models.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""What openroles returns: one shape for every applicant tracking system."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import html
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
ATS = ("greenhouse", "lever", "ashby", "workday")
|
|
10
|
+
BODY_CAP = 50_000
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BoardError(Exception):
|
|
14
|
+
"""A board could not be read: a wrong token, no answer, or robots.txt says no."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RobotsDisallowed(BoardError):
|
|
18
|
+
"""The host's robots.txt does not allow this board to be read."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Board:
|
|
23
|
+
"""One employer's job board on one ATS.
|
|
24
|
+
|
|
25
|
+
`token` is the board's name on its ATS: `acme` for Greenhouse, Lever and Ashby,
|
|
26
|
+
and `tenant.wdN/Site` for Workday (from https://tenant.wdN.myworkdayjobs.com/Site).
|
|
27
|
+
"""
|
|
28
|
+
ats: str
|
|
29
|
+
token: str
|
|
30
|
+
|
|
31
|
+
def __post_init__(self):
|
|
32
|
+
if self.ats not in ATS:
|
|
33
|
+
raise ValueError(f"unknown ATS {self.ats!r}; expected one of {', '.join(ATS)}")
|
|
34
|
+
|
|
35
|
+
def __str__(self) -> str:
|
|
36
|
+
return f"{self.ats}:{self.token}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Job:
|
|
41
|
+
"""One open role, in the same shape whichever ATS it came from."""
|
|
42
|
+
id: str
|
|
43
|
+
ats: str
|
|
44
|
+
board: str
|
|
45
|
+
title: str
|
|
46
|
+
url: str
|
|
47
|
+
location: str = ""
|
|
48
|
+
locations: List[str] = field(default_factory=list)
|
|
49
|
+
remote: Optional[bool] = None
|
|
50
|
+
posted: str = ""
|
|
51
|
+
department: str = ""
|
|
52
|
+
employment_type: str = ""
|
|
53
|
+
compensation: str = ""
|
|
54
|
+
deadline: str = ""
|
|
55
|
+
description: Optional[str] = None
|
|
56
|
+
|
|
57
|
+
def to_dict(self) -> dict:
|
|
58
|
+
return asdict(self)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_TAG = re.compile(r"<[^>]+>")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def html_to_text(content: str) -> str:
|
|
65
|
+
"""Plain text from an ATS's HTML, which some boards entity-encode twice."""
|
|
66
|
+
s = content or ""
|
|
67
|
+
for _ in range(3):
|
|
68
|
+
un = html.unescape(s)
|
|
69
|
+
if un == s:
|
|
70
|
+
break
|
|
71
|
+
s = un
|
|
72
|
+
s = re.sub(r"<(br|/p|/li|/div|/h\d)[^>]*>", "\n", s, flags=re.I)
|
|
73
|
+
s = _TAG.sub(" ", s)
|
|
74
|
+
s = re.sub(r"[ \t]{2,}", " ", re.sub(r"\n{3,}", "\n\n", s))
|
|
75
|
+
return "\n".join(line.strip() for line in s.splitlines()).strip()[:BODY_CAP]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def clean(value) -> str:
|
|
79
|
+
"""One line of text, whitespace collapsed."""
|
|
80
|
+
return " ".join(str(value or "").split())
|