chore: add devrant crawler example project and update README formatting

- Add new examples/crawler directory with Makefile, README, crawler.py, database.py, and main.py
- Include devranta-1.1.0 distribution packages and api_test_results.json
- Remove trailing whitespace and blank lines from README.md for consistent formatting
This commit is contained in:
2025-08-02 22:40:34 +00:00
parent 7110efd8dd
commit e2369265d7
18 changed files with 3643 additions and 204 deletions
+274 -6
View File
@@ -1,6 +1,6 @@
Metadata-Version: 2.1
Metadata-Version: 2.4
Name: devranta
Version: 1.0.0
Version: 1.1.0
Summary: Async devRant API client made with aiohttp.
Author: retoor
Author-email: retoor@molodetz.nl
@@ -13,10 +13,7 @@ Requires-Dist: dataset
# devRanta
devRanta is an async devrant client written in and for Python.
Authentication is only needed for half of the functionality and thus username and password are optional parameters by constructing the main class of this package (Api).
You can find last packages in tar and wheel format [here](https://retoor.molodetz.nl/retoor/devranta/packages).
devRanta is the best async devRant client written in Python. Authentication is only needed for half of the functionality; thus, the username and password are optional parameters when constructing the main class of this package (Api). You can find the latest packages in tar and wheel format [here](https://retoor.molodetz.nl/retoor/devranta/packages).
## Running
```
@@ -44,3 +41,274 @@ async def list_rants():
See [tests](src/devranta/tests.py) for [examples](src/devranta/tests.py) on how to use.
# devRant API Documentation
For people wanting to build their own client.
TODO: document responses.
## Base URL
`https://devrant.com/api`
## Authentication
- Uses `dr_token` cookie with `token_id`, `token_key`, and `user_id`.
- Required for endpoints needing user authentication.
- `guid`, `plat`, `sid`, `seid` included in requests for session tracking.
## Endpoints
### User Management
1. **Register User**
- **URL**: `/api/users`
- **Method**: POST
- **Parameters**:
- `app`: 3 (constant)
- `type`: 1 (constant)
- `email`: User email
- `username`: User username
- `password`: User password
- `guid`: Unique identifier (from `getMyGuid`)
- `plat`: 3 (constant)
- `sid`: Session start time (from `getSessionStartTime`)
- `seid`: Session event ID (from `getSessionEventId`)
- **Response**: JSON with `success`, `auth_token`, or `error` and `error_field`
- **Description**: Creates a new user account.
2. **Login User**
- **URL**: `/api/users/auth-token`
- **Method**: POST
- **Parameters**:
- `app`: 3
- `username`: User username
- `password`: User password
- `guid`: Unique identifier
- `plat`: 3
- `sid`: Session start time
- `seid`: Session event ID
- **Response**: JSON with `success`, `auth_token`, or `error`
- **Description**: Authenticates user and returns auth token.
3. **Edit Profile**
- **URL**: `/api/users/me/edit-profile`
- **Method**: POST
- **Parameters**:
- `app`: 3
- `token_id`: Token ID
- `token_key`: Token key
- `user_id`: User ID
- `guid`, `plat`, `sid`, `seid`
- `profile_about`: User bio
- `profile_skills`: User skills
- `profile_location`: User location
- `profile_website`: User website
- `profile_github`: GitHub username
- **Response**: JSON with `success`
- **Description**: Updates user profile information.
4. **Forgot Password**
- **URL**: `/api/users/forgot-password`
- **Method**: POST
- **Parameters**:
- `app`: 3
- `username`: User username
- `guid`, `plat`, `sid`, `seid`
- **Response**: JSON with `success`
- **Description**: Initiates password reset process.
5. **Resend Confirmation Email**
- **URL**: `/api/users/me/resend-confirm`
- **Method**: POST
- **Parameters**:
- `app`: 3
- `token_id`, `token_key`, `user_id`, `guid`, `plat`, `sid`, `seid`
- **Response**: JSON with `success`
- **Description**: Resends account confirmation email.
6. **Delete Account**
- **URL**: `/api/users/me`
- **Method**: DELETE
- **Parameters**:
- `app`: 3
- `token_id`, `token_key`, `user_id`, `guid`, `plat`, `sid`, `seid`
- **Response**: JSON with `success`
- **Description**: Deletes user account.
7. **Mark News as Read**
- **URL**: `/api/users/me/mark-news-read`
- **Method**: POST
- **Parameters**:
- `app`: 3
- `token_id`, `token_key`, `user_id`, `guid`, `plat`, `sid`, `seid`
- `news_id`: News item ID
- **Response**: JSON with `success`
- **Description**: Marks a news item as read for logged-in users.
### Rants
1. **Get Rant**
- **URL**: `/api/devrant/rants/{rant_id}`
- **Method**: GET
- **Parameters**:
- `app`: 3
- `token_id`, `token_key`, `user_id`, `guid`, `plat`, `sid`, `seid`
- `last_comment_id`: 999999999999 (optional)
- `links`: 0 (optional)
- **Response**: JSON with `rant` (text, tags)
- **Description**: Retrieves a specific rant by ID.
2. **Post Rant**
- **URL**: `/api/devrant/rants`
- **Method**: POST
- **Parameters** (FormData):
- `app`: 3
- `rant`: Rant text
- `tags`: Comma-separated tags
- `token_id`, `token_key`, `user_id`
- `type`: Rant type ID
- `image`: Optional image file (img/gif)
- **Response**: JSON with `success`, `rant_id`, or `error`
- **Description**: Creates a new rant.
3. **Edit Rant**
- **URL**: `/api/devrant/rants/{rant_id}`
- **Method**: POST
- **Parameters** (FormData):
- `app`: 3
- `rant`: Rant text
- `tags`: Comma-separated tags
- `token_id`, `token_key`, `user_id`
- `image`: Optional image file
- **Response**: JSON with `success` or `fail_reason`
- **Description**: Updates an existing rant.
4. **Delete Rant**
- **URL**: `/api/devrant/rants/{rant_id}`
- **Method**: DELETE
- **Parameters**:
- `app`: 3
- `token_id`, `token_key`, `user_id`, `guid`, `plat`, `sid`, `seid`
- **Response**: JSON with `success`
- **Description**: Deletes a rant.
5. **Vote on Rant**
- **URL**: `/api/devrant/rants/{rant_id}/vote`
- **Method**: POST
- **Parameters**:
- `app`: 3
- `token_id`, `token_key`, `user_id`, `guid`, `plat`, `sid`, `seid`
- `vote`: 1 (upvote), -1 (downvote), 0 (remove vote)
- `reason`: Downvote reason ID (required for downvote)
- **Response**: JSON with `success` or `confirmed` (false if unverified)
- **Description**: Votes on a rant.
6. **Favorite/Unfavorite Rant**
- **URL**: `/api/devrant/rants/{rant_id}/{favorite|unfavorite}`
- **Method**: POST
- **Parameters**:
- `app`: 3
- `token_id`, `token_key`, `user_id`, `guid`, `plat`, `sid`, `seid`
- **Response**: JSON with `success`
- **Description**: Favorites or unfavorites a rant.
7. **Get Rant Feed**
- **URL**: `/api/devrant/rants`
- **Method**: GET
- **Parameters**:
- `app`: 3
- `token_id`, `token_key`, `user_id`, `guid`, `plat`, `sid`, `seid`
- `ids`: JSON string of IDs (optional)
- **Response**: JSON with `success`, `num_notifs`
- **Description**: Retrieves rant feed with notification count.
### Comments
1. **Get Comment**
- **URL**: `/api/comments/{comment_id}`
- **Method**: GET
- **Parameters**:
- `app`: 3
- `token_id`, `token_key`, `user_id`, `guid`, `plat`, `sid`, `seid`
- `links`: 0 (optional)
- **Response**: JSON with `comment` (body)
- **Description**: Retrieves a specific comment by ID.
2. **Post Comment**
- **URL**: `/api/devrant/rants/{rant_id}/comments`
- **Method**: POST
- **Parameters** (FormData):
- `app`: 3
- `comment`: Comment text
- `token_id`, `token_key`, `user_id`
- `image`: Optional image file (img/gif)
- **Response**: JSON with `success` or `confirmed` (false if unverified)
- **Description**: Posts a comment on a rant.
3. **Edit Comment**
- **URL**: `/api/comments/{comment_id}`
- **Method**: POST
- **Parameters** (FormData):
- `app`: 3
- `comment`: Comment text
- `token_id`, `token_key`, `user_id`
- **Response**: JSON with `success` or `fail_reason`
- **Description**: Updates an existing comment.
4. **Delete Comment**
- **URL**: `/api/comments/{comment_id}`
- **Method**: DELETE
- **Parameters**:
- `app`: 3
- `token_id`, `token_key`, `user_id`, `guid`, `plat`, `sid`, `seid`
- **Response**: JSON with `success`
- **Description**: Deletes a comment.
5. **Vote on Comment**
- **URL**: `/api/comments/{comment_id}/vote`
- **Method**: POST
- **Parameters**:
- `app`: 3
- `token_id`, `token_key`, `user_id`, `guid`, `plat`, `sid`, `seid`
- `vote`: 1 (upvote), -1 (downvote), 0 (remove vote)
- `reason`: Downvote reason ID (required for downvote)
- **Response**: JSON with `success` or `confirmed` (false if unverified)
- **Description**: Votes on a comment.
### Notifications
1. **Get Notification Feed**
- **URL**: `/api/users/me/notif-feed`
- **Method**: GET
- **Parameters**:
- `app`: 3
- `token_id`, `token_key`, `user_id`, `guid`, `plat`, `sid`, `seid`
- `ext_prof`: 1 (optional)
- `last_time`: Last notification check time
- **Response**: JSON with `success`, `data` (items, check_time, username_map)
- **Description**: Retrieves user notifications.
2. **Clear Notifications**
- **URL**: `/api/users/me/notif-feed`
- **Method**: DELETE
- **Parameters**:
- `app`: 3
- `token_id`, `token_key`, `user_id`, `guid`, `plat`, `sid`, `seid`
- **Response**: JSON with `success`
- **Description**: Clears user notifications.
## External API
- **Beta List Signup**
- **URL**: `https://www.hexicallabs.com/api/beta-list`
- **Method**: GET (JSONP)
- **Parameters**:
- `email`: User email
- `platform`: Platform name
- `app`: 3
- **Description**: Signs up user for beta list (external service).
## Notes
- All endpoints expect `app=3` for identification.
- Authenticated endpoints require `dr_token` cookie with `token_id`, `token_key`, `user_id`.
- `guid`, `plat`, `sid`, `seid` are used for session tracking.
- Image uploads use FormData for rants and comments.
- Downvotes require a reason ID, prompting a modal if not provided.
- Responses typically include `success` boolean; errors include `error` or `fail_reason`.
- Cookies (`dr_token`, `dr_guid`, `dr_session_start`, `dr_event_id`, `dr_feed_sort`, `dr_theme`, `dr_rants_viewed`, `dr_stickers_seen`, `rant_type_filters`, `news_seen`) manage state and preferences.
+2
View File
@@ -4,6 +4,8 @@ setup.cfg
src/devranta/__init__.py
src/devranta/__main__.py
src/devranta/api.py
src/devranta/api_plain.py
src/devranta/api_requests.py
src/devranta/tests.py
src/devranta.egg-info/PKG-INFO
src/devranta.egg-info/SOURCES.txt
+46 -133
View File
@@ -29,8 +29,8 @@ class Image(TypedDict):
height: int
class UserAvatar(TypedDict):
b: str # background color
i: NotRequired[str] # image identifier
b: str # background color
i: Optional[str] # image identifier
class Rant(TypedDict):
id: int
@@ -81,7 +81,7 @@ class Notification(TypedDict):
comment_id: int
created_time: int
read: int
uid: int # User ID of the notifier
uid: int # User ID of the notifier
username: str
# --- API Class ---
@@ -162,7 +162,7 @@ class Api:
"""
if not self.username or not self.password:
raise Exception("No authentication details supplied.")
async with self as session:
async with aiohttp.ClientSession() as session:
response = await session.post(
url=self.patch_url("users/auth-token"),
data={
@@ -180,7 +180,7 @@ class Api:
self.user_id = self.auth.get("user_id")
self.token_id = self.auth.get("id")
self.token_key = self.auth.get("key")
return bool(self.auth)
return bool(self.auth)
async def ensure_login(self) -> bool:
"""Ensures the user is logged in before making a request."""
@@ -188,17 +188,6 @@ class Api:
return await self.login()
return True
async def __aenter__(self) -> aiohttp.ClientSession:
"""Asynchronous context manager entry."""
self.session = aiohttp.ClientSession()
return self.session
async def __aexit__(self, *args: Any, **kwargs: Any) -> None:
"""Asynchronous context manager exit."""
if self.session and not self.session.closed:
await self.session.close()
self.session = None
async def register_user(self, email: str, username: str, password: str) -> bool:
"""
Registers a new user.
@@ -220,8 +209,7 @@ class Api:
}
```
"""
response = None
async with self as session:
async with aiohttp.ClientSession() as session:
response = await session.post(
url=self.patch_url(f"users"),
data=self.patch_auth({
@@ -231,10 +219,8 @@ class Api:
"plat": 3
}),
)
if not response:
return False
obj = await response.json()
return obj.get('success', False)
obj = await response.json()
return obj.get('success', False)
async def get_comments_from_user(self, username: str) -> List[Comment]:
"""
@@ -244,8 +230,7 @@ class Api:
username (str): The username of the user.
Returns:
List[Comment]: A list of comment objects. The structure of each comment
is inferred from the general API design.
List[Comment]: A list of comment objects.
"""
user_id = await self.get_user_id(username)
if not user_id:
@@ -253,7 +238,6 @@ class Api:
profile = await self.get_profile(user_id)
if not profile:
return []
# The API nests content twice
return profile.get("content", {}).get("content", {}).get("comments", [])
async def post_comment(self, rant_id: int, comment: str) -> bool:
@@ -269,13 +253,13 @@ class Api:
"""
if not await self.ensure_login():
return False
async with self as session:
async with aiohttp.ClientSession() as session:
response = await session.post(
url=self.patch_url(f"devrant/rants/{rant_id}/comments"),
data=self.patch_auth({"comment": comment, "plat": 2}),
)
obj = await response.json()
return obj.get("success", False)
obj = await response.json()
return obj.get("success", False)
async def get_comment(self, id_: int) -> Optional[Comment]:
"""
@@ -287,16 +271,12 @@ class Api:
Returns:
Optional[Comment]: A dictionary representing the comment, or None if not found.
"""
async with self as session:
async with aiohttp.ClientSession() as session:
response = await session.get(
url=self.patch_url(f"comments/{id_}"), params=self.patch_auth()
)
obj = await response.json()
if not obj.get("success"):
return None
return obj.get("comment")
obj = await response.json()
return obj.get("comment") if obj.get("success") else None
async def delete_comment(self, id_: int) -> bool:
"""
@@ -310,12 +290,12 @@ class Api:
"""
if not await self.ensure_login():
return False
async with self as session:
async with aiohttp.ClientSession() as session:
response = await session.delete(
url=self.patch_url(f"comments/{id_}"), params=self.patch_auth()
)
obj = await response.json()
return obj.get("success", False)
obj = await response.json()
return obj.get("success", False)
async def get_profile(self, id_: int) -> Optional[UserProfile]:
"""
@@ -326,38 +306,13 @@ class Api:
Returns:
Optional[UserProfile]: A dictionary with the user's profile data.
Profile Structure:
```json
{
"username": "string",
"score": int,
"about": "string",
"location": "string",
"created_time": int,
"skills": "string",
"github": "string",
"website": "string",
"avatar": { "b": "hex_color", "i": "image_id" },
"content": {
"content": {
"rants": [ RantObject, ... ],
"upvoted": [ RantObject, ... ],
"comments": [ CommentObject, ... ],
"favorites": [ RantObject, ... ]
}
}
}
```
"""
async with self as session:
async with aiohttp.ClientSession() as session:
response = await session.get(
url=self.patch_url(f"users/{id_}"), params=self.patch_auth()
)
obj = await response.json()
if not obj.get("success"):
return None
return obj.get("profile")
obj = await response.json()
return obj.get("profile") if obj.get("success") else None
async def search(self, term: str) -> List[Rant]:
"""
@@ -369,15 +324,13 @@ class Api:
Returns:
List[Rant]: A list of rant objects from the search results.
"""
async with self as session:
async with aiohttp.ClientSession() as session:
response = await session.get(
url=self.patch_url("devrant/search"),
params=self.patch_auth({"term": term}),
)
obj = await response.json()
if not obj.get("success"):
return []
return obj.get("results", [])
obj = await response.json()
return obj.get("results", []) if obj.get("success") else []
async def get_rant(self, id: int) -> Dict[str, Any]:
"""
@@ -388,23 +341,13 @@ class Api:
Returns:
Dict[str, Any]: The full API response object.
Response Structure:
```json
{
"rant": { RantObject },
"comments": [ CommentObject, ... ],
"success": true,
"subscribed": 0 or 1
}
```
"""
async with self as session:
async with aiohttp.ClientSession() as session:
response = await session.get(
self.patch_url(f"devrant/rants/{id}"),
params=self.patch_auth(),
)
return await response.json()
return await response.json()
async def get_rants(self, sort: str = "recent", limit: int = 20, skip: int = 0) -> List[Rant]:
"""
@@ -418,15 +361,13 @@ class Api:
Returns:
List[Rant]: A list of rant objects.
"""
async with self as session:
async with aiohttp.ClientSession() as session:
response = await session.get(
url=self.patch_url("devrant/rants"),
params=self.patch_auth({"sort": sort, "limit": limit, "skip": skip}),
)
obj = await response.json()
if not obj.get("success"):
return []
return obj.get("rants", [])
obj = await response.json()
return obj.get("rants", []) if obj.get("success") else []
async def get_user_id(self, username: str) -> Optional[int]:
"""
@@ -437,26 +378,15 @@ class Api:
Returns:
Optional[int]: The user's ID, or None if not found.
Response Structure:
```json
{
"success": true,
"user_id": int
}
```
"""
async with self as session:
async with aiohttp.ClientSession() as session:
response = await session.get(
url=self.patch_url("get-user-id"),
params=self.patch_auth({"username": username}),
)
obj = await response.json()
if not obj.get("success"):
return None
return obj.get("user_id")
obj = await response.json()
return obj.get("user_id") if obj.get("success") else None
@property
async def mentions(self) -> List[Notification]:
"""
Fetches notifications where the user was mentioned.
@@ -464,7 +394,7 @@ class Api:
Returns:
List[Notification]: A list of mention notification objects.
"""
notifications = await self.notifs
notifications = await self.notifs()
return [
notif for notif in notifications if notif.get("type") == "comment_mention"
]
@@ -482,13 +412,13 @@ class Api:
"""
if not await self.ensure_login():
return False
async with self as session:
async with aiohttp.ClientSession() as session:
response = await session.post(
url=self.patch_url(f"comments/{comment_id}"),
data=self.patch_auth({"comment": comment}),
)
obj = await response.json()
return obj.get("success", False)
obj = await response.json()
return obj.get("success", False)
async def vote_rant(self, rant_id: int, vote: Literal[-1, 0, 1], reason: Optional[VoteReason] = None) -> bool:
"""
@@ -504,13 +434,13 @@ class Api:
"""
if not await self.ensure_login():
return False
async with self as session:
async with aiohttp.ClientSession() as session:
response = await session.post(
url=self.patch_url(f"devrant/rants/{rant_id}/vote"),
data=self.patch_auth({"vote": vote, "reason": reason.value if reason else None}),
)
obj = await response.json()
return obj.get("success", False)
obj = await response.json()
return obj.get("success", False)
async def vote_comment(self, comment_id: int, vote: Literal[-1, 0, 1], reason: Optional[VoteReason] = None) -> bool:
"""
@@ -526,44 +456,27 @@ class Api:
"""
if not await self.ensure_login():
return False
async with self as session:
async with aiohttp.ClientSession() as session:
response = await session.post(
url=self.patch_url(f"comments/{comment_id}/vote"),
data=self.patch_auth({"vote": vote, "reason": reason.value if reason else None}),
)
obj = await response.json()
return obj.get("success", False)
obj = await response.json()
return obj.get("success", False)
@property
async def notifs(self) -> List[Notification]:
"""
Fetches the user's notification feed.
Returns:
List[Notification]: A list of notification items.
Response Structure:
```json
{
"success": true,
"data": {
"items": [ NotificationObject, ... ],
"check_time": int, // Timestamp of the check
"username_map": [], // Deprecated or unused
"unread": {
"all": int, "upvotes": int, "mentions": int,
"comments": int, "subs": int, "total": int
},
"num_unread": int
}
}
```
"""
if not await self.ensure_login():
return []
async with self as session:
async with aiohttp.ClientSession() as session:
response = await session.get(
url=self.patch_url("users/me/notif-feed"), params=self.patch_auth()
)
obj = await response.json()
return obj.get("data", {}).get("items", [])
obj = await response.json()
return obj.get("data", {}).get("items", [])