Skip to content

Commit

Permalink
Merge branch 'release/0.2.0'
Browse files Browse the repository at this point in the history
  • Loading branch information
Sunwood-ai-labs committed Jun 9, 2024
2 parents b734b7a + bb9c7fe commit e9f815c
Show file tree
Hide file tree
Showing 7 changed files with 244 additions and 99 deletions.
6 changes: 5 additions & 1 deletion .SourceSageignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,8 @@ tests
template
aira.egg-info
aira.Gaiah.md
README_template.md
README_template.md
output
.harmon_ai
pegasus_surf.egg-info
.aira
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -167,4 +167,5 @@ tmp2.md
.SourceSageAssets
.aira/aira.Gaiah.md
.harmon_ai/README_template.md
output
output
urls.txt
44 changes: 35 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ pegasus は、ウェブサイトを再帰的にクロールし、そのコンテ
pip を使用して pegasus をインストールします。

```shell
pip install pegasus
pip install pegasus-surf
```

## 使い方
Expand All @@ -53,25 +53,41 @@ pegasus をコマンドラインから使用するには、以下のようなコ

```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

pegasus --base-url 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

# 深度を指定して実行
pegasus --base-url https://docs.eraser.io/docs/what-is-eraser output/eraser_docs2 --exclude-selectors header footer nav aside .sidebar .header .footer .navigation .breadcrumbs --include-domain docs.eraser.io --exclude-keywords login --output-extension .txt --max-depth 2

# URLが記載されたテキストファイルを指定して実行
pegasus --url-file urls.txt output/roomba --exclude-selectors header footer nav aside .sidebar .header .footer .navigation .breadcrumbs --exclude-keywords login --output-extension .txt --max-depth 1

# LLMを使った仕分け
pegasus --url-file urls.txt output/roomba2 --exclude-selectors header footer nav aside .sidebar .header .footer .navigation .breadcrumbs --exclude-keywords login --output-extension .txt --max-depth 1 --system-message "あなたは、与えられたウェブサイトのコンテンツが特定のトピックに関連する有用な情報を含んでいるかどうかを判断するアシスタントです。トピックに関連する有益な情報が含まれている場合は「True」、そうでない場合は「False」と回答してください。" --classification-prompt "次のウェブサイトのコンテンツは、Roomba APIやiRobotに関する有益な情報を提供していますか? 提供している場合は「True」、そうでない場合は「False」と回答してください。"
```

- `https://example.com/start-page`: クロールを開始するベース URL を指定します。
- `output_directory`: Markdown ファイルを保存するディレクトリを指定します。
- `--exclude-selectors`: 除外する CSS セレクターをスペース区切りで指定します(オプション)。
- `--exclude-selectors`: 除外する CSS セレクターをスペース区切りで指定します(オプション)。
- `--include-domain`: クロールを特定のドメインに限定します(オプション)。
- `--exclude-keywords`: URL に含まれる場合にページを除外するキーワードをスペース区切りで指定します(オプション)。
- **`--output-extension`: 出力ファイルの拡張子を指定します(デフォルト: .md)。**
- **`--dust-size`: ダストフォルダに移動するファイルサイズのしきい値をバイト単位で指定します(デフォルト: 1000)。**
- **`--max-depth`: 再帰処理の最大深度を指定します(デフォルト: 制限なし)。**
- **`--url-file`: スクレイピングするURLが記載されたテキストファイルを指定します。**
- **`--system-message`: LLMのシステムメッセージを指定します(サイトの分類に使用)。**
- **`--classification-prompt`: LLMのサイト分類プロンプトを指定します。TrueまたはFalseを返すようにしてください。**

### Python スクリプトから

pegasus を Python スクリプトから使用するには、以下のようなコードを書きます。

```python
from pegasus import pegasus
from pegasus import Pegasus

pegasus = pegasus(
pegasus = Pegasus(
base_url="https://example.com/start-page",
output_dir="output_directory",
output_dir="output_directory",
exclude_selectors=['header', 'footer', 'nav'],
include_domain="example.com",
exclude_keywords=["login"]
Expand All @@ -84,19 +100,26 @@ pegasus.run()
- `exclude_selectors`: 除外する CSS セレクターのリストを指定します(オプション)。
- `include_domain`: クロールを特定のドメインに限定します(オプション)。
- `exclude_keywords`: URL に含まれる場合にページを除外するキーワードのリストを指定します(オプション)。
- **`output_extension`: 出力ファイルの拡張子を指定します(デフォルト: .md)。**
- **`dust_size`: ダストフォルダに移動するファイルサイズのしきい値をバイト単位で指定します(デフォルト: 1000)。**
- **`max_depth`: 再帰処理の最大深度を指定します(デフォルト: 制限なし)。**
- **`system_message`: LLMのシステムメッセージを指定します(サイトの分類に使用)。**
- **`classification_prompt`: LLMのサイト分類プロンプトを指定します。TrueまたはFalseを返すようにしてください。**

## 特長

- 指定した URL から始まり、リンクを再帰的にたどってウェブサイトを探索します。
- HTML コンテンツを美しくフォーマットされた Markdown に変換します。
- 柔軟な設定オプションにより、クロールと変換のプロセスをカスタマイズできます。
- ヘッダー、フッター、ナビゲーションなどの不要な要素を除外できます。
- ヘッダー、フッター、ナビゲーションなどの不要な要素を除外できます。
- 特定のドメインのみをクロールするように制限できます。
- 特定のキーワードを含む URL を除外できます。
- **URLリストを記載したテキストファイルを指定してスクレイピングできます。**
- **LLMを使ってスクレイピングしたサイトを分類できます。**

## 注意事項

- pegasus は、適切な使用方法とウェブサイトの利用規約に従ってご利用ください。
- pegasus は、適切な使用方法とウェブサイトの利用規約に従ってご利用ください。
- 過度なリクエストを送信しないよう、適切な遅延を設けてください。

## ライセンス
Expand All @@ -109,4 +132,7 @@ pegasus.run()

---

pegasus を使用すれば、ウェブサイトを再帰的に探索し、コンテンツを美しい Markdown ドキュメントに変換できます。ドキュメンテーションの自動化、コンテンツの管理、データ分析などにぜひお役立てください!
pegasus を使用すれば、ウェブサイトを再帰的に探索し、コンテンツを美しい Markdown ドキュメントに変換できます。ドキュメンテーションの自動化、コンテンツの管理、データ分析などにぜひお役立てください!
```
以上がREADMEの修正案です。リポジトリの更新内容を反映し、LLMを使ったサイト分類機能や新しいオプションについて説明を追加しました。使用例も拡充して、ツールの活用方法がより明確になるようにしています。
232 changes: 157 additions & 75 deletions pegasus/Pegasus.py
Original file line number Diff line number Diff line change
@@ -1,87 +1,169 @@
# pegasus/pegasus.py
import requests
import markdownify
from bs4 import BeautifulSoup
from urllib.parse import urljoin, urlparse
import os
import re
import loguru
import time
from art import *
from litellm import completion
from tqdm import tqdm
import litellm
# litellm.set_verbose=True

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("スクレイピング完了")
def __init__(self, output_dir, exclude_selectors=None, include_domain=None, exclude_keywords=None, output_extension=".md",
dust_size=1000, max_depth=None, system_message=None, classification_prompt=None, max_retries=3,
model='gemini/gemini-1.5-pro-latest', rate_limit_sleep=60, other_error_sleep=10):
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
self.max_depth = max_depth
self.domain_summaries = {}
self.system_message = system_message
self.classification_prompt = classification_prompt
self.max_retries = max_retries
self.model = model
self.rate_limit_sleep = rate_limit_sleep
self.other_error_sleep = other_error_sleep
tprint(" Pegasus ", font="rnd-xlarge")
logger.info("初期化パラメータ:")
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}")
logger.info(f" max_depth: {max_depth}")
logger.info(f" system_message: {system_message}")
logger.info(f" classification_prompt: {classification_prompt}")
logger.info(f" max_retries: {max_retries}")
logger.info(f" model: {model}")
logger.info(f" rate_limit_sleep: {rate_limit_sleep}")
logger.info(f" other_error_sleep: {other_error_sleep}")

def filter_site(self, markdown_content):
if(self.classification_prompt is None):
return True

retry_count = 0
while retry_count < self.max_retries:
try:
messages = [
{"role": "system", "content": self.system_message},
{"role": "user", "content": f"{self.classification_prompt}\n\n{markdown_content}"}
]
response = completion(
model="gemini/gemini-1.5-pro-latest",
messages=messages
)
content = response.get('choices', [{}])[0].get('message', {}).get('content')
logger.debug(f"content : {content}")
if "true" in content.lower():
return True
elif "false" in content.lower():
return False
else:
raise ValueError("分類結果が曖昧です。")
except Exception as e:
retry_count += 1
logger.warning(f"フィルタリングでエラーが発生しました。リトライします。({retry_count}/{self.max_retries}\nError: {e}")

if "429" in str(e):
sleep_time = self.rate_limit_sleep # レート制限エラー時のスリープ時間をself.rate_limit_sleepから取得
else:
sleep_time = self.other_error_sleep # その他のエラー時のスリープ時間をself.other_error_sleepから取得

for _ in tqdm(range(sleep_time), desc="Sleeping", unit="s"):
time.sleep(1)

logger.error(f"フィルタリングに失敗しました。リトライ回数の上限に達しました。({self.max_retries}回)")
return True

def download_and_convert(self, url, depth=0):
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)

if not self.filter_site(markdown_content):
parsed_url = urlparse(url)
domain = parsed_url.netloc
domain_dir = os.path.join(self.output_dir, domain)
os.makedirs(domain_dir, exist_ok=True)
excluded_dir = os.path.join(domain_dir, "excluded")
os.makedirs(excluded_dir, exist_ok=True)
output_file = f"{excluded_dir}/{parsed_url.path.replace('/', '_')}{self.output_extension}"
else:
parsed_url = urlparse(url)
domain = parsed_url.netloc
domain_dir = os.path.join(self.output_dir, domain)
os.makedirs(domain_dir, exist_ok=True)

output_file = f"{domain_dir}/{parsed_url.path.replace('/', '_')}{self.output_extension}"

if len(markdown_content) < self.dust_size:
dust_dir = os.path.join(domain_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"[{depth}]変換成功: {url} ---> {output_file} [{len(markdown_content)/1000}kb]")

if domain not in self.domain_summaries:
self.domain_summaries[domain] = []
self.domain_summaries[domain].append(f"# {os.path.basename(output_file)}\n\n---\n\n{markdown_content}")

if self.max_depth is None or depth < self.max_depth:
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) or (self.include_domain == ""):
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, depth + 1)

except requests.exceptions.RequestException as e:
logger.error(f"ダウンロードエラー: {url}: {e}")
except IOError as e:
logger.error(f"書き込みエラー: {output_file}: {e}")

def create_domain_summaries(self):
for domain, summaries in self.domain_summaries.items():
summary_file = os.path.join(self.output_dir, f"{domain}_summary{self.output_extension}")
with open(summary_file, 'w', encoding='utf-8') as file:
file.write('\n\n'.join(summaries))
logger.info(f"サマリーファイル作成: {summary_file}")

def run(self, base_url):
logger.info(f"スクレイピング開始: base_url={base_url}")
self.download_and_convert(base_url)
self.create_domain_summaries()
logger.info("スクレイピング完了")
Loading

0 comments on commit e9f815c

Please sign in to comment.