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>
86 lines
2.7 KiB
TypeScript
86 lines
2.7 KiB
TypeScript
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');
|
||
});
|
||
});
|