scrape-cli 1.3.0__tar.gz → 1.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrape_cli
3
- Version: 1.3.0
3
+ Version: 1.3.2
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.2"
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.2"
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')
@@ -54,6 +56,21 @@ def convert_css_to_xpath(expression):
54
56
  print(f"Error converting CSS selector to XPath: {e}", file=sys.stderr)
55
57
  sys.exit(1)
56
58
 
59
+ def xpath_result_items(result):
60
+ """XPath functions return a single string, number or boolean instead of a node list."""
61
+ if isinstance(result, list):
62
+ return result
63
+ if isinstance(result, bool):
64
+ return ['true' if result else 'false']
65
+ if isinstance(result, float):
66
+ return [str(int(result)) if result.is_integer() else str(result)]
67
+ return [result] if result else []
68
+
69
+
70
+ def wrap_in_body(results):
71
+ return "<!DOCTYPE html>\n<html>\n<body>\n" + "".join(r + "\n" for r in results) + "</body>\n</html>\n"
72
+
73
+
57
74
  def is_xpath(expression):
58
75
  """
59
76
  Check if the expression is XPath by looking for common XPath patterns:
@@ -118,13 +135,10 @@ examples:
118
135
  parser.add_argument('--version', action='version', version=f'%(prog)s {__version__}')
119
136
 
120
137
  # 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
- print("Error: use -be not -eb.\n scrape -be \"//article\" file.html", file=sys.stderr)
124
- sys.exit(1)
125
- if '-ej' in joined_argv:
126
- print("Error: use -je not -ej.\n scrape -je \"//article\" file.html", file=sys.stderr)
127
- sys.exit(1)
138
+ for wrong, right in (('-eb', '-be'), ('-ej', '-je')):
139
+ if wrong in sys.argv[1:]:
140
+ print(f"Error: use {right} not {wrong}.\n scrape {right} \"//article\" file.html", file=sys.stderr)
141
+ sys.exit(1)
128
142
  # Defines the HTML input argument (can be a file, URL or stdin)
129
143
  parser.add_argument('html', nargs='?', type=str, default='',
130
144
  help="HTML input (file, URL or stdin, default: stdin)", metavar="HTML")
@@ -262,13 +276,16 @@ examples:
262
276
  sys.exit(1)
263
277
 
264
278
  results = []
279
+ string_results = []
265
280
  # For each expression, perform the search in the parsed HTML
266
281
  for e in expression:
267
- els = list(document.xpath(e))
282
+ result = document.xpath(e)
268
283
 
269
284
  # If check-existence is enabled, return 0 or 1 depending on the existence of elements
270
285
  if args.check_existence:
271
- sys.exit(1 if len(els) == 0 else 0)
286
+ sys.exit(0 if result else 1)
287
+
288
+ els = xpath_result_items(result)
272
289
 
273
290
  # Extract the text or content of the found elements
274
291
  for el in els:
@@ -278,6 +295,10 @@ examples:
278
295
  # Clean the text when using -t option
279
296
  if args.text:
280
297
  text = clean_text(text)
298
+ # Strings are plain text, but -j parses the results as XML
299
+ if args.json:
300
+ string_results.append(text.strip())
301
+ text = escape(text)
281
302
  elif args.text:
282
303
  # If -t option is used, extract only text content
283
304
  if hasattr(el, 'text_content'):
@@ -303,14 +324,20 @@ examples:
303
324
 
304
325
  # Output handling
305
326
  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)
327
+ values = [s for s in string_results if s]
328
+ if values and len(string_results) == len(results):
329
+ # Only strings (@attr, text()): XML conversion would merge them into one
330
+ parsed = {"html": {"body": values[0] if len(values) == 1 else values}}
331
+ else:
332
+ try:
333
+ parsed = xmltodict.parse(wrap_in_body(results))
334
+ except ExpatError as e:
335
+ print(f"Error: cannot convert result to JSON, it is not valid XML ({e}). "
336
+ "Attribute names like @click or :bind are not supported by -j.", file=sys.stderr)
337
+ sys.exit(1)
308
338
  sys.stdout.write(json.dumps(parsed, indent=2, ensure_ascii=False) + "\n")
309
339
  elif args.body and not args.text:
310
- sys.stdout.write("<!DOCTYPE html>\n<html>\n<body>\n")
311
- for result in results:
312
- sys.stdout.write(result + "\n")
313
- sys.stdout.write("</body>\n</html>\n")
340
+ sys.stdout.write(wrap_in_body(results))
314
341
  else:
315
342
  # Normal output (or text-only output when -t is used)
316
343
  for result in results:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrape_cli
3
- Version: 1.3.0
3
+ Version: 1.3.2
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,92 @@ 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
+ SCALAR_HTML = "<h1>Ciao</h1><a>1</a><a>2</a>"
292
+
293
+
294
+ def test_xpath_functions_return_a_single_value():
295
+ cases = {
296
+ "string(//h1)": "Ciao\n",
297
+ "count(//a)": "2\n",
298
+ "boolean(//a)": "true\n",
299
+ "boolean(//nulla)": "false\n",
300
+ "string(//nulla)": "",
301
+ }
302
+ for expression, expected in cases.items():
303
+ result = run_scrape("-e", expression, input_data=SCALAR_HTML)
304
+ assert result.returncode == 0, result.stderr
305
+ assert result.stdout == expected, expression
306
+
307
+
308
+ def test_check_existence_follows_xpath_function_value():
309
+ for expression, expected in {
310
+ "string(//h1)": 0,
311
+ "string(//nulla)": 1,
312
+ "count(//a)": 0,
313
+ "count(//nulla)": 1,
314
+ "boolean(//a)": 0,
315
+ "boolean(//nulla)": 1,
316
+ }.items():
317
+ result = run_scrape("-x", "-e", expression, input_data=SCALAR_HTML)
318
+ assert result.returncode == expected, expression
319
+
320
+
321
+ def test_json_flag_with_xpath_functions():
322
+ for expression, expected in {"string(//h1)": "Ciao", "count(//a)": "2"}.items():
323
+ result = run_scrape("-je", expression, input_data=SCALAR_HTML)
324
+ assert result.returncode == 0, result.stderr
325
+ assert json.loads(result.stdout)["html"]["body"] == expected
326
+
327
+
328
+ def test_json_flag_text_results_work_on_test_file():
329
+ result = run_scrape(str(TEST_HTML), "-je", "//p/text()")
330
+
331
+ assert result.returncode == 0, result.stderr
332
+ assert json.loads(result.stdout)["html"]["body"]
333
+
334
+
335
+ def test_json_flag_reports_clean_error_on_non_xml_attribute_name():
336
+ result = run_scrape("-je", "#v", input_data='<div id="v" @click="go">x</div>')
337
+
338
+ assert result.returncode == 1
339
+ assert "Error:" in result.stderr
340
+ assert "Traceback" not in result.stderr
341
+ assert result.stdout == ""
342
+
343
+
238
344
  def test_url_input_downloads_and_extracts_text():
239
345
  html_bytes = TEST_HTML.read_bytes()
240
346
  server, thread = run_test_server(html_bytes)
File without changes