commit b7d7b8d7594be142d3ea007189ec2a7827f7d9ea from: Aleksey Ryndin date: Tue Jan 30 11:35:57 2024 UTC First working implementation commit - 50b625959a82bace149a21113b1a9fda77aab8f3 commit + b7d7b8d7594be142d3ea007189ec2a7827f7d9ea blob - e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 blob + 0bf7c0b166be42fb3f3145fc4e047a15a4f5ba5c --- README +++ README @@ -0,0 +1,3 @@ +ReRU: Посты русскоязычного Fediverse, собранные из ботов-ретрансляторов + +Поднятый экземпляр сервера: https://reru.any-key.press/ blob - /dev/null blob + e6a66f947ebf2f98766637b177d50a1cd7a7e6f5 (mode 644) --- /dev/null +++ LICENSE @@ -0,0 +1,5 @@ +Copyright (C) 2024 by @continue@honk.any-key.press + +Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. blob - /dev/null blob + 5b3753660c68b54f658670ec0a07c5e0375418cf (mode 644) Binary files /dev/null and data/favicon.ico differ blob - /dev/null blob + b718b1198ca08478fd73cfe29e629232adbee5c2 (mode 644) --- /dev/null +++ data/index.html.footer @@ -0,0 +1,6 @@ + + + + blob - /dev/null blob + 916d1012610a5bb8d89d67d0268dac8758241b42 (mode 644) --- /dev/null +++ data/index.html.header @@ -0,0 +1,16 @@ + + + + + + + + + ReRU: Посты русскоязычного Fediverse, собранные из ботов-ретрансляторов + + +
+ ReRU: Посты русскоязычного Fediverse, собранные из ботов-ретрансляторов +
+
+ blob - /dev/null blob + a44392627831d28bdea68f8e7d2e6260fc8ea0fb (mode 644) --- /dev/null +++ data/style.css @@ -0,0 +1,7 @@ +table { + border-collapse: collapse; + width: 100%; +} +tr { + border: 1px solid; +} blob - /dev/null blob + 613715b01b1faedc454b6450c1489cfbeafebba9 (mode 644) --- /dev/null +++ reru.py @@ -0,0 +1,213 @@ +import json + +from argparse import ArgumentParser +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime +from http import HTTPStatus +from http.server import HTTPServer, BaseHTTPRequestHandler +from urllib.request import urlopen +import xml.etree.ElementTree as ET + + +_FROM = [ + ("lor.sh", "ru"), + ("mastodon.ml", "rf"), + ("mastodon.social", "russian_mastodon"), + ("lor.sh", "rur"), +] + + +def _lookup_account_id(host, name): + with urlopen(f"https://{host}/api/v1/accounts/lookup?acct={name}") as f: + return json.loads(f.read().decode("utf-8"))["id"] + + +def _get_accounts_statuses(host, account_id): + with urlopen(f"https://{host}/api/v1/accounts/{account_id}/statuses") as f: + return json.loads(f.read().decode("utf-8")) + + +def _worker(host, name): + feeds = [] + for status in _get_accounts_statuses(host, _lookup_account_id(host, name)): + reblog = status.get("reblog") + if not reblog: + continue + feeds.append(reblog) + return feeds + + +def _harvest_reblogs(executor): + already = set() + feeds = [] + + futures = [executor.submit(_worker, *args) for args in _FROM] + for f in as_completed(futures): + for reblog in f.result(): + if reblog["url"] in already: + continue + already.add(reblog["url"]) + feeds.append(reblog) + + feeds.sort(key=lambda status: datetime.fromisoformat(status["created_at"]), reverse=True) + return feeds + + +def _img(src, alt, **kwargs): + img = ET.Element("img") + img.attrib.update(src=src, alt=alt, **kwargs) + return ET.tostring(img, encoding="unicode") + + +def _make_table(feeds): + table = " \n" + for status in feeds: + avatar_static = (status.get("account") or {}).get("avatar_static") + avatar = "" + if avatar_static: + avatar = _img(src=avatar_static, alt="avatar", height="48", width="48") + user_name = "
".join([ + (status.get("account") or {}).get("display_name", ""), + (status.get("account") or {}).get("acct", ""), + ]) + table += ( + " \n" + f" \n" + f" \n" + f" \n" + f" \n" + " \n" + ) + + poll = status.get("poll") + if poll: + table += ( + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + ) + + media_attachments = status.get("media_attachments") + for attach in media_attachments or []: + url = attach.get("remote_url") or attach.get("url") + if not url: + continue + table += ( + " \n" + " \n" + " \n" + " \n" + f' \n" + " \n" + ) + table += "
{avatar}{user_name}{status.get('content') or ''}
\n" + "
    " + ) + table += "".join([ + f'
  • {option.get("title", "")} ({option.get("votes_count", 0)})
  • ' + for option in (poll.get("options") or []) + ]) + table += ( + "
\n" + "
' + ) + if attach.get("type") == "image": + table += _img(src=url, alt=attach.get("description") or "", width="640") + else: + table += url + table += ( + "
\n" + return table + + +class _HTTPServer(HTTPServer): + def __init__( + self, + *args, + executor=None, + header_file_path=None, + footer_file_path=None, + icon_file_path=None, + css_file_path=None, + **kwargs + ): + super().__init__(*args, **kwargs) + self.harvester_executor = executor + with open(header_file_path, "rb") as f: + self.header_file_bytes = f.read() + with open(footer_file_path, "rb") as f: + self.footer_file_bytes = f.read() + if icon_file_path: + with open(icon_file_path, "rb") as f: + self.icon_file_bytes = f.read() + else: + self.icon_file_bytes = None + if css_file_path: + with open(css_file_path, "rb") as f: + self.css_file_bytes = f.read() + else: + self.css_file_bytes = None + + +class _RequestHandler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path in {"/index.html", "/index.htm", "/index", "/"}: + self.send_response(HTTPStatus.OK) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write(self.server.header_file_bytes) + self.wfile.write(_make_table(_harvest_reblogs(self.server.harvester_executor)).encode()) + self.wfile.write(self.server.footer_file_bytes) + return + + if self.path == "/favicon.ico" and self.server.icon_file_bytes: + self.send_response(HTTPStatus.OK) + self.send_header("Content-type", "image/x-icon") + self.end_headers() + self.wfile.write(self.server.icon_file_bytes) + return + + if self.path == "/style.css" and self.server.css_file_bytes: + self.send_response(HTTPStatus.OK) + self.send_header("Content-type", "text/css") + self.end_headers() + self.wfile.write(self.server.css_file_bytes) + return + + self.send_error(HTTPStatus.NOT_FOUND, "File not found") + return + + +def _main(): + parser = ArgumentParser() + parser.add_argument("--address", default="127.0.0.1", help="bind to this address (default: %(default)s)") + parser.add_argument("--port", default=8000, type=int, help="bind to this port (default: %(default)s)") + parser.add_argument("--header", required=True, help="path to `index.html.header`") + parser.add_argument("--footer", required=True, help="path to `index.html.footer`") + parser.add_argument("--icon", help="path to `favicon.ico`") + parser.add_argument("--css", help="path to `style.css`") + args = parser.parse_args() + + with ThreadPoolExecutor(max_workers=len(_FROM)) as executor: + with _HTTPServer( + (args.address, args.port), + _RequestHandler, + executor=executor, + header_file_path=args.header, + footer_file_path=args.footer, + icon_file_path=args.icon, + css_file_path=args.css, + ) as httpd: + sock_host, sock_port = httpd.socket.getsockname()[:2] + print(f"HTTP server started ({sock_host}:{sock_port})...") + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\nKeyboard interrupt received, exiting.") + + +if __name__ == '__main__': + _main()