Files

1 line
67 KiB
JSON
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.
{"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"}}}}