| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477 |
- 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<any> {
- 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<any> {
- 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<any> {
- 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<any> {
- 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<any> {
- 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<any> {
- return this.http.post('/backend/api/download/video', payload);
- }
- getManagedDownloadTask(taskId: string): Observable<any> {
- 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<void> {
- 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<any>[] {
- 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<never> {
- 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));
- }
- }
|