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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrape_cli
3
- Version: 1.3.1
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.6
71
+ - Python >=3.8
72
72
  - requests
73
73
  - lxml
74
74
  - cssselect
@@ -53,7 +53,7 @@ pip install -e .
53
53
 
54
54
  ## Requirements
55
55
 
56
- - Python >=3.6
56
+ - Python >=3.8
57
57
  - requests
58
58
  - lxml
59
59
  - cssselect
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "scrape_cli"
7
- version = "1.3.1"
7
+ version = "1.3.3"
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.1"
7
+ __version__ = "1.3.3"
8
8
  __author__ = "Andrea Borruso"
9
9
  __author_email__ = "aborruso@gmail.com"
10
10
 
@@ -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='Extract HTML elements using an XPath query or CSS3 selector.',
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 XPath expression
109
- scrape -e "h1.title" file.html CSS selector
110
- scrape -e "//a" -a href file.html extract attribute
111
- scrape -t file.html extract all text
112
- scrape -e "//h1" https://example.com fetch from URL
113
- cat file.html | scrape -e "//h1" read from stdin
114
- scrape -e "//h1" -x file.html check existence (exit 0/1)
115
- scrape -be "//article" file.html wrap output in <html><body>
116
- scrape -je "//article" file.html output as JSON (built-in)
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
- if '-eb' in sys.argv[1:]:
124
- print("Error: use -be not -eb.\n scrape -be \"//article\" file.html", file=sys.stderr)
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)
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 (file, URL or stdin, default: stdin)", metavar="HTML")
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="Argument to extract from the tag")
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="Include result in HTML and BODY tags")
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="Extract only text content (useful for LLMs)")
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 result as structured JSON")
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="Returns an exit value indicating existence")
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="Do not parse HTML before passing to etree (useful for CData)")
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="Custom User-Agent string for HTTP requests")
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
- els = list(document.xpath(e))
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(1 if len(els) == 0 else 0)
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(wrapped)
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("<!DOCTYPE html>\n<html>\n<body>\n")
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.1
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.6
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