Skip to main content

pagesift_api/
server.rs

1//! HTTP 路由:Firecrawl v2 兼容接口挂在根路径 `/v2/*`,另有 `/healthz` 和 `/metrics`。
2
3use std::collections::HashMap;
4use std::sync::Arc;
5use std::sync::atomic::Ordering;
6use std::time::Instant;
7
8use axum::extract::rejection::JsonRejection;
9use axum::extract::{Extension, Path, Query, Request, State};
10use axum::http::{HeaderMap, Method, StatusCode, header};
11use axum::middleware::{self, Next};
12use axum::response::{IntoResponse, Response};
13use axum::routing::{get, post};
14use axum::{Json, Router};
15use pagesift_store::Store;
16use serde::Deserialize;
17use tokio::sync::Mutex;
18
19use crate::engine::Engine;
20use crate::error::{ApiError, ErrorCode};
21use crate::jobs::{Jobs, Start, rfc3339};
22use crate::limits::{Caller, KeyLimits, Limiter};
23use crate::metrics::{Metrics, PageSource};
24use crate::types::{
25    BatchScrapeRequest, CrawlRequest, ExtractRequest, JobErrors, JobStarted, JobStatus, MapRequest,
26    MapResponse, ScrapeRequest, ScrapeResponse, SearchRequest,
27};
28use crate::validate::{check_scrape_options, check_url};
29use crate::webhook::{WebhookConfig, WebhookSettings, Webhooks};
30
31/// 任务状态分页时每页最多的文档数。
32const PAGE_SIZE: usize = 100;
33const DEFAULT_BATCH_CONCURRENCY: usize = 8;
34
35#[derive(Clone, Default)]
36pub struct Config {
37    /// 为空时不鉴权;否则要求 `Authorization: Bearer <key>`。
38    pub api_keys: Vec<String>,
39    /// 对外地址,用来生成任务的绝对 URL 和分页的 `next`;不设时用请求的 Host 头。
40    pub public_url: Option<String>,
41    pub max_batch_concurrency: Option<usize>,
42    /// 任务落盘用的存储:重启后结束的任务照样能查,进行中的从断点继续。
43    /// 不设时任务只在内存里。
44    pub store: Option<Store>,
45    /// 单独设了限额的 API key(它们也是有效的 key)。
46    pub key_limits: HashMap<String, KeyLimits>,
47    /// 其余 key 的限额;不鉴权时所有调用方合用这一份。默认不限。
48    pub default_limits: KeyLimits,
49    pub webhook: WebhookSettings,
50}
51
52impl std::fmt::Debug for Config {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        // 不打印 key 本身
55        f.debug_struct("Config")
56            .field("api_keys", &self.api_keys.len())
57            .field("public_url", &self.public_url)
58            .field("max_batch_concurrency", &self.max_batch_concurrency)
59            .field("store", &self.store.is_some())
60            .field("key_limits", &self.key_limits.len())
61            .field("default_limits", &self.default_limits)
62            .field("webhook", &self.webhook)
63            .finish()
64    }
65}
66
67#[derive(Clone)]
68pub struct AppState {
69    engine: Arc<dyn Engine>,
70    /// key → 限额。为空时不鉴权。
71    keys: Arc<HashMap<String, Arc<KeyLimits>>>,
72    default_limits: Arc<KeyLimits>,
73    public_url: Option<String>,
74    batch_concurrency: usize,
75    jobs: Jobs,
76    limiter: Arc<Limiter>,
77    webhooks: Webhooks,
78    extracts: Arc<Mutex<std::collections::HashMap<String, ExtractJob>>>,
79    metrics: Arc<Metrics>,
80}
81
82struct ExtractJob {
83    status: &'static str,
84    data: Option<serde_json::Value>,
85    error: Option<ApiError>,
86    created: std::time::SystemTime,
87}
88
89/// 组装路由。给了 [`Config::store`] 时会恢复存储里的任务,需要在 tokio 运行时里调用。
90pub fn router(engine: Arc<dyn Engine>, config: Config) -> Router {
91    let default_limits = Arc::new(config.default_limits);
92    let mut keys: HashMap<String, Arc<KeyLimits>> = config
93        .api_keys
94        .into_iter()
95        .map(|k| (k, default_limits.clone()))
96        .collect();
97    keys.extend(config.key_limits.into_iter().map(|(k, l)| (k, Arc::new(l))));
98    // 恢复任务时按调用方名字找回限额
99    let accounts: HashMap<String, Arc<KeyLimits>> = keys
100        .iter()
101        .map(|(k, l)| {
102            (
103                Caller::new(Some(k), l.clone()).account.to_string(),
104                l.clone(),
105            )
106        })
107        .collect();
108    let metrics = Arc::new(Metrics::default());
109    let limiter = Arc::new(Limiter::new(config.store.clone()));
110    let webhooks = Webhooks::new(config.webhook, metrics.clone());
111    let jobs = Jobs::new(
112        config.store,
113        limiter.clone(),
114        Some(webhooks.clone()),
115        metrics.clone(),
116    );
117    if tokio::runtime::Handle::try_current().is_ok() {
118        let fallback = default_limits.clone();
119        jobs.restore(&engine, &|account| {
120            accounts
121                .get(account)
122                .cloned()
123                .unwrap_or_else(|| fallback.clone())
124        });
125    } else {
126        tracing::warn!("router built outside a tokio runtime: saved jobs are not resumed");
127    }
128    let state = AppState {
129        engine,
130        keys: Arc::new(keys),
131        default_limits,
132        public_url: config
133            .public_url
134            .map(|u| u.trim_end_matches('/').to_string()),
135        batch_concurrency: config
136            .max_batch_concurrency
137            .unwrap_or(DEFAULT_BATCH_CONCURRENCY),
138        jobs,
139        limiter,
140        webhooks,
141        extracts: Arc::default(),
142        metrics,
143    };
144    let v2 = Router::new()
145        .route("/v2/scrape", post(scrape))
146        .route("/v2/batch/scrape", post(batch_start))
147        .route("/v2/batch/scrape/{id}", get(job_status).delete(job_cancel))
148        .route("/v2/batch/scrape/{id}/errors", get(job_errors))
149        .route("/v2/map", post(map))
150        .route("/v2/search", post(search))
151        .route("/v2/extract", post(extract_start))
152        .route("/v2/extract/{id}", get(extract_status))
153        .route("/v2/crawl", post(crawl_start))
154        .route("/v2/crawl/active", get(jobs_active))
155        .route("/v2/crawl/{id}", get(job_status).delete(job_cancel))
156        .route("/v2/crawl/{id}/errors", get(job_errors))
157        .route_layer(middleware::from_fn_with_state(state.clone(), auth));
158    Router::new()
159        .route("/healthz", get(|| async { "ok" }))
160        .route("/metrics", get(render_metrics))
161        .merge(v2)
162        .fallback(|| async { ApiError::new(ErrorCode::NotFound, "no such endpoint") })
163        .layer(middleware::from_fn_with_state(state.clone(), observe))
164        .with_state(state)
165}
166
167/// 鉴权并认出调用方;发起工作的请求(POST)按调用方的每分钟请求数限流。
168/// 查询状态、取消这类轮询请求不计数(Firecrawl 对它们的限额也宽得多)。
169async fn auth(
170    State(s): State<AppState>,
171    headers: HeaderMap,
172    mut req: Request,
173    next: Next,
174) -> Response {
175    let caller = if s.keys.is_empty() {
176        Caller::new(None, s.default_limits.clone())
177    } else {
178        let key = headers
179            .get(header::AUTHORIZATION)
180            .and_then(|v| v.to_str().ok())
181            .and_then(|v| v.strip_prefix("Bearer "))
182            .map(str::trim);
183        match key.and_then(|k| s.keys.get(k).map(|l| (k, l))) {
184            Some((k, l)) => Caller::new(Some(k), l.clone()),
185            None => {
186                return ApiError::new(ErrorCode::Unauthorized, "missing or invalid API key")
187                    .into_response();
188            }
189        }
190    };
191    if req.method() == Method::POST
192        && let Err(e) = s.limiter.check_rate(&caller)
193    {
194        let mut resp = e.into_response();
195        resp.extensions_mut().insert(caller);
196        return resp;
197    }
198    req.extensions_mut().insert(caller.clone());
199    let mut resp = next.run(req).await;
200    resp.extensions_mut().insert(caller);
201    resp
202}
203
204/// 每个请求的监控指标和结构化日志:按路由的请求数和延迟、在途请求数、按错误码的错误数。
205async fn observe(State(s): State<AppState>, req: Request, next: Next) -> Response {
206    let route = req
207        .extensions()
208        .get::<axum::extract::MatchedPath>()
209        .map(|p| p.as_str().to_string())
210        .unwrap_or_else(|| "unmatched".into());
211    let method = req.method().clone();
212    let started = Instant::now();
213    s.metrics.request_started();
214    let resp = next.run(req).await;
215    let elapsed = started.elapsed();
216    let status = resp.status().as_u16();
217    s.metrics.request_finished(&route, status, elapsed);
218    let code = resp.extensions().get::<ErrorCode>().map(|c| c.as_str());
219    if let Some(code) = code {
220        s.metrics.error(code, "http");
221    }
222    let account = resp.extensions().get::<Caller>().map(|c| c.account.clone());
223    let latency_ms = elapsed.as_secs_f64() * 1000.0;
224    if route.starts_with("/v2/") {
225        tracing::info!(
226            target: "pagesift::http",
227            %method, %route, status, latency_ms, account = account.as_deref(), code,
228            "request"
229        );
230    } else {
231        tracing::debug!(target: "pagesift::http", %method, %route, status, latency_ms, "request");
232    }
233    resp
234}
235
236/// JSON 解析失败时也返回统一的错误格式。
237fn body<T>(r: Result<Json<T>, JsonRejection>) -> Result<T, ApiError> {
238    r.map(|Json(v)| v)
239        .map_err(|e| ApiError::invalid(format!("invalid request body: {}", e.body_text())))
240}
241
242fn base_url(s: &AppState, headers: &HeaderMap) -> String {
243    if let Some(u) = &s.public_url {
244        return u.clone();
245    }
246    let host = headers
247        .get(header::HOST)
248        .and_then(|v| v.to_str().ok())
249        .unwrap_or("localhost:3002");
250    format!("http://{host}")
251}
252
253async fn scrape(
254    State(s): State<AppState>,
255    caller: Option<Extension<Caller>>,
256    req: Result<Json<ScrapeRequest>, JsonRejection>,
257) -> Result<Json<ScrapeResponse>, ApiError> {
258    let req = body(req)?;
259    check_url(&req.url)?;
260    check_scrape_options(&req.options, s.engine.as_ref())?;
261    if let Some(Extension(c)) = &caller {
262        s.limiter.check_quota(c)?;
263    }
264    match s.engine.scrape(&req.url, &req.options).await {
265        Ok(data) => {
266            s.metrics.scrape_ok.fetch_add(1, Ordering::Relaxed);
267            s.metrics.page(PageSource::Scrape);
268            if let Some(Extension(c)) = &caller {
269                s.limiter.consume_pages(c, 1).await;
270            }
271            Ok(Json(ScrapeResponse {
272                success: true,
273                warning: data.warning.clone(),
274                data,
275            }))
276        }
277        Err(e) => {
278            s.metrics.scrape_err.fetch_add(1, Ordering::Relaxed);
279            Err(e)
280        }
281    }
282}
283
284async fn batch_start(
285    State(s): State<AppState>,
286    caller: Option<Extension<Caller>>,
287    headers: HeaderMap,
288    req: Result<Json<BatchScrapeRequest>, JsonRejection>,
289) -> Result<Json<JobStarted>, ApiError> {
290    let req = body(req)?;
291    check_scrape_options(&req.options, s.engine.as_ref())?;
292    let (mut valid, mut invalid) = (Vec::new(), Vec::new());
293    for u in req.urls {
294        match check_url(&u) {
295            Ok(()) => valid.push(u),
296            Err(e) if !req.ignore_invalid_urls => return Err(e),
297            Err(_) => invalid.push(u),
298        }
299    }
300    if valid.is_empty() {
301        return Err(ApiError::invalid("no valid URLs"));
302    }
303    let start = Start {
304        owner: caller.map(|Extension(c)| c),
305        webhook: webhook(&s, req.webhook.as_ref())?,
306    };
307    let concurrency = req
308        .max_concurrency
309        .unwrap_or(s.batch_concurrency)
310        .min(s.batch_concurrency);
311    let id = s
312        .jobs
313        .start_batch_with(s.engine.clone(), valid, req.options, concurrency, start)
314        .await?;
315    Ok(Json(JobStarted {
316        success: true,
317        url: format!("{}/v2/batch/scrape/{id}", base_url(&s, &headers)),
318        id,
319        invalid_urls: req.ignore_invalid_urls.then_some(invalid),
320    }))
321}
322
323#[derive(Deserialize)]
324struct Paging {
325    #[serde(default)]
326    skip: usize,
327}
328
329async fn job_status(
330    State(s): State<AppState>,
331    headers: HeaderMap,
332    uri: axum::http::Uri,
333    Path(id): Path<String>,
334    Query(p): Query<Paging>,
335) -> Result<Json<JobStatus>, ApiError> {
336    let base = base_url(&s, &headers);
337    let path = uri.path().to_string();
338    let page = s
339        .jobs
340        .status(&id, p.skip, PAGE_SIZE)
341        .await
342        .ok_or_else(|| ApiError::new(ErrorCode::NotFound, format!("job {id} not found")))?;
343    let end = p.skip.min(page.docs) + page.data.len();
344    let more = end < page.docs || (page.state.as_str() == "scraping" && end < page.total);
345    Ok(Json(JobStatus {
346        success: true,
347        status: page.state.as_str().into(),
348        completed: page.completed,
349        total: page.total,
350        credits_used: page.completed,
351        expires_at: rfc3339(page.expires_at),
352        next: (more && end > p.skip).then(|| format!("{base}{path}?skip={end}")),
353        data: page.data,
354        pagesift: page.summary,
355    }))
356}
357
358async fn job_errors(
359    State(s): State<AppState>,
360    Path(id): Path<String>,
361) -> Result<Json<JobErrors>, ApiError> {
362    s.jobs
363        .errors(&id)
364        .await
365        .map(|(errors, robots_blocked)| {
366            Json(JobErrors {
367                errors,
368                robots_blocked,
369            })
370        })
371        .ok_or_else(|| ApiError::new(ErrorCode::NotFound, format!("job {id} not found")))
372}
373
374async fn job_cancel(
375    State(s): State<AppState>,
376    Path(id): Path<String>,
377) -> Result<Response, ApiError> {
378    if s.jobs.cancel(&id).await {
379        Ok(Json(serde_json::json!({ "success": true, "status": "cancelled" })).into_response())
380    } else {
381        Err(ApiError::new(
382            ErrorCode::NotFound,
383            format!("job {id} not found"),
384        ))
385    }
386}
387
388async fn jobs_active(State(s): State<AppState>) -> Json<serde_json::Value> {
389    let ids = s.jobs.active().await;
390    Json(serde_json::json!({
391        "success": true,
392        "crawls": ids.into_iter().map(|id| serde_json::json!({ "id": id })).collect::<Vec<_>>(),
393    }))
394}
395
396async fn map(
397    State(s): State<AppState>,
398    req: Result<Json<MapRequest>, JsonRejection>,
399) -> Result<Json<MapResponse>, ApiError> {
400    let req = body(req)?;
401    check_url(&req.url)?;
402    let links = s.engine.map(&req).await?;
403    Ok(Json(MapResponse {
404        success: true,
405        links,
406    }))
407}
408
409async fn search(
410    State(s): State<AppState>,
411    req: Result<Json<SearchRequest>, JsonRejection>,
412) -> Result<Json<serde_json::Value>, ApiError> {
413    let req = body(req)?;
414    if req.query.trim().is_empty() {
415        return Err(ApiError::invalid("query must not be empty"));
416    }
417    if !(1..=100).contains(&req.limit) {
418        return Err(ApiError::invalid("limit must be between 1 and 100"));
419    }
420    if let Some(o) = &req.scrape_options {
421        check_scrape_options(o, s.engine.as_ref())?;
422    }
423    let data = s.engine.search(&req).await?;
424    let credits = data["web"].as_array().map_or(0, Vec::len);
425    Ok(Json(serde_json::json!({
426        "success": true,
427        "data": data,
428        "creditsUsed": credits,
429        "id": uuid::Uuid::new_v4().to_string(),
430    })))
431}
432
433async fn extract_start(
434    State(s): State<AppState>,
435    req: Result<Json<ExtractRequest>, JsonRejection>,
436) -> Result<Json<serde_json::Value>, ApiError> {
437    let req = body(req)?;
438    if req.urls.is_empty() {
439        return Err(ApiError::invalid("urls must not be empty"));
440    }
441    if req.prompt.is_none() && req.schema.is_none() {
442        return Err(ApiError::invalid("extract needs a prompt or a schema"));
443    }
444    if req.enable_web_search {
445        return Err(ApiError::unsupported(
446            "enableWebSearch is not available in this build",
447        ));
448    }
449    for u in &req.urls {
450        if let Err(e) = check_url(u.trim_end_matches("/*"))
451            && !req.ignore_invalid_urls
452        {
453            return Err(e);
454        }
455    }
456    let id = uuid::Uuid::new_v4().to_string();
457    {
458        let mut map = s.extracts.lock().await;
459        let now = std::time::SystemTime::now();
460        map.retain(|_, j| j.created + crate::jobs::JOB_TTL > now);
461        map.insert(
462            id.clone(),
463            ExtractJob {
464                status: "processing",
465                data: None,
466                error: None,
467                created: now,
468            },
469        );
470    }
471    let (engine, extracts, job_id) = (s.engine.clone(), s.extracts.clone(), id.clone());
472    tokio::spawn(async move {
473        let result = engine.extract(&req).await;
474        if let Some(job) = extracts.lock().await.get_mut(&job_id) {
475            match result {
476                Ok(v) => {
477                    job.status = "completed";
478                    job.data = Some(v);
479                }
480                Err(e) => {
481                    job.status = "failed";
482                    job.error = Some(e);
483                }
484            }
485        }
486    });
487    Ok(Json(serde_json::json!({ "success": true, "id": id })))
488}
489
490async fn extract_status(
491    State(s): State<AppState>,
492    Path(id): Path<String>,
493) -> Result<Json<serde_json::Value>, ApiError> {
494    let map = s.extracts.lock().await;
495    let job = map
496        .get(&id)
497        .ok_or_else(|| ApiError::new(ErrorCode::NotFound, format!("extract job {id} not found")))?;
498    let mut body = serde_json::json!({
499        "success": job.error.is_none(),
500        "status": job.status,
501        "expiresAt": rfc3339(job.created + crate::jobs::JOB_TTL),
502    });
503    if let Some(d) = &job.data {
504        body["data"] = d.clone();
505    }
506    if let Some(e) = &job.error {
507        body["error"] = format!("{}: {}", e.code.as_str(), e.message).into();
508    }
509    Ok(Json(body))
510}
511
512async fn crawl_start(
513    State(s): State<AppState>,
514    caller: Option<Extension<Caller>>,
515    headers: HeaderMap,
516    req: Result<Json<CrawlRequest>, JsonRejection>,
517) -> Result<Json<JobStarted>, ApiError> {
518    let req = body(req)?;
519    check_url(&req.url)?;
520    check_scrape_options(&req.scrape_options, s.engine.as_ref())?;
521    for pattern in req.include_paths.iter().chain(&req.exclude_paths) {
522        regex_check(pattern)?;
523    }
524    let start = Start {
525        owner: caller.map(|Extension(c)| c),
526        webhook: webhook(&s, req.webhook.as_ref())?,
527    };
528    let id = s
529        .jobs
530        .start_crawl_with(s.engine.clone(), req, start)
531        .await?;
532    Ok(Json(JobStarted {
533        success: true,
534        url: format!("{}/v2/crawl/{id}", base_url(&s, &headers)),
535        id,
536        invalid_urls: None,
537    }))
538}
539
540/// 请求里的 `webhook`:解析并做 SSRF 校验。
541fn webhook(s: &AppState, v: Option<&serde_json::Value>) -> Result<Option<WebhookConfig>, ApiError> {
542    v.filter(|v| !v.is_null())
543        .map(|v| WebhookConfig::parse(v, s.webhooks.ssrf()))
544        .transpose()
545}
546
547/// includePaths 和 excludePaths 是正则;写错时在启动任务前就报错。
548fn regex_check(pattern: &str) -> Result<(), ApiError> {
549    regex::RegexBuilder::new(pattern)
550        .size_limit(1 << 20)
551        .build()
552        .map(|_| ())
553        .map_err(|e| ApiError::invalid(format!("invalid path pattern {pattern:?}: {e}")))
554}
555
556async fn render_metrics(State(s): State<AppState>) -> impl IntoResponse {
557    let out = s.metrics.render(&s.jobs.counts().await);
558    (
559        StatusCode::OK,
560        [(header::CONTENT_TYPE, "text/plain; version=0.0.4")],
561        out,
562    )
563}