1608 lines
64 KiB
Python
1608 lines
64 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
浏览器自动化爬虫模块 v1.0 (browser_scraper.py)
|
||
|
||
当常规 HTTP 爬虫被反爬拦截时,自动启动真实浏览器模拟人工操作:
|
||
- 可见窗口模式(非 headless),模拟真人浏览
|
||
- 隐身上下文(Stealth),绕过反爬检测
|
||
- Cookie / 弹窗自动处理
|
||
- 等待特定元素出现后提取数据
|
||
- 自动滚动加载懒加载内容
|
||
- 截图保存用于人工验证
|
||
- 登录流程支持(可选)
|
||
- 与 http_utils 降级链无缝衔接
|
||
|
||
依赖:pip install playwright && playwright install chromium
|
||
缺失时所有方法返回 None 并提示安装命令,不影响其他模块。
|
||
|
||
用法:
|
||
from browser_scraper import BrowserScraper
|
||
bs = BrowserScraper(headless=False) # 可见模式,便于观察
|
||
html = bs.fetch("https://www.example.com")
|
||
# 或提取特定数据
|
||
data = bs.extract_text("https://www.example.com", selector=".content")
|
||
# 截图留存
|
||
bs.screenshot("https://www.example.com", "output/shot.png")
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import random
|
||
import re
|
||
import sys
|
||
import threading
|
||
import time
|
||
import urllib.request
|
||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||
from pathlib import Path
|
||
from typing import Dict, List, Optional, Any, Callable, Tuple
|
||
from urllib.parse import urljoin, urlparse
|
||
|
||
try:
|
||
from playwright.sync_api import sync_playwright, Browser, Page, BrowserContext
|
||
from playwright.sync_api import TimeoutError as PlaywrightTimeout
|
||
PLAYWRIGHT_AVAILABLE = True
|
||
except ImportError:
|
||
PLAYWRIGHT_AVAILABLE = False
|
||
Browser = None # type: ignore
|
||
Page = None # type: ignore
|
||
|
||
SKILL_DIR = Path(__file__).resolve().parent.parent
|
||
DATA_DIR = SKILL_DIR / "data"
|
||
BROWSER_CACHE_DIR = DATA_DIR / "browser_cache"
|
||
BROWSER_CACHE_DIR.mkdir(exist_ok=True, parents=True)
|
||
SCREENSHOT_DIR = DATA_DIR / "screenshots"
|
||
SCREENSHOT_DIR.mkdir(exist_ok=True, parents=True)
|
||
# v6.0.0: 浏览器会话状态持久化(Cookie / localStorage 跨进程复用)
|
||
BROWSER_STATE_DIR = DATA_DIR / "browser_state"
|
||
BROWSER_STATE_DIR.mkdir(exist_ok=True, parents=True)
|
||
BROWSER_STATE_FILE = BROWSER_STATE_DIR / "state.json"
|
||
|
||
INSTALL_HINT = (
|
||
"浏览器自动化模块需要 Playwright。请安装:\n"
|
||
" pip install playwright\n"
|
||
" playwright install chromium\n"
|
||
"Mac/Linux 用户可能还需要: playwright install-deps chromium"
|
||
)
|
||
|
||
# ── 反检测隐身脚本(v4.7 升级:12+ 项指纹)─────────────────────────────
|
||
STEALTH_JS = r"""
|
||
(function() {
|
||
'use strict';
|
||
// ===== 1. 隐藏 webdriver 标记 =====
|
||
try { Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); } catch (e) {}
|
||
|
||
// ===== 2. 伪造 plugins(含 PluginArray 原型,返回真实结构)=====
|
||
try {
|
||
const makePlugins = () => {
|
||
const ps = [
|
||
{ name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
|
||
{ name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '' },
|
||
{ name: 'Native Client', filename: 'internal-nacl-plugin', description: '' },
|
||
];
|
||
ps.item = (i) => ps[i] || null;
|
||
ps.namedItem = (n) => ps.find(p => p.name === n) || null;
|
||
ps.refresh = () => {};
|
||
return ps;
|
||
};
|
||
const ps = makePlugins();
|
||
try { Object.setPrototypeOf(ps, PluginArray.prototype); } catch (e) {}
|
||
Object.defineProperty(navigator, 'plugins', { get: () => ps });
|
||
} catch (e) {}
|
||
|
||
// ===== 3. 伪造 languages =====
|
||
try { Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh', 'en-US', 'en'] }); } catch (e) {}
|
||
|
||
// ===== 4. 伪造 platform =====
|
||
try { Object.defineProperty(navigator, 'platform', { get: () => 'Win32' }); } catch (e) {}
|
||
|
||
// ===== 5. 伪造 hardwareConcurrency / deviceMemory =====
|
||
try { Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 }); } catch (e) {}
|
||
try { Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 }); } catch (e) {}
|
||
|
||
// ===== 6. 伪造 permissions API(notifications 用真实状态)=====
|
||
try {
|
||
const origQuery = navigator.permissions.query;
|
||
navigator.permissions.query = (parameters) => (
|
||
parameters && parameters.name === 'notifications'
|
||
? Promise.resolve({ state: Notification.permission })
|
||
: origQuery(parameters)
|
||
);
|
||
} catch (e) {}
|
||
|
||
// ===== 7. 伪造 window.chrome / chrome.runtime(headless 检测关键)=====
|
||
try {
|
||
window.chrome = { runtime: {}, loadTimes: function(){}, csi: function(){}, app: {} };
|
||
} catch (e) {}
|
||
|
||
// ===== 8. WebGL vendor/renderer 伪造 =====
|
||
try {
|
||
const getParameter = WebGLRenderingContext.prototype.getParameter;
|
||
WebGLRenderingContext.prototype.getParameter = function(parameter) {
|
||
if (parameter === 37445) return 'Google Inc. (Intel)';
|
||
if (parameter === 37446) return 'ANGLE (Intel, Intel(R) UHD Graphics 630 Direct3D11 vs_5_0 ps_5_0, D3D11)';
|
||
return getParameter.call(this, parameter);
|
||
};
|
||
} catch (e) {}
|
||
|
||
// ===== 9. outerWidth / outerHeight 不为 0(headless 常为 0)=====
|
||
try { Object.defineProperty(window, 'outerWidth', { get: () => window.innerWidth }); } catch (e) {}
|
||
try { Object.defineProperty(window, 'outerHeight', { get: () => window.innerHeight + 100 }); } catch (e) {}
|
||
|
||
// ===== 10. connection.rtt 伪造 =====
|
||
try {
|
||
if (navigator.connection) Object.defineProperty(navigator.connection, 'rtt', { get: () => 100 });
|
||
} catch (e) {}
|
||
|
||
// ===== 11. 移除 PhantomJS / headless 痕迹 =====
|
||
delete window.callPhantom;
|
||
delete window._phantom;
|
||
delete window.__phantomas;
|
||
})();
|
||
"""
|
||
|
||
# ── 广告/跟踪域名黑名单(v5.0.0 网络拦截去广告用)─────────────────────
|
||
AD_BLOCK_DOMAINS = {
|
||
"doubleclick.net", "googleadservices.com", "googlesyndication.com",
|
||
"google-analytics.com", "googletagmanager.com", "googletagservices.com",
|
||
"hm.baidu.com", "cnzz.com", "adsense", "adservice",
|
||
"scorecardresearch.com", "criteo.com", "taboola.com", "outbrain.com",
|
||
"adservice.google", "amazon-adsystem.com",
|
||
}
|
||
|
||
|
||
def _should_block_url(url: str, resource_type: str) -> bool:
|
||
"""v5.0.0: 判定资源是否应拦截(广告/跟踪域的大资源,正文域名放行)。"""
|
||
if resource_type not in ("image", "media", "font"):
|
||
return False
|
||
low = url.lower()
|
||
return any(dom in low for dom in AD_BLOCK_DOMAINS)
|
||
|
||
# ── 常见 Cookie 弹窗选择器 ─────────────────────────────────────
|
||
COOKIE_CONSENT_SELECTORS = [
|
||
# 中文
|
||
"button:has-text('同意')", "button:has-text('接受')", "button:has-text('确定')",
|
||
"button:has-text('知道了')", "button:has-text('允许')",
|
||
"a:has-text('同意')", "span:has-text('同意')",
|
||
".cookie-accept", ".cookie-consent button",
|
||
# 英文
|
||
"button:has-text('Accept')", "button:has-text('Accept All')",
|
||
"button:has-text('Agree')", "button:has-text('OK')",
|
||
"button:has-text('Got it')", "button:has-text('I agree')",
|
||
"button:has-text('Allow All')", "button:has-text('Consent')",
|
||
# 通用
|
||
"[aria-label='Accept cookies']", "[data-testid='cookie-accept']",
|
||
"#accept-cookies", ".accept-cookies", "[id*='cookie'] button",
|
||
".cc-btn", ".cc_btn", ".cookie-btn",
|
||
]
|
||
|
||
|
||
class BrowserScraper:
|
||
"""浏览器自动化爬虫 — 类人操作模式
|
||
|
||
headless=False: 可见窗口,随机延迟,模拟真人体浏览行为
|
||
headless=True: 后台运行,速度更快但部分网站会检测到
|
||
stealth=True: 注入反检测脚本,隐藏 webdriver 标记
|
||
"""
|
||
|
||
def __init__(self, headless: bool = False, stealth: bool = True,
|
||
timeout_ms: int = 30000, viewport: dict = None,
|
||
proxy: Optional[Any] = None, ad_block: bool = True,
|
||
channel: str = "chromium"):
|
||
if not PLAYWRIGHT_AVAILABLE:
|
||
raise ImportError(INSTALL_HINT)
|
||
self.headless = headless
|
||
self.stealth = stealth
|
||
self.timeout_ms = timeout_ms
|
||
self.viewport = viewport or {"width": 1920, "height": 1080}
|
||
# v5.0.0: proxy 支持("http://host:port" / "socks5://host:port" / dict)
|
||
self.proxy = proxy
|
||
# v5.0.0: 网络拦截去广告(拦截广告/跟踪域大资源请求)
|
||
self.ad_block = ad_block
|
||
self.channel = channel or "chromium"
|
||
self._playwright = None
|
||
self._browser = None
|
||
self._context = None
|
||
self._last_page = None
|
||
|
||
# ── v6.0.0: 会话保持(storage_state 持久化)──────────────────────
|
||
|
||
def _load_storage_state(self) -> Optional[dict]:
|
||
"""加载持久化会话状态(Cookie / localStorage)。"""
|
||
if BROWSER_STATE_FILE.exists():
|
||
try:
|
||
return json.loads(BROWSER_STATE_FILE.read_text(encoding="utf-8"))
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
def _save_storage_state(self):
|
||
"""保存当前上下文会话状态到磁盘,供下次进程复用。"""
|
||
try:
|
||
if self._context is not None:
|
||
state = self._context.storage_state()
|
||
BROWSER_STATE_FILE.write_text(
|
||
json.dumps(state, ensure_ascii=False), encoding="utf-8")
|
||
except Exception:
|
||
pass
|
||
|
||
# ── 生命周期 ─────────────────────────────────────────────
|
||
|
||
@staticmethod
|
||
def _resolve_proxy(proxy: Any) -> dict:
|
||
"""归一化 proxy 配置为 Playwright 需要的 {server, username?, password?}。"""
|
||
if isinstance(proxy, dict):
|
||
return proxy
|
||
return {"server": proxy}
|
||
|
||
def _install_ad_block(self, context):
|
||
"""v5.0.0: 网络拦截去广告 — 按 resource_type + 域名黑名单拦截,正文域名放行。"""
|
||
|
||
def _route(route):
|
||
try:
|
||
if _should_block_url(route.request.url, route.request.resource_type):
|
||
route.abort()
|
||
else:
|
||
route.continue_()
|
||
except Exception:
|
||
try:
|
||
route.abort()
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
context.route("**/*", _route)
|
||
except Exception:
|
||
pass
|
||
|
||
def start(self):
|
||
"""启动浏览器(单例复用)。"""
|
||
if self._browser is not None:
|
||
return
|
||
self._playwright = sync_playwright().start()
|
||
launch_kwargs = dict(
|
||
headless=self.headless,
|
||
args=[
|
||
"--disable-blink-features=AutomationControlled",
|
||
"--no-sandbox",
|
||
"--disable-dev-shm-usage",
|
||
],
|
||
)
|
||
if self.proxy:
|
||
launch_kwargs["proxy"] = self._resolve_proxy(self.proxy)
|
||
self._browser = self._playwright.chromium.launch(**launch_kwargs)
|
||
context_kwargs = dict(
|
||
viewport=self.viewport,
|
||
user_agent=(
|
||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||
"Chrome/131.0.0.0 Safari/537.36"
|
||
),
|
||
locale="zh-CN",
|
||
timezone_id="Asia/Shanghai",
|
||
)
|
||
# v6.0.0: 复用上次会话状态(Cookie / localStorage)
|
||
saved_state = self._load_storage_state()
|
||
if saved_state:
|
||
context_kwargs["storage_state"] = saved_state
|
||
self._context = self._browser.new_context(**context_kwargs)
|
||
if self.stealth:
|
||
self._context.add_init_script(STEALTH_JS)
|
||
if self.ad_block:
|
||
self._install_ad_block(self._context)
|
||
|
||
def stop(self):
|
||
"""关闭浏览器释放资源(先持久化会话状态)。"""
|
||
self._save_storage_state()
|
||
if self._context:
|
||
self._context.close()
|
||
self._context = None
|
||
if self._browser:
|
||
self._browser.close()
|
||
self._browser = None
|
||
if self._playwright:
|
||
try:
|
||
self._playwright.stop()
|
||
except Exception:
|
||
pass
|
||
self._playwright = None
|
||
|
||
def __enter__(self):
|
||
self.start()
|
||
return self
|
||
|
||
def __exit__(self, *args):
|
||
self.stop()
|
||
|
||
def new_page(self) -> Page:
|
||
"""创建新页面(带隐身脚本)。"""
|
||
self.start()
|
||
page = self._context.new_page()
|
||
page.set_default_timeout(self.timeout_ms)
|
||
self._last_page = page
|
||
return page
|
||
|
||
# ── v5.0.0 增强:并发标签页 + 统一重试 ─────────────────────
|
||
|
||
def _with_retry(self, fn: Callable, retries: int = 2,
|
||
name: str = "") -> Any:
|
||
"""统一重试包装(随机退避)。供各抓取方法复用。"""
|
||
for attempt in range(retries + 1):
|
||
try:
|
||
return fn()
|
||
except Exception as e:
|
||
if attempt < retries:
|
||
time.sleep(random.uniform(0.5, 2.0))
|
||
else:
|
||
print(f"[BrowserScraper] {name or getattr(fn, '__name__', 'op')} 失败: {e}",
|
||
file=sys.stderr)
|
||
return None
|
||
|
||
def extract_many(self, urls: List[str], max_workers: int = 4) -> List[Tuple[str, Optional[str]]]:
|
||
"""v5.0.0: 并发标签页抓取多个 URL(复用同一 context),按输入序返回。
|
||
|
||
Returns:
|
||
[(url, html_or_None), ...] — 单个失败不阻塞整体。
|
||
"""
|
||
if not urls:
|
||
return []
|
||
if not PLAYWRIGHT_AVAILABLE:
|
||
return [(u, None) for u in urls]
|
||
self.start()
|
||
max_workers = min(max(1, max_workers), 8)
|
||
results: List[Tuple[str, Optional[str]]] = []
|
||
with ThreadPoolExecutor(max_workers=max_workers) as ex:
|
||
futures = {ex.submit(self._fetch_one_new_page, u): u for u in urls}
|
||
for fut in as_completed(futures):
|
||
u = futures[fut]
|
||
try:
|
||
results.append((u, fut.result()))
|
||
except Exception:
|
||
results.append((u, None))
|
||
order = {u: i for i, u in enumerate(urls)}
|
||
results.sort(key=lambda x: order[x[0]])
|
||
return results
|
||
|
||
def _fetch_one_new_page(self, url: str) -> Optional[str]:
|
||
"""并发标签页单 URL 抓取(独立 page,finally 关闭)。"""
|
||
page = None
|
||
try:
|
||
page = self.new_page()
|
||
page.goto(url, wait_until="domcontentloaded", timeout=self.timeout_ms)
|
||
self._human_delay(0.2, 0.6)
|
||
return page.content()
|
||
except Exception:
|
||
return None
|
||
finally:
|
||
if page is not None:
|
||
try:
|
||
page.close()
|
||
except Exception:
|
||
pass
|
||
|
||
# ── 核心抓取 ─────────────────────────────────────────────
|
||
|
||
def fetch(self, url: str, wait_until: str = "domcontentloaded",
|
||
wait_selector: str = None, scroll: bool = False,
|
||
dismiss_cookies: bool = True, retries: int = 2,
|
||
captcha_handoff: bool = True,
|
||
include_iframes: bool = False) -> Optional[str]:
|
||
"""打开网页并返回完整 HTML。
|
||
|
||
wait_until: domcontentloaded / load / networkidle
|
||
wait_selector: 等待该 CSS 选择器出现后再提取
|
||
scroll: 是否滚动页面加载懒加载内容
|
||
dismiss_cookies: 是否自动点击 Cookie 弹窗
|
||
retries: 超时/失败后重试次数(默认 2,随机退避)
|
||
captcha_handoff: v6.0.0 检测到验证码时截图保存并返回 None(人工接管)
|
||
include_iframes: v6.0.0 是否合并内嵌 iframe 的内容
|
||
"""
|
||
if not PLAYWRIGHT_AVAILABLE:
|
||
print(INSTALL_HINT, file=sys.stderr)
|
||
return None
|
||
for attempt in range(retries + 1):
|
||
page = None
|
||
try:
|
||
page = self.new_page()
|
||
page.goto(url, wait_until=wait_until, timeout=self.timeout_ms)
|
||
self._human_delay(0.5, 1.5)
|
||
|
||
if dismiss_cookies:
|
||
self._dismiss_cookie_popup(page)
|
||
|
||
# v6.0.0: 验证码检测 → 截图 + 人工接管(不自动破解)
|
||
if captcha_handoff:
|
||
cap = self._detect_captcha(page, url)
|
||
if cap:
|
||
print(f"[BrowserScraper] 验证码接管: {cap.get('screenshot', '')}",
|
||
file=sys.stderr)
|
||
return None
|
||
|
||
if wait_selector:
|
||
page.wait_for_selector(wait_selector, timeout=self.timeout_ms)
|
||
self._human_delay(0.3, 0.8)
|
||
|
||
if scroll:
|
||
self._random_scroll(page, times=random.randint(3, 6))
|
||
|
||
html = page.content()
|
||
if include_iframes:
|
||
html = self._merge_iframes(html, page)
|
||
return html
|
||
except PlaywrightTimeout:
|
||
if attempt < retries:
|
||
time.sleep(random.uniform(1, 3))
|
||
continue
|
||
return None
|
||
except Exception as e:
|
||
# v6.0.0: 错误恢复 — 页面崩溃/目标关闭时重建页面重试
|
||
msg = str(e).lower()
|
||
if attempt < retries and (
|
||
"disconnected" in msg or "target closed" in msg
|
||
or "crash" in msg or "frame was detached" in msg
|
||
):
|
||
time.sleep(random.uniform(1, 2))
|
||
continue
|
||
print(f"[BrowserScraper] fetch 失败: {e}", file=sys.stderr)
|
||
return None
|
||
finally:
|
||
if page is not None:
|
||
try:
|
||
page.close()
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
def extract_text(self, url: str, selector: str = "body",
|
||
wait_selector: str = None, scroll: bool = False,
|
||
dismiss_cookies: bool = True) -> Optional[str]:
|
||
"""提取指定元素的文本内容。"""
|
||
page = None
|
||
try:
|
||
page = self.new_page()
|
||
page.goto(url, wait_until="domcontentloaded", timeout=self.timeout_ms)
|
||
self._human_delay(0.5, 1.5)
|
||
|
||
if dismiss_cookies:
|
||
self._dismiss_cookie_popup(page)
|
||
|
||
if wait_selector:
|
||
page.wait_for_selector(wait_selector, timeout=self.timeout_ms)
|
||
|
||
if scroll:
|
||
self._auto_scroll(page)
|
||
|
||
text = page.text_content(selector, timeout=self.timeout_ms)
|
||
return text.strip() if text else None
|
||
except PlaywrightTimeout:
|
||
return None
|
||
except Exception as e:
|
||
print(f"[BrowserScraper] extract_text 失败: {e}", file=sys.stderr)
|
||
return None
|
||
finally:
|
||
if page is not None:
|
||
try:
|
||
page.close()
|
||
except Exception:
|
||
pass
|
||
|
||
def extract_multiple(self, url: str, selectors: Dict[str, str],
|
||
**kwargs) -> Dict[str, Optional[str]]:
|
||
"""批量提取多个选择器的文本。selectors: {key: css_selector}"""
|
||
results = {}
|
||
page = None
|
||
try:
|
||
page = self.new_page()
|
||
page.goto(url, wait_until="domcontentloaded", timeout=self.timeout_ms)
|
||
self._human_delay(0.5, 1.5)
|
||
if kwargs.get("dismiss_cookies", True):
|
||
self._dismiss_cookie_popup(page)
|
||
if kwargs.get("scroll", False):
|
||
self._auto_scroll(page)
|
||
|
||
for key, sel in selectors.items():
|
||
try:
|
||
el = page.query_selector(sel)
|
||
results[key] = el.text_content().strip() if el else None
|
||
except Exception:
|
||
results[key] = None
|
||
except Exception as e:
|
||
print(f"[BrowserScraper] extract_multiple 失败: {e}", file=sys.stderr)
|
||
finally:
|
||
if page is not None:
|
||
try:
|
||
page.close()
|
||
except Exception:
|
||
pass
|
||
return results
|
||
|
||
def extract_table(self, url: str, table_selector: str = "table",
|
||
**kwargs) -> List[Dict[str, str]]:
|
||
"""提取 HTML 表格为 dict 列表。"""
|
||
rows_data = []
|
||
page = None
|
||
try:
|
||
page = self.new_page()
|
||
page.goto(url, wait_until="domcontentloaded", timeout=self.timeout_ms)
|
||
self._human_delay(0.5, 1.5)
|
||
if kwargs.get("dismiss_cookies", True):
|
||
self._dismiss_cookie_popup(page)
|
||
if kwargs.get("scroll", False):
|
||
self._auto_scroll(page)
|
||
|
||
rows = page.query_selector_all(f"{table_selector} tr")
|
||
if not rows:
|
||
return rows_data
|
||
|
||
# 第一行作表头
|
||
headers = [th.text_content().strip()
|
||
for th in rows[0].query_selector_all("th,td")]
|
||
|
||
for row in rows[1:]:
|
||
cells = [td.text_content().strip()
|
||
for td in row.query_selector_all("td")]
|
||
if cells:
|
||
row_dict = {}
|
||
for i, cell in enumerate(cells):
|
||
key = headers[i] if i < len(headers) else f"col_{i}"
|
||
row_dict[key] = cell
|
||
rows_data.append(row_dict)
|
||
except Exception as e:
|
||
print(f"[BrowserScraper] extract_table 失败: {e}", file=sys.stderr)
|
||
finally:
|
||
if page is not None:
|
||
try:
|
||
page.close()
|
||
except Exception:
|
||
pass
|
||
return rows_data
|
||
|
||
# ── 截图 ─────────────────────────────────────────────────
|
||
|
||
def screenshot(self, url: str, output_path: str = None,
|
||
full_page: bool = True, **kwargs) -> Optional[str]:
|
||
"""打开网页并截图保存。返回保存路径。"""
|
||
if not output_path:
|
||
ts = time.strftime("%Y%m%d_%H%M%S")
|
||
domain = re.sub(r"https?://(www\.)?", "", url).split("/")[0][:30]
|
||
output_path = str(SCREENSHOT_DIR / f"{domain}_{ts}.png")
|
||
|
||
try:
|
||
page = self.new_page()
|
||
page.goto(url, wait_until="domcontentloaded", timeout=self.timeout_ms)
|
||
self._human_delay(0.5, 1.5)
|
||
if kwargs.get("dismiss_cookies", True):
|
||
self._dismiss_cookie_popup(page)
|
||
page.screenshot(path=output_path, full_page=full_page)
|
||
page.close()
|
||
return output_path
|
||
except Exception as e:
|
||
print(f"[BrowserScraper] screenshot 失败: {e}", file=sys.stderr)
|
||
return None
|
||
|
||
# ── 交互操作 ─────────────────────────────────────────────
|
||
|
||
def click_and_wait(self, url: str, click_selector: str,
|
||
wait_selector: str = None) -> Optional[str]:
|
||
"""点击某元素后等待并返回新页面的 HTML。"""
|
||
try:
|
||
page = self.new_page()
|
||
page.goto(url, wait_until="domcontentloaded", timeout=self.timeout_ms)
|
||
self._dismiss_cookie_popup(page)
|
||
page.click(click_selector, timeout=self.timeout_ms)
|
||
self._human_delay(0.5, 1.5)
|
||
if wait_selector:
|
||
page.wait_for_selector(wait_selector, timeout=self.timeout_ms)
|
||
html = page.content()
|
||
page.close()
|
||
return html
|
||
except Exception as e:
|
||
print(f"[BrowserScraper] click_and_wait 失败: {e}", file=sys.stderr)
|
||
return None
|
||
|
||
def type_and_submit(self, url: str, input_selector: str, text: str,
|
||
submit_selector: str = None) -> Optional[str]:
|
||
"""在输入框中输入文本,可选提交。返回操作后页面 HTML。"""
|
||
try:
|
||
page = self.new_page()
|
||
page.goto(url, wait_until="domcontentloaded", timeout=self.timeout_ms)
|
||
self._dismiss_cookie_popup(page)
|
||
page.fill(input_selector, text, timeout=self.timeout_ms)
|
||
self._human_delay(0.3, 0.8)
|
||
if submit_selector:
|
||
page.click(submit_selector, timeout=self.timeout_ms)
|
||
page.wait_for_load_state("domcontentloaded")
|
||
self._human_delay(0.5, 1.0)
|
||
html = page.content()
|
||
page.close()
|
||
return html
|
||
except Exception as e:
|
||
print(f"[BrowserScraper] type_and_submit 失败: {e}", file=sys.stderr)
|
||
return None
|
||
|
||
def login_and_fetch(self, url: str, login_url: str,
|
||
username_sel: str, password_sel: str,
|
||
submit_sel: str, username: str, password: str,
|
||
target_selector: str = None) -> Optional[str]:
|
||
"""登录流程:先访问登录页填入凭据,再跳转到目标页抓取。"""
|
||
try:
|
||
page = self.new_page()
|
||
# 登录
|
||
page.goto(login_url, wait_until="domcontentloaded", timeout=self.timeout_ms)
|
||
self._human_delay(0.5, 1.0)
|
||
page.fill(username_sel, username, timeout=self.timeout_ms)
|
||
self._human_delay(0.2, 0.5)
|
||
page.fill(password_sel, password, timeout=self.timeout_ms)
|
||
self._human_delay(0.3, 0.8)
|
||
page.click(submit_sel, timeout=self.timeout_ms)
|
||
page.wait_for_load_state("domcontentloaded")
|
||
self._human_delay(1.0, 2.0)
|
||
# 跳转目标页
|
||
page.goto(url, wait_until="domcontentloaded", timeout=self.timeout_ms)
|
||
if target_selector:
|
||
page.wait_for_selector(target_selector, timeout=self.timeout_ms)
|
||
html = page.content()
|
||
page.close()
|
||
return html
|
||
except Exception as e:
|
||
print(f"[BrowserScraper] login_and_fetch 失败: {e}", file=sys.stderr)
|
||
return None
|
||
|
||
# ── 智能回退 ─────────────────────────────────────────────
|
||
|
||
def smart_fetch(self, url: str, html_text: Optional[str] = None,
|
||
**kwargs) -> Optional[str]:
|
||
"""智能抓取:优先用传入的 html_text;失败则启动浏览器。
|
||
|
||
这是最推荐的使用方式——先尝试常规 http_utils 爬取,
|
||
返回空时自动调用浏览器 fetch() 兜底。
|
||
"""
|
||
if html_text and len(html_text) > 200:
|
||
return html_text
|
||
return self.fetch(url, **kwargs)
|
||
|
||
# ── 便捷函数 ─────────────────────────────────────────────
|
||
|
||
def fetch_with_screenshot(self, url: str) -> dict:
|
||
"""抓取+截图,返回 {html, screenshot_path}。"""
|
||
html = self.fetch(url)
|
||
shot = self.screenshot(url)
|
||
return {"html": html, "screenshot_path": shot}
|
||
|
||
# ── 内部辅助 ─────────────────────────────────────────────
|
||
|
||
def _dismiss_cookie_popup(self, page: Page):
|
||
"""尝试点击页面上的 Cookie 同意按钮。"""
|
||
for selector in COOKIE_CONSENT_SELECTORS:
|
||
try:
|
||
btn = page.query_selector(selector)
|
||
if btn and btn.is_visible():
|
||
btn.click(timeout=3000)
|
||
return
|
||
except Exception:
|
||
continue
|
||
|
||
def _auto_scroll(self, page: Page, times: int = 5):
|
||
"""模拟人工滚动,触发懒加载内容。"""
|
||
for i in range(times):
|
||
page.evaluate(f"window.scrollTo(0, {(i + 1) * 800})")
|
||
self._human_delay(0.3, 0.8)
|
||
# 滚回顶部
|
||
page.evaluate("window.scrollTo(0, 0)")
|
||
|
||
@staticmethod
|
||
def _human_delay(min_s: float = 0.1, max_s: float = 0.5):
|
||
"""随机延迟,模拟人类浏览速度。"""
|
||
time.sleep(random.uniform(min_s, max_s))
|
||
|
||
# ── v6.0.0: 验证码检测 → 人工接管 ───────────────────────────
|
||
|
||
CAPTCHA_TEXT_SIGNS = (
|
||
"captcha", "recaptcha", "verify you are human", "verify you're a human",
|
||
"unusual traffic", "人机验证", "安全验证", "滑动验证", "安全校验",
|
||
"请输入验证码", "请完成验证", "拖动滑块", "点选验证",
|
||
)
|
||
|
||
def _detect_captcha(self, page: Page, url: str = "") -> Optional[Dict[str, Any]]:
|
||
"""检测验证码页面 → 截图保存 → 返回检测结果(不自动破解,等待人工)。
|
||
|
||
Returns:
|
||
{"captcha": 命中特征, "screenshot": 截图路径 | None};未命中返回 None
|
||
"""
|
||
try:
|
||
body = (page.text_content("body") or "")[:4000].lower()
|
||
except Exception:
|
||
return None
|
||
for sign in self.CAPTCHA_TEXT_SIGNS:
|
||
if sign in body:
|
||
shot = None
|
||
try:
|
||
ts = time.strftime("%Y%m%d_%H%M%S")
|
||
domain = re.sub(r"https?://(www\.)?", "", url or "").split("/")[0][:20] or "unknown"
|
||
shot = str(SCREENSHOT_DIR / f"captcha_{domain}_{ts}.png")
|
||
page.screenshot(path=shot)
|
||
# v7.0.0: 空截图(<100B)是探测失败的产物,落盘即垃圾,直接删
|
||
if Path(shot).stat().st_size < 100:
|
||
Path(shot).unlink(missing_ok=True)
|
||
shot = None
|
||
except Exception:
|
||
shot = None
|
||
print(f"[BrowserScraper] ⚠️ 检测到验证码({sign})"
|
||
f"{f',截图已保存: {shot}' if shot else ''} — 请人工处理或稍后重试",
|
||
file=sys.stderr)
|
||
return {"captcha": sign, "screenshot": shot}
|
||
return None
|
||
|
||
# ── v6.0.0: iframe 内容合并 ────────────────────────────────
|
||
|
||
def _merge_iframes(self, html: str, page: Page,
|
||
max_frames: int = 8) -> str:
|
||
"""提取页面内嵌 iframe 的 HTML 并合并到主文档(正文常在 iframe 中)。
|
||
|
||
合并内容用 HTML 注释包裹,供下游解析器识别。
|
||
"""
|
||
try:
|
||
frames = page.frames
|
||
parts = []
|
||
for fr in frames[1:max_frames + 1]: # frames[0] 是主 frame
|
||
try:
|
||
content = fr.content()
|
||
if content and len(content) > 500:
|
||
parts.append(content)
|
||
except Exception:
|
||
continue
|
||
if parts:
|
||
sep = "\n<!-- v6.0.0-iframe-content -->\n"
|
||
html = html + sep + sep.join(parts)
|
||
except Exception:
|
||
pass
|
||
return html
|
||
|
||
# ── v6.0.0: 随机悬停 / 随机标签页切换 ─────────────────────────
|
||
|
||
def _random_hover(self, page: Page, tries: int = 3):
|
||
"""随机悬停菜单/下拉元素,触发动态子菜单(模拟真人浏览)。"""
|
||
for _ in range(tries):
|
||
try:
|
||
els = page.query_selector_all(
|
||
"nav a, .menu li, .dropdown, .nav-item, [class*='nav'] a")
|
||
if not els:
|
||
return
|
||
el = random.choice(els)
|
||
if el.is_visible():
|
||
el.hover(timeout=3000)
|
||
self._human_delay(0.3, 0.8)
|
||
except Exception:
|
||
continue
|
||
|
||
def _random_tab_switch(self, page: Page, prob: float = 0.1):
|
||
"""低频(默认 10%)开新标签页浏览后切回,模拟真人多标签行为。"""
|
||
if random.random() >= prob:
|
||
return
|
||
try:
|
||
ctx = page.context
|
||
p2 = ctx.new_page()
|
||
try:
|
||
p2.goto("about:blank")
|
||
self._human_delay(0.5, 1.5)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
p2.close()
|
||
except Exception:
|
||
pass
|
||
page.bring_to_front()
|
||
except Exception:
|
||
pass
|
||
|
||
# ── v4.5 类人操作增强 ─────────────────────────────────────
|
||
|
||
def _human_mouse_move(self, page: Page, target_x: float, target_y: float,
|
||
steps: int = 25):
|
||
"""模拟鼠标移动到目标位置(贝塞尔曲线 + 随机偏移 + 随机速度)。
|
||
|
||
使用三次贝塞尔曲线生成平滑轨迹,中途加入微小随机抖动。
|
||
"""
|
||
try:
|
||
# 起点:随机在页面中上区域
|
||
start_x = random.uniform(100, min(target_x + 200, 800))
|
||
start_y = random.uniform(100, min(target_y + 200, 600))
|
||
# 控制点:随机偏移形成弧线
|
||
cp1_x = start_x + random.uniform(-150, 150)
|
||
cp1_y = start_y + random.uniform(-100, 100)
|
||
cp2_x = target_x + random.uniform(-100, 100)
|
||
cp2_y = target_y + random.uniform(-100, 100)
|
||
|
||
for i in range(steps + 1):
|
||
t = i / steps
|
||
# 三次贝塞尔曲线: B(t) = (1-t)³P0 + 3(1-t)²t·P1 + 3(1-t)t²·P2 + t³P3
|
||
x = ((1-t)**3 * start_x + 3*(1-t)**2*t * cp1_x +
|
||
3*(1-t)*t**2 * cp2_x + t**3 * target_x)
|
||
y = ((1-t)**3 * start_y + 3*(1-t)**2*t * cp1_y +
|
||
3*(1-t)*t**2 * cp2_y + t**3 * target_y)
|
||
# 添加微小抖动
|
||
x += random.uniform(-2, 2)
|
||
y += random.uniform(-1, 1)
|
||
page.mouse.move(x, y)
|
||
# 随机速度:每步 5-25ms
|
||
time.sleep(random.uniform(0.005, 0.025))
|
||
except Exception:
|
||
# 浏览器不可用时跳过
|
||
pass
|
||
|
||
def _human_type(self, page: Page, selector: str, text: str,
|
||
typo_rate: float = 0.03):
|
||
"""模拟人类打字:随机字符间隔 + 偶尔打错+退格修正。
|
||
|
||
v6.0.0: 15% 概率走「粘贴」路径(insert_text 一次注入),
|
||
模拟真人复制粘贴长文本,避免逐字输入的机械特征。
|
||
|
||
Args:
|
||
page: Playwright Page
|
||
selector: 目标输入框 CSS 选择器
|
||
text: 要输入的文本
|
||
typo_rate: 打错概率(默认 3%)
|
||
"""
|
||
try:
|
||
page.click(selector)
|
||
self._human_delay(0.1, 0.3)
|
||
# v6.0.0: 低频粘贴模式(长文本更真实)
|
||
if len(text) > 10 and random.random() < 0.15:
|
||
page.keyboard.insert_text(text)
|
||
self._human_delay(0.2, 0.6)
|
||
return
|
||
for ch in text:
|
||
if random.random() < typo_rate and ch.isalpha():
|
||
# 模拟打错 — 输入相邻键位然后退格
|
||
typo = chr(ord(ch) + random.choice([-1, 1]))
|
||
page.keyboard.type(typo, delay=random.randint(30, 80))
|
||
self._human_delay(0.05, 0.15)
|
||
page.keyboard.press("Backspace")
|
||
self._human_delay(0.05, 0.1)
|
||
# 正常输入当前字符
|
||
page.keyboard.type(ch, delay=random.randint(50, 200))
|
||
# 句子间额外停顿
|
||
if ch in "。!?\n":
|
||
self._human_delay(0.2, 0.6)
|
||
except Exception:
|
||
pass
|
||
|
||
def _human_dwell(self, min_s: float = 0.5, max_s: float = 2.0,
|
||
content_length: int = 0):
|
||
"""自适应停留时间:内容越长看得越久。"""
|
||
# 基础随机停留
|
||
base = random.uniform(min_s, max_s)
|
||
# 内容长度加成:每 1000 字符增加 0.1-0.3 秒
|
||
if content_length > 0:
|
||
bonus = min(2.0, content_length / 1000 * random.uniform(0.1, 0.3))
|
||
base += bonus
|
||
time.sleep(base)
|
||
|
||
# ── v6.0.0: 多样化滚动模式 ──────────────────────────────────
|
||
|
||
SCROLL_MODES = ("fast_browse", "slow_read", "segmented", "rewind")
|
||
|
||
def _random_scroll(self, page: Page, times: int = 5,
|
||
mode: Optional[str] = None):
|
||
"""随机滚动模式:4 种人类浏览行为随机切换。
|
||
|
||
fast_browse: 快速扫读(大步长 + 短停顿)
|
||
slow_read: 慢速精读(小步长 + 长停顿)
|
||
segmented: 分段阅读(中步长 + 分段停顿)
|
||
rewind: 扫读+回看(随机回滚,原 v4.5 行为)
|
||
"""
|
||
mode = mode or random.choice(self.SCROLL_MODES)
|
||
try:
|
||
if mode == "fast_browse":
|
||
for _ in range(times):
|
||
page.evaluate(f"window.scrollBy(0, {random.randint(500, 900)})")
|
||
time.sleep(random.uniform(0.1, 0.4))
|
||
elif mode == "slow_read":
|
||
for _ in range(times):
|
||
page.evaluate("window.scrollBy(0, 300)")
|
||
time.sleep(random.uniform(1.0, 2.5))
|
||
elif mode == "segmented":
|
||
for _ in range(times):
|
||
page.evaluate(f"window.scrollBy(0, {random.randint(200, 500)})")
|
||
time.sleep(random.uniform(0.4, 1.2))
|
||
time.sleep(random.uniform(0.5, 1.5))
|
||
else: # rewind
|
||
for i in range(times):
|
||
distance = random.randint(200, 900)
|
||
if random.random() < 0.2 and i > 0:
|
||
distance = -random.randint(100, 300)
|
||
page.evaluate(f"window.scrollBy(0, {distance})")
|
||
time.sleep(random.uniform(0.2, 1.5))
|
||
except Exception:
|
||
pass
|
||
try:
|
||
page.evaluate("window.scrollTo(0, 0)")
|
||
except Exception:
|
||
pass
|
||
self._human_delay(0.3, 0.6)
|
||
|
||
def _random_viewport(self, page: Page):
|
||
"""随机切换 viewport 尺寸,模拟不同设备分辨率。"""
|
||
viewports = [
|
||
{"width": 1920, "height": 1080}, # 标准桌面
|
||
{"width": 1680, "height": 1050}, # 常见笔记本
|
||
{"width": 1440, "height": 900}, # 小型桌面
|
||
{"width": 1366, "height": 768}, # 常见笔记本
|
||
{"width": 2560, "height": 1440}, # 2K 桌面
|
||
]
|
||
vp = random.choice(viewports)
|
||
try:
|
||
page.set_viewport_size(vp)
|
||
except Exception:
|
||
pass
|
||
|
||
def humanlike_fetch(self, url: str, scroll: bool = True,
|
||
dwell: bool = True, random_viewport: bool = True,
|
||
mouse_simulation: bool = True,
|
||
input_selector: str = None,
|
||
input_text: str = None,
|
||
think_time: bool = True,
|
||
hover: bool = True,
|
||
tab_switch: bool = True,
|
||
captcha_handoff: bool = True,
|
||
include_iframes: bool = False) -> Optional[str]:
|
||
"""类人模式抓取:随机 viewport → 打开 → 阅读停顿 → 鼠标移动 → 悬停 → 滚动 → (表单输入) → HTML。
|
||
|
||
一键开启所有类人行为,适合高反爬网站。
|
||
|
||
Args:
|
||
url: 目标网址
|
||
scroll: 是否随机滚动(默认 True)
|
||
dwell: 是否自适应停留阅读(默认 True)
|
||
random_viewport: 是否随机 viewport 尺寸(默认 True)
|
||
mouse_simulation: 是否模拟贝塞尔鼠标移动(默认 True,v4.7 新增)
|
||
input_selector: 表单输入框 CSS 选择器(配合 input_text)
|
||
input_text: 需要模拟输入的文本(如搜索关键词)
|
||
think_time: v6.0.0 阅读停顿 — 页面加载后随机停留 2-8s 模拟阅读
|
||
hover: v6.0.0 随机悬停菜单触发下拉内容
|
||
tab_switch: v6.0.0 低频开新标签页再切回(模拟多标签)
|
||
captcha_handoff: v6.0.0 验证码检测 → 截图保存 + 返回 None(人工接管)
|
||
include_iframes: v6.0.0 合并内嵌 iframe 内容
|
||
"""
|
||
page = None
|
||
try:
|
||
page = self.new_page()
|
||
if random_viewport:
|
||
self._random_viewport(page)
|
||
|
||
page.goto(url, wait_until="domcontentloaded", timeout=self.timeout_ms)
|
||
self._dismiss_cookie_popup(page)
|
||
self._human_delay(0.5, 1.5)
|
||
|
||
# v6.0.0: 验证码检测 → 截图 + 人工接管(不自动破解)
|
||
if captcha_handoff:
|
||
cap = self._detect_captcha(page, url)
|
||
if cap:
|
||
return None
|
||
|
||
# v6.0.0: 随机标签页切换(低频)
|
||
if tab_switch:
|
||
self._random_tab_switch(page)
|
||
|
||
# ★ v4.7:贝塞尔鼠标移动 2-3 次(模拟真人在页面上移动光标)
|
||
if mouse_simulation:
|
||
try:
|
||
vp = page.viewport_size or {"width": 1920, "height": 1080}
|
||
for _ in range(random.randint(2, 4)):
|
||
self._human_mouse_move(
|
||
page,
|
||
random.uniform(100, vp["width"] - 100),
|
||
random.uniform(100, vp["height"] - 100),
|
||
steps=random.randint(20, 30),
|
||
)
|
||
self._human_delay(0.3, 0.8)
|
||
except Exception:
|
||
pass
|
||
|
||
if dwell:
|
||
content_len = 0
|
||
try:
|
||
body_text = page.text_content("body") or ""
|
||
content_len = len(body_text)
|
||
except Exception:
|
||
pass
|
||
self._human_dwell(0.5, 2.0, content_len)
|
||
|
||
# v6.0.0: 阅读停顿 — 加载后停留 2-8 秒模拟真人阅读
|
||
if think_time:
|
||
time.sleep(random.uniform(2.0, 8.0))
|
||
|
||
# v6.0.0: 随机悬停触发下拉菜单
|
||
if hover:
|
||
self._random_hover(page)
|
||
|
||
if scroll:
|
||
self._random_scroll(page, times=random.randint(3, 7))
|
||
|
||
# ★ v4.7:表单输入(如搜索框)
|
||
if input_selector and input_text:
|
||
self._human_type(page, input_selector, input_text)
|
||
self._human_delay(0.5, 1.5)
|
||
|
||
html = page.content()
|
||
if include_iframes:
|
||
html = self._merge_iframes(html, page)
|
||
return html
|
||
except Exception as e:
|
||
print(f"[BrowserScraper] humanlike_fetch 失败: {e}", file=sys.stderr)
|
||
return None
|
||
finally:
|
||
if page is not None:
|
||
try:
|
||
page.close()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# ── 模块级便捷函数 ──────────────────────────────────────────
|
||
|
||
# v5.0.0: 进程级单例浏览器(供 sentiment / web_parser 复用,避免每源冷启动)
|
||
_BS_SINGLETON: Optional[BrowserScraper] = None
|
||
_BS_LOCK = threading.Lock()
|
||
|
||
|
||
def _get_browser_scraper(headless: bool = True, **kwargs) -> Optional["BrowserScraper"]:
|
||
"""获取进程级单例 BrowserScraper(惰性创建,跨源复用)。
|
||
|
||
Args:
|
||
headless: 默认后台运行(舆情兜底场景)。
|
||
**kwargs: 透传给 BrowserScraper(proxy/ad_block 等)。
|
||
|
||
Returns:
|
||
BrowserScraper 单例;Playwright 不可用时返回 None。
|
||
"""
|
||
global _BS_SINGLETON
|
||
if not PLAYWRIGHT_AVAILABLE:
|
||
return None
|
||
with _BS_LOCK:
|
||
if _BS_SINGLETON is None:
|
||
try:
|
||
_BS_SINGLETON = BrowserScraper(headless=headless, **kwargs)
|
||
_BS_SINGLETON.start()
|
||
except Exception:
|
||
_BS_SINGLETON = None
|
||
return _BS_SINGLETON
|
||
|
||
|
||
def release_browser():
|
||
"""关闭并释放进程级单例浏览器。"""
|
||
global _BS_SINGLETON
|
||
with _BS_LOCK:
|
||
if _BS_SINGLETON is not None:
|
||
try:
|
||
_BS_SINGLETON.stop()
|
||
except Exception:
|
||
pass
|
||
_BS_SINGLETON = None
|
||
|
||
|
||
def browser_fetch(url: str, headless: bool = False,
|
||
scroll: bool = False) -> Optional[str]:
|
||
"""一行代码启动浏览器抓取。"""
|
||
with BrowserScraper(headless=headless) as bs:
|
||
return bs.fetch(url, scroll=scroll)
|
||
|
||
|
||
def browser_screenshot(url: str, output_path: str = None) -> Optional[str]:
|
||
"""一行代码截图。"""
|
||
with BrowserScraper(headless=True) as bs:
|
||
return bs.screenshot(url, output_path)
|
||
|
||
|
||
def smart_fallback(url: str, http_html: Optional[str],
|
||
headless: bool = False) -> Optional[str]:
|
||
"""智能回退:HTTP 返回空 → 浏览器兜底。"""
|
||
if http_html and len(http_html) > 200:
|
||
return http_html
|
||
return browser_fetch(url, headless=headless)
|
||
|
||
|
||
# ── v4.5 类人翻页 / 图文全抓(增强) ─────────────────────────
|
||
# 以下方法通过 monkey-patch 追加到 BrowserScraper 类(保持模块化)
|
||
|
||
_IMG_TAG_RE = re.compile(r'<img[^>]+src=["\']([^"\']+)["\']', re.IGNORECASE)
|
||
def _paginate(self, url: str, next_selector: str,
|
||
max_pages: int = 10,
|
||
stop_when_no_change: bool = True,
|
||
until_selector: str = None,
|
||
wait_after_click: float = 1.0,
|
||
total_timeout: float = 60.0,
|
||
scroll_each: bool = True,
|
||
dismiss_cookies: bool = True) -> Dict[str, Any]:
|
||
"""类人翻页:点 next → 等出现内容 → 抓全文 → 累积到 pages。
|
||
|
||
停止条件(任一触发):
|
||
1. 连续 2 轮 next_selector 不存在
|
||
2. 连续 2 轮新页面 hash 与上一轮相同(去除时间戳)
|
||
3. 达到 max_pages
|
||
4. 出现 until_selector
|
||
5. 累计 > total_timeout 秒
|
||
|
||
Returns:
|
||
{"pages": [{"url", "html", "page_num"}], "total_pages": int,
|
||
"stopped_reason": str, "elapsed_seconds": float}
|
||
"""
|
||
start_time = time.time()
|
||
pages: List[Dict[str, Any]] = []
|
||
last_hashes: List[str] = []
|
||
stopped_reason = "max_pages"
|
||
current_url = url
|
||
|
||
page_obj = self.new_page()
|
||
try:
|
||
page_obj.goto(current_url, wait_until="domcontentloaded",
|
||
timeout=self.timeout_ms)
|
||
if dismiss_cookies:
|
||
self._dismiss_cookie_popup(page_obj)
|
||
self._human_delay(0.5, 1.5)
|
||
|
||
for page_num in range(1, max_pages + 1):
|
||
if total_timeout and (time.time() - start_time) > total_timeout:
|
||
stopped_reason = "total_timeout"
|
||
break
|
||
|
||
# 检查 until_selector
|
||
if until_selector:
|
||
try:
|
||
el = page_obj.query_selector(until_selector)
|
||
if el:
|
||
stopped_reason = "until_selector_found"
|
||
break
|
||
except Exception:
|
||
pass
|
||
|
||
# 等内容稳定
|
||
self._human_delay(0.3, 0.8)
|
||
if scroll_each:
|
||
self._auto_scroll(page_obj, times=3)
|
||
html = page_obj.content()
|
||
content_hash = hashlib.md5(
|
||
_strip_dynamic_markers(html).encode("utf-8", "ignore")
|
||
).hexdigest()
|
||
|
||
pages.append({
|
||
"url": current_url,
|
||
"html": html,
|
||
"page_num": page_num,
|
||
})
|
||
|
||
# 检测重复(连续 2 轮相同)
|
||
if stop_when_no_change and len(last_hashes) >= 1 \
|
||
and last_hashes[-1] == content_hash:
|
||
stopped_reason = "no_change_2x"
|
||
break
|
||
last_hashes.append(content_hash)
|
||
if len(last_hashes) > 3:
|
||
last_hashes.pop(0)
|
||
|
||
# 找"下一页"按钮
|
||
if page_num >= max_pages:
|
||
stopped_reason = "max_pages"
|
||
break
|
||
|
||
try:
|
||
next_btn = page_obj.query_selector(next_selector)
|
||
except Exception:
|
||
next_btn = None
|
||
if not next_btn:
|
||
stopped_reason = "next_not_found"
|
||
break
|
||
try:
|
||
if not next_btn.is_visible():
|
||
stopped_reason = "next_not_visible"
|
||
break
|
||
next_btn.click(timeout=5000)
|
||
page_obj.wait_for_load_state("domcontentloaded",
|
||
timeout=self.timeout_ms)
|
||
self._human_delay(wait_after_click, wait_after_click + 0.5)
|
||
current_url = page_obj.url
|
||
except Exception:
|
||
stopped_reason = "next_click_failed"
|
||
break
|
||
finally:
|
||
try:
|
||
page_obj.close()
|
||
except Exception:
|
||
pass
|
||
|
||
return {
|
||
"pages": pages,
|
||
"total_pages": len(pages),
|
||
"stopped_reason": stopped_reason,
|
||
"elapsed_seconds": round(time.time() - start_time, 2),
|
||
}
|
||
|
||
|
||
def _extract_links(self, url: str, selector: str = "a[href]",
|
||
base: str = None,
|
||
dismiss_cookies: bool = True) -> List[Dict[str, str]]:
|
||
"""提取页面所有链接(href + 文本 + 内/外链标记)。
|
||
|
||
Returns:
|
||
[{"href", "text", "type": "internal"|"external", "domain"}]
|
||
"""
|
||
out: List[Dict[str, str]] = []
|
||
page = None
|
||
try:
|
||
page = self.new_page()
|
||
page.goto(url, wait_until="domcontentloaded", timeout=self.timeout_ms)
|
||
if dismiss_cookies:
|
||
self._dismiss_cookie_popup(page)
|
||
self._human_delay(0.3, 0.8)
|
||
base_domain = urlparse(base or url).netloc
|
||
links = page.query_selector_all(selector)
|
||
for el in links:
|
||
try:
|
||
href = el.get_attribute("href") or ""
|
||
text = (el.text_content() or "").strip()[:100]
|
||
if not href or href.startswith(("javascript:", "mailto:", "#")):
|
||
continue
|
||
full_href = urljoin(base or url, href)
|
||
link_domain = urlparse(full_href).netloc
|
||
link_type = "internal" if link_domain == base_domain else "external"
|
||
out.append({"href": full_href, "text": text,
|
||
"type": link_type, "domain": link_domain})
|
||
except Exception:
|
||
continue
|
||
except Exception as e:
|
||
print(f"[BrowserScraper] extract_links 失败: {e}", file=sys.stderr)
|
||
finally:
|
||
if page is not None:
|
||
try:
|
||
page.close()
|
||
except Exception:
|
||
pass
|
||
return out
|
||
|
||
|
||
def _follow_links(self, list_url: str, link_selector: str,
|
||
content_selector: Optional[str] = None,
|
||
max_links: int = 20,
|
||
wait_after_goto: float = 1.0,
|
||
dismiss_cookies: bool = True) -> List[Dict[str, Any]]:
|
||
"""列表页 → 逐条进入详情页 → 返回正文列表。
|
||
|
||
Returns:
|
||
[{"url", "html", "text", "title"}]
|
||
"""
|
||
results: List[Dict[str, Any]] = []
|
||
page = None
|
||
try:
|
||
page = self.new_page()
|
||
page.goto(list_url, wait_until="domcontentloaded", timeout=self.timeout_ms)
|
||
if dismiss_cookies:
|
||
self._dismiss_cookie_popup(page)
|
||
self._human_delay(0.5, 1.0)
|
||
|
||
hrefs = page.eval_on_selector_all(
|
||
link_selector,
|
||
"els => els.map(e => e.href).filter(h => h && !h.startsWith('javascript:'))"
|
||
)
|
||
if not hrefs:
|
||
return results
|
||
|
||
for href in hrefs[:max_links]:
|
||
try:
|
||
page.goto(href, wait_until="domcontentloaded",
|
||
timeout=self.timeout_ms)
|
||
self._human_delay(wait_after_goto, wait_after_goto + 0.5)
|
||
html = page.content()
|
||
text = ""
|
||
title = ""
|
||
if content_selector:
|
||
el = page.query_selector(content_selector)
|
||
if el:
|
||
text = (el.text_content() or "").strip()
|
||
else:
|
||
text = page.text_content("body") or ""
|
||
title_el = page.query_selector("title")
|
||
if title_el:
|
||
title = (title_el.text_content() or "").strip()[:200]
|
||
results.append({"url": href, "html": html,
|
||
"text": text, "title": title})
|
||
except Exception as e:
|
||
print(f"[BrowserScraper] follow_links 单条失败 {href}: {e}",
|
||
file=sys.stderr)
|
||
except Exception as e:
|
||
print(f"[BrowserScraper] follow_links 失败: {e}", file=sys.stderr)
|
||
finally:
|
||
if page is not None:
|
||
try:
|
||
page.close()
|
||
except Exception:
|
||
pass
|
||
return results
|
||
|
||
|
||
def _download_images(self, html: str, base_url: str,
|
||
output_dir: str,
|
||
max_images: int = 10,
|
||
inline_svg: str = "preserve",
|
||
same_url_dedup: bool = True) -> Dict[str, Any]:
|
||
"""解析 HTML 中的 <img>/<source>/<image> → 下载到 output_dir → 返回替换映射。
|
||
|
||
策略:
|
||
- URL 标准化(去 ?v=xxx)作为 cache key
|
||
- 同 URL 多处引用 → 只下载一次
|
||
- 失败保留原 URL(不删 src)
|
||
- 总量限制 max_images(默认 10)
|
||
|
||
Args:
|
||
html: HTML 字符串
|
||
base_url: 用于相对 URL 转绝对
|
||
output_dir: 图片输出目录
|
||
max_images: 最大下载数
|
||
inline_svg: "preserve" | "rasterize" | "skip"
|
||
same_url_dedup: 是否对相同 URL 去重
|
||
|
||
Returns:
|
||
{"downloaded": [(local_path, original_url)], "image_map": {orig: local},
|
||
"skipped": [orig_urls], "errors": [...]}
|
||
"""
|
||
out_dir = Path(output_dir)
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
image_map: Dict[str, str] = {}
|
||
downloaded: List[tuple] = []
|
||
skipped: List[str] = []
|
||
errors: List[str] = []
|
||
|
||
# 提取所有 <img src>
|
||
seen_urls: Dict[str, str] = {} # normalized_url -> local_path
|
||
matches = list(_IMG_TAG_RE.finditer(html))
|
||
if len(matches) > max_images:
|
||
skipped = [f"total {len(matches)} > max_images {max_images}"] \
|
||
+ [m.group(1) for m in matches[max_images:]]
|
||
matches = matches[:max_images]
|
||
|
||
import urllib.request
|
||
for m in matches:
|
||
raw_url = m.group(1)
|
||
full_url = urljoin(base_url, raw_url)
|
||
# URL 标准化
|
||
parsed = urlparse(full_url)
|
||
norm = f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
|
||
if same_url_dedup and norm in seen_urls:
|
||
image_map[full_url] = seen_urls[norm]
|
||
continue
|
||
|
||
# 推断扩展名
|
||
ext = _guess_image_ext(full_url)
|
||
# 文件名用 URL 哈希
|
||
fname = hashlib.md5(norm.encode("utf-8")).hexdigest()[:16] + ext
|
||
local_path = out_dir / fname
|
||
|
||
try:
|
||
req = urllib.request.Request(
|
||
full_url,
|
||
headers={"User-Agent": "Mozilla/5.0 (compatible; cn-financial-scraper/4.5)"},
|
||
)
|
||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||
data = resp.read()
|
||
if len(data) > 10 * 1024 * 1024: # 单张 >10MB 跳过
|
||
skipped.append(full_url)
|
||
continue
|
||
local_path.write_bytes(data)
|
||
downloaded.append((str(local_path), full_url))
|
||
seen_urls[norm] = str(local_path)
|
||
image_map[full_url] = str(local_path)
|
||
except Exception as e:
|
||
errors.append(f"{full_url}: {e}")
|
||
skipped.append(full_url)
|
||
|
||
return {"downloaded": downloaded, "image_map": image_map,
|
||
"skipped": skipped, "errors": errors,
|
||
"total_found": len(_IMG_TAG_RE.findall(html))}
|
||
|
||
|
||
def _capture_canvas(self, url: str, selector: str = "canvas",
|
||
output_dir: Optional[str] = None,
|
||
wait_seconds: float = 2.0,
|
||
dismiss_cookies: bool = True) -> List[str]:
|
||
"""对每个 <canvas> 元素等待 JS 渲染后截图保存为 PNG。
|
||
|
||
Returns:
|
||
保存的 PNG 文件路径列表
|
||
"""
|
||
out_paths: List[str] = []
|
||
if output_dir is None:
|
||
output_dir = str(SCREENSHOT_DIR)
|
||
out_p = Path(output_dir)
|
||
out_p.mkdir(parents=True, exist_ok=True)
|
||
page = None
|
||
try:
|
||
page = self.new_page()
|
||
page.goto(url, wait_until="domcontentloaded", timeout=self.timeout_ms)
|
||
if dismiss_cookies:
|
||
self._dismiss_cookie_popup(page)
|
||
self._human_delay(wait_seconds, wait_seconds + 1.0)
|
||
canvases = page.query_selector_all(selector)
|
||
ts = time.strftime("%Y%m%d_%H%M%S")
|
||
for i, canvas in enumerate(canvases):
|
||
try:
|
||
png_path = out_p / f"canvas_{ts}_{i}.png"
|
||
canvas.screenshot(path=str(png_path))
|
||
out_paths.append(str(png_path))
|
||
except Exception as e:
|
||
print(f"[BrowserScraper] capture_canvas 单个失败: {e}",
|
||
file=sys.stderr)
|
||
except Exception as e:
|
||
print(f"[BrowserScraper] capture_canvas 失败: {e}", file=sys.stderr)
|
||
finally:
|
||
if page is not None:
|
||
try:
|
||
page.close()
|
||
except Exception:
|
||
pass
|
||
return out_paths
|
||
|
||
|
||
def _fetch_full_article(self, url: str,
|
||
image_strategy: str = "download",
|
||
max_pages: int = 1,
|
||
paginate: bool = False,
|
||
next_selector: Optional[str] = None,
|
||
max_images: int = 10,
|
||
media_dir: Optional[str] = None,
|
||
dismiss_cookies: bool = True) -> Dict[str, Any]:
|
||
"""主入口:单页 fetch + 翻页 + 图片本地化 + 返回结构化结果。
|
||
|
||
Args:
|
||
url: 起始 URL
|
||
image_strategy: "download" | "none"
|
||
max_pages: 最多翻几页(paginate=True 时生效)
|
||
paginate: 是否自动翻页
|
||
next_selector: "下一页"选择器(paginate=True 时必需)
|
||
max_images: 单篇最多下载图片数
|
||
media_dir: 图片输出目录(默认 data/media/<domain>/<timestamp>/)
|
||
|
||
Returns:
|
||
{"pages": [{"url","html","page_num"}],
|
||
"images": [(local_path, original_url)],
|
||
"image_map": {orig: local},
|
||
"canvases": [png_paths],
|
||
"elapsed_seconds": float,
|
||
"url": str}
|
||
"""
|
||
start_time = time.time()
|
||
domain = re.sub(r"https?://(www\.)?", "", url).split("/")[0][:30]
|
||
if media_dir is None:
|
||
ts = time.strftime("%Y%m%d_%H%M%S")
|
||
media_dir = str(DATA_DIR / "media" / domain / ts)
|
||
|
||
pages_data: List[Dict[str, Any]] = []
|
||
images: List[tuple] = []
|
||
image_map: Dict[str, str] = {}
|
||
canvases: List[str] = []
|
||
|
||
try:
|
||
if paginate and next_selector:
|
||
paginate_result = self.paginate(
|
||
url, next_selector, max_pages=max_pages,
|
||
dismiss_cookies=dismiss_cookies,
|
||
)
|
||
pages_data = paginate_result["pages"]
|
||
else:
|
||
html = self.fetch(url, scroll=True, dismiss_cookies=dismiss_cookies)
|
||
if html:
|
||
pages_data = [{"url": url, "html": html, "page_num": 1}]
|
||
|
||
# 下载图片
|
||
if image_strategy == "download" and pages_data:
|
||
all_imgs: List[tuple] = []
|
||
all_map: Dict[str, str] = {}
|
||
for p in pages_data:
|
||
result = self.download_images(
|
||
p["html"], p["url"], media_dir,
|
||
max_images=max_images,
|
||
)
|
||
all_imgs.extend(result["downloaded"])
|
||
all_map.update(result["image_map"])
|
||
images = all_imgs
|
||
image_map = all_map
|
||
|
||
# 截图 canvas(仅首页)
|
||
if pages_data:
|
||
canvases = self.capture_canvas(
|
||
pages_data[0]["url"], output_dir=media_dir,
|
||
dismiss_cookies=dismiss_cookies,
|
||
)
|
||
except Exception as e:
|
||
print(f"[BrowserScraper] fetch_full_article 失败: {e}", file=sys.stderr)
|
||
|
||
return {
|
||
"url": url,
|
||
"pages": pages_data,
|
||
"images": images,
|
||
"image_map": image_map,
|
||
"canvases": canvases,
|
||
"media_dir": media_dir,
|
||
"elapsed_seconds": round(time.time() - start_time, 2),
|
||
}
|
||
|
||
|
||
def _strip_dynamic_markers(html: str) -> str:
|
||
"""去掉动态时间戳/计数器,便于翻页停止判定。"""
|
||
# 去掉 <script> 内的动态内容
|
||
html = re.sub(r"<script[\s\S]*?</script>", "", html, flags=re.IGNORECASE)
|
||
# 去掉常见时间戳
|
||
html = re.sub(r"\b\d{10,13}\b", "TS", html)
|
||
html = re.sub(r"\b\d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2}:\d{2}", "DT", html)
|
||
return html
|
||
|
||
|
||
def _guess_image_ext(url: str) -> str:
|
||
"""从 URL 推断图片扩展名。"""
|
||
path = urlparse(url).path.lower()
|
||
for ext in (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"):
|
||
if path.endswith(ext):
|
||
return ".jpg" if ext == ".jpeg" else ext
|
||
return ".jpg" # 默认
|
||
|
||
|
||
# 把上述函数挂到 BrowserScraper 类
|
||
BrowserScraper.paginate = _paginate
|
||
BrowserScraper.extract_links = _extract_links
|
||
BrowserScraper.follow_links = _follow_links
|
||
BrowserScraper.download_images = _download_images
|
||
BrowserScraper.capture_canvas = _capture_canvas
|
||
BrowserScraper.fetch_full_article = _fetch_full_article
|
||
|
||
|
||
# ── CLI ──────────────────────────────────────────────────────
|
||
|
||
def main():
|
||
if not PLAYWRIGHT_AVAILABLE:
|
||
print(INSTALL_HINT)
|
||
return
|
||
|
||
import argparse
|
||
ap = argparse.ArgumentParser(description="浏览器自动化爬虫")
|
||
ap.add_argument("url", help="目标网址")
|
||
ap.add_argument("--visible", action="store_true", help="可见窗口模式")
|
||
ap.add_argument("--screenshot", action="store_true", help="截图保存")
|
||
ap.add_argument("--scroll", action="store_true", help="自动滚动加载")
|
||
ap.add_argument("--selector", help="提取指定 CSS 选择器的文本")
|
||
ap.add_argument("--wait", help="等待该选择器出现后再提取")
|
||
ap.add_argument("--output", help="HTML 输出文件路径")
|
||
args = ap.parse_args()
|
||
|
||
with BrowserScraper(headless=not args.visible) as bs:
|
||
if args.screenshot:
|
||
path = bs.screenshot(args.url)
|
||
print(f"截图: {path}")
|
||
|
||
if args.selector:
|
||
text = bs.extract_text(args.url, selector=args.selector,
|
||
wait_selector=args.wait,
|
||
scroll=args.scroll)
|
||
if text:
|
||
print(text[:2000])
|
||
else:
|
||
html = bs.fetch(args.url, wait_selector=args.wait,
|
||
scroll=args.scroll)
|
||
if html:
|
||
if args.output:
|
||
Path(args.output).write_text(html, encoding="utf-8")
|
||
print(f"已保存: {args.output} ({len(html)} 字符)")
|
||
else:
|
||
print(f"成功: {len(html)} 字符")
|
||
# 提取 title
|
||
m = re.search(r"<title[^>]*>(.*?)</title>", html,
|
||
re.IGNORECASE | re.DOTALL)
|
||
if m:
|
||
print(f"标题: {m.group(1).strip()[:100]}")
|
||
else:
|
||
print("抓取失败")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|