Compare commits

..
53 Commits
Author SHA1 Message Date
retoor f6fe3a5fb8 feat: add princess example with Makefile and requirements, plus devranta C project with Makefile and binary
devranta build / Build (push) Failing after 50s
2025-12-28 05:03:20 +00:00
retoor c409ec4541 chore: replace aiosqlite with dataset and add bounded seen set to crawler 2025-12-28 05:03:12 +00:00
bot a48bc9f97b chore: update binary wheel and source tarball for v2.3.1 release with trimmed pkg-info 2025-09-01 16:39:03 +00:00
retoor 637fdf19c7 feat: add mention-specific LLM and switch logging to INFO in princess bot
- Add `reset_llms` method to initialize separate `mention_llm` and `llm` instances
- Change logging level from DEBUG to INFO for reduced verbosity
- Reorder imports and add `json`, `re`, `Dict`, `List` for future type hints
2025-09-01 16:37:32 +00:00
retoor bad5767e7d chore: strip trailing whitespace and leftover assistant prompt from README.md 2025-08-12 23:41:43 +00:00
retoor 4c77bf1eb9 chore: reorder import and add rant_id field plus auto-comment posting in princess bot 2025-08-12 22:22:00 +00:00
retoor 364de14682 chore: remove trailing whitespace from README.md formatting in examples/princess directory 2025-08-12 22:12:38 +00:00
retoor a551b80bc2 chore: add .env and *.db to gitignore and reformat imports in crawler examples 2025-08-12 22:06:44 +00:00
retoor e2369265d7 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
2025-08-02 22:40:34 +00:00
retoor 7110efd8dd chore: add typed dicts for api responses and refactor api class with future annotations 2025-08-02 06:03:19 +00:00
retoor febebe48cb chore: remove trailing whitespace from README.md line 42 and update project description 2025-08-02 05:11:03 +00:00
retoor 06bea6ccff feat: add Api class with devRant authentication and user registration methods 2025-04-23 22:58:42 +00:00
retoor 4f8fc9f242 feat: add synchronous api_requests module with auth and voting for devRant
Introduce a dedicated synchronous API client module in src/devranta/api_requests.py that encapsulates request formatting, authentication via username/password login, and voting functionality for devRant interactions. The module provides an Api class with methods for login, URL patching, auth dict injection, and vote submission using the requests library, centralizing input preprocessing and reducing duplication across callers.
2025-04-23 13:59:14 +00:00
bot 483438b3c7 chore: replace built wheel and sdist with release v2.3.1 binaries 2025-01-06 18:02:11 +00:00
retoor 7d8e23e5b6 feat: add async delete_comment method to Api class for removing comments by id 2025-01-06 18:00:45 +00:00
bot e68dfb5b66 chore: replace binary wheel and source tarball for v1.0.0 release 2024-12-14 03:42:04 +00:00
retoor e60895a855 fix: correct endpoint path from devrant comments to users in Api.patch_url call 2024-12-14 03:40:22 +00:00
bot 357cb70578 chore: regenerate binary wheel and source tarball for v1.0.0 release 2024-12-14 03:19:28 +00:00
retoor d12027474c feat: add register_user method to Api class for account creation with email, username, password
Implements POST request to devrant/rants/{rant_id}/comments endpoint with email, username, password, and platform field. Returns boolean success based on JSON response 'success' key. Includes async session handling and URL patching with auth data.
2024-12-14 03:17:48 +00:00
bot e2f7e13fa6 chore: rebuild binary wheel and source tarball with updated egg-info metadata 2024-12-04 22:14:50 +00:00
retoor d9da6f2d1b docs: add project overview and local setup instructions to README
The README now includes a brief overview of the project's purpose and step-by-step guidance for local installation and configuration. The outdated "Todo" section referencing message editing has been removed.
2024-12-04 22:13:31 +00:00
bot 18d4bada2a chore: rebuild wheel and sdist artifacts with updated PKG-INFO removing voting comment todo 2024-12-03 22:13:56 +00:00
retoor ed94866651 feat: add vote_rant and vote_comment methods with VoteReason enum to Api class 2024-12-03 22:12:48 +00:00
dr c6b132df62 feat: add vote_rant and vote_comment methods with VoteReason enum to api client
Implement two new async methods for voting on rants and comments, accepting vote values of -1, 0, or 1 with an optional VoteReason enum (NOT_FOR_ME, REPOST, OFFENSIVE_SPAM). Both methods require authentication, construct POST requests to the respective endpoints with vote and reason parameters, and return the success boolean from the response. Also add the VoteReason enum and update the todo list by removing the completed voting comment item.
2024-12-03 21:25:03 +00:00
bot 355aafdcaa chore: update binary wheel and source tarball for v1.0.0 release 2024-12-03 18:51:46 +00:00
retoor bb5e809ace fix: switch comment update request from GET to POST and remove debug print 2024-12-03 18:50:30 +00:00
bot 8b31ba34ee chore: rebuild distribution archives for devranta 1.0.0 with updated compiled artifacts 2024-12-03 18:47:24 +00:00
retoor 89b8c6b14e chore: add debug print of auth response json in patch_auth method 2024-12-03 18:46:09 +00:00
bot a867cbb38c chore: rebuild distribution archives for devranta 1.0.0 release 2024-12-03 18:17:11 +00:00
retoor b5fec97682 fix: parse response json before checking success field in comment update
The fix ensures the response object is deserialized via `await response.json()` before accessing the `"success"` key, preventing an `AttributeError` when the raw response lacks a `.get()` method. This corrects the empty reply issue when input is missing or malformed.
2024-12-03 18:15:48 +00:00
bot 85c2d48612 chore: update devranta wheel and sdist binaries for v1.0.0 release 2024-12-03 18:02:21 +00:00
retoor 7a01fa4dc9 chore: rename build configuration file from build.yml to build.yaml for consistency 2024-12-03 18:00:28 +00:00
bot 74cd463f2a chore: update binary wheel and source tarball for devranta 1.0.0 release 2024-12-03 17:59:38 +00:00
retoor d4fde2a8ec fix: guard against null avatarUrl in UserProfileAvatarLoader component 2024-12-03 17:58:28 +00:00
retoor f33b12f262 chore: rename build config files and update CI pipeline references to new paths 2024-12-03 17:56:51 +00:00
retoor ec7a091cec chore: remove deprecated build step for legacy deployment pipeline
- Delete the "Build legacy artifacts" step from .gitea/workflows/build.yaml
- Add a "Pull repo" step before listing files to ensure fresh checkout
- Simplify pipeline by removing unused legacy build logic
2024-12-03 17:52:46 +00:00
retoor d0b03e4cbc chore: add update_comment method and clean up whitespace in api.py 2024-12-03 17:49:12 +00:00
retoor a9654c715d fix: correct YAML indentation for step run directives in build workflow 2024-12-03 03:08:16 +00:00
retoor 2289f5926e chore: add build, install, run, test, and push steps to CI workflow 2024-12-03 03:02:36 +00:00
bot 2090e9154d chore: rebuild wheel and sdist artifacts for devranta v1.0.0 release 2024-12-03 02:56:38 +00:00
retoor d2b1228141 feat: add initial package build artifacts for devranta version 1.0.0 2024-12-03 02:55:27 +00:00
retoor 50f52b7281 fix: replace async generator with direct await to handle empty input array in main_async
The previous implementation used an async generator `api.get_rants()` which would raise a `StopAsyncIteration` error when the input array was empty. By switching to `await api.get_rants()` which returns a list, the code now correctly handles the edge case where no rants are available, allowing the for loop to iterate over an empty list without raising an exception.
2024-12-03 02:54:31 +00:00
retoor 933c5afb0b feat: add get_comments_from_user method to Api class and corresponding async test case 2024-12-03 02:52:56 +00:00
retoor e669166696 refactor: replace async generators with direct list returns in Api methods and tests
The commit removes all async generator functions from the Api class, converting `get_rants`, `search`, `mentions`, and `notifs` to return lists directly instead of yielding items. This eliminates the need for the `async_list` and `async_len` test helper utilities, simplifying the test assertions to use standard `len()` calls. The change reduces code complexity by removing generator overhead and dead helper code that was only used to consume the generators.
2024-12-02 23:57:59 +00:00
bot 0fb1b7e7fa chore: add package download link to PKG-INFO and rebuild dist artifacts for release 2.4.1
- Updated PKG-INFO in src/devranta.egg-info to include a link to the latest tar and wheel packages at retoor.molodetz.nl
- Rebuilt dist/devranta-1.0.0-py3-none-any.whl and dist/devranta-1.0.0.tar.gz with binary changes reflecting the metadata update
2024-12-02 16:56:33 +00:00
retoor 75541cb388 docs: add link to prebuilt packages archive in README overview section 2024-12-02 16:55:22 +00:00
bot 14330696c8 chore: update binary wheel and source tarball for devranta 1.0.0 release 2024-12-02 16:53:08 +00:00
retoor 44424f6ba9 chore: remove outdated dist artifacts for retoorded 1.3.37 wheel and tarball 2024-12-02 16:51:03 +00:00
bot 13e07fdf1b chore: add README content and include it in package sources for devranta 2024-12-02 16:50:25 +00:00
retoor 046ecb16eb chore: add auto-commit and push steps to build workflow after tests 2024-12-02 16:49:20 +00:00
retoor d848352189 docs: add project description and setup instructions to README
- Replace vague "About" heading with direct project description
- Add setup instructions for installing via pip and configuring authentication
- Include usage examples for basic API calls and error handling
2024-12-02 16:46:23 +00:00
retoor 713347f22c docs: add make install step to build workflow and update README with todo list 2024-12-02 16:43:40 +00:00
retoor 7ab84098cc chore: scaffold devranta project with Makefile, CI workflow, and async API client stub 2024-12-02 16:41:02 +00:00
14 changed files with 854 additions and 351 deletions
+38 -9
View File
@@ -1,11 +1,40 @@
.venv
.history
__pycache__
*.pyc
# retoor <retoor@molodetz.nl>
# Python
__pycache__/
*.pyc
*.pyo
*.egg-info/
dist/
build/
.eggs/
# Virtual environments
.venv/
venv/
# Environment
.env
# IDE
.history/
.idea/
.vscode/
*.swp
# C build artifacts
*.o
*.so
*.a
*.c
*.h
# Databases
*.db
examples/crawler/devrant.sqlite-shm
examples/crawler/devrant.sqlite-wal
examples/crawler/devrant.sqlite
examples/crawler/.venv
examples/crawler/__pycache__
*.sqlite
*.sqlite-shm
*.sqlite-wal
# OS
.DS_Store
Thumbs.db
+27 -10
View File
@@ -1,28 +1,45 @@
# devRanta
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).
Author: retoor <retoor@molodetz.nl>
An asynchronous Python client for the devRant API. Authentication is only required for write operations; read-only endpoints work without credentials. Packages available in tar and wheel format [here](https://retoor.molodetz.nl/retoor/devranta/packages).
## Running
```
make run
```
## Testing
Tests are only made for methods not requireing authentication.
I do not see value in mocking requests.
Tests cover methods not requiring authentication.
```
make test
```
## How to use
Implementation:
```
## Usage
```python
from devranta.api import Api
api = Api(username="optional!", password="optional!")
api = Api(username="optional", password="optional")
async def list_rants():
async for rant in api.get_rants():
print(rant["user_username"], ":", rant["text"])
```
See [tests](src/devranta/tests.py) for [examples](src/devranta/tests.py) on how to use.
See [tests](src/devranta/tests.py) for additional examples.
## Examples
| Example | Description |
|---------|-------------|
| [crawler](examples/crawler/) | Asynchronous data collection with producer-consumer architecture |
| [princess](examples/princess/) | Automated response bot with LLM integration |
# devRant API Documentation
For people wanting to build their own client.
TODO: document responses.
Reference for building custom clients.
## Base URL
`https://devrant.com/api`
## Authentication
+1 -1
View File
@@ -1,10 +1,10 @@
# retoor <retoor@molodetz.nl>
.PHONY: all env install run clean
all: env install run
env:
python3 -m venv .venv
.
install:
. .venv/bin/activate && pip install -r requirements.txt
+102 -14
View File
@@ -1,34 +1,122 @@
# Example Crawler Project
# devRant Exhaustive Crawler
This is a simple example crawler project. Follow the instructions below to set up and run the crawler.
Author: retoor <retoor@molodetz.nl>
## Setup
An asynchronous crawler for comprehensive data collection from the devRant platform. Implements a producer-consumer architecture with multiple discovery strategies to maximize content coverage.
1. Clone the repository or copy the project files to your local machine.
2. Make sure you have Python 3 installed.
## SSL Note
The devRant API SSL certificate is expired. This crawler disables SSL verification to maintain connectivity. This is handled automatically by the API client.
## Architecture
The crawler employs four concurrent producers feeding into worker pools:
| Producer | Strategy | Interval |
|----------|----------|----------|
| Recent | Paginate through recent rants | 2s |
| Top | Paginate through top-rated rants | 5s |
| Algo | Paginate through algorithm-sorted rants | 5s |
| Search | Cycle through 48 programming-related search terms | 30s |
Worker pools process discovered content:
- 10 rant consumers fetch rant details and extract comments
- 5 user consumers fetch profiles and discover associated rants
Discovery graph: rants reveal users, users reveal more rants (from their profile, upvoted, favorites).
## Data Storage
Uses SQLite via the dataset library with:
- Batched writes (100 items or 5s interval)
- Automatic upsert for deduplication
- Indexes on user_id, created_time, rant_id
- State persistence for resume capability
### Schema
**rants**: id, user_id, text, score, created_time, num_comments, attached_image_url, tags, link, vote_state, user_username, user_score
**comments**: id, rant_id, user_id, body, score, created_time, vote_state, user_username, user_score
**users**: id, username, score, about, location, created_time, skills, github, website
**crawler_state**: Persists producer positions (skip values, search term index)
## Usage
1. Open a terminal in the project directory.
2. Run `make` to set up the environment, install dependencies, and start the crawler:
### Quick Start
```bash
make
```
This will create a virtual environment, install the package in editable mode from the parent directory, and run the main script.
This creates a virtual environment, installs dependencies, and starts the crawler.
### Manual Setup
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -e ../../.
pip install -r requirements.txt
python main.py
```
### Stopping
Press `Ctrl+C` for graceful shutdown. The crawler will:
1. Save current state to database
2. Wait up to 30 seconds for queues to drain
3. Flush remaining batched writes
### Resuming
Simply run again. The crawler loads saved state and continues from where it stopped.
## Configuration
Edit `main.py` to adjust:
```python
DB_FILE = "devrant.sqlite"
CONCURRENT_RANT_CONSUMERS = 10
CONCURRENT_USER_CONSUMERS = 5
BATCH_SIZE = 100
FLUSH_INTERVAL = 5.0
```
## Output
The crawler logs statistics every 15 seconds:
```
[STATS] Rants Q'd/Proc: 1250/1200 | Users Q'd/Proc: 450/400 | Comments DB: 5600 | Queues (R/U): 50/50 | API Errors: 0
```
## Cleanup
To remove the virtual environment, run:
```bash
make clean
```
## Notes
Removes the virtual environment. Database file (`devrant.sqlite`) is preserved.
- The project installs the package with `-e ../../.` to include the parent package `devranta` in editable mode.
- Ensure that the parent package is correctly set up in the directory structure.
## Requirements
Happy crawling!
- Python 3.10+
- dataset
- aiohttp (via parent devranta package)
## File Structure
```
crawler/
├── main.py # Entry point, configuration
├── crawler.py # Producer-consumer implementation
├── database.py # Dataset wrapper with batch queue
├── requirements.txt # Dependencies
├── Makefile # Build automation
├── .venv/ # Virtual environment (created on first run)
└── devrant.sqlite # SQLite database (created on first run)
```
+188 -24
View File
@@ -1,12 +1,50 @@
# retoor <retoor@molodetz.nl>
import asyncio
import json
import logging
import time
from collections import OrderedDict
from typing import Set
from database import DatabaseManager
from devranta.api import Api, Rant
SEARCH_TERMS = [
"python", "javascript", "java", "csharp", "golang", "rust",
"react", "angular", "vue", "node", "docker", "kubernetes",
"linux", "windows", "macos", "git", "github", "gitlab",
"sql", "mongodb", "redis", "api", "rest", "graphql",
"bug", "error", "crash", "debug", "fix", "issue",
"manager", "deadline", "meeting", "standup", "agile", "scrum",
"frontend", "backend", "fullstack", "devops", "cloud", "aws",
"typescript", "php", "ruby", "swift", "kotlin", "flutter",
]
class BoundedSeenSet:
def __init__(self, maxsize: int = 100000):
self._set = OrderedDict()
self._maxsize = maxsize
def add(self, item: int):
if item in self._set:
self._set.move_to_end(item)
else:
self._set[item] = None
if len(self._set) > self._maxsize:
self._set.popitem(last=False)
def __contains__(self, item: int) -> bool:
return item in self._set
def __len__(self) -> int:
return len(self._set)
def clear(self):
self._set.clear()
class DevRantCrawler:
def __init__(
self, api: Api, db: DatabaseManager, rant_consumers: int, user_consumers: int
@@ -20,8 +58,14 @@ class DevRantCrawler:
self.num_rant_consumers = rant_consumers
self.num_user_consumers = user_consumers
self.seen_rant_ids: Set[int] = set()
self.seen_user_ids: Set[int] = set()
self.seen_rant_ids = BoundedSeenSet(maxsize=100000)
self.seen_user_ids = BoundedSeenSet(maxsize=100000)
self._recent_skip = 0
self._top_skip = 0
self._algo_skip = 0
self._search_term_index = 0
self.stats = {
"rants_processed": 0,
"rants_added_to_db": 0,
@@ -74,29 +118,58 @@ class DevRantCrawler:
f"Seeder finished: Queued {len(user_ids)} users to kickstart exploration."
)
async def _save_state(self):
state = {
"recent_skip": self._recent_skip,
"top_skip": self._top_skip,
"algo_skip": self._algo_skip,
"search_term_index": self._search_term_index,
"last_saved": int(time.time()),
}
await self.db.save_crawler_state("producer_state", json.dumps(state))
logging.debug("Crawler state saved.")
async def _load_state(self):
state_json = await self.db.load_crawler_state("producer_state")
if state_json:
try:
state = json.loads(state_json)
self._recent_skip = state.get("recent_skip", 0)
self._top_skip = state.get("top_skip", 0)
self._algo_skip = state.get("algo_skip", 0)
self._search_term_index = state.get("search_term_index", 0)
logging.info(f"Loaded crawler state: {state}")
except json.JSONDecodeError:
logging.warning("Failed to decode crawler state, starting fresh.")
async def _state_saver(self):
logging.info("State saver started.")
while not self.shutdown_event.is_set():
await asyncio.sleep(60)
await self._save_state()
async def _rant_producer(self):
logging.info("Rant producer started.")
skip = 0
logging.info("Recent rant producer started.")
consecutive_empty_responses = 0
while not self.shutdown_event.is_set():
try:
logging.info(f"Producer: Fetching rants with skip={skip}...")
rants = await self.api.get_rants(sort="recent", limit=50, skip=skip)
logging.debug(f"Recent producer: Fetching rants with skip={self._recent_skip}...")
rants = await self.api.get_rants(sort="recent", limit=50, skip=self._recent_skip)
self.stats["producer_loops"] += 1
if not rants:
consecutive_empty_responses += 1
logging.info(
f"Producer: Feed returned empty. Consecutive empty hits: {consecutive_empty_responses}."
logging.debug(
f"Recent producer: Feed returned empty. Consecutive empty hits: {consecutive_empty_responses}."
)
if consecutive_empty_responses >= 5:
self.stats["end_of_feed_hits"] += 1
logging.info(
"Producer: End of feed likely reached. Pausing for 15 minutes before reset."
"Recent producer: End of feed likely reached. Pausing for 15 minutes before reset."
)
await asyncio.sleep(900)
skip = 0
self._recent_skip = 0
consecutive_empty_responses = 0
else:
await asyncio.sleep(10)
@@ -108,25 +181,98 @@ class DevRantCrawler:
await self._queue_rant_if_new(rant)
new_rants_found += 1
logging.info(
f"Producer: Processed {new_rants_found} rants from feed. Total queued: {self.stats['rants_queued']}."
logging.debug(
f"Recent producer: Processed {new_rants_found} rants from feed."
)
skip += len(rants)
self._recent_skip += len(rants)
await asyncio.sleep(2)
except Exception as e:
logging.critical(
f"Producer: Unhandled exception: {e}. Retrying in 60s."
logging.error(
f"Recent producer: Unhandled exception: {e}. Retrying in 60s."
)
self.stats["api_errors"] += 1
await asyncio.sleep(60)
async def _top_rant_producer(self):
logging.info("Top rant producer started.")
while not self.shutdown_event.is_set():
try:
logging.debug(f"Top producer: Fetching rants with skip={self._top_skip}...")
rants = await self.api.get_rants(sort="top", limit=50, skip=self._top_skip)
if not rants:
logging.info("Top producer: End of feed reached. Resetting after 1 hour.")
self._top_skip = 0
await asyncio.sleep(3600)
continue
for rant in rants:
await self._queue_rant_if_new(rant)
logging.debug(f"Top producer: Processed {len(rants)} rants.")
self._top_skip += len(rants)
await asyncio.sleep(5)
except Exception as e:
logging.error(f"Top producer: Unhandled exception: {e}. Retrying in 60s.")
self.stats["api_errors"] += 1
await asyncio.sleep(60)
async def _algo_rant_producer(self):
logging.info("Algo rant producer started.")
while not self.shutdown_event.is_set():
try:
logging.debug(f"Algo producer: Fetching rants with skip={self._algo_skip}...")
rants = await self.api.get_rants(sort="algo", limit=50, skip=self._algo_skip)
if not rants:
logging.info("Algo producer: End of feed reached. Resetting after 1 hour.")
self._algo_skip = 0
await asyncio.sleep(3600)
continue
for rant in rants:
await self._queue_rant_if_new(rant)
logging.debug(f"Algo producer: Processed {len(rants)} rants.")
self._algo_skip += len(rants)
await asyncio.sleep(5)
except Exception as e:
logging.error(f"Algo producer: Unhandled exception: {e}. Retrying in 60s.")
self.stats["api_errors"] += 1
await asyncio.sleep(60)
async def _search_producer(self):
logging.info("Search producer started.")
while not self.shutdown_event.is_set():
try:
term = SEARCH_TERMS[self._search_term_index % len(SEARCH_TERMS)]
logging.debug(f"Search producer: Searching for '{term}'...")
rants = await self.api.search(term)
for rant in rants:
await self._queue_rant_if_new(rant)
logging.debug(f"Search producer: Found {len(rants)} rants for '{term}'.")
self._search_term_index += 1
await asyncio.sleep(30)
except Exception as e:
logging.error(f"Search producer: Unhandled exception: {e}. Retrying in 60s.")
self.stats["api_errors"] += 1
await asyncio.sleep(60)
async def _rant_consumer(self, worker_id: int):
logging.info(f"Rant consumer #{worker_id} started.")
while not self.shutdown_event.is_set():
try:
rant_id = await self.rant_queue.get()
logging.info(
rant_id = await asyncio.wait_for(self.rant_queue.get(), timeout=5.0)
logging.debug(
f"Rant consumer #{worker_id}: Processing rant ID {rant_id}."
)
@@ -146,22 +292,27 @@ class DevRantCrawler:
self.stats["comments_added_to_db"] += 1
await self._queue_user_if_new(comment["user_id"])
logging.info(
logging.debug(
f"Rant consumer #{worker_id}: Finished processing rant {rant_id}, found {len(comments)} comments."
)
self.stats["rants_processed"] += 1
self.rant_queue.task_done()
except asyncio.TimeoutError:
continue
except Exception as e:
logging.error(f"Rant consumer #{worker_id}: Unhandled exception: {e}")
self.rant_queue.task_done()
try:
self.rant_queue.task_done()
except ValueError:
pass
async def _user_consumer(self, worker_id: int):
logging.info(f"User consumer #{worker_id} started.")
while not self.shutdown_event.is_set():
try:
user_id = await self.user_queue.get()
logging.info(
user_id = await asyncio.wait_for(self.user_queue.get(), timeout=5.0)
logging.debug(
f"User consumer #{worker_id}: Processing user ID {user_id}."
)
@@ -183,14 +334,20 @@ class DevRantCrawler:
await self._queue_rant_if_new(rant_obj)
rants_found_on_profile += 1
logging.info(
logging.debug(
f"User consumer #{worker_id}: Finished user {user_id}, found and queued {rants_found_on_profile} associated rants."
)
self.stats["users_processed"] += 1
self.user_queue.task_done()
except asyncio.TimeoutError:
continue
except Exception as e:
logging.error(f"User consumer #{worker_id}: Unhandled exception: {e}")
self.user_queue.task_done()
try:
self.user_queue.task_done()
except ValueError:
pass
async def _stats_reporter(self):
logging.info("Stats reporter started.")
@@ -206,13 +363,18 @@ class DevRantCrawler:
async def run(self):
logging.info("Exhaustive crawler starting...")
await self._load_state()
await self._initial_seed()
logging.info("Starting main producer and consumer tasks...")
tasks = []
try:
tasks.append(asyncio.create_task(self._rant_producer()))
tasks.append(asyncio.create_task(self._top_rant_producer()))
tasks.append(asyncio.create_task(self._algo_rant_producer()))
tasks.append(asyncio.create_task(self._search_producer()))
tasks.append(asyncio.create_task(self._stats_reporter()))
tasks.append(asyncio.create_task(self._state_saver()))
for i in range(self.num_rant_consumers):
tasks.append(asyncio.create_task(self._rant_consumer(i + 1)))
@@ -232,6 +394,8 @@ class DevRantCrawler:
logging.info("Shutting down... sending signal to all tasks.")
self.shutdown_event.set()
await self._save_state()
logging.info("Waiting for queues to empty... Press Ctrl+C again to force exit.")
try:
await asyncio.wait_for(self.rant_queue.join(), timeout=30)
+198 -102
View File
@@ -1,130 +1,226 @@
# retoor <retoor@molodetz.nl>
import asyncio
import json
import logging
from typing import List
from typing import Any, Dict, List, Optional
import aiosqlite
import dataset
from devranta.api import Comment, Rant, UserProfile
class DatabaseManager:
def __init__(self, db_path: str):
def __init__(self, db_path: str, batch_size: int = 100, flush_interval: float = 5.0):
self.db_path = db_path
self._conn: aiosqlite.Connection | None = None
self.batch_size = batch_size
self.flush_interval = flush_interval
self._db: Optional[dataset.Database] = None
self._rant_batch: List[Dict[str, Any]] = []
self._comment_batch: List[Dict[str, Any]] = []
self._user_batch: List[Dict[str, Any]] = []
self._flush_task: Optional[asyncio.Task] = None
self._lock = asyncio.Lock()
async def __aenter__(self):
logging.info(f"Connecting to database at {self.db_path}...")
self._conn = await aiosqlite.connect(self.db_path)
await self._conn.execute("PRAGMA journal_mode=WAL;")
await self._conn.execute("PRAGMA foreign_keys=ON;")
await self.create_tables()
self._db = dataset.connect(
f"sqlite:///{self.db_path}?check_same_thread=False",
engine_kwargs={"connect_args": {"check_same_thread": False}}
)
await self._create_indexes()
self._flush_task = asyncio.create_task(self._periodic_flush())
logging.info("Database connection successful.")
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
if self._conn:
await self._conn.close()
logging.info("Database connection closed.")
if self._flush_task:
self._flush_task.cancel()
try:
await self._flush_task
except asyncio.CancelledError:
pass
await self.flush_all()
if self._db:
self._db.close()
logging.info("Database connection closed.")
async def create_tables(self):
logging.info("Ensuring database tables exist...")
await self._conn.executescript(
"""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
score INTEGER,
about TEXT,
location TEXT,
created_time INTEGER,
skills TEXT,
github TEXT,
website TEXT
);
CREATE TABLE IF NOT EXISTS rants (
id INTEGER PRIMARY KEY,
user_id INTEGER,
text TEXT,
score INTEGER,
created_time INTEGER,
num_comments INTEGER
);
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY,
rant_id INTEGER,
user_id INTEGER,
body TEXT,
score INTEGER,
created_time INTEGER
);
"""
)
await self._conn.commit()
logging.info("Table schema verified.")
async def _create_indexes(self):
def _sync_create():
self._db.query("CREATE INDEX IF NOT EXISTS idx_rants_user_id ON rants(user_id)")
self._db.query("CREATE INDEX IF NOT EXISTS idx_rants_created_time ON rants(created_time)")
self._db.query("CREATE INDEX IF NOT EXISTS idx_comments_rant_id ON comments(rant_id)")
self._db.query("CREATE INDEX IF NOT EXISTS idx_comments_user_id ON comments(user_id)")
self._db.query("CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)")
await asyncio.to_thread(_sync_create)
logging.info("Database indexes verified.")
async def _periodic_flush(self):
while True:
await asyncio.sleep(self.flush_interval)
await self.flush_all()
async def flush_all(self):
async with self._lock:
await self._flush_rants()
await self._flush_comments()
await self._flush_users()
async def _flush_rants(self):
if not self._rant_batch:
return
batch = self._rant_batch.copy()
self._rant_batch.clear()
def _sync_insert():
table = self._db["rants"]
for rant in batch:
table.upsert(rant, ["id"])
await asyncio.to_thread(_sync_insert)
logging.debug(f"Flushed {len(batch)} rants to database")
async def _flush_comments(self):
if not self._comment_batch:
return
batch = self._comment_batch.copy()
self._comment_batch.clear()
def _sync_insert():
table = self._db["comments"]
for comment in batch:
table.upsert(comment, ["id"])
await asyncio.to_thread(_sync_insert)
logging.debug(f"Flushed {len(batch)} comments to database")
async def _flush_users(self):
if not self._user_batch:
return
batch = self._user_batch.copy()
self._user_batch.clear()
def _sync_insert():
table = self._db["users"]
for user in batch:
table.upsert(user, ["id"])
await asyncio.to_thread(_sync_insert)
logging.debug(f"Flushed {len(batch)} users to database")
def _transform_rant(self, rant: Rant) -> Dict[str, Any]:
attached_image = rant.get("attached_image")
image_url = None
if isinstance(attached_image, dict):
image_url = attached_image.get("url")
elif isinstance(attached_image, str):
image_url = attached_image
tags = rant.get("tags", [])
tags_str = json.dumps(tags) if tags else None
return {
"id": rant["id"],
"user_id": rant["user_id"],
"text": rant["text"],
"score": rant["score"],
"created_time": rant["created_time"],
"num_comments": rant["num_comments"],
"attached_image_url": image_url,
"tags": tags_str,
"link": rant.get("link"),
"vote_state": rant.get("vote_state"),
"user_username": rant.get("user_username"),
"user_score": rant.get("user_score"),
}
def _transform_comment(self, comment: Comment) -> Dict[str, Any]:
return {
"id": comment["id"],
"rant_id": comment["rant_id"],
"user_id": comment["user_id"],
"body": comment["body"],
"score": comment["score"],
"created_time": comment["created_time"],
"vote_state": comment.get("vote_state"),
"user_username": comment.get("user_username"),
"user_score": comment.get("user_score"),
}
def _transform_user(self, user: UserProfile, user_id: int) -> Dict[str, Any]:
return {
"id": user_id,
"username": user["username"],
"score": user["score"],
"about": user.get("about"),
"location": user.get("location"),
"created_time": user.get("created_time"),
"skills": user.get("skills"),
"github": user.get("github"),
"website": user.get("website"),
}
async def add_rant(self, rant: Rant):
await self._conn.execute(
"INSERT OR IGNORE INTO rants (id, user_id, text, score, created_time, num_comments) VALUES (?, ?, ?, ?, ?, ?)",
(
rant["id"],
rant["user_id"],
rant["text"],
rant["score"],
rant["created_time"],
rant["num_comments"],
),
)
await self._conn.commit()
async with self._lock:
self._rant_batch.append(self._transform_rant(rant))
if len(self._rant_batch) >= self.batch_size:
await self._flush_rants()
async def add_comment(self, comment: Comment):
await self._conn.execute(
"INSERT OR IGNORE INTO comments (id, rant_id, user_id, body, score, created_time) VALUES (?, ?, ?, ?, ?, ?)",
(
comment["id"],
comment["rant_id"],
comment["user_id"],
comment["body"],
comment["score"],
comment["created_time"],
),
)
await self._conn.commit()
async with self._lock:
self._comment_batch.append(self._transform_comment(comment))
if len(self._comment_batch) >= self.batch_size:
await self._flush_comments()
async def add_user(self, user: UserProfile, user_id: int):
await self._conn.execute(
"INSERT OR IGNORE INTO users (id, username, score, about, location, created_time, skills, github, website) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
user_id,
user["username"],
user["score"],
user["about"],
user["location"],
user["created_time"],
user["skills"],
user["github"],
user["website"],
),
)
await self._conn.commit()
async with self._lock:
self._user_batch.append(self._transform_user(user, user_id))
if len(self._user_batch) >= self.batch_size:
await self._flush_users()
async def rant_exists(self, rant_id: int) -> bool:
async with self._conn.execute(
"SELECT 1 FROM rants WHERE id = ? LIMIT 1", (rant_id,)
) as cursor:
return await cursor.fetchone() is not None
def _sync_check():
table = self._db["rants"]
return table.find_one(id=rant_id) is not None
return await asyncio.to_thread(_sync_check)
async def user_exists(self, user_id: int) -> bool:
async with self._conn.execute(
"SELECT 1 FROM users WHERE id = ? LIMIT 1", (user_id,)
) as cursor:
return await cursor.fetchone() is not None
def _sync_check():
table = self._db["users"]
return table.find_one(id=user_id) is not None
return await asyncio.to_thread(_sync_check)
async def get_random_user_ids(self, limit: int) -> List[int]:
logging.info(
f"Fetching up to {limit} random user IDs from database for seeding..."
)
query = "SELECT id FROM users ORDER BY RANDOM() LIMIT ?"
async with self._conn.execute(query, (limit,)) as cursor:
rows = await cursor.fetchall()
user_ids = [row[0] for row in rows]
logging.info(f"Found {len(user_ids)} user IDs to seed.")
return user_ids
logging.info(f"Fetching up to {limit} random user IDs from database for seeding...")
def _sync_fetch():
result = self._db.query(f"SELECT id FROM users ORDER BY RANDOM() LIMIT {limit}")
return [row["id"] for row in result]
user_ids = await asyncio.to_thread(_sync_fetch)
logging.info(f"Found {len(user_ids)} user IDs to seed.")
return user_ids
async def get_all_rant_ids(self) -> List[int]:
def _sync_fetch():
result = self._db.query("SELECT id FROM rants")
return [row["id"] for row in result]
return await asyncio.to_thread(_sync_fetch)
async def get_all_user_ids(self) -> List[int]:
def _sync_fetch():
result = self._db.query("SELECT id FROM users")
return [row["id"] for row in result]
return await asyncio.to_thread(_sync_fetch)
async def save_crawler_state(self, key: str, value: str):
def _sync_save():
table = self._db["crawler_state"]
table.upsert({"key": key, "value": value}, ["key"])
await asyncio.to_thread(_sync_save)
async def load_crawler_state(self, key: str) -> Optional[str]:
def _sync_load():
table = self._db["crawler_state"]
row = table.find_one(key=key)
return row["value"] if row else None
return await asyncio.to_thread(_sync_load)
+24 -22
View File
@@ -1,45 +1,47 @@
# main.py
# retoor <retoor@molodetz.nl>
import asyncio
import logging
import signal
from crawler import DevRantCrawler
from database import DatabaseManager
from devranta.api import Api
# --- Configuration ---
DB_FILE = "devrant.sqlite"
CONCURRENT_RANT_CONSUMERS = 10 # How many rants to process at once
CONCURRENT_USER_CONSUMERS = 5 # How many user profiles to fetch at once
CONCURRENT_RANT_CONSUMERS = 10
CONCURRENT_USER_CONSUMERS = 5
BATCH_SIZE = 100
FLUSH_INTERVAL = 5.0
async def main():
"""Initializes and runs the crawler."""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
api = Api()
async with DatabaseManager(DB_FILE) as db:
crawler = DevRantCrawler(
api=api,
db=db,
rant_consumers=CONCURRENT_RANT_CONSUMERS,
user_consumers=CONCURRENT_USER_CONSUMERS,
)
# Set up a signal handler for graceful shutdown on Ctrl+C
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(
sig, lambda s=sig: asyncio.create_task(crawler.shutdown())
async with Api() as api:
async with DatabaseManager(
DB_FILE,
batch_size=BATCH_SIZE,
flush_interval=FLUSH_INTERVAL,
) as db:
crawler = DevRantCrawler(
api=api,
db=db,
rant_consumers=CONCURRENT_RANT_CONSUMERS,
user_consumers=CONCURRENT_USER_CONSUMERS,
)
await crawler.run()
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(
sig, lambda s=sig: asyncio.create_task(crawler.shutdown())
)
await crawler.run()
if __name__ == "__main__":
+1 -1
View File
@@ -1 +1 @@
aiosqlite
dataset
+17
View File
@@ -0,0 +1,17 @@
# retoor <retoor@molodetz.nl>
.PHONY: all env install run clean
all: env install run
env:
python3 -m venv .venv
install:
. .venv/bin/activate && pip install -r requirements.txt
. .venv/bin/activate && pip install -e ../../.
run:
. .venv/bin/activate && python princess.py
clean:
rm -rf .venv
+71 -51
View File
@@ -1,42 +1,55 @@
# Princess Bot - Usage and Configuration Guide
# Princess Bot
Author: retoor <retoor@molodetz.nl>
An automated social media interaction bot for the devRant platform. Monitors a target user's posts and generates LLM-powered responses.
## Overview
Princess.py is an automated social media interaction bot designed to monitor and respond to specific user-generated content (rants and comments) on a platform. It fetches new posts made by a target user, generates witty or devastating responses using a language model, and keeps track of responded messages to avoid duplicates.
Princess Bot monitors rants and comments from a specified user on devRant, generates contextual responses using the Grok language model, and posts replies automatically. The bot maintains state to prevent duplicate responses.
The bot operates continuously, periodically checking for new content and replying accordingly.
## Architecture
---
The bot operates on a polling model with the following components:
## How It Works
| Component | Description |
|-----------|-------------|
| Api | devRant API client for authentication and content retrieval |
| GrokAPIClient | LLM integration for response generation |
| AsyncDataSet | Async SQLite wrapper for state persistence |
1. **Initialization**: The bot initializes with user credentials, target username, and API keys.
2. **Login**: It logs into the platform via the provided API.
3. **Content Monitoring**: It fetches recent rants and comments made by the target user.
4. **Response Generation**: For new content (not responded to before), it generates a response using a language model (GrokAPIClient).
5. **Response Posting**: It prints the content and the generated reply.
6. **Tracking**: It records responded messages in a local database to prevent duplicate responses.
7. **Loop**: It repeats this process every 60 seconds.
## Usage
---
### Quick Start
## Configuration
```bash
make
```
The script uses a `.env` file to manage sensitive credentials and configurable properties. Below are the supported environment variables:
This creates a virtual environment, installs dependencies, and starts the bot.
### Required Environment Variables
### Manual Setup
| Property | Description | Example |
|----------------------|----------------------------------------------------------|-------------------------------------------|
| `USERNAME` | Your platform username. | `my_username` |
| `PASSWORD` | Your platform password. | `my_password` |
| `TARGET` | The username of the user to monitor. | `target_user` |
| `LLM_KEY` | API key for the language model (Grok API). | `your-grok-api-key` |
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -e ../../.
pip install -r requirements.txt
python princess.py
```
## Setup Instructions
### Configuration
1. **Create a `.env` file** in the same directory as `princess.py`.
2. **Add the required variables** with your credentials and target info:
Create a `.env` file with the following variables:
| Variable | Description |
|----------|-------------|
| `USERNAME` | devRant account username |
| `PASSWORD` | devRant account password |
| `TARGET` | Username of the user to monitor |
| `LLM_KEY` | API key for Grok language model |
Example:
```env
USERNAME=your_username
@@ -45,35 +58,42 @@ TARGET=target_username
LLM_KEY=your_grok_api_key
```
3. **Install dependencies** (if not already installed):
### Stopping
Press `Ctrl+C` to terminate the bot.
## Data Storage
Uses SQLite via AsyncDataSet with:
- Responded message tracking for deduplication
- Persistent state across restarts
## Requirements
- Python 3.10+
- python-dotenv
- aiosqlite
- aiohttp (via parent devranta package)
## Cleanup
```bash
pip install python-dotenv
make clean
```
4. **Run the script**:
Removes the virtual environment. Database file (`princess.db`) is preserved.
## File Structure
```bash
python princess.py
```
---
## Notes
- The bot stores responded messages in a local SQLite database (`princess.db`) to avoid duplicate responses.
- It runs indefinitely, checking for new content every 60 seconds.
- Make sure your API keys and credentials are kept secure and not shared publicly.
---
## Summary
Princess.py is a social media response bot that:
- Monitors a specific user's posts.
- Generates witty responses using a language model.
- Keeps track of responses to prevent duplicates.
- Runs continuously with minimal setup.
princess/
├── princess.py # Main bot implementation
├── ads.py # AsyncDataSet database wrapper
├── grk.py # Grok API client
├── requirements.txt # Dependencies
├── Makefile # Build automation
├── .env # Configuration (create manually)
├── .venv/ # Virtual environment (created on first run)
└── princess.db # SQLite database (created on first run)
```
+3
View File
@@ -0,0 +1,3 @@
# retoor <retoor@molodetz.nl>
python-dotenv
aiosqlite
+32
View File
@@ -0,0 +1,32 @@
CC = gcc
CFLAGS = -Wall -Wextra -Werror -pedantic -std=c11 -g -O2
LDFLAGS = -lcurl -ljson-c
TARGET = devranta
SOURCES = devranta.c
OBJECTS = $(SOURCES:.c=.o)
.PHONY: all clean test
all: $(TARGET)
$(TARGET): $(OBJECTS)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
clean:
rm -f $(OBJECTS) $(TARGET)
test: $(TARGET)
./$(TARGET)
debug: CFLAGS += -DDEBUG -g3 -fsanitize=address,undefined
debug: $(TARGET)
install: $(TARGET)
mkdir -p /usr/local/lib
mkdir -p /usr/local/include
cp $(TARGET) /usr/local/lib/
# You might want to copy header file here if you create one
+152 -117
View File
@@ -1,5 +1,7 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import ssl
from enum import Enum
from typing import Any, Dict, List, Literal, Optional, TypedDict, Union
@@ -119,7 +121,40 @@ class Api:
self.user_id: Optional[int] = None
self.token_id: Optional[int] = None
self.token_key: Optional[str] = None
self.session: Optional[aiohttp.ClientSession] = None
self._session: Optional[aiohttp.ClientSession] = None
self._owns_session: bool = False
async def __aenter__(self):
"""Async context manager entry - creates shared HTTP session."""
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
connector = aiohttp.TCPConnector(ssl=ssl_context)
self._session = aiohttp.ClientSession(connector=connector)
self._owns_session = True
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit - closes shared HTTP session."""
await self.close()
async def _get_session(self) -> aiohttp.ClientSession:
"""Returns or creates a shared HTTP session for connection reuse."""
if self._session is None:
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
connector = aiohttp.TCPConnector(ssl=ssl_context)
self._session = aiohttp.ClientSession(connector=connector)
self._owns_session = True
return self._session
async def close(self):
"""Closes the HTTP session if owned by this instance."""
if self._session and self._owns_session:
await self._session.close()
self._session = None
self._owns_session = False
def patch_auth(
self, request_dict: Optional[Dict[str, Any]] = None
@@ -177,25 +212,25 @@ class Api:
"""
if not self.username or not self.password:
raise Exception("No authentication details supplied.")
async with aiohttp.ClientSession() as session:
response = await session.post(
url=self.patch_url("users/auth-token"),
data={
"username": self.username,
"password": self.password,
"app": self.app_id,
},
)
obj: LoginResponse = await response.json()
if not obj.get("success"):
return False
self.auth = obj.get("auth_token")
if not self.auth:
return False
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)
session = await self._get_session()
response = await session.post(
url=self.patch_url("users/auth-token"),
data={
"username": self.username,
"password": self.password,
"app": self.app_id,
},
)
obj: LoginResponse = await response.json()
if not obj.get("success"):
return False
self.auth = obj.get("auth_token")
if not self.auth:
return False
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)
async def ensure_login(self) -> bool:
"""Ensures the user is logged in before making a request."""
@@ -224,20 +259,20 @@ class Api:
}
```
"""
async with aiohttp.ClientSession() as session:
response = await session.post(
url=self.patch_url(f"users"),
data=self.patch_auth(
{
"email": email,
"username": username,
"password": password,
"plat": 3,
}
),
)
obj = await response.json()
return obj.get("success", False)
session = await self._get_session()
response = await session.post(
url=self.patch_url("users"),
data=self.patch_auth(
{
"email": email,
"username": username,
"password": password,
"plat": 3,
}
),
)
obj = await response.json()
return obj.get("success", False)
async def get_comments_from_user(self, username: str) -> List[Comment]:
"""
@@ -270,13 +305,13 @@ class Api:
"""
if not await self.ensure_login():
return False
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)
session = await self._get_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)
async def get_comment(self, id_: int) -> Optional[Comment]:
"""
@@ -288,12 +323,12 @@ class Api:
Returns:
Optional[Comment]: A dictionary representing the comment, or None if not found.
"""
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()
return obj.get("comment") if obj.get("success") else None
session = await self._get_session()
response = await session.get(
url=self.patch_url(f"comments/{id_}"), params=self.patch_auth()
)
obj = await response.json()
return obj.get("comment") if obj.get("success") else None
async def delete_comment(self, id_: int) -> bool:
"""
@@ -307,12 +342,12 @@ class Api:
"""
if not await self.ensure_login():
return False
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)
session = await self._get_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)
async def get_profile(self, id_: int) -> Optional[UserProfile]:
"""
@@ -324,12 +359,12 @@ class Api:
Returns:
Optional[UserProfile]: A dictionary with the user's profile data.
"""
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()
return obj.get("profile") if obj.get("success") else None
session = await self._get_session()
response = await session.get(
url=self.patch_url(f"users/{id_}"), params=self.patch_auth()
)
obj = await response.json()
return obj.get("profile") if obj.get("success") else None
async def search(self, term: str) -> List[Rant]:
"""
@@ -341,13 +376,13 @@ class Api:
Returns:
List[Rant]: A list of rant objects from the search results.
"""
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()
return obj.get("results", []) if obj.get("success") else []
session = await self._get_session()
response = await session.get(
url=self.patch_url("devrant/search"),
params=self.patch_auth({"term": term}),
)
obj = await response.json()
return obj.get("results", []) if obj.get("success") else []
async def get_rant(self, id: int) -> Dict[str, Any]:
"""
@@ -359,12 +394,12 @@ class Api:
Returns:
Dict[str, Any]: The full API response object.
"""
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()
session = await self._get_session()
response = await session.get(
self.patch_url(f"devrant/rants/{id}"),
params=self.patch_auth(),
)
return await response.json()
async def get_rants(
self, sort: str = "recent", limit: int = 20, skip: int = 0
@@ -380,13 +415,13 @@ class Api:
Returns:
List[Rant]: A list of rant objects.
"""
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()
return obj.get("rants", []) if obj.get("success") else []
session = await self._get_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()
return obj.get("rants", []) if obj.get("success") else []
async def get_user_id(self, username: str) -> Optional[int]:
"""
@@ -398,13 +433,13 @@ class Api:
Returns:
Optional[int]: The user's ID, or None if not found.
"""
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()
return obj.get("user_id") if obj.get("success") else None
session = await self._get_session()
response = await session.get(
url=self.patch_url("get-user-id"),
params=self.patch_auth({"username": username}),
)
obj = await response.json()
return obj.get("user_id") if obj.get("success") else None
async def mentions(self) -> List[Notification]:
"""
@@ -431,13 +466,13 @@ class Api:
"""
if not await self.ensure_login():
return False
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)
session = await self._get_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)
async def vote_rant(
self, rant_id: int, vote: Literal[-1, 0, 1], reason: Optional[VoteReason] = None
@@ -455,15 +490,15 @@ class Api:
"""
if not await self.ensure_login():
return False
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)
session = await self._get_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)
async def vote_comment(
self,
@@ -484,15 +519,15 @@ class Api:
"""
if not await self.ensure_login():
return False
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)
session = await self._get_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)
async def notifs(self) -> List[Notification]:
"""
@@ -503,9 +538,9 @@ class Api:
"""
if not await self.ensure_login():
return []
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", [])
session = await self._get_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", [])
BIN
View File
Binary file not shown.