mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-05-06 20:42:52 +08:00
Merge pull request #83 from JefferyHcool/feature/kuaishou
feat(download): 添加快手下载器并优化下载配置功能
This commit is contained in:
@@ -13,6 +13,8 @@ import StepBar from '@/pages/HomePage/components/StepBar.tsx'
|
||||
import Downloading from '@/components/Lottie/download.tsx'
|
||||
import Prompt from '@/pages/SettingPage/Prompt.tsx'
|
||||
import AboutPage from '@/pages/SettingPage/about.tsx'
|
||||
import Downloader from '@/pages/SettingPage/Downloader.tsx'
|
||||
import DownloaderForm from '@/components/Form/DownloaderForm/Form.tsx'
|
||||
function App() {
|
||||
useTaskPolling(3000) // 每 3 秒轮询一次
|
||||
const steps = [
|
||||
@@ -35,8 +37,11 @@ function App() {
|
||||
{/*<Route index element={<Navigate to="openai" replace />} />*/}
|
||||
<Route path=":id" element={<ProviderForm />} />
|
||||
</Route>
|
||||
{/*<Route path="transcriber" elment={<Transcriber />}></Route>*/}
|
||||
<Route path="prompt" element={<Prompt />}></Route>
|
||||
{/*<Route path="transcriber" element={<Transcriber />}></Route>*/}
|
||||
{/*<Route path="prompt" element={<Prompt />}></Route>*/}
|
||||
<Route path="download" element={<Downloader />}>
|
||||
<Route path=":id" element={<DownloaderForm />} />
|
||||
</Route>
|
||||
<Route path="about" element={<AboutPage />}></Route>
|
||||
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// 下载器 Cookie 设置表单(最简化版)
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { getDownloaderCookie, updateDownloaderCookie } from '@/services/downloader' // 你自定义的请求
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { videoPlatforms } from '@/constant/note.ts'
|
||||
|
||||
const CookieSchema = z.object({
|
||||
cookie: z.string().min(10, '请填写有效 Cookie'),
|
||||
})
|
||||
|
||||
const DownloaderForm = () => {
|
||||
const form = useForm({
|
||||
resolver: zodResolver(CookieSchema),
|
||||
defaultValues: { cookie: '' },
|
||||
})
|
||||
const { id } = useParams()
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const loadCookie = async () => {
|
||||
setLoading(true) // 🔁 切换平台时显示 loading
|
||||
try {
|
||||
const res = await getDownloaderCookie(id)
|
||||
const cookie = res?.data?.data?.cookie || ''
|
||||
form.reset({ cookie }) // ✅ 正确重置表单值
|
||||
} catch (e) {
|
||||
toast.error('加载 Cookie 失败: ' + e)
|
||||
form.reset({ cookie: '' }) // ❗失败时也要清空旧值
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (id) loadCookie()
|
||||
}, [id]) // 🔁 每当 id 变化时触发
|
||||
|
||||
const onSubmit = async values => {
|
||||
try {
|
||||
await updateDownloaderCookie({
|
||||
platform: id,
|
||||
cookie: String(values.cookie),
|
||||
})
|
||||
toast.success('保存成功')
|
||||
} catch (e) {
|
||||
toast.error('保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="p-4">加载中...</div>
|
||||
|
||||
return (
|
||||
<div className="max-w-xl p-4">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
|
||||
<div className="text-lg font-bold">
|
||||
设置{videoPlatforms.find(item => item.value === id)?.label}下载器 Cookie
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cookie"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col gap-2">
|
||||
<FormLabel>Cookie</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="输入 Cookie" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit">保存</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DownloaderForm
|
||||
@@ -0,0 +1,34 @@
|
||||
import ProviderCard from '@/components/Form/DownloaderForm/providerCard.tsx'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import { useProviderStore } from '@/store/providerStore'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { DouyinLogo, KuaishouLogo } from '@/components/Icons/platform.tsx'
|
||||
import { videoPlatforms } from '@/constant/note.ts'
|
||||
|
||||
const Provider = () => {
|
||||
const navigate = useNavigate()
|
||||
const handleClick = () => {
|
||||
navigate(`/settings/model/new`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-sm font-light">下载器配置</div>
|
||||
<div>
|
||||
{videoPlatforms &&
|
||||
videoPlatforms.map((provider, index) => {
|
||||
if (provider.value !== 'local')
|
||||
return (
|
||||
<ProviderCard
|
||||
key={index}
|
||||
providerName={provider.label}
|
||||
Icon={provider?.logo}
|
||||
id={provider.value}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default Provider
|
||||
@@ -0,0 +1,6 @@
|
||||
.card {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
.card:hover {
|
||||
background-color: #f7f7f7;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Switch } from '@/components/ui/switch.tsx'
|
||||
import { FC } from 'react'
|
||||
import styles from './index.module.css'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import AILogo from '@/components/Form/modelForm/Icons'
|
||||
import { useProviderStore } from '@/store/providerStore'
|
||||
export interface IProviderCardProps {
|
||||
id: string
|
||||
providerName: string
|
||||
Icon: any
|
||||
}
|
||||
const ProviderCard: FC<IProviderCardProps> = ({ providerName, Icon, id }: IProviderCardProps) => {
|
||||
const navigate = useNavigate()
|
||||
const updateProvider = useProviderStore(state => state.updateProvider)
|
||||
const handleClick = () => {
|
||||
navigate(`/settings/download/${id}`)
|
||||
}
|
||||
|
||||
const rawId = useParams()
|
||||
console.log('rawId', rawId)
|
||||
// @ts-ignore
|
||||
const { id: currentId } = useParams()
|
||||
const isActive = currentId === id
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
handleClick()
|
||||
}}
|
||||
className={
|
||||
styles.card +
|
||||
' flex h-14 items-center justify-between rounded border border-[#f3f3f3] p-2' +
|
||||
(isActive ? ' bg-[#F0F0F0] font-semibold text-blue-600' : '')
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-lg">
|
||||
<div className="flex h-6 w-6 items-center">{<Icon></Icon>}</div>
|
||||
<div className="font-semibold">{providerName}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default ProviderCard
|
||||
168
BillNote_frontend/src/components/Icons/platform.tsx
Normal file
168
BillNote_frontend/src/components/Icons/platform.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
export const KuaishouLogo = () => {
|
||||
return (
|
||||
<svg
|
||||
t="1746695310517"
|
||||
className="icon"
|
||||
viewBox="0 0 1024 1024"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
p-id="1680"
|
||||
width="200"
|
||||
height="200"
|
||||
>
|
||||
<path
|
||||
d="M299.27936 624.43008v87.48544c0 14.64832 10.70592 21.24288 23.78752 14.65856l83.49696-42.01984v-32.76288L323.072 609.7664c-13.08672-6.58432-23.79264 0.01536-23.79264 14.66368zM654.42304 436.03456c36.72064 0 66.59584-29.87008 66.59584-66.59072s-29.8752-66.59584-66.59584-66.59584c-36.71552 0-66.5856 29.8752-66.5856 66.59584s29.87008 66.59072 66.5856 66.59072zM443.56096 435.65056c47.73376 0 86.56384-38.8352 86.56384-86.56896s-38.83008-86.56896-86.56384-86.56896-86.56896 38.8352-86.56896 86.56896 38.8352 86.56896 86.56896 86.56896z"
|
||||
fill="#FF4A08"
|
||||
p-id="1681"
|
||||
></path>
|
||||
<path
|
||||
d="M849.92 51.2H174.08c-67.8656 0-122.88 55.0144-122.88 122.88v675.84c0 67.8656 55.0144 122.88 122.88 122.88h675.84c67.8656 0 122.88-55.0144 122.88-122.88V174.08c0-67.8656-55.0144-122.88-122.88-122.88zM443.56096 204.8c54.05184 0 101.22752 29.89056 125.93664 73.99936 22.24128-20.85376 52.11136-33.664 84.93056-33.664 68.54656 0 124.30848 55.76704 124.30848 124.30848s-55.76704 124.30336-124.30848 124.30336c-41.40544 0-78.12608-20.37248-100.73088-51.60448-26.48576 31.29856-66.01728 51.22048-110.13632 51.22048-79.55968 0-144.2816-64.72704-144.2816-144.2816S364.00128 204.8 443.56096 204.8z m336.65536 505.63584c0 59.97568-48.78848 108.76416-108.76416 108.76416H515.328c-47.05792 0-87.22432-30.04416-102.34368-71.96672l-87.81824 42.40384c-9.43616 4.5568-18.97984 6.8608-28.37504 6.8608h-0.00512c-30.70976 0-53.00224-24.3712-53.00224-57.9328v-140.5696c0-33.57696 22.29248-57.94304 53.00736-57.94304 9.3952 0 18.93888 2.30912 28.36992 6.86592l87.59808 42.29632c14.93504-42.26048 55.26528-72.63232 102.56896-72.63232h156.11904c59.97568 0 108.76416 48.7936 108.76416 108.76928v85.08416z"
|
||||
fill="#FF4A08"
|
||||
p-id="1682"
|
||||
></path>
|
||||
<path
|
||||
d="M671.45216 574.28992H515.328c-28.14976 0-51.05664 22.90688-51.05664 51.05664v85.08928c0 28.14976 22.90688 51.05664 51.05664 51.05664h156.11904c28.14976 0 51.05664-22.90688 51.05664-51.05664v-85.08928c0-28.14976-22.90176-51.05664-51.05152-51.05664z"
|
||||
fill="#FF4A08"
|
||||
p-id="1683"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
export const DouyinLogo = () => {
|
||||
return (
|
||||
<svg
|
||||
t="1746695428425"
|
||||
className="icon"
|
||||
viewBox="0 0 1024 1024"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
p-id="2731"
|
||||
width="200"
|
||||
height="200"
|
||||
>
|
||||
<path
|
||||
d="M0 0m184.32 0l655.36 0q184.32 0 184.32 184.32l0 655.36q0 184.32-184.32 184.32l-655.36 0q-184.32 0-184.32-184.32l0-655.36q0-184.32 184.32-184.32Z"
|
||||
fill="#111111"
|
||||
p-id="2732"
|
||||
></path>
|
||||
<path
|
||||
d="M204.27776 670.59712a246.25152 246.25152 0 0 1 245.97504-245.97504v147.57888a98.49856 98.49856 0 0 0-98.38592 98.38592c0 48.34304 26.14272 100.352 83.54816 100.352 3.81952 0 93.55264-0.88064 93.55264-77.19936V134.35904h157.26592a133.31456 133.31456 0 0 0 133.12 132.99712l-0.13312 147.31264a273.152 273.152 0 0 1-142.62272-38.912l-0.06144 317.98272c0 146.00192-124.24192 224.77824-241.14176 224.77824-131.74784 0.03072-231.1168-106.56768-231.1168-247.92064z"
|
||||
fill="#FF4040"
|
||||
p-id="2733"
|
||||
></path>
|
||||
<path
|
||||
d="M164.92544 631.23456a246.25152 246.25152 0 0 1 245.97504-245.97504v147.57888a98.49856 98.49856 0 0 0-98.38592 98.38592c0 48.34304 26.14272 100.352 83.54816 100.352 3.81952 0 93.55264-0.88064 93.55264-77.19936V94.99648h157.26592a133.31456 133.31456 0 0 0 133.12 132.99712l-0.13312 147.31264a273.152 273.152 0 0 1-142.62272-38.912l-0.06144 317.98272c0 146.00192-124.24192 224.77824-241.14176 224.77824-131.74784 0.03072-231.1168-106.56768-231.1168-247.92064z"
|
||||
fill="#00F5FF"
|
||||
p-id="2734"
|
||||
></path>
|
||||
<path
|
||||
d="M410.91072 427.58144c-158.8224 20.15232-284.44672 222.72-154.112 405.00224 120.40192 98.47808 373.68832 41.20576 380.70272-171.85792l-0.17408-324.1472a280.7296 280.7296 0 0 0 142.88896 38.62528V261.2224a144.98816 144.98816 0 0 1-72.8064-54.82496 135.23968 135.23968 0 0 1-54.70208-72.45824h-123.66848l-0.08192 561.41824c-0.11264 78.46912-130.9696 106.41408-164.18816 30.2592-83.18976-39.77216-64.37888-190.9248 46.31552-192.57344z"
|
||||
fill="#FFFFFF"
|
||||
p-id="2735"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export const BiliBiliLogo = () => {
|
||||
return (
|
||||
<svg
|
||||
t="1746696526393"
|
||||
className="icon"
|
||||
viewBox="0 0 1024 1024"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
p-id="3757"
|
||||
width="200"
|
||||
height="200"
|
||||
>
|
||||
<path
|
||||
d="M0 0m184.32 0l655.36 0q184.32 0 184.32 184.32l0 655.36q0 184.32-184.32 184.32l-655.36 0q-184.32 0-184.32-184.32l0-655.36q0-184.32 184.32-184.32Z"
|
||||
fill="#EC5D85"
|
||||
p-id="3758"
|
||||
></path>
|
||||
<path
|
||||
d="M512 241.96096h52.224l65.06496-96.31744c49.63328-50.31936 89.64096 0.43008 63.85664 45.71136l-34.31424 51.5072c257.64864 5.02784 257.64864 43.008 257.64864 325.03808 0 325.94944 0 336.46592-404.48 336.46592S107.52 893.8496 107.52 567.90016c0-277.69856 0-318.80192 253.14304-324.95616l-39.43424-58.368c-31.26272-54.90688 37.33504-90.40896 64.68608-42.37312l60.416 99.80928c18.18624-0.0512 41.18528-0.0512 65.66912-0.0512z"
|
||||
fill="#EF85A7"
|
||||
p-id="3759"
|
||||
></path>
|
||||
<path
|
||||
d="M512 338.5856c332.8 0 332.8 0 332.8 240.64s0 248.39168-332.8 248.39168-332.8-7.75168-332.8-248.39168 0-240.64 332.8-240.64z"
|
||||
fill="#EC5D85"
|
||||
p-id="3760"
|
||||
></path>
|
||||
<path
|
||||
d="M281.6 558.08a30.72 30.72 0 0 1-27.47392-16.97792 30.72 30.72 0 0 1 13.73184-41.216l122.88-61.44a30.72 30.72 0 0 1 41.216 13.74208 30.72 30.72 0 0 1-13.74208 41.216l-122.88 61.44a30.59712 30.59712 0 0 1-13.73184 3.23584zM752.64 558.08a30.60736 30.60736 0 0 1-12.8512-2.83648l-133.12-61.44a30.72 30.72 0 0 1-15.04256-40.7552 30.72 30.72 0 0 1 40.76544-15.02208l133.12 61.44A30.72 30.72 0 0 1 752.64 558.08zM454.656 666.88a15.36 15.36 0 0 1-12.288-6.1952 15.36 15.36 0 0 1 3.072-21.49376l68.5056-50.91328 50.35008 52.62336a15.36 15.36 0 0 1-22.20032 21.23776l-31.5904-33.024-46.71488 34.72384a15.28832 15.28832 0 0 1-9.13408 3.04128z"
|
||||
fill="#EF85A7"
|
||||
p-id="3761"
|
||||
></path>
|
||||
<path
|
||||
d="M65.536 369.31584c15.03232 101.90848 32.84992 147.17952 44.544 355.328 14.63296 2.18112 177.70496 10.04544 204.05248-74.62912a16.14848 16.14848 0 0 0 1.64864-10.87488c-30.60736-80.3328-169.216-60.416-169.216-60.416s-10.36288-146.50368-11.49952-238.83776zM362.25024 383.03744l34.816 303.17568h34.64192L405.23776 381.1328zM309.52448 536.28928h45.48608l16.09728 158.6176-31.82592 1.85344zM446.86336 542.98624h45.80352V705.3312h-33.87392zM296.6016 457.97376h21.39136l5.2736 58.99264-18.91328 2.26304zM326.99392 457.97376h21.39136l2.53952 55.808-17.408 1.61792zM470.62016 459.88864h19.456v62.27968h-19.456zM440.23808 459.88864h22.20032v62.27968h-16.62976z"
|
||||
fill="#FFFFFF"
|
||||
p-id="3762"
|
||||
></path>
|
||||
<path
|
||||
d="M243.56864 645.51936a275.456 275.456 0 0 1-28.4672 23.74656 242.688 242.688 0 0 1-29.53216 17.52064 2.70336 2.70336 0 0 1-4.4032-1.95584 258.60096 258.60096 0 0 1-5.12-29.57312c-1.41312-12.1856-1.95584-25.68192-2.16064-36.36224 0-0.3072 0-2.5088 3.01056-1.90464a245.92384 245.92384 0 0 1 34.22208 9.5744 257.024 257.024 0 0 1 32.3584 15.17568c0.52224 0.256 2.51904 1.4848 0.09216 3.77856z"
|
||||
fill="#EB5480"
|
||||
p-id="3763"
|
||||
></path>
|
||||
<path
|
||||
d="M513.29024 369.31584c15.03232 101.90848 32.84992 147.17952 44.544 355.328 14.63296 2.18112 177.70496 10.04544 204.05248-74.62912a16.14848 16.14848 0 0 0 1.64864-10.87488c-30.60736-80.3328-169.216-60.416-169.216-60.416s-10.36288-146.50368-11.49952-238.83776zM810.00448 383.03744l34.816 303.17568h34.64192L852.992 381.1328zM757.27872 536.28928h45.48608l16.09728 158.6176-31.82592 1.85344zM894.6176 542.98624h45.80352V705.3312H906.5472zM744.35584 457.97376h21.39136l5.2736 58.99264-18.91328 2.26304zM774.74816 457.97376h21.39136l2.53952 55.808-17.408 1.61792zM918.3744 459.88864h19.456v62.27968h-19.456zM887.99232 459.88864h22.20032v62.27968h-16.62976z"
|
||||
fill="#FFFFFF"
|
||||
p-id="3764"
|
||||
></path>
|
||||
<path
|
||||
d="M691.32288 645.51936a275.456 275.456 0 0 1-28.4672 23.74656 242.688 242.688 0 0 1-29.53216 17.52064 2.70336 2.70336 0 0 1-4.4032-1.95584 258.60096 258.60096 0 0 1-5.12-29.57312c-1.41312-12.1856-1.95584-25.68192-2.16064-36.36224 0-0.3072 0-2.5088 3.01056-1.90464a245.92384 245.92384 0 0 1 34.22208 9.5744 257.024 257.024 0 0 1 32.3584 15.17568c0.52224 0.256 2.51904 1.4848 0.09216 3.77856z"
|
||||
fill="#EB5480"
|
||||
p-id="3765"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export const YoutubeLogo = () => {
|
||||
return (
|
||||
<svg
|
||||
t="1746696577253"
|
||||
className="icon"
|
||||
viewBox="0 0 1024 1024"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
p-id="4785"
|
||||
width="200"
|
||||
height="200"
|
||||
>
|
||||
<path
|
||||
d="M426.666667 682.666667V384l256 149.845333L426.666667 682.666667z m587.093333-355.541334s-10.026667-71.04-40.704-102.357333c-38.954667-41.088-82.602667-41.258667-102.613333-43.648C727.168 170.666667 512.213333 170.666667 512.213333 170.666667h-0.426666s-214.954667 0-358.229334 10.453333c-20.053333 2.389333-63.658667 2.56-102.656 43.648-30.677333 31.317333-40.661333 102.4-40.661333 102.4S0 410.538667 0 493.952v78.293333c0 83.456 10.24 166.912 10.24 166.912s9.984 71.04 40.661333 102.357334c38.997333 41.088 90.154667 39.765333 112.938667 44.074666C245.76 893.568 512 896 512 896s215.168-0.341333 358.442667-10.752c20.053333-2.432 63.658667-2.602667 102.613333-43.690667 30.72-31.317333 40.704-102.4 40.704-102.4s10.24-83.413333 10.24-166.869333v-78.250667c0-83.456-10.24-166.912-10.24-166.912z"
|
||||
fill="#FF0000"
|
||||
p-id="4786"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export const LocalLogo = () => {
|
||||
return (
|
||||
<svg
|
||||
t="1746696617516"
|
||||
className="icon"
|
||||
viewBox="0 0 1024 1024"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
p-id="5795"
|
||||
width="200"
|
||||
height="200"
|
||||
>
|
||||
<path
|
||||
d="M948.736 144.384H461.568l-56.576-83.456c-6.144-7.168-15.36-10.752-24.576-9.728H79.872c-17.152-0.512-34.048 5.632-46.592 17.408-12.544 11.776-19.968 28.16-20.48 45.312v222.464c0-18.944 7.424-37.12 20.992-50.432 13.312-13.312 31.488-20.992 50.432-20.992h855.808c18.944 0 37.12 7.424 50.432 20.992 13.312 13.312 20.992 31.488 20.992 50.432V213.248c1.28-36.096-26.624-66.816-62.72-68.864z m0 0"
|
||||
fill="#FFD569"
|
||||
p-id="5796"
|
||||
></path>
|
||||
<path
|
||||
d="M939.776 265.216H84.224C44.8 265.216 12.8 297.216 12.8 336.64v570.368c0 18.944 7.424 37.12 20.992 50.432 13.312 13.312 31.488 20.992 50.432 20.992h855.808c18.944 0 37.12-7.424 50.432-20.992 13.312-13.312 20.992-31.488 20.992-50.432V336.64c0-18.944-7.424-37.12-20.992-50.432-13.568-13.312-31.744-20.992-50.688-20.992z m-213.76 467.968c0.256 6.4-3.328 12.288-9.216 14.848-1.792 0.256-3.84 0.256-5.632 0-4.096 0-7.936-1.792-10.752-4.864l-54.784-59.136v77.056c0.256 8.704-6.4 15.872-14.848 16.384h-317.44c-7.936-0.512-14.336-6.912-14.848-14.848V495.616c-0.256-8.704 6.4-15.872 14.848-16.384h317.44c8.704 0.512 15.616 7.68 15.36 16.384v76.544l54.784-57.344c3.84-4.864 10.496-6.144 16.128-3.584 5.632 2.816 9.472 8.704 9.216 14.848v207.104z m0 0"
|
||||
fill="#FFC225"
|
||||
p-id="5797"
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,35 @@
|
||||
/* -------------------- 常量 -------------------- */
|
||||
import {
|
||||
BiliBiliLogo,
|
||||
DouyinLogo,
|
||||
KuaishouLogo,
|
||||
LocalLogo,
|
||||
YoutubeLogo,
|
||||
} from '@/components/Icons/platform.tsx'
|
||||
|
||||
export const noteFormats = [
|
||||
{ label: '目录', value: 'toc' },
|
||||
{ label: '原片跳转', value: 'link' },
|
||||
{ label: '原片截图', value: 'screenshot' },
|
||||
{ label: 'AI总结', value: 'summary' },
|
||||
{ label: '目录', value: 'toc' },
|
||||
{ label: '原片跳转', value: 'link' },
|
||||
{ label: '原片截图', value: 'screenshot' },
|
||||
{ label: 'AI总结', value: 'summary' },
|
||||
] as const
|
||||
|
||||
export const noteStyles = [
|
||||
{ label: '精简', value: 'minimal' },
|
||||
{ label: '详细', value: 'detailed' },
|
||||
{ label: '教程', value: 'tutorial' },
|
||||
{ label: '学术', value: 'academic' },
|
||||
{ label: '小红书', value: 'xiaohongshu' },
|
||||
{ label: '生活向', value: 'life_journal' },
|
||||
{ label: '任务导向', value: 'task_oriented' },
|
||||
{ label: '商业风格', value: 'business' },
|
||||
{ label: '会议纪要', value: 'meeting_minutes' },
|
||||
] as const
|
||||
{ label: '精简', value: 'minimal' },
|
||||
{ label: '详细', value: 'detailed' },
|
||||
{ label: '教程', value: 'tutorial' },
|
||||
{ label: '学术', value: 'academic' },
|
||||
{ label: '小红书', value: 'xiaohongshu' },
|
||||
{ label: '生活向', value: 'life_journal' },
|
||||
{ label: '任务导向', value: 'task_oriented' },
|
||||
{ label: '商业风格', value: 'business' },
|
||||
{ label: '会议纪要', value: 'meeting_minutes' },
|
||||
] as const
|
||||
|
||||
export const videoPlatforms = [
|
||||
{ label: '哔哩哔哩', value: 'bilibili', logo: BiliBiliLogo },
|
||||
{ label: 'YouTube', value: 'youtube', logo: YoutubeLogo },
|
||||
{ label: '抖音', value: 'douyin', logo: DouyinLogo },
|
||||
{ label: '快手', value: 'kuaishou', logo: KuaishouLogo },
|
||||
{ label: '本地视频', value: 'local', logo: LocalLogo },
|
||||
] as const
|
||||
|
||||
@@ -1,108 +1,128 @@
|
||||
/* NoteForm.tsx ---------------------------------------------------- */
|
||||
import {
|
||||
Form, FormControl, FormField, FormItem, FormLabel, FormMessage,
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form.tsx'
|
||||
import { useEffect } from 'react'
|
||||
import { useForm, useWatch } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { Info, Loader2 ,Plus} from 'lucide-react'
|
||||
import { Info, Loader2, Plus } from 'lucide-react'
|
||||
import { message, Alert } from 'antd'
|
||||
import { generateNote } from '@/services/note.ts'
|
||||
import { uploadFile } from '@/services/upload.ts'
|
||||
import { useTaskStore } from '@/store/taskStore'
|
||||
import { useModelStore } from '@/store/modelStore'
|
||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip.tsx";
|
||||
import {Checkbox} from "@/components/ui/checkbox.tsx";
|
||||
import {ScrollArea} from "@/components/ui/scroll-area.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select.tsx";
|
||||
import {Input} from "@/components/ui/input.tsx";
|
||||
import {Textarea} from "@/components/ui/textarea.tsx";
|
||||
import {noteStyles,noteFormats} from "@/constant/note.ts";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip.tsx'
|
||||
import { Checkbox } from '@/components/ui/checkbox.tsx'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area.tsx'
|
||||
import { Button } from '@/components/ui/button.tsx'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select.tsx'
|
||||
import { Input } from '@/components/ui/input.tsx'
|
||||
import { Textarea } from '@/components/ui/textarea.tsx'
|
||||
import { noteStyles, noteFormats, videoPlatforms } from '@/constant/note.ts'
|
||||
|
||||
/* -------------------- 校验 Schema -------------------- */
|
||||
const formSchema = z.object({
|
||||
video_url: z.string(),
|
||||
platform: z.string().nonempty('请选择平台'),
|
||||
quality: z.enum(['fast', 'medium', 'slow']),
|
||||
screenshot: z.boolean().optional(),
|
||||
link: z.boolean().optional(),
|
||||
model_name: z.string().nonempty('请选择模型'),
|
||||
format: z.array(z.string()).default([]),
|
||||
style: z.string().nonempty('请选择笔记生成风格'),
|
||||
extras: z.string().optional(),
|
||||
video_understanding: z.boolean().optional(),
|
||||
video_interval: z.coerce.number().min(1).max(30).default(4).optional(),
|
||||
grid_size: z.tuple([
|
||||
z.coerce.number().min(1).max(10),
|
||||
z.coerce.number().min(1).max(10),
|
||||
]).default([3, 3]).optional(),
|
||||
}).superRefine(({ video_url, platform }, ctx) => {
|
||||
if (platform === 'local' || platform === 'douyin') {
|
||||
if (!video_url) {
|
||||
ctx.addIssue({ code: 'custom', message: '本地视频路径不能为空', path: ['video_url'] })
|
||||
const formSchema = z
|
||||
.object({
|
||||
video_url: z.string(),
|
||||
platform: z.string().nonempty('请选择平台'),
|
||||
quality: z.enum(['fast', 'medium', 'slow']),
|
||||
screenshot: z.boolean().optional(),
|
||||
link: z.boolean().optional(),
|
||||
model_name: z.string().nonempty('请选择模型'),
|
||||
format: z.array(z.string()).default([]),
|
||||
style: z.string().nonempty('请选择笔记生成风格'),
|
||||
extras: z.string().optional(),
|
||||
video_understanding: z.boolean().optional(),
|
||||
video_interval: z.coerce.number().min(1).max(30).default(4).optional(),
|
||||
grid_size: z
|
||||
.tuple([z.coerce.number().min(1).max(10), z.coerce.number().min(1).max(10)])
|
||||
.default([3, 3])
|
||||
.optional(),
|
||||
})
|
||||
.superRefine(({ video_url, platform }, ctx) => {
|
||||
if (platform === 'local' || platform === 'douyin') {
|
||||
if (!video_url) {
|
||||
ctx.addIssue({ code: 'custom', message: '本地视频路径不能为空', path: ['video_url'] })
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const url = new URL(video_url)
|
||||
if (!['http:', 'https:'].includes(url.protocol)) throw new Error()
|
||||
} catch {
|
||||
ctx.addIssue({ code: 'custom', message: '请输入正确的视频链接', path: ['video_url'] })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const url = new URL(video_url)
|
||||
if (!['http:', 'https:'].includes(url.protocol)) throw new Error()
|
||||
} catch {
|
||||
ctx.addIssue({ code: 'custom', message: '请输入正确的视频链接', path: ['video_url'] })
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
type NoteFormValues = z.infer<typeof formSchema>
|
||||
|
||||
/* -------------------- 可复用子组件 -------------------- */
|
||||
const SectionHeader = ({ title, tip }: { title: string; tip?: string }) => (
|
||||
<div className="my-3 flex items-center justify-between">
|
||||
<h2 className="block">{title}</h2>
|
||||
{tip && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="hover:text-primary h-4 w-4 cursor-pointer text-neutral-400" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-xs">{tip}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
<div className="my-3 flex items-center justify-between">
|
||||
<h2 className="block">{title}</h2>
|
||||
{tip && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="hover:text-primary h-4 w-4 cursor-pointer text-neutral-400" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-xs">{tip}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const CheckboxGroup = ({
|
||||
value = [], onChange, disabledMap,
|
||||
}: {
|
||||
value = [],
|
||||
onChange,
|
||||
disabledMap,
|
||||
}: {
|
||||
value?: string[]
|
||||
onChange: (v: string[]) => void
|
||||
disabledMap: Record<string, boolean>
|
||||
}) => (
|
||||
<div className="flex flex-wrap space-x-1.5">
|
||||
{noteFormats.map(({ label, value: v }) => (
|
||||
<label key={v} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={value.includes(v)}
|
||||
disabled={disabledMap[v]}
|
||||
onCheckedChange={checked =>
|
||||
onChange(checked ? [...value, v] : value.filter(x => x !== v))}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap space-x-1.5">
|
||||
{noteFormats.map(({ label, value: v }) => (
|
||||
<label key={v} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={value.includes(v)}
|
||||
disabled={disabledMap[v]}
|
||||
onCheckedChange={checked =>
|
||||
onChange(checked ? [...value, v] : value.filter(x => x !== v))
|
||||
}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
/* -------------------- 主组件 -------------------- */
|
||||
const NoteForm = () => {
|
||||
/* ---- 全局状态 ---- */
|
||||
const { addPendingTask, currentTaskId, setCurrentTask,getCurrentTask ,retryTask} = useTaskStore()
|
||||
const { addPendingTask, currentTaskId, setCurrentTask, getCurrentTask, retryTask } =
|
||||
useTaskStore()
|
||||
const { loadEnabledModels, modelList, showFeatureHint, setShowFeatureHint } = useModelStore()
|
||||
|
||||
|
||||
/* ---- 表单 ---- */
|
||||
const form = useForm<NoteFormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
@@ -116,35 +136,32 @@ const NoteForm = () => {
|
||||
format: [],
|
||||
},
|
||||
})
|
||||
const currentTask = getCurrentTask()
|
||||
const currentTask = getCurrentTask()
|
||||
|
||||
/* ---- 派生状态(只 watch 一次,提高性能) ---- */
|
||||
const platform = useWatch({ control: form.control, name: 'platform' }) as string
|
||||
const videoUnderstandingEnabled = useWatch({ control: form.control, name: 'video_understanding' })
|
||||
const editing = currentTask && currentTask.id
|
||||
const editing = currentTask && currentTask.id
|
||||
|
||||
/* ---- 副作用 ---- */
|
||||
/* ---- 副作用 ---- */
|
||||
useEffect(() => {
|
||||
loadEnabledModels()
|
||||
|
||||
return}, [])
|
||||
useEffect(() => {
|
||||
const currentTask = getCurrentTask()
|
||||
const { formData } = currentTask || {}
|
||||
if (!currentTask) return
|
||||
form.reset(
|
||||
{
|
||||
...formData,
|
||||
extras: formData?.extras || '',
|
||||
}
|
||||
|
||||
)
|
||||
return
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
const currentTask = getCurrentTask()
|
||||
const { formData } = currentTask || {}
|
||||
if (!currentTask) return
|
||||
form.reset({
|
||||
...formData,
|
||||
extras: formData?.extras || '',
|
||||
})
|
||||
}, [currentTaskId])
|
||||
|
||||
/* ---- 帮助函数 ---- */
|
||||
const isGenerating = () =>
|
||||
!['SUCCESS', 'FAILED', undefined].includes(getCurrentTask()?.status)
|
||||
const generating = isGenerating()
|
||||
const isGenerating = () => !['SUCCESS', 'FAILED', undefined].includes(getCurrentTask()?.status)
|
||||
const generating = isGenerating()
|
||||
const handleFileUpload = async (file: File, cb: (url: string) => void) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
@@ -158,321 +175,324 @@ const NoteForm = () => {
|
||||
}
|
||||
|
||||
const onSubmit = async (values: NoteFormValues) => {
|
||||
|
||||
const payload:NoteFormValues = {
|
||||
...values,
|
||||
provider_id: modelList.find(m => m.model_name === values.model_name)!.provider_id,
|
||||
task_id: currentTaskId || '',
|
||||
}
|
||||
if (currentTaskId){
|
||||
retryTask(currentTaskId,payload)
|
||||
return
|
||||
const payload: NoteFormValues = {
|
||||
...values,
|
||||
provider_id: modelList.find(m => m.model_name === values.model_name)!.provider_id,
|
||||
task_id: currentTaskId || '',
|
||||
}
|
||||
if (currentTaskId) {
|
||||
retryTask(currentTaskId, payload)
|
||||
return
|
||||
}
|
||||
|
||||
message.success('已提交任务')
|
||||
const { data } = await generateNote(payload)
|
||||
addPendingTask(data.task_id, values.platform, payload)
|
||||
}
|
||||
const handleCreateNew = () => {
|
||||
// 🔁 这里清空当前任务状态
|
||||
// 比如调用 resetCurrentTask() 或者 navigate 到一个新页面
|
||||
setCurrentTask(null)
|
||||
const handleCreateNew = () => {
|
||||
// 🔁 这里清空当前任务状态
|
||||
// 比如调用 resetCurrentTask() 或者 navigate 到一个新页面
|
||||
setCurrentTask(null)
|
||||
}
|
||||
const FormButton = () => {
|
||||
const label = generating ? '正在生成…' : editing ? '重新生成' : '生成笔记'
|
||||
|
||||
}
|
||||
const FormButton = () => {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
className={!editing ? 'w-full' : 'w-2/3' + ' bg-primary'}
|
||||
disabled={generating}
|
||||
>
|
||||
{generating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{label}
|
||||
</Button>
|
||||
|
||||
|
||||
const label = generating
|
||||
? '正在生成…'
|
||||
: editing
|
||||
? '重新生成'
|
||||
: '生成笔记'
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" className={!editing?'w-full':'w-2/3'+" bg-primary"} disabled={generating}>
|
||||
{generating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{label}
|
||||
</Button>
|
||||
|
||||
{editing && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-1/3"
|
||||
onClick={handleCreateNew}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新建笔记
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{editing && (
|
||||
<Button type="button" variant="outline" className="w-1/3" onClick={handleCreateNew}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新建笔记
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* -------------------- 渲染 -------------------- */
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
{/* 顶部按钮 */}
|
||||
<FormButton></FormButton>
|
||||
<div className="h-full w-full">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
{/* 顶部按钮 */}
|
||||
<FormButton></FormButton>
|
||||
|
||||
{/* 视频链接 & 平台 */}
|
||||
<SectionHeader title="视频链接" tip="支持 B 站、YouTube 等平台" />
|
||||
<div className="flex gap-2">
|
||||
{/* 平台选择 */}
|
||||
{/* 视频链接 & 平台 */}
|
||||
<SectionHeader title="视频链接" tip="支持 B 站、YouTube 等平台" />
|
||||
<div className="flex gap-2">
|
||||
{/* 平台选择 */}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="platform"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Select disabled={!!editing} value={field.value} onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-32"><SelectValue /></SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent >
|
||||
<SelectItem value="bilibili">哔哩哔哩</SelectItem>
|
||||
<SelectItem value="youtube">YouTube</SelectItem>
|
||||
<SelectItem value="douyin">抖音</SelectItem>
|
||||
<SelectItem value="local">本地视频</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* 链接输入 / 上传框 */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="video_url"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
{platform === 'local' ? (
|
||||
<>
|
||||
<Input disabled={!!editing} placeholder="请输入本地视频路径" {...field} />
|
||||
|
||||
</>
|
||||
) : (
|
||||
<Input disabled={!!editing} placeholder="请输入视频网站链接" {...field} />
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="video_url"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
{platform === 'local' && (
|
||||
<>
|
||||
<div
|
||||
className="hover:border-primary mt-2 flex h-40 cursor-pointer items-center justify-center rounded-md border-2 border-dashed border-gray-300 transition-colors"
|
||||
onDragOver={e => { e.preventDefault(); e.stopPropagation() }}
|
||||
onDrop={e => {
|
||||
e.preventDefault()
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (file) handleFileUpload(file, field.onChange)
|
||||
}}
|
||||
onClick={() => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = 'video/*'
|
||||
input.onchange = e => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (file) handleFileUpload(file, field.onChange)
|
||||
}
|
||||
input.click()
|
||||
}}
|
||||
>
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
拖拽文件到这里上传 <br />
|
||||
<span className="text-xs text-gray-400">或点击选择文件</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
|
||||
{/* 模型选择 */}
|
||||
<FormField
|
||||
className="w-full"
|
||||
control={form.control}
|
||||
name="model_name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<SectionHeader title="模型选择" tip="不同模型效果不同,建议自行测试" />
|
||||
<Select value={field.value} onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full min-w-0 truncate" ><SelectValue /></SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{modelList.map(m => (
|
||||
<SelectItem key={m.id} value={m.model_name}>
|
||||
{m.model_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* 笔记风格 */}
|
||||
<FormField
|
||||
className="w-full"
|
||||
control={form.control}
|
||||
name="style"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<SectionHeader title="笔记风格" tip="选择生成笔记的呈现风格" />
|
||||
<Select value={field.value} onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full min-w-0 truncate" ><SelectValue /></SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{noteStyles.map(({ label, value }) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{/* 视频理解 */}
|
||||
<SectionHeader title="视频理解" tip="将视频截图发给多模态模型辅助分析" />
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="video_understanding"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>启用</FormLabel>
|
||||
<Checkbox
|
||||
checked={videoUnderstandingEnabled}
|
||||
onCheckedChange={v => form.setValue('video_understanding', v)}
|
||||
/>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* 采样间隔 */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="video_interval"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>采样间隔(秒)</FormLabel>
|
||||
<Input
|
||||
disabled={!videoUnderstandingEnabled}
|
||||
type="number"
|
||||
{...field}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* 拼图大小 */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="grid_size"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>拼图尺寸(列 × 行)</FormLabel>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input
|
||||
disabled={!videoUnderstandingEnabled}
|
||||
type="number"
|
||||
value={field.value?.[0] || 3}
|
||||
onChange={e =>
|
||||
field.onChange([+e.target.value, field.value?.[1] || 3])}
|
||||
className="w-16"
|
||||
/>
|
||||
<span>x</span>
|
||||
<Input
|
||||
disabled={!videoUnderstandingEnabled}
|
||||
type="number"
|
||||
value={field.value?.[1] || 3}
|
||||
onChange={e =>
|
||||
field.onChange([field.value?.[0] || 3, +e.target.value])}
|
||||
className="w-16"
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="platform"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Select
|
||||
disabled={!!editing}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{videoPlatforms?.map(p => (
|
||||
<SelectItem key={p.value} value={p.value}>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="h-4 w-4">{p.logo()}</div>
|
||||
<span>{p.label}</span>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Alert
|
||||
closable
|
||||
type="error"
|
||||
message={
|
||||
<div>
|
||||
<strong>提示:</strong>
|
||||
<p>视频理解功能必须使用多模态模型。</p>
|
||||
|
||||
</div>
|
||||
}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* 笔记格式 */}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* 链接输入 / 上传框 */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="video_url"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
{platform === 'local' ? (
|
||||
<>
|
||||
<Input disabled={!!editing} placeholder="请输入本地视频路径" {...field} />
|
||||
</>
|
||||
) : (
|
||||
<Input disabled={!!editing} placeholder="请输入视频网站链接" {...field} />
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="video_url"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
{platform === 'local' && (
|
||||
<>
|
||||
<div
|
||||
className="hover:border-primary mt-2 flex h-40 cursor-pointer items-center justify-center rounded-md border-2 border-dashed border-gray-300 transition-colors"
|
||||
onDragOver={e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onDrop={e => {
|
||||
e.preventDefault()
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (file) handleFileUpload(file, field.onChange)
|
||||
}}
|
||||
onClick={() => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = 'video/*'
|
||||
input.onchange = e => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (file) handleFileUpload(file, field.onChange)
|
||||
}
|
||||
input.click()
|
||||
}}
|
||||
>
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
拖拽文件到这里上传 <br />
|
||||
<span className="text-xs text-gray-400">或点击选择文件</span>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{/* 模型选择 */}
|
||||
<FormField
|
||||
className="w-full"
|
||||
control={form.control}
|
||||
name="model_name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<SectionHeader title="模型选择" tip="不同模型效果不同,建议自行测试" />
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full min-w-0 truncate">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{modelList.map(m => (
|
||||
<SelectItem key={m.id} value={m.model_name}>
|
||||
{m.model_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* 笔记风格 */}
|
||||
<FormField
|
||||
className="w-full"
|
||||
control={form.control}
|
||||
name="style"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<SectionHeader title="笔记风格" tip="选择生成笔记的呈现风格" />
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full min-w-0 truncate">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{noteStyles.map(({ label, value }) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{/* 视频理解 */}
|
||||
<SectionHeader title="视频理解" tip="将视频截图发给多模态模型辅助分析" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="video_understanding"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>启用</FormLabel>
|
||||
<Checkbox
|
||||
checked={videoUnderstandingEnabled}
|
||||
onCheckedChange={v => form.setValue('video_understanding', v)}
|
||||
/>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* 采样间隔 */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="format"
|
||||
name="video_interval"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<SectionHeader title="笔记格式" tip="选择要包含的笔记元素" />
|
||||
<CheckboxGroup
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
disabledMap={{
|
||||
link: platform === 'local',
|
||||
screenshot: !videoUnderstandingEnabled,
|
||||
}}
|
||||
<FormItem>
|
||||
<FormLabel>采样间隔(秒)</FormLabel>
|
||||
<Input disabled={!videoUnderstandingEnabled} type="number" {...field} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* 拼图大小 */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="grid_size"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>拼图尺寸(列 × 行)</FormLabel>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input
|
||||
disabled={!videoUnderstandingEnabled}
|
||||
type="number"
|
||||
value={field.value?.[0] || 3}
|
||||
onChange={e => field.onChange([+e.target.value, field.value?.[1] || 3])}
|
||||
className="w-16"
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
<span>x</span>
|
||||
<Input
|
||||
disabled={!videoUnderstandingEnabled}
|
||||
type="number"
|
||||
value={field.value?.[1] || 3}
|
||||
onChange={e => field.onChange([field.value?.[0] || 3, +e.target.value])}
|
||||
className="w-16"
|
||||
/>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Alert
|
||||
closable
|
||||
type="error"
|
||||
message={
|
||||
<div>
|
||||
<strong>提示:</strong>
|
||||
<p>视频理解功能必须使用多模态模型。</p>
|
||||
</div>
|
||||
}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 备注 */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="extras"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<SectionHeader title="备注" tip="可在 Prompt 结尾附加自定义说明" />
|
||||
<Textarea placeholder="笔记需要罗列出 xxx 关键点…" {...field} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
{/* 笔记格式 */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="format"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<SectionHeader title="笔记格式" tip="选择要包含的笔记元素" />
|
||||
<CheckboxGroup
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
disabledMap={{
|
||||
link: platform === 'local',
|
||||
screenshot: !videoUnderstandingEnabled,
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 备注 */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="extras"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<SectionHeader title="备注" tip="可在 Prompt 结尾附加自定义说明" />
|
||||
<Textarea placeholder="笔记需要罗列出 xxx 关键点…" {...field} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
16
BillNote_frontend/src/pages/SettingPage/Downloader.tsx
Normal file
16
BillNote_frontend/src/pages/SettingPage/Downloader.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import Provider from '@/components/Form/modelForm/Provider.tsx'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import Options from '@/components/Form/DownloaderForm/Options.tsx'
|
||||
const Downloader = () => {
|
||||
return (
|
||||
<div className={'flex h-full bg-white'}>
|
||||
<div className={'flex-1/5 border-r border-neutral-200 p-2'}>
|
||||
<Options></Options>
|
||||
</div>
|
||||
<div className={'flex-4/5'}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default Downloader
|
||||
@@ -24,12 +24,12 @@ const Menu = () => {
|
||||
// path: '/settings/transcriber',
|
||||
// },
|
||||
// //下载配置
|
||||
// {
|
||||
// id: 'download',
|
||||
// name: '下载配置',
|
||||
// icon: <HardDriveDownload />,
|
||||
// path: '/settings/download',
|
||||
// },
|
||||
{
|
||||
id: 'download',
|
||||
name: '下载配置',
|
||||
icon: <HardDriveDownload />,
|
||||
path: '/settings/download',
|
||||
},
|
||||
// //其他配置
|
||||
// {
|
||||
// id: 'prompt',
|
||||
|
||||
9
BillNote_frontend/src/services/downloader.ts
Normal file
9
BillNote_frontend/src/services/downloader.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import request from '@/utils/request.ts'
|
||||
|
||||
export const getDownloaderCookie = async id => {
|
||||
return await request.get('/get_downloader_cookie/' + id)
|
||||
}
|
||||
|
||||
export const updateDownloaderCookie = async (data: { cookie: string; platform: any }) => {
|
||||
return await request.post('/update_downloader_cookie', data)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
from fastapi import FastAPI
|
||||
from .routers import note, provider,model
|
||||
from .routers import note, provider, model, config
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
@@ -7,4 +7,5 @@ def create_app() -> FastAPI:
|
||||
app.include_router(note.router, prefix="/api")
|
||||
app.include_router(provider.router, prefix="/api")
|
||||
app.include_router(model.router,prefix="/api")
|
||||
app.include_router(config.router, prefix="/api")
|
||||
return app
|
||||
|
||||
@@ -13,13 +13,14 @@ from app.downloaders.base import Downloader
|
||||
from app.downloaders.douyin_helper.abogus import ABogus
|
||||
from app.enmus.note_enums import DownloadQuality
|
||||
from app.models.audio_model import AudioDownloadResult
|
||||
from app.services.cookie_manager import CookieConfigManager
|
||||
from app.utils.path_helper import get_data_dir
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
DOUYIN_DOMAIN = "https://www.douyin.com"
|
||||
|
||||
|
||||
cfm=CookieConfigManager()
|
||||
def get_timestamp(unit: str = "milli"):
|
||||
"""
|
||||
根据给定的单位获取当前时间 (Get the current time based on the given unit)
|
||||
@@ -112,7 +113,7 @@ class DouyinDownloader(Downloader):
|
||||
def __init__(self, cookie=None):
|
||||
super().__init__()
|
||||
self.headers_config = DouyinConfig.HEADERS.copy()
|
||||
self.headers_config["Cookie"] = os.getenv('DOUYIN_COOKIES')
|
||||
self.headers_config["Cookie"] = cfm.get('douyin')
|
||||
print(self.headers_config)
|
||||
self.proxies_config = DouyinConfig.PROXIES.copy()
|
||||
self.ttwid_config = DouyinConfig.TTWID.copy()
|
||||
|
||||
97
backend/app/downloaders/kuaishou_downloader.py
Normal file
97
backend/app/downloaders/kuaishou_downloader.py
Normal file
@@ -0,0 +1,97 @@
|
||||
import os
|
||||
import subprocess
|
||||
from abc import ABC
|
||||
from typing import Union, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from app.downloaders.base import Downloader
|
||||
from app.downloaders.kuaishou_helper.kuaishou import KuaiShou
|
||||
from app.enmus.note_enums import DownloadQuality
|
||||
from app.models.audio_model import AudioDownloadResult
|
||||
from app.utils.path_helper import get_data_dir
|
||||
|
||||
|
||||
class KuaiShouDownloader(Downloader, ABC):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def download(
|
||||
self,
|
||||
video_url: str,
|
||||
output_dir: Union[str, None] = None,
|
||||
quality: str = "fast",
|
||||
need_video: Optional[bool] = False
|
||||
) -> AudioDownloadResult:
|
||||
if output_dir is None:
|
||||
output_dir = get_data_dir()
|
||||
if not output_dir:
|
||||
output_dir = self.cache_data
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
ks = KuaiShou()
|
||||
video_raw_info = ks.run(video_url)
|
||||
print(video_raw_info)
|
||||
photo_info = video_raw_info['visionVideoDetail']['photo']
|
||||
video_id = photo_info['id']
|
||||
title = photo_info['caption'].strip().replace('\n', '').replace(' ', '_')[:50]
|
||||
mp4_path = os.path.join(output_dir, f"{video_id}.mp4")
|
||||
mp3_path = os.path.join(output_dir, f"{video_id}.mp3")
|
||||
|
||||
if os.path.exists(mp3_path):
|
||||
print(f"[已存在] 跳过下载: {mp3_path}")
|
||||
return AudioDownloadResult(
|
||||
file_path=mp3_path,
|
||||
title=title,
|
||||
duration=photo_info['duration'],
|
||||
cover_url=photo_info['coverUrl'],
|
||||
platform="kuaishou",
|
||||
video_id=video_id,
|
||||
raw_info={
|
||||
'tags': ','.join(tag['name'] for tag in video_raw_info.get('tags', []) if tag.get('name'))
|
||||
},
|
||||
video_path=mp4_path
|
||||
)
|
||||
|
||||
# 下载 mp4 视频
|
||||
resp = requests.get(photo_info['photoUrl'], stream=True)
|
||||
if resp.status_code == 200:
|
||||
with open(mp4_path, "wb") as f:
|
||||
for chunk in resp.iter_content(1024 * 1024):
|
||||
f.write(chunk)
|
||||
else:
|
||||
raise Exception(f"视频下载失败: {resp.status_code}")
|
||||
|
||||
# 使用 ffmpeg 转换为 mp3
|
||||
try:
|
||||
subprocess.run([
|
||||
"ffmpeg", "-y", "-i", mp4_path, "-vn", "-acodec", "libmp3lame", mp3_path
|
||||
], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except subprocess.CalledProcessError:
|
||||
raise Exception("ffmpeg 转换 MP3 失败")
|
||||
|
||||
return AudioDownloadResult(
|
||||
file_path=mp3_path,
|
||||
title=photo_info['caption'],
|
||||
duration=photo_info['duration'],
|
||||
cover_url=photo_info['coverUrl'],
|
||||
platform="kuaishou",
|
||||
video_id=video_id,
|
||||
raw_info={
|
||||
'tags': ','.join(tag['name'] for tag in video_raw_info.get('tags', []) if tag.get('name'))
|
||||
},
|
||||
video_path=mp4_path
|
||||
)
|
||||
|
||||
def download_video(
|
||||
self,
|
||||
video_url: str,
|
||||
output_dir: Union[str, None] = None,
|
||||
) -> str:
|
||||
print('self.download(video_url, output_dir).video_path',self.download(video_url, output_dir).video_path)
|
||||
return self.download(video_url, output_dir).video_path
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
ks = KuaiShouDownloader()
|
||||
ks.download('https://v.kuaishou.com/2vBqX74 王宝强携手刘昊然、岳云鹏上演精彩名场面 全程高能 看一遍笑一遍 "唐探1900 "快成长计划 ...更多')
|
||||
0
backend/app/downloaders/kuaishou_helper/__init__.py
Normal file
0
backend/app/downloaders/kuaishou_helper/__init__.py
Normal file
101
backend/app/downloaders/kuaishou_helper/kuaishou.py
Normal file
101
backend/app/downloaders/kuaishou_helper/kuaishou.py
Normal file
@@ -0,0 +1,101 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from app.services.cookie_manager import CookieConfigManager
|
||||
from app.utils.logger import get_logger
|
||||
KUAISHOU_API_BASE = 'https://www.kuaishou.com/graphql'
|
||||
KUAISHOU_URL = "https://www.kuaishou.com/"
|
||||
load_dotenv()
|
||||
headers = {
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
# 'Cookie': 'did=web_9e8cfa4403000587b9e7d67233e6b04c; didv=1719811812378; kpf=PC_WEB; clientid=3; kpn=KUAISHOU_VISION',
|
||||
'Origin': 'https://www.kuaishou.com',
|
||||
'Pragma': 'no-cache',
|
||||
'Referer': 'https://www.kuaishou.com/',
|
||||
'Sec-Fetch-Dest': 'empty',
|
||||
'Sec-Fetch-Mode': 'cors',
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
|
||||
'accept': '*/*',
|
||||
'content-type': 'application/json',
|
||||
'sec-ch-ua': '"Not/A)Brand";v="8", "Chromium";v="126", "Google Chrome";v="126"',
|
||||
'sec-ch-ua-mobile': '?0',
|
||||
'sec-ch-ua-platform': '"Windows"',
|
||||
# 'Cookie':cookies.strip()
|
||||
}
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
cfm=CookieConfigManager()
|
||||
class KuaiShou:
|
||||
def __init__(self):
|
||||
self.header = headers.copy()
|
||||
self.cookie = None
|
||||
|
||||
@staticmethod
|
||||
def _extract_kuaishou_link(text):
|
||||
|
||||
url = re.findall('http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+', text)
|
||||
return url[0]
|
||||
|
||||
def get_photo_id(self, url):
|
||||
response = requests.get(url, allow_redirects=True, headers=self.header)
|
||||
real_url = response.url
|
||||
# 提取short—video/后面的id
|
||||
pattern = re.compile(r'short-video/(\w+)')
|
||||
match = pattern.search(real_url)
|
||||
return match.group().split('/')[1]
|
||||
|
||||
def get_temp_cookies(self):
|
||||
is_exist = cfm.get('kuaishou')
|
||||
print(is_exist)
|
||||
if is_exist:
|
||||
return is_exist
|
||||
res = requests.get(url=KUAISHOU_URL, headers=self.header, allow_redirects=True)
|
||||
cookie_string = '; '.join([f"{k}={v}" for k, v in res.cookies.get_dict().items()])
|
||||
return cookie_string
|
||||
|
||||
def get_video_details(self, url, photo_id):
|
||||
json_data = {
|
||||
'operationName': 'visionVideoDetail',
|
||||
"variables": {"photoId": photo_id, "page": "detail"},
|
||||
"query": "query visionVideoDetail($photoId: String, $type: String, $page: String, $webPageArea: String) {\n visionVideoDetail(photoId: $photoId, type: $type, page: $page, webPageArea: $webPageArea) {\n status\n type\n author {\n id\n name\n following\n headerUrl\n __typename\n }\n photo {\n id\n duration\n caption\n likeCount\n realLikeCount\n coverUrl\n photoUrl\n liked\n timestamp\n expTag\n llsid\n viewCount\n videoRatio\n stereoType\n croppedPhotoUrl\n manifest {\n mediaType\n businessType\n version\n adaptationSet {\n id\n duration\n representation {\n id\n defaultSelect\n backupUrl\n codecs\n url\n height\n width\n avgBitrate\n maxBitrate\n m3u8Slice\n qualityType\n qualityLabel\n frameRate\n featureP2sp\n hidden\n disableAdaptive\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n }\n tags {\n type\n name\n __typename\n }\n commentLimit {\n canAddComment\n __typename\n }\n llsid\n danmakuSwitch\n __typename\n }\n}\n"
|
||||
}
|
||||
response = requests.post(url=KUAISHOU_API_BASE, headers=self.header, json=json_data)
|
||||
if response.status_code == 200:
|
||||
response.raise_for_status()
|
||||
|
||||
return response.json()
|
||||
else:
|
||||
return None
|
||||
|
||||
def run(self, url):
|
||||
real_url = self._extract_kuaishou_link(url)
|
||||
if not real_url:
|
||||
logger.error(f"快手视频 URL 解析失败 {url}")
|
||||
|
||||
cookies = self.get_temp_cookies()
|
||||
if not cookies:
|
||||
logger.error(f"快手视频 cookies 解析失败 {url},请考虑设置环境变量 KUAISHOU_COOKIES")
|
||||
|
||||
self.header['Cookie'] = cookies.strip()
|
||||
photo_id = self.get_photo_id(real_url)
|
||||
if photo_id is None:
|
||||
logger.error(f"快手视频 ID 解析失败 {url}")
|
||||
video_details = self.get_video_details(real_url, photo_id)
|
||||
print(video_details)
|
||||
if video_details is None:
|
||||
logger.error(f"快手视频详情解析失败 {url}")
|
||||
return video_details['data']
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
ks = KuaiShou()
|
||||
ks.run(
|
||||
'https://v.kuaishou.com/2vBqX74 王宝强携手刘昊然、岳云鹏上演精彩名场面 全程高能 看一遍笑一遍 "唐探1900 "快成长计划 ...更多')
|
||||
30
backend/app/routers/config.py
Normal file
30
backend/app/routers/config.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from app.utils.response import ResponseWrapper as R
|
||||
|
||||
from app.services.cookie_manager import CookieConfigManager
|
||||
|
||||
router = APIRouter()
|
||||
cookie_manager = CookieConfigManager()
|
||||
|
||||
|
||||
class CookieUpdateRequest(BaseModel):
|
||||
platform: str
|
||||
cookie: str
|
||||
|
||||
|
||||
@router.get("/get_downloader_cookie/{platform}")
|
||||
def get_cookie(platform: str):
|
||||
cookie = cookie_manager.get(platform)
|
||||
if not cookie:
|
||||
return R.success(msg='未找到Cookies')
|
||||
return R.success(
|
||||
data={"platform": platform, "cookie": cookie}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/update_downloader_cookie")
|
||||
def update_cookie(data: CookieUpdateRequest):
|
||||
cookie_manager.set(data.platform, data.cookie)
|
||||
return {"message": "Cookie updated successfully"}
|
||||
@@ -1,5 +1,6 @@
|
||||
from app.downloaders.bilibili_downloader import BilibiliDownloader
|
||||
from app.downloaders.douyin_downloader import DouyinDownloader
|
||||
from app.downloaders.kuaishou_downloader import KuaiShouDownloader
|
||||
from app.downloaders.local_downloader import LocalDownloader
|
||||
from app.downloaders.youtube_downloader import YoutubeDownloader
|
||||
|
||||
@@ -7,6 +8,7 @@ SUPPORT_PLATFORM_MAP = {
|
||||
'youtube':YoutubeDownloader(),
|
||||
'bilibili':BilibiliDownloader(),
|
||||
'tiktok':DouyinDownloader(),
|
||||
'kuaishou':KuaiShouDownloader(),
|
||||
'douyin':DouyinDownloader(),
|
||||
'local':LocalDownloader()
|
||||
}
|
||||
44
backend/app/services/cookie_manager.py
Normal file
44
backend/app/services/cookie_manager.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict
|
||||
|
||||
|
||||
class CookieConfigManager:
|
||||
def __init__(self, filepath: str = "config/downloader.json"):
|
||||
self.path = Path(filepath)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not self.path.exists():
|
||||
self._write({})
|
||||
|
||||
def _read(self) -> Dict[str, Dict[str, str]]:
|
||||
try:
|
||||
with self.path.open("r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def _write(self, data: Dict[str, Dict[str, str]]):
|
||||
with self.path.open("w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def get(self, platform: str) -> Optional[str]:
|
||||
data = self._read()
|
||||
return data.get(platform, {}).get("cookie")
|
||||
|
||||
def set(self, platform: str, cookie: str):
|
||||
data = self._read()
|
||||
data[platform] = {"cookie": cookie}
|
||||
self._write(data)
|
||||
|
||||
def delete(self, platform: str):
|
||||
data = self._read()
|
||||
if platform in data:
|
||||
del data[platform]
|
||||
self._write(data)
|
||||
|
||||
def list_all(self) -> Dict[str, str]:
|
||||
data = self._read()
|
||||
return {k: v.get("cookie", "") for k, v in data.items()}
|
||||
|
||||
def exists(self, platform: str) -> bool:
|
||||
return self.get(platform) is not None
|
||||
@@ -1,24 +1,30 @@
|
||||
from pydantic import AnyUrl, validator, BaseModel
|
||||
from pydantic import AnyUrl, validator, BaseModel, field_validator
|
||||
import re
|
||||
|
||||
SUPPORTED_PLATFORMS = {
|
||||
"bilibili": r"(https?://)?(www\.)?bilibili\.com/video/[a-zA-Z0-9]+",
|
||||
"youtube": r"(https?://)?(www\.)?(youtube\.com/watch\?v=|youtu\.be/)[\w\-]+",
|
||||
"douyin": r"'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F])"
|
||||
|
||||
"douyin": "douyin",
|
||||
"kuaishou": "kuaishou"
|
||||
}
|
||||
|
||||
|
||||
|
||||
def is_supported_video_url(url: str) -> bool:
|
||||
return any(re.match(pattern, url) for pattern in SUPPORTED_PLATFORMS.values())
|
||||
for name, pattern in SUPPORTED_PLATFORMS.items():
|
||||
if pattern in ["douyin", "kuaishou"]:
|
||||
if pattern in url:
|
||||
return True
|
||||
else:
|
||||
if re.match(pattern, url):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class VideoRequest(BaseModel):
|
||||
url: AnyUrl
|
||||
platform: str
|
||||
|
||||
@validator("url")
|
||||
@field_validator("url")
|
||||
def validate_video_url(cls, v):
|
||||
if not is_supported_video_url(str(v)):
|
||||
raise ValueError("暂不支持该视频平台或链接格式无效")
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user