nos-private-api 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nos_private_api-0.1.0/LICENSE +21 -0
- nos_private_api-0.1.0/PKG-INFO +142 -0
- nos_private_api-0.1.0/README.md +121 -0
- nos_private_api-0.1.0/nos_private_api/__init__.py +14 -0
- nos_private_api-0.1.0/nos_private_api/client.py +117 -0
- nos_private_api-0.1.0/nos_private_api/types.py +415 -0
- nos_private_api-0.1.0/nos_private_api.egg-info/PKG-INFO +142 -0
- nos_private_api-0.1.0/nos_private_api.egg-info/SOURCES.txt +11 -0
- nos_private_api-0.1.0/nos_private_api.egg-info/dependency_links.txt +1 -0
- nos_private_api-0.1.0/nos_private_api.egg-info/requires.txt +1 -0
- nos_private_api-0.1.0/nos_private_api.egg-info/top_level.txt +1 -0
- nos_private_api-0.1.0/pyproject.toml +32 -0
- nos_private_api-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 11philip22
|
|
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.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nos-private-api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the NOS Android app API
|
|
5
|
+
Author: 11philip22
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/11philip22/nos-private-api
|
|
8
|
+
Project-URL: Repository, https://github.com/11philip22/nos-private-api
|
|
9
|
+
Project-URL: Issues, https://github.com/11philip22/nos-private-api/issues
|
|
10
|
+
Keywords: nos,news,api,client
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: httpx
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
<div align="center">
|
|
23
|
+
|
|
24
|
+
# nos-private-api
|
|
25
|
+
|
|
26
|
+
*Python client and notes for the NOS Android app API*
|
|
27
|
+
|
|
28
|
+

|
|
29
|
+

|
|
30
|
+

|
|
31
|
+
|
|
32
|
+
[Features](#features) - [Installation](#installation) - [Usage](#usage) - [API map](#api-map)
|
|
33
|
+
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
`nos-private-api` is a small Python package for reading NOS app content through the same first-party endpoints used by the Android app. It includes a typed client for article feeds and item details, plus reverse-engineered API notes in [`docs/apis`](https://github.com/11philip22/nos-private-api/tree/main/docs/apis).
|
|
37
|
+
|
|
38
|
+
> [!IMPORTANT]
|
|
39
|
+
> This project targets an unofficial private API. Endpoints, response shapes, and header requirements can change without notice.
|
|
40
|
+
|
|
41
|
+
## Features
|
|
42
|
+
|
|
43
|
+
- Generate the app-style `X-NOS` header automatically.
|
|
44
|
+
- Fetch paginated item feeds with category, subcategory, type, and `before` filters.
|
|
45
|
+
- Fetch full article details by item ID.
|
|
46
|
+
- Parse item summaries, articles, images, videos, links, tags, and categories into dataclasses.
|
|
47
|
+
- Keep the original API payload available on `.raw` for fields that are not modeled yet.
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
Install from the repository root:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
python -m pip install .
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
For local development:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
python -m pip install -e .
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Usage
|
|
64
|
+
|
|
65
|
+
### List news articles
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from nos_private_api import NosClient
|
|
69
|
+
|
|
70
|
+
client = NosClient()
|
|
71
|
+
|
|
72
|
+
page = client.list_items(
|
|
73
|
+
main_categories="nieuws",
|
|
74
|
+
types="article",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
for item in page.items:
|
|
78
|
+
print(item.id, item.title)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Fetch an article
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
article = client.get_item(page.items[0].id)
|
|
85
|
+
|
|
86
|
+
print(article.title)
|
|
87
|
+
print(article.published_at)
|
|
88
|
+
print(article.text)
|
|
89
|
+
|
|
90
|
+
for image in article.images:
|
|
91
|
+
print(image.description, image.best_url(width=800, ratio="16:9"))
|
|
92
|
+
|
|
93
|
+
for video in article.videos:
|
|
94
|
+
print(video.title, video.best_url())
|
|
95
|
+
|
|
96
|
+
for link in article.links:
|
|
97
|
+
print(link.title, link.url)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Filter by subcategory
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
page = client.list_items(
|
|
104
|
+
main_categories="nieuws",
|
|
105
|
+
sub_categories="politiek",
|
|
106
|
+
types="article",
|
|
107
|
+
)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Common news subcategories include `binnenland`, `buitenland`, `cultuur-en-media`, `economie`, `koningshuis`, `opmerkelijk`, `politiek`, and `tech`.
|
|
111
|
+
|
|
112
|
+
### Follow pagination
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
page = client.list_items(main_categories="nieuws")
|
|
116
|
+
|
|
117
|
+
while page.links.next:
|
|
118
|
+
page = client.list_items(url=page.links.next)
|
|
119
|
+
|
|
120
|
+
for item in page.items:
|
|
121
|
+
print(item.id, item.title)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## API map
|
|
125
|
+
|
|
126
|
+
The [`docs/apis`](https://github.com/11philip22/nos-private-api/tree/main/docs/apis) directory documents the Android API research behind the client:
|
|
127
|
+
|
|
128
|
+
| Area | Notes |
|
|
129
|
+
| --- | --- |
|
|
130
|
+
| [`auth.md`](https://github.com/11philip22/nos-private-api/blob/main/docs/apis/auth.md) | Timestamp endpoint and `X-NOS` header format |
|
|
131
|
+
| [`nos-content.md`](https://github.com/11philip22/nos-private-api/blob/main/docs/apis/nos-content.md) | Items, pages, search, live, weather, soccer, and widget endpoints |
|
|
132
|
+
| [`recommendations.md`](https://github.com/11philip22/nos-private-api/blob/main/docs/apis/recommendations.md) | Content-based and collaborative recommendation endpoints |
|
|
133
|
+
| [`region-and-third-party.md`](https://github.com/11philip22/nos-private-api/blob/main/docs/apis/region-and-third-party.md) | Regional broadcaster APIs and NPO telemetry endpoints |
|
|
134
|
+
|
|
135
|
+
## Development
|
|
136
|
+
|
|
137
|
+
This package uses `setuptools` and depends on `httpx`.
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
python -m pip install -e .
|
|
141
|
+
python -c "from nos_private_api import NosClient; print(NosClient.BASE_URL)"
|
|
142
|
+
```
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# nos-private-api
|
|
4
|
+
|
|
5
|
+
*Python client and notes for the NOS Android app API*
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+

|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
[Features](#features) - [Installation](#installation) - [Usage](#usage) - [API map](#api-map)
|
|
12
|
+
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
`nos-private-api` is a small Python package for reading NOS app content through the same first-party endpoints used by the Android app. It includes a typed client for article feeds and item details, plus reverse-engineered API notes in [`docs/apis`](https://github.com/11philip22/nos-private-api/tree/main/docs/apis).
|
|
16
|
+
|
|
17
|
+
> [!IMPORTANT]
|
|
18
|
+
> This project targets an unofficial private API. Endpoints, response shapes, and header requirements can change without notice.
|
|
19
|
+
|
|
20
|
+
## Features
|
|
21
|
+
|
|
22
|
+
- Generate the app-style `X-NOS` header automatically.
|
|
23
|
+
- Fetch paginated item feeds with category, subcategory, type, and `before` filters.
|
|
24
|
+
- Fetch full article details by item ID.
|
|
25
|
+
- Parse item summaries, articles, images, videos, links, tags, and categories into dataclasses.
|
|
26
|
+
- Keep the original API payload available on `.raw` for fields that are not modeled yet.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
Install from the repository root:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
python -m pip install .
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
For local development:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
python -m pip install -e .
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
### List news articles
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from nos_private_api import NosClient
|
|
48
|
+
|
|
49
|
+
client = NosClient()
|
|
50
|
+
|
|
51
|
+
page = client.list_items(
|
|
52
|
+
main_categories="nieuws",
|
|
53
|
+
types="article",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
for item in page.items:
|
|
57
|
+
print(item.id, item.title)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Fetch an article
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
article = client.get_item(page.items[0].id)
|
|
64
|
+
|
|
65
|
+
print(article.title)
|
|
66
|
+
print(article.published_at)
|
|
67
|
+
print(article.text)
|
|
68
|
+
|
|
69
|
+
for image in article.images:
|
|
70
|
+
print(image.description, image.best_url(width=800, ratio="16:9"))
|
|
71
|
+
|
|
72
|
+
for video in article.videos:
|
|
73
|
+
print(video.title, video.best_url())
|
|
74
|
+
|
|
75
|
+
for link in article.links:
|
|
76
|
+
print(link.title, link.url)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Filter by subcategory
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
page = client.list_items(
|
|
83
|
+
main_categories="nieuws",
|
|
84
|
+
sub_categories="politiek",
|
|
85
|
+
types="article",
|
|
86
|
+
)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Common news subcategories include `binnenland`, `buitenland`, `cultuur-en-media`, `economie`, `koningshuis`, `opmerkelijk`, `politiek`, and `tech`.
|
|
90
|
+
|
|
91
|
+
### Follow pagination
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
page = client.list_items(main_categories="nieuws")
|
|
95
|
+
|
|
96
|
+
while page.links.next:
|
|
97
|
+
page = client.list_items(url=page.links.next)
|
|
98
|
+
|
|
99
|
+
for item in page.items:
|
|
100
|
+
print(item.id, item.title)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## API map
|
|
104
|
+
|
|
105
|
+
The [`docs/apis`](https://github.com/11philip22/nos-private-api/tree/main/docs/apis) directory documents the Android API research behind the client:
|
|
106
|
+
|
|
107
|
+
| Area | Notes |
|
|
108
|
+
| --- | --- |
|
|
109
|
+
| [`auth.md`](https://github.com/11philip22/nos-private-api/blob/main/docs/apis/auth.md) | Timestamp endpoint and `X-NOS` header format |
|
|
110
|
+
| [`nos-content.md`](https://github.com/11philip22/nos-private-api/blob/main/docs/apis/nos-content.md) | Items, pages, search, live, weather, soccer, and widget endpoints |
|
|
111
|
+
| [`recommendations.md`](https://github.com/11philip22/nos-private-api/blob/main/docs/apis/recommendations.md) | Content-based and collaborative recommendation endpoints |
|
|
112
|
+
| [`region-and-third-party.md`](https://github.com/11philip22/nos-private-api/blob/main/docs/apis/region-and-third-party.md) | Regional broadcaster APIs and NPO telemetry endpoints |
|
|
113
|
+
|
|
114
|
+
## Development
|
|
115
|
+
|
|
116
|
+
This package uses `setuptools` and depends on `httpx`.
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
python -m pip install -e .
|
|
120
|
+
python -c "from nos_private_api import NosClient; print(NosClient.BASE_URL)"
|
|
121
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from .client import NosClient
|
|
2
|
+
from .types import Article, Category, Image, ItemList, ItemSummary, Links, Tag, Video
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"Article",
|
|
6
|
+
"Category",
|
|
7
|
+
"Image",
|
|
8
|
+
"ItemList",
|
|
9
|
+
"ItemSummary",
|
|
10
|
+
"Links",
|
|
11
|
+
"NosClient",
|
|
12
|
+
"Tag",
|
|
13
|
+
"Video",
|
|
14
|
+
]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .types import Article, ItemList
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class NosClient:
|
|
12
|
+
BASE_URL = "https://api.nos.nl/"
|
|
13
|
+
TIMESTAMP_URL = BASE_URL + "nosapp/v4//data/timestamp"
|
|
14
|
+
SECRET = ";UB}7Gaji==JPHtjX3@c"
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
client: httpx.Client | None = None,
|
|
19
|
+
*,
|
|
20
|
+
device: str = "generic",
|
|
21
|
+
model: str = "sdk_gphone64_x86_64",
|
|
22
|
+
sdk_int: int = 35,
|
|
23
|
+
incremental: str = "123456",
|
|
24
|
+
android_release: str = "15",
|
|
25
|
+
) -> None:
|
|
26
|
+
self._client = client or httpx.Client(timeout=20, follow_redirects=True)
|
|
27
|
+
self.device = device
|
|
28
|
+
self.model = model
|
|
29
|
+
self.sdk_int = sdk_int
|
|
30
|
+
self.incremental = incremental
|
|
31
|
+
self.android_release = android_release
|
|
32
|
+
self._server_epoch: int | None = None
|
|
33
|
+
self._synced_at: float | None = None
|
|
34
|
+
|
|
35
|
+
def list_items(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
main_categories: str | Iterable[str] | None = None,
|
|
39
|
+
sub_categories: str | Iterable[str] | None = None,
|
|
40
|
+
types: str | Iterable[str] | None = ("article",),
|
|
41
|
+
before: str | None = None,
|
|
42
|
+
url: str | None = None,
|
|
43
|
+
) -> ItemList:
|
|
44
|
+
"""Return a page of items, optionally filtered or fetched by URL."""
|
|
45
|
+
params = (
|
|
46
|
+
None
|
|
47
|
+
if url
|
|
48
|
+
else self._list_items_params(main_categories, sub_categories, types, before)
|
|
49
|
+
)
|
|
50
|
+
data = self._request_json(
|
|
51
|
+
url or self.BASE_URL + "nosapp/v4/items", params=params
|
|
52
|
+
)
|
|
53
|
+
return ItemList.from_json(data)
|
|
54
|
+
|
|
55
|
+
def get_item(self, item_id: int) -> Article:
|
|
56
|
+
"""Return the full article for an item ID."""
|
|
57
|
+
return Article.from_json(
|
|
58
|
+
self._request_json(f"{self.BASE_URL}nosapp/v4/items/{item_id}")
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def _request_json(
|
|
62
|
+
self, url: str, *, params: list[tuple[str, str]] | None = None
|
|
63
|
+
) -> dict:
|
|
64
|
+
response = self._client.get(
|
|
65
|
+
url, params=params, headers={"X-NOS": self._x_nos_header()}
|
|
66
|
+
)
|
|
67
|
+
response.raise_for_status()
|
|
68
|
+
return response.json()
|
|
69
|
+
|
|
70
|
+
def _list_items_params(
|
|
71
|
+
self,
|
|
72
|
+
main_categories: str | Iterable[str] | None,
|
|
73
|
+
sub_categories: str | Iterable[str] | None,
|
|
74
|
+
types: str | Iterable[str] | None,
|
|
75
|
+
before: str | None,
|
|
76
|
+
) -> list[tuple[str, str]]:
|
|
77
|
+
params: list[tuple[str, str]] = []
|
|
78
|
+
params += self._array_params("mainCategories", main_categories)
|
|
79
|
+
params += self._array_params("subCategories", sub_categories)
|
|
80
|
+
params += self._array_params("types", types)
|
|
81
|
+
if before:
|
|
82
|
+
params.append(("before", before))
|
|
83
|
+
return params
|
|
84
|
+
|
|
85
|
+
def _array_params(
|
|
86
|
+
self, name: str, values: str | Iterable[str] | None
|
|
87
|
+
) -> list[tuple[str, str]]:
|
|
88
|
+
if values is None:
|
|
89
|
+
return []
|
|
90
|
+
if isinstance(values, str):
|
|
91
|
+
values = (values,)
|
|
92
|
+
return [(f"{name}[]", value) for value in values]
|
|
93
|
+
|
|
94
|
+
def _x_nos_header(self) -> str:
|
|
95
|
+
epoch = self._timestamp()
|
|
96
|
+
payload = (
|
|
97
|
+
f"nos;{epoch};"
|
|
98
|
+
f"{self.device}/{self.model};"
|
|
99
|
+
f"Android {self.sdk_int}/{self.incremental};"
|
|
100
|
+
f"nl.nos.app/{self.android_release}"
|
|
101
|
+
)
|
|
102
|
+
signature = hashlib.md5((self.SECRET + payload).encode()).hexdigest()
|
|
103
|
+
body = base64.b64encode(payload.encode()).decode()
|
|
104
|
+
return signature + body
|
|
105
|
+
|
|
106
|
+
def _timestamp(self) -> int:
|
|
107
|
+
if self._server_epoch is not None and self._synced_at is not None:
|
|
108
|
+
return int(self._server_epoch + (time.time() - self._synced_at))
|
|
109
|
+
try:
|
|
110
|
+
response = self._client.get(self.TIMESTAMP_URL)
|
|
111
|
+
response.raise_for_status()
|
|
112
|
+
data = response.json()
|
|
113
|
+
self._server_epoch = int(data["timestamp"])
|
|
114
|
+
self._synced_at = time.time()
|
|
115
|
+
return self._server_epoch
|
|
116
|
+
except Exception:
|
|
117
|
+
return int(time.time())
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
from collections.abc import Iterator
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from types import SimpleNamespace
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
JsonDict = dict[str, Any]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _get(data: JsonDict, *names: str, default: Any = None) -> Any:
|
|
11
|
+
for name in names:
|
|
12
|
+
if name in data:
|
|
13
|
+
return data[name]
|
|
14
|
+
return default
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _dict(value: Any) -> JsonDict:
|
|
18
|
+
return value if isinstance(value, dict) else {}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _list(value: Any) -> list[Any]:
|
|
22
|
+
return value if isinstance(value, list) else []
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _dicts(value: Any) -> list[JsonDict]:
|
|
26
|
+
return [item for item in _list(value) if isinstance(item, dict)]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _url_map(value: Any) -> dict[str, str]:
|
|
30
|
+
if isinstance(value, dict):
|
|
31
|
+
return {str(key): str(url) for key, url in value.items() if url is not None}
|
|
32
|
+
if isinstance(value, str):
|
|
33
|
+
return {"url": value}
|
|
34
|
+
return {}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _first_url(urls: dict[str, str]) -> str | None:
|
|
38
|
+
for key in ("jpg", "mp4", "url"):
|
|
39
|
+
if key in urls:
|
|
40
|
+
return urls[key]
|
|
41
|
+
return next(iter(urls.values()), None)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _best_url(formats: list[JsonDict], width: int | None) -> str | None:
|
|
45
|
+
if not formats:
|
|
46
|
+
return None
|
|
47
|
+
if width is None:
|
|
48
|
+
selected = max(formats, key=lambda item: item.get("width") or 0)
|
|
49
|
+
else:
|
|
50
|
+
wider = [
|
|
51
|
+
item for item in formats if item.get("width") and item["width"] >= width
|
|
52
|
+
]
|
|
53
|
+
selected = (
|
|
54
|
+
min(wider, key=lambda item: item.get("width") or 0)
|
|
55
|
+
if wider
|
|
56
|
+
else max(formats, key=lambda item: item.get("width") or 0)
|
|
57
|
+
)
|
|
58
|
+
return _first_url(_url_map(selected.get("url")))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(slots=True)
|
|
62
|
+
class Links:
|
|
63
|
+
first: str | None = None
|
|
64
|
+
next: str | None = None
|
|
65
|
+
prev: str | None = None
|
|
66
|
+
raw: JsonDict = field(default_factory=dict)
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_json(cls, data: JsonDict | None) -> "Links":
|
|
70
|
+
data = data or {}
|
|
71
|
+
return cls(
|
|
72
|
+
first=data.get("first"),
|
|
73
|
+
next=data.get("next"),
|
|
74
|
+
prev=data.get("prev"),
|
|
75
|
+
raw=data,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(slots=True)
|
|
80
|
+
class Tag:
|
|
81
|
+
name: str | None = None
|
|
82
|
+
label: str | None = None
|
|
83
|
+
raw: JsonDict = field(default_factory=dict)
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def from_json(cls, data: JsonDict) -> "Tag":
|
|
87
|
+
return cls(name=data.get("name"), label=data.get("label"), raw=data)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(slots=True)
|
|
91
|
+
class Category:
|
|
92
|
+
name: str | None = None
|
|
93
|
+
label: str | None = None
|
|
94
|
+
main_category: str | None = None
|
|
95
|
+
raw: JsonDict = field(default_factory=dict)
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def from_json(cls, data: JsonDict) -> "Category":
|
|
99
|
+
return cls(
|
|
100
|
+
name=data.get("name"),
|
|
101
|
+
label=data.get("label"),
|
|
102
|
+
main_category=data.get("main_category"),
|
|
103
|
+
raw=data,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(slots=True)
|
|
108
|
+
class Image:
|
|
109
|
+
copyright: str | None = None
|
|
110
|
+
copyright_url: str | None = None
|
|
111
|
+
description: str | None = None
|
|
112
|
+
display: str | None = None
|
|
113
|
+
formats: list[JsonDict] = field(default_factory=list)
|
|
114
|
+
aspect_ratios: list[JsonDict] = field(default_factory=list)
|
|
115
|
+
raw: JsonDict = field(default_factory=dict)
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def from_json(cls, data: JsonDict | None) -> "Image | None":
|
|
119
|
+
if not isinstance(data, dict):
|
|
120
|
+
return None
|
|
121
|
+
return cls(
|
|
122
|
+
copyright=data.get("copyright"),
|
|
123
|
+
copyright_url=data.get("copyright_url"),
|
|
124
|
+
description=data.get("description"),
|
|
125
|
+
display=data.get("display"),
|
|
126
|
+
formats=_dicts(data.get("formats")),
|
|
127
|
+
aspect_ratios=_dicts(data.get("aspect_ratios")),
|
|
128
|
+
raw=data,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def best_url(
|
|
132
|
+
self, *, width: int | None = None, ratio: str | None = None
|
|
133
|
+
) -> str | None:
|
|
134
|
+
formats = self.formats
|
|
135
|
+
if ratio:
|
|
136
|
+
formats = next(
|
|
137
|
+
(
|
|
138
|
+
_dicts(item.get("formats"))
|
|
139
|
+
for item in self.aspect_ratios
|
|
140
|
+
if item.get("ratio") == ratio
|
|
141
|
+
),
|
|
142
|
+
formats,
|
|
143
|
+
)
|
|
144
|
+
return _best_url(formats, width)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(slots=True)
|
|
148
|
+
class ItemSummary:
|
|
149
|
+
id: int
|
|
150
|
+
type: str | None = None
|
|
151
|
+
title: str | None = None
|
|
152
|
+
description: str | None = None
|
|
153
|
+
image: Image | None = None
|
|
154
|
+
owner: str | None = None
|
|
155
|
+
published_at: str | None = None
|
|
156
|
+
modified_at: str | None = None
|
|
157
|
+
item_at: str | None = None
|
|
158
|
+
categories: list[Category] = field(default_factory=list)
|
|
159
|
+
collections: list[JsonDict] = field(default_factory=list)
|
|
160
|
+
keywords: list[Tag] = field(default_factory=list)
|
|
161
|
+
system_tag: Tag | None = None
|
|
162
|
+
label: str | None = None
|
|
163
|
+
status: str | None = None
|
|
164
|
+
has_video: bool = False
|
|
165
|
+
has_audio: bool = False
|
|
166
|
+
has_photos: bool = False
|
|
167
|
+
raw: JsonDict = field(default_factory=dict)
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def _item_fields(data: JsonDict) -> dict[str, Any]:
|
|
171
|
+
return {
|
|
172
|
+
"id": int(data["id"]),
|
|
173
|
+
"type": data.get("type"),
|
|
174
|
+
"title": data.get("title"),
|
|
175
|
+
"description": data.get("description"),
|
|
176
|
+
"image": Image.from_json(data.get("image")),
|
|
177
|
+
"owner": data.get("owner"),
|
|
178
|
+
"published_at": _get(data, "published_at", "publishedDate"),
|
|
179
|
+
"modified_at": _get(data, "modified_at", "modifiedDate"),
|
|
180
|
+
"item_at": data.get("item_at"),
|
|
181
|
+
"categories": [
|
|
182
|
+
Category.from_json(item) for item in _dicts(data.get("categories"))
|
|
183
|
+
],
|
|
184
|
+
"collections": _dicts(data.get("collections")),
|
|
185
|
+
"keywords": [
|
|
186
|
+
Tag.from_json(item) for item in _dicts(data.get("keywords"))
|
|
187
|
+
],
|
|
188
|
+
"system_tag": (
|
|
189
|
+
Tag.from_json(data["system_tag"])
|
|
190
|
+
if isinstance(data.get("system_tag"), dict)
|
|
191
|
+
else None
|
|
192
|
+
),
|
|
193
|
+
"raw": data,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
@classmethod
|
|
197
|
+
def from_json(cls, data: JsonDict) -> "ItemSummary":
|
|
198
|
+
return cls(
|
|
199
|
+
**cls._item_fields(data),
|
|
200
|
+
label=data.get("label"),
|
|
201
|
+
status=data.get("status"),
|
|
202
|
+
has_video=bool(_get(data, "has_video", "hasVideo", default=False)),
|
|
203
|
+
has_audio=bool(_get(data, "has_audio", "hasAudio", default=False)),
|
|
204
|
+
has_photos=bool(_get(data, "has_photos", "hasPhotos", default=False)),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def published_date(self) -> str | None:
|
|
209
|
+
return self.published_at
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def modified_date(self) -> str | None:
|
|
213
|
+
return self.modified_at
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@dataclass(slots=True)
|
|
217
|
+
class ItemList:
|
|
218
|
+
items: list[ItemSummary]
|
|
219
|
+
links: Links
|
|
220
|
+
raw: JsonDict = field(default_factory=dict)
|
|
221
|
+
|
|
222
|
+
@classmethod
|
|
223
|
+
def from_json(cls, data: JsonDict) -> "ItemList":
|
|
224
|
+
return cls(
|
|
225
|
+
items=[ItemSummary.from_json(item) for item in _dicts(data.get("items"))],
|
|
226
|
+
links=Links.from_json(data.get("links")),
|
|
227
|
+
raw=data,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@dataclass(slots=True)
|
|
232
|
+
class Video:
|
|
233
|
+
id: int | None = None
|
|
234
|
+
title: str | None = None
|
|
235
|
+
description: str | None = None
|
|
236
|
+
duration: int | None = None
|
|
237
|
+
geoprotection: bool = False
|
|
238
|
+
published_at: str | None = None
|
|
239
|
+
modified_at: str | None = None
|
|
240
|
+
image: Image | None = None
|
|
241
|
+
formats: list[JsonDict] = field(default_factory=list)
|
|
242
|
+
aspect_ratios: JsonDict | None = None
|
|
243
|
+
pre_roll: str | None = None
|
|
244
|
+
pre_rolls: JsonDict | None = None
|
|
245
|
+
related: list[ItemSummary] = field(default_factory=list)
|
|
246
|
+
system_tag: Tag | None = None
|
|
247
|
+
raw: JsonDict = field(default_factory=dict)
|
|
248
|
+
|
|
249
|
+
@classmethod
|
|
250
|
+
def from_json(cls, data: JsonDict | None) -> "Video | None":
|
|
251
|
+
if not isinstance(data, dict):
|
|
252
|
+
return None
|
|
253
|
+
return cls(
|
|
254
|
+
id=data.get("id"),
|
|
255
|
+
title=data.get("title"),
|
|
256
|
+
description=data.get("description"),
|
|
257
|
+
duration=data.get("duration"),
|
|
258
|
+
geoprotection=bool(data.get("geoprotection", False)),
|
|
259
|
+
published_at=data.get("published_at"),
|
|
260
|
+
modified_at=data.get("modified_at"),
|
|
261
|
+
image=Image.from_json(data.get("image")),
|
|
262
|
+
formats=_dicts(data.get("formats")),
|
|
263
|
+
aspect_ratios=(
|
|
264
|
+
data.get("aspect_ratios")
|
|
265
|
+
if isinstance(data.get("aspect_ratios"), dict)
|
|
266
|
+
else None
|
|
267
|
+
),
|
|
268
|
+
pre_roll=data.get("pre_roll"),
|
|
269
|
+
pre_rolls=(
|
|
270
|
+
data.get("pre_rolls")
|
|
271
|
+
if isinstance(data.get("pre_rolls"), dict)
|
|
272
|
+
else None
|
|
273
|
+
),
|
|
274
|
+
related=[
|
|
275
|
+
ItemSummary.from_json(item) for item in _dicts(data.get("related"))
|
|
276
|
+
],
|
|
277
|
+
system_tag=(
|
|
278
|
+
Tag.from_json(data["system_tag"])
|
|
279
|
+
if isinstance(data.get("system_tag"), dict)
|
|
280
|
+
else None
|
|
281
|
+
),
|
|
282
|
+
raw=data,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
def best_url(self, *, width: int | None = None) -> str | None:
|
|
286
|
+
if self.formats:
|
|
287
|
+
return _best_url(self.formats, width)
|
|
288
|
+
profiles = _dicts(_dict(self.aspect_ratios).get("profiles"))
|
|
289
|
+
return profiles[0].get("url") if profiles else None
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@dataclass(slots=True)
|
|
293
|
+
class Article(ItemSummary):
|
|
294
|
+
external_id: str | None = None
|
|
295
|
+
narration: str | None = None
|
|
296
|
+
automated_recommendations: bool = False
|
|
297
|
+
content: JsonDict | None = None
|
|
298
|
+
bios: list[JsonDict] = field(default_factory=list)
|
|
299
|
+
push_topics: list[JsonDict] = field(default_factory=list)
|
|
300
|
+
banners: list[JsonDict] = field(default_factory=list)
|
|
301
|
+
|
|
302
|
+
@classmethod
|
|
303
|
+
def from_json(cls, data: JsonDict) -> "Article":
|
|
304
|
+
content = data.get("content")
|
|
305
|
+
return cls(
|
|
306
|
+
**cls._item_fields(data),
|
|
307
|
+
external_id=data.get("external_id"),
|
|
308
|
+
narration=data.get("narration"),
|
|
309
|
+
automated_recommendations=bool(
|
|
310
|
+
data.get("automated_recommendations", False)
|
|
311
|
+
),
|
|
312
|
+
content=content if isinstance(content, dict) else None,
|
|
313
|
+
bios=_dicts(data.get("bios")),
|
|
314
|
+
push_topics=_dicts(data.get("push_topics")),
|
|
315
|
+
banners=_dicts(data.get("banners")),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
@property
|
|
319
|
+
def text(self) -> str:
|
|
320
|
+
return "\n\n".join(
|
|
321
|
+
text
|
|
322
|
+
for block in _walk_content(self.content)
|
|
323
|
+
if (text := _content_text(block))
|
|
324
|
+
).strip()
|
|
325
|
+
|
|
326
|
+
@property
|
|
327
|
+
def images(self) -> list[Image]:
|
|
328
|
+
images = [self.image] if self.image else []
|
|
329
|
+
images.extend(
|
|
330
|
+
image
|
|
331
|
+
for block in _walk_content(self.content)
|
|
332
|
+
if (image := _content_image(block))
|
|
333
|
+
)
|
|
334
|
+
return images
|
|
335
|
+
|
|
336
|
+
@property
|
|
337
|
+
def videos(self) -> list[Video]:
|
|
338
|
+
return [
|
|
339
|
+
video
|
|
340
|
+
for block in _walk_content(self.content)
|
|
341
|
+
if (video := _content_video(block))
|
|
342
|
+
]
|
|
343
|
+
|
|
344
|
+
@property
|
|
345
|
+
def links(self) -> list[SimpleNamespace]:
|
|
346
|
+
return [
|
|
347
|
+
link
|
|
348
|
+
for block in _walk_content(self.content)
|
|
349
|
+
for link in _content_links(block)
|
|
350
|
+
]
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _walk_content(block: JsonDict | None) -> Iterator[JsonDict]:
|
|
354
|
+
if block is None:
|
|
355
|
+
return
|
|
356
|
+
yield block
|
|
357
|
+
for child in _dicts(block.get("children")):
|
|
358
|
+
yield from _walk_content(child)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _content_text(block: JsonDict) -> str | None:
|
|
362
|
+
block_type = block.get("type")
|
|
363
|
+
if block_type == "text":
|
|
364
|
+
return block.get("text")
|
|
365
|
+
if block_type == "title":
|
|
366
|
+
return block.get("title")
|
|
367
|
+
if block_type == "quote":
|
|
368
|
+
return _dict(block.get("quote")).get("text")
|
|
369
|
+
if block_type == "table":
|
|
370
|
+
return "\n".join(
|
|
371
|
+
" | ".join(str(cell) for cell in row)
|
|
372
|
+
for row in _list(block.get("data"))
|
|
373
|
+
if isinstance(row, list)
|
|
374
|
+
)
|
|
375
|
+
return None
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _content_image(block: JsonDict) -> Image | None:
|
|
379
|
+
block_type = block.get("type")
|
|
380
|
+
if block_type == "image":
|
|
381
|
+
return Image.from_json(block.get("image"))
|
|
382
|
+
if block_type == "video":
|
|
383
|
+
return Image.from_json(_dict(block.get("video")).get("image"))
|
|
384
|
+
if block_type == "external_content":
|
|
385
|
+
external = _dict(block.get("external_content")) or block
|
|
386
|
+
return Image.from_json(external.get("image"))
|
|
387
|
+
return None
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _content_video(block: JsonDict) -> Video | None:
|
|
391
|
+
return Video.from_json(block.get("video")) if block.get("type") == "video" else None
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _content_links(block: JsonDict) -> list[SimpleNamespace]:
|
|
395
|
+
if block.get("type") != "link_container":
|
|
396
|
+
return []
|
|
397
|
+
return [_link_view(item) for item in _dicts(block.get("children"))]
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _link_view(data: JsonDict) -> SimpleNamespace:
|
|
401
|
+
link_type = data.get("type")
|
|
402
|
+
if link_type == "internal_link":
|
|
403
|
+
link = _dict(data.get("internal_link"))
|
|
404
|
+
elif link_type == "external_link":
|
|
405
|
+
link = _dict(data.get("external_link")) or _dict(data.get("link")) or data
|
|
406
|
+
else:
|
|
407
|
+
link = data
|
|
408
|
+
item = link.get("item")
|
|
409
|
+
return SimpleNamespace(
|
|
410
|
+
title=link.get("title"),
|
|
411
|
+
url=link.get("url"),
|
|
412
|
+
item=ItemSummary.from_json(item) if isinstance(item, dict) else None,
|
|
413
|
+
type=link_type,
|
|
414
|
+
raw=data,
|
|
415
|
+
)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nos-private-api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the NOS Android app API
|
|
5
|
+
Author: 11philip22
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/11philip22/nos-private-api
|
|
8
|
+
Project-URL: Repository, https://github.com/11philip22/nos-private-api
|
|
9
|
+
Project-URL: Issues, https://github.com/11philip22/nos-private-api/issues
|
|
10
|
+
Keywords: nos,news,api,client
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: httpx
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
<div align="center">
|
|
23
|
+
|
|
24
|
+
# nos-private-api
|
|
25
|
+
|
|
26
|
+
*Python client and notes for the NOS Android app API*
|
|
27
|
+
|
|
28
|
+

|
|
29
|
+

|
|
30
|
+

|
|
31
|
+
|
|
32
|
+
[Features](#features) - [Installation](#installation) - [Usage](#usage) - [API map](#api-map)
|
|
33
|
+
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
`nos-private-api` is a small Python package for reading NOS app content through the same first-party endpoints used by the Android app. It includes a typed client for article feeds and item details, plus reverse-engineered API notes in [`docs/apis`](https://github.com/11philip22/nos-private-api/tree/main/docs/apis).
|
|
37
|
+
|
|
38
|
+
> [!IMPORTANT]
|
|
39
|
+
> This project targets an unofficial private API. Endpoints, response shapes, and header requirements can change without notice.
|
|
40
|
+
|
|
41
|
+
## Features
|
|
42
|
+
|
|
43
|
+
- Generate the app-style `X-NOS` header automatically.
|
|
44
|
+
- Fetch paginated item feeds with category, subcategory, type, and `before` filters.
|
|
45
|
+
- Fetch full article details by item ID.
|
|
46
|
+
- Parse item summaries, articles, images, videos, links, tags, and categories into dataclasses.
|
|
47
|
+
- Keep the original API payload available on `.raw` for fields that are not modeled yet.
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
Install from the repository root:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
python -m pip install .
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
For local development:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
python -m pip install -e .
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Usage
|
|
64
|
+
|
|
65
|
+
### List news articles
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from nos_private_api import NosClient
|
|
69
|
+
|
|
70
|
+
client = NosClient()
|
|
71
|
+
|
|
72
|
+
page = client.list_items(
|
|
73
|
+
main_categories="nieuws",
|
|
74
|
+
types="article",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
for item in page.items:
|
|
78
|
+
print(item.id, item.title)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Fetch an article
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
article = client.get_item(page.items[0].id)
|
|
85
|
+
|
|
86
|
+
print(article.title)
|
|
87
|
+
print(article.published_at)
|
|
88
|
+
print(article.text)
|
|
89
|
+
|
|
90
|
+
for image in article.images:
|
|
91
|
+
print(image.description, image.best_url(width=800, ratio="16:9"))
|
|
92
|
+
|
|
93
|
+
for video in article.videos:
|
|
94
|
+
print(video.title, video.best_url())
|
|
95
|
+
|
|
96
|
+
for link in article.links:
|
|
97
|
+
print(link.title, link.url)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Filter by subcategory
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
page = client.list_items(
|
|
104
|
+
main_categories="nieuws",
|
|
105
|
+
sub_categories="politiek",
|
|
106
|
+
types="article",
|
|
107
|
+
)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Common news subcategories include `binnenland`, `buitenland`, `cultuur-en-media`, `economie`, `koningshuis`, `opmerkelijk`, `politiek`, and `tech`.
|
|
111
|
+
|
|
112
|
+
### Follow pagination
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
page = client.list_items(main_categories="nieuws")
|
|
116
|
+
|
|
117
|
+
while page.links.next:
|
|
118
|
+
page = client.list_items(url=page.links.next)
|
|
119
|
+
|
|
120
|
+
for item in page.items:
|
|
121
|
+
print(item.id, item.title)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## API map
|
|
125
|
+
|
|
126
|
+
The [`docs/apis`](https://github.com/11philip22/nos-private-api/tree/main/docs/apis) directory documents the Android API research behind the client:
|
|
127
|
+
|
|
128
|
+
| Area | Notes |
|
|
129
|
+
| --- | --- |
|
|
130
|
+
| [`auth.md`](https://github.com/11philip22/nos-private-api/blob/main/docs/apis/auth.md) | Timestamp endpoint and `X-NOS` header format |
|
|
131
|
+
| [`nos-content.md`](https://github.com/11philip22/nos-private-api/blob/main/docs/apis/nos-content.md) | Items, pages, search, live, weather, soccer, and widget endpoints |
|
|
132
|
+
| [`recommendations.md`](https://github.com/11philip22/nos-private-api/blob/main/docs/apis/recommendations.md) | Content-based and collaborative recommendation endpoints |
|
|
133
|
+
| [`region-and-third-party.md`](https://github.com/11philip22/nos-private-api/blob/main/docs/apis/region-and-third-party.md) | Regional broadcaster APIs and NPO telemetry endpoints |
|
|
134
|
+
|
|
135
|
+
## Development
|
|
136
|
+
|
|
137
|
+
This package uses `setuptools` and depends on `httpx`.
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
python -m pip install -e .
|
|
141
|
+
python -c "from nos_private_api import NosClient; print(NosClient.BASE_URL)"
|
|
142
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
nos_private_api/__init__.py
|
|
5
|
+
nos_private_api/client.py
|
|
6
|
+
nos_private_api/types.py
|
|
7
|
+
nos_private_api.egg-info/PKG-INFO
|
|
8
|
+
nos_private_api.egg-info/SOURCES.txt
|
|
9
|
+
nos_private_api.egg-info/dependency_links.txt
|
|
10
|
+
nos_private_api.egg-info/requires.txt
|
|
11
|
+
nos_private_api.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
httpx
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nos_private_api
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nos-private-api"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for the NOS Android app API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "11philip22" },
|
|
15
|
+
]
|
|
16
|
+
keywords = ["nos", "news", "api", "client"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
23
|
+
]
|
|
24
|
+
dependencies = ["httpx"]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/11philip22/nos-private-api"
|
|
28
|
+
Repository = "https://github.com/11philip22/nos-private-api"
|
|
29
|
+
Issues = "https://github.com/11philip22/nos-private-api/issues"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools]
|
|
32
|
+
packages = ["nos_private_api"]
|