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

3 阅读9分钟

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>`;
}

如何集成到教育志项目中

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

    bash

    export TARGET_HOST=ragflow.example.com
    node server.js
    

    服务默认运行在 3002 端口。

  2. 修改前端代码
    在需要展示原文的页面中,将 iframe 的 src 指向代理地址:

    html

    <iframe id="ragflowPreview" src="http://your-proxy-domain:3002/ragflow/path/to/document"></iframe>
    
  3. 监听消息并插入编辑器
    在父窗口中监听 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 操作难题时,不妨试试这个“中间人”思路。代码在手,跨域我有!


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