Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -0,0 +1,986 @@
|
||||
#!/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 <url>
|
||||
python scripts/source_to_md/web_to_md.py <url1> <url2> ...
|
||||
python scripts/source_to_md/web_to_md.py -f urls.txt
|
||||
python scripts/source_to_md/web_to_md.py <url> -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"<meta[^>]+charset=[\"']?\s*([a-zA-Z0-9_\-]+)",
|
||||
rb"<meta[^>]+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""
|
||||
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""
|
||||
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("<!--")
|
||||
final_output.append(f" Source: {url}")
|
||||
final_output.append(
|
||||
f" Crawled: {datetime.datetime.now().isoformat()}")
|
||||
if metadata['date']:
|
||||
final_output.append(f" Published: {metadata['date']}")
|
||||
if metadata['author']:
|
||||
final_output.append(f" Author: {metadata['author']}")
|
||||
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())
|
||||
Reference in New Issue
Block a user