https://github.com/unclecode/crawl4ai
GitHub - unclecode/crawl4ai: ๐๐ค Crawl4AI: Open-source LLM Friendly Web Crawler & Scraper. Don't be shy, join here: https:/
๐๐ค Crawl4AI: Open-source LLM Friendly Web Crawler & Scraper. Don't be shy, join here: https://discord.gg/jP8KfhDhyN - unclecode/crawl4ai
github.com
ํ์ด์ฌ์ผ๋ก ํฌ๋กค๋ง์ ๊ตฌํํ์ ๋ถ๋ค์ Selenium์ด๋ BeautifulSoup์ผ๋ก ํน์ ์ฌ์ดํธ ์ ์ฉ ์คํฌ๋ ํผ๋ฅผ ๋ง๋ค์ด ๋ณธ ๊ฒฝํ์ด ์์ ๊ฒ์ ๋๋ค. ํ์ง๋ง ์ด ๋ฐฉ์์๋ ๋ช ํํ ํ๊ณ๊ฐ ์์ต๋๋ค.
- ๊ตฌ์กฐ์ ์ข ์์ฑ: ํฌ๋กค๋ง ๋์์ด A ์ฌ์ดํธ์์ B ์ฌ์ดํธ๋ก ๋ฐ๋๋ ์๊ฐ, ๋ชจ๋ ์ฝ๋๋ ์ธ๋ชจ์์ด์ง๋๋ค. div.content-area๊ฐ article#main์ผ๋ก ๋ฐ๋์๋ค๋ ์ด์ ๋ง์ผ๋ก, ๋งค๋ฒ ์๋ก์ด ์คํฌ๋ ํผ๋ฅผ ์์ฑํด์ผ ํฉ๋๋ค.
- ์ด๋ฏธ์ง ์ฝํ ์ธ ๋ถ์ฌ: ๋ ํฐ ๋ฌธ์ ๋ ์ ๋ณด๊ฐ ํ ์คํธ๊ฐ ์๋ ์ด๋ฏธ์ง์ ๋ด๊ฒจ์๋ ๊ฒฝ์ฐ์ ๋๋ค. alt ํ๊ทธ๊ฐ ๋น์ด์๋ ์ฐจํธ, ๋ฐฐ๋, ๋ค์ด์ด๊ทธ๋จ์ ๊ทธ์ 'ํ์ผ ๊ฒฝ๋ก'์ผ ๋ฟ, ๊ทธ ์์ ํต์ฌ ์ ๋ณด๋ฅผ ๋์น๊ฒ ๋ฉ๋๋ค.
์ ๋ ์ด ๋ ๊ฐ์ง ํ๊ณ(๊ตฌ์กฐ์ ์ข ์์ฑ, ์ด๋ฏธ์ง ์ฝํ ์ธ ๋ถ์ฌ)๋ฅผ ๋์์ ๊ทน๋ณตํ๊ธฐ ์ํด, ํน์ ์ฌ์ดํธ์ HTML ๊ตฌ์กฐ์ ์์กดํ์ง ์๋ AI ํฌ๋กค๋ง ํ์ดํ๋ผ์ธ์ ๊ตฌ์ถํ์ต๋๋ค.
์ด ํ์ดํ๋ผ์ธ์ ๋จผ์
1. crawl4ai์ ๋ฅํฌ๋กค๋ง์ผ๋ก ์ฌ์ดํธ์ ๋ชจ๋ URL์ ์์งํ๊ณ ,
2. ๊ฐ ํ์ด์ง์ ๋ชจ๋ ์ด๋ฏธ์ง๋ฅผ Gemini Vision ๋ชจ๋ธ๋ก ๋ถ์ํฉ๋๋ค.
3. ๊ทธ ํ, ์๋ณธ ํ ์คํธ์ ๋ถ์๋ ์ด๋ฏธ์ง ์ค๋ช ์ 'ํ๋์ HTML' ๊ตฌ์กฐ๋ก ๊ฒฐํฉํ ๋ค,
4. ์ด ํตํฉ ๋ฌธ์๋ฅผ ๋ค์ Gemini LLM์๊ฒ ๋๊ฒจ "ํต์ฌ ๋ณธ๋ฌธ"๋ง ๊ฑธ๋ฌ๋ด๋๋ก ์ค๊ณ๋์์ต๋๋ค.
์ด ๊ธ์์๋ crawl4ai, Gemini, BeautifulSoup๋ฅผ ์ฌ์ฉํด ์ด ๊ณผ์ ์ ์ด๋ป๊ฒ ์๋ํํ๋์ง, ๊ทธ๋ฆฌ๊ณ ๊ฐ ๊ธฐ์ ์ ์ ํํ ๊ทผ๊ฑฐ๋ ๋ฌด์์ธ์ง ์์ธํ ๊ณต์ ํฉ๋๋ค. ์์ ํฌ๋กค๋ง ํ์ด์ง๋... ์ ๊นํ๋ธ ํ์ด์ง๋ก ํ๊ฒ ์ต๋๋ค.
https://github.com/moonjoo98?tab=repositories
moonjoo98 - Overview
moonjoo98 has 29 repositories available. Follow their code on GitHub.
github.com
์ฌ์ฉํ ํต์ฌ ๊ธฐ์ ์ ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
- ํฌ๋กค๋ง ์์ง: crawl4ai (AsyncWebCrawler, LLMContentFilter)
- AI ๋ชจ๋ธ: Google Gemini 2.5 Flash (VLM ๋ฐ LLM)
- HTML ์ฒ๋ฆฌ: BeautifulSoup
- ๋น๋๊ธฐ ์ฒ๋ฆฌ: asyncio
ํ๋ก์ธ์ค 1: ์ฌ์ดํธ ๋งตํ (๋ชจ๋ ์ ํจ URL ์์ง)
๊ฐ์ฅ ๋จผ์ , ์ฐ๋ฆฌ๊ฐ ์ฒ๋ฆฌํด์ผ ํ ๋์์ด ๋ช ๊ฐ์ธ์ง ์์์ผ ํฉ๋๋ค. crawl4ai์ ๋ฅํฌ๋กค๋ง ๊ธฐ์ ์ ์ฌ์ฉํ์ฌ ์ฌ์ดํธ ์ ์ฒด๋ฅผ ์ค์บํ์ฌ ๋ฐฉ๋ฌธ ๊ฐ๋ฅํ ๋ชจ๋ ํ์ด์ง์ url๋ฅผ ์์งํ์ต๋๋ค.
์ฌ์ฉํ ๊ธฐ์ : crawl4ai์ BFSDeepCrawlStrategy (๋๋น ์ฐ์ ํ์)
- ์ฒด๊ณ์ ์ธ ํ์ (BFS > DFS): ๋ชจ๋ ๋งํฌ๋ฅผ ๋น ์ง์์ด ์ฐพ๋ ๊ฒ์ด ๋ชฉํ์ผ ๋, BFS(๋๋น ์ฐ์ )๋ ๊ฐ์ฅ ์ฒด๊ณ์ ์ด๊ณ ์์ ์ ์ธ ๋ฐฉ๋ฒ์ ๋๋ค. 1๋จ๊ณ ๊น์ด์ ๋ชจ๋ ๋งํฌ๋ฅผ ์ฐพ๊ณ , ๊ทธ๋ค์ 2๋จ๊ณ ๊น์ด์ ๋ชจ๋ ๋งํฌ๋ฅผ ์ฐพ๋ ๋ฐฉ์์ด์ฃ . ๋ฐ๋ฉด DFS(๊น์ด ์ฐ์ )๋ ํน์ ๊ฒฝ๋ก์ ๋๋ฌด ๊น์ด ๋น ์ ธ(์: ๋ฌดํ ์บ๋ฆฐ๋ ํ์ด์ง) ๋ค๋ฅธ ์ค์ํ ์น์ ์ ๋์น ์ํ์ด ์์ต๋๋ค.
- ๋ช ํํ ๊ฒฝ๊ณ ์ค์ (include_external=False): ์ ์ ๋ชฉํ๋ github.com ๋ด๋ถ ์ฝํ ์ธ ์ ๋๋ค. include_external=False ์ต์ ์ ํฌ๋กค๋ฌ๊ฐ ์ธ๋ถ SNS, ๋ธ๋ก๊ทธ, ๊ด๊ณ ๋งํฌ๋ก ๋น ์ ธ๋๊ฐ ์์์ ๋ญ๋นํ๋ ๊ฒ์ ๋ง์์ค๋๋ค.
- ์์
์ ๋ถ๋ฆฌ (Separation of Concerns): "URL ์์ง"๊ณผ "์ฝํ
์ธ ์ฒ๋ฆฌ"๋ ์์ ํ ๋ค๋ฅธ ์์
์
๋๋ค. ์ด ๋ ์์
์ ๋ถ๋ฆฌํ๋ฉด, URL ์์ง์ด ์คํจํ๋๋ผ๋ ์ด๋ฏธ ์ฒ๋ฆฌํ ์ฝํ
์ธ ๋ ์์ ํ๋ฉฐ, ๋์ค์ ์ฝํ
์ธ ์ฒ๋ฆฌ๋ง ์ฌ์๋ํ ์ ์์ด ๋งค์ฐ ์์ ์ ์ด๊ณ ํจ์จ์ ์ธ ํ์ดํ๋ผ์ธ์ด ๋ฉ๋๋ค.
- ๋จ, CrawlerRunConfig์ ๊ฐ์ด ๋ฅํฌ๋กค๋ง๊ณผ ํ ์คํธ ์์ง์ ๋์์ ์งํํ ์๋ ์๋๋ฐ์. ์ด๊ฒ ํจ์ฌ ๋ ํจ์จ์ ์ด๊ณ ๋น ๋ฅด๊ธด ํ๋, ์ ๋ ์ด๋ฏธ์ง์ ์ ํ์๋ ํ ์คํธ์ ์ด๋ฏธ์ง์ ๋ํ ์ค๋ช ์ด ๋ชจ๋ ํ์ํ๊ธฐ ๋๋ฌธ์ ์์ ์ ๋ถ๋ฆฌํ์ต๋๋ค.
- crawl4ai์์๋ Tesseract OCR๊ณผ ๊ฐ์ ๊ฒ์ ๋ถ๋ฌ์์ ์ฌ์ฉํ ์ ์๋ ๊ฒ์ผ๋ก ์๊ธดํ๋๋ฐ, Tesseract OCR์ ํ๊ตญ์ด ์ฑ๋ฅ์ด ๋จ์ด์ง๋ ๊ฒ ๊ฐ๊ธฐ๋ํ๊ณ ์ด๋ฏธ์ง์ ๋ํ ์ค๋ช ์ ๋ฌ์์ค ์๋ ์์ด์ ์์ ์ ๋ถ๋ฆฌํ์ต๋๋ค.
# 1๋จ๊ณ: URL ์์ง ์ฝ๋ ์์
deep_crawl_config = BFSDeepCrawlStrategy(
max_depth=5, # ์ฌ์ดํธ ๊ตฌ์กฐ์ ๋ง์ถฐ ์ ์ ํ ๊น์ด
include_external=False, # ์ฐ๋ฆฌ ๋๋ฉ์ธ์๋ง ์ง์ค
max_pages=500 # ์๋ฒ ๋ถ๋ด์ ์ค์ด๊ธฐ ์ํ ์์ ์ฅ์น
)
์๋์ ๊ฐ์ด ์์ ํ์ด์ง https://github.com/moonjoo98?tab=repositories ์์ ๋ชจ๋ ๋งํฌ๋ฅผ max_depth = 5 ๊น์ง ํ์ํ๋ฉด์ ๋ชจ๋ ๋งํฌ๋ฅผ ๋น ์ง์์ด ์ฐพ๊ณ ์๊ณ , githun.com ๋๋ฉ์ธ์ URL๋ง ์์งํ๋ ๊ฒ์ ๋ณด์ค ์ ์์ต๋๋ค.

import asyncio
from crawl4ai import AsyncWebCrawler, CrawlerRunConfig
from crawl4ai.deep_crawling import BFSDeepCrawlStrategy
from crawl4ai.content_scraping_strategy import LXMLWebScrapingStrategy
async def main():
# ํฌ๋กค๋งํ ์์ URL
start_url = "https://github.com/moonjoo98?tab=repositories"
# ๋ฅํฌ๋กค๋ง ์ ๋ต ์ค์
deep_crawl_config = BFSDeepCrawlStrategy(
max_depth=5,
# include_external=False: ํด๋น ๋๋ฉ์ธ ๋ด์ ๋งํฌ๋ง ์์ง
include_external=False,
max_pages=500
)
# ์ ์ฒด ํฌ๋กค๋ฌ ์คํ ์ค์
config = CrawlerRunConfig(
deep_crawl_strategy=deep_crawl_config,
scraping_strategy=LXMLWebScrapingStrategy(),
verbose=True # ํฌ๋กค๋ง ์งํ ์ํฉ์ ์ฝ์์ ์ถ๋ ฅ
)
# ์์ง๋ ๊ณ ์ ๋งํฌ๋ฅผ ์ ์ฅํ Set
collected_links = set()
print(f"ํฌ๋กค๋ง์ ์์ํฉ๋๋ค. ๋์: {start_url}")
async with AsyncWebCrawler() as crawler:
results = await crawler.arun(start_url, config=config)
for result in results:
if result.url:
collected_links.add(result.url)
print(f"\n--- ํฌ๋กค๋ง ์๋ฃ ---")
print(f"์ด {len(collected_links)}๊ฐ์ ๊ณ ์ ํ ๋งํฌ๋ฅผ ์์งํ์ต๋๋ค.")
# ์์ง๋ ๋งํฌ ๋ชฉ๋ก์ ์ ๋ ฌํ์ฌ ๋ฐํ
return sorted(list(collected_links))
# --- ๋ฉ์ธ ์คํ ๋ถ๋ถ ---
if __name__ == "__main__":
# main ํจ์๋ฅผ ์คํํ๊ณ URL ๋ฆฌ์คํธ๋ฅผ ๋ฐ์
url_list = asyncio.run(main())
# URL ๋ฆฌ์คํธ๋ฅผ ํ์ผ์ ์ ์ฅ
output_filename = "collected_urls_test.txt"
try:
with open(output_filename, "w", encoding="utf-8") as f:
for url in url_list:
f.write(url + "\n") # ๊ฐ URL์ ์ ์ค์ ์ ์ฅ
print(f"'{output_filename}' ํ์ผ์ {len(url_list)}๊ฐ์ URL์ ์ฑ๊ณต์ ์ผ๋ก ์ ์ฅํ์ต๋๋ค.")
except Exception as e:
print(f"ํ์ผ ์ ์ฅ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค: {e}")
ํ๋ก์ธ์ค 2: ๋์ HTML ์ฝํ ์ธ ํ๋ณด
์์งํ URL์ ์ด์ ํ๋์ฉ ์ฒ๋ฆฌํฉ๋๋ค. ํ์ง๋ง ์ง์ ํด๋ณด์๋ฉด ์น์ฌ์ดํธ๋ requests.get()๋ง์ผ๋ก๋ ์ํ๋ HTML ๊ตฌ์กฐ๋ฅผ ๋ชจ๋ ๊ฐ์ ธ์ฌ ์ ์๋ค๋ ๊ฒ์ ์์ค๊ฒ๋๋ค.
- ์ฌ์ฉํ ๊ธฐ์ : crawl4ai์ AsyncWebCrawler + BrowserConfig
- ์๋ฐ์คํฌ๋ฆฝํธ ๋ ๋๋ง ๋์: ํน์ ์ฌ์ดํธ๋ EgovPageLink.do?link=...์ ๊ฐ์ด URL ํ๋ผ๋ฏธํฐ๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ์๋ฐ์คํฌ๋ฆฝํธ๊ฐ ์ฝํ ์ธ ๋ฅผ ๋์ ์ผ๋ก ์์ฑํฉ๋๋ค. requests๋ httpx ๊ฐ์ ๋จ์ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ ํ ๋น ๊ป๋ฐ๊ธฐ HTML๋ง ๊ฐ์ ธ์ต๋๋ค. BrowserConfig(headless=True)๋ crawl4ai๊ฐ ๋ฐฑ๊ทธ๋ผ์ด๋์์ ์ค์ ๋ธ๋ผ์ฐ์ (Playwright)๋ฅผ ์คํํ๋๋ก ์ง์ํฉ๋๋ค. ์ด ๋ธ๋ผ์ฐ์ ๋ ์๋ฐ์คํฌ๋ฆฝํธ๋ฅผ ๋ชจ๋ ์คํํ์ฌ ์ฌ์ฉ์๊ฐ ๋ณด๋ ์ต์ข ๋ ๋๋ง ๊ฒฐ๊ณผ(HTML)๋ฅผ ์ฐ๋ฆฌ์๊ฒ ์ ๋ฌํฉ๋๋ค.
- ์ถ์ํ์ ํธ๋ฆฌํจ: Selenium์ด๋ Playwright๋ฅผ ์ง์ ์ฐ๋ฉด ์ฝ๋๊ฐ ๋งค์ฐ ๋ณต์กํด์ง๋๋ค. crawl4ai๋ ์ด ๋ณต์กํ ๋ธ๋ผ์ฐ์ ์ ์ด๋ฅผ crawler.arun(url)์ด๋ผ๋ ๋จ ํ๋์ ๋ช ๋ น์ด๋ก ์ถ์ํํด ์ค๋๋ค.
browser_config = BrowserConfig(headless=True, verbose=False) # ๋ฃจํ ์ค์๋ False ๊ถ์ฅ
crawl_config = CrawlerRunConfig(
cache_mode=CacheMode.ENABLED,
delay_before_return_html=2 # html์ด ๋ชจ๋ ๋๋๋ง ๋ ๋๊น์ง ์ง์ฐ์๊ฐ ์ถ๊ฐ
)
final_result = { "url": url, "combined_markdown": None }
try:
print(f" [์์] ํฌ๋กค๋ง ์์: {url}")
async with AsyncWebCrawler(config=browser_config) as crawler:
result = await crawler.arun(url, config=crawl_config)
ํ๋ก์ธ์ค 3: ์ด๋ฏธ์ง ์๋ฏธ๋ก ์ ๋ถ์ (VLM)
HTML์ ํ๋ณดํ๋ค๋ฉด, ์ด์ 'ํ ์คํธ'์ '์ด๋ฏธ์ง'๋ฅผ ๋ถ๋ฆฌํฉ๋๋ค. ์ด๋ฏธ์ง๋ ๋จ์ํ ํ์ผ์ด ์๋๋ผ ๊ทธ ์์ฒด๋ก ์ค์ํ ์ฝํ ์ธ ์ ๋๋ค.
- ์ฌ์ฉํ ๊ธฐ์ : BeautifulSoup + asyncio.gather + Gemini-2.5-Flash (Vision)
- ์ ๋ขฐํ ์ ์๋ alt ํ๊ทธ: <img> ํ๊ทธ์ alt ์์ฑ์ ๋๋ถ๋ถ ๋น์ด์๊ฑฐ๋, "๋ฉ์ธ ๋ฐฐ๋", "์์ด์ฝ"์ฒ๋ผ ๋ฌด์๋ฏธํฉ๋๋ค. ์ฐ๋ฆฌ๋ ์ด๋ฏธ์ง์ ์ค์ ๋ก ๋ฌด์จ ๋ด์ฉ์ด ์๋์ง ์์์ผ ํฉ๋๋ค. Gemini๋ ์ด๋ฏธ์ง๋ฅผ ์ง์ ๋ณด๊ณ ๊ทธ ์์ ๋ด๊ธด ํ ์คํธ๋ฅผ ๊ทธ๋๋ก ์ธ์ํ๊ฑฐ๋ ํ, ๋ค์ด์ด๊ทธ๋จ, ์ํ ์ด๋ฏธ์ง ๋ฑ์ ์ค๋ช ์ ์์ฑํ ์ ์์ต๋๋ค.
- ๋ณ๋ ฌ ์ฒ๋ฆฌ : ํ ํ์ด์ง์ 20๊ฐ์ ์ด๋ฏธ์ง๊ฐ ์๋ค๋ฉด, ์์ฐจ์ ์ผ๋ก ์ฒ๋ฆฌ ์ 1๋ถ ์ด์ ๊ฑธ๋ฆด ์ ์์ต๋๋ค. asyncio.gather(*image_tasks)๋ 20๊ฐ์ ์ด๋ฏธ์ง ๋ถ์ ์์ฒญ์ ๋์์ Gemini ์๋ฒ๋ก ์ ์กํ๊ณ ๊ฐ์ฅ ๋น ๋ฅธ ์์๋๋ก ์๋ต์ ๋ฐ์ต๋๋ค. ์ด๋ ์ ์ฒด ํ๋ก์ธ์ค ์๋๋ฅผ ํฅ์์ํค๋ ํต์ฌ ๊ธฐ์ ์ ๋๋ค.
- ๋น์ฉ ๋ฐ ๋ ธ์ด์ฆ ์ต์ ํ (MIN_IMAGE_SIZE): ์นํ์ด์ง์๋ 1x1 ํฝ์ ์ง๋ฆฌ ์ถ์ ์ฉ ์ด๋ฏธ์ง๋ ์์ ์์ด์ฝ์ด ๋ง์ต๋๋ค. if img.width < 100: ๊ฐ์ ํํฐ๋ฅผ ์ถ๊ฐํ์ฌ, ์๋ฏธ ์์ ํ๋ฅ ์ด ๋์ ์์ ์ด๋ฏธ์ง์ ๋ํด์๋ VLM API ํธ์ถ์ ์ฐจ๋จํ์ต๋๋ค.
async def get_image_description_from_llm(image_url: str):
if not image_url or image_url.startswith("data:"):
return "๋ฐ์ดํฐ URI ๋๋ ๋น ์ด๋ฏธ์ง"
model = genai.GenerativeModel('gemini-2.5-flash')
try:
headers = {'User-Agent': 'Mozilla/5.0'}
response_img = requests.get(image_url, headers=headers, timeout=30)
response_img.raise_for_status()
img = Image.open(BytesIO(response_img.content))
if img.width < MIN_IMAGE_SIZE or img.height < MIN_IMAGE_SIZE:
return "" # ํํฐ๋ง๋จ (๋น ๋ฌธ์์ด)
response_llm = await model.generate_content_async([instruction_for_images, img])
if not response_llm.parts:
return ""
return response_llm.text
except Exception as e:
return f"LLM/์ด๋ฏธ์ง ๋ค์ด๋ก๋/์ด๊ธฐ ์คํจ: {e}"
async def process_url_structured(url: str):
"""
ํ๋์ URL์ ๋ฐ์ ํ
์คํธ/์ด๋ฏธ์ง๋ฅผ ๊ฒฐํฉํ ๋งํฌ๋ค์ด ๊ฒฐ๊ณผ๋ฅผ
ํ์ด์ฌ ๋์
๋๋ฆฌ ํํ๋ก ๋ฐํํฉ๋๋ค.
"""
browser_config = BrowserConfig(headless=True, verbose=False) # ๋ฃจํ ์ค์๋ False ๊ถ์ฅ
crawl_config = CrawlerRunConfig(
cache_mode=CacheMode.ENABLED,
delay_before_return_html=2
)
final_result = { "url": url, "combined_markdown": None }
try:
print(f" [์์] ํฌ๋กค๋ง ์์: {url}")
async with AsyncWebCrawler(config=browser_config) as crawler:
result = await crawler.arun(url, config=crawl_config)
if not result.success or not result.cleaned_html:
print(f" [์คํจ] HTML์ ๊ฐ์ ธ์ค์ง ๋ชปํ์ต๋๋ค: {url}")
return None # ์คํจ ์ None ๋ฐํ
html_content = result.cleaned_html
soup = BeautifulSoup(html_content, 'html.parser')
# --- ๋จ๊ณ 1: ์ด๋ฏธ์ง ํ๊ทธ ์ฐพ๊ธฐ ๋ฐ ๋ณ๋ ฌ ์ฒ๋ฆฌ ์ค๋น ---
print(f" [์งํ] LLM Vision ์ฒ๋ฆฌ (์ด๋ฏธ์ง)...")
image_tags = soup.find_all('img')
processed_urls = set()
image_tasks = []
url_to_tag_map = {}
for img_tag in image_tags:
img_src = img_tag.get('src')
if not img_src:
img_tag.decompose() # src ์๋ ํ๊ทธ๋ HTML์์ ์ ๊ฑฐ
continue
absolute_img_url = urljoin(url, img_src)
if absolute_img_url in processed_urls:
continue # ์ค๋ณต URL
processed_urls.add(absolute_img_url)
url_to_tag_map[absolute_img_url] = img_tag
image_tasks.append(get_image_description_from_llm(absolute_img_url))
# --- ๋จ๊ณ 2: ๋ชจ๋ ์ด๋ฏธ์ง LLM ๋ณ๋ ฌ ์ฒ๋ฆฌ ---
if image_tasks:
print(f" [์งํ] ๊ณ ์ ์ด๋ฏธ์ง {len(image_tasks)}๊ฐ ๋ณ๋ ฌ ์ฒ๋ฆฌ ์ค...")
image_results = await asyncio.gather(*image_tasks)
print(f" [์๋ฃ] ์ด๋ฏธ์ง LLM ์ฒ๋ฆฌ ์๋ฃ.")
else:
image_results = []
# --- ๋จ๊ณ 3: HTML์ ์ด๋ฏธ์ง ์ค๋ช
์ถ๊ฐ ---
for (img_url, img_tag), description in zip(url_to_tag_map.items(), image_results):
if description and "LLM/์ด๋ฏธ์ง" not in description:
# [์ฑ๊ณต] LLM ๊ฒฐ๊ณผ๋ฅผ <img> ํ๊ทธ ๋์ ์ฝ์
placeholder_text = f"\n\n--- [์ด๋ฏธ์ง: {img_url}] ---\n{description}\n--- [์ด๋ฏธ์ง ๋] ---\n\n"
img_tag.replace_with(BeautifulSoup(placeholder_text, 'html.parser'))
else:
# [์คํจ ๋๋ ํํฐ๋ง๋จ] <img> ํ๊ทธ๋ฅผ HTML์์ ๊ทธ๋ฅ ์ญ์
img_tag.decompose()
# ์ค๋ณต ์ฌ์ฉ๋ <img> ํ๊ทธ๋ค ์ฒ๋ฆฌ (์ฒซ ๋ฒ์งธ๊ฐ ์๋์๋ ํ๊ทธ๋ค)
for img_tag in soup.find_all('img'):
img_tag.decompose() # ๋จ์ <img> ํ๊ทธ ๋ชจ๋ ์ ๊ฑฐ
modified_html = str(soup)
# --- ๋จ๊ณ 4: ์ต์ข
ํ
์คํธ ํํฐ๋ง ---
print(f" [์งํ] LLM ํํฐ๋ง (ํ
์คํธ + ์ด๋ฏธ์ง ์ค๋ช
ํตํฉ๋ณธ)...")
filter = LLMContentFilter(
llm_config=LLMConfig(
provider="gemini/gemini-2.5-flash", # ๋ชจ๋ธ๋ช
์์
api_token=API_KEY
),
instruction=instruction_to_keep_all, # HTML ๊ตฌ์กฐ์์ ์ถ์ถํ๊ณ ์ถ์ ๋ถ๋ถ์ ํ๋กฌํํธ๋ก ์ ๋ฌ.
verbose=False # ๋ฃจํ ์ค์๋ False ๊ถ์ฅ
)
filtered_content_list = filter.filter_content(modified_html)
final_result["combined_markdown"] = "\n".join(filtered_content_list)
text_snippet = final_result["combined_markdown"][:100].replace("\n", " ")
print(f" [๊ฒฐ๊ณผ] ํ
์คํธ (100์): {text_snippet}...")
return final_result # ์ฑ๊ณต ์ ๊ฒฐ๊ณผ ๋์
๋๋ฆฌ ๋ฐํ
except Exception as e:
print(f" [์ค๋ฅ] {url} ์ฒ๋ฆฌ ์ค ์์ธ ๋ฐ์: {e}")
return None # ์คํจ ์ None ๋ฐํ
ํ๋ก์ธ์ค 4: ํ ์คํธ-์ด๋ฏธ์ง ์ฝํ ์ธ ๊ฒฐํฉ
์ด์ '์๋ณธ ํ ์คํธ'์ '์ด๋ฏธ์ง ์ค๋ช ํ ์คํธ'๋ฅผ ๊ฐ๊ฐ ๊ฐ์ง๊ณ ์์ต๋๋ค. ๊ทผ๋ฐ ์ ๋ ์นํ์ด์ง์ ๊ตฌ์กฐ๋ฅผ ์ต๋ํ ๋ณด์กดํ ์ํ๋ก ํ๋์ ๋งํฌ๋ค์ด ๊ตฌ์กฐ๋ก ๋ณํํ๋ ๊ฒ์ด ๋ชฉํ์ ๋๋ค. ๊ทธ๋์ ์ด๋ฏธ์ง ํ๊ทธ๋ฅผ ์ด๋ฏธ์ง OCR ๊ฒฐ๊ณผ๋ ์ค๋ช ์ผ๋ก ๊ต์ฒดํด๋ฒ๋ ค์ ์ต๋ํ ์ค์ ์นํ์ด์ง์ ๋ณด์ด๋ ์์๋๋ก ํ ์คํธ๋ฅผ ๋ฐฐ์ดํฉ๋๋ค.
์ด๋ ๊ฒ ํ ์คํธ-์ด๋ฏธ์ง ์ฝํ ์ธ ๋ฅผ ๊ฒฐํฉํด์ ์ต์ข Output์ผ๋ก ์ ์ ํ๊ณ ์ถ์ด url ์์ง๊ณผ ์คํฌ๋ํ ์์ ์ ๋ถ๋ฆฌํ์ต๋๋ค.
- ์ฌ์ฉํ ๊ธฐ์ : BeautifulSoup์ img_tag.replace_with()
ํ ์คํธ์ ์ด๋ฏธ์ง ๋ถ์ ๊ฒฐ๊ณผ๋ฅผ ๋ณ๊ฐ์ ํ์ผ๋ก ์ ์ฅํ๋ ๋์ , ์๋ณธ HTML์ <img> ํ๊ทธ๋ฅผ VLM์ด ๋ถ์ํ "์ด๋ฏธ์ง ์ค๋ช ํ ์คํธ"๋ก ๊ต์ฒดํด ๋ฒ๋ ธ์ต๋๋ค.
์์๋ ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
<p>์์ ์๋ด์
๋๋ค.</p>
<img src="chart.jpg">
<p>์ฃผ์์ฌํญ์
๋๋ค.</p>
<p>์์ ์๋ด์
๋๋ค.</p>
"--- [์ด๋ฏธ์ง: .../chart.jpg] ---
์ฆ์(Symptom)๊ณผ ์น๋ฃ๋ฒ(Treatment)์ ๋ํ๋ด๋ ํ.
์ฝ๋งํ: ์ฝ๋ฌผ ์น๋ฃ
--- [์ด๋ฏธ์ง ๋] ---"
<p>์ฃผ์์ฌํญ์
๋๋ค.</p>
์ด์ : ์ด๋ ๊ฒ ํ๋ฉด 'ํ ์คํธ'์ '์ด๋ฏธ์ง'๋ผ๋ ๋ ์ข ๋ฅ์ ๋ฐ์ดํฐ๋ฅผ "ํ๋์ ์์ํ ํ ์คํธ ๋ฌธ์"๋ก ํตํฉํ ์ ์์ต๋๋ค. ์ด์ ๋ค์ ๋จ๊ณ์ LLM์ ์ด ํตํฉ๋ ๋ฌธ์๋ฅผ ๋ณด๊ณ "์, ์ด ํ ์คํธ ๋ค์์ ์ด๋ฐ ์ด๋ฏธ์ง๊ฐ ์์๊ตฌ๋"๋ผ๋ฉฐ ๋ฌธ๋งฅ(Context)์ ์๋ฒฝํ๊ฒ ์ดํดํ ์ ์์ต๋๋ค. ๊ทธ๋ฆฌ๊ณ , ์ค์ ๋ก RAG์์ ๋ฌธ์๋ฅผ ๊ฒ์ํ ๋๋ ๊ด๋ จ ์๋ ์ด๋ฏธ์ง๊น์ง ํฌํจํด์ ๊ฒ์์ ์ํํ ์ ์์ด ์ฑ๋ฅ ํฅ์์ ๋์์ด ๋ฉ๋๋ค.
ํ๋ก์ธ์ค 5: ์ต์ข ์ฝํ ์ธ ์ ์ (LLM)
์ด์ ์ฐ๋ฆฌ๋ ํ ์คํธ์ ์ด๋ฏธ์ง ์ค๋ช ์ด ํฉ์ณ์ง, ํ์ง๋ง ์ฌ์ ํ "ํค๋", "ํธํฐ", "๋ฉ๋ด", "๊ด๊ณ " ๋ฑ ๋ ธ์ด์ฆ๊ฐ ๊ฐ๋ํ HTML์ ๊ฐ์ง๊ณ ์์ต๋๋ค.
- ์ฌ์ฉํ ๊ธฐ์ : crawl4ai์ LLMContentFilter + ๋ง์ถคํ ํ๋กฌํํธ
- soup.get_text()์ ํ๊ณ: ๋จ์ํ get_text()๋ฅผ ์ฐ๋ฉด "Copyright", "๋ก๊ทธ์ธ", "๋งจ ์๋ก ๊ฐ๊ธฐ", "๋น ๋ฅธ ์๋ด" ๋ฑ ์จ๊ฐ UI ํ ์คํธ๊ฐ ๋ค์์ธ ๋ฐ์ดํฐ๊ฐ ๋์ต๋๋ค.
- ํ๋กฌํํธ๋ = ๋ก์ง: ์ ๋ AI์๊ฒ ๋ช
ํํ ๊ท์น์ ์ง์ํ์ต๋๋ค. (instruction_to_keep_all๋ ํ๋กฌํํธ์
๋๋ค.)
- ๊ท์น 1 (๋งํฌ ์ ์ ): [๋๋ฆผ์ ](/dl/index.do) ๊ฐ์ ๋งํฌ๋ค์ด ๋งํฌ๋ฅผ "๋๋ฆผ์ "์ด๋ผ๋ ์์ ํ ์คํธ๋ก ๋ณํํฉ๋๋ค. []()๊ณผ URL์ ์ ๊ฑฐํ์ฌ ์๋ฏธ๋ง ๋จ๊น๋๋ค.
- ๊ท์น 2 (๋ ธ์ด์ฆ ์ ๊ฑฐ): "๊ณตํต ํค๋/ํธํฐ", "์ฌ์ ์ ์ ๋ณด", "Copyright" ๋ฑ [์ ์ธํ ๋ด์ฉ]์ ๋ช ์๋ ๋ชจ๋ UI ์์๋ฅผ ์ญ์ ํฉ๋๋ค.
- ๊ท์น 3 (๋ณธ๋ฌธ ๋ณด์กด): [ํฌํจํ ๋ด์ฉ]์ ๋ช ์๋ "ํต์ฌ ๋ณธ๋ฌธ"๊ณผ "์ด๋ฏธ์ง ์ค๋ช "์ ๋ณด์กดํฉ๋๋ค.
- ๊ท์น 4 (๋งํฌ๋ค์ด ๋ณํ): LLM์ ๋งํฌ๋ค์ด ํฌ๋งท์ ๋ฌธ๋งฅ์ ๋ ์ ์ดํดํ๋ค๋ ์ฐ๊ตฌ ๊ฒฐ๊ณผ๊ฐ ์์ต๋๋ค. ๋ํ, ํ ์ด๋ธ๋ ๋งํฌ๋ค์ด์ผ๋ก ๋ณํํ๋ฉด LLM์ ํ ์ดํด๋๊ฐ ์ฌ๋ผ๊ฐ๊ธฐ๋ ํ์ฃ . ๊ทธ๋์ HTML ๊ตฌ์กฐ๋ฅผ ๋งํฌ๋ค์ด ํ์์ผ๋ก ๋ณํํ ์ต์ข ๊ฒฐ๊ณผ๋ฅผ ์์ฑํ๋๋ก ์ง์ํฉ๋๋ค.
LLMContentFilter๋ ์ด ์ง์๋ฌธ์ ๋ฐ์, ๋ ธ์ด์ฆ๊ฐ ๋ง์ modified_html์ ์ ๋ ฅ๋ฐ๊ณ , ์ฐ๋ฆฌ๊ฐ ์ํ๋ ์์ํ ํต์ฌ ์ฝํ ์ธ (combined_markdown)๋ง ๋งํฌ๋ค์ด ํ์์ผ๋ก ์ถ๋ ฅํด ์ค๋๋ค.
๊ฒฐ๋ก
์ด ํ์ดํ๋ผ์ธ์ Selenium ์ฝ๋๊ฐ ํน์ HTML ๊ตฌ์กฐ์ ์ข ์๋๋ ํ๊ณ์, ์ด๋ฏธ์ง ์ค์ฌ์ ์ฝํ ์ธ ๋ฅผ ๋์น๋ ํ๊ณ๋ฅผ crawl4ai์ Gemini๋ฅผ ํ์ฉํ์ฌ ๊ทน๋ณตํ์ต๋๋ค.
๊ฐ ๋จ๊ณ์์ ์ requests ๋์ ๋ธ๋ผ์ฐ์ ๋ฅผ, ์ alt ํ๊ทธ ๋์ VLM์, ์ ์์ฐจ ๋์ ๋ณ๋ ฌ ์ฒ๋ฆฌ๋ฅผ, ์ get_text() ๋์ LLM ํํฐ๋ฅผ, ๊ทธ๋ฆฌ๊ณ ์ ๋จ์ผ ํ์ผ ๋์ ๊ฐ๋ณ ํ์ผ์ ์ ํํ๋์ง์ ๋ํด ์ ๊ฐ ์๊ฐํ ๋ถ๋ถ๋ค๋ ๊ธ์ ๋ด์๋ดค์ต๋๋ค.
๋ฌผ๋ก , LLM์ผ๋ก ์ ์ ํ๊ณ ์ด๋ฏธ์ง ํด์์ ๋งก๊ธฐ๋ค๋ณด๋ ํ ๋ฃจ์๋ค์ด์ ์ด ๋ฐ์ํ ์๋ ์์ต๋๋ค. ์ด๋ฌํ ๊ฒฝ์ฐ๋ฅผ ๊ณ ๋ คํ ํ์ฒ๋ฆฌ ๋ฐฉ์๋ค์ ์ง์ ๊ตฌํํ์ ์ ์ฌ์ฉํ์๋ฉด ์ข์ ๊ฒ ๊ฐ์ต๋๋ค.
๋ํ, ์ด๋ฏธ์ง ์ค๋ช ์ ์ ์ธํ๊ณ OCR + ์์ ํ ์คํธ ์ ์ฒด๋ฅผ ์์งํ๊ณ ์ถ์ผ์๋ค๋ฉด crawl4ai ์์ฒด ๊ธฐ๋ฅ๋ง์ผ๋ก๋ ์ถฉ๋ถํ ๊ตฌํ ๊ฐ๋ฅํ ๋ฐฉ๋ฒ๋ค์ด ์์ผ๋ ์๋์ ๊ณต์ ๋ฌธ์์ ์์๋ฅผ ์ฐธ๊ณ ํ์ฌ ํ์ํ ์ํฉ์ ๋ง๊ฒ ์ฌ์ฉํ์๋ฉด ์ข์ ๊ฒ ๊ฐ์ต๋๋ค.
๊ฐ์ฌํฉ๋๋ค.