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.
Files changed (163) hide show
  1. {any2md-1.1.1 → any2md-1.2.0}/CHANGELOG.md +77 -1
  2. {any2md-1.1.1 → any2md-1.2.0}/CONTRIBUTING.md +1 -1
  3. {any2md-1.1.1 → any2md-1.2.0}/PKG-INFO +5 -6
  4. {any2md-1.1.1 → any2md-1.2.0}/any2md/__init__.py +1 -1
  5. {any2md-1.1.1 → any2md-1.2.0}/any2md/config.py +2 -15
  6. {any2md-1.1.1 → any2md-1.2.0}/any2md/converters/html.py +126 -0
  7. {any2md-1.1.1 → any2md-1.2.0}/any2md/frontmatter.py +12 -0
  8. {any2md-1.1.1 → any2md-1.2.0}/docs/architecture.md +3 -0
  9. {any2md-1.1.1 → any2md-1.2.0}/docs/output-format.md +46 -8
  10. {any2md-1.1.1 → any2md-1.2.0}/pyproject.toml +3 -4
  11. {any2md-1.1.1 → any2md-1.2.0}/requirements.txt +6 -6
  12. any2md-1.2.0/tests/fixtures/docs/web_bold_title_first_line.html +15 -0
  13. any2md-1.2.0/tests/fixtures/docs/web_h1_in_hero.html +38 -0
  14. any2md-1.2.0/tests/fixtures/docs/web_h3_sections.html +21 -0
  15. any2md-1.2.0/tests/fixtures/docs/web_no_title.html +12 -0
  16. any2md-1.2.0/tests/integration/test_html_h1.py +148 -0
  17. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_docling_cache.py +2 -2
  18. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_frontmatter_compose.py +28 -0
  19. any2md-1.2.0/tests/unit/test_html_heading_levels.py +72 -0
  20. any2md-1.2.0/tests/unit/test_html_title_h1.py +117 -0
  21. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_lockfile_freshness.py +1 -1
  22. {any2md-1.1.1 → any2md-1.2.0}/.gitignore +0 -0
  23. {any2md-1.1.1 → any2md-1.2.0}/CODE_OF_CONDUCT.md +0 -0
  24. {any2md-1.1.1 → any2md-1.2.0}/LICENSE +0 -0
  25. {any2md-1.1.1 → any2md-1.2.0}/README.md +0 -0
  26. {any2md-1.1.1 → any2md-1.2.0}/SECURITY.md +0 -0
  27. {any2md-1.1.1 → any2md-1.2.0}/any2md/__main__.py +0 -0
  28. {any2md-1.1.1 → any2md-1.2.0}/any2md/_docling.py +0 -0
  29. {any2md-1.1.1 → any2md-1.2.0}/any2md/_docling_cache.py +0 -0
  30. {any2md-1.1.1 → any2md-1.2.0}/any2md/_http.py +0 -0
  31. {any2md-1.1.1 → any2md-1.2.0}/any2md/_logging.py +0 -0
  32. {any2md-1.1.1 → any2md-1.2.0}/any2md/cli.py +0 -0
  33. {any2md-1.1.1 → any2md-1.2.0}/any2md/converters/__init__.py +0 -0
  34. {any2md-1.1.1 → any2md-1.2.0}/any2md/converters/docx.py +0 -0
  35. {any2md-1.1.1 → any2md-1.2.0}/any2md/converters/pdf.py +0 -0
  36. {any2md-1.1.1 → any2md-1.2.0}/any2md/converters/txt.py +0 -0
  37. {any2md-1.1.1 → any2md-1.2.0}/any2md/heuristics.py +0 -0
  38. {any2md-1.1.1 → any2md-1.2.0}/any2md/pipeline/__init__.py +0 -0
  39. {any2md-1.1.1 → any2md-1.2.0}/any2md/pipeline/cleanup.py +0 -0
  40. {any2md-1.1.1 → any2md-1.2.0}/any2md/pipeline/structured.py +0 -0
  41. {any2md-1.1.1 → any2md-1.2.0}/any2md/pipeline/text.py +0 -0
  42. {any2md-1.1.1 → any2md-1.2.0}/any2md/utils.py +0 -0
  43. {any2md-1.1.1 → any2md-1.2.0}/any2md/validators.py +0 -0
  44. {any2md-1.1.1 → any2md-1.2.0}/assets/logo-original.jpeg +0 -0
  45. {any2md-1.1.1 → any2md-1.2.0}/assets/logo.png +0 -0
  46. {any2md-1.1.1 → any2md-1.2.0}/assets/social-preview.jpg +0 -0
  47. {any2md-1.1.1 → any2md-1.2.0}/docs/cli-reference.md +0 -0
  48. {any2md-1.1.1 → any2md-1.2.0}/docs/superpowers/plans/2026-04-26-any2md-v1-phase1-foundation.md +0 -0
  49. {any2md-1.1.1 → any2md-1.2.0}/docs/superpowers/plans/2026-04-26-any2md-v1-phase2-docling-backend.md +0 -0
  50. {any2md-1.1.1 → any2md-1.2.0}/docs/superpowers/plans/2026-04-26-any2md-v1-phase3-figures-ocr-text-lane.md +0 -0
  51. {any2md-1.1.1 → any2md-1.2.0}/docs/superpowers/plans/2026-04-26-any2md-v1-phase4-polish-config-docs.md +0 -0
  52. {any2md-1.1.1 → any2md-1.2.0}/docs/superpowers/plans/2026-04-26-any2md-v1.0.2-implementation.md +0 -0
  53. {any2md-1.1.1 → any2md-1.2.0}/docs/superpowers/plans/2026-04-26-issue-17-toc-followups.md +0 -0
  54. {any2md-1.1.1 → any2md-1.2.0}/docs/superpowers/plans/2026-04-27-v1.0.6-security-hardening.md +0 -0
  55. {any2md-1.1.1 → any2md-1.2.0}/docs/superpowers/plans/2026-05-04-v1.1.0-persistent-converter.md +0 -0
  56. {any2md-1.1.1 → any2md-1.2.0}/docs/superpowers/plans/2026-05-06-v1.1.1-security-hardening.md +0 -0
  57. {any2md-1.1.1 → any2md-1.2.0}/docs/superpowers/specs/2026-04-26-any2md-v1-design.md +0 -0
  58. {any2md-1.1.1 → any2md-1.2.0}/docs/superpowers/specs/2026-04-26-any2md-v1.0.2-design.md +0 -0
  59. {any2md-1.1.1 → any2md-1.2.0}/docs/superpowers/specs/2026-04-26-issue-17-toc-followups-design.md +0 -0
  60. {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
  61. {any2md-1.1.1 → any2md-1.2.0}/docs/superpowers/specs/2026-04-27-v1.0.6-security-hardening-design.md +0 -0
  62. {any2md-1.1.1 → any2md-1.2.0}/docs/superpowers/specs/2026-05-04-v1.1.0-persistent-converter-design.md +0 -0
  63. {any2md-1.1.1 → any2md-1.2.0}/docs/superpowers/specs/2026-05-06-v1.1.1-security-design.md +0 -0
  64. {any2md-1.1.1 → any2md-1.2.0}/docs/troubleshooting.md +0 -0
  65. {any2md-1.1.1 → any2md-1.2.0}/docs/upgrading-from-0.7.md +0 -0
  66. {any2md-1.1.1 → any2md-1.2.0}/scripts/audit-outputs.py +0 -0
  67. {any2md-1.1.1 → any2md-1.2.0}/scripts/bench_docling_cache.py +0 -0
  68. {any2md-1.1.1 → any2md-1.2.0}/scripts/validate-phase1.sh +0 -0
  69. {any2md-1.1.1 → any2md-1.2.0}/tests/__init__.py +0 -0
  70. {any2md-1.1.1 → any2md-1.2.0}/tests/cli/__init__.py +0 -0
  71. {any2md-1.1.1 → any2md-1.2.0}/tests/cli/test_cli_backend.py +0 -0
  72. {any2md-1.1.1 → any2md-1.2.0}/tests/cli/test_cli_existing_flags.py +0 -0
  73. {any2md-1.1.1 → any2md-1.2.0}/tests/cli/test_cli_exit_codes.py +0 -0
  74. {any2md-1.1.1 → any2md-1.2.0}/tests/cli/test_cli_high_fidelity.py +0 -0
  75. {any2md-1.1.1 → any2md-1.2.0}/tests/cli/test_cli_meta.py +0 -0
  76. {any2md-1.1.1 → any2md-1.2.0}/tests/cli/test_cli_no_arxiv_lookup.py +0 -0
  77. {any2md-1.1.1 → any2md-1.2.0}/tests/cli/test_cli_ocr_save_images.py +0 -0
  78. {any2md-1.1.1 → any2md-1.2.0}/tests/cli/test_cli_quiet_verbose.py +0 -0
  79. {any2md-1.1.1 → any2md-1.2.0}/tests/cli/test_cli_strict.py +0 -0
  80. {any2md-1.1.1 → any2md-1.2.0}/tests/conftest.py +0 -0
  81. {any2md-1.1.1 → any2md-1.2.0}/tests/fixtures/__init__.py +0 -0
  82. {any2md-1.1.1 → any2md-1.2.0}/tests/fixtures/docs/ligatures_and_softhyphens.txt +0 -0
  83. {any2md-1.1.1 → any2md-1.2.0}/tests/fixtures/docs/multi_column.pdf +0 -0
  84. {any2md-1.1.1 → any2md-1.2.0}/tests/fixtures/docs/table_heavy.docx +0 -0
  85. {any2md-1.1.1 → any2md-1.2.0}/tests/fixtures/docs/web_page.html +0 -0
  86. {any2md-1.1.1 → any2md-1.2.0}/tests/fixtures/make_fixtures.py +0 -0
  87. {any2md-1.1.1 → any2md-1.2.0}/tests/fixtures/snapshots/ligatures_and_softhyphens.md +0 -0
  88. {any2md-1.1.1 → any2md-1.2.0}/tests/fixtures/snapshots/multi_column.docling.md +0 -0
  89. {any2md-1.1.1 → any2md-1.2.0}/tests/fixtures/snapshots/multi_column.fallback.md +0 -0
  90. {any2md-1.1.1 → any2md-1.2.0}/tests/fixtures/snapshots/table_heavy.docling.md +0 -0
  91. {any2md-1.1.1 → any2md-1.2.0}/tests/fixtures/snapshots/table_heavy.fallback.md +0 -0
  92. {any2md-1.1.1 → any2md-1.2.0}/tests/fixtures/snapshots/web_page.md +0 -0
  93. {any2md-1.1.1 → any2md-1.2.0}/tests/integration/__init__.py +0 -0
  94. {any2md-1.1.1 → any2md-1.2.0}/tests/integration/test_docling_persistence.py +0 -0
  95. {any2md-1.1.1 → any2md-1.2.0}/tests/integration/test_docx_backend_override.py +0 -0
  96. {any2md-1.1.1 → any2md-1.2.0}/tests/integration/test_docx_docling.py +0 -0
  97. {any2md-1.1.1 → any2md-1.2.0}/tests/integration/test_docx_fallback_on_warn.py +0 -0
  98. {any2md-1.1.1 → any2md-1.2.0}/tests/integration/test_docx_mammoth_fallback.py +0 -0
  99. {any2md-1.1.1 → any2md-1.2.0}/tests/integration/test_html_trafilatura.py +0 -0
  100. {any2md-1.1.1 → any2md-1.2.0}/tests/integration/test_pdf_backend_override.py +0 -0
  101. {any2md-1.1.1 → any2md-1.2.0}/tests/integration/test_pdf_docling.py +0 -0
  102. {any2md-1.1.1 → any2md-1.2.0}/tests/integration/test_pdf_pymupdf_fallback.py +0 -0
  103. {any2md-1.1.1 → any2md-1.2.0}/tests/integration/test_save_images.py +0 -0
  104. {any2md-1.1.1 → any2md-1.2.0}/tests/integration/test_snapshots.py +0 -0
  105. {any2md-1.1.1 → any2md-1.2.0}/tests/integration/test_txt.py +0 -0
  106. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/__init__.py +0 -0
  107. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/__init__.py +0 -0
  108. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_footnote_markers.py +0 -0
  109. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_html_entities.py +0 -0
  110. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_html_entities_double_encoded.py +0 -0
  111. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_ligatures.py +0 -0
  112. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_nfc.py +0 -0
  113. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_quotes_dashes.py +0 -0
  114. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_soft_hyphens.py +0 -0
  115. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_validate.py +0 -0
  116. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_whitespace.py +0 -0
  117. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_runner.py +0 -0
  118. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_strip_orphan_punctuation.py +0 -0
  119. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_structured_body_cleanup_stages.py +0 -0
  120. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_structured_compact_tables.py +0 -0
  121. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_structured_heading_hierarchy.py +0 -0
  122. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_structured_lift_figures.py +0 -0
  123. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_structured_normalize_cites.py +0 -0
  124. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_text_dedupe_paragraphs.py +0 -0
  125. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_text_dedupe_toc.py +0 -0
  126. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_text_dedupe_toc_table.py +0 -0
  127. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_text_dehyphenate.py +0 -0
  128. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_text_repair_line_wraps.py +0 -0
  129. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_text_restore_lists_code.py +0 -0
  130. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_text_strip_cover_artifacts.py +0 -0
  131. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_text_strip_headers_footers.py +0 -0
  132. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_text_strip_repeated_byline.py +0 -0
  133. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/pipeline/test_text_strip_web_fragments.py +0 -0
  134. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/scripts/__init__.py +0 -0
  135. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/scripts/test_audit_outputs.py +0 -0
  136. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_atomic_write.py +0 -0
  137. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_atomic_write_symlink_race.py +0 -0
  138. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_auto_id.py +0 -0
  139. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_config.py +0 -0
  140. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_config_toml_warn.py +0 -0
  141. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_config_walkup.py +0 -0
  142. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_content_hash.py +0 -0
  143. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_docling_helper.py +0 -0
  144. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_frontmatter_helpers.py +0 -0
  145. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_frontmatter_key_injection.py +0 -0
  146. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_heuristics.py +0 -0
  147. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_heuristics_wikipedia_host.py +0 -0
  148. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_http_ip_allowlist.py +0 -0
  149. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_http_safe.py +0 -0
  150. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_http_ssrf_pin.py +0 -0
  151. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_http_user_agent.py +0 -0
  152. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_log_sanitizer.py +0 -0
  153. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_logging_module.py +0 -0
  154. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_meta_overrides.py +0 -0
  155. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_meta_reserved_keys.py +0 -0
  156. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_pdf_complexity.py +0 -0
  157. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_pdf_image_symlink.py +0 -0
  158. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_safe_dir_name.py +0 -0
  159. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_security_error_strings.py +0 -0
  160. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_url_creds_scrubbed.py +0 -0
  161. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_validators.py +0 -0
  162. {any2md-1.1.1 → any2md-1.2.0}/tests/unit/test_workflow_permissions.py +0 -0
  163. {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.1.1] — 2026-05-DD
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.10 or later is required.
32
+ Python 3.11 or later is required.
33
33
 
34
34
  ## Running tests
35
35
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: any2md
3
- Version: 1.1.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.10
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,>=12.2.0
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>=0.6.0; extra == 'dev'
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
@@ -2,7 +2,7 @@
2
2
 
3
3
  from any2md._docling_cache import docling_session, release_models
4
4
 
5
- __version__ = "1.1.1"
5
+ __version__ = "1.2.0"
6
6
 
7
7
  __all__ = [
8
8
  "__version__",
@@ -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`` on Python 3.11+. On older
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 sys
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`. It's documented in its own subsection below and listed alongside the
58
- other any2md extension fields.
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. The pipeline's S4 stage promotes the
404
- first heading to H1 if no H1 was present in the source, and demotes any
405
- subsequent H1s to H2. This rule is what makes downstream chunkers and titlers
406
- work reliably.
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. An H2 followed by an H4 in the source is rewritten
411
- to H2 → H3 → H4. A skip emits a non-fatal validator warning during conversion
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.10"
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>=12.2.0,<13", # CVE-2026-25990, CVE-2026-40192 fix
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>=0.6.0",
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.4.22
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.0
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.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==12.2.0
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.8.3
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.6.3
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>