forked from PrivateCoffee/small
feat: initialize project structure for Small
Add initial project structure for Small, a clean frontend for reading Medium articles. This setup includes: - Basic Flask app configuration with necessary dependencies. - RESTful endpoint to fetch and serve Medium articles. - Templates and static assets for the user interface. - Utility functions for parsing Medium article IDs. - Custom error pages (404 and 500) for nicer error handling. - Project metadata files such as .gitignore, README, LICENSE, and pyproject.toml for project setup and documentation. This structure sets the foundation for further development and feature additions.
This commit is contained in:
commit
675f5195fe
22 changed files with 524 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
venv/
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
/dist/
|
19
LICENSE
Normal file
19
LICENSE
Normal file
|
@ -0,0 +1,19 @@
|
|||
Copyright (c) 2024 Private.coffee Team <support@private.coffee>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
68
README.md
Normal file
68
README.md
Normal file
|
@ -0,0 +1,68 @@
|
|||
# Small
|
||||
|
||||
Small is an alternative frontend for Medium articles, built with Flask. It allows users to read Medium articles without the clutter and distractions of the original Medium interface.
|
||||
|
||||
## Features
|
||||
|
||||
- Clean, minimalist interface for reading Medium articles
|
||||
- Fetches article content directly from Medium's GraphQL API
|
||||
- Parses and displays article content, including text and basic formatting
|
||||
- Responsive design for comfortable reading on various devices
|
||||
|
||||
## Installation
|
||||
|
||||
1. Clone the repository:
|
||||
```
|
||||
git clone https://git.private.coffee/PrivateCoffee/small.git
|
||||
cd small
|
||||
```
|
||||
|
||||
2. Create a virtual environment and activate it:
|
||||
```
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
3. Install the package:
|
||||
```
|
||||
pip install .
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
1. Start the Flask development server:
|
||||
```
|
||||
small
|
||||
```
|
||||
|
||||
2. Open your web browser and navigate to `http://localhost:5000`
|
||||
|
||||
3. To read a Medium article, replace `https://medium.com` in the article's URL with `http://localhost:5000`
|
||||
|
||||
For example:
|
||||
- Original URL: `https://medium.com/@username/article-title-123abc`
|
||||
- Small URL: `http://localhost:5000/@username/article-title-123abc`
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
|
||||
1. Fork the repository
|
||||
2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
|
||||
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. Push to the branch (`git push origin feature/AmazingFeature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- Inspired by the [Scribe](https://git.sr.ht/~edwardloveall/scribe) project built with Crystal and Lucky
|
||||
- Thanks to Medium for providing the content through their API
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This project is not affiliated with, endorsed, or sponsored by Medium. It's an independent project created to provide an alternative reading experience for Medium content.
|
26
pyproject.toml
Normal file
26
pyproject.toml
Normal file
|
@ -0,0 +1,26 @@
|
|||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "small"
|
||||
version = "0.1.0"
|
||||
authors = [{ name = "Private.coffee Team", email = "support@private.coffee" }]
|
||||
description = "A simple frontend for Medium"
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
]
|
||||
dependencies = ["flask", "bs4", "requests"]
|
||||
|
||||
[project.scripts]
|
||||
small = "small.app:main"
|
||||
|
||||
[project.urls]
|
||||
"Homepage" = "https://git.private.coffee/privatecoffee/small"
|
||||
"Bug Tracker" = "https://git.private.coffee/privatecoffee/small/issues"
|
||||
"Source Code" = "https://git.private.coffee/privatecoffee/small"
|
0
src/small/__init__.py
Normal file
0
src/small/__init__.py
Normal file
28
src/small/app.py
Normal file
28
src/small/app.py
Normal file
|
@ -0,0 +1,28 @@
|
|||
from flask import Flask
|
||||
|
||||
from os import environ
|
||||
|
||||
from .const import Config
|
||||
from .views import home, article, error, proxy
|
||||
|
||||
|
||||
def create_app(config_class=Config):
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(config_class)
|
||||
|
||||
app.register_blueprint(home.bp)
|
||||
app.register_blueprint(article.bp)
|
||||
app.register_blueprint(error.bp)
|
||||
app.register_blueprint(proxy.bp)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def main():
|
||||
app = create_app()
|
||||
port = int(environ.get("PORT", 8115))
|
||||
app.run(port=port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
6
src/small/const.py
Normal file
6
src/small/const.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
import os
|
||||
|
||||
|
||||
class Config:
|
||||
MEDIUM_API_URL = "https://medium.com/_/graphql"
|
||||
GITHUB_API_URL = "https://api.github.com"
|
28
src/small/models/nodes.py
Normal file
28
src/small/models/nodes.py
Normal file
|
@ -0,0 +1,28 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import List, Union
|
||||
|
||||
|
||||
@dataclass
|
||||
class Text:
|
||||
content: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Image:
|
||||
src: str
|
||||
alt: str
|
||||
width: int
|
||||
height: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class Paragraph:
|
||||
children: List[Union[Text, Image]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Page:
|
||||
title: str
|
||||
author: str
|
||||
created_at: str
|
||||
content: List[Paragraph]
|
10
src/small/models/page.py
Normal file
10
src/small/models/page.py
Normal file
|
@ -0,0 +1,10 @@
|
|||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class Page:
|
||||
title: str
|
||||
author: str
|
||||
created_at: datetime
|
||||
content: str
|
71
src/small/services/medium_client.py
Normal file
71
src/small/services/medium_client.py
Normal file
|
@ -0,0 +1,71 @@
|
|||
import requests
|
||||
|
||||
from flask import url_for
|
||||
|
||||
from small.models.nodes import Page, Paragraph, Text, Image
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class MediumClient:
|
||||
@staticmethod
|
||||
def get_post(post_id):
|
||||
url = "https://medium.com/_/graphql"
|
||||
query = (
|
||||
"""
|
||||
query {
|
||||
post(id: "%s") {
|
||||
title
|
||||
createdAt
|
||||
creator {
|
||||
name
|
||||
}
|
||||
content {
|
||||
bodyModel {
|
||||
paragraphs {
|
||||
type
|
||||
text
|
||||
metadata {
|
||||
id
|
||||
originalWidth
|
||||
originalHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
% post_id
|
||||
)
|
||||
|
||||
response = requests.post(url, json={"query": query})
|
||||
data = response.json()["data"]["post"]
|
||||
|
||||
paragraphs = []
|
||||
for p in data["content"]["bodyModel"]["paragraphs"]:
|
||||
if p["type"] == "IMG":
|
||||
children = [
|
||||
Image(
|
||||
src=url_for(
|
||||
"proxy.image",
|
||||
original_width=p["metadata"]["originalWidth"],
|
||||
id=p["metadata"]["id"],
|
||||
),
|
||||
alt=p["text"],
|
||||
width=p["metadata"]["originalWidth"],
|
||||
height=p["metadata"]["originalHeight"],
|
||||
)
|
||||
]
|
||||
else:
|
||||
children = [Text(content=p["text"])]
|
||||
paragraphs.append(Paragraph(children=children))
|
||||
|
||||
return Page(
|
||||
title=data["title"],
|
||||
author=data["creator"]["name"],
|
||||
created_at=datetime.fromtimestamp(data["createdAt"] / 1000).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
),
|
||||
content=paragraphs,
|
||||
)
|
91
src/small/static/css/style.css
Normal file
91
src/small/static/css/style.css
Normal file
|
@ -0,0 +1,91 @@
|
|||
/* General Styles */
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #1a73e8;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Header Styles */
|
||||
header {
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 2.5em;
|
||||
}
|
||||
|
||||
header nav {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
header nav a {
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
/* Footer Styles */
|
||||
footer {
|
||||
margin-top: 40px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
font-size: 0.9em;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Article Styles */
|
||||
article {
|
||||
background-color: #fff;
|
||||
padding: 30px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
article h1 {
|
||||
font-size: 2.2em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
article .meta {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
article img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
/* Error Page Styles */
|
||||
.error-container {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
background-color: #fff;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.error-container h1 {
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
.error-container ul {
|
||||
text-align: left;
|
||||
display: inline-block;
|
||||
}
|
21
src/small/templates/article.html
Normal file
21
src/small/templates/article.html
Normal file
|
@ -0,0 +1,21 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ page.title }} - Small{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ page.title }}</h1>
|
||||
<p>By {{ page.author }} on {{ page.created_at }}</p>
|
||||
<article>
|
||||
{% for paragraph in page.content %}
|
||||
<p>
|
||||
{% for child in paragraph.children %}
|
||||
{% if child.__class__.__name__ == 'Text' %}
|
||||
{{ child.content }}
|
||||
{% elif child.__class__.__name__ == 'Image' %}
|
||||
<img src="{{ child.src }}" alt="{{ child.alt }}" width="{{ child.width }}" height="{{ child.height }}">
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</p>
|
||||
{% endfor %}
|
||||
</article>
|
||||
{% endblock %}
|
25
src/small/templates/base.html
Normal file
25
src/small/templates/base.html
Normal file
|
@ -0,0 +1,25 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Small{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Small</h1>
|
||||
<nav>
|
||||
<a href="{{ url_for('home.home') }}">Home</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>Small is <a href="https://git.private.coffee/PrivateCoffee/small">open source software</a> brought to you by <a href="https://private.coffee">Private.coffee</a>.</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
23
src/small/templates/errors/404.html
Normal file
23
src/small/templates/errors/404.html
Normal file
|
@ -0,0 +1,23 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}404 Not Found - Small{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="error-container">
|
||||
<h1>404 Not Found</h1>
|
||||
<p>Sorry, the page you are looking for could not be found.</p>
|
||||
<p>This could be because:</p>
|
||||
<ul>
|
||||
<li>The URL you entered is incorrect or outdated.</li>
|
||||
<li>The article you're trying to access no longer exists on Medium.</li>
|
||||
<li>You are trying to access something that is not an article. Small currently only supports Medium articles, no user profiles or other pages.</li>
|
||||
<li>There was an error parsing the Medium URL.</li>
|
||||
</ul>
|
||||
<p>You can try:</p>
|
||||
<ul>
|
||||
<li>Double-checking the URL for any typos.</li>
|
||||
<li>Going back to <a href="{{ url_for('home.home') }}">our homepage</a> and starting over.</li>
|
||||
<li>Searching for the article directly on Medium.</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
23
src/small/templates/errors/500.html
Normal file
23
src/small/templates/errors/500.html
Normal file
|
@ -0,0 +1,23 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}500 Internal Server Error - Small{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="error-container">
|
||||
<h1>500 Internal Server Error</h1>
|
||||
<p>We're sorry, but something went wrong on our end.</p>
|
||||
<p>This could be because:</p>
|
||||
<ul>
|
||||
<li>There was an issue connecting to Medium's servers.</li>
|
||||
<li>Medium's API structure has changed, and we haven't updated yet.</li>
|
||||
<li>There's a bug in our application.</li>
|
||||
</ul>
|
||||
<p>You can try:</p>
|
||||
<ul>
|
||||
<li>Refreshing the page.</li>
|
||||
<li>Coming back later - the issue might be temporary.</li>
|
||||
<li>Going back to <a href="{{ url_for('home.home') }}">our homepage</a> and trying again.</li>
|
||||
</ul>
|
||||
<p>If the problem persists, please contact the site administrator.</p>
|
||||
</div>
|
||||
{% endblock %}
|
14
src/small/templates/home.html
Normal file
14
src/small/templates/home.html
Normal file
|
@ -0,0 +1,14 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="home-container">
|
||||
<h1>Welcome to Small</h1>
|
||||
<p><strong>Smaller than Medium</strong></p>
|
||||
<p>To use Small, simply replace "medium.com" with "{{ url_for("home.home", _external=True) }}" in any Medium article URL.</p>
|
||||
<h2>Example:</h2>
|
||||
<p>
|
||||
<strong>Original:</strong> https://medium.com/@username/article-title-123abc<br>
|
||||
<strong>Small:</strong> https://{{ url_for("home.home", _external=True) }}@username/article-title-123abc
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
5
src/small/utils/parse_article_id.py
Normal file
5
src/small/utils/parse_article_id.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
import re
|
||||
|
||||
def parse_article_id(url):
|
||||
match = re.search(r'[a-f0-9]{12}$', url)
|
||||
return match.group(0) if match else None
|
6
src/small/views/__init__.py
Normal file
6
src/small/views/__init__.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
import small.views.article as article
|
||||
import small.views.error as error
|
||||
import small.views.home as home
|
||||
import small.views.proxy as proxy
|
||||
|
||||
__all__ = ["article", "error", "home", "proxy"]
|
20
src/small/views/article.py
Normal file
20
src/small/views/article.py
Normal file
|
@ -0,0 +1,20 @@
|
|||
from flask import Blueprint, render_template, abort
|
||||
|
||||
from small.services.medium_client import MediumClient
|
||||
from small.utils.parse_article_id import parse_article_id
|
||||
|
||||
bp = Blueprint("articles", __name__)
|
||||
|
||||
|
||||
@bp.route("/<path:article_url>")
|
||||
def article(article_url):
|
||||
article_id = parse_article_id(article_url)
|
||||
if not article_id:
|
||||
abort(404)
|
||||
|
||||
try:
|
||||
page = MediumClient.get_post(article_id)
|
||||
return render_template("article.html", page=page)
|
||||
except Exception as e:
|
||||
print(f"Error fetching article: {str(e)}")
|
||||
abort(500)
|
13
src/small/views/error.py
Normal file
13
src/small/views/error.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
from flask import Blueprint, render_template
|
||||
|
||||
bp = Blueprint("errors", __name__)
|
||||
|
||||
|
||||
@bp.app_errorhandler(404)
|
||||
def not_found_error(error):
|
||||
return render_template("errors/404.html"), 404
|
||||
|
||||
|
||||
@bp.app_errorhandler(500)
|
||||
def internal_error(error):
|
||||
return render_template("errors/500.html"), 500
|
7
src/small/views/home.py
Normal file
7
src/small/views/home.py
Normal file
|
@ -0,0 +1,7 @@
|
|||
from flask import Blueprint, render_template
|
||||
|
||||
bp = Blueprint('home', __name__)
|
||||
|
||||
@bp.route('/')
|
||||
def home():
|
||||
return render_template('home.html')
|
15
src/small/views/proxy.py
Normal file
15
src/small/views/proxy.py
Normal file
|
@ -0,0 +1,15 @@
|
|||
from flask import Blueprint, abort
|
||||
|
||||
from requests import get
|
||||
|
||||
bp = Blueprint("proxy", __name__)
|
||||
|
||||
|
||||
@bp.route("/image/<int:original_width>/<id>")
|
||||
def image(original_width, id):
|
||||
try:
|
||||
response = get(f"https://miro.medium.com/max/{original_width}/{id}")
|
||||
return response.content, response.status_code, response.headers.items()
|
||||
except Exception as e:
|
||||
print(f"Error fetching image: {str(e)}")
|
||||
abort(500)
|
Loading…
Reference in a new issue