import asyncio from pathlib import Path from bs4 import BeautifulSoup from crawler.http_client import HTTPClient from crawler.playwright_client import PlaywrightClient from crawler.url_manager import URLManager from converter.markdown_converter import MarkdownConverter from extractor.html_cleaner import HTMLCleaner from extractor.html_extractor import HTMLExtractor from models.crawl_result import CrawlResult from storage.file_writer import FileWriter from storage.metadata_db import MetadataDB from storage.path_mapper import PathMapper from utils.logger import logger from extractor.asset_downloader import AssetDownloader from crawler.sphinx_discovery import SphinxDiscovery from crawler.navigation_parser import NavigationParser START_URLS = [ "https://api.intra-mart.jp/iap/", "https://document.intra-mart.jp/library/" ] OUTPUT_DIR = "output" ALLOWED_DOMAINS = [ "api.intra-mart.jp", "document.intra-mart.jp" ] REMOVE_SELECTORS = [ "script", "style", "noscript", ".sidebar-ad", ".navigation-footer", ".breadcrumb", ".toc", ".page-footer", ".header", ".search", ".ads" ] CONTENT_SELECTORS = [ "main", "article", ".content", ".document", ".markdown-body", ".theme-doc-markdown", "#content", # Javadoc (api.intra-mart.jp/iap/javadoc/...) — static # pages, by far the bulk of the queue. Matching this # selector lets the HTTP fast-path skip Playwright. "div.contentContainer", # apilist-ssjs (api.intra-mart.jp/iap/apilist-ssjs/...) # wraps its body content in
. Use a # body-scoped selector so we don't match `.main` # elements nested inside navigation widgets. "body > div.main", # document.intra-mart.jp/library/... — most user-guide # pages put their content in a single top-level #
. "body > div.container", ] MAX_PAGES_PER_RUN = 100000 MAX_ATTEMPTS = 3 BROWSER_RECYCLE_EVERY = 500 class _PermanentFetchFailure(Exception): """Raised when re-fetching the URL cannot succeed (4xx).""" # Number of concurrent page workers. Each worker shares the # single PlaywrightContext + single httpx client; only DB # writes serialize through self.db_lock. The bottleneck is # network I/O, so this is the multiplier on throughput. CONCURRENCY = 8 class DocumentationCrawler: """ Main crawler orchestrator. """ def __init__(self): self.client = PlaywrightClient( headless=True, content_selectors=CONTENT_SELECTORS, ) self.http_client = HTTPClient() self.url_manager = URLManager( allowed_domains=ALLOWED_DOMAINS ) self.sphinx_discovery = SphinxDiscovery( url_manager=self.url_manager ) self.navigation_parser = NavigationParser( url_manager=self.url_manager ) self.navigation_trees = [] self.cleaner = HTMLCleaner( REMOVE_SELECTORS ) self.extractor = HTMLExtractor( CONTENT_SELECTORS ) self.asset_downloader = AssetDownloader( output_dir=str(Path(OUTPUT_DIR) / "assets"), http_client=self.http_client.client, ) self.converter = MarkdownConverter() self.writer = FileWriter() self.db = MetadataDB() self.path_mapper = PathMapper( Path(OUTPUT_DIR) / "markdown" ) self.processed_this_run = 0 # Single lock serializing DB write blocks across # concurrent workers. SQLite + Python's sqlite3 module # already mutex on the connection, but a transaction() # context spans many statements — without this lock, # two workers could interleave statements mid-tx. self.db_lock = asyncio.Lock() # In-flight worker count for clean shutdown: a worker # that sees an empty queue must wait until all peers # finish (their process_page may enqueue new URLs). self._in_flight = 0 self._stop = False # Track domains we've already navigation-parsed. # Sidebars are site-wide on Sphinx/Javadoc, so the same # tree is rediscovered on every page — running the # heavy parser repeatedly wastes CPU. Any per-section # URLs we miss here will still be picked up by # extract_links (it iterates all ). self._nav_parsed_domains = set() async def initialize(self): """ Initialize crawler resources. """ logger.info( "Initializing crawler..." ) self.db.reset_stuck_processing() await self.client.start() for url in START_URLS: normalized = self.url_manager.normalize(url) if normalized and self.url_manager.is_allowed(normalized): self.db.enqueue_url( normalized, parent_url=None, depth=0, priority=100 ) for url in START_URLS: discovered = await self.sphinx_discovery.discover_from_base(url) for discovered_url in discovered: if not self.url_manager.should_enqueue(discovered_url): continue if self.db.is_done(discovered_url): continue if self.db.is_known(discovered_url): continue self.db.enqueue_url( discovered_url, parent_url=url, depth=1, priority=50 ) self.db.save_discovery_source( url=discovered_url, discovery_type="sphinx_base_discovery", source_url=url ) logger.info(f"Initial queue stats: {self.db.queue_stats()}") async def shutdown(self): """ Cleanup resources. """ logger.info( "Shutting down crawler..." ) self.export_summary() await self.client.stop() await self.http_client.aclose() self.db.close() logger.info( "Crawler shutdown complete." ) async def crawl(self): """ Main crawl entry point. Spawns CONCURRENCY workers that share the single browser context + httpx client and serialize through self.db_lock for DB writes. """ await self.initialize() try: workers = [ asyncio.create_task(self._worker(i)) for i in range(CONCURRENCY) ] await asyncio.gather(*workers) finally: await self.shutdown() async def _claim_next_url(self): """ Atomically pick the next pending URL and mark it processing. Returns None when the queue is empty. """ async with self.db_lock: item = self.db.get_next_pending_url() if not item: return None url = item["url"] if self.db.is_done(url): # Stale row — skip without claiming. return "skip" self.db.mark_processing(url) self._in_flight += 1 return item async def _worker(self, worker_id: int): """ One concurrent worker. Loops until the queue stays empty *and* no peer worker is in-flight (peers can enqueue new URLs as they discover links). """ while not self._stop: if self.processed_this_run >= MAX_PAGES_PER_RUN: return item = await self._claim_next_url() if item == "skip": continue if item is None: # No work available — but a peer might enqueue # more. Exit only if everyone else is idle too. if self._in_flight == 0: return await asyncio.sleep(0.3) continue url = item["url"] depth = item["depth"] try: result = await self.process_page( url=url, depth=depth, ) async with self.db_lock: self.db.mark_done(url) if result and result.url != url: self.db.mark_done(result.url) self.processed_this_run += 1 n = self.processed_this_run if n % 20 == 0: logger.info( f"Progress: {n} pages this run. " f"Queue: {self.db.queue_stats()}" ) if n % 100 == 0: self.export_summary() logger.info( "Intermediate summary exported." ) except asyncio.CancelledError: raise except _PermanentFetchFailure as e: # Skip the retry ramp — re-fetching can't # succeed. logger.warning(f"Permanent failure on {url}: {e}") async with self.db_lock: self.db.mark_permanently_failed(url, str(e)) except Exception as e: logger.exception( f"Failed processing {url}: {e}" ) async with self.db_lock: self.db.mark_failed( url, str(e), max_attempts=MAX_ATTEMPTS, ) finally: async with self.db_lock: self._in_flight -= 1 async def _fetch_and_extract(self, url: str): """ Try a cheap HTTP fetch first. If a configured content selector matches we're done. Otherwise fall back to a full Playwright render (handles JS-only pages). Returns (response_dict, extraction_dict) or (None, None) when neither path produces usable content. """ # Conditional GET: ask the server whether the page # has changed since we last cached it. A 304 lets us # skip all downstream work. etag, last_modified = self.db.get_cached_headers(url) http_response = await self.http_client.fetch_page( url, etag=etag, last_modified=last_modified, ) if http_response is not None and http_response.get("is_binary"): return http_response, None if http_response is not None and http_response.get("not_modified"): # Signal "nothing changed" to process_page. return http_response, None # Hard 4xx (404, 410, 451, ...): there is nothing # Playwright can do that httpx cannot. Surface this so # the worker can mark the URL permanently failed and # stop wasting retries. if ( http_response is not None and http_response.get("status_code") is not None and 400 <= http_response["status_code"] < 500 ): return http_response, None if http_response is not None and http_response.get("ok"): # Parse the HTML once here. The cleaner mutates # the soup in place; the extractor uses it; assets # later use the matched node. No re-parses. cleaning_soup = BeautifulSoup( http_response["html"], "lxml" ) self.cleaner.clean(cleaning_soup) extraction = self.extractor.extract_with_status( cleaning_soup ) if extraction["matched"]: logger.info(f"HTTP fast-path success: {url}") return http_response, extraction logger.info( f"HTTP fetch ok but no content selector matched " f"— falling back to Playwright: {url}" ) # Fallback: full browser render pw_response = await self.client.fetch_page(url) if pw_response.get("is_binary"): return pw_response, None cleaning_soup = BeautifulSoup(pw_response["html"], "lxml") self.cleaner.clean(cleaning_soup) extraction = self.extractor.extract_with_status( cleaning_soup ) return pw_response, extraction async def process_page(self, url: str, depth: int): """ Process single page. """ logger.info( f"Crawling: {url}" ) if self.url_manager.is_binary_url(url): logger.info(f"Skipping binary URL as page: {url}") return None response, extraction = await self._fetch_and_extract(url) if response is None: return None if response.get("is_binary"): logger.info(f"Skipping binary response: {url}") return None # 304 Not Modified: skip the entire pipeline. Worker # will mark_done as usual. if response.get("not_modified"): logger.info(f"Not modified, skipping: {url}") return CrawlResult( status_code=304, url=url, title="", markdown_path="", links=[], success=True, ) # _fetch_and_extract returns extraction=None with a # 4xx response when the URL is a permanent failure # (404 etc.). Raise a typed error so the worker maps # it to mark_permanently_failed instead of retrying. status_code = response.get("status_code") if extraction is None and status_code and 400 <= status_code < 500: raise _PermanentFetchFailure( f"HTTP {status_code} on {url}" ) final_url = response.get("url", url) html = response["html"] title = response["title"] # The extracted node lives inside the cleaning soup # already produced in _fetch_and_extract. Use it # directly to avoid one BS4 re-parse per page. extracted_node = extraction["node"] # Resolve output path first output_path = self.path_mapper.url_to_path( final_url ) # Download assets — pass the already-parsed node # rather than its serialized HTML string. downloaded_assets = await self.asset_downloader.download_assets_from_html( base_url=final_url, html=extracted_node, ) async with self.db_lock: with self.db.transaction(): for asset in downloaded_assets: self.db.save_asset( page_url=final_url, asset_url=asset["url"], local_path=asset["local_path"] ) # Rewrite asset links in place on the same parsed # node — no re-parse needed. self.asset_downloader.rewrite_asset_links( html=extracted_node, base_url=final_url, current_output_path=output_path, ) # Convert markdown markdown = self.converter.html_to_markdown( str(extracted_node), title=title, source_url=final_url, media_dir=Path(OUTPUT_DIR) / "assets" / "pandoc", ) self.writer.write_text( output_path, markdown ) # Parse the ORIGINAL html ONCE for all three # downstream consumers. Previously each one re-parsed # the same string independently. original_soup = BeautifulSoup(html, "lxml") discovered_links = self.extract_links( final_url, original_soup, ) sphinx_links = await self.sphinx_discovery.discover_from_rendered_html( base_url=final_url, html=original_soup, ) # Only parse navigation the first time we visit a # given domain. The sidebar tree is site-wide and # repeating the parse on every page is the single # most expensive bit of per-page CPU we still have. nav_domain = self.url_manager.extract_domain(final_url) if nav_domain in self._nav_parsed_domains: navigation_result = {"urls": [], "tree": []} else: navigation_result = self.navigation_parser.parse_navigation( base_url=final_url, html=original_soup, ) self._nav_parsed_domains.add(nav_domain) self.navigation_trees.extend( navigation_result["tree"] ) for nav_url in navigation_result["urls"]: if nav_url not in discovered_links: discovered_links.append(nav_url) logger.info( f"SPHINX_LINKS: {len(sphinx_links)} from {final_url}" ) for sphinx_link in sphinx_links: if sphinx_link not in discovered_links: discovered_links.append(sphinx_link) # Batch every per-link write + the page row into one # transaction per page — turns hundreds of fsyncs into # one, and one acquisition of db_lock per worker turn. async with self.db_lock: with self.db.transaction(): for link in discovered_links: logger.debug(f"DISCOVERED: {link}") self.db.save_page_link( source_url=final_url, target_url=link, link_type="sphinx_or_html_internal" ) if not self.url_manager.should_enqueue(link): continue if self.db.is_done(link): continue if self.db.is_known(link): continue logger.debug(f"ENQUEUED: {link}") self.db.enqueue_url( url=link, parent_url=final_url, depth=depth + 1, priority=0 ) self.db.save_discovery_source( url=link, discovery_type="page_internal_link", source_url=final_url ) self.db.save_page( url=final_url, title=title, markdown_path=str(output_path), status="success", status_code=response.get( "status_code", 200 ), markdown_length=len(markdown), word_count=len( markdown.split() ), metadata={ "depth": depth, "links_found": len(discovered_links), "assets_found": len(downloaded_assets) }, etag=response.get("etag"), last_modified=response.get("last_modified"), ) logger.debug( f"Saved markdown: " f"{output_path}" ) return CrawlResult( status_code=response.get("status_code"), url=final_url, title=title, markdown_path=str(output_path), links=discovered_links, success=True ) def extract_links( self, base_url, html ): """ Extract crawlable links. Accepts an HTML string or a parsed BS4 soup/Tag. """ if hasattr(html, "select") and hasattr(html, "find_all"): soup = html else: soup = BeautifulSoup(html, "lxml") discovered = set() for a in soup.find_all( "a", href=True ): href = a.get("href") resolved = self.url_manager.resolve( base_url, href ) if not resolved: continue if not self.url_manager.is_allowed( resolved ): continue discovered.add( resolved ) selectors = [ "a.reference.internal", ".toctree-wrapper a", ".wy-menu a", ".sphinxsidebar a", ".globaltoc a", ".localtoc a", "link[rel='next']", "link[rel='prev']", "link[rel='up']" ] for selector in selectors: for node in soup.select(selector): href = node.get("href") resolved = self.url_manager.resolve( base_url, href ) if not resolved: continue if not self.url_manager.is_allowed(resolved): continue discovered.add(resolved) return list(discovered) def export_summary(self): """ Export crawl summary. """ summary_path = ( Path(OUTPUT_DIR) / "crawl_summary.json" ) data = { "processed_this_run": self.processed_this_run, "queue_stats": self.db.queue_stats() } self.writer.write_json( summary_path, data ) graph_path = ( Path(OUTPUT_DIR) / "site_graph.json" ) self.db.export_site_graph( graph_path ) navigation_tree = self.navigation_parser.dedupe_tree( self.navigation_trees ) navigation_path = ( Path(OUTPUT_DIR) / "navigation_tree.json" ) self.navigation_parser.export_tree_json( navigation_tree, navigation_path ) summary_md = self.navigation_parser.generate_summary_markdown( navigation_tree ) summary_md_path = ( Path(OUTPUT_DIR) / "SUMMARY.md" ) self.writer.write_text( summary_md_path, summary_md ) logger.info( f"Summary exported: " f"{summary_path}" ) async def recycle_browser(self): """ Restart Playwright browser/context periodically to avoid long-running memory/socket leaks. """ logger.info("Recycling browser context...") await self.client.stop() await asyncio.sleep(2) await self.client.start() logger.info("Browser context recycled.") async def async_main(): crawler = DocumentationCrawler() await crawler.crawl() def main(): asyncio.run( async_main() ) if __name__ == "__main__": main()