├── .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 |
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 |
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 |
--------------------------------------------------------------------------------