| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211 |
- 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<IpEvidenceFetchResult> {
- 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>): 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)}`;
- }
- }
|