我在45分钟内为Claude构建了一个Chrome扩展(以下是我的收获)

发布: (2026年3月3日 GMT+8 18:17)
9 分钟阅读
原文: Dev.to

抱歉,我需要您提供要翻译的完整文本(即文章的正文内容)。目前只收到来源链接,若您把文章的文字粘贴在这里,我就可以按照要求把它翻译成简体中文并保留原有的格式、代码块和 URL。谢谢!

我构建的内容

一个在 claude.ai 上运行的 Chrome 扩展,能够在你输入时实时显示令牌估算。它会展示:

  • 令牌计数
  • 已使用上下文的百分比
  • 大致费用

徽章会在每一次按键时更新,并且还能追踪整个会话中你发送的令牌数量。

TL;DR – 从空文件夹到可工作的未打包扩展,约 45 分钟完成。

Manifest V3 不再是可选的

Chrome 正在淘汰 Manifest V2 扩展。迁移已经“即将到来”三年,但实际上现在已经开始。Manifest V3 是你必须使用的。

让人卡住的主要点是:后台脚本现在是 service workers。它们没有 DOM 访问权限,不能使用 setInterval 来进行长时间任务(worker 会被挂起),也不能在内存中保持持久状态。

我的后台脚本最终只有 15 行,因为它基本上什么也做不了:

// background.js – service worker
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
  if (msg.type === 'MODEL_CHANGED') {
    chrome.tabs.query({ url: 'https://claude.ai/*' }, (tabs) => {
      tabs.forEach((tab) => {
        chrome.tabs.sendMessage(tab.id, msg);
      });
    });
    sendResponse({ ok: true });
  }
});

它只是把弹出窗口的模型更改消息转发给内容脚本。任何有状态的内容都存放在 chrome.storage.local 中。

MutationObserver,而非轮询

第一反应是使用 setInterval 每 200 毫秒轮询检查输入框。这在 MV2 中还能工作,但在 MV3 中是个坏主意——服务工作线程随时可能被挂起,而在内容脚本中使用 setInterval 感觉不对,因为还有更好的办法。

claude.ai 是一个 React 应用。消息输入框会在你在对话之间切换时挂载和卸载。如果在页面加载时获取元素,可能还不存在,而保存的引用在重新渲染后会变得陈旧。

MutationObserver 能干净利落地处理这个问题:

const observer = new MutationObserver(() => {
  const el = findInputElement();
  if (el) attachToInput(el);
});

observer.observe(document.body, { childList: true, subtree: true });

每当 DOM 变化时,我们检查编辑器是否出现,如果出现就附加监听器。attachToInput 会防止重复附加:

let inputEl = null;

function attachToInput(el) {
  if (inputEl === el) return; // already watching this element
  inputEl = el;
  // attach input handlers…
}

这种模式适用于任何面向现代单页应用(SPA)的扩展。

定位编辑器

Claude 的消息输入是一个 contenteditable div,而不是 textarea。你应通过 el.innerText 读取其内容,而不是 el.value

可靠地找到它是关键难点。React 应用中的类名经常变化——通常是哈希或自动生成的——因此不要依赖它们。相反,使用基于属性的选择器。Claude 为编辑器添加了 data-testid 属性,该属性用于测试,通常比较稳定:

function findInputElement() {
  // Primary: contenteditable div used by Claude
  const ce = document.querySelector('div[contenteditable="true"][data-testid]');
  if (ce) return ce;

  // Fallbacks: common patterns if the primary selector breaks
  const fallback = document.querySelector(
    'div[contenteditable="true"].ProseMirror,' +
    'div[contenteditable="true"][class*="composer"],' +
    'div[contenteditable="true"][placeholder]'
  );
  return fallback || document.querySelector('div[contenteditable="true"]');
}

使用多个回退方案,最不具体的放在最后,可让扩展在 UI 变化时有更大的存活机会。

在没有 API 的情况下进行令牌计数

你无法直接在 Chrome 扩展中调用 Anthropic 分词器。进行 API 调用既繁琐又会导致实时计数过慢。

对英文文本来说,一个简单的近似方法效果很好:words × 1.3。它并不完全精确——代码片段和特殊字符会使计数偏差——但足以区分“≈ 800 tokens”和“≈ 8 000 tokens”,这正是我们的目标所在。

function countTokens(text) {
  if (!text || !text.trim()) return 0;
  const words = text.trim().split(/\s+/).length;
  return Math.round(words * 1.3);
}

在弹出窗口中,我还会显示一个粗略的费用估算。Claude 3.5 Sonnet 的费用为 $3 / 百万输入令牌,因此一条 1 000‑token 的消息大约花费 $0.003。这对关注 API 开支的用户非常实用。

徽章

在别人的页面中注入 UI 总是有点乱。我使用固定定位、高 z-index,并希望它不会与站点的 CSS 冲突。

当输入框为空时徽章会隐藏,一旦开始输入就会显示。它会在 inputkeyup 事件上都进行更新,因为 input 并不总是在程序化更改时触发:

el.addEventListener('input', handler, { passive: true });
el.addEventListener('keyup', handler, { passive: true });

passive: true 告诉浏览器这些处理函数不会调用 preventDefault(),因此不会阻塞滚动。这是一个小的性能提升,但值得养成这个习惯。

清单

整个扩展的设置大约有 25 行:

{
  "manifest_version": 3,
  "name": "Claude Token Counter",
  "version": "1.0.0",
  "description": "Live token counter for claude.ai",
  "permissions": ["storage", "tabs"],
  "background": {
    "service_worker": "background.js"
  },
  "action": {
    "default_popup": "popup.html",
    "default_icon": {
      "16": "icons/icon16.png",
      "48": "icons/icon48.png",
      "128": "icons/icon128.png"
    }
  },
  "content_scripts": [
    {
      "matches": ["https://claude.ai/*"],
      "js": ["content.js"],
      "css": ["styles.css"],
      "run_at": "document_idle"
    }
  ],
  "icons": {
    "16": "icons/icon16.png",
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  }
}

就这样!扩展可以正常工作,遵守 MV3 限制,并实时显示您在 Claude 上消耗的 token 数量。祝使用愉快!

Chrome 扩展清单 (MV3) – 替代示例

{
  "manifest_version": 3,
  "name": "Claude Token Counter",
  "description": "Shows token usage for Claude AI chats.",
  "version": "0.1",
  "icons": {
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  },
  "action": {
    "default_popup": "popup.html",
    "default_icon": {
      "48": "icons/icon48.png",
      "128": "icons/icon128.png"
    }
  },
  "content_scripts": [
    {
      "matches": ["https://claude.ai/*"],
      "js": ["content.js"],
      "run_at": "document_idle"
    }
  ],
  "background": {
    "service_worker": "background.js"
  },
  "permissions": ["storage"],
  "host_permissions": ["https://claude.ai/*"]
}

注意: host_permissions 是 Manifest V3 中独立于 permissions 的设置。我花了一分钟才弄清楚为什么我的内容脚本会悄悄失效——两者都是必需的。

全屏控制(演示)

Enter fullscreen mode
Exit fullscreen mode

让我惊讶的事

会话跟踪

我想累计整个对话中发送的 token 总数。难点在于确定 何时 实际发送了一条消息。

没有干净的 “用户提交消息” 事件。变通办法是监听 Enter 键(不带 Shift,因为 Shift + Enter 会换行)以及发送按钮的点击:

document.addEventListener(
  'keydown',
  (e) => {
    if (
      e.key === 'Enter' &&
      !e.shiftKey &&
      inputEl &&
      document.activeElement === inputEl
    ) {
      // Slight delay, let Claude's UI process it first
      setTimeout(onMessageSent, 100);
    }
  },
  true
);

为什么要延迟 100 ms?

这个延迟是一个技巧。如果不加延迟,onMessageSent 会在输入框清空 之前 运行,导致计数器在错误的时刻被重置。加上延迟后,时机就能正确对齐。虽然脆弱,但能工作。

状态

  • 扩展: 已提交至 Chrome 网上应用店;目前正在审查中(审查过程可能需要几天到几周不等)。
  • 源代码: 您也可以在启用开发者模式后,通过 chrome://extensions/ 加载未打包的版本。
  • 网页版: 使用相同的令牌计数逻辑(词数 × 1.3),无需任何安装。
0 浏览
Back to Blog

相关文章

阅读更多 »