现实需求
药监局数据查询 非常有用,比如可以据此核对执业药师、核对药店、核对药品;但依靠人工查询很麻烦,也难以嵌入业务系统做自动化; 苦逼打工人受命从该站点爬数据、封装接口,下面分享一下我被埋的经历以及解决方案。
反爬措施
无限debugger
该站点会主动检测客户端是否打开了devtools(比如利用js代码执行的时间差检测),然后向js里无限插入debugger,阻止调试,也就很难拿到接口;
虽然有办法绕过去,但很烦,并且还会碰到加密js;而且该站点毕竟是政府网站,我们遵纪守法,不让看那就不看。
模拟器检测
该站点会检测模拟器(比如selenium),即使用非headless模式,修改user-agent等,也会立即被检出,直接白屏;
python+selenium 方案我试过几次,很快就白屏,难搞。
客户端行为检测
该站点会在客户端写入IndexDB,猜测用于记录用户行为,触发某个阈值后,直接在Cookie里写入标记,该标记会影响ajax的接口数据返回(直接固定为同一个返回值)
我在这里被埋过,列表页翻页翻到100页,呈现的还是第一页的数据,(꒦_꒦)
随机空白数据
药品详情页,10次里有1-3次,界面上表格呈现出来,但是数据是空的,如图所示
最开始没注意 爬下来的数据大量空白,哎!
IP频次限制
这是服务端做的做的,每分钟打开10个页面基本上就触发了,大多会返回空白页,有时候直接禁止访问;
这个没招,只能换IP
应对措施
- 用playwright 手搓一个RPA,模拟人工操作,打开Edge,一步步操作网页
- 抹去playwright痕迹,防止站点通过PerformanceAPI检测到本地调试端口打开
- Edge上安装Proxy插件,挂上IP池,每分钟换IP
- 定时清空该站点的全部本地存储(IndexDB/LocalStorage/Cookie)
- 检测页面字段是否空白,如是,则reload
启动Edge并用playwright连接
- 用命令行启动本地安装的Edge,带调试端口(建议不要用默认的9222),该端口同时支持http和websocket协议
// startNewEdge 启动新的 Edge 实例
func startNewEdge(edgePath, port string) (*exec.Cmd, error) {
cmd := exec.Command(edgePath,
"--new-window",
"about:blank",
"--remote-debugging-port="+port,
"--remote-allow-origins=http://127.0.0.1:"+port)
err := cmd.Start()
if err != nil {
return nil, fmt.Errorf("无法启动 Edge 浏览器: %v", err)
}
return cmd, nil
}
- playwright通过CDP连接到Edge的调试端口
// connectToExistingEdge 检查是否有已运行的 Edge 实例并尝试连接
func connectToExistingEdge(pw *playwright.Playwright, port string) (playwright.Browser, bool) {
// 尝试连接到默认调试端口
browser, err := pw.Chromium.ConnectOverCDP("http://127.0.0.1:" + port)
if err == nil {
return browser, true
}
return nil, false
}
- 启动Edge、启动playwright、CDP连接到Edge过程
// 1. 找到系统中 Edge 的可执行文件路径
edgePath := "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe"
// 2. 获取一个可用端口,用于调试
var debugPort int
if port > 0 {
debugPort = port
} else {
var err error
debugPort, err = getValidPort()
if err != nil {
return nil, fmt.Errorf("无法获取可用端口: %v", err)
}
}
// 3. 启动 Playwright
pw, err := playwright.Run()
if err != nil {
return nil, fmt.Errorf("无法启动 Playwright: %v", err)
}
// 4. 尝试连接到已运行的 Edge 实例
browser, found := connectToExistingEdge(pw, fmt.Sprintf("%d", debugPort))
if !found {
// 如果没有找到已运行的 Edge 实例,则关闭所有 Edge 进程并启动一个新实例
err = killEdgeProcesses()
if err != nil {
pw.Stop()
return nil, fmt.Errorf("无法关闭 Edge 进程: %v", err)
}
// 启动新的 Edge 实例
_, err = startNewEdge(edgePath, fmt.Sprintf("%d", debugPort))
if err != nil {
pw.Stop()
return nil, fmt.Errorf("无法启动 Edge 浏览器: %v", err)
}
// 等待片刻,确保浏览器启动
time.Sleep(2 * time.Second)
log.Printf("Edge 浏览器已通过系统命令启动,调试端口: %d\n", debugPort)
// 连接到新启动的 Edge 实例
browser, found = connectToExistingEdge(pw, fmt.Sprintf("%d", debugPort))
if !found {
pw.Stop()
return nil, fmt.Errorf("无法连接到新启动的 Edge 实例")
}
} else {
log.Printf("已找到正在运行的 Edge 实例,调试端口:%d\n", debugPort)
}
抹除playwright痕迹
-
理论上可以通过Edge的启动参数(--remote-debugging-port)判定Edge开启了调试模式,但站点JS没有权限拿到这个启动参数,所以不做处理;
-
站点可以通过调用 Performance API 侦测本地的websocket连接,而playwright与edge间建立的就是websocket连接,此处需要抹除;
- 侦测
// 获取所有资源加载条目 const resources = performance.getEntriesByType('resource'); // 筛选 WebSocket 握手请求(协议为 ws:// 或 wss://) const wsHandshakes = resources.filter(entry => entry.name.startsWith('ws://') || entry.name.startsWith('wss://') ); console.log('WebSocket 握手请求:', wsHandshakes); - 抹除
通过const debugPort = "10086"; const originalGetEntries = performance.getEntries; const originalGetEntriesByType = performance.getEntriesByType; // 过滤 Performance 条目 performance.getEntries = function() { return performance.getEntries().filter(entry => !entry.name.includes("127.0.0.1:" + debugPort) && !entry.name.includes("localhost:" + debugPort) ); }; performance.getEntriesByType = function(type) { return originalGetEntriesByType.call(this, type).filter(entry => !entry.name.includes("127.0.0.1:" + debugPort) && !entry.name.includes("localhost:" + debugPort) ); };playwright.Page.Evaluate(script)在页面加载后注入
- 侦测
-
Edge打开了websocket端口,站点JS可以通过猜端口,尝试连接该端口的方式进行侦测,此处需要拦截;
// 网络请求拦截 page.Route("**/*:*", func(route playwright.Route) { req := route.Request() if strings.Contains(req.URL(), fmt.Sprintf(":%d", port)) { route.Abort() } else { route.Continue() } })
换IP应对服务端限制
- IP池我用的快代理的隧道代理服务,每分钟换一次IP,一小时5-6元
- Edge上安装浏览器插件 SmartProxy 配置上隧道代理的地址、端口、用户名、密码,模式这位始终开启
- 脚本运行中,遇到白屏或forbidden等,多次尝试reload,等代理自动切换IP后就能正常执行了
定期删除站点本地存储,防本地检测
脚本打开读取若干页面后(我一般设置为300页),删除站点本地存储,重启访问站点,通过模拟Esc按键跳过网站的新手指导,继续访问;
func clear_local_storage(edge *PlaywrightEdge) error {
// ---------- 清除所有存储 ----------
page := edge.CurrentPage()
// 1. 清除 localStorage 和 sessionStorage
if _, err := page.Evaluate("localStorage.clear()"); err != nil {
log.Fatalf("清空 localStorage 失败: %v", err)
}
if _, err := page.Evaluate("sessionStorage.clear()"); err != nil {
log.Fatalf("清空 sessionStorage 失败: %v", err)
}
// 2. 清除 Cookies(针对当前域名)
if err := edge.context.ClearCookies(); err != nil {
log.Fatalf("清除 Cookies 失败: %v", err)
}
// 3. 清除 IndexedDB(通过 JavaScript)
if _, err := page.Evaluate(`
async () => {
const databases = await window.indexedDB.databases();
for (const db of databases) {
if (db.name) {
window.indexedDB.deleteDatabase(db.name);
}
}
}
`); err != nil {
log.Fatalf("清除 IndexedDB 失败: %v", err)
}
log.Println("所有存储已清除!")
return nil
}
检查页面数据,结合reload防止空白页,增加容错
对于概率性出现的空白数据页,只能检查关键数据是否呈现,如果没有,则需要对当前页面执行reload,并多次尝试;
func wait_for_detail_display(edge *PlaywrightEdge) (playwright.Locator, error) {
tbody, err := edge.WaitForSelector("table > tbody", 10000)
if err != nil {
time.Sleep(1 * time.Second)
edge.CurrentPage().Reload()
return nil, nil
}
for i := range 5 {
registerNo, err := tbody.Locator("tr:nth-child(1) > td:nth-child(2) > div > div").InnerText()
if err == nil && registerNo != "" {
return tbody, nil
}
time.Sleep(1 * time.Second)
log.Printf("...第 %d 次等待详情页注册证号", i+1)
}
time.Sleep(10 * time.Second)
edge.CurrentPage().Reload()
registerNo, err := tbody.Locator("tr:nth-child(1) > td:nth-child(2) > div > div").InnerText()
if err == nil && registerNo != "" {
return tbody, nil
}
return nil, nil
}
期望
搞这个其实很没意思,但是这个站点的数据非常有用,业务上也非常重要,我们有非常强烈的付费使用的意愿,如果能开放一个OpenAPI就好了。