# -*- 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\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'
]+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 中的
// → 下载到 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] = []
# 提取所有
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]:
"""对每个