douyin.service.ts 17 KB

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