Initial commit
commit
77f1d100bf
|
@ -0,0 +1,4 @@
|
|||
rdrama.json
|
||||
.venv
|
||||
__pycache__
|
||||
.vscode
|
|
@ -0,0 +1,32 @@
|
|||
## Makeshift reddit aggregator using pullpush.io and Jinja2
|
||||
|
||||
Notable problems:
|
||||
* Using just pullpush.io means that the score counter usually sits at 1.
|
||||
* No support for BlueSky
|
||||
* Code needs comments and refactoring
|
||||
* I got lazy towards the end
|
||||
* Recursive quotation breaks if any of the comments have markdown quotes
|
||||
* It seems like it's culling some parent comments, but I haven't bothered to check.
|
||||
|
||||
What this does well compared to the others (at time of writing):
|
||||
* Recursive quotations
|
||||
* (presumably) Rendering markdown in Jinja to make extensions easier
|
||||
* (mixed) Uses async for faster processing
|
||||
* This would be more useful when integrated with BlueSky or if rDrama didn't have a rate limit of 1/s for submitting comments.
|
||||
* It makes it harder for people not familiar with async to use/reuse code
|
||||
|
||||
## Installing
|
||||
```
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate # (or .venv/bin/activate.fish if using fish)
|
||||
python3 -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Running
|
||||
```
|
||||
source .venv/bin/activate # (or .venv/bin/activate.fish if using fish)
|
||||
python3 src/main.py
|
||||
```
|
||||
|
||||
## Example
|
||||
https://rdrama.net/post/280889/testing-yet-another-bardfinn-digest-20240702
|
|
@ -0,0 +1,8 @@
|
|||
## Async HTTP
|
||||
aiohttp
|
||||
## Template rendering
|
||||
Jinja2
|
||||
## BlueSky
|
||||
# atproto
|
||||
## Reddit
|
||||
# asyncpraw
|
|
@ -0,0 +1,99 @@
|
|||
from jinja2 import Environment, FileSystemLoader
|
||||
from typing import List, Tuple
|
||||
import pullpush
|
||||
import asyncio
|
||||
from datetime import datetime, UTC
|
||||
from rdrama import RDrama
|
||||
import json
|
||||
import logging
|
||||
|
||||
jinja_env = Environment(loader=FileSystemLoader("src/templates"), enable_async=True)
|
||||
with open('rdrama.json') as fi:
|
||||
rdrama_params = json.load(fi)
|
||||
rdrama = RDrama(rdrama_params['client_id'], rdrama_params['token'], base_url="https://rdrama.net")
|
||||
|
||||
class DigestThread:
|
||||
time_format = '%Y-%m-%d %H:%M %Z'
|
||||
|
||||
def __init__(self, user: str, since: int, until: int, depth: int):
|
||||
self.user = user
|
||||
self.since = since
|
||||
self.until = until
|
||||
self.depth = depth
|
||||
self.comments = None
|
||||
self.submissions = None
|
||||
self.parent_submissions = None
|
||||
assert depth > 0
|
||||
|
||||
async def render_thread(self) -> str:
|
||||
await self._fetch_if_not_present()
|
||||
|
||||
metadata = {
|
||||
'since': format(datetime.fromtimestamp(self.since, UTC), DigestThread.time_format),
|
||||
'until': format(datetime.fromtimestamp(self.until, UTC), DigestThread.time_format),
|
||||
}
|
||||
return await jinja_env.get_template("digest_header.jinja").render_async(
|
||||
{"comments": self.comments, "submissions": self.submissions, "metadata": metadata}
|
||||
)
|
||||
|
||||
async def render_comments(self) -> List[str]:
|
||||
await self._fetch_if_not_present()
|
||||
template = jinja_env.get_template("digest_comment.jinja")
|
||||
async def render(comment):
|
||||
parent_submission = self.parent_submissions[pullpush.get_id_from_permalink(comment['permalink'])]
|
||||
return await template.render_async(comment=comment, parent_submission=parent_submission)
|
||||
|
||||
return list(
|
||||
await asyncio.gather(
|
||||
*(render(comment) for comment in self.comments)
|
||||
)
|
||||
)
|
||||
|
||||
async def render_submissions(self) -> List[str]:
|
||||
await self._fetch_if_not_present()
|
||||
template = jinja_env.get_template("digest_submission.jinja")
|
||||
return list(
|
||||
await asyncio.gather(
|
||||
*(
|
||||
template.render_async(submission=submission)
|
||||
for submission in self.submissions
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
async def _fetch_if_not_present(self) -> Tuple[List[dict], List[dict]]:
|
||||
# TODO: fix spaghetti with _fetch_comments being removed
|
||||
return await asyncio.gather(self._fetch_submissions(), self._fetch_comment_parent_submissions())
|
||||
|
||||
async def _fetch_comments(self) -> List[dict]:
|
||||
if self.comments is None:
|
||||
self.comments = await pullpush.get_comments_from_user_recursive(
|
||||
self.user, self.since, self.until, self.depth
|
||||
)
|
||||
return self.comments
|
||||
|
||||
async def _fetch_submissions(self) -> List[dict]:
|
||||
if self.submissions is None:
|
||||
self.submissions = await pullpush.get_submissions_from_user(
|
||||
self.user, self.since, self.until
|
||||
)
|
||||
return self.submissions
|
||||
|
||||
async def _fetch_comment_parent_submissions(self) -> List[dict]:
|
||||
await self._fetch_comments()
|
||||
if self.parent_submissions is None:
|
||||
self.parent_submissions = await pullpush.get_submissions_by_ids(
|
||||
(pullpush.get_id_from_permalink(comment['permalink']) for comment in self.comments))
|
||||
return self.parent_submissions
|
||||
|
||||
async def publish(self, title: str, hole: str):
|
||||
submission_body = await self.render_thread()
|
||||
logging.info(f'Creating submission...')
|
||||
submission_id = await rdrama.make_submission(title, None, submission_body, hole)
|
||||
submissions, comments = await asyncio.gather(self.render_submissions(), self.render_comments())
|
||||
submission_tasks = [asyncio.create_task(rdrama.make_comment(submission_id, submission)) for submission in submissions]
|
||||
comment_tasks = [asyncio.create_task(rdrama.make_comment(submission_id, comment)) for comment in comments]
|
||||
logging.info(f'Adding comments...')
|
||||
await asyncio.gather(*(submission_tasks + comment_tasks))
|
||||
logging.info(f'Publishing done!')
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import logging
|
||||
from typing import Union
|
||||
from digest import DigestThread
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import datetime, UTC
|
||||
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
|
||||
async def main(user: str, since: int, until: int, depth: int, hole: Union[str, None]):
|
||||
thread = DigestThread(user, since, until, depth)
|
||||
|
||||
print(await thread.render_thread())
|
||||
for submission in await thread.render_submissions():
|
||||
print(submission)
|
||||
for comment in await thread.render_comments():
|
||||
print(comment)
|
||||
|
||||
if input("Submit?") == "Y":
|
||||
since = format(datetime.fromtimestamp(since, UTC), DigestThread.time_format)
|
||||
until = format(datetime.fromtimestamp(until, UTC), DigestThread.time_format)
|
||||
submission_title = (
|
||||
f"Testing Yet Another Bardfinn Digest: {since} through {until}"
|
||||
)
|
||||
await thread.publish(submission_title, hole)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.new_event_loop().run_until_complete(
|
||||
main("bardfinn", time.time() - (60 * 60 * 24), time.time(), 4, None)
|
||||
)
|
|
@ -0,0 +1,84 @@
|
|||
from typing import Iterable, List, Dict, Generator
|
||||
import asyncio
|
||||
import logging
|
||||
import aiohttp
|
||||
import re
|
||||
|
||||
pullpush_semaphore = asyncio.Semaphore(3)
|
||||
|
||||
|
||||
async def get_comments_from_user(user: str, since: int, until: int) -> List[dict]:
|
||||
since = int(since)
|
||||
until = int(until)
|
||||
return await pullpush_fetch(
|
||||
"comment", {"author": user, "since": since, "until": until}
|
||||
)
|
||||
|
||||
|
||||
async def get_submissions_from_user(user: str, since: int, until: int) -> List[dict]:
|
||||
since = int(since)
|
||||
until = int(until)
|
||||
return await pullpush_fetch(
|
||||
"submission", {"author": user, "since": since, "until": until}
|
||||
)
|
||||
|
||||
|
||||
async def get_comments_by_ids(ids: Iterable[int]) -> Dict[str, dict]:
|
||||
ids = filter(lambda id: not id.startswith("t3"), ids)
|
||||
ids = set(ids)
|
||||
response = await pullpush_fetch("comment", {"ids": ",".join(ids)})
|
||||
return dict(((comment["id"], comment) for comment in response))
|
||||
|
||||
|
||||
async def get_submissions_by_ids(ids: Iterable[int]) -> Dict[str, dict]:
|
||||
response = await pullpush_fetch("submission", {"ids": ",".join(ids)})
|
||||
ids = set(ids)
|
||||
return dict(((submission["id"], submission) for submission in response))
|
||||
|
||||
|
||||
permalink_id_re = re.compile(r"/r/\w+/comments/(\w+?)/")
|
||||
|
||||
|
||||
def get_id_from_permalink(permalink: str):
|
||||
return permalink_id_re.search(permalink).group(1)
|
||||
|
||||
|
||||
async def get_comments_by_ids_recursive(
|
||||
ids: Iterable[str], depth: int
|
||||
) -> Dict[str, dict]:
|
||||
if depth <= 0:
|
||||
return None
|
||||
comments_map = await get_comments_by_ids(ids)
|
||||
parent_ids = set(comment["parent_id"] for comment in comments_map.values())
|
||||
parent_comments = await get_comments_by_ids_recursive(parent_ids, depth - 1)
|
||||
if parent_comments is not None:
|
||||
for comment in comments_map.values():
|
||||
if comment["parent_id"] in parent_comments:
|
||||
comment["parent"] = parent_comments["parent_id"]
|
||||
return comments_map
|
||||
|
||||
|
||||
async def get_comments_from_user_recursive(
|
||||
user: str, since: str, until: str, depth: int
|
||||
) -> List[dict]:
|
||||
comments = await get_comments_from_user(user, since, until)
|
||||
parent_comments = await get_comments_by_ids_recursive(
|
||||
(comment["parent_id"] for comment in comments), depth - 1
|
||||
)
|
||||
for comment in comments:
|
||||
parent_id_trimmed = comment["parent_id"].replace("t1_", "")
|
||||
if parent_id_trimmed in parent_comments:
|
||||
comment["parent"] = parent_comments[parent_id_trimmed]
|
||||
return comments
|
||||
|
||||
|
||||
async def pullpush_fetch(type: str, params: dict) -> dict:
|
||||
params.setdefault("html_decode", "True")
|
||||
|
||||
# Fetch from pullpush
|
||||
async with pullpush_semaphore:
|
||||
async with aiohttp.ClientSession("https://api.pullpush.io/") as session:
|
||||
async with session.get(f"/reddit/{type}/search", params=params) as response:
|
||||
logging.info(f"Retrieved from URL {response.url}")
|
||||
assert response.status == 200
|
||||
return (await response.json())["data"]
|
|
@ -0,0 +1,71 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import socket
|
||||
from typing import List, Union
|
||||
import aiohttp
|
||||
from jinja2 import FileSystemLoader, Environment
|
||||
|
||||
|
||||
class RDrama:
|
||||
semaphore = asyncio.Semaphore(1)
|
||||
wait_time = 2
|
||||
|
||||
def __init__(self, client_id: str, token=None, base_url="https://rdrama.net/"):
|
||||
self.client_id = client_id
|
||||
self.base_url = base_url
|
||||
self.token = token
|
||||
|
||||
# async def log_in(self): # TODO: awful, redo in asyncio
|
||||
# logging.info(f'Logging in...')
|
||||
# self.logged_in = True
|
||||
|
||||
async def make_submission(
|
||||
self,
|
||||
title: str,
|
||||
url: Union[str, None],
|
||||
body: Union[str, None],
|
||||
hole: Union[str, None],
|
||||
) -> Union[str, None]:
|
||||
if self.token is None:
|
||||
self.log_in()
|
||||
logging.info(f"Creating new rDrama submission...")
|
||||
form_data = aiohttp.FormData()
|
||||
form_data_dict = {"url": url, "title": title, "body": body, "hole": hole}
|
||||
for k, v in form_data_dict.items():
|
||||
if v is not None:
|
||||
form_data.add_field(k, v)
|
||||
request_headers = {"Authorization": self.token}
|
||||
async with RDrama.semaphore:
|
||||
async with aiohttp.ClientSession(self.base_url) as session:
|
||||
async with session.post(
|
||||
"/submit", data=form_data, headers=request_headers
|
||||
) as response:
|
||||
ret: dict = await response.json()
|
||||
logging.info(f"Submission {form_data} response: {ret}")
|
||||
assert response.status == 200
|
||||
await asyncio.sleep(RDrama.wait_time)
|
||||
return ret.get("id", None)
|
||||
|
||||
async def make_comment(self, parent_id: str, body: str) -> Union[str, None]:
|
||||
parent_id = str(parent_id)
|
||||
if self.token is None:
|
||||
self.log_in()
|
||||
logging.info(f"Creating new rDrama comment...")
|
||||
form_data = aiohttp.FormData()
|
||||
if not parent_id.startswith("p_"):
|
||||
parent_id = "p_" + parent_id
|
||||
form_data_dict = {"body": body, "parent_fullname": parent_id}
|
||||
for k, v in form_data_dict.items():
|
||||
if v is not None:
|
||||
form_data.add_field(k, v)
|
||||
request_headers = {"Authorization": self.token}
|
||||
async with RDrama.semaphore:
|
||||
async with aiohttp.ClientSession(self.base_url) as session:
|
||||
async with session.post(
|
||||
"/comment", data=form_data, headers=request_headers
|
||||
) as response:
|
||||
ret: dict = await response.json()
|
||||
logging.info(f"Comment {form_data} response: {ret}")
|
||||
assert response.status == 200
|
||||
await asyncio.sleep(RDrama.wait_time)
|
||||
return ret.get("id", None)
|
|
@ -0,0 +1,13 @@
|
|||
{% macro comment_chain(comment_descent) -%}
|
||||
{% if 'parent' in comment_descent -%}
|
||||
{% for line in comment_chain(comment_descent.parent).split('\n\n') -%}
|
||||
>{{line|replace('\n',' ')|trim}}
|
||||
{% endfor -%}
|
||||
{% endif -%}
|
||||
{% for line in comment_descent.body.split('\n\n') -%}
|
||||
> {{line|replace('\n',' ')|trim}}
|
||||
{% endfor -%}
|
||||
{% endmacro -%}
|
||||
Comment in {{ comment.subreddit_name_prefixed }}:
|
||||
Main thread: [{{parent_submission.title|trim}}](https://reddit.com{{parent_submission.permalink}})
|
||||
{{ comment_chain(comment)}}
|
|
@ -0,0 +1,2 @@
|
|||
## Bardfinn digest from {{ metadata.since }} until {{ metadata.until }}:
|
||||
{{ comments|length }} comment{% if comments|length > 1 %}s{% endif %} and {{ submissions|length }} post{% if posts|length > 1 %}s{% endif %}
|
|
@ -0,0 +1,5 @@
|
|||
Submission in {{ submission.subreddit_name_prefixed }}
|
||||
[{{ submission.title}}](https://reddit.com{{submission.permalink}})
|
||||
{% for line in submission.selftext.split('\n\n') -%}
|
||||
> {{line|replace('\n',' ')|trim}}
|
||||
{% endfor %}
|
Loading…
Reference in New Issue