scraper-rust 0.4.0__tar.gz → 0.4.1__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.
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/Cargo.lock +25 -3
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/Cargo.toml +2 -1
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/PKG-INFO +2 -1
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/README.md +1 -0
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/pyproject.toml +1 -1
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/scraper_rs/__init__.py +2 -0
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/scraper_rs.pyi +21 -0
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/src/lib.rs +512 -1
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/tests/test_scraper.py +50 -0
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/uv.lock +1 -1
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/.github/workflows/benchmark.yml +0 -0
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/.github/workflows/bump-version.yml +0 -0
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/.github/workflows/release.yml +0 -0
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/.github/workflows/tests.yml +0 -0
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/.gitignore +0 -0
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/AGENTS.md +0 -0
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/LICENSE +0 -0
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/benchmarks/README.md +0 -0
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/benchmarks/bench_parse_memory.py +0 -0
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/benchmarks/bench_sync_async.py +0 -0
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/benchmarks/bench_vs_markupever.py +0 -0
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/docs/README.md +0 -0
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/docs/api.md +0 -0
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/docs/architecture.md +0 -0
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/docs/async-api.md +0 -0
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/docs/development.md +0 -0
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/docs/limits-and-errors.md +0 -0
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/examples/demo.py +0 -0
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/examples/demo_async_document.py +0 -0
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/examples/demo_asyncio.py +0 -0
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/examples/demo_prettify_url.py +0 -0
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/justfile +0 -0
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/py.typed +0 -0
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/scraper_rs/asyncio.py +0 -0
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/scraper_rs/asyncio.pyi +0 -0
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/tests/test_asyncio.py +0 -0
- {scraper_rust-0.4.0 → scraper_rust-0.4.1}/tests/test_performance.py +0 -0
|
@@ -545,7 +545,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
|
545
545
|
checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2"
|
|
546
546
|
dependencies = [
|
|
547
547
|
"log",
|
|
548
|
-
"markup5ever",
|
|
548
|
+
"markup5ever 0.38.0",
|
|
549
|
+
]
|
|
550
|
+
|
|
551
|
+
[[package]]
|
|
552
|
+
name = "html5ever"
|
|
553
|
+
version = "0.39.0"
|
|
554
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
555
|
+
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
|
|
556
|
+
dependencies = [
|
|
557
|
+
"log",
|
|
558
|
+
"markup5ever 0.39.0",
|
|
549
559
|
]
|
|
550
560
|
|
|
551
561
|
[[package]]
|
|
@@ -1215,6 +1225,17 @@ dependencies = [
|
|
|
1215
1225
|
"web_atoms",
|
|
1216
1226
|
]
|
|
1217
1227
|
|
|
1228
|
+
[[package]]
|
|
1229
|
+
name = "markup5ever"
|
|
1230
|
+
version = "0.39.0"
|
|
1231
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1232
|
+
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
|
|
1233
|
+
dependencies = [
|
|
1234
|
+
"log",
|
|
1235
|
+
"tendril",
|
|
1236
|
+
"web_atoms",
|
|
1237
|
+
]
|
|
1238
|
+
|
|
1218
1239
|
[[package]]
|
|
1219
1240
|
name = "memchr"
|
|
1220
1241
|
version = "2.8.0"
|
|
@@ -1820,7 +1841,7 @@ dependencies = [
|
|
|
1820
1841
|
"cssparser",
|
|
1821
1842
|
"ego-tree",
|
|
1822
1843
|
"getopts",
|
|
1823
|
-
"html5ever",
|
|
1844
|
+
"html5ever 0.38.0",
|
|
1824
1845
|
"precomputed-hash",
|
|
1825
1846
|
"selectors",
|
|
1826
1847
|
"tendril",
|
|
@@ -1828,8 +1849,9 @@ dependencies = [
|
|
|
1828
1849
|
|
|
1829
1850
|
[[package]]
|
|
1830
1851
|
name = "scraper-rs"
|
|
1831
|
-
version = "0.4.
|
|
1852
|
+
version = "0.4.1"
|
|
1832
1853
|
dependencies = [
|
|
1854
|
+
"html5ever 0.39.0",
|
|
1833
1855
|
"pyo3",
|
|
1834
1856
|
"pyo3-async-runtimes",
|
|
1835
1857
|
"scraper",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "scraper-rs"
|
|
3
|
-
version = "0.4.
|
|
3
|
+
version = "0.4.1"
|
|
4
4
|
edition = "2024"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
|
|
@@ -20,6 +20,7 @@ tokio = { version = "1", features = ["rt", "macros"] }
|
|
|
20
20
|
scraper = { version = "0.25.0", git = "https://github.com/rust-scraper/scraper", features = ["atomic"], rev = "d07444effb9ca192d9b3919fb920ab9c6c701c4f" }
|
|
21
21
|
|
|
22
22
|
xee-xpath = "0.1.5"
|
|
23
|
+
html5ever = "0.39.0"
|
|
23
24
|
|
|
24
25
|
[profile.release]
|
|
25
26
|
lto = "fat"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scraper-rust
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Classifier: Programming Language :: Python
|
|
5
5
|
Classifier: Programming Language :: Python :: 3
|
|
6
6
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
@@ -166,6 +166,7 @@ If you have `just` installed, the repo includes helpers: `just build` (local whe
|
|
|
166
166
|
## Projects Using scraper-rs
|
|
167
167
|
|
|
168
168
|
- [**silkworm**](https://github.com/BitingSnakes/silkworm) - Async web scraping framework on top of Rust
|
|
169
|
+
- [**silkworm-mcp**](https://github.com/BitingSnakes/silkworm-mcp) - An MCP server for silkworm
|
|
169
170
|
|
|
170
171
|
## Development
|
|
171
172
|
|
|
@@ -139,6 +139,7 @@ If you have `just` installed, the repo includes helpers: `just build` (local whe
|
|
|
139
139
|
## Projects Using scraper-rs
|
|
140
140
|
|
|
141
141
|
- [**silkworm**](https://github.com/BitingSnakes/silkworm) - Async web scraping framework on top of Rust
|
|
142
|
+
- [**silkworm-mcp**](https://github.com/BitingSnakes/silkworm-mcp) - An MCP server for silkworm
|
|
142
143
|
|
|
143
144
|
## Development
|
|
144
145
|
|
|
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "scraper-rust"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.1"
|
|
8
8
|
description = "Python bindings around rust-scraper/scraper with PyO3"
|
|
9
9
|
authors = [{ name = "Yehor Smoliakov", email = "egorsmkv@gmail.com" }]
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -8,6 +8,21 @@ class ElementDict(TypedDict):
|
|
|
8
8
|
html: str
|
|
9
9
|
attrs: dict[str, str]
|
|
10
10
|
|
|
11
|
+
class HtmlNodeDict(TypedDict, total=False):
|
|
12
|
+
node_type: str
|
|
13
|
+
tag: str
|
|
14
|
+
namespace: str
|
|
15
|
+
attrs: dict[str, str]
|
|
16
|
+
children: list[HtmlNodeDict]
|
|
17
|
+
text: str
|
|
18
|
+
quirks_mode: str
|
|
19
|
+
errors: list[str]
|
|
20
|
+
name: str
|
|
21
|
+
public_id: str
|
|
22
|
+
system_id: str
|
|
23
|
+
target: str
|
|
24
|
+
data: str
|
|
25
|
+
|
|
11
26
|
class Element:
|
|
12
27
|
tag: str
|
|
13
28
|
text: str
|
|
@@ -62,6 +77,12 @@ class Document:
|
|
|
62
77
|
def parse(
|
|
63
78
|
html: str, *, max_size_bytes: int | None = ..., truncate_on_limit: bool = False
|
|
64
79
|
) -> Document: ...
|
|
80
|
+
def parse_document(
|
|
81
|
+
html: str, *, max_size_bytes: int | None = ..., truncate_on_limit: bool = False
|
|
82
|
+
) -> HtmlNodeDict: ...
|
|
83
|
+
def parse_fragment(
|
|
84
|
+
html: str, *, max_size_bytes: int | None = ..., truncate_on_limit: bool = False
|
|
85
|
+
) -> HtmlNodeDict: ...
|
|
65
86
|
def prettify(
|
|
66
87
|
html: str, *, max_size_bytes: int | None = ..., truncate_on_limit: bool = False
|
|
67
88
|
) -> str: ...
|
|
@@ -5,10 +5,17 @@ use std::future::Future;
|
|
|
5
5
|
use std::rc::Rc;
|
|
6
6
|
use std::sync::{Arc, Mutex, OnceLock};
|
|
7
7
|
|
|
8
|
+
use html5ever::interface::{ElementFlags, NodeOrText, QuirksMode};
|
|
9
|
+
use html5ever::tendril::{StrTendril, TendrilSink};
|
|
10
|
+
use html5ever::tree_builder::TreeSink;
|
|
11
|
+
use html5ever::{
|
|
12
|
+
Attribute, ParseOpts, QualName, local_name, ns, parse_document as html5ever_parse_document,
|
|
13
|
+
parse_fragment as html5ever_parse_fragment,
|
|
14
|
+
};
|
|
8
15
|
use pyo3::IntoPyObject;
|
|
9
16
|
use pyo3::exceptions::PyValueError;
|
|
10
17
|
use pyo3::prelude::*;
|
|
11
|
-
use pyo3::types::PyDict;
|
|
18
|
+
use pyo3::types::{PyDict, PyList};
|
|
12
19
|
use pyo3::wrap_pyfunction;
|
|
13
20
|
use scraper::{Html, Selector, element_ref::ElementRef};
|
|
14
21
|
use xee_xpath::context::StaticContextBuilder;
|
|
@@ -110,6 +117,484 @@ fn ensure_within_size_limit(
|
|
|
110
117
|
Ok(Cow::Borrowed(html))
|
|
111
118
|
}
|
|
112
119
|
|
|
120
|
+
#[derive(Clone)]
|
|
121
|
+
enum Html5DictNodeData {
|
|
122
|
+
Document,
|
|
123
|
+
Doctype {
|
|
124
|
+
name: String,
|
|
125
|
+
public_id: String,
|
|
126
|
+
system_id: String,
|
|
127
|
+
},
|
|
128
|
+
Text {
|
|
129
|
+
text: String,
|
|
130
|
+
},
|
|
131
|
+
Comment {
|
|
132
|
+
text: String,
|
|
133
|
+
},
|
|
134
|
+
Element {
|
|
135
|
+
name: QualName,
|
|
136
|
+
attrs: Vec<Attribute>,
|
|
137
|
+
template_contents: Option<usize>,
|
|
138
|
+
},
|
|
139
|
+
ProcessingInstruction {
|
|
140
|
+
target: String,
|
|
141
|
+
data: String,
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
#[derive(Clone)]
|
|
146
|
+
struct Html5DictNode {
|
|
147
|
+
parent: Option<usize>,
|
|
148
|
+
children: Vec<usize>,
|
|
149
|
+
data: Html5DictNodeData,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#[derive(Clone, Copy)]
|
|
153
|
+
enum Html5ParseKind {
|
|
154
|
+
Document,
|
|
155
|
+
Fragment,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
#[derive(Debug)]
|
|
159
|
+
struct Html5ElemName {
|
|
160
|
+
ns: html5ever::Namespace,
|
|
161
|
+
local_name: html5ever::LocalName,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
impl html5ever::interface::ElemName for Html5ElemName {
|
|
165
|
+
fn ns(&self) -> &html5ever::Namespace {
|
|
166
|
+
&self.ns
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
fn local_name(&self) -> &html5ever::LocalName {
|
|
170
|
+
&self.local_name
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
struct Html5DictTree {
|
|
175
|
+
nodes: Vec<Html5DictNode>,
|
|
176
|
+
quirks_mode: QuirksMode,
|
|
177
|
+
errors: Vec<String>,
|
|
178
|
+
parse_kind: Html5ParseKind,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
struct Html5DictSink {
|
|
182
|
+
nodes: RefCell<Vec<Html5DictNode>>,
|
|
183
|
+
quirks_mode: std::cell::Cell<QuirksMode>,
|
|
184
|
+
errors: RefCell<Vec<String>>,
|
|
185
|
+
parse_kind: Html5ParseKind,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
impl Html5DictSink {
|
|
189
|
+
fn new(parse_kind: Html5ParseKind) -> Self {
|
|
190
|
+
Self {
|
|
191
|
+
nodes: RefCell::new(vec![Html5DictNode {
|
|
192
|
+
parent: None,
|
|
193
|
+
children: Vec::new(),
|
|
194
|
+
data: Html5DictNodeData::Document,
|
|
195
|
+
}]),
|
|
196
|
+
quirks_mode: std::cell::Cell::new(QuirksMode::NoQuirks),
|
|
197
|
+
errors: RefCell::new(Vec::new()),
|
|
198
|
+
parse_kind,
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
fn create_node(&self, data: Html5DictNodeData) -> usize {
|
|
203
|
+
let mut nodes = self.nodes.borrow_mut();
|
|
204
|
+
let id = nodes.len();
|
|
205
|
+
nodes.push(Html5DictNode {
|
|
206
|
+
parent: None,
|
|
207
|
+
children: Vec::new(),
|
|
208
|
+
data,
|
|
209
|
+
});
|
|
210
|
+
id
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
fn remove_from_parent_impl(nodes: &mut [Html5DictNode], target: usize) {
|
|
214
|
+
let Some(parent) = nodes[target].parent.take() else {
|
|
215
|
+
return;
|
|
216
|
+
};
|
|
217
|
+
if let Some(position) = nodes[parent]
|
|
218
|
+
.children
|
|
219
|
+
.iter()
|
|
220
|
+
.position(|&child| child == target)
|
|
221
|
+
{
|
|
222
|
+
nodes[parent].children.remove(position);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
fn append_text_node(&self, nodes: &mut Vec<Html5DictNode>, parent: usize, text: StrTendril) {
|
|
227
|
+
if let Some(&last_child) = nodes[parent].children.last()
|
|
228
|
+
&& let Html5DictNodeData::Text { text: existing } = &mut nodes[last_child].data
|
|
229
|
+
{
|
|
230
|
+
existing.push_str(text.as_ref());
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let id = nodes.len();
|
|
235
|
+
nodes.push(Html5DictNode {
|
|
236
|
+
parent: Some(parent),
|
|
237
|
+
children: Vec::new(),
|
|
238
|
+
data: Html5DictNodeData::Text {
|
|
239
|
+
text: text.to_string(),
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
nodes[parent].children.push(id);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
fn append_node(&self, nodes: &mut [Html5DictNode], parent: usize, child: usize) {
|
|
246
|
+
Self::remove_from_parent_impl(nodes, child);
|
|
247
|
+
nodes[child].parent = Some(parent);
|
|
248
|
+
nodes[parent].children.push(child);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
impl TreeSink for Html5DictSink {
|
|
253
|
+
type Handle = usize;
|
|
254
|
+
type Output = Html5DictTree;
|
|
255
|
+
type ElemName<'a>
|
|
256
|
+
= Html5ElemName
|
|
257
|
+
where
|
|
258
|
+
Self: 'a;
|
|
259
|
+
|
|
260
|
+
fn finish(self) -> Self::Output {
|
|
261
|
+
Html5DictTree {
|
|
262
|
+
nodes: self.nodes.into_inner(),
|
|
263
|
+
quirks_mode: self.quirks_mode.get(),
|
|
264
|
+
errors: self.errors.into_inner(),
|
|
265
|
+
parse_kind: self.parse_kind,
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
fn parse_error(&self, msg: Cow<'static, str>) {
|
|
270
|
+
self.errors.borrow_mut().push(msg.into_owned());
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
fn get_document(&self) -> Self::Handle {
|
|
274
|
+
0
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
fn elem_name<'a>(&'a self, target: &'a Self::Handle) -> Self::ElemName<'a> {
|
|
278
|
+
let nodes = self.nodes.borrow();
|
|
279
|
+
match &nodes[*target].data {
|
|
280
|
+
Html5DictNodeData::Element { name, .. } => Html5ElemName {
|
|
281
|
+
ns: name.ns.clone(),
|
|
282
|
+
local_name: name.local.clone(),
|
|
283
|
+
},
|
|
284
|
+
_ => panic!("elem_name called on a non-element node"),
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
fn create_element(
|
|
289
|
+
&self,
|
|
290
|
+
name: QualName,
|
|
291
|
+
attrs: Vec<Attribute>,
|
|
292
|
+
flags: ElementFlags,
|
|
293
|
+
) -> Self::Handle {
|
|
294
|
+
let template_contents = if flags.template {
|
|
295
|
+
Some(self.create_node(Html5DictNodeData::Document))
|
|
296
|
+
} else {
|
|
297
|
+
None
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
self.create_node(Html5DictNodeData::Element {
|
|
301
|
+
name,
|
|
302
|
+
attrs,
|
|
303
|
+
template_contents,
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
fn create_comment(&self, text: StrTendril) -> Self::Handle {
|
|
308
|
+
self.create_node(Html5DictNodeData::Comment {
|
|
309
|
+
text: text.to_string(),
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
fn create_pi(&self, target: StrTendril, data: StrTendril) -> Self::Handle {
|
|
314
|
+
self.create_node(Html5DictNodeData::ProcessingInstruction {
|
|
315
|
+
target: target.to_string(),
|
|
316
|
+
data: data.to_string(),
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
fn append(&self, parent: &Self::Handle, child: NodeOrText<Self::Handle>) {
|
|
321
|
+
let mut nodes = self.nodes.borrow_mut();
|
|
322
|
+
match child {
|
|
323
|
+
NodeOrText::AppendText(text) => self.append_text_node(&mut nodes, *parent, text),
|
|
324
|
+
NodeOrText::AppendNode(child) => self.append_node(&mut nodes, *parent, child),
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
fn append_based_on_parent_node(
|
|
329
|
+
&self,
|
|
330
|
+
element: &Self::Handle,
|
|
331
|
+
prev_element: &Self::Handle,
|
|
332
|
+
child: NodeOrText<Self::Handle>,
|
|
333
|
+
) {
|
|
334
|
+
let has_parent = self.nodes.borrow()[*element].parent.is_some();
|
|
335
|
+
if has_parent {
|
|
336
|
+
self.append_before_sibling(element, child);
|
|
337
|
+
} else {
|
|
338
|
+
self.append(prev_element, child);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
fn append_doctype_to_document(
|
|
343
|
+
&self,
|
|
344
|
+
name: StrTendril,
|
|
345
|
+
public_id: StrTendril,
|
|
346
|
+
system_id: StrTendril,
|
|
347
|
+
) {
|
|
348
|
+
let id = self.create_node(Html5DictNodeData::Doctype {
|
|
349
|
+
name: name.to_string(),
|
|
350
|
+
public_id: public_id.to_string(),
|
|
351
|
+
system_id: system_id.to_string(),
|
|
352
|
+
});
|
|
353
|
+
self.append(&0, NodeOrText::AppendNode(id));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
fn get_template_contents(&self, target: &Self::Handle) -> Self::Handle {
|
|
357
|
+
let nodes = self.nodes.borrow();
|
|
358
|
+
match &nodes[*target].data {
|
|
359
|
+
Html5DictNodeData::Element {
|
|
360
|
+
template_contents: Some(contents),
|
|
361
|
+
..
|
|
362
|
+
} => *contents,
|
|
363
|
+
_ => panic!("get_template_contents called on a non-template element"),
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
fn same_node(&self, x: &Self::Handle, y: &Self::Handle) -> bool {
|
|
368
|
+
x == y
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
fn set_quirks_mode(&self, mode: QuirksMode) {
|
|
372
|
+
self.quirks_mode.set(mode);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
fn append_before_sibling(&self, sibling: &Self::Handle, new_node: NodeOrText<Self::Handle>) {
|
|
376
|
+
let mut nodes = self.nodes.borrow_mut();
|
|
377
|
+
let Some(parent) = nodes[*sibling].parent else {
|
|
378
|
+
return;
|
|
379
|
+
};
|
|
380
|
+
let Some(position) = nodes[parent]
|
|
381
|
+
.children
|
|
382
|
+
.iter()
|
|
383
|
+
.position(|&child| child == *sibling)
|
|
384
|
+
else {
|
|
385
|
+
return;
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
match new_node {
|
|
389
|
+
NodeOrText::AppendText(text) => {
|
|
390
|
+
if position > 0 {
|
|
391
|
+
let previous = nodes[parent].children[position - 1];
|
|
392
|
+
if let Html5DictNodeData::Text { text: existing } = &mut nodes[previous].data {
|
|
393
|
+
existing.push_str(text.as_ref());
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if let Html5DictNodeData::Text { text: existing } = &mut nodes[*sibling].data {
|
|
399
|
+
existing.insert_str(0, text.as_ref());
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
let id = nodes.len();
|
|
404
|
+
nodes.push(Html5DictNode {
|
|
405
|
+
parent: Some(parent),
|
|
406
|
+
children: Vec::new(),
|
|
407
|
+
data: Html5DictNodeData::Text {
|
|
408
|
+
text: text.to_string(),
|
|
409
|
+
},
|
|
410
|
+
});
|
|
411
|
+
nodes[parent].children.insert(position, id);
|
|
412
|
+
}
|
|
413
|
+
NodeOrText::AppendNode(child) => {
|
|
414
|
+
Self::remove_from_parent_impl(&mut nodes, child);
|
|
415
|
+
nodes[child].parent = Some(parent);
|
|
416
|
+
let Some(new_position) = nodes[parent]
|
|
417
|
+
.children
|
|
418
|
+
.iter()
|
|
419
|
+
.position(|&node| node == *sibling)
|
|
420
|
+
else {
|
|
421
|
+
return;
|
|
422
|
+
};
|
|
423
|
+
nodes[parent].children.insert(new_position, child);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
fn add_attrs_if_missing(&self, target: &Self::Handle, attrs: Vec<Attribute>) {
|
|
429
|
+
let mut nodes = self.nodes.borrow_mut();
|
|
430
|
+
let Html5DictNodeData::Element {
|
|
431
|
+
attrs: existing_attrs,
|
|
432
|
+
..
|
|
433
|
+
} = &mut nodes[*target].data
|
|
434
|
+
else {
|
|
435
|
+
panic!("add_attrs_if_missing called on a non-element node");
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
for attr in attrs {
|
|
439
|
+
if existing_attrs
|
|
440
|
+
.iter()
|
|
441
|
+
.all(|existing| existing.name != attr.name)
|
|
442
|
+
{
|
|
443
|
+
existing_attrs.push(attr);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
fn remove_from_parent(&self, target: &Self::Handle) {
|
|
449
|
+
let mut nodes = self.nodes.borrow_mut();
|
|
450
|
+
Self::remove_from_parent_impl(&mut nodes, *target);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
fn reparent_children(&self, node: &Self::Handle, new_parent: &Self::Handle) {
|
|
454
|
+
let children = {
|
|
455
|
+
let mut nodes = self.nodes.borrow_mut();
|
|
456
|
+
std::mem::take(&mut nodes[*node].children)
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
for child in children {
|
|
460
|
+
self.append(new_parent, NodeOrText::AppendNode(child));
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
fn quirks_mode_name(mode: QuirksMode) -> &'static str {
|
|
466
|
+
match mode {
|
|
467
|
+
QuirksMode::Quirks => "quirks",
|
|
468
|
+
QuirksMode::LimitedQuirks => "limited-quirks",
|
|
469
|
+
QuirksMode::NoQuirks => "no-quirks",
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
fn qual_name_to_string(name: &QualName) -> String {
|
|
474
|
+
name.local.to_string()
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
fn attrs_to_map(attrs: &[Attribute]) -> HashMap<String, String> {
|
|
478
|
+
attrs
|
|
479
|
+
.iter()
|
|
480
|
+
.map(|attr| (attr.name.local.to_string(), attr.value.to_string()))
|
|
481
|
+
.collect()
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
fn html5_dict_node_to_py(
|
|
485
|
+
py: Python<'_>,
|
|
486
|
+
tree: &Html5DictTree,
|
|
487
|
+
node_id: usize,
|
|
488
|
+
root_override: Option<&str>,
|
|
489
|
+
) -> PyResult<Py<PyDict>> {
|
|
490
|
+
let node = &tree.nodes[node_id];
|
|
491
|
+
let dict = PyDict::new(py);
|
|
492
|
+
|
|
493
|
+
let node_type = root_override.unwrap_or(match &node.data {
|
|
494
|
+
Html5DictNodeData::Document => "document",
|
|
495
|
+
Html5DictNodeData::Doctype { .. } => "doctype",
|
|
496
|
+
Html5DictNodeData::Text { .. } => "text",
|
|
497
|
+
Html5DictNodeData::Comment { .. } => "comment",
|
|
498
|
+
Html5DictNodeData::Element { .. } => "element",
|
|
499
|
+
Html5DictNodeData::ProcessingInstruction { .. } => "processing_instruction",
|
|
500
|
+
});
|
|
501
|
+
dict.set_item("node_type", node_type)?;
|
|
502
|
+
|
|
503
|
+
match &node.data {
|
|
504
|
+
Html5DictNodeData::Document => {}
|
|
505
|
+
Html5DictNodeData::Doctype {
|
|
506
|
+
name,
|
|
507
|
+
public_id,
|
|
508
|
+
system_id,
|
|
509
|
+
} => {
|
|
510
|
+
dict.set_item("name", name)?;
|
|
511
|
+
dict.set_item("public_id", public_id)?;
|
|
512
|
+
dict.set_item("system_id", system_id)?;
|
|
513
|
+
}
|
|
514
|
+
Html5DictNodeData::Text { text } | Html5DictNodeData::Comment { text } => {
|
|
515
|
+
dict.set_item("text", text)?;
|
|
516
|
+
}
|
|
517
|
+
Html5DictNodeData::Element { name, attrs, .. } => {
|
|
518
|
+
dict.set_item("tag", qual_name_to_string(name))?;
|
|
519
|
+
dict.set_item("namespace", name.ns.to_string())?;
|
|
520
|
+
dict.set_item("attrs", attrs_to_map(attrs))?;
|
|
521
|
+
}
|
|
522
|
+
Html5DictNodeData::ProcessingInstruction { target, data } => {
|
|
523
|
+
dict.set_item("target", target)?;
|
|
524
|
+
dict.set_item("data", data)?;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
let child_ids = if node_id == 0 && matches!(tree.parse_kind, Html5ParseKind::Fragment) {
|
|
529
|
+
let mut flattened = Vec::new();
|
|
530
|
+
for &child in &node.children {
|
|
531
|
+
if let Html5DictNodeData::Element { name, .. } = &tree.nodes[child].data
|
|
532
|
+
&& name.local.as_ref() == "html"
|
|
533
|
+
{
|
|
534
|
+
flattened.extend(tree.nodes[child].children.iter().copied());
|
|
535
|
+
} else {
|
|
536
|
+
flattened.push(child);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
flattened
|
|
540
|
+
} else {
|
|
541
|
+
node.children.clone()
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
let children = PyList::empty(py);
|
|
545
|
+
for child in child_ids {
|
|
546
|
+
children.append(html5_dict_node_to_py(py, tree, child, None)?)?;
|
|
547
|
+
}
|
|
548
|
+
dict.set_item("children", children)?;
|
|
549
|
+
|
|
550
|
+
if node_id == 0 {
|
|
551
|
+
dict.set_item("quirks_mode", quirks_mode_name(tree.quirks_mode))?;
|
|
552
|
+
dict.set_item("errors", &tree.errors)?;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
Ok(dict.into())
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
fn parse_document_to_dict_tree(
|
|
559
|
+
html: &str,
|
|
560
|
+
max_size_bytes: Option<usize>,
|
|
561
|
+
truncate_on_limit: bool,
|
|
562
|
+
) -> PyResult<Html5DictTree> {
|
|
563
|
+
let max_size_bytes = max_size_bytes.unwrap_or(DEFAULT_MAX_PARSE_BYTES);
|
|
564
|
+
let html_to_parse = ensure_within_size_limit(html, max_size_bytes, truncate_on_limit)?;
|
|
565
|
+
Ok(html5ever_parse_document(
|
|
566
|
+
Html5DictSink::new(Html5ParseKind::Document),
|
|
567
|
+
ParseOpts::default(),
|
|
568
|
+
)
|
|
569
|
+
.one(html_to_parse.as_ref()))
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
fn parse_fragment_to_dict_tree(
|
|
573
|
+
html: &str,
|
|
574
|
+
max_size_bytes: Option<usize>,
|
|
575
|
+
truncate_on_limit: bool,
|
|
576
|
+
) -> PyResult<Html5DictTree> {
|
|
577
|
+
let max_size_bytes = max_size_bytes.unwrap_or(DEFAULT_MAX_PARSE_BYTES);
|
|
578
|
+
let html_to_parse = ensure_within_size_limit(html, max_size_bytes, truncate_on_limit)?;
|
|
579
|
+
let context_name = QualName::new(None, ns!(html), local_name!("div"));
|
|
580
|
+
Ok(html5ever_parse_fragment(
|
|
581
|
+
Html5DictSink::new(Html5ParseKind::Fragment),
|
|
582
|
+
ParseOpts::default(),
|
|
583
|
+
context_name,
|
|
584
|
+
Vec::new(),
|
|
585
|
+
false,
|
|
586
|
+
)
|
|
587
|
+
.one(html_to_parse.as_ref()))
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
fn html5_tree_to_py_dict(py: Python<'_>, tree: Html5DictTree) -> PyResult<Py<PyDict>> {
|
|
591
|
+
let root_override = match tree.parse_kind {
|
|
592
|
+
Html5ParseKind::Document => Some("document"),
|
|
593
|
+
Html5ParseKind::Fragment => Some("document_fragment"),
|
|
594
|
+
};
|
|
595
|
+
html5_dict_node_to_py(py, &tree, 0, root_override)
|
|
596
|
+
}
|
|
597
|
+
|
|
113
598
|
/// Tiny helper to truncate text in __repr__.
|
|
114
599
|
fn truncate_for_repr(s: &str, max_chars: usize) -> String {
|
|
115
600
|
let mut out = String::new();
|
|
@@ -1413,6 +1898,30 @@ fn parse(html: &str, max_size_bytes: Option<usize>, truncate_on_limit: bool) ->
|
|
|
1413
1898
|
Document::from_html(html, max_size_bytes, truncate_on_limit)
|
|
1414
1899
|
}
|
|
1415
1900
|
|
|
1901
|
+
#[pyfunction(name = "parse_document")]
|
|
1902
|
+
#[pyo3(signature = (html, *, max_size_bytes=None, truncate_on_limit=false))]
|
|
1903
|
+
fn parse_document_dict(
|
|
1904
|
+
py: Python<'_>,
|
|
1905
|
+
html: &str,
|
|
1906
|
+
max_size_bytes: Option<usize>,
|
|
1907
|
+
truncate_on_limit: bool,
|
|
1908
|
+
) -> PyResult<Py<PyDict>> {
|
|
1909
|
+
let tree = parse_document_to_dict_tree(html, max_size_bytes, truncate_on_limit)?;
|
|
1910
|
+
html5_tree_to_py_dict(py, tree)
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
#[pyfunction(name = "parse_fragment")]
|
|
1914
|
+
#[pyo3(signature = (html, *, max_size_bytes=None, truncate_on_limit=false))]
|
|
1915
|
+
fn parse_fragment_dict(
|
|
1916
|
+
py: Python<'_>,
|
|
1917
|
+
html: &str,
|
|
1918
|
+
max_size_bytes: Option<usize>,
|
|
1919
|
+
truncate_on_limit: bool,
|
|
1920
|
+
) -> PyResult<Py<PyDict>> {
|
|
1921
|
+
let tree = parse_fragment_to_dict_tree(html, max_size_bytes, truncate_on_limit)?;
|
|
1922
|
+
html5_tree_to_py_dict(py, tree)
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1416
1925
|
#[pyfunction]
|
|
1417
1926
|
#[pyo3(signature = (html, *, max_size_bytes=None, truncate_on_limit=false))]
|
|
1418
1927
|
fn prettify(
|
|
@@ -1629,6 +2138,8 @@ fn scraper_rs(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
|
1629
2138
|
|
|
1630
2139
|
// Top-level functions
|
|
1631
2140
|
m.add_function(wrap_pyfunction!(parse, m)?)?;
|
|
2141
|
+
m.add_function(wrap_pyfunction!(parse_document_dict, m)?)?;
|
|
2142
|
+
m.add_function(wrap_pyfunction!(parse_fragment_dict, m)?)?;
|
|
1632
2143
|
m.add_function(wrap_pyfunction!(prettify, m)?)?;
|
|
1633
2144
|
m.add_function(wrap_pyfunction!(select, m)?)?;
|
|
1634
2145
|
m.add_function(wrap_pyfunction!(select_first, m)?)?;
|
|
@@ -7,6 +7,8 @@ from scraper_rs import (
|
|
|
7
7
|
__version__,
|
|
8
8
|
first,
|
|
9
9
|
parse,
|
|
10
|
+
parse_document,
|
|
11
|
+
parse_fragment,
|
|
10
12
|
prettify,
|
|
11
13
|
select,
|
|
12
14
|
select_first,
|
|
@@ -204,6 +206,54 @@ def test_top_level_parse_and_select(sample_html: str) -> None:
|
|
|
204
206
|
assert xpath_first(sample_html, "//div[@data-id='1']/a").text == "First"
|
|
205
207
|
|
|
206
208
|
|
|
209
|
+
def test_parse_document_to_dict(sample_html: str) -> None:
|
|
210
|
+
parsed = parse_document(sample_html)
|
|
211
|
+
|
|
212
|
+
assert parsed["node_type"] == "document"
|
|
213
|
+
assert parsed["quirks_mode"] in {"quirks", "limited-quirks", "no-quirks"}
|
|
214
|
+
assert isinstance(parsed["errors"], list)
|
|
215
|
+
|
|
216
|
+
html_node = next(
|
|
217
|
+
child
|
|
218
|
+
for child in parsed["children"]
|
|
219
|
+
if child["node_type"] == "element" and child["tag"] == "html"
|
|
220
|
+
)
|
|
221
|
+
body_node = next(
|
|
222
|
+
child
|
|
223
|
+
for child in html_node["children"]
|
|
224
|
+
if child["node_type"] == "element" and child["tag"] == "body"
|
|
225
|
+
)
|
|
226
|
+
first_div = next(
|
|
227
|
+
child
|
|
228
|
+
for child in body_node["children"]
|
|
229
|
+
if child["node_type"] == "element" and child["tag"] == "div"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
assert first_div["attrs"]["class"] == "item"
|
|
233
|
+
assert first_div["attrs"]["data-id"] == "1"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_parse_fragment_to_dict() -> None:
|
|
237
|
+
parsed = parse_fragment('<div class="item">Hello <span>world</span><!-- note --></div>')
|
|
238
|
+
|
|
239
|
+
assert parsed["node_type"] == "document_fragment"
|
|
240
|
+
assert parsed["errors"] == []
|
|
241
|
+
|
|
242
|
+
div_node = next(
|
|
243
|
+
child
|
|
244
|
+
for child in parsed["children"]
|
|
245
|
+
if child["node_type"] == "element" and child["tag"] == "div"
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
assert div_node["attrs"]["class"] == "item"
|
|
249
|
+
assert div_node["children"][0]["node_type"] == "text"
|
|
250
|
+
assert div_node["children"][0]["text"] == "Hello "
|
|
251
|
+
assert div_node["children"][1]["tag"] == "span"
|
|
252
|
+
assert div_node["children"][1]["children"][0]["text"] == "world"
|
|
253
|
+
assert div_node["children"][2]["node_type"] == "comment"
|
|
254
|
+
assert div_node["children"][2]["text"] == " note "
|
|
255
|
+
|
|
256
|
+
|
|
207
257
|
def test_css_alias_and_invalid_selector(sample_html: str) -> None:
|
|
208
258
|
doc = Document(sample_html)
|
|
209
259
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|