Mass-scale automated ad delivery
Este script não deve ser instalado diretamente. Este script é uma biblioteca de outros scripts para incluir com o diretório meta // @require https://update.greasyfork.org/scripts/582137/1848327/auto%20CH_TK.js
// ==UserScript==
// @name 巨量引擎投放自动化助手(sheng)
// @namespace http://tampermonkey.net/
// @version 5.27
// @description 巨量引擎投放智能辅助脚本,支持Excel数据导入持久化、抖音号增删改查、全域ROI系数配比与安全限额、双击悬浮球快速运行及悬浮球最小化控制台、自动采集管理页已有项目限制500最大数量上限。修复其他电脑上因国内 CDN 拦截导致 XLSX.read is not a function 的报错,加入国内多线路动态修补。完美修复抖音号保存失效、下拉切换不生效的遗留问题。修复管理页 getManageTotalProjects 缺失导致无法自动循环的致命错误。完美重装已搭/未搭进度上下各5行动态滚动对焦功能。
// @author 327ohnson
// @match *://ad.oceanengine.com/ad/web/manage*
// @match *://ad.oceanengine.com/ad/web/roi/ad/create*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @require https://registry.npmmirror.com/xlsx/0.18.5/files/dist/xlsx.full.min.js
// @run-at document-end
// ==/UserScript==
(function() {
'use strict';
// 1. 初始化油猴原生专属存储空间 (安全隔离,不会被网页的 localStorage 清理机制抹除)
const STORAGE_KEY = 'oceanengine_excel_data_secure';
const AUTO_ACTIVE_KEY = 'oceanengine_auto_active_secure'; // 跨页面自动化标记
const DOUYIN_STORAGE_KEY = 'oceanengine_douyin_secure'; // 抖音号持久化键
const ACTIVE_DOUYIN_KEY = 'oceanengine_active_douyin_id'; // 当前选定抖音ID持久化键
const PROGRESS_INDEX_KEY = 'oceanengine_progress_index'; // 自动填表行进度键
// v3.2 核心:开辟独立于Excel文件的“已搭”防丢哈希映射数据库
const YIDA_RECORDS_KEY = 'oceanengine_yida_records_secure'; // 已搭状态映射表
// ROI 专属安全沙盒持久化键
const ROI_COEFFICIENT_KEY = 'oceanengine_roi_coefficient'; // 投放ROI值
const ROI_LOCKED_KEY = 'oceanengine_roi_locked'; // 投放ROI锁定状态
const ROI_MIN_LIMIT_KEY = 'oceanengine_roi_min_limit'; // ROI防错最低限度
const ROI_MAX_LIMIT_KEY = 'oceanengine_roi_max_limit'; // ROI防错最高限度
// 标题管理专属持久化键
const TITLES_STORAGE_KEY = 'oceanengine_titles_secure'; // 标题库存储
const TITLES_LOCKED_KEY = 'oceanengine_titles_locked'; // 标题库锁定状态
// 产品卖点管理专属持久化键
const SELLING_POINTS_STORAGE_KEY = 'oceanengine_selling_points_secure'; // 卖点库存储
const SELLING_POINTS_LOCKED_KEY = 'oceanengine_selling_points_locked'; // 卖点锁定状态
// 项目名模板管理专属持久化键
const PROJECT_NAME_STORAGE_KEY = 'oceanengine_project_name_secure'; // 项目名模板库存储
const PROJECT_NAME_LOCKED_KEY = 'oceanengine_project_name_locked'; // 项目名锁定状态
// 剩下可搭项目数据管理持久化键
const TOTAL_CREATED_PROJECTS_KEY = 'oceanengine_total_created_projects'; // 已创建项目总数
const REMAINING_PROJECTS_KEY = 'oceanengine_remaining_projects'; // 剩下可搭项目数
let excelData = null;
let douyinAccounts = []; // 存放抖音号列表:[{id: '', name: '', remark: ''}]
let editIndex = -1; // 当前正在编辑的抖音号索引 (-1代表普通新增状态)
// 自动化状态控制机
let isRunning = false;
let isPaused = false;
// 从油猴专属沙盒获取已存在的数据
try {
const savedData = GM_getValue(STORAGE_KEY, null);
if (savedData) excelData = JSON.parse(savedData);
const savedDouyin = GM_getValue(DOUYIN_STORAGE_KEY, null);
if (savedDouyin) douyinAccounts = JSON.parse(savedDouyin);
} catch (e) {
console.error('读取油猴专属沙盒初始化数据失败:', e);
}
// 动态评估页面类型的变量
let isManagePage = location.href.includes('/ad/web/manage');
let isCreatePage = location.href.includes('/ad/web/roi/ad/create');
// 2. 注入全局 iOS 风格 CSS 样式
const style = document.createElement('style');
style.innerHTML = `
:root {
--ios-blue: #007AFF;
--ios-blue-active: #0056B3;
--ios-orange: #FF9500;
--ios-orange-active: #D57C00;
--ios-red: #FF3B30;
--ios-red-active: #C62820;
--ios-gray: #8E8E93;
--ios-bg: rgba(255, 255, 255, 0.85);
--ios-card-bg: rgba(242, 242, 247, 0.8);
--ios-text: #000000;
--ios-text-sec: #8E8E93;
--ios-border: rgba(60, 60, 67, 0.18);
}
#ios-helper-panel {
position: fixed;
top: 100px;
right: 40px;
width: 340px;
max-height: 85vh;
background: var(--ios-bg);
backdrop-filter: blur(20px) saturate(190%);
-webkit-backdrop-filter: blur(20px) saturate(190%);
border-radius: 20px;
box-shadow: 0 12px 38px rgba(0, 0, 0, 0.15), 0 2px 8px rgba(0, 0, 0, 0.05);
border: 1px solid var(--ios-border);
z-index: 999999;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Icons", "Helvetica Neue", Helvetica, Arial, sans-serif;
color: var(--ios-text);
display: flex;
flex-direction: column;
overflow: hidden;
user-select: none;
transition: opacity 0.2s ease, transform 0.2s ease;
}
/* 顶部拖动导航条 */
.ios-header {
padding: 14px 18px;
background: rgba(255, 255, 255, 0.5);
border-bottom: 0.5px solid var(--ios-border);
cursor: move;
display: flex;
align-items: center;
justify-content: space-between;
}
.ios-header-title {
font-size: 17px;
font-weight: 600;
letter-spacing: -0.4px;
}
.ios-header-subtitle {
font-size: 11px;
color: var(--ios-text-sec);
background: rgba(0,0,0,0.05);
padding: 2px 8px;
border-radius: 10px;
}
/* iOS 经典悬浮球样式 (AssistiveTouch) */
#ios-assistive-touch {
position: fixed;
width: 54px;
height: 54px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(15px) saturate(190%);
-webkit-backdrop-filter: blur(15px) saturate(190%);
border: 2px solid rgba(255, 255, 255, 0.25);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.35), inset 0 0 12px rgba(255, 255, 255, 0.15);
z-index: 999999;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
box-sizing: border-box;
user-select: none;
transition: transform 0.1s;
}
#ios-assistive-touch:active {
transform: scale(0.9);
}
#ios-assistive-touch::before {
content: '';
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.35);
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
#ios-assistive-touch::after {
content: '';
position: absolute;
width: 18px;
height: 18px;
border-radius: 50%;
/* CSS 属性配色自适应。运行中显示绿色(#21a675),暂停或待命显示深红色(#c91f37) */
background: var(--ball-color, #c91f37);
box-shadow: 0 0 10px var(--ball-shadow, rgba(201, 31, 55, 0.6));
box-sizing: border-box;
transition: background 0.25s ease, box-shadow 0.25s ease;
}
/* iOS 经典分段切换控制 (Segmented Control) */
.ios-segmented-control {
display: flex;
background: rgba(120, 120, 128, 0.12);
border-radius: 9px;
padding: 2px;
margin: 0 4px;
}
.ios-segment-btn {
flex: 1;
border: none;
background: transparent;
padding: 6px 0;
font-size: 13px;
font-weight: 500;
border-radius: 7px;
cursor: pointer;
color: var(--ios-text-sec);
transition: all 0.2s;
outline: none;
}
.ios-segment-btn.active {
background: #ffffff;
box-shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 1px rgba(0,0,0,0.05);
color: var(--ios-text);
font-weight: 600;
}
/* 内容承载区域与标签页面 */
.ios-content {
padding: 18px;
overflow-y: auto;
max-height: calc(85vh - 60px);
display: flex;
flex-direction: column;
gap: 12px;
}
.ios-tab-pane {
display: none;
flex-direction: column;
gap: 12px;
}
.ios-tab-pane.active-pane {
display: flex;
}
/* 输入框 */
.ios-input-text {
width: 100%;
height: 38px;
border-radius: 8px;
border: 0.5px solid var(--ios-border);
padding: 0 10px;
font-size: 14px;
background: #ffffff;
color: #000000;
box-sizing: border-box;
outline: none;
transition: border-color 0.2s;
}
.ios-input-text:focus {
border-color: var(--ios-blue);
}
.ios-input-text:disabled {
background-color: #E5E5EA !important;
color: #8E8E93 !important;
cursor: not-allowed;
}
/* iOS风格文本域 */
.ios-textarea {
width: 100%;
height: 100px;
border-radius: 8px;
border: 0.5px solid var(--ios-border);
padding: 8px 10px;
font-size: 13px;
background: #ffffff;
color: #000000;
box-sizing: border-box;
outline: none;
transition: border-color 0.2s;
resize: vertical;
line-height: 1.5;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Arial, sans-serif;
}
.ios-textarea:focus {
border-color: var(--ios-blue);
}
.ios-textarea:disabled {
background-color: #E5E5EA !important;
color: #8E8E93 !important;
cursor: not-allowed;
}
/* 已搭重置按钮(用于局部清除) */
.yida-badge-btn {
color: #34C759;
font-weight: 600;
background: rgba(52, 199, 89, 0.15);
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
cursor: pointer;
transition: all 0.2s;
display: inline-block;
user-select: none;
}
.yida-badge-btn:hover {
background: var(--ios-red) !important;
color: #ffffff !important;
}
/* 下拉选择框 */
#active-douyin-select {
width: 100%;
height: 38px;
border-radius: 8px;
border: 0.5px solid var(--ios-border);
padding: 0 10px;
font-size: 14px;
background-color: #ffffff;
color: #000000;
box-sizing: border-box;
outline: none;
cursor: pointer;
transition: border-color 0.2s;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2212%22%20height%3D%2212%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%238E8E93%22%20stroke-width%3D%223%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 12px;
padding-right: 30px;
}
#active-douyin-select:focus {
border-color: var(--ios-blue);
}
/* 抖音账号单条样式 */
.dy-item {
background: rgba(255, 255, 255, 0.7);
border-radius: 10px;
padding: 10px;
border: 0.5px solid var(--ios-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.dy-item-info {
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
flex: 1;
padding-right: 8px;
}
.dy-item-name {
font-size: 14px;
font-weight: 600;
color: var(--ios-text);
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.dy-item-details {
font-size: 11px;
color: var(--ios-text-sec);
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.dy-item-actions {
display: flex;
gap: 6px;
}
.dy-action-btn {
border: none;
background: transparent;
padding: 5px 8px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
border-radius: 6px;
transition: all 0.2s;
outline: none;
}
.dy-btn-edit {
color: var(--ios-blue);
background: rgba(0, 122, 255, 0.1);
}
.dy-btn-edit:active {
background: rgba(0, 122, 255, 0.2);
}
.dy-btn-delete {
color: var(--ios-red);
background: rgba(255, 59, 48, 0.1);
}
.dy-btn-delete:active {
background: rgba(255, 59, 48, 0.2);
}
/* 控制栏并排组 */
.ios-btn-group-primary {
display: flex;
gap: 6px;
width: 100%;
}
/* iOS 按钮系统 */
.ios-btn {
height: 44px;
border-radius: 12px;
border: none;
font-size: 15px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
transition: all 0.2s cubic-bezier(0.25, 0.8, 0.25, 1);
outline: none;
min-width: 0;
}
.ios-btn-primary {
background-color: var(--ios-blue);
color: #ffffff;
}
.ios-btn-primary:active {
background-color: var(--ios-blue-active);
transform: scale(0.97);
}
.ios-btn-orange {
background-color: var(--ios-orange);
color: #ffffff;
}
.ios-btn-orange:active {
background-color: var(--ios-orange-active);
transform: scale(0.97);
}
.ios-btn-secondary {
background-color: #ffffff;
color: var(--ios-blue);
border: 1px solid var(--ios-border);
}
.ios-btn-secondary:active {
background-color: rgba(0, 122, 255, 0.08);
transform: scale(0.97);
}
.ios-btn-danger {
background-color: var(--ios-red);
color: #ffffff;
}
.ios-btn-danger:active {
background-color: var(--ios-red-active);
transform: scale(0.97);
}
.ios-btn-disabled {
background-color: #E5E5EA !important;
color: #AEAEB2 !important;
cursor: not-allowed !important;
transform: none !important;
}
/* 底部双按钮对齐结构 */
.ios-btn-group {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 8px;
width: 100%;
}
/* iOS风格卡片包装器 */
.ios-card {
background: var(--ios-card-bg);
border-radius: 12px;
padding: 12px;
border: 0.5px solid var(--ios-border);
}
.ios-card-title {
font-size: 13px;
font-weight: 500;
color: var(--ios-text-sec);
text-transform: uppercase;
margin-bottom: 8px;
}
/* Excel预览表格样式 */
.excel-preview-container {
max-height: 180px;
overflow: auto;
border-radius: 8px;
border: 0.5px solid var(--ios-border);
background: #ffffff;
}
.excel-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
text-align: left;
}
.excel-table th {
background: #F2F2F7;
position: sticky;
top: 0;
padding: 6px 8px;
font-weight: 600;
border-bottom: 0.5px solid var(--ios-border);
white-space: nowrap;
z-index: 10;
}
.excel-table td {
padding: 6px 8px;
border-bottom: 0.5px solid rgba(60, 60, 67, 0.08);
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 日志窗口 */
.log-console {
background: #000000;
color: #00FF00;
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
font-size: 11px;
padding: 10px;
border-radius: 8px;
max-height: 100px;
overflow-y: auto;
box-shadow: inset 0 2px 8px rgba(0,0,0,0.8);
}
.log-item {
margin-bottom: 4px;
line-height: 1.3;
}
/* 隐藏的原生文件输入框 */
#excel-file-picker {
display: none;
}
`;
document.head.appendChild(style);
// 根据页面类型设置主按钮文本
const primaryBtnText = isManagePage ? '开始搭建 (跳转)' : '开始自动运行';
// 3. 构建 UI Dom 结构
const panel = document.createElement('div');
panel.id = 'ios-helper-panel';
panel.innerHTML = `
<div class="ios-header" id="ios-drag-handle">
<span class="ios-header-title">投放自动化助手</span>
<div style="display: flex; align-items: center; gap: 8px;">
<span class="ios-header-subtitle">Johnson 阿盛</span>
<!-- iOS 最小化缩放按钮 -->
<button id="btn-minimize-panel" style="width: 24px; height: 24px; border: none; background: transparent; cursor: pointer; display: flex; align-items: center; justify-content: center; color: var(--ios-text-sec); outline: none;" title="最小化为悬浮球">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line></svg>
</button>
</div>
</div>
<div class="ios-content">
<!-- iOS 分段式 Tab 切换器 -->
<div class="ios-segmented-control">
<button class="ios-segment-btn active" id="segment-auto">自动化投放</button>
<button class="ios-segment-btn" id="segment-douyin">抖音号管理</button>
<button class="ios-segment-btn" id="segment-admin">安全管理</button>
</div>
<!-- TAB 1: 自动化投放 -->
<div id="tab-auto-pane" class="ios-tab-pane active-pane">
<!-- 抖音号选择下拉卡片 -->
<div class="ios-card">
<div class="ios-card-title">当前投放抖音号</div>
<select id="active-douyin-select">
<option value="">-- 请选择要投放的抖音号 --</option>
</select>
</div>
<!-- 全域ROI系数配置卡片 -->
<div class="ios-card" style="margin-bottom: 2px;">
<div class="ios-card-title">全域ROI系数配置</div>
<div style="display: flex; gap: 8px; align-items: center;">
<input type="number" step="0.01" id="roi-coefficient-input" class="ios-input-text" placeholder="输入投放ROI系数" style="flex: 1;">
<button class="ios-btn ios-btn-primary" id="btn-save-roi" style="width: 70px; height: 38px; font-size: 14px; flex-shrink: 0; margin: 0;">确定</button>
<button class="ios-btn ios-btn-secondary" id="btn-edit-roi" style="width: 70px; height: 38px; font-size: 14px; display: none; flex-shrink: 0; margin: 0;">修改</button>
</div>
</div>
<!-- 剩下可搭项目额度卡片(第一页中间位置) -->
<div class="ios-card" style="margin-bottom: 2px; background: rgba(0, 122, 255, 0.05); border-left: 4px solid var(--ios-blue);">
<div style="display: flex; align-items: center; justify-content: space-between; width: 100%;">
<span style="font-size: 13px; font-weight: 600; color: var(--ios-text-sec);">剩下可搭项目:</span>
<span id="remaining-projects-count" style="font-size: 16px; color: var(--ios-blue); font-weight: 700; transition: all 0.3s;">-- 个</span>
</div>
<div style="font-size: 10px; color: var(--ios-text-sec); margin-top: 4px; text-align: left;" id="remaining-projects-tip">
(正在检测巨量后台已有项目数...)
</div>
</div>
<!-- 核心控制动作栏:开始、暂停、重新运行 -->
<div class="ios-btn-group-primary">
<button class="ios-btn ios-btn-primary" id="btn-start-build" style="flex: 2; min-width: 0;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
<span id="btn-text-label" style="font-size: 13px; text-overflow: ellipsis; white-space: nowrap; overflow: hidden;">${primaryBtnText}</span>
</button>
<button class="ios-btn ios-btn-disabled" id="btn-pause-build" style="width: 60px; flex-shrink: 0; font-size: 14px;">
<span id="btn-pause-label">暂停</span>
</button>
<button class="ios-btn ios-btn-secondary" id="btn-rerun-build" style="width: 80px; flex-shrink: 0; font-size: 13px; font-weight: 700; color: var(--ios-orange); border-color: rgba(255,149,0,0.4);" title="重置当前流程,重新扫描已搭数据库并从第一个未搭剧目重试运行">
重新运行
</button>
</div>
<!-- 导入与清空 -->
<div class="ios-btn-group">
<button class="ios-btn ios-btn-secondary" id="btn-import-excel">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line></svg>
导入数据/Excel
</button>
<button class="ios-btn ios-btn-danger" id="btn-clear-data">清空</button>
</div>
<input type="file" id="excel-file-picker" accept=".xlsx, .xls" />
<!-- 当前搭建剧目看板与手动跳过控制 -->
<div class="ios-card" style="padding: 10px 14px; background: rgba(255, 149, 0, 0.1); border-left: 4px solid var(--ios-orange); border-radius: 12px; margin-bottom: 2px;">
<div style="display: flex; align-items: center; justify-content: space-between; width: 100%; box-sizing: border-box; margin-bottom: 6px;">
<span style="font-size: 13px; font-weight: 600; color: var(--ios-text-sec);">正搭建的剧目为:</span>
<span id="current-building-drama-name" style="font-size: 14px; color: var(--ios-orange); font-weight: 700; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; max-width: 160px; text-align: right;">--</span>
</div>
<div style="display: flex; justify-content: flex-end; gap: 8px;">
<button class="ios-btn ios-btn-secondary" id="btn-manual-yida" style="height: 26px; font-size: 11px; padding: 0 10px; border-radius: 6px; margin: 0; background: #ffffff; color: var(--ios-orange); border: 0.5px solid var(--ios-orange);" title="手动将当前显示剧目在安全库与Excel中标记为‘已搭’,防止重复配置">
手动标记“已搭”
</button>
</div>
</div>
<!-- 数据载入状态 -->
<div class="ios-card">
<div class="ios-card-title" id="data-status-title">数据载入状态</div>
<div id="data-preview-box">
<div style="font-size: 13px; color: var(--ios-text-sec); text-align: center; padding: 20px 0;">
暂无导入数据,请先导入Excel
</div>
</div>
</div>
<!-- 控制台 -->
<div class="ios-card">
<div class="ios-card-title">系统控制台日志</div>
<div class="log-console" id="log-console-box">
<div class="log-item" style="color: #8E8E93;">[系统] 助手加载完毕,等待操作...</div>
</div>
</div>
</div>
<!-- TAB 2: 抖音号管理 -->
<div id="tab-douyin-pane" class="ios-tab-pane">
<!-- 抖音号编辑/添加卡片 -->
<div class="ios-card">
<div class="ios-card-title" id="douyin-form-title">添加抖音号</div>
<div style="display: flex; flex-direction: column; gap: 8px;">
<input type="text" id="dy-input-id" class="ios-input-text" placeholder="请输入抖音ID (唯一标识)">
<input type="text" id="dy-input-name" class="ios-input-text" placeholder="请输入抖音名称">
<input type="text" id="dy-input-remark" class="ios-input-text" placeholder="备注信息 (可选)">
<div style="display: flex; gap: 8px; margin-top: 4px;">
<button class="ios-btn ios-btn-primary" id="btn-save-douyin" style="flex: 1; height: 38px; font-size: 14px;">保存抖音号</button>
<button class="ios-btn ios-btn-secondary" id="btn-cancel-douyin" style="flex: 1; height: 38px; font-size: 14px; display: none; flex-shrink: 0; margin: 0;">取消修改</button>
</div>
</div>
</div>
<!-- 已保存的抖音号卡片 -->
<div class="ios-card" style="flex-grow: 1; display: flex; flex-direction: column;">
<div class="ios-card-title">已存抖音号库</div>
<div id="douyin-list-box" style="display: flex; flex-direction: column; gap: 8px; max-height: 280px; overflow-y: auto; padding-right: 2px;">
<div style="font-size: 13px; color: var(--ios-text-sec); text-align: center; padding: 25px 0;">
抖音库暂无配置,请在上方录入
</div>
</div>
</div>
</div>
<!-- TAB 3: 安全管理 -->
<div id="tab-admin-pane" class="ios-tab-pane">
<!-- 项目名配置卡片 -->
<div class="ios-card">
<div class="ios-card-title">项目名配置</div>
<div style="display: flex; flex-direction: column; gap: 8px;">
<input type="text" id="project-name-template-input" class="ios-input-text" placeholder="例: <剧目>_全域投放_<日期>_ROI:<ROI>">
<div style="display: flex; gap: 4px; flex-wrap: wrap;">
<button class="ios-btn ios-btn-secondary" id="btn-insert-date-tag" style="height: 28px; font-size: 11px; padding: 0 6px; border-radius: 6px; margin: 0; flex: 1;" title="点击插入日期占位符 (打包输出时转化为 %m%d 格式)">插入 <日期></button>
<button class="ios-btn ios-btn-secondary" id="btn-insert-drama-tag" style="height: 28px; font-size: 11px; padding: 0 6px; border-radius: 6px; margin: 0; flex: 1;" title="点击插入剧目名占位符 (自动提取当前搭建剧目名称)">插入 <剧目></button>
<button class="ios-btn ios-btn-secondary" id="btn-insert-roi-tag" style="height: 28px; font-size: 11px; padding: 0 6px; border-radius: 6px; margin: 0; flex: 1;" title="点击插入ROI占位符 (自动提取第一页配置的全域ROI)">插入 <ROI></button>
</div>
<div style="display: flex; gap: 6px; width: 100%;">
<button class="ios-btn ios-btn-primary" id="btn-lock-project-name" style="flex: 1; height: 38px; font-size: 13px;">锁定</button>
<button class="ios-btn ios-btn-secondary" id="btn-modify-project-name" style="flex: 1; height: 38px; font-size: 13px; display: none;">修改</button>
<button class="ios-btn ios-btn-danger" id="btn-clear-project-name" style="width: 70px; height: 38px; font-size: 13px;">清空</button>
</div>
</div>
</div>
<div class="ios-card">
<div class="ios-card-title">ROI 防错上下限配置</div>
<div style="display: flex; flex-direction: column; gap: 8px;">
<div style="display: flex; gap: 8px; align-items: center; justify-content: space-between;">
<span style="font-size: 13px; font-weight: 600; color: var(--ios-text-sec); width: 80px;">最小值限制:</span>
<input type="number" step="0.01" id="roi-min-input" class="ios-input-text" placeholder="设置ROI下限(防错)" style="flex: 1;">
</div>
<div style="display: flex; gap: 8px; align-items: center; justify-content: space-between;">
<span style="font-size: 13px; font-weight: 600; color: var(--ios-text-sec); width: 80px;">最大值限制:</span>
<input type="number" step="0.01" id="roi-max-input" class="ios-input-text" placeholder="设置ROI上限(防错)" style="flex: 1;">
</div>
<button class="ios-btn ios-btn-primary" id="btn-save-admin-limits" style="height: 38px; font-size: 14px; margin-top: 4px; width: 100%;">保存防错配置</button>
</div>
</div>
<!-- 标题管理卡片 -->
<div class="ios-card">
<div class="ios-card-title">标题管理 (每行一个标题)</div>
<div style="display: flex; flex-direction: column; gap: 8px;">
<textarea id="title-management-input" class="ios-textarea" placeholder="在此输入自定义创意标题(支持多行输入) 每行代表一个标题"></textarea>
<div style="display: flex; gap: 6px; width: 100%;">
<button class="ios-btn ios-btn-primary" id="btn-lock-titles" style="flex: 1; height: 38px; font-size: 13px;">锁定</button>
<button class="ios-btn ios-btn-secondary" id="btn-modify-titles" style="flex: 1; height: 38px; font-size: 13px; display: none;">修改</button>
<button class="ios-btn ios-btn-danger" id="btn-clear-titles" style="width: 70px; height: 38px; font-size: 13px;">清空</button>
</div>
</div>
</div>
<!-- 产品卖点管理卡片 -->
<div class="ios-card">
<div class="ios-card-title">产品卖点管理 (每行一个卖点,最多10个)</div>
<div style="display: flex; flex-direction: column; gap: 8px;">
<textarea id="selling-points-input" class="ios-textarea" placeholder="在此输入自定义产品卖点(支持多行输入) 每行代表一个产品卖点"></textarea>
<div style="display: flex; gap: 6px; width: 100%;">
<button class="ios-btn ios-btn-primary" id="btn-lock-points" style="flex: 1; height: 38px; font-size: 13px;">锁定</button>
<button class="ios-btn ios-btn-secondary" id="btn-modify-points" style="flex: 1; height: 38px; font-size: 13px; display: none;">修改</button>
<button class="ios-btn ios-btn-danger" id="btn-clear-points" style="width: 70px; height: 38px; font-size: 13px;">清空</button>
</div>
</div>
</div>
</div>
</div>
`;
document.body.appendChild(panel);
// 4. 获取 DOM 元素引用
const dragHandle = document.getElementById('ios-drag-handle');
const btnStartBuild = document.getElementById('btn-start-build');
const btnPauseBuild = document.getElementById('btn-pause-build');
const btnPauseLabel = document.getElementById('btn-pause-label');
const btnRerunBuild = document.getElementById('btn-rerun-build');
const btnManualYida = document.getElementById('btn-manual-yida');
const btnImportExcel = document.getElementById('btn-import-excel');
const btnClearData = document.getElementById('btn-clear-data');
const filePicker = document.getElementById('excel-file-picker');
const dataStatusTitle = document.getElementById('data-status-title');
const dataPreviewBox = document.getElementById('data-preview-box');
const logConsoleBox = document.getElementById('log-console-box');
// 抖音管理与多Tab专属 DOM
const tabAuto = document.getElementById('segment-auto');
const tabDouyin = document.getElementById('segment-douyin');
const tabAdmin = document.getElementById('segment-admin');
const paneAuto = document.getElementById('tab-auto-pane');
const paneDouyin = document.getElementById('tab-douyin-pane');
const paneAdmin = document.getElementById('tab-admin-pane');
const dyFormTitle = document.getElementById('douyin-form-title');
const dyInputId = document.getElementById('dy-input-id');
const dyInputName = document.getElementById('dy-input-name');
const dyInputRemark = document.getElementById('dy-input-remark');
const btnSaveDouyin = document.getElementById('btn-save-douyin');
const btnCancelDouyin = document.getElementById('btn-cancel-douyin');
const douyinListBox = document.getElementById('douyin-list-box');
const activeDouyinSelect = document.getElementById('active-douyin-select');
// ROI 投放与极值 DOM 引用
const roiInput = document.getElementById('roi-coefficient-input');
const btnSaveRoi = document.getElementById('btn-save-roi');
const btnEditRoi = document.getElementById('btn-edit-roi');
const minLimitInput = document.getElementById('roi-min-input');
const maxLimitInput = document.getElementById('roi-max-input');
const btnSaveAdminLimits = document.getElementById('btn-save-admin-limits');
// 标题管理 DOM 引用
const titleInput = document.getElementById('title-management-input');
const btnLockTitles = document.getElementById('btn-lock-titles');
const btnModifyTitles = document.getElementById('btn-modify-titles');
const btnClearTitles = document.getElementById('btn-clear-titles');
// 产品卖点 DOM 引用
const pointsInput = document.getElementById('selling-points-input');
const btnLockPoints = document.getElementById('btn-lock-points');
const btnModifyPoints = document.getElementById('btn-modify-points');
const btnClearPoints = document.getElementById('btn-clear-points');
// 项目名模板 DOM 引用
const projectNameInput = document.getElementById('project-name-template-input');
const btnLockProjectName = document.getElementById('btn-lock-project-name');
const btnModifyProjectName = document.getElementById('btn-modify-project-name');
const btnClearProjectName = document.getElementById('btn-clear-project-name');
const btnInsertDateTag = document.getElementById('btn-insert-date-tag');
const btnInsertDramaTag = document.getElementById('btn-insert-drama-tag');
const btnInsertRoiTag = document.getElementById('btn-insert-roi-tag');
// 创建 iOS 物理悬浮球 (AssistiveTouch)
const floatBall = document.createElement('div');
floatBall.id = 'ios-assistive-touch';
floatBall.title = '投放自动化助手 - 双击快速开始投放 | 点击还原';
document.body.appendChild(floatBall);
// 5. 工具方法:打印日志
function log(message, type = 'info') {
const time = new Date().toLocaleTimeString();
const logItem = document.createElement('div');
logItem.className = 'log-item';
let color = '#00FF00'; // 默认绿
if (type === 'error') color = '#FF3B30';
if (type === 'warning') color = '#FF9500';
if (type === 'system') color = '#8E8E93';
logItem.style.color = color;
logItem.innerText = `[${time}] ${message}`;
logConsoleBox.appendChild(logItem);
logConsoleBox.scrollTop = logConsoleBox.scrollHeight;
}
// 确保 XLSX 解析库完美加载的国内动态多线路双重保险修补逻辑
async function ensureXLSXLoaded() {
let activeXLSX = typeof XLSX !== 'undefined' ? XLSX : (typeof window.XLSX !== 'undefined' ? window.XLSX : null);
if (activeXLSX && typeof activeXLSX.read === 'function') {
return true;
}
log("⚠️ 检测到本地 Excel 解析库(XLSX)加载异常,正在自动启动国内高速多线路动态修补...", "warning");
// 整理国内极其稳定且高速的知名 CDN 镜像(依次降级读取)
const cdns = [
'https://lib.baomitu.com/xlsx/0.18.5/xlsx.full.min.js', // 360 Baomitu 源 (国内极速)
'https://cdn.staticfile.org/xlsx/0.18.5/xlsx.full.min.js', // Staticfile 国内联合源
'https://registry.npmmirror.com/xlsx/0.18.5/files/dist/xlsx.full.min.js' // 阿里淘宝原厂源
];
for (const url of cdns) {
try {
log(`- 正在尝试通过备用高速线路注入加载: ${url}`, 'system');
const loaded = await new Promise((resolve) => {
const script = document.createElement('script');
script.src = url;
script.onload = () => {
const testXLSX = typeof window.XLSX !== 'undefined' ? window.XLSX : null;
if (testXLSX && typeof testXLSX.read === 'function') {
resolve(true);
} else {
resolve(false);
}
};
script.onerror = () => resolve(false);
document.head.appendChild(script);
});
if (loaded) {
log("✅ 备用线路动态修补成功!Excel 解析库已修复并重新激活。", "info");
return true;
}
} catch (e) {
console.error('动态注入 Excel CDN 线路时发生捕获异常:', e);
}
}
return false;
}
// UI 正搭建剧目显示更新
function updateCurrentBuildingDramaName(name) {
const dramaEl = document.getElementById('current-building-drama-name');
if (dramaEl) {
dramaEl.innerText = name || '--';
}
}
// 呼吸指示环配色动态管理
function updateBallStyle() {
if (isRunning && !isPaused) {
floatBall.style.setProperty('--ball-color', '#21a675');
floatBall.style.setProperty('--ball-shadow', 'rgba(33, 166, 117, 0.7)');
} else {
floatBall.style.setProperty('--ball-color', '#c91f37');
floatBall.style.setProperty('--ball-shadow', 'rgba(201, 31, 55, 0.7)');
}
}
// 智能元素真实可见度核对方法 (屏蔽 SPA 骨架屏、加载遮罩、不可见占位符)
function isVisible(el) {
if (!el) return false;
// 1. 物理宽高基本过滤
if (el.offsetWidth === 0 || el.offsetHeight === 0) {
return false;
}
// 2. CSS 隐形与骨架屏排查
try {
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
return false;
}
// 匹配剔除骨架屏常见样式
const className = String(el.className || '').toLowerCase();
if (className.includes('skeleton') || className.includes('loading') || className.includes('placeholder')) {
return false;
}
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
return false;
}
} catch (e) {}
return true;
}
// 字符串规范化归一化工具,移除所有空格、特殊标点符号,统一转小写,防止中英文符号差异导致比对失败
function normalizeString(str) {
if (!str) return '';
return String(str)
.toLowerCase()
.replace(/[\s\p{P}\p{S}\p{Z}]/gu, ''); // 完美清洗标点与空格
}
// 6. 实现拖拽与悬浮球、面板位置共享同步逻辑
let isDragging = false;
let startX, startY;
let initialLeft, initialTop;
const storedX = GM_getValue('ios_helper_x', null);
const storedY = GM_getValue('ios_helper_y', null);
// 同步刷新或初加载时的坐标
function syncConsolePositions(x, y) {
panel.style.left = x + 'px';
panel.style.top = y + 'px';
panel.style.right = 'auto';
floatBall.style.left = x + 'px';
floatBall.style.top = y + 'px';
}
if (storedX !== null && storedY !== null) {
syncConsolePositions(storedX, storedY);
}
// 拖动主面板
dragHandle.addEventListener('mousedown', (e) => {
if (e.target.closest('#btn-minimize-panel')) return;
isDragging = true;
startX = e.clientX;
startY = e.clientY;
const rect = panel.getBoundingClientRect();
initialLeft = rect.left;
initialTop = rect.top;
panel.style.right = 'auto';
panel.style.bottom = 'auto';
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
e.preventDefault();
});
function onMouseMove(e) {
if (!isDragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
let newLeft = initialLeft + dx;
let newTop = initialTop + dy;
const maxX = window.innerWidth - panel.offsetWidth;
const maxY = window.innerHeight - panel.offsetHeight;
newLeft = Math.max(0, Math.min(newLeft, maxX));
newTop = Math.max(0, Math.min(newTop, maxY));
syncConsolePositions(newLeft, newTop);
}
// 拖动释放
function onMouseUp() {
if (isDragging) {
isDragging = false;
GM_setValue('ios_helper_x', parseInt(panel.style.left));
GM_setValue('ios_helper_y', parseInt(panel.style.top));
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
}
}
// iOS 经典悬浮球 independent 仿真拖动与双击
let isDraggingBall = false;
let ballStartX, ballStartY;
let ballInitialLeft, ballInitialTop;
let lastClickTime = 0;
let clickTimeout = null;
floatBall.addEventListener('mousedown', (e) => {
isDraggingBall = false;
ballStartX = e.clientX;
ballStartY = e.clientY;
const rect = floatBall.getBoundingClientRect();
ballInitialLeft = rect.left;
ballInitialTop = rect.top;
document.addEventListener('mousemove', onMouseMoveBall);
document.addEventListener('mouseup', onMouseUpBall);
e.preventDefault();
});
function onMouseMoveBall(e) {
const dx = e.clientX - ballStartX;
const dy = e.clientY - ballStartY;
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) {
isDraggingBall = true;
}
let newLeft = ballInitialLeft + dx;
let newTop = ballInitialTop + dy;
const maxX = window.innerWidth - floatBall.offsetWidth;
const maxY = window.innerHeight - floatBall.offsetHeight;
newLeft = Math.max(0, Math.min(newLeft, maxX));
newTop = Math.max(0, Math.min(newTop, maxY));
syncConsolePositions(newLeft, newTop);
}
// 悬浮球释放
function onMouseUpBall() {
document.removeEventListener('mousemove', onMouseMoveBall);
document.removeEventListener('mouseup', onMouseUpBall);
if (isDraggingBall) {
GM_setValue('ios_helper_x', parseInt(floatBall.style.left));
GM_setValue('ios_helper_y', parseInt(floatBall.style.top));
} else {
// 双击逻辑
const currentTime = Date.now();
if (currentTime - lastClickTime < 300) {
clearTimeout(clickTimeout);
if (isRunning) {
log("⏸ [悬浮球双击] 流程运行中,触发暂停/继续状态切换", "system");
btnPauseBuild.click();
} else {
log("▶ [悬浮球双击] 触发一键极速启动自动化投放流程...", "info");
if (btnStartBuild.classList.contains('ios-btn-disabled')) {
log("❌ 极速启动失败:请先导入 Excel 数据并选定抖音账号!", "error");
} else {
btnStartBuild.click();
}
}
} else {
// 单击候选
clickTimeout = setTimeout(() => {
togglePanelMinimizeState(false);
}, 300);
}
lastClickTime = currentTime;
}
}
// 控制台窗口大小切换处理器
function togglePanelMinimizeState(minimize) {
if (minimize) {
panel.style.display = 'none';
floatBall.style.display = 'flex';
GM_setValue('ios_helper_minimized', 'true');
} else {
panel.style.display = 'flex';
floatBall.style.display = 'none';
GM_setValue('ios_helper_minimized', 'false');
}
}
// 最小化事件
document.getElementById('btn-minimize-panel').addEventListener('click', (e) => {
e.stopPropagation();
togglePanelMinimizeState(true);
});
// 7. 三页分段式 Tab 切换机制
tabAuto.addEventListener('click', () => {
tabAuto.classList.add('active');
tabDouyin.classList.remove('active');
tabAdmin.classList.remove('active');
paneAuto.classList.add('active-pane');
paneDouyin.classList.remove('active-pane');
paneAdmin.classList.remove('active-pane');
});
tabDouyin.addEventListener('click', () => {
tabDouyin.classList.add('active');
tabAuto.classList.remove('active');
tabAdmin.classList.remove('active');
paneDouyin.classList.add('active-pane');
paneAuto.classList.remove('active-pane');
paneAdmin.classList.remove('active-pane');
renderDouyinList(); // 渲染抖音列表
});
tabAdmin.addEventListener('click', () => {
tabAdmin.classList.add('active');
tabAuto.classList.remove('active');
tabDouyin.classList.remove('active');
paneAdmin.classList.add('active-pane');
paneAuto.classList.remove('active-pane');
paneDouyin.classList.remove('active-pane');
});
// 全域 ROI 系数配置持久化载入
const storedRoi = GM_getValue(ROI_COEFFICIENT_KEY, '');
const isRoiLocked = GM_getValue(ROI_LOCKED_KEY, 'false');
if (storedRoi) {
roiInput.value = storedRoi;
}
if (isRoiLocked === 'true') {
roiInput.disabled = true;
btnSaveRoi.style.display = 'none';
btnEditRoi.style.display = 'block';
} else {
roiInput.disabled = false;
btnSaveRoi.style.display = 'block';
btnEditRoi.style.display = 'none';
}
// 点击确定保存 ROI
btnSaveRoi.addEventListener('click', () => {
const val = parseFloat(roiInput.value);
if (isNaN(val)) {
log("❌ 保存失败:请输入有效的ROI数值!", "error");
return;
}
const minLimit = GM_getValue(ROI_MIN_LIMIT_KEY, '');
const maxLimit = GM_getValue(ROI_MAX_LIMIT_KEY, '');
if (minLimit !== '' && val < parseFloat(minLimit)) {
log(`❌ 保存失败:当前设定的系数 [${val}] 低于管理设定的防错下限 [${minLimit}]!`, "error");
return;
}
if (maxLimit !== '' && val > parseFloat(maxLimit)) {
log(`❌ 保存失败:当前设定的系数 [${val}] 高于管理设定的防错上限 [${maxLimit}]!`, "error");
return;
}
GM_setValue(ROI_COEFFICIENT_KEY, val);
GM_setValue(ROI_LOCKED_KEY, 'true');
roiInput.disabled = true;
btnSaveRoi.style.display = 'none';
btnEditRoi.style.display = 'block';
log(`✨ ROI 系数已成功配置并锁定为:【${val}】`, "info");
});
// ROI 修改解锁
btnEditRoi.addEventListener('click', () => {
GM_setValue(ROI_LOCKED_KEY, 'false');
roiInput.disabled = false;
btnSaveRoi.style.display = 'block';
btnEditRoi.style.display = 'none';
roiInput.focus();
log("📝 ROI 系数已解锁,已进入可修改编辑模式。", "system");
});
// TAB 3: 管理页面上下限载入与保存
minLimitInput.value = GM_getValue(ROI_MIN_LIMIT_KEY, '');
maxLimitInput.value = GM_getValue(ROI_MAX_LIMIT_KEY, '');
btnSaveAdminLimits.addEventListener('click', () => {
const minVal = minLimitInput.value.trim();
const maxVal = maxLimitInput.value.trim();
if (minVal !== '' && isNaN(parseFloat(minVal))) {
log("❌ 保存失败:防错最小值必须为有效数字!", "error");
return;
}
if (maxVal !== '' && isNaN(parseFloat(maxVal))) {
log("❌ 保存失败:防错最大值必须为有效数字!", "error");
return;
}
if (minVal !== '' && maxVal !== '' && parseFloat(minVal) > parseFloat(maxVal)) {
log("❌ 保存失败:安全防错下限不可大于防错上限!", "error");
return;
}
GM_setValue(ROI_MIN_LIMIT_KEY, minVal);
GM_setValue(ROI_MAX_LIMIT_KEY, maxVal);
log("🔒 ROI 安全管理上下限已保存成功。", "info");
});
// ==========================================
// TAB 3 创意标题管理控制逻辑极其数据持久化
// ==========================================
if (titleInput) {
titleInput.value = GM_getValue(TITLES_STORAGE_KEY, '');
}
function updateTitlesUIState(locked) {
if (!titleInput || !btnLockTitles || !btnModifyTitles) return;
if (locked === 'true' || locked === true) {
titleInput.disabled = true;
btnLockTitles.style.display = 'none';
btnModifyTitles.style.display = 'block';
} else {
titleInput.disabled = false;
btnLockTitles.style.display = 'block';
btnModifyTitles.style.display = 'none';
}
}
// 加载时自检创意标题锁定状态
const isTitlesLocked = GM_getValue(TITLES_LOCKED_KEY, 'false');
updateTitlesUIState(isTitlesLocked);
// 【锁定】按钮绑定事件
if (btnLockTitles) {
btnLockTitles.addEventListener('click', () => {
const rawValue = titleInput.value; // 保留换行符 \n
GM_setValue(TITLES_STORAGE_KEY, rawValue);
GM_setValue(TITLES_LOCKED_KEY, 'true');
updateTitlesUIState(true);
// 计算有效行数并日志反馈
const lineCount = rawValue.split('\n').filter(line => line.trim() !== '').length;
log(`🔒 标题库已保存并锁定!(共 ${lineCount} 行有效标题)`, "info");
});
}
// 【修改】按钮绑定事件
if (btnModifyTitles) {
btnModifyTitles.addEventListener('click', () => {
GM_setValue(TITLES_LOCKED_KEY, 'false');
updateTitlesUIState(false);
titleInput.focus();
log("📝 标题库已解锁,进入编辑修改模式。", "system");
});
}
// 【清空】按钮绑定事件
if (btnClearTitles) {
btnClearTitles.addEventListener('click', () => {
titleInput.value = '';
GM_deleteValue(TITLES_STORAGE_KEY);
GM_setValue(TITLES_LOCKED_KEY, 'false');
updateTitlesUIState(false);
log("🧹 标题库已彻底清空,并重置为未锁定编辑状态。", "system");
});
}
// ==========================================
// TAB 3 产品卖点管理控制逻辑极其数据持久化
// ==========================================
if (pointsInput) {
pointsInput.value = GM_getValue(SELLING_POINTS_STORAGE_KEY, '');
}
function updatePointsUIState(locked) {
if (!pointsInput || !btnLockPoints || !btnModifyPoints) return;
if (locked === 'true' || locked === true) {
pointsInput.disabled = true;
btnLockPoints.style.display = 'none';
btnModifyPoints.style.display = 'block';
} else {
pointsInput.disabled = false;
btnLockPoints.style.display = 'block';
btnModifyPoints.style.display = 'none';
}
}
// 加载时自检产品的锁定状态
const isPointsLocked = GM_getValue(SELLING_POINTS_LOCKED_KEY, 'false');
updatePointsUIState(isPointsLocked);
// 产品卖点 【锁定】 按钮绑定事件
if (btnLockPoints) {
btnLockPoints.addEventListener('click', () => {
const rawValue = pointsInput.value; // 保留换行 \n
GM_setValue(SELLING_POINTS_STORAGE_KEY, rawValue);
GM_setValue(SELLING_POINTS_LOCKED_KEY, 'true');
updatePointsUIState(true);
const lineCount = rawValue.split('\n').filter(line => line.trim() !== '').length;
log(`🔒 产品卖点库已保存并锁定!(共 ${lineCount} 条有效产品卖点)`, "info");
});
}
// 产品卖点 【修改】 按钮绑定事件
if (btnModifyPoints) {
btnModifyPoints.addEventListener('click', () => {
GM_setValue(SELLING_POINTS_LOCKED_KEY, 'false');
updatePointsUIState(false);
pointsInput.focus();
log("📝 产品卖点库已解锁,进入编辑修改模式。", "system");
});
}
// 产品卖点 【清空】 按钮绑定事件
if (btnClearPoints) {
btnClearPoints.addEventListener('click', () => {
pointsInput.value = '';
GM_deleteValue(SELLING_POINTS_STORAGE_KEY);
GM_setValue(SELLING_POINTS_LOCKED_KEY, 'false');
updatePointsUIState(false);
log("🧹 产品卖点库已彻底清空,并重置为未锁定编辑状态。", "system");
});
}
// ==========================================
// TAB 3 项目名模板管理控制逻辑极其数据持久化
// ==========================================
if (projectNameInput) {
projectNameInput.value = GM_getValue(PROJECT_NAME_STORAGE_KEY, '');
}
function updateProjectNameUIState(locked) {
if (!projectNameInput || !btnLockProjectName || !btnModifyProjectName) return;
if (locked === 'true' || locked === true) {
projectNameInput.disabled = true;
btnLockProjectName.style.display = 'none';
btnModifyProjectName.style.display = 'block';
if (btnInsertDateTag) btnInsertDateTag.classList.add('ios-btn-disabled');
if (btnInsertDramaTag) btnInsertDramaTag.classList.add('ios-btn-disabled');
if (btnInsertRoiTag) btnInsertRoiTag.classList.add('ios-btn-disabled');
} else {
projectNameInput.disabled = false;
btnLockProjectName.style.display = 'block';
btnModifyProjectName.style.display = 'none';
if (btnInsertDateTag) btnInsertDateTag.classList.remove('ios-btn-disabled');
if (btnInsertDramaTag) btnInsertDramaTag.classList.remove('ios-btn-disabled');
if (btnInsertRoiTag) btnInsertRoiTag.classList.remove('ios-btn-disabled');
}
}
const isProjectNameLocked = GM_getValue(PROJECT_NAME_LOCKED_KEY, 'false');
updateProjectNameUIState(isProjectNameLocked);
if (btnInsertDateTag) {
btnInsertDateTag.addEventListener('click', () => {
if (projectNameInput.disabled) return;
insertAtCursor(projectNameInput, '<日期>');
});
}
if (btnInsertDramaTag) {
btnInsertDramaTag.addEventListener('click', () => {
if (projectNameInput.disabled) return;
insertAtCursor(projectNameInput, '<剧目>');
});
}
if (btnInsertRoiTag) {
btnInsertRoiTag.addEventListener('click', () => {
if (projectNameInput.disabled) return;
insertAtCursor(projectNameInput, '<ROI>');
});
}
if (btnLockProjectName) {
btnLockProjectName.addEventListener('click', () => {
const rawValue = projectNameInput.value;
GM_setValue(PROJECT_NAME_STORAGE_KEY, rawValue);
GM_setValue(PROJECT_NAME_LOCKED_KEY, 'true');
updateProjectNameUIState(true);
log(`🔒 项目名模板已保存并锁定!`, "info");
});
}
if (btnModifyProjectName) {
btnModifyProjectName.addEventListener('click', () => {
GM_setValue(PROJECT_NAME_LOCKED_KEY, 'false');
updateProjectNameUIState(false);
projectNameInput.focus();
log("📝 项目名模板已解锁,进入编辑修改模式。", "system");
});
}
if (btnClearProjectName) {
btnClearProjectName.addEventListener('click', () => {
projectNameInput.value = '';
GM_deleteValue(PROJECT_NAME_STORAGE_KEY);
GM_setValue(PROJECT_NAME_LOCKED_KEY, 'false');
updateProjectNameUIState(false);
log("🧹 项目名模板已彻底清空,并重置为未锁定编辑状态。", "system");
});
}
// 局部智能清除“已搭”记录逻辑
function removeSpecificYida(drama, dyid) {
let yidaRecords = {};
try {
const savedYida = GM_getValue(YIDA_RECORDS_KEY, '{}');
yidaRecords = JSON.parse(savedYida);
} catch (e) {
console.error(e);
}
const cleanDrama = String(drama).trim().replace(/\s+/g, '');
const cleanDyid = String(dyid).trim();
const key = `${cleanDrama}_${cleanDyid}`;
if (yidaRecords[key]) {
delete yidaRecords[key];
GM_setValue(YIDA_RECORDS_KEY, JSON.stringify(yidaRecords));
log(`✨ 已成功解除剧目 【${drama}】 的已搭标记!`, 'info');
}
// 同步本地Excel内存缓存数据
if (excelData && excelData.length > 1) {
const activeAccount = douyinAccounts.find(acc => acc.id === cleanDyid);
let colIdx = -1;
if (activeAccount) {
colIdx = getDouyinColumnIndex(excelData, activeAccount.id, activeAccount.name);
}
if (colIdx !== -1) {
const rows = excelData.slice(1);
const rIdx = rows.findIndex(r => String(r[0]).trim().replace(/\s+/g, '') === cleanDrama);
if (rIdx !== -1) {
if (excelData[rIdx + 1] && excelData[rIdx + 1][colIdx] === "已搭") {
excelData[rIdx + 1][colIdx] = ""; // 还原为空白
GM_setValue(STORAGE_KEY, JSON.stringify(excelData));
}
}
}
}
// 重新检索最新的未搭建进度索引并安全覆盖
const nextIdx = getNextUncompletedIndex();
GM_setValue(PROGRESS_INDEX_KEY, nextIdx);
const rows = excelData ? excelData.slice(1) : [];
if (rows[nextIdx]) {
updateCurrentBuildingDramaName(rows[nextIdx][0]);
} else {
updateCurrentBuildingDramaName('--');
}
renderPreviewTable();
}
// 对预览表的数据容器进行事件委托绑定
if (dataPreviewBox) {
dataPreviewBox.addEventListener('click', (e) => {
const targetBadge = e.target.closest('.yida-badge-btn');
if (targetBadge) {
e.stopPropagation();
const drama = targetBadge.getAttribute('data-drama');
const dyid = targetBadge.getAttribute('data-dyid');
if (drama && dyid) {
removeSpecificYida(drama, dyid);
}
}
});
}
// 8. 渲染抖音列表 (CRUD)
function renderDouyinList() {
if (!douyinAccounts || douyinAccounts.length === 0) {
douyinListBox.innerHTML = `
<div style="font-size: 13px; color: var(--ios-text-sec); text-align: center; padding: 25px 0;">
抖音库暂无配置,请在上方录入
</div>
`;
updateActiveDouyinDropdown();
return;
}
let html = '';
douyinAccounts.forEach((acc, idx) => {
html += `
<div class="dy-item">
<div class="dy-item-info">
<div class="dy-item-name">${acc.name}</div>
<div class="dy-item-details">ID: ${acc.id} ${acc.remark ? `| 备注: ${acc.remark}` : ''}</div>
</div>
<div class="dy-item-actions">
<button class="dy-action-btn dy-btn-edit" data-idx="${idx}">编辑</button>
<button class="dy-action-btn dy-btn-delete" data-idx="${idx}">删除</button>
</div>
</div>
`;
});
douyinListBox.innerHTML = html;
// 绑定编辑 and 删除按钮
douyinListBox.querySelectorAll('.dy-btn-edit').forEach(btn => {
btn.addEventListener('click', (e) => {
const idx = parseInt(e.target.getAttribute('data-idx'));
enterEditMode(idx);
});
});
douyinListBox.querySelectorAll('.dy-btn-delete').forEach(btn => {
btn.addEventListener('click', (e) => {
const idx = parseInt(e.target.getAttribute('data-idx'));
deleteDouyinAccount(idx);
});
});
updateActiveDouyinDropdown();
}
// 同步 Tab 1 下拉选择框
function updateActiveDouyinDropdown() {
if (!activeDouyinSelect) return;
const currentActiveId = GM_getValue(ACTIVE_DOUYIN_KEY, '');
let html = '<option value="">-- 请选择要投放的抖音号 --</option>';
if (douyinAccounts && douyinAccounts.length > 0) {
douyinAccounts.forEach(acc => {
const isSelected = acc.id === currentActiveId ? 'selected' : '';
html += `<option value="${acc.id}" ${isSelected}>${acc.name} (ID: ${acc.id})</option>`;
});
} else {
html = '<option value="">-- 请先在“抖音号管理”中录入账号 --</option>';
}
activeDouyinSelect.innerHTML = html;
}
// 进入编辑模式
function enterEditMode(index) {
editIndex = index;
const acc = douyinAccounts[index];
dyInputId.value = acc.id;
dyInputName.value = acc.name;
dyInputRemark.value = acc.remark || '';
dyFormTitle.innerText = "修改抖音号配置";
btnSaveDouyin.innerText = "保存修改";
btnCancelDouyin.style.display = "block";
}
// 退出编辑模式
function exitEditMode() {
editIndex = -1;
dyInputId.value = '';
dyInputName.value = '';
dyInputRemark.value = '';
dyFormTitle.innerText = "添加抖音号";
btnSaveDouyin.innerText = "保存抖音号";
btnCancelDouyin.style.display = "none";
}
// 删除抖音号
function deleteDouyinAccount(index) {
const deleted = douyinAccounts[index];
douyinAccounts.splice(index, 1);
GM_setValue(DOUYIN_STORAGE_KEY, JSON.stringify(douyinAccounts));
const currentActiveId = GM_getValue(ACTIVE_DOUYIN_KEY, '');
if (currentActiveId === deleted.id) {
GM_deleteValue(ACTIVE_DOUYIN_KEY);
}
if (editIndex === index) {
exitEditMode();
} else if (editIndex > index) {
editIndex--;
}
renderDouyinList();
}
// ==========================================
// 🌟 补全抖音号管理页的核心交互事件监听 🌟
// ==========================================
if (btnSaveDouyin) {
btnSaveDouyin.addEventListener('click', () => {
const id = dyInputId.value.trim();
const name = dyInputName.value.trim();
const remark = dyInputRemark.value.trim();
if (!id || !name) {
log("❌ 保存失败:抖音ID和抖音名称不能为空!", "error");
return;
}
if (editIndex === -1) {
// 新增状态下,校验 ID 的唯一性
const exists = douyinAccounts.some(acc => acc.id === id);
if (exists) {
log(`❌ 保存失败:已存在 ID 为 [${id}] 的抖音号!`, "error");
return;
}
douyinAccounts.push({ id, name, remark });
log(`✨ 成功保存并添加新抖音号:【${name}】(ID: ${id})`, "info");
} else {
// 修改编辑状态
const originalId = douyinAccounts[editIndex].id;
if (originalId !== id) {
const exists = douyinAccounts.some((acc, idx) => idx !== editIndex && acc.id === id);
if (exists) {
log(`❌ 修改失败:已存在 ID 为 [${id}] 的其他抖音号!`, "error");
return;
}
}
douyinAccounts[editIndex] = { id, name, remark };
log(`✨ 成功修改抖音号:【${name}】(ID: ${id})`, "info");
exitEditMode();
}
// 安全将修改或新增写入油猴持久化沙盒
GM_setValue(DOUYIN_STORAGE_KEY, JSON.stringify(douyinAccounts));
renderDouyinList();
// 成功后自动清空表单
dyInputId.value = '';
dyInputName.value = '';
dyInputRemark.value = '';
});
}
if (btnCancelDouyin) {
btnCancelDouyin.addEventListener('click', () => {
exitEditMode();
log("📝 已取消当前修改并复位表单。", "system");
});
}
// 🌟 第一页:当前选择投放的抖音号下拉菜单切换时,联动沙盒更新并自适应刷新 previewTable 🌟
if (activeDouyinSelect) {
activeDouyinSelect.addEventListener('change', (e) => {
const selectedId = e.target.value;
GM_setValue(ACTIVE_DOUYIN_KEY, selectedId);
log(`🎯 已成功切换当前投放抖音号:【${selectedId || '未选择'}】`, 'info');
// 联动重绘:抖音号切换后,其对应的“已搭”、“未搭”映射列发生改变,必须重新计算当前未搭建的第一行起点进度!
const nextIdx = getNextUncompletedIndex();
GM_setValue(PROGRESS_INDEX_KEY, nextIdx);
const rows = excelData ? excelData.slice(1) : [];
if (rows[nextIdx]) {
updateCurrentBuildingDramaName(rows[nextIdx][0]);
} else {
updateCurrentBuildingDramaName('--');
}
// 触发局部重绘,自动重新刷新 Excel 预览视图的高亮行
renderPreviewTable();
});
}
// 辅助工具:模糊对应列位置
function getDouyinColumnIndex(excelData, activeId, activeName) {
if (!excelData || excelData.length === 0) return -1;
const headers = excelData[0];
const cleanId = String(activeId).trim().toLowerCase();
const cleanName = String(activeName).trim().toLowerCase();
for (let j = 0; j < headers.length; j++) {
if (!headers[j]) continue;
const headerVal = String(headers[j]).trim().toLowerCase();
if (headerVal === cleanId || headerVal === cleanName || headerVal.includes(cleanId) || headerVal.includes(cleanName)) {
return j;
}
}
return -1;
}
// 智能获取下一行尚未搭建的剧目行索引
function getNextUncompletedIndex() {
if (!excelData || excelData.length <= 1) return 0;
const rows = excelData.slice(1);
const activeId = GM_getValue(ACTIVE_DOUYIN_KEY, '');
if (!activeId) return 0;
const activeAccount = douyinAccounts.find(acc => acc.id === activeId);
let colIdx = -1;
if (activeAccount) {
colIdx = getDouyinColumnIndex(excelData, activeAccount.id, activeAccount.name);
}
let yidaRecords = {};
try {
const savedYida = GM_getValue(YIDA_RECORDS_KEY, '{}');
yidaRecords = JSON.parse(savedYida);
} catch (e) {}
for (let r = 0; r < rows.length; r++) {
const drama = String(rows[r][0]).trim().replace(/\s+/g, '');
const cleanActiveId = String(activeId).trim();
const key = `${drama}_${cleanActiveId}`;
const hasYida = yidaRecords[key] === true || (colIdx !== -1 && rows[r][colIdx] && String(rows[r][colIdx]).trim() === '已搭');
if (!hasYida) {
return r; // 返回首个未搭行
}
}
return rows.length; // 全部已完成
}
// 将特定剧目在安全库与 Excel 中标记为已搭
function markDramaAsCompleted(dramaName) {
if (!dramaName || dramaName === '--') return;
const activeId = GM_getValue(ACTIVE_DOUYIN_KEY, '');
if (!activeId) return;
// 1. 持久化至沙盒已搭库
let yidaRecords = {};
try {
const savedYida = GM_getValue(YIDA_RECORDS_KEY, '{}');
yidaRecords = JSON.parse(savedYida);
} catch (e) {}
const cleanDramaName = String(dramaName).trim().replace(/\s+/g, '');
const cleanActiveId = String(activeId).trim();
yidaRecords[`${cleanDramaName}_${cleanActiveId}`] = true;
GM_setValue(YIDA_RECORDS_KEY, JSON.stringify(yidaRecords));
// 2. 标记本地内存中的 Excel 数据
if (excelData && excelData.length > 1) {
const rows = excelData.slice(1);
const activeAccount = douyinAccounts.find(acc => acc.id === activeId);
let colIdx = -1;
if (activeAccount) {
colIdx = getDouyinColumnIndex(excelData, activeAccount.id, activeAccount.name);
}
if (colIdx !== -1) {
const rIdx = rows.findIndex(r => String(r[0]).trim().replace(/\s+/g, '') === cleanDramaName);
if (rIdx !== -1) {
if (!excelData[rIdx + 1]) excelData[rIdx + 1] = [];
while (excelData[rIdx + 1].length <= colIdx) {
excelData[rIdx + 1].push("");
}
excelData[rIdx + 1][colIdx] = "已搭";
GM_setValue(STORAGE_KEY, JSON.stringify(excelData));
}
}
}
log(`📝 剧目 【${dramaName}】 已成功标记为 “已搭”状态!`, 'info');
renderPreviewTable();
}
// 全局提交/发布监控哨兵
document.addEventListener('click', (e) => {
const btn = e.target.closest('button, .oc-button, .ovui-button, [role="button"]');
if (btn) {
const txt = btn.textContent.trim();
const isInsidePopup = btn.closest('.ovui-drawer, .ovui-dialog, .product-drawer-wrap, .ovui-modal, [class*="drawer"], [class*="dialog"], [class*="modal"]');
if (!isInsidePopup) {
if (txt.includes('发布') || txt.includes('保存') || txt.includes('提交') || txt.includes('确定创建') || txt.includes('新建项目')) {
const currentDrama = document.getElementById('current-building-drama-name')?.innerText;
if (currentDrama && currentDrama !== '--') {
log(`[物理提交拦截] 侦测到官方项目保存动作【${txt}】,自动同步剧目 “${currentDrama}” 为已搭状态...`, 'system');
markDramaAsCompleted(currentDrama);
const nextIdx = getNextUncompletedIndex();
GM_setValue(PROGRESS_INDEX_KEY, nextIdx);
renderPreviewTable(); // 🌟 同步渲染
}
}
}
}
}, true);
// 自动扫描与获取可见 IAP 链接输入框
function findIapInputElements() {
return Array.from(document.querySelectorAll('input.ovui-input[placeholder*="IAP"], input[placeholder*="IAP"]'))
.filter(isVisible);
}
// 🌟 全局高精度自动定位项目管理页面“总计 X 项” 🌟
function getManageTotalProjects() {
// 方法 A: 高精度 summary 特征单元格检索
const summaryCells = document.querySelectorAll('.ovui-t-summary-cell, .ovui-table-cell, .ovui-tr-summary, [class*="summary"]');
for (const cell of summaryCells) {
const text = cell.textContent.trim();
const match = text.match(/总计\s*(\d+)\s*项/);
if (match) {
return parseInt(match[1], 10);
}
}
// 方法 B: 降级兜底文字穿透扫描
const spans = document.querySelectorAll('span, th, td, div');
for (const el of spans) {
if (el.children.length === 0 || (el.children.length === 1 && el.firstElementChild?.tagName === 'SPAN')) {
const text = el.textContent.trim();
const match = text.match(/总计\s*(\d+)\s*项/);
if (match) {
return parseInt(match[1], 10);
}
}
}
return null;
}
// 🌟 全局高鲁棒性精确定位 IAP 链接 “点击添加” 按钮 🌟
function findIapAddButton() {
const firstBtnWrap = document.querySelector('div[data-e2e*="roi/ad/create__creativeTitleGroup"].oc-add-button, div[data-e2e*="creativeTitleGroup].oc-add-button');
if (firstBtnWrap && isVisible(firstBtnWrap)) {
const btnEl = firstBtnWrap.querySelector('button') || firstBtnWrap;
if (btnEl && btnEl.textContent.trim().includes('点击添加')) {
return btnEl;
}
}
const basicModule = document.querySelector('#BasicMaterialModule');
if (basicModule) {
const addButtons = Array.from(basicModule.querySelectorAll('.oc-add-button, .oc-add-button button, div[class*="oc-add-button"]'))
.filter(isVisible);
const matchedBtn = addButtons.find(el => {
const txt = el.textContent.trim();
return txt.includes('点击添加') && !txt.includes('标签') && !txt.includes('商品');
});
if (matchedBtn) return matchedBtn;
}
const iapInputs = findIapInputElements();
if (iapInputs.length > 0) {
const lastInput = iapInputs[iapInputs.length - 1];
let curr = lastInput;
for (let d = 0; d < 6; d++) {
if (!curr.parentElement) break;
curr = curr.parentElement;
const innerBtn = curr.querySelector('.oc-add-button, button.ovui-button');
if (innerBtn && isVisible(innerBtn) && innerBtn.textContent.trim().includes('点击添加')) {
return innerBtn;
}
let sibling = curr.nextElementSibling;
while (sibling) {
const sibAddBtn = sibling.matches('.oc-add-button') ? sibling : sibling.querySelector('.oc-add-button');
if (sibAddBtn && isVisible(sibAddBtn) && sibAddBtn.textContent.trim().includes('点击添加')) {
const target = sibAddBtn.querySelector('button') || sibAddBtn;
return target;
}
sibling = sibling.nextElementSibling;
}
}
}
const candidates = Array.from(document.querySelectorAll('.oc-add-button, .oc-button-wrap-block button, button'));
return candidates.find(el => {
if (!isVisible(el)) return false;
const txt = el.textContent.trim();
return txt.includes('点击添加') && !txt.includes('商品') && !txt.includes('标签');
});
}
// 链式自增 IAP 链接框生成器
async function waitAndAddIapInput(targetIndex) {
let addBtn = findIapAddButton();
if (addBtn) {
log(`- 🎯 成功捕获第 ${targetIndex + 1} 个 IAP 链接的 “点击添加” 按钮,正在模拟物理点击...`, 'system');
triggerClick(addBtn);
for (let j = 0; j < 25; j++) {
await sleep(150);
const currentInputs = findIapInputElements();
if (currentInputs.length > targetIndex) {
log(`- ✨ 第 ${targetIndex + 1} 个 IAP 输入框已动态创建成功!`, 'system');
return currentInputs[targetIndex];
}
}
} else {
log(`❌ 定位 IAP 点击添加按钮失败,可能已达到该模块的最大录入限制。`, 'error');
}
return null;
}
// 辅助工具:向输入框光标处插入内容 (项目名配置专属)
function insertAtCursor(myField, myValue) {
if (!myField) return;
if (myField.selectionStart || myField.selectionStart === 0) {
const startPos = myField.selectionStart;
const endPos = myField.selectionEnd;
myField.value = myField.value.substring(0, startPos)
+ myValue
+ myField.value.substring(endPos, myField.value.length);
myField.selectionStart = startPos + myValue.length;
myField.selectionEnd = startPos + myValue.length;
} else {
myField.value += myValue;
}
myField.dispatchEvent(new Event('input', { bubbles: true }));
myField.dispatchEvent(new Event('change', { bubbles: true }));
}
// 辅助工具:提取和计算当前日期为 %m%d 形式
function getFormattedDate() {
const now = new Date();
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
const date = String(now.getUTCDate()).padStart(2, '0');
return month + date; // e.g. "0610"
}
// 辅助工具:解析项目名模板中的占位符标签
function processProjectNameTemplate(template, dramaName) {
if (!template) return '';
let output = template;
output = output.replace(/<日期>/g, getFormattedDate());
output = output.replace(/<剧目>/g, dramaName || '');
const roiVal = GM_getValue(ROI_COEFFICIENT_KEY, '');
output = output.replace(/<ROI>/g, roiVal || '');
return output;
}
// 🌟 v5.17 深度重构表格预览渲染机制 🌟
function renderPreviewTable() {
if (!excelData || excelData.length === 0) {
dataStatusTitle.innerText = '数据载入状态';
dataPreviewBox.innerHTML = `
<div style="font-size: 13px; color: var(--ios-text-sec); text-align: center; padding: 20px 0;">
暂无导入数据,请先导入Excel
</div>
`;
btnStartBuild.classList.add('ios-btn-disabled');
return;
}
btnStartBuild.classList.remove('ios-btn-disabled');
const rowCount = excelData.length;
const colCount = excelData[0] ? excelData[0].length : 0;
// 读取当前正处于搭建进程中的行索引 (0-based)
let currentIndex = parseInt(GM_getValue(PROGRESS_INDEX_KEY, '0'));
if (isNaN(currentIndex)) currentIndex = 0;
dataStatusTitle.innerText = `数据已载入 (共 ${rowCount} 行, ${colCount} 列 | 当前第 ${currentIndex + 1} 行)`;
let tableHTML = `
<div class="excel-preview-container">
<table class="excel-table">
<thead>
<tr>
`;
const headers = excelData[0] || [];
headers.forEach(header => {
tableHTML += `<th>${header !== undefined ? header : ''}</th>`;
});
tableHTML += `
</tr>
</thead>
<tbody>
`;
let yidaRecords = {};
try {
const savedYida = GM_getValue(YIDA_RECORDS_KEY, '{}');
yidaRecords = JSON.parse(savedYida);
} catch (e) {
console.error(e);
}
// 计算当前搭建行所处于的绝对 Excel 数据行索引 (考虑 header 占用 index 0,所以 targetCenter 需为 currentIndex + 1)
const targetCenter = currentIndex + 1;
let startRow = Math.max(1, targetCenter - 5);
let endRow = Math.min(rowCount - 1, targetCenter + 5);
// 如果上下行边界补齐不够 11 行,则自动朝另一侧拉伸,确保最大限度提供 11 行视图空间
if (endRow - startRow < 10) {
if (startRow === 1) {
endRow = Math.min(rowCount - 1, startRow + 10);
} else if (endRow === rowCount - 1) {
startRow = Math.max(1, endRow - 10);
}
}
// 如果上方仍然有被折叠折蔽的数据行,绘制折叠标识
if (startRow > 1) {
tableHTML += `
<tr>
<td colspan="${headers.length}" style="text-align: center; color: var(--ios-text-sec); background: #F8F8FA; font-size: 10px; padding: 4px 0; font-style: italic;">
▲ 其余上方 ${startRow - 1} 行数据已折叠...
</td>
</tr>
`;
}
for (let i = startRow; i <= endRow; i++) {
const isCurrentRow = (i === targetCenter);
// 经典高亮正在搭建的黄金骨干数据行
const trStyle = isCurrentRow ? 'style="background: rgba(255, 149, 0, 0.08); border-left: 3px solid var(--ios-orange);"' : '';
tableHTML += `<tr ${trStyle}>`;
const row = excelData[i] || [];
const dramaName = row[0];
for (let j = 0; j < headers.length; j++) {
const val = row[j];
let displayVal = val !== undefined ? val : '';
let matchedId = '';
const headerVal = String(headers[j]).trim().toLowerCase();
const matchedAcc = douyinAccounts.find(acc => {
const cleanId = String(acc.id).trim().toLowerCase();
const cleanName = String(acc.name).trim().toLowerCase();
return headerVal === cleanId || headerVal === cleanName || headerVal.includes(cleanId) || headerVal.includes(cleanName);
});
if (matchedAcc) {
matchedId = matchedAcc.id;
}
const recordKey = `${dramaName}_${matchedId}`;
const isYida = (matchedId && yidaRecords[recordKey]) || String(displayVal).trim() === '已搭';
if (isYida) {
displayVal = `<span class="yida-badge-btn" data-drama="${dramaName}" data-dyid="${matchedId}" title="点击清除此条已搭标记并准备重搭">已搭 ✕</span>`;
}
tableHTML += `<td title="${val !== undefined ? val : ''}">${displayVal}</td>`;
}
tableHTML += `</tr>`;
}
// 如果下方仍然有折叠折蔽数据,绘制尾部标识
if (endRow < rowCount - 1) {
tableHTML += `
<tr>
<td colspan="${headers.length}" style="text-align: center; color: var(--ios-text-sec); background: #F8F8FA; font-size: 10px; padding: 4px 0; font-style: italic;">
▼ 其余下方 ${rowCount - 1 - endRow} 行数据已折叠...
</td>
</tr>
`;
}
tableHTML += `
</tbody>
</table>
</div>
`;
dataPreviewBox.innerHTML = tableHTML;
}
// 导入触发
btnImportExcel.addEventListener('click', () => {
filePicker.click();
});
// 导入处理 (🌟 加入国内多线路动态修补与深度容错)
filePicker.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
log(`正在解析文件: ${file.name}...`, 'system');
const reader = new FileReader();
reader.onload = async function(evt) {
try {
// 1. 在导入的第一关展开动态 XLSX 安全自检
const isLoaded = await ensureXLSXLoaded();
if (!isLoaded) {
log('❌ 导入失败:Excel 解析库 (XLSX) 未能正确加载,可能是因为您的网络已被完全切断或遭受防火墙彻底拦截。', 'error');
return;
}
// 2. 多重全局检索,杜绝 React 页面变量覆盖冲突
const activeXLSX = typeof XLSX !== 'undefined' ? XLSX : (typeof window.XLSX !== 'undefined' ? window.XLSX : null);
if (!activeXLSX || typeof activeXLSX.read !== 'function') {
log('❌ 导入失败:XLSX 变量未能获得有效的读取函数,请尝试刷新重试。', 'error');
return;
}
const data = new Uint8Array(evt.target.result);
const workbook = activeXLSX.read(data, { type: 'array' });
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
const parsedRows = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
if (parsedRows && parsedRows.length > 0) {
excelData = parsedRows;
GM_setValue(STORAGE_KEY, JSON.stringify(excelData));
log(`成功解析并存储 Excel,读取到 ${excelData.length} 行数据!已在沙盒安全固化。`, 'info');
renderPreviewTable();
checkAutoResumeState();
} else {
log('Excel表格中未发现有效行数据', 'warning');
}
} catch (err) {
log(`Excel解析失败: ${err.message}`, 'error');
console.error(err);
}
filePicker.value = '';
};
reader.readAsArrayBuffer(file);
});
// 清空数据
btnClearData.addEventListener('click', () => {
if (!excelData) {
log('当前没有可供清除的数据', 'warning');
return;
}
excelData = null;
GM_deleteValue(STORAGE_KEY);
GM_deleteValue(YIDA_RECORDS_KEY);
GM_deleteValue(AUTO_ACTIVE_KEY);
GM_deleteValue(PROGRESS_INDEX_KEY);
updateCurrentBuildingDramaName('--');
log('沙盒中缓存 of Excel 数据及执行进度已彻底清空', 'system');
renderPreviewTable();
});
// 手动已搭
btnManualYida.addEventListener('click', () => {
const currentDrama = document.getElementById('current-building-drama-name')?.innerText;
if (!currentDrama || currentDrama === '--') {
log('❌ 手动标记失败:当前没有正在搭建的剧目名称!', 'error');
return;
}
markDramaAsCompleted(currentDrama);
const nextIdx = getNextUncompletedIndex();
GM_setValue(PROGRESS_INDEX_KEY, nextIdx);
const rows = excelData.slice(1);
if (rows[nextIdx]) {
updateCurrentBuildingDramaName(rows[nextIdx][0]);
} else {
updateCurrentBuildingDramaName('--');
}
renderPreviewTable(); // 🌟 同步滚动视角对焦
});
// 重新运行
btnRerunBuild.addEventListener('click', async () => {
log("🔄 [一键重载] 正在强制重置运行状态并安全复位流程指针...", "system");
isRunning = false;
isPaused = false;
updateBallStyle();
GM_deleteValue(PROGRESS_INDEX_KEY);
const correctedIndex = getNextUncompletedIndex();
GM_setValue(PROGRESS_INDEX_KEY, correctedIndex);
log(`- 🎯 起点校准完毕:首个未搭建的目标确定为:【第 ${correctedIndex + 1} 行】剧目。`, 'info');
renderPreviewTable(); // 🌟 同步滚动视角对焦
if (isCreatePage) {
log("🔄 正在执行页面物理刷新以清空表单并准备接力...", "warning");
await sleep(1000);
location.reload();
} else {
log("▶ 正在跳转至“新建项目”...", "info");
clickCreateProjectButton();
}
});
// 开始构建按钮事件
btnStartBuild.addEventListener('click', () => {
if (btnStartBuild.classList.contains('ios-btn-disabled') || !excelData) {
log('请先导入Excel数据再进行操作', 'warning');
return;
}
const activeId = GM_getValue(ACTIVE_DOUYIN_KEY, '');
if (!activeId && isCreatePage) {
log("❌ 启动失败:请先在“当前投放抖音号”下拉框中选择要投放的抖音号!", "error");
return;
}
updatePageStatus();
if (isManagePage) {
log('▶ 启动自动跳转至“新建项目”流程...', 'info');
clickCreateProjectButton();
} else if (isCreatePage) {
if (isRunning) {
log('⏱ 自动化已经在运行中,无需重复点击。', 'warning');
return;
}
log('▶ 开始自动化表单填充流程...', 'info');
executeAutomationWorkflow();
} else {
log('当前页面非巨量投放列表或新建项目页,无法执行此操作。', 'warning');
}
});
// 暂停/继续
btnPauseBuild.addEventListener('click', () => {
if (btnPauseBuild.classList.contains('ios-btn-disabled') || !isRunning) {
return;
}
if (!isPaused) {
isPaused = true;
btnPauseLabel.innerText = '继续';
btnPauseBuild.classList.remove('ios-btn-orange');
btnPauseBuild.classList.add('ios-btn-primary');
log('⏸ 自动化流程已被用户手动[暂停]。', 'warning');
} else {
isPaused = false;
btnPauseLabel.innerText = '暂停';
btnPauseBuild.classList.remove('ios-btn-primary');
btnPauseBuild.classList.add('ios-btn-orange');
log('▶ 自动化流程已[继续]恢复运行。', 'info');
}
updateBallStyle();
});
// 定位并跳转到“新建项目”按钮
function clickCreateProjectButton() {
const jsSelector = "#app > div > div.oc-card.oc-card--scheme-primary > div > div > div.manage-filter > div:nth-child(1) > div > div.right-content > div:nth-child(1) > div.oc-button-wrap.oc-button-scene-default > button > span";
let retryCount = 0;
const maxRetries = 15;
const interval = 600;
log('正在查找“新建项目”按钮...', 'system');
const findAndClickTimer = setInterval(() => {
let target = null;
const candidates = Array.from(document.querySelectorAll(jsSelector));
for (const cand of candidates) {
if (isVisible(cand)) {
target = cand;
break;
}
}
if (!target) {
const buttons = document.querySelectorAll('button');
for (const b of buttons) {
if (b.textContent.trim().includes('新建项目') && isVisible(b)) {
target = b;
break;
}
}
}
if (target) {
clearInterval(findAndClickTimer);
log('🎯 成功定位到“新建项目”按钮,正在触发点击并准备跨页面自启动...', 'info');
GM_setValue(AUTO_ACTIVE_KEY, 'true');
let nextIdx = GM_getValue(PROGRESS_INDEX_KEY, null);
if (nextIdx === null) {
nextIdx = getNextUncompletedIndex();
GM_setValue(PROGRESS_INDEX_KEY, nextIdx);
}
log(`- 🎯 起点确认:将从 Excel 表第 ${parseInt(nextIdx) + 1} 行剧目开始跳转配置。`, 'info');
renderPreviewTable(); // 🌟 同步对焦
triggerClick(target);
} else {
retryCount++;
log(`等待“新建项目”元素渲染中 (${retryCount}/${maxRetries})...`, 'warning');
if (retryCount >= maxRetries) {
clearInterval(findAndClickTimer);
log('无法定位“新建项目”按钮,可能页面未完全加载,请手动点击。', 'error');
}
}
}, interval);
}
// 仿真物理点击 (v5.22 高精重构:完美兼容 React/Vue 受控状态与合成事件)
function triggerClick(element) {
if (!element) return;
try {
// 如果 is 下拉选择器,展开内部的 wrapper 容器进行触发
if (element.classList.contains('ovui-select') || element.closest('.ovui-select')) {
const selectEl = element.classList.contains('ovui-select') ? element : element.closest('.ovui-select');
const wrapper = selectEl.querySelector('.ovui-input__wrapper, .ovui-select__input');
if (wrapper) {
element = wrapper;
}
} else if (element.tagName !== 'BUTTON' && element.querySelector('button')) {
element = element.querySelector('button');
}
try {
element.focus();
} catch(e){}
// 1. 优先调用原生原生 .click(),这是触发 React onChange 和事件合成的最佳且最稳定方式
element.click();
} catch (e) {
console.error('原生 click 失败,尝试执行事件派发:', e);
}
// 2. 派发辅助鼠标/指针事件作为双重保险,确保物理触发完整
try {
const rect = element.getBoundingClientRect();
const clientX = rect.left + rect.width / 2;
const clientY = rect.top + rect.height / 2;
const eventOpts = {
bubbles: true,
cancelable: true,
view: window,
clientX: clientX,
clientY: clientY
};
element.dispatchEvent(new MouseEvent('mousedown', eventOpts));
element.dispatchEvent(new MouseEvent('mouseup', eventOpts));
element.dispatchEvent(new Event('change', { bubbles: true }));
} catch(e){}
}
// 辅助工具:发送回车指令进行搜索 (支持 keydown, keypress, keyup 复合触发)
function pressEnter(element) {
if (!element) return;
const events = ['keydown', 'keypress', 'keyup'];
events.forEach(name => {
try {
element.dispatchEvent(new KeyboardEvent(name, {
bubbles: true,
cancelable: true,
key: 'Enter',
keyCode: 13,
code: 'Enter',
which: 13
}));
} catch (e) {}
});
}
// 更新页面状态
function updatePageStatus() {
isManagePage = location.href.includes('/ad/web/manage');
isCreatePage = location.href.includes('/ad/web/roi/ad/create');
const btnTextLabel = document.getElementById('btn-text-label');
if (btnTextLabel) {
btnTextLabel.innerText = isManagePage ? '开始搭建 (跳转)' : '开始自动运行';
}
}
// 异步抓取巨量后台项目总数,计算得出可搭建额度(500 阈值拦截控制)
async function scrapeManageTotalAndCalc() {
if (!isManagePage) return;
log('🔍 正在检测巨量后台已有的总项目数...', 'system');
let found = false;
// 强轮询 40 次 (共约 20 秒) 适配网络延迟或 React 数据延迟加载
for (let attempt = 0; attempt < 40; attempt++) {
if (!isManagePage) return;
const total = getManageTotalProjects();
if (total !== null) {
const remaining = Math.max(0, 500 - total);
GM_setValue(TOTAL_CREATED_PROJECTS_KEY, total);
GM_setValue(REMAINING_PROJECTS_KEY, remaining);
updateRemainingUI();
log(`📊 [数据同步成功] 巨量后台已有项目: ${total} 个 | 剩下可搭项目数: ${remaining} 个`, 'info');
found = true;
break;
}
await new Promise(resolve => setTimeout(resolve, 500));
}
if (!found) {
const textContent = document.body.textContent;
if (textContent.includes('暂无项目') || textContent.includes('点击上方按钮') || textContent.includes('暂无数据')) {
GM_setValue(TOTAL_CREATED_PROJECTS_KEY, 0);
GM_setValue(REMAINING_PROJECTS_KEY, 500);
updateRemainingUI();
log(`📊 [数据同步成功] 页面提示暂无项目,剩下可搭项目数: 500 个`, 'info');
} else {
log('⚠️ 未能捕获到“总计 X 项”或暂无项目提示,请在页面完全加载后重试。', 'warning');
}
}
}
// 新建项目自启动及重载接力
async function checkAutoResumeState() {
updatePageStatus();
// 自动处理上一个搭建成功剧目的“已搭”落盘标记 (解决SPA页面销毁时数据无法保存的竞态瓶颈)
const lastSubmitted = GM_getValue('oceanengine_last_submitted_drama', '');
if (lastSubmitted) {
log(`🎉 [自动接力哨兵] 侦测到项目 “${lastSubmitted}” 搭建成功并已成功跳转!自动执行沙盒“已搭”持久化更新...`, 'info');
markDramaAsCompleted(lastSubmitted);
GM_deleteValue('oceanengine_last_submitted_drama'); // 消费后立刻擦除,防止重复写盘
}
if (isCreatePage) {
// 核心拦截:检测剩下可搭项目额度
const remaining = GM_getValue(REMAINING_PROJECTS_KEY, 500);
if (remaining <= 0) {
log('🛑 [安全管控] 剩下可搭项目数为 0!已达到 500 个上限,自动搭建流程已安全终止!', 'error');
isRunning = false;
updateBallStyle();
return;
}
if (excelData && excelData.length > 1) {
const isMinimized = GM_getValue('ios_helper_minimized', 'false');
if (isMinimized === 'true') {
togglePanelMinimizeState(true);
}
if (!isRunning && !isPaused) {
let nextIndex = GM_getValue(PROGRESS_INDEX_KEY, null);
if (nextIndex === null) {
nextIndex = getNextUncompletedIndex();
GM_setValue(PROGRESS_INDEX_KEY, nextIndex);
} else {
nextIndex = parseInt(nextIndex);
}
renderPreviewTable(); // 🌟 重绘对焦
const dramaList = excelData.slice(1);
if (dramaList[nextIndex]) {
updateCurrentBuildingDramaName(dramaList[nextIndex][0]);
}
log(`🔄 [自启动接力] 侦测到进入新建页面,系统将在 2 秒后接力第 ${nextIndex + 1} 行剧目配置...`, 'info');
setTimeout(() => {
if (!isRunning && !isPaused) {
executeAutomationWorkflow();
}
}, 2000);
}
} else {
log('⚠️ 检测到进入新建页面,但尚未导入Excel数据。请在悬浮窗中先导入数据。', 'warning');
}
} else if (isManagePage) {
// 🌟 1. 先抓取管理页最新的项目数并计算剩余搭建次数 (增加防挂防御沙盒)
try {
await scrapeManageTotalAndCalc();
} catch (e) {
console.error("安全采集已有项目总数发生崩溃:", e);
}
// 🌟 2. 检查抓取计算后的项目安全额度
const remaining = GM_getValue(REMAINING_PROJECTS_KEY, 500);
if (remaining <= 0) {
log('🛑 [安全管控] 剩下可搭项目数为 0!已达到 500 个上限,拒绝执行新建,流程安全挂起。', 'error');
GM_setValue(AUTO_ACTIVE_KEY, 'false');
isRunning = false;
updateBallStyle();
return;
}
const autoActive = GM_getValue(AUTO_ACTIVE_KEY, 'false');
if (autoActive === 'true') {
const nextIdx = getNextUncompletedIndex();
GM_setValue(PROGRESS_INDEX_KEY, nextIdx);
renderPreviewTable(); // 🌟 同步滚动视角对焦
// 如果后续已经没有未搭的数据,则彻底宣告大循环终止,重置引擎
if (excelData && nextIdx >= excelData.length - 1) {
log('🎉 所有Excel剧目全量自动搭建投放完毕!自动化投放助手宣告功成身退。', 'info');
GM_setValue(AUTO_ACTIVE_KEY, 'false');
GM_deleteValue(PROGRESS_INDEX_KEY);
isRunning = false;
updateBallStyle();
return;
}
log('[自动流接力] 侦测到已成功退回到管理主页,正在自动为您点击“新建项目”以开始接力下一条剧目...', 'info');
setTimeout(() => {
clickCreateProjectButton();
}, 1500); // 预留1.5秒安全延迟等待官方React渲染
}
}
}
// 单页面 SPA 路由哨兵
let lastUrl = location.href;
setInterval(async () => {
if (location.href !== lastUrl) {
lastUrl = location.href;
log('检测到网页单页面路由发生变更,正在重校助手状态...', 'system');
await checkAutoResumeState();
}
}, 1200);
// 🌟 waitAndFindElement 核心机制 🌟
function waitAndFindElement(selectors, searchTexts = [], timeoutMs = 15000) {
return new Promise((resolve, reject) => {
let accumulatedTime = 0;
const interval = 150; // 极速心跳轮询
const timer = setInterval(() => {
if (isPaused) return;
if (!isRunning) {
clearInterval(timer);
reject(new Error("用户终止运行"));
return;
}
let el = null;
for (const selector of selectors) {
if (typeof selector === 'function') {
try {
const cand = selector();
if (isVisible(cand)) {
el = cand;
break;
}
} catch(e) {}
} else {
const candidates = Array.from(document.querySelectorAll(selector));
for (const cand of candidates) {
if (isVisible(cand)) {
el = cand;
break;
}
}
}
if (el) break;
}
// 文本深度穿透扫描与可见性校对过滤
if (!el && searchTexts.length > 0) {
const allElements = Array.from(document.querySelectorAll('div, span, button, input, p, label, a, strong'));
for (const candidate of allElements) {
if (!isVisible(candidate)) continue;
const txt = candidate.textContent.trim();
if (searchTexts.some(st => txt === st || txt.includes(st))) {
el = candidate;
break;
}
}
}
if (el) {
clearInterval(timer);
resolve(el);
} else {
accumulatedTime += interval;
if (accumulatedTime > timeoutMs) {
clearInterval(timer);
reject(new Error(`等待目标元素超时`));
}
}
}, interval);
});
}
// 仿真微等待
async function sleep(ms) {
let slept = 0;
const interval = 100;
while (slept < ms) {
await checkPauseState();
if (!isRunning) return;
await new Promise(resolve => setTimeout(resolve, interval));
if (!isPaused) {
slept += interval;
}
}
}
// 全域 ROI 检索
function findRoiInputElement() {
const allLabels = Array.from(document.querySelectorAll('div, span, label, p'));
for (const el of allLabels) {
const txt = el.textContent.trim().replace(/\s+/g, '');
if (txt === '全域ROI系数' || txt === '全域ROI') {
let parent = el;
for (let d = 0; d < 4; d++) {
if (!parent.parentElement) break;
parent = parent.parentElement;
const input = parent.querySelector("input[type='number'], input.ovui-input");
if (input && isVisible(input)) return input;
}
}
}
const selectors = [
"div.oc-row-input input[type='number']",
"input.ovui-input[type='number'][placeholder='请输入']",
"div[class*='roi'] input.ovui-input[placeholder*='输入']",
"div[class*='ROI'] input.ovui-input"
];
for (const sel of selectors) {
const cand = document.querySelector(sel);
if (isVisible(cand)) return cand;
}
return null;
}
// ROI 扫描
function waitAndFindRoiInput(timeoutMs = 8000) {
return new Promise((resolve) => {
const startTime = Date.now();
const timer = setInterval(() => {
if (isPaused) return;
const el = findRoiInputElement();
if (el) {
clearInterval(timer);
resolve(el);
} else if (Date.now() - startTime > timeoutMs) {
clearInterval(timer);
resolve(null);
}
}, 400);
});
}
// 🌟 全局高精度自动定位 IAA 链接输入框 🌟
function findIaaInputElement() {
const directPlaceholder = document.querySelector('input.ovui-input[placeholder="请输入IAA链接"], input[placeholder="请输入IAA链接"]');
if (isVisible(directPlaceholder)) return directPlaceholder;
const allLabels = Array.from(document.querySelectorAll('div, span, label, p'));
for (const el of allLabels) {
const txt = el.textContent.trim().replace(/\s+/g, '');
if (txt.includes('IAA链接')) {
let parent = el;
for (let d = 0; d < 4; d++) {
if (!parent.parentElement) break;
parent = parent.parentElement;
const input = parent.querySelector("input.ovui-input, input");
if (isVisible(input)) return input;
}
}
}
const selectors = [
"input.ovui-input[placeholder*='IAA']",
"input[placeholder*='IAA']"
];
for (const sel of selectors) {
const candidates = Array.from(document.querySelectorAll(sel));
for (const cand of candidates) {
if (isVisible(cand)) return cand;
}
}
return null;
}
// 🌟 IAA 链接输入框强力阻塞式快速侦测 🌟
function waitAndFindIaaInput(timeoutMs = 8000) {
return new Promise((resolve) => {
const startTime = Date.now();
const timer = setInterval(() => {
if (isPaused) return;
const el = findIaaInputElement();
if (el) {
clearInterval(timer);
resolve(el);
} else if (Date.now() - startTime > timeoutMs) {
clearInterval(timer);
resolve(null);
}
}, 150); // 极速轮询检测
});
}
// 暂停心跳锁
async function checkPauseState() {
while (isPaused && isRunning) {
await new Promise(resolve => setTimeout(resolve, 200));
}
}
// React 16+ 深度状态穿透赋值
function setReactInputValue(inputElement, value) {
if (!inputElement) return;
try {
inputElement.focus();
let lastValue = inputElement.value;
const prototype = inputElement.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(prototype, 'value').set;
nativeInputValueSetter.call(inputElement, value);
const tracker = inputElement._valueTracker;
if (tracker) {
tracker.setValue(lastValue);
}
// 触发标准 React 值的双端 input 与 change 事件完成底层落盘同步
inputElement.dispatchEvent(new Event('input', { bubbles: true }));
inputElement.dispatchEvent(new Event('change', { bubbles: true }));
} catch (e) {
console.error('React 状态同步注入异常:', e);
inputElement.value = value;
inputElement.dispatchEvent(new Event('input', { bubbles: true }));
}
}
// 提取已选择的数量 (蓝框内容解析)
function getSelectedCount() {
const el = document.querySelector('.oc-create-material-submit-bar-count, [class*="submit-bar-count"], .oc-create-material-submit-bar-left span');
if (!el) return -1;
const text = el.textContent || '';
const match = text.match(/(?:已选择|已选)\s*(\d+)/) || text.match(/(\d+)\s*\/\s*\d+/);
return match ? parseInt(match[1], 10) : -1;
}
// 提取视频总数量 (黄框内容解析)
function getTotalCount() {
const el = document.querySelector('.create-material-list-card-header-count, [class*="card-header-count"], .ad-video-lib-header .create-material-list-card-header-main');
if (!el) return -1;
const text = el.textContent || '';
const match = text.match(/(?:共|包含)\s*(\d+)/) || text.match(/共\s*(\d+)\s*个/) || text.match(/(\d+)/);
return match ? parseInt(match[1], 10) : -1;
}
// 15. 【新建项目】表单填充自动化核心控制区
async function executeAutomationWorkflow() {
const rows = excelData.slice(1);
if (rows.length === 0) {
log('未找到有效的剧名数据,请检查导入的Excel!', 'error');
return;
}
const activeId = GM_getValue(ACTIVE_DOUYIN_KEY, '');
if (!activeId) {
log("❌ 执行被阻断:请先在助手悬浮窗选择【当前投放抖音号】!", "error");
isRunning = false;
updateBallStyle();
return;
}
isRunning = true;
isPaused = false;
btnPauseBuild.className = 'ios-btn ios-btn-orange';
btnPauseLabel.innerText = '暂停';
updateBallStyle();
const activeAccount = douyinAccounts.find(acc => acc.id === activeId);
let colIdx = -1;
if (activeAccount) {
colIdx = getDouyinColumnIndex(excelData, activeAccount.id, activeAccount.name);
}
let startIndex = GM_getValue(PROGRESS_INDEX_KEY, null);
if (startIndex === null) {
startIndex = getNextUncompletedIndex();
GM_setValue(PROGRESS_INDEX_KEY, startIndex);
} else {
startIndex = parseInt(startIndex);
}
log(`[流程初始化] 本次从第 [ ${startIndex + 1} / ${rows.length} ] 组数据开始执行...`, 'info');
const triggerSelectors = [
"div.create-product-add-empty",
"div.create-product-add-single",
"[class*='create-product-add']",
"#ad-main > div:nth-child(2) > div > div.moduler-wrapper.newModuler > div:nth-child(4) > div > div > div > div > div > div > div > div > div > div > div > div > div",
"#ad-main > div:nth-child(2) > div > div.moduler-wrapper.newModuler > div:nth-child(4) > div > div > div > div > div > div > div > div > div"
];
// 🌟 黄金级商品搜索框定位器:通过自定义校验机制精确定位黄色框,绝对排除左侧红框下拉菜单
const inputSelectors = [
() => {
const inputs = Array.from(document.querySelectorAll("input"));
return inputs.find(inp => {
if (!isVisible(inp)) return false;
// 1. 必须匹配搜索框特有的提示词
const placeholder = String(inp.getAttribute('placeholder') || '');
if (!placeholder.includes('名称') && !placeholder.includes('ID')) {
return false;
}
// 2. 强力排除:凡是在任何下拉筛选容器(Select)内部的输入框,一律排除!防止误填左侧红框
if (inp.closest('.ovui-select') || inp.closest('.oc-select') || inp.closest('[class*="select"]') || inp.closest('[data-auto-id*="select"]')) {
return false;
}
return true;
});
},
"div.oc-drawer-body-wrap input[placeholder*='名称或ID']",
"div.oc-drawer-body-wrap input[placeholder*='名称']",
"div.oc-drawer input[placeholder*='名称或ID']",
".ovui-drawer input[placeholder*='名称或ID']",
"div.product-drawer-content input[placeholder*='名称或ID']",
".product-drawer-wrap input[placeholder*='名称或ID']",
"input[placeholder*='请输入商品名称或ID']",
"input[placeholder*='请输入商品名称']"
];
// 确定按钮定位器
const confirmSelectors = [
"div.oc-drawer button.ovui-button--primary",
"div.oc-drawer-body-wrap button.ovui-button--primary",
".ovui-drawer button.ovui-button--primary",
"div.product-drawer-wrap button.ovui-button--primary",
"div.product-drawer-wrap button[data-e2e='button']",
"button.ovui-button--primary-fill",
"button.ovui-button--primary"
];
const douyinTriggerSelectors = [
"div.ovui-custom-input__content",
"[data-e2e='oc_emptyKey_ad/web/roi/ad/create']",
"span.oc-typography"
];
// 产品卖点高精定位器
const pointsInputSelectors = [
"input.ovui-input[placeholder*='产品卖点']",
"input[placeholder*='最多10个产品卖点']",
"div.ovui-custom-input__search input.ovui-input",
"div[class*='productSelling'] input"
];
// 项目名称高精定位器
const projectNameTextareaSelectors = [
"textarea.ovui-textarea[placeholder*='项目名称']",
"textarea[placeholder*='请输入项目名称']",
"textarea.ovui-textarea",
"div[class*='project-name'] textarea"
];
for (let i = startIndex; i < rows.length; i++) {
await checkPauseState();
if (!isRunning) break;
// 🌟 每一圈填充开始前进行安全额度自检 🌟
const remaining = GM_getValue(REMAINING_PROJECTS_KEY, 500);
if (remaining <= 0) {
log('🛑 [安全管控] 当前剩下可搭项目数为 0!已达到 500 限制上限,自动搭建已安全退出。', 'error');
isRunning = false;
updateBallStyle();
break;
}
GM_setValue(PROGRESS_INDEX_KEY, i);
renderPreviewTable(); // 🌟 核心补回:让表格预览自动向当前行(第i行)对焦,恢复动态显示上下各5行!
const currentRow = rows[i];
const dramaName = currentRow[0];
if (!dramaName) {
log(`第 ${i + 1} 行第一列的剧名为空,自动跳过...`, 'warning');
continue;
}
let checkYida = {};
try {
const savedYida = GM_getValue(YIDA_RECORDS_KEY, '{}');
checkYida = JSON.parse(savedYida);
} catch(e){}
const cleanDName = String(dramaName).trim().replace(/\s+/g, '');
const cleanAId = String(activeId).trim();
const yidaRecordKey = `${cleanDName}_${cleanAId}`;
const isCompleted = checkYida[yidaRecordKey] === true || (colIdx !== -1 && currentRow[colIdx] && String(currentRow[colIdx]).trim() === '已搭');
if (isCompleted) {
log(`- ⏩ 检测到第 ${i + 1} 行剧目 【${dramaName}】 在库中为 “已搭” 状态,自动跳过。`, 'system');
continue;
}
updateCurrentBuildingDramaName(dramaName);
log(`[任务 ${i + 1}/${rows.length}] 🚀 准备注入剧名: “${dramaName}”`, 'info');
try {
// 步骤 1: 触发添加商品抽屉
log(`- 正在定位“点击添加商品”按钮区域...`, 'system');
const triggerButton = await waitAndFindElement(triggerSelectors, ['点击添加商品', '添加商品']);
await checkPauseState();
log(`- 成功捕获商品卡片,仿真物理点击...`, 'system');
triggerClick(triggerButton);
log(`- 等待抽屉展开加载...`, 'system');
await sleep(1000);
await checkPauseState();
// 步骤 2: 定位输入框
log("- 正在检索黄色搜索输入框...", "system");
const inputElement = await waitAndFindElement(inputSelectors, []);
await checkPauseState();
// 步骤 3: 注入剧名并搜索
log(`- 已成功直达黄色搜索输入框,强行同步剧名: “${dramaName}”...`, 'system');
setReactInputValue(inputElement, dramaName);
log(`- 正在触发自动搜索 “${dramaName}”...`, 'system');
await sleep(500); // 预留 React 同步缓冲
// 发送回车指令搜索
pressEnter(inputElement);
await sleep(200);
// 点击放大镜图标(双重保险搜索)
const parentContainer = inputElement.parentElement;
if (parentContainer) {
const searchIcon = Array.from(parentContainer.querySelectorAll('svg, i, [class*="icon"], [class*="search"]')).find(el => {
if (!isVisible(el)) return false;
const className = String(el.className || '').toLowerCase();
const id = String(el.id || '').toLowerCase();
const dataAutoId = String(el.getAttribute('data-auto-id') || '').toLowerCase();
if (className.includes('clear') || className.includes('close') || className.includes('delete') || className.includes('cancel')) {
return false;
}
return className.includes('search') || className.includes('magnify') || dataAutoId.includes('search') || el.closest('[class*="search"]');
});
if (searchIcon) {
log(`- 成功捕捉放大镜按钮,辅助触发检索...`, 'system');
triggerClick(searchIcon);
}
}
await checkPauseState();
// 步骤 4: 等待结果匹配
log(`- 正在等待搜寻结果渲染就绪...`, 'system');
let matchedCard = null;
const searchTimeout = 8000;
const searchStart = Date.now();
while (Date.now() - searchStart < searchTimeout) {
await checkPauseState();
// 直接定位真实可见的商品卡片容器
const cards = Array.from(document.querySelectorAll(
'div[data-auto-id="create-product-card"], ' +
'.oc-create-product-card, ' +
'[class*="oc-create-product-card"], ' +
'[class*="product-card"]'
)).filter(isVisible);
// 🌟 策略 1: 文本比对归一化(洗去一切中英文符号、空格影响,彻底杜绝逗号括号比对差异)
const cleanDramaName = normalizeString(dramaName);
for (const card of cards) {
const cleanCardText = normalizeString(card.textContent || '');
if (cleanCardText.includes(cleanDramaName) || cleanDramaName.includes(cleanCardText)) {
matchedCard = card;
log(`- 🎯 文本归一化完美匹配:选中 “${dramaName}”`, 'system');
break;
}
}
// 🌟 策略 2: 降级兜底 - 只要搜索结果有卡片且未匹配成功,直接选中第一张卡片
if (!matchedCard && cards.length > 0) {
matchedCard = cards[0];
log(`- ⚠️ 未能精确字符匹配到 “${dramaName}”,启动自动选择列表首张商品卡片机制!`, 'warning');
}
if (matchedCard) break;
await sleep(300);
}
await checkPauseState();
if (matchedCard) {
log(`- 🎯 已锁定商品卡片,执行勾选动作...`, 'info');
// 定位卡片内部复选框包裹元素
const selectableBox = matchedCard.querySelector(
'input[type="checkbox"], ' +
'.ovui-checkbox__inner, ' +
'.ovui-checkbox__wrapper, ' +
'.ovui-checkbox, ' +
'.oc-checkbox'
);
if (selectableBox) {
log(`- 优先点击复选框确保事件落盘...`, 'system');
triggerClick(selectableBox);
} else {
log(`- 点击卡片本体完成勾选...`, 'system');
triggerClick(matchedCard);
}
await sleep(600);
// 步骤 5: 点击“确定”提交商品选择
log(`- 提交商品保存,点击“确定”...`, 'system');
const confirmBtn = await waitAndFindElement(confirmSelectors, ['确定']);
triggerClick(confirmBtn);
log(`- 剧名 “${dramaName}” 已成功保存并点击确定!`, 'info');
// 步骤 6: 等待抽屉滑出消失
log(`- 正在检测等待商品选择侧拉弹窗完全关闭...`, 'system');
const drawerSelector = "div.product-drawer-wrap, div.oc-drawer-body-wrap, .ovui-drawer";
let drawerClosed = false;
const closeTimeout = 5000;
const closeStart = Date.now();
while (Date.now() - closeStart < closeTimeout) {
await checkPauseState();
const drawer = document.querySelector(drawerSelector);
if (!drawer || drawer.offsetWidth === 0 || drawer.offsetHeight === 0) {
drawerClosed = true;
break;
}
await sleep(200);
}
if (drawerClosed) {
log(`- 商品抽屉已顺利关闭。`, 'system');
} else {
log(`- 未检测到抽屉关闭动作,正在强制继续...`, 'warning');
}
await sleep(600);
await checkPauseState();
// 步骤 7: 点击【请选择抖音号】
log(`- 正在定位“请选择抖音号”配置项...`, 'system');
const douyinTrigger = await waitAndFindElement(douyinTriggerSelectors, ['请选择抖音号']);
log(`- 成功捕获抖音号栏,展开账号列表!`, 'info');
triggerClick(douyinTrigger);
log(`- 等待账号下拉菜单载入...`, 'system');
await sleep(650);
await checkPauseState();
// 步骤 8: 抖音搜索检索
const searchDOUYINSelectors = [
"input[placeholder*='输入抖音ID']",
"input[placeholder*='抖音ID']",
"div.ovui-select__dropdown input",
"div[class*='dropdown'] input"
];
let dySearchInput = null;
for (const sel of searchDOUYINSelectors) {
dySearchInput = document.querySelector(sel);
if (dySearchInput) break;
}
if (dySearchInput) {
log(`- 已定位到抖音号检索框,检索 ID:【${activeId}】...`, 'system');
setReactInputValue(dySearchInput, activeId);
await sleep(800);
}
// 步骤 9: 匹配目标抖音号卡片
log(`- 正在精准搜寻匹配 ID 【${activeId}】 的抖音配置行...`, 'system');
let matchedDYAcountCard = null;
let authStatus = '';
const matchTimeout = 5000;
const matchStart = Date.now();
while (Date.now() - matchStart < matchTimeout) {
await checkPauseState();
const elements = Array.from(document.querySelectorAll('div, span, li, p'));
const leafIdElements = elements.filter(el => {
const cleanText = el.textContent.trim().replace(/\s+/g, '');
return el.children.length <= 1 && cleanText === activeId;
});
for (const leaf of leafIdElements) {
let curr = leaf;
let cardBox = null;
for (let d = 0; d < 8; d++) {
if (!curr.parentElement) break;
curr = curr.parentElement;
if (curr.textContent.includes('已投放') || curr.textContent.includes('全域投放授权')) {
cardBox = curr;
break;
}
}
if (cardBox) {
matchedDYAcountCard = cardBox;
const text = cardBox.textContent;
if (text.includes('已投放')) {
authStatus = 'delivered';
} else if (text.includes('全域投放授权')) {
authStatus = 'authorized';
} else {
authStatus = 'other';
}
break;
}
}
if (matchedDYAcountCard) break;
await sleep(300);
}
await checkPauseState();
if (matchedDYAcountCard) {
// 分支 A: 匹配为 “全域投放授权”
if (authStatus === 'authorized') {
log(`- 🎯 匹配成功!该账号为【全域投放授权】状态,执行点击选择!`, 'info');
triggerClick(matchedDYAcountCard);
await sleep(1000);
await checkPauseState();
// 步骤 10: 定位并填充 ROI
log(`- 正在检索页面上的“全域ROI系数”输入位置...`, 'system');
const roiInputElement = await waitAndFindRoiInput(8000);
if (roiInputElement) {
const activeRoiValue = GM_getValue(ROI_COEFFICIENT_KEY, '');
if (activeRoiValue !== '') {
log(`- 🎯 成功锁定全域ROI输入框!强行注入系数:【${activeRoiValue}】...`, 'info');
setReactInputValue(roiInputElement, activeRoiValue);
} else {
log(`⚠️ 自动ROI配置警告:未录入和确定ROI系数,跳过注入。`, 'warning');
}
} else {
log(`⚠️ 自动ROI配置失败:未定位到“全域ROI系数”输入框,请手动核查。`, 'warning');
}
await sleep(800);
await checkPauseState();
// 步骤 11: 点击“混合投放(IAA+IAP)”
log(`- 定位“混合投放(IAA+IAP)”选项...`, 'system');
const mixPlacementSelectors = [
"[data-e2e='r3project_monetizationMode_2']",
"div.ovui-radio-item[data-e2e='r3project_monetizationMode_2']",
"div.ovui-radio-item"
];
const mixPlacementButton = await waitAndFindElement(mixPlacementSelectors, ["混合投放", "混合投放(IAA+IAP)"]);
log(`- 🎯 成功找到并点击“混合投放”单选按钮...`, 'info');
triggerClick(mixPlacementButton);
await sleep(800);
await checkPauseState();
// 步骤 12: 点击“添加视频”
log(`- 正在定位“添加视频”按钮...`, 'system');
const addVideoSelectors = [
"div[btn-auto-id='add-video-btn'] button",
"div[data-e2e='Add_video'] button",
"div[btn-auto-id='add-video-btn']",
"div[data-e2e='Add_video']"
];
const addVideoButton = await waitAndFindElement(addVideoSelectors, ["添加视频"]);
log(`- 🎯 成功锁定添加视频按钮,仿真物理点击...`, 'info');
triggerClick(addVideoButton);
log(`- 正在等待添加视频弹出对话框完全展开...`, 'system');
await sleep(1200);
await checkPauseState();
// 步骤 13: 在“添加视频”对话框中输入当前剧名并搜索
log(`- 正在寻找添加视频弹出面板的检索输入框...`, 'system');
const videoInputSelectors = [
"input[placeholder*='可搜索视频名称或ID']",
"div.ovui-dialog input[placeholder*='可搜索']",
"div.ovui-modal input[placeholder*='视频']",
"input.ovui-input[placeholder*='视频名称']"
];
const videoInputElement = await waitAndFindElement(videoInputSelectors, []);
await checkPauseState();
log(`- 🎯 已定位到视频检索框,强行注入剧名:【${dramaName}】...`, 'info');
setReactInputValue(videoInputElement, dramaName);
await sleep(500);
await checkPauseState();
log(`- 发送回车指令过滤素材...`, 'system');
pressEnter(videoInputElement);
log(`- ⏳ 正在进行新检索素材 cold 启动重绘缓冲 (1.5秒)...`, 'system');
await sleep(1500);
await checkPauseState();
log(`- 正在等待视频素材列表首张卡片渲染呈现在屏幕上...`, 'system');
const videoCardSelectors = [
"div[data-auto-id='create-material-card']",
"[data-e2e='createad_myVideo__createMaterialList']",
".oc-create-material-card-wrapper",
"div.create-material-list-card-item div[data-auto-id='create-material-card']",
".create-material-list-card-item"
];
await waitAndFindElement(videoCardSelectors, [], 10000); // 10秒强力阻塞轮询
log(`- 🎯 检测到视频素材卡片已渲染就绪!`, 'info');
log(`- ⏳ 额外等待 1 秒(1000ms)以确保分页组件动作绑定就绪...`, 'system');
await sleep(1000);
await checkPauseState();
// 步骤 14: 点击 “40条/页” 下拉选择菜单
log(`- 正在寻找弹窗底部的分页条数下拉菜单...`, 'system');
const pageSelectSelectors = [
() => {
const e2eWrapper = document.querySelector("[data-e2e*='pagination_group_select']");
if (e2eWrapper) {
return e2eWrapper.querySelector('.ovui-input__wrapper') || e2eWrapper.querySelector('.ovui-select') || e2eWrapper;
}
return null;
},
() => {
const inputs = Array.from(document.querySelectorAll("input.ovui-input, input"));
const targetInput = inputs.find(inp => {
const val = String(inp.value || '').trim();
return val.includes("条/页") || val.includes("40条");
});
if (targetInput) {
return targetInput.closest(".ovui-select") || targetInput.closest(".ovui-input__wrapper") || targetInput;
}
return null;
},
"div[data-e2e='createad_myVideo__createMaterialList_pagination_group_select'] div.ovui-select",
"div[data-e2e='createad_myVideo__createMaterialList_pagination_group_select'] input.ovui-input",
"div.ovui-dialog input[value='40条/页']",
"div.ovui-dialog input[value*='条/页']",
"div.ovui-dialog div.ovui-select",
"div.ovui-dialog .ovui-select__input",
"div.create-material-list-pagination-list-content div.ovui-select input.ovui-input"
];
const pageSelectElement = await waitAndFindElement(pageSelectSelectors, ['条/页']);
await checkPauseState();
log(`- 已锁定分页菜单,等待分页组件底层事件绑定稳定 (300ms)...`, 'system');
await sleep(300);
await checkPauseState();
log(`- 🎯 成功捕获分页菜单,点击展开选择框下拉条目...`, 'system');
triggerClick(pageSelectElement);
await sleep(300);
await checkPauseState();
// 步骤 15: 选择 “160条/页” 选项并进行刷新等待
log(`- 正在精准检索 “160条/页” 下拉选项卡片...`, 'system');
const optionSelectors = [
() => {
const candidates = Array.from(document.querySelectorAll('.ovui-option__content, .ovui-option, .ovui-select-option, li'));
return candidates.find(opt => {
const txt = opt.textContent.trim().replace(/\s+/g, '');
return (txt === '160条/页' || txt.includes('160条')) && opt.offsetHeight > 0;
});
},
"div.ovui-option__content",
"div.ovui-select_dropdown div",
".ovui-option"
];
const optionElement = await waitAndFindElement(optionSelectors, ["160条/页"]);
await checkPauseState();
log(`- 🎯 成功锁定 “160条/页” 选项,物理仿真点击切换!`, 'info');
triggerClick(optionElement);
log(`- 分页条目已重置为 160条/页,等待页面关闭下拉菜单与异步遮罩层清空...`, 'system');
// 核心时差等待
let dropdownClosed = false;
for (let j = 0; j < 30; j++) {
const dropdown = document.querySelector(".ovui-select_dropdown, .ovui-select-dropdown, [class*='dropdown']");
if (!dropdown || dropdown.offsetHeight === 0) {
dropdownClosed = true;
break;
}
await sleep(100);
}
// 核心时差等待
let loadingCleared = false;
for (let j = 0; j < 30; j++) {
const loading = document.querySelector('.ovui-loading, [class*="loading"], .oc-loading, .ovui-loading-mask, [class*="mask"]');
if (!loading || loading.offsetHeight === 0) {
loadingCleared = true;
break;
}
await sleep(100);
}
log(`- ⏳ 额外预留 300 毫秒重载重绘缓冲时间,确保 React DOM 彻底稳定...`, 'system');
await sleep(300);
await checkPauseState();
// 步骤 16: 自动跨翻页本页全选与数量对齐校验
let pageNum = 1;
let autoPaginationComplete = false;
while (isRunning && !isPaused) {
log(`- [第 ${pageNum} 页] 正在定位“本页全选”复选框...`, 'system');
const selectAllCheckbox = await waitAndFindElement([
() => {
const labels = Array.from(document.querySelectorAll('.ovui-checkbox, label, [class*="checkbox"]'));
const labelEl = labels.find(l => l.textContent.includes('本页全选') && l.offsetHeight > 0);
if (labelEl) {
return labelEl.querySelector('.ovui-checkbox__inner, .ovui-checkbox__wrapper, input[type="checkbox"]') || labelEl;
}
return null;
},
() => {
const wrapper = document.querySelector("div[data-auto-id='oc-checkbox'], .oc-checkbox");
if (wrapper && wrapper.offsetHeight > 0) {
return wrapper.querySelector('.ovui-checkbox__inner, input[type="checkbox"]') || wrapper;
}
return null;
}
], ['本页全选'], 10000);
await checkPauseState();
log(`- 🎯 成功捕获第 ${pageNum} 页全选复选框,执行物理仿真点击选中本页所有素材...`, 'info');
triggerClick(selectAllCheckbox);
// 等待计数更新
log(`- ⏳ 正在等待全选后视频选择计数器同步...`, 'system');
await sleep(800);
await checkPauseState();
// 读取已选择数量并进行高精校对
let countMatched = false;
let lastSelected = -1;
let lastTotal = -1;
// 循环轮询对比5次
for (let attempt = 0; attempt < 5; attempt++) {
await checkPauseState();
const sCount = getSelectedCount();
const tCount = getTotalCount();
lastSelected = sCount;
lastTotal = tCount;
log(`- [第 ${pageNum} 页/校对 ${attempt + 1}] 当前已选择: ${sCount} / 库总共: ${tCount}`, 'system');
if (sCount !== -1 && tCount !== -1 && sCount === tCount) {
countMatched = true;
break;
}
await sleep(300);
}
if (countMatched) {
log(`- 🎯 数量完美对齐![已选: ${lastSelected}] === [总共: ${lastTotal}]。自动跨页全选圆满结束!`, 'info');
autoPaginationComplete = true;
break;
} else {
// 数量不一致,尝试翻到下一页
log(`- ⚠️ 当前页全选后数量不一致 [已选择: ${lastSelected} / 库总共: ${lastTotal}],尝试查找下一页按钮...`, 'warning');
const nextBtn = document.querySelector(".ovui-page-turner__next-icon, [class*='page-turner__next']");
let nextBtnLi = null;
if (nextBtn) {
nextBtnLi = nextBtn.closest('li') || nextBtn;
} else {
const turnerItems = document.querySelectorAll(".ovui-page-turner__item, [class*='page-turner__item']");
if (turnerItems.length > 0) {
nextBtnLi = turnerItems[turnerItems.length - 1];
}
}
const isDisabled = nextBtnLi && (
nextBtnLi.classList.contains('ovui-page-turner__item--disabled') ||
nextBtnLi.classList.contains('ovui-page-turner__item-disabled') ||
nextBtnLi.hasAttribute('disabled') ||
nextBtnLi.getAttribute('aria-disabled') === 'true'
);
if (nextBtnLi && !isDisabled) {
pageNum++;
log(`- 🔄 发现下一页翻页按钮,触发物理点击翻到 [第 ${pageNum} 页]...`, 'info');
triggerClick(nextBtnLi);
// 等待新页面加载渲染
log(`- ⏳ 正在等待新一页视频素材异步载入...`, 'system');
await sleep(1000);
let pageLoadingCleared = false;
for (let j = 0; j < 30; j++) {
const loading = document.querySelector('.ovui-loading, [class*="loading"], .oc-loading, .ovui-loading-mask, [class*="mask"]');
if (!loading || loading.offsetHeight === 0) {
pageLoadingCleared = true;
break;
}
await sleep(100);
}
await sleep(300); // 稳定缓冲
} else {
log(`- ❌ 数量未对齐,且未找到下一页按钮或已达到最末页。跨页全选无法继续。`, 'break');
break;
}
}
}
// 步骤 17: 根据全自动校对结果决定自动提交
if (autoPaginationComplete) {
log(`- 🎯 开始检索对话框“确定”保存按钮...`, 'info');
const modalConfirmBtn = await waitAndFindElement([
() => {
const activeVideoDialog = Array.from(document.querySelectorAll('.ovui-dialog, .ovui-modal, .ovui-drawer'))
.find(container => {
if (!isVisible(container)) return false;
return container.textContent.includes('添加视频') || container.textContent.includes('视频库') || container.textContent.includes('本页全选');
});
if (activeVideoDialog) {
const buttons = Array.from(activeVideoDialog.querySelectorAll('button'));
return buttons.find(btn => (btn.textContent.trim().includes('确定') || btn.textContent.trim() === '确定') && isVisible(btn));
}
return null;
},
() => {
const visibleFooters = Array.from(document.querySelectorAll('.oc-drawer-footer, .oc-modal-footer, .ovui-dialog__footer, [class*="drawer-footer"], [class*="modal-footer"]'))
.filter(isVisible);
for (const footer of visibleFooters) {
const btn = Array.from(footer.querySelectorAll('button')).find(b => b.textContent.includes('确定') && isVisible(b));
if (btn) return btn;
}
return null;
}
], ['确定'], 5000);
await checkPauseState();
log(`- 🎯 成功锁定提交“确定”按钮,物理仿真点击保存!`, 'info');
triggerClick(modalConfirmBtn);
// 步骤 18: 等待素材弹窗完全关闭
log(`- ⏳ 正在监测并等待素材选择弹窗完全关闭并安全释放 DOM 树...`, 'system');
let videoDialogClosed = false;
const videoCloseStart = Date.now();
while (Date.now() - videoCloseStart < 6000) { // 6秒超时保护
await checkPauseState();
const videoDialog = document.querySelector('.ovui-dialog, .ovui-modal, .ovui-drawer, .product-drawer-wrap, [class*="dialog"], [class*="modal"]');
if (!videoDialog || videoDialog.offsetHeight === 0) {
videoDialogClosed = true;
break;
}
await sleep(100);
}
if (videoDialogClosed) {
log(`- 🎯 素材弹窗已顺利关闭。开始自动寻找并注入 IAA / IAP 链接...`, 'system');
} else {
log(`- ⚠️ 未侦测到弹窗关闭,正在强行寻找并注入链接...`, 'warning');
}
await sleep(300);
// A. 注入 IAA 链接
log(`- 正在精准寻找“IAA 链接”输入框位置...`, 'system');
const iaaInputElement = await waitAndFindIaaInput(8000);
if (iaaInputElement) {
const iaaLinkValue = currentRow[4]; // 精准读取 Excel 数据第 5 列
if (iaaLinkValue !== undefined && String(iaaLinkValue).trim() !== '') {
log(`- 🎯 成功锁定 IAA 链接输入框!正在自动写入链接:【${iaaLinkValue}】...`, 'info');
setReactInputValue(iaaInputElement, String(iaaLinkValue).trim());
} else {
log(`⚠️ IAA 链接提示:Excel 第 ${i + 1} 行剧目 【${dramaName}】 的第 5 列(IAA链接)为空,跳过自动输入。`, 'warning');
}
} else {
log(`⚠️ 自动 IAA 链接配置失败:未能在页面上定位到“IAA链接”输入框,请手动核查。`, 'warning');
}
await sleep(300);
// B. 智能链式注入 IAP 链接 (第 6-9 列,索引 5 到 8)
log(`- 正在检测并自动注入 IAP 链接列表 (第 6-9 列)...`, 'system');
const iapLinks = [];
for (let col = 5; col <= 8; col++) {
const val = currentRow[col];
if (val !== undefined && val !== null && String(val).trim() !== '') {
iapLinks.push(String(val).trim());
}
}
if (iapLinks.length > 0) {
log(`- 检测到当前剧目包含 ${iapLinks.length} 个非空 IAP 链接,开始填充流程...`, 'info');
for (let idx = 0; idx < iapLinks.length; idx++) {
const linkVal = iapLinks[idx];
// 重新扫描可见 IAP 链接输入框
let currentInputs = findIapInputElements();
let targetInput = currentInputs[idx];
// 若当前输入框不存在,代表需要链式生成,触发 "+ 点击添加"
if (!targetInput) {
targetInput = await waitAndAddIapInput(idx);
}
if (targetInput) {
log(`- 🎯 正在向第 ${idx + 1} 个 IAP 链接框注入:【${linkVal}】...`, 'info');
setReactInputValue(targetInput, linkVal);
await sleep(300); // 防 React 状态更新
} else {
log(`❌ 无法生成或定位第 ${idx + 1} 个 IAP 链接框,跳过。`, 'error');
}
}
log(`✨ 成功完成 ${iapLinks.length} 组 IAP 链接的智能填充配置!`, 'info');
} else {
log(`⚠️ IAP 链接提示:Excel 第 ${i + 1} 行剧目 【${dramaName}】 的第 6-9 列为空,跳过。`, 'warning');
}
await sleep(300);
// C. 注入随机创意标题
log(`- 正在检测并自动注入随机创意标题...`, 'system');
const savedTitles = GM_getValue(TITLES_STORAGE_KEY, '');
const titleLines = savedTitles.split('\n').map(t => t.trim()).filter(t => t.length > 0);
if (titleLines.length > 0) {
log(`- 正在精准寻找“创意标题”输入框位置...`, 'system');
const titleInputSelectors = [
"input.ovui-input[placeholder*='5-55个字的标题']",
"input[placeholder*='请输入5-55个字的标题']",
"input[data-e2e*='creativeTitleGroup_title_input']",
"input[data-e2e*='creativeTitles']"
];
const creativeTitleInput = await waitAndFindElement(titleInputSelectors, [], 8000).catch(() => null);
if (creativeTitleInput) {
const randomIndex = Math.floor(Math.random() * titleLines.length);
const selectedTitle = titleLines[randomIndex];
log(`- 🎯 成功锁定创意标题输入框!正在随机注入标题:【${selectedTitle}】...`, 'info');
setReactInputValue(creativeTitleInput, selectedTitle);
} else {
log(`⚠️ 自动标题配置失败:未能在页面上定位到“创意标题”输入框,请手动核查。`, 'warning');
}
} else {
log(`⚠️ 自动标题配置警告:您未在“安全管理”页面中锁定任何有效标题,已跳过标题自动注入。`, 'warning');
}
await sleep(500);
// D. 自动选择产品主图第一个方块
log(`- 正在检测产品主图配置...`, 'system');
const addImgBtn = await waitAndFindElement([
"div.oc-create-product-img-add-button",
"div[data-auto-id='oc-create-product-img-add-button']"
], [], 8000).catch(() => null);
if (addImgBtn) {
log(`- 🎯 成功捕获产品主图 “+” 添加按钮,模拟物理点击...`, 'info');
triggerClick(addImgBtn);
log(`- 等待素材图片库弹出加载 (1.2秒)...`, 'system');
await sleep(1200);
await checkPauseState();
log(`- 正在寻找并选中弹窗内第一个图片卡片...`, 'system');
const firstImgCard = await waitAndFindElement([
() => {
const activeContainer = Array.from(document.querySelectorAll('.ovui-drawer, .ovui-dialog, .ovui-modal, [class*="drawer"], [class*="dialog"], [class*="modal"]'))
.find(container => {
if (!isVisible(container)) return false;
return container.querySelector('.oc-create-material-lib-body, .oc-drawer-body') ||
container.textContent.includes('我的图片') ||
container.textContent.includes('本地上传');
});
if (activeContainer) {
const card = Array.from(activeContainer.querySelectorAll('.oc-create-material-card-content-data, div[data-auto-id="oc-image"], div.oc-image-square, .oc-create-material-card-content-show-img'))
.find(isVisible);
if (card) return card;
const fallback = activeContainer.querySelector('.create-material-list-card-container-body > div:nth-child(1) .oc-create-material-card-content-data');
if (fallback && isVisible(fallback)) return fallback;
}
return null;
}
], [], 10000).catch(() => null);
if (firstImgCard) {
log(`- 🎯 已成功定位并选中库内第一个图片卡片,模拟点击选择...`, 'info');
triggerClick(firstImgCard);
await sleep(600);
await checkPauseState();
log(`- 正在寻找弹窗底部的 “确定” 保存按钮...`, 'system');
const imgConfirmBtn = await waitAndFindElement([
() => {
const activeContainer = Array.from(document.querySelectorAll('.ovui-drawer, .ovui-dialog, .ovui-modal, [class*="drawer"], [class*="dialog"], [class*="modal"]'))
.find(container => {
if (!isVisible(container)) return false;
return container.querySelector('.oc-create-material-lib-body, .oc-drawer-body') ||
container.textContent.includes('我的图片') ||
container.textContent.includes('本地上传');
});
if (activeContainer && isVisible(activeContainer)) {
const buttons = Array.from(activeContainer.querySelectorAll('button'));
return buttons.find(btn => (btn.textContent.trim().includes('确定') || btn.textContent.trim() === '确定') && isVisible(btn));
}
return null;
},
"button.ovui-button--primary",
"button.ovui-button--primary-fill"
], ['确定'], 6000).catch(() => null);
if (imgConfirmBtn) {
log(`- 🎯 成功锁定确定按钮,点击保存图片选择!`, 'info');
triggerClick(imgConfirmBtn);
// 等待图片弹窗关闭
log(`- ⏳ 正在等待图片弹窗安全关闭...`, 'system');
let imgDialogClosed = false;
const imgCloseStart = Date.now();
while (Date.now() - imgCloseStart < 6000) {
await checkPauseState();
const activeContainer = Array.from(document.querySelectorAll('.ovui-drawer, .ovui-dialog, .ovui-modal'))
.find(container => {
if (!isVisible(container)) return false;
return container.querySelector('.oc-create-material-lib-body, .oc-drawer-body') ||
container.textContent.includes('我的图片') ||
container.textContent.includes('本地上传');
});
if (!activeContainer) {
imgDialogClosed = true;
break;
}
await sleep(150);
}
if (imgDialogClosed) {
log(`- ✨ 产品主图已顺利保存并关闭弹窗!`, 'info');
} else {
log(`- ⚠️ 图片库弹窗未检测到完全关闭,正在强行继续...`, 'warning');
}
} else {
log(`❌ 未能定位到图片选择弹窗的“确定”保存按钮,请手动确认。`, 'error');
}
} else {
log(`❌ 未能在弹窗中找到任何可选的图片卡片,请手动选择。`, 'error');
}
} else {
log(`⚠️ 未能定位到“产品主图”添加按钮,请核查页面是否展开了产品主图模块。`, 'warning');
}
await sleep(500);
// E. 自动填入产品卖点 (空格联立一次性极速注入)
log(`- 正在检测产品卖点配置...`, 'system');
const savedPoints = GM_getValue(SELLING_POINTS_STORAGE_KEY, '');
const pointsLines = savedPoints.split('\n').map(p => p.trim()).filter(p => p.length > 0);
if (pointsLines.length > 0) {
log(`- 正在精准寻找“产品卖点”输入框位置...`, 'system');
const pointsTargetInput = await waitAndFindElement(pointsInputSelectors, [], 8000).catch(() => null);
if (pointsTargetInput) {
log(`- 🎯 成功锁定产品卖点输入框!准备空格全装极速注入中...`, 'info');
const maxPointsToFill = Math.min(pointsLines.length, 10);
const slicedPoints = pointsLines.slice(0, maxPointsToFill);
const combinedPoints = slicedPoints.join(' '); // 用半角空格拼装
log(`- 正在一次性填入卖点: 【${combinedPoints}】`, 'system');
setReactInputValue(pointsTargetInput, combinedPoints);
await sleep(300);
pointsTargetInput.dispatchEvent(new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 'Enter',
keyCode: 13,
code: 'Enter',
which: 13
}));
await sleep(400);
log(`- ✨ 产品卖点急速全量注入完成!`, 'info');
} else {
log(`⚠️ 自动产品卖点配置失败:未能在页面上定位到“产品卖点”输入框,请手动核查。`, 'warning');
}
} else {
log(`⚠️ 自动产品卖点配置提示:产品卖点库为空或处于未锁定状态,跳过自动注入。`, 'warning');
}
await sleep(500);
// F. 自动注入并更新项目名称 (支持 ROI 占位替换)
log(`- 正在检测项目名配置模板...`, 'system');
const rawProjectTemplate = GM_getValue(PROJECT_NAME_STORAGE_KEY, '');
if (rawProjectTemplate) {
log(`- 正在精准寻找网页上的“项目名称”输入框位置...`, 'system');
const nameTextarea = await waitAndFindElement(projectNameTextareaSelectors, [], 8000).catch(() => null);
if (nameTextarea) {
const evaluatedName = processProjectNameTemplate(rawProjectTemplate, dramaName);
log(`- 🎯 成功锁定项目名称输入框!正在一次性清空并填入:【${evaluatedName}】...`, 'info');
// 强力 React 16+ 双兼容性底层一次性极速注入
setReactInputValue(nameTextarea, evaluatedName);
await sleep(500);
log(`- ✨ 项目名称急速全量注入覆盖完成!`, 'info');
} else {
log(`⚠️ 自动项目名称配置失败:未能在页面上定位到“项目名称”输入框,请手动核查。`, 'warning');
}
} else {
log(`⚠️ 自动项目名称配置提示:项目名模板为空或处于未锁定状态,跳过。`, 'warning');
}
await sleep(600); // 等待过渡
log('📝 本页全部表单要素均已配置填装成功!正在模拟点击【保存并投放】...', 'info');
// 自动点击【保存并投放】
const saveAndLaunchSelectors = [
"button.ovui-button--primary-fill",
"button.ovui-button[data-e2e='button']",
() => {
const buttons = Array.from(document.querySelectorAll('button'));
return buttons.find(b => b.textContent.includes('保存并投放') && isVisible(b));
}
];
const saveAndLaunchBtn = await waitAndFindElement(saveAndLaunchSelectors, ['保存并投放'], 6000).catch(() => null);
if (saveAndLaunchBtn) {
log(`- 🎯 成功捕获到【保存并投放】按钮,开始触发仿真物理点击提交!`, 'info');
// 先行在油猴沙盒记盘“当前待搭成功的剧目”以防止SPA页面跳转拦截导致的数据写盘断档
GM_setValue('oceanengine_last_submitted_drama', dramaName);
triggerClick(saveAndLaunchBtn);
let redirected = false;
let elapsed = 0;
const checkInterval = 1000;
log(`- ⏳ 已进入最长 5 秒死锁轮询保护,正监听管理主页跳转状态...`, 'system');
while (isRunning && !isPaused) {
await sleep(checkInterval);
elapsed += checkInterval;
// 检测是否成功返回管理主页
if (location.href.includes('/ad/web/manage')) {
redirected = true;
break;
}
// 如果超过 5 秒依然处于配置页,说明巨量网络抖动或表单拦截,执行再次补点提交
if (elapsed >= 5000) {
log(`⚠️ 侦测到 5 秒内未完成跳转,可能巨量发生抖动,重新触发【保存并投放】点击...`, 'warning');
const retryBtn = Array.from(document.querySelectorAll('button')).find(b => b.textContent.includes('保存并投放') && isVisible(b));
if (retryBtn) {
triggerClick(retryBtn);
}
elapsed = 0; // 重置计时器
}
}
if (redirected) {
// 进位重载指针
GM_setValue(PROGRESS_INDEX_KEY, i + 1);
renderPreviewTable(); // 🌟 同步滚动视角对焦
GM_setValue(AUTO_ACTIVE_KEY, 'true'); // 确保返回管理页自启动
// 成功时重置运行标志,等待自启动接力
isRunning = false;
updateBallStyle();
log(`- 🚀 跳转成功!页面正在重新加载,等待执行下行剧目搭建接力...`, 'warning');
await sleep(1000);
return; // 退出当前工作流,等待页面实例自动重载重构
}
} else {
log(`❌ 无法捕获【保存并投放】提交按钮,表单无法全自动保存,已挂起暂停。请手动点击!`, 'error');
isPaused = true;
btnPauseLabel.innerText = '继续';
btnPauseBuild.classList.remove('ios-btn-orange');
btnPauseBuild.classList.add('ios-btn-primary');
updateBallStyle();
await checkPauseState();
}
} else {
log(`- ⚠️ 跨页自动全选未能实现数量对齐(可能已达末页、视频总数超出单页160等)。`, 'warning');
log(`- 当前已选择:${getSelectedCount()} | 库中总共:${getTotalCount()}。`, 'warning');
log(`- 脚本流程已自动[挂起/暂停],请核对并确认无误后,手动点击右下角“确定”按钮提交。`, 'warning');
log(`- 手动确认后在脚本悬浮窗点击“继续”,系统将接力配置下一个剧目.`, 'warning');
isPaused = true;
btnPauseLabel.innerText = '继续';
btnPauseBuild.classList.remove('ios-btn-orange');
btnPauseBuild.classList.add('ios-btn-primary');
updateBallStyle();
await checkPauseState();
}
// 分支 B: 匹配为 “已投放” -> 双层哈希锁定、安全存盘刷新页面接力下一行
} else if (authStatus === 'delivered') {
log(`- ⚠️ 匹配完毕!此账号当前为 [已投放] 状态!`, 'warning');
markDramaAsCompleted(dramaName);
log(`- ⏳ 安全同步落盘中,请勿触摸/关闭浏览器...`, 'system');
await sleep(600);
log(`- 🔄 存盘完毕。触发页面重载自启动,为您接力第 ${i + 2} 行数据!`, 'warning');
GM_setValue(PROGRESS_INDEX_KEY, i + 1);
renderPreviewTable(); // 🌟 同步滚动视角对焦
GM_setValue(AUTO_ACTIVE_KEY, 'true');
// 重置状态
isRunning = false;
updateBallStyle();
await sleep(1000);
location.reload();
return;
} else {
log(`- ⚠️ 该抖音号状态处于非全域授权,流程挂起...`, 'warning');
isPaused = true;
btnPauseLabel.innerText = '继续';
btnPauseBuild.classList.remove('ios-btn-orange');
btnPauseBuild.classList.add('ios-btn-primary');
updateBallStyle();
await checkPauseState();
}
} else {
log(`- ❌ 列表没有匹配的抖音 ID 【${activeId}】,流程挂起...`, 'error');
isPaused = true;
btnPauseLabel.innerText = '继续';
btnPauseBuild.classList.remove('ios-btn-orange');
btnPauseBuild.classList.add('ios-btn-primary');
updateBallStyle();
await checkPauseState();
}
} else {
log(`⚠️ 未能在商品搜索结果中自动匹配到剧名 “${dramaName}”!`, 'warning');
log(`- 脚本流程已为您自动[挂起/暂停],请您手动在界面点击选择正确商品,并点击“确定”保存。`, 'warning');
log(`- 手动配置完成后,请在脚本悬浮窗点击“继续”,系统将接力配置下一个剧目.`, 'warning');
isPaused = true;
btnPauseLabel.innerText = '继续';
btnPauseBuild.classList.remove('ios-btn-orange');
btnPauseBuild.classList.add('ios-btn-primary');
updateBallStyle();
await checkPauseState();
continue;
}
// 任务间隔延时
if (i < rows.length - 1) {
log("等待 4 秒后执行下一行配置...", "system");
await sleep(4000);
}
} catch (error) {
log(`[报错] 自动注入流在第 ${i + 1} 行中断: ${error.message}`, "error");
log('流程暂停。请核实页面弹窗状态,随后点击自运行按钮即可一键恢复接力。', 'warning');
isRunning = false;
isPaused = false;
updateBallStyle();
break;
}
}
isRunning = false;
isPaused = false;
btnPauseBuild.className = 'ios-btn ios-btn-disabled';
btnPauseLabel.innerText = '暂停';
updateBallStyle();
log('🎉 所有自动化配置执行完毕!', 'info');
updateCurrentBuildingDramaName('--');
GM_deleteValue(PROGRESS_INDEX_KEY);
}
// 16. 页面首次加载与初始化
renderPreviewTable();
renderDouyinList();
updateRemainingUI(); // 渲染初始化剩下的搭项目额度数据
const isMinimized = GM_getValue('ios_helper_minimized', 'false');
if (isMinimized === 'true') {
togglePanelMinimizeState(true);
} else {
togglePanelMinimizeState(false);
}
checkAutoResumeState();
})();