agentsclimarketplace

Html to markdown markdownify subclass

Skill kjuhwa/skills-hub/skills/preprocessing/html-to-markdown-markdownify-subclass

Subclass markdownify.MarkdownConverter to customize link escape, drop javascript/data URIs, convert input[type=checkbox] to GFM task syntax, and fall back to plain text on deeply-nested HTML.From its SKILL.md

Install
npx -y skills add kjuhwa/skills-hub --skill html-to-markdown-markdownify-subclass

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

6.6 KB, ~1.4k tokens by cl100k_base, as published. Nobody here has run it

Custom markdownify subclass for LLM-ready HTML→Markdown

markdownify does a good-enough job out of the box, but for LLM ingestion you usually want to: strip JS links so prompts don't echo them, truncate inline data URIs so images don't blow the context budget, escape URIs so markdown doesn't get broken by unusual characters, and render <input type=checkbox> as GFM [x]/[ ] so checklists survive. Subclass MarkdownConverter and override four converters; wrap with a strict/fallback switch for the RecursionError case.

The subclass

import re
import markdownify
from urllib.parse import quote, unquote, urlparse, urlunparse

class _CustomMarkdownify(markdownify.MarkdownConverter):
    def __init__(self, **options):
        options["heading_style"] = options.get("heading_style", markdownify.ATX)  # # not ====
        options["keep_data_uris"] = options.get("keep_data_uris", False)
        super().__init__(**options)

    def convert_hn(self, n, el, text, convert_as_inline=False, **kw):
        # Force a blank line before headings so they aren't swallowed into prior paragraph.
        if not convert_as_inline and not re.search(r"^\n", text):
            return "\n" + super().convert_hn(n, el, text, convert_as_inline)
        return super().convert_hn(n, el, text, convert_as_inline)

    def convert_a(self, el, text, convert_as_inline=False, **kw):
        prefix, suffix, text = markdownify.chomp(text)
        if not text: return ""
        if el.find_parent("pre") is not None: return text
        href, title = el.get("href"), el.get("title")
        if href:
            try:
                p = urlparse(href)
                # Drop javascript:, mailto: as plain text.
                if p.scheme and p.scheme.lower() not in ("http", "https", "file"):
                    return f"{prefix}{text}{suffix}"
                # Re-escape path to keep markdown parsers happy.
                href = urlunparse(p._replace(path=quote(unquote(p.path))))
            except ValueError:
                return f"{prefix}{text}{suffix}"
        if self.options["autolinks"] and text.replace(r"\_", "_") == href and not title:
            return f"<{href}>"
        title_part = f' "{title.replace(chr(34), chr(92)+chr(34))}"' if title else ""
        return f"{prefix}[{text}]({href}{title_part}){suffix}" if href else text

    def convert_img(self, el, text, convert_as_inline=False, **kw):
        alt = (el.attrs.get("alt") or "").replace("\n", " ")
        src = el.attrs.get("src") or el.attrs.get("data-src") or ""
        title = el.attrs.get("title") or ""
        title_part = f' "{title}"' if title else ""
        if convert_as_inline and el.parent.name not in self.options["keep_inline_images_in"]:
            return alt
        # Truncate long data URIs — they eat LLM context budget for no value.
        if src.startswith("data:") and not self.options["keep_data_uris"]:
            src = src.split(",", 1)[0] + "..."
        return f"![{alt}]({src}{title_part})"

    def convert_input(self, el, text, convert_as_inline=False, **kw):
        # GFM task-list checkbox syntax
        if el.get("type") == "checkbox":
            return "[x] " if el.has_attr("checked") else "[ ] "
        return ""

Driver with recursion fallback

markdownify is recursive; deeply-nested HTML (nested tables, SPA-rendered containers) can blow Python's recursion limit. Catch it and fall back to BeautifulSoup's iterative get_text so the caller still gets usable text instead of raw HTML:

import warnings
from bs4 import BeautifulSoup

def convert_html(html_bytes: bytes, *, charset="utf-8", strict=False, **kw) -> str:
    soup = BeautifulSoup(html_bytes, "html.parser", from_encoding=charset)
    for tag in soup(["script", "style"]):      # strip noise first
        tag.extract()
    target = soup.find("body") or soup
    try:
        return _CustomMarkdownify(**kw).convert_soup(target).strip()
    except RecursionError:
        if strict: raise
        warnings.warn(
            "HTML too deeply nested for markdown conversion; falling back to plain text.",
            stacklevel=2,
        )
        return target.get_text("\n", strip=True).strip()

Why these four overrides

OverrideWhat it preventsDefault behavior that's wrong
convert_hnHeading glued onto prior paragraphOutput foo## Heading with no blank line
convert_aLLM prompts leaking javascript: payloads, or URLs breaking markdown link syntax via unescaped charactersRenders the raw href as-is
convert_imgMulti-megabyte data:image/png;base64,... eating contextKeeps the whole data URI verbatim
convert_inputTask lists becoming emptyReturns empty string for checkboxes

Call-site usage

The subclass plugs into any HTML→Markdown driver. For DOCX, many tools convert to HTML first (e.g. mammoth.convert_to_html(...)) and then run this subclass; same for XLSX (pandas to_html → markdown). Reusing the same formatter gives you consistent output across source formats.

Anti-patterns

  • Using markdownify.markdownify(html) (module-level helper) with options passed inline. You lose the ability to keep state across sub-document calls. Instantiate the subclass once.
  • Stripping scripts/styles after markdownify. You've already paid for parsing/escaping noise you didn't want.
  • Not catching RecursionError. One pathological input kills the whole ingestion pipeline.
  • Keeping data URIs in images. A single PNG base64 is frequently 100k+ tokens.

Variations

  • Keep some data URIs. Pass keep_data_uris=True via options when you actually need the pixels (OCR, downstream captioning).
  • Image captioning hook. In place of src = split(",")[0] + "...", call an LLM-caption pipeline (see companion skill image-as-data-uri-chat-completion) and emit ![caption](src) instead.
  • Custom table flattening. Override convert_table to linearize rows with pipe separators if the target is a plain-text LLM instead of GFM-aware.

What ships with it

Read from the repository

Just SKILL.md. No reference files, no scripts.

Keep looking

Skills are one crate of 326,499. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.