#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ web_to_md.py - Web Page to Markdown Converter (Python Version) Usage: python scripts/source_to_md/web_to_md.py python scripts/source_to_md/web_to_md.py ... python scripts/source_to_md/web_to_md.py -f urls.txt python scripts/source_to_md/web_to_md.py -o output.md Dependencies: pip install requests beautifulsoup4 TLS fingerprint handling: Some sites (e.g., WeChat mp.weixin.qq.com) block Python's default 'requests' library based on TLS fingerprints (JA3). If 'curl_cffi' is installed, this script uses it to impersonate a modern Chrome fingerprint and bypass such blocks. If 'curl_cffi' is unavailable, it silently falls back to plain 'requests' — so non-blocking sites still work without the extra dependency. Install for WeChat / Chinese-portal coverage: pip install curl_cffi If curl_cffi is unavailable on your platform, the Node.js counterpart (scripts/source_to_md/web_to_md.cjs) remains available as a fallback. """ import argparse import codecs import datetime import io import json import os import re import sys import time from pathlib import Path from urllib.parse import urljoin, urlparse _SCRIPTS_DIR = Path(__file__).resolve().parents[1] if str(_SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(_SCRIPTS_DIR)) from console_encoding import configure_utf8_stdio # noqa: E402 from _conversion_profile import ( # noqa: E402 profile_path_for, write_conversion_profile_best_effort, ) configure_utf8_stdio() try: import requests from bs4 import BeautifulSoup, NavigableString, Tag except ImportError: print("Error: This script requires 'requests' and 'beautifulsoup4'.") print("Please run: pip install requests beautifulsoup4") sys.exit(1) # Prefer curl_cffi for TLS-fingerprint impersonation (bypasses JA3 blocking on # sites like WeChat). Fall back to plain requests when it's not installed. try: from curl_cffi import requests as curl_requests # type: ignore _CURL_IMPERSONATE = "chrome120" except ImportError: curl_requests = None _CURL_IMPERSONATE = None def _http_get(url: str, *, headers: dict | None = None, timeout: int | None = None, verify: bool = False, stream: bool = False): """HTTP GET with curl_cffi preferred, requests fallback. Using curl_cffi lets this script fetch sites that reject Python's default TLS fingerprint (notably mp.weixin.qq.com). Signature mirrors the subset of requests.get() this script actually uses. """ if curl_requests is not None: return curl_requests.get( url, headers=headers, timeout=timeout, verify=verify, impersonate=_CURL_IMPERSONATE, stream=stream, ) return requests.get(url, headers=headers, timeout=timeout, verify=verify, stream=stream) def _normalize_charset(charset: str | None) -> str: """Return a Python codec name when the declared charset is usable.""" if not charset: return "" charset = charset.strip().strip('"').strip("'").lower() if not charset: return "" try: return codecs.lookup(charset).name except LookupError: return "" def _charset_from_headers(headers: dict) -> str: content_type = headers.get("Content-Type") or headers.get("content-type") or "" match = re.search(r"charset\s*=\s*([^;\s]+)", content_type, re.I) return _normalize_charset(match.group(1)) if match else "" def _charset_from_html(raw: bytes) -> str: """Extract a charset declaration from the first chunk of HTML bytes.""" head = raw[:8192] patterns = [ rb"]+charset=[\"']?\s*([a-zA-Z0-9_\-]+)", rb"]+content=[\"'][^\"']*charset=\s*([a-zA-Z0-9_\-]+)", ] for pattern in patterns: match = re.search(pattern, head, re.I) if match: return _normalize_charset(match.group(1).decode("ascii", "ignore")) return "" def _decode_quality_score(text: str) -> int: """Score obvious decode artifacts; lower is better.""" mojibake_markers = [ "�", "锟", "Ã", "Â", "â€", "’", "“", "â€\x9d", "琚", "佸", "鍦", "涓", "鏄", "寤", "骞", "鏈", "鏃", "鈥", ] marker_hits = sum(text.count(marker) for marker in mojibake_markers) control_hits = sum(1 for ch in text if ord(ch) < 32 and ch not in "\t\n\r") return marker_hits * 20 + control_hits * 10 + text.count("\ufffd") * 50 def _decode_response_text(response) -> str: """Decode HTTP response bytes without letting guessed encodings override declarations.""" raw = response.content declared = [ _charset_from_headers(response.headers), _charset_from_html(raw), ] if raw.startswith(codecs.BOM_UTF8): declared.insert(0, "utf-8-sig") seen = set() declared = [enc for enc in declared if enc and not (enc in seen or seen.add(enc))] for enc in declared: try: return raw.decode(enc) except UnicodeDecodeError: continue candidates = [] for enc in [ getattr(response, "encoding", None), getattr(response, "apparent_encoding", None), "utf-8", "gb18030", "big5", ]: enc = _normalize_charset(enc) if enc and enc not in candidates: candidates.append(enc) decoded = [] for enc in candidates: try: text = raw.decode(enc) except UnicodeDecodeError: continue decoded.append((_decode_quality_score(text), enc, text)) if decoded: decoded.sort(key=lambda item: item[0]) return decoded[0][2] return raw.decode("utf-8", errors="replace") try: from PIL import Image PILLOW_AVAILABLE = True except ImportError: PILLOW_AVAILABLE = False print("[WARN] Pillow not installed. WebP images will not be converted to PNG.") print(" Run: pip install Pillow") # ============ Config ============ CONFIG = { "output_dir": "./projects", "timeout": 30, "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", # Specific content identifiers often found in Chinese CMS (Gov/News) "content_selectors": [ {"class_": re.compile(r"tys-main-zt-show", re.I)}, {"class_": re.compile(r"tys-main", re.I)}, {"class_": "TRS_Editor"}, {"class_": "TRS_UEDITOR"}, {"class_": "ucontent"}, {"class_": "article-content"}, {"class_": "news-content"}, {"class_": "detail-content"}, {"class_": "content-text"}, {"class_": "pages_content"}, {"class_": "zwgk_content"}, {"class_": "content_detail"}, {"class_": "text_content"}, {"class_": "main-content"}, {"class_": "main_content"}, {"class_": "view-content"}, {"class_": "info-content"}, {"id": "Zoom"}, {"id": "content"}, {"id": "article"}, {"class_": "content"}, {"name": "article"}, # tag name {"name": "main"}, # tag name ] } def fetch_url(url: str) -> str: """Fetch a web page with explicit headers and encoding detection. Args: url: Target URL. Returns: The response body as text. """ headers = { "User-Agent": CONFIG["user_agent"], "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8" } try: response = _http_get(url, headers=headers, timeout=CONFIG["timeout"], verify=False) response.raise_for_status() return _decode_response_text(response) except Exception as e: raise Exception(f"Failed to fetch {url}: {str(e)}") def clean_title(title: str) -> str: """Remove common site suffixes from a title.""" if not title: return "" # Remove site name suffixes often found in Chinese titles clean = re.sub(r"[-_|].*?(政府|门户|网站|委员会).*$", "", title) return clean.strip() def sanitize_filename(name: str) -> str: """Sanitize a string for filesystem-safe filenames.""" # Replace whitespace with underscore first clean = re.sub(r'\s+', '_', name) # Remove all except Chinese, English, Numbers, Underscore clean = re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9_]', '', clean) # Collapse repeating underscores clean = re.sub(r'_+', '_', clean) return clean[:80] # Truncate def derive_base_name(title: str, url: str) -> str: """Derive a safe, non-empty basename from a title or URL.""" base = sanitize_filename(title or "") if base: return base parsed = urlparse(url) path = parsed.path.strip('/') if path: candidate = f"{parsed.netloc}_{path}" else: candidate = parsed.netloc or "untitled" base = sanitize_filename(candidate) if base: return base ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") return f"untitled_{ts}" def build_image_filename(abs_url: str, seq: int, content_type: str | None = None) -> str: """Build a safe image filename from URL metadata.""" parsed = urlparse(abs_url) basename = os.path.basename(parsed.path).split('?')[0] stem, ext = os.path.splitext(basename) if not ext or len(ext) > 5 or '/' in ext: ext = "" if not ext and content_type: ctype = content_type.split(';')[0].lower() ext_map = { "image/jpeg": ".jpg", "image/jpg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp", } ext = ext_map.get(ctype, "") if not ext: ext = ".jpg" stem = sanitize_filename(stem) if stem else f"image_{seq}" return f"{stem}{ext}" def download_and_rewrite_images( content_element: Tag | None, page_url: str, image_dir: str, rel_prefix: str, ) -> int: """Download images under the main content node and rewrite `src` paths.""" if content_element is None: return 0 images = list(content_element.find_all("img")) if not images: return 0 os.makedirs(image_dir, exist_ok=True) downloaded = {} manifest_by_filename: dict[str, dict[str, object]] = {} saved = 0 for idx, img in enumerate(images): # Prefer lazy-load attributes — WeChat, Zhihu, and many CMSes keep the # real image URL in data-src / data-original / data-lazy-src, with # `src` pointing at a 1x1 placeholder or a template literal. candidates = [ img.get("data-src"), img.get("data-original"), img.get("data-lazy-src"), img.get("data-actualsrc"), img.get("src"), ] src = next((s for s in candidates if s and not s.startswith("data:") and s.startswith(("http://", "https://", "//", "/"))), None) if not src: continue # Promote the chosen URL into the element's src so downstream rewrite # (which matches on src) can retarget it to the local file. img["src"] = src abs_url = urljoin(page_url, src) content_type = "" converted_from = "" if abs_url in downloaded: saved_name = downloaded[abs_url] else: try: resp = _http_get( abs_url, headers={"User-Agent": CONFIG["user_agent"]}, timeout=CONFIG["timeout"], verify=False, ) resp.raise_for_status() filename = build_image_filename( abs_url, idx, resp.headers.get("Content-Type")) # Check if image is webp and convert to png stem, ext = os.path.splitext(filename) content_type = resp.headers.get("Content-Type", "").lower() is_webp = ext.lower() == ".webp" or "webp" in content_type if is_webp and PILLOW_AVAILABLE: # Convert webp to png (optimized) try: img_data = io.BytesIO(resp.content) pil_image = Image.open(img_data) # Update filename to .png converted_from = filename filename = f"{stem}.png" local_path = os.path.join(image_dir, filename) # Avoid accidental overwrites if filenames collide counter = 1 while os.path.exists(local_path): local_path = os.path.join( image_dir, f"{stem}_{counter}.png") filename = os.path.basename(local_path) counter += 1 # Save as PNG directly (Pillow auto-converts, no need for explicit mode conversion) pil_image.save(local_path, 'PNG', optimize=False) pil_image.close() print(f" [INFO] Converted webp to png: {filename}") except Exception as convert_err: print( f" [WARN] Failed to convert webp: {convert_err}, saving as-is") local_path = os.path.join(image_dir, filename) counter = 1 stem, ext = os.path.splitext(filename) while os.path.exists(local_path): local_path = os.path.join( image_dir, f"{stem}_{counter}{ext}") filename = os.path.basename(local_path) counter += 1 with open(local_path, "wb") as f: f.write(resp.content) else: local_path = os.path.join(image_dir, filename) # Avoid accidental overwrites if filenames collide counter = 1 stem, ext = os.path.splitext(filename) while os.path.exists(local_path): local_path = os.path.join( image_dir, f"{stem}_{counter}{ext}") filename = os.path.basename(local_path) counter += 1 with open(local_path, "wb") as f: f.write(resp.content) downloaded[abs_url] = filename saved_name = filename manifest_by_filename[saved_name] = { "index": len(manifest_by_filename) + 1, "filename": saved_name, "original_filename": converted_from or saved_name, "asset_kind": "bitmap", "svg_renderable": True, "pptx_native_supported": True, "source_kind": "web_image", "source_url": abs_url, "source_page_url": page_url, "content_type": content_type.split(";")[0] if content_type else "", "occurrences": [], } saved += 1 except Exception as e: print(f" [WARN] Skip image {abs_url}: {e}") continue rel_path = os.path.join( rel_prefix, saved_name) if rel_prefix else saved_name img["src"] = rel_path manifest_item = manifest_by_filename.get(saved_name) if manifest_item is not None: occurrences = manifest_item.setdefault("occurrences", []) if isinstance(occurrences, list): occurrences.append({ "occurrence_index": idx + 1, "source_url": abs_url, "alt_text": img.get("alt", ""), }) manifest_item["usage_count"] = len(occurrences) if manifest_by_filename: manifest_path = os.path.join(image_dir, "image_manifest.json") with open(manifest_path, "w", encoding="utf-8") as f: json.dump( list(manifest_by_filename.values()), f, ensure_ascii=False, indent=2, ) f.write("\n") return saved def extract_metadata(soup: BeautifulSoup, url: str) -> dict[str, str]: """Extract page metadata such as title, date, description, and author.""" # 1. Title title_tag = soup.title title = clean_title(title_tag.string if title_tag else "") # 2. Meta tags metas = {} for meta in soup.find_all("meta"): name = meta.get("name") or meta.get("property") content = meta.get("content") if name and content: metas[name.lower()] = content.strip() # 3. Date Extraction Strategies date = ( metas.get("article:published_time") or metas.get("og:published_time") or metas.get("pubdate") or metas.get("publishdate") or metas.get("date") ) if not date: # Try matching date patterns in the text text_content = soup.get_text() date_patterns = [ r"发布[时日]间[::]\s*(\d{4}[-\/年]\d{1,2}[-\/月]\d{1,2}[日]?)", r"日期[::]\s*(\d{4}[-\/年]\d{1,2}[-\/月]\d{1,2}[日]?)", r"(\d{4}[-\/年]\d{1,2}[-\/月]\d{1,2}[日]?)\s*(?:发布|来源)", r"时间[::]\s*(\d{4}[-\/]\d{1,2}[-\/]\d{1,2})" ] for pattern in date_patterns: match = re.search(pattern, text_content) if match: date = match.group(1).replace( "年", "-").replace("月", "-").replace("日", "") break if not date: # Try URL matching match = re.search(r"(\d{4})(\d{2})[\/_](?:t\d+_)?", url) if match: date = f"{match.group(1)}-{match.group(2)}" else: match = re.search(r"(\d{4})[-\/](\d{2})[-\/](\d{2})", url) if match: date = f"{match.group(1)}-{match.group(2)}-{match.group(3)}" # 4. Description description = ( metas.get("description") or metas.get("og:description") or metas.get("twitter:description") or "" ) # 5. Author/Source author = metas.get("author") or metas.get("article:author") if not author: # Try common patterns source_patterns = [ r"来源[::]\s*([^\s<]+)", r"发布(?:单位|机构)[::]\s*([^\s<]+)" ] for pattern in source_patterns: match = re.search(pattern, soup.get_text()) if match: author = match.group(1) break return { "title": title or metas.get("og:title") or "Untitled", "date": date or "", "description": description, "author": author or "", "source_url": url } def find_main_content(soup: BeautifulSoup) -> Tag | None: """Find the most likely main content container in a page.""" # 1. Clean up first (remove known clutter) for tag in soup(["script", "style", "nav", "header", "footer", "aside", "noscript", "iframe"]): tag.decompose() best_element = None max_score = 0 # 2. Strategy A: Check specific classes/ids for selector in CONFIG["content_selectors"]: if "name" in selector: # Tag name match (article, main) elements = soup.find_all(selector["name"]) else: # Class or ID match elements = soup.find_all(attrs=selector) for el in elements: # Score based on text length and chinese character count text = el.get_text(strip=True) length = len(text) if length < 100: continue chinese_count = len(re.findall(r'[\u4e00-\u9fa5]', text)) score = length + (chinese_count * 2) if score > max_score: max_score = score best_element = el # 3. Strategy B: If no specific container found, look for dense text areas with paragraphs if not best_element or max_score < 200: for div in soup.find_all("div"): p_count = len(div.find_all("p", recursive=False)) # recursive=False ensures we don't just pick the top-level body by accident # but sometimes content is nested deep if p_count == 0: # Check if it has lots of text even without p tags (br tags?) pass text = div.get_text(strip=True) if len(text) > 200 and p_count >= 1: # Recalculate deep score chinese_count = len(re.findall(r'[\u4e00-\u9fa5]', text)) score = len(text) + (chinese_count * 2) + (p_count * 50) if score > max_score: max_score = score best_element = div # Fallback to body return best_element if best_element else soup.body def element_to_markdown(element: Tag | NavigableString | None) -> str: """Recursively convert a BeautifulSoup node to Markdown.""" if element is None: return "" if isinstance(element, NavigableString): text = str(element).strip() return text if text else "" tag_name = element.name.lower() # Skip hidden/unwanted tags if tag_name in ['script', 'style', 'meta', 'link', 'input', 'button', 'select']: return "" content = "" for child in element.children: content += element_to_markdown(child) # Add spacing logic here if needed, but usually block elements handle it # Block handlers if tag_name in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']: level = int(tag_name[1]) return f"\n{'#' * level} {content}\n\n" elif tag_name == 'p': # Clean up internal whitespace content = re.sub(r'\s+', ' ', content).strip() return f"\n{content}\n\n" if content else "" elif tag_name == 'br': return " \n" elif tag_name == 'hr': return "\n---\n" elif tag_name == 'div': return f"\n{content}\n" elif tag_name == 'blockquote': lines = content.strip().split('\n') quoted = '\n'.join([f"> {line}" for line in lines if line.strip()]) return f"\n{quoted}\n\n" elif tag_name in ['ul', 'ol']: # This is tricky without "state" (knowing we are in a list) # For simplicity in this recursive version, we rely on LI handling return f"\n{content}\n" elif tag_name == 'li': # Simple list handling clean_content = content.strip() return f"- {clean_content}\n" elif tag_name == 'pre': return f"\n```\n{content}\n```\n\n" elif tag_name == 'code': # If parent is pre, handle in pre. If inline: parent = element.parent if parent and parent.name == 'pre': return content return f"`{content}`" elif tag_name == 'a': href = element.get('href', '') if href and not href.startswith('javascript:'): return f"[{content}]({href})" return content elif tag_name == 'img': src = element.get('src', '') alt = element.get('alt', '') if src: return f"![{alt}]({src})" return "" elif tag_name == 'table': # Basic table text extraction, full markdown table support is complex # Leaving as raw text or simplistic conversion for now # Ideally, we'd parse TRs and TDs return f"\n{content}\n" elif tag_name == 'tr': return f"{content}|\n" elif tag_name in ['td', 'th']: return f"| {content.strip()} " # Style formatting elif tag_name in ['strong', 'b']: return f"**{content}**" elif tag_name in ['em', 'i']: return f"*{content}*" elif tag_name in ['del', 's', 'strike']: return f"~~{content}~~" # Default for span, section, etc. return f"{content} " def simple_html_to_markdown_traversal(soup: Tag | BeautifulSoup | None) -> str: """Convert HTML content to Markdown using BeautifulSoup traversal.""" lines = [] def traverse(node: Tag | NavigableString) -> str: if isinstance(node, NavigableString): text = str(node) # Normalize whitespace but keep single spaces text = re.sub(r'\s+', ' ', text) if text.strip(): return text return "" if node.name in ['script', 'style', 'comment', 'meta', 'link']: return "" # Handle Block Elements is_block = node.name in ['p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'blockquote', 'pre', 'hr', 'table', 'tr'] # Pre-processing prefix = "" suffix = "" if node.name in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']: level = int(node.name[1]) prefix = f"\n\n{'#' * level} " suffix = "\n\n" elif node.name == 'p': prefix = "\n\n" suffix = "\n\n" elif node.name == 'li': prefix = "\n- " elif node.name == 'blockquote': prefix = "\n> " suffix = "\n" elif node.name == 'hr': return "\n\n---\n\n" elif node.name == 'br': return " \n" elif node.name == 'pre': # Extract raw text from pre to preserve formatting return f"\n\n```\n{node.get_text()}\n```\n\n" # Inline formatting if node.name in ['strong', 'b']: prefix, suffix = "**", "**" elif node.name in ['em', 'i']: prefix, suffix = "*", "*" elif node.name == 'code' and node.parent.name != 'pre': prefix, suffix = "`", "`" elif node.name == 'a': href = node.get('href') if href and not href.startswith('javascript:'): prefix = "[" suffix = f"]({href})" else: prefix, suffix = "", "" elif node.name == 'img': src = node.get('src') alt = node.get('alt', '') if src: return f"![{alt}]({src})" return "" # Recurse inner_text = "" for child in node.children: res = traverse(child) if res: inner_text += res # Post-processing for tables (simplified) if node.name == 'tr': # count tds cells = [c.get_text(strip=True) for c in node.find_all( ['td', 'th'], recursive=False)] return f"| {' | '.join(cells)} |\n" if node.name == 'table': # Try to add a separator line after first row if it looks like a header rows = inner_text.strip().split('\n') if rows: cols_count = rows[0].count('|') - 1 if cols_count > 0: # rough approx sep = "| " + " | ".join(["---"] * int(cols_count/2)) + " |" # Actually, the traverse of TR returns newline terminated strings. # Let's just return what we gathered. pass return f"\n\n{inner_text}\n\n" return f"{prefix}{inner_text}{suffix}" # Actually, a simpler approach for this script is "just get string" but with markers? # Let's use a simplified approach: use get_text but with 'separator' logic? # text = soup.get_text(separator='\n\n') # But that loses links and boldness. # Recommendation: Let's stick to the traversal above which constructs a string. md = traverse(soup) # Cleanup Markdown if md: # Remove excessive newlines md = re.sub(r'\n{3,}', '\n\n', md) md = md.strip() return md or "" def process_url(url: str, output_file: str | None = None) -> tuple[bool, str, str | None, str | None]: """Fetch, convert, and save one web page as Markdown. Returns (success, url, error, output_path). output_path is the actual saved Markdown path (derived from the article title when no output_file is given), so a caller can locate a title-named file it did not choose upfront. """ print(f"\n[Fetching] {url}") try: html = fetch_url(url) soup = BeautifulSoup(html, 'html.parser') # Extract Metadata metadata = extract_metadata(soup, url) print(f" [OK] Title: {metadata['title']}") if metadata['date']: print(f" [OK] Date: {metadata['date']}") # Determine output path and image directory upfront if output_file: output_path = output_file else: base_name = derive_base_name(metadata['title'], url) filename = f"{base_name}.md" output_path = os.path.join(CONFIG["output_dir"], filename) output_dirname = os.path.dirname(output_path) or "." os.makedirs(output_dirname, exist_ok=True) base_name = os.path.splitext(os.path.basename(output_path))[0] image_dir = os.path.join(output_dirname, f"{base_name}_files") rel_image_prefix = os.path.relpath(image_dir, output_dirname) # Extract Content content_div = find_main_content(soup) # Download images and rewrite src before markdown conversion image_count = download_and_rewrite_images( content_div, url, image_dir, rel_image_prefix) if image_count: print(f" [OK] Images: {image_count} saved to {image_dir}") # Convert to MD # Note: We pass the element to our traversal function markdown_text = simple_html_to_markdown_traversal(content_div) print(f" [OK] Content: {len(markdown_text)} chars") # Construct content final_output = [] final_output.append("\n") if metadata['title']: final_output.append(f"# {metadata['title']}\n") if metadata['description']: final_output.append(f"> {metadata['description']}\n") final_output.append(markdown_text) full_content = "\n".join(final_output) with open(output_path, 'w', encoding='utf-8') as f: f.write(full_content) profile_path = write_conversion_profile_best_effort( input_path=url, markdown_path=output_path, converter="web_to_md.py", conversion_type="web", asset_dir=image_dir, ) print(f" [OK] Saved: {output_path}") if profile_path: print(f" [OK] Conversion profile: {profile_path}") return True, url, None, output_path except Exception as e: print(f" [ERROR] {str(e)}") return False, url, str(e), None def _write_emit_result(result_file: str, url: str, markdown_path: str) -> None: """Write the actual saved path as JSON so a caller can locate the output.""" md = Path(markdown_path).resolve() profile = profile_path_for(md) payload = { "input": url, "markdown": str(md), "conversion_profile": str(profile) if profile.is_file() else "", } try: Path(result_file).write_text( json.dumps(payload, ensure_ascii=False), encoding="utf-8") except OSError as exc: print(f" [WARN] Could not write --emit-result: {exc}") def main(argv: list[str] | None = None) -> int: """Run the CLI entry point.""" parser = argparse.ArgumentParser( description="Web to Markdown Converter (Python)") parser.add_argument("urls", nargs="*", help="URLs to process") parser.add_argument( "-f", "--file", help="File containing URLs (one per line)") parser.add_argument("-o", "--output", help="Output file (single URL only)") parser.add_argument("-d", "--dir", help="Output directory") parser.add_argument( "--emit-result", help="On success, write the saved output path as JSON to this file " "(single-URL dispatcher use, so a title-named file can be located)") args = parser.parse_args(argv) if args.dir: CONFIG["output_dir"] = args.dir targets = [] if args.urls: targets.extend(args.urls) if args.file: if os.path.exists(args.file): with open(args.file, 'r', encoding='utf-8') as f: lines = [l.strip() for l in f if l.strip() and not l.strip().startswith("#")] targets.extend(lines) else: print(f"Error: File {args.file} not found", file=sys.stderr) return 1 if not targets: parser.print_usage(sys.stderr) print( "web_to_md.py: error: at least one URL or --file is required", file=sys.stderr, ) return 2 results = [] for i, url in enumerate(targets): # Allow specific output file only if 1 URL out = args.output if (len(targets) == 1 and args.output) else None success, url, err, out_path = process_url(url, out) results.append((success, url, err)) if args.emit_result and success and out_path: _write_emit_result(args.emit_result, url, out_path) # Summary success_count = sum(1 for r in results if r[0]) fail_count = len(results) - success_count print("\n" + "="*50) print( f"[Done] Success: {success_count}/{len(results)}, Failed: {fail_count}") if fail_count > 0: print("\n[Failed URLs]:") for r in results: if not r[0]: print(f" - {r[1]}: {r[2]}") return 1 return 0 if __name__ == "__main__": # Disable warnings for verify=False if needed, though often useful to see import urllib3 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) raise SystemExit(main())