scrape-cli 1.3.5__tar.gz → 1.3.6__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.5
3
+ Version: 1.3.6
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
@@ -285,6 +285,8 @@ Output:
285
285
 
286
286
  `-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.
287
287
 
288
+ Use `--json-array` when a script needs one shape for any number of results. The output is a flat list with no `html`/`body` envelope, one item per result, in the order of the expressions. Strings stay strings and elements become objects with the tag as key, so `scrape --json-array -e "//a/@href" -e "b"` gives `["x", "y", {"b": "tre"}]`, and no results give `[]`. It implies `-j` and has the same limits.
289
+
288
290
  `-j` is mutually exclusive with `-t`, `-x` (`--check-existence`) and `-a` (`--argument`).
289
291
 
290
292
  `-j` exits with an error when a matched element has an attribute name that is not valid XML, such as `@click` or `:bind`.
@@ -268,6 +268,8 @@ Output:
268
268
 
269
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.
270
270
 
271
+ Use `--json-array` when a script needs one shape for any number of results. The output is a flat list with no `html`/`body` envelope, one item per result, in the order of the expressions. Strings stay strings and elements become objects with the tag as key, so `scrape --json-array -e "//a/@href" -e "b"` gives `["x", "y", {"b": "tre"}]`, and no results give `[]`. It implies `-j` and has the same limits.
272
+
271
273
  `-j` is mutually exclusive with `-t`, `-x` (`--check-existence`) and `-a` (`--argument`).
272
274
 
273
275
  `-j` exits with an error when a matched element has an attribute name that is not valid XML, such as `@click` or `:bind`.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "scrape_cli"
7
- version = "1.3.5"
7
+ version = "1.3.6"
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.5"
7
+ __version__ = "1.3.6"
8
8
  __author__ = "Andrea Borruso"
9
9
  __author_email__ = "aborruso@gmail.com"
10
10
 
@@ -133,6 +133,7 @@ examples:
133
133
  scrape -t file.html all body text, no script or style
134
134
  scrape -te "//article" file.html text of the matching elements
135
135
  scrape -je "a" file.html JSON
136
+ scrape --json-array -e "a" file.html JSON, always a flat list
136
137
  scrape -be "//article" file.html wrap output in <html><body>
137
138
  scrape -x -e "//h1" file.html test only: exit code, no output
138
139
  scrape -e "//h1" https://example.com fetch from URL
@@ -147,6 +148,8 @@ output:
147
148
  is an object, several are a list
148
149
  strings one is a string, several are a list
149
150
  nothing null
151
+ --json-array always a flat list, one item per result, in expression order:
152
+ [] for nothing, strings as strings, elements as {"tag": ...}
150
153
 
151
154
  exit codes:
152
155
  0 success, also when nothing matches: empty output, or null body with -j
@@ -155,6 +158,7 @@ exit codes:
155
158
 
156
159
  notes:
157
160
  - No match is not an error: use -x to test for it.
161
+ - HTML with no elements, such as only spaces or comments, exits 1.
158
162
  - With short flags together, -e goes last: -be, -je, -te. As an argument on
159
163
  its own, -eb or -ej is rejected, because -e would take "b" or "j" as value.
160
164
  - -x evaluates only the first -e.
@@ -192,6 +196,8 @@ notes:
192
196
  # Option to output structured JSON (built-in, no external xq needed)
193
197
  parser.add_argument('-j', '--json', action='store_true', default=False,
194
198
  help="Output structured JSON, see below. Not with -t, -x, -a")
199
+ parser.add_argument('--json-array', action='store_true', default=False,
200
+ help="Output JSON as a flat list, one item per result, see below. Same limits as -j")
195
201
  # Allows to specify one or more XPath or CSS3 selector expressions
196
202
  parser.add_argument('-e', '--expression', default=[], action='append',
197
203
  help="XPath query or CSS3 selector, detected automatically. Repeatable")
@@ -207,6 +213,8 @@ notes:
207
213
  args = parser.parse_args()
208
214
 
209
215
  # JSON flag is mutually exclusive with -t, -x, -a
216
+ if args.json_array:
217
+ args.json = True
210
218
  if args.json and args.text:
211
219
  print("Error: --json and --text are mutually exclusive.", file=sys.stderr)
212
220
  sys.exit(1)
@@ -314,12 +322,18 @@ notes:
314
322
  inp = inp.decode('iso-8859-1').encode('utf-8')
315
323
  document = etree.fromstring(inp, html_parser)
316
324
  except (etree.XMLSyntaxError, UnicodeDecodeError) as e:
317
- # Print an error in case of syntax issues in the HTML
318
- print(f"Error parsing HTML: {e}", file=sys.stderr)
325
+ # Print an error in case of syntax issues in the input
326
+ print(f"Error parsing {'XML' if args.rawinput else 'HTML'}: {e}", file=sys.stderr)
327
+ sys.exit(1)
328
+
329
+ if document is None:
330
+ print("Error parsing HTML: no elements found in the input", file=sys.stderr)
319
331
  sys.exit(1)
320
332
 
321
333
  results = []
322
334
  string_results = []
335
+ # --json-array items: str for strings, bytes for element markup
336
+ array_items = []
323
337
  # For each expression, perform the search in the parsed HTML
324
338
  for e in expression:
325
339
  try:
@@ -336,6 +350,13 @@ notes:
336
350
 
337
351
  # Extract the text or content of the found elements
338
352
  for el in els:
353
+ if args.json_array:
354
+ if isinstance(el, str):
355
+ if el.strip():
356
+ array_items.append(el.strip())
357
+ elif isinstance(el.tag, str): # not a comment or processing instruction
358
+ array_items.append(etree.tostring(el, with_tail=False))
359
+ continue
339
360
  if isinstance(el, str):
340
361
  # If the element is a string, use the text directly
341
362
  text = el
@@ -356,7 +377,7 @@ notes:
356
377
  text = clean_text(text)
357
378
  elif not args.argument:
358
379
  # If no attribute is specified, return the element as HTML string
359
- text = etree.tostring(el, pretty_print=True).decode('utf-8')
380
+ text = etree.tostring(el, pretty_print=True, with_tail=False).decode('utf-8')
360
381
  else:
361
382
  # Otherwise, extract the specified attribute
362
383
  text = el.get(args.argument)
@@ -372,16 +393,18 @@ notes:
372
393
  # Output handling
373
394
  if args.json:
374
395
  values = [s for s in string_results if s]
375
- if values and len(string_results) == len(results):
376
- # Only strings (@attr, text()): XML conversion would merge them into one
377
- parsed = {"html": {"body": values[0] if len(values) == 1 else values}}
378
- else:
379
- try:
396
+ try:
397
+ if args.json_array:
398
+ parsed = [xmltodict.parse(i) if isinstance(i, bytes) else i for i in array_items]
399
+ elif values and len(string_results) == len(results):
400
+ # Only strings (@attr, text()): XML conversion would merge them into one
401
+ parsed = {"html": {"body": values[0] if len(values) == 1 else values}}
402
+ else:
380
403
  parsed = xmltodict.parse(wrap_in_body(results))
381
- except ExpatError as e:
382
- print(f"Error: cannot convert result to JSON, it is not valid XML ({e}). "
383
- "Attribute names like @click or :bind are not supported by -j.", file=sys.stderr)
384
- sys.exit(1)
404
+ except ExpatError as e:
405
+ print(f"Error: cannot convert result to JSON, it is not valid XML ({e}). "
406
+ "Attribute names like @click or :bind are not supported by -j.", file=sys.stderr)
407
+ sys.exit(1)
385
408
  sys.stdout.write(json.dumps(parsed, indent=2, ensure_ascii=False) + "\n")
386
409
  elif args.body and not args.text:
387
410
  sys.stdout.write(wrap_in_body(results))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrape_cli
3
- Version: 1.3.5
3
+ Version: 1.3.6
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
@@ -285,6 +285,8 @@ Output:
285
285
 
286
286
  `-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.
287
287
 
288
+ Use `--json-array` when a script needs one shape for any number of results. The output is a flat list with no `html`/`body` envelope, one item per result, in the order of the expressions. Strings stay strings and elements become objects with the tag as key, so `scrape --json-array -e "//a/@href" -e "b"` gives `["x", "y", {"b": "tre"}]`, and no results give `[]`. It implies `-j` and has the same limits.
289
+
288
290
  `-j` is mutually exclusive with `-t`, `-x` (`--check-existence`) and `-a` (`--argument`).
289
291
 
290
292
  `-j` exits with an error when a matched element has an attribute name that is not valid XML, such as `@click` or `:bind`.
@@ -188,6 +188,12 @@ def test_short_check_existence_flag_x():
188
188
  assert missing.returncode == 1
189
189
 
190
190
 
191
+ def test_rawinput_malformed_xml_reports_xml_error():
192
+ result = run_scrape("-r", "-e", "//a", input_data="<root><a></root>")
193
+ assert result.returncode == 1
194
+ assert "Error parsing XML: " in result.stderr
195
+
196
+
191
197
  def test_rawinput_parses_xml_without_html_parser():
192
198
  xml_data = "<root><item>one</item><item>two</item></root>"
193
199
  result = run_scrape("-e", "//item[2]/text()", "-r", input_data=xml_data)
@@ -272,7 +278,7 @@ def test_help_documents_the_contract_for_agents():
272
278
  result = run_scrape("--help")
273
279
 
274
280
  assert result.returncode == 0
275
- for section in ("output:", "exit codes:", "notes:", "examples:"):
281
+ for section in ("output:", "exit codes:", "notes:", "examples:", "--json-array"):
276
282
  assert section in result.stdout
277
283
 
278
284
 
@@ -444,3 +450,97 @@ def test_url_input_downloads_and_extracts_text():
444
450
 
445
451
  assert result.returncode == 0
446
452
  assert result.stdout.strip() == "Welcome to the Test Page"
453
+
454
+
455
+ TAIL_HTML = '<p>Prima <a href="/x">Link</a> dopo.</p>'
456
+ ARRAY_HTML = (
457
+ '<html><body><!-- c --><p class="a">uno</p><p class="a">due</p>'
458
+ '<a href="x">l</a><a href="y">m</a><b>tre</b></body></html>'
459
+ )
460
+
461
+
462
+ def test_text_after_element_stays_out_of_html_output():
463
+ result = run_scrape("-e", "//a", input_data=TAIL_HTML)
464
+ assert result.stdout.strip() == '<a href="/x">Link</a>'
465
+
466
+
467
+ def test_text_after_element_stays_out_of_body_output():
468
+ result = run_scrape("-be", "//a", input_data=TAIL_HTML)
469
+ assert "dopo" not in result.stdout
470
+
471
+
472
+ def test_text_after_element_stays_out_of_json_output():
473
+ result = run_scrape("-je", "//a", input_data=TAIL_HTML)
474
+ assert json.loads(result.stdout) == {"html": {"body": {"a": {"@href": "/x", "#text": "Link"}}}}
475
+
476
+
477
+ def json_array(*expressions, input_data=ARRAY_HTML):
478
+ args = ["--json-array"]
479
+ for expression in expressions:
480
+ args += ["-e", expression]
481
+ result = run_scrape(*args, input_data=input_data)
482
+ assert result.returncode == 0, result.stderr
483
+ return json.loads(result.stdout)
484
+
485
+
486
+ def test_json_array_no_results():
487
+ assert json_array("p.none") == []
488
+
489
+
490
+ def test_json_array_one_result():
491
+ assert json_array("b") == [{"b": "tre"}]
492
+
493
+
494
+ def test_json_array_several_results():
495
+ assert json_array("//a/@href") == ["x", "y"]
496
+ assert json_array("p") == [
497
+ {"p": {"@class": "a", "#text": "uno"}},
498
+ {"p": {"@class": "a", "#text": "due"}},
499
+ ]
500
+
501
+
502
+ def test_json_array_keeps_the_order_of_the_expressions():
503
+ assert json_array("//a/@href", "b") == ["x", "y", {"b": "tre"}]
504
+ assert json_array("b", "//a/@href") == [{"b": "tre"}, "x", "y"]
505
+
506
+
507
+ def test_json_array_element_item():
508
+ assert json_array("a")[0] == {"a": {"@href": "x", "#text": "l"}}
509
+
510
+
511
+ def test_json_array_leaves_out_text_after_element():
512
+ assert json_array("b", input_data="<p>a <b>x</b> coda</p>") == [{"b": "x"}]
513
+
514
+
515
+ def test_json_array_xpath_functions():
516
+ assert json_array("count(//p)") == ["2"]
517
+ assert json_array("string(//none)") == []
518
+
519
+
520
+ def test_json_array_skips_comments():
521
+ assert json_array("//comment()") == []
522
+
523
+
524
+ def test_json_array_rejects_conflicting_flags():
525
+ for flags in (["-t"], ["-x"], ["-a", "href"]):
526
+ result = run_scrape("--json-array", *flags, "-e", "a", input_data=ARRAY_HTML)
527
+ assert result.returncode == 1
528
+ assert "Error:" in result.stderr
529
+
530
+
531
+ def test_json_array_reports_clean_error_on_non_xml_attribute_name():
532
+ result = run_scrape("--json-array", "-e", "a", input_data='<a @click="x">l</a>')
533
+ assert result.returncode == 1
534
+ assert result.stdout == ""
535
+ assert "Error:" in result.stderr
536
+ assert "Traceback" not in result.stderr
537
+
538
+
539
+ def test_html_input_with_no_elements_reports_clean_error():
540
+ for html in (" ", "<!-- c -->", '<?xml version="1.0"?>'):
541
+ for flags in (["-e", "//a"], ["-t"], ["-j", "-e", "//a"], ["--json-array", "-e", "//a"]):
542
+ result = run_scrape(*flags, input_data=html)
543
+ assert result.returncode == 1
544
+ assert result.stdout == ""
545
+ assert "Error parsing HTML: no elements found" in result.stderr
546
+ assert "Traceback" not in result.stderr
File without changes