wsws-md 0.1.2__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.
wsws_md-0.1.2/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joe Mount
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
wsws_md-0.1.2/PKG-INFO ADDED
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: wsws-md
3
+ Version: 0.1.2
4
+ Summary: Convert World Socialist Web Site articles to Markdown
5
+ License: MIT
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: requests>=2.28
10
+ Requires-Dist: beautifulsoup4>=4.12
11
+ Requires-Dist: markdownify>=0.13
12
+ Requires-Dist: lxml>=4.9
13
+ Provides-Extra: test
14
+ Requires-Dist: pytest>=7.0; extra == "test"
15
+ Requires-Dist: pytest-cov>=5.0; extra == "test"
16
+ Provides-Extra: dev
17
+ Requires-Dist: wsws-md[test]; extra == "dev"
18
+ Requires-Dist: ruff>=0.6; extra == "dev"
19
+ Requires-Dist: mypy>=1.10; extra == "dev"
20
+ Requires-Dist: types-requests; extra == "dev"
21
+ Requires-Dist: types-beautifulsoup4; extra == "dev"
22
+ Dynamic: license-file
23
+
24
+ # WSWS Markdown
25
+
26
+ [![CI](https://github.com/JoeMountWSWS/wsws-md/actions/workflows/ci.yml/badge.svg)](https://github.com/JoeMountWSWS/wsws-md/actions/workflows/ci.yml)
27
+ [![Publish to PyPI](https://github.com/JoeMountWSWS/wsws-md/actions/workflows/publish.yml/badge.svg)](https://github.com/JoeMountWSWS/wsws-md/actions/workflows/publish.yml)
28
+
29
+ A tool to convert articles from the World Socialist Web Site to Markdown format.
30
+
31
+ ## Install
32
+
33
+ ```sh
34
+ pip install wsws-md
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ```sh
40
+ wswsmd "https://www.wsws.org/en/articles/2026/07/03/coeq-j03.html"
41
+ ```
42
+
43
+ Prints the article as Markdown (with YAML frontmatter containing title, author,
44
+ date, description and source URL) to stdout. Use `-o FILE` / `--output FILE`
45
+ to write to a file instead:
46
+
47
+ ```sh
48
+ wswsmd "https://www.wsws.org/en/articles/2026/07/03/coeq-j03.html" -o article.md
49
+ ```
50
+
51
+ ## Development
52
+
53
+ ```sh
54
+ pip install -e ".[test]"
55
+ pytest
56
+ ```
57
+
58
+ Pull request titles must follow [Conventional Commits](https://www.conventionalcommits.org/)
59
+ — see [CONTRIBUTING.md](CONTRIBUTING.md). Releases are automated with
60
+ [release-please](https://github.com/googleapis/release-please).
@@ -0,0 +1,37 @@
1
+ # WSWS Markdown
2
+
3
+ [![CI](https://github.com/JoeMountWSWS/wsws-md/actions/workflows/ci.yml/badge.svg)](https://github.com/JoeMountWSWS/wsws-md/actions/workflows/ci.yml)
4
+ [![Publish to PyPI](https://github.com/JoeMountWSWS/wsws-md/actions/workflows/publish.yml/badge.svg)](https://github.com/JoeMountWSWS/wsws-md/actions/workflows/publish.yml)
5
+
6
+ A tool to convert articles from the World Socialist Web Site to Markdown format.
7
+
8
+ ## Install
9
+
10
+ ```sh
11
+ pip install wsws-md
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ ```sh
17
+ wswsmd "https://www.wsws.org/en/articles/2026/07/03/coeq-j03.html"
18
+ ```
19
+
20
+ Prints the article as Markdown (with YAML frontmatter containing title, author,
21
+ date, description and source URL) to stdout. Use `-o FILE` / `--output FILE`
22
+ to write to a file instead:
23
+
24
+ ```sh
25
+ wswsmd "https://www.wsws.org/en/articles/2026/07/03/coeq-j03.html" -o article.md
26
+ ```
27
+
28
+ ## Development
29
+
30
+ ```sh
31
+ pip install -e ".[test]"
32
+ pytest
33
+ ```
34
+
35
+ Pull request titles must follow [Conventional Commits](https://www.conventionalcommits.org/)
36
+ — see [CONTRIBUTING.md](CONTRIBUTING.md). Releases are automated with
37
+ [release-please](https://github.com/googleapis/release-please).
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "wsws-md"
7
+ version = "0.1.2"
8
+ description = "Convert World Socialist Web Site articles to Markdown"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ dependencies = [
13
+ "requests>=2.28",
14
+ "beautifulsoup4>=4.12",
15
+ "markdownify>=0.13",
16
+ "lxml>=4.9",
17
+ ]
18
+
19
+ [project.scripts]
20
+ wswsmd = "wswsmd.cli:main"
21
+
22
+ [project.optional-dependencies]
23
+ test = ["pytest>=7.0", "pytest-cov>=5.0"]
24
+ dev = [
25
+ "wsws-md[test]",
26
+ "ruff>=0.6",
27
+ "mypy>=1.10",
28
+ "types-requests",
29
+ "types-beautifulsoup4",
30
+ ]
31
+
32
+ [tool.setuptools.packages.find]
33
+ where = ["src"]
34
+
35
+ [tool.pytest.ini_options]
36
+ addopts = "--cov=wswsmd --cov-report=term-missing --cov-fail-under=90"
37
+
38
+ [tool.coverage.run]
39
+ source = ["src/wswsmd"]
40
+
41
+ [tool.ruff]
42
+ target-version = "py310"
43
+ src = ["src", "tests"]
44
+
45
+ [tool.mypy]
46
+ python_version = "3.10"
47
+ packages = ["wswsmd"]
48
+ warn_unused_configs = true
49
+
50
+ [[tool.mypy.overrides]]
51
+ module = "wswsmd.parse"
52
+ disable_error_code = ["union-attr", "index", "call-arg", "return-value"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: wsws-md
3
+ Version: 0.1.2
4
+ Summary: Convert World Socialist Web Site articles to Markdown
5
+ License: MIT
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: requests>=2.28
10
+ Requires-Dist: beautifulsoup4>=4.12
11
+ Requires-Dist: markdownify>=0.13
12
+ Requires-Dist: lxml>=4.9
13
+ Provides-Extra: test
14
+ Requires-Dist: pytest>=7.0; extra == "test"
15
+ Requires-Dist: pytest-cov>=5.0; extra == "test"
16
+ Provides-Extra: dev
17
+ Requires-Dist: wsws-md[test]; extra == "dev"
18
+ Requires-Dist: ruff>=0.6; extra == "dev"
19
+ Requires-Dist: mypy>=1.10; extra == "dev"
20
+ Requires-Dist: types-requests; extra == "dev"
21
+ Requires-Dist: types-beautifulsoup4; extra == "dev"
22
+ Dynamic: license-file
23
+
24
+ # WSWS Markdown
25
+
26
+ [![CI](https://github.com/JoeMountWSWS/wsws-md/actions/workflows/ci.yml/badge.svg)](https://github.com/JoeMountWSWS/wsws-md/actions/workflows/ci.yml)
27
+ [![Publish to PyPI](https://github.com/JoeMountWSWS/wsws-md/actions/workflows/publish.yml/badge.svg)](https://github.com/JoeMountWSWS/wsws-md/actions/workflows/publish.yml)
28
+
29
+ A tool to convert articles from the World Socialist Web Site to Markdown format.
30
+
31
+ ## Install
32
+
33
+ ```sh
34
+ pip install wsws-md
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ```sh
40
+ wswsmd "https://www.wsws.org/en/articles/2026/07/03/coeq-j03.html"
41
+ ```
42
+
43
+ Prints the article as Markdown (with YAML frontmatter containing title, author,
44
+ date, description and source URL) to stdout. Use `-o FILE` / `--output FILE`
45
+ to write to a file instead:
46
+
47
+ ```sh
48
+ wswsmd "https://www.wsws.org/en/articles/2026/07/03/coeq-j03.html" -o article.md
49
+ ```
50
+
51
+ ## Development
52
+
53
+ ```sh
54
+ pip install -e ".[test]"
55
+ pytest
56
+ ```
57
+
58
+ Pull request titles must follow [Conventional Commits](https://www.conventionalcommits.org/)
59
+ — see [CONTRIBUTING.md](CONTRIBUTING.md). Releases are automated with
60
+ [release-please](https://github.com/googleapis/release-please).
@@ -0,0 +1,19 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/wsws_md.egg-info/PKG-INFO
5
+ src/wsws_md.egg-info/SOURCES.txt
6
+ src/wsws_md.egg-info/dependency_links.txt
7
+ src/wsws_md.egg-info/entry_points.txt
8
+ src/wsws_md.egg-info/requires.txt
9
+ src/wsws_md.egg-info/top_level.txt
10
+ src/wswsmd/__init__.py
11
+ src/wswsmd/cli.py
12
+ src/wswsmd/convert.py
13
+ src/wswsmd/fetch.py
14
+ src/wswsmd/parse.py
15
+ tests/test_cli.py
16
+ tests/test_convert.py
17
+ tests/test_fetch.py
18
+ tests/test_parse.py
19
+ tests/test_parse_edge_cases.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ wswsmd = wswsmd.cli:main
@@ -0,0 +1,15 @@
1
+ requests>=2.28
2
+ beautifulsoup4>=4.12
3
+ markdownify>=0.13
4
+ lxml>=4.9
5
+
6
+ [dev]
7
+ wsws-md[test]
8
+ ruff>=0.6
9
+ mypy>=1.10
10
+ types-requests
11
+ types-beautifulsoup4
12
+
13
+ [test]
14
+ pytest>=7.0
15
+ pytest-cov>=5.0
@@ -0,0 +1 @@
1
+ wswsmd
@@ -0,0 +1 @@
1
+ __version__ = "0.1.1"
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ import requests
7
+
8
+ from wswsmd.convert import to_markdown
9
+ from wswsmd.fetch import fetch_html
10
+ from wswsmd.parse import parse_article
11
+
12
+
13
+ def build_parser() -> argparse.ArgumentParser:
14
+ parser = argparse.ArgumentParser(
15
+ prog="wswsmd",
16
+ description="Convert a World Socialist Web Site article to Markdown.",
17
+ )
18
+ parser.add_argument("url", help="URL of the WSWS article to convert")
19
+ parser.add_argument(
20
+ "-o",
21
+ "--output",
22
+ metavar="FILE",
23
+ help="write Markdown to FILE instead of printing to stdout",
24
+ )
25
+ return parser
26
+
27
+
28
+ def main(argv: list[str] | None = None) -> int:
29
+ args = build_parser().parse_args(argv)
30
+
31
+ try:
32
+ html = fetch_html(args.url)
33
+ article = parse_article(html, args.url)
34
+ except requests.RequestException as exc:
35
+ print(f"wswsmd: failed to fetch {args.url}: {exc}", file=sys.stderr)
36
+ return 1
37
+ except ValueError as exc:
38
+ print(f"wswsmd: {exc}", file=sys.stderr)
39
+ return 1
40
+
41
+ markdown = to_markdown(article)
42
+
43
+ if args.output:
44
+ with open(args.output, "w", encoding="utf-8") as f:
45
+ f.write(markdown)
46
+ else:
47
+ print(markdown, end="")
48
+
49
+ return 0
50
+
51
+
52
+ if __name__ == "__main__":
53
+ sys.exit(main())
@@ -0,0 +1,43 @@
1
+ import re
2
+
3
+ from markdownify import ATX, markdownify
4
+
5
+ from wswsmd.parse import Article
6
+
7
+ _BLANK_LINES = re.compile(r"\n{3,}")
8
+
9
+
10
+ def _yaml_str(value: str) -> str:
11
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ")
12
+ return f'"{escaped}"'
13
+
14
+
15
+ def _frontmatter(article: Article) -> str:
16
+ lines = [
17
+ "---",
18
+ f"title: {_yaml_str(article.title)}",
19
+ f"author: {_yaml_str(article.author)}",
20
+ f"date: {_yaml_str(article.date_iso)}",
21
+ f"description: {_yaml_str(article.description)}",
22
+ f"url: {_yaml_str(article.url)}",
23
+ "---",
24
+ ]
25
+ return "\n".join(lines)
26
+
27
+
28
+ def _byline(article: Article) -> str:
29
+ parts = [p for p in (article.author, article.date_display) if p]
30
+ return f"*{' — '.join(parts)}*" if parts else ""
31
+
32
+
33
+ def to_markdown(article: Article) -> str:
34
+ body = markdownify(article.body_html, heading_style=ATX, bullets="-").strip()
35
+ body = _BLANK_LINES.sub("\n\n", body)
36
+
37
+ sections = [_frontmatter(article), f"# {article.title}"]
38
+ byline = _byline(article)
39
+ if byline:
40
+ sections.append(byline)
41
+ sections.append(body)
42
+
43
+ return "\n\n".join(sections) + "\n"
@@ -0,0 +1,14 @@
1
+ import requests
2
+
3
+ TIMEOUT_SECONDS = 15
4
+
5
+
6
+ def fetch_html(url: str) -> str:
7
+ """Fetch a URL and return its response body as text.
8
+
9
+ Raises requests.RequestException (or a subclass) on network errors
10
+ or non-2xx status codes.
11
+ """
12
+ response = requests.get(url, timeout=TIMEOUT_SECONDS)
13
+ response.raise_for_status()
14
+ return response.text
@@ -0,0 +1,107 @@
1
+ from dataclasses import dataclass
2
+
3
+ from bs4 import BeautifulSoup, Tag
4
+
5
+ TITLE_SUFFIX = " - World Socialist Web Site"
6
+
7
+
8
+ @dataclass
9
+ class Article:
10
+ title: str
11
+ author: str
12
+ date_iso: str
13
+ date_display: str
14
+ url: str
15
+ description: str
16
+ body_html: str
17
+
18
+
19
+ def _meta_content(soup: BeautifulSoup, **attrs) -> str:
20
+ tag = soup.find("meta", attrs=attrs)
21
+ return tag["content"].strip() if tag and tag.get("content") else ""
22
+
23
+
24
+ def _extract_title(soup: BeautifulSoup) -> str:
25
+ title = _meta_content(soup, property="og:title")
26
+ if title:
27
+ return title
28
+ if soup.title and soup.title.string:
29
+ text = soup.title.string.strip()
30
+ if text.endswith(TITLE_SUFFIX):
31
+ text = text[: -len(TITLE_SUFFIX)]
32
+ return text
33
+ return ""
34
+
35
+
36
+ def _extract_author(soup: BeautifulSoup) -> str:
37
+ header = soup.find("header")
38
+ if not header:
39
+ return ""
40
+ link = header.find("a", href=lambda h: h and h.startswith("/en/authors/"))
41
+ return link.get_text(strip=True) if link else ""
42
+
43
+
44
+ def _extract_date(soup: BeautifulSoup) -> tuple[str, str]:
45
+ header = soup.find("header")
46
+ time_tag = header.find("time") if header else soup.find("time")
47
+ if not time_tag:
48
+ return "", ""
49
+ return time_tag.get("datetime", "").strip(), time_tag.get_text(strip=True)
50
+
51
+
52
+ def _extract_body(soup: BeautifulSoup) -> Tag:
53
+ section = soup.select_one('[itemprop="articleBody"]')
54
+ if section is None:
55
+ raise ValueError(
56
+ "Could not find article body (no [itemprop=articleBody] element)"
57
+ )
58
+
59
+ # The real prose lives in a nested div.content; everything above it
60
+ # (share-icon <ul>, layout <style>/<div> wrappers) is chrome, and
61
+ # everything below/around it ("Read more", comments, related topics)
62
+ # is a sibling, not part of the article.
63
+ content = section.select_one("div.content") or section.find("div", recursive=False)
64
+ if content is None:
65
+ raise ValueError("Could not find article body content")
66
+
67
+ # Widgets (audio player, newsletter signup, promo banners) are the
68
+ # only direct-child <div>s here; real paragraphs are always <p>.
69
+ for widget in content.find_all("div", recursive=False):
70
+ widget.decompose()
71
+ for aside in content.find_all("aside"):
72
+ aside.decompose()
73
+ # Photos are wrapped as <figure><img/><figcaption>...</figcaption></figure>;
74
+ # dropping the whole <figure> removes the image and its caption together.
75
+ for figure in content.find_all("figure"):
76
+ figure.decompose()
77
+ for tag in content.find_all(["script", "style", "svg"]):
78
+ tag.decompose()
79
+ for img in content.find_all("img"):
80
+ classes = img.get("class") or []
81
+ if "dn-m" in classes:
82
+ img.decompose()
83
+
84
+ return content
85
+
86
+
87
+ def parse_article(html: str, url: str) -> Article:
88
+ soup = BeautifulSoup(html, "lxml")
89
+
90
+ title = _extract_title(soup)
91
+ if not title:
92
+ raise ValueError(f"Could not find an article title at {url}")
93
+
94
+ author = _extract_author(soup)
95
+ date_iso, date_display = _extract_date(soup)
96
+ description = _meta_content(soup, name="description")
97
+ body = _extract_body(soup)
98
+
99
+ return Article(
100
+ title=title,
101
+ author=author,
102
+ date_iso=date_iso,
103
+ date_display=date_display,
104
+ url=url,
105
+ description=description,
106
+ body_html=body.decode_contents(),
107
+ )
@@ -0,0 +1,61 @@
1
+ from pathlib import Path
2
+ from unittest.mock import patch
3
+
4
+ import pytest
5
+ import requests
6
+
7
+ from wswsmd import cli
8
+
9
+ FIXTURE = Path(__file__).parent / "fixtures" / "coeq-j03.html"
10
+ URL = "https://www.wsws.org/en/articles/2026/07/03/coeq-j03.html"
11
+
12
+
13
+ def test_build_parser_requires_url():
14
+ parser = cli.build_parser()
15
+ with pytest.raises(SystemExit):
16
+ parser.parse_args([])
17
+
18
+
19
+ def test_build_parser_accepts_output_flag():
20
+ parser = cli.build_parser()
21
+ args = parser.parse_args([URL, "-o", "out.md"])
22
+ assert args.url == URL
23
+ assert args.output == "out.md"
24
+
25
+
26
+ def test_main_prints_markdown_to_stdout(capsys):
27
+ html = FIXTURE.read_text()
28
+ with patch("wswsmd.cli.fetch_html", return_value=html):
29
+ exit_code = cli.main([URL])
30
+
31
+ captured = capsys.readouterr()
32
+ assert exit_code == 0
33
+ assert "The Colorado primary" in captured.out
34
+
35
+
36
+ def test_main_writes_markdown_to_file(tmp_path):
37
+ html = FIXTURE.read_text()
38
+ output_file = tmp_path / "article.md"
39
+ with patch("wswsmd.cli.fetch_html", return_value=html):
40
+ exit_code = cli.main([URL, "-o", str(output_file)])
41
+
42
+ assert exit_code == 0
43
+ assert "The Colorado primary" in output_file.read_text()
44
+
45
+
46
+ def test_main_reports_fetch_errors(capsys):
47
+ with patch("wswsmd.cli.fetch_html", side_effect=requests.ConnectionError("boom")):
48
+ exit_code = cli.main([URL])
49
+
50
+ captured = capsys.readouterr()
51
+ assert exit_code == 1
52
+ assert "failed to fetch" in captured.err
53
+
54
+
55
+ def test_main_reports_parse_errors(capsys):
56
+ with patch("wswsmd.cli.fetch_html", return_value="<html><body></body></html>"):
57
+ exit_code = cli.main([URL])
58
+
59
+ captured = capsys.readouterr()
60
+ assert exit_code == 1
61
+ assert "wswsmd:" in captured.err
@@ -0,0 +1,47 @@
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+
5
+ from wswsmd.convert import to_markdown
6
+ from wswsmd.parse import parse_article
7
+
8
+ FIXTURE = Path(__file__).parent / "fixtures" / "coeq-j03.html"
9
+ URL = "https://www.wsws.org/en/articles/2026/07/03/coeq-j03.html"
10
+
11
+
12
+ @pytest.fixture
13
+ def markdown():
14
+ html = FIXTURE.read_text()
15
+ article = parse_article(html, URL)
16
+ return to_markdown(article)
17
+
18
+
19
+ def test_frontmatter(markdown):
20
+ assert markdown.startswith("---\n")
21
+ front, _, rest = markdown[4:].partition("\n---\n")
22
+ assert (
23
+ 'title: "The Colorado primary and the growing support for socialism"' in front
24
+ )
25
+ assert 'author: "Patrick Martin"' in front
26
+ assert f'url: "{URL}"' in front
27
+
28
+ assert "# The Colorado primary and the growing support for socialism" in rest
29
+ assert "*Patrick Martin — 2 July 2026*" in rest
30
+
31
+
32
+ def test_body_is_markdown_paragraphs(markdown):
33
+ assert "Diana DeGette" in markdown
34
+ assert "<p>" not in markdown
35
+ assert "<div" not in markdown
36
+
37
+
38
+ def test_no_leftover_ads_or_navigation(markdown):
39
+ assert "AudioNative" not in markdown
40
+ assert "Read more" not in markdown
41
+ assert "Related Topics" not in markdown
42
+
43
+
44
+ def test_inline_emphasis_preserved(markdown):
45
+ assert (
46
+ "*Washington Post*" in markdown
47
+ ) # <em>Washington Post</em> converts to emphasis
@@ -0,0 +1,29 @@
1
+ from unittest.mock import Mock, patch
2
+
3
+ import pytest
4
+ import requests
5
+
6
+ from wswsmd.fetch import TIMEOUT_SECONDS, fetch_html
7
+
8
+
9
+ def test_fetch_html_returns_response_text():
10
+ mock_response = Mock()
11
+ mock_response.text = "<html>ok</html>"
12
+ mock_response.raise_for_status.return_value = None
13
+
14
+ with patch("wswsmd.fetch.requests.get", return_value=mock_response) as mock_get:
15
+ result = fetch_html("https://example.com/article")
16
+
17
+ assert result == "<html>ok</html>"
18
+ mock_get.assert_called_once_with(
19
+ "https://example.com/article", timeout=TIMEOUT_SECONDS
20
+ )
21
+
22
+
23
+ def test_fetch_html_raises_on_http_error():
24
+ mock_response = Mock()
25
+ mock_response.raise_for_status.side_effect = requests.HTTPError("404")
26
+
27
+ with patch("wswsmd.fetch.requests.get", return_value=mock_response):
28
+ with pytest.raises(requests.HTTPError):
29
+ fetch_html("https://example.com/missing")
@@ -0,0 +1,72 @@
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+
5
+ from wswsmd.parse import parse_article
6
+
7
+ FIXTURE = Path(__file__).parent / "fixtures" / "coeq-j03.html"
8
+ URL = "https://www.wsws.org/en/articles/2026/07/03/coeq-j03.html"
9
+
10
+ PHOTO_FIXTURE = Path(__file__).parent / "fixtures" / "yfab-j12.html"
11
+ PHOTO_URL = "https://www.wsws.org/en/articles/2026/07/12/yfab-j12.html"
12
+
13
+
14
+ @pytest.fixture
15
+ def article():
16
+ html = FIXTURE.read_text()
17
+ return parse_article(html, URL)
18
+
19
+
20
+ @pytest.fixture
21
+ def photo_article():
22
+ html = PHOTO_FIXTURE.read_text()
23
+ return parse_article(html, PHOTO_URL)
24
+
25
+
26
+ def test_metadata(article):
27
+ assert article.title == "The Colorado primary and the growing support for socialism"
28
+ assert article.author == "Patrick Martin"
29
+ assert article.date_iso == "2026-07-03T02:01:26.000Z"
30
+ assert article.date_display == "2 July 2026"
31
+ assert article.url == URL
32
+ assert "Democratic Socialists of America" in article.description
33
+
34
+
35
+ def test_body_contains_article_text(article):
36
+ assert "Diana DeGette" in article.body_html
37
+ assert "Socialist Equality Party" in article.body_html
38
+
39
+
40
+ def test_body_excludes_ads_and_widgets(article):
41
+ assert "AudioNative" not in article.body_html
42
+ assert "elevenlabs" not in article.body_html.lower()
43
+ assert "jun25-declaration-of-independence" not in article.body_html
44
+
45
+
46
+ def test_body_excludes_trailing_navigation(article):
47
+ assert "Read more" not in article.body_html
48
+ assert "Related Topics" not in article.body_html
49
+ assert "Contact us" not in article.body_html
50
+
51
+
52
+ def test_body_has_no_duplicate_mobile_images(article):
53
+ assert "dn-m" not in article.body_html
54
+
55
+
56
+ def test_photo_metadata(photo_article):
57
+ assert photo_article.title == (
58
+ "Durham Miners' Gala used by labour and union bureaucracy "
59
+ "to back Andy Burnham as new prime minister"
60
+ )
61
+ assert photo_article.author == "Our reporters"
62
+ assert photo_article.date_iso == "2026-07-12T17:42:21.000Z"
63
+ assert photo_article.url == PHOTO_URL
64
+
65
+
66
+ def test_body_excludes_photos_and_captions(photo_article):
67
+ assert "<figure" not in photo_article.body_html
68
+ assert "<figcaption" not in photo_article.body_html
69
+ assert "<img" not in photo_article.body_html
70
+ assert (
71
+ "Andrea Egan speaking at the Durham Miners' Gala" not in photo_article.body_html
72
+ )
@@ -0,0 +1,94 @@
1
+ import pytest
2
+ from bs4 import BeautifulSoup
3
+
4
+ from wswsmd.parse import (
5
+ _extract_author,
6
+ _extract_body,
7
+ _extract_date,
8
+ _extract_title,
9
+ parse_article,
10
+ )
11
+
12
+
13
+ def soup_of(html: str) -> BeautifulSoup:
14
+ return BeautifulSoup(html, "lxml")
15
+
16
+
17
+ def test_extract_title_falls_back_to_html_title_tag():
18
+ soup = soup_of(
19
+ "<html><head><title>Some Article - World Socialist Web Site</title>"
20
+ "</head></html>"
21
+ )
22
+ assert _extract_title(soup) == "Some Article"
23
+
24
+
25
+ def test_extract_title_keeps_full_text_when_no_suffix():
26
+ soup = soup_of("<html><head><title>Some Article</title></head></html>")
27
+ assert _extract_title(soup) == "Some Article"
28
+
29
+
30
+ def test_extract_title_returns_empty_when_no_title_available():
31
+ soup = soup_of("<html><head></head></html>")
32
+ assert _extract_title(soup) == ""
33
+
34
+
35
+ def test_extract_author_returns_empty_without_header():
36
+ soup = soup_of("<html><body><p>no header here</p></body></html>")
37
+ assert _extract_author(soup) == ""
38
+
39
+
40
+ def test_extract_date_returns_empty_without_time_tag():
41
+ soup = soup_of("<html><body><header></header></body></html>")
42
+ assert _extract_date(soup) == ("", "")
43
+
44
+
45
+ def test_extract_body_raises_without_article_body():
46
+ soup = soup_of("<html><body><div>no article body here</div></body></html>")
47
+ with pytest.raises(ValueError, match="articleBody"):
48
+ _extract_body(soup)
49
+
50
+
51
+ def test_extract_body_raises_without_content_div():
52
+ soup = soup_of('<html><body><div itemprop="articleBody"></div></body></html>')
53
+ with pytest.raises(ValueError, match="body content"):
54
+ _extract_body(soup)
55
+
56
+
57
+ def test_extract_body_strips_scripts_styles_and_svgs():
58
+ soup = soup_of(
59
+ '<html><body><div itemprop="articleBody"><div class="content">'
60
+ "<p>Keep me</p>"
61
+ "<script>evil()</script>"
62
+ "<style>.x{}</style>"
63
+ "<svg></svg>"
64
+ "</div></div></body></html>"
65
+ )
66
+ content = _extract_body(soup)
67
+ html = str(content)
68
+ assert "Keep me" in html
69
+ assert "<script" not in html
70
+ assert "<style" not in html
71
+ assert "<svg" not in html
72
+
73
+
74
+ def test_extract_body_strips_mobile_only_images():
75
+ soup = soup_of(
76
+ '<html><body><div itemprop="articleBody"><div class="content">'
77
+ "<p>Text</p>"
78
+ '<img class="dn-m" src="mobile.jpg">'
79
+ '<img class="dn-d" src="desktop.jpg">'
80
+ "</div></div></body></html>"
81
+ )
82
+ content = _extract_body(soup)
83
+ html = str(content)
84
+ assert "mobile.jpg" not in html
85
+ assert "desktop.jpg" in html
86
+
87
+
88
+ def test_parse_article_raises_without_title():
89
+ html = (
90
+ '<html><head></head><body><div itemprop="articleBody">'
91
+ '<div class="content"><p>Body text</p></div></div></body></html>'
92
+ )
93
+ with pytest.raises(ValueError, match="title"):
94
+ parse_article(html, "https://example.com/x")