ip-operator-evidence.service.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. import { Injectable } from '@angular/core';
  2. import { Observable, throwError } from 'rxjs';
  3. import { catchError, map } from 'rxjs/operators';
  4. import { ViralAnalysis } from '../models/douyin-insight.model';
  5. import {
  6. IpBenchmarkEvidence,
  7. IpEvidenceFetchJob,
  8. IpOperatorPlan,
  9. } from '../models/ip-operator.model';
  10. import { IpOperatorService } from './ip-operator.service';
  11. import { ViralAnalysisService } from './viral-analysis.service';
  12. export interface IpEvidenceFetchResult {
  13. job: IpEvidenceFetchJob;
  14. evidence?: IpBenchmarkEvidence;
  15. plan: IpOperatorPlan;
  16. }
  17. @Injectable({ providedIn: 'root' })
  18. export class IpOperatorEvidenceService {
  19. constructor(
  20. private readonly ipOperator: IpOperatorService,
  21. private readonly viralAnalysis: ViralAnalysisService,
  22. ) {}
  23. fetchEvidenceFromDouyinUrl(planId: string, benchmarkId: string | undefined, inputUrl: string): Observable<IpEvidenceFetchResult> {
  24. const plan = this.requirePlan(planId);
  25. const url = String(inputUrl || '').trim();
  26. const awemeId = this.extractAwemeId(url);
  27. const now = new Date().toISOString();
  28. const baseJob: IpEvidenceFetchJob = {
  29. id: this.createId('ip_evidence_job'),
  30. planId: plan.id,
  31. profileId: plan.profileId,
  32. benchmarkId: benchmarkId || undefined,
  33. inputUrl: url,
  34. awemeId: awemeId || undefined,
  35. status: awemeId ? 'fetching_detail' : 'failed',
  36. evidenceStrength: 'none',
  37. failureCode: awemeId ? undefined : 'invalid_url',
  38. failureMessage: awemeId ? undefined : '无法从作品链接中解析 awemeId;短链需要先在浏览器中展开为 douyin.com/video/{id}。',
  39. createdAt: now,
  40. updatedAt: now,
  41. };
  42. this.ipOperator.recordEvidenceFetchJob(plan.id, baseJob);
  43. if (!awemeId) {
  44. return throwError(() => new Error(baseJob.failureMessage));
  45. }
  46. return this.viralAnalysis.analyzeVideo({ awemeId, source: 'manual' }).pipe(
  47. map((analysis) => {
  48. const strength = this.evidenceStrength(analysis);
  49. if (strength === 'none') {
  50. const failed = this.finishJob(baseJob, {
  51. status: 'failed',
  52. failureCode: 'detail_failed',
  53. failureMessage: '平台详情、互动数据、评论样本均不可用,本次不写入对标证据。',
  54. evidenceStrength: 'none',
  55. analysisId: analysis.id,
  56. });
  57. const updatedPlan = this.ipOperator.recordEvidenceFetchJob(plan.id, failed);
  58. return { job: failed, plan: updatedPlan };
  59. }
  60. const evidence = this.analysisToEvidence(plan, analysis, benchmarkId, url, strength);
  61. const planWithEvidence = this.ipOperator.addBenchmarkEvidence(plan.id, evidence);
  62. const completed = this.finishJob(baseJob, {
  63. status: strength === 'weak' ? 'partial' : 'completed',
  64. evidenceStrength: strength,
  65. evidenceId: evidence.id,
  66. analysisId: analysis.id,
  67. failureCode: strength === 'weak' ? 'comments_failed' : undefined,
  68. failureMessage: strength === 'weak' ? '仅获得弱证据,建议补充评论或逐字稿后再用于正式迁移判断。' : undefined,
  69. });
  70. const updatedPlan = this.ipOperator.recordEvidenceFetchJob(planWithEvidence.id, completed);
  71. return { job: completed, evidence, plan: updatedPlan };
  72. }),
  73. catchError((error) => {
  74. const failed = this.finishJob(baseJob, {
  75. status: 'failed',
  76. failureCode: 'analysis_failed',
  77. failureMessage: error?.message || '对标作品证据采集失败',
  78. evidenceStrength: 'none',
  79. });
  80. const updatedPlan = this.ipOperator.recordEvidenceFetchJob(plan.id, failed);
  81. return throwError(() => Object.assign(new Error(failed.failureMessage), { result: { job: failed, plan: updatedPlan } }));
  82. }),
  83. );
  84. }
  85. bindSavedAnalysis(planId: string, benchmarkId: string | undefined, analysisId: string): IpEvidenceFetchResult {
  86. const plan = this.requirePlan(planId);
  87. const analysis = this.viralAnalysis.getLocalAnalysis(analysisId);
  88. if (!analysis) throw new Error('未找到已保存的爆款分析记录');
  89. const strength = this.evidenceStrength(analysis);
  90. const evidence = this.analysisToEvidence(plan, analysis, benchmarkId, '', strength);
  91. const updatedPlan = this.ipOperator.addBenchmarkEvidence(plan.id, evidence);
  92. const now = new Date().toISOString();
  93. const job: IpEvidenceFetchJob = {
  94. id: this.createId('ip_evidence_job'),
  95. planId: plan.id,
  96. profileId: plan.profileId,
  97. benchmarkId: benchmarkId || undefined,
  98. inputUrl: analysis.awemeId,
  99. awemeId: analysis.awemeId,
  100. status: strength === 'weak' ? 'partial' : 'completed',
  101. evidenceId: evidence.id,
  102. analysisId: analysis.id,
  103. evidenceStrength: strength,
  104. createdAt: now,
  105. updatedAt: now,
  106. };
  107. const finalPlan = this.ipOperator.recordEvidenceFetchJob(updatedPlan.id, job);
  108. return { job, evidence, plan: finalPlan };
  109. }
  110. extractAwemeId(input: string): string | null {
  111. const text = String(input || '').trim();
  112. if (!text) return null;
  113. const patterns = [
  114. /^(\d{8,})$/,
  115. /douyin\.com\/video\/(\d+)/i,
  116. /douyin\.com\/share\/video\/(\d+)/i,
  117. /modal_id=(\d+)/i,
  118. /aweme_id=(\d+)/i,
  119. /douyin\.com\/[^?\s]+\/(\d{8,})/i,
  120. /v\.douyin\.com\/[^?\s]+\?id=(\d+)/i,
  121. ];
  122. for (const pattern of patterns) {
  123. const match = text.match(pattern);
  124. if (match?.[1]) return match[1];
  125. }
  126. return null;
  127. }
  128. private analysisToEvidence(
  129. plan: IpOperatorPlan,
  130. analysis: ViralAnalysis,
  131. benchmarkId: string | undefined,
  132. inputUrl: string,
  133. strength: IpEvidenceFetchJob['evidenceStrength'],
  134. ): IpBenchmarkEvidence {
  135. const content = analysis.analysis;
  136. const comments = [
  137. ...analysis.commentsSnapshot.map((item) => item.text),
  138. ...(analysis.repliesSnapshot || []).map((item) => item.text),
  139. ].map((item) => this.clean(item)).filter(Boolean);
  140. const confidence: IpBenchmarkEvidence['confidence'] = strength === 'strong' ? 'high' : strength === 'medium' ? 'medium' : 'low';
  141. return {
  142. id: this.createId('ip_evidence'),
  143. planId: plan.id,
  144. profileId: plan.profileId,
  145. benchmarkId: benchmarkId || undefined,
  146. source: inputUrl ? 'douyin_fetch' : 'viral_analysis',
  147. sourceId: analysis.id,
  148. awemeId: analysis.awemeId,
  149. inputUrl: inputUrl || undefined,
  150. title: analysis.videoSnapshot.desc || analysis.awemeId,
  151. authorName: analysis.videoSnapshot.authorName,
  152. summary: content.summary,
  153. hookPattern: content.openingPattern || content.hookType,
  154. reusableFrame: content.reusableFrame,
  155. commentPainPoints: comments.slice(0, 8),
  156. migrationSuggestion: this.migrationSuggestion(content.reusableFrame, comments),
  157. nonCopyableRisk: (content.riskNotes || [])[0] || '仅迁移结构和用户问题,不直接照搬原视频表达。',
  158. evidenceRefs: content.evidenceRefs || [],
  159. confidence,
  160. createdAt: new Date().toISOString(),
  161. };
  162. }
  163. private evidenceStrength(analysis: ViralAnalysis): IpEvidenceFetchJob['evidenceStrength'] {
  164. const video = analysis.videoSnapshot;
  165. const hasDetail = !!(video.desc || video.authorName || video.diggCount || video.commentCount || video.shareCount || video.playCount);
  166. const sampleCount = analysis.commentsSnapshot.length + (analysis.repliesSnapshot || []).length;
  167. if (!hasDetail && !sampleCount && !analysis.transcript) return 'none';
  168. if (analysis.confidence === 'high') return 'strong';
  169. if (analysis.confidence === 'medium') return 'medium';
  170. return 'weak';
  171. }
  172. private finishJob(job: IpEvidenceFetchJob, patch: Partial<IpEvidenceFetchJob>): IpEvidenceFetchJob {
  173. return {
  174. ...job,
  175. ...patch,
  176. updatedAt: new Date().toISOString(),
  177. };
  178. }
  179. private requirePlan(planId: string): IpOperatorPlan {
  180. const plan = this.ipOperator.getPlan(planId);
  181. if (!plan) throw new Error('未找到 IP 方案,无法采集对标证据');
  182. return plan;
  183. }
  184. private migrationSuggestion(frame: string, comments: string[]): string {
  185. const comment = comments.find((item) => /怎么|为什么|能不能|有没有|吗|?|\?/.test(item)) || comments[0] || '';
  186. return [
  187. frame ? `保留结构:${frame}` : '保留原视频的内容结构,不复制具体话术。',
  188. comment ? `迁移到当前 IP 时优先回应用户问题:${comment}` : '迁移到当前 IP 时必须补充自身定位、服务场景和真实案例。',
  189. ].join(' ');
  190. }
  191. private clean(value: string): string {
  192. return String(value || '').replace(/\s+/g, ' ').trim();
  193. }
  194. private createId(prefix: string): string {
  195. return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
  196. }
  197. }