当前位置: 首页 > news >正文

Chrome插件学习笔记(四)

Chrome插件学习笔记(四)

参考文章:

  • https://developer.chrome.com/docs/extensions/reference/api/devtools/recorder?hl=zh-cn
  • https://pptr.nodejs.cn/

1、行为录制插件

1.1、背景

由于最近在弄ui自动化相关内容,了解到了Chrome Recorder这个工具,初步使用下感觉还蛮不错,但是在后续使用中发现了一些问题。

比如元素选定的时候css选择器选择上会有些问题,例如webpack、vite等打包工具为了让css样式不重复,打包的时候css样式会增加一些随机字符串,这就导致Chrome Recorder录制下来的数据难以回放,最初想法是使用xpath选择器,这样子应该可以保证是唯一的,但是Chrome Recorder并没有提供完整的xpath路径选择器,且后续发现例如弹窗等动态元素会导致元素定位变化,xpath也不能找到元素准确的定位;

1.2、代码实现

manifest.json

manifest.json 中定义了插件名字,所需要的权限等信息

{"name": "Record Extension","description": "Record user actions","version": "0.0.1","manifest_version": 3,"permissions": ["sidePanel","storage","tabs","cookies"],"host_permissions": ["<all_urls>"],"action": {"default_title": "Click to switch side panel"},"background": {"service_worker": "service_worker.js","type": "module"},"content_scripts": [{"matches": ["<all_urls>"],"js": ["data/dictionary.js","content_script.js"],"run_at": "document_start","all_frames": true}],"side_panel": {"default_path": "side_panel/side_panel.html"}
}
service_worker.js

service_worker.js 主要工作是

  • 处理来自 side panel 的 start/stop 指令,转发给对应的 content script
  • 转发来自 content script 的事件给side panel
(() => {console.log("service worker ready!")chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true })// 处理来自 side panel 的 start/stop 指令,转发给对应的 content scriptchrome.runtime.onMessage.addListener(async (msg, sender, sendResponse) => {if ((msg.action === 'start-recording' || msg.action === 'stop-recording') && msg.tabId != null) {// 直接转发给指定 tab 的 content scriptchrome.tabs.sendMessage(msg.tabId, { action: msg.action }, (response) => {// 发送响应给侧边栏sendResponse(response);});return true;}// 转发来自 content script 的事件给 side panelif (msg?.from === 'content' && msg?.type === 'event') {const enriched = {...msg,from: "service-worker",payload: msg.payload};// 广播给 side panelchrome.runtime.sendMessage(enriched);}});
})();
side_panel.html

side_panel.html 定义了 side panel 相关的页面,这里做的很简陋只是三个按钮、提示文案和录制的 json 数据

<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Recorder SidePanel</title><style>body {padding: 10px;font-family: Arial, sans-serif;}.container {text-align: center;}button {padding: 8px 16px;margin: 5px;cursor: pointer;}button:disabled {opacity: 0.5;cursor: not-allowed;}#result {margin-top: 8px;color: #666;}#pageInfo {margin-top: 8px;padding: 8px;background: #f0f8ff;border-radius: 4px;font-size: 12px;text-align: left;}#pageInfo h4 {margin: 0 0 4px 0;color: #333;}#pageInfo div {margin: 2px 0;}#preview {text-align: left;white-space: pre-wrap;margin-top: 8px;overflow: auto;background: #f5f5f5;padding: 8px;border-radius: 4px;font-family: monospace;font-size: 12px;}</style>
</head>
<body><div class="container"><div><button id="startBtn">Start</button><button id="stopBtn" disabled>Stop</button><button id="exportBtn" disabled>Export</button></div><div id="result">点击Start开始录制</div><pre id="preview"></pre></div><script type="module" src="side_panel.js"></script>
</body>
</html>
side_panel.js

side_panel.js 定义了事件的处理

  • 处理三个按钮点击事件
  • 录制结果回显
  • 监听 server worker 发送的事件消息
// 侧边栏中的JavaScript逻辑
(() => {console.log("side panel ready!")const startBtn = document.getElementById('startBtn');const stopBtn = document.getElementById('stopBtn');const exportBtn = document.getElementById('exportBtn');const resultEl = document.getElementById('result');const previewEl = document.getElementById('preview');let recording = {title: "xumeng03 recorder",viewport: {},navigate: {},steps: []};// 显示录制结果function setResult(message) {if (resultEl) resultEl.textContent = message;}// 根据是否正在录制中切换按钮状态function toggleButtons({ isRecording }) {startBtn.disabled = isRecording;stopBtn.disabled = !isRecording;exportBtn.disabled = recording.steps.length === 0;}// 清空录制function resetRecording() {recording.steps = []if (previewEl) previewEl.textContent = '';toggleButtons({ isRecording: false });setResult('已清空录制。');}// 监听来自 service worker 的消息chrome.runtime.onMessage.addListener((msg) => {if (msg?.from === 'service-worker' && msg?.type === 'event') {if (msg.payload.type == 'setViewport&navigate') {recording.viewport = msg.payload.viewport;recording.navigate = msg.payload.navigate;}else {recording.steps.push(msg.payload);}if (previewEl) {previewEl.textContent = JSON.stringify(recording, null, 2);}toggleButtons({ isRecording: !!window.__isRecording });}});// 开始录制async function startRecording() {resetRecording();window.__isRecording = true;toggleButtons({ isRecording: true });try {// 获取当前活动页面的 tabIdconst [tab] = await chrome.tabs.query({ active: true, currentWindow: true });if (!tab?.id) {setResult('无法获取当前页面信息');return;}chrome.runtime.sendMessage({action: 'start-recording',tabId: tab.id});setResult('开始录制用户事件…');} catch (error) {console.error('Start recording error:', error);setResult('开始录制失败:' + (error?.message || String(error)));}}// 停止录制async function stopRecording() {window.__isRecording = false;toggleButtons({ isRecording: false });try {const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });if (tab?.id) {chrome.runtime.sendMessage({action: 'stop-recording',tabId: tab.id});}setResult('已停止录制');} catch (error) {console.error('Stop recording error:', error);setResult('停止录制失败:' + (error?.message || String(error)));}}// 导出录制function exportRecording() {if (!recording.steps.length) {setResult('没有可导出的事件');return;}try {const blob = new Blob([JSON.stringify(recording, null, 2)], { type: 'application/json' });const url = URL.createObjectURL(blob);const a = document.createElement('a');a.href = url;a.download = 'recording.json';a.click();URL.revokeObjectURL(url);setResult('已导出 recording.json');} catch (error) {console.error('Export error:', error);setResult('导出失败:' + (error?.message || String(error)));}}// 监听按钮点击事件startBtn.addEventListener('click', startRecording);stopBtn.addEventListener('click', stopRecording);exportBtn.addEventListener('click', exportRecording);
})();
content_script.js

content_script.js主要记录网页行为

  • click 事件
  • change 事件
  • 元素选择器
    • 属性选择器(推荐!!!)
    • css选择器
    • xpath选择器
  • 发送事件消息给 server worker
  • 监听 server worker 发送的事件消息
(() => {console.log("content script ready!")// 录制状态const state = {isRecording: false,};// 发送事件给 service worker,service worker 会广播给所有扩展页面(如侧边栏)function sendEvent(eventPayload) {chrome.runtime.sendMessage({from: 'content',type: 'event',payload: eventPayload});}// 获取 Viewport 信息function getViewportInfo() {return {type: 'viewport',width: window.innerWidth,height: window.innerHeight,deviceScaleFactor: window.devicePixelRatio || 1,isMobile: false,hasTouch: false,isLandscape: false,};}// 获取 Navigate 信息function getNavigateInfo() {return {type: 'navigate',title: document.title,url: location.href,};}function getFramePath() {// 获取元素在 iframe 中的层级路径const framePath = [];let currentWindow = window;// 从当前元素开始,向上查找 iframe 层级while (currentWindow !== window.top) {// 找到当前 window 对应的 iframe 元素const iframes = currentWindow.parent.document.querySelectorAll('iframe');let iframeElement = null;let iframeIndex = 0;for (let i = 0; i < iframes.length; i++) {if (iframes[i].contentWindow === currentWindow) {iframeElement = iframes[i];iframeIndex = i;break;}}if (iframeElement) {framePath.unshift(iframeIndex);}currentWindow = currentWindow.parent;}return framePath;}function inFrame() {// 检查元素是否在 iframe 中return window !== window.top;}function isUnique(selector) {if (selector.startsWith('xpath///')) {// 处理 XPath 选择器const xpath = selector.replace('xpath/', '');const result = document.evaluate(xpath, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);console.log(selector, result);return result.snapshotLength === 1;} else {// 处理 CSS 选择器return document.querySelectorAll(selector).length === 1;}}function getDataAttributeSelector(element) {let sel = nulllet found = falsewhile (element.parentElement) {if (element.hasAttribute('data-record')) {if (sel) {sel = `${element.tagName.toLowerCase()}[data-record="${element.getAttribute('data-record')}"] >` + sel;} else {sel = `${element.tagName.toLowerCase()}[data-record="${element.getAttribute('data-record')}"]`;}found = truebreak;} else {// 计算元素在同名兄弟中的位置let nth = 1;for (let prev = element.previousElementSibling; prev; prev = prev.previousElementSibling) {if (prev.tagName === element.tagName) {nth++;}}if (nth == 1) {if (sel) {sel = `${element.tagName.toLowerCase()}>` + sel;} else {sel = `${element.tagName.toLowerCase()}`;}} else {if (sel) {sel = `${element.tagName.toLowerCase()}:nth-of-type(${nth}) >` + sel;} else {sel = `${element.tagName.toLowerCase()}:nth-of-type(${nth})`;}}}element = element.parentElement;}return found ? isUnique(sel) ? sel : null : null;}function getInputSelector(element) {const type = element.getAttribute('type');const placeholder = element.getAttribute('placeholder');const name = element.getAttribute('name');const accept = element.getAttribute('accept');const disabled = element.getAttribute('disabled');const readonly = element.getAttribute('readonly');const required = element.getAttribute('required');const min = element.getAttribute('min');const max = element.getAttribute('max');if (type || placeholder || name || accept || disabled || readonly || required || min || max) {const sel = `${element.tagName.toLowerCase()}${type ? `[type="${type}"]` : ''}${placeholder ? `[placeholder="${placeholder}"]` : ''}${name ? `[name="${name}"]` : ''}${accept ? `[accept="${accept}"]` : ''}${disabled ? `[disabled="${disabled}"]` : ''}${readonly ? `[readonly="${readonly}"]` : ''}${required ? `[required="${required}"]` : ''}${min ? `[min="${min}"]` : ''}${max ? `[max="${max}"]` : ''}`;return isUnique(sel) ? sel : null;}// 如果都没有找到,返回 nullreturn null;}function isSubset(subset, superset) {return subset.every(item => superset.includes(item));}function getStandardCss(element) {const classes = element.getAttribute('class');if (classes) {return classes.trim().split(' ').filter(cls => {const cls_parts = cls.split(/[-_]+/);return isSubset(cls_parts, window.__dictionary) ? cls : null;}).join('.');}return null;}function getCssSelector(element) {let sel = nulllet found = falsewhile (element.parentElement) {const cssSelector = getStandardCss(element);const ariaHidden = element.getAttribute('aria-hidden');const cssSel = `${element.tagName.toLowerCase()}${cssSelector ? `.${cssSelector.trim().split(' ').join('.')}` : ''}${ariaHidden ? `[aria-hidden="${ariaHidden}"]` : ''}`;console.log(cssSel, isUnique(cssSel));if (isUnique(cssSel)) {if (sel) {sel = `${cssSel}>` + sel;} else {sel = cssSel;}found = truebreak} else {// 计算元素在同名兄弟中的位置let nth = 1;for (let prev = element.previousElementSibling; prev; prev = prev.previousElementSibling) {if (prev.tagName === element.tagName) {nth++;}}if (nth == 1) {if (sel) {sel = `${cssSel}>` + sel;} else {sel = `${cssSel}`;}} else {if (sel) {sel = `${cssSel}:nth-of-type(${nth}) >` + sel;} else {sel = `${cssSel}:nth-of-type(${nth})`;}}}element = element.parentElement;}return found ? sel : null;}function getTextSelector(element) {const text = element.textContent;if (text && text.trim().length >= 4) {const sel = `xpath///${element.tagName.toLowerCase()}[contains(., '${text.trim()}')]`;return isUnique(sel) ? sel : null;}return null;}function getFullXPathSelector(element) {if (!(element instanceof Element)) return null;const segments = [];for (let node = element; node && node.nodeType === Node.ELEMENT_NODE; node = node.parentElement) {const name = node.localName; // 兼容 svg/mathmlif (!name) break;// 计算在所有同名兄弟中的序号(1-based)let index = 1;for (let prev = node.previousElementSibling; prev; prev = prev.previousElementSibling) {if (prev.localName === name) index++;}if (index == 1) {segments.unshift(`${name}`);}else {segments.unshift(`${name}[${index}]`);}}return 'xpath///' + segments.join('/');}function getSelectors(element) {if (!(element instanceof Element)) return null;const selectors = [];selectors.push(getDataAttributeSelector(element));selectors.push(getCssSelector(element));const tagName = element.tagName.toLowerCase();if (tagName === 'input' || tagName === 'textarea') {selectors.push(getInputSelector(element));} else if (tagName === 'button' || tagName === 'span' || tagName === 'li' || tagName === 'a') {selectors.push(getTextSelector(element));}selectors.push(getFullXPathSelector(element));return selectors.filter((sel) => sel !== null);}function onClick(e) {// console.log('onClick', e);if (!state.isRecording) return;const target = e.target;if (target.tagName.toLowerCase() === 'input' && target.type === 'file') return;sendEvent({type: 'click',selectors: getSelectors(e.target),inFrame: inFrame(),frame: getFramePath()});}function onChange(e) {// console.log('onChange', e);if (!state.isRecording) return;const target = e.target;if (!(target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement)) return;sendEvent({type: 'change',behavior: target instanceof HTMLInputElement ? target.type === 'file' ? 'upload' : 'input' : 'input',value: target.value,selectors: getSelectors(e.target),inFrame: inFrame(),frame: getFramePath()});}function addListeners() {window.addEventListener('click', onClick, true);window.addEventListener('change', onChange, true);}function removeListeners() {window.removeEventListener('click', onClick, true);window.removeEventListener('change', onChange, true);}function start() {if (state.isRecording) return;state.isRecording = true;addListeners();// 仅主 frame 发送初始的 viewport 和 navigate 信息,避免子 frame 重复上报if (window === window.top) {sendEvent({type: 'setViewport&navigate',viewport: getViewportInfo(),navigate: getNavigateInfo()});}}function stop() {if (!state.isRecording) return;state.isRecording = false;removeListeners();}// 监听来自后台的开始/停止录制指令chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {if (msg?.action === 'start-recording') {start();sendResponse({ ok: true });}if (msg?.action === 'stop-recording') {stop();sendResponse({ ok: true });}});
})();
dictionary.js

dictionary.js主要是定义了一些常见的css类名,因为打包工具的随机字符无法直观的判断,所以只能限定css类名允许出现的字符,不过更好的方式其实是使用data-*选择器,css选择器的存在是为了给自己使用的组件库组件无法设定data-*属性且涉及teleport时候使用的

const tags = [// HTML5相关"a", "abbr", "address", "area", "article", "aside", "audio","b", "base", "bdi", "bdo", "blockquote", "body", "br", "button","canvas", "caption", "cite", "code", "col", "colgroup","data", "datalist", "dd", "del", "details", "dfn", "dialog", "div", "dl", "dt","em", "embed","fieldset", "figcaption", "figure", "footer", "form","h1", "h2", "h3", "h4", "h5", "h6", "head", "header", "hr", "html","i", "iframe", "img", "input", "ins","kbd","label", "legend", "li", "link","main", "map", "mark", "meta", "meter","nav", "noscript","object", "ol", "optgroup", "option", "output","p", "param", "picture", "pre", "progress","q","rp", "rt", "ruby","s", "samp", "script", "section", "select", "small", "source", "span", "strong", "style", "sub", "summary", "sup","table", "tbody", "td", "template", "textarea", "tfoot", "th", "thead", "time", "title", "tr", "track","u", "ul","var", "video", "wbr",// SVG相关"svg", "animate", "animateMotion", "animateTransform", "circle", "clipPath", "defs", "desc","ellipse", "feBlend", "feColorMatrix", "feComponentTransfer", "feComposite", "feConvolveMatrix","feDiffuseLighting", "feDisplacementMap", "feDropShadow", "feFlood", "feFuncA", "feFuncB", "feFuncG", "feFuncR","feGaussianBlur", "feImage", "feMerge", "feMergeNode", "feMorphology", "feOffset", "fePointLight","feSpecularLighting", "feSpotLight", "feTile", "feTurbulence","filter", "foreignObject", "g", "image", "line", "linearGradient", "marker", "mask", "metadata", "mpath","path", "pattern", "polygon", "polyline", "radialGradient", "rect", "script", "set", "stop","style", "switch", "symbol", "text", "textPath", "title", "tspan", "use", "view",// Canvas相关"canvas"
];const attributes = [// 全局属性 Global Attributes"accesskey", "autocapitalize", "class", "contenteditable", "contextmenu", "dir","draggable", "hidden", "id", "lang", "spellcheck", "style", "tabindex", "title", "translate",// Data attributes"data-*",// ARIA 属性(辅助无障碍)"aria","activedescendant", "atomic", "autocomplete", "busy", "checked","colcount", "colindex", "colspan", "controls", "current","describedby", "details", "disabled", "errormessage", "expanded","flowto", "haspopup", "hidden", "invalid", "keyshortcuts","label", "labelledby", "level", "live", "modal", "multiline","multiselectable", "orientation", "owns", "placeholder", "posinset","pressed", "readonly", "relevant", "required", "roledescription","rowcount", "rowindex", "rowspan", "selected", "setsize", "sort","valuemax", "valuemin", "valuenow", "valuetext",// 链接相关"href", "target", "download", "hreflang", "rel", "type", "referrerpolicy",// 图片/媒体"src", "srcset", "alt", "width", "height", "sizes", "poster", "preload", "autoplay", "controls", "loop", "muted",// 表单属性"accept", "accept", "charset", "action", "autocomplete", "autofocus", "checked", "dirname","disabled", "enctype", "form", "formaction", "formenctype", "formmethod", "formnovalidate", "formtarget","list", "max", "maxlength", "min", "minlength", "method", "multiple", "name", "novalidate", "pattern", "placeholder","readonly", "required", "selected", "size", "step", "type", "value",// 输入型"inputmode",// 按钮型"onclick", "ondblclick", "oninput", "onchange", "onfocus", "onblur", "onkeydown", "onkeyup", "onkeypress", "onmousedown", "onmouseup",// 元数据"charset", "content", "http-equiv",// 样式/布局"align", "valign", "colspan", "rowspan", "span",// 列表相关"start", "reversed",// 表格属性"cellpadding", "cellspacing", "frame", "rules", "summary", "border",// 模块、外链"integrity", "crossorigin", "nonce", "async", "defer",// 其他"manifest", "ping", "sandbox", "scoped", "shape", "coords",// SVG相关"fill", "stroke", "stroke-width", "viewBox", "cx", "cy", "r", "x", "y", "d", "points",// 只读/框架"readonly", "frameborder", "allow", "allowfullscreen",// 事件相关(HTML 属性,真实 JS 事件名更多,这里只列属性名称)"onabort", "onafterprint", "onbeforeprint", "onbeforeunload", "onerror", "onhashchange", "onload", "onmessage", "onoffline", "ononline","onpagehide", "onpageshow", "onpopstate", "onresize", "onstorage", "onunload","onanimationend", "onanimationiteration", "onanimationstart", "ontransitionend","ontransitioncancel", "ontransitionrun", "ontransitionstart",
];const properties = [// 布局/盒模型"display", "position", "top", "right", "bottom", "left","float", "clear", "z-index", "overflow", "overflow-x", "overflow-y", "clip", "visibility","box-sizing", "width", "height", "min-width", "min-height", "max-width", "max-height",// 边距/填充"margin", "margin-top", "margin-right", "margin-bottom", "margin-left","padding", "padding-top", "padding-right", "padding-bottom", "padding-left",// 边框"border", "border-width", "border-style", "border-color","border-top", "border-right", "border-bottom", "border-left","border-top-width", "border-right-width", "border-bottom-width", "border-left-width","border-top-style", "border-right-style", "border-bottom-style", "border-left-style","border-top-color", "border-right-color", "border-bottom-color", "border-left-color","border-radius", "border-top-left-radius", "border-top-right-radius","border-bottom-left-radius", "border-bottom-right-radius","border-image", "border-image-source", "border-image-slice", "border-image-width", "border-image-outset", "border-image-repeat",// 背景"background", "background-color", "background-image", "background-size", "background-position", "background-repeat", "background-origin", "background-clip", "background-attachment",// 颜色/不透明"color", "opacity",// 字体/文本"font", "font-family", "font-size", "font-weight", "font-style", "font-variant", "font-stretch", "font-size-adjust","line-height", "letter-spacing", "word-spacing", "text-align", "text-decoration", "text-decoration-line", "text-decoration-style", "text-decoration-color", "text-transform", "text-shadow","text-overflow", "white-space", "vertical-align", "direction", "unicode-bidi",// 列表"list-style", "list-style-type", "list-style-position", "list-style-image",// 表格"border-collapse", "border-spacing", "caption-side", "empty-cells", "table-layout", "cell", "row", "thead", "tbody", "tfoot", "tr", "th", "td", "colgroup", "col", "caption", "table",// 游标/可选"cursor", "pointer-events", "resize",// 转换/动画/过渡"transition", "transition-property", "transition-duration", "transition-timing-function", "transition-delay","transform", "transform-origin", "transform-style", "perspective", "perspective-origin","backface-visibility","animation", "animation-name", "animation-duration", "animation-timing-function", "animation-delay", "animation-iteration-count", "animation-direction", "animation-fill-mode", "animation-play-state",// 滤镜/特效"filter", "backdrop-filter", "mix-blend-mode", "isolation",// 阴影"box-shadow",// 多列"column", "columns", "column-count", "column-gap", "column-width", "column-rule", "column-rule-width", "column-rule-style", "column-rule-color",// Flexbox"flex", "flex-direction", "flex-wrap", "flex-flow", "flex-basis", "flex-grow", "flex-shrink","justify-content", "align-items", "align-self", "align-content", "order",// Grid"grid", "grid-area", "grid-template", "grid-template-areas", "grid-template-columns", "grid-template-rows", "grid-column", "grid-column-start", "grid-column-end", "grid-row", "grid-row-start", "grid-row-end","grid-gap", "column-gap", "row-gap", "gap", "place-items", "place-content", "place-self",// CSS变量/自定义属性"--*",// 响应式"@media", "@supports", "@container", "@keyframes", "@font-face",// SVG相关"stroke", "stroke-width", "fill",// 其他"outline", "outline-width", "outline-style", "outline-color", "outline-offset","box-decoration-break", "clip-path", "will-change", "object-fit", "object-position","user-select", "caret-color", "writing-mode", "scroll-behavior", "scroll-snap-type", "scroll-snap-align","tab-size", "touch-action", "word-break", "overflow-wrap", "break-word","zoom", "page-break-before", "page-break-after", "page-break-inside",// webkit, moz, ms 前缀属性(仅列名头,实际使用要带前缀)"-webkit-", "-moz-", "-ms-", "-o-",// 其它新增/实验/较少用到属性"content", "quotes", "counter-increment", "counter-reset",
];const colors = [// 基础Web(HTML/CSS)色名"black", "silver", "gray", "white", "maroon", "red", "purple", "fuchsia", "green", "lime", "olive", "yellow","navy", "blue", "teal", "aqua",// 扩展 X11/标准 CSS3/4 色名"orange", "aliceblue", "antiquewhite", "aquamarine", "azure", "beige", "bisque", "blanchedalmond", "blueviolet", "brown","burlywood", "cadetblue", "chartreuse", "chocolate", "coral", "cornflowerblue", "cornsilk", "crimson","cyan", "darkblue", "darkcyan", "darkgoldenrod", "darkgray", "darkgreen", "darkgrey", "darkkhaki", "darkmagenta","darkolivegreen", "darkorange", "darkorchid", "darkred", "darksalmon", "darkseagreen", "darkslateblue","darkslategray", "darkslategrey", "darkturquoise", "darkviolet", "deeppink", "deepskyblue", "dimgray", "dimgrey","dodgerblue", "firebrick", "floralwhite", "forestgreen", "gainsboro", "ghostwhite", "gold", "goldenrod", "greenyellow","honeydew", "hotpink", "indianred", "indigo", "ivory", "khaki", "lavender", "lavenderblush", "lawngreen", "lemonchiffon","lightblue", "lightcoral", "lightcyan", "lightgoldenrodyellow", "lightgray", "lightgreen", "lightgrey", "lightpink","lightsalmon", "lightseagreen", "lightskyblue", "lightslategray", "lightslategrey", "lightsteelblue", "lightyellow","limegreen", "linen", "magenta", "mediumaquamarine", "mediumblue", "mediumorchid", "mediumpurple", "mediumseagreen","mediumslateblue", "mediumspringgreen", "mediumturquoise", "mediumvioletred", "midnightblue", "mintcream","mistyrose", "moccasin", "navajowhite", "oldlace", "olivedrab", "orangered", "orchid", "palegoldenrod", "palegreen","paleturquoise", "palevioletred", "papayawhip", "peachpuff", "peru", "pink", "plum", "powderblue", "rosybrown","royalblue", "saddlebrown", "salmon", "sandybrown", "seagreen", "seashell", "sienna", "skyblue", "slateblue","slategray", "slategrey", "snow", "springgreen", "steelblue", "tan", "thistle", "tomato", "turquoise", "violet","wheat", "whitesmoke", "yellowgreen", "rebeccapurple"
];const prefixes = ["ant","adm","el","van","ivu","btn","Mui","arco","q","p","n","nut","t","next","bee","layui","sui","ivu","smi","bd"
];const others = ["","date","datetime","picker","panel","modal","dialog","drawer","dropdown","menu","tooltip","range","content","wrapper","popper","is","has","pure","roboto","md","sm","lg","xl","xxl","xxxl","xxxl","xxxl","icon","search"
]window.__dictionary = [...tags,...attributes,...properties,...colors,...prefixes,...others,
];

2、行为回放脚本

2.1、背景

最初的一版其实是录制json数据在Chrome Recorder中回放,但是碰到了文件上传的问题,浏览器中文件上传会唤起文件上传的弹窗Chrome Recorder是无法处理这个交互的,如果涉及到文件上传就会导致后续的步骤无法进行;所以决定自己编写行为回放

2.2、代码实现

package.json

package.json定义项目信息

{"name": "business-ad-recorder","version": "1.0.0","description": "","main": "src/index.ts","scripts": {"dev": "ts-node src/index.ts"},"private": true,"dependencies": {"puppeteer": "^24.16.2"},"devDependencies": {"@types/node": "^24.3.0","ts-node": "^10.9.2","typescript": "^5.9.2"}
}
tsconfig.json

tsconfig.json定义项目ts规范

{"compilerOptions": {"target": "ES2020","module": "commonjs","lib": ["ES2020","DOM"],"outDir": "./dist","rootDir": "./src","strict": true,"esModuleInterop": true,"moduleResolution": "node","skipLibCheck": true,"forceConsistentCasingInFileNames": true,"resolveJsonModule": true},"include": ["src/**/*"],"exclude": ["node_modules","dist"]
}
.gitignore
.DS_Store
node_modules
dist
package-lock.json
/src/doc-anytime
# local env files
.env.local
.env.*.local# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*# Editor directories and files
.idea
.vscode
.history
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?# turbo
.turbo
delay.ts

delay.ts定义了delay函数,功能等同于sleep

export const delay = (ms: number): Promise<void> => {return new Promise(resolve => setTimeout(resolve, ms));
};
behavior.ts

behavior.ts定义了行为

  • focus:聚焦到指定元素,并给予高亮(如果是input清空value)
  • click:点击事件
  • change:文本输入/文件上传
import {ElementHandle, Page} from "puppeteer";
import {delay} from "./delay";
import path from "node:path";export const focus = async (page: Page, selector: string) => {await page.waitForSelector(selector, {timeout: 10000});const elementHandle = await page.$(selector);if (!elementHandle) {console.warn(`dev: element not found for selector: ${selector}`);return;}await elementHandle.evaluate(async (el) => {(el as HTMLElement).scrollIntoView({block: "center", inline: "center"});(el as HTMLElement).style.backgroundColor = `skyblue`;if (el instanceof HTMLInputElement) {el.value = '';}},elementHandle)
}export const click = async (page: Page, selector: string, d: number = 1000) => {await focus(page, selector);await page.click(selector);await delay(d);
}export const change = async (page: Page, type: string, selector: string, value: any, d: number = 1000) => {await focus(page, selector);if (type === 'input') {await page.type(selector, value, {delay: 50});}if (type === 'upload') {// await upload_able(page);const fileInput = await page.$(selector) as ElementHandle<HTMLInputElement>;const filePath = path.resolve(__dirname, 'file/attachment.xlsx');await fileInput.uploadFile(filePath);// await upload_disable(page);}await delay(d);
}
RecordData.ts

行为录制的数据格式

export interface RecordDataStep {type: string;behavior?: string;value?: string;selectors: string[];offsetX: number;offsetY: number;inFrame: boolean;frame: number[];delay: number;[key: string]: any;
}export interface RecordData {title: string;viewport: {width: number;height: number;[key: string]: any;},navigate: {title: string;url: string;}steps: RecordDataStep[]
}
index.ts

读取录制的数据,并执行

import puppeteer, {Browser, CookieData} from 'puppeteer';
import {delay} from "./delay";
import {change, click} from "./behavior";
import * as fs from "node:fs";
import {RecordData} from "./type/RecordData";const cookies: CookieData[] = [{name: '_AJSESSIONID',value: '85785022663a3426a8c24e5bc8b7b08b',domain: '.bilibili.co'},{name: '_gitlab_token',value: 'Z2xwYXQtWG1zcTNNVnFhc3JpNXdjY0FlYmc=',domain: '.bilibili.co'},{name: '_uuid',value: 'F10D2F5102-AC310-D14A-9B25-FF10E107931102601407infoc',domain: '.bilibili.com'},{name: 'B_DedeUserID',value: '16899876',domain: '.bilibili.com'},{name: 'B_DedeUserID__ckMd5',value: '5d4c7feea0ee8a27',domain: '.bilibili.com'},{name: 'b_lsid',value: 'E10BF58ED_1985FABB228',domain: '.bilibili.com'},{name: 'b_lsid',value: '93D377E6_198BB31DAFA',domain: '.bilibili.co'},{name: 'b_nut',value: '1726653350',domain: '.bilibili.co'},{name: 'b_nut',value: '1750940462',domain: '.bilibili.com'},{name: 'B_SESSDATA',value: '743b48d7%2C1759040812%2C9be37%2A610024',domain: '.bilibili.com'},{name: 'B_sid',value: '6ddy881c',domain: '.bilibili.com'},{name: 'B_sid',value: '6ddy881c',domain: '.bilibili.co'},{name: 'bili_b_jct',value: '23d8fd454de2f25223826b2b7daad194',domain: '.bilibili.com'},{name: 'billions_jwt',value: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NTU3NDY1NzUsImlhdCI6MTc1NTY2MDE3NSwiaXNzIjoieHVtZW5nMDMifQ.35u7sl_ez9ct5Gh7JNv3TsyTRCjdRbCtj0YmCVb4dJg',domain: '.bilibili.co'}
]async function main(): Promise<void> {const {title, viewport, navigate, steps}: RecordData = JSON.parse(fs.readFileSync('src/data/create_contract.json', 'utf-8'));console.log("当前执行:",title);const browser: Browser = await puppeteer.launch({headless: false,defaultViewport: viewport});const context = browser.defaultBrowserContext();await context.setCookie(...cookies);// const page: Page = await context.newPage();const [page] = await browser.pages();try {await page.goto(navigate.url, {waitUntil: 'networkidle2',timeout: 30000});await page.waitForSelector('body');await page.waitForFunction(() => {// 禁用所有文件输入框,防止触发文件上传窗口const fileInputs = document.querySelectorAll('input[type="file"]');fileInputs.forEach(fileInput => {(fileInput as HTMLInputElement).disabled = true;})return document.readyState === 'complete';});for (const step of steps) {if (step.type === 'click'){await click(page, step.selectors[0])}if (step.type === 'change'){await change(page, step.behavior!, step.selectors[0], step.value!)}}await delay(300000);} catch (error) {console.error('Error:', error);} finally {await browser.close();}
}main().catch(console.error);

3、后续

Chrome插件学习笔记(四)效果视频

项目还有其他需要完善的点,例如cookie上传(无需硬编码)、回放结果保存可以切换puppeteer到playwright(用法差异很小)、多tab行为录制(插件监听tab切换事件)、cookie上传(定期更新cookie,无需硬编码)、录制数据动态执行(录制数据单独作为仓库,结合jenkins执行时拉取所需要的数据)、插件美化(可以结合Chrome插件学习笔记(三))、回放集成到行为录制插件中(利用Chrome DevTools Protocol,Chrome浏览器提供的一个调试协议)

由于一些原因该项目最终在内部被弃用,但仍会更新需要完善的点。

http://www.dtcms.com/a/353363.html

相关文章:

  • 豆包分析linux top
  • 李飞飞谈 AI 世界模型:技术内涵与应用前景
  • 深度学习——卷积神经网络CNN(原理:基本结构流程、卷积层、池化层、全连接层等)
  • 编程算法实例-算法学习网站
  • [Mysql数据库] 知识点总结4
  • LeetCode热题 100——48. 旋转图像
  • CB1-3-面向对象
  • 琼脂糖凝胶核酸电泳条带异常问题及解决方案汇总
  • Day29 基于fork+exec的minishell实现与pthread多线程
  • 【Linux】基本指令学习3
  • IBMS集成管理系统与3D数字孪生智能服务系统的应用
  • Linux驱动 — 导出proc虚拟文件系统属性信息
  • LabVIEW 音频信号处理
  • 【ElasticSearch】原理分析
  • opencv+yolov8n图像模型训练和推断完整代码
  • django注册app时两种方式比较
  • PyTorch图像预处理完全指南:从基础操作到GPU加速实战
  • jQuery版EasyUI的ComboBox(下拉列表框)问题
  • 通义万相音频驱动视频模型Wan2.2-S2V重磅开源
  • 聊一聊 单体分布式 和 微服务分布式
  • Package.xml的字段说明
  • 前端架构知识体系:css架构模式和代码规范
  • 趣味学习Rust基础篇(用Rust做一个猜数字游戏)
  • PAT 1087 All Roads Lead to Rome
  • 嵌入式学习资料分享
  • java中的数据类型
  • 《FastAPI零基础入门与进阶实战》第14篇:ORM之第一个案例改善-用户查询
  • 【图文介绍】PCIe 6.0 Retimer板来了!
  • 快速上手对接币安加密货币API
  • 《Linux 网络编程四:TCP 并发服务器:构建模式、原理及关键技术(以select )》