├── .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 | 
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 |
5 |
6 | 测试中
7 |
8 |
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 |
118 |
119 |
120 |
121 |
122 |
Summary for Bilibili
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
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 |
5 |
6 |
有没有封号风险?
7 |
当前接入方式和成熟的产品接入方式相同大概率不会被封,不排除,介意用小号使用。
8 |
如何使用?
9 |
使用之前登录你的chatgpt账号方可使用。
10 |
提示进行人机验证?
11 |
可以在登录后有时候还需要进行人机验证,验证成功后即可使用,若还是提示,可以关闭vpn或者更换vpn地址。
12 |
生成质量功能是干什么?
13 |
摘要质量越高获取摘要的速度越慢。
14 |
清空缓存是干什么?
15 |
清空当前缓存:有时候你的摘要生成质量不高,可以通过清空当前缓存来重新获取摘要。
16 |
清空全部缓存:本功能将会清空全部摘要。
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/src/content-scripts/pages/Setting.vue:
--------------------------------------------------------------------------------
1 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | 清空
36 |
37 |
38 |
39 |
40 | 清空
41 |
42 |
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/src/content-scripts/pages/Subtitle.vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 | 内容
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/src/content-scripts/pages/Summary.vue:
--------------------------------------------------------------------------------
1 |
30 |
31 |
32 |
33 |
34 |
35 |
38 |
39 | {{ i.content }}
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/src/content-scripts/pages/infoPage/Fetching.vue:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 | 正在获取获取视频摘要
8 | 取消
10 |
11 |
12 |
--------------------------------------------------------------------------------
/src/content-scripts/pages/infoPage/Info.vue:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 | {{ summary.otherComponentState.tips }}
8 |
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 |
2 |
4 |
5 |
6 |
7 |
Summary for Bilibili
8 |
9 |
10 |
11 |
12 | 设置
13 |
15 | 帮助
16 | 关于
17 |
18 |
19 |
20 |
21 |
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 |
5 |
6 |
7 | 前端程序员,技术涉猎比较广泛,目前小公司开发Electron应用,详细可以看我GitHub。希望35之前能构建好被动收入。为此努力学习投资,开发自己的产品。
8 |
9 |
12 |
15 |
18 |
21 |
22 | 上面各种途径可以联系我,B站上也有视频,有测试群地址,可以加入测试群,有问题可以在群里提问。
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/src/option/pages/help.vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
有没有封号风险?
7 |
当前接入方式和成熟的产品接入方式相同大概率不会被封,不排除,介意用小号使用。自己玩脱不负责任
8 |
如何使用?
9 |
使用之前登录你的chatgpt账号方可使用。或者直接使用apikey就行了。个人建议直接使用chatgpt账号,已经够用了。
10 |
提示进行人机验证?
11 |
可以在登录后有时候还需要进行人机验证,验证成功后即可使用,若还是提示,可以关闭vpn或者更换vpn地址。
12 |
无法获取字幕?
13 |
开启强制触发字幕获取
14 |
出bug咋办?
15 |
关闭浏览器再试,不行进入测试群
16 |
其他问题?
17 |
查看关于Tab页进入测试群
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/src/option/pages/provider/ChatgptWeb.vue:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
调参指导:调节下列参数然后查看这里理解运行过程
8 |
{{ `假设当前字幕是16000个字, 那么你的请求将会分为${Math.ceil(16000/store.meta.ChatgptWebSetting.stopCount)}次发送:16000/${store.meta.ChatgptWebSetting.stopCount}次。`}}
9 |
{{ `每次时间间隔为${store.meta.ChatgptWebSetting.stopIntervalMs}毫秒。每次请求的${store.meta.ChatgptWebSetting.stopCount}汉字将会最多被压缩为${store.meta.ChatgptWebSetting.words}个汉字。`}}
10 |
{{`生成${store.meta.ChatgptWebSetting.minCount}到${store.meta.ChatgptWebSetting.maxCount}个要点。` }}
11 |
{{`合并基准时间${store.meta.ChatgptWebSetting.baseTime}分钟,步长${store.meta.ChatgptWebSetting.step}分钟。每次压缩到${store.meta.ChatgptWebSetting.count}次就放弃压缩。` }}
12 |
{{`超时时间${store.meta.ChatgptWebSetting.timeout}分钟,自动取消请求。` }}
13 |
一个小时内有限额,用超需要等一个小时,我不能排除封号危险,建议使用小号。
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/src/option/pages/provider/Openai.vue:
--------------------------------------------------------------------------------
1 |
15 |
16 |
17 |
调参指导:调节下列参数然后查看这里理解运行过程
18 |
{{ `假设当前字幕是16000个字, 那么你的请求将会分为${Math.ceil(16000/store.meta.OpenaiSetting.stopCount)}次发送:16000/${store.meta.OpenaiSetting.stopCount}次。`}}
19 |
{{ `每次时间间隔为${store.meta.OpenaiSetting.stopIntervalMs}毫秒。每次请求的${store.meta.OpenaiSetting.stopCount}汉字将会最多被压缩为${store.meta.OpenaiSetting.words}个汉字。`}}
20 |
{{`生成${store.meta.OpenaiSetting.minCount}到${store.meta.OpenaiSetting.maxCount}个要点。` }}
21 |
{{`合并基准时间${store.meta.OpenaiSetting.baseTime}分钟,步长${store.meta.OpenaiSetting.step}分钟。每次压缩到${store.meta.OpenaiSetting.count}次就放弃压缩。` }}
22 |
{{`超时时间${store.meta.OpenaiSetting.timeout}分钟,自动取消请求。` }}
23 |
{{`最大token数${store.meta.OpenaiSetting.maxTokens}代表返回答案长度。` }}
24 |
需要输入apikey,选择模型使用, 费用较高,谨慎使用 。
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
--------------------------------------------------------------------------------
/src/option/pages/setting.vue:
--------------------------------------------------------------------------------
1 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
--------------------------------------------------------------------------------
/src/option/pages/subtitle.vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 | 字幕
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/src/option/pages/summary.vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 | 总结
7 |
8 |
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 |
--------------------------------------------------------------------------------