【Chrome插件开发】某视频网站的m4s视频/音频下载方案,及其Chrome插件实现-v250415

引言

Sample:aHR0cHM6Ly93d3cuYmlsaWJpbGkuY29tL3ZpZGVvL0JWMXBwNDIxZDc1cQ==

我去年写了这篇文章:【前端甜点】某视频网站的m4s视频/音频下载方案(20240420),但大概从去年8月开始,我发现,下载质量较高的音频时会报错net::ERR_FAILED 206,较低的则不报错。我百思不得其解,只能暂且猜测:

  1. 该网站发现了xhr请求和fetch api发的请求的差异,导致失败。
  2. 跨域问题。

但在某次尝试中,我copy as fetch,重放请求,发现虽然报错,但这个错误似乎可以解决:

csharp 复制代码
<详情页url>:1 Access to fetch at '<m4s url>' from origin 'https://www.example.com' has been blocked by CORS policy: Request header field pragma is not allowed by Access-Control-Allow-Headers in preflight response.

做法很简单,报错提到什么字段,我就删什么字段,比如根据上面的报错,我们要删"pragma"。如此反复做几次,就能得到可以成功下载的fetch代码。据此,我新写了一版代码:

js 复制代码
// https://www.cnblogs.com/Sherries/p/14840404.html
function blobToDataURI(blob) {
  return new Promise((res, rej) => {
    const reader = new FileReader();
    reader.readAsDataURL(blob);
    reader.onload = (e) => {
      res(e.target.result);
    };
    reader.onerror = () => {
      rej(new Error('文件流异常'));
    };
  });
}

function sendFetchReq(url, rangeStart = 0) {
  return fetch(url, {
    headers: {
      'accept': '*/*',
      'accept-language': 'zh-CN,zh;q=0.9',
      'range': `bytes=${rangeStart}-`,
      // This header is unnecessary. 'sec-ch-ua': '"Chromium";v="134", "Not:A-Brand";v="24", "Google Chrome";v="134"',
      'sec-ch-ua-mobile': '?0',
      'sec-ch-ua-platform': '"Windows"',
      'sec-fetch-dest': 'empty',
      'sec-fetch-mode': 'cors',
      'sec-fetch-site': 'cross-site'
    },
    referrerPolicy: 'no-referrer-when-downgrade',
    body: null,
    method: 'GET',
    mode: 'cors',
    credentials: 'omit'
  }).then(res => res.blob());
}

async function downloader(url, rangeStart = 0) {
  const blob = await sendFetchReq(url, rangeStart);
  console.log(blob); // type is Blob
  const dataURI = await blobToDataURI(blob);
  // data:application/octet-stream;base64,
  const rawBase64Str = String(dataURI);
  const base64Str = rawBase64Str.substring(rawBase64Str.indexOf('base64,') + 7);
  console.log(rawBase64Str.substring(0, 100));
  console.log(base64Str); // 之后在浏览器控制台复制字符串,粘贴到 base64_str_m4s.txt
}

downloader('<m4s file url>');

其他函数不变,sendXhrReq改成sendFetchReq,将原本的XMLHttpRequest改成现在的fetch函数。

写完这段代码后一段时间,我刷到了码农高天的视频。我想,现在LLM已经很厉害了,不妨让AI辅助我,根据这段代码,快速生成一个Chrome插件。

本文52pojie:www.52pojie.cn/thread-2026...

本文CSDN:blog.csdn.net/hans7748829...

本文juejin:juejin.cn/post/749600...

作者:hans774882968以及hans774882968以及hans774882968

效果

v1.0.0

github.com/Hans7748829...

TODO

这里只给出最重要的TODO,完整版见:其他笔记.md

  1. 番剧页面的代码要求我们改用Babel AST 来分析代码,而且window.__playinfo__的数据结构也和视频详情页不同。如下文bangumi.js所示。另外,限免集和会员集的数据结构也不一样。限免集是有video_info.dash的,仍然给你视频和音频的m4s;会员集则只有video_info.durls,直接给你mp4文件。

让AI写初稿

最初的Prompt传送门

请帮我写一个Chrome扩展程序。要求如下:

  1. 使用manifest v3,只在 www.example.com/ 激活。
  2. 请生成前端工程,使用React 18和Yarn,组件库用ant-design。
  3. 为鼠标右键添加一个选项,点击产生一个弹窗。弹窗由两个div组成,分别占据弹窗的左侧和右侧。左侧和右侧div的class名分别为"url-list"和"result",下文分别用url-list和div.result指代。这两个div的width和height都应一样。url-list是一个列表,每个条目展示一个url,每个条目的右侧都有一个按钮,文案为"获取"。div.result展示一个base64字符串,文本过长则用省略号省略过长部分,并提供一个按钮,文案为"复制",点击后将字符串复制到剪贴板。
  4. 监听网络请求,如果某请求的url包含字符串30216.m4s、30232.m4s、30264.m4s、30280.m4s中的一个,则将该url添加到url-list中展示,注意它要添加到url-list的开头,使得最新的请求排在最前面。点击某url右侧"获取"按钮,则向该url发送get请求,将返回体转为blob,进而编码为base64字符串,展示在div.result中。
  5. url-list提供分页功能,每页展示10个条目。

生成的代码有很多问题,比如不是React脚手架生成的、没有package.json等。所以有了后续的Chat:

  • 该react项目应使用vite脚手架生成
  • 请提供package.json

写README的时候再回顾这段Prompt,感觉可能是我脑海里的需求点太过模糊,也可能是我对Chrome插件的API不够熟悉,才导致了AI生成的代码离可用有很大的鸿沟。当然,它还是给了我一点点启发。

两条路:在content.js里,还是popup.js里发请求?

我们知道Chrome插件分为3层,分别为content.jsbackground.jspopup.html / popup.jscontent.js注入某个标签页中运行,background.js是插件的后台程序,popup.js则可以弹出一个网页。所以理论上,我们可以在content.js中或popup.js中发请求。前者的流程图(参考链接3):

后者的流程图:

前者需要让数据从content.js,流到background.js,再到popup.js。后者需要我们用纯JS弹出一个窗口,作为插件的UI。那选哪条路呢?理论上,两条路都是OK的,只是实现上的难点略有差异。

但实际上,经过我亲自开发踩坑,因为该网站有请求头校验,而fetch API无法修改请求头的Origin,所以在popup.js中发请求的方案是不可行的。因为也浪费了些精力,所以我仍然把该方案的代码留了下来,放在bili-m4s-fetch-demo-deprecated文件夹。我会在我的下一篇blog(TODO: Chrome插件实现PPT编辑)中简单讲下其难点。

World in content.js

新建项目

powershell 复制代码
yarn create vite bili-m4s-fetch-demo

即可新建Vite + React项目。因为我感觉这只是一个袖珍项目,所以并没有为项目引入TypeScript。

项目结构:

css 复制代码
.
│  .gitignore
│  background.js
│  eslint.config.js
│  index.html
│  manifest.json
│  package.json
│  README.md
│  vite.config.js
│  
├─bili-m4s-fetch-demo-deprecated
│  │  省略
│          
├─public
│      favicon.ico
│      
├─README_assets
│      1-example-v1.0.0.jpg
│      2-3-layers.mmd
│      2-3-layers.png
│      3-world-in-content.mmd
│      3-world-in-content.png
│      
└─src
    │  App.jsx
    │  App.scss
    │  content.js
    │  main.jsx
    │  
    ├─clamp-js
    │      HansClamp.jsx
    │      
    ├─common
    │      downloadFile.js
    │      getUrlsFromExampleCom.js
    │      M4sUrlDesc.js
    │      request.js
    │      utils.js
    │      
    └─images
            bili.png

如何打包

代码传送门

manifest.json等文件,需要复制到dist文件夹,可用rollup-plugin-copy实现。

为了方便调试,我们希望content.js能在开发环境模拟生产环境注入某标签页的场景,因此我们约定入口为index.html,相应地,该html要在vite给的初始html的基础上改一下。在生产环境,入口显然为content.js。问deepseek可知,defineConfig提供了mode参数判断是什么环境,而在content.js中,可用const isDev = import.meta.env.MODE === 'development';判断。

另外,问deepseek可知,需要build.rollupOptions.output.entryFileNames: '[name].js',让产物JS文件名没有hash。deepseek还很贴心地给了张对比表:

配置项 默认行为 我们需要的行为
input 从 index.html 开始打包 直接从 content.js 开始打包
entryFileNames 带 hash 的文件名 (content.hash.js) 固定文件名 (content.js)
chunkFileNames 带 hash 的 chunk 名 固定 chunk 名

相关Prompt:

  1. 打包框架是vite,这是一个vite脚手架生成的react项目。我希望修改vite.config.js,以content.js为打包入口,去掉没必要存在的index.html,且打包后对应的js文件名仍为content.js。请根据以上信息修改上述代码。
  2. 请调整vite.config.js及其他文件,实现:在开发模式下,入口为content.html,引用content.js;在生产模式下,和原来一样,入口为content.js

background.js:在鼠标右键菜单添加一个选项,点击后通知content.js第一次创建弹窗

问AI即可拿到可用的代码:

js 复制代码
const MENU_ITEM_ID = 'bili-m4s-fetch-demo';

chrome.runtime.onInstalled.addListener(() => {
  chrome.contextMenus.create({
    id: MENU_ITEM_ID,
    title: 'Bili M4S Fetch Demo',
  });
});

chrome.contextMenus.onClicked.addListener((info, tab) => {
  if (info.menuItemId === MENU_ITEM_ID && tab.id) {
    chrome.tabs.sendMessage(tab.id, { type: 'openDownloaderDialog' });
  }
});

console.log(`[${MENU_ITEM_ID}] background.js loaded`);

但有个无伤大雅的问题:在其他网站仍然会出现该选项,点击后却没有反应。从交互的角度来说,在其他网站,要么不展示它,要么点击后弹出提示。于是我问AI要代码

请修改background.js,实现:只有当前标签页url在manifest.json中host_permissions指定的url列表中时,才为右键菜单添加选项

我在AI给的代码的基础上改了一点点:

js 复制代码
const MENU_ITEM_ID = 'bili-m4s-fetch-demo';

let menuItemCreated = false;

// 检查URL是否匹配host_permissions
function isUrlInHostPermissions(url) {
  if (!url) return false;

  try {
    const allowedPatterns = [
      'https://www.example.com/*'
    ];

    return allowedPatterns.some((pattern) => {
      const regex = new RegExp(pattern
        .replace(/\./g, '\\.')
        .replace(/\*/g, '.*'));
      return regex.test(url);
    });
  } catch (error) {
    console.error('URL检查出错:', error);
    return false;
  }
}

// 维护状态机:创建或移除右键菜单
async function updateContextMenu(tabId) {
  try {
    const tab = await chrome.tabs.get(tabId);
    const urlAllowed = isUrlInHostPermissions(tab.url);
    // AI 代码的 bug : menuItemCreated 是互斥锁,需要在耗时操作之前就上锁,耗时操作之后再解锁,否则会有并发问题
    if (urlAllowed && !menuItemCreated) {
      menuItemCreated = true;
      chrome.contextMenus.create({
        id: MENU_ITEM_ID,
        title: 'Bili M4S Fetch Demo',
      });
    } else if (!urlAllowed && menuItemCreated) {
      // AI 没发现的区别:创建菜单不会返回 Promise ,移除菜单则会,所以记得加 await
      await chrome.contextMenus.remove(MENU_ITEM_ID);
      menuItemCreated = false;
    }
  } catch (error) {
    console.error('更新右键菜单出错:', error);
  }
}

// 监听标签页更新
chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
  if (changeInfo.url || changeInfo.status === 'complete') {
    updateContextMenu(tabId);
  }
});

// 监听标签页切换
chrome.tabs.onActivated.addListener((activeInfo) => {
  updateContextMenu(activeInfo.tabId);
});

chrome.runtime.onInstalled.addListener(async () => {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  if (tab) {
    updateContextMenu(tab.id);
  }
});

chrome.contextMenus.onClicked.addListener((info, tab) => {
  if (info.menuItemId === MENU_ITEM_ID && tab.id) {
    chrome.tabs.sendMessage(tab.id, { type: 'openDownloaderDialog' });
  }
});

console.log(`[${MENU_ITEM_ID}] background.js loaded`);

生成的代码有70行,但思路很简单,就是写一个函数,维护一个状态机,在标签页切换时调用,写过算法题的同学们都很熟悉。但chrome.contextMenus.remove()只传了一个参数,所以返回Promise,却忘记加await,导致并发问题:有可能重复创建相同ID的菜单选项。加上await以后,发现还是有并发问题,后来我猜测是互斥锁应该要更早上锁,所以加了那行注释。因为已经采用了下文的解决方案,所以我不打算检验这段代码的正确性了。

在我一筹莫展时,查文档发现,其实Chrome已经提供了一个documentUrlPatterns参数来实现这个功能,哪里需要这70行代码......完整代码:

js 复制代码
const MENU_ITEM_ID = 'bili-m4s-fetch-demo';

chrome.runtime.onInstalled.addListener(() => {
  chrome.contextMenus.create({
    id: MENU_ITEM_ID,
    title: 'Bili M4S Fetch Demo',
    documentUrlPatterns: ['https://www.example.com/*']
  });
});

chrome.contextMenus.onClicked.addListener((info, tab) => {
  if (info.menuItemId === MENU_ITEM_ID && tab.id) {
    chrome.tabs.sendMessage(tab.id, { type: 'openDownloaderDialog' });
  }
});

console.log(`[${MENU_ITEM_ID}] background.js loaded`);

eslint 9如何镇压__dirname的eslint报错

eslint 8的老配置是:{ env: { node: true, browser: true }}(它们并不冲突),而新版要改为:

js 复制代码
    languageOptions: {
      // https://stackoverflow.com/questions/48584556/eslint-chrome-is-not-defined-no-undef
      globals: {
        ...globals.node,
        ...globals.browser,
        ...globals.webextensions
      },
    }

同理,它们并不冲突。

content.css:希望像普通的前端项目一样写CSS

为了能像普通的前端项目一样写CSS,我们希望让jsx import的CSS文件能被打包为content.css

首先,需要修改manifest.json,让插件在标签页注入CSS:

json 复制代码
  "content_scripts": [
    {
      "matches": [
        "https://www.example.com/*"
      ],
      "js": [
        "content.js"
      ],
      "css": [
        "content.css"
      ]
    }
  ],

接着,修改打包配置:build.rollupOptions.output.assetFileNames: '[name].[ext]'。然后,React组件正常import CSS文件就行:import './App.scss';。为了使用scss,需要先装好sass包:yarn add -D sass

content.js挂载组件

参考Vite + React项目模板main.jsx的代码,我们不难想到,需要用react-dom才能做到。

jsx 复制代码
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';

let dialogRef = null;

export function renderDownloaderDialog(initialUrls = []) {
  if (dialogRef) {
    return;
  }
  const dialog = document.createElement('div');
  dialog.id = 'bili-downloader-dialog';
  dialog.style.position = 'relative'; // for expand-dialog-btn, may be unnecessary
  document.body.appendChild(dialog);
  dialogRef = dialog;
  createRoot(dialog).render(
    <StrictMode>
      <App initialUrls={initialUrls} />
    </StrictMode>,
  );
}

难点1:在万恶的manifest V3中,如何拿到视频URL

一开始我考虑的方案非常simple非常naive:监听网络请求,匹配30280.m4s等。在manifest V2里,这完全OK。但现在万恶的咕果逼我们使用manifest V3,而相关资料少得可怜。

被PASS的方案:onRuleMatchedDebug

于是我问doubao:

使用Chrome的manifest v3 declarativeNetRequest如何获取每一个网络请求的URL

得到代码:

manifest.json

json 复制代码
{
    "manifest_version": 3,
    "name": "URL Catcher",
    "version": "1.0",
    "description": "Catch all network request URLs",
    "permissions": [
        "declarativeNetRequest",
        "declarativeNetRequestFeedback",
        "activeTab"
    ],
    "background": {
        "service_worker": "background.js"
    }
}

background.js

js 复制代码
// 规则 ID
const RULE_ID = 1;

// 定义规则,匹配所有请求
const rules = [
    {
        id: RULE_ID,
        priority: 1,
        action: { type: 'allow' },
        condition: { urlFilter: '*', resourceTypes: ['main_frame', 'sub_frame', 'stylesheet', 'script', 'image', 'font', 'object', 'xmlhttprequest', 'ping', 'csp_report', 'media', 'websocket', 'webtransport', 'webbundle'] }
    }
];

// 注册规则
chrome.declarativeNetRequest.updateDynamicRules({
    removeRuleIds: [RULE_ID],
    addRules: rules
}, () => {
    console.log('Rules updated');
});

// 监听请求反馈
chrome.declarativeNetRequest.onRuleMatchedDebug.addListener(({ request, rule }) => {
    if (rule.id === RULE_ID) {
        console.log('Caught URL:', request.url);
    }
});

方案:解析window.__playinfo__

但后来我发现,该网站提供了window.__playinfo__,只需要解析它就行,所以我没运行代码就PASS掉了上述方案。

js 复制代码
{
    code: 0, data: { dash: {
    audio: [{ id: 30280 / 30232 / 30216, baseUrl: "", backupUrl: "" }] // 3 items
    video: [{ id: 80 / 64 / 32 / 16, baseUrl: "", backupUrl: "" }] // 4 * 3 items
    }}
}

content.js是独立的执行环境,拿不到这个变量,所以我们需要用JS去读script标签的代码。于是继续问doubao拿代码(getUrlsFromExampleCom.js):

js 复制代码
function parseScriptTags(scriptTags) {
  for (const scriptTag of scriptTags) {
    const scriptContent = scriptTag.textContent;
    if (!scriptContent || !scriptContent.includes('window.__playinfo__')) continue;
    const startIndex = scriptContent.indexOf('{');
    const endIndex = scriptContent.lastIndexOf('}') + 1;
    const jsonString = scriptContent.slice(startIndex, endIndex);
    try {
      const playInfo = JSON.parse(jsonString);
      return playInfo;
    } catch (error) {
      console.error('Error parsing playInfo JSON:', error);
    }
  }
  return {};
}

export function getPlayInfoFromScriptTag() {
  if (window.__playinfo__ && typeof window.__playinfo__ === 'object') {
    return window.__playinfo__;
  }
  const scriptTags = [...document.getElementsByTagName('script')];
  return parseScriptTags(scriptTags);
}

因为这个script标签只有window.__playinfo__ = {...}这一行赋值语句,所以可以用这个很粗糙的算法去拿。数据处理的代码比较杂乱,在此只展示一点点:

js 复制代码
const isDev = import.meta.env.MODE === 'development';

export function getUrlsFromExampleCom() {
  if (isDev) {
    return [
      new M4sUrlDesc('not a url 1', 80, M4sUrlDesc.VIDEO),
      new M4sUrlDesc('not a url 2', 64, M4sUrlDesc.VIDEO),
      new M4sUrlDesc('not a url 3', 32, M4sUrlDesc.VIDEO),
      new M4sUrlDesc('not a url 4', 16, M4sUrlDesc.VIDEO),
      new M4sUrlDesc('not a url 5', 30280, M4sUrlDesc.AUDIO),
      new M4sUrlDesc('not a url 6', 30232, M4sUrlDesc.AUDIO),
      new M4sUrlDesc('not a url 7', 30216, M4sUrlDesc.AUDIO),
      new M4sUrlDesc(`this is a ${'long'.repeat(200)} url`, 114514, M4sUrlDesc.AUDIO)
    ];
  }
  const playInfo = getPlayInfoFromScriptTag();
  const urlsObj = getUrlsObjFromPlayInfo(playInfo);
  const urls = [...urlsObj.videoUrls, ...urlsObj.audioUrls];
  return urls;
}

接下来我们要考虑如何让组件拿到这些URL。最后我认为,因为在同一个详情页中,window.__playinfo__一直不变,所以渲染时把URL初值从content.js传进组件就OK。

js 复制代码
const isDev = import.meta.env.MODE === 'development';
const initialUrls = getUrlsFromExampleCom();
if (isDev) {
  renderDownloaderDialog(initialUrls);
} else {
  chrome.runtime.onMessage.addListener((message) => {
    if (message.type === 'openDownloaderDialog') {
      renderDownloaderDialog(initialUrls);
    }
  });
}

方案升级:使用Babel AST解析代码

TODO

难点2:切换到其他视频详情页,该网站并不刷新,也不更新window.__playinfo__

这就导致我的组件拿不到最新的视频URL。我抓包发现,它请求了https://api.example.com/x/player/wbi/playurl,参数很复杂,不想花精力去逆向它。然后我想到,只需要用fetch API再向网页发一次请求即可。相关代码:

js 复制代码
// 在同一个url请求,会得到301,但无伤大雅
export async function getNewPlayInfoFromHtml() {
  const resp = await fetch(window.location.href);
  const htmlStr = await resp.text();
  const parser = new DOMParser();
  const doc = parser.parseFromString(htmlStr, 'text/html');
  const scriptTags = [...doc.getElementsByTagName('script')];
  return parseScriptTags(scriptTags);
}

export async function getNewUrlsFromHtml() {
  const playInfo = await getNewPlayInfoFromHtml();
  const urlsObj = getUrlsObjFromPlayInfo(playInfo);
  const urls = [...urlsObj.videoUrls, ...urlsObj.audioUrls];
  return urls;
}

然后在UI的对话框加一个"同步"按钮,提示用户手动按按钮去拿到最新的URL。

jsx 复制代码
  const [urls, setUrls] = useState(initialUrls);

  const updateM4sUrls = async () => {
    const newUrls = await getNewUrlsFromHtml();
    setUrls(newUrls);
    messageApi.success('同步完成');
  };
        // 组件:
        <Header className="toolbar">
          <Button
            type="primary"
            icon={<ReloadOutlined />}
            onClick={updateM4sUrls}
          >
            同步
          </Button>
          <Tooltip title={syncBtnTooltipTitle}>
            <QuestionCircleTwoTone style={{ fontSize: '16px' }} />
          </Tooltip>
        </Header>

难点3:用户多次点击右键菜单新增的条目,如何应对?用"用户思维"规避技术难题

  1. 让对话框再次出现。content.js的代码在组件之外,所以这不现实。
  2. 叉掉对话框时,让它被销毁,而非被隐藏。看了下控制台,对话框的DOM元素并不是<div id="root">的子元素。如果要强行实现这个方案,代码会比较丑。

后来我意识到,不需要在技术层面让组件消失,只需要在对话框隐藏期间,让组件对用户来说不太显眼。相应地,多次点击右键菜单新增条目,我们不予理会。于是我们用用户思维成功规避了技术难题。但在对话框隐藏期间,我们如何唤出它?很简单,我们展示一个悬浮在网页右上角的按钮,用户点击按钮时,对话框便再次出现。

jsx 复制代码
  const [isDialogOpen, setIsDialogOpen] = useState(true);
  const expandDialog = () => {
    setIsDialogOpen(true);
  };
  const expandDialogBtn = !isDialogOpen && (
    <Button
      type="primary"
      className="expand-dialog-btn"
      style={{ position: 'fixed', zIndex: 114514, top: '80px', right: '16px' }}
      icon={<ArrowsAltOutlined />}
      onClick={expandDialog}
    >
      展开下载助手
    </Button>
  );

还有个小插曲。我给按钮设置了如下CSS:

scss 复制代码
.expand-dialog-btn {
  position: fixed; // 这条属性被去掉是因为antd为button指定了position: relative,所以我们为它指定同样的内联样式
  z-index: 114514;
  top: 80px;
  right: 16px;
}

但调试时发现,position: fixed这行CSS被划线了。后来发现,这是因为antd为button指定了position: relative,且优先级比我这行CSS高。于是我把这几行CSS都复制到了组件的内联样式中。

常规1:提供文件下载功能

下载文件的JS代码,网上随处可见。原理很简单:创建a标签,设置download属性,将blob装进去。

js 复制代码
export function downloadFileByALink(blob, fileName) {
  const aLink = document.createElement('a');
  document.body.appendChild(aLink);
  aLink.style.display = 'none';
  const objectUrl = window.URL.createObjectURL(blob);
  aLink.href = objectUrl;
  aLink.download = fileName;
  aLink.click();
  document.body.removeChild(aLink);
}

接着在按钮点击时调用:

js 复制代码
  const handleExport = () => {
    downloadFileByALink(blobResult, m4sFileName);
  };

至于blobResult,只需要:

js 复制代码
fetch().then(res => res.blob());

就能拿到。

难点4:在React项目中实现,多行文本只展示前10行(clamp-js-main)

我不仅在界面提供了可下载的文件,还提供了可复制的文件内容的Base64字符串。于是我希望只展示前10行文本,其余的显示省略号。一开始我用了纯CSS:

scss 复制代码
.line-clamp-10 {
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 10;
    overflow: hidden;
}

这就是搜索引擎上流行的解法,但实测不生效。于是我通过一番搜索,找到了react-dotdotdot这个包。但运行时报错:Could not resolve "prop-types"。问了doubao,解决这个报错后,代码能跑,但控制台又有报错,说它用了React废弃的老API。所以我决定放弃这个方案。于是又问deepseek:

为了在Vite + React项目中实现10行以上文本显示省略号,可以使用react-dotdotdot包。但该包太古老了。有更新的替代方案吗?

看了一眼,觉得它给的3个方案都不靠谱,但我搜到一个包叫clamp.js,就继续问:

如何在react项目中使用clamp.js,实现10行以上文本显示省略号?

它就给我推荐了yarn add clamp-js-main。虽然看GitHub,这个包已经变只读了,但似乎能跑,我就不管那么多了。src\clamp-js\HansClamp.jsx

jsx 复制代码
import { useEffect, useRef } from 'react';
import { clamp } from 'clamp-js-main';

export default function HansClamp({ text, lines, className = '' }) {
  const textRef = useRef(null);

  useEffect(() => {
    if (textRef.current) {
      clamp(textRef.current, { clamp: lines });
    }
  }, [text, lines]);

  return (
    <div ref={textRef} className={className}>
      {text}
    </div>
  );
}

App.jsx调用:

jsx 复制代码
            <Content className="sub-div-content result-content">
              <HansClamp
                lines={10}
                text={base64Result || '暂无,请先发请求'}
              />
            </Content>

参考资料

  1. blog.haoji.me/chrome-plug...
  2. developer.chrome.com/docs/extens...
  3. 将消息从background.js传递到popup.jsstackoverflow.com/questions/1...
相关推荐
weixin_382395237 小时前
为小工厂量身打造:本地部署的物料管理系统带缺料计算
前端·制造
__zRainy__7 小时前
解决pnpm v10+不自动构建
前端·pnpm·工程化
猫猫不是喵喵.8 小时前
Vue3 Props 属性
前端·javascript·vue.js
醉城夜风~9 小时前
CSS元素显示模式(display)
前端·css
AI大模型-小华9 小时前
Codex 三方充值快速入门指南
java·前端·数据库·chatgpt·ai编程·codex·chatgpt pro
做前端的娜娜子12 小时前
同一链接实现 PC Web 与移动 H5 自适应
前端·掘金·金石计划
小帅不太帅12 小时前
架构没变、规模没变,DeepSeek V4 Flash 正式版凭什么暴涨 47 分?
前端·aigc·deepseek
jarvisuni12 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
卷福同学14 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端