use chromiumoxide::cdp::js_protocol::runtime::EvaluateParams;
use chromiumoxide::Page;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LiveEvent {
pub at_ms: u64,
pub politeness: String,
pub role: Option<String>,
pub text: String,
}
impl LiveEvent {
pub fn is_announcement(&self) -> bool {
!self.text.trim().is_empty()
}
}
const INSTALL_JS: &str = r#"
(function () {
if (window.__ams_live_log) { return true; }
window.__ams_live_log = [];
var lastText = new WeakMap();
function roleOf(el) {
var r = (el.getAttribute('role') || '').toLowerCase();
return (r === 'alert' || r === 'status' || r === 'log') ? r : null;
}
function isLive(el) {
if (!el || el.nodeType !== 1) return false;
if (el.hasAttribute('aria-live')) return true;
return roleOf(el) !== null;
}
function politenessOf(el) {
var declared = (el.getAttribute('aria-live') || '').toLowerCase();
if (declared) return declared;
var r = roleOf(el);
if (r === 'alert') return 'assertive';
return 'polite';
}
// Aufwärts bis zur nächsten Live-Region, über Shadow-Grenzen hinweg.
function regionFor(node) {
var el = (node && node.nodeType === 1) ? node : (node ? node.parentElement : null);
var guard = 0;
while (el && guard++ < 200) {
if (isLive(el)) return el;
if (el.parentElement) { el = el.parentElement; continue; }
var root = el.getRootNode && el.getRootNode();
el = (root && root.host) ? root.host : null;
}
return null;
}
var observer = new MutationObserver(function (records) {
for (var i = 0; i < records.length; i++) {
var region = regionFor(records[i].target);
if (!region) continue;
var text = (region.textContent || '').trim().slice(0, 300);
if (lastText.get(region) === text) continue;
lastText.set(region, text);
window.__ams_live_log.push({
at: Math.round((window.performance && performance.now()) || 0),
politeness: politenessOf(region),
role: roleOf(region),
text: text
});
if (window.__ams_live_log.length > 200) window.__ams_live_log.shift();
}
});
var opts = { subtree: true, childList: true, characterData: true, attributes: false };
function observeRoot(root) {
try { observer.observe(root, opts); } catch (e) { /* abgelöste Wurzel */ }
}
observeRoot(document.documentElement);
// Vorhandene Shadow Roots: der Observer durchdringt sie nicht von selbst.
(function walk(root, depth) {
if (depth > 12) return;
var all = root.querySelectorAll('*');
for (var i = 0; i < all.length; i++) {
if (all[i].shadowRoot) { observeRoot(all[i].shadowRoot); walk(all[i].shadowRoot, depth + 1); }
}
})(document, 0);
// Später erzeugte Shadow Roots.
var origAttach = Element.prototype.attachShadow;
Element.prototype.attachShadow = function () {
var root = origAttach.apply(this, arguments);
observeRoot(root);
return root;
};
return true;
})()
"#;
const DRAIN_JS: &str = r#"
(function () {
var log = window.__ams_live_log || [];
window.__ams_live_log = [];
return log;
})()
"#;
async fn eval_value(page: &Page, js: &str) -> Option<serde_json::Value> {
let params = EvaluateParams::builder()
.expression(js.to_string())
.return_by_value(true)
.build()
.ok()?;
page.execute(params).await.ok()?.result.result.value.clone()
}
pub async fn install(page: &Page) -> bool {
eval_value(page, INSTALL_JS)
.await
.and_then(|v| v.as_bool())
.unwrap_or(false)
}
pub async fn drain(page: &Page) -> Vec<LiveEvent> {
let Some(value) = eval_value(page, DRAIN_JS).await else {
return Vec::new();
};
parse_events(&value)
}
fn parse_events(value: &serde_json::Value) -> Vec<LiveEvent> {
let Some(items) = value.as_array() else {
return Vec::new();
};
items
.iter()
.filter_map(|item| {
Some(LiveEvent {
at_ms: item.get("at").and_then(|v| v.as_u64()).unwrap_or(0),
politeness: item
.get("politeness")
.and_then(|v| v.as_str())
.unwrap_or("polite")
.to_string(),
role: item
.get("role")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
text: item.get("text").and_then(|v| v.as_str())?.to_string(),
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn ereignisse_werden_gelesen() {
let events = parse_events(&json!([
{"at": 1200, "politeness": "assertive", "role": "alert", "text": "Bitte Namen angeben"},
{"at": 1400, "politeness": "polite", "role": null, "text": ""}
]));
assert_eq!(events.len(), 2);
assert_eq!(events[0].at_ms, 1200);
assert_eq!(events[0].politeness, "assertive");
assert_eq!(events[0].role.as_deref(), Some("alert"));
assert!(events[0].is_announcement());
}
#[test]
fn leere_region_ist_keine_ankuendigung() {
let events = parse_events(&json!([{"at": 1, "politeness": "polite", "text": " "}]));
assert_eq!(events.len(), 1);
assert!(!events[0].is_announcement());
}
#[test]
fn unbrauchbare_aufzeichnung_ergibt_nichts() {
assert!(parse_events(&json!(null)).is_empty());
assert!(parse_events(&json!({"at": 1})).is_empty());
let events = parse_events(&json!([{"at": 1}, {"at": 2, "text": "da"}]));
assert_eq!(events.len(), 1);
assert_eq!(events[0].text, "da");
}
}