feat(discovery): Helix top-clips-crawler module + rangeLastDays helper (9 tests)

Pillar 7 (Auto-Discovery) addition. Pure module — fetchImpl injizierbar fuer
Tests. Helix /clips endpoint, supports broadcaster_id + first (clamped 1-100) +
started_at/ended_at. Returns sorted-desc by view_count. snake_case Helix-
Felder werden auf camelCase im Public-Interface gemappt.

219 unit tests. IPC wiring + Auto-Discovery-Scheduler kommt post-5.1.0-alpha.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-12 00:00:38 +02:00
parent f775e7a9e2
commit dc2b609132
2 changed files with 272 additions and 0 deletions

View File

@ -0,0 +1,137 @@
import { test, expect, describe } from 'vitest';
import { fetchTopClips, rangeLastDays } from './top-clips-crawler';
function fakeFetch(rows: Array<Record<string, unknown>>, status = 200): typeof fetch {
return (async (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
// verify request shape lightly inside the fake
const headers = init?.headers as Record<string, string> | undefined;
if (status === 200 && (!headers?.['Authorization'] || !headers?.['Client-Id'])) {
return new Response('missing auth headers', { status: 401 });
}
return new Response(JSON.stringify({ data: rows }), {
status,
headers: { 'Content-Type': 'application/json' },
});
}) as unknown as typeof fetch;
}
describe('fetchTopClips', () => {
test('returns parsed clips sorted by view_count desc', async () => {
const fakeRows = [
{
id: 'C2', url: 'u2', embed_url: 'e2', broadcaster_id: 'b', broadcaster_name: 'B',
creator_id: 'c', creator_name: 'C', video_id: 'v', game_id: 'g', language: 'en',
title: 'mid', view_count: 50, created_at: '2026-05-10T00:00:00Z',
thumbnail_url: 't', duration: 30, vod_offset: 120,
},
{
id: 'C1', url: 'u1', embed_url: 'e1', broadcaster_id: 'b', broadcaster_name: 'B',
creator_id: 'c', creator_name: 'C', video_id: 'v', game_id: 'g', language: 'en',
title: 'high', view_count: 200, created_at: '2026-05-09T00:00:00Z',
thumbnail_url: 't', duration: 45, vod_offset: null,
},
];
const clips = await fetchTopClips({
clientId: 'CID', accessToken: 'TOK', broadcasterId: 'b',
fetchImpl: fakeFetch(fakeRows),
});
expect(clips).toHaveLength(2);
expect(clips[0].id).toBe('C1');
expect(clips[0].viewCount).toBe(200);
expect(clips[1].id).toBe('C2');
expect(clips[1].vodOffsetSeconds).toBe(120);
expect(clips[0].vodOffsetSeconds).toBeNull();
});
test('snake_case → camelCase mapping for broadcaster fields', async () => {
const fakeRows = [
{
id: 'X', url: 'u', embed_url: 'e', broadcaster_id: 'bid', broadcaster_name: 'BName',
creator_id: 'cid', creator_name: 'CName', video_id: 'vid', game_id: 'gid',
language: 'de', title: 'T', view_count: 10, created_at: '2026-05-01T00:00:00Z',
thumbnail_url: 'th', duration: 12,
},
];
const [c] = await fetchTopClips({
clientId: 'CID', accessToken: 'TOK', broadcasterId: 'bid',
fetchImpl: fakeFetch(fakeRows),
});
expect(c.broadcasterId).toBe('bid');
expect(c.broadcasterName).toBe('BName');
expect(c.creatorId).toBe('cid');
expect(c.creatorName).toBe('CName');
expect(c.videoId).toBe('vid');
expect(c.gameId).toBe('gid');
});
test('builds query string with broadcaster_id + first + date range', async () => {
let capturedUrl: string | null = null;
const captureFetch = (async (url: string | URL | Request): Promise<Response> => {
capturedUrl = String(url);
return new Response(JSON.stringify({ data: [] }), { status: 200 });
}) as unknown as typeof fetch;
await fetchTopClips({
clientId: 'CID', accessToken: 'TOK', broadcasterId: '12345',
startedAt: '2026-05-01T00:00:00Z', endedAt: '2026-05-11T00:00:00Z',
first: 50, fetchImpl: captureFetch,
});
expect(capturedUrl).toContain('broadcaster_id=12345');
expect(capturedUrl).toContain('first=50');
expect(capturedUrl).toContain('started_at=2026-05-01T00%3A00%3A00Z');
expect(capturedUrl).toContain('ended_at=2026-05-11T00%3A00%3A00Z');
});
test('clamps first to [1, 100]', async () => {
let capturedUrl: string | null = null;
const captureFetch = (async (url: string | URL | Request): Promise<Response> => {
capturedUrl = String(url);
return new Response(JSON.stringify({ data: [] }), { status: 200 });
}) as unknown as typeof fetch;
await fetchTopClips({ clientId: 'C', accessToken: 'T', broadcasterId: 'b', first: 999, fetchImpl: captureFetch });
expect(capturedUrl).toContain('first=100');
await fetchTopClips({ clientId: 'C', accessToken: 'T', broadcasterId: 'b', first: 0, fetchImpl: captureFetch });
expect(capturedUrl).toContain('first=1');
});
test('throws on non-2xx response', async () => {
await expect(fetchTopClips({
clientId: 'C', accessToken: 'T', broadcasterId: 'b',
fetchImpl: fakeFetch([], 503),
})).rejects.toThrow(/503/);
});
test('throws on malformed JSON', async () => {
const brokenFetch = (async (): Promise<Response> => new Response('{not-json', { status: 200 })) as unknown as typeof fetch;
await expect(fetchTopClips({
clientId: 'C', accessToken: 'T', broadcasterId: 'b', fetchImpl: brokenFetch,
})).rejects.toThrow(/parse failed/);
});
test('empty data returns empty array (not null)', async () => {
const emptyFetch = (async (): Promise<Response> => new Response(JSON.stringify({ data: [] }), { status: 200 })) as unknown as typeof fetch;
const clips = await fetchTopClips({
clientId: 'C', accessToken: 'T', broadcasterId: 'b', fetchImpl: emptyFetch,
});
expect(clips).toEqual([]);
});
});
describe('rangeLastDays', () => {
test('produces ISO RFC3339 strings exactly N days apart', () => {
const now = new Date('2026-05-11T12:00:00Z');
const range = rangeLastDays(7, now);
expect(range.endedAt).toBe('2026-05-11T12:00:00.000Z');
expect(range.startedAt).toBe('2026-05-04T12:00:00.000Z');
});
test('1-day range', () => {
const now = new Date('2026-05-11T12:00:00Z');
const range = rangeLastDays(1, now);
expect(range.startedAt).toBe('2026-05-10T12:00:00.000Z');
expect(range.endedAt).toBe('2026-05-11T12:00:00.000Z');
});
});

View File

@ -0,0 +1,135 @@
// Twitch Helix Top-Clips Crawler. Pure: fetch wird via injizierter fetchImpl
// aufgerufen (Tests koennen mocken). Helix-Endpunkt:
// GET https://api.twitch.tv/helix/clips?broadcaster_id=X&first=N
//
// Auth: Client-Credentials (app-token) reicht — kein User-Token noetig.
// Spaeter koennen wir aus token-store den default-Twitch-User-Token nehmen.
const HELIX_CLIPS_URL = 'https://api.twitch.tv/helix/clips';
export interface TopClip {
id: string;
url: string;
embedUrl: string;
broadcasterId: string;
broadcasterName: string;
creatorId: string;
creatorName: string;
videoId: string;
gameId: string;
language: string;
title: string;
viewCount: number;
createdAt: string; // ISO timestamp
thumbnailUrl: string;
duration: number; // seconds
vodOffsetSeconds: number | null;
}
interface HelixClipRow {
id: string;
url: string;
embed_url: string;
broadcaster_id: string;
broadcaster_name: string;
creator_id: string;
creator_name: string;
video_id: string;
game_id: string;
language: string;
title: string;
view_count: number;
created_at: string;
thumbnail_url: string;
duration: number;
vod_offset?: number | null;
}
interface HelixClipsResponse {
data?: HelixClipRow[];
pagination?: { cursor?: string };
}
export interface FetchTopClipsOptions {
clientId: string;
accessToken: string;
broadcasterId: string;
startedAt?: string; // ISO RFC3339
endedAt?: string;
first?: number; // 1-100, default 20
fetchImpl?: typeof fetch;
}
function rowToClip(row: HelixClipRow): TopClip {
return {
id: row.id,
url: row.url,
embedUrl: row.embed_url,
broadcasterId: row.broadcaster_id,
broadcasterName: row.broadcaster_name,
creatorId: row.creator_id,
creatorName: row.creator_name,
videoId: row.video_id,
gameId: row.game_id,
language: row.language,
title: row.title,
viewCount: row.view_count,
createdAt: row.created_at,
thumbnailUrl: row.thumbnail_url,
duration: row.duration,
vodOffsetSeconds: row.vod_offset ?? null,
};
}
export async function fetchTopClips(opts: FetchTopClipsOptions): Promise<TopClip[]> {
const fetchFn = opts.fetchImpl ?? fetch;
const first = Math.min(100, Math.max(1, opts.first ?? 20));
const params = new URLSearchParams({
broadcaster_id: opts.broadcasterId,
first: String(first),
});
if (opts.startedAt) params.set('started_at', opts.startedAt);
if (opts.endedAt) params.set('ended_at', opts.endedAt);
const res = await fetchFn(`${HELIX_CLIPS_URL}?${params.toString()}`, {
headers: {
'Authorization': `Bearer ${opts.accessToken}`,
'Client-Id': opts.clientId,
},
});
const text = await res.text();
if (!res.ok) {
throw new Error(`top-clips-crawler: helix ${res.status}: ${text}`);
}
let parsed: HelixClipsResponse;
try {
parsed = JSON.parse(text) as HelixClipsResponse;
} catch (e) {
throw new Error(`top-clips-crawler: parse failed: ${e instanceof Error ? e.message : String(e)}`);
}
const rows = parsed.data ?? [];
// Helix returns clips already sorted by view_count desc, but we re-sort
// defensively in case that order ever changes.
return rows.map(rowToClip).sort((a, b) => b.viewCount - a.viewCount);
}
export interface DateRange {
startedAt: string;
endedAt: string;
}
/**
* Convenience: ISO range fuer "letzte N Tage" ab jetzt. Twitch erwartet
* RFC3339 Format (`2026-05-11T00:00:00Z`).
*/
export function rangeLastDays(days: number, now: Date = new Date()): DateRange {
const end = new Date(now.getTime());
const start = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
return {
startedAt: start.toISOString(),
endedAt: end.toISOString(),
};
}