update playworks checker

This commit is contained in:
2026-05-28 14:17:26 +08:00
parent 4f17523f26
commit cce30c0729
20 changed files with 616 additions and 54 deletions

View File

@@ -0,0 +1,90 @@
package main
import (
"archive/zip"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
func TestDogCat3MraidChecker(t *testing.T) {
root := filepath.Join("..", "aiAssets", "DogCat3")
files, err := collectDogCat3MraidTargets(root)
if err != nil {
t.Fatalf("collect targets: %v", err)
}
if len(files) == 0 {
t.Fatalf("no DogCat3 MRAID targets found")
}
for _, file := range files {
t.Run(filepath.Base(file), func(t *testing.T) {
html, displayPath, err := readDogCat3HTML(file)
if err != nil {
t.Fatalf("read target: %v", err)
}
issues := checkMraid(html, displayPath)
if len(issues) == 0 {
return
}
var parts []string
for _, issue := range issues {
parts = append(parts, issue.ID+": "+issue.Title+" ("+issue.Severity+")")
}
t.Fatalf("MRAID checker issues:\n%s", strings.Join(parts, "\n"))
})
}
}
func collectDogCat3MraidTargets(root string) ([]string, error) {
var out []string
for _, folder := range []string{"Applovin", "Ironsource", "Unity"} {
dir := filepath.Join(root, folder)
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := strings.ToLower(entry.Name())
if strings.HasSuffix(name, ".html") || strings.HasSuffix(name, ".htm") || strings.HasSuffix(name, ".zip") {
out = append(out, filepath.Join(dir, entry.Name()))
}
}
}
return out, nil
}
func readDogCat3HTML(file string) (html string, displayPath string, err error) {
if strings.HasSuffix(strings.ToLower(file), ".zip") {
zr, err := zip.OpenReader(file)
if err != nil {
return "", "", err
}
defer zr.Close()
for _, entry := range zr.File {
if strings.EqualFold(entry.Name, "index.html") {
rc, err := entry.Open()
if err != nil {
return "", "", err
}
defer rc.Close()
data, err := io.ReadAll(rc)
if err != nil {
return "", "", err
}
return string(data), file + "#index.html", nil
}
}
return "", "", os.ErrNotExist
}
data, err := os.ReadFile(file)
if err != nil {
return "", "", err
}
return string(data), file, nil
}

View File

@@ -82,6 +82,7 @@ func PlayworksPage(w http.ResponseWriter, r *http.Request) {
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="mo" checked /><span class="net-label">Moloco <span class="net-tag">mo</span></span><span class="net-note">HTML + FbPlayableAd CTA</span></label>
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="vu" checked /><span class="net-label">Vungle <span class="net-tag">vu</span></span><span class="net-note">ZIP + __VUNGLE__ flag</span></label>
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="mtg" checked /><span class="net-label">Mintegral <span class="net-tag">mtg</span></span><span class="net-note">ZIP + onload gameReady</span></label>
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="un" checked /><span class="net-label">Unity <span class="net-tag">un</span></span><span class="net-note">HTML + mraid.js in head</span></label>
<label class="net-row"><input type="checkbox" class="net-cb" data-tag="tt" /><span class="net-label">TikTok <span class="net-tag">tt</span></span><span class="net-note">HTML + __TIKTOK__ flag</span></label>
</div>
</div>
@@ -234,6 +235,12 @@ var playworksExitapiScriptRx = regexp.MustCompile(`(?i)<script\b[^>]*\bsrc\s*=\s
var playworksHeadCloseRx = regexp.MustCompile(`(?i)</head>`)
var playworksBodyOpenRx = regexp.MustCompile(`(?i)<body\b[^>]*>`)
var playworksBodyTagRx = regexp.MustCompile(`(?i)<body\b([^>]*)>`)
var playworksHtmlCloseRx = regexp.MustCompile(`(?i)</html\s*>`)
var playworksRemoteDebugScriptRx = regexp.MustCompile(`(?is)<script\b[^>]*>.*?insertYourRemoteDebuggingTokenHere.*?</script>\s*`)
var playworksStatsURLRx = regexp.MustCompile(`(?i)https://mrdoob\.github\.io/stats\.js/build/stats\.min\.js`)
var playworksCrossoriginRx = regexp.MustCompile(`(?i)\s+crossorigin(?:\s*=\s*("[^"]*"|'[^']*'|[^\s>]+))?`)
var playworksTypeModuleRx = regexp.MustCompile(`(?i)\s+type\s*=\s*["']module["']`)
var playworksConsoleErrorRx = regexp.MustCompile(`\bconsole\.error\s*\(`)
func playworksSourceBaseName(sourcePath string) string {
base := strings.TrimSuffix(filepath.Base(sourcePath), filepath.Ext(sourcePath))
@@ -305,6 +312,8 @@ func playworksNetworkOutputFolder(network string) string {
return "Vungle"
case "mtg":
return "Mintegral"
case "un":
return "Unity"
case "tt":
return "TikTok"
default:
@@ -317,12 +326,12 @@ func playworksNeedsZip(network string) bool {
}
func playworksIsMraidNetwork(network string) bool {
return network == "al" || network == "is"
return network == "al" || network == "is" || network == "un"
}
func playworksIsSupportedNetwork(network string) bool {
switch network {
case "al", "is", "fb", "gg", "mo", "vu", "mtg", "tt":
case "al", "is", "fb", "gg", "mo", "vu", "mtg", "un", "tt":
return true
default:
return false
@@ -330,11 +339,10 @@ func playworksIsSupportedNetwork(network string) bool {
}
func transformPlayworksHTML(htmlSrc, network string) string {
result := htmlSrc
result = removePlayworksNetworkScripts(result, network)
result := sanitizePlayworksHTML(htmlSrc)
headInject := buildPlayworksLifecycleScript()
if playworksIsMraidNetwork(network) {
headInject += `<script src="mraid.js"></script>`
headInject += `<script src="mraid.js"></script>` + buildPlayworksMraidComplianceScript()
} else if network == "gg" {
headInject += `<script src="exitapi.js"></script>`
}
@@ -349,23 +357,64 @@ func transformPlayworksHTML(htmlSrc, network string) string {
}
result = replacePlayworksCTAScript(result, network)
return finalizePlayworksNetworkHTML(result, network)
}
func sanitizePlayworksHTML(htmlSrc string) string {
return removePlayworksNetworkScripts(cleanupPlayworksHTML(htmlSrc))
}
func cleanupPlayworksHTML(htmlSrc string) string {
result := trimPlayworksAfterFirstHTMLClose(htmlSrc)
result = playworksRemoteDebugScriptRx.ReplaceAllString(result, "")
result = playworksStatsURLRx.ReplaceAllString(result, "data:text/javascript,")
result = playworksCrossoriginRx.ReplaceAllString(result, "")
result = playworksTypeModuleRx.ReplaceAllString(result, "")
result = playworksConsoleErrorRx.ReplaceAllString(result, "console.warn(")
return result
}
func removePlayworksNetworkScripts(htmlSrc, network string) string {
result := htmlSrc
if playworksIsMraidNetwork(network) {
result = playworksMraidScriptRx.ReplaceAllString(result, "")
func trimPlayworksAfterFirstHTMLClose(htmlSrc string) string {
loc := playworksHtmlCloseRx.FindStringIndex(htmlSrc)
if loc == nil {
return htmlSrc
}
if network == "gg" {
result = playworksExitapiScriptRx.ReplaceAllString(result, "")
return htmlSrc[:loc[1]]
}
func finalizePlayworksNetworkHTML(htmlSrc, network string) string {
result := cleanupPlayworksHTML(htmlSrc)
result = dedupePlayworksScriptSrc(result, "mraid.js", playworksIsMraidNetwork(network))
result = dedupePlayworksScriptSrc(result, "exitapi.js", network == "gg")
return result
}
func removePlayworksNetworkScripts(htmlSrc string) string {
result := playworksMraidScriptRx.ReplaceAllString(htmlSrc, "")
result = playworksExitapiScriptRx.ReplaceAllString(result, "")
return result
}
func dedupePlayworksScriptSrc(htmlSrc, src string, shouldExist bool) string {
rx := regexp.MustCompile(`(?i)<script\b[^>]*\bsrc\s*=\s*["']` + regexp.QuoteMeta(src) + `["'][^>]*>\s*</script>\s*`)
seen := false
result := rx.ReplaceAllStringFunc(htmlSrc, func(match string) string {
if shouldExist && !seen {
seen = true
return `<script src="` + src + `"></script>`
}
return ""
})
if shouldExist && !seen {
result = injectPlayworksBeforeHeadClose(result, `<script src="`+src+`"></script>`)
}
return result
}
func injectPlayworksBeforeHeadClose(htmlSrc, injection string) string {
if playworksHeadCloseRx.MatchString(htmlSrc) {
return playworksHeadCloseRx.ReplaceAllString(htmlSrc, injection+"\n</head>")
loc := playworksHeadCloseRx.FindStringIndex(htmlSrc)
if loc != nil {
return htmlSrc[:loc[0]] + injection + "\n" + htmlSrc[loc[0]:]
}
return injection + "\n" + htmlSrc
}
@@ -489,12 +538,34 @@ func buildPlayworksLifecycleScript() string {
}, "")
}
func buildPlayworksMraidComplianceScript() string {
return strings.Join([]string{
`<script>(function(){`,
`var m=null,scene=null,viewable=true,exposed=true,volume=null;`,
`function get(){return window.mraid||m;}`,
`function emit(name,detail){try{window.dispatchEvent(new CustomEvent(name,{detail:detail}));}catch(e){}}`,
`function apply(){emit("hpl:mraid:visibility",{viewable:viewable,exposed:exposed,hidden:!viewable||!exposed});if(scene&&scene.sound&&typeof scene.sound.setMute==="function")scene.sound.setMute(!viewable||!exposed);}`,
`function onViewable(v){viewable=!!v;apply();}`,
`function onExposure(p){if(typeof p==="number")exposed=p>0;apply();}`,
`function onVolume(v){if(typeof v==="number"){volume=v/100;emit("hpl:mraid:volume",volume);if(scene&&scene.sound&&typeof scene.sound.setVolume==="function")scene.sound.setVolume(volume);}}`,
`function add(name,fn){var x=get();try{if(x&&typeof x.addEventListener==="function")x.addEventListener(name,fn);}catch(e){}}`,
`function unload(){try{if(typeof mraid!=="undefined"&&typeof mraid.unload==="function")mraid.unload();}catch(e){}}`,
`window.__hplMraidUnload=unload;`,
`function setup(){var x=get();if(!x||setup.done)return;setup.done=true;m=x;try{if(typeof mraid!=="undefined"&&typeof mraid.isViewable==="function")viewable=!!mraid.isViewable();else if(typeof x.isViewable==="function")viewable=!!x.isViewable();}catch(e){}try{if(typeof mraid!=="undefined"&&typeof mraid.addEventListener==="function"){mraid.addEventListener("error",function(message,action){console.warn("[MRAID error]",{message:message,action:action});});mraid.addEventListener("stateChange",function(state){console.log("[MRAID stateChange]",state);});mraid.addEventListener("exposureChange",onExposure);mraid.addEventListener("viewableChange",onViewable);mraid.addEventListener("audioVolumeChange",onVolume);apply();return;}}catch(e){}add("error",function(message,action){console.warn("[MRAID error]",{message:message,action:action});});add("stateChange",function(state){console.log("[MRAID stateChange]",state);});add("exposureChange",onExposure);add("viewableChange",onViewable);add("audioVolumeChange",onVolume);apply();}`,
`function ready(){var x=get();if(!x)return false;try{if(typeof mraid!=="undefined"&&typeof mraid.getState==="function"&&mraid.getState()==="loading"){mraid.addEventListener("ready",setup);return true;}if(typeof x.getState==="function"&&x.getState()==="loading"){add("ready",setup);return true;}}catch(e){}setup();return true;}`,
`window.__hplMraidBindScene=function(s){scene=s;apply();if(typeof volume==="number"&&scene&&scene.sound&&typeof scene.sound.setVolume==="function")scene.sound.setVolume(volume);};`,
`if(!ready()){var end=Date.now()+500;(function poll(){if(ready()||Date.now()>end)return;setTimeout(poll,50);})();}`,
`setTimeout(setup,2000);`,
`})();</script>`,
}, "")
}
func buildPlayworksCTAScript(network string) string {
urlSetup := `n=n||window.$environment.packageConfig.iosLink,i=i||window.$environment.packageConfig.androidLink;var o=/iphone|ipad|ipod|macintosh/i.test(window.navigator.userAgent.toLowerCase())?n:i;`
closeCall := `try{window.gameClose();}catch(e){}`
ctaLogic := closeCall + `window.open(o,"_blank");`
switch network {
case "al", "is":
case "al", "is", "un":
ctaLogic = closeCall + `if(typeof mraid!=="undefined"&&typeof mraid.open==="function"){var s=typeof mraid.getState==="function"?mraid.getState():"default";if(s!=="loading"){mraid.open(o);return;}}window.open(o,"_blank");`
case "fb":
ctaLogic = closeCall + `if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}window.open(o,"_blank");`

View File

@@ -0,0 +1,32 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestExportPlayworksNetworksForRuntimeCheck(t *testing.T) {
outDir := os.Getenv("PLAYWORKS_EXPORT_DIR")
if outDir == "" {
t.Skip("PLAYWORKS_EXPORT_DIR not set")
}
sourcePath := filepath.Join("..", "aiAssets", "Playworks_UnityNetwork.html")
data, err := os.ReadFile(sourcePath)
if err != nil {
t.Fatalf("read source: %v", err)
}
opts := playworksConvertOptions{
SourcePath: sourcePath,
OutputDir: outDir,
BaseName: "wat_mip_grhpl_dogcat3_05_real_na_noseason_en_full_na",
}
for _, network := range []string{"al", "fb", "gg", "is", "mo", "mtg", "un", "vu"} {
if _, err := convertPlayworksNetwork(string(data), opts, network); err != nil {
t.Fatalf("convert %s: %v", network, err)
}
}
}

View File

@@ -0,0 +1,75 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestTransformPlayworksHTMLNetworkRequirements(t *testing.T) {
sourcePath := filepath.Join("..", "aiAssets", "Playworks_UnityNetwork.html")
data, err := os.ReadFile(sourcePath)
if err != nil {
t.Fatalf("read source: %v", err)
}
tests := []struct {
network string
wantMraid int
wantExitapi int
}{
{network: "al", wantMraid: 1},
{network: "is", wantMraid: 1},
{network: "un", wantMraid: 1},
{network: "gg", wantExitapi: 1},
{network: "fb"},
{network: "mo"},
{network: "vu"},
{network: "mtg"},
}
for _, tt := range tests {
t.Run(tt.network, func(t *testing.T) {
html := transformPlayworksHTML(string(data), tt.network)
assertCount(t, html, `src="mraid.js"`, tt.wantMraid)
assertCount(t, html, `src="exitapi.js"`, tt.wantExitapi)
assertCount(t, strings.ToLower(html), "</html>", 1)
assertCount(t, strings.ToLower(html), "</head>", 1)
for _, forbidden := range []string{
`type="module"`,
`crossorigin`,
`console.error(`,
`stats.min.js`,
`insertYourRemoteDebuggingTokenHere`,
`console.re/connector.js`,
} {
if strings.Contains(html, forbidden) {
t.Fatalf("forbidden content remains: %s", forbidden)
}
}
for _, required := range []string{"gameReady", "gameStart", "gameEnd", "gameClose"} {
if !strings.Contains(html, required) {
t.Fatalf("missing lifecycle symbol: %s", required)
}
}
if tt.wantMraid == 1 {
for _, required := range []string{"exposureChange", "viewableChange", "audioVolumeChange", "[MRAID error]", "stateChange"} {
if !strings.Contains(html, required) {
t.Fatalf("missing MRAID requirement marker: %s", required)
}
}
}
})
}
}
func assertCount(t *testing.T, haystack, needle string, want int) {
t.Helper()
if got := strings.Count(haystack, needle); got != want {
t.Fatalf("%q count = %d, want %d", needle, got, want)
}
}