├── .github └── FUNDING.yml ├── .gitignore ├── Procfile ├── README.md ├── fedi_feed ├── __init__.py ├── asgi.py ├── settings.py ├── urls.py └── wsgi.py ├── frontend ├── .gitignore ├── dist │ ├── bundle.js │ └── index.html ├── package-lock.json ├── package.json ├── src │ ├── components │ │ ├── Feed.tsx │ │ └── Status.tsx │ ├── features │ │ ├── coreServerFeature.ts │ │ ├── favsFeature.ts │ │ └── reblogsFeature.ts │ ├── feeds │ │ ├── homeFeed.ts │ │ └── topPostsFeed.ts │ ├── index.html │ ├── index.tsx │ ├── types.tsx │ └── utils │ │ ├── theAlgorithm.ts │ │ └── useOnScreen.tsx ├── tsconfig.json ├── webpack-stats.json └── webpack.config.js ├── manage.py ├── requirements.txt └── user ├── __init__.py ├── admin.py ├── apps.py ├── migrations ├── 0001_initial.py └── __init__.py ├── models.py ├── templates └── user │ └── index.html ├── tests.py ├── urls.py └── views.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [pkreissel] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | */__pycache__/ 3 | .env 4 | staticfiles/ -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: gunicorn fedi_feed.wsgi --timeout 60 --log-file - 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Update: 2 | As of May 16th I will archive this Repo to continue development here: https://github.com/pkreissel/foryoufeed 3 | The new project enables me to do everything in react without a backend server. It is hosted here: https://vercel.com/pkreissel/foryoufeed 4 | If you only need the algorithm without all the fuss for your own project you can use this package: https://github.com/pkreissel/fedialgo 5 | 6 | # fedifeed 7 | Display Mastodon Posts in a curated feed with an user-customisable algorithm 8 | 9 | # Usage 10 | Example is hosted here: 11 | https://fedifeed.herokuapp.com 12 | 13 | Be aware this is a very early alpha, so try at your own risk 14 | 15 | Steps: 16 | 1. Put your Mastodon Instance Url in the field in the format "https://example.social" 17 | 2. Login with Mastodon 18 | 3. Wait a few seconds for the feed to load (first time takes longer) 19 | 4. Change Feed Algorithm and enjoy 20 | 21 | 22 | # Development 23 | Project is based on Django and React Frameworks. See their docs for further info. 24 | To start the backend server you need 25 | 26 | ``` 27 | pip install -r requirements.txt 28 | ``` 29 | Then set some env vars: 30 | ``` 31 | FIELD_ENCRYPTION_KEY= // generate this with python manage.py generate_encryption_key 32 | DATABASE_URL=Postgresql Database URL 33 | SECRET_KEY=Some Secret 34 | HOSTED_URL=http://127.0.0.1:8000/ (for local dev) 35 | DEBUG=True 36 | ``` 37 | Run the server: 38 | ``` 39 | python manage.py makemigrations 40 | python manage.py migrate 41 | python manage.py runserver 42 | ``` 43 | Only the last command is required every time. 44 | 45 | To start the frontend dev server: 46 | ``` 47 | cd frontend 48 | npx webpack --config webpack.config.js --watch 49 | ``` 50 | 51 | # Todos: 52 | - [ ] Improve CI/CD 53 | - [ ] Add Tests 54 | - [ ] Add more Documentation 55 | - [x] Add storage for feed Settings 56 | - [x] Add most liked users to weights 57 | - [ ] Add option to choose which Instances to pull the top posts from 58 | - [ ] More description for weights 59 | - [x] Add Logout Button and invalidate token 60 | - [ ] Better UI, Support for Polls, Videos, Images, etc. 61 | - [ ] Working Links back into the traditional Mastodon Interface 62 | - [x] Retweet, Like etc. Buttons 63 | - [ ] Profile View to delete profile etc. 64 | - [ ] Feed should cache posts and only load new ones 65 | - [ ] Add more features for algorithm, e.g. include posts from suggested users, prioritise recent follows etc. 66 | - [ ] Add local machine learning in the browser to tweak the features automatically 67 | 68 | 69 | -------------------------------------------------------------------------------- /fedi_feed/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pkreissel/fedifeed/8a19c94452c1de9417e210c24ee7671fb3526586/fedi_feed/__init__.py -------------------------------------------------------------------------------- /fedi_feed/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for fedi_feed project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'fedi_feed.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /fedi_feed/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for fedi_feed project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.2.12. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.2/ref/settings/ 11 | """ 12 | 13 | import os 14 | import environ 15 | from pathlib import Path 16 | env = environ.Env( 17 | # set casting, default value 18 | DEBUG=(bool, False) 19 | ) 20 | 21 | STORAGES = { 22 | "staticfiles": { 23 | "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", 24 | }, 25 | } 26 | 27 | 28 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 29 | BASE_DIR = Path(__file__).resolve().parent.parent 30 | 31 | # Take environment variables from .env file 32 | environ.Env.read_env(os.path.join(BASE_DIR, '.env')) 33 | 34 | # Quick-start development settings - unsuitable for production 35 | # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ 36 | 37 | # SECURITY WARNING: keep the secret key used in production secret! 38 | SECRET_KEY = env("SECRET_KEY") 39 | 40 | HOSTED_URL = env("HOSTED_URL") 41 | 42 | # SECURITY WARNING: don't run with debug turned on in production! 43 | DEBUG = env("DEBUG") 44 | 45 | ALLOWED_HOSTS = ["fedifeed.herokuapp.com", "localhost", "127.0.0.1"] 46 | 47 | 48 | # Application definition 49 | 50 | INSTALLED_APPS = [ 51 | 'django.contrib.admin', 52 | 'django.contrib.auth', 53 | 'django.contrib.contenttypes', 54 | 'django.contrib.sessions', 55 | 'django.contrib.messages', 56 | 'django.contrib.staticfiles', 57 | 'encrypted_model_fields', 58 | "webpack_loader", 59 | "user" 60 | ] 61 | 62 | MIDDLEWARE = [ 63 | 'django.middleware.security.SecurityMiddleware', 64 | "whitenoise.middleware.WhiteNoiseMiddleware", 65 | 'django.contrib.sessions.middleware.SessionMiddleware', 66 | 'django.middleware.common.CommonMiddleware', 67 | 'django.middleware.csrf.CsrfViewMiddleware', 68 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 69 | 'django.contrib.messages.middleware.MessageMiddleware', 70 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 71 | ] 72 | 73 | ROOT_URLCONF = 'fedi_feed.urls' 74 | FIELD_ENCRYPTION_KEY = env("FIELD_ENCRYPTION_KEY") 75 | 76 | TEMPLATES = [ 77 | { 78 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 79 | 'DIRS': [os.path.join(BASE_DIR, "frontend/dist")], 80 | 'APP_DIRS': True, 81 | 'OPTIONS': { 82 | 'context_processors': [ 83 | 'django.template.context_processors.debug', 84 | 'django.template.context_processors.request', 85 | 'django.contrib.auth.context_processors.auth', 86 | 'django.contrib.messages.context_processors.messages', 87 | ], 88 | }, 89 | }, 90 | ] 91 | 92 | STATIC_URL = '/static/' 93 | STATICFILES_DIRS = [os.path.join(BASE_DIR, 'frontend/dist')] 94 | STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') 95 | 96 | WSGI_APPLICATION = 'fedi_feed.wsgi.application' 97 | 98 | 99 | # Database 100 | # https://docs.djangoproject.com/en/3.2/ref/settings/#databases 101 | 102 | DATABASES = { 103 | 'default': env.db(), 104 | } 105 | 106 | WEBPACK_LOADER = { 107 | 'DEFAULT': { 108 | 'CACHE': not DEBUG, 109 | 'STATS_FILE': os.path.join(BASE_DIR, 'frontend/webpack-stats.json'), 110 | 'POLL_INTERVAL': 0.1, 111 | 'IGNORE': [r'.+\.hot-update.js', r'.+\.map'], 112 | } 113 | } 114 | 115 | 116 | # Password validation 117 | # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators 118 | 119 | AUTH_PASSWORD_VALIDATORS = [ 120 | { 121 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 122 | }, 123 | { 124 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 125 | }, 126 | { 127 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 128 | }, 129 | { 130 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 131 | }, 132 | ] 133 | 134 | 135 | # Internationalization 136 | # https://docs.djangoproject.com/en/3.2/topics/i18n/ 137 | 138 | LANGUAGE_CODE = 'en-us' 139 | 140 | TIME_ZONE = 'UTC' 141 | 142 | USE_I18N = True 143 | 144 | USE_L10N = True 145 | 146 | USE_TZ = True 147 | 148 | 149 | # Static files (CSS, JavaScript, Images) 150 | # https://docs.djangoproject.com/en/3.2/howto/static-files/ 151 | 152 | STATIC_URL = '/static/' 153 | 154 | # Default primary key field type 155 | # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field 156 | 157 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 158 | -------------------------------------------------------------------------------- /fedi_feed/urls.py: -------------------------------------------------------------------------------- 1 | """fedi_feed URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import path, include 18 | 19 | urlpatterns = [ 20 | path('admin/', admin.site.urls), 21 | path('', include('user.urls')), 22 | ] 23 | -------------------------------------------------------------------------------- /fedi_feed/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for fedi_feed project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'fedi_feed.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /frontend/dist/index.html: -------------------------------------------------------------------------------- 1 | React Django App
-------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "webpack", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "bootstrap": "^5.2.3", 15 | "html-react-parser": "^3.0.14", 16 | "masto": "^5.10.0", 17 | "react": "^18.2.0", 18 | "react-bootstrap": "^2.7.2", 19 | "react-dom": "^18.2.0", 20 | "react-infinite-scroller": "^1.2.6", 21 | "react-persistent-state": "^1.1.8" 22 | }, 23 | "devDependencies": { 24 | "@types/jest": "^29.5.0", 25 | "@types/react": "^18.0.29", 26 | "@types/react-dom": "^18.0.11", 27 | "css-loader": "^6.7.3", 28 | "html-webpack-plugin": "^5.5.0", 29 | "iterator-helpers-polyfill": "^2.2.8", 30 | "jest": "^29.5.0", 31 | "mini-css-extract-plugin": "^2.7.5", 32 | "style-loader": "^3.3.2", 33 | "ts-jest": "^29.0.5", 34 | "ts-loader": "^9.4.2", 35 | "typescript": "^5.0.2", 36 | "webpack": "^5.76.3", 37 | "webpack-bundle-tracker": "^1.8.1", 38 | "webpack-cli": "^5.0.1", 39 | "webpack-dev-server": "^4.13.1" 40 | } 41 | } -------------------------------------------------------------------------------- /frontend/src/components/Feed.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useRef } from 'react'; 2 | import { useEffect, useState } from 'react'; 3 | import { login, SerializerNativeImpl } from 'masto'; 4 | 5 | import Container from 'react-bootstrap/Container'; 6 | import Stack from 'react-bootstrap/esm/Stack'; 7 | import Form from 'react-bootstrap/Form'; 8 | import Status from './Status'; 9 | import Accordion from 'react-bootstrap/esm/Accordion'; 10 | import Spinner from 'react-bootstrap/Spinner'; 11 | import Alert from 'react-bootstrap/Alert'; 12 | import { usePersistentState } from 'react-persistent-state' 13 | import { Button, Col, Navbar, Row } from 'react-bootstrap'; 14 | import { weightsType, StatusType } from '../types'; 15 | import sortFeed from '../utils/theAlgorithm'; 16 | import useOnScreen from '../utils/useOnScreen'; 17 | import reblogsFeature from '../features/reblogsFeature'; 18 | import favsFeature from '../features/favsFeature'; 19 | import coreServersFeature from '../features/coreServerFeature'; 20 | import topPostsFeed from '../feeds/topPostsFeed'; 21 | import homeFeed from '../feeds/homeFeed'; 22 | 23 | export default function Feed(props: { token: string, server: string }) { 24 | const [isLoading, setLoading] = useState(true); //loading state 25 | const [error, setError] = useState(""); //error message 26 | const [feed, setFeed] = useState([]); //feed to display 27 | const [records, setRecords] = useState(20); //how many records to show 28 | const [rawFeed, setRawFeed] = useState([]); //save raw feed for sorting without re-fetching 29 | const [seenFeed, setSeenFeed] = usePersistentState(new Array(), "seen"); 30 | const [api, setApi] = useState(null); //save api object for later use 31 | //Features: 32 | const [userReblogs, setReblogs] = useState([]); //save user reblogs for later use 33 | const [userFavs, setFavs] = useState([]); //save user favs for later use 34 | const [userCoreServers, setCoreServers] = useState([]); //save user core servers for later use 35 | //Weights 36 | const [userReblogWeight, setUserReblogWeight] = usePersistentState(2, "reblogW"); //weight posts by accounts the user reblogs 37 | const [userFavWeight, setUserFavWeight] = usePersistentState(1, "favW"); //weight posts by accounts the user favs 38 | const [topPostWeight, setTopPostWeight] = usePersistentState(2, "topW"); //weight for top posts 39 | const [frequencyWeight, setFrequencyWeight] = usePersistentState(3, "frequW"); //weight for frequency 40 | //Penalty 41 | const [activePenalty, setActivePenalty] = usePersistentState(0.8, "activeW") //penalty for active accounts 42 | const [timePenalty, setTimePenalty] = usePersistentState(1, "timeW") //penalty for time since post 43 | //User Setting 44 | const [autoAdjust, setAutoAdjust] = usePersistentState(true, "autoAdjust") //auto adjust weights 45 | 46 | const bottomRef = useRef(null); 47 | const isBottom = useOnScreen(bottomRef) 48 | const topRef = React.useRef(null); 49 | const seenFeedLength = useMemo(() => { 50 | console.log("seen feed length: " + seenFeed.length) 51 | return seenFeed.length 52 | }, []) 53 | 54 | //Contruct Feed on Page Load 55 | useEffect(() => { 56 | const token = props.token; 57 | login({ 58 | accessToken: token, 59 | url: props.server + '/api/v1/', 60 | }).then((masto) => { 61 | setApi(masto) 62 | constructFeed(masto) 63 | }).catch((err) => { 64 | setError(err); 65 | console.log(err) 66 | }) 67 | }, []); 68 | 69 | //Sort Feed on Manual Weight Change 70 | useEffect(() => { 71 | if (autoAdjust) return 72 | const results = sortFeed(rawFeed, userReblogs, userCoreServers, userFavs, seenFeed, userReblogWeight, userFavWeight, topPostWeight, frequencyWeight, activePenalty, timePenalty) 73 | setFeed(results); 74 | }, [userReblogWeight, topPostWeight, frequencyWeight, userFavWeight, timePenalty, activePenalty]) 75 | 76 | //Load More Posts on Scroll 77 | useEffect(() => { 78 | if (isBottom) { 79 | console.log("bottom") 80 | if (records < feed.length) { 81 | console.log("load more") 82 | setRecords(records + 20) 83 | } else { 84 | setRecords(feed.length) 85 | } 86 | } 87 | }, [isBottom]) 88 | 89 | async function constructFeed(masto: any) { 90 | //Fetche Features and Feeds, pass to Algorithm 91 | const featureFuncs = [reblogsFeature, coreServersFeature, favsFeature] 92 | const features = Promise.all(featureFuncs.map((func) => func())) 93 | const [reblogs, core_servers, favs] = await features 94 | setReblogs(reblogs); 95 | setCoreServers(core_servers); 96 | setFavs(favs); 97 | const feeds = Promise.all([ 98 | homeFeed(masto), 99 | topPostsFeed(core_servers), 100 | ]) 101 | let results = (await feeds).flat(1); 102 | setRawFeed(results); 103 | results = sortFeed(results, reblogs, core_servers, favs, seenFeed, userReblogWeight, userFavWeight, topPostWeight, frequencyWeight, activePenalty, timePenalty); 104 | console.log(results) 105 | setFeed(results); 106 | setLoading(false); 107 | } 108 | 109 | 110 | 111 | const resolve = async (status: StatusType): Promise => { 112 | //Resolve Links to other instances on homeserver 113 | const masto = api; 114 | if (status.uri.includes(props.server)) { 115 | return status; 116 | } else { 117 | const res = await masto.v2.search({ q: status.uri, resolve: true }) 118 | return res.statuses[0] 119 | } 120 | } 121 | 122 | const reblog = async (status: StatusType) => { 123 | //Reblog a post 124 | const masto = api; 125 | const status_ = await resolve(status); 126 | weightAdjust(status.weights) 127 | const id = status_.id; 128 | (async () => { 129 | const res = await masto.v1.statuses.reblog(id); 130 | console.log(res); 131 | })(); 132 | } 133 | 134 | const fav = async (status: StatusType) => { 135 | //Favourite a post 136 | console.log(status.weights) 137 | const masto = api; 138 | const status_ = await resolve(status); 139 | weightAdjust(status.weights) 140 | const id = status_.id; 141 | (async () => { 142 | const res = await masto.v1.statuses.favourite(id); 143 | console.log(res); 144 | })(); 145 | } 146 | 147 | const followUri = async (status: StatusType) => { 148 | //Follow a link to another instance on the homeserver 149 | const status_ = await resolve(status); 150 | weightAdjust(status.weights) 151 | console.log(status_) 152 | window.open(props.server + "/@" + status_.account.acct + "/" + status_.id, "_blank"); 153 | } 154 | 155 | const followLink = async (status: StatusType) => { 156 | //Follow an article link 157 | weightAdjust(status.weights) 158 | window.open(status.card.url, "_blank"); 159 | } 160 | 161 | const onView = async (status: StatusType) => { 162 | //Mark a post as seen 163 | console.log(status.account.acct) 164 | const status_ = { ...status }; 165 | status_.value = -1 166 | seenFeed.push(status_) 167 | const seenFeedSet = new Set(seenFeed) 168 | const seenFeedArray = [...seenFeedSet].filter((item) => { 169 | const seconds = Math.floor((new Date().getTime() - new Date(item.createdAt).getTime()) / 1000); 170 | return seconds < 3600 * 24 171 | }) 172 | setSeenFeed(seenFeedArray) 173 | } 174 | 175 | const weightAdjust = (weight: weightsType) => { 176 | //Adjust Weights based on user interaction 177 | if (autoAdjust === false) return; 178 | console.log(weight) 179 | if (weight == undefined) return; 180 | const mean = Object.values(weight).reduce((accumulator, currentValue) => accumulator + currentValue, 0) / Object.values(weight).length; 181 | for (let key in weight) { 182 | if (weight.hasOwnProperty(key)) { 183 | weight[key] = weight[key] / mean; 184 | } 185 | } 186 | const currentWeight: weightsType = { 187 | "userReblogWeight": userReblogWeight, 188 | "userFavWeight": userFavWeight, 189 | "topPostWeight": topPostWeight, 190 | "frequencyWeight": frequencyWeight 191 | } 192 | const currentMean = Object.values(weight).reduce((accumulator, currentValue) => accumulator + currentValue, 0) / Object.values(weight).length; 193 | setUserReblogWeight(userReblogWeight + 0.1 * userReblogWeight * weight["userReblogWeight"] / (currentWeight["userReblogWeight"] / currentMean)) 194 | setUserFavWeight(userFavWeight + 0.1 * userFavWeight * weight["userFavWeight"] / (currentWeight["userFavWeight"] / currentMean)) 195 | setTopPostWeight(topPostWeight + 0.1 * topPostWeight * weight["topPostWeight"] / (currentWeight["topPostWeight"] / currentMean)) 196 | setFrequencyWeight(frequencyWeight + 0.1 * frequencyWeight * weight["frequencyWeight"] / (currentWeight["frequencyWeight"] / currentMean)) 197 | } 198 | 199 | return ( 200 | 201 | 202 | 203 | 204 | 205 | 206 |

Feed

207 | 208 | 209 | 210 | 211 |
212 | 213 | 214 | 215 | Feed Algorithmus 216 | 217 | Show more Posts of Users you reblogged in the past. {userReblogWeight} 218 | setUserReblogWeight(parseInt(event.target.value))} /> 219 | Show more Posts of Users you faved in the past. {userFavWeight} 220 | setUserFavWeight(parseInt(event.target.value))} /> 221 | Show more Trending Posts from your favorite Servers {topPostWeight} 222 | setTopPostWeight(parseInt(event.target.value))} /> 223 | Show more Posts that were repeatedly reposted in your feed {frequencyWeight} 224 | setFrequencyWeight(parseInt(event.target.value))} /> 225 | Time Penalty {timePenalty} 226 | setTimePenalty(parseFloat(event.target.value))} /> 227 | Reduce frequency highly active users would appear in the feed 228 | setActivePenalty(parseFloat(event.target.value))} /> 229 | Auto Adjust Weights (if you interact with a post, the weights will "learn") 230 | setAutoAdjust(event.target.checked)} /> 231 | 232 | 233 | 234 | 235 |
236 | {isLoading && 237 | 238 | } 239 | {error != "" && 240 | 241 | {error} 242 | 243 | } 244 | 245 | {feed.slice(0, Math.max(20, records)).map((status: any, index) => { 246 | return ( 247 | 257 | ) 258 | })} 259 | 260 |
261 |
262 | ) 263 | } 264 | -------------------------------------------------------------------------------- /frontend/src/components/Status.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, RefObject, useEffect, useLayoutEffect, useRef } from 'react'; 2 | import parse from 'html-react-parser' 3 | import Card from 'react-bootstrap/Card'; 4 | import Carousel from 'react-bootstrap/esm/Carousel'; 5 | import Nav from 'react-bootstrap/Nav'; 6 | import NavDropdown from 'react-bootstrap/NavDropdown'; 7 | import useOnScreen from '../utils/useOnScreen'; 8 | 9 | export default function Status(props: { 10 | status: any, 11 | onView: (status: any) => void, 12 | fav: (status: any) => void, 13 | reblog: (status: any) => void, 14 | followUri: (status: any) => void, 15 | followLink: (status: any) => void, 16 | key: string, 17 | isTop: boolean, 18 | }): ReactElement { 19 | const ref = useRef(null); 20 | const [status, setStatus] = React.useState(props.status); 21 | const isVisible = useOnScreen(ref) 22 | 23 | useLayoutEffect(() => { 24 | if (props.isTop) ref.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); 25 | }, []); 26 | 27 | useEffect(() => { 28 | if (isVisible) props.onView(props.status) 29 | }, [isVisible]) 30 | 31 | const handleSelect = (eventKey: keyof { "reblog": string, "link": string, "fav": string, "status": string }) => { 32 | console.log("eventKey") 33 | console.log(eventKey) 34 | switch (eventKey) { 35 | case "reblog": 36 | props.status.reblogged = true; 37 | props.status.reblogsCount += 1; 38 | props.reblog(status); 39 | setStatus(props.status); 40 | break; 41 | case "fav": 42 | props.fav(status); 43 | break; 44 | case "status": 45 | props.followUri(status); 46 | break; 47 | case "link": 48 | props.followLink(status); 49 | default: 50 | break; 51 | } 52 | } 53 | return ( 54 | 55 | 56 | {status.reblog_by && 57 |

Reblog by {status.reblog_by}

58 | } 59 | avatar 60 | {status.account.displayName} 61 | {status.account.username} 62 | {status.topPost && 63 | Top Post 64 | } 65 |
66 | 67 | {parse(status.content)} 68 | {status.mediaAttachments?.length > 0 && 69 | 70 | {status.mediaAttachments?.map((media: any) => { 71 | return ( 72 | 73 | media 74 | 75 | ) 76 | })} 77 | 78 | } 79 | { 80 | status.card && 81 | handleSelect("link")}> 82 | { 83 | status.card.image !== null && 84 | 85 | } 86 | 87 | {status.card.title} 88 | {status.card.description} 89 | 90 | {status.card.url} 91 | 92 | 93 | } 94 | 113 | 114 | 115 | 116 | {status.id} - {status.createdAt} - Value: {status.value} 117 | 118 | 119 |
120 | ) 121 | } -------------------------------------------------------------------------------- /frontend/src/features/coreServerFeature.ts: -------------------------------------------------------------------------------- 1 | export default async function coreServerFeature() { 2 | const res = await fetch("/core_servers") 3 | if (!res.ok) { 4 | return { errors: res } 5 | } 6 | const data = await res.json(); 7 | return data 8 | } -------------------------------------------------------------------------------- /frontend/src/features/favsFeature.ts: -------------------------------------------------------------------------------- 1 | export default async function favsFeature() { 2 | const res = await fetch("/favorites") 3 | if (!res.ok) { 4 | return { errors: res } 5 | } 6 | const data = await res.json(); 7 | return data 8 | } -------------------------------------------------------------------------------- /frontend/src/features/reblogsFeature.ts: -------------------------------------------------------------------------------- 1 | export default async function reblogsFeature() { 2 | const res = await fetch("/reblogs") 3 | if (!res.ok) { 4 | return { errors: res } 5 | } 6 | const data = await res.json(); 7 | return data["reblogs"] 8 | } -------------------------------------------------------------------------------- /frontend/src/feeds/homeFeed.ts: -------------------------------------------------------------------------------- 1 | export default async function getHomeFeed(masto: any) { 2 | let results: any[] = []; 3 | let pages = 10; 4 | for await (const page of masto.v1.timelines.listHome()) { 5 | results = results.concat(page) 6 | pages--; 7 | if (pages === 0) { 8 | break; 9 | } 10 | } 11 | return results; 12 | } -------------------------------------------------------------------------------- /frontend/src/feeds/topPostsFeed.ts: -------------------------------------------------------------------------------- 1 | import { SerializerNativeImpl } from "masto"; 2 | 3 | export default async function getTopPostFeed(core_servers: any) { 4 | let results: any[] = []; 5 | const serializer = new SerializerNativeImpl(); 6 | for (const server of Object.keys(core_servers)) { 7 | const res = await fetch(server + "/api/v1/trends/statuses") 8 | const data: any[] = serializer.deserialize('application/json', await res.text()); 9 | results = results.concat(data.map((status: any) => { 10 | status.topPost = true; 11 | return status; 12 | }).slice(0, 5)) 13 | } 14 | console.log(results) 15 | return results; 16 | } -------------------------------------------------------------------------------- /frontend/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | React Django App 10 | 11 | 12 | 13 |
14 | 15 | 16 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import { useEffect } from 'react'; 4 | import Feed from './components/Feed'; 5 | import Form from 'react-bootstrap/Form'; 6 | import Container from 'react-bootstrap/esm/Container'; 7 | import Button from 'react-bootstrap/Button'; 8 | import Card from 'react-bootstrap/esm/Card'; 9 | 10 | const App: React.FC = () => { 11 | const [server, setServer] = React.useState(''); 12 | const loggedIn = document.getElementById('login_status').dataset.login === 'True'; 13 | 14 | if (loggedIn) { 15 | const token = document.getElementById('login_status').dataset.token; 16 | const server = document.getElementById('login_status').dataset.server; 17 | return ; 18 | } 19 | //show mastodon server input 20 | return ( 21 | 31 |

Mastodon Smart-Feed

32 | 33 | 34 | Enter Mastodon Server in the form: https://example.social 35 | { 36 | setServer(e.target.value); 37 | }} /> 38 | 39 | 40 | 41 | 42 | 48 | Attention 49 | 50 | 51 | This is a demo application. It might contain security issues. Please use at your own risk. 52 | 53 | 54 | 55 |
56 | ) 57 | 58 | 59 | }; 60 | ReactDOM.render(, document.getElementById('app')); -------------------------------------------------------------------------------- /frontend/src/types.tsx: -------------------------------------------------------------------------------- 1 | interface weightsType { 2 | [key: string]: number; // Replace 'any' with the desired value type (e.g., string, number, etc.) 3 | } 4 | 5 | enum scopeType { 6 | read_accounts = "read:accounts", 7 | read_blocks = "read:blocks", 8 | read_bookmarks = "read:bookmarks", 9 | read_favourites = "read:favourites", 10 | read_filters = "read:filters", 11 | read_follows = "read:follows", 12 | read_lists = "read:lists", 13 | read_mutes = "read:mutes", 14 | read_notifications = "read:notifications", 15 | read_search = "read:search", 16 | read_statuses = "read:statuses", 17 | write_accounts = "write:accounts", 18 | write_blocks = "write:blocks", 19 | write_bookmarks = "write:bookmarks", 20 | write_conversations = "write:conversations", 21 | write_favourites = "write:favourites", 22 | write_filters = "write:filters", 23 | write_follows = "write:follows", 24 | write_lists = "write:lists", 25 | write_media = "write:media", 26 | write_mutes = "write:mutes", 27 | write_notifications = "write:notifications", 28 | write_reports = "write:reports", 29 | write_statuses = "write:statuses", 30 | } 31 | 32 | type StatusType = { 33 | id: string; 34 | uri: string; 35 | url: string; 36 | account: { 37 | id: string; 38 | username: string; 39 | acct: string; 40 | display_name: string; 41 | }, 42 | card?: { 43 | url: string; 44 | title: string; 45 | description: string; 46 | image: string; 47 | }, 48 | weights?: weightsType; 49 | reblog?: StatusType; 50 | topPost?: boolean; 51 | inReplyToId: string; 52 | createdAt: string; 53 | value?: number; 54 | content: string; 55 | reblogged?: Boolean 56 | } 57 | 58 | export { weightsType, scopeType, StatusType } -------------------------------------------------------------------------------- /frontend/src/utils/theAlgorithm.ts: -------------------------------------------------------------------------------- 1 | import { StatusType, weightsType } from "../types"; 2 | 3 | export default function sortFeed( 4 | array: StatusType[], 5 | reblogs: any, 6 | core_servers: any, 7 | favs: any, 8 | seenFeed: StatusType[], 9 | userReblogWeight: number, 10 | userFavWeight: number, 11 | topPostWeight: number, 12 | frequencyWeight: number, 13 | timePenalty: number, 14 | activePenalty: number, 15 | ): StatusType[] { 16 | //how often a post is in the feed 17 | var weights: { [key: string]: weightsType } = {}; 18 | var accounts: { [key: string]: number } = {}; 19 | 20 | //Apply Weights 21 | array.forEach((item) => { 22 | if (item.reblog) item.uri = item.reblog.uri; 23 | if (!(item.uri in weights)) weights[item.uri] = { 24 | "userReblogWeight": 0, 25 | "userFavWeight": 0, 26 | "topPostWeight": 0, 27 | "frequencyWeight": 0, 28 | "similarity": 0, 29 | } 30 | const weight: weightsType = { 31 | "userReblogWeight": (item.account.acct in reblogs) ? reblogs[item.account.acct] * userReblogWeight : 0, 32 | "userFavWeight": (item.account.acct in favs) ? favs[item.account.acct] * userFavWeight : 0, 33 | "topPostWeight": item.topPost ? topPostWeight : 0, 34 | "frequencyWeight": frequencyWeight, 35 | } 36 | console.log(weight) 37 | for (let key in weight) { 38 | if (weights[item.uri].hasOwnProperty(key)) { 39 | weights[item.uri][key] += weight[key] 40 | } 41 | } 42 | console.log(weights[item.uri]) 43 | }); 44 | 45 | //Remove already seen content - Currently Not Implemented 46 | const seenUris = [...seenFeed].map((item) => item.uri); 47 | 48 | //Remove unwanted content 49 | array = array 50 | .filter(item => item != undefined) 51 | .filter(item => item.inReplyToId === null) 52 | .filter((item: StatusType) => item.content.includes("RT @") === false) 53 | .filter((item: StatusType) => !item.reblogged) 54 | 55 | //Remove duplicates 56 | array = [...new Map(array.map(item => [item["uri"], item])).values()]; 57 | 58 | //Apply Weights and sort 59 | const sortedArray = array.map((item) => { 60 | console.log(weights[item.uri]) 61 | item.weights = weights[item.uri] 62 | item.value = Object.values(weights[item.uri]).reduce((accumulator, currentValue) => accumulator + currentValue, 0); 63 | return item; 64 | }).filter((item) => item.value > 0) 65 | .sort(function (a, b) { 66 | return b.value - a.value 67 | }) 68 | 69 | //Apply Discounts 70 | const mixedArray = sortedArray.map((item) => { 71 | if (item.account.acct in accounts) { 72 | accounts[item.account.acct] += 1; 73 | } else { 74 | accounts[item.account.acct] = 1; 75 | } 76 | const seconds = Math.floor((new Date().getTime() - new Date(item.createdAt).getTime()) / 1000); 77 | const timediscount = Math.pow((1 + timePenalty * 0.2), -Math.pow((seconds / 3600), 2)); 78 | item.value = item.value * timediscount 79 | item.value = item.value * Math.pow(activePenalty, accounts[item.account.acct]) 80 | return item; 81 | }).sort(function (a, b) { 82 | return b.value - a.value 83 | }) 84 | 85 | //Finalize Feed for display 86 | const finalArray = mixedArray.map((status: any) => { 87 | if (status.reblog) { 88 | status.reblog.value = status.value; 89 | status.reblog.weights = status.weights 90 | status.reblog.reblog_by = status.account.acct; 91 | return status.reblog; 92 | } 93 | status.reblog_by = null; 94 | return status; 95 | }) 96 | console.log(finalArray) 97 | return finalArray 98 | } -------------------------------------------------------------------------------- /frontend/src/utils/useOnScreen.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState, RefObject } from 'react' 2 | 3 | export default function useOnScreen(ref: RefObject) { 4 | 5 | const [isIntersecting, setIntersecting] = useState(false) 6 | 7 | const observer = useMemo(() => new IntersectionObserver( 8 | ([entry]) => setIntersecting(entry.isIntersecting) 9 | ), [ref]) 10 | 11 | useEffect(() => { 12 | observer.observe(ref.current) 13 | return () => observer.disconnect() 14 | }, []) 15 | 16 | return isIntersecting 17 | } 18 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "sourceMap": true, 5 | "noImplicitAny": true, 6 | "module": "esnext", 7 | "target": "ES2015", 8 | "jsx": "react", 9 | "lib": [ 10 | "esnext.asynciterable", 11 | "dom", 12 | "ES2021", 13 | ], 14 | "allowJs": true, 15 | "moduleResolution": "node", 16 | "allowSyntheticDefaultImports": true, 17 | "esModuleInterop": true, 18 | "resolveJsonModule": true 19 | }, 20 | "include": [ 21 | "src/**/*" 22 | ], 23 | "exclude": [ 24 | "node_modules" 25 | ] 26 | } -------------------------------------------------------------------------------- /frontend/webpack-stats.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "done", 3 | "assets": { 4 | "bundle.js": { 5 | "name": "bundle.js", 6 | "path": "/Users/philip/Desktop/Mastodon/fedi_feed/frontend/dist/bundle.js" 7 | }, 8 | "index.html": { 9 | "name": "index.html", 10 | "path": "/Users/philip/Desktop/Mastodon/fedi_feed/frontend/dist/index.html" 11 | } 12 | }, 13 | "chunks": { 14 | "main": [ 15 | "bundle.js" 16 | ] 17 | } 18 | } -------------------------------------------------------------------------------- /frontend/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 4 | const BundleTracker = require('webpack-bundle-tracker'); 5 | 6 | module.exports = { 7 | mode: 'production', 8 | entry: './src/index.tsx', 9 | devtool: "eval-source-map", 10 | output: { 11 | path: path.resolve(__dirname, 'dist'), 12 | filename: 'bundle.js', 13 | }, 14 | resolve: { 15 | extensions: ['.ts', '.tsx', '.js', '.jsx'], 16 | }, 17 | module: { 18 | rules: [ 19 | { 20 | test: /\.tsx?$/, 21 | use: 'ts-loader', 22 | exclude: /node_modules/, 23 | }, 24 | { 25 | test: /\.css$/, 26 | use: [MiniCssExtractPlugin.loader, 'css-loader'], 27 | }, 28 | ], 29 | }, 30 | plugins: [ 31 | new HtmlWebpackPlugin({ 32 | template: './src/index.html', 33 | }), 34 | new MiniCssExtractPlugin(), 35 | new BundleTracker({ filename: './webpack-stats.json' }) 36 | ], 37 | devServer: { 38 | static: { 39 | directory: path.join(__dirname, 'dist'), 40 | }, 41 | 42 | compress: true, 43 | port: 3000 44 | }, 45 | }; -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'fedi_feed.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | django-environ 2 | django 3 | Mastodon.py 4 | requests 5 | django-encrypted-model-fields 6 | gunicorn 7 | postgres 8 | psycopg2-binary 9 | whitenoise 10 | django-heroku 11 | django-webpack-loader 12 | pandas -------------------------------------------------------------------------------- /user/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pkreissel/fedifeed/8a19c94452c1de9417e210c24ee7671fb3526586/user/__init__.py -------------------------------------------------------------------------------- /user/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /user/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class UserConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'user' 7 | -------------------------------------------------------------------------------- /user/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.12 on 2023-03-27 13:45 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import encrypted_model_fields.fields 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='MastodonServer', 20 | fields=[ 21 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('api_base_url', models.CharField(max_length=100)), 23 | ('client_id', encrypted_model_fields.fields.EncryptedCharField()), 24 | ('client_secret', encrypted_model_fields.fields.EncryptedCharField()), 25 | ], 26 | ), 27 | migrations.CreateModel( 28 | name='MastodonUser', 29 | fields=[ 30 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 31 | ('username', models.CharField(max_length=100)), 32 | ('userId', models.CharField(max_length=100, null=True)), 33 | ('token', encrypted_model_fields.fields.EncryptedCharField()), 34 | ('last_updated', models.DateTimeField(auto_now=True)), 35 | ('server', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='users', to='user.mastodonserver')), 36 | ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='mastodon', to=settings.AUTH_USER_MODEL)), 37 | ], 38 | ), 39 | ] 40 | -------------------------------------------------------------------------------- /user/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pkreissel/fedifeed/8a19c94452c1de9417e210c24ee7671fb3526586/user/migrations/__init__.py -------------------------------------------------------------------------------- /user/models.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlparse 2 | 3 | from django.db import models 4 | from django.contrib.auth.models import User 5 | from encrypted_model_fields.fields import EncryptedCharField 6 | # Create your models here. 7 | 8 | 9 | class MastodonServer(models.Model): 10 | api_base_url = models.CharField(max_length=100) 11 | client_id = EncryptedCharField(max_length=500) 12 | client_secret = EncryptedCharField(max_length=500) 13 | 14 | 15 | @staticmethod 16 | def standardize_servername(server): 17 | # Parse server url 18 | if server.startswith("https://"): 19 | server = "https://" + urlparse(server).netloc 20 | else: 21 | server = "https://" + server 22 | return server 23 | 24 | 25 | class MastodonUser(models.Model): 26 | user = models.OneToOneField( 27 | User, on_delete=models.CASCADE, related_name="mastodon") 28 | username = models.CharField(max_length=100) 29 | userId = models.CharField(max_length=100, null=True) 30 | server = models.ForeignKey( 31 | MastodonServer, on_delete=models.CASCADE, null=True, related_name="users") 32 | token = EncryptedCharField(max_length=200) 33 | last_updated = models.DateTimeField(auto_now=True) 34 | -------------------------------------------------------------------------------- /user/templates/user/index.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | 13 | React Django App 14 | 15 | 16 |
17 | 18 |
23 |
24 | 25 | 26 | -------------------------------------------------------------------------------- /user/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /user/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from .views import index, login, reblogs, core_servers, register, logout, favorites 3 | urlpatterns = [ 4 | path('', index), 5 | path("login", login), 6 | path("logout", logout), 7 | path("register", register), 8 | path("reblogs", reblogs, name="reblogs"), 9 | path("favorites", favorites, name="favorites"), 10 | path("core_servers", core_servers, name="core_servers") 11 | ] 12 | -------------------------------------------------------------------------------- /user/views.py: -------------------------------------------------------------------------------- 1 | from http import client 2 | from unittest.mock import DEFAULT 3 | from django.shortcuts import render, redirect 4 | import requests 5 | from django.http import JsonResponse, HttpResponse 6 | from mastodon import Mastodon 7 | from .models import MastodonUser, MastodonServer 8 | from django.contrib.auth.models import User 9 | from django.contrib.auth import login as auth_login, logout as auth_logout 10 | from django.contrib.auth.decorators import login_required 11 | from django.conf import settings 12 | # Create your views here. 13 | 14 | 15 | DEFAULT_SCOPES = ["read", "write"] 16 | 17 | FINE_SCOPES = ["read:favourites", "read:follows", "read:search", "read:blocks", 18 | "read:accounts", "read:statuses", "write:favourites", "write:statuses", "write:follows", 19 | "read:lists", "write:lists", "read:filters", "read:blocks" 20 | ] 21 | 22 | 23 | def index(request): 24 | auth_url = None 25 | print(auth_url) 26 | if request.user.is_authenticated: 27 | token = request.user.mastodon.token 28 | server = request.user.mastodon.server.api_base_url 29 | else: 30 | token = "" 31 | server = "" 32 | return render(request, 'user/index.html', context={ 33 | "auth_url": auth_url, 34 | "token": token, 35 | "server": server 36 | }) 37 | 38 | 39 | @login_required 40 | def logout(request): 41 | mastoServer = request.user.mastodon.server 42 | api = Mastodon( 43 | access_token=request.user.mastodon.token, 44 | api_base_url=request.user.mastodon.server.api_base_url, 45 | client_id=mastoServer.client_id, 46 | client_secret=mastoServer.client_secret, 47 | ) 48 | api.revoke_access_token() 49 | auth_logout(request) 50 | return redirect('/') 51 | 52 | 53 | def login(request): 54 | code = request.GET.get('code') 55 | # Server Name from coookie 56 | print(code) 57 | server = request.session.get('server') 58 | server = MastodonServer.standardize_servername(server) 59 | print(server) 60 | mastoServer = MastodonServer.objects.get(api_base_url=server) 61 | api = Mastodon( 62 | client_id=mastoServer.client_id, 63 | client_secret=mastoServer.client_secret, 64 | api_base_url=mastoServer.api_base_url, 65 | ) 66 | token = api.log_in( 67 | code=code, 68 | redirect_uri=settings.HOSTED_URL + "/login", 69 | scopes=DEFAULT_SCOPES 70 | ) 71 | print(token) 72 | me = api.me() 73 | if User.objects.filter(username=me.url).count() > 0: 74 | user = User.objects.get(username=me.url) 75 | else: 76 | password = User.objects.make_random_password() 77 | user = User.objects.create_user(me.url, password=password) 78 | mastodonuser, created = MastodonUser.objects.get_or_create( 79 | user=user, 80 | userId=me.id, 81 | username=me.username, 82 | server=mastoServer 83 | ) 84 | mastodonuser.token = token 85 | mastodonuser.save() 86 | auth_login(request, user) 87 | return redirect('/') 88 | 89 | 90 | def register(request): 91 | from urllib.parse import urlparse 92 | server = request.GET.get('server') 93 | server = MastodonServer.standardize_servername(server) 94 | mastoServer, created = MastodonServer.objects.get_or_create( 95 | api_base_url=server) 96 | request.session['server'] = server 97 | if created or settings.DEBUG: 98 | client_id, client_secret = Mastodon.create_app( 99 | api_base_url=server, 100 | redirect_uris=settings.HOSTED_URL + "/login", 101 | scopes=DEFAULT_SCOPES, 102 | client_name="SmartFeed", 103 | ) 104 | mastoServer.client_id = client_id 105 | mastoServer.client_secret = client_secret 106 | print(client_id) 107 | print(client_secret) 108 | mastoServer.save() 109 | print(mastoServer.api_base_url) 110 | print(mastoServer.client_id) 111 | print(mastoServer.client_secret) 112 | api = Mastodon( 113 | client_id=mastoServer.client_id, 114 | client_secret=mastoServer.client_secret, 115 | api_base_url=mastoServer.api_base_url, 116 | ) 117 | auth_url = api.auth_request_url( 118 | client_id=mastoServer.client_id, 119 | redirect_uris=settings.HOSTED_URL + "/login", 120 | scopes=DEFAULT_SCOPES 121 | ) 122 | print(auth_url) 123 | return redirect(auth_url) 124 | 125 | 126 | @login_required 127 | def reblogs(request): 128 | """Fetch Users that are reblogged the most 129 | 130 | Args: 131 | request (request): request 132 | 133 | Returns: 134 | JSONResponse: Most reblogged users as a json-dict 135 | """ 136 | import pandas as pd 137 | from django.core.cache import cache 138 | if len(cache.get(f'reblogs{request.user.id}', [])) > 0: 139 | frequ = cache.get(f'reblogs{request.user.id}') 140 | else: 141 | api = Mastodon( 142 | access_token=request.user.mastodon.token, 143 | api_base_url=request.user.mastodon.server.api_base_url, 144 | ) 145 | id = api.me().id 146 | page = api.account_statuses( 147 | id, exclude_replies=False, exclude_reblogs=False) 148 | results = page 149 | for _ in range(3): 150 | page = api.fetch_next(page) 151 | if page is None: 152 | break 153 | results.extend(page) 154 | if len(results) == 0: 155 | return JsonResponse({}) 156 | reblogs = [ 157 | result.reblog for result in results if result.reblog] 158 | mentions = [result.mentions for result in results if result.mentions] 159 | mentions = [acc for mention in mentions for acc in mention] 160 | mentions_frequ = pd.json_normalize(mentions).value_counts('acct') 161 | reblogs_frequ = pd.json_normalize(reblogs).value_counts('account.acct') 162 | frequ = { 163 | "reblogs": reblogs_frequ.to_dict(), 164 | "mentions": mentions_frequ.to_dict(), 165 | } 166 | cache.set(f'reblogs{request.user.id}', frequ, 60*60*24) 167 | return JsonResponse(frequ) 168 | 169 | 170 | @login_required 171 | def favorites(request): 172 | """Fetch Users that are favorited the most 173 | 174 | Args: 175 | request (request): The request 176 | 177 | Returns: 178 | JSONResponse: Most favorited users as a json-dict 179 | """ 180 | import pandas as pd 181 | from django.core.cache import cache 182 | if len(cache.get(f'favs{request.user.id}', [])) > 0: 183 | frequent = cache.get(f'favs{request.user.id}') 184 | else: 185 | api = Mastodon( 186 | access_token=request.user.mastodon.token, 187 | api_base_url=request.user.mastodon.server.api_base_url, 188 | ) 189 | id = api.me().id 190 | page = api.favourites() 191 | results = page 192 | for _ in range(3): 193 | page = api.fetch_next(page) 194 | if page is None: 195 | break 196 | results.extend(page) 197 | if len(results) == 0: 198 | return JsonResponse({}) 199 | frequent = pd.json_normalize(results).value_counts('account.acct') 200 | cache.set(f'favs{request.user.id}', frequent, 60*60*24) 201 | return JsonResponse(frequent.to_dict()) 202 | 203 | 204 | @login_required 205 | def core_accounts(request): 206 | api = Mastodon( 207 | access_token=request.user.mastodon.token, 208 | api_base_url=request.user.mastodon.server.api_base_url, 209 | ) 210 | 211 | 212 | @login_required 213 | def core_servers(request): 214 | import pandas as pd 215 | from django.core.cache import cache 216 | if len(cache.get(f'core_servers{request.user.id}', [])) > 0: 217 | frequent_server = cache.get(f'core_servers{request.user.id}') 218 | else: 219 | api = Mastodon( 220 | access_token=request.user.mastodon.token, 221 | api_base_url=request.user.mastodon.server.api_base_url, 222 | ) 223 | if not request.user.mastodon.userId: 224 | me = api.me() 225 | mastodonuser = request.user.mastodon 226 | mastodonuser.userId = me.id 227 | mastodonuser.save() 228 | 229 | followers = api.fetch_remaining(api.account_following( 230 | request.user.mastodon.userId, limit=500)) 231 | frequent = pd.json_normalize(followers) 232 | server = frequent['url'].str.split('@').str[0] 233 | frequent_server = server.value_counts()[:5].to_dict() 234 | cache.set(f'core_servers{request.user.id}', frequent_server, 60*60*24) 235 | return JsonResponse(frequent_server) 236 | --------------------------------------------------------------------------------