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.
- {scrape_cli-1.3.6 → scrape_cli-1.3.7}/PKG-INFO +94 -1
- {scrape_cli-1.3.6 → scrape_cli-1.3.7}/README.md +93 -0
- {scrape_cli-1.3.6 → scrape_cli-1.3.7}/pyproject.toml +1 -1
- {scrape_cli-1.3.6 → scrape_cli-1.3.7}/scrape_cli/__init__.py +1 -1
- {scrape_cli-1.3.6 → scrape_cli-1.3.7}/scrape_cli/scrape.py +184 -13
- {scrape_cli-1.3.6 → scrape_cli-1.3.7}/scrape_cli.egg-info/PKG-INFO +94 -1
- {scrape_cli-1.3.6 → scrape_cli-1.3.7}/tests/test_scrape.py +318 -2
- {scrape_cli-1.3.6 → scrape_cli-1.3.7}/scrape_cli.egg-info/SOURCES.txt +0 -0
- {scrape_cli-1.3.6 → scrape_cli-1.3.7}/scrape_cli.egg-info/dependency_links.txt +0 -0
- {scrape_cli-1.3.6 → scrape_cli-1.3.7}/scrape_cli.egg-info/entry_points.txt +0 -0
- {scrape_cli-1.3.6 → scrape_cli-1.3.7}/scrape_cli.egg-info/requires.txt +0 -0
- {scrape_cli-1.3.6 → scrape_cli-1.3.7}/scrape_cli.egg-info/top_level.txt +0 -0
- {scrape_cli-1.3.6 → scrape_cli-1.3.7}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scrape_cli
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.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)
|
|
@@ -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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|