fetch.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. #!/usr/bin/env python3
  2. """
  3. Universal web content extractor (Scrapling + html2text).
  4. Returns clean Markdown with headings, links, images, lists, and code blocks.
  5. Usage:
  6. python3 fetch.py <url> [max_chars] [--stealth]
  7. Modes:
  8. (default) Fast HTTP fetch via Fetcher — works for most sites (~1-3s)
  9. --stealth Headless browser via StealthyFetcher — for JS-rendered or
  10. anti-scraping sites like WeChat, Zhihu, Juejin (~5-15s)
  11. Examples:
  12. python3 fetch.py https://sspai.com/post/73145
  13. python3 fetch.py https://mp.weixin.qq.com/s/xxx 30000 --stealth
  14. python3 fetch.py https://zhuanlan.zhihu.com/p/12345 --stealth
  15. """
  16. import sys
  17. import re
  18. import json
  19. import logging
  20. def check_dependencies():
  21. """Check if required packages are installed and provide install instructions."""
  22. missing = []
  23. try:
  24. import scrapling # noqa: F401
  25. except ImportError:
  26. missing.append("scrapling")
  27. try:
  28. import html2text # noqa: F401
  29. except ImportError:
  30. missing.append("html2text")
  31. if missing:
  32. print(
  33. f"Error: missing dependencies: {', '.join(missing)}\n"
  34. f"Install with:\n"
  35. f" pip install {' '.join(missing)}",
  36. file=sys.stderr,
  37. )
  38. sys.exit(1)
  39. def fix_lazy_images(html_raw):
  40. """
  41. Promote data-src to src for lazy-loaded images (WeChat, Zhihu, etc.).
  42. Many Chinese platforms use data-src for the real image URL while src
  43. holds a tiny placeholder. html2text only reads src, so we swap them.
  44. """
  45. return re.sub(
  46. r'<img([^>]*?)\sdata-src="([^"]+)"([^>]*?)>',
  47. lambda m: f'<img{m.group(1)} src="{m.group(2)}"{m.group(3)}>',
  48. html_raw,
  49. )
  50. # CSS selectors in priority order — the first match with enough content wins.
  51. # Covers most blog/article platforms without needing per-site customization.
  52. CONTENT_SELECTORS = [
  53. "article",
  54. "main",
  55. ".post-content",
  56. ".entry-content",
  57. ".article-content",
  58. ".article-body",
  59. ".article-detail", # 36kr
  60. ".article-holder", # InfoQ
  61. ".post_body", # 163.com (NetEase)
  62. ".markdown-body", # GitHub
  63. ".Post-RichText", # Zhihu
  64. "#article_content", # CSDN
  65. ".article-area", # Juejin
  66. ".ssa-article", # Toutiao
  67. '[role="article"]',
  68. '[itemprop="articleBody"]',
  69. ]
  70. # WeChat has a unique DOM structure — try these first for mp.weixin.qq.com
  71. WECHAT_SELECTORS = [
  72. "div#js_content",
  73. "div.rich_media_content",
  74. ]
  75. # Minimum characters for a selector match to be considered "real content"
  76. MIN_CONTENT_LENGTH = 200
  77. def html_to_markdown(html_raw, max_chars=30000):
  78. """Convert raw HTML to clean Markdown."""
  79. import html2text
  80. html_raw = fix_lazy_images(html_raw)
  81. h = html2text.HTML2Text()
  82. h.ignore_links = False
  83. h.ignore_images = False
  84. h.body_width = 0 # No line wrapping
  85. h.skip_internal_links = True
  86. h.ignore_emphasis = False
  87. md = h.handle(html_raw)
  88. md = re.sub(r"\n{3,}", "\n\n", md).strip()
  89. return md[:max_chars]
  90. def extract_content(page, url, max_chars=30000):
  91. """
  92. Try content selectors to find the article body.
  93. Returns (markdown_text, matched_selector).
  94. """
  95. is_wechat = "mp.weixin.qq.com" in url
  96. selectors = (WECHAT_SELECTORS + CONTENT_SELECTORS) if is_wechat else CONTENT_SELECTORS
  97. for selector in selectors:
  98. els = page.css(selector)
  99. if els:
  100. md = html_to_markdown(els[0].html_content, max_chars)
  101. if len(md) >= MIN_CONTENT_LENGTH:
  102. return md, selector
  103. # Fallback: convert the entire page
  104. md = html_to_markdown(page.html_content, max_chars)
  105. return md, "body(fallback)"
  106. def _suppress_scrapling_logs():
  107. """Scrapling's logger is noisy (deprecation warnings, fetch info). Silence it."""
  108. logging.getLogger("scrapling").setLevel(logging.CRITICAL)
  109. def fetch_fast(url, max_chars=30000, timeout=15):
  110. """
  111. Fast HTTP fetch — no JavaScript execution.
  112. Works for most blogs and static sites.
  113. """
  114. from scrapling.fetchers import Fetcher
  115. _suppress_scrapling_logs()
  116. page = Fetcher().get(url, timeout=timeout, stealthy_headers=True)
  117. return extract_content(page, url, max_chars)
  118. def fetch_stealth(url, max_chars=30000, timeout=30000):
  119. """
  120. Headless browser fetch — executes JavaScript, bypasses anti-scraping.
  121. Required for: WeChat articles, Zhihu, Juejin, and other JS-rendered pages.
  122. Slower (~5-15s) but more reliable for protected content.
  123. """
  124. from scrapling.fetchers import StealthyFetcher
  125. _suppress_scrapling_logs()
  126. page = StealthyFetcher().fetch(
  127. url,
  128. headless=True,
  129. network_idle=True,
  130. timeout=timeout,
  131. )
  132. return extract_content(page, url, max_chars)
  133. def fetch(url, max_chars=30000, stealth=False):
  134. """
  135. Main entry point. Fetches URL and returns (markdown, selector, mode).
  136. If stealth=False, tries fast mode first and falls back to stealth
  137. when the result is too short (likely a JS-rendered page).
  138. """
  139. if stealth:
  140. md, selector = fetch_stealth(url, max_chars)
  141. return md, selector, "stealth"
  142. # Try fast mode first
  143. md, selector = fetch_fast(url, max_chars)
  144. # If fast mode got barely any content, the page likely needs JS rendering
  145. if len(md) < MIN_CONTENT_LENGTH:
  146. try:
  147. md_stealth, sel_stealth = fetch_stealth(url, max_chars)
  148. if len(md_stealth) > len(md):
  149. return md_stealth, sel_stealth, "stealth(auto-fallback)"
  150. except Exception:
  151. pass # Stick with fast mode result
  152. return md, selector, "fast"
  153. def main():
  154. if len(sys.argv) < 2:
  155. print(
  156. "Usage: python3 fetch.py <url> [max_chars] [--stealth]\n"
  157. "\n"
  158. "Options:\n"
  159. " max_chars Maximum output characters (default: 30000)\n"
  160. " --stealth Use headless browser for JS-rendered pages\n"
  161. " --json Output as JSON with metadata\n",
  162. file=sys.stderr,
  163. )
  164. sys.exit(1)
  165. url = sys.argv[1]
  166. args = sys.argv[2:]
  167. stealth = "--stealth" in args
  168. json_output = "--json" in args
  169. args = [a for a in args if not a.startswith("--")]
  170. max_chars = int(args[0]) if args else 30000
  171. try:
  172. md, selector, mode = fetch(url, max_chars, stealth=stealth)
  173. if json_output:
  174. result = {
  175. "url": url,
  176. "mode": mode,
  177. "selector": selector,
  178. "content_length": len(md),
  179. "content": md,
  180. }
  181. print(json.dumps(result, ensure_ascii=False, indent=2))
  182. else:
  183. print(md)
  184. except Exception as e:
  185. error_msg = f"Error fetching {url}: {type(e).__name__}: {e}"
  186. if json_output:
  187. print(json.dumps({"url": url, "error": error_msg}, ensure_ascii=False))
  188. else:
  189. print(error_msg, file=sys.stderr)
  190. sys.exit(1)
  191. if __name__ == "__main__":
  192. check_dependencies()
  193. main()