Files
2026-06-23 21:47:51 +08:00

975 lines
31 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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"![{alt}]({src})"
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"![{alt_text}](pics/{img_filename})\n"
else:
return f"![{alt_text}]({src})\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()