diff --git a/README.md b/README.md index 498adf5..94b9552 100644 --- a/README.md +++ b/README.md @@ -3,21 +3,23 @@ ![GitHub Actions](https://img.shields.io/badge/GitHub_Actions-success?style=flat&logo=githubactions) ![Python](https://img.shields.io/badge/Python-3.10%2B-blue?style=flat&logo=python) ![Telegram](https://img.shields.io/badge/Telegram-Bot-blue?style=flat&logo=telegram) +![Discord](https://img.shields.io/badge/Discord-Webhook-5865F2?style=flat&logo=discord) [![CodeQL](https://github.com/reagento/relator/actions/workflows/codeql.yml/badge.svg)](https://github.com/reagento/relator/actions/workflows/codeql.yml) -**Relator** (Latin _referre_ - "to report") - delivers beautifully formatted GitHub notifications to Telegram. Get instant alerts for issues and PRs with smart labeling and clean formatting, keeping your team informed in real-time. +**Relator** (Latin _referre_ - "to report") - delivers beautifully formatted GitHub notifications to Telegram and Discord. Get instant alerts for issues and PRs with smart labeling and clean formatting, keeping your team informed in real-time. ## ✨ Features +- **Multi-Platform**: Send notifications to Telegram, Discord, or both simultaneously - **Instant Notifications**: Get real-time alerts for new events -- **Rich Formatting**: Clean HTML and MD formatting -- **Label Support**: Automatically converts GitHub labels to Telegram hashtags +- **Rich Formatting**: HTML for Telegram, rich embeds for Discord +- **Label Support**: Automatically converts GitHub labels to hashtags - **Customizable**: Multiple configuration options for different needs -- **Reliable**: Built-in retry mechanism for Telegram API +- **Reliable**: Built-in retry mechanism with exponential backoff ## 🚀 Quick Start -### Basic Usage +### Telegram Notifications ```yaml name: Event Notifier @@ -45,6 +47,45 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} ``` +### Discord Notifications + +```yaml +name: Event Notifier + +on: + issues: + types: [opened, reopened] + pull_request_target: + types: [opened, reopened] + +permissions: + issues: read + pull_request: read + +jobs: + notify: + name: "Discord notification" + runs-on: ubuntu-latest + steps: + - name: Send Discord notification for new issue or pull request + uses: reagento/relator@v1.6.0 + with: + discord-webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }} + github-token: ${{ secrets.GITHUB_TOKEN }} +``` + +### Both Platforms Simultaneously + +```yaml +- name: Send notification to Telegram and Discord + uses: reagento/relator@v1.6.0 + with: + tg-bot-token: ${{ secrets.TELEGRAM_BOT_TOKEN }} + tg-chat-id: ${{ vars.TELEGRAM_CHAT_ID }} + discord-webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }} + github-token: ${{ secrets.GITHUB_TOKEN }} +``` + > github-token it's not required for public projects and is unlikely to hit any [limits](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28#primary-rate-limit-for-unauthenticated-users). However, github actions uses IP-based limits, and since github actions has a limited pool of addresses, these limits are considered public, and you'll hit them very quickly. ### Advanced Configuration @@ -71,6 +112,8 @@ jobs: ## 🔧 Setup Instructions +### Telegram Setup + 1. Create a Telegram Bot - Message `@BotFather` on [Telegram](https://t.me/botfather) @@ -90,8 +133,26 @@ jobs: - `TELEGRAM_BOT_TOKEN` - `TELEGRAM_CHAT_ID` +### Discord Setup + +1. Create a Discord Webhook + +- Go to your Discord server settings +- Navigate to **Integrations** → **Webhooks** +- Click **New Webhook** +- Customize the webhook name and select the target channel +- Copy the **Webhook URL** + +2. Configure GitHub Secrets + Add these secrets in your repository settings: + +- `DISCORD_WEBHOOK_URL` +- `DISCORD_THREAD_ID` (optional) + ## 📋 Example Output +### Telegram + Your Telegram notifications will look like this: Issue: @@ -120,9 +181,22 @@ Pull requests: sent via relator ``` +### Discord + +Discord notifications appear as rich embeds with: + +- **Color-coded embeds**: Green for issues, purple for pull requests +- **User avatars**: GitHub profile picture displayed +- **Repository links**: Clickable links to repository and issue/PR +- **Organized fields**: Repository, issue/PR number, changes (for PRs), branch info (for PRs) +- **Markdown formatting**: Clean formatting with proper code blocks, bold, italic, and links +- **Labels as hashtags**: Same label format as Telegram + ## 🤝 Acknowledgments -This action uses the excellent [sulguk](https://github.com/Tishka17/sulguk) library by `@Tishka17` for reliable Telegram message delivery +This action uses: +- [sulguk](https://github.com/Tishka17/sulguk) by `@Tishka17` for reliable Telegram message delivery +- [markdownify](https://github.com/matthewwithanm/python-markdownify) for HTML to Markdown conversion for Discord ## 🌟 Support diff --git a/action.yml b/action.yml index 1d5d93f..e866699 100644 --- a/action.yml +++ b/action.yml @@ -1,17 +1,23 @@ name: "reagento/relator" -description: "Send Telegram notifications for new GitHub issues or PRs" +description: "Send Telegram and Discord notifications for new GitHub issues or PRs" author: "Sehat1137" inputs: tg-bot-token: description: "Telegram Bot Token" - required: true + required: false tg-chat-id: description: "Telegram Chat ID" - required: true + required: false tg-message-thread-id: description: "Telegram Message Thread ID" required: false + discord-webhook-url: + description: "Discord Webhook URL" + required: false + discord-thread-id: + description: "Discord thread ID to post in (optional)" + required: false github-token: description: "GitHub Token for API access" required: false @@ -51,18 +57,20 @@ runs: run: | pip install -r $GITHUB_ACTION_PATH/requirements.txt - - name: Send Telegram notification + - name: Send notifications shell: bash env: TELEGRAM_BOT_TOKEN: ${{ inputs.tg-bot-token }} TELEGRAM_CHAT_ID: ${{ inputs.tg-chat-id }} + TELEGRAM_MESSAGE_THREAD_ID: ${{ inputs.tg-message-thread-id }} + DISCORD_WEBHOOK_URL: ${{ inputs.discord-webhook-url }} + DISCORD_THREAD_ID: ${{ inputs.discord-thread-id }} GITHUB_TOKEN: ${{ inputs.github-token }} EVENT_URL: ${{ github.event.issue.url || github.event.pull_request.url }} BASE_URL: ${{ inputs.base-url }} ATTEMPT_COUNT: ${{ inputs.attempt-count }} HTML_TEMPLATE: ${{ inputs.html-template }} MD_TEMPLATE: ${{ inputs.md-template }} - TELEGRAM_MESSAGE_THREAD_ID: ${{ inputs.tg-message-thread-id }} JOIN_INPUT_WITH_LIST: ${{ inputs.join-input-with-list }} CUSTOM_LABELS: ${{ inputs.custom-labels }} run: | diff --git a/notifier/__main__.py b/notifier/__main__.py index 45b4baa..a24ddbc 100644 --- a/notifier/__main__.py +++ b/notifier/__main__.py @@ -1,9 +1,12 @@ import os import re import sys +import traceback from notifier.application.interactors import SendIssue, SendPR +from notifier.application.interfaces import Notifier from notifier.application.services import RenderService +from notifier.infrastructure.discord_gateway import DiscordGateway from notifier.infrastructure.github_gateway import GithubGateway from notifier.infrastructure.telegram_gateway import TelegramGateway @@ -24,15 +27,6 @@ def get_interactor(url: str) -> type[SendIssue] | type[SendPR]: if __name__ == "__main__": - html_template = os.environ.get("HTML_TEMPLATE", "").strip() - - telegram_gateway = TelegramGateway( - chat_id=os.environ["TELEGRAM_CHAT_ID"], - bot_token=os.environ["TELEGRAM_BOT_TOKEN"], - attempt_count=int(os.environ["ATTEMPT_COUNT"]), - message_thread_id=os.environ.get("TELEGRAM_MESSAGE_THREAD_ID"), - ) - event_url = os.environ["EVENT_URL"] github_gateway = GithubGateway( @@ -49,15 +43,46 @@ def get_interactor(url: str) -> type[SendIssue] | type[SendPR]: join_input_with_list=os.environ.get("JOIN_INPUT_WITH_LIST") == "1", ) + notifiers: list[Notifier] = [] + + tg_bot_token = os.environ.get("TELEGRAM_BOT_TOKEN") + tg_chat_id = os.environ.get("TELEGRAM_CHAT_ID") + if tg_bot_token and tg_chat_id: + html_template = os.environ.get("HTML_TEMPLATE", "").strip() + telegram_gateway = TelegramGateway( + chat_id=tg_chat_id, + bot_token=tg_bot_token, + attempt_count=int(os.environ.get("ATTEMPT_COUNT", "2")), + message_thread_id=os.environ.get("TELEGRAM_MESSAGE_THREAD_ID"), + custom_template=html_template, + ) + notifiers.append(telegram_gateway) + + discord_webhook_url = os.environ.get("DISCORD_WEBHOOK_URL") + if discord_webhook_url: + discord_gateway = DiscordGateway( + webhook_url=discord_webhook_url, + attempt_count=int(os.environ.get("ATTEMPT_COUNT", "2")), + ) + notifiers.append(discord_gateway) + + if not notifiers: + print( + "Error: No notification platform configured. " + "Please provide either TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID or DISCORD_WEBHOOK_URL", + file=sys.stderr, + ) + sys.exit(1) + interactor = get_interactor(event_url)( - template=html_template, github=github_gateway, - telegram=telegram_gateway, + notifiers=notifiers, render_service=render_service, ) try: interactor.handler() except Exception as e: + traceback.print_exc(file=sys.stderr) print(f"Error processing event: {e}", file=sys.stderr) sys.exit(1) diff --git a/notifier/application/interactors.py b/notifier/application/interactors.py index d53a7b1..103b6f8 100644 --- a/notifier/application/interactors.py +++ b/notifier/application/interactors.py @@ -1,130 +1,42 @@ -import typing - -import sulguk - from notifier.application import interfaces from notifier.application.services import RenderService -from notifier.domain.entities import Issue, PullRequest - -TG_MESSAGE_LIMIT: typing.Final = 4096 - - -ISSUE_TEMPLATE: typing.Final = ( - "🚀 New issue to {repository} by @{user}
" - "📝 {title} (#{id})

" - "{body}
" - "{labels}" - "{promo}" -) - -PR_TEMPLATE: typing.Final = ( - "🎉 New Pull Request to {repository} by @{user}
" - "✨ {title} (#{id})
" - "📊 +{additions}/-{deletions}
" - "🌿 {head_ref} → {base_ref}

" - "{body}
" - "{labels}" - "{promo}" -) class SendIssue: def __init__( self, - template: str, github: interfaces.Github, - telegram: interfaces.Telegram, + notifiers: list[interfaces.Notifier], render_service: RenderService, ) -> None: - self._template = template or ISSUE_TEMPLATE self._github = github - self._telegram = telegram + self._notifiers = notifiers self._render_service = render_service def handler(self) -> None: issue = self._github.get_issue() - labels = self._render_service.format_labels(issue.labels) body = self._render_service.format_body(issue.body) - message = self._create_message(issue, body, labels) - - render_result = sulguk.transform_html( - message, - base_url="https://github.com", - ) - - if len(render_result.text) <= TG_MESSAGE_LIMIT: - return self._telegram.send_message(render_result) - - message_without_description = self._create_message(issue, "

", labels) - - sulguk.transform_html( - message_without_description, - base_url="https://github.com", - ) - - def _create_message(self, issue: Issue, body: str, labels: str) -> str: - return self._template.format( - id=issue.id, - user=issue.user, - title=issue.title, - labels=labels, - url=issue.url, - body=body, - repository=issue.repository, - promo="sent via relator", - ) + for notifier in self._notifiers: + notifier.send_issue(issue, body, labels) class SendPR: def __init__( self, - template: str, github: interfaces.Github, - telegram: interfaces.Telegram, + notifiers: list[interfaces.Notifier], render_service: RenderService, ) -> None: - self._template = template or PR_TEMPLATE self._github = github - self._telegram = telegram + self._notifiers = notifiers self._render_service = render_service def handler(self) -> None: pr = self._github.get_pull_request() - labels = self._render_service.format_labels(pr.labels) body = self._render_service.format_body(pr.body) - message = self._create_message(pr, body, labels) - - render_result = sulguk.transform_html( - message, - base_url="https://github.com", - ) - - if len(render_result.text) <= TG_MESSAGE_LIMIT: - return self._telegram.send_message(render_result) - - message_without_description = self._create_message(pr, "

", labels) - - sulguk.transform_html( - message_without_description, - base_url="https://github.com", - ) - - def _create_message(self, pr: PullRequest, body: str, labels: str) -> str: - return self._template.format( - id=pr.id, - user=pr.user, - title=pr.title, - labels=labels, - url=pr.url, - body=body, - repository=pr.repository, - additions=pr.additions, - deletions=pr.deletions, - head_ref=pr.head_ref, - base_ref=pr.base_ref, - promo="sent via relator", - ) + for notifier in self._notifiers: + notifier.send_pull_request(pr, body, labels) diff --git a/notifier/application/interfaces.py b/notifier/application/interfaces.py index 4e815f4..a6ffb42 100644 --- a/notifier/application/interfaces.py +++ b/notifier/application/interfaces.py @@ -1,9 +1,7 @@ import abc import typing -import sulguk - -from notifier.domain.entities import PullRequest, Issue +from notifier.domain.entities import Issue, PullRequest class Github(typing.Protocol): @@ -14,6 +12,19 @@ def get_issue(self) -> Issue: ... def get_pull_request(self) -> PullRequest: ... -class Telegram(typing.Protocol): +class Notifier(typing.Protocol): + @abc.abstractmethod + def send_issue( + self, + issue: Issue, + formatted_body: str, + formatted_labels: str, + ) -> None: ... + @abc.abstractmethod - def send_message(self, render_result: sulguk.RenderResult) -> None: ... + def send_pull_request( + self, + pull_request: PullRequest, + formatted_body: str, + formatted_labels: str, + ) -> None: ... diff --git a/notifier/infrastructure/discord_gateway.py b/notifier/infrastructure/discord_gateway.py new file mode 100644 index 0000000..e337b61 --- /dev/null +++ b/notifier/infrastructure/discord_gateway.py @@ -0,0 +1,191 @@ +import typing +from datetime import datetime, timezone + +import bs4 +from markdownify import markdownify + +from notifier.application import interfaces +from notifier.domain.entities import Issue, PullRequest +from notifier.infrastructure.send_weebhook import send_webhook + +DISCORD_EMBED_DESC_LIMIT: typing.Final = 2000 +DISCORD_COLOR_ISSUE: typing.Final = 0x28A745 # green +DISCORD_COLOR_PR: typing.Final = 0x6F42C1 # purple + + +class DiscordGateway(interfaces.Notifier): + def __init__( + self, + webhook_url: str, + attempt_count: int, + ) -> None: + self._webhook_url = webhook_url + self._attempt_count = attempt_count + + def send_issue( + self, + issue: Issue, + formatted_body: str, + formatted_labels: str, + ) -> None: + embed = self._format_issue(issue, formatted_body, formatted_labels) + send_webhook( + url=self._webhook_url, + payload={"embeds": [embed]}, + attempts=self._attempt_count, + ) + + def send_pull_request( + self, + pull_request: PullRequest, + formatted_body: str, + formatted_labels: str, + ) -> None: + embed = self._format_pull_request( + pull_request, formatted_body, formatted_labels + ) + send_webhook( + url=self._webhook_url, + payload={"embeds": [embed]}, + attempts=self._attempt_count, + ) + + def _format_issue( + self, issue: Issue, body: str, labels: str + ) -> dict[str, typing.Any]: + markdown_body = self._html_to_markdown(body) + description = self._create_description(markdown_body, labels) + + embed = { + "title": f"🚀 New Issue #{issue.id}: {self._truncate_title(issue.title)}", + "description": description, + "url": issue.url, + "color": DISCORD_COLOR_ISSUE, + "author": { + "name": f"@{issue.user}", + "url": f"https://github.com/{issue.user}", + "icon_url": f"https://github.com/{issue.user}.png?size=32", + }, + "fields": [ + { + "name": "Repository", + "value": f"[{issue.repository}](https://github.com/{issue.repository})", + "inline": True, + }, + { + "name": "Issue Number", + "value": f"#{issue.id}", + "inline": True, + }, + ], + "footer": { + "text": "sent via relator", + }, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + + return embed + + def _format_pull_request( + self, pr: PullRequest, body: str, labels: str + ) -> dict[str, typing.Any]: + labels = labels.rstrip("
") + markdown_body = self._html_to_markdown(body) + description = self._create_description(markdown_body, labels) + + embed = { + "title": f"🎉 New PR #{pr.id}: {self._truncate_title(pr.title)}", + "description": description, + "url": pr.url, + "color": DISCORD_COLOR_PR, + "author": { + "name": f"@{pr.user}", + "url": f"https://github.com/{pr.user}", + "icon_url": f"https://github.com/{pr.user}.png?size=32", + }, + "fields": [ + { + "name": "Repository", + "value": f"[{pr.repository}](https://github.com/{pr.repository})", + "inline": True, + }, + { + "name": "PR Number", + "value": f"#{pr.id}", + "inline": True, + }, + { + "name": "Changes", + "value": f"+{pr.additions} / -{pr.deletions}", + "inline": True, + }, + { + "name": "Branch", + "value": f"`{pr.head_ref}` → `{pr.base_ref}`", + "inline": False, + }, + ], + "footer": { + "text": "sent via relator", + }, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + + return embed + + def _html_to_markdown(self, html: str) -> str: + if not html or html == "

": + return "" + + html = html.replace("
", "\n") + print(f"After trim {html=}") + try: + markdown = markdownify( + html, + heading_style="ATX", + bullets="-", + strip=["script", "style"], + ) + markdown = self._clean_markdown(markdown) + return markdown.strip() + except Exception: + soup = bs4.BeautifulSoup(html, "lxml") + return soup.get_text().strip() + + def _clean_markdown(self, markdown: str) -> str: + lines = markdown.split("\n") + cleaned_lines = [] + empty_count = 0 + + for line in lines: + if line.strip() == "": + empty_count += 1 + if empty_count <= 2: + cleaned_lines.append(line) + else: + empty_count = 0 + cleaned_lines.append(line) + + return "\n".join(cleaned_lines) + + def _create_description(self, markdown_body: str, labels: str) -> str: + labels_text = f"\n\n{labels}" if labels.strip() else "" + reserved_for_labels = len(labels_text) + 100 + available_for_body = DISCORD_EMBED_DESC_LIMIT - reserved_for_labels + + if len(markdown_body) > available_for_body: + truncated_body = markdown_body[: available_for_body - 4] + "..." + else: + truncated_body = markdown_body + + description = truncated_body + labels_text + + if len(description) > DISCORD_EMBED_DESC_LIMIT: + description = description[: DISCORD_EMBED_DESC_LIMIT - 3] + "..." + + return description + + def _truncate_title(self, title: str, max_length: int = 200) -> str: + if len(title) <= max_length: + return title + return title[: max_length - 3] + "..." diff --git a/notifier/infrastructure/send_weebhook.py b/notifier/infrastructure/send_weebhook.py new file mode 100644 index 0000000..eefecf8 --- /dev/null +++ b/notifier/infrastructure/send_weebhook.py @@ -0,0 +1,23 @@ +import sys +import time +from typing import Any + +import requests + + +def send_webhook(*, payload: dict[str, Any], url: str, attempts: int) -> None: + count = 0 + while count < attempts: + response = requests.post(url, json=payload, timeout=30) + try: + response.raise_for_status() + except requests.exceptions.HTTPError: + print(response.content, file=sys.stderr) + count += 1 + time.sleep(count * 2) + else: + print( + f"Response: {response.status_code=} {response.text=}", + file=sys.stdout, + ) + return diff --git a/notifier/infrastructure/telegram_gateway.py b/notifier/infrastructure/telegram_gateway.py index 95bab39..f6696d8 100644 --- a/notifier/infrastructure/telegram_gateway.py +++ b/notifier/infrastructure/telegram_gateway.py @@ -1,40 +1,89 @@ -import sys -import time +import typing -import requests import sulguk from notifier.application import interfaces +from notifier.domain.entities import Issue, PullRequest +from notifier.infrastructure.send_weebhook import send_webhook +TG_MESSAGE_LIMIT: typing.Final = 4096 -class TelegramGateway(interfaces.Telegram): +ISSUE_TEMPLATE: typing.Final = ( + "🚀 New issue to {repository} by @{user}
" + "📝 {title} (#{id})

" + "{body}
" + "{labels}" + "{promo}" +) + +PR_TEMPLATE: typing.Final = ( + "🎉 New Pull Request to {repository} by @{user}
" + "✨ {title} (#{id})
" + "📊 +{additions}/-{deletions}
" + "🌿 {head_ref} → {base_ref}

" + "{body}
" + "{labels}" + "{promo}" +) + + +class TelegramGateway(interfaces.Notifier): def __init__( self, chat_id: str, bot_token: str, attempt_count: int, - message_thread_id: str | int | None, + message_thread_id: str | int | None = None, + custom_template: str = "", ) -> None: self._chat_id = chat_id self._bot_token = bot_token self._attempt_count = attempt_count self._message_thread_id = message_thread_id + self._custom_template = custom_template - def send_message(self, render_result: sulguk.RenderResult) -> None: - count = 0 - payload = self._create_payload(render_result) - url = f"https://api.telegram.org/bot{self._bot_token}/sendMessage" - while count < self._attempt_count: - response = requests.post(url, json=payload, timeout=30) - try: - response.raise_for_status() - except requests.exceptions.HTTPError: - print(response.content, file=sys.stderr) - count += 1 - time.sleep(count * 2) - else: - print(response.json(), file=sys.stdout) - return + def send_issue( + self, + issue: Issue, + formatted_body: str, + formatted_labels: str, + ) -> None: + message = self._create_issue_message(issue, formatted_body, formatted_labels) + render_result = sulguk.transform_html(message, base_url="https://github.com") + + if len(render_result.text) > TG_MESSAGE_LIMIT: + message = self._create_issue_message(issue, "

", formatted_labels) + render_result = sulguk.transform_html( + message, base_url="https://github.com" + ) + send_webhook( + payload=self._create_payload(render_result), + url=f"https://api.telegram.org/bot{self._bot_token}/sendMessage", + attempts=self._attempt_count, + ) + + def send_pull_request( + self, + pull_request: PullRequest, + formatted_body: str, + formatted_labels: str, + ) -> None: + message = self._create_pr_message( + pull_request, formatted_body, formatted_labels + ) + render_result = sulguk.transform_html(message, base_url="https://github.com") + + if len(render_result.text) > TG_MESSAGE_LIMIT: + message = self._create_pr_message(pull_request, "

", formatted_labels) + render_result = sulguk.transform_html( + message, base_url="https://github.com" + ) + + send_webhook( + payload=self._create_payload(render_result), + url=f"https://api.telegram.org/bot{self._bot_token}/sendMessage", + attempts=self._attempt_count, + ) def _create_payload(self, render_result: sulguk.RenderResult) -> dict: for e in render_result.entities: @@ -51,3 +100,34 @@ def _create_payload(self, render_result: sulguk.RenderResult) -> dict: payload["message_thread_id"] = self._message_thread_id return payload + + def _create_issue_message(self, issue: Issue, body: str, labels: str) -> str: + template = self._custom_template or ISSUE_TEMPLATE + return template.format( + id=issue.id, + user=issue.user, + title=issue.title, + labels=labels, + url=issue.url, + body=body, + repository=issue.repository, + promo="sent via relator", + ) + + def _create_pr_message(self, pr: PullRequest, body: str, labels: str) -> str: + """Create HTML message for pull request""" + template = self._custom_template or PR_TEMPLATE + return template.format( + id=pr.id, + user=pr.user, + title=pr.title, + labels=labels, + url=pr.url, + body=body, + repository=pr.repository, + additions=pr.additions, + deletions=pr.deletions, + head_ref=pr.head_ref, + base_ref=pr.base_ref, + promo="sent via relator", + ) diff --git a/requirements.txt b/requirements.txt index 4617dbf..12d6930 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ sulguk==0.10.1 requests==2.32.5 beautifulsoup4==4.14.2 lxml==6.0.2 +markdownify==0.12.1