moveezi 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.
- moveezi-1.0.0/PKG-INFO +152 -0
- moveezi-1.0.0/README.md +133 -0
- moveezi-1.0.0/moveezi/__init__.py +189 -0
- moveezi-1.0.0/moveezi/__main__.py +115 -0
- moveezi-1.0.0/moveezi.egg-info/PKG-INFO +152 -0
- moveezi-1.0.0/moveezi.egg-info/SOURCES.txt +9 -0
- moveezi-1.0.0/moveezi.egg-info/dependency_links.txt +1 -0
- moveezi-1.0.0/moveezi.egg-info/entry_points.txt +2 -0
- moveezi-1.0.0/moveezi.egg-info/top_level.txt +1 -0
- moveezi-1.0.0/pyproject.toml +33 -0
- moveezi-1.0.0/setup.cfg +4 -0
moveezi-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: moveezi
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Client for the public Moveezi API: read moveezi.com as structured data, search it, and reach the sales team. No credentials needed.
|
|
5
|
+
Author-email: Dumont Pty Ltd <sales@moveezi.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://www.moveezi.com/developers
|
|
8
|
+
Project-URL: Documentation, https://www.moveezi.com/openapi.json
|
|
9
|
+
Project-URL: Repository, https://github.com/DumontAI/moveezi-sdk
|
|
10
|
+
Project-URL: Issues, https://github.com/DumontAI/moveezi-sdk/issues
|
|
11
|
+
Keywords: moveezi,moving,relocation,storage,logistics,api,sdk,mcp,agent,llm
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Office/Business
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# Moveezi SDK
|
|
21
|
+
|
|
22
|
+
Client libraries and a CLI for the public [Moveezi](https://www.moveezi.com/) API.
|
|
23
|
+
|
|
24
|
+
[Moveezi](https://www.moveezi.com/) is the operating system for moving, relocation and
|
|
25
|
+
storage companies: sales and quoting, on-site and video surveys, dispatch and crew
|
|
26
|
+
scheduling, a mobile crew app, warehousing, sea/air/road shipment tracking, a live control
|
|
27
|
+
tower and per-job profit and loss.
|
|
28
|
+
|
|
29
|
+
**There is nothing to authenticate with.** No key, no signup, no token. Every read is open.
|
|
30
|
+
The one write, `submitLead`, is the same endpoint the website's own forms post to, and it
|
|
31
|
+
has a sandbox so you can check a payload without creating anything.
|
|
32
|
+
|
|
33
|
+
- API reference: <https://www.moveezi.com/openapi.json>
|
|
34
|
+
- Developer portal: <https://www.moveezi.com/developers>
|
|
35
|
+
- MCP server: `https://www.moveezi.com/mcp` (Streamable HTTP, no credentials)
|
|
36
|
+
- Authentication, in one page: <https://www.moveezi.com/auth.md>
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
**JavaScript / TypeScript** (Node 18+, zero dependencies):
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npm install moveezi
|
|
44
|
+
# or straight from source, no registry account needed:
|
|
45
|
+
npm install DumontAI/moveezi-sdk
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**Python** (3.9+, standard library only):
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install moveezi
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**CLI**, without installing anything:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npx moveezi search "video survey"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Use it
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
import { MoveeziClient } from "moveezi";
|
|
64
|
+
|
|
65
|
+
const moveezi = new MoveeziClient();
|
|
66
|
+
|
|
67
|
+
const hits = await moveezi.searchSite("video survey", { limit: 3 });
|
|
68
|
+
const pricing = await moveezi.getPage("pricing", { format: "markdown" });
|
|
69
|
+
const pages = await moveezi.allPages({ prefix: "blog/" });
|
|
70
|
+
|
|
71
|
+
// Check a lead before you send one. Creates nothing.
|
|
72
|
+
const check = await moveezi.validateLead({ name: "Ada", email: "ada@example.com" });
|
|
73
|
+
if (check.wouldSubmit) {
|
|
74
|
+
await moveezi.submitLead(
|
|
75
|
+
{ name: "Ada", email: "ada@example.com", intent: "demo" },
|
|
76
|
+
{ idempotencyKey: crypto.randomUUID() }, // a retry will not create a second lead
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from moveezi import Moveezi
|
|
83
|
+
|
|
84
|
+
moveezi = Moveezi()
|
|
85
|
+
hits = moveezi.search("video survey", limit=3)
|
|
86
|
+
pricing = moveezi.get_page("pricing", markdown=True)
|
|
87
|
+
pages = list(moveezi.all_pages(prefix="blog/"))
|
|
88
|
+
|
|
89
|
+
check = moveezi.validate_lead({"name": "Ada", "email": "ada@example.com"})
|
|
90
|
+
if check["wouldSubmit"]:
|
|
91
|
+
moveezi.submit_lead({"name": "Ada", "email": "ada@example.com"},
|
|
92
|
+
idempotency_key="a3f1c9e2")
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## CLI
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
moveezi search <query> Search moveezi.com
|
|
99
|
+
moveezi page <slug> One page as markdown
|
|
100
|
+
moveezi pages [--prefix] Every published page
|
|
101
|
+
moveezi batch <slug...> Several pages in one request
|
|
102
|
+
moveezi pricing The pricing page
|
|
103
|
+
moveezi lead [--submit] Check a lead; only sends with --submit
|
|
104
|
+
moveezi openapi The OpenAPI 3.1 document
|
|
105
|
+
moveezi auth How to authenticate here (you do not)
|
|
106
|
+
moveezi policy Versioning and deprecation policy
|
|
107
|
+
moveezi mcp MCP endpoint and client config
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Rate limits
|
|
111
|
+
|
|
112
|
+
Reads are 120 requests per minute per IP. `POST /api/lead` is 5 per hour per IP. Both
|
|
113
|
+
report `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` and `RateLimit-Policy`
|
|
114
|
+
on every response, and `Retry-After` on a 429. Both clients expose them:
|
|
115
|
+
|
|
116
|
+
```js
|
|
117
|
+
await moveezi.listPages();
|
|
118
|
+
moveezi.lastRateLimit; // { limit: 120, remaining: 119, reset: 60 }
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
A 429 throws a `MoveeziError` with `retryAfter` in seconds, so you can back off on real
|
|
122
|
+
numbers rather than guessing.
|
|
123
|
+
|
|
124
|
+
## If you are an AI agent
|
|
125
|
+
|
|
126
|
+
You may not need this package at all. The API is plain HTTP with an
|
|
127
|
+
[OpenAPI 3.1 document](https://www.moveezi.com/openapi.json), every page is available as
|
|
128
|
+
markdown (append `.md`, or send `Accept: text/markdown`), and there is an
|
|
129
|
+
[MCP server](https://www.moveezi.com/mcp) with tools for search, page retrieval and
|
|
130
|
+
pricing. Start at [llms.txt](https://www.moveezi.com/llms.txt).
|
|
131
|
+
|
|
132
|
+
Please only call `submitLead` on behalf of a person who asked you to contact Moveezi, and
|
|
133
|
+
send an `Idempotency-Key` so a retry does not create a duplicate. `validateLead` is there
|
|
134
|
+
so you never have to learn the rules by creating a bad record.
|
|
135
|
+
|
|
136
|
+
## Stability
|
|
137
|
+
|
|
138
|
+
The version is in the path (`/api/v1`) and this API changes additively within it. Anything
|
|
139
|
+
being withdrawn gets `Deprecation` and `Sunset` headers and six months' notice first. Full
|
|
140
|
+
policy: <https://www.moveezi.com/api-deprecation-policy>.
|
|
141
|
+
|
|
142
|
+
## Development
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
npm test # JavaScript, no network
|
|
146
|
+
MOVEEZI_LIVE=1 npm test # plus a couple against production
|
|
147
|
+
cd python && python3 -m unittest test_moveezi
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Licence
|
|
151
|
+
|
|
152
|
+
MIT. See [LICENSE](LICENSE). The API and the content it serves belong to Dumont Pty Ltd.
|
moveezi-1.0.0/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Moveezi SDK
|
|
2
|
+
|
|
3
|
+
Client libraries and a CLI for the public [Moveezi](https://www.moveezi.com/) API.
|
|
4
|
+
|
|
5
|
+
[Moveezi](https://www.moveezi.com/) is the operating system for moving, relocation and
|
|
6
|
+
storage companies: sales and quoting, on-site and video surveys, dispatch and crew
|
|
7
|
+
scheduling, a mobile crew app, warehousing, sea/air/road shipment tracking, a live control
|
|
8
|
+
tower and per-job profit and loss.
|
|
9
|
+
|
|
10
|
+
**There is nothing to authenticate with.** No key, no signup, no token. Every read is open.
|
|
11
|
+
The one write, `submitLead`, is the same endpoint the website's own forms post to, and it
|
|
12
|
+
has a sandbox so you can check a payload without creating anything.
|
|
13
|
+
|
|
14
|
+
- API reference: <https://www.moveezi.com/openapi.json>
|
|
15
|
+
- Developer portal: <https://www.moveezi.com/developers>
|
|
16
|
+
- MCP server: `https://www.moveezi.com/mcp` (Streamable HTTP, no credentials)
|
|
17
|
+
- Authentication, in one page: <https://www.moveezi.com/auth.md>
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
**JavaScript / TypeScript** (Node 18+, zero dependencies):
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install moveezi
|
|
25
|
+
# or straight from source, no registry account needed:
|
|
26
|
+
npm install DumontAI/moveezi-sdk
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**Python** (3.9+, standard library only):
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install moveezi
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**CLI**, without installing anything:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npx moveezi search "video survey"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Use it
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
import { MoveeziClient } from "moveezi";
|
|
45
|
+
|
|
46
|
+
const moveezi = new MoveeziClient();
|
|
47
|
+
|
|
48
|
+
const hits = await moveezi.searchSite("video survey", { limit: 3 });
|
|
49
|
+
const pricing = await moveezi.getPage("pricing", { format: "markdown" });
|
|
50
|
+
const pages = await moveezi.allPages({ prefix: "blog/" });
|
|
51
|
+
|
|
52
|
+
// Check a lead before you send one. Creates nothing.
|
|
53
|
+
const check = await moveezi.validateLead({ name: "Ada", email: "ada@example.com" });
|
|
54
|
+
if (check.wouldSubmit) {
|
|
55
|
+
await moveezi.submitLead(
|
|
56
|
+
{ name: "Ada", email: "ada@example.com", intent: "demo" },
|
|
57
|
+
{ idempotencyKey: crypto.randomUUID() }, // a retry will not create a second lead
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from moveezi import Moveezi
|
|
64
|
+
|
|
65
|
+
moveezi = Moveezi()
|
|
66
|
+
hits = moveezi.search("video survey", limit=3)
|
|
67
|
+
pricing = moveezi.get_page("pricing", markdown=True)
|
|
68
|
+
pages = list(moveezi.all_pages(prefix="blog/"))
|
|
69
|
+
|
|
70
|
+
check = moveezi.validate_lead({"name": "Ada", "email": "ada@example.com"})
|
|
71
|
+
if check["wouldSubmit"]:
|
|
72
|
+
moveezi.submit_lead({"name": "Ada", "email": "ada@example.com"},
|
|
73
|
+
idempotency_key="a3f1c9e2")
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## CLI
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
moveezi search <query> Search moveezi.com
|
|
80
|
+
moveezi page <slug> One page as markdown
|
|
81
|
+
moveezi pages [--prefix] Every published page
|
|
82
|
+
moveezi batch <slug...> Several pages in one request
|
|
83
|
+
moveezi pricing The pricing page
|
|
84
|
+
moveezi lead [--submit] Check a lead; only sends with --submit
|
|
85
|
+
moveezi openapi The OpenAPI 3.1 document
|
|
86
|
+
moveezi auth How to authenticate here (you do not)
|
|
87
|
+
moveezi policy Versioning and deprecation policy
|
|
88
|
+
moveezi mcp MCP endpoint and client config
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Rate limits
|
|
92
|
+
|
|
93
|
+
Reads are 120 requests per minute per IP. `POST /api/lead` is 5 per hour per IP. Both
|
|
94
|
+
report `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` and `RateLimit-Policy`
|
|
95
|
+
on every response, and `Retry-After` on a 429. Both clients expose them:
|
|
96
|
+
|
|
97
|
+
```js
|
|
98
|
+
await moveezi.listPages();
|
|
99
|
+
moveezi.lastRateLimit; // { limit: 120, remaining: 119, reset: 60 }
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
A 429 throws a `MoveeziError` with `retryAfter` in seconds, so you can back off on real
|
|
103
|
+
numbers rather than guessing.
|
|
104
|
+
|
|
105
|
+
## If you are an AI agent
|
|
106
|
+
|
|
107
|
+
You may not need this package at all. The API is plain HTTP with an
|
|
108
|
+
[OpenAPI 3.1 document](https://www.moveezi.com/openapi.json), every page is available as
|
|
109
|
+
markdown (append `.md`, or send `Accept: text/markdown`), and there is an
|
|
110
|
+
[MCP server](https://www.moveezi.com/mcp) with tools for search, page retrieval and
|
|
111
|
+
pricing. Start at [llms.txt](https://www.moveezi.com/llms.txt).
|
|
112
|
+
|
|
113
|
+
Please only call `submitLead` on behalf of a person who asked you to contact Moveezi, and
|
|
114
|
+
send an `Idempotency-Key` so a retry does not create a duplicate. `validateLead` is there
|
|
115
|
+
so you never have to learn the rules by creating a bad record.
|
|
116
|
+
|
|
117
|
+
## Stability
|
|
118
|
+
|
|
119
|
+
The version is in the path (`/api/v1`) and this API changes additively within it. Anything
|
|
120
|
+
being withdrawn gets `Deprecation` and `Sunset` headers and six months' notice first. Full
|
|
121
|
+
policy: <https://www.moveezi.com/api-deprecation-policy>.
|
|
122
|
+
|
|
123
|
+
## Development
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
npm test # JavaScript, no network
|
|
127
|
+
MOVEEZI_LIVE=1 npm test # plus a couple against production
|
|
128
|
+
cd python && python3 -m unittest test_moveezi
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Licence
|
|
132
|
+
|
|
133
|
+
MIT. See [LICENSE](LICENSE). The API and the content it serves belong to Dumont Pty Ltd.
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Client for the public Moveezi API.
|
|
2
|
+
|
|
3
|
+
There is nothing to authenticate with: every read is open, and the one write
|
|
4
|
+
(``submit_lead``) is the same endpoint the website's own forms post to. The
|
|
5
|
+
description this mirrors is at https://www.moveezi.com/openapi.json.
|
|
6
|
+
|
|
7
|
+
Standard library only, so it installs anywhere without pulling a dependency
|
|
8
|
+
tree behind it.
|
|
9
|
+
|
|
10
|
+
>>> from moveezi import Moveezi
|
|
11
|
+
>>> Moveezi().search("video survey")["count"] # doctest: +SKIP
|
|
12
|
+
5
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.parse
|
|
19
|
+
import urllib.request
|
|
20
|
+
from typing import Any, Dict, Iterator, List, Optional
|
|
21
|
+
|
|
22
|
+
__all__ = ["Moveezi", "MoveeziError", "MCP_ENDPOINT", "MCP_MANIFEST", "__version__"]
|
|
23
|
+
|
|
24
|
+
__version__ = "1.0.0"
|
|
25
|
+
|
|
26
|
+
BASE = "https://www.moveezi.com"
|
|
27
|
+
MCP_ENDPOINT = BASE + "/mcp"
|
|
28
|
+
MCP_MANIFEST = BASE + "/.well-known/mcp.json"
|
|
29
|
+
_UA = "moveezi-sdk-python/%s (+https://www.moveezi.com/developers)" % __version__
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MoveeziError(Exception):
|
|
33
|
+
"""Any non-2xx response, carrying what the API said and its limits."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, message: str, status: Optional[int] = None,
|
|
36
|
+
body: Any = None, rate_limit: Optional[Dict[str, int]] = None,
|
|
37
|
+
retry_after: Optional[int] = None) -> None:
|
|
38
|
+
super().__init__(message)
|
|
39
|
+
self.status = status
|
|
40
|
+
self.body = body
|
|
41
|
+
self.rate_limit = rate_limit
|
|
42
|
+
#: Seconds to wait. Present on a 429.
|
|
43
|
+
self.retry_after = retry_after
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _rate_limit(headers) -> Optional[Dict[str, int]]:
|
|
47
|
+
limit = headers.get("RateLimit-Limit")
|
|
48
|
+
if limit is None:
|
|
49
|
+
return None
|
|
50
|
+
out = {"limit": int(limit)}
|
|
51
|
+
for key, name in (("remaining", "RateLimit-Remaining"), ("reset", "RateLimit-Reset")):
|
|
52
|
+
value = headers.get(name)
|
|
53
|
+
if value is not None:
|
|
54
|
+
out[key] = int(value)
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Moveezi:
|
|
59
|
+
"""The public Moveezi API. No credentials, no setup."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, base_url: str = BASE, timeout: float = 30.0,
|
|
62
|
+
user_agent: str = _UA) -> None:
|
|
63
|
+
self.base_url = base_url.rstrip("/")
|
|
64
|
+
self.timeout = timeout
|
|
65
|
+
self.user_agent = user_agent
|
|
66
|
+
#: Rate-limit headers from the most recent response.
|
|
67
|
+
self.last_rate_limit: Optional[Dict[str, int]] = None
|
|
68
|
+
|
|
69
|
+
# -- plumbing ---------------------------------------------------------
|
|
70
|
+
def _request(self, path: str, method: str = "GET", body: Any = None,
|
|
71
|
+
accept: str = "application/json") -> Any:
|
|
72
|
+
data = None if body is None else json.dumps(body).encode("utf-8")
|
|
73
|
+
headers = {"Accept": accept, "User-Agent": self.user_agent}
|
|
74
|
+
if data is not None:
|
|
75
|
+
headers["Content-Type"] = "application/json"
|
|
76
|
+
request = urllib.request.Request(self.base_url + path, data=data,
|
|
77
|
+
headers=headers, method=method)
|
|
78
|
+
try:
|
|
79
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
80
|
+
self.last_rate_limit = _rate_limit(response.headers)
|
|
81
|
+
raw = response.read().decode("utf-8")
|
|
82
|
+
ctype = response.headers.get("Content-Type", "")
|
|
83
|
+
return json.loads(raw) if "json" in ctype else raw
|
|
84
|
+
except urllib.error.HTTPError as err:
|
|
85
|
+
raw = err.read().decode("utf-8", "replace")
|
|
86
|
+
try:
|
|
87
|
+
payload = json.loads(raw)
|
|
88
|
+
except ValueError:
|
|
89
|
+
payload = raw
|
|
90
|
+
self.last_rate_limit = _rate_limit(err.headers)
|
|
91
|
+
# 422 from the sandbox is the answer the caller asked for, not a failure.
|
|
92
|
+
if err.code == 422 and path.startswith("/api/v1/lead/validate"):
|
|
93
|
+
return payload
|
|
94
|
+
retry = err.headers.get("Retry-After")
|
|
95
|
+
message = (payload.get("error") if isinstance(payload, dict)
|
|
96
|
+
else None) or "Moveezi API returned %s" % err.code
|
|
97
|
+
raise MoveeziError(message, status=err.code, body=payload,
|
|
98
|
+
rate_limit=self.last_rate_limit,
|
|
99
|
+
retry_after=int(retry) if retry else None) from None
|
|
100
|
+
|
|
101
|
+
# -- content ----------------------------------------------------------
|
|
102
|
+
def list_pages(self, limit: Optional[int] = None, cursor: Optional[str] = None,
|
|
103
|
+
prefix: Optional[str] = None) -> Dict[str, Any]:
|
|
104
|
+
"""One page of the page index. Follow ``nextCursor`` for the rest."""
|
|
105
|
+
query = {k: v for k, v in (("limit", limit), ("cursor", cursor),
|
|
106
|
+
("prefix", prefix)) if v is not None}
|
|
107
|
+
suffix = "?" + urllib.parse.urlencode(query) if query else ""
|
|
108
|
+
return self._request("/api/v1/pages" + suffix)
|
|
109
|
+
|
|
110
|
+
def all_pages(self, prefix: Optional[str] = None) -> Iterator[Dict[str, Any]]:
|
|
111
|
+
"""Every page, following the cursor for you."""
|
|
112
|
+
cursor = None
|
|
113
|
+
while True:
|
|
114
|
+
batch = self.list_pages(limit=100, cursor=cursor, prefix=prefix)
|
|
115
|
+
for page in batch["pages"]:
|
|
116
|
+
yield page
|
|
117
|
+
cursor = batch.get("nextCursor")
|
|
118
|
+
if not cursor:
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
def get_page(self, slug: str, markdown: bool = False):
|
|
122
|
+
"""One page. ``markdown=True`` returns the markdown document as a string."""
|
|
123
|
+
path = "/api/v1/pages/" + slug.strip("/")
|
|
124
|
+
return self._request(path, accept="text/markdown" if markdown else "application/json")
|
|
125
|
+
|
|
126
|
+
def get_pages(self, slugs: List[str]) -> Dict[str, Any]:
|
|
127
|
+
"""Up to 50 pages in one round trip. Missing slugs come back marked."""
|
|
128
|
+
return self._request("/api/v1/pages/batch", "POST", {"slugs": list(slugs)})
|
|
129
|
+
|
|
130
|
+
def search(self, query: str, limit: Optional[int] = None) -> Dict[str, Any]:
|
|
131
|
+
"""Ranked full-text search across the site."""
|
|
132
|
+
params = {"q": query}
|
|
133
|
+
if limit is not None:
|
|
134
|
+
params["limit"] = limit
|
|
135
|
+
return self._request("/api/v1/search?" + urllib.parse.urlencode(params))
|
|
136
|
+
|
|
137
|
+
# -- sales ------------------------------------------------------------
|
|
138
|
+
def validate_lead(self, lead: Dict[str, Any]) -> Dict[str, Any]:
|
|
139
|
+
"""Check a lead against the same rules as ``submit_lead``, writing nothing."""
|
|
140
|
+
return self._request("/api/v1/lead/validate", "POST", lead)
|
|
141
|
+
|
|
142
|
+
def submit_lead(self, lead: Dict[str, Any],
|
|
143
|
+
idempotency_key: Optional[str] = None) -> Dict[str, Any]:
|
|
144
|
+
"""Send an enquiry to the Moveezi sales team.
|
|
145
|
+
|
|
146
|
+
This creates a CRM record and emails a human, so only call it on behalf
|
|
147
|
+
of someone who asked you to. With an ``idempotency_key``, a retry
|
|
148
|
+
replays the first response instead of creating a second lead.
|
|
149
|
+
"""
|
|
150
|
+
data = json.dumps(lead).encode("utf-8")
|
|
151
|
+
headers = {"Accept": "application/json", "Content-Type": "application/json",
|
|
152
|
+
"User-Agent": self.user_agent}
|
|
153
|
+
if idempotency_key:
|
|
154
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
155
|
+
request = urllib.request.Request(self.base_url + "/api/lead", data=data,
|
|
156
|
+
headers=headers, method="POST")
|
|
157
|
+
try:
|
|
158
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
159
|
+
self.last_rate_limit = _rate_limit(response.headers)
|
|
160
|
+
return json.loads(response.read().decode("utf-8"))
|
|
161
|
+
except urllib.error.HTTPError as err:
|
|
162
|
+
raw = err.read().decode("utf-8", "replace")
|
|
163
|
+
try:
|
|
164
|
+
payload = json.loads(raw)
|
|
165
|
+
except ValueError:
|
|
166
|
+
payload = raw
|
|
167
|
+
retry = err.headers.get("Retry-After")
|
|
168
|
+
raise MoveeziError(
|
|
169
|
+
(payload.get("error") if isinstance(payload, dict) else None)
|
|
170
|
+
or "Moveezi API returned %s" % err.code,
|
|
171
|
+
status=err.code, body=payload, rate_limit=_rate_limit(err.headers),
|
|
172
|
+
retry_after=int(retry) if retry else None) from None
|
|
173
|
+
|
|
174
|
+
# -- documents --------------------------------------------------------
|
|
175
|
+
def openapi(self) -> Dict[str, Any]:
|
|
176
|
+
"""The OpenAPI 3.1 description of everything above."""
|
|
177
|
+
return self._request("/openapi.json")
|
|
178
|
+
|
|
179
|
+
def sandbox(self) -> Dict[str, Any]:
|
|
180
|
+
"""Where the sandbox is and what it covers."""
|
|
181
|
+
return self._request("/api/v1/sandbox")
|
|
182
|
+
|
|
183
|
+
def auth_doc(self) -> str:
|
|
184
|
+
"""How authentication works here, which is: it does not."""
|
|
185
|
+
return self._request("/auth.md", accept="text/markdown")
|
|
186
|
+
|
|
187
|
+
def deprecation_policy(self) -> str:
|
|
188
|
+
"""How this API is allowed to change, and how much notice you get."""
|
|
189
|
+
return self._request("/api-deprecation-policy", accept="text/markdown")
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""`python -m moveezi` / the `moveezi` console script."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from . import MCP_ENDPOINT, Moveezi, MoveeziError, __version__
|
|
9
|
+
|
|
10
|
+
LEAD_FIELDS = ("name", "email", "company", "phone", "dialcode", "country",
|
|
11
|
+
"message", "intent", "segment", "warehouses", "moves")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main(argv=None) -> int:
|
|
15
|
+
parser = argparse.ArgumentParser(
|
|
16
|
+
prog="moveezi",
|
|
17
|
+
description="Read moveezi.com from a terminal. Nothing here needs a key.")
|
|
18
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
19
|
+
parser.add_argument("--base", default=None, help="point at a different host")
|
|
20
|
+
parser.add_argument("--json", action="store_true", help="raw JSON instead of text")
|
|
21
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
22
|
+
|
|
23
|
+
p = sub.add_parser("search", help="search moveezi.com")
|
|
24
|
+
p.add_argument("query", nargs="+")
|
|
25
|
+
p.add_argument("--limit", type=int)
|
|
26
|
+
|
|
27
|
+
p = sub.add_parser("page", help="one page as markdown")
|
|
28
|
+
p.add_argument("slug")
|
|
29
|
+
|
|
30
|
+
p = sub.add_parser("pages", help="every published page")
|
|
31
|
+
p.add_argument("--prefix")
|
|
32
|
+
|
|
33
|
+
p = sub.add_parser("lead", help="check a lead; --submit to actually send it")
|
|
34
|
+
for field in LEAD_FIELDS:
|
|
35
|
+
p.add_argument("--" + field)
|
|
36
|
+
p.add_argument("--idempotency-key")
|
|
37
|
+
p.add_argument("--submit", action="store_true")
|
|
38
|
+
|
|
39
|
+
sub.add_parser("openapi", help="the OpenAPI 3.1 document")
|
|
40
|
+
sub.add_parser("auth", help="how to authenticate here (you do not)")
|
|
41
|
+
sub.add_parser("policy", help="versioning and deprecation policy")
|
|
42
|
+
sub.add_parser("mcp", help="MCP endpoint and client config")
|
|
43
|
+
|
|
44
|
+
args = parser.parse_args(argv)
|
|
45
|
+
client = Moveezi(args.base) if args.base else Moveezi()
|
|
46
|
+
|
|
47
|
+
def out(value):
|
|
48
|
+
if args.json or not isinstance(value, str):
|
|
49
|
+
print(json.dumps(value, indent=2))
|
|
50
|
+
else:
|
|
51
|
+
print(value)
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
if args.command == "search":
|
|
55
|
+
data = client.search(" ".join(args.query), args.limit)
|
|
56
|
+
if args.json:
|
|
57
|
+
return out(data) or 0
|
|
58
|
+
if not data["count"]:
|
|
59
|
+
print("Nothing on moveezi.com matches that.")
|
|
60
|
+
return 0
|
|
61
|
+
for r in data["results"]:
|
|
62
|
+
print("\n%s\n %s\n %s" % (r["title"], r["url"], r["snippet"]))
|
|
63
|
+
return 0
|
|
64
|
+
if args.command == "page":
|
|
65
|
+
out(client.get_page(args.slug, markdown=not args.json))
|
|
66
|
+
return 0
|
|
67
|
+
if args.command == "pages":
|
|
68
|
+
pages = list(client.all_pages(args.prefix))
|
|
69
|
+
if args.json:
|
|
70
|
+
return out(pages) or 0
|
|
71
|
+
for p in pages:
|
|
72
|
+
print("%-34s %s" % (p["slug"], p["title"]))
|
|
73
|
+
return 0
|
|
74
|
+
if args.command == "lead":
|
|
75
|
+
lead = {f: getattr(args, f) for f in LEAD_FIELDS if getattr(args, f)}
|
|
76
|
+
if args.submit:
|
|
77
|
+
out(client.submit_lead(lead, args.idempotency_key))
|
|
78
|
+
return 0
|
|
79
|
+
res = client.validate_lead(lead)
|
|
80
|
+
if args.json:
|
|
81
|
+
return out(res) or 0
|
|
82
|
+
if res["wouldSubmit"]:
|
|
83
|
+
print("Looks good. Nothing was sent - add --submit to send it.")
|
|
84
|
+
return 0
|
|
85
|
+
print("This would be rejected:")
|
|
86
|
+
for problem in res["problems"]:
|
|
87
|
+
print(" %s: %s" % (problem["field"], problem["error"]))
|
|
88
|
+
return 1
|
|
89
|
+
if args.command == "openapi":
|
|
90
|
+
return out(client.openapi()) or 0
|
|
91
|
+
if args.command == "auth":
|
|
92
|
+
return out(client.auth_doc()) or 0
|
|
93
|
+
if args.command == "policy":
|
|
94
|
+
return out(client.deprecation_policy()) or 0
|
|
95
|
+
if args.command == "mcp":
|
|
96
|
+
config = {"mcpServers": {"moveezi": {"type": "http", "url": MCP_ENDPOINT}}}
|
|
97
|
+
if args.json:
|
|
98
|
+
return out(config) or 0
|
|
99
|
+
print("Moveezi speaks MCP over Streamable HTTP.\n\n %s\n\n"
|
|
100
|
+
"Claude Code:\n claude mcp add --transport http moveezi %s\n\n"
|
|
101
|
+
"Any client that reads a config file:\n%s\n\n"
|
|
102
|
+
"No credentials. Tools: search_moveezi_site, get_moveezi_page,\n"
|
|
103
|
+
"list_moveezi_pages, get_moveezi_pricing."
|
|
104
|
+
% (MCP_ENDPOINT, MCP_ENDPOINT, json.dumps(config, indent=2)))
|
|
105
|
+
return 0
|
|
106
|
+
except MoveeziError as err:
|
|
107
|
+
print("moveezi: %s" % err, file=sys.stderr)
|
|
108
|
+
if err.retry_after:
|
|
109
|
+
print(" rate limited; retry in %ss" % err.retry_after, file=sys.stderr)
|
|
110
|
+
return 1
|
|
111
|
+
return 2
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
sys.exit(main())
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: moveezi
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Client for the public Moveezi API: read moveezi.com as structured data, search it, and reach the sales team. No credentials needed.
|
|
5
|
+
Author-email: Dumont Pty Ltd <sales@moveezi.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://www.moveezi.com/developers
|
|
8
|
+
Project-URL: Documentation, https://www.moveezi.com/openapi.json
|
|
9
|
+
Project-URL: Repository, https://github.com/DumontAI/moveezi-sdk
|
|
10
|
+
Project-URL: Issues, https://github.com/DumontAI/moveezi-sdk/issues
|
|
11
|
+
Keywords: moveezi,moving,relocation,storage,logistics,api,sdk,mcp,agent,llm
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Office/Business
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# Moveezi SDK
|
|
21
|
+
|
|
22
|
+
Client libraries and a CLI for the public [Moveezi](https://www.moveezi.com/) API.
|
|
23
|
+
|
|
24
|
+
[Moveezi](https://www.moveezi.com/) is the operating system for moving, relocation and
|
|
25
|
+
storage companies: sales and quoting, on-site and video surveys, dispatch and crew
|
|
26
|
+
scheduling, a mobile crew app, warehousing, sea/air/road shipment tracking, a live control
|
|
27
|
+
tower and per-job profit and loss.
|
|
28
|
+
|
|
29
|
+
**There is nothing to authenticate with.** No key, no signup, no token. Every read is open.
|
|
30
|
+
The one write, `submitLead`, is the same endpoint the website's own forms post to, and it
|
|
31
|
+
has a sandbox so you can check a payload without creating anything.
|
|
32
|
+
|
|
33
|
+
- API reference: <https://www.moveezi.com/openapi.json>
|
|
34
|
+
- Developer portal: <https://www.moveezi.com/developers>
|
|
35
|
+
- MCP server: `https://www.moveezi.com/mcp` (Streamable HTTP, no credentials)
|
|
36
|
+
- Authentication, in one page: <https://www.moveezi.com/auth.md>
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
**JavaScript / TypeScript** (Node 18+, zero dependencies):
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npm install moveezi
|
|
44
|
+
# or straight from source, no registry account needed:
|
|
45
|
+
npm install DumontAI/moveezi-sdk
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**Python** (3.9+, standard library only):
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install moveezi
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**CLI**, without installing anything:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npx moveezi search "video survey"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Use it
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
import { MoveeziClient } from "moveezi";
|
|
64
|
+
|
|
65
|
+
const moveezi = new MoveeziClient();
|
|
66
|
+
|
|
67
|
+
const hits = await moveezi.searchSite("video survey", { limit: 3 });
|
|
68
|
+
const pricing = await moveezi.getPage("pricing", { format: "markdown" });
|
|
69
|
+
const pages = await moveezi.allPages({ prefix: "blog/" });
|
|
70
|
+
|
|
71
|
+
// Check a lead before you send one. Creates nothing.
|
|
72
|
+
const check = await moveezi.validateLead({ name: "Ada", email: "ada@example.com" });
|
|
73
|
+
if (check.wouldSubmit) {
|
|
74
|
+
await moveezi.submitLead(
|
|
75
|
+
{ name: "Ada", email: "ada@example.com", intent: "demo" },
|
|
76
|
+
{ idempotencyKey: crypto.randomUUID() }, // a retry will not create a second lead
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from moveezi import Moveezi
|
|
83
|
+
|
|
84
|
+
moveezi = Moveezi()
|
|
85
|
+
hits = moveezi.search("video survey", limit=3)
|
|
86
|
+
pricing = moveezi.get_page("pricing", markdown=True)
|
|
87
|
+
pages = list(moveezi.all_pages(prefix="blog/"))
|
|
88
|
+
|
|
89
|
+
check = moveezi.validate_lead({"name": "Ada", "email": "ada@example.com"})
|
|
90
|
+
if check["wouldSubmit"]:
|
|
91
|
+
moveezi.submit_lead({"name": "Ada", "email": "ada@example.com"},
|
|
92
|
+
idempotency_key="a3f1c9e2")
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## CLI
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
moveezi search <query> Search moveezi.com
|
|
99
|
+
moveezi page <slug> One page as markdown
|
|
100
|
+
moveezi pages [--prefix] Every published page
|
|
101
|
+
moveezi batch <slug...> Several pages in one request
|
|
102
|
+
moveezi pricing The pricing page
|
|
103
|
+
moveezi lead [--submit] Check a lead; only sends with --submit
|
|
104
|
+
moveezi openapi The OpenAPI 3.1 document
|
|
105
|
+
moveezi auth How to authenticate here (you do not)
|
|
106
|
+
moveezi policy Versioning and deprecation policy
|
|
107
|
+
moveezi mcp MCP endpoint and client config
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Rate limits
|
|
111
|
+
|
|
112
|
+
Reads are 120 requests per minute per IP. `POST /api/lead` is 5 per hour per IP. Both
|
|
113
|
+
report `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` and `RateLimit-Policy`
|
|
114
|
+
on every response, and `Retry-After` on a 429. Both clients expose them:
|
|
115
|
+
|
|
116
|
+
```js
|
|
117
|
+
await moveezi.listPages();
|
|
118
|
+
moveezi.lastRateLimit; // { limit: 120, remaining: 119, reset: 60 }
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
A 429 throws a `MoveeziError` with `retryAfter` in seconds, so you can back off on real
|
|
122
|
+
numbers rather than guessing.
|
|
123
|
+
|
|
124
|
+
## If you are an AI agent
|
|
125
|
+
|
|
126
|
+
You may not need this package at all. The API is plain HTTP with an
|
|
127
|
+
[OpenAPI 3.1 document](https://www.moveezi.com/openapi.json), every page is available as
|
|
128
|
+
markdown (append `.md`, or send `Accept: text/markdown`), and there is an
|
|
129
|
+
[MCP server](https://www.moveezi.com/mcp) with tools for search, page retrieval and
|
|
130
|
+
pricing. Start at [llms.txt](https://www.moveezi.com/llms.txt).
|
|
131
|
+
|
|
132
|
+
Please only call `submitLead` on behalf of a person who asked you to contact Moveezi, and
|
|
133
|
+
send an `Idempotency-Key` so a retry does not create a duplicate. `validateLead` is there
|
|
134
|
+
so you never have to learn the rules by creating a bad record.
|
|
135
|
+
|
|
136
|
+
## Stability
|
|
137
|
+
|
|
138
|
+
The version is in the path (`/api/v1`) and this API changes additively within it. Anything
|
|
139
|
+
being withdrawn gets `Deprecation` and `Sunset` headers and six months' notice first. Full
|
|
140
|
+
policy: <https://www.moveezi.com/api-deprecation-policy>.
|
|
141
|
+
|
|
142
|
+
## Development
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
npm test # JavaScript, no network
|
|
146
|
+
MOVEEZI_LIVE=1 npm test # plus a couple against production
|
|
147
|
+
cd python && python3 -m unittest test_moveezi
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Licence
|
|
151
|
+
|
|
152
|
+
MIT. See [LICENSE](LICENSE). The API and the content it serves belong to Dumont Pty Ltd.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
moveezi
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "moveezi"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Client for the public Moveezi API: read moveezi.com as structured data, search it, and reach the sales team. No credentials needed."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Dumont Pty Ltd", email = "sales@moveezi.com" }]
|
|
13
|
+
keywords = ["moveezi", "moving", "relocation", "storage", "logistics", "api", "sdk", "mcp", "agent", "llm"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Office/Business",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://www.moveezi.com/developers"
|
|
25
|
+
Documentation = "https://www.moveezi.com/openapi.json"
|
|
26
|
+
Repository = "https://github.com/DumontAI/moveezi-sdk"
|
|
27
|
+
Issues = "https://github.com/DumontAI/moveezi-sdk/issues"
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
moveezi = "moveezi.__main__:main"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
include = ["moveezi*"]
|
moveezi-1.0.0/setup.cfg
ADDED