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