atsfill 0.0.5.dev191__tar.gz → 0.0.5.dev193__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.dev191 → atsfill-0.0.5.dev193}/PKG-INFO +2 -1
  2. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/README.md +1 -0
  3. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/pyproject.toml +1 -1
  4. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/__init__.py +2 -0
  6. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/phenom.py +20 -10
  7. atsfill-0.0.5.dev193/src/atsfill/adapters/werecruit.py +230 -0
  8. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/registry.toml +8 -0
  9. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/LICENSE +0 -0
  10. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/__init__.py +0 -0
  11. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/adp.py +0 -0
  12. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/adpmyjobs.py +0 -0
  13. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/ashby.py +0 -0
  14. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/bamboohr.py +0 -0
  15. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/breezy.py +0 -0
  16. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/dayforce.py +0 -0
  17. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/eightfold.py +0 -0
  18. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/greenhouse.py +0 -0
  19. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/gupy.py +0 -0
  20. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/hireology.py +0 -0
  21. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/hiringthing.py +0 -0
  22. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/hrmdirect.py +0 -0
  23. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/isolvedhire.py +0 -0
  24. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/jazzhr.py +0 -0
  25. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/jobaffinity.py +0 -0
  26. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/jobstreet.py +0 -0
  27. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/join.py +0 -0
  28. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/lever.py +0 -0
  29. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/manatal.py +0 -0
  30. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/oracle.py +0 -0
  31. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/paycor.py +0 -0
  32. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/paylocity.py +0 -0
  33. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/personio.py +0 -0
  34. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/pinpoint.py +0 -0
  35. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/recruitee.py +0 -0
  36. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/recruiterflow.py +0 -0
  37. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/rippling.py +0 -0
  38. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/smartrecruiters.py +0 -0
  39. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/successfactors.js +0 -0
  40. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/successfactors.py +0 -0
  41. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/taleo.py +0 -0
  42. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/teamtailor.py +0 -0
  43. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/ukg.py +0 -0
  44. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/welcomekit.py +0 -0
  45. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/workable.py +0 -0
  46. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/workatastartup.py +0 -0
  47. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/workday.py +0 -0
  48. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/wttj.py +0 -0
  49. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/adapters/zohorecruit.py +0 -0
  50. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/cli.py +0 -0
  51. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/core/__init__.py +0 -0
  52. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/core/accounts.py +0 -0
  53. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/core/adapter.py +0 -0
  54. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/core/attempts.py +0 -0
  55. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/core/browser.py +0 -0
  56. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/core/commands.py +0 -0
  57. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/core/conditions.py +0 -0
  58. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/core/countries.py +0 -0
  59. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/core/html.py +0 -0
  60. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/core/matching.py +0 -0
  61. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/core/otp.py +0 -0
  62. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/core/passwords.py +0 -0
  63. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/core/paths.py +0 -0
  64. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/core/registry.py +0 -0
  65. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/core/results.py +0 -0
  66. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/core/snapshot.py +0 -0
  67. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/src/atsfill/core/telemetry.py +0 -0
  68. {atsfill-0.0.5.dev191 → atsfill-0.0.5.dev193}/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.dev191
3
+ Version: 0.0.5.dev193
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.dev191"
3
+ version = "0.0.5.dev193"
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.dev191"
3
+ version = "0.0.5.dev193"
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(),
@@ -25,7 +25,7 @@ from typing import Any
25
25
  from urllib.parse import parse_qs, urlsplit
26
26
 
27
27
  from atsfill.core import browser, matching, paths
28
- from atsfill.core.adapter import Gap, NotTaken, Unsupported, known_gap
28
+ from atsfill.core.adapter import Blocked, Gap, NotTaken, Unsupported, known_gap
29
29
  from atsfill.core.browser import HTTP_TIMEOUT_S, BrowserError, Locator, Overlaid, Page, Timeout
30
30
  from atsfill.core.results import (
31
31
  AllOf,
@@ -238,22 +238,24 @@ _FETCH = """async url => {
238
238
  return response.json();
239
239
  }"""
240
240
 
241
- # Posts each body to `/applySubmit`, `most` at a time; null for one that fails.
241
+ # Posts each body to `/applySubmit`, `most` at a time; null for one that fails. With the
242
+ # answers, whether one was refused (403), after which none is posted.
242
243
  _POSTS = """async ([bodies, token, most]) => {
243
244
  const out = bodies.map(() => null);
244
- let next = 0;
245
+ let next = 0, refused = false;
245
246
  const post = async () => {
246
- for (let i = next++; i < bodies.length; i = next++) {
247
+ for (let i = next++; i < bodies.length && !refused; i = next++) {
247
248
  try {
248
249
  const response = await fetch('/applySubmit', {method: 'POST',
249
250
  body: JSON.stringify(bodies[i]),
250
251
  headers: {'Content-Type': 'application/json', 'x-csrf-token': token}});
251
252
  if (response.ok) out[i] = await response.json();
253
+ refused ||= response.status === 403;
252
254
  } catch {}
253
255
  }
254
256
  };
255
257
  await Promise.all(Array.from({length: most}, post));
256
- return out;
258
+ return [out, refused];
257
259
  }"""
258
260
 
259
261
 
@@ -350,14 +352,22 @@ class Phenom:
350
352
  except ValueError as e:
351
353
  raise RuntimeError(str(e)) from None
352
354
  token = get(ph, "sessionParams.csrfToken") or ""
355
+
356
+ refused = False
357
+
358
+ def posts(bodies: list[dict[str, Any]]) -> list[Any]:
359
+ nonlocal refused
360
+ out, now = page.evaluate(_POSTS, [bodies, token, LOADERS_AT_ONCE])
361
+ refused |= now
362
+ return out
363
+
353
364
  try:
354
- fields, raws = _read(
355
- ph,
356
- lambda url: page.evaluate(_FETCH, url),
357
- lambda bodies: page.evaluate(_POSTS, [bodies, token, LOADERS_AT_ONCE]),
358
- )
365
+ fields, raws = _read(ph, lambda url: page.evaluate(_FETCH, url), posts)
359
366
  except (BrowserError, ValueError, KeyError) as e:
360
367
  raise RuntimeError(f"no configuration for the wizard ({e})") from None
368
+ finally: # raised once the read ends, so the answers that came are kept
369
+ if refused:
370
+ raise Blocked("rate_limited: the careers site answers /applySubmit 403")
361
371
  self._raws = {raw["id"]: raw for raw in raws}
362
372
  return fields
363
373
 
@@ -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"
@@ -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