#!/usr/bin/env bun /** * ICP备案源代码导出脚本 * * 功能: * 1. 执行 expo prebuild 生成原生代码 * 2. 收集源代码文件 * 3. 按ICP备案要求导出到DOCX文档 * * 要求: * - 前后30页,共60页,每页不少于50行 * - 必须有开头结尾、最后一页应是结束页 * - 不足60页的需要全部提交 * * 使用方式: * bun run scripts/export-icp-source.ts --platform android * bun run scripts/export-icp-source.ts --platform ios * bun run scripts/export-icp-source.ts --platform android --skip-prebuild */ import { $ } from "bun"; import * as fs from "fs"; import * as path from "path"; import { Document, Packer, Paragraph, TextRun, PageBreak, Header, Footer, AlignmentType, PageNumber, NumberFormat, } from "docx"; // 配置 const CONFIG = { LINES_PER_PAGE: 50, // 每页最少行数 TOTAL_PAGES: 60, // 总页数要求 FRONT_PAGES: 30, // 前30页 BACK_PAGES: 30, // 后30页 FONT_SIZE: 18, // 字号 (half-points, 18 = 9pt) FONT_NAME: "Consolas", // 等宽字体 // 原生代码扩展名(只包含核心源码) NATIVE_EXTENSIONS: [ ".kt", ".java", // Android Kotlin/Java 源码 ".swift", ".m", ".h", // iOS Swift/ObjC 源码 ], // JS/TS 源代码扩展名 JS_EXTENSIONS: [ ".js", ".ts", ".tsx", ".jsx", ], // 排除的目录 EXCLUDE_DIRS: [ "node_modules", ".gradle", "build", "Pods", ".git", "DerivedData", "__pycache__", ".idea", ".vscode", "assets", ".expo", "dist", "scripts", ], // 排除的文件名模式 EXCLUDE_FILES: [ ".gradle", ".properties", ".xml", ".plist", ".pbxproj", ".json", ".lock", ".md", ".css", ".png", ".jpg", ], }; // 解析命令行参数 function parseArgs(): { platform: "android" | "ios"; skipPrebuild: boolean } { const args = process.argv.slice(2); let platform: "android" | "ios" = "android"; let skipPrebuild = false; for (let i = 0; i < args.length; i++) { if (args[i] === "--platform" && args[i + 1]) { const p = args[i + 1].toLowerCase(); if (p === "android" || p === "ios") { platform = p; } i++; } if (args[i] === "--skip-prebuild") { skipPrebuild = true; } } return { platform, skipPrebuild }; } // 执行 expo prebuild async function runPrebuild(platform: "android" | "ios"): Promise { console.log(`🔧 正在执行 expo prebuild --platform ${platform}...`); const projectDir = path.resolve(import.meta.dir, ".."); try { await $`bunx expo prebuild --platform ${platform} --clean`.cwd(projectDir); console.log(`✅ prebuild 完成`); } catch (error) { console.error(`❌ prebuild 失败:`, error); throw error; } } // 收集原生源代码文件(.kt, .swift 等) function collectNativeSourceFiles(dir: string, files: string[] = []): string[] { if (!fs.existsSync(dir)) { return files; } const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { if (!CONFIG.EXCLUDE_DIRS.includes(entry.name)) { collectNativeSourceFiles(fullPath, files); } } else if (entry.isFile()) { const ext = path.extname(entry.name).toLowerCase(); if (CONFIG.NATIVE_EXTENSIONS.includes(ext)) { files.push(fullPath); } } } return files; } // 收集 JS/TS 源代码文件 function collectJsSourceFiles(dir: string, files: string[] = []): string[] { if (!fs.existsSync(dir)) { return files; } const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { if (!CONFIG.EXCLUDE_DIRS.includes(entry.name)) { collectJsSourceFiles(fullPath, files); } } else if (entry.isFile()) { const ext = path.extname(entry.name).toLowerCase(); if (CONFIG.JS_EXTENSIONS.includes(ext)) { files.push(fullPath); } } } return files; } // 读取文件内容并处理(过滤空行) function readFileContent(filePath: string, baseDir: string): { relativePath: string; lines: string[] } { const content = fs.readFileSync(filePath, "utf-8"); const relativePath = path.relative(baseDir, filePath).replace(/\\/g, "/"); // 过滤空行和只有空白字符的行 const lines = content.split("\n").filter(line => line.trim().length > 0); return { relativePath, lines }; } // 将所有源代码整合成行数组 interface SourceLine { content: string; filePath: string; lineNumber: number; isFileHeader: boolean; isFileEnd: boolean; } function consolidateSourceCode(files: string[], baseDir: string): SourceLine[] { const allLines: SourceLine[] = []; for (const file of files) { const { relativePath, lines } = readFileContent(file, baseDir); // 添加文件头 allLines.push({ content: `${"=".repeat(60)}`, filePath: relativePath, lineNumber: 0, isFileHeader: true, isFileEnd: false, }); allLines.push({ content: `文件: ${relativePath}`, filePath: relativePath, lineNumber: 0, isFileHeader: true, isFileEnd: false, }); allLines.push({ content: `${"=".repeat(60)}`, filePath: relativePath, lineNumber: 0, isFileHeader: true, isFileEnd: false, }); // 添加文件内容 for (let i = 0; i < lines.length; i++) { allLines.push({ content: lines[i], filePath: relativePath, lineNumber: i + 1, isFileHeader: false, isFileEnd: i === lines.length - 1, }); } // 标记最后一行为文件结束 if (allLines.length > 0) { allLines[allLines.length - 1].isFileEnd = true; } } return allLines; } // 将行分成页 interface Page { pageNumber: number; lines: SourceLine[]; isFirstPage: boolean; isLastPage: boolean; } function paginateLines(allLines: SourceLine[], linesPerPage: number): Page[] { const pages: Page[] = []; let currentPageLines: SourceLine[] = []; let pageNumber = 1; for (const line of allLines) { currentPageLines.push(line); if (currentPageLines.length >= linesPerPage) { pages.push({ pageNumber, lines: [...currentPageLines], isFirstPage: pageNumber === 1, isLastPage: false, }); currentPageLines = []; pageNumber++; } } // 处理最后一页(可能不足 linesPerPage 行) if (currentPageLines.length > 0) { pages.push({ pageNumber, lines: currentPageLines, isFirstPage: pageNumber === 1, isLastPage: true, }); } else if (pages.length > 0) { pages[pages.length - 1].isLastPage = true; } return pages; } // 选择要导出的页面(前30页 + 后30页,或全部) function selectPages(allPages: Page[]): Page[] { const totalPages = allPages.length; if (totalPages <= CONFIG.TOTAL_PAGES) { // 不足60页,全部提交 console.log(`📄 总共 ${totalPages} 页,不足60页,将全部导出`); return allPages; } // 超过60页,取前30页和后30页 console.log(`📄 总共 ${totalPages} 页,将导出前30页和后30页`); const frontPages = allPages.slice(0, CONFIG.FRONT_PAGES); const backPages = allPages.slice(-CONFIG.BACK_PAGES); // 重新编号后30页(标记为省略后的页码) const selectedPages = [ ...frontPages, ...backPages.map((page, index) => ({ ...page, pageNumber: CONFIG.FRONT_PAGES + index + 1, })), ]; // 确保最后一页标记正确 selectedPages[selectedPages.length - 1].isLastPage = true; return selectedPages; } // 创建封面页 function createCoverPage(platform: string, totalFiles: number, totalLines: number, totalPages: number): Paragraph[] { return [ new Paragraph({ alignment: AlignmentType.CENTER, spacing: { before: 2000, after: 400 }, children: [ new TextRun({ text: "软件源代码", bold: true, size: 56, font: "SimHei", }), ], }), new Paragraph({ alignment: AlignmentType.CENTER, spacing: { before: 400, after: 200 }, children: [ new TextRun({ text: "(ICP备案材料)", size: 32, font: "SimSun", }), ], }), new Paragraph({ alignment: AlignmentType.CENTER, spacing: { before: 800, after: 200 }, children: [ new TextRun({ text: `平台: ${platform === "android" ? "Android" : "iOS"}`, size: 28, font: "SimSun", }), ], }), new Paragraph({ alignment: AlignmentType.CENTER, spacing: { before: 200, after: 200 }, children: [ new TextRun({ text: `源文件数: ${totalFiles}`, size: 28, font: "SimSun", }), ], }), new Paragraph({ alignment: AlignmentType.CENTER, spacing: { before: 200, after: 200 }, children: [ new TextRun({ text: `总行数: ${totalLines}`, size: 28, font: "SimSun", }), ], }), new Paragraph({ alignment: AlignmentType.CENTER, spacing: { before: 200, after: 200 }, children: [ new TextRun({ text: `导出页数: ${totalPages}`, size: 28, font: "SimSun", }), ], }), new Paragraph({ alignment: AlignmentType.CENTER, spacing: { before: 800, after: 200 }, children: [ new TextRun({ text: `生成时间: ${new Date().toLocaleString("zh-CN")}`, size: 24, font: "SimSun", }), ], }), new Paragraph({ children: [new PageBreak()], }), ]; } // 创建分隔页(前30页和后30页之间) function createSeparatorPage(skippedPages: number): Paragraph[] { return [ new Paragraph({ alignment: AlignmentType.CENTER, spacing: { before: 3000, after: 400 }, children: [ new TextRun({ text: "(此处省略中间部分)", bold: true, size: 36, font: "SimHei", }), ], }), new Paragraph({ alignment: AlignmentType.CENTER, spacing: { before: 400, after: 400 }, children: [ new TextRun({ text: `省略页数: ${skippedPages} 页`, size: 28, font: "SimSun", }), ], }), new Paragraph({ alignment: AlignmentType.CENTER, spacing: { before: 400, after: 400 }, children: [ new TextRun({ text: "以下为源代码最后30页内容", size: 28, font: "SimSun", }), ], }), new Paragraph({ children: [new PageBreak()], }), ]; } // 创建结束页 function createEndPage(): Paragraph[] { return [ new Paragraph({ children: [new PageBreak()], }), new Paragraph({ alignment: AlignmentType.CENTER, spacing: { before: 3000, after: 400 }, children: [ new TextRun({ text: "源代码结束", bold: true, size: 48, font: "SimHei", }), ], }), new Paragraph({ alignment: AlignmentType.CENTER, spacing: { before: 400, after: 200 }, children: [ new TextRun({ text: "— END —", size: 32, font: "SimSun", }), ], }), ]; } // 创建代码页面内容 function createCodePageContent(page: Page): Paragraph[] { const paragraphs: Paragraph[] = []; for (const line of page.lines) { let displayText = line.content; // 如果不是文件头,添加行号 if (!line.isFileHeader && line.lineNumber > 0) { const lineNumStr = String(line.lineNumber).padStart(5, " "); displayText = `${lineNumStr} | ${line.content}`; } paragraphs.push( new Paragraph({ spacing: { after: 20, line: 240 }, children: [ new TextRun({ text: displayText || " ", // 空行显示空格 size: CONFIG.FONT_SIZE, font: CONFIG.FONT_NAME, }), ], }) ); } // 添加分页符(除了最后一页) if (!page.isLastPage) { paragraphs.push( new Paragraph({ children: [new PageBreak()], }) ); } return paragraphs; } // 生成DOCX文档 async function generateDocx( platform: string, selectedPages: Page[], totalFiles: number, totalLines: number, allPagesCount: number, outputPath: string ): Promise { const allParagraphs: Paragraph[] = []; // 1. 添加封面 allParagraphs.push(...createCoverPage(platform, totalFiles, totalLines, selectedPages.length)); // 2. 添加代码页 const needsSeparator = allPagesCount > CONFIG.TOTAL_PAGES; const skippedPages = allPagesCount - CONFIG.TOTAL_PAGES; for (let i = 0; i < selectedPages.length; i++) { const page = selectedPages[i]; // 如果需要分隔页,在前30页之后添加 if (needsSeparator && i === CONFIG.FRONT_PAGES) { allParagraphs.push(...createSeparatorPage(skippedPages)); } allParagraphs.push(...createCodePageContent(page)); } // 3. 添加结束页 allParagraphs.push(...createEndPage()); // 创建文档 const doc = new Document({ sections: [ { properties: { page: { margin: { top: 720, // 0.5 inch right: 720, bottom: 720, left: 720, }, }, }, headers: { default: new Header({ children: [ new Paragraph({ alignment: AlignmentType.CENTER, children: [ new TextRun({ text: `${platform === "android" ? "Android" : "iOS"} 源代码 - ICP备案材料`, size: 20, font: "SimSun", }), ], }), ], }), }, footers: { default: new Footer({ children: [ new Paragraph({ alignment: AlignmentType.CENTER, children: [ new TextRun({ text: "第 ", size: 20, }), new TextRun({ children: [PageNumber.CURRENT], size: 20, }), new TextRun({ text: " 页", size: 20, }), ], }), ], }), }, children: allParagraphs, }, ], }); // 保存文档 const buffer = await Packer.toBuffer(doc); fs.writeFileSync(outputPath, buffer); console.log(`✅ 文档已保存到: ${outputPath}`); } // 主函数 async function main(): Promise { console.log("🚀 ICP备案源代码导出工具"); console.log("========================\n"); const { platform, skipPrebuild } = parseArgs(); const projectDir = path.resolve(import.meta.dir, ".."); console.log(`📱 目标平台: ${platform}`); console.log(`📁 项目目录: ${projectDir}\n`); // 1. 执行 prebuild(如果没有跳过) if (!skipPrebuild) { await runPrebuild(platform); } else { console.log("⏭️ 跳过 prebuild 步骤\n"); } // 2. 确定源代码目录 const nativeDir = path.join(projectDir, platform); if (!fs.existsSync(nativeDir)) { console.error(`❌ 原生代码目录不存在: ${nativeDir}`); console.error(`请先运行 expo prebuild --platform ${platform}`); process.exit(1); } // 3. 收集源代码文件 console.log("📂 正在收集源代码文件..."); // 收集原生代码 (.kt, .swift 等) const nativeFiles = collectNativeSourceFiles(nativeDir); console.log(` 原生源码: ${nativeFiles.length} 个文件 (${platform === "android" ? ".kt/.java" : ".swift/.m/.h"})`); // 收集 JS/TS 源代码(app, components, hooks, utils, ble 等目录) const jsDirectories = ["app", "components", "hooks", "utils", "ble", "constants", "@share"]; let jsFiles: string[] = []; for (const dir of jsDirectories) { const dirPath = path.join(projectDir, dir); if (fs.existsSync(dirPath)) { collectJsSourceFiles(dirPath, jsFiles); } } console.log(` JS/TS源码: ${jsFiles.length} 个文件 (.ts/.tsx/.js/.jsx)`); // 合并所有文件(JS源码在前,原生代码在后) const files = [...jsFiles, ...nativeFiles]; console.log(` 总计: ${files.length} 个源文件\n`); if (files.length === 0) { console.error("❌ 未找到任何源代码文件"); process.exit(1); } // 4. 整合所有源代码 console.log("📝 正在整合源代码..."); const allLines = consolidateSourceCode(files, projectDir); console.log(` 共 ${allLines.length} 行\n`); // 5. 分页 console.log("📄 正在分页..."); const allPages = paginateLines(allLines, CONFIG.LINES_PER_PAGE); console.log(` 共 ${allPages.length} 页\n`); // 6. 选择要导出的页面 const selectedPages = selectPages(allPages); // 7. 生成输出文件名 const timestamp = new Date().toISOString().slice(0, 10).replace(/-/g, ""); const outputFileName = `ICP备案_源代码_${platform}_${timestamp}.docx`; const outputPath = path.join(projectDir, outputFileName); // 8. 生成DOCX console.log("\n📖 正在生成DOCX文档..."); await generateDocx( platform, selectedPages, files.length, allLines.length, allPages.length, outputPath ); console.log("\n✨ 导出完成!"); console.log(` 文件: ${outputFileName}`); console.log(` 页数: ${selectedPages.length + 2} (含封面和结束页)`); console.log(` 源文件数: ${files.length}`); console.log(` 总行数: ${allLines.length}`); } // 运行 main().catch((error) => { console.error("❌ 导出失败:", error); process.exit(1); });