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:
parent
f775e7a9e2
commit
dc2b609132
137
src/main/domain/top-clips-crawler.test.ts
Normal file
137
src/main/domain/top-clips-crawler.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
135
src/main/domain/top-clips-crawler.ts
Normal file
135
src/main/domain/top-clips-crawler.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user