douyin.service.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477
  1. import { Injectable } from '@angular/core';
  2. import { HttpClient, HttpHeaders } from '@angular/common/http';
  3. import { Observable, throwError, timer } from 'rxjs';
  4. import { catchError, retry } from 'rxjs/operators';
  5. import { DouyinVideo, VideoDownloadTask } from '../models/types';
  6. import { environment } from '../../environments/environment';
  7. @Injectable({ providedIn: 'root' })
  8. export class DouyinService {
  9. private readonly baseUrl = environment.douyinApi.baseUrl;
  10. private readonly TIMEOUT = environment.douyinApi.timeout;
  11. private readonly MAX_RETRIES = environment.douyinApi.maxRetries;
  12. constructor(private http: HttpClient) {}
  13. // 抖音话题搜索 V2
  14. searchVideos(
  15. keyword: string,
  16. sortType: string = '0',
  17. cursor: number = 0,
  18. publishTime: string = '0',
  19. filterDuration: string = '0',
  20. contentType: string = '0',
  21. searchId: string = '',
  22. backtrace: string = ''
  23. ): Observable<any> {
  24. const requestBody = {
  25. keyword: keyword || '',
  26. sort_type: sortType || '0',
  27. cursor: cursor, // 保持为数字,根据API文档示例
  28. publish_time: publishTime || '0',
  29. filter_duration: filterDuration || '0',
  30. content_type: contentType || '0',
  31. search_id: searchId || '',
  32. backtrace: backtrace || ''
  33. };
  34. console.log('🔍 发送搜索请求:', {
  35. url: `${this.baseUrl}/api/v1/douyin/search/fetch_general_search_v2`,
  36. body: requestBody,
  37. headers: this.getHeaders()
  38. });
  39. return this.http.post(`${this.baseUrl}/api/v1/douyin/search/fetch_general_search_v2`, requestBody, {
  40. headers: this.getHeaders(),
  41. timeout: this.TIMEOUT
  42. }).pipe(
  43. retry({
  44. count: this.MAX_RETRIES,
  45. delay: (error, retryCount) => {
  46. console.log(`请求失败,第${retryCount}次重试...`, error);
  47. return timer(retryCount * 1000); // 递增延迟
  48. }
  49. }),
  50. catchError(this.handleError)
  51. );
  52. }
  53. // 获取视频详情
  54. getVideoDetail(awemeId: string): Observable<any> {
  55. return this.http.get(`${this.baseUrl}/api/v1/douyin/app/v3/fetch_one_video_v3`, {
  56. params: { aweme_id: awemeId },
  57. headers: this.getHeaders(),
  58. timeout: this.TIMEOUT
  59. }).pipe(
  60. retry(this.MAX_RETRIES),
  61. catchError(this.handleError)
  62. );
  63. }
  64. getUserProfileByUniqueId(uniqueId: string): Observable<any> {
  65. return this.http.get(`${this.baseUrl}/api/v1/douyin/web/handler_user_profile_v2`, {
  66. params: { unique_id: uniqueId },
  67. headers: this.getHeaders(),
  68. timeout: this.TIMEOUT
  69. }).pipe(
  70. retry(this.MAX_RETRIES),
  71. catchError(this.handleError)
  72. );
  73. }
  74. getUserProfileBySecUserId(secUserId: string): Observable<any> {
  75. return this.http.get(`${this.baseUrl}/api/v1/douyin/app/v3/handler_user_profile`, {
  76. params: { sec_user_id: secUserId },
  77. headers: this.getHeaders(),
  78. timeout: this.TIMEOUT
  79. }).pipe(
  80. retry(this.MAX_RETRIES),
  81. catchError(this.handleError)
  82. );
  83. }
  84. getUserPostVideos(secUserId: string, maxCursor: string = '0', count: number = 18, filterType: string = '0'): Observable<any> {
  85. return this.http.get(`${this.baseUrl}/api/v1/douyin/web/fetch_user_post_videos`, {
  86. params: {
  87. sec_user_id: secUserId,
  88. max_cursor: maxCursor,
  89. count,
  90. filter_type: filterType
  91. },
  92. headers: this.getHeaders(),
  93. timeout: this.TIMEOUT
  94. }).pipe(
  95. retry(this.MAX_RETRIES),
  96. catchError(this.handleError)
  97. );
  98. }
  99. extractUserProfileData(payload: any): any {
  100. const normalizedResponse = this.parseJsonPayload(payload);
  101. const normalizedData = this.parseJsonPayload(normalizedResponse?.data);
  102. const nestedData = this.parseJsonPayload(normalizedData?.data);
  103. return this.parseJsonPayload(
  104. normalizedData?.user_info
  105. || normalizedData?.user
  106. || nestedData?.user_info
  107. || nestedData?.user
  108. || nestedData
  109. || normalizedData
  110. || null
  111. );
  112. }
  113. extractUserPostVideos(payload: any): { items: any[]; hasMore: boolean; cursor: string } {
  114. const normalizedResponse = this.parseJsonPayload(payload);
  115. const normalizedData = this.parseJsonPayload(normalizedResponse?.data);
  116. const nestedData = this.parseJsonPayload(normalizedData?.data);
  117. const source = nestedData || normalizedData || normalizedResponse || {};
  118. const items = Array.isArray(source?.aweme_list)
  119. ? source.aweme_list
  120. : Array.isArray(source?.items)
  121. ? source.items
  122. : [];
  123. const hasMore = source?.has_more === 1 || source?.has_more === true;
  124. const cursor = String(source?.max_cursor ?? source?.cursor ?? '0');
  125. return { items, hasMore, cursor };
  126. }
  127. extractVideoDetailData(videoData: any): any {
  128. const normalizedResponse = this.parseJsonPayload(videoData);
  129. const normalizedData = this.parseJsonPayload(normalizedResponse?.data);
  130. const nestedData = this.parseJsonPayload(normalizedData?.data);
  131. return this.parseJsonPayload(
  132. normalizedData?.aweme_detail
  133. || normalizedData?.aweme_info
  134. || normalizedData?.aweme_details?.[0]
  135. || nestedData?.aweme_detail
  136. || nestedData?.aweme_info
  137. || nestedData?.aweme_details?.[0]
  138. || nestedData
  139. || normalizedData
  140. || null
  141. );
  142. }
  143. extractVideoPreviewUrl(videoData: any): string | null {
  144. const detailData = this.extractVideoDetailData(videoData);
  145. if (!detailData) {
  146. return null;
  147. }
  148. return this.pickPreferredUrl(this.collectPreviewUrls(detailData));
  149. }
  150. extractVideoDownloadUrl(videoData: any): string | null {
  151. return this.pickPreferredUrl(this.extractVideoDownloadUrls(videoData));
  152. }
  153. extractVideoDownloadUrls(videoData: any): string[] {
  154. const detailData = this.extractVideoDetailData(videoData);
  155. if (!detailData) {
  156. return [];
  157. }
  158. const downloadCandidates = [
  159. ...(Array.isArray(detailData.download_addr?.url_list) ? detailData.download_addr.url_list : []),
  160. ...(Array.isArray(detailData.video?.download_addr?.url_list) ? detailData.video.download_addr.url_list : []),
  161. ...(Array.isArray(detailData.video?.download_suffix_logo_addr?.url_list) ? detailData.video.download_suffix_logo_addr.url_list : [])
  162. ];
  163. return this.pickUniqueUrls([...downloadCandidates, ...this.collectPlayableUrls(detailData)]);
  164. }
  165. startManagedDownload(payload: {
  166. url: string;
  167. urls?: string[];
  168. filename: string;
  169. title: string;
  170. description: string;
  171. tags: string[];
  172. thumbnail: string;
  173. duration: number;
  174. resolution: string;
  175. awemeId: string;
  176. authorName: string;
  177. }): Observable<any> {
  178. return this.http.post('/backend/api/download/video', payload);
  179. }
  180. getManagedDownloadTask(taskId: string): Observable<any> {
  181. return this.http.get(`/backend/api/download/video/${taskId}`);
  182. }
  183. private collectPreviewUrls(detailData: any): string[] {
  184. const prioritizedUrls = [
  185. ...(Array.isArray(detailData.video?.play_addr_lowbr?.url_list) ? detailData.video.play_addr_lowbr.url_list : []),
  186. ...(Array.isArray(detailData.video?.play_addr_h264?.url_list) ? detailData.video.play_addr_h264.url_list : []),
  187. ...(Array.isArray(detailData.video?.play_addr?.url_list) ? detailData.video.play_addr.url_list : []),
  188. ...(Array.isArray(detailData.video?.play_addr_265?.url_list) ? detailData.video.play_addr_265.url_list : [])
  189. ];
  190. return this.pickUniqueUrls([...prioritizedUrls, ...this.collectPlayableUrls(detailData)]);
  191. }
  192. private collectPlayableUrls(detailData: any): string[] {
  193. const bitRateUrls = Array.isArray(detailData.video?.bit_rate)
  194. ? detailData.video.bit_rate.reduce((urls: string[], item: any) => {
  195. const currentUrls = Array.isArray(item?.play_addr?.url_list) ? item.play_addr.url_list : [];
  196. return urls.concat(currentUrls);
  197. }, [])
  198. : [];
  199. const directUrls = [
  200. ...(Array.isArray(detailData.video?.play_addr?.url_list) ? detailData.video.play_addr.url_list : []),
  201. ...(Array.isArray(detailData.video?.play_addr_h264?.url_list) ? detailData.video.play_addr_h264.url_list : []),
  202. ...(Array.isArray(detailData.video?.play_addr_265?.url_list) ? detailData.video.play_addr_265.url_list : []),
  203. ...(Array.isArray(detailData.video?.play_addr_lowbr?.url_list) ? detailData.video.play_addr_lowbr.url_list : []),
  204. ...(Array.isArray(detailData.video?.download_addr?.url_list) ? detailData.video.download_addr.url_list : []),
  205. ...(Array.isArray(detailData.download_addr?.url_list) ? detailData.download_addr.url_list : []),
  206. detailData.video?.play_addr?.uri,
  207. detailData.video?.download_addr?.uri
  208. ];
  209. return this.pickUniqueUrls([...directUrls, ...bitRateUrls]);
  210. }
  211. private pickUniqueUrls(urls: any[]): string[] {
  212. return urls.filter((url, index, list) => (
  213. typeof url === 'string'
  214. && /^https?:\/\//i.test(url)
  215. && list.indexOf(url) === index
  216. ));
  217. }
  218. private pickPreferredUrl(urls: any[]): string | null {
  219. const validUrls = urls.filter(url => typeof url === 'string' && url.trim() !== '');
  220. const preferredUrl = validUrls.find(url => !/watermark|playwm|download_suffix_logo/i.test(url));
  221. return preferredUrl || validUrls[0] || null;
  222. }
  223. private parseJsonPayload(value: any): any {
  224. let current = value;
  225. while (typeof current === 'string') {
  226. const trimmed = current.trim();
  227. if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) {
  228. break;
  229. }
  230. try {
  231. current = JSON.parse(trimmed);
  232. } catch {
  233. break;
  234. }
  235. }
  236. return current;
  237. }
  238. // 下载视频到本地(改进版本,支持进度跟踪)
  239. async downloadVideo(videoUrl: string, filename: string, onProgress?: (progress: number) => void): Promise<void> {
  240. try {
  241. const response = await fetch(videoUrl, {
  242. method: 'GET',
  243. headers: {
  244. 'Accept': 'video/mp4,*/*',
  245. 'Cache-Control': 'no-cache'
  246. }
  247. });
  248. if (!response.ok) {
  249. throw new Error(`下载失败: ${response.status} ${response.statusText}`);
  250. }
  251. const contentLength = response.headers.get('content-length');
  252. const totalBytes = contentLength ? parseInt(contentLength, 10) : 0;
  253. const reader = response.body?.getReader();
  254. if (!reader) {
  255. throw new Error('无法读取视频流');
  256. }
  257. const chunks: Uint8Array[] = [];
  258. let receivedBytes = 0;
  259. while (true) {
  260. const { done, value } = await reader.read();
  261. if (done) break;
  262. chunks.push(value);
  263. receivedBytes += value.length;
  264. if (onProgress && totalBytes > 0) {
  265. const progress = Math.round((receivedBytes / totalBytes) * 100);
  266. onProgress(progress);
  267. }
  268. }
  269. // 合并所有数据块
  270. const blob = new Blob(chunks as BlobPart[], { type: 'video/mp4' });
  271. // 创建下载链接
  272. const url = window.URL.createObjectURL(blob);
  273. const link = document.createElement('a');
  274. link.href = url;
  275. link.download = filename;
  276. link.style.display = 'none';
  277. document.body.appendChild(link);
  278. link.click();
  279. // 清理
  280. setTimeout(() => {
  281. document.body.removeChild(link);
  282. window.URL.revokeObjectURL(url);
  283. }, 100);
  284. if (onProgress) {
  285. onProgress(100);
  286. }
  287. } catch (error) {
  288. console.error('视频下载错误:', error);
  289. throw error;
  290. }
  291. }
  292. // 从URL中提取aweme_id(增强版本)
  293. extractAwemeId(url: string): string | null {
  294. if (!url) return null;
  295. // 处理多种抖音链接格式
  296. const patterns = [
  297. /douyin\.com\/video\/(\d+)/, // https://www.douyin.com/video/1234567890
  298. /douyin\.com\/\w+\/(\d+)/, // https://www.douyin.com/user/123/71891234567890
  299. /aweme_id=(\d+)/, // aweme_id=1234567890
  300. /^(\d+)$/, // 纯数字ID
  301. /douyin\.com\/\?modal_id=(\d+)/, // 分享链接格式
  302. /v\.douyin\.com\/\w+\?id=(\d+)/, // 短链接格式
  303. /douyin\.com\/share\/video\/(\d+)/ // 分享页面格式
  304. ];
  305. for (const pattern of patterns) {
  306. const match = url.match(pattern);
  307. if (match) {
  308. return match[1];
  309. }
  310. }
  311. return null;
  312. }
  313. // 批量获取视频信息
  314. getBatchVideoDetails(awemeIds: string[]): Observable<any>[] {
  315. return awemeIds.map(id => this.getVideoDetail(id));
  316. }
  317. // 验证视频数据完整性
  318. validateVideoData(videoData: any): boolean {
  319. if (!videoData || !videoData.data) {
  320. return false;
  321. }
  322. const data = videoData.data;
  323. return !!(data.aweme_id && data.desc !== undefined && data.author);
  324. }
  325. // 格式化视频信息用于显示
  326. formatVideoInfo(videoData: DouyinVideo): DouyinVideo {
  327. return {
  328. ...videoData,
  329. statistics: {
  330. digg_count: videoData.statistics?.digg_count || 0,
  331. play_count: videoData.statistics?.play_count || 0,
  332. comment_count: videoData.statistics?.comment_count || 0,
  333. share_count: videoData.statistics?.share_count || 0
  334. },
  335. author: {
  336. nickname: videoData.author?.nickname || '未知作者',
  337. sec_uid: videoData.author?.sec_uid || '',
  338. avatar_thumb: videoData.author?.avatar_thumb
  339. }
  340. };
  341. }
  342. // 生成安全的文件名
  343. generateSafeFilename(video: DouyinVideo): string {
  344. const author = video.author?.nickname || '未知作者';
  345. const title = video.desc || `视频_${video.aweme_id}`;
  346. const safeTitle = title
  347. .replace(/[<>:"/\\|?*]/g, '_') // 移除Windows文件名非法字符
  348. .replace(/\s+/g, '_') // 空格替换为下划线
  349. .substring(0, 50); // 限制标题长度
  350. return `${author}_${safeTitle}_${video.aweme_id}.mp4`;
  351. }
  352. // 获取请求头
  353. private getHeaders(): HttpHeaders {
  354. return new HttpHeaders({
  355. 'Content-Type': 'application/json',
  356. 'Accept': 'application/json',
  357. 'Authorization': `Bearer ${environment.douyinApi.token}`
  358. });
  359. }
  360. // 错误处理
  361. private handleError(error: any): Observable<never> {
  362. let errorMessage = '';
  363. if (error.error instanceof ErrorEvent) {
  364. // 客户端错误
  365. errorMessage = `网络错误: ${error.error.message}`;
  366. } else {
  367. // 服务端错误
  368. switch (error.status) {
  369. case 401:
  370. errorMessage = '认证失败 (401): Token无效或已过期';
  371. break;
  372. case 403:
  373. errorMessage = '访问被拒绝 (403): 权限不足';
  374. break;
  375. case 429:
  376. errorMessage = '请求过于频繁 (429): 请稍后重试';
  377. break;
  378. case 500:
  379. errorMessage = '服务器内部错误 (500): 请稍后重试';
  380. break;
  381. case 400:
  382. errorMessage = '请求参数错误 (400): 请检查请求参数格式';
  383. break;
  384. case 0:
  385. errorMessage = '网络连接失败: 请检查网络设置';
  386. break;
  387. default:
  388. errorMessage = `请求失败 (${error.status}): ${error.message || '未知错误'}`;
  389. }
  390. // 检查API返回的具体错误信息
  391. if (error.error) {
  392. console.log('🔍 详细错误信息:', {
  393. status: error.status,
  394. statusText: error.statusText,
  395. error: error.error,
  396. message: error.message,
  397. url: error.url
  398. });
  399. if (error.error.msg) {
  400. errorMessage += ` | API错误: ${error.error.msg}`;
  401. }
  402. if (error.error.data) {
  403. errorMessage += ` | 错误数据: ${JSON.stringify(error.error.data)}`;
  404. }
  405. }
  406. }
  407. console.error('Douyin API Error:', errorMessage, error);
  408. return throwError(() => new Error(errorMessage));
  409. }
  410. }