From 7d92e0de1c5c0be553a03e0d3772db8182336d00 Mon Sep 17 00:00:00 2001
From: Martin <60476606+mx-320@users.noreply.github.com>
Date: Fri, 10 Jul 2026 17:47:56 +0800
Subject: [PATCH 01/11] feat(db): add CompetitorCreative + RemixJob models
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds the competitor-intel data model per docs/growth/07 §4 (+ 09 §3's
segmentPlan addition): CompetitorCreative caches AppGrowing ad metadata
and AI analysis, idempotent on (orgId, source, externalId); RemixJob is
a queryable ledger for turning competitor creatives into our-own-IP
assets (not wired to an engine yet — Phase 2). Purely additive, both
relate to Organization like the rest of the schema.
Migration generated via `prisma migrate diff --from-schema --to-schema
prisma/schema.prisma --script` (no local Postgres available in this
worktree — see .claude/rules/schema.md).
---
.../migration.sql | 67 +++++++++++++++++
prisma/schema.prisma | 75 +++++++++++++++++++
2 files changed, 142 insertions(+)
create mode 100644 prisma/migrations/20260710180000_competitor_intel/migration.sql
diff --git a/prisma/migrations/20260710180000_competitor_intel/migration.sql b/prisma/migrations/20260710180000_competitor_intel/migration.sql
new file mode 100644
index 0000000..d19b00d
--- /dev/null
+++ b/prisma/migrations/20260710180000_competitor_intel/migration.sql
@@ -0,0 +1,67 @@
+-- CreateTable
+CREATE TABLE "CompetitorCreative" (
+ "id" TEXT NOT NULL,
+ "orgId" TEXT NOT NULL,
+ "source" TEXT NOT NULL DEFAULT 'appgrowing',
+ "externalId" TEXT NOT NULL,
+ "appName" TEXT,
+ "advertiser" TEXT,
+ "mediaPlatforms" JSONB,
+ "adFormat" TEXT,
+ "region" TEXT,
+ "language" TEXT,
+ "adDays" INTEGER,
+ "impressions" BIGINT,
+ "firstSeenAt" TIMESTAMP(3),
+ "lastSeenAt" TIMESTAMP(3),
+ "originalPostUrl" TEXT,
+ "ratio" TEXT,
+ "duration" INTEGER,
+ "creativeTags" JSONB,
+ "sellingPoints" JSONB,
+ "emotionalTriggers" JSONB,
+ "screenUnderstanding" JSONB,
+ "storyboard" JSONB,
+ "transcript" TEXT,
+ "bgm" TEXT,
+ "aiPrompt" TEXT,
+ "segmentPlan" JSONB,
+ "rawMeta" JSONB,
+ "assetId" TEXT,
+ "ingestedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "CompetitorCreative_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "RemixJob" (
+ "id" TEXT NOT NULL,
+ "orgId" TEXT NOT NULL,
+ "competitorCreativeId" TEXT NOT NULL,
+ "briefId" TEXT,
+ "mode" TEXT NOT NULL,
+ "remixPrompt" TEXT NOT NULL,
+ "status" TEXT NOT NULL DEFAULT 'queued',
+ "assetId" TEXT,
+ "creativeId" TEXT,
+ "aiPointsSpent" INTEGER,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "RemixJob_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "CompetitorCreative_orgId_appName_idx" ON "CompetitorCreative"("orgId", "appName");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "CompetitorCreative_orgId_source_externalId_key" ON "CompetitorCreative"("orgId", "source", "externalId");
+
+-- CreateIndex
+CREATE INDEX "RemixJob_orgId_status_idx" ON "RemixJob"("orgId", "status");
+
+-- AddForeignKey
+ALTER TABLE "CompetitorCreative" ADD CONSTRAINT "CompetitorCreative_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "RemixJob" ADD CONSTRAINT "RemixJob_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 02a7a50..d5e7ed4 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -129,6 +129,10 @@ model Organization {
appReviews AppReview[]
creativeBriefs CreativeBrief[]
creativeVariants CreativeVariant[]
+
+ // Competitor intel (docs/growth/07 §4, 09 §3)
+ competitorCreatives CompetitorCreative[]
+ remixJobs RemixJob[]
}
model Webhook {
@@ -979,3 +983,74 @@ model CreativeVariant {
@@index([briefId])
@@index([orgId])
}
+
+// =====================================================================
+// Competitor intel (docs/growth/07-competitor-intel-remix.md §4,
+// docs/growth/09-pipeline-adex-integration.md §3)
+// =====================================================================
+
+// Competitor ad creative pulled from AppGrowing (or another future source),
+// pushed via POST /api/ingest/competitor. Idempotent on (orgId, source,
+// externalId). `assetId` links to our own GCS copy of the media once
+// fetched/uploaded — see src/lib/platforms/appgrowing.ts.
+model CompetitorCreative {
+ id String @id @default(cuid())
+ orgId String
+ source String @default("appgrowing") // where this row was sourced from
+ externalId String // source's own id → idempotency key
+ // Hard metadata
+ appName String?
+ advertiser String?
+ mediaPlatforms Json? // ["google_ads","meta",...]
+ adFormat String?
+ region String?
+ language String?
+ adDays Int? // days live — longevity proxy
+ impressions BigInt?
+ firstSeenAt DateTime?
+ lastSeenAt DateTime?
+ originalPostUrl String? // public original source (e.g. YouTube)
+ ratio String?
+ duration Int?
+ // AI analysis layer (cached from the source, not re-derived here)
+ creativeTags Json?
+ sellingPoints Json?
+ emotionalTriggers Json?
+ screenUnderstanding Json?
+ storyboard Json? // shot-by-shot breakdown
+ transcript String? // speech-to-text
+ bgm String? // recognition result only — never the audio itself (IP risk)
+ aiPrompt String? // source-inferred generation prompt
+ segmentPlan Json? // segment routing plan (09 §3 addition)
+ rawMeta Json? // raw source response, kept for audit
+ // Links
+ assetId String? // our GCS copy (Asset)
+ ingestedAt DateTime @default(now())
+
+ org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
+
+ @@unique([orgId, source, externalId])
+ @@index([orgId, appName])
+}
+
+// Queryable Remix job ledger — tracks turning a CompetitorCreative into an
+// our-own-IP Creative via Seedance2. Not wired to a processing engine in
+// this PR (Phase 2, docs/growth/09 §3) — the model exists so the ledger can
+// be written to by the local creative-pipeline tooling.
+model RemixJob {
+ id String @id @default(cuid())
+ orgId String
+ competitorCreativeId String
+ briefId String? // reuses CreativeBrief
+ mode String // text2video | image2video | full
+ remixPrompt String // extractor output
+ status String @default("queued") // queued | generating | ready | failed | rejected
+ assetId String? // Seedance2 output
+ creativeId String? // landed Creative
+ aiPointsSpent Int? // source AI-points cost
+ createdAt DateTime @default(now())
+
+ org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
+
+ @@index([orgId, status])
+}
From d53dec7a9460826f2460b318cbd5bf3c247003ee Mon Sep 17 00:00:00 2001
From: Martin <60476606+mx-320@users.noreply.github.com>
Date: Fri, 10 Jul 2026 17:48:04 +0800
Subject: [PATCH 02/11] feat(api): add competitor-intel ingest + listing routes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
POST /api/ingest/competitor?org= — clones the ingest/scenes
HMAC + idempotent-upsert pattern for competitor ad creatives pushed by
the local creative-pipeline/AppGrowing bridge. Accepts a single item or
an array; mediaUrl already on our GCS bucket is linked directly, an
external mediaUrl is fetched once and uploaded to GCS. Per-item
created/updated/failed result. Connector parsing/mapping logic lives in
src/lib/platforms/appgrowing.ts so the ingest source is isolated from
the route, per docs/growth/07 §5.
GET /api/competitors — session-scoped listing (requireAuthWithOrg,
never trusts a client orgId), filterable by appName/level, orderable
by adDays/impressions.
Ref: docs/growth/09-pipeline-adex-integration.md §3
---
src/app/api/competitors/route.ts | 61 ++++++++
src/app/api/ingest/competitor/route.ts | 149 +++++++++++++++++++
src/lib/platforms/appgrowing.ts | 192 +++++++++++++++++++++++++
3 files changed, 402 insertions(+)
create mode 100644 src/app/api/competitors/route.ts
create mode 100644 src/app/api/ingest/competitor/route.ts
create mode 100644 src/lib/platforms/appgrowing.ts
diff --git a/src/app/api/competitors/route.ts b/src/app/api/competitors/route.ts
new file mode 100644
index 0000000..9b35202
--- /dev/null
+++ b/src/app/api/competitors/route.ts
@@ -0,0 +1,61 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { prisma } from '@/lib/prisma'
+import { requireAuthWithOrg } from '@/lib/auth'
+
+/**
+ * GET /api/competitors?appName=&level=&orderBy=adDays|impressions&limit=
+ *
+ * Org-scoped listing of ingested competitor creatives (session auth — never
+ * trusts a client-supplied orgId). `level` has no dedicated column (see
+ * docs/growth/09-pipeline-adex-integration.md §3) — it's whatever the
+ * source's classification stored under `rawMeta.level`, filtered via a
+ * Postgres JSON-path match.
+ *
+ * Ref: docs/growth/09-pipeline-adex-integration.md §3
+ */
+export async function GET(req: NextRequest) {
+ let org
+ try {
+ const ctx = await requireAuthWithOrg()
+ org = ctx.org
+ } catch {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ try {
+ const searchParams = req.nextUrl.searchParams
+ const appName = searchParams.get('appName')
+ const level = searchParams.get('level')
+ const orderByParam = searchParams.get('orderBy')
+ const limitParam = searchParams.get('limit')
+
+ const limit = Math.min(Math.max(parseInt(limitParam || '50', 10) || 50, 1), 200)
+
+ const where: Record = { orgId: org.id }
+ if (appName) where.appName = { contains: appName }
+ if (level) where.rawMeta = { path: ['level'], equals: level }
+
+ const orderBy: Record =
+ orderByParam === 'adDays'
+ ? { adDays: 'desc' }
+ : orderByParam === 'impressions'
+ ? { impressions: 'desc' }
+ : { ingestedAt: 'desc' }
+
+ const rows = await prisma.competitorCreative.findMany({
+ where,
+ orderBy,
+ take: limit,
+ })
+
+ // BigInt isn't JSON-serializable — stringify impressions.
+ const serialized = rows.map((row) => ({
+ ...row,
+ impressions: row.impressions === null ? null : row.impressions.toString(),
+ }))
+
+ return NextResponse.json(serialized)
+ } catch {
+ return NextResponse.json([], { status: 200 })
+ }
+}
diff --git a/src/app/api/ingest/competitor/route.ts b/src/app/api/ingest/competitor/route.ts
new file mode 100644
index 0000000..3cbecbf
--- /dev/null
+++ b/src/app/api/ingest/competitor/route.ts
@@ -0,0 +1,149 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { prisma } from '@/lib/prisma'
+import { verifyHmac } from '@/lib/growth/ingest-auth'
+import { uploadToGCS } from '@/lib/storage'
+import {
+ APPGROWING_SOURCE,
+ parseCompetitorItems,
+ isOwnGcsUrl,
+ mapCompetitorItemToFields,
+ type CompetitorIngestItem,
+} from '@/lib/platforms/appgrowing'
+
+/**
+ * POST /api/ingest/competitor?org=
+ *
+ * Local creative-pipeline (or any AppGrowing bridge) pushes competitor ad
+ * creative metadata + AI analysis; we upsert each as a CompetitorCreative
+ * row, idempotent by (orgId, source, externalId). `mediaUrl` already on our
+ * GCS bucket is linked directly (no re-fetch); external URLs are fetched
+ * once and uploaded to GCS. Auth mirrors ingest/scenes: HMAC over the raw
+ * body using the org's 'ingest' PlatformAuth apiKey.
+ *
+ * Ref: docs/growth/09-pipeline-adex-integration.md §3
+ */
+
+const IMAGE_EXT_RE = /\.(jpe?g|png|webp|gif)(\?|$)/i
+
+function guessAssetType(url: string): string {
+ return IMAGE_EXT_RE.test(url) ? 'image' : 'video'
+}
+
+async function ensureAsset(
+ item: CompetitorIngestItem,
+ ctx: { orgId: string; uploadedBy: string },
+): Promise {
+ const mediaUrl = item.mediaUrl
+ if (!mediaUrl) return null
+
+ if (isOwnGcsUrl(mediaUrl)) {
+ const asset = await prisma.asset.create({
+ data: {
+ orgId: ctx.orgId,
+ uploadedBy: ctx.uploadedBy,
+ name: item.appName || `Competitor ${item.externalId}`,
+ type: guessAssetType(mediaUrl),
+ source: APPGROWING_SOURCE,
+ fileUrl: mediaUrl,
+ status: 'ready',
+ ratio: item.ratio ?? undefined,
+ duration: item.duration ?? undefined,
+ },
+ })
+ return asset.id
+ }
+
+ const res = await fetch(mediaUrl)
+ if (!res.ok) throw new Error(`fetch mediaUrl failed (${res.status})`)
+ const buffer = Buffer.from(await res.arrayBuffer())
+ const contentType = res.headers.get('content-type') || 'application/octet-stream'
+ const ext = guessAssetType(mediaUrl) === 'image' ? 'jpg' : 'mp4'
+ const filename = `competitor/${item.externalId}-${Date.now()}.${ext}`
+ const fileUrl = await uploadToGCS(buffer, filename, contentType)
+
+ const asset = await prisma.asset.create({
+ data: {
+ orgId: ctx.orgId,
+ uploadedBy: ctx.uploadedBy,
+ name: item.appName || `Competitor ${item.externalId}`,
+ type: guessAssetType(mediaUrl),
+ source: APPGROWING_SOURCE,
+ fileUrl,
+ status: 'ready',
+ ratio: item.ratio ?? undefined,
+ duration: item.duration ?? undefined,
+ },
+ })
+ return asset.id
+}
+
+export async function POST(req: NextRequest) {
+ const orgId = new URL(req.url).searchParams.get('org')
+ if (!orgId) return NextResponse.json({ error: 'missing ?org' }, { status: 400 })
+
+ const rawBody = await req.text()
+
+ let secret: string | undefined = process.env.INGEST_WEBHOOK_SECRET
+ const org = await prisma.organization.findUnique({ where: { id: orgId }, select: { createdBy: true } })
+ if (!org) return NextResponse.json({ error: 'unknown org' }, { status: 404 })
+ try {
+ const auth = await prisma.platformAuth.findUnique({ where: { orgId_platform: { orgId, platform: 'ingest' } } })
+ if (auth?.apiKey) secret = auth.apiKey
+ } catch {
+ // env fallback
+ }
+
+ if (!verifyHmac({ secret, timestamp: req.headers.get('x-adex-timestamp'), signature: req.headers.get('x-adex-signature'), rawBody })) {
+ return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
+ }
+
+ let payload: unknown
+ try {
+ payload = JSON.parse(rawBody)
+ } catch {
+ return NextResponse.json({ error: 'invalid json' }, { status: 400 })
+ }
+
+ const items = parseCompetitorItems(payload)
+ if (items.length === 0) return NextResponse.json({ ok: true, results: [] })
+
+ const results: Array<{ externalId: string; status: 'created' | 'updated' | 'failed'; id?: string; error?: string }> = []
+
+ for (const item of items) {
+ try {
+ const existing = await prisma.competitorCreative.findUnique({
+ where: { orgId_source_externalId: { orgId, source: APPGROWING_SOURCE, externalId: item.externalId } },
+ })
+
+ const assetId = existing?.assetId
+ ? existing.assetId
+ : await ensureAsset(item, { orgId, uploadedBy: org.createdBy })
+
+ const fields = mapCompetitorItemToFields(item)
+
+ if (existing) {
+ const updated = await prisma.competitorCreative.update({
+ where: { id: existing.id },
+ data: { ...fields, assetId: assetId ?? existing.assetId },
+ })
+ results.push({ externalId: item.externalId, status: 'updated', id: updated.id })
+ } else {
+ const created = await prisma.competitorCreative.create({
+ data: {
+ orgId,
+ source: APPGROWING_SOURCE,
+ externalId: item.externalId,
+ assetId: assetId ?? undefined,
+ ...fields,
+ },
+ })
+ results.push({ externalId: item.externalId, status: 'created', id: created.id })
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'ingest failed'
+ results.push({ externalId: item.externalId, status: 'failed', error: message })
+ }
+ }
+
+ return NextResponse.json({ ok: true, results })
+}
diff --git a/src/lib/platforms/appgrowing.ts b/src/lib/platforms/appgrowing.ts
new file mode 100644
index 0000000..48dbc1a
--- /dev/null
+++ b/src/lib/platforms/appgrowing.ts
@@ -0,0 +1,192 @@
+/**
+ * AppGrowing competitor-intel connector (pure parsing/mapping — no DB, no
+ * network I/O here; those live in the route handler). Isolates "which
+ * source pushed this row" from the ingest route, per
+ * docs/growth/07-competitor-intel-remix.md §5 ("把'到底走哪条取数路径'这件事
+ * 隔离在一个文件里,上层不感知").
+ *
+ * Ref: docs/growth/07-competitor-intel-remix.md §4,
+ * docs/growth/09-pipeline-adex-integration.md §3
+ */
+
+import { GCS_BUCKET } from '@/lib/storage'
+import type { Prisma } from '@/generated/prisma/client'
+
+export const APPGROWING_SOURCE = 'appgrowing'
+
+/** Inbound shape for one competitor creative (POST /api/ingest/competitor). */
+export interface CompetitorIngestItem {
+ externalId: string
+ appName?: string | null
+ advertiser?: string | null
+ mediaPlatforms?: unknown
+ adFormat?: string | null
+ region?: string | null
+ language?: string | null
+ adDays?: number | null
+ impressions?: number | string | null
+ firstSeenAt?: string | null
+ lastSeenAt?: string | null
+ originalPostUrl?: string | null
+ ratio?: string | null
+ duration?: number | null
+ creativeTags?: unknown
+ sellingPoints?: unknown
+ emotionalTriggers?: unknown
+ screenUnderstanding?: unknown
+ storyboard?: unknown
+ transcript?: string | null
+ bgm?: string | null
+ aiPrompt?: string | null
+ segmentPlan?: unknown
+ rawMeta?: unknown
+ mediaUrl?: string | null
+ keyframeUrl?: string | null
+}
+
+/**
+ * Parse an inbound ingest payload — accepts a single object or an array
+ * (bare array or `{ items: [...] }`). Drops entries missing `externalId`.
+ */
+export function parseCompetitorItems(raw: unknown): CompetitorIngestItem[] {
+ const arr = Array.isArray(raw)
+ ? raw
+ : Array.isArray((raw as { items?: unknown })?.items)
+ ? (raw as { items: unknown[] }).items
+ : raw && typeof raw === 'object'
+ ? [raw]
+ : []
+
+ const out: CompetitorIngestItem[] = []
+ for (const entry of arr as Array>) {
+ if (!entry || typeof entry !== 'object') continue
+ const externalId = typeof entry.externalId === 'string' ? entry.externalId : null
+ if (!externalId) continue
+ out.push({
+ externalId,
+ appName: typeof entry.appName === 'string' ? entry.appName : null,
+ advertiser: typeof entry.advertiser === 'string' ? entry.advertiser : null,
+ mediaPlatforms: entry.mediaPlatforms ?? null,
+ adFormat: typeof entry.adFormat === 'string' ? entry.adFormat : null,
+ region: typeof entry.region === 'string' ? entry.region : null,
+ language: typeof entry.language === 'string' ? entry.language : null,
+ adDays: typeof entry.adDays === 'number' ? entry.adDays : null,
+ impressions:
+ typeof entry.impressions === 'number' || typeof entry.impressions === 'string'
+ ? entry.impressions
+ : null,
+ firstSeenAt: typeof entry.firstSeenAt === 'string' ? entry.firstSeenAt : null,
+ lastSeenAt: typeof entry.lastSeenAt === 'string' ? entry.lastSeenAt : null,
+ originalPostUrl: typeof entry.originalPostUrl === 'string' ? entry.originalPostUrl : null,
+ ratio: typeof entry.ratio === 'string' ? entry.ratio : null,
+ duration: typeof entry.duration === 'number' ? entry.duration : null,
+ creativeTags: entry.creativeTags ?? null,
+ sellingPoints: entry.sellingPoints ?? null,
+ emotionalTriggers: entry.emotionalTriggers ?? null,
+ screenUnderstanding: entry.screenUnderstanding ?? null,
+ storyboard: entry.storyboard ?? null,
+ transcript: typeof entry.transcript === 'string' ? entry.transcript : null,
+ bgm: typeof entry.bgm === 'string' ? entry.bgm : null,
+ aiPrompt: typeof entry.aiPrompt === 'string' ? entry.aiPrompt : null,
+ segmentPlan: entry.segmentPlan ?? null,
+ rawMeta: entry.rawMeta ?? null,
+ mediaUrl: typeof entry.mediaUrl === 'string' ? entry.mediaUrl : null,
+ keyframeUrl: typeof entry.keyframeUrl === 'string' ? entry.keyframeUrl : null,
+ })
+ }
+ return out
+}
+
+/** True when `url` already points at our own GCS bucket (no re-fetch needed). */
+export function isOwnGcsUrl(url: string): boolean {
+ return url.startsWith(`https://storage.googleapis.com/${GCS_BUCKET}/`)
+}
+
+/** Parse a date string, returning null on anything invalid/absent. */
+function parseDate(value: string | null | undefined): Date | null {
+ if (!value) return null
+ const d = new Date(value)
+ return Number.isNaN(d.getTime()) ? null : d
+}
+
+/** Parse impressions into a BigInt, returning null on anything invalid/absent. */
+function parseImpressions(value: number | string | null | undefined): bigint | null {
+ if (value === null || value === undefined) return null
+ try {
+ return BigInt(Math.trunc(Number(value)))
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Merge `keyframeUrl` (no dedicated column) into rawMeta so it isn't lost.
+ */
+function mergeRawMeta(item: CompetitorIngestItem): Record | null {
+ const base = item.rawMeta && typeof item.rawMeta === 'object' ? { ...(item.rawMeta as object) } : {}
+ const merged: Record = { ...base }
+ if (item.keyframeUrl) merged.keyframeUrl = item.keyframeUrl
+ return Object.keys(merged).length ? merged : null
+}
+
+type JsonField = Prisma.InputJsonValue | undefined
+
+/** Prisma create/update payload shape shared by both. */
+export interface CompetitorCreativeFields {
+ appName: string | null
+ advertiser: string | null
+ mediaPlatforms: JsonField
+ adFormat: string | null
+ region: string | null
+ language: string | null
+ adDays: number | null
+ impressions: bigint | null
+ firstSeenAt: Date | null
+ lastSeenAt: Date | null
+ originalPostUrl: string | null
+ ratio: string | null
+ duration: number | null
+ creativeTags: JsonField
+ sellingPoints: JsonField
+ emotionalTriggers: JsonField
+ screenUnderstanding: JsonField
+ storyboard: JsonField
+ transcript: string | null
+ bgm: string | null
+ aiPrompt: string | null
+ segmentPlan: JsonField
+ rawMeta: JsonField
+}
+
+function asJson(value: unknown): JsonField {
+ return value === null || value === undefined ? undefined : (value as Prisma.InputJsonValue)
+}
+
+/** Map a parsed ingest item to the Prisma field set (excludes id/org/assetId). */
+export function mapCompetitorItemToFields(item: CompetitorIngestItem): CompetitorCreativeFields {
+ return {
+ appName: item.appName ?? null,
+ advertiser: item.advertiser ?? null,
+ mediaPlatforms: asJson(item.mediaPlatforms),
+ adFormat: item.adFormat ?? null,
+ region: item.region ?? null,
+ language: item.language ?? null,
+ adDays: item.adDays ?? null,
+ impressions: parseImpressions(item.impressions),
+ firstSeenAt: parseDate(item.firstSeenAt),
+ lastSeenAt: parseDate(item.lastSeenAt),
+ originalPostUrl: item.originalPostUrl ?? null,
+ ratio: item.ratio ?? null,
+ duration: item.duration ?? null,
+ creativeTags: asJson(item.creativeTags),
+ sellingPoints: asJson(item.sellingPoints),
+ emotionalTriggers: asJson(item.emotionalTriggers),
+ screenUnderstanding: asJson(item.screenUnderstanding),
+ storyboard: asJson(item.storyboard),
+ transcript: item.transcript ?? null,
+ bgm: item.bgm ?? null,
+ aiPrompt: item.aiPrompt ?? null,
+ segmentPlan: asJson(item.segmentPlan),
+ rawMeta: asJson(mergeRawMeta(item)),
+ }
+}
From 7a8c20b5f8198562eaae4c09329f8d28993769d2 Mon Sep 17 00:00:00 2001
From: Martin <60476606+mx-320@users.noreply.github.com>
Date: Fri, 10 Jul 2026 17:48:13 +0800
Subject: [PATCH 03/11] fix(platform): seedance2 video-url + duration handling
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Real-world doubao-seedance-2-0-260128 task responses return the
generated video URL at content[].video_url.url, not output.video_url —
add resolveVideoUrl() that prefers content, falls back to output for
older/other model shapes. Also round the requested duration to whole
seconds before submitting to Ark; a fractional duration gets a 400
InvalidParameter.
Found via first real Seedance2 calls in the local creative-pipeline
tooling (commit e06e215/1224f1c).
Ref: docs/growth/09-pipeline-adex-integration.md §3
---
src/app/api/seedance2/status/route.ts | 9 +++++----
src/lib/platforms/seedance2.ts | 23 ++++++++++++++++++++++-
2 files changed, 27 insertions(+), 5 deletions(-)
diff --git a/src/app/api/seedance2/status/route.ts b/src/app/api/seedance2/status/route.ts
index de6f85a..7b40e31 100644
--- a/src/app/api/seedance2/status/route.ts
+++ b/src/app/api/seedance2/status/route.ts
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { requireAuthWithOrg } from '@/lib/auth'
-import { Seedance2Client } from '@/lib/platforms/seedance2'
+import { Seedance2Client, resolveVideoUrl } from '@/lib/platforms/seedance2'
const SEEDANCE2_API_KEY = process.env.SEEDANCE2_API_KEY || ''
@@ -33,10 +33,11 @@ export async function GET(req: NextRequest) {
if (asset) {
const updateData: Record = {}
- if (task.status === 'succeeded' && task.output?.video_url) {
+ const videoUrl = resolveVideoUrl(task)
+ if (task.status === 'succeeded' && videoUrl) {
updateData.status = 'ready'
- updateData.fileUrl = task.output.video_url
- if (task.output.duration) updateData.duration = task.output.duration
+ updateData.fileUrl = videoUrl
+ if (task.output?.duration) updateData.duration = task.output.duration
} else if (task.status === 'failed') {
updateData.status = 'failed'
updateData.errorMessage = task.error?.message || 'Generation failed'
diff --git a/src/lib/platforms/seedance2.ts b/src/lib/platforms/seedance2.ts
index e2d371f..a74c058 100644
--- a/src/lib/platforms/seedance2.ts
+++ b/src/lib/platforms/seedance2.ts
@@ -31,6 +31,10 @@ export interface Seedance2TaskRequest {
export interface Seedance2TaskResponse {
id: string
model: string
+ // On a succeeded task, doubao-seedance-2-0-260128 returns the video URL at
+ // content[].video_url.url (real-world response shape) — output.video_url
+ // below is a fallback for older/other model responses. See
+ // docs/growth/09-pipeline-adex-integration.md §3.
content: ContentItem[]
status: 'queued' | 'running' | 'succeeded' | 'failed'
error?: { code: string; message: string }
@@ -45,6 +49,17 @@ export interface Seedance2TaskResponse {
updated_at: number
}
+/**
+ * Resolve the generated video URL from a task response. Prefers
+ * `content[].video_url.url` (actual doubao-seedance-2-0-260128 response
+ * shape, confirmed 2026-07 via creative-pipeline commit e06e215/1224f1c),
+ * falls back to `output.video_url` for older/other model shapes.
+ */
+export function resolveVideoUrl(task: Seedance2TaskResponse): string | undefined {
+ const fromContent = task.content?.find((c) => c.type === 'video_url' && c.video_url?.url)?.video_url?.url
+ return fromContent || task.output?.video_url
+}
+
export class Seedance2Client {
private config: Seedance2Config
@@ -121,12 +136,18 @@ export class Seedance2Client {
}
}
+ // Ark rejects a fractional `duration` with 400 InvalidParameter — the
+ // API only accepts whole seconds (confirmed 2026-07 via creative-pipeline
+ // commit e06e215/1224f1c). Round rather than truncate so e.g. 4.6s asks
+ // for 5s, not 4s.
+ const duration = Math.round(params.duration || 5)
+
const body: Seedance2TaskRequest = {
model: this.model,
content,
generate_audio: params.generateAudio ?? false,
ratio: params.ratio || '16:9',
- duration: params.duration || 5,
+ duration,
watermark: params.watermark ?? false,
}
From 4f1b7f1bcddc62432826b9c65cffedf93756439c Mon Sep 17 00:00:00 2001
From: Martin <60476606+mx-320@users.noreply.github.com>
Date: Fri, 10 Jul 2026 17:48:21 +0800
Subject: [PATCH 04/11] test(e2e): cover competitor-intel ingest happy path +
auth
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Register→login→GET /api/orgs→POST /api/ingest/competitor round trip:
HMAC-signed ingest creates then idempotently updates a
CompetitorCreative row, a bad signature 401s, GET /api/competitors
requires a session and reflects the upsert. playwright.config.ts's
webServer.env gains INVITE_CODES_DISABLED + INGEST_WEBHOOK_SECRET so
the spec can self-provision a user/org and sign requests without a
PlatformAuth row.
Verified the code path against a build with no DATABASE_URL (fails at
the expected `DATABASE_URL is not set` boundary, confirming
registration/ingest wiring is correct) — no live Postgres was
available in this worktree to run the full assertions end-to-end.
---
e2e/competitor-ingest.spec.ts | 132 ++++++++++++++++++++++++++++++++++
playwright.config.ts | 6 ++
2 files changed, 138 insertions(+)
create mode 100644 e2e/competitor-ingest.spec.ts
diff --git a/e2e/competitor-ingest.spec.ts b/e2e/competitor-ingest.spec.ts
new file mode 100644
index 0000000..2af2d8e
--- /dev/null
+++ b/e2e/competitor-ingest.spec.ts
@@ -0,0 +1,132 @@
+/**
+ * POST /api/ingest/competitor + GET /api/competitors — happy path, HMAC
+ * rejection, and idempotent upsert. Uses the register→login programmatic
+ * pattern from .claude/rules/testing.md (no direct DB fixture setup —
+ * importing the generated Prisma client from an e2e spec file doesn't play
+ * well with Playwright's ESM test loader). Ingest auth uses the
+ * `INGEST_WEBHOOK_SECRET` env fallback (see
+ * src/app/api/ingest/competitor/route.ts) configured in
+ * playwright.config.ts's webServer.env, so no PlatformAuth row is needed.
+ *
+ * Ref: docs/growth/09-pipeline-adex-integration.md §3
+ */
+import { test, expect, APIRequestContext } from '@playwright/test'
+import crypto from 'node:crypto'
+
+const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH || ''
+const p = (path: string) => `${BASE_PATH}${path.startsWith('/') ? path : '/' + path}`
+
+// Must match playwright.config.ts webServer.env.INGEST_WEBHOOK_SECRET.
+const INGEST_SECRET = 'e2e-competitor-ingest-secret'
+
+function signHmac(secret: string, rawBody: string, timestamp = Math.floor(Date.now() / 1000)) {
+ const signature = `sha256=${crypto.createHmac('sha256', secret).update(`${timestamp}:${rawBody}`).digest('hex')}`
+ return { timestamp: String(timestamp), signature }
+}
+
+/** Register a fresh user + personal org, log in, return an authed request context + orgId. */
+async function registerAndLogin(request: APIRequestContext) {
+ const suffix = crypto.randomBytes(6).toString('hex')
+ const email = `e2e-competitor-${suffix}@adex.test`
+ const password = 'e2e-test-password-1234'
+
+ const reg = await request.post(p('/api/auth/register'), {
+ data: { email, password, name: 'E2E Competitor Tester' },
+ })
+ expect(reg.ok(), `register failed: ${reg.status()} ${await reg.text()}`).toBe(true)
+
+ const login = await request.post(p('/api/auth/login'), { data: { email, password } })
+ expect(login.ok(), `login failed: ${login.status()} ${await login.text()}`).toBe(true)
+
+ const orgsRes = await request.get(p('/api/orgs'))
+ expect(orgsRes.ok()).toBe(true)
+ const orgs = await orgsRes.json()
+ expect(Array.isArray(orgs) && orgs.length).toBeTruthy()
+
+ return { orgId: orgs[0].id as string, suffix }
+}
+
+test.describe('competitor intel ingest', () => {
+ test('rejects a request with a bad HMAC signature', async ({ request }) => {
+ const { orgId } = await registerAndLogin(request)
+ const body = JSON.stringify({ externalId: `bad-sig-${crypto.randomBytes(4).toString('hex')}` })
+ const res = await request.post(p(`/api/ingest/competitor?org=${orgId}`), {
+ headers: {
+ 'content-type': 'application/json',
+ 'x-adex-timestamp': String(Math.floor(Date.now() / 1000)),
+ 'x-adex-signature': 'sha256=deadbeef',
+ },
+ data: body,
+ })
+ expect(res.status()).toBe(401)
+ })
+
+ test('ingests a competitor creative and upserts idempotently by externalId', async ({ request }) => {
+ const { orgId, suffix } = await registerAndLogin(request)
+ const externalId = `appgrowing-${suffix}-1`
+ const payload = {
+ externalId,
+ appName: 'Puzzle Quest',
+ advertiser: 'Acme Games',
+ mediaPlatforms: ['google_ads', 'meta'],
+ adFormat: 'video',
+ region: 'US',
+ language: 'en',
+ adDays: 42,
+ impressions: 1_500_000,
+ ratio: '9:16',
+ duration: 15,
+ sellingPoints: ['fast matches', 'daily rewards'],
+ aiPrompt: 'cheerful mobile puzzle ad, bright colors',
+ }
+ const rawBody = JSON.stringify(payload)
+ const { timestamp, signature } = signHmac(INGEST_SECRET, rawBody)
+
+ const res1 = await request.post(p(`/api/ingest/competitor?org=${orgId}`), {
+ headers: {
+ 'content-type': 'application/json',
+ 'x-adex-timestamp': timestamp,
+ 'x-adex-signature': signature,
+ },
+ data: rawBody,
+ })
+ expect(res1.ok(), `ingest failed: ${res1.status()} ${await res1.text()}`).toBe(true)
+ const json1 = await res1.json()
+ expect(json1.ok).toBe(true)
+ expect(json1.results).toHaveLength(1)
+ expect(json1.results[0]).toMatchObject({ externalId, status: 'created' })
+
+ // Re-ingest the same externalId — should update, not duplicate.
+ const rawBody2 = JSON.stringify({ ...payload, adDays: 43 })
+ const sig2 = signHmac(INGEST_SECRET, rawBody2)
+ const res2 = await request.post(p(`/api/ingest/competitor?org=${orgId}`), {
+ headers: {
+ 'content-type': 'application/json',
+ 'x-adex-timestamp': sig2.timestamp,
+ 'x-adex-signature': sig2.signature,
+ },
+ data: rawBody2,
+ })
+ expect(res2.ok()).toBe(true)
+ const json2 = await res2.json()
+ expect(json2.results[0]).toMatchObject({ externalId, status: 'updated' })
+
+ // GET /api/competitors — same authed context (cookie persists) — should
+ // list exactly one row for this externalId, and reflect the update.
+ const list = await request.get(p(`/api/competitors?appName=Puzzle`))
+ expect(list.ok()).toBe(true)
+ const rows = await list.json()
+ const matches = (rows as Array<{ externalId: string; adDays: number | null }>).filter(
+ (r) => r.externalId === externalId,
+ )
+ expect(matches).toHaveLength(1)
+ expect(matches[0].adDays).toBe(43)
+ })
+
+ test('GET /api/competitors requires a logged-in session', async ({ playwright }) => {
+ const anonRequest = await playwright.request.newContext({ baseURL: `http://localhost:${process.env.PORT || '3000'}` })
+ const res = await anonRequest.get(p('/api/competitors'))
+ expect(res.status()).toBe(401)
+ await anonRequest.dispose()
+ })
+})
diff --git a/playwright.config.ts b/playwright.config.ts
index 984a1f5..ed9cfc3 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -27,6 +27,12 @@ export default defineConfig({
reuseExistingServer: !process.env.CI,
env: {
AUTH_TOKEN_SECRET: 'e2e-test-secret-do-not-use-in-production',
+ // Lets e2e/competitor-ingest.spec.ts register a fresh user without a
+ // pre-issued invite code, and sign ingest requests without needing a
+ // PlatformAuth row (route falls back to this env var — see
+ // src/app/api/ingest/competitor/route.ts).
+ INVITE_CODES_DISABLED: 'true',
+ INGEST_WEBHOOK_SECRET: 'e2e-competitor-ingest-secret',
},
},
})
From dc5671d15f1979244feb8ed6519a2359f2a89965 Mon Sep 17 00:00:00 2001
From: Martin <60476606+mx-320@users.noreply.github.com>
Date: Fri, 10 Jul 2026 17:48:28 +0800
Subject: [PATCH 05/11] docs(growth): competitor-intel remix + pipeline
integration specs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds the design docs this PR implements: 07 (competitor-intel + remix
proposal, incl. the CompetitorCreative/RemixJob model proposal), 08
(creative pipeline stages), 09 (this PR's scope — pipeline↔adex
integration, PR #1 boundaries).
---
docs/growth/07-competitor-intel-remix.md | 288 ++++++++++++++++++
.../growth/08-competitor-creative-pipeline.md | 164 ++++++++++
docs/growth/09-pipeline-adex-integration.md | 50 +++
3 files changed, 502 insertions(+)
create mode 100644 docs/growth/07-competitor-intel-remix.md
create mode 100644 docs/growth/08-competitor-creative-pipeline.md
create mode 100644 docs/growth/09-pipeline-adex-integration.md
diff --git a/docs/growth/07-competitor-intel-remix.md b/docs/growth/07-competitor-intel-remix.md
new file mode 100644
index 0000000..b21d7e1
--- /dev/null
+++ b/docs/growth/07-competitor-intel-remix.md
@@ -0,0 +1,288 @@
+# 竞品情报 + 物料 Remix — 完整调研方案 (P22)
+
+> Version v1 · 2026-07-08 · 作者调研:实地走查 AppGrowing Global 会员账号 + 通读 Adex 现有创意链路
+> 关联:[00-cuddler-first-redesign.md](00-cuddler-first-redesign.md) §6(Scene-as-Creative)· [03-creative-studio.md](03-creative-studio.md)(物料能力)· [04-status.md](04-status.md)(render seam 待补)
+> 目标:用 AppGrowing 会员账号,给 Adex 装上两件事 —
+>
+> 1. **竞品情报**:把竞品的整体物料库 + 数据分析能力接进 Adex(可搜、可看、可沉淀)。
+> 2. **物料 Remix**:以竞品爆款物料为"结构/钩子/卖点参照",用我们自己的 AI 生成栈(Seedance2 / Seedream / Claude)产出**本产品自己的**差异化物料。
+
+---
+
+## TL;DR(先看这段)
+
+- AppGrowing 是一个 **10 亿级广告素材情报库(账号内实测 4,500 万条可检索)**,覆盖 30+ 媒体渠道,每条素材都带**深度结构化元数据 + 一层 AI 分析**(卖点、情绪触发点、画面理解、逐镜 storyboard、语音转写、BGM 识别,以及**从任意片段反推一段 T2V 生成 prompt**)。这正是 Remix 的上游燃料。
+- Adex 侧 **90% 的下游已经建好**:Creative Studio(brief→变体矩阵)、`Asset.referenceData` 多模态参照位、Seedance2 `video2video/full` 生成、`ingest/scenes` 幂等入库模板、`review→push` 审核门。[04-status.md:26](04-status.md) 里那条"变体→真实 Seedance2 job→转码→推送"的 **render seam 一直空着 —— 本方案正好把它对竞品物料这一路补上**。
+- **关键取数决策**:官方合规通道优先(会员套餐内的下载/Collections 导出,或厂商宣传的 "Ad Creative Skill" for AI agents —— 待与 AppGrowing 确认),**不要**把爬内部 GraphQL 当主路径(脆弱 + 违反 ToS + 封号风险)。
+- **法务是头号约束,不是脚注**:仓库里已把"广告物料含第三方/竞品 IP"定性为**独立法律层级**([02-integration-contract.md:74](02-integration-contract.md))。本方案的核心设计原则是 **"借结构、不复刻"(derive, don't copy)** —— 竞品素材只作分析/参照输入,**产出永远是我们自己的 IP/品牌/角色**,且**任何物料上线前必须过人工审核门**。
+- **建议路线**:先做零基建的 **Phase 0 手工 PoC**(本周,10~20 条爆款 → 走一遍 Studio→Seedance2→审核),验证全链路 + 拿法务口径;跑通再依次上 竞品库入库 → Remix 引擎 → 每日自动同步。
+
+---
+
+## 1. AppGrowing 能力盘点(账号内实测)
+
+> 图例:✅ = 本次登录账号内亲眼确认;📢 = 厂商官网宣传、需向 AppGrowing 侧确认;💰 = 计费/限额项。
+> 账号语境:本次进入的是 **Game / US 区 / 某竞品 appBrand** 的 market-dashboard(URL 参数 `purpose=1&appBrand=…&subjectArea=US`)。
+
+### 1.1 数据广度
+
+| 项 | 观察 |
+| --- | --- |
+| 素材规模 | ✅ Ad Creatives 库检索显示 **"45.0M results found"**;📢 官网称 "1 billion+ ad creatives across 30+ channels" |
+| 媒体渠道 | ✅ Meta Ads / Google Ads / TikTok for Business / Facebook / Instagram / Facebook (FAN) / AdMob …(可多选过滤) |
+| 顶部导航 | ✅ Dashboard · Creatives · Insights · Analytics · Store · Collections |
+| Creatives 左栏 | ✅ Competitor Innovative Creatives、TikTok/Facebook Creative Picks;**Ad Intelligence**:Ad Creatives、Top Clips(🔒New/未解锁)、Ad Descriptions、W2A Landing Pages、Pre-Register Pages、Playable Ads;**Store Creatives**:LiveOps Creatives(🔒Pro);**AI Creatives**:AI Voice Generator |
+| Insights(套餐功能表) | 📢 Top Apps / Top Developers / Top APKs / Top AI / Top AI Websites / Top Short Dramas / Top Novels / Game Trends / CPM Trends / App Store Charts / Google Play Downloads / New Apps … |
+| 内部数据 API | ✅ 前端全部走 **GraphQL**:`POST https://api-appgrowing-global.youcloud.com/graphql`(非公开、无文档、Cookie 鉴权) |
+
+### 1.2 检索与过滤维度(Ad Creatives / `/leaflet`)
+
+✅ 亲测过滤器极其丰富,这是"竞品数据分析能力"的主体:
+
+- **Creatives / Ads 双视图** + Exact match 开关 + Deduplication Statistics
+- **Store Category**(Action/RPG/Casual/…)、**Game Tags**(Themes/Gameplay/Role Playing/…)、**Creative Tags**(Sub-gameplay / Visual Format / Character Type / Character Face / Character Age Group / Number of Characters …)
+- **Media**(上述渠道多选)
+- **Date**:Last 7/30/180/365 Days · All · 自定义区间 · **Latest / New Ads / Innovative Creatives(AI 判定"创新")/ Pre-registration**
+- **Ad Attributes**:Regions、Languages、Platforms、**Ad Format**、**Ad Days(在投天数 = 长青度/间接效果代理)**、Audience Analysis、Monetization Approach、Promotion Types、Promotion Platforms、Custom Product Pages、Original Post、Violating Ad、Cooperative Ad
+- **Creative Attributes**:Creatives Types、**Aspect Ratio**、**Video Duration**、HD、**With Voiceover**、Voiceover Languages
+- **🌟 AI Selling Point Filter**(用 AI 打的语义标签直接当过滤条件):Core Selling Points、Emotional Triggers、Screen Understanding、Characters、Selling Point、User Objective、Audience Assumption、BGM & Voiceover
+
+### 1.3 单条素材的元数据 + AI 分析(Remix 的核心燃料)
+
+✅ 点开任意一条(实测 Arknights 一条 Google Ads 视频),detail 抽屉给到两层:
+
+**A. 硬元数据**
+`Video 640×360` · 版式 `In-Stream Video / Rewarded / In-Feed`(带 Android/iOS)· Media(投放渠道)· **Ad Platform** `Google Ads` · **Region** `Japan` · **Ad Days** `1,942 天` · **Impressions** `710.4K` · Similar Creatives / **Related Ads** `19` · **Duration**(首见~末见)`2021-03-15 ~ 2026-07-08` · **Original Post**(常直链 YouTube 等**公开原始出处**)。
+
+**B. AI 分析(逐条,标签页切换)**
+
+| Tab | 产出 | 计费 |
+| --- | --- | --- |
+| **Creative Overview** | Creative Tags(Fiction Style/2D/3D/Anime Characters/…)· **Core Selling Points**(演技张力/光效冲击/机甲特效/…)· **Emotional Triggers**(战斗张力/操作爽感/…)· **Screen Understanding → Primary Visual Elements**(角色/机甲/道具/光效逐项) | 通常已缓存 |
+| **AI Prompt** 🌟 | **从任意 ≤1min 片段反推一段可用于文生视频的 prompt**(English/Chinese)。**这是 Remix 的"半成品":一段结构化的生成描述**,可直接喂我们的 Seedance2。 | 💰 **3 AI Points / 次** |
+| **Speech to Text** | 口播/旁白**逐字转写**(可做脚本参照 + 多语本地化) | 💰(计点) |
+| **BGM Recognition** | 背景音乐识别(⚠️ 音乐版权敏感,见 §6) | 💰(计点) |
+| **Video Split** | **Storyboard Extraction —— 逐镜拆解**(镜头级 storyboard,用于抽参考帧) | 💰 **10 AI Points / 次** |
+
+### 1.4 计费与限额(💰 —— 会直接决定 Remix 规模化成本)
+
+- **AI Points 计点制**:实测余额 **32,000 点**,其中 **2,000 点本月过期**(用进废退)。AI Prompt 3 点 / Storyboard 10 点 → 一条素材若两样都抽 ≈ **13 点**。
+- **下载受套餐门禁**:实测某条素材 Download 提示 **"Insufficient permissions. Please upgrade your plan."**;而 **Enterprise 套餐** 功能表标注 `Ad Download: Unlimited` / `Searching: Unlimited`(Top Apps、App Ad Overview 各 50/月)。→ **本账号非 Enterprise,下载与抽取都有额度**。
+- **Collections**:可把素材收藏进 Collections(团队版 100G),配合官方**浏览器插件**"Collect Videos on Web Pages"批量收集 —— 这是一条**官方许可的导出路径**。
+
+### 1.5 官方 AI-Agent 接入(📢 待确认,可能是最优取数路径)
+
+官网检索到:AppGrowing 宣称提供一个 **"dedicated Ad Creative Skill",可插入 Claude / ChatGPT / 自定义 agent,实时问答并拉取 AppGrowing 数据**("Select creatives, ask questions … our AI analyzes the creative strategy")。若属实,这是**最合规、最省事**的程序化取数通道 —— 但**当前会员套餐功能表里没有明列**,需**向 AppGrowing 销售确认**(是否含在套餐/加购/是否给 API key 或 MCP endpoint)。**在确认前,不把它写进关键路径**。
+
+---
+
+## 2. Adex 现状 —— 下游几乎已就绪
+
+> 全部 file:line 来自本次仓库走查。**仓内目前没有任何 appgrowing/竞品/remix 代码**,以下都是可直接对接的现成 seam。
+
+### 2.1 生成栈(Remix 的"产出端")
+
+| 能力 | 入口 | 形状 |
+| --- | --- | --- |
+| Seedance2 视频 | `POST /api/seedance2/generate` [route.ts:8](../../src/app/api/seedance2/generate/route.ts) | body `{ prompt*, mode:'text2video'\|'image2video'\|'video2video'\|'full', referenceImages?[], referenceVideos?[], referenceAudios?[], generateAudio?, ratio?, duration? }` → 建 `Asset(source:'seedance2', status:'generating', taskId, model)`,**参照 URL 落 `Asset.referenceData`(JSON)** ← Remix 参照位就在这 |
+| 轮询回写 | `GET /api/seedance2/status?taskId=&assetId=` [route.ts:8](../../src/app/api/seedance2/status/route.ts) | 成功写 `Asset.fileUrl=output.video_url, status:'ready'` |
+| Seedance2 client | [src/lib/platforms/seedance2.ts](../../src/lib/platforms/seedance2.ts) | `createAdCreative`(全多模态:text+image+video+audio refs);content 支持 `role:'reference_image'\|'reference_video'\|'reference_audio'` —— **竞品帧/视频作参照就喂这里** |
+| Studio(brief→矩阵) | `POST /api/creatives/studio` [route.ts:44](../../src/app/api/creatives/studio/route.ts) | `{ product*, platforms*[], audience?, angle?, hooks?[], languages?[] }` → `CreativeBrief` + `CreativeVariant` 矩阵 + 逐平台限字文案 |
+| Studio 出片 | `POST /api/creatives/studio/produce` [route.ts:20](../../src/app/api/creatives/studio/produce/route.ts) | `{ variantId }` → 组 storyboard+文案成 prompt,建 `Creative(source:'agent', reviewStatus:'pending')`。**注释 :12 明说实际 render 是"credentialed follow-up" —— 这就是那条空着的 seam** |
+| 文案 | `POST /api/creatives/generate-copy` [route.ts:15](../../src/app/api/creatives/generate-copy/route.ts) | `{ productDescription*, audience?, tone?, platform?, count? }` → `{ variants:[{headline,description,callToAction}] }`,限 30/hr/user |
+
+### 2.2 入库 / 同步模板(Remix 的"上游端")
+
+| 用途 | 模板 | 复用点 |
+| --- | --- | --- |
+| 推送式入库 | `POST /api/ingest/scenes?org=` [route.ts](../../src/app/api/ingest/scenes/route.ts) + [scene-import.ts](../../src/lib/growth/scene-import.ts) | HMAC 鉴权 → 解析 → LLM 打标 → **按 `(orgId, sourceRef, source)` 幂等** → 建 `Creative(source:'imported_scene', reviewStatus:'pending')`。**竞品入库直接克隆这套** |
+| 每日拉取 | `POST /api/cron/review-sync` [route.ts:47](../../src/app/api/cron/review-sync/route.ts) | 遍历 org → 拉外部 feed → LLM 分类 → **按外部 id upsert 幂等**。**竞品每日同步照抄:遍历 org → 拉 AppGrowing 精选 → 存 GCS → upsert** |
+| Cron 鉴权 | [src/lib/cron-auth.ts:34](../../src/lib/cron-auth.ts) | `verifyCronAuth(req, 'competitor-sync')` + 注册 `CronSecret` 行 |
+| 文件→Asset | `POST /api/assets/sync` [route.ts](../../src/app/api/assets/sync/route.ts) | Google Drive 递归拉取、按 `driveFileId` 去重 —— **抓取远端媒体入 Asset 的可用范例** |
+| GCS 存储 | [src/lib/storage.ts:49](../../src/lib/storage.ts) | `uploadToGCS(buffer, filename, contentType) → 公网URL`。**抓 AppGrowing 媒体 → Buffer → uploadToGCS → 即成 Seedance2 的 `reference_video/image`** |
+| 数据源连接器位 | [src/lib/platforms/](../../src/lib/platforms/) (`gdrive.ts`/`appsflyer.ts` 这类只读) | 新增 `appgrowing.ts`,归入 [00 §DATA_SOURCE_PLATFORMS](00-cuddler-first-redesign.md) 的**只读数据源**一类 |
+
+### 2.3 数据模型(现有,可挂靠)
+
+- **`Asset`** [schema.prisma:464](../../prisma/schema.prisma):`orgId/type/source/fileUrl/thumbnailUrl/prompt/`**`referenceData(JSON)`**`/taskId/status/ratio/duration/model/tags(JSON)/`**`driveFileId(去重)`** —— **竞品原始媒体的落脚点**(`source:'appgrowing'`)。
+- **`Creative`** [schema.prisma:331](../../prisma/schema.prisma):`source(…imported_scene)/`**`sourceRef(溯源)`**`/tags/`**`reviewStatus(none/pending/approved/rejected)`**`/platformPolicy` —— **Remix 产物 + 审核门**。
+- **`CreativeBrief`/`CreativeVariant`/`Campaign`/`Report`/`PlatformAuth`**([:209](../../prisma/schema.prisma),`@@unique([orgId,platform])`,open-ended `platform` 字符串 —— **`appgrowing` 凭据/Cookie 存这**)。
+- ⚠️ **无 `CreativeJob` 模型**:Seedance2 job 态内联在 `Asset.taskId+status`。若要**可查询的 Remix job 队列**,需新增模型(见 §4)。
+
+### 2.4 LLM(Remix 的"大脑")
+
+[src/lib/llm.ts](../../src/lib/llm.ts):默认 **`claude-sonnet-4-5`**(可 `ANTHROPIC_MODEL` 覆盖)。
+
+- `completeWithStructuredTool()` [:130](../../src/lib/llm.ts) —— 强制 tool-use 出结构化 + prompt-caching。**最适合做"Remix brief 抽取器":读竞品分析 + 我方产品 brief → 吐一份差异化 storyboard + Seedance2 prompt + 文案骨架**。
+- ⚠️ **现有 helper 全是纯文本 in/out,没有图像/视频输入块**。两条路:(a) **靠 AppGrowing 已抽好的 AI 分析**(卖点/storyboard/AI Prompt/转写)当文本输入 —— **推荐,零改造且省点数**;(b) 若要 Claude **亲自看**竞品画面,给 `llm.ts` 加一个多模态 `completeWithMedia` 变体。
+
+---
+
+## 3. 目标能力设计
+
+### 3.1 能力一:竞品情报库(Competitor Intelligence)
+
+把 AppGrowing 的检索/分析能力**沉淀进 Adex**,而不是每次去 AppGrowing 现查:
+
+```text
+AppGrowing 精选竞品素材
+ │ 取数(见 §5 决策:官方导出 / Collections / Skill)
+ ▼
+CompetitorCreative(我方竞品库) ── 关联 ──► Asset(GCS 存的媒体)
+ │ 元数据 + AI 分析(卖点/情绪/画面/storyboard/转写/AI Prompt/Ad Days/Impressions)
+ ▼
+Adex 内可搜可筛的竞品面板(按 app/渠道/卖点/在投天数/曝光排序)
+ → 直接回答:"这个赛道最近哪些钩子/卖点在赢?哪些创新素材值得 Remix?"
+```
+
+**独立价值**:即便不做 Remix,一个"我方自己的、可沉淀、可跨会话复用的竞品洞察库"本身就有价值(AppGrowing 会员是个人视角、易过期;Adex 里是团队资产 + 可接进 Growth Agent)。
+
+### 3.2 能力二:物料 Remix(核心)
+
+```text
+CompetitorCreative(选定的爆款) 我方产品 brief(product/IP/brand/audience/角色)
+ │ 分析层:卖点、情绪触发、逐镜 storyboard、 │
+ │ hook 时序、AI Prompt、口播脚本 │
+ └──────────────┬───────────────────────────────────────┘
+ ▼
+ Remix Brief 抽取器(Claude, completeWithStructuredTool)
+ 系统提示核心:"借结构/节奏/卖点,换我方 IP/品牌/角色/画面 —— 差异化,禁复刻"
+ ▼
+ 产出:differentiated storyboard + Seedance2 prompt + 文案骨架
+ ▼
+ Seedance2 generate(text2video 由 prompt;或 image2video/full 带**我方**品牌参照)
+ + generate-copy(限字文案)
+ ▼
+ Creative(source:'remix', sourceRef=竞品外部id, reviewStatus:'pending')
+ ▼
+ ⛔ 人工审核门(creatives/review) ── IP/授权/品牌安全 ── 批准后 ─► 平台推送
+```
+
+**核心设计原则 —— "借结构、不复刻"(写死进系统提示 + 审核清单):**
+
+1. **产出永远是我方 IP**:竞品素材只作**分析/参照输入**,**绝不把竞品的版权帧当作我方广告输出**。Seedance2 的参照优先用**我方**品牌素材(image2video/full);若用竞品帧仅作**风格/构图**参照,必须经审核判定"已充分转化"。
+2. **借的是结构层**:hook 节奏、卖点顺序、情绪曲线、版式/时长/比例 —— 这些不是版权客体,是可学习的"广告工程"。
+3. **人工审核门不可绕过**:复用现有 `reviewStatus:'pending'` → `creatives/review`,把它同时当作 **IP/授权门 + 品牌安全门**。
+4. **全程留痕**:每条 `CompetitorCreative` 与每次 Remix 都写 `sourceRef` + `logAudit()`(见 [audit.ts](../../src/lib/audit.ts)),可追溯"这条我方物料参照了哪条竞品"。
+
+---
+
+## 4. 数据模型增量(设计,非落地)
+
+> 遵循 [.claude/rules/schema.md](../../.claude/rules/schema.md):落地时 `npx prisma generate` + `npx prisma migrate dev --name competitor_intel`,并把 migration 一起提交。以下为提案。
+
+**新增 `CompetitorCreative`**(竞品情报,富元数据):
+
+```prisma
+model CompetitorCreative {
+ id String @id @default(cuid())
+ orgId String
+ source String @default("appgrowing") // 取数来源
+ externalId String // AppGrowing 素材 id → 幂等键
+ // 硬元数据
+ appName String?
+ advertiser String?
+ mediaPlatforms Json? // ["google_ads","meta",...]
+ adFormat String?
+ region String?
+ language String?
+ adDays Int? // 在投天数(长青度代理)
+ impressions BigInt?
+ firstSeenAt DateTime?
+ lastSeenAt DateTime?
+ originalPostUrl String? // 公开原始出处(YouTube 等)
+ ratio String?
+ duration Int?
+ // AI 分析层(来自 AppGrowing,缓存)
+ creativeTags Json?
+ sellingPoints Json? // core selling points
+ emotionalTriggers Json?
+ screenUnderstanding Json? // 画面元素
+ storyboard Json? // 逐镜
+ transcript String? // speech-to-text
+ bgm String? // ⚠️ 版权敏感,仅存识别结果不存音频
+ aiPrompt String? // AppGrowing 反推的生成 prompt
+ rawMeta Json? // 原始响应留档
+ // 关联
+ assetId String? // 我方 GCS 存的媒体(Asset)
+ ingestedAt DateTime @default(now())
+ @@unique([orgId, source, externalId]) // 幂等
+ @@index([orgId, appName])
+}
+```
+
+**新增 `RemixJob`**(可查询的 Remix 队列 —— 因为现有无 CreativeJob):
+
+```prisma
+model RemixJob {
+ id String @id @default(cuid())
+ orgId String
+ competitorCreativeId String
+ briefId String? // 复用 CreativeBrief
+ mode String // text2video/image2video/full
+ remixPrompt String // 抽取器产出
+ status String @default("queued") // queued/generating/ready/failed/rejected
+ assetId String? // Seedance2 产物
+ creativeId String? // 落地的 Creative
+ aiPointsSpent Int? // AppGrowing 点数成本
+ createdAt DateTime @default(now())
+ @@index([orgId, status])
+}
+```
+
+`Creative.source` 增加 `'remix'` 取值;`Asset.source` 增加 `'appgrowing'`。**均为新增,不动既有字段 —— 遵循 [CLAUDE.md 的"未经询问不删既有代码/模型"](../../CLAUDE.md)**。
+
+---
+
+## 5. 取数策略 —— 三条路,选官方
+
+> 这是整个方案里**唯一需要你拍板**的核心决策(见 §8)。
+> ✅ **2026-07-09 决议:实际走 B. 桥接(过渡)**——浏览器插件收集 + Original Post 公开出处,推 `ingest/competitor`;销售侧(Skill/API)与法务侧均已确认无阻塞。C 仍不作主路径。
+
+| 方案 | 机制 | 优点 | 缺点 / 风险 | 结论 |
+| --- | --- | --- | --- | --- |
+| **A. 官方通道(推荐)** | (1) 📢 **Ad Creative Skill / API**(若厂商确认可加购)→ 程序化实时取数;否则 (2) 会员套餐内**手工/Collections 导出 + 浏览器插件收集** → 推我方 `POST /api/ingest/competitor` | 合规、稳定、可长期化;人工精选=天然质量门 | Skill 待确认;手工路径有额度(下载/点数);半自动 | ✅ **主路径**。先手工精选,Skill 确认后升级为自动 |
+| **B. 桥接(过渡)** | 借已登录会话:官方浏览器插件"Collect Videos on Web Pages",或抓 **Original Post 公开 URL**(YouTube 等)→ 推 ingest | 用公开原始出处规避二次分发争议;半自动 | 依赖会话;ToS 边界需读细则 | 🟡 **仅作 A 的补充**(尤其"从公开出处再抓一份") |
+| **C. 爬内部 GraphQL(不推荐)** | 用会员 Cookie 直连 `api-appgrowing-global.youcloud.com/graphql` | 快、字段全 | **脆弱**(schema 随时变)· **大概率违反 ToS** · **封号风险** · 无稳定契约 | ⛔ **不作主路径**;至多做一次性 spike 验证字段结构 |
+
+**取数落地(无论 A/B):**统一收敛到一个入口 —— `POST /api/ingest/competitor?org=`(克隆 `ingest/scenes` 的 HMAC + 幂等),body 带一条或多条竞品素材的 `{externalId, 元数据, 媒体URL, AI分析}`;路由内 `fetch(媒体URL) → uploadToGCS → Asset` + `upsert CompetitorCreative`。连接器逻辑封进 `src/lib/platforms/appgrowing.ts`,**把"到底走哪条取数路径"这件事隔离在一个文件里**,上层不感知。
+
+---
+
+## 6. 风险登记册(法务列首位)
+
+| # | 风险 | 等级 | 缓解 |
+| --- | --- | --- | --- |
+| R1 | **第三方/竞品 IP、角色/品牌形象、BGM 音乐版权** 进入我方物料 | 🔴 最高 | "借结构不复刻"设计;产出=我方 IP;**人工审核门=IP 门**;`sourceRef`+audit 留痕;**Phase 2 推送前先过法务口径**;BGM 仅存识别结果、**绝不把竞品音轨用进产出** |
+| R2 | **AppGrowing ToS**:数据二次分发/自动化抓取限制;下载受套餐门禁 | 🔴 高 | 官方通道优先(§5-A);尊重套餐额度;**不把爬 GraphQL 当主路径**;读会员协议里 data-reuse 条款 |
+| R3 | **成本叠加**:AI Points(月度过期)+ 下载额度 + Seedance2 渲染 + Claude token | 🟠 中 | 优先用**已缓存**的 AI 分析(省点数);**每 org 预算上限**;Remix 变体沿用 Studio 的 `DEFAULT_MAX_VARIANTS=40` 上限;`RemixJob.aiPointsSpent` 记账 |
+| R4 | **数据准确性**:AppGrowing 标签是 AI 生成、非真值;Impressions/Ad Days 是估算 | 🟡 中 | 当信号非真理;人工精选;不基于单条数据自动决策 |
+| R5 | **品牌安全 / 平台政策**:Remix 产出可能 off-brand 或违反投放政策 | 🟡 中 | 复用 `validateCreative` + `Creative.platformPolicy` + 审核门 |
+| R6 | **取数通道稳定性**:内部 GraphQL / 未确认的 Skill 会变 | 🟡 中 | `appgrowing.ts` 连接器抽象隔离;A 为主、多路径可切 |
+
+---
+
+## 7. 分期落地
+
+| 期 | 目标 | 交付 | 依赖 |
+| --- | --- | --- | --- |
+| **Phase 0 —— 手工 PoC(本周,零基建)** | 验证全链路 + 拿法务口径 | 在 AppGrowing 精选 **10~20 条**本赛道爆款(按 Ad Days/Impressions/Innovative 排序)→ 抄其 AI Prompt / storyboard / 卖点 → 手工填进现有 `/creatives/studio` brief → `produce` → **手工触发 Seedance2** 出 3~5 条差异化变体 → 走 `creatives/review`。**完全复用已建能力,补上 [04-status.md:26](04-status.md) 那条 render seam 的第一次真实跑通** | `SEEDANCE2_API_KEY` |
+| **Phase 1 —— 竞品情报库** | 竞品数据沉淀进 Adex | `src/lib/platforms/appgrowing.ts` 连接器 · `CompetitorCreative` 模型 + migration · `POST /api/ingest/competitor`(HMAC+幂等) · 媒体入 GCS · `GET /api/competitors` 列表/筛选 · 一个只读竞品面板 | §5 取数决策 |
+| **Phase 2 —— Remix 引擎** | 竞品→我方物料自动化 | `POST /api/creatives/remix` · Claude Remix-brief 抽取器(`completeWithStructuredTool` + "差异化禁复刻"系统提示) · 接 Seedance2 生成 + generate-copy · `RemixJob` 模型 · 落 `Creative(source:'remix', reviewStatus:'pending')`。**正式补完 render seam 的竞品这一路** | Phase 1 + **法务 sign-off(R1)** |
+| **Phase 3 —— 自动化 + 情报** | 常态化运营 | `POST /api/cron/competitor-sync`(克隆 review-sync,每日拉精选 Collection) · 竞品趋势看板(哪些卖点/hook 按 Ad Days/Impressions 在赢) · 每 org 预算上限 + 完整 audit · 若确认 **AppGrowing Skill/API** 则升级取数为全自动 | Skill 确认(可选) |
+
+---
+
+## 8. 需要你拍板的开放决策
+
+1. **取数通道(§5)**:是否**联系 AppGrowing 销售确认 "Ad Creative Skill"/API 是否可用、是否在套餐内/需加购、是否给 key 或 MCP endpoint**?这决定 Phase 1/3 是全自动还是手工精选起步。(我方建议:先手工精选 + 并行去问销售。)**✅ 已解决(2026-07-09):销售侧无问题,取数按 §5 决议走 B 桥接。**
+2. **法务口径(R1)**:Phase 2 把 Remix 产物**推上平台之前**,是否需要先过一次法务对"借结构不复刻 + 人工审核门"的书面认可?(强烈建议:需要。)**✅ 已解决(2026-07-09):法务口径已确认无问题。**
+3. **先库还是先 Remix**:先做**竞品情报库**(独立有价值、风险低)再做 Remix,还是并行?(建议:Phase 0 手工 PoC 立刻做,Phase 1 库先行。)
+4. **种子范围**:先聚焦哪个**赛道/竞品/区域**?(账号当前锁定 Game / US / 某 appBrand —— 是否就以此为首个种子?)
+5. **AI Points 预算**:32,000 点(2,000 本月过期)——是否先用**将过期的 2,000 点**跑 Phase 0 的抽取(AI Prompt×3 点、Storyboard×10 点),把额度花在刀刃上?
+
+---
+
+## 附:一句话结论
+
+**下游已备好 90%(Studio + Seedance2 + ingest + 审核门),缺的正是竞品这路的取数 + 一个"借结构不复刻"的 Remix 抽取器。**建议本周先用将过期的点数跑一遍零基建手工 PoC 打通全链路、同时并行确认官方取数通道与法务口径,再按 库→引擎→自动化 三期推进。
diff --git a/docs/growth/08-competitor-creative-pipeline.md b/docs/growth/08-competitor-creative-pipeline.md
new file mode 100644
index 0000000..5ff9797
--- /dev/null
+++ b/docs/growth/08-competitor-creative-pipeline.md
@@ -0,0 +1,164 @@
+# 竞品素材生产工艺层 (Competitor Creative Pipeline — 工艺层)
+
+> Version v1 · 2026-07-09 · 定位:[07-competitor-intel-remix.md](07-competitor-intel-remix.md)(P22,系统层:取数/数据模型/法务/分期)的**配套生产工艺**——素材拿到手之后"怎么拆、怎么分级、用什么工具做出来、怎么质检"。
+> 已确认:新产品 = **Cuddler**;取数走 07 §5-B 桥接(过渡),不爬 GraphQL;销售/法务侧无阻塞;Phase 1 工艺脚本**独立目录起步**、稳定后并入 adex。
+> 方法论来源:Hakko 主 SOP(`/Users/mt/work/project/Hakko/文案&脚本&视频制作-Hakko/00_文档/广告视频产出流程与优化总结-Hakko.md`)与 Luddi 主 SOP(`/Users/mt/work/project/Luddi/文案&脚本&视频制作/文档/广告视频产出流程与优化总结.md`)。
+
+## 0. 目标与成功标准
+
+**目标**:把 AppGrowing 拉到的竞品/同品类广告素材,经"拆解 → 分级 → 路由到不同生产工具",稳定产出 Cuddler 可投放的广告素材。
+
+**成功标准**(v1 验收):
+
+1. 任一批竞品素材进入流水线后,自动产出分析产物 + 分级路由建议,无需手工跑命令。
+2. 每条产出素材过完红线质检 + 人工审核门才可标记"可投"。
+3. 首批产出 ≥10 条 IP-洁净素材(对应 [05-cuddler-playbook.md](05-cuddler-playbook.md) §0 门禁"10–14 条官方自制 IP-洁净素材")。
+4. 全链路产物落在统一目录/命名约定下,台账(manifest)可追溯每条成片的竞品来源与改造方式(与 07 §3.2 的 `sourceRef` + audit 留痕同构)。
+
+## 1. 总览
+
+```text
+① 采集入库 ② 拆解分析 ③ 分类分级 ④ 生产 ⑤ 质检→投放
+AppGrowing 素材 ──► 双分析层 ──────► L1–L4 × 素材类型 ──► 按路由调工具 ──► 红线QC → adex审核门
+(07 §5 官方通道) (AppGrowing AI 分析 (LLM建议+人工抽检) (分级见 §4) → DCO变体矩阵 → push
+ + 本地四件套)
+```
+
+五段中 ①② 全自动,③ LLM 给建议 + 人批量确认,④ 按级别自动化程度不同,⑤ 脚本预检 + 人工终审(IP/授权门永远人审,与 [05-cuddler-playbook.md](05-cuddler-playbook.md) §3 及 07 §3.2 原则四一致)。
+
+## 2. ① 采集入库 (Ingest)
+
+- **取数通道**:按 07 §5 决议(2026-07-09)走 **B. 桥接(过渡)**——借已登录会话,用官方浏览器插件 "Collect Videos on Web Pages" 收集,或抓 Original Post 公开出处(YouTube 等);**不爬内部 GraphQL**。半自动:人负责收集,落盘/推送后脚本接管。统一入口 `POST /api/ingest/competitor`(Phase 1 落地,克隆 `ingest/scenes` 的 HMAC + 幂等);Phase 0 手工阶段先"批量文件夹落盘 + manifest.csv"。
+- **优先级**:AppGrowing 的 **Ad Days(在投天数)/ Impressions / Innovative 标记**是免费的"已验证信号"——投得久、量级大的先进队列;这也决定哪些素材值得花 AI Points 做深度抽取(见 §3)。
+- **编目**:沿用 Hakko/Luddi 已验证的约定——源素材加 `已盘点_` 前缀 + `manifest.csv`,记录:竞品名、投放渠道、AppGrowing 指标(Ad Days/Impressions)、externalId(对齐 07 §4 `CompetitorCreative` 幂等键)、格式、时长、分辨率。
+- **去重**:同一竞品多渠道重复投放的素材按内容指纹(时长+抽帧 phash)合并;AppGrowing 侧已有 Deduplication Statistics 可先用。
+
+## 3. ② 拆解分析 (Analyze) — 双分析层
+
+**先用 AppGrowing 已有的 AI 分析,本地四件套做它做不了的事**(07 §2.4 的建议:零改造且省点数):
+
+**A 层 — AppGrowing 情报层**(随素材入库,存 `CompetitorCreative` 对应字段):
+
+| 产物 | 来源 | 计费 |
+| --- | --- | --- |
+| 卖点/情绪触发/画面理解/Creative Tags | Creative Overview | 通常已缓存,免费 |
+| 反推生成 prompt(Remix 半成品) | AI Prompt | 3 AI Points/次 |
+| 逐镜 storyboard | Video Split | 10 AI Points/次 |
+| 口播逐字转写 | Speech to Text | 计点 |
+
+点数纪律:只对**过了优先级筛选(§2)且分级为 L3/L4 候选**的素材花点;优先消耗将过期点数(07 §8-5)。
+
+**B 层 — 本地四件套**(对已下载媒体跑,AppGrowing 给不了的):
+
+| 产物 | 工具 | 用途 |
+| --- | --- | --- |
+| `metadata.csv` | ffprobe | 规格台账(Luddi 路径C) |
+| 关键帧拼图(0.3s/2s/5s/中段/尾帧) | ffmpeg 抽帧 | 视觉筛选(Hakko §0) |
+| `transcript.csv` 声轨转写 | whisper-cli(`ggml-base.en`/多语言 `small`) | **品牌名/slogan 声轨定位**(Hakko §0 前置听查)+ 校验 AppGrowing 转写 |
+| `ocr_frames.csv` 品牌露出扫描 | tesseract 抽帧 OCR | **delogo 坐标定位**(L1/L2 必需)+ QC 复扫 |
+
+B 层同时承担对 A 层的**真值校验**——AppGrowing 标签是 AI 估算(07 风险 R4),关键判断(品牌露出、分级)以本地扫描为准。这些命令目前在 Hakko/Luddi 是手工跑的,本流水线第一优先固化成脚本。
+
+## 4. ③ 分类分级 (Classify & Route) — 核心决策表
+
+两个轴:**改造深度 L1–L4**(决定工具、成本与 IP 风险)×**素材类型**(决定具体管线)。判据继承 Hakko §0 决策图 + Luddi C1–C8 分类。
+
+### 4.1 改造深度分级
+
+| 级别 | 判据 | 做法 | 主工具 | 单条成本/速度 | IP 洁净度 |
+| --- | --- | --- | --- | --- | --- |
+| **L1 轻改造** | 成片主体可用,品牌露出仅 logo/尾帧/固定文案(Hakko 路径A / Luddi C1–C3) | 剪尾、delogo、遮标、换尾帧 | ffmpeg 模板(Hakko §12 三套 + Luddi `process_pathC.sh`) | 分钟级,最便宜 | 🔴 低(画面仍是竞品的) |
+| **L2 重组混剪** | 有 2–8s 高价值片段但整体不能直接用(Hakko 路径B) | 抽片段重组 + 重配字幕/VO/BGM + 自有素材穿插 | ffmpeg + Luddi `build.py` 混音管线 + ElevenLabs v3 | 小时级 | 🟠 中(仍含竞品片段) |
+| **L3 复刻重制** | 素材"结构值钱、画面不能用",hook-节奏-叙事已被投放验证 | 拆分镜 → Seedance2 逐镜生成 + 自有产品录屏/UI 替换 → 重组 | Seedance2(seedance2-skill prompt 方法;AppGrowing AI Prompt 作底稿)+ OpenMontage 参考复刻模式 + adex `storyboard.ts` | 小时~天级 | ✅ 高(借结构不复刻) |
+| **L4 灵感入库** | 只提炼角度:hook 文案、卖点、人设、开场模式 | 进 hooks/angles 库 → 走 Creative Studio brief 全新生成 | adex `POST /api/creatives/studio`;口播类用 video-use,通用用 OpenMontage/Seedance2 | 与常规自制相同 | ✅ 最高 |
+
+**Cuddler 主线 = L3/L4**,即 07 §3.2 的 Remix 引擎("借结构、不复刻"):
+
+- pilot 门禁要求 IP-洁净素材,且 07 已把"物料含第三方/竞品 IP"定性为独立法律层级(R1 最高风险)——**L1/L2 产物对 Cuddler 默认不投放**。
+- L1/L2 是 Hakko/Luddi 存量业务已在用的工艺,脚本固化后两边直接受益;对 Cuddler 仅当法务口径(07 §8-2)明确允许后,才可用于小额探针验证 hook,验证过的结构仍升级为 L3 重制。
+- **强制降级**(不可被"素材很好"覆盖):含第三方 IP 角色/名人形象 → 最高 L3(画面必须重制);竞品名贯穿画面或声轨且剪不掉(Hakko"放弃"路径)→ L4。
+- **真人出镜单独判定,不是降级理由**(2026-07-10 按一线经验修正):真人+无品牌露出+通用 hook/网络梗 → 可直接按 L1/L2 复用;`has_real_face` 影响的是生成阶段输入方式(Seedance 拦截写实真人脸参照,L3 时只能文字化结构走 text2video),不影响复用分级。核心判断轴是**品牌露出位置**:尾帧/固定 logo → L1 好处理;贯穿主体(画面中段/声轨口播)才是难点,按可救程度分流 L2/L3/L4。
+- **分段路由 segment_plan**(2026-07-10 增补):整条分级之下再按时间段细化执行计划——干净且有价值的段(尤其真人梗 hook)标 `reuse` 直接复用原片,品牌贯穿的段标 `remake` 走重制,纯品牌尾帧段标 `drop` 换 Cuddler 尾帧。混合体素材(梗 hook + 品牌主体)由此实现"hook 原样复用 + 主体 AI 重制 + 拼接"——梗的价值在原片本身,严禁 AI 重生成梗段。生成 prompt 只覆盖 remake 段;拼装由 assemble.py 统一(reuse 实切 + 生成段 + 尾帧插槽,1080×1920 归一,段间 30ms 音频淡变)。
+
+### 4.2 素材类型 → 生产管线路由
+
+| 素材类型 | L3/L4 的生成管线 |
+| --- | --- |
+| 口播/talking-head | 脚本(转写作参照)→(真人拍 or AI 数字人)→ **video-use** 剪辑(去语气词/字幕/调色)+ ElevenLabs VO |
+| 短剧/情绪/POV | 分镜(AppGrowing storyboard 作底)→ **Seedance2** text2video/image2video 逐镜生成 → 拼接混音 |
+| 产品功能演示 | 自有录屏管线(Luddi `自动化游戏录屏_pipeline` 模式,适配 Cuddler UI/场景)+ 字幕/VO |
+| 混剪 montage | 自有素材池 + `beat_detect.py` 卡点 + build.py 族 |
+| 图文/静态 | Seedream 生图 + 文案(Creative Studio `generate-copy` 已有) |
+
+### 4.3 良率机制(2026-07-10 增补,方法来源:OpenMontage / video-use / seedance2-skill 调研)
+
+分析与生成的方法栈:
+
+- **结构拆解 schema**:借 OpenMontage `video-reference-analyst` 的 **5-aspect 逐镜拆解**(主体/动作/场景+文字层单列/景别构图/运镜,不适用字段显式 N/A)+ motion_type 判定(真运动/静图动画/静帧)——这是"借结构不复刻"的结构化载体,分级精排和 remix brief 都基于它。
+- **生成 prompt 模板**:seedance-prompting 的 8 组件结构 + 跨镜身份锚点逐字重复 × seedance2-skill 的 @引用语法(每个引用声明用途)+ 分时段描述(>8s 必用)。
+- **硬约束进路由**:Seedance2 参考视频 ≤3 个/总时长 2-15s(竞品素材先按分镜切片);**写实真人脸素材被模型拦截**——含真人脸的素材禁用视频参照,只能文字化结构走 text2video(分级器输出 has_real_face 维度);输出上限 720p,成片需补放大工序到 1080×1920。
+
+良率闸门(按成本从低到高,前一道不过不进下一道):
+
+1. **prompt lint**(静态免费):引用有用途声明、无冲突运镜、单镜 ≤3 动作、有音频设计、生成内不放可读文字/logo(文字层走后期 overlay)、时长与复杂度匹配。
+2. **storyboard 多样性检查**:景别多样性、静态镜占比、废话词——防"每条长一样/幻灯片感"(借 variation_checker 思路)。
+3. **sample-first 门禁**(OpenMontage Step 5,强制):先只生成 **hook 首镜 + 卖点核心镜** 两条 clip 过审,风格/结构确认后再铺全片——用最小成本提前抓风格错配。
+4. **成片 qc**:品牌词声轨/OCR 复扫(§6,已含 ASR 变体)。
+
+竖版规范(进 brief 模板,来源 OpenMontage short-form.md):hook 前 1-2s pattern interrupt、每 1-3s 换画面、底部 300-320px 平台 UI 死区不放重点、15s 档完播率最优。口播线复用 video-use 的转写→文本决策→EDL→渲染主干与现成竖版适配(字幕安全区 MarginV=90、-14 LUFS、HDR→SDR),自建它缺的 hook 前 3s 校验与 CTA 尾帧原语。
+
+## 5. ④ 生产 (Produce) — 统一收尾
+
+无论哪条路由,收尾统一(Hakko §13 / Luddi A/B 收尾合并):
+
+```text
+字幕 → ElevenLabs eleven_v3 VO(情绪 tags)→ BGM/SFX(ElevenLabs Music/SFX,绝不用竞品音轨)
+→ 混音 amix/sidechain + loudnorm I=-14:TP=-1.0(VO 1.0 / BGM 0.12–0.15 / SFX 0.2–0.3)
+→ [尾帧插槽] + [logo插槽] → libx264 crf18 + aac 192k + faststart,1080×1920 (9:16)
+```
+
+- **尾帧/logo 为插槽 (slot)**:Cuddler 品牌资产未到位前,流水线先跑通、产物停在"待尾帧"状态;资产给到后批量补渲染(IAB DCO swappable slot 思路,与 Creative Studio 同构)。
+- API key 一律运行时 `hakko-secret ` 获取,不落盘。
+- 命名沿用 Luddi 约定:`来源级别_日期_类型_内容_9x16.mp4` + 质量标签 `A_强推荐/B_可用/C_备用/X_不要用`。
+
+## 6. ⑤ 质检与投放接入 (QC → adex)
+
+1. **脚本预检**:whisper 复扫(竞品名/slogan 残留)、tesseract 复扫(logo/水印残留)、delogo 前中尾 2x 局部对比图(Luddi 质检规范)、ffprobe 规格校验。
+2. **红线清单**:Hakko R1–R10 移植为 checklist(声轨前置确认、能剪先剪、delogo 边距≥4、复评不得增加品牌色块等)+ 内容红线(无未成年疑似、无非同意、无 IP/名人脸)+ 07 §3.2 的"已充分转化"判定(若用了竞品帧作风格参照)。
+3. **入 adex**:产物落 `Creative(source:'remix', sourceRef=竞品externalId, reviewStatus:'pending')`(07 §3.2);**人工 approve(`/creatives/review`)是 IP/授权终审门,不自动化**。
+4. **变体与本地化**:审核通过后走 Creative Studio 变体矩阵做 平台×格式×钩子×语言 扇出;多语言按 Hakko 本地化 SOP 分层 L0(换尾帧)/L1(重配 VO)/L2(遮写烧录字)。
+
+## 7. 落地节奏 — 对齐 07 §7 分期
+
+数据模型、API、取数连接器以 07 §4/§5 为准;本文档补充的是各期的**工艺交付物**:
+
+| 期(同 07) | 本文档的工艺交付 |
+| --- | --- |
+| **Phase 0 手工 PoC** | 10~20 条爆款走一遍 ③④⑤:人工分级 → AppGrowing AI Prompt 改写为 Cuddler 差异化 prompt → Seedance2 出 3~5 条 → 红线 checklist 人工过。验证 L3 工艺可行性 + 单条成本 |
+| **Phase 1 情报库** | **独立目录**(已确认,`/Users/mt/work/project/github/creative-pipeline`)固化脚本:本地四件套批处理、优先级排序、分级建议器(v1 规则版,LLM 精排后续加)、QC 预检;对 Hakko/Luddi 已盘点素材回归验收 |
+| **Phase 2 Remix 引擎** | ④ 的路由管线接进 `RemixJob`:Seedance2 逐镜生成、video-use 口播线、录屏线、统一收尾脚本、尾帧/logo 插槽批量补渲染 |
+| **Phase 3 自动化** | 分级建议器接每日同步;QC 预检做成 review 页的辅助信号(残留检测结果随 Creative 展示给审核人) |
+
+工艺脚本稳定后并入 adex(转码 worker / render worker,填 [04-status.md](04-status.md) 的 render seam),入库前过 schema/契约评审。
+
+## 8. 分工
+
+**建设期(写脚本/接系统)**:
+
+- 主会话(Fable 5):流程与判据设计、分级 prompt、红线清单、Remix 抽取器系统提示("借结构不复刻"写死)、验收。
+- fast-worker:固化脚本(四件套批处理、ffmpeg 模板参数化、QC 预检、统一收尾)——验收标准:对 Hakko/Luddi 已盘点竞品素材复现出与现有 csv 一致的产物。
+- deep-reasoner:Phase 2 接 adex 前的 schema/契约评审(不可逆决策)。
+
+**运行期(日常生产)**:
+
+- 全自动:①采集编目、②双分析层、⑤脚本预检。
+- LLM 建议 + 人批量确认:③分级路由(人只看分级建议表,改错的);AI Points 花费需人确认(点数月度过期,见 07 §1.4)。
+- 人工必审:⑤ IP/授权终审门、投放前 approve;付费放量决策永远是人的(与 playbook §6 一致)。
+
+## 9. 决议记录与遗留项
+
+**已确认(2026-07-09)**:① 新产品 = Cuddler;② 取数走 07 §5-B 桥接(过渡);③ Phase 1 工艺脚本独立目录起步、稳定后并入 adex;④ 销售侧(Skill/API)与法务口径均无阻塞。
+
+**遗留决策**(在 07 §8 跟踪):首个种子赛道/竞品/区域、AI Points 预算(优先花将过期的 2,000 点)。
+
+注:法务通过的是"借结构不复刻 + 人工审核门"这套口径;§4.1 中"L1/L2 对 Cuddler 默认不投放"的限制**依旧保留**——它来自 pilot 门禁对 IP-洁净素材的要求,不随法务口径解除。若要为 Cuddler 放开 L1/L2 探针投放,需单独决策。
diff --git a/docs/growth/09-pipeline-adex-integration.md b/docs/growth/09-pipeline-adex-integration.md
new file mode 100644
index 0000000..0073267
--- /dev/null
+++ b/docs/growth/09-pipeline-adex-integration.md
@@ -0,0 +1,50 @@
+# 竞品素材流水线 ↔ Adex 接入方案
+
+> Version v1 · 2026-07-10 · 承接 [07-competitor-intel-remix.md](07-competitor-intel-remix.md) Phase 1/2 与 [08-competitor-creative-pipeline.md](08-competitor-creative-pipeline.md) §7。
+> 本地工艺层代码在 `/Users/mt/work/project/github/creative-pipeline`(独立 git 仓库,已全链路验证:分析→分级→分段路由→brief→Seedance 生成→拼装→qc)。
+
+## 1. 分工边界(为什么不全搬进 adex)
+
+| 层 | 跑在哪 | 理由 |
+| --- | --- | --- |
+| 采集(AppGrowing 桥接收集)、四件套分析(whisper/tesseract/ffmpeg)、分级、brief、剪辑拼装、qc 预检 | **本地 creative-pipeline** | 依赖本机重型工具链(whisper-cli/tesseract/ffmpeg-full),Cloud Run 容器不适合跑;素材文件大,本地处理零传输成本 |
+| 竞品情报库(CompetitorCreative)、素材库(Asset+GCS)、Remix 任务台账(RemixJob)、Seedance 生成编排、人工审核门、DCO 变体矩阵、平台推送 | **adex** | 团队共享资产、审核流程、投放链路本来就在 adex;07 §2 已确认 90% 下游现成 |
+
+接口:本地 pipeline 产出 → **HMAC 推送** adex ingest API;媒体文件 → **GCS**(`adex-data-gameclaw`,公开读,上传即得公网 URL——Seedance/Ark 参照素材直接用这个 URL,已实测 bucket 可访问)。
+
+## 2. 数据流
+
+```text
+AppGrowing(浏览器插件收集,B桥接)
+ → 本地落盘 → analyze/classify/classify_llm(segment_plan)
+ → push_to_adex.py:
+ 媒体+关键帧拼图 → GCS(uploads/competitor/…)
+ 元数据+分析JSON → POST /api/ingest/competitor(HMAC,按 externalId 幂等)
+ → adex: CompetitorCreative 行 + Asset 行(source:'appgrowing')
+ → 人在 adex 竞品面板筛选"值得做"的条目 → 建 RemixJob
+ → 本地拉 brief/生成/拼装(或 adex 直接调 Seedance——render seam)
+ → 成片回传 GCS + Creative(source:'remix', sourceRef=externalId, reviewStatus:'pending')
+ → /creatives/review 人工审核门 → 变体矩阵 → push
+```
+
+## 3. PR #1 范围(feat/competitor-intel)
+
+1. **Prisma 模型**(07 §4 提案,均为新增不动既有):`CompetitorCreative`(含 `segmentPlan Json?`——07 提案之后新增的分段路由字段)+ `RemixJob` + migration。
+2. **`POST /api/ingest/competitor?org=`**:克隆 `ingest/scenes` 的 HMAC + 幂等模式;body 为一条或多条 `{externalId, 元数据, analysis(five_aspect/segment_plan/level_v2/…), mediaUrl?, keyframeUrl?}`;`mediaUrl` 为 GCS URL 时直接挂 Asset,为外链时 fetch→uploadToGCS。
+3. **`GET /api/competitors`**:按 org 列表/筛选(level/material_type/adDays 排序)。
+4. **seedance2.ts 实战修复**(首次真实调用发现,见 creative-pipeline commit e06e215/1224f1c):
+ - 任务响应的视频 URL 在 `content.video_url`,不是现在假设的 `output.video_url`(doubao-seedance-2-0-260128 实测);
+ - `duration` 必须整数秒(浮点返回 400 InvalidParameter)。
+5. **设计文档入库**:docs/growth/07、08、09(本文档)。
+6. e2e smoke:新 ingest/competitors 路由 happy path。
+
+**不在本 PR**:每日自动同步 cron(Phase 3)、Remix 引擎 API(`/api/creatives/remix`,等本地工艺跑出足够量再抽象)、竞品面板 UI(先 API,面板下个 PR)。
+
+## 4. 本地侧配套(creative-pipeline 仓库,PR 合并后做)
+
+- `push_to_adex.py`:读批次产物 → gcloud/GCS 上传媒体 → 调 ingest API(HMAC key 经 hakko-secret)。
+- `seedance_gen.py --ref-video` 直接吃 GCS URL(已支持 URL,无需改)。
+
+## 5. 成本口径(2026-07-10 校准)
+
+Ark 按 video token 计费(`宽×高×fps×时长/1024`),实测 5s@720p = 108,900 tokens。按 Seedance 1.0 pro 公开价 ¥15/M 估:**5s ≈ ¥1.6**;25s 成片走分段路由只生成 remake 段(~12s)≈ ¥4,含试错 ¥10–30/条。待用真实账单(任务 `cgt-20260710153030-s5t5x`)校准单价常量。
From e43e3d19e2fc2a5a7890bd7ebd6cc700e886b529 Mon Sep 17 00:00:00 2001
From: Martin <60476606+mx-320@users.noreply.github.com>
Date: Fri, 10 Jul 2026 17:51:14 +0800
Subject: [PATCH 06/11] docs(growth): revise 09 to adex-native architecture
(v2)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
WHY: product decision — the whole pipeline lands on adex, not local
tooling. Control plane stays in the Next.js service; minute-scale
audio/video batch work moves to a Cloud Run Jobs worker whose image
packages the already-verified creative-pipeline scripts. Adds
per-stage design table, PR roadmap (#1-#4), cost + model-tier policy.
---
docs/growth/09-pipeline-adex-integration.md | 84 ++++++++++++---------
1 file changed, 47 insertions(+), 37 deletions(-)
diff --git a/docs/growth/09-pipeline-adex-integration.md b/docs/growth/09-pipeline-adex-integration.md
index 0073267..3986b34 100644
--- a/docs/growth/09-pipeline-adex-integration.md
+++ b/docs/growth/09-pipeline-adex-integration.md
@@ -1,50 +1,60 @@
-# 竞品素材流水线 ↔ Adex 接入方案
+# 竞品素材流水线落地 Adex 方案(adex 原生)
-> Version v1 · 2026-07-10 · 承接 [07-competitor-intel-remix.md](07-competitor-intel-remix.md) Phase 1/2 与 [08-competitor-creative-pipeline.md](08-competitor-creative-pipeline.md) §7。
-> 本地工艺层代码在 `/Users/mt/work/project/github/creative-pipeline`(独立 git 仓库,已全链路验证:分析→分级→分段路由→brief→Seedance 生成→拼装→qc)。
+> Version v2 · 2026-07-10 · 承接 [07-competitor-intel-remix.md](07-competitor-intel-remix.md) Phase 1/2 与 [08-competitor-creative-pipeline.md](08-competitor-creative-pipeline.md)。
+> **定位修正(v2)**:整套流程落在 adex 上运行,不是本地工具。本地仓库 `/Users/mt/work/project/github/creative-pipeline` 的角色 = **worker 容器镜像的内核 + 开发调试环境**——工艺脚本已在本地全链路验证(分析→分级→分段路由→brief→Seedance 生成→拼装→qc),现在原样容器化,不重写。
-## 1. 分工边界(为什么不全搬进 adex)
+## 1. 整体架构:控制面 + worker 面,都在 adex 的云上
-| 层 | 跑在哪 | 理由 |
-| --- | --- | --- |
-| 采集(AppGrowing 桥接收集)、四件套分析(whisper/tesseract/ffmpeg)、分级、brief、剪辑拼装、qc 预检 | **本地 creative-pipeline** | 依赖本机重型工具链(whisper-cli/tesseract/ffmpeg-full),Cloud Run 容器不适合跑;素材文件大,本地处理零传输成本 |
-| 竞品情报库(CompetitorCreative)、素材库(Asset+GCS)、Remix 任务台账(RemixJob)、Seedance 生成编排、人工审核门、DCO 变体矩阵、平台推送 | **adex** | 团队共享资产、审核流程、投放链路本来就在 adex;07 §2 已确认 90% 下游现成 |
+```text
+┌─ adex Cloud Run Service(Next.js,现有)────────────────────────────┐
+│ 控制面:ingest API · CompetitorCreative/RemixJob 台账 · 分级精排 │
+│ (llm.ts 调 Anthropic) · Seedance 生成编排(seedance2.ts,已有) · │
+│ 竞品面板/审核门 UI · 变体矩阵 · 平台推送 · cron 触发 │
+└──────────────┬────────────────────────────────────────────────────┘
+ │ Cloud Run Jobs API / Cloud Tasks 触发
+┌─ pipeline worker(Cloud Run Job,新增)──────────────────────────────┐
+│ 重活批处理:ffmpeg(抽帧/delogo/剪辑/拼装/转码) · whisper-cpp │
+│ (转写/qc 复扫) · tesseract(OCR 定位) —— 镜像 = creative-pipeline │
+│ 脚本 + 工具链,从 GCS 拉媒体、结果写回 DB/GCS │
+└────────────────────────────────────────────────────────────────────┘
+媒体统一落 GCS(adex-data-gameclaw,公开读)——也是 Seedance/Ark 参照素材的 URL 来源
+```
-接口:本地 pipeline 产出 → **HMAC 推送** adex ingest API;媒体文件 → **GCS**(`adex-data-gameclaw`,公开读,上传即得公网 URL——Seedance/Ark 参照素材直接用这个 URL,已实测 bucket 可访问)。
+原则:**请求-响应的事(API 调用、LLM 调用、任务编排)在 service 进程;分钟级批处理(音视频)在 Job 容器**。Cloud Run Jobs 支持长时任务,ffmpeg/tesseract/whisper-cpp(CPU 推理)容器化无障碍;service 与 job 共享同一 Cloud SQL + GCS。
-## 2. 数据流
+## 2. 各环节设计
-```text
-AppGrowing(浏览器插件收集,B桥接)
- → 本地落盘 → analyze/classify/classify_llm(segment_plan)
- → push_to_adex.py:
- 媒体+关键帧拼图 → GCS(uploads/competitor/…)
- 元数据+分析JSON → POST /api/ingest/competitor(HMAC,按 externalId 幂等)
- → adex: CompetitorCreative 行 + Asset 行(source:'appgrowing')
- → 人在 adex 竞品面板筛选"值得做"的条目 → 建 RemixJob
- → 本地拉 brief/生成/拼装(或 adex 直接调 Seedance——render seam)
- → 成片回传 GCS + Creative(source:'remix', sourceRef=externalId, reviewStatus:'pending')
- → /creatives/review 人工审核门 → 变体矩阵 → push
-```
+| # | 环节 | 跑在哪 | 触发 | 输入 → 输出 | 人的位置 |
+| --- | --- | --- | --- | --- | --- |
+| ① | 采集 | 人(浏览器插件,07 §5-B 桥接) + service | 人收集后上传 | AppGrowing 素材 → `POST /api/ingest/competitor`(元数据+AppGrowing AI 分析) + 媒体上传 GCS → `CompetitorCreative` + `Asset` | 收集与精选 |
+| ② | 拆解分析 | **analyze worker**(Job) | ingest 后由 service 派发(Cloud Tasks/cron 扫 pending) | GCS 媒体 → 四件套(metadata/关键帧拼图/转写/OCR 品牌定位)写回 DB 字段 + 拼图入 GCS。AppGrowing 自带 AI 分析优先,本地转写主要做品牌定位与真值校验(08 §3 双分析层) | 无 |
+| ③ | 分级路由 | service(llm.ts 多模态) | ②完成即触发 | 拼图 URL+转写+OCR 命中 → `level_v2` + `segment_plan` + five_aspect 存 `CompetitorCreative` | 面板上批量抽检改错 |
+| ④a | L1/L2 改造 | **edit worker**(Job) | 面板确认后建任务 | segment_plan → delogo/剪尾/重组/换尾帧插槽 → 成片入 GCS + `Creative(reviewStatus:'pending')` | 确认哪些条目投产 |
+| ④b | L3 生成 | service 编排 + Ark | `RemixJob` 状态机 | brief(service 调 LLM 生成,lint 过)→ sample-first 两镜 → 人审 → 全片各 remake 段 → Seedance API(参照素材用 GCS URL) | sample 过审;花钱确认(条数护栏在 RemixJob 上限) |
+| ④c | 拼装 | **edit worker**(Job) | ④b 各段就绪后 | reuse 段实切 + 生成段 + 尾帧插槽 → 1080×1920 归一拼接 | 无 |
+| ⑤ | qc 预检 | worker(Job 内串联) | ④a/④c 产出即跑 | 品牌词声轨/OCR 复扫 → 结果附在 Creative 上供审核人看 | 无 |
+| ⑥ | 审核→投放 | service(现有) | — | `/creatives/review` 人工终审 → 变体矩阵 → push | **IP/授权终审,不自动化** |
+
+失败处理通则:worker 任务带状态机(queued/running/done/failed+error),失败不静默重试超过 1 次,超限落 `needs_attention` 由面板展示;所有花钱动作(Seedance/AppGrowing 点数)前置护栏 + 记账(沿用 RemixJob.aiPointsSpent / spend 台账)。
-## 3. PR #1 范围(feat/competitor-intel)
+## 3. 落地顺序(PR 路线)
-1. **Prisma 模型**(07 §4 提案,均为新增不动既有):`CompetitorCreative`(含 `segmentPlan Json?`——07 提案之后新增的分段路由字段)+ `RemixJob` + migration。
-2. **`POST /api/ingest/competitor?org=`**:克隆 `ingest/scenes` 的 HMAC + 幂等模式;body 为一条或多条 `{externalId, 元数据, analysis(five_aspect/segment_plan/level_v2/…), mediaUrl?, keyframeUrl?}`;`mediaUrl` 为 GCS URL 时直接挂 Asset,为外链时 fetch→uploadToGCS。
-3. **`GET /api/competitors`**:按 org 列表/筛选(level/material_type/adDays 排序)。
-4. **seedance2.ts 实战修复**(首次真实调用发现,见 creative-pipeline commit e06e215/1224f1c):
- - 任务响应的视频 URL 在 `content.video_url`,不是现在假设的 `output.video_url`(doubao-seedance-2-0-260128 实测);
- - `duration` 必须整数秒(浮点返回 400 InvalidParameter)。
-5. **设计文档入库**:docs/growth/07、08、09(本文档)。
-6. e2e smoke:新 ingest/competitors 路由 happy path。
+| PR | 范围 | 状态 |
+| --- | --- | --- |
+| **#1 feat/competitor-intel** | `CompetitorCreative`(+segmentPlan)/`RemixJob` 模型+迁移 · `POST /api/ingest/competitor`(HMAC 幂等,媒体→GCS) · `GET /api/competitors` · seedance2.ts 实战修复(`content.video_url`、整数 duration) · 设计文档 07/08/09 入库 · e2e smoke | 进行中 |
+| **#2 pipeline worker 镜像** | `worker/Dockerfile`(ffmpeg+whisper-cpp+tesseract+creative-pipeline 脚本) · analyze/edit 两个 entrypoint · Cloud Run Jobs 部署配置 · service 侧派发 API(`POST /api/competitors/[id]/analyze` 等)+ cron 扫描 | 待 #1 |
+| **#3 分级与 Remix 编排** | llm.ts 加多模态 `completeWithMedia`(07 §2.4 预留项) · 分级精排接 ③ · `POST /api/creatives/remix`(RemixJob 状态机:brief→lint→sample→全片→拼装派发) · 生成条数/预算护栏 | 待 #2 |
+| **#4 竞品面板 UI** | 列表/筛选/分级抽检改错/任务状态/qc 结果展示,挂进 dashboard | 待 #1(可与 #2/#3 并行) |
-**不在本 PR**:每日自动同步 cron(Phase 3)、Remix 引擎 API(`/api/creatives/remix`,等本地工艺跑出足够量再抽象)、竞品面板 UI(先 API,面板下个 PR)。
+本地仓库后续动作:抽出 `worker/` 结构供 #2 直接 COPY;保留本地 CLI 作为开发调试入口(同一套代码两个入口,不分叉)。
-## 4. 本地侧配套(creative-pipeline 仓库,PR 合并后做)
+## 4. 成本口径(2026-07-10 校准)
-- `push_to_adex.py`:读批次产物 → gcloud/GCS 上传媒体 → 调 ingest API(HMAC key 经 hakko-secret)。
-- `seedance_gen.py --ref-video` 直接吃 GCS URL(已支持 URL,无需改)。
+Ark 按 video token 计费(`宽×高×fps×时长/1024`),实测 5s@720p = 108,900 tokens(任务 `cgt-20260710153030-s5t5x`)。按 Seedance 1.0 pro 公开价 ¥15/M 估:**5s ≈ ¥1.6**;25s 成片分段路由只生成 remake 段(~12s)≈ ¥4,含 sample-first 试错 **¥10–30/条 L3 成片**;L1/L2 成片 ≈ ¥0.5(仅分级 LLM)。月估(入库 200 条,产出 40 L1/L2 + 12 L3)≈ **¥400–600/月**;Cloud Run Jobs 计算费(CPU 批处理,分钟级)月估另加 ¥50–200,量级不变。待真实账单校准 Seedance 单价常量。
-## 5. 成本口径(2026-07-10 校准)
+## 5. 模型分层(什么自动走 / 什么升级)
-Ark 按 video token 计费(`宽×高×fps×时长/1024`),实测 5s@720p = 108,900 tokens。按 Seedance 1.0 pro 公开价 ¥15/M 估:**5s ≈ ¥1.6**;25s 成片走分段路由只生成 remake 段(~12s)≈ ¥4,含试错 ¥10–30/条。待用真实账单(任务 `cgt-20260710153030-s5t5x`)校准单价常量。
+- **零 LLM 全自动**(~80% 环节):四件套、规则预筛、prompt lint、ffmpeg 改造拼装、qc。有确定性算法就不请模型。
+- **Sonnet 标准档(默认,勿降 Haiku)**:分级精排/segment_plan/brief。这是路由决策——判错的下游代价(IP 漏判、误走 L3 白花生成费、真人脸误判被 Ark 拒单)远超省的几毛钱,且 IP/真人脸判定吃视觉能力。
+- **升级 Opus 档的判据 = 决策下游花钱或不可逆**:① 要投真钱的头部 L3 结构的 brief 精修;② lint 两轮不过/人审两次打回的失败复盘(问题在理解层,换强模型重写而非同档重试);③ "已充分转化"边界案例的法务风险独立意见(人终审);④ 新产品/赛道接入时的一次性设计决策。升级本身便宜(单次几元),真正贵的是 Seedance 重生成与人工返工——LLM 质量花在能避免这两样的地方。
+- **人工必须**:采集、分级抽检、sample 过审、IP/授权终审、放量决策(与 [05-cuddler-playbook.md](05-cuddler-playbook.md) §6 一致)。
From fe6095ad8ebc720cab94bd9a94466ad4e60aa963 Mon Sep 17 00:00:00 2001
From: Martin <60476606+mx-320@users.noreply.github.com>
Date: Fri, 10 Jul 2026 18:05:08 +0800
Subject: [PATCH 07/11] =?UTF-8?q?docs(growth):=20record=20storage-write=20?=
=?UTF-8?q?decision=20=E2=80=94=20media=20enters=20via=20ingest=20API=20on?=
=?UTF-8?q?ly?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/growth/09-pipeline-adex-integration.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/docs/growth/09-pipeline-adex-integration.md b/docs/growth/09-pipeline-adex-integration.md
index 3986b34..806b9ec 100644
--- a/docs/growth/09-pipeline-adex-integration.md
+++ b/docs/growth/09-pipeline-adex-integration.md
@@ -22,6 +22,8 @@
原则:**请求-响应的事(API 调用、LLM 调用、任务编排)在 service 进程;分钟级批处理(音视频)在 Job 容器**。Cloud Run Jobs 支持长时任务,ffmpeg/tesseract/whisper-cpp(CPU 推理)容器化无障碍;service 与 job 共享同一 Cloud SQL + GCS。
+**存储写入决议(2026-07-10)**:个人账号不直写生产 bucket(`adex-data-gameclaw` 对个人账号只读,IAM 不另开)——媒体**一律经 ingest API 由 Cloud Run 服务账号在服务端写入**(PR #1 的 `mediaUrl` fetch→uploadToGCS 路径)。Seedance 锚定生成所需的参照素材公网 URL 也由此获得;实验排期在 PR #1 合并部署之后。
+
## 2. 各环节设计
| # | 环节 | 跑在哪 | 触发 | 输入 → 输出 | 人的位置 |
From 8a7d6686c04ff778363e081cc2f43f0d833bb256 Mon Sep 17 00:00:00 2001
From: Martin <60476606+mx-320@users.noreply.github.com>
Date: Fri, 10 Jul 2026 19:40:56 +0800
Subject: [PATCH 08/11] fix(platform): correct seedance2 response parsing to
real API shape
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
WHY: verified against live task cgt-20260710153030-s5t5x — real
doubao-seedance-2-0-260128 returns content as an OBJECT {video_url:string}
(not an array) and duration at TOP LEVEL. Prior resolveVideoUrl threw
TypeError (content.find is not a function), leaving Assets stuck
'generating' — the PR's core fix did not work against the target model.
WHAT: resolveVideoUrl handles object/array/legacy shapes (array form skips
echoed reference_video inputs); resolveDuration reads top-level→output→
usage; generate route rounds duration before the Int? persist (was a 500
after a paid Ark job started).
---
src/app/api/seedance2/generate/route.ts | 5 +-
src/app/api/seedance2/status/route.ts | 5 +-
src/lib/platforms/seedance2.ts | 62 ++++++++++++++++++++-----
3 files changed, 58 insertions(+), 14 deletions(-)
diff --git a/src/app/api/seedance2/generate/route.ts b/src/app/api/seedance2/generate/route.ts
index c92068a..9db6cb9 100644
--- a/src/app/api/seedance2/generate/route.ts
+++ b/src/app/api/seedance2/generate/route.ts
@@ -95,7 +95,10 @@ export async function POST(req: NextRequest) {
taskId: result.id,
status: 'generating',
ratio: ratio || '16:9',
- duration: duration || 5,
+ // Asset.duration is an Int? column; a fractional client value would
+ // throw Prisma "Expected Int, provided Float" AFTER the paid Ark task
+ // already started. Round to match what createTask actually sends to Ark.
+ duration: Math.round(Number(duration) || 5),
model: 'doubao-seedance-2-0-260128',
},
})
diff --git a/src/app/api/seedance2/status/route.ts b/src/app/api/seedance2/status/route.ts
index 7b40e31..c4ee57b 100644
--- a/src/app/api/seedance2/status/route.ts
+++ b/src/app/api/seedance2/status/route.ts
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { requireAuthWithOrg } from '@/lib/auth'
-import { Seedance2Client, resolveVideoUrl } from '@/lib/platforms/seedance2'
+import { Seedance2Client, resolveVideoUrl, resolveDuration } from '@/lib/platforms/seedance2'
const SEEDANCE2_API_KEY = process.env.SEEDANCE2_API_KEY || ''
@@ -37,7 +37,8 @@ export async function GET(req: NextRequest) {
if (task.status === 'succeeded' && videoUrl) {
updateData.status = 'ready'
updateData.fileUrl = videoUrl
- if (task.output?.duration) updateData.duration = task.output.duration
+ const dur = resolveDuration(task)
+ if (dur !== undefined) updateData.duration = dur
} else if (task.status === 'failed') {
updateData.status = 'failed'
updateData.errorMessage = task.error?.message || 'Generation failed'
diff --git a/src/lib/platforms/seedance2.ts b/src/lib/platforms/seedance2.ts
index a74c058..8a06c4b 100644
--- a/src/lib/platforms/seedance2.ts
+++ b/src/lib/platforms/seedance2.ts
@@ -31,13 +31,19 @@ export interface Seedance2TaskRequest {
export interface Seedance2TaskResponse {
id: string
model: string
- // On a succeeded task, doubao-seedance-2-0-260128 returns the video URL at
- // content[].video_url.url (real-world response shape) — output.video_url
- // below is a fallback for older/other model responses. See
- // docs/growth/09-pipeline-adex-integration.md §3.
- content: ContentItem[]
+ // ⚠️ Response `content` is NOT the request's ContentItem[]. The real
+ // doubao-seedance-2-0-260128 success response (verified 2026-07-10 against
+ // task cgt-20260710153030-s5t5x) returns:
+ // content: { video_url: "https://..." } ← an OBJECT, video_url is a STRING
+ // duration: 5 ← TOP-LEVEL number
+ // output: absent
+ // Older/other models may instead use output.video_url / output.duration, or
+ // an array-form content. We keep all shapes loosely typed and resolve
+ // defensively — see resolveVideoUrl / resolveDuration. Do NOT assume an array.
+ content?: unknown
status: 'queued' | 'running' | 'succeeded' | 'failed'
error?: { code: string; message: string }
+ duration?: number
output?: {
video_url?: string
duration?: number
@@ -49,15 +55,49 @@ export interface Seedance2TaskResponse {
updated_at: number
}
+/** Pull a string url out of a content-item value that may be a bare string or `{ url }`. */
+function videoUrlFromItem(item: unknown): string | undefined {
+ if (typeof item === 'string') return item
+ if (item && typeof item === 'object') {
+ const v = (item as { video_url?: unknown }).video_url
+ if (typeof v === 'string') return v
+ if (v && typeof v === 'object' && typeof (v as { url?: unknown }).url === 'string') {
+ return (v as { url: string }).url
+ }
+ }
+ return undefined
+}
+
/**
- * Resolve the generated video URL from a task response. Prefers
- * `content[].video_url.url` (actual doubao-seedance-2-0-260128 response
- * shape, confirmed 2026-07 via creative-pipeline commit e06e215/1224f1c),
- * falls back to `output.video_url` for older/other model shapes.
+ * Resolve the generated video URL from a task response, defensive across shapes:
+ * 1. content as object `{ video_url: "http..." }` (real doubao-seedance-2-0-260128)
+ * 2. content as array of items (older/other models) — skips reference inputs (role set)
+ * 3. output.video_url (legacy fallback)
+ * Returns undefined rather than throwing on an unexpected shape.
*/
export function resolveVideoUrl(task: Seedance2TaskResponse): string | undefined {
- const fromContent = task.content?.find((c) => c.type === 'video_url' && c.video_url?.url)?.video_url?.url
- return fromContent || task.output?.video_url
+ const content = task.content
+ if (Array.isArray(content)) {
+ for (const c of content) {
+ // Skip echoed reference inputs — only the generated output has no role.
+ if (c && typeof c === 'object' && (c as { role?: unknown }).role) continue
+ const url = videoUrlFromItem(c)
+ if (url) return url
+ }
+ } else {
+ const url = videoUrlFromItem((content as { video_url?: unknown } | undefined))
+ if (url) return url
+ }
+ return task.output?.video_url
+}
+
+/**
+ * Resolve the rendered duration (seconds), defensive across shapes: real model
+ * returns it top-level; others under output/usage. undefined if none present.
+ */
+export function resolveDuration(task: Seedance2TaskResponse): number | undefined {
+ const d = task.duration ?? task.output?.duration ?? task.usage?.duration
+ return typeof d === 'number' && Number.isFinite(d) ? Math.round(d) : undefined
}
export class Seedance2Client {
From 2bd2d7204edb1751638e0da301b8348de7deffaa Mon Sep 17 00:00:00 2001
From: Martin <60476606+mx-320@users.noreply.github.com>
Date: Fri, 10 Jul 2026 19:41:11 +0800
Subject: [PATCH 09/11] feat(db): add CompetitorCreative.level column + (orgId,
level) index
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
WHY: code review — the competitors list filtered level via a rawMeta JSON
path that nothing writes (dead filter), while its sibling segmentPlan got
a real column. level is the primary routing axis (09 §3), so it warrants a
first-class indexed column, not an untyped blob key.
---
.../20260710190000_competitor_level/migration.sql | 6 ++++++
prisma/schema.prisma | 2 ++
2 files changed, 8 insertions(+)
create mode 100644 prisma/migrations/20260710190000_competitor_level/migration.sql
diff --git a/prisma/migrations/20260710190000_competitor_level/migration.sql b/prisma/migrations/20260710190000_competitor_level/migration.sql
new file mode 100644
index 0000000..13a2dc2
--- /dev/null
+++ b/prisma/migrations/20260710190000_competitor_level/migration.sql
@@ -0,0 +1,6 @@
+-- AlterTable
+ALTER TABLE "CompetitorCreative" ADD COLUMN "level" TEXT;
+
+-- CreateIndex
+CREATE INDEX "CompetitorCreative_orgId_level_idx" ON "CompetitorCreative"("orgId", "level");
+
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index d5e7ed4..d618779 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -1012,6 +1012,7 @@ model CompetitorCreative {
originalPostUrl String? // public original source (e.g. YouTube)
ratio String?
duration Int?
+ level String? // classification tier L1/L2/L3/L4 (routing axis, 09 §3)
// AI analysis layer (cached from the source, not re-derived here)
creativeTags Json?
sellingPoints Json?
@@ -1031,6 +1032,7 @@ model CompetitorCreative {
@@unique([orgId, source, externalId])
@@index([orgId, appName])
+ @@index([orgId, level])
}
// Queryable Remix job ledger — tracks turning a CompetitorCreative into an
From 6f56a1feb697b9bfbafe172d5c47aad0e5973cf4 Mon Sep 17 00:00:00 2001
From: Martin <60476606+mx-320@users.noreply.github.com>
Date: Fri, 10 Jul 2026 19:41:12 +0800
Subject: [PATCH 10/11] fix(api): harden competitor ingest + listing per code
review
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
WHY/WHAT (xhigh review findings):
- SSRF: ingest fetched caller-supplied mediaUrl with no validation → new
storage.uploadFromUrl blocks non-http(s) + private/link-local hosts.
- OOM/timeout: whole video buffered, unbounded batch → 100MB cap + 50-item
batch cap (413).
- competitors list masked all errors as empty 200 → returns 500 on failure;
level filter now hits the real column.
- parseHumanNumber fixes impressions/adDays silently dropping '710.4K' /
'1,942' (the ranking signals); mergeRawMeta no longer corrupts array
payloads; asset type prefers content-type over extension; isOwnGcsUrl +
gcsPublicPrefix consolidated into storage.ts (was duplicated).
---
src/app/api/competitors/route.ts | 15 +++---
src/app/api/ingest/competitor/route.ts | 56 ++++++++++++----------
src/lib/platforms/appgrowing.ts | 66 ++++++++++++++++++++------
src/lib/storage.ts | 62 +++++++++++++++++++++++-
4 files changed, 150 insertions(+), 49 deletions(-)
diff --git a/src/app/api/competitors/route.ts b/src/app/api/competitors/route.ts
index 9b35202..642756b 100644
--- a/src/app/api/competitors/route.ts
+++ b/src/app/api/competitors/route.ts
@@ -6,10 +6,8 @@ import { requireAuthWithOrg } from '@/lib/auth'
* GET /api/competitors?appName=&level=&orderBy=adDays|impressions&limit=
*
* Org-scoped listing of ingested competitor creatives (session auth — never
- * trusts a client-supplied orgId). `level` has no dedicated column (see
- * docs/growth/09-pipeline-adex-integration.md §3) — it's whatever the
- * source's classification stored under `rawMeta.level`, filtered via a
- * Postgres JSON-path match.
+ * trusts a client-supplied orgId). `level` is a first-class column filtered
+ * directly (indexed via @@index([orgId, level])).
*
* Ref: docs/growth/09-pipeline-adex-integration.md §3
*/
@@ -33,7 +31,7 @@ export async function GET(req: NextRequest) {
const where: Record = { orgId: org.id }
if (appName) where.appName = { contains: appName }
- if (level) where.rawMeta = { path: ['level'], equals: level }
+ if (level) where.level = level
const orderBy: Record =
orderByParam === 'adDays'
@@ -55,7 +53,10 @@ export async function GET(req: NextRequest) {
}))
return NextResponse.json(serialized)
- } catch {
- return NextResponse.json([], { status: 200 })
+ } catch (error) {
+ // Don't mask a real query failure as an empty library — surface a 500 so
+ // the caller (and logs) can tell "broken" apart from "no rows".
+ console.error('GET /api/competitors failed', error)
+ return NextResponse.json({ error: 'failed to list competitors' }, { status: 500 })
}
}
diff --git a/src/app/api/ingest/competitor/route.ts b/src/app/api/ingest/competitor/route.ts
index 3cbecbf..5f6981b 100644
--- a/src/app/api/ingest/competitor/route.ts
+++ b/src/app/api/ingest/competitor/route.ts
@@ -1,11 +1,10 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { verifyHmac } from '@/lib/growth/ingest-auth'
-import { uploadToGCS } from '@/lib/storage'
+import { isOwnGcsUrl, uploadFromUrl } from '@/lib/storage'
import {
APPGROWING_SOURCE,
parseCompetitorItems,
- isOwnGcsUrl,
mapCompetitorItemToFields,
type CompetitorIngestItem,
} from '@/lib/platforms/appgrowing'
@@ -24,8 +23,14 @@ import {
*/
const IMAGE_EXT_RE = /\.(jpe?g|png|webp|gif)(\?|$)/i
+const MAX_INGEST_ITEMS = 50
-function guessAssetType(url: string): string {
+/** image vs video, preferring the response content-type over the URL extension. */
+function assetType(url: string, contentType?: string): string {
+ if (contentType) {
+ if (contentType.startsWith('image/')) return 'image'
+ if (contentType.startsWith('video/')) return 'video'
+ }
return IMAGE_EXT_RE.test(url) ? 'image' : 'video'
}
@@ -36,37 +41,27 @@ async function ensureAsset(
const mediaUrl = item.mediaUrl
if (!mediaUrl) return null
+ // Already hosted in our own bucket → link directly, no re-fetch.
+ // External URL → SSRF/size-guarded fetch + upload (see storage.uploadFromUrl).
+ let fileUrl: string
+ let type: string
if (isOwnGcsUrl(mediaUrl)) {
- const asset = await prisma.asset.create({
- data: {
- orgId: ctx.orgId,
- uploadedBy: ctx.uploadedBy,
- name: item.appName || `Competitor ${item.externalId}`,
- type: guessAssetType(mediaUrl),
- source: APPGROWING_SOURCE,
- fileUrl: mediaUrl,
- status: 'ready',
- ratio: item.ratio ?? undefined,
- duration: item.duration ?? undefined,
- },
- })
- return asset.id
+ fileUrl = mediaUrl
+ type = assetType(mediaUrl)
+ } else {
+ const ext = IMAGE_EXT_RE.test(mediaUrl) ? 'jpg' : 'mp4'
+ const filename = `competitor/${item.externalId}-${Date.now()}.${ext}`
+ const uploaded = await uploadFromUrl(mediaUrl, filename)
+ fileUrl = uploaded.fileUrl
+ type = assetType(mediaUrl, uploaded.contentType)
}
- const res = await fetch(mediaUrl)
- if (!res.ok) throw new Error(`fetch mediaUrl failed (${res.status})`)
- const buffer = Buffer.from(await res.arrayBuffer())
- const contentType = res.headers.get('content-type') || 'application/octet-stream'
- const ext = guessAssetType(mediaUrl) === 'image' ? 'jpg' : 'mp4'
- const filename = `competitor/${item.externalId}-${Date.now()}.${ext}`
- const fileUrl = await uploadToGCS(buffer, filename, contentType)
-
const asset = await prisma.asset.create({
data: {
orgId: ctx.orgId,
uploadedBy: ctx.uploadedBy,
name: item.appName || `Competitor ${item.externalId}`,
- type: guessAssetType(mediaUrl),
+ type,
source: APPGROWING_SOURCE,
fileUrl,
status: 'ready',
@@ -106,6 +101,15 @@ export async function POST(req: NextRequest) {
const items = parseCompetitorItems(payload)
if (items.length === 0) return NextResponse.json({ ok: true, results: [] })
+ // Each item may trigger a media fetch+upload; a large batch in one request
+ // path risks Cloud Run's request timeout. Cap it — larger pushes should be
+ // chunked by the caller (or run via the pipeline worker, docs 09 §1).
+ if (items.length > MAX_INGEST_ITEMS) {
+ return NextResponse.json(
+ { error: `too many items (${items.length} > ${MAX_INGEST_ITEMS}); chunk the batch` },
+ { status: 413 },
+ )
+ }
const results: Array<{ externalId: string; status: 'created' | 'updated' | 'failed'; id?: string; error?: string }> = []
diff --git a/src/lib/platforms/appgrowing.ts b/src/lib/platforms/appgrowing.ts
index 48dbc1a..ae45ebe 100644
--- a/src/lib/platforms/appgrowing.ts
+++ b/src/lib/platforms/appgrowing.ts
@@ -9,7 +9,6 @@
* docs/growth/09-pipeline-adex-integration.md §3
*/
-import { GCS_BUCKET } from '@/lib/storage'
import type { Prisma } from '@/generated/prisma/client'
export const APPGROWING_SOURCE = 'appgrowing'
@@ -23,7 +22,8 @@ export interface CompetitorIngestItem {
adFormat?: string | null
region?: string | null
language?: string | null
- adDays?: number | null
+ level?: string | null
+ adDays?: number | string | null
impressions?: number | string | null
firstSeenAt?: string | null
lastSeenAt?: string | null
@@ -70,7 +70,11 @@ export function parseCompetitorItems(raw: unknown): CompetitorIngestItem[] {
adFormat: typeof entry.adFormat === 'string' ? entry.adFormat : null,
region: typeof entry.region === 'string' ? entry.region : null,
language: typeof entry.language === 'string' ? entry.language : null,
- adDays: typeof entry.adDays === 'number' ? entry.adDays : null,
+ level: typeof entry.level === 'string' ? entry.level : null,
+ adDays:
+ typeof entry.adDays === 'number' || typeof entry.adDays === 'string'
+ ? entry.adDays
+ : null,
impressions:
typeof entry.impressions === 'number' || typeof entry.impressions === 'string'
? entry.impressions
@@ -97,11 +101,6 @@ export function parseCompetitorItems(raw: unknown): CompetitorIngestItem[] {
return out
}
-/** True when `url` already points at our own GCS bucket (no re-fetch needed). */
-export function isOwnGcsUrl(url: string): boolean {
- return url.startsWith(`https://storage.googleapis.com/${GCS_BUCKET}/`)
-}
-
/** Parse a date string, returning null on anything invalid/absent. */
function parseDate(value: string | null | undefined): Date | null {
if (!value) return null
@@ -109,24 +108,59 @@ function parseDate(value: string | null | undefined): Date | null {
return Number.isNaN(d.getTime()) ? null : d
}
+const SUFFIX_MULT: Record = { k: 1e3, m: 1e6, b: 1e9 }
+
+/**
+ * Parse a human-formatted count into a number: accepts plain numbers,
+ * comma-grouped ("1,942"), and K/M/B-suffixed ("710.4K", "2.3M") — the exact
+ * forms AppGrowing's UI displays (docs §1.3). Returns null on anything
+ * unparseable. `Number("710.4K")` is NaN, so the old `Number()` path silently
+ * nulled these — the primary ranking signals — which this fixes.
+ */
+function parseHumanNumber(value: number | string | null | undefined): number | null {
+ if (value === null || value === undefined) return null
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null
+ const s = value.trim().replace(/,/g, '')
+ const m = /^(-?\d+(?:\.\d+)?)\s*([kmb])?$/i.exec(s)
+ if (!m) return null
+ const n = Number(m[1])
+ if (!Number.isFinite(n)) return null
+ const mult = m[2] ? SUFFIX_MULT[m[2].toLowerCase()] : 1
+ return n * mult
+}
+
/** Parse impressions into a BigInt, returning null on anything invalid/absent. */
function parseImpressions(value: number | string | null | undefined): bigint | null {
- if (value === null || value === undefined) return null
+ const n = parseHumanNumber(value)
+ if (n === null) return null
try {
- return BigInt(Math.trunc(Number(value)))
+ return BigInt(Math.trunc(n))
} catch {
return null
}
}
+/** Parse adDays into an integer, returning null on anything invalid/absent. */
+function parseAdDays(value: number | string | null | undefined): number | null {
+ const n = parseHumanNumber(value)
+ return n === null ? null : Math.trunc(n)
+}
+
/**
* Merge `keyframeUrl` (no dedicated column) into rawMeta so it isn't lost.
*/
function mergeRawMeta(item: CompetitorIngestItem): Record | null {
- const base = item.rawMeta && typeof item.rawMeta === 'object' ? { ...(item.rawMeta as object) } : {}
- const merged: Record = { ...base }
- if (item.keyframeUrl) merged.keyframeUrl = item.keyframeUrl
- return Object.keys(merged).length ? merged : null
+ // Only spread plain objects — `typeof [] === 'object'`, so an array rawMeta
+ // would be corrupted into `{0:.., 1:..}`. Keep an array payload under a key.
+ const raw = item.rawMeta
+ const base: Record =
+ raw && typeof raw === 'object' && !Array.isArray(raw)
+ ? { ...(raw as Record) }
+ : raw !== null && raw !== undefined
+ ? { _raw: raw }
+ : {}
+ if (item.keyframeUrl) base.keyframeUrl = item.keyframeUrl
+ return Object.keys(base).length ? base : null
}
type JsonField = Prisma.InputJsonValue | undefined
@@ -139,6 +173,7 @@ export interface CompetitorCreativeFields {
adFormat: string | null
region: string | null
language: string | null
+ level: string | null
adDays: number | null
impressions: bigint | null
firstSeenAt: Date | null
@@ -171,7 +206,8 @@ export function mapCompetitorItemToFields(item: CompetitorIngestItem): Competito
adFormat: item.adFormat ?? null,
region: item.region ?? null,
language: item.language ?? null,
- adDays: item.adDays ?? null,
+ level: item.level ?? null,
+ adDays: parseAdDays(item.adDays),
impressions: parseImpressions(item.impressions),
firstSeenAt: parseDate(item.firstSeenAt),
lastSeenAt: parseDate(item.lastSeenAt),
diff --git a/src/lib/storage.ts b/src/lib/storage.ts
index 5428aa9..c42f37e 100644
--- a/src/lib/storage.ts
+++ b/src/lib/storage.ts
@@ -78,7 +78,7 @@ export async function uploadToGCS(
* Delete a file from GCS by its public URL.
*/
export async function deleteFromGCS(publicUrl: string): Promise {
- const prefix = `https://storage.googleapis.com/${GCS_BUCKET}/`
+ const prefix = gcsPublicPrefix()
if (!publicUrl.startsWith(prefix)) return
const objectPath = publicUrl.slice(prefix.length)
@@ -92,6 +92,66 @@ export async function deleteFromGCS(publicUrl: string): Promise {
})
}
+/** Public URL prefix for objects in our own bucket. Single source of truth. */
+export function gcsPublicPrefix(): string {
+ return `https://storage.googleapis.com/${GCS_BUCKET}/`
+}
+
+/** Is this URL an object already hosted in our own GCS bucket? */
+export function isOwnGcsUrl(url: string): boolean {
+ return url.startsWith(gcsPublicPrefix())
+}
+
+const PRIVATE_HOST_RE =
+ /^(localhost$|127\.|10\.|192\.168\.|169\.254\.|::1$|\[::1\]$|0\.0\.0\.0$|metadata\.google\.internal$)|^172\.(1[6-9]|2\d|3[01])\./i
+
+/**
+ * Fetch a remote media URL and upload it to GCS, returning the public URL.
+ * Hardened for server-side fetch of caller-supplied URLs:
+ * - only http/https, and the host must not resolve to a private/link-local
+ * range (blocks SSRF to the metadata server / internal services)
+ * - rejects bodies over `maxBytes` (Content-Length pre-check + hard cap while
+ * reading) so one oversized file can't OOM the instance
+ * Returns `{ fileUrl, contentType }`. Throws on validation/size/fetch failure.
+ */
+export async function uploadFromUrl(
+ url: string,
+ filename: string,
+ opts: { maxBytes?: number } = {},
+): Promise<{ fileUrl: string; contentType: string }> {
+ const maxBytes = opts.maxBytes ?? 100 * 1024 * 1024 // 100MB default cap
+
+ let parsed: URL
+ try {
+ parsed = new URL(url)
+ } catch {
+ throw new Error('invalid mediaUrl')
+ }
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
+ throw new Error(`mediaUrl scheme not allowed: ${parsed.protocol}`)
+ }
+ if (PRIVATE_HOST_RE.test(parsed.hostname)) {
+ throw new Error(`mediaUrl host not allowed: ${parsed.hostname}`)
+ }
+
+ const res = await fetch(url, { redirect: 'error' })
+ if (!res.ok) throw new Error(`fetch mediaUrl failed (${res.status})`)
+
+ const declaredLen = Number(res.headers.get('content-length') || '0')
+ if (declaredLen > maxBytes) {
+ throw new Error(`mediaUrl too large (${declaredLen} > ${maxBytes})`)
+ }
+
+ const buffer = Buffer.from(await res.arrayBuffer())
+ if (buffer.byteLength > maxBytes) {
+ throw new Error(`mediaUrl too large (${buffer.byteLength} > ${maxBytes})`)
+ }
+
+ const contentType = res.headers.get('content-type')?.split(';')[0].trim() || 'application/octet-stream'
+ const fileUrl = await uploadToGCS(buffer, filename, contentType)
+ return { fileUrl, contentType }
+}
+
/**
* Check if GCS is available (for graceful fallback in dev).
*/
From 6615e7fb1b8437e38b306ffa97307facb6eeec22 Mon Sep 17 00:00:00 2001
From: Martin <60476606+mx-320@users.noreply.github.com>
Date: Fri, 10 Jul 2026 19:41:12 +0800
Subject: [PATCH 11/11] =?UTF-8?q?test(app):=20make=20competitor-ingest=20e?=
=?UTF-8?q?2e=20robust=20=E2=80=94=20shared=20registration?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
WHY: register is rate-limited 5/hour/IP; registering per-test flaked across
retries/reruns. Register once in beforeAll and share the authed context;
the anon test uses the default unauthenticated request fixture (config
baseURL, no hardcoded localhost).
---
e2e/competitor-ingest.spec.ts | 68 +++++++++++++++++++----------------
1 file changed, 38 insertions(+), 30 deletions(-)
diff --git a/e2e/competitor-ingest.spec.ts b/e2e/competitor-ingest.spec.ts
index 2af2d8e..7f74e62 100644
--- a/e2e/competitor-ingest.spec.ts
+++ b/e2e/competitor-ingest.spec.ts
@@ -24,33 +24,42 @@ function signHmac(secret: string, rawBody: string, timestamp = Math.floor(Date.n
return { timestamp: String(timestamp), signature }
}
-/** Register a fresh user + personal org, log in, return an authed request context + orgId. */
-async function registerAndLogin(request: APIRequestContext) {
- const suffix = crypto.randomBytes(6).toString('hex')
- const email = `e2e-competitor-${suffix}@adex.test`
- const password = 'e2e-test-password-1234'
+// Register ONE user for the whole describe. The register endpoint is rate-
+// limited to 5/hour/IP, so registering per-test (× retries × reruns against a
+// reused dev server) flakes; the authed tests share this one context.
+let authed: APIRequestContext
+let orgId: string
+const suffix = crypto.randomBytes(6).toString('hex')
- const reg = await request.post(p('/api/auth/register'), {
- data: { email, password, name: 'E2E Competitor Tester' },
- })
- expect(reg.ok(), `register failed: ${reg.status()} ${await reg.text()}`).toBe(true)
+test.describe('competitor intel ingest', () => {
+ test.beforeAll(async ({ playwright }, testInfo) => {
+ const baseURL = testInfo.project.use.baseURL
+ authed = await playwright.request.newContext({ baseURL })
+ const email = `e2e-competitor-${suffix}@adex.test`
+ const password = 'e2e-test-password-1234'
- const login = await request.post(p('/api/auth/login'), { data: { email, password } })
- expect(login.ok(), `login failed: ${login.status()} ${await login.text()}`).toBe(true)
+ const reg = await authed.post(p('/api/auth/register'), {
+ data: { email, password, name: 'E2E Competitor Tester' },
+ })
+ expect(reg.ok(), `register failed: ${reg.status()} ${await reg.text()}`).toBe(true)
- const orgsRes = await request.get(p('/api/orgs'))
- expect(orgsRes.ok()).toBe(true)
- const orgs = await orgsRes.json()
- expect(Array.isArray(orgs) && orgs.length).toBeTruthy()
+ const login = await authed.post(p('/api/auth/login'), { data: { email, password } })
+ expect(login.ok(), `login failed: ${login.status()} ${await login.text()}`).toBe(true)
- return { orgId: orgs[0].id as string, suffix }
-}
+ const orgsRes = await authed.get(p('/api/orgs'))
+ expect(orgsRes.ok()).toBe(true)
+ const orgs = await orgsRes.json()
+ expect(Array.isArray(orgs) && orgs.length).toBeTruthy()
+ orgId = orgs[0].id as string
+ })
-test.describe('competitor intel ingest', () => {
- test('rejects a request with a bad HMAC signature', async ({ request }) => {
- const { orgId } = await registerAndLogin(request)
+ test.afterAll(async () => {
+ await authed?.dispose()
+ })
+
+ test('rejects a request with a bad HMAC signature', async () => {
const body = JSON.stringify({ externalId: `bad-sig-${crypto.randomBytes(4).toString('hex')}` })
- const res = await request.post(p(`/api/ingest/competitor?org=${orgId}`), {
+ const res = await authed.post(p(`/api/ingest/competitor?org=${orgId}`), {
headers: {
'content-type': 'application/json',
'x-adex-timestamp': String(Math.floor(Date.now() / 1000)),
@@ -61,8 +70,7 @@ test.describe('competitor intel ingest', () => {
expect(res.status()).toBe(401)
})
- test('ingests a competitor creative and upserts idempotently by externalId', async ({ request }) => {
- const { orgId, suffix } = await registerAndLogin(request)
+ test('ingests a competitor creative and upserts idempotently by externalId', async () => {
const externalId = `appgrowing-${suffix}-1`
const payload = {
externalId,
@@ -82,7 +90,7 @@ test.describe('competitor intel ingest', () => {
const rawBody = JSON.stringify(payload)
const { timestamp, signature } = signHmac(INGEST_SECRET, rawBody)
- const res1 = await request.post(p(`/api/ingest/competitor?org=${orgId}`), {
+ const res1 = await authed.post(p(`/api/ingest/competitor?org=${orgId}`), {
headers: {
'content-type': 'application/json',
'x-adex-timestamp': timestamp,
@@ -99,7 +107,7 @@ test.describe('competitor intel ingest', () => {
// Re-ingest the same externalId — should update, not duplicate.
const rawBody2 = JSON.stringify({ ...payload, adDays: 43 })
const sig2 = signHmac(INGEST_SECRET, rawBody2)
- const res2 = await request.post(p(`/api/ingest/competitor?org=${orgId}`), {
+ const res2 = await authed.post(p(`/api/ingest/competitor?org=${orgId}`), {
headers: {
'content-type': 'application/json',
'x-adex-timestamp': sig2.timestamp,
@@ -113,7 +121,7 @@ test.describe('competitor intel ingest', () => {
// GET /api/competitors — same authed context (cookie persists) — should
// list exactly one row for this externalId, and reflect the update.
- const list = await request.get(p(`/api/competitors?appName=Puzzle`))
+ const list = await authed.get(p(`/api/competitors?appName=Puzzle`))
expect(list.ok()).toBe(true)
const rows = await list.json()
const matches = (rows as Array<{ externalId: string; adDays: number | null }>).filter(
@@ -123,10 +131,10 @@ test.describe('competitor intel ingest', () => {
expect(matches[0].adDays).toBe(43)
})
- test('GET /api/competitors requires a logged-in session', async ({ playwright }) => {
- const anonRequest = await playwright.request.newContext({ baseURL: `http://localhost:${process.env.PORT || '3000'}` })
- const res = await anonRequest.get(p('/api/competitors'))
+ test('GET /api/competitors requires a logged-in session', async ({ request }) => {
+ // The default `request` fixture is unauthenticated and honours the config
+ // baseURL — no hardcoded host, no shared cookies.
+ const res = await request.get(p('/api/competitors'))
expect(res.status()).toBe(401)
- await anonRequest.dispose()
})
})