rusteerio 1.1.1 → 1.1.2
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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/README.md +37 -37
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/dist/{chunk-FPRP6UIX.mjs → chunk-HN63P3U7.mjs} +625 -401
- package/dist/chunk-HN63P3U7.mjs.map +1 -0
- package/dist/index.cjs +624 -400
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +3 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.mjs +1 -1
- package/dist/slim.cjs +555 -407
- package/dist/slim.cjs.map +1 -1
- package/dist/slim.d.mts +2 -2
- package/dist/slim.d.ts +2 -2
- package/dist/slim.mjs +1 -1
- package/dist/{types-Cs9UP1Vv.d.mts → types-CJsc0PH-.d.mts} +12 -0
- package/dist/{types-Cs9UP1Vv.d.ts → types-CJsc0PH-.d.ts} +12 -0
- package/dist/utils.d.mts +1 -1
- package/dist/utils.d.ts +1 -1
- package/native/src/arena.rs +163 -7
- package/native/src/attributes.rs +18 -1
- package/native/src/codec.rs +79 -0
- package/native/src/lib.rs +117 -30
- package/native/src/snapshot.rs +117 -80
- package/native/src/tokenizer.rs +34 -20
- package/package.json +1 -1
- package/rusteerio.darwin-arm64.node +0 -0
- package/rusteerio.darwin-x64.node +0 -0
- package/rusteerio.linux-arm64-gnu.node +0 -0
- package/rusteerio.linux-arm64-musl.node +0 -0
- package/rusteerio.linux-x64-gnu.node +0 -0
- package/rusteerio.linux-x64-musl.node +0 -0
- package/rusteerio.win32-arm64-msvc.node +0 -0
- package/rusteerio.win32-x64-msvc.node +0 -0
- package/src/extract.ts +117 -24
- package/src/index.ts +27 -6
- package/src/match.ts +4 -4
- package/src/native.ts +4 -0
- package/src/nodes.ts +70 -10
- package/src/router.ts +80 -4
- package/src/selection.ts +18 -17
- package/src/snapshot.ts +37 -0
- package/src/transport.ts +1 -1
- package/dist/chunk-FPRP6UIX.mjs.map +0 -1
package/native/src/tokenizer.rs
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
//! Build htmlparser2-compatible trees from tokenizer events. Events come from
|
|
2
2
|
//! the native tokenizer port or from an explicitly supplied JavaScript
|
|
3
3
|
//! Tokenizer, whose recorded events directly determine the DOM.
|
|
4
|
-
use crate::arena::{
|
|
4
|
+
use crate::arena::{
|
|
5
|
+
append_parsed, append_parsed_new, append_parsed_text, html_parser_void, implied_close,
|
|
6
|
+
DomArena, DomNode,
|
|
7
|
+
};
|
|
5
8
|
use crate::markup_tokenizer::Token;
|
|
6
9
|
use serde_json::{json, Map, Value};
|
|
7
10
|
|
|
8
11
|
struct Pending {
|
|
9
12
|
name: String,
|
|
10
|
-
attrs: Map<String, Value>,
|
|
11
13
|
start: usize,
|
|
12
14
|
}
|
|
13
15
|
struct Builder<'a> {
|
|
@@ -16,6 +18,9 @@ struct Builder<'a> {
|
|
|
16
18
|
parents: Vec<u32>,
|
|
17
19
|
foreign: Vec<bool>,
|
|
18
20
|
pending: Option<Pending>,
|
|
21
|
+
/// The pending tag's attributes in source order, collected so its map is
|
|
22
|
+
/// allocated once at its final size.
|
|
23
|
+
attributes: Vec<(String, String)>,
|
|
19
24
|
attribute_name: String,
|
|
20
25
|
attribute_value: String,
|
|
21
26
|
start: usize,
|
|
@@ -40,14 +45,21 @@ impl<'a> Builder<'a> {
|
|
|
40
45
|
let with_end_indices = options.get("withEndIndices") == Some(&Value::Bool(true));
|
|
41
46
|
let recognize_self_closing = options.get("recognizeSelfClosing").and_then(Value::as_bool);
|
|
42
47
|
let mut arena = DomArena::from_records(vec![DomNode::new("root")], options);
|
|
43
|
-
// Growing the arena one doubling at a time copies every node record
|
|
44
|
-
|
|
48
|
+
// Growing the arena one doubling at a time copies every node record
|
|
49
|
+
// again. Every node but text starts with `<`, and text lies between them.
|
|
50
|
+
let markup_starts = source
|
|
51
|
+
.units
|
|
52
|
+
.iter()
|
|
53
|
+
.filter(|unit| **unit == u16::from(b'<'))
|
|
54
|
+
.count();
|
|
55
|
+
arena.nodes.reserve(markup_starts + 1);
|
|
45
56
|
Self {
|
|
46
57
|
arena,
|
|
47
58
|
source,
|
|
48
59
|
parents: vec![0],
|
|
49
60
|
foreign: vec![xml],
|
|
50
61
|
pending: None,
|
|
62
|
+
attributes: Vec::new(),
|
|
51
63
|
attribute_name: String::new(),
|
|
52
64
|
attribute_value: String::new(),
|
|
53
65
|
start: 0,
|
|
@@ -109,9 +121,9 @@ impl<'a> Builder<'a> {
|
|
|
109
121
|
self.foreign.push(false);
|
|
110
122
|
}
|
|
111
123
|
}
|
|
124
|
+
self.attributes.clear();
|
|
112
125
|
self.pending = Some(Pending {
|
|
113
126
|
name,
|
|
114
|
-
attrs: Map::new(),
|
|
115
127
|
start: self.start,
|
|
116
128
|
});
|
|
117
129
|
}
|
|
@@ -123,18 +135,25 @@ impl<'a> Builder<'a> {
|
|
|
123
135
|
"style" if !self.xml => "style",
|
|
124
136
|
_ => "tag",
|
|
125
137
|
};
|
|
126
|
-
let mut node = DomNode::new(kind);
|
|
127
138
|
let lowercase_name = (pending.name.bytes().any(|byte| byte.is_ascii_uppercase()))
|
|
128
139
|
.then(|| pending.name.to_ascii_lowercase());
|
|
129
|
-
|
|
130
|
-
node.attribs = pending.attrs;
|
|
131
|
-
let id = append_parsed(
|
|
140
|
+
let id = append_parsed_new(
|
|
132
141
|
&mut self.arena,
|
|
133
142
|
*self.parents.last().unwrap(),
|
|
134
|
-
|
|
143
|
+
kind,
|
|
135
144
|
pending.start,
|
|
136
145
|
self.end,
|
|
137
146
|
);
|
|
147
|
+
let node = &mut self.arena.nodes[id as usize];
|
|
148
|
+
node.name = Some(pending.name);
|
|
149
|
+
let mut attribs = Map::with_capacity(self.attributes.len());
|
|
150
|
+
for (name, value) in self.attributes.drain(..) {
|
|
151
|
+
// A repeated attribute keeps its first value, as htmlparser2 does.
|
|
152
|
+
if let serde_json::map::Entry::Vacant(entry) = attribs.entry(name) {
|
|
153
|
+
entry.insert(Value::String(value));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
node.attribs = attribs.into();
|
|
138
157
|
let name = lowercase_name
|
|
139
158
|
.as_deref()
|
|
140
159
|
.or(self.arena.nodes[id as usize].name.as_deref())
|
|
@@ -153,12 +172,10 @@ impl<'a> Builder<'a> {
|
|
|
153
172
|
}
|
|
154
173
|
fn text(&mut self, data: String, end: usize) {
|
|
155
174
|
self.end = end.saturating_sub(1);
|
|
156
|
-
|
|
157
|
-
node.data = Some(data);
|
|
158
|
-
append_parsed(
|
|
175
|
+
append_parsed_text(
|
|
159
176
|
&mut self.arena,
|
|
160
177
|
*self.parents.last().unwrap(),
|
|
161
|
-
|
|
178
|
+
data,
|
|
162
179
|
self.start,
|
|
163
180
|
self.end,
|
|
164
181
|
);
|
|
@@ -422,12 +439,9 @@ impl Builder<'_> {
|
|
|
422
439
|
fn on_attribute_end(&mut self, end: usize) {
|
|
423
440
|
self.end = end;
|
|
424
441
|
let value = std::mem::take(&mut self.attribute_value);
|
|
425
|
-
if
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
.attrs
|
|
429
|
-
.insert(self.attribute_name.clone(), Value::String(value));
|
|
430
|
-
}
|
|
442
|
+
if self.pending.is_some() {
|
|
443
|
+
self.attributes
|
|
444
|
+
.push((std::mem::take(&mut self.attribute_name), value));
|
|
431
445
|
}
|
|
432
446
|
}
|
|
433
447
|
fn on_close_tag(&mut self, start: usize, end: usize) {
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/src/extract.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { Cheerio, nativeExtractionMethods } from './selection.js';
|
|
2
2
|
import { types } from 'node:util';
|
|
3
3
|
import { compileExtractPlan, hydrateExtractResult } from './extract-plan.js';
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
4
|
+
import { decodeJson } from './codec.js';
|
|
5
|
+
import { DOCUMENT, writeCount } from './router.js';
|
|
6
|
+
import type { AnyNode, CheerioOptions, Element, ExtractMap, ExtractedMap, ExtractDescriptor } from './types.js';
|
|
7
|
+
|
|
8
|
+
type ExtractPlan = NonNullable<ReturnType<typeof compileExtractPlan>>;
|
|
6
9
|
|
|
7
10
|
export interface ExtractMethods<T = AnyNode> {
|
|
8
11
|
extract<M extends ExtractMap>(map: M): ExtractedMap<M>;
|
|
@@ -12,26 +15,32 @@ function plainKeys(map: ExtractMap): string[] | undefined {
|
|
|
12
15
|
return !types.isProxy(map) && (Object.getPrototypeOf(map) === Object.prototype || Object.getPrototypeOf(map) === null) ? Object.keys(map) : undefined;
|
|
13
16
|
}
|
|
14
17
|
function nativePrefix(scope: Cheerio<AnyNode>, map: ExtractMap, keys: string[] | undefined, key: string): { keys: string[]; values: Record<string, unknown> } | undefined {
|
|
15
|
-
if (!keys || scope
|
|
18
|
+
if (!keys || !hasNativeExtraction(scope)) return undefined;
|
|
19
|
+
const prefix = prefixPlan(map, keys, key, scope.options);
|
|
20
|
+
// A single scalar entry costs one selector call and one property call; the
|
|
21
|
+
// native plan also avoids constructing an intermediate selection for each.
|
|
22
|
+
if (!prefix) return undefined;
|
|
23
|
+
return { keys: prefix.names, values: runPlan(scope, prefix.plan) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The native plan for the longest run of entries from `key` on that compile to one. */
|
|
27
|
+
function prefixPlan(map: ExtractMap, keys: string[], key: string, options: CheerioOptions): { names: string[]; plan: ExtractPlan } | undefined {
|
|
16
28
|
const prefix: ExtractMap = Object.create(null);
|
|
17
|
-
let
|
|
18
|
-
let
|
|
19
|
-
const index = keys.indexOf(key);
|
|
20
|
-
for (let position = index; position < keys.length; position++) {
|
|
29
|
+
let plan: ExtractPlan | undefined;
|
|
30
|
+
for (let position = keys.indexOf(key); position < keys.length; position++) {
|
|
21
31
|
const name = keys[position];
|
|
22
32
|
const slot = Object.getOwnPropertyDescriptor(map, name);
|
|
23
33
|
if (!slot || !slot.enumerable || !('value' in slot)) break;
|
|
24
34
|
Object.defineProperty(prefix, name, { value: slot.value, enumerable: true, configurable: true });
|
|
25
|
-
const compiled = compileExtractPlan(prefix,
|
|
35
|
+
const compiled = compileExtractPlan(prefix, options);
|
|
26
36
|
if (!compiled) { delete prefix[name]; break; }
|
|
27
|
-
plan = compiled;
|
|
37
|
+
plan = compiled;
|
|
28
38
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
return { keys: Object.keys(prefix), values };
|
|
39
|
+
return plan ? { names: Object.keys(prefix), plan } : undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function hasNativeExtraction(scope: Cheerio<AnyNode>): boolean {
|
|
43
|
+
return scope._make === nativeExtractionMethods.make && scope._findBySelector === nativeExtractionMethods.find && scope.prop === nativeExtractionMethods.prop && scope.extract === nativeExtractionMethods.extract;
|
|
35
44
|
}
|
|
36
45
|
|
|
37
46
|
/** Descriptor callbacks stay in JavaScript; matching and properties use Rust. */
|
|
@@ -87,8 +96,19 @@ interface NativeRows { keys: Set<string>; rows: Record<string, unknown>[]; write
|
|
|
87
96
|
* exactly the fields before it, as in Cheerio.
|
|
88
97
|
*/
|
|
89
98
|
function nativeRows(scope: Cheerio<AnyNode>, key: string, all: boolean, selector: string, map: ExtractMap): NativeRows | undefined {
|
|
99
|
+
if (!hasNativeExtraction(scope)) return undefined;
|
|
100
|
+
const planned = rowsPlan(key, all, selector, map, scope.options);
|
|
101
|
+
if (!planned) return undefined;
|
|
102
|
+
const writes = writeCount();
|
|
103
|
+
const extracted = runPlan(scope, planned.plan)[key];
|
|
104
|
+
const rows = (all ? extracted : extracted === undefined ? [] : [extracted]) as Record<string, unknown>[];
|
|
105
|
+
return Array.isArray(rows) ? { keys: planned.nativeKeys, rows, writes } : undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The native plan for the fields of `map` that compile to one, when only some do. */
|
|
109
|
+
function rowsPlan(key: string, all: boolean, selector: string, map: ExtractMap, options: CheerioOptions): { nativeKeys: Set<string>; plan: ExtractPlan } | undefined {
|
|
90
110
|
const keys = plainKeys(map);
|
|
91
|
-
if (!keys
|
|
111
|
+
if (!keys) return undefined;
|
|
92
112
|
const subset: ExtractMap = Object.create(null);
|
|
93
113
|
const nativeKeys = new Set<string>();
|
|
94
114
|
for (const name of keys) {
|
|
@@ -96,21 +116,94 @@ function nativeRows(scope: Cheerio<AnyNode>, key: string, all: boolean, selector
|
|
|
96
116
|
if (!slot || !slot.enumerable || !('value' in slot)) continue;
|
|
97
117
|
const single: ExtractMap = Object.create(null);
|
|
98
118
|
Object.defineProperty(single, name, { value: slot.value, enumerable: true, configurable: true });
|
|
99
|
-
if (!compileExtractPlan(single,
|
|
119
|
+
if (!compileExtractPlan(single, options)) continue;
|
|
100
120
|
Object.defineProperty(subset, name, { value: slot.value, enumerable: true, configurable: true });
|
|
101
121
|
nativeKeys.add(name);
|
|
102
122
|
}
|
|
103
123
|
if (!nativeKeys.size || nativeKeys.size === keys.length) return undefined;
|
|
104
124
|
const outer: ExtractMap = Object.create(null);
|
|
105
125
|
Object.defineProperty(outer, key, { value: all ? [{ selector, value: subset }] : { selector, value: subset }, enumerable: true, configurable: true });
|
|
106
|
-
const plan = compileExtractPlan(outer,
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
126
|
+
const plan = compileExtractPlan(outer, options);
|
|
127
|
+
return plan ? { nativeKeys, plan } : undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The plans a walk of `map` from a document's root runs first, in order, so
|
|
132
|
+
* a batch can run them on its parsing threads. Callbacks may change the
|
|
133
|
+
* document, so later plans of the walk stay on the calling thread.
|
|
134
|
+
*/
|
|
135
|
+
export function rootExtractPlans(map: ExtractMap, options: CheerioOptions): ExtractPlan[] {
|
|
136
|
+
// Reading a map ahead of the walk must not run code the walk would run in
|
|
137
|
+
// a different order, and a malformed map fails later with the walk's own error.
|
|
138
|
+
if (!isPlainData(map, 0) || hasInheritedEntryFields()) return [];
|
|
139
|
+
try { return collectRootExtractPlans(map, options); } catch { return []; }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Whether reading `value` and everything it holds runs no code: no proxies, accessors or class instances. */
|
|
143
|
+
function isPlainData(value: unknown, depth: number): boolean {
|
|
144
|
+
if (typeof value !== 'object' || value === null) return true;
|
|
145
|
+
if (depth > 32 || types.isProxy(value)) return false;
|
|
146
|
+
const prototype = Object.getPrototypeOf(value);
|
|
147
|
+
if (prototype !== Object.prototype && prototype !== Array.prototype && prototype !== null) return false;
|
|
148
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
149
|
+
const slot = Object.getOwnPropertyDescriptor(value, key)!;
|
|
150
|
+
if (!('value' in slot) || !isPlainData(slot.value, depth + 1)) return false;
|
|
151
|
+
}
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function hasInheritedEntryFields(): boolean {
|
|
156
|
+
return 'selector' in Object.prototype || 'value' in Object.prototype || '0' in Array.prototype;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function collectRootExtractPlans(map: ExtractMap, options: CheerioOptions): ExtractPlan[] {
|
|
160
|
+
const plans: ExtractPlan[] = [];
|
|
161
|
+
const keys = plainKeys(map);
|
|
162
|
+
const processed = new Set<string>();
|
|
163
|
+
for (const key in map) {
|
|
164
|
+
if (processed.has(key)) continue;
|
|
165
|
+
const prefix = keys ? prefixPlan(map, keys, key, options) : undefined;
|
|
166
|
+
if (prefix) {
|
|
167
|
+
for (const name of prefix.names) processed.add(name);
|
|
168
|
+
plans.push(prefix.plan);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const entry = map[key];
|
|
172
|
+
const all = Array.isArray(entry);
|
|
173
|
+
const descriptor = (all ? entry[0] : entry) as string | ExtractDescriptor;
|
|
174
|
+
const value = typeof descriptor === 'string' ? 'textContent' : descriptor.value ?? 'textContent';
|
|
175
|
+
if (typeof value !== 'object' || value === null) continue;
|
|
176
|
+
const planned = rowsPlan(key, all, typeof descriptor === 'string' ? descriptor : descriptor.selector, value, options);
|
|
177
|
+
if (planned) plans.push(planned.plan);
|
|
178
|
+
}
|
|
179
|
+
return plans.filter((plan) => !JSON.stringify(plan).includes('innerHTML'));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Results of root plans a batch ran while parsing, valid while no document changes. */
|
|
183
|
+
const preparedPlans = new WeakMap<object, { writes: number; results: Map<string, string> }>();
|
|
184
|
+
|
|
185
|
+
export function prepareExtractPlans(document: object, plans: ExtractPlan[], results: (string | null)[] | null | undefined): void {
|
|
186
|
+
if (!results) return;
|
|
187
|
+
const prepared = new Map<string, string>();
|
|
188
|
+
for (let index = 0; index < plans.length; index++) {
|
|
189
|
+
const result = results[index];
|
|
190
|
+
if (typeof result === 'string') prepared.set(JSON.stringify(plans[index]), decodeJson(result));
|
|
191
|
+
}
|
|
192
|
+
preparedPlans.set(document, { writes: writeCount(), results: prepared });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function releaseExtractPlans(document: object): void {
|
|
196
|
+
preparedPlans.delete(document);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function runPlan(scope: Cheerio<AnyNode>, plan: ExtractPlan): Record<string, unknown> {
|
|
200
|
+
const document = scope[DOCUMENT] as { rootId(): number };
|
|
201
|
+
const prepared = preparedPlans.get(document);
|
|
202
|
+
const isRoot = prepared !== undefined && prepared.writes === writeCount() && scope.ids.length === 1 && scope.ids[0] === document.rootId();
|
|
203
|
+
const result = isRoot ? prepared.results.get(JSON.stringify(plan)) : undefined;
|
|
204
|
+
const values = result !== undefined ? JSON.parse(result) as Record<string, unknown> : scope.op<Record<string, unknown>>('extractPlan', { ids: scope.ids, plan });
|
|
110
205
|
hydrateExtractResult(values, plan.entries);
|
|
111
|
-
|
|
112
|
-
const rows = (all ? extracted : extracted === undefined ? [] : [extracted]) as Record<string, unknown>[];
|
|
113
|
-
return Array.isArray(rows) ? { keys: nativeKeys, rows, writes } : undefined;
|
|
206
|
+
return values;
|
|
114
207
|
}
|
|
115
208
|
|
|
116
209
|
/**
|
package/src/index.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import * as nativeDOM from './dom.js';
|
|
2
2
|
import { parseNativeDocument, parserOptions, deliverParseErrors, hasParserHooks, cleanParserOptions, createTreeAdapterSession } from './hooks.js';
|
|
3
3
|
import { compileExtractPlan, hydrateExtractResult } from './extract-plan.js';
|
|
4
|
-
import { extractCooperatively } from './extract.js';
|
|
4
|
+
import { extractCooperatively, prepareExtractPlans, releaseExtractPlans, rootExtractPlans } from './extract.js';
|
|
5
5
|
import { setImmediate as yieldToEventLoop } from 'node:timers/promises';
|
|
6
6
|
import { availableParallelism } from 'node:os';
|
|
7
7
|
import { requireNative, nativeStatus, type NativeDocument, type NativeQuery } from './native.js';
|
|
8
8
|
import { Cheerio } from './selection.js';
|
|
9
9
|
import { getNode, importNodes, nodeBinding, operation, rootNode, viewIdIn } from './nodes.js';
|
|
10
10
|
import { isHtml } from './utils.js';
|
|
11
|
-
import { DOCUMENT, exposeDocumentTo, resumeCollection, routeDocument, sameNodeSpace } from './router.js';
|
|
11
|
+
import { DOCUMENT, exposeDocumentTo, resumeCollection, routeDocument, sameNodeSpace, usePreparedSnapshot } from './router.js';
|
|
12
12
|
import type { AnyNode, Document, CheerioAPI, CheerioOptions, LoadContent, ParallelOptions, ExtractMap, ExtractedMap } from './types.js';
|
|
13
13
|
|
|
14
14
|
export type * from './types.js';
|
|
@@ -26,7 +26,11 @@ export { configureWorkers, workerPoolInfo } from './workers.js';
|
|
|
26
26
|
export type { WorkerOptions, WorkerPoolInfo } from './workers.js';
|
|
27
27
|
export { loadBuffer, stringStream, decodeStream, fromURL } from './io.js';
|
|
28
28
|
|
|
29
|
+
const defaultOptions: CheerioOptions = { _useHtmlParser2: false };
|
|
30
|
+
|
|
31
|
+
/** Cheerio's option merge: without overrides, calls share the base options object. */
|
|
29
32
|
export function flattenOptions(options?: CheerioOptions | null, base?: CheerioOptions): CheerioOptions {
|
|
33
|
+
if (!options) return base ?? defaultOptions;
|
|
30
34
|
const result: CheerioOptions = { _useHtmlParser2: false, ...base, ...options };
|
|
31
35
|
if (options?.xml) { result._useHtmlParser2 = true; result.xmlMode = true; if (options.xml !== true) Object.assign(result, options.xml); }
|
|
32
36
|
else if (options?.xmlMode) result._useHtmlParser2 = true;
|
|
@@ -181,13 +185,29 @@ function parallelOptions(options?: ParallelOptions | null): { options: CheerioOp
|
|
|
181
185
|
}
|
|
182
186
|
|
|
183
187
|
export async function loadAll(content: readonly string[], options?: ParallelOptions | null, isDocument = true): Promise<CheerioAPI[]> {
|
|
188
|
+
return loadAllWithPlans(content, options, isDocument);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Loads documents in parallel. With `plans`, the parsing threads also run
|
|
193
|
+
* them on each root and build each document's snapshot, which extraction
|
|
194
|
+
* with callbacks would otherwise do on the calling thread when it needs them.
|
|
195
|
+
*/
|
|
196
|
+
async function loadAllWithPlans(content: readonly string[], options: ParallelOptions | null | undefined, isDocument: boolean, plans?: ReturnType<typeof rootExtractPlans>): Promise<CheerioAPI[]> {
|
|
184
197
|
const { options: opts, threads } = parallelOptions(options);
|
|
185
198
|
if (!Array.isArray(content) || content.some((item) => typeof item !== 'string')) throw new TypeError('loadAll expects an array of HTML strings');
|
|
186
199
|
if (!content.length) return [];
|
|
187
200
|
const binding = requireNative();
|
|
188
201
|
if (!binding.parseManyAsync || hasParserHooks(opts)) return Promise.all(content.map((html) => loadAsync(html, opts, isDocument)));
|
|
189
|
-
const documents = await binding.parseManyAsync([...content], isDocument, threads, JSON.stringify(opts));
|
|
190
|
-
return documents.map((document) =>
|
|
202
|
+
const documents = await binding.parseManyAsync([...content], isDocument, threads, JSON.stringify(opts), plans && JSON.stringify(plans));
|
|
203
|
+
return documents.map((document) => {
|
|
204
|
+
const routed = routeDocument(document, opts);
|
|
205
|
+
if (plans) {
|
|
206
|
+
usePreparedSnapshot(routed);
|
|
207
|
+
prepareExtractPlans(routed, plans, document.takePreparedPlans?.());
|
|
208
|
+
}
|
|
209
|
+
return createAPI(routed, opts);
|
|
210
|
+
});
|
|
191
211
|
}
|
|
192
212
|
|
|
193
213
|
function nativeQueries(map: ExtractMap, options: CheerioOptions): NativeQuery[] | undefined {
|
|
@@ -223,11 +243,12 @@ export async function extractAll<M extends ExtractMap>(content: readonly string[
|
|
|
223
243
|
}
|
|
224
244
|
return results as ExtractedMap<M>[];
|
|
225
245
|
}
|
|
226
|
-
const documents = await
|
|
246
|
+
const documents = await loadAllWithPlans(content, { ...opts, threads }, true, hasParserHooks(opts) ? undefined : rootExtractPlans(map, opts));
|
|
227
247
|
const results: ExtractedMap<M>[] = [];
|
|
228
248
|
let yieldedAt = performance.now();
|
|
229
249
|
for (const document of documents) {
|
|
230
|
-
|
|
250
|
+
const root = document.root();
|
|
251
|
+
try { results.push((await extractCooperatively(root, map)).value); } finally { releaseExtractPlans(root[DOCUMENT]); }
|
|
231
252
|
if (performance.now() - yieldedAt >= 4) { await yieldToEventLoop(); yieldedAt = performance.now(); }
|
|
232
253
|
}
|
|
233
254
|
return results;
|
package/src/match.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { NativeDocument } from './native.js';
|
|
2
2
|
import type { CheerioOptions } from './types.js';
|
|
3
|
-
import { attributeAt, isRouted,
|
|
3
|
+
import { attributeAt, isRouted, recordAt, stateIn, structureSnapshot, type DocumentState } from './router.js';
|
|
4
4
|
import { NodeSnapshot } from './snapshot.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -88,13 +88,13 @@ export function selectSimpleWithin(document: NativeDocument, contexts: readonly
|
|
|
88
88
|
const isElementType = (type: string | undefined): boolean => type === 'tag' || type === 'script' || type === 'style';
|
|
89
89
|
|
|
90
90
|
function typeAt(state: DocumentState, local: number): string | undefined {
|
|
91
|
-
const snapshot =
|
|
91
|
+
const snapshot = structureSnapshot(state, local);
|
|
92
92
|
return snapshot ? snapshot.typeOf(local) : recordAt(state, local)?.type;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
/** Pushes children last to first, so they pop in document order. */
|
|
96
96
|
function pushChildren(state: DocumentState, local: number, stack: number[]): void {
|
|
97
|
-
const snapshot =
|
|
97
|
+
const snapshot = structureSnapshot(state, local);
|
|
98
98
|
if (snapshot) {
|
|
99
99
|
for (let index = snapshot.childCount(local) - 1; index >= 0; index--) stack.push(snapshot.childAt(local, index));
|
|
100
100
|
return;
|
|
@@ -116,7 +116,7 @@ function sameMatchOptions(documentOptions: CheerioOptions, isXml: boolean, isLow
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
function matchesCompound(state: DocumentState, local: number, compound: Compound, isLowerCaseTags: boolean, isLowerCaseAttributes: boolean): boolean {
|
|
119
|
-
const snapshot =
|
|
119
|
+
const snapshot = structureSnapshot(state, local);
|
|
120
120
|
const record = snapshot ? undefined : recordAt(state, local);
|
|
121
121
|
if (!snapshot && !record) return false;
|
|
122
122
|
if (!isElementType(snapshot ? snapshot.typeOf(local) : record!.type)) return false;
|
package/src/native.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync } from 'node:fs';
|
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { wrapNativeBinding } from './transport.js';
|
|
6
|
+
import type { NativeSnapshot } from './snapshot.js';
|
|
6
7
|
|
|
7
8
|
export interface NativeNodeRecord {
|
|
8
9
|
id: number;
|
|
@@ -41,6 +42,8 @@ export interface NativeDocument {
|
|
|
41
42
|
tagName(id: number): string | undefined | null;
|
|
42
43
|
exportTree(): string;
|
|
43
44
|
rootId(): number;
|
|
45
|
+
takePreparedPlans?(): (string | null)[] | null;
|
|
46
|
+
takePreparedSnapshot?(isWanted: boolean): NativeSnapshot | null;
|
|
44
47
|
parserDiagnostics?(): string;
|
|
45
48
|
}
|
|
46
49
|
|
|
@@ -76,6 +79,7 @@ export interface NativeBinding {
|
|
|
76
79
|
isDocument: boolean,
|
|
77
80
|
threads: number,
|
|
78
81
|
optionsJson?: string,
|
|
82
|
+
plansJson?: string,
|
|
79
83
|
) => Promise<NativeDocument[]>;
|
|
80
84
|
extractManyAsync?: (
|
|
81
85
|
html: string[],
|
package/src/nodes.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { NativeDocument, NativeNodeRecord } from './native.js';
|
|
2
|
-
import
|
|
2
|
+
import { NodeSnapshot } from './snapshot.js';
|
|
3
3
|
import type { AnyNode } from './types.js';
|
|
4
4
|
import { nodePrototype } from './dom.js';
|
|
5
|
-
import { cachedView, cacheView, exposeTo, isLocalState, isRouted, onViewRefresh, plainSnapshot, recordAt, routedFor, stateIn, VIEW_ATTRIBUTES, VIEW_CHILD_TARGET, VIEW_LOCAL, VIEW_SELF, VIEW_STATE, type DocumentState } from './router.js';
|
|
5
|
+
import { cachedView, cacheView, exposeTo, isLocalState, isRouted, onViewRefresh, plainSnapshot, recordAt, routedFor, stateIn, structureSnapshot, VIEW_ATTRIBUTES, VIEW_CHILD_TARGET, VIEW_LOCAL, VIEW_SELF, VIEW_STATE, type DocumentState } from './router.js';
|
|
6
6
|
|
|
7
7
|
export interface NodeBinding { document: NativeDocument; id: number }
|
|
8
8
|
|
|
@@ -85,6 +85,10 @@ export function traverseRecords(document: NativeDocument, ids: number[], kind: s
|
|
|
85
85
|
for (const id of ids) {
|
|
86
86
|
const state = stateIn(document, id);
|
|
87
87
|
const { base } = state;
|
|
88
|
+
// Attribute writes leave a snapshot's links and types current, and other
|
|
89
|
+
// writes discard it, so its arrays answer without records.
|
|
90
|
+
const snapshot = state.bulk instanceof NodeSnapshot ? state.bulk : undefined;
|
|
91
|
+
if (snapshot?.isPresent(id - base) && traverseSnapshot(snapshot, base, id - base, kind, result)) continue;
|
|
88
92
|
const node = recordAt(state, id - base);
|
|
89
93
|
if (!node) continue;
|
|
90
94
|
const isElement = (local: number): boolean => isElementType(recordAt(state, local)?.type);
|
|
@@ -126,15 +130,62 @@ export function traverseRecords(document: NativeDocument, ids: number[], kind: s
|
|
|
126
130
|
return result;
|
|
127
131
|
}
|
|
128
132
|
|
|
133
|
+
/** `traverseRecords` for one node of a current snapshot; false for kinds it leaves to records. */
|
|
134
|
+
function traverseSnapshot(snapshot: NodeSnapshot, base: number, local: number, kind: string, result: number[]): boolean {
|
|
135
|
+
const pushChildren = (parent: number, isElementsOnly: boolean, except: number): void => {
|
|
136
|
+
const count = snapshot.childCount(parent);
|
|
137
|
+
for (let index = 0; index < count; index++) {
|
|
138
|
+
const child = snapshot.childAt(parent, index);
|
|
139
|
+
if (child !== except && (!isElementsOnly || snapshot.isElementAt(child))) result.push(base + child);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
switch (kind) {
|
|
143
|
+
case 'children': pushChildren(local, true, -1); return true;
|
|
144
|
+
case 'contents': pushChildren(local, false, -1); return true;
|
|
145
|
+
case 'siblings': {
|
|
146
|
+
const parent = snapshot.linkOf(local, 1);
|
|
147
|
+
if (parent !== null && snapshot.isPresent(parent)) pushChildren(parent, true, local);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
case 'parent': {
|
|
151
|
+
const parent = snapshot.linkOf(local, 1);
|
|
152
|
+
if (parent !== null && snapshot.isPresent(parent) && snapshot.isElementAt(parent)) result.push(base + parent);
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
case 'parents': case 'ancestors': {
|
|
156
|
+
let parent = snapshot.linkOf(local, 1);
|
|
157
|
+
for (let steps = 0; parent !== null && steps < snapshot.size && snapshot.isPresent(parent) && !snapshot.isRootAt(parent); steps++) {
|
|
158
|
+
if (snapshot.isElementAt(parent)) result.push(base + parent);
|
|
159
|
+
parent = snapshot.linkOf(parent, 1);
|
|
160
|
+
}
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
case 'next': case 'prev': case 'nextAll': case 'prevAll': {
|
|
164
|
+
const offset = kind === 'next' || kind === 'nextAll' ? 3 : 2;
|
|
165
|
+
const isSingle = kind === 'next' || kind === 'prev';
|
|
166
|
+
let sibling = snapshot.linkOf(local, offset);
|
|
167
|
+
for (let steps = 0; sibling !== null && steps < snapshot.size && snapshot.isPresent(sibling); steps++) {
|
|
168
|
+
if (snapshot.isElementAt(sibling)) {
|
|
169
|
+
result.push(base + sibling);
|
|
170
|
+
if (isSingle) break;
|
|
171
|
+
}
|
|
172
|
+
sibling = snapshot.linkOf(sibling, offset);
|
|
173
|
+
}
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
default: return false;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
129
180
|
/** The node object for an arena slot, created on first use and then shared. */
|
|
130
181
|
export function viewAt(state: DocumentState, local: number): AnyNode | undefined {
|
|
131
182
|
const existing = state.views[local] ?? cachedView(state, local);
|
|
132
183
|
if (existing) return existing as AnyNode;
|
|
133
184
|
const parsed = state.adapterNodes?.[local];
|
|
134
185
|
if (parsed) { state.adapterNodes![local] = undefined; return adoptObject(state, local, parsed); }
|
|
135
|
-
const snapshot =
|
|
186
|
+
const snapshot = structureSnapshot(state, local);
|
|
136
187
|
let constructor: ViewConstructor;
|
|
137
|
-
if (snapshot) constructor = viewConstructor(snapshot.typeOf(local), false);
|
|
188
|
+
if (snapshot && !snapshot.hasExtraFields(local)) constructor = viewConstructor(snapshot.typeOf(local), false);
|
|
138
189
|
else {
|
|
139
190
|
const record = recordAt(state, local);
|
|
140
191
|
if (!record) return undefined;
|
|
@@ -342,14 +393,23 @@ function fillChildren(view: object): void {
|
|
|
342
393
|
if (!target) return;
|
|
343
394
|
const state = (view as View)[VIEW_STATE];
|
|
344
395
|
const local = (view as View)[VIEW_LOCAL];
|
|
345
|
-
const snapshot =
|
|
396
|
+
const snapshot = structureSnapshot(state, local);
|
|
346
397
|
if (snapshot && state.stale.size === 0) {
|
|
347
398
|
fillFromSnapshot(state, snapshot, local, target);
|
|
348
399
|
return;
|
|
349
400
|
}
|
|
350
401
|
const locals = viewRecord(view as View).children ?? [];
|
|
351
|
-
target.length = locals.length;
|
|
352
402
|
for (let index = 0; index < locals.length; index++) target[index] = viewAt(state, locals[index])!;
|
|
403
|
+
truncate(target, locals.length);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Children are written in order and the length is trimmed afterwards:
|
|
408
|
+
* presetting the length of a fresh array makes it holey and costs more than
|
|
409
|
+
* filling it.
|
|
410
|
+
*/
|
|
411
|
+
function truncate(target: AnyNode[], length: number): void {
|
|
412
|
+
if (target.length !== length) target.length = length;
|
|
353
413
|
}
|
|
354
414
|
|
|
355
415
|
/**
|
|
@@ -364,17 +424,17 @@ function fillFromSnapshot(state: DocumentState, snapshot: NodeSnapshot, local: n
|
|
|
364
424
|
const views = state.views;
|
|
365
425
|
const isStrong = !state.adapterNodes;
|
|
366
426
|
const constructors = snapshotConstructors(snapshot);
|
|
367
|
-
target.length = count;
|
|
368
427
|
for (let index = 0; index < count; index++) {
|
|
369
428
|
const child = childIds[first + index];
|
|
370
429
|
let node = (views[child] ?? (state.weakViews.size ? cachedView(state, child) : undefined)) as AnyNode | undefined;
|
|
371
430
|
if (node === undefined) {
|
|
372
|
-
const constructor = isStrong && child < state.baseline && snapshot.
|
|
431
|
+
const constructor = isStrong && child < state.baseline && snapshot.isPresent(child) && !snapshot.hasExtraFields(child) ? constructors(snapshot.typeReference(child)) : undefined;
|
|
373
432
|
if (constructor) views[child] = node = new constructor(state, child) as unknown as AnyNode;
|
|
374
433
|
else node = viewAt(state, child)!;
|
|
375
434
|
}
|
|
376
435
|
target[index] = node;
|
|
377
436
|
}
|
|
437
|
+
truncate(target, count);
|
|
378
438
|
}
|
|
379
439
|
|
|
380
440
|
function snapshotConstructors(snapshot: NodeSnapshot): (reference: number) => ViewConstructor {
|
|
@@ -397,7 +457,7 @@ const snapshotField = (field: 'type' | 'name' | 'data'): Accessor => ({
|
|
|
397
457
|
get() {
|
|
398
458
|
const state = this[VIEW_STATE];
|
|
399
459
|
const local = this[VIEW_LOCAL];
|
|
400
|
-
const snapshot = plainSnapshot(state, local);
|
|
460
|
+
const snapshot = field === 'data' ? plainSnapshot(state, local) : structureSnapshot(state, local);
|
|
401
461
|
if (snapshot) return field === 'type' ? snapshot.typeOf(local) : field === 'name' ? snapshot.nameOf(local) : snapshot.dataOf(local);
|
|
402
462
|
return (viewRecord(this) as any)[field];
|
|
403
463
|
},
|
|
@@ -408,7 +468,7 @@ const pointerField = (field: 'parent' | 'prev' | 'next'): Accessor => ({
|
|
|
408
468
|
get() {
|
|
409
469
|
const state = this[VIEW_STATE];
|
|
410
470
|
const local = this[VIEW_LOCAL];
|
|
411
|
-
const snapshot =
|
|
471
|
+
const snapshot = structureSnapshot(state, local);
|
|
412
472
|
return linkedView(this, snapshot ? snapshot.linkOf(local, linkOffsets[field]) : viewRecord(this)[field]);
|
|
413
473
|
},
|
|
414
474
|
set(value) { setPointer(this, field, value); },
|