chore: add .env and *.db to gitignore and reformat imports in crawler examples

This commit is contained in:
2025-08-12 22:06:44 +00:00
parent e2369265d7
commit a551b80bc2
11 changed files with 1860 additions and 244 deletions
+73 -39
View File
@@ -1,11 +1,16 @@
import asyncio
import logging
from typing import Set
from devranta.api import Api, Rant
from database import DatabaseManager
from devranta.api import Api, Rant
class DevRantCrawler:
def __init__(self, api: Api, db: DatabaseManager, rant_consumers: int, user_consumers: int):
def __init__(
self, api: Api, db: DatabaseManager, rant_consumers: int, user_consumers: int
):
self.api = api
self.db = db
self.rant_queue = asyncio.Queue(maxsize=1000000)
@@ -18,23 +23,29 @@ class DevRantCrawler:
self.seen_rant_ids: Set[int] = set()
self.seen_user_ids: Set[int] = set()
self.stats = {
"rants_processed": 0, "rants_added_to_db": 0,
"comments_added_to_db": 0, "users_processed": 0, "users_added_to_db": 0,
"api_errors": 0, "producer_loops": 0, "end_of_feed_hits": 0,
"rants_queued": 0, "users_queued": 0
"rants_processed": 0,
"rants_added_to_db": 0,
"comments_added_to_db": 0,
"users_processed": 0,
"users_added_to_db": 0,
"api_errors": 0,
"producer_loops": 0,
"end_of_feed_hits": 0,
"rants_queued": 0,
"users_queued": 0,
}
async def _queue_user_if_new(self, user_id: int):
if user_id in self.seen_user_ids:
return
self.seen_user_ids.add(user_id)
if not await self.db.user_exists(user_id):
await self.user_queue.put(user_id)
self.stats["users_queued"] += 1
async def _queue_rant_if_new(self, rant_obj: Rant):
rant_id = rant_obj['id']
rant_id = rant_obj["id"]
if rant_id in self.seen_rant_ids:
return
@@ -49,52 +60,64 @@ class DevRantCrawler:
logging.info("Starting initial seeder to re-ignite crawling process...")
user_ids = await self.db.get_random_user_ids(limit=2000)
if not user_ids:
logging.info("Seeder found no existing users. Crawler will start from scratch.")
logging.info(
"Seeder found no existing users. Crawler will start from scratch."
)
return
for user_id in user_ids:
if user_id not in self.seen_user_ids:
self.seen_user_ids.add(user_id)
await self.user_queue.put(user_id)
self.stats["users_queued"] += 1
logging.info(f"Seeder finished: Queued {len(user_ids)} users to kickstart exploration.")
logging.info(
f"Seeder finished: Queued {len(user_ids)} users to kickstart exploration."
)
async def _rant_producer(self):
logging.info("Rant producer started.")
skip = 0
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)
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.info(
f"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.")
logging.info(
"Producer: End of feed likely reached. Pausing for 15 minutes before reset."
)
await asyncio.sleep(900)
skip = 0
consecutive_empty_responses = 0
else:
await asyncio.sleep(10)
continue
consecutive_empty_responses = 0
new_rants_found = 0
for rant in rants:
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.info(
f"Producer: Processed {new_rants_found} rants from feed. Total queued: {self.stats['rants_queued']}."
)
skip += len(rants)
await asyncio.sleep(2)
except Exception as e:
logging.critical(f"Producer: Unhandled exception: {e}. Retrying in 60s.")
logging.critical(
f"Producer: Unhandled exception: {e}. Retrying in 60s."
)
self.stats["api_errors"] += 1
await asyncio.sleep(60)
@@ -103,23 +126,29 @@ class DevRantCrawler:
while not self.shutdown_event.is_set():
try:
rant_id = await self.rant_queue.get()
logging.info(f"Rant consumer #{worker_id}: Processing rant ID {rant_id}.")
logging.info(
f"Rant consumer #{worker_id}: Processing rant ID {rant_id}."
)
rant_details = await self.api.get_rant(rant_id)
if not rant_details or not rant_details.get("success"):
logging.warning(f"Rant consumer #{worker_id}: Failed to fetch details for rant {rant_id}.")
logging.warning(
f"Rant consumer #{worker_id}: Failed to fetch details for rant {rant_id}."
)
self.rant_queue.task_done()
continue
await self._queue_user_if_new(rant_details['rant']['user_id'])
await self._queue_user_if_new(rant_details["rant"]["user_id"])
comments = rant_details.get("comments", [])
for comment in comments:
await self.db.add_comment(comment)
self.stats["comments_added_to_db"] += 1
await self._queue_user_if_new(comment['user_id'])
logging.info(f"Rant consumer #{worker_id}: Finished processing rant {rant_id}, found {len(comments)} comments.")
await self._queue_user_if_new(comment["user_id"])
logging.info(
f"Rant consumer #{worker_id}: Finished processing rant {rant_id}, found {len(comments)} comments."
)
self.stats["rants_processed"] += 1
self.rant_queue.task_done()
@@ -132,17 +161,21 @@ class DevRantCrawler:
while not self.shutdown_event.is_set():
try:
user_id = await self.user_queue.get()
logging.info(f"User consumer #{worker_id}: Processing user ID {user_id}.")
logging.info(
f"User consumer #{worker_id}: Processing user ID {user_id}."
)
profile = await self.api.get_profile(user_id)
if not profile:
logging.warning(f"User consumer #{worker_id}: Could not fetch profile for user {user_id}.")
logging.warning(
f"User consumer #{worker_id}: Could not fetch profile for user {user_id}."
)
self.user_queue.task_done()
continue
await self.db.add_user(profile, user_id)
self.stats["users_added_to_db"] += 1
rants_found_on_profile = 0
content_sections = profile.get("content", {}).get("content", {})
for section_name in ["rants", "upvoted", "favorites"]:
@@ -150,13 +183,15 @@ class DevRantCrawler:
await self._queue_rant_if_new(rant_obj)
rants_found_on_profile += 1
logging.info(f"User consumer #{worker_id}: Finished user {user_id}, found and queued {rants_found_on_profile} associated rants.")
logging.info(
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 Exception as e:
logging.error(f"User consumer #{worker_id}: Unhandled exception: {e}")
self.user_queue.task_done()
async def _stats_reporter(self):
logging.info("Stats reporter started.")
while not self.shutdown_event.is_set():
@@ -172,7 +207,7 @@ class DevRantCrawler:
async def run(self):
logging.info("Exhaustive crawler starting...")
await self._initial_seed()
logging.info("Starting main producer and consumer tasks...")
tasks = []
try:
@@ -181,7 +216,7 @@ class DevRantCrawler:
for i in range(self.num_rant_consumers):
tasks.append(asyncio.create_task(self._rant_consumer(i + 1)))
for i in range(self.num_user_consumers):
tasks.append(asyncio.create_task(self._user_consumer(i + 1)))
@@ -190,7 +225,7 @@ class DevRantCrawler:
logging.info("Crawler run cancelled.")
finally:
await self.shutdown()
async def shutdown(self):
if self.shutdown_event.is_set():
return
@@ -207,8 +242,7 @@ class DevRantCrawler:
tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
logging.info("All tasks cancelled.")
logging.info(f"--- FINAL STATS ---\n{self.stats}")
+45 -11
View File
@@ -1,7 +1,10 @@
import logging
import aiosqlite
from typing import List
from devranta.api import Rant, Comment, UserProfile
import aiosqlite
from devranta.api import Comment, Rant, UserProfile
class DatabaseManager:
def __init__(self, db_path: str):
@@ -24,7 +27,8 @@ class DatabaseManager:
async def create_tables(self):
logging.info("Ensuring database tables exist...")
await self._conn.executescript("""
await self._conn.executescript(
"""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
@@ -52,45 +56,75 @@ class DatabaseManager:
score INTEGER,
created_time INTEGER
);
""")
"""
)
await self._conn.commit()
logging.info("Table schema verified.")
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'])
(
rant["id"],
rant["user_id"],
rant["text"],
rant["score"],
rant["created_time"],
rant["num_comments"],
),
)
await self._conn.commit()
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'])
(
comment["id"],
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):
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'])
(
user_id,
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 with self._conn.execute("SELECT 1 FROM rants WHERE id = ? LIMIT 1", (rant_id,)) as cursor:
async with self._conn.execute(
"SELECT 1 FROM rants WHERE id = ? LIMIT 1", (rant_id,)
) as cursor:
return await cursor.fetchone() is not None
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:
async with self._conn.execute(
"SELECT 1 FROM users WHERE id = ? LIMIT 1", (user_id,)
) as cursor:
return await cursor.fetchone() is not None
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...")
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
+11 -8
View File
@@ -3,14 +3,16 @@ import asyncio
import logging
import signal
from devranta.api import Api
from database import DatabaseManager
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_USER_CONSUMERS = 5 # How many user profiles to fetch at once
async def main():
"""Initializes and runs the crawler."""
@@ -21,13 +23,13 @@ async def main():
)
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
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
@@ -39,6 +41,7 @@ async def main():
await crawler.run()
if __name__ == "__main__":
try:
asyncio.run(main())