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>
329 lines
7.3 KiB
Python
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 |