Skip to content

Instantly share code, notes, and snippets.

@jackrusher
Last active May 18, 2023 08:44
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jackrusher/c971defaf78274de296d78e31941048b to your computer and use it in GitHub Desktop.
Save jackrusher/c971defaf78274de296d78e31941048b to your computer and use it in GitHub Desktop.
Super simple command line user/post recommender for BlueSky
#!/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