@playcraft/build 0.0.49 → 0.0.51
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.
- package/dist/engines/playable-scripts-adapter.d.ts +5 -0
- package/dist/engines/playable-scripts-adapter.js +37 -2
- package/dist/platforms/bigo-lifecycle.d.ts +6 -0
- package/dist/platforms/bigo-lifecycle.js +35 -0
- package/dist/platforms/bigo.js +2 -0
- package/dist/platforms/unity.js +118 -26
- package/dist/validation/profiles.js +5 -6
- package/dist/validation/source-scanner.js +282 -20
- package/dist/vite/platform-configs.js +1 -1
- package/package.json +2 -2
|
@@ -160,6 +160,11 @@ export declare class PlayableScriptsAdapter {
|
|
|
160
160
|
*/
|
|
161
161
|
private getPlatformFilename;
|
|
162
162
|
private runDevkitOutputValidation;
|
|
163
|
+
/**
|
|
164
|
+
* Devkit 产物会跳过 ViteBuilder,必须在这里补齐 PlayCraft 生命周期到渠道接口的桥接。
|
|
165
|
+
* 普通构建链由对应 PlatformAdapter 处理。
|
|
166
|
+
*/
|
|
167
|
+
private applyChannelLifecycleBridges;
|
|
163
168
|
private getValidationChannels;
|
|
164
169
|
private isSupportedValidationChannel;
|
|
165
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'
|
|
25
|
+
const UNSUPPORTED_CHANNELS = new Set(['remerge']);
|
|
24
26
|
/**
|
|
25
27
|
* 将 PlayCraft 平台名映射为 playable-scripts 渠道名
|
|
26
28
|
*/
|
|
@@ -129,6 +131,7 @@ export class PlayableScriptsAdapter {
|
|
|
129
131
|
const outputs = await this.collectOutputs();
|
|
130
132
|
// 7. 复制产物到 PlayCraft outputDir
|
|
131
133
|
const files = await this.copyToOutputDir(outputs);
|
|
134
|
+
await this.applyChannelLifecycleBridges(files);
|
|
132
135
|
await this.runDevkitOutputValidation(files);
|
|
133
136
|
// 7. 构建元数据
|
|
134
137
|
const metadata = {
|
|
@@ -1320,6 +1323,34 @@ export class PlayableScriptsAdapter {
|
|
|
1320
1323
|
}
|
|
1321
1324
|
}
|
|
1322
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
|
+
}
|
|
1323
1354
|
getValidationChannels() {
|
|
1324
1355
|
if (this.options.validation?.channel) {
|
|
1325
1356
|
return [this.options.validation.channel];
|
|
@@ -1351,9 +1382,13 @@ export class PlayableScriptsAdapter {
|
|
|
1351
1382
|
if (artifacts.length === 1) {
|
|
1352
1383
|
return artifacts[0];
|
|
1353
1384
|
}
|
|
1385
|
+
const mappedChannel = mapChannel(channel).toLowerCase();
|
|
1354
1386
|
const direct = artifacts.find((artifact) => {
|
|
1355
1387
|
const normalized = artifact.replace(/\\/g, '/').toLowerCase();
|
|
1356
|
-
return normalized.includes(`/${channel}/`)
|
|
1388
|
+
return normalized.includes(`/${channel}/`)
|
|
1389
|
+
|| normalized.includes(`-${channel}.`)
|
|
1390
|
+
|| normalized.includes(`/${mappedChannel}/`)
|
|
1391
|
+
|| normalized.includes(`-${mappedChannel}.`);
|
|
1357
1392
|
});
|
|
1358
1393
|
return direct ?? artifacts[0];
|
|
1359
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
|
+
}
|
package/dist/platforms/bigo.js
CHANGED
|
@@ -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);
|
package/dist/platforms/unity.js
CHANGED
|
@@ -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
|
-
|
|
15
|
-
const
|
|
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 '
|
|
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'
|
|
29
|
-
setTimeout(function() { listener(
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
|
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'
|
|
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
|
|
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.
|
|
207
|
-
requiredPatternRule('mintegral', 'lifecycle.
|
|
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.
|
|
70
|
-
pattern:
|
|
71
|
-
message: 'Mintegral requires
|
|
72
|
-
recommendation: 'Call sdk.
|
|
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
|
|
81
|
-
message: 'BIGO requires
|
|
82
|
-
recommendation: 'Call
|
|
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
|
|
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,
|
|
146
|
-
evidence: trimEvidence(match
|
|
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: ['
|
|
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.
|
|
3
|
+
"version": "0.0.51",
|
|
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
|
}
|