mirror of
https://github.com/0xMassi/webclaw.git
synced 2026-07-23 07:21:02 +02:00
feat: add support for SvelteKit and JS object literal extraction in structured data
This commit is contained in:
parent
0d0da265ab
commit
cb4a7f3f06
2 changed files with 112 additions and 2 deletions
|
|
@ -179,8 +179,9 @@ pub fn extract_with_options(
|
||||||
let domain_type = domain::detect(url, html);
|
let domain_type = domain::detect(url, html);
|
||||||
let domain_data = Some(DomainData { domain_type });
|
let domain_data = Some(DomainData { domain_type });
|
||||||
|
|
||||||
// JSON-LD structured data (Schema.org Product, Article, etc.)
|
// Structured data (JSON-LD + JS data islands)
|
||||||
let structured_data = structured_data::extract_json_ld(html);
|
let mut structured_data = structured_data::extract_json_ld(html);
|
||||||
|
structured_data.extend(structured_data::extract_js_objects(html));
|
||||||
|
|
||||||
Ok(ExtractionResult {
|
Ok(ExtractionResult {
|
||||||
metadata: meta,
|
metadata: meta,
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,115 @@ pub fn extract_json_ld(html: &str) -> Vec<Value> {
|
||||||
results
|
results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract JSON-like objects from regular <script> tags.
|
||||||
|
///
|
||||||
|
/// This handles modern frameworks like SvelteKit that embed data as JS object
|
||||||
|
/// literals (where keys aren't quoted) rather than pure JSON.
|
||||||
|
pub fn extract_js_objects(html: &str) -> Vec<Value> {
|
||||||
|
let mut results = Vec::new();
|
||||||
|
|
||||||
|
// Look for SvelteKit's kit.start({ ... data: [...] }) pattern
|
||||||
|
// or general large JS object assignments.
|
||||||
|
let mut search_from = 0;
|
||||||
|
while let Some(tag_start) = html[search_from..].find("<script") {
|
||||||
|
let abs_start = search_from + tag_start;
|
||||||
|
let tag_region = &html[abs_start..];
|
||||||
|
|
||||||
|
let Some(tag_end_offset) = tag_region.find('>') else {
|
||||||
|
search_from = abs_start + 7;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let opening_tag = &tag_region[..tag_end_offset].to_lowercase();
|
||||||
|
// Skip JSON-LD (already handled) or external scripts
|
||||||
|
if opening_tag.contains("application/ld+json") || opening_tag.contains("src=") {
|
||||||
|
search_from = abs_start + tag_end_offset + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content_start = abs_start + tag_end_offset + 1;
|
||||||
|
let remaining = &html[content_start..];
|
||||||
|
let Some(close_offset) = remaining.to_lowercase().find("</script>") else {
|
||||||
|
search_from = content_start;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let js_code = &remaining[..close_offset];
|
||||||
|
search_from = content_start + close_offset + 9;
|
||||||
|
|
||||||
|
// Try to find the "data" array in SvelteKit initialization
|
||||||
|
// Uses a regex to handle data: [ or data:[ or data: [
|
||||||
|
let data_re = regex::Regex::new(r"data\s*:\s*\[").unwrap();
|
||||||
|
if let Some(mat) = data_re.find(js_code) {
|
||||||
|
let start_idx = mat.start() + (mat.as_str().find('[').unwrap());
|
||||||
|
if let Some(data_array) = extract_balanced_bracket(&js_code[start_idx..], '[', ']') {
|
||||||
|
if let Ok(val) = parse_js_literal(&data_array) {
|
||||||
|
if let Value::Array(arr) = val {
|
||||||
|
for item in arr {
|
||||||
|
if !item.is_null() {
|
||||||
|
results.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
results.push(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract content between balanced brackets.
|
||||||
|
fn extract_balanced_bracket(text: &str, open: char, close: char) -> Option<String> {
|
||||||
|
let mut depth = 0;
|
||||||
|
let mut start = None;
|
||||||
|
|
||||||
|
for (i, c) in text.char_indices() {
|
||||||
|
if c == open {
|
||||||
|
if depth == 0 {
|
||||||
|
start = Some(i);
|
||||||
|
}
|
||||||
|
depth += 1;
|
||||||
|
} else if c == close {
|
||||||
|
depth -= 1;
|
||||||
|
if depth == 0 {
|
||||||
|
if let Some(s) = start {
|
||||||
|
return Some(text[s..=i].to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempt to parse a JavaScript object literal as JSON by quoting keys.
|
||||||
|
/// This is a heuristic parser for data islands.
|
||||||
|
fn parse_js_literal(js: &str) -> Result<Value, serde_json::Error> {
|
||||||
|
// 1. Try direct parse (sometimes it IS valid JSON)
|
||||||
|
if let Ok(v) = serde_json::from_str(js) {
|
||||||
|
return Ok(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. heuristic: wrap keys in quotes and remove JS specific values
|
||||||
|
let mut cleaned = js.to_string();
|
||||||
|
|
||||||
|
// Replace unquoted keys: {key: or ,key: -> {"key": or ,"key":
|
||||||
|
// Handles whitespace around the key and colon
|
||||||
|
let key_regex = regex::Regex::new(r#"([{,])\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:"#).unwrap();
|
||||||
|
cleaned = key_regex.replace_all(&cleaned, r#"$1"$2":"#).to_string();
|
||||||
|
|
||||||
|
// Remove "new URL(...)" wrappers - greedy matching for the constructor
|
||||||
|
let url_regex = regex::Regex::new(r"new\s+URL\s*\([^)]+\)").unwrap();
|
||||||
|
cleaned = url_regex.replace_all(&cleaned, "\"redacted_url\"").to_string();
|
||||||
|
|
||||||
|
// Remove "undefined"
|
||||||
|
cleaned = cleaned.replace("undefined", "null");
|
||||||
|
|
||||||
|
serde_json::from_str(&cleaned)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue