diff --git a/.SourceSageignore b/.SourceSageignore new file mode 100644 index 0000000..e29d512 --- /dev/null +++ b/.SourceSageignore @@ -0,0 +1,38 @@ +.git +__pycache__ +LICENSE +output.md +assets +Style-Bert-VITS2 +output +streamlit +SourceSage.md +data +.gitignore +.SourceSageignore +*.png +Changelog +SourceSageAssets +SourceSageAssetsDemo +__pycache__ +.pyc +**/__pycache__/** +modules\__pycache__ +.svg +sourcesage.egg-info +.pytest_cache +dist +build +.env +example + +.gaiah.md +.Gaiah.md +tmp.md +tmp2.md +.SourceSageAssets +tests +template +aira.egg-info +aira.Gaiah.md +README_template.md \ No newline at end of file diff --git a/.aira/config.dev.yml b/.aira/config.dev.yml new file mode 100644 index 0000000..9940731 --- /dev/null +++ b/.aira/config.dev.yml @@ -0,0 +1,69 @@ +aira: + gaiah: # 共通設定 + run: true + repo: + repo_name: "PEGASUS" + description: "Evolutionary Merge Experiment" + private: false + local: + repo_dir: "C:/Prj/PEGASUS" + no_initial_commit: false + commit: + commit_msg_path: ".Gaiah.md" + branch_name: null + + dev: # 開発時の設定 (必要に応じて上書き) + repo: + create_repo: false + local: + init_repo: false + commit: + process_commits: true + + init: # 初期化時の設定 (必要に応じて上書き) + repo: + create_repo: true + local: + init_repo: true + commit: + process_commits: false + + llm: + model: "gemini/gemini-1.5-pro-latest" # 利用するLLMモデル + + repository_summary_output_dir: .aira # リポジトリ概要の出力ディレクトリ + readme_prompt_template_path: .aira/readme_prompt_template.txt # README生成のプロンプトテンプレートのパス + + harmon_ai: + run: true + environment: + repo_name: "PEGASUS" + owner_name: "Sunwood-ai-labs" + package_name: "PEGASUS" + icon_url: "hhttps://huggingface.co/datasets/MakiAi/IconAssets/resolve/main/PEGASUS.jpeg" + title: "PEGASUS" + subtitle: "~ Evolutionary Merge Experiment ~" + website_url: "https://hamaruki.com/" + github_url: "https://github.com/Sunwood-ai-labs" + twitter_url: "https://x.com/hAru_mAki_ch" + blog_url: "https://hamaruki.com/" + + product: + important_message_file: "important_template.md" + sections_content_file: "sections_template.md" + output_file: "README_template.md" + cicd_file_path: "publish-to-pypi.yml" + cicd_main_path: "publish-to-pypi.yml" + github_cicd_dir: ".github/workflows" + + llm_product: + sections_content_file: "sections_template_llm.md" + + development: + output_dir: "C:/Prj/PEGASUS/.harmon_ai" + + main: + main_dir: "C:/Prj/PEGASUS/" + replace_readme: true + + instructions_prompt: .aira/instructions.md \ No newline at end of file diff --git a/.gitignore b/.gitignore index 82bded5..cfb8f92 100644 --- a/.gitignore +++ b/.gitignore @@ -164,4 +164,7 @@ tmp.md tmp2.md .Prothiel.md .Gaiah.md -.SourceSageAssets \ No newline at end of file +.SourceSageAssets +.aira/aira.Gaiah.md +.harmon_ai/README_template.md +output \ No newline at end of file diff --git a/README.md b/README.md index ef35338..e06eb61 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@


-

PEGASUS

+

P.E.G.A.S.U.S

~ Parsing Extracting Generating Automated Scraping Utility System ~
@@ -34,84 +34,79 @@ >[!IMPORTANT] >このリポジトリのリリースノートやREADME、コミットメッセージの9割近くは[claude.ai](https://claude.ai/)や[ChatGPT4](https://chatgpt.com/)を活用した[AIRA](https://github.com/Sunwood-ai-labs/AIRA), [SourceSage](https://github.com/Sunwood-ai-labs/SourceSage), [Gaiah](https://github.com/Sunwood-ai-labs/Gaiah), [HarmonAI_II](https://github.com/Sunwood-ai-labs/HarmonAI_II)で生成しています。 -## 🌟 イントロダクション -**Pegasus** は、ウェブサイトを再帰的にクロールし、そのコンテンツを美しくフォーマットされた Markdown ドキュメントに変換する、パワフルで柔軟な Python パッケージです。指定された URL から始まり、リンクをたどって関連するページを探索し、HTML コンテンツを構造化された Markdown ファイルに変換します。コマンドラインインターフェイス(CLI)から実行することも、Python スクリプトから直接使用することもできます。 +pegasus は、ウェブサイトを再帰的にクロールし、そのコンテンツを Markdown 形式に変換するパワフルで柔軟な Python パッケージです。指定した URL から始まり、リンクをたどって関連するページを探索し、HTML コンテンツを美しい Markdown ドキュメントに変換します。コマンドラインインターフェイス (CLI) から実行することも、Python スクリプトから直接使用することもできます。 -## 🎥 デモ +## インストール -*デモ動画は現在準備中です。* +pip を使用して pegasus をインストールします。 -## 🚀 はじめに - -このリポジトリには、Pegasus を Docker Compose で簡単に実行するための設定ファイルが含まれています。 - -### 前提条件 - -* Docker -* Docker Compose - -### 実行方法 - -1. リポジトリをクローンします。 - -```bash -git clone https://github.com/[あなたのユーザー名]/pegasus-docker-compose.git +```shell +pip install pegasus ``` -2. ディレクトリに移動します。 +## 使い方 -```bash -cd pegasus-docker-compose -``` - -3. `.env` ファイルを編集し、`TARGET_URL` をクロールしたいウェブサイトの URL に設定します。 +### コマンドラインから -4. Docker Compose を起動します。 +pegasus をコマンドラインから使用するには、以下のようなコマンドを実行します。 -```bash -docker-compose up -d +```shell +pegasus https://example.com/start-page output_directory --exclude-selectors header footer nav --include-domain example.com --exclude-keywords login --output-extension txt +pegasus https://docs.eraser.io/docs/what-is-eraser output/eraser_docs --exclude-selectors header footer nav aside .sidebar .header .footer .navigation .breadcrumbs --include-domain docs.eraser.io --exclude-keywords login --output-extension .txt ``` -5. プロセスが完了すると、Markdown ファイルが `output` ディレクトリに出力されます。 - -### オプション +- `https://example.com/start-page`: クロールを開始するベース URL を指定します。 +- `output_directory`: Markdown ファイルを保存するディレクトリを指定します。 +- `--exclude-selectors`: 除外する CSS セレクターをスペース区切りで指定します(オプション)。 +- `--include-domain`: クロールを特定のドメインに限定します(オプション)。 +- `--exclude-keywords`: URL に含まれる場合にページを除外するキーワードをスペース区切りで指定します(オプション)。 -`.env` ファイルで以下の環境変数を設定することで、Pegasus の動作をカスタマイズできます。 +### Python スクリプトから -* `TARGET_URL`: クロールするウェブサイトの URL (必須) -* `OUTPUT_DIRECTORY`: Markdown ファイルを出力するディレクトリ (デフォルト: `./output`) -* `DEPTH`: クロールする深さ (デフォルト: `-1` (無制限)) -* `LOG_LEVEL`: ログレベル (デフォルト: `INFO`) +pegasus を Python スクリプトから使用するには、以下のようなコードを書きます。 -### 例 +```python +from pegasus import pegasus -`https://www.example.com` をクロールし、Markdown ファイルを `./my-output` ディレクトリに出力する例: - -``` -TARGET_URL=https://www.example.com -OUTPUT_DIRECTORY=./my-output +pegasus = pegasus( + base_url="https://example.com/start-page", + output_dir="output_directory", + exclude_selectors=['header', 'footer', 'nav'], + include_domain="example.com", + exclude_keywords=["login"] +) +pegasus.run() ``` -### 注意 +- `base_url`: クロールを開始するベース URL を指定します。 +- `output_dir`: Markdown ファイルを保存するディレクトリを指定します。 +- `exclude_selectors`: 除外する CSS セレクターのリストを指定します(オプション)。 +- `include_domain`: クロールを特定のドメインに限定します(オプション)。 +- `exclude_keywords`: URL に含まれる場合にページを除外するキーワードのリストを指定します(オプション)。 -* Pegasus は、ウェブサイトの構造やコンテンツによっては、期待通りの結果を得られない場合があります。 -* 大規模なウェブサイトをクロールする場合は、時間とリソースの使用量に注意してください。 -* クロールする前に、ウェブサイトの利用規約を確認してください。 +## 特長 -## 📝 更新情報 +- 指定した URL から始まり、リンクを再帰的にたどってウェブサイトを探索します。 +- HTML コンテンツを美しくフォーマットされた Markdown に変換します。 +- 柔軟な設定オプションにより、クロールと変換のプロセスをカスタマイズできます。 +- ヘッダー、フッター、ナビゲーションなどの不要な要素を除外できます。 +- 特定のドメインのみをクロールするように制限できます。 +- 特定のキーワードを含む URL を除外できます。 -*最新情報については、CHANGELOG.md ファイルを参照してください。* +## 注意事項 -## 🤝 コントリビューション +- pegasus は、適切な使用方法とウェブサイトの利用規約に従ってご利用ください。 +- 過度なリクエストを送信しないよう、適切な遅延を設けてください。 -*コントリビューションは大歓迎です!* +## ライセンス -## 📄 ライセンス +このプロジェクトは MIT ライセンスの下で公開されています。詳細については、[LICENSE](LICENSE) ファイルを参照してください。 -*このプロジェクトは、[ライセンス名] ライセンスの下でライセンスされています。* +## 貢献 -## 🙏 謝辞 +プルリクエストや改善案は大歓迎です。バグ報告や機能リクエストがある場合は、issue を作成してください。 -*Pegasus の開発に貢献してくれたすべての人に感謝します。* +--- +pegasus を使用すれば、ウェブサイトを再帰的に探索し、コンテンツを美しい Markdown ドキュメントに変換できます。ドキュメンテーションの自動化、コンテンツの管理、データ分析などにぜひお役立てください! \ No newline at end of file diff --git a/demo.py b/demo.py new file mode 100644 index 0000000..c88f98c --- /dev/null +++ b/demo.py @@ -0,0 +1,10 @@ +from pegasus.pegasus import Pegasus + +pegasus = Pegasus( + base_url="https://docs.eraser.io/docs/what-is-eraser", + output_dir="eraser_docs", + exclude_selectors=['header', 'footer', 'nav', 'aside', '.sidebar', '.header', '.footer', '.navigation', '.breadcrumbs'], + include_domain="docs.eraser.io", + exclude_keywords=["login"] +) +pegasus.run() \ No newline at end of file diff --git a/example/example01.py b/example/example01.py new file mode 100644 index 0000000..9a0b322 --- /dev/null +++ b/example/example01.py @@ -0,0 +1,28 @@ +import requests +import html2text + +def download_and_convert(url, output_file): + try: + # URLからWebページをダウンロード + response = requests.get(url) + response.raise_for_status() + + # HTMLをマークダウンに変換 + h = html2text.HTML2Text() + h.ignore_links = True + markdown_content = h.handle(response.text) + + # マークダウンをファイルに保存 + with open(output_file, 'w', encoding='utf-8') as file: + file.write(markdown_content) + + print(f"Successfully converted {url} to {output_file}") + except requests.exceptions.RequestException as e: + print(f"Error downloading {url}: {e}") + except IOError as e: + print(f"Error writing to {output_file}: {e}") + +# 使用例 +url = "https://docs.eraser.io/docs/what-is-eraser" +output_file = "example.md" +download_and_convert(url, output_file) \ No newline at end of file diff --git a/example/example02.py b/example/example02.py new file mode 100644 index 0000000..431385d --- /dev/null +++ b/example/example02.py @@ -0,0 +1,50 @@ +import requests +import html2text +from bs4 import BeautifulSoup +from urllib.parse import urljoin, urlparse + +def download_and_convert(url, output_dir, visited_urls): + if url in visited_urls: + return + visited_urls.add(url) + + try: + # URLからWebページをダウンロード + response = requests.get(url) + response.raise_for_status() + + # HTMLをマークダウンに変換 + h = html2text.HTML2Text() + h.ignore_links = True + markdown_content = h.handle(response.text) + + # マークダウンをファイルに保存 + parsed_url = urlparse(url) + output_file = f"{output_dir}/{parsed_url.path.replace('/', '_')}.md" + with open(output_file, 'w', encoding='utf-8') as file: + file.write(markdown_content) + + print(f"Successfully converted {url} to {output_file}") + + # ページ内のリンクを探索 + soup = BeautifulSoup(response.text, 'html.parser') + for link in soup.find_all('a'): + href = link.get('href') + if href: + absolute_url = urljoin(url, href) + if "docs.eraser.io" in absolute_url: + # docs.eraser.ioを含むURLのみ探索 + # URLのフラグメント部分を除去 + absolute_url = absolute_url.split('#')[0] + download_and_convert(absolute_url, output_dir, visited_urls) + + except requests.exceptions.RequestException as e: + print(f"Error downloading {url}: {e}") + except IOError as e: + print(f"Error writing to {output_file}: {e}") + +# 使用例 +base_url = "https://docs.eraser.io/docs/what-is-eraser" +output_dir = "eraser_docs" +visited_urls = set() +download_and_convert(base_url, output_dir, visited_urls) \ No newline at end of file diff --git a/example/example03.py b/example/example03.py new file mode 100644 index 0000000..704b265 --- /dev/null +++ b/example/example03.py @@ -0,0 +1,46 @@ +import requests +from bs4 import BeautifulSoup +from urllib.parse import urljoin, urlparse +import os + +def download_and_save(url, output_dir, visited_urls): + if url in visited_urls: + return + visited_urls.add(url) + + try: + # URLからWebページをダウンロード + response = requests.get(url) + response.raise_for_status() + + # HTMLを保存 + parsed_url = urlparse(url) + output_file = f"{output_dir}/{parsed_url.path.replace('/', '_')}.md" + os.makedirs(os.path.dirname(output_file), exist_ok=True) + with open(output_file, 'w', encoding='utf-8') as file: + file.write(response.text) + + print(f"Successfully saved {url} to {output_file}") + + # ページ内のリンクを探索 + soup = BeautifulSoup(response.text, 'html.parser') + for link in soup.find_all('a'): + href = link.get('href') + if href: + absolute_url = urljoin(url, href) + if "docs.eraser.io" in absolute_url: + # docs.eraser.ioを含むURLのみ探索 + # URLのフラグメント部分を除去 + absolute_url = absolute_url.split('#')[0] + download_and_save(absolute_url, output_dir, visited_urls) + + except requests.exceptions.RequestException as e: + print(f"Error downloading {url}: {e}") + except IOError as e: + print(f"Error writing to {output_file}: {e}") + +# 使用例 +base_url = "https://docs.eraser.io/docs/what-is-eraser" +output_dir = "eraser_docs_html" +visited_urls = set() +download_and_save(base_url, output_dir, visited_urls) \ No newline at end of file diff --git a/example/example04.py b/example/example04.py new file mode 100644 index 0000000..c3d119a --- /dev/null +++ b/example/example04.py @@ -0,0 +1,26 @@ +import requests +import markdownify + +def download_and_convert(url, output_file): + try: + # URLからWebページをダウンロード + response = requests.get(url) + response.raise_for_status() + + # HTMLをマークダウンに変換 + markdown_content = markdownify.markdownify(response.text) + + # マークダウンをファイルに保存 + with open(output_file, 'w', encoding='utf-8') as file: + file.write(markdown_content) + + print(f"Successfully converted {url} to {output_file}") + except requests.exceptions.RequestException as e: + print(f"Error downloading {url}: {e}") + except IOError as e: + print(f"Error writing to {output_file}: {e}") + +# 使用例 +url = "https://docs.eraser.io/docs/examples-4" +output_file = "example.md" +download_and_convert(url, output_file) \ No newline at end of file diff --git a/example/example05.py b/example/example05.py new file mode 100644 index 0000000..9147d31 --- /dev/null +++ b/example/example05.py @@ -0,0 +1,81 @@ +import requests +import markdownify +from bs4 import BeautifulSoup +from urllib.parse import urljoin, urlparse +import os +import re + +def download_and_convert(url, output_dir, visited_urls, exclude_selectors=None, include_domain=None, exclude_keywords=None): + os.makedirs(output_dir, exist_ok=True) + if url in visited_urls: + return + visited_urls.add(url) + + try: + # URLからWebページをダウンロード + response = requests.get(url) + response.raise_for_status() + + # BeautifulSoupオブジェクトを作成 + soup = BeautifulSoup(response.text, 'html.parser') + + # 除外するセレクターに一致する要素を削除 + if exclude_selectors: + for selector in exclude_selectors: + for element in soup.select(selector): + element.decompose() + + # HTMLをマークダウンに変換 + markdown_content = markdownify.markdownify(str(soup)) + + # 5行以上の連続した空行を削除 + markdown_content = re.sub(r'\n{5,}', '\n\n\n\n', markdown_content) + + # マークダウンをファイルに保存 + parsed_url = urlparse(url) + output_file = f"{output_dir}/{parsed_url.path.replace('/', '_')}.md" + with open(output_file, 'w', encoding='utf-8') as file: + file.write(markdown_content) + + print(f"Successfully converted {url} ---> {output_file}") + + # BeautifulSoupオブジェクトを作成 + soup_url = BeautifulSoup(response.text, 'html.parser') + + # ページ内のリンクを探索 + for link in soup_url.find_all('a'): + href = link.get('href') + if href: + absolute_url = urljoin(url, href) + if include_domain and include_domain in absolute_url: + if exclude_keywords: + if any(keyword in absolute_url for keyword in exclude_keywords): + continue + # URLのフラグメント部分を除去 + absolute_url = absolute_url.split('#')[0] + download_and_convert(absolute_url, output_dir, visited_urls, exclude_selectors, include_domain, exclude_keywords) + + except requests.exceptions.RequestException as e: + print(f"Error downloading {url}: {e}") + except IOError as e: + print(f"Error writing to {output_file}: {e}") + +# 使用例 +base_url = "https://docs.eraser.io/docs/what-is-eraser" +output_dir = "eraser_docs" +visited_urls = set() +exclude_selectors = [ + 'header', + 'footer', + 'nav', + 'aside', + '.sidebar', + '.header', + '.footer', + '.navigation', + '.breadcrumbs' +] +include_domain = "docs.eraser.io" +exclude_keywords = ["login"] + +download_and_convert(base_url, output_dir, visited_urls, exclude_selectors, include_domain, exclude_keywords) \ No newline at end of file diff --git a/pegasus/Pegasus.py b/pegasus/Pegasus.py new file mode 100644 index 0000000..f079eda --- /dev/null +++ b/pegasus/Pegasus.py @@ -0,0 +1,87 @@ +# pegasus/pegasus.py +import requests +import markdownify +from bs4 import BeautifulSoup +from urllib.parse import urljoin, urlparse +import os +import re +import loguru +from art import * + +logger = loguru.logger + +class Pegasus: + def __init__(self, base_url, output_dir, exclude_selectors=None, include_domain=None, exclude_keywords=None, output_extension=".md", dust_size=1000): + self.base_url = base_url + self.output_dir = output_dir + self.exclude_selectors = exclude_selectors + self.include_domain = include_domain + self.exclude_keywords = exclude_keywords + self.visited_urls = set() + self.output_extension = output_extension + self.dust_size = dust_size + tprint(" Pegasus ", font="rnd-xlarge") + logger.info("初期化パラメータ:") + logger.info(f" base_url: {base_url}") + logger.info(f" output_dir: {output_dir}") + logger.info(f" exclude_selectors: {exclude_selectors}") + logger.info(f" include_domain: {include_domain}") + logger.info(f" exclude_keywords: {exclude_keywords}") + logger.info(f" output_extension: {output_extension}") + logger.info(f" dust_size: {dust_size}") + + def download_and_convert(self, url): + os.makedirs(self.output_dir, exist_ok=True) + if url in self.visited_urls: + return + self.visited_urls.add(url) + + try: + response = requests.get(url) + response.raise_for_status() + + soup = BeautifulSoup(response.text, 'html.parser') + + if self.exclude_selectors: + for selector in self.exclude_selectors: + for element in soup.select(selector): + element.decompose() + + markdown_content = markdownify.markdownify(str(soup)) + markdown_content = re.sub(r'\n{5,}', '\n\n\n\n', markdown_content) + + parsed_url = urlparse(url) + output_file = f"{self.output_dir}/{parsed_url.path.replace('/', '_')}{self.output_extension}" + + if len(markdown_content) < self.dust_size: + dust_dir = os.path.join(self.output_dir, "dust") + os.makedirs(dust_dir, exist_ok=True) + output_file = f"{dust_dir}/{parsed_url.path.replace('/', '_')}{self.output_extension}" + + with open(output_file, 'w', encoding='utf-8') as file: + file.write(markdown_content) + + logger.info(f"変換成功: {url} ---> {output_file} [{len(markdown_content)/1000}kb]") + + soup_url = BeautifulSoup(response.text, 'html.parser') + + for link in soup_url.find_all('a'): + href = link.get('href') + if href: + absolute_url = urljoin(url, href) + if self.include_domain and self.include_domain in absolute_url: + if self.exclude_keywords: + if any(keyword in absolute_url for keyword in self.exclude_keywords): + continue + absolute_url = absolute_url.split('#')[0] + self.download_and_convert(absolute_url) + + except requests.exceptions.RequestException as e: + logger.error(f"ダウンロードエラー: {url}: {e}") + except IOError as e: + logger.error(f"書き込みエラー: {output_file}: {e}") + + def run(self): + logger.info(f"スクレイピング開始: base_url={self.base_url}") + self.download_and_convert(self.base_url) + logger.info("スクレイピング完了") \ No newline at end of file diff --git a/pegasus/__init__.py b/pegasus/__init__.py new file mode 100644 index 0000000..8069539 --- /dev/null +++ b/pegasus/__init__.py @@ -0,0 +1 @@ +from .Pegasus import Pegasus \ No newline at end of file diff --git a/pegasus/cli.py b/pegasus/cli.py new file mode 100644 index 0000000..5a10c42 --- /dev/null +++ b/pegasus/cli.py @@ -0,0 +1,29 @@ +# pegasus/cli.py +import argparse +from .Pegasus import Pegasus + +def main(): + parser = argparse.ArgumentParser(description='Pegasus') + parser.add_argument('base_url', help='Base URL to start scraping') + parser.add_argument('output_dir', help='Output directory for markdown files') + parser.add_argument('--exclude-selectors', nargs='+', help='CSS selectors to exclude') + parser.add_argument('--include-domain', help='Domain to include in URL matching') + parser.add_argument('--exclude-keywords', nargs='+', help='Keywords to exclude in URL matching') + parser.add_argument('--output-extension', default='.md', help='Output file extension (default: .md)') + parser.add_argument('--dust-size', type=int, default=1000, help='File size threshold for moving to dust folder (default: 1000 bytes)') + + args = parser.parse_args() + + pegasus = Pegasus( + base_url=args.base_url, + output_dir=args.output_dir, + exclude_selectors=args.exclude_selectors, + include_domain=args.include_domain, + exclude_keywords=args.exclude_keywords, + output_extension=args.output_extension, + dust_size=args.dust_size + ) + pegasus.run() + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/script/activate-pegasus.bat b/script/activate-pegasus.bat new file mode 100644 index 0000000..4792b4b --- /dev/null +++ b/script/activate-pegasus.bat @@ -0,0 +1 @@ +conda activate pegasus \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..21cd61e --- /dev/null +++ b/setup.py @@ -0,0 +1,33 @@ +from setuptools import setup, find_packages + +setup( + name='pegasus-surf', + version='0.1.0', + description='A package for scraping websites and converting them to Markdown', + author='Maki', + author_email='sunwood.ai.labs@gmail.com', + url='https://github.com/Sunwood-ai-labs/PEGASUS', + packages=find_packages(), + install_requires=[ + 'requests', + 'markdownify', + 'beautifulsoup4', + 'loguru', + 'art', + ], + entry_points={ + 'console_scripts': [ + 'pegasus=pegasus.cli:main', + ], + }, + classifiers=[ + 'Development Status :: 3 - Alpha', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + ], +) \ No newline at end of file