atsfill 0.0.4.dev73__tar.gz → 0.0.4.dev75__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.
Files changed (40) hide show
  1. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/PKG-INFO +2 -2
  2. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/README.md +1 -1
  3. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/__init__.py +2 -0
  6. atsfill-0.0.4.dev75/src/atsfill/adapters/personio.py +293 -0
  7. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/registry.toml +2 -0
  8. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/LICENSE +0 -0
  9. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/__init__.py +0 -0
  10. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/ashby.py +0 -0
  11. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/bamboohr.py +0 -0
  12. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/breezy.py +0 -0
  13. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/dayforce.py +0 -0
  14. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/greenhouse.py +0 -0
  15. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/hireology.py +0 -0
  16. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/jazzhr.py +0 -0
  17. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/lever.py +0 -0
  18. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/manatal.py +0 -0
  19. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/oracle.py +0 -0
  20. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/paycor.py +0 -0
  21. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/paylocity.py +0 -0
  22. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/phenom.py +0 -0
  23. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/recruitee.py +0 -0
  24. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/smartrecruiters.py +0 -0
  25. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/teamtailor.py +0 -0
  26. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/workable.py +0 -0
  27. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/wttj.py +0 -0
  28. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/adapters/zohorecruit.py +0 -0
  29. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/cli.py +0 -0
  30. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/core/__init__.py +0 -0
  31. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/core/accounts.py +0 -0
  32. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/core/adapter.py +0 -0
  33. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/core/attempts.py +0 -0
  34. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/core/browser.py +0 -0
  35. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/core/commands.py +0 -0
  36. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/core/matching.py +0 -0
  37. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/core/paths.py +0 -0
  38. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/core/registry.py +0 -0
  39. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/core/results.py +0 -0
  40. {atsfill-0.0.4.dev73 → atsfill-0.0.4.dev75}/src/atsfill/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atsfill
3
- Version: 0.0.4.dev73
3
+ Version: 0.0.4.dev75
4
4
  Summary: Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle.
5
5
  Keywords: job-applications,ats,ashby,greenhouse,teamtailor,ai-agents,cli
6
6
  Author: shbernal
@@ -54,7 +54,7 @@ It is built for agents with a shell: every command prints one JSON document and
54
54
  ## Supported hiring systems
55
55
 
56
56
  <!-- badges -->
57
- ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![dayforce: dry run verified 2026-10-20](https://img.shields.io/badge/dayforce-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![bamboohr: dry run verified 2026-10-21](https://img.shields.io/badge/bamboohr-dry%20run%20verified%202026--10--21-7aa2f7) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![jazzhr: dry run verified 2026-10-20](https://img.shields.io/badge/jazzhr-dry%20run%20verified%202026--10--20-7aa2f7) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![hireology: dry run verified 2026-10-20](https://img.shields.io/badge/hireology-dry%20run%20verified%202026--10--20-7aa2f7) ![paycor: dry run verified 2026-10-20](https://img.shields.io/badge/paycor-dry%20run%20verified%202026--10--20-7aa2f7) ![zohorecruit: dry run verified 2026-10-22](https://img.shields.io/badge/zohorecruit-dry%20run%20verified%202026--10--22-7aa2f7) ![phenom: dry run verified 2026-10-21](https://img.shields.io/badge/phenom-dry%20run%20verified%202026--10--21-7aa2f7) ![breezy: dry run verified 2026-10-21](https://img.shields.io/badge/breezy-dry%20run%20verified%202026--10--21-7aa2f7) ![recruitee: dry run verified 2026-10-21](https://img.shields.io/badge/recruitee-dry%20run%20verified%202026--10--21-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
57
+ ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![dayforce: dry run verified 2026-10-20](https://img.shields.io/badge/dayforce-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![bamboohr: dry run verified 2026-10-21](https://img.shields.io/badge/bamboohr-dry%20run%20verified%202026--10--21-7aa2f7) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![jazzhr: dry run verified 2026-10-20](https://img.shields.io/badge/jazzhr-dry%20run%20verified%202026--10--20-7aa2f7) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![hireology: dry run verified 2026-10-20](https://img.shields.io/badge/hireology-dry%20run%20verified%202026--10--20-7aa2f7) ![paycor: dry run verified 2026-10-20](https://img.shields.io/badge/paycor-dry%20run%20verified%202026--10--20-7aa2f7) ![zohorecruit: dry run verified 2026-10-22](https://img.shields.io/badge/zohorecruit-dry%20run%20verified%202026--10--22-7aa2f7) ![phenom: dry run verified 2026-10-21](https://img.shields.io/badge/phenom-dry%20run%20verified%202026--10--21-7aa2f7) ![breezy: dry run verified 2026-10-21](https://img.shields.io/badge/breezy-dry%20run%20verified%202026--10--21-7aa2f7) ![recruitee: dry run verified 2026-10-21](https://img.shields.io/badge/recruitee-dry%20run%20verified%202026--10--21-7aa2f7) ![personio: dry run verified 2026-10-22](https://img.shields.io/badge/personio-dry%20run%20verified%202026--10--22-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
58
58
  <!-- /badges -->
59
59
 
60
60
  | System | Account needed | `read` without a browser |
@@ -29,7 +29,7 @@ It is built for agents with a shell: every command prints one JSON document and
29
29
  ## Supported hiring systems
30
30
 
31
31
  <!-- badges -->
32
- ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![dayforce: dry run verified 2026-10-20](https://img.shields.io/badge/dayforce-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![bamboohr: dry run verified 2026-10-21](https://img.shields.io/badge/bamboohr-dry%20run%20verified%202026--10--21-7aa2f7) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![jazzhr: dry run verified 2026-10-20](https://img.shields.io/badge/jazzhr-dry%20run%20verified%202026--10--20-7aa2f7) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![hireology: dry run verified 2026-10-20](https://img.shields.io/badge/hireology-dry%20run%20verified%202026--10--20-7aa2f7) ![paycor: dry run verified 2026-10-20](https://img.shields.io/badge/paycor-dry%20run%20verified%202026--10--20-7aa2f7) ![zohorecruit: dry run verified 2026-10-22](https://img.shields.io/badge/zohorecruit-dry%20run%20verified%202026--10--22-7aa2f7) ![phenom: dry run verified 2026-10-21](https://img.shields.io/badge/phenom-dry%20run%20verified%202026--10--21-7aa2f7) ![breezy: dry run verified 2026-10-21](https://img.shields.io/badge/breezy-dry%20run%20verified%202026--10--21-7aa2f7) ![recruitee: dry run verified 2026-10-21](https://img.shields.io/badge/recruitee-dry%20run%20verified%202026--10--21-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
32
+ ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![dayforce: dry run verified 2026-10-20](https://img.shields.io/badge/dayforce-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![bamboohr: dry run verified 2026-10-21](https://img.shields.io/badge/bamboohr-dry%20run%20verified%202026--10--21-7aa2f7) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![jazzhr: dry run verified 2026-10-20](https://img.shields.io/badge/jazzhr-dry%20run%20verified%202026--10--20-7aa2f7) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![hireology: dry run verified 2026-10-20](https://img.shields.io/badge/hireology-dry%20run%20verified%202026--10--20-7aa2f7) ![paycor: dry run verified 2026-10-20](https://img.shields.io/badge/paycor-dry%20run%20verified%202026--10--20-7aa2f7) ![zohorecruit: dry run verified 2026-10-22](https://img.shields.io/badge/zohorecruit-dry%20run%20verified%202026--10--22-7aa2f7) ![phenom: dry run verified 2026-10-21](https://img.shields.io/badge/phenom-dry%20run%20verified%202026--10--21-7aa2f7) ![breezy: dry run verified 2026-10-21](https://img.shields.io/badge/breezy-dry%20run%20verified%202026--10--21-7aa2f7) ![recruitee: dry run verified 2026-10-21](https://img.shields.io/badge/recruitee-dry%20run%20verified%202026--10--21-7aa2f7) ![personio: dry run verified 2026-10-22](https://img.shields.io/badge/personio-dry%20run%20verified%202026--10--22-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
33
33
  <!-- /badges -->
34
34
 
35
35
  | System | Account needed | `read` without a browser |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev73"
3
+ version = "0.0.4.dev75"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev73"
3
+ version = "0.0.4.dev75"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -10,6 +10,7 @@ from atsfill.adapters.manatal import Manatal
10
10
  from atsfill.adapters.oracle import Oracle
11
11
  from atsfill.adapters.paycor import Paycor
12
12
  from atsfill.adapters.paylocity import Paylocity
13
+ from atsfill.adapters.personio import Personio
13
14
  from atsfill.adapters.phenom import Phenom
14
15
  from atsfill.adapters.recruitee import Recruitee
15
16
  from atsfill.adapters.smartrecruiters import SmartRecruiters
@@ -41,5 +42,6 @@ def adapters() -> list[Adapter]:
41
42
  Breezy(),
42
43
  BambooHR(),
43
44
  ZohoRecruit(),
45
+ Personio(),
44
46
  Phenom(), # last: it claims a URL by its path alone
45
47
  ]
@@ -0,0 +1,293 @@
1
+ """Personio (`<employer>.jobs.personio.com/job/<id>`), as docs/adapters/personio.md observes.
2
+
3
+ The apply page is rendered on the server, its form whole in the HTML: every field is one native
4
+ control named by its id, labelled by a `label[for]` or its group's label, a star marking it
5
+ required. `read_api` fetches that HTML over HTTP, `read_form` takes the page's form, and both
6
+ list the fields from it with one parser. A document is uploaded to Personio once attached.
7
+ """
8
+
9
+ import re
10
+ import urllib.request
11
+ from contextlib import suppress
12
+ from dataclasses import dataclass, field
13
+ from html.parser import HTMLParser
14
+ from urllib.parse import urlsplit
15
+
16
+ from atsfill.core.adapter import Blocked, Gap, Unsupported
17
+ from atsfill.core.browser import HTTP_TIMEOUT_S, Locator, Page, Timeout
18
+ from atsfill.core.results import Answer, Field, FieldType, SideEffect
19
+
20
+ HOST = re.compile(r"^([a-z0-9-]+)\.jobs\.personio\.(?:com|de)$")
21
+ JOB = re.compile(r"^/job/(\d+)(?:/apply)?/?$")
22
+ APPLIED = re.compile(r"/api/v1/jobs/\d+/application$")
23
+ FORM = "form:has(button[type=submit])"
24
+ SUBMIT = f"{FORM} button[type=submit]"
25
+ FORM_TIMEOUT_MS = 30_000
26
+ UPLOAD_TIMEOUT_MS = 30_000
27
+ ENABLED_TIMEOUT_MS = 30_000
28
+ SUBMIT_TIMEOUT_MS = 90_000
29
+ PHONE = "phone"
30
+ # Each control the form renders, by its tag or input type, and its field type.
31
+ KINDS = {
32
+ "text": FieldType.TEXT,
33
+ "email": FieldType.TEXT,
34
+ "date": FieldType.DATE,
35
+ "select": FieldType.CHOICE,
36
+ "file": FieldType.FILE,
37
+ }
38
+ VOID = {"input", "br", "img", "hr", "meta", "link", "source", "wbr", "col", "area"}
39
+
40
+ # What each control holds: a value, a select's option value, a document's uploaded names.
41
+ _READ_BACK = """(form, ids) => {
42
+ const out = {};
43
+ for (const id of ids) {
44
+ const el = form.querySelector(`[name="${id}"]`);
45
+ if (!el) continue;
46
+ if (el.type === "file") {
47
+ const group = el.closest("[role=group]");
48
+ out[id] = [...(group?.querySelectorAll("[role=listitem] span") ?? [])]
49
+ .map(e => e.textContent.trim());
50
+ } else out[id] = el.value;
51
+ }
52
+ return out;
53
+ }"""
54
+
55
+
56
+ class Personio:
57
+ name = "personio"
58
+ known_gaps: dict[str, Gap] = {}
59
+ side_effects = [
60
+ SideEffect("upload", "hiring_system", "a document is uploaded to Personio at once"),
61
+ SideEffect("submit", "employer", "the application reaches the employer"),
62
+ ]
63
+
64
+ def __init__(self) -> None:
65
+ self._options: dict[str, dict[str, str]] = {} # a choice's option texts by value
66
+ self._fields: list[Field] = []
67
+
68
+ def detect(self, url: str, page: Page | None) -> bool:
69
+ return _job(url) is not None
70
+
71
+ def posting(self, url: str) -> str:
72
+ job = _job(url)
73
+ return f"https://{job[0]}.jobs.personio.com/job/{job[1]}" if job else url
74
+
75
+ def read_api(self, url: str) -> list[Field] | None:
76
+ if _job(url) is None:
77
+ return None
78
+ request = urllib.request.Request(_apply(url), headers={"User-Agent": "atsfill"})
79
+ try:
80
+ with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
81
+ html = response.read().decode()
82
+ except OSError, ValueError:
83
+ return None
84
+ form = next((n for n in _parse(html).walk() if n.tag == "form"), None)
85
+ return [f for f, _ in _form(form)] if form else None
86
+
87
+ def read_form(self, page: Page) -> list[Field]:
88
+ if _job(page.url) and not urlsplit(page.url).path.rstrip("/").endswith("/apply"):
89
+ page.goto(_apply(page.url))
90
+ try:
91
+ page.locator(SUBMIT).wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
92
+ except Timeout:
93
+ raise Unsupported("posting_gone: no application form on the page") from None
94
+ listed = _form(_parse(page.locator(FORM).evaluate("f => f.outerHTML")))
95
+ self._options = {f.id: o for f, o in listed if o and not f.unfillable}
96
+ self._fields = [f for f, _ in listed if not f.unfillable]
97
+ return [f for f, _ in listed]
98
+
99
+ def fill(self, page: Page, answers: dict[str, Answer]) -> None:
100
+ form = page.locator(FORM)
101
+ for f in self._fields:
102
+ if f.id not in answers:
103
+ continue
104
+ answer = answers[f.id]
105
+ value = answer if isinstance(answer, str) else answer[0]
106
+ control = form.locator(f"[name='{f.id}']")
107
+ match f.type:
108
+ case FieldType.FILE:
109
+ _attach(control, value)
110
+ case FieldType.CHOICE:
111
+ by_text = {t: v for v, t in self._options[f.id].items()}
112
+ control.select_option(value=by_text.get(value, value))
113
+ case _:
114
+ control.fill(value)
115
+
116
+ def read_back(self, page: Page) -> dict[str, Answer]:
117
+ values = page.locator(FORM).evaluate(_READ_BACK, [f.id for f in self._fields])
118
+ out: dict[str, Answer] = {}
119
+ for field_id, value in values.items():
120
+ if field_id in self._options:
121
+ value = self._options[field_id].get(value, value)
122
+ elif isinstance(value, list): # a document's last upload
123
+ value = value[-1] if value else None
124
+ if value not in (None, ""):
125
+ out[field_id] = value
126
+ return out
127
+
128
+ def submit(self, page: Page) -> str | None:
129
+ """Waits for submit to be enabled (Turnstile), clicks it; the evidence is the answer to the
130
+ application's POST."""
131
+ button = page.locator(SUBMIT)
132
+ try:
133
+ page.wait_for_function(
134
+ "el => !el.disabled", arg=button.element_handle(), timeout=ENABLED_TIMEOUT_MS
135
+ )
136
+ except Timeout:
137
+ raise Blocked("captcha: Turnstile kept the submit disabled") from None
138
+ with page.expect_response(
139
+ lambda r: (
140
+ r.request.method == "POST" and APPLIED.search(urlsplit(r.url).path) is not None
141
+ ),
142
+ timeout=SUBMIT_TIMEOUT_MS,
143
+ ) as applied:
144
+ button.click()
145
+ response = applied.value
146
+ if not response.ok:
147
+ raise RuntimeError(f"apply answered {response.status}: {response.text()[:200]}")
148
+ return f"{response.url} {response.status}"
149
+
150
+
151
+ def _job(url: str) -> tuple[str, str] | None:
152
+ """The account and job id, from `<account>.jobs.personio.{com,de}/job/<id>[/apply]`."""
153
+ parts = urlsplit(url)
154
+ host, path = HOST.match(parts.hostname or ""), JOB.match(parts.path)
155
+ return (host[1], path[1]) if host and path else None
156
+
157
+
158
+ def _apply(url: str) -> str:
159
+ """The posting's apply page, in the language the URL asks for."""
160
+ parts = urlsplit(url)
161
+ job = JOB.match(parts.path)
162
+ assert job is not None
163
+ language = re.search(r"(?:^|&)(language=[\w-]+)", parts.query)
164
+ query = f"?{language[1]}" if language else ""
165
+ return f"https://{parts.hostname}/job/{job[1]}/apply{query}"
166
+
167
+
168
+ @dataclass
169
+ class _Node:
170
+ tag: str
171
+ attrs: dict[str, str]
172
+ parent: _Node | None = None
173
+ children: list[_Node | str] = field(default_factory=list)
174
+
175
+ def walk(self) -> list[_Node]:
176
+ out = []
177
+ for child in self.children:
178
+ if isinstance(child, _Node):
179
+ out += [child, *child.walk()]
180
+ return out
181
+
182
+ def text(self) -> str:
183
+ return "".join(c if isinstance(c, str) else c.text() for c in self.children)
184
+
185
+ def label(self) -> str:
186
+ """The text, without the spans that mark it required or optional."""
187
+ own = (c for c in self.children if isinstance(c, str) or c.tag != "span")
188
+ return " ".join("".join(c if isinstance(c, str) else c.text() for c in own).split())
189
+
190
+ def starred(self) -> bool:
191
+ return any(
192
+ isinstance(c, _Node) and c.tag == "span" and c.text().strip() == "*"
193
+ for c in self.children
194
+ )
195
+
196
+ def ancestor(self, role: str) -> _Node | None:
197
+ node = self.parent
198
+ while node is not None and node.attrs.get("role") != role:
199
+ node = node.parent
200
+ return node
201
+
202
+
203
+ class _Tree(HTMLParser):
204
+ def __init__(self) -> None:
205
+ super().__init__()
206
+ self.root = self.open = _Node("", {})
207
+
208
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
209
+ node = _Node(tag, {k: v or "" for k, v in attrs}, self.open)
210
+ self.open.children.append(node)
211
+ if tag not in VOID:
212
+ self.open = node
213
+
214
+ def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
215
+ self.open.children.append(_Node(tag, {k: v or "" for k, v in attrs}, self.open))
216
+
217
+ def handle_endtag(self, tag: str) -> None:
218
+ node: _Node | None = self.open
219
+ while node is not None and node.tag != tag:
220
+ node = node.parent
221
+ if node is not None and node.parent is not None:
222
+ self.open = node.parent
223
+
224
+ def handle_data(self, data: str) -> None:
225
+ self.open.children.append(data)
226
+
227
+
228
+ def _parse(html: str) -> _Node:
229
+ tree = _Tree()
230
+ tree.feed(html)
231
+ return tree.root
232
+
233
+
234
+ def _form(form: _Node) -> list[tuple[Field, dict[str, str] | None]]:
235
+ """Each named control of the form, with a choice's option texts by value."""
236
+ nodes = form.walk()
237
+ by_id = {n.attrs["id"]: n for n in nodes if "id" in n.attrs}
238
+ labels = {n.attrs["for"]: n for n in nodes if n.tag == "label" and "for" in n.attrs}
239
+ out: list[tuple[Field, dict[str, str] | None]] = []
240
+ for node in nodes:
241
+ name = node.attrs.get("name")
242
+ if node.tag not in ("input", "select", "textarea") or not name:
243
+ continue
244
+ kind = node.attrs.get("type", "text") if node.tag == "input" else node.tag
245
+ if kind == "hidden":
246
+ continue
247
+ group = node.ancestor("group")
248
+ group_label = by_id.get(group.attrs.get("aria-labelledby", "")) if group else None
249
+ own = labels.get(node.attrs.get("id", ""))
250
+ label = (own.label() if own else "") or (group_label.label() if group_label else "") or name
251
+ required = any(n is not None and n.starred() for n in (own, group_label))
252
+ if kind not in KINDS or "multiple" in node.attrs and kind == "select":
253
+ reason = f"unhandled_rendering: {name} is a {kind}"
254
+ out.append((Field(name, label, FieldType.TEXT, required, unfillable=reason), None))
255
+ continue
256
+ field_type = FieldType.PHONE if name == PHONE else KINDS[kind]
257
+ options = None
258
+ if kind == "select":
259
+ options = {
260
+ o.attrs["value"]: " ".join(o.text().split())
261
+ for o in node.walk()
262
+ if o.tag == "option" and o.attrs.get("value")
263
+ }
264
+ max_length = int(node.attrs.get("maxlength", 0)) or None
265
+ accept = (
266
+ node.attrs["accept"].split(",") if kind == "file" and "accept" in node.attrs else None
267
+ )
268
+ out.append(
269
+ (
270
+ Field(
271
+ name,
272
+ label,
273
+ field_type,
274
+ required,
275
+ list(options.values()) if options else None,
276
+ max_length if field_type in (FieldType.TEXT, FieldType.PHONE) else None,
277
+ accept,
278
+ ),
279
+ options,
280
+ )
281
+ )
282
+ return out
283
+
284
+
285
+ def _attach(control: Locator, path: str) -> None:
286
+ """Attaches a document and waits for Personio's upload to show its name in the category."""
287
+ name = path.rsplit("/", 1)[-1]
288
+ control.set_input_files(path)
289
+ shown = control.locator("xpath=ancestor::*[@role='group'][1]").locator(
290
+ "[role=listitem] span", has_text=name
291
+ )
292
+ with suppress(Timeout):
293
+ shown.first.wait_for(timeout=UPLOAD_TIMEOUT_MS)
@@ -334,7 +334,9 @@ kind = "ats"
334
334
  share = 0.0041
335
335
  account = "none"
336
336
  hosting = ["hosted"]
337
+ adapter = "personio"
337
338
  mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
339
+ dry_run_verified = { date = 2026-10-22, version = "0.0.3", by = "adapter_gate", commit = "ca99247" }
338
340
 
339
341
  [gupy]
340
342
  kind = "ats"
File without changes