webchanges 3.34.2__tar.gz → 3.35.0__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.
- {webchanges-3.34.2/webchanges.egg-info → webchanges-3.35.0}/PKG-INFO +13 -10
- {webchanges-3.34.2 → webchanges-3.35.0}/README.rst +9 -8
- {webchanges-3.34.2 → webchanges-3.35.0}/pyproject.toml +2 -1
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/__init__.py +1 -1
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/_vendored/headers.py +1 -5
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/command.py +8 -5
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/differs.py +3 -3
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/filters/_html.py +4 -4
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/filters/_shell.py +4 -1
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/handler.py +1 -1
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/jobs/_base.py +6 -3
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/jobs/_browser.py +29 -11
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/jobs/_url.py +343 -46
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/reporters/_base.py +2 -2
- {webchanges-3.34.2 → webchanges-3.35.0/webchanges.egg-info}/PKG-INFO +13 -10
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges.egg-info/requires.txt +4 -1
- {webchanges-3.34.2 → webchanges-3.35.0}/LICENSE.md +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/MANIFEST.in +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/requirements.txt +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/setup.cfg +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/__main__.py +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/_vendored/__init__.py +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/_vendored/packaging_version.py +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/cli.py +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/config.py +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/filters/__init__.py +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/filters/_base.py +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/filters/_document.py +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/filters/_encoding.py +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/filters/_text.py +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/jobs/__init__.py +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/jobs/_exceptions.py +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/jobs/_shell.py +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/mailer.py +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/main.py +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/py.typed +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/reporters/__init__.py +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/reporters/_browser.py +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/reporters/_chat.py +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/reporters/_email.py +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/reporters/_filter.py +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/reporters/_stdout.py +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/reporters/_system.py +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/reporters/_web.py +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/storage/__init__.py +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/storage/_base.py +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/storage/_config.py +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/storage/_minidb.py +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/storage/_redis.py +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/storage/_sqlite3.py +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/storage/_ssdb.py +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/storage/_yaml.py +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/storage_minidb.py +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/util.py +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/worker.py +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges.egg-info/SOURCES.txt +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges.egg-info/dependency_links.txt +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges.egg-info/entry_points.txt +0 -0
- {webchanges-3.34.2 → webchanges-3.35.0}/webchanges.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: webchanges
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.35.0
|
|
4
4
|
Summary: Web Changes Delivered. AI-Summarized. Totally Anonymous.
|
|
5
5
|
Author-email: Mike Borsetti <mike+webchanges@borsetti.com>
|
|
6
6
|
Maintainer-email: Mike Borsetti <mike+webchanges@borsetti.com>
|
|
@@ -89,6 +89,8 @@ Provides-Extra: pushover
|
|
|
89
89
|
Requires-Dist: chump; extra == "pushover"
|
|
90
90
|
Provides-Extra: xmpp
|
|
91
91
|
Requires-Dist: aioxmpp; extra == "xmpp"
|
|
92
|
+
Provides-Extra: curl-cffi
|
|
93
|
+
Requires-Dist: curl_cffi; extra == "curl-cffi"
|
|
92
94
|
Provides-Extra: redis
|
|
93
95
|
Requires-Dist: redis; extra == "redis"
|
|
94
96
|
Provides-Extra: requests
|
|
@@ -96,7 +98,7 @@ Requires-Dist: requests; extra == "requests"
|
|
|
96
98
|
Provides-Extra: safe-password
|
|
97
99
|
Requires-Dist: keyring; extra == "safe-password"
|
|
98
100
|
Provides-Extra: all
|
|
99
|
-
Requires-Dist: webchanges[beautify,bs4,deepdiff_xml,html5lib,ical2text,imagediff,jq,matrix,ocr,pdf2text,pushbullet,pushover,pypdf_crypto,redis,requests,safe_password,use_browser,xmpp]; extra == "all"
|
|
101
|
+
Requires-Dist: webchanges[beautify,bs4,curl_cffi,deepdiff_xml,html5lib,ical2text,imagediff,jq,matrix,ocr,pdf2text,pushbullet,pushover,pypdf_crypto,redis,requests,safe_password,use_browser,xmpp]; extra == "all"
|
|
100
102
|
Dynamic: license-file
|
|
101
103
|
|
|
102
104
|
.. role:: underline
|
|
@@ -139,7 +141,7 @@ Install **webchanges** with ``uv`` (recommended):
|
|
|
139
141
|
.. code-block:: bash
|
|
140
142
|
|
|
141
143
|
uv pip install webchanges
|
|
142
|
-
|
|
144
|
+
|
|
143
145
|
or with ``pip``:
|
|
144
146
|
|
|
145
147
|
.. code-block:: bash
|
|
@@ -154,13 +156,14 @@ implementation (no browser) `here <https://github.com/yubiuser/webchanges-docker
|
|
|
154
156
|
|
|
155
157
|
As a GitHub Action
|
|
156
158
|
------------------
|
|
157
|
-
**webchanges** can easily run as a `GitHub Action <https://www.docker.com/>`__! You will find an implementation
|
|
159
|
+
**webchanges** can easily run as a `GitHub Action <https://www.docker.com/>`__! You will find an implementation
|
|
158
160
|
`here <https://github.com/swimmwatch/webchanges-action>`__.
|
|
159
161
|
|
|
160
162
|
|
|
161
163
|
Documentation |readthedocs|
|
|
162
164
|
===========================
|
|
163
|
-
The documentation is hosted on `Read the Docs <https://webchanges.readthedocs.io/>`__.
|
|
165
|
+
The documentation is hosted on `Read the Docs <https://webchanges.readthedocs.io/>`__. A (very good) AI-generated
|
|
166
|
+
high-level architectural overview is available on `DeepWiki <https://deepwiki.com/mborsetti/webchanges>`__.
|
|
164
167
|
|
|
165
168
|
|
|
166
169
|
Quick Start
|
|
@@ -208,7 +211,7 @@ Schedule
|
|
|
208
211
|
|
|
209
212
|
Code
|
|
210
213
|
====
|
|
211
|
-
|coveralls| |
|
|
214
|
+
|coveralls| |issues| |code_style| |OpenSSF_Scorecard|
|
|
212
215
|
|
|
213
216
|
The code, issues tracker, and discussions are hosted on `GitHub <https://github.com/mborsetti/webchanges>`__.
|
|
214
217
|
|
|
@@ -240,7 +243,7 @@ Compatibility with **urlwatch**
|
|
|
240
243
|
This project is based on code from `urlwatch 2.21
|
|
241
244
|
<https://github.com/thp/urlwatch/tree/346b25914b0418342ffe2fb0529bed702fddc01f>`__ dated 30 July 2020.
|
|
242
245
|
For upgrading from **urlwatch** to **webchanges** (using the same job and
|
|
243
|
-
configuration files), see `here <https://webchanges.readthedocs.io/en/stable/upgrading.html>`__.
|
|
246
|
+
configuration files), see `here <https://webchanges.readthedocs.io/en/stable/upgrading.html>`__.
|
|
244
247
|
|
|
245
248
|
|
|
246
249
|
.. |support| image:: https://img.shields.io/pypi/pyversions/webchanges.svg
|
|
@@ -285,9 +288,9 @@ configuration files), see `here <https://webchanges.readthedocs.io/en/stable/upg
|
|
|
285
288
|
.. .. |security| image:: https://img.shields.io/badge/security-bandit-green.svg
|
|
286
289
|
.. :target: https://github.com/PyCQA/bandit
|
|
287
290
|
.. :alt: Security Status
|
|
288
|
-
.. |snyk| image:: https://snyk.io/advisor/python/
|
|
289
|
-
|
|
290
|
-
|
|
291
|
+
.. .. |snyk| image:: https://snyk.io/advisor/python/webchanges/badge.svg
|
|
292
|
+
.. :target: https://security.snyk.io/package/pip/webchanges
|
|
293
|
+
.. :alt: Snyk Package Health Score
|
|
291
294
|
.. |OpenSSF_Scorecard| image:: https://api.scorecard.dev/projects/github.com/mborsetti/webchanges/badge
|
|
292
295
|
:target: https://scorecard.dev/viewer/?uri=github.com/mborsetti/webchanges
|
|
293
296
|
:alt: OpenSSF Scoreard
|
|
@@ -38,7 +38,7 @@ Install **webchanges** with ``uv`` (recommended):
|
|
|
38
38
|
.. code-block:: bash
|
|
39
39
|
|
|
40
40
|
uv pip install webchanges
|
|
41
|
-
|
|
41
|
+
|
|
42
42
|
or with ``pip``:
|
|
43
43
|
|
|
44
44
|
.. code-block:: bash
|
|
@@ -53,13 +53,14 @@ implementation (no browser) `here <https://github.com/yubiuser/webchanges-docker
|
|
|
53
53
|
|
|
54
54
|
As a GitHub Action
|
|
55
55
|
------------------
|
|
56
|
-
**webchanges** can easily run as a `GitHub Action <https://www.docker.com/>`__! You will find an implementation
|
|
56
|
+
**webchanges** can easily run as a `GitHub Action <https://www.docker.com/>`__! You will find an implementation
|
|
57
57
|
`here <https://github.com/swimmwatch/webchanges-action>`__.
|
|
58
58
|
|
|
59
59
|
|
|
60
60
|
Documentation |readthedocs|
|
|
61
61
|
===========================
|
|
62
|
-
The documentation is hosted on `Read the Docs <https://webchanges.readthedocs.io/>`__.
|
|
62
|
+
The documentation is hosted on `Read the Docs <https://webchanges.readthedocs.io/>`__. A (very good) AI-generated
|
|
63
|
+
high-level architectural overview is available on `DeepWiki <https://deepwiki.com/mborsetti/webchanges>`__.
|
|
63
64
|
|
|
64
65
|
|
|
65
66
|
Quick Start
|
|
@@ -107,7 +108,7 @@ Schedule
|
|
|
107
108
|
|
|
108
109
|
Code
|
|
109
110
|
====
|
|
110
|
-
|coveralls| |
|
|
111
|
+
|coveralls| |issues| |code_style| |OpenSSF_Scorecard|
|
|
111
112
|
|
|
112
113
|
The code, issues tracker, and discussions are hosted on `GitHub <https://github.com/mborsetti/webchanges>`__.
|
|
113
114
|
|
|
@@ -139,7 +140,7 @@ Compatibility with **urlwatch**
|
|
|
139
140
|
This project is based on code from `urlwatch 2.21
|
|
140
141
|
<https://github.com/thp/urlwatch/tree/346b25914b0418342ffe2fb0529bed702fddc01f>`__ dated 30 July 2020.
|
|
141
142
|
For upgrading from **urlwatch** to **webchanges** (using the same job and
|
|
142
|
-
configuration files), see `here <https://webchanges.readthedocs.io/en/stable/upgrading.html>`__.
|
|
143
|
+
configuration files), see `here <https://webchanges.readthedocs.io/en/stable/upgrading.html>`__.
|
|
143
144
|
|
|
144
145
|
|
|
145
146
|
.. |support| image:: https://img.shields.io/pypi/pyversions/webchanges.svg
|
|
@@ -184,9 +185,9 @@ configuration files), see `here <https://webchanges.readthedocs.io/en/stable/upg
|
|
|
184
185
|
.. .. |security| image:: https://img.shields.io/badge/security-bandit-green.svg
|
|
185
186
|
.. :target: https://github.com/PyCQA/bandit
|
|
186
187
|
.. :alt: Security Status
|
|
187
|
-
.. |snyk| image:: https://snyk.io/advisor/python/
|
|
188
|
-
|
|
189
|
-
|
|
188
|
+
.. .. |snyk| image:: https://snyk.io/advisor/python/webchanges/badge.svg
|
|
189
|
+
.. :target: https://security.snyk.io/package/pip/webchanges
|
|
190
|
+
.. :alt: Snyk Package Health Score
|
|
190
191
|
.. |OpenSSF_Scorecard| image:: https://api.scorecard.dev/projects/github.com/mborsetti/webchanges/badge
|
|
191
192
|
:target: https://scorecard.dev/viewer/?uri=github.com/mborsetti/webchanges
|
|
192
193
|
:alt: OpenSSF Scoreard
|
|
@@ -85,12 +85,13 @@ pushbullet = ['pushbullet.py']
|
|
|
85
85
|
pushover = ['chump']
|
|
86
86
|
xmpp = ['aioxmpp']
|
|
87
87
|
# other
|
|
88
|
+
curl_cffi = ['curl_cffi']
|
|
88
89
|
redis = ['redis']
|
|
89
90
|
requests = ['requests']
|
|
90
91
|
safe_password = ['keyring']
|
|
91
92
|
# all
|
|
92
93
|
all = [
|
|
93
|
-
'webchanges[use_browser,beautify,bs4,html5lib,ical2text,jq,ocr,pdf2text,pypdf_crypto,deepdiff_xml,imagediff,matrix,pushbullet,pushover,xmpp,redis,requests,safe_password]',
|
|
94
|
+
'webchanges[use_browser,beautify,bs4,html5lib,ical2text,jq,ocr,pdf2text,pypdf_crypto,deepdiff_xml,imagediff,matrix,pushbullet,pushover,xmpp,curl_cffi,redis,requests,safe_password]',
|
|
94
95
|
]
|
|
95
96
|
|
|
96
97
|
|
|
@@ -22,7 +22,7 @@ __project_name__ = str(__package__)
|
|
|
22
22
|
# * MINOR version when you add functionality in a backwards compatible manner, and
|
|
23
23
|
# * MICRO or PATCH version when you make backwards compatible bug fixes. We no longer use '0'
|
|
24
24
|
# If unsure on increments, use pkg_resources.parse_version to parse
|
|
25
|
-
__version__ = '3.
|
|
25
|
+
__version__ = '3.35.0'
|
|
26
26
|
__description__ = (
|
|
27
27
|
'Check web (or command output) for changes since last run and notify.\n\nAnonymously alerts you of web changes.'
|
|
28
28
|
)
|
|
@@ -36,11 +36,7 @@ from collections.abc import Mapping
|
|
|
36
36
|
|
|
37
37
|
# from https://github.com/encode/httpx/blob/master/httpx/_types.py
|
|
38
38
|
HeaderTypes: typing.TypeAlias = (
|
|
39
|
-
'Headers'
|
|
40
|
-
| typing.Mapping[str, str]
|
|
41
|
-
| typing.Mapping[bytes, bytes]
|
|
42
|
-
| typing.Sequence[tuple[str, str]]
|
|
43
|
-
| typing.Sequence[tuple[bytes, bytes]]
|
|
39
|
+
'Headers | typing.Mapping[str, str] | typing.Mapping[bytes, bytes] | typing.Sequence[tuple[str, str]] | typing.Sequence[tuple[bytes, bytes]]'
|
|
44
40
|
)
|
|
45
41
|
|
|
46
42
|
|
|
@@ -195,6 +195,7 @@ class UrlwatchCommand:
|
|
|
195
195
|
'cryptography',
|
|
196
196
|
'cssbeautifier',
|
|
197
197
|
'cssselect',
|
|
198
|
+
'curl_cffi',
|
|
198
199
|
'deepdiff',
|
|
199
200
|
'h2',
|
|
200
201
|
'html2text',
|
|
@@ -512,6 +513,8 @@ class UrlwatchCommand:
|
|
|
512
513
|
return
|
|
513
514
|
self.urlwatcher.urlwatch_config.joblist = list(self.urlwatcher.urlwatch_config.joblist) + new_jobs
|
|
514
515
|
self.urlwatcher.run_jobs()
|
|
516
|
+
if self.urlwatcher.report.config['display']['new']:
|
|
517
|
+
self.urlwatcher.report.finish(self.urlwatcher.jobs_storage.filename)
|
|
515
518
|
return
|
|
516
519
|
|
|
517
520
|
def test_differ(self, arg_test_differ: list[str]) -> int:
|
|
@@ -713,12 +716,12 @@ class UrlwatchCommand:
|
|
|
713
716
|
jobs_to_run = [job for job in jobs if not job.__is_browser__]
|
|
714
717
|
if jobs_to_run:
|
|
715
718
|
logger.debug(
|
|
716
|
-
"Running jobs that do not require
|
|
719
|
+
"Running jobs that do not require a browser (without 'use_browser') in parallel with "
|
|
717
720
|
"Python's default max_workers."
|
|
718
721
|
)
|
|
719
722
|
yield from job_runner(stack, jobs_to_run, self.urlwatch_config.max_workers)
|
|
720
723
|
else:
|
|
721
|
-
logger.debug("Found no jobs that do not require
|
|
724
|
+
logger.debug("Found no jobs that do not require a browser (i.e. without 'use_browser').")
|
|
722
725
|
|
|
723
726
|
# run BrowserJob jobs after
|
|
724
727
|
jobs_to_run = [job for job in jobs if job.__is_browser__]
|
|
@@ -732,12 +735,12 @@ class UrlwatchCommand:
|
|
|
732
735
|
max_workers = max(int(virt_mem / 800), 1)
|
|
733
736
|
max_workers = min(max_workers, os.cpu_count() or 1)
|
|
734
737
|
logger.debug(
|
|
735
|
-
f"Running jobs that require
|
|
738
|
+
f"Running jobs that require a browser (i.e. with 'use_browser') in parallel with "
|
|
736
739
|
f'{max_workers} max_workers.'
|
|
737
740
|
)
|
|
738
741
|
yield from job_runner(stack, jobs_to_run, max_workers)
|
|
739
742
|
else:
|
|
740
|
-
logger.debug("Found no jobs that require
|
|
743
|
+
logger.debug("Found no jobs that require a browser (i.e. with 'use_browser').")
|
|
741
744
|
|
|
742
745
|
start = time.perf_counter()
|
|
743
746
|
|
|
@@ -1071,7 +1074,7 @@ class UrlwatchCommand:
|
|
|
1071
1074
|
cfg: _ConfigReportersList = self.urlwatcher.config_storage.config['report'][reporter_name] # ty:ignore[invalid-key]
|
|
1072
1075
|
if job_state: # we want a full report
|
|
1073
1076
|
cfg['enabled'] = True
|
|
1074
|
-
self.urlwatcher.config_storage.config['display'][label] = True
|
|
1077
|
+
self.urlwatcher.config_storage.config['display'][label] = True # ty:ignore[invalid-key]
|
|
1075
1078
|
self.urlwatcher.config_storage.config['report']['text']['details'] = True
|
|
1076
1079
|
self.urlwatcher.config_storage.config['report']['text']['footer'] = True
|
|
1077
1080
|
self.urlwatcher.config_storage.config['report']['text']['minimal'] = False
|
|
@@ -1735,9 +1735,9 @@ class AIGoogleDiffer(DifferBase):
|
|
|
1735
1735
|
if directives.get('tools'):
|
|
1736
1736
|
data['tools'] = directives['tools']
|
|
1737
1737
|
if directives.get('thinking_level'):
|
|
1738
|
-
data['generationConfig'].update({'thinkingConfig': {'thinkingLevel': directives['thinking_level']}})
|
|
1738
|
+
data['generationConfig'].update({'thinkingConfig': {'thinkingLevel': directives['thinking_level']}}) # ty:ignore[no-matching-overload]
|
|
1739
1739
|
elif directives.get('thinking_budget'):
|
|
1740
|
-
data['generationConfig'].update({'thinkingConfig': {'thinkingBudget': directives['thinking_budget']}})
|
|
1740
|
+
data['generationConfig'].update({'thinkingConfig': {'thinkingBudget': directives['thinking_budget']}}) # ty:ignore[no-matching-overload]
|
|
1741
1741
|
logger.info(f'Job {job.index_number}: Making the content generation request to Google AI model {model}')
|
|
1742
1742
|
model_version = model # default
|
|
1743
1743
|
with httpx.Client(http2=h2 is not None) as http_client:
|
|
@@ -1806,7 +1806,7 @@ class AIGoogleDiffer(DifferBase):
|
|
|
1806
1806
|
report_kind: ReportKind,
|
|
1807
1807
|
_unfiltered_diff: dict[ReportKind, str] | None = None,
|
|
1808
1808
|
tz: ZoneInfo | None = None,
|
|
1809
|
-
) -> dict[ReportKind, str]:
|
|
1809
|
+
) -> dict[ReportKind, str]: # ty:ignore[invalid-method-override]
|
|
1810
1810
|
logger.info(f'Job {self.job.index_number}: Running the {self.__kind__} differ from hooks.py')
|
|
1811
1811
|
# warnings.warn(
|
|
1812
1812
|
# f'Job {self.job.index_number}: Using differ {self.__kind__}, which is BETA, may have bugs, and may '
|
|
@@ -529,12 +529,12 @@ class LxmlParser:
|
|
|
529
529
|
if isinstance(element, etree._ElementUnicodeResult):
|
|
530
530
|
parent = element.getparent()
|
|
531
531
|
if (
|
|
532
|
-
(element.is_tail and parent.tail is None)
|
|
533
|
-
or (element.is_text and parent.text is None)
|
|
534
|
-
or (element.is_attribute and parent.attrib.get(element.attrname) is None)
|
|
532
|
+
(element.is_tail and parent.tail is None) # ty:ignore[unresolved-attribute]
|
|
533
|
+
or (element.is_text and parent.text is None) # ty:ignore[unresolved-attribute]
|
|
534
|
+
or (element.is_attribute and parent.attrib.get(element.attrname) is None) # ty:ignore[unresolved-attribute]
|
|
535
535
|
):
|
|
536
536
|
return True
|
|
537
|
-
element = parent
|
|
537
|
+
element = parent # ty:ignore[invalid-assignment]
|
|
538
538
|
try:
|
|
539
539
|
tree = element.getroottree()
|
|
540
540
|
path = tree.getpath(element)
|
|
@@ -60,7 +60,10 @@ def _pipe_filter(f_cls: FilterBase, data: str | bytes, subfilter: dict[str, Any]
|
|
|
60
60
|
env=env,
|
|
61
61
|
).stdout
|
|
62
62
|
except subprocess.CalledProcessError as e:
|
|
63
|
-
logger.error(
|
|
63
|
+
logger.error(
|
|
64
|
+
f"The '{f_cls.__kind__}' filter returned error code {e.returncode} ({f_cls.job.get_indexed_location()}):\n"
|
|
65
|
+
f'{e.stderr}\n---\n{e.stdout}'
|
|
66
|
+
)
|
|
64
67
|
raise e
|
|
65
68
|
except FileNotFoundError as e:
|
|
66
69
|
logger.error(f"The '{f_cls.__kind__}' filter returned error ({f_cls.job.get_indexed_location()}):\n{e}")
|
|
@@ -65,7 +65,7 @@ ErrorData = TypedDict('ErrorData', {'type': str, 'message': str}, total=False)
|
|
|
65
65
|
class JobState(ContextManager):
|
|
66
66
|
"""The JobState class, which contains run information about a job."""
|
|
67
67
|
|
|
68
|
-
_http_client_used:
|
|
68
|
+
_http_client_used: Literal['httpx', 'requests', 'curl_cffi', 'playwright'] | None = None
|
|
69
69
|
error_ignored: bool
|
|
70
70
|
exception: Exception | None = None
|
|
71
71
|
generated_diff: dict[ReportKind, str]
|
|
@@ -78,7 +78,7 @@ class JobBase(metaclass=TrackSubClasses):
|
|
|
78
78
|
# __required__ in derived classes
|
|
79
79
|
url: str = ''
|
|
80
80
|
command: str = ''
|
|
81
|
-
use_browser: bool | None = False
|
|
81
|
+
use_browser: bool | str | None = False
|
|
82
82
|
|
|
83
83
|
# __optional__ in derived classes
|
|
84
84
|
_delay: float | None = None # TODO: WIP experiment
|
|
@@ -98,9 +98,11 @@ class JobBase(metaclass=TrackSubClasses):
|
|
|
98
98
|
encoding: str | None = None # UrlJobBase
|
|
99
99
|
evaluate: str | None = None # BrowserJob
|
|
100
100
|
filters: FiltersList | list[FiltersList | dict[str, Any]] | None = None
|
|
101
|
+
fingerprints: dict[str, str | dict[str, Any]] | None = None # UrlJob (curl_cffi backend only)
|
|
101
102
|
guid: str = ''
|
|
102
103
|
headers = Headers(encoding='utf-8') # UrlJobBase
|
|
103
|
-
http_client: Literal['httpx', 'requests'] | None = None # UrlJob
|
|
104
|
+
http_client: Literal['httpx', 'requests', 'curl_cffi'] | None = None # UrlJob
|
|
105
|
+
http_version: Literal['v1', 'v2', 'v2tls', 'v2_prior_knowledge', 'v3', 'v3only'] | None = None # UrlJob
|
|
104
106
|
http_credentials: str | None = None # BrowserJob
|
|
105
107
|
ignore_cached: bool | None = None # UrlJobBase
|
|
106
108
|
ignore_connection_errors: bool | None = None # UrlJobBase
|
|
@@ -110,9 +112,10 @@ class JobBase(metaclass=TrackSubClasses):
|
|
|
110
112
|
ignore_https_errors: bool | None = None # BrowserJob
|
|
111
113
|
ignore_timeout_errors: bool | None = None # UrlJobBase
|
|
112
114
|
ignore_too_many_redirects: bool | None = None # UrlJobBase
|
|
115
|
+
impersonate: str | None = None # UrlJob (curl_cffi backend only)
|
|
113
116
|
init_script: str | None = None # BrowserJob
|
|
114
117
|
initialization_js: str | None = None # BrowserJob
|
|
115
|
-
initialization_url: str | None = None # BrowserJob
|
|
118
|
+
initialization_url: str | None = None # UrlJob, BrowserJob
|
|
116
119
|
is_markdown: bool | None = None
|
|
117
120
|
kind: str | None = None # hooks.py
|
|
118
121
|
loop: asyncio.AbstractEventLoop | None = None
|
|
@@ -406,12 +406,27 @@ class BrowserJob(UrlJobBase):
|
|
|
406
406
|
with ExitStack() as stack:
|
|
407
407
|
p = stack.enter_context(sync_playwright())
|
|
408
408
|
executable_path = os.getenv('WEBCHANGES_BROWSER_PATH')
|
|
409
|
-
|
|
410
|
-
|
|
409
|
+
value = self.use_browser if isinstance(self.use_browser, str) else 'chrome'
|
|
410
|
+
if value.startswith(('chrome', 'msedge')):
|
|
411
|
+
browser_type = p.chromium
|
|
412
|
+
channel = None if executable_path else value
|
|
413
|
+
elif value == 'firefox':
|
|
414
|
+
browser_type = p.firefox
|
|
415
|
+
channel = None
|
|
416
|
+
elif value == 'webkit':
|
|
417
|
+
browser_type = p.webkit
|
|
418
|
+
channel = None
|
|
419
|
+
else:
|
|
420
|
+
raise ValueError(
|
|
421
|
+
f"Job {job_state.job.index_number}: Directive 'use_browser' value {value!r} must be "
|
|
422
|
+
f"'firefox', 'webkit', or start with 'chrome' or 'msedge' "
|
|
423
|
+
f'( {self.get_indexed_location()} ).'
|
|
424
|
+
)
|
|
425
|
+
browser_name = executable_path or value
|
|
411
426
|
no_viewport = False if not self.switches else any('--window-size' in switch for switch in self.switches)
|
|
412
427
|
if not self.user_data_dir:
|
|
413
428
|
browser = stack.enter_context(
|
|
414
|
-
|
|
429
|
+
browser_type.launch(
|
|
415
430
|
executable_path=executable_path,
|
|
416
431
|
channel=channel,
|
|
417
432
|
args=args,
|
|
@@ -422,11 +437,14 @@ class BrowserJob(UrlJobBase):
|
|
|
422
437
|
)
|
|
423
438
|
)
|
|
424
439
|
browser_version = browser.version
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
440
|
+
if browser_type is p.chromium:
|
|
441
|
+
default_user_agent = (
|
|
442
|
+
f'Mozilla/5.0 ({self.get_user_agent_platform()}) AppleWebKit/537.36 (KHTML, like Gecko) '
|
|
443
|
+
f'Chrome/{browser_version.split(".", maxsplit=1)[0]}.0.0.0 Safari/537.36'
|
|
444
|
+
)
|
|
445
|
+
user_agent = headers.pop('User-Agent', default_user_agent)
|
|
446
|
+
else:
|
|
447
|
+
user_agent = headers.pop('User-Agent', None)
|
|
430
448
|
context = stack.enter_context(
|
|
431
449
|
browser.new_context(
|
|
432
450
|
no_viewport=no_viewport,
|
|
@@ -445,7 +463,7 @@ class BrowserJob(UrlJobBase):
|
|
|
445
463
|
user_agent = headers.pop('User-Agent', '')
|
|
446
464
|
|
|
447
465
|
context = stack.enter_context(
|
|
448
|
-
|
|
466
|
+
browser_type.launch_persistent_context(
|
|
449
467
|
user_data_dir=self.user_data_dir,
|
|
450
468
|
channel=channel,
|
|
451
469
|
executable_path=executable_path,
|
|
@@ -607,10 +625,10 @@ class BrowserJob(UrlJobBase):
|
|
|
607
625
|
try:
|
|
608
626
|
if return_data is not None:
|
|
609
627
|
logger.info(f"Job {self.index_number}: Using the 'return_data' Callable")
|
|
610
|
-
return return_data(page, url, self.wait_until, self.referer)
|
|
628
|
+
return return_data(page, url, self.wait_until, self.referer) # ty:ignore[invalid-argument-type]
|
|
611
629
|
if response_handler is not None:
|
|
612
630
|
logger.info(f"Job {self.index_number}: Using the 'response_handler' Callable")
|
|
613
|
-
response = response_handler(page, url, self.wait_until, self.referer)
|
|
631
|
+
response = response_handler(page, url, self.wait_until, self.referer) # ty:ignore[invalid-argument-type]
|
|
614
632
|
else:
|
|
615
633
|
response = page.goto(url, wait_until=self.wait_until, referer=self.referer)
|
|
616
634
|
|
|
@@ -13,8 +13,8 @@ import sys
|
|
|
13
13
|
import time
|
|
14
14
|
from ftplib import FTP
|
|
15
15
|
from pathlib import Path
|
|
16
|
-
from typing import TYPE_CHECKING, Mapping, Sequence
|
|
17
|
-
from urllib.parse import urlencode, urlparse, urlsplit
|
|
16
|
+
from typing import TYPE_CHECKING, Any, Mapping, Sequence
|
|
17
|
+
from urllib.parse import parse_qsl, urlencode, urlparse, urlsplit
|
|
18
18
|
|
|
19
19
|
import html2text
|
|
20
20
|
|
|
@@ -45,8 +45,24 @@ except ImportError as e: # pragma: no cover
|
|
|
45
45
|
requests = str(e) # ty:ignore[invalid-assignment]
|
|
46
46
|
urllib3 = str(e) # ty:ignore[invalid-assignment]
|
|
47
47
|
|
|
48
|
+
try:
|
|
49
|
+
from curl_cffi import requests as curl_cffi_requests
|
|
50
|
+
from curl_cffi.requests import exceptions as curl_cffi_exceptions
|
|
51
|
+
except ImportError as e: # pragma: no cover
|
|
52
|
+
curl_cffi_requests = str(e) # ty:ignore[invalid-assignment]
|
|
53
|
+
curl_cffi_exceptions = str(e) # ty:ignore[invalid-assignment]
|
|
54
|
+
|
|
48
55
|
logger = logging.getLogger(__name__)
|
|
49
56
|
|
|
57
|
+
# HTTP-version directive mappings.
|
|
58
|
+
# httpx supports only HTTP/1.1 and HTTP/2; curl_cffi (via libcurl) supports the full set.
|
|
59
|
+
_HTTPX_HTTP_VERSIONS: dict[str, tuple[bool, bool]] = {
|
|
60
|
+
# value -> (http1, http2)
|
|
61
|
+
'v1': (True, False),
|
|
62
|
+
'v2': (True, True),
|
|
63
|
+
}
|
|
64
|
+
_CURL_CFFI_HTTP_VERSIONS: tuple[str, ...] = ('v1', 'v2', 'v2tls', 'v2_prior_knowledge', 'v3', 'v3only')
|
|
65
|
+
|
|
50
66
|
|
|
51
67
|
class UrlJob(UrlJobBase):
|
|
52
68
|
"""Retrieve a URL from a web server."""
|
|
@@ -55,8 +71,12 @@ class UrlJob(UrlJobBase):
|
|
|
55
71
|
|
|
56
72
|
__optional__: tuple[str, ...] = (
|
|
57
73
|
'empty_as_transient',
|
|
74
|
+
'fingerprints',
|
|
58
75
|
'http_client',
|
|
76
|
+
'http_version',
|
|
59
77
|
'ignore_dh_key_too_small',
|
|
78
|
+
'impersonate',
|
|
79
|
+
'initialization_url',
|
|
60
80
|
'no_redirects',
|
|
61
81
|
'retries',
|
|
62
82
|
'ssl_no_verify',
|
|
@@ -77,6 +97,26 @@ class UrlJob(UrlJobBase):
|
|
|
77
97
|
self.url = location
|
|
78
98
|
self.guid = self.get_guid()
|
|
79
99
|
|
|
100
|
+
def _resolve_initialization_url(self, final_url: str) -> str:
|
|
101
|
+
"""Resolves the main URL by substituting parameters extracted from the initialization URL's final redirect.
|
|
102
|
+
|
|
103
|
+
Mirrors the behavior of ``initialization_url`` in BrowserJob: parses the URL params (;-separated) from the
|
|
104
|
+
final redirected URL and substitutes them into ``self.url`` via str.format().
|
|
105
|
+
|
|
106
|
+
:param final_url: The URL after the initialization request completed (after redirects).
|
|
107
|
+
:returns: The resolved URL to use for the main request.
|
|
108
|
+
"""
|
|
109
|
+
init_url_params = dict(parse_qsl(urlparse(final_url).params))
|
|
110
|
+
try:
|
|
111
|
+
url = self.url.format(**init_url_params)
|
|
112
|
+
except KeyError as e:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"Job {self.index_number}: Directive 'initialization_url' did not find key {e} to substitute in 'url'."
|
|
115
|
+
) from None
|
|
116
|
+
if url != self.url:
|
|
117
|
+
logger.info(f'Job {self.index_number}: URL updated to {url}')
|
|
118
|
+
return url
|
|
119
|
+
|
|
80
120
|
def _retrieve_httpx(
|
|
81
121
|
self,
|
|
82
122
|
headers: (
|
|
@@ -89,19 +129,43 @@ class UrlJob(UrlJobBase):
|
|
|
89
129
|
:return: The data retrieved and the ETag.
|
|
90
130
|
:raises NotModifiedError: If an HTTP 304 response is received.
|
|
91
131
|
"""
|
|
92
|
-
|
|
93
|
-
if
|
|
94
|
-
|
|
95
|
-
|
|
132
|
+
http1: bool = True
|
|
133
|
+
if self.http_version is not None:
|
|
134
|
+
if self.http_version not in _HTTPX_HTTP_VERSIONS:
|
|
135
|
+
raise ValueError(
|
|
136
|
+
f"Job {self.index_number}: 'http_version: {self.http_version}' is not supported by the httpx "
|
|
137
|
+
f"HTTP client; httpx only supports 'v1' and 'v2'. Use 'http_client: curl_cffi' for other "
|
|
138
|
+
f'HTTP versions ( {self.get_indexed_location()} ).'
|
|
139
|
+
)
|
|
140
|
+
http1, http2 = _HTTPX_HTTP_VERSIONS[self.http_version]
|
|
141
|
+
if http2 and h2 is None:
|
|
142
|
+
raise ImportError(
|
|
143
|
+
f"Job {self.index_number}: 'http_version: v2' requires the 'h2' package, which is not installed. "
|
|
144
|
+
f"Please install it using e.g. 'pip install h2' ( {self.get_indexed_location()} )."
|
|
145
|
+
)
|
|
96
146
|
logger.info(
|
|
97
|
-
f'Job {self.index_number}: Using the HTTPX HTTP client library
|
|
98
|
-
f'h2 is not installed)'
|
|
147
|
+
f'Job {self.index_number}: Using the HTTPX HTTP client library with http_version={self.http_version}'
|
|
99
148
|
)
|
|
149
|
+
else:
|
|
150
|
+
http2: bool = h2 is not None
|
|
151
|
+
if http2:
|
|
152
|
+
logger.info(f'Job {self.index_number}: Using the HTTPX HTTP client library with HTTP/2 support')
|
|
153
|
+
else:
|
|
154
|
+
logger.info(
|
|
155
|
+
f'Job {self.index_number}: Using the HTTPX HTTP client library (HTTP/2 support is not available '
|
|
156
|
+
f'since h2 is not installed)'
|
|
157
|
+
)
|
|
100
158
|
|
|
101
159
|
proxy = self.get_proxy()
|
|
102
160
|
if proxy is not None:
|
|
103
161
|
logger.debug(f'Job {self.index_number}: Proxy: {proxy}')
|
|
104
162
|
|
|
163
|
+
if self.fingerprints:
|
|
164
|
+
logger.warning(
|
|
165
|
+
f"Job {self.index_number}: 'fingerprints' is only supported with 'http_client: curl_cffi'; "
|
|
166
|
+
f'ignoring the directive.'
|
|
167
|
+
)
|
|
168
|
+
|
|
105
169
|
if self.ignore_dh_key_too_small:
|
|
106
170
|
logger.debug(
|
|
107
171
|
'Setting default cipher list to ciphers that do not make any use of Diffie Hellman Key Exchange '
|
|
@@ -116,15 +180,22 @@ class UrlJob(UrlJobBase):
|
|
|
116
180
|
headers=headers,
|
|
117
181
|
cookies=self.cookies,
|
|
118
182
|
verify=context,
|
|
183
|
+
http1=http1,
|
|
119
184
|
http2=http2,
|
|
120
185
|
proxy=proxy,
|
|
121
186
|
timeout=timeout,
|
|
122
187
|
follow_redirects=(not self.no_redirects),
|
|
123
188
|
) as http_client:
|
|
189
|
+
url = self.url
|
|
190
|
+
if self.initialization_url:
|
|
191
|
+
logger.info(f'Job {self.index_number}: Initializing by navigating to {self.initialization_url}')
|
|
192
|
+
init_response = http_client.get(self.initialization_url)
|
|
193
|
+
url = self._resolve_initialization_url(str(init_response.url))
|
|
194
|
+
|
|
124
195
|
try:
|
|
125
196
|
response = http_client.request(
|
|
126
197
|
method=self.method, # ty:ignore[invalid-argument-type]
|
|
127
|
-
url=
|
|
198
|
+
url=url,
|
|
128
199
|
data=self.data, # ty:ignore[invalid-argument-type]
|
|
129
200
|
params=self.params,
|
|
130
201
|
)
|
|
@@ -148,15 +219,19 @@ class UrlJob(UrlJobBase):
|
|
|
148
219
|
parsed_json = json.loads(response.text)
|
|
149
220
|
error_message = json.dumps(parsed_json, ensure_ascii=False, separators=(',', ':'))
|
|
150
221
|
except json.JSONDecodeError:
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
222
|
+
if '<title' in response.text:
|
|
223
|
+
html_text = (
|
|
224
|
+
response.text.split('<title', maxsplit=1)[0]
|
|
225
|
+
+ response.text.split('</title>', maxsplit=1)[-1]
|
|
226
|
+
)
|
|
227
|
+
parser = html2text.HTML2Text()
|
|
228
|
+
parser.unicode_snob = True
|
|
229
|
+
parser.body_width = 0
|
|
230
|
+
parser.single_line_break = True
|
|
231
|
+
parser.ignore_images = True
|
|
232
|
+
error_message = parser.handle(html_text).strip()
|
|
233
|
+
else:
|
|
234
|
+
error_message = response.text
|
|
160
235
|
http_error_msg += f'\n{error_message}'
|
|
161
236
|
|
|
162
237
|
if response.status_code in (429, 500, 502, 503, 504):
|
|
@@ -223,6 +298,12 @@ class UrlJob(UrlJobBase):
|
|
|
223
298
|
# required to suppress warnings with 'ssl_no_verify: true'
|
|
224
299
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
225
300
|
|
|
301
|
+
if self.fingerprints:
|
|
302
|
+
logger.warning(
|
|
303
|
+
f"Job {self.index_number}: 'fingerprints' is only supported with 'http_client: curl_cffi'; "
|
|
304
|
+
f'ignoring the directive.'
|
|
305
|
+
)
|
|
306
|
+
|
|
226
307
|
if self.ignore_dh_key_too_small:
|
|
227
308
|
# https://stackoverflow.com/questions/38015537
|
|
228
309
|
logger.debug(
|
|
@@ -238,18 +319,29 @@ class UrlJob(UrlJobBase):
|
|
|
238
319
|
)
|
|
239
320
|
logger.error('See https://github.com/psf/requests/issues/6443')
|
|
240
321
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
322
|
+
with requests.Session() as session:
|
|
323
|
+
if headers:
|
|
324
|
+
session.headers.update(headers)
|
|
325
|
+
if self.cookies:
|
|
326
|
+
session.cookies.update(self.cookies)
|
|
327
|
+
if proxies:
|
|
328
|
+
session.proxies.update(proxies)
|
|
329
|
+
session.verify = not self.ssl_no_verify
|
|
330
|
+
|
|
331
|
+
url = self.url
|
|
332
|
+
if self.initialization_url:
|
|
333
|
+
logger.info(f'Job {self.index_number}: Initializing by navigating to {self.initialization_url}')
|
|
334
|
+
init_response = session.get(self.initialization_url, timeout=timeout)
|
|
335
|
+
url = self._resolve_initialization_url(str(init_response.url))
|
|
336
|
+
|
|
337
|
+
response = session.request(
|
|
338
|
+
method=self.method, # ty:ignore[invalid-argument-type]
|
|
339
|
+
url=url,
|
|
340
|
+
params=self.params,
|
|
341
|
+
data=self.data,
|
|
342
|
+
timeout=timeout,
|
|
343
|
+
allow_redirects=(not self.no_redirects),
|
|
344
|
+
)
|
|
253
345
|
|
|
254
346
|
if 400 <= response.status_code < 600:
|
|
255
347
|
# Custom version of request.raise_for_status() to include returned text.
|
|
@@ -274,15 +366,19 @@ class UrlJob(UrlJobBase):
|
|
|
274
366
|
parsed_json = json.loads(response.text)
|
|
275
367
|
error_message = json.dumps(parsed_json, ensure_ascii=False, separators=(',', ':'))
|
|
276
368
|
except json.JSONDecodeError:
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
369
|
+
if '<title' in response.text:
|
|
370
|
+
html_text = (
|
|
371
|
+
response.text.split('<title', maxsplit=1)[0]
|
|
372
|
+
+ response.text.split('</title>', maxsplit=1)[-1]
|
|
373
|
+
)
|
|
374
|
+
parser = html2text.HTML2Text()
|
|
375
|
+
parser.unicode_snob = True
|
|
376
|
+
parser.body_width = 0
|
|
377
|
+
parser.single_line_break = True
|
|
378
|
+
parser.ignore_images = True
|
|
379
|
+
error_message = parser.handle(html_text).strip()
|
|
380
|
+
else:
|
|
381
|
+
error_message = response.text
|
|
286
382
|
http_error_msg += f'\n{error_message}'
|
|
287
383
|
|
|
288
384
|
if response.status_code in (429, 500, 502, 503, 504):
|
|
@@ -337,8 +433,164 @@ class UrlJob(UrlJobBase):
|
|
|
337
433
|
|
|
338
434
|
return data, etag, mime_type
|
|
339
435
|
|
|
340
|
-
def
|
|
341
|
-
|
|
436
|
+
def _retrieve_curl_cffi(
|
|
437
|
+
self, headers: Mapping[str, str | bytes] | None, timeout: float | None
|
|
438
|
+
) -> tuple[str | bytes, str, str]:
|
|
439
|
+
"""Retrieves the data, Etag, and media type using the curl_cffi library (browser TLS/JA3 impersonation).
|
|
440
|
+
|
|
441
|
+
:return: The data retrieved, the ETag and media type.
|
|
442
|
+
:raises NotModifiedError: If an HTTP 304 response is received.
|
|
443
|
+
"""
|
|
444
|
+
impersonate = self.impersonate or 'chrome'
|
|
445
|
+
logger.info(
|
|
446
|
+
f'Job {self.index_number}: Using the curl_cffi HTTP client library '
|
|
447
|
+
f'(browser TLS impersonation: {impersonate})'
|
|
448
|
+
)
|
|
449
|
+
proxy_str = self.get_proxy()
|
|
450
|
+
if proxy_str is not None:
|
|
451
|
+
scheme = urlsplit(self.url).scheme
|
|
452
|
+
proxies: dict[str, str] | None = {scheme: proxy_str}
|
|
453
|
+
logger.debug(f'Job {self.index_number}: Proxies: {proxies}')
|
|
454
|
+
else:
|
|
455
|
+
proxies = None
|
|
456
|
+
|
|
457
|
+
if self.ignore_dh_key_too_small:
|
|
458
|
+
logger.warning(
|
|
459
|
+
f'Job {self.index_number}: ignore_dh_key_too_small is not supported with the curl_cffi HTTP client '
|
|
460
|
+
f'library (TLS ciphers are fixed by the impersonated browser profile); ignoring the directive.'
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
session_kwargs: dict[str, Any] = {
|
|
464
|
+
'headers': headers,
|
|
465
|
+
'cookies': self.cookies,
|
|
466
|
+
'proxies': proxies,
|
|
467
|
+
'verify': (not self.ssl_no_verify),
|
|
468
|
+
'timeout': timeout,
|
|
469
|
+
'impersonate': impersonate,
|
|
470
|
+
}
|
|
471
|
+
if self.http_version is not None:
|
|
472
|
+
if self.http_version not in _CURL_CFFI_HTTP_VERSIONS:
|
|
473
|
+
raise ValueError(
|
|
474
|
+
f"Job {self.index_number}: 'http_version: {self.http_version}' is not a valid value; expected one "
|
|
475
|
+
f'of {_CURL_CFFI_HTTP_VERSIONS} ( {self.get_indexed_location()} ).'
|
|
476
|
+
)
|
|
477
|
+
session_kwargs['http_version'] = self.http_version
|
|
478
|
+
logger.info(f'Job {self.index_number}: curl_cffi http_version={self.http_version}')
|
|
479
|
+
|
|
480
|
+
if self.fingerprints:
|
|
481
|
+
allowed_fp_keys = {'ja3', 'akamai', 'extra_fp'}
|
|
482
|
+
unknown_fp_keys = set(self.fingerprints) - allowed_fp_keys
|
|
483
|
+
if unknown_fp_keys:
|
|
484
|
+
raise ValueError(
|
|
485
|
+
f"Job {self.index_number}: 'fingerprints' contains unknown key(s) {sorted(unknown_fp_keys)}; "
|
|
486
|
+
f'expected any of {sorted(allowed_fp_keys)} ( {self.get_indexed_location()} ).'
|
|
487
|
+
)
|
|
488
|
+
for key in allowed_fp_keys:
|
|
489
|
+
if key in self.fingerprints:
|
|
490
|
+
session_kwargs[key] = self.fingerprints[key]
|
|
491
|
+
logger.info(f'Job {self.index_number}: curl_cffi fingerprints applied: {sorted(self.fingerprints)}')
|
|
492
|
+
|
|
493
|
+
with curl_cffi_requests.Session(**session_kwargs) as session:
|
|
494
|
+
url = self.url
|
|
495
|
+
if self.initialization_url:
|
|
496
|
+
logger.info(f'Job {self.index_number}: Initializing by navigating to {self.initialization_url}')
|
|
497
|
+
init_response = session.get(self.initialization_url)
|
|
498
|
+
url = self._resolve_initialization_url(str(init_response.url))
|
|
499
|
+
|
|
500
|
+
response = session.request(
|
|
501
|
+
method=self.method,
|
|
502
|
+
url=url,
|
|
503
|
+
params=self.params,
|
|
504
|
+
data=self.data,
|
|
505
|
+
allow_redirects=(not self.no_redirects),
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
if 400 <= response.status_code < 600:
|
|
509
|
+
# Custom version of request.raise_for_status() to include returned text.
|
|
510
|
+
if isinstance(response.reason, bytes):
|
|
511
|
+
try:
|
|
512
|
+
reason = response.reason.decode()
|
|
513
|
+
except UnicodeDecodeError:
|
|
514
|
+
reason = response.reason.decode('iso-8859-1')
|
|
515
|
+
else:
|
|
516
|
+
reason = response.reason
|
|
517
|
+
|
|
518
|
+
if response.status_code < 500:
|
|
519
|
+
http_error_msg = f'{response.status_code} Client Error: {reason} for url: {response.url}'
|
|
520
|
+
else:
|
|
521
|
+
http_error_msg = f'{response.status_code} Server Error: {reason} for url: {response.url}'
|
|
522
|
+
|
|
523
|
+
if response.status_code != 404:
|
|
524
|
+
try:
|
|
525
|
+
parsed_json = json.loads(response.text)
|
|
526
|
+
error_message = json.dumps(parsed_json, ensure_ascii=False, separators=(',', ':'))
|
|
527
|
+
except json.JSONDecodeError:
|
|
528
|
+
if '<title' in response.text:
|
|
529
|
+
html_text = (
|
|
530
|
+
response.text.split('<title', maxsplit=1)[0]
|
|
531
|
+
+ response.text.split('</title>', maxsplit=1)[-1]
|
|
532
|
+
)
|
|
533
|
+
parser = html2text.HTML2Text()
|
|
534
|
+
parser.unicode_snob = True
|
|
535
|
+
parser.body_width = 0
|
|
536
|
+
parser.single_line_break = True
|
|
537
|
+
parser.ignore_images = True
|
|
538
|
+
error_message = parser.handle(html_text).strip()
|
|
539
|
+
else:
|
|
540
|
+
error_message = response.text
|
|
541
|
+
http_error_msg += f'\n{error_message}'
|
|
542
|
+
|
|
543
|
+
if response.status_code in (429, 500, 502, 503, 504):
|
|
544
|
+
logger.debug(f'Job {self.index_number}: Response error is transient.')
|
|
545
|
+
raise TransientHTTPError(http_error_msg, status_code=response.status_code)
|
|
546
|
+
|
|
547
|
+
raise curl_cffi_exceptions.HTTPError(http_error_msg, response=response)
|
|
548
|
+
|
|
549
|
+
if response.status_code == 304:
|
|
550
|
+
logger.debug(f'Job {self.index_number}: Intercepted response with {response.status_code} status')
|
|
551
|
+
raise NotModifiedError(response.status_code)
|
|
552
|
+
|
|
553
|
+
# Save ETag from response to be used as If-None-Match header in future requests
|
|
554
|
+
if not response.history: # no redirects
|
|
555
|
+
etag = response.headers.get('ETag', '')
|
|
556
|
+
else:
|
|
557
|
+
logger.info(f'Job {self.index_number}: ETag not captured as response was redirected to {response.url}')
|
|
558
|
+
etag = ''
|
|
559
|
+
# Save the media type (fka MIME type)
|
|
560
|
+
mime_type = response.headers.get('Content-Type', '').split(';')[0]
|
|
561
|
+
|
|
562
|
+
if FilterBase.filter_chain_needs_bytes(self.filters): # ty:ignore[invalid-argument-type]
|
|
563
|
+
return response.content, etag, mime_type
|
|
564
|
+
|
|
565
|
+
if self.encoding:
|
|
566
|
+
response.encoding = self.encoding
|
|
567
|
+
# Note: curl_cffi's Response has no apparent_encoding, so we can't replicate the requests-library
|
|
568
|
+
# ISO-8859-1 RFC 2616 override here. curl_cffi's encoding detection is libcurl-driven and reliable enough
|
|
569
|
+
# in practice.
|
|
570
|
+
|
|
571
|
+
is_redirect = 300 <= response.status_code < 400 and 'Location' in response.headers
|
|
572
|
+
if self.no_redirects and is_redirect:
|
|
573
|
+
new_location = response.headers['Location']
|
|
574
|
+
if mime_type == 'text/plain':
|
|
575
|
+
data = f'Redirect {response.status_code} {response.reason} to {new_location}:\n{response.text}'
|
|
576
|
+
elif mime_type == 'text/html':
|
|
577
|
+
data = (
|
|
578
|
+
f'Redirect <b>{response.status_code} {response.reason}</b> to <a href="{new_location}">'
|
|
579
|
+
f'{new_location}</a>:<br>{response.text}'
|
|
580
|
+
)
|
|
581
|
+
elif mime_type == 'application/json':
|
|
582
|
+
data = f'{{"Redirect {response.status_code} {response.reason}":"{new_location}"}}'
|
|
583
|
+
else:
|
|
584
|
+
data = f'Redirect {response.status_code} {response.reason} to {new_location}.'
|
|
585
|
+
else:
|
|
586
|
+
data = response.text
|
|
587
|
+
|
|
588
|
+
return data, etag, mime_type
|
|
589
|
+
|
|
590
|
+
def retrieve( # noqa: C901 mccabe complexity too high
|
|
591
|
+
self, job_state: JobState, headless: bool = True
|
|
592
|
+
) -> tuple[str | bytes, str, str]:
|
|
593
|
+
"""Runs job to retrieve the data, and returns data, ETag and media type.
|
|
342
594
|
|
|
343
595
|
:param job_state: The JobState object, to keep track of the state of the retrieval.
|
|
344
596
|
:param headless: For browser-based jobs, whether headless mode should be used.
|
|
@@ -434,7 +686,19 @@ class UrlJob(UrlJobBase):
|
|
|
434
686
|
logger.debug(f'Job {self.index_number}: Headers: {headers}')
|
|
435
687
|
logger.debug(f'Job {self.index_number}: Cookies: {self.cookies}')
|
|
436
688
|
|
|
437
|
-
if self.http_client == '
|
|
689
|
+
if self.http_client == 'curl_cffi' or (not self.http_client and not httpx and isinstance(requests, str)):
|
|
690
|
+
if isinstance(curl_cffi_requests, str):
|
|
691
|
+
message = f'Job {job_state.job.index_number} cannot be run '
|
|
692
|
+
if self.http_client == 'curl_cffi':
|
|
693
|
+
message += "with 'http_client: curl_cffi' "
|
|
694
|
+
message += (
|
|
695
|
+
f'( {self.get_indexed_location()} ):\n{curl_cffi_requests}\n'
|
|
696
|
+
f"Please install module using e.g. 'pip install --upgrade webchanges[curl_cffi]'."
|
|
697
|
+
)
|
|
698
|
+
raise ImportError(message)
|
|
699
|
+
job_state._http_client_used = 'curl_cffi'
|
|
700
|
+
data, etag, mime_type = self._retrieve_curl_cffi(headers=headers, timeout=timeout)
|
|
701
|
+
elif self.http_client == 'requests' or not httpx:
|
|
438
702
|
if isinstance(requests, str):
|
|
439
703
|
message = f'Job {job_state.job.index_number} cannot be run '
|
|
440
704
|
if self.http_client == 'requests':
|
|
@@ -444,6 +708,12 @@ class UrlJob(UrlJobBase):
|
|
|
444
708
|
f"Please install module using e.g. 'pip install --upgrade webchanges[requests]'."
|
|
445
709
|
)
|
|
446
710
|
raise ImportError(message)
|
|
711
|
+
if self.http_version is not None:
|
|
712
|
+
raise ValueError(
|
|
713
|
+
f"Job {job_state.job.index_number}: 'http_version' is not supported by the 'requests' HTTP "
|
|
714
|
+
f"client (HTTP/1.1 only). Use 'http_client: httpx' or 'http_client: curl_cffi' "
|
|
715
|
+
f'( {self.get_indexed_location()} ).'
|
|
716
|
+
)
|
|
447
717
|
job_state._http_client_used = 'requests'
|
|
448
718
|
data, etag, mime_type = self._retrieve_requests(headers=headers, timeout=timeout)
|
|
449
719
|
elif not self.http_client or self.http_client == 'httpx':
|
|
@@ -456,7 +726,7 @@ class UrlJob(UrlJobBase):
|
|
|
456
726
|
f"Please install module using e.g. 'pip install --upgrade httpx[http2,zstd]'."
|
|
457
727
|
)
|
|
458
728
|
raise ImportError(message)
|
|
459
|
-
job_state._http_client_used = '
|
|
729
|
+
job_state._http_client_used = 'httpx'
|
|
460
730
|
data, etag, mime_type = self._retrieve_httpx(headers=headers, timeout=timeout)
|
|
461
731
|
else:
|
|
462
732
|
raise ValueError(
|
|
@@ -495,9 +765,16 @@ class UrlJob(UrlJobBase):
|
|
|
495
765
|
:returns: A string to display and/or use in reports.
|
|
496
766
|
"""
|
|
497
767
|
if (
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
768
|
+
(
|
|
769
|
+
httpx
|
|
770
|
+
and isinstance(exception, (httpx.HTTPError, httpx.InvalidURL, httpx.CookieConflict, httpx.StreamError))
|
|
771
|
+
)
|
|
772
|
+
or (not isinstance(requests, str) and isinstance(exception, requests.exceptions.RequestException))
|
|
773
|
+
or (
|
|
774
|
+
not isinstance(curl_cffi_exceptions, str)
|
|
775
|
+
and isinstance(exception, curl_cffi_exceptions.RequestException)
|
|
776
|
+
)
|
|
777
|
+
):
|
|
501
778
|
# Instead of a full traceback, just show the error
|
|
502
779
|
exception_str = str(exception).strip()
|
|
503
780
|
if self.proxy and (
|
|
@@ -572,4 +849,24 @@ class UrlJob(UrlJobBase):
|
|
|
572
849
|
if isinstance(exception, TransientHTTPError):
|
|
573
850
|
return self._ignore_http_error_code(exception.status_code)
|
|
574
851
|
|
|
852
|
+
elif not isinstance(curl_cffi_exceptions, str) and isinstance(
|
|
853
|
+
exception, (curl_cffi_exceptions.RequestException, TransientHTTPError)
|
|
854
|
+
):
|
|
855
|
+
if self.ignore_timeout_errors and (
|
|
856
|
+
isinstance(exception, curl_cffi_exceptions.Timeout)
|
|
857
|
+
or (isinstance(exception, TransientHTTPError) and exception.status_code == 504)
|
|
858
|
+
):
|
|
859
|
+
return True
|
|
860
|
+
if self.ignore_connection_errors and (
|
|
861
|
+
isinstance(exception, (curl_cffi_exceptions.ConnectionError, TransientHTTPError))
|
|
862
|
+
):
|
|
863
|
+
return True
|
|
864
|
+
if self.ignore_too_many_redirects and isinstance(exception, curl_cffi_exceptions.TooManyRedirects):
|
|
865
|
+
return True
|
|
866
|
+
if self.ignore_http_error_codes:
|
|
867
|
+
if isinstance(exception, curl_cffi_exceptions.HTTPError) and exception.response is not None:
|
|
868
|
+
return self._ignore_http_error_code(exception.response.status_code)
|
|
869
|
+
if isinstance(exception, TransientHTTPError):
|
|
870
|
+
return self._ignore_http_error_code(exception.status_code)
|
|
871
|
+
|
|
575
872
|
return False
|
|
@@ -145,7 +145,7 @@ class ReporterBase(metaclass=TrackSubClasses):
|
|
|
145
145
|
else:
|
|
146
146
|
config = {}
|
|
147
147
|
|
|
148
|
-
return othercls(self.report, config, self.job_states, self.duration, self.jobs_files, self.differ_defaults)
|
|
148
|
+
return othercls(self.report, config, self.job_states, self.duration, self.jobs_files, self.differ_defaults) # ty:ignore[invalid-argument-type]
|
|
149
149
|
|
|
150
150
|
@classmethod
|
|
151
151
|
def get_base_config(cls, report: Report) -> dict[str, Any]:
|
|
@@ -236,7 +236,7 @@ class ReporterBase(metaclass=TrackSubClasses):
|
|
|
236
236
|
|
|
237
237
|
any_enabled = False
|
|
238
238
|
for name, subclass in cls.__subclasses__.items():
|
|
239
|
-
cfg: _ConfigReportersList = report.config['report'].get(name, {})
|
|
239
|
+
cfg: _ConfigReportersList = report.config['report'].get(name, {}) # ty:ignore[invalid-assignment]
|
|
240
240
|
if cfg.get('enabled', False):
|
|
241
241
|
any_enabled = True
|
|
242
242
|
logger.info(f'Submitting with {name} ({subclass})')
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: webchanges
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.35.0
|
|
4
4
|
Summary: Web Changes Delivered. AI-Summarized. Totally Anonymous.
|
|
5
5
|
Author-email: Mike Borsetti <mike+webchanges@borsetti.com>
|
|
6
6
|
Maintainer-email: Mike Borsetti <mike+webchanges@borsetti.com>
|
|
@@ -89,6 +89,8 @@ Provides-Extra: pushover
|
|
|
89
89
|
Requires-Dist: chump; extra == "pushover"
|
|
90
90
|
Provides-Extra: xmpp
|
|
91
91
|
Requires-Dist: aioxmpp; extra == "xmpp"
|
|
92
|
+
Provides-Extra: curl-cffi
|
|
93
|
+
Requires-Dist: curl_cffi; extra == "curl-cffi"
|
|
92
94
|
Provides-Extra: redis
|
|
93
95
|
Requires-Dist: redis; extra == "redis"
|
|
94
96
|
Provides-Extra: requests
|
|
@@ -96,7 +98,7 @@ Requires-Dist: requests; extra == "requests"
|
|
|
96
98
|
Provides-Extra: safe-password
|
|
97
99
|
Requires-Dist: keyring; extra == "safe-password"
|
|
98
100
|
Provides-Extra: all
|
|
99
|
-
Requires-Dist: webchanges[beautify,bs4,deepdiff_xml,html5lib,ical2text,imagediff,jq,matrix,ocr,pdf2text,pushbullet,pushover,pypdf_crypto,redis,requests,safe_password,use_browser,xmpp]; extra == "all"
|
|
101
|
+
Requires-Dist: webchanges[beautify,bs4,curl_cffi,deepdiff_xml,html5lib,ical2text,imagediff,jq,matrix,ocr,pdf2text,pushbullet,pushover,pypdf_crypto,redis,requests,safe_password,use_browser,xmpp]; extra == "all"
|
|
100
102
|
Dynamic: license-file
|
|
101
103
|
|
|
102
104
|
.. role:: underline
|
|
@@ -139,7 +141,7 @@ Install **webchanges** with ``uv`` (recommended):
|
|
|
139
141
|
.. code-block:: bash
|
|
140
142
|
|
|
141
143
|
uv pip install webchanges
|
|
142
|
-
|
|
144
|
+
|
|
143
145
|
or with ``pip``:
|
|
144
146
|
|
|
145
147
|
.. code-block:: bash
|
|
@@ -154,13 +156,14 @@ implementation (no browser) `here <https://github.com/yubiuser/webchanges-docker
|
|
|
154
156
|
|
|
155
157
|
As a GitHub Action
|
|
156
158
|
------------------
|
|
157
|
-
**webchanges** can easily run as a `GitHub Action <https://www.docker.com/>`__! You will find an implementation
|
|
159
|
+
**webchanges** can easily run as a `GitHub Action <https://www.docker.com/>`__! You will find an implementation
|
|
158
160
|
`here <https://github.com/swimmwatch/webchanges-action>`__.
|
|
159
161
|
|
|
160
162
|
|
|
161
163
|
Documentation |readthedocs|
|
|
162
164
|
===========================
|
|
163
|
-
The documentation is hosted on `Read the Docs <https://webchanges.readthedocs.io/>`__.
|
|
165
|
+
The documentation is hosted on `Read the Docs <https://webchanges.readthedocs.io/>`__. A (very good) AI-generated
|
|
166
|
+
high-level architectural overview is available on `DeepWiki <https://deepwiki.com/mborsetti/webchanges>`__.
|
|
164
167
|
|
|
165
168
|
|
|
166
169
|
Quick Start
|
|
@@ -208,7 +211,7 @@ Schedule
|
|
|
208
211
|
|
|
209
212
|
Code
|
|
210
213
|
====
|
|
211
|
-
|coveralls| |
|
|
214
|
+
|coveralls| |issues| |code_style| |OpenSSF_Scorecard|
|
|
212
215
|
|
|
213
216
|
The code, issues tracker, and discussions are hosted on `GitHub <https://github.com/mborsetti/webchanges>`__.
|
|
214
217
|
|
|
@@ -240,7 +243,7 @@ Compatibility with **urlwatch**
|
|
|
240
243
|
This project is based on code from `urlwatch 2.21
|
|
241
244
|
<https://github.com/thp/urlwatch/tree/346b25914b0418342ffe2fb0529bed702fddc01f>`__ dated 30 July 2020.
|
|
242
245
|
For upgrading from **urlwatch** to **webchanges** (using the same job and
|
|
243
|
-
configuration files), see `here <https://webchanges.readthedocs.io/en/stable/upgrading.html>`__.
|
|
246
|
+
configuration files), see `here <https://webchanges.readthedocs.io/en/stable/upgrading.html>`__.
|
|
244
247
|
|
|
245
248
|
|
|
246
249
|
.. |support| image:: https://img.shields.io/pypi/pyversions/webchanges.svg
|
|
@@ -285,9 +288,9 @@ configuration files), see `here <https://webchanges.readthedocs.io/en/stable/upg
|
|
|
285
288
|
.. .. |security| image:: https://img.shields.io/badge/security-bandit-green.svg
|
|
286
289
|
.. :target: https://github.com/PyCQA/bandit
|
|
287
290
|
.. :alt: Security Status
|
|
288
|
-
.. |snyk| image:: https://snyk.io/advisor/python/
|
|
289
|
-
|
|
290
|
-
|
|
291
|
+
.. .. |snyk| image:: https://snyk.io/advisor/python/webchanges/badge.svg
|
|
292
|
+
.. :target: https://security.snyk.io/package/pip/webchanges
|
|
293
|
+
.. :alt: Snyk Package Health Score
|
|
291
294
|
.. |OpenSSF_Scorecard| image:: https://api.scorecard.dev/projects/github.com/mborsetti/webchanges/badge
|
|
292
295
|
:target: https://scorecard.dev/viewer/?uri=github.com/mborsetti/webchanges
|
|
293
296
|
:alt: OpenSSF Scoreard
|
|
@@ -16,7 +16,7 @@ colorama
|
|
|
16
16
|
tzdata
|
|
17
17
|
|
|
18
18
|
[all]
|
|
19
|
-
webchanges[beautify,bs4,deepdiff_xml,html5lib,ical2text,imagediff,jq,matrix,ocr,pdf2text,pushbullet,pushover,pypdf_crypto,redis,requests,safe_password,use_browser,xmpp]
|
|
19
|
+
webchanges[beautify,bs4,curl_cffi,deepdiff_xml,html5lib,ical2text,imagediff,jq,matrix,ocr,pdf2text,pushbullet,pushover,pypdf_crypto,redis,requests,safe_password,use_browser,xmpp]
|
|
20
20
|
|
|
21
21
|
[beautify]
|
|
22
22
|
beautifulsoup4
|
|
@@ -26,6 +26,9 @@ cssbeautifier
|
|
|
26
26
|
[bs4]
|
|
27
27
|
beautifulsoup4
|
|
28
28
|
|
|
29
|
+
[curl_cffi]
|
|
30
|
+
curl_cffi
|
|
31
|
+
|
|
29
32
|
[deepdiff]
|
|
30
33
|
deepdiff
|
|
31
34
|
|
|
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
|