scrape-cli 1.3.1__tar.gz → 1.3.3__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.1 → scrape_cli-1.3.3}/PKG-INFO +2 -2
- {scrape_cli-1.3.1 → scrape_cli-1.3.3}/README.md +1 -1
- {scrape_cli-1.3.1 → scrape_cli-1.3.3}/pyproject.toml +1 -1
- {scrape_cli-1.3.1 → scrape_cli-1.3.3}/scrape_cli/__init__.py +1 -1
- {scrape_cli-1.3.1 → scrape_cli-1.3.3}/scrape_cli/scrape.py +90 -33
- {scrape_cli-1.3.1 → scrape_cli-1.3.3}/scrape_cli.egg-info/PKG-INFO +2 -2
- {scrape_cli-1.3.1 → scrape_cli-1.3.3}/tests/test_scrape.py +77 -0
- {scrape_cli-1.3.1 → scrape_cli-1.3.3}/scrape_cli.egg-info/SOURCES.txt +0 -0
- {scrape_cli-1.3.1 → scrape_cli-1.3.3}/scrape_cli.egg-info/dependency_links.txt +0 -0
- {scrape_cli-1.3.1 → scrape_cli-1.3.3}/scrape_cli.egg-info/entry_points.txt +0 -0
- {scrape_cli-1.3.1 → scrape_cli-1.3.3}/scrape_cli.egg-info/requires.txt +0 -0
- {scrape_cli-1.3.1 → scrape_cli-1.3.3}/scrape_cli.egg-info/top_level.txt +0 -0
- {scrape_cli-1.3.1 → scrape_cli-1.3.3}/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.3
|
|
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
|
|
@@ -68,7 +68,7 @@ pip install -e .
|
|
|
68
68
|
|
|
69
69
|
## Requirements
|
|
70
70
|
|
|
71
|
-
- Python >=3.
|
|
71
|
+
- Python >=3.8
|
|
72
72
|
- requests
|
|
73
73
|
- lxml
|
|
74
74
|
- cssselect
|
|
@@ -56,6 +56,21 @@ def convert_css_to_xpath(expression):
|
|
|
56
56
|
print(f"Error converting CSS selector to XPath: {e}", file=sys.stderr)
|
|
57
57
|
sys.exit(1)
|
|
58
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
|
+
|
|
59
74
|
def is_xpath(expression):
|
|
60
75
|
"""
|
|
61
76
|
Check if the expression is XPath by looking for common XPath patterns:
|
|
@@ -101,57 +116,94 @@ def is_xpath(expression):
|
|
|
101
116
|
def main():
|
|
102
117
|
# Command line argument parser definition
|
|
103
118
|
parser = argparse.ArgumentParser(
|
|
104
|
-
description=
|
|
119
|
+
description=(
|
|
120
|
+
'Extract parts of an HTML page using an XPath query or CSS3 selector.\n'
|
|
121
|
+
'Reads static HTML and does not run JavaScript: content built in the\n'
|
|
122
|
+
'browser is not there. Never prompts for input.'
|
|
123
|
+
),
|
|
105
124
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
106
125
|
epilog='''\
|
|
107
126
|
examples:
|
|
108
|
-
scrape -e "//h1" file.html
|
|
109
|
-
scrape -e "h1.title" file.html
|
|
110
|
-
scrape -e "//a"
|
|
111
|
-
scrape -
|
|
112
|
-
scrape -e "//
|
|
113
|
-
|
|
114
|
-
scrape -
|
|
115
|
-
scrape -
|
|
116
|
-
scrape -je "
|
|
127
|
+
scrape -e "//h1" file.html XPath: matching elements as HTML
|
|
128
|
+
scrape -e "h1.title" file.html CSS selector, detected automatically
|
|
129
|
+
scrape -e "//a/@href" file.html attribute values as plain text
|
|
130
|
+
scrape -e "//a" -a href file.html same, elements without href skipped
|
|
131
|
+
scrape -e "count(//a)" file.html XPath function: one value (string,
|
|
132
|
+
number, true/false)
|
|
133
|
+
scrape -t file.html all body text, no script or style
|
|
134
|
+
scrape -te "//article" file.html text of the matching elements
|
|
135
|
+
scrape -je "a" file.html JSON
|
|
136
|
+
scrape -be "//article" file.html wrap output in <html><body>
|
|
137
|
+
scrape -x -e "//h1" file.html test only: exit code, no output
|
|
138
|
+
scrape -e "//h1" https://example.com fetch from URL
|
|
139
|
+
cat file.html | scrape -e "//h1" read from stdin
|
|
140
|
+
|
|
141
|
+
output:
|
|
142
|
+
default one result after the other, on stdout: elements as HTML, strings
|
|
143
|
+
(@attr, text(), XPath functions) as plain text
|
|
144
|
+
-e repeated: results follow the order of the expressions
|
|
145
|
+
-j always {"html": {"body": ...}}, where body is:
|
|
146
|
+
elements attributes as @name, text as #text; one match of a tag
|
|
147
|
+
is an object, several are a list
|
|
148
|
+
strings one is a string, several are a list
|
|
149
|
+
nothing null
|
|
150
|
+
|
|
151
|
+
exit codes:
|
|
152
|
+
0 success, also when nothing matches: empty output, or null body with -j
|
|
153
|
+
1 error, with a message on stderr; with -x, no match, with stderr empty
|
|
154
|
+
2 invalid usage: unknown option or missing option value
|
|
155
|
+
|
|
156
|
+
notes:
|
|
157
|
+
- No match is not an error: use -x to test for it.
|
|
158
|
+
- With short flags together, -e goes last: -be, -je, -te. As an argument on
|
|
159
|
+
its own, -eb or -ej is rejected, because -e would take "b" or "j" as value.
|
|
160
|
+
- -x evaluates only the first -e.
|
|
161
|
+
- -t with -e includes the text of any script or style inside the matches.
|
|
162
|
+
- -j fails with exit 1 on attribute names that are not valid XML, such as
|
|
163
|
+
@click or :bind. Use -e without -j on those pages.
|
|
164
|
+
- URLs: 30 s timeout, HTTP errors (4xx, 5xx) exit 1.
|
|
165
|
+
- Charset: from the meta tag, otherwise UTF-8. Other encodings need the meta
|
|
166
|
+
tag, or characters come out wrong.
|
|
117
167
|
'''
|
|
118
168
|
)
|
|
119
169
|
|
|
120
170
|
parser.add_argument('--version', action='version', version=f'%(prog)s {__version__}')
|
|
121
171
|
|
|
122
172
|
# Check for incorrect argument order (-eb instead of -be, -ej instead of -je)
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
print("Error: use -je not -ej.\n scrape -je \"//article\" file.html", file=sys.stderr)
|
|
128
|
-
sys.exit(1)
|
|
173
|
+
for wrong, right in (('-eb', '-be'), ('-ej', '-je')):
|
|
174
|
+
if wrong in sys.argv[1:]:
|
|
175
|
+
print(f"Error: use {right} not {wrong}.\n scrape {right} \"//article\" file.html", file=sys.stderr)
|
|
176
|
+
sys.exit(1)
|
|
129
177
|
# Defines the HTML input argument (can be a file, URL or stdin)
|
|
130
178
|
parser.add_argument('html', nargs='?', type=str, default='',
|
|
131
|
-
help="HTML input
|
|
179
|
+
help="HTML input: local file or http(s) URL. Stdin if omitted", metavar="HTML")
|
|
132
180
|
# Defines the optional argument to extract from the tag
|
|
133
181
|
parser.add_argument('-a', '--argument', default="",
|
|
134
|
-
help="
|
|
182
|
+
help="Output the value of this attribute instead of the element. "
|
|
183
|
+
"Elements without it are skipped")
|
|
135
184
|
# Option to include the result within HTML and BODY tags
|
|
136
185
|
parser.add_argument('-b', '--body', action='store_true', default=False,
|
|
137
|
-
help="
|
|
186
|
+
help="Wrap the output in a <!DOCTYPE html><html><body> document. "
|
|
187
|
+
"Ignored with -t and -j")
|
|
138
188
|
# Option to extract only text content
|
|
139
189
|
parser.add_argument('-t', '--text', action='store_true', default=False,
|
|
140
|
-
help="
|
|
190
|
+
help="Output text only, with whitespace cleaned. "
|
|
191
|
+
"Without -e, all body text except script and style")
|
|
141
192
|
# Option to output structured JSON (built-in, no external xq needed)
|
|
142
193
|
parser.add_argument('-j', '--json', action='store_true', default=False,
|
|
143
|
-
help="Output
|
|
194
|
+
help="Output structured JSON, see below. Not with -t, -x, -a")
|
|
144
195
|
# Allows to specify one or more XPath or CSS3 selector expressions
|
|
145
196
|
parser.add_argument('-e', '--expression', default=[], action='append',
|
|
146
|
-
help="XPath query or CSS3 selector")
|
|
197
|
+
help="XPath query or CSS3 selector, detected automatically. Repeatable")
|
|
147
198
|
# Option to verify the existence of elements matching the expression
|
|
148
199
|
parser.add_argument('-x', '--check-existence', dest='check_existence', action='store_true', default=False,
|
|
149
|
-
help="
|
|
200
|
+
help="Output nothing, exit 0 if the first expression matches, 1 if not")
|
|
150
201
|
# Option to avoid initial HTML parsing, useful in specific cases like CData
|
|
151
202
|
parser.add_argument('-r', '--rawinput', action='store_true', default=False,
|
|
152
|
-
help="
|
|
203
|
+
help="Parse the input as strict XML instead of HTML (useful for CData). "
|
|
204
|
+
"Malformed input is an error")
|
|
153
205
|
parser.add_argument('-u', '--user-agent', default=None,
|
|
154
|
-
help="
|
|
206
|
+
help="User-Agent for URL requests")
|
|
155
207
|
args = parser.parse_args()
|
|
156
208
|
|
|
157
209
|
# JSON flag is mutually exclusive with -t, -x, -a
|
|
@@ -195,6 +247,9 @@ examples:
|
|
|
195
247
|
except FileNotFoundError:
|
|
196
248
|
print(f"Error: The file '{args.html}' was not found.", file=sys.stderr)
|
|
197
249
|
sys.exit(1)
|
|
250
|
+
except OSError as e:
|
|
251
|
+
print(f"Error: cannot read '{args.html}': {e.strerror}", file=sys.stderr)
|
|
252
|
+
sys.exit(1)
|
|
198
253
|
else:
|
|
199
254
|
# If the input is from stdin
|
|
200
255
|
try:
|
|
@@ -266,11 +321,17 @@ examples:
|
|
|
266
321
|
string_results = []
|
|
267
322
|
# For each expression, perform the search in the parsed HTML
|
|
268
323
|
for e in expression:
|
|
269
|
-
|
|
324
|
+
try:
|
|
325
|
+
result = document.xpath(e)
|
|
326
|
+
except etree.XPathError as err:
|
|
327
|
+
print(f"Error: invalid XPath expression '{e}': {err}", file=sys.stderr)
|
|
328
|
+
sys.exit(1)
|
|
270
329
|
|
|
271
330
|
# If check-existence is enabled, return 0 or 1 depending on the existence of elements
|
|
272
331
|
if args.check_existence:
|
|
273
|
-
sys.exit(
|
|
332
|
+
sys.exit(0 if result else 1)
|
|
333
|
+
|
|
334
|
+
els = xpath_result_items(result)
|
|
274
335
|
|
|
275
336
|
# Extract the text or content of the found elements
|
|
276
337
|
for el in els:
|
|
@@ -314,19 +375,15 @@ examples:
|
|
|
314
375
|
# Only strings (@attr, text()): XML conversion would merge them into one
|
|
315
376
|
parsed = {"html": {"body": values[0] if len(values) == 1 else values}}
|
|
316
377
|
else:
|
|
317
|
-
wrapped = "<!DOCTYPE html>\n<html>\n<body>\n" + "".join(r + "\n" for r in results) + "</body>\n</html>\n"
|
|
318
378
|
try:
|
|
319
|
-
parsed = xmltodict.parse(
|
|
379
|
+
parsed = xmltodict.parse(wrap_in_body(results))
|
|
320
380
|
except ExpatError as e:
|
|
321
381
|
print(f"Error: cannot convert result to JSON, it is not valid XML ({e}). "
|
|
322
382
|
"Attribute names like @click or :bind are not supported by -j.", file=sys.stderr)
|
|
323
383
|
sys.exit(1)
|
|
324
384
|
sys.stdout.write(json.dumps(parsed, indent=2, ensure_ascii=False) + "\n")
|
|
325
385
|
elif args.body and not args.text:
|
|
326
|
-
sys.stdout.write(
|
|
327
|
-
for result in results:
|
|
328
|
-
sys.stdout.write(result + "\n")
|
|
329
|
-
sys.stdout.write("</body>\n</html>\n")
|
|
386
|
+
sys.stdout.write(wrap_in_body(results))
|
|
330
387
|
else:
|
|
331
388
|
# Normal output (or text-only output when -t is used)
|
|
332
389
|
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.3
|
|
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
|
|
@@ -68,7 +68,7 @@ pip install -e .
|
|
|
68
68
|
|
|
69
69
|
## Requirements
|
|
70
70
|
|
|
71
|
-
- Python >=3.
|
|
71
|
+
- Python >=3.8
|
|
72
72
|
- requests
|
|
73
73
|
- lxml
|
|
74
74
|
- cssselect
|
|
@@ -203,6 +203,46 @@ def test_order_checks_ignore_filenames_and_selectors(tmp_path):
|
|
|
203
203
|
assert "use -" not in result.stderr
|
|
204
204
|
|
|
205
205
|
|
|
206
|
+
def test_invalid_xpath_reports_clean_error():
|
|
207
|
+
result = run_scrape("-e", "//[bad", input_data="<p>x</p>")
|
|
208
|
+
|
|
209
|
+
assert result.returncode == 1
|
|
210
|
+
assert "Error: invalid XPath" in result.stderr
|
|
211
|
+
assert "Traceback" not in result.stderr
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_unreadable_input_reports_clean_error(tmp_path):
|
|
215
|
+
result = run_scrape("-e", "h1", str(tmp_path))
|
|
216
|
+
|
|
217
|
+
assert result.returncode == 1
|
|
218
|
+
assert "Error: cannot read" in result.stderr
|
|
219
|
+
assert "Traceback" not in result.stderr
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_help_documents_the_contract_for_agents():
|
|
223
|
+
result = run_scrape("--help")
|
|
224
|
+
|
|
225
|
+
assert result.returncode == 0
|
|
226
|
+
for section in ("output:", "exit codes:", "notes:", "examples:"):
|
|
227
|
+
assert section in result.stdout
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_exit_codes_match_help():
|
|
231
|
+
html = "<h1>x</h1>"
|
|
232
|
+
|
|
233
|
+
no_match = run_scrape("-e", "//nulla", input_data=html)
|
|
234
|
+
assert (no_match.returncode, no_match.stdout) == (0, "")
|
|
235
|
+
|
|
236
|
+
not_found = run_scrape("-x", "-e", "//nulla", input_data=html)
|
|
237
|
+
assert not_found.returncode == 1
|
|
238
|
+
assert "Error" not in not_found.stderr
|
|
239
|
+
|
|
240
|
+
assert run_scrape("--bogus", input_data=html).returncode == 2
|
|
241
|
+
|
|
242
|
+
first_only = run_scrape("-x", "-e", "//nulla", "-e", "//h1", input_data=html)
|
|
243
|
+
assert first_only.returncode == 1
|
|
244
|
+
|
|
245
|
+
|
|
206
246
|
def test_invalid_css_selector_fails_conversion():
|
|
207
247
|
result = run_scrape(str(TEST_HTML), "-e", "div[")
|
|
208
248
|
|
|
@@ -288,6 +328,43 @@ def test_json_flag_mixed_elements_and_strings_still_converts():
|
|
|
288
328
|
assert body["#text"] == "x&y"
|
|
289
329
|
|
|
290
330
|
|
|
331
|
+
SCALAR_HTML = "<h1>Ciao</h1><a>1</a><a>2</a>"
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def test_xpath_functions_return_a_single_value():
|
|
335
|
+
cases = {
|
|
336
|
+
"string(//h1)": "Ciao\n",
|
|
337
|
+
"count(//a)": "2\n",
|
|
338
|
+
"boolean(//a)": "true\n",
|
|
339
|
+
"boolean(//nulla)": "false\n",
|
|
340
|
+
"string(//nulla)": "",
|
|
341
|
+
}
|
|
342
|
+
for expression, expected in cases.items():
|
|
343
|
+
result = run_scrape("-e", expression, input_data=SCALAR_HTML)
|
|
344
|
+
assert result.returncode == 0, result.stderr
|
|
345
|
+
assert result.stdout == expected, expression
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def test_check_existence_follows_xpath_function_value():
|
|
349
|
+
for expression, expected in {
|
|
350
|
+
"string(//h1)": 0,
|
|
351
|
+
"string(//nulla)": 1,
|
|
352
|
+
"count(//a)": 0,
|
|
353
|
+
"count(//nulla)": 1,
|
|
354
|
+
"boolean(//a)": 0,
|
|
355
|
+
"boolean(//nulla)": 1,
|
|
356
|
+
}.items():
|
|
357
|
+
result = run_scrape("-x", "-e", expression, input_data=SCALAR_HTML)
|
|
358
|
+
assert result.returncode == expected, expression
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def test_json_flag_with_xpath_functions():
|
|
362
|
+
for expression, expected in {"string(//h1)": "Ciao", "count(//a)": "2"}.items():
|
|
363
|
+
result = run_scrape("-je", expression, input_data=SCALAR_HTML)
|
|
364
|
+
assert result.returncode == 0, result.stderr
|
|
365
|
+
assert json.loads(result.stdout)["html"]["body"] == expected
|
|
366
|
+
|
|
367
|
+
|
|
291
368
|
def test_json_flag_text_results_work_on_test_file():
|
|
292
369
|
result = run_scrape(str(TEST_HTML), "-je", "//p/text()")
|
|
293
370
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|