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.
- {scrape_cli-1.3.0 → scrape_cli-1.3.2}/PKG-INFO +5 -2
- {scrape_cli-1.3.0 → scrape_cli-1.3.2}/README.md +4 -1
- {scrape_cli-1.3.0 → scrape_cli-1.3.2}/pyproject.toml +1 -1
- {scrape_cli-1.3.0 → scrape_cli-1.3.2}/scrape_cli/__init__.py +1 -1
- {scrape_cli-1.3.0 → scrape_cli-1.3.2}/scrape_cli/scrape.py +43 -16
- {scrape_cli-1.3.0 → scrape_cli-1.3.2}/scrape_cli.egg-info/PKG-INFO +5 -2
- {scrape_cli-1.3.0 → scrape_cli-1.3.2}/tests/test_scrape.py +106 -0
- {scrape_cli-1.3.0 → scrape_cli-1.3.2}/scrape_cli.egg-info/SOURCES.txt +0 -0
- {scrape_cli-1.3.0 → scrape_cli-1.3.2}/scrape_cli.egg-info/dependency_links.txt +0 -0
- {scrape_cli-1.3.0 → scrape_cli-1.3.2}/scrape_cli.egg-info/entry_points.txt +0 -0
- {scrape_cli-1.3.0 → scrape_cli-1.3.2}/scrape_cli.egg-info/requires.txt +0 -0
- {scrape_cli-1.3.0 → scrape_cli-1.3.2}/scrape_cli.egg-info/top_level.txt +0 -0
- {scrape_cli-1.3.0 → scrape_cli-1.3.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scrape_cli
|
|
3
|
-
Version: 1.3.
|
|
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`.
|
|
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`.
|
|
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:
|
|
@@ -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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
307
|
-
|
|
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(
|
|
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.
|
|
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`.
|
|
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&b=2">AT&T <b></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&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&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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|