addashboard-cli 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/submission.js +95 -60
- package/dist/commands/submission.js.map +1 -1
- package/dist/schemas/submit-plan.js +11 -0
- package/dist/schemas/submit-plan.js.map +1 -1
- package/package.json +1 -1
- package/skills/ad-ops/SKILL.md +33 -0
- package/skills/ad-ops/ad-planner.html +94 -12
- package/skills/ad-ops/scripts/adops.sh +55 -8
- package/skills/ad-ops/scripts/assets_sync.py +277 -0
- package/skills/ad-ops/scripts/plan_lint.py +58 -9
- package/skills/ad-ops/scripts/test_assets_sync.py +132 -0
|
@@ -432,6 +432,7 @@ def summarize(plan: dict) -> None:
|
|
|
432
432
|
or a.get("videoId")
|
|
433
433
|
or a.get("imageHash")
|
|
434
434
|
or a.get("youtubeVideoId")
|
|
435
|
+
or ",".join(v.get("youtubeVideoId") or v.get("videoPath") or "?" for v in a.get("videos") or [])
|
|
435
436
|
or ",".join((a.get("marketingImages") or []) + (a.get("squareMarketingImages") or []))
|
|
436
437
|
or "-"
|
|
437
438
|
)
|
|
@@ -466,6 +467,14 @@ def lint(plan: dict, plan_path: str = "") -> None:
|
|
|
466
467
|
# refs の鮮度と無関係にローカルで確定する
|
|
467
468
|
plan_cp_names: set[str] = set()
|
|
468
469
|
|
|
470
|
+
# YDA のバナー広告は 1200x628 (Card) を受け付けない。入稿計画JSONに画像サイズは
|
|
471
|
+
# 載らないので、参照表の card 側に登録されている mediaId を集めて逆引きする。
|
|
472
|
+
card_media_ids = {
|
|
473
|
+
str(v.get("card"))
|
|
474
|
+
for v in (account.get("refs", {}).get("media") or {}).values()
|
|
475
|
+
if isinstance(v, dict) and v.get("card")
|
|
476
|
+
}
|
|
477
|
+
|
|
469
478
|
if not base:
|
|
470
479
|
warn(f"アカウントJSONに baseArticleUrl がありません({platform}/{plan.get('accountId')})。"
|
|
471
480
|
" finalUrl が相対パスになる原因になります")
|
|
@@ -530,7 +539,7 @@ def lint(plan: dict, plan_path: str = "") -> None:
|
|
|
530
539
|
# クリエイティブの持ち方は媒体で違う。
|
|
531
540
|
# yda : mediaIds / videoMediaId
|
|
532
541
|
# meta : imageHash / videoId
|
|
533
|
-
# google : youtubeVideoId / marketingImages / squareMarketingImages
|
|
542
|
+
# google : youtubeVideoId / videos[] / marketingImages / squareMarketingImages
|
|
534
543
|
# bytedance : videoId + imageIds (カバー画像) / アップロード前は videoPath
|
|
535
544
|
has_creative = bool(
|
|
536
545
|
media
|
|
@@ -539,6 +548,7 @@ def lint(plan: dict, plan_path: str = "") -> None:
|
|
|
539
548
|
or a.get("imageHash")
|
|
540
549
|
or a.get("imageIds")
|
|
541
550
|
or a.get("youtubeVideoId")
|
|
551
|
+
or a.get("videos")
|
|
542
552
|
or a.get("marketingImages")
|
|
543
553
|
or a.get("squareMarketingImages")
|
|
544
554
|
)
|
|
@@ -569,6 +579,7 @@ def lint(plan: dict, plan_path: str = "") -> None:
|
|
|
569
579
|
if a.get("coverImagePath"):
|
|
570
580
|
local_paths.append(a["coverImagePath"])
|
|
571
581
|
local_paths.extend(a.get("coverImagePaths") or [])
|
|
582
|
+
local_paths.extend(v["videoPath"] for v in a.get("videos") or [] if v.get("videoPath"))
|
|
572
583
|
for raw in local_paths:
|
|
573
584
|
resolved = resolve_media_path(raw, base_dir)
|
|
574
585
|
if not os.path.isfile(resolved):
|
|
@@ -576,19 +587,57 @@ def lint(plan: dict, plan_path: str = "") -> None:
|
|
|
576
587
|
f" → 解決先 {resolved}(相対パスの基準は計画JSONのディレクトリ "
|
|
577
588
|
f"{base_dir})")
|
|
578
589
|
|
|
590
|
+
# YDA のバナー広告 (BANNER_AD) に 1200x628 の画像は使えない。
|
|
591
|
+
# 公式の入稿規定でバナー(画像)の対応アスペクト比は 1:1 / 6:5 等で、
|
|
592
|
+
# 1.91:1 はレスポンシブ側のサイズ (2026-10-13 に広告事業部へ確認済み)。
|
|
593
|
+
# 実 API は 220115 Unexpected aspect ratio で弾くが dry-run は通るので、
|
|
594
|
+
# プランナーと二重に止める (プランナーを経由しない計画にも効かせる)。
|
|
595
|
+
if platform == "yda" and (a.get("adType") or "RESPONSIVE_AD") == "BANNER_AD":
|
|
596
|
+
banner_cards = [
|
|
597
|
+
str(m) for m in (a.get("mediaIds") or []) if str(m) in card_media_ids
|
|
598
|
+
]
|
|
599
|
+
if banner_cards:
|
|
600
|
+
err(f"CP「{name}」のバナー広告に Card(1200x628) の画像が指定されています"
|
|
601
|
+
f"({a.get('adName') or ''} / mediaId {', '.join(banner_cards)})\n"
|
|
602
|
+
f" → Yahoo はバナー広告の 1200x628 を受け付けません"
|
|
603
|
+
f"(220115 Unexpected aspect ratio)。"
|
|
604
|
+
f"広告タイプをレスポンシブにするか、形式を SQ にしてください")
|
|
605
|
+
|
|
579
606
|
# Google (Demand Gen) の動画は「動画IDかローカルパスのどちらか一方」。
|
|
580
607
|
# 両方あると CLI が余計にアップロードし、使われない動画が YouTube に残る。
|
|
581
608
|
# backend も superRefine で 400 にするが、CLI はその手前でアップロードを
|
|
582
609
|
# 済ませてしまうので lint でも止める(Google の安全弁は二重が原則)。
|
|
610
|
+
# 複数本(縦・横など・#347)は videos[]。1 本だけなら旧形式でもよい。
|
|
583
611
|
if platform == "google" and a.get("type") == "DEMAND_GEN_VIDEO_RESPONSIVE_AD":
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
612
|
+
label = a.get("adName") or ""
|
|
613
|
+
legacy = a.get("youtubeVideoId") or a.get("videoPath")
|
|
614
|
+
videos = a.get("videos")
|
|
615
|
+
if videos is not None and legacy:
|
|
616
|
+
err(f"CP「{name}」の動画広告に videos と youtubeVideoId / videoPath が両方あります"
|
|
617
|
+
f"({label})\n → どちらか一方の形式で書いてください")
|
|
618
|
+
videos = []
|
|
619
|
+
else:
|
|
620
|
+
if videos is None:
|
|
621
|
+
videos = [a] if legacy else []
|
|
622
|
+
if not videos:
|
|
623
|
+
err(f"CP「{name}」の動画広告に動画がありません({label})\n"
|
|
624
|
+
f" → videos、または youtubeVideoId / アップロード前なら videoPath が必要")
|
|
625
|
+
if len(videos) > 5:
|
|
626
|
+
err(f"CP「{name}」の動画広告の動画が {len(videos)} 本あります({label})"
|
|
627
|
+
f"\n → 1 広告 5 本までです")
|
|
628
|
+
seen_v = set()
|
|
629
|
+
for k, v in enumerate(videos, 1):
|
|
630
|
+
if v.get("youtubeVideoId") and v.get("videoPath"):
|
|
631
|
+
err(f"CP「{name}」の動画{k}本目に youtubeVideoId と videoPath が両方あります"
|
|
632
|
+
f"({label})\n"
|
|
633
|
+
f" → アップロードした動画は使われません。差し替えたいなら "
|
|
634
|
+
f"youtubeVideoId を、既存動画のままでよいなら videoPath を消してください")
|
|
635
|
+
key = v.get("youtubeVideoId") or v.get("videoPath")
|
|
636
|
+
if not key:
|
|
637
|
+
err(f"CP「{name}」の動画{k}本目が空です({label})")
|
|
638
|
+
elif key in seen_v:
|
|
639
|
+
err(f"CP「{name}」の動画{k}本目が他の動画と重複しています: {key}({label})")
|
|
640
|
+
seen_v.add(key)
|
|
592
641
|
|
|
593
642
|
# TikTok の SINGLE_VIDEO は **動画とカバー画像の両方**が必要。
|
|
594
643
|
# 片方だけだと lint を通り、live で動画だけアップロードされた後に
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""assets_sync.py の単体テスト(実 API を叩かない)。
|
|
3
|
+
|
|
4
|
+
python3 test_assets_sync.py
|
|
5
|
+
|
|
6
|
+
媒体のレスポンスを固定データで与え、振り分け・除外・マージの判断を確かめる。
|
|
7
|
+
参照表は「既存を絶対に壊さない」のが最重要なので、そこを厚く見る。
|
|
8
|
+
"""
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
|
|
15
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
16
|
+
import assets_sync as S # noqa: E402
|
|
17
|
+
|
|
18
|
+
ok = 0
|
|
19
|
+
ng = []
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def check(name: str, cond: bool, detail: str = "") -> None:
|
|
23
|
+
global ok
|
|
24
|
+
if cond:
|
|
25
|
+
ok += 1
|
|
26
|
+
print(f" ✓ {name}")
|
|
27
|
+
else:
|
|
28
|
+
ng.append(f"{name}{(': ' + detail) if detail else ''}")
|
|
29
|
+
print(f" ✗ {name}{(': ' + detail) if detail else ''}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def media(mid, name, aspect, approval="APPROVED"):
|
|
33
|
+
return {"mediaId": mid, "mediaName": name, "aspectRatio": aspect,
|
|
34
|
+
"approvalStatus": approval}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
print("── 振り分け(実寸で判定し、名前は使わない) ──")
|
|
38
|
+
c = S.classify([
|
|
39
|
+
media("1001", "10-1200.jpg", "AR_191_100"),
|
|
40
|
+
media("1002", "10-1080.jpg", "AR_1_1"),
|
|
41
|
+
media("1003", "11-1200.jpg", "AR_1200_628"), # 表記ゆれも card に寄せる
|
|
42
|
+
])
|
|
43
|
+
check("1.91:1 は card", c["candidates"].get(("10", "card")) == ["1001"])
|
|
44
|
+
check("1:1 は sq", c["candidates"].get(("10", "sq")) == ["1002"])
|
|
45
|
+
check("AR_1200_628 も card", c["candidates"].get(("11", "card")) == ["1003"])
|
|
46
|
+
|
|
47
|
+
# 名前が -1080 でも実寸が 1.91:1 なら card。実データの96%が名前とズレているため
|
|
48
|
+
c = S.classify([media("2001", "20-1080.jpg", "AR_191_100")])
|
|
49
|
+
check("名前が -1080 でも実寸が 1.91:1 なら card",
|
|
50
|
+
c["candidates"].get(("20", "card")) == ["2001"])
|
|
51
|
+
|
|
52
|
+
print("\n── 除外 ──")
|
|
53
|
+
c = S.classify([
|
|
54
|
+
media("3001", "30-1200.jpg", "AR_191_100", "PRE_DISAPPROVED"),
|
|
55
|
+
media("3002", "31-1200.jpg", "AR_191_100", "POST_DISAPPROVED"),
|
|
56
|
+
media("3003", "32-1200.jpg", "AR_191_100", "PENDING_UNSPECIFIED_AD"),
|
|
57
|
+
])
|
|
58
|
+
check("否認済み(PRE/POST)は取り込まない", c["skipped_approval"] == 2)
|
|
59
|
+
check("審査保留(PENDING)は取り込む", ("32", "card") in c["candidates"])
|
|
60
|
+
|
|
61
|
+
c = S.classify([media("4001", "40-xx.jpg", "AR_4_5")])
|
|
62
|
+
check("card/sq 以外の比率は取り込まない", c["skipped_aspect"] == 1 and not c["candidates"])
|
|
63
|
+
|
|
64
|
+
c = S.classify([media("5001", "banner-1200.jpg", "AR_191_100")])
|
|
65
|
+
check("名前から番号を取れないものは取り込まない",
|
|
66
|
+
c["skipped_name"] == 1 and not c["candidates"])
|
|
67
|
+
|
|
68
|
+
print("\n── 既存を壊さない ──")
|
|
69
|
+
existing = {"10": {"card": "9999"}, "20": {"sq": "8888"}}
|
|
70
|
+
c = S.classify([
|
|
71
|
+
media("1001", "10-1200.jpg", "AR_191_100"), # 既存と違う mediaId
|
|
72
|
+
media("1002", "10-1080.jpg", "AR_1_1"), # 空いている枠
|
|
73
|
+
media("2002", "20-1080.jpg", "AR_1_1"), # 既存と違う mediaId
|
|
74
|
+
])
|
|
75
|
+
merged, report, stats = S.merge(existing, c)
|
|
76
|
+
check("既存の値を上書きしない", merged["10"]["card"] == "9999",
|
|
77
|
+
f'got {merged["10"]["card"]}')
|
|
78
|
+
check("空いている枠には足す", merged["10"].get("sq") == "1002")
|
|
79
|
+
check("食い違いを報告する", stats["conflicts"] == 2, f'got {stats["conflicts"]}')
|
|
80
|
+
check("追加件数が合う", stats["added"] == 1, f'got {stats["added"]}')
|
|
81
|
+
|
|
82
|
+
print("\n── 同じ番号・同じ形式に候補が複数 ──")
|
|
83
|
+
c = S.classify([
|
|
84
|
+
media("6001", "60-1200.jpg", "AR_191_100"),
|
|
85
|
+
media("6002", "60-1200_dup.jpg", "AR_191_100"),
|
|
86
|
+
])
|
|
87
|
+
merged, report, stats = S.merge({}, c)
|
|
88
|
+
check("どれが正か決められないので取り込まない", "60" not in merged)
|
|
89
|
+
check("候補が複数あることを報告する", stats["ambiguous"] == 1)
|
|
90
|
+
|
|
91
|
+
print("\n── 新規番号 ──")
|
|
92
|
+
c = S.classify([media("7001", "70-1200.jpg", "AR_191_100")])
|
|
93
|
+
merged, report, stats = S.merge({}, c)
|
|
94
|
+
check("参照表に無い番号は新規に作る", merged.get("70") == {"card": "7001"})
|
|
95
|
+
|
|
96
|
+
print("\n── CLI の挙動(実 API は叩かない) ──")
|
|
97
|
+
with tempfile.TemporaryDirectory() as td:
|
|
98
|
+
acc_dir = os.path.join(td, "アカウント", "yda")
|
|
99
|
+
os.makedirs(acc_dir)
|
|
100
|
+
acc_path = os.path.join(acc_dir, "テスト案件.json")
|
|
101
|
+
with open(acc_path, "w", encoding="utf-8") as f:
|
|
102
|
+
json.dump({"accountId": "999", "refs": {"media": {"1": {"sq": "111"}}}},
|
|
103
|
+
f, ensure_ascii=False)
|
|
104
|
+
before = open(acc_path, encoding="utf-8").read()
|
|
105
|
+
|
|
106
|
+
env = {**os.environ, "ADOPS_CLI": "false"} # 必ず失敗する CLI を使わせる
|
|
107
|
+
script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets_sync.py")
|
|
108
|
+
# SKILL_DIR を一時ディレクトリに向けるため、スクリプトを一時側へ写して実行する
|
|
109
|
+
tmp_scripts = os.path.join(td, "scripts")
|
|
110
|
+
os.makedirs(tmp_scripts)
|
|
111
|
+
import shutil
|
|
112
|
+
shutil.copy2(script, os.path.join(tmp_scripts, "assets_sync.py"))
|
|
113
|
+
p = subprocess.run([sys.executable, os.path.join(tmp_scripts, "assets_sync.py"),
|
|
114
|
+
"yda", "999", "--write"],
|
|
115
|
+
capture_output=True, text=True, env=env)
|
|
116
|
+
check("取得に失敗したら書き込まない(終了コード1)", p.returncode == 1,
|
|
117
|
+
f"exit={p.returncode}")
|
|
118
|
+
check("取得に失敗しても参照表が変わらない",
|
|
119
|
+
open(acc_path, encoding="utf-8").read() == before)
|
|
120
|
+
|
|
121
|
+
p = subprocess.run([sys.executable, os.path.join(tmp_scripts, "assets_sync.py"),
|
|
122
|
+
"yda", "不明なID"],
|
|
123
|
+
capture_output=True, text=True, env=env)
|
|
124
|
+
check("参照表が無いアカウントはエラーで止まる", p.returncode == 1)
|
|
125
|
+
|
|
126
|
+
print()
|
|
127
|
+
if ng:
|
|
128
|
+
print(f"✗ {len(ng)} 件failed / {ok} 件passed")
|
|
129
|
+
for n in ng:
|
|
130
|
+
print(f" - {n}")
|
|
131
|
+
sys.exit(1)
|
|
132
|
+
print(f"✓ {ok} 件すべて passed")
|