Files

1051 lines
44 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
网页操作深度增强模块 v7.0.0 (browser_pro.py)
在 browser_scraper.BrowserScraper(Playwright/Chromium,类人化)之上新增四大能力:
1. 网络拦截 / API 挖掘(intercept_network / discover_api)
监听页面发出的 XHR/fetch 响应,自动捕获背后的 JSON 接口(URL/参数/分页规律/
响应样本),沉淀到 data/api_registry.json 供降级链直连复用——对挖掘
东财式"页面背后接口"价值最大,成功后无需浏览器即可高速直连接口。
2. 操作宏录制与回放(MacroPlayer / record_macro)
宏 = JSON 操作序列(goto/click/fill/press/upload/download/drag/extract/…),
支持 {{参数}} 占位符替换,一键重放;record_macro 注入 JS 捕获真实点击/输入
事件生成宏草稿。
3. 文件上传 / 下载 / 拖拽(upload_file / download_file / drag_and_drop /
type_contenteditable / press_keys)
补齐真实网页交互所需的全部动词,并 monkey-patch 到 BrowserScraper。
4. 异步并发标签池(AsyncBrowserPool / async_batch_fetch)
async_playwright + semaphore 多页并发,批量抓取提速;sync BrowserScraper
保持不变,双模式并存。
依赖:与 browser_scraper 相同(可选 Playwright)。缺失时全部优雅降级:
返回 None / 空结果并打印安装提示,不影响其他模块(零 pip 依赖核心不受影响)。
用法:
from scripts.browser_pro import discover_api, play_macro, async_batch_fetch
api = discover_api("https://fund.eastmoney.com/company/") # 挖接口
out = play_macro("macros/demo.json", params={"keyword": "茅台"})
res = async_batch_fetch(["https://a.com", "https://b.com"], concurrency=4)
"""
from __future__ import annotations
import asyncio
import json
import re
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlsplit, parse_qsl, urlencode, urlunsplit
try:
from .browser_scraper import (
BrowserScraper, PLAYWRIGHT_AVAILABLE, INSTALL_HINT,
_get_browser_scraper, AD_BLOCK_DOMAINS, _should_block_url,
)
except ImportError: # 脚本直跑(python scripts/browser_pro.py)
from browser_scraper import ( # type: ignore
BrowserScraper, PLAYWRIGHT_AVAILABLE, INSTALL_HINT,
_get_browser_scraper, AD_BLOCK_DOMAINS, _should_block_url,
)
SKILL_DIR = Path(__file__).resolve().parent.parent
DATA_DIR = SKILL_DIR / "data"
API_REGISTRY_FILE = DATA_DIR / "api_registry.json"
DOWNLOAD_DIR = DATA_DIR / "downloads"
MACRO_DIR = SKILL_DIR / "macros"
DOWNLOAD_DIR.mkdir(exist_ok=True, parents=True)
MACRO_DIR.mkdir(exist_ok=True, parents=True)
MAX_SAMPLE_LEN = 2000 # 响应样本最大保存字符数
MAX_BODY_CAPTURE = 60 # 单页最多捕获的响应条数
# ══════════════════════════════════════════════════════════════
# 一、网络拦截 / API 挖掘
# ══════════════════════════════════════════════════════════════
_NUM_PARAM_RE = re.compile(r"^(page|p|pn|pageIndex|pageNo|page_index|page_no|"
r"currentPage|current|offset|start|skip|from|begin)$", re.I)
def _truncate(obj: Any, limit: int = MAX_SAMPLE_LEN) -> str:
"""JSON 序列化并截断(响应样本持久化用)。"""
try:
s = json.dumps(obj, ensure_ascii=False)
except Exception:
s = str(obj)
return s[:limit] + ("…[截断]" if len(s) > limit else "")
def _normalize_api_url(url: str) -> Tuple[str, Dict[str, str]]:
"""把 URL 拆成 (路径模板, 查询参数),用于同接口归并去重。"""
sp = urlsplit(url)
query = dict(parse_qsl(sp.query, keep_blank_values=True))
return (sp.scheme + "://" + sp.netloc + sp.path), query
def _detect_pagination(entries: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""从捕获的请求里识别分页规律:同路径模板下某数值参数递增。"""
groups: Dict[str, List[Dict[str, Any]]] = {}
for e in entries:
tpl, q = _normalize_api_url(e["url"])
groups.setdefault(tpl, []).append({**e, "_q": q})
findings = []
for tpl, es in groups.items():
if len(es) < 2:
continue
param_names = set()
for e in es:
param_names.update(e["_q"].keys())
for p in param_names:
if not _NUM_PARAM_RE.match(p):
continue
vals = []
ok = True
for e in sorted(es, key=lambda x: x["ts"]):
raw = e["_q"].get(p, "")
try:
vals.append(int(raw))
except (TypeError, ValueError):
ok = False
break
if ok and len(vals) >= 2 and len(set(vals)) >= 2:
step = None
if len(vals) >= 2:
diffs = {vals[i + 1] - vals[i] for i in range(len(vals) - 1)}
step = diffs.pop() if len(diffs) == 1 else None
findings.append({
"url_template": tpl + ("?" if param_names else ""),
"pagination_param": p,
"observed_values": vals[:10],
"step": step,
"times_seen": len(es),
})
break
return findings
class ApiRegistry:
"""API 注册表:挖掘到的接口持久化到 data/api_registry.json,可复用可去重。"""
def __init__(self, path: Path = API_REGISTRY_FILE):
self.path = path
self._doc: Dict[str, Any] = {"_meta": {"version": "7.0.0",
"updated": ""}, "endpoints": []}
self._load()
def _load(self):
if self.path.exists():
try:
doc = json.loads(self.path.read_text(encoding="utf-8"))
if isinstance(doc, dict) and isinstance(doc.get("endpoints"), list):
self._doc = doc
except Exception:
pass
def add(self, endpoint: Dict[str, Any]) -> bool:
"""新增/更新一个端点(按 url+参数名集合去重)。返回是否写入。"""
tpl, q = _normalize_api_url(endpoint.get("url", ""))
key = (tpl, tuple(sorted(q.keys())))
for ep in self._doc["endpoints"]:
etpl, eq = _normalize_api_url(ep.get("url", ""))
if (etpl, tuple(sorted(eq.keys()))) == key:
ep["hit_count"] = ep.get("hit_count", 1) + 1
ep["last_seen"] = endpoint.get("discovered_at", "")
if endpoint.get("sample_response"):
ep["sample_response"] = endpoint["sample_response"]
return False
endpoint.setdefault("hit_count", 1)
self._doc["endpoints"].append(endpoint)
return True
def save(self):
self._doc["_meta"]["updated"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
self._doc["_meta"]["count"] = len(self._doc["endpoints"])
self.path.write_text(json.dumps(self._doc, ensure_ascii=False, indent=1),
encoding="utf-8")
def search(self, keyword: str = "", domain: str = "") -> List[Dict[str, Any]]:
out = []
for ep in self._doc["endpoints"]:
if keyword and keyword.lower() not in json.dumps(ep, ensure_ascii=False).lower():
continue
if domain and domain not in ep.get("url", ""):
continue
out.append(ep)
return out
def _intercept_network(self: BrowserScraper, url: str,
wait_seconds: float = 6.0,
scroll_times: int = 3,
resource_types: Tuple[str, ...] = ("xhr", "fetch"),
url_pattern: str = "",
save_to_registry: bool = True,
block_assets: bool = True,
extra_actions: Optional[List[Dict[str, Any]]] = None,
) -> Optional[Dict[str, Any]]:
"""打开页面并监听网络层,捕获 XHR/fetch 的 JSON 接口。
Args:
url: 目标页面(SPA 优先,页面加载/滚动/交互时发出的接口都能抓到)
wait_seconds: 页面就绪后的继续监听时长(触发懒加载请求)
scroll_times: 滚动次数(触发滚动加载的接口,如 Infinite List)
resource_types: 监听的资源类型(默认 xhr+fetch)
url_pattern: 只保留匹配该正则的请求(空=不过滤)
save_to_registry: 命中接口写入 data/api_registry.json(去重累积)
block_assets: 拦截图片/字体/媒体请求提速
extra_actions: 捕获前执行的操作序列(MacroPlayer 动词,
如 [{"action":"click","selector":".load-more"}])
Returns:
{"url", "captured_count", "apis": [{url,method,status,content_type,
params, sample_response…}], "pagination": [...], "registry_saved": int}
"""
if not PLAYWRIGHT_AVAILABLE:
print(INSTALL_HINT, file=sys.stderr)
return None
captured: List[Dict[str, Any]] = []
pattern_re = re.compile(url_pattern) if url_pattern else None
def on_response(resp):
try:
if len(captured) >= MAX_BODY_CAPTURE:
return
req = resp.request
rt = req.resource_type
if rt not in resource_types:
return
u = resp.url
if pattern_re and not pattern_re.search(u):
return
low = u.lower()
if any(dom in low for dom in AD_BLOCK_DOMAINS):
return
ct = (resp.headers or {}).get("content-type", "")
entry: Dict[str, Any] = {
"url": u, "method": req.method, "status": resp.status,
"resource_type": rt, "content_type": ct, "ts": time.time(),
"params": dict(parse_qsl(urlsplit(u).query, keep_blank_values=True)),
}
if "json" in ct.lower() or u.endswith(".json"):
try:
entry["sample_response"] = _truncate(resp.json())
except Exception:
pass
captured.append(entry)
except Exception:
pass
page = None
try:
page = self.new_page()
if block_assets:
def _route(route):
try:
if route.request.resource_type in ("image", "font", "media"):
return route.abort()
return route.continue_()
except Exception:
pass
page.route("**/*", _route)
page.on("response", on_response)
page.goto(url, wait_until="domcontentloaded", timeout=self.timeout_ms)
try:
page.wait_for_load_state("networkidle", timeout=8000)
except Exception:
pass
# 滚动触发懒加载接口
for _ in range(max(0, scroll_times)):
try:
page.mouse.wheel(0, 1200)
except Exception:
pass
time.sleep(0.8)
# 可选交互(点加载更多/翻页等)再触发一批接口
if extra_actions:
try:
MacroPlayer(scraper=self).play({"steps": extra_actions})
except Exception:
pass
time.sleep(max(0.0, wait_seconds))
pagination = _detect_pagination(captured)
saved = 0
if save_to_registry and captured:
reg = ApiRegistry()
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
domain = urlsplit(url).netloc
for e in captured:
if "sample_response" not in e:
continue
if reg.add({
"url": e["url"], "method": e["method"],
"content_type": e["content_type"], "params": e["params"],
"sample_response": e["sample_response"],
"discovered_from": url, "domain": domain,
"discovered_at": now,
}):
saved += 1
if saved:
reg.save()
# 汇总输出(响应样本可能很长,条目里保留)
return {
"url": url,
"captured_count": len(captured),
"apis": captured,
"pagination": pagination,
"registry_saved": saved,
}
except Exception as e:
print(f"[browser_pro] intercept_network 失败: {e}", file=sys.stderr)
return None
finally:
if page is not None:
try:
page.close()
except Exception:
pass
def discover_api(url: str, wait_seconds: float = 6.0,
url_pattern: str = "", headless: bool = True,
) -> Optional[Dict[str, Any]]:
"""一行代码 API 挖掘(复用进程级单例浏览器)。"""
bs = _get_browser_scraper(headless=headless)
if bs is None:
print(INSTALL_HINT, file=sys.stderr)
return None
return bs.intercept_network(url, wait_seconds=wait_seconds,
url_pattern=url_pattern)
# ══════════════════════════════════════════════════════════════
# 二、文件上传 / 下载 / 拖拽 / 组合键 / 富文本(monkey-patch 到 BrowserScraper)
# ══════════════════════════════════════════════════════════════
def _upload_file(self: BrowserScraper, url: str, selector: str,
file_paths: List[str], wait_after: float = 2.0,
submit_selector: str = "") -> Optional[Dict[str, Any]]:
"""文件上传:打开 url → set_input_files → (可选)点提交,返回提交后 HTML。
Args:
selector: <input type=file> 的 CSS 选择器(可为数组输入 multiple)
file_paths: 本地文件路径列表
submit_selector: 上传按钮选择器(空=不点击)
"""
if not PLAYWRIGHT_AVAILABLE:
print(INSTALL_HINT, file=sys.stderr)
return None
page = None
try:
page = self.new_page()
page.goto(url, wait_until="domcontentloaded", timeout=self.timeout_ms)
paths = [str(Path(p).resolve()) for p in file_paths]
page.set_input_files(selector, paths if len(paths) > 1 else paths[0])
time.sleep(wait_after)
if submit_selector:
page.click(submit_selector)
try:
page.wait_for_load_state("networkidle", timeout=15000)
except Exception:
pass
return {"ok": True, "url": url, "uploaded": len(paths),
"html": page.content()}
except Exception as e:
print(f"[browser_pro] upload_file 失败: {e}", file=sys.stderr)
return None
finally:
if page is not None:
try:
page.close()
except Exception:
pass
def _download_file(self: BrowserScraper, url: str,
click_selector: str = "",
save_dir: str = str(DOWNLOAD_DIR),
filename: str = "", timeout_ms: int = 60000,
) -> Optional[Dict[str, Any]]:
"""文件下载:打开 url → (可选)点击触发下载 → 等待下载完成并落盘。
Args:
click_selector: 触发下载的按钮/链接选择器;为空则直接从 url 导航下载
filename: 保存文件名(空=用服务端建议名)
Returns:
{"ok", "path", "url"};失败返回 None
"""
if not PLAYWRIGHT_AVAILABLE:
print(INSTALL_HINT, file=sys.stderr)
return None
page = None
try:
Path(save_dir).mkdir(exist_ok=True, parents=True)
page = self.new_page()
page.goto(url, wait_until="domcontentloaded", timeout=self.timeout_ms)
with page.expect_download(timeout=timeout_ms) as dl_info:
if click_selector:
page.click(click_selector)
else:
page.evaluate(
"u => { const a = document.createElement('a');"
" a.href = u; document.body.appendChild(a); a.click(); }",
url)
download = dl_info.value
name = filename or download.suggested_filename or "download.bin"
dest = Path(save_dir) / name
download.save_as(str(dest))
return {"ok": True, "path": str(dest), "url": url}
except Exception as e:
print(f"[browser_pro] download_file 失败: {e}", file=sys.stderr)
return None
finally:
if page is not None:
try:
page.close()
except Exception:
pass
def _drag_and_drop(self: BrowserScraper, url: str, source_selector: str,
target_selector: str, humanlike: bool = True,
) -> Optional[Dict[str, Any]]:
"""拖拽元素:source → target(滑块验证/排序/看板场景)。返回操作后 HTML。"""
if not PLAYWRIGHT_AVAILABLE:
print(INSTALL_HINT, file=sys.stderr)
return None
page = None
try:
page = self.new_page()
page.goto(url, wait_until="domcontentloaded", timeout=self.timeout_ms)
if humanlike and hasattr(self, "_human_delay"):
# 类人模式:按下 → 缓慢移动 → 释放(抗行为检测)
src = page.locator(source_selector).first
tgt = page.locator(target_selector).first
sb = src.bounding_box()
tb = tgt.bounding_box()
if sb and tb:
import random as _r
sx = sb["x"] + sb["width"] / 2
sy = sb["y"] + sb["height"] / 2
tx = tb["x"] + tb["width"] / 2
ty = tb["y"] + tb["height"] / 2
page.mouse.move(sx, sy)
if hasattr(self, "_human_delay"):
self._human_delay(0.1, 0.3)
page.mouse.down()
steps = _r.randint(15, 30)
for i in range(1, steps + 1):
page.mouse.move(sx + (tx - sx) * i / steps,
sy + (ty - sy) * i / steps + _r.uniform(-2, 2))
time.sleep(_r.uniform(0.01, 0.05))
page.mouse.up()
else:
page.drag_and_drop(source_selector, target_selector)
time.sleep(0.5)
return {"ok": True, "url": url, "html": page.content()}
except Exception as e:
print(f"[browser_pro] drag_and_drop 失败: {e}", file=sys.stderr)
return None
finally:
if page is not None:
try:
page.close()
except Exception:
pass
def _press_keys(self: BrowserScraper, url: str, keys: str,
selector: str = "") -> Optional[str]:
"""在页面(或先聚焦某元素)按组合键,如 "Control+A"、"Control+C"、
"Enter"、"ArrowDown"。返回操作后 HTML。"""
if not PLAYWRIGHT_AVAILABLE:
print(INSTALL_HINT, file=sys.stderr)
return None
page = None
try:
page = self.new_page()
page.goto(url, wait_until="domcontentloaded", timeout=self.timeout_ms)
if selector:
page.locator(selector).first.click()
time.sleep(0.2)
page.keyboard.press(keys)
time.sleep(0.3)
return page.content()
except Exception as e:
print(f"[browser_pro] press_keys 失败: {e}", file=sys.stderr)
return None
finally:
if page is not None:
try:
page.close()
except Exception:
pass
def _type_contenteditable(self: BrowserScraper, url: str, selector: str,
text: str, submit: bool = False) -> Optional[str]:
"""富文本输入:contenteditable / textarea / 可编辑 div(逐字符带随机延迟)。"""
if not PLAYWRIGHT_AVAILABLE:
print(INSTALL_HINT, file=sys.stderr)
return None
page = None
try:
import random as _r
page = self.new_page()
page.goto(url, wait_until="domcontentloaded", timeout=self.timeout_ms)
loc = page.locator(selector).first
loc.click()
time.sleep(0.2)
for ch in text:
page.keyboard.type(ch, delay=_r.randint(30, 120))
if submit:
page.keyboard.press("Enter")
time.sleep(0.5)
return page.content()
except Exception as e:
print(f"[browser_pro] type_contenteditable 失败: {e}", file=sys.stderr)
return None
finally:
if page is not None:
try:
page.close()
except Exception:
pass
# ══════════════════════════════════════════════════════════════
# 三、操作宏:JSON 序列 → 参数化回放
# ══════════════════════════════════════════════════════════════
_VAR_RE = re.compile(r"\{\{\s*([A-Za-z_][\w]*)\s*\}\}")
def _substitute(obj: Any, params: Dict[str, Any]) -> Any:
"""递归替换 {{var}} 占位符;整串恰为一个占位符时保留原类型。"""
if isinstance(obj, str):
m = _VAR_RE.fullmatch(obj.strip())
if m and m.group(1) in params:
return params[m.group(1)]
return _VAR_RE.sub(lambda mm: str(params.get(mm.group(1), mm.group(0))), obj)
if isinstance(obj, list):
return [_substitute(x, params) for x in obj]
if isinstance(obj, dict):
return {k: _substitute(v, params) for k, v in obj.items()}
return obj
class MacroPlayer:
"""网页操作宏回放器。
宏格式(JSON 文件或 dict):
{
"name": "search-and-extract",
"params": {"keyword": "示例"}, # 默认参数
"steps": [
{"action": "goto", "url": "https://…"},
{"action": "fill", "selector": "#kw", "text": "{{keyword}}"},
{"action": "press", "keys": "Enter"},
{"action": "wait_for_selector", "selector": "#result"},
{"action": "scroll", "direction": "down", "amount": 800},
{"action": "upload", "selector": "input[type=file]",
"files": ["{{csv_path}}"]},
{"action": "download", "click_selector": ".btn-export",
"filename": "out.xlsx"},
{"action": "drag", "source": "#a", "target": "#b"},
{"action": "extract", "as": "html"}, # 存入 outputs.html
{"action": "extract", "selector": "h1", "as": "title"},
{"action": "screenshot", "path": "shot.png"},
{"action": "intercept","as": "api", "wait_seconds": 5}
]
}
"""
def __init__(self, scraper: Optional[BrowserScraper] = None,
headless: bool = True):
self._owns = scraper is None
self.scraper = scraper or _get_browser_scraper(headless=headless)
# ── 宏文件 I/O ──────────────────────────────────────────
@staticmethod
def save_macro(macro: Dict[str, Any], name: str) -> Path:
path = MACRO_DIR / (name if name.endswith(".json") else name + ".json")
path.write_text(json.dumps(macro, ensure_ascii=False, indent=2),
encoding="utf-8")
return path
@staticmethod
def load_macro(name: str) -> Dict[str, Any]:
path = Path(name)
if not path.exists():
path = MACRO_DIR / (name if name.endswith(".json") else name + ".json")
return json.loads(path.read_text(encoding="utf-8"))
@staticmethod
def list_macros() -> List[str]:
return sorted(p.name for p in MACRO_DIR.glob("*.json"))
# ── 回放 ────────────────────────────────────────────────
def play(self, macro: Dict[str, Any],
params: Optional[Dict[str, Any]] = None,
timeout_s: float = 180.0) -> Dict[str, Any]:
"""回放宏。返回 {"ok", "steps_done", "steps_total", "outputs", "error"}。"""
if self.scraper is None:
return {"ok": False, "error": INSTALL_HINT, "steps_done": 0,
"steps_total": 0, "outputs": {}}
merged = dict(macro.get("params") or {})
if params:
merged.update(params)
steps = _substitute(macro.get("steps") or [], merged)
outputs: Dict[str, Any] = {}
page = None
t0 = time.time()
try:
page = self.scraper.new_page()
for i, step in enumerate(steps):
if time.time() - t0 > timeout_s:
raise TimeoutError(f"宏总时长超过 {timeout_s}s")
self._exec(page, step, outputs)
return {"ok": True, "steps_done": len(steps),
"steps_total": len(steps), "outputs": outputs, "error": ""}
except Exception as e:
return {"ok": False, "steps_done": len(outputs.get("_done", [])),
"steps_total": len(steps), "outputs": outputs, "error": str(e)}
finally:
if page is not None:
try:
page.close()
except Exception:
pass
def _exec(self, page, step: Dict[str, Any], outputs: Dict[str, Any]):
act = step.get("action", "")
to_ms = self.scraper.timeout_ms
if act == "goto":
page.goto(step["url"], wait_until=step.get("wait", "domcontentloaded"),
timeout=to_ms)
elif act == "click":
page.click(step["selector"], timeout=step.get("timeout", to_ms))
elif act in ("fill", "type"):
if act == "fill":
page.fill(step["selector"], str(step.get("text", "")))
else:
page.locator(step["selector"]).first.click()
page.keyboard.type(str(step.get("text", "")), delay=45)
elif act == "select":
page.select_option(step["selector"], step["value"])
elif act == "hover":
page.hover(step["selector"])
elif act == "press":
page.keyboard.press(step["keys"])
elif act == "scroll":
amount = int(step.get("amount", 600))
dy = -amount if step.get("direction") == "up" else amount
page.mouse.wheel(0, dy)
elif act == "wait":
time.sleep(float(step.get("seconds", 1)))
elif act == "wait_for_selector":
page.wait_for_selector(step["selector"],
timeout=step.get("timeout", to_ms))
elif act == "upload":
files = step.get("files") or []
files = [str(Path(f).resolve()) for f in files]
page.set_input_files(step["selector"],
files if len(files) > 1 else (files[0] if files else ""))
elif act == "download":
with page.expect_download(timeout=step.get("timeout", 60000)) as dl:
page.click(step["click_selector"])
name = step.get("filename") or dl.value.suggested_filename or "download.bin"
dest = Path(step.get("save_dir", str(DOWNLOAD_DIR))) / name
dest.parent.mkdir(exist_ok=True, parents=True)
dl.value.save_as(str(dest))
if step.get("as"):
outputs[step["as"]] = str(dest)
elif act == "drag":
page.drag_and_drop(step["source"], step["target"])
elif act == "extract":
key = step.get("as") or f"extract_{len(outputs)}"
if step.get("selector"):
el = page.locator(step["selector"]).first
outputs[key] = el.text_content() if not step.get("attribute") \
else el.get_attribute(step["attribute"])
else:
outputs[key] = page.content()
elif act == "screenshot":
path = step.get("path") or str(
DATA_DIR / "screenshots" /
f"macro_{datetime.now():%Y%m%d_%H%M%S}.png")
page.screenshot(path=path, full_page=bool(step.get("full_page", True)))
if step.get("as"):
outputs[step["as"]] = path
elif act == "intercept":
# 在当前页面临时挂监听再触发后续(简化:滚动 + 等待收集)
found: List[Dict[str, Any]] = []
def _on_resp(resp):
try:
if resp.request.resource_type in ("xhr", "fetch"):
found.append({"url": resp.url, "status": resp.status,
"method": resp.request.method})
except Exception:
pass
page.on("response", _on_resp)
for _ in range(int(step.get("scroll_times", 2))):
page.mouse.wheel(0, 1000)
time.sleep(0.6)
time.sleep(float(step.get("wait_seconds", 4)))
page.remove_listener("response", _on_resp)
outputs[step.get("as") or "api"] = found
else:
raise ValueError(f"未知宏动作: {act}")
outputs.setdefault("_done", []).append(act)
def play_macro(name_or_macro, params: Optional[Dict[str, Any]] = None,
headless: bool = True) -> Dict[str, Any]:
"""一行代码回放宏文件(macros/ 下名字或路径,或直接传 dict)。"""
macro = MacroPlayer.load_macro(name_or_macro) if isinstance(name_or_macro, str) \
else name_or_macro
return MacroPlayer(headless=headless).play(macro, params=params)
# ── 宏录制(注入 JS 捕获真实交互,草稿生成)───────────────────
_RECORD_JS = r"""
(() => {
if (window.__v7_recording) return;
window.__v7_recording = true;
window.__v7_macro_log = [];
const sel = (el) => {
if (!el || !el.tagName) return '';
if (el.id) return '#' + el.id;
const name = el.getAttribute('name');
if (name) return `${el.tagName.toLowerCase()}[name="${name}"]`;
const cls = (el.className && typeof el.className === 'string')
? el.className.trim().split(/\s+/).slice(0, 2).map(c => '.' + c).join('')
: '';
const base = el.tagName.toLowerCase() + cls;
const sibs = Array.from(el.parentElement ? el.parentElement.children : []);
return sibs.length > 1 ? base + `:nth-child(${sibs.indexOf(el) + 1})` : base;
};
document.addEventListener('click', (ev) => {
window.__v7_macro_log.push({action: 'click', selector: sel(ev.target), ts: Date.now()});
}, true);
document.addEventListener('change', (ev) => {
const t = ev.target;
if (!t) return;
if (t.tagName === 'SELECT') {
window.__v7_macro_log.push({action: 'select', selector: sel(t), value: t.value, ts: Date.now()});
} else if (t.type === 'file') {
window.__v7_macro_log.push({action: 'upload', selector: sel(t), files: ['{{file}}'], ts: Date.now()});
}
}, true);
document.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') {
window.__v7_macro_log.push({action: 'press', keys: 'Enter', ts: Date.now()});
}
}, true);
let lastInput = 0;
document.addEventListener('input', (ev) => {
const t = ev.target;
if (!t || (t.tagName !== 'INPUT' && t.tagName !== 'TEXTAREA' && !t.isContentEditable)) return;
const now = Date.now();
if (now - lastInput < 900) return; // 合并连续输入
lastInput = now;
window.__v7_macro_log.push({action: 'fill', selector: sel(t), text: '{{input}}', ts: now});
}, true);
})();
"""
def record_macro(url: str, record_seconds: float = 20.0,
headless: bool = False,
name: str = "") -> Optional[Dict[str, Any]]:
"""打开页面并录制真实交互(可见模式),生成可编辑的宏草稿。
录制 = 注入事件监听(click/fill/select/upload/Enter)→ 观察期结束后
收集 window.__v7_macro_log → 转为 MacroPlayer 宏格式(未识别的步骤用
占位参数 {{input}}/{{file}},回放前人工补全)。
Returns:
{"macro": {...}, "saved_to": path};失败返回 None
"""
if not PLAYWRIGHT_AVAILABLE:
print(INSTALL_HINT, file=sys.stderr)
return None
bs = None
page = None
try:
bs = BrowserScraper(headless=headless)
page = bs.new_page()
page.goto(url, wait_until="domcontentloaded")
page.evaluate(_RECORD_JS)
print(f"[browser_pro] 录制中… 请在浏览器里操作({record_seconds}s 后自动收集)")
time.sleep(max(3.0, record_seconds))
raw = page.evaluate("window.__v7_macro_log || []")
steps = [{"action": "goto", "url": url}]
for r in raw or []:
steps.append({k: v for k, v in r.items() if k != "ts"})
macro = {"name": name or f"recorded_{datetime.now():%Y%m%d_%H%M%S}",
"description": "record_macro 自动生成草稿,回放前请核对选择器与参数",
"params": {"input": "示例文本", "file": "C:/path/to/file"},
"steps": steps}
path = MacroPlayer.save_macro(macro, macro["name"])
return {"macro": macro, "saved_to": str(path)}
except Exception as e:
print(f"[browser_pro] record_macro 失败: {e}", file=sys.stderr)
return None
finally:
if page is not None:
try:
page.close()
except Exception:
pass
if bs is not None:
try:
bs.stop()
except Exception:
pass
# ══════════════════════════════════════════════════════════════
# 四、异步并发标签池(async_playwright)
# ══════════════════════════════════════════════════════════════
try:
from playwright.async_api import async_playwright
ASYNC_PLAYWRIGHT_AVAILABLE = True
except ImportError:
ASYNC_PLAYWRIGHT_AVAILABLE = False
class AsyncBrowserPool:
"""异步并发标签池:一个浏览器 + semaphore 控制并发页,批量抓取提速。
用法(同步调用方):
AsyncBrowserPool(concurrency=6).run(urls) # → [(url, html|None), …]
异步调用方:
async with AsyncBrowserPool(concurrency=6) as pool:
results = await pool.fetch_many(urls)
"""
def __init__(self, concurrency: int = 4, headless: bool = True,
timeout_ms: int = 30000, proxy: Optional[Any] = None):
if not ASYNC_PLAYWRIGHT_AVAILABLE:
raise ImportError(INSTALL_HINT)
self.concurrency = max(1, concurrency)
self.headless = headless
self.timeout_ms = timeout_ms
self.proxy = proxy
self._pw = None
self._browser = None
self._context = None
self._sem: Optional[asyncio.Semaphore] = None
async def start(self):
if self._browser is not None:
return
self._pw = await async_playwright().start()
kwargs = dict(headless=self.headless, args=[
"--disable-blink-features=AutomationControlled",
"--no-sandbox", "--disable-dev-shm-usage"])
if self.proxy:
from browser_scraper import BrowserScraper as _BS # 复用解析逻辑
kwargs["proxy"] = _BS._resolve_proxy(self.proxy)
self._browser = await self._pw.chromium.launch(**kwargs)
self._context = await self._browser.new_context(
viewport={"width": 1920, "height": 1080},
user_agent=("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/133.0.0.0 Safari/537.36"),
locale="zh-CN", timezone_id="Asia/Shanghai")
self._sem = asyncio.Semaphore(self.concurrency)
async def stop(self):
for obj, closer in ((self._context, "close"), (self._browser, "close"),
(self._pw, "stop")):
if obj is not None:
try:
await getattr(obj, closer)()
except Exception:
pass
self._context = self._browser = self._pw = None
async def __aenter__(self):
await self.start()
return self
async def __aexit__(self, *args):
await self.stop()
async def fetch(self, url: str, wait_until: str = "domcontentloaded",
wait_seconds: float = 0.0,
scroll_times: int = 0) -> Tuple[str, Optional[str]]:
"""抓单个 URL(受 semaphore 约束)。"""
assert self._sem is not None, "先 await pool.start()"
async with self._sem:
page = await self._context.new_page()
try:
page.set_default_timeout(self.timeout_ms)
await page.goto(url, wait_until=wait_until,
timeout=self.timeout_ms)
for _ in range(max(0, scroll_times)):
await page.mouse.wheel(0, 1200)
await asyncio.sleep(0.5)
if wait_seconds > 0:
await asyncio.sleep(wait_seconds)
return url, await page.content()
except Exception as e:
print(f"[AsyncBrowserPool] {url} 失败: {e}", file=sys.stderr)
return url, None
finally:
try:
await page.close()
except Exception:
pass
async def fetch_many(self, urls: List[str],
**kw) -> List[Tuple[str, Optional[str]]]:
"""并发抓取多个 URL,按输入序返回。"""
await self.start()
return list(await asyncio.gather(*(self.fetch(u, **kw) for u in urls)))
def run(self, urls: List[str], **kw) -> List[Tuple[str, Optional[str]]]:
"""同步入口:自建事件循环跑完即关(调用方无 loop 时用这个)。"""
async def _main():
async with self:
return await self.fetch_many(urls, **kw)
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(_main())
# 已在异步上下文里被同步调用:另起线程跑,避免死锁
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
return ex.submit(asyncio.run, _main()).result()
def async_batch_fetch(urls: List[str], concurrency: int = 4,
headless: bool = True, **kw) -> Optional[List[Tuple[str, Optional[str]]]]:
"""一行代码异步并发批量抓取。Playwright 缺失返回 None。"""
if not ASYNC_PLAYWRIGHT_AVAILABLE:
print(INSTALL_HINT, file=sys.stderr)
return None
return AsyncBrowserPool(concurrency=concurrency, headless=headless).run(urls, **kw)
# ══ v7.3.0 浏览器侧智能内容识别 ══════════════════════════════
def smart_page_extract(url: str, humanlike: bool = False,
headless: bool = True) -> Optional[Dict[str, Any]]:
"""浏览器打开页面 + 内容识别引擎,返回结构化识别结果。
适用:动态渲染列表页/行情数据页的“识别 + 结构化”一步到位。
Playwright 缺失或页面打不开时返回 None(调用方可降级到 HTTP+识别)。
"""
try:
from .web_recognizer import smart_extract
except ImportError:
try:
from web_recognizer import smart_extract # type: ignore
except ImportError:
return None
html = None
try:
with BrowserScraper(headless=headless) as bs:
if humanlike and hasattr(bs, "humanlike_fetch"):
html = bs.humanlike_fetch(url)
else:
html = bs.fetch(url) if hasattr(bs, "fetch") else None
except Exception as e:
print(f"[smart_page_extract] 浏览器打开失败: {e}", file=sys.stderr)
if not html:
return None
return smart_extract(html, url=url)
# ══════════════════════════════════════════════════════════════
# monkey-patch:把 v7.0.0 新动词挂到 BrowserScraper
# ══════════════════════════════════════════════════════════════
BrowserScraper.intercept_network = _intercept_network
BrowserScraper.upload_file = _upload_file
BrowserScraper.download_file = _download_file
BrowserScraper.drag_and_drop = _drag_and_drop
BrowserScraper.press_keys = _press_keys
BrowserScraper.type_contenteditable = _type_contenteditable
def _selfcheck() -> int:
"""离线自检:宏参数替换 / API 归并 / 分页识别。"""
ok = 0
sub = _substitute({"a": "{{x}}", "b": ["{{x}}", "k{{y}}"], "c": 1},
{"x": 42, "y": "v"})
assert sub["a"] == 42 and sub["b"][0] == 42 and sub["b"][1] == "kv" and sub["c"] == 1
ok += 1
pag = _detect_pagination([
{"url": "https://a.com/api?pn=1&size=20", "ts": 1},
{"url": "https://a.com/api?pn=2&size=20", "ts": 2},
{"url": "https://a.com/api?pn=3&size=20", "ts": 3},
])
assert pag and pag[0]["pagination_param"] == "pn" and pag[0]["step"] == 1
ok += 1
reg = ApiRegistry(DATA_DIR / "_selftest_api_registry.json")
reg.add({"url": "https://a.com/api?pn=1", "sample_response": "{}",
"discovered_at": "now"})
assert not reg.add({"url": "https://a.com/api?pn=2", "sample_response": "{}",
"discovered_at": "now2"})
reg.path.unlink(missing_ok=True)
ok += 1
print(f"browser_pro selfcheck: {ok}/3 OK")
return ok
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser(description="v7.0.0 网页操作深度增强")
ap.add_argument("url", nargs="?", help="目标网址")
ap.add_argument("--discover-api", action="store_true", help="网络拦截挖接口")
ap.add_argument("--wait", type=float, default=6.0, help="拦截监听时长")
ap.add_argument("--pattern", default="", help="接口 URL 过滤正则")
ap.add_argument("--record", type=float, default=0, help="录制宏秒数")
ap.add_argument("--macro", default="", help="回放宏文件")
ap.add_argument("--params", default="", help='回放参数 JSON,如 {"k":"v"}')
ap.add_argument("--selfcheck", action="store_true", help="离线自检")
args = ap.parse_args()
if args.selfcheck:
sys.exit(0 if _selfcheck() == 3 else 1)
if args.record and args.url:
r = record_macro(args.url, record_seconds=args.record)
print(json.dumps(r, ensure_ascii=False, indent=2) if r else "录制失败")
elif args.macro:
p = json.loads(args.params) if args.params else {}
print(json.dumps(play_macro(args.macro, params=p), ensure_ascii=False,
default=str)[:3000])
elif args.discover_api and args.url:
r = discover_api(args.url, wait_seconds=args.wait, url_pattern=args.pattern)
if r:
print(f"捕获 {r['captured_count']} 个请求,"
f"分页规律 {len(r['pagination'])} 条,"
f"新入库接口 {r['registry_saved']} 个")
for pg in r["pagination"]:
print(" 分页:", pg["url_template"], "参数", pg["pagination_param"],
"步长", pg["step"])
for api in r["apis"][:10]:
print(" 接口:", api["method"], api["status"], api["url"][:120])
else:
print("挖掘失败(Playwright 未安装或页面打不开)")
else:
ap.print_help()