monkey.js 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508
  1. /**
  2. * 网页油猴 - 注入引擎 v2
  3. * - 多匹配规则(mIncludes/mExcludes)
  4. * - 三档运行时机(document-start / end / idle)
  5. * - MAIN/ISOLATED 世界 + CSP 严格站点自动兜底
  6. * - CSS 独立注入
  7. * - GM_* API 注入
  8. * - 错误回传到管理界面
  9. * - 命中计数
  10. */
  11. import InjectTools from './inject-tools.js';
  12. const PAGE_MONKEY_LOCAL_STORAGE_KEY = 'PAGE-MODIFIER-LOCAL-STORAGE-KEY';
  13. const PAGE_MONKEY_LOG_KEY = 'PAGE-MODIFIER-LOG-KEY';
  14. /* ================== 数据迁移 ================== */
  15. const migrateMonkey = (cm) => {
  16. if (!cm || typeof cm !== 'object') return cm;
  17. if (!cm.mIncludes || !cm.mIncludes.length) {
  18. cm.mIncludes = cm.mPattern ? [cm.mPattern] : [];
  19. }
  20. if (!Array.isArray(cm.mExcludes)) cm.mExcludes = [];
  21. if (!cm.mRunAt) cm.mRunAt = 'document-end';
  22. if (typeof cm.mAllFrames !== 'boolean') cm.mAllFrames = false;
  23. if (!cm.mWorld) cm.mWorld = 'MAIN';
  24. if (!Array.isArray(cm.mGrants)) cm.mGrants = [];
  25. if (!Array.isArray(cm.mTags)) cm.mTags = [];
  26. if (typeof cm.mHits !== 'number') cm.mHits = 0;
  27. if (typeof cm.mStyle !== 'string') cm.mStyle = '';
  28. return cm;
  29. };
  30. /* ================== 匹配引擎 ================== */
  31. const matchOnePattern = (pattern, url) => {
  32. if (!pattern) return false;
  33. let m = String(pattern).match(/^\/(.*)\/([gimsuy]*)$/);
  34. if (m) {
  35. try { return new RegExp(m[1], m[2] || '').test(url); } catch (e) { return false; }
  36. }
  37. if (pattern.indexOf('*') > -1) {
  38. let p = pattern;
  39. if (p.startsWith('*://')) p = p.replace('*://', '(http|https|file)://');
  40. else if (p.indexOf('://') < 0) p = '(http|https|file)://' + p;
  41. try {
  42. return new RegExp('^' + p.replace(/\./g, '\\.').replace(/\//g, '\\/').replace(/\*/g, '.*').replace(/\?/g, '\\?') + '$').test(url);
  43. } catch (e) { return false; }
  44. }
  45. let arr = [pattern, `${pattern}/`];
  46. if (!pattern.startsWith('http://') && !pattern.startsWith('https://') && !pattern.startsWith('file://')) {
  47. arr = arr.concat([`http://${pattern}`, `http://${pattern}/`, `https://${pattern}`, `https://${pattern}/`]);
  48. }
  49. return arr.includes(url);
  50. };
  51. const isMatch = (cm, url) => {
  52. let includes = cm.mIncludes && cm.mIncludes.length ? cm.mIncludes : (cm.mPattern ? [cm.mPattern] : []);
  53. if (!includes.length) return false;
  54. if (!includes.some(p => matchOnePattern(p, url))) return false;
  55. if ((cm.mExcludes || []).some(p => matchOnePattern(p, url))) return false;
  56. return true;
  57. };
  58. /* ================== GM API 源码 ================== */
  59. const buildGmApi = (monkey) => {
  60. const meta = JSON.stringify({
  61. id: monkey.id,
  62. name: monkey.mName || '',
  63. version: monkey.mVersion || '1.0.0',
  64. author: monkey.mAuthor || '',
  65. description: monkey.mDescription || ''
  66. });
  67. return `
  68. const __GM_PREFIX = '__FH_GM_' + ${JSON.stringify(monkey.id)} + '_';
  69. const GM_info = { script: ${meta}, version: '2.0', scriptHandler: 'FeHelper Monkey' };
  70. const GM_setValue = function(k, v){ try { localStorage.setItem(__GM_PREFIX + k, JSON.stringify(v)); } catch(e){} };
  71. const GM_getValue = function(k, d){ try { var v = localStorage.getItem(__GM_PREFIX + k); return v != null ? JSON.parse(v) : d; } catch(e){ return d; } };
  72. const GM_deleteValue = function(k){ try { localStorage.removeItem(__GM_PREFIX + k); } catch(e){} };
  73. const GM_listValues = function(){ try { return Object.keys(localStorage).filter(function(k){ return k.indexOf(__GM_PREFIX) === 0; }).map(function(k){ return k.slice(__GM_PREFIX.length); }); } catch(e){ return []; } };
  74. const GM_addStyle = function(css){ var s = document.createElement('style'); s.textContent = css; (document.head || document.documentElement).appendChild(s); return s; };
  75. const GM_log = function(){ try { console.log.apply(console, ['[FH-Monkey:' + (GM_info.script.name || '') + ']'].concat([].slice.call(arguments))); } catch(e){} };
  76. const GM_openInTab = function(url, opts){ try { return window.open(url, (opts && opts.active === false) ? '_blank' : '_blank'); } catch(e){} };
  77. const GM_setClipboard = function(text){ try { navigator.clipboard && navigator.clipboard.writeText(text); } catch(e){} };
  78. const GM_notification = function(opts){
  79. try {
  80. var title = typeof opts === 'string' ? '' : (opts && opts.title) || 'FeHelper';
  81. var text = typeof opts === 'string' ? opts : (opts && opts.text) || '';
  82. if (window.Notification && Notification.permission === 'granted') {
  83. new Notification(title, { body: text });
  84. } else {
  85. var d = document.createElement('div');
  86. d.style.cssText = 'position:fixed;top:20px;right:20px;background:rgba(0,0,0,.85);color:#fff;padding:12px 16px;border-radius:8px;z-index:2147483647;font:14px/1.5 sans-serif;max-width:320px;box-shadow:0 8px 32px rgba(0,0,0,.3);';
  87. d.innerHTML = (title ? '<b style="display:block;margin-bottom:4px">' + title + '</b>' : '') + text;
  88. document.body && document.body.appendChild(d);
  89. setTimeout(function(){ d.remove(); }, 4000);
  90. }
  91. } catch(e){}
  92. };
  93. const GM_xmlhttpRequest = function(details){
  94. try {
  95. var ctrl = new AbortController();
  96. var headers = details.headers || {};
  97. var p = fetch(details.url, {
  98. method: details.method || 'GET',
  99. headers: headers,
  100. body: details.data,
  101. credentials: details.anonymous ? 'omit' : 'include',
  102. signal: ctrl.signal
  103. }).then(function(r){
  104. return r.text().then(function(text){
  105. var resObj = { status: r.status, statusText: r.statusText, responseText: text, response: text, finalUrl: r.url, responseHeaders: '' };
  106. try { r.headers.forEach(function(v, k){ resObj.responseHeaders += k + ': ' + v + '\\r\\n'; }); } catch(e){}
  107. details.onload && details.onload(resObj);
  108. return resObj;
  109. });
  110. }).catch(function(e){ details.onerror && details.onerror(e); });
  111. return { abort: function(){ try{ ctrl.abort(); }catch(e){} } };
  112. } catch(e){ details.onerror && details.onerror(e); }
  113. };
  114. const unsafeWindow = window;
  115. const GM = {
  116. setValue: GM_setValue, getValue: GM_getValue, deleteValue: GM_deleteValue, listValues: GM_listValues,
  117. addStyle: GM_addStyle, notification: GM_notification, xmlHttpRequest: GM_xmlhttpRequest, openInTab: GM_openInTab,
  118. setClipboard: GM_setClipboard, log: GM_log, info: GM_info
  119. };
  120. `;
  121. };
  122. /* ================== 拼接最终代码 ================== */
  123. const buildFinalCode = (monkey) => {
  124. let requires = (monkey.mRequireJs || '').split(/[\s,,]+/).map(s => s.trim()).filter(Boolean);
  125. let userScript = monkey.mScript || '';
  126. let refresh = parseInt(monkey.mRefresh) || 0;
  127. let nameStr = JSON.stringify(monkey.mName || '');
  128. let idStr = JSON.stringify(monkey.id);
  129. // 注意:MAIN world 没有 chrome.runtime API,所以日志一律走
  130. // window.postMessage('FH_MONKEY_LOG' channel),由 ISOLATED world
  131. // 桥接器(_injectLogBridge)转发到 background。
  132. return `
  133. (function(){
  134. ${buildGmApi(monkey)}
  135. var __post = function(level, msg){
  136. try {
  137. window.postMessage({
  138. __fh_monkey_log: true,
  139. payload: {
  140. id: ${idStr},
  141. name: ${nameStr},
  142. level: level,
  143. msg: String((msg && msg.stack) || msg),
  144. url: location.href,
  145. time: Date.now()
  146. }
  147. }, '*');
  148. } catch(_) {}
  149. };
  150. var __reportError = function(e){
  151. __post('error', e);
  152. try { console.error('[FH-Monkey:' + ${nameStr} + ']', e); } catch(_) {}
  153. };
  154. // 让 GM_log 也回流到运行日志面板(每个脚本作用域独立闭包,必须每次都 hook)
  155. try {
  156. if (typeof GM_log === 'function') {
  157. var __origGmLog = GM_log;
  158. GM_log = function(){
  159. try {
  160. var args = [].slice.call(arguments);
  161. var text = args.map(function(a){
  162. try { return typeof a === 'object' ? JSON.stringify(a) : String(a); } catch(_) { return String(a); }
  163. }).join(' ');
  164. __post('info', text);
  165. } catch(_) {}
  166. try { return __origGmLog.apply(null, arguments); } catch(_) {}
  167. };
  168. }
  169. } catch(_) {}
  170. // 全局 console / error / unhandledrejection 监听只挂一次(避免多脚本 N 倍重复 + 归属错乱),
  171. // 由全局 sentinel 控制;归属为通用 'page-monkey',具体脚本错误请用 try-catch 自行 GM_log。
  172. try {
  173. if (!window.__fhMonkeyGlobalHooked) {
  174. window.__fhMonkeyGlobalHooked = true;
  175. var __postGlobal = function(level, msg){
  176. try {
  177. window.postMessage({
  178. __fh_monkey_log: true,
  179. payload: {
  180. id: '__global__', name: 'page-monkey',
  181. level: level, msg: String((msg && msg.stack) || msg),
  182. url: location.href, time: Date.now()
  183. }
  184. }, '*');
  185. } catch(_) {}
  186. };
  187. ['error', 'warn'].forEach(function(level){
  188. var orig = console[level];
  189. console[level] = function(){
  190. var args = [].slice.call(arguments);
  191. var text = args.map(function(a){
  192. try {
  193. if (a && a.stack) return a.stack;
  194. if (typeof a === 'object') return JSON.stringify(a);
  195. return String(a);
  196. } catch(_) { return String(a); }
  197. }).join(' ');
  198. __postGlobal(level, text);
  199. try { orig.apply(console, args); } catch(_) {}
  200. };
  201. });
  202. window.addEventListener('error', function(e){
  203. __postGlobal('error', (e && (e.message || (e.error && e.error.stack))) || 'window.onerror');
  204. });
  205. window.addEventListener('unhandledrejection', function(e){
  206. var r = e && e.reason;
  207. __postGlobal('error', 'UnhandledRejection: ' + ((r && r.stack) || r));
  208. });
  209. }
  210. } catch(_) {}
  211. var __runUser = function(){
  212. __post('info', '脚本开始执行');
  213. try {
  214. (function(){
  215. ${userScript}
  216. })();
  217. } catch(e) { __reportError(e); }
  218. ${refresh > 0 ? `try{ setTimeout(function(){ try{ location.reload(); }catch(e){} }, ${refresh * 1000}); }catch(e){}` : ''}
  219. };
  220. var __requires = ${JSON.stringify(requires)};
  221. // 通过 ISOLATED 桥 + background 代理 fetch @require 脚本,
  222. // 这样可以绕过页面 CSP / CORS 限制(MAIN world 直接 fetch 经常被 CSP block)。
  223. var __fetchRequire = function(url){
  224. return new Promise(function(resolve){
  225. var reqId = 'req_' + Date.now() + '_' + Math.random().toString(36).slice(2);
  226. var timer = setTimeout(function(){
  227. window.removeEventListener('message', onMsg, false);
  228. resolve({ok:false, err:'require timeout'});
  229. }, 15000);
  230. function onMsg(e){
  231. if (!e || !e.data || e.data.__fh_monkey_require_resp !== true) return;
  232. if (e.data.reqId !== reqId) return;
  233. clearTimeout(timer);
  234. window.removeEventListener('message', onMsg, false);
  235. resolve(e.data);
  236. }
  237. window.addEventListener('message', onMsg, false);
  238. try {
  239. window.postMessage({__fh_monkey_require: true, reqId: reqId, url: url}, '*');
  240. } catch (err) {
  241. clearTimeout(timer);
  242. window.removeEventListener('message', onMsg, false);
  243. resolve({ok:false, err:String(err && err.message || err)});
  244. }
  245. });
  246. };
  247. if (__requires.length) {
  248. Promise.all(__requires.map(function(u){
  249. return __fetchRequire(u).then(function(r){
  250. if (!r || !r.ok) {
  251. __reportError('require failed: ' + u + ' / ' + (r && r.err));
  252. return;
  253. }
  254. var t = r.text || '';
  255. try { (0, eval)(t); } catch(e) {
  256. try {
  257. var s = document.createElement('script');
  258. s.textContent = t;
  259. (document.head || document.documentElement).appendChild(s);
  260. s.remove();
  261. } catch (e2) {
  262. __reportError('require eval failed: ' + u + ' / ' + e2);
  263. }
  264. }
  265. });
  266. })).then(__runUser).catch(__runUser);
  267. } else {
  268. __runUser();
  269. }
  270. })();
  271. `;
  272. };
  273. /* ================== 注入实现 ================== */
  274. const _injectCss = (tabId, allFrames, css) => {
  275. try {
  276. chrome.scripting.insertCSS({
  277. target: { tabId, allFrames },
  278. css
  279. }).catch(() => {});
  280. } catch (e) {}
  281. };
  282. // MAIN world 没有 chrome.runtime,需要在 ISOLATED world 中常驻一个桥接监听器,
  283. // 通过 window.postMessage 接收 MAIN world 抛出的日志,再转发到 background。
  284. // 返回 Promise,调用方需 await 以保证桥接器在 user script 之前就位(否则启动期日志会丢)。
  285. const _injectLogBridge = (tabId, allFrames) => {
  286. try {
  287. return chrome.scripting.executeScript({
  288. target: { tabId, allFrames },
  289. func: function () {
  290. if (window.__fhMonkeyBridgeReady) return;
  291. window.__fhMonkeyBridgeReady = true;
  292. window.addEventListener('message', function (e) {
  293. if (!e || !e.data) return;
  294. var d = e.data;
  295. // 1) 日志桥
  296. if (d.__fh_monkey_log === true) {
  297. try {
  298. chrome.runtime.sendMessage({
  299. type: 'fh-dynamic-any-thing',
  300. thing: 'page-monkey-log',
  301. params: d.payload || {}
  302. });
  303. } catch (_) {}
  304. return;
  305. }
  306. // 2) @require 代理:通过 background fetch 远程脚本,
  307. // 避免页面 CSP/CORS 限制 MAIN world 直接 fetch
  308. if (d.__fh_monkey_require === true && d.url && d.reqId) {
  309. var reqId = d.reqId, url = d.url;
  310. try {
  311. chrome.runtime.sendMessage({
  312. type: 'fh-dynamic-any-thing',
  313. thing: 'page-monkey-require-fetch',
  314. params: { url: url }
  315. }, function (resp) {
  316. try {
  317. window.postMessage({
  318. __fh_monkey_require_resp: true,
  319. reqId: reqId,
  320. ok: !!(resp && resp.ok),
  321. text: (resp && resp.text) || '',
  322. err: (resp && resp.err) || ''
  323. }, '*');
  324. } catch (_) {}
  325. });
  326. } catch (err) {
  327. try {
  328. window.postMessage({
  329. __fh_monkey_require_resp: true,
  330. reqId: reqId, ok: false, text: '',
  331. err: String(err && err.message || err)
  332. }, '*');
  333. } catch (_) {}
  334. }
  335. }
  336. }, false);
  337. },
  338. world: 'ISOLATED',
  339. injectImmediately: true
  340. }).catch(() => {});
  341. } catch (_) {
  342. return Promise.resolve();
  343. }
  344. };
  345. const _injectScript = (tabId, monkey) => {
  346. let allFrames = !!monkey.mAllFrames;
  347. let world = monkey.mWorld === 'ISOLATED' ? 'ISOLATED' : 'MAIN';
  348. let finalCode = buildFinalCode(monkey);
  349. // 必须先等 ISOLATED 桥接器就位(MAIN/ISOLATED 模式都需要:
  350. // ISOLATED 模式下 user script 也通过 postMessage 走桥接,统一通道)。
  351. // 否则 user script 启动期的早期日志/@require 请求会丢失。
  352. let bridgeReady = _injectLogBridge(tabId, allFrames) || Promise.resolve();
  353. const exec = (worldOption, isFallback) => {
  354. try {
  355. chrome.scripting.executeScript({
  356. target: { tabId, allFrames },
  357. func: function (code) {
  358. try { (0, eval)(code); } catch (e) {
  359. // 注入阶段的 eval 错误也走桥接
  360. try {
  361. window.postMessage({
  362. __fh_monkey_log: true,
  363. payload: { level: 'error', msg: 'inject eval error: ' + ((e && e.stack) || e), time: Date.now() }
  364. }, '*');
  365. } catch (_) {}
  366. }
  367. },
  368. args: [finalCode],
  369. world: worldOption,
  370. injectImmediately: true
  371. }).catch((err) => {
  372. if (!isFallback && worldOption === 'MAIN') {
  373. exec('ISOLATED', true);
  374. } else {
  375. // 最终失败也回报一条日志
  376. log({
  377. id: monkey.id, name: monkey.mName || '', level: 'error',
  378. msg: 'inject failed: ' + ((err && err.message) || err),
  379. url: '(tab ' + tabId + ')', time: Date.now()
  380. });
  381. }
  382. });
  383. } catch (e) {
  384. if (!isFallback && worldOption === 'MAIN') exec('ISOLATED', true);
  385. }
  386. };
  387. bridgeReady.then(() => exec(world, false), () => exec(world, false));
  388. };
  389. const injectMonkey = (tabId, monkey) => {
  390. let allFrames = !!monkey.mAllFrames;
  391. if (monkey.mStyle && monkey.mStyle.trim()) {
  392. _injectCss(tabId, allFrames, monkey.mStyle);
  393. }
  394. _injectScript(tabId, monkey);
  395. _hit(monkey.id);
  396. };
  397. /* ================== 命中计数 ================== */
  398. let _hitTimer = null;
  399. let _hitBuffer = {};
  400. const _hit = (id) => {
  401. _hitBuffer[id] = (_hitBuffer[id] || 0) + 1;
  402. if (_hitTimer) return;
  403. _hitTimer = setTimeout(() => {
  404. let buf = _hitBuffer; _hitBuffer = {}; _hitTimer = null;
  405. chrome.storage.local.get(PAGE_MONKEY_LOCAL_STORAGE_KEY, resps => {
  406. let raw = resps && resps[PAGE_MONKEY_LOCAL_STORAGE_KEY];
  407. if (!raw) return;
  408. try {
  409. let arr = JSON.parse(raw);
  410. arr.forEach(cm => { if (buf[cm.id]) cm.mHits = (cm.mHits || 0) + buf[cm.id]; });
  411. let data = {}; data[PAGE_MONKEY_LOCAL_STORAGE_KEY] = JSON.stringify(arr);
  412. chrome.storage.local.set(data);
  413. } catch (e) {}
  414. });
  415. }, 1500);
  416. };
  417. /* ================== 启动入口(按 runAt 触发) ================== */
  418. const start = (params) => {
  419. try {
  420. if (!params || !params.url || params.tabId == null) return true;
  421. let runAt = params.runAt || 'document-end';
  422. chrome.storage.local.get(PAGE_MONKEY_LOCAL_STORAGE_KEY, (resps) => {
  423. let raw, storageMode = false;
  424. if ((!resps || !resps[PAGE_MONKEY_LOCAL_STORAGE_KEY]) && typeof localStorage !== 'undefined') {
  425. raw = localStorage.getItem(PAGE_MONKEY_LOCAL_STORAGE_KEY) || '[]';
  426. storageMode = true;
  427. } else {
  428. raw = (resps && resps[PAGE_MONKEY_LOCAL_STORAGE_KEY]) || '[]';
  429. }
  430. let monkeys = [];
  431. try { monkeys = JSON.parse(raw); } catch (e) {}
  432. monkeys = monkeys.map(migrateMonkey);
  433. monkeys
  434. .filter(cm => !cm.mDisabled)
  435. .filter(cm => (cm.mRunAt || 'document-end') === runAt)
  436. .filter(cm => isMatch(cm, params.url))
  437. .forEach(cm => injectMonkey(params.tabId, cm));
  438. if (storageMode) {
  439. let data = {}; data[PAGE_MONKEY_LOCAL_STORAGE_KEY] = raw;
  440. chrome.storage.local.set(data);
  441. }
  442. });
  443. } catch (e) {
  444. console.log('monkey error', e);
  445. }
  446. return true;
  447. };
  448. /* ================== 日志收集 ================== */
  449. const log = (params) => {
  450. if (!params) return;
  451. chrome.storage.local.get(PAGE_MONKEY_LOG_KEY, resps => {
  452. let arr = [];
  453. try { arr = JSON.parse((resps && resps[PAGE_MONKEY_LOG_KEY]) || '[]'); } catch (e) {}
  454. arr.push(Object.assign({ time: Date.now() }, params));
  455. if (arr.length > 300) arr = arr.slice(-300);
  456. let data = {}; data[PAGE_MONKEY_LOG_KEY] = JSON.stringify(arr);
  457. chrome.storage.local.set(data);
  458. });
  459. };
  460. /* ================== @require 白名单校验 ================== */
  461. // 桥接器代理 fetch 容易被恶意页面滥用做任意 URL 代理(绕过 CORS),
  462. // 因此 background 端必须校验 URL 必须出现在某个已启用脚本的 mRequireJs 列表中。
  463. const isAllowedRequireUrl = (url) => {
  464. return new Promise(resolve => {
  465. if (!url || typeof url !== 'string') return resolve(false);
  466. try {
  467. chrome.storage.local.get(PAGE_MONKEY_LOCAL_STORAGE_KEY, resps => {
  468. let raw = resps && resps[PAGE_MONKEY_LOCAL_STORAGE_KEY];
  469. if (!raw) return resolve(false);
  470. let monkeys = [];
  471. try { monkeys = JSON.parse(raw); } catch (e) {}
  472. let allowed = monkeys.some(cm => {
  473. if (!cm || cm.mDisabled) return false;
  474. let list = (cm.mRequireJs || '').split(/[\s,,]+/).map(s => s.trim()).filter(Boolean);
  475. return list.indexOf(url) !== -1;
  476. });
  477. resolve(allowed);
  478. });
  479. } catch (_) { resolve(false); }
  480. });
  481. };
  482. export default { start, log, migrateMonkey, isMatch, matchOnePattern, isAllowedRequireUrl };