在上一部分,我们搭建了插件的基本骨架,并成功将其加载到了浏览器中。现在,我们将进入核心功能的开发,让插件真正开始工作!本篇将重点介绍:
-
内容脚本 (
content_script.js
): 如何与目标网页交互,移除复制限制。 -
弹出界面 (
popup.html
/popup.js
): 如何创建用户界面,让用户控制插件行为。
回顾
我们已经有了包含 manifest.json
, 图标和简单 popup.html
的项目结构。manifest.json
中也定义了 content_scripts
和 action.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 加载完成后执行初始化函数。
测试当前进展
-
回到
chrome://extensions
页面。 -
点击插件卡片上的刷新图标。
-
找一个有复制限制的网站(或者一个普通网站也可以看到效果)。
-
刷新该网站页面 (重要!让新的
content_script.js
注入)。 -
尝试选择文本、右键点击。如果网站有限制,现在应该被解除了(因为
content_script.js
默认启用了)。 -
点击浏览器工具栏上的插件图标。
-
应该看到新的弹出界面,包含标题、开关(默认开启)、状态(显示 Enabled)、作者信息。
-
尝试点击开关。你会看到状态文本在 "Enabled" 和 "Disabled" 之间切换,并且控制台会打印日志。但是,网页上的解除限制行为暂时不会改变,因为我们还没实现
popup.js
和content_script.js
之间的状态同步。
-
做得好! 你的插件现在有了核心的解除限制能力和一个基本的交互界面。
下一步
在最后的第三部分教程中,我们将添加更高级的功能并学习如何部署:
-
国际化 (i18n): 让插件支持中英文切换。
-
持久化与站点设置: 使用
chrome.storage
记住用户对每个网站的启用/禁用设置,并让content_script.js
响应这些设置。 -
打包与安装: 如何将插件打包成
.crx
文件,以及如何安装已解压和打包后的插件。
继续努力!