爬药监局数据查询页的那些坑以及应对策略

1,100 阅读6分钟

现实需求

药监局数据查询 非常有用,比如可以据此核对执业药师、核对药店、核对药品;但依靠人工查询很麻烦,也难以嵌入业务系统做自动化; 苦逼打工人受命从该站点爬数据、封装接口,下面分享一下我被埋的经历以及解决方案。

反爬措施

无限debugger

该站点会主动检测客户端是否打开了devtools(比如利用js代码执行的时间差检测),然后向js里无限插入debugger,阻止调试,也就很难拿到接口;

虽然有办法绕过去,但很烦,并且还会碰到加密js;而且该站点毕竟是政府网站,我们遵纪守法,不让看那就不看。

模拟器检测

该站点会检测模拟器(比如selenium),即使用非headless模式,修改user-agent等,也会立即被检出,直接白屏;

python+selenium 方案我试过几次,很快就白屏,难搞。

客户端行为检测

该站点会在客户端写入IndexDB,猜测用于记录用户行为,触发某个阈值后,直接在Cookie里写入标记,该标记会影响ajax的接口数据返回(直接固定为同一个返回值)

我在这里被埋过,列表页翻页翻到100页,呈现的还是第一页的数据,(꒦_꒦)

随机空白数据

药品详情页,10次里有1-3次,界面上表格呈现出来,但是数据是空的,如图所示

空数据陷阱.jpg

最开始没注意 爬下来的数据大量空白,哎!

IP频次限制

这是服务端做的做的,每分钟打开10个页面基本上就触发了,大多会返回空白页,有时候直接禁止访问;

这个没招,只能换IP

应对措施

  1. playwright 手搓一个RPA,模拟人工操作,打开Edge,一步步操作网页
  2. 抹去playwright痕迹,防止站点通过PerformanceAPI检测到本地调试端口打开
  3. Edge上安装Proxy插件,挂上IP池,每分钟换IP
  4. 定时清空该站点的全部本地存储(IndexDB/LocalStorage/Cookie)
  5. 检测页面字段是否空白,如是,则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痕迹

  1. 理论上可以通过Edge的启动参数(--remote-debugging-port)判定Edge开启了调试模式,但站点JS没有权限拿到这个启动参数,所以不做处理;

  2. 站点可以通过调用 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)在页面加载后注入
  3. 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应对服务端限制

  1. IP池我用的快代理的隧道代理服务,每分钟换一次IP,一小时5-6元
  2. Edge上安装浏览器插件 SmartProxy 配置上隧道代理的地址、端口、用户名、密码,模式这位始终开启
  3. 脚本运行中,遇到白屏或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就好了。