import { Injectable } from '@angular/core'; import { Observable, throwError } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; import { ViralAnalysis } from '../models/douyin-insight.model'; import { IpBenchmarkEvidence, IpEvidenceFetchJob, IpOperatorPlan, } from '../models/ip-operator.model'; import { IpOperatorService } from './ip-operator.service'; import { ViralAnalysisService } from './viral-analysis.service'; export interface IpEvidenceFetchResult { job: IpEvidenceFetchJob; evidence?: IpBenchmarkEvidence; plan: IpOperatorPlan; } @Injectable({ providedIn: 'root' }) export class IpOperatorEvidenceService { constructor( private readonly ipOperator: IpOperatorService, private readonly viralAnalysis: ViralAnalysisService, ) {} fetchEvidenceFromDouyinUrl(planId: string, benchmarkId: string | undefined, inputUrl: string): Observable { const plan = this.requirePlan(planId); const url = String(inputUrl || '').trim(); const awemeId = this.extractAwemeId(url); const now = new Date().toISOString(); const baseJob: IpEvidenceFetchJob = { id: this.createId('ip_evidence_job'), planId: plan.id, profileId: plan.profileId, benchmarkId: benchmarkId || undefined, inputUrl: url, awemeId: awemeId || undefined, status: awemeId ? 'fetching_detail' : 'failed', evidenceStrength: 'none', failureCode: awemeId ? undefined : 'invalid_url', failureMessage: awemeId ? undefined : '无法从作品链接中解析 awemeId;短链需要先在浏览器中展开为 douyin.com/video/{id}。', createdAt: now, updatedAt: now, }; this.ipOperator.recordEvidenceFetchJob(plan.id, baseJob); if (!awemeId) { return throwError(() => new Error(baseJob.failureMessage)); } return this.viralAnalysis.analyzeVideo({ awemeId, source: 'manual' }).pipe( map((analysis) => { const strength = this.evidenceStrength(analysis); if (strength === 'none') { const failed = this.finishJob(baseJob, { status: 'failed', failureCode: 'detail_failed', failureMessage: '平台详情、互动数据、评论样本均不可用,本次不写入对标证据。', evidenceStrength: 'none', analysisId: analysis.id, }); const updatedPlan = this.ipOperator.recordEvidenceFetchJob(plan.id, failed); return { job: failed, plan: updatedPlan }; } const evidence = this.analysisToEvidence(plan, analysis, benchmarkId, url, strength); const planWithEvidence = this.ipOperator.addBenchmarkEvidence(plan.id, evidence); const completed = this.finishJob(baseJob, { status: strength === 'weak' ? 'partial' : 'completed', evidenceStrength: strength, evidenceId: evidence.id, analysisId: analysis.id, failureCode: strength === 'weak' ? 'comments_failed' : undefined, failureMessage: strength === 'weak' ? '仅获得弱证据,建议补充评论或逐字稿后再用于正式迁移判断。' : undefined, }); const updatedPlan = this.ipOperator.recordEvidenceFetchJob(planWithEvidence.id, completed); return { job: completed, evidence, plan: updatedPlan }; }), catchError((error) => { const failed = this.finishJob(baseJob, { status: 'failed', failureCode: 'analysis_failed', failureMessage: error?.message || '对标作品证据采集失败', evidenceStrength: 'none', }); const updatedPlan = this.ipOperator.recordEvidenceFetchJob(plan.id, failed); return throwError(() => Object.assign(new Error(failed.failureMessage), { result: { job: failed, plan: updatedPlan } })); }), ); } bindSavedAnalysis(planId: string, benchmarkId: string | undefined, analysisId: string): IpEvidenceFetchResult { const plan = this.requirePlan(planId); const analysis = this.viralAnalysis.getLocalAnalysis(analysisId); if (!analysis) throw new Error('未找到已保存的爆款分析记录'); const strength = this.evidenceStrength(analysis); const evidence = this.analysisToEvidence(plan, analysis, benchmarkId, '', strength); const updatedPlan = this.ipOperator.addBenchmarkEvidence(plan.id, evidence); const now = new Date().toISOString(); const job: IpEvidenceFetchJob = { id: this.createId('ip_evidence_job'), planId: plan.id, profileId: plan.profileId, benchmarkId: benchmarkId || undefined, inputUrl: analysis.awemeId, awemeId: analysis.awemeId, status: strength === 'weak' ? 'partial' : 'completed', evidenceId: evidence.id, analysisId: analysis.id, evidenceStrength: strength, createdAt: now, updatedAt: now, }; const finalPlan = this.ipOperator.recordEvidenceFetchJob(updatedPlan.id, job); return { job, evidence, plan: finalPlan }; } extractAwemeId(input: string): string | null { const text = String(input || '').trim(); if (!text) return null; const patterns = [ /^(\d{8,})$/, /douyin\.com\/video\/(\d+)/i, /douyin\.com\/share\/video\/(\d+)/i, /modal_id=(\d+)/i, /aweme_id=(\d+)/i, /douyin\.com\/[^?\s]+\/(\d{8,})/i, /v\.douyin\.com\/[^?\s]+\?id=(\d+)/i, ]; for (const pattern of patterns) { const match = text.match(pattern); if (match?.[1]) return match[1]; } return null; } private analysisToEvidence( plan: IpOperatorPlan, analysis: ViralAnalysis, benchmarkId: string | undefined, inputUrl: string, strength: IpEvidenceFetchJob['evidenceStrength'], ): IpBenchmarkEvidence { const content = analysis.analysis; const comments = [ ...analysis.commentsSnapshot.map((item) => item.text), ...(analysis.repliesSnapshot || []).map((item) => item.text), ].map((item) => this.clean(item)).filter(Boolean); const confidence: IpBenchmarkEvidence['confidence'] = strength === 'strong' ? 'high' : strength === 'medium' ? 'medium' : 'low'; return { id: this.createId('ip_evidence'), planId: plan.id, profileId: plan.profileId, benchmarkId: benchmarkId || undefined, source: inputUrl ? 'douyin_fetch' : 'viral_analysis', sourceId: analysis.id, awemeId: analysis.awemeId, inputUrl: inputUrl || undefined, title: analysis.videoSnapshot.desc || analysis.awemeId, authorName: analysis.videoSnapshot.authorName, summary: content.summary, hookPattern: content.openingPattern || content.hookType, reusableFrame: content.reusableFrame, commentPainPoints: comments.slice(0, 8), migrationSuggestion: this.migrationSuggestion(content.reusableFrame, comments), nonCopyableRisk: (content.riskNotes || [])[0] || '仅迁移结构和用户问题,不直接照搬原视频表达。', evidenceRefs: content.evidenceRefs || [], confidence, createdAt: new Date().toISOString(), }; } private evidenceStrength(analysis: ViralAnalysis): IpEvidenceFetchJob['evidenceStrength'] { const video = analysis.videoSnapshot; const hasDetail = !!(video.desc || video.authorName || video.diggCount || video.commentCount || video.shareCount || video.playCount); const sampleCount = analysis.commentsSnapshot.length + (analysis.repliesSnapshot || []).length; if (!hasDetail && !sampleCount && !analysis.transcript) return 'none'; if (analysis.confidence === 'high') return 'strong'; if (analysis.confidence === 'medium') return 'medium'; return 'weak'; } private finishJob(job: IpEvidenceFetchJob, patch: Partial): IpEvidenceFetchJob { return { ...job, ...patch, updatedAt: new Date().toISOString(), }; } private requirePlan(planId: string): IpOperatorPlan { const plan = this.ipOperator.getPlan(planId); if (!plan) throw new Error('未找到 IP 方案,无法采集对标证据'); return plan; } private migrationSuggestion(frame: string, comments: string[]): string { const comment = comments.find((item) => /怎么|为什么|能不能|有没有|吗|?|\?/.test(item)) || comments[0] || ''; return [ frame ? `保留结构:${frame}` : '保留原视频的内容结构,不复制具体话术。', comment ? `迁移到当前 IP 时优先回应用户问题:${comment}` : '迁移到当前 IP 时必须补充自身定位、服务场景和真实案例。', ].join(' '); } private clean(value: string): string { return String(value || '').replace(/\s+/g, ' ').trim(); } private createId(prefix: string): string { return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; } }