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.
Files changed (138) hide show
  1. {content_core-2.2.0 → content_core-2.3.0}/.claude-plugin/plugin.json +1 -1
  2. {content_core-2.2.0 → content_core-2.3.0}/.codex-plugin/plugin.json +1 -1
  3. {content_core-2.2.0 → content_core-2.3.0}/AGENTS.md +2 -0
  4. {content_core-2.2.0 → content_core-2.3.0}/CHANGELOG.md +10 -0
  5. {content_core-2.2.0 → content_core-2.3.0}/PKG-INFO +2 -1
  6. {content_core-2.2.0 → content_core-2.3.0}/README.md +1 -0
  7. {content_core-2.2.0 → content_core-2.3.0}/docs/cli.md +7 -0
  8. {content_core-2.2.0 → content_core-2.3.0}/docs/usage.md +31 -0
  9. {content_core-2.2.0 → content_core-2.3.0}/pyproject.toml +1 -1
  10. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/cli.py +7 -0
  11. content_core-2.3.0/src/content_core/common/http.py +131 -0
  12. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/config.py +16 -1
  13. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/extraction.py +3 -4
  14. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/document/docling.py +62 -1
  15. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/url/__init__.py +5 -2
  16. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/url/bs4.py +3 -3
  17. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/url/reddit.py +3 -2
  18. {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_config_file.py +18 -0
  19. content_core-2.3.0/tests/unit/test_docling_vision_api.py +138 -0
  20. content_core-2.3.0/tests/unit/test_guarded_http.py +170 -0
  21. {content_core-2.2.0 → content_core-2.3.0}/uv.lock +1 -1
  22. {content_core-2.2.0 → content_core-2.3.0}/.agent-smith/index.json +0 -0
  23. {content_core-2.2.0 → content_core-2.3.0}/.agents/plugins/marketplace.json +0 -0
  24. {content_core-2.2.0 → content_core-2.3.0}/.claude-plugin/marketplace.json +0 -0
  25. {content_core-2.2.0 → content_core-2.3.0}/.gitattributes +0 -0
  26. {content_core-2.2.0 → content_core-2.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  27. {content_core-2.2.0 → content_core-2.3.0}/.github/workflows/claude-code-review.yml +0 -0
  28. {content_core-2.2.0 → content_core-2.3.0}/.github/workflows/claude.yml +0 -0
  29. {content_core-2.2.0 → content_core-2.3.0}/.github/workflows/create-tag.yml +0 -0
  30. {content_core-2.2.0 → content_core-2.3.0}/.github/workflows/package.yml +0 -0
  31. {content_core-2.2.0 → content_core-2.3.0}/.github/workflows/publish.yml +0 -0
  32. {content_core-2.2.0 → content_core-2.3.0}/.github/workflows/test.yml +0 -0
  33. {content_core-2.2.0 → content_core-2.3.0}/.gitignore +0 -0
  34. {content_core-2.2.0 → content_core-2.3.0}/.maintainer/PROFILE.md +0 -0
  35. {content_core-2.2.0 → content_core-2.3.0}/.maintainer/README.md +0 -0
  36. {content_core-2.2.0 → content_core-2.3.0}/.maintainer/gotchas.md +0 -0
  37. {content_core-2.2.0 → content_core-2.3.0}/.maintainer/profile.toml +0 -0
  38. {content_core-2.2.0 → content_core-2.3.0}/.maintainer/release/runbook.md +0 -0
  39. {content_core-2.2.0 → content_core-2.3.0}/.maintainer/release/test-matrix.md +0 -0
  40. {content_core-2.2.0 → content_core-2.3.0}/.python-version +0 -0
  41. {content_core-2.2.0 → content_core-2.3.0}/ARCHITECTURE.md +0 -0
  42. {content_core-2.2.0 → content_core-2.3.0}/CLAUDE.md +0 -0
  43. {content_core-2.2.0 → content_core-2.3.0}/CONTRIBUTING.md +0 -0
  44. {content_core-2.2.0 → content_core-2.3.0}/LICENSE +0 -0
  45. {content_core-2.2.0 → content_core-2.3.0}/Makefile +0 -0
  46. {content_core-2.2.0 → content_core-2.3.0}/REFACTOR.md +0 -0
  47. {content_core-2.2.0 → content_core-2.3.0}/TEST_PLAN.md +0 -0
  48. {content_core-2.2.0 → content_core-2.3.0}/docs/mcp.md +0 -0
  49. {content_core-2.2.0 → content_core-2.3.0}/docs/processors.md +0 -0
  50. {content_core-2.2.0 → content_core-2.3.0}/examples/main.py +0 -0
  51. {content_core-2.2.0 → content_core-2.3.0}/prompts/content/cleanup.jinja +0 -0
  52. {content_core-2.2.0 → content_core-2.3.0}/prompts/content/summarize.jinja +0 -0
  53. {content_core-2.2.0 → content_core-2.3.0}/skills/content-core/SKILL.md +0 -0
  54. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/__init__.py +0 -0
  55. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/common/__init__.py +0 -0
  56. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/common/exceptions.py +0 -0
  57. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/common/messages.py +0 -0
  58. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/common/retry.py +0 -0
  59. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/common/state.py +0 -0
  60. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/common/types.py +0 -0
  61. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/content/__init__.py +0 -0
  62. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/content/extraction/__init__.py +0 -0
  63. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/content/identification/__init__.py +0 -0
  64. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/content/identification/file_detector.py +0 -0
  65. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/content/summary/__init__.py +0 -0
  66. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/content/summary/core.py +0 -0
  67. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/logging.py +0 -0
  68. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/mcp/__init__.py +0 -0
  69. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/mcp/server.py +0 -0
  70. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/models.py +0 -0
  71. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/document/__init__.py +0 -0
  72. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/document/docx.py +0 -0
  73. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/document/epub.py +0 -0
  74. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/document/odf.py +0 -0
  75. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/document/pdf.py +0 -0
  76. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/document/pptx.py +0 -0
  77. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/document/xlsx.py +0 -0
  78. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/media/__init__.py +0 -0
  79. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/media/audio.py +0 -0
  80. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/media/video.py +0 -0
  81. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/text.py +0 -0
  82. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/url/crawl4ai.py +0 -0
  83. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/url/firecrawl.py +0 -0
  84. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/url/jina.py +0 -0
  85. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/processors/url/youtube.py +0 -0
  86. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/py.typed +0 -0
  87. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/templated_message.py +0 -0
  88. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/tools/__init__.py +0 -0
  89. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/tools/extract.py +0 -0
  90. {content_core-2.2.0 → content_core-2.3.0}/src/content_core/tools/summarize.py +0 -0
  91. {content_core-2.2.0 → content_core-2.3.0}/tests/conftest.py +0 -0
  92. {content_core-2.2.0 → content_core-2.3.0}/tests/e2e/__init__.py +0 -0
  93. {content_core-2.2.0 → content_core-2.3.0}/tests/e2e/test_docling.py +0 -0
  94. {content_core-2.2.0 → content_core-2.3.0}/tests/e2e/test_media.py +0 -0
  95. {content_core-2.2.0 → content_core-2.3.0}/tests/e2e/test_remote.py +0 -0
  96. {content_core-2.2.0 → content_core-2.3.0}/tests/e2e/test_url_engines.py +0 -0
  97. {content_core-2.2.0 → content_core-2.3.0}/tests/e2e/test_youtube.py +0 -0
  98. {content_core-2.2.0 → content_core-2.3.0}/tests/input_content/file.docx +0 -0
  99. {content_core-2.2.0 → content_core-2.3.0}/tests/input_content/file.epub +0 -0
  100. {content_core-2.2.0 → content_core-2.3.0}/tests/input_content/file.md +0 -0
  101. {content_core-2.2.0 → content_core-2.3.0}/tests/input_content/file.mp3 +0 -0
  102. {content_core-2.2.0 → content_core-2.3.0}/tests/input_content/file.mp4 +0 -0
  103. {content_core-2.2.0 → content_core-2.3.0}/tests/input_content/file.odp +0 -0
  104. {content_core-2.2.0 → content_core-2.3.0}/tests/input_content/file.ods +0 -0
  105. {content_core-2.2.0 → content_core-2.3.0}/tests/input_content/file.odt +0 -0
  106. {content_core-2.2.0 → content_core-2.3.0}/tests/input_content/file.pdf +0 -0
  107. {content_core-2.2.0 → content_core-2.3.0}/tests/input_content/file.pptx +0 -0
  108. {content_core-2.2.0 → content_core-2.3.0}/tests/input_content/file.txt +0 -0
  109. {content_core-2.2.0 → content_core-2.3.0}/tests/input_content/file.xlsx +0 -0
  110. {content_core-2.2.0 → content_core-2.3.0}/tests/input_content/new_pdf.pdf +0 -0
  111. {content_core-2.2.0 → content_core-2.3.0}/tests/integration/conftest.py +0 -0
  112. {content_core-2.2.0 → content_core-2.3.0}/tests/integration/test_cli_v2.py +0 -0
  113. {content_core-2.2.0 → content_core-2.3.0}/tests/integration/test_extraction.py +0 -0
  114. {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_audio_concurrency.py +0 -0
  115. {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_cli.py +0 -0
  116. {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_config_v2.py +0 -0
  117. {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_crawl4ai_docker.py +0 -0
  118. {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_docling_extraction.py +0 -0
  119. {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_epub_extraction.py +0 -0
  120. {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_exceptions.py +0 -0
  121. {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_file_detector.py +0 -0
  122. {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_file_detector_critical.py +0 -0
  123. {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_file_detector_performance.py +0 -0
  124. {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_html_file_extraction.py +0 -0
  125. {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_logging.py +0 -0
  126. {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_mcp_server_version.py +0 -0
  127. {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_mcp_v2.py +0 -0
  128. {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_media_pipeline.py +0 -0
  129. {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_models_v2.py +0 -0
  130. {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_office_extraction.py +0 -0
  131. {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_pdf_extraction.py +0 -0
  132. {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_pdf_helpers.py +0 -0
  133. {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_reddit_extraction.py +0 -0
  134. {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_retry.py +0 -0
  135. {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_routing.py +0 -0
  136. {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_text_processing.py +0 -0
  137. {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_url_engine_select.py +0 -0
  138. {content_core-2.2.0 → content_core-2.3.0}/tests/unit/test_youtube_parsing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "content-core",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "Extract text content from URLs, documents, YouTube videos, Reddit posts, and audio/video files — via CLI (uvx) or MCP.",
5
5
  "author": {
6
6
  "name": "Luis Novo",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "content-core",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "Extract text content from URLs, documents, YouTube videos, Reddit posts, and audio/video files — via CLI (uvx) or MCP.",
5
5
  "skills": "./skills/"
6
6
  }
@@ -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.2.0
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.2.0"
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 aiohttp.ClientSession(trust_env=True) as session:
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 PdfPipelineOptions
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 aiohttp.ClientSession(trust_env=True) as session:
39
- async with session.head(url, timeout=10, allow_redirects=True) as resp:
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 aiohttp.ClientSession(trust_env=True) as session:
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 aiohttp.ClientSession(trust_env=True) as session:
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"]