【Chrome插件开发】某视频网站的m4s视频/音频下载方案,及其Chrome插件实现-v250415
文章目录
- 引言
- 效果
- v1.0.0
- TODO
- 让AI写初稿
- 两条路:在`content.js`里,还是`popup.js`里发请求?
- World in `content.js`
- 新建项目
- 如何打包
- `background.js`:在鼠标右键菜单添加一个选项,点击后通知`content.js`第一次创建弹窗
- eslint 9如何镇压`__dirname`的eslint报错
- `content.css`:希望像普通的前端项目一样写CSS
- 让`content.js`挂载组件
- 难点1:在万恶的manifest V3中,如何拿到视频URL
- 被PASS的方案:onRuleMatchedDebug
- 方案:解析`window.__playinfo__`
- 方案升级:使用Babel AST解析代码
- 难点2:切换到其他视频详情页,该网站并不刷新,也不更新`window.__playinfo__`
- 难点3:用户多次点击右键菜单新增的条目,如何应对?用“用户思维”规避技术难题
- 常规1:提供文件下载功能
- 难点4:在React项目中实现,多行文本只展示前10行(clamp-js-main)
- 参考资料
引言
Sample:aHR0cHM6Ly93d3cuYmlsaWJpbGkuY29tL3ZpZGVvL0JWMXBwNDIxZDc1cQ==
我去年写了这篇文章:【前端甜点】某视频网站的m4s视频/音频下载方案(20240420),但大概从去年8月开始,我发现,下载质量较高的音频时会报错net::ERR_FAILED 206
,较低的则不报错。我百思不得其解,只能暂且猜测:
- 该网站发现了xhr请求和fetch api发的请求的差异,导致失败。
- 跨域问题。
但在某次尝试中,我copy as fetch
,重放请求,发现虽然报错,但这个错误似乎可以解决:
<详情页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代码。据此,我新写了一版代码:
// 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 Blobconst 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:https://www.52pojie.cn/thread-2026417-1-1.html
本文CSDN:https://blog.csdn.net/hans774882968/article/details/147429322
本文juejin:https://juejin.cn/post/7496003348198588470
作者:hans774882968以及hans774882968以及hans774882968
效果
v1.0.0
https://github.com/Hans774882968/bili-m4s-fetch-demo/releases/tag/v1.0.0
TODO
这里只给出最重要的TODO,完整版见:其他笔记.md。
- 番剧页面的代码要求我们改用Babel AST来分析代码,而且
window.__playinfo__
的数据结构也和视频详情页不同。如下文bangumi.js
所示。另外,限免集和会员集的数据结构也不一样。限免集是有video_info.dash
的,仍然给你视频和音频的m4s;会员集则只有video_info.durls
,直接给你mp4文件。
让AI写初稿
最初的Prompt传送门:
请帮我写一个Chrome扩展程序。要求如下:
- 使用manifest v3,只在 https://www.example.com/ 激活。
- 请生成前端工程,使用React 18和Yarn,组件库用ant-design。
- 为鼠标右键添加一个选项,点击产生一个弹窗。弹窗由两个div组成,分别占据弹窗的左侧和右侧。左侧和右侧div的class名分别为“url-list”和“result”,下文分别用url-list和div.result指代。这两个div的width和height都应一样。url-list是一个列表,每个条目展示一个url,每个条目的右侧都有一个按钮,文案为“获取”。div.result展示一个base64字符串,文本过长则用省略号省略过长部分,并提供一个按钮,文案为“复制”,点击后将字符串复制到剪贴板。
- 监听网络请求,如果某请求的url包含字符串30216.m4s、30232.m4s、30264.m4s、30280.m4s中的一个,则将该url添加到url-list中展示,注意它要添加到url-list的开头,使得最新的请求排在最前面。点击某url右侧“获取”按钮,则向该url发送get请求,将返回体转为blob,进而编码为base64字符串,展示在div.result中。
- url-list提供分页功能,每页展示10个条目。
生成的代码有很多问题,比如不是React脚手架生成的、没有package.json等。所以有了后续的Chat:
- 该react项目应使用vite脚手架生成
- 请提供package.json
写README的时候再回顾这段Prompt,感觉可能是我脑海里的需求点太过模糊,也可能是我对Chrome插件的API不够熟悉,才导致了AI生成的代码离可用有很大的鸿沟。当然,它还是给了我一点点启发。
两条路:在content.js
里,还是popup.js
里发请求?
我们知道Chrome插件分为3层,分别为content.js
、background.js
和popup.html / popup.js
。content.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
新建项目
yarn create vite bili-m4s-fetch-demo
即可新建Vite + React项目。因为我感觉这只是一个袖珍项目,所以并没有为项目引入TypeScript。
项目结构:
.
│ .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│ └─imagesbili.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:
- 打包框架是vite,这是一个vite脚手架生成的react项目。我希望修改vite.config.js,以content.js为打包入口,去掉没必要存在的index.html,且打包后对应的js文件名仍为content.js。请根据以上信息修改上述代码。
- 请调整vite.config.js及其他文件,实现:在开发模式下,入口为content.html,引用content.js;在生产模式下,和原来一样,入口为content.js
background.js
:在鼠标右键菜单添加一个选项,点击后通知content.js
第一次创建弹窗
问AI即可拿到可用的代码:
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给的代码的基础上改了一点点:
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 ,移除菜单则会,所以记得加 awaitawait 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行代码……完整代码:
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 }}
(它们并不冲突),而新版要改为:
languageOptions: {// https://stackoverflow.com/questions/48584556/eslint-chrome-is-not-defined-no-undefglobals: {...globals.node,...globals.browser,...globals.webextensions},}
同理,它们并不冲突。
content.css
:希望像普通的前端项目一样写CSS
为了能像普通的前端项目一样写CSS,我们希望让jsx import的CSS文件能被打包为content.css
。
首先,需要修改manifest.json
,让插件在标签页注入CSS:
"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
才能做到。
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 unnecessarydocument.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
:
{"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
:
// 规则 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掉了上述方案。
{code: 0, data: { dash: {audio: [{ id: 30280 / 30232 / 30216, baseUrl: "", backupUrl: "" }] // 3 itemsvideo: [{ id: 80 / 64 / 32 / 16, baseUrl: "", backupUrl: "" }] // 4 * 3 items}}
}
但content.js
是独立的执行环境,拿不到这个变量,所以我们需要用JS去读script标签的代码。于是继续问doubao拿代码(getUrlsFromExampleCom.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__ = {...}
这一行赋值语句,所以可以用这个很粗糙的算法去拿。数据处理的代码比较杂乱,在此只展示一点点:
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。
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再向网页发一次请求即可。相关代码:
// 在同一个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。
const [urls, setUrls] = useState(initialUrls);const updateM4sUrls = async () => {const newUrls = await getNewUrlsFromHtml();setUrls(newUrls);messageApi.success('同步完成');};// 组件:<Header className="toolbar"><Buttontype="primary"icon={<ReloadOutlined />}onClick={updateM4sUrls}>同步</Button><Tooltip title={syncBtnTooltipTitle}><QuestionCircleTwoTone style={{ fontSize: '16px' }} /></Tooltip></Header>
难点3:用户多次点击右键菜单新增的条目,如何应对?用“用户思维”规避技术难题
- 让对话框再次出现。
content.js
的代码在组件之外,所以这不现实。 - 叉掉对话框时,让它被销毁,而非被隐藏。看了下控制台,对话框的DOM元素并不是
<div id="root">
的子元素。如果要强行实现这个方案,代码会比较丑。
后来我意识到,不需要在技术层面让组件消失,只需要在对话框隐藏期间,让组件对用户来说不太显眼。相应地,多次点击右键菜单新增条目,我们不予理会。于是我们用用户思维成功规避了技术难题。但在对话框隐藏期间,我们如何唤出它?很简单,我们展示一个悬浮在网页右上角的按钮,用户点击按钮时,对话框便再次出现。
const [isDialogOpen, setIsDialogOpen] = useState(true);const expandDialog = () => {setIsDialogOpen(true);};const expandDialogBtn = !isDialogOpen && (<Buttontype="primary"className="expand-dialog-btn"style={{ position: 'fixed', zIndex: 114514, top: '80px', right: '16px' }}icon={<ArrowsAltOutlined />}onClick={expandDialog}>展开下载助手</Button>);
还有个小插曲。我给按钮设置了如下CSS:
.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装进去。
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);
}
接着在按钮点击时调用:
const handleExport = () => {downloadFileByALink(blobResult, m4sFileName);};
至于blobResult
,只需要:
fetch().then(res => res.blob());
就能拿到。
难点4:在React项目中实现,多行文本只展示前10行(clamp-js-main)
我不仅在界面提供了可下载的文件,还提供了可复制的文件内容的Base64字符串。于是我希望只展示前10行文本,其余的显示省略号。一开始我用了纯CSS:
.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
:
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
调用:
<Content className="sub-div-content result-content"><HansClamplines={10}text={base64Result || '暂无,请先发请求'}/></Content>
参考资料
- https://blog.haoji.me/chrome-plugin-develop.html
- https://developer.chrome.com/docs/extensions/reference/api/runtime?hl=zh-cn
- 将消息从
background.js
传递到popup.js
:https://stackoverflow.com/questions/12265403/passing-message-from-background-js-to-popup-js
相关文章:
【Chrome插件开发】某视频网站的m4s视频/音频下载方案,及其Chrome插件实现-v250415
文章目录 引言效果v1.0.0 TODO让AI写初稿两条路:在content.js里,还是popup.js里发请求?World in content.js新建项目如何打包background.js:在鼠标右键菜单添加一个选项,点击后通知content.js第一次创建弹窗eslint 9如…...
Nginx:前后端分离配置(静态资源+反向代理)
Nginx 前后端分离配置 [!IMPORTANT] 前端静态资源位置:/www/wwwroot/dist后端部署端口:9999 server {listen 80;server_name www.0ll1.com;location / {root /www/wwwroot/dist;try_files $uri $uri/ /index.html;index index.html index.htm;…...
go中map和slice非线程安全
参考视频:百度 Go二面: map与切片哪个是线程安全的_哔哩哔哩_bilibili go中的map和slice是非线程安全类型的。 非线程安全类型的表现为: 并发调用时会报错并发调用后结果不可预测 go中三种线程安全类型: channel,底…...
第T9周:猫狗识别2
🍨 本文为🔗365天深度学习训练营 中的学习记录博客🍖 原作者:K同学啊 第T9周:猫狗识别2 tf.config.list_physical_devices(“GPU”),用于检测当前系统是否有可用的 GPU,并将结果存入 gpus 变量…...
AlmaLinux 9.5 调整home和根分区大小
在使用linux的过程中, 有时会出现因为安装系统时分区不当导致有的分区空间不足,而有的分区空间过剩的情况.下面本文将讲述解决linux系统AlmaLinux 下调整home和根分区大小的方法。 1、查看现有分区 df -Th2、备份/home中的用户数据 mkdir /backup && mv /home/* /ba…...
[Vue3]绑定props 默认值
前言 使用TS、Vue3组织组件中传入props的通用方式 步骤 步骤1:使用 defineProps 定义 Props 使用interface定义props中各项的类型: // 组件List.vue// 定义 Props 类型和接口 interface ListItem {name: string;time: string;content: {status: numbe…...
【android bluetooth 协议分析 11】【AVDTP详解 1】【宏观感受一下avdtp是个啥东东】
我们先从宏观感受一下avdtp协议是个啥东东, 和 a2dp 是啥关系。 在蓝牙协议中的层次。以及他是如何和 例如l2cap 、sdp、a2dp 配合的。先从宏观把握,我们在逐步展开对 avdtp 的源码分析。 我们先从生活中的小例子感性的认识一下 avdtp 在 蓝牙协议中的作…...
【MATLAB第116期】基于MATLAB的NBRO-XGBoost的SHAP可解释回归模型(敏感性分析方法)
【MATLAB第116期】基于MATLAB的NBRO-XGBoost的SHAP可解释回归模型(敏感性分析方法) 引言 该文章实现了一个可解释的回归模型,使用NBRO-XGBoost(方法可以替换,但是需要有一定的编程基础)来预测特征输出。该…...
【Spring】静态代理、动态代理
Java中,代理模式是一种设计模式,用于通过代理对象控制对目标对象的访问。代理可以分为静态代理和动态代理,其中动态代理又包括JDK动态代理和CGLIB动态代理。这些机制在Spring框架中广泛用于AOP(面向切面编程)、事务管理…...
关于el-table可展开行实现懒加载的方案
场景: 一个流程记录,以表格的形式展示。点击展开表格的某一行,可以看到该流程的详细记录。但是,详细记录数据独立于表格数据,在还没有展开这一行的时候就不去请求这一行的详细数据,以便加快网络请求的速度。…...
AutoJs相关学习
一、控件点击、模拟点击 如果一个控件的 clickablefalse,但它的父级控件是 clickabletrue,我们可以通过 向上查找父级控件 的方式找到可点击的父级,然后执行点击操作。以下是几种实现方法: 方法 1:使用 parent() 查找可…...
ISO15189认证有什么要求?ISO15189认证流程
ISO 15189 认证要求及流程详解 ISO 15189 是国际标准化组织(ISO)针对 医学实验室质量和能力 的认证标准,适用于医院检验科、第三方医学实验室、血站等机构。该认证确保实验室的技术能力和管理体系符合国际标准,提高检测结果的准确…...
【每天一个知识点】大模型的幻觉问题
“大模型的幻觉问题”是指大语言模型(如GPT系列、BERT衍生模型等)在生成内容时,产生不符合事实或逻辑的虚假信息,即所谓的“幻觉”(hallucination)。这在诸如问答、摘要、翻译、代码生成等任务中尤其常见。…...
光谱相机在肤质检测中的应用
光谱相机在肤质检测中具有独特优势,能够通过多波段光谱分析皮肤深层成分及生理状态,实现非侵入式、高精度、多维度的皮肤健康评估。以下是其核心应用与技术细节: 一、工作原理 光谱反射与吸收特性: 血红蛋白&a…...
【c语言】指针和数组笔试题解析
一维数组: //数组名a如果既不单独放在sizeof()中,也不与&结合,那么就表示数组首元素的大小 //a一般表示数组首元素地址,只有两种情况表示整个数组,sizeof(arr)表示整个数组的大小,&arr表示数组的地址 int a[]…...
【Spring】AutoConfigureOrder与Order注解的区别与使用方式
@AutoConfigureOrder与@Order都是Spring框架中用于控制组件优先级的注解,但它们有不同的应用场景和作用范围。 一、基本区别 1. 包和位置 @Order: 位于org.springframework.core.annotation包,是Spring核心包的一部分@AutoConfigureOrder: 位于org.springframework.boot.au…...
基于SpringBoot的校园赛事直播管理系统-项目分享
基于SpringBoot的校园赛事直播管理系统-项目分享 项目介绍项目摘要管理员功能图用户功能图项目预览首页总览个人中心礼物管理主播管理 最后 项目介绍 使用者:管理员、用户 开发技术:MySQLJavaSpringBootVue 项目摘要 随着互联网和移动技术的持续进步&…...
科研小白可以做哪些准备
断更五个月啦,这段时间一直忙于自己的研究课题。今天给大家分享我在这段时间对科研的一些认识和积累的经验,希望对大家有所帮助。 一、学术研究的认识与思考 什么是科研 什么是好的研究 首先,就是要回答“什么是科研?什么是好研…...
2025.4.22学习日记 JavaScript的常用事件
在 JavaScript 里,事件是在文档或者浏览器窗口中发生的特定交互瞬间,例如点击按钮、页面加载完成等等。下面是一些常用的事件以及案例: 1. click 事件 当用户点击元素时触发 const button document.createElement(button); button.textCo…...
TikTok X-Gnarly纯算分享
TK核心签名校验:X-Bougs 比较简单 X-Gnarly已经替代了_signature参数(不好校验数据) 主要围绕query body ua进行加密验证 伴随着时间戳 浏览器指纹 随机值 特征值 秘钥转换 自写算法 魔改base64编码 与X-bougs 长a-Bougs流程一致。 视频…...
CentOS7 环境配置
CentOS 7 环境配置 我的基础环境: Windows11 CentOS版本:CentOS Linux release 7.7.1908 (Core) Vmware版本:VMware Workstation 17 Pro 17.5.0 build-22583795 使用工具:MobaXterm 注意: 所有有关防火墙的操作都可以…...
缓存,内存,本地缓存等辨析
快速辨析缓存,内存,本地缓存,memcache,redis等 (个人临时记录) 缓存 泛指所有用于暂存数据以提升访问速度的技术,包括本地缓存、分布式缓存、CPU缓存等。核心目标是减少对慢速存储(…...
C++模板学习(进阶)
目录 一.非类型模板参数 二.模板的特化 一).函数模板特化 二).类模板特化 1.全特化 2.偏特化 三.模板分离编译 一).什么是分离编译 1. 问题描述 2. 模板的实例化机制 3. 分离编译的困境 二).解决方法 1. 头文件包含…...
【Git】fork 和 branch 的区别
在 Git 中,“fork” 和 “branch” 是两个不同的概念,它们用于不同的场景并且服务于不同的目的。理解这两者的区别对于有效地使用 Git 进行版本控制非常重要。 1. Fork(分叉) 定义 Fork 是指在 GitHub、GitLab 等代码托管平台上…...
STM32单片机入门学习——第45节: [13-2] 修改频主睡眠模式停止模式待机模式
写这个文章是用来学习的,记录一下我的学习过程。希望我能一直坚持下去,我只是一个小白,只是想好好学习,我知道这会很难,但我还是想去做! 本文写于:2025.04.22 STM32开发板学习——第45节: [13-2] 修改主频&睡眠模式&停止模式&待…...
Java中常见API的分类概述及示例
1. 集合框架(java.util 包) 核心接口与实现类 接口实现类特点示例代码ListArrayList, LinkedList有序、可重复List<String> list new ArrayList<>(); list.add("Java");SetHashSet, TreeSet无序、唯一Set<Integer> set …...
IOT项目——物联网 GPS
GeoLinker - 物联网 GPS 可视化工具 项目来源制作引导 项目来源 [视频链接] https://youtu.be/vi_cIuxDpcA?sigMaOKv681bAirQF8 想要在任何地方追踪任何东西吗?在本视频中,我们将向您展示如何使用 ESP32 和 Neo-6M GPS 模块构建 GPS 跟踪器——这是一…...
开源状态机引擎,在实战中可以放心使用
### Squirrel-Foundation 状态机开源项目介绍 **Squirrel-Foundation** 是一个轻量级、灵活、可扩展、易于使用且类型安全的 Java 状态机实现,适用于企业级应用。它提供了多种方式来定义状态机,包括注解声明和 Fluent API,并且支持状态转换、…...
TockOS,一种新安全软件架构的RTOS介绍
文章目录 1. TockOS介绍详细总结 2. TockOS开源项目的目录结构3. 胶囊(Capsules)胶囊的本质胶囊的特点胶囊的应用场景 4. 胶囊的实现模块化设计安全隔离事件驱动可复用性 1. TockOS介绍 Tock 是一款面向 Cortex-M 和 RISC-V 微控制器的安全嵌入式操作系…...
AGI大模型(12):向量检索之关键字搜索
1 检索的方式有那些 列举两种: 关键字搜索:通过用户输入的关键字来查找文本数据。语义搜索:不仅考虑关键词的匹配,还考虑词汇之间的语义关系,以提供更准确的搜索结果。2 关键字搜索 先看一个最基础的实现 安装模块 pip install redis 不会redis的去看我的redis专题 首…...
数据库MySQL学习——day1(创建表与数据类型)
文章目录 1. 创建表(CREATE TABLE)1.1. 创建表的基本语法:1.2. 示例:创建学生信息表 2. 数据类型2.1. 常用的数据类型: 3. 表约束3.1. 常见约束类型:3.2. 示例:添加约束条件3.3. 修改表添加约束…...
基于Transformer与随机森林的多变量时间序列预测
哈喽,我不是小upper,今天和大家聊聊基于Transformer与随机森林的多变量时间序列预测。 不懂Transformer的小伙伴可以看我上篇文章:一文带你彻底搞懂!Transformer !!https://blog.csdn.net/qq_70350287/article/detail…...
【程序员 NLP 入门】词嵌入 - 上下文中的窗口大小是什么意思? (★小白必会版★)
🌟 嗨,你好,我是 青松 ! 🌈 希望用我的经验,让“程序猿”的AI学习之路走的更容易些,若我的经验能为你前行的道路增添一丝轻松,我将倍感荣幸!共勉~ 【程序员 NLP 入门】词…...
MATLAB Coder 应用:转换 MATLAB 代码至 C/C++ | 实践步骤与问题解决
注:本文为 “ MATLAB 代码至 C/C 应用” 相关文章合辑。 未整理去重。 如有内容异常,请看原文。 MATLAB 代码转换为 C/C 代码的详细指南 随心 390 zhihu 发布于 2020-07-12 12:39 在实际项目中,我们常常遇到需要将 MATLAB 代码转换为 C/C …...
BLE 6.0 六大核心特性全解析
写在前面: 2025年1月15日,Bluetooth SIG发布了备受期待的 Bluetooth Core Specification 6.0。相比5.x系列,6.0在测距精度、能耗优化、扫描过滤、音频体验和协议灵活性等方面实现了重大突破。本文将以浅显易懂的语言、丰富的图示和真实案例,带你全面深入了解BLE 6.0的六大核…...
网络应用程序体系结构
本文来源 : 《计算机网络 自顶向下方法》 应用程序体系结构(application architecture)由应用程序研发者设计,规定了如何在各种端系统上组织该应用程序。 现代网络应用程序中使用的两种主流体系结构: (1)客户-服务器…...
Filename too long 错误
Filename too long 错误表明文件名超出了文件系统或版本控制系统允许的最大长度。 可能的原因 文件系统限制 不同的文件系统对文件名长度有不同的限制。例如,FAT32 文件名最长为 255 个字符,而 NTFS 虽然支持较长的文件名,但在某些情况下也…...
Linux学习——UDP
编程的整体框架 bind:绑定服务器:TCP地址和端口号 receivefrom():阻塞等待客户端数据 sendto():指定服务器的IP地址和端口号,要发送的数据 无连接尽力传输,UDP:是不可靠传输 实时的音视频传输&#x…...
C++:继承
目录 一:继承的概念 1.1 继承的定义 1.2 继承方式 1.3 可见性区别 公有方式 私有方式 保护方式 1.4 一般规则 二、继承中的隐藏规则 三、基类和派生类间的转换 四、派生类的默认成员函数 实现一个不能被继承的类 继承与友元 五、继承与静态成员 六、多…...
RSGISLib:一款功能强大的GIS与RS数据处理Python工具包
今天为大家介绍的软件是RSGISLib:一款功能丰富的遥感与GIS数据的python库。下面,我们将从软件的主要功能、支持的系统、软件官网等方面对其进行简单的介绍。 RSGISLib官网网址为:http://rsgislib.org/,它提供了一个丰富的工具集&…...
Git管理
1.创建git仓库 git init 2.让文件添加到暂存区 git add. 3.给暂存区文件添加说明,并提交到本地仓库 git commit -m 说明 4.查看历史记录 git log /git log --oneline 查看状态:git status 5. 引用旧版 git reset --hard commitid 6.创建分支 …...
Java中内部类
1.静态类与非静态类是内部类的区分,外部类不可以被static修饰。 2.类的加载过程:类只有被使用才会被类加载器加载,加载后类的信息放在元空间(方法区)中。类的使用包括初始化对象、静态方法的调用。 3.静态内部类与普…...
[U-Net-Dual]DEU-Net
论文题目:DEU-Net: Dual-Encoder U-Net for Automated Skin Lesion Segmentation 中文题目:DEU-Net:用于自动皮肤病变分割的双编码器U-Net 0摘要 皮肤病的计算机辅助诊断(CAD)在很大程度上依赖于皮肤病变的自动分割,尽管由于病变在形状、大小、颜色和纹理上的多样性以及…...
【数据结构】第五弹——Stack 和 Queue
文章目录 一. 栈(Stack)1.1 概念1.2 栈的使用1.3 栈的模拟实现1.3.1 顺序表结构1.3.2 进栈 压栈1.3.3 删除栈顶元素1.3.4 获取栈顶元素1.3.5 自定义异常 1.4 栈的应用场景1.改变元素序列2. 将递归转化为循环3. 四道习题 1.5 概念分区 二. 队列(Queue)2.1 概念2.2 队列的使用2.3…...
LSTM如何解决梯度消失问题
LSTM如何解决梯度消失问题 一、传统RNN的梯度消失困境 在标准RNN中,隐藏状态更新公式为: h t tanh ( W h h h t − 1 W x h x t b h ) h_t \tanh(W_{hh}h_{t-1} W_{xh}x_t b_h) httanh(Whhht−1Wxhxtbh) 梯度计算通过链式法则展…...
什么是管理思维?
管理思维是指在管理活动中形成的系统性、战略性和创造性的思考方式,帮助个人或团队更高效地达成目标。它不仅适用于企业管理,也适用于个人成长、项目执行和复杂问题解决。以下是关于管理思维的核心内容: 一、管理思维的核心特征 1. 系统性思…...
缓存与内存;缺页中断;缓存映射:组相联
文章目录 内存(RAM)与缓存(Cache)Memory Management Unit缺页中断 多级缓存缓存替换策略缓存的映射方式 内存(RAM)与缓存(Cache) 缓存: CPU 内部或非常靠近的高速存储&a…...
12.5/Q1,GBD高分文章解读
文章题目:Global, regional, and national burdens of early onset pancreatic cancer in adolescents and adults aged 15-49 years from 1990 to 2019 based on the Global Burden of Disease Study 2019: a cross-sectional stud DOI:10.1097/JS9.000…...
路由交换网络专题 | 第六章 | OSPF | BGP | BGP属性 | 防环机制
目录 拓扑图 (1)AS 400 内部使用 OSPF 路由协议,使 PC2 访问 PC3 的路径优先选择 AR2-AR4-AR3。 (2)AS 400 内部使用 RIP 路由协议,使 PC2 访问 PC3 的路径优先选择 AR2-AR4-AR3。 (3&#…...
ubuntu 安装 redis server
ubuntu 安装 redis server sudo apt update sudo apt install redis-server The following NEW packages will be installed:libhiredis0.14 libjemalloc2 liblua5.1-0 lua-bitop lua-cjson redis-server redis-toolssudo systemctl start redis-server sudo systemctl ena…...