1use axum::Json;
5use axum::extract::{Extension, Path, State};
6use axum::http::{HeaderMap, StatusCode, header};
7use axum::response::{IntoResponse, Response};
8use serde::Deserialize;
9use std::sync::atomic::Ordering;
10
11use super::stream_sse::{error_event, split_paragraphs, sse_parts, sse_response};
12use crate::limits::Caller;
13use crate::metrics::PageSource;
14use crate::server::AppState;
15use crate::types::ScrapeOptions;
16
17#[derive(Debug, Deserialize)]
18pub struct JinaPostRequest {
19 pub url: String,
20}
21
22pub(crate) async fn handle_jina_get(
24 State(s): State<AppState>,
25 caller: Option<Extension<Caller>>,
26 headers: HeaderMap,
27 Path(raw_url): Path<String>,
28) -> Response {
29 let clean_url = parse_target_url(&raw_url);
30 process_jina_scrape(s, caller, headers, &clean_url).await
31}
32
33pub(crate) async fn handle_jina_post(
35 State(s): State<AppState>,
36 caller: Option<Extension<Caller>>,
37 headers: HeaderMap,
38 Json(payload): Json<JinaPostRequest>,
39) -> Response {
40 let clean_url = parse_target_url(&payload.url);
41 process_jina_scrape(s, caller, headers, &clean_url).await
42}
43
44fn parse_target_url(raw: &str) -> String {
45 let trimmed = raw.trim();
46 if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
47 trimmed.to_string()
48 } else if let Some(idx) = trimmed.find("http:/") {
49 if trimmed[idx..].starts_with("http://") {
50 trimmed[idx..].to_string()
51 } else {
52 format!("http://{}", trimmed[idx + 6..].trim_start_matches('/'))
53 }
54 } else if let Some(idx) = trimmed.find("https:/") {
55 if trimmed[idx..].starts_with("https://") {
56 trimmed[idx..].to_string()
57 } else {
58 format!("https://{}", trimmed[idx + 7..].trim_start_matches('/'))
59 }
60 } else {
61 format!("https://{}", trimmed.trim_start_matches('/'))
62 }
63}
64
65async fn process_jina_scrape(
66 s: AppState,
67 caller: Option<Extension<Caller>>,
68 headers: HeaderMap,
69 target_url: &str,
70) -> Response {
71 if let Err(e) = crate::validate::check_url(target_url) {
72 return e.into_response();
73 }
74 if let Some(Extension(c)) = &caller
75 && let Err(e) = s.limiter.check_quota(c)
76 {
77 return e.into_response();
78 }
79
80 let (mut opts, params) = match super::headers::parse(&headers) {
81 Ok(v) => v,
82 Err(e) => return e.into_response(),
83 };
84 opts.owner = caller
85 .as_ref()
86 .map(|Extension(c)| c.account.to_string())
87 .filter(|owner| owner != "anonymous");
88 if let Err(e) = crate::validate::check_scrape_options(&opts, s.engine.as_ref()) {
89 return e.into_response();
90 }
91 let accept = headers
92 .get(header::ACCEPT)
93 .and_then(|v| v.to_str().ok())
94 .unwrap_or("");
95 if accept.contains("text/event-stream") {
96 return stream_scrape(
97 s,
98 caller.map(|Extension(c)| c),
99 target_url.to_string(),
100 opts,
101 params,
102 );
103 }
104
105 match s.engine.scrape(target_url, &opts).await {
106 Ok(doc) => {
107 record_ok(&s, caller.as_ref().map(|Extension(c)| c)).await;
108 let r = match super::respond::render(&doc, target_url, ¶ms) {
109 Ok(r) => r,
110 Err(e) => return e.into_response(),
111 };
112 let mut warnings = doc
113 .metadata
114 .pagesift
115 .as_ref()
116 .map(|m| m.warnings.clone())
117 .unwrap_or_default();
118 warnings.extend(r.warnings.iter().cloned());
119 if accept.contains("application/json") {
120 let mut data = serde_json::json!({
121 "title": doc.metadata.title.as_deref().unwrap_or(""),
122 "description": doc.metadata.description.clone().unwrap_or_default(),
123 "url": target_url,
124 "content": r.body,
125 });
126 if let Some(chunks) = &r.chunks {
127 data["chunks"] = serde_json::json!(chunks);
128 }
129 if !warnings.is_empty() {
130 data["warnings"] = serde_json::json!(warnings);
131 }
132 Json(serde_json::json!({ "code": 200, "status": 20000, "data": data }))
134 .into_response()
135 } else {
136 let body = r.text;
137 let mut resp = Response::builder().header(header::CONTENT_TYPE, r.content_type);
138 if let Some(v) = (!warnings.is_empty())
140 .then(|| warnings.join("; "))
141 .and_then(|w| axum::http::HeaderValue::from_str(&header_safe(&w)).ok())
142 {
143 resp = resp.header("x-pagesift-warnings", v);
144 }
145 resp.body(axum::body::Body::from(body)).unwrap_or_else(|_| {
146 (StatusCode::INTERNAL_SERVER_ERROR, "response builder error").into_response()
147 })
148 }
149 }
150 Err(e) => {
151 s.metrics.scrape_err.fetch_add(1, Ordering::Relaxed);
152 e.into_response()
153 }
154 }
155}
156
157async fn record_ok(s: &AppState, caller: Option<&Caller>) {
158 s.metrics.scrape_ok.fetch_add(1, Ordering::Relaxed);
159 s.metrics.page(PageSource::Scrape);
160 if let Some(c) = caller {
161 s.limiter.consume_pages(c, 1).await;
162 }
163}
164
165fn stream_scrape(
171 s: AppState,
172 caller: Option<Caller>,
173 url: String,
174 opts: ScrapeOptions,
175 params: super::headers::JinaParams,
176) -> Response {
177 stream_scrape_with(
178 s,
179 caller,
180 url,
181 opts,
182 params,
183 crate::firecrawl::stream::KEEPALIVE,
184 )
185}
186
187fn stream_scrape_with(
191 s: AppState,
192 caller: Option<Caller>,
193 url: String,
194 opts: ScrapeOptions,
195 params: super::headers::JinaParams,
196 keepalive: std::time::Duration,
197) -> Response {
198 let (tx, body) = crate::stream_body::channel();
199 tokio::spawn(async move {
200 let scrape = s.engine.scrape(&url, &opts);
201 tokio::pin!(scrape);
202 let mut tick = tokio::time::interval_at(tokio::time::Instant::now() + keepalive, keepalive);
203 let r = loop {
204 tokio::select! {
205 r = &mut scrape => break r,
206 () = tx.closed() => return,
207 _ = tick.tick() => {
208 if tx.send(axum::body::Bytes::from_static(b": keepalive\n\n")).await.is_err() {
209 return;
210 }
211 }
212 }
213 };
214 match r {
215 Ok(doc) => {
216 record_ok(&s, caller.as_ref()).await;
217 let r = match super::respond::render(&doc, &url, ¶ms) {
219 Ok(r) => r,
220 Err(e) => {
221 let _ = tx.send(error_event(&e).into()).await;
222 return;
223 }
224 };
225 let mut warnings = doc
226 .metadata
227 .pagesift
228 .as_ref()
229 .map(|m| m.warnings.clone())
230 .unwrap_or_default();
231 warnings.extend(r.warnings.iter().cloned());
232 let parts: Vec<String> = match r.chunks {
233 Some(c) => c,
234 None => split_paragraphs(&r.body),
235 };
236 let events = sse_parts(&url, doc.metadata.title.as_deref(), &parts, &warnings);
237 for event in events {
238 if tx.send(event.into()).await.is_err() {
239 return;
240 }
241 }
242 }
243 Err(e) => {
244 s.metrics.scrape_err.fetch_add(1, Ordering::Relaxed);
245 let _ = tx.send(error_event(&e).into()).await;
246 }
247 }
248 });
249 sse_response(body)
250}
251
252fn header_safe(s: &str) -> String {
254 let mut out = String::with_capacity(s.len());
255 for b in s.bytes() {
256 if (b' '..=b'~').contains(&b) && b != b'%' {
257 out.push(b as char);
258 } else {
259 out.push_str(&format!("%{b:02X}"));
260 }
261 }
262 out
263}