quillbot 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quillbot-0.1.0/PKG-INFO +125 -0
- quillbot-0.1.0/README.md +111 -0
- quillbot-0.1.0/pyproject.toml +25 -0
- quillbot-0.1.0/quillbot/__init__.py +21 -0
- quillbot-0.1.0/quillbot/auth.py +367 -0
- quillbot-0.1.0/quillbot/client.py +286 -0
- quillbot-0.1.0/quillbot/endpoints.py +212 -0
- quillbot-0.1.0/quillbot/exceptions.py +42 -0
- quillbot-0.1.0/quillbot/http.py +132 -0
- quillbot-0.1.0/quillbot/models.py +79 -0
- quillbot-0.1.0/quillbot/server.py +61 -0
- quillbot-0.1.0/quillbot.egg-info/PKG-INFO +125 -0
- quillbot-0.1.0/quillbot.egg-info/SOURCES.txt +17 -0
- quillbot-0.1.0/quillbot.egg-info/dependency_links.txt +1 -0
- quillbot-0.1.0/quillbot.egg-info/entry_points.txt +2 -0
- quillbot-0.1.0/quillbot.egg-info/requires.txt +7 -0
- quillbot-0.1.0/quillbot.egg-info/top_level.txt +1 -0
- quillbot-0.1.0/setup.cfg +4 -0
- quillbot-0.1.0/tests/test_unit.py +227 -0
quillbot-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
quillbot-0.1.0/README.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# QuillBot Python SDK
|
|
2
|
+
|
|
3
|
+
A lightweight, purely HTTP-based Python SDK for interacting with the QuillBot API.
|
|
4
|
+
This SDK allows you to easily integrate QuillBot's paraphrasing and summarizing capabilities into your applications, LLM agents, or command-line tools.
|
|
5
|
+
|
|
6
|
+
## Features
|
|
7
|
+
|
|
8
|
+
- **Paraphrasing**: Rewrite text using QuillBot's sophisticated paraphrasing engine.
|
|
9
|
+
- **Bulk Thesaurus**: Automatically fetch synonym suggestions for every word/phrase in the paraphrased text in a single request.
|
|
10
|
+
- **Summarization**: Condense long texts into brief, readable summaries.
|
|
11
|
+
- **Frozen Words**: Prevent specific words or phrases from being altered during paraphrasing.
|
|
12
|
+
- **Pure HTTP**: No browser automation (Selenium/Playwright) required. Fast and lightweight.
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
This SDK requires Python 3.10+.
|
|
17
|
+
|
|
18
|
+
Clone the repository and install the dependencies (we recommend using a virtual environment):
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# Example for installing with pip
|
|
22
|
+
pip install -r requirements.txt
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
If you are using `pytest` for running tests, install it using:
|
|
26
|
+
```bash
|
|
27
|
+
pip install pytest httpx --break-system-packages
|
|
28
|
+
```
|
|
29
|
+
*(Note: `--break-system-packages` may be required on some modern Linux distributions if you are not using a virtual environment.)*
|
|
30
|
+
|
|
31
|
+
## Authentication
|
|
32
|
+
|
|
33
|
+
To use the SDK, you need a valid QuillBot `useridtoken`.
|
|
34
|
+
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`.
|
|
35
|
+
|
|
36
|
+
Create a `.env` file in the root directory and add your token:
|
|
37
|
+
|
|
38
|
+
```env
|
|
39
|
+
QUILLBOT_USERIDTOKEN=your_actual_token_here
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quick Start
|
|
43
|
+
|
|
44
|
+
### Paraphrasing
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from quillbot import QuillBot
|
|
48
|
+
import os
|
|
49
|
+
|
|
50
|
+
# Initialize the client with your token
|
|
51
|
+
token = os.getenv("QUILLBOT_USERIDTOKEN")
|
|
52
|
+
bot = QuillBot(token)
|
|
53
|
+
|
|
54
|
+
# Basic Paraphrasing
|
|
55
|
+
text = "The quick brown fox jumps over the lazy dog."
|
|
56
|
+
result = bot.paraphrase(text, strength=9)
|
|
57
|
+
|
|
58
|
+
print(f"Original: {result.original_text}")
|
|
59
|
+
print(f"Paraphrased: {result.text}")
|
|
60
|
+
print(f"Phrases found: {result.phrases}")
|
|
61
|
+
|
|
62
|
+
# Accessing Synonym Suggestions (Interactive Editing)
|
|
63
|
+
if result.synonyms:
|
|
64
|
+
first_word = result.phrases[0]
|
|
65
|
+
suggestions = result.available_replacements(first_word)
|
|
66
|
+
print(f"Suggestions for '{first_word}': {suggestions}")
|
|
67
|
+
|
|
68
|
+
# Paraphrasing with Frozen Words
|
|
69
|
+
frozen_result = bot.paraphrase(
|
|
70
|
+
"Python is a great programming language.",
|
|
71
|
+
frozen_words=["Python"]
|
|
72
|
+
)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Summarization
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
long_text = (
|
|
79
|
+
"Artificial intelligence (AI) is intelligence demonstrated by machines, "
|
|
80
|
+
"as opposed to the natural intelligence displayed by animals including humans. "
|
|
81
|
+
"Leading AI textbooks define the field as the study of intelligent agents: "
|
|
82
|
+
"any system that perceives its environment and takes actions that maximize "
|
|
83
|
+
"its chance of achieving its goals."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
summary = bot.summarize(long_text)
|
|
87
|
+
print("Summary:", summary)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Core Architecture
|
|
91
|
+
|
|
92
|
+
The SDK is intentionally designed to be thin and close to the QuillBot API.
|
|
93
|
+
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.
|
|
94
|
+
|
|
95
|
+
- The application (your script) is responsible for decision-making (e.g., deciding which word to replace with which synonym).
|
|
96
|
+
- The SDK purely provides the data (rewritten text and synonym maps).
|
|
97
|
+
|
|
98
|
+
## Running Tests
|
|
99
|
+
|
|
100
|
+
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.
|
|
101
|
+
|
|
102
|
+
1. Ensure your `.env` contains a valid `QUILLBOT_USERIDTOKEN`.
|
|
103
|
+
2. Run the tests:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
pytest tests/test_unit.py -v -s
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Disclaimer
|
|
110
|
+
|
|
111
|
+
This is an unofficial SDK. Use responsibly and ensure you comply with QuillBot's Terms of Service.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "quillbot"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Lightweight Python SDK for QuillBot's paraphrasing and summarization APIs."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"httpx>=0.27",
|
|
14
|
+
"platformdirs>=4.0.0",
|
|
15
|
+
"mcp>=1.2.1",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
quillbot-mcp = "quillbot.server:main"
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
dev = [
|
|
23
|
+
"pytest>=8.0",
|
|
24
|
+
"pytest-asyncio>=0.24",
|
|
25
|
+
]
|
|
@@ -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
|
+
]
|
|
@@ -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
|
+
)
|