基于Playwright与多Agent架构的网站行为流自动化还原工程实践
1. 项目概述从“一键还原”到工程化实践最近在折腾自动化测试和爬虫项目时我遇到了一个挺有意思的挑战如何稳定、高效地“克隆”一个目标网站的行为流包括它的页面状态、交互逻辑乃至背后的数据请求。这不仅仅是简单的截图或保存HTML而是要能完整复现用户在网站上的操作路径和状态变化。传统的工具链比如Selenium配合Requests或者单纯的Playwright脚本在面对复杂的SPA单页应用、动态加载、反爬机制时总显得有些力不从心脚本脆弱、维护成本高是常态。于是我开始探索将Playwright与多智能体Multi-Agent的工程思想结合起来构建一套更健壮、更智能的自动化流程。这套流程的目标很明确给定一个目标网站URL系统能自动分析其结构规划操作路径执行交互并最终将整个过程包括中间状态、网络请求、最终渲染完整地“还原”出来形成一个可复现、可审计的报告或脚本。这听起来有点像自动化测试的逆向工程但其应用场景远不止于此比如竞品分析、工作流自动化、甚至为AI训练提供高质量的交互数据。核心的驱动力在于“工程流”。它不是一个简单的脚本而是一套包含角色分工、错误处理、状态管理和质量评估的体系。Playwright提供了强大的浏览器自动化能力而多Agent的架构思想则让我们可以将复杂的“还原”任务分解为分析、决策、执行、验证等多个子任务由不同的“智能体”各司其职协同完成。这大大提升了整个系统的鲁棒性和适应性。2. 核心架构与多Agent角色设计这套工程流的核心在于将“还原网站”这个宏观目标拆解成一系列可被独立管理和执行的微观任务。我们借鉴了软件工程和分布式系统中的Agent概念这里每个Agent并非指必须使用大语言模型驱动的AI智能体而是指一个具有特定职责、能感知环境、做出决策并执行动作的功能模块。它们通过清晰定义的接口和共享状态进行协作。2.1 四大核心Agent的职责解析在我们的设计中主要包含以下四个核心Agent它们构成了一个处理管道Pipeline1. 侦察与分析Agent (Reconnaissance Analysis Agent)这是流程的起点。它的任务是“读懂”目标网站。它接收一个初始URL然后利用Playwright打开页面但并不急于交互。它的工作包括静态结构分析快速扫描页面的DOM树识别关键的结构化元素如导航栏、主内容区、表单、列表等。它会生成一个初步的页面元素地图。动态行为嗅探监听页面加载初期及空闲时触发的网络请求XHR/Fetch分析API端点、请求参数和响应格式。这有助于理解网站的数据获取方式。技术栈推断通过检查全局变量如window.React、window.Vue、特征性的CSS类名或脚本判断网站可能使用的框架React, Vue, Angular等这对后续的元素定位策略有指导意义。初始状态快照保存页面加载后的初始HTML、CSSOM以及完整的网络请求瀑布图。这个快照是后续所有操作的“基线”。实操心得这个Agent的关键是“轻”和“快”。它不应该执行任何可能改变页面状态的操作如点击、滚动。我们通常会将Playwright的page.goto()的waitUntil参数设置为‘networkidle’并配合page.on(‘request’)和page.on(‘response’)事件监听器来捕获网络活动。分析结果会结构化成一份JSON报告传递给下一个Agent。2. 策略规划Agent (Strategy Planning Agent)拿到分析报告后这个Agent负责回答“我们应该如何与这个网站交互”它需要模拟一个真实用户的探索路径。其决策逻辑可以基于规则也可以引入简单的启发式算法或轻量级模型路径生成基于分析报告中的可交互元素按钮、链接、输入框生成一系列可能的操作序列。例如对于一个电商网站典型路径可能是“首页 - 点击某个商品分类 - 滚动浏览商品列表 - 点击进入商品详情页 - 查看商品评价”。风险评估预判某些操作可能带来的风险比如点击一个按钮可能导致弹窗、页面跳转或触发复杂的JS验证。规划时需要准备应对策略如等待弹窗出现后关闭。目标定义明确本次“还原”的深度和广度。是只还原主流程还是需要遍历所有主要标签页策略规划Agent需要根据配置制定一个清晰的、可执行的任务清单。3. 执行与交互Agent (Execution Interaction Agent)这是最“实干”的Agent直接驱动Playwright执行规划好的操作序列。它的核心要求是稳定和容错。健壮的元素定位绝不依赖脆弱的XPath或易变的CSS选择器。优先使用>import { defineConfig, devices } from playwright/test; export default defineConfig({ timeout: 120000, // 单个操作超时设为2分钟给复杂页面足够时间 use: { headless: false, // 开发调试时可设为false生产环境为true viewport: { width: 1920, height: 1080 }, // 固定视口保证一致性 ignoreHTTPSErrors: true, screenshot: only-on-failure, // 协调器会管理自己的截图逻辑这里仅备灾 video: off, // 视频录制开销大通常由我们的状态捕获替代 trace: off, // 同理使用自定义追踪 userAgent: Mozilla/5.0 ... (一个常见的桌面浏览器UA), // 模拟真实用户 }, // 可以配置多个项目来模拟不同浏览器环境 projects: [ { name: chromium, use: { ...devices[Desktop Chrome] } } ], });3.2 侦察分析Agent的深度实现分析Agent的代码核心在于信息收集的全面性和非侵入性。以下是一个简化的示例// src/agents/analyzer/index.ts import { Page, Browser } from playwright; import { AnalysisResult } from ../../core/types; export class AnalyzerAgent { async analyze(page: Page, url: string): PromiseAnalysisResult { const result: AnalysisResult { url, timestamp: new Date().toISOString(), initialDom: , networkActivities: [], interactiveElements: [], techStackHints: [], }; // 1. 导航并等待网络空闲 await page.goto(url, { waitUntil: networkidle, timeout: 60000 }); // 2. 捕获初始DOM清理敏感信息 result.initialDom await this.sanitizeDom(await page.content()); // 3. 监听并记录网络请求在goto之前就开始监听 page.on(request, request { if (this.isRelevantRequest(request)) { result.networkActivities.push({ url: request.url(), method: request.method(), resourceType: request.resourceType(), // 注意不记录postData等可能包含敏感信息的字段 }); } }); // 4. 识别可交互元素使用更稳健的选择器 const buttons await page.locator(button, [rolebutton], a, input, select, textarea).all(); for (const element of buttons.slice(0, 50)) { // 限制数量避免过多 const tag await element.evaluate(el el.tagName.toLowerCase()); const text await element.innerText().catch(() ); const placeholder await element.getAttribute(placeholder); const id await element.getAttribute(id); result.interactiveElements.push({ tag, text: text?.trim(), placeholder, id }); } // 5. 技术栈推断启发式 const techDetect await page.evaluate(() { const hints []; if (window.React) hints.push(React); if (window.Vue) hints.push(Vue); if (window.angular) hints.push(Angular); // 检查特定类名或属性 if (document.querySelector([data-reactroot], [data-reactid])) hints.push(React (by DOM)); return hints; }); result.techStackHints techDetect; return result; } private isRelevantRequest(request: any): boolean { const ignoreTypes [image, stylesheet, font, media]; return !ignoreTypes.includes(request.resourceType()); } private sanitizeDom(html: string): string { // 简易的DOM清理移除脚本、样式、表单值等可根据需要扩展 // 这里可以使用类似cheerio的库进行更精细的操作 return html.replace(/script\b[^]*(?:(?!\/script)[^]*)*\/script/gi, ) .replace(/style\b[^]*(?:(?!\/style)[^]*)*\/style/gi, ); } }3.3 执行交互Agent的容错与状态捕获执行Agent是稳定性的关键。我们必须假设页面环境是不稳定的。// src/agents/executor/index.ts import { Page, Locator } from playwright; import { PlannedAction, StepSnapshot } from ../../core/types; import { Logger } from ../../utils/logger; export class ExecutorAgent { constructor(private page: Page, private logger: Logger) {} async executeAction(action: PlannedAction, stepIndex: number): PromiseStepSnapshot { const snapshot: StepSnapshot { step: stepIndex, action, before: {}, after: {} }; const { selector, actionType, value } action; // 步骤A执行前状态捕获 snapshot.before await this.capturePageState(Step ${stepIndex} - Before); try { const locator this.resolveLocator(selector); await locator.waitFor({ state: visible, timeout: 30000 }); switch (actionType) { case click: await locator.click({ delay: 100 }); // 加入微小延迟模拟真人 break; case fill: await locator.fill(value || ); break; case select: await locator.selectOption(value || ); break; case scroll: await this.page.mouse.wheel(0, parseInt(value || 500)); break; // ... 其他操作类型 } // 步骤B等待操作后的副作用稳定这是避免Flaky测试的核心 await this.waitForStability(); // 步骤C执行后状态捕获 snapshot.after await this.capturePageState(Step ${stepIndex} - After); snapshot.success true; } catch (error) { this.logger.error(Step ${stepIndex} failed: ${error.message}); snapshot.success false; snapshot.error error.message; // 即使失败也尝试捕获失败后的状态 snapshot.after await this.capturePageState(Step ${stepIndex} - Error State); // 根据策略决定是重试、跳过还是终止流程 } return snapshot; } private async capturePageState(label: string) { const state { domHash: , // 可以计算DOM的哈希值用于快速比较 screenshotPath: , networkSinceLastStep: [], }; // 1. 获取并清理DOM const rawHtml await this.page.content(); state.domHash this.computeHash(this.sanitizeDom(rawHtml)); // 2. 截图仅针对视口或特定区域 state.screenshotPath ./outputs/snapshots/${label.replace(/[^a-z0-9]/gi, _)}.png; await this.page.screenshot({ path: state.screenshotPath, fullPage: false }); // 3. 记录此阶段新增的网络请求需要与全局网络监听器配合 // ... 实现略 return state; } private async waitForStability(timeout 10000) { // 实现一个自定义的“稳定”等待例如 // - 等待没有新的网络请求持续1秒 // - 等待某个加载指示器消失 // - 或者简单等待一个固定时间最后手段 await this.page.waitForTimeout(1000); // 基础等待 // 更高级的实现可以监听 networkidle 或特定元素状态 } private resolveLocator(selector: string): Locator { // 智能定位器解析可以支持多种选择器格式 // 例如如果selector以text开头使用 page.getByText if (selector.startsWith(text)) { return this.page.getByText(selector.substring(5)); } // 默认使用CSS选择器 return this.page.locator(selector); } }4. 策略规划Agent的智能决策逻辑规划Agent是这套流程的“大脑”。最简单的实现是基于配置的规则模板。例如针对常见网站类型博客、电商、后台管理系统预定义操作模板。更高级的实现可以引入轻量级的决策逻辑基于元素优先级的探索给“登录按钮”、“搜索框”、“查看更多链接”这类元素赋予更高的操作优先级。广度优先搜索BFS将页面视为图可点击元素是边新页面/状态是节点进行有限深度的遍历。与LLM结合进阶将分析Agent的报告发送给大语言模型如通过API调用GPT-4提示它“作为一个用户请规划探索这个网站的5个关键步骤”。LLM可以生成更接近人类、更语义化的操作序列如“点击右上角的头像然后选择‘个人设置’菜单”。然后我们需要一个翻译层将这些自然语言描述转换回Playwright可执行的定位器和操作指令。// 一个简单的基于规则的规划器示例 // src/agents/planner/rule-based-planner.ts import { AnalysisResult, PlannedAction } from ../../core/types; export class RuleBasedPlanner { generatePlan(analysis: AnalysisResult, config: any): PlannedAction[] { const plan: PlannedAction[] []; const { interactiveElements, url } analysis; // 规则1如果页面有搜索框先执行一次搜索使用配置的关键词 const searchInput interactiveElements.find(el el.tag input (el.placeholder?.toLowerCase().includes(搜索) || el.id?.includes(search))); if (searchInput config.defaultSearchTerm) { plan.push({ selector: input[placeholder*${searchInput.placeholder}], // 简化示例 actionType: fill, value: config.defaultSearchTerm, description: 在搜索框输入“${config.defaultSearchTerm}” }); plan.push({ selector: button[typesubmit], actionType: click, description: 点击搜索按钮 }); } // 规则2点击第一个看起来是主要内容的链接非导航、非页脚 const mainLinks interactiveElements.filter(el el.tag a el.text el.text.length 5); if (mainLinks.length 0) { plan.push({ selector: text${mainLinks[0].text}, actionType: click, description: 点击链接“${mainLinks[0].text}” }); } // 规则3向下滚动以加载更多内容针对无限滚动页面 plan.push({ selector: body, actionType: scroll, value: 2000, description: 向下滚动页面 }); return plan.slice(0, config.maxSteps || 10); // 限制最大步骤数 } }5. 验证、汇编与最终输出所有步骤执行完毕后验证与汇编Agent开始工作。它的任务不仅是打包更是质量守门员。// src/agents/verifier/index.ts import { ExecutionResult, StepSnapshot, FinalReport } from ../../core/types; import * as fs from fs/promises; import * as path from path; export class VerifierAgent { async verifyAndAssemble(result: ExecutionResult, outputDir: string): PromiseFinalReport { const report: FinalReport { summary: { totalSteps: result.snapshots.length, successfulSteps: 0, failedSteps: 0 }, startUrl: result.startUrl, startTime: result.startTime, endTime: new Date().toISOString(), snapshots: [], analysis: result.initialAnalysis, }; for (const snapshot of result.snapshots) { report.snapshots.push({ ...snapshot, // 可以在这里计算前后状态的差异度例如DOM哈希值是否变化 domChanged: snapshot.before.domHash ! snapshot.after?.domHash, }); if (snapshot.success) { report.summary.successfulSteps; } else { report.summary.failedSteps; } } // 生成可读的HTML报告 await this.generateHtmlReport(report, outputDir); // 生成结构化的JSON日志便于其他程序分析 await fs.writeFile( path.join(outputDir, execution-log.json), JSON.stringify(report, null, 2) ); return report; } private async generateHtmlReport(report: FinalReport, outputDir: string) { // 这是一个简化的示例实际可以生成非常丰富的报告 const html !DOCTYPE html html headtitle网站还原报告 - ${report.startUrl}/titlestyle/* 样式省略 *//style/head body h1还原报告/h1 p目标URL: ${report.startUrl}/p p成功率: ${report.summary.successfulSteps}/${report.summary.totalSteps}/p hr ${report.snapshots.map(s div classstep ${s.success ? success : failed} h3步骤 ${s.step}: ${s.action?.description}/h3 p状态: ${s.success ? 成功 : 失败}/p ${s.before.screenshotPath ? img src${path.basename(s.before.screenshotPath)} width400 : } ${s.after?.screenshotPath ? img src${path.basename(s.after.screenshotPath)} width400 : } /div ).join()} /body /html; await fs.writeFile(path.join(outputDir, report.html), html); // 同时需要将截图文件复制到outputDir下 } }6. 常见问题、排查技巧与进阶优化在实际搭建和运行这套工程流时你会遇到不少挑战。以下是我踩过坑后总结的一些核心要点。6.1 元素定位失败稳定性第一生命线这是自动化脚本的头号杀手。我们的执行Agent已经采用了稳健的定位策略但还需要更多防御性编程。问题页面元素加载慢、动态生成、被遮挡、或选择器因前端改动而失效。排查与解决优先使用面向用户的定位器Playwright推荐的page.getByRole(),page.getByText(),page.getByLabel()是首选。它们基于可访问性属性比CSS选择器稳定得多。组合定位不要依赖单一的属性。可以组合角色、文本和邻近元素。例如page.getByRole(button).filter({ hasText: Submit }).and(page.locator(.primary))。自定义测试属性如果目标网站是你可控的强烈建议推动开发团队为关键交互元素添加>// 生成的Playwright脚本示例 import { test, expect } from playwright/test; test(replicated-flow-from-2024-05-27, async ({ page }) { await page.goto(https://target-website.com); await page.getByPlaceholder(搜索商品...).fill(笔记本电脑); await page.getByRole(button, { name: 搜索 }).click(); await page.locator(.product-item:first-child).click(); // ... 更多步骤 });这样你就实现了从“记录”到“生成测试用例”的闭环极大地提升了编写端到端E2E测试的效率。构建这样一套Playwright结合多Agent思想的工程流初期投入确实比写一个直线脚本要大。但当你需要处理数十个、上百个不同结构的网站或者对一个网站进行长期、稳定的监控和回归验证时它的模块化、可维护性和鲁棒性优势就会淋漓尽致地展现出来。它不再是一个脆弱的“脚本”而是一个真正意义上的“工程系统”。

相关新闻

实测10款AI写小说工具|挑选合适的写小说软件不再踩坑

实测10款AI写小说工具|挑选合适的写小说软件不再踩坑

这两年各类AI模型快速普及,身边不少网文作者都会备上多款写小说工具辅助创作。 老实说,长期卡文、坚持日更赶稿的创作者,大多都会尝试小说软件生成器,依靠ai写小说缓解创作压力。但市面上工具水平参差不齐,有的生成文…

2026/10/12 2:05:26 阅读更多 →
新手写小说软件怎么选?实测8款靠谱AI写小说工具

新手写小说软件怎么选?实测8款靠谱AI写小说工具

写小说的谁没崩溃过?脑洞想炸了,人设打磨半天,结果一写就卡文,剧情接不上,写出来的文字还干巴巴的,完全没看点。 写网文这几年,我踩烂了一堆坑,试过各种各样的写小说软件&#xff0…

2026/10/6 10:21:14 阅读更多 →
双向带头循环链表:原理、实现与应用场景

双向带头循环链表:原理、实现与应用场景

1. 双向带头循环链表概述双向带头循环链表是一种特殊的链表结构,它结合了双向链表、带头节点和循环链表的特性。这种数据结构在实际开发中有着广泛的应用场景,特别是在需要频繁进行前后遍历操作的场景下表现优异。我第一次接触这种数据结构是在开发一个音…

2026/10/9 14:34:51 阅读更多 →

最新新闻

物理与动画系统架构深度解析:从帧循环到Transform协同的引擎设计要点

物理与动画系统架构深度解析:从帧循环到Transform协同的引擎设计要点

做引擎这几年,最常被问到的一个问题就是:物理和动画这两个模块放在一起讲,是不是有点强行组CP?其实不是,这俩在帧循环里的位置紧挨着,数据耦合又深,渲染那边等着同一份Transform结果。你拆开看会…

2026/10/12 2:06:09 阅读更多 →
28岁没房没车别焦虑:转行前先搞懂副业与财富自由的底层逻辑

28岁没房没车别焦虑:转行前先搞懂副业与财富自由的底层逻辑

28岁,无车无房,收入一眼望到头,拿着几千块的工资,晚上躺床上刷手机,看到别人晒新房晒婚礼,再想想自己连恋爱都不敢谈,一种说不出的恐慌直接顶到嗓子眼儿。这种日子我太熟悉了,因为我…

2026/10/12 2:06:09 阅读更多 →
给Claude Code接入MCP搜索:告别过期答案,实时联网查资料

给Claude Code接入MCP搜索:告别过期答案,实时联网查资料

我印象最深的一次,是在某次项目重构里要用到一个库的最新接口。Claude Code 三两下就把代码写完了,看起来头头是道,结果一编译直接报错。后来我自己上官网翻文档才发现,这个库在两三周前刚改过一次函数签名,而 Claude …

2026/10/12 2:06:09 阅读更多 →
Ant Design Landing 设计资源页解析:Sketch 源文件、数据模型与前端实现

Ant Design Landing 设计资源页解析:Sketch 源文件、数据模型与前端实现

前端文档 【免费下载链接】ant-design-landing :mountain_bicyclist: Landing Pages of Ant Design System 项目地址: https://gitcode.com/gh_mirrors/antd/ant-design-landing 点击查看 免费下载 Ant Design Landing(ant-design-landing)是…

2026/10/12 2:06:09 阅读更多 →
Apache Beam Python 的 Mean 聚合变换:Globally 与 PerKey 用法及底层实现

Apache Beam Python 的 Mean 聚合变换:Globally 与 PerKey 用法及底层实现

【免费下载链接】beam Apache Beam is a unified programming model for Batch and Streaming data processing. 项目地址: https://gitcode.com/gh_mirrors/beam18/beam 点击查看 免费下载 本文围绕 Apache Beam Python SDK 中计算算术平均值的 Mean 聚合变换展开…

2026/10/12 2:06:09 阅读更多 →
Jenkins 2.346.1 内网离线安装插件:依赖解析与版本匹配实战

Jenkins 2.346.1 内网离线安装插件:依赖解析与版本匹配实战

简介:本资源面向在内网、隔离网等无外网环境中部署Jenkins的运维与DevOps工程师,针对Jenkins 2.346.1无法在线拉取插件的问题,提供一套完整的离线插件安装方案。压缩包共约2000个文件,整体314.4MB,涵盖90个jpi与30个hp…

2026/10/12 2:05:08 阅读更多 →

日新闻

复古胶片颗粒感噪点合成器:Canvas ImageData 像素高斯杂色注入算法

复古胶片颗粒感噪点合成器:Canvas ImageData 像素高斯杂色注入算法

在数码相机、高清显示屏与现代矢量图形技术高度发达的今天,画面可以做到绝对的锐利、平滑与无瑕。然而,当一张秋日手账插画或拍立得照片过于“平整无瑕”时,往往会散发出一种冰冷生硬的“数码塑料感(Digital Plasticity&#xff0…

2026/10/12 0:00:59 阅读更多 →
活字印刷古籍线装排版:Canvas 竖排文字与栏线自适应算法

活字印刷古籍线装排版:Canvas 竖排文字与栏线自适应算法

在现代网页与移动端设计中,横排(Horizontal Layout)早已经成为了绝对的主流。然而,当我们翻开泛黄的线装古籍、宋版木刻诗集,或是欣赏一张茶道雅集的手写便签时,那种**自上而下纵向书写、自右向左逐列铺展&…

2026/10/12 0:00:59 阅读更多 →
周日晚间的“精神松绑减震器”:无压力情绪倾倒箱与温和轻声陪伴

周日晚间的“精神松绑减震器”:无压力情绪倾倒箱与温和轻声陪伴

每到周日的晚上八点到十点,很多人心里都会悄悄亮起一盏警示灯。 在心理学上,这种现象有一个专门的称谓——“周日夜晚焦虑症(Sunday Scaries)”。明天又是周一,闹钟又要重新在七点响彻卧房;脑海里仿佛有一个…

2026/10/12 0:00:59 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/12 0:16:30 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/12 0:16:38 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/12 0:16:43 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/11 10:45:37 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/11 14:36:53 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/11 14:36:54 阅读更多 →