diff --git a/.gitignore b/.gitignore index a9edce7..0c8f280 100644 --- a/.gitignore +++ b/.gitignore @@ -169,3 +169,4 @@ tmp2.md .harmon_ai/README_template.md output urls.txt +memo.md diff --git a/README.md b/README.md index 18714a5..ace1b28 100644 --- a/README.md +++ b/README.md @@ -53,18 +53,25 @@ pip install pegasus-surf PEGASUSをコマンドラインから使用するには、以下のようなコマンドを実行します。 +#### 検索スクレイピング +```shell +pegasus search --search-query "お好み焼き レシピ" +``` + +#### 再帰スクレイピング + ```shell # 単一のURLからスクレイピングを開始 🌐 -pegasus --base-url https://example.com/start-page output_directory --exclude-selectors header footer nav --include-domain example.com --exclude-keywords login --output-extension txt +pegasus recursive --base-url https://www.otafuku.co.jp/recipe/cook/noodle/nood01.html --output_dir output/yakisoba --exclude-selectors header footer nav --include-domain example.com --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 +pegasus recursive --base-url https://docs.eraser.io/docs/what-is-eraser --output_dir 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 +pegasus recursive --url-file urls.txt --output_dir output/okonomi --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」と回答してください。" +pegasus recursive --url-file urls.txt --output_dir 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」と回答してください。" ``` オプションの意味はこんな感じです!👀 @@ -158,6 +165,11 @@ pegasus.run("https://example.com/start-page") [View on Eraser![](https://app.eraser.io/workspace/8cnaevNcF1kxMsfSfGl0/preview?elements=KStkmTRUuZ5AiJNPC8E40g&type=embed)](https://app.eraser.io/workspace/8cnaevNcF1kxMsfSfGl0?elements=KStkmTRUuZ5AiJNPC8E40g) +### SourceSage + +```shell +sourcesage --mode DocuMind --docuMind-model "gemini/gemini-1.5-pro-latest" --docuMind-db ".SourceSageAssets\DOCUMIND\Repository_summary.md" --docuMind-release-report ".SourceSageAssets\RELEASE_REPORT\Report_v0.3.0.md" --docuMind-changelog ".SourceSageAssets\Changelog\CHANGELOG_release_5.0.0.md" --docuMind-output ".SourceSageAssets/DOCUMIND/RELEASE_NOTES_v0.3.0.md" --docuMind-prompt-output ".SourceSageAssets/DOCUMIND/_PROMPT_v0.3.0.md" --repo-name "SourceSage" --repo-version "v0.3.0" +``` ## 🤝 貢献 diff --git a/example/ex06_ddg_recipe_query.py b/example/ex06_ddg_recipe_query.py new file mode 100644 index 0000000..67f334e --- /dev/null +++ b/example/ex06_ddg_recipe_query.py @@ -0,0 +1,21 @@ +from duckduckgo_search import DDGS +import json + +# クエリ +with DDGS() as ddgs: + results = list(ddgs.text( + keywords='お好み焼き レシピ', # 検索ワード + region='jp-jp', # リージョン 日本は"jp-jp",指定なしの場合は"wt-wt" + safesearch='off', # セーフサーチOFF->"off",ON->"on",標準->"moderate" + timelimit=None, # 期間指定 指定なし->None,過去1日->"d",過去1週間->"w", + # 過去1か月->"m",過去1年->"y" + max_results=4 # 取得件数 + )) + +# レスポンスの表示 +for line in results: + print(json.dumps( + line, + indent=2, + ensure_ascii=False + )) \ No newline at end of file diff --git a/pegasus/Pegasus.py b/pegasus/Pegasus.py index 8d2a816..72d9488 100644 --- a/pegasus/Pegasus.py +++ b/pegasus/Pegasus.py @@ -10,14 +10,15 @@ from litellm import completion from tqdm import tqdm import litellm -# litellm.set_verbose=True +from duckduckgo_search import DDGS +import json logger = loguru.logger class Pegasus: 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): + model='gemini/gemini-1.5-pro-latest', rate_limit_sleep=60, other_error_sleep=10, search_query=None, max_results=10, base_url=None, url_file="urls.txt"): self.output_dir = output_dir self.exclude_selectors = exclude_selectors self.include_domain = include_domain @@ -33,6 +34,10 @@ def __init__(self, output_dir, exclude_selectors=None, include_domain=None, excl self.model = model self.rate_limit_sleep = rate_limit_sleep self.other_error_sleep = other_error_sleep + self.search_query = search_query + self.max_results = max_results + self.base_url = base_url + self.url_file = url_file tprint(" Pegasus ", font="rnd-xlarge") logger.info("初期化パラメータ:") logger.info(f" output_dir: {output_dir}") @@ -48,6 +53,34 @@ def __init__(self, output_dir, exclude_selectors=None, include_domain=None, excl logger.info(f" model: {model}") logger.info(f" rate_limit_sleep: {rate_limit_sleep}") logger.info(f" other_error_sleep: {other_error_sleep}") + logger.info(f" search_query: {search_query}") + logger.info(f" max_results: {max_results}") + logger.info(f" base_url: {base_url}") + logger.info(f" url_file: {url_file}") + + + def search_scraping(self): + tprint(">> Search Scraping ") + if self.search_query is None: + return + + with DDGS() as ddgs: + results = list(ddgs.text( + keywords=self.search_query, + region='jp-jp', + safesearch='off', + timelimit=None, + max_results=self.max_results + )) + + with open("urls.txt", "w", encoding="utf-8") as file: + for result in results: + url = result['href'] + title = result['title'] + body = result['body'] + file.write(f"{url}, {title}, {body[:20]}\n") + + logger.info(f"検索スクレイピング完了 .... {self.max_results}件取得") def filter_site(self, markdown_content): if(self.classification_prompt is None): @@ -61,7 +94,7 @@ def filter_site(self, markdown_content): {"role": "user", "content": f"{self.classification_prompt}\n\n{markdown_content}"} ] response = completion( - model="gemini/gemini-1.5-pro-latest", + model=self.model, messages=messages ) content = response.get('choices', [{}])[0].get('message', {}).get('content') @@ -77,9 +110,9 @@ def filter_site(self, markdown_content): 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から取得 + sleep_time = self.rate_limit_sleep else: - sleep_time = self.other_error_sleep # その他のエラー時のスリープ時間をself.other_error_sleepから取得 + sleep_time = self.other_error_sleep for _ in tqdm(range(sleep_time), desc="Sleeping", unit="s"): time.sleep(1) @@ -87,7 +120,7 @@ def filter_site(self, markdown_content): logger.error(f"フィルタリングに失敗しました。リトライ回数の上限に達しました。({self.max_retries}回)") return True - def download_and_convert(self, url, depth=0): + def download_and_convert(self, url, title, depth=0): if url in self.visited_urls: return self.visited_urls.add(url) @@ -106,6 +139,11 @@ def download_and_convert(self, url, depth=0): markdown_content = markdownify.markdownify(str(soup)) markdown_content = re.sub(r'\n{5,}', '\n\n\n\n', markdown_content) + # 文字化けチェック + if not self.is_valid_text(markdown_content): + logger.warning(f"文字化けを検出したため除外: {url}") + return + if not self.filter_site(markdown_content): parsed_url = urlparse(url) domain = parsed_url.netloc @@ -130,7 +168,7 @@ def download_and_convert(self, url, depth=0): 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]") + logger.info(f"[Depth:{depth}]変換成功: {url} ---> {output_file} [{len(markdown_content)/1000}kb]") if domain not in self.domain_summaries: self.domain_summaries[domain] = [] @@ -148,13 +186,24 @@ def download_and_convert(self, url, depth=0): 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) + self.download_and_convert(absolute_url, title, depth + 1) except requests.exceptions.RequestException as e: logger.error(f"ダウンロードエラー: {url}: {e}") except IOError as e: logger.error(f"書き込みエラー: {output_file}: {e}") + def is_valid_text(self, text): + # ASCII範囲外の文字の割合を計算 + non_ascii_chars = re.findall(r'[^\x00-\x7F]', text) + non_ascii_ratio = len(non_ascii_chars) / len(text) + + # 割合が一定以上であれば文字化けとみなす + if non_ascii_ratio > 0.3: + return False + else: + return True + 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}") @@ -162,8 +211,21 @@ def create_domain_summaries(self): 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) + def recursive_scraping(self): + tprint(">> Recursive Scraping ") + logger.info("再帰スクレイピング開始") + if self.base_url: + logger.info(f"base_url={self.base_url} から再帰スクレイピングを開始します") + self.download_and_convert(self.base_url, "") + else: + with open("urls.txt", "r", encoding="utf-8") as file: + for line in file: + parts = line.strip().split(",") + url = parts[0] + title = parts[1] if len(parts) > 1 else "" + logger.info(f"---------------------------------------") + logger.info(f"スクレイピング開始: url={url}") + if(title): logger.info(f"タイトル: {title})") + self.download_and_convert(url, title) self.create_domain_summaries() - logger.info("スクレイピング完了") \ No newline at end of file + logger.info("再帰スクレイピング完了") \ No newline at end of file diff --git a/pegasus/cli.py b/pegasus/cli.py index 3967257..cf1e398 100644 --- a/pegasus/cli.py +++ b/pegasus/cli.py @@ -5,9 +5,8 @@ def main(): parser = argparse.ArgumentParser(description='Pegasus') - parser.add_argument('--base-url', help='スクレイピングを開始するベースURL') - parser.add_argument('--url-file', help='スクレイピングするURLが記載されたテキストファイル') - parser.add_argument('output_dir', help='Markdownファイルの出力ディレクトリ') + parser.add_argument('mode', choices=['search', 'recursive'], help='実行モード(検索スクレイピングまたは再帰スクレイピング)') + parser.add_argument('--output_dir', default='output', help='Markdownファイルの出力ディレクトリ') parser.add_argument('--exclude-selectors', nargs='+', help='除外するCSSセレクター') parser.add_argument('--include-domain', default='', help='URLマッチングに含めるドメイン') parser.add_argument('--exclude-keywords', nargs='+', help='URLマッチングから除外するキーワード') @@ -20,7 +19,11 @@ def main(): parser.add_argument('--model', default='gemini/gemini-1.5-pro-latest', help='LiteLLMのモデル名 (デフォルト: gemini/gemini-1.5-pro-latest)') parser.add_argument('--rate-limit-sleep', type=int, default=60, help='レート制限エラー時のスリープ時間(秒) (デフォルト: 60)') parser.add_argument('--other-error-sleep', type=int, default=10, help='その他のエラー時のスリープ時間(秒) (デフォルト: 10)') - + parser.add_argument('--search-query', help='検索スクレイピングで使用するクエリ') + parser.add_argument('--max-results', type=int, default=3, help='検索スクレイピングの最大数') + parser.add_argument('--base-url', help='再帰スクレイピングを開始するベースURL') + parser.add_argument('--url-file', default="urls.txt", help='スクレイピングするURLが記載されたテキストファイル') + args = parser.parse_args() pegasus = Pegasus( @@ -33,18 +36,20 @@ def main(): max_depth=args.max_depth, system_message=args.system_message, classification_prompt=args.classification_prompt, - max_retries=args.max_retries + max_retries=args.max_retries, + model=args.model, + rate_limit_sleep=args.rate_limit_sleep, + other_error_sleep=args.other_error_sleep, + search_query=args.search_query, + max_results=args.max_results, + base_url=args.base_url, + url_file=args.url_file ) - if args.base_url: - pegasus.run(args.base_url) - elif args.url_file: - with open(args.url_file, 'r') as file: - urls = file.read().splitlines() - for url in urls: - pegasus.run(url) - else: - parser.error("--base-url または --url-file のいずれかを指定してください。") + if args.mode == 'search': + pegasus.search_scraping() + elif args.mode == 'recursive': + pegasus.recursive_scraping() if __name__ == '__main__': main() \ No newline at end of file diff --git a/setup.py b/setup.py index 965ba23..14210c2 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,7 @@ 'litellm', 'python-dotenv', 'google-generativeai', + 'duckduckgo-search', ], entry_points={ 'console_scripts': [