connectd/scoutd/lemmy.py

227 lines
6.5 KiB
Python
Raw Normal View History

"""
scoutd/lemmy.py - lemmy (fediverse reddit) discovery
lemmy is federated so we hit multiple instances.
great for finding lost builders in communities like:
- /c/programming, /c/technology, /c/linux
- /c/antiwork, /c/workreform (lost builders!)
- /c/selfhosted, /c/privacy, /c/opensource
no auth needed for public posts.
"""
import requests
import json
import time
import os
from datetime import datetime
from pathlib import Path
from .signals import analyze_text
from .lost import (
analyze_social_for_lost_signals,
analyze_text_for_lost_signals,
classify_user,
)
# popular lemmy instances
LEMMY_INSTANCES = [
'lemmy.ml',
'lemmy.world',
'programming.dev',
'lemm.ee',
'sh.itjust.works',
]
# communities to scout (format: community@instance or just community for local)
TARGET_COMMUNITIES = [
# builder communities
'programming',
'selfhosted',
'linux',
'opensource',
'privacy',
'technology',
'webdev',
'rust',
'python',
'golang',
# lost builder communities (people struggling, stuck, seeking)
'antiwork',
'workreform',
'careerguidance',
'cscareerquestions',
'learnprogramming',
'findapath',
]
CACHE_DIR = Path(__file__).parent.parent / 'db' / 'cache' / 'lemmy'
CACHE_DIR.mkdir(parents=True, exist_ok=True)
def get_community_posts(instance, community, limit=50, sort='New'):
"""get posts from a lemmy community"""
try:
url = f"https://{instance}/api/v3/post/list"
params = {
'community_name': community,
'sort': sort,
'limit': limit,
}
resp = requests.get(url, params=params, timeout=30)
if resp.status_code == 200:
return resp.json().get('posts', [])
return []
except Exception as e:
return []
def get_user_profile(instance, username):
"""get lemmy user profile"""
try:
url = f"https://{instance}/api/v3/user"
params = {'username': username}
resp = requests.get(url, params=params, timeout=30)
if resp.status_code == 200:
return resp.json()
return None
except Exception:
return None
def analyze_lemmy_user(instance, username, posts=None):
"""analyze a lemmy user for values alignment and lost signals"""
profile = get_user_profile(instance, username)
if not profile:
return None
person = profile.get('person_view', {}).get('person', {})
counts = profile.get('person_view', {}).get('counts', {})
bio = person.get('bio', '') or ''
display_name = person.get('display_name') or person.get('name', username)
# analyze bio
bio_score, bio_signals, bio_reasons = analyze_text(bio)
# analyze posts if provided
post_signals = []
post_text = []
if posts:
for post in posts[:10]:
post_data = post.get('post', {})
title = post_data.get('name', '')
body = post_data.get('body', '')
post_text.append(f"{title} {body}")
_, signals, _ = analyze_text(f"{title} {body}")
post_signals.extend(signals)
all_signals = list(set(bio_signals + post_signals))
total_score = bio_score + len(post_signals) * 5
# lost builder detection
profile_for_lost = {
'bio': bio,
'post_count': counts.get('post_count', 0),
'comment_count': counts.get('comment_count', 0),
}
posts_for_lost = [{'text': t} for t in post_text]
lost_signals, lost_weight = analyze_social_for_lost_signals(profile_for_lost, posts_for_lost)
lost_potential_score = lost_weight
user_type = classify_user(lost_potential_score, 50, total_score)
return {
'platform': 'lemmy',
'username': f"{username}@{instance}",
'url': f"https://{instance}/u/{username}",
'name': display_name,
'bio': bio,
'location': None,
'score': total_score,
'confidence': min(0.9, 0.3 + len(all_signals) * 0.1),
'signals': all_signals,
'negative_signals': [],
'reasons': bio_reasons,
'contact': {},
'extra': {
'instance': instance,
'post_count': counts.get('post_count', 0),
'comment_count': counts.get('comment_count', 0),
},
'lost_potential_score': lost_potential_score,
'lost_signals': lost_signals,
'user_type': user_type,
}
def scrape_lemmy(db, limit_per_community=30):
"""scrape lemmy instances for aligned builders"""
print("scouting lemmy...")
found = 0
lost_found = 0
seen_users = set()
for instance in LEMMY_INSTANCES:
print(f" instance: {instance}")
for community in TARGET_COMMUNITIES:
posts = get_community_posts(instance, community, limit=limit_per_community)
if not posts:
continue
print(f" /c/{community}: {len(posts)} posts")
# group posts by user
user_posts = {}
for post in posts:
creator = post.get('creator', {})
username = creator.get('name')
if not username:
continue
user_key = f"{username}@{instance}"
if user_key in seen_users:
continue
if user_key not in user_posts:
user_posts[user_key] = []
user_posts[user_key].append(post)
# analyze each user
for user_key, posts in user_posts.items():
username = user_key.split('@')[0]
if user_key in seen_users:
continue
seen_users.add(user_key)
result = analyze_lemmy_user(instance, username, posts)
if not result:
continue
if result['score'] >= 20 or result.get('lost_potential_score', 0) >= 30:
db.save_human(result)
found += 1
if result.get('user_type') in ['lost', 'both']:
lost_found += 1
print(f" {result['username']}: {result['score']:.0f} (lost: {result['lost_potential_score']:.0f})")
elif result['score'] >= 40:
print(f" {result['username']}: {result['score']:.0f}")
time.sleep(0.5) # rate limit
time.sleep(1) # between communities
time.sleep(2) # between instances
print(f"lemmy: found {found} humans ({lost_found} lost builders)")
return found