browser-environment.mjs 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
  1. import {BrowserWindow,session} from 'electron'
  2. import {randomUUID} from 'node:crypto'
  3. import {join} from 'node:path'
  4. import {mkdirSync,chmodSync} from 'node:fs'
  5. export function browserURL(value){if(typeof value!=='string'||value.length>4096||/[\\\x00-\x20]/.test(value))throw new Error('请输入完整的 http 或 https 地址');const u=new URL(value);if(!['http:','https:'].includes(u.protocol)||u.username||u.password)throw new Error('浏览器只支持 http 和 https 页面');return u.href}
  6. const allowed=url=>url==='about:blank'||(()=>{try{browserURL(url);return true}catch{return false}})()
  7. /** A bot gets an isolated Chromium profile, never the app or personal browser's
  8. * cookies. All commands are structured; no model-supplied JavaScript or CDP. */
  9. export class BrowserEnvironment {
  10. constructor(root){mkdirSync(root,{recursive:true,mode:0o700});chmodSync(root,0o700);this.root=root;this.browsers=new Map();this.sessions=new Map()}
  11. state(key){const b=this.browsers.get(key);return {open:!!b,tabs:b?[...b.tabs].map(([id,w])=>({id,title:w.webContents.getTitle()||'新标签页',url:w.webContents.getURL(),active:id===b.active})):[],profile:b?.profile??'persistent'}}
  12. async open(key,profile){if(!/^[a-f0-9]{64}$/.test(key)||!['persistent','temporary'].includes(profile))throw new Error('浏览器身份无效');let b=this.browsers.get(key);if(!b){let s=this.sessions.get(key);if(!s){s=profile==='persistent'?session.fromPath(join(this.root,key)):session.fromPartition('yaoyao-temporary-'+key+'-'+randomUUID());s.setPermissionRequestHandler((_w,_p,callback)=>callback(false));s.setPermissionCheckHandler(()=>false);s.on('will-download',event=>event.preventDefault());this.sessions.set(key,s)}b={tabs:new Map(),active:'',session:s,profile};this.browsers.set(key,b);await this.newTab(b,'about:blank')}return b}
  13. async newTab(b,url){if(b.tabs.size>=12)throw new Error('最多打开 12 个标签页,请先关闭不用的页面');const id=randomUUID(),w=new BrowserWindow({show:false,width:1280,height:800,useContentSize:true,webPreferences:{session:b.session,sandbox:true,nodeIntegration:false,contextIsolation:true,backgroundThrottling:false,webSecurity:true,allowRunningInsecureContent:false}});b.tabs.set(id,w);b.active=id
  14. w.webContents.setWindowOpenHandler(({url})=>{if(allowed(url))void this.newTab(b,url).catch(()=>{});return {action:'deny'}})
  15. w.webContents.on('will-navigate',(event,url)=>{if(!allowed(url))event.preventDefault()});w.webContents.on('will-redirect',(event,url)=>{if(!allowed(url))event.preventDefault()});w.webContents.on('will-attach-webview',event=>event.preventDefault());w.webContents.on('did-start-navigation',()=>{w.snapshotId=undefined})
  16. w.on('closed',()=>{b.tabs.delete(id);if(b.active===id)b.active=b.tabs.keys().next().value??''})
  17. try{await this.load(w,url)}catch(error){if(!w.isDestroyed()&&error.code!=='ERR_ABORTED')throw new Error('页面加载失败,请检查地址或网络')}
  18. return id
  19. }
  20. async load(w,url){let timer;try{await Promise.race([w.loadURL(url),new Promise((_,reject)=>{timer=setTimeout(()=>{if(!w.isDestroyed())w.webContents.stop();reject(new Error('页面加载超时'))},18000)})])}finally{clearTimeout(timer)}}
  21. current(b){const w=b.tabs.get(b.active);if(!w||w.isDestroyed())throw new Error('浏览器标签页已关闭,请新建标签页');return w}
  22. async view(key){const b=this.browsers.get(key);if(!b)throw new Error('浏览器尚未打开,点击打开浏览器');const wc=this.current(b).webContents;await wc.executeJavaScript('new Promise(resolve=>{requestAnimationFrame(()=>requestAnimationFrame(resolve));setTimeout(resolve,200)})').catch(()=>{});const image=await wc.capturePage();if(image.isEmpty())throw new Error('浏览器画面尚未就绪');return {data:image.toPNG().toString('base64'),...image.getSize()}}
  23. async action(key,profile,a){const b=await this.open(key,profile);if(a.kind==='new-tab'){await this.newTab(b,browserURL(a.url));return this.state(key)}
  24. if(['select-tab','close-tab'].includes(a.kind)){if(!b.tabs.has(a.tabId))throw new Error('标签页已关闭');if(a.kind==='select-tab')b.active=a.tabId;else{b.tabs.get(a.tabId).destroy();if(!b.tabs.size)await this.newTab(b,'about:blank')}return this.state(key)}
  25. const w=this.current(b),wc=w.webContents
  26. if(a.kind==='navigate'){const url=browserURL(a.url);w.snapshotId=undefined;try{await this.load(w,url)}catch(error){if(error.code!=='ERR_ABORTED')throw new Error('页面加载失败,请检查地址或网络')}}
  27. else if(a.kind==='back'){w.snapshotId=undefined;if(wc.navigationHistory.canGoBack())wc.navigationHistory.goBack()}
  28. else if(a.kind==='forward'){w.snapshotId=undefined;if(wc.navigationHistory.canGoForward())wc.navigationHistory.goForward()}
  29. else if(a.kind==='reload'){w.snapshotId=undefined;wc.reload()}
  30. else if(a.kind==='snapshot'){
  31. const id=randomUUID();w.snapshotId=id
  32. // Ref metadata lives in an isolated world, out of reach of page scripts.
  33. const value=await wc.executeJavaScriptInIsolatedWorld(999,[{code:`(()=>{const nodes=[...document.querySelectorAll('a,button,input,textarea,select,[role="button"],[contenteditable="true"]')].filter(e=>e.getClientRects().length).slice(0,500);globalThis.__yaoyaoRefs=new Map(nodes.map((e,i)=>['e'+i,e]));return {title:document.title,url:location.href,text:document.body?.innerText.slice(0,24000)||'',elements:nodes.map((e,i)=>({ref:'e'+i,tag:e.tagName.toLowerCase(),role:e.getAttribute('role'),label:e.getAttribute('aria-label')||e.getAttribute('placeholder')||e.innerText?.slice(0,160)||'',type:e.getAttribute('type')}))}})()`}]);return {...value,snapshotId:id}
  34. }else if(a.kind==='click'||a.kind==='fill'){
  35. if(!w.snapshotId||w.snapshotId!==a.snapshotId||!/^e[0-9]+$/.test(a.ref))throw new Error('页面已变化,请重新读取 snapshot')
  36. if(a.kind==='fill'&&typeof a.text!=='string')throw new Error('填写内容缺失')
  37. const code=`(()=>{const e=globalThis.__yaoyaoRefs?.get(${JSON.stringify(a.ref)});if(!e||!e.isConnected||!e.getClientRects().length)throw new Error('元素已变化,请重新读取 snapshot');if(e instanceof HTMLInputElement&&e.type==='file')throw new Error('请通过页面接管处理文件选择');e.scrollIntoView({block:'center'});${a.kind==='click'?'e.click();':`if(!(e instanceof HTMLInputElement||e instanceof HTMLTextAreaElement||e.isContentEditable))throw new Error('该元素不能填写');e.focus();if(e.isContentEditable)e.textContent=${JSON.stringify(a.text)};else{const setter=Object.getOwnPropertyDescriptor(e instanceof HTMLInputElement?HTMLInputElement.prototype:HTMLTextAreaElement.prototype,'value').set;setter.call(e,${JSON.stringify(a.text)})}e.dispatchEvent(new Event('input',{bubbles:true}));e.dispatchEvent(new Event('change',{bubbles:true}));`}return {ok:true}})()`
  38. await wc.executeJavaScriptInIsolatedWorld(999,[{code}]);w.snapshotId=undefined
  39. }else throw new Error('浏览器操作无效')
  40. return this.state(key)
  41. }
  42. async input(key,a,frame){const b=this.browsers.get(key);if(!b)throw new Error('浏览器未打开');const w=this.current(b),wc=w.webContents,size=w.getContentSize();w.snapshotId=undefined
  43. const point=(x,y)=>{if(!Number.isFinite(x)||!Number.isFinite(y)||x<0||y<0||x>=frame.width||y>=frame.height)throw new Error('输入超出画面');return {x:Math.floor(x*size[0]/frame.width),y:Math.floor(y*size[1]/frame.height)}}
  44. if(a.kind==='text')await wc.insertText(a.text)
  45. else if(a.kind==='key'){const keyCode=({Return:'Enter',BackSpace:'Backspace',Up:'Up',Down:'Down',Left:'Left',Right:'Right',space:'Space'}[a.key]??a.key),modifiers=(a.modifiers??[]).map(m=>({ctrl:'control',alt:'alt',shift:'shift',super:'meta'}[m]));wc.sendInputEvent({type:'keyDown',keyCode,modifiers});wc.sendInputEvent({type:'keyUp',keyCode,modifiers})}
  46. else if(a.kind==='click'){const p=point(a.x,a.y),button=a.button??'left',clickCount=a.count??1;wc.sendInputEvent({type:'mouseMove',...p});wc.sendInputEvent({type:'mouseDown',...p,button,clickCount});wc.sendInputEvent({type:'mouseUp',...p,button,clickCount})}
  47. else if(a.kind==='drag'){const start=point(a.fromX,a.fromY),end=point(a.toX,a.toY);wc.sendInputEvent({type:'mouseDown',...start,button:'left',clickCount:1});wc.sendInputEvent({type:'mouseMove',...end,modifiers:['leftButtonDown']});wc.sendInputEvent({type:'mouseUp',...end,button:'left',clickCount:1})}
  48. else if(a.kind==='scroll'){const amount=(a.amount??3)*80;wc.sendInputEvent({type:'mouseWheel',x:Math.floor(size[0]/2),y:Math.floor(size[1]/2),deltaX:a.direction==='left'?-amount:a.direction==='right'?amount:0,deltaY:a.direction==='up'?-amount:a.direction==='down'?amount:0,canScroll:true})}
  49. else throw new Error('浏览器输入无效')
  50. return {ok:true}
  51. }
  52. async close(){for(const b of this.browsers.values())for(const w of b.tabs.values())w.destroy();this.browsers.clear();for(const s of this.sessions.values()){s.flushStorageData();await s.cookies.flushStore();if(!s.isPersistent())await s.clearStorageData()}this.sessions.clear()}
  53. }