use axum::Json;
use axum::extract::{Extension, Path, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use serde::Deserialize;
use std::sync::atomic::Ordering;
use super::stream_sse::{error_event, split_paragraphs, sse_parts, sse_response};
use crate::limits::Caller;
use crate::metrics::PageSource;
use crate::server::AppState;
use crate::types::ScrapeOptions;
#[derive(Debug, Deserialize)]
pub struct JinaPostRequest {
pub url: String,
}
pub(crate) async fn handle_jina_get(
State(s): State<AppState>,
caller: Option<Extension<Caller>>,
headers: HeaderMap,
Path(raw_url): Path<String>,
) -> Response {
let clean_url = parse_target_url(&raw_url);
process_jina_scrape(s, caller, headers, &clean_url).await
}
pub(crate) async fn handle_jina_post(
State(s): State<AppState>,
caller: Option<Extension<Caller>>,
headers: HeaderMap,
Json(payload): Json<JinaPostRequest>,
) -> Response {
let clean_url = parse_target_url(&payload.url);
process_jina_scrape(s, caller, headers, &clean_url).await
}
fn parse_target_url(raw: &str) -> String {
let trimmed = raw.trim();
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
trimmed.to_string()
} else if let Some(idx) = trimmed.find("http:/") {
if trimmed[idx..].starts_with("http://") {
trimmed[idx..].to_string()
} else {
format!("http://{}", trimmed[idx + 6..].trim_start_matches('/'))
}
} else if let Some(idx) = trimmed.find("https:/") {
if trimmed[idx..].starts_with("https://") {
trimmed[idx..].to_string()
} else {
format!("https://{}", trimmed[idx + 7..].trim_start_matches('/'))
}
} else {
format!("https://{}", trimmed.trim_start_matches('/'))
}
}
async fn process_jina_scrape(
s: AppState,
caller: Option<Extension<Caller>>,
headers: HeaderMap,
target_url: &str,
) -> Response {
if let Err(e) = crate::validate::check_url(target_url) {
return e.into_response();
}
if let Some(Extension(c)) = &caller
&& let Err(e) = s.limiter.check_quota(c)
{
return e.into_response();
}
let (mut opts, params) = match super::headers::parse(&headers) {
Ok(v) => v,
Err(e) => return e.into_response(),
};
opts.owner = caller
.as_ref()
.map(|Extension(c)| c.account.to_string())
.filter(|owner| owner != "anonymous");
if let Err(e) = crate::validate::check_scrape_options(&opts, s.engine.as_ref()) {
return e.into_response();
}
let accept = headers
.get(header::ACCEPT)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if accept.contains("text/event-stream") {
return stream_scrape(
s,
caller.map(|Extension(c)| c),
target_url.to_string(),
opts,
params,
);
}
match s.engine.scrape(target_url, &opts).await {
Ok(doc) => {
record_ok(&s, caller.as_ref().map(|Extension(c)| c)).await;
let r = match super::respond::render(&doc, target_url, ¶ms) {
Ok(r) => r,
Err(e) => return e.into_response(),
};
let mut warnings = doc
.metadata
.pagesift
.as_ref()
.map(|m| m.warnings.clone())
.unwrap_or_default();
warnings.extend(r.warnings.iter().cloned());
if accept.contains("application/json") {
let mut data = serde_json::json!({
"title": doc.metadata.title.as_deref().unwrap_or(""),
"description": doc.metadata.description.clone().unwrap_or_default(),
"url": target_url,
"content": r.body,
});
if let Some(chunks) = &r.chunks {
data["chunks"] = serde_json::json!(chunks);
}
if !warnings.is_empty() {
data["warnings"] = serde_json::json!(warnings);
}
Json(serde_json::json!({ "code": 200, "status": 20000, "data": data }))
.into_response()
} else {
let body = r.text;
let mut resp = Response::builder().header(header::CONTENT_TYPE, r.content_type);
if let Some(v) = (!warnings.is_empty())
.then(|| warnings.join("; "))
.and_then(|w| axum::http::HeaderValue::from_str(&header_safe(&w)).ok())
{
resp = resp.header("x-pagesift-warnings", v);
}
resp.body(axum::body::Body::from(body)).unwrap_or_else(|_| {
(StatusCode::INTERNAL_SERVER_ERROR, "response builder error").into_response()
})
}
}
Err(e) => {
s.metrics.scrape_err.fetch_add(1, Ordering::Relaxed);
e.into_response()
}
}
}
async fn record_ok(s: &AppState, caller: Option<&Caller>) {
s.metrics.scrape_ok.fetch_add(1, Ordering::Relaxed);
s.metrics.page(PageSource::Scrape);
if let Some(c) = caller {
s.limiter.consume_pages(c, 1).await;
}
}
fn stream_scrape(
s: AppState,
caller: Option<Caller>,
url: String,
opts: ScrapeOptions,
params: super::headers::JinaParams,
) -> Response {
stream_scrape_with(
s,
caller,
url,
opts,
params,
crate::firecrawl::stream::KEEPALIVE,
)
}
fn stream_scrape_with(
s: AppState,
caller: Option<Caller>,
url: String,
opts: ScrapeOptions,
params: super::headers::JinaParams,
keepalive: std::time::Duration,
) -> Response {
let (tx, body) = crate::stream_body::channel();
tokio::spawn(async move {
let scrape = s.engine.scrape(&url, &opts);
tokio::pin!(scrape);
let mut tick = tokio::time::interval_at(tokio::time::Instant::now() + keepalive, keepalive);
let r = loop {
tokio::select! {
r = &mut scrape => break r,
() = tx.closed() => return,
_ = tick.tick() => {
if tx.send(axum::body::Bytes::from_static(b": keepalive\n\n")).await.is_err() {
return;
}
}
}
};
match r {
Ok(doc) => {
record_ok(&s, caller.as_ref()).await;
let r = match super::respond::render(&doc, &url, ¶ms) {
Ok(r) => r,
Err(e) => {
let _ = tx.send(error_event(&e).into()).await;
return;
}
};
let mut warnings = doc
.metadata
.pagesift
.as_ref()
.map(|m| m.warnings.clone())
.unwrap_or_default();
warnings.extend(r.warnings.iter().cloned());
let parts: Vec<String> = match r.chunks {
Some(c) => c,
None => split_paragraphs(&r.body),
};
let events = sse_parts(&url, doc.metadata.title.as_deref(), &parts, &warnings);
for event in events {
if tx.send(event.into()).await.is_err() {
return;
}
}
}
Err(e) => {
s.metrics.scrape_err.fetch_add(1, Ordering::Relaxed);
let _ = tx.send(error_event(&e).into()).await;
}
}
});
sse_response(body)
}
fn header_safe(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
if (b' '..=b'~').contains(&b) && b != b'%' {
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out
}