atsfill 0.0.4.dev71__tar.gz → 0.0.4.dev73__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 (39) hide show
  1. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/PKG-INFO +2 -2
  2. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/README.md +1 -1
  3. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/__init__.py +2 -0
  6. atsfill-0.0.4.dev73/src/atsfill/adapters/zohorecruit.py +528 -0
  7. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/registry.toml +3 -1
  8. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/LICENSE +0 -0
  9. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/__init__.py +0 -0
  10. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/ashby.py +0 -0
  11. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/bamboohr.py +0 -0
  12. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/breezy.py +0 -0
  13. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/dayforce.py +0 -0
  14. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/greenhouse.py +0 -0
  15. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/hireology.py +0 -0
  16. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/jazzhr.py +0 -0
  17. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/lever.py +0 -0
  18. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/manatal.py +0 -0
  19. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/oracle.py +0 -0
  20. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/paycor.py +0 -0
  21. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/paylocity.py +0 -0
  22. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/phenom.py +0 -0
  23. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/recruitee.py +0 -0
  24. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/smartrecruiters.py +0 -0
  25. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/teamtailor.py +0 -0
  26. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/workable.py +0 -0
  27. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/adapters/wttj.py +0 -0
  28. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/cli.py +0 -0
  29. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/core/__init__.py +0 -0
  30. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/core/accounts.py +0 -0
  31. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/core/adapter.py +0 -0
  32. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/core/attempts.py +0 -0
  33. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/core/browser.py +0 -0
  34. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/core/commands.py +0 -0
  35. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/core/matching.py +0 -0
  36. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/core/paths.py +0 -0
  37. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/core/registry.py +0 -0
  38. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/src/atsfill/core/results.py +0 -0
  39. {atsfill-0.0.4.dev71 → atsfill-0.0.4.dev73}/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.dev71
3
+ Version: 0.0.4.dev73
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) ![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) ![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) ![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) ![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.dev71"
3
+ version = "0.0.4.dev73"
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.dev71"
3
+ version = "0.0.4.dev73"
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"
@@ -16,6 +16,7 @@ from atsfill.adapters.smartrecruiters import SmartRecruiters
16
16
  from atsfill.adapters.teamtailor import Teamtailor
17
17
  from atsfill.adapters.workable import Workable
18
18
  from atsfill.adapters.wttj import WelcomeToTheJungle
19
+ from atsfill.adapters.zohorecruit import ZohoRecruit
19
20
  from atsfill.core.adapter import Adapter
20
21
 
21
22
 
@@ -39,5 +40,6 @@ def adapters() -> list[Adapter]:
39
40
  Recruitee(),
40
41
  Breezy(),
41
42
  BambooHR(),
43
+ ZohoRecruit(),
42
44
  Phenom(), # last: it claims a URL by its path alone
43
45
  ]
@@ -0,0 +1,528 @@
1
+ """Zoho Recruit (`<account>.zohorecruit.<tld>/jobs/<page>/<job>` career sites), as
2
+ docs/adapters/zohorecruit.md observes.
3
+
4
+ The job page names its application form, which "I'm interested" renders from a public form
5
+ API: sections of fields by id, type and requirement, and the pre-screening questions a form
6
+ may ask with them. `read_api` fetches it over HTTP, `read_form` through the page's browser
7
+ context, and both list the fields from it. Each field renders as a Lyte component whose id
8
+ derives from the field's; nothing but the submit reaches Zoho.
9
+ """
10
+
11
+ import json
12
+ import re
13
+ import urllib.request
14
+ from contextlib import suppress
15
+ from dataclasses import replace
16
+ from datetime import date, datetime
17
+ from typing import Any
18
+ from urllib.parse import quote, urlsplit
19
+
20
+ from atsfill.core import browser
21
+ from atsfill.core.adapter import Blocked, Gap, Unsupported
22
+ from atsfill.core.browser import HTTP_TIMEOUT_S, Page, Timeout
23
+ from atsfill.core.results import Answer, Field, FieldType, SideEffect
24
+
25
+ HOST = re.compile(r"^[\w-]+\.zohorecruit\.(?:com|eu|in|ca|com\.au|jp|com\.cn|sa|uk)$")
26
+ JOB = re.compile(r"^/jobs/([^/]+)/(\d+)(?:/|$)")
27
+ POSTING = "https://{host}/recruit/v2/public/Job_Openings/{job}"
28
+ API = POSTING + "/apply/{form}?pagename={page}&source=CareerSite"
29
+ WEBFORM = re.compile(r'"webform_id":"(\d+)"')
30
+ APPLY = "#detail-page-applybtn button"
31
+ COOKIES = "button.cookie-decline-btn"
32
+ FORM = "rec-form-component[webformid]"
33
+ SUBMIT = "#cw-submit-btn button"
34
+ FORM_TIMEOUT_MS = 30_000
35
+ COOKIE_TIMEOUT_MS = 5_000
36
+ COOKIE_ATTEMPTS = 3
37
+ SHOWN_TIMEOUT_MS = 2_000
38
+ TAG_TIMEOUT_MS = 2_000
39
+ TAG_ATTEMPTS = 4
40
+ SUBMIT_TIMEOUT_MS = 90_000
41
+ YES_NO = ["Yes", "No"]
42
+ NONE = "-None-"
43
+
44
+ # A field's kind, by its data type; a skill set is a textarea the page renders as tags.
45
+ KINDS = {
46
+ "text": "input",
47
+ "email": "input",
48
+ "website": "input",
49
+ "textarea": "textarea",
50
+ "integer": "number",
51
+ "bigint": "number",
52
+ "double": "number",
53
+ "currency": "number",
54
+ "phone": "phone",
55
+ "date": "date",
56
+ "picklist": "picklist",
57
+ "multipicklist": "multipicklist",
58
+ "boolean": "boolean",
59
+ "monthrange": "month",
60
+ }
61
+ SKILLS_UI = 600
62
+ NOT_FIELDS = {"captcha", "gdpr"} # the CAPTCHA is typed on submit; consent is not rendered
63
+ # A pre-screening question's kind, by its type.
64
+ QUESTIONS = {1: "input", 2: "textarea", 3: "radio", 6: "picklist"}
65
+ TYPES = {
66
+ "input": FieldType.TEXT,
67
+ "textarea": FieldType.TEXT,
68
+ "number": FieldType.TEXT,
69
+ "skills": FieldType.TEXT,
70
+ "month": FieldType.TEXT,
71
+ "phone": FieldType.PHONE,
72
+ "date": FieldType.DATE,
73
+ "picklist": FieldType.CHOICE,
74
+ "radio": FieldType.CHOICE,
75
+ "boolean": FieldType.CHOICE,
76
+ "multipicklist": FieldType.MULTI_CHOICE,
77
+ "file": FieldType.FILE,
78
+ }
79
+
80
+ # The job page and its form API, fetched as the page fetches them.
81
+ _FETCH = """async url => {
82
+ const response = await fetch(url);
83
+ return response.ok ? {text: await response.text()} : {status: response.status};
84
+ }"""
85
+
86
+ # What each present control shows: `controls` holds [field id, kind, component selector].
87
+ _READ_BACK = """controls => {
88
+ const out = {};
89
+ const text = el => el ? el.textContent.replace(/\\s+/g, " ").trim() : "";
90
+ for (const [id, kind, sel] of controls) {
91
+ const el = document.querySelector(sel);
92
+ if (!el) continue;
93
+ if (kind === "input" || kind === "number") out[id] = el.querySelector("input")?.value;
94
+ else if (kind === "textarea") out[id] = el.querySelector("textarea")?.value;
95
+ else if (kind === "date") {
96
+ out[id] = [el.querySelector("input")?.value, el.querySelector("lyte-input")
97
+ ?.getAttribute("lt-prop-format")];
98
+ } else if (kind === "phone") {
99
+ const number = el.querySelector("input[name]")?.value;
100
+ const dial = text(el.querySelector(".flag-drop-code")).replace(/[()]/g, "");
101
+ out[id] = number ? `${dial} ${number}` : null;
102
+ } else if (kind === "picklist") {
103
+ const shown = el.querySelector("lyte-drop-button");
104
+ out[id] = shown?.querySelector(".lyteDropPlaceholderNormal") ? null : text(shown);
105
+ } else if (kind === "multipicklist") {
106
+ out[id] = [...el.querySelectorAll("lyte-drop-button li:not(.lyteMultiselectInput)")]
107
+ .map(text).filter(Boolean);
108
+ } else if (kind === "boolean") out[id] = el.querySelector("input").checked ? "Yes" : "No";
109
+ else if (kind === "radio") {
110
+ out[id] = text(el.querySelector("input:checked")?.closest("label")
111
+ ?.querySelector(".lyteRadioLabel")) || null;
112
+ } else if (kind === "skills") {
113
+ out[id] = [...el.querySelectorAll("li span[aria-label]")]
114
+ .map(s => s.getAttribute("aria-label")).join(", ");
115
+ } else if (kind === "month") {
116
+ const month = document.getElementById(`cxdby-${el.id}_month`)
117
+ ?.querySelector("lyte-drop-item[selected=true]")?.dataset.value;
118
+ const year = text(document.getElementById(`${el.id}_year`)?.querySelector(
119
+ "lyte-drop-button"));
120
+ out[id] = [month, year];
121
+ } else if (kind === "file") {
122
+ out[id] = text(el.querySelector(".lyteFileUpdList .lyteFileUpdFileName"))
123
+ || text(el.querySelector(".lyteFileUpdList")) || null;
124
+ }
125
+ }
126
+ return out;
127
+ }"""
128
+
129
+
130
+ class ZohoRecruit:
131
+ name = "zohorecruit"
132
+ known_gaps: dict[str, Gap] = {}
133
+ side_effects = [SideEffect("submit", "employer", "the application reaches the employer")]
134
+
135
+ def __init__(self) -> None:
136
+ self._controls: list[_Control] = [] # in the form's order
137
+ self._captcha = False
138
+ self._job: str | None = None
139
+
140
+ def detect(self, url: str, page: Page | None) -> bool:
141
+ return _job(url) is not None
142
+
143
+ def posting(self, url: str) -> str:
144
+ parts, job = urlsplit(url), _job(url)
145
+ return POSTING.format(host=parts.hostname, job=job[2]) if job else url
146
+
147
+ def read_api(self, url: str) -> list[Field] | None:
148
+ if _job(url) is None:
149
+ return None
150
+ try:
151
+ html = _get(url)
152
+ if (form := WEBFORM.search(html)) is None:
153
+ return None
154
+ api = json.loads(_get(_api(url, form[1])))
155
+ return [c.field for c in _form(api)[0]]
156
+ except OSError, ValueError, KeyError, TypeError, Unsupported:
157
+ return None
158
+
159
+ def read_form(self, page: Page) -> list[Field]:
160
+ job = _job(page.url)
161
+ if job is None:
162
+ raise Unsupported(f"posting_gone: {page.url} is not a job page")
163
+ apply = page.locator(APPLY).first
164
+ try:
165
+ apply.wait_for(timeout=FORM_TIMEOUT_MS)
166
+ except Timeout:
167
+ raise Unsupported("posting_gone: no apply button on the job page") from None
168
+ html = page.evaluate(_FETCH, page.url)
169
+ if "status" in html or (form := WEBFORM.search(html["text"])) is None:
170
+ raise Unsupported("posting_gone: the job page names no application form")
171
+ _open(page, apply)
172
+ page.locator(SUBMIT).wait_for(timeout=FORM_TIMEOUT_MS)
173
+ api = page.evaluate(_FETCH, _api(page.url, form[1]))
174
+ if "status" in api:
175
+ raise Unsupported(f"posting_gone: the form API answered {api['status']}")
176
+ self._controls, self._captcha = _form(json.loads(api["text"]))
177
+ self._job = job[2]
178
+ for control in self._controls:
179
+ if control.field.unfillable or control.row or control.shown_by:
180
+ continue
181
+ if page.locator(control.sel).count() == 0:
182
+ reason = f"unhandled_rendering: {control.field.id} is not on the page"
183
+ control.field = replace(control.field, unfillable=reason)
184
+ fields = [c.field for c in self._controls]
185
+ self._controls = [c for c in self._controls if not c.field.unfillable]
186
+ return fields
187
+
188
+ def fill(self, page: Page, answers: dict[str, Answer]) -> None:
189
+ rows = {c.row for c in self._controls if c.row and c.field.id in answers}
190
+ for add in rows: # one entry of each repeatable section answered
191
+ browser.click(page.locator(_id(add)))
192
+ for control in self._controls:
193
+ if control.field.id not in answers:
194
+ continue
195
+ el = page.locator(control.sel).first
196
+ if control.shown_by: # rendered once the field that shows it is answered
197
+ with suppress(Timeout):
198
+ el.wait_for(state="visible", timeout=SHOWN_TIMEOUT_MS)
199
+ if not el.is_visible():
200
+ continue
201
+ _fill(page, control, el, answers[control.field.id])
202
+
203
+ def read_back(self, page: Page) -> dict[str, Answer]:
204
+ controls = [[c.field.id, c.kind, c.sel] for c in self._controls]
205
+ values = page.evaluate(_READ_BACK, controls)
206
+ out: dict[str, Answer] = {}
207
+ for control in self._controls:
208
+ value = values.get(control.field.id)
209
+ if control.kind == "date" and value:
210
+ value = _iso(*value)
211
+ elif control.kind == "month" and value:
212
+ month, year = value
213
+ value = f"{int(month):02}/{year}" if month and month != "0" else None
214
+ if value not in (None, "", []):
215
+ out[control.field.id] = value
216
+ return out
217
+
218
+ def submit(self, page: Page) -> str | None:
219
+ """Clicks submit; the evidence is the form API's answer to the application. A form
220
+ with an image CAPTCHA is not sent: atsfill does not read the image."""
221
+ if self._captcha:
222
+ raise Blocked("captcha: the form asks to type an image CAPTCHA")
223
+ with page.expect_response(
224
+ lambda r: r.request.method == "POST" and f"/Job_Openings/{self._job}/apply" in r.url,
225
+ timeout=SUBMIT_TIMEOUT_MS,
226
+ ) as applied:
227
+ browser.click(page.locator(SUBMIT))
228
+ response = applied.value
229
+ if not response.ok:
230
+ raise RuntimeError(f"apply answered {response.status}: {response.text()[:200]}")
231
+ return f"{response.url.split('?')[0]} {response.status}"
232
+
233
+
234
+ class _Control:
235
+ """A field and how the page renders it: its kind, its component's selector, the button
236
+ that adds its section's entry, and whether another field's answer shows it."""
237
+
238
+ def __init__(
239
+ self, field: Field, kind: str, sel: str, row: str | None = None, shown_by: bool = False
240
+ ) -> None:
241
+ self.field, self.kind, self.sel, self.row, self.shown_by = field, kind, sel, row, shown_by
242
+
243
+
244
+ def _job(url: str) -> re.Match[str] | None:
245
+ parts = urlsplit(url)
246
+ if not HOST.match(parts.hostname or ""):
247
+ return None
248
+ return JOB.match(parts.path)
249
+
250
+
251
+ def _api(url: str, form: str) -> str:
252
+ parts, job = urlsplit(url), _job(url)
253
+ assert job is not None
254
+ return API.format(host=parts.hostname, job=job[2], form=form, page=quote(job[1]))
255
+
256
+
257
+ def _get(url: str) -> str:
258
+ request = urllib.request.Request(url, headers={"User-Agent": "atsfill"})
259
+ with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
260
+ return response.read().decode()
261
+
262
+
263
+ def _open(page: Page, apply) -> None:
264
+ """Clicks "I'm interested", declining the cookie banner first: its layer takes every
265
+ click while it shows, and it shows a moment after the page, at times after a while."""
266
+ decline = page.locator(COOKIES)
267
+ for attempt in range(COOKIE_ATTEMPTS):
268
+ with suppress(Timeout):
269
+ decline.wait_for(timeout=COOKIE_TIMEOUT_MS if attempt == 0 else 0)
270
+ decline.click()
271
+ try:
272
+ browser.click(apply)
273
+ return
274
+ except browser.Covered:
275
+ if attempt == COOKIE_ATTEMPTS - 1:
276
+ raise
277
+ decline.wait_for(timeout=COOKIE_TIMEOUT_MS)
278
+
279
+
280
+ def _form(api: dict[str, Any]) -> tuple[list[_Control], bool]:
281
+ """Each field of the form, in its order, and whether it asks for a CAPTCHA. A repeatable
282
+ section is read as its first entry; the pre-screening questions are read when the form
283
+ asks them with the application."""
284
+ webform = api["webforms"][0]
285
+ shown = {
286
+ f["api_name"]: (rule["primary_field"]["api_name"], query["criteria"])
287
+ for rule in api["meta"].get("layout_rules") or []
288
+ if rule.get("active", True)
289
+ for query in rule["queries"]
290
+ for action in query["actions"]
291
+ if action["type"] == "Show_Fields"
292
+ for f in action["fields"]
293
+ }
294
+ labels = {
295
+ f["api_name"]: f["field_label"].strip()
296
+ for s in webform["sections"]
297
+ for f in s.get("fields") or []
298
+ }
299
+ controls, captcha = [], "captcha_info" in webform
300
+ for section in sorted(webform["sections"], key=lambda s: s["sequence_number"]):
301
+ if section.get("isattachment"):
302
+ controls += [_file(a) for a in section["attachmenttypes"]]
303
+ elif section.get("istabular"):
304
+ controls += _row(section, webform["layout"]["id"])
305
+ elif section.get("isVisible", True):
306
+ for f in section.get("fields") or []:
307
+ if f["data_type"] == "captcha":
308
+ captcha = True
309
+ if (control := _control(f, labels, shown)) is not None:
310
+ controls.append(control)
311
+ assessment = api.get("Assessments")
312
+ if webform.get("mandate_assessment") and isinstance(assessment, dict):
313
+ controls += [_question(q) for q in assessment.get("questions") or []]
314
+ return controls, captcha
315
+
316
+
317
+ def _control(
318
+ f: dict[str, Any], labels: dict[str, str], shown: dict[str, tuple[str, dict]]
319
+ ) -> _Control | None:
320
+ kind = f["data_type"]
321
+ if kind in NOT_FIELDS or not f.get("visible", True):
322
+ return None
323
+ if kind not in KINDS:
324
+ return _unhandled(f["api_name"], f["field_label"].strip(), f.get("required"), kind)
325
+ kind = "skills" if f["ui_type"] == SKILLS_UI else KINDS[kind]
326
+ field = _field(f["api_name"], f["field_label"].strip(), kind, f.get("required", False), f)
327
+ if trigger := shown.get(f["api_name"]):
328
+ name, criteria = trigger
329
+ negated = "not " if criteria["comparator"] == "not_equal" else ""
330
+ label = labels.get(name, name)
331
+ field = Field(
332
+ field.id,
333
+ f"{field.label} (asked when {label!r} is {negated}{criteria['value']})",
334
+ field.type,
335
+ False,
336
+ field.options,
337
+ field.max_length,
338
+ )
339
+ return _Control(field, kind, _id(f"rec-form_{f['id']}"), shown_by=f["api_name"] in shown)
340
+
341
+
342
+ def _field(field_id: str, label: str, kind: str, required: bool, f: dict[str, Any]) -> Field:
343
+ options = None
344
+ if kind in ("picklist", "multipicklist"):
345
+ options = [o["display_value"] for o in f["pick_list_values"] if o["actual_value"] != NONE]
346
+ elif kind == "boolean":
347
+ options, required = YES_NO, False # a box left clear is an answer
348
+ elif kind == "number":
349
+ label += " (a number)"
350
+ elif kind == "month":
351
+ label += " (MM/YYYY)"
352
+ elif kind == "skills":
353
+ label += " (comma-separated)"
354
+ max_length = f.get("length") if kind in ("input", "textarea") else None
355
+ return Field(field_id, label, TYPES[kind], bool(required), options, max_length)
356
+
357
+
358
+ def _row(section: dict[str, Any], layout: str) -> list[_Control]:
359
+ """The fields of a repeatable section's first entry: `<section>.<field>`, its month
360
+ range as a start and an end. A box ("Currently pursuing") hides the range's end once
361
+ ticked, so it comes first and the end is asked when it is clear."""
362
+ tabular = next(f for f in section["fields"] if f["data_type"] == "tabular")
363
+ fields = tabular.get("fields") or [f for f in section["fields"] if f is not tabular]
364
+ fields = [f for f in fields if f.get("visible", True)]
365
+ fields.sort(key=lambda f: (f["data_type"] != "boolean", f.get("sequence_number", 0)))
366
+ heading = section.get("display_label") or tabular["field_label"]
367
+ add = f"crc-tabular-add-btn-{layout}_{section['id']}"
368
+ label = lambda f: f"{heading}: {f['field_label'].strip()}" # noqa: E731
369
+ current = next((label(f) for f in fields if f["data_type"] == "boolean"), None)
370
+ out = []
371
+ for f in fields:
372
+ field_id, dom = f"{tabular['api_name']}.{f['api_name']}", f"{f['id']}_1"
373
+ if (kind := KINDS.get(f["data_type"])) is None:
374
+ out.append(_unhandled(field_id, label(f), f.get("required"), f["data_type"]))
375
+ continue
376
+ if kind != "month":
377
+ field = _field(field_id, label(f), kind, f.get("required"), f)
378
+ out.append(_Control(field, kind, _id(dom), add))
379
+ continue
380
+ start = _field(f"{field_id}.from", f"{label(f)} start", kind, f.get("required"), f)
381
+ out.append(_Control(start, kind, _id(f"{dom}_from"), add))
382
+ end = _field(f"{field_id}.to", f"{label(f)} end", kind, f.get("required"), f)
383
+ if current:
384
+ end = Field(end.id, f"{end.label} (asked when {current!r} is No)", end.type, False)
385
+ out.append(_Control(end, kind, _id(f"{dom}_to"), add, shown_by=current is not None))
386
+ return out
387
+
388
+
389
+ def _file(attachment: dict[str, Any]) -> _Control:
390
+ accept = sorted({f".{a}" for a in attachment["$accept"].split(",") if "/" not in a})
391
+ field = Field(
392
+ attachment["api_name"],
393
+ attachment["field_label"].strip(),
394
+ FieldType.FILE,
395
+ bool(attachment.get("required")),
396
+ accept=accept,
397
+ )
398
+ # The attachment's own input: the autofill uploader above the form names RESUME too
399
+ upload = f"lyte-fileupload:has(input[name='rec-form_{attachment['id']}_file'])"
400
+ return _Control(field, "file", upload)
401
+
402
+
403
+ def _question(q: dict[str, Any]) -> _Control:
404
+ label = re.sub(r"\s+", " ", q["question"]).strip()
405
+ if (kind := QUESTIONS.get(q["type"])) is None:
406
+ what = f"pre-screening question of type {q['type']}"
407
+ return _unhandled(q["id"], label, q.get("ismandatory"), what)
408
+ options = [o["value"] for o in q["options"]] or None
409
+ field = Field(q["id"], label, TYPES[kind], bool(q.get("ismandatory")), options)
410
+ return _Control(field, kind, _id(f"rec-form_{q['id']}"))
411
+
412
+
413
+ def _unhandled(field_id: str, label: str, required: Any, what: str) -> _Control:
414
+ reason = f"unhandled_rendering: {field_id} is a {what}"
415
+ field = Field(field_id, label, FieldType.TEXT, bool(required), unfillable=reason)
416
+ return _Control(field, "", "") # listed, never filled
417
+
418
+
419
+ def _fill(page: Page, control: _Control, el, answer: Answer) -> None:
420
+ value = answer if isinstance(answer, str) else answer[0]
421
+ match control.kind:
422
+ case "input" | "number":
423
+ el.locator("input").first.fill(value)
424
+ case "textarea":
425
+ el.locator("textarea").first.fill(value)
426
+ case "picklist":
427
+ _pick(page, el, [value])
428
+ case "multipicklist":
429
+ _pick(page, el, [answer] if isinstance(answer, str) else answer)
430
+ case "radio":
431
+ browser.click(el.locator("label").filter(has_text=_exact(value)))
432
+ case "boolean":
433
+ if el.locator("input").is_checked() != (value == "Yes"):
434
+ browser.click(el.locator("label"))
435
+ case "date":
436
+ fmt = el.locator("lyte-input").get_attribute("lt-prop-format") or "MM/DD/YYYY"
437
+ day = date.fromisoformat(value)
438
+ typed = (
439
+ fmt.replace("YYYY", f"{day.year:04}")
440
+ .replace("MM", f"{day.month:02}")
441
+ .replace("DD", f"{day.day:02}")
442
+ )
443
+ el.locator("input").fill(typed)
444
+ page.keyboard.press("Escape")
445
+ case "phone":
446
+ _phone(page, el, value)
447
+ case "skills":
448
+ for skill in (s.strip() for s in value.split(",")):
449
+ if skill:
450
+ _tag(page, el, skill)
451
+ case "month":
452
+ month, _, year = value.partition("/")
453
+ dom = el.get_attribute("id")
454
+ _drop(page, f"{dom}_month", f"[data-value='{int(month)}']") # named in its language
455
+ _drop(page, f"{dom}_year", f":text-is('{year}')")
456
+ case "file":
457
+ el.locator("input[type=file]").set_input_files(value)
458
+
459
+
460
+ def _pick(page: Page, el, values: list[str]) -> None:
461
+ """Opens a dropdown and picks each value; its list renders at the end of the page."""
462
+ dom = el.locator("lyte-dropdown").first.get_attribute("id")
463
+ items = page.locator(f"lyte-drop-body[class*='parentscope_{dom}_'] lyte-drop-item")
464
+ for value in values:
465
+ browser.click(el.locator(".lyteDummyEventContainer").first)
466
+ browser.click(items.filter(has_text=_exact(value)).first)
467
+ page.keyboard.press("Escape")
468
+
469
+
470
+ def _tag(page: Page, el, skill: str) -> None:
471
+ """Adds a skill's tag: Enter takes the first of the suggestions the page looks up as the
472
+ skill is typed, so it does nothing until they are in. A new tag opens a proficiency
473
+ popover, and the input, while it has the focus, a suggestions popover: both cover the
474
+ fields below until closed."""
475
+ typed, tags = el.locator("input"), el.locator("li span[aria-label]")
476
+ added = tags.count()
477
+ typed.press_sequentially(skill) # the lookup runs on keystrokes
478
+ for _ in range(TAG_ATTEMPTS):
479
+ typed.press("Enter")
480
+ with suppress(Timeout):
481
+ tags.nth(added).wait_for(timeout=TAG_TIMEOUT_MS)
482
+ break
483
+ page.keyboard.press("Escape")
484
+ typed.blur()
485
+ page.keyboard.press("Escape")
486
+
487
+
488
+ def _drop(page: Page, dom: str, item: str) -> None:
489
+ """Picks the item a selector names from a month range's dropdown."""
490
+ browser.click(page.locator(f"[id='{dom}'] .lyteDummyEventContainer"))
491
+ browser.click(page.locator(f"[id='cxdby-{dom}'] lyte-drop-item{item}").first)
492
+
493
+
494
+ def _phone(page: Page, el, value: str) -> None:
495
+ """Picks the country whose dial code the number starts with, the longest that fits,
496
+ then types the rest of the number."""
497
+ digits = re.sub(r"\D", "", value)
498
+ browser.click(el.locator(".lyteDummyEventContainer").first)
499
+ items = page.locator(f"lyte-drop-body[class*='parentscope_{el.get_attribute('id')}_input']")
500
+ countries = items.locator("lyte-drop-item").evaluate_all(
501
+ "items => items.map(i => [i.dataset.value, i.querySelector('.zrc-cc')?.textContent])"
502
+ )
503
+ fits = [(c, re.sub(r"\D", "", d)) for c, d in countries if d]
504
+ fits = [(c, d) for c, d in fits if value.startswith("+") and digits.startswith(d)]
505
+ if not fits:
506
+ page.keyboard.press("Escape")
507
+ el.locator("input[name]").fill(digits)
508
+ return
509
+ country, dial = max(fits, key=lambda cd: len(cd[1])) # the first listed of the longest
510
+ browser.click(items.locator(f"lyte-drop-item[data-value='{country}']"))
511
+ el.locator("input[name]").fill(digits[len(dial) :])
512
+
513
+
514
+ def _iso(typed: str | None, fmt: str | None) -> str | None:
515
+ if not typed:
516
+ return None
517
+ pattern = (fmt or "MM/DD/YYYY").replace("YYYY", "%Y").replace("MM", "%m").replace("DD", "%d")
518
+ with suppress(ValueError):
519
+ return datetime.strptime(typed, pattern).date().isoformat()
520
+ return typed
521
+
522
+
523
+ def _id(dom: str) -> str:
524
+ return f"[id='{dom}']:not(template)" # the component, not the template it renders from
525
+
526
+
527
+ def _exact(value: str) -> re.Pattern[str]:
528
+ return re.compile(rf"^\s*{re.escape(value)}\s*$")
@@ -243,7 +243,9 @@ kind = "ats"
243
243
  share = 0.0089
244
244
  account = "none"
245
245
  hosting = ["hosted"]
246
+ adapter = "zohorecruit"
246
247
  mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
248
+ dry_run_verified = { date = 2026-10-22, version = "0.0.3", by = "adapter_gate", commit = "4f0baa6" }
247
249
 
248
250
  [ukgready]
249
251
  kind = "ats"
@@ -310,7 +312,7 @@ mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25
310
312
  [isolvedhire]
311
313
  kind = "ats"
312
314
  share = 0.0047
313
- account = "none"
315
+ account = "to_read" # Apply now first verifies the email or phone with a one-time code, then creates an account on the employer's careers site; the application wizard (/apply/<job>-0.php) redirects to that site's login until then
314
316
  hosting = ["hosted"]
315
317
  mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
316
318
 
File without changes