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
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Connected accounts resource — client.connected_accounts.*"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from listingsapi._types import APIObject
|
|
8
|
+
from listingsapi._utils import encode_location_id
|
|
9
|
+
from listingsapi.resources._base import APIResource
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConnectedAccounts(APIResource):
|
|
13
|
+
"""Manage Google and Facebook connected accounts.
|
|
14
|
+
|
|
15
|
+
Example:
|
|
16
|
+
accounts = client.connected_accounts.list()
|
|
17
|
+
url = client.connected_accounts.connect_google("https://ok.com", "https://err.com")
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def list(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
publisher: str | None = None,
|
|
24
|
+
status: str | None = None,
|
|
25
|
+
page: int | None = None,
|
|
26
|
+
per_page: int | None = None,
|
|
27
|
+
) -> APIObject:
|
|
28
|
+
"""Get connected third-party accounts with optional filters."""
|
|
29
|
+
params: dict[str, Any] = {}
|
|
30
|
+
if publisher is not None:
|
|
31
|
+
params["publisher"] = publisher
|
|
32
|
+
if status is not None:
|
|
33
|
+
params["status"] = status
|
|
34
|
+
if page is not None:
|
|
35
|
+
params["page"] = page
|
|
36
|
+
if per_page is not None:
|
|
37
|
+
params["perPage"] = per_page
|
|
38
|
+
data = self._get("connected-accounts", params)
|
|
39
|
+
return APIObject(data.get("data", {}).get("connectedAccountsInfo") or {})
|
|
40
|
+
|
|
41
|
+
def details(self, connected_account_id: str) -> APIObject:
|
|
42
|
+
"""Get details for a connected account."""
|
|
43
|
+
data = self._get(f"connected-accounts/{connected_account_id}/details")
|
|
44
|
+
return APIObject(data.get("data", {}).get("connectedAccountDetails") or {})
|
|
45
|
+
|
|
46
|
+
def folders(self, connected_account_id: str, *, folder_name: str | None = None) -> list[APIObject]:
|
|
47
|
+
"""Get folders under a connected Google account."""
|
|
48
|
+
params: dict[str, str] = {}
|
|
49
|
+
if folder_name is not None:
|
|
50
|
+
params["folderName"] = folder_name
|
|
51
|
+
data = self._get(f"connected-accounts/{connected_account_id}/folders", params)
|
|
52
|
+
items = data.get("data", {}).get("getFoldersUnderGoogleAccount") or []
|
|
53
|
+
return [APIObject(item) for item in items]
|
|
54
|
+
|
|
55
|
+
def suggestions(
|
|
56
|
+
self, connected_account_id: str, *, page: int | None = None, per_page: int | None = None
|
|
57
|
+
) -> APIObject:
|
|
58
|
+
"""Get suggested matches between a connected account's listings and listingsAPI locations."""
|
|
59
|
+
params: dict[str, Any] = {}
|
|
60
|
+
if page is not None:
|
|
61
|
+
params["page"] = page
|
|
62
|
+
if per_page is not None:
|
|
63
|
+
params["perPage"] = per_page
|
|
64
|
+
data = self._get(f"connected-accounts/{connected_account_id}/connection-suggestions", params)
|
|
65
|
+
return APIObject(data.get("data", {}).get("connectionSuggestionsForAccount") or {})
|
|
66
|
+
|
|
67
|
+
def listings(
|
|
68
|
+
self,
|
|
69
|
+
connected_account_id: str,
|
|
70
|
+
*,
|
|
71
|
+
location_info: str | None = None,
|
|
72
|
+
page: int | None = None,
|
|
73
|
+
per_page: int | None = None,
|
|
74
|
+
) -> APIObject:
|
|
75
|
+
"""Get listings the connected account has access to."""
|
|
76
|
+
payload: dict[str, Any] = {"connectedAccountId": connected_account_id}
|
|
77
|
+
if location_info is not None:
|
|
78
|
+
payload["locationInfo"] = location_info
|
|
79
|
+
if page is not None:
|
|
80
|
+
payload["page"] = page
|
|
81
|
+
if per_page is not None:
|
|
82
|
+
payload["perPage"] = per_page
|
|
83
|
+
data = self._post("connected-accounts/connected-account-listings", payload)
|
|
84
|
+
return APIObject(data.get("data", {}).get("connectedAccountListings") or {})
|
|
85
|
+
|
|
86
|
+
def connect_google(self, success_url: str, error_url: str) -> APIObject:
|
|
87
|
+
"""Get a URL to connect a Google account. Valid 24 hours."""
|
|
88
|
+
data = self._post(
|
|
89
|
+
"connected-accounts/connect-google",
|
|
90
|
+
{"input": {"successUrl": success_url, "errorUrl": error_url}},
|
|
91
|
+
)
|
|
92
|
+
return APIObject(data.get("data", {}).get("bulkConnectLinkForGoogle") or {})
|
|
93
|
+
|
|
94
|
+
def connect_facebook(self, success_url: str, error_url: str) -> APIObject:
|
|
95
|
+
"""Get a URL to connect a Facebook account. Valid 24 hours."""
|
|
96
|
+
data = self._post(
|
|
97
|
+
"connected-accounts/connect-facebook",
|
|
98
|
+
{"input": {"successUrl": success_url, "errorUrl": error_url}},
|
|
99
|
+
)
|
|
100
|
+
return APIObject(data.get("data", {}).get("bulkConnectLinkForFacebook") or {})
|
|
101
|
+
|
|
102
|
+
def disconnect_google(self, connected_account_id: str) -> APIObject:
|
|
103
|
+
"""Disconnect a Google connected account."""
|
|
104
|
+
data = self._post(
|
|
105
|
+
"connected-accounts/disconnect-google",
|
|
106
|
+
{"input": {"connectedAccountId": connected_account_id}},
|
|
107
|
+
)
|
|
108
|
+
return APIObject(data.get("data", {}).get("gmbBulkDisconnect") or {})
|
|
109
|
+
|
|
110
|
+
def disconnect_facebook(self, connected_account_id: str) -> APIObject:
|
|
111
|
+
"""Disconnect a Facebook connected account."""
|
|
112
|
+
data = self._post(
|
|
113
|
+
"connected-accounts/disconnect-facebook",
|
|
114
|
+
{"input": {"connectedAccountId": connected_account_id}},
|
|
115
|
+
)
|
|
116
|
+
return APIObject(data.get("data", {}).get("fbBulkDisconnect") or {})
|
|
117
|
+
|
|
118
|
+
def trigger_matches(self, connected_account_ids: list[str]) -> APIObject:
|
|
119
|
+
"""Trigger matching of profiles to listingsAPI locations."""
|
|
120
|
+
data = self._post(
|
|
121
|
+
"connected-accounts/trigger-matches",
|
|
122
|
+
{"input": {"connectedAccountIds": connected_account_ids}},
|
|
123
|
+
)
|
|
124
|
+
return APIObject(data.get("data", {}).get("connectedAccountsTriggerMatches") or {})
|
|
125
|
+
|
|
126
|
+
def confirm_matches(self, match_record_ids: list[str]) -> APIObject:
|
|
127
|
+
"""Confirm suggested matches between connected account listings and locations."""
|
|
128
|
+
data = self._post(
|
|
129
|
+
"connected-accounts/confirm-matches",
|
|
130
|
+
{"input": {"matchRecordIds": match_record_ids}},
|
|
131
|
+
)
|
|
132
|
+
return APIObject(data.get("data", {}).get("confirmConnectMatches") or {})
|
|
133
|
+
|
|
134
|
+
def oauth_url(
|
|
135
|
+
self, location_id: str | int, site: str, success_url: str, error_url: str
|
|
136
|
+
) -> APIObject:
|
|
137
|
+
"""Get a URL to connect a Google/Facebook profile to a single location. Valid 24 hours."""
|
|
138
|
+
data = self._post(
|
|
139
|
+
"locations/oauth_connect_url",
|
|
140
|
+
{
|
|
141
|
+
"input": {
|
|
142
|
+
"locationId": encode_location_id(location_id),
|
|
143
|
+
"site": site.upper(),
|
|
144
|
+
"successUrl": success_url,
|
|
145
|
+
"errorUrl": error_url,
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
)
|
|
149
|
+
return APIObject(data.get("data", {}).get("connectUrl") or {})
|
|
150
|
+
|
|
151
|
+
def oauth_disconnect(self, location_id: str | int, site: str) -> APIObject:
|
|
152
|
+
"""Disconnect a Google/Facebook profile from a single location."""
|
|
153
|
+
data = self._post(
|
|
154
|
+
"locations/oauth-disconnect",
|
|
155
|
+
{"input": {"locationId": encode_location_id(location_id), "site": site.upper()}},
|
|
156
|
+
)
|
|
157
|
+
return APIObject(data.get("data", {}).get("disconnectConnectedAccountsLocations") or {})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Listings resource — client.listings.*"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from listingsapi._types import APIObject
|
|
8
|
+
from listingsapi._utils import encode_location_id
|
|
9
|
+
from listingsapi.resources._base import APIResource
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Listings(APIResource):
|
|
13
|
+
"""Manage directory listings for locations.
|
|
14
|
+
|
|
15
|
+
Example:
|
|
16
|
+
listings = client.listings.premium(16808)
|
|
17
|
+
for listing in listings:
|
|
18
|
+
print(listing.site, listing.syncStatus)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def premium(self, location_id: str | int) -> list[APIObject]:
|
|
22
|
+
"""Get premium directory listings (Google, Yelp, etc.)."""
|
|
23
|
+
data = self._location_get(location_id, "listings/premium")
|
|
24
|
+
items = data.get("data", {}).get("listingsForLocation") or []
|
|
25
|
+
return [APIObject(item) for item in items]
|
|
26
|
+
|
|
27
|
+
def voice(self, location_id: str | int) -> list[APIObject]:
|
|
28
|
+
"""Get voice assistant listings (Google, Alexa, Siri)."""
|
|
29
|
+
data = self._location_get(location_id, "voice-assistants")
|
|
30
|
+
items = data.get("data", {}).get("voiceAssistantsForLocation") or []
|
|
31
|
+
return [APIObject(item) for item in items]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def duplicates(self, location_id: str | int) -> list[APIObject]:
|
|
35
|
+
"""Get duplicate listings for a location."""
|
|
36
|
+
data = self._location_get(location_id, "listings/duplicates")
|
|
37
|
+
items = data.get("data", {}).get("duplicateListingsForLocation") or []
|
|
38
|
+
return [APIObject(item) for item in items]
|
|
39
|
+
|
|
40
|
+
def all_duplicates(self, *, tag: str | None = None, page: int | None = None) -> APIObject:
|
|
41
|
+
"""Get duplicate listings across all locations."""
|
|
42
|
+
params: dict[str, Any] = {}
|
|
43
|
+
if tag is not None:
|
|
44
|
+
params["tag"] = tag
|
|
45
|
+
if page is not None:
|
|
46
|
+
params["page"] = page
|
|
47
|
+
data = self._get("locations/listings/duplicates", params)
|
|
48
|
+
return APIObject(data.get("data", {}).get("duplicateListingsRollup") or {})
|
|
49
|
+
|
|
50
|
+
def mark_duplicate(self, location_id: str | int, listing_item_ids: list[str]) -> APIObject:
|
|
51
|
+
"""Mark listing items as duplicates."""
|
|
52
|
+
data = self._post(
|
|
53
|
+
"locations/listings/mark-as-duplicate",
|
|
54
|
+
{"input": {"locationId": encode_location_id(location_id), "listingItemIds": listing_item_ids}},
|
|
55
|
+
)
|
|
56
|
+
return APIObject(data.get("data", {}).get("markAsDuplicate") or {})
|
|
57
|
+
|
|
58
|
+
def mark_not_duplicate(self, location_id: str | int, listing_item_ids: list[str]) -> APIObject:
|
|
59
|
+
"""Clear duplicate status for listing items."""
|
|
60
|
+
data = self._post(
|
|
61
|
+
"locations/listings/mark-as-not-duplicate",
|
|
62
|
+
{"input": {"locationId": encode_location_id(location_id), "listingItemIds": listing_item_ids}},
|
|
63
|
+
)
|
|
64
|
+
return APIObject(data.get("data", {}).get("markAsNotDuplicate") or {})
|
|
65
|
+
|
|
66
|
+
def connect(
|
|
67
|
+
self, location_id: str | int, connected_account_listing_id: str, connected_account_id: str
|
|
68
|
+
) -> APIObject:
|
|
69
|
+
"""Link a location to a listing from a connected account."""
|
|
70
|
+
data = self._post(
|
|
71
|
+
"connected-accounts/connect-listing",
|
|
72
|
+
{
|
|
73
|
+
"input": {
|
|
74
|
+
"locationId": encode_location_id(location_id),
|
|
75
|
+
"connectedAccountListingId": connected_account_listing_id,
|
|
76
|
+
"connectedAccountId": connected_account_id,
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
)
|
|
80
|
+
return APIObject(data.get("data", {}).get("connectListing") or {})
|
|
81
|
+
|
|
82
|
+
def disconnect(self, location_id: str | int, site: str) -> APIObject:
|
|
83
|
+
"""Disconnect a location from its Google or Facebook listing."""
|
|
84
|
+
data = self._post(
|
|
85
|
+
"connected-accounts/disconnect-listing",
|
|
86
|
+
{"input": {"locationId": encode_location_id(location_id), "site": site.upper()}},
|
|
87
|
+
)
|
|
88
|
+
return APIObject(data.get("data", {}).get("disconnectConnectedAccountsLocations") or {})
|
|
89
|
+
|
|
90
|
+
def create_gmb(
|
|
91
|
+
self, location_id: str | int, connected_account_id: str, *, folder_id: str | None = None
|
|
92
|
+
) -> APIObject:
|
|
93
|
+
"""Create a Google Business Profile listing for an existing location."""
|
|
94
|
+
payload: dict[str, Any] = {
|
|
95
|
+
"locationId": encode_location_id(location_id),
|
|
96
|
+
"connectedAccountId": connected_account_id,
|
|
97
|
+
}
|
|
98
|
+
if folder_id is not None:
|
|
99
|
+
payload["folderId"] = folder_id
|
|
100
|
+
data = self._post("locations/create/gmb-listing", {"input": payload})
|
|
101
|
+
return APIObject(data.get("data", {}).get("createGmbListingForLocation") or {})
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Locations resource — client.locations.*"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from listingsapi._types import APIObject, SyncPage
|
|
9
|
+
from listingsapi._utils import encode_location_id
|
|
10
|
+
from listingsapi.resources._base import APIResource
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Locations(APIResource):
|
|
14
|
+
"""Manage locations.
|
|
15
|
+
|
|
16
|
+
Example:
|
|
17
|
+
# List locations
|
|
18
|
+
page = client.locations.list(first=10)
|
|
19
|
+
for loc in page:
|
|
20
|
+
print(loc.name, loc.city)
|
|
21
|
+
|
|
22
|
+
# Auto-paginate
|
|
23
|
+
for loc in client.locations.list(first=50).auto_paging_iter():
|
|
24
|
+
print(loc.name)
|
|
25
|
+
|
|
26
|
+
# Create
|
|
27
|
+
result = client.locations.create({"name": "Acme", "street": "123 Main St", ...})
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def list(
|
|
31
|
+
self,
|
|
32
|
+
*,
|
|
33
|
+
first: int | None = None,
|
|
34
|
+
after: str | None = None,
|
|
35
|
+
before: str | None = None,
|
|
36
|
+
last: int | None = None,
|
|
37
|
+
) -> SyncPage:
|
|
38
|
+
"""List locations with cursor-based pagination.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
SyncPage of location objects. Use .auto_paging_iter() to iterate all.
|
|
42
|
+
"""
|
|
43
|
+
params: dict[str, Any] = {}
|
|
44
|
+
if first is not None:
|
|
45
|
+
params["first"] = first
|
|
46
|
+
if after is not None:
|
|
47
|
+
params["after"] = after
|
|
48
|
+
if before is not None:
|
|
49
|
+
params["before"] = before
|
|
50
|
+
if last is not None:
|
|
51
|
+
params["last"] = last
|
|
52
|
+
|
|
53
|
+
data = self._get("locations", params)
|
|
54
|
+
all_locs = data.get("data", {}).get("allLocations") or {}
|
|
55
|
+
edges = all_locs.get("edges") or []
|
|
56
|
+
page_info = all_locs.get("pageInfo") or {}
|
|
57
|
+
items = [edge["node"] for edge in edges]
|
|
58
|
+
end_cursor = edges[-1]["cursor"] if edges else None
|
|
59
|
+
|
|
60
|
+
return SyncPage(
|
|
61
|
+
data=items,
|
|
62
|
+
has_more=page_info.get("hasNextPage", False),
|
|
63
|
+
end_cursor=end_cursor,
|
|
64
|
+
total=page_info.get("total"),
|
|
65
|
+
_fetch_next=lambda cursor: self.list(first=first, after=cursor),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def retrieve(self, location_id: str | int) -> APIObject:
|
|
69
|
+
"""Get a single location by ID.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
location_id: Numeric (e.g. 16808) or base64-encoded ID.
|
|
73
|
+
"""
|
|
74
|
+
results = self.list_by_ids([location_id])
|
|
75
|
+
if not results:
|
|
76
|
+
from listingsapi.exceptions import NotFoundError
|
|
77
|
+
|
|
78
|
+
raise NotFoundError(f"Location {location_id} not found", status_code=404)
|
|
79
|
+
return results[0]
|
|
80
|
+
|
|
81
|
+
def list_by_ids(self, location_ids: list[str | int]) -> list[APIObject]:
|
|
82
|
+
"""Get locations by a list of IDs (numeric or base64)."""
|
|
83
|
+
if not location_ids:
|
|
84
|
+
return []
|
|
85
|
+
encoded = [encode_location_id(lid) for lid in location_ids]
|
|
86
|
+
data = self._get("locations-by-ids", {"ids": json.dumps(encoded)})
|
|
87
|
+
items = data.get("data", {}).get("getLocationsByIds") or []
|
|
88
|
+
return [APIObject(item) for item in items]
|
|
89
|
+
|
|
90
|
+
def list_by_store_codes(self, store_codes: list[str]) -> list[APIObject]:
|
|
91
|
+
"""Get locations matching the given store codes."""
|
|
92
|
+
if not store_codes:
|
|
93
|
+
return []
|
|
94
|
+
data = self._get("locations-by-store-codes", {"storeCodes": json.dumps(store_codes)})
|
|
95
|
+
items = data.get("data", {}).get("getLocationsByStoreCodes") or []
|
|
96
|
+
return [APIObject(item) for item in items]
|
|
97
|
+
|
|
98
|
+
def search(
|
|
99
|
+
self,
|
|
100
|
+
query: str,
|
|
101
|
+
*,
|
|
102
|
+
fields: list[str] | None = None,
|
|
103
|
+
first: int | None = None,
|
|
104
|
+
after: str | None = None,
|
|
105
|
+
before: str | None = None,
|
|
106
|
+
last: int | None = None,
|
|
107
|
+
) -> SyncPage:
|
|
108
|
+
"""Search locations by keyword (name, address, or store ID).
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
query: Search term.
|
|
112
|
+
fields: Restrict search to specific fields (e.g. ["name"], ["store_id"]).
|
|
113
|
+
"""
|
|
114
|
+
params: dict[str, Any] = {"query": query}
|
|
115
|
+
if fields is not None:
|
|
116
|
+
params["fields"] = json.dumps(fields)
|
|
117
|
+
if first is not None:
|
|
118
|
+
params["first"] = first
|
|
119
|
+
if after is not None:
|
|
120
|
+
params["after"] = after
|
|
121
|
+
if before is not None:
|
|
122
|
+
params["before"] = before
|
|
123
|
+
if last is not None:
|
|
124
|
+
params["last"] = last
|
|
125
|
+
|
|
126
|
+
data = self._get("locations/search", params)
|
|
127
|
+
result = data.get("data", {}).get("searchLocations") or {}
|
|
128
|
+
edges = result.get("edges") or []
|
|
129
|
+
page_info = result.get("pageInfo") or {}
|
|
130
|
+
items = [e["node"] for e in edges]
|
|
131
|
+
end_cursor = edges[-1]["cursor"] if edges else None
|
|
132
|
+
|
|
133
|
+
return SyncPage(
|
|
134
|
+
data=items,
|
|
135
|
+
has_more=page_info.get("hasNextPage", False),
|
|
136
|
+
end_cursor=end_cursor,
|
|
137
|
+
total=page_info.get("total"),
|
|
138
|
+
_fetch_next=lambda cursor: self.search(query, fields=fields, first=first, after=cursor),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def create(self, input: dict[str, Any]) -> APIObject:
|
|
144
|
+
"""Create a new location from a raw input dict. Pass camelCase field names.
|
|
145
|
+
|
|
146
|
+
Required by the API: name, description (min 200 characters), subCategoryId,
|
|
147
|
+
countryIso, and city (for countries with city-level addressing). Prefer
|
|
148
|
+
locations.add() for keyword arguments and client-side validation.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
input: Location data dict with camelCase keys.
|
|
152
|
+
"""
|
|
153
|
+
data = self._post("locations", {"input": input})
|
|
154
|
+
return APIObject(data.get("data", {}).get("createLocation") or {})
|
|
155
|
+
|
|
156
|
+
def add(
|
|
157
|
+
self,
|
|
158
|
+
*,
|
|
159
|
+
name: str,
|
|
160
|
+
description: str,
|
|
161
|
+
sub_category_id: int,
|
|
162
|
+
country_iso: str,
|
|
163
|
+
city: str | None = None,
|
|
164
|
+
street: str | None = None,
|
|
165
|
+
state_iso: str | None = None,
|
|
166
|
+
postal_code: str | None = None,
|
|
167
|
+
phone: str | None = None,
|
|
168
|
+
website: str | None = None,
|
|
169
|
+
store_id: str | None = None,
|
|
170
|
+
hide_address: bool | None = None,
|
|
171
|
+
business_hours: list[dict[str, Any]] | None = None,
|
|
172
|
+
folder_ids: list[str] | None = None,
|
|
173
|
+
service_area: dict[str, Any] | None = None,
|
|
174
|
+
place_action_links: list[dict[str, Any]] | None = None,
|
|
175
|
+
enabled_site_ids: list[int] | None = None,
|
|
176
|
+
additional_fields: dict[str, Any] | None = None,
|
|
177
|
+
) -> APIObject:
|
|
178
|
+
"""Create a location in one call with every mandatory field as a keyword argument.
|
|
179
|
+
|
|
180
|
+
Validates the API's create requirements client-side and raises ValidationError
|
|
181
|
+
before any network call, so a bad payload fails fast with a clear message.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
name: Business name, 2-150 characters.
|
|
185
|
+
description: Business description, minimum 200 characters. Publishers use
|
|
186
|
+
it as the primary listing copy.
|
|
187
|
+
sub_category_id: Primary subcategory ID (see the subcategories endpoint
|
|
188
|
+
for valid IDs).
|
|
189
|
+
country_iso: Two-letter country ISO code (must be a supported country).
|
|
190
|
+
city: City. Required for countries with city-level addressing; may be
|
|
191
|
+
omitted only for service-area businesses with hide_address=True.
|
|
192
|
+
street: Street address.
|
|
193
|
+
state_iso: State/region ISO code.
|
|
194
|
+
postal_code: Postal or ZIP code.
|
|
195
|
+
phone: Primary phone number, validated for the country.
|
|
196
|
+
website: Business website URL.
|
|
197
|
+
store_id: Your unique store code; must be unique across the account.
|
|
198
|
+
hide_address: Hide the street address (service-area businesses).
|
|
199
|
+
business_hours: Weekly hours, e.g. [{"day": "MONDAY", "slots": [...]}].
|
|
200
|
+
folder_ids: Folder IDs to place the location in.
|
|
201
|
+
service_area: Service-area config: {businessType, regionCode, placeInfos}.
|
|
202
|
+
place_action_links: Google action links, e.g.
|
|
203
|
+
[{"placeActionType": "APPOINTMENT", "uri": "https://...", "isPreferred": True}].
|
|
204
|
+
enabled_site_ids: Site IDs to publish this location to, when you want a
|
|
205
|
+
subset of your plan's sites (see the plan-sites endpoint for IDs).
|
|
206
|
+
additional_fields: Extra camelCase fields merged into the payload as-is.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
The createLocation payload; .location holds the new location and .success
|
|
210
|
+
is True on success.
|
|
211
|
+
|
|
212
|
+
Example:
|
|
213
|
+
result = client.locations.add(
|
|
214
|
+
name="Acme Dental",
|
|
215
|
+
description="Acme Dental is a family-owned dental practice...", # 200+ chars
|
|
216
|
+
sub_category_id=1432,
|
|
217
|
+
country_iso="US",
|
|
218
|
+
city="New York",
|
|
219
|
+
street="123 Jump Street",
|
|
220
|
+
postal_code="10013",
|
|
221
|
+
phone="6443859313",
|
|
222
|
+
)
|
|
223
|
+
print(result.location.id)
|
|
224
|
+
"""
|
|
225
|
+
problems: list[str] = []
|
|
226
|
+
if not name or not (2 <= len(name.strip()) <= 150):
|
|
227
|
+
problems.append("name must be 2-150 characters")
|
|
228
|
+
if not description or len(description.strip()) < 200:
|
|
229
|
+
got = len(description.strip()) if description else 0
|
|
230
|
+
problems.append(f"description must be at least 200 characters (got {got})")
|
|
231
|
+
if not sub_category_id:
|
|
232
|
+
problems.append("sub_category_id is required")
|
|
233
|
+
if not country_iso or not str(country_iso).strip():
|
|
234
|
+
problems.append("country_iso is required")
|
|
235
|
+
if not (city and city.strip()) and hide_address is not True:
|
|
236
|
+
problems.append(
|
|
237
|
+
"city is required for countries with city-level addressing "
|
|
238
|
+
"(omit only for service-area businesses with hide_address=True)"
|
|
239
|
+
)
|
|
240
|
+
if problems:
|
|
241
|
+
from listingsapi.exceptions import ValidationError
|
|
242
|
+
|
|
243
|
+
raise ValidationError("Invalid location: " + "; ".join(problems))
|
|
244
|
+
|
|
245
|
+
input: dict[str, Any] = {
|
|
246
|
+
"name": name.strip(),
|
|
247
|
+
"description": description.strip(),
|
|
248
|
+
"subCategoryId": sub_category_id,
|
|
249
|
+
"countryIso": country_iso,
|
|
250
|
+
}
|
|
251
|
+
optional = {
|
|
252
|
+
"city": city,
|
|
253
|
+
"street": street,
|
|
254
|
+
"stateIso": state_iso,
|
|
255
|
+
"postalCode": postal_code,
|
|
256
|
+
"phone": phone,
|
|
257
|
+
"website": website,
|
|
258
|
+
"storeId": store_id,
|
|
259
|
+
"hideAddress": hide_address,
|
|
260
|
+
"businessHours": business_hours,
|
|
261
|
+
"folderIds": folder_ids,
|
|
262
|
+
"serviceArea": service_area,
|
|
263
|
+
"placeActionLinks": place_action_links,
|
|
264
|
+
"enabledSiteIds": enabled_site_ids,
|
|
265
|
+
}
|
|
266
|
+
input.update({k: v for k, v in optional.items() if v is not None})
|
|
267
|
+
if additional_fields:
|
|
268
|
+
input.update(additional_fields)
|
|
269
|
+
|
|
270
|
+
return self.create(input)
|
|
271
|
+
|
|
272
|
+
def update(self, input: dict[str, Any]) -> APIObject:
|
|
273
|
+
"""Update a location. Pass id plus fields to change."""
|
|
274
|
+
if "id" in input:
|
|
275
|
+
input = {**input, "id": encode_location_id(input["id"])}
|
|
276
|
+
data = self._post("locations/update", {"input": input})
|
|
277
|
+
return APIObject(data.get("data", {}).get("updateLocation") or {})
|
|
278
|
+
|
|
279
|
+
def archive(self, location_ids: list[str | int]) -> APIObject:
|
|
280
|
+
"""Archive one or more locations."""
|
|
281
|
+
encoded = [encode_location_id(lid) for lid in location_ids]
|
|
282
|
+
data = self._post("locations/archive", {"input": {"locationIds": encoded}})
|
|
283
|
+
return APIObject(data.get("data", {}).get("archiveLocations") or {})
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def cancel_archive(
|
|
287
|
+
self, location_ids: list[str | int], selection_type: str, changed_by: str
|
|
288
|
+
) -> APIObject:
|
|
289
|
+
"""Cancel a scheduled archival."""
|
|
290
|
+
encoded = [encode_location_id(lid) for lid in location_ids]
|
|
291
|
+
data = self._post(
|
|
292
|
+
"locations/cancel_archive",
|
|
293
|
+
{"input": {"locationIds": encoded, "selectionType": selection_type, "changedBy": changed_by}},
|
|
294
|
+
)
|
|
295
|
+
return APIObject(data.get("data", {}).get("cancelLocationsArchive") or {})
|
|
296
|
+
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Photos resource — client.photos.*"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from listingsapi._types import APIObject
|
|
8
|
+
from listingsapi._utils import encode_location_id
|
|
9
|
+
from listingsapi.resources._base import APIResource
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Photos(APIResource):
|
|
13
|
+
"""Manage location photos.
|
|
14
|
+
|
|
15
|
+
Example:
|
|
16
|
+
photos = client.photos.list(16808)
|
|
17
|
+
client.photos.add(16808, [{"photo": "https://example.com/img.jpg", "type": "ADDITIONAL"}])
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def list(self, location_id: str | int) -> list[APIObject]:
|
|
21
|
+
"""Get photos attached to a location."""
|
|
22
|
+
data = self._location_get(location_id, "photos")
|
|
23
|
+
items = data.get("data", {}).get("mediaFilesOfLocation") or []
|
|
24
|
+
return [APIObject(item) for item in items]
|
|
25
|
+
|
|
26
|
+
def add(self, location_id: str | int, photos: list[dict[str, Any]]) -> APIObject:
|
|
27
|
+
"""Add photos to a location. Each item needs 'photo' (URL) and 'type' (LOGO, COVER, ADDITIONAL)."""
|
|
28
|
+
data = self._post(
|
|
29
|
+
"locations/photos",
|
|
30
|
+
{"input": {"locationId": encode_location_id(location_id), "photos": photos}},
|
|
31
|
+
)
|
|
32
|
+
return APIObject(data.get("data", {}).get("addLocationPhotos") or {})
|
|
33
|
+
|
|
34
|
+
def remove(self, location_id: str | int, photo_ids: list[str]) -> APIObject:
|
|
35
|
+
"""Remove photos from a location."""
|
|
36
|
+
data = self._post(
|
|
37
|
+
"locations/photos/remove",
|
|
38
|
+
{"input": {"locationId": encode_location_id(location_id), "photoIds": photo_ids}},
|
|
39
|
+
)
|
|
40
|
+
return APIObject(data.get("data", {}).get("removeLocationPhotos") or {})
|
|
41
|
+
|
|
42
|
+
def star(self, location_id: str | int, media_ids: list[str], *, starred: bool = True) -> APIObject:
|
|
43
|
+
"""Star or unstar photos. Max 4 starred photos per account."""
|
|
44
|
+
data = self._post(
|
|
45
|
+
"locations/photos/star",
|
|
46
|
+
{"input": {"locationId": encode_location_id(location_id), "mediaIds": media_ids, "starred": starred}},
|
|
47
|
+
)
|
|
48
|
+
return APIObject(data.get("data", {}).get("starUnstarLocationPhotos") or {})
|
|
49
|
+
|
|
50
|
+
def upload_status(self, request_id: str) -> APIObject:
|
|
51
|
+
"""Get processing status for a bulk photo upload."""
|
|
52
|
+
data = self._get(f"locations/photos/requests/{request_id}")
|
|
53
|
+
return APIObject(data.get("data", {}).get("bulkImageProcessingStatus") or {})
|