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.
- markdown_this-0.1.0/PKG-INFO +58 -0
- markdown_this-0.1.0/README.md +40 -0
- markdown_this-0.1.0/pyproject.toml +35 -0
- markdown_this-0.1.0/pyproject.toml.orig +24 -0
- markdown_this-0.1.0/src/markdown_this/__init__.py +45 -0
- markdown_this-0.1.0/src/markdown_this/extractor.py +80 -0
- markdown_this-0.1.0/src/markdown_this/fetchers.py +186 -0
- markdown_this-0.1.0/src/markdown_this/html.py +77 -0
- markdown_this-0.1.0/src/markdown_this/markdown.py +147 -0
|
@@ -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
|
+
[](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
|
+
[](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"})"
|
|
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 ""
|