a2ztools 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.
- a2ztools-1.0.0/.gitignore +16 -0
- a2ztools-1.0.0/LICENSE +24 -0
- a2ztools-1.0.0/PKG-INFO +48 -0
- a2ztools-1.0.0/README.md +26 -0
- a2ztools-1.0.0/pyproject.toml +35 -0
- a2ztools-1.0.0/src/a2ztools/__init__.py +5 -0
- a2ztools-1.0.0/src/a2ztools/_endpoints.py +325 -0
- a2ztools-1.0.0/src/a2ztools/_version.py +1 -0
- a2ztools-1.0.0/src/a2ztools/client.py +118 -0
- a2ztools-1.0.0/src/a2ztools/py.typed +0 -0
- a2ztools-1.0.0/tests/test_client.py +100 -0
a2ztools-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Reliable Software Technology (A2Z Tools)
|
|
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.
|
|
22
|
+
|
|
23
|
+
The WordPress plugin in integrations/wordpress/ is licensed separately under
|
|
24
|
+
GPL-2.0-or-later; see the header of that plugin.
|
a2ztools-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: a2ztools
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Free API client for website checks: SSL certificate expiry, DNS, WHOIS (RDAP), SPF, DKIM, DMARC, security headers, robots.txt, llms.txt and more.
|
|
5
|
+
Project-URL: Homepage, https://a2z.tools/developers
|
|
6
|
+
Project-URL: Documentation, https://a2z.tools/developers
|
|
7
|
+
Project-URL: Source, https://github.com/a2ztoolsdev-source/a2ztools
|
|
8
|
+
Project-URL: Issues, https://github.com/a2ztoolsdev-source/a2ztools/issues
|
|
9
|
+
Author-email: A2Z Tools <app@a2z.tools>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: api-client,dkim,dmarc,dns,security-headers,seo,spf,ssl,website-monitoring,whois
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Internet :: Name Service (DNS)
|
|
18
|
+
Classifier: Topic :: Security
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# a2ztools
|
|
24
|
+
|
|
25
|
+
Free Python client for the [A2Z Tools public API](https://a2z.tools/developers). It covers 68 checks for SSL certificates, DNS, WHOIS (RDAP), SPF, DKIM, DMARC, security headers, robots.txt, llms.txt and site health. It uses only the standard library and needs Python 3.9+.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install a2ztools
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from a2ztools import A2zClient, A2zApiError
|
|
33
|
+
|
|
34
|
+
a2z = A2zClient("a2z_...") # key optional
|
|
35
|
+
cert = a2z.ssl.certificate("example.com")
|
|
36
|
+
mx = a2z.dns.records("example.com", type="MX")
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
a2z.email.dmarc("example.com")
|
|
40
|
+
except A2zApiError as e:
|
|
41
|
+
print(e.status, e.code, e.retry_after)
|
|
42
|
+
|
|
43
|
+
print(a2z.last_rate_limit)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Without a key you get 10 calls a minute and 100 a day. A free key from <https://a2z.tools/developers> gives 60 a minute and 2,000 a day.
|
|
47
|
+
|
|
48
|
+
MIT licensed.
|
a2ztools-1.0.0/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# a2ztools
|
|
2
|
+
|
|
3
|
+
Free Python client for the [A2Z Tools public API](https://a2z.tools/developers). It covers 68 checks for SSL certificates, DNS, WHOIS (RDAP), SPF, DKIM, DMARC, security headers, robots.txt, llms.txt and site health. It uses only the standard library and needs Python 3.9+.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install a2ztools
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from a2ztools import A2zClient, A2zApiError
|
|
11
|
+
|
|
12
|
+
a2z = A2zClient("a2z_...") # key optional
|
|
13
|
+
cert = a2z.ssl.certificate("example.com")
|
|
14
|
+
mx = a2z.dns.records("example.com", type="MX")
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
a2z.email.dmarc("example.com")
|
|
18
|
+
except A2zApiError as e:
|
|
19
|
+
print(e.status, e.code, e.retry_after)
|
|
20
|
+
|
|
21
|
+
print(a2z.last_rate_limit)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Without a key you get 10 calls a minute and 100 a day. A free key from <https://a2z.tools/developers> gives 60 a minute and 2,000 a day.
|
|
25
|
+
|
|
26
|
+
MIT licensed.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "a2ztools"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Free API client for website checks: SSL certificate expiry, DNS, WHOIS (RDAP), SPF, DKIM, DMARC, security headers, robots.txt, llms.txt and more."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "A2Z Tools", email = "app@a2z.tools" }]
|
|
13
|
+
keywords = ["ssl", "dns", "whois", "dmarc", "spf", "dkim", "security-headers", "website-monitoring", "seo", "api-client"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Internet :: Name Service (DNS)",
|
|
20
|
+
"Topic :: Security",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
dependencies = []
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://a2z.tools/developers"
|
|
27
|
+
Documentation = "https://a2z.tools/developers"
|
|
28
|
+
Source = "https://github.com/a2ztoolsdev-source/a2ztools"
|
|
29
|
+
Issues = "https://github.com/a2ztoolsdev-source/a2ztools/issues"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.version]
|
|
32
|
+
path = "src/a2ztools/_version.py"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["src/a2ztools"]
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
# Generated by scripts/generate.mjs from openapi/openapi.json - do not edit.
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Dict, Optional, TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
7
|
+
from .client import A2zClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SslGroup:
|
|
11
|
+
"""SSL / TLS: Certificates, expiry, protocols, HSTS, OCSP and certificate transparency."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, client: "A2zClient") -> None:
|
|
14
|
+
self._client = client
|
|
15
|
+
|
|
16
|
+
def certificate(self, host: str, port: Optional[int] = None) -> Dict[str, Any]:
|
|
17
|
+
"""Certificate details and expiry. Connects to the host and returns the certificate chain, issuer, subject alternative names, validity dates and days until expiry. Results are cached for 1800 seconds. Try it in a browser: https://a2z.tools/ssl-certificate-checker"""
|
|
18
|
+
return self._client.call("ssl", "certificate", {"host": host, "port": port})
|
|
19
|
+
|
|
20
|
+
def protocols(self, host: str, port: Optional[int] = None) -> Dict[str, Any]:
|
|
21
|
+
"""Supported TLS versions. Which TLS protocol versions the server accepts (TLS 1.0 to 1.3). Results are cached for 1800 seconds."""
|
|
22
|
+
return self._client.call("ssl", "protocols", {"host": host, "port": port})
|
|
23
|
+
|
|
24
|
+
def https(self, domain: str) -> Dict[str, Any]:
|
|
25
|
+
"""HTTPS availability. Whether the domain answers over HTTPS with a valid certificate. Results are cached for 1800 seconds."""
|
|
26
|
+
return self._client.call("ssl", "https", {"domain": domain})
|
|
27
|
+
|
|
28
|
+
def https_redirect(self, domain: str) -> Dict[str, Any]:
|
|
29
|
+
"""HTTP to HTTPS redirect. Whether plain HTTP redirects to HTTPS, and how. Results are cached for 1800 seconds."""
|
|
30
|
+
return self._client.call("ssl", "https-redirect", {"domain": domain})
|
|
31
|
+
|
|
32
|
+
def hsts(self, domain: str) -> Dict[str, Any]:
|
|
33
|
+
"""HSTS header. The Strict-Transport-Security header, parsed and checked. Results are cached for 1800 seconds."""
|
|
34
|
+
return self._client.call("ssl", "hsts", {"domain": domain})
|
|
35
|
+
|
|
36
|
+
def hsts_preload(self, domain: str) -> Dict[str, Any]:
|
|
37
|
+
"""HSTS preload eligibility. Whether the domain meets the hstspreload.org requirements. Results are cached for 1800 seconds."""
|
|
38
|
+
return self._client.call("ssl", "hsts-preload", {"domain": domain})
|
|
39
|
+
|
|
40
|
+
def ocsp(self, url: str) -> Dict[str, Any]:
|
|
41
|
+
"""OCSP status. Asks the certificate's OCSP responder whether it has been revoked. Results are cached for 1800 seconds."""
|
|
42
|
+
return self._client.call("ssl", "ocsp", {"url": url})
|
|
43
|
+
|
|
44
|
+
def crl(self, url: str) -> Dict[str, Any]:
|
|
45
|
+
"""CRL revocation check. Checks the certificate against its CRL distribution point. Results are cached for 1800 seconds."""
|
|
46
|
+
return self._client.call("ssl", "crl", {"url": url})
|
|
47
|
+
|
|
48
|
+
def ciphers(self, url: str) -> Dict[str, Any]:
|
|
49
|
+
"""Cipher suites. The cipher suites the server negotiates, graded. Results are cached for 1800 seconds."""
|
|
50
|
+
return self._client.call("ssl", "ciphers", {"url": url})
|
|
51
|
+
|
|
52
|
+
def certificate_transparency(self, url: str) -> Dict[str, Any]:
|
|
53
|
+
"""Certificate transparency. Certificates logged for the domain in public CT logs. Results are cached for 1800 seconds."""
|
|
54
|
+
return self._client.call("ssl", "certificate-transparency", {"url": url})
|
|
55
|
+
|
|
56
|
+
class DnsGroup:
|
|
57
|
+
"""DNS: Records, propagation across public resolvers, reverse DNS and DNSSEC."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, client: "A2zClient") -> None:
|
|
60
|
+
self._client = client
|
|
61
|
+
|
|
62
|
+
def records(self, domain: str, type: Optional[str] = None) -> Dict[str, Any]:
|
|
63
|
+
"""DNS records. A, AAAA, CNAME, MX, NS, TXT, SOA, CAA and more. Omit type to get all common types. Results are cached for 300 seconds. Try it in a browser: https://a2z.tools/dns-lookup"""
|
|
64
|
+
return self._client.call("dns", "records", {"domain": domain, "type": type})
|
|
65
|
+
|
|
66
|
+
def propagation(self, domain: str, type: Optional[str] = None) -> Dict[str, Any]:
|
|
67
|
+
"""DNS propagation. The answer from several public resolvers around the world, side by side. Counts as 2 calls. Results are cached for 300 seconds. Try it in a browser: https://a2z.tools/dns-propagation-checker"""
|
|
68
|
+
return self._client.call("dns", "propagation", {"domain": domain, "type": type})
|
|
69
|
+
|
|
70
|
+
def reverse(self, ip: str) -> Dict[str, Any]:
|
|
71
|
+
"""Reverse DNS (PTR). The PTR name for an IP address. Results are cached for 300 seconds."""
|
|
72
|
+
return self._client.call("dns", "reverse", {"ip": ip})
|
|
73
|
+
|
|
74
|
+
def dnssec(self, domain: str) -> Dict[str, Any]:
|
|
75
|
+
"""DNSSEC. Whether the zone is signed and the chain of trust validates. Results are cached for 300 seconds."""
|
|
76
|
+
return self._client.call("dns", "dnssec", {"domain": domain})
|
|
77
|
+
|
|
78
|
+
class EmailGroup:
|
|
79
|
+
"""Email authentication: SPF, DKIM, DMARC, BIMI, MTA-STS and TLS-RPT, separately or as one graded check."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, client: "A2zClient") -> None:
|
|
82
|
+
self._client = client
|
|
83
|
+
|
|
84
|
+
def security(self, domain: str, dkim_selector: Optional[str] = None) -> Dict[str, Any]:
|
|
85
|
+
"""Email security grade. SPF, DKIM, DMARC, BIMI, MTA-STS and TLS-RPT in one graded report. Results are cached for 900 seconds. Try it in a browser: https://a2z.tools/email-security-checker"""
|
|
86
|
+
return self._client.call("email", "security", {"domain": domain, "dkimSelector": dkim_selector})
|
|
87
|
+
|
|
88
|
+
def spf(self, domain: str) -> Dict[str, Any]:
|
|
89
|
+
"""SPF record. The SPF record, parsed, with the DNS look-up count and problems. Results are cached for 900 seconds. Try it in a browser: https://a2z.tools/spf-checker"""
|
|
90
|
+
return self._client.call("email", "spf", {"domain": domain})
|
|
91
|
+
|
|
92
|
+
def dmarc(self, domain: str) -> Dict[str, Any]:
|
|
93
|
+
"""DMARC record. The DMARC policy, parsed, with reporting addresses and problems. Results are cached for 900 seconds. Try it in a browser: https://a2z.tools/dmarc-checker"""
|
|
94
|
+
return self._client.call("email", "dmarc", {"domain": domain})
|
|
95
|
+
|
|
96
|
+
def dkim(self, domain: str, selector: str) -> Dict[str, Any]:
|
|
97
|
+
"""DKIM record. The DKIM public key for a selector, parsed. Results are cached for 900 seconds. Try it in a browser: https://a2z.tools/dkim-checker"""
|
|
98
|
+
return self._client.call("email", "dkim", {"domain": domain, "selector": selector})
|
|
99
|
+
|
|
100
|
+
def bimi(self, domain: str, selector: Optional[str] = None) -> Dict[str, Any]:
|
|
101
|
+
"""BIMI record. The BIMI record and logo location. Results are cached for 900 seconds."""
|
|
102
|
+
return self._client.call("email", "bimi", {"domain": domain, "selector": selector})
|
|
103
|
+
|
|
104
|
+
def mta_sts(self, domain: str) -> Dict[str, Any]:
|
|
105
|
+
"""MTA-STS. The MTA-STS DNS record and policy file. Results are cached for 900 seconds."""
|
|
106
|
+
return self._client.call("email", "mta-sts", {"domain": domain})
|
|
107
|
+
|
|
108
|
+
def tls_rpt(self, domain: str) -> Dict[str, Any]:
|
|
109
|
+
"""TLS-RPT. The SMTP TLS reporting record. Results are cached for 900 seconds."""
|
|
110
|
+
return self._client.call("email", "tls-rpt", {"domain": domain})
|
|
111
|
+
|
|
112
|
+
class DomainGroup:
|
|
113
|
+
"""Domain & IP: WHOIS over RDAP, IP and ASN information, reverse IP."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, client: "A2zClient") -> None:
|
|
116
|
+
self._client = client
|
|
117
|
+
|
|
118
|
+
def whois(self, domain: str) -> Dict[str, Any]:
|
|
119
|
+
"""WHOIS (RDAP). Registrar, registration and expiry dates, status and name servers, from RDAP. Results are cached for 3600 seconds. Try it in a browser: https://a2z.tools/whois-lookup"""
|
|
120
|
+
return self._client.call("domain", "whois", {"domain": domain})
|
|
121
|
+
|
|
122
|
+
def ip(self, ip: str) -> Dict[str, Any]:
|
|
123
|
+
"""IP information. Network, owner and location information for an IP address. Results are cached for 3600 seconds."""
|
|
124
|
+
return self._client.call("domain", "ip", {"ip": ip})
|
|
125
|
+
|
|
126
|
+
def asn(self, asn: str) -> Dict[str, Any]:
|
|
127
|
+
"""ASN information. The organisation and prefixes of an autonomous system. Results are cached for 3600 seconds."""
|
|
128
|
+
return self._client.call("domain", "asn", {"asn": asn})
|
|
129
|
+
|
|
130
|
+
def reverse_ip(self, ip: str) -> Dict[str, Any]:
|
|
131
|
+
"""Reverse IP. Other host names seen on the same IP address. Results are cached for 3600 seconds."""
|
|
132
|
+
return self._client.call("domain", "reverse-ip", {"ip": ip})
|
|
133
|
+
|
|
134
|
+
class HttpGroup:
|
|
135
|
+
"""HTTP & headers: Status, redirects, response headers, security headers, CSP, CORS, cookies, caching and performance basics."""
|
|
136
|
+
|
|
137
|
+
def __init__(self, client: "A2zClient") -> None:
|
|
138
|
+
self._client = client
|
|
139
|
+
|
|
140
|
+
def headers(self, url: str) -> Dict[str, Any]:
|
|
141
|
+
"""Response headers. The response headers of a URL. Results are cached for 300 seconds. Try it in a browser: https://a2z.tools/http-headers-checker"""
|
|
142
|
+
return self._client.call("http", "headers", {"url": url})
|
|
143
|
+
|
|
144
|
+
def status(self, url: str) -> Dict[str, Any]:
|
|
145
|
+
"""HTTP status. The status code and response time of a URL. Results are cached for 300 seconds."""
|
|
146
|
+
return self._client.call("http", "status", {"url": url})
|
|
147
|
+
|
|
148
|
+
def redirects(self, url: str) -> Dict[str, Any]:
|
|
149
|
+
"""Redirect chain. Every hop from the URL to its final destination. Results are cached for 300 seconds."""
|
|
150
|
+
return self._client.call("http", "redirects", {"url": url})
|
|
151
|
+
|
|
152
|
+
def security_headers(self, url: str) -> Dict[str, Any]:
|
|
153
|
+
"""Security headers grade. HSTS, CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy and Permissions-Policy, graded. Results are cached for 300 seconds. Try it in a browser: https://a2z.tools/security-headers-checker"""
|
|
154
|
+
return self._client.call("http", "security-headers", {"url": url})
|
|
155
|
+
|
|
156
|
+
def csp(self, url: str) -> Dict[str, Any]:
|
|
157
|
+
"""Content-Security-Policy. The CSP header parsed directive by directive, with weaknesses. Results are cached for 300 seconds."""
|
|
158
|
+
return self._client.call("http", "csp", {"url": url})
|
|
159
|
+
|
|
160
|
+
def cors(self, url: str) -> Dict[str, Any]:
|
|
161
|
+
"""CORS configuration. The CORS headers a URL sends, with risky combinations flagged. Results are cached for 300 seconds."""
|
|
162
|
+
return self._client.call("http", "cors", {"url": url})
|
|
163
|
+
|
|
164
|
+
def cookies(self, url: str) -> Dict[str, Any]:
|
|
165
|
+
"""Cookies. Cookies set by the URL and their Secure, HttpOnly and SameSite flags. Results are cached for 300 seconds."""
|
|
166
|
+
return self._client.call("http", "cookies", {"url": url})
|
|
167
|
+
|
|
168
|
+
def methods(self, url: str) -> Dict[str, Any]:
|
|
169
|
+
"""Allowed HTTP methods. Which HTTP methods the server accepts. Results are cached for 300 seconds."""
|
|
170
|
+
return self._client.call("http", "methods", {"url": url})
|
|
171
|
+
|
|
172
|
+
def server_banner(self, url: str) -> Dict[str, Any]:
|
|
173
|
+
"""Server banner. Server software and version disclosed in headers. Results are cached for 300 seconds."""
|
|
174
|
+
return self._client.call("http", "server-banner", {"url": url})
|
|
175
|
+
|
|
176
|
+
def framing(self, url: str) -> Dict[str, Any]:
|
|
177
|
+
"""Clickjacking protection. Whether the page can be framed by other sites. Results are cached for 300 seconds."""
|
|
178
|
+
return self._client.call("http", "framing", {"url": url})
|
|
179
|
+
|
|
180
|
+
def cross_origin(self, url: str) -> Dict[str, Any]:
|
|
181
|
+
"""Cross-origin headers. COOP, COEP and CORP headers. Results are cached for 300 seconds."""
|
|
182
|
+
return self._client.call("http", "cross-origin", {"url": url})
|
|
183
|
+
|
|
184
|
+
def cross_origin_isolation(self, url: str) -> Dict[str, Any]:
|
|
185
|
+
"""Cross-origin isolation. Whether the page is cross-origin isolated. Results are cached for 300 seconds."""
|
|
186
|
+
return self._client.call("http", "cross-origin-isolation", {"url": url})
|
|
187
|
+
|
|
188
|
+
def permissions_policy(self, url: str) -> Dict[str, Any]:
|
|
189
|
+
"""Permissions-Policy. The Permissions-Policy header, parsed. Results are cached for 300 seconds."""
|
|
190
|
+
return self._client.call("http", "permissions-policy", {"url": url})
|
|
191
|
+
|
|
192
|
+
def referrer_policy(self, url: str) -> Dict[str, Any]:
|
|
193
|
+
"""Referrer-Policy. The Referrer-Policy in effect. Results are cached for 300 seconds."""
|
|
194
|
+
return self._client.call("http", "referrer-policy", {"url": url})
|
|
195
|
+
|
|
196
|
+
def compression(self, url: str) -> Dict[str, Any]:
|
|
197
|
+
"""Compression. Whether gzip, Brotli or zstd is served. Results are cached for 300 seconds."""
|
|
198
|
+
return self._client.call("http", "compression", {"url": url})
|
|
199
|
+
|
|
200
|
+
def cache_control(self, url: str) -> Dict[str, Any]:
|
|
201
|
+
"""Caching headers. Cache-Control, ETag, Last-Modified and Expires, explained. Results are cached for 300 seconds."""
|
|
202
|
+
return self._client.call("http", "cache-control", {"url": url})
|
|
203
|
+
|
|
204
|
+
def protocol(self, url: str) -> Dict[str, Any]:
|
|
205
|
+
"""HTTP version. HTTP/1.1, HTTP/2 or HTTP/3 support. Results are cached for 300 seconds."""
|
|
206
|
+
return self._client.call("http", "protocol", {"url": url})
|
|
207
|
+
|
|
208
|
+
def ttfb(self, url: str) -> Dict[str, Any]:
|
|
209
|
+
"""Time to first byte. DNS, connect, TLS and first-byte timings from our server. Results are cached for 60 seconds."""
|
|
210
|
+
return self._client.call("http", "ttfb", {"url": url})
|
|
211
|
+
|
|
212
|
+
def cdn(self, domain: str) -> Dict[str, Any]:
|
|
213
|
+
"""CDN detection. Which CDN serves the domain, if any. Results are cached for 300 seconds."""
|
|
214
|
+
return self._client.call("http", "cdn", {"domain": domain})
|
|
215
|
+
|
|
216
|
+
def static_assets(self, url: str) -> Dict[str, Any]:
|
|
217
|
+
"""Static asset audit. Size, caching and compression of the page's scripts, styles and images. Results are cached for 300 seconds."""
|
|
218
|
+
return self._client.call("http", "static-assets", {"url": url})
|
|
219
|
+
|
|
220
|
+
class SeoGroup:
|
|
221
|
+
"""SEO & crawlers: Open Graph, canonical, hreflang, structured data, robots.txt, sitemaps, llms.txt, AI crawler access and ads.txt."""
|
|
222
|
+
|
|
223
|
+
def __init__(self, client: "A2zClient") -> None:
|
|
224
|
+
self._client = client
|
|
225
|
+
|
|
226
|
+
def open_graph(self, url: str) -> Dict[str, Any]:
|
|
227
|
+
"""Open Graph & Twitter cards. og: and twitter: tags, with missing or broken values flagged. Results are cached for 600 seconds."""
|
|
228
|
+
return self._client.call("seo", "open-graph", {"url": url})
|
|
229
|
+
|
|
230
|
+
def canonical(self, url: str) -> Dict[str, Any]:
|
|
231
|
+
"""Canonical URL. The canonical link and whether it agrees with the URL. Results are cached for 600 seconds."""
|
|
232
|
+
return self._client.call("seo", "canonical", {"url": url})
|
|
233
|
+
|
|
234
|
+
def hreflang(self, url: str) -> Dict[str, Any]:
|
|
235
|
+
"""hreflang. hreflang alternates and reciprocal-link problems. Results are cached for 600 seconds."""
|
|
236
|
+
return self._client.call("seo", "hreflang", {"url": url})
|
|
237
|
+
|
|
238
|
+
def schema(self, url: str) -> Dict[str, Any]:
|
|
239
|
+
"""Structured data. JSON-LD, Microdata and RDFa found on the page, validated. Results are cached for 600 seconds."""
|
|
240
|
+
return self._client.call("seo", "schema", {"url": url})
|
|
241
|
+
|
|
242
|
+
def mixed_content(self, url: str) -> Dict[str, Any]:
|
|
243
|
+
"""Mixed content. HTTP resources loaded by an HTTPS page. Results are cached for 600 seconds."""
|
|
244
|
+
return self._client.call("seo", "mixed-content", {"url": url})
|
|
245
|
+
|
|
246
|
+
def technology(self, url: str) -> Dict[str, Any]:
|
|
247
|
+
"""Technology detection. CMS, frameworks, analytics and hosting the page uses. Results are cached for 600 seconds."""
|
|
248
|
+
return self._client.call("seo", "technology", {"url": url})
|
|
249
|
+
|
|
250
|
+
def robots_txt(self, domain: str) -> Dict[str, Any]:
|
|
251
|
+
"""robots.txt. The robots.txt file, parsed by user agent. Results are cached for 600 seconds. Try it in a browser: https://a2z.tools/robots-txt-checker"""
|
|
252
|
+
return self._client.call("seo", "robots-txt", {"domain": domain})
|
|
253
|
+
|
|
254
|
+
def sitemap(self, url: str) -> Dict[str, Any]:
|
|
255
|
+
"""XML sitemap. Fetches and validates an XML sitemap or sitemap index. Results are cached for 600 seconds."""
|
|
256
|
+
return self._client.call("seo", "sitemap", {"url": url})
|
|
257
|
+
|
|
258
|
+
def llms_txt(self, domain: str) -> Dict[str, Any]:
|
|
259
|
+
"""llms.txt. Whether the site publishes /llms.txt, and whether it is well formed. Results are cached for 600 seconds. Try it in a browser: https://a2z.tools/llms-txt-checker"""
|
|
260
|
+
return self._client.call("seo", "llms-txt", {"domain": domain})
|
|
261
|
+
|
|
262
|
+
def ai_crawlers(self, domain: str) -> Dict[str, Any]:
|
|
263
|
+
"""AI crawler access. Which AI crawlers (GPTBot, ClaudeBot, PerplexityBot, Google-Extended and others) robots.txt allows. Results are cached for 600 seconds. Try it in a browser: https://a2z.tools/ai-crawler-access-checker"""
|
|
264
|
+
return self._client.call("seo", "ai-crawlers", {"domain": domain})
|
|
265
|
+
|
|
266
|
+
def ads_txt(self, domain: str) -> Dict[str, Any]:
|
|
267
|
+
"""ads.txt. The ads.txt file, parsed and validated. Results are cached for 600 seconds. Try it in a browser: https://a2z.tools/ads-txt-checker"""
|
|
268
|
+
return self._client.call("seo", "ads-txt", {"domain": domain})
|
|
269
|
+
|
|
270
|
+
def app_ads_txt(self, domain: str) -> Dict[str, Any]:
|
|
271
|
+
"""app-ads.txt. The app-ads.txt file, parsed and validated. Results are cached for 600 seconds."""
|
|
272
|
+
return self._client.call("seo", "app-ads-txt", {"domain": domain})
|
|
273
|
+
|
|
274
|
+
class SiteGroup:
|
|
275
|
+
"""Site health: One-call health summaries, PWA manifest, favicon, security.txt and front-end dependency checks."""
|
|
276
|
+
|
|
277
|
+
def __init__(self, client: "A2zClient") -> None:
|
|
278
|
+
self._client = client
|
|
279
|
+
|
|
280
|
+
def health(self, domain: str) -> Dict[str, Any]:
|
|
281
|
+
"""Website health summary. DNS, HTTPS, certificate expiry, security headers and email authentication in one call. Counts as 3 calls. Counts as 3 calls. Results are cached for 900 seconds. Try it in a browser: https://a2z.tools/website-health-checker"""
|
|
282
|
+
return self._client.call("site", "health", {"domain": domain})
|
|
283
|
+
|
|
284
|
+
def audit(self, domain: str) -> Dict[str, Any]:
|
|
285
|
+
"""Website security audit. The full security audit behind the A2Z website audit tool. Counts as 5 calls. Counts as 5 calls. Results are cached for 900 seconds."""
|
|
286
|
+
return self._client.call("site", "audit", {"domain": domain})
|
|
287
|
+
|
|
288
|
+
def security_txt(self, domain: str) -> Dict[str, Any]:
|
|
289
|
+
"""security.txt. The /.well-known/security.txt file, parsed and validated. Results are cached for 900 seconds."""
|
|
290
|
+
return self._client.call("site", "security-txt", {"domain": domain})
|
|
291
|
+
|
|
292
|
+
def manifest(self, url: str) -> Dict[str, Any]:
|
|
293
|
+
"""Web app manifest. The PWA manifest, validated for installability. Results are cached for 900 seconds."""
|
|
294
|
+
return self._client.call("site", "manifest", {"url": url})
|
|
295
|
+
|
|
296
|
+
def service_worker(self, url: str) -> Dict[str, Any]:
|
|
297
|
+
"""Service worker. Whether the page registers a service worker. Results are cached for 900 seconds."""
|
|
298
|
+
return self._client.call("site", "service-worker", {"url": url})
|
|
299
|
+
|
|
300
|
+
def favicon(self, url: str) -> Dict[str, Any]:
|
|
301
|
+
"""Favicons. Every favicon and touch icon the page declares, with sizes. Results are cached for 900 seconds."""
|
|
302
|
+
return self._client.call("site", "favicon", {"url": url})
|
|
303
|
+
|
|
304
|
+
def third_party_scripts(self, url: str) -> Dict[str, Any]:
|
|
305
|
+
"""Third-party scripts. Scripts loaded from other domains, grouped by owner. Results are cached for 900 seconds."""
|
|
306
|
+
return self._client.call("site", "third-party-scripts", {"url": url})
|
|
307
|
+
|
|
308
|
+
def external_resources(self, url: str) -> Dict[str, Any]:
|
|
309
|
+
"""External resources. Every external resource the page loads. Results are cached for 900 seconds."""
|
|
310
|
+
return self._client.call("site", "external-resources", {"url": url})
|
|
311
|
+
|
|
312
|
+
def source_maps(self, url: str) -> Dict[str, Any]:
|
|
313
|
+
"""Exposed source maps. Publicly reachable JavaScript source maps. Results are cached for 900 seconds."""
|
|
314
|
+
return self._client.call("site", "source-maps", {"url": url})
|
|
315
|
+
|
|
316
|
+
def dependencies(self, url: str) -> Dict[str, Any]:
|
|
317
|
+
"""Front-end dependencies. JavaScript libraries and versions detected on the page. Results are cached for 900 seconds."""
|
|
318
|
+
return self._client.call("site", "dependencies", {"url": url})
|
|
319
|
+
|
|
320
|
+
def vulnerable_libraries(self, url: str) -> Dict[str, Any]:
|
|
321
|
+
"""Vulnerable libraries. Detected libraries with known vulnerabilities. Results are cached for 900 seconds."""
|
|
322
|
+
return self._client.call("site", "vulnerable-libraries", {"url": url})
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
GROUPS = {"ssl": SslGroup, "dns": DnsGroup, "email": EmailGroup, "domain": DomainGroup, "http": HttpGroup, "seo": SeoGroup, "site": SiteGroup}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Client for the A2Z Tools public API (https://a2z.tools/developers). Standard library only."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import urllib.request
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Any, Dict, Mapping, Optional
|
|
11
|
+
|
|
12
|
+
from . import _endpoints
|
|
13
|
+
from ._version import __version__
|
|
14
|
+
|
|
15
|
+
DEFAULT_BASE_URL = "https://a2z.tools/public-api/v1"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class A2zApiError(Exception):
|
|
19
|
+
"""An error answer from the API (4xx/5xx), or a request that never got one."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str, status: int = 0, code: Optional[str] = None,
|
|
22
|
+
retry_after: Optional[int] = None, body: Any = None) -> None:
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
#: HTTP status, or 0 when there was no answer.
|
|
25
|
+
self.status = status
|
|
26
|
+
#: missing_parameter, invalid_key, key_suspended, key_revoked, key_required,
|
|
27
|
+
#: rate_limited, daily_quota, target_rate_limited, api_disabled, network_error
|
|
28
|
+
self.code = code
|
|
29
|
+
#: Seconds to wait before retrying (429 / 503).
|
|
30
|
+
self.retry_after = retry_after
|
|
31
|
+
self.body = body
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class RateLimit:
|
|
36
|
+
limit: int
|
|
37
|
+
remaining: Optional[int]
|
|
38
|
+
#: Next reset (00:00 UTC).
|
|
39
|
+
reset: Optional[datetime]
|
|
40
|
+
#: True when the answer came from the API's cache.
|
|
41
|
+
cached: bool
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _int(value: Optional[str]) -> Optional[int]:
|
|
45
|
+
try:
|
|
46
|
+
return int(value) if value not in (None, "") else None
|
|
47
|
+
except ValueError:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class A2zClient:
|
|
52
|
+
"""Free website, DNS, SSL, email-authentication and SEO checks.
|
|
53
|
+
|
|
54
|
+
>>> client = A2zClient(api_key="a2z_...") # the key is optional
|
|
55
|
+
>>> client.ssl.certificate(host="example.com")["result"]["daysRemaining"]
|
|
56
|
+
|
|
57
|
+
Each check returns the decoded JSON answer. ``ok`` is False when the check ran but
|
|
58
|
+
found a problem (for example no record); API errors raise :class:`A2zApiError`.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, api_key: Optional[str] = None, *, site: Optional[str] = None,
|
|
62
|
+
base_url: str = DEFAULT_BASE_URL, timeout: float = 30.0, opener: Any = None) -> None:
|
|
63
|
+
self._api_key = api_key
|
|
64
|
+
self._site_host = site
|
|
65
|
+
self._base_url = base_url.rstrip("/")
|
|
66
|
+
self._timeout = timeout
|
|
67
|
+
self._opener = opener or urllib.request.build_opener()
|
|
68
|
+
#: Limits from the most recent response.
|
|
69
|
+
self.last_rate_limit: Optional[RateLimit] = None
|
|
70
|
+
for name, cls in _endpoints.GROUPS.items():
|
|
71
|
+
setattr(self, name, cls(self))
|
|
72
|
+
|
|
73
|
+
def call(self, group: str, name: str, params: Optional[Mapping[str, Any]] = None) -> Dict[str, Any]:
|
|
74
|
+
"""Calls any endpoint by group and name, e.g. ``call("ssl", "certificate", {"host": "example.com"})``."""
|
|
75
|
+
query = {k: str(v) for k, v in (params or {}).items() if v is not None and v != ""}
|
|
76
|
+
url = f"{self._base_url}/{group}/{name}"
|
|
77
|
+
if query:
|
|
78
|
+
url += "?" + urllib.parse.urlencode(query)
|
|
79
|
+
|
|
80
|
+
headers = {"Accept": "application/json", "User-Agent": f"a2ztools-python/{__version__}"}
|
|
81
|
+
if self._api_key:
|
|
82
|
+
headers["X-API-Key"] = self._api_key
|
|
83
|
+
if self._site_host:
|
|
84
|
+
headers["X-A2Z-Site"] = self._site_host
|
|
85
|
+
|
|
86
|
+
request = urllib.request.Request(url, headers=headers, method="GET")
|
|
87
|
+
try:
|
|
88
|
+
response = self._opener.open(request, timeout=self._timeout)
|
|
89
|
+
status, resp_headers, raw = response.status, response.headers, response.read()
|
|
90
|
+
except urllib.error.HTTPError as err:
|
|
91
|
+
status, resp_headers, raw = err.code, err.headers, err.read()
|
|
92
|
+
except (urllib.error.URLError, OSError) as err:
|
|
93
|
+
raise A2zApiError(f"Request to {group}/{name} failed: {err}", 0, "network_error") from err
|
|
94
|
+
|
|
95
|
+
self.last_rate_limit = self._rate_limit(resp_headers) or self.last_rate_limit
|
|
96
|
+
try:
|
|
97
|
+
body = json.loads(raw.decode("utf-8")) if raw else None
|
|
98
|
+
except ValueError:
|
|
99
|
+
body = None
|
|
100
|
+
|
|
101
|
+
if status >= 400:
|
|
102
|
+
message = body.get("error") if isinstance(body, dict) else None
|
|
103
|
+
code = body.get("code") if isinstance(body, dict) else None
|
|
104
|
+
raise A2zApiError(message or f"HTTP {status}", status, code, _int(resp_headers.get("Retry-After")), body)
|
|
105
|
+
return body
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def _rate_limit(headers: Any) -> Optional[RateLimit]:
|
|
109
|
+
limit = _int(headers.get("X-RateLimit-Limit"))
|
|
110
|
+
if limit is None:
|
|
111
|
+
return None
|
|
112
|
+
reset = _int(headers.get("X-RateLimit-Reset"))
|
|
113
|
+
return RateLimit(
|
|
114
|
+
limit=limit,
|
|
115
|
+
remaining=_int(headers.get("X-RateLimit-Remaining")),
|
|
116
|
+
reset=datetime.fromtimestamp(reset, tz=timezone.utc) if reset is not None else None,
|
|
117
|
+
cached=headers.get("X-A2Z-Cache") == "HIT",
|
|
118
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
import urllib.error
|
|
4
|
+
from email.message import Message
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from a2ztools import A2zApiError, A2zClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FakeResponse:
|
|
12
|
+
def __init__(self, status, body, headers):
|
|
13
|
+
self.status = status
|
|
14
|
+
self.headers = headers
|
|
15
|
+
self._body = body
|
|
16
|
+
|
|
17
|
+
def read(self):
|
|
18
|
+
return self._body
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def make_headers(values):
|
|
22
|
+
m = Message()
|
|
23
|
+
for k, v in values.items():
|
|
24
|
+
m[k] = v
|
|
25
|
+
return m
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FakeOpener:
|
|
29
|
+
def __init__(self, status=200, body=None, headers=None):
|
|
30
|
+
self.status = status
|
|
31
|
+
self.body = json.dumps(body if body is not None else {"ok": True}).encode()
|
|
32
|
+
self.headers = make_headers(headers or {})
|
|
33
|
+
self.requests = []
|
|
34
|
+
|
|
35
|
+
def open(self, request, timeout=None):
|
|
36
|
+
self.requests.append(request)
|
|
37
|
+
if self.status >= 400:
|
|
38
|
+
raise urllib.error.HTTPError(request.full_url, self.status, "error", self.headers, io.BytesIO(self.body))
|
|
39
|
+
return FakeResponse(self.status, self.body, self.headers)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_every_group_is_attached():
|
|
43
|
+
c = A2zClient(opener=FakeOpener())
|
|
44
|
+
for group in ("ssl", "dns", "email", "domain", "http", "seo", "site"):
|
|
45
|
+
assert hasattr(c, group)
|
|
46
|
+
assert callable(c.site.health)
|
|
47
|
+
assert callable(c.email.security)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_url_headers_and_rate_limit():
|
|
51
|
+
o = FakeOpener(body={"ok": True, "domain": "example.com"}, headers={
|
|
52
|
+
"X-RateLimit-Limit": "2000", "X-RateLimit-Remaining": "1999",
|
|
53
|
+
"X-RateLimit-Reset": "1793232000", "X-A2Z-Cache": "HIT"})
|
|
54
|
+
c = A2zClient("a2z_test", site="shop.example", opener=o)
|
|
55
|
+
r = c.dns.records("example.com", type="MX")
|
|
56
|
+
assert r["domain"] == "example.com"
|
|
57
|
+
req = o.requests[0]
|
|
58
|
+
assert req.full_url == "https://a2z.tools/public-api/v1/dns/records?domain=example.com&type=MX"
|
|
59
|
+
assert req.get_header("X-api-key") == "a2z_test"
|
|
60
|
+
assert req.get_header("X-a2z-site") == "shop.example"
|
|
61
|
+
assert c.last_rate_limit.limit == 2000
|
|
62
|
+
assert c.last_rate_limit.remaining == 1999
|
|
63
|
+
assert c.last_rate_limit.cached is True
|
|
64
|
+
assert c.last_rate_limit.reset.isoformat() == "2026-10-29T00:00:00+00:00"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_optional_parameters_are_left_out_and_snake_case_maps_back():
|
|
68
|
+
o = FakeOpener()
|
|
69
|
+
c = A2zClient(opener=o, base_url="http://localhost:1/public-api/v1/")
|
|
70
|
+
c.ssl.certificate("a2z.tools")
|
|
71
|
+
c.email.security("google.com", dkim_selector="google")
|
|
72
|
+
assert o.requests[0].full_url == "http://localhost:1/public-api/v1/ssl/certificate?host=a2z.tools"
|
|
73
|
+
assert o.requests[1].full_url.endswith("/email/security?domain=google.com&dkimSelector=google")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_errors_raise_with_code_and_retry_after():
|
|
77
|
+
o = FakeOpener(status=429, body={"ok": False, "code": "rate_limited", "error": "Too many requests"}, headers={"Retry-After": "17"})
|
|
78
|
+
c = A2zClient(opener=o)
|
|
79
|
+
with pytest.raises(A2zApiError) as e:
|
|
80
|
+
c.seo.llms_txt("x.com")
|
|
81
|
+
assert e.value.status == 429
|
|
82
|
+
assert e.value.code == "rate_limited"
|
|
83
|
+
assert e.value.retry_after == 17
|
|
84
|
+
assert str(e.value) == "Too many requests"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_a_check_that_found_nothing_is_not_an_error():
|
|
88
|
+
c = A2zClient(opener=FakeOpener(body={"ok": False, "error": "Not found (HTTP 404)"}))
|
|
89
|
+
assert c.seo.llms_txt("example.com")["ok"] is False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_network_errors():
|
|
93
|
+
class Broken:
|
|
94
|
+
def open(self, request, timeout=None):
|
|
95
|
+
raise urllib.error.URLError("no route")
|
|
96
|
+
|
|
97
|
+
with pytest.raises(A2zApiError) as e:
|
|
98
|
+
A2zClient(opener=Broken()).http.status("https://x.com")
|
|
99
|
+
assert e.value.code == "network_error"
|
|
100
|
+
assert e.value.status == 0
|