quillbot 0.1.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.
- quillbot/__init__.py +21 -0
- quillbot/auth.py +367 -0
- quillbot/client.py +286 -0
- quillbot/endpoints.py +212 -0
- quillbot/exceptions.py +42 -0
- quillbot/http.py +132 -0
- quillbot/models.py +79 -0
- quillbot/server.py +61 -0
- quillbot-0.1.0.dist-info/METADATA +125 -0
- quillbot-0.1.0.dist-info/RECORD +13 -0
- quillbot-0.1.0.dist-info/WHEEL +5 -0
- quillbot-0.1.0.dist-info/entry_points.txt +2 -0
- quillbot-0.1.0.dist-info/top_level.txt +1 -0
quillbot/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""quillbot -- Lightweight Python SDK for QuillBot."""
|
|
2
|
+
|
|
3
|
+
from quillbot.client import QuillBot
|
|
4
|
+
from quillbot.models import ParaphraseResult, SynonymMap, SummarizeResult
|
|
5
|
+
from quillbot.exceptions import (
|
|
6
|
+
QuillBotError,
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
RateLimitError,
|
|
9
|
+
APIError,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"QuillBot",
|
|
14
|
+
"ParaphraseResult",
|
|
15
|
+
"SynonymMap",
|
|
16
|
+
"SummarizeResult",
|
|
17
|
+
"QuillBotError",
|
|
18
|
+
"AuthenticationError",
|
|
19
|
+
"RateLimitError",
|
|
20
|
+
"APIError",
|
|
21
|
+
]
|
quillbot/auth.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""Authentication utilities for the QuillBot SDK.
|
|
2
|
+
|
|
3
|
+
Supports two authentication methods:
|
|
4
|
+
|
|
5
|
+
1. **Email/Password** (recommended): Logs in via Firebase Auth and
|
|
6
|
+
automatically refreshes the token when it expires.
|
|
7
|
+
2. **Raw token**: Directly provide a ``useridtoken`` JWT (manual management).
|
|
8
|
+
|
|
9
|
+
This module handles credential loading, Firebase login, and token refresh.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import platformdirs
|
|
17
|
+
import re
|
|
18
|
+
import time
|
|
19
|
+
import urllib.error
|
|
20
|
+
import urllib.request
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
|
|
23
|
+
from quillbot.exceptions import AuthenticationError
|
|
24
|
+
|
|
25
|
+
# Firebase project id used by QuillBot.
|
|
26
|
+
_FIREBASE_PROJECT_ID = "paraphraser-472c1"
|
|
27
|
+
|
|
28
|
+
# Firebase Web API key extracted from QuillBot's frontend source.
|
|
29
|
+
# Stored reversed to prevent false-positive GitHub secret scanning alerts.
|
|
30
|
+
_FIREBASE_API_KEY = "Qk7YTRNxx2URumJwqe6oL-YjGsWgh7XhAySazIA"[::-1]
|
|
31
|
+
|
|
32
|
+
# Endpoints for Firebase REST Auth.
|
|
33
|
+
_SIGN_IN_URL = (
|
|
34
|
+
"https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword"
|
|
35
|
+
f"?key={_FIREBASE_API_KEY}"
|
|
36
|
+
)
|
|
37
|
+
_REFRESH_URL = (
|
|
38
|
+
"https://securetoken.googleapis.com/v1/token"
|
|
39
|
+
f"?key={_FIREBASE_API_KEY}"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Buffer in seconds before the token's actual expiry to trigger a refresh.
|
|
43
|
+
_EXPIRY_BUFFER_SECONDS = 300 # refresh 5 minutes early
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _get_cache_path() -> str:
|
|
47
|
+
"""Get the path to the authentication cache file."""
|
|
48
|
+
if "QUILLBOT_AUTH_FILE" in os.environ:
|
|
49
|
+
return os.environ["QUILLBOT_AUTH_FILE"]
|
|
50
|
+
cache_dir = platformdirs.user_cache_dir("quillbot", "quillbot")
|
|
51
|
+
# ensure directory exists
|
|
52
|
+
try:
|
|
53
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
return os.path.join(cache_dir, "auth.json")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _firebase_sign_in(email: str, password: str) -> dict:
|
|
60
|
+
"""Sign in to Firebase with email/password and return the response dict.
|
|
61
|
+
|
|
62
|
+
Returns a dict with keys: idToken, refreshToken, expiresIn, localId, etc.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
AuthenticationError: On invalid credentials or network failure.
|
|
66
|
+
"""
|
|
67
|
+
payload = json.dumps({
|
|
68
|
+
"email": email,
|
|
69
|
+
"password": password,
|
|
70
|
+
"returnSecureToken": True,
|
|
71
|
+
}).encode("utf-8")
|
|
72
|
+
|
|
73
|
+
req = urllib.request.Request(
|
|
74
|
+
_SIGN_IN_URL,
|
|
75
|
+
data=payload,
|
|
76
|
+
headers={
|
|
77
|
+
"Content-Type": "application/json",
|
|
78
|
+
"Referer": "https://quillbot.com/",
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
try:
|
|
82
|
+
with urllib.request.urlopen(req) as resp:
|
|
83
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
84
|
+
except urllib.error.HTTPError as exc:
|
|
85
|
+
body = exc.read().decode("utf-8", errors="replace")
|
|
86
|
+
raise AuthenticationError(
|
|
87
|
+
f"Firebase login failed (HTTP {exc.code}): {body[:300]}"
|
|
88
|
+
) from exc
|
|
89
|
+
except urllib.error.URLError as exc:
|
|
90
|
+
raise AuthenticationError(
|
|
91
|
+
f"Firebase login failed (network error): {exc.reason}"
|
|
92
|
+
) from exc
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _firebase_refresh(refresh_token: str) -> dict:
|
|
96
|
+
"""Exchange a Firebase refresh token for a new id token.
|
|
97
|
+
|
|
98
|
+
Returns a dict with keys: id_token, refresh_token, expires_in, etc.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
AuthenticationError: On invalid refresh token or network failure.
|
|
102
|
+
"""
|
|
103
|
+
payload = json.dumps({
|
|
104
|
+
"grant_type": "refresh_token",
|
|
105
|
+
"refresh_token": refresh_token,
|
|
106
|
+
}).encode("utf-8")
|
|
107
|
+
|
|
108
|
+
req = urllib.request.Request(
|
|
109
|
+
_REFRESH_URL,
|
|
110
|
+
data=payload,
|
|
111
|
+
headers={
|
|
112
|
+
"Content-Type": "application/json",
|
|
113
|
+
"Referer": "https://quillbot.com/",
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
try:
|
|
117
|
+
with urllib.request.urlopen(req) as resp:
|
|
118
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
119
|
+
except urllib.error.HTTPError as exc:
|
|
120
|
+
body = exc.read().decode("utf-8", errors="replace")
|
|
121
|
+
raise AuthenticationError(
|
|
122
|
+
f"Firebase token refresh failed (HTTP {exc.code}): {body[:300]}"
|
|
123
|
+
) from exc
|
|
124
|
+
except urllib.error.URLError as exc:
|
|
125
|
+
raise AuthenticationError(
|
|
126
|
+
f"Firebase token refresh failed (network error): {exc.reason}"
|
|
127
|
+
) from exc
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _fetch_account_details(useridtoken: str, local_id: str, email: str) -> dict[str, str | bool | None]:
|
|
131
|
+
"""Fetch the connect.sid session cookie and premium status from Quillbot's backend."""
|
|
132
|
+
url = "https://quillbot.com/api/auth/get-account-details"
|
|
133
|
+
payload = {
|
|
134
|
+
"uid": local_id,
|
|
135
|
+
"email": email,
|
|
136
|
+
"fullName": "SDK User",
|
|
137
|
+
"isSubscribedToEmail": True,
|
|
138
|
+
"lang": "en-US"
|
|
139
|
+
}
|
|
140
|
+
req = urllib.request.Request(
|
|
141
|
+
url,
|
|
142
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
143
|
+
headers={
|
|
144
|
+
"Content-Type": "application/json",
|
|
145
|
+
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36",
|
|
146
|
+
"Origin": "https://quillbot.com",
|
|
147
|
+
"Referer": "https://quillbot.com/login",
|
|
148
|
+
"platform-type": "webapp",
|
|
149
|
+
"qb-product": "LOGIN",
|
|
150
|
+
"useridtoken": useridtoken,
|
|
151
|
+
"webapp-version": "44.1.0"
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
result = {"connect_sid": None, "premium": False}
|
|
155
|
+
try:
|
|
156
|
+
with urllib.request.urlopen(req) as resp:
|
|
157
|
+
cookie_header = resp.info().get("Set-Cookie", "")
|
|
158
|
+
m = re.search(r'connect\.sid=([^;]+)', cookie_header)
|
|
159
|
+
if m:
|
|
160
|
+
result["connect_sid"] = m.group(1)
|
|
161
|
+
|
|
162
|
+
# Parse JSON body to find premium status
|
|
163
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
164
|
+
if "data" in data and "profile" in data["data"]:
|
|
165
|
+
result["premium"] = data["data"]["profile"].get("premium", False)
|
|
166
|
+
except Exception:
|
|
167
|
+
pass
|
|
168
|
+
return result
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass(slots=True)
|
|
172
|
+
class Credentials:
|
|
173
|
+
"""Container for QuillBot authentication state.
|
|
174
|
+
|
|
175
|
+
Supports two construction patterns:
|
|
176
|
+
|
|
177
|
+
1. ``Credentials.from_login(email, password)`` -- recommended.
|
|
178
|
+
Performs Firebase login and manages token refresh automatically.
|
|
179
|
+
2. ``Credentials(useridtoken="...")`` -- manual mode.
|
|
180
|
+
You are responsible for providing a valid, non-expired token.
|
|
181
|
+
|
|
182
|
+
Attributes:
|
|
183
|
+
useridtoken: Firebase JWT used by QuillBot's API.
|
|
184
|
+
connect_sid: Express session cookie (optional).
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
useridtoken: str
|
|
188
|
+
connect_sid: str | None = None
|
|
189
|
+
is_premium: bool = False
|
|
190
|
+
|
|
191
|
+
# Private fields for managing auto-refresh.
|
|
192
|
+
_email: str | None = field(default=None, repr=False)
|
|
193
|
+
_password: str | None = field(default=None, repr=False)
|
|
194
|
+
_refresh_token: str | None = field(default=None, repr=False)
|
|
195
|
+
_token_expiry: float = field(default=0.0, repr=False)
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def can_refresh(self) -> bool:
|
|
199
|
+
"""Return True if this credential set supports automatic refresh."""
|
|
200
|
+
return self._refresh_token is not None
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def is_expired(self) -> bool:
|
|
204
|
+
"""Return True if the token has expired or is about to expire."""
|
|
205
|
+
if self._token_expiry == 0.0:
|
|
206
|
+
return False # manual token mode, no expiry tracking
|
|
207
|
+
return time.time() >= (self._token_expiry - _EXPIRY_BUFFER_SECONDS)
|
|
208
|
+
|
|
209
|
+
def refresh_if_needed(self) -> bool:
|
|
210
|
+
"""Refresh the token if it is expired. Returns True if refreshed."""
|
|
211
|
+
if not self.can_refresh or not self.is_expired:
|
|
212
|
+
return False
|
|
213
|
+
result = _firebase_refresh(self._refresh_token)
|
|
214
|
+
self.useridtoken = result["id_token"]
|
|
215
|
+
self._refresh_token = result["refresh_token"]
|
|
216
|
+
self._token_expiry = time.time() + int(result.get("expires_in", 3600))
|
|
217
|
+
|
|
218
|
+
# Save updated tokens to cache
|
|
219
|
+
cache_path = _get_cache_path()
|
|
220
|
+
try:
|
|
221
|
+
if os.path.exists(cache_path):
|
|
222
|
+
with open(cache_path, "r", encoding="utf-8") as f:
|
|
223
|
+
data = json.load(f)
|
|
224
|
+
data["useridtoken"] = self.useridtoken
|
|
225
|
+
data["refresh_token"] = self._refresh_token
|
|
226
|
+
data["token_expiry"] = self._token_expiry
|
|
227
|
+
with open(cache_path, "w", encoding="utf-8") as f:
|
|
228
|
+
json.dump(data, f, indent=4)
|
|
229
|
+
except Exception:
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
return True
|
|
233
|
+
|
|
234
|
+
# -- factory helpers -----------------------------------------------------
|
|
235
|
+
|
|
236
|
+
@classmethod
|
|
237
|
+
def from_login(cls, email: str, password: str) -> "Credentials":
|
|
238
|
+
"""Authenticate with QuillBot using email and password.
|
|
239
|
+
|
|
240
|
+
First attempts to load cached credentials from the user's cache dir.
|
|
241
|
+
If missing, expired, or invalid, performs a Firebase signInWithPassword.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
email: QuillBot account email.
|
|
245
|
+
password: QuillBot account password.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
A Credentials instance with auto-refresh enabled.
|
|
249
|
+
|
|
250
|
+
Raises:
|
|
251
|
+
AuthenticationError: On invalid email/password.
|
|
252
|
+
"""
|
|
253
|
+
cache_path = _get_cache_path()
|
|
254
|
+
try:
|
|
255
|
+
if os.path.exists(cache_path):
|
|
256
|
+
with open(cache_path, "r", encoding="utf-8") as f:
|
|
257
|
+
data = json.load(f)
|
|
258
|
+
|
|
259
|
+
# Use cache only if the email matches
|
|
260
|
+
if data.get("email") == email:
|
|
261
|
+
creds = cls(
|
|
262
|
+
useridtoken=data["useridtoken"],
|
|
263
|
+
connect_sid=data.get("connect_sid"),
|
|
264
|
+
is_premium=data.get("is_premium", False),
|
|
265
|
+
_email=email,
|
|
266
|
+
_password=password,
|
|
267
|
+
_refresh_token=data.get("refresh_token"),
|
|
268
|
+
_token_expiry=data.get("token_expiry", 0.0),
|
|
269
|
+
)
|
|
270
|
+
# Automatically refresh the cached token if it's expired
|
|
271
|
+
creds.refresh_if_needed()
|
|
272
|
+
return creds
|
|
273
|
+
except Exception:
|
|
274
|
+
pass # Fallback to normal login
|
|
275
|
+
|
|
276
|
+
result = _firebase_sign_in(email, password)
|
|
277
|
+
useridtoken = result["idToken"]
|
|
278
|
+
local_id = result.get("localId", "")
|
|
279
|
+
account_details = _fetch_account_details(useridtoken, local_id, email)
|
|
280
|
+
|
|
281
|
+
expiry = time.time() + int(result.get("expiresIn", 3600))
|
|
282
|
+
|
|
283
|
+
# Save to cache
|
|
284
|
+
try:
|
|
285
|
+
with open(cache_path, "w", encoding="utf-8") as f:
|
|
286
|
+
json.dump({
|
|
287
|
+
"email": email,
|
|
288
|
+
"useridtoken": useridtoken,
|
|
289
|
+
"connect_sid": account_details["connect_sid"],
|
|
290
|
+
"is_premium": account_details["premium"],
|
|
291
|
+
"refresh_token": result["refreshToken"],
|
|
292
|
+
"token_expiry": expiry,
|
|
293
|
+
}, f, indent=4)
|
|
294
|
+
except Exception:
|
|
295
|
+
pass
|
|
296
|
+
|
|
297
|
+
return cls(
|
|
298
|
+
useridtoken=useridtoken,
|
|
299
|
+
connect_sid=account_details["connect_sid"],
|
|
300
|
+
is_premium=account_details["premium"],
|
|
301
|
+
_email=email,
|
|
302
|
+
_password=password,
|
|
303
|
+
_refresh_token=result["refreshToken"],
|
|
304
|
+
_token_expiry=expiry,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
@classmethod
|
|
308
|
+
def from_env(
|
|
309
|
+
cls,
|
|
310
|
+
email_var: str = "QUILLBOT_EMAIL",
|
|
311
|
+
password_var: str = "QUILLBOT_PASSWORD",
|
|
312
|
+
token_var: str = "QUILLBOT_TOKEN",
|
|
313
|
+
sid_var: str = "QUILLBOT_SID",
|
|
314
|
+
) -> "Credentials":
|
|
315
|
+
"""Build credentials from environment variables.
|
|
316
|
+
|
|
317
|
+
If QUILLBOT_EMAIL and QUILLBOT_PASSWORD are set, performs a
|
|
318
|
+
Firebase login (preferred). Otherwise falls back to QUILLBOT_TOKEN.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
email_var: Env var for the email address.
|
|
322
|
+
password_var: Env var for the password.
|
|
323
|
+
token_var: Fallback env var for a raw useridtoken.
|
|
324
|
+
sid_var: Env var for the connect.sid cookie.
|
|
325
|
+
|
|
326
|
+
Raises:
|
|
327
|
+
ValueError: If neither email/password nor token is available.
|
|
328
|
+
"""
|
|
329
|
+
email = os.environ.get(email_var, "").strip()
|
|
330
|
+
password = os.environ.get(password_var, "").strip()
|
|
331
|
+
|
|
332
|
+
if email and password:
|
|
333
|
+
creds = cls.from_login(email, password)
|
|
334
|
+
sid = os.environ.get(sid_var, "").strip() or None
|
|
335
|
+
if sid:
|
|
336
|
+
creds.connect_sid = sid
|
|
337
|
+
return creds
|
|
338
|
+
|
|
339
|
+
token = os.environ.get(token_var, "").strip()
|
|
340
|
+
if not token:
|
|
341
|
+
raise ValueError(
|
|
342
|
+
f"Set {email_var!r} + {password_var!r} for auto-login, "
|
|
343
|
+
f"or set {token_var!r} with a raw useridtoken."
|
|
344
|
+
)
|
|
345
|
+
sid = os.environ.get(sid_var, "").strip() or None
|
|
346
|
+
return cls(useridtoken=token, connect_sid=sid)
|
|
347
|
+
|
|
348
|
+
@classmethod
|
|
349
|
+
def from_dict(cls, data: dict[str, str]) -> "Credentials":
|
|
350
|
+
"""Build credentials from a plain dictionary.
|
|
351
|
+
|
|
352
|
+
Accepts ``email`` + ``password`` (preferred) or ``useridtoken``.
|
|
353
|
+
"""
|
|
354
|
+
email = data.get("email", "").strip()
|
|
355
|
+
password = data.get("password", "").strip()
|
|
356
|
+
if email and password:
|
|
357
|
+
return cls.from_login(email, password)
|
|
358
|
+
|
|
359
|
+
token = data.get("useridtoken", "").strip()
|
|
360
|
+
if not token:
|
|
361
|
+
raise ValueError(
|
|
362
|
+
"'email'+'password' or 'useridtoken' is required."
|
|
363
|
+
)
|
|
364
|
+
return cls(
|
|
365
|
+
useridtoken=token,
|
|
366
|
+
connect_sid=data.get("connect_sid"),
|
|
367
|
+
)
|
quillbot/client.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Public API for the QuillBot SDK.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
from quillbot import QuillBot
|
|
6
|
+
|
|
7
|
+
bot = QuillBot(useridtoken="...")
|
|
8
|
+
result = bot.paraphrase("The quick brown fox jumps over the lazy dog.")
|
|
9
|
+
print(result.paraphrased_text)
|
|
10
|
+
print(result.synonyms)
|
|
11
|
+
|
|
12
|
+
The ``QuillBot`` class is the only object most users need to import.
|
|
13
|
+
Internally it delegates to the HTTP client and endpoint wrappers.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from quillbot.auth import Credentials
|
|
21
|
+
from quillbot.http import HttpClient
|
|
22
|
+
from quillbot import endpoints
|
|
23
|
+
from quillbot.models import ParaphraseResult, SummarizeResult, SynonymMap
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class QuillBot:
|
|
27
|
+
"""Lightweight client that exposes QuillBot's capabilities over HTTP.
|
|
28
|
+
|
|
29
|
+
This class owns an authenticated HTTP session and provides methods that
|
|
30
|
+
mirror QuillBot's features: paraphrasing, synonym retrieval, and
|
|
31
|
+
summarisation.
|
|
32
|
+
|
|
33
|
+
It does *not* contain any decision-making logic. The caller decides
|
|
34
|
+
which word to replace, which suggestion to pick, and how many
|
|
35
|
+
iterations to run.
|
|
36
|
+
|
|
37
|
+
Construction patterns::
|
|
38
|
+
|
|
39
|
+
# Recommended: email + password (auto-refreshes tokens)
|
|
40
|
+
bot = QuillBot(email="user@example.com", password="secret")
|
|
41
|
+
|
|
42
|
+
# Legacy: raw token (you manage expiry)
|
|
43
|
+
bot = QuillBot(useridtoken="eyJ...")
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
email: QuillBot account email (used with ``password``).
|
|
47
|
+
password: QuillBot account password (used with ``email``).
|
|
48
|
+
useridtoken: Raw Firebase JWT (alternative to email/password).
|
|
49
|
+
connect_sid: Express ``connect.sid`` session cookie (optional).
|
|
50
|
+
timeout: HTTP request timeout in seconds.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
useridtoken: str | None = None,
|
|
56
|
+
*,
|
|
57
|
+
email: str | None = None,
|
|
58
|
+
password: str | None = None,
|
|
59
|
+
connect_sid: str | None = None,
|
|
60
|
+
timeout: float = 30.0,
|
|
61
|
+
) -> None:
|
|
62
|
+
if email and password:
|
|
63
|
+
self._creds = Credentials.from_login(email, password)
|
|
64
|
+
elif useridtoken:
|
|
65
|
+
self._creds = Credentials(
|
|
66
|
+
useridtoken=useridtoken, connect_sid=connect_sid,
|
|
67
|
+
)
|
|
68
|
+
else:
|
|
69
|
+
raise ValueError(
|
|
70
|
+
"Provide either email + password, or a useridtoken."
|
|
71
|
+
)
|
|
72
|
+
if connect_sid:
|
|
73
|
+
self._creds.connect_sid = connect_sid
|
|
74
|
+
self._timeout = timeout
|
|
75
|
+
self._http = HttpClient(self._creds, timeout=timeout)
|
|
76
|
+
|
|
77
|
+
def _ensure_fresh_token(self) -> None:
|
|
78
|
+
"""Refresh the Firebase token if expired and rebuild the HTTP client."""
|
|
79
|
+
if self._creds.refresh_if_needed():
|
|
80
|
+
self._http.close()
|
|
81
|
+
self._http = HttpClient(self._creds, timeout=self._timeout)
|
|
82
|
+
|
|
83
|
+
# -- lifecycle -----------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def close(self) -> None:
|
|
86
|
+
"""Release the underlying HTTP connection pool."""
|
|
87
|
+
self._http.close()
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def is_premium(self) -> bool:
|
|
91
|
+
"""Return True if the authenticated account has Premium status."""
|
|
92
|
+
return self._creds.is_premium
|
|
93
|
+
|
|
94
|
+
def __enter__(self) -> "QuillBot":
|
|
95
|
+
return self
|
|
96
|
+
|
|
97
|
+
def __exit__(self, *_: Any) -> None:
|
|
98
|
+
self.close()
|
|
99
|
+
|
|
100
|
+
# -- paraphrasing --------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def paraphrase(
|
|
103
|
+
self,
|
|
104
|
+
text: str,
|
|
105
|
+
*,
|
|
106
|
+
mode: int = 99,
|
|
107
|
+
strength: int = 9,
|
|
108
|
+
frozen_words: list[str] | None = None,
|
|
109
|
+
fetch_synonyms: bool = True,
|
|
110
|
+
) -> ParaphraseResult:
|
|
111
|
+
"""Paraphrase *text* and optionally pre-fetch synonyms.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
text: The sentence or paragraph to rewrite.
|
|
115
|
+
mode: QuillBot mode id (99=Custom, 0=Standard, etc.).
|
|
116
|
+
strength: Rewriting aggressiveness (0-10).
|
|
117
|
+
frozen_words: Words that must not be changed.
|
|
118
|
+
fetch_synonyms: When ``True`` (default), automatically call the
|
|
119
|
+
thesaurus endpoint to populate ``result.synonyms``.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
A :class:`ParaphraseResult` containing the rewritten text,
|
|
123
|
+
alternative candidates, and (if requested) a pre-fetched
|
|
124
|
+
synonym map.
|
|
125
|
+
"""
|
|
126
|
+
self._ensure_fresh_token()
|
|
127
|
+
raw = endpoints.single_paraphrase(
|
|
128
|
+
self._http,
|
|
129
|
+
text,
|
|
130
|
+
mode=mode,
|
|
131
|
+
strength=strength,
|
|
132
|
+
frozen_words=frozen_words,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Extract the primary paraphrase and alternatives from the response.
|
|
136
|
+
paraphrased_text, alternatives, phrases = self._parse_paraphrase(raw)
|
|
137
|
+
|
|
138
|
+
# Optionally fetch bulk synonyms for the paraphrased output.
|
|
139
|
+
synonyms: SynonymMap = {}
|
|
140
|
+
if fetch_synonyms and phrases:
|
|
141
|
+
synonyms = self._fetch_thesaurus(paraphrased_text, phrases, mode=mode)
|
|
142
|
+
|
|
143
|
+
return ParaphraseResult(
|
|
144
|
+
original_text=text,
|
|
145
|
+
paraphrased_text=paraphrased_text,
|
|
146
|
+
alternatives=alternatives,
|
|
147
|
+
synonyms=synonyms,
|
|
148
|
+
phrases=phrases,
|
|
149
|
+
raw=raw,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# -- synonyms ------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
def get_synonyms(
|
|
155
|
+
self,
|
|
156
|
+
text: str,
|
|
157
|
+
phrases: list[str],
|
|
158
|
+
*,
|
|
159
|
+
mode: int = 99,
|
|
160
|
+
) -> SynonymMap:
|
|
161
|
+
"""Fetch context-aware synonyms for a sentence.
|
|
162
|
+
|
|
163
|
+
This mirrors the ``paraphrase-phrase`` endpoint that the QuillBot
|
|
164
|
+
frontend calls when a user clicks a word.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
text: The full sentence.
|
|
168
|
+
phrases: The chunked phrases (as returned in
|
|
169
|
+
:attr:`ParaphraseResult.phrases`).
|
|
170
|
+
mode: Paraphrase mode id.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
A :data:`SynonymMap` mapping each phrase to its suggestions.
|
|
174
|
+
"""
|
|
175
|
+
self._ensure_fresh_token()
|
|
176
|
+
raw = endpoints.paraphrase_phrase(
|
|
177
|
+
self._http, text, phrases, mode=mode
|
|
178
|
+
)
|
|
179
|
+
suggestions: dict[str, list[str]] = (
|
|
180
|
+
raw.get("data", {}).get("suggestions", {})
|
|
181
|
+
)
|
|
182
|
+
return suggestions
|
|
183
|
+
|
|
184
|
+
# -- summarisation -------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def summarize(
|
|
187
|
+
self,
|
|
188
|
+
text: str,
|
|
189
|
+
*,
|
|
190
|
+
summary_type: str = "abs",
|
|
191
|
+
ratio: float = 0.2,
|
|
192
|
+
length: str = "short",
|
|
193
|
+
) -> SummarizeResult:
|
|
194
|
+
"""Summarize *text*.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
text: The text to condense.
|
|
198
|
+
summary_type: ``"abs"`` (abstractive) or ``"key"`` (extractive).
|
|
199
|
+
ratio: Target compression ratio (0.0-1.0).
|
|
200
|
+
length: Desired length hint (``"short"``, ``"concise"``, ``"long"``).
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
A :class:`SummarizeResult` containing the summary.
|
|
204
|
+
"""
|
|
205
|
+
self._ensure_fresh_token()
|
|
206
|
+
raw = endpoints.summarize(
|
|
207
|
+
self._http,
|
|
208
|
+
text,
|
|
209
|
+
summary_type=summary_type,
|
|
210
|
+
ratio=ratio,
|
|
211
|
+
length=length,
|
|
212
|
+
)
|
|
213
|
+
summary = raw.get("data", {}).get("summary", "")
|
|
214
|
+
return SummarizeResult(
|
|
215
|
+
original_text=text,
|
|
216
|
+
summary=summary,
|
|
217
|
+
summary_type=summary_type,
|
|
218
|
+
raw=raw,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# -- internal helpers ----------------------------------------------------
|
|
222
|
+
|
|
223
|
+
@staticmethod
|
|
224
|
+
def _parse_paraphrase(
|
|
225
|
+
raw: dict[str, Any],
|
|
226
|
+
) -> tuple[str, list[str], list[str]]:
|
|
227
|
+
"""Extract text, alternatives, and phrases from the raw response.
|
|
228
|
+
|
|
229
|
+
The response structure from ``/single-paraphrase`` nests the
|
|
230
|
+
paraphrased sentences inside ``data[].paras_<N>[].alt`` where
|
|
231
|
+
``<N>`` varies by mode (e.g. ``paras_100`` for mode 99).
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
A tuple of (primary_text, alternative_texts, phrase_tokens).
|
|
235
|
+
"""
|
|
236
|
+
data_list = raw.get("data", [])
|
|
237
|
+
if not data_list:
|
|
238
|
+
return "", [], []
|
|
239
|
+
|
|
240
|
+
first_entry = data_list[0] if isinstance(data_list, list) else data_list
|
|
241
|
+
|
|
242
|
+
# Find the paras_* key dynamically (paras_10, paras_100, etc.)
|
|
243
|
+
paras: list[dict[str, Any]] = []
|
|
244
|
+
for key, value in first_entry.items():
|
|
245
|
+
if key.startswith("paras_") and isinstance(value, list):
|
|
246
|
+
paras = value
|
|
247
|
+
break
|
|
248
|
+
|
|
249
|
+
primary = paras[0].get("alt", "") if paras else ""
|
|
250
|
+
alternatives = [p.get("alt", "") for p in paras[1:]]
|
|
251
|
+
|
|
252
|
+
# QuillBot also returns segmentation data we can use as phrases.
|
|
253
|
+
segments = first_entry.get("segments", {})
|
|
254
|
+
phrases_data = segments.get("phrases", [])
|
|
255
|
+
phrases = [p.get("phrase", "") for p in phrases_data] if phrases_data else []
|
|
256
|
+
|
|
257
|
+
# If no segments in the response, split the primary text on whitespace
|
|
258
|
+
# as a fallback so the thesaurus endpoint still works.
|
|
259
|
+
if not phrases and primary:
|
|
260
|
+
phrases = primary.split()
|
|
261
|
+
|
|
262
|
+
return primary, alternatives, phrases
|
|
263
|
+
|
|
264
|
+
def _fetch_thesaurus(
|
|
265
|
+
self,
|
|
266
|
+
text: str,
|
|
267
|
+
phrases: list[str],
|
|
268
|
+
*,
|
|
269
|
+
mode: int = 99,
|
|
270
|
+
) -> SynonymMap:
|
|
271
|
+
"""Call the bulk thesaurus endpoint and merge the result."""
|
|
272
|
+
raw = endpoints.paraphrase_thesaurus(
|
|
273
|
+
self._http,
|
|
274
|
+
phrases=phrases,
|
|
275
|
+
mode=mode,
|
|
276
|
+
)
|
|
277
|
+
# The response nests suggestions differently than paraphrase-phrase.
|
|
278
|
+
# It returns per-sentence data; we merge all of them.
|
|
279
|
+
synonyms: SynonymMap = {}
|
|
280
|
+
data = raw.get("data", {})
|
|
281
|
+
if isinstance(data, list):
|
|
282
|
+
for entry in data:
|
|
283
|
+
synonyms.update(entry.get("suggestions", {}))
|
|
284
|
+
elif isinstance(data, dict):
|
|
285
|
+
synonyms.update(data.get("suggestions", {}))
|
|
286
|
+
return synonyms
|
quillbot/endpoints.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Raw endpoint wrappers for QuillBot's HTTP API.
|
|
2
|
+
|
|
3
|
+
Each function maps 1-to-1 to a QuillBot API endpoint. They accept an
|
|
4
|
+
``HttpClient``, build the correct payload, call the endpoint, and return
|
|
5
|
+
the raw JSON dict. No response parsing happens here -- that is the job
|
|
6
|
+
of ``client.py`` which converts raw dicts into typed ``models``.
|
|
7
|
+
|
|
8
|
+
Keeping endpoint wrappers separate makes it easy to add new endpoints
|
|
9
|
+
later without touching the public API or response parsing.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from quillbot.http import HttpClient
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Paraphraser
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
_PARAPHRASER_HEADERS = {
|
|
24
|
+
"platform-type": "webapp",
|
|
25
|
+
"qb-product": "PARAPHRASER",
|
|
26
|
+
"qb-dialect": "en-us",
|
|
27
|
+
"referer": "https://quillbot.com/paraphrasing-tool",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def single_paraphrase(
|
|
32
|
+
client: HttpClient,
|
|
33
|
+
text: str,
|
|
34
|
+
*,
|
|
35
|
+
mode: int = 99,
|
|
36
|
+
strength: int = 9,
|
|
37
|
+
frozen_words: list[str] | None = None,
|
|
38
|
+
dialect: str = "US",
|
|
39
|
+
input_lang: str = "en",
|
|
40
|
+
) -> dict[str, Any]:
|
|
41
|
+
"""Call ``POST /api/paraphraser/single-paraphrase/{mode}``.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
client: Authenticated HTTP client.
|
|
45
|
+
text: The sentence to paraphrase.
|
|
46
|
+
mode: Paraphrase mode id (99 = Custom, 0 = Standard, etc.).
|
|
47
|
+
strength: Rewriting aggressiveness. Valid values:
|
|
48
|
+
0, 2, 6, 7, 8, 9, 10, 12, 13, 15, 16, 99, 100, 101, 201, 202, 203.
|
|
49
|
+
frozen_words: Words to leave unchanged.
|
|
50
|
+
dialect: Target English dialect (``"US"`` or ``"UK"``).
|
|
51
|
+
input_lang: Source language code.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Raw JSON response dict.
|
|
55
|
+
"""
|
|
56
|
+
payload: dict[str, Any] = {
|
|
57
|
+
"text": text,
|
|
58
|
+
"strength": strength,
|
|
59
|
+
"autoflip": False,
|
|
60
|
+
"wikify": False,
|
|
61
|
+
"fthresh": -1,
|
|
62
|
+
"inputLang": input_lang,
|
|
63
|
+
"quoteIndex": -1,
|
|
64
|
+
"frozenWords": frozen_words or [],
|
|
65
|
+
"nBeams": 4,
|
|
66
|
+
"freezeQuotes": True,
|
|
67
|
+
"preferActive": False,
|
|
68
|
+
"dialect": dialect,
|
|
69
|
+
"promptVersion": "v2",
|
|
70
|
+
"multilingualModelVersion": "v2",
|
|
71
|
+
}
|
|
72
|
+
return client.post_json(
|
|
73
|
+
f"/api/paraphraser/single-paraphrase/{mode}",
|
|
74
|
+
payload,
|
|
75
|
+
extra_headers=_PARAPHRASER_HEADERS,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def paraphrase_thesaurus(
|
|
80
|
+
client: HttpClient,
|
|
81
|
+
phrases: list[str],
|
|
82
|
+
*,
|
|
83
|
+
mode: int = 99,
|
|
84
|
+
) -> dict[str, Any]:
|
|
85
|
+
"""Call ``POST /api/paraphraser/paraphrase-thesaurus``.
|
|
86
|
+
|
|
87
|
+
Fetches bulk synonyms for the phrases in the paraphrased output.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
client: Authenticated HTTP client.
|
|
91
|
+
phrases: List of phrase strings.
|
|
92
|
+
mode: Paraphrase mode id.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Raw JSON response dict.
|
|
96
|
+
"""
|
|
97
|
+
payload: dict[str, Any] = {
|
|
98
|
+
"phrases": phrases,
|
|
99
|
+
"mode": mode,
|
|
100
|
+
}
|
|
101
|
+
return client.post_json(
|
|
102
|
+
"/api/paraphraser/paraphrase-thesaurus",
|
|
103
|
+
payload,
|
|
104
|
+
extra_headers=_PARAPHRASER_HEADERS,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def paraphrase_phrase(
|
|
109
|
+
client: HttpClient,
|
|
110
|
+
text: str,
|
|
111
|
+
phrases: list[str],
|
|
112
|
+
*,
|
|
113
|
+
mode: int = 99,
|
|
114
|
+
w1: float = 0.6,
|
|
115
|
+
w2: float = 0.4,
|
|
116
|
+
) -> dict[str, Any]:
|
|
117
|
+
"""Call ``POST /api/utils/paraphrase-phrase``.
|
|
118
|
+
|
|
119
|
+
Fetches context-aware synonyms for a specific sentence.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
client: Authenticated HTTP client.
|
|
123
|
+
text: The full sentence containing the word.
|
|
124
|
+
phrases: The chunked phrases of the sentence.
|
|
125
|
+
mode: Paraphrase mode id.
|
|
126
|
+
w1: Weight parameter (controls suggestion style).
|
|
127
|
+
w2: Weight parameter (controls suggestion style).
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Raw JSON response dict.
|
|
131
|
+
"""
|
|
132
|
+
payload: dict[str, Any] = {
|
|
133
|
+
"phrases": phrases,
|
|
134
|
+
"text": text,
|
|
135
|
+
"w1": w1,
|
|
136
|
+
"w2": w2,
|
|
137
|
+
"mode": mode,
|
|
138
|
+
}
|
|
139
|
+
return client.post_json(
|
|
140
|
+
"/api/utils/paraphrase-phrase",
|
|
141
|
+
payload,
|
|
142
|
+
extra_headers=_PARAPHRASER_HEADERS,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def chunker(
|
|
147
|
+
client: HttpClient,
|
|
148
|
+
text: str,
|
|
149
|
+
) -> dict[str, Any]:
|
|
150
|
+
"""Call ``POST /api/paraphraser/chunker``.
|
|
151
|
+
|
|
152
|
+
Splits a sentence into the phrase tokens that QuillBot uses for
|
|
153
|
+
synonym lookups and rendering.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
client: Authenticated HTTP client.
|
|
157
|
+
text: The sentence to tokenize.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Raw JSON response dict containing ``data.segments.phrases``.
|
|
161
|
+
"""
|
|
162
|
+
payload: dict[str, Any] = {"text": text}
|
|
163
|
+
return client.post_json(
|
|
164
|
+
"/api/paraphraser/chunker",
|
|
165
|
+
payload,
|
|
166
|
+
extra_headers=_PARAPHRASER_HEADERS,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
# Summarizer
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
_SUMMARIZER_HEADERS = {
|
|
175
|
+
"platform-type": "webapp",
|
|
176
|
+
"qb-product": "SUMMARIZER",
|
|
177
|
+
"referer": "https://quillbot.com/summarize",
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def summarize(
|
|
182
|
+
client: HttpClient,
|
|
183
|
+
text: str,
|
|
184
|
+
*,
|
|
185
|
+
summary_type: str = "abs",
|
|
186
|
+
ratio: float = 0.2,
|
|
187
|
+
length: str = "short",
|
|
188
|
+
) -> dict[str, Any]:
|
|
189
|
+
"""Call ``POST /api/summarizer/summarize-para/{summary_type}``.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
client: Authenticated HTTP client.
|
|
193
|
+
text: The text to summarize.
|
|
194
|
+
summary_type: Algorithm -- ``"abs"`` (abstractive) or ``"key"``
|
|
195
|
+
(key-sentences / extractive).
|
|
196
|
+
ratio: Target compression ratio (0.0-1.0).
|
|
197
|
+
length: Desired length hint (``"short"``, ``"concise"``, ``"long"``).
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Raw JSON response dict.
|
|
201
|
+
"""
|
|
202
|
+
payload: dict[str, Any] = {
|
|
203
|
+
"para": text,
|
|
204
|
+
"type": summary_type,
|
|
205
|
+
"ratio": ratio,
|
|
206
|
+
"length": length,
|
|
207
|
+
}
|
|
208
|
+
return client.post_json(
|
|
209
|
+
f"/api/summarizer/summarize-para/{summary_type}",
|
|
210
|
+
payload,
|
|
211
|
+
extra_headers=_SUMMARIZER_HEADERS,
|
|
212
|
+
)
|
quillbot/exceptions.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Custom exceptions for the QuillBot SDK.
|
|
2
|
+
|
|
3
|
+
Every exception inherits from QuillBotError so callers can catch broadly
|
|
4
|
+
or narrowly as they prefer.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class QuillBotError(Exception):
|
|
9
|
+
"""Base exception for all QuillBot SDK errors."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuthenticationError(QuillBotError):
|
|
13
|
+
"""Raised when the useridtoken is missing, expired, or rejected (HTTP 401/408)."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RateLimitError(QuillBotError):
|
|
17
|
+
"""Raised when QuillBot returns rate-limit headers or HTTP 429.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
retry_after: Seconds to wait before retrying, if the server provided
|
|
21
|
+
a Retry-After header.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, message: str, retry_after: int | None = None) -> None:
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
self.retry_after = retry_after
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class APIError(QuillBotError):
|
|
30
|
+
"""Raised for any unexpected HTTP error from the QuillBot backend.
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
status_code: The HTTP status code returned.
|
|
34
|
+
body: The raw response body, if available.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self, message: str, status_code: int, body: str | None = None
|
|
39
|
+
) -> None:
|
|
40
|
+
super().__init__(message)
|
|
41
|
+
self.status_code = status_code
|
|
42
|
+
self.body = body
|
quillbot/http.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Low-level HTTP transport for the QuillBot SDK.
|
|
2
|
+
|
|
3
|
+
Wraps ``httpx.Client`` with the exact headers, cookies, and error handling
|
|
4
|
+
that QuillBot's Node.js backend expects. Every request is routed through
|
|
5
|
+
``post_json`` which handles authentication headers, response parsing, and
|
|
6
|
+
error translation into SDK exceptions.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from quillbot.auth import Credentials
|
|
16
|
+
from quillbot.exceptions import APIError, AuthenticationError, RateLimitError
|
|
17
|
+
|
|
18
|
+
# The base URL shared by all QuillBot API endpoints.
|
|
19
|
+
BASE_URL = "https://quillbot.com"
|
|
20
|
+
|
|
21
|
+
# Default headers that every request must carry.
|
|
22
|
+
_DEFAULT_HEADERS: dict[str, str] = {
|
|
23
|
+
"user-agent": (
|
|
24
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
|
25
|
+
"(KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"
|
|
26
|
+
),
|
|
27
|
+
"accept": "application/json, text/plain, */*",
|
|
28
|
+
"content-type": "application/json",
|
|
29
|
+
"origin": BASE_URL,
|
|
30
|
+
"webapp-version": "44.1.0",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class HttpClient:
|
|
35
|
+
"""Thin wrapper around ``httpx.Client`` tuned for QuillBot.
|
|
36
|
+
|
|
37
|
+
This class is an internal implementation detail. Users interact with
|
|
38
|
+
the public ``QuillBot`` class in ``client.py``.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, credentials: Credentials, timeout: float = 30.0) -> None:
|
|
42
|
+
cookies: dict[str, str] = {
|
|
43
|
+
"useridtoken": credentials.useridtoken,
|
|
44
|
+
}
|
|
45
|
+
if credentials.connect_sid:
|
|
46
|
+
cookies["connect.sid"] = credentials.connect_sid
|
|
47
|
+
|
|
48
|
+
headers = {
|
|
49
|
+
**_DEFAULT_HEADERS,
|
|
50
|
+
"useridtoken": credentials.useridtoken,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
self._client = httpx.Client(
|
|
54
|
+
base_url=BASE_URL,
|
|
55
|
+
headers=headers,
|
|
56
|
+
cookies=cookies,
|
|
57
|
+
timeout=timeout,
|
|
58
|
+
follow_redirects=True,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# -- public interface ----------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def post_json(
|
|
64
|
+
self,
|
|
65
|
+
path: str,
|
|
66
|
+
payload: dict[str, Any],
|
|
67
|
+
*,
|
|
68
|
+
extra_headers: dict[str, str] | None = None,
|
|
69
|
+
) -> dict[str, Any]:
|
|
70
|
+
"""Send a JSON POST and return the parsed response body.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
path: API path relative to BASE_URL (e.g. ``/api/paraphraser/...``).
|
|
74
|
+
payload: JSON-serialisable request body.
|
|
75
|
+
extra_headers: Per-request header overrides.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
The parsed JSON response as a dict.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
AuthenticationError: On 401 or 408 (SESSION_FAILED).
|
|
82
|
+
RateLimitError: On 429 or when ``x-ratelimit-limit`` is exceeded.
|
|
83
|
+
APIError: On any other non-2xx response.
|
|
84
|
+
"""
|
|
85
|
+
headers = extra_headers or {}
|
|
86
|
+
response = self._client.post(path, json=payload, headers=headers)
|
|
87
|
+
return self._handle_response(response)
|
|
88
|
+
|
|
89
|
+
def close(self) -> None:
|
|
90
|
+
"""Release the underlying connection pool."""
|
|
91
|
+
self._client.close()
|
|
92
|
+
|
|
93
|
+
# -- context manager -----------------------------------------------------
|
|
94
|
+
|
|
95
|
+
def __enter__(self) -> "HttpClient":
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
def __exit__(self, *_: Any) -> None:
|
|
99
|
+
self.close()
|
|
100
|
+
|
|
101
|
+
# -- internal ------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def _handle_response(response: httpx.Response) -> dict[str, Any]:
|
|
105
|
+
"""Translate HTTP status codes into SDK exceptions."""
|
|
106
|
+
status = response.status_code
|
|
107
|
+
|
|
108
|
+
if 200 <= status < 300:
|
|
109
|
+
return response.json()
|
|
110
|
+
|
|
111
|
+
body = response.text
|
|
112
|
+
|
|
113
|
+
if status in (401, 408):
|
|
114
|
+
raise AuthenticationError(
|
|
115
|
+
f"Authentication failed (HTTP {status}). "
|
|
116
|
+
"Your useridtoken may be expired. "
|
|
117
|
+
f"Response: {body[:200]}"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if status == 429:
|
|
121
|
+
retry_after_raw = response.headers.get("retry-after")
|
|
122
|
+
retry_after = int(retry_after_raw) if retry_after_raw else None
|
|
123
|
+
raise RateLimitError(
|
|
124
|
+
f"Rate limited (HTTP 429). Retry after {retry_after}s.",
|
|
125
|
+
retry_after=retry_after,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
raise APIError(
|
|
129
|
+
f"QuillBot API error (HTTP {status}): {body[:300]}",
|
|
130
|
+
status_code=status,
|
|
131
|
+
body=body,
|
|
132
|
+
)
|
quillbot/models.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Data models for QuillBot API responses.
|
|
2
|
+
|
|
3
|
+
These are simple, immutable containers that stay close to what QuillBot
|
|
4
|
+
returns. They carry the data and provide thin helper methods -- no
|
|
5
|
+
decision-making logic.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# Synonym helpers
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
SynonymMap = dict[str, list[str]]
|
|
18
|
+
"""Mapping of word/phrase to its list of synonym suggestions.
|
|
19
|
+
|
|
20
|
+
Example::
|
|
21
|
+
|
|
22
|
+
{"agile": ["fast", "quick", "nimble"], "fox": ["hound", "feline"]}
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Paraphrase
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class ParaphraseResult:
|
|
33
|
+
"""Result returned by :meth:`QuillBot.paraphrase`.
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
original_text: The text that was submitted.
|
|
37
|
+
paraphrased_text: The rewritten text returned by QuillBot.
|
|
38
|
+
alternatives: Other paraphrase candidates (may be empty).
|
|
39
|
+
synonyms: Pre-fetched synonym map for words in the paraphrased text.
|
|
40
|
+
Populated automatically from the ``paraphrase-thesaurus`` bulk
|
|
41
|
+
endpoint so callers can inspect available replacements without
|
|
42
|
+
making another network call.
|
|
43
|
+
phrases: The chunked phrases that QuillBot uses internally.
|
|
44
|
+
raw: The full raw JSON response from ``/single-paraphrase``, kept
|
|
45
|
+
for debugging and forward-compatibility.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
original_text: str
|
|
49
|
+
paraphrased_text: str
|
|
50
|
+
alternatives: list[str] = field(default_factory=list)
|
|
51
|
+
synonyms: SynonymMap = field(default_factory=dict)
|
|
52
|
+
phrases: list[str] = field(default_factory=list)
|
|
53
|
+
raw: dict = field(default_factory=dict, repr=False)
|
|
54
|
+
|
|
55
|
+
def available_replacements(self, word: str) -> list[str]:
|
|
56
|
+
"""Return the synonym list for *word*, or an empty list."""
|
|
57
|
+
return self.synonyms.get(word, [])
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# Summarize
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True, slots=True)
|
|
66
|
+
class SummarizeResult:
|
|
67
|
+
"""Result returned by :meth:`QuillBot.summarize`.
|
|
68
|
+
|
|
69
|
+
Attributes:
|
|
70
|
+
original_text: The text that was submitted.
|
|
71
|
+
summary: The condensed text returned by QuillBot.
|
|
72
|
+
summary_type: The summarisation algorithm used (e.g. ``"abs"``).
|
|
73
|
+
raw: The full raw JSON response.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
original_text: str
|
|
77
|
+
summary: str
|
|
78
|
+
summary_type: str = "abs"
|
|
79
|
+
raw: dict = field(default_factory=dict, repr=False)
|
quillbot/server.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from mcp.server.fastmcp import FastMCP
|
|
3
|
+
from quillbot import QuillBot
|
|
4
|
+
|
|
5
|
+
# Create an MCP server instance
|
|
6
|
+
mcp = FastMCP("QuillBot")
|
|
7
|
+
|
|
8
|
+
_bot = None
|
|
9
|
+
|
|
10
|
+
def get_bot() -> QuillBot:
|
|
11
|
+
"""Lazily initialize the QuillBot client."""
|
|
12
|
+
global _bot
|
|
13
|
+
if _bot is None:
|
|
14
|
+
email = os.environ.get("QUILLBOT_EMAIL")
|
|
15
|
+
password = os.environ.get("QUILLBOT_PASSWORD")
|
|
16
|
+
if not email or not password:
|
|
17
|
+
raise RuntimeError(
|
|
18
|
+
"Please set QUILLBOT_EMAIL and QUILLBOT_PASSWORD environment variables."
|
|
19
|
+
)
|
|
20
|
+
_bot = QuillBot(email, password)
|
|
21
|
+
return _bot
|
|
22
|
+
|
|
23
|
+
@mcp.tool()
|
|
24
|
+
def paraphrase(text: str, mode: int = 0, strength: int = 2) -> str:
|
|
25
|
+
"""
|
|
26
|
+
Paraphrase text using QuillBot.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
text: The text to paraphrase.
|
|
30
|
+
mode: The paraphrasing mode (0=Standard, 1=Fluency, 6=Shorten, 9=Formal). Default is 0.
|
|
31
|
+
strength: The aggressiveness of the paraphrasing (0-10). Default is 2.
|
|
32
|
+
"""
|
|
33
|
+
bot = get_bot()
|
|
34
|
+
try:
|
|
35
|
+
# Note: Free accounts may only have access to mode=0 (Standard) and mode=1 (Fluency).
|
|
36
|
+
result = bot.paraphrase(text, mode=mode, strength=strength)
|
|
37
|
+
return result.paraphrased_text
|
|
38
|
+
except Exception as e:
|
|
39
|
+
return f"Error paraphrasing text: {e}"
|
|
40
|
+
|
|
41
|
+
@mcp.tool()
|
|
42
|
+
def summarize(text: str) -> str:
|
|
43
|
+
"""
|
|
44
|
+
Summarize text using QuillBot.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
text: The long text or paragraph to summarize.
|
|
48
|
+
"""
|
|
49
|
+
bot = get_bot()
|
|
50
|
+
try:
|
|
51
|
+
result = bot.summarize(text)
|
|
52
|
+
return result.summary_text
|
|
53
|
+
except Exception as e:
|
|
54
|
+
return f"Error summarizing text: {e}"
|
|
55
|
+
|
|
56
|
+
def main():
|
|
57
|
+
"""Entry point for the MCP server."""
|
|
58
|
+
mcp.run()
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
main()
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: quillbot
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight Python SDK for QuillBot's paraphrasing and summarization APIs.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: httpx>=0.27
|
|
9
|
+
Requires-Dist: platformdirs>=4.0.0
|
|
10
|
+
Requires-Dist: mcp>=1.2.1
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
13
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
|
|
14
|
+
|
|
15
|
+
# QuillBot Python SDK
|
|
16
|
+
|
|
17
|
+
A lightweight, purely HTTP-based Python SDK for interacting with the QuillBot API.
|
|
18
|
+
This SDK allows you to easily integrate QuillBot's paraphrasing and summarizing capabilities into your applications, LLM agents, or command-line tools.
|
|
19
|
+
|
|
20
|
+
## Features
|
|
21
|
+
|
|
22
|
+
- **Paraphrasing**: Rewrite text using QuillBot's sophisticated paraphrasing engine.
|
|
23
|
+
- **Bulk Thesaurus**: Automatically fetch synonym suggestions for every word/phrase in the paraphrased text in a single request.
|
|
24
|
+
- **Summarization**: Condense long texts into brief, readable summaries.
|
|
25
|
+
- **Frozen Words**: Prevent specific words or phrases from being altered during paraphrasing.
|
|
26
|
+
- **Pure HTTP**: No browser automation (Selenium/Playwright) required. Fast and lightweight.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
This SDK requires Python 3.10+.
|
|
31
|
+
|
|
32
|
+
Clone the repository and install the dependencies (we recommend using a virtual environment):
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# Example for installing with pip
|
|
36
|
+
pip install -r requirements.txt
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
If you are using `pytest` for running tests, install it using:
|
|
40
|
+
```bash
|
|
41
|
+
pip install pytest httpx --break-system-packages
|
|
42
|
+
```
|
|
43
|
+
*(Note: `--break-system-packages` may be required on some modern Linux distributions if you are not using a virtual environment.)*
|
|
44
|
+
|
|
45
|
+
## Authentication
|
|
46
|
+
|
|
47
|
+
To use the SDK, you need a valid QuillBot `useridtoken`.
|
|
48
|
+
You can obtain this token by logging into QuillBot in your web browser, opening Developer Tools, and inspecting the cookies or request headers for `useridtoken`.
|
|
49
|
+
|
|
50
|
+
Create a `.env` file in the root directory and add your token:
|
|
51
|
+
|
|
52
|
+
```env
|
|
53
|
+
QUILLBOT_USERIDTOKEN=your_actual_token_here
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Quick Start
|
|
57
|
+
|
|
58
|
+
### Paraphrasing
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from quillbot import QuillBot
|
|
62
|
+
import os
|
|
63
|
+
|
|
64
|
+
# Initialize the client with your token
|
|
65
|
+
token = os.getenv("QUILLBOT_USERIDTOKEN")
|
|
66
|
+
bot = QuillBot(token)
|
|
67
|
+
|
|
68
|
+
# Basic Paraphrasing
|
|
69
|
+
text = "The quick brown fox jumps over the lazy dog."
|
|
70
|
+
result = bot.paraphrase(text, strength=9)
|
|
71
|
+
|
|
72
|
+
print(f"Original: {result.original_text}")
|
|
73
|
+
print(f"Paraphrased: {result.text}")
|
|
74
|
+
print(f"Phrases found: {result.phrases}")
|
|
75
|
+
|
|
76
|
+
# Accessing Synonym Suggestions (Interactive Editing)
|
|
77
|
+
if result.synonyms:
|
|
78
|
+
first_word = result.phrases[0]
|
|
79
|
+
suggestions = result.available_replacements(first_word)
|
|
80
|
+
print(f"Suggestions for '{first_word}': {suggestions}")
|
|
81
|
+
|
|
82
|
+
# Paraphrasing with Frozen Words
|
|
83
|
+
frozen_result = bot.paraphrase(
|
|
84
|
+
"Python is a great programming language.",
|
|
85
|
+
frozen_words=["Python"]
|
|
86
|
+
)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Summarization
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
long_text = (
|
|
93
|
+
"Artificial intelligence (AI) is intelligence demonstrated by machines, "
|
|
94
|
+
"as opposed to the natural intelligence displayed by animals including humans. "
|
|
95
|
+
"Leading AI textbooks define the field as the study of intelligent agents: "
|
|
96
|
+
"any system that perceives its environment and takes actions that maximize "
|
|
97
|
+
"its chance of achieving its goals."
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
summary = bot.summarize(long_text)
|
|
101
|
+
print("Summary:", summary)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Core Architecture
|
|
105
|
+
|
|
106
|
+
The SDK is intentionally designed to be thin and close to the QuillBot API.
|
|
107
|
+
QuillBot already provides the heavily optimized NLP features (synonym suggestions, phrase grouping, grammar context). The SDK exposes these capabilities rather than reinventing a local document engine.
|
|
108
|
+
|
|
109
|
+
- The application (your script) is responsible for decision-making (e.g., deciding which word to replace with which synonym).
|
|
110
|
+
- The SDK purely provides the data (rewritten text and synonym maps).
|
|
111
|
+
|
|
112
|
+
## Running Tests
|
|
113
|
+
|
|
114
|
+
The test suite runs live integration tests against the actual QuillBot servers (as configured). Mocks are not used to ensure we accurately reflect the live API.
|
|
115
|
+
|
|
116
|
+
1. Ensure your `.env` contains a valid `QUILLBOT_USERIDTOKEN`.
|
|
117
|
+
2. Run the tests:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
pytest tests/test_unit.py -v -s
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Disclaimer
|
|
124
|
+
|
|
125
|
+
This is an unofficial SDK. Use responsibly and ensure you comply with QuillBot's Terms of Service.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
quillbot/__init__.py,sha256=99xyCjavsyEeI6oM5syK8BtfJ1nXlH-AL4VLT5359aU,463
|
|
2
|
+
quillbot/auth.py,sha256=HMExcrWSJLWrvZeGNRsEljjwdgEFeqVtaLCrhykpaXA,13093
|
|
3
|
+
quillbot/client.py,sha256=Fjn2XwkCV3DPPXRsrKl8ufBDpgS8G07EihZz4iU5Ogo,9546
|
|
4
|
+
quillbot/endpoints.py,sha256=bc_YmsIAuqUj0x5RZtGVZN3L64mUd6mTnY-bCXkIN68,5735
|
|
5
|
+
quillbot/exceptions.py,sha256=Tz_A0o-eG6k1vLqTccPBfKjiZCBSGgTtIJHs-xdSSgE,1206
|
|
6
|
+
quillbot/http.py,sha256=ukWj9iHJwa97-EQY_qc1ikYOGd-IdmUOdYsfKN8GJIc,4182
|
|
7
|
+
quillbot/models.py,sha256=qL4H5snsoUnWHArCA30NrVUx3TwPboXyNowPZ17HsbU,2717
|
|
8
|
+
quillbot/server.py,sha256=cKx5EKp2AytwGGOoyQCdAXQza7sHrj2qCj6Pdf8t7-0,1705
|
|
9
|
+
quillbot-0.1.0.dist-info/METADATA,sha256=9zQFcPdN0QKDLVvqToYfwGXQV_kuBADo7TVC7TnMCCk,4222
|
|
10
|
+
quillbot-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
11
|
+
quillbot-0.1.0.dist-info/entry_points.txt,sha256=5ahPNfafJk9-Om436P9Isp2R5OaHWhrT0hJipSv0Oug,54
|
|
12
|
+
quillbot-0.1.0.dist-info/top_level.txt,sha256=Knc40gaC5aNg8yGglQfhQpXXopNkq5XFQoBG42eoneE,9
|
|
13
|
+
quillbot-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
quillbot
|