@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.
@@ -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', 'mintegral']);
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}/`) || 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
+ }
@@ -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.49",
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
  }