commit 675f5195fe8b252c0c64fbe8bc3e1bd6430363cf Author: Kumi Date: Thu Sep 19 09:14:05 2024 +0200 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..738528c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +venv/ +.venv/ +__pycache__/ +*.pyc +/dist/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3788438 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2024 Private.coffee Team + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4f07b47 --- /dev/null +++ b/README.md @@ -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. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b33bff7 --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/src/small/__init__.py b/src/small/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/small/app.py b/src/small/app.py new file mode 100644 index 0000000..f11b3ce --- /dev/null +++ b/src/small/app.py @@ -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() diff --git a/src/small/const.py b/src/small/const.py new file mode 100644 index 0000000..1aa2d06 --- /dev/null +++ b/src/small/const.py @@ -0,0 +1,6 @@ +import os + + +class Config: + MEDIUM_API_URL = "https://medium.com/_/graphql" + GITHUB_API_URL = "https://api.github.com" diff --git a/src/small/models/nodes.py b/src/small/models/nodes.py new file mode 100644 index 0000000..ce84463 --- /dev/null +++ b/src/small/models/nodes.py @@ -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] diff --git a/src/small/models/page.py b/src/small/models/page.py new file mode 100644 index 0000000..359f93e --- /dev/null +++ b/src/small/models/page.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass +from datetime import datetime + + +@dataclass +class Page: + title: str + author: str + created_at: datetime + content: str diff --git a/src/small/services/medium_client.py b/src/small/services/medium_client.py new file mode 100644 index 0000000..493abc1 --- /dev/null +++ b/src/small/services/medium_client.py @@ -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, + ) diff --git a/src/small/static/css/style.css b/src/small/static/css/style.css new file mode 100644 index 0000000..f0e4aa5 --- /dev/null +++ b/src/small/static/css/style.css @@ -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; +} \ No newline at end of file diff --git a/src/small/templates/article.html b/src/small/templates/article.html new file mode 100644 index 0000000..c374ca7 --- /dev/null +++ b/src/small/templates/article.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} + +{% block title %}{{ page.title }} - Small{% endblock %} + +{% block content %} +

{{ page.title }}

+

By {{ page.author }} on {{ page.created_at }}

+
+ {% for paragraph in page.content %} +

+ {% for child in paragraph.children %} + {% if child.__class__.__name__ == 'Text' %} + {{ child.content }} + {% elif child.__class__.__name__ == 'Image' %} + {{ child.alt }} + {% endif %} + {% endfor %} +

+ {% endfor %} +
+{% endblock %} diff --git a/src/small/templates/base.html b/src/small/templates/base.html new file mode 100644 index 0000000..eceea3b --- /dev/null +++ b/src/small/templates/base.html @@ -0,0 +1,25 @@ + + + + + + {% block title %}Small{% endblock %} + + + +
+

Small

+ +
+ +
+ {% block content %}{% endblock %} +
+ + + + diff --git a/src/small/templates/errors/404.html b/src/small/templates/errors/404.html new file mode 100644 index 0000000..51707b4 --- /dev/null +++ b/src/small/templates/errors/404.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} + +{% block title %}404 Not Found - Small{% endblock %} + +{% block content %} +
+

404 Not Found

+

Sorry, the page you are looking for could not be found.

+

This could be because:

+
    +
  • The URL you entered is incorrect or outdated.
  • +
  • The article you're trying to access no longer exists on Medium.
  • +
  • You are trying to access something that is not an article. Small currently only supports Medium articles, no user profiles or other pages.
  • +
  • There was an error parsing the Medium URL.
  • +
+

You can try:

+
    +
  • Double-checking the URL for any typos.
  • +
  • Going back to our homepage and starting over.
  • +
  • Searching for the article directly on Medium.
  • +
+
+{% endblock %} diff --git a/src/small/templates/errors/500.html b/src/small/templates/errors/500.html new file mode 100644 index 0000000..41e8cb1 --- /dev/null +++ b/src/small/templates/errors/500.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} + +{% block title %}500 Internal Server Error - Small{% endblock %} + +{% block content %} +
+

500 Internal Server Error

+

We're sorry, but something went wrong on our end.

+

This could be because:

+
    +
  • There was an issue connecting to Medium's servers.
  • +
  • Medium's API structure has changed, and we haven't updated yet.
  • +
  • There's a bug in our application.
  • +
+

You can try:

+
    +
  • Refreshing the page.
  • +
  • Coming back later - the issue might be temporary.
  • +
  • Going back to our homepage and trying again.
  • +
+

If the problem persists, please contact the site administrator.

+
+{% endblock %} diff --git a/src/small/templates/home.html b/src/small/templates/home.html new file mode 100644 index 0000000..6d50522 --- /dev/null +++ b/src/small/templates/home.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} + +{% block content %} +
+

Welcome to Small

+

Smaller than Medium

+

To use Small, simply replace "medium.com" with "{{ url_for("home.home", _external=True) }}" in any Medium article URL.

+

Example:

+

+ Original: https://medium.com/@username/article-title-123abc
+ Small: https://{{ url_for("home.home", _external=True) }}@username/article-title-123abc +

+
+{% endblock %} diff --git a/src/small/utils/parse_article_id.py b/src/small/utils/parse_article_id.py new file mode 100644 index 0000000..a469062 --- /dev/null +++ b/src/small/utils/parse_article_id.py @@ -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 diff --git a/src/small/views/__init__.py b/src/small/views/__init__.py new file mode 100644 index 0000000..8d3bc7d --- /dev/null +++ b/src/small/views/__init__.py @@ -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"] \ No newline at end of file diff --git a/src/small/views/article.py b/src/small/views/article.py new file mode 100644 index 0000000..f52aa08 --- /dev/null +++ b/src/small/views/article.py @@ -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("/") +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) diff --git a/src/small/views/error.py b/src/small/views/error.py new file mode 100644 index 0000000..9248947 --- /dev/null +++ b/src/small/views/error.py @@ -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 diff --git a/src/small/views/home.py b/src/small/views/home.py new file mode 100644 index 0000000..93526b1 --- /dev/null +++ b/src/small/views/home.py @@ -0,0 +1,7 @@ +from flask import Blueprint, render_template + +bp = Blueprint('home', __name__) + +@bp.route('/') +def home(): + return render_template('home.html') diff --git a/src/small/views/proxy.py b/src/small/views/proxy.py new file mode 100644 index 0000000..6bf2fb9 --- /dev/null +++ b/src/small/views/proxy.py @@ -0,0 +1,15 @@ +from flask import Blueprint, abort + +from requests import get + +bp = Blueprint("proxy", __name__) + + +@bp.route("/image//") +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)