seolyticize 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Seolyticize
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,118 @@
1
+ Metadata-Version: 2.4
2
+ Name: seolyticize
3
+ Version: 0.4.0
4
+ Summary: Seolyticize for Python: every API tool as a method, and on-site helpers for analytics, IndexNow and deploys.
5
+ License: MIT
6
+ Project-URL: Homepage, https://seolyticize.com
7
+ Project-URL: Documentation, https://seolyticize.com/docs/python-sdk
8
+ Project-URL: Source, https://github.com/Seolyticize/sdk-python
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Internet :: WWW/HTTP :: Site Management
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Dynamic: license-file
16
+
17
+ # Seolyticize for Python
18
+
19
+ Every [Seolyticize](https://seolyticize.com) API tool (about 500 in 0.4.0) as a method, and on-site helpers for analytics, IndexNow and deploys. Standard library only, Python 3.9+.
20
+
21
+ ```sh
22
+ pip install "git+https://github.com/Seolyticize/sdk-python"
23
+ ```
24
+
25
+ ## Quick start
26
+
27
+ ```python
28
+ from seolyticize import Seolyticize
29
+
30
+ client = Seolyticize() # reads SEOLYTICIZE_API_KEY
31
+
32
+ # Onboard a site: one call returns everything to deploy.
33
+ manifest = client.onboard_site(domain="example.com", framework="html")
34
+ # …deploy manifest["files"] and the analytics snippet, then:
35
+ checklist = client.verify_site(client_id=manifest["clientId"])
36
+
37
+ # The weekly loop.
38
+ for item in client.get_next_steps(client_id=manifest["clientId"])["weekly"]:
39
+ print(item["tool"], item["why"])
40
+ ```
41
+
42
+ Paid research (any domain, the AI answer index, Maps, AI Mode beside rank) takes `max_cost_micros`. Quote the exact call first:
43
+
44
+ ```python
45
+ args = {"clientId": client_id, "target": "rival.com/blog/", "change": "new"}
46
+ quote = client.estimate_cost(tool="get_ranked_keywords", arguments=args)
47
+ ranked = client.get_ranked_keywords(
48
+ client_id=client_id, target="rival.com/blog/", change="new",
49
+ max_cost_micros=quote["estimatedMaxMicros"],
50
+ )
51
+
52
+ # Free reads over stored data:
53
+ pages = client.get_landing_pages(client_id=client_id, tracker_id=tracker_id)
54
+ grid = client.get_local_visibility(client_id=client_id, location_id=location_id, term="emergency plumber")
55
+ redirects = client.plan_redirect_map(client_id=client_id, old_crawl_id=old_crawl_id, format_="csv")
56
+ ```
57
+
58
+ Arguments are keyword-only and snake_case (`client_id`, `crawl_id`); Python keywords get an underscore (`from_`). Tools newer than this package are reachable with `client.call("tool_name", {"camelCase": "input"})`. `client.list_tools(group="onboarding")` lists what your key may call, with schemas.
59
+
60
+ ## Errors
61
+
62
+ ```python
63
+ from seolyticize import RefusedError, RateLimitError
64
+
65
+ try:
66
+ client.connect_search_console_service_account(client_id="client_…")
67
+ except RefusedError as error:
68
+ print(error.code) # "search_console_no_access" — branch on this
69
+ print(error.next) # what to do next
70
+ print(error.request_id) # quote it when asking for help
71
+ ```
72
+
73
+ `ValidationError` (400), `AuthenticationError` (401), `PermissionError` (403), `NotFoundError` (404), `RefusedError` (422), `RateLimitError` (429), `ServerError` (5xx), `NetworkError` — all subclasses of `SeolyticizeError`. 429s are retried after `Retry-After`; server and network errors are retried only for read-only tools, since a write that failed may already have happened.
74
+
75
+ ## On your site
76
+
77
+ ```python
78
+ from seolyticize.site import analytics_tag, indexnow_key_response, notify_deploy, track_event
79
+
80
+ analytics_tag("SITE_KEY", exclude=["/account/*", "/verify"]) # the <script> for your <head>
81
+ body, headers = indexnow_key_response("KEY") # serve at /KEY.txt
82
+ notify_deploy("https://seolyticize.com/api/v1/hooks/deploy/dh_…") # after each deploy
83
+ track_event("SITE_KEY", "https://example.com/pricing", name="Signup") # server-side event
84
+ ```
85
+
86
+ ### Server-side collection
87
+
88
+ Reports page views from the server — above all the AI assistants (ChatGPT-User, Claude-User, Perplexity-User) that read pages without running the browser tag. Needs the server key from `issue_server_collect_key`; keep it in the environment.
89
+
90
+ ```python
91
+ import os
92
+ from seolyticize.collect import ASGICollector, Collector, WSGICollector, django_middleware
93
+
94
+ collector = Collector("SITE_KEY", os.environ["SEOLYTICIZE_SERVER_KEY"],
95
+ exclude=["/admin/*", "/api/*"],
96
+ only="agents") # with the browser tag installed too; "all" without it
97
+
98
+ app.wsgi_app = WSGICollector(app.wsgi_app, collector) # Flask
99
+ app = ASGICollector(app, collector) # FastAPI / Starlette
100
+ SeolyticizeMiddleware = django_middleware(collector) # Django: add to MIDDLEWARE
101
+ ```
102
+
103
+ Only the path and `utm_*` parameters (change with `allow_params`), the referrer's origin, the visitor's address, user agent and country are sent; Seolyticize hashes the address and user agent under a daily salt and stores neither. Bots are dropped before anything is sent. Reports go out on a background thread and never raise.
104
+
105
+ To credit orders to the visit they came from, send a reference from the checkout — the tag's `seolyticize('event', 'Checkout', {ref: cartId})`, or `collector.track(checkout_url, name="Checkout", props={"ref": cart_id}, user_agent=…, ip=…)` with the visitor's own agent and address so it joins their visit — and put the same value in the order's metadata as `seolyticize_ref` (Stripe metadata, a Shopify cart attribute, Paddle `custom_data`).
106
+
107
+ A conversion that happens after the visit — a payment confirmed by webhook, a trial that converts next week — is credited to the visit by its token. Ask for it while the visitor is there and keep it on your server with the account (never in a cookie or the page); the token lasts 35 days:
108
+
109
+ ```python
110
+ visit = collector.track_for_visit(request.url, "Signup", user_agent=ua, ip=ip) # returns the token
111
+ account.seolyticize_visit = visit
112
+ # ...days later, from the payment webhook:
113
+ collector.track_later(account.seolyticize_visit, "Top-up", props={"value": 25})
114
+ ```
115
+
116
+ Docs: [seolyticize.com/docs/python-sdk](https://seolyticize.com/docs/python-sdk) · Agent quick start: [seolyticize.com/AGENTS.md](https://seolyticize.com/AGENTS.md)
117
+
118
+ The tool methods are generated from the same registry that runs the API. License: MIT.
@@ -0,0 +1,102 @@
1
+ # Seolyticize for Python
2
+
3
+ Every [Seolyticize](https://seolyticize.com) API tool (about 500 in 0.4.0) as a method, and on-site helpers for analytics, IndexNow and deploys. Standard library only, Python 3.9+.
4
+
5
+ ```sh
6
+ pip install "git+https://github.com/Seolyticize/sdk-python"
7
+ ```
8
+
9
+ ## Quick start
10
+
11
+ ```python
12
+ from seolyticize import Seolyticize
13
+
14
+ client = Seolyticize() # reads SEOLYTICIZE_API_KEY
15
+
16
+ # Onboard a site: one call returns everything to deploy.
17
+ manifest = client.onboard_site(domain="example.com", framework="html")
18
+ # …deploy manifest["files"] and the analytics snippet, then:
19
+ checklist = client.verify_site(client_id=manifest["clientId"])
20
+
21
+ # The weekly loop.
22
+ for item in client.get_next_steps(client_id=manifest["clientId"])["weekly"]:
23
+ print(item["tool"], item["why"])
24
+ ```
25
+
26
+ Paid research (any domain, the AI answer index, Maps, AI Mode beside rank) takes `max_cost_micros`. Quote the exact call first:
27
+
28
+ ```python
29
+ args = {"clientId": client_id, "target": "rival.com/blog/", "change": "new"}
30
+ quote = client.estimate_cost(tool="get_ranked_keywords", arguments=args)
31
+ ranked = client.get_ranked_keywords(
32
+ client_id=client_id, target="rival.com/blog/", change="new",
33
+ max_cost_micros=quote["estimatedMaxMicros"],
34
+ )
35
+
36
+ # Free reads over stored data:
37
+ pages = client.get_landing_pages(client_id=client_id, tracker_id=tracker_id)
38
+ grid = client.get_local_visibility(client_id=client_id, location_id=location_id, term="emergency plumber")
39
+ redirects = client.plan_redirect_map(client_id=client_id, old_crawl_id=old_crawl_id, format_="csv")
40
+ ```
41
+
42
+ Arguments are keyword-only and snake_case (`client_id`, `crawl_id`); Python keywords get an underscore (`from_`). Tools newer than this package are reachable with `client.call("tool_name", {"camelCase": "input"})`. `client.list_tools(group="onboarding")` lists what your key may call, with schemas.
43
+
44
+ ## Errors
45
+
46
+ ```python
47
+ from seolyticize import RefusedError, RateLimitError
48
+
49
+ try:
50
+ client.connect_search_console_service_account(client_id="client_…")
51
+ except RefusedError as error:
52
+ print(error.code) # "search_console_no_access" — branch on this
53
+ print(error.next) # what to do next
54
+ print(error.request_id) # quote it when asking for help
55
+ ```
56
+
57
+ `ValidationError` (400), `AuthenticationError` (401), `PermissionError` (403), `NotFoundError` (404), `RefusedError` (422), `RateLimitError` (429), `ServerError` (5xx), `NetworkError` — all subclasses of `SeolyticizeError`. 429s are retried after `Retry-After`; server and network errors are retried only for read-only tools, since a write that failed may already have happened.
58
+
59
+ ## On your site
60
+
61
+ ```python
62
+ from seolyticize.site import analytics_tag, indexnow_key_response, notify_deploy, track_event
63
+
64
+ analytics_tag("SITE_KEY", exclude=["/account/*", "/verify"]) # the <script> for your <head>
65
+ body, headers = indexnow_key_response("KEY") # serve at /KEY.txt
66
+ notify_deploy("https://seolyticize.com/api/v1/hooks/deploy/dh_…") # after each deploy
67
+ track_event("SITE_KEY", "https://example.com/pricing", name="Signup") # server-side event
68
+ ```
69
+
70
+ ### Server-side collection
71
+
72
+ Reports page views from the server — above all the AI assistants (ChatGPT-User, Claude-User, Perplexity-User) that read pages without running the browser tag. Needs the server key from `issue_server_collect_key`; keep it in the environment.
73
+
74
+ ```python
75
+ import os
76
+ from seolyticize.collect import ASGICollector, Collector, WSGICollector, django_middleware
77
+
78
+ collector = Collector("SITE_KEY", os.environ["SEOLYTICIZE_SERVER_KEY"],
79
+ exclude=["/admin/*", "/api/*"],
80
+ only="agents") # with the browser tag installed too; "all" without it
81
+
82
+ app.wsgi_app = WSGICollector(app.wsgi_app, collector) # Flask
83
+ app = ASGICollector(app, collector) # FastAPI / Starlette
84
+ SeolyticizeMiddleware = django_middleware(collector) # Django: add to MIDDLEWARE
85
+ ```
86
+
87
+ Only the path and `utm_*` parameters (change with `allow_params`), the referrer's origin, the visitor's address, user agent and country are sent; Seolyticize hashes the address and user agent under a daily salt and stores neither. Bots are dropped before anything is sent. Reports go out on a background thread and never raise.
88
+
89
+ To credit orders to the visit they came from, send a reference from the checkout — the tag's `seolyticize('event', 'Checkout', {ref: cartId})`, or `collector.track(checkout_url, name="Checkout", props={"ref": cart_id}, user_agent=…, ip=…)` with the visitor's own agent and address so it joins their visit — and put the same value in the order's metadata as `seolyticize_ref` (Stripe metadata, a Shopify cart attribute, Paddle `custom_data`).
90
+
91
+ A conversion that happens after the visit — a payment confirmed by webhook, a trial that converts next week — is credited to the visit by its token. Ask for it while the visitor is there and keep it on your server with the account (never in a cookie or the page); the token lasts 35 days:
92
+
93
+ ```python
94
+ visit = collector.track_for_visit(request.url, "Signup", user_agent=ua, ip=ip) # returns the token
95
+ account.seolyticize_visit = visit
96
+ # ...days later, from the payment webhook:
97
+ collector.track_later(account.seolyticize_visit, "Top-up", props={"value": 25})
98
+ ```
99
+
100
+ Docs: [seolyticize.com/docs/python-sdk](https://seolyticize.com/docs/python-sdk) · Agent quick start: [seolyticize.com/AGENTS.md](https://seolyticize.com/AGENTS.md)
101
+
102
+ The tool methods are generated from the same registry that runs the API. License: MIT.
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "seolyticize"
7
+ version = "0.4.0"
8
+ description = "Seolyticize for Python: every API tool as a method, and on-site helpers for analytics, IndexNow and deploys."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ dependencies = []
13
+ classifiers = [
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3",
16
+ "Topic :: Internet :: WWW/HTTP :: Site Management",
17
+ ]
18
+
19
+ [project.urls]
20
+ Homepage = "https://seolyticize.com"
21
+ Documentation = "https://seolyticize.com/docs/python-sdk"
22
+ Source = "https://github.com/Seolyticize/sdk-python"
23
+
24
+ [tool.setuptools.packages.find]
25
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,31 @@
1
+ """Seolyticize: the API client, and on-site helpers in :mod:`seolyticize.site`."""
2
+
3
+ from ._client import DEFAULT_BASE_URL, VERSION, Seolyticize
4
+ from ._tools import TOOL_COUNT
5
+ from .errors import (
6
+ AuthenticationError,
7
+ NetworkError,
8
+ NotFoundError,
9
+ PermissionError,
10
+ RateLimitError,
11
+ RefusedError,
12
+ SeolyticizeError,
13
+ ServerError,
14
+ ValidationError,
15
+ )
16
+
17
+ __version__ = VERSION
18
+ __all__ = [
19
+ "Seolyticize",
20
+ "DEFAULT_BASE_URL",
21
+ "TOOL_COUNT",
22
+ "SeolyticizeError",
23
+ "ValidationError",
24
+ "AuthenticationError",
25
+ "PermissionError",
26
+ "NotFoundError",
27
+ "RefusedError",
28
+ "RateLimitError",
29
+ "ServerError",
30
+ "NetworkError",
31
+ ]
@@ -0,0 +1,152 @@
1
+ """The client: one call() for any tool, and a typed method per tool on top."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import random
8
+ import time
9
+ import urllib.error
10
+ import urllib.request
11
+ from typing import Any, Callable, Dict, Optional
12
+
13
+ from ._tools import READ_ONLY_TOOLS, Tools
14
+ from .errors import NetworkError, RateLimitError, SeolyticizeError, error_for_status
15
+
16
+ DEFAULT_BASE_URL = "https://seolyticize.com"
17
+ VERSION = "0.4.0"
18
+
19
+
20
+ class Seolyticize(Tools):
21
+ """A Seolyticize API client.
22
+
23
+ >>> client = Seolyticize() # reads SEOLYTICIZE_API_KEY
24
+ >>> client.get_next_steps(client_id="client_…")
25
+
26
+ Every tool is a method (``client.onboard_site(domain="example.com")``), and
27
+ ``client.call("tool_name", {...})`` reaches tools newer than this SDK.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ api_key: Optional[str] = None,
33
+ *,
34
+ base_url: Optional[str] = None,
35
+ timeout: float = 60.0,
36
+ max_retries: int = 2,
37
+ max_retry_after: float = 60.0,
38
+ sleep: Callable[[float], None] = time.sleep,
39
+ ) -> None:
40
+ key = api_key or os.environ.get("SEOLYTICIZE_API_KEY")
41
+ if not key:
42
+ raise SeolyticizeError(
43
+ "An API key is required: pass api_key or set SEOLYTICIZE_API_KEY.",
44
+ code="no_api_key",
45
+ )
46
+ self.api_key = key
47
+ self.base_url = (base_url or os.environ.get("SEOLYTICIZE_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
48
+ self.timeout = timeout
49
+ self.max_retries = max_retries
50
+ self.max_retry_after = max_retry_after
51
+ self._sleep = sleep
52
+ #: The X-RateLimit-* headers from the latest response.
53
+ self.last_rate_limit: Dict[str, Optional[int]] = {}
54
+
55
+ # -- requests ----------------------------------------------------------
56
+
57
+ def _request(self, method: str, path: str, body: Optional[Dict[str, Any]], tool: Optional[str], retry_transient: bool) -> Any:
58
+ data = None if body is None else json.dumps(body).encode("utf-8")
59
+ attempt = 0
60
+ while True:
61
+ request = urllib.request.Request(
62
+ self.base_url + path,
63
+ data=data,
64
+ method=method,
65
+ headers={
66
+ "authorization": f"Bearer {self.api_key}",
67
+ "content-type": "application/json",
68
+ "accept": "application/json",
69
+ "user-agent": f"seolyticize-python/{VERSION}",
70
+ },
71
+ )
72
+ try:
73
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
74
+ self._remember_rate(response.headers)
75
+ payload = json.loads(response.read().decode("utf-8") or "{}")
76
+ return payload.get("data", payload)
77
+ except urllib.error.HTTPError as failure:
78
+ self._remember_rate(failure.headers)
79
+ status = failure.code
80
+ details = _details(failure)
81
+ retry_after = _float(failure.headers.get("retry-after"))
82
+ can_retry = attempt < self.max_retries
83
+ if status == 429 and can_retry and (retry_after or 0) <= self.max_retry_after:
84
+ self._sleep(retry_after if retry_after is not None else _backoff(attempt))
85
+ attempt += 1
86
+ continue
87
+ if status >= 500 and retry_transient and can_retry:
88
+ self._sleep(_backoff(attempt))
89
+ attempt += 1
90
+ continue
91
+ extra = {"retry_after": retry_after} if status == 429 else {}
92
+ raise error_for_status(status, details["message"], code=details["code"], next=details["next"], request_id=details["request_id"], tool=tool, **extra) from None
93
+ except (urllib.error.URLError, TimeoutError, ConnectionError) as failure:
94
+ if retry_transient and attempt < self.max_retries:
95
+ self._sleep(_backoff(attempt))
96
+ attempt += 1
97
+ continue
98
+ raise NetworkError(f"No response from {self.base_url}: {failure}", tool=tool) from None
99
+
100
+ def _remember_rate(self, headers: Any) -> None:
101
+ if headers is None:
102
+ return
103
+ self.last_rate_limit = {
104
+ "limit": _int(headers.get("x-ratelimit-limit")),
105
+ "remaining": _int(headers.get("x-ratelimit-remaining")),
106
+ "reset_seconds": _int(headers.get("x-ratelimit-reset")),
107
+ }
108
+
109
+ # -- public ------------------------------------------------------------
110
+
111
+ def call(self, tool: str, input: Optional[Dict[str, Any]] = None) -> Any:
112
+ """Calls any tool by name with a JSON-shaped input (camelCase keys, as the API takes them)."""
113
+ # A tool this SDK has never heard of may be a write; it is not retried
114
+ # on server errors, since repeating a write could do it twice.
115
+ return self._request("POST", f"/api/v1/tools/{tool}", input or {}, tool, tool in READ_ONLY_TOOLS)
116
+
117
+ def list_tools(self, group: Optional[str] = None) -> Dict[str, Any]:
118
+ """The tools this key may call, with their schemas; ``group`` narrows to one job (onboarding, weekly, technical, research, reporting)."""
119
+ query = f"?group={group}" if group else ""
120
+ return self._request("GET", f"/api/v1/tools{query}", None, None, True)
121
+
122
+
123
+ def _details(failure: urllib.error.HTTPError) -> Dict[str, Optional[str]]:
124
+ try:
125
+ body = json.loads(failure.read().decode("utf-8") or "{}")
126
+ except Exception: # an HTML error page from a proxy
127
+ body = {}
128
+ text = lambda value: value if isinstance(value, str) and value else None # noqa: E731
129
+ return {
130
+ "message": text(body.get("error")) or f"HTTP {failure.code}",
131
+ "code": text(body.get("code")),
132
+ "next": text(body.get("next")),
133
+ "request_id": text(body.get("requestId")) or failure.headers.get("x-request-id"),
134
+ }
135
+
136
+
137
+ def _backoff(attempt: int) -> float:
138
+ return min(8.0, 0.5 * (2**attempt)) * (0.5 + random.random() / 2)
139
+
140
+
141
+ def _int(value: Optional[str]) -> Optional[int]:
142
+ try:
143
+ return int(value) if value is not None else None
144
+ except ValueError:
145
+ return None
146
+
147
+
148
+ def _float(value: Optional[str]) -> Optional[float]:
149
+ try:
150
+ return float(value) if value is not None else None
151
+ except ValueError:
152
+ return None