atsfill 0.0.4.dev116__tar.gz → 0.0.4.dev118__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.dev116 → atsfill-0.0.4.dev118}/PKG-INFO +1 -1
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/pyproject.toml.orig +1 -1
- atsfill-0.0.4.dev118/src/atsfill/adapters/taleo.py +613 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/registry.toml +3 -1
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/LICENSE +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/README.md +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/adapters/successfactors.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/adapters/ukg.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev116 → atsfill-0.0.4.dev118}/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.dev118
|
|
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
|
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
"""Taleo Enterprise career sections
|
|
2
|
+
(`<zone>.taleo.net/careersection/<section>/jobdetail.ftl?job=…`), as docs/adapters/taleo.md
|
|
3
|
+
observes.
|
|
4
|
+
|
|
5
|
+
Applying needs an account, one per zone: the account scope. The form is a wizard of
|
|
6
|
+
server-rendered pages, one in the DOM at a time, each saved before the next shows.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import html
|
|
10
|
+
import re
|
|
11
|
+
import secrets
|
|
12
|
+
import time
|
|
13
|
+
from contextlib import suppress
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any
|
|
16
|
+
from urllib.parse import parse_qs, quote, urlsplit
|
|
17
|
+
|
|
18
|
+
from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, Unsupported
|
|
19
|
+
from atsfill.core.browser import Page, Request, Timeout
|
|
20
|
+
from atsfill.core.results import Answer, AnyOf, Field, FieldType, Is, OptionSet, SideEffect
|
|
21
|
+
|
|
22
|
+
HOST = re.compile(r"^(?P<zone>[a-z0-9-]+)\.taleo\.net$")
|
|
23
|
+
PATH = re.compile(r"^/careersection/(?P<section>[^/]+)/(jobdetail|jobapply)\.ftl$")
|
|
24
|
+
LOGIN_PATH = "/careersection/iam/accessmanagement/"
|
|
25
|
+
SECURITY_PATH = "/accessmanagement/changeQuestionsAnswers.jsf"
|
|
26
|
+
FLOW_PATH = "/candidateacquisition/flow.jsf"
|
|
27
|
+
SECURITY_QUESTION = "Which value did atsfill generate?"
|
|
28
|
+
FORM_TIMEOUT_MS = 30_000
|
|
29
|
+
SAVE_TIMEOUT_MS = 60_000
|
|
30
|
+
LEVEL_TIMEOUT_MS = 10_000
|
|
31
|
+
UPLOAD_TIMEOUT_MS = 60_000
|
|
32
|
+
SUBMIT_TIMEOUT_MS = 90_000
|
|
33
|
+
SETTLE_MS = 500
|
|
34
|
+
SIGN_IN_TIMEOUT_S = 30
|
|
35
|
+
SIGN_IN_POLL_MS = 500
|
|
36
|
+
MAX_GATES = 4 # pages apply can pass before the wizard: an agreement, security questions
|
|
37
|
+
|
|
38
|
+
FORM_ID = "dialogTemplate-dialogForm"
|
|
39
|
+
NEXT = "input[type=button][id$='-saveContinueCmdBottom']"
|
|
40
|
+
CONFIRM = "input[type=button][id$='-saveContinueCmdFlowPopUp']:visible"
|
|
41
|
+
SUBMIT = "input[type=button][value='Submit']:visible"
|
|
42
|
+
ACCEPT = re.compile(r"^\s*(I )?(accept|agree)", re.IGNORECASE)
|
|
43
|
+
FIRST_PAGE = "a[id$='-0-dtGotoPageLink']"
|
|
44
|
+
ERRORS_HEADING = "Errors occurred"
|
|
45
|
+
SKIPPED_STEPS = {"Resume Parsing"} # its upload parses a resume into the profile, not used
|
|
46
|
+
SKIPPED_TITLES = {"Authentication"}
|
|
47
|
+
SOURCE_TYPE, SOURCES = "recruitmentSourceType", "recruitmentSourceDP"
|
|
48
|
+
ERROR_TEXT = re.compile(r'class="[^"]*[eE]rror[^"]*"[^>]*>(.*?)</', re.DOTALL)
|
|
49
|
+
LOGGED = re.compile(r'id="[^"]*:Logged"[^>]*value="(true|false)"')
|
|
50
|
+
LOCATION_GAP = "location_levels"
|
|
51
|
+
DATE_PICKER_GAP = "date_picker"
|
|
52
|
+
YES_NO = ["Yes", "No"]
|
|
53
|
+
PLACEHOLDER = re.compile(r"^(not specified|select one\.*|no selection|month|year|)$", re.I)
|
|
54
|
+
NANP = re.compile(r"^\+?1?\D*(\d{3})\D*(\d{3})\D*(\d{4})$")
|
|
55
|
+
NANP_MASKED = re.compile(r"^\(\d{3}\)\d{3}-\d{4}$")
|
|
56
|
+
|
|
57
|
+
ERRORS = [
|
|
58
|
+
(re.compile(r"already (in use|exists|used)|is not available|already registered"),
|
|
59
|
+
Refused, "email_in_use"),
|
|
60
|
+
(re.compile(r"(user name|username) (and|or) (the )?password|does not correspond|"
|
|
61
|
+
r"invalid (user|credentials)"), Refused, "wrong_password"),
|
|
62
|
+
(re.compile(r"password"), Refused, "password_rule"),
|
|
63
|
+
(re.compile(r"captcha"), Blocked, "captcha"),
|
|
64
|
+
(re.compile(r"locked|too many"), Blocked, "rate_limited"),
|
|
65
|
+
] # fmt: skip
|
|
66
|
+
|
|
67
|
+
# Each control of the current page, in page order, by how it renders. A question's radios or
|
|
68
|
+
# boxes are one field, named by their fieldset's legend; a month and a year select, one.
|
|
69
|
+
_READ = r"""() => {
|
|
70
|
+
const clean = s => (s || "").replace(/\s+/g, " ").trim();
|
|
71
|
+
const shown = e => !!(e.offsetWidth || e.offsetHeight || e.getClientRects().length);
|
|
72
|
+
const text = el => {
|
|
73
|
+
if (!el) return "";
|
|
74
|
+
const copy = el.cloneNode(true);
|
|
75
|
+
copy.querySelectorAll(".hidden-audible, img, .index").forEach(n => n.remove());
|
|
76
|
+
return clean(copy.textContent).replace(/[\s.*:]+$/, "");
|
|
77
|
+
};
|
|
78
|
+
const marked = el => !!el && (/Required/.test(el.textContent) ||
|
|
79
|
+
!!el.querySelector?.("img.requiredimg"));
|
|
80
|
+
const labelOf = id => document.querySelector(`label[for="${CSS.escape(id)}"]`);
|
|
81
|
+
const placeholder = t => /^(not specified|select one\.*|no selection|month|year|)$/i.test(t);
|
|
82
|
+
const boxLabel = b => text(b.closest("label") || labelOf(b.id));
|
|
83
|
+
const out = [], seen = new Set();
|
|
84
|
+
for (const el of document.querySelectorAll("input, select, textarea")) {
|
|
85
|
+
const type = (el.type || "").toLowerCase();
|
|
86
|
+
if (["hidden", "button", "submit", "password", "image"].includes(type)) continue;
|
|
87
|
+
if (/sendMeJobs|ResumeParsing/i.test(el.id)) continue;
|
|
88
|
+
if (/AttachedFilesBlock/.test(el.id) && !/uploadedFile$/.test(el.id)) continue;
|
|
89
|
+
const explanation = /questionExplanationText$/.test(el.id);
|
|
90
|
+
if (!shown(el) && !explanation) continue;
|
|
91
|
+
const set = el.closest("fieldset");
|
|
92
|
+
const group = set && /PossibleAnswer/.test(el.id);
|
|
93
|
+
if (type === "radio" || (type === "checkbox" && group)) {
|
|
94
|
+
const legend = set?.querySelector("legend label") || set?.querySelector("legend");
|
|
95
|
+
const id = legend?.getAttribute("for") || el.name;
|
|
96
|
+
if (seen.has(id)) continue;
|
|
97
|
+
seen.add(id);
|
|
98
|
+
const boxes = [...(set || document).querySelectorAll(`input[type=${type}]`)]
|
|
99
|
+
.filter(b => type === "checkbox" || b.name === el.name);
|
|
100
|
+
out.push({id, kind: type === "radio" ? "radio" : "checks",
|
|
101
|
+
label: text(legend?.querySelector(".description") || legend) || id,
|
|
102
|
+
required: marked(legend), options: boxes.map(boxLabel),
|
|
103
|
+
value: boxes.filter(b => b.checked).map(boxLabel), controls: boxes.map(b => b.id)});
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (el.tagName === "SELECT" && /\.(month|year)$/.test(el.id)) {
|
|
107
|
+
const base = el.id.replace(/\.(month|year)$/, "");
|
|
108
|
+
if (seen.has(base)) continue;
|
|
109
|
+
seen.add(base);
|
|
110
|
+
const month = document.getElementById(base + ".month");
|
|
111
|
+
const year = document.getElementById(base + ".year");
|
|
112
|
+
const label = labelOf(base) || el.closest("fieldset")?.querySelector("legend");
|
|
113
|
+
const pick = s => s && !placeholder(clean(s.selectedOptions[0]?.textContent)) ?
|
|
114
|
+
clean(s.selectedOptions[0].textContent) : "";
|
|
115
|
+
const m = pick(month), y = pick(year);
|
|
116
|
+
const number = m ? String(month.selectedIndex).padStart(2, "0") : "";
|
|
117
|
+
out.push({id: base, kind: "monthyear", required: marked(label),
|
|
118
|
+
label: text(label) || base.split(/[_-]/).pop(), options: [],
|
|
119
|
+
value: m && y ? `${number}/${y}` : ""});
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const id = el.id;
|
|
123
|
+
if (!id || seen.has(id)) continue;
|
|
124
|
+
seen.add(id);
|
|
125
|
+
const label = labelOf(id);
|
|
126
|
+
const field = {id, label: text(label) || el.title || id, required: marked(label),
|
|
127
|
+
kind: el.tagName === "TEXTAREA" ? "text" : type, options: [],
|
|
128
|
+
value: el.value, maxLength: el.maxLength > 0 ? el.maxLength : null};
|
|
129
|
+
if (explanation) {
|
|
130
|
+
const legend = el.closest(".questionnaire")?.querySelector("fieldset legend label");
|
|
131
|
+
field.parent = legend?.getAttribute("for") || null;
|
|
132
|
+
field.kind = "text";
|
|
133
|
+
}
|
|
134
|
+
if (el.tagName === "SELECT") {
|
|
135
|
+
field.level = +((id.match(/Location-(\d+)$/) || [0, -1])[1]);
|
|
136
|
+
field.kind = field.level >= 0 ? "level" : "select";
|
|
137
|
+
field.options = [...el.options].map(o => clean(o.textContent)).filter(t => !placeholder(t))
|
|
138
|
+
.filter(t => !(/recruitmentSourceType$/.test(id) && /event ID/i.test(t)));
|
|
139
|
+
const chosen = clean(el.selectedOptions[0]?.textContent);
|
|
140
|
+
field.value = placeholder(chosen) ? "" : chosen;
|
|
141
|
+
} else if (type === "checkbox") {
|
|
142
|
+
field.value = el.checked ? "Yes" : "No";
|
|
143
|
+
} else if (type === "file") {
|
|
144
|
+
field.accept = (el.accept || "").split(",").filter(Boolean);
|
|
145
|
+
field.value = "";
|
|
146
|
+
} else if (/Phone$/i.test(id)) field.kind = "phone";
|
|
147
|
+
else if (["text", "email", "number", "tel", ""].includes(type)) field.kind = "text";
|
|
148
|
+
out.push(field);
|
|
149
|
+
}
|
|
150
|
+
for (const a of document.querySelectorAll("a[id$='.display']")) {
|
|
151
|
+
if (!shown(a)) continue;
|
|
152
|
+
const id = a.id.replace(/\.display$/, "");
|
|
153
|
+
const label = labelOf(id);
|
|
154
|
+
out.push({id, kind: "datepicker", label: text(label) || "Date", required: marked(label),
|
|
155
|
+
options: [], value: ""});
|
|
156
|
+
}
|
|
157
|
+
const levels = out.filter(f => f.kind === "level");
|
|
158
|
+
for (const f of levels) { // the section marks the levels required, not each select
|
|
159
|
+
const section = document.getElementById(f.id)?.closest("fieldset, table");
|
|
160
|
+
const mark = section?.querySelector("legend, .requiredimg");
|
|
161
|
+
f.required = f.required || marked(mark?.parentElement);
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}"""
|
|
165
|
+
|
|
166
|
+
KINDS = {
|
|
167
|
+
"text": FieldType.TEXT,
|
|
168
|
+
"phone": FieldType.PHONE,
|
|
169
|
+
"select": FieldType.CHOICE,
|
|
170
|
+
"level": FieldType.CHOICE,
|
|
171
|
+
"radio": FieldType.CHOICE,
|
|
172
|
+
"checkbox": FieldType.CHOICE,
|
|
173
|
+
"checks": FieldType.MULTI_CHOICE,
|
|
174
|
+
"monthyear": FieldType.TEXT,
|
|
175
|
+
"file": FieldType.FILE,
|
|
176
|
+
"datepicker": FieldType.DATE,
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass(frozen=True)
|
|
181
|
+
class Posting:
|
|
182
|
+
zone: str
|
|
183
|
+
section: str
|
|
184
|
+
job: str
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def base(self) -> str:
|
|
188
|
+
return f"https://{self.zone}.taleo.net/careersection/{self.section}"
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def detail(self) -> str:
|
|
192
|
+
return f"{self.base}/jobdetail.ftl?job={self.job}"
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def apply(self) -> str:
|
|
196
|
+
return f"{self.base}/jobapply.ftl?job={self.job}&lang=en"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def posting(url: str) -> Posting | None:
|
|
200
|
+
parts = urlsplit(url)
|
|
201
|
+
host = HOST.match((parts.hostname or "").lower())
|
|
202
|
+
path = PATH.match(parts.path)
|
|
203
|
+
job = parse_qs(parts.query).get("job", [""])[0]
|
|
204
|
+
if not host or not path or not job:
|
|
205
|
+
return None
|
|
206
|
+
return Posting(host["zone"], path["section"], job)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class Taleo:
|
|
210
|
+
name = "taleo"
|
|
211
|
+
per_employer = True
|
|
212
|
+
asks_name = False
|
|
213
|
+
known_gaps = {LOCATION_GAP: Gap.RENDERING, DATE_PICKER_GAP: Gap.RENDERING}
|
|
214
|
+
side_effects = [
|
|
215
|
+
SideEffect("sign_up", "employer", "the account is created in the employer's zone"),
|
|
216
|
+
SideEffect("fill", "employer", "each page is saved to a draft application"),
|
|
217
|
+
SideEffect("upload", "employer", "an attachment is stored with the draft"),
|
|
218
|
+
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
def __init__(self) -> None:
|
|
222
|
+
self._postings: dict[str, Posting] = {} # by zone, the last posting's
|
|
223
|
+
self._kinds: dict[str, str] = {} # each read field's rendering, by its id
|
|
224
|
+
self._read_back: dict[str, Answer] = {}
|
|
225
|
+
|
|
226
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
227
|
+
return posting(url) is not None
|
|
228
|
+
|
|
229
|
+
def posting(self, url: str) -> str:
|
|
230
|
+
found = posting(url)
|
|
231
|
+
return found.detail if found else url
|
|
232
|
+
|
|
233
|
+
def account_scope(self, url: str) -> str | None:
|
|
234
|
+
found = posting(url)
|
|
235
|
+
if found is None:
|
|
236
|
+
return None
|
|
237
|
+
self._postings[found.zone] = found
|
|
238
|
+
return found.zone
|
|
239
|
+
|
|
240
|
+
def login_url(self, scope: str | None) -> str:
|
|
241
|
+
"""The zone's sign-in, back to the last posting's form once signed in."""
|
|
242
|
+
if scope not in self._postings:
|
|
243
|
+
raise ValueError(f"no Taleo posting seen for the zone {scope!r}")
|
|
244
|
+
found = self._postings[scope]
|
|
245
|
+
return (f"https://{scope}.taleo.net{LOGIN_PATH}login.jsf?lang=en"
|
|
246
|
+
f"&redirectionURI={quote(found.apply, safe='')}") # fmt: skip
|
|
247
|
+
|
|
248
|
+
def signed_in(self, request: Request, scope: str | None) -> bool:
|
|
249
|
+
"""The posting's form loads instead of redirecting to the sign-in."""
|
|
250
|
+
if scope not in self._postings:
|
|
251
|
+
return False
|
|
252
|
+
response = request.get(self._postings[scope].apply)
|
|
253
|
+
path = urlsplit(response.url).path
|
|
254
|
+
if not response.ok or LOGIN_PATH in path or SECURITY_PATH in path:
|
|
255
|
+
return False
|
|
256
|
+
logged = LOGGED.search(response.text()) # a privacy agreement shows to a visitor too
|
|
257
|
+
return logged is None or logged[1] == "true"
|
|
258
|
+
|
|
259
|
+
def sign_in(self, page: Page, email: str, password: str) -> None:
|
|
260
|
+
name = page.locator(f"#{FORM_ID}-login-name1")
|
|
261
|
+
name.wait_for(timeout=FORM_TIMEOUT_MS)
|
|
262
|
+
name.fill(email)
|
|
263
|
+
page.locator(f"#{FORM_ID}-login-password").fill(password)
|
|
264
|
+
page.locator(f"#{FORM_ID}-login-defaultCmd").click()
|
|
265
|
+
_left_sign_in(page)
|
|
266
|
+
|
|
267
|
+
def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
|
|
268
|
+
page.locator(f"#{FORM_ID}-login-register").click()
|
|
269
|
+
page.wait_for_load_state()
|
|
270
|
+
accept = page.locator(f"#{FORM_ID}-StatementBeforeAuthentificationContent-ContinueButton")
|
|
271
|
+
if accept.count():
|
|
272
|
+
accept.click()
|
|
273
|
+
page.wait_for_load_state()
|
|
274
|
+
page.locator(f"#{FORM_ID}-userName").wait_for(timeout=FORM_TIMEOUT_MS)
|
|
275
|
+
if not page.locator(f"#{FORM_ID}-password").count():
|
|
276
|
+
raise Unsupported("unhandled_rendering: the zone's sign-up mails the password")
|
|
277
|
+
for field, value in (("userName", email), ("password", password),
|
|
278
|
+
("passwordConfirm", password), ("email", email),
|
|
279
|
+
("emailConfirm", email)): # fmt: skip
|
|
280
|
+
control = page.locator(f"#{FORM_ID}-{field}")
|
|
281
|
+
if control.count():
|
|
282
|
+
control.fill(value)
|
|
283
|
+
page.locator(f"#{FORM_ID}-defaultCmd").click()
|
|
284
|
+
_left_sign_in(page)
|
|
285
|
+
|
|
286
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
287
|
+
"""Walks the wizard from its first page, saving each page it can: a page that does not
|
|
288
|
+
save with what it holds (a fresh draft's Personal Information) ends the walk, its
|
|
289
|
+
fields listed. Reading saves pages, as a fill does."""
|
|
290
|
+
self._open(page)
|
|
291
|
+
fields: list[Field] = []
|
|
292
|
+
self._kinds = {}
|
|
293
|
+
while not _on_review(page):
|
|
294
|
+
step = _step(page)
|
|
295
|
+
if step not in SKIPPED_STEPS:
|
|
296
|
+
for raw in _read(page):
|
|
297
|
+
field = _field(step, raw)
|
|
298
|
+
self._kinds[field.id] = raw["kind"]
|
|
299
|
+
fields.append(field)
|
|
300
|
+
if raw["id"].endswith(SOURCE_TYPE):
|
|
301
|
+
fields.append(_sources(page, step, raw))
|
|
302
|
+
if not _save(page):
|
|
303
|
+
break
|
|
304
|
+
return fields
|
|
305
|
+
|
|
306
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
307
|
+
"""Fills each page from the first and saves it, until Review and Submit. A page that
|
|
308
|
+
does not save stops the fill with its messages."""
|
|
309
|
+
self._open(page)
|
|
310
|
+
self._read_back = {}
|
|
311
|
+
while not _on_review(page):
|
|
312
|
+
step = _step(page)
|
|
313
|
+
raws = [] if step in SKIPPED_STEPS else _read(page)
|
|
314
|
+
filled: set[str] = set()
|
|
315
|
+
for shown in (raws, _read(page) if raws else []): # a list an answer shows
|
|
316
|
+
for raw in sorted(shown, key=_order):
|
|
317
|
+
field_id = _field_id(step, raw)
|
|
318
|
+
if field_id in answers and field_id not in filled:
|
|
319
|
+
_fill(page, raw, answers[field_id])
|
|
320
|
+
filled.add(field_id)
|
|
321
|
+
for raw in _read(page) if raws else []:
|
|
322
|
+
value = _value(raw)
|
|
323
|
+
if value not in (None, "", []):
|
|
324
|
+
self._read_back[_field_id(step, raw)] = value
|
|
325
|
+
if not _save(page):
|
|
326
|
+
unread = any(_field_id(step, r) not in self._kinds for r in raws)
|
|
327
|
+
errors = "; ".join(_errors(page))
|
|
328
|
+
raise NotTaken(f"{step} was not saved: {errors}" + ("; read again" * unread))
|
|
329
|
+
|
|
330
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
331
|
+
return dict(self._read_back)
|
|
332
|
+
|
|
333
|
+
def submit(self, page: Page) -> str | None:
|
|
334
|
+
"""Clicks Review and Submit's Submit; Taleo moves to its confirmation page."""
|
|
335
|
+
page.locator(SUBMIT).first.click()
|
|
336
|
+
page.wait_for_load_state()
|
|
337
|
+
page.locator(SUBMIT).first.wait_for(state="detached", timeout=SUBMIT_TIMEOUT_MS)
|
|
338
|
+
return page.url
|
|
339
|
+
|
|
340
|
+
def _open(self, page: Page) -> None:
|
|
341
|
+
"""The wizard's first page, from the posting's apply URL."""
|
|
342
|
+
found = posting(page.url) or next(reversed(self._postings.values()), None)
|
|
343
|
+
if found is None:
|
|
344
|
+
raise Unsupported("unhandled_rendering: not a Taleo posting page")
|
|
345
|
+
page.goto(found.apply)
|
|
346
|
+
page.wait_for_load_state()
|
|
347
|
+
for _ in range(MAX_GATES):
|
|
348
|
+
path = urlsplit(page.url).path
|
|
349
|
+
if LOGIN_PATH in path:
|
|
350
|
+
raise Blocked("login_wall: the form redirects to the sign-in")
|
|
351
|
+
if SECURITY_PATH in path:
|
|
352
|
+
_security_question(page)
|
|
353
|
+
continue
|
|
354
|
+
if FLOW_PATH in path or page.locator(f"{NEXT}, {SUBMIT}, {FIRST_PAGE}").count():
|
|
355
|
+
break
|
|
356
|
+
accept = page.locator("input[type=button]:visible", has_text=ACCEPT)
|
|
357
|
+
accept = accept if accept.count() else page.get_by_role("button", name=ACCEPT)
|
|
358
|
+
if not accept.count():
|
|
359
|
+
if PATH.match(path) and path.endswith("jobdetail.ftl"):
|
|
360
|
+
raise Unsupported("posting_gone: the posting does not take applications")
|
|
361
|
+
raise Unsupported(f"unhandled_rendering: apply lands on {path}")
|
|
362
|
+
accept.first.click()
|
|
363
|
+
page.wait_for_load_state()
|
|
364
|
+
first = page.locator(FIRST_PAGE)
|
|
365
|
+
if first.count():
|
|
366
|
+
first.first.click()
|
|
367
|
+
page.wait_for_load_state()
|
|
368
|
+
try:
|
|
369
|
+
page.locator(NEXT).first.wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
|
|
370
|
+
except Timeout:
|
|
371
|
+
raise Unsupported("posting_gone: no application form on the page") from None
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _left_sign_in(page: Page) -> None:
|
|
375
|
+
"""Waits to leave the zone's sign-in pages, or for the error they show."""
|
|
376
|
+
deadline = time.monotonic() + SIGN_IN_TIMEOUT_S
|
|
377
|
+
said = page.locator(".errorMessage:visible, .error:visible, [role=alert]:visible")
|
|
378
|
+
while time.monotonic() < deadline:
|
|
379
|
+
path = urlsplit(page.url).path
|
|
380
|
+
if SECURITY_PATH in path:
|
|
381
|
+
_security_question(page)
|
|
382
|
+
continue
|
|
383
|
+
if LOGIN_PATH not in path:
|
|
384
|
+
page.wait_for_load_state()
|
|
385
|
+
logged = LOGGED.search(page.content())
|
|
386
|
+
if logged and logged[1] == "false":
|
|
387
|
+
text = " ".join(page.locator("body").inner_text().split())[:200]
|
|
388
|
+
raise Unsupported(f"unhandled_rendering: not signed in after the sign-in: {text}")
|
|
389
|
+
return
|
|
390
|
+
if said.count():
|
|
391
|
+
text = " ".join(t.strip() for t in said.all_inner_texts() if t.strip())
|
|
392
|
+
if text:
|
|
393
|
+
for pattern, error, code in ERRORS:
|
|
394
|
+
if pattern.search(text.lower()):
|
|
395
|
+
raise error(f"{code}: {text}")
|
|
396
|
+
raise Unsupported(f"unhandled_rendering: the sign-in page says {text!r}")
|
|
397
|
+
page.wait_for_timeout(SIGN_IN_POLL_MS)
|
|
398
|
+
raise Unsupported("unhandled_rendering: the sign-in page answered nothing")
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _security_question(page: Page) -> None:
|
|
402
|
+
"""The zone's security questions, asked once signed in: each gets a question of atsfill's
|
|
403
|
+
and a random answer it does not keep, so a lost password goes through the email reset."""
|
|
404
|
+
page.wait_for_load_state()
|
|
405
|
+
for question in page.locator("input[id$=-question]:visible").all():
|
|
406
|
+
if not question.input_value():
|
|
407
|
+
question.fill(SECURITY_QUESTION)
|
|
408
|
+
for answer in page.locator("input[id$=-answer]:visible").all():
|
|
409
|
+
answer.fill(secrets.token_urlsafe(16))
|
|
410
|
+
page.locator(f"#{FORM_ID}-defaultCmd").click()
|
|
411
|
+
page.wait_for_url(lambda url: SECURITY_PATH not in url, timeout=FORM_TIMEOUT_MS)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _slug(step: str) -> str:
|
|
415
|
+
return re.sub(r"[^a-z0-9]+", "_", step.lower()).strip("_")
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _short(dom_id: str) -> str:
|
|
419
|
+
"""A control's DOM id without the page's generated segments."""
|
|
420
|
+
short = re.sub(r"^.*?-ftf-(gp-)?", "", dom_id)
|
|
421
|
+
short = re.sub(r"j_id_id\d+pc\d+[-:]?|page_+\d+[-:]?", "", short)
|
|
422
|
+
return re.sub(r"[-:]+", "-", short).strip("-")
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _field_id(step: str, raw: dict[str, Any]) -> str:
|
|
426
|
+
return f"{_slug(step)}/{_short(raw['id'])}"
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _field(step: str, raw: dict[str, Any]) -> Field:
|
|
430
|
+
kind, field_id = raw["kind"], _field_id(step, raw)
|
|
431
|
+
label, required = raw["label"], raw["required"]
|
|
432
|
+
if kind not in KINDS:
|
|
433
|
+
reason = f"unhandled_rendering: {field_id} is a {kind} input"
|
|
434
|
+
return Field(field_id, label, FieldType.TEXT, required, unfillable=reason)
|
|
435
|
+
if kind == "datepicker":
|
|
436
|
+
reason = f"{Gap.RENDERING}: {DATE_PICKER_GAP}: {field_id} is a date picker"
|
|
437
|
+
return Field(field_id, label, FieldType.DATE, required, unfillable=reason)
|
|
438
|
+
if kind == "checkbox":
|
|
439
|
+
return Field(field_id, label, FieldType.CHOICE, required, YES_NO, default="No")
|
|
440
|
+
if kind == "monthyear":
|
|
441
|
+
return Field(field_id, f"{label} (MM/YYYY)", FieldType.TEXT, required)
|
|
442
|
+
if kind == "file":
|
|
443
|
+
return Field(field_id, label, FieldType.FILE, required, accept=raw.get("accept") or None)
|
|
444
|
+
if kind == "level" and raw["level"] > 0:
|
|
445
|
+
if raw.get("value"): # the profile's place, its list loaded for the level above
|
|
446
|
+
return Field(field_id, label, FieldType.CHOICE, required, raw["options"],
|
|
447
|
+
prefilled=raw["value"]) # fmt: skip
|
|
448
|
+
reason = (f"{Gap.RENDERING}: {LOCATION_GAP}: {field_id} lists its options only once "
|
|
449
|
+
"the level above is picked") # fmt: skip
|
|
450
|
+
return Field(field_id, label, FieldType.CHOICE, required, [], unfillable=reason)
|
|
451
|
+
if raw.get("parent"): # a question's explanation, asked once it is answered Yes
|
|
452
|
+
parent = Is(f"{_slug(step)}/{_short(raw['parent'])}", ["Yes"])
|
|
453
|
+
return Field(field_id, label, FieldType.TEXT, False, max_length=raw.get("maxLength"),
|
|
454
|
+
shown_when=parent, required_when=parent) # fmt: skip
|
|
455
|
+
options = raw["options"] if kind in ("select", "level", "radio", "checks") else None
|
|
456
|
+
value = raw.get("value") or None
|
|
457
|
+
return Field(
|
|
458
|
+
field_id, label, KINDS[kind], required, options, raw.get("maxLength"),
|
|
459
|
+
default=value if kind in ("select", "level", "radio") else None,
|
|
460
|
+
) # fmt: skip
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _sources(page: Page, step: str, raw: dict[str, Any]) -> Field:
|
|
464
|
+
"""The sources a source type lists once picked, loaded by picking each type in turn; the
|
|
465
|
+
page's own pick is put back."""
|
|
466
|
+
source_type = page.locator(f'[id="{raw["id"]}"]')
|
|
467
|
+
kept = source_type.input_value()
|
|
468
|
+
sources = page.locator(f"select[id$='{SOURCES}']")
|
|
469
|
+
sets = []
|
|
470
|
+
for option in raw["options"]:
|
|
471
|
+
source_type.select_option(label=option)
|
|
472
|
+
page.wait_for_load_state("networkidle", timeout=SAVE_TIMEOUT_MS)
|
|
473
|
+
page.wait_for_timeout(SETTLE_MS)
|
|
474
|
+
if sources.count() and sources.first.is_visible():
|
|
475
|
+
names = sources.first.evaluate(
|
|
476
|
+
"e => [...e.options].map(o => o.textContent.replace(/\\s+/g, ' ').trim())"
|
|
477
|
+
)
|
|
478
|
+
names = [n for n in names if not PLACEHOLDER.match(n)]
|
|
479
|
+
if names:
|
|
480
|
+
sets.append(OptionSet(Is(_field_id(step, raw), [option]), names))
|
|
481
|
+
source_type.select_option(kept)
|
|
482
|
+
page.wait_for_load_state("networkidle", timeout=SAVE_TIMEOUT_MS)
|
|
483
|
+
field_id = f"{_slug(step)}/{_short(raw['id']).replace(SOURCE_TYPE, SOURCES)}"
|
|
484
|
+
shown = AnyOf([s.when for s in sets]) if sets else Is(_field_id(step, raw), [])
|
|
485
|
+
return Field(field_id, "Sources", FieldType.CHOICE, True, [], option_sets=sets,
|
|
486
|
+
shown_when=shown) # fmt: skip
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _order(raw: dict[str, Any]) -> tuple[bool, int, bool]:
|
|
490
|
+
"""Location levels top down before the rest of the page; explanations last."""
|
|
491
|
+
return raw["kind"] != "level", raw.get("level", 0), bool(raw.get("parent"))
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _fill(page: Page, raw: dict[str, Any], answer: Answer) -> None:
|
|
495
|
+
values = [answer] if isinstance(answer, str) else answer
|
|
496
|
+
control = page.locator(f'[id="{raw["id"]}"]')
|
|
497
|
+
match raw["kind"]:
|
|
498
|
+
case "select" | "level":
|
|
499
|
+
labels = control.evaluate("e => [...e.options].map(o => o.textContent)")
|
|
500
|
+
label = next((o for o in labels if " ".join(o.split()) == values[0]), None)
|
|
501
|
+
if label is None:
|
|
502
|
+
raise NotTaken(f"no option {values[0]!r} in {raw['label']}")
|
|
503
|
+
if raw["value"] == values[0]:
|
|
504
|
+
return # picking it again would reload the levels below
|
|
505
|
+
control.select_option(label=label.strip())
|
|
506
|
+
if raw["kind"] == "level":
|
|
507
|
+
_wait_for_level(page, raw["id"])
|
|
508
|
+
case "radio" | "checks":
|
|
509
|
+
for box_id, option in zip(raw["controls"], raw["options"], strict=True):
|
|
510
|
+
box = page.locator(f'[id="{box_id}"]')
|
|
511
|
+
if box.is_checked() != (option in values):
|
|
512
|
+
box.click()
|
|
513
|
+
page.wait_for_timeout(SETTLE_MS)
|
|
514
|
+
case "checkbox":
|
|
515
|
+
control.set_checked(values[0] == "Yes")
|
|
516
|
+
case "monthyear":
|
|
517
|
+
month, _, year = values[0].partition("/")
|
|
518
|
+
page.locator(f'[id="{raw["id"]}.month"]').select_option(index=int(month))
|
|
519
|
+
page.locator(f'[id="{raw["id"]}.year"]').select_option(label=year)
|
|
520
|
+
case "file":
|
|
521
|
+
_upload(page, raw["id"], values[0])
|
|
522
|
+
case "datepicker":
|
|
523
|
+
pass
|
|
524
|
+
case "phone":
|
|
525
|
+
control.fill(_masked(values[0]))
|
|
526
|
+
case _:
|
|
527
|
+
control.fill(values[0])
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _masked(number: str) -> str:
|
|
531
|
+
"""A +1 number in the North American mask zones validate, `(999)999-9999`; another as given,
|
|
532
|
+
for the `+999999999999999` mask."""
|
|
533
|
+
number = number.strip()
|
|
534
|
+
match = NANP.match(number)
|
|
535
|
+
if not match or (number.startswith("+") and not number.startswith("+1")):
|
|
536
|
+
return number
|
|
537
|
+
return f"({match[1]}){match[2]}-{match[3]}"
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _value(raw: dict[str, Any]) -> Answer | None:
|
|
541
|
+
match raw["kind"]:
|
|
542
|
+
case "checks":
|
|
543
|
+
return raw["value"]
|
|
544
|
+
case "radio":
|
|
545
|
+
return raw["value"][0] if raw["value"] else None
|
|
546
|
+
case "file" | "datepicker":
|
|
547
|
+
return None
|
|
548
|
+
case "phone" if NANP_MASKED.match(raw["value"]):
|
|
549
|
+
return f"+1 {raw['value']}"
|
|
550
|
+
case _:
|
|
551
|
+
return raw["value"]
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _wait_for_level(page: Page, level_id: str) -> None:
|
|
555
|
+
"""Waits for the next location level's options, where there is one."""
|
|
556
|
+
following = re.sub(r"-(\d+)$", lambda m: f"-{int(m[1]) + 1}", level_id)
|
|
557
|
+
if not page.locator(f'[id="{following}"]').count():
|
|
558
|
+
return
|
|
559
|
+
with suppress(Timeout): # a place with nothing below it
|
|
560
|
+
page.wait_for_function(
|
|
561
|
+
"id => (document.getElementById(id)?.options.length || 0) > 1",
|
|
562
|
+
arg=following, timeout=LEVEL_TIMEOUT_MS,
|
|
563
|
+
) # fmt: skip
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _upload(page: Page, input_id: str, path: str) -> None:
|
|
567
|
+
"""Attaches the file with the page's Attach button, then waits for it in the list."""
|
|
568
|
+
name = path.rsplit("/", 1)[-1]
|
|
569
|
+
page.locator(f'[id="{input_id}"]').set_input_files(path)
|
|
570
|
+
page.locator("input[type=button][id$='-attachFileCommand']").first.click()
|
|
571
|
+
page.wait_for_load_state()
|
|
572
|
+
page.get_by_text(name).first.wait_for(timeout=UPLOAD_TIMEOUT_MS)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _read(page: Page) -> list[dict[str, Any]]:
|
|
576
|
+
page.wait_for_timeout(SETTLE_MS)
|
|
577
|
+
return page.evaluate(_READ)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _step(page: Page) -> str:
|
|
581
|
+
"""The current page's title, the wizard step it is."""
|
|
582
|
+
titles = [" ".join(h.split()) for h in page.locator("h1").all_inner_texts()]
|
|
583
|
+
titles = [t for t in titles if t and t not in SKIPPED_TITLES and ERRORS_HEADING not in t]
|
|
584
|
+
return titles[-1] if titles else "page" # after a site's own heading
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _on_review(page: Page) -> bool:
|
|
588
|
+
return page.locator(SUBMIT).count() > 0
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _errors(page: Page) -> list[str]:
|
|
592
|
+
"""The messages a page that did not save lists, each in an element of an error class."""
|
|
593
|
+
found = [
|
|
594
|
+
" ".join(html.unescape(re.sub(r"<[^>]+>", " ", m)).split())
|
|
595
|
+
for m in ERROR_TEXT.findall(page.content())
|
|
596
|
+
]
|
|
597
|
+
said = [t for t in dict.fromkeys(found) if t and not t.startswith(ERRORS_HEADING)]
|
|
598
|
+
return said[:5] or ["the page shows errors"]
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _save(page: Page) -> bool:
|
|
602
|
+
"""Presses Save and Continue; whether the page saved and moved on."""
|
|
603
|
+
before = _step(page)
|
|
604
|
+
page.locator(NEXT).first.click()
|
|
605
|
+
page.wait_for_load_state()
|
|
606
|
+
confirm = page.locator(CONFIRM)
|
|
607
|
+
if confirm.count():
|
|
608
|
+
confirm.first.click()
|
|
609
|
+
page.wait_for_load_state()
|
|
610
|
+
page.wait_for_load_state("networkidle", timeout=SAVE_TIMEOUT_MS)
|
|
611
|
+
if page.locator(f"h1:has-text('{ERRORS_HEADING}')").count():
|
|
612
|
+
return False
|
|
613
|
+
return _on_review(page) or _step(page) != before
|
|
@@ -196,6 +196,7 @@ share = 0.0125
|
|
|
196
196
|
account = "to_read"
|
|
197
197
|
hosting = ["hosted"]
|
|
198
198
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
199
|
+
unsupported = { date = 2026-10-26, version = "0.0.3", commit = "284a5e5", by = "agent", reason = "unsupported_rendering: location_levels: a place's state and city list their options only once the level above is picked, so a fresh profile's are not listed (3 of 4 zones walked)" }
|
|
199
200
|
|
|
200
201
|
[jazzhr]
|
|
201
202
|
kind = "ats"
|
|
@@ -259,9 +260,10 @@ dry_run_verified = { date = 2026-10-22, version = "0.0.3", by = "adapter_gate",
|
|
|
259
260
|
[ukgready]
|
|
260
261
|
kind = "ats"
|
|
261
262
|
share = 0.0085
|
|
262
|
-
account = "to_read" #
|
|
263
|
+
account = "to_read" # the first step's Continue creates a password-less account and the job application; a sign-up with a password asks a zip code
|
|
263
264
|
hosting = ["hosted"]
|
|
264
265
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
266
|
+
unsupported = { date = 2026-10-26, version = "0.0.3", commit = "95f2522", by = "agent", reason = "unsupported_flow: sign_up_zip: the sign-up that sets a password requires a zip code, which login does not take; the first step's account has none, so it cannot be signed in to again (14 of 14 tenants)" }
|
|
265
267
|
|
|
266
268
|
[adpmyjobs]
|
|
267
269
|
kind = "ats"
|
|
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
|