usenet 0.1.0a2__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.
usenet-0.1.0a2/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Licensed under the Apache License, Version 2.0 (the "License");
6
+ you may not use this file except in compliance with the License.
7
+ You may obtain a copy of the License at
8
+
9
+ http://www.apache.org/licenses/LICENSE-2.0
10
+
11
+ Unless required by applicable law or agreed to in writing, software
12
+ distributed under the License is distributed on an "AS IS" BASIS,
13
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ See the License for the specific language governing permissions and
15
+ limitations under the License.
16
+
17
+ Copyright 2026 JarbasAi
18
+
19
+ Full text: https://www.apache.org/licenses/LICENSE-2.0.txt
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: usenet
3
+ Version: 0.1.0a2
4
+ Summary: NNTP client and newsgroup harvesting toolkit for Python.
5
+ Author-email: JarbasAi <jarbasai@mailfence.com>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/JarbasAl/usenet
8
+ Keywords: usenet,nntp,newsgroups,scraper,corpus,dataset
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: Apache Software License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Topic :: Communications :: Usenet News
15
+ Classifier: Topic :: Software Development :: Libraries
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: dateparser
20
+ Requires-Dist: standard-nntplib; python_version >= "3.13"
21
+ Provides-Extra: scrape
22
+ Requires-Dist: unblock_requests; extra == "scrape"
23
+ Provides-Extra: anon
24
+ Requires-Dist: anon_requests; extra == "anon"
25
+ Provides-Extra: test
26
+ Requires-Dist: pytest>=7; extra == "test"
27
+ Requires-Dist: pytest-cov; extra == "test"
28
+ Dynamic: license-file
29
+
30
+ # usenet
31
+
32
+ NNTP client and newsgroup-harvesting toolkit for Python. Read and post articles,
33
+ discover public servers, and harvest groups into a text corpus.
34
+
35
+ Works on Python 3.9–3.13: `nntplib` was removed from the standard library in
36
+ 3.13 (PEP 594), so the `standard-nntplib` backport is pulled in automatically
37
+ there.
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install usenet
43
+ # optional: anti-bot + Wayback transport for the legacy server-list scrapers
44
+ pip install usenet[scrape]
45
+ ```
46
+
47
+ ## Quickstart
48
+
49
+ Read a group:
50
+
51
+ ```python
52
+ from datetime import timedelta
53
+ from usenet import UsenetServer
54
+
55
+ with UsenetServer("news.eternal-september.org") as server:
56
+ for article in server.get_new_news("comp.lang.python", since=timedelta(days=7)):
57
+ print(article.subject, article.author, article.date)
58
+ print(article.text)
59
+ ```
60
+
61
+ Post an article (most servers need a free account):
62
+
63
+ ```python
64
+ from usenet import UsenetServer
65
+
66
+ with UsenetServer("news.eternal-september.org", user="login", pswd="secret") as server:
67
+ server.post("this is a test", subject="hello", group="misc.test")
68
+ ```
69
+
70
+ ## Server discovery
71
+
72
+ A curated, offline list ships with the package:
73
+
74
+ ```python
75
+ from usenet import get_known_servers
76
+
77
+ for s in get_known_servers():
78
+ print(s.url, s._can_post)
79
+ ```
80
+
81
+ The legacy directory scrapers in `usenet.scrappers` are a secondary "refresh
82
+ once" path; their source sites are mostly dead, so with `usenet[scrape]`
83
+ installed requests fall back to the Wayback Machine. See `examples/`.
84
+
85
+ ## Dataset
86
+
87
+ `dataset.py` harvests a newsgroup into a JSONL corpus (one article per line) for
88
+ publishing to the Hugging Face Hub. See [docs/dataset.md](docs/dataset.md).
89
+
90
+ ```bash
91
+ python dataset.py comp.lang.python --days 30 --out comp.lang.python.jsonl
92
+ ```
93
+
94
+ ## Testing
95
+
96
+ ```bash
97
+ pip install -e .[test]
98
+ pytest test/
99
+ ```
100
+
101
+ The unit tests are offline; they exercise article parsing, post framing, the
102
+ bundled server list, and scraper HTML parsing against fixtures.
103
+
104
+ ## License
105
+
106
+ Apache-2.0
@@ -0,0 +1,77 @@
1
+ # usenet
2
+
3
+ NNTP client and newsgroup-harvesting toolkit for Python. Read and post articles,
4
+ discover public servers, and harvest groups into a text corpus.
5
+
6
+ Works on Python 3.9–3.13: `nntplib` was removed from the standard library in
7
+ 3.13 (PEP 594), so the `standard-nntplib` backport is pulled in automatically
8
+ there.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pip install usenet
14
+ # optional: anti-bot + Wayback transport for the legacy server-list scrapers
15
+ pip install usenet[scrape]
16
+ ```
17
+
18
+ ## Quickstart
19
+
20
+ Read a group:
21
+
22
+ ```python
23
+ from datetime import timedelta
24
+ from usenet import UsenetServer
25
+
26
+ with UsenetServer("news.eternal-september.org") as server:
27
+ for article in server.get_new_news("comp.lang.python", since=timedelta(days=7)):
28
+ print(article.subject, article.author, article.date)
29
+ print(article.text)
30
+ ```
31
+
32
+ Post an article (most servers need a free account):
33
+
34
+ ```python
35
+ from usenet import UsenetServer
36
+
37
+ with UsenetServer("news.eternal-september.org", user="login", pswd="secret") as server:
38
+ server.post("this is a test", subject="hello", group="misc.test")
39
+ ```
40
+
41
+ ## Server discovery
42
+
43
+ A curated, offline list ships with the package:
44
+
45
+ ```python
46
+ from usenet import get_known_servers
47
+
48
+ for s in get_known_servers():
49
+ print(s.url, s._can_post)
50
+ ```
51
+
52
+ The legacy directory scrapers in `usenet.scrappers` are a secondary "refresh
53
+ once" path; their source sites are mostly dead, so with `usenet[scrape]`
54
+ installed requests fall back to the Wayback Machine. See `examples/`.
55
+
56
+ ## Dataset
57
+
58
+ `dataset.py` harvests a newsgroup into a JSONL corpus (one article per line) for
59
+ publishing to the Hugging Face Hub. See [docs/dataset.md](docs/dataset.md).
60
+
61
+ ```bash
62
+ python dataset.py comp.lang.python --days 30 --out comp.lang.python.jsonl
63
+ ```
64
+
65
+ ## Testing
66
+
67
+ ```bash
68
+ pip install -e .[test]
69
+ pytest test/
70
+ ```
71
+
72
+ The unit tests are offline; they exercise article parsing, post framing, the
73
+ bundled server list, and scraper HTML parsing against fixtures.
74
+
75
+ ## License
76
+
77
+ Apache-2.0
@@ -0,0 +1,54 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "usenet"
7
+ dynamic = ["version"]
8
+ description = "NNTP client and newsgroup harvesting toolkit for Python."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = {text = "Apache-2.0"}
12
+ authors = [{name = "JarbasAi", email = "jarbasai@mailfence.com"}]
13
+ keywords = ["usenet", "nntp", "newsgroups", "scraper", "corpus", "dataset"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: Apache Software License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Topic :: Communications :: Usenet News",
21
+ "Topic :: Software Development :: Libraries",
22
+ ]
23
+ dependencies = [
24
+ "dateparser",
25
+ # nntplib was removed from the stdlib in Python 3.13 (PEP 594); the
26
+ # backport keeps `import nntplib` working there. On 3.9-3.12 the stdlib
27
+ # module is used and this dependency is not installed.
28
+ "standard-nntplib; python_version >= '3.13'",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ # Anti-bot transport for the (mostly archival) server-list scrapers; dead
33
+ # sites resolve transparently via the Wayback fallback.
34
+ scrape = ["unblock_requests"]
35
+ # IP rotation layer, composed with the scrape transport.
36
+ anon = ["anon_requests"]
37
+ test = ["pytest>=7", "pytest-cov"]
38
+
39
+ [project.urls]
40
+ Homepage = "https://github.com/JarbasAl/usenet"
41
+
42
+ [tool.setuptools.dynamic]
43
+ version = {attr = "usenet.version.__version__"}
44
+
45
+ [tool.setuptools.packages.find]
46
+ include = ["usenet*"]
47
+ exclude = ["test*", "examples*", "docs*"]
48
+
49
+ [tool.setuptools.package-data]
50
+ "usenet.data" = ["*.json"]
51
+
52
+ [tool.pytest.ini_options]
53
+ testpaths = ["test"]
54
+ addopts = "-q"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,84 @@
1
+ """Offline tests for usenet.article_entry.Article."""
2
+ from datetime import datetime
3
+
4
+ from usenet.article_entry import Article
5
+
6
+ HEADERS = [
7
+ b"From: Alice <alice@example.com>",
8
+ b"Subject: Hello World",
9
+ b"Date: Mon, 5 May 2025 10:00:00 +0000",
10
+ b"Content-Language: en",
11
+ b"Newsgroups: comp.lang.python",
12
+ ]
13
+ BODY = [b"first line", b"second line"]
14
+
15
+
16
+ def test_headers_parsed():
17
+ art = Article("1", headers=HEADERS, body=BODY)
18
+ h = art.headers
19
+ assert h["From"] == "Alice <alice@example.com>"
20
+ assert h["Subject"] == "Hello World"
21
+ assert h["Content-Language"] == "en"
22
+
23
+
24
+ def test_subject_author_language():
25
+ art = Article("1", headers=HEADERS, body=BODY)
26
+ assert art.subject == "Hello World"
27
+ assert art.author == "Alice <alice@example.com>"
28
+ assert art.language == "en"
29
+
30
+
31
+ def test_date_parsed():
32
+ art = Article("1", headers=HEADERS, body=BODY)
33
+ assert isinstance(art.date, datetime)
34
+ assert art.date.year == 2025
35
+
36
+
37
+ def test_date_with_rfc2822_utc_comment():
38
+ # the obsolete "-0000 (UTC)" comment form that dateparser returns None for
39
+ art = Article("1", headers=[b"Date: Thu, 14 May 2026 01:06:51 -0000 (UTC)"],
40
+ body=[])
41
+ assert isinstance(art.date, datetime)
42
+ assert (art.date.year, art.date.month, art.date.day) == (2026, 5, 14)
43
+
44
+
45
+ def test_text_join():
46
+ art = Article("1", headers=HEADERS, body=BODY)
47
+ assert art.text == "first line\nsecond line"
48
+
49
+
50
+ def test_missing_headers_are_safe():
51
+ art = Article("1", headers=[b"X-Foo: bar"], body=[])
52
+ assert art.subject == ""
53
+ assert art.author == ""
54
+ assert art.language is None
55
+ assert art.date is None
56
+ assert art.text == ""
57
+
58
+
59
+ def test_binary_body_falls_back():
60
+ art = Article("1", headers=HEADERS, body=[b"\xff\xfe\x00bad"])
61
+ assert "<failed to decode line, binary data?>" in art.text
62
+
63
+
64
+ def test_str_lines_pass_through():
65
+ art = Article("1", headers=["Subject: already str"], body=["plain"])
66
+ assert art.subject == "already str"
67
+ assert art.text == "plain"
68
+
69
+
70
+ def test_missing_article_is_tolerated():
71
+ # a cancelled/expired article number raises 4xx on head/body; must not crash
72
+ import nntplib
73
+
74
+ class GoneConn:
75
+ def head(self, article_id):
76
+ raise nntplib.NNTPTemporaryError("423 no such article")
77
+
78
+ def body(self, article_id):
79
+ raise nntplib.NNTPTemporaryError("423 no such article")
80
+
81
+ art = Article("999", connection=GoneConn())
82
+ assert art.subject == ""
83
+ assert art.text == ""
84
+ assert art.date is None
@@ -0,0 +1,23 @@
1
+ """Offline tests for the bundled public-server list."""
2
+ from usenet import get_known_servers, load_server_records
3
+ from usenet.models import ServerRecord
4
+ from usenet.server_entry import UsenetServer
5
+
6
+
7
+ def test_records_load():
8
+ records = load_server_records()
9
+ assert isinstance(records, list)
10
+ assert records, "bundled servers.json should not be empty"
11
+ assert all(isinstance(r, ServerRecord) for r in records)
12
+ for rec in records:
13
+ assert rec.host
14
+ assert isinstance(rec.port, int)
15
+
16
+
17
+ def test_get_known_servers_yields_server_objects():
18
+ servers = list(get_known_servers()) # validate=False -> no network
19
+ assert servers
20
+ assert all(isinstance(s, UsenetServer) for s in servers)
21
+ # the posting hint is carried over from the JSON
22
+ hosts = {s.url for s in servers}
23
+ assert "news.eternal-september.org" in hosts
@@ -0,0 +1,55 @@
1
+ """Offline tests for the dataclass value objects."""
2
+ from dataclasses import asdict
3
+
4
+ from usenet.article_entry import Article
5
+ from usenet.models import ArticleRecord, ServerRecord
6
+ from usenet.server_entry import UsenetServer
7
+
8
+
9
+ def test_server_record_from_dict_defaults():
10
+ rec = ServerRecord.from_dict({"host": "news.example.org"})
11
+ assert rec.host == "news.example.org"
12
+ assert rec.port == 119
13
+ assert rec.tls_port is None
14
+ assert rec.post is None
15
+ assert rec.auth == "none"
16
+
17
+
18
+ def test_server_record_to_server_carries_post_hint():
19
+ rec = ServerRecord.from_dict({"host": "news.example.org", "post": False})
20
+ server = rec.to_server()
21
+ assert isinstance(server, UsenetServer)
22
+ assert server.url == "news.example.org"
23
+ assert server._can_post is False
24
+
25
+
26
+ def test_server_record_is_frozen():
27
+ rec = ServerRecord(host="h")
28
+ try:
29
+ rec.host = "other" # type: ignore[misc]
30
+ except Exception as exc:
31
+ assert exc.__class__.__name__ == "FrozenInstanceError"
32
+ else:
33
+ raise AssertionError("ServerRecord should be immutable")
34
+
35
+
36
+ def test_article_record_from_article():
37
+ headers = [
38
+ b"From: Bob <bob@example.com>",
39
+ b"Subject: Hi",
40
+ b"Date: Mon, 5 May 2025 10:00:00 +0000",
41
+ b"Content-Language: en",
42
+ ]
43
+ art = Article("42", headers=headers, body=[b"hello"])
44
+ rec = ArticleRecord.from_article(art, group="comp.lang.python")
45
+ assert rec.group == "comp.lang.python"
46
+ assert rec.message_id == "42"
47
+ assert rec.subject == "Hi"
48
+ assert rec.author == "Bob <bob@example.com>"
49
+ assert rec.language == "en"
50
+ assert rec.date.startswith("2025-05-05")
51
+ assert rec.text == "hello"
52
+ # serializes cleanly for JSONL
53
+ assert set(asdict(rec)) == {
54
+ "group", "message_id", "subject", "author", "date", "language", "text",
55
+ }
@@ -0,0 +1,29 @@
1
+ """Offline tests for the directory scrapers (HTML parsing only)."""
2
+ import usenet.scrappers as scrappers
3
+
4
+
5
+ def test_elfqrin_parses_hosts(monkeypatch):
6
+ html = 'var x; c[i]="news.example.com"; i++; c[i]="news2.example.com"; i++;'
7
+ monkeypatch.setattr(scrappers, "get_html", lambda url, **kw: html)
8
+ hosts = [s.url for s in scrappers.get_elfqrin(validate=False)]
9
+ assert hosts == ["news.example.com", "news2.example.com"]
10
+
11
+
12
+ def test_news_url_table_parses_sok(monkeypatch):
13
+ html = (
14
+ "<table>"
15
+ '<tr><th>name</th></tr>'
16
+ '<tr><td><a href="news://news.alpha.org/">alpha</a></td></tr>'
17
+ '<tr><td><a href="news://news.beta.net/">beta</a></td></tr>'
18
+ "</table>"
19
+ )
20
+ monkeypatch.setattr(scrappers, "get_html", lambda url, **kw: html)
21
+ hosts = [s.url for s in scrappers.get_sok(validate=False)]
22
+ assert hosts == ["news.alpha.org", "news.beta.net"]
23
+
24
+
25
+ def test_scraper_skips_blank_hosts(monkeypatch):
26
+ html = 'c[i]=""; i++; c[i]="news.good.org"; i++;'
27
+ monkeypatch.setattr(scrappers, "get_html", lambda url, **kw: html)
28
+ hosts = [s.url for s in scrappers.get_elfqrin(validate=False)]
29
+ assert hosts == ["news.good.org"]
@@ -0,0 +1,82 @@
1
+ """Offline tests for usenet.server_entry.UsenetServer (no network)."""
2
+ from datetime import date, timedelta
3
+
4
+ from usenet.server_entry import UsenetServer
5
+
6
+
7
+ class FakeConn:
8
+ """Records arguments instead of talking to a real server."""
9
+
10
+ def __init__(self, group_range=None):
11
+ self.posted = None
12
+ self.newnews_args = None
13
+ self._group_range = group_range # (count, first, last)
14
+
15
+ def post(self, body):
16
+ self.posted = body
17
+ return "240 article posted"
18
+
19
+ def newnews(self, group, since):
20
+ self.newnews_args = (group, since)
21
+ return "230 list follows", []
22
+
23
+ def group(self, name):
24
+ count, first, last = self._group_range
25
+ return ("211 group selected", count, first, last, name)
26
+
27
+
28
+ def _server_with(conn):
29
+ server = UsenetServer("fake.invalid")
30
+ server._connection = conn
31
+ server._dead = False
32
+ return server
33
+
34
+
35
+ def test_post_frames_headers_and_body():
36
+ conn = FakeConn()
37
+ server = _server_with(conn)
38
+ server.post("hello body", subject="hi", group="misc.test")
39
+ raw = conn.posted.decode("utf-8")
40
+ assert "Subject: hi\r\n" in raw
41
+ assert "Newsgroups: misc.test\r\n" in raw
42
+ assert raw.endswith("\r\n\r\nhello body")
43
+ # default anonymous sender injected
44
+ assert "From: Anonymous User <anonymous@example.com>\r\n" in raw
45
+
46
+
47
+ def test_post_respects_from_address():
48
+ conn = FakeConn()
49
+ server = _server_with(conn)
50
+ server.post("x", subject="s", group="g", from_address="me@example.com")
51
+ assert "From: me@example.com\r\n" in conn.posted.decode("utf-8")
52
+
53
+
54
+ def test_get_new_news_converts_timedelta_to_date():
55
+ conn = FakeConn()
56
+ server = _server_with(conn)
57
+ server.get_new_news("comp.lang.python", since=timedelta(days=3))
58
+ group, since = conn.newnews_args
59
+ assert group == "comp.lang.python"
60
+ assert since == date.today() - timedelta(days=3)
61
+
62
+
63
+ def test_get_new_news_defaults_to_five_days():
64
+ conn = FakeConn()
65
+ server = _server_with(conn)
66
+ server.get_new_news("comp.lang.python")
67
+ _, since = conn.newnews_args
68
+ assert since == date.today() - timedelta(days=5)
69
+
70
+
71
+ def test_get_articles_returns_newest_first():
72
+ conn = FakeConn(group_range=(16, 10, 25))
73
+ server = _server_with(conn)
74
+ arts = server.get_articles("comp.lang.python", limit=5)
75
+ assert [a.article_id for a in arts] == [25, 24, 23, 22, 21]
76
+
77
+
78
+ def test_get_articles_clamps_to_group_start():
79
+ conn = FakeConn(group_range=(2, 1, 2))
80
+ server = _server_with(conn)
81
+ arts = server.get_articles("misc.test", limit=10)
82
+ assert [a.article_id for a in arts] == [2, 1]
@@ -0,0 +1,19 @@
1
+ from usenet.server_entry import UsenetServer
2
+ from usenet.article_entry import Article
3
+ from usenet.models import ServerRecord, ArticleRecord
4
+ from usenet.known_servers import get_known_servers, load_server_records
5
+ from usenet.probe import ServerProbe, probe_server, probe_many
6
+ from usenet.version import __version__
7
+
8
+ __all__ = [
9
+ "UsenetServer",
10
+ "Article",
11
+ "ServerRecord",
12
+ "ArticleRecord",
13
+ "get_known_servers",
14
+ "load_server_records",
15
+ "ServerProbe",
16
+ "probe_server",
17
+ "probe_many",
18
+ "__version__",
19
+ ]
@@ -0,0 +1,109 @@
1
+ """A single Usenet article, with lazy header/body retrieval."""
2
+ from datetime import datetime
3
+ from email.utils import parsedate_to_datetime
4
+ from typing import Dict, List, Optional
5
+
6
+ import dateparser
7
+ import nntplib
8
+
9
+
10
+ class Article:
11
+ def __init__(self, article_id, headers=None, body=None, connection=None):
12
+ self.article_id = article_id
13
+ self._headers = headers or {}
14
+ self._body = body
15
+ self.connection = connection
16
+
17
+ def bind(self, connection) -> None:
18
+ self.connection = connection
19
+
20
+ @property
21
+ def headers(self) -> Dict[str, str]:
22
+ headers: Dict[str, str] = {}
23
+ decoded = self._decode_headers()
24
+ for idx, line in enumerate(decoded):
25
+ if not line:
26
+ continue
27
+ # fold continuation lines (a header value wrapped onto the next line)
28
+ next_line = decoded[idx + 1] if idx + 1 < len(decoded) else None
29
+ if next_line and ": " not in next_line:
30
+ line = line + next_line
31
+ if ": " in line:
32
+ field, _, val = line.partition(": ")
33
+ headers[field] = val
34
+ return headers
35
+
36
+ @property
37
+ def text(self) -> str:
38
+ return "\n".join(self._decode_body())
39
+
40
+ @property
41
+ def subject(self) -> str:
42
+ return self.headers.get('Subject', '').replace("\n", " ")
43
+
44
+ @property
45
+ def author(self) -> str:
46
+ return self.headers.get('From', '')
47
+
48
+ @property
49
+ def date(self) -> Optional[datetime]:
50
+ raw = self.headers.get('Date')
51
+ if not raw:
52
+ return None
53
+ # email.utils handles RFC-2822 news/mail dates, including the obsolete
54
+ # "-0000 (UTC)" comment form that trips dateparser.
55
+ try:
56
+ return parsedate_to_datetime(raw)
57
+ except (TypeError, ValueError):
58
+ return dateparser.parse(raw)
59
+
60
+ @property
61
+ def language(self) -> Optional[str]:
62
+ return self.headers.get('Content-Language')
63
+
64
+ # internal
65
+ def _decode_body(self) -> List[str]:
66
+ if self.connection and not self._body:
67
+ # lazy get
68
+ self._get_body()
69
+ if not self._body:
70
+ return []
71
+ return self._decode(self._body)
72
+
73
+ def _decode_headers(self) -> List[str]:
74
+ if self.connection and not self._headers:
75
+ # lazy get
76
+ self._get_headers()
77
+ if not self._headers:
78
+ return []
79
+ return self._decode(self._headers)
80
+
81
+ @staticmethod
82
+ def _decode(lines) -> List[str]:
83
+ decoded_lines = []
84
+ for line in lines:
85
+ if isinstance(line, str):
86
+ decoded_lines.append(line)
87
+ continue
88
+ try:
89
+ decoded_lines.append(line.decode("utf-8"))
90
+ except (UnicodeDecodeError, AttributeError):
91
+ decoded_lines.append("<failed to decode line, binary data?>")
92
+ return decoded_lines
93
+
94
+ def _get_body(self, connection=None) -> None:
95
+ connection = connection or self.connection
96
+ # an article in a GROUP range may be cancelled/expired (4xx) — tolerate it
97
+ try:
98
+ response, article = connection.body(self.article_id)
99
+ self._body = article.lines if article else []
100
+ except (nntplib.NNTPError, OSError):
101
+ self._body = []
102
+
103
+ def _get_headers(self, connection=None) -> None:
104
+ connection = connection or self.connection
105
+ try:
106
+ response, article = connection.head(self.article_id)
107
+ self._headers = article.lines if article else []
108
+ except (nntplib.NNTPError, OSError):
109
+ self._headers = []
@@ -0,0 +1,13 @@
1
+ {
2
+ "description": "Curated list of public, text-oriented NNTP servers. `anon_read`/`anon_post` were verified live with usenet.probe (reachability and policy drift over time; re-probe to refresh). Binary servers are intentionally excluded.",
3
+ "servers": [
4
+ {"host": "paganini.bofh.team", "port": 119, "tls_port": 563, "anon_read": true, "anon_post": true, "post": true, "auth": "none", "notes": "BOFH.team; accepts anonymous posts to test groups (verified)."},
5
+ {"host": "news.tcpreset.net", "port": 119, "tls_port": 563, "anon_read": true, "anon_post": true, "post": true, "auth": "none", "notes": "tcpreset; accepts anonymous posts to test groups (verified)."},
6
+ {"host": "news.neodome.net", "port": 119, "tls_port": 563, "anon_read": true, "anon_post": false, "post": false, "auth": "free account to post", "notes": "Neodome; anonymous reading, carries alt.anonymous.messages; posting needs an account."},
7
+ {"host": "news.samoylyk.net", "port": 119, "tls_port": 563, "anon_read": true, "anon_post": false, "post": false, "auth": "free account to post", "notes": "Samoylyk public feed; anonymous reading."},
8
+ {"host": "freenews.netfront.net", "port": 119, "tls_port": null, "anon_read": true, "anon_post": false, "post": false, "auth": "free account to post", "notes": "NetFront free news; anonymous reading."},
9
+ {"host": "news.bbs.nz", "port": 119, "tls_port": 563, "anon_read": true, "anon_post": false, "post": false, "auth": "free account to post", "notes": "BBS.nz; anonymous reading."},
10
+ {"host": "news.dizum.net", "port": 119, "tls_port": null, "anon_read": true, "anon_post": false, "post": false, "auth": "free account to post", "notes": "Dizum (sewer); anonymous reading; also runs a mail2news gateway."},
11
+ {"host": "news.eternal-september.org", "port": 119, "tls_port": 563, "anon_read": false, "anon_post": false, "post": false, "auth": "free account", "notes": "Eternal September; free registration required to read and post."}
12
+ ]
13
+ }