Fixes #117. Adds an `Original` variant to `--dump` that streams the raw HTTP response body verbatim to stdout (or `--output`), bypassing the browser/JS/DOM stack entirely. Useful for binary payloads (jpg, png, fonts) and any non-HTML resource where parsing through the DOM layer would corrupt or discard data. The new path uses ObscuraHttpClient directly, honours --proxy and --user-agent, and writes bytes through tokio::io::stdout so binary payloads (including embedded NUL bytes) round-trip cleanly.
This commit is contained in:
1 parent
2ff0f8fe1d
commit
09df3050f7
2 files changed
+163
-4
No files matched your search
@@ -107,6 +107,10 @@ obscura fetch https://news.ycombinator.com --dump html
|
|||||||
# Write dump or eval output to a file
|
# Write dump or eval output to a file
|
||||||
obscura fetch https://example.com --dump text --output page.txt
|
obscura fetch https://example.com --dump text --output page.txt
|
||||||
|
|
||||||
|
# Stream the raw response body verbatim (binary-safe; bypasses the JS/DOM layer).
|
||||||
|
# Use this for images, JSON, JS, CSS, or any non-HTML resource.
|
||||||
|
obscura fetch https://picsum.photos/200/300 --dump original > photo.jpg
|
||||||
|
|
||||||
# Fetch through an HTTP or SOCKS proxy
|
# Fetch through an HTTP or SOCKS proxy
|
||||||
obscura --proxy socks5://127.0.0.1:1080 fetch https://example.com --dump text
|
obscura --proxy socks5://127.0.0.1:1080 fetch https://example.com --dump text
|
||||||
|
|
||||||
@@ -263,7 +267,7 @@ Fetch and render a single page.
|
|||||||
|
|
||||||
| Flag | Default | Description |
|
| Flag | Default | Description |
|
||||||
|------|---------|-------------|
|
|------|---------|-------------|
|
||||||
| `--dump` | `html` | Output: `html`, `text`, or `links` |
|
| `--dump` | `html` | Output: `html`, `text`, `links`, `markdown`, or `original` (raw response body) |
|
||||||
| `--eval` | — | JavaScript expression to evaluate |
|
| `--eval` | — | JavaScript expression to evaluate |
|
||||||
| `--wait-until` | `load` | Wait: `load`, `domcontentloaded`, `networkidle0` |
|
| `--wait-until` | `load` | Wait: `load`, `domcontentloaded`, `networkidle0` |
|
||||||
| `--timeout` | `30` | Maximum navigation time in seconds |
|
| `--timeout` | `30` | Maximum navigation time in seconds |
|
||||||
|
|||||||
@@ -128,12 +128,16 @@ enum Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, clap::ValueEnum)]
|
#[derive(Clone, Debug, clap::ValueEnum, PartialEq, Eq)]
|
||||||
enum DumpFormat {
|
enum DumpFormat {
|
||||||
Html,
|
Html,
|
||||||
Text,
|
Text,
|
||||||
Links,
|
Links,
|
||||||
Markdown,
|
Markdown,
|
||||||
|
/// Stream the raw HTTP response body verbatim (binary-safe).
|
||||||
|
/// Bypasses the browser/JS layer — useful for fetching images,
|
||||||
|
/// JSON, JS, CSS, or any non-HTML resource (cf. issue #117).
|
||||||
|
Original,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_banner(port: u16) {
|
fn print_banner(port: u16) {
|
||||||
@@ -379,6 +383,22 @@ async fn run_fetch(
|
|||||||
quiet: bool,
|
quiet: bool,
|
||||||
proxy: Option<String>,
|
proxy: Option<String>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
|
// --dump original short-circuits the browser stack entirely: fetch the raw
|
||||||
|
// response body via HTTP and stream the bytes verbatim. Useful for binary
|
||||||
|
// payloads (images, fonts, …) and any non-HTML resource where parsing the
|
||||||
|
// body through the DOM/JS layer would corrupt or discard data.
|
||||||
|
if dump == DumpFormat::Original {
|
||||||
|
let bytes = fetch_original_bytes(
|
||||||
|
url_str,
|
||||||
|
proxy,
|
||||||
|
user_agent.clone(),
|
||||||
|
timeout_secs,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
write_or_print_bytes(&bytes, output.as_ref()).await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
let context = Arc::new(BrowserContext::with_options("fetch".to_string(), proxy, stealth));
|
let context = Arc::new(BrowserContext::with_options("fetch".to_string(), proxy, stealth));
|
||||||
let mut page = Page::new("fetch-page".to_string(), context);
|
let mut page = Page::new("fetch-page".to_string(), context);
|
||||||
|
|
||||||
@@ -428,12 +448,40 @@ async fn run_fetch(
|
|||||||
DumpFormat::Text => dump_text(&mut page),
|
DumpFormat::Text => dump_text(&mut page),
|
||||||
DumpFormat::Links => dump_links(&page),
|
DumpFormat::Links => dump_links(&page),
|
||||||
DumpFormat::Markdown => dump_markdown(&mut page),
|
DumpFormat::Markdown => dump_markdown(&mut page),
|
||||||
|
// Handled above via the short-circuit branch; unreachable here.
|
||||||
|
DumpFormat::Original => unreachable!("Original dump handled before page navigation"),
|
||||||
};
|
};
|
||||||
write_or_print(rendered, output.as_ref()).await?;
|
write_or_print(rendered, output.as_ref()).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn fetch_original_bytes(
|
||||||
|
url_str: &str,
|
||||||
|
proxy: Option<String>,
|
||||||
|
user_agent: Option<String>,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> anyhow::Result<Vec<u8>> {
|
||||||
|
let url = url::Url::parse(url_str)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Invalid URL '{}': {}", url_str, e))?;
|
||||||
|
|
||||||
|
let client = obscura_net::ObscuraHttpClient::with_options(
|
||||||
|
Arc::new(obscura_net::CookieJar::new()),
|
||||||
|
proxy.as_deref(),
|
||||||
|
);
|
||||||
|
if let Some(ua) = user_agent {
|
||||||
|
client.set_user_agent(&ua).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = match timeout(Duration::from_secs(timeout_secs), client.fetch(&url)).await {
|
||||||
|
Ok(Ok(resp)) => resp,
|
||||||
|
Ok(Err(e)) => anyhow::bail!("Failed to fetch {}: {}", url_str, e),
|
||||||
|
Err(_) => anyhow::bail!("Timed out fetching {} after {}s", url_str, timeout_secs),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(response.body)
|
||||||
|
}
|
||||||
|
|
||||||
async fn write_or_print(content: String, output: Option<&std::path::PathBuf>) -> anyhow::Result<()> {
|
async fn write_or_print(content: String, output: Option<&std::path::PathBuf>) -> anyhow::Result<()> {
|
||||||
if let Some(path) = output {
|
if let Some(path) = output {
|
||||||
tokio::fs::write(path, content)
|
tokio::fs::write(path, content)
|
||||||
@@ -445,6 +493,30 @@ async fn write_or_print(content: String, output: Option<&std::path::PathBuf>) ->
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn write_or_print_bytes(
|
||||||
|
bytes: &[u8],
|
||||||
|
output: Option<&std::path::PathBuf>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
if let Some(path) = output {
|
||||||
|
tokio::fs::write(path, bytes)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to write {}: {}", path.display(), e))?;
|
||||||
|
} else {
|
||||||
|
// Write raw bytes to stdout — never println! (would append a newline
|
||||||
|
// and break binary payloads like JPEG/PNG).
|
||||||
|
let mut stdout = tokio::io::stdout();
|
||||||
|
stdout
|
||||||
|
.write_all(bytes)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to write to stdout: {}", e))?;
|
||||||
|
stdout
|
||||||
|
.flush()
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to flush stdout: {}", e))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn wait_for_selector(page: &mut Page, selector: &str, timeout_secs: u64) -> bool {
|
async fn wait_for_selector(page: &mut Page, selector: &str, timeout_secs: u64) -> bool {
|
||||||
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(timeout_secs);
|
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(timeout_secs);
|
||||||
loop {
|
loop {
|
||||||
@@ -824,12 +896,95 @@ fn dump_links(page: &Page) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
extract_readable_text, is_quiet_command, merge_proxy, select_log_filter, write_or_print,
|
extract_readable_text, fetch_original_bytes, is_quiet_command, merge_proxy,
|
||||||
Args, Command,
|
select_log_filter, write_or_print, write_or_print_bytes, Args, Command, DumpFormat,
|
||||||
};
|
};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use obscura_dom::parse_html;
|
use obscura_dom::parse_html;
|
||||||
|
|
||||||
|
// Issue #117 — `--dump original` short-circuits the browser stack and
|
||||||
|
// streams the raw response body verbatim, including for binary payloads.
|
||||||
|
//
|
||||||
|
// Two tests below pin the behaviour:
|
||||||
|
// 1. clap accepts `--dump original` as a valid DumpFormat variant.
|
||||||
|
// 2. `fetch_original_bytes` returns the exact bytes a `file://` URL
|
||||||
|
// points at (binary-safe round-trip — no UTF-8 decode, no trailing
|
||||||
|
// newline, no DOM mutation).
|
||||||
|
// 3. `write_or_print_bytes` writes raw bytes to a file without the
|
||||||
|
// trailing newline that `println!` would add.
|
||||||
|
#[test]
|
||||||
|
fn parsed_fetch_dump_original_is_accepted_by_clap() {
|
||||||
|
let args = Args::try_parse_from([
|
||||||
|
"obscura",
|
||||||
|
"fetch",
|
||||||
|
"--dump",
|
||||||
|
"original",
|
||||||
|
"https://example.com/image.jpg",
|
||||||
|
])
|
||||||
|
.expect("clap should accept --dump original");
|
||||||
|
match args.command {
|
||||||
|
Some(Command::Fetch { dump, .. }) => {
|
||||||
|
assert_eq!(dump, DumpFormat::Original);
|
||||||
|
}
|
||||||
|
_ => panic!("expected Fetch command"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn fetch_original_bytes_returns_file_contents_verbatim() {
|
||||||
|
// A real binary payload: a 1×1 transparent PNG (89 50 4E 47 …) —
|
||||||
|
// exactly the kind of resource #117 wants to stream without HTML/
|
||||||
|
// JS rendering.
|
||||||
|
const PNG_BYTES: &[u8] = &[
|
||||||
|
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
|
||||||
|
0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00,
|
||||||
|
0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, 0x54, 0x78,
|
||||||
|
0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
|
||||||
|
];
|
||||||
|
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"obscura-fetch-original-test-{}.png",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let _ = tokio::fs::remove_file(&path).await;
|
||||||
|
tokio::fs::write(&path, PNG_BYTES)
|
||||||
|
.await
|
||||||
|
.expect("seed temp PNG fixture");
|
||||||
|
|
||||||
|
let file_url = format!("file://{}", path.display());
|
||||||
|
let bytes = fetch_original_bytes(&file_url, None, None, 5)
|
||||||
|
.await
|
||||||
|
.expect("fetch_original_bytes should round-trip the file body");
|
||||||
|
|
||||||
|
let _ = tokio::fs::remove_file(&path).await;
|
||||||
|
|
||||||
|
assert_eq!(bytes, PNG_BYTES, "raw response body must match the file byte-for-byte");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn write_or_print_bytes_writes_without_trailing_newline() {
|
||||||
|
// Regression guard for #117: stdout must receive raw bytes. The file
|
||||||
|
// path used here exercises the file-output branch — println!-style
|
||||||
|
// output (used by write_or_print) would append a 0x0A byte and
|
||||||
|
// corrupt binary payloads. write_or_print_bytes must not.
|
||||||
|
let payload: &[u8] = &[0x00, 0xFF, b'h', b'i', 0x00];
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"obscura-write-bytes-test-{}.bin",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let _ = tokio::fs::remove_file(&path).await;
|
||||||
|
|
||||||
|
write_or_print_bytes(payload, Some(&path))
|
||||||
|
.await
|
||||||
|
.expect("write_or_print_bytes should write the file");
|
||||||
|
|
||||||
|
let read_back = tokio::fs::read(&path).await.expect("read back");
|
||||||
|
let _ = tokio::fs::remove_file(&path).await;
|
||||||
|
|
||||||
|
assert_eq!(read_back, payload, "file bytes must match the payload exactly");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
#[tokio::test(flavor = "current_thread")]
|
||||||
async fn write_or_print_writes_output_file_with_tokio_fs() {
|
async fn write_or_print_writes_output_file_with_tokio_fs() {
|
||||||
let path = std::env::temp_dir().join(format!(
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
|||||||
Reference in new issue
Block a user