package main import ( "archive/zip" "encoding/json" "fmt" "net/http" "os" "path/filepath" "regexp" "strings" ) type playworksConvertOptions struct { SourcePath string `json:"sourcePath"` OutputDir string `json:"outputDir"` BaseName string `json:"baseName"` Networks []string `json:"networks"` } type playworksNetworkResult struct { Network string `json:"network"` File string `json:"file"` OK bool `json:"ok"` Error string `json:"error,omitempty"` } func PlayworksPage(w http.ResponseWriter, r *http.Request) { body := `

Playworks Converter

Convert a Playworks HTML export into network-specific playable outputs.

Inputs

Source HTML (Playworks export)
Base Filename
Output Folder
Networks to generate
` w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write([]byte(Page("/playworks", "Playworks Converter", body))) } func PlayworksPickSource(w http.ResponseWriter, r *http.Request) { picked := PickFiles("Select Playworks HTML", []FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}}, false, "") if len(picked) == 0 { writeJSON(w, map[string]any{}) return } writeJSON(w, map[string]any{"path": picked[0], "dir": filepath.Dir(picked[0]), "baseName": playworksSourceBaseName(picked[0])}) } func PlayworksPickOutput(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"path": PickFolder("Select Output Folder", "")}) } func PlayworksConvert(w http.ResponseWriter, r *http.Request) { var opts playworksConvertOptions if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": err.Error()}) return } if !fileExists(opts.SourcePath) { writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": "Source file not found."}) return } if err := os.MkdirAll(opts.OutputDir, 0755); err != nil { writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": "Could not create output folder."}) return } data, err := os.ReadFile(opts.SourcePath) if err != nil { writeJSON(w, map[string]any{"results": []playworksNetworkResult{}, "error": "Could not read source: " + err.Error()}) return } results := []playworksNetworkResult{} unityPath, err := copyPlayworksUnityInput(data, opts) if err != nil { results = append(results, playworksNetworkResult{Network: "un", OK: false, Error: err.Error()}) } else { results = append(results, playworksNetworkResult{Network: "un", File: unityPath, OK: true}) } for _, network := range opts.Networks { filePath, err := convertPlayworksNetwork(string(data), opts, network) if err != nil { results = append(results, playworksNetworkResult{Network: network, OK: false, Error: err.Error()}) continue } results = append(results, playworksNetworkResult{Network: network, File: filePath, OK: true}) } writeJSON(w, map[string]any{"results": results}) } var playworksSuffixRx = regexp.MustCompile(`_(?:un|al|is|fb|gg|ap|mo|vu|mtg|tt)$`) var playworksMraidScriptRx = regexp.MustCompile(`(?i)]*\bsrc\s*=\s*["']mraid\.js["'][^>]*>\s*\s*`) var playworksExitapiScriptRx = regexp.MustCompile(`(?i)]*\bsrc\s*=\s*["']exitapi\.js["'][^>]*>\s*\s*`) var playworksHeadCloseRx = regexp.MustCompile(`(?i)`) var playworksBodyOpenRx = regexp.MustCompile(`(?i)]*>`) var playworksBodyTagRx = regexp.MustCompile(`(?i)]*)>`) var playworksHtmlCloseRx = regexp.MustCompile(`(?i)`) var playworksRemoteDebugScriptRx = regexp.MustCompile(`(?is)]*>.*?insertYourRemoteDebuggingTokenHere.*?\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)) return playworksSuffixRx.ReplaceAllString(base, "") } func playworksOutputBaseName(opts playworksConvertOptions) string { baseName := strings.TrimSpace(opts.BaseName) if baseName == "" { baseName = playworksSourceBaseName(opts.SourcePath) } baseName = strings.TrimSuffix(baseName, filepath.Ext(baseName)) baseName = filepath.Base(baseName) baseName = strings.Map(func(r rune) rune { switch r { case '<', '>', ':', '"', '/', '\\', '|', '?', '*': return '_' default: return r } }, baseName) baseName = strings.TrimSpace(baseName) if baseName == "" { return playworksSourceBaseName(opts.SourcePath) } return baseName } func convertPlayworksNetwork(htmlSrc string, opts playworksConvertOptions, network string) (string, error) { if !playworksIsSupportedNetwork(network) { return "", fmt.Errorf("unsupported Playworks target network: %s", network) } transformed := transformPlayworksHTML(htmlSrc, network) baseName := playworksOutputBaseName(opts) htmlFileName := fmt.Sprintf("%s_%s.html", baseName, network) outputDir := filepath.Join(opts.OutputDir, playworksNetworkOutputFolder(network)) if err := os.MkdirAll(outputDir, 0755); err != nil { return "", err } if playworksNeedsZip(network) { zipPath := filepath.Join(outputDir, fmt.Sprintf("%s_%s.zip", baseName, network)) if err := createSingleFileZip([]byte(transformed), "index.html", zipPath); err != nil { return "", err } return zipPath, nil } outPath := filepath.Join(outputDir, htmlFileName) if err := os.WriteFile(outPath, []byte(transformed), 0644); err != nil { return "", err } return outPath, nil } func copyPlayworksUnityInput(data []byte, opts playworksConvertOptions) (string, error) { baseName := playworksOutputBaseName(opts) outputDir := filepath.Join(opts.OutputDir, "Unity") if err := os.MkdirAll(outputDir, 0755); err != nil { return "", err } outPath := filepath.Join(outputDir, fmt.Sprintf("%s_un.html", baseName)) if err := os.WriteFile(outPath, data, 0644); err != nil { return "", err } return outPath, nil } func playworksNetworkOutputFolder(network string) string { switch network { case "al": return "Applovin" case "is": return "Ironsource" case "fb": return "Facebook" case "gg": return "GoogleAds" case "ap": return "Appreciate" case "mo": return "Moloco" case "vu": return "Vungle" case "mtg": return "Mintegral" case "tt": return "TikTok" default: return network } } func playworksNeedsZip(network string) bool { return network == "gg" || network == "ap" || network == "vu" || network == "mtg" } func playworksIsMraidNetwork(network string) bool { return network == "al" || network == "is" } func playworksIsExitapiNetwork(network string) bool { return network == "gg" || network == "ap" } func playworksIsSupportedNetwork(network string) bool { switch network { case "al", "is", "fb", "gg", "ap", "mo", "vu", "mtg", "tt": return true default: return false } } func transformPlayworksHTML(htmlSrc, network string) string { result := sanitizePlayworksHTML(htmlSrc) headInject := buildPlayworksLifecycleScript() if playworksIsMraidNetwork(network) { headInject += `` + buildPlayworksMraidComplianceScript() } else if playworksIsExitapiNetwork(network) { headInject += `` } result = injectPlayworksBeforeHeadClose(result, headInject) if network == "vu" { result = injectPlayworksAfterBodyOpen(result, ``) } else if network == "mtg" { result = addPlayworksBodyOnload(result, "gameReady()") } else if network == "tt" { result = injectPlayworksAfterBodyOpen(result, ``) } 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 trimPlayworksAfterFirstHTMLClose(htmlSrc string) string { loc := playworksHtmlCloseRx.FindStringIndex(htmlSrc) if loc == nil { return htmlSrc } 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", playworksIsExitapiNetwork(network)) 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)]*\bsrc\s*=\s*["']` + regexp.QuoteMeta(src) + `["'][^>]*>\s*\s*`) seen := false result := rx.ReplaceAllStringFunc(htmlSrc, func(match string) string { if shouldExist && !seen { seen = true return `` } return "" }) if shouldExist && !seen { result = injectPlayworksBeforeHeadClose(result, ``) } return result } func injectPlayworksBeforeHeadClose(htmlSrc, injection string) string { loc := playworksHeadCloseRx.FindStringIndex(htmlSrc) if loc != nil { return htmlSrc[:loc[0]] + injection + "\n" + htmlSrc[loc[0]:] } return injection + "\n" + htmlSrc } func injectPlayworksAfterBodyOpen(htmlSrc, injection string) string { loc := playworksBodyOpenRx.FindStringIndex(htmlSrc) if loc == nil { return injection + "\n" + htmlSrc } return htmlSrc[:loc[1]] + "\n" + injection + htmlSrc[loc[1]:] } func addPlayworksBodyOnload(htmlSrc, handler string) string { loc := playworksBodyTagRx.FindStringSubmatchIndex(htmlSrc) if loc == nil { return `` + "\n" + htmlSrc } attrs := htmlSrc[loc[2]:loc[3]] onloadStart, onloadEnd, quote, valueStart, valueEnd := findPlayworksOnloadAttr(attrs) if onloadStart == -1 { updated := `` return htmlSrc[:loc[0]] + updated + htmlSrc[loc[1]:] } value := attrs[valueStart:valueEnd] if strings.Contains(value, handler) { return htmlSrc } replacement := `onload=` + quote + value + `;` + handler + quote updatedAttrs := attrs[:onloadStart] + replacement + attrs[onloadEnd:] updatedTag := `` return htmlSrc[:loc[0]] + updatedTag + htmlSrc[loc[1]:] } func findPlayworksOnloadAttr(attrs string) (start int, end int, quote string, valueStart int, valueEnd int) { lower := strings.ToLower(attrs) idx := strings.Index(lower, "onload") for idx != -1 { if idx > 0 { prev := lower[idx-1] if (prev >= 'a' && prev <= 'z') || (prev >= '0' && prev <= '9') || prev == '-' || prev == '_' { next := strings.Index(lower[idx+len("onload"):], "onload") if next == -1 { return -1, -1, "", -1, -1 } idx += len("onload") + next continue } } pos := idx + len("onload") for pos < len(attrs) && (attrs[pos] == ' ' || attrs[pos] == '\t' || attrs[pos] == '\n' || attrs[pos] == '\r') { pos++ } if pos >= len(attrs) || attrs[pos] != '=' { return -1, -1, "", -1, -1 } pos++ for pos < len(attrs) && (attrs[pos] == ' ' || attrs[pos] == '\t' || attrs[pos] == '\n' || attrs[pos] == '\r') { pos++ } if pos >= len(attrs) || (attrs[pos] != '"' && attrs[pos] != '\'') { return -1, -1, "", -1, -1 } q := attrs[pos] valueStart = pos + 1 valueEnd = strings.IndexByte(attrs[valueStart:], q) if valueEnd == -1 { return -1, -1, "", -1, -1 } valueEnd += valueStart return idx, valueEnd + 1, string(q), valueStart, valueEnd } return -1, -1, "", -1, -1 } func replacePlayworksCTAScript(htmlSrc, network string) string { marker := "Luna.Unity.Playable.InstallFullGame=function" markerIdx := strings.LastIndex(htmlSrc, marker) if markerIdx == -1 { return htmlSrc } scriptOpenIdx := strings.LastIndex(htmlSrc[:markerIdx], "") if scriptCloseRel == -1 { return htmlSrc } scriptCloseIdx := markerIdx + scriptCloseRel return htmlSrc[:scriptOpenIdx] + buildPlayworksConsoleRestoreScript() + buildPlayworksCTAScript(network) + htmlSrc[scriptCloseIdx+len(""):] } func buildPlayworksConsoleRestoreScript() string { return strings.Join([]string{ ``, }, "") } func buildPlayworksLifecycleScript() string { return strings.Join([]string{ ``, }, "") } func buildPlayworksMraidComplianceScript() string { return strings.Join([]string{ ``, }, "") } 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": 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");` case "gg", "ap": ctaLogic = closeCall + `if(typeof ExitApi!=="undefined"&&typeof ExitApi.exit==="function"){ExitApi.exit();return;}window.open(o,"_blank");` case "mo": ctaLogic = closeCall + `if(typeof FbPlayableAd!=="undefined"&&typeof FbPlayableAd.onCTAClick==="function"){FbPlayableAd.onCTAClick();return;}if(typeof window.clickTag==="string"&&window.clickTag){window.open(window.clickTag,"_blank");return;}window.open(o,"_blank");` case "vu": ctaLogic = closeCall + `try{parent.postMessage("download","*");}catch(e){}` case "mtg": ctaLogic = closeCall + `if(typeof window.install==="function"){window.install();return;}window.open(o,"_blank");` case "tt": ctaLogic = closeCall + `if(typeof window.openAppStore==="function"){window.openAppStore();return;}window.open(o,"_blank");` } return `` } func createSingleFileZip(data []byte, nameInZip, destZipPath string) error { _ = os.Remove(destZipPath) out, err := os.Create(destZipPath) if err != nil { return err } defer out.Close() zw := zip.NewWriter(out) entry, err := zw.Create(nameInZip) if err != nil { _ = zw.Close() return err } if _, err := entry.Write(data); err != nil { _ = zw.Close() return err } return zw.Close() }