diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..148ff3f --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + Version 2, December 2004 + + Copyright (C) 2023 beucismis + + Everyone is permitted to copy and distribute verbatim or modified + copies of this license document, and changing it is allowed as long + as the name is changed. + + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. You just DO WHAT THE FUCK YOU WANT TO. diff --git a/README.md b/README.md index e3a183e..6a81a56 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,10 @@ # ozgursozluk Free alternative simple ekşi sözlük front-end + +# Installing +``` +git clone https://github.com/beucismis/ozgursozluk +cd ozgursozluk/ +pip3 install -r requirements.txt +python3 serve.py +``` diff --git a/ozgursozluk/__init__.py b/ozgursozluk/__init__.py new file mode 100644 index 0000000..e28014e --- /dev/null +++ b/ozgursozluk/__init__.py @@ -0,0 +1,13 @@ +import flask + + +__version__ = "0.1" +__author__ = "beucismis" +__source__ = "https://github.com/beucismis/ozgursozluk" +__description__ = "free alternative simple ekşi sözlük front-end" + +app = flask.Flask(__name__) +app.config.from_object("ozgursozluk.config") + + +import ozgursozluk.views diff --git a/ozgursozluk/api.py b/ozgursozluk/api.py new file mode 100644 index 0000000..201703b --- /dev/null +++ b/ozgursozluk/api.py @@ -0,0 +1,138 @@ +from typing import Iterator +from dataclasses import dataclass + +import flask +import requests +from bs4 import BeautifulSoup +from fake_useragent import UserAgent + + +from ozgursozluk.config import DEFAULT_EKSI_BASE_URL + + +CHARMAP = { + "ç": "c", + "ı": "i", + "ö": "o", + "ş": "s", + "ü": "u", +} + + +@dataclass +class Agenda: + title: str + views: str + pinned: bool + permalink: str + + +@dataclass +class Entry: + id: str + content: str + author: str + datetime: str + permalink: str + + +@dataclass +class Topic: + id: str + title: str + pagecount: int + permalink: str + entrys: Iterator[Entry] + + def title_id(self) -> str: + return _unicode_tr(f"{self.title}--{self.id}".replace(" ", "-")) + + +class Eksi: + def __init__(self, base_url: str = DEFAULT_EKSI_BASE_URL) -> None: + self.base_url = base_url + self.headers = {"User-Agent": UserAgent().random} + + def _get(self, endpoint: str = "/", params: dict = {}) -> dict: + response = requests.get( + f"{self.base_url}{endpoint}", params=params, headers=self.headers + ) + + if response.status_code != 200: + flask.abort(response.status_code) + + return response + + def _get_entrys(self, soup: BeautifulSoup) -> Iterator[Entry]: + entry_items = soup.find_all("li", id="entry-item") + + for entry in entry_items: + a = entry.find("a", class_="entry-date permalink", href=True) + yield Entry( + entry.attrs["data-id"], + entry.find("div", class_="content"), + entry.find("a", class_="entry-author").text, + a.text, + self.base_url + a["href"], + ) + + def search_topic(self, q: str) -> Topic: + response = self._get("/", {"q": q}) + soup = BeautifulSoup(response.content, "html.parser") + h1 = soup.find("h1", id="title") + pager = soup.find("div", class_="pager") + + return Topic( + h1.attrs["data-id"], + h1.attrs["data-title"], + int(pager.attrs["data-pagecount"]) if pager is not None else 0, + self.base_url + h1.find("a", href=True)["href"], + self._get_entrys(soup), + ) + + def get_topic(self, title: str, page: int = 1) -> Topic: + response = self._get(f"/{title}", {"p": page}) + soup = BeautifulSoup(response.content, "html.parser") + h1 = soup.find("h1", id="title") + pager = soup.find("div", class_="pager") + + return Topic( + h1.attrs["data-id"], + h1.attrs["data-title"], + int(pager.attrs["data-pagecount"]) if pager is not None else 0, + self.base_url + h1.find("a", href=True)["href"], + self._get_entrys(soup), + ) + + def get_entry(self, id: int) -> Topic: + response = self._get(f"/entry/{id}") + soup = BeautifulSoup(response.content, "html.parser") + h1 = soup.find("h1", id="title") + + return Topic( + h1.attrs["data-id"], + h1.attrs["data-title"], + 0, + self.base_url + h1.find("a", href=True)["href"], + self._get_entrys(soup), + ) + + def get_agenda(self) -> Iterator[Agenda]: + response = self._get() + soup = BeautifulSoup(response.content, "html.parser") + topic_list = soup.find("ul", class_="topic-list").find_all("a", href=True) + + for topic in topic_list: + yield Agenda( + topic.contents[0], + "" if len(topic.contents) < 2 else topic.contents[1], + topic.has_attr("class"), + topic["href"], + ) + + +def _unicode_tr(text: str) -> str: + for key, value in CHARMAP.items(): + text = text.replace(key, value) + + return text diff --git a/ozgursozluk/config.py b/ozgursozluk/config.py new file mode 100644 index 0000000..2cdbf2e --- /dev/null +++ b/ozgursozluk/config.py @@ -0,0 +1,7 @@ +HOST = "0.0.0.0" +PORT = 3131 +DEBUG = False +SECRET_KEY = "Some secret string here" + +DEFAULT_THEME = "light" +DEFAULT_EKSI_BASE_URL = "https://eksisozluk.com" diff --git a/ozgursozluk/static/favicon.png b/ozgursozluk/static/favicon.png new file mode 100644 index 0000000..3a42df4 Binary files /dev/null and b/ozgursozluk/static/favicon.png differ diff --git a/ozgursozluk/static/style.css b/ozgursozluk/static/style.css new file mode 100644 index 0000000..7c1da56 --- /dev/null +++ b/ozgursozluk/static/style.css @@ -0,0 +1,174 @@ +:root { + --text-color: #000000; + --title-color: #81C14B; + --background-color: #FFFFFF; + --entry-link-color: #81C14B; + --entry-background-color: #EEEEEE; + --form-text-color: #000000; + --form-border-color: #8F8F9D; + --form-input-background-color: #FFFFFF; + --form-button-background-color: #E9E9ED; +} + +.dark { + --text-color: #E8E6E3; + --title-color: #81C14B; + --background-color: #181A1B; + --entry-link-color: #81C14B; + --entry-background-color: #222426; + --form-text-color: #D8D4CF; + --form-border-color: #373C3E; + --form-input-background-color: #232627; + --form-button-background-color: #232627; +} + +body { + font-family: sans-serif; + color: var(--text-color); + background-color: var(--background-color); +} + +a { + color: var(--text-color); +} + +header div { + top: 0; + right: 0; + position: absolute; +} + +header a { + display: inline-block; + padding: 0.5rem 0.2rem; +} + +main { + width: 80%; + margin: 0 auto; +} + +nav { + width: 50%; + margin: 0 auto; + text-align: center; +} + +nav a { + padding: 0.5rem; + display: inline-block; +} + +.title { + text-align: center; + font-weight: normal; +} + +h1 a { + color: black; + text-decoration: none; +} + +small { + opacity: 0.5; + color: var(--text-color); +} + +.ozgur { + color: var(--text-color); +} + +.sozluk { + color: var(--title-color); +} + +.description { + text-align: center; +} + +.topic { + width: 70%; + margin: 0 auto; +} + +.topic > .entry { + margin: 1rem; + padding: 0.5rem; + background-color: var(--entry-background-color); +} + +.content { + margin: 0; + padding: 0; +} + +.content .b, .content .url { + text-decoration: none; + color: var(--entry-link-color); +} + +.entry-datetime { + opacity: 0.5; + text-decoration: none; +} + +.info { + margin: 1rem; + display: flex; + padding-top: 1rem; + align-items: center; + justify-content: space-between; +} + +.info > div p { + margin: 0; + padding: 0; +} + +.settings { + width: 35%; + margin: 0 auto; + padding-top: 3rem; +} + +form { + padding-top: 0.5rem; +} + +form .settings-group { + display: flex; + align-items: center; + padding-bottom: 0.5rem; +} + +form .settings-group > :last-child { + margin-left: auto; +} + +form input[type=text], form select, form button { + padding: 0.5rem; + color: var(--form-text-color); + border: 1px solid var(--form-border-color); +} + +form input[type=text] { + background-color: var(--form-input-background-color); +} + +form select, form button { + background-color: var(--form-button-background-color); +} + +@media only screen and (max-width: 768px) { + main, nav, .topic, .settings { + width: 100%; + } + + nav, .title, .description { + text-align: left; + } + + form input[type=text], form select { + width: 50%; + } +} diff --git a/ozgursozluk/templates/404.html b/ozgursozluk/templates/404.html new file mode 100644 index 0000000..aee7892 --- /dev/null +++ b/ozgursozluk/templates/404.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% block title %}özgürsözlük - 404{% endblock %} +{% block main %} + +
+

Page not found!

+
+{% endblock %} diff --git a/ozgursozluk/templates/base.html b/ozgursozluk/templates/base.html new file mode 100644 index 0000000..28a9e6b --- /dev/null +++ b/ozgursozluk/templates/base.html @@ -0,0 +1,27 @@ + + + + + + + + {% block title %}{% endblock %} + + +
+
+ settings + source code +
+
+
+

+ + özgürsözlük + +

+

{{ description }}

+ {% block main %}{% endblock %} +
+ + diff --git a/ozgursozluk/templates/index.html b/ozgursozluk/templates/index.html new file mode 100644 index 0000000..6b9c60f --- /dev/null +++ b/ozgursozluk/templates/index.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}özgürsözlük - {{ description }}{% endblock %} +{% block main %} + +
+
+

gündem

+
+ {% for topic in agenda %} +
+
+
+ + {{ topic.title }} + +
+
+ {% if topic.pinned %} + pinned + {% endif %} + {{ topic.views|safe }} +
+
+
+ {% endfor %} +
+{% endblock %} diff --git a/ozgursozluk/templates/navigation.html b/ozgursozluk/templates/navigation.html new file mode 100644 index 0000000..d5460cb --- /dev/null +++ b/ozgursozluk/templates/navigation.html @@ -0,0 +1,4 @@ +
+ + +
diff --git a/ozgursozluk/templates/settings.html b/ozgursozluk/templates/settings.html new file mode 100644 index 0000000..331943c --- /dev/null +++ b/ozgursozluk/templates/settings.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} +{% block title %}özgürsözlük - settings{% endblock %} +{% block main %} +
+
+
+ theme: + +
+
+ ekşi base url: + +
+
+ +
+
+
v{{ version }}
+
+{% endblock %} diff --git a/ozgursozluk/templates/topic.html b/ozgursozluk/templates/topic.html new file mode 100644 index 0000000..5869ee0 --- /dev/null +++ b/ozgursozluk/templates/topic.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% block title %}özgürsözlük - {{ topic.title }}{% endblock %} +{% block main %} + +
+
+

{{ topic.title }}

+
+ {% if p > 1 %} + previous + {% if p < topic.pagecount %}/{% endif %} + {% endif %} + {% if p < topic.pagecount %} + next + {% endif %} + {% if topic.pagecount > 1 %} + - + last page + {% endif %} +
+
+ {% for entry in topic.entrys %} +
+ {{ entry.content|safe }} +
+
{{ entry.datetime }}
+
+ {% endfor %} +
+{% endblock %} diff --git a/ozgursozluk/views.py b/ozgursozluk/views.py new file mode 100644 index 0000000..d598c61 --- /dev/null +++ b/ozgursozluk/views.py @@ -0,0 +1,82 @@ +import flask + +import ozgursozluk +from ozgursozluk.api import Eksi +from ozgursozluk.config import DEFAULT_THEME, DEFAULT_EKSI_BASE_URL + + +eksi = Eksi() + + +@ozgursozluk.app.context_processor +def context_processor(): + return dict( + version=ozgursozluk.__version__, + source=ozgursozluk.__source__, + description=ozgursozluk.__description__, + ) + + +@ozgursozluk.app.route("/", methods=["GET", "POST"]) +def index(): + q = flask.request.args.get("q", default=None, type=str) + + if q is not None: + return flask.redirect(flask.url_for("search", q=q)) + + if flask.request.method == "POST": + return flask.redirect(flask.url_for("search", q=flask.request.form["q"])) + + eksi.base_url = flask.request.cookies.get("eksi_base_url", DEFAULT_EKSI_BASE_URL) + agenda = eksi.get_agenda() + + return flask.render_template("index.html", agenda=agenda) + + +@ozgursozluk.app.route("/search/") +def search(q: str): + eksi.base_url = flask.request.cookies.get("eksi_base_url", DEFAULT_EKSI_BASE_URL) + + return flask.render_template("topic.html", topic=eksi.search_topic(q), p=1) + + +@ozgursozluk.app.route("/") +def topic(title: str): + p = flask.request.args.get("p", default=1, type=int) + eksi.base_url = flask.request.cookies.get("eksi_base_url", DEFAULT_EKSI_BASE_URL) + result = eksi.get_topic(title, p) + + return flask.render_template("topic.html", topic=result, p=p) + + +@ozgursozluk.app.route("/entry/<int:id>") +def entry(id: int): + eksi.base_url = flask.request.cookies.get("eksi_base_url", DEFAULT_EKSI_BASE_URL) + + return flask.render_template("topic.html", topic=eksi.get_entry(id), p=1) + + +@ozgursozluk.app.route("/settings", methods=["GET", "POST"]) +def settings(): + theme = flask.request.cookies.get("theme", DEFAULT_THEME) + eksi_base_url = flask.request.cookies.get("eksi_base_url", DEFAULT_EKSI_BASE_URL) + + if flask.request.method == "POST": + response = flask.make_response( + flask.render_template( + "settings.html", theme=theme, eksi_base_url=eksi_base_url + ) + ) + response.set_cookie("theme", flask.request.form["theme"]) + response.set_cookie("eksi_base_url", flask.request.form["eksi_base_url"]) + + return response + + return flask.render_template( + "settings.html", theme=theme, eksi_base_url=eksi_base_url + ) + + +@ozgursozluk.app.errorhandler(404) +def page_not_found(e): + return flask.render_template("404.html"), 404 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..927f13b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +flask==2.2.3 +requests==2.28.2 +fake-useragent==1.1.3 +beautifulsoup4==4.12.2 diff --git a/serve.py b/serve.py new file mode 100755 index 0000000..9ceb743 --- /dev/null +++ b/serve.py @@ -0,0 +1,8 @@ +#!/usr/bin/python3 + +from ozgursozluk import app +from ozgursozluk.config import HOST, PORT, DEBUG + + +if __name__ == "__main__": + app.run(host=HOST, port=PORT, debug=DEBUG) diff --git a/test.py b/test.py new file mode 100644 index 0000000..d273831 --- /dev/null +++ b/test.py @@ -0,0 +1,22 @@ +import unittest + +from ozgursozluk.api import Eksi + + +eksi = Eksi() +topic = eksi.search_topic("linux") + + +class TestTopic(unittest.TestCase): + def test_id(self): + self.assertEqual(topic.id, "32084") + + def test_title(self): + self.assertEqual(topic.title, "linux") + + def test_permalink(self): + self.assertEqual(topic.permalink, "https://eksisozluk2023.com/linux--32084") + + +if __name__ == "__main__": + unittest.main()