scrape-cli 1.3.5__tar.gz → 1.3.7__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.7
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,39 @@ 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
+
290
+ With mixed expressions, each result keeps its own form and its place in the order of the expressions. XPath function values are strings, so `count()` gives `"2"`, not `2`:
291
+
292
+ ```bash
293
+ scrape --json-array -e "h1" -e "//a/@href" -e "count(//a)" page.html
294
+ # [{"h1": "News"}, "/one", "/two", "2"]
295
+ ```
296
+
297
+ The list shape makes `jq` pipelines simple, with the same code for zero, one or many results:
298
+
299
+ ```bash
300
+ # Count the results
301
+ scrape --json-array -e "a" page.html | jq 'length'
302
+
303
+ # Loop over attribute values; with no results, the loop does not run
304
+ scrape --json-array -e "//a/@href" page.html | jq -r '.[]' | while read -r href; do
305
+ echo "got $href"
306
+ done
307
+
308
+ # Read fields of each element, as TSV
309
+ scrape --json-array -e "a" page.html | jq -r '.[].a | [.["@href"], .["#text"]] | @tsv'
310
+
311
+ # Filter elements by attribute
312
+ scrape --json-array -e "a" page.html | jq -c '.[] | select(.a["@class"] == "ext")'
313
+
314
+ # With mixed expressions, keep only the strings
315
+ scrape --json-array -e "h1" -e "//a/@href" page.html | jq -c 'map(strings)'
316
+
317
+ # Turn a count() into a number
318
+ scrape --json-array -e "count(//a)" page.html | jq '.[0] | tonumber'
319
+ ```
320
+
288
321
  `-j` is mutually exclusive with `-t`, `-x` (`--check-existence`) and `-a` (`--argument`).
289
322
 
290
323
  `-j` exits with an error when a matched element has an attribute name that is not valid XML, such as `@click` or `:bind`.
@@ -299,6 +332,68 @@ Some notes on the commands:
299
332
  - `-t` to extract only text content (useful for LLMs and text processing)
300
333
 
301
334
 
335
+ ### Several fields per element
336
+
337
+ Separate `-e` expressions lose the link between fields: if an article has no date, every following date ends up on the wrong article. Use `--row` to select the elements, and one `--field NAME=EXPR` for each value, relative to the row:
338
+
339
+ ```bash
340
+ scrape --row "article" --field "title=h2" --field "url=.//a/@href" --field "date=time/@datetime" page.html
341
+ ```
342
+
343
+ ```json
344
+ [
345
+ {"title": "First story", "url": "/one", "date": "2026-01-01"},
346
+ {"title": "Second story", "url": "/two", "date": null}
347
+ ]
348
+ ```
349
+
350
+ - Every object has the same keys, and every value is a string or `null`. `null` means no match, `""` a match with no text.
351
+ - A field gives only its first match. Elements give their cleaned text, and XPath functions a string.
352
+ - Field expressions are XPath or CSS, as with `-e`. Write attributes as `.//a/@href`: `a/@href` looks among direct children only, and `//a` is refused because it would read the whole document. In functions, write `string(.//h2)`.
353
+ - `--absolute-urls` and `--base-url` apply to `href` and `src` fields.
354
+ - `--row` cannot be combined with `-e`, `-t`, `-x`, `-a`, `-b`, `-j` or `--json-array`.
355
+
356
+ ### JSON Lines
357
+
358
+ Use `--jsonl` to get one JSON value per line: the objects of `--row`, or otherwise the items of `--json-array`. With no results the output is empty. It cannot be combined with `-t`, `-x`, `-a`, `-b` or `-j`.
359
+
360
+ ```bash
361
+ scrape --jsonl --row "article" --field "title=h2" --field "date=time/@datetime" page.html
362
+ # {"title": "First story", "date": "2026-01-01"}
363
+ # {"title": "Second story", "date": null}
364
+
365
+ # Articles with no date, with jq
366
+ scrape --jsonl --row "article" --field "title=h2" --field "date=time/@datetime" page.html \
367
+ | jq -r 'select(.date == null) | .title'
368
+
369
+ # Query with DuckDB
370
+ scrape --jsonl --row "article" --field "title=h2" --field "date=time/@datetime" page.html \
371
+ | duckdb -c "select title, date from read_json('/dev/stdin', format='newline_delimited')"
372
+ ```
373
+
374
+ The whole page is read and the output built in memory: `--jsonl` changes the format, not the memory use.
375
+
376
+ ### Absolute URLs
377
+
378
+ Links and images often have relative values. Use `--absolute-urls` to get absolute `href` and `src` values, in every output mode except `-t`:
379
+
380
+ ```bash
381
+ scrape --absolute-urls -e "//a/@href" https://www.iana.org/help/example-domains
382
+ ```
383
+
384
+ With stdin or a local file there is no page URL, so pass it with `--base-url`, which implies `--absolute-urls`:
385
+
386
+ ```bash
387
+ curl -s https://www.iana.org/help/example-domains \
388
+ | scrape --base-url https://www.iana.org/help/example-domains -e "//a/@href"
389
+ ```
390
+
391
+ - The base is the first `<base href>` of the page, resolved against the page URL, as in browsers. With a URL as input, the page URL is the final one after redirects.
392
+ - With no absolute base, the command exits with code 1.
393
+ - Selection uses the original values, so `a[href^="/"]` still matches.
394
+ - Other attributes, such as `srcset`, and XPath functions, such as `string(//a/@href)`, are not changed. Use `//a/@href`.
395
+ - `--absolute-urls` and `--base-url` cannot be combined with `-r`, and are ignored with `-t` and `-x`.
396
+
302
397
  ## License
303
398
 
304
399
  [MIT](LICENSE)
@@ -268,6 +268,39 @@ 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
+
273
+ With mixed expressions, each result keeps its own form and its place in the order of the expressions. XPath function values are strings, so `count()` gives `"2"`, not `2`:
274
+
275
+ ```bash
276
+ scrape --json-array -e "h1" -e "//a/@href" -e "count(//a)" page.html
277
+ # [{"h1": "News"}, "/one", "/two", "2"]
278
+ ```
279
+
280
+ The list shape makes `jq` pipelines simple, with the same code for zero, one or many results:
281
+
282
+ ```bash
283
+ # Count the results
284
+ scrape --json-array -e "a" page.html | jq 'length'
285
+
286
+ # Loop over attribute values; with no results, the loop does not run
287
+ scrape --json-array -e "//a/@href" page.html | jq -r '.[]' | while read -r href; do
288
+ echo "got $href"
289
+ done
290
+
291
+ # Read fields of each element, as TSV
292
+ scrape --json-array -e "a" page.html | jq -r '.[].a | [.["@href"], .["#text"]] | @tsv'
293
+
294
+ # Filter elements by attribute
295
+ scrape --json-array -e "a" page.html | jq -c '.[] | select(.a["@class"] == "ext")'
296
+
297
+ # With mixed expressions, keep only the strings
298
+ scrape --json-array -e "h1" -e "//a/@href" page.html | jq -c 'map(strings)'
299
+
300
+ # Turn a count() into a number
301
+ scrape --json-array -e "count(//a)" page.html | jq '.[0] | tonumber'
302
+ ```
303
+
271
304
  `-j` is mutually exclusive with `-t`, `-x` (`--check-existence`) and `-a` (`--argument`).
272
305
 
273
306
  `-j` exits with an error when a matched element has an attribute name that is not valid XML, such as `@click` or `:bind`.
@@ -282,6 +315,68 @@ Some notes on the commands:
282
315
  - `-t` to extract only text content (useful for LLMs and text processing)
283
316
 
284
317
 
318
+ ### Several fields per element
319
+
320
+ Separate `-e` expressions lose the link between fields: if an article has no date, every following date ends up on the wrong article. Use `--row` to select the elements, and one `--field NAME=EXPR` for each value, relative to the row:
321
+
322
+ ```bash
323
+ scrape --row "article" --field "title=h2" --field "url=.//a/@href" --field "date=time/@datetime" page.html
324
+ ```
325
+
326
+ ```json
327
+ [
328
+ {"title": "First story", "url": "/one", "date": "2026-01-01"},
329
+ {"title": "Second story", "url": "/two", "date": null}
330
+ ]
331
+ ```
332
+
333
+ - Every object has the same keys, and every value is a string or `null`. `null` means no match, `""` a match with no text.
334
+ - A field gives only its first match. Elements give their cleaned text, and XPath functions a string.
335
+ - Field expressions are XPath or CSS, as with `-e`. Write attributes as `.//a/@href`: `a/@href` looks among direct children only, and `//a` is refused because it would read the whole document. In functions, write `string(.//h2)`.
336
+ - `--absolute-urls` and `--base-url` apply to `href` and `src` fields.
337
+ - `--row` cannot be combined with `-e`, `-t`, `-x`, `-a`, `-b`, `-j` or `--json-array`.
338
+
339
+ ### JSON Lines
340
+
341
+ Use `--jsonl` to get one JSON value per line: the objects of `--row`, or otherwise the items of `--json-array`. With no results the output is empty. It cannot be combined with `-t`, `-x`, `-a`, `-b` or `-j`.
342
+
343
+ ```bash
344
+ scrape --jsonl --row "article" --field "title=h2" --field "date=time/@datetime" page.html
345
+ # {"title": "First story", "date": "2026-01-01"}
346
+ # {"title": "Second story", "date": null}
347
+
348
+ # Articles with no date, with jq
349
+ scrape --jsonl --row "article" --field "title=h2" --field "date=time/@datetime" page.html \
350
+ | jq -r 'select(.date == null) | .title'
351
+
352
+ # Query with DuckDB
353
+ scrape --jsonl --row "article" --field "title=h2" --field "date=time/@datetime" page.html \
354
+ | duckdb -c "select title, date from read_json('/dev/stdin', format='newline_delimited')"
355
+ ```
356
+
357
+ The whole page is read and the output built in memory: `--jsonl` changes the format, not the memory use.
358
+
359
+ ### Absolute URLs
360
+
361
+ Links and images often have relative values. Use `--absolute-urls` to get absolute `href` and `src` values, in every output mode except `-t`:
362
+
363
+ ```bash
364
+ scrape --absolute-urls -e "//a/@href" https://www.iana.org/help/example-domains
365
+ ```
366
+
367
+ With stdin or a local file there is no page URL, so pass it with `--base-url`, which implies `--absolute-urls`:
368
+
369
+ ```bash
370
+ curl -s https://www.iana.org/help/example-domains \
371
+ | scrape --base-url https://www.iana.org/help/example-domains -e "//a/@href"
372
+ ```
373
+
374
+ - The base is the first `<base href>` of the page, resolved against the page URL, as in browsers. With a URL as input, the page URL is the final one after redirects.
375
+ - With no absolute base, the command exits with code 1.
376
+ - Selection uses the original values, so `a[href^="/"]` still matches.
377
+ - Other attributes, such as `srcset`, and XPath functions, such as `string(//a/@href)`, are not changed. Use `//a/@href`.
378
+ - `--absolute-urls` and `--base-url` cannot be combined with `-r`, and are ignored with `-t` and `-x`.
379
+
285
380
  ## License
286
381
 
287
382
  [MIT](LICENSE)
@@ -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.7"
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.7"
8
8
  __author__ = "Andrea Borruso"
9
9
  __author_email__ = "aborruso@gmail.com"
10
10
 
@@ -15,10 +15,12 @@ import sys
15
15
  import re
16
16
  import json
17
17
  import argparse
18
+ import copy
18
19
  import requests
19
20
  import xmltodict
20
21
  from lxml import etree
21
22
  from cssselect import GenericTranslator
23
+ from urllib.parse import urljoin, urlsplit
22
24
  from xml.parsers.expat import ExpatError
23
25
  from xml.sax.saxutils import escape
24
26
 
@@ -67,6 +69,92 @@ def xpath_result_items(result):
67
69
  return [result] if result else []
68
70
 
69
71
 
72
+ def is_absolute_url(url):
73
+ try:
74
+ parts = urlsplit(url)
75
+ except ValueError:
76
+ return False
77
+ return bool(parts.scheme and parts.netloc)
78
+
79
+
80
+ def resolve_url(base, value):
81
+ """Return value as an absolute URL, or unchanged if it cannot be parsed."""
82
+ try:
83
+ return urljoin(base, value.strip())
84
+ except ValueError:
85
+ return value
86
+
87
+
88
+ def document_base(document, page_url):
89
+ """Base as in the HTML standard: the first <base href>, against the page URL."""
90
+ hrefs = document.xpath('(//base[@href])[1]/@href')
91
+ if hrefs:
92
+ base = resolve_url(page_url or '', hrefs[0])
93
+ if is_absolute_url(base):
94
+ return base
95
+ return page_url
96
+
97
+
98
+ def with_absolute_urls(el, base):
99
+ """Copy the element, so that later expressions still see the original values."""
100
+ el = copy.deepcopy(el)
101
+ for node in el.xpath('descendant-or-self::*[@href or @src]'):
102
+ for name in ('href', 'src'):
103
+ if name in node.attrib:
104
+ node.set(name, resolve_url(base, node.get(name)))
105
+ return el
106
+
107
+
108
+ def evaluate(context, expression):
109
+ try:
110
+ return context.xpath(expression)
111
+ except etree.XPathError as err:
112
+ print(f"Error: invalid XPath expression '{expression}': {err}", file=sys.stderr)
113
+ sys.exit(1)
114
+
115
+
116
+ def parse_fields(fields):
117
+ """Turn NAME=EXPR strings into {name: XPath}, exiting on invalid ones."""
118
+ parsed = {}
119
+ for field in fields:
120
+ name, _, expression = (part.strip() for part in field.partition('='))
121
+ if not name or not expression:
122
+ print(f"Error: --field must be NAME=EXPR, got '{field}'.", file=sys.stderr)
123
+ sys.exit(1)
124
+ if name in parsed:
125
+ print(f"Error: repeated --field name '{name}'.", file=sys.stderr)
126
+ sys.exit(1)
127
+ if expression.startswith(('/', '(/')):
128
+ print(f"Error: --field '{field}' reads the whole document on each row: "
129
+ "start the expression with .// instead.", file=sys.stderr)
130
+ sys.exit(1)
131
+ parsed[name] = expression if is_xpath(expression) else convert_css_to_xpath(expression)
132
+ return parsed
133
+
134
+
135
+ def field_value(row, expression, base):
136
+ """String form of the first match in the row, or None if there is none."""
137
+ for item in xpath_result_items(evaluate(row, expression)):
138
+ if isinstance(item, str):
139
+ is_attribute = getattr(item, 'is_attribute', False)
140
+ if base and is_attribute and item.attrname in ('href', 'src'):
141
+ return resolve_url(base, item)
142
+ if is_attribute or item.strip():
143
+ return item.strip()
144
+ elif isinstance(item.tag, str): # not a comment or processing instruction
145
+ return clean_text(''.join(item.itertext()))
146
+ return None
147
+
148
+
149
+ def write_json(value, lines):
150
+ """Print a JSON value, or with lines one item of the list per line."""
151
+ if lines:
152
+ for item in value:
153
+ sys.stdout.write(json.dumps(item, ensure_ascii=False) + "\n")
154
+ else:
155
+ sys.stdout.write(json.dumps(value, indent=2, ensure_ascii=False) + "\n")
156
+
157
+
70
158
  def wrap_in_body(results):
71
159
  return "<!DOCTYPE html>\n<html>\n<body>\n" + "".join(r + "\n" for r in results) + "</body>\n</html>\n"
72
160
 
@@ -84,7 +172,7 @@ def is_xpath(expression):
84
172
  expr = expression.strip()
85
173
 
86
174
  # Direct XPath patterns
87
- if expr.startswith('/') or expr.startswith('//'):
175
+ if expr.startswith('/') or expr.startswith('//') or expr.startswith('./'):
88
176
  return True
89
177
  if '::' in expr:
90
178
  return True
@@ -133,6 +221,15 @@ examples:
133
221
  scrape -t file.html all body text, no script or style
134
222
  scrape -te "//article" file.html text of the matching elements
135
223
  scrape -je "a" file.html JSON
224
+ scrape --json-array -e "a" file.html JSON, always a flat list
225
+ scrape --absolute-urls -e "//a/@href" https://example.org/
226
+ absolute href and src values
227
+ curl -s https://example.org/ | scrape --base-url https://example.org/ -e "a"
228
+ same, for stdin or a local file
229
+ scrape --row "article" --field "title=h2" --field "url=.//a/@href" file.html
230
+ one JSON object per article
231
+ scrape --jsonl -e "//a/@href" file.html
232
+ one JSON value per line
136
233
  scrape -be "//article" file.html wrap output in <html><body>
137
234
  scrape -x -e "//h1" file.html test only: exit code, no output
138
235
  scrape -e "//h1" https://example.com fetch from URL
@@ -147,6 +244,15 @@ output:
147
244
  is an object, several are a list
148
245
  strings one is a string, several are a list
149
246
  nothing null
247
+ --json-array always a flat list, one item per result, in expression order:
248
+ [] for nothing, strings as strings, elements as {"tag": ...},
249
+ function values as strings: count() gives "2"
250
+ --row a list with one object per row, one key per --field in the order
251
+ given, each value a string or null (no match); "" is a match with
252
+ no text. Elements give their cleaned text. Rows with no match in
253
+ any field stay in the list.
254
+ --jsonl one JSON value per line: the objects of --row, otherwise the items
255
+ of --json-array. Nothing at all when there are no results.
150
256
 
151
257
  exit codes:
152
258
  0 success, also when nothing matches: empty output, or null body with -j
@@ -155,13 +261,24 @@ exit codes:
155
261
 
156
262
  notes:
157
263
  - No match is not an error: use -x to test for it.
264
+ - HTML with no elements, such as only spaces or comments, exits 1.
158
265
  - With short flags together, -e goes last: -be, -je, -te. As an argument on
159
266
  its own, -eb or -ej is rejected, because -e would take "b" or "j" as value.
160
267
  - -x evaluates only the first -e.
161
268
  - -t with -e includes the text of any script or style inside the matches.
162
269
  - -j fails with exit 1 on attribute names that are not valid XML, such as
163
270
  @click or :bind. Use -e without -j on those pages.
271
+ - --field is relative to the row and gives only the first match. Write
272
+ .//a/@href: a/@href looks among direct children only, and //a is refused
273
+ because it would read the whole document. In functions, write
274
+ string(.//h2): string(//h2) is not refused and reads the whole document.
275
+ - --row is not allowed with -e, -t, -x, -a, -b, -j, --json-array.
276
+ --jsonl is not allowed with -t, -x, -a, -b, -j.
164
277
  - URLs: 30 s timeout, HTTP errors (4xx, 5xx) exit 1.
278
+ - --absolute-urls changes href and src in the output, not in selection:
279
+ a[href^="/"] still matches. Base: the first <base href>, against the page
280
+ URL or --base-url. No absolute base: exit 1. XPath functions such as
281
+ string(//a/@href) are not changed. Not with -r, ignored with -t and -x.
165
282
  - Charset: from the meta tag, otherwise UTF-8. If the bytes are not valid
166
283
  UTF-8: Windows-1252, then ISO-8859-1. Other encodings need the meta tag.
167
284
  '''
@@ -192,6 +309,18 @@ notes:
192
309
  # Option to output structured JSON (built-in, no external xq needed)
193
310
  parser.add_argument('-j', '--json', action='store_true', default=False,
194
311
  help="Output structured JSON, see below. Not with -t, -x, -a")
312
+ parser.add_argument('--json-array', action='store_true', default=False,
313
+ help="Output JSON as a flat list, one item per result, see below. Same limits as -j")
314
+ parser.add_argument('--absolute-urls', action='store_true', default=False,
315
+ help="Output href and src values as absolute URLs, see notes")
316
+ parser.add_argument('--base-url', default=None, metavar="URL",
317
+ help="Page URL for stdin and local files. Implies --absolute-urls")
318
+ parser.add_argument('--row', default=None, metavar="EXPR",
319
+ help="Select the elements that give one JSON object each. Needs --field")
320
+ parser.add_argument('--field', default=[], action='append', metavar="NAME=EXPR",
321
+ help="Value of a key in each --row object, relative to the row. Repeatable")
322
+ parser.add_argument('--jsonl', action='store_true', default=False,
323
+ help="Output one JSON value per line, see below. Implies --json-array without --row")
195
324
  # Allows to specify one or more XPath or CSS3 selector expressions
196
325
  parser.add_argument('-e', '--expression', default=[], action='append',
197
326
  help="XPath query or CSS3 selector, detected automatically. Repeatable")
@@ -206,19 +335,46 @@ notes:
206
335
  help="User-Agent for URL requests")
207
336
  args = parser.parse_args()
208
337
 
338
+ if args.jsonl and (args.text or args.check_existence or args.argument or args.body or args.json):
339
+ print("Error: --jsonl cannot be combined with -t, -x, -a, -b or -j.", file=sys.stderr)
340
+ sys.exit(1)
341
+ if bool(args.row) != bool(args.field):
342
+ print("Error: --row and --field must be used together.", file=sys.stderr)
343
+ sys.exit(1)
344
+ fields = {}
345
+ if args.row:
346
+ if (args.expression or args.text or args.check_existence or args.argument
347
+ or args.body or args.json or args.json_array):
348
+ print("Error: --row cannot be combined with -e, -t, -x, -a, -b, -j or --json-array.", file=sys.stderr)
349
+ sys.exit(1)
350
+ fields = parse_fields(args.field)
351
+ elif args.jsonl:
352
+ args.json_array = True
353
+
209
354
  # JSON flag is mutually exclusive with -t, -x, -a
355
+ json_flag = '--json-array' if args.json_array else '--json'
356
+ if args.json_array:
357
+ args.json = True
210
358
  if args.json and args.text:
211
- print("Error: --json and --text are mutually exclusive.", file=sys.stderr)
359
+ print(f"Error: {json_flag} and --text are mutually exclusive.", file=sys.stderr)
212
360
  sys.exit(1)
213
361
  if args.json and args.check_existence:
214
- print("Error: --json and --check-existence are mutually exclusive.", file=sys.stderr)
362
+ print(f"Error: {json_flag} and --check-existence are mutually exclusive.", file=sys.stderr)
215
363
  sys.exit(1)
216
364
  if args.json and args.argument:
217
- print("Error: --json cannot be combined with -a/--argument.", file=sys.stderr)
365
+ print(f"Error: {json_flag} cannot be combined with -a/--argument.", file=sys.stderr)
366
+ sys.exit(1)
367
+
368
+ if args.base_url and not is_absolute_url(args.base_url):
369
+ print(f"Error: --base-url must be an absolute URL, got '{args.base_url}'.", file=sys.stderr)
370
+ sys.exit(1)
371
+ absolute_urls = args.absolute_urls or bool(args.base_url)
372
+ if absolute_urls and args.rawinput:
373
+ print("Error: --absolute-urls and --base-url cannot be combined with -r/--rawinput.", file=sys.stderr)
218
374
  sys.exit(1)
219
375
 
220
376
  # Check that at least one expression is provided by the user (unless using -t option)
221
- if not args.expression and not args.text:
377
+ if not args.expression and not args.text and not args.row:
222
378
  print(
223
379
  "Error: no expression specified. Use -e \"//selector\" or -t.\n"
224
380
  " scrape -e \"//h1\" file.html\n"
@@ -227,6 +383,7 @@ notes:
227
383
  )
228
384
  sys.exit(1)
229
385
 
386
+ page_url = args.base_url
230
387
  # Determine the source of the input: URL, file, or stdin
231
388
  if args.html:
232
389
  if args.html.startswith('http://') or args.html.startswith('https://'):
@@ -237,6 +394,7 @@ notes:
237
394
  response = requests.get(args.html, headers=headers, timeout=30)
238
395
  response.raise_for_status()
239
396
  inp = response.content
397
+ page_url = page_url or response.url
240
398
  except requests.RequestException as e:
241
399
  print(f"Error downloading HTML: {e}", file=sys.stderr)
242
400
  sys.exit(1)
@@ -314,19 +472,40 @@ notes:
314
472
  inp = inp.decode('iso-8859-1').encode('utf-8')
315
473
  document = etree.fromstring(inp, html_parser)
316
474
  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)
475
+ # Print an error in case of syntax issues in the input
476
+ print(f"Error parsing {'XML' if args.rawinput else 'HTML'}: {e}", file=sys.stderr)
477
+ sys.exit(1)
478
+
479
+ if document is None:
480
+ print("Error parsing HTML: no elements found in the input", file=sys.stderr)
319
481
  sys.exit(1)
320
482
 
483
+ # -t and -x output no attribute values
484
+ base = None
485
+ if absolute_urls and not args.text and not args.check_existence:
486
+ base = document_base(document, page_url)
487
+ if not base:
488
+ print("Error: no base for --absolute-urls: use --base-url URL, "
489
+ "or a page with an absolute <base href>.", file=sys.stderr)
490
+ sys.exit(1)
491
+
492
+ if args.row:
493
+ row_expression = args.row if is_xpath(args.row) else convert_css_to_xpath(args.row)
494
+ rows = xpath_result_items(evaluate(document, row_expression))
495
+ if any(isinstance(row, str) or not isinstance(row.tag, str) for row in rows):
496
+ print(f"Error: --row must select elements, not values: '{args.row}'.", file=sys.stderr)
497
+ sys.exit(1)
498
+ write_json([{name: field_value(row, expression, base) for name, expression in fields.items()}
499
+ for row in rows], args.jsonl)
500
+ return
501
+
321
502
  results = []
322
503
  string_results = []
504
+ # --json-array items: str for strings, bytes for element markup
505
+ array_items = []
323
506
  # For each expression, perform the search in the parsed HTML
324
507
  for e in expression:
325
- try:
326
- result = document.xpath(e)
327
- except etree.XPathError as err:
328
- print(f"Error: invalid XPath expression '{e}': {err}", file=sys.stderr)
329
- sys.exit(1)
508
+ result = evaluate(document, e)
330
509
 
331
510
  # If check-existence is enabled, return 0 or 1 depending on the existence of elements
332
511
  if args.check_existence:
@@ -336,6 +515,19 @@ notes:
336
515
 
337
516
  # Extract the text or content of the found elements
338
517
  for el in els:
518
+ if base:
519
+ if isinstance(el, str):
520
+ if getattr(el, 'is_attribute', False) and el.attrname in ('href', 'src'):
521
+ el = resolve_url(base, el)
522
+ elif isinstance(el.tag, str):
523
+ el = with_absolute_urls(el, base)
524
+ if args.json_array:
525
+ if isinstance(el, str):
526
+ if el.strip():
527
+ array_items.append(el.strip())
528
+ elif isinstance(el.tag, str): # not a comment or processing instruction
529
+ array_items.append(etree.tostring(el, with_tail=False))
530
+ continue
339
531
  if isinstance(el, str):
340
532
  # If the element is a string, use the text directly
341
533
  text = el
@@ -356,7 +548,7 @@ notes:
356
548
  text = clean_text(text)
357
549
  elif not args.argument:
358
550
  # If no attribute is specified, return the element as HTML string
359
- text = etree.tostring(el, pretty_print=True).decode('utf-8')
551
+ text = etree.tostring(el, pretty_print=True, with_tail=False).decode('utf-8')
360
552
  else:
361
553
  # Otherwise, extract the specified attribute
362
554
  text = el.get(args.argument)
@@ -372,17 +564,19 @@ notes:
372
564
  # Output handling
373
565
  if args.json:
374
566
  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:
567
+ try:
568
+ if args.json_array:
569
+ parsed = [xmltodict.parse(i) if isinstance(i, bytes) else i for i in array_items]
570
+ elif values and len(string_results) == len(results):
571
+ # Only strings (@attr, text()): XML conversion would merge them into one
572
+ parsed = {"html": {"body": values[0] if len(values) == 1 else values}}
573
+ else:
380
574
  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)
385
- sys.stdout.write(json.dumps(parsed, indent=2, ensure_ascii=False) + "\n")
575
+ except ExpatError as e:
576
+ print(f"Error: cannot convert result to JSON, it is not valid XML ({e}). "
577
+ f"Attribute names like @click or :bind are not supported by {'--json-array' if args.json_array else '-j'}.", file=sys.stderr)
578
+ sys.exit(1)
579
+ write_json(parsed, args.jsonl)
386
580
  elif args.body and not args.text:
387
581
  sys.stdout.write(wrap_in_body(results))
388
582
  else: