scrape-cli 1.2.4__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.2.4
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
@@ -11,6 +11,7 @@ Description-Content-Type: text/markdown
11
11
  Requires-Dist: cssselect
12
12
  Requires-Dist: lxml
13
13
  Requires-Dist: requests
14
+ Requires-Dist: xmltodict
14
15
 
15
16
  [![PyPI version](https://img.shields.io/pypi/v/scrape-cli.svg?label=PyPI%20version)](https://pypi.org/project/scrape-cli/)
16
17
  [![Python Versions](https://img.shields.io/pypi/pyversions/scrape-cli.svg)](https://pypi.org/project/scrape-cli/)
@@ -71,6 +72,7 @@ pip install -e .
71
72
  - requests
72
73
  - lxml
73
74
  - cssselect
75
+ - xmltodict
74
76
 
75
77
  ## How does it work?
76
78
 
@@ -230,13 +232,12 @@ scrape -te 'h1, h2, h3' resources/test.html
230
232
 
231
233
  The `-t` option automatically excludes text from `<script>` and `<style>` tags and cleans up whitespace for better readability.
232
234
 
233
- ### JSON Output Integration
235
+ ### JSON Output
234
236
 
235
- You can integrate scrape-cli with [xq](https://github.com/kislyuk/yq) (part of yq) to convert HTML output to structured JSON:
237
+ Use the `-j`/`--json` flag to get structured JSON natively, with no external tools:
236
238
 
237
239
  ```bash
238
- # Extract and convert to JSON (requires -b for complete HTML)
239
- scrape -be "a.external-link" resources/test.html | xq .
240
+ scrape -je "a.external-link" resources/test.html
240
241
  ```
241
242
 
242
243
  Output:
@@ -258,7 +259,7 @@ Output:
258
259
  Table extraction example:
259
260
 
260
261
  ```bash
261
- scrape -be "table.data-table td" resources/test.html | xq .
262
+ scrape -je "table.data-table td" resources/test.html
262
263
  ```
263
264
 
264
265
  Output:
@@ -268,19 +269,23 @@ Output:
268
269
  "html": {
269
270
  "body": {
270
271
  "td": [
271
- "1",
272
- "John Doe",
273
- "john@example.com",
274
- "2",
275
- "Jane Smith",
276
- "jane@example.com"
272
+ "Italy",
273
+ "Rome",
274
+ "59",
275
+ "France",
276
+ "Paris",
277
+ "68"
277
278
  ]
278
279
  }
279
280
  }
280
281
  }
281
282
  ```
282
283
 
283
- **Note**: The `-b` flag is mandatory to produce valid HTML with `<html>`, `<head>` and `<body>` tags.
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.
285
+
286
+ `-j` is mutually exclusive with `-t`, `-x` (`--check-existence`) and `-a` (`--argument`).
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`.
284
289
 
285
290
  Useful for JSON-based pipelines, APIs, databases, and processing with jq/DuckDB.
286
291
 
@@ -288,6 +293,7 @@ Some notes on the commands:
288
293
 
289
294
  - `-e` to set the query
290
295
  - `-b` to add `<html>`, `<head>` and `<body>` tags to the HTML output
296
+ - `-j` to output structured JSON (built-in)
291
297
  - `-t` to extract only text content (useful for LLMs and text processing)
292
298
 
293
299
 
@@ -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
 
@@ -216,13 +217,12 @@ scrape -te 'h1, h2, h3' resources/test.html
216
217
 
217
218
  The `-t` option automatically excludes text from `<script>` and `<style>` tags and cleans up whitespace for better readability.
218
219
 
219
- ### JSON Output Integration
220
+ ### JSON Output
220
221
 
221
- You can integrate scrape-cli with [xq](https://github.com/kislyuk/yq) (part of yq) to convert HTML output to structured JSON:
222
+ Use the `-j`/`--json` flag to get structured JSON natively, with no external tools:
222
223
 
223
224
  ```bash
224
- # Extract and convert to JSON (requires -b for complete HTML)
225
- scrape -be "a.external-link" resources/test.html | xq .
225
+ scrape -je "a.external-link" resources/test.html
226
226
  ```
227
227
 
228
228
  Output:
@@ -244,7 +244,7 @@ Output:
244
244
  Table extraction example:
245
245
 
246
246
  ```bash
247
- scrape -be "table.data-table td" resources/test.html | xq .
247
+ scrape -je "table.data-table td" resources/test.html
248
248
  ```
249
249
 
250
250
  Output:
@@ -254,19 +254,23 @@ Output:
254
254
  "html": {
255
255
  "body": {
256
256
  "td": [
257
- "1",
258
- "John Doe",
259
- "john@example.com",
260
- "2",
261
- "Jane Smith",
262
- "jane@example.com"
257
+ "Italy",
258
+ "Rome",
259
+ "59",
260
+ "France",
261
+ "Paris",
262
+ "68"
263
263
  ]
264
264
  }
265
265
  }
266
266
  }
267
267
  ```
268
268
 
269
- **Note**: The `-b` flag is mandatory to produce valid HTML with `<html>`, `<head>` and `<body>` tags.
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
+
271
+ `-j` is mutually exclusive with `-t`, `-x` (`--check-existence`) and `-a` (`--argument`).
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`.
270
274
 
271
275
  Useful for JSON-based pipelines, APIs, databases, and processing with jq/DuckDB.
272
276
 
@@ -274,6 +278,7 @@ Some notes on the commands:
274
278
 
275
279
  - `-e` to set the query
276
280
  - `-b` to add `<html>`, `<head>` and `<body>` tags to the HTML output
281
+ - `-j` to output structured JSON (built-in)
277
282
  - `-t` to extract only text content (useful for LLMs and text processing)
278
283
 
279
284
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "scrape_cli"
7
- version = "1.2.4"
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 = [
@@ -18,7 +18,8 @@ requires-python = ">=3.8"
18
18
  dependencies = [
19
19
  "cssselect",
20
20
  "lxml",
21
- "requests"
21
+ "requests",
22
+ "xmltodict"
22
23
  ]
23
24
 
24
25
  [project.urls]
@@ -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.2.4"
7
+ __version__ = "1.3.1"
8
8
  __author__ = "Andrea Borruso"
9
9
  __author_email__ = "aborruso@gmail.com"
10
10
 
@@ -6,17 +6,21 @@
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
 
13
13
  import os
14
14
  import sys
15
15
  import re
16
+ import json
16
17
  import argparse
17
18
  import requests
19
+ import xmltodict
18
20
  from lxml import etree
19
21
  from cssselect import GenericTranslator
22
+ from xml.parsers.expat import ExpatError
23
+ from xml.sax.saxutils import escape
20
24
 
21
25
  # Read version from __init__.py
22
26
  init_file = os.path.join(os.path.dirname(__file__), '__init__.py')
@@ -109,15 +113,19 @@ examples:
109
113
  cat file.html | scrape -e "//h1" read from stdin
110
114
  scrape -e "//h1" -x file.html check existence (exit 0/1)
111
115
  scrape -be "//article" file.html wrap output in <html><body>
116
+ scrape -je "//article" file.html output as JSON (built-in)
112
117
  '''
113
118
  )
114
119
 
115
120
  parser.add_argument('--version', action='version', version=f'%(prog)s {__version__}')
116
121
 
117
- # Check for incorrect argument order (-eb instead of -be)
118
- if '-eb' in ' '.join(sys.argv):
122
+ # Check for incorrect argument order (-eb instead of -be, -ej instead of -je)
123
+ if '-eb' in sys.argv[1:]:
119
124
  print("Error: use -be not -eb.\n scrape -be \"//article\" file.html", file=sys.stderr)
120
125
  sys.exit(1)
126
+ if '-ej' in sys.argv[1:]:
127
+ print("Error: use -je not -ej.\n scrape -je \"//article\" file.html", file=sys.stderr)
128
+ sys.exit(1)
121
129
  # Defines the HTML input argument (can be a file, URL or stdin)
122
130
  parser.add_argument('html', nargs='?', type=str, default='',
123
131
  help="HTML input (file, URL or stdin, default: stdin)", metavar="HTML")
@@ -130,6 +138,9 @@ examples:
130
138
  # Option to extract only text content
131
139
  parser.add_argument('-t', '--text', action='store_true', default=False,
132
140
  help="Extract only text content (useful for LLMs)")
141
+ # Option to output structured JSON (built-in, no external xq needed)
142
+ parser.add_argument('-j', '--json', action='store_true', default=False,
143
+ help="Output result as structured JSON")
133
144
  # Allows to specify one or more XPath or CSS3 selector expressions
134
145
  parser.add_argument('-e', '--expression', default=[], action='append',
135
146
  help="XPath query or CSS3 selector")
@@ -143,6 +154,17 @@ examples:
143
154
  help="Custom User-Agent string for HTTP requests")
144
155
  args = parser.parse_args()
145
156
 
157
+ # JSON flag is mutually exclusive with -t, -x, -a
158
+ if args.json and args.text:
159
+ print("Error: --json and --text are mutually exclusive.", file=sys.stderr)
160
+ sys.exit(1)
161
+ if args.json and args.check_existence:
162
+ print("Error: --json and --check-existence are mutually exclusive.", file=sys.stderr)
163
+ sys.exit(1)
164
+ if args.json and args.argument:
165
+ print("Error: --json cannot be combined with -a/--argument.", file=sys.stderr)
166
+ sys.exit(1)
167
+
146
168
  # Check that at least one expression is provided by the user (unless using -t option)
147
169
  if not args.expression and not args.text:
148
170
  print(
@@ -241,6 +263,7 @@ examples:
241
263
  sys.exit(1)
242
264
 
243
265
  results = []
266
+ string_results = []
244
267
  # For each expression, perform the search in the parsed HTML
245
268
  for e in expression:
246
269
  els = list(document.xpath(e))
@@ -257,6 +280,10 @@ examples:
257
280
  # Clean the text when using -t option
258
281
  if args.text:
259
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)
260
287
  elif args.text:
261
288
  # If -t option is used, extract only text content
262
289
  if hasattr(el, 'text_content'):
@@ -281,7 +308,21 @@ examples:
281
308
  results = [final_text] if final_text else []
282
309
 
283
310
  # Output handling
284
- if args.body and not args.text:
311
+ if args.json:
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)
324
+ sys.stdout.write(json.dumps(parsed, indent=2, ensure_ascii=False) + "\n")
325
+ elif args.body and not args.text:
285
326
  sys.stdout.write("<!DOCTYPE html>\n<html>\n<body>\n")
286
327
  for result in results:
287
328
  sys.stdout.write(result + "\n")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrape_cli
3
- Version: 1.2.4
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
@@ -11,6 +11,7 @@ Description-Content-Type: text/markdown
11
11
  Requires-Dist: cssselect
12
12
  Requires-Dist: lxml
13
13
  Requires-Dist: requests
14
+ Requires-Dist: xmltodict
14
15
 
15
16
  [![PyPI version](https://img.shields.io/pypi/v/scrape-cli.svg?label=PyPI%20version)](https://pypi.org/project/scrape-cli/)
16
17
  [![Python Versions](https://img.shields.io/pypi/pyversions/scrape-cli.svg)](https://pypi.org/project/scrape-cli/)
@@ -71,6 +72,7 @@ pip install -e .
71
72
  - requests
72
73
  - lxml
73
74
  - cssselect
75
+ - xmltodict
74
76
 
75
77
  ## How does it work?
76
78
 
@@ -230,13 +232,12 @@ scrape -te 'h1, h2, h3' resources/test.html
230
232
 
231
233
  The `-t` option automatically excludes text from `<script>` and `<style>` tags and cleans up whitespace for better readability.
232
234
 
233
- ### JSON Output Integration
235
+ ### JSON Output
234
236
 
235
- You can integrate scrape-cli with [xq](https://github.com/kislyuk/yq) (part of yq) to convert HTML output to structured JSON:
237
+ Use the `-j`/`--json` flag to get structured JSON natively, with no external tools:
236
238
 
237
239
  ```bash
238
- # Extract and convert to JSON (requires -b for complete HTML)
239
- scrape -be "a.external-link" resources/test.html | xq .
240
+ scrape -je "a.external-link" resources/test.html
240
241
  ```
241
242
 
242
243
  Output:
@@ -258,7 +259,7 @@ Output:
258
259
  Table extraction example:
259
260
 
260
261
  ```bash
261
- scrape -be "table.data-table td" resources/test.html | xq .
262
+ scrape -je "table.data-table td" resources/test.html
262
263
  ```
263
264
 
264
265
  Output:
@@ -268,19 +269,23 @@ Output:
268
269
  "html": {
269
270
  "body": {
270
271
  "td": [
271
- "1",
272
- "John Doe",
273
- "john@example.com",
274
- "2",
275
- "Jane Smith",
276
- "jane@example.com"
272
+ "Italy",
273
+ "Rome",
274
+ "59",
275
+ "France",
276
+ "Paris",
277
+ "68"
277
278
  ]
278
279
  }
279
280
  }
280
281
  }
281
282
  ```
282
283
 
283
- **Note**: The `-b` flag is mandatory to produce valid HTML with `<html>`, `<head>` and `<body>` tags.
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.
285
+
286
+ `-j` is mutually exclusive with `-t`, `-x` (`--check-existence`) and `-a` (`--argument`).
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`.
284
289
 
285
290
  Useful for JSON-based pipelines, APIs, databases, and processing with jq/DuckDB.
286
291
 
@@ -288,6 +293,7 @@ Some notes on the commands:
288
293
 
289
294
  - `-e` to set the query
290
295
  - `-b` to add `<html>`, `<head>` and `<body>` tags to the HTML output
296
+ - `-j` to output structured JSON (built-in)
291
297
  - `-t` to extract only text content (useful for LLMs and text processing)
292
298
 
293
299
 
@@ -1,3 +1,4 @@
1
1
  cssselect
2
2
  lxml
3
3
  requests
4
+ xmltodict
@@ -1,3 +1,4 @@
1
+ import json
1
2
  import subprocess
2
3
  import sys
3
4
  import threading
@@ -182,6 +183,26 @@ def test_incorrect_eb_order_exits_with_specific_message():
182
183
  assert "use -be not -eb" in result.stderr
183
184
 
184
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
+
185
206
  def test_invalid_css_selector_fails_conversion():
186
207
  result = run_scrape(str(TEST_HTML), "-e", "div[")
187
208
 
@@ -189,6 +210,100 @@ def test_invalid_css_selector_fails_conversion():
189
210
  assert "Error converting CSS selector to XPath" in result.stderr
190
211
 
191
212
 
213
+ def test_json_flag_produces_valid_json_for_single_element():
214
+ result = run_scrape(str(TEST_HTML), "-je", "a.external-link")
215
+
216
+ assert result.returncode == 0
217
+ payload = json.loads(result.stdout)
218
+ a = payload["html"]["body"]["a"]
219
+ assert a["@href"] == "https://example.com"
220
+ assert a["@class"] == "external-link"
221
+ assert a["#text"] == "Example Link"
222
+
223
+
224
+ def test_json_flag_produces_array_for_multiple_elements():
225
+ result = run_scrape(str(TEST_HTML), "-je", "table.data-table td")
226
+
227
+ assert result.returncode == 0
228
+ payload = json.loads(result.stdout)
229
+ tds = payload["html"]["body"]["td"]
230
+ assert isinstance(tds, list)
231
+ assert "Italy" in tds
232
+ assert "Rome" in tds
233
+
234
+
235
+ def test_json_flag_works_without_b_flag():
236
+ only_j = run_scrape(str(TEST_HTML), "-j", "-e", "a.external-link")
237
+ with_bj = run_scrape(str(TEST_HTML), "-bj", "-e", "a.external-link")
238
+
239
+ assert only_j.returncode == 0
240
+ assert with_bj.returncode == 0
241
+ assert only_j.stdout == with_bj.stdout
242
+
243
+
244
+ def test_json_flag_conflicts_with_text_check_existence_argument():
245
+ jt = run_scrape(str(TEST_HTML), "-j", "-t")
246
+ assert jt.returncode == 1
247
+ assert "mutually exclusive" in jt.stderr
248
+
249
+ jx = run_scrape(str(TEST_HTML), "-j", "-e", "//h1", "-x")
250
+ assert jx.returncode == 1
251
+ assert "mutually exclusive" in jx.stderr
252
+
253
+ ja = run_scrape(str(TEST_HTML), "-j", "-e", "//a", "-a", "href")
254
+ assert ja.returncode == 1
255
+ assert "cannot be combined" in ja.stderr
256
+
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
+
192
307
  def test_url_input_downloads_and_extracts_text():
193
308
  html_bytes = TEST_HTML.read_bytes()
194
309
  server, thread = run_test_server(html_bytes)
File without changes