RAGFlow 跨域文本选中无法获取?自己写个代理中间层,零后端搞定!
教育志项目需要嵌入 RAGFlow 的原文预览,并获取用户选中的文本插入编辑器。RAGFlow 无后端接口、无法修改代码,跨域三要素全占,怎么办?自己写个 Node 代理中间层,轻松破局!
前言
最近参与了一个教育志编修项目,核心需求是多人协同编写教育年鉴,并依赖 RAGFlow 对原始文献进行切片管理。作者在编写文档时,需要随时检索、查看 RAGFlow 中的原始文献,并能够将原文中选中的片段直接插入到正在编写的文档中。
技术方案很自然:在编辑器旁边通过 iframe 嵌入 RAGFlow 的原文预览页面,用户选中文字,点击“引用”按钮,即可将选中内容插入编辑器。然而,现实给了我们一记重拳——跨域。
RAGFlow 是独立部署的系统,与教育志项目的主应用完全不同源(协议、域名、端口三要素全占)。更棘手的是:RAGFlow 没有提供任何后端接口,没有技术支持,我们无法修改它的代码,也没有办法通过后端代理去抓取页面(因为涉及动态交互) 。浏览器同源策略像一堵无法逾越的墙,父窗口无法通过 contentWindow.document 访问 iframe 内的 DOM,更别说监听选中事件了。
常规方案纷纷失效
| 方案 |
为什么不行 |
| postMessage |
需要目标页面内配合发送消息,但 RAGFlow 代码无法修改 |
| CORS 跨域资源共享 |
只适用于接口请求,对 DOM 操作无效 |
| 服务器端代理 |
由后端抓取页面再返回,但 RAGFlow 页面是动态交互的,无法模拟用户选中行为 |
项目工期紧,前端必须自己杀出一条血路。最终,我们采用了一个“骚操作”——自建 Node 代理中间层,在代理层动态修改 HTML,注入我们需要的脚本,让 iframe 和父窗口“同源”,从而实现跨域 DOM 操作。
本文将完整还原这一方案,并附上可直接运行的源码。无论你遇到的是 RAGFlow 还是任何其他跨域页面,只要你想获取 iframe 内的用户选区,这套方法都能帮你“曲线救国”。
最终效果
我们搭建的代理服务运行在本地 3002 端口,前端只需将 iframe 的 src 指向代理地址,例如:
html
<iframe src="http://localhost:3002/ragflow/docs/123.html"></iframe>
当用户在 iframe 内选中任何文本,父窗口就能收到包含文本内容、位置、上下文等详细信息的消息:
json
{
"type": "TEXT_SELECTED",
"text": "光绪二十四年(1898年),京师大学堂成立...",
"context": {
"before": "此前,中国近代教育...",
"after": "此后,各省纷纷设立学堂..."
},
"position": { "x": 150, "y": 200 },
"meta": { "charCount": 48, "wordCount": 9 }
}
父窗口收到消息后,可以立即将文本插入编辑器中,整个过程对用户透明,RAGFlow 无需任何改动,教育志项目后端也无需介入。
原理图解
整个方案的核心是:利用 Node.js 创建一个代理服务器,将 RAGFlow 的页面“偷”回来,然后在返回前注入我们自己的脚本。
text
浏览器 (教育志项目)
│
│ iframe src="http://localhost:3002/ragflow/docs/..."
▼
代理服务 (Node.js) ← 这是我们自己写的,独立部署
│
│ 1. 向 RAGFlow 服务器发起请求(无任何修改)
▼
RAGFlow 服务器 (https://ragflow.example.com) ← 完全不知情
│
│ 2. 返回 HTML 内容
▼
代理服务
│
│ 3. 解压、修改 HTML
│ ├─ 插入 <base> 标签(修正资源路径)
│ └─ 注入自定义脚本(不仅限于文本选中,可以是任意你需要的脚本)
│ 4. 返回修改后的 HTML 给 iframe
▼
iframe 加载修改后的页面,注入的脚本开始工作
│
│ 5. 根据注入脚本的功能执行操作(如监听 mouseup、捕获选中文本)
│ 6. 通过 window.parent.postMessage 发送给父窗口
▼
父窗口收到消息,将文本插入编辑器
通过这种方式,iframe 的源变成了代理服务的源(例如 http://localhost:3002),与父窗口同源,postMessage 通信畅通无阻,且脚本可以自由操作 iframe 的 DOM。整个过程对 RAGFlow 完全透明,它甚至不知道自己被代理了。
更关键的是:注入的脚本不限于文本选择——你可以利用这个能力,在目标页面中植入任何你想要的功能,例如:
- 自动填充表单
- 追踪用户点击行为
- 修改页面样式
- 劫持 Ajax 请求
- 甚至是一个完整的调试工具
代理层就像是一个“中间人”,让你在不修改原始页面的前提下,为它增加任意前端能力。
核心代码逐段解析
1. 启动 HTTP 服务器
javascript
const http = require("http");
const url = require("url");
const PORT = process.env.PROXY_PORT || 3002;
const TARGET_HOST = process.env.TARGET_HOST || "ragflow.example.com"; // 你的 RAGFlow 域名
const server = http.createServer((req, res) => {
const parsed = url.parse(req.url, true);
const pathname = parsed.pathname;
// 健康检查
if (pathname === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok" }));
} else {
// 其他所有请求都交给代理函数处理
proxyRequest(pathname + (parsed.search || ""), res).catch(err => {
res.writeHead(502);
res.end("Proxy Error: " + err.message);
});
}
});
server.listen(PORT, () => {
console.log(`Proxy running at http://localhost:${PORT}`);
});
2. 代理请求函数 proxyRequest
这是最核心的部分,负责向 RAGFlow 发起请求,并根据返回内容做不同处理。
javascript
const https = require("https");
const zlib = require("zlib");
async function proxyRequest(targetPath, res) {
const options = {
hostname: TARGET_HOST,
port: 443,
protocol: "https:",
path: targetPath,
headers: {
"User-Agent": "Mozilla/5.0 ...",
"Accept-Encoding": "gzip, deflate, br",
// ... 其他头
},
rejectUnauthorized: false, // 忽略证书错误(调试用)
};
return new Promise((resolve, reject) => {
const proxyReq = https.request(options, async (proxyRes) => {
// 收集数据
const chunks = [];
proxyRes.on("data", chunk => chunks.push(chunk));
proxyRes.on("end", async () => {
const buffer = Buffer.concat(chunks);
const encoding = proxyRes.headers["content-encoding"];
const decompressed = await decompress(buffer, encoding);
const contentType = proxyRes.headers["content-type"] || "";
const statusCode = proxyRes.statusCode;
// 处理重定向
if (statusCode >= 300 && statusCode < 400 && proxyRes.headers.location) {
const location = proxyRes.headers.location;
const newPath = location.startsWith("http")
? url.parse(location).path
: location;
return proxyRequest(newPath, res).then(resolve).catch(reject);
}
// 非200错误
if (statusCode !== 200) {
res.writeHead(statusCode, { "Content-Type": "text/plain" });
res.end("Error: " + statusCode);
return resolve();
}
// 判断是否为 HTML(RAGFlow 的原文页面通常是 HTML)
const isHtml = contentType.includes("text/html");
const headers = { "Access-Control-Allow-Origin": "*" };
if (isHtml) {
// 修改 HTML 并注入脚本
let html = decompressed.toString("utf-8");
html = modifyHtml(html, TARGET_HOST);
headers["Content-Type"] = "text/html; charset=utf-8";
headers["Content-Length"] = Buffer.byteLength(html);
res.writeHead(200, headers);
res.end(html);
} else {
// 非 HTML 资源(CSS、JS、图片等)直接透传
headers["Content-Type"] = contentType || "application/octet-stream";
res.writeHead(200, headers);
res.end(decompressed);
}
resolve();
});
});
proxyReq.on("error", reject);
proxyReq.on("timeout", () => {
proxyReq.destroy();
reject(new Error("Timeout"));
});
proxyReq.end();
});
}
3. 解压函数 decompress
支持 gzip、deflate、br 解压。
javascript
function decompress(buffer, encoding) {
return new Promise((resolve, reject) => {
if (!encoding || encoding === "identity") resolve(buffer);
else if (encoding === "gzip") zlib.gunzip(buffer, (e, r) => e ? reject(e) : resolve(r));
else if (encoding === "deflate") zlib.inflate(buffer, (e, r) => e ? reject(e) : resolve(r));
else if (encoding === "br") zlib.brotliDecompress(buffer, (e, r) => e ? reject(e) : resolve(r));
else resolve(buffer);
});
}
4. 修改 HTML 并注入脚本 modifyHtml
这里做了两件事:替换相对路径为绝对路径(防止资源加载失败),并注入我们的自定义脚本。你可以把脚本换成任何你需要的功能,不局限于文本选择。
javascript
// 注入脚本 - 这里以文本选择捕获为例
// 你可以根据需求替换为其他任意功能
const INJECTED_SCRIPT = `<script>
(function() {
if (window.__knowledgeProxyInjected) return;
window.__knowledgeProxyInjected = true;
console.log('[RAGFlow Proxy] 脚本已注入');
// 示例:监听文本选择
document.addEventListener('mouseup', function(e) {
const selection = window.getSelection();
const text = selection.toString().trim();
if (!text) return;
// 获取选区位置、上下文等信息
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
// 提取上下文(前后各100字符)
const container = range.commonAncestorContainer;
const fullText = container.textContent || '';
const index = fullText.indexOf(text);
const before = index > 0 ? fullText.substring(Math.max(0, index - 100), index) : '';
const after = index + text.length < fullText.length ? fullText.substring(index + text.length, index + text.length + 100) : '';
window.parent.postMessage({
type: 'TEXT_SELECTED',
text: text,
context: { before, after },
position: {
x: e.clientX,
y: e.clientY,
rect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
},
meta: {
charCount: text.length,
wordCount: text.split(/\s+/).filter(w => w.length > 0).length,
timestamp: Date.now()
}
}, '*');
});
// 也可以注入其他功能,比如:
// - 监听点击事件并上报
// - 自动填充表单
// - 修改页面样式
// - 劫持 fetch 请求
// - 添加调试面板
// 通知父窗口 iframe 已就绪
window.parent.postMessage({ type: 'IFRAME_READY' }, '*');
})();
</script>`;
function modifyHtml(html, targetHost) {
// 替换相对路径为绝对路径
html = html.replace(/(href|src)=["']/([^"']+)["']/gi, '$1="https://' + targetHost + '/$2"');
html = html.replace(/url(["']?/([^"')]+)["']?)/gi, 'url(https://' + targetHost + '/$1)');
// 插入 base 标签和脚本
const baseTag = '<base href="https://' + targetHost + '/">';
const headEndIndex = html.toLowerCase().indexOf('</head>');
if (headEndIndex !== -1) {
html = html.slice(0, headEndIndex) + baseTag + INJECTED_SCRIPT + html.slice(headEndIndex);
} else {
html = baseTag + INJECTED_SCRIPT + html;
}
return html;
}
5. (可选)DOCX 等二进制文件的友好处理
RAGFlow 中可能包含 Word 文档,浏览器无法直接预览,我们可以返回一个下载提示页,并提供“请求转换”的扩展点(用于调用后端转换服务)。
javascript
function generateDocxPage(targetHost, targetPath) {
return `<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><title>文档下载</title><style>...</style></head>
<body>
<div class="box">
<h2>Word 文档</h2>
<p>该文档为 DOCX 格式,无法直接在浏览器中预览</p>
<a class="btn" href="https://${targetHost}${targetPath}" download>下载文档</a>
<button class="btn" onclick="window.parent.postMessage({type:'REQUEST_DOCX_CONVERT',url:window.location.href},'*')">请求转换</button>
</div>
</body>
</html>`;
}
如何集成到教育志项目中
-
部署代理服务
将上述 server.js 部署到服务器(或本地开发环境),通过环境变量 TARGET_HOST 指定 RAGFlow 的域名,例如:
bash
export TARGET_HOST=ragflow.example.com
node server.js
服务默认运行在 3002 端口。
-
修改前端代码
在需要展示原文的页面中,将 iframe 的 src 指向代理地址:
html
<iframe id="ragflowPreview" src="http://your-proxy-domain:3002/ragflow/path/to/document"></iframe>
-
监听消息并插入编辑器
在父窗口中监听 message 事件,收到 TEXT_SELECTED 消息后,将文本插入编辑器(如 TinyMCE、Quill 或自定义编辑器):
javascript
window.addEventListener('message', (event) => {
if (event.data.type === 'TEXT_SELECTED') {
editor.insertText(event.data.text); // 根据实际编辑器 API 调整
}
});
整个过程完全无侵入:RAGFlow 不需要任何改动,教育志项目后端也不需要提供新接口,前端只需要修改 iframe 的 src 地址即可。
为什么不用其他方案?(再次强调)
| 方案 |
问题 |
| postMessage |
需要 RAGFlow 页面内添加代码,不可能 |
| CORS |
只适用于接口,不适用于 DOM |
| 后端代理抓取 |
需要后端配合,且无法模拟用户交互(选中文本) |
| 浏览器插件 |
需要用户安装,不现实 |
而我们的代理中间层方案,独立部署、零侵入、纯前端集成,完美解决了所有痛点。
进阶功能:注入任意脚本,扩展无限可能
代理层的核心价值在于:你可以在目标页面中执行任何你想要的 JavaScript 代码。除了文本选择捕获,你还可以:
-
用户行为分析:监听点击、滚动、停留时间,上报给父窗口进行埋点。
-
动态样式调整:根据父窗口的主题,动态修改 iframe 内的 CSS,实现视觉统一。
-
表单自动填充:为 RAGFlow 的搜索框自动填入关键词(父窗口传递)。
-
请求拦截与修改:劫持 iframe 内的 fetch/XHR 请求,添加认证头或修改返回值。
-
注入调试工具:在开发环境中注入 Eruda 或 vConsole,方便调试。
你只需要修改 INJECTED_SCRIPT 的内容,就可以像操作自己的页面一样操作跨域 iframe 内的所有内容。这为前端开发打开了无限的可能性。
注意事项
-
CSP 限制:如果 RAGFlow 页面有严格的 Content-Security-Policy,可能阻止内联脚本执行。此时需要更复杂的处理(如通过 nonce 或动态创建 script 标签),但大多数系统不会设置如此严格的策略。
-
证书问题:如果 RAGFlow 使用自签名证书,设置
rejectUnauthorized: false 可临时绕过,生产环境建议妥善配置证书。
-
性能优化:代理会缓冲整个响应体,对于超大 HTML 可能占用内存。可考虑流式转发,但修改 HTML 需要完整内容,此处不再展开。
完整源码
最后,附上整合了以上所有功能的 server.js 完整源码(可直接运行):
javascript
// server.js - 教育志 RAGFlow 代理中间层
const http = require("http");
const https = require("https");
const url = require("url");
const zlib = require("zlib");
const PORT = process.env.PROXY_PORT || 3002;
const TARGET_HOST = process.env.TARGET_HOST || "ragflow.example.com";
// 注入脚本 - 你可以根据需要自由修改!
const INJECTED_SCRIPT = `<script>
(function() {
if (window.__knowledgeProxyInjected) return;
window.__knowledgeProxyInjected = true;
console.log('[RAGFlow Proxy] 脚本已注入');
// 示例:监听文本选择
document.addEventListener('mouseup', function(e) {
const selection = window.getSelection();
const text = selection.toString().trim();
if (!text) return;
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
// 提取上下文
const container = range.commonAncestorContainer;
const fullText = container.textContent || '';
const index = fullText.indexOf(text);
const before = index > 0 ? fullText.substring(Math.max(0, index - 100), index) : '';
const after = index + text.length < fullText.length ? fullText.substring(index + text.length, index + text.length + 100) : '';
window.parent.postMessage({
type: 'TEXT_SELECTED',
text: text,
context: { before, after },
position: {
x: e.clientX,
y: e.clientY,
rect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
},
meta: { charCount: text.length, wordCount: text.split(/\s+/).filter(w => w.length > 0).length }
}, '*');
});
// 你可以在这里注入任意其他功能:
// - 监听点击事件并上报
// - 自动填充表单
// - 修改页面样式
// - 劫持 fetch 请求
// - 添加调试面板
window.parent.postMessage({ type: 'IFRAME_READY' }, '*');
})();
</script>`;
// 解压函数
function decompress(buffer, encoding) {
return new Promise((resolve, reject) => {
if (!encoding || encoding === "identity") resolve(buffer);
else if (encoding === "gzip") zlib.gunzip(buffer, (err, result) => err ? reject(err) : resolve(result));
else if (encoding === "deflate") zlib.inflate(buffer, (err, result) => err ? reject(err) : resolve(result));
else if (encoding === "br") zlib.brotliDecompress(buffer, (err, result) => err ? reject(err) : resolve(result));
else resolve(buffer);
});
}
// 修改 HTML
function modifyHtml(html, targetHost) {
html = html.replace(/(href|src)=["']/([^"']+)["']/gi, '$1="https://' + targetHost + '/$2"');
html = html.replace(/url(["']?/([^"')]+)["']?)/gi, 'url(https://' + targetHost + '/$1)');
const baseTag = '<base href="https://' + targetHost + '/">';
const headEndIndex = html.toLowerCase().indexOf('</head>');
if (headEndIndex !== -1) {
html = html.slice(0, headEndIndex) + baseTag + INJECTED_SCRIPT + html.slice(headEndIndex);
} else {
html = baseTag + INJECTED_SCRIPT + html;
}
return html;
}
// 代理请求
async function proxyRequest(targetPath, res) {
const options = {
hostname: TARGET_HOST,
port: 443,
protocol: "https:",
path: targetPath,
method: "GET",
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
"Connection": "keep-alive",
},
timeout: 30000,
rejectUnauthorized: false,
};
return new Promise((resolve, reject) => {
const proxyReq = https.request(options, async (proxyRes) => {
try {
const chunks = [];
proxyRes.on("data", (chunk) => chunks.push(chunk));
const buffer = await new Promise((resolve, reject) => {
proxyRes.on("end", () => resolve(Buffer.concat(chunks)));
proxyRes.on("error", reject);
});
const encoding = proxyRes.headers["content-encoding"];
const decompressed = await decompress(buffer, encoding);
const contentType = proxyRes.headers["content-type"] || "";
const statusCode = proxyRes.statusCode;
// 处理重定向
if (statusCode >= 300 && statusCode < 400 && proxyRes.headers.location) {
const location = proxyRes.headers.location;
const newPath = location.startsWith("http") ? url.parse(location).path : location;
return proxyRequest(newPath, res).then(resolve).catch(reject);
}
if (statusCode !== 200) {
res.writeHead(statusCode, { "Content-Type": "text/plain" });
res.end("Error: " + statusCode);
return resolve();
}
const isHtml = contentType.includes("text/html");
const headers = { "Access-Control-Allow-Origin": "*", "Cache-Control": "no-cache" };
if (isHtml) {
let html = decompressed.toString("utf-8");
html = modifyHtml(html, TARGET_HOST);
headers["Content-Type"] = "text/html; charset=utf-8";
headers["Content-Length"] = Buffer.byteLength(html);
res.writeHead(200, headers);
res.end(html);
console.log("[Proxy] HTML 已处理并注入脚本");
} else {
headers["Content-Type"] = contentType || "application/octet-stream";
res.writeHead(200, headers);
res.end(decompressed);
}
resolve();
} catch (err) {
reject(err);
}
});
proxyReq.on("error", reject);
proxyReq.on("timeout", () => {
proxyReq.destroy();
reject(new Error("Timeout"));
});
proxyReq.end();
});
}
// 创建 HTTP 服务器
const server = http.createServer((req, res) => {
const parsed = url.parse(req.url, true);
const pathname = parsed.pathname;
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
if (req.method === "OPTIONS") {
res.writeHead(200);
return res.end();
}
if (pathname === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok", target: TARGET_HOST }));
} else {
proxyRequest(pathname + (parsed.search || ""), res).catch((err) => {
console.error("[Proxy Error]", err);
res.writeHead(502, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: err.message }));
});
}
});
server.listen(PORT, () => {
console.log(`[教育志 RAGFlow 代理] 运行在 http://localhost:${PORT}`);
console.log(`目标主机: ${TARGET_HOST}`);
});
总结
通过自建 Node 代理中间层,我们在零后端配合的情况下,完美实现了跨域 iframe 中选中文本的捕获,并将文本实时传递到教育志项目的编辑器中。但更重要的是,这个方案为你打开了在任意第三方网页上执行任意脚本的大门——注入文本选择只是其中一个小小例子。
当你再次面对跨域 iframe DOM 操作难题时,不妨试试这个“中间人”思路。代码在手,跨域我有!
希望这篇文章能帮助到遇到类似问题的同行。有任何疑问或改进建议,欢迎在评论区留言交流。