any2md 1.1.1__tar.gz → 1.2.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.
- {any2md-1.1.1 → any2md-1.2.0}/CHANGELOG.md +77 -1
- {any2md-1.1.1 → any2md-1.2.0}/CONTRIBUTING.md +1 -1
- {any2md-1.1.1 → any2md-1.2.0}/PKG-INFO +5 -6
- {any2md-1.1.1 → any2md-1.2.0}/any2md/__init__.py +1 -1
- {any2md-1.1.1 → any2md-1.2.0}/any2md/config.py +2 -15
- {any2md-1.1.1 → any2md-1.2.0}/any2md/converters/html.py +126 -0
- {any2md-1.1.1 → any2md-1.2.0}/any2md/frontmatter.py +12 -0
- {any2md-1.1.1 → any2md-1.2.0}/docs/architecture.md +3 -0
- {any2md-1.1.1 → any2md-1.2.0}/docs/output-format.md +46 -8
- {any2md-1.1.1 → any2md-1.2.0}/pyproject.toml +3 -4
- {any2md-1.1.1 → any2md-1.2.0}/requirements.txt +6 -6
- any2md-1.2.0/tests/fixtures/docs/web_bold_title_first_line.html +15 -0
- any2md-1.2.0/tests/fixtures/docs/web_h1_in_hero.html +38 -0
- any2md-1.2.0/tests/fixtures/docs/web_h3_sections.html +21 -0
- any2md-1.2.0/tests/fixtures/docs/web_no_title.html +12 -0
- any2md-1.2.0/tests/integration/test_html_h1.py +148 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_docling_cache.py +2 -2
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_frontmatter_compose.py +28 -0
- any2md-1.2.0/tests/unit/test_html_heading_levels.py +72 -0
- any2md-1.2.0/tests/unit/test_html_title_h1.py +117 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_lockfile_freshness.py +1 -1
- {any2md-1.1.1 → any2md-1.2.0}/.gitignore +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/CODE_OF_CONDUCT.md +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/LICENSE +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/README.md +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/SECURITY.md +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/any2md/__main__.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/any2md/_docling.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/any2md/_docling_cache.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/any2md/_http.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/any2md/_logging.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/any2md/cli.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/any2md/converters/__init__.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/any2md/converters/docx.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/any2md/converters/pdf.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/any2md/converters/txt.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/any2md/heuristics.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/any2md/pipeline/__init__.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/any2md/pipeline/cleanup.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/any2md/pipeline/structured.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/any2md/pipeline/text.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/any2md/utils.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/any2md/validators.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/assets/logo-original.jpeg +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/assets/logo.png +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/assets/social-preview.jpg +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/docs/cli-reference.md +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/docs/superpowers/plans/2026-04-26-any2md-v1-phase1-foundation.md +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/docs/superpowers/plans/2026-04-26-any2md-v1-phase2-docling-backend.md +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/docs/superpowers/plans/2026-04-26-any2md-v1-phase3-figures-ocr-text-lane.md +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/docs/superpowers/plans/2026-04-26-any2md-v1-phase4-polish-config-docs.md +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/docs/superpowers/plans/2026-04-26-any2md-v1.0.2-implementation.md +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/docs/superpowers/plans/2026-04-26-issue-17-toc-followups.md +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/docs/superpowers/plans/2026-04-27-v1.0.6-security-hardening.md +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/docs/superpowers/plans/2026-05-04-v1.1.0-persistent-converter.md +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/docs/superpowers/plans/2026-05-06-v1.1.1-security-hardening.md +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/docs/superpowers/specs/2026-04-26-any2md-v1-design.md +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/docs/superpowers/specs/2026-04-26-any2md-v1.0.2-design.md +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/docs/superpowers/specs/2026-04-26-issue-17-toc-followups-design.md +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/docs/superpowers/specs/2026-04-26-v1.0.3-empty-title-orphan-punct-design.md +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/docs/superpowers/specs/2026-04-27-v1.0.6-security-hardening-design.md +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/docs/superpowers/specs/2026-05-04-v1.1.0-persistent-converter-design.md +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/docs/superpowers/specs/2026-05-06-v1.1.1-security-design.md +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/docs/troubleshooting.md +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/docs/upgrading-from-0.7.md +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/scripts/audit-outputs.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/scripts/bench_docling_cache.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/scripts/validate-phase1.sh +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/__init__.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/cli/__init__.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/cli/test_cli_backend.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/cli/test_cli_existing_flags.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/cli/test_cli_exit_codes.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/cli/test_cli_high_fidelity.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/cli/test_cli_meta.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/cli/test_cli_no_arxiv_lookup.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/cli/test_cli_ocr_save_images.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/cli/test_cli_quiet_verbose.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/cli/test_cli_strict.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/conftest.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/fixtures/__init__.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/fixtures/docs/ligatures_and_softhyphens.txt +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/fixtures/docs/multi_column.pdf +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/fixtures/docs/table_heavy.docx +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/fixtures/docs/web_page.html +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/fixtures/make_fixtures.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/fixtures/snapshots/ligatures_and_softhyphens.md +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/fixtures/snapshots/multi_column.docling.md +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/fixtures/snapshots/multi_column.fallback.md +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/fixtures/snapshots/table_heavy.docling.md +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/fixtures/snapshots/table_heavy.fallback.md +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/fixtures/snapshots/web_page.md +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/integration/__init__.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/integration/test_docling_persistence.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/integration/test_docx_backend_override.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/integration/test_docx_docling.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/integration/test_docx_fallback_on_warn.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/integration/test_docx_mammoth_fallback.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/integration/test_html_trafilatura.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/integration/test_pdf_backend_override.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/integration/test_pdf_docling.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/integration/test_pdf_pymupdf_fallback.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/integration/test_save_images.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/integration/test_snapshots.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/integration/test_txt.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/__init__.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/__init__.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_footnote_markers.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_html_entities.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_html_entities_double_encoded.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_ligatures.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_nfc.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_quotes_dashes.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_soft_hyphens.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_validate.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_whitespace.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_runner.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_strip_orphan_punctuation.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_structured_body_cleanup_stages.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_structured_compact_tables.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_structured_heading_hierarchy.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_structured_lift_figures.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_structured_normalize_cites.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_text_dedupe_paragraphs.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_text_dedupe_toc.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_text_dedupe_toc_table.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_text_dehyphenate.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_text_repair_line_wraps.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_text_restore_lists_code.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_text_strip_cover_artifacts.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_text_strip_headers_footers.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_text_strip_repeated_byline.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_text_strip_web_fragments.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/scripts/__init__.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/scripts/test_audit_outputs.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_atomic_write.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_atomic_write_symlink_race.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_auto_id.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_config.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_config_toml_warn.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_config_walkup.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_content_hash.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_docling_helper.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_frontmatter_helpers.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_frontmatter_key_injection.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_heuristics.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_heuristics_wikipedia_host.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_http_ip_allowlist.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_http_safe.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_http_ssrf_pin.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_http_user_agent.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_log_sanitizer.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_logging_module.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_meta_overrides.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_meta_reserved_keys.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_pdf_complexity.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_pdf_image_symlink.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_safe_dir_name.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_security_error_strings.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_url_creds_scrubbed.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_validators.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_workflow_permissions.py +0 -0
- {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_zipbomb_guard.py +0 -0
|
@@ -4,7 +4,83 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
6
|
|
|
7
|
-
## [1.
|
|
7
|
+
## [1.2.0] — 2026-11-09
|
|
8
|
+
|
|
9
|
+
Drops Python 3.10 to take the Pillow 13 security fixes, and fixes
|
|
10
|
+
missing H1 headings in HTML/URL output.
|
|
11
|
+
|
|
12
|
+
### Removed
|
|
13
|
+
|
|
14
|
+
- **Python 3.10 support.** `requires-python` is now `>=3.11`. Pillow
|
|
15
|
+
13.0.0 is the only release that fixes the advisories below, and it
|
|
16
|
+
requires Python 3.11. Python 3.10 reached end-of-life in October 2026.
|
|
17
|
+
pip on 3.10 keeps resolving any2md 1.1.1, which pins the vulnerable
|
|
18
|
+
pillow 12.x; upgrade the interpreter to get these fixes. The `tomli`
|
|
19
|
+
fallback dependency is gone with it (stdlib `tomllib` covers 3.11+),
|
|
20
|
+
and the CI matrix now tests 3.11, 3.12, and 3.13.
|
|
21
|
+
|
|
22
|
+
### Security
|
|
23
|
+
|
|
24
|
+
- **Pillow 13.0.0.** The `pillow>=12.2.0,<13` range held installs on a
|
|
25
|
+
release with 39 published advisories (e.g. CVE-2026-112395), all fixed
|
|
26
|
+
in 13.0.0. The range is now `pillow>=13.0.0,<14`. Docling's packages
|
|
27
|
+
accept `pillow<14`, so the `high-fidelity` extra still resolves.
|
|
28
|
+
- **Locked dev-container dependencies.** `.devcontainer/requirements.lock`
|
|
29
|
+
and `requirements.txt` move certifi, lxml-html-clean, soupsieve, and
|
|
30
|
+
urllib3 to their fixed releases (lxml follows to 6.1.3). Both
|
|
31
|
+
lockfiles now audit clean with `pip-audit`.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- **HTML/URL bodies had no H1.** trafilatura keeps a heading only
|
|
36
|
+
when it sits inside the content node it selects. A page `<h1>` in a
|
|
37
|
+
hero banner or `<header>` block outside that node was dropped, so
|
|
38
|
+
nearly every blog page warned `validator: H1 count is 0 (expected 1)`
|
|
39
|
+
(106 of 106 labs.zenity.io posts, 182 of 214 zenity.io/blog posts).
|
|
40
|
+
The HTML converter now restores the H1 before the pipeline runs:
|
|
41
|
+
- The body already has an H1: unchanged.
|
|
42
|
+
- The first body line repeats the title as plain or bold text: that
|
|
43
|
+
line is promoted to the H1, so the title appears once.
|
|
44
|
+
- Otherwise `# <title>` is inserted as the first line.
|
|
45
|
+
- No title is available: unchanged (the validator still warns).
|
|
46
|
+
|
|
47
|
+
The H1 text is the page's own `<h1>` when the page has exactly one
|
|
48
|
+
non-empty `<h1>`, else trafilatura's metadata title. `content_hash`,
|
|
49
|
+
`token_estimate`, `word_count`, `recommended_chunk_level`, and
|
|
50
|
+
`abstract_for_rag` are computed from the body with the H1 in place.
|
|
51
|
+
PDF, DOCX, and TXT output is unchanged.
|
|
52
|
+
- **HTML/URL heading levels skipped below the H1.** Many CMS templates
|
|
53
|
+
mark body sections as H3 or H4 directly under the page H1, so the
|
|
54
|
+
restored H1 surfaced `validator: heading level skip h1 -> h3` (34 of
|
|
55
|
+
the 320 zenity posts above). Headings are now re-ranked by section
|
|
56
|
+
depth below the H1: siblings stay siblings, nesting is kept, and only
|
|
57
|
+
the `#` prefix changes (`H3, H3, H4, H3` becomes `H2, H2, H3, H2`).
|
|
58
|
+
Bodies without skips are byte-identical. All 320 posts now convert
|
|
59
|
+
with 0 validator warnings.
|
|
60
|
+
|
|
61
|
+
### Added
|
|
62
|
+
|
|
63
|
+
- **`seo_title`** frontmatter extension field (HTML/URL only). Holds
|
|
64
|
+
trafilatura's metadata title (`og:title` / `<title>`) when it differs
|
|
65
|
+
from `title`. Omitted when the two match.
|
|
66
|
+
|
|
67
|
+
### Changed
|
|
68
|
+
|
|
69
|
+
- **CI pins ruff to 0.15.22.** Ruff 0.16 and later enable a wider default
|
|
70
|
+
rule set this repo has not adopted, which failed the required lint check
|
|
71
|
+
on an unchanged `main`. The `dev` extra pins the same version.
|
|
72
|
+
- **Release workflow pins `pypa/gh-action-pypi-publish` v1.14.3.** The
|
|
73
|
+
previous pin bundled twine 6.1, which rejects the Metadata-Version 2.5
|
|
74
|
+
that current hatchling writes, so uploads failed before reaching the
|
|
75
|
+
index. The validate step now pins `build==1.6.1` and `twine==7.0.0`.
|
|
76
|
+
- **HTML/URL `title` follows the visible page heading.** `title` is
|
|
77
|
+
still derived from the body H1, and the body H1 is now the page's
|
|
78
|
+
`<h1>`. On pages whose `og:title` differs from their `<h1>`, `title`
|
|
79
|
+
changes from the SEO title to the visible heading. The previous value
|
|
80
|
+
moves to `seo_title`. Pin a title with `--meta title=...` if you
|
|
81
|
+
depend on the old value.
|
|
82
|
+
|
|
83
|
+
## [1.1.1] — 2026-05-07
|
|
8
84
|
|
|
9
85
|
Security patch release. Closes 10 findings from a comprehensive scan
|
|
10
86
|
(bandit, snyk-code, gitleaks, pip-audit, trivy, manual review) plus
|
|
@@ -29,7 +29,7 @@ the test suite gates Docling-dependent tests with
|
|
|
29
29
|
`@pytest.mark.skipif(not has_docling(), …)` so they're transparently skipped
|
|
30
30
|
without it. Coverage of fallback paths is unaffected.
|
|
31
31
|
|
|
32
|
-
Python 3.
|
|
32
|
+
Python 3.11 or later is required.
|
|
33
33
|
|
|
34
34
|
## Running tests
|
|
35
35
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: any2md
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Convert PDF, DOCX, HTML, and TXT files — or web pages by URL — to clean, LLM-optimized Markdown with YAML frontmatter.
|
|
5
5
|
Project-URL: Homepage, https://github.com/rocklambros/any2md
|
|
6
6
|
Project-URL: Issues, https://github.com/rocklambros/any2md/issues
|
|
@@ -11,16 +11,15 @@ Classifier: License :: OSI Approved :: MIT License
|
|
|
11
11
|
Classifier: Operating System :: OS Independent
|
|
12
12
|
Classifier: Programming Language :: Python :: 3
|
|
13
13
|
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
14
|
-
Requires-Python: >=3.
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
15
|
Requires-Dist: beautifulsoup4<5,>=4.12.0
|
|
16
16
|
Requires-Dist: defusedxml<1,>=0.7.1
|
|
17
17
|
Requires-Dist: lxml<7,>=6.1.0
|
|
18
18
|
Requires-Dist: mammoth<2,>=1.6.0
|
|
19
19
|
Requires-Dist: markdownify<2,>=0.13.0
|
|
20
|
-
Requires-Dist: pillow<13
|
|
20
|
+
Requires-Dist: pillow<14,>=13.0.0
|
|
21
21
|
Requires-Dist: pymupdf4llm<1,>=0.0.17
|
|
22
22
|
Requires-Dist: pymupdf<2,>=1.24.0
|
|
23
|
-
Requires-Dist: tomli>=2.0.0; python_version < '3.11'
|
|
24
23
|
Requires-Dist: trafilatura<3,>=1.12.0
|
|
25
24
|
Requires-Dist: urllib3<3,>=2.6.3
|
|
26
25
|
Provides-Extra: dev
|
|
@@ -28,7 +27,7 @@ Requires-Dist: pytest-snapshot>=0.9.0; extra == 'dev'
|
|
|
28
27
|
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
29
28
|
Requires-Dist: pyyaml>=6.0; extra == 'dev'
|
|
30
29
|
Requires-Dist: reportlab>=4.0.0; extra == 'dev'
|
|
31
|
-
Requires-Dist: ruff
|
|
30
|
+
Requires-Dist: ruff==0.15.22; extra == 'dev'
|
|
32
31
|
Provides-Extra: high-fidelity
|
|
33
32
|
Requires-Dist: docling>=2.0.0; extra == 'high-fidelity'
|
|
34
33
|
Description-Content-Type: text/markdown
|
|
@@ -5,28 +5,17 @@ is treated as frontmatter overrides (deep-merged below ``--meta-file``
|
|
|
5
5
|
and CLI ``--meta`` arguments). The ``[document_id]`` table provides
|
|
6
6
|
``publisher_prefix`` / ``type_code`` overrides for ``--auto-id``.
|
|
7
7
|
|
|
8
|
-
TOML parsing uses stdlib ``tomllib``
|
|
9
|
-
interpreters we fall back to the optional ``tomli`` package; if neither
|
|
10
|
-
is importable, ``load_toml`` returns ``{}`` and config is silently
|
|
11
|
-
disabled (no hard error — config is opt-in convenience).
|
|
8
|
+
TOML parsing uses stdlib ``tomllib`` (any2md requires Python 3.11+).
|
|
12
9
|
"""
|
|
13
10
|
|
|
14
11
|
from __future__ import annotations
|
|
15
12
|
|
|
16
|
-
import
|
|
13
|
+
import tomllib
|
|
17
14
|
from pathlib import Path
|
|
18
15
|
from typing import Any
|
|
19
16
|
|
|
20
17
|
from any2md import _logging
|
|
21
18
|
|
|
22
|
-
if sys.version_info >= (3, 11):
|
|
23
|
-
import tomllib
|
|
24
|
-
else: # pragma: no cover - exercised only on Python 3.10
|
|
25
|
-
try:
|
|
26
|
-
import tomli as tomllib # type: ignore[import-not-found,no-redef]
|
|
27
|
-
except ImportError:
|
|
28
|
-
tomllib = None # type: ignore[assignment]
|
|
29
|
-
|
|
30
19
|
|
|
31
20
|
def load_toml(path: Path) -> dict[str, Any]:
|
|
32
21
|
"""Load a TOML file. Returns ``{}`` on any failure.
|
|
@@ -35,8 +24,6 @@ def load_toml(path: Path) -> dict[str, Any]:
|
|
|
35
24
|
not block conversion. Callers that need strict validation should
|
|
36
25
|
parse the file themselves.
|
|
37
26
|
"""
|
|
38
|
-
if tomllib is None: # pragma: no cover - 3.10 without tomli
|
|
39
|
-
return {}
|
|
40
27
|
try:
|
|
41
28
|
with open(path, "rb") as f:
|
|
42
29
|
return tomllib.load(f)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import re
|
|
5
6
|
import sys
|
|
6
7
|
from datetime import date
|
|
7
8
|
from email.utils import parsedate_to_datetime
|
|
@@ -84,6 +85,122 @@ def _extract(raw_html: str) -> tuple[str, str]:
|
|
|
84
85
|
return md, "trafilatura+bs4_fallback"
|
|
85
86
|
|
|
86
87
|
|
|
88
|
+
_WS_RE = re.compile(r"\s+")
|
|
89
|
+
_ATX_H1_RE = re.compile(r"^#[ \t]+\S")
|
|
90
|
+
_FENCE_RE = re.compile(r"^\s*(?:```|~~~)")
|
|
91
|
+
_WRAPPING_EMPHASIS_RE = re.compile(r"^(\*\*|__|\*|_)(.+?)\1$")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _one_line(text: str) -> str:
|
|
95
|
+
"""Collapse every whitespace run (newlines included) to one space."""
|
|
96
|
+
return _WS_RE.sub(" ", text).strip()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _visible_h1(raw_html: str) -> str | None:
|
|
100
|
+
"""Return the page's own ``<h1>`` text when the page has exactly one.
|
|
101
|
+
|
|
102
|
+
Empty ``<h1>`` wrappers (logo images, icon fonts) are ignored. Two or
|
|
103
|
+
more non-empty ``<h1>`` elements make the document title ambiguous,
|
|
104
|
+
so the caller falls back to the metadata title instead of guessing.
|
|
105
|
+
"""
|
|
106
|
+
soup = BeautifulSoup(raw_html, "lxml")
|
|
107
|
+
texts = [t for t in (_one_line(h.get_text()) for h in soup.find_all("h1")) if t]
|
|
108
|
+
return texts[0] if len(texts) == 1 else None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _unwrap_emphasis(text: str) -> str:
|
|
112
|
+
"""Strip emphasis markers that wrap the whole line (``**x**``, ``***x***``)."""
|
|
113
|
+
for _ in range(3): # bold-italic nests at most three marker levels deep
|
|
114
|
+
m = _WRAPPING_EMPHASIS_RE.match(text)
|
|
115
|
+
if not m:
|
|
116
|
+
break
|
|
117
|
+
text = _one_line(m.group(2))
|
|
118
|
+
return text
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _ensure_h1(body: str, title: str | None) -> str:
|
|
122
|
+
"""Guarantee the body opens with one H1 when a title is known.
|
|
123
|
+
|
|
124
|
+
trafilatura keeps a heading only when it sits inside the content node
|
|
125
|
+
it selects, so a page ``<h1>`` in a hero banner or ``<header>`` block
|
|
126
|
+
never reaches the body. This puts it back so the C7 validator and the
|
|
127
|
+
README's single-H1 guarantee hold for HTML/URL output.
|
|
128
|
+
|
|
129
|
+
- Body already has an ATX H1 outside fenced code: returned unchanged.
|
|
130
|
+
- No usable title: returned unchanged (the validator still warns).
|
|
131
|
+
- First non-blank line repeats the title, as plain or emphasized text
|
|
132
|
+
(case-insensitive): that line is promoted to the H1 so the title
|
|
133
|
+
does not appear twice.
|
|
134
|
+
- Otherwise ``# <title>`` and a blank line are prepended.
|
|
135
|
+
"""
|
|
136
|
+
lines = body.split("\n")
|
|
137
|
+
in_fence = False
|
|
138
|
+
for line in lines:
|
|
139
|
+
if _FENCE_RE.match(line):
|
|
140
|
+
in_fence = not in_fence
|
|
141
|
+
elif not in_fence and _ATX_H1_RE.match(line):
|
|
142
|
+
return body
|
|
143
|
+
|
|
144
|
+
heading = _one_line(title or "")
|
|
145
|
+
if not heading:
|
|
146
|
+
return body
|
|
147
|
+
|
|
148
|
+
first = next((i for i, line in enumerate(lines) if line.strip()), None)
|
|
149
|
+
if first is None:
|
|
150
|
+
return f"# {heading}\n"
|
|
151
|
+
|
|
152
|
+
candidate = _unwrap_emphasis(_one_line(lines[first]))
|
|
153
|
+
if candidate.casefold() == heading.casefold():
|
|
154
|
+
# Keep the page's own wording and casing for the promoted line.
|
|
155
|
+
lines[first] = f"# {candidate}"
|
|
156
|
+
return "\n".join(lines)
|
|
157
|
+
|
|
158
|
+
return f"# {heading}\n\n" + "\n".join(lines[first:])
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
_ATX_HEADING_RE = re.compile(r"^(#{1,6})(?=[ \t]+\S)")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _normalize_heading_levels(body: str) -> str:
|
|
165
|
+
"""Re-rank body headings so levels never skip below the H1.
|
|
166
|
+
|
|
167
|
+
CMS templates often mark sections as H3/H4 directly under the page
|
|
168
|
+
H1, which the C7 validator reports as an ``h1 -> h3`` skip. Each
|
|
169
|
+
heading's new level is its depth in a stack of the original levels
|
|
170
|
+
of the sections still open above it, so siblings stay siblings and
|
|
171
|
+
nesting is kept: ``H3, H3, H4, H3`` becomes ``H2, H2, H3, H2``.
|
|
172
|
+
|
|
173
|
+
Structured-lane S4 (``enforce_heading_hierarchy``) flattens one
|
|
174
|
+
heading at a time, which turns sibling H3s into H2 + H3 children;
|
|
175
|
+
that is why this lane uses its own ranking. H1 lines are never
|
|
176
|
+
moved and nothing is ever promoted to H1. Only the ``#`` prefix of
|
|
177
|
+
a heading line changes. A body with no skips is returned unchanged.
|
|
178
|
+
"""
|
|
179
|
+
lines = body.split("\n")
|
|
180
|
+
stack: list[int] = [1] # original levels of open sections; H1 at bottom
|
|
181
|
+
in_fence = False
|
|
182
|
+
changed = False
|
|
183
|
+
for i, line in enumerate(lines):
|
|
184
|
+
if _FENCE_RE.match(line):
|
|
185
|
+
in_fence = not in_fence
|
|
186
|
+
continue
|
|
187
|
+
m = None if in_fence else _ATX_HEADING_RE.match(line)
|
|
188
|
+
if not m:
|
|
189
|
+
continue
|
|
190
|
+
level = len(m.group(1))
|
|
191
|
+
if level == 1:
|
|
192
|
+
stack = [1]
|
|
193
|
+
continue
|
|
194
|
+
while len(stack) > 1 and stack[-1] >= level:
|
|
195
|
+
stack.pop()
|
|
196
|
+
stack.append(level)
|
|
197
|
+
new_level = len(stack)
|
|
198
|
+
if new_level != level:
|
|
199
|
+
lines[i] = "#" * new_level + line[level:]
|
|
200
|
+
changed = True
|
|
201
|
+
return "\n".join(lines) if changed else body
|
|
202
|
+
|
|
203
|
+
|
|
87
204
|
def _extract_metadata(
|
|
88
205
|
raw_html: str,
|
|
89
206
|
) -> tuple[str | None, list[str], str | None, str | None, list[str]]:
|
|
@@ -167,6 +284,14 @@ def convert_html(
|
|
|
167
284
|
|
|
168
285
|
md_text, extracted_via = _extract(raw_html)
|
|
169
286
|
title_hint, authors, org, doc_date, keywords = _extract_metadata(raw_html)
|
|
287
|
+
# Runs before pipeline.run so every text-lane stage, the C7
|
|
288
|
+
# validator, and compose() (content_hash, token_estimate, chunk
|
|
289
|
+
# level, abstract) all see the final body with its H1. The
|
|
290
|
+
# visible <h1> wins over the metadata title because it is the
|
|
291
|
+
# heading a reader sees; compose() keeps the metadata title as
|
|
292
|
+
# seo_title when the two differ.
|
|
293
|
+
md_text = _ensure_h1(md_text, _visible_h1(raw_html) or title_hint)
|
|
294
|
+
md_text = _normalize_heading_levels(md_text)
|
|
170
295
|
|
|
171
296
|
if source_url and not doc_date:
|
|
172
297
|
doc_date = _http_last_modified(source_url)
|
|
@@ -189,6 +314,7 @@ def convert_html(
|
|
|
189
314
|
doc_type="html",
|
|
190
315
|
extracted_via=extracted_via,
|
|
191
316
|
lane="text",
|
|
317
|
+
seo_title=title_hint,
|
|
192
318
|
)
|
|
193
319
|
full = compose(md_text, meta, options, overrides=options.frontmatter_overrides)
|
|
194
320
|
|
|
@@ -128,6 +128,15 @@ class SourceMeta:
|
|
|
128
128
|
# is the human/legal entity. Default None to keep existing call sites
|
|
129
129
|
# working without churn.
|
|
130
130
|
produced_by: str | None = None
|
|
131
|
+
# v1.2.0: HTML metadata title (og:title / <title> via trafilatura).
|
|
132
|
+
# Emitted only when it differs from the final `title`, which follows
|
|
133
|
+
# the body H1 (the page's visible heading).
|
|
134
|
+
seo_title: str | None = None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _same_title(a: str, b: str) -> bool:
|
|
138
|
+
"""Case- and whitespace-insensitive title equality."""
|
|
139
|
+
return " ".join(a.split()).casefold() == " ".join(b.split()).casefold()
|
|
131
140
|
|
|
132
141
|
|
|
133
142
|
def estimate_tokens(body: str) -> int:
|
|
@@ -318,6 +327,9 @@ def _build_fields(
|
|
|
318
327
|
fields["extracted_via"] = meta.extracted_via # v1.0 provenance extension
|
|
319
328
|
if meta.produced_by is not None:
|
|
320
329
|
fields["produced_by"] = meta.produced_by # v1.0.2 extension (spec §6.1)
|
|
330
|
+
seo_title = (meta.seo_title or "").strip()
|
|
331
|
+
if seo_title and not _same_title(seo_title, title):
|
|
332
|
+
fields["seo_title"] = seo_title # v1.2.0 extension (HTML only)
|
|
321
333
|
if meta.pages is not None:
|
|
322
334
|
fields["pages"] = meta.pages
|
|
323
335
|
if meta.word_count is not None:
|
|
@@ -301,6 +301,8 @@ class SourceMeta:
|
|
|
301
301
|
"trafilatura", "trafilatura+bs4_fallback", "heuristic",
|
|
302
302
|
]
|
|
303
303
|
lane: Lane
|
|
304
|
+
seo_title: str | None = None # NEW v1.2.0 — HTML metadata title; emitted
|
|
305
|
+
# only when it differs from the final title.
|
|
304
306
|
```
|
|
305
307
|
|
|
306
308
|
### Field-by-field
|
|
@@ -311,6 +313,7 @@ class SourceMeta:
|
|
|
311
313
|
| `authors` | PDF (`/Author` parsed), DOCX (`dc:creator`), HTML (`<meta name="author">`), TXT (always `[]`). v1.0.2 adds body-text extraction and an arxiv API enrichment via `heuristics.extract_authors`. | No — empty `[]` is valid |
|
|
312
314
|
| `organization` | PDF/DOCX `Company`, HTML `og:site_name`, otherwise `None`. v1.0.2 routes the PDF `Creator` field and DOCX `<Application>` element through `heuristics.filter_organization` so software values populate `produced_by` instead. | No — empty `""` is valid |
|
|
313
315
|
| `produced_by` | PDF `Creator` field (when it matches a known software pattern); DOCX `<Application>` element of `docProps/app.xml`; otherwise `None`. New in v1.0.2. | No — extension field |
|
|
316
|
+
| `seo_title` | HTML/URL only: trafilatura's metadata title (`og:title` / `<title>`). `compose()` emits it only when it differs from the final `title`, which follows the body H1. Before the pipeline runs, the HTML converter restores that H1 from the page's single `<h1>` (else the metadata title) when trafilatura drops it. New in v1.2.0. | No — extension field |
|
|
314
317
|
| `date` | PDF `creationDate`, DOCX `dcterms:modified`, URL `Last-Modified` HEAD response, file mtime, today | Yes — derived to today if all sources fail |
|
|
315
318
|
| `keywords` | PDF `/Keywords`, DOCX `cp:keywords`, HTML `<meta name="keywords">`, trafilatura `categories`, otherwise `[]` | No — emitted only when non-empty |
|
|
316
319
|
| `pages` | PDF only (page count via `pymupdf.open`); other formats `None` | No — extension field |
|
|
@@ -54,8 +54,9 @@ optional, then any2md extension fields retained from v0.7 for traceability.
|
|
|
54
54
|
This is the order they appear in the YAML block.
|
|
55
55
|
|
|
56
56
|
v1.0.2 added one extension field, `produced_by`, between `extracted_via` and
|
|
57
|
-
`pages`.
|
|
58
|
-
other any2md
|
|
57
|
+
`pages`. v1.2.0 added `seo_title` (HTML/URL only) right after it. Both are
|
|
58
|
+
documented in their own subsections below and listed alongside the other any2md
|
|
59
|
+
extension fields.
|
|
59
60
|
|
|
60
61
|
## Field-by-field reference
|
|
61
62
|
|
|
@@ -77,6 +78,11 @@ trafilatura), then to the source filename with the extension removed and
|
|
|
77
78
|
underscores converted to spaces. Never empty — the fallback is guaranteed to
|
|
78
79
|
produce a non-empty string.
|
|
79
80
|
|
|
81
|
+
For HTML and URL input, the body H1 is the page's own `<h1>` when the page has
|
|
82
|
+
exactly one non-empty `<h1>`, so `title` is the heading a reader sees. When the
|
|
83
|
+
`og:title` / `<title>` metadata differs from it, that value is kept in
|
|
84
|
+
[`seo_title`](#seo_title).
|
|
85
|
+
|
|
80
86
|
**Example:** `"Quarterly Financial Report"`
|
|
81
87
|
|
|
82
88
|
**Common mistake:** Editing the H1 in the body after generation invalidates
|
|
@@ -385,6 +391,22 @@ get a real organization name (or `""`) and never see software junk there.
|
|
|
385
391
|
|
|
386
392
|
**Example empty:** field is omitted from the YAML block.
|
|
387
393
|
|
|
394
|
+
### `seo_title`
|
|
395
|
+
|
|
396
|
+
**auto, conditional** | type: `string` (any2md extension field, new in v1.2.0)
|
|
397
|
+
|
|
398
|
+
**Derivation:** The HTML metadata title that trafilatura reads from
|
|
399
|
+
`og:title` or `<title>`. Emitted only for HTML and URL input, and only when it
|
|
400
|
+
differs from `title` (case and whitespace ignored). On many sites the SEO title
|
|
401
|
+
and the visible `<h1>` are written separately. `title` follows the visible
|
|
402
|
+
heading, so `seo_title` keeps the other one for search and citation use.
|
|
403
|
+
|
|
404
|
+
**Example:** `title: "A Read-Only Account is All it Takes to Own a LiteLLM
|
|
405
|
+
Server"` alongside `seo_title: "LiteLLM CVE-2026-35029: Exploited One Day After
|
|
406
|
+
Disclosure"`.
|
|
407
|
+
|
|
408
|
+
**Example empty:** field is omitted from the YAML block.
|
|
409
|
+
|
|
388
410
|
### `pages`, `word_count`
|
|
389
411
|
|
|
390
412
|
**auto, conditional** | type: `integer`
|
|
@@ -400,15 +422,31 @@ follows three rules.
|
|
|
400
422
|
|
|
401
423
|
### Single H1
|
|
402
424
|
|
|
403
|
-
Exactly one H1 (`# Heading`) per document.
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
425
|
+
Exactly one H1 (`# Heading`) per document. This rule is what makes downstream
|
|
426
|
+
chunkers and titlers work reliably. How it is enforced depends on the lane:
|
|
427
|
+
|
|
428
|
+
- **Structured lane (Docling):** the S4 stage promotes the first heading to H1
|
|
429
|
+
if no H1 was present in the source, and demotes any subsequent H1s to H2.
|
|
430
|
+
- **HTML / URL:** the converter restores the page title as the H1 when
|
|
431
|
+
trafilatura drops it (a page `<h1>` outside the article container is not
|
|
432
|
+
extracted). If the first body line already repeats the title as plain or
|
|
433
|
+
bold text, that line is promoted instead of adding a duplicate.
|
|
434
|
+
- **Other text-lane input (pymupdf4llm, mammoth, TXT):** no rewriting. A body
|
|
435
|
+
without an H1 emits the validator warning described below.
|
|
407
436
|
|
|
408
437
|
### No skipped levels
|
|
409
438
|
|
|
410
|
-
Heading levels never skip.
|
|
411
|
-
|
|
439
|
+
Heading levels never skip. How skips are repaired depends on the lane:
|
|
440
|
+
|
|
441
|
+
- **Structured lane (Docling):** the S4 stage rewrites a heading that jumps
|
|
442
|
+
more than one level to one level below the previous heading.
|
|
443
|
+
- **HTML / URL:** headings are re-ranked by section depth below the H1. A body
|
|
444
|
+
whose sections are marked H3 or H4 directly under the H1 becomes H2 sections,
|
|
445
|
+
sibling headings stay siblings, and nesting is kept (`H3, H3, H4, H3` becomes
|
|
446
|
+
`H2, H2, H3, H2`). Only the `#` prefix of a heading line changes.
|
|
447
|
+
- **Other text-lane input (pymupdf4llm, mammoth, TXT):** no rewriting.
|
|
448
|
+
|
|
449
|
+
Any skip that survives emits a non-fatal validator warning during conversion
|
|
412
450
|
(visible in stderr; promoted to a non-zero exit by `--strict`).
|
|
413
451
|
|
|
414
452
|
### Citations
|
|
@@ -8,7 +8,7 @@ dynamic = ["version"]
|
|
|
8
8
|
description = "Convert PDF, DOCX, HTML, and TXT files — or web pages by URL — to clean, LLM-optimized Markdown with YAML frontmatter."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
11
|
-
requires-python = ">=3.
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
12
|
authors = [
|
|
13
13
|
{ name = "rocklambros", email = "rock@rockcyber.com" },
|
|
14
14
|
]
|
|
@@ -26,10 +26,9 @@ dependencies = [
|
|
|
26
26
|
"trafilatura>=1.12.0,<3",
|
|
27
27
|
"beautifulsoup4>=4.12.0,<5",
|
|
28
28
|
"lxml>=6.1.0,<7", # CVE-2026-41066 fix
|
|
29
|
-
"pillow>=
|
|
29
|
+
"pillow>=13.0.0,<14", # 39 advisories fixed in 13.0.0 (e.g. CVE-2026-112395)
|
|
30
30
|
"urllib3>=2.6.3,<3", # CVE-2026-21441 fix
|
|
31
31
|
"defusedxml>=0.7.1,<1", # XXE defense-in-depth (Task P2)
|
|
32
|
-
"tomli>=2.0.0; python_version < '3.11'", # stdlib tomllib is 3.11+; needed by config.load_toml on 3.10
|
|
33
32
|
]
|
|
34
33
|
|
|
35
34
|
[project.optional-dependencies]
|
|
@@ -38,7 +37,7 @@ dev = [
|
|
|
38
37
|
"pytest-snapshot>=0.9.0",
|
|
39
38
|
"pyyaml>=6.0",
|
|
40
39
|
"reportlab>=4.0.0",
|
|
41
|
-
"ruff
|
|
40
|
+
"ruff==0.15.22", # keep in step with .github/workflows/ci.yml
|
|
42
41
|
]
|
|
43
42
|
high-fidelity = [
|
|
44
43
|
"docling>=2.0.0",
|
|
@@ -10,7 +10,7 @@ beautifulsoup4==4.14.3
|
|
|
10
10
|
# via
|
|
11
11
|
# any2md (pyproject.toml)
|
|
12
12
|
# markdownify
|
|
13
|
-
certifi==2026.
|
|
13
|
+
certifi==2026.10.23
|
|
14
14
|
# via trafilatura
|
|
15
15
|
charset-normalizer==3.4.7
|
|
16
16
|
# via
|
|
@@ -28,20 +28,20 @@ htmldate==1.9.4
|
|
|
28
28
|
# via trafilatura
|
|
29
29
|
justext==3.0.2
|
|
30
30
|
# via trafilatura
|
|
31
|
-
lxml[html-clean]==6.1.
|
|
31
|
+
lxml[html-clean]==6.1.3
|
|
32
32
|
# via
|
|
33
33
|
# any2md (pyproject.toml)
|
|
34
34
|
# htmldate
|
|
35
35
|
# justext
|
|
36
36
|
# lxml-html-clean
|
|
37
37
|
# trafilatura
|
|
38
|
-
lxml-html-clean==0.4.
|
|
38
|
+
lxml-html-clean==0.4.6
|
|
39
39
|
# via lxml
|
|
40
40
|
mammoth==1.12.0
|
|
41
41
|
# via any2md (pyproject.toml)
|
|
42
42
|
markdownify==1.2.2
|
|
43
43
|
# via any2md (pyproject.toml)
|
|
44
|
-
pillow==
|
|
44
|
+
pillow==13.0.0
|
|
45
45
|
# via any2md (pyproject.toml)
|
|
46
46
|
pymupdf==1.27.2.3
|
|
47
47
|
# via
|
|
@@ -61,7 +61,7 @@ six==1.17.0
|
|
|
61
61
|
# via
|
|
62
62
|
# markdownify
|
|
63
63
|
# python-dateutil
|
|
64
|
-
soupsieve==2.
|
|
64
|
+
soupsieve==2.10
|
|
65
65
|
# via beautifulsoup4
|
|
66
66
|
tabulate==0.10.0
|
|
67
67
|
# via pymupdf4llm
|
|
@@ -73,7 +73,7 @@ typing-extensions==4.15.0
|
|
|
73
73
|
# via beautifulsoup4
|
|
74
74
|
tzlocal==5.3.1
|
|
75
75
|
# via dateparser
|
|
76
|
-
urllib3==2.
|
|
76
|
+
urllib3==2.8.0
|
|
77
77
|
# via
|
|
78
78
|
# any2md (pyproject.toml)
|
|
79
79
|
# courlan
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Contest Announcement</title>
|
|
6
|
+
</head>
|
|
7
|
+
<body>
|
|
8
|
+
<section class="hero"><div class="heading"><h1>Join the Prompt Jar Contest</h1></div></section>
|
|
9
|
+
<article>
|
|
10
|
+
<p><strong>Join the Prompt Jar Contest</strong></p>
|
|
11
|
+
<p>Attackers increasingly target AI gateways because a single deployment holds provider keys, user records, and spend data for every model behind it. This paragraph is long enough to survive extraction.</p>
|
|
12
|
+
<p>Participants submit their most creative prompts at the booth, and the winner receives a prize announced at the end of the conference week.</p>
|
|
13
|
+
</article>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Gateway Escalation Writeup | Example Labs</title>
|
|
6
|
+
<meta property="og:title" content="Gateway CVE: Exploited One Day After Disclosure">
|
|
7
|
+
<meta name="author" content="Test Author">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<nav><a href="/">Home</a> <a href="/blog">Blog</a></nav>
|
|
11
|
+
<main>
|
|
12
|
+
<section class="hero">
|
|
13
|
+
<header>
|
|
14
|
+
<p>Research</p>
|
|
15
|
+
<h1>A Read-Only Account Is All It Takes</h1>
|
|
16
|
+
</header>
|
|
17
|
+
</section>
|
|
18
|
+
<article>
|
|
19
|
+
<div class="prose">
|
|
20
|
+
<p>Attackers increasingly target AI gateways because a single deployment holds provider keys, user records, and spend data for every model behind it. This paragraph is long enough to survive extraction.</p>
|
|
21
|
+
<h2>Background</h2>
|
|
22
|
+
<p>The vulnerable endpoint accepted configuration updates from accounts that the documentation described as read-only, which let a low-privilege user rewrite the server configuration.</p>
|
|
23
|
+
<h2>Impact</h2>
|
|
24
|
+
<p>An attacker with the lowest role could read every stored provider key and pivot to the upstream model accounts without any further exploitation steps.</p>
|
|
25
|
+
<p>Detail paragraph 1: the gateway stored provider credentials in a shared database, and the role check ran only on the user interface, so direct calls to the administrative interface skipped it. Operators who rotated keys after the advisory still exposed the new keys to any account that could reach the endpoint.</p>
|
|
26
|
+
<p>Detail paragraph 2: the gateway stored provider credentials in a shared database, and the role check ran only on the user interface, so direct calls to the administrative interface skipped it. Operators who rotated keys after the advisory still exposed the new keys to any account that could reach the endpoint.</p>
|
|
27
|
+
<p>Detail paragraph 3: the gateway stored provider credentials in a shared database, and the role check ran only on the user interface, so direct calls to the administrative interface skipped it. Operators who rotated keys after the advisory still exposed the new keys to any account that could reach the endpoint.</p>
|
|
28
|
+
<p>Detail paragraph 4: the gateway stored provider credentials in a shared database, and the role check ran only on the user interface, so direct calls to the administrative interface skipped it. Operators who rotated keys after the advisory still exposed the new keys to any account that could reach the endpoint.</p>
|
|
29
|
+
<p>Detail paragraph 5: the gateway stored provider credentials in a shared database, and the role check ran only on the user interface, so direct calls to the administrative interface skipped it. Operators who rotated keys after the advisory still exposed the new keys to any account that could reach the endpoint.</p>
|
|
30
|
+
<p>Detail paragraph 6: the gateway stored provider credentials in a shared database, and the role check ran only on the user interface, so direct calls to the administrative interface skipped it. Operators who rotated keys after the advisory still exposed the new keys to any account that could reach the endpoint.</p>
|
|
31
|
+
<p>Detail paragraph 7: the gateway stored provider credentials in a shared database, and the role check ran only on the user interface, so direct calls to the administrative interface skipped it. Operators who rotated keys after the advisory still exposed the new keys to any account that could reach the endpoint.</p>
|
|
32
|
+
<p>Detail paragraph 8: the gateway stored provider credentials in a shared database, and the role check ran only on the user interface, so direct calls to the administrative interface skipped it. Operators who rotated keys after the advisory still exposed the new keys to any account that could reach the endpoint.</p>
|
|
33
|
+
</div>
|
|
34
|
+
</article>
|
|
35
|
+
</main>
|
|
36
|
+
<footer>Site footer that must be stripped.</footer>
|
|
37
|
+
</body>
|
|
38
|
+
</html>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Agent Recon Notes</title>
|
|
6
|
+
</head>
|
|
7
|
+
<body>
|
|
8
|
+
<section class="hero"><div class="heading"><h1>Agent Recon Notes</h1></div></section>
|
|
9
|
+
<article>
|
|
10
|
+
<p>This section explains one part of the incident in enough words that trafilatura treats the block as main content rather than boilerplate.</p>
|
|
11
|
+
<h3>Discovery</h3>
|
|
12
|
+
<p>This section explains one part of the incident in enough words that trafilatura treats the block as main content rather than boilerplate.</p>
|
|
13
|
+
<h3>Enumeration</h3>
|
|
14
|
+
<p>This section explains one part of the incident in enough words that trafilatura treats the block as main content rather than boilerplate.</p>
|
|
15
|
+
<h4>Tooling</h4>
|
|
16
|
+
<p>This section explains one part of the incident in enough words that trafilatura treats the block as main content rather than boilerplate.</p>
|
|
17
|
+
<h3>Takeaways</h3>
|
|
18
|
+
<p>This section explains one part of the incident in enough words that trafilatura treats the block as main content rather than boilerplate.</p>
|
|
19
|
+
</article>
|
|
20
|
+
</body>
|
|
21
|
+
</html>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
</head>
|
|
6
|
+
<body>
|
|
7
|
+
<article>
|
|
8
|
+
<p>Attackers increasingly target AI gateways because a single deployment holds provider keys, user records, and spend data for every model behind it. This paragraph is long enough to survive extraction.</p>
|
|
9
|
+
<p>A second paragraph with no headings anywhere on the page and no title metadata in the document head at all.</p>
|
|
10
|
+
</article>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|