跨域获取 iframe 选中文本?自己写个代理中间层,再也不求后端!

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

ini 复制代码
<iframe src="http://localhost:3002/ragflow/docs/123.html"></iframe>

当用户在 iframe 内选中任何文本,父窗口就能收到包含文本内容、位置、上下文等详细信息的消息:

json

css 复制代码
{
  "type": "TEXT_SELECTED",
  "text": "光绪二十四年(1898年),京师大学堂成立...",
  "context": {
    "before": "此前,中国近代教育...",
    "after": "此后,各省纷纷设立学堂..."
  },
  "position": { "x": 150, "y": 200 },
  "meta": { "charCount": 48, "wordCount": 9 }
}

父窗口收到消息后,可以立即将文本插入编辑器中,整个过程对用户透明,RAGFlow 无需任何改动,教育志项目后端也无需介入

原理图解

整个方案的核心是:利用 Node.js 创建一个代理服务器,将 RAGFlow 的页面"偷"回来,然后在返回前注入我们自己的脚本

text

less 复制代码
浏览器 (教育志项目) 
    │
    │ 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

ini 复制代码
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

ini 复制代码
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

scss 复制代码
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

ini 复制代码
// 注入脚本 - 这里以文本选择捕获为例
// 你可以根据需求替换为其他任意功能
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

xml 复制代码
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>`;
}

如何集成到教育志项目中

  1. 部署代理服务

    将上述 server.js 部署到服务器(或本地开发环境),通过环境变量 TARGET_HOST 指定 RAGFlow 的域名,例如:

    bash

    ini 复制代码
    export TARGET_HOST=ragflow.example.com
    node server.js

    服务默认运行在 3002 端口。

  2. 修改前端代码

    在需要展示原文的页面中,将 iframe 的 src 指向代理地址:

    html

    bash 复制代码
    <iframe id="ragflowPreview" src="http://your-proxy-domain:3002/ragflow/path/to/document"></iframe>
  3. 监听消息并插入编辑器

    在父窗口中监听 message 事件,收到 TEXT_SELECTED 消息后,将文本插入编辑器(如 TinyMCE、Quill 或自定义编辑器):

    javascript

    csharp 复制代码
    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

ini 复制代码
// 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 操作难题时,不妨试试这个"中间人"思路。代码在手,跨域我有!


希望这篇文章能帮助到遇到类似问题的同行。有任何疑问或改进建议,欢迎在评论区留言交流。

相关推荐
比尔盖茨的大脑2 小时前
事件循环底层原理:从 V8 引擎到浏览器实现
前端·javascript·面试
天才熊猫君2 小时前
Vue3 命令式弹窗原理和 provide/inject 隔离机制详解
前端
bluceli2 小时前
Vue 3 Composition API深度解析:构建可复用逻辑的终极方案
前端·vue.js
程序员ys2 小时前
前端权限控制设计
前端·vue.js·react.js
忆江南2 小时前
Flutter GetX 深入浅出详解
前端
滕青山2 小时前
腾讯域名拦截查询 在线工具核心JS实现
前端·javascript·vue.js
Qinana2 小时前
从 URL 输入到页面展示:一场跨越进程与协议的“装修”大戏
前端·面试·程序员
不会敲代码12 小时前
从零开始用 TypeScript + React 打造类型安全的 Todo 应用
前端·react.js·typescript
gyx_这个杀手不太冷静3 小时前
让 AI 替你写代码:OpenCode 完全配置与高效使用手册
前端·ai编程