975 lines
31 KiB
Python
975 lines
31 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Two-phase MQL5 content extraction: download HTML → convert to Markdown.
|
||
|
||
Phase 1 (download): Fetch all HTML from mql5.com, save losslessly to html_cache/
|
||
Phase 2 (convert): Parse local HTML, download images, write Markdown files
|
||
|
||
Usage:
|
||
# Phase 1: download all HTML
|
||
python scripts/extract.py download --sitemap sitemaps/sitemap_book_en.xml
|
||
python scripts/extract.py download --sitemap sitemaps/sitemap_docs_en.xml
|
||
python scripts/extract.py download --all
|
||
|
||
# Phase 2: convert HTML → Markdown
|
||
python scripts/extract.py convert --sitemap sitemaps/sitemap_book_en.xml
|
||
python scripts/extract.py convert --sitemap sitemaps/sitemap_docs_en.xml
|
||
python scripts/extract.py convert --all
|
||
|
||
# Debug single page (fetches + converts)
|
||
python scripts/extract.py debug URL
|
||
"""
|
||
|
||
import argparse
|
||
import hashlib
|
||
import logging
|
||
import os
|
||
import random
|
||
import re
|
||
import time
|
||
import xml.etree.ElementTree as ET
|
||
from pathlib import Path
|
||
from urllib.parse import urlparse
|
||
|
||
import requests
|
||
from bs4 import BeautifulSoup, NavigableString, PageElement, Tag
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Constants
|
||
# ---------------------------------------------------------------------------
|
||
|
||
USER_AGENTS = {
|
||
"firefox": "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0",
|
||
"msedge": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36 Edg/148.0.0.0",
|
||
"chromium": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
|
||
}
|
||
UA_KEYS = list(USER_AGENTS.keys())
|
||
_NUM_UA = len(UA_KEYS)
|
||
|
||
|
||
def load_user_agents_csv(csv_path: str) -> None:
|
||
"""Load User-Agent strings from a CSV file, replacing module-level UA pool.
|
||
|
||
CSV must have a ``user_agent`` column. Each row becomes one entry;
|
||
keys are ``u0``, ``u1``, … in file order.
|
||
"""
|
||
global USER_AGENTS, UA_KEYS, _NUM_UA
|
||
import csv as _csv
|
||
|
||
with open(csv_path, newline="", encoding="utf-8") as f:
|
||
reader = _csv.DictReader(f)
|
||
if "user_agent" not in (reader.fieldnames or []):
|
||
raise ValueError(f"CSV {csv_path} has no 'user_agent' column")
|
||
pool = {f"u{i}": row["user_agent"] for i, row in enumerate(reader)}
|
||
|
||
if not pool:
|
||
raise ValueError(f"CSV {csv_path} contains no User-Agent rows")
|
||
|
||
USER_AGENTS = pool
|
||
UA_KEYS = list(USER_AGENTS.keys())
|
||
_NUM_UA = len(UA_KEYS)
|
||
log.info("Loaded %d User-Agents from %s", _NUM_UA, csv_path)
|
||
|
||
|
||
def get_user_agent(index: int | None = None) -> str:
|
||
"""Return a User-Agent string. Deterministic by index, or random."""
|
||
if index is not None:
|
||
return USER_AGENTS[UA_KEYS[index % _NUM_UA]]
|
||
return USER_AGENTS[random.choice(UA_KEYS)]
|
||
|
||
REQUEST_DELAY = 0.9 # seconds between requests
|
||
MAX_RETRIES = 3
|
||
RETRY_BACKOFF = 2 # exponential backoff multiplier
|
||
|
||
SITEMAP_NS = {"s": "http://www.sitemaps.org/schemas/sitemap/0.9"}
|
||
|
||
# CSS selector for main content start
|
||
CONTENT_START_SELECTOR = "#help > h1:nth-child(1)"
|
||
|
||
# HTML cache root
|
||
HTML_CACHE_ROOT = Path("html_cache")
|
||
|
||
# Mapping: sitemap path → (output md dir, label)
|
||
SITEMAP_DEFAULTS = {
|
||
"sitemaps/sitemap_book_en.xml": (
|
||
"skills/mql5/references/book",
|
||
"book",
|
||
),
|
||
"sitemaps/sitemap_docs_en.xml": (
|
||
"skills/mql5/references/docs",
|
||
"docs",
|
||
),
|
||
}
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Logging
|
||
# ---------------------------------------------------------------------------
|
||
|
||
log = logging.getLogger("extract")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Sitemap parsing
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def parse_sitemap(sitemap_path: str) -> list[str]:
|
||
"""Extract <loc> URLs from a sitemap XML file, in document order."""
|
||
tree = ET.parse(sitemap_path)
|
||
urls = [loc.text for loc in tree.findall(".//s:url/s:loc", SITEMAP_NS)]
|
||
log.info("Parsed %d URLs from %s", len(urls), sitemap_path)
|
||
return urls
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# URL → file mapping (naming convention, shared by both phases)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def sanitize_name(name: str) -> str:
|
||
"""Convert a URL segment to a safe file/folder name."""
|
||
name = name.lower()
|
||
name = name.replace("_", "-")
|
||
name = re.sub(r"[^a-z0-9-]", "", name)
|
||
name = re.sub(r"-{2,}", "-", name)
|
||
name = name.strip("-")
|
||
return name
|
||
|
||
|
||
def derive_name_from_url(url: str, root_prefix: str) -> str:
|
||
"""Given a full URL and the root prefix (e.g. '/en/book/'),
|
||
return the 'path tail' used for naming."""
|
||
path = url
|
||
if path.startswith("http"):
|
||
parsed = urlparse(path)
|
||
path = parsed.path
|
||
|
||
path_clean = path.strip("/")
|
||
prefix_clean = root_prefix.strip("/")
|
||
|
||
if path_clean.lower() == prefix_clean.lower():
|
||
return ""
|
||
if path_clean.lower().startswith(prefix_clean.lower() + "/"):
|
||
return path_clean[len(prefix_clean) + 1 :]
|
||
return path_clean
|
||
|
||
|
||
def build_file_map(
|
||
urls: list[str], root_prefix: str, output_dir: str, label: str
|
||
) -> list[dict]:
|
||
"""Build the mapping from URL index → file path.
|
||
|
||
Returns a list of dicts with keys:
|
||
url, index (0-based), file_path (.md), html_path (.html),
|
||
chapter_folder, filename
|
||
"""
|
||
html_cache_dir = HTML_CACHE_ROOT / label
|
||
file_map = []
|
||
chapter_counter = 0
|
||
chapter_map: dict[str, int] = {}
|
||
|
||
for idx, url in enumerate(urls):
|
||
tail = derive_name_from_url(url, root_prefix)
|
||
parts = tail.split("/") if tail else []
|
||
|
||
if not parts:
|
||
# Root URL
|
||
base = f"{idx:04d}-{label}"
|
||
filename_md = f"{base}.md"
|
||
filename_html = f"{base}.html"
|
||
file_path = Path(output_dir) / filename_md
|
||
html_path = html_cache_dir / filename_html
|
||
chapter_folder = None
|
||
else:
|
||
depth1 = parts[0]
|
||
if depth1 not in chapter_map:
|
||
chapter_map[depth1] = chapter_counter
|
||
chapter_counter += 1
|
||
ch_num = chapter_map[depth1]
|
||
chapter_folder = f"{ch_num:02d}-{sanitize_name(depth1)}"
|
||
|
||
if len(parts) == 1:
|
||
derived = sanitize_name(depth1)
|
||
else:
|
||
derived = "-".join(sanitize_name(p) for p in parts)
|
||
|
||
base = f"{idx:04d}-{derived}"
|
||
filename_md = f"{base}.md"
|
||
filename_html = f"{base}.html"
|
||
file_path = Path(output_dir) / chapter_folder / filename_md
|
||
html_path = html_cache_dir / chapter_folder / filename_html
|
||
|
||
file_map.append(
|
||
{
|
||
"url": url,
|
||
"index": idx,
|
||
"file_path": str(file_path),
|
||
"html_path": str(html_path),
|
||
"chapter_folder": chapter_folder,
|
||
"filename": filename_md,
|
||
}
|
||
)
|
||
|
||
log.info(
|
||
"Built file map: %d files, %d chapters",
|
||
len(file_map),
|
||
chapter_counter,
|
||
)
|
||
return file_map
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Phase 1: Download HTML
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def fetch_html(url: str, session: requests.Session) -> str | None:
|
||
"""Fetch a URL with retry + backoff. Returns HTML text or None on failure."""
|
||
for attempt in range(1, MAX_RETRIES + 1):
|
||
try:
|
||
resp = session.get(url, timeout=30)
|
||
if resp.status_code == 200:
|
||
return resp.text
|
||
if resp.status_code in (429, 500, 502, 503, 504):
|
||
wait = RETRY_BACKOFF**attempt
|
||
log.warning(
|
||
"HTTP %d for %s, retry %d/%d in %ds",
|
||
resp.status_code, url, attempt, MAX_RETRIES, wait,
|
||
)
|
||
time.sleep(wait)
|
||
continue
|
||
log.error("HTTP %d for %s — skipping", resp.status_code, url)
|
||
return None
|
||
except requests.RequestException as exc:
|
||
wait = RETRY_BACKOFF**attempt
|
||
log.warning(
|
||
"Request error for %s: %s, retry %d/%d in %ds",
|
||
url, exc, attempt, MAX_RETRIES, wait,
|
||
)
|
||
time.sleep(wait)
|
||
log.error("All %d retries failed for %s", MAX_RETRIES, url)
|
||
return None
|
||
|
||
|
||
def download_one(url: str, html_path: str, session: requests.Session) -> bool:
|
||
"""Download a single URL and save to html_path. Returns True on success."""
|
||
out = Path(html_path)
|
||
if out.exists():
|
||
log.debug("Already cached: %s", html_path)
|
||
return True
|
||
|
||
html = fetch_html(url, session)
|
||
if html is None:
|
||
return False
|
||
|
||
out.parent.mkdir(parents=True, exist_ok=True)
|
||
out.write_text(html, encoding="utf-8")
|
||
return True
|
||
|
||
|
||
def run_download(
|
||
sitemap_path: str,
|
||
force: bool = False,
|
||
dry_run: bool = False,
|
||
random_ua: bool = False,
|
||
random_delay: bool = False,
|
||
user_agents_csv: str | None = None,
|
||
) -> None:
|
||
"""Phase 1: download all HTML files for a sitemap."""
|
||
if user_agents_csv:
|
||
load_user_agents_csv(user_agents_csv)
|
||
urls = parse_sitemap(sitemap_path)
|
||
root_prefix = _root_prefix_from_sitemap(sitemap_path)
|
||
label = _label_from_sitemap(sitemap_path)
|
||
md_output_dir = SITEMAP_DEFAULTS.get(sitemap_path, ("", label))[0]
|
||
|
||
file_map = build_file_map(urls, root_prefix, md_output_dir, label)
|
||
|
||
if dry_run:
|
||
print(f"\n=== Download dry run: {sitemap_path} ===")
|
||
print(f"Total URLs: {len(file_map)}")
|
||
print(f"Cache dir: {HTML_CACHE_ROOT / label}")
|
||
for entry in file_map[:20]:
|
||
print(f" [{entry['index']:4d}] {entry['url']}")
|
||
print(f" → {entry['html_path']}")
|
||
if len(file_map) > 20:
|
||
print(f" ... ({len(file_map) - 20} more)")
|
||
return
|
||
|
||
# Progress log
|
||
log_path = HTML_CACHE_ROOT / label / ".download.log"
|
||
processed = _load_log(log_path) if not force else set()
|
||
|
||
session = requests.Session()
|
||
|
||
success = 0
|
||
skipped = 0
|
||
failed = 0
|
||
|
||
for entry in file_map:
|
||
url = entry["url"]
|
||
if url in processed and not force:
|
||
skipped += 1
|
||
continue
|
||
|
||
session.headers.update({
|
||
"User-Agent": get_user_agent() if random_ua else get_user_agent(entry["index"])
|
||
})
|
||
ok = download_one(url, entry["html_path"], session)
|
||
if ok:
|
||
success += 1
|
||
_append_log(log_path, url)
|
||
log.info("[%d/%d] OK: %s", success + skipped + failed, len(file_map), url)
|
||
else:
|
||
failed += 1
|
||
log.error("[%d/%d] FAIL: %s", success + skipped + failed, len(file_map), url)
|
||
|
||
if random_delay:
|
||
time.sleep(REQUEST_DELAY * random.uniform(1.0, 3.0))
|
||
else:
|
||
time.sleep(REQUEST_DELAY)
|
||
|
||
log.info(
|
||
"Done: %d success, %d skipped, %d failed (total %d)",
|
||
success, skipped, failed, len(file_map),
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Phase 2: Convert HTML → Markdown
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# Image downloading (only needed during convert phase)
|
||
|
||
|
||
def download_image(
|
||
img_url: str, pics_dir: Path, session: requests.Session
|
||
) -> str | None:
|
||
"""Download an image, save to pics_dir, return local filename or None."""
|
||
parsed = urlparse(img_url)
|
||
name = Path(parsed.path).name
|
||
if not name or "." not in name:
|
||
ext = ".png"
|
||
name = hashlib.md5(img_url.encode()).hexdigest()[:12] + ext
|
||
|
||
local_path = pics_dir / name
|
||
if local_path.exists():
|
||
return name
|
||
|
||
try:
|
||
resp = session.get(img_url, timeout=30)
|
||
if resp.status_code == 200:
|
||
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||
local_path.write_bytes(resp.content)
|
||
return name
|
||
except requests.RequestException as exc:
|
||
log.warning("Failed to download image %s: %s", img_url, exc)
|
||
return None
|
||
|
||
|
||
# HTML → Markdown conversion helpers
|
||
|
||
|
||
def get_text(el: Tag | NavigableString, sep: str = "") -> str:
|
||
"""Recursively get text content."""
|
||
if isinstance(el, NavigableString):
|
||
return str(el)
|
||
parts = []
|
||
for child in el.children:
|
||
t = get_text(child, sep)
|
||
if t:
|
||
parts.append(t)
|
||
return sep.join(parts)
|
||
|
||
|
||
def inline_to_md(el: Tag) -> str:
|
||
"""Convert an inline element to Markdown."""
|
||
if isinstance(el, NavigableString):
|
||
return str(el)
|
||
|
||
tag = el.name
|
||
inner = "".join(inline_to_md(c) for c in el.children)
|
||
|
||
if tag in ("strong", "b"):
|
||
return f"**{inner}**"
|
||
if tag in ("em", "i"):
|
||
return f"*{inner}*"
|
||
if tag == "code":
|
||
if "`" in inner:
|
||
return f"`` {inner} ``"
|
||
return f"`{inner}`"
|
||
if tag == "a":
|
||
href = el.get("href", "")
|
||
if href and not href.startswith("#"):
|
||
return f"[{inner}]({href})"
|
||
return inner
|
||
if tag == "br":
|
||
return " \n"
|
||
if tag == "img":
|
||
src = el.get("src", "")
|
||
alt = el.get("alt", "")
|
||
return f""
|
||
if tag in ("span", "font", "u", "s", "sub", "sup", "small", "big", "mark"):
|
||
return inner
|
||
return inner
|
||
|
||
|
||
def table_to_md(table: Tag) -> str:
|
||
"""Convert a <table> to Markdown.
|
||
|
||
Has <thead> → data table. No <thead> → code block.
|
||
"""
|
||
thead = table.find("thead")
|
||
tbody = table.find("tbody") or table
|
||
|
||
if thead:
|
||
rows = []
|
||
for tr in thead.find_all("tr"):
|
||
cells = [get_text(td, " ").strip() for td in tr.find_all(["th", "td"])]
|
||
rows.append(cells)
|
||
|
||
ncols = max((len(r) for r in rows), default=0)
|
||
if rows and ncols:
|
||
rows.append(["---"] * ncols)
|
||
|
||
body_rows = tbody.find_all("tr") if tbody else []
|
||
for tr in body_rows:
|
||
if thead and tr.find_parent("thead"):
|
||
continue
|
||
cells = [get_text(td, " ").strip() for td in tr.find_all(["th", "td"])]
|
||
rows.append(cells)
|
||
|
||
lines = []
|
||
for row in rows:
|
||
while len(row) < ncols:
|
||
row.append("")
|
||
lines.append("| " + " | ".join(row) + " |")
|
||
return "\n".join(lines)
|
||
else:
|
||
code_lines = []
|
||
all_rows = tbody.find_all("tr") if tbody else table.find_all("tr")
|
||
for tr in all_rows:
|
||
cells = tr.find_all(["td", "th"])
|
||
for cell in cells:
|
||
code_el = cell.find("code") or cell.find("pre")
|
||
if code_el:
|
||
code_lines.append(get_text(code_el, "\n"))
|
||
else:
|
||
text = cell.get_text()
|
||
if text.strip():
|
||
code_lines.append(text)
|
||
|
||
code_text = "\n".join(code_lines)
|
||
lang = detect_code_lang(table)
|
||
return f"```{lang}\n{code_text}\n```"
|
||
|
||
|
||
def detect_code_lang(el: Tag) -> str:
|
||
"""Try to detect code language from CSS classes."""
|
||
classes = el.get("class", [])
|
||
if isinstance(classes, str):
|
||
classes = classes.split()
|
||
for cls in classes:
|
||
cl = cls.lower()
|
||
if "mql" in cl or "cpp" in cl or "c-plus" in cl:
|
||
return "cpp"
|
||
if "console" in cl or "output" in cl or "result" in cl:
|
||
return ""
|
||
if "sql" in cl:
|
||
return "sql"
|
||
parent = el.parent
|
||
if parent and isinstance(parent, Tag):
|
||
return detect_code_lang(parent)
|
||
return ""
|
||
|
||
|
||
def pre_to_md(pre: Tag) -> str:
|
||
"""Convert <pre><code> to fenced Markdown code."""
|
||
code = pre.find("code")
|
||
if code:
|
||
lang = detect_code_lang(code)
|
||
text = get_text(code, "\n")
|
||
else:
|
||
lang = ""
|
||
text = get_text(pre, "\n")
|
||
return f"```{lang}\n{text}\n```"
|
||
|
||
|
||
def element_to_md(
|
||
el: Tag,
|
||
pics_dir: Path | None,
|
||
session: requests.Session | None,
|
||
base_url: str,
|
||
img_cache: dict[str, str | None],
|
||
) -> str:
|
||
"""Convert a single content element to Markdown string."""
|
||
if isinstance(el, NavigableString):
|
||
text = str(el).strip()
|
||
return text if text else ""
|
||
|
||
tag = el.name
|
||
|
||
# --- Headings ---
|
||
if tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
|
||
level = int(tag[1])
|
||
text = get_text(el, " ").strip()
|
||
return f"{'#' * level} {text}\n"
|
||
|
||
# --- Paragraphs ---
|
||
if tag == "p":
|
||
img_child = el.find("img")
|
||
if img_child:
|
||
return element_to_md(img_child, pics_dir, session, base_url, img_cache)
|
||
inner = "".join(inline_to_md(c) for c in el.children)
|
||
inner = inner.strip()
|
||
if not inner:
|
||
return ""
|
||
return f"{inner}\n"
|
||
|
||
# --- Images ---
|
||
if tag == "img":
|
||
src = el.get("src", "")
|
||
if not src:
|
||
return ""
|
||
|
||
if src.startswith("/"):
|
||
src = f"https://www.mql5.com{src}"
|
||
elif not src.startswith("http"):
|
||
src = f"{base_url.rstrip('/')}/{src}"
|
||
|
||
# Get alt text
|
||
alt_text = ""
|
||
next_sibling = el.find_next_sibling("p")
|
||
if not next_sibling and el.parent and isinstance(el.parent, Tag):
|
||
next_sibling = el.parent.find_next_sibling("p")
|
||
if next_sibling:
|
||
alt_text = get_text(next_sibling, " ").strip()
|
||
if not alt_text:
|
||
alt_text = el.get("alt", "")
|
||
|
||
# Download image
|
||
img_filename = None
|
||
if pics_dir and session:
|
||
if src in img_cache:
|
||
img_filename = img_cache[src]
|
||
else:
|
||
img_filename = download_image(src, pics_dir, session)
|
||
img_cache[src] = img_filename
|
||
|
||
if img_filename:
|
||
return f"\n"
|
||
else:
|
||
return f"\n"
|
||
|
||
# --- Tables ---
|
||
if tag == "table":
|
||
return table_to_md(el) + "\n"
|
||
|
||
# --- Preformatted code ---
|
||
if tag == "pre":
|
||
return pre_to_md(el) + "\n"
|
||
|
||
# --- Lists ---
|
||
if tag in ("ul", "ol"):
|
||
items = []
|
||
for i, li in enumerate(el.find_all("li", recursive=False)):
|
||
prefix = f"{i+1}. " if tag == "ol" else "- "
|
||
# If <li> contains an <img>, handle it through element_to_md
|
||
img_child = li.find("img")
|
||
if img_child:
|
||
img_md = element_to_md(img_child, pics_dir, session, base_url, img_cache)
|
||
# Get remaining text after the image
|
||
rest = "".join(inline_to_md(c) for c in li.children if not (isinstance(c, Tag) and c.name == "img"))
|
||
inner = (img_md.strip() + " " + rest.strip()).strip()
|
||
else:
|
||
inner = "".join(inline_to_md(c) for c in li.children)
|
||
items.append(f"{prefix}{inner.strip()}")
|
||
return "\n".join(items) + "\n"
|
||
|
||
# --- Blockquote ---
|
||
if tag == "blockquote":
|
||
inner = get_text(el, " ").strip()
|
||
lines = inner.split("\n")
|
||
return "\n".join(f"> {line}" for line in lines) + "\n"
|
||
|
||
# --- Horizontal rule ---
|
||
if tag == "hr":
|
||
return "---\n"
|
||
|
||
# --- Code (standalone) ---
|
||
if tag == "code":
|
||
text = get_text(el, "\n")
|
||
return f"`{text}`\n"
|
||
|
||
# --- Block containers: recurse ---
|
||
if tag in ("div", "section", "article", "figure", "figcaption",
|
||
"dl", "dd", "dt", "details", "summary", "main", "aside"):
|
||
parts = []
|
||
for child in el.children:
|
||
if isinstance(child, NavigableString):
|
||
text = str(child).strip()
|
||
if text:
|
||
parts.append(text)
|
||
elif isinstance(child, Tag):
|
||
md = element_to_md(child, pics_dir, session, base_url, img_cache)
|
||
if md:
|
||
parts.append(md)
|
||
return "\n".join(parts)
|
||
|
||
# --- Unknown tag ---
|
||
inner = "".join(inline_to_md(c) for c in el.children)
|
||
return inner.strip() if inner.strip() else ""
|
||
|
||
|
||
def extract_content(html: str, url: str) -> list[PageElement]:
|
||
"""Parse HTML and extract the main content elements from #help."""
|
||
soup = BeautifulSoup(html, "html.parser")
|
||
|
||
help_div = soup.find(id="help")
|
||
if not help_div:
|
||
for sel in ("article", "main", ".article", "#article"):
|
||
help_div = soup.select_one(sel)
|
||
if help_div:
|
||
break
|
||
|
||
if not help_div:
|
||
log.warning("No content container found for %s", url)
|
||
return []
|
||
|
||
start_el = help_div.select_one(CONTENT_START_SELECTOR)
|
||
if not start_el:
|
||
start_el = help_div.find("h1")
|
||
if not start_el:
|
||
for child in help_div.children:
|
||
if isinstance(child, Tag):
|
||
start_el = child
|
||
break
|
||
if not start_el:
|
||
log.warning("No content found in #help for %s", url)
|
||
return []
|
||
|
||
elements: list[PageElement] = []
|
||
current: PageElement | None = start_el
|
||
while current:
|
||
elements.append(current)
|
||
current = current.find_next_sibling()
|
||
|
||
return elements
|
||
|
||
|
||
def elements_to_markdown(
|
||
elements: list[PageElement],
|
||
pics_dir: Path | None,
|
||
session: requests.Session | None,
|
||
base_url: str,
|
||
) -> str:
|
||
"""Convert a list of content elements to a Markdown string."""
|
||
img_cache: dict[str, str | None] = {}
|
||
parts = []
|
||
for el in elements:
|
||
md = element_to_md(el, pics_dir, session, base_url, img_cache)
|
||
if md:
|
||
parts.append(md)
|
||
|
||
result = "\n".join(parts)
|
||
result = re.sub(r"\n{3,}", "\n\n", result)
|
||
return result.strip() + "\n"
|
||
|
||
|
||
# Phase 2: convert orchestrator
|
||
|
||
|
||
def convert_one(
|
||
html_path: str,
|
||
md_path: str,
|
||
pics_dir: Path | None,
|
||
session: requests.Session | None,
|
||
base_url: str,
|
||
) -> bool:
|
||
"""Read a local HTML file and convert to Markdown. Returns True on success."""
|
||
html_file = Path(html_path)
|
||
if not html_file.exists():
|
||
log.warning("HTML cache miss: %s", html_path)
|
||
return False
|
||
|
||
html = html_file.read_text(encoding="utf-8")
|
||
elements = extract_content(html, base_url)
|
||
if not elements:
|
||
log.warning("No content extracted from %s", html_path)
|
||
return False
|
||
|
||
md_text = elements_to_markdown(elements, pics_dir, session, base_url)
|
||
|
||
out = Path(md_path)
|
||
out.parent.mkdir(parents=True, exist_ok=True)
|
||
out.write_text(md_text, encoding="utf-8")
|
||
return True
|
||
|
||
|
||
def run_convert(
|
||
sitemap_path: str,
|
||
force: bool = False,
|
||
dry_run: bool = False,
|
||
) -> None:
|
||
"""Phase 2: convert cached HTML to Markdown."""
|
||
urls = parse_sitemap(sitemap_path)
|
||
root_prefix = _root_prefix_from_sitemap(sitemap_path)
|
||
label = _label_from_sitemap(sitemap_path)
|
||
md_output_dir = SITEMAP_DEFAULTS.get(sitemap_path, ("", label))[0]
|
||
|
||
file_map = build_file_map(urls, root_prefix, md_output_dir, label)
|
||
|
||
if dry_run:
|
||
print(f"\n=== Convert dry run: {sitemap_path} ===")
|
||
print(f"Total: {len(file_map)} files")
|
||
html_cache = HTML_CACHE_ROOT / label
|
||
cached = sum(1 for e in file_map if Path(e["html_path"]).exists())
|
||
missing = len(file_map) - cached
|
||
print(f"Cached HTML: {cached}, Missing: {missing}")
|
||
for entry in file_map[:10]:
|
||
exists = Path(entry["html_path"]).exists()
|
||
mark = "✓" if exists else "✗"
|
||
print(f" [{entry['index']:4d}] {mark} {entry['html_path']}")
|
||
print(f" → {entry['file_path']}")
|
||
if len(file_map) > 10:
|
||
print(f" ... ({len(file_map) - 10} more)")
|
||
return
|
||
|
||
# Progress log
|
||
log_path = Path(md_output_dir) / ".convert.log"
|
||
processed = _load_log(log_path) if not force else set()
|
||
|
||
# Image session (only needed if images exist)
|
||
img_session: requests.Session | None = None
|
||
|
||
success = 0
|
||
skipped = 0
|
||
failed = 0
|
||
|
||
for entry in file_map:
|
||
url = entry["url"]
|
||
if url in processed and not force:
|
||
skipped += 1
|
||
continue
|
||
|
||
pics_dir = None
|
||
if entry["chapter_folder"]:
|
||
pics_dir = Path(md_output_dir) / entry["chapter_folder"] / "pics"
|
||
|
||
# Lazy-init image session only when needed
|
||
if img_session is None:
|
||
img_session = requests.Session()
|
||
|
||
img_session.headers.update({"User-Agent": get_user_agent(entry["index"])})
|
||
|
||
base_url = url.rsplit("/", 1)[0] + "/" if "/" in url else url
|
||
ok = convert_one(
|
||
entry["html_path"], entry["file_path"],
|
||
pics_dir, img_session, base_url,
|
||
)
|
||
if ok:
|
||
success += 1
|
||
_append_log(log_path, url)
|
||
log.info("[%d/%d] OK: %s", success + skipped + failed, len(file_map), url)
|
||
else:
|
||
failed += 1
|
||
log.error("[%d/%d] FAIL: %s", success + skipped + failed, len(file_map), url)
|
||
|
||
log.info(
|
||
"Done: %d success, %d skipped, %d failed (total %d)",
|
||
success, skipped, failed, len(file_map),
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Debug
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def run_debug(url: str) -> None:
|
||
"""Debug: fetch a single URL, print HTML structure, extract + convert."""
|
||
session = requests.Session()
|
||
session.headers.update({"User-Agent": get_user_agent()})
|
||
|
||
print(f"Fetching: {url}")
|
||
html = fetch_html(url, session)
|
||
if html is None:
|
||
print("FETCH FAILED")
|
||
return
|
||
|
||
soup = BeautifulSoup(html, "html.parser")
|
||
help_div = soup.find(id="help")
|
||
if not help_div:
|
||
print("#help NOT FOUND")
|
||
return
|
||
|
||
print("#help found")
|
||
|
||
h1 = help_div.select_one(CONTENT_START_SELECTOR)
|
||
if not h1:
|
||
h1 = help_div.find("h1")
|
||
if h1:
|
||
print(f"Start h1: {h1.get_text(strip=True)[:80]}")
|
||
|
||
tables = help_div.find_all("table")
|
||
imgs = help_div.find_all("img")
|
||
pres = help_div.find_all("pre")
|
||
ps = help_div.find_all("p")
|
||
print(f"Tables: {len(tables)}, Imgs: {len(imgs)}, Pre: {len(pres)}, P: {len(ps)}")
|
||
|
||
print("\n--- Tables ---")
|
||
for i, t in enumerate(tables):
|
||
thead = t.find("thead")
|
||
rows = t.find_all("tr")
|
||
first_cell = rows[0].find(["td", "th"]) if rows else None
|
||
cell_text = first_cell.get_text(strip=True)[:60] if first_cell else "(empty)"
|
||
has_code = bool(t.find("code") or t.find("pre"))
|
||
print(f" Table {i}: thead={bool(thead)}, rows={len(rows)}, "
|
||
f"code={has_code}, first: {cell_text}")
|
||
|
||
print("\n--- Images ---")
|
||
for i, img in enumerate(imgs[:10]):
|
||
src = img.get("src", "")
|
||
alt = img.get("alt", "")
|
||
next_p = img.find_next_sibling("p")
|
||
next_p_text = next_p.get_text(strip=True)[:60] if next_p else "(no next p)"
|
||
print(f" Img {i}: src={src[:60]}, alt={alt[:40]}, next_p: {next_p_text}")
|
||
|
||
print("\n--- Extraction ---")
|
||
elements = extract_content(html, url)
|
||
print(f"Extracted {len(elements)} elements")
|
||
|
||
base_url = url.rsplit("/", 1)[0] + "/" if "/" in url else url
|
||
md_text = elements_to_markdown(elements, None, session, base_url)
|
||
print(f"\n--- Markdown output ({len(md_text)} chars) ---")
|
||
print(md_text[:3000])
|
||
if len(md_text) > 3000:
|
||
print(f"\n... ({len(md_text) - 3000} more chars)")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Progress log helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _load_log(log_path: Path) -> set[str]:
|
||
"""Load set of already-processed URLs from log file."""
|
||
if not log_path.exists():
|
||
return set()
|
||
urls = set()
|
||
for line in log_path.read_text().splitlines():
|
||
line = line.strip()
|
||
if line and not line.startswith("#"):
|
||
urls.add(line)
|
||
return urls
|
||
|
||
|
||
def _append_log(log_path: Path, url: str) -> None:
|
||
"""Append a URL to the processing log."""
|
||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||
with open(log_path, "a") as f:
|
||
f.write(url + "\n")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Sitemap helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _root_prefix_from_sitemap(sitemap_path: str) -> str:
|
||
if "book" in sitemap_path:
|
||
return "/en/book/"
|
||
if "docs" in sitemap_path:
|
||
return "/en/docs/"
|
||
return "/"
|
||
|
||
|
||
def _label_from_sitemap(sitemap_path: str) -> str:
|
||
if "book" in sitemap_path:
|
||
return "book"
|
||
if "docs" in sitemap_path:
|
||
return "docs"
|
||
return "index"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# CLI
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def main() -> None:
|
||
parser = argparse.ArgumentParser(
|
||
description="Two-phase MQL5 extraction: download HTML → convert to Markdown."
|
||
)
|
||
sub = parser.add_subparsers(dest="command", required=True)
|
||
|
||
# --- download ---
|
||
dl = sub.add_parser("download", help="Phase 1: download HTML from mql5.com")
|
||
dl_group = dl.add_mutually_exclusive_group(required=True)
|
||
dl_group.add_argument("--sitemap", help="Path to a sitemap XML file")
|
||
dl_group.add_argument("--all", action="store_true", help="All known sitemaps")
|
||
dl.add_argument("--force", action="store_true", help="Re-download all")
|
||
dl.add_argument("--dry-run", action="store_true", help="Show plan without fetching")
|
||
dl.add_argument("--random-ua", action="store_true",
|
||
help="Use random User-Agent per request (default: deterministic rotation)")
|
||
dl.add_argument("--random-delay", action="store_true",
|
||
help="Random delay between requests (1×–3× base delay)")
|
||
dl.add_argument("--user-agents-csv", metavar="CSV",
|
||
help="Import User-Agents from CSV file (must have 'user_agent' column)")
|
||
|
||
# --- convert ---
|
||
cv = sub.add_parser("convert", help="Phase 2: convert cached HTML to Markdown")
|
||
cv_group = cv.add_mutually_exclusive_group(required=True)
|
||
cv_group.add_argument("--sitemap", help="Path to a sitemap XML file")
|
||
cv_group.add_argument("--all", action="store_true", help="All known sitemaps")
|
||
cv.add_argument("--force", action="store_true", help="Re-convert all")
|
||
cv.add_argument("--dry-run", action="store_true", help="Show plan without converting")
|
||
|
||
# --- debug ---
|
||
dbg = sub.add_parser("debug", help="Debug: fetch + analyze a single URL")
|
||
dbg.add_argument("url", help="URL to debug")
|
||
|
||
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose logging")
|
||
|
||
args = parser.parse_args()
|
||
|
||
level = logging.DEBUG if args.verbose else logging.INFO
|
||
logging.basicConfig(
|
||
level=level,
|
||
format="%(asctime)s %(levelname)s %(message)s",
|
||
datefmt="%H:%M:%S",
|
||
)
|
||
|
||
if args.command == "debug":
|
||
run_debug(args.url)
|
||
return
|
||
|
||
targets = []
|
||
if args.command == "download":
|
||
if args.all:
|
||
targets = list(SITEMAP_DEFAULTS.keys())
|
||
else:
|
||
targets = [args.sitemap]
|
||
for st in targets:
|
||
if not os.path.exists(st):
|
||
log.warning("Sitemap not found: %s — skipping", st)
|
||
continue
|
||
run_download(st, force=args.force, dry_run=args.dry_run,
|
||
random_ua=args.random_ua, random_delay=args.random_delay,
|
||
user_agents_csv=args.user_agents_csv)
|
||
|
||
elif args.command == "convert":
|
||
if args.all:
|
||
targets = list(SITEMAP_DEFAULTS.keys())
|
||
else:
|
||
targets = [args.sitemap]
|
||
for st in targets:
|
||
if not os.path.exists(st):
|
||
log.warning("Sitemap not found: %s — skipping", st)
|
||
continue
|
||
run_convert(st, force=args.force, dry_run=args.dry_run)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|