listingsapi 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- listingsapi/__init__.py +46 -0
- listingsapi/_client.py +269 -0
- listingsapi/_types.py +137 -0
- listingsapi/_utils.py +15 -0
- listingsapi/exceptions.py +104 -0
- listingsapi/resources/__init__.py +21 -0
- listingsapi/resources/_base.py +29 -0
- listingsapi/resources/analytics.py +54 -0
- listingsapi/resources/connected_accounts.py +157 -0
- listingsapi/resources/listings.py +101 -0
- listingsapi/resources/locations.py +296 -0
- listingsapi/resources/photos.py +53 -0
- listingsapi/resources/posts.py +376 -0
- listingsapi/resources/reviews.py +199 -0
- listingsapi/resources/workflows.py +192 -0
- listingsapi-0.5.0.dist-info/METADATA +577 -0
- listingsapi-0.5.0.dist-info/RECORD +20 -0
- listingsapi-0.5.0.dist-info/WHEEL +5 -0
- listingsapi-0.5.0.dist-info/licenses/LICENSE +21 -0
- listingsapi-0.5.0.dist-info/top_level.txt +1 -0
listingsapi/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""listingsAPI Python SDK — listings, reviews, posts, and analytics for local marketing.
|
|
2
|
+
|
|
3
|
+
Example:
|
|
4
|
+
import listingsapi
|
|
5
|
+
|
|
6
|
+
client = listingsapi.ListingsAPI() # reads LISTINGSAPI_KEY from env
|
|
7
|
+
|
|
8
|
+
for location in client.locations.list(first=10):
|
|
9
|
+
print(location.name, location.city)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from listingsapi._client import ListingsAPI
|
|
13
|
+
from listingsapi._types import APIObject, SyncPage
|
|
14
|
+
from listingsapi.exceptions import (
|
|
15
|
+
APIConnectionError,
|
|
16
|
+
APIError,
|
|
17
|
+
AuthenticationError,
|
|
18
|
+
InternalServerError,
|
|
19
|
+
ListingsAPIError,
|
|
20
|
+
NotFoundError,
|
|
21
|
+
PermissionDeniedError,
|
|
22
|
+
RateLimitError,
|
|
23
|
+
ValidationError,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__version__ = "0.5.0"
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
# Client
|
|
30
|
+
"ListingsAPI",
|
|
31
|
+
# Types
|
|
32
|
+
"APIObject",
|
|
33
|
+
"SyncPage",
|
|
34
|
+
# Exceptions
|
|
35
|
+
"ListingsAPIError",
|
|
36
|
+
"APIError",
|
|
37
|
+
"AuthenticationError",
|
|
38
|
+
"PermissionDeniedError",
|
|
39
|
+
"NotFoundError",
|
|
40
|
+
"ValidationError",
|
|
41
|
+
"RateLimitError",
|
|
42
|
+
"InternalServerError",
|
|
43
|
+
"APIConnectionError",
|
|
44
|
+
# Version
|
|
45
|
+
"__version__",
|
|
46
|
+
]
|
listingsapi/_client.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Main listingsAPI client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
from requests.adapters import HTTPAdapter
|
|
11
|
+
from urllib3.util.retry import Retry
|
|
12
|
+
|
|
13
|
+
from listingsapi._types import APIObject
|
|
14
|
+
from listingsapi._utils import encode_location_id
|
|
15
|
+
from listingsapi.exceptions import (
|
|
16
|
+
APIConnectionError,
|
|
17
|
+
APIError,
|
|
18
|
+
AuthenticationError,
|
|
19
|
+
InternalServerError,
|
|
20
|
+
NotFoundError,
|
|
21
|
+
PermissionDeniedError,
|
|
22
|
+
RateLimitError,
|
|
23
|
+
ValidationError,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("listingsapi")
|
|
27
|
+
|
|
28
|
+
DEFAULT_BASE_URL = "https://listingsapi.com"
|
|
29
|
+
DEFAULT_TIMEOUT = 240.0
|
|
30
|
+
DEFAULT_MAX_RETRIES = 2
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ListingsAPI:
|
|
34
|
+
"""Client for the listingsAPI platform.
|
|
35
|
+
|
|
36
|
+
Example:
|
|
37
|
+
import listingsapi
|
|
38
|
+
|
|
39
|
+
# Reads LISTINGSAPI_KEY from environment
|
|
40
|
+
client = listingsapi.ListingsAPI()
|
|
41
|
+
|
|
42
|
+
# Or pass explicitly
|
|
43
|
+
client = listingsapi.ListingsAPI(api_key="your-key")
|
|
44
|
+
|
|
45
|
+
# With custom config
|
|
46
|
+
client = listingsapi.ListingsAPI(api_key="...", timeout=60.0, max_retries=3)
|
|
47
|
+
|
|
48
|
+
# Use resources
|
|
49
|
+
page = client.locations.list(first=10)
|
|
50
|
+
for location in page:
|
|
51
|
+
print(location.name, location.city)
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
api_key: str | None = None,
|
|
57
|
+
*,
|
|
58
|
+
base_url: str | None = None,
|
|
59
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
60
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
61
|
+
):
|
|
62
|
+
self.api_key = api_key or os.environ.get("LISTINGSAPI_KEY")
|
|
63
|
+
if not self.api_key:
|
|
64
|
+
raise AuthenticationError(
|
|
65
|
+
"No API key provided. Set the LISTINGSAPI_KEY environment variable "
|
|
66
|
+
"or pass api_key= to ListingsAPI().",
|
|
67
|
+
status_code=401,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
self._base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
71
|
+
self._timeout = timeout
|
|
72
|
+
|
|
73
|
+
self._session = requests.Session()
|
|
74
|
+
self._session.headers.update(
|
|
75
|
+
{
|
|
76
|
+
"Authorization": f"API {self.api_key}",
|
|
77
|
+
"Content-Type": "application/json",
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
retry = Retry(
|
|
82
|
+
total=max_retries,
|
|
83
|
+
backoff_factor=0.5,
|
|
84
|
+
status_forcelist=[429, 500, 502, 503, 504],
|
|
85
|
+
allowed_methods=["GET", "POST"],
|
|
86
|
+
respect_retry_after_header=True,
|
|
87
|
+
)
|
|
88
|
+
adapter = HTTPAdapter(max_retries=retry)
|
|
89
|
+
self._session.mount("https://", adapter)
|
|
90
|
+
self._session.mount("http://", adapter)
|
|
91
|
+
|
|
92
|
+
# Resources — lazy imports to avoid circular deps
|
|
93
|
+
from listingsapi.resources import (
|
|
94
|
+
Analytics,
|
|
95
|
+
ConnectedAccounts,
|
|
96
|
+
Listings,
|
|
97
|
+
Locations,
|
|
98
|
+
Photos,
|
|
99
|
+
Posts,
|
|
100
|
+
Reviews,
|
|
101
|
+
Workflows,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
self.locations = Locations(self)
|
|
105
|
+
self.reviews = Reviews(self)
|
|
106
|
+
self.posts = Posts(self)
|
|
107
|
+
self.listings = Listings(self)
|
|
108
|
+
self.analytics = Analytics(self)
|
|
109
|
+
self.connected_accounts = ConnectedAccounts(self)
|
|
110
|
+
self.photos = Photos(self)
|
|
111
|
+
self.workflows = Workflows(self)
|
|
112
|
+
|
|
113
|
+
# --- HTTP methods (used by resources) ---
|
|
114
|
+
|
|
115
|
+
def _get(self, path: str, params: dict | None = None) -> dict[str, Any]:
|
|
116
|
+
url = f"{self._base_url}/api/v4/{path}"
|
|
117
|
+
logger.debug("GET %s params=%s", url, params)
|
|
118
|
+
try:
|
|
119
|
+
response = self._session.get(url, params=params or {}, timeout=self._timeout)
|
|
120
|
+
except requests.ConnectionError as e:
|
|
121
|
+
raise APIConnectionError(f"Connection error: {e}") from e
|
|
122
|
+
except requests.Timeout as e:
|
|
123
|
+
raise APIConnectionError(f"Request timed out: {e}") from e
|
|
124
|
+
return self._handle_response(response)
|
|
125
|
+
|
|
126
|
+
def _post(self, path: str, json_body: dict[str, Any]) -> dict[str, Any]:
|
|
127
|
+
url = f"{self._base_url}/api/v4/{path}"
|
|
128
|
+
logger.debug("POST %s", url)
|
|
129
|
+
try:
|
|
130
|
+
response = self._session.post(url, json=json_body, timeout=self._timeout)
|
|
131
|
+
except requests.ConnectionError as e:
|
|
132
|
+
raise APIConnectionError(f"Connection error: {e}") from e
|
|
133
|
+
except requests.Timeout as e:
|
|
134
|
+
raise APIConnectionError(f"Request timed out: {e}") from e
|
|
135
|
+
data = self._handle_response(response)
|
|
136
|
+
self._raise_for_mutation_errors(data, response)
|
|
137
|
+
return data
|
|
138
|
+
|
|
139
|
+
def _delete(self, path: str, json_body: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
140
|
+
url = f"{self._base_url}/api/v4/{path}"
|
|
141
|
+
logger.debug("DELETE %s", url)
|
|
142
|
+
try:
|
|
143
|
+
response = self._session.delete(url, json=json_body, timeout=self._timeout)
|
|
144
|
+
except requests.ConnectionError as e:
|
|
145
|
+
raise APIConnectionError(f"Connection error: {e}") from e
|
|
146
|
+
except requests.Timeout as e:
|
|
147
|
+
raise APIConnectionError(f"Request timed out: {e}") from e
|
|
148
|
+
data = self._handle_response(response)
|
|
149
|
+
self._raise_for_mutation_errors(data, response)
|
|
150
|
+
return data
|
|
151
|
+
|
|
152
|
+
def _location_get(
|
|
153
|
+
self, location_id: str | int, path_suffix: str, params: dict | None = None
|
|
154
|
+
) -> dict[str, Any]:
|
|
155
|
+
encoded_id = encode_location_id(location_id)
|
|
156
|
+
return self._get(f"locations/{encoded_id}/{path_suffix}", params)
|
|
157
|
+
|
|
158
|
+
def _handle_response(self, response: requests.Response) -> dict[str, Any]:
|
|
159
|
+
if response.ok:
|
|
160
|
+
data = response.json()
|
|
161
|
+
if isinstance(data, dict):
|
|
162
|
+
errors = _parse_error_entries(data.get("errors"))
|
|
163
|
+
if errors:
|
|
164
|
+
raise _exception_for_errors(errors, response)
|
|
165
|
+
return data
|
|
166
|
+
|
|
167
|
+
status = response.status_code
|
|
168
|
+
body = response.text
|
|
169
|
+
msg = f"API request failed: {status}"
|
|
170
|
+
|
|
171
|
+
if status == 401:
|
|
172
|
+
raise AuthenticationError(msg, status_code=status, response_body=body)
|
|
173
|
+
elif status == 403:
|
|
174
|
+
raise PermissionDeniedError(msg, status_code=status, response_body=body)
|
|
175
|
+
elif status == 404:
|
|
176
|
+
raise NotFoundError(msg, status_code=status, response_body=body)
|
|
177
|
+
elif status == 429:
|
|
178
|
+
retry_after = response.headers.get("Retry-After")
|
|
179
|
+
raise RateLimitError(msg, status_code=status, response_body=body, retry_after=retry_after)
|
|
180
|
+
elif status in (400, 422):
|
|
181
|
+
raise ValidationError(msg, status_code=status, response_body=body)
|
|
182
|
+
elif status >= 500:
|
|
183
|
+
raise InternalServerError(msg, status_code=status, response_body=body)
|
|
184
|
+
else:
|
|
185
|
+
raise APIError(msg, status_code=status, response_body=body)
|
|
186
|
+
|
|
187
|
+
def _raise_for_mutation_errors(self, data: dict[str, Any], response: requests.Response) -> None:
|
|
188
|
+
if not isinstance(data, dict):
|
|
189
|
+
return
|
|
190
|
+
payload = data.get("data")
|
|
191
|
+
if not isinstance(payload, dict):
|
|
192
|
+
return
|
|
193
|
+
for result in payload.values():
|
|
194
|
+
if not isinstance(result, dict):
|
|
195
|
+
continue
|
|
196
|
+
errors = _parse_error_entries(result.get("errors"))
|
|
197
|
+
if errors or result.get("success") is False:
|
|
198
|
+
if not errors:
|
|
199
|
+
errors = [{"code": None, "message": "The API reported success=false", "context": {}}]
|
|
200
|
+
raise ValidationError(
|
|
201
|
+
"API request failed",
|
|
202
|
+
status_code=response.status_code,
|
|
203
|
+
response_body=response.text,
|
|
204
|
+
errors=errors,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# --- Account-level convenience methods ---
|
|
208
|
+
|
|
209
|
+
def plan_sites(self) -> list[APIObject]:
|
|
210
|
+
"""Get supported directories for your plan."""
|
|
211
|
+
data = self._get("plan-sites")
|
|
212
|
+
items = data.get("data", {}).get("planSites") or []
|
|
213
|
+
return [APIObject(item) for item in items]
|
|
214
|
+
|
|
215
|
+
def subcategories(self) -> list[APIObject]:
|
|
216
|
+
"""Get business subcategories (use the databaseId as subCategoryId when creating locations)."""
|
|
217
|
+
data = self._get("sub-categories")
|
|
218
|
+
items = data.get("data", {}).get("subcategories") or []
|
|
219
|
+
return [APIObject(item) for item in items]
|
|
220
|
+
|
|
221
|
+
def countries(self) -> list[APIObject]:
|
|
222
|
+
"""Get supported countries and states (ISO codes)."""
|
|
223
|
+
data = self._get("countries")
|
|
224
|
+
items = data.get("data", {}).get("supportedCountries") or []
|
|
225
|
+
return [APIObject(item) for item in items]
|
|
226
|
+
|
|
227
|
+
def subscriptions(self) -> list[APIObject]:
|
|
228
|
+
"""Get active subscriptions for the account."""
|
|
229
|
+
data = self._get("subscriptions")
|
|
230
|
+
items = data.get("data", {}).get("activeSubscriptions") or []
|
|
231
|
+
return [APIObject(item) for item in items]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _parse_error_entries(raw: Any) -> list[dict[str, Any]]:
|
|
235
|
+
if not raw:
|
|
236
|
+
return []
|
|
237
|
+
entries: list[dict[str, Any]] = []
|
|
238
|
+
for item in raw if isinstance(raw, list) else [raw]:
|
|
239
|
+
if isinstance(item, dict):
|
|
240
|
+
entries.append(
|
|
241
|
+
{
|
|
242
|
+
"code": item.get("code"),
|
|
243
|
+
"message": item.get("message") or str(item),
|
|
244
|
+
"context": item.get("context") or {},
|
|
245
|
+
}
|
|
246
|
+
)
|
|
247
|
+
else:
|
|
248
|
+
entries.append({"code": None, "message": str(item), "context": {}})
|
|
249
|
+
return entries
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
_AUTH_CODES = {"SY90005", "SY90001"}
|
|
253
|
+
_PERMISSION_CODES = {"SY90003"}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _exception_for_errors(errors: list[dict[str, Any]], response: requests.Response) -> APIError:
|
|
257
|
+
codes = {e["code"] for e in errors if e.get("code")}
|
|
258
|
+
kwargs: dict[str, Any] = {
|
|
259
|
+
"status_code": response.status_code,
|
|
260
|
+
"response_body": response.text,
|
|
261
|
+
"errors": errors,
|
|
262
|
+
}
|
|
263
|
+
if codes & _AUTH_CODES:
|
|
264
|
+
return AuthenticationError("Authentication failed", **kwargs)
|
|
265
|
+
if codes & _PERMISSION_CODES:
|
|
266
|
+
return PermissionDeniedError("Permission denied", **kwargs)
|
|
267
|
+
if codes == {"RATE_LIMITED"}:
|
|
268
|
+
return RateLimitError("Rate limit exceeded", **kwargs)
|
|
269
|
+
return APIError("API request failed", **kwargs)
|
listingsapi/_types.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Response types for the listingsAPI SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable, Generator
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class APIObject:
|
|
9
|
+
"""API response object with attribute access. Works like a dict but with dot notation.
|
|
10
|
+
|
|
11
|
+
Example:
|
|
12
|
+
location = client.locations.retrieve(16808)
|
|
13
|
+
print(location.name) # "StarBucks New York"
|
|
14
|
+
print(location.city) # "New York"
|
|
15
|
+
print(location["stateIso"]) # "NY" (dict-style also works)
|
|
16
|
+
print(location.to_dict()) # raw dict
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, data: dict[str, Any] | None = None):
|
|
20
|
+
object.__setattr__(self, "_data", data or {})
|
|
21
|
+
|
|
22
|
+
def __getattr__(self, name: str) -> Any:
|
|
23
|
+
try:
|
|
24
|
+
value = self._data[name]
|
|
25
|
+
except KeyError:
|
|
26
|
+
raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'")
|
|
27
|
+
if isinstance(value, dict):
|
|
28
|
+
return APIObject(value)
|
|
29
|
+
if isinstance(value, list):
|
|
30
|
+
return [APIObject(v) if isinstance(v, dict) else v for v in value]
|
|
31
|
+
return value
|
|
32
|
+
|
|
33
|
+
def __getitem__(self, key: str) -> Any:
|
|
34
|
+
return self._data[key]
|
|
35
|
+
|
|
36
|
+
def __contains__(self, key: str) -> bool:
|
|
37
|
+
return key in self._data
|
|
38
|
+
|
|
39
|
+
def __iter__(self):
|
|
40
|
+
return iter(self._data)
|
|
41
|
+
|
|
42
|
+
def __len__(self) -> int:
|
|
43
|
+
return len(self._data)
|
|
44
|
+
|
|
45
|
+
def keys(self):
|
|
46
|
+
return self._data.keys()
|
|
47
|
+
|
|
48
|
+
def values(self):
|
|
49
|
+
return self._data.values()
|
|
50
|
+
|
|
51
|
+
def items(self):
|
|
52
|
+
return self._data.items()
|
|
53
|
+
|
|
54
|
+
def __repr__(self) -> str:
|
|
55
|
+
return repr(self._data)
|
|
56
|
+
|
|
57
|
+
def __str__(self) -> str:
|
|
58
|
+
return str(self._data)
|
|
59
|
+
|
|
60
|
+
def __bool__(self) -> bool:
|
|
61
|
+
return bool(self._data)
|
|
62
|
+
|
|
63
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
64
|
+
return self._data.get(key, default)
|
|
65
|
+
|
|
66
|
+
def to_dict(self) -> dict[str, Any]:
|
|
67
|
+
"""Return the raw dict."""
|
|
68
|
+
return self._data
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class SyncPage:
|
|
72
|
+
"""A page of results with built-in pagination.
|
|
73
|
+
|
|
74
|
+
Example:
|
|
75
|
+
page = client.locations.list(first=10)
|
|
76
|
+
for location in page:
|
|
77
|
+
print(location.name)
|
|
78
|
+
|
|
79
|
+
# Auto-paginate through all results
|
|
80
|
+
for location in page.auto_paging_iter():
|
|
81
|
+
print(location.name)
|
|
82
|
+
|
|
83
|
+
# Manual pagination
|
|
84
|
+
if page.has_more:
|
|
85
|
+
next_page = page.next_page()
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
data: list[dict[str, Any]],
|
|
91
|
+
has_more: bool = False,
|
|
92
|
+
end_cursor: str | None = None,
|
|
93
|
+
total: int | None = None,
|
|
94
|
+
_fetch_next: Callable[..., "SyncPage"] | None = None,
|
|
95
|
+
):
|
|
96
|
+
self.data: list[APIObject] = [
|
|
97
|
+
APIObject(item) if isinstance(item, dict) else item for item in data
|
|
98
|
+
]
|
|
99
|
+
self.has_more = has_more
|
|
100
|
+
self.total = total
|
|
101
|
+
self._end_cursor = end_cursor
|
|
102
|
+
self._fetch_next = _fetch_next
|
|
103
|
+
|
|
104
|
+
def __iter__(self):
|
|
105
|
+
return iter(self.data)
|
|
106
|
+
|
|
107
|
+
def __len__(self) -> int:
|
|
108
|
+
return len(self.data)
|
|
109
|
+
|
|
110
|
+
def __bool__(self) -> bool:
|
|
111
|
+
return bool(self.data)
|
|
112
|
+
|
|
113
|
+
def __getitem__(self, index):
|
|
114
|
+
return self.data[index]
|
|
115
|
+
|
|
116
|
+
def __repr__(self) -> str:
|
|
117
|
+
return f"SyncPage(items={len(self.data)}, has_more={self.has_more})"
|
|
118
|
+
|
|
119
|
+
def next_page(self) -> SyncPage | None:
|
|
120
|
+
"""Fetch the next page of results, or None if no more pages."""
|
|
121
|
+
if not self.has_more or not self._fetch_next:
|
|
122
|
+
return None
|
|
123
|
+
return self._fetch_next(self._end_cursor)
|
|
124
|
+
|
|
125
|
+
def auto_paging_iter(self) -> Generator[APIObject, None, None]:
|
|
126
|
+
"""Iterate through all pages automatically.
|
|
127
|
+
|
|
128
|
+
Example:
|
|
129
|
+
for location in client.locations.list(first=10).auto_paging_iter():
|
|
130
|
+
print(location.name)
|
|
131
|
+
"""
|
|
132
|
+
page: SyncPage | None = self
|
|
133
|
+
while page is not None:
|
|
134
|
+
yield from page.data
|
|
135
|
+
if not page.has_more:
|
|
136
|
+
break
|
|
137
|
+
page = page.next_page()
|
listingsapi/_utils.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Internal utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def encode_location_id(id_value: str | int) -> str:
|
|
9
|
+
"""Convert a numeric location ID to base64-encoded form, or return as-is if already encoded."""
|
|
10
|
+
if isinstance(id_value, int):
|
|
11
|
+
return base64.b64encode(f"Location:{id_value}".encode()).decode("ascii")
|
|
12
|
+
s = str(id_value).strip()
|
|
13
|
+
if s.isdigit():
|
|
14
|
+
return base64.b64encode(f"Location:{s}".encode()).decode("ascii")
|
|
15
|
+
return s
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""listingsAPI SDK exceptions. Catch specific errors for fine-grained handling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ListingsAPIError(Exception):
|
|
9
|
+
"""Base exception for all listingsAPI SDK errors."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class APIError(ListingsAPIError):
|
|
13
|
+
"""Raised when the API reports a failure.
|
|
14
|
+
|
|
15
|
+
Covers both non-2xx HTTP responses and error payloads the API returns with
|
|
16
|
+
HTTP 200 (the platform reports auth and validation failures in an errors[]
|
|
17
|
+
body; mutations report success=false plus errors[]).
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
status_code: HTTP status code of the response (may be 200 for
|
|
21
|
+
payload-level errors).
|
|
22
|
+
response_body: Raw response text from the API, when available.
|
|
23
|
+
code: The first API error code (e.g. "SY10005"), when the API sent one.
|
|
24
|
+
errors: Parsed API error entries, each normalized to a dict with
|
|
25
|
+
"code", "message", and "context" keys.
|
|
26
|
+
|
|
27
|
+
Example:
|
|
28
|
+
try:
|
|
29
|
+
client.locations.list()
|
|
30
|
+
except listingsapi.APIError as e:
|
|
31
|
+
print(e.status_code, e.code)
|
|
32
|
+
for err in e.errors:
|
|
33
|
+
print(err["code"], err["message"])
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
message: str,
|
|
39
|
+
status_code: int | None = None,
|
|
40
|
+
response_body: str | None = None,
|
|
41
|
+
errors: list[dict[str, Any]] | None = None,
|
|
42
|
+
):
|
|
43
|
+
self.status_code = status_code
|
|
44
|
+
self.response_body = response_body
|
|
45
|
+
self.errors = errors or []
|
|
46
|
+
self.code = self.errors[0].get("code") if self.errors else None
|
|
47
|
+
detail = "; ".join(
|
|
48
|
+
f"{e['code']}: {e['message']}" if e.get("code") else str(e.get("message", ""))
|
|
49
|
+
for e in self.errors
|
|
50
|
+
)
|
|
51
|
+
if detail:
|
|
52
|
+
full = f"{message} — {detail}"
|
|
53
|
+
elif response_body:
|
|
54
|
+
full = f"{message} — {response_body}"
|
|
55
|
+
else:
|
|
56
|
+
full = message
|
|
57
|
+
super().__init__(full)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AuthenticationError(APIError):
|
|
61
|
+
"""401 — Invalid or missing API key."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class PermissionDeniedError(APIError):
|
|
65
|
+
"""403 — Insufficient permissions for this action."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class NotFoundError(APIError):
|
|
69
|
+
"""404 — Resource not found."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ValidationError(APIError):
|
|
73
|
+
"""400/422 — Invalid request parameters."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class RateLimitError(APIError):
|
|
77
|
+
"""429 — Too many requests. Check retry_after for backoff.
|
|
78
|
+
|
|
79
|
+
Attributes:
|
|
80
|
+
retry_after: Seconds to wait before retrying (from Retry-After header), or None.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
message: str,
|
|
86
|
+
status_code: int | None = None,
|
|
87
|
+
response_body: str | None = None,
|
|
88
|
+
retry_after: str | None = None,
|
|
89
|
+
errors: list[dict[str, Any]] | None = None,
|
|
90
|
+
):
|
|
91
|
+
super().__init__(message, status_code=status_code, response_body=response_body, errors=errors)
|
|
92
|
+
self.retry_after = float(retry_after) if retry_after else None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class InternalServerError(APIError):
|
|
96
|
+
"""5xx — Server-side error. Safe to retry."""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class APIConnectionError(ListingsAPIError):
|
|
100
|
+
"""Network failure — could not reach the API."""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# Backward compat
|
|
104
|
+
APIError = APIError
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""listingsAPI resources."""
|
|
2
|
+
|
|
3
|
+
from listingsapi.resources.locations import Locations
|
|
4
|
+
from listingsapi.resources.posts import Posts
|
|
5
|
+
from listingsapi.resources.reviews import Reviews
|
|
6
|
+
from listingsapi.resources.listings import Listings
|
|
7
|
+
from listingsapi.resources.analytics import Analytics
|
|
8
|
+
from listingsapi.resources.connected_accounts import ConnectedAccounts
|
|
9
|
+
from listingsapi.resources.photos import Photos
|
|
10
|
+
from listingsapi.resources.workflows import Workflows
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Locations",
|
|
14
|
+
"Posts",
|
|
15
|
+
"Reviews",
|
|
16
|
+
"Listings",
|
|
17
|
+
"Analytics",
|
|
18
|
+
"ConnectedAccounts",
|
|
19
|
+
"Photos",
|
|
20
|
+
"Workflows",
|
|
21
|
+
]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Base class for API resources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from listingsapi._client import ListingsAPI
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class APIResource:
|
|
12
|
+
"""Base class that all resources inherit from. Provides access to the client's HTTP methods."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, client: ListingsAPI) -> None:
|
|
15
|
+
self._client = client
|
|
16
|
+
|
|
17
|
+
def _get(self, path: str, params: dict | None = None) -> dict[str, Any]:
|
|
18
|
+
return self._client._get(path, params)
|
|
19
|
+
|
|
20
|
+
def _post(self, path: str, json_body: dict[str, Any]) -> dict[str, Any]:
|
|
21
|
+
return self._client._post(path, json_body)
|
|
22
|
+
|
|
23
|
+
def _delete(self, path: str, json_body: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
24
|
+
return self._client._delete(path, json_body)
|
|
25
|
+
|
|
26
|
+
def _location_get(
|
|
27
|
+
self, location_id: str | int, path_suffix: str, params: dict | None = None
|
|
28
|
+
) -> dict[str, Any]:
|
|
29
|
+
return self._client._location_get(location_id, path_suffix, params)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Analytics resource — client.analytics.*"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from listingsapi._types import APIObject
|
|
8
|
+
from listingsapi.resources._base import APIResource
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Analytics(APIResource):
|
|
12
|
+
"""Profile and ranking analytics.
|
|
13
|
+
|
|
14
|
+
Example:
|
|
15
|
+
google = client.analytics.google(16808, from_date="2024-01-01")
|
|
16
|
+
bing = client.analytics.bing(16808)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def google(
|
|
20
|
+
self, location_id: str | int, *, from_date: str | None = None, to_date: str | None = None
|
|
21
|
+
) -> APIObject:
|
|
22
|
+
"""Get Google (GMB) profile analytics for a location."""
|
|
23
|
+
params: dict[str, str] = {}
|
|
24
|
+
if from_date:
|
|
25
|
+
params["fromDate"] = from_date
|
|
26
|
+
if to_date:
|
|
27
|
+
params["toDate"] = to_date
|
|
28
|
+
data = self._location_get(location_id, "google-analytics", params)
|
|
29
|
+
return APIObject(data.get("data", {}).get("googleInsights") or {})
|
|
30
|
+
|
|
31
|
+
def bing(
|
|
32
|
+
self, location_id: str | int, *, from_date: str | None = None, to_date: str | None = None
|
|
33
|
+
) -> APIObject:
|
|
34
|
+
"""Get Bing profile analytics for a location."""
|
|
35
|
+
params: dict[str, str] = {}
|
|
36
|
+
if from_date:
|
|
37
|
+
params["fromDate"] = from_date
|
|
38
|
+
if to_date:
|
|
39
|
+
params["toDate"] = to_date
|
|
40
|
+
data = self._location_get(location_id, "bing-analytics", params)
|
|
41
|
+
return APIObject(data.get("data", {}).get("bingInsights") or {})
|
|
42
|
+
|
|
43
|
+
def facebook(
|
|
44
|
+
self, location_id: str | int, *, from_date: str | None = None, to_date: str | None = None
|
|
45
|
+
) -> APIObject:
|
|
46
|
+
"""Get Facebook page analytics for a location."""
|
|
47
|
+
params: dict[str, str] = {}
|
|
48
|
+
if from_date:
|
|
49
|
+
params["fromDate"] = from_date
|
|
50
|
+
if to_date:
|
|
51
|
+
params["toDate"] = to_date
|
|
52
|
+
data = self._location_get(location_id, "facebook-analytics", params)
|
|
53
|
+
return APIObject(data.get("data", {}).get("facebookInsights") or {})
|
|
54
|
+
|