Playwright-based crawler that renders pages from api.intra-mart.jp and document.intra-mart.jp, extracts main content, and converts to Markdown via Pandoc. SQLite-backed queue drives a resumable sequential pipeline across crawler, extractor, converter, and storage modules. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
248 lines
5.8 KiB
Python
248 lines
5.8 KiB
Python
import asyncio
|
|
from typing import Set
|
|
|
|
from bs4 import BeautifulSoup
|
|
|
|
from crawler.playwright_client import PlaywrightClient
|
|
from crawler.url_manager import URLManager
|
|
|
|
from extractor.html_extractor import HTMLExtractor
|
|
from extractor.html_cleaner import HTMLCleaner
|
|
|
|
from converter.markdown_converter import MarkdownConverter
|
|
from converter.path_mapper import PathMapper
|
|
|
|
from storage.file_writer import FileWriter
|
|
|
|
from utils.logger import logger
|
|
|
|
from converter.link_rewriter import LinkRewriter
|
|
from extractor.asset_downloader import AssetDownloader
|
|
|
|
class DocumentationCrawler:
|
|
def __init__(self, config):
|
|
self.config = config
|
|
|
|
self.client = PlaywrightClient(
|
|
timeout=config["crawl"]["timeout"],
|
|
headless=config["playwright"]["headless"]
|
|
)
|
|
|
|
self.url_manager = URLManager(
|
|
config["allowed_domains"]
|
|
)
|
|
|
|
self.extractor = HTMLExtractor(
|
|
config["selectors"]["main_content"]
|
|
)
|
|
|
|
self.cleaner = HTMLCleaner(
|
|
config["selectors"]["remove"]
|
|
)
|
|
|
|
self.converter = MarkdownConverter()
|
|
|
|
self.mapper = PathMapper(
|
|
config["output_dir"] + "/markdown"
|
|
)
|
|
|
|
self.writer = FileWriter()
|
|
|
|
self.link_rewriter = LinkRewriter(
|
|
config["output_dir"] + "/markdown"
|
|
)
|
|
|
|
self.asset_downloader = AssetDownloader(
|
|
output_dir=config["output_dir"] + "/assets"
|
|
)
|
|
self.delay_seconds = config["crawl"]["delay_seconds"]
|
|
self.max_retries = config["crawl"]["max_retries"]
|
|
|
|
async def crawl(self):
|
|
"""
|
|
Main crawling entry point
|
|
"""
|
|
|
|
logger.info("Starting crawler...")
|
|
|
|
await self.client.start()
|
|
|
|
queue = asyncio.Queue()
|
|
|
|
for url in self.config["seed_urls"]:
|
|
await queue.put(url)
|
|
|
|
try:
|
|
while not queue.empty():
|
|
|
|
current_url = await queue.get()
|
|
|
|
current_url = self.url_manager.normalize(current_url)
|
|
|
|
if not self.url_manager.should_visit(current_url):
|
|
continue
|
|
|
|
self.url_manager.mark_visited(current_url)
|
|
|
|
logger.info(f"Crawling: {current_url}")
|
|
|
|
try:
|
|
await self.process_page(current_url, queue)
|
|
|
|
except Exception as e:
|
|
logger.exception(
|
|
f"Failed processing {current_url}: {e}"
|
|
)
|
|
|
|
await asyncio.sleep(self.delay_seconds)
|
|
|
|
finally:
|
|
await self.client.stop()
|
|
|
|
logger.info("Crawler stopped.")
|
|
|
|
async def process_page(
|
|
self,
|
|
url: str,
|
|
queue: asyncio.Queue
|
|
):
|
|
"""
|
|
Process single page:
|
|
- render html
|
|
- extract content
|
|
- clean html
|
|
- convert markdown
|
|
- save markdown
|
|
- discover links
|
|
"""
|
|
|
|
page_data = await self.fetch_with_retry(url)
|
|
|
|
if not page_data:
|
|
return
|
|
|
|
html = page_data["html"]
|
|
|
|
await self.asset_downloader.download_assets_from_html(
|
|
base_url=url,
|
|
html=html
|
|
)
|
|
|
|
logger.info(f"Extracting content: {url}")
|
|
|
|
main_html = self.extractor.extract_main_content(html)
|
|
|
|
clean_html = self.cleaner.clean(main_html)
|
|
|
|
logger.info(f"Converting markdown: {url}")
|
|
|
|
markdown = self.converter.html_to_markdown(
|
|
clean_html
|
|
)
|
|
|
|
output_path = self.mapper.url_to_path(url)
|
|
|
|
markdown = self.link_rewriter.rewrite(
|
|
markdown=markdown,
|
|
current_url=url,
|
|
current_output_path=output_path
|
|
)
|
|
|
|
self.writer.write_text(
|
|
output_path,
|
|
markdown
|
|
)
|
|
|
|
logger.info(f"Saved markdown: {output_path}")
|
|
|
|
await self.discover_links(
|
|
base_url=url,
|
|
html=html,
|
|
queue=queue
|
|
)
|
|
|
|
async def fetch_with_retry(
|
|
self,
|
|
url: str
|
|
):
|
|
"""
|
|
Retry wrapper for page fetching
|
|
"""
|
|
|
|
for attempt in range(1, self.max_retries + 1):
|
|
|
|
try:
|
|
logger.info(
|
|
f"Fetching ({attempt}/{self.max_retries}): {url}"
|
|
)
|
|
|
|
return await self.client.fetch_page(url)
|
|
|
|
except Exception as e:
|
|
|
|
logger.warning(
|
|
f"Retry {attempt} failed for {url}: {e}"
|
|
)
|
|
|
|
await asyncio.sleep(2)
|
|
|
|
logger.error(f"Max retries exceeded: {url}")
|
|
|
|
return None
|
|
|
|
async def discover_links(
|
|
self,
|
|
base_url: str,
|
|
html: str,
|
|
queue: asyncio.Queue
|
|
):
|
|
"""
|
|
Extract all links from page
|
|
and push valid links into queue
|
|
"""
|
|
|
|
soup = BeautifulSoup(html, "lxml")
|
|
|
|
links_found = 0
|
|
|
|
for a in soup.find_all("a", href=True):
|
|
|
|
href = a["href"].strip()
|
|
|
|
if not href:
|
|
continue
|
|
|
|
if href.startswith("#"):
|
|
continue
|
|
|
|
if href.startswith("mailto:"):
|
|
continue
|
|
|
|
if href.startswith("javascript:"):
|
|
continue
|
|
|
|
try:
|
|
next_url = self.url_manager.resolve(
|
|
base_url,
|
|
href
|
|
)
|
|
|
|
next_url = self.url_manager.normalize(
|
|
next_url
|
|
)
|
|
|
|
if self.url_manager.should_visit(next_url):
|
|
|
|
await queue.put(next_url)
|
|
|
|
links_found += 1
|
|
|
|
except Exception as e:
|
|
|
|
logger.warning(
|
|
f"Invalid URL {href} from {base_url}: {e}"
|
|
)
|
|
|
|
logger.info(
|
|
f"Discovered {links_found} new links from {base_url}"
|
|
) |