expo-duooomi-app/scripts/export-icp-source.ts

688 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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<void> {
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<void> {
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<void> {
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);
});