import { Injectable } from '@angular/core'; import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Observable, throwError, timer } from 'rxjs'; import { catchError, retry } from 'rxjs/operators'; import { DouyinVideo, VideoDownloadTask } from '../models/types'; import { environment } from '../../environments/environment'; @Injectable({ providedIn: 'root' }) export class DouyinService { private readonly baseUrl = environment.douyinApi.baseUrl; private readonly TIMEOUT = environment.douyinApi.timeout; private readonly MAX_RETRIES = environment.douyinApi.maxRetries; constructor(private http: HttpClient) {} // 抖音话题搜索 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 || '' }; console.log('🔍 发送搜索请求:', { url: `${this.baseUrl}/api/v1/douyin/search/fetch_general_search_v2`, body: requestBody, headers: this.getHeaders() }); return this.http.post(`${this.baseUrl}/api/v1/douyin/search/fetch_general_search_v2`, requestBody, { headers: this.getHeaders(), timeout: this.TIMEOUT }).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.http.get(`${this.baseUrl}/api/v1/douyin/app/v3/fetch_one_video_v3`, { params: { aweme_id: awemeId }, headers: this.getHeaders(), timeout: this.TIMEOUT }).pipe( retry(this.MAX_RETRIES), catchError(this.handleError) ); } getUserProfileByUniqueId(uniqueId: string): Observable { return this.http.get(`${this.baseUrl}/api/v1/douyin/web/handler_user_profile_v2`, { params: { unique_id: uniqueId }, headers: this.getHeaders(), timeout: this.TIMEOUT }).pipe( retry(this.MAX_RETRIES), catchError(this.handleError) ); } getUserProfileBySecUserId(secUserId: string): Observable { return this.http.get(`${this.baseUrl}/api/v1/douyin/app/v3/handler_user_profile`, { params: { sec_user_id: secUserId }, headers: this.getHeaders(), timeout: this.TIMEOUT }).pipe( retry(this.MAX_RETRIES), catchError(this.handleError) ); } getUserPostVideos(secUserId: string, maxCursor: string = '0', count: number = 18, filterType: string = '0'): Observable { return this.http.get(`${this.baseUrl}/api/v1/douyin/web/fetch_user_post_videos`, { params: { sec_user_id: secUserId, max_cursor: maxCursor, count, filter_type: filterType }, headers: this.getHeaders(), timeout: this.TIMEOUT }).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 getHeaders(): HttpHeaders { return new HttpHeaders({ 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': `Bearer ${environment.douyinApi.token}` }); } // 错误处理 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)); } }