Merge pull request #83 from JefferyHcool/feature/kuaishou

feat(download): 添加快手下载器并优化下载配置功能
This commit is contained in:
Jianwu Huang
2025-05-08 18:16:37 +08:00
committed by GitHub
21 changed files with 1106 additions and 413 deletions
+7 -2
View File
@@ -13,6 +13,8 @@ import StepBar from '@/pages/HomePage/components/StepBar.tsx'
import Downloading from '@/components/Lottie/download.tsx' import Downloading from '@/components/Lottie/download.tsx'
import Prompt from '@/pages/SettingPage/Prompt.tsx' import Prompt from '@/pages/SettingPage/Prompt.tsx'
import AboutPage from '@/pages/SettingPage/about.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() { function App() {
useTaskPolling(3000) // 每 3 秒轮询一次 useTaskPolling(3000) // 每 3 秒轮询一次
const steps = [ const steps = [
@@ -35,8 +37,11 @@ function App() {
{/*<Route index element={<Navigate to="openai" replace />} />*/} {/*<Route index element={<Navigate to="openai" replace />} />*/}
<Route path=":id" element={<ProviderForm />} /> <Route path=":id" element={<ProviderForm />} />
</Route> </Route>
{/*<Route path="transcriber" elment={<Transcriber />}></Route>*/} {/*<Route path="transcriber" element={<Transcriber />}></Route>*/}
<Route path="prompt" element={<Prompt />}></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="about" element={<AboutPage />}></Route>
<Route path="*" element={<NotFoundPage />} /> <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
@@ -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>
)
}
+16
View File
@@ -1,4 +1,12 @@
/* -------------------- 常量 -------------------- */ /* -------------------- 常量 -------------------- */
import {
BiliBiliLogo,
DouyinLogo,
KuaishouLogo,
LocalLogo,
YoutubeLogo,
} from '@/components/Icons/platform.tsx'
export const noteFormats = [ export const noteFormats = [
{ label: '目录', value: 'toc' }, { label: '目录', value: 'toc' },
{ label: '原片跳转', value: 'link' }, { label: '原片跳转', value: 'link' },
@@ -17,3 +25,11 @@ export const noteStyles = [
{ label: '商业风格', value: 'business' }, { label: '商业风格', value: 'business' },
{ label: '会议纪要', value: 'meeting_minutes' }, { label: '会议纪要', value: 'meeting_minutes' },
] as const ] 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,30 +1,46 @@
/* NoteForm.tsx ---------------------------------------------------- */ /* NoteForm.tsx ---------------------------------------------------- */
import { import {
Form, FormControl, FormField, FormItem, FormLabel, FormMessage, Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form.tsx' } from '@/components/ui/form.tsx'
import { useEffect } from 'react' import { useEffect } from 'react'
import { useForm, useWatch } from 'react-hook-form' import { useForm, useWatch } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { z } from '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 { message, Alert } from 'antd'
import { generateNote } from '@/services/note.ts' import { generateNote } from '@/services/note.ts'
import { uploadFile } from '@/services/upload.ts' import { uploadFile } from '@/services/upload.ts'
import { useTaskStore } from '@/store/taskStore' import { useTaskStore } from '@/store/taskStore'
import { useModelStore } from '@/store/modelStore' import { useModelStore } from '@/store/modelStore'
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip.tsx"; import {
import {Checkbox} from "@/components/ui/checkbox.tsx"; Tooltip,
import {ScrollArea} from "@/components/ui/scroll-area.tsx"; TooltipContent,
import {Button} from "@/components/ui/button.tsx"; TooltipProvider,
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select.tsx"; TooltipTrigger,
import {Input} from "@/components/ui/input.tsx"; } from '@/components/ui/tooltip.tsx'
import {Textarea} from "@/components/ui/textarea.tsx"; import { Checkbox } from '@/components/ui/checkbox.tsx'
import {noteStyles,noteFormats} from "@/constant/note.ts"; 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 -------------------- */ /* -------------------- 校验 Schema -------------------- */
const formSchema = z.object({ const formSchema = z
.object({
video_url: z.string(), video_url: z.string(),
platform: z.string().nonempty('请选择平台'), platform: z.string().nonempty('请选择平台'),
quality: z.enum(['fast', 'medium', 'slow']), quality: z.enum(['fast', 'medium', 'slow']),
@@ -36,11 +52,12 @@ const formSchema = z.object({
extras: z.string().optional(), extras: z.string().optional(),
video_understanding: z.boolean().optional(), video_understanding: z.boolean().optional(),
video_interval: z.coerce.number().min(1).max(30).default(4).optional(), video_interval: z.coerce.number().min(1).max(30).default(4).optional(),
grid_size: z.tuple([ grid_size: z
z.coerce.number().min(1).max(10), .tuple([z.coerce.number().min(1).max(10), z.coerce.number().min(1).max(10)])
z.coerce.number().min(1).max(10), .default([3, 3])
]).default([3, 3]).optional(), .optional(),
}).superRefine(({ video_url, platform }, ctx) => { })
.superRefine(({ video_url, platform }, ctx) => {
if (platform === 'local' || platform === 'douyin') { if (platform === 'local' || platform === 'douyin') {
if (!video_url) { if (!video_url) {
ctx.addIssue({ code: 'custom', message: '本地视频路径不能为空', path: ['video_url'] }) ctx.addIssue({ code: 'custom', message: '本地视频路径不能为空', path: ['video_url'] })
@@ -53,7 +70,7 @@ const formSchema = z.object({
ctx.addIssue({ code: 'custom', message: '请输入正确的视频链接', path: ['video_url'] }) ctx.addIssue({ code: 'custom', message: '请输入正确的视频链接', path: ['video_url'] })
} }
} }
}) })
type NoteFormValues = z.infer<typeof formSchema> type NoteFormValues = z.infer<typeof formSchema>
@@ -75,8 +92,10 @@ const SectionHeader = ({ title, tip }: { title: string; tip?: string }) => (
) )
const CheckboxGroup = ({ const CheckboxGroup = ({
value = [], onChange, disabledMap, value = [],
}: { onChange,
disabledMap,
}: {
value?: string[] value?: string[]
onChange: (v: string[]) => void onChange: (v: string[]) => void
disabledMap: Record<string, boolean> disabledMap: Record<string, boolean>
@@ -88,7 +107,8 @@ const CheckboxGroup = ({
checked={value.includes(v)} checked={value.includes(v)}
disabled={disabledMap[v]} disabled={disabledMap[v]}
onCheckedChange={checked => onCheckedChange={checked =>
onChange(checked ? [...value, v] : value.filter(x => x !== v))} onChange(checked ? [...value, v] : value.filter(x => x !== v))
}
/> />
<span>{label}</span> <span>{label}</span>
</label> </label>
@@ -99,10 +119,10 @@ const CheckboxGroup = ({
/* -------------------- 主组件 -------------------- */ /* -------------------- 主组件 -------------------- */
const NoteForm = () => { const NoteForm = () => {
/* ---- 全局状态 ---- */ /* ---- 全局状态 ---- */
const { addPendingTask, currentTaskId, setCurrentTask,getCurrentTask ,retryTask} = useTaskStore() const { addPendingTask, currentTaskId, setCurrentTask, getCurrentTask, retryTask } =
useTaskStore()
const { loadEnabledModels, modelList, showFeatureHint, setShowFeatureHint } = useModelStore() const { loadEnabledModels, modelList, showFeatureHint, setShowFeatureHint } = useModelStore()
/* ---- 表单 ---- */ /* ---- 表单 ---- */
const form = useForm<NoteFormValues>({ const form = useForm<NoteFormValues>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
@@ -127,23 +147,20 @@ const NoteForm = () => {
useEffect(() => { useEffect(() => {
loadEnabledModels() loadEnabledModels()
return}, []) return
}, [])
useEffect(() => { useEffect(() => {
const currentTask = getCurrentTask() const currentTask = getCurrentTask()
const { formData } = currentTask || {} const { formData } = currentTask || {}
if (!currentTask) return if (!currentTask) return
form.reset( form.reset({
{
...formData, ...formData,
extras: formData?.extras || '', extras: formData?.extras || '',
} })
)
}, [currentTaskId]) }, [currentTaskId])
/* ---- 帮助函数 ---- */ /* ---- 帮助函数 ---- */
const isGenerating = () => const isGenerating = () => !['SUCCESS', 'FAILED', undefined].includes(getCurrentTask()?.status)
!['SUCCESS', 'FAILED', undefined].includes(getCurrentTask()?.status)
const generating = isGenerating() const generating = isGenerating()
const handleFileUpload = async (file: File, cb: (url: string) => void) => { const handleFileUpload = async (file: File, cb: (url: string) => void) => {
const formData = new FormData() const formData = new FormData()
@@ -158,14 +175,13 @@ const NoteForm = () => {
} }
const onSubmit = async (values: NoteFormValues) => { const onSubmit = async (values: NoteFormValues) => {
const payload: NoteFormValues = {
const payload:NoteFormValues = {
...values, ...values,
provider_id: modelList.find(m => m.model_name === values.model_name)!.provider_id, provider_id: modelList.find(m => m.model_name === values.model_name)!.provider_id,
task_id: currentTaskId || '', task_id: currentTaskId || '',
} }
if (currentTaskId){ if (currentTaskId) {
retryTask(currentTaskId,payload) retryTask(currentTaskId, payload)
return return
} }
@@ -177,33 +193,23 @@ const NoteForm = () => {
// 🔁 这里清空当前任务状态 // 🔁 这里清空当前任务状态
// 比如调用 resetCurrentTask() 或者 navigate 到一个新页面 // 比如调用 resetCurrentTask() 或者 navigate 到一个新页面
setCurrentTask(null) setCurrentTask(null)
} }
const FormButton = () => { const FormButton = () => {
const label = generating ? '正在生成…' : editing ? '重新生成' : '生成笔记'
const label = generating
? '正在生成…'
: editing
? '重新生成'
: '生成笔记'
return ( return (
<div className="flex gap-2"> <div className="flex gap-2">
<Button type="submit" className={!editing?'w-full':'w-2/3'+" bg-primary"} disabled={generating}> <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" />} {generating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{label} {label}
</Button> </Button>
{editing && ( {editing && (
<Button <Button type="button" variant="outline" className="w-1/3" onClick={handleCreateNew}>
type="button"
variant="outline"
className="w-1/3"
onClick={handleCreateNew}
>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
</Button> </Button>
@@ -230,15 +236,26 @@ const NoteForm = () => {
name="platform" name="platform"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<Select disabled={!!editing} value={field.value} onValueChange={field.onChange} defaultValue={field.value}> <Select
disabled={!!editing}
value={field.value}
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl> <FormControl>
<SelectTrigger className="w-32"><SelectValue /></SelectTrigger> <SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
</FormControl> </FormControl>
<SelectContent > <SelectContent>
<SelectItem value="bilibili"></SelectItem> {videoPlatforms?.map(p => (
<SelectItem value="youtube">YouTube</SelectItem> <SelectItem key={p.value} value={p.value}>
<SelectItem value="douyin"></SelectItem> <div className="flex items-center justify-center gap-2">
<SelectItem value="local"></SelectItem> <div className="h-4 w-4">{p.logo()}</div>
<span>{p.label}</span>
</div>
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage /> <FormMessage />
@@ -254,7 +271,6 @@ const NoteForm = () => {
{platform === 'local' ? ( {platform === 'local' ? (
<> <>
<Input disabled={!!editing} placeholder="请输入本地视频路径" {...field} /> <Input disabled={!!editing} placeholder="请输入本地视频路径" {...field} />
</> </>
) : ( ) : (
<Input disabled={!!editing} placeholder="请输入视频网站链接" {...field} /> <Input disabled={!!editing} placeholder="请输入视频网站链接" {...field} />
@@ -263,7 +279,6 @@ const NoteForm = () => {
</FormItem> </FormItem>
)} )}
/> />
</div> </div>
<FormField <FormField
@@ -275,7 +290,10 @@ const NoteForm = () => {
<> <>
<div <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" 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() }} onDragOver={e => {
e.preventDefault()
e.stopPropagation()
}}
onDrop={e => { onDrop={e => {
e.preventDefault() e.preventDefault()
const file = e.dataTransfer.files?.[0] const file = e.dataTransfer.files?.[0]
@@ -297,7 +315,6 @@ const NoteForm = () => {
<span className="text-xs text-gray-400"></span> <span className="text-xs text-gray-400"></span>
</p> </p>
</div> </div>
</> </>
)} )}
<FormMessage /> <FormMessage />
@@ -305,7 +322,6 @@ const NoteForm = () => {
)} )}
/> />
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{/* 模型选择 */} {/* 模型选择 */}
<FormField <FormField
className="w-full" className="w-full"
@@ -314,9 +330,15 @@ const NoteForm = () => {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<SectionHeader title="模型选择" tip="不同模型效果不同,建议自行测试" /> <SectionHeader title="模型选择" tip="不同模型效果不同,建议自行测试" />
<Select value={field.value} onValueChange={field.onChange} defaultValue={field.value}> <Select
value={field.value}
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl> <FormControl>
<SelectTrigger className="w-full min-w-0 truncate" ><SelectValue /></SelectTrigger> <SelectTrigger className="w-full min-w-0 truncate">
<SelectValue />
</SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
{modelList.map(m => ( {modelList.map(m => (
@@ -338,13 +360,21 @@ const NoteForm = () => {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<SectionHeader title="笔记风格" tip="选择生成笔记的呈现风格" /> <SectionHeader title="笔记风格" tip="选择生成笔记的呈现风格" />
<Select value={field.value} onValueChange={field.onChange} defaultValue={field.value}> <Select
value={field.value}
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl> <FormControl>
<SelectTrigger className="w-full min-w-0 truncate" ><SelectValue /></SelectTrigger> <SelectTrigger className="w-full min-w-0 truncate">
<SelectValue />
</SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
{noteStyles.map(({ label, value }) => ( {noteStyles.map(({ label, value }) => (
<SelectItem key={value} value={value}>{label}</SelectItem> <SelectItem key={value} value={value}>
{label}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
@@ -356,7 +386,6 @@ const NoteForm = () => {
{/* 视频理解 */} {/* 视频理解 */}
<SectionHeader title="视频理解" tip="将视频截图发给多模态模型辅助分析" /> <SectionHeader title="视频理解" tip="将视频截图发给多模态模型辅助分析" />
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<FormField <FormField
control={form.control} control={form.control}
name="video_understanding" name="video_understanding"
@@ -382,11 +411,7 @@ const NoteForm = () => {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel></FormLabel>
<Input <Input disabled={!videoUnderstandingEnabled} type="number" {...field} />
disabled={!videoUnderstandingEnabled}
type="number"
{...field}
/>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@@ -403,8 +428,7 @@ const NoteForm = () => {
disabled={!videoUnderstandingEnabled} disabled={!videoUnderstandingEnabled}
type="number" type="number"
value={field.value?.[0] || 3} value={field.value?.[0] || 3}
onChange={e => onChange={e => field.onChange([+e.target.value, field.value?.[1] || 3])}
field.onChange([+e.target.value, field.value?.[1] || 3])}
className="w-16" className="w-16"
/> />
<span>x</span> <span>x</span>
@@ -412,8 +436,7 @@ const NoteForm = () => {
disabled={!videoUnderstandingEnabled} disabled={!videoUnderstandingEnabled}
type="number" type="number"
value={field.value?.[1] || 3} value={field.value?.[1] || 3}
onChange={e => onChange={e => field.onChange([field.value?.[0] || 3, +e.target.value])}
field.onChange([field.value?.[0] || 3, +e.target.value])}
className="w-16" className="w-16"
/> />
</div> </div>
@@ -429,15 +452,12 @@ const NoteForm = () => {
<div> <div>
<strong></strong> <strong></strong>
<p>使</p> <p>使</p>
</div> </div>
} }
className="text-sm" className="text-sm"
/> />
</div> </div>
{/* 笔记格式 */} {/* 笔记格式 */}
<FormField <FormField
control={form.control} control={form.control}
@@ -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', // path: '/settings/transcriber',
// }, // },
// //下载配置 // //下载配置
// { {
// id: 'download', id: 'download',
// name: '下载配置', name: '下载配置',
// icon: <HardDriveDownload />, icon: <HardDriveDownload />,
// path: '/settings/download', path: '/settings/download',
// }, },
// //其他配置 // //其他配置
// { // {
// id: 'prompt', // id: 'prompt',
@@ -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)
}
+2 -1
View File
@@ -1,5 +1,5 @@
from fastapi import FastAPI from fastapi import FastAPI
from .routers import note, provider,model from .routers import note, provider, model, config
def create_app() -> FastAPI: def create_app() -> FastAPI:
@@ -7,4 +7,5 @@ def create_app() -> FastAPI:
app.include_router(note.router, prefix="/api") app.include_router(note.router, prefix="/api")
app.include_router(provider.router, prefix="/api") app.include_router(provider.router, prefix="/api")
app.include_router(model.router,prefix="/api") app.include_router(model.router,prefix="/api")
app.include_router(config.router, prefix="/api")
return app return app
+3 -2
View File
@@ -13,13 +13,14 @@ from app.downloaders.base import Downloader
from app.downloaders.douyin_helper.abogus import ABogus from app.downloaders.douyin_helper.abogus import ABogus
from app.enmus.note_enums import DownloadQuality from app.enmus.note_enums import DownloadQuality
from app.models.audio_model import AudioDownloadResult from app.models.audio_model import AudioDownloadResult
from app.services.cookie_manager import CookieConfigManager
from app.utils.path_helper import get_data_dir from app.utils.path_helper import get_data_dir
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
DOUYIN_DOMAIN = "https://www.douyin.com" DOUYIN_DOMAIN = "https://www.douyin.com"
cfm=CookieConfigManager()
def get_timestamp(unit: str = "milli"): def get_timestamp(unit: str = "milli"):
""" """
根据给定的单位获取当前时间 (Get the current time based on the given unit) 根据给定的单位获取当前时间 (Get the current time based on the given unit)
@@ -112,7 +113,7 @@ class DouyinDownloader(Downloader):
def __init__(self, cookie=None): def __init__(self, cookie=None):
super().__init__() super().__init__()
self.headers_config = DouyinConfig.HEADERS.copy() 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) print(self.headers_config)
self.proxies_config = DouyinConfig.PROXIES.copy() self.proxies_config = DouyinConfig.PROXIES.copy()
self.ttwid_config = DouyinConfig.TTWID.copy() self.ttwid_config = DouyinConfig.TTWID.copy()
@@ -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,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
View 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"}
+2
View File
@@ -1,5 +1,6 @@
from app.downloaders.bilibili_downloader import BilibiliDownloader from app.downloaders.bilibili_downloader import BilibiliDownloader
from app.downloaders.douyin_downloader import DouyinDownloader from app.downloaders.douyin_downloader import DouyinDownloader
from app.downloaders.kuaishou_downloader import KuaiShouDownloader
from app.downloaders.local_downloader import LocalDownloader from app.downloaders.local_downloader import LocalDownloader
from app.downloaders.youtube_downloader import YoutubeDownloader from app.downloaders.youtube_downloader import YoutubeDownloader
@@ -7,6 +8,7 @@ SUPPORT_PLATFORM_MAP = {
'youtube':YoutubeDownloader(), 'youtube':YoutubeDownloader(),
'bilibili':BilibiliDownloader(), 'bilibili':BilibiliDownloader(),
'tiktok':DouyinDownloader(), 'tiktok':DouyinDownloader(),
'kuaishou':KuaiShouDownloader(),
'douyin':DouyinDownloader(), 'douyin':DouyinDownloader(),
'local':LocalDownloader() 'local':LocalDownloader()
} }
+44
View 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
+12 -6
View File
@@ -1,24 +1,30 @@
from pydantic import AnyUrl, validator, BaseModel from pydantic import AnyUrl, validator, BaseModel, field_validator
import re import re
SUPPORTED_PLATFORMS = { SUPPORTED_PLATFORMS = {
"bilibili": r"(https?://)?(www\.)?bilibili\.com/video/[a-zA-Z0-9]+", "bilibili": r"(https?://)?(www\.)?bilibili\.com/video/[a-zA-Z0-9]+",
"youtube": r"(https?://)?(www\.)?(youtube\.com/watch\?v=|youtu\.be/)[\w\-]+", "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: 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): class VideoRequest(BaseModel):
url: AnyUrl url: AnyUrl
platform: str platform: str
@validator("url") @field_validator("url")
def validate_video_url(cls, v): def validate_video_url(cls, v):
if not is_supported_video_url(str(v)): if not is_supported_video_url(str(v)):
raise ValueError("暂不支持该视频平台或链接格式无效") raise ValueError("暂不支持该视频平台或链接格式无效")
Binary file not shown.