├── .env.example ├── .github └── workflows │ └── update.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── api.py ├── formatters.py ├── howitlooks.png ├── models.py ├── render ├── .gitkeep ├── icon.png ├── style.css └── style.js ├── requirements.txt ├── run.py ├── scorers.py ├── templates ├── digest.html.jinja └── posts.html.jinja └── thresholds.py /.env.example: -------------------------------------------------------------------------------- 1 | MASTODON_TOKEN=SETME 2 | MASTODON_BASE_URL=SETME 3 | MASTODON_USERNAME=SETME -------------------------------------------------------------------------------- /.github/workflows/update.yml: -------------------------------------------------------------------------------- 1 | name: My Mastodon Digest 2 | on: 3 | schedule: 4 | - cron: '0 10,22 * * *' 5 | workflow_dispatch: 6 | jobs: 7 | update: 8 | name: digest 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: checkout 12 | uses: actions/checkout@master 13 | with: 14 | ref: main 15 | - name: python setup 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: '3.9' 19 | - name: python things 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install -r requirements.txt 23 | - name: run digest 24 | env: 25 | MASTODON_TOKEN: ${{ secrets.MASTODON_TOKEN }} 26 | MASTODON_BASE_URL: ${{ secrets.MASTODON_BASE_URL }} 27 | MASTODON_USERNAME: ${{ secrets.MASTODON_USERNAME }} 28 | run: | 29 | python run.py -n 12 -s SimpleWeighted -t lax 30 | - name: publish 31 | uses: crazy-max/ghaction-github-pages@v3 32 | with: 33 | target_branch: gh-pages 34 | build_dir: render 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | MANIFEST 2 | build 3 | dist 4 | _build 5 | docs/man/*.gz 6 | docs/source/api/generated 7 | docs/source/config.rst 8 | docs/gh-pages 9 | notebook/i18n/*/LC_MESSAGES/*.mo 10 | notebook/i18n/*/LC_MESSAGES/nbjs.json 11 | notebook/static/components 12 | notebook/static/style/*.min.css* 13 | notebook/static/*/js/built/ 14 | notebook/static/*/built/ 15 | notebook/static/built/ 16 | notebook/static/*/js/main.min.js* 17 | notebook/static/lab/*bundle.js 18 | node_modules 19 | *.py[co] 20 | __pycache__ 21 | *.egg-info 22 | *~ 23 | *.bak 24 | .ipynb_checkpoints 25 | .tox 26 | .DS_Store 27 | \#*# 28 | .#* 29 | .coverage 30 | src 31 | 32 | *.swp 33 | *.map 34 | .idea/ 35 | Read the Docs 36 | config.rst 37 | 38 | /.project 39 | /.pydevproject 40 | 41 | package-lock.json 42 | .vscode* 43 | 44 | .env 45 | 46 | render/*.html -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-slim-bullseye 2 | 3 | ENV PYTHONUNBUFFERED=1 4 | 5 | ARG WORKDIR 6 | ARG BUILD_DATE 7 | ARG NAME 8 | ARG ORG 9 | ARG VCS_REF 10 | ARG VENDOR 11 | ARG VERSION 12 | 13 | WORKDIR $WORKDIR 14 | 15 | COPY requirements.txt . 16 | RUN mkdir -p venvs 17 | RUN python3 -m venv venvs/$NAME 18 | RUN venvs/$NAME/bin/pip install --upgrade pip 19 | RUN venvs/$NAME/bin/pip install -r requirements.txt 20 | 21 | COPY templates/ ./templates/ 22 | COPY *.py . 23 | 24 | LABEL org.label-schema.build-date=$BUILD_DATE \ 25 | org.label-schema.name=$NAME \ 26 | org.label-schema.description="A Python script that aggregates recent popular tweets from your Mastodon timeline " \ 27 | org.label-schema.url="https://github.com/${ORG}/${NAME}" \ 28 | org.label-schema.vcs-url="https://github.com/${ORG}/${NAME}" \ 29 | org.label-schema.vcs-ref=$VCS_REF \ 30 | org.label-schema.vendor=$VENDOR \ 31 | org.label-schema.version=$VERSION \ 32 | org.label-schema.docker.schema-version="1.0" \ 33 | org.label-schema.docker.cmd="docker run ${ORG}/${NAME}" 34 | 35 | ENTRYPOINT ["venvs/mastodon_digest/bin/python3", "run.py"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022, Matt Hodges 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of Mastodon Digest nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: run help 2 | 3 | VERSION := $(shell git describe --abbrev=0 --tags) 4 | BUILD_DATE := "$(shell date -u)" 5 | VCS_REF := $(shell git log -1 --pretty=%h) 6 | NAME := $(shell pwd | xargs basename) 7 | VENDOR := "Matt Hodges" 8 | ORG := hodgesmr 9 | WORKDIR := "/opt/${NAME}" 10 | 11 | DOCKER_SCAN_SUGGEST=false 12 | 13 | FLAGS ?= 14 | 15 | print: 16 | @echo BUILD_DATE=${BUILD_DATE} 17 | @echo NAME=${NAME} 18 | @echo ORG=${ORG} 19 | @echo VCS_REF=${VCS_REF} 20 | @echo VENDOR=${VENDOR} 21 | @echo VERSION=${VERSION} 22 | @echo WORKDIR=${WORKDIR} 23 | @echo USER_OPTIONS=${USER_OPTIONS} 24 | 25 | .EXPORT_ALL_VARIABLES: 26 | build: 27 | docker build -f Dockerfile \ 28 | -t ${ORG}/${NAME}:${VERSION} \ 29 | -t ${ORG}/${NAME}:latest . \ 30 | --build-arg VERSION=${VERSION} \ 31 | --build-arg BUILD_DATE=${BUILD_DATE} \ 32 | --build-arg VCS_REF=${VCS_REF} \ 33 | --build-arg NAME=${NAME} \ 34 | --build-arg VENDOR=${VENDOR} \ 35 | --build-arg ORG=${ORG} \ 36 | --build-arg WORKDIR=${WORKDIR} 37 | 38 | .EXPORT_ALL_VARIABLES: 39 | help: 40 | docker run --env-file .env -it --rm -v "$(PWD)/render:${WORKDIR}/render" ${ORG}/${NAME} -h 41 | 42 | .EXPORT_ALL_VARIABLES: 43 | run: 44 | docker run --env-file .env -it --rm -v "$(PWD)/render:${WORKDIR}/render" ${ORG}/${NAME} ${FLAGS} 45 | python -m webbrowser -t "file://$(PWD)/render/index.html" -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A fork of [hodgesmr/mastodon_digest](https://github.com/hodgesmr/mastodon_digest) that 2 | 3 | - runs on github actions 4 | - renders mastodon posts without iframes 5 | - in a clean and responsive style 6 | 7 | ![Mastodon Digest](howitlooks.png) 8 | 9 | [**see live** 🎉](https://mauforonda.github.io/mastodon_digest/) 10 | 11 | --- 12 | 13 | > **Mastodon Digest** scans posts you haven't yet seen in your timeline, finds the most popular ones and shows them to you in a pretty page. 14 | 15 | ## To run your own 16 | 17 | 1. Fork this repository 18 | 2. Create repository secrets (`Settings` → `Secrets/Actions` → `New repository secrets`) for: 19 | - `MASTODON_BASE_URL`: the url of your instance, like `https://mastodon.social` 20 | - `MASTODON_USERNAME`: your user name, like `Gargron` 21 | - `MASTODON_TOKEN`: a token you request in your instance settings under `Preferences` → `Development` 22 | 3. Adjust the [github workflow](.github/workflows/update.yml) however you want 23 | - edit `cron` to define how often you want the digest to run 24 | - edit the command `python run.py -n 12 -s SimpleWeighted -t lax` with your own preferences for: 25 | ``` 26 | -n {1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24} 27 | The number of hours to consider (default: 12) 28 | -s {ExtendedSimple,ExtendedSimpleWeighted,Simple,SimpleWeighted} 29 | Which post scoring criteria to use. Simple scorers take a geometric 30 | mean of boosts and favs. Extended scorers include reply counts in 31 | the geometric mean. Weighted scorers multiply the score by an 32 | inverse sqaure root of the author's followers, to reduce the 33 | influence of large accounts. (default: SimpleWeighted) 34 | -t {lax,normal,strict} 35 | Which post threshold criteria to use. lax = 90th percentile, normal 36 | = 95th percentile, strict = 98th percentile (default: normal) 37 | ``` 38 | 4. Enable github actions under `Settings` → `Actions/General`, run the action from the `Actions` tab and when it succeeds publish your digest by going to `Settings` → `Pages` and selecting to deploy from the `root` of the `gh-pages` branch. -------------------------------------------------------------------------------- /api.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime, timedelta, timezone 4 | from typing import TYPE_CHECKING 5 | 6 | from models import ScoredPost 7 | 8 | if TYPE_CHECKING: 9 | from mastodon import Mastodon 10 | 11 | 12 | def fetch_posts_and_boosts( 13 | hours: int, mastodon_client: Mastodon, mastodon_username: str 14 | ) -> tuple[list[ScoredPost], list[ScoredPost]]: 15 | """Fetches posts form the home timeline that the account hasn't interactied with""" 16 | 17 | TIMELINE_LIMIT = 1000 18 | 19 | # First, get our filters 20 | filters = mastodon_client.filters() 21 | 22 | # Set our start query 23 | start = datetime.now(timezone.utc) - timedelta(hours=hours) 24 | 25 | posts = [] 26 | boosts = [] 27 | seen_post_urls = set() 28 | total_posts_seen = 0 29 | 30 | # Iterate over our home timeline until we run out of posts or we hit the limit 31 | response = mastodon_client.timeline(min_id=start) 32 | while response and total_posts_seen < TIMELINE_LIMIT: 33 | 34 | # Apply our server-side filters 35 | if filters: 36 | filtered_response = mastodon_client.filters_apply(response, filters, "home") 37 | else: 38 | filtered_response = response 39 | 40 | for post in filtered_response: 41 | if post["visibility"] != "public": 42 | continue 43 | 44 | total_posts_seen += 1 45 | 46 | boost = False 47 | if post["reblog"] is not None: 48 | post = post["reblog"] # look at the bosted post 49 | boost = True 50 | 51 | scored_post = ScoredPost(post) # wrap the post data as a ScoredPost 52 | 53 | if scored_post.url not in seen_post_urls: 54 | # Apply our local filters 55 | # Basically ignore my posts or posts I've interacted with 56 | if ( 57 | not scored_post.info["reblogged"] 58 | and not scored_post.info["favourited"] 59 | and not scored_post.info["bookmarked"] 60 | and scored_post.info["account"]["acct"] != mastodon_username 61 | ): 62 | # Append to either the boosts list or the posts lists 63 | if boost: 64 | boosts.append(scored_post) 65 | else: 66 | posts.append(scored_post) 67 | seen_post_urls.add(scored_post.url) 68 | 69 | response = mastodon_client.fetch_previous( 70 | response 71 | ) # fext the previous (because of reverse chron) page of results 72 | 73 | return posts, boosts 74 | -------------------------------------------------------------------------------- /formatters.py: -------------------------------------------------------------------------------- 1 | def format_post(post, mastodon_base_url) -> dict: 2 | 3 | def format_media(media): 4 | formats = { 5 | 'image': f'
{media["description"]
', 6 | 'video': f'
', 7 | 'gifv': f'
' 8 | } 9 | if formats.__contains__(media.type): 10 | return formats[media.type] 11 | else: 12 | return "" 13 | 14 | def format_displayname(display_name, emojis): 15 | for emoji in emojis: 16 | display_name = display_name.replace(f':{emoji["shortcode"]}:', f'{emoji["shortcode"]}') 17 | return display_name 18 | 19 | account_avatar = post.data['account']['avatar'] 20 | account_url = post.data['account']['url'] 21 | display_name = format_displayname( 22 | post.data['account']['display_name'], 23 | post.data['account']['emojis'] 24 | ) 25 | username = post.data['account']['username'] 26 | content = post.data['content'] 27 | media = "\n".join([format_media(media) for media in post.data.media_attachments]) 28 | # created_at = post.data['created_at'].strftime('%B %d, %Y at %H:%M') 29 | created_at = post.data['created_at'].isoformat() 30 | home_link = f'home' 31 | original_link = f'original' 32 | replies_count = post.data['replies_count'] 33 | reblogs_count = post.data['reblogs_count'] 34 | favourites_count = post.data['favourites_count'] 35 | 36 | return dict( 37 | account_avatar=account_avatar, 38 | account_url=account_url, 39 | display_name=display_name, 40 | username=username, 41 | content=content, 42 | media=media, 43 | created_at=created_at, 44 | home_link=home_link, 45 | original_link=original_link, 46 | replies_count=replies_count, 47 | reblogs_count=reblogs_count, 48 | favourites_count=favourites_count 49 | ) 50 | 51 | def format_posts(posts, mastodon_base_url): 52 | return [format_post(post, mastodon_base_url) for post in posts] 53 | -------------------------------------------------------------------------------- /howitlooks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mauforonda/mastodon_digest/48803b413d65526d097562df66193fd417f5af47/howitlooks.png -------------------------------------------------------------------------------- /models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from scorers import Scorer 7 | 8 | 9 | class ScoredPost: 10 | def __init__(self, info: dict): 11 | self.info = info 12 | 13 | @property 14 | def url(self) -> str: 15 | return self.info["url"] 16 | 17 | def get_home_url(self, mastodon_base_url: str) -> str: 18 | return f"{mastodon_base_url}/@{self.info['account']['acct']}/{self.info['id']}" 19 | 20 | def get_score(self, scorer: Scorer) -> float: 21 | return scorer.score(self) 22 | 23 | @property 24 | def data(self): 25 | return self.info 26 | -------------------------------------------------------------------------------- /render/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mauforonda/mastodon_digest/48803b413d65526d097562df66193fd417f5af47/render/.gitkeep -------------------------------------------------------------------------------- /render/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mauforonda/mastodon_digest/48803b413d65526d097562df66193fd417f5af47/render/icon.png -------------------------------------------------------------------------------- /render/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --background: #edeff2; 3 | --text: #55525c; 4 | --post-background: #fff; 5 | --post-border: #E6E3E1; 6 | --link: #5d4f84; 7 | --link-hover: #4d54d1; 8 | --mastodon-links: #e9e9f5; 9 | --post-decoration: #e7eaec; 10 | } 11 | 12 | @media (prefers-color-scheme:dark) { 13 | :root { 14 | --background: #1c1d1e; 15 | --text: #d1d3d7; 16 | --post-background: #2c3240; 17 | --post-border: #282c37; 18 | --link-hover: #fff; 19 | --link: #9da2f0; 20 | --mastodon-links: #1b1f3d; 21 | --post-decoration: #3e475e; 22 | } 23 | } 24 | 25 | @import url('https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,400;0,700;1,400;1,700&display=swap'); 26 | 27 | body { 28 | background-color: var(--background); 29 | color: var(--text); 30 | text-align: left; 31 | font-family: 'Roboto', sans-serif; 32 | font-size: 15px; 33 | margin: 0px 10px; 34 | } 35 | 36 | #container { 37 | display: flex; 38 | justify-content: center; 39 | } 40 | 41 | .blocks { 42 | display: flex; 43 | flex-direction: column; 44 | line-height: 1.6; 45 | flex-direction: row; 46 | column-gap: 15px; 47 | flex-wrap: wrap; 48 | justify-content: center; 49 | } 50 | 51 | .stream { 52 | width: 100%; 53 | max-width: 450px; 54 | } 55 | 56 | .header { 57 | max-width: 200px; 58 | text-align: right; 59 | padding: 15px; 60 | margin-top: 30px; 61 | } 62 | 63 | 64 | .header .title { 65 | font-size: 2.5em; 66 | line-height: 1; 67 | font-weight: bold; 68 | margin-bottom: 8px; 69 | } 70 | 71 | .run_score { 72 | display: flex; 73 | flex-direction: column; 74 | } 75 | 76 | .run_score div { 77 | display: flex; 78 | flex-direction: row; 79 | justify-content: right; 80 | column-gap: 3px; 81 | font-size: .7em; 82 | } 83 | 84 | .run_time.date { 85 | margin-bottom: 8px; 86 | /*! font-weight: bold; */ 87 | font-size: .9em; 88 | } 89 | 90 | .meta_desc { 91 | opacity: .5; 92 | } 93 | 94 | .stream_title{ 95 | text-align: center; 96 | font-weight: bold; 97 | padding: 10px; 98 | } 99 | 100 | .posts { 101 | background-color: var(--background); 102 | padding: 0px; 103 | display: flex; 104 | overflow: scroll; 105 | flex-direction: row; 106 | flex-wrap: wrap; 107 | /*! gap: 15px; */ 108 | } 109 | 110 | .post { 111 | background-color: var(--post-background); 112 | padding: 18px 20px; 113 | border: 1px solid var(--post-border); 114 | border-radius: 10px; 115 | display: flex; 116 | box-shadow: 0.2px 0.4px 0.8px -10px rgba(0,0,0,0.03), 0.4px 0.9px 2px -10px rgba(0,0,0,0.030), 0.8px 1.8px 3.8px -10px rgba(0,0,0,0.038), 1.3px 3.1px 6.7px -10px rgba(0,0,0,0.045), 2.5px 5.8px 12.5px -10px rgba(0,0,0,0.06), 6px 14px 10px -10px rgba(0, 0, 0, 0.16); 117 | width: 100%; 118 | max-width: 450px; 119 | margin: 0px 5px 15px 5px; 120 | } 121 | 122 | .status { 123 | width: 100%; 124 | } 125 | 126 | .post_header { 127 | display: flex; 128 | flex-direction: row; 129 | justify-content: space-between; 130 | } 131 | 132 | .user { 133 | display: flex; 134 | flex-direction: row; 135 | } 136 | 137 | .links { 138 | font-size: .8em; 139 | display: flex; 140 | flex-wrap: wrap; 141 | gap: 5px; 142 | align-content: baseline; 143 | justify-content: right; 144 | flex-direction: row; 145 | } 146 | 147 | .links a { 148 | background: var(--post-decoration); 149 | padding: 2px 6px; 150 | border-radius: 5px; 151 | opacity: .7; 152 | } 153 | 154 | .links a[href] { 155 | color: var(--test); 156 | } 157 | 158 | .post a { 159 | text-decoration: none; 160 | } 161 | 162 | .avatar { 163 | max-width: 56px; 164 | min-width: 56px; 165 | height: 56px; 166 | margin-right: 20px; 167 | } 168 | 169 | .avatar img { 170 | max-width: 100%; 171 | border-radius: 20%; 172 | border: 1px solid var(--post-border); 173 | } 174 | 175 | .user a { 176 | display: flex; 177 | flex-wrap: wrap; 178 | flex-direction: column; 179 | max-width: 200px; 180 | } 181 | 182 | .user>a { 183 | width: 150px; 184 | } 185 | 186 | .username { 187 | font-size: .85em; 188 | } 189 | 190 | .displayname { 191 | font-weight: bold; 192 | font-size: 1.2em; 193 | } 194 | 195 | .displayname img { 196 | height: 16px; 197 | width: 16px; 198 | vertical-align: middle; 199 | object-fit: contain; 200 | } 201 | 202 | .content { 203 | color: var(--text); 204 | font-size: 14px; 205 | padding: 15px 15px 15px 0px; 206 | overflow-wrap: break-word; 207 | word-break: break-word; 208 | } 209 | 210 | .medias { 211 | padding-bottom: 15px; 212 | display: flex; 213 | gap: 5px; 214 | justify-content: center; 215 | } 216 | 217 | .content p { 218 | max-width: none; 219 | margin: 15px 0px; 220 | } 221 | 222 | .post a { 223 | color: var(--link); 224 | } 225 | 226 | .content a:hover, .footer a:hover, .user a:hover, .links a[href]:hover { 227 | color: var(--link-hover); 228 | } 229 | 230 | .ellipsis::after { 231 | content: "…"; 232 | } 233 | 234 | 235 | .content .invisible { 236 | display: none; 237 | } 238 | 239 | .status a:not(.hashtag):not(.mention) span:not(.invisible){ 240 | font-weight: bold 241 | } 242 | 243 | .home-link, .original-link, a.hashtag, a.mention, .footer .links a { 244 | text-decoration: none; 245 | opacity: .8; 246 | background-color: var(--mastodon-links); 247 | padding: 0 5px; 248 | border-radius: 5px; 249 | } 250 | 251 | .media img, .media video { 252 | max-width: 100%; 253 | border-radius: 5px; 254 | } 255 | 256 | .post_footer { 257 | display: flex; 258 | flex-wrap: wrap; 259 | flex-direction: row; 260 | justify-content: space-between; 261 | font-size: .8em; 262 | opacity: .7; 263 | padding-top: 15px; 264 | border-top: 2px solid var(--post-decoration); 265 | } 266 | 267 | .post_footer .date { 268 | padding-right: 10px 269 | } 270 | 271 | .post_footer .reactions span { 272 | padding-right: 5px 273 | } 274 | 275 | .post_footer div { 276 | padding-right: 10px; 277 | } 278 | 279 | 280 | @media (max-width: 720px) { 281 | 282 | .header { 283 | text-align: center; 284 | width: 100%; 285 | max-width: 400px; 286 | } 287 | 288 | .run_score { 289 | flex-direction: row; 290 | justify-content: center !important; 291 | gap: 25px; 292 | } 293 | } 294 | 295 | @media (min-width: 1180px) { 296 | 297 | .blocks { 298 | margin-right: 230px; 299 | } 300 | 301 | } 302 | 303 | -------------------------------------------------------------------------------- /render/style.js: -------------------------------------------------------------------------------- 1 | const dateFormat = {minute:'numeric', hour:'numeric', day:'numeric', month:'long'} 2 | document.querySelectorAll('.date'). 3 | forEach(d => { 4 | const date = new Date(d.dataset.date). 5 | toLocaleString('en-US', dateFormat) 6 | d.textContent = date 7 | }) -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Jinja2==3.1.* 2 | Mastodon.py==1.8.* 3 | scipy==1.9.* -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import os 5 | import sys 6 | from datetime import datetime 7 | from pathlib import Path 8 | from typing import TYPE_CHECKING 9 | 10 | from jinja2 import Environment, FileSystemLoader 11 | from mastodon import Mastodon 12 | 13 | from api import fetch_posts_and_boosts 14 | from scorers import get_scorers 15 | from thresholds import get_threshold_from_name, get_thresholds 16 | from formatters import format_posts 17 | 18 | if TYPE_CHECKING: 19 | from scorers import Scorer 20 | from thresholds import Threshold 21 | 22 | 23 | def render_digest(context: dict, output_dir: Path) -> None: 24 | environment = Environment(loader=FileSystemLoader("templates/")) 25 | template = environment.get_template("digest.html.jinja") 26 | output_html = template.render(context) 27 | output_file_path = output_dir / 'index.html' 28 | output_file_path.write_text(output_html) 29 | 30 | 31 | def run( 32 | hours: int, 33 | scorer: Scorer, 34 | threshold: Threshold, 35 | mastodon_token: str, 36 | mastodon_base_url: str, 37 | mastodon_username: str, 38 | output_dir: Path, 39 | ) -> None: 40 | 41 | print(f"Building digest from the past {hours} hours...") 42 | 43 | mst = Mastodon( 44 | access_token=mastodon_token, 45 | api_base_url=mastodon_base_url, 46 | ) 47 | 48 | # 1. Fetch all the posts and boosts from our home timeline that we haven't interacted with 49 | posts, boosts = fetch_posts_and_boosts(hours, mst, mastodon_username) 50 | 51 | # 2. Score them, and return those that meet our threshold 52 | threshold_posts = format_posts( 53 | threshold.posts_meeting_criteria(posts, scorer), 54 | mastodon_base_url) 55 | threshold_boosts = format_posts( 56 | threshold.posts_meeting_criteria(boosts, scorer), 57 | mastodon_base_url) 58 | 59 | # 3. Build the digest 60 | render_digest( 61 | context={ 62 | "hours": hours, 63 | "posts": threshold_posts, 64 | "boosts": threshold_boosts, 65 | "mastodon_base_url": mastodon_base_url, 66 | "rendered_at": datetime.utcnow().isoformat() + 'Z', 67 | # "rendered_at": datetime.utcnow().strftime('%B %d, %Y at %H:%M:%S UTC'), 68 | "threshold": threshold.get_name(), 69 | "scorer": scorer.get_name(), 70 | }, 71 | output_dir=output_dir, 72 | ) 73 | 74 | 75 | if __name__ == "__main__": 76 | scorers = get_scorers() 77 | thresholds = get_thresholds() 78 | 79 | arg_parser = argparse.ArgumentParser( 80 | prog="mastodon_digest", 81 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, 82 | ) 83 | arg_parser.add_argument( 84 | "-n", 85 | choices=range(1, 25), 86 | default=12, 87 | dest="hours", 88 | help="The number of hours to include in the Mastodon Digest", 89 | type=int, 90 | ) 91 | arg_parser.add_argument( 92 | "-s", 93 | choices=list(scorers.keys()), 94 | default="SimpleWeighted", 95 | dest="scorer", 96 | help="""Which post scoring criteria to use. 97 | Simple scorers take a geometric mean of boosts and favs. 98 | Extended scorers include reply counts in the geometric mean. 99 | Weighted scorers multiply the score by an inverse sqaure root 100 | of the author's followers, to reduce the influence of large accounts. 101 | """, 102 | ) 103 | arg_parser.add_argument( 104 | "-t", 105 | choices=list(thresholds.keys()), 106 | default="normal", 107 | dest="threshold", 108 | help="""Which post threshold criteria to use. 109 | lax = 90th percentile, 110 | normal = 95th percentile, 111 | strict = 98th percentile 112 | """, 113 | ) 114 | arg_parser.add_argument( 115 | "-o", 116 | default="./render/", 117 | dest="output_dir", 118 | help="Output directory for the rendered digest", 119 | required=False, 120 | ) 121 | args = arg_parser.parse_args() 122 | 123 | output_dir = Path(args.output_dir) 124 | if not output_dir.exists() or not output_dir.is_dir(): 125 | sys.exit(f"Output directory not found: {args.output_dir}") 126 | 127 | mastodon_token = os.getenv("MASTODON_TOKEN") 128 | mastodon_base_url = os.getenv("MASTODON_BASE_URL") 129 | mastodon_username = os.getenv("MASTODON_USERNAME") 130 | 131 | if not mastodon_token: 132 | sys.exit("Missing environment variable: MASTODON_TOKEN") 133 | if not mastodon_base_url: 134 | sys.exit("Missing environment variable: MASTODON_BASE_URL") 135 | if not mastodon_username: 136 | sys.exit("Missing environment variable: MASTODON_USERNAME") 137 | 138 | run( 139 | args.hours, 140 | scorers[args.scorer](), 141 | get_threshold_from_name(args.threshold), 142 | mastodon_token, 143 | mastodon_base_url, 144 | mastodon_username, 145 | output_dir, 146 | ) 147 | -------------------------------------------------------------------------------- /scorers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import importlib 4 | import inspect 5 | from abc import ABC, abstractmethod 6 | from math import sqrt 7 | from typing import TYPE_CHECKING 8 | 9 | from scipy import stats 10 | 11 | if TYPE_CHECKING: 12 | from models import ScoredPost 13 | 14 | 15 | class Weight(ABC): 16 | @classmethod 17 | @abstractmethod 18 | def weight(cls, scored_post: ScoredPost): 19 | pass 20 | 21 | 22 | class UniformWeight(Weight): 23 | @classmethod 24 | def weight(cls, scored_post: ScoredPost) -> UniformWeight: 25 | return 1 26 | 27 | 28 | class InverseFollowerWeight(Weight): 29 | @classmethod 30 | def weight(cls, scored_post: ScoredPost) -> InverseFollowerWeight: 31 | # Zero out posts by accounts with zero followers that somehow made it to my feed 32 | if scored_post.info["account"]["followers_count"] == 0: 33 | weight = 0 34 | else: 35 | # inversely weight against how big the account is 36 | weight = 1 / sqrt(scored_post.info["account"]["followers_count"]) 37 | 38 | return weight 39 | 40 | 41 | class Scorer(ABC): 42 | @classmethod 43 | @abstractmethod 44 | def score(cls, scored_post: ScoredPost): 45 | pass 46 | 47 | @classmethod 48 | def get_name(cls): 49 | return cls.__name__.replace("Scorer", "") 50 | 51 | 52 | class SimpleScorer(UniformWeight, Scorer): 53 | @classmethod 54 | def score(cls, scored_post: ScoredPost) -> SimpleScorer: 55 | if scored_post.info["reblogs_count"] or scored_post.info["favourites_count"]: 56 | # If there's at least one metric 57 | # We don't want zeros in other metrics to multiply that out 58 | # Inflate every value by 1 59 | metric_average = stats.gmean( 60 | [ 61 | scored_post.info["reblogs_count"]+1, 62 | scored_post.info["favourites_count"]+1, 63 | ] 64 | ) 65 | else: 66 | metric_average = 0 67 | return metric_average * super().weight(scored_post) 68 | 69 | 70 | class SimpleWeightedScorer(InverseFollowerWeight, SimpleScorer): 71 | @classmethod 72 | def score(cls, scored_post: ScoredPost) -> SimpleWeightedScorer: 73 | return super().score(scored_post) * super().weight(scored_post) 74 | 75 | 76 | class ExtendedSimpleScorer(UniformWeight, Scorer): 77 | @classmethod 78 | def score(cls, scored_post: ScoredPost) -> ExtendedSimpleScorer: 79 | if scored_post.info["reblogs_count"] or scored_post.info["favourites_count"] or scored_post.info["replies_count"]: 80 | # If there's at least one metric 81 | # We don't want zeros in other metrics to multiply that out 82 | # Inflate every value by 1 83 | metric_average = stats.gmean( 84 | [ 85 | scored_post.info["reblogs_count"]+1, 86 | scored_post.info["favourites_count"]+1, 87 | scored_post.info["replies_count"]+1, 88 | ], 89 | ) 90 | else: 91 | metric_average = 0 92 | return metric_average * super().weight(scored_post) 93 | 94 | 95 | class ExtendedSimpleWeightedScorer(InverseFollowerWeight, ExtendedSimpleScorer): 96 | @classmethod 97 | def score(cls, scored_post: ScoredPost) -> ExtendedSimpleWeightedScorer: 98 | return super().score(scored_post) * super().weight(scored_post) 99 | 100 | 101 | def get_scorers(): 102 | all_classes = inspect.getmembers(importlib.import_module(__name__), inspect.isclass) 103 | scorers = [c for c in all_classes if c[1] != Scorer and issubclass(c[1], Scorer)] 104 | return {scorer[1].get_name(): scorer[1] for scorer in scorers} 105 | -------------------------------------------------------------------------------- /templates/digest.html.jinja: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Mastodon Digest 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 |
19 | 20 |
21 | 22 |
Mastodon Digest
23 | 24 |
25 |
26 |
for the past
27 |
scorer
28 |
threshold
29 |
30 | 31 |
32 | 33 |
34 |
Posts
35 |
36 | {% with posts=posts %} 37 | {% include "posts.html.jinja" %} 38 | {% endwith %} 39 |
40 |
41 | 42 |
43 |
Boosts
44 |
45 | {% with posts=boosts %} 46 | {% include "posts.html.jinja" %} 47 | {% endwith %} 48 |
49 |
50 | 51 |
52 | 53 |
54 | 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /templates/posts.html.jinja: -------------------------------------------------------------------------------- 1 | {% for post in posts %} 2 | 3 |
4 | 5 |
6 | 7 |
8 | 9 | 23 | 24 | 28 | 29 |
30 | 31 |
32 | 33 |
34 | {{ post['content'] }} 35 |
36 | 37 | {% if post['media'] %} 38 |
39 | {{ post['media'] }} 40 |
41 | {% endif %} 42 | 43 |
44 | 45 | 56 | 57 |
58 | 59 |
60 | 61 | {% endfor %} 62 | -------------------------------------------------------------------------------- /thresholds.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import Enum 4 | from typing import TYPE_CHECKING 5 | 6 | from scipy import stats 7 | 8 | if TYPE_CHECKING: 9 | from models import ScoredPost 10 | from scorers import Scorer 11 | 12 | 13 | class Threshold(Enum): 14 | LAX = 90 15 | NORMAL = 95 16 | STRICT = 98 17 | 18 | def get_name(self): 19 | return self.name.lower() 20 | 21 | def posts_meeting_criteria( 22 | self, posts: list[ScoredPost], scorer: Scorer 23 | ) -> list[ScoredPost]: 24 | """Returns a list of ScoredPosts that meet this Threshold with the given Scorer""" 25 | 26 | all_post_scores = [p.get_score(scorer) for p in posts] 27 | threshold_posts = [ 28 | p 29 | for p in posts 30 | if stats.percentileofscore(all_post_scores, p.get_score(scorer)) 31 | >= self.value 32 | ] 33 | 34 | return threshold_posts 35 | 36 | 37 | def get_thresholds(): 38 | """Returns a dictionary mapping lowercase threshold names to values""" 39 | 40 | return {i.get_name(): i.value for i in Threshold} 41 | 42 | 43 | def get_threshold_from_name(name: str) -> Threshold: 44 | """Returns Threshold for a given named string""" 45 | 46 | return Threshold[name.upper()] 47 | --------------------------------------------------------------------------------