本帖最后由 WindrunnerMax 于 2024-7-21 18:35 编辑
腾讯文档-PPT解除复制限制思路
事情的起因是一个 issue 希望支持腾讯文档的 PPT 文本复制,当然说是腾讯文档不如叫腾讯 Office 算了,无论是文档、表格、幻灯片都有。
那么既然是在 Web 中实现的 PPT ,那么想都不用想大概率是 Canavs 绘制的,在这种情况下页面中几乎是没有可以表示我们需要的文本内容的信息的,这种情况下除了考虑将 PPT 内容提取之后使用 DOM 重新附加在页面上之外,就只能考虑 Hook 其内部的数据处理方法了,那么此时我们就需要搬出来我们的数据处理三件套。
全局变量
很多网站都会将模块直接挂载到全局上,这种方式主要是为了方便本身内部各个模块的通信,特别是功能模块很复杂的情况下。那么在 PPT 这里可以很明显地关注到一些 service、doc-component、renderData 等对象,实际上基本在一眼看不到 editor/app 这类对象的情况下基本就很难找到可用的全局对象了。
![image.png](data/attachment/forum/202407/21/172113j82d1bfnbuxwyzia.png)
但是经过排查之后可以发现,这些 service 以及 docs-component 都是基本没什么作用的,这里的没什么作用指的是对于我们的复制行为这个目标没什么作用,至于这些模块本身要处理的功能我们并不是很关注。
那么为什么没有提到 renderData ,这个对象是有用的但是不多。了解 SSR/SSG 的朋友都知道,在首屏直出页面时初始化数据会直接作用在 HTML 页面中,所以这部分数据实际上是这么来的,如果切换左侧的 PPT 页面这部分数据是不会跟随变化的,所以就变成了有用但不多。
劫持函数调用
三板斧的第二板斧就是劫持函数调用,因为没有全局变量,我们如果想要取得闭包的值就需要尝试通过其函数调用拿到参数或者 this 信息。这点与 React/Vue 还不太一样,React/Vue 会在 DOM 结构上挂载相关属性,我们可以比较容易地取得其 DOM 本身的 props/state,甚至取得全局的 store 也是可以的,然而 PPT 这里基本都是独立模块相互调用,使用视图层本身的能力很少。
因此在这里我们可以尝试去 Hook Function.prototype.apply 以及 Function.prototype.call,因为这两个方法会被大量地调用,所以这里需要过滤一些输出。而由于我们现在是要处理剪贴板的能力,所以只输出剪贴板相关的参数内容是很合理的,这里的 index 也是过滤用的,输出太多的时候,可以通过不断重置来在我们想让其输出的时候再输出。
// ==UserScript==
// @name New Userscript
// @namespace https://bbs.tampermonkey.net.cn/
// @version 0.1.0
// @description try to take over the world!
// @author You
// @grant GM_xmlhttpRequest
// @run-at document-start
// @match https://docs.qq.com/*
// @grant unsafeWindow
// ==/UserScript==
let index = 0;
const log = window.console.log;
(function reset() {
index = 0;
setTimeout(reset, 3000);
})();
const native = [Array.prototype.slice, Object.prototype.toString, Object.prototype.hasOwnProperty];
Function.prototype.apply = function (dynamic, args = []) {
if (
!dynamic ||
typeof dynamic !== "object" ||
native.includes(this) ||
!args.length ||
dynamic.nodeType
) {
return this.bind(dynamic)(...args);
}
index++;
if (
(typeof dynamic === "object" && dynamic?.clipboardData)
|| (typeof args[0] === "object" && args[0]?.clipboardData)
|| (typeof args[1] === "object" && args[1]?.clipboardData)
) {
log("__dynamic", dynamic);
log("__args", args);
log("__this", this);
}
return this.bind(dynamic)(...args);
};
然而其输出的内容并不尽如人意,我新建了一个可以复制的 PPT ,如果是在编辑模式下,也就是我们有复制权限的情况下是可以输出足够的信息让我们拿到全局对象的,然而我们一旦切换到没权限的只读模式下,就只能输出这点信息,完全不足以让我们 Hook 相关函数调用,当然对于 call 的调用我也尝试过了,输出的内容仅有 Auth 模块,更不足以处理这个问题。
![image.png](data/attachment/forum/202407/21/175555ffltzcezwketnf6e.png)
当然,原生函数的调用还有很多,例如 Object.defineProperty 通常也是可行的选择,只不过在这里我没有继续尝试,而是拿起了第三板斧。
WebpackJsonp
我们可以很轻易地看出来这个页面是使用 webpack 打包的,那么既然需要打包就必不可少地要组织各个模块之间的函数调用,webpackJsonp 就是用来实现这个调度的方式,那么第三板斧就是先于 webpack 来处理模块,在加载这里的模块时是 window 作用域的,我们不需要考虑 this 的问题,因此我们就可以使用 new Function 来生成新的函数。
不过在此之前,我们先来看看究竟要做什么事,也就先用 debug 将我们具体要 hook 的点位找出来。复制这个行为是必须要写剪贴板的,那么如果想写入剪贴板,肯定离不开 onCopy 事件的监听,而且为了兼容性通常也不会使用 navigator.clipboard 来写入多种类型内容的剪贴板数据。
因此我们就需要先将入口的断点打出来,在 Devtools 的 Elements 面板就有 EventListeners 可以使用,我们可以比较轻易地定位到其 handler 的位置。
![image.png](data/attachment/forum/202407/21/180752qo1yjdzjkyay58nn.png)
通过定位后的代码可以看到这里明显是代理事件,也就是事件会被重新分发一次,那么如果我们直接打断点,就会导致无论是我们鼠标的移动还是按下键盘都会触发这里的事件,造成很大的困扰,因此这里我们使用条件断点来调试。
![image.png](data/attachment/forum/202407/21/182753ihf6czhbvtssnjjh.png)
多点几次调试,我们可以很轻易的发现某个 copy 的函数。
![image.png](data/attachment/forum/202407/21/182838i5kc99rhgjbb9vl9.png)
我们可以继续调试,发现其进入到了一个函数中,这里就有个很可以的 shouldResponseCopy 函数调用。
![image.png](data/attachment/forum/202407/21/182920g2pm07g4p0ssfmk7.png)
实际上我们到这里已经没有必要继续调试下去了,因为我们可以通过有权限的 PPT 和没权限的 PPT 调试对比,发现这里的返回值一个是 true 一个是 false,所以我们的目标点位就是将其设置为 true。
那么剩下的就很简单了,我们只需要在 webpackJsonp 处理模块之前将其处理掉即可,这里是抹除了函数调用,让其永远返回 true,如果需要处理其他模块的话,也可以将其挂载到 window 上。
// ==UserScript==
// @name New Userscript
// @namespace https://bbs.tampermonkey.net.cn/
// @version 0.1.0
// @description try to take over the world!
// @author You
// @grant GM_xmlhttpRequest
// @run-at document-start
// @match https://docs.qq.com/*
// @grant unsafeWindow
// ==/UserScript==
let originObecjt = unsafeWindow.webpackJsonp;
Object.defineProperty(unsafeWindow, "webpackJsonp", {
get() {
return originObecjt;
},
set(newValue) {
if (newValue.push.status == 123456) {
return;
}
originObecjt = newValue;
const originPush = originObecjt.push;
let index = 0;
function fuckPush(...args) {
const [chunk, mods] = args[0];
for (const [key, fn] of Object.entries(mods)) {
index++;
let modified = false;
let stringifyFn = String(fn);
if (/shouldResponseCopy/.test(stringifyFn)) {
modified=true;
stringifyFn = stringifyFn.replace(/this.shouldResponseCopy\(/g, "(() => true)(");
}
if (/this.storeManager=/.test(stringifyFn)) {
modified=true;
stringifyFn = stringifyFn.replace(/this.storeManager=/g, "window.storeManager=this.storeManager=");
}
if(modified){
console.log(stringifyFn)
mods[key] = new Function(`return (${stringifyFn})`)();
}
}
return originPush.call(this, ...args);
}
fuckPush.status = 123456
originObecjt.push = fuckPush;
},
});
在这里我还陷入了一个误区,可以看下下面参考的两篇文章,已经非常清晰了。简单来说就是如果我在 document-start 定义的 webpackJsonp push 函数会被 webpack 自身的 push 函数抓住,从而先调用 webpack 自身的 push 函数,而我们的目的实际上是相同的,都是 hook push 函数,所以我们需要在 webpack hook 完之后我们再 hook,从而保证我们的 push 函数是先于 webpack 本身的 push 函数调用的。
参考
https://bbs.tampermonkey.net.cn/forum.php?mod=viewthread&tid=5373
https://bbs.tampermonkey.net.cn/thread-2950-1-1.html