content-core 2.1.0__tar.gz → 2.2.1__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 (143) hide show
  1. {content_core-2.1.0 → content_core-2.2.1}/.claude-plugin/plugin.json +1 -1
  2. {content_core-2.1.0 → content_core-2.2.1}/.codex-plugin/plugin.json +1 -1
  3. {content_core-2.1.0 → content_core-2.2.1}/.github/workflows/claude-code-review.yml +5 -5
  4. {content_core-2.1.0 → content_core-2.2.1}/AGENTS.md +5 -4
  5. {content_core-2.1.0 → content_core-2.2.1}/CHANGELOG.md +28 -0
  6. {content_core-2.1.0 → content_core-2.2.1}/PKG-INFO +18 -9
  7. {content_core-2.1.0 → content_core-2.2.1}/README.md +8 -1
  8. {content_core-2.1.0 → content_core-2.2.1}/docs/cli.md +1 -0
  9. {content_core-2.1.0 → content_core-2.2.1}/docs/processors.md +9 -0
  10. {content_core-2.1.0 → content_core-2.2.1}/docs/usage.md +77 -2
  11. {content_core-2.1.0 → content_core-2.2.1}/pyproject.toml +12 -10
  12. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/cli.py +1 -0
  13. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/common/exceptions.py +2 -9
  14. content_core-2.2.1/src/content_core/common/http.py +131 -0
  15. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/common/retry.py +33 -1
  16. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/config.py +3 -0
  17. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/content/identification/file_detector.py +17 -2
  18. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/extraction.py +44 -23
  19. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/processors/document/__init__.py +24 -1
  20. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/processors/document/epub.py +2 -1
  21. content_core-2.2.1/src/content_core/processors/document/odf.py +218 -0
  22. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/processors/document/pdf.py +3 -2
  23. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/processors/media/audio.py +75 -43
  24. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/processors/media/video.py +44 -59
  25. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/processors/text.py +3 -2
  26. content_core-2.2.1/src/content_core/processors/url/__init__.py +220 -0
  27. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/processors/url/bs4.py +13 -15
  28. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/processors/url/crawl4ai.py +27 -8
  29. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/processors/url/firecrawl.py +3 -3
  30. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/processors/url/reddit.py +3 -2
  31. content_core-2.2.1/src/content_core/processors/url/youtube.py +371 -0
  32. content_core-2.2.1/tests/input_content/file.odp +0 -0
  33. content_core-2.2.1/tests/input_content/file.ods +0 -0
  34. content_core-2.2.1/tests/input_content/file.odt +0 -0
  35. {content_core-2.1.0 → content_core-2.2.1}/tests/integration/test_extraction.py +54 -0
  36. {content_core-2.1.0 → content_core-2.2.1}/tests/unit/test_config_v2.py +21 -0
  37. {content_core-2.1.0 → content_core-2.2.1}/tests/unit/test_crawl4ai_docker.py +15 -0
  38. {content_core-2.1.0 → content_core-2.2.1}/tests/unit/test_epub_extraction.py +8 -0
  39. {content_core-2.1.0 → content_core-2.2.1}/tests/unit/test_exceptions.py +6 -13
  40. {content_core-2.1.0 → content_core-2.2.1}/tests/unit/test_file_detector.py +106 -1
  41. content_core-2.2.1/tests/unit/test_guarded_http.py +170 -0
  42. {content_core-2.1.0 → content_core-2.2.1}/tests/unit/test_media_pipeline.py +188 -1
  43. content_core-2.2.1/tests/unit/test_office_extraction.py +340 -0
  44. {content_core-2.1.0 → content_core-2.2.1}/tests/unit/test_pdf_extraction.py +22 -1
  45. {content_core-2.1.0 → content_core-2.2.1}/tests/unit/test_retry.py +53 -0
  46. {content_core-2.1.0 → content_core-2.2.1}/tests/unit/test_routing.py +116 -1
  47. {content_core-2.1.0 → content_core-2.2.1}/tests/unit/test_text_processing.py +13 -0
  48. content_core-2.2.1/tests/unit/test_url_engine_select.py +519 -0
  49. content_core-2.2.1/tests/unit/test_youtube_parsing.py +517 -0
  50. content_core-2.2.1/uv.lock +7720 -0
  51. content_core-2.1.0/src/content_core/processors/url/__init__.py +0 -175
  52. content_core-2.1.0/src/content_core/processors/url/youtube.py +0 -216
  53. content_core-2.1.0/tests/unit/test_office_extraction.py +0 -99
  54. content_core-2.1.0/tests/unit/test_url_engine_select.py +0 -206
  55. content_core-2.1.0/tests/unit/test_youtube_parsing.py +0 -163
  56. content_core-2.1.0/uv.lock +0 -6676
  57. {content_core-2.1.0 → content_core-2.2.1}/.agent-smith/index.json +0 -0
  58. {content_core-2.1.0 → content_core-2.2.1}/.agents/plugins/marketplace.json +0 -0
  59. {content_core-2.1.0 → content_core-2.2.1}/.claude-plugin/marketplace.json +0 -0
  60. {content_core-2.1.0 → content_core-2.2.1}/.gitattributes +0 -0
  61. {content_core-2.1.0 → content_core-2.2.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  62. {content_core-2.1.0 → content_core-2.2.1}/.github/workflows/claude.yml +0 -0
  63. {content_core-2.1.0 → content_core-2.2.1}/.github/workflows/create-tag.yml +0 -0
  64. {content_core-2.1.0 → content_core-2.2.1}/.github/workflows/package.yml +0 -0
  65. {content_core-2.1.0 → content_core-2.2.1}/.github/workflows/publish.yml +0 -0
  66. {content_core-2.1.0 → content_core-2.2.1}/.github/workflows/test.yml +0 -0
  67. {content_core-2.1.0 → content_core-2.2.1}/.gitignore +0 -0
  68. {content_core-2.1.0 → content_core-2.2.1}/.maintainer/PROFILE.md +0 -0
  69. {content_core-2.1.0 → content_core-2.2.1}/.maintainer/README.md +0 -0
  70. {content_core-2.1.0 → content_core-2.2.1}/.maintainer/gotchas.md +0 -0
  71. {content_core-2.1.0 → content_core-2.2.1}/.maintainer/profile.toml +0 -0
  72. {content_core-2.1.0 → content_core-2.2.1}/.maintainer/release/runbook.md +0 -0
  73. {content_core-2.1.0 → content_core-2.2.1}/.maintainer/release/test-matrix.md +0 -0
  74. {content_core-2.1.0 → content_core-2.2.1}/.python-version +0 -0
  75. {content_core-2.1.0 → content_core-2.2.1}/ARCHITECTURE.md +0 -0
  76. {content_core-2.1.0 → content_core-2.2.1}/CLAUDE.md +0 -0
  77. {content_core-2.1.0 → content_core-2.2.1}/CONTRIBUTING.md +0 -0
  78. {content_core-2.1.0 → content_core-2.2.1}/LICENSE +0 -0
  79. {content_core-2.1.0 → content_core-2.2.1}/Makefile +0 -0
  80. {content_core-2.1.0 → content_core-2.2.1}/REFACTOR.md +0 -0
  81. {content_core-2.1.0 → content_core-2.2.1}/TEST_PLAN.md +0 -0
  82. {content_core-2.1.0 → content_core-2.2.1}/docs/mcp.md +0 -0
  83. {content_core-2.1.0 → content_core-2.2.1}/examples/main.py +0 -0
  84. {content_core-2.1.0 → content_core-2.2.1}/prompts/content/cleanup.jinja +0 -0
  85. {content_core-2.1.0 → content_core-2.2.1}/prompts/content/summarize.jinja +0 -0
  86. {content_core-2.1.0 → content_core-2.2.1}/skills/content-core/SKILL.md +0 -0
  87. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/__init__.py +0 -0
  88. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/common/__init__.py +0 -0
  89. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/common/messages.py +0 -0
  90. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/common/state.py +0 -0
  91. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/common/types.py +0 -0
  92. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/content/__init__.py +0 -0
  93. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/content/extraction/__init__.py +0 -0
  94. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/content/identification/__init__.py +0 -0
  95. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/content/summary/__init__.py +0 -0
  96. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/content/summary/core.py +0 -0
  97. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/logging.py +0 -0
  98. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/mcp/__init__.py +0 -0
  99. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/mcp/server.py +0 -0
  100. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/models.py +0 -0
  101. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/processors/document/docling.py +0 -0
  102. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/processors/document/docx.py +0 -0
  103. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/processors/document/pptx.py +0 -0
  104. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/processors/document/xlsx.py +0 -0
  105. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/processors/media/__init__.py +0 -0
  106. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/processors/url/jina.py +0 -0
  107. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/py.typed +0 -0
  108. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/templated_message.py +0 -0
  109. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/tools/__init__.py +0 -0
  110. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/tools/extract.py +0 -0
  111. {content_core-2.1.0 → content_core-2.2.1}/src/content_core/tools/summarize.py +0 -0
  112. {content_core-2.1.0 → content_core-2.2.1}/tests/conftest.py +0 -0
  113. {content_core-2.1.0 → content_core-2.2.1}/tests/e2e/__init__.py +0 -0
  114. {content_core-2.1.0 → content_core-2.2.1}/tests/e2e/test_docling.py +0 -0
  115. {content_core-2.1.0 → content_core-2.2.1}/tests/e2e/test_media.py +0 -0
  116. {content_core-2.1.0 → content_core-2.2.1}/tests/e2e/test_remote.py +0 -0
  117. {content_core-2.1.0 → content_core-2.2.1}/tests/e2e/test_url_engines.py +0 -0
  118. {content_core-2.1.0 → content_core-2.2.1}/tests/e2e/test_youtube.py +0 -0
  119. {content_core-2.1.0 → content_core-2.2.1}/tests/input_content/file.docx +0 -0
  120. {content_core-2.1.0 → content_core-2.2.1}/tests/input_content/file.epub +0 -0
  121. {content_core-2.1.0 → content_core-2.2.1}/tests/input_content/file.md +0 -0
  122. {content_core-2.1.0 → content_core-2.2.1}/tests/input_content/file.mp3 +0 -0
  123. {content_core-2.1.0 → content_core-2.2.1}/tests/input_content/file.mp4 +0 -0
  124. {content_core-2.1.0 → content_core-2.2.1}/tests/input_content/file.pdf +0 -0
  125. {content_core-2.1.0 → content_core-2.2.1}/tests/input_content/file.pptx +0 -0
  126. {content_core-2.1.0 → content_core-2.2.1}/tests/input_content/file.txt +0 -0
  127. {content_core-2.1.0 → content_core-2.2.1}/tests/input_content/file.xlsx +0 -0
  128. {content_core-2.1.0 → content_core-2.2.1}/tests/input_content/new_pdf.pdf +0 -0
  129. {content_core-2.1.0 → content_core-2.2.1}/tests/integration/conftest.py +0 -0
  130. {content_core-2.1.0 → content_core-2.2.1}/tests/integration/test_cli_v2.py +0 -0
  131. {content_core-2.1.0 → content_core-2.2.1}/tests/unit/test_audio_concurrency.py +0 -0
  132. {content_core-2.1.0 → content_core-2.2.1}/tests/unit/test_cli.py +0 -0
  133. {content_core-2.1.0 → content_core-2.2.1}/tests/unit/test_config_file.py +0 -0
  134. {content_core-2.1.0 → content_core-2.2.1}/tests/unit/test_docling_extraction.py +0 -0
  135. {content_core-2.1.0 → content_core-2.2.1}/tests/unit/test_file_detector_critical.py +0 -0
  136. {content_core-2.1.0 → content_core-2.2.1}/tests/unit/test_file_detector_performance.py +0 -0
  137. {content_core-2.1.0 → content_core-2.2.1}/tests/unit/test_html_file_extraction.py +0 -0
  138. {content_core-2.1.0 → content_core-2.2.1}/tests/unit/test_logging.py +0 -0
  139. {content_core-2.1.0 → content_core-2.2.1}/tests/unit/test_mcp_server_version.py +0 -0
  140. {content_core-2.1.0 → content_core-2.2.1}/tests/unit/test_mcp_v2.py +0 -0
  141. {content_core-2.1.0 → content_core-2.2.1}/tests/unit/test_models_v2.py +0 -0
  142. {content_core-2.1.0 → content_core-2.2.1}/tests/unit/test_pdf_helpers.py +0 -0
  143. {content_core-2.1.0 → content_core-2.2.1}/tests/unit/test_reddit_extraction.py +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "content-core",
3
- "version": "2.1.0",
3
+ "version": "2.2.1",
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.1.0",
3
+ "version": "2.2.1",
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
  }
@@ -12,11 +12,11 @@ on:
12
12
 
13
13
  jobs:
14
14
  claude-review:
15
- # Optional: Filter by PR author
16
- # if: |
17
- # github.event.pull_request.user.login == 'external-contributor' ||
18
- # github.event.pull_request.user.login == 'new-developer' ||
19
- # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
15
+ # Fork PRs get no repository secrets, so the review could only fail there.
16
+ # (pull_request_target would expose the secrets to untrusted code.)
17
+ # To also filter by PR author, extend this expression with `&&` -- a job
18
+ # takes a single `if:`.
19
+ if: github.event.pull_request.head.repo.full_name == github.repository
20
20
 
21
21
  runs-on: ubuntu-latest
22
22
  permissions:
@@ -92,6 +92,7 @@ src/content_core/
92
92
  │ │ ├── docx.py # python-docx
93
93
  │ │ ├── pptx.py # python-pptx
94
94
  │ │ ├── xlsx.py # openpyxl
95
+ │ │ ├── odf.py # ODT/ODS/ODP via odfpy
95
96
  │ │ └── docling.py # Optional Docling integration
96
97
  │ └── media/ # Audio/video processing
97
98
  │ ├── __init__.py # Video→audio pipeline
@@ -129,7 +130,7 @@ from content_core import ContentCoreConfig
129
130
  config = ContentCoreConfig(url_engine="firecrawl", audio_concurrency=5)
130
131
  ```
131
132
 
132
- Key settings: `url_engine`, `document_engine`, `audio_provider`, `audio_model`, `firecrawl_api_url`, `youtube_languages`, `llm_provider`, `llm_model`, `docling_ocr`, `docling_formulas`, `docling_vision`
133
+ Key settings: `url_engine`, `document_engine`, `audio_provider`, `audio_model`, `firecrawl_api_url`, `youtube_languages`, `youtube_cookies_file`, `youtube_proxy`, `llm_provider`, `llm_model`, `docling_ocr`, `docling_formulas`, `docling_vision`
133
134
 
134
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.
135
136
 
@@ -166,7 +167,7 @@ When you change a specific processor or module, run only the relevant tests for
166
167
  | `processors/url/youtube.py` | `uv run pytest -k "youtube"` |
167
168
  | `processors/url/reddit.py` | `uv run pytest -k "reddit"` |
168
169
  | `processors/document/pdf.py` | `uv run pytest -k "pdf"` |
169
- | `processors/document/docx.py` or `pptx.py` or `xlsx.py` | `uv run pytest -k "office"` |
170
+ | `processors/document/docx.py` or `pptx.py` or `xlsx.py` or `odf.py` | `uv run pytest -k "office"` |
170
171
  | `processors/document/docling.py` | `uv run pytest -k "docling"` |
171
172
  | `processors/text.py` | `uv run pytest -k "text_processing or html_file"` |
172
173
  | `processors/media/audio.py` | `uv run pytest -k "audio or media_pipeline"` |
@@ -191,7 +192,7 @@ tests/
191
192
  │ ├── test_pdf_extraction.py # PDF text cleaning, extraction with mocked pdfplumber
192
193
  │ ├── test_pdf_helpers.py # Formula detection, table conversion helpers
193
194
  │ ├── test_epub_extraction.py # EPUB extraction with mocked fast-ebook
194
- │ ├── test_office_extraction.py # DOCX/PPTX/XLSX routing and extraction
195
+ │ ├── test_office_extraction.py # DOCX/PPTX/XLSX/ODT/ODS/ODP routing and extraction
195
196
  │ ├── test_docling_extraction.py # Docling output formats with mocked converter
196
197
  │ ├── test_text_processing.py # HTML detection, markdown conversion, <title>/<head> handling
197
198
  │ ├── test_html_file_extraction.py # Local .html file → markdown via text processor
@@ -206,7 +207,7 @@ tests/
206
207
  │ └── test_file_detector*.py # MIME detection, performance, edge cases
207
208
  │
208
209
  ├── integration/ # Local files, no network (~22 tests)
209
- │ ├── test_extraction.py # Real file extraction (PDF, DOCX, PPTX, XLSX, EPUB, HTML, etc.)
210
+ │ ├── test_extraction.py # Real file extraction (PDF, DOCX, PPTX, XLSX, ODT/ODS/ODP, EPUB, HTML, etc.)
210
211
  │ └── test_cli_v2.py # CLI subcommands via CliRunner with real extraction
211
212
  │
212
213
  └── e2e/
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.2.1] - 2026-11-03
11
+
12
+ ### Security
13
+ - 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`).
14
+
15
+ ## [2.2.0] - 2026-10-03
16
+
17
+ ### Added
18
+ - OpenDocument extraction for `.odt`, `.ods` and `.odp` through the office route, using `odfpy` (new core dependency). Text documents become markdown paragraphs, headings, lists and tables; spreadsheets become one markdown table per sheet; presentations become one block per slide. Detection reads the ZIP's `mimetype` member, so a wrong or missing extension still works (#43).
19
+ - YouTube cookie and proxy support for the transcript path (#46). `youtube_cookies_file` (`CCORE_YOUTUBE_COOKIES_FILE`) loads a Netscape `cookies.txt` into the `youtube-transcript-api` session, unblocking IP-flagged networks; a missing or unreadable file raises `ConfigurationError`. `youtube_proxy` (`CCORE_YOUTUBE_PROXY`) routes both `youtube-transcript-api` and the `pytubefix` fallback through a proxy URL — use a residential proxy, as datacenter proxies are blocked. With neither set, behavior is unchanged. Cookie values are never logged.
20
+ - `audio_segment_minutes` setting (`CCORE_AUDIO_SEGMENT_MINUTES`) controls the length of the segments long audio is split into before transcription. The default stays 10 minutes; `0` sends the file whole, for self-hosted STT endpoints without an upload size limit (#94).
21
+
22
+ ### Changed
23
+ - **URL extraction failures now raise instead of returning empty output** (#60). `content=""` now only means the source was genuinely empty. A named `url_engine` that fails raises `NetworkError` (connection/timeout/DNS), `NotFoundError` (HTTP 404/410 from the page, `simple` engine), `ExternalServiceError` (engine API or HTTP errors) or `ConfigurationError` (`crawl4ai` not installed). `auto` still falls through firecrawl → jina → crawl4ai → simple and raises the last error only when every engine failed. A malformed URL raises `InvalidInputError`. The `simple` engine no longer returns `"Failed to extract content: ..."` or `"No content found"` as page content. Remote-file downloads raise the same typed errors, and a download that cannot be saved locally raises `FileOperationError`.
24
+ - File extraction failures raise typed errors (#60): corrupted PDF, EPUB or unreadable text files raise `FileOperationError` (chained to the original cause) instead of bare `Exception`/`RuntimeError`; a missing file still raises `FileNotFoundError`. ffmpeg/ffprobe failures in audio and video processing raise `FileOperationError`: video extraction no longer returns empty content when ffprobe or ffmpeg fails. Speech-to-text provider failures (after retries) raise `ExternalServiceError`, and an STT model that cannot be created raises `ConfigurationError`.
25
+ - Retry classification now treats `ConfigurationError`, `UnsupportedTypeException`, `InvalidInputError` and `FileOperationError` as permanent and `NetworkError` as transient (#60).
26
+ - YouTube extraction raises instead of returning empty content when no transcript can be fetched (#60): `NoTranscriptFound` when neither youtube-transcript-api nor pytubefix finds a transcript, `ExternalServiceError` (chained to the provider error) when YouTube blocks or fails the request on both paths, e.g. `IpBlocked`, and `InvalidInputError` for a YouTube URL without a video ID. Provider errors from `get_best_transcript` and `extract_transcript_pytubefix` now propagate after retries instead of being swallowed (`extract_transcript_pytubefix` still returns `(None, None)` for a video without captions); a failure of the first path still falls back to the second.
27
+ - An explicit `audio_provider`/`audio_model` that cannot be created now raises `ConfigurationError` instead of silently transcribing with the default STT model, per "Explicit choice is a requirement" (#99).
28
+ - youtube-transcript-api failures are retried by type: blocked requests (`RequestBlocked`/`IpBlocked`) are retried, so a rotating proxy can get a new IP; other transcript errors that name a state of the video (unavailable, transcripts disabled, age-restricted...) are not (#99).
29
+
30
+ ### Fixed
31
+ - `youtube_proxy` now also applies to the YouTube video title lookup, which previously used only env-var proxies (#99).
32
+ - `requests`, imported directly for the YouTube cookie session, is declared as a dependency instead of arriving transitively (#99).
33
+ - A failed Crawl4AI crawl (navigation timeout, blocked page) now raises `ExternalServiceError` instead of failing with a pydantic `ValidationError` on `content=None`, and `auto` falls through to the next engine. Applies to both the local browser and the Docker API mode (#101).
34
+
35
+ ### Security
36
+ - Raised minimum versions to releases without known vulnerabilities: `aiohttp>=3.14.3`, `pillow>=12.3.0`, `click>=8.3.3`, `pydantic-settings>=2.14.2`, and in the extras `docling>=2.94.0`, `crawl4ai>=0.9.0` and `langchain-core>=1.3.3`. Upgrading content-core upgrades these packages in an existing environment. The development lock was refreshed; it now also exercises `fastmcp` 4 and `mcp` 2, which fresh installs already resolve to (#102).
37
+
10
38
  ## [2.1.0] - 2026-09-06
11
39
 
12
40
  ### Added
@@ -1,14 +1,14 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: content-core
3
- Version: 2.1.0
3
+ Version: 2.2.1
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
7
7
  Requires-Python: >=3.10
8
8
  Requires-Dist: ai-prompter>=0.4.0
9
- Requires-Dist: aiohttp>=3.13
9
+ Requires-Dist: aiohttp>=3.14.3
10
10
  Requires-Dist: bs4>=0.0.2
11
- Requires-Dist: click>=8.3.0
11
+ Requires-Dist: click>=8.3.3
12
12
  Requires-Dist: esperanto>=2.26.0
13
13
  Requires-Dist: fast-ebook>=0.2.0
14
14
  Requires-Dist: fastmcp>=3.0.0
@@ -17,28 +17,30 @@ Requires-Dist: jinja2>=3.1.6
17
17
  Requires-Dist: langdetect>=1.0.9
18
18
  Requires-Dist: loguru>=0.7.3
19
19
  Requires-Dist: markdownify>=0.14.0
20
+ Requires-Dist: odfpy>=1.4.1
20
21
  Requires-Dist: openpyxl>=3.1.5
21
22
  Requires-Dist: pandas>=2.2.3
22
23
  Requires-Dist: pdfplumber>=0.11.0
23
- Requires-Dist: pillow>=12.0.0
24
- Requires-Dist: pydantic-settings>=2.12.0
24
+ Requires-Dist: pillow>=12.3.0
25
+ Requires-Dist: pydantic-settings>=2.14.2
25
26
  Requires-Dist: python-docx>=1.1.2
26
27
  Requires-Dist: python-dotenv>=1.2.0
27
28
  Requires-Dist: python-pptx>=1.0.2
28
29
  Requires-Dist: pytubefix>=10.0.0
29
30
  Requires-Dist: readability-lxml>=0.8.4.1
31
+ Requires-Dist: requests>=2.31.0
30
32
  Requires-Dist: tenacity>=9.0.0
31
33
  Requires-Dist: tomli>=2.0; python_version < '3.11'
32
34
  Requires-Dist: validators>=0.34.0
33
35
  Requires-Dist: youtube-transcript-api>=1.2.0
34
36
  Provides-Extra: crawl4ai
35
- Requires-Dist: crawl4ai>=0.7.0; extra == 'crawl4ai'
37
+ Requires-Dist: crawl4ai>=0.9.0; extra == 'crawl4ai'
36
38
  Provides-Extra: docling
37
- Requires-Dist: docling>=2.86.0; extra == 'docling'
39
+ Requires-Dist: docling>=2.94.0; extra == 'docling'
38
40
  Requires-Dist: peft>=0.18.0; extra == 'docling'
39
41
  Requires-Dist: transformers<5,>=4.50; extra == 'docling'
40
42
  Provides-Extra: langchain
41
- Requires-Dist: langchain-core>=0.1.0; extra == 'langchain'
43
+ Requires-Dist: langchain-core>=1.3.3; extra == 'langchain'
42
44
  Description-Content-Type: text/markdown
43
45
 
44
46
  # Content Core
@@ -59,7 +61,7 @@ Extract, process, and summarize content from URLs, files, and text through a uni
59
61
  | Category | Formats |
60
62
  |----------|---------|
61
63
  | Web | URLs, HTML pages, YouTube videos, Reddit posts |
62
- | Documents | PDF, DOCX, PPTX, XLSX, EPUB, HTML, Markdown, plain text |
64
+ | Documents | PDF, DOCX, PPTX, XLSX, ODT, ODS, ODP, EPUB, HTML, Markdown, plain text |
63
65
  | Media | MP3, WAV, M4A, FLAC, OGG (audio); MP4, AVI, MOV, MKV (video) |
64
66
 
65
67
  ## Quick Start
@@ -269,6 +271,7 @@ Content Core uses `ContentCoreConfig` powered by pydantic-settings. Settings are
269
271
  | `CCORE_URL_ENGINE` | URL extraction engine (`auto`, `simple`, `firecrawl`, `jina`, `crawl4ai`) | `auto` |
270
272
  | `CCORE_DOCUMENT_ENGINE` | Document extraction engine (`auto`, `simple`, `docling`) — `docling` raises `ConfigurationError` if the extra is not installed; `auto` falls back silently | `auto` |
271
273
  | `CCORE_AUDIO_CONCURRENCY` | Concurrent audio transcriptions (1-10) | `3` |
274
+ | `CCORE_AUDIO_SEGMENT_MINUTES` | Length of the segments long audio is split into; `0` sends the file whole | `10` |
272
275
  | `CRAWL4AI_API_URL` | Crawl4AI Docker API URL (omit for local browser mode) | - |
273
276
  | `CRAWL4AI_API_TOKEN` | Bearer token for the Crawl4AI Docker API (required by Crawl4AI >= 0.9.0) | - |
274
277
  | `FIRECRAWL_API_URL` | Custom Firecrawl API URL for self-hosted instances or Firecrawl-compatible backends (e.g. fastCRW) | - |
@@ -280,6 +283,8 @@ Content Core uses `ContentCoreConfig` powered by pydantic-settings. Settings are
280
283
  | `CCORE_STT_MODEL` | Speech-to-text model | - |
281
284
  | `CCORE_STT_TIMEOUT` | Speech-to-text timeout in seconds | - |
282
285
  | `CCORE_YOUTUBE_LANGUAGES` | Preferred YouTube transcript languages | - |
286
+ | `CCORE_YOUTUBE_COOKIES_FILE` | Path to a Netscape `cookies.txt` for YouTube transcripts (missing file raises `ConfigurationError`) | - |
287
+ | `CCORE_YOUTUBE_PROXY` | Proxy URL for YouTube transcripts, e.g. `http://user:pass@host:port` | - |
283
288
 
284
289
  API keys for external services are set via their standard environment variables (e.g., `OPENAI_API_KEY`, `FIRECRAWL_API_KEY`, `JINA_API_KEY`).
285
290
 
@@ -287,6 +292,10 @@ API keys for external services are set via their standard environment variables
287
292
 
288
293
  Content Core reads standard `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` environment variables automatically. No additional configuration is needed.
289
294
 
295
+ ### YouTube on Blocked Networks
296
+
297
+ If YouTube extraction fails with `IpBlocked`/`RequestBlocked` on a flagged IP (cloud hosts, VPNs), either point `CCORE_YOUTUBE_COOKIES_FILE` at a `cookies.txt` exported from a logged-in browser (re-export it when the cookies expire), or set `CCORE_YOUTUBE_PROXY` to a **residential** proxy — datacenter proxies get blocked or CAPTCHA'd. See [docs/usage.md](docs/usage.md#youtube-on-blocked-networks).
298
+
290
299
  ## Optional Dependencies
291
300
 
292
301
  ```bash
@@ -16,7 +16,7 @@ Extract, process, and summarize content from URLs, files, and text through a uni
16
16
  | Category | Formats |
17
17
  |----------|---------|
18
18
  | Web | URLs, HTML pages, YouTube videos, Reddit posts |
19
- | Documents | PDF, DOCX, PPTX, XLSX, EPUB, HTML, Markdown, plain text |
19
+ | Documents | PDF, DOCX, PPTX, XLSX, ODT, ODS, ODP, EPUB, HTML, Markdown, plain text |
20
20
  | Media | MP3, WAV, M4A, FLAC, OGG (audio); MP4, AVI, MOV, MKV (video) |
21
21
 
22
22
  ## Quick Start
@@ -226,6 +226,7 @@ Content Core uses `ContentCoreConfig` powered by pydantic-settings. Settings are
226
226
  | `CCORE_URL_ENGINE` | URL extraction engine (`auto`, `simple`, `firecrawl`, `jina`, `crawl4ai`) | `auto` |
227
227
  | `CCORE_DOCUMENT_ENGINE` | Document extraction engine (`auto`, `simple`, `docling`) — `docling` raises `ConfigurationError` if the extra is not installed; `auto` falls back silently | `auto` |
228
228
  | `CCORE_AUDIO_CONCURRENCY` | Concurrent audio transcriptions (1-10) | `3` |
229
+ | `CCORE_AUDIO_SEGMENT_MINUTES` | Length of the segments long audio is split into; `0` sends the file whole | `10` |
229
230
  | `CRAWL4AI_API_URL` | Crawl4AI Docker API URL (omit for local browser mode) | - |
230
231
  | `CRAWL4AI_API_TOKEN` | Bearer token for the Crawl4AI Docker API (required by Crawl4AI >= 0.9.0) | - |
231
232
  | `FIRECRAWL_API_URL` | Custom Firecrawl API URL for self-hosted instances or Firecrawl-compatible backends (e.g. fastCRW) | - |
@@ -237,6 +238,8 @@ Content Core uses `ContentCoreConfig` powered by pydantic-settings. Settings are
237
238
  | `CCORE_STT_MODEL` | Speech-to-text model | - |
238
239
  | `CCORE_STT_TIMEOUT` | Speech-to-text timeout in seconds | - |
239
240
  | `CCORE_YOUTUBE_LANGUAGES` | Preferred YouTube transcript languages | - |
241
+ | `CCORE_YOUTUBE_COOKIES_FILE` | Path to a Netscape `cookies.txt` for YouTube transcripts (missing file raises `ConfigurationError`) | - |
242
+ | `CCORE_YOUTUBE_PROXY` | Proxy URL for YouTube transcripts, e.g. `http://user:pass@host:port` | - |
240
243
 
241
244
  API keys for external services are set via their standard environment variables (e.g., `OPENAI_API_KEY`, `FIRECRAWL_API_KEY`, `JINA_API_KEY`).
242
245
 
@@ -244,6 +247,10 @@ API keys for external services are set via their standard environment variables
244
247
 
245
248
  Content Core reads standard `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` environment variables automatically. No additional configuration is needed.
246
249
 
250
+ ### YouTube on Blocked Networks
251
+
252
+ If YouTube extraction fails with `IpBlocked`/`RequestBlocked` on a flagged IP (cloud hosts, VPNs), either point `CCORE_YOUTUBE_COOKIES_FILE` at a `cookies.txt` exported from a logged-in browser (re-export it when the cookies expire), or set `CCORE_YOUTUBE_PROXY` to a **residential** proxy — datacenter proxies get blocked or CAPTCHA'd. See [docs/usage.md](docs/usage.md#youtube-on-blocked-networks).
253
+
247
254
  ## Optional Dependencies
248
255
 
249
256
  ```bash
@@ -154,6 +154,7 @@ content-core config delete llm_provider
154
154
  |-----|-------------|---------|
155
155
  | `audio_concurrency` | Parallel transcription limit (1-10) | `3` |
156
156
  | `audio_model` | Override STT model | — |
157
+ | `audio_segment_minutes` | Audio segment length in minutes; `0` disables splitting | `10` |
157
158
  | `crawl4ai_api_url` | Crawl4AI Docker API URL (omit for local mode) | — |
158
159
  | `crawl4ai_api_token` | Bearer token for the Crawl4AI Docker API (required by Crawl4AI >= 0.9.0) | — |
159
160
  | `audio_provider` | Override STT provider | — |
@@ -106,6 +106,14 @@ The `document_engine` setting controls document processing. When set to `auto` (
106
106
  - File: `document/xlsx.py`
107
107
  - Extracts data from Excel spreadsheets
108
108
 
109
+ ### OpenDocument (ODT/ODS/ODP)
110
+
111
+ - File: `document/odf.py`
112
+ - Uses odfpy for LibreOffice/OpenOffice formats
113
+ - ODT: paragraphs, headings (by outline level), lists and tables as markdown
114
+ - ODS: one markdown table per sheet, preceded by `# Sheet: <sheet name>` (as for XLSX)
115
+ - ODP: one block per slide (`# Slide N` followed by its text frames, as for PPTX)
116
+
109
117
  ### Docling (Optional)
110
118
 
111
119
  - File: `document/docling.py`
@@ -160,6 +168,7 @@ Located in `src/content_core/processors/media/`.
160
168
  - Detection methods:
161
169
  - Binary signature matching for PDF, images, audio, video, and archives
162
170
  - ZIP structure inspection for Office formats (DOCX, XLSX, PPTX) and EPUB
171
+ - OpenDocument (ODT, ODS, ODP) identified by the ZIP's `mimetype` member
163
172
  - Content analysis for text-based formats (HTML, XML, JSON, YAML, CSV, Markdown)
164
173
  - Reads only the first 512 bytes for binary signatures and 1024 bytes for text content analysis
165
174
  - Works regardless of file extension
@@ -67,6 +67,9 @@ result = await content_core.extract_content(file_path="slides.pptx")
67
67
  # Excel
68
68
  result = await content_core.extract_content(file_path="data.xlsx")
69
69
 
70
+ # OpenDocument (LibreOffice): .odt, .ods, .odp
71
+ result = await content_core.extract_content(file_path="report.odt")
72
+
70
73
  # Use Docling engine for richer parsing
71
74
  from content_core import ContentCoreConfig
72
75
  config = ContentCoreConfig(document_engine="docling", docling_output_format="html")
@@ -95,6 +98,55 @@ result = await content_core.extract_content(url="https://www.youtube.com/watch?v
95
98
  print(result.content) # Video transcript
96
99
  ```
97
100
 
101
+ #### YouTube on blocked networks
102
+
103
+ On IP-flagged networks (cloud hosts, some ISPs, VPNs) YouTube refuses transcript
104
+ requests even for public videos with captions — the extraction fails with
105
+ `IpBlocked`/`RequestBlocked` from `youtube-transcript-api`. Two settings help;
106
+ both are off by default, and with neither set the behavior is unchanged.
107
+
108
+ **Browser cookies** (`youtube_cookies_file` / `CCORE_YOUTUBE_COOKIES_FILE`) —
109
+ path to a Netscape-format `cookies.txt` exported from a browser that is logged
110
+ in to YouTube (use a "Get cookies.txt"-style browser extension, or let yt-dlp
111
+ dump its cookie jar: `yt-dlp --cookies-from-browser chrome --cookies cookies.txt
112
+ --skip-download <any YouTube URL>`). This unblocks the transcript path on the
113
+ same IP.
114
+
115
+ - Cookies are credentials: keep the file private. Content Core logs only the
116
+ file path (at debug level), never cookie values.
117
+ - Session cookies expire. When extraction starts failing again, re-export the
118
+ file.
119
+ - A missing, unreadable or malformed file raises `ConfigurationError` — Content
120
+ Core does not silently fall back to anonymous requests.
121
+ - Cookies apply to the `youtube-transcript-api` path only; the `pytubefix`
122
+ fallback has no cookies-file support.
123
+
124
+ **Proxy** (`youtube_proxy` / `CCORE_YOUTUBE_PROXY`) — a proxy URL such as
125
+ `http://user:pass@host:port`, used by every YouTube request: the
126
+ `youtube-transcript-api` transcript, the `pytubefix` fallback and the video
127
+ title lookup. The standard `HTTP_PROXY`/`HTTPS_PROXY` variables keep working
128
+ too; this setting scopes the proxy to YouTube.
129
+
130
+ Use a **residential** proxy — preferably a rotating one (e.g. Webshare
131
+ "Residential"), so a blocked IP is swapped on the next retry. In testing
132
+ against a blocked network, **datacenter proxies failed** (`IpBlocked`,
133
+ `RequestBlocked` or a Google CAPTCHA), while rotating and sticky residential
134
+ proxies returned the full transcript. See the test matrix in
135
+ [issue #46](https://github.com/lfnovo/content-core/issues/46).
136
+
137
+ ```python
138
+ config = ContentCoreConfig(
139
+ youtube_cookies_file="~/secrets/youtube-cookies.txt",
140
+ youtube_proxy="http://user:pass@p.webshare.io:80",
141
+ )
142
+ result = await content_core.extract_content(url="https://youtu.be/dQw4w9WgXcQ", config=config)
143
+ ```
144
+
145
+ ```bash
146
+ CCORE_YOUTUBE_COOKIES_FILE=/path/to/cookies.txt
147
+ CCORE_YOUTUBE_PROXY=http://user:pass@host:port
148
+ ```
149
+
98
150
  ### Local HTML Files
99
151
 
100
152
  ```python
@@ -177,6 +229,8 @@ CCORE_STT_PROVIDER=openai
177
229
  CCORE_STT_MODEL=whisper-1
178
230
  CCORE_STT_TIMEOUT=3600
179
231
  CCORE_YOUTUBE_LANGUAGES=en,pt
232
+ CCORE_YOUTUBE_COOKIES_FILE=/path/to/cookies.txt
233
+ CCORE_YOUTUBE_PROXY=http://user:pass@host:port
180
234
  CCORE_FIRECRAWL_PROXY=auto
181
235
  CCORE_FIRECRAWL_WAIT_FOR=3000
182
236
  FIRECRAWL_API_URL=http://localhost:3002
@@ -217,12 +271,12 @@ FIRECRAWL_API_KEY=anykey
217
271
 
218
272
  ### Document Engines
219
273
 
220
- The `document_engine` setting controls how files (PDF, DOCX, PPTX, XLSX, HTML) are processed:
274
+ The `document_engine` setting controls how files (PDF, DOCX, PPTX, XLSX, ODT, ODS, ODP, HTML) are processed:
221
275
 
222
276
  | Engine | Description | Requirements |
223
277
  |--------|-------------|-------------|
224
278
  | `auto` (default) | Tries Docling first, falls back to simple | Depends on installed extras |
225
- | `simple` | pdfplumber for PDF, fast-ebook for EPUB, python-docx/openpyxl/python-pptx for Office, markdownify for HTML | None (included) |
279
+ | `simple` | pdfplumber for PDF, fast-ebook for EPUB, python-docx/openpyxl/python-pptx for Office, odfpy for OpenDocument, markdownify for HTML | None (included) |
226
280
  | `docling` | Docling library for rich document parsing | `pip install content-core[docling]` |
227
281
 
228
282
  `auto` is a preference: it uses Docling when installed and silently falls back to `simple` otherwise. `docling` is a requirement: if the extra is not installed, extraction raises `ConfigurationError` instead of silently producing simple-engine output. Install it with `pip install content-core[docling]`, or set `CCORE_DOCUMENT_ENGINE=simple` (or `auto`) to proceed without it.
@@ -305,6 +359,27 @@ config = ContentCoreConfig(audio_concurrency=5)
305
359
 
306
360
  Higher values speed up processing of long files but may hit API rate limits.
307
361
 
362
+ ### Segment Length
363
+
364
+ Long audio is split into 10-minute segments, a length that fits the upload
365
+ size limits of the cloud STT APIs (OpenAI rejects uploads over 25 MB). A
366
+ self-hosted endpoint has no such limit, and every cut costs it the context
367
+ that falls across it. Set a longer segment, or `0` to send the file whole:
368
+
369
+ ```bash
370
+ CCORE_AUDIO_SEGMENT_MINUTES=0
371
+ ```
372
+
373
+ Or in code:
374
+
375
+ ```python
376
+ config = ContentCoreConfig(audio_segment_minutes=0)
377
+ ```
378
+
379
+ Keep the default when using a cloud provider: with splitting off or a long
380
+ segment, a long file can exceed the provider's upload limit and the
381
+ transcription fails.
382
+
308
383
  ### Custom STT Models
309
384
 
310
385
  Override the speech-to-text provider and model per call:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "content-core"
3
- version = "2.1.0"
3
+ version = "2.2.1"
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"
@@ -9,7 +9,7 @@ authors = [
9
9
  ]
10
10
  requires-python = ">=3.10"
11
11
  dependencies = [
12
- "aiohttp>=3.13",
12
+ "aiohttp>=3.14.3",
13
13
  "bs4>=0.0.2",
14
14
  "esperanto>=2.26.0",
15
15
  "jinja2>=3.1.6",
@@ -27,20 +27,22 @@ dependencies = [
27
27
  "ai-prompter>=0.4.0",
28
28
  "readability-lxml>=0.8.4.1",
29
29
  "firecrawl-py>=4.13.0",
30
- "pillow>=12.0.0",
30
+ "pillow>=12.3.0",
31
31
  "pytubefix>=10.0.0",
32
32
  "fastmcp>=3.0.0",
33
33
  "tenacity>=9.0.0",
34
34
  "markdownify>=0.14.0",
35
- "pydantic-settings>=2.12.0",
35
+ "pydantic-settings>=2.14.2",
36
36
  "tomli>=2.0; python_version < '3.11'",
37
- "click>=8.3.0",
37
+ "click>=8.3.3",
38
+ "odfpy>=1.4.1",
39
+ "requests>=2.31.0",
38
40
  ]
39
41
 
40
42
  [project.optional-dependencies]
41
- docling = ["docling>=2.86.0", "peft>=0.18.0", "transformers>=4.50,<5"]
42
- crawl4ai = ["crawl4ai>=0.7.0"]
43
- langchain = ["langchain-core>=0.1.0"]
43
+ docling = ["docling>=2.94.0", "peft>=0.18.0", "transformers>=4.50,<5"]
44
+ crawl4ai = ["crawl4ai>=0.9.0"]
45
+ langchain = ["langchain-core>=1.3.3"]
44
46
 
45
47
  [project.scripts]
46
48
  content-core = "content_core.cli:cli"
@@ -60,8 +62,8 @@ package-dir = {"content_core" = "src/content_core"}
60
62
 
61
63
  [dependency-groups]
62
64
  dev = [
63
- "crawl4ai>=0.8.0",
64
- "docling>=2.86.0",
65
+ "crawl4ai>=0.9.0",
66
+ "docling>=2.94.0",
65
67
  "transformers>=4.50,<5",
66
68
  "ipykernel>=7.0.0",
67
69
  "ipywidgets>=8.0.0",
@@ -109,6 +109,7 @@ def config():
109
109
  \b
110
110
  Available keys:
111
111
  audio_concurrency Parallel transcription limit (1-10, default: 3)
112
+ audio_segment_minutes Audio segment length in minutes, 0 disables splitting (default: 10)
112
113
  crawl4ai_api_url Crawl4AI Docker API URL (default: none, uses local mode)
113
114
  crawl4ai_api_token Bearer token for the Crawl4AI Docker API (required by Crawl4AI >= 0.9.0)
114
115
  audio_model Override STT model
@@ -1,15 +1,8 @@
1
1
  """The public exception taxonomy for ``extract_content``.
2
2
 
3
3
  Per the raise/degrade boundary in ``ARCHITECTURE.md``, total failure raises
4
- one of these; degradation only exists within a single source.
5
-
6
- The taxonomy is complete, its raise sites are not yet: ``NotFoundError``,
7
- ``NetworkError``, ``ExternalServiceError`` and ``FileOperationError`` have
8
- no raise site in this release -- the failures they name still escape as
9
- untyped exceptions (an ``aiohttp.ClientError`` out of a download, say)
10
- until the migration in #60 lands. ``ContentCoreError`` catches every typed
11
- failure, so it is the right single handler -- but it does not yet cover the
12
- whole library.
4
+ one of these; degradation only exists within a single source. Every one
5
+ derives from ``ContentCoreError``, the single handler for typed failures.
13
6
  """
14
7
 
15
8
 
@@ -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