1use super::*;
2
3pub(super) fn fetch_request(url: &str, opts: &ScrapeOptions) -> Result<FetchRequest, ApiError> {
4 let mut req = FetchRequest::new(url);
5 for (k, v) in &opts.headers {
6 req = req.header(k.clone(), v.clone());
7 }
8 if let Some(etag) = &opts.if_none_match {
9 req = req.if_none_match(etag.clone());
10 }
11 if let Some(t) = &opts.if_modified_since {
12 let t = httpdate::parse_http_date(t).map_err(|_| {
13 ApiError::invalid(format!("ifModifiedSince is not an HTTP date: {t:?}"))
14 })?;
15 req = req.if_modified_since(t);
16 }
17 req.timeout = opts.timeout.map(Duration::from_millis);
18 Ok(req)
19}
20
21pub fn chunk_markdown(
23 md: &str,
24 spec: &serde_json::Value,
25) -> Result<Vec<pagesift_api::types::DocChunk>, ApiError> {
26 let snake = match spec {
28 serde_json::Value::Object(m) => serde_json::Value::Object(
29 m.iter()
30 .map(|(k, v)| {
31 let mut out = String::new();
32 for c in k.chars() {
33 if c.is_ascii_uppercase() {
34 out.push('_');
35 out.push(c.to_ascii_lowercase());
36 } else {
37 out.push(c);
38 }
39 }
40 (out, v.clone())
41 })
42 .collect(),
43 ),
44 other => other.clone(),
45 };
46 let strategy: pagesift_extract::ChunkStrategy = serde_json::from_value(snake)
47 .map_err(|e| ApiError::invalid(format!("invalid chunking: {e}")))?;
48 let chunks = pagesift_extract::chunk(md, &strategy)
49 .map_err(|e| ApiError::invalid(format!("invalid chunking: {e}")))?;
50 Ok(chunks
51 .into_iter()
52 .map(|c| pagesift_api::types::DocChunk {
53 text: c.text,
54 heading_path: c.heading_path,
55 start: c.start,
56 end: c.end,
57 tokens: c.tokens,
58 })
59 .collect())
60}
61
62pub(super) async fn apply_query_filter(
64 doc: &mut Document,
65 q: &pagesift_api::types::QueryFilter,
66) -> Result<(), ApiError> {
67 let Some(md) = doc.markdown.take() else {
68 return Ok(());
69 };
70 let fo = pagesift_extract::QueryFilterOptions {
71 threshold: q.threshold,
72 relative: q.relative,
73 top_k: q.top_k,
74 max_tokens: q.max_tokens,
75 min_tokens: q.min_tokens,
76 headings: q.headings,
77 };
78 let query = q.query.clone();
79 let (f, before) = cpu_task(move || {
80 let f = pagesift_extract::filter_markdown_by_query(&md, &query, &fo);
81 (f, pagesift_content::estimate_tokens(&md))
82 })
83 .await?;
84 let ext = doc.metadata.pagesift.get_or_insert_with(Default::default);
85 if f.kept == 0 && f.total > 0 {
86 ext.warnings.push("query_filter_no_match".into());
87 }
88 ext.query_filter = Some(pagesift_api::types::QueryFilterStats {
89 kept_blocks: f.kept,
90 total_blocks: f.total,
91 tokens_before: before,
92 tokens_after: pagesift_content::estimate_tokens(&f.markdown),
93 });
94 doc.markdown = Some(f.markdown);
95 Ok(())
96}
97
98pub(super) async fn process_html(
99 html: String,
100 base: &Url,
101 status: Option<u16>,
102 opts: &ScrapeOptions,
103) -> Result<(pagesift_content::Document, String), ApiError> {
104 let copts = content_options(base, status, opts);
105 let tags = tag_filter(opts)?;
106 cpu_task(move || {
107 if let Some(document) = super::feed::parse(&html, copts.links) {
109 return (document, html);
110 }
111 let filtered = tags.map(|t| t.apply(&html));
113 let source = filtered.as_deref().unwrap_or(&html);
114 let document = CONTENT_WORKSPACE.with(|workspace| {
115 pagesift_content::process_with_workspace(source, &copts, &mut workspace.borrow_mut())
116 });
117 (document, html)
118 })
119 .await
120}
121
122pub(super) fn content_options(
124 base: &Url,
125 status: Option<u16>,
126 opts: &ScrapeOptions,
127) -> pagesift_content::Options {
128 pagesift_content::Options {
129 base_url: Some(base.clone()),
130 only_main_content: opts.only_main_content,
131 status,
132 detect_barrier: true,
133 clean_html: opts.wants("html"),
134 links: opts.wants("links"),
135 images: opts.wants("images"),
136 fingerprints: opts.wants("changeTracking"),
138 ..Default::default()
139 }
140}
141
142pub fn tag_filter(opts: &ScrapeOptions) -> Result<Option<pagesift_content::TagFilter>, ApiError> {
144 pagesift_content::TagFilter::new(&opts.include_tags, &opts.exclude_tags)
145 .map_err(ApiError::invalid)
146}
147
148pub(super) fn check_barrier(
149 doc: &pagesift_content::Document,
150 raw_html: &str,
151) -> Result<(), ApiError> {
152 let Some(b) = &doc.barrier else { return Ok(()) };
153 let interactive = {
157 let html = raw_html.to_ascii_lowercase();
158 [
159 "g-recaptcha",
160 "h-captcha",
161 "cf-turnstile",
162 "px-captcha",
163 "captcha-delivery",
164 "geetest",
165 ]
166 .iter()
167 .any(|marker| html.contains(marker))
168 };
169 Err(ApiError::new(
170 ErrorCode::AccessBarrier,
171 format!(
172 "the site returned an access barrier ({} {})",
173 b.vendor,
174 b.kind.as_str()
175 ),
176 )
177 .with_details(serde_json::json!({
178 "vendor": b.vendor,
179 "kind": b.kind.as_str(),
180 "confidence": b.confidence,
181 "ruleId": b.rule_id,
182 "needsHuman": interactive || matches!(b.kind, pagesift_content::BarrierKind::Captcha),
183 })))
184}
185
186pub(super) fn document(
187 doc: pagesift_content::Document,
188 opts: &ScrapeOptions,
189 raw_html: String,
190) -> Document {
191 Document {
192 markdown: opts.wants("markdown").then_some(doc.markdown),
193 html: doc.html,
194 raw_html: opts.wants("rawHtml").then_some(raw_html),
195 link_text: doc
196 .links
197 .as_ref()
198 .map(|l| l.iter().map(|l| l.text.clone()).collect()),
199 alternates: doc.metadata.alternates.clone(),
200 links: doc.links.map(|l| l.into_iter().map(|l| l.url).collect()),
201 images: doc.images.map(|i| i.into_iter().map(|i| i.src).collect()),
202 ..Default::default()
203 }
204}
205
206pub(super) fn fill_fingerprints(ext: &mut PagesiftMeta, doc: &pagesift_content::Document) {
209 match &doc.fingerprints {
210 Some(f) => {
211 ext.content_hash = Some(f.markdown.clone());
212 ext.raw_hash = Some(f.raw_html.clone());
213 ext.text_hash = Some(f.text.clone());
214 }
215 None => ext.content_hash = Some(pagesift_content::hash_hex(doc.markdown.as_bytes())),
216 }
217}
218
219pub(super) fn fill_structured(ext: &mut PagesiftMeta, doc: &pagesift_content::Document) {
221 ext.json_ld = doc.metadata.json_ld().to_vec();
222}
223
224pub(super) use pagesift_sink::meta::fill as fill_metadata;
225
226#[allow(clippy::too_many_arguments)] pub(super) fn finish(
228 mut doc: Document,
229 mut meta: Metadata,
230 mut ext: PagesiftMeta,
231 mut timings: BTreeMap<String, f64>,
232 mut warnings: Vec<String>,
233 raw_bytes: Option<usize>,
234 video_fallback: bool,
235 started: Instant,
236) -> Document {
237 timings.insert("total".into(), ms(started.elapsed()));
238 if meta.status_code >= 400 {
239 warnings.push(format!("http_error_status: HTTP {}", meta.status_code));
240 } else if (300..400).contains(&meta.status_code) {
241 warnings.push(format!(
243 "http_redirect_status: HTTP {} was not followed (missing or unusable Location)",
244 meta.status_code
245 ));
246 }
247 const THIN_MAX_CHARS: usize = 500;
250 let markdown_chars = doc.markdown.as_deref().map(|markdown| {
251 let mut count = 0;
252 for c in markdown.chars() {
253 if !c.is_whitespace() {
254 count += 1;
255 if count > THIN_MAX_CHARS {
256 break;
257 }
258 }
259 }
260 count
261 });
262 if meta.status_code < 400 && markdown_chars == Some(0) {
263 warnings.push(format!(
264 "empty_content: no readable text extracted (HTTP {})",
265 meta.status_code
266 ));
267 }
268 const THIN_MIN_HTML_BYTES: usize = 20_000;
272 if meta.status_code < 400
273 && !video_fallback
274 && let (Some(chars @ 1..THIN_MAX_CHARS), Some(bytes)) = (markdown_chars, raw_bytes)
275 && bytes >= THIN_MIN_HTML_BYTES
276 {
277 warnings.push(format!(
278 "thin_content: extracted {chars} characters from {} KB of HTML; the page may be a JavaScript shell or the main content was not selected",
279 bytes / 1024
280 ));
281 }
282 ext.timings = timings;
283 ext.warnings = warnings;
284 meta.pagesift = Some(ext);
285 doc.metadata = meta;
286 doc
287}
288
289pub(super) fn ms(d: Duration) -> f64 {
290 (d.as_secs_f64() * 1000.0 * 100.0).round() / 100.0
291}
292
293pub(super) fn thin_basis_bytes(html: &str, markdown: &str) -> usize {
297 const ENOUGH: usize = 500;
299 if markdown
300 .chars()
301 .filter(|c| !c.is_whitespace())
302 .take(ENOUGH)
303 .count()
304 >= ENOUGH
305 {
306 return html.len();
307 }
308 let lower = html.to_ascii_lowercase();
309 let mut removed = 0usize;
310 for tag in ["style"] {
311 let (open, close) = (format!("<{tag}"), format!("</{tag}"));
312 let mut at = 0;
313 while let Some(i) = lower[at..].find(&open) {
314 let start = at + i;
315 let Some(j) = lower[start..].find(&close) else {
316 removed += lower.len() - start;
317 break;
318 };
319 removed += j;
320 at = start + j + close.len();
321 }
322 }
323 let mut at = 0;
324 while let Some(i) = lower[at..].find("data:") {
325 let start = at + i;
326 let len = lower[start..]
327 .find(['"', '\'', ')', ' ', '>'])
328 .unwrap_or(lower.len() - start);
329 removed += len;
330 at = start + len.max(1);
331 }
332 html.len().saturating_sub(removed)
333}
334
335pub(super) fn json_markdown(content_type: Option<&str>, body: &[u8]) -> Option<String> {
338 const PRETTY_MAX_BYTES: usize = 5 * 1024 * 1024;
340 let kind = content_type?.split(';').next()?.trim().to_ascii_lowercase();
341 if kind != "application/json" && kind != "text/json" && !kind.ends_with("+json") {
342 return None;
343 }
344 let text = std::str::from_utf8(body.strip_prefix(b"\xef\xbb\xbf").unwrap_or(body)).ok()?;
345 let value: serde_json::Value = serde_json::from_str(text).ok()?;
346 let rendered = if text.len() <= PRETTY_MAX_BYTES {
347 serde_json::to_string_pretty(&value).ok()?
348 } else {
349 text.trim().to_string()
350 };
351 let mut fence = String::from("```");
353 while rendered.contains(fence.as_str()) {
354 fence.push('`');
355 }
356 Some(format!("{fence}json\n{rendered}\n{fence}\n"))
357}
358
359pub(super) fn is_pdf(body: &[u8], content_type: Option<&str>) -> bool {
362 body.starts_with(b"%PDF-")
363 || (content_type.is_some_and(|c| c.to_ascii_lowercase().contains("application/pdf"))
364 && !looks_like_markup(body))
365}
366
367pub(super) fn unsupported_content_type(content_type: Option<&str>, body: &[u8]) -> Option<String> {
373 let raw = content_type?;
374 let kind = raw.split(';').next()?.trim().to_ascii_lowercase();
375 if kind.is_empty() {
376 return None;
377 }
378 if kind.starts_with("image/")
379 || kind.starts_with("audio/")
380 || kind.starts_with("video/")
381 || kind.starts_with("font/")
382 {
383 return Some(kind);
384 }
385 if kind == "application/pdf" || body.starts_with(b"%PDF-") {
387 return None;
388 }
389 if kind.starts_with("text/")
390 || kind.contains("html")
391 || kind.contains("xml")
392 || kind.contains("json")
393 || matches!(
394 kind.as_str(),
395 "application/javascript" | "application/x-javascript" | "application/ecmascript"
396 )
397 {
398 return None;
399 }
400 if looks_like_markup(body) {
401 return None;
402 }
403 Some(kind)
404}
405
406fn looks_like_markup(body: &[u8]) -> bool {
407 let head = &body[..body.len().min(1024)];
408 let text = String::from_utf8_lossy(head);
409 let text = text.trim_start_matches('\u{feff}').trim_start();
410 let lower = text.to_ascii_lowercase();
411 lower.starts_with("<!doctype html")
412 || lower.starts_with("<html")
413 || lower.starts_with("<head")
414 || lower.starts_with("<body")
415 || lower.starts_with("<?xml")
416}
417
418pub fn policy_error(e: pagesift_policy::Error) -> ApiError {
419 use pagesift_policy::ErrorCode as P;
420 let code = match e.code() {
421 P::RobotsDisallowed => ErrorCode::RobotsDisallowed,
422 P::AccessBarrier => ErrorCode::AccessBarrier,
423 P::RateLimited => ErrorCode::RateLimited,
424 P::HostCooldown => ErrorCode::HostCooldown,
425 P::Timeout => ErrorCode::Timeout,
426 P::SsrfBlocked => ErrorCode::SsrfBlocked,
427 P::TooLarge => ErrorCode::TooLarge,
428 P::UpstreamError => ErrorCode::UpstreamError,
429 P::UnsupportedOption => ErrorCode::UnsupportedOption,
430 P::InvalidRequest => ErrorCode::InvalidRequest,
431 P::DnsFailure => ErrorCode::DnsFailure,
432 P::NetworkError => ErrorCode::NetworkError,
433 P::TlsCertExpired => ErrorCode::TlsCertExpired,
434 P::TlsHostnameMismatch => ErrorCode::TlsHostnameMismatch,
435 P::TlsUntrustedRoot => ErrorCode::TlsUntrustedRoot,
436 P::TlsError => ErrorCode::TlsError,
437 _ => ErrorCode::Internal,
438 };
439 let retry = e.retry_after().map(|d| d.as_secs().max(1));
440 let mut err = ApiError::new(code, e.to_string());
441 if let Some(s) = retry {
442 err = err.with_retry_after(s);
443 }
444 err
445}
446
447#[cfg(test)]
448mod tests {
449 use super::{is_pdf, json_markdown, thin_basis_bytes, unsupported_content_type};
450
451 #[test]
452 fn thin_basis_ignores_inline_payloads() {
453 let image = format!(
454 "<p>short</p><img src=\"data:image/png;base64,{}\">",
455 "A".repeat(100_000)
456 );
457 assert!(thin_basis_bytes(&image, "short") < 1_000);
458 let style = format!("<STYLE>{}</STYLE><p>x</p>", "y".repeat(50_000));
459 assert!(thin_basis_bytes(&style, "x") < 1_000);
460 let next = format!(
461 "<script id=\"__NEXT_DATA__\">{}</script><div id=\"app\"></div>",
462 "x".repeat(50_000)
463 );
464 assert_eq!(thin_basis_bytes(&next, ""), next.len());
465 let shell = format!("<div>{}</div>", "<span></span>".repeat(5_000));
466 assert_eq!(thin_basis_bytes(&shell, ""), shell.len());
467 let long = "word ".repeat(200);
468 assert_eq!(thin_basis_bytes(&image, &long), image.len());
469 }
470
471 #[test]
472 fn json_responses_become_code_blocks() {
473 let md = json_markdown(
474 Some("application/json; charset=utf-8"),
475 br#"{"html":"<h1>x</h1>","n":[1,2]}"#,
476 )
477 .unwrap();
478 assert!(md.starts_with("```json\n{"), "{md}");
479 assert!(md.contains("\"html\": \"<h1>x</h1>\""), "{md}");
480 assert!(md.ends_with("\n```\n"), "{md}");
481 let md = json_markdown(Some("application/ld+json"), br#"{"a":"```"}"#).unwrap();
482 assert!(md.starts_with("````json"), "{md}");
483 assert_eq!(json_markdown(Some("text/html"), b"{}"), None);
484 assert_eq!(json_markdown(Some("application/json"), b"<html>"), None);
485 }
486
487 #[test]
488 fn rejects_binary_content_types() {
489 for kind in [
490 "image/png",
491 "image/jpeg",
492 "image/svg+xml",
493 "audio/mpeg",
494 "video/mp4",
495 "font/woff2",
496 "application/zip",
497 "application/octet-stream",
498 ] {
499 assert_eq!(
500 unsupported_content_type(Some(kind), b"\x89PNG\r\n"),
501 Some(kind.to_string()),
502 "{kind}"
503 );
504 }
505 }
506
507 #[test]
508 fn accepts_text_and_mislabeled_markup() {
509 for kind in [
510 "text/html; charset=utf-8",
511 "text/plain",
512 "application/xhtml+xml",
513 "application/json",
514 "application/rss+xml",
515 "application/pdf",
516 ] {
517 assert_eq!(
518 unsupported_content_type(Some(kind), b"anything"),
519 None,
520 "{kind}"
521 );
522 }
523 assert_eq!(
524 unsupported_content_type(Some("application/octet-stream"), b"<!doctype html><p>x"),
525 None
526 );
527 assert_eq!(
528 unsupported_content_type(Some("application/octet-stream"), b" <html><p>x"),
529 None
530 );
531 assert_eq!(unsupported_content_type(None, b"\x89PNG"), None);
532 for kind in ["application/octet-stream", "binary/octet-stream"] {
533 assert_eq!(
534 unsupported_content_type(Some(kind), b"%PDF-1.7\n1 0 obj"),
535 None
536 );
537 assert!(is_pdf(b"%PDF-1.7\n1 0 obj", Some(kind)));
538 }
539 let pdf = Some("application/pdf");
541 assert!(!is_pdf(
542 b"<!DOCTYPE html><html><body>Access denied</body></html>",
543 pdf
544 ));
545 assert!(!is_pdf(b"\n <html><body>x</body></html>", pdf));
546 assert!(is_pdf(b"junk before the header %PDF-1.4", pdf));
547 assert!(is_pdf(b"%PDF-1.4", Some("text/html")));
548 assert_eq!(
549 unsupported_content_type(Some("application/octet-stream"), b"\x00\x01binary"),
550 Some("application/octet-stream".into())
551 );
552 }
553}