scrape-cli 1.3.0__tar.gz → 1.3.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrape_cli
3
- Version: 1.3.0
3
+ Version: 1.3.1
4
4
  Summary: It's a command-line tool to extract HTML elements using an XPath query or CSS3 selector.
5
5
  Author-email: Andrea Borruso <aborruso@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/aborruso/scrape-cli
@@ -72,6 +72,7 @@ pip install -e .
72
72
  - requests
73
73
  - lxml
74
74
  - cssselect
75
+ - xmltodict
75
76
 
76
77
  ## How does it work?
77
78
 
@@ -280,10 +281,12 @@ Output:
280
281
  }
281
282
  ```
282
283
 
283
- `-j` automatically wraps the result in `<html>/<body>` before conversion, so you don't need to pass `-b`. The output is the same you would get from `scrape -be ... | xq .` (the underlying converter is `xmltodict`, the same library used by `xq`).
284
+ `-j` automatically wraps the result in `<html>/<body>` before conversion, so you don't need to pass `-b`. For elements, the output is the same you would get from `scrape -be ... | xq .` (the underlying converter is `xmltodict`, the same library used by `xq`). Expressions that return only strings, such as `//a/@href` or `//p/text()`, give the string in `body` when there is one result and an array when there are more.
284
285
 
285
286
  `-j` is mutually exclusive with `-t`, `-x` (`--check-existence`) and `-a` (`--argument`).
286
287
 
288
+ `-j` exits with an error when a matched element has an attribute name that is not valid XML, such as `@click` or `:bind`.
289
+
287
290
  Useful for JSON-based pipelines, APIs, databases, and processing with jq/DuckDB.
288
291
 
289
292
  Some notes on the commands:
@@ -57,6 +57,7 @@ pip install -e .
57
57
  - requests
58
58
  - lxml
59
59
  - cssselect
60
+ - xmltodict
60
61
 
61
62
  ## How does it work?
62
63
 
@@ -265,10 +266,12 @@ Output:
265
266
  }
266
267
  ```
267
268
 
268
- `-j` automatically wraps the result in `<html>/<body>` before conversion, so you don't need to pass `-b`. The output is the same you would get from `scrape -be ... | xq .` (the underlying converter is `xmltodict`, the same library used by `xq`).
269
+ `-j` automatically wraps the result in `<html>/<body>` before conversion, so you don't need to pass `-b`. For elements, the output is the same you would get from `scrape -be ... | xq .` (the underlying converter is `xmltodict`, the same library used by `xq`). Expressions that return only strings, such as `//a/@href` or `//p/text()`, give the string in `body` when there is one result and an array when there are more.
269
270
 
270
271
  `-j` is mutually exclusive with `-t`, `-x` (`--check-existence`) and `-a` (`--argument`).
271
272
 
273
+ `-j` exits with an error when a matched element has an attribute name that is not valid XML, such as `@click` or `:bind`.
274
+
272
275
  Useful for JSON-based pipelines, APIs, databases, and processing with jq/DuckDB.
273
276
 
274
277
  Some notes on the commands:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "scrape_cli"
7
- version = "1.3.0"
7
+ version = "1.3.1"
8
8
  description = "It's a command-line tool to extract HTML elements using an XPath query or CSS3 selector."
9
9
  readme = "README.md"
10
10
  authors = [
@@ -4,7 +4,7 @@ scrape-cli - A command-line tool to extract HTML elements using XPath or CSS3 se
4
4
 
5
5
  from scrape_cli.scrape import main
6
6
 
7
- __version__ = "1.3.0"
7
+ __version__ = "1.3.1"
8
8
  __author__ = "Andrea Borruso"
9
9
  __author_email__ = "aborruso@gmail.com"
10
10
 
@@ -6,7 +6,7 @@
6
6
  # $ curl 'https://en.wikipedia.org/wiki/List_of_sovereign_states' -s \
7
7
  # | scrape -e 'table.wikitable > tbody > tr > td > b > a'
8
8
  #
9
- # Dependencies: lxml, cssselect, requests
9
+ # Dependencies: lxml, cssselect, requests, xmltodict
10
10
  #
11
11
  # Author: http://jeroenjanssens.com
12
12
 
@@ -19,6 +19,8 @@ import requests
19
19
  import xmltodict
20
20
  from lxml import etree
21
21
  from cssselect import GenericTranslator
22
+ from xml.parsers.expat import ExpatError
23
+ from xml.sax.saxutils import escape
22
24
 
23
25
  # Read version from __init__.py
24
26
  init_file = os.path.join(os.path.dirname(__file__), '__init__.py')
@@ -118,11 +120,10 @@ examples:
118
120
  parser.add_argument('--version', action='version', version=f'%(prog)s {__version__}')
119
121
 
120
122
  # Check for incorrect argument order (-eb instead of -be, -ej instead of -je)
121
- joined_argv = ' '.join(sys.argv)
122
- if '-eb' in joined_argv:
123
+ if '-eb' in sys.argv[1:]:
123
124
  print("Error: use -be not -eb.\n scrape -be \"//article\" file.html", file=sys.stderr)
124
125
  sys.exit(1)
125
- if '-ej' in joined_argv:
126
+ if '-ej' in sys.argv[1:]:
126
127
  print("Error: use -je not -ej.\n scrape -je \"//article\" file.html", file=sys.stderr)
127
128
  sys.exit(1)
128
129
  # Defines the HTML input argument (can be a file, URL or stdin)
@@ -262,6 +263,7 @@ examples:
262
263
  sys.exit(1)
263
264
 
264
265
  results = []
266
+ string_results = []
265
267
  # For each expression, perform the search in the parsed HTML
266
268
  for e in expression:
267
269
  els = list(document.xpath(e))
@@ -278,6 +280,10 @@ examples:
278
280
  # Clean the text when using -t option
279
281
  if args.text:
280
282
  text = clean_text(text)
283
+ # Strings are plain text, but -j parses the results as XML
284
+ if args.json:
285
+ string_results.append(text.strip())
286
+ text = escape(text)
281
287
  elif args.text:
282
288
  # If -t option is used, extract only text content
283
289
  if hasattr(el, 'text_content'):
@@ -303,8 +309,18 @@ examples:
303
309
 
304
310
  # Output handling
305
311
  if args.json:
306
- wrapped = "<!DOCTYPE html>\n<html>\n<body>\n" + "".join(r + "\n" for r in results) + "</body>\n</html>\n"
307
- parsed = xmltodict.parse(wrapped)
312
+ values = [s for s in string_results if s]
313
+ if values and len(string_results) == len(results):
314
+ # Only strings (@attr, text()): XML conversion would merge them into one
315
+ parsed = {"html": {"body": values[0] if len(values) == 1 else values}}
316
+ else:
317
+ wrapped = "<!DOCTYPE html>\n<html>\n<body>\n" + "".join(r + "\n" for r in results) + "</body>\n</html>\n"
318
+ try:
319
+ parsed = xmltodict.parse(wrapped)
320
+ except ExpatError as e:
321
+ print(f"Error: cannot convert result to JSON, it is not valid XML ({e}). "
322
+ "Attribute names like @click or :bind are not supported by -j.", file=sys.stderr)
323
+ sys.exit(1)
308
324
  sys.stdout.write(json.dumps(parsed, indent=2, ensure_ascii=False) + "\n")
309
325
  elif args.body and not args.text:
310
326
  sys.stdout.write("<!DOCTYPE html>\n<html>\n<body>\n")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrape_cli
3
- Version: 1.3.0
3
+ Version: 1.3.1
4
4
  Summary: It's a command-line tool to extract HTML elements using an XPath query or CSS3 selector.
5
5
  Author-email: Andrea Borruso <aborruso@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/aborruso/scrape-cli
@@ -72,6 +72,7 @@ pip install -e .
72
72
  - requests
73
73
  - lxml
74
74
  - cssselect
75
+ - xmltodict
75
76
 
76
77
  ## How does it work?
77
78
 
@@ -280,10 +281,12 @@ Output:
280
281
  }
281
282
  ```
282
283
 
283
- `-j` automatically wraps the result in `<html>/<body>` before conversion, so you don't need to pass `-b`. The output is the same you would get from `scrape -be ... | xq .` (the underlying converter is `xmltodict`, the same library used by `xq`).
284
+ `-j` automatically wraps the result in `<html>/<body>` before conversion, so you don't need to pass `-b`. For elements, the output is the same you would get from `scrape -be ... | xq .` (the underlying converter is `xmltodict`, the same library used by `xq`). Expressions that return only strings, such as `//a/@href` or `//p/text()`, give the string in `body` when there is one result and an array when there are more.
284
285
 
285
286
  `-j` is mutually exclusive with `-t`, `-x` (`--check-existence`) and `-a` (`--argument`).
286
287
 
288
+ `-j` exits with an error when a matched element has an attribute name that is not valid XML, such as `@click` or `:bind`.
289
+
287
290
  Useful for JSON-based pipelines, APIs, databases, and processing with jq/DuckDB.
288
291
 
289
292
  Some notes on the commands:
@@ -183,6 +183,26 @@ def test_incorrect_eb_order_exits_with_specific_message():
183
183
  assert "use -be not -eb" in result.stderr
184
184
 
185
185
 
186
+ def test_incorrect_ej_order_exits_with_specific_message():
187
+ result = run_scrape("-ej")
188
+
189
+ assert result.returncode == 1
190
+ assert "use -je not -ej" in result.stderr
191
+
192
+
193
+ def test_order_checks_ignore_filenames_and_selectors(tmp_path):
194
+ for name in ("page-eb.html", "page-ej.html"):
195
+ html_file = tmp_path / name
196
+ html_file.write_text("<h1>ok</h1>", encoding="utf-8")
197
+ result = run_scrape(str(html_file), "-e", "h1")
198
+ assert result.returncode == 0, result.stderr
199
+ assert "ok" in result.stdout
200
+
201
+ for selector in (".page-ejected", ".page-ebook"):
202
+ result = run_scrape("-e", selector, input_data='<p class="x">ok</p>')
203
+ assert "use -" not in result.stderr
204
+
205
+
186
206
  def test_invalid_css_selector_fails_conversion():
187
207
  result = run_scrape(str(TEST_HTML), "-e", "div[")
188
208
 
@@ -235,6 +255,55 @@ def test_json_flag_conflicts_with_text_check_existence_argument():
235
255
  assert "cannot be combined" in ja.stderr
236
256
 
237
257
 
258
+ def test_json_flag_handles_string_results_with_special_characters():
259
+ html = '<a href="x?a=1&amp;b=2">AT&amp;T &lt;b&gt;</a>'
260
+
261
+ attr = run_scrape("-je", "//a/@href", input_data=html)
262
+ assert attr.returncode == 0, attr.stderr
263
+ assert json.loads(attr.stdout)["html"]["body"] == "x?a=1&b=2"
264
+
265
+ text = run_scrape("-je", "//a/text()", input_data=html)
266
+ assert text.returncode == 0, text.stderr
267
+ assert json.loads(text.stdout)["html"]["body"] == "AT&T <b>"
268
+
269
+
270
+ def test_json_flag_returns_array_for_multiple_string_results():
271
+ html = '<a href="x?a=1&amp;b=2">uno</a> <a href="y">due</a>'
272
+
273
+ attrs = run_scrape("-je", "//a/@href", input_data=html)
274
+ assert attrs.returncode == 0, attrs.stderr
275
+ assert json.loads(attrs.stdout)["html"]["body"] == ["x?a=1&b=2", "y"]
276
+
277
+ # Whitespace-only text nodes are not results
278
+ texts = run_scrape("-je", "//body//text()", input_data=html)
279
+ assert json.loads(texts.stdout)["html"]["body"] == ["uno", "due"]
280
+
281
+
282
+ def test_json_flag_mixed_elements_and_strings_still_converts():
283
+ result = run_scrape("-je", "//a | //a/@href", input_data='<a href="x&amp;y">uno</a>')
284
+
285
+ assert result.returncode == 0, result.stderr
286
+ body = json.loads(result.stdout)["html"]["body"]
287
+ assert body["a"]["#text"] == "uno"
288
+ assert body["#text"] == "x&y"
289
+
290
+
291
+ def test_json_flag_text_results_work_on_test_file():
292
+ result = run_scrape(str(TEST_HTML), "-je", "//p/text()")
293
+
294
+ assert result.returncode == 0, result.stderr
295
+ assert json.loads(result.stdout)["html"]["body"]
296
+
297
+
298
+ def test_json_flag_reports_clean_error_on_non_xml_attribute_name():
299
+ result = run_scrape("-je", "#v", input_data='<div id="v" @click="go">x</div>')
300
+
301
+ assert result.returncode == 1
302
+ assert "Error:" in result.stderr
303
+ assert "Traceback" not in result.stderr
304
+ assert result.stdout == ""
305
+
306
+
238
307
  def test_url_input_downloads_and_extracts_text():
239
308
  html_bytes = TEST_HTML.read_bytes()
240
309
  server, thread = run_test_server(html_bytes)
File without changes