├── .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 | 
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'
',
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'
')
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 |
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 |
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 |
--------------------------------------------------------------------------------