深入核心:开发解除复制限制插件 (二) - 内容脚本与弹出交互

在上一部分,我们搭建了插件的基本骨架,并成功将其加载到了浏览器中。现在,我们将进入核心功能的开发,让插件真正开始工作!本篇将重点介绍:

  • 内容脚本 (content_script.js): 如何与目标网页交互,移除复制限制。

  • 弹出界面 (popup.html / popup.js): 如何创建用户界面,让用户控制插件行为。

回顾

我们已经有了包含 manifest.json, 图标和简单 popup.html 的项目结构。manifest.json 中也定义了 content_scriptsaction.default_popup 的配置。

内容脚本 (content_script.js):与网页的桥梁

内容脚本是插件注入到网页中的 JavaScript 文件。它拥有访问和操作网页 DOM 的能力,但它运行在一个隔离的环境中,不能直接访问网页自身的 JavaScript 变量或函数(反之亦然),但可以共享 DOM。

1. 创建 content_script.js 文件

copy-unblocker 根目录下创建 content_script.js 文件。

2. 实现解除限制的核心逻辑

我们将使用两种主要策略来解除限制:

  • CSS 注入: 强制覆盖禁止文本选择的 CSS 样式 (user-select: none)。

  • 事件拦截: 阻止网页监听和响应与复制、选择、右键相关的 JavaScript 事件。

将以下代码添加到 content_script.js

// content_script.js
​
console.log("解除复制限制插件:内容脚本已加载", window.location.hostname);
​
// 全局状态变量,稍后会由 storage 初始化或更新
window.copyUnblockerEnabled = true; // 初始默认值
let currentHostname = ''; // 保存当前主机名
​
/**
 * 尝试解除网页限制的核心函数
 */
function unlockRestrictions() {
  // 首先检查插件是否对当前站点启用
  if (!window.copyUnblockerEnabled) {
      console.log(`解除复制限制插件 [${currentHostname}]: 当前已禁用,跳过解除操作。`);
      // 注意:禁用时,理想情况是恢复限制,但这很难完美实现。
      // 我们简单地不再主动解除,但之前添加的样式和移除的监听器可能仍然生效。
      return;
  }
  console.log(`解除复制限制插件 [${currentHostname}]: 正在尝试解除限制...`);
​
  // --- 策略一:CSS 注入 ---
  try {
    const styleId = 'copy-unblocker-style';
    // 避免重复注入样式
    if (document.getElementById(styleId)) {
        console.log(`[${currentHostname}] CSS 样式已存在。`);
    } else {
        const style = document.createElement('style');
        style.id = styleId;
        style.textContent = `
          * {
            -webkit-user-select: auto !important; /* Safari, Chrome */
            -moz-user-select: auto !important;    /* Firefox */
            -ms-user-select: auto !important;     /* IE/Edge */
            user-select: auto !important;         /* Standard */
          }
          /* 针对特定元素可能存在的覆盖 */
          body, div, span, p, article, main, section {
            -webkit-user-select: auto !important;
            -moz-user-select: auto !important;
            -ms-user-select: auto !important;
            user-select: auto !important;
          }
        `;
        (document.head || document.documentElement).appendChild(style);
        console.log(`[${currentHostname}] 已注入CSS覆盖 user-select`);
    }
  } catch (error) {
    console.error(`[${currentHostname}] 注入CSS失败:`, error);
  }
​
  // --- 策略二:事件拦截 ---
  const eventsToBlock = [
      'copy',        // 复制事件
      'cut',         // 剪切事件
      'selectstart', // 开始选择文本事件
      'contextmenu', // 右键菜单事件
      'dragstart',   // 开始拖拽事件
      'mousedown'    // 鼠标按下事件 (有些网站用它阻止选择)
  ];
​
  // 存储我们自己添加的监听器,以便未来可能移除
  if (!window.copyUnblockerListeners) {
      window.copyUnblockerListeners = {};
  }
​
  eventsToBlock.forEach(eventType => {
      // 检查是否已为该事件类型添加了监听器
      if (!window.copyUnblockerListeners[eventType]) {
            const listener = (e) => stopPropagationImmediately(e, eventType);
            // 在捕获阶段监听,优先级更高,尽早阻止事件传播
            window.addEventListener(eventType, listener, true);
            document.addEventListener(eventType, listener, true); // 同时监听 document
            window.copyUnblockerListeners[eventType] = listener; // 保存引用
            console.log(`[${currentHostname}] 已添加 ${eventType} 事件监听器`);
      }
  });
​
  function stopPropagationImmediately(e, eventType) {
      // 再次检查启用状态
      if (!window.copyUnblockerEnabled) return;
      console.log(`解除复制限制 (启用中) [${currentHostname}]: 阻止了 ${eventType} 事件`);
      e.stopImmediatePropagation(); // 阻止事件继续传播,并且阻止同一元素上该事件的其他监听器执行
      // 对于右键和选择,有时需要阻止默认行为,但要小心副作用
      // if (eventType === 'selectstart' || eventType === 'contextmenu') {
      //      e.preventDefault();
      // }
  }
​
  // --- 策略三:移除 body 上的内联事件处理器 (针对旧网站) ---
  try {
        const body = document.body;
        if (body) {
            eventsToBlock.forEach(event => {
                const attr = `on${event}`;
                if (body.hasAttribute(attr)) {
                    body.removeAttribute(attr);
                    console.log(`[${currentHostname}] 移除了 body 的 ${attr} 属性`);
                }
            });
        }
    } catch(error) {
        console.error(`[${currentHostname}] 移除 body 事件属性失败:`, error);
    }
​
  console.log(`[${currentHostname}] 基础限制解除尝试完成 (状态: ${window.copyUnblockerEnabled ? '启用' : '禁用'})`);
}
​
​
// ---- 初始化和运行逻辑 (将在 Part 3 中完善状态管理) ----
​
function runUnlockLogicMultipleTimes() {
    // 在不同阶段尝试运行,确保覆盖各种加载情况
    unlockRestrictions(); // 尝试立即运行
    // DOM 加载完成后运行
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', unlockRestrictions);
    } else {
      setTimeout(unlockRestrictions, 50); // DOM 已加载,稍后运行
    }
    // 页面完全加载(包括图片等资源)后再次运行
    window.addEventListener('load', () => setTimeout(unlockRestrictions, 150));
}
​
// --- 脚本入口 ---
// (Part 3 会在这里添加从 Storage 读取状态的逻辑)
currentHostname = window.location.hostname; // 先获取主机名
runUnlockLogicMultipleTimes(); // 运行解除逻辑
​

代码解释:

  • 我们定义了 unlockRestrictions 函数来封装所有的解除逻辑。

  • CSS 注入: 创建一个 <style> 元素,包含强制覆盖 user-select 的 CSS 规则,并将其添加到页面的 <head><html> 中。使用 !important 提高优先级。我们添加了 ID 并检查,避免重复注入。

  • 事件拦截:

    • 定义了一个要拦截的事件列表 eventsToBlock

    • 使用 window.addEventListener(eventType, listener, true)document.addEventListener(eventType, listener, true) 来监听这些事件。关键是第三个参数 true,表示在捕获阶段 (capturing phase) 监听。这意味着我们的监听器会在事件到达目标元素之前、在冒泡阶段之前执行,可以更早地阻止事件传播。

    • stopPropagationImmediately(e) 函数被调用时,会执行 e.stopImmediatePropagation()。这不仅会阻止事件冒泡或继续捕获,还会阻止同一元素上绑定到该事件的其他监听器被执行,比 e.stopPropagation() 更强力。

  • 移除内联处理器: 简单地检查 <body> 元素是否直接设置了 oncopy, onselectstart 等属性,并移除它们。

  • 运行逻辑: 我们在脚本加载时、DOM 加载完成时、页面完全加载后都尝试运行 unlockRestrictions,以应对不同的网页加载方式和脚本执行时机。

  • 状态检查: unlockRestrictions 函数开头会检查 window.copyUnblockerEnabled 变量(我们暂时硬编码为 true,第三部分会动态设置)。如果为 false,则跳过所有解除操作。

弹出界面 (popup.html / popup.js):用户控制中心

现在,我们需要一个界面让用户可以针对当前网站启用或禁用插件。

1. 更新 popup.html

修改 popup.html,加入开关、状态显示和作者信息:

<!DOCTYPE html>
<html>
<head>
  <title>__MSG_popupTitle__</title> <!-- 使用 i18n 键名 -->
  <meta charset="UTF-8">
  <style>
    body { font-family: sans-serif; width: 220px; padding: 10px; font-size: 14px; }
    .row { display: flex; align-items: center; margin-bottom: 10px; }
    .label { flex-grow: 1; margin-right: 10px;}
    .switch { position: relative; display: inline-block; width: 50px; height: 24px; flex-shrink: 0;}
    .switch input { opacity: 0; width: 0; height: 0; }
    .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .4s; border-radius: 24px; }
    .slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: white; transition: .4s; border-radius: 50%; }
    input:checked + .slider { background-color: #2196F3; }
    input:checked + .slider:before { transform: translateX(26px); }
    .status { margin-top: 10px; font-size: 0.9em; color: #555; }
    h3 { margin-top: 0; margin-bottom: 15px; font-size: 1.1em; text-align: center;}
    .footer { margin-top: 15px; padding-top: 10px; border-top: 1px solid #eee; font-size: 0.8em; color: #777; text-align: center; }
    .footer a { color: #007bff; text-decoration: none; }
    .footer a:hover { text-decoration: underline; }
  </style>
</head>
<body>
  <h3 id="popupTitle"></h3> <!-- 标题 -->
  <div class="row">
    <span class="label" id="enableLabel"></span> <!-- 启用标签 -->
    <label class="switch">
      <input type="checkbox" id="enableSwitch">
      <span class="slider round"></span>
    </label>
  </div>
  <div class="status">
      <span id="statusLabel"></span>: <span id="statusText"></span> <!-- 状态显示 -->
  </div>
​
  <div class="footer"> <!-- 作者信息 -->
    <span id="createdByLabel"></span> Brian (<a href="mailto:bianqiang@gmail.com" target="_blank" title="Send feedback">bianqiang@gmail.com</a>)
  </div>
​
  <script src="popup.js"></script> <!-- 引入 JS -->
</body>
</html>

2. 创建 popup.js 文件

copy-unblocker 根目录下创建 popup.js 文件,用于处理弹出窗口的逻辑:

// popup.js
​
// 获取 HTML 元素引用
const enableSwitch = document.getElementById('enableSwitch');
const popupTitle = document.getElementById('popupTitle');
const enableLabel = document.getElementById('enableLabel');
const statusLabel = document.getElementById('statusLabel');
const statusText = document.getElementById('statusText');
const createdByLabel = document.getElementById('createdByLabel');
​
/**
 * 设置弹出窗口的静态文本 (将在 Part 3 结合 i18n 使用)
 */
function setLocaleText() {
    // 暂时硬编码,Part 3 会替换为 chrome.i18n.getMessage()
    popupTitle.textContent = "Unlock Copy Restrictions";
    enableLabel.textContent = "Enable on this site:";
    statusLabel.textContent = "Status";
    createdByLabel.textContent = "Created by:";
    document.title = "Unlock Copy"; // 设置窗口标题
}
​
/**
 * 获取当前激活的标签页信息和主机名
 * @returns {Promise<{tab: chrome.tabs.Tab | null, hostname: string | null}>}
 */
async function getCurrentTabAndHost() {
  try {
    // 优先查询当前窗口的激活标签页
    let queryOptions = { active: true, currentWindow: true };
    let [tab] = await chrome.tabs.query(queryOptions);
​
    // 检查 tab 是否有效,URL 是否为 http/https
    if (tab?.url && tab.url.startsWith('http')) {
        try {
            const url = new URL(tab.url);
            return { tab, hostname: url.hostname };
        } catch (e) {
            console.warn("无法解析 URL:", tab.url, e);
        }
    }
​
    // 如果当前窗口查询失败或 URL 无效,尝试查询最后一个获得焦点的窗口
    queryOptions = { active: true, lastFocusedWindow: true };
    [tab] = await chrome.tabs.query(queryOptions);
     if (tab?.url && tab.url.startsWith('http')) {
        try {
            const url = new URL(tab.url);
            return { tab, hostname: url.hostname };
        } catch (e) {
            console.warn("无法解析 URL (fallback):", tab.url, e);
        }
    }
​
  } catch (e) {
      console.error("查询标签页出错:", e);
  }
  // 获取失败或 URL 不支持
  return { tab: null, hostname: null };
}
​
/**
 * 更新弹出窗口的开关状态和状态文本
 */
async function updatePopupStatus() {
  statusText.textContent = "Loading..."; // 初始显示加载中
  const { tab, hostname } = await getCurrentTabAndHost();
​
  if (!tab || !hostname) {
      // 无法获取标签页或当前页面不支持 (如 chrome://)
      statusText.textContent = "Unsupported Page";
      enableSwitch.checked = false;
      enableSwitch.disabled = true; // 禁用开关
      return;
  }
​
  // --- Part 3 将在这里从 chrome.storage 读取状态 ---
  // 暂时假设默认启用
  const isEnabled = true; // 硬编码为 true
  enableSwitch.checked = isEnabled;
  enableSwitch.disabled = false; // 启用开关
  statusText.textContent = isEnabled ? "Enabled" : "Disabled";
}
​
/**
 * 处理开关状态变化事件
 */
enableSwitch.addEventListener('change', async () => {
  const { tab, hostname } = await getCurrentTabAndHost();
  if (!tab || !hostname) return; // 不应发生,但做检查
​
  const isEnabled = enableSwitch.checked;
  statusText.textContent = isEnabled ? "Enabled" : "Disabled";
​
  console.log(`用户为 ${hostname} 设置状态为: ${isEnabled}`);
​
  // --- Part 3 将在这里将状态保存到 chrome.storage ---
  // --- Part 3 之前,content_script 不会响应这个变化 ---
​
});
​
// --- 初始化弹出窗口 ---
document.addEventListener('DOMContentLoaded', () => {
    setLocaleText(); // 设置静态文本
    updatePopupStatus(); // 获取并设置初始状态
});

代码解释:

  • 获取了 HTML 中各个元素的引用。

  • setLocaleText: 暂时硬编码英文文本,第三部分会用 chrome.i18n 替换。

  • getCurrentTabAndHost: 异步函数,使用 chrome.tabs.query 获取当前激活的标签页,并从中解析出 hostname (主机名,如 www.example.com)。增加了对非 http/https 页面的处理。

  • updatePopupStatus: 获取当前状态(暂时硬编码为 true)并更新开关的选中状态和状态文本。处理了无法获取标签或页面不支持的情况。

  • addEventListener('change', ...): 监听开关的变化事件,当用户点击开关时,获取新的状态并更新状态文本。(保存状态和通知内容脚本的逻辑将在第三部分添加)。

  • DOMContentLoaded: 确保在 HTML 加载完成后执行初始化函数。

测试当前进展

  1. 回到 chrome://extensions 页面。

  2. 点击插件卡片上的刷新图标

  3. 找一个有复制限制的网站(或者一个普通网站也可以看到效果)。

  4. 刷新该网站页面 (重要!让新的 content_script.js 注入)。

  5. 尝试选择文本、右键点击。如果网站有限制,现在应该被解除了(因为 content_script.js 默认启用了)。

  6. 点击浏览器工具栏上的插件图标。

    • 应该看到新的弹出界面,包含标题、开关(默认开启)、状态(显示 Enabled)、作者信息。

    • 尝试点击开关。你会看到状态文本在 "Enabled" 和 "Disabled" 之间切换,并且控制台会打印日志。但是,网页上的解除限制行为暂时不会改变,因为我们还没实现 popup.jscontent_script.js 之间的状态同步。

做得好! 你的插件现在有了核心的解除限制能力和一个基本的交互界面。

下一步

在最后的第三部分教程中,我们将添加更高级的功能并学习如何部署:

  • 国际化 (i18n): 让插件支持中英文切换。

  • 持久化与站点设置: 使用 chrome.storage 记住用户对每个网站的启用/禁用设置,并让 content_script.js 响应这些设置。

  • 打包与安装: 如何将插件打包成 .crx 文件,以及如何安装已解压和打包后的插件。

继续努力!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值