ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Playwright实战:定位span元素并自动下载资源,彻底搞懂class含义

Playwright实战:定位span元素并自动下载资源,彻底搞懂class含义 元旦前我在整理节日素材看到某个页面上写着“新年快乐·简约金线小马封面免费领取”第一反应是直接右键保存那张封面图。但等我打开浏览器开发者工具往下翻才发现问题没这么简单这行标题不是普通文本而是被放在了一个span classjs_title_inner标签里旁边的按钮、下载链接和文案全被各种动态节点包住。如果只靠肉眼复制粘贴处理一两个素材还行想批量收集就得写脚本。而脚本里第一个绕不开的动作就是稳定定位到这个带 class 的 span 元素。这场景其实挺典型。很多人搜“playwright 定位span”“python中class函数的用法”本质都是在处理网页元素时被“class”这个词搞混了网页里有 class 属性Python 里有 class 关键字Java 报错里又有“class not registered”三个东西长得像实际完全是两码事。我这次就以这个“金线小马封面领取页”为例把从定位 span、理解 class到最终自动下载资源的完整链路拆开讲一遍顺便把过程中踩过的坑和常见报错整理成速查思路省得你以后在类似素材页上浪费时间。如果你是刚开始接触浏览器自动化的新手这一篇能帮你搞懂 Playwright 最常用的几种定位写法和页面结构判断如果你已经写过一些脚本后面“常见问题与排查实录”那部分可能会更有参考价值。整个项目没有用什么高深技术就是一个能落到实处的免费资源采集小工具前提是页面标注了免费授权。1. 先把那段HTML拆开看span、class 和 js_title_inner1.1 为什么资源标题会藏在一个 span 里先看这个标题的原始结构实际网页里就是类似这样span classjs_title_inner[特殊字符]新年快乐·简约金线小马封面免费领取/spanHTML 里span是一个行内元素意思是它不会自己换行通常用来包裹一段文字、一个数字、或者某个需要单独控制的文本节点。和div这种块级元素相比span 本身没有任何默认样式纯粹是一个“文本容器”。很多内容管理系统、前端模板和资源站都会用 span 来包裹标题这种短文本为的是给前端 JavaScript 或 CSS 一个精准的挂钩点。class 属性就是那个“挂钩点”。一个元素可以在 class 里写一个或多个类名比如classtitle js_title_inner。在 CSS 里我们通过.js_title_inner选中它在 JavaScript 里通过document.getElementsByClassName(js_title_inner)选中它在 Playwright 里也一样几乎无缝迁移。这个页面的开发者特意用js_title_inner这种带js_前缀的类名说明这个元素大概率是被前端脚本控制的也许加载后会把特殊字符动态替换成对应图标也许要监听点击事件。如果你只是想在浏览器里找到这行标题最简单的办法不是翻源码而是在页面上直接右键“检查”。Chrome 的开发者工具会自动帮你定位到那个 span并且显示它的完整路径和 class 名。这比写选择器猜来猜去快得多。但自动化脚本没法每次都手工打开开发者工具所以你得学会把这种人工定位经验转写成代码选择器。1.2 如何在页面上快速找到这个元素定位一个元素核心目标是“唯一、稳定”。看到span.js_title_inner后第一反应可以先试一个最简单的 CSS 选择器span.js_title_inner这段选择器的意思是找所有span标签里 class 含有js_title_inner的元素。CSS 选择器里标签名直接写class 用点号.开头两者之间没有空格表示同时满足这两个条件。如果页面上只有这一个 span 是这个类名那问题就解决了。但我在实际操作中遇到了两个新情况标题文本里可能包含不可见字符比如 UFEFF 或者零宽空格导致直接匹配文字失败。同一个卡片区域里不止一个js_title_inner可能是标题外层和内层都挂了同一类名。这时候就需要用更精确的“组合定位”。Playwright 里提供了两种非常顺手的写法一种是用 has_text一种是用正则表达式。比如我想找包含“金线小马封面”的那个 spanlocator page.locator(span.js_title_inner, has_text金线小马封面)如果文本里有零宽字符has_text仍然可能匹配到部分内容因为它的默认行为是包含关系而不是完全相等。如果担心开头那段奇怪的[特殊字符]影响判断可以进一步用正则过滤。总之先理解元素在 DOM 里的层级关系再动手选择器比直接抄一段长长的 xpath 要可靠得多。2. “class”这个词的三副面孔网页属性、Python关键字和JDBC驱动2.1 HTML class 属性和 Python 中的 class 类不是一回事很多人第一次搜“python中class函数的用法”其实是把 HTML 里的 class 概念带过来了。我刚开始也犯过这个错以为网页元素上的 class 能直接对应到 Python 里的类对象后来才发现两者只是同名。在 Python 里class是一个语法关键字用来定义新的数据类型。比如我想把“素材资源”抽象成一个对象可以写class MaterialItem: def __init__(self, title, download_url): self.title title self.download_url download_url item MaterialItem(金线小马封面, https://example.com/cover.png) print(item.title)这里class定义的MaterialItem是我自己创建的类型跟 HTML 元素上的 class 属性没有任何直接关系。HTML 里的 class 更接近“分类标签”它表示“这个元素属于哪个视觉或逻辑分组”。CSS 负责用这个标签套样式JS 负责用这个标签找节点Python 代码只有通过类似 Playwright、BeautifulSoup 这类工具去解析页面时才会读取这些 class 属性然后把它当作定位条件。所以在写定位代码时你要处理的是“字符串形式的 class 属性值”不是 Python 的类。你别试图在 playright 页面locator里写classjs_title_inner然后就被 Python 中 class 关键字混淆——CSS 和 Playwright 各自的语法已经内置了对 HTML class 的支持不需要你动用 Python 的 class 关键字去“转换”什么。2.2 “class not registered”这类报错是怎样发生的再看另一类网络热词“class not registered. you need the following file to be installed on your mac”和“cant create driver instance (class org.apache.hive.jdbc.hivedriver)”。这两个报错里的 class又变成了“Java 类”的意思。举个例子当你用 Python 连接 Hive 数据库时经常需要引入 JDBC 驱动。如果你写的驱动类名不完整或者大小写写错比如本应是org.apache.hive.jdbc.HiveDriver你写成了org.apache.hive.jdbc.hivedriverJava 运行时就会尝试通过反射加载这个类但找不到于是抛出“cant create driver instance”的错误。解决办法通常是确认 jar 包已经放进项目的 classpath确认驱动类名的大小写完全正确如果是在 macOS 上遇到需要安装某个本地文件通常说明驱动依赖的原生库缺失需要安装对应依赖。这个报错和网页里的 class 属性八杆子打不着。但很有意思的是我在自动化脚本的答疑群里经常看到有人贴这种 Java 报错然后问“是不是因为 span 元素的 class 写错了导致定位不到”实际上完全是两套技术栈。你需要做的是先看报错发生在哪一步如果是浏览器自动化阶段报错通常来自 Playwright、Selenium 或浏览器驱动如果是 JDBC 连接阶段才需要去排查类路径、jar 包和数据库连接参数。2.3 三种 class 的对照速查为了避免以后再被“同名不同义”坑到我整理了一个速查表场景class 含义典型写法常见错误HTML 元素标签的 class 属性用于 CSS/JS 定位span classjs_title_inner写法上误把 class 当 Python 类Python 代码定义类的关键字class MaterialItem:试图用 class 名直接定位网页元素Java/JDBC类的全限定名驱动注册时使用Class.forName(org.apache.hive.jdbc.HiveDriver)类名大小写错误或缺少 jar 包把这三层概念分开后再回去看热搜词里的“python中class函数的用法”这个问题本身也有点问题class在 Python 里是定义类的关键字不是函数正确说法是“类的定义和用法”。当你理解它只是个模板时自然会意识到它和网页元素上的 class 之间需要工具作为桥梁而 Playwright 就是其中一座很方便的桥。3. 用Playwright精准定位span并读取资源标题3.1 为什么选 Playwright 而不是裸写正则对于“从一个 HTML 片段里提取文本”你也可以用 requests 拿页面源码然后用正则搜索js_title_inner再截取内容。这种方法在处理静态页面时能跑通但现在的素材站基本都上了前端渲染、接口异步加载甚至登录后才能看到完整标题。单纯的正则只能匹配到初始 HTML等数据被 JavaScript 填充进去后你拿到的源码里根本找不到对应的文本。我这次选择 Playwright是因为它直接驱动真实浏览器。页面加载完、异步请求完成、DOM 更新稳定后再去定位拿到的是用户真正看到的那一版内容。它跟 Selenium 相比更现代内置了自动等待和重试代码也更简洁降低了很多“等待 3 秒再抓取”这种临时写法的脆弱性。安装也简单pip install playwright playwright install chromium如果你的机器上已经装了 Chrome也可以不下载 Chromium直接把 launch 的 channel 参数设为 Chrome但为了环境一致我通常只用 Playwright 自带的 Chromium。3.2 定位 span 的几种写法和代码示例假设页面结构是消息卡片div classmaterial-card h3 span classjs_title_inner[特殊字符]新年快乐·简约金线小马封面免费领取/span /h3 button classdownload-btn免费领取/button /div我要读取标题文本最简单是from playwright.sync_api import sync_playwright with sync_playwright() as p: browser p.chromium.launch(headlessFalse) page browser.new_page() page.goto(https://example.com/holiday-covers) page.wait_for_selector(span.js_title_inner, timeout10000) title page.locator(span.js_title_inner).text_content() print(标题:, title) browser.close()这里有几个细节值得展开wait_for_selector会一直等到元素出现超时时间是 10 秒。如果不加这一步页面还没渲染完就去取 text_content很可能拿到 None 或者直接报定位超时。text_content()返回的是元素内部文字但也会包含子元素里的所有文本。如果你的 span 里还嵌套了其他标签返回值可能比预期长。使用headlessFalse可以让你亲眼看到浏览器操作过程便于调试。等脚本稳定以后再改成headlessTrue放到服务器上跑。如果你不仅要读取标题还要判断标题是不是免费领取资源可以加一层判断locator page.locator(span.js_title_inner).first text locator.text_content() if 金线小马封面 in text: print(找到目标资源) else: print(不是目标资源)如果页面上有多个 span.js_title_inner而且我只想找“包含金线小马封面”的那个可以用 Playwright 的 has_text 过滤target page.locator( span.js_title_inner, has_text金线小马封面 ) print(target.text_content())这样即使同类名元素出现多次也能精准锁定目标。3.3 定位不到、重复元素等情况的处理实际运行中问题最多的不是选择器语法而是“为什么我写的选择器明明能选中Playwright 却报超时”。我遇到过三种情况第一种是元素在 iframe 里。素材站的弹窗、登录框、或者第三方活动组件经常被塞进 iframe。主页面 DOM 里的选择器自然抓不到 iframe 内容。Playwright 里要用frame_locatorframe page.frame_locator(iframe[nameactivity]) inner frame.locator(span.js_title_inner)第二种是页面一开始只有一个骨架屏js_title_inner要在接口返回后才被创建。这种情况只要把wait_for_selector的超时时间调大或者等待某个接口响应完成就行。粗暴做法是page.wait_for_timeout(3000)但能不写就别写宁可等待元素也不要固定睡三秒。第三种是页面里出现了多个相同 class 的 span比如热门素材列表每张卡片都有这个 class。解决办法也很直接先count()看数量再用nth(i)或前面说的has_text筛选。count page.locator(span.js_title_inner).count() print(数量:, count) for i in range(count): text page.locator(span.js_title_inner).nth(i).text_content() print(i, text)把这段核心逻辑跑通以后标题的读取就已经解决了。接下来要做的是顺着这个 span 找到同级的“免费领取”按钮真正把封面下载到本地。4. 从“免费领取”到“下载封面”的完整自动化流程4.1 通用流程设计我把整个自动化领取流程拆成了五步打开素材页面。等待并确认目标 span 出现。从目标 span 向上定位到整个资源卡片容器。在卡片容器内找到“免费领取”按钮或下载入口。点击按钮触发下载并保存文件。这个拆法能应对大多数资源领取页因为标题和按钮通常在一个卡片区域里。如果直接全页面找“免费领取”按钮很容易点到其他不相关的活动入口。先定位标题再回到它的父容器搜索范围就被限制在卡片内部准确率会高很多。假设卡片容器是一个带material-card类的 div。Playwright 里向上找父节点有两个常见写法一种是使用 XPath 的..另一种是用locator(xpath..)。我习惯在 span 的 locator 基础上再相对查找card page.locator( span.js_title_inner, has_text金线小马封面 ).locator(xpath..).locator(xpath..)这个..和..意思是先跳到父级再跳到父级刚才的例子从span跳到h3再跳到.material-card。如果 DOM 层级多这样写会很脆弱。更好的做法是直接使用 CSS 的ancestor定位XPath 支持ancestor::div[contains(class,material-card)]。当然在 Playwright 中也支持page.locator(span.js_title_inner).locator(xpathancestor::div[contains(class, material-card)])虽然稍长一点但层级变了也不容易挂。更推荐的做法是给卡片区域设置一个稳定的 locator然后在其内部寻找 span 和按钮card page.locator(div.material-card).filter( has_text金线小马封面 ) title card.locator(span.js_title_inner).text_content() button card.locator(button:has-text(免费领取))这样语义清晰很多卡片里有什么、目标是什么一目了然。4.2 示例脚本定位标题、找到领取入口并下载下面是一个相对完整的示例脚本用来模拟打开一个免费素材详情页、识别标题、点击领取并保存文件。在实际使用中你只需要替换页面 URL 和按钮文案即可。from playwright.sync_api import sync_playwright import re PAGE_URL https://example.com/holiday-free-cover TITLE_KEYWORD 金线小马封面 with sync_playwright() as p: browser p.chromium.launch(headlessFalse) page browser.new_page(accept_downloadsTrue) page.goto(PAGE_URL, timeout60000) # 1. 等待核心 span 出现 page.wait_for_selector(span.js_title_inner, timeout15000) # 2. 定位包含关键字的卡片 card page.locator(div.material-card).filter( has_textTITLE_KEYWORD ) card.wait_for(statevisible, timeout10000) title card.locator(span.js_title_inner).text_content() print(识别标题:, title) # 3. 点击“免费领取”按钮并监听下载事件 with page.expect_download() as download_info: card.get_by_role(button, name免费领取).click() download download_info.value # Playwright 会把下载文件先放到临时目录需要保存到自己指定的路径 filename re.sub(r[\\/:*?|\n\r\t], _, download.suggested_filename) download.save_as(./downloads/ filename) print(下载完成:, filename) browser.close()运行前需要提前创建downloads目录否则保存时会报错。整个脚本的流程不算复杂但有几个环节容易踩坑download.suggested_filename是浏览器根据服务器返回内容建议的文件名可能包含标题里的特殊字符所以需要先清洗掉文件系统不允许的字符。如果点击后没有触发下载而是新开了一个网页预览那就要考虑是不是需要先点击进入详情页再在详情页里找下载按钮而不是直接在这层点击。有些封面资源是多文件打包比如 zip但只要触发了下载事件处理方式都是一样的。4.3 涉及登录表单时的处理思路与安全提示不是所有免费资源都能匿名领取。很多站点会要求先登录甚至弹出一个 login-formform classlogin-form h2Login/h2 input typetext placeholder用户名 input typepassword placeholder密码 button typesubmit登录/button /form如果你在自动化流程里卡在这一步可以先用 Playwright 填充表单login_form page.locator(form.login-form) login_form.locator(input[typetext]).fill(your-username) login_form.locator(input[typepassword]).fill(your-password) login_form.locator(button[typesubmit]).click() page.wait_for_load_state(networkidle)这里我不建议把用户名密码硬编码在脚本里尤其是项目要提交到 Git 仓库时。可以把账号密码放到环境变量或者单独维护一个本地配置文件。还要慎重处理验证码如果出现图形验证码最好由人工在弹出的浏览器窗口里手动完成一次这比试图用脚本去破解更稳妥也符合平台规则。另外一定要强调自动化领取只适用于页面明确标注免费授权、自己有权使用的素材。不要用这套技术去绕过付费限制、批量抓取付费内容。脚本本身没有善恶使用边界才决定了它是否合规。5. 实操中遇到的五个经典问题与排查方法5.1 定位一直超时问题不在 selector我最开始调试时定位器写法完全没错但wait_for_selector一直报 TimeoutError。后来发现这个 span 并不是在页面初始 HTML 里的而是某个异步接口返回后前端再用 JavaScript 把标题渲染进去。接口响应慢一点元素出现的时间就晚了。解决办法不是无限延长超时而是要判断“元素没出来”和“元素被加载到别的层”是哪种原因。打开浏览器的 Network 面板筛选 XHR 请求看接口是否成功如果接口返回了但页面没渲染可能是前端报错了。此时你可以用 Playwright 截图page.screenshot(pathdebug.png, full_pageTrue)截图之后别只在本地看也要打开浏览器控制台信息。最省事的是使用 Playwright 自带的 trace 录制它能忠实记录整个操作过程、网络请求和控制台报错context browser.new_context() context.tracing.start(screenshotsTrue, snapshotsTrue) # ... 执行操作 context.tracing.stop(pathtrace.zip)生成 trace 后可以直接在 https://trace.playwright.dev/ 打开一步步回放浏览器状态排查效率非常高。5.2 页面有多个同名 span抓错了目标资源列表页特别容易出现这种情况。同一个类名比如js_title_inner几乎每张素材卡片都在用。我只想找画着小马的那张结果打印出来一堆标题或者点击下载时永远点到了第一张。处理办法前面提过用filter(has_text...)先确认唯一目标。如果has_text还不够精准可以换成正则target_card page.locator(div.material-card).filter( has_textre.compile(r金线小马.*封面) )但要注意如果页面做了懒加载某些卡片虽然在页面底部但尚未渲染它的count()是不含这些未加载元素的。你要先往下滚动页面把目标区域加载出来。滚动可以简单执行page.locator(footer).scroll_into_view_if_needed()或者用键盘事件按 End 键滚到底部再往上找。不要指望一次定位就拿到全部内容前端页面不把所有节点放上来自动化脚本需要顺应它的加载机制。5.3 点击没有反应或下载没触发有次我定位到了“免费领取”按钮click()也执行了看起来没有报错但下载目录里什么都没出现。排查后发现页面上其实浮着一个透明的引导遮罩层正好挡住了按钮。Playwright 的点击会自动滚动到元素并判断可点击但如果元素被遮罩覆盖它默认会报错。如果使用了forceTrue则可能跳过遮挡点击到错误的层所以我不建议一上来就 force。正确顺序是检查页面是否有 cookie 弹窗、活动浮层、广告遮罩如果有就点掉。调用button.hover()再调用button.click()有时候能触发页面预备状态。如果还是不行用button.click(forceTrue)前先确认按钮本身确实是可交互的。触发下载时还容易因为accept_downloads没开启而导致点击后下载被浏览器拦截。我在new_context或new_page里要显式声明接受下载context browser.new_context(accept_downloadsTrue) page context.new_page()如果不设置这个参数Playwright 在遇到下载时会默认禁止测试直接中断。5.4 混淆浏览器驱动和数据库驱动的 class 报错再回到热词里那个 Hive JDBC 驱动问题。我在编写这个脚本的间隙顺手想连一下 Hive 里存的历史素材元数据结果在代码里写错了驱动类名Class.forName(org.apache.hive.jdbc.hivedriver);结果运行时报了一整串cant create driver instance (class org.apache.hive.jdbc.hivedriver) ...。问题就出在hivedriver的d小写了正确写法是HiveDriver。Java 里的类名严格区分大小写驱动注册时找不到这个类就会直接失败。遇到这种报错正常排查步骤是先确认hive-jdbc的 jar 包是否在项目依赖里。再确认类名全限定名是否正确可以去 jar 包里查看org/apache/hive/jdbc/目录下到底有哪些 class 文件。最后确认 JDBC URL 是否带上了正确的驱动类参数例如jdbc:hive2://host:10000/default。这个报错和网页元素定位没有任何关系但因为都出现了“class”这个词网络上很容易搜混。我更倾向于把这类问题记为“运行环境/依赖问题”而不是“页面定位问题”。当你看到class not registered时先检查是不是本地缺了什么原生库或驱动包尤其报错还提示you need the following file to be installed on your mac时通常是 ODBC 驱动或本地扩展库没有被系统找到并不需要去动 Playwright 的代码。5.5 代码能用但换台电脑就崩了最后一个问题最让人头疼我在自己电脑上跑得好好的等放到服务器或者另一台 Mac 上莫名其妙就报错。常见原因有这几个另一台机器没安装 Playwright 对应的浏览器需要执行playwright install chromium。下载目录不存在代码里没有用os.makedirs自动创建。系统上缺少动态库尤其是 Linux 服务器上需要额外安装libnss3、libatk等依赖可以执行playwright install-deps一次性补上。环境变量里没有设置账号密码而代码里用了os.getenv导致登录失败。所以我习惯在脚本开头加一段健壮性检查import os from pathlib import Path DOWNLOAD_DIR Path(./downloads) DOWNLOAD_DIR.mkdir(exist_okTrue) username os.getenv(COVER_USERNAME) password os.getenv(COVER_PASSWORD) if not username or not password: raise RuntimeError(请先设置 COVER_USERNAME 和 COVER_PASSWORD)这样至少能把“缺文件、缺环境变量”这种低级错误提前拦截下来。最后分享一个我一直在用的小技巧页面上的文字从来不是“看起来那么简单”。我这次要抓的标题里有“[特殊字符]”这种前缀第一次直接用text_content()打印肉眼看不到差异但拿去做字符串匹配时怎么都对不上。后来用repr()打印文本才发现后面还藏了一个零宽空格。所以现在只要是从网页上读取文本我都会先打个repr()看看原始内容再去写匹配关键词。尤其在处理素材站、活动页这类充斥着特殊符号和动态类名的环境里这一个习惯能帮你省掉很多排查时间。如果你也想批量采集类似免费封面资源建议先别急着写下载先跑通“定位标题 打印文本”这两步确认数据干净了再往后接点击和保存。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进