atsfill 0.0.4.dev113__tar.gz → 0.0.4.dev115__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/PKG-INFO +2 -2
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/README.md +1 -1
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/adapters/__init__.py +2 -0
- atsfill-0.0.4.dev115/src/atsfill/adapters/successfactors.js +72 -0
- atsfill-0.0.4.dev115/src/atsfill/adapters/successfactors.py +386 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/core/passwords.py +1 -1
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/registry.toml +4 -1
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/LICENSE +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/adapters/ukg.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev113 → atsfill-0.0.4.dev115}/src/atsfill/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atsfill
|
|
3
|
-
Version: 0.0.4.
|
|
3
|
+
Version: 0.0.4.dev115
|
|
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
|
|
@@ -57,7 +57,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
57
57
|
## Supported hiring systems
|
|
58
58
|
|
|
59
59
|
<!-- badges -->
|
|
60
|
-
                         
|
|
60
|
+
                          
|
|
61
61
|
<!-- /badges -->
|
|
62
62
|
|
|
63
63
|
| System | Account needed | `read` without a browser |
|
|
@@ -29,7 +29,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
29
29
|
## Supported hiring systems
|
|
30
30
|
|
|
31
31
|
<!-- badges -->
|
|
32
|
-
                         
|
|
32
|
+
                          
|
|
33
33
|
<!-- /badges -->
|
|
34
34
|
|
|
35
35
|
| System | Account needed | `read` without a browser |
|
|
@@ -18,6 +18,7 @@ from atsfill.adapters.pinpoint import Pinpoint
|
|
|
18
18
|
from atsfill.adapters.recruitee import Recruitee
|
|
19
19
|
from atsfill.adapters.rippling import Rippling
|
|
20
20
|
from atsfill.adapters.smartrecruiters import SmartRecruiters
|
|
21
|
+
from atsfill.adapters.successfactors import SuccessFactors
|
|
21
22
|
from atsfill.adapters.teamtailor import Teamtailor
|
|
22
23
|
from atsfill.adapters.ukg import UKG
|
|
23
24
|
from atsfill.adapters.workable import Workable
|
|
@@ -55,6 +56,7 @@ def adapters() -> list[Adapter]:
|
|
|
55
56
|
Eightfold(),
|
|
56
57
|
Workday(),
|
|
57
58
|
UKG(),
|
|
59
|
+
SuccessFactors(), # it claims a career site's job path on any host
|
|
58
60
|
Phenom(), # last: it claims a URL by its path alone
|
|
59
61
|
]
|
|
60
62
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The application page's fields, one descriptor each, keyed as the adapter names them.
|
|
2
|
+
() => {
|
|
3
|
+
const slug = s => s.toLowerCase().normalize('NFKD').replace(/[^\w]+/g, '-').replace(/^-|-$/g, '').slice(0, 60);
|
|
4
|
+
const seen = {};
|
|
5
|
+
const out = [];
|
|
6
|
+
for (const box of document.querySelectorAll('.RCMFormField')) {
|
|
7
|
+
if (!box.offsetParent || box.closest('[id$="_bgElementContainer"]')) continue;
|
|
8
|
+
const labelEl = box.querySelector('label.rcmFormFieldLabel, .rcmFormFieldLabel');
|
|
9
|
+
const label = (labelEl ? labelEl.innerText : '').replace(/\*/g, '').replace(/\s+/g, ' ').trim();
|
|
10
|
+
const required = !!box.querySelector('.requiredField');
|
|
11
|
+
const d = { label, required, kind: 'unknown', value: '', options: null, id: null, owns: null, name: null };
|
|
12
|
+
const picklist = box.querySelector('input.rcmpaginatedselectinput');
|
|
13
|
+
const text = box.querySelector('input[data-testid="sfTextField"], input.fd-input[type="text"]');
|
|
14
|
+
const area = box.querySelector('textarea');
|
|
15
|
+
const select = box.querySelector('select');
|
|
16
|
+
const radios = [...box.querySelectorAll('input[type="radio"]')];
|
|
17
|
+
const check = box.querySelector('input[type="checkbox"]');
|
|
18
|
+
if (box.classList.contains('attachmentField')) {
|
|
19
|
+
d.kind = 'file';
|
|
20
|
+
d.files = [...box.querySelectorAll('[id$="_attachDownloadLabelLink"]')].filter(e => e.offsetParent).map(e => e.innerText.trim());
|
|
21
|
+
d.value = d.files.length ? d.files[d.files.length - 1] : '';
|
|
22
|
+
const icon = [...box.querySelectorAll('[id$="_attachIcon"]')].find(e => e.offsetParent);
|
|
23
|
+
d.multiple = !!icon && icon.classList.contains('removeAttachments'); // it holds a file
|
|
24
|
+
d.id = icon ? icon.id : null;
|
|
25
|
+
} else if (picklist) {
|
|
26
|
+
d.kind = 'picklist';
|
|
27
|
+
d.id = picklist.id;
|
|
28
|
+
d.owns = picklist.getAttribute('aria-owns');
|
|
29
|
+
d.value = picklist.value === picklist.placeholder ? '' : picklist.value;
|
|
30
|
+
d.required = d.required || picklist.getAttribute('aria-required') === 'true';
|
|
31
|
+
} else if (radios.length) {
|
|
32
|
+
d.kind = 'radio';
|
|
33
|
+
d.options = radios.map(r => {
|
|
34
|
+
const l = r.labels && r.labels[0] ? r.labels[0].innerText : (r.closest('label') || r.parentElement).innerText;
|
|
35
|
+
return l.trim();
|
|
36
|
+
});
|
|
37
|
+
d.ids = radios.map(r => r.id);
|
|
38
|
+
const on = radios.findIndex(r => r.checked);
|
|
39
|
+
d.value = on >= 0 ? d.options[on] : '';
|
|
40
|
+
} else if (select) {
|
|
41
|
+
d.kind = 'select';
|
|
42
|
+
d.id = select.id;
|
|
43
|
+
d.options = [...select.options].filter(o => o.value).map(o => o.text.trim());
|
|
44
|
+
d.value = select.value ? select.options[select.selectedIndex].text.trim() : '';
|
|
45
|
+
} else if (area) {
|
|
46
|
+
d.kind = 'text';
|
|
47
|
+
d.id = area.id;
|
|
48
|
+
d.name = area.name;
|
|
49
|
+
d.value = area.value;
|
|
50
|
+
d.max = area.maxLength > 0 ? area.maxLength : null;
|
|
51
|
+
} else if (text) {
|
|
52
|
+
d.kind = text.closest('[class*="DatePicker"], [class*="datePicker"], .sfDateInput') ? 'date' : 'text';
|
|
53
|
+
d.id = text.id;
|
|
54
|
+
d.name = text.getAttribute('name');
|
|
55
|
+
d.value = text.value;
|
|
56
|
+
d.max = text.maxLength > 0 ? text.maxLength : null;
|
|
57
|
+
d.required = d.required || text.getAttribute('aria-required') === 'true';
|
|
58
|
+
} else if (check) {
|
|
59
|
+
d.kind = 'checkbox';
|
|
60
|
+
d.id = check.id;
|
|
61
|
+
d.value = check.checked ? 'Yes' : 'No';
|
|
62
|
+
} else if (!box.querySelector('input, select, textarea')) {
|
|
63
|
+
continue; // a label alone: a heading or an instruction
|
|
64
|
+
}
|
|
65
|
+
let key = d.name || slug(label) || d.kind;
|
|
66
|
+
seen[key] = (seen[key] || 0) + 1;
|
|
67
|
+
if (seen[key] > 1) key += '-' + seen[key];
|
|
68
|
+
d.key = key;
|
|
69
|
+
out.push(d);
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""SAP SuccessFactors Recruiting (`<career site>[/<subsite>]/job/<slug>/<id>/`), as
|
|
2
|
+
docs/adapters/successfactors.md observes.
|
|
3
|
+
|
|
4
|
+
A posting is on the employer's career site; its apply link leads to the candidate pages on a
|
|
5
|
+
SuccessFactors data center host, where applying needs an account, one per employer: the
|
|
6
|
+
account scope is the career site's host. The application is one page of sections; the
|
|
7
|
+
fields are read from it, each by its input's name, else its label.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import urlsplit
|
|
15
|
+
|
|
16
|
+
from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, Unsupported, known_gap
|
|
17
|
+
from atsfill.core.browser import Locator, Page, Request, Timeout
|
|
18
|
+
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
19
|
+
|
|
20
|
+
JOB = re.compile(r"^(?:/[^/]+)?/job/[^/]+/(?P<id>\d{5,})/?$")
|
|
21
|
+
DC_HOST = re.compile(r"^career\d*\.(?:sapsf|successfactors)\.(?:eu|com|cn)$")
|
|
22
|
+
APPLY = "a[href*='/talentcommunity/apply/']"
|
|
23
|
+
COMPANY_COOKIE = "careerSiteCompanyId"
|
|
24
|
+
PROFILE = "/career?company={company}&career_ns=job_listing_summary&navBarLevel=MY_PROFILE"
|
|
25
|
+
SIGNED_IN = re.compile(r"\bsignOut|\blogout\b|_logout", re.IGNORECASE)
|
|
26
|
+
FIELDS = (Path(__file__).with_suffix(".js")).read_text()
|
|
27
|
+
# What the candidate pages show once loaded
|
|
28
|
+
SIGN_IN = "#username"
|
|
29
|
+
SIGN_UP = "#fbclc_userName"
|
|
30
|
+
QUICK_APPLY = "#fbqa_apply"
|
|
31
|
+
FORM = ".RCMFormField"
|
|
32
|
+
LANDED = f"{SIGN_IN}, {SIGN_UP}, {FORM}"
|
|
33
|
+
REGISTER = "a[href*='login_ns=register']"
|
|
34
|
+
SIGN_IN_LINK = "a[href*='login_ns=login'], a[href*='career_ns=login']"
|
|
35
|
+
SIGN_IN_MODAL = "a[onclick*=openSignInModal]" # a Quick Apply page's
|
|
36
|
+
# An error a sign-up shows; the password rules beside the field show whatever is typed
|
|
37
|
+
REFUSAL = re.compile(r"required|exist|already|invalid|bereits|erforderlich|ungültig", re.I)
|
|
38
|
+
ACTIVATE = re.compile(r"activat|verify your|verification e-?mail|bestätig", re.I)
|
|
39
|
+
PRIVACY = "#dataPrivacyId"
|
|
40
|
+
EXPAND = "[id$=_expandAllSections]"
|
|
41
|
+
SUBMIT = "[id$=_submitBtn]"
|
|
42
|
+
OVERLAY = "#sfOverlayMgr" # a confirmation dialog
|
|
43
|
+
ERROR = "[role=alert]:visible" # a validation message area, shown
|
|
44
|
+
LANDING_TIMEOUT_MS = 45_000
|
|
45
|
+
SIGN_IN_TIMEOUT_MS = 30_000
|
|
46
|
+
LIST_TIMEOUT_MS = 5_000
|
|
47
|
+
OPEN_TRIES = 3
|
|
48
|
+
UPLOAD_TIMEOUT_S = 30
|
|
49
|
+
POLL_MS = 500
|
|
50
|
+
SETTLE_MS = 1_000
|
|
51
|
+
SUBMIT_TIMEOUT_MS = 60_000
|
|
52
|
+
NO_SELECTION = "No Selection"
|
|
53
|
+
YES_NO = ["Yes", "No"]
|
|
54
|
+
TYPES = {
|
|
55
|
+
"text": FieldType.TEXT,
|
|
56
|
+
"date": FieldType.DATE,
|
|
57
|
+
"picklist": FieldType.CHOICE,
|
|
58
|
+
"select": FieldType.CHOICE,
|
|
59
|
+
"radio": FieldType.CHOICE,
|
|
60
|
+
"checkbox": FieldType.CHOICE,
|
|
61
|
+
"file": FieldType.FILE,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _id(element_id: str) -> str:
|
|
66
|
+
"""A CSS selector for a juic id, which holds a colon."""
|
|
67
|
+
return f'[id="{element_id}"]'
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _company(request: Request) -> list[tuple[str, str]]:
|
|
71
|
+
"""The data center hosts and company ids a session's cookies are for."""
|
|
72
|
+
return sorted(
|
|
73
|
+
{
|
|
74
|
+
(c["domain"].lstrip("."), c["value"])
|
|
75
|
+
for c in request.storage_state()["cookies"]
|
|
76
|
+
if c["name"] == COMPANY_COOKIE and DC_HOST.match(c["domain"].lstrip("."))
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class SuccessFactors:
|
|
82
|
+
name = "successfactors"
|
|
83
|
+
per_employer = True
|
|
84
|
+
asks_name = True
|
|
85
|
+
known_gaps = {"quick_apply_sign_up": Gap.FLOW, "sign_up_fields": Gap.FLOW}
|
|
86
|
+
side_effects = [
|
|
87
|
+
SideEffect("sign_up", "employer", "the account is created in the employer's tenant"),
|
|
88
|
+
SideEffect("fill", "hiring_system", "an attachment is uploaded to the account's profile"),
|
|
89
|
+
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
def __init__(self) -> None:
|
|
93
|
+
self._postings: dict[str, str] = {} # by career site host, the last posting's URL
|
|
94
|
+
self._fields: dict[str, dict[str, Any]] = {}
|
|
95
|
+
self._uploads: dict[str, str] = {} # by field, the name of the file uploaded to it
|
|
96
|
+
|
|
97
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
98
|
+
if not JOB.match(urlsplit(url).path):
|
|
99
|
+
return False
|
|
100
|
+
return page is None or page.locator(APPLY).count() > 0
|
|
101
|
+
|
|
102
|
+
def posting(self, url: str) -> str:
|
|
103
|
+
parts = urlsplit(url)
|
|
104
|
+
job = JOB.match(parts.path)
|
|
105
|
+
return f"https://{parts.hostname}/job/{job['id']}" if job else url
|
|
106
|
+
|
|
107
|
+
def account_scope(self, url: str) -> str | None:
|
|
108
|
+
host = (urlsplit(url).hostname or "").lower()
|
|
109
|
+
if JOB.match(urlsplit(url).path):
|
|
110
|
+
self._postings[host] = url
|
|
111
|
+
return host
|
|
112
|
+
|
|
113
|
+
def login_url(self, scope: str | None) -> str:
|
|
114
|
+
return self._postings.get(scope or "", f"https://{scope}/")
|
|
115
|
+
|
|
116
|
+
def signed_in(self, request: Request, scope: str | None) -> bool:
|
|
117
|
+
"""The profile page of the session's company shows a sign-out for an account."""
|
|
118
|
+
for host, company in _company(request):
|
|
119
|
+
response = request.get(f"https://{host}{PROFILE.format(company=company)}")
|
|
120
|
+
if response.ok and SIGNED_IN.search(response.text()):
|
|
121
|
+
return True
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
def sign_in(self, page: Page, email: str, password: str) -> None:
|
|
125
|
+
self._candidate(page)
|
|
126
|
+
if not page.locator(SIGN_IN).count():
|
|
127
|
+
page.locator(SIGN_IN_LINK).first.click()
|
|
128
|
+
page.locator(SIGN_IN).wait_for(timeout=LANDING_TIMEOUT_MS)
|
|
129
|
+
page.fill(SIGN_IN, email)
|
|
130
|
+
page.fill("#password", password)
|
|
131
|
+
page.locator("button[onclick*=validateFields]").click()
|
|
132
|
+
try:
|
|
133
|
+
page.locator(FORM).first.wait_for(state="attached", timeout=SIGN_IN_TIMEOUT_MS)
|
|
134
|
+
except Timeout:
|
|
135
|
+
message = self._message(page)
|
|
136
|
+
if ACTIVATE.search(message):
|
|
137
|
+
raise Blocked(f"email_link: {message}") from None
|
|
138
|
+
raise Refused(f"wrong_password: {message}") from None
|
|
139
|
+
|
|
140
|
+
def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
|
|
141
|
+
self._candidate(page)
|
|
142
|
+
if page.locator(SIGN_IN).count():
|
|
143
|
+
page.locator(REGISTER).first.click()
|
|
144
|
+
elif page.locator(QUICK_APPLY).count(): # its sign-up sends the application too
|
|
145
|
+
page.locator(SIGN_IN_MODAL).first.click()
|
|
146
|
+
register = page.locator(f"{REGISTER}:visible")
|
|
147
|
+
try:
|
|
148
|
+
register.first.wait_for(timeout=LIST_TIMEOUT_MS)
|
|
149
|
+
except Timeout:
|
|
150
|
+
raise Unsupported(
|
|
151
|
+
known_gap(self, "quick_apply_sign_up", "its sign-in offers no sign-up")
|
|
152
|
+
) from None
|
|
153
|
+
register.first.click()
|
|
154
|
+
page.locator(SIGN_UP).wait_for(timeout=LANDING_TIMEOUT_MS)
|
|
155
|
+
first, _, last = (name or "").strip().partition(" ")
|
|
156
|
+
for selector, value in [
|
|
157
|
+
(SIGN_UP, email),
|
|
158
|
+
("#fbclc_emailConf", email),
|
|
159
|
+
("#fbclc_pwd", password),
|
|
160
|
+
("#fbclc_pwdConf", password),
|
|
161
|
+
("#fbclc_fName", first),
|
|
162
|
+
("#fbclc_lName", last or first),
|
|
163
|
+
]:
|
|
164
|
+
page.fill(selector, value)
|
|
165
|
+
for select in page.locator("select:visible").all(): # the country, a phone code
|
|
166
|
+
if not select.input_value():
|
|
167
|
+
values = select.evaluate("s => [...s.options].map(o => o.value).filter(v => v)")
|
|
168
|
+
select.select_option(values[0])
|
|
169
|
+
if page.locator(PRIVACY).count():
|
|
170
|
+
page.locator(PRIVACY).click()
|
|
171
|
+
dialog = page.locator("[role=dialog]:visible").last
|
|
172
|
+
dialog.locator("[id^=dlgButton]").first.click()
|
|
173
|
+
page.locator("#fbclc_createAccountButton").click()
|
|
174
|
+
deadline = time.monotonic() + SIGN_IN_TIMEOUT_MS / 1000
|
|
175
|
+
while time.monotonic() < deadline:
|
|
176
|
+
page.wait_for_timeout(POLL_MS)
|
|
177
|
+
if page.locator(FORM).count():
|
|
178
|
+
return
|
|
179
|
+
if not page.locator(f"{SIGN_UP}:visible").count():
|
|
180
|
+
break
|
|
181
|
+
if self._message(page, REFUSAL):
|
|
182
|
+
break
|
|
183
|
+
message = self._message(page, REFUSAL)
|
|
184
|
+
if re.search(r"exist|already|bereits|registered", message, re.IGNORECASE):
|
|
185
|
+
raise Refused(f"email_in_use: {message}")
|
|
186
|
+
if re.search(r"password|kennwort", message, re.IGNORECASE):
|
|
187
|
+
raise Refused(f"password_rule: {message}")
|
|
188
|
+
if message:
|
|
189
|
+
raise Unsupported(known_gap(self, "sign_up_fields", message))
|
|
190
|
+
text = page.locator("body").inner_text()
|
|
191
|
+
if ACTIVATE.search(text):
|
|
192
|
+
raise Blocked("email_link: the sign-up sent an activation email")
|
|
193
|
+
if page.locator(f"{SIGN_IN}:visible").count(): # created, signed out
|
|
194
|
+
self.sign_in(page, email, password)
|
|
195
|
+
return
|
|
196
|
+
raise Unsupported(f"unhandled_rendering: the sign-up led to {urlsplit(page.url).path}")
|
|
197
|
+
|
|
198
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
199
|
+
self._candidate(page)
|
|
200
|
+
if not page.locator(FORM).count():
|
|
201
|
+
if page.locator(f"{SIGN_IN}, {SIGN_UP}").count():
|
|
202
|
+
raise Blocked("login_wall: the application asks to sign in")
|
|
203
|
+
raise Unsupported(f"unhandled_rendering: no application form: {self._message(page)}")
|
|
204
|
+
expand = page.locator(EXPAND)
|
|
205
|
+
if expand.count() and expand.first.is_visible():
|
|
206
|
+
expand.first.click()
|
|
207
|
+
page.wait_for_timeout(SETTLE_MS)
|
|
208
|
+
fields = []
|
|
209
|
+
for d in page.evaluate(FIELDS):
|
|
210
|
+
if d["kind"] == "picklist":
|
|
211
|
+
d["options"] = self._options(page, d)
|
|
212
|
+
self._fields[d["key"]] = d
|
|
213
|
+
fields.append(self._field(d))
|
|
214
|
+
return fields
|
|
215
|
+
|
|
216
|
+
def _field(self, d: dict[str, Any]) -> Field:
|
|
217
|
+
kind = d["kind"]
|
|
218
|
+
options = d["options"]
|
|
219
|
+
if kind == "checkbox":
|
|
220
|
+
options = YES_NO
|
|
221
|
+
if kind == "picklist" and not options: # a cascading list, filled once its parent is
|
|
222
|
+
return Field(
|
|
223
|
+
d["key"],
|
|
224
|
+
d["label"],
|
|
225
|
+
FieldType.TYPEAHEAD,
|
|
226
|
+
d["required"],
|
|
227
|
+
prefilled=d["value"] or None,
|
|
228
|
+
)
|
|
229
|
+
unfillable = None
|
|
230
|
+
if kind == "unknown":
|
|
231
|
+
unfillable = f"unhandled_rendering: {d['key']} is a control atsfill does not know"
|
|
232
|
+
return Field(
|
|
233
|
+
d["key"],
|
|
234
|
+
d["label"],
|
|
235
|
+
TYPES.get(kind, FieldType.TEXT),
|
|
236
|
+
d["required"],
|
|
237
|
+
options=options if TYPES.get(kind) == FieldType.CHOICE else None,
|
|
238
|
+
max_length=d.get("max"),
|
|
239
|
+
prefilled=(d["value"] or None) if kind not in ("checkbox", "radio") else None,
|
|
240
|
+
default=d["value"] if kind in ("checkbox", "radio") and d["value"] else None,
|
|
241
|
+
unfillable=unfillable,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def _options(self, page: Page, d: dict[str, Any]) -> list[str]:
|
|
245
|
+
"""A picklist's items, read by opening it."""
|
|
246
|
+
items = self._open(page, d)
|
|
247
|
+
options = [t for t in items.evaluate_all("as => as.map(a => a.title.trim())") if t]
|
|
248
|
+
self._close(page, d)
|
|
249
|
+
return [o for o in options if o != NO_SELECTION]
|
|
250
|
+
|
|
251
|
+
def _open(self, page: Page, d: dict[str, Any]) -> Locator:
|
|
252
|
+
"""Opens a picklist; a click can go unheard, or close a list another left open."""
|
|
253
|
+
button = page.locator(_id(d["id"])).locator("xpath=following-sibling::button")
|
|
254
|
+
items = page.locator(f"{_id(d['owns'])} li[role=option] a")
|
|
255
|
+
for _ in range(OPEN_TRIES):
|
|
256
|
+
button.click()
|
|
257
|
+
try:
|
|
258
|
+
items.first.wait_for(timeout=LIST_TIMEOUT_MS)
|
|
259
|
+
return items
|
|
260
|
+
except Timeout:
|
|
261
|
+
continue
|
|
262
|
+
return items
|
|
263
|
+
|
|
264
|
+
def _close(self, page: Page, d: dict[str, Any]) -> None:
|
|
265
|
+
if page.locator(f"{_id(d['owns'])}:visible").count():
|
|
266
|
+
page.keyboard.press("Escape")
|
|
267
|
+
if page.locator(f"{_id(d['owns'])}:visible").count():
|
|
268
|
+
page.locator(_id(d["id"])).locator("xpath=following-sibling::button").click()
|
|
269
|
+
|
|
270
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
271
|
+
"""Fills in the page's order, a country before its states. A widget re-renders with new
|
|
272
|
+
ids as it changes, so each field is looked up again before it is filled."""
|
|
273
|
+
if not self._fields:
|
|
274
|
+
self.browser_read(page)
|
|
275
|
+
for key in [k for k in self._fields if k in answers]:
|
|
276
|
+
d = self._current(page, key)
|
|
277
|
+
answer = answers[key]
|
|
278
|
+
value = answer if isinstance(answer, str) else ", ".join(answer)
|
|
279
|
+
kind = d["kind"]
|
|
280
|
+
if kind == "file":
|
|
281
|
+
self._uploads[key] = Path(value).name
|
|
282
|
+
if value == d["value"] or self._uploads.get(key) in d.get("files", []):
|
|
283
|
+
continue
|
|
284
|
+
if d["id"] is None and kind != "radio":
|
|
285
|
+
raise NotTaken(f"{key} shows no control to fill")
|
|
286
|
+
if kind in ("text", "date"):
|
|
287
|
+
target = page.locator(_id(d["id"]))
|
|
288
|
+
target.fill(value)
|
|
289
|
+
target.press("Tab")
|
|
290
|
+
elif kind == "picklist":
|
|
291
|
+
self._pick(page, d, value)
|
|
292
|
+
elif kind == "select":
|
|
293
|
+
page.locator(_id(d["id"])).select_option(label=value)
|
|
294
|
+
elif kind == "radio":
|
|
295
|
+
page.locator(_id(d["ids"][d["options"].index(value)])).check()
|
|
296
|
+
elif kind == "checkbox":
|
|
297
|
+
box = page.locator(_id(d["id"]))
|
|
298
|
+
if box.is_checked() != (value == "Yes"):
|
|
299
|
+
box.click()
|
|
300
|
+
elif kind == "file":
|
|
301
|
+
self._upload(page, d, value)
|
|
302
|
+
|
|
303
|
+
def _pick(self, page: Page, d: dict[str, Any], value: str) -> None:
|
|
304
|
+
items = self._open(page, d)
|
|
305
|
+
titles = [t.strip() for t in items.evaluate_all("as => as.map(a => a.title)")]
|
|
306
|
+
wanted = next((i for i, t in enumerate(titles) if t == value), None)
|
|
307
|
+
if wanted is None:
|
|
308
|
+
folded = [t.casefold() for t in titles]
|
|
309
|
+
wanted = next((i for i, t in enumerate(folded) if t.startswith(value.casefold())), None)
|
|
310
|
+
if wanted is None:
|
|
311
|
+
self._close(page, d)
|
|
312
|
+
raise NotTaken(f"{d['key']} lists no {value!r}")
|
|
313
|
+
items.nth(wanted).click()
|
|
314
|
+
page.wait_for_timeout(SETTLE_MS)
|
|
315
|
+
|
|
316
|
+
def _upload(self, page: Page, d: dict[str, Any], path: str) -> None:
|
|
317
|
+
"""The attachment's button opens a callout of sources, the device's a file input that
|
|
318
|
+
uploads once set."""
|
|
319
|
+
if self._current(page, d["key"])["multiple"]: # it holds a file: deleted first
|
|
320
|
+
page.locator(_id(self._current(page, d["key"])["id"])).click()
|
|
321
|
+
confirm = page.locator(f"{OVERLAY} button.globalPrimaryButton:visible")
|
|
322
|
+
confirm.first.wait_for(timeout=LIST_TIMEOUT_MS)
|
|
323
|
+
confirm.first.click()
|
|
324
|
+
deadline = time.monotonic() + UPLOAD_TIMEOUT_S
|
|
325
|
+
while self._current(page, d["key"])["multiple"]:
|
|
326
|
+
if time.monotonic() > deadline:
|
|
327
|
+
raise NotTaken(f"{d['key']} kept its file")
|
|
328
|
+
page.wait_for_timeout(POLL_MS)
|
|
329
|
+
source = page.locator("[role=dialog]:visible input[type=file]")
|
|
330
|
+
for _ in range(OPEN_TRIES): # a first click can only close a list left open
|
|
331
|
+
page.locator(_id(self._current(page, d["key"])["id"])).click()
|
|
332
|
+
try:
|
|
333
|
+
source.last.wait_for(state="attached", timeout=LIST_TIMEOUT_MS)
|
|
334
|
+
break
|
|
335
|
+
except Timeout:
|
|
336
|
+
continue
|
|
337
|
+
source.last.set_input_files(path)
|
|
338
|
+
deadline = time.monotonic() + UPLOAD_TIMEOUT_S
|
|
339
|
+
while Path(path).name not in self._current(page, d["key"])["files"]:
|
|
340
|
+
if time.monotonic() > deadline:
|
|
341
|
+
raise NotTaken(f"{d['key']} did not show {Path(path).name} once uploaded")
|
|
342
|
+
page.wait_for_timeout(POLL_MS)
|
|
343
|
+
|
|
344
|
+
def _current(self, page: Page, key: str) -> dict[str, Any]:
|
|
345
|
+
found = next((d for d in page.evaluate(FIELDS) if d["key"] == key), None)
|
|
346
|
+
if found is None:
|
|
347
|
+
raise NotTaken(f"{key} is no longer on the page")
|
|
348
|
+
return found | {"options": self._fields[key]["options"]}
|
|
349
|
+
|
|
350
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
351
|
+
"""Each field's value; an attachment's, the file uploaded to it when it lists several."""
|
|
352
|
+
values = {}
|
|
353
|
+
for d in page.evaluate(FIELDS):
|
|
354
|
+
uploaded = self._uploads.get(d["key"])
|
|
355
|
+
value = uploaded if uploaded in d.get("files", []) else d["value"]
|
|
356
|
+
if value:
|
|
357
|
+
values[d["key"]] = value
|
|
358
|
+
return values
|
|
359
|
+
|
|
360
|
+
def submit(self, page: Page) -> str | None:
|
|
361
|
+
page.locator(SUBMIT).first.click()
|
|
362
|
+
page.wait_for_load_state(timeout=SUBMIT_TIMEOUT_MS)
|
|
363
|
+
if message := self._message(page):
|
|
364
|
+
raise Blocked(f"not_submitted: {message}")
|
|
365
|
+
return page.url
|
|
366
|
+
|
|
367
|
+
def _candidate(self, page: Page) -> None:
|
|
368
|
+
"""From the posting, follows its apply link to the candidate pages."""
|
|
369
|
+
host = urlsplit(page.url).hostname or ""
|
|
370
|
+
if not DC_HOST.match(host):
|
|
371
|
+
apply = page.locator(APPLY)
|
|
372
|
+
if not apply.count():
|
|
373
|
+
raise Unsupported("posting_gone: the posting has no apply link")
|
|
374
|
+
apply.first.evaluate("a => a.click()") # some sites open a dialog on a real click
|
|
375
|
+
try:
|
|
376
|
+
page.locator(LANDED).first.wait_for(state="attached", timeout=LANDING_TIMEOUT_MS)
|
|
377
|
+
except Timeout:
|
|
378
|
+
raise Unsupported(
|
|
379
|
+
f"unhandled_rendering: the apply link led to {urlsplit(page.url).path}"
|
|
380
|
+
) from None
|
|
381
|
+
page.wait_for_timeout(SETTLE_MS)
|
|
382
|
+
|
|
383
|
+
def _message(self, page: Page, only: re.Pattern | None = None) -> str:
|
|
384
|
+
"""The errors the page shows, those matching `only` when given."""
|
|
385
|
+
texts = [" ".join(t.split()) for t in page.locator(ERROR).all_inner_texts()]
|
|
386
|
+
return " ".join(t for t in texts if t and (only is None or only.search(t)))[:300]
|
|
@@ -18,7 +18,7 @@ from atsfill.core import paths
|
|
|
18
18
|
SERVICE = "atsfill"
|
|
19
19
|
COMMAND_TIMEOUT_S = 60 # a password manager may wait for its unlock
|
|
20
20
|
FILE = "passwords.json" # in the user's directory, only without a secret store
|
|
21
|
-
GENERATED_LENGTH =
|
|
21
|
+
GENERATED_LENGTH = 16 # SuccessFactors refuses a password longer than 18
|
|
22
22
|
CLASSES = (string.ascii_lowercase, string.ascii_uppercase, string.digits, "!#%+-.:=?@_")
|
|
23
23
|
|
|
24
24
|
|
|
@@ -95,6 +95,8 @@ kind = "ats"
|
|
|
95
95
|
share = 0.0350
|
|
96
96
|
account = "to_read"
|
|
97
97
|
hosting = ["hosted"]
|
|
98
|
+
adapter = "successfactors"
|
|
99
|
+
unsupported = { date = 2026-10-26, version = "0.0.3", commit = "0d8baea", by = "agent", reason = "quick_apply_sign_up: a Quick Apply page creates the account and sends the application in one submit, its sign-in offering no sign-up; sign_up_fields: a sign-up asking a phone number" }
|
|
98
100
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
99
101
|
|
|
100
102
|
[ukg]
|
|
@@ -168,9 +170,10 @@ mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25
|
|
|
168
170
|
[mycareersfuture]
|
|
169
171
|
kind = "board"
|
|
170
172
|
share = 0.0157
|
|
171
|
-
account = "to_read"
|
|
173
|
+
account = "to_read" # Log in to Apply goes to Singpass, Singapore's national digital identity; there is no email sign-up
|
|
172
174
|
hosting = ["hosted"]
|
|
173
175
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
176
|
+
unsupported = { date = 2026-10-26, version = "0.0.3", commit = "46095c4", by = "agent", reason = "unsupported_flow: national_id_login: applying needs a Singpass sign-in, whose account takes a Singapore NRIC or FIN number; no account can be made for the Test user (7 of 7 employers)" }
|
|
174
177
|
|
|
175
178
|
[adzuna]
|
|
176
179
|
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
|