atsfill 0.0.4.dev104__tar.gz → 0.0.4.dev106__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.dev104 → atsfill-0.0.4.dev106}/PKG-INFO +2 -2
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/README.md +1 -1
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/recruitee.py +129 -68
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/teamtailor.py +5 -1
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/registry.toml +1 -1
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/LICENSE +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev104 → atsfill-0.0.4.dev106}/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.dev106
|
|
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
|
-
                                         
|
|
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
|
-
                                         
|
|
33
33
|
<!-- /badges -->
|
|
34
34
|
|
|
35
35
|
| System | Account needed | `read` without a browser |
|
|
@@ -7,23 +7,27 @@ field's id. Nothing reaches Recruitee before submit, so the read-back is the pag
|
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
import re
|
|
10
|
+
import urllib.request
|
|
10
11
|
from typing import Any
|
|
11
12
|
from urllib.parse import urlsplit
|
|
12
13
|
|
|
13
|
-
from atsfill.core import browser
|
|
14
|
+
from atsfill.core import browser, html
|
|
14
15
|
from atsfill.core.adapter import Blocked, Gap, Unsupported, out_of_scope
|
|
15
|
-
from atsfill.core.browser import Locator, Page, Timeout
|
|
16
|
+
from atsfill.core.browser import HTTP_TIMEOUT_S, Locator, Page, Timeout
|
|
17
|
+
from atsfill.core.html import Node
|
|
16
18
|
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
17
19
|
|
|
18
20
|
JOB = re.compile(r"^/o/([^/?#]+)")
|
|
19
21
|
CAREER_SITE = "script[src*='/js/PublicApp/'], script[src*='recruiteecdn.com']"
|
|
20
|
-
|
|
22
|
+
FORM_ID = "offer-application-form"
|
|
23
|
+
FORM = f"form#{FORM_ID}"
|
|
21
24
|
FORM_TIMEOUT_MS = 30_000
|
|
22
25
|
SUBMIT_TIMEOUT_MS = 90_000
|
|
23
26
|
KEY_DELAY_MS = 20
|
|
24
27
|
YES_NO = ["Yes", "No"]
|
|
25
28
|
LETTER_FILE, LETTER = "candidate.coverLetterFile", "candidate.coverLetter"
|
|
26
|
-
|
|
29
|
+
LETTER_SWITCH_CY = "cover-letter-switch-button"
|
|
30
|
+
LETTER_SWITCH = f"[data-cy={LETTER_SWITCH_CY}]"
|
|
27
31
|
EXTENSIONS = {"image/png": [".png"], "image/jpeg": [".jpg", ".jpeg"], "application/pdf": [".pdf"]}
|
|
28
32
|
KINDS = {
|
|
29
33
|
"text": FieldType.TEXT,
|
|
@@ -37,69 +41,6 @@ KINDS = {
|
|
|
37
41
|
"checkboxes": FieldType.MULTI_CHOICE,
|
|
38
42
|
}
|
|
39
43
|
|
|
40
|
-
# The fields in page order, one per control name: its question's label, and how it renders. A
|
|
41
|
-
# labelled question without a named control (a video recorder, drawn late) comes as kind
|
|
42
|
-
# `video`; one without a label is text only. A cover letter upload the switch turns into a
|
|
43
|
-
# textarea is followed by that textarea, its alternative.
|
|
44
|
-
_READ = """(form, [letterFile, letter, letterSwitch]) => {
|
|
45
|
-
const text = el => {
|
|
46
|
-
if (!el) return "";
|
|
47
|
-
const copy = el.cloneNode(true);
|
|
48
|
-
copy.querySelectorAll("[aria-hidden=true]").forEach(e => e.remove());
|
|
49
|
-
return copy.textContent.replace(/\\s+/g, " ").trim();
|
|
50
|
-
};
|
|
51
|
-
const found = new Map();
|
|
52
|
-
const controls = form.querySelectorAll(
|
|
53
|
-
"input[name]:not([type=hidden]), textarea[name], input[name$='.openQuestionId']");
|
|
54
|
-
for (const el of controls) {
|
|
55
|
-
if (el.type === "hidden") {
|
|
56
|
-
const question = el.name.replace(/\\.openQuestionId$/, "");
|
|
57
|
-
const label = el.parentElement.querySelector("label");
|
|
58
|
-
if (label && !form.querySelector(
|
|
59
|
-
`[name^="${CSS.escape(question)}."]:not([type=hidden])`))
|
|
60
|
-
found.set(question, {id: question, kind: "video", label: text(label),
|
|
61
|
-
required: /\\*/.test(label.textContent), options: []});
|
|
62
|
-
continue;
|
|
63
|
-
}
|
|
64
|
-
const name = el.name;
|
|
65
|
-
let field = found.get(name);
|
|
66
|
-
if (!field) {
|
|
67
|
-
const group = el.closest("fieldset");
|
|
68
|
-
field = {id: name, required: false, options: [], accept: null,
|
|
69
|
-
maxLength: el.maxLength > 0 ? el.maxLength : null};
|
|
70
|
-
if (el.type === "radio") field.kind = "radio";
|
|
71
|
-
else if (el.type === "checkbox" && /\\.(flag|consent)$/.test(name)) {
|
|
72
|
-
field.kind = "legal";
|
|
73
|
-
field.label = text(form.querySelector(`[id="${el.getAttribute("aria-labelledby")}"]`));
|
|
74
|
-
} else if (el.type === "checkbox") field.kind = "checkboxes";
|
|
75
|
-
else if (el.type === "file") {
|
|
76
|
-
field.kind = "file";
|
|
77
|
-
field.accept = el.accept ? el.accept.split(",").map(a => a.trim()) : null;
|
|
78
|
-
} else if (el.type === "tel") field.kind = "phone";
|
|
79
|
-
else if (el.type === "date") field.kind = "date";
|
|
80
|
-
else if (el.type === "number") field.kind = "number";
|
|
81
|
-
else field.kind = "text";
|
|
82
|
-
if (field.label === undefined) field.label = ["radio", "checkboxes"].includes(field.kind)
|
|
83
|
-
? text(group?.querySelector("legend"))
|
|
84
|
-
: text(form.querySelector(`label[for="${el.id}"]`)) || el.placeholder;
|
|
85
|
-
found.set(name, field);
|
|
86
|
-
}
|
|
87
|
-
field.required ||= el.required;
|
|
88
|
-
if (el.type === "radio" || el.type === "checkbox" && field.kind === "checkboxes")
|
|
89
|
-
field.options.push(text(form.querySelector(`label[for="${el.id}"]`)) || el.value);
|
|
90
|
-
}
|
|
91
|
-
const out = [];
|
|
92
|
-
for (const field of found.values()) {
|
|
93
|
-
out.push(field);
|
|
94
|
-
if (field.id === letterFile && form.querySelector(letterSwitch)) {
|
|
95
|
-
field.oneOf = letterFile;
|
|
96
|
-
out.push({id: letter, kind: "letter", label: field.label, required: field.required,
|
|
97
|
-
options: [], accept: null, maxLength: null, oneOf: letterFile});
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
return out;
|
|
101
|
-
}"""
|
|
102
|
-
|
|
103
44
|
# What each field shows: a choice its checked option's text, a file its attached name.
|
|
104
45
|
_READ_BACK = """(form, kinds) => {
|
|
105
46
|
const optionText = el => (form.querySelector(`label[for="${el.id}"]`)?.textContent || el.value)
|
|
@@ -151,8 +92,28 @@ class Recruitee:
|
|
|
151
92
|
form.wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
|
|
152
93
|
except Timeout:
|
|
153
94
|
raise Unsupported("posting_gone: no application form on the page") from None
|
|
95
|
+
return self._listed(html.parse(form.evaluate("f => f.outerHTML")))
|
|
96
|
+
|
|
97
|
+
def request_read(self, url: str) -> list[Field] | None:
|
|
98
|
+
"""The form's own page, server-rendered, on a `*.recruitee.com` host: a custom domain is
|
|
99
|
+
known only by its page."""
|
|
100
|
+
parts = urlsplit(url)
|
|
101
|
+
if not JOB.match(parts.path) or not (parts.hostname or "").endswith(".recruitee.com"):
|
|
102
|
+
return None
|
|
103
|
+
request = urllib.request.Request(
|
|
104
|
+
f"{self.posting(url)}/c/new", headers={"User-Agent": "atsfill"}
|
|
105
|
+
)
|
|
106
|
+
try:
|
|
107
|
+
with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
|
|
108
|
+
page = html.parse(response.read().decode())
|
|
109
|
+
except OSError, ValueError:
|
|
110
|
+
return None
|
|
111
|
+
form = page.find(lambda n: n.tag == "form" and n.attrs.get("id") == FORM_ID)
|
|
112
|
+
return self._listed(form) if form else None
|
|
113
|
+
|
|
114
|
+
def _listed(self, form: Node) -> list[Field]:
|
|
154
115
|
fields, self._kinds = [], {}
|
|
155
|
-
for raw in form
|
|
116
|
+
for raw in _read(form):
|
|
156
117
|
fields.append(_field(raw))
|
|
157
118
|
if raw["kind"] != "video":
|
|
158
119
|
self._kinds[raw["id"]] = raw["kind"]
|
|
@@ -239,3 +200,103 @@ def _option(page: Page, controls: Locator, value: str) -> Locator | None:
|
|
|
239
200
|
if " ".join((text or "").split()) == value:
|
|
240
201
|
return control
|
|
241
202
|
return None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _text(node: Node | None) -> str:
|
|
206
|
+
"""A node's text without what it hides from screen readers, its runs of whitespace one
|
|
207
|
+
space."""
|
|
208
|
+
if node is None:
|
|
209
|
+
return ""
|
|
210
|
+
|
|
211
|
+
def shown(n: Node) -> str:
|
|
212
|
+
return "".join(
|
|
213
|
+
c if isinstance(c, str) else shown(c)
|
|
214
|
+
for c in n.children
|
|
215
|
+
if isinstance(c, str) or c.attrs.get("aria-hidden") != "true"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
return re.sub(r"[\s\ufeff]+", " ", shown(node)).strip() # as JavaScript's `\s`
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _read(form: Node) -> list[dict[str, Any]]:
|
|
222
|
+
"""The fields in page order, one per control name: its question's label, and how it
|
|
223
|
+
renders. A labelled question without a named control (a video recorder, drawn late) comes as
|
|
224
|
+
kind `video`; one without a label is text only. A cover letter upload the switch turns into
|
|
225
|
+
a textarea is followed by that textarea, its alternative."""
|
|
226
|
+
nodes = form.walk()
|
|
227
|
+
by_id = {n.attrs["id"]: n for n in nodes if "id" in n.attrs}
|
|
228
|
+
labels: dict[str, Node] = {}
|
|
229
|
+
for n in nodes:
|
|
230
|
+
if n.tag == "label" and "for" in n.attrs:
|
|
231
|
+
labels.setdefault(n.attrs["for"], n)
|
|
232
|
+
found: dict[str, dict[str, Any]] = {}
|
|
233
|
+
for el in nodes:
|
|
234
|
+
name, kind = el.attrs.get("name"), el.attrs.get("type", "text").lower()
|
|
235
|
+
if el.tag not in ("input", "textarea") or not name:
|
|
236
|
+
continue
|
|
237
|
+
if el.tag == "textarea":
|
|
238
|
+
kind = "textarea"
|
|
239
|
+
if kind == "hidden":
|
|
240
|
+
if not name.endswith(".openQuestionId") or el.parent is None:
|
|
241
|
+
continue
|
|
242
|
+
question = name.removesuffix(".openQuestionId")
|
|
243
|
+
label = el.parent.find(lambda n: n.tag == "label")
|
|
244
|
+
answered = any(
|
|
245
|
+
n.attrs.get("name", "").startswith(f"{question}.")
|
|
246
|
+
and n.attrs.get("type") != "hidden"
|
|
247
|
+
for n in nodes
|
|
248
|
+
)
|
|
249
|
+
if label is not None and not answered:
|
|
250
|
+
found[question] = {"id": question, "kind": "video", "label": _text(label),
|
|
251
|
+
"required": "*" in label.text(), "options": []} # fmt: skip
|
|
252
|
+
continue
|
|
253
|
+
field = found.get(name)
|
|
254
|
+
if field is None:
|
|
255
|
+
max_length = int(el.attrs.get("maxlength") or 0)
|
|
256
|
+
field: dict[str, Any] = {
|
|
257
|
+
"id": name,
|
|
258
|
+
"required": False,
|
|
259
|
+
"options": [],
|
|
260
|
+
"accept": None,
|
|
261
|
+
"maxLength": max_length if max_length > 0 else None,
|
|
262
|
+
}
|
|
263
|
+
if kind == "radio":
|
|
264
|
+
field["kind"] = "radio"
|
|
265
|
+
elif kind == "checkbox" and re.search(r"\.(flag|consent)$", name):
|
|
266
|
+
field["kind"] = "legal"
|
|
267
|
+
field["label"] = _text(by_id.get(el.attrs.get("aria-labelledby", "")))
|
|
268
|
+
elif kind == "checkbox":
|
|
269
|
+
field["kind"] = "checkboxes"
|
|
270
|
+
elif kind == "file":
|
|
271
|
+
field["kind"] = "file"
|
|
272
|
+
accept = el.attrs.get("accept")
|
|
273
|
+
field["accept"] = [a.strip() for a in accept.split(",")] if accept else None
|
|
274
|
+
else:
|
|
275
|
+
field["kind"] = {"tel": "phone", "date": "date", "number": "number"}.get(
|
|
276
|
+
kind, "text"
|
|
277
|
+
)
|
|
278
|
+
if "label" not in field:
|
|
279
|
+
if field["kind"] in ("radio", "checkboxes"):
|
|
280
|
+
group = el.closest(lambda n: n.tag == "fieldset")
|
|
281
|
+
field["label"] = _text(
|
|
282
|
+
group.find(lambda n: n.tag == "legend") if group else None
|
|
283
|
+
)
|
|
284
|
+
else:
|
|
285
|
+
own = labels.get(el.attrs.get("id", ""))
|
|
286
|
+
field["label"] = _text(own) or el.attrs.get("placeholder", "")
|
|
287
|
+
found[name] = field
|
|
288
|
+
field["required"] = field["required"] or "required" in el.attrs
|
|
289
|
+
if kind == "radio" or (kind == "checkbox" and field["kind"] == "checkboxes"):
|
|
290
|
+
field["options"].append(
|
|
291
|
+
_text(labels.get(el.attrs.get("id", ""))) or el.attrs.get("value", "")
|
|
292
|
+
)
|
|
293
|
+
switch = any(n.attrs.get("data-cy") == LETTER_SWITCH_CY for n in nodes)
|
|
294
|
+
out = []
|
|
295
|
+
for field in found.values():
|
|
296
|
+
out.append(field)
|
|
297
|
+
if field["id"] == LETTER_FILE and switch:
|
|
298
|
+
field["oneOf"] = LETTER_FILE
|
|
299
|
+
out.append({"id": LETTER, "kind": "letter", "label": field["label"],
|
|
300
|
+
"required": field["required"], "options": [], "accept": None,
|
|
301
|
+
"maxLength": None, "oneOf": LETTER_FILE}) # fmt: skip
|
|
302
|
+
return out
|
|
@@ -373,7 +373,11 @@ def _pick(question: Locator, value: str) -> None:
|
|
|
373
373
|
trigger = question.locator("button[aria-haspopup]").first
|
|
374
374
|
if trigger.get_attribute("aria-expanded") != "true":
|
|
375
375
|
browser.click(trigger)
|
|
376
|
-
|
|
376
|
+
items = question.locator("[role^=menuitem]")
|
|
377
|
+
for item in items.all(): # the text may hold a quote, which a selector would have to escape
|
|
378
|
+
if item.get_attribute("data-search-text") == value:
|
|
379
|
+
browser.click(item)
|
|
380
|
+
return
|
|
377
381
|
|
|
378
382
|
|
|
379
383
|
def _is_question(node: Node) -> bool:
|
|
@@ -288,7 +288,7 @@ account = "none"
|
|
|
288
288
|
hosting = ["hosted"]
|
|
289
289
|
adapter = "recruitee"
|
|
290
290
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
291
|
-
dry_run_verified = { date = 2026-10-
|
|
291
|
+
dry_run_verified = { date = 2026-10-25, version = "0.0.3", by = "adapter_gate", commit = "f47f160" }
|
|
292
292
|
|
|
293
293
|
[hrmdirect]
|
|
294
294
|
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
|