WhatsApp-Chatexport einlesen und Videos chronologisch ordnen
Erkennt iPhone- und Android-Exporte auf Deutsch und Englisch, ordnet Videos nach Zeitstempel statt nach Chatreihenfolge, meldet fehlende Dateien, markiert doppelt gepostete Videos und filtert auf Montag bis Freitag der Vorwoche. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
f7d249f28b
commit
899b212cba
2 files changed
+362
No files matched your search
@@ -0,0 +1,209 @@
|
||||
// Liest WhatsApp-Chatexporte (iPhone und Android, deutsch und englisch) und
|
||||
// ordnet die Videos des Exports chronologisch. Läuft im Browser und in Node.
|
||||
|
||||
// Unsichtbare Richtungszeichen, die WhatsApp vor Zeilen und Namen setzt.
|
||||
const INVISIBLE = /[\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
|
||||
const ODD_SPACES = /[\u00a0\u202f]/g;
|
||||
|
||||
// iPhone: "[24.09.26, 16:36:19] Name: Text"
|
||||
const IOS_HEADER =
|
||||
/^\[(\d{1,2})([./])(\d{1,2})\2(\d{2,4}),? (\d{1,2}):(\d{2})(?::(\d{2}))?(?: ?([AP]M))?\] (.*)$/i;
|
||||
// Android: "24.09.26, 16:36 - Name: Text"
|
||||
const ANDROID_HEADER =
|
||||
/^(\d{1,2})([./])(\d{1,2})\2(\d{2,4}),? (\d{1,2}):(\d{2})(?::(\d{2}))?(?: ?([AP]M))? - (.*)$/i;
|
||||
|
||||
// "<Anhang: 00001338-VIDEO-2026-09-24-16-36-19.mp4>" bzw. "<attached: …>"
|
||||
const TAGGED_ATTACHMENT = /<[^<>:]{1,40}:\s*([^<>]+?\.[A-Za-z0-9]{2,5})>/g;
|
||||
// "VID-20260924-WA0012.mp4 (Datei angehängt)" bzw. "(file attached)"
|
||||
const ANDROID_ATTACHMENT = /([^\s<>]+\.[A-Za-z0-9]{2,5}) \((?:Datei angehängt|file attached)\)/g;
|
||||
|
||||
const VIDEO_EXTENSION = /\.(mp4|mov|m4v|3gp|avi|mkv|webm)$/i;
|
||||
|
||||
const pad = (value) => String(value).padStart(2, '0');
|
||||
|
||||
function cleanLine(line) {
|
||||
return line.replace(INVISIBLE, '').replace(ODD_SPACES, ' ');
|
||||
}
|
||||
|
||||
export function isVideoFile(name) {
|
||||
return VIDEO_EXTENSION.test(name);
|
||||
}
|
||||
|
||||
export function baseName(path) {
|
||||
return path.split(/[\\/]/).pop();
|
||||
}
|
||||
|
||||
// Bei Datumsangaben mit Schrägstrich ist die Reihenfolge nicht eindeutig
|
||||
// (US: M/D, sonst D/M). Ein Tag über 12 irgendwo im Chat entscheidet.
|
||||
function detectDayFirst(lines) {
|
||||
for (const line of lines) {
|
||||
const match = line.match(IOS_HEADER) || line.match(ANDROID_HEADER);
|
||||
if (match && match[2] === '/' && Number(match[1]) > 12) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function toTimestamp(match, dayFirst) {
|
||||
const [, first, separator, second, rawYear, rawHour, minute, second_ = '0', meridiem] = match;
|
||||
let day = Number(first);
|
||||
let month = Number(second);
|
||||
if (separator === '/' && !dayFirst) [day, month] = [month, day];
|
||||
let year = Number(rawYear);
|
||||
if (year < 100) year += 2000;
|
||||
let hour = Number(rawHour);
|
||||
if (meridiem) {
|
||||
const pm = meridiem.toUpperCase() === 'PM';
|
||||
if (hour === 12) hour = pm ? 12 : 0;
|
||||
else if (pm) hour += 12;
|
||||
}
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23) return null;
|
||||
return `${year}-${pad(month)}-${pad(day)}T${pad(hour)}:${minute}:${pad(second_)}`;
|
||||
}
|
||||
|
||||
function splitSender(rest) {
|
||||
const index = rest.indexOf(': ');
|
||||
if (index > 0) return { sender: rest.slice(0, index).trim(), text: rest.slice(index + 2) };
|
||||
if (rest.endsWith(':')) return { sender: rest.slice(0, -1).trim(), text: '' };
|
||||
// Systemmeldung ohne Absender, z. B. "Max hat die Gruppe verlassen"
|
||||
return { sender: null, text: rest };
|
||||
}
|
||||
|
||||
export function findAttachments(text) {
|
||||
const names = [];
|
||||
for (const pattern of [TAGGED_ATTACHMENT, ANDROID_ATTACHMENT]) {
|
||||
for (const match of text.matchAll(pattern)) names.push(match[1].trim());
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function captionOf(text) {
|
||||
return text.replace(TAGGED_ATTACHMENT, '').replace(ANDROID_ATTACHMENT, '').trim();
|
||||
}
|
||||
|
||||
export function parseChat(rawText) {
|
||||
const lines = rawText.replace(/\r\n?/g, '\n').split('\n').map(cleanLine);
|
||||
const dayFirst = detectDayFirst(lines);
|
||||
const messages = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(IOS_HEADER) || line.match(ANDROID_HEADER);
|
||||
const timestamp = match && toTimestamp(match, dayFirst);
|
||||
if (timestamp) {
|
||||
messages.push({ index: messages.length, timestamp, ...splitSender(match[9]) });
|
||||
} else if (messages.length > 0) {
|
||||
messages[messages.length - 1].text += `\n${line}`;
|
||||
}
|
||||
}
|
||||
|
||||
return messages.map((message) => ({
|
||||
...message,
|
||||
text: message.text.trim(),
|
||||
attachments: findAttachments(message.text),
|
||||
}));
|
||||
}
|
||||
|
||||
// Fallback für Videos ohne Chatzeile: der Dateiname trägt den Zeitpunkt.
|
||||
export function timestampFromFileName(name) {
|
||||
const ios = name.match(/(\d{4})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(\d{2})\.\w+$/);
|
||||
if (ios) return `${ios[1]}-${ios[2]}-${ios[3]}T${ios[4]}:${ios[5]}:${ios[6]}`;
|
||||
const android = name.match(/-(\d{4})(\d{2})(\d{2})-WA\d+/);
|
||||
if (android) return `${android[1]}-${android[2]}-${android[3]}T00:00:00`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function compareClips(a, b) {
|
||||
return (
|
||||
a.timestamp.localeCompare(b.timestamp) ||
|
||||
a.order - b.order ||
|
||||
a.file.localeCompare(b.file)
|
||||
);
|
||||
}
|
||||
|
||||
// Doppelt gepostete Videos sind bytegleich; die Dateigröße genügt als Merkmal.
|
||||
function markDuplicates(clips) {
|
||||
const firstBySize = new Map();
|
||||
for (const clip of clips) {
|
||||
if (!clip.size) continue;
|
||||
const first = firstBySize.get(clip.size);
|
||||
if (first) clip.duplicateOf = first.file;
|
||||
else firstBySize.set(clip.size, clip);
|
||||
}
|
||||
}
|
||||
|
||||
// files: [{ name, size }] aus dem Export (Pfade erlaubt).
|
||||
// Liefert die Videos chronologisch, dazu im Chat genannte, aber fehlende Dateien.
|
||||
export function collectClips(messages, files) {
|
||||
const videos = new Map();
|
||||
for (const file of files) {
|
||||
if (isVideoFile(file.name)) videos.set(baseName(file.name), file);
|
||||
}
|
||||
|
||||
const clips = [];
|
||||
const missing = [];
|
||||
const used = new Set();
|
||||
|
||||
for (const message of messages) {
|
||||
for (const attachment of message.attachments) {
|
||||
if (!isVideoFile(attachment)) continue;
|
||||
const name = baseName(attachment);
|
||||
const file = videos.get(name);
|
||||
if (!file) {
|
||||
missing.push({ file: name, timestamp: message.timestamp, sender: message.sender });
|
||||
continue;
|
||||
}
|
||||
if (used.has(name)) continue;
|
||||
used.add(name);
|
||||
clips.push({
|
||||
file: name,
|
||||
path: file.name,
|
||||
size: file.size ?? 0,
|
||||
timestamp: message.timestamp,
|
||||
sender: message.sender,
|
||||
caption: captionOf(message.text),
|
||||
order: message.index,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, file] of videos) {
|
||||
if (used.has(name)) continue;
|
||||
const timestamp = timestampFromFileName(name);
|
||||
if (!timestamp) continue;
|
||||
clips.push({
|
||||
file: name,
|
||||
path: file.name,
|
||||
size: file.size ?? 0,
|
||||
timestamp,
|
||||
sender: null,
|
||||
caption: '',
|
||||
order: Number.MAX_SAFE_INTEGER,
|
||||
});
|
||||
}
|
||||
|
||||
clips.sort(compareClips);
|
||||
markDuplicates(clips);
|
||||
return { clips, missing };
|
||||
}
|
||||
|
||||
// from/to als "JJJJ-MM-TT", beide Tage eingeschlossen.
|
||||
export function clipsInRange(clips, from, to) {
|
||||
return clips.filter((clip) => {
|
||||
const day = clip.timestamp.slice(0, 10);
|
||||
return (!from || day >= from) && (!to || day <= to);
|
||||
});
|
||||
}
|
||||
|
||||
export function toDateString(date) {
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
}
|
||||
|
||||
// Montag bis Freitag der Kalenderwoche vor der von `today`.
|
||||
export function previousWorkWeek(today = new Date()) {
|
||||
const date = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
const daysSinceMonday = (date.getDay() + 6) % 7;
|
||||
const monday = new Date(date);
|
||||
monday.setDate(date.getDate() - daysSinceMonday - 7);
|
||||
const friday = new Date(monday);
|
||||
friday.setDate(monday.getDate() + 4);
|
||||
return { from: toDateString(monday), to: toDateString(friday) };
|
||||
}
|
||||
Reference in new issue
Block a user