feat: columna ad_daily.link_clicks (Meta clics enlace reales)
Persistimos link_clicks en warehouse (con backfill desde __lc), sync escribe la columna y creatividades la leen sin romper si falta. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
103
scripts/dokploy_apply_ad_daily_link_clicks.py
Normal file
103
scripts/dokploy_apply_ad_daily_link_clicks.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Apply sql/ad_daily_link_clicks.sql via Dokploy supabase-db terminal."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
ROOT = Path("/Users/4ventures/4ventures-client-portal")
|
||||||
|
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
|
||||||
|
SQL = ROOT / "sql" / "ad_daily_link_clicks.sql"
|
||||||
|
BASE = "https://dokploy.4ventures.es"
|
||||||
|
|
||||||
|
|
||||||
|
def env(key: str) -> str:
|
||||||
|
v = os.environ.get(key, "").strip()
|
||||||
|
if v:
|
||||||
|
return v
|
||||||
|
p = BPS / ".env"
|
||||||
|
if not p.is_file():
|
||||||
|
return ""
|
||||||
|
for line in p.read_text().splitlines():
|
||||||
|
if line.startswith(f"{key}="):
|
||||||
|
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||||
|
password = env("DOKPLOY_PASSWORD")
|
||||||
|
if not password:
|
||||||
|
print("NO_PASSWORD", flush=True)
|
||||||
|
return 2
|
||||||
|
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
|
||||||
|
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||||
|
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
|
||||||
|
page.wait_for_timeout(800)
|
||||||
|
if "/dashboard" not in page.url:
|
||||||
|
page.locator('input[type="email"], input[name="email"]').first.fill(email)
|
||||||
|
page.locator('input[type="password"]').first.fill(password)
|
||||||
|
page.locator('button[type="submit"]').first.click()
|
||||||
|
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
|
||||||
|
print("login ok", flush=True)
|
||||||
|
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
|
||||||
|
page.wait_for_timeout(800)
|
||||||
|
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
|
||||||
|
"supabase-db"
|
||||||
|
)
|
||||||
|
page.wait_for_timeout(1000)
|
||||||
|
page.keyboard.press("Escape")
|
||||||
|
page.wait_for_timeout(200)
|
||||||
|
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
|
||||||
|
page.wait_for_timeout(400)
|
||||||
|
page.get_by_text("Terminal", exact=True).click()
|
||||||
|
page.wait_for_timeout(1000)
|
||||||
|
if page.locator("button", has_text="Bash").count():
|
||||||
|
page.locator("button", has_text="Bash").first.click()
|
||||||
|
page.wait_for_timeout(2800)
|
||||||
|
if page.locator("textarea.xterm-helper-textarea").count():
|
||||||
|
page.locator("textarea.xterm-helper-textarea").first.click()
|
||||||
|
|
||||||
|
def run(cmd: str, wait: int = 800) -> None:
|
||||||
|
page.keyboard.type(cmd, delay=1)
|
||||||
|
page.keyboard.press("Enter")
|
||||||
|
page.wait_for_timeout(wait)
|
||||||
|
|
||||||
|
run("rm -f /tmp/adlc.b64 /tmp/adlc.sql; echo READY", 1200)
|
||||||
|
for i, ch in enumerate(chunks):
|
||||||
|
op = ">" if i == 0 else ">>"
|
||||||
|
run(f"printf '%s' '{ch}' {op} /tmp/adlc.b64", 400)
|
||||||
|
run(
|
||||||
|
"base64 -d /tmp/adlc.b64 > /tmp/adlc.sql && "
|
||||||
|
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/adlc.sql; echo EXIT:$?",
|
||||||
|
12000,
|
||||||
|
)
|
||||||
|
run(
|
||||||
|
"psql -U postgres -d postgres -c "
|
||||||
|
"\"SELECT column_name,data_type FROM information_schema.columns "
|
||||||
|
"WHERE table_schema='public' AND table_name='ad_daily' "
|
||||||
|
"AND column_name='link_clicks';\" ; echo COL_DONE",
|
||||||
|
4000,
|
||||||
|
)
|
||||||
|
text = page.evaluate(
|
||||||
|
"() => (document.querySelector('.xterm-rows')||{}).innerText||''"
|
||||||
|
)
|
||||||
|
browser.close()
|
||||||
|
ok = "EXIT:0" in text and "link_clicks" in text
|
||||||
|
print("SQL_OK", ok, flush=True)
|
||||||
|
if not ok:
|
||||||
|
print(text[-2000:], flush=True)
|
||||||
|
return 0 if ok else 6
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -1,3 +1,18 @@
|
|||||||
-- Meta: clics en el enlace (Ads Manager) aparte de clicks = clics todos.
|
-- Meta: clics en el enlace (Ads Manager) aparte de clicks = clics todos.
|
||||||
ALTER TABLE public.ad_daily
|
ALTER TABLE public.ad_daily
|
||||||
ADD COLUMN IF NOT EXISTS link_clicks numeric(14, 6) NOT NULL DEFAULT 0;
|
ADD COLUMN IF NOT EXISTS link_clicks numeric(14, 6) NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Backfill from legacy Meta marker stored in ad_name (`__lc:N`).
|
||||||
|
UPDATE public.ad_daily
|
||||||
|
SET link_clicks = (regexp_match(ad_name, '^__lc:([0-9.]+)$'))[1]::numeric
|
||||||
|
WHERE platform = 'meta'
|
||||||
|
AND ad_name ~ '^__lc:'
|
||||||
|
AND (link_clicks IS NULL OR link_clicks = 0);
|
||||||
|
|
||||||
|
-- Google: link clicks ≈ all clicks at campaign level.
|
||||||
|
UPDATE public.ad_daily
|
||||||
|
SET link_clicks = clicks
|
||||||
|
WHERE platform = 'google'
|
||||||
|
AND (link_clicks IS NULL OR link_clicks = 0)
|
||||||
|
AND clicks IS NOT NULL
|
||||||
|
AND clicks > 0;
|
||||||
|
|||||||
@@ -76,23 +76,39 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
const agency = isAgencyRole(userRole(user));
|
const agency = isAgencyRole(userRole(user));
|
||||||
let daily: AdDailyRow[] = [];
|
let daily: AdDailyRow[] = [];
|
||||||
|
const selectWithLc =
|
||||||
|
'date,platform,campaign_name,ad_id,ad_name,impressions,clicks,link_clicks,spend,platform_leads';
|
||||||
|
const selectNoLc =
|
||||||
|
'date,platform,campaign_name,ad_id,ad_name,impressions,clicks,spend,platform_leads';
|
||||||
if (agency && ctx.viaView) {
|
if (agency && ctx.viaView) {
|
||||||
const tid = encodeURIComponent(ctx.tenantId);
|
const tid = encodeURIComponent(ctx.tenantId);
|
||||||
const d = await restGet<AdDailyRow[]>(
|
let d = await restGet<AdDailyRow[]>(
|
||||||
`/rest/v1/ad_daily?tenant_id=eq.${tid}&date=gte.${since}&date=lte.${until}&select=date,platform,campaign_name,ad_id,ad_name,impressions,clicks,spend,platform_leads&order=date.desc&limit=5000`
|
`/rest/v1/ad_daily?tenant_id=eq.${tid}&date=gte.${since}&date=lte.${until}&select=${selectWithLc}&order=date.desc&limit=5000`
|
||||||
);
|
);
|
||||||
|
if (!d.ok && /link_clicks/i.test(String(d.error || ''))) {
|
||||||
|
d = await restGet<AdDailyRow[]>(
|
||||||
|
`/rest/v1/ad_daily?tenant_id=eq.${tid}&date=gte.${since}&date=lte.${until}&select=${selectNoLc}&order=date.desc&limit=5000`
|
||||||
|
);
|
||||||
|
}
|
||||||
daily = d.data || [];
|
daily = d.data || [];
|
||||||
} else {
|
} else {
|
||||||
const { data } = await supabase
|
let q = await supabase
|
||||||
.from('ad_daily')
|
.from('ad_daily')
|
||||||
.select(
|
.select(selectWithLc)
|
||||||
'date,platform,campaign_name,ad_id,ad_name,impressions,clicks,spend,platform_leads'
|
|
||||||
)
|
|
||||||
.gte('date', since)
|
.gte('date', since)
|
||||||
.lte('date', until)
|
.lte('date', until)
|
||||||
.order('date', { ascending: false })
|
.order('date', { ascending: false })
|
||||||
.limit(5000);
|
.limit(5000);
|
||||||
daily = (data as AdDailyRow[]) || [];
|
if (q.error && /link_clicks/i.test(q.error.message || '')) {
|
||||||
|
q = await supabase
|
||||||
|
.from('ad_daily')
|
||||||
|
.select(selectNoLc)
|
||||||
|
.gte('date', since)
|
||||||
|
.lte('date', until)
|
||||||
|
.order('date', { ascending: false })
|
||||||
|
.limit(5000);
|
||||||
|
}
|
||||||
|
daily = (q.data as AdDailyRow[]) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|||||||
@@ -365,30 +365,50 @@ async function replaceDaily(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// chunk inserts (link_clicks → ad_name marker until column exists)
|
// chunk inserts — prefer link_clicks column; fallback to Meta `__lc:N` marker
|
||||||
for (let i = 0; i < rows.length; i += 400) {
|
for (let i = 0; i < rows.length; i += 400) {
|
||||||
const chunk = rows.slice(i, i + 400).map((r) => {
|
const full = rows.slice(i, i + 400).map((r) => ({
|
||||||
const link = Math.round(r.link_clicks ?? r.clicks);
|
...r,
|
||||||
const { link_clicks: _lc, ...rest } = r;
|
link_clicks: Math.round(r.link_clicks ?? r.clicks),
|
||||||
if (r.platform === 'meta') {
|
ad_name: r.ad_name || '',
|
||||||
return { ...rest, ad_name: `__lc:${link}` };
|
}));
|
||||||
}
|
let r = await fetch(`${base}/rest/v1/ad_daily`, {
|
||||||
return rest;
|
|
||||||
});
|
|
||||||
const r = await fetch(`${base}/rest/v1/ad_daily`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
...headers,
|
...headers,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Prefer: 'return=minimal',
|
Prefer: 'return=minimal',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(chunk),
|
body: JSON.stringify(full),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const errText = await r.text();
|
||||||
|
if (!/link_clicks/i.test(errText)) {
|
||||||
|
throw new Error(`insert ad_daily: ${errText.slice(0, 220)}`);
|
||||||
|
}
|
||||||
|
const legacy = full.map((row) => {
|
||||||
|
const link = Math.round(row.link_clicks ?? row.clicks);
|
||||||
|
const { link_clicks: _lc, ...rest } = row;
|
||||||
|
if (row.platform === 'meta') {
|
||||||
|
return { ...rest, ad_name: `__lc:${link}` };
|
||||||
|
}
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
r = await fetch(`${base}/rest/v1/ad_daily`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'return=minimal',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(legacy),
|
||||||
});
|
});
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
throw new Error(`insert ad_daily: ${(await r.text()).slice(0, 220)}`);
|
throw new Error(`insert ad_daily: ${(await r.text()).slice(0, 220)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function upsertWeeklyFromDaily(tenantId: string, days: number) {
|
async function upsertWeeklyFromDaily(tenantId: string, days: number) {
|
||||||
const headers = serviceHeaders();
|
const headers = serviceHeaders();
|
||||||
|
|||||||
@@ -29,13 +29,13 @@ export type AdDailyRow = {
|
|||||||
tenant_id?: string;
|
tenant_id?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Meta link clicks stored as ad_name `__lc:N` until link_clicks column is used. */
|
/** Meta link clicks: column first, else `__lc:N` in ad_name (legacy), else all clicks. */
|
||||||
export function linkClicksOf(r: AdDailyRow): number {
|
export function linkClicksOf(r: AdDailyRow): number {
|
||||||
|
const m = String(r.ad_name || '').match(/^__lc:(\d+(?:\.\d+)?)$/);
|
||||||
|
if (m) return Number(m[1]) || 0;
|
||||||
if (r.link_clicks != null && Number.isFinite(Number(r.link_clicks))) {
|
if (r.link_clicks != null && Number.isFinite(Number(r.link_clicks))) {
|
||||||
return Number(r.link_clicks) || 0;
|
return Number(r.link_clicks) || 0;
|
||||||
}
|
}
|
||||||
const m = String(r.ad_name || '').match(/^__lc:(\d+(?:\.\d+)?)$/);
|
|
||||||
if (m) return Number(m[1]) || 0;
|
|
||||||
return Number(r.clicks || 0) || 0;
|
return Number(r.clicks || 0) || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user