fix(mail): Duplikate auch bei neu erzeugten PDFs erkennen
Build and Push Multi-Platform Images / build-and-push (push) Successful in 45s

Absender erzeugen PDFs beim erneuten Versand oft neu (andere
Erstellungszeit/Dokument-ID), dadurch unterscheidet sich die Checksumme
und die Duplikatprüfung griff nicht (z.B. Mail 7071 vs. 7072).

- Neuer Text-Fingerprint (SHA-256 über normalisierten PDF-Text via
  pdf-parse) in Attachments.ContentFingerprint, inkl. Migration und Index
- Wird beim Mail-Abruf berechnet; Altbestand füllt ein Hintergrund-
  Backfill beim Start auf, fehlende Werte werden bei Bedarf nachberechnet
- Duplikatprüfung und Import-Sperre berücksichtigen Anhänge mit gleichem
  Inhalt, die importiert sind oder gerade importiert werden
- Jest braucht für pdf-parse --experimental-vm-modules (test-Skripte)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
bjoernpoettkerandClaude Opus 5.5 committed 2026-09-25 12:27:31 +02:00
1 parent 534b313054
commit 797cbb9b71
12 files changed
+746 -7

No files matched your search

@@ -31,6 +31,15 @@ export class Attachment {
@Column({ type: 'varchar', length: 32, default: '' })
Checksum!: string;
/**
* SHA-256 über den normalisierten PDF-Text (siehe pdf-fingerprint.util).
* Erkennt Duplikate, deren Datei neu erzeugt wurde (andere Checksumme).
* `''` = berechnet, aber ohne Textebene; `null` = noch nicht berechnet.
*/
@Index('IX_Attachments_ContentFingerprint')
@Column({ type: 'varchar', length: 64, nullable: true })
ContentFingerprint!: string | null;
@Column({ type: 'varchar', length: 255, nullable: true })
ContentId!: string | null;
@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Inhaltsbasierter Fingerprint für Mail-Anhänge: erkennt Duplikate, deren
* PDF vom Absender neu erzeugt wurde und daher eine andere Checksumme hat.
* Bestehende Anhänge werden beim Start im Hintergrund nachberechnet
* (AttachmentFingerprintService).
*/
export class AttachmentContentFingerprint1790000000000 implements MigrationInterface {
name = 'AttachmentContentFingerprint1790000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE \`Attachments\`
ADD \`ContentFingerprint\` varchar(64) NULL DEFAULT NULL
`);
await queryRunner.query(`
CREATE INDEX \`IX_Attachments_ContentFingerprint\`
ON \`Attachments\` (\`ContentFingerprint\`)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX \`IX_Attachments_ContentFingerprint\` ON \`Attachments\``,
);
await queryRunner.query(
`ALTER TABLE \`Attachments\` DROP COLUMN \`ContentFingerprint\``,
);
}
}
@@ -19,6 +19,7 @@ import { Email } from '../database/entities/email.entity';
import { Attachment } from '../database/entities/attachment.entity';
import { Content } from '../database/entities/content.entity';
import { isERechnung } from './zugferd.util';
import { computeContentFingerprint } from '../email/pdf-fingerprint.util';
function sanitizeFilename(name: string): string {
return name
@@ -304,6 +305,10 @@ export class EmailDownloadService {
attachment.IsEmbedded = isEmbedded;
attachment.ContentId = att.cid ? att.cid.slice(0, 255) : null;
attachment.Checksum = crypto.createHash('md5').update(buffer).digest('hex');
attachment.ContentFingerprint =
contentType.toLowerCase() === 'application/pdf'
? await computeContentFingerprint(buffer)
: null;
attachment.Erechnung =
contentType.toLowerCase() === 'application/pdf'
? isERechnung(buffer)
@@ -0,0 +1,157 @@
import { Test } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Brackets } from 'typeorm';
import { PDFDocument, StandardFonts } from 'pdf-lib';
import { AttachmentFingerprintService } from './attachment-fingerprint.service';
import { Attachment } from '../database/entities/attachment.entity';
import { Content } from '../database/entities/content.entity';
async function pdfMitText(text: string): Promise<Buffer> {
const doc = await PDFDocument.create();
const font = await doc.embedFont(StandardFonts.Helvetica);
doc.addPage().drawText(text, { x: 50, y: 700, font, size: 12 });
return Buffer.from(await doc.save());
}
const pdfAnhang = (felder: Partial<Attachment> = {}) =>
({
Id: 7,
ContentType: 'application/pdf',
ContentFingerprint: null,
...felder,
}) as Attachment;
describe('AttachmentFingerprintService', () => {
let service: AttachmentFingerprintService;
let query: Record<string, jest.Mock>;
let attachmentRepo: { update: jest.Mock; createQueryBuilder: jest.Mock };
let contentRepo: { findOne: jest.Mock };
beforeEach(async () => {
query = {
select: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([]),
getCount: jest.fn().mockResolvedValue(0),
};
attachmentRepo = {
update: jest.fn(),
createQueryBuilder: jest.fn().mockReturnValue(query),
};
contentRepo = {
findOne: jest.fn().mockResolvedValue({
Content1: await pdfMitText('Lastschriftankuendigung Mandat 0000844118'),
}),
};
const module = await Test.createTestingModule({
providers: [
AttachmentFingerprintService,
{ provide: getRepositoryToken(Attachment), useValue: attachmentRepo },
{ provide: getRepositoryToken(Content), useValue: contentRepo },
],
}).compile();
service = module.get(AttachmentFingerprintService);
});
describe('ensure', () => {
it('berechnet einen fehlenden Fingerprint und speichert ihn', async () => {
const anhang = pdfAnhang();
const fp = await service.ensure(anhang);
expect(fp).toMatch(/^[0-9a-f]{64}$/);
expect(attachmentRepo.update).toHaveBeenCalledWith(7, {
ContentFingerprint: fp,
});
expect(anhang.ContentFingerprint).toBe(fp);
});
it('nutzt einen vorhandenen Fingerprint ohne erneute Berechnung', async () => {
await expect(
service.ensure(pdfAnhang({ ContentFingerprint: 'abc' })),
).resolves.toBe('abc');
expect(contentRepo.findOne).not.toHaveBeenCalled();
});
it('liefert null für PDFs ohne Textebene', async () => {
await expect(
service.ensure(pdfAnhang({ ContentFingerprint: '' })),
).resolves.toBeNull();
});
it('ignoriert Anhänge, die kein PDF sind', async () => {
await expect(
service.ensure(pdfAnhang({ ContentType: 'message/rfc822' })),
).resolves.toBeNull();
expect(contentRepo.findOne).not.toHaveBeenCalled();
});
});
describe('hasImportedTwin', () => {
const bedingungen = (range: string) => {
const brackets = query.andWhere.mock.calls
.map(([arg]) => arg as unknown)
.find((arg): arg is Brackets => arg instanceof Brackets)!;
const inner = {
where: jest.fn().mockReturnThis(),
orWhere: jest.fn().mockReturnThis(),
};
brackets.whereFactory(inner as never);
return { inner, range };
};
it('sucht andere Anhänge mit gleichem Fingerprint', async () => {
query.getCount.mockResolvedValue(1);
await expect(service.hasImportedTwin(7, 'fp', 'full')).resolves.toBe(
true,
);
expect(query.where).toHaveBeenCalledWith(
'a.ContentFingerprint = :fingerprint',
{ fingerprint: 'fp' },
);
expect(query.andWhere).toHaveBeenCalledWith('a.Id != :attachmentId', {
attachmentId: 7,
});
});
it('zählt beim ganzen Anhang jeden abgeschlossenen Import', async () => {
await service.hasImportedTwin(7, 'fp', 'full');
const { inner } = bedingungen('full');
expect(inner.where).toHaveBeenCalledWith(expect.any(String), {
jsonPath: '$."full"',
});
expect(inner.orWhere).toHaveBeenCalledWith('a.ImportStatus = 1');
});
it('verlangt bei Teilbereichen denselben Seitenbereich', async () => {
await service.hasImportedTwin(7, 'fp', '2-3');
const { inner } = bedingungen('2-3');
expect(inner.where).toHaveBeenCalledWith(expect.any(String), {
jsonPath: '$."2-3"',
});
expect(inner.orWhere).toHaveBeenCalledWith(expect.any(String), {
range: '2-3',
});
expect(inner.orWhere).not.toHaveBeenCalledWith('a.ImportStatus = 1');
});
});
it('backfill berechnet fehlende Fingerprints portionsweise', async () => {
query.getMany
.mockResolvedValueOnce([pdfAnhang({ Id: 1 }), pdfAnhang({ Id: 2 })])
.mockResolvedValueOnce([]);
await expect(service.backfill(2)).resolves.toBe(2);
expect(attachmentRepo.update).toHaveBeenCalledTimes(2);
expect(query.andWhere).toHaveBeenLastCalledWith('a.Id > :lastId', {
lastId: 2,
});
});
});
@@ -0,0 +1,124 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Brackets, Repository } from 'typeorm';
import { Attachment } from '../database/entities/attachment.entity';
import { Content } from '../database/entities/content.entity';
import { computeContentFingerprint } from './pdf-fingerprint.util';
/**
* Verwaltet den inhaltsbasierten Fingerprint der Mail-Anhänge: berechnet ihn
* bei Bedarf nach, füllt ihn für Altbestände im Hintergrund auf und findet
* bereits importierte Anhänge mit gleichem Inhalt.
*/
@Injectable()
export class AttachmentFingerprintService implements OnApplicationBootstrap {
private readonly logger = new Logger(AttachmentFingerprintService.name);
constructor(
@InjectRepository(Attachment)
private readonly attachmentRepo: Repository<Attachment>,
@InjectRepository(Content)
private readonly contentRepo: Repository<Content>,
) {}
onApplicationBootstrap(): void {
// Nicht auf den Backfill warten – der Start soll nicht blockieren
this.backfill().catch((err: Error) =>
this.logger.error(`Fingerprint-Backfill fehlgeschlagen: ${err.message}`),
);
}
/**
* Liefert den Fingerprint eines Anhangs und berechnet ihn nach, falls er
* noch fehlt (z.B. Mail vor dem Backfill eingegangen). `null`, wenn kein
* vergleichbarer Text vorliegt.
*/
async ensure(attachment: Attachment): Promise<string | null> {
if (attachment.ContentFingerprint != null) {
return attachment.ContentFingerprint || null;
}
if (attachment.ContentType !== 'application/pdf') return null;
const content = await this.contentRepo.findOne({
where: { AttachmentEntityId: attachment.Id },
});
if (!content) return null;
const fingerprint = await computeContentFingerprint(content.Content1);
if (fingerprint === null) return null;
await this.attachmentRepo.update(attachment.Id, {
ContentFingerprint: fingerprint,
});
attachment.ContentFingerprint = fingerprint;
return fingerprint || null;
}
/**
* Gibt es einen anderen Anhang mit gleichem Inhalt, dessen Seitenbereich
* bereits importiert wurde oder gerade importiert wird?
*/
async hasImportedTwin(
attachmentId: number,
fingerprint: string,
range: string,
): Promise<boolean> {
const count = await this.attachmentRepo
.createQueryBuilder('a')
.where('a.ContentFingerprint = :fingerprint', { fingerprint })
.andWhere('a.Id != :attachmentId', { attachmentId })
.andWhere(
new Brackets((qb) => {
// Abgeschlossener Import mit ermittelter Paperless-ID
qb.where(
"JSON_CONTAINS_PATH(a.PaperlessDocumentIds, 'one', :jsonPath) = 1",
{ jsonPath: `$."${range}"` },
)
// Laufender oder abgeschlossener Upload (fehlgeschlagene Tasks
// löscht der Task-Processor)
.orWhere(
'EXISTS (SELECT 1 FROM tasks t WHERE t.SourceAttachmentID = a.Id AND t.SourceAttachmentRange = :range)',
{ range },
);
// Ganzer Anhang: jeder Import zählt, auch wenn die ID beim Upload
// nicht ermittelt werden konnte
if (range === 'full') qb.orWhere('a.ImportStatus = 1');
}),
)
.getCount();
return count > 0;
}
/** Berechnet fehlende Fingerprints aller PDF-Anhänge in kleinen Portionen. */
async backfill(batchSize = 25): Promise<number> {
let lastId = 0;
let updated = 0;
for (;;) {
const batch = await this.attachmentRepo
.createQueryBuilder('a')
.select(['a.Id', 'a.ContentType', 'a.ContentFingerprint'])
.where('a.ContentFingerprint IS NULL')
.andWhere("a.ContentType = 'application/pdf'")
.andWhere('a.Id > :lastId', { lastId })
.orderBy('a.Id', 'ASC')
.take(batchSize)
.getMany();
if (batch.length === 0) break;
for (const attachment of batch) {
// lastId weiterschieben, damit unlesbare PDFs nicht endlos drankommen
lastId = attachment.Id;
try {
if ((await this.ensure(attachment)) !== null) updated++;
} catch (err) {
this.logger.warn(
`Fingerprint für Anhang ${attachment.Id} nicht berechenbar: ${(err as Error).message}`,
);
}
}
}
if (updated > 0) {
this.logger.log(`Fingerprint-Backfill: ${updated} Anhänge ergänzt.`);
}
return updated;
}
}
@@ -13,6 +13,7 @@ import { PaperlessService } from '../paperless/paperless.service';
import { PdfService } from '../preprocessing/pdf.service';
import { EmailPageCacheService } from './email-page-cache.service';
import { ImapFolderService } from './imap-folder.service';
import { AttachmentFingerprintService } from './attachment-fingerprint.service';
async function pdfMitSeiten(anzahl: number): Promise<Buffer> {
const doc = await PDFDocument.create();
@@ -34,6 +35,7 @@ describe('EmailImportService – Duplikatprüfung', () => {
checksumExists: jest.Mock;
uploadDocument: jest.Mock;
};
let fingerprints: { ensure: jest.Mock; hasImportedTwin: jest.Mock };
beforeEach(async () => {
taskQuery = {
@@ -54,6 +56,10 @@ describe('EmailImportService – Duplikatprüfung', () => {
checksumExists: jest.fn().mockResolvedValue(false),
uploadDocument: jest.fn(),
};
fingerprints = {
ensure: jest.fn().mockResolvedValue(null),
hasImportedTwin: jest.fn().mockResolvedValue(false),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
@@ -76,6 +82,7 @@ describe('EmailImportService – Duplikatprüfung', () => {
{ provide: PdfService, useValue: {} },
{ provide: EmailPageCacheService, useValue: {} },
{ provide: ImapFolderService, useValue: {} },
{ provide: AttachmentFingerprintService, useValue: fingerprints },
],
}).compile();
@@ -109,6 +116,20 @@ describe('EmailImportService – Duplikatprüfung', () => {
await expect(service.checkDuplicate(7)).resolves.toBe(false);
});
it('erkennt neu erzeugte PDFs mit gleichem Inhalt über den Fingerprint', async () => {
fingerprints.ensure.mockResolvedValue('fp-inhalt');
fingerprints.hasImportedTwin.mockResolvedValue(true);
await expect(service.checkDuplicate(7)).resolves.toBe(true);
expect(fingerprints.hasImportedTwin).toHaveBeenCalledWith(
7,
'fp-inhalt',
'full',
);
expect(paperlessService.checksumExists).not.toHaveBeenCalled();
});
it('meldet kein Duplikat für Anhänge ohne Checksumme', async () => {
attachmentRepo.findOne.mockResolvedValue({ Id: 7, Checksum: '' });
@@ -142,6 +163,20 @@ describe('EmailImportService – Duplikatprüfung', () => {
);
});
it('vergleicht den Fingerprint mit demselben Seitenbereich', async () => {
fingerprints.ensure.mockResolvedValue('fp-inhalt');
fingerprints.hasImportedTwin.mockResolvedValue(true);
await expect(
service.checkDuplicate(7, { start: 2, end: 999 }),
).resolves.toBe(true);
expect(fingerprints.hasImportedTwin).toHaveBeenCalledWith(
7,
'fp-inhalt',
'2-3',
);
});
it('prüft in Paperless die Checksumme des Teil-PDFs', async () => {
await expect(
service.checkDuplicate(7, { start: 1, end: 1 }),
@@ -205,6 +240,39 @@ describe('EmailImportService – Duplikatprüfung', () => {
expect(check).not.toHaveBeenCalled();
});
it('sperrt auch parallele Importe verschiedener Dateien mit gleichem Inhalt', async () => {
fingerprints.ensure.mockResolvedValue('fp-inhalt');
attachmentRepo.findOne.mockImplementation(
({ where: { Id } }: { where: { Id: number } }) =>
Promise.resolve({
Id,
Checksum: `checksum-${Id}`,
FileName: 'a.pdf',
}),
);
let ersterFertig!: () => void;
jest
.spyOn(
service as never as { runImport: () => Promise<unknown> },
'runImport',
)
.mockImplementationOnce(
() => new Promise((resolve) => (ersterFertig = () => resolve({}))),
);
const erster = service.executeImport(importDaten());
await new Promise((r) => setImmediate(r));
const zweiter = importDaten();
zweiter.attachments[0] = { ...zweiter.attachments[0], attachmentId: 8 };
await expect(service.executeImport(zweiter)).rejects.toThrow(
ConflictException,
);
ersterFertig();
await erster;
});
it('lässt denselben Anhang nicht zweimal gleichzeitig importieren', async () => {
let ersterFertig!: () => void;
const run = jest
@@ -20,6 +20,7 @@ import { deriveTaskMetadata } from '../paperless/task-metadata.util';
import * as QRCode from 'qrcode';
import { EmailPageCacheService } from './email-page-cache.service';
import { ImapFolderService } from './imap-folder.service';
import { AttachmentFingerprintService } from './attachment-fingerprint.service';
import { PdfService } from '../preprocessing/pdf.service';
import * as path from 'path';
import * as os from 'os';
@@ -71,6 +72,7 @@ export class EmailImportService {
private readonly pdfService: PdfService,
private readonly pageCache: EmailPageCacheService,
private readonly imapFolderService: ImapFolderService,
private readonly fingerprints: AttachmentFingerprintService,
) {}
async ensurePreviews(emailId: number): Promise<void> {
@@ -263,8 +265,14 @@ export class EmailImportService {
if (!attachment) return false;
if (!pages) {
if (
attachment.Checksum &&
(await this.hasPendingImport(attachment.Checksum, 'full'))
) {
return true;
}
if (await this.hasTwinByContent(attachment, 'full')) return true;
if (!attachment.Checksum) return false;
if (await this.hasPendingImport(attachment.Checksum, 'full')) return true;
return this.paperlessService.checksumExists(attachment.Checksum);
}
@@ -287,6 +295,9 @@ export class EmailImportService {
) {
return true;
}
if (await this.hasTwinByContent(attachment, `${start}-${end}`)) {
return true;
}
const sliced = await PDFDocument.create();
const indices = Array.from(
@@ -322,6 +333,19 @@ export class EmailImportService {
return count > 0;
}
/**
* Gleicher Inhalt, andere Datei: Absender erzeugen PDFs beim erneuten
* Versand oft neu (andere Checksumme). Der Text-Fingerprint erkennt das.
*/
private async hasTwinByContent(
attachment: Attachment,
range: string,
): Promise<boolean> {
const fingerprint = await this.fingerprints.ensure(attachment);
if (!fingerprint) return false;
return this.fingerprints.hasImportedTwin(attachment.Id, fingerprint, range);
}
// --- Print Preview ---
async generatePrintPdf(
attachmentId: number,
@@ -570,7 +594,9 @@ export class EmailImportService {
const ranges = att.splitRanges?.length ? att.splitRanges : [undefined];
for (const range of ranges) {
const bereich = range ? `${range.start}-${range.end}` : 'full';
const key = `${attachment.Checksum || `id:${attachment.Id}`}:${bereich}`;
// Fingerprint vor Checksumme: fängt auch neu erzeugte PDFs ab
const fingerprint = await this.fingerprints.ensure(attachment);
const key = `${fingerprint || attachment.Checksum || `id:${attachment.Id}`}:${bereich}`;
const bezeichnung = range
? `„${attachment.FileName}" (Seiten ${bereich})`
: `„${attachment.FileName}"`;
+7 -1
View File
@@ -10,6 +10,7 @@ import { EmailPageCacheService } from './email-page-cache.service';
import { EmailImportController } from './email-import.controller';
import { EmailImportService } from './email-import.service';
import { ImapFolderService } from './imap-folder.service';
import { AttachmentFingerprintService } from './attachment-fingerprint.service';
import { CorrespondentEmailMapping } from '../database/entities/correspondent-email-mapping.entity';
import { Task } from '../database/entities/task.entity';
import { PreprocessingModule } from '../preprocessing/preprocessing.module';
@@ -27,7 +28,12 @@ import { PreprocessingModule } from '../preprocessing/preprocessing.module';
PreprocessingModule,
],
controllers: [EmailController, EmailImportController],
providers: [EmailImportService, EmailPageCacheService, ImapFolderService],
providers: [
EmailImportService,
EmailPageCacheService,
ImapFolderService,
AttachmentFingerprintService,
],
exports: [EmailPageCacheService],
})
export class EmailModule {}
@@ -0,0 +1,58 @@
import { PDFDocument, StandardFonts } from 'pdf-lib';
import { computeContentFingerprint } from './pdf-fingerprint.util';
async function pdfMitText(
zeilen: string[],
erstellt = new Date('2026-09-24T12:00:00Z'),
): Promise<Buffer> {
const doc = await PDFDocument.create();
doc.setCreationDate(erstellt);
doc.setModificationDate(erstellt);
const font = await doc.embedFont(StandardFonts.Helvetica);
const page = doc.addPage();
zeilen.forEach((text, i) =>
page.drawText(text, { x: 50, y: 700 - i * 20, font, size: 12 }),
);
return Buffer.from(await doc.save());
}
const TEXT = [
'Lastschriftankuendigung / Pre-Notification',
'Mandatsreferenz 0000844118',
'Betrag 12,50 EUR',
];
describe('computeContentFingerprint', () => {
it('liefert für neu erzeugte PDFs mit gleichem Inhalt denselben Fingerprint', async () => {
const a = await pdfMitText(TEXT, new Date('2026-09-24T12:37:00Z'));
const b = await pdfMitText(TEXT, new Date('2026-09-24T13:45:00Z'));
expect(a.equals(b)).toBe(false);
const fpA = await computeContentFingerprint(a);
expect(fpA).toMatch(/^[0-9a-f]{64}$/);
await expect(computeContentFingerprint(b)).resolves.toBe(fpA);
});
it('unterscheidet PDFs mit anderem Inhalt', async () => {
const a = await pdfMitText(TEXT);
const b = await pdfMitText([...TEXT.slice(0, 2), 'Betrag 13,50 EUR']);
expect(await computeContentFingerprint(a)).not.toBe(
await computeContentFingerprint(b),
);
});
it('liefert einen Leerstring für PDFs ohne Textebene (z.B. Scans)', async () => {
const doc = await PDFDocument.create();
doc.addPage();
const leer = Buffer.from(await doc.save());
await expect(computeContentFingerprint(leer)).resolves.toBe('');
});
it('liefert null, wenn die Datei kein lesbares PDF ist', async () => {
await expect(
computeContentFingerprint(Buffer.from('kein pdf')),
).resolves.toBeNull();
});
});
@@ -0,0 +1,37 @@
import * as crypto from 'crypto';
import { PDFParse } from 'pdf-parse';
/**
* Unterhalb dieser Textlänge gilt ein PDF als "ohne Textebene" (z.B. Scan) –
* ein paar Zeichen reichen nicht, um Dokumente sicher zu unterscheiden.
*/
const MIN_TEXT_LENGTH = 20;
/**
* Inhaltsbasierter Fingerprint eines PDFs: SHA-256 über den extrahierten,
* whitespace-normalisierten Text. Anders als die Datei-Checksumme bleibt er
* gleich, wenn ein Absender dasselbe Dokument neu erzeugt (andere
* Erstellungszeit, Dokument-ID o.ä.).
*
* @returns Fingerprint (64 Hex-Zeichen), `''` bei PDFs ohne verwertbaren Text
* (bereits berechnet, aber nicht vergleichbar) oder `null`, wenn das
* PDF nicht gelesen werden konnte.
*/
export async function computeContentFingerprint(
pdf: Buffer,
): Promise<string | null> {
// pdf.js übernimmt den Puffer, deshalb eine Kopie übergeben
const parser = new PDFParse({ data: new Uint8Array(pdf) });
try {
const { text } = await parser.getText();
const normalized = text.replace(/\s+/g, ' ').trim();
// Seitenmarker ("-- 1 of 1 --") zählen nicht als Inhalt
const inhalt = normalized.replace(/-- \d+ of \d+ --/g, '').trim();
if (inhalt.length < MIN_TEXT_LENGTH) return '';
return crypto.createHash('sha256').update(normalized).digest('hex');
} catch {
return null;
} finally {
await parser.destroy().catch(() => undefined);
}
}