├── .gitignore ├── .vscode └── extensions.json ├── LICENSE ├── README.md ├── index.html ├── logo.png ├── manifest.json ├── option.html ├── package-lock.json ├── package.json ├── public └── vite.svg ├── screenShot ├── demo.png ├── f1.png ├── f2.png ├── logo.jpg └── logo.png ├── src ├── action │ ├── App.vue │ ├── main.ts │ └── style.css ├── ai │ ├── index.ts │ └── provider │ │ ├── chatGptWeb.ts │ │ └── openai.ts ├── background.ts ├── background │ ├── index.ts │ └── prompt.ts ├── chat │ └── main.ts ├── config.ts ├── content-scripts │ ├── App.vue │ ├── icon │ │ └── ChatGPT.svg │ ├── lang.ts │ ├── main.ts │ ├── pages │ │ ├── Help.vue │ │ ├── Setting.vue │ │ ├── Subtitle.vue │ │ ├── Summary.vue │ │ └── infoPage │ │ │ ├── Fetching.vue │ │ │ └── Info.vue │ ├── state.ts │ └── utils.ts ├── inject │ ├── injecter.ts │ └── main-world.ts ├── option │ ├── App.vue │ ├── main.ts │ ├── pages │ │ ├── about.vue │ │ ├── help.vue │ │ ├── provider │ │ │ ├── ChatgptWeb.vue │ │ │ └── Openai.vue │ │ ├── setting.vue │ │ ├── subtitle.vue │ │ └── summary.vue │ └── state.ts ├── prompt.ts ├── rank │ └── index.ts ├── store │ └── index.ts ├── types.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"] 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2023, Qixiang Zhu 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BilibiliSummary 2 | ![运行截图](https://raw.githubusercontent.com/lxfater/BilibiliSummary/main/screenShot/logo.jpg) 3 | ## Introduction 4 | BilibiliSummary is a Chrome extension that helps you summarize videos on Bilibili. 5 | 6 | Do you ever feel tired when watching long videos and wish you could quickly understand the main content of the video without spending a lot of time watching the whole thing? Then our Chrome extension is designed for you! 7 | 8 | ## How to Use 9 | Log in to Bilibili, click on a video, and then click the BilibiliSummary icon to start generating a video summary. Our extension will analyze the video content within 5 seconds and generate a short but detailed video summary, telling you the main content and key points of the video. You can choose to watch the entire video or just quickly browse the summary according to your needs. 10 | 11 | Chrome store: https://chrome.google.com/webstore/detail/summary-for-bilibili/hjjdhgophcjfgkempifgiflgekhecnme?hl=ch 12 | 13 | ### Notes 14 | Do not close the ChatGPT tab, as this will interrupt your service. 15 | 16 | Because we use subtitles for parsing, it is not suitable to use an APIKEY that charges by the number of words. This method will not consume your APIKEY and will not cost you any money, of course, it will not cost me any money either. 17 | 18 | 19 | ### Usage 20 | Download the dist.zip from Releases and then unzip and install it. 21 | 22 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + Vue + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lxfater/BilibiliSummary/26cd21f5ec6928bd10bffb864079a98156947e54/logo.png -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Summary for Bilibili", 4 | "version": "1.0.0", 5 | "icons": { 6 | "16": "logo.png", 7 | "32": "logo.png", 8 | "48": "logo.png", 9 | "128": "logo.png" 10 | }, 11 | "host_permissions": ["https://*.openai.com/"], 12 | "content_scripts": [ 13 | { 14 | "matches": ["https://www.bilibili.com/video/*"], 15 | "js": ["src/inject/injecter.ts"], 16 | "run_at": "document_start" 17 | }, 18 | { 19 | "matches": [ "https://www.bilibili.com/video/*" ], 20 | "js": [ "src/content-scripts/main.ts"], 21 | "run_at": "document_idle" 22 | }, 23 | { 24 | "matches": [ "https://chat.openai.com/chat" ], 25 | "js": [ "src/chat/main.ts"], 26 | "run_at": "document_idle" 27 | } 28 | ], 29 | "options_page": "option.html", 30 | "permissions": [ 31 | "unlimitedStorage", 32 | "storage" 33 | ], 34 | "background": { 35 | "service_worker": "src/background/index.ts", 36 | "type": "module" 37 | } 38 | } 39 | 40 | -------------------------------------------------------------------------------- /option.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 设置 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bilibilisummary", 3 | "private": true, 4 | "version": "0.1.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vue-tsc && vite build", 9 | "fast-build": "vite build", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@lxfater/ai-bridge": "^1.0.1", 14 | "ai-bridge": "^1.0.3", 15 | "ajax-hook": "^2.1.3", 16 | "element-plus": "^2.2.32", 17 | "eventsource-parser": "^0.1.0", 18 | "expiry-map": "^2.0.0", 19 | "markdown-it": "^13.0.1", 20 | "openai": "^3.1.0", 21 | "p-queue": "^7.3.4", 22 | "pinia": "^2.0.31", 23 | "prompt-engine": "^0.0.31", 24 | "simple-notify": "^0.5.5", 25 | "uuid": "^9.0.0", 26 | "vant": "^4.0.11", 27 | "vue": "^3.2.45", 28 | "vue-router": "^4.1.6" 29 | }, 30 | "devDependencies": { 31 | "@crxjs/vite-plugin": "^2.0.0-beta.12", 32 | "@types/markdown-it": "^12.2.3", 33 | "@types/node": "^18.14.0", 34 | "@types/webextension-polyfill": "^0.10.0", 35 | "@vitejs/plugin-vue": "^4.0.0", 36 | "sass": "^1.58.3", 37 | "typescript": "^4.9.3", 38 | "vite": "^4.1.0", 39 | "vite-svg-loader": "^4.0.0", 40 | "vue-tsc": "^1.0.24", 41 | "webextension-polyfill": "^0.10.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /screenShot/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lxfater/BilibiliSummary/26cd21f5ec6928bd10bffb864079a98156947e54/screenShot/demo.png -------------------------------------------------------------------------------- /screenShot/f1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lxfater/BilibiliSummary/26cd21f5ec6928bd10bffb864079a98156947e54/screenShot/f1.png -------------------------------------------------------------------------------- /screenShot/f2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lxfater/BilibiliSummary/26cd21f5ec6928bd10bffb864079a98156947e54/screenShot/f2.png -------------------------------------------------------------------------------- /screenShot/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lxfater/BilibiliSummary/26cd21f5ec6928bd10bffb864079a98156947e54/screenShot/logo.jpg -------------------------------------------------------------------------------- /screenShot/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lxfater/BilibiliSummary/26cd21f5ec6928bd10bffb864079a98156947e54/screenShot/logo.png -------------------------------------------------------------------------------- /src/action/App.vue: -------------------------------------------------------------------------------- 1 | 3 | 4 | 9 | 10 | 24 | -------------------------------------------------------------------------------- /src/action/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import './style.css' 3 | import App from './App.vue' 4 | 5 | createApp(App).mount('#app') -------------------------------------------------------------------------------- /src/action/style.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lxfater/BilibiliSummary/26cd21f5ec6928bd10bffb864079a98156947e54/src/action/style.css -------------------------------------------------------------------------------- /src/ai/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ChatGptWebProvider } from "./provider/chatGptWeb"; -------------------------------------------------------------------------------- /src/ai/provider/chatGptWeb.ts: -------------------------------------------------------------------------------- 1 | 2 | import { v4 as uuidv4 } from "uuid"; 3 | import ExpiryMap from "expiry-map"; 4 | import { createParser } from "eventsource-parser"; 5 | type MessageInfo = { 6 | message: string; 7 | done: boolean; 8 | } 9 | type TalkOptions = { 10 | signal?: AbortSignal; 11 | cId?: string; 12 | pId?: string; 13 | refreshToken?: boolean; 14 | deleteConversation?: boolean; 15 | onMessage?: (messageInfo: MessageInfo) => void; 16 | } 17 | 18 | type AskOptions = { 19 | signal?: AbortSignal; 20 | deleteConversation?: boolean; 21 | refreshToken?: boolean; 22 | onMessage?: (messageInfo: MessageInfo) => void; 23 | } 24 | 25 | 26 | export interface Rely { 27 | message: Message; 28 | conversation_id: string; 29 | error: null; 30 | } 31 | 32 | export interface Message { 33 | id: string; 34 | role: string; 35 | user: null; 36 | create_time: null; 37 | update_time: null; 38 | content: Content; 39 | end_turn: null; 40 | weight: number; 41 | metadata: Metadata; 42 | recipient: string; 43 | } 44 | 45 | export interface Content { 46 | content_type: string; 47 | parts: string[]; 48 | } 49 | 50 | export interface Metadata { 51 | message_type: string; 52 | model_slug: string; 53 | } 54 | 55 | 56 | export default class Bridge { 57 | ACCESS_TOKEN = 'ACCESS_TOKEN' 58 | tokenCache = new ExpiryMap(30 * 60 * 1000); 59 | errorText = { 60 | "Too many requests": "tooManyRequests", 61 | "Unauthorized": "unauthorized", 62 | "Not Found": "notFound", 63 | "Unknown": "unknown", 64 | "CloudFlare": "cloudFlare", 65 | "Only one": "onlyOne", 66 | "Rate limit": "rateLimit", 67 | } 68 | private async fetchResult(path: string, options: { method: any; headers: any; body: any; signal: any; }) { 69 | const { method, headers, body, signal } = options; 70 | const response = await fetch(path, { 71 | signal, 72 | method, 73 | headers, 74 | body, 75 | }); 76 | if (this.isCloudFlare(response.status)) 77 | throw new Error(this.errorText["CloudFlare"]); 78 | if (response.status === 429) { 79 | throw new Error(this.errorText["Rate limit"]); 80 | } 81 | if (response.ok) { 82 | return response; 83 | } 84 | const result = await response.json().catch((e) => { 85 | // console.log(e) 86 | }); 87 | throw this.createErrorResult(result) 88 | } 89 | isCloudFlare(status: number) { 90 | return status === 403; 91 | } 92 | createErrorResult(result: { detail: { message: string } }) { 93 | const text = result.detail.message || ""; 94 | const errorName = Object.keys(this.errorText).find((key) => text.includes(key)) || 'Unknown'; 95 | const errorMessage = this.errorText[errorName as keyof typeof this.errorText]; 96 | return new Error(errorMessage); 97 | } 98 | async getToken(refreshToken = false) { 99 | if (refreshToken === false && this.tokenCache.get(this.ACCESS_TOKEN)) { 100 | return this.tokenCache.get(this.ACCESS_TOKEN); 101 | } 102 | const response = await fetch("https://chat.openai.com/api/auth/session") 103 | if (this.isCloudFlare(response.status)) { 104 | throw new Error(this.errorText["CloudFlare"]); 105 | } 106 | const result = await response.json(); 107 | if (result.accessToken) { 108 | this.tokenCache.set(this.ACCESS_TOKEN, result.accessToken); 109 | return result.accessToken; 110 | } 111 | throw new Error(this.errorText['Unauthorized']) 112 | 113 | } 114 | private async getSSE(resource: string, options: any) { 115 | const { onData, ...fetchOptions } = options; 116 | try { 117 | const resp = await this.fetchResult(resource, fetchOptions); 118 | const feeder = createParser((event) => { 119 | if (event.type === "event") { 120 | onData(event.data); 121 | } 122 | }); 123 | if (resp.body === null) { 124 | throw new Error(' Null response body') 125 | } 126 | const textDecoder = new TextDecoder(); 127 | const reader = resp.body.getReader(); 128 | try { 129 | while (true) { 130 | const { done, value } = await reader.read(); 131 | if (done) { 132 | return void 0; 133 | } else { 134 | const info = textDecoder.decode(value); 135 | feeder.feed(info); 136 | } 137 | } 138 | } finally { 139 | reader.releaseLock(); 140 | } 141 | } catch (error) { 142 | debugger 143 | throw error; 144 | } 145 | 146 | 147 | } 148 | async ask(question: string, options: AskOptions) { 149 | const { signal, deleteConversation, refreshToken, onMessage: onData } = options; 150 | const accessToken = await this.getToken(refreshToken); 151 | let message = ''; 152 | let conversationID = '' 153 | signal?.addEventListener("abort", () => { 154 | this.clearConversation(accessToken, conversationID) 155 | }) 156 | const body = JSON.stringify({ 157 | action: "next", 158 | messages: [ 159 | { 160 | id: uuidv4(), 161 | role: "user", 162 | content: { 163 | content_type: "text", 164 | parts: [question], 165 | }, 166 | }, 167 | ], 168 | model: "text-davinci-002-render-sha", 169 | parent_message_id: uuidv4(), 170 | }); 171 | return new Promise((resolve, reject) => { 172 | this.getSSE("https://chat.openai.com/backend-api/conversation", { 173 | method: "POST", 174 | signal, 175 | headers: { 176 | "Content-Type": "application/json", 177 | Authorization: `Bearer ${accessToken}`, 178 | }, 179 | body, 180 | onData: (str: string) => { 181 | if (str === "[DONE]") { 182 | if (deleteConversation) { 183 | this.clearConversation(accessToken, conversationID) 184 | } 185 | if (onData) { 186 | onData({ 187 | message, 188 | done: true 189 | }) 190 | } 191 | resolve(message) 192 | return; 193 | } else { 194 | try { 195 | const data = JSON.parse(str); 196 | message = data.message?.content?.parts?.[0]; 197 | conversationID = data.conversation_id 198 | if (onData) { 199 | onData({ 200 | message, 201 | done: false 202 | }) 203 | } 204 | } catch (error) { 205 | console.error(error); 206 | } 207 | } 208 | }, 209 | }).catch(e => { 210 | reject(e) 211 | }) 212 | }) 213 | } 214 | async talk(question: string, options: TalkOptions) { 215 | const { signal, cId, pId, onMessage: onData, deleteConversation, refreshToken } = options; 216 | const accessToken = await this.getToken(refreshToken); 217 | let message = ''; 218 | let id = ''; 219 | let conversationID = '' 220 | signal?.addEventListener("abort", () => { 221 | this.clearConversation(accessToken, conversationID) 222 | }) 223 | const basis = { 224 | action: "next", 225 | messages: [ 226 | { 227 | id: uuidv4(), 228 | role: "user", 229 | content: { 230 | content_type: "text", 231 | parts: [question], 232 | }, 233 | }, 234 | ], 235 | model: "text-davinci-002-render-sha", 236 | parent_message_id: pId ? pId : uuidv4(), 237 | } 238 | const body = JSON.stringify(cId ? { ...basis, conversation_id: cId } : basis); 239 | return new Promise<{ 240 | text: string; 241 | cId: string; 242 | pId: string; 243 | }>((resolve, reject) => { 244 | try { 245 | this.getSSE("https://chat.openai.com/backend-api/conversation", { 246 | method: "POST", 247 | signal, 248 | headers: { 249 | "Content-Type": "application/json", 250 | Authorization: `Bearer ${accessToken}`, 251 | }, 252 | body, 253 | onData: (str: string) => { 254 | if (str === "[DONE]") { 255 | if (deleteConversation) { 256 | this.clearConversation(accessToken, conversationID) 257 | } 258 | if (onData) { 259 | onData({ 260 | message, 261 | done: true 262 | }) 263 | } 264 | resolve({ 265 | text: message, 266 | cId: conversationID, 267 | pId: id 268 | }) 269 | } else { 270 | try { 271 | const data = JSON.parse(str) as Rely 272 | message = data.message?.content?.parts?.[0]; 273 | id = data.message.id 274 | conversationID = data.conversation_id 275 | if (onData) { 276 | onData({ 277 | message, 278 | done: false 279 | }) 280 | } 281 | } catch (error) { 282 | // console.error(error); 283 | } 284 | } 285 | }, 286 | }); 287 | } catch (error) { 288 | reject(error); 289 | } 290 | 291 | }) 292 | } 293 | async clearConversation(token: string, conversationId: string) { 294 | const path = `https://chat.openai.com/backend-api/conversation/${conversationId}`; 295 | const data = { is_visible: false }; 296 | const options: RequestInit = { 297 | method: 'PATCH', body: JSON.stringify(data), headers: { 298 | 'Content-Type': 'application/json', 299 | Authorization: `Bearer ${token}`, 300 | } 301 | }; 302 | await fetch(`${path}`, options); 303 | } 304 | } -------------------------------------------------------------------------------- /src/ai/provider/openai.ts: -------------------------------------------------------------------------------- 1 | import { createParser } from "eventsource-parser"; 2 | export class Openai { 3 | key: string 4 | gpt35 = ['gpt-3.5-turbo-0301','gpt-3.5-turbo'] 5 | model: string = 'gpt-3.5-turbo' 6 | maxTokens: number 7 | constructor(key: string, mode:string, maxTokens: string) { 8 | this.key = key; 9 | this.model = mode; 10 | this.maxTokens = parseInt(maxTokens); 11 | } 12 | getUrl() { 13 | return this.gpt35.includes(this.model) ? 'https://api.openai.com/v1/chat/completions': 'https://api.openai.com/v1/completions' ; 14 | } 15 | private async getSSE(resource:string, options: any) { 16 | const { onData, ...fetchOptions } = options; 17 | const resp = await fetch(resource, fetchOptions); 18 | const feeder = createParser((event) => { 19 | if (event.type === "event") { 20 | onData(event.data); 21 | } 22 | }); 23 | if(resp.body === null) { 24 | throw new Error(' Null response body') 25 | } 26 | const textDecoder = new TextDecoder(); 27 | const reader = resp.body.getReader(); 28 | try { 29 | while (true) { 30 | const { done, value } = await reader.read(); 31 | if (done) { 32 | return void 0; 33 | } else { 34 | const info = textDecoder.decode(value); 35 | feeder.feed(info); 36 | } 37 | } 38 | } finally { 39 | reader.releaseLock(); 40 | } 41 | } 42 | getBody(question: string) { 43 | return this.gpt35.includes(this.model) ? JSON.stringify({ 44 | model: this.model, 45 | messages: [{role: "user", content:question}], 46 | stream: true, 47 | max_tokens: this.maxTokens 48 | }) : JSON.stringify({ 49 | prompt: question, 50 | model: this.model, 51 | stream: true, 52 | max_tokens: this.maxTokens, 53 | }) 54 | } 55 | parseMessage(data: any) { 56 | return this.gpt35.includes(this.model) ? (data.choices[0].delta.content || '') : data.choices[0].text 57 | } 58 | async ask(question: string, options: { signal: any; onMessage: any; }) { 59 | const { signal,onMessage: onData } = options; 60 | let message = ''; 61 | const body = this.getBody(question); 62 | return new Promise((resolve, reject) => { 63 | try { 64 | this.getSSE(this.getUrl(), { 65 | method: "POST", 66 | signal, 67 | headers: { 68 | "Content-Type": "application/json", 69 | Authorization: `Bearer ${this.key}`, 70 | }, 71 | body, 72 | onData:(str: string) => { 73 | if (str === "[DONE]") { 74 | if(onData) { 75 | onData({ 76 | message, 77 | done: true 78 | }) 79 | } 80 | resolve(message) 81 | return; 82 | } else { 83 | try { 84 | const data = JSON.parse(str); 85 | message += this.parseMessage(data) 86 | if(onData) { 87 | onData({ 88 | message, 89 | done: false 90 | }) 91 | } 92 | } catch (error) { 93 | console.error(error); 94 | } 95 | } 96 | }, 97 | }); 98 | } catch (error) { 99 | reject(error); 100 | } 101 | }) 102 | } 103 | } -------------------------------------------------------------------------------- /src/background.ts: -------------------------------------------------------------------------------- 1 | import Browser from 'webextension-polyfill' 2 | import { ChatGptWebProvider } from "@lxfater/ai-bridge" 3 | import { getSmallSizeTranscripts, getSummaryPrompt } from "./prompt"; 4 | const chatGptWebProvider = new ChatGptWebProvider() 5 | 6 | function getPrompt(content: any[], title: string) { 7 | const textData = content.map((item, index) => { 8 | return { 9 | text: item.content, 10 | index 11 | } 12 | }) 13 | const text = getSmallSizeTranscripts(textData, textData, 7000); 14 | const prompt = getSummaryPrompt(title,text, 7000); 15 | 16 | return prompt; 17 | } 18 | let lastController: AbortController | null = null 19 | Browser.runtime.onConnect.addListener((port) => { 20 | console.debug('connected', port) 21 | if (port.name === 'BilibiliSUMMARY') { 22 | port.onMessage.addListener(async (job, port) => { 23 | if (job.type === 'getSummary') { 24 | const question = getPrompt(job.content, job.title) 25 | console.log(question) 26 | // //@ts-ignore 27 | try { 28 | if(lastController) { 29 | lastController.abort() 30 | } 31 | lastController = new AbortController() 32 | let result = await chatGptWebProvider.ask(question,{ 33 | deleteConversation: true, 34 | signal: lastController!.signal, 35 | onMessage: (message) => { 36 | console.log(message) 37 | } 38 | }) 39 | port.postMessage({ 40 | type: 'summary', 41 | content: result 42 | }) 43 | } catch (error) { 44 | console.error(error) 45 | port.postMessage({ 46 | type: 'error', 47 | content: 'error' 48 | }) 49 | } 50 | } 51 | }) 52 | } 53 | }) -------------------------------------------------------------------------------- /src/background/index.ts: -------------------------------------------------------------------------------- 1 | import Browser from 'webextension-polyfill' 2 | import { getSmallSizeTranscripts, getSummaryPrompt } from './prompt'; 3 | import { Openai } from '../ai/provider/openai'; 4 | import { OpenaiSetting } from '../config'; 5 | import { Body } from '../types'; 6 | import { ExtensionStorage } from '../store/index' 7 | 8 | function getPrompt(content: Body[], words: number, baseTime: number,step: number, maxCount = 5, minCount =3, count = 188) { 9 | let group = groupSubtitleByTime(content,baseTime,step, maxCount, minCount, count); 10 | let totalLength = group.map(x => x.map(y => y.content)).join('').length; 11 | console.log(totalLength, '总字数') 12 | let times: string[] = []; 13 | let result = group.map(textData => { 14 | let from = textData[0].from; 15 | let itemLength = textData.map(x => x.content).join('').length 16 | const group = textData.map((item, index) => { 17 | return { 18 | text: item.content, 19 | index: index 20 | } 21 | }) 22 | const limit = Math.max(30, Math.round( itemLength / totalLength * (words * 2))) 23 | const content = getSmallSizeTranscripts(group, group,limit); 24 | return { 25 | from: from, 26 | content: content 27 | } 28 | }).filter(x => x.content.length > 0).map(x => { 29 | times.push(x.from) 30 | return `${x.from}: ${x.content}` 31 | }) 32 | const text = result.join('\n') 33 | const prompt = getSummaryPrompt('',text, times); 34 | 35 | return prompt; 36 | } 37 | 38 | // group the subtitle by the time 39 | function groupSubtitleByTime(subtitle: Body[], baseTime: number,step: number, maxCount = 6, minCount =3, count = 188) { 40 | 41 | const groupSubtitle: Body[][] = []; 42 | let group: Body[] = []; 43 | let lastTime = 0; 44 | for (let i = 0; i < subtitle.length; i++) { 45 | const item = subtitle[i]; 46 | if (lastTime === 0) { 47 | lastTime = item.from; 48 | group.push(item); 49 | } else { 50 | if (item.from - lastTime < baseTime) { 51 | group.push(item); 52 | } else { 53 | groupSubtitle.push(group); 54 | group = []; 55 | group.push(item); 56 | } 57 | lastTime = item.from; 58 | } 59 | } 60 | if (group.length > 0) { 61 | groupSubtitle.push(group); 62 | } 63 | if (groupSubtitle.length > maxCount && count > 0) { 64 | return groupSubtitleByTime(subtitle, baseTime + step, step, maxCount, minCount, count -1) 65 | } else if (groupSubtitle.length < minCount && count > 0) { 66 | return groupSubtitleByTime(subtitle, baseTime - step, step, maxCount, minCount, count - 1) 67 | } 68 | 69 | return groupSubtitle 70 | } 71 | 72 | // 将字幕里面的content根据数字分为几个集合 73 | function groupSubtitleByCount(subtitle: Body[], count: number) { 74 | const groupSubtitle: Body[][] = []; 75 | let group: Body[] = []; 76 | let textLength = 0; 77 | for (let i = 0; i < subtitle.length; i++) { 78 | const item = subtitle[i]; 79 | textLength += item.content.length; 80 | if (textLength < count) { 81 | group.push(item); 82 | } else { 83 | groupSubtitle.push(group); 84 | group = []; 85 | textLength = 0; 86 | } 87 | } 88 | if (group.length > 0) { 89 | groupSubtitle.push(group); 90 | } 91 | return groupSubtitle 92 | } 93 | 94 | class SummaryCache { 95 | public async getSummary(key: string) { 96 | try { 97 | let result = await Browser.storage.local.get([key]); 98 | if (result[key]) { 99 | return null; 100 | } else { 101 | return null; 102 | } 103 | } catch (error) { 104 | console.error(error); 105 | return null; 106 | } 107 | } 108 | 109 | public async setSummary(key: string, value: string) { 110 | try { 111 | await Browser.storage.local.set({ [key]: value }); 112 | } catch (error) { 113 | console.error(error); 114 | } 115 | } 116 | } 117 | const summaryCache = new SummaryCache(); 118 | 119 | class CommonService { 120 | async openOptionsPage() { 121 | const optionsUrl = Browser.runtime.getURL('option.html'); 122 | const tabs = await Browser.tabs.query({url: optionsUrl}); 123 | if (tabs.length && tabs[0] && tabs[0].windowId) { 124 | Browser.windows.update(tabs[0].windowId, {focused: true}); 125 | } else { 126 | Browser.runtime.openOptionsPage() 127 | } 128 | } 129 | } 130 | const commonService = new CommonService(); 131 | 132 | 133 | // write a code to await ms 134 | function sleep(ms: number) { 135 | return new Promise(resolve => setTimeout(resolve, ms)); 136 | } 137 | let storage = new ExtensionStorage(); 138 | class Store { 139 | optionKey ='meta' 140 | openai = "Openai" 141 | chatgpt = "ChatgptWeb" 142 | async getType() { 143 | let result = await Browser.storage.local.get([this.optionKey]) 144 | if(result[this.optionKey]) { 145 | return result[this.optionKey].providerType 146 | } else { 147 | return "ChatgptWeb" 148 | } 149 | } 150 | async getOpenaiSetting() { 151 | let result = await Browser.storage.local.get([this.optionKey]) 152 | if(result[this.optionKey]) { 153 | return result[this.optionKey].OpenaiSetting 154 | } else { 155 | return storage.getDefaultMetaKey().ChatgptWebSetting 156 | } 157 | } 158 | async getChatgptSetting() { 159 | let result = await Browser.storage.local.get([this.optionKey]) 160 | if(result[this.optionKey]) { 161 | return result[this.optionKey].ChatgptWebSetting 162 | } else { 163 | return storage.getDefaultMetaKey().ChatgptWebSetting 164 | } 165 | } 166 | } 167 | const store = new Store(); 168 | 169 | class OpenaiProvider { 170 | private lastController: AbortController | null = null; 171 | openai: Openai; 172 | constructor(private port: Browser.Runtime.Port, apikey: string, model: string, maxTokens: number) { 173 | this.openai = new Openai(apikey, model, maxTokens); 174 | } 175 | private cancel() { 176 | if (this.lastController) { 177 | this.lastController.abort(); 178 | this.lastController = null; 179 | } 180 | this.port.postMessage({ 181 | type: 'error', 182 | content: 'cancel' 183 | }); 184 | } 185 | 186 | private timeout(ms: number) { 187 | return setTimeout(() => { 188 | this.cancel(); 189 | this.port.postMessage({ 190 | type: 'error', 191 | content: 'timeout' 192 | }); 193 | }, ms); 194 | } 195 | 196 | public async handleJob(job) { 197 | if (job.type === 'getSummary') { 198 | let cache = await summaryCache.getSummary(job.videoId) 199 | if(cache) { 200 | this.port.postMessage({ 201 | type: 'summary', 202 | content: { 203 | videoId: job.videoId, 204 | message: cache 205 | } 206 | }) 207 | return 0; 208 | } 209 | if (this.lastController && job.force === false) { 210 | this.port.postMessage({ 211 | type: 'error', 212 | content: 'onlyOne' 213 | }); 214 | return 0; 215 | } 216 | 217 | try { 218 | if (this.lastController) { 219 | this.lastController.abort(); 220 | } 221 | this.lastController = new AbortController(); 222 | let setting = await store.getOpenaiSetting() 223 | if(!setting.apiKey) { 224 | this.port.postMessage({ 225 | type: 'error', 226 | content: 'emptyApiKey' 227 | }); 228 | return 0; 229 | } 230 | const timeoutThreshold = setting.timeout * 30 * 1000; 231 | const timeoutHandle = this.timeout(timeoutThreshold); 232 | const stopIntervalMs = setting.stopIntervalMs || 500 233 | const questions =groupSubtitleByCount(job.subtitle,parseFloat(setting.stopCount)).map(x => { 234 | return getPrompt(x,parseInt(setting.words),parseFloat(setting.baseTime),parseFloat(setting.step),parseInt(setting.maxCount), parseInt(setting.minCount), parseInt(setting.count)) 235 | }) 236 | let resultSum = '' 237 | for await (const question of questions) { 238 | if(this.lastController.signal.aborted) { 239 | break; 240 | } 241 | try { 242 | let result = await this.openai.ask(question,{ 243 | signal: this.lastController!.signal, 244 | onMessage: (m) => { 245 | clearTimeout(timeoutHandle); 246 | if(!this.lastController?.signal.aborted) { 247 | this.port.postMessage({ 248 | type: 'summary', 249 | content: { 250 | message: m.message, 251 | videoId: job.videoId 252 | } 253 | }); 254 | } 255 | }}) as string 256 | resultSum += result 257 | this.port.postMessage({ 258 | type: 'summaryFinal', 259 | content: { 260 | message: result, 261 | videoId: job.videoId 262 | } 263 | }); 264 | try { 265 | clearTimeout(timeoutHandle); 266 | } catch (error) { 267 | console.error(error); 268 | } 269 | await sleep(stopIntervalMs) 270 | } catch (error) { 271 | console.error(error); 272 | this.port.postMessage({ 273 | type: 'error', 274 | content: error.message 275 | }); 276 | } 277 | 278 | } 279 | 280 | try { 281 | await summaryCache.setSummary(job.videoId,resultSum) 282 | } catch (error) { 283 | console.error(error); 284 | } 285 | try { 286 | this.port.postMessage({ 287 | type: 'summaryFinalCache', 288 | content: { 289 | message: resultSum, 290 | videoId: job.videoId 291 | } 292 | }); 293 | } catch (error) { 294 | console.error(error); 295 | } 296 | } catch (error: any) { 297 | console.error(error); 298 | this.port.postMessage({ 299 | type: 'error', 300 | content: error.message 301 | }); 302 | } finally { 303 | this.lastController = null; 304 | } 305 | } else if (job.type === 'cancel') { 306 | this.cancel(); 307 | } 308 | } 309 | } 310 | class ChatgptConnector { 311 | BilibiliSummaryPort: Browser.Runtime.Port | null = null 312 | ChatgptPort: Browser.Runtime.Port | null = null 313 | async connect() { 314 | let tabs = await Browser.tabs.query({}) 315 | let find = false 316 | for await (let tab of tabs) { 317 | if (tab.url && tab.url!.includes("https://chat.openai.com")) { 318 | const updateProperties = { 'active': true }; 319 | await Browser.tabs.update(tab.id!, updateProperties); 320 | await Browser.tabs.reload(tab.id!) 321 | find = true 322 | } 323 | } 324 | 325 | if(!find) { 326 | await Browser.tabs.create({url: "https://chat.openai.com"}) 327 | } 328 | } 329 | async sendToChatgptPort(job: any,port: Browser.Runtime.Port) { 330 | if(job.type === 'login') { 331 | await this.connect() 332 | setTimeout(() => { 333 | port.postMessage({ 334 | type: 'error', 335 | content: 'reFetchable' 336 | }) 337 | }, 1000); 338 | } else { 339 | if(this.ChatgptPort) { 340 | if(job.type ==='getSummary') { 341 | let cache = await summaryCache.getSummary(job.videoId) 342 | if(cache) { 343 | port.postMessage({ 344 | type: 'summary', 345 | content: { 346 | videoId: job.videoId, 347 | message: cache 348 | } 349 | }) 350 | return 0; 351 | } 352 | let setting = await store.getChatgptSetting() 353 | const stopIntervalMs = setting.stopIntervalMs || 500 354 | const questions =groupSubtitleByCount(job.subtitle,parseFloat(setting.stopCount)).map(x => { 355 | return getPrompt(x,parseInt(setting.words),parseFloat(setting.baseTime),parseFloat(setting.step),parseInt(setting.maxCount), parseInt(setting.minCount), parseInt(setting.count)) 356 | }) 357 | this.ChatgptPort.postMessage({ 358 | type: 'getSummary', 359 | questions, 360 | videoId: job.videoId, 361 | force: job.force, 362 | timeout: parseInt(setting.timeout), 363 | refreshToken: job.refreshToken, 364 | stopIntervalMs 365 | }) 366 | } else { 367 | this.ChatgptPort.postMessage(job) 368 | } 369 | } else { 370 | port.postMessage({ 371 | type: 'error', 372 | content: 'unauthorized' 373 | }) 374 | } 375 | } 376 | } 377 | bilibiliSummaryPortOnDisconnect(port: Browser.Runtime.Port) { 378 | port.onDisconnect.addListener(() => { 379 | this.BilibiliSummaryPort = null 380 | }) 381 | } 382 | sendToBilibiliSummaryPort(port: Browser.Runtime.Port) { 383 | this.ChatgptPort = port; 384 | port.postMessage({ 385 | type: 'connected' 386 | }) 387 | port.onMessage.addListener(async (job, port) => { 388 | if(this.BilibiliSummaryPort) { 389 | if(job.type === 'summaryFinalCache') { 390 | await summaryCache.setSummary(job.content.videoId,job.content.message) 391 | this.BilibiliSummaryPort.postMessage(job) 392 | } else { 393 | this.BilibiliSummaryPort.postMessage(job) 394 | } 395 | } 396 | }) 397 | port.onDisconnect.addListener(() => { 398 | this.ChatgptPort = null 399 | }) 400 | } 401 | } 402 | 403 | const connector = new ChatgptConnector() 404 | 405 | Browser.runtime.onConnect.addListener((port) => { 406 | console.debug('connected', port) 407 | if (port.name === 'BILIBILISUMMARY') { 408 | connector.BilibiliSummaryPort = port; 409 | port.onMessage.addListener(async (job, port) => { 410 | if(job.type === 'CommonService') { 411 | const method = job.content.method as keyof typeof commonService 412 | try { 413 | // @ts-ignore 414 | commonService[method]() 415 | } catch (error) { 416 | console.error(error) 417 | } 418 | return 0; 419 | } 420 | let currentType = await store.getType() 421 | if(currentType === store.openai) { 422 | let setting = await store.getOpenaiSetting() 423 | let openaiProvider = new OpenaiProvider(port,setting.apiKey,setting.model,setting.maxTokens) 424 | openaiProvider.handleJob(job) 425 | } else { 426 | connector.sendToChatgptPort(job,port) 427 | } 428 | }) 429 | connector.bilibiliSummaryPortOnDisconnect(port) 430 | } else if (port.name === 'CHATGPT') { 431 | connector.sendToBilibiliSummaryPort(port) 432 | } 433 | }) -------------------------------------------------------------------------------- /src/background/prompt.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | export function getSummaryPrompt(title: string,transcript: string, times: string[]) { 4 | const description = "请根据对应的字幕进行详细总结。"; 5 | const example = "字幕:" + "\n" + "485.5: <时间点485.5的字幕>" + "\n" + "总结列表:" + "\n" + "485.5: <时间点485.5的总结>" + "\n" + "字幕:" + "\n" + "19.78: <时间点19的字幕>" + "\n" + "65.89: <时间点65的字幕>" + "\n" + "总结列表:" + "\n" + "19.78: <时间点19的总结>" + "\n" + "65.89: <时间点65的总结>" + "\n"; 6 | const question = "字幕:"+ "\n" + transcript + "\n" + `总结列表(总结列表只能包括${times.join(',')}的信息):` + "\n" 7 | 8 | return description + example + question; 9 | 10 | } 11 | 12 | export function limitTranscriptByteLength(str: string, byteLimit: number) { 13 | const utf8str = unescape(encodeURIComponent(str)); 14 | const byteLength = utf8str.length; 15 | if (byteLength > byteLimit) { 16 | const ratio = byteLimit / byteLength; 17 | const newStr = str.substring(0, Math.floor(str.length * ratio)); 18 | return newStr; 19 | } 20 | return str; 21 | } 22 | function filterHalfRandomly(arr: T[]): T[] { 23 | const filteredArr: T[] = []; 24 | const halfLength = Math.floor(arr.length / 2); 25 | const indicesToFilter = new Set(); 26 | 27 | // 随机生成要过滤掉的元素的下标 28 | while (indicesToFilter.size < halfLength) { 29 | const index = Math.floor(Math.random() * arr.length); 30 | if (!indicesToFilter.has(index)) { 31 | indicesToFilter.add(index); 32 | } 33 | } 34 | 35 | // 过滤掉要过滤的元素 36 | for (let i = 0; i < arr.length; i++) { 37 | if (!indicesToFilter.has(i)) { 38 | filteredArr.push(arr[i]); 39 | } 40 | } 41 | 42 | return filteredArr; 43 | } 44 | 45 | // filterOddItem in the array 46 | function filterOddItem(arr: T[]): T[] { 47 | const filteredArr: T[] = []; 48 | for (let i = 0; i < arr.length; i++) { 49 | if (i % 2 === 0) { 50 | filteredArr.push(arr[i]); 51 | } 52 | } 53 | return filteredArr; 54 | } 55 | function getByteLength(text: string) { 56 | return unescape(encodeURIComponent(text)).length; 57 | } 58 | 59 | function itemInIt(textData: SubtitleItem[], text: string): boolean { 60 | return textData.find(t => t.text === text) !== undefined; 61 | } 62 | 63 | type SubtitleItem = { 64 | text: string; 65 | index: number; 66 | } 67 | export function getSmallSizeTranscripts(newTextData: SubtitleItem[], oldTextData: SubtitleItem[], byteLimit: number): string { 68 | const text = newTextData.sort((a, b) => a.index - b.index).map(t => t.text).join(" "); 69 | const byteLength = getByteLength(text); 70 | if(newTextData.length === 1 && byteLength > byteLimit) { 71 | const s = newTextData[0].text.split(' ').map((x, index) => ({ text: x, index: index })) 72 | if(s.length >= 2) { 73 | const filtedData = filterOddItem(s); 74 | return getSmallSizeTranscripts(filtedData, s, byteLimit); 75 | } else { 76 | return newTextData[0].text; 77 | } 78 | 79 | } 80 | if (byteLength > byteLimit ) { 81 | const filtedData = filterOddItem(newTextData); 82 | return getSmallSizeTranscripts(filtedData, oldTextData, byteLimit); 83 | } 84 | 85 | let resultData = newTextData.slice(); 86 | let resultText = text; 87 | let lastByteLength = byteLength; 88 | 89 | for (let i = 0; i < oldTextData.length; i++) { 90 | const obj = oldTextData[i]; 91 | if (itemInIt(newTextData, obj.text)) { 92 | continue; 93 | } 94 | 95 | const nextTextByteLength = getByteLength(obj.text); 96 | const isOverLimit = lastByteLength + nextTextByteLength > byteLimit; 97 | if (isOverLimit) { 98 | const overRate = (lastByteLength + nextTextByteLength - byteLimit) / nextTextByteLength; 99 | const chunkedText = obj.text.substring(0, Math.floor(obj.text.length * overRate)); 100 | resultData.push({ text: chunkedText, index: obj.index }); 101 | break; 102 | } else { 103 | resultData.push(obj); 104 | } 105 | resultText = resultData.sort((a, b) => a.index - b.index).map(t => t.text).join(" "); 106 | lastByteLength = getByteLength(resultText); 107 | } 108 | 109 | return resultText; 110 | } -------------------------------------------------------------------------------- /src/chat/main.ts: -------------------------------------------------------------------------------- 1 | import Browser from 'webextension-polyfill' 2 | import { ChatGptWebProvider } from "../ai" 3 | import Notify from 'simple-notify' 4 | import 'simple-notify/dist/simple-notify.min.css' 5 | const chatGptWebProvider = new ChatGptWebProvider() 6 | 7 | 8 | 9 | type ChatgptJob = { 10 | type: 'getSummary' | 'cancel' 11 | videoId: string 12 | questions: string[] 13 | refreshToken?: boolean 14 | force?: boolean 15 | timeout: number 16 | stopIntervalMs: number 17 | } 18 | function sleep(ms: number) { 19 | return new Promise(resolve => setTimeout(resolve, ms)); 20 | } 21 | 22 | export const port = Browser.runtime.connect({ name: 'CHATGPT' }) 23 | class ChatgptProvider { 24 | private lastController: AbortController | null = null; 25 | 26 | private cancel() { 27 | if (this.lastController) { 28 | this.lastController.abort(); 29 | this.lastController = null; 30 | } 31 | port.postMessage({ 32 | type: 'error', 33 | content: 'cancel' 34 | }); 35 | } 36 | 37 | private timeout(ms: number) { 38 | return setTimeout(() => { 39 | this.cancel(); 40 | port.postMessage({ 41 | type: 'error', 42 | content: 'timeout' 43 | }); 44 | }, ms); 45 | } 46 | 47 | public async handleJob(job: ChatgptJob) { 48 | console.log('chatgpt job', job) 49 | if (job.type === 'getSummary') { 50 | if (this.lastController && job.force === false) { 51 | port.postMessage({ 52 | type: 'error', 53 | content: 'onlyOne' 54 | }); 55 | return 0; 56 | } 57 | 58 | try { 59 | if (this.lastController) { 60 | this.lastController.abort(); 61 | } 62 | this.lastController = new AbortController(); 63 | const timeoutThreshold = job.timeout * 60 * 1000; 64 | const timeoutHandle = this.timeout(timeoutThreshold); 65 | let resultSum = '' 66 | for await (const question of job.questions) { 67 | if(this.lastController.signal.aborted) { 68 | break; 69 | } 70 | try { 71 | let result = await chatGptWebProvider.ask(question, { 72 | deleteConversation: true, 73 | signal: this.lastController!.signal, 74 | refreshToken: job.refreshToken, 75 | onMessage: (m) => { 76 | clearTimeout(timeoutHandle); 77 | if(!this.lastController!.signal.aborted) { 78 | port.postMessage({ 79 | type: 'summary', 80 | content: { 81 | message: m.message, 82 | videoId: job.videoId 83 | } 84 | }); 85 | } 86 | 87 | } 88 | }); 89 | resultSum += result 90 | port.postMessage({ 91 | type: 'summaryFinal', 92 | content: { 93 | message: result, 94 | videoId: job.videoId 95 | } 96 | }); 97 | try { 98 | clearTimeout(timeoutHandle); 99 | } catch (error) { 100 | console.error(error); 101 | } 102 | await sleep(job.stopIntervalMs || 500) 103 | } catch (error) { 104 | console.error(error); 105 | port.postMessage({ 106 | type: 'error', 107 | content: error.message 108 | }); 109 | } 110 | port.postMessage({ 111 | type: 'summaryFinalCache', 112 | content: { 113 | message: resultSum, 114 | videoId: job.videoId 115 | } 116 | }); 117 | 118 | } 119 | } catch (error: any) { 120 | console.error(error); 121 | port.postMessage({ 122 | type: 'error', 123 | content: error.message 124 | }); 125 | } finally { 126 | this.lastController = null; 127 | } 128 | } else if (job.type === 'cancel') { 129 | this.cancel(); 130 | } else if (job.type === 'connected') { 131 | new Notify({ 132 | status: 'success', 133 | title: '连接成功', 134 | text: '请不要关闭这个页面,否则Summary for Bilibili将无法使用', 135 | effect: 'fade', 136 | speed: 300, 137 | showIcon: true, 138 | showCloseButton: true, 139 | autoclose: true, 140 | autotimeout: 5000, 141 | gap: 20, 142 | distance: 20, 143 | type: 1, 144 | position: 'right top' 145 | }) 146 | } 147 | } 148 | } 149 | 150 | const chatgptProvider = new ChatgptProvider(); 151 | port.onMessage.addListener(async (job, port) => { 152 | chatgptProvider.handleJob(job) 153 | }) 154 | 155 | // a Notification when web page is loaded 156 | 157 | 158 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | export const OpenaiSetting ={ 2 | apiKey: '', 3 | model: 'gpt-3.5-turbo', 4 | maxTokens: 200, 5 | } -------------------------------------------------------------------------------- /src/content-scripts/App.vue: -------------------------------------------------------------------------------- 1 | 117 | 134 | 135 | -------------------------------------------------------------------------------- /src/content-scripts/icon/ChatGPT.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/content-scripts/lang.ts: -------------------------------------------------------------------------------- 1 | export let titleMap = { 2 | Summary: '视频摘要', 3 | Subtitle: '字幕', 4 | Setting: '设置', 5 | Help: '帮助' 6 | } -------------------------------------------------------------------------------- /src/content-scripts/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import App from "./App.vue"; 3 | import { NavBar,Col, Row,Icon, Button, Switch,Field, Slider } from 'vant'; 4 | import 'vant/lib/index.css'; 5 | import { createPinia } from 'pinia' 6 | const app = createApp(App) 7 | const pinia = createPinia() 8 | app 9 | .use(Slider) 10 | .use(Field) 11 | .use(Switch) 12 | .use(Button) 13 | .use(pinia) 14 | .use(NavBar) 15 | .use(Col) 16 | .use(Row) 17 | .use(Icon); 18 | 19 | setTimeout(() => { 20 | const el = document.querySelector('#danmukuBox'); 21 | if (el) { 22 | el.insertAdjacentHTML( 23 | 'beforebegin', 24 | '
', 25 | ); 26 | app.mount('#summary-app-dkfjslkf'); 27 | } else { 28 | console.error('element not found'); 29 | } 30 | },800) -------------------------------------------------------------------------------- /src/content-scripts/pages/Help.vue: -------------------------------------------------------------------------------- 1 | 4 | 19 | 20 | -------------------------------------------------------------------------------- /src/content-scripts/pages/Setting.vue: -------------------------------------------------------------------------------- 1 | 21 | 45 | 46 | -------------------------------------------------------------------------------- /src/content-scripts/pages/Subtitle.vue: -------------------------------------------------------------------------------- 1 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /src/content-scripts/pages/Summary.vue: -------------------------------------------------------------------------------- 1 | 30 | 47 | 48 | -------------------------------------------------------------------------------- /src/content-scripts/pages/infoPage/Fetching.vue: -------------------------------------------------------------------------------- 1 | 5 | 11 | 12 | -------------------------------------------------------------------------------- /src/content-scripts/pages/infoPage/Info.vue: -------------------------------------------------------------------------------- 1 | 5 | 9 | 10 | -------------------------------------------------------------------------------- /src/content-scripts/state.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { Body } from '../types'; 3 | import MarkdownIt from "markdown-it"; 4 | const markdown = new MarkdownIt(); 5 | import { titleMap } from './lang' 6 | import { getBVid, getSubtitle, port } from './utils'; 7 | import Browser from 'webextension-polyfill'; 8 | import { ExtensionStorage } from '../store/index' 9 | export const storage = new ExtensionStorage() 10 | type SummaryState = 'reFetchable' | 'fetchable' | 'fetching' | 'unfetchable' | 'fetched' | 'tooManyRequests' | 'unauthorized' | 'notFound' | 'unknown' | 'cloudFlare' | 'onlyOne' | 'timeout' | 'canced' 11 | type State = { 12 | free: boolean, 13 | preMessage: string, 14 | videoId: string, 15 | subtitle: Body[], 16 | summary: string, 17 | summaryState: SummaryState, 18 | [storage.metaKey]: ReturnType 19 | } 20 | export const useStore = defineStore('store', { 21 | state: () : State => ({ 22 | free: true, 23 | preMessage: '', 24 | videoId: '', 25 | subtitle: [], 26 | summary: '', 27 | summaryState: 'fetchable', 28 | [storage.metaKey]: storage.getDefaultMetaKey(), 29 | }), 30 | getters: { 31 | markdownContent: (state) => { 32 | return markdown.render(state.summary); 33 | }, 34 | list: (state) => { 35 | return state.summary.split('\n').map((item) => { 36 | let [time, content] = item.split(':'); 37 | return { 38 | time, 39 | content 40 | } 41 | }).filter(x => x.time && x.content) 42 | }, 43 | isContent: (state) => { 44 | return state.summaryState === 'fetched' 45 | }, 46 | otherComponentState: (state) => { 47 | const stateMap = { 48 | 'unfetchable': { 49 | 'icon': 'warning-o', 50 | 'tips': '抱歉,缺少字幕,无法解析当前视频', 51 | 'action': 'getSummary', 52 | 'class': '' 53 | }, 54 | 'fetchable': { 55 | 'icon': 'guide-o', 56 | 'tips': `点击图标获取${titleMap['Summary']}`, 57 | 'action':'getSummary', 58 | 'class': '' 59 | }, 60 | 'reFetchable': { 61 | 'icon': 'guide-o', 62 | 'tips': `再次点击图标获取${titleMap['Summary']}`, 63 | 'action': 'forceSummaryWithNewToken', 64 | 'class': '' 65 | }, 66 | 'fetched': { 67 | 'icon': 'comment-o', 68 | 'tips': `获取${titleMap['Summary']}成功`, 69 | 'action': 'getSummary', 70 | 'class': '' 71 | }, 72 | 'cloudFlare': { 73 | 'icon': 'link-o', 74 | 'tips': `需要进行人机验证,请点击图标`, 75 | 'action': 'login', 76 | 'class': '' 77 | }, 78 | "onlyOne": { 79 | 'icon': 'warning-o', 80 | 'tips': `一次只能获取一个${titleMap['Summary']},再次点击强制中断之前的任务`, 81 | 'action': 'forceSummary', 82 | 'class': '' 83 | }, 84 | "tooManyRequests": { 85 | 'icon': 'replay', 86 | 'tips': `请求过多,请稍后再图标重试`, 87 | 'action': 'getSummary', 88 | 'class': '' 89 | }, 90 | 'unauthorized': { 91 | 'icon': 'link-o', 92 | 'tips': `需要重新登录chatgpt才能获取${titleMap['Summary']},点击图标跳转登录`, 93 | 'action': 'login', 94 | 'class': '' 95 | }, 96 | 'notFound': { 97 | 'icon': 'replay', 98 | 'tips': `404,点击图标重试`, 99 | 'action':'forceSummary', 100 | 'class': '' 101 | }, 102 | 'unknown': { 103 | 'icon': 'replay', 104 | 'tips': `chatgpt未知错误,点击图标重试`, 105 | 'action':'forceSummary', 106 | 'class': '' 107 | }, 108 | 'timeout': { 109 | 'icon': 'replay', 110 | 'tips': `获取超时,点击图标重试获取${titleMap['Summary']}`, 111 | 'action': 'forceSummary', 112 | 'class': '' 113 | }, 114 | 'cancel': { 115 | 'icon': 'replay', 116 | 'tips': `取消成功,点击图标重试获取${titleMap['Summary']}`, 117 | 'action': 'forceSummary', 118 | 'class': '' 119 | }, 120 | "rateLimit": { 121 | 'icon': 'warning-o', 122 | 'tips': `超出使用额度,请换号,或者过一段时间(1小时?)再试`, 123 | 'action':'e', 124 | 'class': '' 125 | }, 126 | 'emptyApiKey': { 127 | 'icon': 'replay', 128 | 'tips': `请先设置API Key,然后再点击重试`, 129 | 'action':'forceSummary', 130 | 'class': '' 131 | } 132 | } 133 | return stateMap[state.summaryState as keyof typeof stateMap] 134 | } 135 | }, 136 | actions: { 137 | async loadMeta() { 138 | let meta = await storage.getMetaKey(); 139 | this[storage.metaKey] = Object.assign(this[storage.metaKey], meta) 140 | }, 141 | async getClickSubtitle() { 142 | //@ts-ignore 143 | return this[storage.metaKey].clickSubtitle 144 | }, 145 | async getGpt3Summary(){ 146 | const videoId = getBVid(window.location.href) 147 | this.videoId = videoId 148 | const subtitle = await getSubtitle(videoId) 149 | if(!subtitle) { 150 | this.summaryState = 'unfetchable' 151 | return 0; 152 | } 153 | try { 154 | port.postMessage({ 155 | type: 'gtp3Summary', 156 | videoId, 157 | subtitle, 158 | title: document.title, 159 | }) 160 | this.summaryState = 'fetching' 161 | } catch (error) { 162 | console.error(error) 163 | this.summaryState = 'unfetchable' 164 | } 165 | }, 166 | async getSummary(){ 167 | await this.summaryWithType('getSummary') 168 | }, 169 | async summaryWithType(type:string) { 170 | this.free = false; 171 | this.preMessage = '' 172 | const videoId = getBVid(window.location.href) 173 | this.videoId = videoId 174 | const subtitle = await this.getSubtitle(videoId) 175 | if(!subtitle || subtitle.length === 0) { 176 | this.summaryState = 'unfetchable' 177 | return 0; 178 | } 179 | try { 180 | port.postMessage({ 181 | type: 'getSummary', 182 | videoId, 183 | subtitle, 184 | title: document.title, 185 | refreshToken: type === 'forceSummaryWithNewToken' ? true : false, 186 | force: (type === 'forceSummary') || (type === 'forceSummaryWithNewToken') ? true : false, 187 | }) 188 | this.summaryState = 'fetching' 189 | } catch (error) { 190 | console.error(error) 191 | this.summaryState = 'unfetchable' 192 | } 193 | }, 194 | async forceSummary (){ 195 | await this.summaryWithType('forceSummary') 196 | }, 197 | async forceSummaryWithNewToken() { 198 | await this.summaryWithType('forceSummaryWithNewToken') 199 | }, 200 | async login(){ 201 | port.postMessage({ 202 | type: 'login', 203 | }) 204 | }, 205 | cancel() { 206 | this.preMessage = '' 207 | port.postMessage({ 208 | type: 'cancel' 209 | }) 210 | }, 211 | async getSubtitle(videoId:string){ 212 | try { 213 | let result = await (await fetch(`https://api.bilibili.com/x/web-interface/view?bvid=${videoId}`,{ 214 | credentials: 'include' 215 | })).json() 216 | if(result.data.subtitle.list.length > 0) { 217 | let url = result.data.subtitle.list[0].subtitle_url.replace(/^http:/, 'https:') 218 | let subtitle = await (await fetch(url)).json() 219 | this.subtitle = subtitle; 220 | return subtitle.body 221 | } else { 222 | return this.subtitle 223 | } 224 | } catch (error) { 225 | console.error(error) 226 | return null 227 | } 228 | }, 229 | async clearCurrentCache() { 230 | const videoId = getBVid(window.location.href) 231 | this.videoId = videoId 232 | await Browser.storage.local.remove(videoId) 233 | }, 234 | e() { 235 | console.log('e') 236 | } 237 | } 238 | }) 239 | 240 | 241 | 242 | 243 | -------------------------------------------------------------------------------- /src/content-scripts/utils.ts: -------------------------------------------------------------------------------- 1 | import Browser from 'webextension-polyfill' 2 | // url change listener 3 | export const urlChange = (callback: (url: string) => void) => { 4 | let lastUrl = window.location.href 5 | const observer = new MutationObserver((mutations) => { 6 | const url = window.location.href 7 | if (url !== lastUrl) { 8 | lastUrl = url 9 | callback(url) 10 | } 11 | }) 12 | observer.observe(document.body, { 13 | childList: true, 14 | subtree: true, 15 | }) 16 | } 17 | export const getBVid = (url:string) => { 18 | const pattern = /\/(BV\w+)\//; 19 | const result = pattern.exec(url) as RegExpExecArray; 20 | const videoId = result[1]; 21 | return videoId; 22 | } 23 | 24 | 25 | export const getSubtitle = async (videoId:string) => { 26 | try { 27 | let result = await (await fetch(`https://api.bilibili.com/x/web-interface/view?bvid=${videoId}`)).json() 28 | if(result.data.subtitle.list.length > 0) { 29 | let url = result.data.subtitle.list[0].subtitle_url.replace(/^http:/, 'https:') 30 | let subtitle = await (await fetch(url)).json() 31 | return subtitle.body 32 | } else { 33 | return null 34 | } 35 | } catch (error) { 36 | console.error(error) 37 | return null 38 | } 39 | } 40 | export const port = Browser.runtime.connect({ name: 'BILIBILISUMMARY' }) -------------------------------------------------------------------------------- /src/inject/injecter.ts: -------------------------------------------------------------------------------- 1 | //@ts-ignore 2 | import mainWorld from './main-world?script&module' 3 | import Browser from 'webextension-polyfill' 4 | const script = document.createElement('script') 5 | script.src = Browser.runtime.getURL(mainWorld) 6 | script.type = 'module' 7 | document.head.prepend(script) -------------------------------------------------------------------------------- /src/inject/main-world.ts: -------------------------------------------------------------------------------- 1 | import {proxy} from "ajax-hook"; 2 | function absoluteUrl(relativeUrl: string): string { 3 | // @ts-ignore 4 | return relativeUrl.startsWith("//") ? "https:" + relativeUrl : relativeUrl 5 | } 6 | 7 | window.addEventListener('message', function(event) { 8 | if (event.data.type === 'goto') { 9 | //@ts-ignore 10 | window.player.seek(parseInt(event.data.content)) 11 | } 12 | }); 13 | export const getBVid = (url:string) => { 14 | const pattern = /\/(BV\w+)\//; 15 | const result = pattern.exec(url) as RegExpExecArray; 16 | const videoId = result[1]; 17 | return videoId; 18 | } 19 | 20 | 21 | export const getSubtitle = async (videoId:string) => { 22 | try { 23 | let result = await (await fetch(`https://api.bilibili.com/x/web-interface/view?bvid=${videoId}`,{ 24 | credentials: 'include', 25 | })).json() 26 | if(result.data.subtitle.list.length > 0) { 27 | let url = result.data.subtitle.list[0].subtitle_url.replace(/^http:/, 'https:') 28 | let subtitle = await (await fetch(url)).json() 29 | return subtitle.body 30 | } else { 31 | return null 32 | } 33 | } catch (error) { 34 | console.error(error) 35 | return null 36 | } 37 | } 38 | let id = getBVid(window.location.href) 39 | getSubtitle(id).then(x => { 40 | if (x) { 41 | window.postMessage({ type: 'getSummary', content: JSON.stringify({ 42 | body: x, 43 | })},'*' /* targetOrigin: any */ ); 44 | } 45 | }) 46 | 47 | proxy({ 48 | onResponse: (response, handler) => { 49 | const url = new URL(absoluteUrl(response.config.url)); 50 | if (/bfs\/ai_subtitle\/prod.*/.test(url.pathname)) { 51 | const subtitleList= response.response 52 | window.postMessage({ type: 'getSummary', content: JSON.stringify(subtitleList)},'*' /* targetOrigin: any */ ); 53 | } 54 | handler.next(response); 55 | }, 56 | }); -------------------------------------------------------------------------------- /src/option/App.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 36 | 37 | -------------------------------------------------------------------------------- /src/option/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | import ElementPlus from 'element-plus' 4 | import 'element-plus/dist/index.css' 5 | import { createRouter, createWebHashHistory } from 'vue-router' 6 | 7 | 8 | import Setting from './pages/Setting.vue' 9 | import About from './pages/About.vue' 10 | import Summary from './pages/Summary.vue' 11 | import Subtitle from './pages/Subtitle.vue' 12 | import Help from './pages/Help.vue'; 13 | import ChatgptWeb from './pages/provider/ChatgptWeb.vue'; 14 | import Openai from './pages/provider/Openai.vue'; 15 | import { createPinia } from 'pinia' 16 | const pinia = createPinia() 17 | const routes = [ 18 | { path: '/', component: Setting, 19 | children: [ 20 | { path: 'ChatgptWeb', component: ChatgptWeb }, 21 | { path: 'Openai', component: Openai }, 22 | ] 23 | }, 24 | { path: '/summary', component: Summary }, 25 | { path: '/subtitle', component: Subtitle }, 26 | { path: '/help', component: Help }, 27 | { path: '/about', component: About }, 28 | ] 29 | 30 | const router = createRouter({ 31 | history: createWebHashHistory(), 32 | routes, 33 | }) 34 | 35 | createApp(App) 36 | .use(pinia) 37 | .use(router) 38 | .use(ElementPlus) 39 | .mount('#app') -------------------------------------------------------------------------------- /src/option/pages/about.vue: -------------------------------------------------------------------------------- 1 | 4 | 26 | 27 | -------------------------------------------------------------------------------- /src/option/pages/help.vue: -------------------------------------------------------------------------------- 1 | 4 | 20 | 21 | -------------------------------------------------------------------------------- /src/option/pages/provider/ChatgptWeb.vue: -------------------------------------------------------------------------------- 1 | 5 | 45 | 46 | -------------------------------------------------------------------------------- /src/option/pages/provider/Openai.vue: -------------------------------------------------------------------------------- 1 | 15 | 67 | 68 | -------------------------------------------------------------------------------- /src/option/pages/setting.vue: -------------------------------------------------------------------------------- 1 | 26 | 46 | 47 | -------------------------------------------------------------------------------- /src/option/pages/subtitle.vue: -------------------------------------------------------------------------------- 1 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /src/option/pages/summary.vue: -------------------------------------------------------------------------------- 1 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /src/option/state.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { ExtensionStorage } from '../store/index' 3 | type ProviderType = 'ChatgptWeb' | 'Openai' 4 | 5 | export const storage = new ExtensionStorage() 6 | export const useStore = defineStore('store', { 7 | state: () => { 8 | return { 9 | [storage.metaKey]: storage.getDefaultMetaKey(), 10 | } 11 | }, 12 | getters: { 13 | }, 14 | actions: { 15 | async loadSettings() { 16 | let meta = await storage.getMetaKey(); 17 | // a function take this[storage.metaKey] as default value for object meta 18 | 19 | 20 | this[storage.metaKey] = Object.assign(this[storage.metaKey],meta) 21 | }, 22 | async changeClickSubtitle(value: boolean) { 23 | this[storage.metaKey].clickSubtitle = value 24 | }, 25 | async changeOpenaiApikey(value: string) { 26 | this[storage.metaKey].ChatgptWebSetting.apiKey = value 27 | }, 28 | async changeOpenaiModel(value: string) { 29 | this[storage.metaKey].ChatgptWebSetting.model = value as ProviderType 30 | }, 31 | async changeOpenaiMaxTokens(value: number) { 32 | this[storage.metaKey].ChatgptWebSetting.maxTokens = value 33 | }, 34 | async changeProviderType(value: string) { 35 | this[storage.metaKey].providerType = value as ProviderType 36 | }, 37 | async changeAutoFetch(value:boolean) { 38 | this[storage.metaKey].autoFetch = value 39 | } 40 | } 41 | }) 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/prompt.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Kazuki Nakayashiki. 2 | // Modified work: Copyright (c) 2023 Qixiang Zhu. 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | 13 | export function getSummaryPrompt(title: string, transcript: string, byteLimit: number) { 14 | const truncatedTranscript = limitTranscriptByteLength(transcript, byteLimit); 15 | return `标题: "${title.replace(/\n+/g, " ").trim()}"\n字幕: "${truncatedTranscript.replace(/\n+/g, " ").trim()}"\n中文总结:`; 16 | } 17 | 18 | export function limitTranscriptByteLength(str: string, byteLimit: number) { 19 | const utf8str = unescape(encodeURIComponent(str)); 20 | const byteLength = utf8str.length; 21 | if (byteLength > byteLimit) { 22 | const ratio = byteLimit / byteLength; 23 | const newStr = str.substring(0, Math.floor(str.length * ratio)); 24 | return newStr; 25 | } 26 | return str; 27 | } 28 | function filterHalfRandomly(arr: T[]): T[] { 29 | const filteredArr: T[] = []; 30 | const halfLength = Math.floor(arr.length / 2); 31 | const indicesToFilter = new Set(); 32 | 33 | // 随机生成要过滤掉的元素的下标 34 | while (indicesToFilter.size < halfLength) { 35 | const index = Math.floor(Math.random() * arr.length); 36 | if (!indicesToFilter.has(index)) { 37 | indicesToFilter.add(index); 38 | } 39 | } 40 | 41 | // 过滤掉要过滤的元素 42 | for (let i = 0; i < arr.length; i++) { 43 | if (!indicesToFilter.has(i)) { 44 | filteredArr.push(arr[i]); 45 | } 46 | } 47 | 48 | return filteredArr; 49 | } 50 | function getByteLength(text: string) { 51 | return unescape(encodeURIComponent(text)).length; 52 | } 53 | 54 | function itemInIt(textData: SubtitleItem[], text: string): boolean { 55 | return textData.find(t => t.text === text) !== undefined; 56 | } 57 | 58 | type SubtitleItem = { 59 | text: string; 60 | index: number; 61 | } 62 | export function getSmallSizeTranscripts(newTextData: SubtitleItem[], oldTextData: SubtitleItem[], byteLimit: number): string { 63 | const text = newTextData.sort((a, b) => a.index - b.index).map(t => t.text).join(" "); 64 | const byteLength = getByteLength(text); 65 | 66 | if (byteLength > byteLimit) { 67 | const filtedData = filterHalfRandomly(newTextData); 68 | return getSmallSizeTranscripts(filtedData, oldTextData, byteLimit); 69 | } 70 | 71 | let resultData = newTextData.slice(); 72 | let resultText = text; 73 | let lastByteLength = byteLength; 74 | 75 | for (let i = 0; i < oldTextData.length; i++) { 76 | const obj = oldTextData[i]; 77 | if (itemInIt(newTextData, obj.text)) { 78 | continue; 79 | } 80 | 81 | const nextTextByteLength = getByteLength(obj.text); 82 | const isOverLimit = lastByteLength + nextTextByteLength > byteLimit; 83 | if (isOverLimit) { 84 | const overRate = (lastByteLength + nextTextByteLength - byteLimit) / nextTextByteLength; 85 | const chunkedText = obj.text.substring(0, Math.floor(obj.text.length * overRate)); 86 | resultData.push({ text: chunkedText, index: obj.index }); 87 | } else { 88 | resultData.push(obj); 89 | } 90 | resultText = resultData.sort((a, b) => a.index - b.index).map(t => t.text).join(" "); 91 | lastByteLength = getByteLength(resultText); 92 | } 93 | 94 | return resultText; 95 | } 96 | -------------------------------------------------------------------------------- /src/rank/index.ts: -------------------------------------------------------------------------------- 1 | 2 | const cardContainer = document.querySelector('#app > div > div.rank-container > div.rank-list-wrap > ul'); 3 | if(cardContainer) { 4 | let jobs = []; 5 | cardContainer.childNodes.forEach((node) => { 6 | // get the url of a element in the children of node 7 | const url = node.querySelector('a').href; 8 | const newDiv = document.createElement('div'); 9 | newDiv.style.height = '100px'; 10 | newDiv.style.width = '100%'; 11 | newDiv.style.pointerEvents = 'all'; 12 | newDiv.style.overflow = 'hidden'; 13 | newDiv.innerHTML = '总结加载中...' 14 | node.appendChild(newDiv); 15 | jobs.push({ 16 | url: url, 17 | div: newDiv 18 | }) 19 | }); 20 | } 21 | 22 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import Browser from 'webextension-polyfill' 2 | type Keys = 'meta' | 'subTitle' | 'summary' 3 | export class ExtensionStorage { 4 | metaKey = 'meta' 5 | subTitle = 'subTitle' 6 | summary = 'summary' 7 | getDefaultMetaKey() { 8 | return { 9 | clickSubtitle: false, 10 | autoFetch: false, 11 | summaryToken: 20, 12 | providerType: 'ChatgptWeb', 13 | shareCache: true, 14 | OpenaiSetting: { 15 | apiKey: '', 16 | model: 'gpt-3.5-turbo', 17 | maxTokens: 450, 18 | words:1000, 19 | baseTime: 5, 20 | step: 0.1, 21 | maxCount:8, 22 | minCount: 5, 23 | count: 30, 24 | timeout: 8, 25 | stopCount: 4000, 26 | stopIntervalMs: 150 27 | }, 28 | ChatgptWebSetting: { 29 | words:1000, 30 | baseTime: 5, 31 | step: 0.1, 32 | maxCount:8, 33 | minCount: 5, 34 | count: 30, 35 | timeout: 8, 36 | stopCount: 4000, 37 | stopIntervalMs: 200 38 | } 39 | } 40 | } 41 | async getMetaKey() { 42 | return await this.get(this.metaKey as Keys) 43 | } 44 | setMetaKey(value: any) { 45 | return this.set(this.metaKey as Keys,value) 46 | } 47 | onMetaKeyChange(handle: (arg0: any) => void) { 48 | this.onChange(this.metaKey as Keys,handle) 49 | } 50 | async get(key: Keys, defaultValue = {}) { 51 | let result = await Browser.storage.local.get([key]) 52 | if(result[key]) { 53 | return result[key] 54 | } else { 55 | return defaultValue 56 | } 57 | } 58 | async set(key:Keys,value: any) { 59 | await Browser.storage.local.set({ 60 | [key]: value 61 | }) 62 | } 63 | onChange(key:Keys,handle: (arg0: any) => void) { 64 | Browser.storage.onChanged.addListener((changes, areaName) => { 65 | if (areaName === 'local' && changes[key]) { 66 | handle(changes[key]) 67 | } 68 | }) 69 | } 70 | } -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface SubTitle { 2 | font_size: number 3 | font_color: string 4 | background_alpha: number 5 | background_color: string 6 | Stroke: string 7 | type: string 8 | lang: string 9 | version: string 10 | body: Body[] 11 | } 12 | export interface Body { 13 | from: number 14 | to: number 15 | sid: number 16 | location: number 17 | content: string 18 | music: number 19 | } 20 | 21 | export type Job = { 22 | type: 'getSummary' | 'getGpt3Summary' | 'forceSummaryWithNewToken' | 'cancel' 23 | videoId: string 24 | subtitle: SubTitle 25 | title: string 26 | summaryTokenNumber?: number 27 | refreshToken?: boolean 28 | force?: boolean 29 | timeout?: number 30 | } 31 | 32 | export type PortName = 'CHATGPT' | 'BILIBILISUMMARY' -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "moduleResolution": "Node", 7 | "strict": true, 8 | "jsx": "preserve", 9 | "resolveJsonModule": true, 10 | "isolatedModules": false, 11 | "esModuleInterop": true, 12 | "lib": ["ESNext", "DOM"], 13 | "skipLibCheck": true, 14 | "noEmit": true 15 | }, 16 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "src/inject_start.tst.ts"], 17 | "references": [{ "path": "./tsconfig.node.json" }] 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | import { crx } from '@crxjs/vite-plugin' 4 | import { resolve } from 'path' 5 | import manifest from './manifest.json' // Node 14 & 16 6 | import svgLoader from 'vite-svg-loader' 7 | // https://vitejs.dev/config/ 8 | export default defineConfig({ 9 | build: { 10 | rollupOptions: { 11 | // add any html pages here 12 | input: { 13 | // output file at '/index.html' 14 | main: resolve(__dirname, 'index.html'), 15 | // output file at '/option.html' 16 | option: resolve(__dirname, 'option.html'), 17 | }, 18 | }, 19 | }, 20 | plugins: [ 21 | vue(), 22 | svgLoader(), 23 | crx({ manifest }), 24 | ], 25 | }) 26 | --------------------------------------------------------------------------------