Manifest V3 实战:从补天网站逆向到 Chrome 扩展开发全记录

Manifest V3 实战:从补天网站逆向到 Chrome 扩展开发全记录

本文将完整记录如何从零逆向补天漏洞赏金平台的 API,并基于 Manifest V3 开发一款实用的 Chrome 扩展------补天SRC助手。涵盖接口抓包分析、MV3 架构设计、Content Script 双 World 通信、智能推荐算法等核心环节,附完整源码。


一、为什么要做这个扩展?

补天(butian.net)是国内主流的漏洞赏金平台,白帽子们每天都要在上面浏览 SRC 列表、比较奖金范围、查看新入驻企业。但原版网站存在几个痛点:没有跨维度的排序筛选,没有"性价比"维度的推荐,每次都要打开完整页面才能获取信息。我希望做一个浏览器扩展,在工具栏弹窗里一键聚合所有关键数据,同时顺带记录自己在补天上的操作轨迹,方便复盘报名流程。

这个需求本身不复杂,但在 Manifest V3 的限制下,每一步都踩了不少坑。下面从逆向分析开始,一步步还原整个开发过程。


二、逆向补天 API:抓包与接口分析

2.1 打开 DevTools,观察网络请求

打开 https://www.butian.net/Reward/plan(奖励计划页面),在 Network 面板中筛选 XHR 请求,能看到几个关键接口:

第一个是 平台统计数据接口 ,地址是 GET /Rank/getNumbers?ajax=1,返回结构如下:

yaml 复制代码
{
  "status": 1,
  "data": {
    "loo": 358921,
    "reward": 289000000,
    "whitehat": 52130,
    "company": 1247
  }
}

字段含义一目了然:累计漏洞数、奖励总额(单位:元)、白帽子数量、入驻企业数。注意 ajax=1 这个查询参数,补天用它来区分页面请求和 AJAX 数据请求。

第二个是 奖励企业列表 ,地址是 POST /Reward/corps,请求体是 name=&sort=2,表示不按名称筛选、按默认排序。返回一个 data.list 数组,每个元素包含 company_idcompany_namelogomax_rewardmin_rewardservice_statuschange_timeintroduce 等字段。

第三个是 新入驻企业列表 ,地址是 POST /Reward/pub,请求体是 name=&p=1。返回结构类似,但字段稍有不同,logo 字段叫 avatar

2.2 请求特征分析

观察请求头,有几个关键点。所有 API 请求都带有 X-Requested-With: XMLHttpRequest 头,这是补天后端判断是否为 AJAX 请求的依据。POST 请求的 Content-Type 是 application/x-www-form-urlencoded,不是 JSON。最重要的是,这些接口依赖 Cookie 中的 Session 信息来判断登录状态------虽然上述三个接口不需要登录也能访问,但后续如果要做报名等操作,Session 是必须的。

这就引出了 MV3 架构中最核心的设计决策:如何在扩展中发起带正确 Cookie 的请求?


三、Manifest V3 的困境与架构设计

3.1 MV3 vs MV2:为什么不能直接在 Background 里 fetch?

在 Manifest V2 时代,Background Page 是一个持久化的页面环境,可以直接发起 fetch 请求,配合 webRequest API 修改请求头,甚至注入 Cookie。但在 MV3 中,Background 被替换为 Service Worker,它运行在独立的上下文中,不携带任何网站的 Cookie

这意味着在 Service Worker 里直接请求 https://www.butian.net/Rank/getNumbers?ajax=1,拿到的要么是未登录状态的数据,要么直接被后端拒绝。同时 MV3 移除了 webRequestBlocking 能力(除了企业策略部署的扩展),无法在请求级别动态注入 Cookie。

3.2 架构方案:Content Script 代理模式

最终采用的方案是让 Content Script 作为 API 代理。思路如下:

scss 复制代码
Popup (前端 UI)
    ↓ chrome.runtime.sendMessage
Service Worker (调度中心/缓存层)
    ↓ chrome.tabs.sendMessage
Content Script (运行在 butian.net 页面上下文)
    ↓ fetch (same-origin, 自动携带 Cookie)
补天 API 服务器

Content Script 注入到 https://www.butian.net/* 页面中,它执行的 fetch 请求天然属于同源请求,浏览器会自动附加该域名下的所有 Cookie。Service Worker 负责调度和缓存,Popup 只负责 UI 渲染。这种三层架构在 MV3 中非常常见,也是目前绕过 Cookie 限制的标准做法。

但这里有一个前提条件:必须有一个打开着的补天标签页。如果用户没有打开补天网站怎么办?我在 Service Worker 中加入了自动创建后台标签页的逻辑,这个后面详细展开。

3.3 manifest.json 配置详解

css 复制代码
{
  "manifest_version": 3,
  "name": "补天SRC助手",
  "version": "1.0.0",
  "permissions": ["storage", "tabs"],
  "host_permissions": [
    "https://www.butian.net/*",
    "https://oss-yg-cztt.yun.qianxin.com/*"
  ],
  "content_scripts": [
    {
      "matches": ["https://www.butian.net/*"],
      "js": ["content/api-bridge.js"],
      "run_at": "document_start"
    },
    {
      "matches": ["https://www.butian.net/*"],
      "js": ["content/page-tracker.js"],
      "run_at": "document_start",
      "world": "MAIN"
    }
  ],
  "background": {
    "service_worker": "background/service-worker.js"
  },
  "action": {
    "default_popup": "popup/popup.html"
  }
}

几个值得注意的配置:

permissions 只申请了 storagetabsstorage 用于本地缓存,tabs 用于查询和创建标签页。没有申请 cookieswebRequest,因为我们根本不需要------Cookie 由 Content Script 的同源 fetch 自动处理。

两个 Content Script 使用了不同的 worldapi-bridge.js 运行在默认的 ISOLATED world(隔离环境),它可以与 Service Worker 通过 chrome.runtime 通信。page-tracker.js 运行在 MAIN world(页面上下文),它能拦截页面原生的 XMLHttpRequestfetchpushState 等方法。这是 MV3 引入的一个非常强大的特性,后面会详细讲解。

host_permissions 包含了补天的 CDN 域名 。企业 logo 图片托管在 oss-yg-cztt.yun.qianxin.com 上,需要这个权限才能在扩展中正常加载。


四、Service Worker:调度中心与缓存层

4.1 缓存设计

Service Worker 在 MV3 中有一个重要特性:它不是持久化运行的。空闲一段时间后会被浏览器终止,下次收到消息时重新启动。这意味着内存中的变量不可靠,必须用持久化存储来做缓存。

我选择了 chrome.storage.local,配合一个简单的 TTL 机制:

javascript 复制代码
const CACHE_TTL = 5 * 60 * 1000; // 5分钟

async function getCachedData(key) {
  return new Promise((resolve) => {
    chrome.storage.local.get([key, `${key}_ts`], (result) => {
      const data = result[key];
      const ts = result[`${key}_ts`];
      if (data && ts && Date.now() - ts < CACHE_TTL) {
        resolve(data);
      } else {
        resolve(null);
      }
    });
  });
}

async function setCachedData(key, data) {
  return new Promise((resolve) => {
    chrome.storage.local.set(
      { [key]: data, [`${key}_ts`]: Date.now() },
      resolve
    );
  });
}

每次读取时检查时间戳,超过 5 分钟就视为过期。这比使用 chrome.alarms 定时清理要简单得多,而且对于这种信息聚合类场景,5 分钟的缓存足够了。

4.2 自动寻找或创建补天标签页

这是 Service Worker 中最关键的一段逻辑。当 Popup 请求数据时,我们需要找到一个运行着补天页面的标签页来执行 API 请求:

javascript 复制代码
async function findButianTab() {
  const tabs = await chrome.tabs.query({ url: 'https://www.butian.net/*' });
  return tabs.length > 0 ? tabs[0] : null;
}

async function ensureButianTab() {
  let tab = await findButianTab();
  if (tab) return tab;

  // 没有打开的补天标签页,在后台创建一个
  tab = await chrome.tabs.create({
    url: 'https://www.butian.net/Reward/plan',
    active: false,
  });

  // 等待页面加载完成,Content Script 注入后才能通信
  await new Promise((resolve) => {
    const listener = (tabId, info) => {
      if (tabId === tab.id && info.status === 'complete') {
        chrome.tabs.onUpdated.removeListener(listener);
        resolve();
      }
    };
    chrome.tabs.onUpdated.addListener(listener);
    setTimeout(() => {
      chrome.tabs.onUpdated.removeListener(listener);
      resolve();
    }, 10000);
  });

  return tab;
}

active: false 确保新标签页在后台打开,不会打断用户当前的工作。10 秒的超时保护避免因网络问题导致 Promise 永远挂起。

4.3 失败重试机制

Content Script 有一个容易被忽略的问题:当扩展重新加载(开发时常见)或更新后,已打开页面中注入的 Content Script 会失效 ,因为它们绑定的是旧的扩展实例。此时 chrome.tabs.sendMessage 会报错 "Could not establish connection"。

解决方案是在首次通信失败时,刷新标签页后重试:

javascript 复制代码
async function fetchViaContentScript(endpoint, method, data, retry = true) {
  const tab = await ensureButianTab();
  const url = endpoint.startsWith('http') ? endpoint : `${API_BASE}${endpoint}`;

  try {
    return await sendToTab(tab.id, {
      type: 'API_REQUEST', url, method: method || 'GET', data
    });
  } catch (err) {
    if (retry) {
      await chrome.tabs.reload(tab.id);
      await waitForTabLoad(tab.id);
      return fetchViaContentScript(endpoint, method, data, false);
    }
    throw err;
  }
}

retry 参数确保最多重试一次,避免无限循环。


五、Content Script 双 World 架构

这是整个项目中最有意思的部分。我使用了两个 Content Script,分别运行在不同的 World 中,各司其职。

5.1 ISOLATED World:api-bridge.js

这个脚本运行在 Chrome 为 Content Script 分配的隔离环境中。它可以访问 chrome.runtime API 与 Service Worker 通信,同时可以执行 fetch 发起同源请求。它是整个数据链路的桥梁:

typescript 复制代码
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === 'API_REQUEST') {
    const { url, method, data } = message;
    const opts = {
      method: method || 'GET',
      headers: { 'X-Requested-With': 'XMLHttpRequest' },
      credentials: 'same-origin',
    };
    if (data && method === 'POST') {
      opts.headers['Content-Type'] = 'application/x-www-form-urlencoded';
      opts.body = new URLSearchParams(data).toString();
    }
    fetch(url, opts)
      .then((r) => r.json())
      .then((json) => sendResponse({ data: json }))
      .catch((err) => sendResponse({ error: err.message }));
    return true; // 保持消息通道开放,等待异步 sendResponse
  }
});

注意几个细节。credentials: 'same-origin' 确保 fetch 携带 Cookie。X-Requested-With: XMLHttpRequest 是补天后端识别 AJAX 请求的标志。return true 是 Chrome 扩展消息系统的约定------如果 sendResponse 会异步调用,必须返回 true,否则消息通道会在函数返回时立即关闭。

5.2 MAIN World:page-tracker.js

这个脚本运行在页面自身的 JavaScript 上下文中,与页面代码共享全局对象。这意味着它可以直接修改 window.fetchXMLHttpRequest.prototypehistory.pushState 等原生 API。

为什么需要这个能力?因为我想记录用户在补天网站上的所有操作轨迹------包括页面跳转、SPA 路由变化、AJAX 请求等。在 ISOLATED World 中,你无法拦截页面代码发起的 XHR,因为两个 World 的全局对象是隔离的。

javascript 复制代码
(function () {
  var CHANNEL = 'BT_TRACKER';

  function send(type, data) {
    window.postMessage({ channel: CHANNEL, type: type, data: data }, '*');
  }

  // 拦截 pushState
  var origPush = history.pushState;
  history.pushState = function () {
    origPush.apply(this, arguments);
    var newUrl = location.href;
    if (newUrl !== lastUrl) {
      lastUrl = newUrl;
      send('url', { url: newUrl, trigger: 'pushState' });
    }
  };

  // 拦截 XMLHttpRequest
  var origOpen = XMLHttpRequest.prototype.open;
  var origSend = XMLHttpRequest.prototype.send;

  XMLHttpRequest.prototype.open = function (method, url) {
    this._btMethod = method;
    this._btUrl = url;
    return origOpen.apply(this, arguments);
  };

  XMLHttpRequest.prototype.send = function (body) {
    var self = this;
    this.addEventListener('load', function () {
      // 过滤静态资源,只记录 API 请求
      if (isPOST || isApiUrl(fullUrl)) {
        send('api', {
          url: fullUrl,
          trigger: 'XHR-' + method,
          body: body ? String(body).substring(0, 300) : '',
          response: self.responseText.substring(0, 300),
        });
      }
    });
    return origSend.apply(this, arguments);
  };

  // 拦截 fetch(类似逻辑,略)
})();

5.3 两个 World 如何通信?

MAIN World 的脚本无法访问 chrome.runtime,ISOLATED World 的脚本无法直接读取 MAIN World 的数据。解决方案是使用 window.postMessage 作为桥梁:

scss 复制代码
MAIN World (page-tracker.js)
    ↓ window.postMessage({ channel: 'BT_TRACKER', data: ... })
ISOLATED World (api-bridge.js)
    ↑ window.addEventListener('message', handler)
    ↓ chrome.storage.local.set(...)
Service Worker / Popup

api-bridge.js 监听 message 事件,通过 channel 字段过滤出来自 page-tracker.js 的消息,然后将数据存入 chrome.storage.local。这样 Popup 就能读取完整的操作历史了。

这里有一个安全考虑:postMessage 是全局的,页面上任何脚本都可以发送消息。因此在接收端做了 event.source !== window 的检查,只处理同一窗口发来的消息,并通过 channel 字段做了二次验证。


六、智能推荐算法

奖励列表本身只是原始数据的展示,我想做一个更实用的"智能推荐"功能,帮白帽子快速找到性价比最高的 SRC。

6.1 评分模型

综合评分由四个维度加权构成:

ini 复制代码
function calcScore(item, allItems) {
  // 最高奖金得分 (权重 40%) --- 归一化到 0-100
  var maxScore = (maxReward / globalMaxReward) * 100;

  // 最低奖金得分 (权重 20%) --- 体现企业诚意
  var minScore = (minReward / globalMaxMin) * 100;

  // 服务状态得分 (权重 20%) --- 开放=100, 暂停=0
  var statusScore = isOpen ? 100 : 0;

  // 活跃度得分 (权重 20%) --- 90天内满分,线性衰减至365天归零
  var activityScore = daysSince <= 90 ? 100 :
    Math.max(0, 100 - ((daysSince - 90) / 275) * 100);

  return maxScore * 0.4 + minScore * 0.2 + statusScore * 0.2 + activityScore * 0.2;
}

为什么最低奖金也纳入评分?因为有些 SRC 的最高奖金看起来很诱人(比如 50 万),但最低奖金只有 100 元,说明大部分漏洞类型的奖金其实很低。最低奖金高的 SRC 通常意味着"保底收入"更可观。

活跃度使用了分段线性衰减:90 天内的企业被认为是活跃的(满分),90-365 天线性下降,超过 1 年直接归零。这反映了一个直觉------长期不更新的 SRC 可能响应慢、审核周期长。

6.2 难度分级与类型分类

为了让筛选更直观,我对企业做了两个维度的标签化处理。

难度分级基于最高奖金额度:入门(≤5000)、中等(≤5万)、困难(≤20万)、专家(>20万)。这不完全等同于技术难度,但从经验来看,高奖金的 SRC 通常资产复杂度更高、安全能力更强。

类型分类基于关键词匹配,对企业名称和简介进行扫描:

css 复制代码
var TYPE_RULES = [  { key: 'finance',  label: '金融', keywords: ['银行','金融','保险','证券','支付',...] },
  { key: 'ecommerce',label: '电商', keywords: ['电商','商城','购物','零售',...] },
  { key: 'gaming',   label: '游戏', keywords: ['游戏','电竞','米哈游',...] },
  // ...
];

这种简单的规则引擎在数据量不大时效果不错,匹配顺序也隐含了优先级------如果一个企业同时匹配"金融"和"互联网",它会被归类为"金融",因为金融规则排在前面。


七、Popup UI:暗色主题与交互细节

7.1 暗色主题设计

考虑到安全从业者普遍偏好暗色界面(长时间使用低光照环境),整个 UI 采用了类似 GitHub Dark 的配色方案:

css 复制代码
body {
  width: 420px;
  height: 560px;
  background: #0d1117;
  color: #e6edf3;
}

主背景 #0d1117,卡片背景 #161b22,边框 #21262d,高亮蓝 #1890ff,成功绿 #3fb950,警告橙 #f0883e。这套配色经过验证,对比度满足 WCAG AA 标准,长时间阅读不会造成视觉疲劳。

7.2 Tab 切换与懒渲染

四个 Tab(奖励排行、智能推荐、新入驻、报名记录)采用了纯 CSS 的显示/隐藏控制,配合 JavaScript 的按需渲染:

csharp 复制代码
function renderCurrentTab() {
  switch (state.currentTab) {
    case 'reward':     renderRewardList();     break;
    case 'recommend':  renderRecommendList();  break;
    case 'newCompany': renderNewCompanyList();  break;
    case 'urlHistory': renderUrlHistory();      break;
  }
}

切换 Tab 时只渲染当前面板的内容,避免一次性渲染所有列表导致的性能问题。搜索框是全局共享的,输入时会根据当前活动的 Tab 来决定过滤哪个列表。

7.3 搜索防抖

搜索输入使用了 200ms 的防抖,避免每按一个键就重新渲染列表:

ini 复制代码
var timer = null;
dom.searchInput.addEventListener('input', function () {
  clearTimeout(timer);
  timer = setTimeout(function () {
    state.searchQuery = dom.searchInput.value.trim();
    renderCurrentTab();
  }, 200);
});

200ms 是一个经验值------足够短以保证即时反馈感,足够长以过滤掉连续快速输入。


八、图标生成:纯代码绘制 PNG

为了让项目完全自包含(不依赖任何外部图片资源),我用两种方式实现了图标生成。

8.1 浏览器端:Canvas 绘制

icons/generate-icons.html 使用 Canvas 2D API 绘制一个蓝色圆角矩形背景 + 白色盾牌 + 蓝色对勾的图标,然后通过 canvas.toDataURL('image/png') 导出:

ini 复制代码
function drawIcon(canvas) {
  const s = canvas.width;
  const ctx = canvas.getContext('2d');

  // 圆角矩形背景
  ctx.beginPath();
  ctx.moveTo(r, 0);
  ctx.lineTo(s - r, 0);
  ctx.quadraticCurveTo(s, 0, s, r);
  // ...
  ctx.fillStyle = '#1890ff';
  ctx.fill();

  // 盾牌路径
  ctx.beginPath();
  ctx.moveTo(cx, top);
  ctx.lineTo(cx - w, top + s * 0.12);
  // ...(贝塞尔曲线构建盾牌轮廓)
  ctx.fillStyle = '#ffffff';
  ctx.fill();

  // 对勾
  ctx.beginPath();
  ctx.strokeStyle = '#1890ff';
  ctx.lineWidth = Math.max(s * 0.08, 1.5);
  ctx.moveTo(cx - s * 0.12, s * 0.45);
  ctx.lineTo(cx - s * 0.02, s * 0.57);
  ctx.lineTo(cx + s * 0.14, s * 0.37);
  ctx.stroke();
}

所有坐标都使用相对比例(如 s * 0.15),确保 16px、48px、128px 三种尺寸下图标都清晰锐利。

8.2 Node.js 端:手工构建 PNG 二进制

icons/gen.js 是一个更极客的方案------直接用 Node.js 逐像素计算颜色,然后手工拼接 PNG 文件的二进制格式(Signature → IHDR → IDAT → IEND),连 canvas 库都不需要安装:

arduino 复制代码
function createPNG(size) {
  const pixels = Buffer.alloc(size * size * 4);

  for (let y = 0; y < size; y++) {
    for (let x = 0; x < size; x++) {
      // 判断像素是否在圆角矩形内
      // 判断是否在盾牌区域内
      // 判断是否在对勾线段上(点到线段距离计算)
      // 根据结果设置 RGBA 颜色
    }
  }

  // 手工构建 PNG:签名 + IHDR + 压缩数据(IDAT) + IEND
  const compressed = zlib.deflateSync(rawData);
  return Buffer.concat([signature, chunk('IHDR', ihdr), chunk('IDAT', compressed), chunk('IEND', empty)]);
}

这段代码本身就是一个迷你的 PNG 编码器教程。对勾的绘制使用了点到线段距离公式,当距离小于线宽时就判定为对勾区域------这种逐像素判断的方式在低分辨率(16px)下比矢量绘制更可控。


九、踩坑记录

坑 1:Service Worker 被终止后状态丢失

最初我把缓存数据存在 Service Worker 的全局变量中,结果发现有时候 Popup 拿到的是空数据。原因是 Chrome 会在 Service Worker 空闲约 30 秒后将其终止,全局变量全部丢失。解决方案就是前面提到的 chrome.storage.local 持久化缓存。

坑 2:Content Script 在扩展重载后失效

开发阶段频繁修改代码并重新加载扩展,但已打开的补天页面中的 Content Script 仍然是旧版本。chrome.tabs.sendMessage 会抛出连接错误。解决方案是在首次通信失败时刷新标签页,让新版 Content Script 重新注入。

坑 3:MAIN World 脚本无法使用 chrome API

刚开始我把所有逻辑写在一个 Content Script 里,设置了 "world": "MAIN"。结果发现 chrome.runtime.onMessage 是 undefined------MAIN World 的脚本运行在页面上下文中,没有扩展 API 的访问权限。必须拆成两个脚本,MAIN World 负责拦截,ISOLATED World 负责通信,通过 postMessage 桥接。

坑 4:sendResponse 的异步陷阱

chrome.runtime.onMessage 的回调中,如果要异步调用 sendResponse(比如等待 fetch 完成),必须 return true。否则消息通道会在回调函数同步执行完毕后立即关闭,之后的 sendResponse 调用不会生效,Popup 端会收到 undefined。这是 Chrome 扩展开发中最经典的坑之一。

坑 5:Popup 关闭后 Promise 中断

Popup 的生命周期很短------用户点击其他地方就会关闭。如果此时有正在进行的 API 请求,Promise 会被中断。因此数据加载使用了 Promise.allSettled 而非 Promise.all,确保即使部分请求失败也能展示已获取的数据。


十、项目结构总结

bash 复制代码
butian-helper/
├── manifest.json                  # MV3 配置,双 Content Script + Service Worker
├── background/
│   └── service-worker.js          # 调度中心:缓存管理 + 标签页管理 + 消息路由
├── content/
│   ├── api-bridge.js              # ISOLATED World:API 代理 + 追踪数据收集
│   └── page-tracker.js            # MAIN World:XHR/fetch/pushState 拦截
├── popup/
│   ├── popup.html                 # 弹窗骨架
│   ├── popup.css                  # 暗色主题样式
│   └── popup.js                   # UI 渲染 + 评分算法 + 交互逻辑
├── icons/
│   ├── gen.js                     # Node.js PNG 生成器
│   └── generate-icons.html        # 浏览器端图标生成
└── README.md

整个项目零外部依赖,纯原生 JavaScript 实现,总代码量约 1200 行。在 MV3 的约束下,通过 Content Script 代理请求、双 World 协作、postMessage 桥接等技巧,完整实现了数据抓取、智能排序、操作追踪等功能。


十一、后续优化方向

当前版本还有几个可以优化的地方。分页加载 ------目前奖励列表只获取了第一页,如果企业数量超过单页上限,需要实现滚动加载或分页请求。离线模式 ------当用户没有登录补天或网络不可用时,可以展示上次缓存的数据并标注"可能不是最新"。通知推送 ------利用 chrome.alarms 定时检查是否有新入驻企业或奖金变动,通过 chrome.notifications 推送桌面通知。导出功能------将筛选后的企业列表导出为 CSV 或 Markdown,方便整理到个人的 SRC 清单中。


如果你也在做 Chrome 扩展开发,特别是需要与目标网站的 Session/Cookie 交互的场景,希望这篇文章中的架构设计和踩坑经验能帮到你。完整源码已在文中给出,可以直接加载使用。

相关推荐
涡能增压发动积1 天前
同样的代码循环 10次正常 循环 100次就抛异常?自定义 Comparator 的 bug 让我丢尽颜面
后端
Wenweno0o1 天前
0基础Go语言Eino框架智能体实战-chatModel
开发语言·后端·golang
于慨1 天前
Lambda 表达式、方法引用(Method Reference)语法
java·前端·servlet
石小石Orz1 天前
油猴脚本实现生产环境加载本地qiankun子应用
前端·架构
swg3213211 天前
Spring Boot 3.X Oauth2 认证服务与资源服务
java·spring boot·后端
从前慢丶1 天前
前端交互规范(Web 端)
前端
tyung1 天前
一个 main.go 搞定协作白板:你画一笔,全世界都看见
后端·go
gelald1 天前
SpringBoot - 自动配置原理
java·spring boot·后端
CHU7290351 天前
便捷约玩,沉浸推理:线上剧本杀APP功能版块设计详解
前端·小程序
GISer_Jing1 天前
Page-agent MCP结构
前端·人工智能