scrapeunblocker-haystack 0.2.0__tar.gz → 0.2.2__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.
- {scrapeunblocker_haystack-0.2.0 → scrapeunblocker_haystack-0.2.2}/CHANGELOG.md +17 -0
- {scrapeunblocker_haystack-0.2.0 → scrapeunblocker_haystack-0.2.2}/PKG-INFO +18 -3
- {scrapeunblocker_haystack-0.2.0 → scrapeunblocker_haystack-0.2.2}/README.md +16 -1
- {scrapeunblocker_haystack-0.2.0 → scrapeunblocker_haystack-0.2.2}/pyproject.toml +1 -1
- scrapeunblocker_haystack-0.2.2/src/scrapeunblocker_haystack/__init__.py +15 -0
- {scrapeunblocker_haystack-0.2.0 → scrapeunblocker_haystack-0.2.2}/src/scrapeunblocker_haystack/fetcher.py +82 -2
- scrapeunblocker_haystack-0.2.2/src/scrapeunblocker_haystack/version.py +1 -0
- {scrapeunblocker_haystack-0.2.0 → scrapeunblocker_haystack-0.2.2}/tests/test_components.py +90 -0
- scrapeunblocker_haystack-0.2.0/src/scrapeunblocker_haystack/__init__.py +0 -4
- scrapeunblocker_haystack-0.2.0/src/scrapeunblocker_haystack/version.py +0 -1
- {scrapeunblocker_haystack-0.2.0 → scrapeunblocker_haystack-0.2.2}/.github/workflows/publish.yml +0 -0
- {scrapeunblocker_haystack-0.2.0 → scrapeunblocker_haystack-0.2.2}/.github/workflows/test.yml +0 -0
- {scrapeunblocker_haystack-0.2.0 → scrapeunblocker_haystack-0.2.2}/.gitignore +0 -0
- {scrapeunblocker_haystack-0.2.0 → scrapeunblocker_haystack-0.2.2}/LICENSE +0 -0
- {scrapeunblocker_haystack-0.2.0 → scrapeunblocker_haystack-0.2.2}/src/scrapeunblocker_haystack/search.py +0 -0
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.2
|
|
4
|
+
|
|
5
|
+
- With `parsed_data=True`, a page that loads but holds no structured data
|
|
6
|
+
(the API answers HTTP 422 `no_data_extracted`, not billed, no HTML) no longer
|
|
7
|
+
shows up as a generic request failure. The fetcher skips it with a clear
|
|
8
|
+
warning, or raises the new `NoDataExtractedError` when
|
|
9
|
+
`raise_on_failure=True`. Fetch without `parsed_data` to get the HTML.
|
|
10
|
+
|
|
11
|
+
## 0.2.1
|
|
12
|
+
|
|
13
|
+
- A URL whose page does not exist (the site answers HTTP 404 or 410) no longer
|
|
14
|
+
shows up as a generic request failure, and an older API response that carried
|
|
15
|
+
the not-found page as a 200 no longer becomes a Document. The fetcher skips it
|
|
16
|
+
with a clear warning, or raises the new `TargetNotFoundError` (`url`,
|
|
17
|
+
`origin_status`, `body`) when `raise_on_failure=True`. The call is billed by
|
|
18
|
+
ScrapeUnblocker and retrying returns the same answer.
|
|
19
|
+
|
|
3
20
|
## 0.2.0
|
|
4
21
|
|
|
5
22
|
- `ScrapeUnblockerFetcher` now supports **browser steps** via the `steps`
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: scrapeunblocker-haystack
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Haystack integration for ScrapeUnblocker - scrape pages behind anti-bot protections
|
|
5
5
|
Project-URL: Homepage, https://www.scrapeunblocker.com
|
|
6
|
-
Project-URL: Documentation, https://
|
|
6
|
+
Project-URL: Documentation, https://docs.scrapeunblocker.com
|
|
7
7
|
Project-URL: Source, https://github.com/ScrapeUnblocker/scrapeunblocker-haystack
|
|
8
8
|
Project-URL: Issues, https://github.com/ScrapeUnblocker/scrapeunblocker-haystack/issues
|
|
9
9
|
Author-email: ScrapeUnblocker <info@scrapeunblocker.com>
|
|
@@ -120,6 +120,21 @@ Steps are **non-idempotent**. If a step fails, the fetch raises
|
|
|
120
120
|
which is logged and skipped by default, or re-raised when
|
|
121
121
|
`raise_on_failure=True`.
|
|
122
122
|
|
|
123
|
+
#### Pages with nothing to parse
|
|
124
|
+
|
|
125
|
+
With `parsed_data=True`, a page that loads but holds no structured data yields
|
|
126
|
+
no Document: it is skipped with a warning, or `NoDataExtractedError` is raised
|
|
127
|
+
when `raise_on_failure=True`. The call is not billed; fetch the URL without
|
|
128
|
+
`parsed_data` to get the HTML.
|
|
129
|
+
|
|
130
|
+
#### Pages that do not exist
|
|
131
|
+
|
|
132
|
+
If the site answers HTTP 404 or 410, there is no page to index: the URL is
|
|
133
|
+
skipped with a warning, or `TargetNotFoundError` (with `url`, `origin_status`
|
|
134
|
+
and the site's own `body`) is raised when `raise_on_failure=True`. That is the
|
|
135
|
+
site's answer, not a block - the call is billed and a retry returns the same
|
|
136
|
+
result.
|
|
137
|
+
|
|
123
138
|
#### List elements
|
|
124
139
|
|
|
125
140
|
Set `list_elements=True` to return the parsed elements JSON
|
|
@@ -206,7 +221,7 @@ reference, not as its value.
|
|
|
206
221
|
## Links
|
|
207
222
|
|
|
208
223
|
- ScrapeUnblocker: [www.scrapeunblocker.com](https://www.scrapeunblocker.com?utm_source=haystack&utm_medium=integration&utm_campaign=haystack-integration)
|
|
209
|
-
- API documentation: [
|
|
224
|
+
- API documentation: [docs.scrapeunblocker.com](https://docs.scrapeunblocker.com?utm_source=haystack&utm_medium=integration&utm_campaign=haystack-integration)
|
|
210
225
|
- Haystack: https://haystack.deepset.ai
|
|
211
226
|
|
|
212
227
|
## License
|
|
@@ -95,6 +95,21 @@ Steps are **non-idempotent**. If a step fails, the fetch raises
|
|
|
95
95
|
which is logged and skipped by default, or re-raised when
|
|
96
96
|
`raise_on_failure=True`.
|
|
97
97
|
|
|
98
|
+
#### Pages with nothing to parse
|
|
99
|
+
|
|
100
|
+
With `parsed_data=True`, a page that loads but holds no structured data yields
|
|
101
|
+
no Document: it is skipped with a warning, or `NoDataExtractedError` is raised
|
|
102
|
+
when `raise_on_failure=True`. The call is not billed; fetch the URL without
|
|
103
|
+
`parsed_data` to get the HTML.
|
|
104
|
+
|
|
105
|
+
#### Pages that do not exist
|
|
106
|
+
|
|
107
|
+
If the site answers HTTP 404 or 410, there is no page to index: the URL is
|
|
108
|
+
skipped with a warning, or `TargetNotFoundError` (with `url`, `origin_status`
|
|
109
|
+
and the site's own `body`) is raised when `raise_on_failure=True`. That is the
|
|
110
|
+
site's answer, not a block - the call is billed and a retry returns the same
|
|
111
|
+
result.
|
|
112
|
+
|
|
98
113
|
#### List elements
|
|
99
114
|
|
|
100
115
|
Set `list_elements=True` to return the parsed elements JSON
|
|
@@ -181,7 +196,7 @@ reference, not as its value.
|
|
|
181
196
|
## Links
|
|
182
197
|
|
|
183
198
|
- ScrapeUnblocker: [www.scrapeunblocker.com](https://www.scrapeunblocker.com?utm_source=haystack&utm_medium=integration&utm_campaign=haystack-integration)
|
|
184
|
-
- API documentation: [
|
|
199
|
+
- API documentation: [docs.scrapeunblocker.com](https://docs.scrapeunblocker.com?utm_source=haystack&utm_medium=integration&utm_campaign=haystack-integration)
|
|
185
200
|
- Haystack: https://haystack.deepset.ai
|
|
186
201
|
|
|
187
202
|
## License
|
|
@@ -25,7 +25,7 @@ dependencies = ["haystack-ai>=2.0.0", "requests>=2.31.0"]
|
|
|
25
25
|
|
|
26
26
|
[project.urls]
|
|
27
27
|
Homepage = "https://www.scrapeunblocker.com"
|
|
28
|
-
Documentation = "https://
|
|
28
|
+
Documentation = "https://docs.scrapeunblocker.com"
|
|
29
29
|
Source = "https://github.com/ScrapeUnblocker/scrapeunblocker-haystack"
|
|
30
30
|
Issues = "https://github.com/ScrapeUnblocker/scrapeunblocker-haystack/issues"
|
|
31
31
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from scrapeunblocker_haystack.fetcher import (
|
|
2
|
+
NoDataExtractedError,
|
|
3
|
+
ScrapeUnblockerFetcher,
|
|
4
|
+
StepExecutionError,
|
|
5
|
+
TargetNotFoundError,
|
|
6
|
+
)
|
|
7
|
+
from scrapeunblocker_haystack.search import ScrapeUnblockerWebSearch
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"NoDataExtractedError",
|
|
11
|
+
"ScrapeUnblockerFetcher",
|
|
12
|
+
"ScrapeUnblockerWebSearch",
|
|
13
|
+
"StepExecutionError",
|
|
14
|
+
"TargetNotFoundError",
|
|
15
|
+
]
|
|
@@ -13,6 +13,59 @@ logger = logging.getLogger(__name__)
|
|
|
13
13
|
|
|
14
14
|
DEFAULT_BASE_URL = "https://api.scrapeunblocker.com"
|
|
15
15
|
DEFAULT_TIMEOUT = 180
|
|
16
|
+
_TARGET_GONE = frozenset({404, 410})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TargetNotFoundError(Exception):
|
|
20
|
+
"""Raised when the target page does not exist (the site answered 404/410).
|
|
21
|
+
|
|
22
|
+
This is the website's own answer, not a block or an API failure: the call
|
|
23
|
+
is billed, and retrying returns the same result. ``body`` is the target's
|
|
24
|
+
own not-found page (can be empty).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, url: str, origin_status: int, body: str = "") -> None:
|
|
28
|
+
self.url = url
|
|
29
|
+
self.origin_status = origin_status
|
|
30
|
+
self.body = body
|
|
31
|
+
super().__init__(
|
|
32
|
+
f"Target page does not exist: {url} answered HTTP {origin_status}. "
|
|
33
|
+
"This is the site's own answer, not a block or an API failure; the "
|
|
34
|
+
"call was billed and retrying returns the same result."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class NoDataExtractedError(Exception):
|
|
39
|
+
"""Raised when ``parsed_data`` found no structured data on the page.
|
|
40
|
+
|
|
41
|
+
The API answers this as HTTP 422 ``no_data_extracted``: the page loaded but
|
|
42
|
+
held nothing to extract. It is not billed and carries no HTML; fetch the
|
|
43
|
+
URL without ``parsed_data`` to get the page itself.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, url: str) -> None:
|
|
47
|
+
self.url = url
|
|
48
|
+
super().__init__(
|
|
49
|
+
f"No structured data could be extracted from {url}: the page loaded, but "
|
|
50
|
+
"nothing on it matched a structured shape. The call was not billed; fetch "
|
|
51
|
+
"it without parsed_data to get the HTML."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _target_gone_status(response: requests.Response) -> Optional[int]:
|
|
56
|
+
"""The target's own 404/410 answer, or None.
|
|
57
|
+
|
|
58
|
+
``/getPageSource`` always exists, so a 404/410 from it is the target site's
|
|
59
|
+
answer, sent with ``X-Origin-Status``. Older API versions returned that
|
|
60
|
+
same answer as a 200 carrying the header, so the header is checked first.
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
origin = int(response.headers.get("X-Origin-Status", ""))
|
|
64
|
+
except (TypeError, ValueError):
|
|
65
|
+
origin = None
|
|
66
|
+
if origin in _TARGET_GONE:
|
|
67
|
+
return origin
|
|
68
|
+
return response.status_code if response.status_code in _TARGET_GONE else None
|
|
16
69
|
|
|
17
70
|
|
|
18
71
|
class StepExecutionError(Exception):
|
|
@@ -102,7 +155,8 @@ class ScrapeUnblockerFetcher:
|
|
|
102
155
|
:param base_url: API base URL. Override to target a different environment.
|
|
103
156
|
:param timeout: HTTP timeout in seconds.
|
|
104
157
|
:param raise_on_failure: Raise instead of skipping when a URL cannot be fetched
|
|
105
|
-
(including when a browser step fails
|
|
158
|
+
(including when a browser step fails, the page does not exist, or
|
|
159
|
+
`parsed_data` finds nothing to extract).
|
|
106
160
|
"""
|
|
107
161
|
self.api_key = api_key
|
|
108
162
|
self.parsed_data = parsed_data
|
|
@@ -155,8 +209,15 @@ class ScrapeUnblockerFetcher:
|
|
|
155
209
|
timeout=self.timeout,
|
|
156
210
|
)
|
|
157
211
|
|
|
158
|
-
|
|
212
|
+
gone = _target_gone_status(response)
|
|
213
|
+
if gone is not None:
|
|
214
|
+
raise TargetNotFoundError(url, gone, response.text)
|
|
215
|
+
|
|
216
|
+
# A failed browser step returns 422 with a structured JSON error; so does
|
|
217
|
+
# a parsed_data call that found nothing to extract.
|
|
159
218
|
if response.status_code == 422:
|
|
219
|
+
if self._is_no_data_extracted(response):
|
|
220
|
+
raise NoDataExtractedError(url)
|
|
160
221
|
error = self._parse_step_error(response)
|
|
161
222
|
if error is not None:
|
|
162
223
|
raise error
|
|
@@ -164,6 +225,14 @@ class ScrapeUnblockerFetcher:
|
|
|
164
225
|
response.raise_for_status()
|
|
165
226
|
return response
|
|
166
227
|
|
|
228
|
+
@staticmethod
|
|
229
|
+
def _is_no_data_extracted(response: requests.Response) -> bool:
|
|
230
|
+
try:
|
|
231
|
+
payload = response.json()
|
|
232
|
+
except ValueError:
|
|
233
|
+
return False
|
|
234
|
+
return isinstance(payload, dict) and payload.get("error") == "no_data_extracted"
|
|
235
|
+
|
|
167
236
|
@staticmethod
|
|
168
237
|
def _parse_step_error(response: requests.Response) -> Optional[StepExecutionError]:
|
|
169
238
|
try:
|
|
@@ -184,6 +253,11 @@ class ScrapeUnblockerFetcher:
|
|
|
184
253
|
"""
|
|
185
254
|
Fetch each URL and return one Document per successfully fetched page.
|
|
186
255
|
|
|
256
|
+
A URL whose page does not exist (the site answers 404/410) yields no
|
|
257
|
+
Document; with `raise_on_failure` it raises `TargetNotFoundError`. With
|
|
258
|
+
`parsed_data`, a page holding no structured data yields no Document
|
|
259
|
+
either, or raises `NoDataExtractedError`.
|
|
260
|
+
|
|
187
261
|
:param urls: URLs to fetch.
|
|
188
262
|
:returns: A dictionary with a `documents` key holding the fetched pages.
|
|
189
263
|
"""
|
|
@@ -194,6 +268,12 @@ class ScrapeUnblockerFetcher:
|
|
|
194
268
|
for url in urls:
|
|
195
269
|
try:
|
|
196
270
|
response = self._fetch(url)
|
|
271
|
+
except (TargetNotFoundError, NoDataExtractedError) as exc:
|
|
272
|
+
if self.raise_on_failure:
|
|
273
|
+
raise
|
|
274
|
+
# A page that does not exist, or has nothing to parse, has no content to index.
|
|
275
|
+
logger.warning("ScrapeUnblocker skipped {url}: {error}", url=url, error=str(exc))
|
|
276
|
+
continue
|
|
197
277
|
except Exception as exc:
|
|
198
278
|
if self.raise_on_failure:
|
|
199
279
|
raise
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.2"
|
|
@@ -10,7 +10,9 @@ from haystack.utils import Secret
|
|
|
10
10
|
from scrapeunblocker_haystack import (
|
|
11
11
|
ScrapeUnblockerFetcher,
|
|
12
12
|
ScrapeUnblockerWebSearch,
|
|
13
|
+
NoDataExtractedError,
|
|
13
14
|
StepExecutionError,
|
|
15
|
+
TargetNotFoundError,
|
|
14
16
|
)
|
|
15
17
|
|
|
16
18
|
API_KEY = Secret.from_token("test_key")
|
|
@@ -249,3 +251,91 @@ class TestWebSearch:
|
|
|
249
251
|
restored = ScrapeUnblockerWebSearch.from_dict(data)
|
|
250
252
|
assert restored.top_k == 5
|
|
251
253
|
assert restored.proxy_country == "us"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class TestTargetNotFound:
|
|
257
|
+
NOT_FOUND = "<html><title>404 Not Found</title></html>"
|
|
258
|
+
|
|
259
|
+
def _gone(self, status_code: int, origin: str) -> MagicMock:
|
|
260
|
+
response = _response(text=self.NOT_FOUND, status_code=status_code)
|
|
261
|
+
response.headers = {"Content-Type": "text/html", "X-Origin-Status": origin}
|
|
262
|
+
return response
|
|
263
|
+
|
|
264
|
+
@pytest.mark.parametrize("status", [404, 410])
|
|
265
|
+
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
266
|
+
def test_missing_page_is_skipped(self, mock_post, status):
|
|
267
|
+
mock_post.side_effect = [self._gone(status, str(status)), _response()]
|
|
268
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY)
|
|
269
|
+
|
|
270
|
+
result = fetcher.run(urls=["https://example.com/gone", "https://example.com"])
|
|
271
|
+
|
|
272
|
+
assert [d.meta["url"] for d in result["documents"]] == ["https://example.com"]
|
|
273
|
+
|
|
274
|
+
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
275
|
+
def test_legacy_200_with_origin_404_is_skipped(self, mock_post):
|
|
276
|
+
mock_post.return_value = self._gone(200, "404")
|
|
277
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY)
|
|
278
|
+
|
|
279
|
+
assert fetcher.run(urls=["https://example.com/gone"])["documents"] == []
|
|
280
|
+
|
|
281
|
+
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
282
|
+
def test_missing_page_raises_when_asked(self, mock_post):
|
|
283
|
+
mock_post.return_value = self._gone(410, "410")
|
|
284
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, raise_on_failure=True)
|
|
285
|
+
|
|
286
|
+
with pytest.raises(TargetNotFoundError) as exc:
|
|
287
|
+
fetcher.run(urls=["https://example.com/gone"])
|
|
288
|
+
assert exc.value.origin_status == 410
|
|
289
|
+
assert exc.value.url == "https://example.com/gone"
|
|
290
|
+
assert "404 Not Found" in exc.value.body
|
|
291
|
+
assert "billed" in str(exc.value)
|
|
292
|
+
|
|
293
|
+
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
294
|
+
def test_dead_origin_5xx_page_is_still_a_document(self, mock_post):
|
|
295
|
+
response = _response(text="<html>502 Bad Gateway</html>")
|
|
296
|
+
response.headers = {"Content-Type": "text/html", "X-Origin-Status": "502"}
|
|
297
|
+
mock_post.return_value = response
|
|
298
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY)
|
|
299
|
+
|
|
300
|
+
assert len(fetcher.run(urls=["https://example.com"])["documents"]) == 1
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class TestNoDataExtracted:
|
|
304
|
+
BODY = {
|
|
305
|
+
"error": "no_data_extracted",
|
|
306
|
+
"detail": "The page was rendered, but no structured data could be extracted from it.",
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
310
|
+
def test_empty_parse_is_skipped(self, mock_post):
|
|
311
|
+
mock_post.side_effect = [
|
|
312
|
+
_response(status_code=422, json_data=self.BODY),
|
|
313
|
+
_response(json_data={"data": {"page_type": "product"}}),
|
|
314
|
+
]
|
|
315
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, parsed_data=True)
|
|
316
|
+
|
|
317
|
+
result = fetcher.run(urls=["https://example.com/empty", "https://example.com"])
|
|
318
|
+
|
|
319
|
+
assert [d.meta["url"] for d in result["documents"]] == ["https://example.com"]
|
|
320
|
+
|
|
321
|
+
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
322
|
+
def test_empty_parse_raises_when_asked(self, mock_post):
|
|
323
|
+
mock_post.return_value = _response(status_code=422, json_data=self.BODY)
|
|
324
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, parsed_data=True, raise_on_failure=True)
|
|
325
|
+
|
|
326
|
+
with pytest.raises(NoDataExtractedError) as exc:
|
|
327
|
+
fetcher.run(urls=["https://example.com/empty"])
|
|
328
|
+
assert exc.value.url == "https://example.com/empty"
|
|
329
|
+
assert "not billed" in str(exc.value)
|
|
330
|
+
|
|
331
|
+
@patch("scrapeunblocker_haystack.fetcher.requests.post")
|
|
332
|
+
def test_dead_url_with_parsed_body_is_skipped(self, mock_post):
|
|
333
|
+
response = _response(
|
|
334
|
+
status_code=404, json_data={"data": {"page_type": "not_found", "data": {}}}
|
|
335
|
+
)
|
|
336
|
+
response.text = '{"data":{"page_type":"not_found","data":{}}}'
|
|
337
|
+
response.headers = {"Content-Type": "application/json", "X-Origin-Status": "404"}
|
|
338
|
+
mock_post.return_value = response
|
|
339
|
+
fetcher = ScrapeUnblockerFetcher(api_key=API_KEY, parsed_data=True)
|
|
340
|
+
|
|
341
|
+
assert fetcher.run(urls=["https://example.com/gone"])["documents"] == []
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.0"
|
{scrapeunblocker_haystack-0.2.0 → scrapeunblocker_haystack-0.2.2}/.github/workflows/publish.yml
RENAMED
|
File without changes
|
{scrapeunblocker_haystack-0.2.0 → scrapeunblocker_haystack-0.2.2}/.github/workflows/test.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|