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.
Files changed (63) hide show
  1. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/PKG-INFO +2 -1
  2. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/README.md +1 -0
  3. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/pyproject.toml +1 -1
  4. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/__init__.py +2 -0
  6. atsfill-0.0.5.dev160/src/atsfill/adapters/jobstreet.py +481 -0
  7. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/join.py +182 -6
  8. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/core/adapter.py +8 -0
  9. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/core/browser.py +8 -1
  10. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/core/commands.py +37 -7
  11. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/core/registry.py +2 -1
  12. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/registry.toml +3 -2
  13. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/LICENSE +0 -0
  14. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/__init__.py +0 -0
  15. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/ashby.py +0 -0
  16. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/bamboohr.py +0 -0
  17. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/breezy.py +0 -0
  18. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/dayforce.py +0 -0
  19. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/eightfold.py +0 -0
  20. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/greenhouse.py +0 -0
  21. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/gupy.py +0 -0
  22. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/hireology.py +0 -0
  23. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/hiringthing.py +0 -0
  24. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/hrmdirect.py +0 -0
  25. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/isolvedhire.py +0 -0
  26. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/jazzhr.py +0 -0
  27. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/jobaffinity.py +0 -0
  28. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/lever.py +0 -0
  29. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/manatal.py +0 -0
  30. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/oracle.py +0 -0
  31. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/paycor.py +0 -0
  32. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/paylocity.py +0 -0
  33. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/personio.py +0 -0
  34. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/phenom.py +0 -0
  35. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/pinpoint.py +0 -0
  36. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/recruitee.py +0 -0
  37. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/recruiterflow.py +0 -0
  38. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/rippling.py +0 -0
  39. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/smartrecruiters.py +0 -0
  40. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/successfactors.js +0 -0
  41. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/successfactors.py +0 -0
  42. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/taleo.py +0 -0
  43. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/teamtailor.py +0 -0
  44. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/ukg.py +0 -0
  45. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/workable.py +0 -0
  46. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/workatastartup.py +0 -0
  47. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/workday.py +0 -0
  48. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/wttj.py +0 -0
  49. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/adapters/zohorecruit.py +0 -0
  50. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/cli.py +0 -0
  51. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/core/__init__.py +0 -0
  52. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/core/accounts.py +0 -0
  53. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/core/attempts.py +0 -0
  54. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/core/conditions.py +0 -0
  55. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/core/html.py +0 -0
  56. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/core/matching.py +0 -0
  57. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/core/otp.py +0 -0
  58. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/core/passwords.py +0 -0
  59. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/core/paths.py +0 -0
  60. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/core/results.py +0 -0
  61. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/core/snapshot.py +0 -0
  62. {atsfill-0.0.5.dev154 → atsfill-0.0.5.dev160}/src/atsfill/core/telemetry.py +0 -0
  63. {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.dev154
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
  [![workday run history](https://shbernal.github.io/atsfill/run-history/workday.svg)](https://shbernal.github.io/atsfill/run-history/)
90
90
  [![successfactors run history](https://shbernal.github.io/atsfill/run-history/successfactors.svg)](https://shbernal.github.io/atsfill/run-history/)
91
91
  [![isolvedhire run history](https://shbernal.github.io/atsfill/run-history/isolvedhire.svg)](https://shbernal.github.io/atsfill/run-history/)
92
+ [![jobstreet run history](https://shbernal.github.io/atsfill/run-history/jobstreet.svg)](https://shbernal.github.io/atsfill/run-history/)
92
93
  [![gupy run history](https://shbernal.github.io/atsfill/run-history/gupy.svg)](https://shbernal.github.io/atsfill/run-history/)
93
94
  [![ukg run history](https://shbernal.github.io/atsfill/run-history/ukg.svg)](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
  [![workday run history](https://shbernal.github.io/atsfill/run-history/workday.svg)](https://shbernal.github.io/atsfill/run-history/)
61
61
  [![successfactors run history](https://shbernal.github.io/atsfill/run-history/successfactors.svg)](https://shbernal.github.io/atsfill/run-history/)
62
62
  [![isolvedhire run history](https://shbernal.github.io/atsfill/run-history/isolvedhire.svg)](https://shbernal.github.io/atsfill/run-history/)
63
+ [![jobstreet run history](https://shbernal.github.io/atsfill/run-history/jobstreet.svg)](https://shbernal.github.io/atsfill/run-history/)
63
64
  [![gupy run history](https://shbernal.github.io/atsfill/run-history/gupy.svg)](https://shbernal.github.io/atsfill/run-history/)
64
65
  [![ukg run history](https://shbernal.github.io/atsfill/run-history/ukg.svg)](https://shbernal.github.io/atsfill/run-history/)
65
66
  <!-- /run-history -->
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.5.dev154"
3
+ version = "0.0.5.dev160"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.5.dev154"
3
+ version = "0.0.5.dev160"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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 = {"profile_incomplete": Gap.FLOW}
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
- detail = f"the {step} step asks what the account profile lacks"
201
- raise Unsupported(known_gap(self, "profile_incomplete", detail)) from None
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
- page.context.storage_state(path=path)
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) and not (
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, adapter.browser_read(page))
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, adapter.browser_read(page))
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](../../src/atsfill/registry.toml) by"
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; its sign-in and sign-up have no password, an emailed OTP alone each time
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
- scope = { is = "in_scope", date = 2026-10-26, reason = "unsupported_flow: sign_in_otp_only: Apply goes to the SEEK sign-in, with no password, every sign-in and sign-up a six-digit OTP emailed to the address, alone (14 of 15 employers)" }
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