atsfill 0.0.5.dev153__tar.gz → 0.0.5.dev158__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.dev153 → atsfill-0.0.5.dev158}/PKG-INFO +2 -1
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/README.md +1 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/pyproject.toml +1 -1
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/pyproject.toml.orig +1 -1
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/__init__.py +2 -0
- atsfill-0.0.5.dev158/src/atsfill/adapters/jobstreet.py +481 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/core/adapter.py +8 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/core/browser.py +8 -1
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/core/commands.py +37 -7
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/core/registry.py +2 -1
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/registry.toml +3 -2
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/LICENSE +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/gupy.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/hrmdirect.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/isolvedhire.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/jobaffinity.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/join.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/recruiterflow.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/successfactors.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/ukg.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/workatastartup.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/core/otp.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/core/snapshot.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/src/atsfill/core/telemetry.py +0 -0
- {atsfill-0.0.5.dev153 → atsfill-0.0.5.dev158}/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.dev158
|
|
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}")
|
|
@@ -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
|
|
File without changes
|