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.
@@ -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
- if a.get("youtubeVideoId") and a.get("videoPath"):
585
- err(f"CP「{name}」の広告に youtubeVideoId と videoPath が両方あります"
586
- f"({a.get('adName') or ''})\n"
587
- f" → アップロードした動画は使われません。差し替えたいなら "
588
- f"youtubeVideoId を、既存動画のままでよいなら videoPath を消してください")
589
- if not a.get("youtubeVideoId") and not a.get("videoPath"):
590
- err(f"CP「{name}」の動画広告に動画がありません({a.get('adName') or ''})\n"
591
- f" → youtubeVideoId か、アップロード前なら videoPath が必要")
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")