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.
Files changed (59) hide show
  1. {webchanges-3.34.2/webchanges.egg-info → webchanges-3.35.0}/PKG-INFO +13 -10
  2. {webchanges-3.34.2 → webchanges-3.35.0}/README.rst +9 -8
  3. {webchanges-3.34.2 → webchanges-3.35.0}/pyproject.toml +2 -1
  4. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/__init__.py +1 -1
  5. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/_vendored/headers.py +1 -5
  6. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/command.py +8 -5
  7. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/differs.py +3 -3
  8. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/filters/_html.py +4 -4
  9. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/filters/_shell.py +4 -1
  10. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/handler.py +1 -1
  11. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/jobs/_base.py +6 -3
  12. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/jobs/_browser.py +29 -11
  13. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/jobs/_url.py +343 -46
  14. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/reporters/_base.py +2 -2
  15. {webchanges-3.34.2 → webchanges-3.35.0/webchanges.egg-info}/PKG-INFO +13 -10
  16. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges.egg-info/requires.txt +4 -1
  17. {webchanges-3.34.2 → webchanges-3.35.0}/LICENSE.md +0 -0
  18. {webchanges-3.34.2 → webchanges-3.35.0}/MANIFEST.in +0 -0
  19. {webchanges-3.34.2 → webchanges-3.35.0}/requirements.txt +0 -0
  20. {webchanges-3.34.2 → webchanges-3.35.0}/setup.cfg +0 -0
  21. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/__main__.py +0 -0
  22. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/_vendored/__init__.py +0 -0
  23. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/_vendored/packaging_version.py +0 -0
  24. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/cli.py +0 -0
  25. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/config.py +0 -0
  26. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/filters/__init__.py +0 -0
  27. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/filters/_base.py +0 -0
  28. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/filters/_document.py +0 -0
  29. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/filters/_encoding.py +0 -0
  30. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/filters/_text.py +0 -0
  31. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/jobs/__init__.py +0 -0
  32. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/jobs/_exceptions.py +0 -0
  33. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/jobs/_shell.py +0 -0
  34. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/mailer.py +0 -0
  35. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/main.py +0 -0
  36. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/py.typed +0 -0
  37. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/reporters/__init__.py +0 -0
  38. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/reporters/_browser.py +0 -0
  39. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/reporters/_chat.py +0 -0
  40. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/reporters/_email.py +0 -0
  41. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/reporters/_filter.py +0 -0
  42. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/reporters/_stdout.py +0 -0
  43. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/reporters/_system.py +0 -0
  44. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/reporters/_web.py +0 -0
  45. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/storage/__init__.py +0 -0
  46. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/storage/_base.py +0 -0
  47. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/storage/_config.py +0 -0
  48. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/storage/_minidb.py +0 -0
  49. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/storage/_redis.py +0 -0
  50. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/storage/_sqlite3.py +0 -0
  51. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/storage/_ssdb.py +0 -0
  52. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/storage/_yaml.py +0 -0
  53. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/storage_minidb.py +0 -0
  54. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/util.py +0 -0
  55. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges/worker.py +0 -0
  56. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges.egg-info/SOURCES.txt +0 -0
  57. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges.egg-info/dependency_links.txt +0 -0
  58. {webchanges-3.34.2 → webchanges-3.35.0}/webchanges.egg-info/entry_points.txt +0 -0
  59. {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.34.2
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| |snyk| |issues| |code_style| |OpenSSF_Scorecard|
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/holidays/badge.svg
289
- :target: https://snyk.io/advisor/python/holidays
290
- :alt: Snyk Package Health Score
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| |snyk| |issues| |code_style| |OpenSSF_Scorecard|
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/holidays/badge.svg
188
- :target: https://snyk.io/advisor/python/holidays
189
- :alt: Snyk Package Health Score
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.34.2'
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 Chrome (without 'use_browser: true') in parallel with "
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 Chrome (i.e. without 'use_browser: true').")
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 Chrome (i.e. with 'use_browser: true') in parallel with "
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 Chrome (i.e. with 'use_browser: true').")
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(f"The '{f_cls.__kind__}' filter returned error ({f_cls.job.get_indexed_location()}):\n{e.stderr}")
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: str | None = None
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
- channel = None if executable_path else 'chrome'
410
- browser_name = executable_path or 'Chrome'
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
- p.chromium.launch(
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
- user_agent = headers.pop(
426
- 'User-Agent',
427
- f'Mozilla/5.0 ({self.get_user_agent_platform()}) AppleWebKit/537.36 (KHTML, like Gecko) '
428
- f'Chrome/{browser_version.split(".", maxsplit=1)[0]}.0.0.0 Safari/537.36',
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
- p.chromium.launch_persistent_context(
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
- http2: bool = h2 is not None
93
- if http2:
94
- logger.info(f'Job {self.index_number}: Using the HTTPX HTTP client library with HTTP/2 support')
95
- else:
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 (HTTP/2 support is not available since '
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=self.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
- html_text = (
152
- response.text.split('<title', maxsplit=1)[0] + response.text.split('</title>', maxsplit=1)[-1]
153
- )
154
- parser = html2text.HTML2Text()
155
- parser.unicode_snob = True
156
- parser.body_width = 0
157
- parser.single_line_break = True
158
- parser.ignore_images = True
159
- error_message = parser.handle(html_text).strip()
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
- response = requests.request(
242
- method=self.method, # ty:ignore[invalid-argument-type]
243
- url=self.url,
244
- params=self.params,
245
- data=self.data,
246
- headers=headers,
247
- cookies=self.cookies,
248
- timeout=timeout,
249
- allow_redirects=(not self.no_redirects),
250
- proxies=proxies,
251
- verify=(not self.ssl_no_verify),
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
- html_text = (
278
- response.text.split('<title', maxsplit=1)[0] + response.text.split('</title>', maxsplit=1)[-1]
279
- )
280
- parser = html2text.HTML2Text()
281
- parser.unicode_snob = True
282
- parser.body_width = 0
283
- parser.single_line_break = True
284
- parser.ignore_images = True
285
- error_message = parser.handle(html_text).strip()
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 retrieve(self, job_state: JobState, headless: bool = True) -> tuple[str | bytes, str, str]:
341
- """Runs job to retrieve the data, and returns data and ETag.
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 == 'requests' or not httpx:
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 = 'HTTPX'
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
- httpx
499
- and isinstance(exception, (httpx.HTTPError, httpx.InvalidURL, httpx.CookieConflict, httpx.StreamError))
500
- ) or (not isinstance(requests, str) and isinstance(exception, requests.exceptions.RequestException)):
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.34.2
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| |snyk| |issues| |code_style| |OpenSSF_Scorecard|
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/holidays/badge.svg
289
- :target: https://snyk.io/advisor/python/holidays
290
- :alt: Snyk Package Health Score
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