use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;
use futures_util::future::{select, Either};
use futures_util::stream::{try_unfold, Stream};
use futures_util::StreamExt;
use serde::{Deserialize, Serialize};
use crate::error::{CdpError, Result};
use crate::listeners::EventStream;
use crate::page::Page;
use chromiumoxide_cdp::cdp::CustomEvent;
pub const DEFAULT_STREAM_EVENT_TIMEOUT_MS: u64 = crate::handler::REQUEST_TIMEOUT;
#[inline]
fn stream_event_timeout() -> Duration {
let ms = std::env::var("CHROMEY_MARKDOWN_STREAM_TIMEOUT_MS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(DEFAULT_STREAM_EVENT_TIMEOUT_MS);
Duration::from_millis(ms)
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct GetMarkdownParams {
pub stream: bool,
}
impl GetMarkdownParams {
pub const IDENTIFIER: &'static str = "Content.getMarkdown";
pub fn new(stream: bool) -> Self {
Self { stream }
}
}
impl chromiumoxide_types::Method for GetMarkdownParams {
fn identifier(&self) -> chromiumoxide_types::MethodId {
Self::IDENTIFIER.into()
}
}
impl chromiumoxide_types::MethodType for GetMarkdownParams {
fn method_id() -> chromiumoxide_types::MethodId
where
Self: Sized,
{
Self::IDENTIFIER.into()
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct GetMarkdownReturns {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub markdown: Option<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
impl GetMarkdownReturns {
pub fn is_empty(&self) -> bool {
self.markdown.is_none() && self.extra.is_empty()
}
}
impl chromiumoxide_types::Command for GetMarkdownParams {
type Response = GetMarkdownReturns;
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct EventMarkdownChunk {
#[serde(default)]
pub chunk: String,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
impl EventMarkdownChunk {
pub const IDENTIFIER: &'static str = "Content.markdownChunk";
}
impl chromiumoxide_types::MethodType for EventMarkdownChunk {
fn method_id() -> chromiumoxide_types::MethodId
where
Self: Sized,
{
Self::IDENTIFIER.into()
}
}
impl CustomEvent for EventMarkdownChunk {}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct EventMarkdownDone {
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
impl EventMarkdownDone {
pub const IDENTIFIER: &'static str = "Content.markdownDone";
}
impl chromiumoxide_types::MethodType for EventMarkdownDone {
fn method_id() -> chromiumoxide_types::MethodId
where
Self: Sized,
{
Self::IDENTIFIER.into()
}
}
impl CustomEvent for EventMarkdownDone {}
pub async fn content_markdown(page: &Page) -> Result<Option<String>> {
Ok(page
.execute(GetMarkdownParams::new(false))
.await?
.result
.markdown)
}
pub async fn content_markdown_streaming(page: &Page) -> Result<Option<String>> {
let mut chunks = page.event_listener::<EventMarkdownChunk>().await?;
let mut done = page.event_listener::<EventMarkdownDone>().await?;
let resp = page.execute(GetMarkdownParams::new(true)).await?.result;
if let Some(markdown) = resp.markdown {
return Ok(Some(markdown));
}
let timeout = stream_event_timeout();
let byte_cap = crate::content_stream::max_accumulated_bytes();
let mut out = String::new();
let mut got_any = false;
let mut rounds: usize = 0;
loop {
if rounds >= crate::content_stream::MAX_CHUNKS {
return Err(CdpError::msg("markdown stream exceeded MAX_CHUNKS"));
}
rounds += 1;
match next_event(&mut chunks, &mut done, timeout).await {
StreamStep::Chunk(chunk) => {
got_any = true;
if out.len().saturating_add(chunk.len()) > byte_cap {
return Err(CdpError::msg(format!(
"markdown stream: accumulated bytes exceeded cap ({} > {})",
out.len().saturating_add(chunk.len()),
byte_cap
)));
}
out.push_str(&chunk);
}
StreamStep::Done => return Ok(Some(out)),
StreamStep::Closed => {
return Err(CdpError::msg(
"markdown stream: event channel closed before Content.markdownDone",
));
}
StreamStep::TimedOut => {
if got_any {
return Err(CdpError::Timeout);
}
return Ok(None);
}
}
}
}
pub fn content_markdown_stream(page: &Page) -> impl Stream<Item = Result<String>> + Send + 'static {
let page = page.clone();
try_unfold(PumpState::Init { page }, |state| async move {
match state {
PumpState::Init { page } => {
let chunks = page.event_listener::<EventMarkdownChunk>().await?;
let done = page.event_listener::<EventMarkdownDone>().await?;
let resp = page.execute(GetMarkdownParams::new(true)).await?.result;
if let Some(markdown) = resp.markdown {
return Ok(Some((markdown, PumpState::Finished)));
}
let timeout = stream_event_timeout();
pump_next(chunks, done, timeout, false, 0).await
}
PumpState::Pumping {
chunks,
done,
timeout,
got_any,
rounds,
} => pump_next(chunks, done, timeout, got_any, rounds).await,
PumpState::Finished => Ok(None),
}
})
}
enum PumpState {
Init { page: Page },
Pumping {
chunks: EventStream<EventMarkdownChunk>,
done: EventStream<EventMarkdownDone>,
timeout: Duration,
got_any: bool,
rounds: usize,
},
Finished,
}
async fn pump_next(
mut chunks: EventStream<EventMarkdownChunk>,
mut done: EventStream<EventMarkdownDone>,
timeout: Duration,
got_any: bool,
rounds: usize,
) -> Result<Option<(String, PumpState)>> {
if rounds >= crate::content_stream::MAX_CHUNKS {
return Ok(None);
}
match next_event(&mut chunks, &mut done, timeout).await {
StreamStep::Chunk(chunk) => Ok(Some((
chunk,
PumpState::Pumping {
chunks,
done,
timeout,
got_any: true,
rounds: rounds + 1,
},
))),
StreamStep::Done => Ok(None),
StreamStep::Closed => Err(CdpError::msg(
"markdown stream: event channel closed before Content.markdownDone",
)),
StreamStep::TimedOut => {
if got_any {
Err(CdpError::Timeout)
} else {
Err(CdpError::msg(
"Content.getMarkdown: no stream events before timeout — the engine \
likely does not support streamed Markdown; fall back to Page::content \
and convert locally",
))
}
}
}
}
enum StreamStep {
Chunk(String),
Done,
Closed,
TimedOut,
}
async fn next_event(
chunks: &mut EventStream<EventMarkdownChunk>,
done: &mut EventStream<EventMarkdownDone>,
timeout: Duration,
) -> StreamStep {
match tokio::time::timeout(timeout, select(chunks.next(), done.next())).await {
Err(_elapsed) => StreamStep::TimedOut,
Ok(Either::Left((Some(ev), _))) => StreamStep::Chunk(
Arc::try_unwrap(ev)
.map(|e| e.chunk)
.unwrap_or_else(|shared| shared.chunk.clone()),
),
Ok(Either::Right((Some(_done_ev), _))) => StreamStep::Done,
Ok(Either::Left((None, _))) | Ok(Either::Right((None, _))) => StreamStep::Closed,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn method_identifiers_match_the_domain() {
use chromiumoxide_types::{Method, MethodType};
assert_eq!(GetMarkdownParams::IDENTIFIER, "Content.getMarkdown");
assert_eq!(EventMarkdownChunk::IDENTIFIER, "Content.markdownChunk");
assert_eq!(EventMarkdownDone::IDENTIFIER, "Content.markdownDone");
assert_eq!(
GetMarkdownParams::default().identifier(),
"Content.getMarkdown"
);
assert_eq!(EventMarkdownChunk::method_id(), "Content.markdownChunk");
assert_eq!(EventMarkdownDone::method_id(), "Content.markdownDone");
assert_eq!(
serde_json::to_value(GetMarkdownParams::new(false)).expect("params must serialize"),
json!({ "stream": false })
);
assert_eq!(
serde_json::to_value(GetMarkdownParams::new(true)).expect("params must serialize"),
json!({ "stream": true })
);
}
#[test]
fn deserializes_single_shot_response() {
let returns: GetMarkdownReturns =
serde_json::from_value(json!({ "markdown": "# Title\n\nBody" }))
.expect("response must deserialize");
assert_eq!(returns.markdown.as_deref(), Some("# Title\n\nBody"));
assert!(returns.extra.is_empty());
assert!(!returns.is_empty());
}
#[test]
fn empty_object_response_means_not_supported() {
let returns: GetMarkdownReturns =
serde_json::from_value(json!({})).expect("empty object must deserialize");
assert_eq!(returns.markdown, None);
assert!(returns.is_empty());
}
#[test]
fn response_extras_are_lossless() {
let wire = json!({ "markdown": "# Hi", "truncated": false, "units": 2 });
let returns: GetMarkdownReturns =
serde_json::from_value(wire.clone()).expect("response must deserialize");
assert_eq!(returns.markdown.as_deref(), Some("# Hi"));
assert_eq!(returns.extra["truncated"], json!(false));
assert_eq!(returns.extra["units"], json!(2));
assert_eq!(
serde_json::to_value(&returns).expect("response must serialize"),
wire
);
}
#[test]
fn chunk_and_done_events_deserialize() {
let chunk: EventMarkdownChunk =
serde_json::from_value(json!({ "chunk": "## Section" })).expect("chunk event");
assert_eq!(chunk.chunk, "## Section");
assert!(chunk.extra.is_empty());
let chunk: EventMarkdownChunk =
serde_json::from_value(json!({ "chunk": "x", "seq": 3 })).expect("chunk event");
assert_eq!(chunk.extra["seq"], json!(3));
let done: EventMarkdownDone = serde_json::from_value(json!({})).expect("done event");
assert!(done.extra.is_empty());
let done: EventMarkdownDone =
serde_json::from_value(json!({ "totalChunks": 7 })).expect("done event");
assert_eq!(done.extra["totalChunks"], json!(7));
}
#[tokio::test]
async fn dispatches_through_custom_event_listeners() {
use crate::listeners::{EventListenerRequest, EventListeners};
let mut listeners = EventListeners::default();
let (chunk_tx, chunk_rx) = tokio::sync::mpsc::unbounded_channel();
let (done_tx, done_rx) = tokio::sync::mpsc::unbounded_channel();
listeners.add_listener(EventListenerRequest::new::<EventMarkdownChunk>(chunk_tx));
listeners.add_listener(EventListenerRequest::new::<EventMarkdownDone>(done_tx));
listeners
.try_send_custom("Content.markdownChunk", json!({ "chunk": "# A" }))
.expect("chunk dispatch");
listeners
.try_send_custom("Content.markdownDone", json!({}))
.expect("done dispatch");
listeners.flush();
let mut chunks = EventStream::<EventMarkdownChunk>::new(chunk_rx);
let mut done = EventStream::<EventMarkdownDone>::new(done_rx);
let ev = chunks.next().await.expect("one chunk event");
assert_eq!(ev.chunk, "# A");
assert!(done.next().await.is_some());
}
#[tokio::test]
async fn next_event_prefers_chunks_then_done() {
let (chunk_tx, chunk_rx) = tokio::sync::mpsc::unbounded_channel();
let (done_tx, done_rx) = tokio::sync::mpsc::unbounded_channel();
let mut chunks = EventStream::<EventMarkdownChunk>::new(chunk_rx);
let mut done = EventStream::<EventMarkdownDone>::new(done_rx);
let chunk_ev: Arc<dyn chromiumoxide_cdp::cdp::Event> = Arc::new(EventMarkdownChunk {
chunk: "part".to_string(),
extra: Default::default(),
});
let done_ev: Arc<dyn chromiumoxide_cdp::cdp::Event> =
Arc::new(EventMarkdownDone::default());
chunk_tx.send(chunk_ev).expect("send chunk");
done_tx.send(done_ev).expect("send done");
let timeout = Duration::from_secs(5);
match next_event(&mut chunks, &mut done, timeout).await {
StreamStep::Chunk(s) => assert_eq!(s, "part"),
_ => panic!("expected the queued chunk first"),
}
match next_event(&mut chunks, &mut done, timeout).await {
StreamStep::Done => {}
_ => panic!("expected the queued done event second"),
}
}
#[tokio::test]
async fn next_event_times_out_when_silent() {
let (_chunk_tx, chunk_rx) = tokio::sync::mpsc::unbounded_channel();
let (_done_tx, done_rx) = tokio::sync::mpsc::unbounded_channel();
let mut chunks = EventStream::<EventMarkdownChunk>::new(chunk_rx);
let mut done = EventStream::<EventMarkdownDone>::new(done_rx);
match next_event(&mut chunks, &mut done, Duration::from_millis(20)).await {
StreamStep::TimedOut => {}
_ => panic!("expected a timeout"),
}
}
}