atsfill 0.0.4.dev85__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.
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/PKG-INFO +1 -1
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/adapters/__init__.py +6 -1
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/adapters/eightfold.py +1 -38
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/adapters/hiringthing.py +0 -9
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/adapters/jazzhr.py +0 -14
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/adapters/oracle.py +5 -9
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/adapters/paylocity.py +0 -9
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/adapters/phenom.py +3 -22
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/adapters/pinpoint.py +0 -13
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/adapters/rippling.py +1 -9
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/adapters/smartrecruiters.py +0 -10
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/adapters/teamtailor.py +11 -28
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/adapters/workable.py +0 -15
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/adapters/wttj.py +1 -1
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/adapters/zohorecruit.py +2 -21
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/core/adapter.py +8 -0
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/core/browser.py +117 -14
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/core/commands.py +3 -3
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/LICENSE +0 -0
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/README.md +0 -0
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev85 → atsfill-0.0.4.dev86}/src/atsfill/py.typed +0 -0
- {atsfill-0.0.4.dev85 → 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.
|
|
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
|
|
@@ -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
|
-
|
|
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,
|
|
505
|
-
|
|
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.
|
|
509
|
-
if "
|
|
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,
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
419
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
"
|
|
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
|
|
60
|
-
"""
|
|
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
|
-
|
|
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
|
|
218
|
-
"""
|
|
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
|
|
222
|
-
return
|
|
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
|
|
232
|
-
return
|
|
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 `
|
|
239
|
-
if
|
|
240
|
-
raise
|
|
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
|
|
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
|
|
192
|
-
result.status, result.reason = Status.BLOCKED, f"
|
|
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:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|