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
+37 -8
View File
@@ -1,11 +1,40 @@
.venv # retoor <retoor@molodetz.nl>
.history
__pycache__ # Python
__pycache__/
*.pyc *.pyc
*.pyo
*.egg-info/
dist/
build/
.eggs/
# Virtual environments
.venv/
venv/
# Environment
.env .env
# IDE
.history/
.idea/
.vscode/
*.swp
# C build artifacts
*.o
*.so
*.a
*.c
*.h
# Databases
*.db *.db
examples/crawler/devrant.sqlite-shm *.sqlite
examples/crawler/devrant.sqlite-wal *.sqlite-shm
examples/crawler/devrant.sqlite *.sqlite-wal
examples/crawler/.venv
examples/crawler/__pycache__ # OS
.DS_Store
Thumbs.db
+27 -10
View File
@@ -1,28 +1,45 @@
# devRanta # 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 ## Running
``` ```
make run make run
``` ```
## Testing ## 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 make test
``` ```
## How to use
Implementation: ## Usage
```
```python
from devranta.api import Api from devranta.api import Api
api = Api(username="optional!", password="optional!")
api = Api(username="optional", password="optional")
async def list_rants(): async def list_rants():
async for rant in api.get_rants(): async for rant in api.get_rants():
print(rant["user_username"], ":", rant["text"]) 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 # devRant API Documentation
For people wanting to build their own client.
TODO: document responses. Reference for building custom clients.
## Base URL ## Base URL
`https://devrant.com/api` `https://devrant.com/api`
## Authentication ## Authentication
+1 -1
View File
@@ -1,10 +1,10 @@
# retoor <retoor@molodetz.nl>
.PHONY: all env install run clean .PHONY: all env install run clean
all: env install run all: env install run
env: env:
python3 -m venv .venv python3 -m venv .venv
.
install: install:
. .venv/bin/activate && pip install -r requirements.txt . .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. ## SSL Note
2. Make sure you have Python 3 installed.
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 ## Usage
1. Open a terminal in the project directory. ### Quick Start
2. Run `make` to set up the environment, install dependencies, and start the crawler:
```bash ```bash
make 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 ## Cleanup
To remove the virtual environment, run:
```bash ```bash
make clean 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. ## Requirements
- Ensure that the parent package is correctly set up in the directory structure.
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 asyncio
import json
import logging import logging
import time
from collections import OrderedDict
from typing import Set from typing import Set
from database import DatabaseManager from database import DatabaseManager
from devranta.api import Api, Rant 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: class DevRantCrawler:
def __init__( def __init__(
self, api: Api, db: DatabaseManager, rant_consumers: int, user_consumers: int 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_rant_consumers = rant_consumers
self.num_user_consumers = user_consumers self.num_user_consumers = user_consumers
self.seen_rant_ids: Set[int] = set() self.seen_rant_ids = BoundedSeenSet(maxsize=100000)
self.seen_user_ids: Set[int] = set() 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 = { self.stats = {
"rants_processed": 0, "rants_processed": 0,
"rants_added_to_db": 0, "rants_added_to_db": 0,
@@ -74,29 +118,58 @@ class DevRantCrawler:
f"Seeder finished: Queued {len(user_ids)} users to kickstart exploration." 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): async def _rant_producer(self):
logging.info("Rant producer started.") logging.info("Recent rant producer started.")
skip = 0
consecutive_empty_responses = 0 consecutive_empty_responses = 0
while not self.shutdown_event.is_set(): while not self.shutdown_event.is_set():
try: try:
logging.info(f"Producer: Fetching rants with 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=skip) rants = await self.api.get_rants(sort="recent", limit=50, skip=self._recent_skip)
self.stats["producer_loops"] += 1 self.stats["producer_loops"] += 1
if not rants: if not rants:
consecutive_empty_responses += 1 consecutive_empty_responses += 1
logging.info( logging.debug(
f"Producer: Feed returned empty. Consecutive empty hits: {consecutive_empty_responses}." f"Recent producer: Feed returned empty. Consecutive empty hits: {consecutive_empty_responses}."
) )
if consecutive_empty_responses >= 5: if consecutive_empty_responses >= 5:
self.stats["end_of_feed_hits"] += 1 self.stats["end_of_feed_hits"] += 1
logging.info( 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) await asyncio.sleep(900)
skip = 0 self._recent_skip = 0
consecutive_empty_responses = 0 consecutive_empty_responses = 0
else: else:
await asyncio.sleep(10) await asyncio.sleep(10)
@@ -108,25 +181,98 @@ class DevRantCrawler:
await self._queue_rant_if_new(rant) await self._queue_rant_if_new(rant)
new_rants_found += 1 new_rants_found += 1
logging.info( logging.debug(
f"Producer: Processed {new_rants_found} rants from feed. Total queued: {self.stats['rants_queued']}." f"Recent producer: Processed {new_rants_found} rants from feed."
) )
skip += len(rants) self._recent_skip += len(rants)
await asyncio.sleep(2) await asyncio.sleep(2)
except Exception as e: except Exception as e:
logging.critical( logging.error(
f"Producer: Unhandled exception: {e}. Retrying in 60s." f"Recent producer: Unhandled exception: {e}. Retrying in 60s."
) )
self.stats["api_errors"] += 1 self.stats["api_errors"] += 1
await asyncio.sleep(60) 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): async def _rant_consumer(self, worker_id: int):
logging.info(f"Rant consumer #{worker_id} started.") logging.info(f"Rant consumer #{worker_id} started.")
while not self.shutdown_event.is_set(): while not self.shutdown_event.is_set():
try: try:
rant_id = await self.rant_queue.get() rant_id = await asyncio.wait_for(self.rant_queue.get(), timeout=5.0)
logging.info( logging.debug(
f"Rant consumer #{worker_id}: Processing rant ID {rant_id}." f"Rant consumer #{worker_id}: Processing rant ID {rant_id}."
) )
@@ -146,22 +292,27 @@ class DevRantCrawler:
self.stats["comments_added_to_db"] += 1 self.stats["comments_added_to_db"] += 1
await self._queue_user_if_new(comment["user_id"]) 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." f"Rant consumer #{worker_id}: Finished processing rant {rant_id}, found {len(comments)} comments."
) )
self.stats["rants_processed"] += 1 self.stats["rants_processed"] += 1
self.rant_queue.task_done() self.rant_queue.task_done()
except asyncio.TimeoutError:
continue
except Exception as e: except Exception as e:
logging.error(f"Rant consumer #{worker_id}: Unhandled exception: {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): async def _user_consumer(self, worker_id: int):
logging.info(f"User consumer #{worker_id} started.") logging.info(f"User consumer #{worker_id} started.")
while not self.shutdown_event.is_set(): while not self.shutdown_event.is_set():
try: try:
user_id = await self.user_queue.get() user_id = await asyncio.wait_for(self.user_queue.get(), timeout=5.0)
logging.info( logging.debug(
f"User consumer #{worker_id}: Processing user ID {user_id}." f"User consumer #{worker_id}: Processing user ID {user_id}."
) )
@@ -183,14 +334,20 @@ class DevRantCrawler:
await self._queue_rant_if_new(rant_obj) await self._queue_rant_if_new(rant_obj)
rants_found_on_profile += 1 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." f"User consumer #{worker_id}: Finished user {user_id}, found and queued {rants_found_on_profile} associated rants."
) )
self.stats["users_processed"] += 1 self.stats["users_processed"] += 1
self.user_queue.task_done() self.user_queue.task_done()
except asyncio.TimeoutError:
continue
except Exception as e: except Exception as e:
logging.error(f"User consumer #{worker_id}: Unhandled exception: {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): async def _stats_reporter(self):
logging.info("Stats reporter started.") logging.info("Stats reporter started.")
@@ -206,13 +363,18 @@ class DevRantCrawler:
async def run(self): async def run(self):
logging.info("Exhaustive crawler starting...") logging.info("Exhaustive crawler starting...")
await self._load_state()
await self._initial_seed() await self._initial_seed()
logging.info("Starting main producer and consumer tasks...") logging.info("Starting main producer and consumer tasks...")
tasks = [] tasks = []
try: try:
tasks.append(asyncio.create_task(self._rant_producer())) 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._stats_reporter()))
tasks.append(asyncio.create_task(self._state_saver()))
for i in range(self.num_rant_consumers): for i in range(self.num_rant_consumers):
tasks.append(asyncio.create_task(self._rant_consumer(i + 1))) 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.") logging.info("Shutting down... sending signal to all tasks.")
self.shutdown_event.set() self.shutdown_event.set()
await self._save_state()
logging.info("Waiting for queues to empty... Press Ctrl+C again to force exit.") logging.info("Waiting for queues to empty... Press Ctrl+C again to force exit.")
try: try:
await asyncio.wait_for(self.rant_queue.join(), timeout=30) 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 import logging
from typing import List from typing import Any, Dict, List, Optional
import aiosqlite import dataset
from devranta.api import Comment, Rant, UserProfile from devranta.api import Comment, Rant, UserProfile
class DatabaseManager: 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.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): async def __aenter__(self):
logging.info(f"Connecting to database at {self.db_path}...") logging.info(f"Connecting to database at {self.db_path}...")
self._conn = await aiosqlite.connect(self.db_path) self._db = dataset.connect(
await self._conn.execute("PRAGMA journal_mode=WAL;") f"sqlite:///{self.db_path}?check_same_thread=False",
await self._conn.execute("PRAGMA foreign_keys=ON;") engine_kwargs={"connect_args": {"check_same_thread": False}}
await self.create_tables() )
await self._create_indexes()
self._flush_task = asyncio.create_task(self._periodic_flush())
logging.info("Database connection successful.") logging.info("Database connection successful.")
return self return self
async def __aexit__(self, exc_type, exc_val, exc_tb): async def __aexit__(self, exc_type, exc_val, exc_tb):
if self._conn: if self._flush_task:
await self._conn.close() self._flush_task.cancel()
logging.info("Database connection closed.") 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): async def _create_indexes(self):
logging.info("Ensuring database tables exist...") def _sync_create():
await self._conn.executescript( 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)")
CREATE TABLE IF NOT EXISTS users ( self._db.query("CREATE INDEX IF NOT EXISTS idx_comments_rant_id ON comments(rant_id)")
id INTEGER PRIMARY KEY, self._db.query("CREATE INDEX IF NOT EXISTS idx_comments_user_id ON comments(user_id)")
username TEXT NOT NULL UNIQUE, self._db.query("CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)")
score INTEGER, await asyncio.to_thread(_sync_create)
about TEXT, logging.info("Database indexes verified.")
location TEXT,
created_time INTEGER, async def _periodic_flush(self):
skills TEXT, while True:
github TEXT, await asyncio.sleep(self.flush_interval)
website TEXT await self.flush_all()
);
CREATE TABLE IF NOT EXISTS rants ( async def flush_all(self):
id INTEGER PRIMARY KEY, async with self._lock:
user_id INTEGER, await self._flush_rants()
text TEXT, await self._flush_comments()
score INTEGER, await self._flush_users()
created_time INTEGER,
num_comments INTEGER async def _flush_rants(self):
); if not self._rant_batch:
CREATE TABLE IF NOT EXISTS comments ( return
id INTEGER PRIMARY KEY, batch = self._rant_batch.copy()
rant_id INTEGER, self._rant_batch.clear()
user_id INTEGER,
body TEXT, def _sync_insert():
score INTEGER, table = self._db["rants"]
created_time INTEGER for rant in batch:
); table.upsert(rant, ["id"])
"""
) await asyncio.to_thread(_sync_insert)
await self._conn.commit() logging.debug(f"Flushed {len(batch)} rants to database")
logging.info("Table schema verified.")
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): async def add_rant(self, rant: Rant):
await self._conn.execute( async with self._lock:
"INSERT OR IGNORE INTO rants (id, user_id, text, score, created_time, num_comments) VALUES (?, ?, ?, ?, ?, ?)", self._rant_batch.append(self._transform_rant(rant))
( if len(self._rant_batch) >= self.batch_size:
rant["id"], await self._flush_rants()
rant["user_id"],
rant["text"],
rant["score"],
rant["created_time"],
rant["num_comments"],
),
)
await self._conn.commit()
async def add_comment(self, comment: Comment): async def add_comment(self, comment: Comment):
await self._conn.execute( async with self._lock:
"INSERT OR IGNORE INTO comments (id, rant_id, user_id, body, score, created_time) VALUES (?, ?, ?, ?, ?, ?)", self._comment_batch.append(self._transform_comment(comment))
( if len(self._comment_batch) >= self.batch_size:
comment["id"], await self._flush_comments()
comment["rant_id"],
comment["user_id"],
comment["body"],
comment["score"],
comment["created_time"],
),
)
await self._conn.commit()
async def add_user(self, user: UserProfile, user_id: int): async def add_user(self, user: UserProfile, user_id: int):
await self._conn.execute( async with self._lock:
"INSERT OR IGNORE INTO users (id, username, score, about, location, created_time, skills, github, website) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", self._user_batch.append(self._transform_user(user, user_id))
( if len(self._user_batch) >= self.batch_size:
user_id, await self._flush_users()
user["username"],
user["score"],
user["about"],
user["location"],
user["created_time"],
user["skills"],
user["github"],
user["website"],
),
)
await self._conn.commit()
async def rant_exists(self, rant_id: int) -> bool: async def rant_exists(self, rant_id: int) -> bool:
async with self._conn.execute( def _sync_check():
"SELECT 1 FROM rants WHERE id = ? LIMIT 1", (rant_id,) table = self._db["rants"]
) as cursor: return table.find_one(id=rant_id) is not None
return await cursor.fetchone() is not None return await asyncio.to_thread(_sync_check)
async def user_exists(self, user_id: int) -> bool: async def user_exists(self, user_id: int) -> bool:
async with self._conn.execute( def _sync_check():
"SELECT 1 FROM users WHERE id = ? LIMIT 1", (user_id,) table = self._db["users"]
) as cursor: return table.find_one(id=user_id) is not None
return await cursor.fetchone() is not None return await asyncio.to_thread(_sync_check)
async def get_random_user_ids(self, limit: int) -> List[int]: async def get_random_user_ids(self, limit: int) -> List[int]:
logging.info( logging.info(f"Fetching up to {limit} random user IDs from database for seeding...")
f"Fetching up to {limit} random user IDs from database for seeding..."
) def _sync_fetch():
query = "SELECT id FROM users ORDER BY RANDOM() LIMIT ?" result = self._db.query(f"SELECT id FROM users ORDER BY RANDOM() LIMIT {limit}")
async with self._conn.execute(query, (limit,)) as cursor: return [row["id"] for row in result]
rows = await cursor.fetchall()
user_ids = [row[0] for row in rows] user_ids = await asyncio.to_thread(_sync_fetch)
logging.info(f"Found {len(user_ids)} user IDs to seed.") logging.info(f"Found {len(user_ids)} user IDs to seed.")
return user_ids 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 asyncio
import logging import logging
import signal import signal
from crawler import DevRantCrawler from crawler import DevRantCrawler
from database import DatabaseManager from database import DatabaseManager
from devranta.api import Api from devranta.api import Api
# --- Configuration ---
DB_FILE = "devrant.sqlite" DB_FILE = "devrant.sqlite"
CONCURRENT_RANT_CONSUMERS = 10 # How many rants to process at once CONCURRENT_RANT_CONSUMERS = 10
CONCURRENT_USER_CONSUMERS = 5 # How many user profiles to fetch at once CONCURRENT_USER_CONSUMERS = 5
BATCH_SIZE = 100
FLUSH_INTERVAL = 5.0
async def main(): async def main():
"""Initializes and runs the crawler."""
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format="%(asctime)s [%(levelname)s] - %(message)s", format="%(asctime)s [%(levelname)s] - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S", datefmt="%Y-%m-%d %H:%M:%S",
) )
api = Api() async with Api() as api:
async with DatabaseManager(
async with DatabaseManager(DB_FILE) as db: DB_FILE,
crawler = DevRantCrawler( batch_size=BATCH_SIZE,
api=api, flush_interval=FLUSH_INTERVAL,
db=db, ) as db:
rant_consumers=CONCURRENT_RANT_CONSUMERS, crawler = DevRantCrawler(
user_consumers=CONCURRENT_USER_CONSUMERS, api=api,
) db=db,
rant_consumers=CONCURRENT_RANT_CONSUMERS,
# Set up a signal handler for graceful shutdown on Ctrl+C user_consumers=CONCURRENT_USER_CONSUMERS,
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() 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__": 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 ## 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. ## Usage
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.
--- ### 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 | ```bash
|----------------------|----------------------------------------------------------|-------------------------------------------| python3 -m venv .venv
| `USERNAME` | Your platform username. | `my_username` | source .venv/bin/activate
| `PASSWORD` | Your platform password. | `my_password` | pip install -e ../../.
| `TARGET` | The username of the user to monitor. | `target_user` | pip install -r requirements.txt
| `LLM_KEY` | API key for the language model (Grok API). | `your-grok-api-key` | python princess.py
```
## Setup Instructions ### Configuration
1. **Create a `.env` file** in the same directory as `princess.py`. Create a `.env` file with the following variables:
2. **Add the required variables** with your credentials and target info:
| 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 ```env
USERNAME=your_username USERNAME=your_username
@@ -45,35 +58,42 @@ TARGET=target_username
LLM_KEY=your_grok_api_key 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 ```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
``` ```
princess/
--- ├── princess.py # Main bot implementation
├── ads.py # AsyncDataSet database wrapper
## Notes ├── grk.py # Grok API client
├── requirements.txt # Dependencies
- The bot stores responded messages in a local SQLite database (`princess.db`) to avoid duplicate responses. ├── Makefile # Build automation
- It runs indefinitely, checking for new content every 60 seconds. ├── .env # Configuration (create manually)
- Make sure your API keys and credentials are kept secure and not shared publicly. ├── .venv/ # Virtual environment (created on first run)
└── princess.db # SQLite database (created on first run)
--- ```
## 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.
+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 from __future__ import annotations
import ssl
from enum import Enum from enum import Enum
from typing import Any, Dict, List, Literal, Optional, TypedDict, Union from typing import Any, Dict, List, Literal, Optional, TypedDict, Union
@@ -119,7 +121,40 @@ class Api:
self.user_id: Optional[int] = None self.user_id: Optional[int] = None
self.token_id: Optional[int] = None self.token_id: Optional[int] = None
self.token_key: Optional[str] = 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( def patch_auth(
self, request_dict: Optional[Dict[str, Any]] = None self, request_dict: Optional[Dict[str, Any]] = None
@@ -177,25 +212,25 @@ class Api:
""" """
if not self.username or not self.password: if not self.username or not self.password:
raise Exception("No authentication details supplied.") raise Exception("No authentication details supplied.")
async with aiohttp.ClientSession() as session: session = await self._get_session()
response = await session.post( response = await session.post(
url=self.patch_url("users/auth-token"), url=self.patch_url("users/auth-token"),
data={ data={
"username": self.username, "username": self.username,
"password": self.password, "password": self.password,
"app": self.app_id, "app": self.app_id,
}, },
) )
obj: LoginResponse = await response.json() obj: LoginResponse = await response.json()
if not obj.get("success"): if not obj.get("success"):
return False return False
self.auth = obj.get("auth_token") self.auth = obj.get("auth_token")
if not self.auth: if not self.auth:
return False return False
self.user_id = self.auth.get("user_id") self.user_id = self.auth.get("user_id")
self.token_id = self.auth.get("id") self.token_id = self.auth.get("id")
self.token_key = self.auth.get("key") self.token_key = self.auth.get("key")
return bool(self.auth) return bool(self.auth)
async def ensure_login(self) -> bool: async def ensure_login(self) -> bool:
"""Ensures the user is logged in before making a request.""" """Ensures the user is logged in before making a request."""
@@ -224,20 +259,20 @@ class Api:
} }
``` ```
""" """
async with aiohttp.ClientSession() as session: session = await self._get_session()
response = await session.post( response = await session.post(
url=self.patch_url(f"users"), url=self.patch_url("users"),
data=self.patch_auth( data=self.patch_auth(
{ {
"email": email, "email": email,
"username": username, "username": username,
"password": password, "password": password,
"plat": 3, "plat": 3,
} }
), ),
) )
obj = await response.json() obj = await response.json()
return obj.get("success", False) return obj.get("success", False)
async def get_comments_from_user(self, username: str) -> List[Comment]: async def get_comments_from_user(self, username: str) -> List[Comment]:
""" """
@@ -270,13 +305,13 @@ class Api:
""" """
if not await self.ensure_login(): if not await self.ensure_login():
return False return False
async with aiohttp.ClientSession() as session: session = await self._get_session()
response = await session.post( response = await session.post(
url=self.patch_url(f"devrant/rants/{rant_id}/comments"), url=self.patch_url(f"devrant/rants/{rant_id}/comments"),
data=self.patch_auth({"comment": comment, "plat": 2}), data=self.patch_auth({"comment": comment, "plat": 2}),
) )
obj = await response.json() obj = await response.json()
return obj.get("success", False) return obj.get("success", False)
async def get_comment(self, id_: int) -> Optional[Comment]: async def get_comment(self, id_: int) -> Optional[Comment]:
""" """
@@ -288,12 +323,12 @@ class Api:
Returns: Returns:
Optional[Comment]: A dictionary representing the comment, or None if not found. Optional[Comment]: A dictionary representing the comment, or None if not found.
""" """
async with aiohttp.ClientSession() as session: session = await self._get_session()
response = await session.get( response = await session.get(
url=self.patch_url(f"comments/{id_}"), params=self.patch_auth() url=self.patch_url(f"comments/{id_}"), params=self.patch_auth()
) )
obj = await response.json() obj = await response.json()
return obj.get("comment") if obj.get("success") else None return obj.get("comment") if obj.get("success") else None
async def delete_comment(self, id_: int) -> bool: async def delete_comment(self, id_: int) -> bool:
""" """
@@ -307,12 +342,12 @@ class Api:
""" """
if not await self.ensure_login(): if not await self.ensure_login():
return False return False
async with aiohttp.ClientSession() as session: session = await self._get_session()
response = await session.delete( response = await session.delete(
url=self.patch_url(f"comments/{id_}"), params=self.patch_auth() url=self.patch_url(f"comments/{id_}"), params=self.patch_auth()
) )
obj = await response.json() obj = await response.json()
return obj.get("success", False) return obj.get("success", False)
async def get_profile(self, id_: int) -> Optional[UserProfile]: async def get_profile(self, id_: int) -> Optional[UserProfile]:
""" """
@@ -324,12 +359,12 @@ class Api:
Returns: Returns:
Optional[UserProfile]: A dictionary with the user's profile data. Optional[UserProfile]: A dictionary with the user's profile data.
""" """
async with aiohttp.ClientSession() as session: session = await self._get_session()
response = await session.get( response = await session.get(
url=self.patch_url(f"users/{id_}"), params=self.patch_auth() url=self.patch_url(f"users/{id_}"), params=self.patch_auth()
) )
obj = await response.json() obj = await response.json()
return obj.get("profile") if obj.get("success") else None return obj.get("profile") if obj.get("success") else None
async def search(self, term: str) -> List[Rant]: async def search(self, term: str) -> List[Rant]:
""" """
@@ -341,13 +376,13 @@ class Api:
Returns: Returns:
List[Rant]: A list of rant objects from the search results. List[Rant]: A list of rant objects from the search results.
""" """
async with aiohttp.ClientSession() as session: session = await self._get_session()
response = await session.get( response = await session.get(
url=self.patch_url("devrant/search"), url=self.patch_url("devrant/search"),
params=self.patch_auth({"term": term}), params=self.patch_auth({"term": term}),
) )
obj = await response.json() obj = await response.json()
return obj.get("results", []) if obj.get("success") else [] return obj.get("results", []) if obj.get("success") else []
async def get_rant(self, id: int) -> Dict[str, Any]: async def get_rant(self, id: int) -> Dict[str, Any]:
""" """
@@ -359,12 +394,12 @@ class Api:
Returns: Returns:
Dict[str, Any]: The full API response object. Dict[str, Any]: The full API response object.
""" """
async with aiohttp.ClientSession() as session: session = await self._get_session()
response = await session.get( response = await session.get(
self.patch_url(f"devrant/rants/{id}"), self.patch_url(f"devrant/rants/{id}"),
params=self.patch_auth(), params=self.patch_auth(),
) )
return await response.json() return await response.json()
async def get_rants( async def get_rants(
self, sort: str = "recent", limit: int = 20, skip: int = 0 self, sort: str = "recent", limit: int = 20, skip: int = 0
@@ -380,13 +415,13 @@ class Api:
Returns: Returns:
List[Rant]: A list of rant objects. List[Rant]: A list of rant objects.
""" """
async with aiohttp.ClientSession() as session: session = await self._get_session()
response = await session.get( response = await session.get(
url=self.patch_url("devrant/rants"), url=self.patch_url("devrant/rants"),
params=self.patch_auth({"sort": sort, "limit": limit, "skip": skip}), params=self.patch_auth({"sort": sort, "limit": limit, "skip": skip}),
) )
obj = await response.json() obj = await response.json()
return obj.get("rants", []) if obj.get("success") else [] return obj.get("rants", []) if obj.get("success") else []
async def get_user_id(self, username: str) -> Optional[int]: async def get_user_id(self, username: str) -> Optional[int]:
""" """
@@ -398,13 +433,13 @@ class Api:
Returns: Returns:
Optional[int]: The user's ID, or None if not found. Optional[int]: The user's ID, or None if not found.
""" """
async with aiohttp.ClientSession() as session: session = await self._get_session()
response = await session.get( response = await session.get(
url=self.patch_url("get-user-id"), url=self.patch_url("get-user-id"),
params=self.patch_auth({"username": username}), params=self.patch_auth({"username": username}),
) )
obj = await response.json() obj = await response.json()
return obj.get("user_id") if obj.get("success") else None return obj.get("user_id") if obj.get("success") else None
async def mentions(self) -> List[Notification]: async def mentions(self) -> List[Notification]:
""" """
@@ -431,13 +466,13 @@ class Api:
""" """
if not await self.ensure_login(): if not await self.ensure_login():
return False return False
async with aiohttp.ClientSession() as session: session = await self._get_session()
response = await session.post( response = await session.post(
url=self.patch_url(f"comments/{comment_id}"), url=self.patch_url(f"comments/{comment_id}"),
data=self.patch_auth({"comment": comment}), data=self.patch_auth({"comment": comment}),
) )
obj = await response.json() obj = await response.json()
return obj.get("success", False) return obj.get("success", False)
async def vote_rant( async def vote_rant(
self, rant_id: int, vote: Literal[-1, 0, 1], reason: Optional[VoteReason] = None 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(): if not await self.ensure_login():
return False return False
async with aiohttp.ClientSession() as session: session = await self._get_session()
response = await session.post( response = await session.post(
url=self.patch_url(f"devrant/rants/{rant_id}/vote"), url=self.patch_url(f"devrant/rants/{rant_id}/vote"),
data=self.patch_auth( data=self.patch_auth(
{"vote": vote, "reason": reason.value if reason else None} {"vote": vote, "reason": reason.value if reason else None}
), ),
) )
obj = await response.json() obj = await response.json()
return obj.get("success", False) return obj.get("success", False)
async def vote_comment( async def vote_comment(
self, self,
@@ -484,15 +519,15 @@ class Api:
""" """
if not await self.ensure_login(): if not await self.ensure_login():
return False return False
async with aiohttp.ClientSession() as session: session = await self._get_session()
response = await session.post( response = await session.post(
url=self.patch_url(f"comments/{comment_id}/vote"), url=self.patch_url(f"comments/{comment_id}/vote"),
data=self.patch_auth( data=self.patch_auth(
{"vote": vote, "reason": reason.value if reason else None} {"vote": vote, "reason": reason.value if reason else None}
), ),
) )
obj = await response.json() obj = await response.json()
return obj.get("success", False) return obj.get("success", False)
async def notifs(self) -> List[Notification]: async def notifs(self) -> List[Notification]:
""" """
@@ -503,9 +538,9 @@ class Api:
""" """
if not await self.ensure_login(): if not await self.ensure_login():
return [] return []
async with aiohttp.ClientSession() as session: session = await self._get_session()
response = await session.get( response = await session.get(
url=self.patch_url("users/me/notif-feed"), params=self.patch_auth() url=self.patch_url("users/me/notif-feed"), params=self.patch_auth()
) )
obj = await response.json() obj = await response.json()
return obj.get("data", {}).get("items", []) return obj.get("data", {}).get("items", [])
BIN
View File
Binary file not shown.