import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { firstValueFrom, from, Observable, throwError, timer } from 'rxjs'; import { catchError, map, retry, switchMap } from 'rxjs/operators'; import { DouyinVideo, VideoDownloadTask } from '../models/types'; import { environment } from '../../environments/environment'; import { AuthCreditService } from './auth-credit.service'; @Injectable({ providedIn: 'root' }) export class DouyinService { private readonly MAX_RETRIES = environment.douyinApi.maxRetries; constructor(private http: HttpClient, private authCredit: AuthCreditService) {} // 抖音话题搜索 V2 searchVideos( keyword: string, sortType: string = '0', cursor: number = 0, publishTime: string = '0', filterDuration: string = '0', contentType: string = '0', searchId: string = '', backtrace: string = '' ): Observable { const requestBody = { keyword: keyword || '', sort_type: sortType || '0', cursor: cursor, // 保持为数字,根据API文档示例 publish_time: publishTime || '0', filter_duration: filterDuration || '0', content_type: contentType || '0', search_id: searchId || '', backtrace: backtrace || '' }; return this.withCreditGate('douyin.search', 1, '视频发现', () => this.callDouyin('searchVideos', { payload: requestBody }).pipe( retry({ count: this.MAX_RETRIES, delay: (error, retryCount) => { console.log(`请求失败,第${retryCount}次重试...`, error); return timer(retryCount * 1000); // 递增延迟 } }), catchError(this.handleError) )); } // 获取视频详情 getVideoDetail(awemeId: string): Observable { return this.withCreditGate('douyin.videoDetail', 1, '视频详情', () => this.callDouyin('videoDetail', { params: { aweme_id: awemeId } }).pipe( retry(this.MAX_RETRIES), catchError(this.handleError) )); } getUserProfileByUniqueId(uniqueId: string): Observable { return this.withCreditGate('douyin.userProfile', 1, '博主资料', () => this.callDouyin('userProfileWeb', { params: { unique_id: uniqueId } }).pipe( retry(this.MAX_RETRIES), catchError(this.handleError) )); } getUserProfileBySecUserId(secUserId: string): Observable { return this.withCreditGate('douyin.userProfile', 1, '博主资料', () => this.callDouyin('userProfileApp', { params: { sec_user_id: secUserId } }).pipe( retry(this.MAX_RETRIES), catchError(this.handleError) )); } getUserPostVideos(secUserId: string, maxCursor: string = '0', count: number = 18, filterType: string = '0'): Observable { return this.withCreditGate('douyin.userPosts', 1, '博主作品', () => this.callDouyin('userPosts', { params: { sec_user_id: secUserId, max_cursor: maxCursor, count, filter_type: filterType } }).pipe( retry(this.MAX_RETRIES), catchError(this.handleError) )); } extractUserProfileData(payload: any): any { const normalizedResponse = this.parseJsonPayload(payload); const normalizedData = this.parseJsonPayload(normalizedResponse?.data); const nestedData = this.parseJsonPayload(normalizedData?.data); return this.parseJsonPayload( normalizedData?.user_info || normalizedData?.user || nestedData?.user_info || nestedData?.user || nestedData || normalizedData || null ); } extractUserPostVideos(payload: any): { items: any[]; hasMore: boolean; cursor: string } { const normalizedResponse = this.parseJsonPayload(payload); const normalizedData = this.parseJsonPayload(normalizedResponse?.data); const nestedData = this.parseJsonPayload(normalizedData?.data); const source = nestedData || normalizedData || normalizedResponse || {}; const items = Array.isArray(source?.aweme_list) ? source.aweme_list : Array.isArray(source?.items) ? source.items : []; const hasMore = source?.has_more === 1 || source?.has_more === true; const cursor = String(source?.max_cursor ?? source?.cursor ?? '0'); return { items, hasMore, cursor }; } extractVideoDetailData(videoData: any): any { const normalizedResponse = this.parseJsonPayload(videoData); const normalizedData = this.parseJsonPayload(normalizedResponse?.data); const nestedData = this.parseJsonPayload(normalizedData?.data); return this.parseJsonPayload( normalizedData?.aweme_detail || normalizedData?.aweme_info || normalizedData?.aweme_details?.[0] || nestedData?.aweme_detail || nestedData?.aweme_info || nestedData?.aweme_details?.[0] || nestedData || normalizedData || null ); } extractVideoPreviewUrl(videoData: any): string | null { const detailData = this.extractVideoDetailData(videoData); if (!detailData) { return null; } return this.pickPreferredUrl(this.collectPreviewUrls(detailData)); } extractVideoDownloadUrl(videoData: any): string | null { return this.pickPreferredUrl(this.extractVideoDownloadUrls(videoData)); } extractVideoDownloadUrls(videoData: any): string[] { const detailData = this.extractVideoDetailData(videoData); if (!detailData) { return []; } const downloadCandidates = [ ...(Array.isArray(detailData.download_addr?.url_list) ? detailData.download_addr.url_list : []), ...(Array.isArray(detailData.video?.download_addr?.url_list) ? detailData.video.download_addr.url_list : []), ...(Array.isArray(detailData.video?.download_suffix_logo_addr?.url_list) ? detailData.video.download_suffix_logo_addr.url_list : []) ]; return this.pickUniqueUrls([...downloadCandidates, ...this.collectPlayableUrls(detailData)]); } startManagedDownload(payload: { url: string; urls?: string[]; filename: string; title: string; description: string; tags: string[]; thumbnail: string; duration: number; resolution: string; awemeId: string; authorName: string; }): Observable { return this.http.post('/backend/api/download/video', payload); } getManagedDownloadTask(taskId: string): Observable { return this.http.get(`/backend/api/download/video/${taskId}`); } private collectPreviewUrls(detailData: any): string[] { const prioritizedUrls = [ ...(Array.isArray(detailData.video?.play_addr_lowbr?.url_list) ? detailData.video.play_addr_lowbr.url_list : []), ...(Array.isArray(detailData.video?.play_addr_h264?.url_list) ? detailData.video.play_addr_h264.url_list : []), ...(Array.isArray(detailData.video?.play_addr?.url_list) ? detailData.video.play_addr.url_list : []), ...(Array.isArray(detailData.video?.play_addr_265?.url_list) ? detailData.video.play_addr_265.url_list : []) ]; return this.pickUniqueUrls([...prioritizedUrls, ...this.collectPlayableUrls(detailData)]); } private collectPlayableUrls(detailData: any): string[] { const bitRateUrls = Array.isArray(detailData.video?.bit_rate) ? detailData.video.bit_rate.reduce((urls: string[], item: any) => { const currentUrls = Array.isArray(item?.play_addr?.url_list) ? item.play_addr.url_list : []; return urls.concat(currentUrls); }, []) : []; const directUrls = [ ...(Array.isArray(detailData.video?.play_addr?.url_list) ? detailData.video.play_addr.url_list : []), ...(Array.isArray(detailData.video?.play_addr_h264?.url_list) ? detailData.video.play_addr_h264.url_list : []), ...(Array.isArray(detailData.video?.play_addr_265?.url_list) ? detailData.video.play_addr_265.url_list : []), ...(Array.isArray(detailData.video?.play_addr_lowbr?.url_list) ? detailData.video.play_addr_lowbr.url_list : []), ...(Array.isArray(detailData.video?.download_addr?.url_list) ? detailData.video.download_addr.url_list : []), ...(Array.isArray(detailData.download_addr?.url_list) ? detailData.download_addr.url_list : []), detailData.video?.play_addr?.uri, detailData.video?.download_addr?.uri ]; return this.pickUniqueUrls([...directUrls, ...bitRateUrls]); } private pickUniqueUrls(urls: any[]): string[] { return urls.filter((url, index, list) => ( typeof url === 'string' && /^https?:\/\//i.test(url) && list.indexOf(url) === index )); } private pickPreferredUrl(urls: any[]): string | null { const validUrls = urls.filter(url => typeof url === 'string' && url.trim() !== ''); const preferredUrl = validUrls.find(url => !/watermark|playwm|download_suffix_logo/i.test(url)); return preferredUrl || validUrls[0] || null; } private parseJsonPayload(value: any): any { let current = value; while (typeof current === 'string') { const trimmed = current.trim(); if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) { break; } try { current = JSON.parse(trimmed); } catch { break; } } return current; } // 下载视频到本地(改进版本,支持进度跟踪) async downloadVideo(videoUrl: string, filename: string, onProgress?: (progress: number) => void): Promise { try { const response = await fetch(videoUrl, { method: 'GET', headers: { 'Accept': 'video/mp4,*/*', 'Cache-Control': 'no-cache' } }); if (!response.ok) { throw new Error(`下载失败: ${response.status} ${response.statusText}`); } const contentLength = response.headers.get('content-length'); const totalBytes = contentLength ? parseInt(contentLength, 10) : 0; const reader = response.body?.getReader(); if (!reader) { throw new Error('无法读取视频流'); } const chunks: Uint8Array[] = []; let receivedBytes = 0; while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); receivedBytes += value.length; if (onProgress && totalBytes > 0) { const progress = Math.round((receivedBytes / totalBytes) * 100); onProgress(progress); } } // 合并所有数据块 const blob = new Blob(chunks as BlobPart[], { type: 'video/mp4' }); // 创建下载链接 const url = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; link.style.display = 'none'; document.body.appendChild(link); link.click(); // 清理 setTimeout(() => { document.body.removeChild(link); window.URL.revokeObjectURL(url); }, 100); if (onProgress) { onProgress(100); } } catch (error) { console.error('视频下载错误:', error); throw error; } } // 从URL中提取aweme_id(增强版本) extractAwemeId(url: string): string | null { if (!url) return null; // 处理多种抖音链接格式 const patterns = [ /douyin\.com\/video\/(\d+)/, // https://www.douyin.com/video/1234567890 /douyin\.com\/\w+\/(\d+)/, // https://www.douyin.com/user/123/71891234567890 /aweme_id=(\d+)/, // aweme_id=1234567890 /^(\d+)$/, // 纯数字ID /douyin\.com\/\?modal_id=(\d+)/, // 分享链接格式 /v\.douyin\.com\/\w+\?id=(\d+)/, // 短链接格式 /douyin\.com\/share\/video\/(\d+)/ // 分享页面格式 ]; for (const pattern of patterns) { const match = url.match(pattern); if (match) { return match[1]; } } return null; } // 批量获取视频信息 getBatchVideoDetails(awemeIds: string[]): Observable[] { return awemeIds.map(id => this.getVideoDetail(id)); } // 验证视频数据完整性 validateVideoData(videoData: any): boolean { if (!videoData || !videoData.data) { return false; } const data = videoData.data; return !!(data.aweme_id && data.desc !== undefined && data.author); } // 格式化视频信息用于显示 formatVideoInfo(videoData: DouyinVideo): DouyinVideo { return { ...videoData, statistics: { digg_count: videoData.statistics?.digg_count || 0, play_count: videoData.statistics?.play_count || 0, comment_count: videoData.statistics?.comment_count || 0, share_count: videoData.statistics?.share_count || 0 }, author: { nickname: videoData.author?.nickname || '未知作者', sec_uid: videoData.author?.sec_uid || '', avatar_thumb: videoData.author?.avatar_thumb } }; } // 生成安全的文件名 generateSafeFilename(video: DouyinVideo): string { const author = video.author?.nickname || '未知作者'; const title = video.desc || `视频_${video.aweme_id}`; const safeTitle = title .replace(/[<>:"/\\|?*]/g, '_') // 移除Windows文件名非法字符 .replace(/\s+/g, '_') // 空格替换为下划线 .substring(0, 50); // 限制标题长度 return `${author}_${safeTitle}_${video.aweme_id}.mp4`; } private callDouyin(route: string, params: { payload?: Record; params?: Record }): Observable { return from(this.callDouyinLocalFirst(route, params)); } private async callDouyinLocalFirst(route: string, params: { payload?: Record; params?: Record }): Promise { try { const local = await firstValueFrom(this.http.post<{ success?: boolean; data?: any; error?: string }>('/backend/api/douyin/call', { route, ...params, })); if (local?.success) return local.data; throw new Error(local?.error || '本地抖音数据代理返回失败'); } catch (localError) { const message = this.readLocalProxyError(localError); throw new Error(`本地抖音数据代理调用失败,请确认已启动 npm run server:${message}`); } } private readLocalProxyError(error: any): string { return error?.error?.error || error?.error?.message || error?.message || '本地抖音数据代理不可用'; } private withCreditGate( operation: string, cost: number, title: string, run: () => Observable ): Observable { if (!this.authCredit.isLoggedIn) { this.authCredit.requestLogin(title); return throwError(() => new Error('请先登录后使用该功能')); } let reservationId = ''; return from(this.authCredit.reserveCredit(operation, cost, title)).pipe( switchMap((reserved) => { reservationId = reserved.reservationId; return run().pipe( switchMap((value) => from(this.authCredit.commitReservation(reservationId, { title })).pipe(map(() => value))), catchError((err) => from(this.authCredit.refundReservation(reservationId, err?.message || '任务失败')).pipe( switchMap(() => throwError(() => err)) )) ); }) ); } // 错误处理 private handleError(error: any): Observable { let errorMessage = ''; if (error.error instanceof ErrorEvent) { // 客户端错误 errorMessage = `网络错误: ${error.error.message}`; } else { // 服务端错误 switch (error.status) { case 401: errorMessage = '认证失败 (401): Token无效或已过期'; break; case 403: errorMessage = '访问被拒绝 (403): 权限不足'; break; case 429: errorMessage = '请求过于频繁 (429): 请稍后重试'; break; case 500: errorMessage = '服务器内部错误 (500): 请稍后重试'; break; case 400: errorMessage = '请求参数错误 (400): 请检查请求参数格式'; break; case 0: errorMessage = '网络连接失败: 请检查网络设置'; break; default: errorMessage = `请求失败 (${error.status}): ${error.message || '未知错误'}`; } // 检查API返回的具体错误信息 if (error.error) { console.log('🔍 详细错误信息:', { status: error.status, statusText: error.statusText, error: error.error, message: error.message, url: error.url }); if (error.error.msg) { errorMessage += ` | API错误: ${error.error.msg}`; } if (error.error.data) { errorMessage += ` | 错误数据: ${JSON.stringify(error.error.data)}`; } } } console.error('Douyin API Error:', errorMessage, error); return throwError(() => new Error(errorMessage)); } }