addashboard-cli 0.12.0 → 0.14.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.
@@ -13,8 +13,16 @@
13
13
  #
14
14
  # 覚えるコマンドを1本に畳むためのラッパー。個別の python3 -c / curl / ls -t を書かない。
15
15
  set -uo pipefail
16
+ # Windows の既定コンソール (cp932) だと Python の ✓ や日本語の出力で UnicodeEncodeError になる
17
+ export PYTHONUTF8=1 PYTHONIOENCODING=utf-8
18
+ # Windows の Python (subprocess) は npx を見つけられない (実体が npx.cmd で WinError 2 になる)。
19
+ # refs_import.py / thumbs_download.py は ADOPS_CLI で CLI を呼ぶので、既定をここで合わせる
20
+ case "${OSTYPE:-}" in
21
+ msys*|cygwin*) export ADOPS_CLI="${ADOPS_CLI:-npx.cmd -y addashboard-cli@latest}" ;;
22
+ esac
16
23
 
17
- SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
24
+ # Windows ではネイティブ形式のパスで持つ (Python に渡すため)。pwd -W が無い Mac は pwd
25
+ SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && { pwd -W 2>/dev/null || pwd; })"
18
26
  # 配信ポート。既定 8799 は cli/playwright.config.ts の webServer と共用する。
19
27
  # **worktree を分けて並行作業していると別 worktree のサーバを再利用してしまう**
20
28
  # (curl が応答すれば「起動済み」と判断するので、別ブランチのプランナーが開く)。
@@ -29,6 +37,20 @@ CLI="${WEBGRAM_CLI:-npx -y addashboard-cli@latest}"
29
37
 
30
38
  die() { echo "✗ $*" >&2; exit 1; }
31
39
 
40
+ # URL をブラウザで開く。Mac は従来どおり open に渡す (`open -a ...` の引数もそのまま通す)。
41
+ # Git Bash (Windows) には open が無いので Chrome を直接起動し、無ければ既定ブラウザで開く。
42
+ # rundll32 は shell を経由しないので、& を含む URL (Google 管理画面など) でも欠けない。
43
+ open_url() {
44
+ if command -v open >/dev/null 2>&1; then open "$@"; return; fi
45
+ local url="${!#}" c
46
+ for c in "/c/Program Files/Google/Chrome/Application/chrome.exe" \
47
+ "/c/Program Files (x86)/Google/Chrome/Application/chrome.exe" \
48
+ "${LOCALAPPDATA:-}/Google/Chrome/Application/chrome.exe"; do
49
+ [ -x "$c" ] && { "$c" "$url" >/dev/null 2>&1 & return; }
50
+ done
51
+ rundll32 url.dll,FileProtocolHandler "$url"
52
+ }
53
+
32
54
  # ------------------------------------------------------------- ensure_login
33
55
  # 未ログインなら auth login を起動して完了まで待ち、そのまま処理を続ける。
34
56
  # 以前は「ターミナルで auth login を実行してください」と案内して止めていたため、
@@ -132,6 +154,35 @@ print(f"✓ 作業中プランを設定: {os.environ['ACC']} / {os.environ['PF']
132
154
  PY
133
155
  }
134
156
 
157
+ # ------------------------------------------------------------ planner env
158
+ # ブラウザはフォルダの絶対パスを知る手段が無いので、プランナーが「保存フォルダ(入稿計画/)」の
159
+ # 場所を案内できるように、配信元のスキルから絶対パスを渡す (.planner-env.json)。
160
+ # あわせて 入稿計画/ と目印ファイルを用意する (プランナーは目印の有無で入稿計画フォルダかを判定する)。
161
+ # 各自の環境のパスなので git では追跡せず、npm にも載せない。
162
+ write_planner_env() {
163
+ local plans="${SKILL_DIR}/入稿計画"
164
+ mkdir -p "$plans"
165
+ [ -f "${plans}/.ad-planner-plans" ] \
166
+ || printf 'ad-planner の入稿計画フォルダの目印です。消さないでください。\n' > "${plans}/.ad-planner-plans"
167
+ PLANS_DIR="$plans" OUT="${SKILL_DIR}/.planner-env.json" python3 - <<'PY'
168
+ import json, os
169
+ with open(os.environ["OUT"], "w", encoding="utf-8") as f:
170
+ json.dump({"plansDir": os.environ["PLANS_DIR"]}, f, ensure_ascii=False)
171
+ f.write("\n")
172
+ PY
173
+ }
174
+
175
+ # 配信サーバーを止める。Mac は従来どおり pkill。
176
+ # Git Bash (Windows) には pkill が無いので、ポートを LISTEN している python だけを taskkill で止める
177
+ # (/PID 形式は Git Bash がパスに変換してしまうので - 形式で渡す)。
178
+ stop_server() {
179
+ if command -v pkill >/dev/null 2>&1; then pkill -f "http.server ${PORT}"; return; fi
180
+ local pid
181
+ pid="$(netstat -ano | awk -v p=":${PORT}" '$1 == "TCP" && $2 ~ p"$" && $4 == "LISTENING" { print $5; exit }')"
182
+ [ -n "$pid" ] && tasklist -FI "PID eq ${pid}" -NH | grep -qi python \
183
+ && taskkill -PID "$pid" -F >/dev/null 2>&1
184
+ }
185
+
135
186
  # file:// で開くと fetch('サンプルプラン.json') がブロックされ、
136
187
  # エラーも出ないまま空のプランナーが表示される。必ずHTTPで開く。
137
188
  cmd_planner() {
@@ -139,7 +190,7 @@ cmd_planner() {
139
190
  while [ $# -gt 0 ]; do
140
191
  case "$1" in
141
192
  --stop)
142
- pkill -f "http.server ${PORT}" && echo "✓ プランナーの配信を停止しました" \
193
+ stop_server && echo "✓ プランナーの配信を停止しました" \
143
194
  || echo "(起動中のサーバーはありませんでした)"
144
195
  return 0 ;;
145
196
  --platform) platform="${2:-}"; shift 2 ;;
@@ -153,8 +204,9 @@ cmd_planner() {
153
204
  || die "--platform と --account は両方指定してください"
154
205
  write_active_plan "$platform" "$account"
155
206
  fi
207
+ write_planner_env
156
208
 
157
- if curl -sf -o /dev/null "$URL"; then
209
+ if curl -sf "$URL" >/dev/null; then
158
210
  echo "✓ 配信は起動済み(再利用)"
159
211
  echo " ⚠ 別の作業ディレクトリ (worktree) が起動したサーバの可能性があります。" >&2
160
212
  echo " プランナーの内容が想定と違うときは ADOPS_PORT=8801 adops.sh planner ... で専用ポートに切り替えてください。" >&2
@@ -164,14 +216,24 @@ cmd_planner() {
164
216
  nohup python3 -m http.server "$PORT" --directory "$SKILL_DIR" \
165
217
  >/tmp/adops-planner.log 2>&1 &
166
218
  for _ in $(seq 1 20); do
167
- curl -sf -o /dev/null "$URL" && break
219
+ curl -sf "$URL" >/dev/null && break
168
220
  sleep 0.3
169
221
  done
170
- curl -sf -o /dev/null "$URL" || die "配信を起動できませんでした(/tmp/adops-planner.log を確認)"
222
+ curl -sf "$URL" >/dev/null || die "配信を起動できませんでした(/tmp/adops-planner.log を確認)"
171
223
  echo "✓ 起動しました"
172
224
  fi
173
225
 
174
- open "$URL"
226
+ # 素材の新規登録・入稿計画の保存は File System Access API (フォルダの読み書き) を使うので Chrome で開く。
227
+ # 素の `open` は BizAgent / VS Code のターミナルだと内蔵ブラウザに渡され、
228
+ # 内蔵ブラウザはフォルダの読み書きを許可しない (許可の確認も出ずに黙って拒否される)。
229
+ if [ -d "/Applications/Google Chrome.app" ] || [ -d "$HOME/Applications/Google Chrome.app" ]; then
230
+ open -a "Google Chrome" "$URL"
231
+ else
232
+ open_url "$URL"
233
+ # Windows は open_url が Chrome を直接探して起動するので、Mac (open がある) のときだけ警告する
234
+ command -v open >/dev/null 2>&1 \
235
+ && echo " ⚠ Google Chrome が見つかりません。素材の登録と入稿計画の保存は Chrome 系ブラウザでしか動きません" >&2
236
+ fi
175
237
  echo " $URL"
176
238
  echo " 停止: adops.sh planner --stop"
177
239
  }
@@ -285,7 +347,7 @@ PY
285
347
  esac
286
348
 
287
349
  echo "$url"
288
- [ -n "$dry" ] || open "$url"
350
+ [ -n "$dry" ] || open_url "$url"
289
351
  }
290
352
 
291
353
  # --------------------------------------------------------------- refs-import
@@ -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
  )
@@ -530,7 +531,7 @@ def lint(plan: dict, plan_path: str = "") -> None:
530
531
  # クリエイティブの持ち方は媒体で違う。
531
532
  # yda : mediaIds / videoMediaId
532
533
  # meta : imageHash / videoId
533
- # google : youtubeVideoId / marketingImages / squareMarketingImages
534
+ # google : youtubeVideoId / videos[] / marketingImages / squareMarketingImages
534
535
  # bytedance : videoId + imageIds (カバー画像) / アップロード前は videoPath
535
536
  has_creative = bool(
536
537
  media
@@ -539,6 +540,7 @@ def lint(plan: dict, plan_path: str = "") -> None:
539
540
  or a.get("imageHash")
540
541
  or a.get("imageIds")
541
542
  or a.get("youtubeVideoId")
543
+ or a.get("videos")
542
544
  or a.get("marketingImages")
543
545
  or a.get("squareMarketingImages")
544
546
  )
@@ -569,6 +571,7 @@ def lint(plan: dict, plan_path: str = "") -> None:
569
571
  if a.get("coverImagePath"):
570
572
  local_paths.append(a["coverImagePath"])
571
573
  local_paths.extend(a.get("coverImagePaths") or [])
574
+ local_paths.extend(v["videoPath"] for v in a.get("videos") or [] if v.get("videoPath"))
572
575
  for raw in local_paths:
573
576
  resolved = resolve_media_path(raw, base_dir)
574
577
  if not os.path.isfile(resolved):
@@ -580,15 +583,37 @@ def lint(plan: dict, plan_path: str = "") -> None:
580
583
  # 両方あると CLI が余計にアップロードし、使われない動画が YouTube に残る。
581
584
  # backend も superRefine で 400 にするが、CLI はその手前でアップロードを
582
585
  # 済ませてしまうので lint でも止める(Google の安全弁は二重が原則)。
586
+ # 複数本(縦・横など・#347)は videos[]。1 本だけなら旧形式でもよい。
583
587
  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 が必要")
588
+ label = a.get("adName") or ""
589
+ legacy = a.get("youtubeVideoId") or a.get("videoPath")
590
+ videos = a.get("videos")
591
+ if videos is not None and legacy:
592
+ err(f"CP「{name}」の動画広告に videos と youtubeVideoId / videoPath が両方あります"
593
+ f"({label})\n → どちらか一方の形式で書いてください")
594
+ videos = []
595
+ else:
596
+ if videos is None:
597
+ videos = [a] if legacy else []
598
+ if not videos:
599
+ err(f"CP「{name}」の動画広告に動画がありません({label})\n"
600
+ f" → videos、または youtubeVideoId / アップロード前なら videoPath が必要")
601
+ if len(videos) > 5:
602
+ err(f"CP「{name}」の動画広告の動画が {len(videos)} 本あります({label})"
603
+ f"\n → 1 広告 5 本までです")
604
+ seen_v = set()
605
+ for k, v in enumerate(videos, 1):
606
+ if v.get("youtubeVideoId") and v.get("videoPath"):
607
+ err(f"CP「{name}」の動画{k}本目に youtubeVideoId と videoPath が両方あります"
608
+ f"({label})\n"
609
+ f" → アップロードした動画は使われません。差し替えたいなら "
610
+ f"youtubeVideoId を、既存動画のままでよいなら videoPath を消してください")
611
+ key = v.get("youtubeVideoId") or v.get("videoPath")
612
+ if not key:
613
+ err(f"CP「{name}」の動画{k}本目が空です({label})")
614
+ elif key in seen_v:
615
+ err(f"CP「{name}」の動画{k}本目が他の動画と重複しています: {key}({label})")
616
+ seen_v.add(key)
592
617
 
593
618
  # TikTok の SINGLE_VIDEO は **動画とカバー画像の両方**が必要。
594
619
  # 片方だけだと lint を通り、live で動画だけアップロードされた後に
@@ -0,0 +1 @@
1
+ ad-planner の入稿計画フォルダの目印です。消さないでください。