Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ NOTE_OUTPUT_DIR=note_results
IMAGE_BASE_URL=/static/screenshots
DATA_DIR=data

# 自托管访问鉴权(默认关闭)
# 公网部署建议开启。开启后 Web 端会显示访问密码登录页,浏览器插件需要在设置页登录。
BILINOTE_AUTH_ENABLED=false
# BILINOTE_AUTH_PASSWORD=please-change-me
# 可选:固定 token 签名密钥;不填时会从访问密码派生,改密码会让旧登录失效。
# BILINOTE_AUTH_SECRET=
# 登录有效期(天)
BILINOTE_AUTH_TOKEN_EXPIRE_DAYS=30

# FFMPEG 配置(Docker 镜像已内置 ffmpeg,留空即可;自建/桌面端可填绝对路径)
FFMPEG_BIN_PATH=

Expand Down
5 changes: 4 additions & 1 deletion BillNote_extension/src/background/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,10 @@ async function startTask(url: string, title?: string): Promise<{ ok: boolean, ta
try {
const res = await fetch(`${backend}/api/generate_note`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...(settings.authToken ? { Authorization: `Bearer ${settings.authToken}` } : {}),
},
body: JSON.stringify({
video_url: url,
platform,
Expand Down
97 changes: 94 additions & 3 deletions BillNote_extension/src/components/MarkdownView.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,98 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import MarkdownIt from 'markdown-it'
import { absolutizeMarkdownImages, stripSourceLink } from '~/logic/api'

const props = defineProps<{ markdown: string, title?: string, hideActions?: boolean }>()

const contentRef = ref<HTMLElement | null>(null)

const md = new MarkdownIt({ html: false, linkify: true, breaks: true })

const html = computed(() => md.render(absolutizeMarkdownImages(stripSourceLink(props.markdown || ''))))
function safeDecode(value: string): string {
try {
return decodeURIComponent(value)
}
catch {
return value
}
}

function slugify(value: string): string {
const slug = safeDecode(value)
.trim()
.toLowerCase()
.replace(/[`*_~[\](){}<>]/g, '')
.replace(/[^\p{L}\p{N}\s-]/gu, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
return slug || 'section'
}

function normalizeAnchor(value: string): string {
return safeDecode(value)
.replace(/^#+/, '')
.replace(/content-\d{1,2}:\d{2}(?::\d{2})?/gi, '')
.replace(/\[[^\]]*\]/g, '')
.replace(/[`*_~()[\]{}<>]/g, '')
.replace(/[^\p{L}\p{N}]+/gu, '')
.toLowerCase()
}

md.renderer.rules.heading_open = (tokens, idx, options, env, self) => {
const text = tokens[idx + 1]?.content || ''
const base = slugify(text)
const headingCounts = (env.headingCounts ||= {}) as Record<string, number>
const count = headingCounts[base] || 0
headingCounts[base] = count + 1
tokens[idx].attrSet('id', count ? `${base}-${count}` : base)
return self.renderToken(tokens, idx, options)
}

const html = computed(() => {
const env: { headingCounts: Record<string, number> } = { headingCounts: {} }
return md.render(absolutizeMarkdownImages(stripSourceLink(props.markdown || '')), env)
})

function findAnchorTarget(href: string): HTMLElement | null {
const root = contentRef.value
if (!root)
return null

const raw = safeDecode(href.replace(/^#/, ''))
const byId = Array.from(root.querySelectorAll<HTMLElement>('[id]'))
let target = byId.find(el => el.id === raw || safeDecode(el.id) === raw || el.id === slugify(raw))
if (target)
return target

const search = normalizeAnchor(raw)
if (!search)
return null

const headings = Array.from(root.querySelectorAll<HTMLElement>('h1, h2, h3, h4, h5, h6'))
target = headings.find((heading) => {
const text = normalizeAnchor(heading.textContent || '')
return !!text && (text.includes(search) || search.includes(text))
})

return target || null
}

function handleContentClick(event: MouseEvent) {
const target = event.target
if (!(target instanceof Element))
return

const link = target.closest<HTMLAnchorElement>('a[href^="#"]')
if (!link || !contentRef.value?.contains(link))
return

event.preventDefault()
const heading = findAnchorTarget(link.getAttribute('href') || '')
if (heading)
heading.scrollIntoView({ behavior: 'smooth', block: 'start' })
}

async function copy() {
await navigator.clipboard.writeText(props.markdown)
Expand All @@ -30,13 +115,19 @@ function download() {
<button class="btn-secondary" @click="copy">复制 Markdown</button>
<button class="btn-secondary" @click="download">下载 .md</button>
</div>
<div class="prose prose-sm max-w-none px-3 py-2 flex-1 min-h-0 overflow-auto" v-html="html" />
<div
ref="contentRef"
class="prose prose-sm max-w-none px-3 py-2 flex-1 min-h-0 overflow-auto"
@click="handleContentClick"
v-html="html"
/>
</div>
</template>

<style>
.prose img { max-width: 100%; }
.prose h1, .prose h2, .prose h3 { font-weight: 600; margin-top: 0.8em; margin-bottom: 0.4em; }
.prose h1, .prose h2, .prose h3, .prose h4, .prose h5, .prose h6 { scroll-margin-top: 0.75rem; }
.prose p { margin-bottom: 0.5em; line-height: 1.55; }
.prose ul, .prose ol { padding-left: 1.4em; margin-bottom: 0.5em; }
.prose code { background: #eee; padding: 0 4px; border-radius: 3px; font-size: 0.9em; }
Expand Down
95 changes: 87 additions & 8 deletions BillNote_extension/src/logic/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import type {
Provider,
ProviderCreatePayload,
ProviderUpdatePayload,
ServerNote,
TaskStatusResponse,
TranscriberConfig,
TranscriberModelsStatus,
TranscriberType,
WhisperModelSize,
} from './types'
import { DEFAULT_BACKEND_URL } from './constants'
import { settings } from './storage'

interface ApiEnvelope<T> {
Expand All @@ -20,12 +22,27 @@ interface ApiEnvelope<T> {
}

function backendUrl(): string {
return (settings.value?.backendUrl || 'http://localhost:8483').replace(/\/$/, '')
const raw = (settings.value?.backendUrl || DEFAULT_BACKEND_URL).trim()
let url = raw || DEFAULT_BACKEND_URL

// Allow users to type shorthand values in the extension options:
// 3015 -> http://localhost:3015
// localhost:3015 -> http://localhost:3015
// http://host:3015/api -> http://host:3015
if (/^\d+$/.test(url))
url = `http://localhost:${url}`
else if (!/^https?:\/\//i.test(url))
url = `http://${url}`

url = url.replace(/\/+$/, '')
if (url.endsWith('/api'))
url = url.slice(0, -4)
return url
}

async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${backendUrl()}${path}`, {
headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) },
headers: { 'Content-Type': 'application/json', ...authHeaders(), ...(init?.headers || {}) },
...init,
})
if (!res.ok)
Expand All @@ -41,6 +58,42 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
return body as T
}

function authHeaders(): Record<string, string> {
const token = settings.value?.authToken
return token ? { Authorization: `Bearer ${token}` } : {}
}

function withAccessToken(url: string): string {
const token = settings.value?.authToken
if (!token)
return url
const sep = url.includes('?') ? '&' : '?'
return `${url}${sep}access_token=${encodeURIComponent(token)}`
}

export async function getAuthStatus(): Promise<{ enabled: boolean, authenticated: boolean }> {
return request('/api/auth/status')
}

export async function login(password: string): Promise<void> {
const data = await request<{ token: string }>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ password }),
})
settings.value.authToken = data.token || ''
}

export async function listNotes(): Promise<ServerNote[]> {
return request('/api/notes')
}

export async function deleteTask(taskId: string): Promise<void> {
await request('/api/delete_task', {
method: 'POST',
body: JSON.stringify({ task_id: taskId }),
})
}

export async function getProviders(): Promise<Provider[]> {
return request<Provider[]>('/api/get_all_providers')
}
Expand Down Expand Up @@ -189,7 +242,9 @@ export async function getTaskStatus(taskId: string): Promise<TaskStatusResponse>
// 成功:{code:0, data:{status, message, task_id, result?}}
// 任务失败:{code:500, msg:'xxx', data:null}
// 这里手动拆,把任务失败翻译成 status:'FAILED',避免 request() 抛错让 UI 收不到状态
const res = await fetch(`${backendUrl()}/api/task_status/${taskId}`)
const res = await fetch(`${backendUrl()}/api/task_status/${taskId}`, {
headers: authHeaders(),
})
if (!res.ok)
throw new Error(`HTTP ${res.status}`)
const body = (await res.json()) as { code: number, msg: string, data: TaskStatusResponse | null }
Expand All @@ -200,7 +255,7 @@ export async function getTaskStatus(taskId: string): Promise<TaskStatusResponse>

export async function ping(): Promise<boolean> {
try {
await getProviders()
await getAuthStatus()
return true
}
catch {
Expand All @@ -211,7 +266,10 @@ export async function ping(): Promise<boolean> {
// markdown 里的 /static/screenshots/xxx 是相对路径,extension 渲染时需要拼绝对地址
export function absolutizeMarkdownImages(md: string): string {
const base = backendUrl()
return md.replace(/!\[([^\]]*)\]\((\/static\/[^)]+)\)/g, (_, alt, path) => `![${alt}](${base}${path})`)
return md.replace(
/!\[([^\]]*)\]\((\/static\/[^)]+)\)/g,
(_, alt, path) => `![${alt}](${withAccessToken(`${base}${path}`)})`,
)
}

// backend 用 note_helper 在笔记开头插一行 '> 来源链接:URL'。侧边栏顶部已经有原片链接卡片,
Expand All @@ -227,9 +285,30 @@ export function resolveImageUrl(url: string | undefined | null): string {
return ''
const base = backendUrl()
if (url.startsWith('/'))
return `${base}${url}`
return withAccessToken(`${base}${url}`)
// B 站封面、抖音封面等会做 referer 校验;走后端代理
if (/(hdslb|byteimg|kpcdn|akamaized|ytimg)\.com/i.test(url))
return `${base}/api/image_proxy?url=${encodeURIComponent(url)}`
if (/(?:hdslb|byteimg|kpcdn|akamaized|ytimg)\.com/i.test(url))
return withAccessToken(`${base}/api/image_proxy?url=${encodeURIComponent(url)}`)
return url
}

export function serverNoteToTask(note: ServerNote): import('./types').TaskRecord {
const formData = note.form_data || {}
return {
taskId: note.task_id,
videoUrl: formData.video_url || '',
platform: (formData.platform || note.audio_meta?.platform || 'bilibili') as import('./types').Platform,
status: note.status,
message: note.message || '',
createdAt: new Date(note.created_at || Date.now()).getTime(),
updatedAt: new Date(note.updated_at || note.created_at || Date.now()).getTime(),
result: note.markdown
? {
markdown: note.markdown,
transcript: note.transcript,
audio_meta: note.audio_meta,
}
: undefined,
title: note.audio_meta?.title,
}
}
4 changes: 3 additions & 1 deletion BillNote_extension/src/logic/constants.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { Settings } from './types'

export const DEFAULT_BACKEND_URL = 'http://localhost:8483'
export const DEFAULT_BACKEND_URL = 'http://localhost:3015'

export const DEFAULT_SETTINGS: Settings = {
backendUrl: DEFAULT_BACKEND_URL,
authToken: '',
providerId: '',
modelName: '',
quality: 'medium',
Expand All @@ -21,3 +22,4 @@ export const MAX_TASKS = 30

export const SETTINGS_KEY = 'bilinote-settings'
export const TASKS_KEY = 'bilinote-tasks'
export const DELETED_TASK_IDS_KEY = 'bilinote-deleted-task-ids'
Loading
Loading