Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add a pluggable post-download `ContentProcessor` registry and built-in
`PdfProcessor` that extracts Markdown from text-based PDFs with local
`pdf-inspector` processing and explicit OCR guidance for unsupported pages.
- Add a built-in `OfficeProcessor` that converts Word (DOCX), PowerPoint (PPTX)
and spreadsheet (XLSX, XLSM, XLS, ODS) documents to Markdown locally, with
zip-bomb limits checked before any part is inflated.
- Discover agent-facing resources from HTTP and HTML declarations plus bounded
conventional probes, including `llms.txt`, `auth.md`, OAuth metadata, MCP
server cards, A2A agent cards, and Agent Skills indexes. Markdown responses
include a compact navigation appendix.

### Changed

- Markdown requests now ask for `text/markdown` first in the `Accept` header, so
sites that serve Markdown on request return it instead of HTML.
- `ContentProcessorInput` now includes the requested output format and HTML
content focus. Custom processors constructing this public input must populate
the new fields.
Expand Down
125 changes: 118 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ tower = { version = "0.5", features = ["util"] }

# Binary document extraction
pdf-inspector = "0.1.7"
calamine = { version = "0.36", default-features = false }
zip = { version = "8", default-features = false, features = ["deflate"] }
quick-xml = "0.41"

# Platform filesystem flags
libc = "0.2"
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ AI-friendly web content fetching tool designed for LLM consumption. Rust library
- **Agent content focus** - Optional low-noise extraction mode for AI agents
- **Crawl discovery** - Optional bounded same-origin page discovery for AI agents
- **HTML-to-Text** - Plain text extraction with clean formatting
- **Content processors** - Post-download extraction for text PDFs, with an extensible registry
- **Content processors** - Post-download extraction for text PDFs and Office documents, with an extensible registry
- **Binary detection** - Returns metadata only for unsupported binary formats
- **Timeout handling** - 1s first-byte, 30s body with partial content on timeout
- **Safety limits** - 10 MB default decompressed body cap with truncation
Expand Down Expand Up @@ -49,6 +49,8 @@ requests.
- `PdfProcessor` - text-based PDF classification and Markdown extraction via
[`pdf-inspector`](https://github.com/firecrawl/pdf-inspector); scanned or
image-only pages are reported as requiring OCR
- `OfficeProcessor` - Word (DOCX), PowerPoint (PPTX) and spreadsheet (XLSX, XLS,
ODS) documents to Markdown, parsed locally with zip-bomb limits

Custom processors implement `ContentProcessor` and can be registered in a
`ContentProcessorRegistry`. Pass that registry to
Expand Down Expand Up @@ -339,10 +341,14 @@ body limit and converted to Markdown. PDF parsing runs locally; no OCR service o
additional network request is used. Scanned/image-only PDFs report `use_ocr` as the
suggested next action.

Office documents are converted the same way, locally: Word headings, lists and
tables, one section per PowerPoint slide, and one Markdown table per spreadsheet
sheet (DOCX, PPTX, XLSX, XLS, ODS). Legacy DOC and PPT files are not supported.

Other binary content returns metadata only:
- Images, audio, video, fonts
- Archives (zip, tar, rar, 7z)
- Office documents
- Legacy Word and PowerPoint files (DOC, PPT)

### HTML Conversion

Expand Down
3 changes: 3 additions & 0 deletions crates/fetchkit/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ bytes = { workspace = true }
async-trait = { workspace = true }
tower = { workspace = true }
pdf-inspector = { workspace = true }
calamine = { workspace = true }
zip = { workspace = true }
quick-xml = { workspace = true }
libc = { workspace = true }
rakers = { workspace = true, optional = true }

Expand Down
27 changes: 16 additions & 11 deletions crates/fetchkit/src/content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ use crate::convert::{
extract_headings, extract_metadata, extract_readable_content, filter_excessive_newlines,
html_to_text, strip_boilerplate, BuiltinHtmlToMarkdownConverter, HtmlToMarkdownConverter,
};
use crate::office::OfficeProcessor;
use crate::{PageMetadata, PageQuality};

// pdf-inspector uses Rayon internally; bound concurrent documents so batch fetches
// cannot multiply parser thread pools without limit.
static PDF_PROCESSING_LIMIT: LazyLock<Arc<Semaphore>> =
// pdf-inspector uses Rayon internally, and every document parser is CPU-bound; bound
// concurrent documents (PDF and Office alike) so batch fetches cannot multiply parser
// work without limit.
pub(crate) static DOCUMENT_PROCESSING_LIMIT: LazyLock<Arc<Semaphore>> =
LazyLock::new(|| Arc::new(Semaphore::new(2)));

/// Bounded response bytes and metadata passed to a [`ContentProcessor`].
Expand Down Expand Up @@ -147,6 +149,7 @@ impl ContentProcessorRegistry {
pub fn with_defaults() -> Self {
let mut registry = Self::new();
registry.register(Box::new(PdfProcessor));
registry.register(Box::new(OfficeProcessor));
registry.register(Box::new(HtmlProcessor::default()));
registry
}
Expand Down Expand Up @@ -271,23 +274,25 @@ impl ContentProcessor for HtmlProcessor {
/// Extracts native text PDFs as Markdown using `pdf-inspector`.
pub struct PdfProcessor;

async fn run_pdf_task<T, F>(limit: Arc<Semaphore>, task: F) -> Result<T, ContentProcessorError>
pub(crate) async fn run_document_task<T, F>(
limit: Arc<Semaphore>,
task: F,
) -> Result<T, ContentProcessorError>
where
T: Send + 'static,
F: FnOnce() -> T + Send + 'static,
{
let permit = limit
.acquire_owned()
.await
.map_err(|error| ContentProcessorError(format!("PDF processing unavailable: {error}")))?;
let permit = limit.acquire_owned().await.map_err(|error| {
ContentProcessorError(format!("document processing unavailable: {error}"))
})?;
tokio::task::spawn_blocking(move || {
// Keep the permit in the blocking task: cancelling its async caller must not
// admit another parse while this non-cancellable work is still running.
let _permit = permit;
task()
})
.await
.map_err(|error| ContentProcessorError(format!("PDF processor task failed: {error}")))
.map_err(|error| ContentProcessorError(format!("document processor task failed: {error}")))
}

#[async_trait]
Expand Down Expand Up @@ -315,7 +320,7 @@ impl ContentProcessor for PdfProcessor {
&self,
input: ContentProcessorInput,
) -> Result<ProcessedContent, ContentProcessorError> {
let result = run_pdf_task(Arc::clone(&PDF_PROCESSING_LIMIT), move || {
let result = run_document_task(Arc::clone(&DOCUMENT_PROCESSING_LIMIT), move || {
process_pdf_mem(&input.body)
})
.await?
Expand Down Expand Up @@ -445,7 +450,7 @@ mod tests {
let limit = Arc::new(Semaphore::new(1));
let (started_tx, started_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel();
let handle = tokio::spawn(run_pdf_task(Arc::clone(&limit), move || {
let handle = tokio::spawn(run_document_task(Arc::clone(&limit), move || {
started_tx.send(()).unwrap();
release_rx.recv().unwrap();
}));
Expand Down
Loading
Loading