atsfill 0.0.5.dev154__tar.gz → 0.0.5.dev160__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.5.dev154 → atsfill-0.0.5.dev160}/PKG-INFO +2 -1
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/README.md +1 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/pyproject.toml +1 -1
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/pyproject.toml.orig +1 -1
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/__init__.py +2 -0
- atsfill-0.0.5.dev160/src/atsfill/adapters/jobstreet.py +481 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/join.py +182 -6
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/core/adapter.py +8 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/core/browser.py +8 -1
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/core/commands.py +37 -7
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/core/registry.py +2 -1
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/registry.toml +3 -2
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/LICENSE +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/gupy.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/hrmdirect.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/isolvedhire.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/jobaffinity.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/recruiterflow.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/successfactors.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/ukg.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/workatastartup.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/core/otp.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/core/snapshot.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/core/telemetry.py +0 -0
- {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atsfill
|
|
3
|
-
Version: 0.0.5.
|
|
3
|
+
Version: 0.0.5.dev160
|
|
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
|
|
@@ -89,6 +89,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
89
89
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
90
90
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
91
91
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
92
|
+
[](https://shbernal.github.io/atsfill/run-history/)
|
|
92
93
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
93
94
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
94
95
|
<!-- /run-history -->
|
|
@@ -60,6 +60,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
60
60
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
61
61
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
62
62
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
63
|
+
[](https://shbernal.github.io/atsfill/run-history/)
|
|
63
64
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
64
65
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
65
66
|
<!-- /run-history -->
|
|
@@ -10,6 +10,7 @@ from atsfill.adapters.hiringthing import HiringThing
|
|
|
10
10
|
from atsfill.adapters.isolvedhire import IsolvedHire
|
|
11
11
|
from atsfill.adapters.jazzhr import JazzHR
|
|
12
12
|
from atsfill.adapters.jobaffinity import JobAffinity
|
|
13
|
+
from atsfill.adapters.jobstreet import Jobstreet
|
|
13
14
|
from atsfill.adapters.join import Join
|
|
14
15
|
from atsfill.adapters.lever import Lever
|
|
15
16
|
from atsfill.adapters.manatal import Manatal
|
|
@@ -68,6 +69,7 @@ def adapters() -> list[Adapter]:
|
|
|
68
69
|
Gupy(),
|
|
69
70
|
UKG(),
|
|
70
71
|
IsolvedHire(),
|
|
72
|
+
Jobstreet(),
|
|
71
73
|
SuccessFactors(), # it claims a career site's job path on any host
|
|
72
74
|
Phenom(), # last: it claims a URL by its path alone
|
|
73
75
|
]
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
"""Jobstreet and Jobsdb, SEEK's boards in Southeast Asia (`<country>.jobstreet.com/job/<id>`,
|
|
2
|
+
`<country>.jobsdb.com/job/<id>`), as docs/adapters/jobstreet.md observes.
|
|
3
|
+
|
|
4
|
+
Quick apply needs a SEEK account without a password, signed in by an emailed OTP on each site.
|
|
5
|
+
The form is a wizard: documents and personal details, the employer's questions, the SEEK
|
|
6
|
+
profile, then review. Each step is kept by the page until submit, so the read-back is each
|
|
7
|
+
step's inputs as they stood when it was left.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
from contextlib import suppress
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import urlsplit
|
|
15
|
+
|
|
16
|
+
from atsfill.core import browser, matching, otp
|
|
17
|
+
from atsfill.core.adapter import (
|
|
18
|
+
Blocked,
|
|
19
|
+
Gap,
|
|
20
|
+
NotTaken,
|
|
21
|
+
Refused,
|
|
22
|
+
SignedOut,
|
|
23
|
+
SignUpDetails,
|
|
24
|
+
Unsupported,
|
|
25
|
+
known_gap,
|
|
26
|
+
)
|
|
27
|
+
from atsfill.core.browser import Locator, Page, Request, Response, Timeout
|
|
28
|
+
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
29
|
+
|
|
30
|
+
HOST = re.compile(r"^(?:[a-z]{2})\.(?:jobstreet|jobsdb)\.com$")
|
|
31
|
+
JOB = re.compile(r"^/(?:[a-z]{2}/)?job/(\d+)")
|
|
32
|
+
SIGN_IN_HOST = "login.seek.com"
|
|
33
|
+
APPLY = "[data-automation=job-detail-apply]"
|
|
34
|
+
CONTINUE = "[data-testid=continue-button]"
|
|
35
|
+
SUBMIT = "[data-testid=review-submit-application]"
|
|
36
|
+
SAVE_DETAILS = "[data-automation=personal-details-save-button]"
|
|
37
|
+
CHALLENGE = "Just a moment..." # Cloudflare's interstitial: it passes on its own, or on a reload
|
|
38
|
+
CHALLENGE_TRIES = 2
|
|
39
|
+
SIGN_IN_TIMEOUT_MS = 90_000 # SEEK's sign-in takes up to ~50 s to show its form
|
|
40
|
+
TURNSTILE_TIMEOUT_S = 45 # Cloudflare's widget passes on its own headless within seconds
|
|
41
|
+
TURNSTILE_POLL_MS = 3_000
|
|
42
|
+
OTP_STEP_TIMEOUT_MS = 30_000
|
|
43
|
+
SIGNED_IN_TIMEOUT_MS = 60_000 # from the OTP typed to the site, through the OAuth redirects
|
|
44
|
+
APPLY_TIMEOUT_MS = 60_000
|
|
45
|
+
STEP_TIMEOUT_MS = 30_000
|
|
46
|
+
SAVE_TIMEOUT_MS = 15_000
|
|
47
|
+
SUBMIT_TIMEOUT_MS = 90_000
|
|
48
|
+
CLICK_TRIES = 5
|
|
49
|
+
STEP_MOVE_MS = 8_000 # from Continue to the next step's address, when the click was taken
|
|
50
|
+
RERENDER_MS = 1_000
|
|
51
|
+
STEP_ERRORS = "[role=alert]:has-text('please address')"
|
|
52
|
+
SUGGEST_TIMEOUT_S = 8
|
|
53
|
+
POLL_MS = 250
|
|
54
|
+
KEY_DELAY_MS = 40
|
|
55
|
+
DETAILS = ("firstName", "lastName", "currentLocation", "phone")
|
|
56
|
+
VISIBILITY = ["Public", "Standard", "Limited", "Hidden"]
|
|
57
|
+
|
|
58
|
+
_STEP_ONE = """() => {
|
|
59
|
+
const value = sel => document.querySelector(sel)?.value ?? null;
|
|
60
|
+
const code = document.querySelector('#countryCallingCode');
|
|
61
|
+
const picked = name => [...document.querySelectorAll(`input[type=radio][name^="${name}"]`)]
|
|
62
|
+
.find(e => e.checked);
|
|
63
|
+
const label = e => e && document.querySelector(`label[for="${e.id}"]`)?.innerText.trim();
|
|
64
|
+
const resume = picked('document-select');
|
|
65
|
+
const letter = document.querySelector('[data-testid=coverLetter-method-change]');
|
|
66
|
+
const phone = value('#phoneNumber');
|
|
67
|
+
return {
|
|
68
|
+
firstName: value('#firstName'), lastName: value('#lastName'),
|
|
69
|
+
currentLocation: value('#currentLocation2'),
|
|
70
|
+
phone: phone && code?.value
|
|
71
|
+
? `${code.options[code.selectedIndex].text.match(/\\+\\d+/)?.[0] ?? ''}${phone}` : null,
|
|
72
|
+
resume: resume && resume.value !== 'dont-include' ? label(resume) : null,
|
|
73
|
+
coverLetter: letter?.checked ? value('[data-testid=coverLetterTextInput]') : null,
|
|
74
|
+
};
|
|
75
|
+
}"""
|
|
76
|
+
|
|
77
|
+
_QUESTIONS = """() => {
|
|
78
|
+
const out = {};
|
|
79
|
+
for (const s of document.querySelectorAll('select[name^="questionnaire."]')) {
|
|
80
|
+
out[s.name] = s.value ? s.options[s.selectedIndex].text.trim() : null;
|
|
81
|
+
}
|
|
82
|
+
for (const e of document.querySelectorAll('input[name^="questionnaire."]:checked')) {
|
|
83
|
+
const text = document.querySelector(`label[for="${e.id}"]`)?.innerText.trim() ?? e.value;
|
|
84
|
+
if (e.type === 'radio') out[e.name] = text;
|
|
85
|
+
else (out[e.name] ??= []).push(text);
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}"""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Jobstreet:
|
|
92
|
+
name = "jobstreet"
|
|
93
|
+
per_employer = True # one SEEK account, signed in on each site apart: the scope is the site
|
|
94
|
+
asks: set[str] = set()
|
|
95
|
+
known_gaps = {"profile_incomplete": Gap.FLOW}
|
|
96
|
+
side_effects = [
|
|
97
|
+
SideEffect("sign_up", "hiring_system", "login --create creates the SEEK account"),
|
|
98
|
+
SideEffect("fill", "hiring_system", "the personal details are saved to the SEEK profile"),
|
|
99
|
+
SideEffect("upload", "hiring_system", "a resume is stored as the SEEK profile's default"),
|
|
100
|
+
SideEffect("submit", "employer", "the application and the SEEK profile reach the employer"),
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
def __init__(self) -> None:
|
|
104
|
+
self._fields: dict[str, Field] = {}
|
|
105
|
+
self._read: dict[str, Answer] = {}
|
|
106
|
+
|
|
107
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
108
|
+
parts = urlsplit(url)
|
|
109
|
+
return bool(HOST.match(parts.hostname or "")) and bool(JOB.match(parts.path))
|
|
110
|
+
|
|
111
|
+
def posting(self, url: str) -> str:
|
|
112
|
+
parts = urlsplit(url)
|
|
113
|
+
match = JOB.match(parts.path)
|
|
114
|
+
return f"https://{parts.hostname}/job/{match[1]}" if match else url
|
|
115
|
+
|
|
116
|
+
def account_scope(self, url: str) -> str:
|
|
117
|
+
return urlsplit(url).hostname or ""
|
|
118
|
+
|
|
119
|
+
def login_url(self, scope: str | None) -> str:
|
|
120
|
+
return f"https://{scope}/oauth/login?returnUrl=%2F"
|
|
121
|
+
|
|
122
|
+
def signed_in(self, request: Request, scope: str | None) -> bool:
|
|
123
|
+
"""The site's `/profile` redirects an account to `/profile/me` and shows a visitor its
|
|
124
|
+
page. Cloudflare answers 403 outside the browser, so a session there reads signed out."""
|
|
125
|
+
response = request.get(f"https://{scope}/profile", max_redirects=0)
|
|
126
|
+
return response.status in (302, 307) and response.headers.get("location", "").endswith(
|
|
127
|
+
"/profile/me"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def sign_in_by_otp(self, page: Page, email: str) -> None:
|
|
131
|
+
_sign_in(page, email, create=False)
|
|
132
|
+
|
|
133
|
+
def sign_up_by_otp(self, page: Page, email: str, details: SignUpDetails) -> None:
|
|
134
|
+
_sign_in(page, email, create=True)
|
|
135
|
+
|
|
136
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
137
|
+
"""Opens Quick apply; the questions and the account's details come from the GraphQL
|
|
138
|
+
answers the page loads with. An Apply that links out has no form here."""
|
|
139
|
+
job = JOB.match(urlsplit(page.url).path)
|
|
140
|
+
if not job:
|
|
141
|
+
raise RuntimeError(f"{page.url} is not a Jobstreet job")
|
|
142
|
+
apply = page.locator(APPLY).first
|
|
143
|
+
for attempt in range(CHALLENGE_TRIES): # a check held once passed on the next load
|
|
144
|
+
try:
|
|
145
|
+
apply.wait_for(timeout=APPLY_TIMEOUT_MS)
|
|
146
|
+
break
|
|
147
|
+
except Timeout:
|
|
148
|
+
if page.title() != CHALLENGE:
|
|
149
|
+
raise RuntimeError("the posting shows no Apply button") from None
|
|
150
|
+
if attempt == CHALLENGE_TRIES - 1:
|
|
151
|
+
raise Blocked("captcha: Cloudflare's check kept the posting") from None
|
|
152
|
+
page.reload()
|
|
153
|
+
if apply.get_attribute("target") == "_blank": # Apply, not Quick apply: `isLinkOut`
|
|
154
|
+
raise Unsupported("external_step: the job applies on the employer's site")
|
|
155
|
+
host = urlsplit(page.url).hostname
|
|
156
|
+
answers: dict[str, Any] = {}
|
|
157
|
+
|
|
158
|
+
def on_response(response: Response) -> None:
|
|
159
|
+
if urlsplit(response.url).path == "/graphql":
|
|
160
|
+
answers.update(_operations(response))
|
|
161
|
+
|
|
162
|
+
page.on("response", on_response)
|
|
163
|
+
try:
|
|
164
|
+
page.goto(f"https://{host}/job/{job[1]}/apply")
|
|
165
|
+
_opened(page)
|
|
166
|
+
deadline = time.monotonic() + STEP_TIMEOUT_MS / 1000
|
|
167
|
+
needed = {"GetPersonalDetails", "GetJobApplicationProcess", "GetUser"}
|
|
168
|
+
while not needed <= answers.keys() and time.monotonic() < deadline:
|
|
169
|
+
page.wait_for_timeout(POLL_MS)
|
|
170
|
+
finally:
|
|
171
|
+
page.remove_listener("response", on_response)
|
|
172
|
+
if not needed <= answers.keys():
|
|
173
|
+
raise RuntimeError(
|
|
174
|
+
f"the apply page loaded without {', '.join(needed - answers.keys())}"
|
|
175
|
+
)
|
|
176
|
+
process = answers["GetJobApplicationProcess"]["jobApplicationProcess"]
|
|
177
|
+
if process.get("linkOut"):
|
|
178
|
+
raise Unsupported("external_step: the job applies on the employer's site")
|
|
179
|
+
viewer = answers["GetPersonalDetails"]["viewer"]
|
|
180
|
+
fields = _documents(page, viewer) + [
|
|
181
|
+
f
|
|
182
|
+
for q in (process.get("questionnaire") or {}).get("questions") or []
|
|
183
|
+
if (f := _question(q))
|
|
184
|
+
]
|
|
185
|
+
if answers["GetUser"]["viewer"].get("profileVisibility2") is None:
|
|
186
|
+
fields.append(Field("profileVisibility", "Profile visibility", FieldType.CHOICE, True,
|
|
187
|
+
VISIBILITY)) # fmt: skip
|
|
188
|
+
self._fields = {f.id: f for f in fields}
|
|
189
|
+
self._read = {}
|
|
190
|
+
return fields
|
|
191
|
+
|
|
192
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
193
|
+
"""Walks the steps in order, each answered and left with Continue, which stays put with
|
|
194
|
+
its errors while one is missing; the review step takes the profile's visibility."""
|
|
195
|
+
self._read = {k: f.prefilled for k in DETAILS if (f := self._fields[k]).prefilled}
|
|
196
|
+
self._read |= _personal_details(page, self._fields, answers)
|
|
197
|
+
_documents_fill(page, answers)
|
|
198
|
+
documents = page.evaluate(_STEP_ONE)
|
|
199
|
+
self._read |= {k: documents[k] for k in ("resume", "coverLetter") if documents[k]}
|
|
200
|
+
_continue(page)
|
|
201
|
+
if page.url.split("?")[0].endswith("/role-requirements"):
|
|
202
|
+
for field_id, answer in answers.items():
|
|
203
|
+
if field_id.startswith("question."):
|
|
204
|
+
_answer(page, self._fields[field_id], answer)
|
|
205
|
+
asked = page.evaluate(_QUESTIONS)
|
|
206
|
+
self._read |= {
|
|
207
|
+
f"question.{name.removeprefix('questionnaire.')}": value
|
|
208
|
+
for name, value in asked.items()
|
|
209
|
+
if value
|
|
210
|
+
}
|
|
211
|
+
_continue(page)
|
|
212
|
+
if page.url.split("?")[0].endswith("/profile"):
|
|
213
|
+
try:
|
|
214
|
+
_continue(page)
|
|
215
|
+
except NotTaken as e:
|
|
216
|
+
if "career history" not in str(e).lower():
|
|
217
|
+
raise
|
|
218
|
+
reason = known_gap(self, "profile_incomplete", "the profile has no career history")
|
|
219
|
+
raise Unsupported(reason) from None
|
|
220
|
+
if "profileVisibility" in answers:
|
|
221
|
+
radio = page.locator(
|
|
222
|
+
f"[data-testid=profile-visibility-field2-radio-group-"
|
|
223
|
+
f"{str(answers['profileVisibility']).lower()}]"
|
|
224
|
+
)
|
|
225
|
+
radio.check(force=True)
|
|
226
|
+
if radio.is_checked():
|
|
227
|
+
self._read["profileVisibility"] = radio.get_attribute("value") or ""
|
|
228
|
+
|
|
229
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
230
|
+
return dict(self._read)
|
|
231
|
+
|
|
232
|
+
def submit(self, page: Page) -> str | None:
|
|
233
|
+
review = page.url
|
|
234
|
+
browser.click(page.locator(SUBMIT))
|
|
235
|
+
try:
|
|
236
|
+
page.wait_for_url(lambda url: url != review, timeout=SUBMIT_TIMEOUT_MS)
|
|
237
|
+
except Timeout:
|
|
238
|
+
raise RuntimeError("Submit application left the review step in place") from None
|
|
239
|
+
return f"Submit application moved to {urlsplit(page.url).path}"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _opened(page: Page) -> None:
|
|
243
|
+
"""Waits for the wizard's first step. A session the site ended itself (its tokens refused)
|
|
244
|
+
sends the page to SEEK's sign-in instead, though `/profile` still holds it."""
|
|
245
|
+
deadline = time.monotonic() + APPLY_TIMEOUT_MS / 1000
|
|
246
|
+
while time.monotonic() < deadline:
|
|
247
|
+
if page.locator(CONTINUE).is_visible():
|
|
248
|
+
return
|
|
249
|
+
if urlsplit(page.url).hostname == SIGN_IN_HOST:
|
|
250
|
+
with suppress(Timeout): # its redirects done: a navigation meanwhile is aborted
|
|
251
|
+
page.get_by_label("Email address").wait_for(timeout=SIGN_IN_TIMEOUT_MS)
|
|
252
|
+
raise SignedOut("login_required: the site sent Quick apply to SEEK's sign-in")
|
|
253
|
+
page.wait_for_timeout(POLL_MS)
|
|
254
|
+
raise RuntimeError("Quick apply showed no first step")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _sign_in(page: Page, email: str, create: bool) -> None:
|
|
258
|
+
"""SEEK's sign-in: the email, Cloudflare's widget, then the OTP it emails, typed in six
|
|
259
|
+
digits that send themselves. An email without an account lands on "Register a new account?",
|
|
260
|
+
where only a sign-up goes on."""
|
|
261
|
+
box = page.get_by_label("Email address")
|
|
262
|
+
try:
|
|
263
|
+
box.wait_for(timeout=SIGN_IN_TIMEOUT_MS)
|
|
264
|
+
except Timeout:
|
|
265
|
+
raise Unsupported(f"unhandled_rendering: no SEEK sign-in on {page.url}") from None
|
|
266
|
+
box.fill(email)
|
|
267
|
+
code = page.get_by_label("verification input")
|
|
268
|
+
deadline = time.monotonic() + TURNSTILE_TIMEOUT_S
|
|
269
|
+
while not code.count():
|
|
270
|
+
if time.monotonic() > deadline:
|
|
271
|
+
raise Blocked("captcha: SEEK's sign-in kept asking to complete the recaptcha")
|
|
272
|
+
page.get_by_role("button", name="Email me a sign in code").click()
|
|
273
|
+
try: # clicked before the widget passed, the page says so and sends nothing
|
|
274
|
+
code.wait_for(timeout=TURNSTILE_POLL_MS)
|
|
275
|
+
except Timeout:
|
|
276
|
+
if not page.get_by_text("complete the recaptcha").count():
|
|
277
|
+
code.wait_for(timeout=OTP_STEP_TIMEOUT_MS)
|
|
278
|
+
otp.enter(code, email, "SEEK's sign-in")
|
|
279
|
+
try:
|
|
280
|
+
page.wait_for_url(
|
|
281
|
+
lambda url: (
|
|
282
|
+
"signin-confirmation" in url
|
|
283
|
+
or (urlsplit(url).hostname != SIGN_IN_HOST and "/oauth" not in urlsplit(url).path)
|
|
284
|
+
),
|
|
285
|
+
timeout=SIGNED_IN_TIMEOUT_MS,
|
|
286
|
+
)
|
|
287
|
+
except Timeout:
|
|
288
|
+
raise Blocked(f"otp_rejected: SEEK's sign-in sent an OTP to {email}") from None
|
|
289
|
+
if "signin-confirmation" not in page.url:
|
|
290
|
+
return
|
|
291
|
+
if not create:
|
|
292
|
+
raise Refused("no_account")
|
|
293
|
+
page.locator("#privacy-chkbox").check(force=True) # the marketing box stays unticked
|
|
294
|
+
page.get_by_role("button", name="Register new account").click()
|
|
295
|
+
page.wait_for_url(
|
|
296
|
+
lambda url: "/oauth" not in urlsplit(url).path and "/sign-in/" not in urlsplit(url).path,
|
|
297
|
+
timeout=SIGNED_IN_TIMEOUT_MS,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _operations(response: Response) -> dict[str, Any]:
|
|
302
|
+
"""The `data` of each operation a GraphQL response answers, by operation name."""
|
|
303
|
+
try:
|
|
304
|
+
sent, answered = response.request.post_data_json, response.json()
|
|
305
|
+
except Exception:
|
|
306
|
+
return {}
|
|
307
|
+
pairs = zip(*(x if isinstance(x, list) else [x] for x in (sent, answered)), strict=False)
|
|
308
|
+
return {
|
|
309
|
+
s["operationName"]: a["data"]
|
|
310
|
+
for s, a in pairs
|
|
311
|
+
if isinstance(s, dict) and isinstance(a, dict) and a.get("data")
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _documents(page: Page, viewer: dict[str, Any]) -> list[Field]:
|
|
316
|
+
"""The first step: the profile's personal details, then the resume and the cover letter,
|
|
317
|
+
optional where the page offers to leave them out."""
|
|
318
|
+
details = viewer["personalDetails"]
|
|
319
|
+
code = (details.get("countryCallingCode") or {}).get("countryCallingCode", "")
|
|
320
|
+
location = (viewer.get("currentLocation2") or {}).get("description")
|
|
321
|
+
resume = page.locator("input[type=file][id^=resume]")
|
|
322
|
+
accept: list[str] | None = None
|
|
323
|
+
if resume.count():
|
|
324
|
+
accept = [str(a) for a in (resume.get_attribute("accept") or "").split(",")]
|
|
325
|
+
return [
|
|
326
|
+
Field("firstName", "First name", FieldType.TEXT, True,
|
|
327
|
+
prefilled=details.get("firstName") or None),
|
|
328
|
+
Field("lastName", "Last name", FieldType.TEXT, True,
|
|
329
|
+
prefilled=details.get("lastName") or None),
|
|
330
|
+
Field("currentLocation", "Home location", FieldType.TYPEAHEAD, True, prefilled=location),
|
|
331
|
+
Field("phone", "Phone number", FieldType.PHONE, False,
|
|
332
|
+
prefilled=f"{code}{details['phoneNumber']}" if details.get("phoneNumber") else None),
|
|
333
|
+
Field("resume", "Resumé", FieldType.FILE,
|
|
334
|
+
not page.locator("input[type=radio][value=dont-include]").count(), accept=accept),
|
|
335
|
+
Field("coverLetter", "Cover letter", FieldType.TEXT,
|
|
336
|
+
not page.locator("[data-testid=coverLetter-method-none]").count()),
|
|
337
|
+
] # fmt: skip
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _question(raw: dict[str, Any]) -> Field | None:
|
|
341
|
+
field_id, label = f"question.{raw['id']}", raw["text"]
|
|
342
|
+
options = [o["text"] for o in raw.get("options") or []]
|
|
343
|
+
match raw["__typename"]:
|
|
344
|
+
case "SingleChoiceQuestion":
|
|
345
|
+
return Field(field_id, label, FieldType.CHOICE, True, options)
|
|
346
|
+
case "MultipleChoiceQuestion":
|
|
347
|
+
return Field(field_id, label, FieldType.MULTI_CHOICE, True, options)
|
|
348
|
+
case "PrivacyPolicyQuestion":
|
|
349
|
+
return Field(field_id, label, FieldType.CHOICE, True, options)
|
|
350
|
+
reason = f"unhandled_rendering: {label!r} is a {raw['__typename']}"
|
|
351
|
+
return Field(field_id, label, FieldType.TEXT, True, unfillable=reason)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _personal_details(
|
|
355
|
+
page: Page, fields: dict[str, Field], answers: dict[str, Answer]
|
|
356
|
+
) -> dict[str, Answer]:
|
|
357
|
+
"""Types the details that differ from the profile's, then saves them to it: the step goes
|
|
358
|
+
no further while the details are open, whether changed or not. Returns what was saved."""
|
|
359
|
+
changed = {
|
|
360
|
+
k: v for k in DETAILS
|
|
361
|
+
if (v := answers.get(k)) is not None and v != fields[k].prefilled
|
|
362
|
+
} # fmt: skip
|
|
363
|
+
if changed and not page.locator("#firstName").is_visible():
|
|
364
|
+
raise Unsupported("unhandled_rendering: the personal details show no inputs")
|
|
365
|
+
for key, selector in (("firstName", "#firstName"), ("lastName", "#lastName")):
|
|
366
|
+
if key in changed:
|
|
367
|
+
page.locator(selector).fill(str(changed[key]))
|
|
368
|
+
if "currentLocation" in changed:
|
|
369
|
+
_suggest(page, fields["currentLocation"], page.locator("#currentLocation2"),
|
|
370
|
+
str(changed["currentLocation"])) # fmt: skip
|
|
371
|
+
if "phone" in changed:
|
|
372
|
+
_phone(page, str(changed["phone"]))
|
|
373
|
+
save = page.locator(SAVE_DETAILS)
|
|
374
|
+
if not save.is_visible():
|
|
375
|
+
return {}
|
|
376
|
+
saved = {k: v for k, v in page.evaluate(_STEP_ONE).items() if k in DETAILS and v}
|
|
377
|
+
save.click()
|
|
378
|
+
try:
|
|
379
|
+
save.wait_for(state="hidden", timeout=SAVE_TIMEOUT_MS)
|
|
380
|
+
except Timeout:
|
|
381
|
+
said = page.locator("[id$=-message]").all_inner_texts()
|
|
382
|
+
raise NotTaken(f"the personal details were not saved: {'; '.join(said)}") from None
|
|
383
|
+
return saved
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _phone(page: Page, answer: str) -> None:
|
|
387
|
+
"""Picks the country whose calling code starts the E.164 answer, the longest one, then
|
|
388
|
+
types the rest."""
|
|
389
|
+
codes = page.locator("#countryCallingCode").evaluate(
|
|
390
|
+
"s => [...s.options].map(o => [o.value, o.text.match(/\\+\\d+/)?.[0]])"
|
|
391
|
+
)
|
|
392
|
+
picked = max(
|
|
393
|
+
((value, code) for value, code in codes if code and answer.startswith(code)),
|
|
394
|
+
key=lambda c: len(c[1]),
|
|
395
|
+
default=None,
|
|
396
|
+
)
|
|
397
|
+
if picked is None:
|
|
398
|
+
raise NotTaken(f"no country calling code starts {answer!r}")
|
|
399
|
+
page.locator("#countryCallingCode").select_option(picked[0])
|
|
400
|
+
page.locator("#phoneNumber").fill(answer.removeprefix(picked[1]))
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _documents_fill(page: Page, answers: dict[str, Answer]) -> None:
|
|
404
|
+
"""Uploads the resume, which the page then picks, or leaves it out; the same for the cover
|
|
405
|
+
letter, written in its box."""
|
|
406
|
+
if (resume := answers.get("resume")) is not None:
|
|
407
|
+
page.locator("input[type=file][id^=resume]").set_input_files(str(resume))
|
|
408
|
+
name = str(resume).rsplit("/", 1)[-1]
|
|
409
|
+
page.locator("label", has_text=name).first.wait_for(timeout=SAVE_TIMEOUT_MS)
|
|
410
|
+
elif (none := page.locator("input[type=radio][value=dont-include]")).count():
|
|
411
|
+
none.check(force=True)
|
|
412
|
+
if (letter := answers.get("coverLetter")) is not None:
|
|
413
|
+
page.locator("[data-testid=coverLetter-method-change]").check(force=True)
|
|
414
|
+
page.locator("[data-testid=coverLetterTextInput]").fill(str(letter))
|
|
415
|
+
elif (none := page.locator("[data-testid=coverLetter-method-none]")).count():
|
|
416
|
+
none.check(force=True)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _answer(page: Page, field: Field, answer: Answer) -> None:
|
|
420
|
+
name = f"questionnaire.{field.id.removeprefix('question.')}"
|
|
421
|
+
select = page.locator(f'select[name="{name}"]')
|
|
422
|
+
if select.count():
|
|
423
|
+
select.select_option(label=str(answer))
|
|
424
|
+
return
|
|
425
|
+
boxes = page.locator(f'input[name="{name}"]')
|
|
426
|
+
if not boxes.count():
|
|
427
|
+
raise Unsupported(f"unhandled_rendering: {field.id} shows no select or boxes")
|
|
428
|
+
wanted = answer if isinstance(answer, list) else [answer]
|
|
429
|
+
for box in boxes.all():
|
|
430
|
+
label = page.locator(f'label[for="{box.get_attribute("id")}"]')
|
|
431
|
+
text = label.inner_text().strip()
|
|
432
|
+
wanted_here = any(matching.matches(field, str(w), text) for w in wanted)
|
|
433
|
+
if box.get_attribute("type") == "radio" and not wanted_here:
|
|
434
|
+
continue
|
|
435
|
+
if box.is_checked() != wanted_here: # the input is styled away: its label takes the click
|
|
436
|
+
browser.click(label)
|
|
437
|
+
if box.is_checked() != wanted_here:
|
|
438
|
+
raise NotTaken(f"{field.id} did not take {text!r}")
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _continue(page: Page) -> None:
|
|
442
|
+
"""Continue, once the next step's address shows; the step's errors otherwise. The profile
|
|
443
|
+
step re-renders as its queries come back, losing a click: one that moves nothing and says
|
|
444
|
+
nothing is clicked again."""
|
|
445
|
+
step = page.url
|
|
446
|
+
page.wait_for_load_state("networkidle", timeout=STEP_TIMEOUT_MS)
|
|
447
|
+
for _ in range(CLICK_TRIES):
|
|
448
|
+
try:
|
|
449
|
+
browser.click(page.locator(CONTINUE))
|
|
450
|
+
page.wait_for_url(lambda url: url != step, timeout=STEP_MOVE_MS)
|
|
451
|
+
break
|
|
452
|
+
except browser.BrowserError as e: # a Timeout too
|
|
453
|
+
if not isinstance(e, Timeout) and "not attached" not in str(e):
|
|
454
|
+
raise
|
|
455
|
+
if page.locator(STEP_ERRORS).count():
|
|
456
|
+
break
|
|
457
|
+
page.wait_for_timeout(RERENDER_MS)
|
|
458
|
+
if page.url == step:
|
|
459
|
+
said = " ".join(page.locator("[role=alert]").all_inner_texts()).strip()
|
|
460
|
+
raise NotTaken(f"Continue stayed on {urlsplit(step).path}: {said}")
|
|
461
|
+
page.locator(f"{CONTINUE}, {SUBMIT}").first.wait_for(timeout=STEP_TIMEOUT_MS)
|
|
462
|
+
# a step renders its inputs once its queries answer, and takes no Continue before
|
|
463
|
+
page.wait_for_load_state("networkidle", timeout=STEP_TIMEOUT_MS)
|
|
464
|
+
if page.url.split("?")[0].endswith("/role-requirements"):
|
|
465
|
+
page.locator('[name^="questionnaire."]').first.wait_for(timeout=STEP_TIMEOUT_MS)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _suggest(page: Page, field: Field, box: Locator, answer: str) -> None:
|
|
469
|
+
"""Types the answer key by key, which loads the suggestions, and picks the one that
|
|
470
|
+
matches it: a typed location the page does not take."""
|
|
471
|
+
box.fill("")
|
|
472
|
+
box.press_sequentially(answer, delay=KEY_DELAY_MS)
|
|
473
|
+
options = page.locator("[role=option]:visible")
|
|
474
|
+
deadline = time.monotonic() + SUGGEST_TIMEOUT_S
|
|
475
|
+
while time.monotonic() < deadline:
|
|
476
|
+
for option in options.all():
|
|
477
|
+
if matching.matches(field, answer, option.inner_text().strip()):
|
|
478
|
+
option.click()
|
|
479
|
+
return
|
|
480
|
+
page.wait_for_timeout(POLL_MS)
|
|
481
|
+
raise NotTaken(f"no suggestion for {answer!r} in {field.id}")
|
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
Applying is a wizard of one step per page, ending on a review page whose Confirm & apply is the
|
|
4
4
|
send. Signed in, the wizard takes the name from the account and asks the CV, a cover letter and
|
|
5
5
|
the job's screening questions, which the page's own GraphQL query lists. The fill walks the
|
|
6
|
-
steps to the review page, and the read-back is what the review page lists.
|
|
6
|
+
steps to the review page, and the read-back is what the review page lists. What the wizard takes
|
|
7
|
+
from the account (name, phone, country, city, links) is the account profile's, a form of one
|
|
8
|
+
profile step that `profile` saves (ADR 0017).
|
|
7
9
|
"""
|
|
8
10
|
|
|
9
11
|
import json
|
|
10
12
|
import re
|
|
11
13
|
import time
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from dataclasses import replace
|
|
12
16
|
from typing import Any
|
|
13
17
|
from urllib.parse import urlsplit
|
|
14
18
|
|
|
@@ -17,10 +21,10 @@ from atsfill.core.adapter import (
|
|
|
17
21
|
Blocked,
|
|
18
22
|
Gap,
|
|
19
23
|
NotTaken,
|
|
24
|
+
ProfileIncomplete,
|
|
20
25
|
Refused,
|
|
21
26
|
SignUpDetails,
|
|
22
27
|
Unsupported,
|
|
23
|
-
known_gap,
|
|
24
28
|
)
|
|
25
29
|
from atsfill.core.browser import Locator, Page, Request, Response, Timeout
|
|
26
30
|
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
@@ -31,7 +35,13 @@ ME = f"https://{HOST}/api/me"
|
|
|
31
35
|
GRAPHQL = f"https://{HOST}/candidate-api/graphql"
|
|
32
36
|
LOGIN = f"https://{HOST}/auth/login/candidate?redirectUrl=%2Ftalent%2Fhome"
|
|
33
37
|
UNVERIFIED = "/auth/candidates/unverified-email"
|
|
38
|
+
PROFILE = f"https://{HOST}/talent/me?edit=profile"
|
|
39
|
+
GENERAL = "general" # the account profile's one profile step
|
|
34
40
|
STEP_TIMEOUT_MS = 20_000
|
|
41
|
+
PROFILE_TIMEOUT_MS = 20_000
|
|
42
|
+
SAVE_TIMEOUT_MS = 20_000
|
|
43
|
+
MENU_TIMEOUT_MS = 5_000
|
|
44
|
+
SUGGEST_TIMEOUT_MS = 10_000 # the city's suggestions come from a request per search
|
|
35
45
|
UPLOAD_TIMEOUT_MS = 30_000
|
|
36
46
|
ACCOUNT_TIMEOUT_MS = 20_000
|
|
37
47
|
SUBMIT_TIMEOUT_MS = 60_000
|
|
@@ -57,8 +67,27 @@ CLASS_STEPS = {
|
|
|
57
67
|
"availability": "AVAILABILITY",
|
|
58
68
|
}
|
|
59
69
|
PROFILE_STEPS = ("personalInformation", "professionalLinks") # the account profile's
|
|
70
|
+
# What a wizard's profile step can ask that the account profile lacks, by input id.
|
|
71
|
+
LACKS = {
|
|
72
|
+
"candidate.firstName": "a first name",
|
|
73
|
+
"candidate.lastName": "a last name",
|
|
74
|
+
"candidate.phoneNumber": "a phone",
|
|
75
|
+
}
|
|
60
76
|
PDF = [".pdf", "application/pdf"]
|
|
61
77
|
|
|
78
|
+
# The profile form's controls, by its field ids.
|
|
79
|
+
TEXTS = {
|
|
80
|
+
"first_name": "candidate.firstName",
|
|
81
|
+
"last_name": "candidate.lastName",
|
|
82
|
+
"linkedin": "professionalLinks.LINKEDIN",
|
|
83
|
+
}
|
|
84
|
+
COUNTRY = '[id="candidate.countryId"]'
|
|
85
|
+
CITY = "[data-testid=CityName]"
|
|
86
|
+
PHONE = '[id="candidate.phoneNumber"]'
|
|
87
|
+
DIAL = "[data-phone-input] button" # the phone's country code, a popover of countries
|
|
88
|
+
POPOVER = "[data-scope=popover][data-part=content]"
|
|
89
|
+
DIAL_CODE = re.compile(r"\(\+(\d+)\)\s*$") # an option of the popover: `France (+33)`
|
|
90
|
+
|
|
62
91
|
# The page's own GraphQL request, from the page.
|
|
63
92
|
_FETCH = """async ([url, body]) => (await fetch(url, {method: "POST", body: JSON.stringify(body),
|
|
64
93
|
headers: {"content-type": "application/json"}})).json()"""
|
|
@@ -70,14 +99,26 @@ _REVIEW = """() => [...document.querySelectorAll("a > div")].map(div => {
|
|
|
70
99
|
? [label.textContent.trim(), value.textContent.trim()] : null;
|
|
71
100
|
}).filter(Boolean)"""
|
|
72
101
|
|
|
102
|
+
# A select's value: the paragraph it shows, not an option of its open menu.
|
|
103
|
+
_SHOWN = """root => {
|
|
104
|
+
const p = [...root.querySelectorAll("p")].find(p => !p.closest("[role=option]"));
|
|
105
|
+
return p ? p.textContent.trim() : "";
|
|
106
|
+
}"""
|
|
107
|
+
|
|
108
|
+
# The ids of the inputs that are empty, of those given.
|
|
109
|
+
_EMPTY = (
|
|
110
|
+
"""ids => ids.filter(id => { const e = document.getElementById(id); return e && !e.value; })"""
|
|
111
|
+
)
|
|
112
|
+
|
|
73
113
|
|
|
74
114
|
class Join:
|
|
75
115
|
name = "join"
|
|
76
116
|
per_employer = False
|
|
77
117
|
asks = {"name"} # the account's first and last name
|
|
78
|
-
known_gaps = {
|
|
118
|
+
known_gaps: dict[str, Gap] = {}
|
|
79
119
|
side_effects = [
|
|
80
120
|
SideEffect("sign_up", "hiring_system", "login --create creates the candidate account"),
|
|
121
|
+
SideEffect("profile", "hiring_system", "profile --answers saves the account profile"),
|
|
81
122
|
SideEffect("upload", "hiring_system", "a file is stored as soon as it is chosen"),
|
|
82
123
|
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
83
124
|
]
|
|
@@ -196,9 +237,11 @@ class Join:
|
|
|
196
237
|
elif step in PROFILE_STEPS:
|
|
197
238
|
try: # shown when the job requires what the account profile lacks
|
|
198
239
|
_next(page, step)
|
|
199
|
-
except NotTaken, RuntimeError:
|
|
200
|
-
|
|
201
|
-
raise
|
|
240
|
+
except NotTaken, RuntimeError: # Continue disabled, or refused on click
|
|
241
|
+
lacks = [LACKS[i] for i in page.evaluate(_EMPTY, list(LACKS))]
|
|
242
|
+
raise ProfileIncomplete(
|
|
243
|
+
f"the job asks for {' and '.join(lacks) or f'what {step} asks'}"
|
|
244
|
+
) from None
|
|
202
245
|
continue
|
|
203
246
|
else:
|
|
204
247
|
raise Unsupported(f"unhandled_rendering: a wizard step {step!r}")
|
|
@@ -239,6 +282,83 @@ class Join:
|
|
|
239
282
|
return f"POST {parts.path}?{parts.query} {response.status}"
|
|
240
283
|
raise RuntimeError(f"POST {response.url} answered {response.status}: {body.get('errors')}")
|
|
241
284
|
|
|
285
|
+
def profile_url(self, scope: str | None) -> str:
|
|
286
|
+
return PROFILE
|
|
287
|
+
|
|
288
|
+
def profile_steps(self, page: Page) -> list[str]:
|
|
289
|
+
return [GENERAL]
|
|
290
|
+
|
|
291
|
+
def open_step(self, page: Page, step: str) -> bool:
|
|
292
|
+
if step != GENERAL:
|
|
293
|
+
raise Unsupported(f"unhandled_rendering: a profile step {step!r}")
|
|
294
|
+
if page.url != PROFILE:
|
|
295
|
+
page.goto(PROFILE)
|
|
296
|
+
page.locator(f'[id="{TEXTS["first_name"]}"]').wait_for(timeout=PROFILE_TIMEOUT_MS)
|
|
297
|
+
return True
|
|
298
|
+
|
|
299
|
+
def profile_read(self, page: Page) -> list[Field]:
|
|
300
|
+
"""The general information form: the name, the phone, the country and city of residence
|
|
301
|
+
and the LinkedIn URL. The photo, the bio, the work authorizations and the other links are
|
|
302
|
+
left as they are, as are the profile's other sections (experience, education, …)."""
|
|
303
|
+
saved = self.profile_read_back(page)
|
|
304
|
+
limit = {
|
|
305
|
+
k: int(page.locator(f'[id="{i}"]').get_attribute("maxlength") or 0) or None
|
|
306
|
+
for k, i in TEXTS.items()
|
|
307
|
+
}
|
|
308
|
+
fields = [
|
|
309
|
+
Field("first_name", "First name", FieldType.TEXT, True, max_length=limit["first_name"]),
|
|
310
|
+
Field("last_name", "Last name", FieldType.TEXT, True, max_length=limit["last_name"]),
|
|
311
|
+
Field("phone", "Phone number, with its country code", FieldType.PHONE, False),
|
|
312
|
+
Field("country", "Country of residence", FieldType.CHOICE, True, _menu(page, COUNTRY)),
|
|
313
|
+
Field("city", "City, in the country of residence", FieldType.TYPEAHEAD, False),
|
|
314
|
+
Field("linkedin", "LinkedIn", FieldType.URL, False),
|
|
315
|
+
] # fmt: skip
|
|
316
|
+
return [replace(f, prefilled=saved.get(f.id)) for f in fields]
|
|
317
|
+
|
|
318
|
+
def profile_fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
319
|
+
"""In the form's order: the city is searched in the country of residence."""
|
|
320
|
+
for field_id, input_id in TEXTS.items():
|
|
321
|
+
if isinstance(answer := answers.get(field_id), str):
|
|
322
|
+
page.locator(f'[id="{input_id}"]').fill(answer)
|
|
323
|
+
if isinstance(answer := answers.get("phone"), str):
|
|
324
|
+
_phone(page, answer)
|
|
325
|
+
if isinstance(answer := answers.get("country"), str):
|
|
326
|
+
_pick(page, COUNTRY, answer, lambda text: text == answer, MENU_TIMEOUT_MS)
|
|
327
|
+
if isinstance(answer := answers.get("city"), str):
|
|
328
|
+
_pick(page, CITY, answer, lambda text: True, SUGGEST_TIMEOUT_MS)
|
|
329
|
+
|
|
330
|
+
def profile_read_back(self, page: Page) -> dict[str, Answer]:
|
|
331
|
+
values = {k: page.locator(f'[id="{i}"]').input_value() for k, i in TEXTS.items()}
|
|
332
|
+
phone = page.locator(PHONE)
|
|
333
|
+
if phone.input_value(): # `data-value` holds the country code and the number
|
|
334
|
+
values["phone"] = phone.get_attribute("data-value") or ""
|
|
335
|
+
values["country"] = _shown(page, COUNTRY)
|
|
336
|
+
values["city"] = _shown(page, CITY)
|
|
337
|
+
return {k: v for k, v in values.items() if v}
|
|
338
|
+
|
|
339
|
+
def save(self, page: Page) -> None:
|
|
340
|
+
"""Save changes posts `UpdateTalentPersonalInformation`; the button stays disabled while
|
|
341
|
+
a required field is empty or nothing changed."""
|
|
342
|
+
button = page.locator("form button[type=submit]").last
|
|
343
|
+
try: # it is enabled a moment after the last change
|
|
344
|
+
page.wait_for_function(
|
|
345
|
+
"e => !e.disabled", arg=button.element_handle(), timeout=BUTTON_TIMEOUT_MS
|
|
346
|
+
)
|
|
347
|
+
except Timeout:
|
|
348
|
+
raise Refused(
|
|
349
|
+
f"step_refused: {GENERAL}: the form keeps its Save button disabled"
|
|
350
|
+
) from None
|
|
351
|
+
with page.expect_response(
|
|
352
|
+
lambda r: "operationName=UpdateTalentPersonalInformation" in r.url,
|
|
353
|
+
timeout=SAVE_TIMEOUT_MS,
|
|
354
|
+
) as sent:
|
|
355
|
+
button.click()
|
|
356
|
+
response = sent.value
|
|
357
|
+
if not response.ok:
|
|
358
|
+
raise RuntimeError(f"UpdateTalentPersonalInformation answered {response.status}")
|
|
359
|
+
if errors := response.json().get("errors"):
|
|
360
|
+
raise Refused(f"step_refused: {GENERAL}: {errors[0].get('message')}")
|
|
361
|
+
|
|
242
362
|
def _file(self, page: Page, field_id: str, answer: Answer | None) -> None:
|
|
243
363
|
if not isinstance(answer, str):
|
|
244
364
|
return
|
|
@@ -348,6 +468,62 @@ def _date(page: Page, answer: str) -> None:
|
|
|
348
468
|
(trigger if trigger.count() else day.first).click()
|
|
349
469
|
|
|
350
470
|
|
|
471
|
+
def _select(page: Page, container: str) -> tuple[Locator, Locator]:
|
|
472
|
+
"""A select of the profile form: its combobox, and the options its menu shows, whose ids
|
|
473
|
+
carry the combobox's."""
|
|
474
|
+
box = page.locator(container).locator("input[role=combobox]")
|
|
475
|
+
suffix = (box.get_attribute("id") or "").removeprefix("select-input-")
|
|
476
|
+
return box, page.locator(f'[id^="react-select-{suffix}-option-"]')
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _menu(page: Page, container: str) -> list[str]:
|
|
480
|
+
box, options = _select(page, container)
|
|
481
|
+
box.click()
|
|
482
|
+
options.first.wait_for(timeout=MENU_TIMEOUT_MS)
|
|
483
|
+
texts = [t.strip() for t in options.all_inner_texts()]
|
|
484
|
+
box.press("Escape")
|
|
485
|
+
return texts
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _pick(page: Page, container: str, answer: str, take: Callable[[str], bool], ms: int) -> None:
|
|
489
|
+
"""Searches the select for the answer and picks the first option `take` accepts."""
|
|
490
|
+
box, options = _select(page, container)
|
|
491
|
+
box.click()
|
|
492
|
+
box.fill(answer)
|
|
493
|
+
try:
|
|
494
|
+
options.first.wait_for(timeout=ms)
|
|
495
|
+
except Timeout:
|
|
496
|
+
raise NotTaken(f"no option for {answer!r}") from None
|
|
497
|
+
texts = [t.strip() for t in options.all_inner_texts()]
|
|
498
|
+
picked = next((i for i, text in enumerate(texts) if take(text)), None)
|
|
499
|
+
if picked is None:
|
|
500
|
+
raise NotTaken(f"no option {answer!r} in {texts}")
|
|
501
|
+
options.nth(picked).click()
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _shown(page: Page, container: str) -> str:
|
|
505
|
+
"""A select's value as it shows, empty under its placeholder."""
|
|
506
|
+
return page.locator(container).first.evaluate(_SHOWN)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _phone(page: Page, answer: str) -> None:
|
|
510
|
+
"""Picks the country whose code starts the answer, the longest of them, then types the rest:
|
|
511
|
+
the number's input takes digits only."""
|
|
512
|
+
digits = re.sub(r"\D", "", answer)
|
|
513
|
+
if not answer.strip().startswith("+"):
|
|
514
|
+
raise NotTaken(f"the phone {answer!r} has no country code, `+<code> <number>`")
|
|
515
|
+
page.locator(DIAL).click()
|
|
516
|
+
options = page.locator(POPOVER).locator("[role=option]")
|
|
517
|
+
options.first.wait_for(timeout=MENU_TIMEOUT_MS)
|
|
518
|
+
codes = [m[1] if (m := DIAL_CODE.search(t)) else "" for t in options.all_inner_texts()]
|
|
519
|
+
starting = [i for i, code in enumerate(codes) if code and digits.startswith(code)]
|
|
520
|
+
if not starting:
|
|
521
|
+
raise NotTaken(f"no country code starts the phone {answer!r}")
|
|
522
|
+
picked = max(starting, key=lambda i: len(codes[i]))
|
|
523
|
+
options.nth(picked).click()
|
|
524
|
+
page.locator(PHONE).fill(digits[len(codes[picked]) :])
|
|
525
|
+
|
|
526
|
+
|
|
351
527
|
def _email_step(page: Page, email: str) -> bool:
|
|
352
528
|
"""The account page's first step: the email. Whether an account has it, from what the page
|
|
353
529
|
asks next: a password alone for one, a name and a password for none."""
|
|
@@ -15,6 +15,14 @@ class Blocked(Exception):
|
|
|
15
15
|
"""
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
class SignedOut(Blocked):
|
|
19
|
+
"""The system's page asks to sign in although `signed_in` found the session holding: the
|
|
20
|
+
system ended it its own way. The core signs in again and reads once more (ADR 0015).
|
|
21
|
+
|
|
22
|
+
The message is `login_required: detail`.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
18
26
|
class Unsupported(Exception):
|
|
19
27
|
"""What stops the form before any fill: a flow the adapter does not handle, or a fact about
|
|
20
28
|
the posting (`posting_gone`, `external_step`). The message is `code: detail`.
|
|
@@ -283,9 +283,16 @@ def login_window(data_dir: Path, url: str, headless: bool = False) -> Generator[
|
|
|
283
283
|
|
|
284
284
|
|
|
285
285
|
def save_session(page: Page, path: Path) -> None:
|
|
286
|
+
"""Stores the context's cookies and each origin's storage; the cookies alone where Firefox
|
|
287
|
+
aborts gathering an origin's storage (a sign-in that framed a Cloudflare challenge)."""
|
|
286
288
|
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
287
289
|
path.touch(mode=0o600)
|
|
288
|
-
|
|
290
|
+
try:
|
|
291
|
+
page.context.storage_state(path=path)
|
|
292
|
+
except BrowserError as e:
|
|
293
|
+
if "NS_ERROR_ABORT" not in str(e):
|
|
294
|
+
raise
|
|
295
|
+
path.write_text(json.dumps({"cookies": page.context.cookies(), "origins": []}))
|
|
289
296
|
|
|
290
297
|
|
|
291
298
|
@contextmanager
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import shlex
|
|
2
2
|
from collections.abc import Callable, Generator
|
|
3
3
|
from contextlib import contextmanager, suppress
|
|
4
|
+
from contextvars import ContextVar
|
|
4
5
|
from dataclasses import dataclass
|
|
5
6
|
from importlib.metadata import version
|
|
6
7
|
from pathlib import Path
|
|
@@ -31,6 +32,7 @@ from atsfill.core.adapter import (
|
|
|
31
32
|
ProfileIncomplete,
|
|
32
33
|
ReadsByRequest,
|
|
33
34
|
Refused,
|
|
35
|
+
SignedOut,
|
|
34
36
|
SignsIn,
|
|
35
37
|
SignsInByOtp,
|
|
36
38
|
SignUpDetails,
|
|
@@ -182,12 +184,40 @@ def _opened(
|
|
|
182
184
|
return
|
|
183
185
|
scope = adapter.account_scope(url) if isinstance(adapter, NeedsAccount) else None
|
|
184
186
|
with otp.source(user, adapter.name, scope):
|
|
185
|
-
if isinstance(adapter, NeedsAccount)
|
|
186
|
-
session and adapter.signed_in(page.request, scope)
|
|
187
|
-
):
|
|
188
|
-
yield _sign_in_again(page, adapter, user, mine, url, credentials) or (page, adapter)
|
|
189
|
-
else:
|
|
187
|
+
if not isinstance(adapter, NeedsAccount):
|
|
190
188
|
yield page, adapter
|
|
189
|
+
return
|
|
190
|
+
token = _again.set(lambda: _sign_in_again(page, adapter, user, mine, url, None))
|
|
191
|
+
try:
|
|
192
|
+
if not (session and adapter.signed_in(page.request, scope)):
|
|
193
|
+
yield _sign_in_again(page, adapter, user, mine, url, credentials) or (
|
|
194
|
+
page,
|
|
195
|
+
adapter,
|
|
196
|
+
)
|
|
197
|
+
else:
|
|
198
|
+
yield page, adapter
|
|
199
|
+
finally:
|
|
200
|
+
_again.reset(token)
|
|
201
|
+
if session: # a system that rotates the session's tokens as the page uses them
|
|
202
|
+
with suppress(Exception):
|
|
203
|
+
browser.save_session(page, session)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
_again: ContextVar[Callable[[], Result | None] | None] = ContextVar("again", default=None)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _read_signed_in(page: Page, adapter: Adapter) -> list[Field]:
|
|
210
|
+
"""`browser_read`, once more after signing in again where the page asked to sign in although
|
|
211
|
+
the session held: the result that ends the command instead is raised as `Blocked`."""
|
|
212
|
+
try:
|
|
213
|
+
return adapter.browser_read(page)
|
|
214
|
+
except SignedOut:
|
|
215
|
+
again = _again.get()
|
|
216
|
+
if again is None:
|
|
217
|
+
raise
|
|
218
|
+
if ended := again():
|
|
219
|
+
raise Blocked(ended.reason) from None
|
|
220
|
+
return adapter.browser_read(page)
|
|
191
221
|
|
|
192
222
|
|
|
193
223
|
def _sign_in_again(
|
|
@@ -338,7 +368,7 @@ def _fill(page: Page, adapter: Adapter, url: str, answers: dict[str, Answer], us
|
|
|
338
368
|
"""Fills and reads back; `ready` when every answer reads back as given."""
|
|
339
369
|
result = _result(adapter, url, user)
|
|
340
370
|
with _stops(result):
|
|
341
|
-
_listed(result,
|
|
371
|
+
_listed(result, _read_signed_in(page, adapter))
|
|
342
372
|
if _unfillable(result, answers):
|
|
343
373
|
return result
|
|
344
374
|
if problems := matching.problems(result.fields, answers):
|
|
@@ -363,7 +393,7 @@ def _browser_read(data: Path, user: str, url: str, sessions: Sessions | None, se
|
|
|
363
393
|
def run() -> Result:
|
|
364
394
|
result = _result(adapter, url, user)
|
|
365
395
|
with _stops(result):
|
|
366
|
-
_listed(result,
|
|
396
|
+
_listed(result, _read_signed_in(page, adapter))
|
|
367
397
|
_unfillable(result)
|
|
368
398
|
_profile_incomplete(result, adapter, user, url)
|
|
369
399
|
return result
|
|
@@ -160,7 +160,8 @@ def backlog(registry: dict[str, Source] | None = None) -> str:
|
|
|
160
160
|
text = [
|
|
161
161
|
"# Hiring systems",
|
|
162
162
|
"",
|
|
163
|
-
"Generated from [src/atsfill/registry.toml]
|
|
163
|
+
"Generated from [src/atsfill/registry.toml]"
|
|
164
|
+
"(https://github.com/shbernal/atsfill/blob/main/src/atsfill/registry.toml) by"
|
|
164
165
|
" `uv run python -m scripts.backlog`. The registry is described in"
|
|
165
166
|
" [ADR 0012](../adr/0012-systems-registry.md), the order in"
|
|
166
167
|
" [ADR 0013](../adr/0013-survey-and-adapter-rounds.md): no account first, blocked and"
|
|
@@ -321,10 +321,11 @@ scope = { is = "supported", date = 2026-10-26, commit = "867de7d" }
|
|
|
321
321
|
[jobstreet]
|
|
322
322
|
kind = "board"
|
|
323
323
|
share = 0.0047
|
|
324
|
-
account = "to_read" # Apply needs a SEEK account
|
|
324
|
+
account = "to_read" # Apply needs a SEEK account without a password, signed in by an emailed OTP on each site
|
|
325
325
|
hosting = ["hosted"]
|
|
326
326
|
hosts = ["jobstreet.com", "jobsdb.com"]
|
|
327
|
-
|
|
327
|
+
adapter = "jobstreet"
|
|
328
|
+
scope = { is = "supported", date = 2026-10-29, commit = "49a997e" }
|
|
328
329
|
|
|
329
330
|
[whatjobs-uk]
|
|
330
331
|
kind = "aggregator"
|
|
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
|
|
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
|
|
File without changes
|