@playcraft/devkit 1.0.21 → 1.0.23

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.
@@ -96,10 +96,11 @@ const REMOTE_CONFIG_REPO="https://git.woa.com/maclerylin/playable-build-config.g
96
96
  *
97
97
  * @param {string} appName - 应用名称,对应远程仓库中的文件夹名
98
98
  * @returns {Promise<object|null>} 远程配置对象,失败返回 null
99
- */async function fetchRemoteConfig(appName){if(!appName)return null;const https=require("https");const http=require("http");const url=require("url");// 构建远程配置文件 URL
100
- const{API_BASE_URL}=require("./constants");const configUrl=`${API_BASE_URL}/aix/playable-build-config/${encodeURIComponent(appName)}/build.config.js`;log(`尝试从远程 URL 获取配置: ${appName}`,"info");log(`URL: ${configUrl}`,"debug");try{// 发起 HTTP 请求获取配置文件
101
- const configContent=await new Promise((resolve,reject)=>{const parsedUrl=url.parse(configUrl);const protocol=parsedUrl.protocol==="https:"?https:http;const request=protocol.get(configUrl,response=>{if(response.statusCode!==200){reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`));return}let data="";response.on("data",chunk=>data+=chunk);response.on("end",()=>resolve(data))});request.on("error",reject);request.setTimeout(30000,()=>{request.destroy();reject(new Error("Request timeout"))})});log("\u2713 \u8FDC\u7A0B\u914D\u7F6E\u6587\u4EF6\u4E0B\u8F7D\u6210\u529F","success");// 使用 vm 模块执行配置文件,避免 require 缓存问题
102
- const vm=require("vm");const sandbox={module:{exports:{}},exports:{},require:require};sandbox.exports=sandbox.module.exports;try{vm.runInNewContext(configContent,sandbox);log(`✓ 成功加载远程配置: ${appName}`,"success");return sandbox.module.exports}catch(parseError){log(`解析远程配置文件失败: ${parseError.message}`,"error");log(`配置内容预览: ${configContent.substring(0,200)}...`,"debug");return null}}catch(error){log(`获取远程配置失败: ${error.message}`,"warn");return null}}/**
99
+ */async function fetchRemoteConfig(appName){if(!appName)return null;const https=require("https");const http=require("http");const url=require("url");const{API_BASE_URL,API_BASE_URL_FALLBACK}=require("./constants");const configPath=`/aix/playable-build-config/${encodeURIComponent(appName)}/build.config.js`;// 主/备域名 fallback 列表
100
+ const configUrls=[`${API_BASE_URL}${configPath}`,`${API_BASE_URL_FALLBACK}${configPath}`];log(`尝试从远程 URL 获取配置: ${appName}`,"info");let configContent=null;let lastError=null;// 遍历 URL 列表,主域名失败则尝试备用
101
+ for(const configUrl of configUrls){log(`URL: ${configUrl}`,"debug");try{configContent=await new Promise((resolve,reject)=>{const parsedUrl=url.parse(configUrl);const protocol=parsedUrl.protocol==="https:"?https:http;const request=protocol.get(configUrl,response=>{if(response.statusCode!==200){reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`));return}let data="";response.on("data",chunk=>data+=chunk);response.on("end",()=>resolve(data))});request.on("error",reject);request.setTimeout(30000,()=>{request.destroy();reject(new Error("Request timeout"))})});// 请求成功,跳出循环
102
+ break}catch(error){lastError=error;if(configUrl!==configUrls[configUrls.length-1]){log(`主域名请求失败,尝试备用域名...`,"warn");continue}}}if(!configContent){var _lastError;log(`获取远程配置失败: ${((_lastError=lastError)===null||_lastError===void 0?void 0:_lastError.message)||"\u672A\u77E5\u9519\u8BEF"}`,"warn");return null}log("\u2713 \u8FDC\u7A0B\u914D\u7F6E\u6587\u4EF6\u4E0B\u8F7D\u6210\u529F","success");// 使用 vm 模块执行配置文件,避免 require 缓存问题
103
+ const vm=require("vm");const sandbox={module:{exports:{}},exports:{},require:require};sandbox.exports=sandbox.module.exports;try{vm.runInNewContext(configContent,sandbox);log(`✓ 成功加载远程配置: ${appName}`,"success");return sandbox.module.exports}catch(parseError){log(`解析远程配置文件失败: ${parseError.message}`,"error");log(`配置内容预览: ${configContent.substring(0,200)}...`,"debug");return null}}/**
103
104
  * 加载配置文件
104
105
  *
105
106
  * 优先级: 本地 builds.config.js > 远程配置 > build.json > 默认值
package/core/constants.js CHANGED
@@ -3,4 +3,4 @@
3
3
  * 全局常量配置
4
4
  *
5
5
  * 集中管理服务端 API 地址、构建参数等常量,方便切换环境。
6
- *//** 服务端 API 基础地址 */const API_BASE_URL="http://9.135.99.122";module.exports={API_BASE_URL};
6
+ *//** 服务端 API 基础地址(主) */const API_BASE_URL="http://9.135.99.122";/** 服务端 API 基础地址(备用),主地址不可用时 fallback */const API_BASE_URL_FALLBACK="https://wd-act.woa.com";module.exports={API_BASE_URL,API_BASE_URL_FALLBACK};
@@ -3,9 +3,17 @@
3
3
  * COS 上传模块
4
4
  *
5
5
  * 功能:上传文件到腾讯云 COS,并刷新 CDN 缓存
6
- */const COS=require("cos-nodejs-sdk-v5");const fs=require("fs");const path=require("path");const http=require("http");const{API_BASE_URL}=require("./constants");/**
7
- * 从远程 API 获取临时凭证
8
- */async function fetchTemporaryCredentials(){const credentialsUrl=`${API_BASE_URL}/aix/playable/api/cos/credentials`;return new Promise((resolve,reject)=>{http.get(credentialsUrl,response=>{if(response.statusCode!==200){reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`));return}let data="";response.on("data",chunk=>data+=chunk);response.on("end",()=>{try{const result=JSON.parse(data);if(result.code==="SUCCESS"){resolve(result.data)}else{reject(new Error(result.message||"Failed to get credentials"))}}catch(error){reject(new Error(`Failed to parse response: ${error.message}`))}})}).on("error",reject).setTimeout(30000,function(){this.destroy();reject(new Error("Request timeout"))})})}/**
6
+ */const COS=require("cos-nodejs-sdk-v5");const fs=require("fs");const path=require("path");const http=require("http");const https=require("https");const{API_BASE_URL,API_BASE_URL_FALLBACK}=require("./constants");/**
7
+ * 通用 API 请求(自动适配 http/https,支持失败 fallback)
8
+ * @param {string} path - API 路径,如 '/aix/playable/api/cos/credentials'
9
+ * @param {string} method - 请求方法 GET/POST
10
+ * @param {Object|null} postData - POST 请求体(JSON 对象)
11
+ * @returns {Promise<Object>} - 解析后的 JSON 响应
12
+ */async function apiRequest(path,method="GET",postData=null){const urls=[`${API_BASE_URL}${path}`,`${API_BASE_URL_FALLBACK}${path}`];let lastError=null;for(const url of urls){try{const result=await new Promise((resolve,reject)=>{const isHttps=url.startsWith("https");const transport=isHttps?https:http;const doRequest=res=>{if(res.statusCode!==200){reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));return}let data="";res.on("data",chunk=>data+=chunk);res.on("end",()=>{try{const result=JSON.parse(data);if(result.code==="SUCCESS"){resolve(result)}else{reject(new Error(result.message||"API request failed"))}}catch(error){reject(new Error(`Failed to parse response: ${error.message}`))}})};let req;if(method==="POST"&&postData){const body=JSON.stringify(postData);req=transport.request(url,{method:"POST",headers:{"Content-Type":"application/json","Content-Length":Buffer.byteLength(body)}},doRequest);req.write(body)}else{req=transport.get(url,doRequest)}req.on("error",reject);req.setTimeout(30000,function(){this.destroy();reject(new Error("Request timeout"))});if(method==="GET"){req.end()}else if(method==="POST"&&!postData){req.end()}});// 成功则直接返回
13
+ return result}catch(error){lastError=error;// 如果还有备用 URL,继续尝试
14
+ if(url!==urls[urls.length-1]){console.warn(`⚠️ 主域名请求失败 (${url}), 尝试备用域名...`);continue}}}throw lastError||new Error("All API endpoints failed")}/**
15
+ * 从远程 API 获取临时凭证(支持主/备域名 fallback)
16
+ */async function fetchTemporaryCredentials(){const result=await apiRequest("/aix/playable/api/cos/credentials");return result.data}/**
9
17
  * 创建 COS 客户端(使用临时凭证)
10
18
  * @param {Object} cosConfig - COS 配置
11
19
  * @param {Object} credentials - 已获取的临时凭证(可选,避免重复请求)
@@ -20,8 +28,8 @@ if(!credentials){credentials=await fetchTemporaryCredentials()}return new COS({g
20
28
  * @param {Object} credentials - 未使用,保留参数兼容性
21
29
  * @returns {Promise<void>}
22
30
  */async function purgeCDNCache(cosConfig,urls,credentials=null){if(!urls||urls.length===0){return}const area=(cosConfig===null||cosConfig===void 0?void 0:cosConfig.CdnArea)||"overseas";// 如果配置中有 SecretId/SecretKey,本地直接刷新
23
- if(cosConfig&&cosConfig.SecretId&&cosConfig.SecretKey){try{const tencentcloud=require("tencentcloud-sdk-nodejs-cdn");const client=new tencentcloud.cdn.v20180606.Client({credential:{secretId:cosConfig.SecretId,secretKey:cosConfig.SecretKey},region:"",profile:{httpProfile:{endpoint:"cdn.tencentcloudapi.com"}}});const batchSize=1000;for(let i=0;i<urls.length;i+=batchSize){const batch=urls.slice(i,i+batchSize);await client.PurgeUrlsCache({Urls:batch,Area:area});console.log(`✓ CDN 缓存已刷新 ${batch.length} 个 URL (区域: ${area})`)}}catch(error){if(error.code==="MODULE_NOT_FOUND"){console.warn("\u26A0\uFE0F \u672A\u5B89\u88C5 tencentcloud-sdk-nodejs-cdn\uFF0C\u8DF3\u8FC7 CDN \u5237\u65B0")}else{throw error}}return}// 否则通过服务端 API 代理刷新
24
- const purgeUrl=`${API_BASE_URL}/aix/playable/api/cdn/purge`;const postData=JSON.stringify({urls,area});return new Promise((resolve,reject)=>{const req=http.request(purgeUrl,{method:"POST",headers:{"Content-Type":"application/json","Content-Length":Buffer.byteLength(postData)}},response=>{let data="";response.on("data",chunk=>data+=chunk);response.on("end",()=>{try{const result=JSON.parse(data);if(result.code==="SUCCESS"){console.log(`✓ CDN 缓存已刷新 ${urls.length} 个 URL (区域: ${area})`);resolve()}else{reject(new Error(result.message||"CDN purge failed"))}}catch(error){reject(new Error(`Failed to parse CDN purge response: ${error.message}`))}})});req.on("error",reject);req.setTimeout(30000,function(){this.destroy();reject(new Error("CDN purge request timeout"))});req.write(postData);req.end()})}/**
31
+ if(cosConfig&&cosConfig.SecretId&&cosConfig.SecretKey){try{const tencentcloud=require("tencentcloud-sdk-nodejs-cdn");const client=new tencentcloud.cdn.v20180606.Client({credential:{secretId:cosConfig.SecretId,secretKey:cosConfig.SecretKey},region:"",profile:{httpProfile:{endpoint:"cdn.tencentcloudapi.com"}}});const batchSize=1000;for(let i=0;i<urls.length;i+=batchSize){const batch=urls.slice(i,i+batchSize);await client.PurgeUrlsCache({Urls:batch,Area:area});console.log(`✓ CDN 缓存已刷新 ${batch.length} 个 URL (区域: ${area})`)}}catch(error){if(error.code==="MODULE_NOT_FOUND"){console.warn("\u26A0\uFE0F \u672A\u5B89\u88C5 tencentcloud-sdk-nodejs-cdn\uFF0C\u8DF3\u8FC7 CDN \u5237\u65B0")}else{throw error}}return}// 否则通过服务端 API 代理刷新(支持主/备域名 fallback)
32
+ try{await apiRequest("/aix/playable/api/cdn/purge","POST",{urls,area});console.log(`✓ CDN 缓存已刷新 ${urls.length} 个 URL (区域: ${area})`)}catch(error){throw new Error(`CDN purge failed: ${error.message}`)}}/**
25
33
  * 上传单个文件到 COS
26
34
  *
27
35
  * @param {Object} cosConfig - COS 配置
@@ -64,9 +64,13 @@ const linkRegex=/<link\s+[^>]*?\/?>/gi;while((match=linkRegex.exec(headHtml))!==
64
64
  * @param {string} headContent - 从原始 HTML 提取的 head 内容
65
65
  * @returns {Promise<string>} 完整的自解压 HTML
66
66
  */async generateDecompressHtml(compressedBase64,fflateLibCode,headContent=""){// 可读的解压代码(保持格式以便维护)
67
- const decompressCodeReadable=`
67
+ // hasHeadContent: 标记 extractHeadContent 是否从原始 HTML 提取了外部资源引用
68
+ // - true: 原始 HTML 有 <head>,外部资源已提取到模板 head,避免解压时重复
69
+ // - false: 原始 HTML 是片段(如 Adikteev),外部资源在压缩数据中,需要完整恢复
70
+ const hasHeadContent=headContent.length>0;const decompressCodeReadable=`
68
71
  (function() {
69
72
  var compressedData = '${compressedBase64}';
73
+ var __hasHeadContent = ${hasHeadContent};
70
74
 
71
75
  try {
72
76
  // Base64 解码为 Uint8Array
@@ -85,20 +89,39 @@ const decompressCodeReadable=`
85
89
  // 替换 body
86
90
  document.body.replaceWith(doc.body);
87
91
 
88
- // 恢复 head 中的内联 style 和内联 script
89
- // (meta、title、外部 script、link 标签已在自解压模板的 head 中保留,无需重复添加)
92
+ // 恢复 head 中的资源引用和内联脚本
93
+ // 如果原始 HTML 有 <head>(__hasHeadContent=true),外部资源已由 extractHeadContent
94
+ // 提取并放在模板 head 中,这里只需追加未被模板覆盖的内联脚本和样式
95
+ // 如果原始 HTML 是片段(__hasHeadContent=false),如 Adikteev 格式,
96
+ // 所有资源引用都在压缩数据中,需要完整恢复——包括外部脚本和样式表
90
97
  Array.from(doc.head.children).forEach(function(child) {
91
- if (child.tagName === 'SCRIPT' && !child.src && child.textContent) {
92
- // 仅恢复内联 script(有内容但无 src 的)
93
- var script = document.createElement('script');
94
- script.textContent = child.textContent;
95
- if (child.type) {
96
- script.type = child.type;
98
+ if (child.tagName === 'SCRIPT') {
99
+ if (!child.src && child.textContent) {
100
+ // 内联 script(有内容但无 src)——始终需要恢复
101
+ var script = document.createElement('script');
102
+ script.textContent = child.textContent;
103
+ if (child.type) script.type = child.type;
104
+ document.head.appendChild(script);
105
+ } else if (child.src && !__hasHeadContent) {
106
+ // 外部 script(有 src 属性,如 <script src="creative.js">)
107
+ // 仅在原始 HTML 无 <head> 时恢复,避免与模板中已提取的重复
108
+ var extScript = document.createElement('script');
109
+ extScript.src = child.src;
110
+ if (child.type) extScript.type = child.type;
111
+ if (child.async) extScript.async = true;
112
+ if (child.defer) extScript.defer = true;
113
+ document.head.appendChild(extScript);
97
114
  }
98
- document.head.appendChild(script);
99
115
  } else if (child.tagName === 'STYLE') {
100
- // 恢复内联 style
116
+ // 内联 style——始终需要恢复
101
117
  document.head.appendChild(child.cloneNode(true));
118
+ } else if (child.tagName === 'LINK' && /stylesheet/i.test(child.rel) && !__hasHeadContent) {
119
+ // 外部样式表(如 <link rel="stylesheet" href="style.css">)
120
+ // 仅在原始 HTML 无 <head> 时恢复,避免与模板中已提取的重复
121
+ var link = document.createElement('link');
122
+ link.rel = 'stylesheet';
123
+ link.href = child.href;
124
+ document.head.appendChild(link);
102
125
  }
103
126
  });
104
127
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/devkit",
3
- "version": "1.0.21",
3
+ "version": "1.0.23",
4
4
  "description": "HTML5 Playable Ads 构建工具,支持多广告渠道打包",
5
5
  "main": "core/index.js",
6
6
  "module": "core/index.js",