webchanges 3.38.0__tar.gz → 3.39.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 (65) hide show
  1. {webchanges-3.38.0/webchanges.egg-info → webchanges-3.39.0}/PKG-INFO +21 -20
  2. {webchanges-3.38.0 → webchanges-3.39.0}/README.rst +21 -20
  3. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/__init__.py +1 -1
  4. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/_resources/config.schema.json +20 -0
  5. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/_resources/jobs.schema.json +27 -3
  6. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/cli.py +5 -0
  7. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/command.py +98 -18
  8. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/config.py +21 -9
  9. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/filters/_html.py +11 -6
  10. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/jobs/_base.py +50 -3
  11. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/jobs/_shell.py +43 -5
  12. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/jobs/_url.py +10 -0
  13. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/mailer.py +10 -1
  14. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/reporters/__init__.py +3 -2
  15. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/reporters/_base.py +3 -2
  16. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/reporters/_email.py +5 -2
  17. webchanges-3.39.0/webchanges/reporters/_system.py +132 -0
  18. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/reporters/_web.py +79 -47
  19. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/storage/__init__.py +2 -0
  20. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/storage/_config.py +15 -0
  21. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/storage/_yaml.py +52 -7
  22. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/worker.py +8 -1
  23. {webchanges-3.38.0 → webchanges-3.39.0/webchanges.egg-info}/PKG-INFO +21 -20
  24. webchanges-3.38.0/webchanges/reporters/_system.py +0 -71
  25. {webchanges-3.38.0 → webchanges-3.39.0}/LICENSE.md +0 -0
  26. {webchanges-3.38.0 → webchanges-3.39.0}/MANIFEST.in +0 -0
  27. {webchanges-3.38.0 → webchanges-3.39.0}/pyproject.toml +0 -0
  28. {webchanges-3.38.0 → webchanges-3.39.0}/requirements.txt +0 -0
  29. {webchanges-3.38.0 → webchanges-3.39.0}/setup.cfg +0 -0
  30. {webchanges-3.38.0 → webchanges-3.39.0}/setup.py +0 -0
  31. {webchanges-3.38.0 → webchanges-3.39.0}/tools/get_manpages_parser.py +0 -0
  32. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/__main__.py +0 -0
  33. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/_resources/__init__.py +0 -0
  34. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/_vendored/__init__.py +0 -0
  35. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/_vendored/headers.py +0 -0
  36. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/_vendored/packaging_version.py +0 -0
  37. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/_vendored/typeguard.py +0 -0
  38. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/differs.py +0 -0
  39. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/filters/__init__.py +0 -0
  40. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/filters/_base.py +0 -0
  41. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/filters/_document.py +0 -0
  42. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/filters/_encoding.py +0 -0
  43. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/filters/_shell.py +0 -0
  44. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/filters/_text.py +0 -0
  45. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/handler.py +0 -0
  46. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/jobs/__init__.py +0 -0
  47. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/jobs/_browser.py +0 -0
  48. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/jobs/_exceptions.py +0 -0
  49. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/main.py +0 -0
  50. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/py.typed +0 -0
  51. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/reporters/_browser.py +0 -0
  52. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/reporters/_chat.py +0 -0
  53. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/reporters/_stdout.py +0 -0
  54. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/storage/_base.py +0 -0
  55. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/storage/_minidb.py +0 -0
  56. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/storage/_redis.py +0 -0
  57. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/storage/_sqlite3.py +0 -0
  58. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/storage/_ssdb.py +0 -0
  59. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/storage_minidb.py +0 -0
  60. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges/util.py +0 -0
  61. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges.egg-info/SOURCES.txt +0 -0
  62. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges.egg-info/dependency_links.txt +0 -0
  63. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges.egg-info/entry_points.txt +0 -0
  64. {webchanges-3.38.0 → webchanges-3.39.0}/webchanges.egg-info/requires.txt +0 -0
  65. {webchanges-3.38.0 → webchanges-3.39.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.38.0
3
+ Version: 3.39.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>
@@ -123,22 +123,25 @@ notifications and AI-powered summaries to your favorite `platform
123
123
 
124
124
  Highlights
125
125
  ==========
126
- - **AI-powered change summaries** via Gemini (`ai_google differ
127
- <https://webchanges.readthedocs.io/en/stable/differs.html#ai-google-diff>`__, BETA).
128
- - **Structural diffs for JSON and XML** via the `deepdiff differ
129
- <https://webchanges.readthedocs.io/en/stable/differs.html#deepdiff-diff>`__.
130
- - **Visual diffs for images** via the `image differ
131
- <https://webchanges.readthedocs.io/en/stable/differs.html#image-diff>`__ (BETA).
132
- - **Bypass TLS/JA3 fingerprinting** with the optional `curl_cffi
133
- <https://webchanges.readthedocs.io/en/stable/jobs.html#http-client>`__ HTTP client.
134
- - **JSON-Schema-validated** ``jobs.yaml`` and ``config.yaml`` for editor autocompletion and inline validation.
126
+ - **Watch anything**: web pages (in a `real browser
127
+ <https://webchanges.readthedocs.io/en/stable/jobs.html#use-browser>`__ if needed), APIs, local files and command
128
+ output.
129
+ - **Track only what matters** with over 30 `filters <https://webchanges.readthedocs.io/en/stable/filters.html>`__.
130
+ - **Read changes at a glance** in formatted HTML, side by side, word by word, as `AI summaries
131
+ <https://webchanges.readthedocs.io/en/stable/differs.html#ai-google-diff>`__ and more.
132
+ - **Get notified anywhere**: email, Telegram, Discord, ntfy, webhooks and `more
133
+ <https://webchanges.readthedocs.io/en/stable/reporters.html>`__.
134
+ - **Few false alarms**: transient errors can be held back and repeated ones reported `once
135
+ <https://webchanges.readthedocs.io/en/stable/jobs.html#suppress-repeated-errors>`__.
136
+ - **Runs on your machine**, total privacy, no account needed, and extends with Python `hooks
137
+ <https://webchanges.readthedocs.io/en/stable/hooks.html>`__.
135
138
 
136
139
 
137
140
  Requirements
138
141
  ============
139
142
  **webchanges** requires |support|. For the best experience, use the current version of `Python
140
- <https://www.python.org/downloads/>`__; older Python versions are supported for 3 years after they're replaced. Free-threaded Python is supported, though some
141
- optional dependencies may not be.
143
+ <https://www.python.org/downloads/>`__; older Python versions are supported for 3 years after they're replaced.
144
+ Free-threaded Python is supported.
142
145
 
143
146
  For Generative AI summaries (BETA), you need a free `API Key from Google Cloud AI Studio
144
147
  <https://aistudio.google.com/app/apikey>`__ (see `here
@@ -224,7 +227,7 @@ Schedule
224
227
 
225
228
  Code
226
229
  ====
227
- |coveralls| |issues| |code_style| |OpenSSF_Scorecard|
230
+ |coveralls| |issues| |code_style|
228
231
 
229
232
  The code, issues tracker, and discussions are hosted on `GitHub <https://github.com/mborsetti/webchanges>`__.
230
233
 
@@ -243,10 +246,11 @@ License
243
246
  =======
244
247
  |license|
245
248
 
246
- See the `complete licenses <https://raw.githubusercontent.com/mborsetti/webchanges/refs/heads/main/LICENSE.md>`__ (released
247
- under the `MIT License <https://opensource.org/licenses/MIT>`__ but redistributing modified source code, dated 30
248
- July 2020, from `urlwatch 2.21 <https://github.com/thp/urlwatch/tree/346b25914b0418342ffe2fb0529bed702fddc01f>`__
249
- licensed under a `BSD 3-Clause License
249
+ See the `complete licenses
250
+ <https://raw.githubusercontent.com/mborsetti/webchanges/refs/heads/main/LICENSE.md>`__ (released under the `MIT
251
+ License <https://opensource.org/licenses/MIT>`__ but redistributing modified source code, dated 30 July 2020, from
252
+ `urlwatch 2.21 <https://github.com/thp/urlwatch/tree/346b25914b0418342ffe2fb0529bed702fddc01f>`__ licensed under a
253
+ `BSD 3-Clause License
250
254
  <https://raw.githubusercontent.com/thp/urlwatch/346b25914b0418342ffe2fb0529bed702fddc01f/COPYING>`__).
251
255
 
252
256
 
@@ -302,6 +306,3 @@ see the `comparison <https://webchanges.readthedocs.io/en/stable/upgrading.html>
302
306
  .. .. |snyk| image:: https://snyk.io/advisor/python/webchanges/badge.svg
303
307
  .. :target: https://security.snyk.io/package/pip/webchanges
304
308
  .. :alt: Snyk Package Health Score
305
- .. |OpenSSF_Scorecard| image:: https://api.scorecard.dev/projects/github.com/mborsetti/webchanges/badge
306
- :target: https://scorecard.dev/viewer/?uri=github.com/mborsetti/webchanges
307
- :alt: OpenSSF Scoreard
@@ -16,22 +16,25 @@ notifications and AI-powered summaries to your favorite `platform
16
16
 
17
17
  Highlights
18
18
  ==========
19
- - **AI-powered change summaries** via Gemini (`ai_google differ
20
- <https://webchanges.readthedocs.io/en/stable/differs.html#ai-google-diff>`__, BETA).
21
- - **Structural diffs for JSON and XML** via the `deepdiff differ
22
- <https://webchanges.readthedocs.io/en/stable/differs.html#deepdiff-diff>`__.
23
- - **Visual diffs for images** via the `image differ
24
- <https://webchanges.readthedocs.io/en/stable/differs.html#image-diff>`__ (BETA).
25
- - **Bypass TLS/JA3 fingerprinting** with the optional `curl_cffi
26
- <https://webchanges.readthedocs.io/en/stable/jobs.html#http-client>`__ HTTP client.
27
- - **JSON-Schema-validated** ``jobs.yaml`` and ``config.yaml`` for editor autocompletion and inline validation.
19
+ - **Watch anything**: web pages (in a `real browser
20
+ <https://webchanges.readthedocs.io/en/stable/jobs.html#use-browser>`__ if needed), APIs, local files and command
21
+ output.
22
+ - **Track only what matters** with over 30 `filters <https://webchanges.readthedocs.io/en/stable/filters.html>`__.
23
+ - **Read changes at a glance** in formatted HTML, side by side, word by word, as `AI summaries
24
+ <https://webchanges.readthedocs.io/en/stable/differs.html#ai-google-diff>`__ and more.
25
+ - **Get notified anywhere**: email, Telegram, Discord, ntfy, webhooks and `more
26
+ <https://webchanges.readthedocs.io/en/stable/reporters.html>`__.
27
+ - **Few false alarms**: transient errors can be held back and repeated ones reported `once
28
+ <https://webchanges.readthedocs.io/en/stable/jobs.html#suppress-repeated-errors>`__.
29
+ - **Runs on your machine**, total privacy, no account needed, and extends with Python `hooks
30
+ <https://webchanges.readthedocs.io/en/stable/hooks.html>`__.
28
31
 
29
32
 
30
33
  Requirements
31
34
  ============
32
35
  **webchanges** requires |support|. For the best experience, use the current version of `Python
33
- <https://www.python.org/downloads/>`__; older Python versions are supported for 3 years after they're replaced. Free-threaded Python is supported, though some
34
- optional dependencies may not be.
36
+ <https://www.python.org/downloads/>`__; older Python versions are supported for 3 years after they're replaced.
37
+ Free-threaded Python is supported.
35
38
 
36
39
  For Generative AI summaries (BETA), you need a free `API Key from Google Cloud AI Studio
37
40
  <https://aistudio.google.com/app/apikey>`__ (see `here
@@ -117,7 +120,7 @@ Schedule
117
120
 
118
121
  Code
119
122
  ====
120
- |coveralls| |issues| |code_style| |OpenSSF_Scorecard|
123
+ |coveralls| |issues| |code_style|
121
124
 
122
125
  The code, issues tracker, and discussions are hosted on `GitHub <https://github.com/mborsetti/webchanges>`__.
123
126
 
@@ -136,10 +139,11 @@ License
136
139
  =======
137
140
  |license|
138
141
 
139
- See the `complete licenses <https://raw.githubusercontent.com/mborsetti/webchanges/refs/heads/main/LICENSE.md>`__ (released
140
- under the `MIT License <https://opensource.org/licenses/MIT>`__ but redistributing modified source code, dated 30
141
- July 2020, from `urlwatch 2.21 <https://github.com/thp/urlwatch/tree/346b25914b0418342ffe2fb0529bed702fddc01f>`__
142
- licensed under a `BSD 3-Clause License
142
+ See the `complete licenses
143
+ <https://raw.githubusercontent.com/mborsetti/webchanges/refs/heads/main/LICENSE.md>`__ (released under the `MIT
144
+ License <https://opensource.org/licenses/MIT>`__ but redistributing modified source code, dated 30 July 2020, from
145
+ `urlwatch 2.21 <https://github.com/thp/urlwatch/tree/346b25914b0418342ffe2fb0529bed702fddc01f>`__ licensed under a
146
+ `BSD 3-Clause License
143
147
  <https://raw.githubusercontent.com/thp/urlwatch/346b25914b0418342ffe2fb0529bed702fddc01f/COPYING>`__).
144
148
 
145
149
 
@@ -194,7 +198,4 @@ see the `comparison <https://webchanges.readthedocs.io/en/stable/upgrading.html>
194
198
  .. :alt: Security Status
195
199
  .. .. |snyk| image:: https://snyk.io/advisor/python/webchanges/badge.svg
196
200
  .. :target: https://security.snyk.io/package/pip/webchanges
197
- .. :alt: Snyk Package Health Score
198
- .. |OpenSSF_Scorecard| image:: https://api.scorecard.dev/projects/github.com/mborsetti/webchanges/badge
199
- :target: https://scorecard.dev/viewer/?uri=github.com/mborsetti/webchanges
200
- :alt: OpenSSF Scoreard
201
+ .. :alt: Snyk Package Health Score
@@ -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.38.0'
25
+ __version__ = '3.39.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
  )
@@ -334,6 +334,11 @@
334
334
  "description": "Sender's email address. Do not use a primary mailbox; create a throwaway account for webchanges.",
335
335
  "default": ""
336
336
  },
337
+ "reply_to": {
338
+ "type": "string",
339
+ "description": "Reply-To email address(es), used by email clients when replying to the report. For multiple addresses, concatenate with commas. If blank, no Reply-To header is sent.",
340
+ "default": ""
341
+ },
337
342
  "subject": {
338
343
  "type": "string",
339
344
  "description": "Subject line. Use {count}, {jobs}, and {jobs_files} placeholders (see html.title for details).",
@@ -984,6 +989,21 @@
984
989
  { "$ref": "#/$defs/_ConfigReportXmpp" },
985
990
  { "type": "array", "items": { "$ref": "#/$defs/_ConfigReportXmpp" }, "minItems": 1 }
986
991
  ]
992
+ },
993
+ "mattermost": {
994
+ "type": ["object", "array"],
995
+ "description": "Deprecated (legacy); replace with 'webhook' and add 'markdown: true'. Still runs as a webhook reporter with Markdown.",
996
+ "deprecated": true
997
+ },
998
+ "shell": {
999
+ "type": ["object", "array"],
1000
+ "description": "Deprecated (legacy); replace with 'run_command'. Still runs its 'command' (a string or a list of arguments, without a shell) with the text report on its standard input; sub-directives 'command', 'ignore_stdout' (default true) and 'ignore_stderr' (default false).",
1001
+ "deprecated": true
1002
+ },
1003
+ "slack": {
1004
+ "type": ["object", "array"],
1005
+ "description": "Deprecated (legacy); rename to 'webhook' (same sub-directives). Still runs as a webhook reporter.",
1006
+ "deprecated": true
987
1007
  }
988
1008
  }
989
1009
  },
@@ -169,6 +169,11 @@
169
169
  "description": "If true, accepts a server's weak (1024-bit) Diffie-Hellman key by lowering the OpenSSL security level (certificates are still verified). Supported by http_client httpx and requests, not by curl_cffi.",
170
170
  "default": false
171
171
  },
172
+ "ignore_incomplete_reads": {
173
+ "type": "boolean",
174
+ "description": "If true, suppresses errors caused by the server closing the connection before sending the complete response. Not supported with use_browser: true.",
175
+ "default": false
176
+ },
172
177
  "params": {
173
178
  "type": "object",
174
179
  "additionalProperties": { "type": "string" },
@@ -369,9 +374,22 @@
369
374
  "deprecated": true,
370
375
  "deprecationMessage": "Use 'url' with 'use_browser: true' instead of 'navigate'."
371
376
  },
377
+ "useragent": {
378
+ "type": "string",
379
+ "description": "Deprecated (legacy): set a 'User-Agent' header in 'headers' instead.",
380
+ "deprecated": true,
381
+ "deprecationMessage": "Set a 'User-Agent' header in 'headers' instead of 'useragent'."
382
+ },
383
+ "browser": {
384
+ "type": "string",
385
+ "enum": ["chromium", "firefox", "webkit"],
386
+ "description": "Deprecated (legacy): use 'use_browser: <browser>' instead ('chromium' is 'use_browser: true').",
387
+ "deprecated": true,
388
+ "deprecationMessage": "Use 'use_browser: <browser>' instead of 'browser'."
389
+ },
372
390
  "wait_for": {
373
391
  "type": ["integer", "string"],
374
- "description": "No longer supported (Pyppeteer): use 'wait_for_function', 'wait_for_selector' or 'wait_for_timeout' instead.",
392
+ "description": "No longer supported (Pyppeteer): use 'wait_for_function', 'wait_for_selector' or 'wait_for_timeout' instead. In legacy 'navigate' jobs, a selector, run as 'wait_for_selector'.",
375
393
  "deprecated": true,
376
394
  "deprecationMessage": "Use 'wait_for_function', 'wait_for_selector' or 'wait_for_timeout' instead of 'wait_for'."
377
395
  },
@@ -383,8 +401,9 @@
383
401
  },
384
402
  "stderr": {
385
403
  "type": "string",
386
- "description": "Ignored; accepted for compatibility with urlwatch (command jobs).",
387
- "deprecated": true
404
+ "enum": ["ignore", "forward", "fail", "stdout"],
405
+ "description": "What to do with the standard error output of a command job: 'ignore' reports it only when the command fails (exits with a non-zero code), 'forward' also writes it to webchanges' own standard error, 'fail' fails the job if there is any, and 'stdout' merges it into the standard output, i.e. into the data. For backward compatibility, 'urlwatch' is accepted for 'forward'.",
406
+ "default": "ignore"
388
407
  },
389
408
  "differ": {
390
409
  "oneOf": [
@@ -744,6 +763,11 @@
744
763
  "type": "boolean",
745
764
  "description": "If true, only report the first occurrence of an error."
746
765
  },
766
+ "treat_new_as_changed": {
767
+ "type": "boolean",
768
+ "description": "If true, report a new job (its first successful run) as changed, with its content shown as a diff from empty content, instead of as new.",
769
+ "default": false
770
+ },
747
771
  "compared_versions": {
748
772
  "type": "integer",
749
773
  "description": "Number of versions to keep/compare."
@@ -72,7 +72,12 @@ def migrate_from_legacy(
72
72
  ('hooks.py', hooks_file),
73
73
  ]
74
74
  if any(dest is not None for _, dest in yaml_dests):
75
+ # Like urlwatch itself: ~/.urlwatch if it exists, otherwise the platform's configuration directory (e.g.
76
+ # ~/.config/urlwatch on Linux).
75
77
  legacy_project_path = Path.home() / f'.{legacy_package}'
78
+ if not legacy_project_path.is_dir():
79
+ legacy_project_path = platformdirs.user_config_path(legacy_package)
80
+ logger.debug('Looking for legacy %s files in %s', legacy_package, legacy_project_path)
76
81
  if legacy_project_path.is_dir():
77
82
  pairs.extend((legacy_project_path / name, dest) for name, dest in yaml_dests if dest is not None)
78
83
 
@@ -103,19 +103,23 @@ class UrlwatchCommand:
103
103
  def jobs_from_joblist(self) -> Iterator[JobBase]:
104
104
  """Generates the jobs to process from the joblist and/or the tags entered in the CLI."""
105
105
  if self.urlwatcher.urlwatch_config.joblist:
106
- jobs = [self._find_job(job_entry) for job_entry in self.urlwatcher.urlwatch_config.joblist]
106
+ # A job selected more than once (e.g. by its index and by its URL, or by --prepare-jobs adding a new job
107
+ # that was also selected) is run only once.
108
+ jobs = list(
109
+ dict.fromkeys(self._find_job(job_entry) for job_entry in self.urlwatcher.urlwatch_config.joblist)
110
+ )
107
111
  enabled_jobs = [job for job in jobs if job.is_enabled()]
108
- disabled = len(enabled_jobs) - len(jobs)
112
+ disabled = len(jobs) - len(enabled_jobs)
109
113
  disabled_str = f' (excluding {disabled} disabled)' if disabled else ''
110
114
  logger.debug(
111
- f'Processing {len(enabled_jobs)} job{"s" if enabled_jobs else ""}{disabled_str} as specified in '
112
- f'command line: {", ".join(str(j) for j in self.urlwatcher.urlwatch_config.joblist)}'
115
+ f'Processing {len(enabled_jobs)} job{"s" if len(enabled_jobs) != 1 else ""}{disabled_str} as '
116
+ f'specified in command line: {", ".join(str(j) for j in self.urlwatcher.urlwatch_config.joblist)}'
113
117
  )
114
118
  else:
115
119
  enabled_jobs = [job for job in self.urlwatcher.jobs if job.is_enabled()]
116
- disabled = len(enabled_jobs) - len(self.urlwatcher.jobs)
120
+ disabled = len(self.urlwatcher.jobs) - len(enabled_jobs)
117
121
  disabled_str = f' (excluding {disabled} disabled)' if disabled else ''
118
- logger.debug(f'Processing {len(enabled_jobs)} job{"s" if enabled_jobs else ""}{disabled_str}')
122
+ logger.debug(f'Processing {len(enabled_jobs)} job{"s" if len(enabled_jobs) != 1 else ""}{disabled_str}')
119
123
 
120
124
  tags = self.tags_from_cli()
121
125
  if tags:
@@ -196,11 +200,12 @@ class UrlwatchCommand:
196
200
  def _find_job(self, query: str | int) -> JobBase:
197
201
  """Finds the job based on a query.
198
202
 
199
- It is matched to the job index (also negative) or a job location (i.e. the url/user_visible_url or command).
203
+ It is matched to the job index (also negative), a job name, or a job location (i.e. the url/user_visible_url
204
+ or command).
200
205
 
201
206
  :param query: The query.
202
207
  :return: The matching JobBase.
203
- :raises IndexError: If job is not found.
208
+ :raises ValueError: If no job, or more than one job, matches.
204
209
  """
205
210
  if isinstance(query, int):
206
211
  index = query
@@ -208,11 +213,25 @@ class UrlwatchCommand:
208
213
  try:
209
214
  index = int(query)
210
215
  except ValueError:
211
- query = unquote_plus(query)
212
- try:
213
- return next((job for job in self.urlwatcher.jobs if unquote_plus(job.get_location()) == query))
214
- except StopIteration:
215
- raise ValueError(f"Job {query} does not match any job's url/user_visible_url or command.") from None
216
+ location = unquote_plus(query)
217
+ jobs = [
218
+ job
219
+ for job in self.urlwatcher.jobs
220
+ if job.name == query or unquote_plus(job.get_location()) == location
221
+ ]
222
+ if not jobs:
223
+ raise ValueError(
224
+ f"Job {query} does not match any job's name, url/user_visible_url or command."
225
+ ) from None
226
+ if len(jobs) > 1:
227
+ raise ValueError(
228
+ f'Job {query} matches {len(jobs)} jobs; select the job by its index number instead:\n'
229
+ + '\n'.join(
230
+ f'• {job.get_indexed_location()}' + (f' ({job.name})' if job.name else '') for job in jobs
231
+ )
232
+ ) from None
233
+ logger.debug('Job %s matched %s', query, jobs[0].get_indexed_location())
234
+ return jobs[0]
216
235
 
217
236
  if index == 0:
218
237
  raise ValueError(f'Job index {index} out of range.')
@@ -226,7 +245,7 @@ class UrlwatchCommand:
226
245
  def _find_job_with_defaults(self, query: str | int) -> JobBase:
227
246
  """Returns the job with defaults based on job_id.
228
247
 
229
- This could match an index or a location (url/user_visible_url or command). Accepts negative numbers.
248
+ This could match an index, a name or a location (url/user_visible_url or command). Accepts negative numbers.
230
249
 
231
250
  :param query: The query.
232
251
  :return: The matching JobBase with defaults.
@@ -813,6 +832,8 @@ class UrlwatchCommand:
813
832
  ('--delete', self.urlwatch_config.delete),
814
833
  ('--add', self.urlwatch_config.add),
815
834
  ('--change-location', self.urlwatch_config.change_location),
835
+ ('--enable', self.urlwatch_config.enable),
836
+ ('--disable', self.urlwatch_config.disable),
816
837
  )
817
838
  if value is not None
818
839
  )
@@ -829,10 +850,13 @@ class UrlwatchCommand:
829
850
  if sys.__stdin__ and sys.__stdin__.isatty():
830
851
  print(
831
852
  f'WARNING: About to permanently delete {job.get_indexed_location()}.\n'
832
- ' Job file will be overwritten and all remarks lost.'
853
+ ' Job file will be overwritten and all remarks lost.\n'
833
854
  ' This operation cannot be undone!\n'
834
855
  )
835
- resp = input(" Please enter 'Y' to proceed: ")
856
+ try:
857
+ resp = input(" Please enter 'Y' to proceed: ")
858
+ except EOFError: # e.g. stdin redirected from NUL on Windows, which isatty() reports as a terminal
859
+ resp = ''
836
860
  if not resp.upper().startswith('Y'):
837
861
  print(f'Quitting. Job {job.index_number} has not been deleted and job file is unmodified.')
838
862
  return 1
@@ -885,11 +909,61 @@ class UrlwatchCommand:
885
909
  print(f'Job not found: "{self.urlwatch_config.change_location[0]}".')
886
910
  return 1
887
911
  message = 'Do you want me to update the jobs file (remarks will be lost)? [y/N] '
888
- if not input(message).lower().startswith('y'):
912
+ try:
913
+ resp = input(message)
914
+ except EOFError: # no input available (e.g. run by cron, or stdin redirected from the null device)
915
+ resp = ''
916
+ if not resp.lower().startswith('y'):
889
917
  print(f'Please manually update the jobs file by replacing "{old_loc}" with "{new_loc}".')
890
918
  else:
891
919
  self.urlwatcher.jobs_storage.save(self.urlwatcher.jobs)
892
920
 
921
+ for query, enable in ((self.urlwatch_config.enable, True), (self.urlwatch_config.disable, False)):
922
+ if query is not None and self._set_job_enabled(query, enable):
923
+ return 1
924
+
925
+ return 0
926
+
927
+ def _set_job_enabled(self, query: str, enable: bool) -> int:
928
+ """Enable or disable a job (urlwatch's --enable and --disable) by setting its 'enabled' directive and
929
+ rewriting the jobs file, which loses its remarks.
930
+
931
+ :param query: The job's index number, name or URL/command.
932
+ :param enable: True to enable the job, False to disable it.
933
+ :returns: 0 if successful (including when the job already was in the state requested), 1 otherwise.
934
+ """
935
+ verb = 'enable' if enable else 'disable'
936
+ try:
937
+ job = self._find_job(query)
938
+ except ValueError as e:
939
+ print(e)
940
+ return 1
941
+ config = self.urlwatcher.config_storage.config
942
+ if job.with_defaults(config).is_enabled() == enable:
943
+ print(f'Job {job.index_number} is already {verb}d: {job.get_location()}. The jobs file is unmodified.')
944
+ return 0
945
+ if sys.__stdin__ and sys.__stdin__.isatty():
946
+ print(
947
+ f'WARNING: About to {verb} {job.get_indexed_location()}.\n'
948
+ ' Job file will be overwritten and all remarks lost.\n'
949
+ )
950
+ try:
951
+ resp = input(" Please enter 'Y' to proceed: ")
952
+ except EOFError: # e.g. stdin redirected from NUL on Windows, which isatty() reports as a terminal
953
+ resp = ''
954
+ if not resp.upper().startswith('Y'):
955
+ print(f'Quitting. Job {job.index_number} has not been {verb}d and job file is unmodified.')
956
+ return 1
957
+ if enable:
958
+ # Remove the directive, unless the job would remain disabled by job_defaults
959
+ job.enabled = None
960
+ if not job.with_defaults(config).is_enabled():
961
+ job.enabled = True
962
+ else:
963
+ job.enabled = False
964
+ logger.info('Job %s: %sd (enabled: %s)', job.index_number, verb, job.enabled)
965
+ print(f'{verb.capitalize()}d job {job.index_number}: {job.get_location()}.')
966
+ self.urlwatcher.jobs_storage.save(self.urlwatcher.jobs)
893
967
  return 0
894
968
 
895
969
  def check_telegram_chats(self) -> None:
@@ -1227,7 +1301,13 @@ class UrlwatchCommand:
1227
1301
  if self.urlwatch_config.dump_history:
1228
1302
  self._exit(self.dump_history(self.urlwatch_config.dump_history))
1229
1303
 
1230
- if self.urlwatch_config.add or self.urlwatch_config.delete or self.urlwatch_config.change_location:
1304
+ if (
1305
+ self.urlwatch_config.add
1306
+ or self.urlwatch_config.delete
1307
+ or self.urlwatch_config.change_location
1308
+ or self.urlwatch_config.enable
1309
+ or self.urlwatch_config.disable
1310
+ ):
1231
1311
  self._exit(self.modify_urls())
1232
1312
 
1233
1313
  if self.urlwatch_config.test_reporter and not self.urlwatch_config.joblist:
@@ -42,10 +42,12 @@ class CommandConfig(BaseConfig):
42
42
  delete: str | None
43
43
  delete_snapshot: str | None
44
44
  detailed_versions: bool
45
+ disable: str | None
45
46
  dump_history: str | None
46
47
  edit: bool
47
48
  edit_config: bool
48
49
  edit_hooks: bool
50
+ enable: str | None
49
51
  errors: str | None
50
52
  features: bool
51
53
  footnote: str | None
@@ -136,7 +138,7 @@ class CommandConfig(BaseConfig):
136
138
  parser.add_argument(
137
139
  'joblist',
138
140
  nargs='*',
139
- help=('JOB(S) to run (index number(s) as per --list; if one also URL/command) (default: run all jobs)'),
141
+ help='JOB(S) to run (index number(s) as per --list, name(s) or URL(s)/command(s)) (default: run all jobs)',
140
142
  metavar='JOB(S)',
141
143
  )
142
144
  parser.add_argument(
@@ -216,6 +218,16 @@ class CommandConfig(BaseConfig):
216
218
  metavar='REGEX',
217
219
  dest='list_jobs',
218
220
  )
221
+ group.add_argument(
222
+ '--enable',
223
+ help='enable a JOB (by index, name or URL/command) by rewriting the jobs file, losing its remarks',
224
+ metavar='JOB',
225
+ )
226
+ group.add_argument(
227
+ '--disable',
228
+ help='disable a JOB (by index, name or URL/command) by rewriting the jobs file, losing its remarks',
229
+ metavar='JOB',
230
+ )
219
231
  group.add_argument(
220
232
  '--tags',
221
233
  action='append',
@@ -234,8 +246,8 @@ class CommandConfig(BaseConfig):
234
246
  '--test',
235
247
  nargs='?',
236
248
  const=True,
237
- help='test a JOB (by index or URL/command) and show filtered output; if no JOB, check config and jobs '
238
- 'file(s) syntax for errors',
249
+ help='test a JOB (by index, name or URL/command) and show filtered output; if no JOB, check config and '
250
+ 'jobs file(s) syntax for errors',
239
251
  metavar='JOB',
240
252
  dest='test_job',
241
253
  )
@@ -255,8 +267,8 @@ class CommandConfig(BaseConfig):
255
267
  group.add_argument(
256
268
  '--test-differ',
257
269
  nargs='+',
258
- help='show diff(s) using existing saved snapshots of a JOB (by index or URL/command); can be combined with '
259
- '--test-reporter',
270
+ help='show diff(s) using existing saved snapshots of a JOB (by index, name or URL/command); can be '
271
+ 'combined with --test-reporter',
260
272
  metavar='JOB',
261
273
  dest='test_differ',
262
274
  )
@@ -270,7 +282,7 @@ class CommandConfig(BaseConfig):
270
282
  )
271
283
  group.add_argument(
272
284
  '--dump-history',
273
- help='print all saved changed snapshots for a JOB (by index or URL/command)',
285
+ help='print all saved changed snapshots for a JOB (by index, name or URL/command)',
274
286
  metavar='JOB',
275
287
  )
276
288
  group.add_argument(
@@ -377,7 +389,7 @@ class CommandConfig(BaseConfig):
377
389
  )
378
390
  group.add_argument(
379
391
  '--delete-snapshot',
380
- help='delete the last saved changed snapshot of JOB (index or URL/command)',
392
+ help='delete the last saved changed snapshot of JOB (index, name or URL/command)',
381
393
  metavar='JOB',
382
394
  )
383
395
  group.add_argument(
@@ -388,7 +400,7 @@ class CommandConfig(BaseConfig):
388
400
  group.add_argument(
389
401
  '--change-location',
390
402
  nargs=2,
391
- help='change the location of an existing JOB (index or URL/command)',
403
+ help='change the location of an existing JOB (index, name or URL/command)',
392
404
  metavar=('JOB', 'NEW_LOCATION'),
393
405
  )
394
406
 
@@ -437,7 +449,7 @@ class CommandConfig(BaseConfig):
437
449
  )
438
450
  group.add_argument(
439
451
  '--delete',
440
- # help='delete a job (by index or URL/command) [use --edit-jobs instead]',
452
+ # help='delete a job (by index, name or URL/command) [use --edit-jobs instead]',
441
453
  help=argparse.SUPPRESS,
442
454
  metavar='JOB',
443
455
  )
@@ -234,6 +234,17 @@ class Html2TextFilter(FilterBase):
234
234
  options = subfilter.copy()
235
235
  method = options.pop('method', 'html2text')
236
236
 
237
+ if method == 'lynx': # urlwatch; lynx's options (e.g. width) mean nothing to the html2text package
238
+ ignored = f' and its options {", ".join(options)}, which are ignored' if options else ''
239
+ warnings.warn(
240
+ f"Filter html2text's method 'lynx' is no longer supported and the filter's default method is used "
241
+ f'instead: remove method{ignored} ({self.job.get_indexed_location()})',
242
+ DeprecationWarning,
243
+ stacklevel=1,
244
+ )
245
+ method = 'html2text'
246
+ options = {}
247
+
237
248
  if method in {'html2text', 'pyhtml2text'}: # pythtml2text for backward compatibility
238
249
  if method == 'pyhtml2text':
239
250
  warnings.warn(
@@ -290,12 +301,6 @@ class Html2TextFilter(FilterBase):
290
301
  stripped_tags = html.unescape(re.sub(r'<[^>]*>', '', data))
291
302
  return '\n'.join((line.rstrip() for line in stripped_tags.splitlines() if line.strip() != '')), 'text/plain'
292
303
 
293
- if method == 'lynx':
294
- raise NotImplementedError(
295
- f"Filter html2text's method 'lynx' is no longer supported; for similar results, use the filter without "
296
- f'specifying a method. ({self.job.get_indexed_location()})'
297
- )
298
-
299
304
  raise ValueError(f"Unknown method {method} for filter 'html2text'. ({self.job.get_indexed_location()})")
300
305
 
301
306