lilith-platform.live/codebase/@features/ad-watch/__tests__/phash.test.ts
Natalie 769bfcd61d feat(ad-watch): plum stdio MCP — scrape ad-platform listings, diff vs canonical
quinn-adwatch: a stateless, plum-local stdio MCP that scrapes Quinn's live
listings on her 11 ad platforms (Eros/Tryst/TS4Rent/MegaPersonals/TSEscorts/
AdultLook/AdultSearch/SkipTheGames + OnlyFans/Fansly/ManyVids) and surfaces
discrepancies vs the canonical provider-config profile.

- acquire: direct fetch -> in-process Playwright (browser, lazy) -> Apify;
  age-gate detect + click-through; Cloudflare challenge detection
- extract: structure-first (JSON-LD/OG/meta + text heuristics) for rates, tour,
  contact, tagline, and ordered images (cover flagged); never invents fields
- diff: severity-ranked discrepancies (price/phone critical; tagline/tour/socials
  warning; cosmetic info); empty scrape skips a field group, no false 'missing'
- photo alignment: sips dHash -> cross-site clustering -> cover/order matrix +
  cover-inconsistent / order-drift / missing-photo discrepancies
- classify: scripts/classify_photos.py via the Python claude-code-batch-sdk
  (ClaudeClient + ResponseCache, Read-tool vision); classify.ts is a thin bridge

Black-independent by design (black + apricot expected to stay down): all deps are
public npm (SDK StdioServerTransport, no @lilith/mcp-common), classify uses the
on-disk Python SDK + local claude CLI, and ADWATCH_CANONICAL_FILE diffs against a
local provider-config snapshot. 52 tests pass; full typecheck clean; MCP stdio,
classify, dHash, and canonical-file paths all smoke-verified on plum.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 19:11:33 -04:00

86 lines
2.7 KiB
TypeScript
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.

import { describe, expect, test } from 'bun:test';
import {
parseBmp24ToGray,
dHashFromGray,
dHashHex,
hamming,
DHASH_W,
DHASH_H,
} from '../src/phash.js';
/** Build a 24-bit bottom-up BMP from top-down row-major [r,g,b] pixels. */
function makeBmp(width: number, height: number, pixels: Array<[number, number, number]>): Buffer {
const rowBytes = width * 3;
const rowPadded = Math.ceil(rowBytes / 4) * 4;
const imageSize = rowPadded * height;
const buf = Buffer.alloc(54 + imageSize);
buf.write('BM', 0, 'ascii');
buf.writeUInt32LE(54 + imageSize, 2);
buf.writeUInt32LE(54, 10);
buf.writeUInt32LE(40, 14);
buf.writeInt32LE(width, 18);
buf.writeInt32LE(height, 22); // positive → bottom-up
buf.writeUInt16LE(1, 26);
buf.writeUInt16LE(24, 28);
for (let y = 0; y < height; y++) {
const srcRow = height - 1 - y; // store bottom-up
const base = 54 + srcRow * rowPadded;
for (let x = 0; x < width; x++) {
const [r, g, b] = pixels[y * width + x]!;
const p = base + x * 3;
buf[p] = b;
buf[p + 1] = g;
buf[p + 2] = r;
}
}
return buf;
}
describe('hamming', () => {
test('counts differing bits', () => {
expect(hamming(0b1011n, 0b1011n)).toBe(0);
expect(hamming(0b1011n, 0b1010n)).toBe(1);
expect(hamming(0b0000n, 0b1111n)).toBe(4);
});
});
describe('parseBmp24ToGray', () => {
test('round-trips colors to luminance, top-down', () => {
// 2×2: top row [white, black], bottom row [red, blue]
const bmp = makeBmp(2, 2, [
[255, 255, 255],
[0, 0, 0],
[255, 0, 0],
[0, 0, 255],
]);
const { width, height, gray } = parseBmp24ToGray(bmp);
expect([width, height]).toEqual([2, 2]);
expect(Math.round(gray[0]!)).toBe(255); // white
expect(Math.round(gray[1]!)).toBe(0); // black
expect(Math.round(gray[2]!)).toBe(76); // red ≈ 0.299*255
expect(Math.round(gray[3]!)).toBe(29); // blue ≈ 0.114*255
});
test('rejects non-BMP', () => {
expect(() => parseBmp24ToGray(Buffer.from('not a bmp'))).toThrow();
});
});
describe('dHashFromGray', () => {
test('strictly increasing rows → all bits set', () => {
const gray: number[] = [];
for (let y = 0; y < DHASH_H; y++) for (let x = 0; x < DHASH_W; x++) gray.push(x * 10);
expect(dHashFromGray(gray, DHASH_W, DHASH_H)).toBe((1n << 64n) - 1n);
});
test('strictly decreasing rows → no bits set', () => {
const gray: number[] = [];
for (let y = 0; y < DHASH_H; y++) for (let x = 0; x < DHASH_W; x++) gray.push((DHASH_W - x) * 10);
expect(dHashFromGray(gray, DHASH_W, DHASH_H)).toBe(0n);
});
test('dHashHex is 16 hex chars', () => {
expect(dHashHex(0n)).toHaveLength(16);
expect(dHashHex((1n << 64n) - 1n)).toBe('ffffffffffffffff');
});
});