1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize, Default)]
5pub struct StructuredData {
6 #[serde(skip_serializing_if = "Vec::is_empty")]
8 pub reservations: Vec<Reservation>,
9 #[serde(skip_serializing_if = "Vec::is_empty")]
11 pub orders: Vec<Order>,
12 #[serde(skip_serializing_if = "Vec::is_empty")]
14 pub events: Vec<EventInfo>,
15 #[serde(skip_serializing_if = "Vec::is_empty")]
17 pub actions: Vec<ActionInfo>,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct Reservation {
23 #[serde(rename = "type")]
25 pub kind: String,
26 pub name: Option<String>,
28 pub reservation_id: Option<String>,
30 pub status: Option<String>,
32 pub start_date: Option<String>,
34 pub end_date: Option<String>,
36 #[serde(skip_serializing_if = "Option::is_none")]
38 pub location: Option<String>,
39 #[serde(skip_serializing_if = "Option::is_none")]
41 pub provider: Option<String>,
42 #[serde(skip_serializing_if = "Option::is_none")]
44 pub departure_airport: Option<String>,
45 #[serde(skip_serializing_if = "Option::is_none")]
47 pub arrival_airport: Option<String>,
48 #[serde(skip_serializing_if = "Option::is_none")]
50 pub flight_number: Option<String>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct Order {
56 pub order_number: Option<String>,
58 pub merchant: Option<String>,
60 pub order_date: Option<String>,
62 pub status: Option<String>,
64 #[serde(skip_serializing_if = "Vec::is_empty")]
66 pub items: Vec<OrderItem>,
67 #[serde(skip_serializing_if = "Option::is_none")]
69 pub total: Option<String>,
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub currency: Option<String>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct OrderItem {
78 pub name: String,
80 #[serde(skip_serializing_if = "Option::is_none")]
82 pub quantity: Option<u32>,
83 #[serde(skip_serializing_if = "Option::is_none")]
85 pub price: Option<String>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct EventInfo {
91 pub name: String,
93 pub start_date: Option<String>,
95 pub end_date: Option<String>,
97 pub location: Option<String>,
99 #[serde(skip_serializing_if = "Option::is_none")]
101 pub url: Option<String>,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct ActionInfo {
107 #[serde(rename = "type")]
109 pub kind: String,
110 pub name: String,
112 pub url: Option<String>,
114}
115
116impl StructuredData {
117 pub fn is_empty(&self) -> bool {
119 self.reservations.is_empty()
120 && self.orders.is_empty()
121 && self.events.is_empty()
122 && self.actions.is_empty()
123 }
124}
125
126pub fn extract_structured_data(html: &str) -> StructuredData {
128 let mut data = StructuredData::default();
129
130 for block in extract_jsonld_blocks(html) {
132 let Ok(value) = serde_json::from_str::<serde_json::Value>(&block) else {
133 continue;
134 };
135
136 let items = if let Some(graph) = value.get("@graph").and_then(|g| g.as_array()) {
138 graph.clone()
139 } else if value.is_array() {
140 value.as_array().cloned().unwrap_or_default()
141 } else {
142 vec![value]
143 };
144
145 for item in items {
146 process_jsonld_item(&item, &mut data);
147 }
148 }
149
150 data
151}
152
153fn extract_jsonld_blocks(html: &str) -> Vec<String> {
154 let mut blocks = Vec::new();
155 let lower = html.to_lowercase();
156 let mut search_from = 0;
157
158 while let Some(tag_start) = lower[search_from..].find("application/ld+json") {
159 let abs_tag = search_from + tag_start;
160
161 let Some(open_end) = lower[abs_tag..].find('>') else {
163 break;
164 };
165 let content_start = abs_tag + open_end + 1;
166
167 let Some(close_pos) = lower[content_start..].find("</script>") else {
169 break;
170 };
171 let content_end = content_start + close_pos;
172
173 let content = html[content_start..content_end].trim();
174 if !content.is_empty() {
175 blocks.push(content.to_string());
176 }
177
178 search_from = content_end + 9;
179 }
180
181 blocks
182}
183
184fn process_jsonld_item(item: &serde_json::Value, data: &mut StructuredData) {
185 let schema_type = item.get("@type").and_then(|t| t.as_str()).unwrap_or("");
186
187 match schema_type {
188 "FlightReservation" => {
189 let flight = item.get("reservationFor");
190 data.reservations.push(Reservation {
191 kind: "flight".into(),
192 name: get_str(flight, "name"),
193 reservation_id: get_str(Some(item), "reservationId"),
194 status: get_str(Some(item), "reservationStatus")
195 .map(|s| s.replace("http://schema.org/Reservation", "")),
196 start_date: get_str(flight, "departureTime"),
197 end_date: get_str(flight, "arrivalTime"),
198 location: None,
199 provider: get_nested_str(item, &["provider", "name"])
200 .or_else(|| get_nested_str(item, &["reservationFor", "airline", "name"])),
201 departure_airport: get_nested_str(
202 item,
203 &["reservationFor", "departureAirport", "iataCode"],
204 )
205 .or_else(|| get_nested_str(item, &["reservationFor", "departureAirport", "name"])),
206 arrival_airport: get_nested_str(
207 item,
208 &["reservationFor", "arrivalAirport", "iataCode"],
209 )
210 .or_else(|| get_nested_str(item, &["reservationFor", "arrivalAirport", "name"])),
211 flight_number: get_str(flight, "flightNumber"),
212 });
213 }
214 "LodgingReservation" => {
215 data.reservations.push(Reservation {
216 kind: "hotel".into(),
217 name: get_nested_str(item, &["reservationFor", "name"]),
218 reservation_id: get_str(Some(item), "reservationId"),
219 status: get_str(Some(item), "reservationStatus"),
220 start_date: get_str(Some(item), "checkinTime"),
221 end_date: get_str(Some(item), "checkoutTime"),
222 location: get_nested_str(item, &["reservationFor", "address", "streetAddress"]),
223 provider: get_nested_str(item, &["reservationFor", "name"]),
224 departure_airport: None,
225 arrival_airport: None,
226 flight_number: None,
227 });
228 }
229 "FoodEstablishmentReservation" => {
230 data.reservations.push(Reservation {
231 kind: "restaurant".into(),
232 name: get_nested_str(item, &["reservationFor", "name"]),
233 reservation_id: get_str(Some(item), "reservationId"),
234 status: get_str(Some(item), "reservationStatus"),
235 start_date: get_str(Some(item), "startTime"),
236 end_date: get_str(Some(item), "endTime"),
237 location: get_nested_str(item, &["reservationFor", "address", "streetAddress"]),
238 provider: None,
239 departure_airport: None,
240 arrival_airport: None,
241 flight_number: None,
242 });
243 }
244 "RentalCarReservation" => {
245 data.reservations.push(Reservation {
246 kind: "rental_car".into(),
247 name: get_nested_str(item, &["reservationFor", "name"]),
248 reservation_id: get_str(Some(item), "reservationId"),
249 status: get_str(Some(item), "reservationStatus"),
250 start_date: get_str(Some(item), "pickupTime"),
251 end_date: get_str(Some(item), "dropoffTime"),
252 location: get_nested_str(item, &["pickupLocation", "name"]),
253 provider: get_nested_str(item, &["provider", "name"]),
254 departure_airport: None,
255 arrival_airport: None,
256 flight_number: None,
257 });
258 }
259 "Order" => {
260 let items = item
261 .get("orderedItem")
262 .or_else(|| item.get("acceptedOffer"))
263 .and_then(|v| {
264 if v.is_array() {
265 v.as_array().cloned()
266 } else {
267 Some(vec![v.clone()])
268 }
269 })
270 .unwrap_or_default()
271 .iter()
272 .filter_map(|oi| {
273 let name = get_str(Some(oi), "name")
274 .or_else(|| get_nested_str(oi, &["orderedItem", "name"]))
275 .unwrap_or_default();
276 if name.is_empty() {
277 return None;
278 }
279 Some(OrderItem {
280 name,
281 quantity: oi
282 .get("orderQuantity")
283 .and_then(|q| q.as_u64())
284 .map(|q| q as u32),
285 price: get_str(Some(oi), "price"),
286 })
287 })
288 .collect();
289
290 data.orders.push(Order {
291 order_number: get_str(Some(item), "orderNumber"),
292 merchant: get_nested_str(item, &["seller", "name"])
293 .or_else(|| get_nested_str(item, &["merchant", "name"])),
294 order_date: get_str(Some(item), "orderDate"),
295 status: get_str(Some(item), "orderStatus")
296 .map(|s| s.replace("http://schema.org/Order", "")),
297 items,
298 total: get_nested_str(item, &["totalPrice", "value"]).or_else(|| {
299 get_str(Some(item), "totalPrice").and_then(|v| {
300 if v.parse::<f64>().is_ok() {
301 Some(v)
302 } else {
303 None
304 }
305 })
306 }),
307 currency: get_nested_str(item, &["totalPrice", "currency"])
308 .or_else(|| get_str(Some(item), "priceCurrency")),
309 });
310 }
311 "Event" | "MusicEvent" | "SportsEvent" | "BusinessEvent" | "SocialEvent" => {
312 data.events.push(EventInfo {
313 name: get_str(Some(item), "name").unwrap_or_default(),
314 start_date: get_str(Some(item), "startDate"),
315 end_date: get_str(Some(item), "endDate"),
316 location: get_nested_str(item, &["location", "name"])
317 .or_else(|| get_str(Some(item), "location").filter(|s| !s.starts_with('{'))),
318 url: sanitize_url(get_str(Some(item), "url")),
319 });
320 }
321 "ConfirmAction" | "ViewAction" | "TrackAction" | "RsvpAction" | "SaveAction" => {
322 let kind = schema_type.replace("Action", "").to_lowercase();
323 data.actions.push(ActionInfo {
324 kind,
325 name: get_str(Some(item), "name").unwrap_or_else(|| schema_type.into()),
326 url: sanitize_url(
327 get_nested_str(item, &["target", "urlTemplate"])
328 .or_else(|| get_str(Some(item), "url")),
329 ),
330 });
331 }
332 _ => {}
334 }
335
336 if let Some(actions) = item.get("potentialAction") {
338 let action_list = if actions.is_array() {
339 actions.as_array().cloned().unwrap_or_default()
340 } else {
341 vec![actions.clone()]
342 };
343 for action in action_list {
344 let action_type = action.get("@type").and_then(|t| t.as_str()).unwrap_or("");
345 if matches!(
346 action_type,
347 "ConfirmAction" | "ViewAction" | "TrackAction" | "RsvpAction" | "SaveAction"
348 ) {
349 process_jsonld_item(&action, data);
350 }
351 }
352 }
353}
354
355fn get_str(obj: Option<&serde_json::Value>, key: &str) -> Option<String> {
356 obj?.get(key)?.as_str().map(|s| s.to_string())
357}
358
359fn get_nested_str(obj: &serde_json::Value, path: &[&str]) -> Option<String> {
360 let mut current = obj;
361 for &key in path {
362 current = current.get(key)?;
363 }
364 current.as_str().map(|s| s.to_string())
365}
366
367fn sanitize_url(url: Option<String>) -> Option<String> {
369 url.filter(|u| u.starts_with("http://") || u.starts_with("https://"))
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 #[test]
377 fn extract_flight_reservation() {
378 let html = r#"<html><head>
379 <script type="application/ld+json">
380 {
381 "@context": "http://schema.org",
382 "@type": "FlightReservation",
383 "reservationId": "RXJ34P",
384 "reservationStatus": "http://schema.org/ReservationConfirmed",
385 "reservationFor": {
386 "@type": "Flight",
387 "flightNumber": "110",
388 "airline": {"@type": "Airline", "name": "United Airlines", "iataCode": "UA"},
389 "departureAirport": {"@type": "Airport", "name": "San Francisco", "iataCode": "SFO"},
390 "arrivalAirport": {"@type": "Airport", "name": "Los Angeles", "iataCode": "LAX"},
391 "departureTime": "2026-04-01T08:00:00-07:00",
392 "arrivalTime": "2026-04-01T09:30:00-07:00"
393 }
394 }
395 </script></head><body>Your flight is confirmed.</body></html>"#;
396
397 let data = extract_structured_data(html);
398 assert_eq!(data.reservations.len(), 1);
399 let r = &data.reservations[0];
400 assert_eq!(r.kind, "flight");
401 assert_eq!(r.reservation_id.as_deref(), Some("RXJ34P"));
402 assert_eq!(r.departure_airport.as_deref(), Some("SFO"));
403 assert_eq!(r.arrival_airport.as_deref(), Some("LAX"));
404 assert_eq!(r.flight_number.as_deref(), Some("110"));
405 }
406
407 #[test]
408 fn extract_hotel_reservation() {
409 let html = r#"<html><body>
410 <script type="application/ld+json">
411 {
412 "@type": "LodgingReservation",
413 "reservationId": "HTL-789",
414 "checkinTime": "2026-05-01T15:00:00",
415 "checkoutTime": "2026-05-03T11:00:00",
416 "reservationFor": {
417 "@type": "Hotel",
418 "name": "Grand Hyatt Tokyo",
419 "address": {"streetAddress": "6-10-3 Roppongi"}
420 }
421 }
422 </script></body></html>"#;
423
424 let data = extract_structured_data(html);
425 assert_eq!(data.reservations.len(), 1);
426 let r = &data.reservations[0];
427 assert_eq!(r.kind, "hotel");
428 assert_eq!(r.name.as_deref(), Some("Grand Hyatt Tokyo"));
429 assert_eq!(r.start_date.as_deref(), Some("2026-05-01T15:00:00"));
430 }
431
432 #[test]
433 fn extract_order() {
434 let html = r#"<script type="application/ld+json">
435 {
436 "@type": "Order",
437 "orderNumber": "W001234",
438 "orderDate": "2026-03-01",
439 "seller": {"@type": "Organization", "name": "Amazon"},
440 "orderedItem": [
441 {"@type": "Product", "name": "Rust Programming Book", "orderQuantity": 1, "price": "39.99"},
442 {"@type": "Product", "name": "USB-C Cable", "orderQuantity": 2, "price": "9.99"}
443 ],
444 "totalPrice": "59.97",
445 "priceCurrency": "USD"
446 }
447 </script>"#;
448
449 let data = extract_structured_data(html);
450 assert_eq!(data.orders.len(), 1);
451 let o = &data.orders[0];
452 assert_eq!(o.order_number.as_deref(), Some("W001234"));
453 assert_eq!(o.merchant.as_deref(), Some("Amazon"));
454 assert_eq!(o.items.len(), 2);
455 assert_eq!(o.items[0].name, "Rust Programming Book");
456 assert_eq!(o.items[1].quantity, Some(2));
457 assert_eq!(o.total.as_deref(), Some("59.97"));
458 assert_eq!(o.currency.as_deref(), Some("USD"));
459 }
460
461 #[test]
462 fn extract_event() {
463 let html = r#"<script type="application/ld+json">
464 {
465 "@type": "Event",
466 "name": "RustConf 2026",
467 "startDate": "2026-09-10",
468 "endDate": "2026-09-12",
469 "location": {"@type": "Place", "name": "Portland Convention Center"},
470 "url": "https://rustconf.com"
471 }
472 </script>"#;
473
474 let data = extract_structured_data(html);
475 assert_eq!(data.events.len(), 1);
476 let e = &data.events[0];
477 assert_eq!(e.name, "RustConf 2026");
478 assert_eq!(e.location.as_deref(), Some("Portland Convention Center"));
479 }
480
481 #[test]
482 fn extract_potential_action() {
483 let html = r#"<script type="application/ld+json">
484 {
485 "@type": "Order",
486 "orderNumber": "123",
487 "potentialAction": {
488 "@type": "TrackAction",
489 "name": "Track Package",
490 "target": {"urlTemplate": "https://track.example.com/123"}
491 }
492 }
493 </script>"#;
494
495 let data = extract_structured_data(html);
496 assert_eq!(data.orders.len(), 1);
497 assert_eq!(data.actions.len(), 1);
498 assert_eq!(data.actions[0].kind, "track");
499 assert_eq!(
500 data.actions[0].url.as_deref(),
501 Some("https://track.example.com/123")
502 );
503 }
504
505 #[test]
506 fn multiple_jsonld_blocks() {
507 let html = r#"
508 <script type="application/ld+json">{"@type":"Event","name":"Event A","startDate":"2026-01-01"}</script>
509 <p>some text</p>
510 <script type="application/ld+json">{"@type":"Event","name":"Event B","startDate":"2026-02-01"}</script>
511 "#;
512
513 let data = extract_structured_data(html);
514 assert_eq!(data.events.len(), 2);
515 assert_eq!(data.events[0].name, "Event A");
516 assert_eq!(data.events[1].name, "Event B");
517 }
518
519 #[test]
520 fn graph_array() {
521 let html = r#"<script type="application/ld+json">
522 {
523 "@context": "http://schema.org",
524 "@graph": [
525 {"@type": "Event", "name": "Talk 1", "startDate": "2026-01-01"},
526 {"@type": "Event", "name": "Talk 2", "startDate": "2026-01-02"}
527 ]
528 }
529 </script>"#;
530
531 let data = extract_structured_data(html);
532 assert_eq!(data.events.len(), 2);
533 }
534
535 #[test]
536 fn no_jsonld() {
537 let html = "<html><body><p>Just plain HTML</p></body></html>";
538 let data = extract_structured_data(html);
539 assert!(data.is_empty());
540 }
541
542 #[test]
543 fn invalid_json() {
544 let html = r#"<script type="application/ld+json">{not valid json}</script>"#;
545 let data = extract_structured_data(html);
546 assert!(data.is_empty());
547 }
548
549 #[test]
550 fn restaurant_reservation() {
551 let html = r#"<script type="application/ld+json">
552 {
553 "@type": "FoodEstablishmentReservation",
554 "reservationId": "RES-456",
555 "startTime": "2026-04-15T19:00:00",
556 "reservationFor": {
557 "@type": "FoodEstablishment",
558 "name": "Sushi Dai",
559 "address": {"streetAddress": "Tsukiji, Tokyo"}
560 }
561 }
562 </script>"#;
563
564 let data = extract_structured_data(html);
565 assert_eq!(data.reservations.len(), 1);
566 assert_eq!(data.reservations[0].kind, "restaurant");
567 assert_eq!(data.reservations[0].name.as_deref(), Some("Sushi Dai"));
568 }
569
570 #[test]
571 fn rental_car_reservation() {
572 let html = r#"<script type="application/ld+json">
573 {
574 "@type": "RentalCarReservation",
575 "reservationId": "CAR-789",
576 "pickupTime": "2026-06-01T10:00:00",
577 "dropoffTime": "2026-06-05T10:00:00",
578 "pickupLocation": {"@type": "Place", "name": "Narita Airport"},
579 "provider": {"@type": "Organization", "name": "Toyota Rent a Car"},
580 "reservationFor": {"@type": "RentalCar", "name": "Toyota Corolla"}
581 }
582 </script>"#;
583
584 let data = extract_structured_data(html);
585 assert_eq!(data.reservations.len(), 1);
586 let r = &data.reservations[0];
587 assert_eq!(r.kind, "rental_car");
588 assert_eq!(r.location.as_deref(), Some("Narita Airport"));
589 assert_eq!(r.provider.as_deref(), Some("Toyota Rent a Car"));
590 }
591}
592
593#[cfg(test)]
594mod boundary_tests {
595 use super::*;
596
597 #[test]
598 fn empty_html_returns_empty() {
599 assert!(extract_structured_data("").is_empty());
600 }
601
602 #[test]
603 fn jsonld_with_only_whitespace_inside_ignored() {
604 let html = r#"<script type="application/ld+json"> </script>"#;
605 assert!(extract_structured_data(html).is_empty());
606 }
607
608 #[test]
609 fn ignores_non_jsonld_script_tags() {
610 let html = r#"<script type="text/javascript">var x = 1;</script>"#;
611 assert!(extract_structured_data(html).is_empty());
612 }
613
614 #[test]
615 fn mixed_jsonld_and_html_content() {
616 let html = r#"<html><body><p>Hi</p>
617 <script type="application/ld+json">{"@type":"Order","orderNumber":"X","seller":{"name":"S"}}</script>
618 <p>Bye</p></body></html>"#;
619 let data = extract_structured_data(html);
620 assert_eq!(data.orders.len(), 1);
621 }
622
623 #[test]
624 fn jsonld_uppercase_type_attribute() {
625 let html = r#"<script TYPE="APPLICATION/LD+JSON">{"@type":"Event","name":"E","startDate":"2026-01-01"}</script>"#;
628 let data = extract_structured_data(html);
629 assert_eq!(data.events.len(), 1);
630 }
631
632 #[test]
633 fn rejects_malformed_jsonld() {
634 let html = r#"<script type="application/ld+json">{not "valid": json,,,}</script>"#;
635 assert!(extract_structured_data(html).is_empty());
636 }
637
638 #[test]
639 fn event_without_name_still_extracted_with_empty_name() {
640 let html = r#"<script type="application/ld+json">{"@type":"Event","startDate":"2026-09-10"}</script>"#;
641 let data = extract_structured_data(html);
642 assert_eq!(data.events.len(), 1);
643 assert_eq!(data.events[0].name, "");
644 }
645
646 #[test]
647 fn order_with_empty_items_list() {
648 let html = r#"<script type="application/ld+json">{"@type":"Order","orderNumber":"X","orderedItem":[]}</script>"#;
649 let data = extract_structured_data(html);
650 assert_eq!(data.orders.len(), 1);
651 assert!(data.orders[0].items.is_empty());
652 }
653
654 #[test]
655 fn potential_action_unknown_type_skipped() {
656 let html = r#"<script type="application/ld+json">{"@type":"Order","orderNumber":"X","potentialAction":{"@type":"WhatIsThisAction","name":"???"}}</script>"#;
657 let data = extract_structured_data(html);
658 assert_eq!(data.actions.len(), 0);
659 }
660
661 #[test]
662 fn url_with_javascript_scheme_filtered_out() {
663 let html = r#"<script type="application/ld+json">{"@type":"Event","name":"E","startDate":"2026-01-01","url":"javascript:alert(1)"}</script>"#;
664 let data = extract_structured_data(html);
665 assert!(data.events[0].url.is_none());
666 }
667
668 #[test]
669 fn url_data_scheme_filtered_out() {
670 let html = r#"<script type="application/ld+json">{"@type":"Event","name":"E","startDate":"2026-01-01","url":"data:text/html,evil"}</script>"#;
671 let data = extract_structured_data(html);
672 assert!(data.events[0].url.is_none());
673 }
674
675 #[test]
676 fn url_http_preserved() {
677 let html = r#"<script type="application/ld+json">{"@type":"Event","name":"E","startDate":"2026-01-01","url":"http://insecure.example.com"}</script>"#;
678 let data = extract_structured_data(html);
679 assert_eq!(
680 data.events[0].url.as_deref(),
681 Some("http://insecure.example.com")
682 );
683 }
684
685 #[test]
686 fn missing_optional_fields_are_none() {
687 let html = r#"<script type="application/ld+json">{"@type":"FlightReservation"}</script>"#;
688 let data = extract_structured_data(html);
689 assert_eq!(data.reservations.len(), 1);
690 let r = &data.reservations[0];
691 assert!(r.reservation_id.is_none());
692 assert!(r.flight_number.is_none());
693 assert!(r.departure_airport.is_none());
694 }
695
696 #[test]
697 fn order_status_strips_schema_url() {
698 let html = r#"<script type="application/ld+json">{"@type":"Order","orderNumber":"X","orderStatus":"http://schema.org/OrderProcessing"}</script>"#;
699 let data = extract_structured_data(html);
700 assert_eq!(data.orders[0].status.as_deref(), Some("Processing"));
701 }
702
703 #[test]
704 fn is_empty_returns_true_for_default() {
705 let data = StructuredData::default();
706 assert!(data.is_empty());
707 }
708
709 #[test]
710 fn is_empty_false_when_anything_present() {
711 let mut data = StructuredData::default();
712 data.events.push(EventInfo {
713 name: "x".into(),
714 start_date: None,
715 end_date: None,
716 location: None,
717 url: None,
718 });
719 assert!(!data.is_empty());
720 }
721}