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 +19 -0
- usenet-0.1.0a2/PKG-INFO +106 -0
- usenet-0.1.0a2/README.md +77 -0
- usenet-0.1.0a2/pyproject.toml +54 -0
- usenet-0.1.0a2/setup.cfg +4 -0
- usenet-0.1.0a2/test/test_article.py +84 -0
- usenet-0.1.0a2/test/test_known_servers.py +23 -0
- usenet-0.1.0a2/test/test_models.py +55 -0
- usenet-0.1.0a2/test/test_scrappers.py +29 -0
- usenet-0.1.0a2/test/test_server.py +82 -0
- usenet-0.1.0a2/usenet/__init__.py +19 -0
- usenet-0.1.0a2/usenet/article_entry.py +109 -0
- usenet-0.1.0a2/usenet/data/servers.json +13 -0
- usenet-0.1.0a2/usenet/known_servers.py +33 -0
- usenet-0.1.0a2/usenet/models.py +73 -0
- usenet-0.1.0a2/usenet/probe.py +78 -0
- usenet-0.1.0a2/usenet/scrappers/__init__.py +102 -0
- usenet-0.1.0a2/usenet/scrappers/transport.py +46 -0
- usenet-0.1.0a2/usenet/server_entry.py +168 -0
- usenet-0.1.0a2/usenet/version.py +8 -0
- usenet-0.1.0a2/usenet.egg-info/PKG-INFO +106 -0
- usenet-0.1.0a2/usenet.egg-info/SOURCES.txt +23 -0
- usenet-0.1.0a2/usenet.egg-info/dependency_links.txt +1 -0
- usenet-0.1.0a2/usenet.egg-info/requires.txt +14 -0
- usenet-0.1.0a2/usenet.egg-info/top_level.txt +1 -0
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
|
usenet-0.1.0a2/PKG-INFO
ADDED
|
@@ -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
|
usenet-0.1.0a2/README.md
ADDED
|
@@ -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"
|
usenet-0.1.0a2/setup.cfg
ADDED
|
@@ -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
|
+
}
|