Last active
May 18, 2023 08:44
-
-
Save jackrusher/c971defaf78274de296d78e31941048b to your computer and use it in GitHub Desktop.
Super simple command line user/post recommender for BlueSky
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env bb | |
;; This script was written using: | |
;; https://github.com/babashka/babashka | |
;; ... go there for install instructions. | |
(ns reco | |
(:require [babashka.http-client :as http] | |
[cheshire.core :as json]) | |
(:import [java.net URLEncoder])) | |
(def config | |
(reduce (fn [m [k v]] | |
(assoc m (keyword (clojure.string/replace k #"--" "")) v)) | |
{} | |
(partition 2 *command-line-args*))) | |
(def auth-handle (or (:auth-handle config) (System/getenv "BSKY_AUTH_HANDLE"))) | |
(def auth-password (or (:auth-password config) (System/getenv "BSKY_AUTH_PASSWORD"))) | |
(when (or (nil? auth-handle) (nil? auth-password)) | |
(println " | |
This program needs a valid handle and password to contact the BlueSky API. This | |
does not need to be the same user you want to compute a recommendation for. You | |
can provide these credentials either on the command line: | |
bb reco.clj --auth-handle HANDLE --auth-password PASSWORD | |
... or by defining storing these credentials in the environment variables | |
BSKY_AUTH_HANDLE and BSKY_AUTH_PASSWORD. | |
") | |
(System/exit 1)) | |
(when (and (nil? (:neighborhood config)) (nil? (:recommend config))) | |
(println " | |
You must specify --neighborhood HANDLE and/or --recommend HANDLE | |
to get output from this program. | |
") | |
(System/exit 1)) | |
(defn handle->did [handle] | |
(-> (str "https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=" (URLEncoder/encode (clojure.string/trim handle))) | |
http/get | |
:body | |
(json/parse-string true) | |
:did)) | |
(defn create-session [did password] | |
(-> (http/post "https://bsky.social/xrpc/com.atproto.server.createSession" | |
{:headers {:content-type "application/json"} | |
:body (json/encode {:identifier did :password password})}) | |
:body | |
(json/parse-string true) | |
:accessJwt)) | |
(def session-key | |
(atom (create-session (handle->did auth-handle) auth-password))) | |
(defn authed-get [uri] | |
(-> (http/get uri {:headers {:authorization (str "Bearer " @session-key)}}) | |
:body | |
(json/parse-string true))) | |
;; get a user's feed | |
(defn posts-for-handle [handle] | |
(->> (authed-get (str "https://bsky.social/xrpc/app.bsky.feed.getAuthorFeed?actor=" handle)) | |
:feed | |
(filter #(= handle (-> % :post :author :handle))) | |
(map #(-> % :post :uri)))) | |
;; get likes for a post | |
(def post-likers | |
(memoize | |
(fn [uri] | |
(mapv #(-> % :actor :handle) | |
(:likes (authed-get (str "https://bsky.social/xrpc/app.bsky.feed.getLikes?uri=" uri))))))) | |
;; get likes for user by handle (automatic limit=50) | |
(def likes-for-handle | |
(memoize | |
(fn [handle] | |
(let [did (handle->did handle)] | |
(mapv #(-> % :value :subject :uri) | |
(:records (authed-get (str "https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=" did "&collection=app.bsky.feed.like")))))))) | |
;; get the record for a post | |
(def post-record | |
(memoize | |
(fn [uri] | |
(try | |
(-> (authed-get (str "https://bsky.social/xrpc/app.bsky.feed.getPostThread?uri=" uri)) | |
:thread | |
:post | |
(select-keys [:author :record])) | |
(catch Exception e nil))))) | |
(defn neighborhood [handle] | |
;; combine handle's posts and likes in one "neighborhood", and | |
;; callect users who like posts in the neighborhood, which gives us | |
;; something like a jaccard similarity-based cluser of users with | |
;; similar taste | |
(let [post-neighborhood (concat (posts-for-handle handle) (likes-for-handle handle))] | |
(->> (map post-likers post-neighborhood) | |
(reduce (fn [acc bundle] ; scaled frequency so popular posts don't sway the output | |
(if (empty? bundle) | |
acc | |
(let [score (float (/ 1 (count bundle)))] | |
(->> (map #(vector % score) bundle) | |
(into {}) | |
(merge-with + acc))))) | |
{}) | |
(sort-by second >) ; sort descending by scaled likes | |
(drop 1) ; remove self | |
(map first) ; just the user IDs | |
(take 10)))) | |
(defn post-recs [handle] | |
(let [;; combine handle's posts and likes | |
post-neighborhood (concat (posts-for-handle handle) (likes-for-handle handle)) | |
;; collect the users who have liking behavior in common with | |
;; handle, which gives us something like a jacquard | |
;; similarity-based cluser of users with similar taste | |
similar-users (->> (map post-likers post-neighborhood) | |
;; scaled so popular posts don't sway the output | |
(reduce (fn [acc bundle] | |
(if (empty? bundle) | |
acc | |
(let [score (float (/ 1 (count bundle)))] | |
(->> (map #(vector % score) bundle) | |
(into {}) | |
(merge-with + acc))))) | |
{}))] | |
(->> similar-users | |
(sort-by second >) ; sort by like freq | |
(drop 1) ; remove self | |
(map first) ; just the user IDs | |
(take 20) ; but only the top 20 for now | |
;; generate post recommendations based on what that cluster likes | |
(reduce (fn [acc user] | |
;; scaled so similar users' likes are more important | |
(->> (map #(vector % (similar-users user)) | |
(likes-for-handle user)) | |
(into {}) | |
(merge-with + acc))) | |
{}) | |
(remove (comp (set post-neighborhood) first)) ; remove the seed posts from this pool | |
(sort-by second >) ; sort by most likes from close users | |
(map first) ; just the post IDs | |
(map #(vector % (post-record %))) ; get the post records | |
(remove (comp nil? second)) ; remove failed post recs (deleted posts) | |
(take 10) ; only the top 10 | |
(mapv (fn [[id post]] ; format the strings... | |
(str (-> post :author :handle) | |
":" | |
(-> post :record :text) | |
"\n" | |
"https://bsky.app/profile/" | |
(-> post :author :handle) | |
"/post/" | |
(last (clojure.string/split id #"/")) | |
"\n\n")))))) | |
(when-let [handle (:neighborhood config)] | |
(println) | |
(doseq [user (neighborhood handle)] | |
(print (str "@" user " "))) | |
(println) | |
(println)) | |
(when-let [handle (:recommend config)] | |
(println) | |
(doseq [post (post-recs handle)] | |
(println post)) | |
(println)) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment