check-proxy-tax 1.0.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.
- check_proxy_tax-1.0.0/LICENSE +21 -0
- check_proxy_tax-1.0.0/PKG-INFO +298 -0
- check_proxy_tax-1.0.0/README.md +267 -0
- check_proxy_tax-1.0.0/check_proxy_tax/__init__.py +5 -0
- check_proxy_tax-1.0.0/check_proxy_tax/cli.py +519 -0
- check_proxy_tax-1.0.0/pyproject.toml +53 -0
- check_proxy_tax-1.0.0/setup.cfg +3 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kais / 600me
|
|
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,298 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: check-proxy-tax
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Zero-dependency CLI that audits real proxy and anti-bot cost: usable rate, p50/p95 latency, bytes billed and effective price per 1,000 usable requests.
|
|
5
|
+
Home-page: https://github.com/600me/check-proxy-tax
|
|
6
|
+
Author: Kais / 600me
|
|
7
|
+
License: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/600me/check-proxy-tax
|
|
9
|
+
Project-URL: Repository, https://github.com/600me/check-proxy-tax
|
|
10
|
+
Project-URL: Issues, https://github.com/600me/check-proxy-tax/issues
|
|
11
|
+
Project-URL: Benchmark, https://scrape.mybskm.top/?utm_source=cli&utm_content=check-proxy-tax
|
|
12
|
+
Keywords: proxy,scraping,benchmark,http,cost,cli
|
|
13
|
+
Requires-Python: >=3.8
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
16
|
+
Classifier: Environment :: Console
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
19
|
+
Classifier: Operating System :: OS Independent
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
26
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
27
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
28
|
+
Classifier: Topic :: System :: Networking
|
|
29
|
+
Classifier: Topic :: Utilities
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
|
|
32
|
+
# check-proxy-tax
|
|
33
|
+
|
|
34
|
+
**Find out what a "cheap" proxy actually costs per 1,000 usable requests.**
|
|
35
|
+
|
|
36
|
+
[](https://www.python.org/)
|
|
37
|
+
[](#install)
|
|
38
|
+
[](LICENSE)
|
|
39
|
+
|
|
40
|
+
Proxy invoices are priced per request, per IP, or per GB. Your scraper is not
|
|
41
|
+
paid in those units. It is paid in responses it can actually use. When an edge
|
|
42
|
+
returns `403`, a request stalls until timeout, or a `200` arrives without the
|
|
43
|
+
product marker, the attempt is still billed. The gap between the advertised
|
|
44
|
+
price and the price of usable traffic is **The 403 Tax**.
|
|
45
|
+
|
|
46
|
+
`check-proxy-tax` measures that gap directly. It fires a small concurrent burst
|
|
47
|
+
at one URL, classifies every response as usable or blocked, and reports:
|
|
48
|
+
|
|
49
|
+
- usable rate and blocked/failed counts;
|
|
50
|
+
- effective cost per 1,000 usable requests;
|
|
51
|
+
- the tax multiplier over the nominal per-1,000 price;
|
|
52
|
+
- p50 / p95 latency and total bytes transferred;
|
|
53
|
+
- status-code and failure-reason breakdown.
|
|
54
|
+
|
|
55
|
+
It is a single core module using only the standard library. No account, no API
|
|
56
|
+
key, no external dependencies, no background service.
|
|
57
|
+
|
|
58
|
+
## Install
|
|
59
|
+
|
|
60
|
+
Python 3.8 or newer.
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
git clone https://github.com/600me/check-proxy-tax.git
|
|
64
|
+
cd check-proxy-tax
|
|
65
|
+
python3 -m pip install .
|
|
66
|
+
check-proxy-tax --help
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
You can also run it straight from a checkout without installing:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
python3 -m check_proxy_tax.cli --url https://example.com --requests 5
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The package is PyPI-ready (`python3 -m build` produces a wheel and sdist), but
|
|
76
|
+
installing from source is the supported path until the first PyPI release.
|
|
77
|
+
|
|
78
|
+
## Quick start
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
check-proxy-tax \
|
|
82
|
+
--url "https://example.com/product/123" \
|
|
83
|
+
--requests 20 \
|
|
84
|
+
--marker "Add to cart" \
|
|
85
|
+
--price-per-1k 0.49
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
```text
|
|
89
|
+
check-proxy-tax 0.1.0 - proxy economics audit
|
|
90
|
+
|
|
91
|
+
+---------------------+------------------------------------------+
|
|
92
|
+
| METRIC | VALUE |
|
|
93
|
+
+---------------------+------------------------------------------+
|
|
94
|
+
| Target URL | https://example.com/product/123 |
|
|
95
|
+
| Samples | 20 (concurrency 4, timeout 10s) |
|
|
96
|
+
| Marker | Add to cart |
|
|
97
|
+
| Usable success | 14 / 20 |
|
|
98
|
+
| Blocked / failed | 6 / 20 |
|
|
99
|
+
| Usable rate | 70.0% |
|
|
100
|
+
+---------------------+------------------------------------------+
|
|
101
|
+
| Nominal price / 1k | $0.4900 |
|
|
102
|
+
| Effective cost / 1k | $0.7000 |
|
|
103
|
+
| Tax multiplier | 1.43x |
|
|
104
|
+
+---------------------+------------------------------------------+
|
|
105
|
+
| Latency p50 | 212.4 ms |
|
|
106
|
+
| Latency p95 | 1840.9 ms |
|
|
107
|
+
| Total transferred | 1.18 MB (1,234,567 bytes) |
|
|
108
|
+
| Status codes | 200:14, 403:5, timeout:1 |
|
|
109
|
+
| Blocked reasons | http_403:5, timeout:1 |
|
|
110
|
+
| Verdict | DEGRADED - 6 of 20 requests blocked |
|
|
111
|
+
+---------------------+------------------------------------------+
|
|
112
|
+
|
|
113
|
+
Benchmark & provider matrix: https://scrape.mybskm.top/?utm_source=cli&utm_content=check-proxy-tax
|
|
114
|
+
FTC Disclosure: Benchmark links may include affiliate tracking.
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## What counts as usable
|
|
118
|
+
|
|
119
|
+
A sample is **usable** only when both conditions hold:
|
|
120
|
+
|
|
121
|
+
1. the final HTTP status is exactly `200`;
|
|
122
|
+
2. when `--marker` is supplied, the response body contains that string
|
|
123
|
+
(case-sensitive, matched against the raw UTF-8 bytes).
|
|
124
|
+
|
|
125
|
+
Everything else is **blocked**: `403`, `429`, other `4xx`, `5xx`, timeout,
|
|
126
|
+
connection/TLS failure, or a `200` without the marker. Redirects are followed
|
|
127
|
+
and the final status is classified.
|
|
128
|
+
|
|
129
|
+
That definition is deliberately strict. A `200` challenge page, a consent
|
|
130
|
+
screen, or an empty shell is not inventory your scraper can use.
|
|
131
|
+
|
|
132
|
+
## The math
|
|
133
|
+
|
|
134
|
+
```text
|
|
135
|
+
usable_pct = usable_count / total_requests * 100
|
|
136
|
+
effective_cost_per_1k = nominal_price_per_1k / (usable_pct / 100)
|
|
137
|
+
tax_multiplier = effective_cost_per_1k / nominal_price_per_1k
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
If `usable_count` is `0`, the effective cost is **Infinite**: no amount of
|
|
141
|
+
nominal budget buys a usable response at that sample rate. The JSON output
|
|
142
|
+
sets `effective_cost_per_1k` and `tax_multiplier` to `null` and
|
|
143
|
+
`cost_status` to `"infinite"`, which keeps the document valid JSON.
|
|
144
|
+
|
|
145
|
+
### Worked example
|
|
146
|
+
|
|
147
|
+
You pay **$0.49 per 1,000 requests**. You sample 20 requests and 14 come back
|
|
148
|
+
usable with the expected marker.
|
|
149
|
+
|
|
150
|
+
```text
|
|
151
|
+
usable_pct = 14 / 20 * 100 = 70.0%
|
|
152
|
+
effective_cost_per_1k = 0.49 / 0.70 = $0.70
|
|
153
|
+
tax_multiplier = 0.70 / 0.49 = 1.43x
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
At 1,000,000 requests per month the invoice is `$490`. Because only 70% of
|
|
157
|
+
attempts are usable, you need about 1,428,571 attempts to obtain 1,000,000
|
|
158
|
+
usable responses, which costs about `$700`. **The 403 Tax is $210 per month**
|
|
159
|
+
on that volume.
|
|
160
|
+
|
|
161
|
+
The model assumes blocked attempts are billed, which is how per-request and
|
|
162
|
+
per-IP plans normally work. If your provider bills only successful requests,
|
|
163
|
+
set `--price-per-1k` to the effective rate you want to test and read the
|
|
164
|
+
multiplier as a quality signal rather than a surcharge.
|
|
165
|
+
|
|
166
|
+
## Options
|
|
167
|
+
|
|
168
|
+
| Option | Default | Description |
|
|
169
|
+
| --- | --- | --- |
|
|
170
|
+
| `--url URL` | required | Absolute `http(s)` URL to probe. |
|
|
171
|
+
| `--requests N` | `20` | Number of samples. |
|
|
172
|
+
| `--marker STRING` | none | String that must appear in the body for a sample to count as usable. |
|
|
173
|
+
| `--price-per-1k FLOAT` | `0.49` | Nominal price per 1,000 requests, in USD. |
|
|
174
|
+
| `--proxy URL` | none | HTTP(S) proxy, e.g. `http://user:pass@host:port`. |
|
|
175
|
+
| `--concurrency N` | `4` | Maximum in-flight requests. |
|
|
176
|
+
| `--timeout SECONDS` | `10` | Per-request timeout. |
|
|
177
|
+
| `--json` | off | Emit one JSON object and nothing else. |
|
|
178
|
+
| `--version` | | Print the version and exit. |
|
|
179
|
+
| `--help` | | Show help, including the FTC disclosure. |
|
|
180
|
+
|
|
181
|
+
## Exit codes
|
|
182
|
+
|
|
183
|
+
| Code | Meaning |
|
|
184
|
+
| --- | --- |
|
|
185
|
+
| `0` | Every sampled request was usable. |
|
|
186
|
+
| `1` | Degraded: at least one request was blocked, at least one succeeded. |
|
|
187
|
+
| `2` | Failed: no request was usable. |
|
|
188
|
+
| `3` | Invalid arguments or URL. |
|
|
189
|
+
|
|
190
|
+
This makes the CLI useful as a gate without parsing text: `0` is the only
|
|
191
|
+
fully healthy result. A CI job that must not fail on a degraded sample can
|
|
192
|
+
capture the code and inspect the JSON instead.
|
|
193
|
+
|
|
194
|
+
## JSON output
|
|
195
|
+
|
|
196
|
+
`--json` writes a single JSON object to stdout, suitable for `jq`, dashboards,
|
|
197
|
+
or a CI artifact. Diagnostics never share that stream.
|
|
198
|
+
|
|
199
|
+
```json
|
|
200
|
+
{
|
|
201
|
+
"tool": "check-proxy-tax",
|
|
202
|
+
"version": "0.1.0",
|
|
203
|
+
"url": "https://example.com/product/123",
|
|
204
|
+
"marker": "Add to cart",
|
|
205
|
+
"requests": 20,
|
|
206
|
+
"concurrency": 4,
|
|
207
|
+
"timeout_seconds": 10.0,
|
|
208
|
+
"proxy_configured": false,
|
|
209
|
+
"nominal_price_per_1k": 0.49,
|
|
210
|
+
"usable_count": 14,
|
|
211
|
+
"blocked_count": 6,
|
|
212
|
+
"usable_pct": 70.0,
|
|
213
|
+
"effective_cost_per_1k": 0.7,
|
|
214
|
+
"tax_multiplier": 1.428571,
|
|
215
|
+
"cost_status": "finite",
|
|
216
|
+
"latency_ms": {
|
|
217
|
+
"p50": 212.4,
|
|
218
|
+
"p95": 1840.9,
|
|
219
|
+
"min": 141.2,
|
|
220
|
+
"max": 2190.7
|
|
221
|
+
},
|
|
222
|
+
"total_bytes": 1234567,
|
|
223
|
+
"status_counts": {
|
|
224
|
+
"200": 14,
|
|
225
|
+
"403": 5,
|
|
226
|
+
"timeout": 1
|
|
227
|
+
},
|
|
228
|
+
"blocked_reasons": {
|
|
229
|
+
"http_403": 5,
|
|
230
|
+
"timeout": 1
|
|
231
|
+
},
|
|
232
|
+
"exit_code": 1,
|
|
233
|
+
"benchmark_url": "https://scrape.mybskm.top/?utm_source=cli&utm_content=check-proxy-tax",
|
|
234
|
+
"ftc_disclosure": "FTC Disclosure: Benchmark links may include affiliate tracking."
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
`status_counts` uses the numeric HTTP status as a string, or `timeout` /
|
|
239
|
+
`error` when no status was received. `blocked_reasons` uses `http_<status>`,
|
|
240
|
+
`marker_missing`, `timeout`, or `connection_error`.
|
|
241
|
+
|
|
242
|
+
## CI example
|
|
243
|
+
|
|
244
|
+
```yaml
|
|
245
|
+
name: proxy-cost-gate
|
|
246
|
+
on:
|
|
247
|
+
workflow_dispatch:
|
|
248
|
+
schedule:
|
|
249
|
+
- cron: "17 6 * * *"
|
|
250
|
+
|
|
251
|
+
jobs:
|
|
252
|
+
audit:
|
|
253
|
+
runs-on: ubuntu-latest
|
|
254
|
+
steps:
|
|
255
|
+
- uses: actions/checkout@v4
|
|
256
|
+
- uses: actions/setup-python@v5
|
|
257
|
+
with:
|
|
258
|
+
python-version: "3.12"
|
|
259
|
+
- run: python3 -m pip install .
|
|
260
|
+
- name: Audit effective proxy cost
|
|
261
|
+
env:
|
|
262
|
+
TARGET_URL: https://example.com/product/123
|
|
263
|
+
run: |
|
|
264
|
+
set +e
|
|
265
|
+
check-proxy-tax \
|
|
266
|
+
--url "$TARGET_URL" \
|
|
267
|
+
--requests 30 \
|
|
268
|
+
--marker "Add to cart" \
|
|
269
|
+
--price-per-1k 0.49 \
|
|
270
|
+
--json > proxy-report.json
|
|
271
|
+
code=$?
|
|
272
|
+
set -e
|
|
273
|
+
cat proxy-report.json
|
|
274
|
+
# Gate on usable rate, not on the raw exit code.
|
|
275
|
+
jq -e '.usable_pct >= 95 and .cost_status == "finite"' proxy-report.json
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Run the suite locally with:
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
python3 -m unittest test_cli.py -v
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
The tests use a local `http.server`; they do not touch the network.
|
|
285
|
+
|
|
286
|
+
## Benchmark and provider matrix
|
|
287
|
+
|
|
288
|
+
Public benchmark and provider comparison:
|
|
289
|
+
|
|
290
|
+
<https://scrape.mybskm.top/?utm_source=cli&utm_content=check-proxy-tax>
|
|
291
|
+
|
|
292
|
+
FTC Disclosure: Benchmark links may include affiliate tracking.
|
|
293
|
+
|
|
294
|
+
## License
|
|
295
|
+
|
|
296
|
+
MIT. See [LICENSE](LICENSE).
|
|
297
|
+
|
|
298
|
+
Copyright (c) 2026 Kais / 600me.
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# check-proxy-tax
|
|
2
|
+
|
|
3
|
+
**Find out what a "cheap" proxy actually costs per 1,000 usable requests.**
|
|
4
|
+
|
|
5
|
+
[](https://www.python.org/)
|
|
6
|
+
[](#install)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
9
|
+
Proxy invoices are priced per request, per IP, or per GB. Your scraper is not
|
|
10
|
+
paid in those units. It is paid in responses it can actually use. When an edge
|
|
11
|
+
returns `403`, a request stalls until timeout, or a `200` arrives without the
|
|
12
|
+
product marker, the attempt is still billed. The gap between the advertised
|
|
13
|
+
price and the price of usable traffic is **The 403 Tax**.
|
|
14
|
+
|
|
15
|
+
`check-proxy-tax` measures that gap directly. It fires a small concurrent burst
|
|
16
|
+
at one URL, classifies every response as usable or blocked, and reports:
|
|
17
|
+
|
|
18
|
+
- usable rate and blocked/failed counts;
|
|
19
|
+
- effective cost per 1,000 usable requests;
|
|
20
|
+
- the tax multiplier over the nominal per-1,000 price;
|
|
21
|
+
- p50 / p95 latency and total bytes transferred;
|
|
22
|
+
- status-code and failure-reason breakdown.
|
|
23
|
+
|
|
24
|
+
It is a single core module using only the standard library. No account, no API
|
|
25
|
+
key, no external dependencies, no background service.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
Python 3.8 or newer.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
git clone https://github.com/600me/check-proxy-tax.git
|
|
33
|
+
cd check-proxy-tax
|
|
34
|
+
python3 -m pip install .
|
|
35
|
+
check-proxy-tax --help
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
You can also run it straight from a checkout without installing:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
python3 -m check_proxy_tax.cli --url https://example.com --requests 5
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The package is PyPI-ready (`python3 -m build` produces a wheel and sdist), but
|
|
45
|
+
installing from source is the supported path until the first PyPI release.
|
|
46
|
+
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
check-proxy-tax \
|
|
51
|
+
--url "https://example.com/product/123" \
|
|
52
|
+
--requests 20 \
|
|
53
|
+
--marker "Add to cart" \
|
|
54
|
+
--price-per-1k 0.49
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```text
|
|
58
|
+
check-proxy-tax 0.1.0 - proxy economics audit
|
|
59
|
+
|
|
60
|
+
+---------------------+------------------------------------------+
|
|
61
|
+
| METRIC | VALUE |
|
|
62
|
+
+---------------------+------------------------------------------+
|
|
63
|
+
| Target URL | https://example.com/product/123 |
|
|
64
|
+
| Samples | 20 (concurrency 4, timeout 10s) |
|
|
65
|
+
| Marker | Add to cart |
|
|
66
|
+
| Usable success | 14 / 20 |
|
|
67
|
+
| Blocked / failed | 6 / 20 |
|
|
68
|
+
| Usable rate | 70.0% |
|
|
69
|
+
+---------------------+------------------------------------------+
|
|
70
|
+
| Nominal price / 1k | $0.4900 |
|
|
71
|
+
| Effective cost / 1k | $0.7000 |
|
|
72
|
+
| Tax multiplier | 1.43x |
|
|
73
|
+
+---------------------+------------------------------------------+
|
|
74
|
+
| Latency p50 | 212.4 ms |
|
|
75
|
+
| Latency p95 | 1840.9 ms |
|
|
76
|
+
| Total transferred | 1.18 MB (1,234,567 bytes) |
|
|
77
|
+
| Status codes | 200:14, 403:5, timeout:1 |
|
|
78
|
+
| Blocked reasons | http_403:5, timeout:1 |
|
|
79
|
+
| Verdict | DEGRADED - 6 of 20 requests blocked |
|
|
80
|
+
+---------------------+------------------------------------------+
|
|
81
|
+
|
|
82
|
+
Benchmark & provider matrix: https://scrape.mybskm.top/?utm_source=cli&utm_content=check-proxy-tax
|
|
83
|
+
FTC Disclosure: Benchmark links may include affiliate tracking.
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## What counts as usable
|
|
87
|
+
|
|
88
|
+
A sample is **usable** only when both conditions hold:
|
|
89
|
+
|
|
90
|
+
1. the final HTTP status is exactly `200`;
|
|
91
|
+
2. when `--marker` is supplied, the response body contains that string
|
|
92
|
+
(case-sensitive, matched against the raw UTF-8 bytes).
|
|
93
|
+
|
|
94
|
+
Everything else is **blocked**: `403`, `429`, other `4xx`, `5xx`, timeout,
|
|
95
|
+
connection/TLS failure, or a `200` without the marker. Redirects are followed
|
|
96
|
+
and the final status is classified.
|
|
97
|
+
|
|
98
|
+
That definition is deliberately strict. A `200` challenge page, a consent
|
|
99
|
+
screen, or an empty shell is not inventory your scraper can use.
|
|
100
|
+
|
|
101
|
+
## The math
|
|
102
|
+
|
|
103
|
+
```text
|
|
104
|
+
usable_pct = usable_count / total_requests * 100
|
|
105
|
+
effective_cost_per_1k = nominal_price_per_1k / (usable_pct / 100)
|
|
106
|
+
tax_multiplier = effective_cost_per_1k / nominal_price_per_1k
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
If `usable_count` is `0`, the effective cost is **Infinite**: no amount of
|
|
110
|
+
nominal budget buys a usable response at that sample rate. The JSON output
|
|
111
|
+
sets `effective_cost_per_1k` and `tax_multiplier` to `null` and
|
|
112
|
+
`cost_status` to `"infinite"`, which keeps the document valid JSON.
|
|
113
|
+
|
|
114
|
+
### Worked example
|
|
115
|
+
|
|
116
|
+
You pay **$0.49 per 1,000 requests**. You sample 20 requests and 14 come back
|
|
117
|
+
usable with the expected marker.
|
|
118
|
+
|
|
119
|
+
```text
|
|
120
|
+
usable_pct = 14 / 20 * 100 = 70.0%
|
|
121
|
+
effective_cost_per_1k = 0.49 / 0.70 = $0.70
|
|
122
|
+
tax_multiplier = 0.70 / 0.49 = 1.43x
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
At 1,000,000 requests per month the invoice is `$490`. Because only 70% of
|
|
126
|
+
attempts are usable, you need about 1,428,571 attempts to obtain 1,000,000
|
|
127
|
+
usable responses, which costs about `$700`. **The 403 Tax is $210 per month**
|
|
128
|
+
on that volume.
|
|
129
|
+
|
|
130
|
+
The model assumes blocked attempts are billed, which is how per-request and
|
|
131
|
+
per-IP plans normally work. If your provider bills only successful requests,
|
|
132
|
+
set `--price-per-1k` to the effective rate you want to test and read the
|
|
133
|
+
multiplier as a quality signal rather than a surcharge.
|
|
134
|
+
|
|
135
|
+
## Options
|
|
136
|
+
|
|
137
|
+
| Option | Default | Description |
|
|
138
|
+
| --- | --- | --- |
|
|
139
|
+
| `--url URL` | required | Absolute `http(s)` URL to probe. |
|
|
140
|
+
| `--requests N` | `20` | Number of samples. |
|
|
141
|
+
| `--marker STRING` | none | String that must appear in the body for a sample to count as usable. |
|
|
142
|
+
| `--price-per-1k FLOAT` | `0.49` | Nominal price per 1,000 requests, in USD. |
|
|
143
|
+
| `--proxy URL` | none | HTTP(S) proxy, e.g. `http://user:pass@host:port`. |
|
|
144
|
+
| `--concurrency N` | `4` | Maximum in-flight requests. |
|
|
145
|
+
| `--timeout SECONDS` | `10` | Per-request timeout. |
|
|
146
|
+
| `--json` | off | Emit one JSON object and nothing else. |
|
|
147
|
+
| `--version` | | Print the version and exit. |
|
|
148
|
+
| `--help` | | Show help, including the FTC disclosure. |
|
|
149
|
+
|
|
150
|
+
## Exit codes
|
|
151
|
+
|
|
152
|
+
| Code | Meaning |
|
|
153
|
+
| --- | --- |
|
|
154
|
+
| `0` | Every sampled request was usable. |
|
|
155
|
+
| `1` | Degraded: at least one request was blocked, at least one succeeded. |
|
|
156
|
+
| `2` | Failed: no request was usable. |
|
|
157
|
+
| `3` | Invalid arguments or URL. |
|
|
158
|
+
|
|
159
|
+
This makes the CLI useful as a gate without parsing text: `0` is the only
|
|
160
|
+
fully healthy result. A CI job that must not fail on a degraded sample can
|
|
161
|
+
capture the code and inspect the JSON instead.
|
|
162
|
+
|
|
163
|
+
## JSON output
|
|
164
|
+
|
|
165
|
+
`--json` writes a single JSON object to stdout, suitable for `jq`, dashboards,
|
|
166
|
+
or a CI artifact. Diagnostics never share that stream.
|
|
167
|
+
|
|
168
|
+
```json
|
|
169
|
+
{
|
|
170
|
+
"tool": "check-proxy-tax",
|
|
171
|
+
"version": "0.1.0",
|
|
172
|
+
"url": "https://example.com/product/123",
|
|
173
|
+
"marker": "Add to cart",
|
|
174
|
+
"requests": 20,
|
|
175
|
+
"concurrency": 4,
|
|
176
|
+
"timeout_seconds": 10.0,
|
|
177
|
+
"proxy_configured": false,
|
|
178
|
+
"nominal_price_per_1k": 0.49,
|
|
179
|
+
"usable_count": 14,
|
|
180
|
+
"blocked_count": 6,
|
|
181
|
+
"usable_pct": 70.0,
|
|
182
|
+
"effective_cost_per_1k": 0.7,
|
|
183
|
+
"tax_multiplier": 1.428571,
|
|
184
|
+
"cost_status": "finite",
|
|
185
|
+
"latency_ms": {
|
|
186
|
+
"p50": 212.4,
|
|
187
|
+
"p95": 1840.9,
|
|
188
|
+
"min": 141.2,
|
|
189
|
+
"max": 2190.7
|
|
190
|
+
},
|
|
191
|
+
"total_bytes": 1234567,
|
|
192
|
+
"status_counts": {
|
|
193
|
+
"200": 14,
|
|
194
|
+
"403": 5,
|
|
195
|
+
"timeout": 1
|
|
196
|
+
},
|
|
197
|
+
"blocked_reasons": {
|
|
198
|
+
"http_403": 5,
|
|
199
|
+
"timeout": 1
|
|
200
|
+
},
|
|
201
|
+
"exit_code": 1,
|
|
202
|
+
"benchmark_url": "https://scrape.mybskm.top/?utm_source=cli&utm_content=check-proxy-tax",
|
|
203
|
+
"ftc_disclosure": "FTC Disclosure: Benchmark links may include affiliate tracking."
|
|
204
|
+
}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
`status_counts` uses the numeric HTTP status as a string, or `timeout` /
|
|
208
|
+
`error` when no status was received. `blocked_reasons` uses `http_<status>`,
|
|
209
|
+
`marker_missing`, `timeout`, or `connection_error`.
|
|
210
|
+
|
|
211
|
+
## CI example
|
|
212
|
+
|
|
213
|
+
```yaml
|
|
214
|
+
name: proxy-cost-gate
|
|
215
|
+
on:
|
|
216
|
+
workflow_dispatch:
|
|
217
|
+
schedule:
|
|
218
|
+
- cron: "17 6 * * *"
|
|
219
|
+
|
|
220
|
+
jobs:
|
|
221
|
+
audit:
|
|
222
|
+
runs-on: ubuntu-latest
|
|
223
|
+
steps:
|
|
224
|
+
- uses: actions/checkout@v4
|
|
225
|
+
- uses: actions/setup-python@v5
|
|
226
|
+
with:
|
|
227
|
+
python-version: "3.12"
|
|
228
|
+
- run: python3 -m pip install .
|
|
229
|
+
- name: Audit effective proxy cost
|
|
230
|
+
env:
|
|
231
|
+
TARGET_URL: https://example.com/product/123
|
|
232
|
+
run: |
|
|
233
|
+
set +e
|
|
234
|
+
check-proxy-tax \
|
|
235
|
+
--url "$TARGET_URL" \
|
|
236
|
+
--requests 30 \
|
|
237
|
+
--marker "Add to cart" \
|
|
238
|
+
--price-per-1k 0.49 \
|
|
239
|
+
--json > proxy-report.json
|
|
240
|
+
code=$?
|
|
241
|
+
set -e
|
|
242
|
+
cat proxy-report.json
|
|
243
|
+
# Gate on usable rate, not on the raw exit code.
|
|
244
|
+
jq -e '.usable_pct >= 95 and .cost_status == "finite"' proxy-report.json
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Run the suite locally with:
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
python3 -m unittest test_cli.py -v
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
The tests use a local `http.server`; they do not touch the network.
|
|
254
|
+
|
|
255
|
+
## Benchmark and provider matrix
|
|
256
|
+
|
|
257
|
+
Public benchmark and provider comparison:
|
|
258
|
+
|
|
259
|
+
<https://scrape.mybskm.top/?utm_source=cli&utm_content=check-proxy-tax>
|
|
260
|
+
|
|
261
|
+
FTC Disclosure: Benchmark links may include affiliate tracking.
|
|
262
|
+
|
|
263
|
+
## License
|
|
264
|
+
|
|
265
|
+
MIT. See [LICENSE](LICENSE).
|
|
266
|
+
|
|
267
|
+
Copyright (c) 2026 Kais / 600me.
|
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
"""check-proxy-tax: measure what usable proxy traffic actually costs.
|
|
2
|
+
|
|
3
|
+
The tool sends a burst of concurrent requests to one URL, classifies every
|
|
4
|
+
response as usable (HTTP 200 plus an optional marker) or blocked, and converts
|
|
5
|
+
the raw per-1,000 price into the price you really pay per 1,000 *usable*
|
|
6
|
+
responses.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
import socket
|
|
15
|
+
import statistics
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.parse
|
|
20
|
+
import urllib.request
|
|
21
|
+
from collections import Counter
|
|
22
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Dict, List, Optional, Sequence, Tuple
|
|
25
|
+
|
|
26
|
+
__version__ = "1.0.0"
|
|
27
|
+
|
|
28
|
+
BENCHMARK_URL = (
|
|
29
|
+
"https://scrape.mybskm.top/?utm_source=cli&utm_content=check-proxy-tax"
|
|
30
|
+
)
|
|
31
|
+
FTC_DISCLOSURE = (
|
|
32
|
+
"FTC Disclosure: Benchmark links may include affiliate tracking."
|
|
33
|
+
)
|
|
34
|
+
USER_AGENT = (
|
|
35
|
+
"check-proxy-tax/{0} (+https://github.com/600me/check-proxy-tax)".format(
|
|
36
|
+
__version__
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
EXIT_USABLE = 0
|
|
41
|
+
EXIT_DEGRADED = 1
|
|
42
|
+
EXIT_FAILED = 2
|
|
43
|
+
EXIT_USAGE = 3
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Sample:
|
|
48
|
+
"""One completed probe attempt."""
|
|
49
|
+
|
|
50
|
+
usable: bool
|
|
51
|
+
status: Optional[int]
|
|
52
|
+
reason: str
|
|
53
|
+
latency_ms: float
|
|
54
|
+
body_bytes: int
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class Metrics:
|
|
59
|
+
"""Aggregated economics for one run."""
|
|
60
|
+
|
|
61
|
+
total: int
|
|
62
|
+
usable: int
|
|
63
|
+
usable_pct: float
|
|
64
|
+
effective_cost_per_1k: Optional[float]
|
|
65
|
+
tax_multiplier: Optional[float]
|
|
66
|
+
cost_status: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _timeout_reason(exc: BaseException) -> str:
|
|
70
|
+
reason = getattr(exc, "reason", exc)
|
|
71
|
+
text = str(reason).lower()
|
|
72
|
+
if isinstance(exc, (socket.timeout, TimeoutError)) or "timed out" in text:
|
|
73
|
+
return "timeout"
|
|
74
|
+
if "timeout" in text:
|
|
75
|
+
return "timeout"
|
|
76
|
+
return "connection_error"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def fetch_once(
|
|
80
|
+
url: str,
|
|
81
|
+
marker: Optional[str],
|
|
82
|
+
timeout: float,
|
|
83
|
+
proxy: Optional[str] = None,
|
|
84
|
+
) -> Sample:
|
|
85
|
+
"""Fetch *url* once and classify the result.
|
|
86
|
+
|
|
87
|
+
Usable means: final HTTP status is exactly 200 and, when a marker was
|
|
88
|
+
supplied, the raw response body contains that marker as UTF-8 bytes.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
handlers: List[urllib.request.BaseHandler] = []
|
|
92
|
+
if proxy:
|
|
93
|
+
handlers.append(
|
|
94
|
+
urllib.request.ProxyHandler({"http": proxy, "https": proxy})
|
|
95
|
+
)
|
|
96
|
+
opener = urllib.request.build_opener(*handlers)
|
|
97
|
+
request = urllib.request.Request(
|
|
98
|
+
url,
|
|
99
|
+
headers={"User-Agent": USER_AGENT, "Accept": "*/*"},
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
started = time.perf_counter()
|
|
103
|
+
try:
|
|
104
|
+
with opener.open(request, timeout=timeout) as response:
|
|
105
|
+
body = response.read()
|
|
106
|
+
elapsed_ms = (time.perf_counter() - started) * 1000.0
|
|
107
|
+
status = int(response.status)
|
|
108
|
+
if status != 200:
|
|
109
|
+
return Sample(False, status, "http_{0}".format(status), elapsed_ms, len(body))
|
|
110
|
+
if marker is not None and marker.encode("utf-8") not in body:
|
|
111
|
+
return Sample(False, status, "marker_missing", elapsed_ms, len(body))
|
|
112
|
+
return Sample(True, status, "ok", elapsed_ms, len(body))
|
|
113
|
+
except urllib.error.HTTPError as exc:
|
|
114
|
+
elapsed_ms = (time.perf_counter() - started) * 1000.0
|
|
115
|
+
try:
|
|
116
|
+
body_bytes = len(exc.read())
|
|
117
|
+
except Exception:
|
|
118
|
+
body_bytes = 0
|
|
119
|
+
return Sample(
|
|
120
|
+
False,
|
|
121
|
+
int(exc.code),
|
|
122
|
+
"http_{0}".format(exc.code),
|
|
123
|
+
elapsed_ms,
|
|
124
|
+
body_bytes,
|
|
125
|
+
)
|
|
126
|
+
except Exception as exc: # URLError, timeouts, TLS and socket errors
|
|
127
|
+
elapsed_ms = (time.perf_counter() - started) * 1000.0
|
|
128
|
+
return Sample(False, None, _timeout_reason(exc), elapsed_ms, 0)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def compute_metrics(usable: int, total: int, nominal_price: float) -> Metrics:
|
|
132
|
+
"""Apply the effective-cost formulas to a finished sample set."""
|
|
133
|
+
|
|
134
|
+
if total < 1:
|
|
135
|
+
raise ValueError("total must be >= 1")
|
|
136
|
+
if usable < 0 or usable > total:
|
|
137
|
+
raise ValueError("usable must be between 0 and total")
|
|
138
|
+
if nominal_price <= 0:
|
|
139
|
+
raise ValueError("nominal_price must be > 0")
|
|
140
|
+
|
|
141
|
+
usable_pct = (usable / total) * 100.0
|
|
142
|
+
if usable == 0:
|
|
143
|
+
return Metrics(total, usable, usable_pct, None, None, "infinite")
|
|
144
|
+
|
|
145
|
+
effective_cost = nominal_price / (usable_pct / 100.0)
|
|
146
|
+
tax_multiplier = effective_cost / nominal_price
|
|
147
|
+
return Metrics(
|
|
148
|
+
total,
|
|
149
|
+
usable,
|
|
150
|
+
usable_pct,
|
|
151
|
+
effective_cost,
|
|
152
|
+
tax_multiplier,
|
|
153
|
+
"finite",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def percentile(values: Sequence[float], pct: float) -> Optional[float]:
|
|
158
|
+
"""Linear-interpolated percentile (the common numpy/Excel definition)."""
|
|
159
|
+
|
|
160
|
+
if not values:
|
|
161
|
+
return None
|
|
162
|
+
ordered = sorted(values)
|
|
163
|
+
if len(ordered) == 1:
|
|
164
|
+
return ordered[0]
|
|
165
|
+
if pct == 50:
|
|
166
|
+
return float(statistics.median(ordered))
|
|
167
|
+
rank = (len(ordered) - 1) * (pct / 100.0)
|
|
168
|
+
lower = int(math.floor(rank))
|
|
169
|
+
upper = int(math.ceil(rank))
|
|
170
|
+
if lower == upper:
|
|
171
|
+
return ordered[lower]
|
|
172
|
+
fraction = rank - lower
|
|
173
|
+
return ordered[lower] + (ordered[upper] - ordered[lower]) * fraction
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _exit_code(metrics: Metrics) -> int:
|
|
177
|
+
if metrics.usable == metrics.total:
|
|
178
|
+
return EXIT_USABLE
|
|
179
|
+
if metrics.usable == 0:
|
|
180
|
+
return EXIT_FAILED
|
|
181
|
+
return EXIT_DEGRADED
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _money(value: Optional[float]) -> str:
|
|
185
|
+
if value is None:
|
|
186
|
+
return "Infinite"
|
|
187
|
+
return "${0:,.4f}".format(value)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _multiplier(value: Optional[float]) -> str:
|
|
191
|
+
if value is None:
|
|
192
|
+
return "Infinite"
|
|
193
|
+
return "{0:,.2f}x".format(value)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _human_bytes(count: int) -> str:
|
|
197
|
+
if count < 1024:
|
|
198
|
+
return "{0} B".format(count)
|
|
199
|
+
value = float(count)
|
|
200
|
+
for unit in ("KB", "MB", "GB", "TB"):
|
|
201
|
+
value /= 1024.0
|
|
202
|
+
if value < 1024.0:
|
|
203
|
+
return "{0:.2f} {1} ({2:,} bytes)".format(value, unit, count)
|
|
204
|
+
return "{0:.2f} PB ({1:,} bytes)".format(value, count)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _table(rows: List[Tuple[str, str]], separators: Sequence[int]) -> str:
|
|
208
|
+
label_width = max([len("METRIC")] + [len(label) for label, _ in rows])
|
|
209
|
+
value_width = max([len("VALUE")] + [len(value) for _, value in rows])
|
|
210
|
+
border = "+{0}+{1}+".format(
|
|
211
|
+
"-" * (label_width + 2), "-" * (value_width + 2)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def row(label: str, value: str) -> str:
|
|
215
|
+
return "| {0:<{1}} | {2:<{3}} |".format(
|
|
216
|
+
label, label_width, value, value_width
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
lines = [border, row("METRIC", "VALUE"), border]
|
|
220
|
+
for index, (label, value) in enumerate(rows):
|
|
221
|
+
lines.append(row(label, value))
|
|
222
|
+
if index in separators:
|
|
223
|
+
lines.append(border)
|
|
224
|
+
lines.append(border)
|
|
225
|
+
return "\n".join(lines)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def render_table(report: Dict[str, object]) -> str:
|
|
229
|
+
"""Render the human-readable ASCII diagnostic table."""
|
|
230
|
+
|
|
231
|
+
latency = report["latency_ms"] # type: ignore[index]
|
|
232
|
+
status_counts = report["status_counts"] # type: ignore[index]
|
|
233
|
+
blocked_reasons = report["blocked_reasons"] # type: ignore[index]
|
|
234
|
+
|
|
235
|
+
def pairs(mapping: Dict[str, int]) -> str:
|
|
236
|
+
if not mapping:
|
|
237
|
+
return "none"
|
|
238
|
+
return ", ".join(
|
|
239
|
+
"{0}:{1}".format(key, value)
|
|
240
|
+
for key, value in sorted(mapping.items())
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
nominal = float(report["nominal_price_per_1k"]) # type: ignore[arg-type]
|
|
244
|
+
effective = report["effective_cost_per_1k"]
|
|
245
|
+
multiplier = report["tax_multiplier"]
|
|
246
|
+
|
|
247
|
+
rows: List[Tuple[str, str]] = [
|
|
248
|
+
("Target URL", str(report["url"])),
|
|
249
|
+
(
|
|
250
|
+
"Samples",
|
|
251
|
+
"{0} (concurrency {1}, timeout {2:g}s)".format(
|
|
252
|
+
report["requests"], report["concurrency"], report["timeout_seconds"]
|
|
253
|
+
),
|
|
254
|
+
),
|
|
255
|
+
("Marker", str(report["marker"]) if report["marker"] else "(none)"),
|
|
256
|
+
(
|
|
257
|
+
"Usable success",
|
|
258
|
+
"{0} / {1}".format(report["usable_count"], report["requests"]),
|
|
259
|
+
),
|
|
260
|
+
(
|
|
261
|
+
"Blocked / failed",
|
|
262
|
+
"{0} / {1}".format(report["blocked_count"], report["requests"]),
|
|
263
|
+
),
|
|
264
|
+
("Usable rate", "{0:.1f}%".format(float(report["usable_pct"]))),
|
|
265
|
+
("Nominal price / 1k", _money(nominal)),
|
|
266
|
+
(
|
|
267
|
+
"Effective cost / 1k",
|
|
268
|
+
_money(float(effective) if effective is not None else None),
|
|
269
|
+
),
|
|
270
|
+
(
|
|
271
|
+
"Tax multiplier",
|
|
272
|
+
_multiplier(float(multiplier) if multiplier is not None else None),
|
|
273
|
+
),
|
|
274
|
+
("Latency p50", "{0:.1f} ms".format(float(latency["p50"]))), # type: ignore[index]
|
|
275
|
+
("Latency p95", "{0:.1f} ms".format(float(latency["p95"]))), # type: ignore[index]
|
|
276
|
+
("Total transferred", _human_bytes(int(report["total_bytes"]))),
|
|
277
|
+
("Status codes", pairs(status_counts)), # type: ignore[arg-type]
|
|
278
|
+
("Blocked reasons", pairs(blocked_reasons)), # type: ignore[arg-type]
|
|
279
|
+
("Verdict", _verdict_from_report(report)),
|
|
280
|
+
]
|
|
281
|
+
|
|
282
|
+
title = "check-proxy-tax {0} - proxy economics audit".format(__version__)
|
|
283
|
+
return "{0}\n\n{1}".format(title, _table(rows, separators=(5, 8)))
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _verdict_from_report(report: Dict[str, object]) -> str:
|
|
287
|
+
usable = int(report["usable_count"]) # type: ignore[arg-type]
|
|
288
|
+
total = int(report["requests"]) # type: ignore[arg-type]
|
|
289
|
+
if usable == total:
|
|
290
|
+
return "PASS - every sampled request was usable"
|
|
291
|
+
if usable == 0:
|
|
292
|
+
return "FAIL - no usable responses"
|
|
293
|
+
return "DEGRADED - {0} of {1} requests blocked".format(total - usable, total)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def build_report(
|
|
297
|
+
args: argparse.Namespace,
|
|
298
|
+
samples: Sequence[Sample],
|
|
299
|
+
metrics: Metrics,
|
|
300
|
+
) -> Dict[str, object]:
|
|
301
|
+
"""Build the JSON-serialisable report shared by both output modes."""
|
|
302
|
+
|
|
303
|
+
latencies = [sample.latency_ms for sample in samples]
|
|
304
|
+
status_counts: Counter = Counter(
|
|
305
|
+
"timeout"
|
|
306
|
+
if sample.reason == "timeout"
|
|
307
|
+
else "error"
|
|
308
|
+
if sample.status is None
|
|
309
|
+
else str(sample.status)
|
|
310
|
+
for sample in samples
|
|
311
|
+
)
|
|
312
|
+
blocked_reasons: Counter = Counter(
|
|
313
|
+
sample.reason for sample in samples if not sample.usable
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
p50 = percentile(latencies, 50)
|
|
317
|
+
p95 = percentile(latencies, 95)
|
|
318
|
+
code = _exit_code(metrics)
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
"tool": "check-proxy-tax",
|
|
322
|
+
"version": __version__,
|
|
323
|
+
"url": args.url,
|
|
324
|
+
"marker": args.marker,
|
|
325
|
+
"requests": args.requests,
|
|
326
|
+
"concurrency": args.concurrency,
|
|
327
|
+
"timeout_seconds": args.timeout,
|
|
328
|
+
"proxy_configured": bool(args.proxy),
|
|
329
|
+
"nominal_price_per_1k": args.price_per_1k,
|
|
330
|
+
"usable_count": metrics.usable,
|
|
331
|
+
"blocked_count": metrics.total - metrics.usable,
|
|
332
|
+
"usable_pct": round(metrics.usable_pct, 4),
|
|
333
|
+
"effective_cost_per_1k": (
|
|
334
|
+
round(metrics.effective_cost_per_1k, 6)
|
|
335
|
+
if metrics.effective_cost_per_1k is not None
|
|
336
|
+
else None
|
|
337
|
+
),
|
|
338
|
+
"tax_multiplier": (
|
|
339
|
+
round(metrics.tax_multiplier, 6)
|
|
340
|
+
if metrics.tax_multiplier is not None
|
|
341
|
+
else None
|
|
342
|
+
),
|
|
343
|
+
"cost_status": metrics.cost_status,
|
|
344
|
+
"latency_ms": {
|
|
345
|
+
"p50": round(p50, 2) if p50 is not None else None,
|
|
346
|
+
"p95": round(p95, 2) if p95 is not None else None,
|
|
347
|
+
"min": round(min(latencies), 2),
|
|
348
|
+
"max": round(max(latencies), 2),
|
|
349
|
+
},
|
|
350
|
+
"total_bytes": sum(sample.body_bytes for sample in samples),
|
|
351
|
+
"status_counts": dict(sorted(status_counts.items())),
|
|
352
|
+
"blocked_reasons": dict(sorted(blocked_reasons.items())),
|
|
353
|
+
"exit_code": code,
|
|
354
|
+
"benchmark_url": BENCHMARK_URL,
|
|
355
|
+
"ftc_disclosure": FTC_DISCLOSURE,
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _positive_int(value: str) -> int:
|
|
360
|
+
try:
|
|
361
|
+
number = int(value)
|
|
362
|
+
except ValueError:
|
|
363
|
+
raise argparse.ArgumentTypeError("must be an integer")
|
|
364
|
+
if number < 1:
|
|
365
|
+
raise argparse.ArgumentTypeError("must be >= 1")
|
|
366
|
+
return number
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _positive_float(value: str) -> float:
|
|
370
|
+
try:
|
|
371
|
+
number = float(value)
|
|
372
|
+
except ValueError:
|
|
373
|
+
raise argparse.ArgumentTypeError("must be a number")
|
|
374
|
+
if not math.isfinite(number) or number <= 0:
|
|
375
|
+
raise argparse.ArgumentTypeError("must be a positive number")
|
|
376
|
+
return number
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class _ArgumentParser(argparse.ArgumentParser):
|
|
380
|
+
"""Keep argument errors distinct from a run where every probe failed."""
|
|
381
|
+
|
|
382
|
+
def error(self, message: str) -> None: # type: ignore[override]
|
|
383
|
+
self.print_usage(sys.stderr)
|
|
384
|
+
self.exit(EXIT_USAGE, "{0}: error: {1}\n".format(self.prog, message))
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
388
|
+
parser = _ArgumentParser(
|
|
389
|
+
prog="check-proxy-tax",
|
|
390
|
+
description=(
|
|
391
|
+
"Sample a URL concurrently and report the effective cost per "
|
|
392
|
+
"1,000 usable responses after 403s, 429s, 5xx errors, timeouts, "
|
|
393
|
+
"and marker misses."
|
|
394
|
+
),
|
|
395
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
396
|
+
epilog=(
|
|
397
|
+
"examples:\n"
|
|
398
|
+
" check-proxy-tax --url https://example.com/product --requests 20 \\\n"
|
|
399
|
+
" --marker \"Add to cart\" --price-per-1k 0.49\n"
|
|
400
|
+
" check-proxy-tax --url https://example.com --json > report.json\n"
|
|
401
|
+
"\n"
|
|
402
|
+
"exit codes:\n"
|
|
403
|
+
" 0 every sampled request was usable\n"
|
|
404
|
+
" 1 some requests were blocked (degraded)\n"
|
|
405
|
+
" 2 no request was usable\n"
|
|
406
|
+
" 3 invalid arguments\n"
|
|
407
|
+
"\n"
|
|
408
|
+
"Benchmark & provider matrix: {0}\n"
|
|
409
|
+
"\n"
|
|
410
|
+
"{1}".format(BENCHMARK_URL, FTC_DISCLOSURE)
|
|
411
|
+
),
|
|
412
|
+
)
|
|
413
|
+
parser.add_argument("--url", required=True, help="absolute http(s) URL to probe")
|
|
414
|
+
parser.add_argument(
|
|
415
|
+
"--requests",
|
|
416
|
+
type=_positive_int,
|
|
417
|
+
default=20,
|
|
418
|
+
help="number of concurrent samples (default: 20)",
|
|
419
|
+
)
|
|
420
|
+
parser.add_argument(
|
|
421
|
+
"--marker",
|
|
422
|
+
default=None,
|
|
423
|
+
help="string that must appear in the response body for a usable sample",
|
|
424
|
+
)
|
|
425
|
+
parser.add_argument(
|
|
426
|
+
"--price-per-1k",
|
|
427
|
+
type=_positive_float,
|
|
428
|
+
default=0.49,
|
|
429
|
+
help="nominal price per 1,000 requests in USD (default: 0.49)",
|
|
430
|
+
)
|
|
431
|
+
parser.add_argument(
|
|
432
|
+
"--proxy",
|
|
433
|
+
default=None,
|
|
434
|
+
help="HTTP(S) proxy URL, e.g. http://user:pass@host:port",
|
|
435
|
+
)
|
|
436
|
+
parser.add_argument(
|
|
437
|
+
"--concurrency",
|
|
438
|
+
type=_positive_int,
|
|
439
|
+
default=4,
|
|
440
|
+
help="maximum in-flight requests (default: 4)",
|
|
441
|
+
)
|
|
442
|
+
parser.add_argument(
|
|
443
|
+
"--timeout",
|
|
444
|
+
type=_positive_float,
|
|
445
|
+
default=10.0,
|
|
446
|
+
help="per-request timeout in seconds (default: 10)",
|
|
447
|
+
)
|
|
448
|
+
parser.add_argument(
|
|
449
|
+
"--json",
|
|
450
|
+
action="store_true",
|
|
451
|
+
dest="as_json",
|
|
452
|
+
help="emit one JSON object for CI/CD pipelines",
|
|
453
|
+
)
|
|
454
|
+
parser.add_argument(
|
|
455
|
+
"--version",
|
|
456
|
+
action="version",
|
|
457
|
+
version="check-proxy-tax {0}".format(__version__),
|
|
458
|
+
)
|
|
459
|
+
return parser
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def run_probe(
|
|
463
|
+
url: str,
|
|
464
|
+
requests: int,
|
|
465
|
+
marker: Optional[str],
|
|
466
|
+
timeout: float,
|
|
467
|
+
proxy: Optional[str],
|
|
468
|
+
concurrency: int,
|
|
469
|
+
) -> List[Sample]:
|
|
470
|
+
workers = max(1, min(concurrency, requests))
|
|
471
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
472
|
+
futures = [
|
|
473
|
+
pool.submit(fetch_once, url, marker, timeout, proxy)
|
|
474
|
+
for _ in range(requests)
|
|
475
|
+
]
|
|
476
|
+
return [future.result() for future in futures]
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
480
|
+
parser = build_parser()
|
|
481
|
+
args = parser.parse_args(argv)
|
|
482
|
+
|
|
483
|
+
parsed_url = urllib.parse.urlsplit(args.url)
|
|
484
|
+
if parsed_url.scheme not in ("http", "https") or not parsed_url.netloc:
|
|
485
|
+
parser.error("--url must be an absolute http(s) URL")
|
|
486
|
+
|
|
487
|
+
try:
|
|
488
|
+
samples = run_probe(
|
|
489
|
+
args.url,
|
|
490
|
+
args.requests,
|
|
491
|
+
args.marker,
|
|
492
|
+
args.timeout,
|
|
493
|
+
args.proxy,
|
|
494
|
+
args.concurrency,
|
|
495
|
+
)
|
|
496
|
+
except KeyboardInterrupt:
|
|
497
|
+
print("check-proxy-tax: interrupted", file=sys.stderr)
|
|
498
|
+
return 130
|
|
499
|
+
|
|
500
|
+
metrics = compute_metrics(
|
|
501
|
+
sum(1 for sample in samples if sample.usable),
|
|
502
|
+
len(samples),
|
|
503
|
+
args.price_per_1k,
|
|
504
|
+
)
|
|
505
|
+
report = build_report(args, samples, metrics)
|
|
506
|
+
|
|
507
|
+
if args.as_json:
|
|
508
|
+
print(json.dumps(report, indent=2, ensure_ascii=False, sort_keys=False))
|
|
509
|
+
else:
|
|
510
|
+
print(render_table(report))
|
|
511
|
+
print()
|
|
512
|
+
print("Benchmark & provider matrix: {0}".format(BENCHMARK_URL))
|
|
513
|
+
print(FTC_DISCLOSURE)
|
|
514
|
+
|
|
515
|
+
return _exit_code(metrics)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
if __name__ == "__main__":
|
|
519
|
+
sys.exit(main())
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "check-proxy-tax"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "CLI tool to audit real proxy costs, 403 blowout multipliers, and effective price per 1,000 usable requests."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Kais / 600me" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"proxy",
|
|
15
|
+
"scraping",
|
|
16
|
+
"benchmark",
|
|
17
|
+
"http",
|
|
18
|
+
"cost",
|
|
19
|
+
"cli",
|
|
20
|
+
]
|
|
21
|
+
classifiers = [
|
|
22
|
+
"Development Status :: 4 - Beta",
|
|
23
|
+
"Environment :: Console",
|
|
24
|
+
"Intended Audience :: Developers",
|
|
25
|
+
"License :: OSI Approved :: MIT License",
|
|
26
|
+
"Operating System :: OS Independent",
|
|
27
|
+
"Programming Language :: Python :: 3",
|
|
28
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
29
|
+
"Programming Language :: Python :: 3.8",
|
|
30
|
+
"Programming Language :: Python :: 3.9",
|
|
31
|
+
"Programming Language :: Python :: 3.10",
|
|
32
|
+
"Programming Language :: Python :: 3.11",
|
|
33
|
+
"Programming Language :: Python :: 3.12",
|
|
34
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
35
|
+
"Topic :: System :: Networking",
|
|
36
|
+
"Topic :: Utilities",
|
|
37
|
+
]
|
|
38
|
+
dependencies = []
|
|
39
|
+
|
|
40
|
+
[project.optional-dependencies]
|
|
41
|
+
dev = ["build>=1.0", "twine>=4.0"]
|
|
42
|
+
|
|
43
|
+
[project.scripts]
|
|
44
|
+
check-proxy-tax = "check_proxy_tax.cli:main"
|
|
45
|
+
|
|
46
|
+
[project.urls]
|
|
47
|
+
Homepage = "https://github.com/600me/check-proxy-tax"
|
|
48
|
+
Repository = "https://github.com/600me/check-proxy-tax"
|
|
49
|
+
Issues = "https://github.com/600me/check-proxy-tax/issues"
|
|
50
|
+
Benchmark = "https://scrape.mybskm.top/?utm_source=cli&utm_content=check-proxy-tax"
|
|
51
|
+
|
|
52
|
+
[tool.setuptools]
|
|
53
|
+
packages = ["check_proxy_tax"]
|