|
@@ -68,6 +68,8 @@ DEFAULT_PERFORMANCE_OLD_DAILY_IMPRESSIONS_THRESHOLD = 100.0
|
|
|
DEFAULT_PERFORMANCE_WINDOW_DAYS = 7
|
|
DEFAULT_PERFORMANCE_WINDOW_DAYS = 7
|
|
|
DEFAULT_AD_PERFORMANCE_WINDOW_DAYS = 3
|
|
DEFAULT_AD_PERFORMANCE_WINDOW_DAYS = 3
|
|
|
DEFAULT_AD_PERFORMANCE_MIN_AGE_DAYS = 5
|
|
DEFAULT_AD_PERFORMANCE_MIN_AGE_DAYS = 5
|
|
|
|
|
+DEFAULT_INTERNAL_ONLY_AGENCIES = ("小程序-自动化",)
|
|
|
|
|
+TOKEN_SKIPPED_SCAN_PREFIX = "account_scan_skipped reason=access_token_unavailable"
|
|
|
AGENCY_REPORT_COLUMNS = (
|
|
AGENCY_REPORT_COLUMNS = (
|
|
|
"代理名称",
|
|
"代理名称",
|
|
|
"账户ID",
|
|
"账户ID",
|
|
@@ -753,6 +755,66 @@ def _env_flag(name: str, default: bool = False) -> bool:
|
|
|
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def _internal_only_agencies() -> set[str]:
|
|
|
|
|
+ raw = os.getenv("CREATIVE_CLEANUP_INTERNAL_ONLY_AGENCIES")
|
|
|
|
|
+ if raw is None:
|
|
|
|
|
+ values = DEFAULT_INTERNAL_ONLY_AGENCIES
|
|
|
|
|
+ else:
|
|
|
|
|
+ values = tuple(raw.split(","))
|
|
|
|
|
+ return {name for value in values if (name := _agency_name(value))}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _is_internal_only_agency(value: Any) -> bool:
|
|
|
|
|
+ return _agency_name(value) in _internal_only_agencies()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _has_cleanup_notification_route(agency: Any, routes: Any) -> bool:
|
|
|
|
|
+ normalized = _agency_name(agency)
|
|
|
|
|
+ return bool(normalized) and (
|
|
|
|
|
+ _is_internal_only_agency(normalized)
|
|
|
|
|
+ or bool(resolve_agency_webhook(normalized, routes or {}))
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _is_access_token_unavailable_error(error: Any) -> bool:
|
|
|
|
|
+ message = str(error or "").lower()
|
|
|
|
|
+ return (
|
|
|
|
|
+ "code=11002" in message
|
|
|
|
|
+ or "invalid access token" in message
|
|
|
|
|
+ or "access_token 无效" in message
|
|
|
|
|
+ or "getaccesstoken" in message
|
|
|
|
|
+ or "token api 请求失败" in message
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _token_skipped_scan_result(account_id: int, error: Any):
|
|
|
|
|
+ return (
|
|
|
|
|
+ [],
|
|
|
|
|
+ {},
|
|
|
|
|
+ {},
|
|
|
|
|
+ {},
|
|
|
|
|
+ None,
|
|
|
|
|
+ 0,
|
|
|
|
|
+ f"{TOKEN_SKIPPED_SCAN_PREFIX} account={account_id} error={error}",
|
|
|
|
|
+ {},
|
|
|
|
|
+ None,
|
|
|
|
|
+ {},
|
|
|
|
|
+ None,
|
|
|
|
|
+ None,
|
|
|
|
|
+ {
|
|
|
|
|
+ "missing_tencent_creatives": 0,
|
|
|
|
|
+ "missing_tencent_ads": 0,
|
|
|
|
|
+ "ad_mismatches": 0,
|
|
|
|
|
+ "missing_source_ad_ids": 0,
|
|
|
|
|
+ "missing_create_time": 0,
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _is_token_skipped_scan_error(error: Any) -> bool:
|
|
|
|
|
+ return str(error or "").startswith(TOKEN_SKIPPED_SCAN_PREFIX)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def resolve_end_date(client, requested=None, *, now=None):
|
|
def resolve_end_date(client, requested=None, *, now=None):
|
|
|
from roi_control.data_source import resolve_end_date as resolve
|
|
from roi_control.data_source import resolve_end_date as resolve
|
|
|
|
|
|
|
@@ -2311,6 +2373,7 @@ def split_notification_rows(
|
|
|
if row.get("agency_notified_at") is None
|
|
if row.get("agency_notified_at") is None
|
|
|
and not _is_performance_rule(row.get("cleanup_rule_type"))
|
|
and not _is_performance_rule(row.get("cleanup_rule_type"))
|
|
|
and str(row.get("agency_name") or "").strip()
|
|
and str(row.get("agency_name") or "").strip()
|
|
|
|
|
+ and not _is_internal_only_agency(row.get("agency_name"))
|
|
|
]
|
|
]
|
|
|
review_internal_rows = [
|
|
review_internal_rows = [
|
|
|
row
|
|
row
|
|
@@ -2428,6 +2491,12 @@ def _scan_one_account(
|
|
|
except Exception as exc:
|
|
except Exception as exc:
|
|
|
creatives = []
|
|
creatives = []
|
|
|
creative_list_error = str(exc)
|
|
creative_list_error = str(exc)
|
|
|
|
|
+ if _is_access_token_unavailable_error(exc):
|
|
|
|
|
+ logger.warning(
|
|
|
|
|
+ "account scan skipped: access token unavailable account=%d",
|
|
|
|
|
+ account_id,
|
|
|
|
|
+ )
|
|
|
|
|
+ return _token_skipped_scan_result(account_id, exc)
|
|
|
logger.exception("creative list scan failed account=%d", account_id)
|
|
logger.exception("creative list scan failed account=%d", account_id)
|
|
|
|
|
|
|
|
ad_list_error = None
|
|
ad_list_error = None
|
|
@@ -2440,6 +2509,12 @@ def _scan_one_account(
|
|
|
except Exception as exc:
|
|
except Exception as exc:
|
|
|
ads = {}
|
|
ads = {}
|
|
|
ad_list_error = str(exc)
|
|
ad_list_error = str(exc)
|
|
|
|
|
+ if _is_access_token_unavailable_error(exc):
|
|
|
|
|
+ logger.warning(
|
|
|
|
|
+ "account scan skipped: access token unavailable account=%d",
|
|
|
|
|
+ account_id,
|
|
|
|
|
+ )
|
|
|
|
|
+ return _token_skipped_scan_result(account_id, exc)
|
|
|
logger.exception("ad list scan failed account=%d", account_id)
|
|
logger.exception("ad list scan failed account=%d", account_id)
|
|
|
|
|
|
|
|
ids = [
|
|
ids = [
|
|
@@ -2464,6 +2539,12 @@ def _scan_one_account(
|
|
|
except Exception as exc:
|
|
except Exception as exc:
|
|
|
review_error = str(exc)
|
|
review_error = str(exc)
|
|
|
raw_by_id = {}
|
|
raw_by_id = {}
|
|
|
|
|
+ if _is_access_token_unavailable_error(exc):
|
|
|
|
|
+ logger.warning(
|
|
|
|
|
+ "account scan skipped: access token unavailable account=%d",
|
|
|
|
|
+ account_id,
|
|
|
|
|
+ )
|
|
|
|
|
+ return _token_skipped_scan_result(account_id, exc)
|
|
|
logger.exception(
|
|
logger.exception(
|
|
|
"creative review scan failed account=%d", account_id
|
|
"creative review scan failed account=%d", account_id
|
|
|
)
|
|
)
|
|
@@ -2486,6 +2567,12 @@ def _scan_one_account(
|
|
|
)
|
|
)
|
|
|
except Exception as exc:
|
|
except Exception as exc:
|
|
|
spend_error = str(exc)
|
|
spend_error = str(exc)
|
|
|
|
|
+ if _is_access_token_unavailable_error(exc):
|
|
|
|
|
+ logger.warning(
|
|
|
|
|
+ "account scan skipped: access token unavailable account=%d",
|
|
|
|
|
+ account_id,
|
|
|
|
|
+ )
|
|
|
|
|
+ return _token_skipped_scan_result(account_id, exc)
|
|
|
logger.exception(
|
|
logger.exception(
|
|
|
"creative cost scan failed account=%d start=%s end=%s",
|
|
"creative cost scan failed account=%d start=%s end=%s",
|
|
|
account_id,
|
|
account_id,
|
|
@@ -2660,6 +2747,12 @@ def _scan_one_account(
|
|
|
except Exception as exc:
|
|
except Exception as exc:
|
|
|
performance_metrics = {}
|
|
performance_metrics = {}
|
|
|
performance_error = str(exc)
|
|
performance_error = str(exc)
|
|
|
|
|
+ if _is_access_token_unavailable_error(exc):
|
|
|
|
|
+ logger.warning(
|
|
|
|
|
+ "account scan skipped: access token unavailable account=%d",
|
|
|
|
|
+ account_id,
|
|
|
|
|
+ )
|
|
|
|
|
+ return _token_skipped_scan_result(account_id, exc)
|
|
|
logger.exception(
|
|
logger.exception(
|
|
|
"creative performance scan failed account=%d start=%s end=%s",
|
|
"creative performance scan failed account=%d start=%s end=%s",
|
|
|
account_id,
|
|
account_id,
|
|
@@ -2752,6 +2845,12 @@ def _scan_one_account(
|
|
|
}
|
|
}
|
|
|
except Exception as exc:
|
|
except Exception as exc:
|
|
|
ad_metric_error = str(exc)
|
|
ad_metric_error = str(exc)
|
|
|
|
|
+ if _is_access_token_unavailable_error(exc):
|
|
|
|
|
+ logger.warning(
|
|
|
|
|
+ "account scan skipped: access token unavailable account=%d",
|
|
|
|
|
+ account_id,
|
|
|
|
|
+ )
|
|
|
|
|
+ return _token_skipped_scan_result(account_id, exc)
|
|
|
logger.exception(
|
|
logger.exception(
|
|
|
"ad performance scan failed account=%d start=%s end=%s",
|
|
"ad performance scan failed account=%d start=%s end=%s",
|
|
|
account_id,
|
|
account_id,
|
|
@@ -2946,10 +3045,15 @@ def run_rejected_creative_cleanup(
|
|
|
if underperformance_preview_only
|
|
if underperformance_preview_only
|
|
|
else AgencyWebhookConfig.from_env()
|
|
else AgencyWebhookConfig.from_env()
|
|
|
)
|
|
)
|
|
|
- if apply_enabled and not webhook_config.enabled:
|
|
|
|
|
|
|
+ if (
|
|
|
|
|
+ apply_enabled
|
|
|
|
|
+ and not webhook_config.enabled
|
|
|
|
|
+ and not _internal_only_agencies()
|
|
|
|
|
+ ):
|
|
|
raise RuntimeError(
|
|
raise RuntimeError(
|
|
|
"DAILY_REJECTED_CREATIVE_APPLY_ENABLED=1 requires "
|
|
"DAILY_REJECTED_CREATIVE_APPLY_ENABLED=1 requires "
|
|
|
- "ROI_AGENCY_WEBHOOK_ENABLED=1"
|
|
|
|
|
|
|
+ "ROI_AGENCY_WEBHOOK_ENABLED=1 or a non-empty "
|
|
|
|
|
+ "CREATIVE_CLEANUP_INTERNAL_ONLY_AGENCIES"
|
|
|
)
|
|
)
|
|
|
if (
|
|
if (
|
|
|
apply_enabled
|
|
apply_enabled
|
|
@@ -3128,6 +3232,11 @@ def run_rejected_creative_cleanup(
|
|
|
else:
|
|
else:
|
|
|
client = tencent
|
|
client = tencent
|
|
|
prefetched_tokens = prefetch_account_access_tokens(account_ids)
|
|
prefetched_tokens = prefetch_account_access_tokens(account_ids)
|
|
|
|
|
+ token_prefetch_skipped_accounts = (
|
|
|
|
|
+ set(account_ids) - set(prefetched_tokens)
|
|
|
|
|
+ if owned_tencent
|
|
|
|
|
+ else set()
|
|
|
|
|
+ )
|
|
|
seed_tokens = getattr(client, "seed_access_tokens", None)
|
|
seed_tokens = getattr(client, "seed_access_tokens", None)
|
|
|
if callable(seed_tokens):
|
|
if callable(seed_tokens):
|
|
|
seed_tokens(prefetched_tokens)
|
|
seed_tokens(prefetched_tokens)
|
|
@@ -3139,6 +3248,7 @@ def run_rejected_creative_cleanup(
|
|
|
scanned_accounts: set[int] = set()
|
|
scanned_accounts: set[int] = set()
|
|
|
performance_scanned_accounts: set[int] = set()
|
|
performance_scanned_accounts: set[int] = set()
|
|
|
ad_scanned_accounts: set[int] = set()
|
|
ad_scanned_accounts: set[int] = set()
|
|
|
|
|
+ token_skipped_accounts: set[int] = set()
|
|
|
confirmed_actions: dict[tuple[int, int], dict[str, Any]] = {}
|
|
confirmed_actions: dict[tuple[int, int], dict[str, Any]] = {}
|
|
|
scan_errors: list[str] = []
|
|
scan_errors: list[str] = []
|
|
|
if review_scope_error:
|
|
if review_scope_error:
|
|
@@ -3173,6 +3283,12 @@ def run_rejected_creative_cleanup(
|
|
|
workers = min(scan_workers, len(accounts), 32) if accounts else 1
|
|
workers = min(scan_workers, len(accounts), 32) if accounts else 1
|
|
|
|
|
|
|
|
def run_scan(account: dict[str, Any]):
|
|
def run_scan(account: dict[str, Any]):
|
|
|
|
|
+ account_id = int(account["account_id"])
|
|
|
|
|
+ if account_id in token_prefetch_skipped_accounts:
|
|
|
|
|
+ return _token_skipped_scan_result(
|
|
|
|
|
+ account_id,
|
|
|
|
|
+ "access token prefetch failed",
|
|
|
|
|
+ )
|
|
|
if owned_tencent:
|
|
if owned_tencent:
|
|
|
from tencent_client import TencentClient
|
|
from tencent_client import TencentClient
|
|
|
|
|
|
|
@@ -3256,8 +3372,12 @@ def run_rejected_creative_cleanup(
|
|
|
}
|
|
}
|
|
|
scanned += account_scanned
|
|
scanned += account_scanned
|
|
|
if error:
|
|
if error:
|
|
|
- logger.error(error)
|
|
|
|
|
- scan_errors.append(error)
|
|
|
|
|
|
|
+ if _is_token_skipped_scan_error(error):
|
|
|
|
|
+ token_skipped_accounts.add(account_id)
|
|
|
|
|
+ logger.warning(error)
|
|
|
|
|
+ else:
|
|
|
|
|
+ logger.error(error)
|
|
|
|
|
+ scan_errors.append(error)
|
|
|
else:
|
|
else:
|
|
|
if account_id in review_account_ids:
|
|
if account_id in review_account_ids:
|
|
|
if review_error:
|
|
if review_error:
|
|
@@ -3310,12 +3430,14 @@ def run_rejected_creative_cleanup(
|
|
|
)
|
|
)
|
|
|
)
|
|
)
|
|
|
logger.info(
|
|
logger.info(
|
|
|
- "account scan progress=%d/%d account=%d creatives=%d error=%s",
|
|
|
|
|
|
|
+ "account scan progress=%d/%d account=%d creatives=%d "
|
|
|
|
|
+ "error=%s token_skipped=%s",
|
|
|
completed,
|
|
completed,
|
|
|
len(accounts),
|
|
len(accounts),
|
|
|
account_id,
|
|
account_id,
|
|
|
account_scanned,
|
|
account_scanned,
|
|
|
- bool(error),
|
|
|
|
|
|
|
+ bool(error and not _is_token_skipped_scan_error(error)),
|
|
|
|
|
+ _is_token_skipped_scan_error(error),
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
if any(source_diagnostic_totals.values()):
|
|
if any(source_diagnostic_totals.values()):
|
|
@@ -3814,10 +3936,11 @@ def run_rejected_creative_cleanup(
|
|
|
agency_name=agency,
|
|
agency_name=agency,
|
|
|
)
|
|
)
|
|
|
item["agency_name"] = agency
|
|
item["agency_name"] = agency
|
|
|
- webhook_url = resolve_agency_webhook(
|
|
|
|
|
- agency, webhook_config.webhooks or {}
|
|
|
|
|
- )
|
|
|
|
|
- if not is_performance_item and (not agency or not webhook_url):
|
|
|
|
|
|
|
+ if not is_performance_item and (
|
|
|
|
|
+ not _has_cleanup_notification_route(
|
|
|
|
|
+ agency, webhook_config.webhooks or {}
|
|
|
|
|
+ )
|
|
|
|
|
+ ):
|
|
|
reason = (
|
|
reason = (
|
|
|
"代理商归属为空,禁止自动删除"
|
|
"代理商归属为空,禁止自动删除"
|
|
|
if not agency
|
|
if not agency
|
|
@@ -4329,10 +4452,9 @@ def run_rejected_creative_cleanup(
|
|
|
int(item["account_id"]),
|
|
int(item["account_id"]),
|
|
|
int(item["dynamic_creative_id"]),
|
|
int(item["dynamic_creative_id"]),
|
|
|
)
|
|
)
|
|
|
- webhook_url = resolve_agency_webhook(
|
|
|
|
|
|
|
+ if _has_cleanup_notification_route(
|
|
|
agency, webhook_config.webhooks or {}
|
|
agency, webhook_config.webhooks or {}
|
|
|
- )
|
|
|
|
|
- if agency and webhook_url:
|
|
|
|
|
|
|
+ ):
|
|
|
if agency != item.get("agency_name"):
|
|
if agency != item.get("agency_name"):
|
|
|
_update_owned_cleanup_item(
|
|
_update_owned_cleanup_item(
|
|
|
int(item["id"]),
|
|
int(item["id"]),
|
|
@@ -4456,6 +4578,19 @@ def run_rejected_creative_cleanup(
|
|
|
owned_publisher = publisher is None
|
|
owned_publisher = publisher is None
|
|
|
sheet_publisher = publisher or RoiFeishuPublisher(require_chat_ids=False)
|
|
sheet_publisher = publisher or RoiFeishuPublisher(require_chat_ids=False)
|
|
|
try:
|
|
try:
|
|
|
|
|
+ # 兼容升级前已成功发送内部汇总、但仍残留代理待通知标记的记录。
|
|
|
|
|
+ # 内部通知已完成时直接关闭被明确抑制的代理渠道,避免每日重复加载。
|
|
|
|
|
+ suppressed_item_ids = [
|
|
|
|
|
+ int(row["id"])
|
|
|
|
|
+ for row in pending_notifications
|
|
|
|
|
+ if _is_internal_only_agency(row.get("agency_name"))
|
|
|
|
|
+ and row.get("agency_notified_at") is None
|
|
|
|
|
+ and row.get("operator_notified_at") is not None
|
|
|
|
|
+ ]
|
|
|
|
|
+ if suppressed_item_ids:
|
|
|
|
|
+ mark_cleanup_items_notified(
|
|
|
|
|
+ suppressed_item_ids, effective_now
|
|
|
|
|
+ )
|
|
|
rows_by_date: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
rows_by_date: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
|
for row in pending_notifications:
|
|
for row in pending_notifications:
|
|
|
rows_by_date[_display_date(row.get("check_date"))].append(row)
|
|
rows_by_date[_display_date(row.get("check_date"))].append(row)
|
|
@@ -4523,10 +4658,25 @@ def run_rejected_creative_cleanup(
|
|
|
)
|
|
)
|
|
|
operator_deliveries.append(operator_outcome)
|
|
operator_deliveries.append(operator_outcome)
|
|
|
if operator_outcome.get("status") == "SENT":
|
|
if operator_outcome.get("status") == "SENT":
|
|
|
|
|
+ operator_item_ids = [
|
|
|
|
|
+ int(row["id"]) for row in operator_rows
|
|
|
|
|
+ ]
|
|
|
mark_cleanup_items_operator_notified(
|
|
mark_cleanup_items_operator_notified(
|
|
|
- [int(row["id"]) for row in operator_rows],
|
|
|
|
|
- effective_now,
|
|
|
|
|
|
|
+ operator_item_ids, effective_now
|
|
|
)
|
|
)
|
|
|
|
|
+ internal_only_item_ids = [
|
|
|
|
|
+ int(row["id"])
|
|
|
|
|
+ for row in operator_rows
|
|
|
|
|
+ if _is_internal_only_agency(
|
|
|
|
|
+ row.get("agency_name")
|
|
|
|
|
+ )
|
|
|
|
|
+ ]
|
|
|
|
|
+ # 这类记录明确只发内部群,代理渠道标记为已完成,
|
|
|
|
|
+ # 避免后续任务持续把它当成待发代理通知。
|
|
|
|
|
+ if internal_only_item_ids:
|
|
|
|
|
+ mark_cleanup_items_notified(
|
|
|
|
|
+ internal_only_item_ids, effective_now
|
|
|
|
|
+ )
|
|
|
else:
|
|
else:
|
|
|
notification_errors.append(
|
|
notification_errors.append(
|
|
|
f"operator={check_date}: "
|
|
f"operator={check_date}: "
|
|
@@ -4653,6 +4803,10 @@ def run_rejected_creative_cleanup(
|
|
|
_is_performance_rule(row.get("cleanup_rule_type"))
|
|
_is_performance_rule(row.get("cleanup_rule_type"))
|
|
|
for row in pending_notification_probe
|
|
for row in pending_notification_probe
|
|
|
)
|
|
)
|
|
|
|
|
+ or any(
|
|
|
|
|
+ _is_internal_only_agency(row.get("agency_name"))
|
|
|
|
|
+ for row in pending_notification_probe
|
|
|
|
|
+ )
|
|
|
):
|
|
):
|
|
|
notification_lock_name = os.getenv(
|
|
notification_lock_name = os.getenv(
|
|
|
"DAILY_REJECTED_CREATIVE_NOTIFICATION_LOCK_NAME",
|
|
"DAILY_REJECTED_CREATIVE_NOTIFICATION_LOCK_NAME",
|
|
@@ -4732,6 +4886,8 @@ def run_rejected_creative_cleanup(
|
|
|
"accounts": len(accounts),
|
|
"accounts": len(accounts),
|
|
|
"account_ids": account_ids,
|
|
"account_ids": account_ids,
|
|
|
"tokens_prefetched": len(prefetched_tokens),
|
|
"tokens_prefetched": len(prefetched_tokens),
|
|
|
|
|
+ "token_skipped_account_count": len(token_skipped_accounts),
|
|
|
|
|
+ "token_skipped_accounts": sorted(token_skipped_accounts),
|
|
|
"creatives_scanned": scanned,
|
|
"creatives_scanned": scanned,
|
|
|
"cleanup_discovered": discovered,
|
|
"cleanup_discovered": discovered,
|
|
|
"rejected_discovered": review_discovered,
|
|
"rejected_discovered": review_discovered,
|