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>
60 lines
2.4 KiB
TypeScript
60 lines
2.4 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
|
import { clusterByHash, buildReport, type HashedImage } from '../src/align.js';
|
|
|
|
function img(platform: string, order: number, thumbnail: boolean, hash: bigint): HashedImage {
|
|
return { platform, order, thumbnail, hash, path: `/tmp/${platform}-${order}` };
|
|
}
|
|
|
|
describe('clusterByHash', () => {
|
|
test('near hashes cluster, far hashes split', () => {
|
|
const a = 0b1010101010101010n;
|
|
const aNear = 0b1010101010101000n; // hamming 1 from a
|
|
const b = 0b0101010101010101n; // far from a
|
|
const clusters = clusterByHash(
|
|
[img('tryst', 0, true, a), img('eros', 3, false, aNear), img('eros', 0, true, b)],
|
|
6,
|
|
);
|
|
expect(clusters).toHaveLength(2);
|
|
const big = clusters.find((c) => c.members.length === 2);
|
|
expect(big?.members.map((m) => m.platform).sort()).toEqual(['eros', 'tryst']);
|
|
});
|
|
});
|
|
|
|
describe('buildReport', () => {
|
|
test('same cover everywhere → no cover-inconsistent discrepancy', () => {
|
|
const cover = 0b1111000011110000n;
|
|
const r = buildReport([img('tryst', 0, true, cover), img('eros', 0, true, cover)]);
|
|
expect(r.discrepancies.some((d) => d.kind === 'cover-inconsistent')).toBe(false);
|
|
});
|
|
|
|
test('different covers across sites → cover-inconsistent warning', () => {
|
|
const coverA = 0b1111000011110000n;
|
|
const coverB = 0b0000111100001111n;
|
|
const r = buildReport([img('tryst', 0, true, coverA), img('eros', 0, true, coverB)]);
|
|
const hit = r.discrepancies.find((d) => d.kind === 'cover-inconsistent');
|
|
expect(hit?.severity).toBe('warning');
|
|
});
|
|
|
|
test('same photo at different gallery positions → order-drift', () => {
|
|
const photo = 0b1100110011001100n;
|
|
const r = buildReport([
|
|
img('tryst', 1, false, photo),
|
|
img('eros', 4, false, photo),
|
|
]);
|
|
const hit = r.discrepancies.find((d) => d.kind === 'order-drift');
|
|
expect(hit?.severity).toBe('info');
|
|
if (hit && hit.kind === 'order-drift') {
|
|
expect(hit.orders['tryst']).toBe(1);
|
|
expect(hit.orders['eros']).toBe(4);
|
|
}
|
|
});
|
|
|
|
test('matrix marks presence per site', () => {
|
|
const photo = 0b1100110011001100n;
|
|
const r = buildReport([img('tryst', 0, true, photo), img('eros', 2, false, photo)]);
|
|
expect(r.platforms).toEqual(['eros', 'tryst']);
|
|
const p = r.photos[0]!;
|
|
expect(p.sites['tryst']?.present).toBe(true);
|
|
expect(p.sites['eros']?.order).toBe(2);
|
|
});
|
|
});
|