markdown-this 0.1.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.
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.3
2
+ Name: markdown-this
3
+ Version: 0.1.0
4
+ Summary: Extract web pages and supported special URLs as Markdown
5
+ Keywords: html,markdown,readability,github,arxiv
6
+ Author: Martín Gaitán
7
+ Author-email: Martín Gaitán <gaitan@gmail.com>
8
+ License: MIT
9
+ Requires-Dist: beautifulsoup4>=4.14.3
10
+ Requires-Dist: markdownify>=1.2.2
11
+ Requires-Dist: readability-lxml>=0.8.4.1
12
+ Requires-Dist: requests>=2.32.5
13
+ Requires-Python: >=3.12
14
+ Project-URL: Homepage, https://github.com/mgaitan/lobstersgram/tree/main/packages/markdown-this
15
+ Project-URL: Repository, https://github.com/mgaitan/lobstersgram
16
+ Project-URL: Issues, https://github.com/mgaitan/lobstersgram/issues
17
+ Description-Content-Type: text/markdown
18
+
19
+ # markdown-this
20
+
21
+ [![PyPI](https://img.shields.io/pypi/v/markdown-this)](https://pypi.org/project/markdown-this/)
22
+
23
+ Extract the readable content of a URL and convert it to Markdown. The package
24
+ also handles GitHub repositories and Markdown files through the GitHub API,
25
+ and arXiv abstract pages through their HTML representation.
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ uv add markdown-this
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ```python
36
+ from markdown_this import extract_main_content
37
+
38
+ title, markdown, fallback_text, intro = extract_main_content(
39
+ "https://example.com/article"
40
+ )
41
+ ```
42
+
43
+ `extract_main_content` returns the title, extracted Markdown, plain-text
44
+ fallback, and a short introduction suitable for a notification or preview.
45
+
46
+ The lower-level fetchers and normalization helpers are available from the
47
+ package modules when an application needs more control over the pipeline.
48
+
49
+ ## Development
50
+
51
+ ```bash
52
+ uv run pytest packages/markdown-this/tests
53
+ uv run ruff check packages/markdown-this
54
+ ```
55
+
56
+ ## License
57
+
58
+ MIT
@@ -0,0 +1,40 @@
1
+ # markdown-this
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/markdown-this)](https://pypi.org/project/markdown-this/)
4
+
5
+ Extract the readable content of a URL and convert it to Markdown. The package
6
+ also handles GitHub repositories and Markdown files through the GitHub API,
7
+ and arXiv abstract pages through their HTML representation.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ uv add markdown-this
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ```python
18
+ from markdown_this import extract_main_content
19
+
20
+ title, markdown, fallback_text, intro = extract_main_content(
21
+ "https://example.com/article"
22
+ )
23
+ ```
24
+
25
+ `extract_main_content` returns the title, extracted Markdown, plain-text
26
+ fallback, and a short introduction suitable for a notification or preview.
27
+
28
+ The lower-level fetchers and normalization helpers are available from the
29
+ package modules when an application needs more control over the pipeline.
30
+
31
+ ## Development
32
+
33
+ ```bash
34
+ uv run pytest packages/markdown-this/tests
35
+ uv run ruff check packages/markdown-this
36
+ ```
37
+
38
+ ## License
39
+
40
+ MIT
@@ -0,0 +1,35 @@
1
+ [project]
2
+ name = "markdown-this"
3
+ version = "0.1.0"
4
+ description = "Extract web pages and supported special URLs as Markdown"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ keywords = [
8
+ "html",
9
+ "markdown",
10
+ "readability",
11
+ "github",
12
+ "arxiv",
13
+ ]
14
+ dependencies = [
15
+ "beautifulsoup4>=4.14.3",
16
+ "markdownify>=1.2.2",
17
+ "readability-lxml>=0.8.4.1",
18
+ "requests>=2.32.5",
19
+ ]
20
+
21
+ [[project.authors]]
22
+ name = "Martín Gaitán"
23
+ email = "gaitan@gmail.com"
24
+
25
+ [project.license]
26
+ text = "MIT"
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/mgaitan/lobstersgram/tree/main/packages/markdown-this"
30
+ Repository = "https://github.com/mgaitan/lobstersgram"
31
+ Issues = "https://github.com/mgaitan/lobstersgram/issues"
32
+
33
+ [build-system]
34
+ requires = ["uv_build>=0.7,<0.12"]
35
+ build-backend = "uv_build"
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "markdown-this"
3
+ version = "0.1.0"
4
+ description = "Extract web pages and supported special URLs as Markdown"
5
+ readme = "README.md"
6
+ authors = [{ name = "Martín Gaitán", email = "gaitan@gmail.com" }]
7
+ license = { text = "MIT" }
8
+ requires-python = ">=3.12"
9
+ keywords = ["html", "markdown", "readability", "github", "arxiv"]
10
+ dependencies = [
11
+ "beautifulsoup4>=4.14.3",
12
+ "markdownify>=1.2.2",
13
+ "readability-lxml>=0.8.4.1",
14
+ "requests>=2.32.5",
15
+ ]
16
+
17
+ [project.urls]
18
+ Homepage = "https://github.com/mgaitan/lobstersgram/tree/main/packages/markdown-this"
19
+ Repository = "https://github.com/mgaitan/lobstersgram"
20
+ Issues = "https://github.com/mgaitan/lobstersgram/issues"
21
+
22
+ [build-system]
23
+ requires = ["uv_build>=0.7,<0.12"]
24
+ build-backend = "uv_build"
@@ -0,0 +1,45 @@
1
+ """Convert web pages and supported special URLs to Markdown."""
2
+
3
+ from markdown_this.extractor import ContentDownloadError, extract_main_content
4
+ from markdown_this.fetchers import (
5
+ _github_repo_match,
6
+ fetch_arxiv_abstract,
7
+ fetch_github_blob_markdown,
8
+ fetch_github_readme,
9
+ fetch_html,
10
+ fetch_url,
11
+ )
12
+ from markdown_this.html import make_images_absolute, preprocess_figures
13
+ from markdown_this.markdown import (
14
+ _extract_leading_heading,
15
+ _is_html_badge_block,
16
+ _make_markdown_images_absolute,
17
+ _make_markdown_links_absolute,
18
+ _normalize_markdown_links,
19
+ _strip_badge_paragraphs,
20
+ extract_intro,
21
+ markdown_to_text,
22
+ strip_leading_title_heading,
23
+ )
24
+
25
+ __all__ = [
26
+ "ContentDownloadError",
27
+ "_extract_leading_heading",
28
+ "_github_repo_match",
29
+ "_is_html_badge_block",
30
+ "_make_markdown_images_absolute",
31
+ "_make_markdown_links_absolute",
32
+ "_normalize_markdown_links",
33
+ "_strip_badge_paragraphs",
34
+ "extract_intro",
35
+ "extract_main_content",
36
+ "fetch_arxiv_abstract",
37
+ "fetch_github_blob_markdown",
38
+ "fetch_github_readme",
39
+ "fetch_html",
40
+ "fetch_url",
41
+ "make_images_absolute",
42
+ "markdown_to_text",
43
+ "preprocess_figures",
44
+ "strip_leading_title_heading",
45
+ ]
@@ -0,0 +1,80 @@
1
+ """The main URL-to-Markdown extraction pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from logging import getLogger
6
+
7
+ from bs4 import BeautifulSoup
8
+ from markdownify import markdownify as html_to_md
9
+ from readability import Document
10
+
11
+ from markdown_this.fetchers import (
12
+ DEFAULT_REQUEST_TIMEOUT,
13
+ fetch_arxiv_abstract,
14
+ fetch_github_blob_markdown,
15
+ fetch_github_readme,
16
+ fetch_html,
17
+ )
18
+ from markdown_this.html import make_images_absolute, preprocess_figures
19
+ from markdown_this.markdown import (
20
+ _make_markdown_links_absolute,
21
+ _normalize_markdown_links,
22
+ extract_intro,
23
+ markdown_to_text,
24
+ )
25
+
26
+ logger = getLogger(__name__)
27
+
28
+
29
+ class ContentDownloadError(RuntimeError):
30
+ """Raised when a URL cannot provide HTML content."""
31
+
32
+ def __init__(self) -> None:
33
+ super().__init__("Failed to download content")
34
+
35
+
36
+ def extract_main_content(
37
+ url: str,
38
+ request_timeout: int = DEFAULT_REQUEST_TIMEOUT,
39
+ min_content_length: int = 200,
40
+ intro_min_length: int = 40,
41
+ ) -> tuple[str, str, str, str]:
42
+ """Return ``(title, markdown, fallback_text, intro)`` for *url*."""
43
+ if github_blob_result := fetch_github_blob_markdown(url, request_timeout):
44
+ title, markdown = github_blob_result
45
+ fallback_text = markdown_to_text(markdown)
46
+ return title, markdown, fallback_text, extract_intro(markdown, fallback_text, intro_min_length)
47
+
48
+ if github_result := fetch_github_readme(url, request_timeout):
49
+ title, markdown = github_result
50
+ fallback_text = markdown_to_text(markdown)
51
+ return title, markdown, fallback_text, extract_intro(markdown, fallback_text, intro_min_length)
52
+
53
+ if arxiv_result := fetch_arxiv_abstract(url, request_timeout):
54
+ title, markdown = arxiv_result
55
+ fallback_text = markdown_to_text(markdown)
56
+ return title, markdown, fallback_text, extract_intro(markdown, fallback_text, intro_min_length)
57
+
58
+ downloaded = fetch_html(url, request_timeout)
59
+ if not downloaded:
60
+ raise ContentDownloadError
61
+
62
+ content_html = ""
63
+ title = url
64
+ try:
65
+ document = Document(downloaded)
66
+ content_html = document.summary() or ""
67
+ title = document.title() or url
68
+ except Exception as exc: # noqa: BLE001
69
+ logger.warning("readability failed error=%s", exc)
70
+
71
+ if not content_html or len(content_html.strip()) < min_content_length:
72
+ content_html = downloaded
73
+
74
+ content_html = preprocess_figures(make_images_absolute(content_html, url))
75
+ extracted_markdown = html_to_md(content_html)
76
+ extracted_markdown = _normalize_markdown_links(extracted_markdown)
77
+ extracted_markdown = _make_markdown_links_absolute(extracted_markdown, url)
78
+ fallback_text = BeautifulSoup(content_html, "html.parser").get_text(separator="\n").strip()
79
+ intro = extract_intro(extracted_markdown, fallback_text, intro_min_length)
80
+ return title, extracted_markdown, fallback_text, intro
@@ -0,0 +1,186 @@
1
+ """HTTP fetchers and special URL handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import re
7
+ import urllib.parse
8
+ from logging import getLogger
9
+
10
+ import requests
11
+ from bs4 import BeautifulSoup, UnicodeDammit
12
+
13
+ from markdown_this.markdown import (
14
+ _extract_leading_heading,
15
+ _make_markdown_images_absolute,
16
+ _strip_badge_paragraphs,
17
+ )
18
+
19
+ DEFAULT_REQUEST_TIMEOUT = 20
20
+ GITHUB_REPO_PATH_PARTS = 2
21
+ logger = getLogger(__name__)
22
+
23
+ _GITHUB_REPO_RE = re.compile(r"^https?://github\.com/(?P<owner>[^/]+)/(?P<repo>[^/?#]+)(?:[/?#].*)?$")
24
+ _GITHUB_BLOB_RE = re.compile(
25
+ r"^https?://github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+)/blob/(?P<branch>[^/]+)/(?P<path>.+\.(?:md|markdown))$",
26
+ re.IGNORECASE,
27
+ )
28
+ _ARXIV_ABS_RE = re.compile(r"^https?://arxiv\.org/abs/(?P<arxiv_id>[^?#]+)(?:[?#].*)?$", re.IGNORECASE)
29
+
30
+
31
+ def fetch_url(url: str, timeout: int = DEFAULT_REQUEST_TIMEOUT) -> str:
32
+ """Follow redirects and return the final URL."""
33
+ logger.debug("fetch_url start url=%s", url)
34
+ response = requests.get(
35
+ url,
36
+ timeout=timeout,
37
+ allow_redirects=True,
38
+ headers={"User-Agent": "lobsters-telegraph-bot"},
39
+ )
40
+ response.raise_for_status()
41
+ logger.debug("fetch_url final url=%s status=%s", response.url, response.status_code)
42
+ return response.url
43
+
44
+
45
+ def fetch_html(url: str, timeout: int = DEFAULT_REQUEST_TIMEOUT) -> str | None:
46
+ """Fetch HTML and decode UTF-8, falling back to BeautifulSoup detection."""
47
+ response = requests.get(url, timeout=timeout, headers={"User-Agent": "lobsters-telegraph-bot"})
48
+ response.raise_for_status()
49
+ try:
50
+ return response.content.decode("utf-8")
51
+ except UnicodeDecodeError:
52
+ dammit = UnicodeDammit(response.content, is_html=True)
53
+ return dammit.unicode_markup or response.content.decode("latin-1")
54
+
55
+
56
+ def _github_repo_match(url: str) -> re.Match[str] | None:
57
+ """Return a match when *url* is a GitHub repository root URL."""
58
+ match = _GITHUB_REPO_RE.match(url)
59
+ if match is None:
60
+ return None
61
+ if len(urllib.parse.urlparse(url).path.strip("/").split("/")) != GITHUB_REPO_PATH_PARTS:
62
+ return None
63
+ return match
64
+
65
+
66
+ def _fetch_github_api_file(api_url: str, timeout: int = DEFAULT_REQUEST_TIMEOUT) -> str | None:
67
+ """Fetch, decode, and normalize a Markdown file from the GitHub API."""
68
+ try:
69
+ response = requests.get(
70
+ api_url,
71
+ timeout=timeout,
72
+ headers={"User-Agent": "lobsters-telegraph-bot", "Accept": "application/vnd.github+json"},
73
+ )
74
+ response.raise_for_status()
75
+ data = response.json()
76
+ name: str = data.get("name", "")
77
+ if not name.lower().endswith((".md", ".markdown")):
78
+ logger.debug("GitHub API file is not Markdown: name=%r url=%s", name, api_url)
79
+ return None
80
+ markdown = base64.b64decode(data.get("content", "")).decode("utf-8")
81
+ markdown = _strip_badge_paragraphs(markdown)
82
+ download_url: str = data.get("download_url") or ""
83
+ if download_url:
84
+ markdown = _make_markdown_images_absolute(markdown, download_url.rsplit("/", 1)[0] + "/")
85
+ except (requests.RequestException, ValueError, KeyError) as exc:
86
+ logger.warning("GitHub API file failed url=%s error=%s", api_url, exc)
87
+ return None
88
+ return markdown
89
+
90
+
91
+ def fetch_github_readme(url: str, timeout: int = DEFAULT_REQUEST_TIMEOUT) -> tuple[str, str] | None:
92
+ """Return ``(title, markdown)`` for a GitHub repository root URL."""
93
+ match = _github_repo_match(url)
94
+ if match is None:
95
+ return None
96
+ owner, repo = match.group("owner"), match.group("repo")
97
+ title = f"{owner}/{repo}"
98
+ try:
99
+ response = requests.get(
100
+ f"https://api.github.com/repos/{owner}/{repo}",
101
+ timeout=timeout,
102
+ headers={"User-Agent": "lobsters-telegraph-bot", "Accept": "application/vnd.github+json"},
103
+ )
104
+ response.raise_for_status()
105
+ data = response.json()
106
+ full_name = data.get("full_name") or title
107
+ description = (data.get("description") or "").strip()
108
+ title = f"{full_name} – {description}" if description else full_name # noqa: RUF001
109
+ except requests.RequestException as exc:
110
+ logger.warning("GitHub repo info failed error=%s", exc)
111
+
112
+ markdown = _fetch_github_api_file(f"https://api.github.com/repos/{owner}/{repo}/readme", timeout)
113
+ if markdown is None:
114
+ return None
115
+ return title, markdown
116
+
117
+
118
+ def fetch_github_blob_markdown(url: str, timeout: int = DEFAULT_REQUEST_TIMEOUT) -> tuple[str, str] | None:
119
+ """Return ``(title, markdown)`` for a GitHub Markdown blob URL."""
120
+ match = _GITHUB_BLOB_RE.match(url)
121
+ if match is None:
122
+ return None
123
+ owner, repo, branch, path = (match.group(name) for name in ("owner", "repo", "branch", "path"))
124
+ api_url = f"https://api.github.com/repos/{owner}/{repo}/contents/{path}?ref={branch}"
125
+ markdown = _fetch_github_api_file(api_url, timeout)
126
+ if markdown is None:
127
+ return None
128
+ heading, markdown = _extract_leading_heading(markdown)
129
+ return heading or f"{owner}/{repo}/{path}", markdown
130
+
131
+
132
+ def _parse_arxiv_html(html: str, arxiv_id: str) -> tuple[str, list[str]]:
133
+ """Parse an arXiv abstract page into a title and Markdown fragments."""
134
+ soup = BeautifulSoup(html, "html.parser")
135
+ title_tag = soup.find("h1", class_="title")
136
+ if title_tag:
137
+ descriptor = title_tag.find("span", class_="descriptor")
138
+ if descriptor:
139
+ descriptor.extract()
140
+ title = title_tag.get_text(separator=" ", strip=True)
141
+ else:
142
+ title = arxiv_id
143
+
144
+ parts: list[str] = []
145
+ authors_tag = soup.find("div", class_="authors")
146
+ if authors_tag:
147
+ descriptor = authors_tag.find("span", class_="descriptor")
148
+ if descriptor:
149
+ descriptor.extract()
150
+ author_links = authors_tag.find_all("a")
151
+ authors_text = ", ".join(author.get_text(strip=True) for author in author_links)
152
+ if not authors_text:
153
+ authors_text = authors_tag.get_text(separator=" ", strip=True)
154
+ if authors_text:
155
+ parts.append(f"**Authors:** {authors_text}")
156
+
157
+ abstract_tag = soup.find("blockquote", class_="abstract")
158
+ if abstract_tag:
159
+ descriptor = abstract_tag.find("span", class_="descriptor")
160
+ if descriptor:
161
+ descriptor.extract()
162
+ abstract_text = abstract_tag.get_text(separator=" ", strip=True)
163
+ if abstract_text:
164
+ parts.append(f"> {abstract_text}")
165
+ return title, parts
166
+
167
+
168
+ def fetch_arxiv_abstract(url: str, timeout: int = DEFAULT_REQUEST_TIMEOUT) -> tuple[str, str] | None:
169
+ """Return ``(title, markdown)`` for an arXiv abstract URL."""
170
+ match = _ARXIV_ABS_RE.match(url)
171
+ if match is None:
172
+ return None
173
+ arxiv_id = match.group("arxiv_id")
174
+ abs_url = f"https://arxiv.org/abs/{arxiv_id}"
175
+ try:
176
+ html = fetch_html(abs_url, timeout)
177
+ except requests.RequestException as exc:
178
+ logger.warning("arXiv fetch failed url=%s error=%s", abs_url, exc)
179
+ return None
180
+ if not html:
181
+ return None
182
+ title, parts = _parse_arxiv_html(html, arxiv_id)
183
+ if not parts:
184
+ logger.warning("arXiv content extraction returned no content url=%s", abs_url)
185
+ return None
186
+ return title, "\n\n".join(parts)
@@ -0,0 +1,77 @@
1
+ """HTML cleanup helpers used before Markdown conversion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import urllib.parse
7
+
8
+ from bs4 import BeautifulSoup
9
+ from bs4.element import Tag
10
+
11
+
12
+ def _best_src_for_img(img: Tag) -> str:
13
+ """Return the best candidate URL for an ``<img>`` element."""
14
+ src = (img.get("src") or "").strip()
15
+ if src and not src.startswith("data:"):
16
+ return src
17
+
18
+ data_src = (img.get("data-src") or "").strip()
19
+ if data_src and not data_src.startswith("data:"):
20
+ return data_src
21
+
22
+ srcset = (img.get("srcset") or "").strip()
23
+ if srcset:
24
+ best_url, best_width = "", -1
25
+ for candidate in srcset.split(","):
26
+ parts = candidate.strip().split()
27
+ if not parts:
28
+ continue
29
+ url = parts[0]
30
+ width = 0
31
+ if len(parts) > 1 and parts[1].endswith("w"):
32
+ with contextlib.suppress(ValueError):
33
+ width = int(parts[1].removesuffix("w"))
34
+ if width > best_width:
35
+ best_width, best_url = width, url
36
+ if best_url:
37
+ return best_url
38
+ return ""
39
+
40
+
41
+ def make_images_absolute(content_html: str, base_url: str) -> str:
42
+ """Resolve image URLs and remove images that cannot be fetched over HTTP(S)."""
43
+ soup = BeautifulSoup(content_html, "html.parser")
44
+ for img in soup.find_all("img"):
45
+ src = _best_src_for_img(img)
46
+ if not src:
47
+ img.decompose()
48
+ continue
49
+ absolute = urllib.parse.urljoin(base_url, src)
50
+ if absolute.startswith(("http://", "https://")):
51
+ img["src"] = absolute
52
+ else:
53
+ img.decompose()
54
+ return str(soup)
55
+
56
+
57
+ def preprocess_figures(content_html: str) -> str:
58
+ """Convert text figures to blockquotes while preserving image figures."""
59
+ soup = BeautifulSoup(content_html, "html.parser")
60
+ for figure in soup.find_all("figure"):
61
+ figcaption = figure.find("figcaption")
62
+ has_body_text = any(
63
+ element.find_parent("figcaption") is None and bool(element.get_text(strip=True))
64
+ for element in figure.find_all(["p", "div"])
65
+ )
66
+ if not has_body_text:
67
+ continue
68
+
69
+ if figcaption:
70
+ figcaption.extract()
71
+ blockquote = soup.new_tag("blockquote")
72
+ for child in list(figure.children):
73
+ blockquote.append(child.extract())
74
+ figure.replace_with(blockquote)
75
+ if figcaption:
76
+ blockquote.insert_after(figcaption)
77
+ return str(soup)
@@ -0,0 +1,147 @@
1
+ """Markdown cleanup and text extraction helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import urllib.parse
7
+ from logging import getLogger
8
+
9
+ from bs4 import BeautifulSoup
10
+ from bs4.element import Tag
11
+
12
+ logger = getLogger(__name__)
13
+
14
+ _BADGE_RE = re.compile(r"\[!\[[^\]]*\]\([^)]+\)\]\([^)]+\)")
15
+ _HTML_P_BLOCK_RE = re.compile(r"<p(?:\s[^>]*)?>.*?</p>", re.IGNORECASE | re.DOTALL)
16
+ _BROKEN_LINK_RE = re.compile(r"\[(\s*\n[ \t]*\n[ \t]*)([^\]]*)\]\(")
17
+ _HARD_BREAK_BEFORE_LINK_RE = re.compile(r"(?<!\\) {2,}\n(?=\[[^\]]+\]\([^)]+\))")
18
+
19
+
20
+ def _extract_leading_heading(markdown: str) -> tuple[str | None, str]:
21
+ """Extract and remove the first heading when it starts the Markdown."""
22
+ stripped = markdown.lstrip("\n")
23
+ match = re.match(r"^#{1,6}\s+(.*?)\s*$", stripped, re.MULTILINE)
24
+ if match:
25
+ return match.group(1).strip(), stripped[match.end() :].lstrip("\n")
26
+ return None, markdown
27
+
28
+
29
+ def _is_html_badge_block(html: str) -> bool: # noqa: C901
30
+ """Return True if *html* is a ``<p>`` element containing at least two badge images."""
31
+ soup = BeautifulSoup(html, "html.parser")
32
+ p = soup.find("p")
33
+ if p is None:
34
+ return False
35
+ img_count = 0
36
+ for child in p.children:
37
+ match child:
38
+ case str() as text:
39
+ if text.strip():
40
+ return False
41
+ case Tag(name="img"):
42
+ img_count += 1
43
+ case Tag(name="a"):
44
+ for grandchild in child.children:
45
+ match grandchild:
46
+ case str() as text:
47
+ if text.strip():
48
+ return False
49
+ case Tag(name="img"):
50
+ img_count += 1
51
+ case _:
52
+ return False
53
+ case _:
54
+ return False
55
+ return img_count >= 2 # noqa: PLR2004
56
+
57
+
58
+ def _strip_badge_paragraphs(markdown: str) -> str:
59
+ """Remove badge-only paragraphs from Markdown."""
60
+ markdown = _HTML_P_BLOCK_RE.sub(
61
+ lambda match: "" if _is_html_badge_block(match.group(0)) else match.group(0),
62
+ markdown,
63
+ )
64
+ result: list[str] = []
65
+ for paragraph in markdown.split("\n\n"):
66
+ remaining = _BADGE_RE.sub("", paragraph).strip()
67
+ if remaining:
68
+ result.append(paragraph)
69
+ return "\n\n".join(result)
70
+
71
+
72
+ def _make_markdown_images_absolute(markdown: str, base_url: str) -> str:
73
+ """Resolve relative image URLs in Markdown against *base_url*."""
74
+
75
+ def replace(match: re.Match[str]) -> str:
76
+ alt, image_url = match.group(1), match.group(2)
77
+ if image_url.startswith(("http://", "https://", "data:")):
78
+ return match.group(0)
79
+ return f"![{alt}]({urllib.parse.urljoin(base_url, image_url)})"
80
+
81
+ return re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", replace, markdown)
82
+
83
+
84
+ def _make_markdown_links_absolute(markdown: str, base_url: str) -> str:
85
+ """Resolve relative Markdown link URLs against *base_url*."""
86
+
87
+ def replace(match: re.Match[str]) -> str:
88
+ text, href = match.group(1), match.group(2).strip()
89
+ if href.startswith(("http://", "https://", "mailto:", "tel:", "data:")):
90
+ return match.group(0)
91
+ absolute = urllib.parse.urljoin(base_url, href)
92
+ if not absolute.startswith(("http://", "https://")):
93
+ return match.group(0)
94
+ return f"[{text}]({absolute})"
95
+
96
+ return re.sub(r"(?<!!)\[([^\]]+)\]\(([^)]+)\)", replace, markdown)
97
+
98
+
99
+ def _normalize_markdown_links(markdown: str) -> str:
100
+ """Fix Markdown links broken by blank lines or hard breaks."""
101
+
102
+ def fix(match: re.Match[str]) -> str:
103
+ text = match.group(2).replace("\n", " ").strip()
104
+ return f"[{text}]("
105
+
106
+ normalized = _BROKEN_LINK_RE.sub(fix, markdown)
107
+ return _HARD_BREAK_BEFORE_LINK_RE.sub("\n", normalized)
108
+
109
+
110
+ def strip_leading_title_heading(markdown: str, title: str) -> str:
111
+ """Remove a leading Markdown heading when it duplicates *title*."""
112
+ stripped = markdown.lstrip("\n")
113
+ match = re.match(r"^#{1,6}\s+(.*)\s*$", stripped, re.MULTILINE)
114
+ if match and match.group(1).strip().lower() == title.strip().lower():
115
+ return stripped[match.end() :].lstrip("\n")
116
+ return markdown
117
+
118
+
119
+ def markdown_to_text(markdown_text: str) -> str:
120
+ """Reduce Markdown to plain text suitable for previews."""
121
+ text = markdown_text
122
+ text = re.sub(r"!\[([^\]]*)\]\([^)]+\)", "", text)
123
+ text = re.sub(r"```.*?```", "", text, flags=re.DOTALL)
124
+ text = re.sub(r"`([^`]+)`", r"\1", text)
125
+ text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
126
+ text = re.sub(r"\[\]\([^)]+\)", "", text)
127
+ text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE)
128
+ text = re.sub(r"^>\s?", "", text, flags=re.MULTILINE)
129
+ text = re.sub(r"^\s*[-*+]\s+", "", text, flags=re.MULTILINE)
130
+ return re.sub(r"[_*]{1,3}([^_*]+)[_*]{1,3}", r"\1", text)
131
+
132
+
133
+ def extract_intro(markdown_text: str, fallback_text: str, min_length: int = 40) -> str:
134
+ """Return the first substantial paragraph, falling back to plain text."""
135
+ text = markdown_to_text(markdown_text)
136
+ for chunk in text.split("\n\n"):
137
+ line = chunk.strip()
138
+ if not line:
139
+ continue
140
+ intro = line.replace("\n", " ").strip()
141
+ if len(intro) >= min_length:
142
+ return intro
143
+ for line in fallback_text.splitlines():
144
+ stripped = line.strip()
145
+ if stripped:
146
+ return stripped
147
+ return ""