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 +21 -0
- wsws_md-0.1.2/PKG-INFO +60 -0
- wsws_md-0.1.2/README.md +37 -0
- wsws_md-0.1.2/pyproject.toml +52 -0
- wsws_md-0.1.2/setup.cfg +4 -0
- wsws_md-0.1.2/src/wsws_md.egg-info/PKG-INFO +60 -0
- wsws_md-0.1.2/src/wsws_md.egg-info/SOURCES.txt +19 -0
- wsws_md-0.1.2/src/wsws_md.egg-info/dependency_links.txt +1 -0
- wsws_md-0.1.2/src/wsws_md.egg-info/entry_points.txt +2 -0
- wsws_md-0.1.2/src/wsws_md.egg-info/requires.txt +15 -0
- wsws_md-0.1.2/src/wsws_md.egg-info/top_level.txt +1 -0
- wsws_md-0.1.2/src/wswsmd/__init__.py +1 -0
- wsws_md-0.1.2/src/wswsmd/cli.py +53 -0
- wsws_md-0.1.2/src/wswsmd/convert.py +43 -0
- wsws_md-0.1.2/src/wswsmd/fetch.py +14 -0
- wsws_md-0.1.2/src/wswsmd/parse.py +107 -0
- wsws_md-0.1.2/tests/test_cli.py +61 -0
- wsws_md-0.1.2/tests/test_convert.py +47 -0
- wsws_md-0.1.2/tests/test_fetch.py +29 -0
- wsws_md-0.1.2/tests/test_parse.py +72 -0
- wsws_md-0.1.2/tests/test_parse_edge_cases.py +94 -0
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
|
+
[](https://github.com/JoeMountWSWS/wsws-md/actions/workflows/ci.yml)
|
|
27
|
+
[](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).
|
wsws_md-0.1.2/README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# WSWS Markdown
|
|
2
|
+
|
|
3
|
+
[](https://github.com/JoeMountWSWS/wsws-md/actions/workflows/ci.yml)
|
|
4
|
+
[](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"]
|
wsws_md-0.1.2/setup.cfg
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
|
+
[](https://github.com/JoeMountWSWS/wsws-md/actions/workflows/ci.yml)
|
|
27
|
+
[](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 @@
|
|
|
1
|
+
|
|
@@ -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")
|