feat: WhatsApp V2 时间戳弹性解析器,自动适配各地区导出变体

将 V2 方括号正则从严格编码改为宽松捕获 + 弹性时间解析器,
自动处理多语言 AM/PM 标记(上午/下午、AM/PM、오전/오후、午前/午後)
和日期顺序推断(YYYY/MM/DD、DD/MM/YYYY、M/D/YY),
新增媒体类型识别(圖片已略去、image omitted 等)

Made-with: Cursor
This commit is contained in:
digua
2026-04-12 00:44:25 +08:00
committed by digua
parent 5b96fec7e1
commit 72e0e420f2
@@ -5,12 +5,13 @@
* 格式特征:
* - 文件头:消息和通话已进行端到端加密 / Messages and calls are end-to-end encrypted
* - 消息格式(V1,无方括号):YYYY/MM/DD HH:MM - 昵称: 内容
* - 消息格式(V2,方括号):
* - [M/D/YY HH:MM:SS] 昵称: 内容(2 位年份)
* - [YYYY/MM/DD HH:MM:SS] 昵称: 内容(4 位年份在前,中文地区)
* - [DD/MM/YYYY, HH:MM:SS] 昵称: 内容(4 位年份在后,英文地区)
* - 消息格式(V2,方括号):[时间戳] 昵称: 内容
* 时间戳由弹性解析器处理,自动适配各地区变体:
* - 日期顺序:YYYY/MM/DD | DD/MM/YYYY | M/D/YY
* - AM/PM 标记:24h 无标记 | AM/PM | 上午/下午 | 오전/오후 | 午前/午後
* - 分隔符:逗号 / 空格 / 特殊空格(U+2009, U+202F
* - 系统消息:时间戳 + 系统内容(无冒号分隔)
* - 媒体占位:<省略影音内容>
* - 媒体占位:<省略影音内容> / 圖片已略去 等
*/
import * as fs from 'fs'
@@ -65,7 +66,7 @@ export const feature: FormatFeature = {
/Messages and calls are end-to-end encrypted/i, // 英文加密提示(WhatsApp 独有)
/你发送给自己的消息已进行端到端加密/, // 简体中文自己对话提示(WhatsApp 独有)
/\d{4}\/\d{1,2}\/\d{1,2} \d{1,2}:\d{2} - /, // 消息行格式特征(无方括号,含 " - " 分隔符,WhatsApp 独有)
/\[\d{1,4}\/\d{1,2}\/\d{2,4},?[ \u2009]\d{1,2}:\d{2}:\d{2}\] /, // 消息行格式特征(方括号 + 秒级时间戳,支持 Thin Space
/\[\d{1,4}\/\d{1,2}\/\d{2,4}[\s,].*\d{1,2}:\d{2}:\d{2}.*\] /, // 消息行格式特征(方括号 + 含日期和时间的时间戳,兼容各地区变体
],
// 文件名特征:与xxx的 WhatsApp 聊天.txt
filename: [/^与.+的\s*WhatsApp\s*聊天\.txt$/i, /^與.+的\s*WhatsApp\s*對話\.txt$/i, /WhatsApp/i],
@@ -89,14 +90,10 @@ function cleanLine(line: string): string {
// 支持月份、日期、小时为 1-2 位数字
const MESSAGE_LINE_REGEX_V1 = /^(\d{4}\/\d{1,2}\/\d{1,2} \d{1,2}:\d{2}) - (.+)$/
// 格式2(方括号格式):支持多种地区导出
// - [6/7/25 22:44:26] 或 [10/12/25, 12:50:16]2 位年份,M/D/YY
// - [2024/10/31 15:50:46]4 位年份在前,YYYY/MM/DD,中文地区)
// - [31/10/2024, 15:50:46]4 位年份在后,DD/MM/YYYY,英文地区)
// - [14/10/2021, 3:34:09 PM]12 小时制,英文地区)
// 日期和时间之间可能有逗号(英文)或没有(中文)
// 注意:部分导出文件在时间和 AM/PM 之间包含 \u202F (NNBSP) 字符
const MESSAGE_LINE_REGEX_V2 = /^\[(\d{1,4}\/\d{1,2}\/\d{2,4},?\s*\d{1,2}:\d{2}:\d{2}(?:\s*(?:AM|PM))?)\] (.+)$/
// 格式2(方括号格式):宽松捕获 [时间戳] 后的内容
// 时间戳的地区变体由 parseFlexibleV2Timestamp() 弹性解析器处理
// 已知变体包括但不限于:中文 上午/下午、英文 AM/PM、韩文 오전/오후、各种日期顺序
const MESSAGE_LINE_REGEX_V2 = /^\[([^\]]+)\] (.+)$/
// 从消息内容中分离昵称和实际内容
// 格式:昵称: 内容(冒号后可能是空格、U+200E LTR Mark 或两者组合)
@@ -148,16 +145,27 @@ function isSystemMessage(content: string): boolean {
function detectMessageType(content: string): MessageType {
const trimmed = content.trim()
// 媒体消息(简体/繁体)
if (trimmed === '<省略影音内容>' || trimmed === '<已省略多媒體檔案>') return MessageType.IMAGE
// 媒体消息(简体/繁体多种变体
if (
trimmed === '<省略影音内容>' ||
trimmed === '<已省略多媒體檔案>' ||
trimmed === '圖片已略去' ||
trimmed === '影片已略去' ||
trimmed === '音訊已略去' ||
trimmed === 'image omitted' ||
trimmed === 'video omitted' ||
trimmed === 'audio omitted'
)
return MessageType.IMAGE
if (trimmed.includes('<已附加:') || trimmed.includes('<附件:') || trimmed.includes('<已附加:'))
return MessageType.FILE
// 贴图/贴纸(繁体中文导出标记)
if (trimmed === '貼圖已忽略') return MessageType.EMOJI
if (trimmed === '貼圖已忽略' || trimmed === '貼圖已略去') return MessageType.EMOJI
// 删除消息(简体/繁体)
if (trimmed === '这条消息已删除' || trimmed.startsWith('此訊息已刪除')) return MessageType.RECALL
// 删除消息(简体/繁体多种变体
if (trimmed === '这条消息已删除' || trimmed.startsWith('此訊息已刪除') || trimmed.startsWith('你已刪除此訊息'))
return MessageType.RECALL
// 系统消息
if (isSystemMessage(trimmed)) return MessageType.SYSTEM
@@ -167,62 +175,87 @@ function detectMessageType(content: string): MessageType {
// ==================== 时间解析 ====================
// 多语言 AM/PM 标记映射(值为 true 表示 PMfalse 表示 AM
const AMPM_MARKERS: [RegExp, boolean][] = [
[/\bPM\b/i, true],
[/\bP\.M\.\b/i, true],
[/下午/, true],
[/午後/, true],
[/오후/, true],
[/\bAM\b/i, false],
[/\bA\.M\.\b/i, false],
[/上午/, false],
[/午前/, false],
[/오전/, false],
]
/**
* 解析 WhatsApp 时间格式为秒级时间戳
* 支持多种格式:
* - 格式12025/12/22 12:35 或 2025/2/2 9:35YYYY/M/D H:MM,月日时可为 1-2 位)
* - 格式2(方括号):
* - 6/7/25 22:44:26M/D/YY HH:MM:SS2 位年份)
* - 2024/10/31 15:50:46YYYY/MM/DD HH:MM:SS4 位年份在前,中文地区)
* - 31/10/2024, 15:50:46DD/MM/YYYY, HH:MM:SS4 位年份在后,英文地区)
* - 14/10/2021, 3:34:09 PM12 小时制,英文地区)
* 弹性解析 V2(方括号)时间戳
* 自动处理各地区变体:日期顺序、AM/PM 多语言标记、特殊空格等
* @returns 秒级时间戳,解析失败返回 null
*/
function parseWhatsAppTime(timeStr: string, isV2Format: boolean = false): number {
if (isV2Format) {
// 方括号格式:规范化特殊空格(Thin Space U+2009, NNBSP U+202F)和逗号
// 逗号替换为空格(非删除),确保日期和时间之间总有分隔
const normalizedStr = timeStr
.replace(/[\u2009\u202F]/g, ' ')
.replace(',', ' ')
.replace(/\s+/g, ' ')
.trim()
const match = normalizedStr.match(/^(\d{1,4})\/(\d{1,2})\/(\d{2,4}) (\d{1,2}):(\d{2}):(\d{2})(?:\s*(AM|PM))?$/i)
if (match) {
const [, part1, part2, part3, hour, minute, second, ampm] = match
let year: number, month: number, day: number, hourNum: number
function parseFlexibleV2Timestamp(raw: string): number | null {
// 1. 规范化特殊空格和不可见字符
let str = raw.replace(/(?:\u2009|\u202F|\uFEFF|\u200E|\u200F|\u200B|\u200C|\u200D|\u2060)/g, ' ').trim()
if (part1.length === 4) {
// YYYY/MM/DD 格式(中文地区,4 位年份在前)
year = parseInt(part1, 10)
month = parseInt(part2, 10)
day = parseInt(part3, 10)
} else if (part3.length === 4) {
// DD/MM/YYYY 格式(英文地区,4 位年份在后)
day = parseInt(part1, 10)
month = parseInt(part2, 10)
year = parseInt(part3, 10)
} else {
// M/D/YY 格式(2 位年份,假设 00-99 对应 2000-2099
month = parseInt(part1, 10)
day = parseInt(part2, 10)
year = 2000 + parseInt(part3, 10)
}
hourNum = parseInt(hour, 10)
if (ampm) {
if (ampm.toUpperCase() === 'PM' && hourNum !== 12) {
hourNum += 12
} else if (ampm.toUpperCase() === 'AM' && hourNum === 12) {
hourNum = 0
}
}
const date = new Date(year, month - 1, day, hourNum, parseInt(minute, 10), parseInt(second, 10))
return Math.floor(date.getTime() / 1000)
// 2. 提取并移除 AM/PM 标记(任何语言)
let isPM: boolean | null = null
for (const [pattern, pm] of AMPM_MARKERS) {
if (pattern.test(str)) {
isPM = pm
str = str.replace(pattern, '').trim()
break
}
}
// 格式1YYYY/M/D H:MM(月、日、时可为 1-2 位数字)
// 3. 移除逗号,规范化连续空格
str = str.replace(/,/g, ' ').replace(/\s+/g, ' ').trim()
// 4. 提取日期部分(含 /)和时间部分(含 :)
const match = str.match(/^(\d{1,4}\/\d{1,2}\/\d{2,4})\s+(\d{1,2}:\d{2}(?::\d{2})?)$/)
if (!match) return null
const [, datePart, timePart] = match
const dateParts = datePart.split('/').map((s) => parseInt(s, 10))
const timeParts = timePart.split(':').map((s) => parseInt(s, 10))
// 5. 推断日期顺序
let year: number, month: number, day: number
if (dateParts[0] > 31) {
// 第一段 > 31 → 一定是年份 → YYYY/MM/DD
year = dateParts[0]
month = dateParts[1]
day = dateParts[2]
} else if (dateParts[2] > 31) {
// 第三段 > 31 → 一定是年份 → DD/MM/YYYY
day = dateParts[0]
month = dateParts[1]
year = dateParts[2]
} else {
// 全部 ≤ 31 → M/D/YY2 位年份,00-99 → 2000-2099
month = dateParts[0]
day = dateParts[1]
year = 2000 + dateParts[2]
}
// 6. 解析时间
let hour = timeParts[0]
const minute = timeParts[1]
const second = timeParts[2] ?? 0
if (isPM === true && hour !== 12) hour += 12
if (isPM === false && hour === 12) hour = 0
// 7. 构造日期并校验
const date = new Date(year, month - 1, day, hour, minute, second)
const ts = Math.floor(date.getTime() / 1000)
return isNaN(ts) ? null : ts
}
/**
* 解析 V1(无方括号)时间格式:YYYY/M/D H:MM
*/
function parseV1Timestamp(timeStr: string): number {
const match = timeStr.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2}) (\d{1,2}):(\d{2})$/)
if (match) {
const [, year, month, day, hour, minute] = match
@@ -237,7 +270,7 @@ function parseWhatsAppTime(timeStr: string, isV2Format: boolean = false): number
return Math.floor(date.getTime() / 1000)
}
// 兜底:尝试标准格式解析(YYYY/MM/DD HH:MM
// 兜底
const normalized = timeStr.replace(/\//g, '-').replace(' ', 'T') + ':00'
const date = new Date(normalized)
return Math.floor(date.getTime() / 1000)
@@ -325,54 +358,64 @@ async function* parseWhatsApp(options: ParseOptions): AsyncGenerator<ParseEvent,
// 清理行首不可见字符
const cleanedLine = cleanLine(line)
// 尝试匹配消息行(两种格式
let lineMatch = cleanedLine.match(MESSAGE_LINE_REGEX_V1)
let isV2Format = false
if (!lineMatch) {
lineMatch = cleanedLine.match(MESSAGE_LINE_REGEX_V2)
isV2Format = true
}
if (lineMatch) {
// 保存前一条消息
// 尝试匹配消息行:优先 V1(严格),再 V2(宽松 + 弹性时间解析
const v1Match = cleanedLine.match(MESSAGE_LINE_REGEX_V1)
if (v1Match) {
saveCurrentMessage()
const timeStr = lineMatch[1]
const restContent = lineMatch[2]
// 尝试分离发送者和内容
const restContent = v1Match[2]
const senderMatch = restContent.match(SENDER_CONTENT_REGEX)
if (senderMatch && !isSystemMessage(restContent)) {
// 普通消息
currentMessage = {
timestamp: parseWhatsAppTime(timeStr, isV2Format),
timestamp: parseV1Timestamp(v1Match[1]),
sender: senderMatch[1].trim(),
contentLines: [senderMatch[2]],
}
} else {
// 系统消息
currentMessage = {
timestamp: parseWhatsAppTime(timeStr, isV2Format),
timestamp: parseV1Timestamp(v1Match[1]),
sender: null,
contentLines: [restContent],
}
}
// 更新进度
if (messagesProcessed % 500 === 0) {
const progress = createProgress(
'parsing',
bytesRead,
totalBytes,
messagesProcessed,
`已处理 ${messagesProcessed} 条消息...`
onProgress?.(
createProgress('parsing', bytesRead, totalBytes, messagesProcessed, `已处理 ${messagesProcessed} 条消息...`)
)
onProgress?.(progress)
}
continue
}
const v2Match = cleanedLine.match(MESSAGE_LINE_REGEX_V2)
if (v2Match) {
const timestamp = parseFlexibleV2Timestamp(v2Match[1])
if (timestamp !== null) {
// 弹性解析成功 → 新消息
saveCurrentMessage()
const restContent = v2Match[2]
const senderMatch = restContent.match(SENDER_CONTENT_REGEX)
if (senderMatch && !isSystemMessage(restContent)) {
currentMessage = {
timestamp,
sender: senderMatch[1].trim(),
contentLines: [senderMatch[2]],
}
} else {
currentMessage = {
timestamp,
sender: null,
contentLines: [restContent],
}
}
if (messagesProcessed % 500 === 0) {
onProgress?.(
createProgress('parsing', bytesRead, totalBytes, messagesProcessed, `已处理 ${messagesProcessed} 条消息...`)
)
}
continue
}
// 弹性解析失败 → 方括号内容不是有效时间戳,降级为续行
}
// 非消息行:可能是多行消息的延续
if (currentMessage && cleanedLine) {
currentMessage.contentLines.push(cleanedLine)