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 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
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
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())
@@ -0,0 +1,6 @@
1
+ """One reader per applicant tracking system."""
2
+ from . import ashby, greenhouse, lever, workday
3
+
4
+ READERS = {"greenhouse": greenhouse, "lever": lever, "ashby": ashby, "workday": workday}
5
+
6
+ __all__ = ["READERS", "ashby", "greenhouse", "lever", "workday"]