content-core 2.2.0__tar.gz → 2.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {content_core-2.2.0 → content_core-2.3.0}/.claude-plugin/plugin.json +1 -1
- {content_core-2.2.0 → content_core-2.3.0}/.codex-plugin/plugin.json +1 -1
- {content_core-2.2.0 → content_core-2.3.0}/AGENTS.md +2 -0
- {content_core-2.2.0 → content_core-2.3.0}/CHANGELOG.md +10 -0
- {content_core-2.2.0 → content_core-2.3.0}/PKG-INFO +2 -1
- {content_core-2.2.0 → content_core-2.3.0}/README.md +1 -0
- {content_core-2.2.0 → content_core-2.3.0}/docs/cli.md +7 -0
- {content_core-2.2.0 → content_core-2.3.0}/docs/usage.md +31 -0
- {content_core-2.2.0 → content_core-2.3.0}/pyproject.toml +1 -1
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/cli.py +7 -0
- content_core-2.3.0/src/content_core/common/http.py +131 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/config.py +16 -1
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/extraction.py +3 -4
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/document/docling.py +62 -1
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/url/__init__.py +5 -2
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/url/bs4.py +3 -3
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/url/reddit.py +3 -2
- {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_config_file.py +18 -0
- content_core-2.3.0/tests/unit/test_docling_vision_api.py +138 -0
- content_core-2.3.0/tests/unit/test_guarded_http.py +170 -0
- {content_core-2.2.0 → content_core-2.3.0}/uv.lock +1 -1
- {content_core-2.2.0 → content_core-2.3.0}/.agent-smith/index.json +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/.agents/plugins/marketplace.json +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/.claude-plugin/marketplace.json +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/.gitattributes +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/.github/workflows/claude-code-review.yml +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/.github/workflows/claude.yml +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/.github/workflows/create-tag.yml +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/.github/workflows/package.yml +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/.github/workflows/publish.yml +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/.github/workflows/test.yml +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/.gitignore +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/.maintainer/PROFILE.md +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/.maintainer/README.md +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/.maintainer/gotchas.md +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/.maintainer/profile.toml +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/.maintainer/release/runbook.md +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/.maintainer/release/test-matrix.md +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/.python-version +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/ARCHITECTURE.md +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/CLAUDE.md +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/CONTRIBUTING.md +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/LICENSE +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/Makefile +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/REFACTOR.md +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/TEST_PLAN.md +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/docs/mcp.md +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/docs/processors.md +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/examples/main.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/prompts/content/cleanup.jinja +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/prompts/content/summarize.jinja +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/skills/content-core/SKILL.md +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/__init__.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/common/__init__.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/common/exceptions.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/common/messages.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/common/retry.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/common/state.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/common/types.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/content/__init__.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/content/extraction/__init__.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/content/identification/__init__.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/content/identification/file_detector.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/content/summary/__init__.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/content/summary/core.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/logging.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/mcp/__init__.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/mcp/server.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/models.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/document/__init__.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/document/docx.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/document/epub.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/document/odf.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/document/pdf.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/document/pptx.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/document/xlsx.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/media/__init__.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/media/audio.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/media/video.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/text.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/url/crawl4ai.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/url/firecrawl.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/url/jina.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/url/youtube.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/py.typed +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/templated_message.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/tools/__init__.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/tools/extract.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/src/content_core/tools/summarize.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/conftest.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/e2e/__init__.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/e2e/test_docling.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/e2e/test_media.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/e2e/test_remote.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/e2e/test_url_engines.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/e2e/test_youtube.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/input_content/file.docx +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/input_content/file.epub +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/input_content/file.md +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/input_content/file.mp3 +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/input_content/file.mp4 +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/input_content/file.odp +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/input_content/file.ods +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/input_content/file.odt +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/input_content/file.pdf +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/input_content/file.pptx +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/input_content/file.txt +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/input_content/file.xlsx +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/input_content/new_pdf.pdf +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/integration/conftest.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/integration/test_cli_v2.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/integration/test_extraction.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_audio_concurrency.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_cli.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_config_v2.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_crawl4ai_docker.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_docling_extraction.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_epub_extraction.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_exceptions.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_file_detector.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_file_detector_critical.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_file_detector_performance.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_html_file_extraction.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_logging.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_mcp_server_version.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_mcp_v2.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_media_pipeline.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_models_v2.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_office_extraction.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_pdf_extraction.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_pdf_helpers.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_reddit_extraction.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_retry.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_routing.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_text_processing.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_url_engine_select.py +0 -0
- {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_youtube_parsing.py +0 -0
|
@@ -134,6 +134,8 @@ Key settings: `url_engine`, `document_engine`, `audio_provider`, `audio_model`,
|
|
|
134
134
|
|
|
135
135
|
Docling enrichment flags (`docling_ocr`, `docling_formulas`, `docling_vision`) control OCR, formula extraction, and image/chart processing when `document_engine="docling"`. These are also exposed as CLI flags (`--formulas`, `--pictures`, `--no-ocr`) and MCP parameters.
|
|
136
136
|
|
|
137
|
+
`docling_vision_url` (plus `docling_vision_model`, `_api_key`, `_prompt`, `_max_tokens`, `_timeout`, `_token_param`) sends Docling's image descriptions to an OpenAI-compatible endpoint instead of the inline model. It only applies when `docling_vision` is on; without the URL, nothing is sent over the network.
|
|
138
|
+
|
|
137
139
|
Priority: constructor args > env vars (`CCORE_*`) > config file (`~/.content-core/config.toml`) > defaults
|
|
138
140
|
|
|
139
141
|
## Code Style
|
|
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.3.0] - 2026-11-07
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Docling image descriptions can use any OpenAI-compatible chat completions endpoint (vLLM, Ollama, LM Studio, MLX servers...) instead of Docling's small inline model (#111, building on #104 by PhantombrainM). Set `docling_vision_url` (`CCORE_DOCLING_VISION_URL`), with optional `docling_vision_model`, `docling_vision_api_key`, `docling_vision_prompt`, `docling_vision_max_tokens` (default 400), `docling_vision_timeout` (default 20s) and `docling_vision_token_param` (`max_completion_tokens` or `max_tokens`). Applies only when `docling_vision` is on; without the URL, behavior is unchanged and nothing leaves the machine. Chart data extraction still uses Docling's own model. All settings work from the constructor, env vars and `content-core config set`.
|
|
14
|
+
|
|
15
|
+
## [2.2.1] - 2026-11-03
|
|
16
|
+
|
|
17
|
+
### Security
|
|
18
|
+
- URL fetches made directly by content-core (the `simple` engine, the MIME-type probe, remote file downloads and Reddit posts) refuse link-local and cloud-metadata addresses, including on every redirect hop and for hostnames that resolve to them. Private and loopback addresses are still allowed. A refused URL raises `InvalidInputError` (`BlockedAddressError`).
|
|
19
|
+
|
|
10
20
|
## [2.2.0] - 2026-10-03
|
|
11
21
|
|
|
12
22
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: content-core
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: Extract what matters from any media source. Available as Python Library, macOS Service, CLI and MCP Server
|
|
5
5
|
Author-email: LUIS NOVO <lfnovo@gmail.com>
|
|
6
6
|
License-File: LICENSE
|
|
@@ -285,6 +285,7 @@ Content Core uses `ContentCoreConfig` powered by pydantic-settings. Settings are
|
|
|
285
285
|
| `CCORE_YOUTUBE_LANGUAGES` | Preferred YouTube transcript languages | - |
|
|
286
286
|
| `CCORE_YOUTUBE_COOKIES_FILE` | Path to a Netscape `cookies.txt` for YouTube transcripts (missing file raises `ConfigurationError`) | - |
|
|
287
287
|
| `CCORE_YOUTUBE_PROXY` | Proxy URL for YouTube transcripts, e.g. `http://user:pass@host:port` | - |
|
|
288
|
+
| `CCORE_DOCLING_VISION_URL` | OpenAI-compatible endpoint (vLLM, Ollama, LM Studio...) for Docling image descriptions; unset keeps Docling's inline model. Related: `CCORE_DOCLING_VISION_MODEL`, `_API_KEY`, `_PROMPT`, `_MAX_TOKENS`, `_TIMEOUT`, `_TOKEN_PARAM` ([details](docs/usage.md#image-descriptions-via-an-openai-compatible-endpoint)) | - |
|
|
288
289
|
|
|
289
290
|
API keys for external services are set via their standard environment variables (e.g., `OPENAI_API_KEY`, `FIRECRAWL_API_KEY`, `JINA_API_KEY`).
|
|
290
291
|
|
|
@@ -240,6 +240,7 @@ Content Core uses `ContentCoreConfig` powered by pydantic-settings. Settings are
|
|
|
240
240
|
| `CCORE_YOUTUBE_LANGUAGES` | Preferred YouTube transcript languages | - |
|
|
241
241
|
| `CCORE_YOUTUBE_COOKIES_FILE` | Path to a Netscape `cookies.txt` for YouTube transcripts (missing file raises `ConfigurationError`) | - |
|
|
242
242
|
| `CCORE_YOUTUBE_PROXY` | Proxy URL for YouTube transcripts, e.g. `http://user:pass@host:port` | - |
|
|
243
|
+
| `CCORE_DOCLING_VISION_URL` | OpenAI-compatible endpoint (vLLM, Ollama, LM Studio...) for Docling image descriptions; unset keeps Docling's inline model. Related: `CCORE_DOCLING_VISION_MODEL`, `_API_KEY`, `_PROMPT`, `_MAX_TOKENS`, `_TIMEOUT`, `_TOKEN_PARAM` ([details](docs/usage.md#image-descriptions-via-an-openai-compatible-endpoint)) | - |
|
|
243
244
|
|
|
244
245
|
API keys for external services are set via their standard environment variables (e.g., `OPENAI_API_KEY`, `FIRECRAWL_API_KEY`, `JINA_API_KEY`).
|
|
245
246
|
|
|
@@ -162,6 +162,13 @@ content-core config delete llm_provider
|
|
|
162
162
|
| `docling_ocr` | Enable OCR for scanned PDFs | `true` |
|
|
163
163
|
| `docling_output_format` | Docling output format | `markdown` |
|
|
164
164
|
| `docling_vision` | Enable image description + chart data extraction | `false` |
|
|
165
|
+
| `docling_vision_url` | OpenAI-compatible chat completions URL for image descriptions (see [usage](usage.md#image-descriptions-via-an-openai-compatible-endpoint)) | — |
|
|
166
|
+
| `docling_vision_model` | Model name sent to `docling_vision_url` | — |
|
|
167
|
+
| `docling_vision_api_key` | Bearer token for `docling_vision_url` | — |
|
|
168
|
+
| `docling_vision_prompt` | Prompt for image descriptions | Docling's prompt |
|
|
169
|
+
| `docling_vision_max_tokens` | Token limit per image description | `400` |
|
|
170
|
+
| `docling_vision_timeout` | Request timeout in seconds | `20` |
|
|
171
|
+
| `docling_vision_token_param` | Token limit field name (`max_completion_tokens`, `max_tokens`) | `max_completion_tokens` |
|
|
165
172
|
| `document_engine` | Document extraction engine (`auto`, `simple`, `docling`) | `auto` |
|
|
166
173
|
| `firecrawl_api_url` | Firecrawl API URL | `https://api.firecrawl.dev` |
|
|
167
174
|
| `firecrawl_proxy` | Firecrawl proxy mode (`auto`, `basic`, `stealth`) | `auto` |
|
|
@@ -476,6 +476,37 @@ extract_content(file_path="paper.pdf", engine="docling", formulas=true, pictures
|
|
|
476
476
|
|
|
477
477
|
> **Note:** Enrichment flags are only applied when `document_engine="docling"`. A warning is logged if flags are set without the Docling engine.
|
|
478
478
|
|
|
479
|
+
### Image descriptions via an OpenAI-compatible endpoint
|
|
480
|
+
|
|
481
|
+
By default, `docling_vision` describes images with Docling's small inline model, which often produces vague descriptions. You can send image descriptions to any OpenAI-compatible `/v1/chat/completions` endpoint instead (vLLM, Ollama, LM Studio, MLX servers, a hosted API...). Setting `docling_vision_url` is the switch: without it, nothing leaves the machine and the other settings below are ignored. `docling_vision` must still be on.
|
|
482
|
+
|
|
483
|
+
| Setting | Env var | What it does | Default |
|
|
484
|
+
|---------|---------|-------------|---------|
|
|
485
|
+
| `docling_vision_url` | `CCORE_DOCLING_VISION_URL` | Chat completions endpoint | — (inline model) |
|
|
486
|
+
| `docling_vision_model` | `CCORE_DOCLING_VISION_MODEL` | Model name sent in the request | — (server default) |
|
|
487
|
+
| `docling_vision_api_key` | `CCORE_DOCLING_VISION_API_KEY` | Bearer token; no `Authorization` header when unset | — |
|
|
488
|
+
| `docling_vision_prompt` | `CCORE_DOCLING_VISION_PROMPT` | Prompt sent with each image, e.g. to describe in another language | Docling's prompt |
|
|
489
|
+
| `docling_vision_max_tokens` | `CCORE_DOCLING_VISION_MAX_TOKENS` | Token limit per image | `400` |
|
|
490
|
+
| `docling_vision_timeout` | `CCORE_DOCLING_VISION_TIMEOUT` | Request timeout in seconds | `20` |
|
|
491
|
+
| `docling_vision_token_param` | `CCORE_DOCLING_VISION_TOKEN_PARAM` | Name of the token limit field: `max_completion_tokens`, or `max_tokens` for servers that don't accept the newer field | `max_completion_tokens` |
|
|
492
|
+
|
|
493
|
+
```bash
|
|
494
|
+
export CCORE_DOCLING_VISION_URL=http://localhost:11434/v1/chat/completions
|
|
495
|
+
export CCORE_DOCLING_VISION_MODEL=qwen2.5vl:7b
|
|
496
|
+
content-core extract --engine docling --pictures report.pdf
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
```python
|
|
500
|
+
config = ContentCoreConfig(
|
|
501
|
+
document_engine="docling",
|
|
502
|
+
docling_vision=True,
|
|
503
|
+
docling_vision_url="http://localhost:11434/v1/chat/completions",
|
|
504
|
+
docling_vision_model="qwen2.5vl:7b",
|
|
505
|
+
)
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
Only image descriptions go to the endpoint. Chart data extraction keeps using Docling's own model, because it depends on special output tokens that a general-purpose vision model does not produce.
|
|
509
|
+
|
|
479
510
|
## Proxy Configuration
|
|
480
511
|
|
|
481
512
|
Content Core uses standard HTTP proxy environment variables:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "content-core"
|
|
3
|
-
version = "2.
|
|
3
|
+
version = "2.3.0"
|
|
4
4
|
description = "Extract what matters from any media source. Available as Python Library, macOS Service, CLI and MCP Server"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
homepage = "https://github.com/lfnovo/content-core"
|
|
@@ -130,6 +130,13 @@ def config():
|
|
|
130
130
|
docling_formulas Enable formula extraction (default: false)
|
|
131
131
|
docling_ocr Enable OCR for scanned PDFs (default: true)
|
|
132
132
|
docling_vision Enable image description + chart extraction (default: false)
|
|
133
|
+
docling_vision_url OpenAI-compatible chat completions URL for image descriptions (default: none, inline model)
|
|
134
|
+
docling_vision_model Model name sent to docling_vision_url
|
|
135
|
+
docling_vision_api_key Bearer token for docling_vision_url
|
|
136
|
+
docling_vision_prompt Prompt for image descriptions (default: docling's)
|
|
137
|
+
docling_vision_max_tokens Token limit per image description (default: 400)
|
|
138
|
+
docling_vision_timeout Request timeout in seconds (default: 20)
|
|
139
|
+
docling_vision_token_param Token limit field: max_completion_tokens or max_tokens (default: max_completion_tokens)
|
|
133
140
|
"""
|
|
134
141
|
pass
|
|
135
142
|
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""HTTP sessions for fetching user-supplied URLs.
|
|
2
|
+
|
|
3
|
+
Callers (Open Notebook, the MCP server) pass URLs they did not choose, and the
|
|
4
|
+
fetch runs from inside their network. ``guarded_session()`` refuses to connect
|
|
5
|
+
to link-local addresses (169.254.0.0/16, fe80::/10, which includes cloud
|
|
6
|
+
metadata services such as 169.254.169.254) and to AWS's IPv6 metadata address,
|
|
7
|
+
on the first request and on every redirect hop:
|
|
8
|
+
|
|
9
|
+
- hostnames are checked by the connector's resolver, on the addresses the
|
|
10
|
+
connection will actually use (no re-resolution between check and connect);
|
|
11
|
+
- IP literals skip the resolver, so they are checked before the request and
|
|
12
|
+
on each redirect ``Location``.
|
|
13
|
+
|
|
14
|
+
Private and loopback addresses stay allowed: self-hosted deployments
|
|
15
|
+
legitimately fetch from their own network. When the session goes through an
|
|
16
|
+
HTTP(S) proxy (``trust_env``), the target hostname is resolved by the proxy,
|
|
17
|
+
so only IP literals are checked locally.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import ipaddress
|
|
21
|
+
import socket
|
|
22
|
+
from types import SimpleNamespace
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import aiohttp
|
|
26
|
+
from aiohttp.abc import AbstractResolver, ResolveResult
|
|
27
|
+
from yarl import URL
|
|
28
|
+
|
|
29
|
+
from content_core.common.exceptions import InvalidInputError
|
|
30
|
+
|
|
31
|
+
# AWS IMDSv6 is a Unique Local Address, not link-local.
|
|
32
|
+
_AWS_IMDS_V6 = ipaddress.ip_address("fd00:ec2::254")
|
|
33
|
+
|
|
34
|
+
IPAddress = ipaddress.IPv4Address | ipaddress.IPv6Address
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class BlockedAddressError(InvalidInputError):
|
|
38
|
+
"""Raised when a URL, or a redirect it leads to, targets a blocked address."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_blocked_address(ip: IPAddress) -> bool:
|
|
42
|
+
"""True for link-local and cloud-metadata addresses."""
|
|
43
|
+
if ip.is_link_local:
|
|
44
|
+
return True
|
|
45
|
+
if isinstance(ip, ipaddress.IPv6Address):
|
|
46
|
+
mapped = ip.ipv4_mapped
|
|
47
|
+
if mapped is not None and mapped.is_link_local:
|
|
48
|
+
return True
|
|
49
|
+
# Compare without scope id so fd00:ec2::254%eth0 is caught too.
|
|
50
|
+
if int(ip) == int(_AWS_IMDS_V6):
|
|
51
|
+
return True
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _parse_ip(host: str) -> IPAddress | None:
|
|
56
|
+
try:
|
|
57
|
+
return ipaddress.ip_address(host.strip("[]").split("%", 1)[0])
|
|
58
|
+
except ValueError:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def check_url_host(url: str | URL) -> None:
|
|
63
|
+
"""Raise BlockedAddressError if ``url``'s host is a blocked IP literal."""
|
|
64
|
+
host = URL(str(url)).host or ""
|
|
65
|
+
ip = _parse_ip(host)
|
|
66
|
+
if ip is not None and is_blocked_address(ip):
|
|
67
|
+
raise BlockedAddressError(
|
|
68
|
+
f"Refusing to fetch {url}: link-local and cloud-metadata "
|
|
69
|
+
"addresses are not allowed"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class GuardedResolver(AbstractResolver):
|
|
74
|
+
"""Resolver that drops blocked addresses and fails if none remain."""
|
|
75
|
+
|
|
76
|
+
def __init__(self) -> None:
|
|
77
|
+
self._resolver = aiohttp.DefaultResolver()
|
|
78
|
+
|
|
79
|
+
async def resolve(
|
|
80
|
+
self, host: str, port: int = 0, family: socket.AddressFamily = socket.AF_INET
|
|
81
|
+
) -> list[ResolveResult]:
|
|
82
|
+
results = await self._resolver.resolve(host, port, family)
|
|
83
|
+
allowed = [
|
|
84
|
+
r
|
|
85
|
+
for r in results
|
|
86
|
+
if not ((ip := _parse_ip(r["host"])) and is_blocked_address(ip))
|
|
87
|
+
]
|
|
88
|
+
if not allowed:
|
|
89
|
+
raise BlockedAddressError(
|
|
90
|
+
f"Refusing to fetch from {host}: it resolves to a link-local or "
|
|
91
|
+
"cloud-metadata address"
|
|
92
|
+
)
|
|
93
|
+
return allowed
|
|
94
|
+
|
|
95
|
+
async def close(self) -> None:
|
|
96
|
+
await self._resolver.close()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def _on_request_redirect(
|
|
100
|
+
session: aiohttp.ClientSession,
|
|
101
|
+
ctx: SimpleNamespace,
|
|
102
|
+
params: aiohttp.TraceRequestRedirectParams,
|
|
103
|
+
) -> None:
|
|
104
|
+
location = params.response.headers.get("Location") or params.response.headers.get(
|
|
105
|
+
"URI"
|
|
106
|
+
)
|
|
107
|
+
if location:
|
|
108
|
+
check_url_host(params.response.url.join(URL(location)))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def guarded_session(**kwargs: Any) -> aiohttp.ClientSession:
|
|
112
|
+
"""A ClientSession that refuses blocked addresses on every hop.
|
|
113
|
+
|
|
114
|
+
Use it for any request whose URL comes from the caller. The initial URL's
|
|
115
|
+
IP literal is checked by ``guarded_request_url``; pass the URL through it
|
|
116
|
+
before the request.
|
|
117
|
+
"""
|
|
118
|
+
trace = aiohttp.TraceConfig()
|
|
119
|
+
trace.on_request_redirect.append(_on_request_redirect)
|
|
120
|
+
kwargs.setdefault("trust_env", True)
|
|
121
|
+
return aiohttp.ClientSession(
|
|
122
|
+
connector=aiohttp.TCPConnector(resolver=GuardedResolver()),
|
|
123
|
+
trace_configs=[trace],
|
|
124
|
+
**kwargs,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def guarded_request_url(url: str) -> str:
|
|
129
|
+
"""Check ``url``'s host before the first request; returns it unchanged."""
|
|
130
|
+
check_url_host(url)
|
|
131
|
+
return url
|
|
@@ -94,6 +94,19 @@ class ContentCoreConfig(BaseSettings):
|
|
|
94
94
|
docling_formulas: bool = False
|
|
95
95
|
docling_vision: bool = False
|
|
96
96
|
|
|
97
|
+
# Docling picture description via an OpenAI-compatible endpoint (vLLM,
|
|
98
|
+
# LM Studio, Ollama, ...). Only used when docling_vision is on; with no
|
|
99
|
+
# URL, docling's inline default model describes the pictures.
|
|
100
|
+
docling_vision_url: Optional[str] = None
|
|
101
|
+
docling_vision_model: Optional[str] = None
|
|
102
|
+
docling_vision_api_key: Optional[str] = None
|
|
103
|
+
docling_vision_prompt: Optional[str] = None # None keeps docling's prompt
|
|
104
|
+
docling_vision_max_tokens: int = Field(default=400, ge=1)
|
|
105
|
+
docling_vision_timeout: float = Field(default=20.0, gt=0)
|
|
106
|
+
docling_vision_token_param: Literal["max_completion_tokens", "max_tokens"] = (
|
|
107
|
+
"max_completion_tokens"
|
|
108
|
+
)
|
|
109
|
+
|
|
97
110
|
@classmethod
|
|
98
111
|
def settings_customise_sources(
|
|
99
112
|
cls,
|
|
@@ -155,7 +168,7 @@ def _write_config_file(data: dict) -> None:
|
|
|
155
168
|
for key, value in sorted(data.items()):
|
|
156
169
|
if isinstance(value, bool):
|
|
157
170
|
lines.append(f"{key} = {str(value).lower()}")
|
|
158
|
-
elif isinstance(value, int):
|
|
171
|
+
elif isinstance(value, (int, float)):
|
|
159
172
|
lines.append(f"{key} = {value}")
|
|
160
173
|
elif isinstance(value, list):
|
|
161
174
|
items = ", ".join(f'"{_escape_toml_string(str(v))}"' for v in value)
|
|
@@ -206,6 +219,8 @@ def config_set(key: str, value: str) -> None:
|
|
|
206
219
|
annotation = field.annotation
|
|
207
220
|
if annotation is int or (hasattr(annotation, "__origin__") and annotation is int):
|
|
208
221
|
data[key] = int(value)
|
|
222
|
+
elif annotation is float:
|
|
223
|
+
data[key] = float(value)
|
|
209
224
|
elif annotation is bool:
|
|
210
225
|
token = value.lower()
|
|
211
226
|
if token not in _TRUE_TOKENS | _FALSE_TOKENS:
|
|
@@ -5,8 +5,6 @@ import os
|
|
|
5
5
|
import tempfile
|
|
6
6
|
from urllib.parse import urlparse
|
|
7
7
|
|
|
8
|
-
import aiohttp
|
|
9
|
-
|
|
10
8
|
from content_core.common.exceptions import (
|
|
11
9
|
ConfigurationError,
|
|
12
10
|
ContentCoreError,
|
|
@@ -15,6 +13,7 @@ from content_core.common.exceptions import (
|
|
|
15
13
|
UnsupportedTypeException,
|
|
16
14
|
)
|
|
17
15
|
from content_core.common.messages import DOCLING_MISSING_MESSAGE
|
|
16
|
+
from content_core.common.http import guarded_request_url, guarded_session
|
|
18
17
|
from content_core.common.retry import retry_download
|
|
19
18
|
from content_core.config import ContentCoreConfig, get_default_config
|
|
20
19
|
from content_core.logging import logger
|
|
@@ -307,8 +306,8 @@ async def _extract_file(
|
|
|
307
306
|
@retry_download()
|
|
308
307
|
async def _fetch_remote_file(url: str) -> tuple:
|
|
309
308
|
"""Download a remote file. Wrapped with retry logic."""
|
|
310
|
-
async with
|
|
311
|
-
async with session.get(url) as resp:
|
|
309
|
+
async with guarded_session() as session:
|
|
310
|
+
async with session.get(guarded_request_url(url)) as resp:
|
|
312
311
|
resp.raise_for_status()
|
|
313
312
|
mime = resp.headers.get("content-type", "").split(";", 1)[0]
|
|
314
313
|
content = await resp.read()
|
|
@@ -2,20 +2,28 @@
|
|
|
2
2
|
Docling-based document extraction processor.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
|
|
5
8
|
from content_core.config import ContentCoreConfig
|
|
6
9
|
from content_core.common.messages import DOCLING_MISSING_MESSAGE
|
|
7
10
|
from content_core.common.state import ExtractionOutput
|
|
11
|
+
from content_core.logging import logger
|
|
8
12
|
|
|
9
13
|
DOCLING_AVAILABLE = False
|
|
10
14
|
try:
|
|
11
15
|
from docling.datamodel.base_models import InputFormat
|
|
12
|
-
from docling.datamodel.pipeline_options import
|
|
16
|
+
from docling.datamodel.pipeline_options import (
|
|
17
|
+
PdfPipelineOptions,
|
|
18
|
+
PictureDescriptionApiOptions,
|
|
19
|
+
)
|
|
13
20
|
from docling.document_converter import DocumentConverter, PdfFormatOption
|
|
14
21
|
|
|
15
22
|
DOCLING_AVAILABLE = True
|
|
16
23
|
except ImportError:
|
|
17
24
|
InputFormat = None # type: ignore
|
|
18
25
|
PdfPipelineOptions = None # type: ignore
|
|
26
|
+
PictureDescriptionApiOptions = None # type: ignore
|
|
19
27
|
PdfFormatOption = None # type: ignore
|
|
20
28
|
|
|
21
29
|
class DocumentConverter: # type: ignore[no-redef]
|
|
@@ -45,14 +53,67 @@ DOCLING_SUPPORTED = {
|
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
|
|
56
|
+
def _picture_description_api_options(
|
|
57
|
+
config: ContentCoreConfig,
|
|
58
|
+
) -> Optional["PictureDescriptionApiOptions"]:
|
|
59
|
+
"""Build the options that send picture description to an OpenAI-compatible
|
|
60
|
+
endpoint, or return None to keep docling's inline default model.
|
|
61
|
+
|
|
62
|
+
``docling_vision_url`` is the switch: without it, nothing leaves the
|
|
63
|
+
machine and the other ``docling_vision_*`` settings are ignored.
|
|
64
|
+
"""
|
|
65
|
+
if not config.docling_vision_url or PictureDescriptionApiOptions is None:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
# The token limit field name differs across OpenAI-compatible servers:
|
|
69
|
+
# newer ones expect max_completion_tokens, older ones only accept
|
|
70
|
+
# max_tokens. docling_vision_token_param selects the name.
|
|
71
|
+
params: dict = {config.docling_vision_token_param: config.docling_vision_max_tokens}
|
|
72
|
+
if config.docling_vision_model:
|
|
73
|
+
params["model"] = config.docling_vision_model
|
|
74
|
+
|
|
75
|
+
# Only send an Authorization header when a key is configured; keyless
|
|
76
|
+
# local servers may reject an empty bearer token.
|
|
77
|
+
headers = {}
|
|
78
|
+
if config.docling_vision_api_key:
|
|
79
|
+
headers["Authorization"] = f"Bearer {config.docling_vision_api_key}"
|
|
80
|
+
|
|
81
|
+
kwargs = {}
|
|
82
|
+
if config.docling_vision_prompt:
|
|
83
|
+
kwargs["prompt"] = config.docling_vision_prompt
|
|
84
|
+
|
|
85
|
+
# Log only scheme and host: the URL may carry credentials in its
|
|
86
|
+
# userinfo or query string.
|
|
87
|
+
endpoint = urlsplit(config.docling_vision_url)
|
|
88
|
+
logger.debug(
|
|
89
|
+
f"Docling picture description via {endpoint.scheme}://{endpoint.hostname} "
|
|
90
|
+
f"(model={config.docling_vision_model or 'server default'})"
|
|
91
|
+
)
|
|
92
|
+
return PictureDescriptionApiOptions(
|
|
93
|
+
url=config.docling_vision_url,
|
|
94
|
+
headers=headers,
|
|
95
|
+
params=params,
|
|
96
|
+
timeout=config.docling_vision_timeout,
|
|
97
|
+
**kwargs,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
48
101
|
async def extract_docling(source: str, config: ContentCoreConfig) -> ExtractionOutput:
|
|
49
102
|
"""Extract content using Docling."""
|
|
50
103
|
if DOCLING_AVAILABLE and PdfPipelineOptions is not None:
|
|
104
|
+
pipeline_kwargs = {}
|
|
105
|
+
api_vision_options = None
|
|
106
|
+
if config.docling_vision:
|
|
107
|
+
api_vision_options = _picture_description_api_options(config)
|
|
108
|
+
if api_vision_options is not None:
|
|
109
|
+
pipeline_kwargs["picture_description_options"] = api_vision_options
|
|
51
110
|
pipeline_options = PdfPipelineOptions(
|
|
52
111
|
do_ocr=config.docling_ocr,
|
|
53
112
|
do_formula_enrichment=config.docling_formulas,
|
|
54
113
|
do_picture_description=config.docling_vision,
|
|
55
114
|
do_chart_extraction=config.docling_vision,
|
|
115
|
+
enable_remote_services=api_vision_options is not None,
|
|
116
|
+
**pipeline_kwargs,
|
|
56
117
|
)
|
|
57
118
|
converter = DocumentConverter(
|
|
58
119
|
format_options={
|
|
@@ -12,6 +12,7 @@ from content_core.common.exceptions import (
|
|
|
12
12
|
NetworkError,
|
|
13
13
|
NotFoundError,
|
|
14
14
|
)
|
|
15
|
+
from content_core.common.http import guarded_request_url, guarded_session
|
|
15
16
|
from content_core.common.retry import retry_url_network
|
|
16
17
|
from content_core.common.types import UrlEngine
|
|
17
18
|
from content_core.config import ContentCoreConfig
|
|
@@ -35,8 +36,10 @@ from content_core.processors.url.crawl4ai import extract_url_crawl4ai
|
|
|
35
36
|
@retry_url_network()
|
|
36
37
|
async def _fetch_url_mime_type(url: str) -> str:
|
|
37
38
|
"""Internal function to fetch URL MIME type - wrapped with retry logic."""
|
|
38
|
-
async with
|
|
39
|
-
async with session.head(
|
|
39
|
+
async with guarded_session() as session:
|
|
40
|
+
async with session.head(
|
|
41
|
+
guarded_request_url(url), timeout=10, allow_redirects=True
|
|
42
|
+
) as resp:
|
|
40
43
|
mime = resp.headers.get("content-type", "").split(";", 1)[0]
|
|
41
44
|
logger.debug(f"MIME type for {url}: {mime}")
|
|
42
45
|
return mime
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import aiohttp
|
|
2
1
|
from bs4 import BeautifulSoup
|
|
3
2
|
from readability import Document
|
|
4
3
|
|
|
4
|
+
from content_core.common.http import guarded_request_url, guarded_session
|
|
5
5
|
from content_core.common.retry import retry_url_network
|
|
6
6
|
from content_core.logging import logger
|
|
7
7
|
|
|
@@ -9,8 +9,8 @@ from content_core.logging import logger
|
|
|
9
9
|
@retry_url_network()
|
|
10
10
|
async def _fetch_url_html(url: str) -> str:
|
|
11
11
|
"""Internal function to fetch URL HTML content - wrapped with retry logic."""
|
|
12
|
-
async with
|
|
13
|
-
async with session.get(url, timeout=10) as response:
|
|
12
|
+
async with guarded_session() as session:
|
|
13
|
+
async with session.get(guarded_request_url(url), timeout=10) as response:
|
|
14
14
|
# Raise ClientResponseError so retry logic can inspect status code
|
|
15
15
|
# (5xx and 429 will be retried, 4xx will not)
|
|
16
16
|
response.raise_for_status()
|
|
@@ -4,6 +4,7 @@ from urllib.parse import urlparse, urlunparse
|
|
|
4
4
|
|
|
5
5
|
import aiohttp
|
|
6
6
|
|
|
7
|
+
from content_core.common.http import guarded_request_url, guarded_session
|
|
7
8
|
from content_core.common.retry import retry_url_network
|
|
8
9
|
from content_core.config import ContentCoreConfig
|
|
9
10
|
from content_core.logging import logger
|
|
@@ -28,9 +29,9 @@ async def _fetch_reddit_json(url: str) -> dict:
|
|
|
28
29
|
clean_url = urlunparse((parsed.scheme, parsed.netloc, clean_path, "", "", ""))
|
|
29
30
|
json_url = clean_url + ".json"
|
|
30
31
|
|
|
31
|
-
async with
|
|
32
|
+
async with guarded_session() as session:
|
|
32
33
|
async with session.get(
|
|
33
|
-
json_url,
|
|
34
|
+
guarded_request_url(json_url),
|
|
34
35
|
headers={"User-Agent": "content-core/2.0"},
|
|
35
36
|
timeout=aiohttp.ClientTimeout(total=15),
|
|
36
37
|
) as resp:
|
|
@@ -54,6 +54,24 @@ class TestConfigSet:
|
|
|
54
54
|
config_set("stt_timeout", "1800")
|
|
55
55
|
assert config_list()["stt_timeout"] == 1800
|
|
56
56
|
|
|
57
|
+
def test_set_float_value(self, config_dir):
|
|
58
|
+
config_set("docling_vision_timeout", "45.5")
|
|
59
|
+
assert config_list()["docling_vision_timeout"] == 45.5
|
|
60
|
+
assert ContentCoreConfig().docling_vision_timeout == 45.5
|
|
61
|
+
|
|
62
|
+
def test_set_docling_vision_endpoint(self, config_dir):
|
|
63
|
+
config_set("docling_vision_url", "http://localhost:1234/v1/chat/completions")
|
|
64
|
+
config_set("docling_vision_max_tokens", "800")
|
|
65
|
+
config_set("docling_vision_token_param", "max_tokens")
|
|
66
|
+
config = ContentCoreConfig()
|
|
67
|
+
assert config.docling_vision_url == "http://localhost:1234/v1/chat/completions"
|
|
68
|
+
assert config.docling_vision_max_tokens == 800
|
|
69
|
+
assert config.docling_vision_token_param == "max_tokens"
|
|
70
|
+
|
|
71
|
+
def test_set_invalid_token_param_raises(self, config_dir):
|
|
72
|
+
with pytest.raises(ValueError, match="max_completion_tokens"):
|
|
73
|
+
config_set("docling_vision_token_param", "max_length")
|
|
74
|
+
|
|
57
75
|
def test_set_list_value(self, config_dir):
|
|
58
76
|
config_set("youtube_languages", "en,pt,fr")
|
|
59
77
|
assert config_list()["youtube_languages"] == ["en", "pt", "fr"]
|