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.
@@ -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
+ ![Python](https://img.shields.io/badge/Python->=3.10-3776ab?style=flat-square&logo=python&logoColor=white)
29
+ ![Status](https://img.shields.io/badge/status-alpha-orange?style=flat-square)
30
+ ![HTTPX](https://img.shields.io/badge/httpx-client-1f6feb?style=flat-square)
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
+ ![Python](https://img.shields.io/badge/Python->=3.10-3776ab?style=flat-square&logo=python&logoColor=white)
8
+ ![Status](https://img.shields.io/badge/status-alpha-orange?style=flat-square)
9
+ ![HTTPX](https://img.shields.io/badge/httpx-client-1f6feb?style=flat-square)
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
+ ![Python](https://img.shields.io/badge/Python->=3.10-3776ab?style=flat-square&logo=python&logoColor=white)
29
+ ![Status](https://img.shields.io/badge/status-alpha-orange?style=flat-square)
30
+ ![HTTPX](https://img.shields.io/badge/httpx-client-1f6feb?style=flat-square)
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
+ 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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+