atsfill 0.0.4.dev84__tar.gz → 0.0.4.dev86__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 (45) hide show
  1. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/PKG-INFO +1 -1
  2. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/pyproject.toml +1 -1
  3. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/pyproject.toml.orig +1 -1
  4. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/adapters/__init__.py +6 -1
  5. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/adapters/eightfold.py +1 -38
  6. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/adapters/hiringthing.py +0 -9
  7. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/adapters/jazzhr.py +0 -14
  8. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/adapters/oracle.py +5 -9
  9. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/adapters/paylocity.py +0 -9
  10. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/adapters/phenom.py +3 -22
  11. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/adapters/pinpoint.py +0 -13
  12. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/adapters/rippling.py +1 -9
  13. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/adapters/smartrecruiters.py +0 -10
  14. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/adapters/teamtailor.py +11 -28
  15. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/adapters/workable.py +0 -15
  16. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/adapters/wttj.py +1 -1
  17. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/adapters/zohorecruit.py +2 -21
  18. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/core/adapter.py +8 -0
  19. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/core/browser.py +117 -14
  20. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/core/commands.py +3 -3
  21. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/core/matching.py +3 -0
  22. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/LICENSE +0 -0
  23. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/README.md +0 -0
  24. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/__init__.py +0 -0
  25. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/adapters/ashby.py +0 -0
  26. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/adapters/bamboohr.py +0 -0
  27. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/adapters/breezy.py +0 -0
  28. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/adapters/dayforce.py +0 -0
  29. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/adapters/greenhouse.py +0 -0
  30. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/adapters/hireology.py +0 -0
  31. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/adapters/lever.py +0 -0
  32. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/adapters/manatal.py +0 -0
  33. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/adapters/paycor.py +0 -0
  34. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/adapters/personio.py +0 -0
  35. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/adapters/recruitee.py +0 -0
  36. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/cli.py +0 -0
  37. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/core/__init__.py +0 -0
  38. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/core/accounts.py +0 -0
  39. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/core/attempts.py +0 -0
  40. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/core/passwords.py +0 -0
  41. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/core/paths.py +0 -0
  42. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/core/registry.py +0 -0
  43. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/core/results.py +0 -0
  44. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/py.typed +0 -0
  45. {atsfill-0.0.4.dev84 → atsfill-0.0.4.dev86}/src/atsfill/registry.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atsfill
3
- Version: 0.0.4.dev84
3
+ Version: 0.0.4.dev86
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev84"
3
+ version = "0.0.4.dev86"
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.dev84"
3
+ version = "0.0.4.dev86"
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"
@@ -22,7 +22,7 @@ from atsfill.adapters.teamtailor import Teamtailor
22
22
  from atsfill.adapters.workable import Workable
23
23
  from atsfill.adapters.wttj import WelcomeToTheJungle
24
24
  from atsfill.adapters.zohorecruit import ZohoRecruit
25
- from atsfill.core.adapter import Adapter
25
+ from atsfill.core.adapter import Adapter, Filtered
26
26
 
27
27
 
28
28
  def adapters() -> list[Adapter]:
@@ -53,3 +53,8 @@ def adapters() -> list[Adapter]:
53
53
  Eightfold(),
54
54
  Phenom(), # last: it claims a URL by its path alone
55
55
  ]
56
+
57
+
58
+ def filters() -> list[str]:
59
+ """The cosmetic filters of each adapter whose system's banner the blocker's lists miss."""
60
+ return [line for a in adapters() if isinstance(a, Filtered) for line in a.filters]
@@ -32,25 +32,9 @@ QUESTIONS = "/api/application/v2/questions?pids={pid}&user_mode=logged_out_candi
32
32
  CSRF = re.compile(r'<meta name="_csrf" content="([^"]+)"')
33
33
  SUBMIT = "[data-test-id=submitApplicationButton]"
34
34
  AGREE = "#confirmUploadResume" # the privacy dialog a resume upload opens
35
- COOKIES = [
36
- "#onetrust-reject-all-handler",
37
- "#truste-consent-required",
38
- "#didomi-notice-disagree-button",
39
- ]
40
- # An employer's own banner, declined by its button's name.
41
- BANNERS = ", ".join(
42
- f"[{attribute}*={word} i]"
43
- for word in ("cookie", "consent", "truste")
44
- for attribute in ("id", "class")
45
- )
46
- DECLINE = re.compile(
47
- r"^\s*((reject|decline|refuse|deny)( all)?( cookies)?|(required|necessary|essential) only)\s*$",
48
- re.IGNORECASE,
49
- )
50
35
  PICKER = "[data-testid=picker-dialog]"
51
36
  MAX_DEPTH = 5 # the deepest chain of questions rendering one another that a label names"
52
37
  FORM_TIMEOUT_MS = 30_000
53
- COOKIE_TIMEOUT_MS = 5_000
54
38
  SHOWN_TIMEOUT_MS = 2_000
55
39
  OPTIONS_TIMEOUT_MS = 5_000
56
40
  UPLOAD_TIMEOUT_MS = 30_000
@@ -221,7 +205,6 @@ class Eightfold:
221
205
  if "status" in api:
222
206
  raise Unsupported(f"posting_gone: the questions API answered {api['status']}")
223
207
  self._controls = _form(json.loads(api["text"])["data"])
224
- _decline_cookies(page)
225
208
  doms = page.evaluate(_DOM_IDS, [[c.field.id, c.ends] for c in self._controls])
226
209
  for control in self._controls:
227
210
  control.dom = doms.get(control.field.id)
@@ -239,7 +222,6 @@ class Eightfold:
239
222
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
240
223
  # the resume first: what its parse finds is written over the fields filled before it
241
224
  order = sorted(self._controls, key=lambda c: not c.resume)
242
- _decline_cookies(page, 0) # a banner shown since the read
243
225
  given: dict[str, Answer] = {}
244
226
  for control in order:
245
227
  if (answer := answers.get(control.field.id)) is None:
@@ -252,12 +234,7 @@ class Eightfold:
252
234
  control.dom = _find(page, control)
253
235
  if control.dom is None:
254
236
  continue
255
- try:
256
- _fill(page, control, answer)
257
- except browser.Covered: # a banner that showed late
258
- page.keyboard.press("Escape")
259
- _decline_cookies(page)
260
- _fill(page, control, answer)
237
+ _fill(page, control, answer)
261
238
  given[control.field.id] = answer
262
239
 
263
240
  def read_back(self, page: Page) -> dict[str, Answer]:
@@ -408,20 +385,6 @@ def _control(q: dict[str, Any], by_id: dict[str, dict[str, Any]]) -> _Control:
408
385
  return _Control(question, kind, ends, parents, q.get("type") == "resumeUpload")
409
386
 
410
387
 
411
- def _decline_cookies(page: Page, wait_ms: int = COOKIE_TIMEOUT_MS) -> None:
412
- """Declines the employer's cookie banner, which covers the form's lower half."""
413
- decline = page.locator(", ".join(COOKIES)).or_(
414
- page.locator(BANNERS).get_by_role("button", name=DECLINE)
415
- )
416
- if wait_ms: # a timeout of 0 waits for ever
417
- with suppress(Timeout):
418
- decline.first.wait_for(timeout=wait_ms)
419
- for button in decline.all():
420
- if button.is_visible():
421
- button.click()
422
- return
423
-
424
-
425
388
  def _find(page: Page, control: _Control) -> str | None:
426
389
  return page.evaluate(_DOM_IDS, [[control.field.id, control.ends]]).get(control.field.id)
427
390
 
@@ -46,7 +46,6 @@ PROPS = "[data-react-class='HiringThing.Components.ApplyButtonGroup']"
46
46
  APPLY = "button.job-apply-button"
47
47
  FORM = "#job-application-form"
48
48
  SUBMIT = f"{FORM} button[type=submit]"
49
- BANNER = ".cookie-banner"
50
49
  UPLOAD = "/client_api/add_file"
51
50
  FORM_TIMEOUT_MS = 30_000
52
51
  PAGE_TIMEOUT_MS = 30_000
@@ -164,7 +163,6 @@ class HiringThing:
164
163
  props.wait_for(state="attached", timeout=PAGE_TIMEOUT_MS)
165
164
  except Timeout:
166
165
  raise Unsupported("posting_gone: no application on the page") from None
167
- _hide_banner(page)
168
166
  form = page.locator(FORM)
169
167
  if not form.count():
170
168
  page.locator(APPLY).filter(visible=True).first.click()
@@ -184,7 +182,6 @@ class HiringThing:
184
182
  ]
185
183
 
186
184
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
187
- _hide_banner(page)
188
185
  form = page.locator(FORM)
189
186
  # the state last: its options are the chosen country's
190
187
  for field_id, kind in sorted(self._kinds.items(), key=lambda k: k[1] == "region"):
@@ -278,12 +275,6 @@ def _field(control: dict, questions: dict[str, str], options: list[str] | None)
278
275
  return Field(field_id, label, TYPES[kind], required, options, max_length)
279
276
 
280
277
 
281
- def _hide_banner(page: Page) -> None:
282
- """Hides the cookie banner, which covers the form's bottom, without answering it."""
283
- with suppress(Exception):
284
- page.locator(BANNER).evaluate_all("es => es.forEach(e => e.style.display = 'none')")
285
-
286
-
287
278
  def _combobox(page: Page, field_id: str) -> Locator:
288
279
  return page.locator(f"{FORM} input[role=combobox][id='{field_id}']")
289
280
 
@@ -19,13 +19,11 @@ from atsfill.core.results import Answer, Field, FieldType, SideEffect
19
19
  HOST = re.compile(r"^[\w-]+\.applytojob\.com$")
20
20
  JOB = re.compile(r"^/apply/([A-Za-z0-9]{10})\b")
21
21
  FORM = "#form_submit_new_resume"
22
- COOKIES = "#tracking-consent-banner .reject"
23
22
  CAPTCHA = "#resumator-recaptcha iframe[title=reCAPTCHA]"
24
23
  CHALLENGE = "iframe[src*='recaptcha/api2/bframe']"
25
24
  RESUME_TYPES = [".pdf", ".doc", ".docx", ".odt", ".txt", ".rtf"] # as the page states them
26
25
  YES_NO = ["Yes", "No"]
27
26
  FORM_TIMEOUT_MS = 20_000
28
- COOKIE_TIMEOUT_MS = 3_000
29
27
  CAPTCHA_TIMEOUT_MS = 20_000
30
28
  SUBMIT_TIMEOUT_MS = 90_000
31
29
 
@@ -141,7 +139,6 @@ class JazzHR:
141
139
  form.wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
142
140
  except Timeout:
143
141
  raise Unsupported("posting_gone: no application form on the page") from None
144
- _reject_cookies(page)
145
142
  fields, self._fields = [], {}
146
143
  for raw in form.evaluate(_READ):
147
144
  fields.append(_field(raw))
@@ -149,7 +146,6 @@ class JazzHR:
149
146
  return fields
150
147
 
151
148
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
152
- _reject_cookies(page, wait=False)
153
149
  form = page.locator(FORM)
154
150
  for field_id, answer in answers.items():
155
151
  values = [answer] if isinstance(answer, str) else answer
@@ -247,13 +243,3 @@ def _field(raw: dict[str, Any]) -> Field:
247
243
  return Field(
248
244
  raw["id"], raw["label"], field_type, raw["required"], options, raw.get("maxLength"), accept
249
245
  )
250
-
251
-
252
- def _reject_cookies(page: Page, wait: bool = True) -> None:
253
- """Rejects the tracking banner, which shows a moment after the page, over its foot."""
254
- reject = page.locator(COOKIES)
255
- with suppress(Timeout):
256
- if wait:
257
- reject.wait_for(timeout=COOKIE_TIMEOUT_MS)
258
- if reject.is_visible():
259
- reject.click()
@@ -34,7 +34,6 @@ LOADING = ".app-loading-spinner, .cx-select--loading, .apply-flow-block .skeleto
34
34
  OTP_PAGE = ".apply-flow-pin-code-required"
35
35
  CAPTCHA = "iframe[src*='hcaptcha.com']"
36
36
  MANUALLY = ".quick-apply-flow__manually--link:visible" # past an offer to import a profile
37
- COOKIES = ".cookie-consent button:visible"
38
37
  ATTACHED = ".attachment-upload-button__download, .misc-attachment-list__item-name"
39
38
  STEP_TIMEOUT_MS = 30_000
40
39
  LIST_TIMEOUT_MS = 3_000
@@ -237,6 +236,7 @@ _READ_BACK = """(ids) => {
237
236
  class Oracle:
238
237
  name = "oracle"
239
238
  known_gaps = {"entries": Gap.RENDERING}
239
+ filters = ["##.cookie-consent-modal"] # some tenants' banner, over the email step and the form
240
240
  side_effects = [
241
241
  SideEffect(
242
242
  "load",
@@ -501,16 +501,12 @@ class Oracle:
501
501
 
502
502
 
503
503
  def _click(page: Page, target: Locator) -> None:
504
- """Clicks, once more after what came over the target: the app's loading spinner, the
505
- cookie banner (declined), or a header fixed over the top of the page."""
504
+ """Clicks, once more after what came over the target: the app's loading spinner, or a
505
+ header fixed over the top of the page."""
506
506
  try:
507
507
  browser.click(target)
508
- except browser.Covered as e:
509
- if "cookie-consent" in str(e):
510
- buttons = page.locator(COOKIES)
511
- decline = buttons.filter(has_text=re.compile("decline|reject|necessary", re.I))
512
- browser.click((decline if decline.count() else buttons).first)
513
- elif "loading" in str(e):
508
+ except browser.Overlaid as e:
509
+ if "loading" in str(e):
514
510
  _settle(page)
515
511
  else: # a header fixed over the top of the page
516
512
  target.evaluate("e => e.scrollIntoView({block: 'center'})")
@@ -31,7 +31,6 @@ SUBMIT = "[data-automation-id=btnSubmit]"
31
31
  STEP = "#appDetailDiv .type-bold.float-right" # "Step 2 of 5"
32
32
  FORCED_RESUME = ".modal.in #btn-forceResume"
33
33
  FORM_TIMEOUT_MS = 30_000
34
- COOKIE_TIMEOUT_MS = 5_000
35
34
  STEP_TIMEOUT_MS = 20_000
36
35
  UPLOAD_TIMEOUT_MS = 30_000
37
36
  SUBMIT_TIMEOUT_MS = 90_000
@@ -354,7 +353,6 @@ class Paylocity:
354
353
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
355
354
  self._values = {}
356
355
  page.locator("form, #app").first.wait_for(timeout=FORM_TIMEOUT_MS)
357
- _dismiss_cookies(page)
358
356
  if "resume" in answers: # first: a parsed resume fills fields the answers then override
359
357
  _upload_resume(page, _one(answers["resume"]))
360
358
  while True:
@@ -524,13 +522,6 @@ def _present(page: Page, control: Control) -> bool:
524
522
  return target.count() == 1 and target.is_visible()
525
523
 
526
524
 
527
- def _dismiss_cookies(page: Page) -> None:
528
- close = page.locator("#onetrust-banner-sdk .onetrust-close-btn-handler")
529
- with suppress(Timeout):
530
- close.first.wait_for(state="visible", timeout=COOKIE_TIMEOUT_MS)
531
- close.first.click()
532
-
533
-
534
525
  def _upload_resume(page: Page, path: str) -> None:
535
526
  forced = page.locator(FORCED_RESUME)
536
527
  if forced.count():
@@ -23,7 +23,7 @@ from urllib.parse import parse_qs, urlsplit
23
23
 
24
24
  from atsfill.core import browser, matching
25
25
  from atsfill.core.adapter import Gap, NotTaken, Unsupported, known_gap
26
- from atsfill.core.browser import HTTP_TIMEOUT_S, BrowserError, Covered, Locator, Page, Timeout
26
+ from atsfill.core.browser import HTTP_TIMEOUT_S, BrowserError, Locator, Overlaid, Page, Timeout
27
27
  from atsfill.core.results import Answer, Field, FieldType, SideEffect
28
28
 
29
29
  JOB = re.compile(r"^(/[a-z]{2,8}/[a-z]{2})/(job|\w*apply)(?:/|$)")
@@ -141,22 +141,6 @@ _DIAL = """form => {
141
141
  return (text.match(/\\(\\+(\\d+)/) || (el.value || '').match(/_(\\d+)$/) || [])[1] || null;
142
142
  }"""
143
143
 
144
- # Declines, else accepts, the cookie banner: it sits over the form's controls.
145
- _COOKIES = """() => {
146
- const banners = [...document.querySelectorAll('[ph-module=gdpr], [id*=cookie i],'
147
- + ' [class*=cookie i], [id*=truste i], [id*=onetrust i], [class*=consent i], .cc-window,'
148
- + ' [role=dialog][aria-label*=cookie i]')];
149
- const buttons = banners.flatMap(b => [...b.querySelectorAll('button, a[role=button], a')])
150
- .filter(b => b.offsetWidth || b.offsetHeight);
151
- const pick = buttons.find(b => /^\\W*(deny|decline|reject)/i.test(b.innerText.trim()))
152
- || buttons.find(b => /^\\W*(allow|accept|agree|i consent|ok|got it|close|dismiss)/i
153
- .test(b.innerText.trim() || b.getAttribute('aria-label') || ''));
154
- if (pick) { pick.click(); return true; }
155
- for (const b of banners) // no button to answer it: out of the way, unanswered
156
- if (getComputedStyle(b).position === 'fixed') b.style.display = 'none';
157
- return false;
158
- }"""
159
-
160
144
  # Picks a dropdown's option by its text, as React sees a change, even to the value it holds.
161
145
  _SELECT = """(el, text) => {
162
146
  const option = [...el.options].find(o => o.text.trim() === text);
@@ -1128,11 +1112,9 @@ def _next(page: Page, number: int) -> None:
1128
1112
 
1129
1113
 
1130
1114
  def _click(page: Page, target: Locator) -> None:
1131
- """Clicks once the page's loading overlay is gone, past the cookie banner, which can show
1132
- at any time and covers the form."""
1115
+ """Clicks once the page's loading overlay is gone."""
1133
1116
  with suppress(Timeout):
1134
1117
  page.locator(LOADING).first.wait_for(state="hidden", timeout=STEP_TIMEOUT_MS)
1135
- page.evaluate(_COOKIES)
1136
1118
  browser.click(target)
1137
1119
 
1138
1120
 
@@ -1309,10 +1291,9 @@ def _fill(page: Page, raw: dict[str, Any], answer: Answer) -> None:
1309
1291
  try:
1310
1292
  _click(page, item.first if item.count() else items.first)
1311
1293
  break
1312
- except BrowserError, Covered: # rendered again under the click
1294
+ except BrowserError, Overlaid: # rendered again under the click
1313
1295
  continue
1314
1296
  case "radio": # its label wraps it or names it by `for`
1315
- page.evaluate(_COOKIES)
1316
1297
  if not form.evaluate(_RADIO, [raw["id"], _one(answer)]):
1317
1298
  raise NotTaken(f"{raw['id']} has no option {_one(answer)!r}")
1318
1299
  case "checkbox":
@@ -34,8 +34,6 @@ FORM_TIMEOUT_MS = 30_000
34
34
  HYDRATE_TIMEOUT_MS = 30_000
35
35
  OPTION_TIMEOUT_MS = 5_000
36
36
  SHOWN_TIMEOUT_MS = 2_000
37
- COOKIE_TIMEOUT_MS = 5_000
38
- ONETRUST = "#onetrust-consent-sdk"
39
37
  US_STATE = ".react-select:has(input[type=hidden][name$='[town]'])" # the town's place in the US
40
38
  UPLOAD_TIMEOUT_MS = 30_000
41
39
  SUBMIT_TIMEOUT_MS = 90_000
@@ -145,7 +143,6 @@ class Pinpoint:
145
143
  return [f for f, _ in listed]
146
144
 
147
145
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
148
- _decline_cookies(page)
149
146
  form = page.locator(FORM)
150
147
  for f in self._fields:
151
148
  if f.id not in answers:
@@ -236,16 +233,6 @@ def _hydrated(page: Page) -> None:
236
233
  )
237
234
 
238
235
 
239
- def _decline_cookies(page: Page) -> None:
240
- """Declines an employer's OneTrust banner, whose backdrop covers the form."""
241
- if not page.locator(ONETRUST).count():
242
- return
243
- reject = page.locator(f"{ONETRUST} #onetrust-reject-all-handler")
244
- with suppress(Timeout):
245
- reject.wait_for(state="visible", timeout=COOKIE_TIMEOUT_MS)
246
- reject.click()
247
-
248
-
249
236
  def _shown(root: Locator) -> bool:
250
237
  """Whether a component shows a control, waiting a moment for one an earlier answer opens."""
251
238
  try:
@@ -39,7 +39,6 @@ SUBMIT_TIMEOUT_MS = 90_000
39
39
  DIAL_CODES = (3, 2, 1) # the lengths a dial code can have, longest tried first
40
40
  FILE_TYPES = [".doc", ".docx", ".pdf"]
41
41
  OTHER = "Other"
42
- COOKIES = "#transcend-consent-manager"
43
42
  TESTIDS = {"pronouns": "pronouns_strategy"} # a field whose element is not named by its id
44
43
 
45
44
  # A standard question's type, by its `fieldType`; the location is a place typeahead.
@@ -160,6 +159,7 @@ _SHOWN = """(root, [id, nth, testids]) => {
160
159
  class Rippling:
161
160
  name = "rippling"
162
161
  known_gaps: dict[str, Gap] = {}
162
+ filters = ["ats.rippling.com###transcend-consent-manager"] # Transcend's banner
163
163
  side_effects = [
164
164
  SideEffect("upload", "hiring_system", "an attached file is uploaded, a résumé parsed"),
165
165
  SideEffect("submit", "employer", "the application reaches the employer"),
@@ -199,7 +199,6 @@ class Rippling:
199
199
  page.locator("[data-testid=Apply]").wait_for(timeout=FORM_TIMEOUT_MS)
200
200
  except Timeout:
201
201
  raise Unsupported("posting_gone: no application form on the page") from None
202
- _hide_cookies(page)
203
202
  data = page.locator("#__NEXT_DATA__").text_content() or "{}"
204
203
  out = []
205
204
  for field in _form(_api(json.loads(data))):
@@ -340,13 +339,6 @@ def _custom(field_id: str, q: dict[str, Any]) -> list[Field]:
340
339
  return out
341
340
 
342
341
 
343
- def _hide_cookies(page: Page) -> None:
344
- """Hides the cookie banner, whose layer takes the clicks on the lower part of the page: it
345
- renders in a closed shadow root, out of reach of a click, and shows a moment after the
346
- form. Nothing is answered to it."""
347
- page.add_style_tag(content=f"{COOKIES} {{display: none !important}}")
348
-
349
-
350
342
  def _click(target: Locator) -> None:
351
343
  """Clicks the target scrolled to the middle of the window, clear of the Apply bar fixed
352
344
  at its bottom."""
@@ -27,7 +27,6 @@ CONTAINER = f"{QUESTIONS} [data-test=question-container]"
27
27
  NEXT = "[data-test=footer-next] button"
28
28
  SUBMIT = "[data-test=footer-submit] button"
29
29
  FORM_TIMEOUT_MS = 30_000
30
- COOKIE_TIMEOUT_MS = 5_000
31
30
  OPTION_TIMEOUT_MS = 10_000
32
31
  UPLOAD_TIMEOUT_MS = 30_000
33
32
  SUBMIT_TIMEOUT_MS = 90_000
@@ -147,7 +146,6 @@ class SmartRecruiters:
147
146
  page.locator(PROFILE[0][1]).wait_for(timeout=FORM_TIMEOUT_MS)
148
147
  except Timeout:
149
148
  raise Unsupported("posting_gone: no application form on the page") from None
150
- _decline_cookies(page)
151
149
  context = page.evaluate(_CONTEXT) or {}
152
150
  screening = context.get("screeningConfiguration") or {}
153
151
  self._questions = {q["id"]: q for q in screening.get("questions") or []}
@@ -170,7 +168,6 @@ class SmartRecruiters:
170
168
  return fields
171
169
 
172
170
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
173
- _decline_cookies(page)
174
171
  for field_id, selector, _ in PROFILE:
175
172
  if field_id in answers:
176
173
  _fill_profile(page, field_id, selector, answers[field_id])
@@ -315,13 +312,6 @@ def _plain(text: str) -> str:
315
312
  return re.sub(r"\s+", " ", text.replace("*", "")).strip()
316
313
 
317
314
 
318
- def _decline_cookies(page: Page) -> None:
319
- reject = page.locator("#onetrust-reject-all-handler")
320
- with suppress(Timeout):
321
- reject.wait_for(state="visible", timeout=COOKIE_TIMEOUT_MS)
322
- reject.click()
323
-
324
-
325
315
  def _profile_field(page: Page, field_id: str, selector: str, host: str) -> Field | None:
326
316
  element = page.locator(host).first
327
317
  if not page.locator(selector).count() or not element.count():
@@ -221,7 +221,6 @@ class Teamtailor:
221
221
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
222
222
  form = page.locator(FORM)
223
223
  self._videos = {}
224
- _decline_cookies(page)
225
224
  for field_id in [i for i in answers if i in self._qualifying]:
226
225
  self._fill(form, field_id, answers[field_id])
227
226
  if form.locator(LOCKED).count():
@@ -252,7 +251,7 @@ class Teamtailor:
252
251
  return {k: v for k, v in values.items() if v not in (None, "", [])}
253
252
 
254
253
  def submit(self, page: Page) -> str | None:
255
- _click(page, page.locator(f"{FORM} [type=submit]"))
254
+ browser.click(page.locator(f"{FORM} [type=submit]"))
256
255
  page.wait_for_url(re.compile(r"/applications/[^/]+/thanks"), timeout=SUBMIT_TIMEOUT_MS)
257
256
  return page.url
258
257
 
@@ -266,17 +265,17 @@ class Teamtailor:
266
265
  case FieldType.CHOICE, str() if host.locator("input[type=checkbox]").count():
267
266
  box = host.locator("input[type=checkbox]")
268
267
  if box.is_checked() != (answer == "Yes"):
269
- _click(page, page.locator(f"label[for='{box.get_attribute('id')}']"))
268
+ browser.click(page.locator(f"label[for='{box.get_attribute('id')}']"))
270
269
  case FieldType.CHOICE, str() if host.locator("input[type=range]").count():
271
270
  host.locator("input[type=range]").fill(answer)
272
271
  case FieldType.TYPEAHEAD, str():
273
- _suggest(page, field, host, answer)
272
+ _suggest(field, host, answer)
274
273
  case FieldType.CHOICE | FieldType.MULTI_CHOICE, _:
275
274
  for value in [answer] if isinstance(answer, str) else answer:
276
- _pick(page, host, value)
275
+ _pick(host, value)
277
276
  trigger = host.locator("button[aria-haspopup][aria-expanded=true]")
278
277
  if trigger.count(): # a multiple choice menu stays open over the next fields
279
- _click(page, trigger.first)
278
+ browser.click(trigger.first)
280
279
  case FieldType.TEXT, str() if host.locator("input[type=number]").count():
281
280
  if re.fullmatch(r"-?\d+(\.\d+)?", answer): # else left empty: a mismatch
282
281
  host.locator("input[type=number]").first.fill(answer)
@@ -374,23 +373,7 @@ _VIDEO = """url => fetch(url, {headers: {Accept: "application/json"}})
374
373
  .then(r => r.ok ? r.json() : {}).then(v => v.file_file_name ?? null)"""
375
374
 
376
375
 
377
- def _decline_cookies(page: Page) -> None:
378
- """Closes the cookie banner, a modal dialog that leaves the form inert while it is open."""
379
- decline = page.locator("[data-action*='cookies--alert#disableAll']").first
380
- if decline.is_visible():
381
- decline.click()
382
-
383
-
384
- def _click(page: Page, target: Locator) -> None:
385
- """Clicks, first declining the cookie banner when it covers the target: it comes back."""
386
- try:
387
- browser.click(target)
388
- except browser.Covered:
389
- _decline_cookies(page)
390
- browser.click(target)
391
-
392
-
393
- def _suggest(page: Page, field: Field, host: Locator, answer: str) -> None:
376
+ def _suggest(field: Field, host: Locator, answer: str) -> None:
394
377
  """Types the address key by key, which loads the suggestions, and picks the first that
395
378
  matches it: the form keeps an address only once a suggestion is picked."""
396
379
  box = host.locator("input[role=combobox]")
@@ -400,20 +383,20 @@ def _suggest(page: Page, field: Field, host: Locator, answer: str) -> None:
400
383
  options.first.wait_for()
401
384
  for i in range(options.count()):
402
385
  if matching.matches(field, answer, options.nth(i).get_attribute("data-description") or ""):
403
- _click(page, options.nth(i))
386
+ browser.click(options.nth(i))
404
387
  return
405
388
  raise NotTaken(f"no suggestion for {answer!r} in {field.id}")
406
389
 
407
390
 
408
- def _pick(page: Page, question: Locator, value: str) -> None:
391
+ def _pick(question: Locator, value: str) -> None:
409
392
  """Checks the option whose text is `value`, a radio or checkbox label or a dropdown item."""
410
393
  label = question.locator("label:visible").filter(
411
394
  has_text=re.compile(rf"^\s*{re.escape(value)}\s*$")
412
395
  )
413
396
  if label.count():
414
- _click(page, label.first)
397
+ browser.click(label.first)
415
398
  return
416
399
  trigger = question.locator("button[aria-haspopup]").first
417
400
  if trigger.get_attribute("aria-expanded") != "true":
418
- _click(page, trigger)
419
- _click(page, question.locator(f"[role^=menuitem][data-search-text='{value}']").first)
401
+ browser.click(trigger)
402
+ browser.click(question.locator(f"[role^=menuitem][data-search-text='{value}']").first)
@@ -24,10 +24,8 @@ JOB = re.compile(r"^(?:/[^/]+)?/j/([0-9A-F]{10})\b")
24
24
  API = "https://apply.workable.com/api/v1/jobs/{code}/form"
25
25
  APPLIED = re.compile(r"/api/v1/jobs/[0-9A-F]{10}/apply$")
26
26
  FORM = "form[data-ui=application-form]"
27
- COOKIES = "[data-ui=cookie-consent-decline]"
28
27
  UPLOADED = "[data-action=delete]" # an uploading file shows its name and an abort button
29
28
  FORM_TIMEOUT_MS = 30_000
30
- COOKIE_TIMEOUT_MS = 5_000
31
29
  OPTIONS_TIMEOUT_MS = 5_000
32
30
  CHECK_TIMEOUT_MS = 3_000
33
31
  UPLOAD_TIMEOUT_MS = 30_000
@@ -184,7 +182,6 @@ class Workable:
184
182
  form.wait_for(timeout=FORM_TIMEOUT_MS)
185
183
  except Timeout:
186
184
  raise Unsupported("posting_gone: no application form on the page") from None
187
- _decline_cookies(page)
188
185
  fields, self._kinds, self._formats = [], {}, {}
189
186
  for raw in form.evaluate(_READ):
190
187
  fields.append(self._field(page, raw))
@@ -194,7 +191,6 @@ class Workable:
194
191
  return fields
195
192
 
196
193
  def fill(self, page: Page, answers: dict[str, Answer]) -> None:
197
- _decline_cookies(page, wait=False)
198
194
  # attaching the resume empties the photo, so it goes first
199
195
  for field_id, answer in sorted(answers.items(), key=lambda a: a[0] != "resume"):
200
196
  values = [answer] if isinstance(answer, str) else answer
@@ -340,17 +336,6 @@ def _code(url: str) -> str | None:
340
336
  return match[1] if match else None
341
337
 
342
338
 
343
- def _decline_cookies(page: Page, wait: bool = True) -> None:
344
- """Declines the cookie banner, a modal dialog that leaves the form inert while it is open;
345
- it shows a moment after the page."""
346
- decline = page.locator(COOKIES)
347
- with suppress(Timeout):
348
- if wait:
349
- decline.wait_for(timeout=COOKIE_TIMEOUT_MS)
350
- if decline.is_visible():
351
- decline.click()
352
-
353
-
354
339
  def _exact(value: str) -> re.Pattern[str]:
355
340
  return re.compile(rf"^\s*{re.escape(value)}\s*$")
356
341
 
@@ -106,7 +106,7 @@ class WelcomeToTheJungle:
106
106
  if not form.is_visible(): # an easyapply link opens the form by itself
107
107
  try:
108
108
  browser.click(apply)
109
- except browser.Covered:
109
+ except browser.Overlaid:
110
110
  if not form.is_visible():
111
111
  raise
112
112
  try:
@@ -28,12 +28,9 @@ POSTING = "https://{host}/recruit/v2/public/Job_Openings/{job}"
28
28
  API = POSTING + "/apply/{form}?pagename={page}&source=CareerSite"
29
29
  WEBFORM = re.compile(r'"webform_id":"(\d+)"')
30
30
  APPLY = "#detail-page-applybtn button"
31
- COOKIES = "button.cookie-decline-btn"
32
31
  FORM = "rec-form-component[webformid]"
33
32
  SUBMIT = "#cw-submit-btn button"
34
33
  FORM_TIMEOUT_MS = 30_000
35
- COOKIE_TIMEOUT_MS = 5_000
36
- COOKIE_ATTEMPTS = 3
37
34
  SHOWN_TIMEOUT_MS = 2_000
38
35
  TAG_TIMEOUT_MS = 2_000
39
36
  TAG_ATTEMPTS = 4
@@ -131,6 +128,7 @@ class ZohoRecruit:
131
128
  name = "zohorecruit"
132
129
  known_gaps: dict[str, Gap] = {}
133
130
  side_effects = [SideEffect("submit", "employer", "the application reaches the employer")]
131
+ filters = ["##career-cookie-consent"] # its banner, on Zoho's domains and employers' own
134
132
 
135
133
  def __init__(self) -> None:
136
134
  self._controls: list[_Control] = [] # in the form's order
@@ -168,7 +166,7 @@ class ZohoRecruit:
168
166
  html = page.evaluate(_FETCH, page.url)
169
167
  if "status" in html or (form := WEBFORM.search(html["text"])) is None:
170
168
  raise Unsupported("posting_gone: the job page names no application form")
171
- _open(page, apply)
169
+ browser.click(apply)
172
170
  page.locator(SUBMIT).wait_for(timeout=FORM_TIMEOUT_MS)
173
171
  api = page.evaluate(_FETCH, _api(page.url, form[1]))
174
172
  if "status" in api:
@@ -260,23 +258,6 @@ def _get(url: str) -> str:
260
258
  return response.read().decode()
261
259
 
262
260
 
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
261
  def _form(api: dict[str, Any]) -> tuple[list[_Control], bool]:
281
262
  """Each field of the form, in its order, and whether it asks for a CAPTCHA. A repeatable
282
263
  section is read as its first entry; the pre-screening questions are read when the form
@@ -84,6 +84,14 @@ class Adapter(Protocol):
84
84
  ...
85
85
 
86
86
 
87
+ @runtime_checkable
88
+ class Filtered(Protocol):
89
+ """An adapter whose system shows a cookie banner the blocker's lists miss: its own
90
+ uBlock Origin cosmetic filters hide it (ADR 0004)."""
91
+
92
+ filters: list[str]
93
+
94
+
87
95
  @runtime_checkable
88
96
  class NeedsAccount(Protocol):
89
97
  """An adapter whose system needs a signed-in session, one per user and account scope
@@ -5,6 +5,8 @@ import fcntl
5
5
  import json
6
6
  import locale
7
7
  import platform
8
+ import re
9
+ import shutil
8
10
  import subprocess
9
11
  import sys
10
12
  import threading
@@ -13,8 +15,11 @@ from collections.abc import Generator
13
15
  from concurrent.futures import Future
14
16
  from contextlib import contextmanager
15
17
  from pathlib import Path
18
+ from urllib.parse import urljoin
19
+ from urllib.request import Request as HttpRequest
20
+ from urllib.request import urlopen
16
21
 
17
- from camoufox.addons import DefaultAddons, maybe_download_addons
22
+ from camoufox.addons import ADDONS_DIR, DefaultAddons, get_addon_path, maybe_download_addons
18
23
  from camoufox.exceptions import CamoufoxNotInstalled
19
24
  from camoufox.fingerprints import Screen, generate_fingerprint
20
25
  from camoufox.pkgman import installed_verstr
@@ -27,7 +32,7 @@ from playwright.sync_api import TimeoutError as Timeout
27
32
  __all__ = [
28
33
  "BUILD",
29
34
  "BrowserError",
30
- "Covered",
35
+ "Overlaid",
31
36
  "Locator",
32
37
  "OutOfTime",
33
38
  "Page",
@@ -50,14 +55,22 @@ BUDGET = 180.0 # seconds per command, lock wait included
50
55
  LOCK_POLL_S = 0.1
51
56
  HTTP_TIMEOUT_S = 20 # per request to a hiring system's API
52
57
  BUILD = "156.0.1-beta.36" # the Camoufox browser build, pinned so results compare across machines
58
+ BLOCKER = ADDONS_DIR / "UBO-annoyances" # Camoufox's uBlock Origin, its annoyance lists on
59
+ BLOCKER_LISTS = "annoyances" # the group of uBlock Origin lists: cookie banners, pop-ups, widgets
60
+ # The adapters' filters for the banners the lists miss: a list of the blocker's, on like them.
61
+ FILTERS = "assets/atsfill/filters.txt"
62
+ INCLUDE = re.compile(r"^!#include (\S+)$", re.M)
63
+ # A page holding a banner the lists hide: until they do, the blocker is still starting.
64
+ PROBE = ("https://blocker.invalid/", '<div id="cookie-banner">probe</div>')
65
+ BLOCKER_TIMEOUT_MS = 30_000 # its lists take seconds to compile on each launch, more under load
53
66
 
54
67
 
55
68
  class OutOfTime(Exception):
56
69
  """The command's time budget ran out."""
57
70
 
58
71
 
59
- class Covered(Exception):
60
- """Another element is on top of the click target; the message describes it."""
72
+ class Overlaid(Exception):
73
+ """An overlay is on top of the click target; the message describes it."""
61
74
 
62
75
 
63
76
  def version() -> str:
@@ -77,9 +90,63 @@ def install() -> int:
77
90
  print(f"the Camoufox build {BUILD} is not installed", file=sys.stderr)
78
91
  return 1
79
92
  maybe_download_addons(list(DefaultAddons))
93
+ try:
94
+ _build_blocker()
95
+ except OSError as e:
96
+ print(f"the annoyance lists could not be fetched: {e}", file=sys.stderr)
97
+ return 1
80
98
  return 0
81
99
 
82
100
 
101
+ def _build_blocker() -> None:
102
+ """Copies Camoufox's uBlock Origin to `BLOCKER` with only its annoyance lists on, read
103
+ from disk: fetched by each launch, they would hold its first page for seconds, and each
104
+ list on is compiled by each launch before the blocker starts."""
105
+ ADDONS_DIR.mkdir(parents=True, exist_ok=True)
106
+ with (ADDONS_DIR / f"{BLOCKER.name}.lock").open("w") as f:
107
+ fcntl.flock(f, fcntl.LOCK_EX)
108
+ build = BLOCKER.with_name(f"{BLOCKER.name}.build")
109
+ shutil.rmtree(build, ignore_errors=True)
110
+ shutil.copytree(get_addon_path(DefaultAddons.UBO.name), build)
111
+ assets = build / "assets" / "assets.json"
112
+ entries = json.loads(assets.read_text())
113
+ for key, entry in entries.items():
114
+ if entry.get("group") != BLOCKER_LISTS:
115
+ if entry.get("content") == "filters": # ads and trackers: compiled for nothing
116
+ entry["off"] = True
117
+ continue
118
+ urls = entry["contentURL"]
119
+ urls = [urls] if isinstance(urls, str) else urls
120
+ local = f"assets/{BLOCKER_LISTS}/{key}.txt"
121
+ (build / local).parent.mkdir(exist_ok=True)
122
+ (build / local).write_text(_filter_list([*urls, *entry.get("cdnURLs", [])]))
123
+ entry.pop("off", None) # on from the start, the way a fresh profile reads it
124
+ entry["contentURL"] = [local, *urls]
125
+ entries["atsfill"] = {"content": "filters", "group": "custom", "title": "atsfill",
126
+ "contentURL": [FILTERS]} # fmt: skip
127
+ (build / FILTERS).parent.mkdir()
128
+ (build / FILTERS).touch()
129
+ assets.write_text(json.dumps(entries))
130
+ shutil.rmtree(BLOCKER, ignore_errors=True)
131
+ build.rename(BLOCKER)
132
+
133
+
134
+ def _filter_list(urls: list[str]) -> str:
135
+ """A filter list from the first of its sources that answers, its includes inlined."""
136
+ for url in (u for u in urls if u.startswith("https://")):
137
+ try:
138
+ return INCLUDE.sub(lambda m, base=url: _fetch(urljoin(base, m[1])), _fetch(url))
139
+ except OSError:
140
+ continue
141
+ raise OSError(f"no source answered for {urls[0]}")
142
+
143
+
144
+ def _fetch(url: str) -> str:
145
+ request = HttpRequest(url, headers={"User-Agent": "Mozilla/5.0"}) # some refuse urllib's
146
+ with urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
147
+ return response.read().decode()
148
+
149
+
83
150
  def identity(data_dir: Path) -> dict:
84
151
  """The device identity every launch reuses, generated for the host OS on the first one."""
85
152
  path = data_dir / "identity.json"
@@ -117,17 +184,53 @@ def lock(data_dir: Path, deadline: float) -> Generator[None]:
117
184
 
118
185
  @contextmanager
119
186
  def _launch(data_dir: Path, headless: bool) -> Generator[Browser]:
187
+ if not (BLOCKER / FILTERS).exists(): # installed before the blocker, or its filters, were
188
+ maybe_download_addons(list(DefaultAddons))
189
+ _build_blocker()
190
+ _write_filters()
120
191
  with Camoufox(
121
192
  headless=headless,
193
+ addons=[str(BLOCKER)],
194
+ exclude_addons=list(DefaultAddons),
122
195
  fingerprint=identity(data_dir),
123
196
  os=HOST_OS,
124
197
  locale=machine_locale(),
125
198
  i_know_what_im_doing=True,
126
199
  ) as browser:
127
200
  assert isinstance(browser, Browser) # not a persistent context
201
+ _wait_for_blocker(browser)
128
202
  yield browser
129
203
 
130
204
 
205
+ def _write_filters() -> None:
206
+ """Writes the adapters' filters into the blocker when they changed, in one rename so that
207
+ a launch beside it reads either version whole."""
208
+ from atsfill.adapters import filters # adapters import this module
209
+
210
+ path = BLOCKER / FILTERS
211
+ text = "".join(f"{line}\n" for line in filters())
212
+ if path.read_text() != text:
213
+ staged = path.with_suffix(f".{threading.get_native_id()}")
214
+ staged.write_text(text)
215
+ staged.replace(path)
216
+
217
+
218
+ def _wait_for_blocker(browser: Browser) -> None:
219
+ """Returns once the blocker hides banners: until then a page shows them, and a fresh
220
+ browser starts on the posting itself."""
221
+ context = browser.new_context()
222
+ url, html = PROBE
223
+ context.route(url, lambda route: route.fulfill(content_type="text/html", body=html))
224
+ page = context.new_page()
225
+ page.goto(url)
226
+ try:
227
+ page.locator("#cookie-banner").wait_for(state="hidden", timeout=BLOCKER_TIMEOUT_MS)
228
+ except Timeout:
229
+ raise BrowserError("the blocker hid no banner: run `atsfill install-browser`") from None
230
+ finally:
231
+ context.close()
232
+
233
+
131
234
  @contextmanager
132
235
  def open_page(
133
236
  data_dir: Path, session: Path | None = None, seconds: float = BUDGET
@@ -198,7 +301,7 @@ def session_request(path: Path) -> Generator[Request]:
198
301
  # What sits on top of a point, null when the element (or the shadow host holding it) is what
199
302
  # a click there would reach. The point defaults to where Playwright clicks: the centre of the
200
303
  # first box, which for wrapped inline text is its first line, not the bounding box's centre.
201
- _COVER = """(el, point) => {
304
+ _OVERLAY = """(el, point) => {
202
305
  const r = [...el.getClientRects()].find(b => b.width && b.height)
203
306
  ?? el.getBoundingClientRect();
204
307
  const [x, y] = point ?? [r.left + r.width / 2, r.top + r.height / 2];
@@ -214,12 +317,12 @@ _COVER = """(el, point) => {
214
317
  }"""
215
318
 
216
319
 
217
- def covered_by(target: Locator) -> str | None:
218
- """What covers the target, in its own document and then at its point in each frame up."""
320
+ def overlay_on(target: Locator) -> str | None:
321
+ """The overlay on the target, in its own document and then at its point in each frame up."""
219
322
  target.scroll_into_view_if_needed()
220
323
  handle = target.element_handle()
221
- if cover := handle.evaluate(_COVER, None):
222
- return cover
324
+ if overlay := handle.evaluate(_OVERLAY, None):
325
+ return overlay
223
326
  box = target.bounding_box() # in the top page's viewport
224
327
  frame = handle.owner_frame()
225
328
  if box is None or frame is None:
@@ -228,14 +331,14 @@ def covered_by(target: Locator) -> str | None:
228
331
  while (parent := frame.parent_frame) is not None:
229
332
  origin = parent.frame_element().bounding_box() if parent.parent_frame else None
230
333
  point = [x - origin["x"], y - origin["y"]] if origin else [x, y]
231
- if cover := frame.frame_element().evaluate(_COVER, point):
232
- return cover
334
+ if overlay := frame.frame_element().evaluate(_OVERLAY, point):
335
+ return overlay
233
336
  frame = parent
234
337
  return None
235
338
 
236
339
 
237
340
  def click(target: Locator) -> None:
238
- """Clicks the target, raising `Covered` when something else is on top of it."""
239
- if cover := covered_by(target):
240
- raise Covered(cover)
341
+ """Clicks the target, raising `Overlaid` when an overlay is on top of it."""
342
+ if overlay := overlay_on(target):
343
+ raise Overlaid(overlay)
241
344
  target.click()
@@ -17,7 +17,7 @@ from atsfill.core.adapter import (
17
17
  SignsIn,
18
18
  Unsupported,
19
19
  )
20
- from atsfill.core.browser import Covered, OutOfTime, Page, open_page
20
+ from atsfill.core.browser import OutOfTime, Overlaid, Page, open_page
21
21
  from atsfill.core.results import (
22
22
  AdapterStatus,
23
23
  Answer,
@@ -188,8 +188,8 @@ def _stops(result: Result) -> Generator[None]:
188
188
  result.status, result.reason = Status.REFUSED, str(e)
189
189
  except Blocked as e:
190
190
  result.status, result.reason = Status.BLOCKED, str(e)
191
- except Covered as e:
192
- result.status, result.reason = Status.BLOCKED, f"covered: {e}"
191
+ except Overlaid as e:
192
+ result.status, result.reason = Status.BLOCKED, f"overlaid: {e}"
193
193
 
194
194
 
195
195
  def _in_budget(url: str, command: Callable[[list[str]], Result]) -> Result:
@@ -73,6 +73,9 @@ def _answer_problem(field: Field, answer: object) -> str | None:
73
73
  values = [answer]
74
74
  if field.type in (FieldType.CHOICE, FieldType.MULTI_CHOICE) and not _in_options(field, values):
75
75
  return f"{answer!r} is not one of {field.options}"
76
+ length = len(_norm(values[0]))
77
+ if field.type is FieldType.TEXT and field.max_length and length > field.max_length:
78
+ return f"{length} characters, over the limit of {field.max_length}"
76
79
  if field.type is FieldType.DATE and not re.fullmatch(r"\d{4}-\d{2}-\d{2}", values[0]):
77
80
  return "a date answer is YYYY-MM-DD"
78
81
  if field.type is FieldType.FILE:
File without changes
File without changes