atsfill 0.0.5.dev192__tar.gz → 0.0.5.dev194__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 (68) hide show
  1. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/PKG-INFO +2 -1
  2. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/README.md +1 -0
  3. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/pyproject.toml +1 -1
  4. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/__init__.py +2 -0
  6. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/teamtailor.py +6 -2
  7. atsfill-0.0.5.dev194/src/atsfill/adapters/werecruit.py +230 -0
  8. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/core/adapter.py +9 -1
  9. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/core/commands.py +3 -0
  10. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/registry.toml +8 -0
  11. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/LICENSE +0 -0
  12. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/__init__.py +0 -0
  13. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/adp.py +0 -0
  14. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/adpmyjobs.py +0 -0
  15. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/ashby.py +0 -0
  16. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/bamboohr.py +0 -0
  17. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/breezy.py +0 -0
  18. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/dayforce.py +0 -0
  19. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/eightfold.py +0 -0
  20. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/greenhouse.py +0 -0
  21. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/gupy.py +0 -0
  22. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/hireology.py +0 -0
  23. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/hiringthing.py +0 -0
  24. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/hrmdirect.py +0 -0
  25. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/isolvedhire.py +0 -0
  26. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/jazzhr.py +0 -0
  27. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/jobaffinity.py +0 -0
  28. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/jobstreet.py +0 -0
  29. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/join.py +0 -0
  30. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/lever.py +0 -0
  31. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/manatal.py +0 -0
  32. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/oracle.py +0 -0
  33. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/paycor.py +0 -0
  34. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/paylocity.py +0 -0
  35. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/personio.py +0 -0
  36. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/phenom.py +0 -0
  37. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/pinpoint.py +0 -0
  38. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/recruitee.py +0 -0
  39. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/recruiterflow.py +0 -0
  40. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/rippling.py +0 -0
  41. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/smartrecruiters.py +0 -0
  42. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/successfactors.js +0 -0
  43. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/successfactors.py +0 -0
  44. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/taleo.py +0 -0
  45. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/ukg.py +0 -0
  46. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/welcomekit.py +0 -0
  47. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/workable.py +0 -0
  48. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/workatastartup.py +0 -0
  49. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/workday.py +0 -0
  50. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/wttj.py +0 -0
  51. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/adapters/zohorecruit.py +0 -0
  52. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/cli.py +0 -0
  53. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/core/__init__.py +0 -0
  54. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/core/accounts.py +0 -0
  55. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/core/attempts.py +0 -0
  56. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/core/browser.py +0 -0
  57. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/core/conditions.py +0 -0
  58. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/core/countries.py +0 -0
  59. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/core/html.py +0 -0
  60. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/core/matching.py +0 -0
  61. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/core/otp.py +0 -0
  62. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/core/passwords.py +0 -0
  63. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/core/paths.py +0 -0
  64. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/core/registry.py +0 -0
  65. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/core/results.py +0 -0
  66. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/core/snapshot.py +0 -0
  67. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/core/telemetry.py +0 -0
  68. {atsfill-0.0.5.dev192 → atsfill-0.0.5.dev194}/src/atsfill/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atsfill
3
- Version: 0.0.5.dev192
3
+ Version: 0.0.5.dev194
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
@@ -82,6 +82,7 @@ It is built for agents with a shell: every command prints one JSON document and
82
82
  [![pinpoint run history](https://shbernal.github.io/atsfill/run-history/pinpoint.svg)](https://shbernal.github.io/atsfill/run-history/)
83
83
  [![hiringthing run history](https://shbernal.github.io/atsfill/run-history/hiringthing.svg)](https://shbernal.github.io/atsfill/run-history/)
84
84
  [![recruiterflow run history](https://shbernal.github.io/atsfill/run-history/recruiterflow.svg)](https://shbernal.github.io/atsfill/run-history/)
85
+ [![werecruit run history](https://shbernal.github.io/atsfill/run-history/werecruit.svg)](https://shbernal.github.io/atsfill/run-history/)
85
86
  [![jobaffinity run history](https://shbernal.github.io/atsfill/run-history/jobaffinity.svg)](https://shbernal.github.io/atsfill/run-history/)
86
87
  [![welcomekit run history](https://shbernal.github.io/atsfill/run-history/welcomekit.svg)](https://shbernal.github.io/atsfill/run-history/)
87
88
  [![join run history](https://shbernal.github.io/atsfill/run-history/join.svg)](https://shbernal.github.io/atsfill/run-history/)
@@ -53,6 +53,7 @@ It is built for agents with a shell: every command prints one JSON document and
53
53
  [![pinpoint run history](https://shbernal.github.io/atsfill/run-history/pinpoint.svg)](https://shbernal.github.io/atsfill/run-history/)
54
54
  [![hiringthing run history](https://shbernal.github.io/atsfill/run-history/hiringthing.svg)](https://shbernal.github.io/atsfill/run-history/)
55
55
  [![recruiterflow run history](https://shbernal.github.io/atsfill/run-history/recruiterflow.svg)](https://shbernal.github.io/atsfill/run-history/)
56
+ [![werecruit run history](https://shbernal.github.io/atsfill/run-history/werecruit.svg)](https://shbernal.github.io/atsfill/run-history/)
56
57
  [![jobaffinity run history](https://shbernal.github.io/atsfill/run-history/jobaffinity.svg)](https://shbernal.github.io/atsfill/run-history/)
57
58
  [![welcomekit run history](https://shbernal.github.io/atsfill/run-history/welcomekit.svg)](https://shbernal.github.io/atsfill/run-history/)
58
59
  [![join run history](https://shbernal.github.io/atsfill/run-history/join.svg)](https://shbernal.github.io/atsfill/run-history/)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.5.dev192"
3
+ version = "0.0.5.dev194"
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.5.dev192"
3
+ version = "0.0.5.dev194"
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"
@@ -30,6 +30,7 @@ from atsfill.adapters.successfactors import SuccessFactors
30
30
  from atsfill.adapters.teamtailor import Teamtailor
31
31
  from atsfill.adapters.ukg import UKG
32
32
  from atsfill.adapters.welcomekit import WelcomeKit
33
+ from atsfill.adapters.werecruit import WeRecruit
33
34
  from atsfill.adapters.workable import Workable
34
35
  from atsfill.adapters.workatastartup import WorkAtAStartup
35
36
  from atsfill.adapters.workday import Workday
@@ -49,6 +50,7 @@ def adapters() -> list[Adapter]:
49
50
  Join(),
50
51
  JobAffinity(),
51
52
  WelcomeKit(),
53
+ WeRecruit(),
52
54
  SmartRecruiters(),
53
55
  Oracle(),
54
56
  Manatal(),
@@ -16,7 +16,7 @@ from typing import Any
16
16
  from urllib.parse import urlsplit
17
17
 
18
18
  from atsfill.core import browser, html, matching
19
- from atsfill.core.adapter import Gap, NotTaken, Unsupported, known_gap
19
+ from atsfill.core.adapter import Gap, Held, NotTaken, Unsupported, known_gap
20
20
  from atsfill.core.browser import HTTP_TIMEOUT_S, Locator, Page, Response
21
21
  from atsfill.core.html import Node
22
22
  from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
@@ -31,6 +31,8 @@ VIDEO = "[data-controller~='questions--tt-video-answer']"
31
31
  UPLOAD = "forms--inputs--upload" # a drop zone's controller
32
32
  CAREER_SITE = "a[href*='//www.teamtailor.com'], script[src*='teamtailor-cdn.com/assets/careersite']"
33
33
  JOB = re.compile(r"/jobs/(\d+)[^/]*")
34
+ EMAIL_LINK = "/applications/email_verification_needed"
35
+ SENT = re.compile(rf"/applications/[^/]+/thanks|{EMAIL_LINK}")
34
36
  SUBMIT_TIMEOUT_MS = 90_000
35
37
  FOLLOW_UP_TIMEOUT_MS = 1_000
36
38
  UPLOAD_TIMEOUT_S = 60
@@ -235,7 +237,9 @@ class Teamtailor:
235
237
 
236
238
  def submit(self, page: Page) -> str | None:
237
239
  browser.click(page.locator(f"{FORM} [type=submit]"))
238
- page.wait_for_url(re.compile(r"/applications/[^/]+/thanks"), timeout=SUBMIT_TIMEOUT_MS)
240
+ page.wait_for_url(SENT, timeout=SUBMIT_TIMEOUT_MS, wait_until="domcontentloaded")
241
+ if page.url.split("?")[0].endswith(EMAIL_LINK):
242
+ raise Held("click the link emailed to the applicant to complete it")
239
243
  return page.url
240
244
 
241
245
  def _fill(self, form: Locator, field_id: str, answer: Answer) -> None:
@@ -0,0 +1,230 @@
1
+ """WeRecruit (`careers.werecruit.io/<lang>/<company>/offres/<slug>-<id>`), as
2
+ docs/adapters/werecruit.md observes.
3
+
4
+ The apply form is on the posting page, rendered on the server: `request_read` fetches it over
5
+ HTTP, `browser_read` takes it from the browser, and both list the fields with one parser. The
6
+ documents go with the form's one multipart post, back to the posting's URL.
7
+ """
8
+
9
+ import re
10
+ import urllib.request
11
+ from dataclasses import dataclass
12
+ from urllib.error import HTTPError
13
+ from urllib.parse import urlsplit
14
+
15
+ from atsfill.core import html
16
+ from atsfill.core.adapter import Gap, Unsupported
17
+ from atsfill.core.browser import HTTP_TIMEOUT_S, Page, Timeout
18
+ from atsfill.core.results import Answer, Field, FieldType, SideEffect
19
+
20
+ HOST = "careers.werecruit.io"
21
+ # A posting on WeRecruit's host names its company; on an employer's own domain it does not.
22
+ HOSTED = re.compile(r"^/(?:fr|en)/([\w-]+)/(?:offres|offers)/(?:[\w-]*-)?([0-9a-f]{6})/?$")
23
+ OWN_DOMAIN = re.compile(r"^/(?:fr|en)/(?:offres|offers)/(?:[\w-]*-)?([0-9a-f]{6})/?$")
24
+ FORM = "form#form"
25
+ PORTAL = f"{FORM} input[name=isEmployerPortal]" # WeRecruit's form, whatever the domain
26
+ FORM_TIMEOUT_MS = 30_000
27
+ SUBMIT_TIMEOUT_MS = 90_000
28
+ KINDS = {
29
+ "text": FieldType.TEXT,
30
+ "email": FieldType.TEXT,
31
+ "tel": FieldType.PHONE,
32
+ "textarea": FieldType.TEXT,
33
+ "select": FieldType.CHOICE,
34
+ "radio": FieldType.CHOICE,
35
+ "file": FieldType.FILE,
36
+ }
37
+
38
+ # What each control holds, by name: a value, a ticked radio's id, a document's file name.
39
+ _READ_BACK = """(form, names) => {
40
+ const out = {};
41
+ for (const name of names) {
42
+ const els = [...form.querySelectorAll(`[name="${name}"]`)];
43
+ const el = els[0];
44
+ if (!el) continue;
45
+ if (el.type === "radio") out[name] = els.find(e => e.checked)?.id ?? "";
46
+ else if (el.type === "file") out[name] = el.files[0]?.name ?? "";
47
+ else out[name] = el.value ?? "";
48
+ }
49
+ return out;
50
+ }"""
51
+
52
+
53
+ @dataclass
54
+ class _Control:
55
+ field: Field
56
+ name: str # the control's own name, which may carry spaces (` photo`)
57
+ kind: str
58
+ options: dict[str, str] # option text by value, or by radio id
59
+
60
+
61
+ class WeRecruit:
62
+ name = "werecruit"
63
+ known_gaps: dict[str, Gap] = {}
64
+ side_effects = [SideEffect("submit", "employer", "the application reaches the employer")]
65
+
66
+ def __init__(self) -> None:
67
+ self._controls: list[_Control] = []
68
+
69
+ def detect(self, url: str, page: Page | None) -> bool:
70
+ """A posting on WeRecruit's host, or one on an employer's domain that serves its form."""
71
+ parts = urlsplit(url)
72
+ if parts.hostname == HOST:
73
+ return HOSTED.match(parts.path) is not None
74
+ if not OWN_DOMAIN.match(parts.path):
75
+ return False
76
+ return page is not None and page.locator(PORTAL).count() > 0
77
+
78
+ def posting(self, url: str) -> str:
79
+ """The company and the id: the slug and the language do not change the posting."""
80
+ parts = urlsplit(url)
81
+ if parts.hostname == HOST and (m := HOSTED.match(parts.path)):
82
+ return f"https://{HOST}/fr/{m[1]}/offres/{m[2]}"
83
+ if m := OWN_DOMAIN.match(parts.path):
84
+ return f"https://{parts.hostname}/fr/offres/{m[1]}"
85
+ return url
86
+
87
+ def request_read(self, url: str) -> list[Field] | None:
88
+ if not self.detect(url, None):
89
+ return None
90
+ try:
91
+ controls = _parse(_get(url.split("?")[0]))
92
+ except HTTPError, OSError, ValueError:
93
+ return None
94
+ return [c.field for c in controls] if controls is not None else None
95
+
96
+ def browser_read(self, page: Page) -> list[Field]:
97
+ try:
98
+ page.locator(FORM).wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
99
+ except Timeout:
100
+ raise Unsupported("posting_gone: no application form on the page") from None
101
+ controls = _parse(page.content())
102
+ assert controls is not None
103
+ self._controls = controls
104
+ return [c.field for c in controls]
105
+
106
+ def fill(self, page: Page, answers: dict[str, Answer]) -> None:
107
+ form = page.locator(FORM)
108
+ for c in self._controls:
109
+ if c.field.id not in answers or c.field.unfillable:
110
+ continue
111
+ value = _one(answers[c.field.id])
112
+ control = form.locator(f'[name="{c.name}"]')
113
+ match c.kind:
114
+ case "select":
115
+ by_text = {t: v for v, t in c.options.items()}
116
+ control.select_option(value=by_text.get(value, value))
117
+ case "radio":
118
+ radio = next((i for i, t in c.options.items() if t == value), value)
119
+ form.locator(f'label[for="{radio}"]').click()
120
+ case "file":
121
+ control.set_input_files(value)
122
+ case _:
123
+ control.fill(value)
124
+
125
+ def read_back(self, page: Page) -> dict[str, Answer]:
126
+ shown = [c for c in self._controls if not c.field.unfillable]
127
+ values = page.locator(FORM).evaluate(_READ_BACK, [c.name for c in shown])
128
+ out: dict[str, Answer] = {}
129
+ for c in shown:
130
+ value = values.get(c.name)
131
+ if c.kind in ("select", "radio"):
132
+ value = c.options.get(value, value)
133
+ if value not in (None, ""):
134
+ out[c.field.id] = value
135
+ return out
136
+
137
+ def submit(self, page: Page) -> str | None:
138
+ """Clicks Je postule; the evidence is the URL and status of the form's post. The page
139
+ validates first: no post, and its errors are the failure."""
140
+ action = page.locator(FORM).get_attribute("action") or page.url
141
+ try:
142
+ with page.expect_response(
143
+ lambda r: r.request.method == "POST" and r.url.split("?")[0] == action,
144
+ timeout=SUBMIT_TIMEOUT_MS,
145
+ ) as posted:
146
+ page.locator("#submit-button").click()
147
+ except Timeout:
148
+ raise RuntimeError(f"the form was not posted: {_errors(page)}") from None
149
+ if posted.value.status >= 400:
150
+ raise RuntimeError(f"the post answered {posted.value.status}")
151
+ return f"{posted.value.url} {posted.value.status}"
152
+
153
+
154
+ def _get(url: str) -> str:
155
+ request = urllib.request.Request(url, headers={"User-Agent": "atsfill"})
156
+ with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
157
+ return response.read().decode()
158
+
159
+
160
+ def _parse(source: str) -> list[_Control] | None:
161
+ """Each named control of the apply form, a radio group once; None without the form."""
162
+ nodes = html.parse(source).walk()
163
+ form = next((n for n in nodes if n.tag == "form" and n.attrs.get("id") == "form"), None)
164
+ if form is None:
165
+ return None
166
+ labels = {n.attrs["for"]: n for n in form.walk() if n.tag == "label" and "for" in n.attrs}
167
+ out: list[_Control] = []
168
+ seen: set[str] = set()
169
+ for node in form.walk():
170
+ name = node.attrs.get("name")
171
+ if node.tag not in ("input", "select", "textarea") or not name or name in seen:
172
+ continue
173
+ kind = node.attrs.get("type", "text") if node.tag == "input" else node.tag
174
+ if kind in ("hidden", "submit", "button"):
175
+ continue
176
+ seen.add(name)
177
+ group = node.closest(lambda n: "form-group" in n.classes())
178
+ own = group.find(lambda n: n.tag == "label") if group else None
179
+ label = _label(own) if own is not None else name.strip()
180
+ required = "required" in node.attrs
181
+ fid = name.strip()
182
+ if kind not in KINDS:
183
+ reason = f"unhandled_rendering: {fid} is a {kind}"
184
+ field = Field(fid, label, FieldType.TEXT, required, unfillable=reason)
185
+ out.append(_Control(field, name, kind, {}))
186
+ continue
187
+ options: dict[str, str] = {}
188
+ if kind == "select":
189
+ options = {
190
+ o.attrs["value"]: " ".join(o.text().split())
191
+ for o in node.walk()
192
+ if o.tag == "option" and o.attrs.get("value")
193
+ }
194
+ elif kind == "radio":
195
+ options = {
196
+ r.attrs["id"]: _label(labels[r.attrs["id"]])
197
+ for r in form.walk()
198
+ if r.tag == "input" and r.attrs.get("name") == name and r.attrs.get("id") in labels
199
+ }
200
+ accept = node.attrs.get("accept", "").split(",") if kind == "file" else []
201
+ field = Field(
202
+ fid,
203
+ label,
204
+ KINDS[kind],
205
+ required,
206
+ list(options.values()) or None,
207
+ accept=[a.strip() for a in accept if a.strip()] or None,
208
+ )
209
+ out.append(_Control(field, name, kind, options))
210
+ return out
211
+
212
+
213
+ def _label(node: html.Node) -> str:
214
+ """A label's text, without its required marker."""
215
+
216
+ def text(n: html.Node | str) -> str:
217
+ if isinstance(n, str):
218
+ return n
219
+ return "" if "required" in n.classes() else "".join(text(c) for c in n.children)
220
+
221
+ return " ".join(text(node).split())
222
+
223
+
224
+ def _one(answer: Answer) -> str:
225
+ return answer if isinstance(answer, str) else answer[0]
226
+
227
+
228
+ def _errors(page: Page) -> str:
229
+ shown = page.locator(f"{FORM} .invalid-feedback:visible, #errorMessageDiv:visible")
230
+ return " ".join(" ".join(shown.all_inner_texts()).split())[:200] or "no error shown"
@@ -15,6 +15,13 @@ class Blocked(Exception):
15
15
  """
16
16
 
17
17
 
18
+ class Held(Exception):
19
+ """Raised by `submit`: the system has the application but holds it until the applicant
20
+ acts, e.g. clicks an emailed link. The send counts as `submitted`, the message is the
21
+ reason's detail (ADR 0006).
22
+ """
23
+
24
+
18
25
  class SignedOut(Blocked):
19
26
  """The system's page asks to sign in although `signed_in` found the session holding: the
20
27
  system ended it its own way. The core signs in again and reads once more (ADR 0015).
@@ -94,7 +101,8 @@ class Adapter(Protocol):
94
101
  def submit(self, page: Page) -> str | None:
95
102
  """Clicks submit; returns the evidence (a URL or a network response), None without.
96
103
 
97
- Raises `Blocked` when the system refuses the send.
104
+ Raises `Blocked` when the system refuses the send, `Held` when it has the application but
105
+ holds it until the applicant acts.
98
106
  """
99
107
  ...
100
108
 
@@ -25,6 +25,7 @@ from atsfill.core.adapter import (
25
25
  Blocked,
26
26
  CreatesAccount,
27
27
  CreatesAccountByOtp,
28
+ Held,
28
29
  KeepsProfile,
29
30
  ListsShownFields,
30
31
  NeedsAccount,
@@ -503,6 +504,8 @@ def _on_page(
503
504
  result.status = Status.SUBMITTED
504
505
  else:
505
506
  result.status, result.reason = Status.UNKNOWN, "no_evidence"
507
+ except Held as e:
508
+ result.status, result.evidence, result.reason = Status.SUBMITTED, page.url, f"held: {e}"
506
509
  except Blocked as e:
507
510
  result.status, result.reason = Status.BLOCKED, str(e)
508
511
  except Exception as e: # after the click, nothing tells a failed send from a sent one
@@ -429,3 +429,11 @@ account = "none"
429
429
  hosting = ["hosted"]
430
430
  adapter = "welcomekit"
431
431
  scope = { is = "supported", date = 2026-10-29, commit = "238798d" }
432
+
433
+ [werecruit]
434
+ kind = "ats"
435
+ share = 0.0007
436
+ account = "none"
437
+ hosting = ["hosted"]
438
+ adapter = "werecruit"
439
+ scope = { is = "supported", date = 2026-10-29, commit = "cf7634f" }
File without changes