rtings-mcp 0.2.1__py3-none-any.whl
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.
- rtings_mcp/__init__.py +14 -0
- rtings_mcp/api.py +242 -0
- rtings_mcp/auth.py +737 -0
- rtings_mcp/auth_tools.py +526 -0
- rtings_mcp/browser_auth.py +397 -0
- rtings_mcp/cache.py +602 -0
- rtings_mcp/cli.py +343 -0
- rtings_mcp/config.py +222 -0
- rtings_mcp/context.py +58 -0
- rtings_mcp/envelope.py +220 -0
- rtings_mcp/errors.py +88 -0
- rtings_mcp/fsutil.py +131 -0
- rtings_mcp/htmlprobe.py +390 -0
- rtings_mcp/http.py +605 -0
- rtings_mcp/models.py +544 -0
- rtings_mcp/normalize.py +546 -0
- rtings_mcp/observations.py +276 -0
- rtings_mcp/ratelimit.py +197 -0
- rtings_mcp/repository.py +2281 -0
- rtings_mcp/schema.py +473 -0
- rtings_mcp/server.py +547 -0
- rtings_mcp/services.py +3200 -0
- rtings_mcp-0.2.1.dist-info/METADATA +240 -0
- rtings_mcp-0.2.1.dist-info/RECORD +27 -0
- rtings_mcp-0.2.1.dist-info/WHEEL +4 -0
- rtings_mcp-0.2.1.dist-info/entry_points.txt +2 -0
- rtings_mcp-0.2.1.dist-info/licenses/LICENSE +21 -0
rtings_mcp/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""rtings-mcp — RTINGS.com test data as structured MCP tools.
|
|
2
|
+
|
|
3
|
+
The one thing to know before reading anything else: RTINGS gates some measurements
|
|
4
|
+
server-side, **per category**, and this server's whole job is to be structurally incapable of
|
|
5
|
+
lying about that. A gated value comes back ``null`` with ``status: "tested_gated"`` — which
|
|
6
|
+
is a different fact from ``not_tested``, ``not_applicable``, ``review_unpublished`` and
|
|
7
|
+
``coverage_unknown``, and the code never collapses two of them into one null.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
__version__ = "0.2.1"
|
|
13
|
+
|
|
14
|
+
__all__ = ["__version__"]
|
rtings_mcp/api.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Typed wrappers for the ``/api/v2/safe/`` queries (RECON §1).
|
|
2
|
+
|
|
3
|
+
**Request shapes are per-query — do not generalize by prefix.** ``table_tool__*`` take
|
|
4
|
+
``{"variables": {...}}``; ``app/product_vue_page__page_body`` takes
|
|
5
|
+
``{"variables": {...}, share_token, url_path}``; ``app/search__search_results`` takes a
|
|
6
|
+
**bare** ``{count, is_admin, query, type}`` with no ``variables`` wrapper.
|
|
7
|
+
|
|
8
|
+
Two request-body rules:
|
|
9
|
+
|
|
10
|
+
* ``named_version`` is always ``"public"``. ``is_admin`` is always ``false`` — that is what
|
|
11
|
+
the logged-out front end sends, and omitting the key would produce a body the real client
|
|
12
|
+
never sends. Never a privileged *value* (``is_admin:true``, ``named_version:"admin"``).
|
|
13
|
+
* ``force_blur:false`` and ``unblur_product_ids:[]`` are sent exactly as the front end sends
|
|
14
|
+
them. Populating ``unblur_product_ids`` beyond that is a client unblur hint, i.e.
|
|
15
|
+
circumvention — and it was measured to be ignored anonymously anyway.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from . import errors
|
|
24
|
+
from .config import BASE_URL
|
|
25
|
+
from .errors import RtingsError
|
|
26
|
+
from .http import Transport
|
|
27
|
+
|
|
28
|
+
NAMED_VERSION = "public"
|
|
29
|
+
|
|
30
|
+
Q_COLUMN_OPTIONS = "table_tool__column_options"
|
|
31
|
+
Q_PRODUCTS_LIST = "table_tool__products_list"
|
|
32
|
+
Q_TEST_RESULTS = "table_tool__test_results"
|
|
33
|
+
Q_RATINGS = "table_tool__ratings"
|
|
34
|
+
Q_GRAPH_URL = "graph_tool__product_graph_data_url"
|
|
35
|
+
Q_SEARCH = "app/search__search_results"
|
|
36
|
+
Q_PAGE_BODY = "app/product_vue_page__page_body"
|
|
37
|
+
Q_SIDE_BY_SIDE = "app/side_by_side__review"
|
|
38
|
+
|
|
39
|
+
#: ``test_results`` scales with rows, so the request side caps the test count to keep the
|
|
40
|
+
#: response inside the session's size ceiling.
|
|
41
|
+
MAX_TESTS_PER_REQUEST = 60
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _dig(payload: Any, *path: str, query: str) -> Any:
|
|
45
|
+
"""Walk ``data.<key>...``, raising the drift alarm rather than degrading to empty."""
|
|
46
|
+
node = payload
|
|
47
|
+
for key in ("data", *path):
|
|
48
|
+
if not isinstance(node, dict) or key not in node:
|
|
49
|
+
raise RtingsError(
|
|
50
|
+
errors.PAYLOAD_MISSING,
|
|
51
|
+
f"{query}: expected data.{'.'.join(path)} in the response",
|
|
52
|
+
)
|
|
53
|
+
node = node[key]
|
|
54
|
+
return node
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def column_options(transport: Transport, silo: str) -> dict[str, Any]:
|
|
58
|
+
"""The silo schema — the one fixed ~357 KB payload, fetched once per silo."""
|
|
59
|
+
payload = await transport.api_post(
|
|
60
|
+
Q_COLUMN_OPTIONS,
|
|
61
|
+
{"variables": {"silo_url_part": silo, "named_version": NAMED_VERSION}},
|
|
62
|
+
referer=f"{BASE_URL}/{silo}/tools/table",
|
|
63
|
+
)
|
|
64
|
+
return _dig(payload, "silo", query=Q_COLUMN_OPTIONS)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def products_list(
|
|
68
|
+
transport: Transport, silo: str, bench_ids: Sequence[str]
|
|
69
|
+
) -> list[dict[str, Any]]:
|
|
70
|
+
"""The catalog for a bench set. Carries ``published`` — the second, orthogonal blur."""
|
|
71
|
+
payload = await transport.api_post(
|
|
72
|
+
Q_PRODUCTS_LIST,
|
|
73
|
+
{
|
|
74
|
+
"variables": {
|
|
75
|
+
"test_bench_ids": [str(b) for b in bench_ids],
|
|
76
|
+
"named_version": NAMED_VERSION,
|
|
77
|
+
"is_admin": False,
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
referer=f"{BASE_URL}/{silo}/tools/table",
|
|
81
|
+
)
|
|
82
|
+
products = _dig(payload, "products", query=Q_PRODUCTS_LIST)
|
|
83
|
+
if not isinstance(products, list):
|
|
84
|
+
raise RtingsError(errors.PAYLOAD_MISSING, f"{Q_PRODUCTS_LIST}: products is not a list")
|
|
85
|
+
return products
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def test_results(
|
|
89
|
+
transport: Transport,
|
|
90
|
+
silo: str,
|
|
91
|
+
bench_ids: Sequence[str],
|
|
92
|
+
original_ids: Sequence[str],
|
|
93
|
+
) -> list[dict[str, Any]]:
|
|
94
|
+
"""Scalar measurements for ``(benches x tests)``, **all products** — there is no product
|
|
95
|
+
filter, which is precisely why the response is the coverage record."""
|
|
96
|
+
payload = await transport.api_post(
|
|
97
|
+
Q_TEST_RESULTS,
|
|
98
|
+
{
|
|
99
|
+
"variables": {
|
|
100
|
+
"test_bench_ids": [str(b) for b in bench_ids],
|
|
101
|
+
"original_ids": [str(t) for t in original_ids],
|
|
102
|
+
"named_version": NAMED_VERSION,
|
|
103
|
+
"is_admin": False,
|
|
104
|
+
"force_blur": False,
|
|
105
|
+
"unblur_product_ids": [],
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
referer=f"{BASE_URL}/{silo}/tools/table",
|
|
109
|
+
)
|
|
110
|
+
rows = _dig(payload, "test_results", query=Q_TEST_RESULTS)
|
|
111
|
+
if not isinstance(rows, list):
|
|
112
|
+
raise RtingsError(errors.PAYLOAD_MISSING, f"{Q_TEST_RESULTS}: test_results is not a list")
|
|
113
|
+
return rows
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def ratings(
|
|
117
|
+
transport: Transport,
|
|
118
|
+
silo: str,
|
|
119
|
+
bench_ids: Sequence[str],
|
|
120
|
+
original_ids: Sequence[str],
|
|
121
|
+
) -> list[dict[str, Any]]:
|
|
122
|
+
"""0-10 usage scores. Usage definitions carry no ``insider_only``, so availability here
|
|
123
|
+
is derived from observed ``unblurred`` only."""
|
|
124
|
+
payload = await transport.api_post(
|
|
125
|
+
Q_RATINGS,
|
|
126
|
+
{
|
|
127
|
+
"variables": {
|
|
128
|
+
"test_bench_ids": [str(b) for b in bench_ids],
|
|
129
|
+
"original_ids": [str(u) for u in original_ids],
|
|
130
|
+
"named_version": NAMED_VERSION,
|
|
131
|
+
"is_admin": False,
|
|
132
|
+
"force_blur": False,
|
|
133
|
+
"unblur_product_ids": [],
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
referer=f"{BASE_URL}/{silo}/tools/table",
|
|
137
|
+
)
|
|
138
|
+
rows = _dig(payload, "ratings", query=Q_RATINGS)
|
|
139
|
+
if not isinstance(rows, list):
|
|
140
|
+
raise RtingsError(errors.PAYLOAD_MISSING, f"{Q_RATINGS}: ratings is not a list")
|
|
141
|
+
return rows
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def graph_data_url(
|
|
145
|
+
transport: Transport, silo: str, product_id: str, test_original_id: str
|
|
146
|
+
) -> str | None:
|
|
147
|
+
"""The CDN path for one ``(product, test)`` curve, or ``None`` when this product has
|
|
148
|
+
none — ``graph_not_available``, which is the common case, not the exception."""
|
|
149
|
+
payload = await transport.api_post(
|
|
150
|
+
Q_GRAPH_URL,
|
|
151
|
+
{
|
|
152
|
+
"variables": {
|
|
153
|
+
"named_version": NAMED_VERSION,
|
|
154
|
+
"product_id": str(product_id),
|
|
155
|
+
"test_original_id": str(test_original_id),
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
referer=f"{BASE_URL}/{silo}/graph",
|
|
159
|
+
)
|
|
160
|
+
# A genuine "no curve" is a well-formed, explicitly EMPTY list — measured 2026-09-04 on
|
|
161
|
+
# X90L test 11: `{"data":{"product":{"review":{"test_results":[]}}}}`. So `_dig` raising
|
|
162
|
+
# here means the SHAPE changed, not that the product has no curve, and the two must not
|
|
163
|
+
# be collapsed: `repository.graph` writes a `graph_not_available` file for the negative,
|
|
164
|
+
# so swallowing drift cached "RTINGS publishes no curve for this test" for three days
|
|
165
|
+
# off a transient blip. Let the drift alarm through — it is never cached.
|
|
166
|
+
results = _dig(payload, "product", "review", "test_results", query=Q_GRAPH_URL)
|
|
167
|
+
if not isinstance(results, list) or not results:
|
|
168
|
+
return None
|
|
169
|
+
url = results[0].get("graph_data_url") if isinstance(results[0], dict) else None
|
|
170
|
+
return str(url) if url else None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
async def search(transport: Transport, query: str, count: int = 10) -> dict[str, Any]:
|
|
174
|
+
"""Cross-silo live search. Bare body, **no** ``variables`` wrapper."""
|
|
175
|
+
payload = await transport.api_post(
|
|
176
|
+
Q_SEARCH,
|
|
177
|
+
{"count": int(count), "is_admin": False, "query": query, "type": "full"},
|
|
178
|
+
referer=f"{BASE_URL}/",
|
|
179
|
+
)
|
|
180
|
+
result = _dig(payload, "search_results", query=Q_SEARCH)
|
|
181
|
+
if not isinstance(result, dict):
|
|
182
|
+
raise RtingsError(errors.PAYLOAD_MISSING, f"{Q_SEARCH}: search_results is not an object")
|
|
183
|
+
return result
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
async def side_by_side_review(
|
|
187
|
+
transport: Transport, silo: str, product_id: str
|
|
188
|
+
) -> dict[str, Any]:
|
|
189
|
+
"""The compare tool's view of one review — RTINGS' *words*, where the table path has
|
|
190
|
+
only numbers (RECON §12.16).
|
|
191
|
+
|
|
192
|
+
Bare ``{product_id}``, no ``variables`` wrapper. What it adds, anonymously, even on a
|
|
193
|
+
category that withholds every measurement:
|
|
194
|
+
|
|
195
|
+
* ``product_score_sets`` — the per-usage verdict prose ("good for mixed usage because…");
|
|
196
|
+
* ``summaries`` — the pros/cons blurbs, with ``priority`` separating them;
|
|
197
|
+
* ``score_sets`` — how each usage score is composed, with weights.
|
|
198
|
+
|
|
199
|
+
**Its ``test_results`` are deliberately ignored.** They are a *third* row shape carrying
|
|
200
|
+
no ``unblurred`` key at all, so the seven-state normalizer has nothing to branch on; the
|
|
201
|
+
table and review paths already answer that question properly.
|
|
202
|
+
|
|
203
|
+
**Metering is unverified.** This is the public compare tool rather than the metered
|
|
204
|
+
review page, and a tool that spent a preview per comparison would be unusable — but that
|
|
205
|
+
is reasoning, not measurement, so Phase 0 confirms it.
|
|
206
|
+
"""
|
|
207
|
+
payload = await transport.api_post(
|
|
208
|
+
Q_SIDE_BY_SIDE,
|
|
209
|
+
{"product_id": str(product_id)},
|
|
210
|
+
referer=f"{BASE_URL}/{silo}/tools/compare",
|
|
211
|
+
)
|
|
212
|
+
review = _dig(payload, "review", query=Q_SIDE_BY_SIDE)
|
|
213
|
+
if not isinstance(review, dict):
|
|
214
|
+
raise RtingsError(errors.PAYLOAD_MISSING, f"{Q_SIDE_BY_SIDE}: review is not an object")
|
|
215
|
+
return review
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
async def page_body(transport: Transport, url_path: str) -> dict[str, Any]:
|
|
219
|
+
"""One full review (~442 KB).
|
|
220
|
+
|
|
221
|
+
**This is the metered endpoint.** Every guard around spending a free account's previews
|
|
222
|
+
lives in the calling tool; this function performs the POST and nothing else. The session
|
|
223
|
+
runs ``max_retries=0``, so one call is one request — the default of 3 would have been
|
|
224
|
+
three consumed previews.
|
|
225
|
+
"""
|
|
226
|
+
path = url_path if url_path.startswith("/") else f"/{url_path}"
|
|
227
|
+
payload = await transport.api_post(
|
|
228
|
+
Q_PAGE_BODY,
|
|
229
|
+
{
|
|
230
|
+
"variables": {"url": path, "named_version": NAMED_VERSION, "version_id": None},
|
|
231
|
+
"share_token": None,
|
|
232
|
+
"url_path": path,
|
|
233
|
+
},
|
|
234
|
+
referer=f"{BASE_URL}{path}",
|
|
235
|
+
)
|
|
236
|
+
# Verified 2026-09-03: the body lands at ``data.page``, and the review itself at
|
|
237
|
+
# ``data.page.product.review`` (402 rows on TV bench 227, every one carrying
|
|
238
|
+
# ``rendered_value`` + ``score`` and **no** ``value`` key).
|
|
239
|
+
page = _dig(payload, "page", query=Q_PAGE_BODY)
|
|
240
|
+
if not isinstance(page, dict):
|
|
241
|
+
raise RtingsError(errors.PAYLOAD_MISSING, f"{Q_PAGE_BODY}: page is not an object")
|
|
242
|
+
return page
|