返回 ppt-master
web_to_md.py
1 #!/usr/bin/env python3
2 # -*- coding: utf-8 -*-
3 """
4 web_to_md.py - Web Page to Markdown Converter (Python Version)
5
6 Usage:
7 python scripts/source_to_md/web_to_md.py <url>
8 python scripts/source_to_md/web_to_md.py <url1> <url2> ...
9 python scripts/source_to_md/web_to_md.py -f urls.txt
10 python scripts/source_to_md/web_to_md.py <url> -o output.md
11
12 Dependencies:
13 pip install requests beautifulsoup4
14
15 TLS fingerprint handling:
16 Some sites (e.g., WeChat mp.weixin.qq.com) block Python's default 'requests'
17 library based on TLS fingerprints (JA3). If 'curl_cffi' is installed, this script
18 uses it to impersonate a modern Chrome fingerprint and bypass such blocks. If
19 'curl_cffi' is unavailable, it silently falls back to plain 'requests' — so
20 non-blocking sites still work without the extra dependency.
21
22 Install for WeChat / Chinese-portal coverage:
23 pip install curl_cffi
24
25 If curl_cffi is unavailable on your platform, the Node.js counterpart
26 (scripts/source_to_md/web_to_md.cjs) remains available as a fallback.
27 """
28
29 import argparse
30 import codecs
31 import datetime
32 import io
33 import json
34 import os
35 import re
36 import sys
37 import time
38 from pathlib import Path
39 from urllib.parse import urljoin, urlparse
40
41 _SCRIPTS_DIR = Path(__file__).resolve().parents[1]
42 if str(_SCRIPTS_DIR) not in sys.path:
43 sys.path.insert(0, str(_SCRIPTS_DIR))
44
45 from console_encoding import configure_utf8_stdio # noqa: E402
46 from _conversion_profile import ( # noqa: E402
47 profile_path_for,
48 write_conversion_profile_best_effort,
49 )
50
51 configure_utf8_stdio()
52
53 try:
54 import requests
55 from bs4 import BeautifulSoup, NavigableString, Tag
56 except ImportError:
57 print("Error: This script requires 'requests' and 'beautifulsoup4'.")
58 print("Please run: pip install requests beautifulsoup4")
59 sys.exit(1)
60
61 # Prefer curl_cffi for TLS-fingerprint impersonation (bypasses JA3 blocking on
62 # sites like WeChat). Fall back to plain requests when it's not installed.
63 try:
64 from curl_cffi import requests as curl_requests # type: ignore
65 _CURL_IMPERSONATE = "chrome120"
66 except ImportError:
67 curl_requests = None
68 _CURL_IMPERSONATE = None
69
70
71 def _http_get(url: str, *, headers: dict | None = None, timeout: int | None = None,
72 verify: bool = False, stream: bool = False):
73 """HTTP GET with curl_cffi preferred, requests fallback.
74
75 Using curl_cffi lets this script fetch sites that reject Python's default
76 TLS fingerprint (notably mp.weixin.qq.com). Signature mirrors the subset of
77 requests.get() this script actually uses.
78 """
79 if curl_requests is not None:
80 return curl_requests.get(
81 url, headers=headers, timeout=timeout,
82 verify=verify, impersonate=_CURL_IMPERSONATE, stream=stream,
83 )
84 return requests.get(url, headers=headers, timeout=timeout,
85 verify=verify, stream=stream)
86
87
88 def _normalize_charset(charset: str | None) -> str:
89 """Return a Python codec name when the declared charset is usable."""
90 if not charset:
91 return ""
92 charset = charset.strip().strip('"').strip("'").lower()
93 if not charset:
94 return ""
95 try:
96 return codecs.lookup(charset).name
97 except LookupError:
98 return ""
99
100
101 def _charset_from_headers(headers: dict) -> str:
102 content_type = headers.get("Content-Type") or headers.get("content-type") or ""
103 match = re.search(r"charset\s*=\s*([^;\s]+)", content_type, re.I)
104 return _normalize_charset(match.group(1)) if match else ""
105
106
107 def _charset_from_html(raw: bytes) -> str:
108 """Extract a charset declaration from the first chunk of HTML bytes."""
109 head = raw[:8192]
110 patterns = [
111 rb"<meta[^>]+charset=[\"']?\s*([a-zA-Z0-9_\-]+)",
112 rb"<meta[^>]+content=[\"'][^\"']*charset=\s*([a-zA-Z0-9_\-]+)",
113 ]
114 for pattern in patterns:
115 match = re.search(pattern, head, re.I)
116 if match:
117 return _normalize_charset(match.group(1).decode("ascii", "ignore"))
118 return ""
119
120
121 def _decode_quality_score(text: str) -> int:
122 """Score obvious decode artifacts; lower is better."""
123 mojibake_markers = [
124 "�", "锟", "Ã", "Â", "â€", "’", "“", "â€\x9d",
125 "琚", "佸", "鍦", "涓", "鏄", "寤", "骞", "鏈", "鏃", "鈥",
126 ]
127 marker_hits = sum(text.count(marker) for marker in mojibake_markers)
128 control_hits = sum(1 for ch in text if ord(ch) < 32 and ch not in "\t\n\r")
129 return marker_hits * 20 + control_hits * 10 + text.count("\ufffd") * 50
130
131
132 def _decode_response_text(response) -> str:
133 """Decode HTTP response bytes without letting guessed encodings override declarations."""
134 raw = response.content
135 declared = [
136 _charset_from_headers(response.headers),
137 _charset_from_html(raw),
138 ]
139 if raw.startswith(codecs.BOM_UTF8):
140 declared.insert(0, "utf-8-sig")
141
142 seen = set()
143 declared = [enc for enc in declared if enc and not (enc in seen or seen.add(enc))]
144 for enc in declared:
145 try:
146 return raw.decode(enc)
147 except UnicodeDecodeError:
148 continue
149
150 candidates = []
151 for enc in [
152 getattr(response, "encoding", None),
153 getattr(response, "apparent_encoding", None),
154 "utf-8",
155 "gb18030",
156 "big5",
157 ]:
158 enc = _normalize_charset(enc)
159 if enc and enc not in candidates:
160 candidates.append(enc)
161
162 decoded = []
163 for enc in candidates:
164 try:
165 text = raw.decode(enc)
166 except UnicodeDecodeError:
167 continue
168 decoded.append((_decode_quality_score(text), enc, text))
169
170 if decoded:
171 decoded.sort(key=lambda item: item[0])
172 return decoded[0][2]
173
174 return raw.decode("utf-8", errors="replace")
175
176 try:
177 from PIL import Image
178 PILLOW_AVAILABLE = True
179 except ImportError:
180 PILLOW_AVAILABLE = False
181 print("[WARN] Pillow not installed. WebP images will not be converted to PNG.")
182 print(" Run: pip install Pillow")
183
184 # ============ Config ============
185 CONFIG = {
186 "output_dir": "./projects",
187 "timeout": 30,
188 "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",
189 # Specific content identifiers often found in Chinese CMS (Gov/News)
190 "content_selectors": [
191 {"class_": re.compile(r"tys-main-zt-show", re.I)},
192 {"class_": re.compile(r"tys-main", re.I)},
193 {"class_": "TRS_Editor"},
194 {"class_": "TRS_UEDITOR"},
195 {"class_": "ucontent"},
196 {"class_": "article-content"},
197 {"class_": "news-content"},
198 {"class_": "detail-content"},
199 {"class_": "content-text"},
200 {"class_": "pages_content"},
201 {"class_": "zwgk_content"},
202 {"class_": "content_detail"},
203 {"class_": "text_content"},
204 {"class_": "main-content"},
205 {"class_": "main_content"},
206 {"class_": "view-content"},
207 {"class_": "info-content"},
208 {"id": "Zoom"},
209 {"id": "content"},
210 {"id": "article"},
211 {"class_": "content"},
212 {"name": "article"}, # tag name
213 {"name": "main"}, # tag name
214 ]
215 }
216
217
218 def fetch_url(url: str) -> str:
219 """Fetch a web page with explicit headers and encoding detection.
220
221 Args:
222 url: Target URL.
223
224 Returns:
225 The response body as text.
226 """
227 headers = {
228 "User-Agent": CONFIG["user_agent"],
229 "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
230 "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8"
231 }
232
233 try:
234 response = _http_get(url, headers=headers,
235 timeout=CONFIG["timeout"], verify=False)
236 response.raise_for_status()
237
238 return _decode_response_text(response)
239 except Exception as e:
240 raise Exception(f"Failed to fetch {url}: {str(e)}")
241
242
243 def clean_title(title: str) -> str:
244 """Remove common site suffixes from a title."""
245 if not title:
246 return ""
247 # Remove site name suffixes often found in Chinese titles
248 clean = re.sub(r"[-_|].*?(政府|门户|网站|委员会).*$", "", title)
249 return clean.strip()
250
251
252 def sanitize_filename(name: str) -> str:
253 """Sanitize a string for filesystem-safe filenames."""
254 # Replace whitespace with underscore first
255 clean = re.sub(r'\s+', '_', name)
256 # Remove all except Chinese, English, Numbers, Underscore
257 clean = re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9_]', '', clean)
258 # Collapse repeating underscores
259 clean = re.sub(r'_+', '_', clean)
260 return clean[:80] # Truncate
261
262
263 def derive_base_name(title: str, url: str) -> str:
264 """Derive a safe, non-empty basename from a title or URL."""
265 base = sanitize_filename(title or "")
266 if base:
267 return base
268
269 parsed = urlparse(url)
270 path = parsed.path.strip('/')
271 if path:
272 candidate = f"{parsed.netloc}_{path}"
273 else:
274 candidate = parsed.netloc or "untitled"
275 base = sanitize_filename(candidate)
276 if base:
277 return base
278
279 ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
280 return f"untitled_{ts}"
281
282
283 def build_image_filename(abs_url: str, seq: int, content_type: str | None = None) -> str:
284 """Build a safe image filename from URL metadata."""
285 parsed = urlparse(abs_url)
286 basename = os.path.basename(parsed.path).split('?')[0]
287 stem, ext = os.path.splitext(basename)
288 if not ext or len(ext) > 5 or '/' in ext:
289 ext = ""
290 if not ext and content_type:
291 ctype = content_type.split(';')[0].lower()
292 ext_map = {
293 "image/jpeg": ".jpg",
294 "image/jpg": ".jpg",
295 "image/png": ".png",
296 "image/gif": ".gif",
297 "image/webp": ".webp",
298 }
299 ext = ext_map.get(ctype, "")
300 if not ext:
301 ext = ".jpg"
302 stem = sanitize_filename(stem) if stem else f"image_{seq}"
303 return f"{stem}{ext}"
304
305
306 def download_and_rewrite_images(
307 content_element: Tag | None,
308 page_url: str,
309 image_dir: str,
310 rel_prefix: str,
311 ) -> int:
312 """Download images under the main content node and rewrite `src` paths."""
313 if content_element is None:
314 return 0
315 images = list(content_element.find_all("img"))
316 if not images:
317 return 0
318
319 os.makedirs(image_dir, exist_ok=True)
320 downloaded = {}
321 manifest_by_filename: dict[str, dict[str, object]] = {}
322 saved = 0
323
324 for idx, img in enumerate(images):
325 # Prefer lazy-load attributes — WeChat, Zhihu, and many CMSes keep the
326 # real image URL in data-src / data-original / data-lazy-src, with
327 # `src` pointing at a 1x1 placeholder or a template literal.
328 candidates = [
329 img.get("data-src"),
330 img.get("data-original"),
331 img.get("data-lazy-src"),
332 img.get("data-actualsrc"),
333 img.get("src"),
334 ]
335 src = next((s for s in candidates
336 if s and not s.startswith("data:")
337 and s.startswith(("http://", "https://", "//", "/"))), None)
338 if not src:
339 continue
340
341 # Promote the chosen URL into the element's src so downstream rewrite
342 # (which matches on src) can retarget it to the local file.
343 img["src"] = src
344
345 abs_url = urljoin(page_url, src)
346 content_type = ""
347 converted_from = ""
348 if abs_url in downloaded:
349 saved_name = downloaded[abs_url]
350 else:
351 try:
352 resp = _http_get(
353 abs_url,
354 headers={"User-Agent": CONFIG["user_agent"]},
355 timeout=CONFIG["timeout"],
356 verify=False,
357 )
358 resp.raise_for_status()
359 filename = build_image_filename(
360 abs_url, idx, resp.headers.get("Content-Type"))
361
362 # Check if image is webp and convert to png
363 stem, ext = os.path.splitext(filename)
364 content_type = resp.headers.get("Content-Type", "").lower()
365 is_webp = ext.lower() == ".webp" or "webp" in content_type
366
367 if is_webp and PILLOW_AVAILABLE:
368 # Convert webp to png (optimized)
369 try:
370 img_data = io.BytesIO(resp.content)
371 pil_image = Image.open(img_data)
372
373 # Update filename to .png
374 converted_from = filename
375 filename = f"{stem}.png"
376 local_path = os.path.join(image_dir, filename)
377
378 # Avoid accidental overwrites if filenames collide
379 counter = 1
380 while os.path.exists(local_path):
381 local_path = os.path.join(
382 image_dir, f"{stem}_{counter}.png")
383 filename = os.path.basename(local_path)
384 counter += 1
385
386 # Save as PNG directly (Pillow auto-converts, no need for explicit mode conversion)
387 pil_image.save(local_path, 'PNG', optimize=False)
388 pil_image.close()
389 print(f" [INFO] Converted webp to png: {filename}")
390 except Exception as convert_err:
391 print(
392 f" [WARN] Failed to convert webp: {convert_err}, saving as-is")
393 local_path = os.path.join(image_dir, filename)
394 counter = 1
395 stem, ext = os.path.splitext(filename)
396 while os.path.exists(local_path):
397 local_path = os.path.join(
398 image_dir, f"{stem}_{counter}{ext}")
399 filename = os.path.basename(local_path)
400 counter += 1
401 with open(local_path, "wb") as f:
402 f.write(resp.content)
403 else:
404 local_path = os.path.join(image_dir, filename)
405
406 # Avoid accidental overwrites if filenames collide
407 counter = 1
408 stem, ext = os.path.splitext(filename)
409 while os.path.exists(local_path):
410 local_path = os.path.join(
411 image_dir, f"{stem}_{counter}{ext}")
412 filename = os.path.basename(local_path)
413 counter += 1
414
415 with open(local_path, "wb") as f:
416 f.write(resp.content)
417 downloaded[abs_url] = filename
418 saved_name = filename
419 manifest_by_filename[saved_name] = {
420 "index": len(manifest_by_filename) + 1,
421 "filename": saved_name,
422 "original_filename": converted_from or saved_name,
423 "asset_kind": "bitmap",
424 "svg_renderable": True,
425 "pptx_native_supported": True,
426 "source_kind": "web_image",
427 "source_url": abs_url,
428 "source_page_url": page_url,
429 "content_type": content_type.split(";")[0] if content_type else "",
430 "occurrences": [],
431 }
432 saved += 1
433 except Exception as e:
434 print(f" [WARN] Skip image {abs_url}: {e}")
435 continue
436
437 rel_path = os.path.join(
438 rel_prefix, saved_name) if rel_prefix else saved_name
439 img["src"] = rel_path
440 manifest_item = manifest_by_filename.get(saved_name)
441 if manifest_item is not None:
442 occurrences = manifest_item.setdefault("occurrences", [])
443 if isinstance(occurrences, list):
444 occurrences.append({
445 "occurrence_index": idx + 1,
446 "source_url": abs_url,
447 "alt_text": img.get("alt", ""),
448 })
449 manifest_item["usage_count"] = len(occurrences)
450
451 if manifest_by_filename:
452 manifest_path = os.path.join(image_dir, "image_manifest.json")
453 with open(manifest_path, "w", encoding="utf-8") as f:
454 json.dump(
455 list(manifest_by_filename.values()),
456 f,
457 ensure_ascii=False,
458 indent=2,
459 )
460 f.write("\n")
461
462 return saved
463
464
465 def extract_metadata(soup: BeautifulSoup, url: str) -> dict[str, str]:
466 """Extract page metadata such as title, date, description, and author."""
467
468 # 1. Title
469 title_tag = soup.title
470 title = clean_title(title_tag.string if title_tag else "")
471
472 # 2. Meta tags
473 metas = {}
474 for meta in soup.find_all("meta"):
475 name = meta.get("name") or meta.get("property")
476 content = meta.get("content")
477 if name and content:
478 metas[name.lower()] = content.strip()
479
480 # 3. Date Extraction Strategies
481 date = (
482 metas.get("article:published_time") or
483 metas.get("og:published_time") or
484 metas.get("pubdate") or
485 metas.get("publishdate") or
486 metas.get("date")
487 )
488
489 if not date:
490 # Try matching date patterns in the text
491 text_content = soup.get_text()
492 date_patterns = [
493 r"发布[时日]间[::]\s*(\d{4}[-\/年]\d{1,2}[-\/月]\d{1,2}[日]?)",
494 r"日期[::]\s*(\d{4}[-\/年]\d{1,2}[-\/月]\d{1,2}[日]?)",
495 r"(\d{4}[-\/年]\d{1,2}[-\/月]\d{1,2}[日]?)\s*(?:发布|来源)",
496 r"时间[::]\s*(\d{4}[-\/]\d{1,2}[-\/]\d{1,2})"
497 ]
498 for pattern in date_patterns:
499 match = re.search(pattern, text_content)
500 if match:
501 date = match.group(1).replace(
502 "年", "-").replace("月", "-").replace("日", "")
503 break
504
505 if not date:
506 # Try URL matching
507 match = re.search(r"(\d{4})(\d{2})[\/_](?:t\d+_)?", url)
508 if match:
509 date = f"{match.group(1)}-{match.group(2)}"
510 else:
511 match = re.search(r"(\d{4})[-\/](\d{2})[-\/](\d{2})", url)
512 if match:
513 date = f"{match.group(1)}-{match.group(2)}-{match.group(3)}"
514
515 # 4. Description
516 description = (
517 metas.get("description") or
518 metas.get("og:description") or
519 metas.get("twitter:description") or
520 ""
521 )
522
523 # 5. Author/Source
524 author = metas.get("author") or metas.get("article:author")
525 if not author:
526 # Try common patterns
527 source_patterns = [
528 r"来源[::]\s*([^\s<]+)",
529 r"发布(?:单位|机构)[::]\s*([^\s<]+)"
530 ]
531 for pattern in source_patterns:
532 match = re.search(pattern, soup.get_text())
533 if match:
534 author = match.group(1)
535 break
536
537 return {
538 "title": title or metas.get("og:title") or "Untitled",
539 "date": date or "",
540 "description": description,
541 "author": author or "",
542 "source_url": url
543 }
544
545
546 def find_main_content(soup: BeautifulSoup) -> Tag | None:
547 """Find the most likely main content container in a page."""
548 # 1. Clean up first (remove known clutter)
549 for tag in soup(["script", "style", "nav", "header", "footer", "aside", "noscript", "iframe"]):
550 tag.decompose()
551
552 best_element = None
553 max_score = 0
554
555 # 2. Strategy A: Check specific classes/ids
556 for selector in CONFIG["content_selectors"]:
557 if "name" in selector:
558 # Tag name match (article, main)
559 elements = soup.find_all(selector["name"])
560 else:
561 # Class or ID match
562 elements = soup.find_all(attrs=selector)
563
564 for el in elements:
565 # Score based on text length and chinese character count
566 text = el.get_text(strip=True)
567 length = len(text)
568 if length < 100:
569 continue
570
571 chinese_count = len(re.findall(r'[\u4e00-\u9fa5]', text))
572 score = length + (chinese_count * 2)
573
574 if score > max_score:
575 max_score = score
576 best_element = el
577
578 # 3. Strategy B: If no specific container found, look for dense text areas with paragraphs
579 if not best_element or max_score < 200:
580 for div in soup.find_all("div"):
581 p_count = len(div.find_all("p", recursive=False))
582 # recursive=False ensures we don't just pick the top-level body by accident
583 # but sometimes content is nested deep
584 if p_count == 0:
585 # Check if it has lots of text even without p tags (br tags?)
586 pass
587
588 text = div.get_text(strip=True)
589 if len(text) > 200 and p_count >= 1:
590 # Recalculate deep score
591 chinese_count = len(re.findall(r'[\u4e00-\u9fa5]', text))
592 score = len(text) + (chinese_count * 2) + (p_count * 50)
593 if score > max_score:
594 max_score = score
595 best_element = div
596
597 # Fallback to body
598 return best_element if best_element else soup.body
599
600
601 def element_to_markdown(element: Tag | NavigableString | None) -> str:
602 """Recursively convert a BeautifulSoup node to Markdown."""
603 if element is None:
604 return ""
605
606 if isinstance(element, NavigableString):
607 text = str(element).strip()
608 return text if text else ""
609
610 tag_name = element.name.lower()
611
612 # Skip hidden/unwanted tags
613 if tag_name in ['script', 'style', 'meta', 'link', 'input', 'button', 'select']:
614 return ""
615
616 content = ""
617 for child in element.children:
618 content += element_to_markdown(child)
619 # Add spacing logic here if needed, but usually block elements handle it
620
621 # Block handlers
622 if tag_name in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']:
623 level = int(tag_name[1])
624 return f"\n{'#' * level} {content}\n\n"
625
626 elif tag_name == 'p':
627 # Clean up internal whitespace
628 content = re.sub(r'\s+', ' ', content).strip()
629 return f"\n{content}\n\n" if content else ""
630
631 elif tag_name == 'br':
632 return " \n"
633
634 elif tag_name == 'hr':
635 return "\n---\n"
636
637 elif tag_name == 'div':
638 return f"\n{content}\n"
639
640 elif tag_name == 'blockquote':
641 lines = content.strip().split('\n')
642 quoted = '\n'.join([f"> {line}" for line in lines if line.strip()])
643 return f"\n{quoted}\n\n"
644
645 elif tag_name in ['ul', 'ol']:
646 # This is tricky without "state" (knowing we are in a list)
647 # For simplicity in this recursive version, we rely on LI handling
648 return f"\n{content}\n"
649
650 elif tag_name == 'li':
651 # Simple list handling
652 clean_content = content.strip()
653 return f"- {clean_content}\n"
654
655 elif tag_name == 'pre':
656 return f"\n```\n{content}\n```\n\n"
657
658 elif tag_name == 'code':
659 # If parent is pre, handle in pre. If inline:
660 parent = element.parent
661 if parent and parent.name == 'pre':
662 return content
663 return f"`{content}`"
664
665 elif tag_name == 'a':
666 href = element.get('href', '')
667 if href and not href.startswith('javascript:'):
668 return f"[{content}]({href})"
669 return content
670
671 elif tag_name == 'img':
672 src = element.get('src', '')
673 alt = element.get('alt', '')
674 if src:
675 return f"![{alt}]({src})"
676 return ""
677
678 elif tag_name == 'table':
679 # Basic table text extraction, full markdown table support is complex
680 # Leaving as raw text or simplistic conversion for now
681 # Ideally, we'd parse TRs and TDs
682 return f"\n{content}\n"
683
684 elif tag_name == 'tr':
685 return f"{content}|\n"
686
687 elif tag_name in ['td', 'th']:
688 return f"| {content.strip()} "
689
690 # Style formatting
691 elif tag_name in ['strong', 'b']:
692 return f"**{content}**"
693 elif tag_name in ['em', 'i']:
694 return f"*{content}*"
695 elif tag_name in ['del', 's', 'strike']:
696 return f"~~{content}~~"
697
698 # Default for span, section, etc.
699 return f"{content} "
700
701
702 def simple_html_to_markdown_traversal(soup: Tag | BeautifulSoup | None) -> str:
703 """Convert HTML content to Markdown using BeautifulSoup traversal."""
704 lines = []
705
706 def traverse(node: Tag | NavigableString) -> str:
707 if isinstance(node, NavigableString):
708 text = str(node)
709 # Normalize whitespace but keep single spaces
710 text = re.sub(r'\s+', ' ', text)
711 if text.strip():
712 return text
713 return ""
714
715 if node.name in ['script', 'style', 'comment', 'meta', 'link']:
716 return ""
717
718 # Handle Block Elements
719 is_block = node.name in ['p', 'div', 'h1', 'h2', 'h3', 'h4',
720 'h5', 'h6', 'li', 'blockquote', 'pre', 'hr', 'table', 'tr']
721
722 # Pre-processing
723 prefix = ""
724 suffix = ""
725
726 if node.name in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']:
727 level = int(node.name[1])
728 prefix = f"\n\n{'#' * level} "
729 suffix = "\n\n"
730 elif node.name == 'p':
731 prefix = "\n\n"
732 suffix = "\n\n"
733 elif node.name == 'li':
734 prefix = "\n- "
735 elif node.name == 'blockquote':
736 prefix = "\n> "
737 suffix = "\n"
738 elif node.name == 'hr':
739 return "\n\n---\n\n"
740 elif node.name == 'br':
741 return " \n"
742 elif node.name == 'pre':
743 # Extract raw text from pre to preserve formatting
744 return f"\n\n```\n{node.get_text()}\n```\n\n"
745
746 # Inline formatting
747 if node.name in ['strong', 'b']:
748 prefix, suffix = "**", "**"
749 elif node.name in ['em', 'i']:
750 prefix, suffix = "*", "*"
751 elif node.name == 'code' and node.parent.name != 'pre':
752 prefix, suffix = "`", "`"
753 elif node.name == 'a':
754 href = node.get('href')
755 if href and not href.startswith('javascript:'):
756 prefix = "["
757 suffix = f"]({href})"
758 else:
759 prefix, suffix = "", ""
760 elif node.name == 'img':
761 src = node.get('src')
762 alt = node.get('alt', '')
763 if src:
764 return f"![{alt}]({src})"
765 return ""
766
767 # Recurse
768 inner_text = ""
769 for child in node.children:
770 res = traverse(child)
771 if res:
772 inner_text += res
773
774 # Post-processing for tables (simplified)
775 if node.name == 'tr':
776 # count tds
777 cells = [c.get_text(strip=True) for c in node.find_all(
778 ['td', 'th'], recursive=False)]
779 return f"| {' | '.join(cells)} |\n"
780 if node.name == 'table':
781 # Try to add a separator line after first row if it looks like a header
782 rows = inner_text.strip().split('\n')
783 if rows:
784 cols_count = rows[0].count('|') - 1
785 if cols_count > 0:
786 # rough approx
787 sep = "| " + " | ".join(["---"] * int(cols_count/2)) + " |"
788 # Actually, the traverse of TR returns newline terminated strings.
789 # Let's just return what we gathered.
790 pass
791 return f"\n\n{inner_text}\n\n"
792
793 return f"{prefix}{inner_text}{suffix}"
794
795 # Actually, a simpler approach for this script is "just get string" but with markers?
796 # Let's use a simplified approach: use get_text but with 'separator' logic?
797 # text = soup.get_text(separator='\n\n')
798 # But that loses links and boldness.
799
800 # Recommendation: Let's stick to the traversal above which constructs a string.
801 md = traverse(soup)
802
803 # Cleanup Markdown
804 if md:
805 # Remove excessive newlines
806 md = re.sub(r'\n{3,}', '\n\n', md)
807 md = md.strip()
808 return md or ""
809
810
811 def process_url(url: str, output_file: str | None = None) -> tuple[bool, str, str | None, str | None]:
812 """Fetch, convert, and save one web page as Markdown.
813
814 Returns (success, url, error, output_path). output_path is the actual saved
815 Markdown path (derived from the article title when no output_file is given),
816 so a caller can locate a title-named file it did not choose upfront.
817 """
818 print(f"\n[Fetching] {url}")
819 try:
820 html = fetch_url(url)
821 soup = BeautifulSoup(html, 'html.parser')
822
823 # Extract Metadata
824 metadata = extract_metadata(soup, url)
825 print(f" [OK] Title: {metadata['title']}")
826 if metadata['date']:
827 print(f" [OK] Date: {metadata['date']}")
828
829 # Determine output path and image directory upfront
830 if output_file:
831 output_path = output_file
832 else:
833 base_name = derive_base_name(metadata['title'], url)
834 filename = f"{base_name}.md"
835 output_path = os.path.join(CONFIG["output_dir"], filename)
836
837 output_dirname = os.path.dirname(output_path) or "."
838 os.makedirs(output_dirname, exist_ok=True)
839 base_name = os.path.splitext(os.path.basename(output_path))[0]
840 image_dir = os.path.join(output_dirname, f"{base_name}_files")
841 rel_image_prefix = os.path.relpath(image_dir, output_dirname)
842
843 # Extract Content
844 content_div = find_main_content(soup)
845
846 # Download images and rewrite src before markdown conversion
847 image_count = download_and_rewrite_images(
848 content_div, url, image_dir, rel_image_prefix)
849 if image_count:
850 print(f" [OK] Images: {image_count} saved to {image_dir}")
851
852 # Convert to MD
853 # Note: We pass the element to our traversal function
854 markdown_text = simple_html_to_markdown_traversal(content_div)
855 print(f" [OK] Content: {len(markdown_text)} chars")
856
857 # Construct content
858 final_output = []
859 final_output.append("<!--")
860 final_output.append(f" Source: {url}")
861 final_output.append(
862 f" Crawled: {datetime.datetime.now().isoformat()}")
863 if metadata['date']:
864 final_output.append(f" Published: {metadata['date']}")
865 if metadata['author']:
866 final_output.append(f" Author: {metadata['author']}")
867 final_output.append("-->\n")
868
869 if metadata['title']:
870 final_output.append(f"# {metadata['title']}\n")
871
872 if metadata['description']:
873 final_output.append(f"> {metadata['description']}\n")
874
875 final_output.append(markdown_text)
876
877 full_content = "\n".join(final_output)
878
879 with open(output_path, 'w', encoding='utf-8') as f:
880 f.write(full_content)
881 profile_path = write_conversion_profile_best_effort(
882 input_path=url,
883 markdown_path=output_path,
884 converter="web_to_md.py",
885 conversion_type="web",
886 asset_dir=image_dir,
887 )
888
889 print(f" [OK] Saved: {output_path}")
890 if profile_path:
891 print(f" [OK] Conversion profile: {profile_path}")
892 return True, url, None, output_path
893
894 except Exception as e:
895 print(f" [ERROR] {str(e)}")
896 return False, url, str(e), None
897
898
899 def _write_emit_result(result_file: str, url: str, markdown_path: str) -> None:
900 """Write the actual saved path as JSON so a caller can locate the output."""
901 md = Path(markdown_path).resolve()
902 profile = profile_path_for(md)
903 payload = {
904 "input": url,
905 "markdown": str(md),
906 "conversion_profile": str(profile) if profile.is_file() else "",
907 }
908 try:
909 Path(result_file).write_text(
910 json.dumps(payload, ensure_ascii=False), encoding="utf-8")
911 except OSError as exc:
912 print(f" [WARN] Could not write --emit-result: {exc}")
913
914
915 def main(argv: list[str] | None = None) -> int:
916 """Run the CLI entry point."""
917 parser = argparse.ArgumentParser(
918 description="Web to Markdown Converter (Python)")
919 parser.add_argument("urls", nargs="*", help="URLs to process")
920 parser.add_argument(
921 "-f", "--file", help="File containing URLs (one per line)")
922 parser.add_argument("-o", "--output", help="Output file (single URL only)")
923 parser.add_argument("-d", "--dir", help="Output directory")
924 parser.add_argument(
925 "--emit-result",
926 help="On success, write the saved output path as JSON to this file "
927 "(single-URL dispatcher use, so a title-named file can be located)")
928
929 args = parser.parse_args(argv)
930
931 if args.dir:
932 CONFIG["output_dir"] = args.dir
933
934 targets = []
935 if args.urls:
936 targets.extend(args.urls)
937
938 if args.file:
939 if os.path.exists(args.file):
940 with open(args.file, 'r', encoding='utf-8') as f:
941 lines = [l.strip() for l in f if l.strip()
942 and not l.strip().startswith("#")]
943 targets.extend(lines)
944 else:
945 print(f"Error: File {args.file} not found", file=sys.stderr)
946 return 1
947
948 if not targets:
949 parser.print_usage(sys.stderr)
950 print(
951 "web_to_md.py: error: at least one URL or --file is required",
952 file=sys.stderr,
953 )
954 return 2
955
956 results = []
957 for i, url in enumerate(targets):
958 # Allow specific output file only if 1 URL
959 out = args.output if (len(targets) == 1 and args.output) else None
960 success, url, err, out_path = process_url(url, out)
961 results.append((success, url, err))
962 if args.emit_result and success and out_path:
963 _write_emit_result(args.emit_result, url, out_path)
964
965 # Summary
966 success_count = sum(1 for r in results if r[0])
967 fail_count = len(results) - success_count
968
969 print("\n" + "="*50)
970 print(
971 f"[Done] Success: {success_count}/{len(results)}, Failed: {fail_count}")
972
973 if fail_count > 0:
974 print("\n[Failed URLs]:")
975 for r in results:
976 if not r[0]:
977 print(f" - {r[1]}: {r[2]}")
978 return 1
979 return 0
980
981
982 if __name__ == "__main__":
983 # Disable warnings for verify=False if needed, though often useful to see
984 import urllib3
985 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
986 raise SystemExit(main())
987
987 lines PYTHON