@playcraft/build 0.0.48 → 0.0.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,8 +15,12 @@ export declare class PlayableScriptsAdapter {
15
15
  private projectDir;
16
16
  private options;
17
17
  private config?;
18
+ private projectBuildsConfig?;
18
19
  private static readonly DEFAULTS;
19
20
  constructor(projectDir: string, options: BaseBuildOptions, config?: PlayableScriptsConfig);
21
+ private loadProjectBuildsConfig;
22
+ private resolveObfuscateLevel;
23
+ private resolveFflateCompression;
20
24
  /**
21
25
  * 执行 playable-scripts 构建
22
26
  *
@@ -156,6 +160,11 @@ export declare class PlayableScriptsAdapter {
156
160
  */
157
161
  private getPlatformFilename;
158
162
  private runDevkitOutputValidation;
163
+ /**
164
+ * Devkit 产物会跳过 ViteBuilder,必须在这里补齐 PlayCraft 生命周期到渠道接口的桥接。
165
+ * 普通构建链由对应 PlatformAdapter 处理。
166
+ */
167
+ private applyChannelLifecycleBridges;
159
168
  private getValidationChannels;
160
169
  private isSupportedValidationChannel;
161
170
  private pickArtifactForChannel;
@@ -3,6 +3,8 @@ import path from 'path';
3
3
  import { spawn } from 'child_process';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { createRequire } from 'module';
6
+ import { strFromU8, strToU8, unzipSync, zipSync } from 'fflate';
7
+ import { injectBigoLifecycleBridge } from '../platforms/bigo-lifecycle.js';
6
8
  import { formatValidationSummary, hasBlockingValidationErrors, runPlayableValidation, saveValidationReport, } from '../validation/index.js';
7
9
  // ESM 兼容:获取当前模块的目录路径
8
10
  const __filename = fileURLToPath(import.meta.url);
@@ -20,7 +22,7 @@ const CHANNEL_MAP = {
20
22
  /**
21
23
  * PlayCraft 不支持的渠道(playable-scripts 不认识的 PlayCraft 平台)
22
24
  */
23
- const UNSUPPORTED_CHANNELS = new Set(['remerge', 'mintegral']);
25
+ const UNSUPPORTED_CHANNELS = new Set(['remerge']);
24
26
  /**
25
27
  * 将 PlayCraft 平台名映射为 playable-scripts 渠道名
26
28
  */
@@ -49,6 +51,42 @@ export class PlayableScriptsAdapter {
49
51
  console.log(`[PlayableScriptsAdapter] 目标渠道: ${config.channels.join(', ')}`);
50
52
  }
51
53
  }
54
+ loadProjectBuildsConfig(configPath) {
55
+ try {
56
+ const resolvedPath = require.resolve(configPath);
57
+ delete require.cache[resolvedPath];
58
+ const loaded = require(resolvedPath);
59
+ if (loaded && typeof loaded === 'object' && !Array.isArray(loaded)) {
60
+ return loaded;
61
+ }
62
+ }
63
+ catch (error) {
64
+ console.warn(`[PlayableScriptsAdapter] Failed to read builds.config.js build params, using PlayCraft defaults: ${error instanceof Error ? error.message : String(error)}`);
65
+ }
66
+ return undefined;
67
+ }
68
+ resolveObfuscateLevel() {
69
+ const explicitValue = this.config?.obfuscateLevel;
70
+ if (typeof explicitValue === 'number' && Number.isFinite(explicitValue)) {
71
+ return explicitValue;
72
+ }
73
+ const projectValue = this.projectBuildsConfig?.obfuscateLevel;
74
+ if (typeof projectValue === 'number' && Number.isFinite(projectValue)) {
75
+ return projectValue;
76
+ }
77
+ return PlayableScriptsAdapter.DEFAULTS.obfuscateLevel;
78
+ }
79
+ resolveFflateCompression() {
80
+ const explicitValue = this.config?.fflateCompression;
81
+ if (typeof explicitValue === 'boolean') {
82
+ return explicitValue;
83
+ }
84
+ const projectValue = this.projectBuildsConfig?.fflateCompression;
85
+ if (typeof projectValue === 'boolean') {
86
+ return projectValue;
87
+ }
88
+ return PlayableScriptsAdapter.DEFAULTS.fflateCompression;
89
+ }
52
90
  /**
53
91
  * 执行 playable-scripts 构建
54
92
  *
@@ -93,6 +131,7 @@ export class PlayableScriptsAdapter {
93
131
  const outputs = await this.collectOutputs();
94
132
  // 7. 复制产物到 PlayCraft outputDir
95
133
  const files = await this.copyToOutputDir(outputs);
134
+ await this.applyChannelLifecycleBridges(files);
96
135
  await this.runDevkitOutputValidation(files);
97
136
  // 7. 构建元数据
98
137
  const metadata = {
@@ -149,10 +188,12 @@ export class PlayableScriptsAdapter {
149
188
  let configContent;
150
189
  try {
151
190
  configContent = await fs.readFile(configPath, 'utf-8');
191
+ this.projectBuildsConfig = this.loadProjectBuildsConfig(configPath);
152
192
  console.log('[PlayableScriptsAdapter] 读取现有 builds.config.js');
153
193
  }
154
194
  catch {
155
195
  configContent = 'module.exports = {};';
196
+ this.projectBuildsConfig = undefined;
156
197
  console.log('[PlayableScriptsAdapter] builds.config.js 不存在,将创建');
157
198
  }
158
199
  // 构建注入内容
@@ -225,11 +266,11 @@ export class PlayableScriptsAdapter {
225
266
  buildCLIArgs() {
226
267
  const config = this.config ?? {};
227
268
  const args = [];
228
- // 混淆等级:config 指定 > PlayCraft 强制默认 4
229
- const obfuscateLevel = config.obfuscateLevel ?? PlayableScriptsAdapter.DEFAULTS.obfuscateLevel;
269
+ // 混淆等级:config 指定 > 项目 builds.config.js > PlayCraft 默认 4
270
+ const obfuscateLevel = this.resolveObfuscateLevel();
230
271
  args.push('--obfuscate-level', `${obfuscateLevel}`);
231
- // fflate 压缩:config 指定 > PlayCraft 强制默认 true
232
- const fflate = config.fflateCompression ?? PlayableScriptsAdapter.DEFAULTS.fflateCompression;
272
+ // fflate 压缩:config 指定 > 项目 builds.config.js > PlayCraft 默认 true
273
+ const fflate = this.resolveFflateCompression();
233
274
  args.push('--fflate-compression', `${fflate}`);
234
275
  // 渠道列表
235
276
  if (config.channels && config.channels.length > 0) {
@@ -280,8 +321,8 @@ export class PlayableScriptsAdapter {
280
321
  // ==================== 3. 日志输出 ====================
281
322
  logBuildParams() {
282
323
  const config = this.config ?? {};
283
- const obfuscateLevel = config.obfuscateLevel ?? PlayableScriptsAdapter.DEFAULTS.obfuscateLevel;
284
- const fflate = config.fflateCompression ?? PlayableScriptsAdapter.DEFAULTS.fflateCompression;
324
+ const obfuscateLevel = this.resolveObfuscateLevel();
325
+ const fflate = this.resolveFflateCompression();
285
326
  const levelLabels = {
286
327
  1: 'minimum - 基础',
287
328
  2: 'medium - 中等',
@@ -1282,6 +1323,34 @@ export class PlayableScriptsAdapter {
1282
1323
  }
1283
1324
  }
1284
1325
  }
1326
+ /**
1327
+ * Devkit 产物会跳过 ViteBuilder,必须在这里补齐 PlayCraft 生命周期到渠道接口的桥接。
1328
+ * 普通构建链由对应 PlatformAdapter 处理。
1329
+ */
1330
+ async applyChannelLifecycleBridges(files) {
1331
+ const channels = this.getValidationChannels();
1332
+ if (!channels.includes('bigo')) {
1333
+ return;
1334
+ }
1335
+ const artifacts = files.assets.filter((file) => file.endsWith('.html') || file.endsWith('.zip'));
1336
+ const artifact = this.pickArtifactForChannel('bigo', artifacts);
1337
+ if (!artifact) {
1338
+ return;
1339
+ }
1340
+ if (artifact.toLowerCase().endsWith('.html')) {
1341
+ const html = await fs.readFile(artifact, 'utf8');
1342
+ await fs.writeFile(artifact, injectBigoLifecycleBridge(html), 'utf8');
1343
+ return;
1344
+ }
1345
+ const archive = unzipSync(await fs.readFile(artifact));
1346
+ const htmlEntry = Object.keys(archive).find((entry) => /(?:^|\/)index\.html$/i.test(entry))
1347
+ ?? Object.keys(archive).find((entry) => /\.html$/i.test(entry));
1348
+ if (!htmlEntry) {
1349
+ throw new Error(`[PlayableScriptsAdapter] BIGO ZIP artifact has no HTML entry: ${artifact}`);
1350
+ }
1351
+ archive[htmlEntry] = strToU8(injectBigoLifecycleBridge(strFromU8(archive[htmlEntry])));
1352
+ await fs.writeFile(artifact, zipSync(archive, { level: 9 }));
1353
+ }
1285
1354
  getValidationChannels() {
1286
1355
  if (this.options.validation?.channel) {
1287
1356
  return [this.options.validation.channel];
@@ -1313,9 +1382,13 @@ export class PlayableScriptsAdapter {
1313
1382
  if (artifacts.length === 1) {
1314
1383
  return artifacts[0];
1315
1384
  }
1385
+ const mappedChannel = mapChannel(channel).toLowerCase();
1316
1386
  const direct = artifacts.find((artifact) => {
1317
1387
  const normalized = artifact.replace(/\\/g, '/').toLowerCase();
1318
- return normalized.includes(`/${channel}/`) || normalized.includes(`-${channel}.`);
1388
+ return normalized.includes(`/${channel}/`)
1389
+ || normalized.includes(`-${channel}.`)
1390
+ || normalized.includes(`/${mappedChannel}/`)
1391
+ || normalized.includes(`-${mappedChannel}.`);
1319
1392
  });
1320
1393
  return direct ?? artifacts[0];
1321
1394
  }
@@ -0,0 +1,6 @@
1
+ export declare const BIGO_LIFECYCLE_BRIDGE_MARKER = "data-playcraft-bigo-lifecycle";
2
+ /**
3
+ * 把 PlayCraft Ads SDK (Advertising Software Development Kit, 广告软件开发工具包)
4
+ * 的 playcraftFinish 事件桥接到 BIGO 渠道结束接口。通过 marker 保证多条构建链重复处理时幂等。
5
+ */
6
+ export declare function injectBigoLifecycleBridge(html: string): string;
@@ -0,0 +1,35 @@
1
+ export const BIGO_LIFECYCLE_BRIDGE_MARKER = 'data-playcraft-bigo-lifecycle';
2
+ const BIGO_LIFECYCLE_BRIDGE = `<script ${BIGO_LIFECYCLE_BRIDGE_MARKER}>(function(){
3
+ var gameEndSent=false;
4
+ var bindAttempts=0;
5
+ function notifyGameEnd(){
6
+ if(gameEndSent)return;
7
+ if(!window.BGY_MRAID||typeof window.BGY_MRAID.gameEnd!=="function")return;
8
+ gameEndSent=true;
9
+ window.BGY_MRAID.gameEnd();
10
+ }
11
+ function bindPlayableSdk(){
12
+ var playableSdk=window.PlayableSDK;
13
+ if(playableSdk&&typeof playableSdk.once==="function"){
14
+ if(playableSdk.isFinished===true){notifyGameEnd();return;}
15
+ playableSdk.once("playcraftFinish",notifyGameEnd);
16
+ return;
17
+ }
18
+ bindAttempts+=1;
19
+ if(bindAttempts<300)window.setTimeout(bindPlayableSdk,100);
20
+ }
21
+ bindPlayableSdk();
22
+ })();</script>`;
23
+ /**
24
+ * 把 PlayCraft Ads SDK (Advertising Software Development Kit, 广告软件开发工具包)
25
+ * 的 playcraftFinish 事件桥接到 BIGO 渠道结束接口。通过 marker 保证多条构建链重复处理时幂等。
26
+ */
27
+ export function injectBigoLifecycleBridge(html) {
28
+ if (html.includes(BIGO_LIFECYCLE_BRIDGE_MARKER)) {
29
+ return html;
30
+ }
31
+ if (/<\/head>/i.test(html)) {
32
+ return html.replace(/<\/head>/i, `${BIGO_LIFECYCLE_BRIDGE}</head>`);
33
+ }
34
+ return `${BIGO_LIFECYCLE_BRIDGE}${html}`;
35
+ }
@@ -1,4 +1,5 @@
1
1
  import { PlatformAdapter } from './base.js';
2
+ import { injectBigoLifecycleBridge } from './bigo-lifecycle.js';
2
3
  export class BigoAdapter extends PlatformAdapter {
3
4
  getName() {
4
5
  return 'BIGO Ads';
@@ -39,6 +40,7 @@ export class BigoAdapter extends PlatformAdapter {
39
40
  const comment = `<!-- BIGO: HTML/ZIP format, config.json includes orientation when present, use BGY_MRAID.open() -->`;
40
41
  // 在 </head> 之前插入
41
42
  html = html.replace('</head>', `${sdkScript}${fallbackScript}${comment}</head>`);
43
+ html = injectBigoLifecycleBridge(html);
42
44
  // 注入统一的 CTA 适配器
43
45
  html = await this.injectCTAAdapterAsync(html);
44
46
  html = await this.injectTrackingAdapterAsync(html);
@@ -1,4 +1,35 @@
1
1
  import { PlatformAdapter } from './base.js';
2
+ const CHARSET_META_PATTERN = /<meta\b(?=[^>]*(?:\bcharset\s*=|\bhttp-equiv\s*=\s*(?:["']content-type["']|content-type)\b))[^>]*>/i;
3
+ function maskHeadRawText(content) {
4
+ return content.replace(/<!--[\s\S]*?(?:-->|$)|<(script|style|template|noscript)\b[^>]*>[\s\S]*?(?:<\/\1\s*>|$)/gi, (match) => match.replace(/[^\r\n]/g, ' '));
5
+ }
6
+ function injectUnityGate(html, gateMarkup) {
7
+ const headMatch = /<head(?:\s[^>]*)?>/i.exec(html);
8
+ if (!headMatch) {
9
+ // 无 head 的非标准文档不做节点重排:优先在首个业务脚本前注入。
10
+ const firstScript = /<script\b/i.exec(html);
11
+ if (firstScript) {
12
+ return `${html.slice(0, firstScript.index)}${gateMarkup}${html.slice(firstScript.index)}`;
13
+ }
14
+ const bodyClose = /<\/body>/i.exec(html);
15
+ if (bodyClose) {
16
+ return `${html.slice(0, bodyClose.index)}${gateMarkup}${html.slice(bodyClose.index)}`;
17
+ }
18
+ return `${html}${gateMarkup}`;
19
+ }
20
+ const headEnd = headMatch.index + headMatch[0].length;
21
+ const headCloseMatch = /<\/head>/i.exec(html.slice(headEnd));
22
+ const headContentEnd = headCloseMatch ? headEnd + headCloseMatch.index : html.length;
23
+ const headContent = html.slice(headEnd, headContentEnd);
24
+ const charsetMatch = CHARSET_META_PATTERN.exec(maskHeadRawText(headContent));
25
+ if (!charsetMatch) {
26
+ return `${html.slice(0, headEnd)}${gateMarkup}${html.slice(headEnd)}`;
27
+ }
28
+ // charset 必须位于文档开头区域;移动到 head 首位后再注入门控,确保门控仍早于业务脚本。
29
+ const charsetStart = headEnd + charsetMatch.index;
30
+ const htmlWithoutCharset = html.slice(0, charsetStart) + html.slice(charsetStart + charsetMatch[0].length);
31
+ return `${htmlWithoutCharset.slice(0, headEnd)}${charsetMatch[0]}${gateMarkup}${htmlWithoutCharset.slice(headEnd)}`;
32
+ }
2
33
  export class UnityAdapter extends PlatformAdapter {
3
34
  getName() {
4
35
  return 'Unity Ads';
@@ -11,10 +42,19 @@ export class UnityAdapter extends PlatformAdapter {
11
42
  return 'html';
12
43
  }
13
44
  async modifyHTML(html, assets) {
14
- // Unity Ads 需要 MRAID 3.0 支持
15
- const unityScriptCode = `
45
+ const unityScript = await this.minifyPlatformScript(this.getPlatformScript(), 'unity-mraid');
46
+ const comment = `<!-- Unity Ads: MRAID 2.0, 等待viewableChange, 使用mraid.open() -->`;
47
+ // 保留 charset 在文档开头,同时确保可见性门控早于首个业务脚本。
48
+ html = injectUnityGate(html, `${unityScript}${comment}`);
49
+ // 注入统一的 CTA 适配器
50
+ html = await this.injectCTAAdapterAsync(html);
51
+ html = await this.injectTrackingAdapterAsync(html);
52
+ return html;
53
+ }
54
+ getPlatformScript() {
55
+ return `
16
56
  window.mraid = window.mraid || {
17
- getVersion: function() { return '3.0'; },
57
+ getVersion: function() { return '2.0'; },
18
58
  isReady: function() { return true; },
19
59
  open: function(url) {
20
60
  console.log('Unity Ads CTA: opening store');
@@ -25,8 +65,10 @@ export class UnityAdapter extends PlatformAdapter {
25
65
  },
26
66
  close: function() { window.close(); },
27
67
  addEventListener: function(event, listener) {
28
- if (event === 'ready' || event === 'viewableChange') {
29
- setTimeout(function() { listener({ viewable: true }); }, 0);
68
+ if (event === 'ready') {
69
+ setTimeout(function() { listener(); }, 0);
70
+ } else if (event === 'viewableChange') {
71
+ setTimeout(function() { listener(true); }, 0);
30
72
  }
31
73
  },
32
74
  removeEventListener: function(event, listener) {},
@@ -37,29 +79,79 @@ export class UnityAdapter extends PlatformAdapter {
37
79
  getCurrentPosition: function() { return { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight }; },
38
80
  getScreenSize: function() { return { width: screen.width, height: screen.height }; }
39
81
  };
40
- `;
41
- const unityScript = await this.minifyPlatformScript(unityScriptCode, 'unity-mraid');
42
- const comment = `<!-- Unity Ads: MRAID 3.0, 等待viewableChange, 使用mraid.open() -->`;
43
- // 在 </head> 之前插入
44
- html = html.replace('</head>', `${unityScript}${comment}</head>`);
45
- // 注入统一的 CTA 适配器
46
- html = await this.injectCTAAdapterAsync(html);
47
- html = await this.injectTrackingAdapterAsync(html);
48
- return html;
49
- }
50
- getPlatformScript() {
51
- return `
52
- // Unity Ads Playable
53
- // 等待 viewableChange 事件
54
-
55
- if (typeof mraid !== 'undefined') {
56
- mraid.addEventListener('viewableChange', function(viewable) {
82
+
83
+ (function() {
84
+ var nativeRequestAnimationFrame = window.requestAnimationFrame
85
+ ? window.requestAnimationFrame.bind(window)
86
+ : function(callback) {
87
+ return window.setTimeout(function() { callback(Date.now()); }, 16);
88
+ };
89
+ var nativeCancelAnimationFrame = window.cancelAnimationFrame
90
+ ? window.cancelAnimationFrame.bind(window)
91
+ : window.clearTimeout.bind(window);
92
+ var queuedFrames = [];
93
+ var nextQueuedFrameId = -1;
94
+ var viewable = false;
95
+ var previousVisibility = document.documentElement.style.visibility;
96
+
97
+ window.__PLAYCRAFT_UNITY_VIEWABLE__ = false;
98
+ document.documentElement.style.visibility = 'hidden';
99
+
100
+ window.requestAnimationFrame = function(callback) {
57
101
  if (viewable) {
58
- console.log('Unity Ads: playable is viewable, starting game');
59
- // 这里启动游戏内容
102
+ return nativeRequestAnimationFrame(callback);
60
103
  }
61
- });
62
- }
104
+ var id = nextQueuedFrameId--;
105
+ queuedFrames.push({ id: id, callback: callback });
106
+ return id;
107
+ };
108
+
109
+ window.cancelAnimationFrame = function(id) {
110
+ if (id < 0) {
111
+ queuedFrames = queuedFrames.filter(function(frame) { return frame.id !== id; });
112
+ return;
113
+ }
114
+ nativeCancelAnimationFrame(id);
115
+ };
116
+
117
+ function dispatchLifecycleEvent(name) {
118
+ var event = document.createEvent('Event');
119
+ event.initEvent(name, false, false);
120
+ window.dispatchEvent(event);
121
+ }
122
+
123
+ function setViewable(nextViewable) {
124
+ var normalizedViewable = nextViewable === true;
125
+ if (viewable === normalizedViewable) {
126
+ return;
127
+ }
128
+ viewable = normalizedViewable;
129
+ window.__PLAYCRAFT_UNITY_VIEWABLE__ = viewable;
130
+ document.documentElement.style.visibility = viewable ? previousVisibility : 'hidden';
131
+
132
+ if (!viewable) {
133
+ dispatchLifecycleEvent('playcraft:unity-hidden');
134
+ return;
135
+ }
136
+
137
+ var pendingFrames = queuedFrames;
138
+ queuedFrames = [];
139
+ pendingFrames.forEach(function(frame) {
140
+ nativeRequestAnimationFrame(frame.callback);
141
+ });
142
+ dispatchLifecycleEvent('playcraft:unity-viewable');
143
+ }
144
+
145
+ function onSdkReady() {
146
+ mraid.addEventListener('viewableChange', setViewable);
147
+ }
148
+
149
+ if (typeof mraid.getState === 'function' && mraid.getState() === 'loading') {
150
+ mraid.addEventListener('ready', onSdkReady);
151
+ } else {
152
+ onSdkReady();
153
+ }
154
+ })();
63
155
  `;
64
156
  }
65
157
  validateOptions() {
@@ -160,13 +160,12 @@ export const CHANNEL_RULE_PROFILES = {
160
160
  ], { optionalStages: ['delivery-package', 'runtime-manual'], deliveryPackageFormat: 'mixed' }),
161
161
  tiktok: profile('tiktok', 'TikTok / Pangle', 'zip', [
162
162
  forbiddenPatternRule('tiktok', 'sdk.mraid-js.forbidden', 'TikTok/Pangle creatives should not include mraid.js.', 'mraid\\.js', 'Remove mraid.js; Pangle integration belongs to the channel SDK.'),
163
- forbiddenPatternRule('tiktok', 'redirect.javascript.forbidden', 'TikTok/Pangle creatives should not contain JavaScript redirects.', '\\blocation\\.(href|assign|replace)\\b|window\\.open\\s*\\(', 'Remove automatic redirects and delegate store jumps to Pangle.'),
163
+ forbiddenPatternRule('tiktok', 'redirect.javascript.forbidden', 'TikTok/Pangle creatives should not contain direct location redirects.', '\\blocation\\.(href|assign|replace)\\b', 'Remove direct location redirects. CTA handling is injected by @playcraft/devkit and delegated to Pangle.'),
164
164
  ], { optionalStages: ['delivery-package'], deliveryPackageFormat: 'zip', devkitOwned: true, internalBuildFormat: 'delegated' }),
165
165
  unity: profile('unity', 'Unity Ads', 'single-html', [
166
- sourcePresenceRule('unity', 'mraid.viewable-change.required', 'Unity requires MRAID viewableChange before gameplay starts.', 'Add a viewableChange listener before starting playable content.'),
167
166
  externalResourceRule('unity'),
168
167
  requiredPatternRule('unity', 'cta.mraid-open.required', 'Unity CTA should use mraid.open().', '\\bmraid\\.open\\s*\\(', 'Route the CTA directly to the store through mraid.open().'),
169
- requiredPatternRule('unity', 'mraid.viewable-change.required', 'Unity playable should wait for MRAID viewableChange.', 'viewableChange', 'Wait for the MRAID viewableChange event before starting gameplay.'),
168
+ requiredPatternRule('unity', 'mraid.viewable-change.required', 'Unity playable should wait for MRAID viewableChange.', `mraid\\.addEventListener\\s*\\(\\s*['"]viewableChange['"]`, 'Wait for the MRAID viewableChange event before starting gameplay.'),
170
169
  manualRule('unity', 'runtime.orientation-and-start.manual', 'Orientation support and gameplay start timing need runtime review.', 'Verify portrait/landscape support and that gameplay waits for viewability.'),
171
170
  ], { optionalStages: ['runtime-manual'] }),
172
171
  ironsource: profile('ironsource', 'ironSource', 'mixed', [
@@ -184,7 +183,7 @@ export const CHANNEL_RULE_PROFILES = {
184
183
  manualRule('moloco', 'runtime.portrait-responsive.manual', 'Portrait responsive behavior needs runtime review.', 'Verify portrait layout and CTA visibility.'),
185
184
  ], { optionalStages: ['runtime-manual'] }),
186
185
  bigo: profile('bigo', 'BIGO', 'zip', [
187
- sourcePresenceRule('bigo', 'lifecycle.game-end.required', 'BIGO requires window.BGY_MRAID.gameEnd() when gameplay ends.', 'Call window.BGY_MRAID.gameEnd() when gameplay ends.'),
186
+ sourcePresenceRule('bigo', 'lifecycle.game-end.required', 'BIGO requires a gameplay finish signal.', 'Call sdk.playcraftFinish() when gameplay ends; the BIGO channel build maps it to window.BGY_MRAID.gameEnd().'),
188
187
  externalResourceRule('bigo', ['static-web.likeevideo.com']),
189
188
  requiredPatternRule('bigo', 'cta.bgy-open.required', 'BIGO CTA should use window.BGY_MRAID.open().', 'window\\.BGY_MRAID\\.open\\s*\\(', 'Route CTA clicks through window.BGY_MRAID.open().'),
190
189
  requiredPatternRule('bigo', 'lifecycle.game-end.required', 'BIGO creatives should call window.BGY_MRAID.gameEnd().', 'window\\.BGY_MRAID\\.gameEnd\\s*\\(', 'Call gameEnd() when gameplay ends.'),
@@ -203,8 +202,8 @@ export const CHANNEL_RULE_PROFILES = {
203
202
  forbiddenPatternRule('remerge', 'redirect.auto.forbidden', 'Remerge creatives should not auto-redirect to the store.', '\\blocation\\.(href|assign|replace)\\b', 'Keep store jumps behind a user CTA.'),
204
203
  ]),
205
204
  mintegral: profile('mintegral', 'Mintegral', 'mixed', [
206
- sourcePresenceRule('mintegral', 'lifecycle.sdk-finish.required', 'Mintegral requires sdk.finish() after gameplay ends.', 'Call sdk.finish() when gameplay reaches win or end state.'),
207
- requiredPatternRule('mintegral', 'lifecycle.sdk-finish.required', 'Mintegral creatives should call sdk.finish() after win or end.', '\\bsdk\\.finish\\s*\\(', 'Call sdk.finish() at the end of gameplay.'),
205
+ sourcePresenceRule('mintegral', 'lifecycle.game-end.required', 'Mintegral requires a gameplay finish signal.', 'Call sdk.playcraftFinish() when gameplay reaches a win or end state.'),
206
+ requiredPatternRule('mintegral', 'lifecycle.game-end.required', 'Mintegral creatives should call window.gameEnd() after gameplay ends.', 'window\\.gameEnd\\s*\\(', 'Map the gameplay finish signal to window.gameEnd().'),
208
207
  forbiddenPatternRule('mintegral', 'redirect.auto.forbidden', 'Mintegral creatives should not auto-redirect to the store.', '\\blocation\\.(href|assign|replace)\\b', 'Keep store jumps behind a user CTA.'),
209
208
  externalValidatorRule('mintegral', 'Mindworks testing should be used before delivery.', 'Validate through the Mintegral/Mindworks testing entry.'),
210
209
  ], { optionalStages: ['external-validator'], deliveryPackageFormat: 'mixed' }),
@@ -54,22 +54,12 @@ const COMMON_SOURCE_PATTERNS = [
54
54
  },
55
55
  ];
56
56
  const CHANNEL_SOURCE_PATTERNS = {
57
- unity: [
58
- {
59
- ruleId: 'unity.mraid.viewable-change.required',
60
- pattern: /viewableChange/g,
61
- message: 'Unity requires waiting for MRAID viewableChange before starting playable content.',
62
- recommendation: 'Add a viewableChange listener and start gameplay only after the ad is viewable.',
63
- severity: 'error',
64
- confidence: 'high',
65
- },
66
- ],
67
57
  mintegral: [
68
58
  {
69
- ruleId: 'mintegral.lifecycle.sdk-finish.required',
70
- pattern: /\bsdk\.finish\s*\(/g,
71
- message: 'Mintegral requires sdk.finish() after gameplay ends.',
72
- recommendation: 'Call sdk.finish() when the win or end state is reached.',
59
+ ruleId: 'mintegral.lifecycle.game-end.required',
60
+ pattern: /(?:\b[\w$]+(?:\?\.|\.)playcraftFinish(?:\?\.)?|window(?:\?\.|\.)gameEnd(?:\?\.)?)\s*\(/g,
61
+ message: 'Mintegral requires a gameplay finish signal.',
62
+ recommendation: 'Call sdk.playcraftFinish() when the win or end state is reached; the channel build maps it to window.gameEnd().',
73
63
  severity: 'error',
74
64
  confidence: 'high',
75
65
  },
@@ -77,9 +67,9 @@ const CHANNEL_SOURCE_PATTERNS = {
77
67
  bigo: [
78
68
  {
79
69
  ruleId: 'bigo.lifecycle.game-end.required',
80
- pattern: /window\.BGY_MRAID\.gameEnd\s*\(/g,
81
- message: 'BIGO requires window.BGY_MRAID.gameEnd() when gameplay ends.',
82
- recommendation: 'Call window.BGY_MRAID.gameEnd() at the end of gameplay.',
70
+ pattern: /(?:\b[\w$]+(?:\?\.|\.)playcraftFinish(?:\?\.)?|window(?:\?\.|\.)BGY_MRAID(?:\?\.|\.)gameEnd(?:\?\.)?)\s*\(/g,
71
+ message: 'BIGO requires a gameplay finish signal.',
72
+ recommendation: 'Call sdk.playcraftFinish() when gameplay ends; the BIGO channel build maps it to window.BGY_MRAID.gameEnd().',
83
73
  severity: 'error',
84
74
  confidence: 'high',
85
75
  },
@@ -130,7 +120,7 @@ async function scanSourceFilesForChannel(context, sourceFiles, includeCommonPatt
130
120
  if (matchedRequiredRuleIds.has(pattern.ruleId)) {
131
121
  continue;
132
122
  }
133
- const match = content.match(pattern.pattern);
123
+ const match = findExecutableSourcePattern(filePath, content, pattern.pattern);
134
124
  if (match) {
135
125
  matchedRequiredRuleIds.add(pattern.ruleId);
136
126
  results.push({
@@ -142,8 +132,8 @@ async function scanSourceFilesForChannel(context, sourceFiles, includeCommonPatt
142
132
  message: `${pattern.message}: passed`,
143
133
  recommendation: pattern.recommendation,
144
134
  file: filePath,
145
- line: lineOfIndex(content, content.search(pattern.pattern)),
146
- evidence: trimEvidence(match[0]),
135
+ line: lineOfIndex(content, match.index),
136
+ evidence: trimEvidence(match.evidence),
147
137
  owner: ownerForContext(context),
148
138
  confidence: pattern.confidence,
149
139
  });
@@ -153,6 +143,278 @@ async function scanSourceFilesForChannel(context, sourceFiles, includeCommonPatt
153
143
  results.push(...missingRequiredPatternResults(context, matchedRequiredRuleIds));
154
144
  return results;
155
145
  }
146
+ function findExecutableSourcePattern(filePath, content, pattern) {
147
+ const executableSource = maskNonExecutableSource(filePath, content);
148
+ const expression = new RegExp(pattern.source, pattern.flags);
149
+ expression.lastIndex = 0;
150
+ const match = expression.exec(executableSource);
151
+ if (!match || match.index == null) {
152
+ return null;
153
+ }
154
+ return {
155
+ index: match.index,
156
+ evidence: content.slice(match.index, match.index + match[0].length),
157
+ };
158
+ }
159
+ function maskNonExecutableSource(filePath, content) {
160
+ const extension = path.extname(filePath).toLowerCase();
161
+ if (extension === '.css') {
162
+ return blankSource(content);
163
+ }
164
+ if (extension === '.html') {
165
+ return maskHtmlSource(content);
166
+ }
167
+ return maskJavaScriptLikeSource(content);
168
+ }
169
+ function maskHtmlSource(content) {
170
+ const masked = blankSource(content).split('');
171
+ const lowerContent = content.toLowerCase();
172
+ let cursor = 0;
173
+ while (cursor < content.length) {
174
+ const commentStart = lowerContent.indexOf('<!--', cursor);
175
+ const scriptStart = lowerContent.indexOf('<script', cursor);
176
+ if (scriptStart < 0) {
177
+ break;
178
+ }
179
+ if (commentStart >= 0 && commentStart < scriptStart) {
180
+ const commentEnd = lowerContent.indexOf('-->', commentStart + 4);
181
+ cursor = commentEnd < 0 ? content.length : commentEnd + 3;
182
+ continue;
183
+ }
184
+ const tagEnd = findHtmlTagEnd(content, scriptStart + 7);
185
+ if (tagEnd < 0) {
186
+ break;
187
+ }
188
+ const openTag = content.slice(scriptStart, tagEnd + 1);
189
+ const closeTagStart = lowerContent.indexOf('</script', tagEnd + 1);
190
+ const scriptEnd = closeTagStart < 0 ? content.length : closeTagStart;
191
+ if (isExecutableScriptTag(openTag)) {
192
+ const scriptSource = content.slice(tagEnd + 1, scriptEnd);
193
+ const maskedScript = maskJavaScriptLikeSource(scriptSource);
194
+ for (let index = 0; index < maskedScript.length; index += 1) {
195
+ masked[tagEnd + 1 + index] = maskedScript[index];
196
+ }
197
+ }
198
+ if (closeTagStart < 0) {
199
+ break;
200
+ }
201
+ const closeTagEnd = findHtmlTagEnd(content, closeTagStart + 8);
202
+ cursor = closeTagEnd < 0 ? content.length : closeTagEnd + 1;
203
+ }
204
+ return masked.join('');
205
+ }
206
+ function findHtmlTagEnd(content, start) {
207
+ let quote = null;
208
+ for (let index = start; index < content.length; index += 1) {
209
+ const character = content[index];
210
+ if (quote) {
211
+ if (character === quote) {
212
+ quote = null;
213
+ }
214
+ continue;
215
+ }
216
+ if (character === '"' || character === "'") {
217
+ quote = character;
218
+ continue;
219
+ }
220
+ if (character === '>') {
221
+ return index;
222
+ }
223
+ }
224
+ return -1;
225
+ }
226
+ function isExecutableScriptTag(openTag) {
227
+ const typeMatch = openTag.match(/\btype\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i);
228
+ if (!typeMatch) {
229
+ return true;
230
+ }
231
+ const type = (typeMatch[1] ?? typeMatch[2] ?? typeMatch[3] ?? '').trim().toLowerCase();
232
+ if (!type) {
233
+ return true;
234
+ }
235
+ const mimeType = type.split(';', 1)[0].trim();
236
+ return mimeType === 'module' || /^(?:text|application)\/(?:java|ecma)script$/.test(mimeType);
237
+ }
238
+ function maskJavaScriptLikeSource(content) {
239
+ const masked = content.split('');
240
+ const maskAt = (index) => {
241
+ if (content[index] !== '\n' && content[index] !== '\r') {
242
+ masked[index] = ' ';
243
+ }
244
+ };
245
+ const maskRange = (start, end) => {
246
+ for (let index = start; index < end; index += 1) {
247
+ maskAt(index);
248
+ }
249
+ };
250
+ const scanQuoted = (start, quote) => {
251
+ let index = start + 1;
252
+ while (index < content.length) {
253
+ if (content[index] === '\\') {
254
+ index = Math.min(content.length, index + 2);
255
+ continue;
256
+ }
257
+ if (content[index] === quote) {
258
+ index += 1;
259
+ break;
260
+ }
261
+ index += 1;
262
+ }
263
+ maskRange(start, index);
264
+ return index;
265
+ };
266
+ const scanLineComment = (start) => {
267
+ let index = start + 2;
268
+ while (index < content.length && content[index] !== '\n' && content[index] !== '\r') {
269
+ index += 1;
270
+ }
271
+ maskRange(start, index);
272
+ return index;
273
+ };
274
+ const scanBlockComment = (start, closingToken) => {
275
+ const closingIndex = content.indexOf(closingToken, start + 2);
276
+ const end = closingIndex < 0 ? content.length : closingIndex + closingToken.length;
277
+ maskRange(start, end);
278
+ return end;
279
+ };
280
+ const previousSignificantIndex = (start) => {
281
+ for (let index = start; index >= 0; index -= 1) {
282
+ if (!/\s/.test(masked[index])) {
283
+ return index;
284
+ }
285
+ }
286
+ return -1;
287
+ };
288
+ const isRegexStart = (index) => {
289
+ if (content[index + 1] === '=') {
290
+ return false;
291
+ }
292
+ const previousIndex = previousSignificantIndex(index - 1);
293
+ if (previousIndex < 0) {
294
+ return true;
295
+ }
296
+ if (/[([{,:;=!?&|+\-*%^~<>}]/.test(masked[previousIndex])) {
297
+ return true;
298
+ }
299
+ let wordStart = previousIndex;
300
+ while (wordStart >= 0 && /[\w$]/.test(masked[wordStart])) {
301
+ wordStart -= 1;
302
+ }
303
+ const previousWord = masked.slice(wordStart + 1, previousIndex + 1).join('');
304
+ return /^(?:await|case|delete|do|else|in|instanceof|new|of|return|throw|typeof|void|yield)$/.test(previousWord);
305
+ };
306
+ const scanRegex = (start) => {
307
+ let index = start + 1;
308
+ let inCharacterClass = false;
309
+ while (index < content.length) {
310
+ const character = content[index];
311
+ if (character === '\n' || character === '\r') {
312
+ break;
313
+ }
314
+ if (character === '\\') {
315
+ index = Math.min(content.length, index + 2);
316
+ continue;
317
+ }
318
+ if (character === '[') {
319
+ inCharacterClass = true;
320
+ }
321
+ else if (character === ']') {
322
+ inCharacterClass = false;
323
+ }
324
+ else if (character === '/' && !inCharacterClass) {
325
+ index += 1;
326
+ while (index < content.length && /[a-z]/i.test(content[index])) {
327
+ index += 1;
328
+ }
329
+ break;
330
+ }
331
+ index += 1;
332
+ }
333
+ maskRange(start, index);
334
+ return index;
335
+ };
336
+ let scanCode;
337
+ const scanTemplate = (start) => {
338
+ maskAt(start);
339
+ let index = start + 1;
340
+ while (index < content.length) {
341
+ if (content[index] === '\\') {
342
+ maskAt(index);
343
+ if (index + 1 < content.length) {
344
+ maskAt(index + 1);
345
+ }
346
+ index += 2;
347
+ continue;
348
+ }
349
+ if (content[index] === '`') {
350
+ maskAt(index);
351
+ return index + 1;
352
+ }
353
+ if (content[index] === '$' && content[index + 1] === '{') {
354
+ maskAt(index);
355
+ maskAt(index + 1);
356
+ index = scanCode(index + 2, true);
357
+ continue;
358
+ }
359
+ maskAt(index);
360
+ index += 1;
361
+ }
362
+ return index;
363
+ };
364
+ scanCode = (start, interpolation) => {
365
+ let index = start;
366
+ let braceDepth = interpolation ? 1 : 0;
367
+ while (index < content.length) {
368
+ const character = content[index];
369
+ const nextCharacter = content[index + 1];
370
+ if (interpolation && character === '{') {
371
+ braceDepth += 1;
372
+ index += 1;
373
+ continue;
374
+ }
375
+ if (interpolation && character === '}') {
376
+ braceDepth -= 1;
377
+ if (braceDepth === 0) {
378
+ maskAt(index);
379
+ return index + 1;
380
+ }
381
+ index += 1;
382
+ continue;
383
+ }
384
+ if (character === '"' || character === "'") {
385
+ index = scanQuoted(index, character);
386
+ continue;
387
+ }
388
+ if (character === '`') {
389
+ index = scanTemplate(index);
390
+ continue;
391
+ }
392
+ if (character === '/' && nextCharacter === '/') {
393
+ index = scanLineComment(index);
394
+ continue;
395
+ }
396
+ if (character === '/' && nextCharacter === '*') {
397
+ index = scanBlockComment(index, '*/');
398
+ continue;
399
+ }
400
+ if (character === '<' && content.startsWith('<!--', index)) {
401
+ index = scanBlockComment(index, '-->');
402
+ continue;
403
+ }
404
+ if (character === '/' && isRegexStart(index)) {
405
+ index = scanRegex(index);
406
+ continue;
407
+ }
408
+ index += 1;
409
+ }
410
+ return index;
411
+ };
412
+ scanCode(0, false);
413
+ return masked.join('');
414
+ }
415
+ function blankSource(content) {
416
+ return content.replace(/[^\r\n]/g, ' ');
417
+ }
156
418
  function evaluateSourcePatternForChannel(context, channel, filePath, content, sourcePattern) {
157
419
  const matches = Array.from(content.matchAll(sourcePattern.pattern));
158
420
  return matches.slice(0, 10).map((match) => ({
@@ -218,7 +218,7 @@ export const PLATFORM_CONFIGS = {
218
218
  minifyJS: true,
219
219
  compressImages: false, // 禁用图片压缩,原图输出效果更好
220
220
  compressModels: true,
221
- injectScripts: ['mraid3'],
221
+ injectScripts: ['mraid'],
222
222
  outputFileName: 'index.html',
223
223
  includeSourcemap: false,
224
224
  imageQuality: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/build",
3
- "version": "0.0.48",
3
+ "version": "0.0.50",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -42,6 +42,7 @@
42
42
  "@vheemstra/vite-plugin-imagemin": "^2.2.1",
43
43
  "archiver": "^7.0.1",
44
44
  "draco3dgltf": "^1.5.7",
45
+ "fflate": "^0.8.2",
45
46
  "imagemin-mozjpeg": "^10.0.0",
46
47
  "imagemin-pngquant": "^10.0.0",
47
48
  "imagemin-webp": "^8.0.0",
@@ -59,7 +60,6 @@
59
60
  "@types/archiver": "^6.0.4",
60
61
  "@types/mime-types": "^2.1.4",
61
62
  "@types/node": "^22.19.8",
62
- "fflate": "^0.8.2",
63
63
  "typescript": "^5.9.3"
64
64
  }
65
65
  }