crawler-intra-mart/crawler/sphinx_discovery.py
Do Duy eb46e739e7 Add intra-mart documentation crawler
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>
2026-05-20 16:29:54 +07:00

329 lines
7.3 KiB
Python

import json
import re
from pathlib import Path
from urllib.parse import urljoin
import httpx
from bs4 import BeautifulSoup
from utils.logger import logger
def _ensure_soup(html_or_soup):
if hasattr(html_or_soup, "select_one") and hasattr(
html_or_soup, "find_all"
):
return html_or_soup
return BeautifulSoup(html_or_soup, "lxml")
class SphinxDiscovery:
"""
Discover hidden / structured pages from Sphinx-like docs.
Sources:
- searchindex.js
- genindex.html
- global index pages
- toctree/sidebar links
"""
def __init__(
self,
url_manager,
timeout=30
):
self.url_manager = url_manager
self.timeout = timeout
async def discover_from_base(
self,
base_url: str
):
"""
Main discovery entry point.
"""
discovered = set()
candidates = self.common_sphinx_urls(
base_url
)
for url in candidates:
urls = await self.discover_from_url(url)
discovered.update(urls)
logger.info(
f"Sphinx discovery found {len(discovered)} URLs from {base_url}"
)
return sorted(discovered)
def common_sphinx_urls(
self,
base_url: str
):
"""
Common Sphinx-generated files.
"""
base = base_url.rstrip("/") + "/"
return [
urljoin(base, "searchindex.js"),
urljoin(base, "genindex.html"),
urljoin(base, "py-modindex.html"),
urljoin(base, "search.html"),
urljoin(base, "contents.html"),
urljoin(base, "index.html"),
]
async def discover_from_url(
self,
url: str
):
"""
Dispatch discovery by file type.
"""
if url.endswith("searchindex.js"):
return await self.parse_searchindex(url)
if url.endswith(".html"):
return await self.parse_html_index(url)
return []
async def fetch_text(
self,
url: str
):
try:
async with httpx.AsyncClient(
timeout=self.timeout,
follow_redirects=True
) as client:
response = await client.get(url)
if response.status_code >= 400:
return None
return response.text
except Exception as e:
logger.debug(
f"Sphinx discovery fetch failed {url}: {e}"
)
return None
async def parse_searchindex(
self,
url: str
):
"""
Parse Sphinx searchindex.js.
Typical format:
Search.setIndex({...})
"""
text = await self.fetch_text(url)
if not text:
return []
try:
payload = self.extract_searchindex_json(text)
if not payload:
return []
docnames = payload.get("docnames", [])
base_url = url.rsplit("/", 1)[0] + "/"
discovered = set()
for docname in docnames:
page_url = urljoin(
base_url,
docname + ".html"
)
normalized = self.url_manager.normalize(page_url)
if (
normalized
and self.url_manager.is_allowed(normalized)
):
discovered.add(normalized)
logger.info(
f"Parsed {len(discovered)} URLs from searchindex: {url}"
)
return sorted(discovered)
except Exception as e:
logger.warning(
f"Failed parsing searchindex {url}: {e}"
)
return []
def extract_searchindex_json(
self,
text: str
):
"""
Extract JSON-like object from Sphinx searchindex.js.
"""
match = re.search(
r"Search\.setIndex\((.*)\)\s*;?\s*$",
text,
re.DOTALL
)
if not match:
return None
raw = match.group(1)
try:
return json.loads(raw)
except Exception:
pass
# Fallback for slightly non-standard JS object
raw = raw.replace("'", '"')
try:
return json.loads(raw)
except Exception:
logger.debug(
"searchindex.js is not valid JSON after normalization."
)
return None
async def parse_html_index(
self,
url: str
):
"""
Parse Sphinx index-like HTML files.
"""
html = await self.fetch_text(url)
if not html:
return []
soup = BeautifulSoup(html, "lxml")
discovered = set()
selectors = [
"a.reference.internal",
".toctree-wrapper a",
".wy-menu a",
".sphinxsidebar a",
".globaltoc a",
".localtoc a",
"nav a",
"a[href]"
]
for selector in selectors:
for a in soup.select(selector):
href = a.get("href")
resolved = self.url_manager.resolve(
url,
href
)
if not resolved:
continue
if not self.url_manager.is_allowed(resolved):
continue
discovered.add(resolved)
logger.info(
f"Parsed {len(discovered)} URLs from HTML index: {url}"
)
return sorted(discovered)
async def discover_from_rendered_html(
self,
base_url: str,
html
):
"""
Discover Sphinx links from already-rendered page HTML.
Accepts either a string or a parsed soup.
"""
soup = _ensure_soup(html)
discovered = set()
selectors = [
"a.reference.internal",
".toctree-wrapper a.reference.internal",
".section a.reference.internal",
".body a.reference.internal",
".document a.reference.internal",
".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")
if not href:
continue
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 sorted(discovered)
def build_site_graph_edges(
self,
source_url: str,
target_urls: list[str]
):
"""
Prepare graph edges.
"""
edges = []
for target in target_urls:
edges.append({
"source": source_url,
"target": target,
"type": "sphinx_internal_link"
})
return edges