1 line
67 KiB
JSON
1 line
67 KiB
JSON
{"repo": "bi4vmr/study-kotlin", "files": {"M01_Overview/C00_Temporary/src/main/kotlin/net/bi4vmr/study/LogStatV2.kt": {"3aeef3f39fae6a43d972e15b7f56d640372a6de4": {"t": 1785745718.5934243, "n": 25338, "h": "d576e73b848f708a", "c": "package net.bi4vmr.study\n\nimport java.io.File\n\n// 修改此路径以指向目标日志文件或目录\n// private const val LOG_FILE_PATH = \"/home/bi4vmr/Downloads/logcat.log\"\nprivate const val LOG_FILE_PATH = \"/home/bi4vmr/Downloads/logcat\"\n\n// 统计维度:BY_PACKAGE 按包名统计;BY_TAG 按日志TAG统计\nprivate val STAT_MODE = StatMode.BY_PACKAGE\n\n// 统计目录时,只处理匹配该扩展名的文件(忽略大小写)\nprivate val LOG_FILE_EXTENSIONS = setOf(\"log\", \"txt\")\n\n// 匹配 \"Start proc <PID>:<packageName>/...\" 格式\nprivate val START_PROC_REGEX = Regex(\"\"\"Start proc (\\d+):([^/\\s]+)\"\"\")\n\n// 匹配 \"Process: <packageName>, PID: <PID>\" 格式(Crash日志)\nprivate val CRASH_PROC_REGEX = Regex(\"\"\"Process:\\s+([^,]+),\\s+PID:\\s+(\\d+)\"\"\")\n\n// 匹配 ANR 触发行中的包名:\"ANR in com.example.app\"\nprivate val ANR_IN_REGEX = Regex(\"\"\"ANR in ([^\\s(,]+)\"\"\")\n\n// 匹配 ANR 块中 \"PID: <num>\" 行的消息部分\nprivate val ANR_PID_LINE_REGEX = Regex(\"\"\"^PID:\\s*(\\d+)\"\"\")\n\n// 匹配 ANR 块中 \"Reason: ...\" 行的消息部分\nprivate val ANR_REASON_REGEX = Regex(\"\"\"^Reason:\\s*(.+)\"\"\")\n\n// 是否提取 Crash/ANR 日志片段并保存到文件\nprivate const val EXTRACT_CRASH_ANR = true\n\n// 提取时是否将相同的 Crash/ANR(相同异常类+相同调用栈顶帧 / 相同 ANR 原因)合并为一条记录\nprivate const val AGGREGATE_CRASH_ANR = true\n\n// DeepSeek API Key(优先读取 local.properties 中的 deepseek.api.key,此处留空即可)\nprivate const val DEEPSEEK_API_KEY = \"\"\n\n// DeepSeek 模型名称(优先读取 local.properties 中的 deepseek.model,此处留空即可)\nprivate const val DEEPSEEK_MODEL = \"\"\n\n/** 统计维度 */\nenum class StatMode {\n /** 按包名聚合(需从日志中识别 PID→包名 映射) */\n BY_PACKAGE,\n\n /** 按日志 TAG 聚合 */\n BY_TAG\n}\n\n/** Crash 日志片段 */\ndata class CrashRecord(\n val pid: String,\n val packageName: String,\n val timestamp: String,\n val lines: List<String>\n)\n\n/** ANR 日志片段 */\ndata class AnrRecord(\n val pid: String,\n val packageName: String,\n val timestamp: String,\n val reason: String,\n val lines: List<String>\n)\n\n// 内部状态类(仅用于解析过程中的临时追踪,不对外暴露)\nprivate class CrashBlockState(val timestamp: String) {\n val lines: MutableList<String> = mutableListOf()\n}\n\nprivate class AnrBlockState(val timestamp: String) {\n val lines: MutableList<String> = mutableListOf()\n var anrPid: String = \"\"\n var packageName: String = \"\"\n var reason: String = \"\"\n}\n\nfun main() {\n // 留空时自动从 local.properties 读取对应配置项\n val apiKey = DEEPSEEK_API_KEY.ifBlank { loadDeepSeekApiKey() }\n val model = DEEPSEEK_MODEL.ifBlank { loadDeepSeekModel() }\n runLogStat(\n LOG_FILE_PATH, STAT_MODE,\n aggregateByPackage = true,\n extractCrashAnr = EXTRACT_CRASH_ANR,\n aggregateCrashAnr = AGGREGATE_CRASH_ANR,\n deepSeekApiKey = apiKey,\n deepSeekModel = model\n )\n}\n\n/**\n * 核心入口:解析指定路径(文件或目录)并按指定维度输出统计结果。\n * 结果通过 println 输出,可在命令行或 GUI 中重定向 stdout 使用。\n *\n * @param path 日志文件路径或目录路径\n * @param mode 统计维度\n * @param aggregateByPackage 仅对 BY_PACKAGE 模式有效:\n * true = 按包名聚合,合并同包名所有 PID 的行数,不输出 PID 明细;\n * false = 展示 PID 明细(含各 PID 对应包名)。\n * @param extractCrashAnr true = 提取 Crash/ANR 日志片段保存到文件,并输出按 PID 统计的计数表。\n * @param aggregateCrashAnr 仅在 extractCrashAnr=true 时有效:将相同异常/原因的 Crash/ANR 合并,\n * 每种问题只保留一个代表性片段文件,文件头注明聚合数量与涉及 PID。\n * @param deepSeekApiKey 非空时对每个保存的片段文件调用 DeepSeek API 进行分析,\n * 结果保存为同名 `.analysis.md`;已存在则跳过。\n * @param deepSeekModel 调用 DeepSeek API 时使用的模型名称;\n * 留空时从 local.properties 读取,再空则使用默认值。\n */\nfun runLogStat(\n path: String,\n mode: StatMode,\n aggregateByPackage: Boolean = true,\n extractCrashAnr: Boolean = false,\n aggregateCrashAnr: Boolean = false,\n deepSeekApiKey: String = \"\",\n deepSeekModel: String = \"\"\n) {\n val input = File(path)\n if (!input.exists()) {\n println(\"Path not found: $path\")\n return\n }\n\n // 收集待处理的文件列表\n val logFiles: List<File> = when {\n input.isFile -> {\n println(\"Input: file -> ${input.name}\")\n listOf(input)\n }\n input.isDirectory -> {\n val files = input.walkTopDown()\n .filter { it.isFile && it.extension.lowercase() in LOG_FILE_EXTENSIONS }\n .sortedBy { it.name }\n .toList()\n println(\"Input: directory -> ${input.absolutePath}\")\n println(\"Found ${files.size} log file(s): ${files.joinToString { it.name }}\")\n files\n }\n else -> {\n println(\"Unsupported path type: $path\")\n return\n }\n }\n\n if (logFiles.isEmpty()) {\n println(\"No log files found.\")\n return\n }\n\n // 跨文件累计的统计数据\n val pidPackageMap = mutableMapOf<String, String>() // pid -> packageName\n val pidLineCount = mutableMapOf<String, Long>() // pid -> lineCount\n val tagLineCount = mutableMapOf<String, Long>() // tag -> lineCount\n val allCrashRecords = if (extractCrashAnr) mutableListOf<CrashRecord>() else null\n val allAnrRecords = if (extractCrashAnr) mutableListOf<AnrRecord>() else null\n var totalLines = 0L\n\n // 逐文件解析,数据累加到同一份 Map 中\n logFiles.forEachIndexed { index, file ->\n println(\" [${index + 1}/${logFiles.size}] Parsing: ${file.name} (${file.length() / 1024} KB)\")\n totalLines += parseLogFile(file, pidPackageMap, pidLineCount, tagLineCount, allCrashRecords, allAnrRecords)\n }\n\n if (totalLines == 0L) {\n println(\"No valid log lines found.\")\n return\n }\n\n when (mode) {\n StatMode.BY_PACKAGE -> {\n val packageLineCount = mutableMapOf<String, Long>()\n var unknownLines = 0L\n for ((pid, count) in pidLineCount) {\n val packageName = pidPackageMap[pid]\n if (packageName != null) {\n packageLineCount[packageName] = packageLineCount.getOrDefault(packageName, 0L) + count\n } else {\n unknownLines += count\n }\n }\n if (unknownLines > 0) packageLineCount[\"(Unknown)\"] = unknownLines\n printPackageStatistics(totalLines, pidLineCount, pidPackageMap, packageLineCount, aggregateByPackage)\n }\n StatMode.BY_TAG -> {\n printTagStatistics(totalLines, tagLineCount)\n }\n }\n\n if (extractCrashAnr && allCrashRecords != null && allAnrRecords != null) {\n // 输出目录与输入路径同级,避免下次扫描时被当作日志文件读入\n val outputDir = if (input.isDirectory)\n File(input.parentFile ?: input, \"${input.name}_crashanr\")\n else\n File(input.parentFile ?: File(\".\"), \"${input.nameWithoutExtension}_crashanr\")\n val effectiveModel = deepSeekModel.ifBlank { loadDeepSeekModel() }\n saveCrashAnrRecords(outputDir, allCrashRecords, allAnrRecords, aggregateCrashAnr, deepSeekApiKey, effectiveModel)\n printCrashAnrStatistics(allCrashRecords, allAnrRecords, aggregateCrashAnr)\n }\n}\n\n/**\n * 解析单个日志文件,将结果累加到传入的 Map 中。\n * 若传入 [crashRecords] 或 [anrRecords],则同时提取对应的日志片段。\n *\n * @param file 待解析的日志文件\n * @param pidPackageMap pid -> packageName 映射(累加写入)\n * @param pidLineCount pid -> 行数 映射(累加写入)\n * @param tagLineCount tag -> 行数 映射(累加写入)\n * @param crashRecords 非 null 时收集 Crash 片段(累加写入)\n * @param anrRecords 非 null 时收集 ANR 片段(累加写入)\n * @return 本文件中解析到的有效日志行数\n */\nfun parseLogFile(\n file: File,\n pidPackageMap: MutableMap<String, String>,\n pidLineCount: MutableMap<String, Long>,\n tagLineCount: MutableMap<String, Long>,\n crashRecords: MutableList<CrashRecord>? = null,\n anrRecords: MutableList<AnrRecord>? = null\n): Long {\n var lines = 0L\n // per-PID crash 块累积器(key = 崩溃进程 PID)\n val activeCrash = mutableMapOf<String, CrashBlockState>()\n // per-AM-PID ANR 块累积器(key = ActivityManager 进程的 PID)\n val activeAnr = mutableMapOf<String, AnrBlockState>()\n\n fun finalizeCrash(pid: String) {\n val state = activeCrash.remove(pid) ?: return\n if (state.lines.isEmpty()) return\n val pkg = pidPackageMap[pid] ?: \"(Unknown)\"\n crashRecords?.add(CrashRecord(pid, pkg, state.timestamp, state.lines.toList()))\n }\n\n fun finalizeAnr(amPid: String) {\n val state = activeAnr.remove(amPid) ?: return\n if (state.lines.isEmpty()) return\n val effectivePid = state.anrPid.ifEmpty { amPid }\n val pkg = state.packageName.ifEmpty { pidPackageMap[effectivePid] ?: \"(Unknown)\" }\n anrRecords?.add(AnrRecord(effectivePid, pkg, state.timestamp, state.reason, state.lines.toList()))\n }\n\n file.forEachLine { line ->\n // 忽略 \"beginning of ...\" 等分隔行\n if (line.contains(\"beginning of \")) return@forEachLine\n\n // 解析 Logcat 标准格式:MM-DD HH:MM:SS.mmm PID TID LEVEL TAG: message\n val parts = line.split(Regex(\" +\"), 7)\n val pid = parts.getOrNull(2) ?: return@forEachLine\n\n // 仅统计 PID 为纯数字的行(过滤无效行)\n if (!pid.all { it.isDigit() }) return@forEachLine\n\n lines++\n pidLineCount[pid] = pidLineCount.getOrDefault(pid, 0L) + 1L\n\n // 提取 TAG(index=5,去除末尾冒号)\n val tag = parts.getOrNull(5)?.trimEnd(':')?.trim() ?: \"(NoTag)\"\n tagLineCount[tag] = tagLineCount.getOrDefault(tag, 0L) + 1L\n\n val timestamp = \"${parts.getOrNull(0) ?: \"\"} ${parts.getOrNull(1) ?: \"\"}\"\n val message = parts.getOrNull(6) ?: \"\"\n\n // 尝试从当前行提取 PID -> 包名 信息\n START_PROC_REGEX.find(line)?.let { match ->\n pidPackageMap[match.groupValues[1]] = match.groupValues[2].trim()\n }\n CRASH_PROC_REGEX.find(line)?.let { match ->\n pidPackageMap[match.groupValues[2]] = match.groupValues[1].trim()\n }\n\n // ── Crash 块追踪(tag: AndroidRuntime)─────────────────────────────────\n if (crashRecords != null) {\n when {\n tag == \"AndroidRuntime\" && message.contains(\"FATAL EXCEPTION\") -> {\n finalizeCrash(pid) // 若该 PID 已有未完成的块,先保存\n activeCrash[pid] = CrashBlockState(timestamp).also { it.lines.add(line) }\n }\n tag == \"AndroidRuntime\" && activeCrash.containsKey(pid) -> {\n activeCrash[pid]!!.lines.add(line)\n }\n activeCrash.containsKey(pid) -> {\n // 该 PID 出现了非 AndroidRuntime 行,视为 crash 块结束\n finalizeCrash(pid)\n }\n }\n }\n\n // ── ANR 块追踪(tag: ActivityManager / am_anr 事件日志)────────────────\n if (anrRecords != null) {\n when {\n tag == \"ActivityManager\" && ANR_IN_REGEX.containsMatchIn(message) -> {\n finalizeAnr(pid)\n val pkg = ANR_IN_REGEX.find(message)?.groupValues?.get(1) ?: \"\"\n activeAnr[pid] = AnrBlockState(timestamp).also {\n it.lines.add(line)\n it.packageName = pkg\n }\n }\n tag == \"ActivityManager\" && activeAnr.containsKey(pid) -> {\n val state = activeAnr[pid]!!\n state.lines.add(line)\n if (state.anrPid.isEmpty()) {\n ANR_PID_LINE_REGEX.find(message)?.let { state.anrPid = it.groupValues[1] }\n }\n if (state.reason.isEmpty()) {\n ANR_REASON_REGEX.find(message)?.let { state.reason = it.groupValues[1].trim() }\n }\n }\n activeAnr.containsKey(pid) -> {\n // 该 PID 出现了非 ActivityManager 行,视为 ANR 块结束\n finalizeAnr(pid)\n }\n }\n\n // am_anr 事件日志(来自 events buffer):格式 \"[userId,anrPid,pkg,...]\"\n if (tag == \"am_anr\") {\n val m = Regex(\"\"\"\\[(\\d+),(\\d+),([^,\\]]+)\"\"\").find(message)\n if (m != null) {\n val anrPid = m.groupValues[2]\n val anrPkg = m.groupValues[3]\n pidPackageMap[anrPid] = anrPkg\n anrRecords.add(AnrRecord(anrPid, anrPkg, timestamp, \"(event log)\", listOf(line)))\n }\n }\n }\n }\n\n // 文件末尾:将所有未关闭的块强制终止并保存\n activeCrash.keys.toList().forEach { finalizeCrash(it) }\n activeAnr.keys.toList().forEach { finalizeAnr(it) }\n\n return lines\n}\n\n/**\n * 按包名维度格式化并打印统计结果。\n *\n * @param aggregateByPackage true = 仅输出包名聚合表(不含 PID 明细);false = 同时输出 PID 明细。\n */\nfun printPackageStatistics(\n totalLines: Long,\n pidLineCount: Map<String, Long>,\n pidPackageMap: Map<String, String>,\n packageLineCount: Map<String, Long>,\n aggregateByPackage: Boolean = true\n) {\n println(\"\\n===== Log Statistics (By Package) =====\")\n println(\"Total Valid Lines : $totalLines\")\n println(\"Unique PIDs : ${pidLineCount.size}\")\n println(\"Identified Pkgs : ${pidPackageMap.values.toSet().size}\")\n println()\n\n val sortedPkgEntries = packageLineCount.entries.sortedByDescending { it.value }\n val colPkg = maxOf(sortedPkgEntries.maxOf { it.key.length }, 11)\n\n printTable(\n labelHeader = \"PackageName\",\n colWidth = colPkg,\n totalLines = totalLines,\n entries = sortedPkgEntries\n )\n\n // aggregateByPackage = false 时额外输出各 PID 明细\n if (!aggregateByPackage) {\n println(\"\\n===== PID Detail =====\")\n val pidHeader = \"%8s %-${colPkg}s %8s %8s\".format(\"PID\", \"PackageName\", \"Lines\", \"Percent\")\n println(pidHeader)\n println(\"-\".repeat(pidHeader.length))\n pidLineCount.entries\n .sortedByDescending { it.value }\n .forEach { (pid, lines) ->\n val pkg = pidPackageMap[pid] ?: \"(Unknown)\"\n val percent = \"%.2f%%\".format(lines.toDouble() / totalLines * 100.0)\n println(\"%8s %-${colPkg}s %8d %8s\".format(pid, pkg, lines, percent))\n }\n }\n}\n\n/**\n * 按 TAG 维度格式化并打印统计结果。\n */\nfun printTagStatistics(\n totalLines: Long,\n tagLineCount: Map<String, Long>\n) {\n println(\"\\n===== Log Statistics (By TAG) =====\")\n println(\"Total Valid Lines : $totalLines\")\n println(\"Unique TAGs : ${tagLineCount.size}\")\n println()\n\n val sortedTagEntries = tagLineCount.entries.sortedByDescending { it.value }\n val colTag = maxOf(sortedTagEntries.maxOf { it.key.length }, 3)\n\n printTable(\n labelHeader = \"TAG\",\n colWidth = colTag,\n totalLines = totalLines,\n entries = sortedTagEntries\n )\n}\n\n/** 通用表格打印:标签列 + Lines 列 + Percent 列,末行打印合计。 */\nprivate fun printTable(\n labelHeader: String,\n colWidth: Int,\n totalLines: Long,\n entries: List<Map.Entry<String, Long>>\n) {\n val header = \"%-${colWidth}s %8s %8s\".format(labelHeader, \"Lines\", \"Percent\")\n val divider = \"-\".repeat(header.length)\n println(header)\n println(divider)\n entries.forEach { (label, lines) ->\n val percent = \"%.2f%%\".format(lines.toDouble() / totalLines * 100.0)\n println(\"%-${colWidth}s %8d %8s\".format(label, lines, percent))\n }\n println(divider)\n println(\"%-${colWidth}s %8d %8s\".format(\"TOTAL\", totalLines, \"100.00%\"))\n}\n\n/**\n * 将提取到的 Crash / ANR 片段写入文件。\n * 目录结构:[outputDir]/crash/ 和 [outputDir]/anr/\n *\n * 非聚合模式:每条记录保存为独立文件,文件名含包名、PID 和序号。\n * 聚合模式 :相同问题(相同异常类+顶帧 / 相同 ANR 原因)合并为一个文件,\n * 文件头注明出现次数、涉及 PID 列表、首末时间戳,正文为代表性片段。\n *\n * 若 [deepSeekApiKey] 非空,在保存每个文件后调用 DeepSeek API 进行 AI 分析,\n * 结果保存为同名 `.analysis.md`(已存在则跳过)。\n */\nfun saveCrashAnrRecords(\n outputDir: File,\n crashRecords: List<CrashRecord>,\n anrRecords: List<AnrRecord>,\n aggregate: Boolean = false,\n deepSeekApiKey: String = \"\",\n deepSeekModel: String = \"\"\n) {\n if (crashRecords.isEmpty() && anrRecords.isEmpty()) {\n println(\"\\nNo Crash or ANR events found.\")\n return\n }\n\n val doAi = deepSeekApiKey.isNotBlank()\n var aiCallCount = 0\n\n fun sanitize(name: String) = name.replace(Regex(\"[^a-zA-Z0-9._-]\"), \"_\")\n\n fun maybeAnalyzeCrash(logFile: File, record: CrashRecord) {\n if (!doAi) return\n analyzeAndSaveResult(logFile, deepSeekApiKey, deepSeekModel, { key ->\n analyzeCrashWithDeepSeek(record, key, deepSeekModel)\n }, needSleep = aiCallCount++ > 0)\n }\n\n fun maybeAnalyzeAnr(logFile: File, record: AnrRecord) {\n if (!doAi) return\n analyzeAndSaveResult(logFile, deepSeekApiKey, deepSeekModel, { key ->\n analyzeAnrWithDeepSeek(record, key, deepSeekModel)\n }, needSleep = aiCallCount++ > 0)\n }\n\n if (crashRecords.isNotEmpty()) {\n val crashDir = File(outputDir, \"crash\").also { it.mkdirs() }\n if (aggregate) {\n crashRecords.groupBy { crashSignature(it) }.entries\n .sortedByDescending { it.value.size }\n .forEachIndexed { idx, (sig, group) ->\n val rep = group.first()\n val exShort = sig.split(\"|\").getOrNull(1)\n ?.substringAfterLast('.')?.take(40) ?: \"crash\"\n val name = \"crash_${sanitize(rep.packageName)}_${sanitize(exShort)}\" +\n \"_${group.size}x_${\"${idx + 1}\".padStart(3, '0')}.txt\"\n val header = buildAggregatedHeader(\n kind = \"Crash\",\n sig = sig,\n group = group.map { Triple(it.pid, it.packageName, it.timestamp) }\n )\n val logFile = File(crashDir, name)\n logFile.writeText(header + rep.lines.joinToString(\"\\n\"))\n maybeAnalyzeCrash(logFile, rep)\n }\n } else {\n crashRecords.groupBy { it.pid }.forEach { (pid, records) ->\n records.forEachIndexed { idx, record ->\n val name = \"crash_${sanitize(record.packageName)}_pid${pid}\" +\n \"_${\"${idx + 1}\".padStart(3, '0')}.txt\"\n val logFile = File(crashDir, name)\n logFile.writeText(record.lines.joinToString(\"\\n\"))\n maybeAnalyzeCrash(logFile, record)\n }\n }\n }\n val label = if (aggregate) \"${crashRecords.groupBy { crashSignature(it) }.size} group(s)\" else \"${crashRecords.size} record(s)\"\n println(\"\\nCrash files → ${File(outputDir, \"crash\").absolutePath} ($label)\")\n }\n\n if (anrRecords.isNotEmpty()) {\n val anrDir = File(outputDir, \"anr\").also { it.mkdirs() }\n if (aggregate) {\n anrRecords.groupBy { anrSignature(it) }.entries\n .sortedByDescending { it.value.size }\n .forEachIndexed { idx, (sig, group) ->\n val rep = group.first()\n val reasonShort = rep.reason.take(40).replace(Regex(\"[^a-zA-Z0-9 _-]\"), \"\").trimEnd()\n val name = \"anr_${sanitize(rep.packageName)}_${sanitize(reasonShort)}\" +\n \"_${group.size}x_${\"${idx + 1}\".padStart(3, '0')}.txt\"\n val header = buildAggregatedHeader(\n kind = \"ANR\",\n sig = sig,\n group = group.map { Triple(it.pid, it.packageName, it.timestamp) }\n )\n val logFile = File(anrDir, name)\n logFile.writeText(header + rep.lines.joinToString(\"\\n\"))\n maybeAnalyzeAnr(logFile, rep)\n }\n } else {\n anrRecords.groupBy { it.pid }.forEach { (pid, records) ->\n records.forEachIndexed { idx, record ->\n val name = \"anr_${sanitize(record.packageName)}_pid${pid}\" +\n \"_${\"${idx + 1}\".padStart(3, '0')}.txt\"\n val logFile = File(anrDir, name)\n logFile.writeText(record.lines.joinToString(\"\\n\"))\n maybeAnalyzeAnr(logFile, record)\n }\n }\n }\n val label = if (aggregate) \"${anrRecords.groupBy { anrSignature(it) }.size} group(s)\" else \"${anrRecords.size} record(s)\"\n println(\"ANR files → ${File(outputDir, \"anr\").absolutePath} ($label)\")\n }\n\n if (doAi) println(\"DeepSeek analysis: $aiCallCount file(s) processed.\")\n}\n\n/** 构建聚合文件顶部的注释头块。 */\nprivate fun buildAggregatedHeader(\n kind: String,\n sig: String,\n group: List<Triple<String, String, String>> // pid, packageName, timestamp\n): String {\n val sep = \"#\" + \"─\".repeat(64)\n val pids = group.map { it.first }.distinct().joinToString(\", \")\n val sigParts = sig.split(\"|\")\n return buildString {\n appendLine(sep)\n appendLine(\"# [$kind] Occurrences : ${group.size}\")\n appendLine(\"# Package : ${group.first().second}\")\n if (sigParts.size >= 2) appendLine(\"# Identifier : ${sigParts.drop(1).joinToString(\" | \")}\")\n appendLine(\"# PIDs : $pids\")\n appendLine(\"# First seen : ${group.first().third}\")\n appendLine(\"# Last seen : ${group.last().third}\")\n appendLine(\"$sep\\n\")\n }\n}\n\n/**\n * 打印 Crash / ANR 统计表。\n *\n * 非聚合模式:按 PID 聚合计数。\n * 聚合模式 :按问题特征(异常类+顶帧 / ANR 原因)聚合,展示每种问题的出现次数与涉及 PID。\n */\nfun printCrashAnrStatistics(\n crashRecords: List<CrashRecord>,\n anrRecords: List<AnrRecord>,\n aggregate: Boolean = false\n) {\n println(\"\\n===== Crash / ANR Statistics =====\")\n\n if (crashRecords.isNotEmpty()) {\n println(\"\\n----- Crash Summary (Total: ${crashRecords.size}) -----\")\n if (aggregate) {\n val groups = crashRecords.groupBy { crashSignature(it) }\n .entries.sortedByDescending { it.value.size }\n val colId = maxOf(groups.maxOf { it.key.length }, 10).coerceAtMost(60)\n val hdr = \"%5s %-${colId}s %s\".format(\"Count\", \"Identifier (pkg|exception|topFrame)\", \"PIDs\")\n println(hdr)\n println(\"-\".repeat(hdr.length))\n groups.forEach { (sig, recs) ->\n val pids = recs.map { it.pid }.distinct().joinToString(\",\")\n println(\"%5d %-${colId}s %s\".format(recs.size, sig.take(colId), pids))\n }\n } else {\n val byPid = crashRecords.groupBy { it.pid }\n val colPkg = maxOf(crashRecords.maxOf { it.packageName.length }, 11)\n val hdr = \"%8s %-${colPkg}s %5s\".format(\"PID\", \"PackageName\", \"Count\")\n println(hdr)\n println(\"-\".repeat(hdr.length))\n byPid.entries.sortedByDescending { it.value.size }.forEach { (pid, recs) ->\n println(\"%8s %-${colPkg}s %5d\".format(pid, recs.first().packageName, recs.size))\n }\n }\n } else {\n println(\"No Crash events found.\")\n }\n\n if (anrRecords.isNotEmpty()) {\n println(\"\\n----- ANR Summary (Total: ${anrRecords.size}) -----\")\n if (aggregate) {\n val groups = anrRecords.groupBy { anrSignature(it) }\n .entries.sortedByDescending { it.value.size }\n val colId = maxOf(groups.maxOf { it.key.length }, 10).coerceAtMost(60)\n val hdr = \"%5s %-${colId}s %s\".format(\"Count\", \"Identifier (pkg|reason)\", \"PIDs\")\n println(hdr)\n println(\"-\".repeat(hdr.length))\n groups.forEach { (sig, recs) ->\n val pids = recs.map { it.pid }.distinct().joinToString(\",\")\n println(\"%5d %-${colId}s %s\".format(recs.size, sig.take(colId), pids))\n }\n } else {\n val byPid = anrRecords.groupBy { it.pid }\n val colPkg = maxOf(anrRecords.maxOf { it.packageName.length }, 11)\n val hdr = \"%8s %-${colPkg}s %5s %s\".format(\"PID\", \"PackageName\", \"Count\", \"LastReason\")\n println(hdr)\n println(\"-\".repeat(hdr.length))\n byPid.entries.sortedByDescending { it.value.size }.forEach { (pid, recs) ->\n println(\"%8s %-${colPkg}s %5d %s\".format(pid, recs.first().packageName, recs.size, recs.last().reason.take(60)))\n }\n }\n } else {\n println(\"No ANR events found.\")\n }\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// 聚合辅助函数\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * 提取 Crash 记录的聚合签名:`<package>|<exceptionClass>|<topFrame>`\n * exceptionClass:第一个包含 Exception/Error 且不是 \"at\" 行的消息\n * topFrame :第一个 \"at\" 行的方法全限定名(不含括号内的文件信息)\n */\nprivate fun crashSignature(record: CrashRecord): String {\n var exClass = \"\"\n var topFrame = \"\"\n for (line in record.lines) {\n val msg = line.split(Regex(\" +\"), 7).getOrNull(6) ?: continue\n val trimmed = msg.trimStart()\n if (exClass.isEmpty()\n && !trimmed.startsWith(\"at \")\n && !trimmed.startsWith(\"FATAL\")\n && !trimmed.startsWith(\"Process:\")\n && (trimmed.contains(\"Exception\") || trimmed.contains(\"Error\"))\n ) {\n exClass = trimmed.substringBefore(':').trim()\n }\n if (topFrame.isEmpty() && trimmed.startsWith(\"at \")) {\n topFrame = trimmed.removePrefix(\"at \").substringBefore('(')\n }\n if (exClass.isNotEmpty() && topFrame.isNotEmpty()) break\n }\n return \"${record.packageName}|${exClass.ifEmpty { \"(unknown)\" }}|${topFrame}\"\n}\n\n/**\n * 提取 ANR 记录的聚合签名:`<package>|<normalizedReason>`\n * 将原因中的纯数字替换为 `#`,避免因不同 PID / 超时毫秒数导致误判为不同问题。\n */\nprivate fun anrSignature(record: AnrRecord): String {\n val normalized = record.reason\n .replace(Regex(\"\"\"0x[0-9a-fA-F]+\"\"\"), \"0x#\")\n .replace(Regex(\"\"\"\\b\\d+\\b\"\"\"), \"#\")\n .trim()\n return \"${record.packageName}|${normalized.ifEmpty { \"(unknown)\" }}\"\n}\n"}, "1aaee3b4d72bd0c6cae4666fa428cb3e83e7013d": {"t": 1785755907.9632475, "n": 25338, "h": "d576e73b848f708a", "c": "package net.bi4vmr.study\n\nimport java.io.File\n\n// 修改此路径以指向目标日志文件或目录\n// private const val LOG_FILE_PATH = \"/home/bi4vmr/Downloads/logcat.log\"\nprivate const val LOG_FILE_PATH = \"/home/bi4vmr/Downloads/logcat\"\n\n// 统计维度:BY_PACKAGE 按包名统计;BY_TAG 按日志TAG统计\nprivate val STAT_MODE = StatMode.BY_PACKAGE\n\n// 统计目录时,只处理匹配该扩展名的文件(忽略大小写)\nprivate val LOG_FILE_EXTENSIONS = setOf(\"log\", \"txt\")\n\n// 匹配 \"Start proc <PID>:<packageName>/...\" 格式\nprivate val START_PROC_REGEX = Regex(\"\"\"Start proc (\\d+):([^/\\s]+)\"\"\")\n\n// 匹配 \"Process: <packageName>, PID: <PID>\" 格式(Crash日志)\nprivate val CRASH_PROC_REGEX = Regex(\"\"\"Process:\\s+([^,]+),\\s+PID:\\s+(\\d+)\"\"\")\n\n// 匹配 ANR 触发行中的包名:\"ANR in com.example.app\"\nprivate val ANR_IN_REGEX = Regex(\"\"\"ANR in ([^\\s(,]+)\"\"\")\n\n// 匹配 ANR 块中 \"PID: <num>\" 行的消息部分\nprivate val ANR_PID_LINE_REGEX = Regex(\"\"\"^PID:\\s*(\\d+)\"\"\")\n\n// 匹配 ANR 块中 \"Reason: ...\" 行的消息部分\nprivate val ANR_REASON_REGEX = Regex(\"\"\"^Reason:\\s*(.+)\"\"\")\n\n// 是否提取 Crash/ANR 日志片段并保存到文件\nprivate const val EXTRACT_CRASH_ANR = true\n\n// 提取时是否将相同的 Crash/ANR(相同异常类+相同调用栈顶帧 / 相同 ANR 原因)合并为一条记录\nprivate const val AGGREGATE_CRASH_ANR = true\n\n// DeepSeek API Key(优先读取 local.properties 中的 deepseek.api.key,此处留空即可)\nprivate const val DEEPSEEK_API_KEY = \"\"\n\n// DeepSeek 模型名称(优先读取 local.properties 中的 deepseek.model,此处留空即可)\nprivate const val DEEPSEEK_MODEL = \"\"\n\n/** 统计维度 */\nenum class StatMode {\n /** 按包名聚合(需从日志中识别 PID→包名 映射) */\n BY_PACKAGE,\n\n /** 按日志 TAG 聚合 */\n BY_TAG\n}\n\n/** Crash 日志片段 */\ndata class CrashRecord(\n val pid: String,\n val packageName: String,\n val timestamp: String,\n val lines: List<String>\n)\n\n/** ANR 日志片段 */\ndata class AnrRecord(\n val pid: String,\n val packageName: String,\n val timestamp: String,\n val reason: String,\n val lines: List<String>\n)\n\n// 内部状态类(仅用于解析过程中的临时追踪,不对外暴露)\nprivate class CrashBlockState(val timestamp: String) {\n val lines: MutableList<String> = mutableListOf()\n}\n\nprivate class AnrBlockState(val timestamp: String) {\n val lines: MutableList<String> = mutableListOf()\n var anrPid: String = \"\"\n var packageName: String = \"\"\n var reason: String = \"\"\n}\n\nfun main() {\n // 留空时自动从 local.properties 读取对应配置项\n val apiKey = DEEPSEEK_API_KEY.ifBlank { loadDeepSeekApiKey() }\n val model = DEEPSEEK_MODEL.ifBlank { loadDeepSeekModel() }\n runLogStat(\n LOG_FILE_PATH, STAT_MODE,\n aggregateByPackage = true,\n extractCrashAnr = EXTRACT_CRASH_ANR,\n aggregateCrashAnr = AGGREGATE_CRASH_ANR,\n deepSeekApiKey = apiKey,\n deepSeekModel = model\n )\n}\n\n/**\n * 核心入口:解析指定路径(文件或目录)并按指定维度输出统计结果。\n * 结果通过 println 输出,可在命令行或 GUI 中重定向 stdout 使用。\n *\n * @param path 日志文件路径或目录路径\n * @param mode 统计维度\n * @param aggregateByPackage 仅对 BY_PACKAGE 模式有效:\n * true = 按包名聚合,合并同包名所有 PID 的行数,不输出 PID 明细;\n * false = 展示 PID 明细(含各 PID 对应包名)。\n * @param extractCrashAnr true = 提取 Crash/ANR 日志片段保存到文件,并输出按 PID 统计的计数表。\n * @param aggregateCrashAnr 仅在 extractCrashAnr=true 时有效:将相同异常/原因的 Crash/ANR 合并,\n * 每种问题只保留一个代表性片段文件,文件头注明聚合数量与涉及 PID。\n * @param deepSeekApiKey 非空时对每个保存的片段文件调用 DeepSeek API 进行分析,\n * 结果保存为同名 `.analysis.md`;已存在则跳过。\n * @param deepSeekModel 调用 DeepSeek API 时使用的模型名称;\n * 留空时从 local.properties 读取,再空则使用默认值。\n */\nfun runLogStat(\n path: String,\n mode: StatMode,\n aggregateByPackage: Boolean = true,\n extractCrashAnr: Boolean = false,\n aggregateCrashAnr: Boolean = false,\n deepSeekApiKey: String = \"\",\n deepSeekModel: String = \"\"\n) {\n val input = File(path)\n if (!input.exists()) {\n println(\"Path not found: $path\")\n return\n }\n\n // 收集待处理的文件列表\n val logFiles: List<File> = when {\n input.isFile -> {\n println(\"Input: file -> ${input.name}\")\n listOf(input)\n }\n input.isDirectory -> {\n val files = input.walkTopDown()\n .filter { it.isFile && it.extension.lowercase() in LOG_FILE_EXTENSIONS }\n .sortedBy { it.name }\n .toList()\n println(\"Input: directory -> ${input.absolutePath}\")\n println(\"Found ${files.size} log file(s): ${files.joinToString { it.name }}\")\n files\n }\n else -> {\n println(\"Unsupported path type: $path\")\n return\n }\n }\n\n if (logFiles.isEmpty()) {\n println(\"No log files found.\")\n return\n }\n\n // 跨文件累计的统计数据\n val pidPackageMap = mutableMapOf<String, String>() // pid -> packageName\n val pidLineCount = mutableMapOf<String, Long>() // pid -> lineCount\n val tagLineCount = mutableMapOf<String, Long>() // tag -> lineCount\n val allCrashRecords = if (extractCrashAnr) mutableListOf<CrashRecord>() else null\n val allAnrRecords = if (extractCrashAnr) mutableListOf<AnrRecord>() else null\n var totalLines = 0L\n\n // 逐文件解析,数据累加到同一份 Map 中\n logFiles.forEachIndexed { index, file ->\n println(\" [${index + 1}/${logFiles.size}] Parsing: ${file.name} (${file.length() / 1024} KB)\")\n totalLines += parseLogFile(file, pidPackageMap, pidLineCount, tagLineCount, allCrashRecords, allAnrRecords)\n }\n\n if (totalLines == 0L) {\n println(\"No valid log lines found.\")\n return\n }\n\n when (mode) {\n StatMode.BY_PACKAGE -> {\n val packageLineCount = mutableMapOf<String, Long>()\n var unknownLines = 0L\n for ((pid, count) in pidLineCount) {\n val packageName = pidPackageMap[pid]\n if (packageName != null) {\n packageLineCount[packageName] = packageLineCount.getOrDefault(packageName, 0L) + count\n } else {\n unknownLines += count\n }\n }\n if (unknownLines > 0) packageLineCount[\"(Unknown)\"] = unknownLines\n printPackageStatistics(totalLines, pidLineCount, pidPackageMap, packageLineCount, aggregateByPackage)\n }\n StatMode.BY_TAG -> {\n printTagStatistics(totalLines, tagLineCount)\n }\n }\n\n if (extractCrashAnr && allCrashRecords != null && allAnrRecords != null) {\n // 输出目录与输入路径同级,避免下次扫描时被当作日志文件读入\n val outputDir = if (input.isDirectory)\n File(input.parentFile ?: input, \"${input.name}_crashanr\")\n else\n File(input.parentFile ?: File(\".\"), \"${input.nameWithoutExtension}_crashanr\")\n val effectiveModel = deepSeekModel.ifBlank { loadDeepSeekModel() }\n saveCrashAnrRecords(outputDir, allCrashRecords, allAnrRecords, aggregateCrashAnr, deepSeekApiKey, effectiveModel)\n printCrashAnrStatistics(allCrashRecords, allAnrRecords, aggregateCrashAnr)\n }\n}\n\n/**\n * 解析单个日志文件,将结果累加到传入的 Map 中。\n * 若传入 [crashRecords] 或 [anrRecords],则同时提取对应的日志片段。\n *\n * @param file 待解析的日志文件\n * @param pidPackageMap pid -> packageName 映射(累加写入)\n * @param pidLineCount pid -> 行数 映射(累加写入)\n * @param tagLineCount tag -> 行数 映射(累加写入)\n * @param crashRecords 非 null 时收集 Crash 片段(累加写入)\n * @param anrRecords 非 null 时收集 ANR 片段(累加写入)\n * @return 本文件中解析到的有效日志行数\n */\nfun parseLogFile(\n file: File,\n pidPackageMap: MutableMap<String, String>,\n pidLineCount: MutableMap<String, Long>,\n tagLineCount: MutableMap<String, Long>,\n crashRecords: MutableList<CrashRecord>? = null,\n anrRecords: MutableList<AnrRecord>? = null\n): Long {\n var lines = 0L\n // per-PID crash 块累积器(key = 崩溃进程 PID)\n val activeCrash = mutableMapOf<String, CrashBlockState>()\n // per-AM-PID ANR 块累积器(key = ActivityManager 进程的 PID)\n val activeAnr = mutableMapOf<String, AnrBlockState>()\n\n fun finalizeCrash(pid: String) {\n val state = activeCrash.remove(pid) ?: return\n if (state.lines.isEmpty()) return\n val pkg = pidPackageMap[pid] ?: \"(Unknown)\"\n crashRecords?.add(CrashRecord(pid, pkg, state.timestamp, state.lines.toList()))\n }\n\n fun finalizeAnr(amPid: String) {\n val state = activeAnr.remove(amPid) ?: return\n if (state.lines.isEmpty()) return\n val effectivePid = state.anrPid.ifEmpty { amPid }\n val pkg = state.packageName.ifEmpty { pidPackageMap[effectivePid] ?: \"(Unknown)\" }\n anrRecords?.add(AnrRecord(effectivePid, pkg, state.timestamp, state.reason, state.lines.toList()))\n }\n\n file.forEachLine { line ->\n // 忽略 \"beginning of ...\" 等分隔行\n if (line.contains(\"beginning of \")) return@forEachLine\n\n // 解析 Logcat 标准格式:MM-DD HH:MM:SS.mmm PID TID LEVEL TAG: message\n val parts = line.split(Regex(\" +\"), 7)\n val pid = parts.getOrNull(2) ?: return@forEachLine\n\n // 仅统计 PID 为纯数字的行(过滤无效行)\n if (!pid.all { it.isDigit() }) return@forEachLine\n\n lines++\n pidLineCount[pid] = pidLineCount.getOrDefault(pid, 0L) + 1L\n\n // 提取 TAG(index=5,去除末尾冒号)\n val tag = parts.getOrNull(5)?.trimEnd(':')?.trim() ?: \"(NoTag)\"\n tagLineCount[tag] = tagLineCount.getOrDefault(tag, 0L) + 1L\n\n val timestamp = \"${parts.getOrNull(0) ?: \"\"} ${parts.getOrNull(1) ?: \"\"}\"\n val message = parts.getOrNull(6) ?: \"\"\n\n // 尝试从当前行提取 PID -> 包名 信息\n START_PROC_REGEX.find(line)?.let { match ->\n pidPackageMap[match.groupValues[1]] = match.groupValues[2].trim()\n }\n CRASH_PROC_REGEX.find(line)?.let { match ->\n pidPackageMap[match.groupValues[2]] = match.groupValues[1].trim()\n }\n\n // ── Crash 块追踪(tag: AndroidRuntime)─────────────────────────────────\n if (crashRecords != null) {\n when {\n tag == \"AndroidRuntime\" && message.contains(\"FATAL EXCEPTION\") -> {\n finalizeCrash(pid) // 若该 PID 已有未完成的块,先保存\n activeCrash[pid] = CrashBlockState(timestamp).also { it.lines.add(line) }\n }\n tag == \"AndroidRuntime\" && activeCrash.containsKey(pid) -> {\n activeCrash[pid]!!.lines.add(line)\n }\n activeCrash.containsKey(pid) -> {\n // 该 PID 出现了非 AndroidRuntime 行,视为 crash 块结束\n finalizeCrash(pid)\n }\n }\n }\n\n // ── ANR 块追踪(tag: ActivityManager / am_anr 事件日志)────────────────\n if (anrRecords != null) {\n when {\n tag == \"ActivityManager\" && ANR_IN_REGEX.containsMatchIn(message) -> {\n finalizeAnr(pid)\n val pkg = ANR_IN_REGEX.find(message)?.groupValues?.get(1) ?: \"\"\n activeAnr[pid] = AnrBlockState(timestamp).also {\n it.lines.add(line)\n it.packageName = pkg\n }\n }\n tag == \"ActivityManager\" && activeAnr.containsKey(pid) -> {\n val state = activeAnr[pid]!!\n state.lines.add(line)\n if (state.anrPid.isEmpty()) {\n ANR_PID_LINE_REGEX.find(message)?.let { state.anrPid = it.groupValues[1] }\n }\n if (state.reason.isEmpty()) {\n ANR_REASON_REGEX.find(message)?.let { state.reason = it.groupValues[1].trim() }\n }\n }\n activeAnr.containsKey(pid) -> {\n // 该 PID 出现了非 ActivityManager 行,视为 ANR 块结束\n finalizeAnr(pid)\n }\n }\n\n // am_anr 事件日志(来自 events buffer):格式 \"[userId,anrPid,pkg,...]\"\n if (tag == \"am_anr\") {\n val m = Regex(\"\"\"\\[(\\d+),(\\d+),([^,\\]]+)\"\"\").find(message)\n if (m != null) {\n val anrPid = m.groupValues[2]\n val anrPkg = m.groupValues[3]\n pidPackageMap[anrPid] = anrPkg\n anrRecords.add(AnrRecord(anrPid, anrPkg, timestamp, \"(event log)\", listOf(line)))\n }\n }\n }\n }\n\n // 文件末尾:将所有未关闭的块强制终止并保存\n activeCrash.keys.toList().forEach { finalizeCrash(it) }\n activeAnr.keys.toList().forEach { finalizeAnr(it) }\n\n return lines\n}\n\n/**\n * 按包名维度格式化并打印统计结果。\n *\n * @param aggregateByPackage true = 仅输出包名聚合表(不含 PID 明细);false = 同时输出 PID 明细。\n */\nfun printPackageStatistics(\n totalLines: Long,\n pidLineCount: Map<String, Long>,\n pidPackageMap: Map<String, String>,\n packageLineCount: Map<String, Long>,\n aggregateByPackage: Boolean = true\n) {\n println(\"\\n===== Log Statistics (By Package) =====\")\n println(\"Total Valid Lines : $totalLines\")\n println(\"Unique PIDs : ${pidLineCount.size}\")\n println(\"Identified Pkgs : ${pidPackageMap.values.toSet().size}\")\n println()\n\n val sortedPkgEntries = packageLineCount.entries.sortedByDescending { it.value }\n val colPkg = maxOf(sortedPkgEntries.maxOf { it.key.length }, 11)\n\n printTable(\n labelHeader = \"PackageName\",\n colWidth = colPkg,\n totalLines = totalLines,\n entries = sortedPkgEntries\n )\n\n // aggregateByPackage = false 时额外输出各 PID 明细\n if (!aggregateByPackage) {\n println(\"\\n===== PID Detail =====\")\n val pidHeader = \"%8s %-${colPkg}s %8s %8s\".format(\"PID\", \"PackageName\", \"Lines\", \"Percent\")\n println(pidHeader)\n println(\"-\".repeat(pidHeader.length))\n pidLineCount.entries\n .sortedByDescending { it.value }\n .forEach { (pid, lines) ->\n val pkg = pidPackageMap[pid] ?: \"(Unknown)\"\n val percent = \"%.2f%%\".format(lines.toDouble() / totalLines * 100.0)\n println(\"%8s %-${colPkg}s %8d %8s\".format(pid, pkg, lines, percent))\n }\n }\n}\n\n/**\n * 按 TAG 维度格式化并打印统计结果。\n */\nfun printTagStatistics(\n totalLines: Long,\n tagLineCount: Map<String, Long>\n) {\n println(\"\\n===== Log Statistics (By TAG) =====\")\n println(\"Total Valid Lines : $totalLines\")\n println(\"Unique TAGs : ${tagLineCount.size}\")\n println()\n\n val sortedTagEntries = tagLineCount.entries.sortedByDescending { it.value }\n val colTag = maxOf(sortedTagEntries.maxOf { it.key.length }, 3)\n\n printTable(\n labelHeader = \"TAG\",\n colWidth = colTag,\n totalLines = totalLines,\n entries = sortedTagEntries\n )\n}\n\n/** 通用表格打印:标签列 + Lines 列 + Percent 列,末行打印合计。 */\nprivate fun printTable(\n labelHeader: String,\n colWidth: Int,\n totalLines: Long,\n entries: List<Map.Entry<String, Long>>\n) {\n val header = \"%-${colWidth}s %8s %8s\".format(labelHeader, \"Lines\", \"Percent\")\n val divider = \"-\".repeat(header.length)\n println(header)\n println(divider)\n entries.forEach { (label, lines) ->\n val percent = \"%.2f%%\".format(lines.toDouble() / totalLines * 100.0)\n println(\"%-${colWidth}s %8d %8s\".format(label, lines, percent))\n }\n println(divider)\n println(\"%-${colWidth}s %8d %8s\".format(\"TOTAL\", totalLines, \"100.00%\"))\n}\n\n/**\n * 将提取到的 Crash / ANR 片段写入文件。\n * 目录结构:[outputDir]/crash/ 和 [outputDir]/anr/\n *\n * 非聚合模式:每条记录保存为独立文件,文件名含包名、PID 和序号。\n * 聚合模式 :相同问题(相同异常类+顶帧 / 相同 ANR 原因)合并为一个文件,\n * 文件头注明出现次数、涉及 PID 列表、首末时间戳,正文为代表性片段。\n *\n * 若 [deepSeekApiKey] 非空,在保存每个文件后调用 DeepSeek API 进行 AI 分析,\n * 结果保存为同名 `.analysis.md`(已存在则跳过)。\n */\nfun saveCrashAnrRecords(\n outputDir: File,\n crashRecords: List<CrashRecord>,\n anrRecords: List<AnrRecord>,\n aggregate: Boolean = false,\n deepSeekApiKey: String = \"\",\n deepSeekModel: String = \"\"\n) {\n if (crashRecords.isEmpty() && anrRecords.isEmpty()) {\n println(\"\\nNo Crash or ANR events found.\")\n return\n }\n\n val doAi = deepSeekApiKey.isNotBlank()\n var aiCallCount = 0\n\n fun sanitize(name: String) = name.replace(Regex(\"[^a-zA-Z0-9._-]\"), \"_\")\n\n fun maybeAnalyzeCrash(logFile: File, record: CrashRecord) {\n if (!doAi) return\n analyzeAndSaveResult(logFile, deepSeekApiKey, deepSeekModel, { key ->\n analyzeCrashWithDeepSeek(record, key, deepSeekModel)\n }, needSleep = aiCallCount++ > 0)\n }\n\n fun maybeAnalyzeAnr(logFile: File, record: AnrRecord) {\n if (!doAi) return\n analyzeAndSaveResult(logFile, deepSeekApiKey, deepSeekModel, { key ->\n analyzeAnrWithDeepSeek(record, key, deepSeekModel)\n }, needSleep = aiCallCount++ > 0)\n }\n\n if (crashRecords.isNotEmpty()) {\n val crashDir = File(outputDir, \"crash\").also { it.mkdirs() }\n if (aggregate) {\n crashRecords.groupBy { crashSignature(it) }.entries\n .sortedByDescending { it.value.size }\n .forEachIndexed { idx, (sig, group) ->\n val rep = group.first()\n val exShort = sig.split(\"|\").getOrNull(1)\n ?.substringAfterLast('.')?.take(40) ?: \"crash\"\n val name = \"crash_${sanitize(rep.packageName)}_${sanitize(exShort)}\" +\n \"_${group.size}x_${\"${idx + 1}\".padStart(3, '0')}.txt\"\n val header = buildAggregatedHeader(\n kind = \"Crash\",\n sig = sig,\n group = group.map { Triple(it.pid, it.packageName, it.timestamp) }\n )\n val logFile = File(crashDir, name)\n logFile.writeText(header + rep.lines.joinToString(\"\\n\"))\n maybeAnalyzeCrash(logFile, rep)\n }\n } else {\n crashRecords.groupBy { it.pid }.forEach { (pid, records) ->\n records.forEachIndexed { idx, record ->\n val name = \"crash_${sanitize(record.packageName)}_pid${pid}\" +\n \"_${\"${idx + 1}\".padStart(3, '0')}.txt\"\n val logFile = File(crashDir, name)\n logFile.writeText(record.lines.joinToString(\"\\n\"))\n maybeAnalyzeCrash(logFile, record)\n }\n }\n }\n val label = if (aggregate) \"${crashRecords.groupBy { crashSignature(it) }.size} group(s)\" else \"${crashRecords.size} record(s)\"\n println(\"\\nCrash files → ${File(outputDir, \"crash\").absolutePath} ($label)\")\n }\n\n if (anrRecords.isNotEmpty()) {\n val anrDir = File(outputDir, \"anr\").also { it.mkdirs() }\n if (aggregate) {\n anrRecords.groupBy { anrSignature(it) }.entries\n .sortedByDescending { it.value.size }\n .forEachIndexed { idx, (sig, group) ->\n val rep = group.first()\n val reasonShort = rep.reason.take(40).replace(Regex(\"[^a-zA-Z0-9 _-]\"), \"\").trimEnd()\n val name = \"anr_${sanitize(rep.packageName)}_${sanitize(reasonShort)}\" +\n \"_${group.size}x_${\"${idx + 1}\".padStart(3, '0')}.txt\"\n val header = buildAggregatedHeader(\n kind = \"ANR\",\n sig = sig,\n group = group.map { Triple(it.pid, it.packageName, it.timestamp) }\n )\n val logFile = File(anrDir, name)\n logFile.writeText(header + rep.lines.joinToString(\"\\n\"))\n maybeAnalyzeAnr(logFile, rep)\n }\n } else {\n anrRecords.groupBy { it.pid }.forEach { (pid, records) ->\n records.forEachIndexed { idx, record ->\n val name = \"anr_${sanitize(record.packageName)}_pid${pid}\" +\n \"_${\"${idx + 1}\".padStart(3, '0')}.txt\"\n val logFile = File(anrDir, name)\n logFile.writeText(record.lines.joinToString(\"\\n\"))\n maybeAnalyzeAnr(logFile, record)\n }\n }\n }\n val label = if (aggregate) \"${anrRecords.groupBy { anrSignature(it) }.size} group(s)\" else \"${anrRecords.size} record(s)\"\n println(\"ANR files → ${File(outputDir, \"anr\").absolutePath} ($label)\")\n }\n\n if (doAi) println(\"DeepSeek analysis: $aiCallCount file(s) processed.\")\n}\n\n/** 构建聚合文件顶部的注释头块。 */\nprivate fun buildAggregatedHeader(\n kind: String,\n sig: String,\n group: List<Triple<String, String, String>> // pid, packageName, timestamp\n): String {\n val sep = \"#\" + \"─\".repeat(64)\n val pids = group.map { it.first }.distinct().joinToString(\", \")\n val sigParts = sig.split(\"|\")\n return buildString {\n appendLine(sep)\n appendLine(\"# [$kind] Occurrences : ${group.size}\")\n appendLine(\"# Package : ${group.first().second}\")\n if (sigParts.size >= 2) appendLine(\"# Identifier : ${sigParts.drop(1).joinToString(\" | \")}\")\n appendLine(\"# PIDs : $pids\")\n appendLine(\"# First seen : ${group.first().third}\")\n appendLine(\"# Last seen : ${group.last().third}\")\n appendLine(\"$sep\\n\")\n }\n}\n\n/**\n * 打印 Crash / ANR 统计表。\n *\n * 非聚合模式:按 PID 聚合计数。\n * 聚合模式 :按问题特征(异常类+顶帧 / ANR 原因)聚合,展示每种问题的出现次数与涉及 PID。\n */\nfun printCrashAnrStatistics(\n crashRecords: List<CrashRecord>,\n anrRecords: List<AnrRecord>,\n aggregate: Boolean = false\n) {\n println(\"\\n===== Crash / ANR Statistics =====\")\n\n if (crashRecords.isNotEmpty()) {\n println(\"\\n----- Crash Summary (Total: ${crashRecords.size}) -----\")\n if (aggregate) {\n val groups = crashRecords.groupBy { crashSignature(it) }\n .entries.sortedByDescending { it.value.size }\n val colId = maxOf(groups.maxOf { it.key.length }, 10).coerceAtMost(60)\n val hdr = \"%5s %-${colId}s %s\".format(\"Count\", \"Identifier (pkg|exception|topFrame)\", \"PIDs\")\n println(hdr)\n println(\"-\".repeat(hdr.length))\n groups.forEach { (sig, recs) ->\n val pids = recs.map { it.pid }.distinct().joinToString(\",\")\n println(\"%5d %-${colId}s %s\".format(recs.size, sig.take(colId), pids))\n }\n } else {\n val byPid = crashRecords.groupBy { it.pid }\n val colPkg = maxOf(crashRecords.maxOf { it.packageName.length }, 11)\n val hdr = \"%8s %-${colPkg}s %5s\".format(\"PID\", \"PackageName\", \"Count\")\n println(hdr)\n println(\"-\".repeat(hdr.length))\n byPid.entries.sortedByDescending { it.value.size }.forEach { (pid, recs) ->\n println(\"%8s %-${colPkg}s %5d\".format(pid, recs.first().packageName, recs.size))\n }\n }\n } else {\n println(\"No Crash events found.\")\n }\n\n if (anrRecords.isNotEmpty()) {\n println(\"\\n----- ANR Summary (Total: ${anrRecords.size}) -----\")\n if (aggregate) {\n val groups = anrRecords.groupBy { anrSignature(it) }\n .entries.sortedByDescending { it.value.size }\n val colId = maxOf(groups.maxOf { it.key.length }, 10).coerceAtMost(60)\n val hdr = \"%5s %-${colId}s %s\".format(\"Count\", \"Identifier (pkg|reason)\", \"PIDs\")\n println(hdr)\n println(\"-\".repeat(hdr.length))\n groups.forEach { (sig, recs) ->\n val pids = recs.map { it.pid }.distinct().joinToString(\",\")\n println(\"%5d %-${colId}s %s\".format(recs.size, sig.take(colId), pids))\n }\n } else {\n val byPid = anrRecords.groupBy { it.pid }\n val colPkg = maxOf(anrRecords.maxOf { it.packageName.length }, 11)\n val hdr = \"%8s %-${colPkg}s %5s %s\".format(\"PID\", \"PackageName\", \"Count\", \"LastReason\")\n println(hdr)\n println(\"-\".repeat(hdr.length))\n byPid.entries.sortedByDescending { it.value.size }.forEach { (pid, recs) ->\n println(\"%8s %-${colPkg}s %5d %s\".format(pid, recs.first().packageName, recs.size, recs.last().reason.take(60)))\n }\n }\n } else {\n println(\"No ANR events found.\")\n }\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// 聚合辅助函数\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * 提取 Crash 记录的聚合签名:`<package>|<exceptionClass>|<topFrame>`\n * exceptionClass:第一个包含 Exception/Error 且不是 \"at\" 行的消息\n * topFrame :第一个 \"at\" 行的方法全限定名(不含括号内的文件信息)\n */\nprivate fun crashSignature(record: CrashRecord): String {\n var exClass = \"\"\n var topFrame = \"\"\n for (line in record.lines) {\n val msg = line.split(Regex(\" +\"), 7).getOrNull(6) ?: continue\n val trimmed = msg.trimStart()\n if (exClass.isEmpty()\n && !trimmed.startsWith(\"at \")\n && !trimmed.startsWith(\"FATAL\")\n && !trimmed.startsWith(\"Process:\")\n && (trimmed.contains(\"Exception\") || trimmed.contains(\"Error\"))\n ) {\n exClass = trimmed.substringBefore(':').trim()\n }\n if (topFrame.isEmpty() && trimmed.startsWith(\"at \")) {\n topFrame = trimmed.removePrefix(\"at \").substringBefore('(')\n }\n if (exClass.isNotEmpty() && topFrame.isNotEmpty()) break\n }\n return \"${record.packageName}|${exClass.ifEmpty { \"(unknown)\" }}|${topFrame}\"\n}\n\n/**\n * 提取 ANR 记录的聚合签名:`<package>|<normalizedReason>`\n * 将原因中的纯数字替换为 `#`,避免因不同 PID / 超时毫秒数导致误判为不同问题。\n */\nprivate fun anrSignature(record: AnrRecord): String {\n val normalized = record.reason\n .replace(Regex(\"\"\"0x[0-9a-fA-F]+\"\"\"), \"0x#\")\n .replace(Regex(\"\"\"\\b\\d+\\b\"\"\"), \"#\")\n .trim()\n return \"${record.packageName}|${normalized.ifEmpty { \"(unknown)\" }}\"\n}\n"}, "2a74421be84f0a476acc0c96f070af271cff5726": {"t": 1785755909.3327322, "n": 7835, "h": "8f8adba180297529", "c": "package net.bi4vmr.study\n\nimport java.io.File\n\n// 修改此路径以指向目标日志文件或目录\n// private const val LOG_FILE_PATH = \"/home/bi4vmr/Downloads/logcat.log\"\nprivate const val LOG_FILE_PATH = \"/home/bi4vmr/Downloads/logcat\"\n\n// 统计维度:BY_PACKAGE 按包名统计;BY_TAG 按日志TAG统计\nprivate val STAT_MODE = StatMode.BY_PACKAGE\n\n// 统计目录时,只处理匹配该扩展名的文件(忽略大小写)\nprivate val LOG_FILE_EXTENSIONS = setOf(\"log\", \"txt\")\n\n// 匹配 \"Start proc <PID>:<packageName>/...\" 格式\nprivate val START_PROC_REGEX = Regex(\"\"\"Start proc (\\d+):([^/\\s]+)\"\"\")\n\n// 匹配 \"Process: <packageName>, PID: <PID>\" 格式(Crash日志)\nprivate val CRASH_PROC_REGEX = Regex(\"\"\"Process:\\s+([^,]+),\\s+PID:\\s+(\\d+)\"\"\")\n\n/** 统计维度 */\nenum class StatMode {\n /** 按包名聚合(需从日志中识别 PID→包名 映射) */\n BY_PACKAGE,\n\n /** 按日志 TAG 聚合 */\n BY_TAG\n}\n\nfun main() {\n runLogStat(LOG_FILE_PATH, STAT_MODE, aggregateByPackage = true)\n}\n\n/**\n * 核心入口:解析指定路径(文件或目录)并按指定维度输出统计结果。\n * 结果通过 println 输出,可在命令行或 GUI 中重定向 stdout 使用。\n *\n * @param path 日志文件路径或目录路径\n * @param mode 统计维度\n * @param aggregateByPackage 仅对 BY_PACKAGE 模式有效:\n * true = 按包名聚合,合并同包名所有 PID 的行数,不输出 PID 明细;\n * false = 展示 PID 明细(含各 PID 对应包名)。\n */\nfun runLogStat(path: String, mode: StatMode, aggregateByPackage: Boolean = true) {\n val input = File(path)\n if (!input.exists()) {\n println(\"Path not found: $path\")\n return\n }\n\n // 收集待处理的文件列表\n val logFiles: List<File> = when {\n input.isFile -> {\n println(\"Input: file -> ${input.name}\")\n listOf(input)\n }\n input.isDirectory -> {\n val files = input.walkTopDown()\n .filter { it.isFile && it.extension.lowercase() in LOG_FILE_EXTENSIONS }\n .sortedBy { it.name }\n .toList()\n println(\"Input: directory -> ${input.absolutePath}\")\n println(\"Found ${files.size} log file(s): ${files.joinToString { it.name }}\")\n files\n }\n else -> {\n println(\"Unsupported path type: $path\")\n return\n }\n }\n\n if (logFiles.isEmpty()) {\n println(\"No log files found.\")\n return\n }\n\n // 跨文件累计的统计数据\n val pidPackageMap = mutableMapOf<String, String>() // pid -> packageName\n val pidLineCount = mutableMapOf<String, Long>() // pid -> lineCount\n val tagLineCount = mutableMapOf<String, Long>() // tag -> lineCount\n var totalLines = 0L\n\n // 逐文件解析,数据累加到同一份 Map 中\n logFiles.forEachIndexed { index, file ->\n println(\" [${index + 1}/${logFiles.size}] Parsing: ${file.name} (${file.length() / 1024} KB)\")\n totalLines += parseLogFile(file, pidPackageMap, pidLineCount, tagLineCount)\n }\n\n if (totalLines == 0L) {\n println(\"No valid log lines found.\")\n return\n }\n\n when (mode) {\n StatMode.BY_PACKAGE -> {\n val packageLineCount = mutableMapOf<String, Long>()\n var unknownLines = 0L\n for ((pid, count) in pidLineCount) {\n val packageName = pidPackageMap[pid]\n if (packageName != null) {\n packageLineCount[packageName] = packageLineCount.getOrDefault(packageName, 0L) + count\n } else {\n unknownLines += count\n }\n }\n if (unknownLines > 0) packageLineCount[\"(Unknown)\"] = unknownLines\n printPackageStatistics(totalLines, pidLineCount, pidPackageMap, packageLineCount, aggregateByPackage)\n }\n StatMode.BY_TAG -> {\n printTagStatistics(totalLines, tagLineCount)\n }\n }\n}\n\n/**\n * 解析单个日志文件,将结果累加到传入的 Map 中。\n *\n * @param file 待解析的日志文件\n * @param pidPackageMap pid -> packageName 映射(累加写入)\n * @param pidLineCount pid -> 行数 映射(累加写入)\n * @param tagLineCount tag -> 行数 映射(累加写入)\n * @return 本文件中解析到的有效日志行数\n */\nfun parseLogFile(\n file: File,\n pidPackageMap: MutableMap<String, String>,\n pidLineCount: MutableMap<String, Long>,\n tagLineCount: MutableMap<String, Long>\n): Long {\n var lines = 0L\n\n file.forEachLine { line ->\n // 忽略 \"beginning of ...\" 等分隔行\n if (line.contains(\"beginning of \")) return@forEachLine\n\n // 解析 Logcat 标准格式:MM-DD HH:MM:SS.mmm PID TID LEVEL TAG: message\n val parts = line.split(Regex(\" +\"), 7)\n val pid = parts.getOrNull(2) ?: return@forEachLine\n\n // 仅统计 PID 为纯数字的行(过滤无效行)\n if (!pid.all { it.isDigit() }) return@forEachLine\n\n lines++\n pidLineCount[pid] = pidLineCount.getOrDefault(pid, 0L) + 1L\n\n // 提取 TAG(index=5,去除末尾冒号)\n val tag = parts.getOrNull(5)?.trimEnd(':')?.trim() ?: \"(NoTag)\"\n tagLineCount[tag] = tagLineCount.getOrDefault(tag, 0L) + 1L\n\n // 尝试从当前行提取 PID -> 包名 信息\n START_PROC_REGEX.find(line)?.let { match ->\n pidPackageMap[match.groupValues[1]] = match.groupValues[2].trim()\n }\n CRASH_PROC_REGEX.find(line)?.let { match ->\n pidPackageMap[match.groupValues[2]] = match.groupValues[1].trim()\n }\n }\n\n return lines\n}\n\n/**\n * 按包名维度格式化并打印统计结果。\n *\n * @param aggregateByPackage true = 仅输出包名聚合表(不含 PID 明细);false = 同时输出 PID 明细。\n */\nfun printPackageStatistics(\n totalLines: Long,\n pidLineCount: Map<String, Long>,\n pidPackageMap: Map<String, String>,\n packageLineCount: Map<String, Long>,\n aggregateByPackage: Boolean = true\n) {\n println(\"\\n===== Log Statistics (By Package) =====\")\n println(\"Total Valid Lines : $totalLines\")\n println(\"Unique PIDs : ${pidLineCount.size}\")\n println(\"Identified Pkgs : ${pidPackageMap.values.toSet().size}\")\n println()\n\n val sortedPkgEntries = packageLineCount.entries.sortedByDescending { it.value }\n val colPkg = maxOf(sortedPkgEntries.maxOf { it.key.length }, 11)\n\n printTable(\n labelHeader = \"PackageName\",\n colWidth = colPkg,\n totalLines = totalLines,\n entries = sortedPkgEntries\n )\n\n // aggregateByPackage = false 时额外输出各 PID 明细\n if (!aggregateByPackage) {\n println(\"\\n===== PID Detail =====\")\n val pidHeader = \"%8s %-${colPkg}s %8s %8s\".format(\"PID\", \"PackageName\", \"Lines\", \"Percent\")\n println(pidHeader)\n println(\"-\".repeat(pidHeader.length))\n pidLineCount.entries\n .sortedByDescending { it.value }\n .forEach { (pid, lines) ->\n val pkg = pidPackageMap[pid] ?: \"(Unknown)\"\n val percent = \"%.2f%%\".format(lines.toDouble() / totalLines * 100.0)\n println(\"%8s %-${colPkg}s %8d %8s\".format(pid, pkg, lines, percent))\n }\n }\n}\n\n/**\n * 按 TAG 维度格式化并打印统计结果。\n */\nfun printTagStatistics(\n totalLines: Long,\n tagLineCount: Map<String, Long>\n) {\n println(\"\\n===== Log Statistics (By TAG) =====\")\n println(\"Total Valid Lines : $totalLines\")\n println(\"Unique TAGs : ${tagLineCount.size}\")\n println()\n\n val sortedTagEntries = tagLineCount.entries.sortedByDescending { it.value }\n val colTag = maxOf(sortedTagEntries.maxOf { it.key.length }, 3)\n\n printTable(\n labelHeader = \"TAG\",\n colWidth = colTag,\n totalLines = totalLines,\n entries = sortedTagEntries\n )\n}\n\n/** 通用表格打印:标签列 + Lines 列 + Percent 列,末行打印合计。 */\nprivate fun printTable(\n labelHeader: String,\n colWidth: Int,\n totalLines: Long,\n entries: List<Map.Entry<String, Long>>\n) {\n val header = \"%-${colWidth}s %8s %8s\".format(labelHeader, \"Lines\", \"Percent\")\n val divider = \"-\".repeat(header.length)\n println(header)\n println(divider)\n entries.forEach { (label, lines) ->\n val percent = \"%.2f%%\".format(lines.toDouble() / totalLines * 100.0)\n println(\"%-${colWidth}s %8d %8s\".format(label, lines, percent))\n }\n println(divider)\n println(\"%-${colWidth}s %8d %8s\".format(\"TOTAL\", totalLines, \"100.00%\"))\n}\n"}}}} |