scrape-cli 1.3.6__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.6
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
@@ -287,6 +287,37 @@ Output:
287
287
 
288
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
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
+
290
321
  `-j` is mutually exclusive with `-t`, `-x` (`--check-existence`) and `-a` (`--argument`).
291
322
 
292
323
  `-j` exits with an error when a matched element has an attribute name that is not valid XML, such as `@click` or `:bind`.
@@ -301,6 +332,68 @@ Some notes on the commands:
301
332
  - `-t` to extract only text content (useful for LLMs and text processing)
302
333
 
303
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
+
304
397
  ## License
305
398
 
306
399
  [MIT](LICENSE)
@@ -270,6 +270,37 @@ Output:
270
270
 
271
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
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
+
273
304
  `-j` is mutually exclusive with `-t`, `-x` (`--check-existence`) and `-a` (`--argument`).
274
305
 
275
306
  `-j` exits with an error when a matched element has an attribute name that is not valid XML, such as `@click` or `:bind`.
@@ -284,6 +315,68 @@ Some notes on the commands:
284
315
  - `-t` to extract only text content (useful for LLMs and text processing)
285
316
 
286
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
+
287
380
  ## License
288
381
 
289
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.6"
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.6"
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
@@ -134,6 +222,14 @@ examples:
134
222
  scrape -te "//article" file.html text of the matching elements
135
223
  scrape -je "a" file.html JSON
136
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
137
233
  scrape -be "//article" file.html wrap output in <html><body>
138
234
  scrape -x -e "//h1" file.html test only: exit code, no output
139
235
  scrape -e "//h1" https://example.com fetch from URL
@@ -149,7 +245,14 @@ output:
149
245
  strings one is a string, several are a list
150
246
  nothing null
151
247
  --json-array always a flat list, one item per result, in expression order:
152
- [] for nothing, strings as strings, elements as {"tag": ...}
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.
153
256
 
154
257
  exit codes:
155
258
  0 success, also when nothing matches: empty output, or null body with -j
@@ -165,7 +268,17 @@ notes:
165
268
  - -t with -e includes the text of any script or style inside the matches.
166
269
  - -j fails with exit 1 on attribute names that are not valid XML, such as
167
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.
168
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.
169
282
  - Charset: from the meta tag, otherwise UTF-8. If the bytes are not valid
170
283
  UTF-8: Windows-1252, then ISO-8859-1. Other encodings need the meta tag.
171
284
  '''
@@ -198,6 +311,16 @@ notes:
198
311
  help="Output structured JSON, see below. Not with -t, -x, -a")
199
312
  parser.add_argument('--json-array', action='store_true', default=False,
200
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")
201
324
  # Allows to specify one or more XPath or CSS3 selector expressions
202
325
  parser.add_argument('-e', '--expression', default=[], action='append',
203
326
  help="XPath query or CSS3 selector, detected automatically. Repeatable")
@@ -212,21 +335,46 @@ notes:
212
335
  help="User-Agent for URL requests")
213
336
  args = parser.parse_args()
214
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
+
215
354
  # JSON flag is mutually exclusive with -t, -x, -a
355
+ json_flag = '--json-array' if args.json_array else '--json'
216
356
  if args.json_array:
217
357
  args.json = True
218
358
  if args.json and args.text:
219
- 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)
220
360
  sys.exit(1)
221
361
  if args.json and args.check_existence:
222
- 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)
223
363
  sys.exit(1)
224
364
  if args.json and args.argument:
225
- 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)
226
374
  sys.exit(1)
227
375
 
228
376
  # Check that at least one expression is provided by the user (unless using -t option)
229
- if not args.expression and not args.text:
377
+ if not args.expression and not args.text and not args.row:
230
378
  print(
231
379
  "Error: no expression specified. Use -e \"//selector\" or -t.\n"
232
380
  " scrape -e \"//h1\" file.html\n"
@@ -235,6 +383,7 @@ notes:
235
383
  )
236
384
  sys.exit(1)
237
385
 
386
+ page_url = args.base_url
238
387
  # Determine the source of the input: URL, file, or stdin
239
388
  if args.html:
240
389
  if args.html.startswith('http://') or args.html.startswith('https://'):
@@ -245,6 +394,7 @@ notes:
245
394
  response = requests.get(args.html, headers=headers, timeout=30)
246
395
  response.raise_for_status()
247
396
  inp = response.content
397
+ page_url = page_url or response.url
248
398
  except requests.RequestException as e:
249
399
  print(f"Error downloading HTML: {e}", file=sys.stderr)
250
400
  sys.exit(1)
@@ -330,17 +480,32 @@ notes:
330
480
  print("Error parsing HTML: no elements found in the input", file=sys.stderr)
331
481
  sys.exit(1)
332
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
+
333
502
  results = []
334
503
  string_results = []
335
504
  # --json-array items: str for strings, bytes for element markup
336
505
  array_items = []
337
506
  # For each expression, perform the search in the parsed HTML
338
507
  for e in expression:
339
- try:
340
- result = document.xpath(e)
341
- except etree.XPathError as err:
342
- print(f"Error: invalid XPath expression '{e}': {err}", file=sys.stderr)
343
- sys.exit(1)
508
+ result = evaluate(document, e)
344
509
 
345
510
  # If check-existence is enabled, return 0 or 1 depending on the existence of elements
346
511
  if args.check_existence:
@@ -350,6 +515,12 @@ notes:
350
515
 
351
516
  # Extract the text or content of the found elements
352
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)
353
524
  if args.json_array:
354
525
  if isinstance(el, str):
355
526
  if el.strip():
@@ -403,9 +574,9 @@ notes:
403
574
  parsed = xmltodict.parse(wrap_in_body(results))
404
575
  except ExpatError as e:
405
576
  print(f"Error: cannot convert result to JSON, it is not valid XML ({e}). "
406
- "Attribute names like @click or :bind are not supported by -j.", file=sys.stderr)
577
+ f"Attribute names like @click or :bind are not supported by {'--json-array' if args.json_array else '-j'}.", file=sys.stderr)
407
578
  sys.exit(1)
408
- sys.stdout.write(json.dumps(parsed, indent=2, ensure_ascii=False) + "\n")
579
+ write_json(parsed, args.jsonl)
409
580
  elif args.body and not args.text:
410
581
  sys.stdout.write(wrap_in_body(results))
411
582
  else:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrape_cli
3
- Version: 1.3.6
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
@@ -287,6 +287,37 @@ Output:
287
287
 
288
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
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
+
290
321
  `-j` is mutually exclusive with `-t`, `-x` (`--check-existence`) and `-a` (`--argument`).
291
322
 
292
323
  `-j` exits with an error when a matched element has an attribute name that is not valid XML, such as `@click` or `:bind`.
@@ -301,6 +332,68 @@ Some notes on the commands:
301
332
  - `-t` to extract only text content (useful for LLMs and text processing)
302
333
 
303
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
+
304
397
  ## License
305
398
 
306
399
  [MIT](LICENSE)
@@ -278,7 +278,7 @@ def test_help_documents_the_contract_for_agents():
278
278
  result = run_scrape("--help")
279
279
 
280
280
  assert result.returncode == 0
281
- for section in ("output:", "exit codes:", "notes:", "examples:", "--json-array"):
281
+ for section in ("output:", "exit codes:", "notes:", "examples:", "--json-array", "--base-url", "--row", "--field", "--jsonl"):
282
282
  assert section in result.stdout
283
283
 
284
284
 
@@ -504,6 +504,10 @@ def test_json_array_keeps_the_order_of_the_expressions():
504
504
  assert json_array("b", "//a/@href") == [{"b": "tre"}, "x", "y"]
505
505
 
506
506
 
507
+ def test_json_array_mixed_expressions_keep_order_and_forms():
508
+ assert json_array("b", "//a/@href", "count(//p)") == [{"b": "tre"}, "x", "y", "2"]
509
+
510
+
507
511
  def test_json_array_element_item():
508
512
  assert json_array("a")[0] == {"a": {"@href": "x", "#text": "l"}}
509
513
 
@@ -525,7 +529,7 @@ def test_json_array_rejects_conflicting_flags():
525
529
  for flags in (["-t"], ["-x"], ["-a", "href"]):
526
530
  result = run_scrape("--json-array", *flags, "-e", "a", input_data=ARRAY_HTML)
527
531
  assert result.returncode == 1
528
- assert "Error:" in result.stderr
532
+ assert "Error: --json-array " in result.stderr
529
533
 
530
534
 
531
535
  def test_json_array_reports_clean_error_on_non_xml_attribute_name():
@@ -534,6 +538,12 @@ def test_json_array_reports_clean_error_on_non_xml_attribute_name():
534
538
  assert result.stdout == ""
535
539
  assert "Error:" in result.stderr
536
540
  assert "Traceback" not in result.stderr
541
+ assert "not supported by --json-array" in result.stderr
542
+
543
+
544
+ def test_json_array_conflict_does_not_change_json_messages():
545
+ result = run_scrape("-j", "-t", "-e", "a", input_data=ARRAY_HTML)
546
+ assert "Error: --json and --text" in result.stderr
537
547
 
538
548
 
539
549
  def test_html_input_with_no_elements_reports_clean_error():
@@ -544,3 +554,309 @@ def test_html_input_with_no_elements_reports_clean_error():
544
554
  assert result.stdout == ""
545
555
  assert "Error parsing HTML: no elements found" in result.stderr
546
556
  assert "Traceback" not in result.stderr
557
+
558
+
559
+ URLS_HTML = (
560
+ '<html><body><a id="i" href="/x">l</a><a href="https://o.org/p">o</a>'
561
+ '<a href="mailto:a@b.it">m</a><a href="javascript:void(0)">j</a>'
562
+ '<a href="http://[bad">b</a><a href=" /y ">s</a>'
563
+ '<div><img src="i.png"></div></body></html>'
564
+ )
565
+ PAGE_URL = "https://e.org/a/b.html"
566
+
567
+
568
+ def absolute(*args, input_data=URLS_HTML):
569
+ result = run_scrape("--base-url", PAGE_URL, *args, input_data=input_data)
570
+ assert result.returncode == 0, result.stderr
571
+ return result.stdout.split()
572
+
573
+
574
+ def test_absolute_urls_attribute_expression():
575
+ assert absolute("-e", "//a/@href")[0] == "https://e.org/x"
576
+ assert absolute("-e", "//img/@src") == ["https://e.org/a/i.png"]
577
+
578
+
579
+ def test_absolute_urls_attribute_option():
580
+ assert absolute("-e", "//img", "-a", "src") == ["https://e.org/a/i.png"]
581
+
582
+
583
+ def test_absolute_urls_element_and_descendants():
584
+ result = run_scrape("--base-url", PAGE_URL, "-e", "div", input_data=URLS_HTML)
585
+ assert 'src="https://e.org/a/i.png"' in result.stdout
586
+ result = run_scrape("--base-url", PAGE_URL, "-be", "(//a)[1]", input_data=URLS_HTML)
587
+ assert 'href="https://e.org/x"' in result.stdout
588
+
589
+
590
+ def test_absolute_urls_json_outputs():
591
+ result = run_scrape("--base-url", PAGE_URL, "--json-array", "-e", "(//a)[1]", input_data=URLS_HTML)
592
+ assert json.loads(result.stdout) == [{"a": {"@id": "i", "@href": "https://e.org/x", "#text": "l"}}]
593
+ result = run_scrape("--base-url", PAGE_URL, "-j", "-e", "//img/@src", input_data=URLS_HTML)
594
+ assert json.loads(result.stdout) == {"html": {"body": "https://e.org/a/i.png"}}
595
+
596
+
597
+ def test_absolute_urls_leaves_other_values_unchanged():
598
+ assert absolute("-e", "//a/@id") == ["i"]
599
+ hrefs = absolute("-e", "//a/@href")
600
+ for value in ("https://o.org/p", "mailto:a@b.it", "javascript:void(0)", "http://[bad"):
601
+ assert value in hrefs
602
+
603
+
604
+ def test_absolute_urls_strips_spaces():
605
+ assert absolute("-e", "//a/@href")[-1] == "https://e.org/y"
606
+
607
+
608
+ def test_no_flag_keeps_original_values():
609
+ result = run_scrape("-e", "//a/@href", input_data=URLS_HTML)
610
+ assert result.stdout.split()[0] == "/x"
611
+
612
+
613
+ def test_absolute_urls_selection_uses_original_values():
614
+ result = run_scrape("--base-url", PAGE_URL, "-e", 'a[href^="/"]', input_data=URLS_HTML)
615
+ assert 'href="https://e.org/x"' in result.stdout
616
+
617
+
618
+ def test_absolute_urls_element_matched_by_two_expressions():
619
+ output = absolute("-e", "//a[@id='i']/@href", "-e", "//a[@href='/x']/@href")
620
+ assert output == ["https://e.org/x", "https://e.org/x"]
621
+ output = run_scrape("--base-url", PAGE_URL, "-e", "//a[@id='i']", "-e", "//a[@href='/x']", input_data=URLS_HTML)
622
+ assert output.stdout.count('href="https://e.org/x"') == 2
623
+
624
+
625
+ def test_absolute_urls_leaves_function_results_unchanged():
626
+ assert absolute("-e", "string(//a/@href)") == ["/x"]
627
+
628
+
629
+ def test_absolute_urls_uses_final_url_after_redirect():
630
+ class Handler(BaseHTTPRequestHandler):
631
+ def do_GET(self):
632
+ if self.path == "/old":
633
+ self.send_response(301)
634
+ self.send_header("Location", "/new/page.html")
635
+ self.end_headers()
636
+ return
637
+ body = b'<a href="x.html">l</a>'
638
+ self.send_response(200)
639
+ self.send_header("Content-Length", str(len(body)))
640
+ self.end_headers()
641
+ self.wfile.write(body)
642
+
643
+ def log_message(self, *args):
644
+ pass
645
+
646
+ server = HTTPServer(("127.0.0.1", 0), Handler)
647
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
648
+ thread.start()
649
+ try:
650
+ port = server.server_address[1]
651
+ result = run_scrape("--absolute-urls", "-e", "//a/@href", f"http://127.0.0.1:{port}/old")
652
+ finally:
653
+ server.shutdown()
654
+ server.server_close()
655
+ thread.join(timeout=2)
656
+ assert result.stdout.strip() == f"http://127.0.0.1:{port}/new/x.html"
657
+
658
+
659
+ def test_base_element_relative_to_page_url():
660
+ html = '<head><base href="/sub/"><base href="/other/"></head><img src="i.png">'
661
+ assert absolute("-e", "//img/@src", input_data=html) == ["https://e.org/sub/i.png"]
662
+
663
+
664
+ def test_base_url_does_not_override_base_element():
665
+ html = '<head><base href="/sub/"></head><img src="i.png">'
666
+ result = run_scrape("--base-url", "https://e.org/", "-e", "//img/@src", input_data=html)
667
+ assert result.stdout.strip() == "https://e.org/sub/i.png"
668
+
669
+
670
+ def test_base_element_that_is_not_usable_falls_back_to_page_url():
671
+ html = '<head><base href="http://[bad"></head><img src="i.png">'
672
+ assert absolute("-e", "//img/@src", input_data=html) == ["https://e.org/a/i.png"]
673
+
674
+
675
+ def test_absolute_base_element_with_no_page_url():
676
+ html = '<head><base href="https://e.org/"></head><a href="/x">l</a>'
677
+ result = run_scrape("--absolute-urls", "-e", "//a/@href", input_data=html)
678
+ assert result.stdout.strip() == "https://e.org/x"
679
+
680
+
681
+ def test_absolute_urls_with_no_base_is_an_error():
682
+ for html in (URLS_HTML, '<head><base href="/sub/"></head><a href="/x">l</a>'):
683
+ result = run_scrape("--absolute-urls", "-e", "//a/@href", input_data=html)
684
+ assert result.returncode == 1
685
+ assert result.stdout == ""
686
+ assert "--base-url" in result.stderr
687
+
688
+
689
+ def test_base_url_must_be_absolute():
690
+ for value in ("page.html", "/a/b", "mailto:a@b.it"):
691
+ result = run_scrape("--base-url", value, "-e", "//a/@href", input_data=URLS_HTML)
692
+ assert result.returncode == 1
693
+ assert result.stdout == ""
694
+ assert "Error: --base-url" in result.stderr
695
+
696
+
697
+ def test_absolute_urls_rejected_with_rawinput():
698
+ for flags in (["--absolute-urls"], ["--base-url", PAGE_URL]):
699
+ result = run_scrape(*flags, "-r", "-e", "//a", input_data="<a/>")
700
+ assert result.returncode == 1
701
+ assert result.stdout == ""
702
+ assert "-r" in result.stderr
703
+
704
+
705
+ def test_absolute_urls_ignored_with_text_and_check_existence():
706
+ result = run_scrape("--absolute-urls", "-t", input_data=URLS_HTML.replace("<body>", "<body>testo "))
707
+ assert result.returncode == 0
708
+ assert "testo" in result.stdout
709
+ assert run_scrape("--absolute-urls", "-x", "-e", "a", input_data=URLS_HTML).returncode == 0
710
+
711
+
712
+ ROWS_HTML = (
713
+ '<html><body>'
714
+ '<article><h2><a href="/1">Uno è</a></h2> dopo <time datetime="2026-01-01">gen</time>'
715
+ '<a class="tag">t1</a><a class="tag">t2</a></article>'
716
+ '<article><h2><a href="/2">Due</a></h2></article>'
717
+ '<article><h2></h2></article>'
718
+ '<section>fuori</section>'
719
+ '</body></html>'
720
+ )
721
+ ROW_FIELDS = ["--row", "article", "--field", "title=h2", "--field", "url=.//a/@href", "--field", "date=time/@datetime"]
722
+
723
+
724
+ def rows(*args, input_data=ROWS_HTML):
725
+ result = run_scrape(*args, input_data=input_data)
726
+ assert result.returncode == 0, result.stderr
727
+ return json.loads(result.stdout)
728
+
729
+
730
+ def rows_error(*args, input_data=ROWS_HTML):
731
+ result = run_scrape(*args, input_data=input_data)
732
+ assert result.returncode == 1, result.stdout
733
+ assert result.stdout == ""
734
+ assert "Error" in result.stderr
735
+ assert "Traceback" not in result.stderr
736
+ return result.stderr
737
+
738
+
739
+ def test_row_fields_stay_together_when_one_is_missing():
740
+ assert rows(*ROW_FIELDS) == [
741
+ {"title": "Uno è", "url": "/1", "date": "2026-01-01"},
742
+ {"title": "Due", "url": "/2", "date": None},
743
+ {"title": "", "url": None, "date": None},
744
+ ]
745
+
746
+
747
+ def test_row_keys_follow_the_order_of_the_fields():
748
+ assert list(rows("--row", "article", "--field", "b=h2", "--field", "a=time")[0]) == ["b", "a"]
749
+
750
+
751
+ def test_row_no_rows():
752
+ assert rows("--row", "aside", "--field", "t=h2") == []
753
+
754
+
755
+ def test_row_with_no_matching_field_is_kept():
756
+ assert rows("--row", "section", "--field", "t=h2") == [{"t": None}]
757
+
758
+
759
+ def test_row_field_takes_the_first_match():
760
+ assert rows("--row", "article", "--field", "t=a.tag")[0] == {"t": "t1"}
761
+
762
+
763
+ def test_row_field_with_function_gives_string():
764
+ assert rows("--row", "article", "--field", "n=count(.//a)")[0] == {"n": "3"}
765
+
766
+
767
+ def test_row_field_text_leaves_out_text_after_element():
768
+ assert rows("--row", "article", "--field", "t=h2")[0] == {"t": "Uno è"}
769
+
770
+
771
+ def test_row_field_expression_with_equals_sign():
772
+ assert rows("--row", "article", "--field", 't=a[href="/2"]')[1] == {"t": "Due"}
773
+
774
+
775
+ def test_row_field_skips_whitespace_only_text():
776
+ html = "<article>\n <b>x</b> testo</article>"
777
+ assert rows("--row", "article", "--field", "t=text()", input_data=html) == [{"t": "testo"}]
778
+
779
+
780
+ def test_row_field_empty_attribute_is_empty_string():
781
+ assert rows("--row", "p", "--field", "t=a/@href", input_data='<p><a href="">l</a></p>') == [{"t": ""}]
782
+
783
+
784
+ def test_row_field_with_absolute_urls():
785
+ result = rows("--base-url", "https://e.org/", *ROW_FIELDS)
786
+ assert result[0]["url"] == "https://e.org/1"
787
+ assert result[0]["date"] == "2026-01-01"
788
+
789
+
790
+ def test_row_with_rawinput():
791
+ xml = "<rss><item><title>a</title><link>l</link></item></rss>"
792
+ assert rows("-r", "--row", "item", "--field", "t=title", "--field", "l=link", input_data=xml) == [
793
+ {"t": "a", "l": "l"}
794
+ ]
795
+
796
+
797
+ def test_jsonl_rows():
798
+ result = run_scrape("--jsonl", *ROW_FIELDS, input_data=ROWS_HTML)
799
+ lines = result.stdout.splitlines()
800
+ assert len(lines) == 3
801
+ assert json.loads(lines[1]) == {"title": "Due", "url": "/2", "date": None}
802
+ assert "è" in lines[0]
803
+
804
+
805
+ def test_jsonl_items_without_row():
806
+ for flags in (["--jsonl"], ["--jsonl", "--json-array"]):
807
+ result = run_scrape(*flags, "-e", "//a/@href", input_data=ROWS_HTML)
808
+ assert [json.loads(line) for line in result.stdout.splitlines()] == ["/1", "/2"]
809
+
810
+
811
+ def test_jsonl_no_results():
812
+ for args in (["--row", "aside", "--field", "t=h2"], ["-e", "aside"]):
813
+ result = run_scrape("--jsonl", *args, input_data=ROWS_HTML)
814
+ assert result.returncode == 0
815
+ assert result.stdout == ""
816
+
817
+
818
+ def test_is_xpath_recognizes_leading_dot_slash():
819
+ assert is_xpath("./body/h1") is True
820
+ result = run_scrape("-e", "./body/h1", input_data="<html><body><h1>t</h1></body></html>")
821
+ assert result.returncode == 0
822
+ assert "<h1>t</h1>" in result.stdout
823
+
824
+
825
+ def test_row_and_field_need_each_other():
826
+ rows_error("--field", "t=h2")
827
+ rows_error("--row", "article")
828
+
829
+
830
+ def test_row_rejects_other_output_modes():
831
+ for flags in (["-e", "a"], ["-t"], ["-x"], ["-a", "href"], ["-b"], ["-j"], ["--json-array"]):
832
+ rows_error(*ROW_FIELDS, *flags)
833
+
834
+
835
+ def test_row_rejects_malformed_fields():
836
+ for field in ("title", "=h2", "title=", " =h2"):
837
+ assert field.strip() in rows_error("--row", "article", "--field", field)
838
+
839
+
840
+ def test_row_rejects_repeated_names():
841
+ assert "title" in rows_error("--row", "article", "--field", "title=h2", "--field", "title=h3")
842
+
843
+
844
+ def test_row_rejects_absolute_xpath_in_field():
845
+ for expression in ("//h2", "/html", "(//h2)[1]"):
846
+ assert ".//" in rows_error("--row", "article", "--field", f"t={expression}")
847
+
848
+
849
+ def test_row_must_select_elements():
850
+ rows_error("--row", "//a/@href", "--field", "t=h2")
851
+ rows_error("--row", "count(//a)", "--field", "t=h2")
852
+
853
+
854
+ def test_row_rejects_invalid_expressions():
855
+ rows_error("--row", "//[", "--field", "t=h2")
856
+ rows_error("--row", "article", "--field", "t=.//[")
857
+ rows_error("--row", "article", "--field", "t=a..b!")
858
+
859
+
860
+ def test_jsonl_rejects_other_output_modes():
861
+ for flags in (["-t"], ["-x"], ["-a", "href"], ["-b"], ["-j"]):
862
+ assert "--jsonl" in rows_error("--jsonl", "-e", "a", *flags)
File without changes