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{} 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|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)]*)>`) 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 playworksNetworkOutputFolder(network string) string { switch network { case "al": return "Applovin" case "is": return "Ironsource" case "fb": return "Facebook" case "gg": return "GoogleAds" 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 == "vu" || network == "mtg" } func playworksIsMraidNetwork(network string) bool { return network == "al" || network == "is" } func playworksIsSupportedNetwork(network string) bool { switch network { case "al", "is", "fb", "gg", "mo", "vu", "mtg", "tt": return true default: return false } } func transformPlayworksHTML(htmlSrc, network string) string { result := htmlSrc result = removePlayworksNetworkScripts(result, network) headInject := buildPlayworksLifecycleScript() if playworksIsMraidNetwork(network) { headInject += `` } else if network == "gg" { 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 result } func removePlayworksNetworkScripts(htmlSrc, network string) string { result := htmlSrc if playworksIsMraidNetwork(network) { result = playworksMraidScriptRx.ReplaceAllString(result, "") } if network == "gg" { result = playworksExitapiScriptRx.ReplaceAllString(result, "") } return result } func injectPlayworksBeforeHeadClose(htmlSrc, injection string) string { if playworksHeadCloseRx.MatchString(htmlSrc) { return playworksHeadCloseRx.ReplaceAllString(htmlSrc, injection+"\n") } 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 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": 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() }