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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ quillbot-mcp = quillbot.server:main
@@ -0,0 +1 @@
1
+ quillbot