afpnews-mcp-server 2.0.2 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/__tests__/create-server.test.ts +5 -0
- package/src/__tests__/fixtures.ts +14 -0
- package/src/__tests__/format-media.test.ts +26 -9
- package/src/__tests__/format.test.ts +35 -2
- package/src/__tests__/get-media.test.ts +33 -0
- package/src/__tests__/search-media-handler.test.ts +125 -0
- package/src/__tests__/server.test.ts +53 -1
- package/src/tools/get-article.ts +11 -3
- package/src/tools/get-media.ts +20 -73
- package/src/tools/list-facets.ts +27 -6
- package/src/tools/search-media.ts +57 -3
- package/src/tools/shared.ts +1 -1
- package/src/utils/embed-media.ts +65 -0
- package/src/utils/format-media.ts +8 -5
- package/src/utils/format.ts +26 -1
- package/src/utils/types.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "afpnews-mcp-server",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -28,12 +28,12 @@
|
|
|
28
28
|
"jose": "^6.2.2"
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|
|
31
|
-
"afpnews-api": "^2.
|
|
31
|
+
"afpnews-api": "^2.5.0",
|
|
32
32
|
"zod": "^4.3.6"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@types/node": "^25.5.2",
|
|
36
|
-
"afpnews-api": "^2.
|
|
36
|
+
"afpnews-api": "^2.5.0",
|
|
37
37
|
"zod": "^4.3.6"
|
|
38
38
|
}
|
|
39
39
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { describe, expect, it, mock } from 'bun:test';
|
|
2
|
+
import * as actualApi from 'afpnews-api';
|
|
2
3
|
|
|
3
4
|
describe('createServer', () => {
|
|
4
5
|
it('authenticates with provided credentials', async () => {
|
|
5
6
|
const authenticateMock = mock().mockResolvedValue(undefined);
|
|
6
7
|
|
|
7
8
|
mock.module('afpnews-api', () => ({
|
|
9
|
+
...actualApi,
|
|
8
10
|
ApiCore: class {
|
|
9
11
|
token?: unknown;
|
|
10
12
|
config?: unknown;
|
|
@@ -23,6 +25,7 @@ describe('createServer', () => {
|
|
|
23
25
|
it('passes baseUrl to ApiCore when provided', async () => {
|
|
24
26
|
let capturedConfig: unknown;
|
|
25
27
|
mock.module('afpnews-api', () => ({
|
|
28
|
+
...actualApi,
|
|
26
29
|
ApiCore: class {
|
|
27
30
|
token?: unknown;
|
|
28
31
|
constructor(config?: unknown) { capturedConfig = config; }
|
|
@@ -40,6 +43,7 @@ describe('createServer', () => {
|
|
|
40
43
|
it('does not set baseUrl on ApiCore when omitted', async () => {
|
|
41
44
|
let capturedConfig: unknown;
|
|
42
45
|
mock.module('afpnews-api', () => ({
|
|
46
|
+
...actualApi,
|
|
43
47
|
ApiCore: class {
|
|
44
48
|
token?: unknown;
|
|
45
49
|
constructor(config?: unknown) { capturedConfig = config; }
|
|
@@ -56,6 +60,7 @@ describe('createServer', () => {
|
|
|
56
60
|
|
|
57
61
|
it('throws on missing credentials', async () => {
|
|
58
62
|
mock.module('afpnews-api', () => ({
|
|
63
|
+
...actualApi,
|
|
59
64
|
ApiCore: class {
|
|
60
65
|
token?: unknown;
|
|
61
66
|
constructor() {}
|
|
@@ -27,6 +27,20 @@ export const FIXTURE_DOC_MINIMAL: AFPDocument = {
|
|
|
27
27
|
news: ['Only one paragraph.'],
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
+
export const FIXTURE_VIDEO_DOC: AFPDocument = {
|
|
31
|
+
uno: 'AFP-TEST-VID-001',
|
|
32
|
+
headline: 'Test Video Headline',
|
|
33
|
+
published: '2026-02-14T10:30:00Z',
|
|
34
|
+
lang: 'fr',
|
|
35
|
+
genre: 'STOCKSHOTS',
|
|
36
|
+
class: 'video',
|
|
37
|
+
news: [
|
|
38
|
+
'1. 00:00-00:12 Vue aérienne de la ville',
|
|
39
|
+
'2. 00:12-00:30 SOUNDBITE 1 - Jean Dupont, témoin',
|
|
40
|
+
'"Tout a commencé très vite"',
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
|
|
30
44
|
export function makeDocs(count: number): AFPDocument[] {
|
|
31
45
|
return Array.from({ length: count }, (_, i) => ({
|
|
32
46
|
uno: `AFP-TEST-${String(i + 1).padStart(3, '0')}`,
|
|
@@ -4,11 +4,13 @@ import { extractRenditions, formatMediaDocument, formatMediaDocumentsAsJson, for
|
|
|
4
4
|
// Fixture bagItem reprenant la structure réelle AFP
|
|
5
5
|
const FIXTURE_BAG_ITEM = [{
|
|
6
6
|
medias: [
|
|
7
|
-
{ role: 'Thumbnail',
|
|
8
|
-
{ role: 'Preview',
|
|
9
|
-
{ role: 'Preview_B',
|
|
10
|
-
{ role: 'HighDef',
|
|
11
|
-
{ role: 'Quicklook',
|
|
7
|
+
{ role: 'Thumbnail', sizeInBytes: 33590, width: 320, height: 213, href: 'https://example.com/thumb.jpg', type: 'Photo' },
|
|
8
|
+
{ role: 'Preview', sizeInBytes: 340621, width: 1200, height: 800, href: 'https://example.com/prev.jpg', type: 'Photo' },
|
|
9
|
+
{ role: 'Preview_B', sizeInBytes: 596996, width: 1800, height: 1200, href: 'https://example.com/prev_b.jpg', type: 'Photo' },
|
|
10
|
+
{ role: 'HighDef', sizeInBytes: 5126566, width: 3429, height: 2286, href: 'https://example.com/hd.jpg', type: 'Photo' },
|
|
11
|
+
{ role: 'Quicklook', sizeInBytes: 14055, width: 245, height: 164, href: 'https://example.com/quick.jpg', type: 'Photo' },
|
|
12
|
+
{ role: 'Mockup', sizeInBytes: 19989, width: 512, height: 342, href: 'https://example.com/mockup.jpg', type: 'Photo' },
|
|
13
|
+
{ role: 'Squared120', sizeInBytes: 3696, width: 120, height: 120, href: 'https://example.com/sq120.jpg', type: 'Photo' },
|
|
12
14
|
],
|
|
13
15
|
}];
|
|
14
16
|
|
|
@@ -21,11 +23,14 @@ const FIXTURE_BAG_NO_PREVIEW = [{
|
|
|
21
23
|
}];
|
|
22
24
|
|
|
23
25
|
describe('MEDIA_RENDITION_ROLE_MAP', () => {
|
|
26
|
+
it('maps Quicklook → quicklook', () => expect(MEDIA_RENDITION_ROLE_MAP['Quicklook']).toBe('quicklook'));
|
|
24
27
|
it('maps Thumbnail → thumbnail', () => expect(MEDIA_RENDITION_ROLE_MAP['Thumbnail']).toBe('thumbnail'));
|
|
25
28
|
it('maps Preview → preview', () => expect(MEDIA_RENDITION_ROLE_MAP['Preview']).toBe('preview'));
|
|
26
29
|
it('maps Preview_B → preview', () => expect(MEDIA_RENDITION_ROLE_MAP['Preview_B']).toBe('preview'));
|
|
27
30
|
it('maps Preview_W → preview', () => expect(MEDIA_RENDITION_ROLE_MAP['Preview_W']).toBe('preview'));
|
|
28
31
|
it('maps HighDef → highdef', () => expect(MEDIA_RENDITION_ROLE_MAP['HighDef']).toBe('highdef'));
|
|
32
|
+
it('maps Mockup → mockup', () => expect(MEDIA_RENDITION_ROLE_MAP['Mockup']).toBe('mockup'));
|
|
33
|
+
it('maps Squared120 → squared120', () => expect(MEDIA_RENDITION_ROLE_MAP['Squared120']).toBe('squared120'));
|
|
29
34
|
});
|
|
30
35
|
|
|
31
36
|
describe('extractRenditions', () => {
|
|
@@ -37,11 +42,17 @@ describe('extractRenditions', () => {
|
|
|
37
42
|
expect(extractRenditions([{}])).toEqual({});
|
|
38
43
|
});
|
|
39
44
|
|
|
40
|
-
it('extracts thumbnail, preview, highdef from standard bagItem', () => {
|
|
45
|
+
it('extracts squared120, quicklook, thumbnail, mockup, preview, highdef from standard bagItem', () => {
|
|
41
46
|
const r = extractRenditions(FIXTURE_BAG_ITEM);
|
|
47
|
+
expect(r.squared120?.href).toBe('https://example.com/sq120.jpg');
|
|
48
|
+
expect(r.squared120?.width).toBe(120);
|
|
49
|
+
expect(r.quicklook?.href).toBe('https://example.com/quick.jpg');
|
|
50
|
+
expect(r.quicklook?.width).toBe(245);
|
|
42
51
|
expect(r.thumbnail?.href).toBe('https://example.com/thumb.jpg');
|
|
43
52
|
expect(r.thumbnail?.width).toBe(320);
|
|
44
53
|
expect(r.thumbnail?.sizeInBytes).toBe(33590);
|
|
54
|
+
expect(r.mockup?.href).toBe('https://example.com/mockup.jpg');
|
|
55
|
+
expect(r.mockup?.width).toBe(512);
|
|
45
56
|
expect(r.preview?.href).toBe('https://example.com/prev.jpg');
|
|
46
57
|
expect(r.preview?.width).toBe(1200);
|
|
47
58
|
expect(r.highdef?.href).toBe('https://example.com/hd.jpg');
|
|
@@ -58,9 +69,15 @@ describe('extractRenditions', () => {
|
|
|
58
69
|
expect(r.preview?.width).toBe(1800);
|
|
59
70
|
});
|
|
60
71
|
|
|
61
|
-
it('ignores unknown roles
|
|
62
|
-
const
|
|
63
|
-
|
|
72
|
+
it('ignores genuinely unknown roles', () => {
|
|
73
|
+
const bagWithUnknownRole = [{
|
|
74
|
+
medias: [
|
|
75
|
+
...FIXTURE_BAG_ITEM[0].medias,
|
|
76
|
+
{ role: 'SomeFutureRole', href: 'https://example.com/future.jpg', width: 999, height: 999, type: 'Photo' },
|
|
77
|
+
],
|
|
78
|
+
}];
|
|
79
|
+
const r = extractRenditions(bagWithUnknownRole);
|
|
80
|
+
expect(Object.keys(r)).not.toContain('somefuturerole');
|
|
64
81
|
});
|
|
65
82
|
|
|
66
83
|
it('uses first bagItem only (index 0)', () => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from 'bun:test';
|
|
2
|
-
import { formatDocument, MARKDOWN_API_FIELDS } from '../utils/format.js';
|
|
3
|
-
import { FIXTURE_DOC, FIXTURE_DOC_MINIMAL } from './fixtures.js';
|
|
2
|
+
import { formatDocument, formatFullArticle, formatShotList, MARKDOWN_API_FIELDS } from '../utils/format.js';
|
|
3
|
+
import { FIXTURE_DOC, FIXTURE_DOC_MINIMAL, FIXTURE_VIDEO_DOC } from './fixtures.js';
|
|
4
4
|
|
|
5
5
|
describe('formatDocument', () => {
|
|
6
6
|
it('returns { type: "text", text: string }', () => {
|
|
@@ -64,3 +64,36 @@ describe('MARKDOWN_API_FIELDS', () => {
|
|
|
64
64
|
expect(MARKDOWN_API_FIELDS).not.toContain('afpshortid');
|
|
65
65
|
});
|
|
66
66
|
});
|
|
67
|
+
|
|
68
|
+
describe('formatShotList', () => {
|
|
69
|
+
it('renders a timecoded shot list for a video document', () => {
|
|
70
|
+
const out = formatShotList(FIXTURE_VIDEO_DOC);
|
|
71
|
+
expect(out).toContain('## Shot list');
|
|
72
|
+
expect(out).toContain('1. [00:00-00:12] Vue aérienne de la ville');
|
|
73
|
+
expect(out).toContain('2. [00:12-00:30] Jean Dupont, témoin');
|
|
74
|
+
expect(out).toContain(' "Tout a commencé très vite"');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('returns null for non-video documents', () => {
|
|
78
|
+
expect(formatShotList(FIXTURE_DOC)).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('returns null for a video whose news has no parseable shot', () => {
|
|
82
|
+
expect(formatShotList({ class: 'video', news: ['no timecode here'] })).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('formatFullArticle', () => {
|
|
87
|
+
it('uses the shot list as body for a video document', () => {
|
|
88
|
+
const result = formatFullArticle(FIXTURE_VIDEO_DOC);
|
|
89
|
+
expect(result.text).toContain('## Shot list');
|
|
90
|
+
expect(result.text).toContain('[00:00-00:12] Vue aérienne de la ville');
|
|
91
|
+
expect(result.text).toContain('**Class:** video');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('keeps the plain text body for non-video documents', () => {
|
|
95
|
+
const result = formatFullArticle(FIXTURE_DOC);
|
|
96
|
+
expect(result.text).not.toContain('## Shot list');
|
|
97
|
+
expect(result.text).toContain('First paragraph of the article.');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -25,11 +25,26 @@ describe('inferMimeType', () => {
|
|
|
25
25
|
|
|
26
26
|
describe('selectRenditionForEmbed', () => {
|
|
27
27
|
const renditions: MediaRenditions = {
|
|
28
|
+
quicklook: { href: 'https://example.com/quick.jpg', width: 245, height: 164, sizeInBytes: 14055 },
|
|
28
29
|
thumbnail: { href: 'https://example.com/thumb.jpg', width: 320, height: 213, sizeInBytes: 33590 },
|
|
29
30
|
preview: { href: 'https://example.com/prev.jpg', width: 1200, height: 800, sizeInBytes: 340621 },
|
|
30
31
|
highdef: { href: 'https://example.com/hd.jpg', width: 3429, height: 2286, sizeInBytes: 5126566 },
|
|
31
32
|
};
|
|
32
33
|
|
|
34
|
+
it('returns quicklook when explicitly requested and available', () => {
|
|
35
|
+
const r = selectRenditionForEmbed(renditions, 'quicklook');
|
|
36
|
+
expect(r?.href).toBe('https://example.com/quick.jpg');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('downgrades to quicklook when thumbnail is absent and requested rendition exceeds 5MB', () => {
|
|
40
|
+
const noThumbnail: MediaRenditions = {
|
|
41
|
+
quicklook: { href: 'https://example.com/quick.jpg', width: 245, height: 164, sizeInBytes: 14055 },
|
|
42
|
+
highdef: { href: 'https://example.com/hd.jpg', width: 3429, height: 2286, sizeInBytes: 5126566 },
|
|
43
|
+
};
|
|
44
|
+
const r = selectRenditionForEmbed(noThumbnail, 'highdef');
|
|
45
|
+
expect(r?.href).toBe('https://example.com/quick.jpg');
|
|
46
|
+
});
|
|
47
|
+
|
|
33
48
|
it('returns requested rendition when available and under size limit', () => {
|
|
34
49
|
const r = selectRenditionForEmbed(renditions, 'preview');
|
|
35
50
|
expect(r?.href).toBe('https://example.com/prev.jpg');
|
|
@@ -62,4 +77,22 @@ describe('selectRenditionForEmbed', () => {
|
|
|
62
77
|
const r = selectRenditionForEmbed({}, 'preview');
|
|
63
78
|
expect(r).toBeUndefined();
|
|
64
79
|
});
|
|
80
|
+
|
|
81
|
+
it('returns mockup when explicitly requested and available', () => {
|
|
82
|
+
const withMockup: MediaRenditions = {
|
|
83
|
+
...renditions,
|
|
84
|
+
mockup: { href: 'https://example.com/mockup.jpg', width: 512, height: 342, sizeInBytes: 19989 },
|
|
85
|
+
};
|
|
86
|
+
const r = selectRenditionForEmbed(withMockup, 'mockup');
|
|
87
|
+
expect(r?.href).toBe('https://example.com/mockup.jpg');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('downgrades to squared120 as last resort when nothing lighter is available', () => {
|
|
91
|
+
const squaredOnly: MediaRenditions = {
|
|
92
|
+
squared120: { href: 'https://example.com/sq120.jpg', width: 120, height: 120, sizeInBytes: 3696 },
|
|
93
|
+
highdef: { href: 'https://example.com/hd.jpg', width: 3429, height: 2286, sizeInBytes: 5126566 },
|
|
94
|
+
};
|
|
95
|
+
const r = selectRenditionForEmbed(squaredOnly, 'highdef');
|
|
96
|
+
expect(r?.href).toBe('https://example.com/sq120.jpg');
|
|
97
|
+
});
|
|
65
98
|
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, mock } from 'bun:test';
|
|
2
|
+
import { afpSearchMediaTool, EMBED_MAX_DOCS } from '../tools/search-media.js';
|
|
3
|
+
|
|
4
|
+
const ORIGINAL_FETCH = globalThis.fetch;
|
|
5
|
+
|
|
6
|
+
function makeMediaDoc(uno: string, extraMedias: Record<string, unknown>[] = []) {
|
|
7
|
+
return {
|
|
8
|
+
uno,
|
|
9
|
+
title: `Photo ${uno}`,
|
|
10
|
+
caption: ['A caption'],
|
|
11
|
+
class: 'picture',
|
|
12
|
+
bagItem: [{
|
|
13
|
+
medias: [
|
|
14
|
+
{ role: 'Quicklook', href: `https://example.com/${uno}-quick.jpg`, width: 245, height: 164, type: 'Photo' },
|
|
15
|
+
{ role: 'Thumbnail', href: `https://example.com/${uno}-thumb.jpg`, width: 320, height: 213, type: 'Photo' },
|
|
16
|
+
...extraMedias,
|
|
17
|
+
],
|
|
18
|
+
}],
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('afpSearchMediaTool.handler — embed', () => {
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
globalThis.fetch = ORIGINAL_FETCH;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('rejects embed with a non-markdown format', async () => {
|
|
28
|
+
const apicore = { search: mock() };
|
|
29
|
+
|
|
30
|
+
const result = await afpSearchMediaTool.handler(apicore, { query: 'test', embed: true, format: 'json' });
|
|
31
|
+
|
|
32
|
+
expect('isError' in result && result.isError).toBe(true);
|
|
33
|
+
expect(apicore.search).not.toHaveBeenCalled();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('caps the effective search size to EMBED_MAX_DOCS when embed is true', async () => {
|
|
37
|
+
const apicore = {
|
|
38
|
+
search: mock().mockResolvedValue({ documents: [], count: 0 }),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
await afpSearchMediaTool.handler(apicore, { query: 'test', size: 50, embed: true });
|
|
42
|
+
|
|
43
|
+
const [request] = apicore.search.mock.calls[0]!;
|
|
44
|
+
expect(request.size).toBe(EMBED_MAX_DOCS);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('does not cap size when embed is false', async () => {
|
|
48
|
+
const apicore = {
|
|
49
|
+
search: mock().mockResolvedValue({ documents: [], count: 0 }),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
await afpSearchMediaTool.handler(apicore, { query: 'test', size: 50 });
|
|
53
|
+
|
|
54
|
+
const [request] = apicore.search.mock.calls[0]!;
|
|
55
|
+
expect(request.size).toBe(50);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('embeds an image block per result, interleaved with its markdown text', async () => {
|
|
59
|
+
globalThis.fetch = mock().mockResolvedValue({
|
|
60
|
+
ok: true,
|
|
61
|
+
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
|
62
|
+
headers: new Headers({ 'content-type': 'image/jpeg' }),
|
|
63
|
+
} as Response);
|
|
64
|
+
|
|
65
|
+
const apicore = {
|
|
66
|
+
search: mock().mockResolvedValue({
|
|
67
|
+
documents: [makeMediaDoc('MEDIA-1'), makeMediaDoc('MEDIA-2')],
|
|
68
|
+
count: 2,
|
|
69
|
+
}),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const result = await afpSearchMediaTool.handler(apicore, { query: 'test', embed: true });
|
|
73
|
+
|
|
74
|
+
// pagination + (text + image) per doc
|
|
75
|
+
expect(result.content).toHaveLength(1 + 2 * 2);
|
|
76
|
+
expect(result.content[1]).toEqual(expect.objectContaining({ type: 'text' }));
|
|
77
|
+
expect((result.content[1] as { text: string }).text).toContain('MEDIA-1');
|
|
78
|
+
expect(result.content[2]).toEqual(expect.objectContaining({ type: 'image', mimeType: 'image/jpeg' }));
|
|
79
|
+
expect((result.content[3] as { text: string }).text).toContain('MEDIA-2');
|
|
80
|
+
expect(result.content[4]).toEqual(expect.objectContaining({ type: 'image' }));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('embeds the thumbnail (poster frame) for video results, not quicklook', async () => {
|
|
84
|
+
globalThis.fetch = mock().mockResolvedValue({
|
|
85
|
+
ok: true,
|
|
86
|
+
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
|
87
|
+
headers: new Headers({ 'content-type': 'image/jpeg' }),
|
|
88
|
+
} as Response);
|
|
89
|
+
|
|
90
|
+
const videoDoc = {
|
|
91
|
+
uno: 'VIDEO-1',
|
|
92
|
+
title: 'A video',
|
|
93
|
+
class: 'video',
|
|
94
|
+
bagItem: [{
|
|
95
|
+
medias: [
|
|
96
|
+
{ role: 'Quicklook', href: 'https://example.com/video-quick.jpg', width: 245, height: 164, type: 'Photo' },
|
|
97
|
+
{ role: 'Thumbnail', href: 'https://example.com/video-thumb.jpg', width: 320, height: 213, type: 'Photo' },
|
|
98
|
+
],
|
|
99
|
+
}],
|
|
100
|
+
};
|
|
101
|
+
const apicore = { search: mock().mockResolvedValue({ documents: [videoDoc], count: 1 }) };
|
|
102
|
+
|
|
103
|
+
await afpSearchMediaTool.handler(apicore, { query: 'test', class: 'video', embed: true });
|
|
104
|
+
|
|
105
|
+
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof mock>;
|
|
106
|
+
expect(fetchMock.mock.calls[0]![0]).toBe('https://example.com/video-thumb.jpg');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('skips embedding SVG graphics but still returns their metadata text', async () => {
|
|
110
|
+
const svgDoc = {
|
|
111
|
+
uno: 'GRAPHIC-1',
|
|
112
|
+
title: 'A graphic',
|
|
113
|
+
class: 'graphic',
|
|
114
|
+
bagItem: [{
|
|
115
|
+
medias: [{ role: 'Preview', href: 'https://example.com/graphic.svg', width: 1200, height: 800, type: 'Graphic' }],
|
|
116
|
+
}],
|
|
117
|
+
};
|
|
118
|
+
const apicore = { search: mock().mockResolvedValue({ documents: [svgDoc], count: 1 }) };
|
|
119
|
+
|
|
120
|
+
const result = await afpSearchMediaTool.handler(apicore, { query: 'test', class: 'graphic', embed: true });
|
|
121
|
+
|
|
122
|
+
expect(result.content.some((c: any) => c.type === 'image')).toBe(false);
|
|
123
|
+
expect(result.content.some((c: any) => c.type === 'text' && c.text.includes('GRAPHIC-1'))).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -6,7 +6,7 @@ import { registerTools } from '../tools/index.js';
|
|
|
6
6
|
import { registerResources } from '../resources/index.js';
|
|
7
7
|
import { registerPrompts } from '../prompts/index.js';
|
|
8
8
|
import type { ServerContext } from '../mcp-server.js';
|
|
9
|
-
import { FIXTURE_DOC, makeDocs } from './fixtures.js';
|
|
9
|
+
import { FIXTURE_DOC, FIXTURE_VIDEO_DOC, makeDocs } from './fixtures.js';
|
|
10
10
|
|
|
11
11
|
function createMockApicore() {
|
|
12
12
|
return {
|
|
@@ -256,6 +256,24 @@ describe('MCP integration', () => {
|
|
|
256
256
|
expect(text).not.toContain('**Country:**');
|
|
257
257
|
});
|
|
258
258
|
|
|
259
|
+
it('renders a timecoded shot list body for video documents', async () => {
|
|
260
|
+
apicore.get.mockResolvedValueOnce(FIXTURE_VIDEO_DOC);
|
|
261
|
+
const result = await client.callTool({ name: 'afp_get_article', arguments: { uno: 'AFP-TEST-VID-001' } });
|
|
262
|
+
const text = getText(result);
|
|
263
|
+
expect(text).toContain('## Shot list');
|
|
264
|
+
expect(text).toContain('1. [00:00-00:12] Vue aérienne de la ville');
|
|
265
|
+
expect(text).toContain('"Tout a commencé très vite"');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('returns the raw document as json when format=json', async () => {
|
|
269
|
+
apicore.get.mockResolvedValueOnce(FIXTURE_DOC);
|
|
270
|
+
const result = await client.callTool({ name: 'afp_get_article', arguments: { uno: 'AFP-TEST-001', format: 'json' } });
|
|
271
|
+
const text = getText(result);
|
|
272
|
+
const parsed = JSON.parse(text);
|
|
273
|
+
expect(parsed.uno).toBe('AFP-TEST-001');
|
|
274
|
+
expect(parsed.news).toEqual(FIXTURE_DOC.news);
|
|
275
|
+
});
|
|
276
|
+
|
|
259
277
|
it('returns isError on API failure', async () => {
|
|
260
278
|
apicore.get.mockRejectedValueOnce(new Error('Not found'));
|
|
261
279
|
const result = await client.callTool({ name: 'afp_get_article', arguments: { uno: 'BAD-UNO' } });
|
|
@@ -337,6 +355,40 @@ describe('MCP integration', () => {
|
|
|
337
355
|
expect(params).toMatchObject({ langs: ['fr'], class: ['text'], provider: ['afp'], dateFrom: 'now-1d' });
|
|
338
356
|
});
|
|
339
357
|
|
|
358
|
+
it('forwards query and date window to the API', async () => {
|
|
359
|
+
await client.callTool({
|
|
360
|
+
name: 'afp_list_facets',
|
|
361
|
+
arguments: { facet: 'slug', query: 'elections', dateFrom: 'now-2d', dateTo: 'now-1d', lang: 'fr' },
|
|
362
|
+
});
|
|
363
|
+
const [facet, params] = apicore.list.mock.calls.at(-1)!;
|
|
364
|
+
expect(facet).toBe('slug');
|
|
365
|
+
expect(params).toMatchObject({
|
|
366
|
+
query: 'elections',
|
|
367
|
+
dateFrom: 'now-2d',
|
|
368
|
+
dateTo: 'now-1d',
|
|
369
|
+
langs: ['fr'],
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('forwards additional facet filters', async () => {
|
|
374
|
+
await client.callTool({
|
|
375
|
+
name: 'afp_list_facets',
|
|
376
|
+
arguments: { facet: 'genre', facets: { country: ['usa'] } },
|
|
377
|
+
});
|
|
378
|
+
const [, params] = apicore.list.mock.calls.at(-1)!;
|
|
379
|
+
expect(params).toMatchObject({ country: ['usa'] });
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('lets explicit dates override the trending preset window', async () => {
|
|
383
|
+
await client.callTool({
|
|
384
|
+
name: 'afp_list_facets',
|
|
385
|
+
arguments: { preset: 'trending-topics', dateFrom: 'now-7d', dateTo: 'now' },
|
|
386
|
+
});
|
|
387
|
+
const [facet, params] = apicore.list.mock.calls.at(-1)!;
|
|
388
|
+
expect(facet).toBe('slug');
|
|
389
|
+
expect(params).toMatchObject({ dateFrom: 'now-7d', dateTo: 'now' });
|
|
390
|
+
});
|
|
391
|
+
|
|
340
392
|
it('returns isError on API failure', async () => {
|
|
341
393
|
apicore.list.mockRejectedValueOnce(new Error('Service unavailable'));
|
|
342
394
|
const result = await client.callTool({ name: 'afp_list_facets', arguments: { facet: 'slug' } });
|
package/src/tools/get-article.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { ApiCore } from 'afpnews-api';
|
|
3
|
-
import { formatFullArticle, toolError } from '../utils/format.js';
|
|
3
|
+
import { formatFullArticle, textContent, toolError } from '../utils/format.js';
|
|
4
4
|
import { formatErrorMessage, UNO_FORMAT_NOTE } from './shared.js';
|
|
5
5
|
|
|
6
6
|
const inputSchema = z.object({
|
|
7
7
|
uno: z.string().describe('The unique UNO identifier of the article'),
|
|
8
|
+
format: z
|
|
9
|
+
.enum(['markdown', 'json'])
|
|
10
|
+
.optional()
|
|
11
|
+
.describe('Output format: markdown (default, rendered article/shot list) or json (raw document, parse it yourself).'),
|
|
8
12
|
});
|
|
9
13
|
|
|
10
14
|
type GetArticleInput = z.infer<typeof inputSchema>;
|
|
@@ -37,11 +41,15 @@ Returns:
|
|
|
37
41
|
- Full article body (all paragraphs, no truncation)
|
|
38
42
|
|
|
39
43
|
Example:
|
|
40
|
-
{ uno: "newsml.afp.com.20260222T090659Z.doc-98hu39e" }
|
|
44
|
+
{ uno: "newsml.afp.com.20260222T090659Z.doc-98hu39e" }
|
|
45
|
+
{ uno: "newsml.afp.com.20260222T090659Z.doc-98hu39e", format: "json" }`,
|
|
41
46
|
inputSchema,
|
|
42
|
-
handler: async (apicore: Pick<ApiCore, 'get'>, { uno }: GetArticleInput) => {
|
|
47
|
+
handler: async (apicore: Pick<ApiCore, 'get'>, { uno, format = 'markdown' }: GetArticleInput) => {
|
|
43
48
|
try {
|
|
44
49
|
const doc = await apicore.get(uno);
|
|
50
|
+
if (format === 'json') {
|
|
51
|
+
return { content: [textContent(JSON.stringify(doc, null, 2))] };
|
|
52
|
+
}
|
|
45
53
|
return { content: [formatFullArticle(doc)] };
|
|
46
54
|
} catch (error) {
|
|
47
55
|
return toolError(formatErrorMessage(`fetching article "${uno}"`, error, 'Verify the UNO identifier is correct.'));
|
package/src/tools/get-media.ts
CHANGED
|
@@ -2,47 +2,17 @@ import { z } from 'zod';
|
|
|
2
2
|
import type { ApiCore } from 'afpnews-api';
|
|
3
3
|
import { textContent, toolError } from '../utils/format.js';
|
|
4
4
|
import { extractRenditions } from '../utils/format-media.js';
|
|
5
|
-
import type { MediaRendition, MediaRenditions
|
|
5
|
+
import type { MediaRendition, MediaRenditions } from '../utils/types.js';
|
|
6
|
+
import { inferMimeType, selectRenditionForEmbed, isSvgRendition, embedRendition } from '../utils/embed-media.js';
|
|
6
7
|
import { renditionEnum, formatErrorMessage } from './shared.js';
|
|
7
8
|
|
|
8
|
-
//
|
|
9
|
-
export
|
|
10
|
-
if (afpType === 'Photo') return 'image/jpeg';
|
|
11
|
-
if (afpType === 'Graphic') return 'image/png';
|
|
12
|
-
const ext = href.split('?')[0].split('.').pop()?.toLowerCase();
|
|
13
|
-
if (ext === 'jpg' || ext === 'jpeg') return 'image/jpeg';
|
|
14
|
-
if (ext === 'png') return 'image/png';
|
|
15
|
-
if (ext === 'webp') return 'image/webp';
|
|
16
|
-
if (ext === 'gif') return 'image/gif';
|
|
17
|
-
if (ext === 'svg') return 'image/svg+xml';
|
|
18
|
-
return 'image/jpeg';
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
// Exported for testing
|
|
22
|
-
export function selectRenditionForEmbed(
|
|
23
|
-
renditions: MediaRenditions,
|
|
24
|
-
requested: 'thumbnail' | 'preview' | 'highdef',
|
|
25
|
-
): MediaRendition | undefined {
|
|
26
|
-
const SIZE_LIMIT = 5_000_000;
|
|
27
|
-
|
|
28
|
-
const get = (key: keyof MediaRenditions): MediaRendition | undefined => renditions[key];
|
|
29
|
-
|
|
30
|
-
// Try requested rendition, then fallback chain
|
|
31
|
-
const candidate = get(requested) ?? get('preview') ?? get('thumbnail');
|
|
32
|
-
if (!candidate) return undefined;
|
|
33
|
-
|
|
34
|
-
// Downgrade if over size limit (only once)
|
|
35
|
-
if ((candidate.sizeInBytes ?? 0) > SIZE_LIMIT) {
|
|
36
|
-
return get('thumbnail') ?? candidate; // proceed with candidate if no thumbnail
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
return candidate;
|
|
40
|
-
}
|
|
9
|
+
// Re-exported for testing (moved to utils/embed-media.ts for reuse by search-media.ts)
|
|
10
|
+
export { inferMimeType, selectRenditionForEmbed };
|
|
41
11
|
|
|
42
12
|
const inputSchema = z.object({
|
|
43
13
|
uno: z.string().describe('AFP document UNO identifier (e.g. newsml.afp.com.20260316T202634Z.doc-a3jc2qq)'),
|
|
44
14
|
embed: z.boolean().optional().describe('When true, fetches the image and returns it as base64 for Claude vision analysis. Default: false.'),
|
|
45
|
-
rendition: renditionEnum.optional().describe("Rendition size to embed: 'thumbnail' (320px), 'preview' (1200px, default), 'highdef' (~3400px)"),
|
|
15
|
+
rendition: renditionEnum.optional().describe("Rendition size to embed: 'squared120' (~120px, lightest, square crop), 'quicklook' (~245px, lightest full-frame), 'thumbnail' (320px), 'mockup' (~512px), 'preview' (1200px, default), 'highdef' (~3400px)"),
|
|
46
16
|
});
|
|
47
17
|
|
|
48
18
|
type GetMediaInput = z.infer<typeof inputSchema>;
|
|
@@ -65,10 +35,13 @@ function formatFullMediaText(doc: Record<string, unknown>, renditions: MediaRend
|
|
|
65
35
|
if (doc.advisory) lines.push(`\n> ${doc.advisory}`);
|
|
66
36
|
|
|
67
37
|
lines.push('\n**Renditions:**');
|
|
68
|
-
const { thumbnail, preview, highdef } = renditions;
|
|
69
|
-
if (
|
|
70
|
-
if (
|
|
71
|
-
if (
|
|
38
|
+
const { squared120, quicklook, thumbnail, mockup, preview, highdef } = renditions;
|
|
39
|
+
if (squared120) lines.push(`- squared120: ${squared120.href} (${squared120.width}×${squared120.height})`);
|
|
40
|
+
if (quicklook) lines.push(`- quicklook: ${quicklook.href} (${quicklook.width}×${quicklook.height})`);
|
|
41
|
+
if (thumbnail) lines.push(`- thumbnail: ${thumbnail.href} (${thumbnail.width}×${thumbnail.height})`);
|
|
42
|
+
if (mockup) lines.push(`- mockup: ${mockup.href} (${mockup.width}×${mockup.height})`);
|
|
43
|
+
if (preview) lines.push(`- preview: ${preview.href} (${preview.width}×${preview.height})`);
|
|
44
|
+
if (highdef) lines.push(`- highdef: ${highdef.href} (${highdef.width}×${highdef.height})`);
|
|
72
45
|
|
|
73
46
|
if (note) lines.push(`\n*${note}*`);
|
|
74
47
|
|
|
@@ -85,8 +58,8 @@ Media classes: picture (photo), video, graphic (infographic/SVG), videography (v
|
|
|
85
58
|
Args:
|
|
86
59
|
- uno: AFP document UNO (e.g. newsml.afp.com.20260316T202634Z.doc-a3jc2qq)
|
|
87
60
|
- embed: When true, fetches the image and returns it as a base64 MCP image block that Claude can see and analyse visually. Default: false.
|
|
88
|
-
- rendition: Size to embed — 'thumbnail' (320px), 'preview' (1200px, default), 'highdef' (~3400px).
|
|
89
|
-
Files > 5 MB are automatically downgraded to
|
|
61
|
+
- rendition: Size to embed — 'squared120' (~120px, square crop, lightest), 'quicklook' (~245px, lightest full-frame), 'thumbnail' (320px), 'mockup' (~512px), 'preview' (1200px, default), 'highdef' (~3400px).
|
|
62
|
+
Files > 5 MB are automatically downgraded to a lighter rendition.
|
|
90
63
|
Videos and videography always use thumbnail (poster frame). SVG graphics cannot be embedded.
|
|
91
64
|
|
|
92
65
|
Returns:
|
|
@@ -112,9 +85,8 @@ Returns:
|
|
|
112
85
|
}
|
|
113
86
|
|
|
114
87
|
// Guard: SVG graphics (URL ends with .svg OR AFP type field is 'Graphic')
|
|
115
|
-
const isSvg = (r: MediaRendition) => r.href.split('?')[0].endsWith('.svg') || r.afpType === 'Graphic';
|
|
116
88
|
const allRenditions = Object.values(renditions).filter(Boolean) as MediaRendition[];
|
|
117
|
-
if (doc.class === 'graphic' && allRenditions.some(
|
|
89
|
+
if (doc.class === 'graphic' && allRenditions.some(isSvgRendition)) {
|
|
118
90
|
return {
|
|
119
91
|
content: [
|
|
120
92
|
metadataText,
|
|
@@ -124,7 +96,7 @@ Returns:
|
|
|
124
96
|
}
|
|
125
97
|
|
|
126
98
|
// Guard: video → use thumbnail as poster frame
|
|
127
|
-
let renditionKey:
|
|
99
|
+
let renditionKey: keyof MediaRenditions = requestedRendition;
|
|
128
100
|
let note: string | undefined;
|
|
129
101
|
if (doc.class === 'video') {
|
|
130
102
|
renditionKey = 'thumbnail';
|
|
@@ -141,46 +113,21 @@ Returns:
|
|
|
141
113
|
};
|
|
142
114
|
}
|
|
143
115
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
try {
|
|
148
|
-
const response = await fetch(chosen.href);
|
|
149
|
-
if (!response.ok) {
|
|
150
|
-
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
151
|
-
}
|
|
152
|
-
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
153
|
-
const chunks: string[] = [];
|
|
154
|
-
for (let i = 0; i < bytes.length; i += 8192) {
|
|
155
|
-
chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));
|
|
156
|
-
}
|
|
157
|
-
imageData = btoa(chunks.join(''));
|
|
158
|
-
// MIME priority: AFP type field → URL extension → HTTP Content-Type → fallback
|
|
159
|
-
mimeType = inferMimeType(chosen.afpType, chosen.href);
|
|
160
|
-
// Override with Content-Type from response if more specific
|
|
161
|
-
const ct = response.headers.get('content-type');
|
|
162
|
-
if (ct && ct.startsWith('image/')) mimeType = ct.split(';')[0].trim();
|
|
163
|
-
} catch (fetchErr) {
|
|
164
|
-
const msg = fetchErr instanceof Error ? fetchErr.message : 'Unknown fetch error';
|
|
116
|
+
const embedded = await embedRendition(chosen);
|
|
117
|
+
if (!embedded.ok) {
|
|
165
118
|
return {
|
|
166
119
|
content: [
|
|
167
120
|
metadataText,
|
|
168
|
-
textContent(`Warning: image embed failed: ${
|
|
121
|
+
textContent(`Warning: image embed failed: ${embedded.error}`),
|
|
169
122
|
],
|
|
170
123
|
};
|
|
171
124
|
}
|
|
172
125
|
|
|
173
|
-
const imageContent: ImageContent = {
|
|
174
|
-
type: 'image',
|
|
175
|
-
data: imageData,
|
|
176
|
-
mimeType,
|
|
177
|
-
};
|
|
178
|
-
|
|
179
126
|
const metaWithNote = note
|
|
180
127
|
? textContent(formatFullMediaText(doc, renditions, note))
|
|
181
128
|
: metadataText;
|
|
182
129
|
|
|
183
|
-
return { content: [metaWithNote,
|
|
130
|
+
return { content: [metaWithNote, embedded.image] };
|
|
184
131
|
} catch (error) {
|
|
185
132
|
return toolError(formatErrorMessage('retrieving AFP media document', error, 'Check the UNO identifier and try again.'));
|
|
186
133
|
}
|
package/src/tools/list-facets.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { ApiCore, SearchQueryParams } from 'afpnews-api';
|
|
|
3
3
|
import { escapeCsvValue, textContent, toolError, truncateToLimit, truncationHint } from '../utils/format.js';
|
|
4
4
|
import {
|
|
5
5
|
type FacetResult,
|
|
6
|
+
facetParamValueSchema,
|
|
6
7
|
formatErrorMessage,
|
|
7
8
|
langEnum,
|
|
8
9
|
listPresetEnum,
|
|
@@ -13,8 +14,12 @@ const inputSchema = z.object({
|
|
|
13
14
|
preset: listPresetEnum.optional().describe('Optional preset for list queries. Available preset: trending-topics.'),
|
|
14
15
|
facet: z.string().optional().describe("Facet to list (e.g. 'slug', 'genre', 'country'). Required when no preset is used."),
|
|
15
16
|
lang: langEnum.optional().describe("Language filter (e.g. 'en', 'fr')"),
|
|
17
|
+
query: z.string().optional().describe("Keywords to scope the facet counts (e.g. 'climate change'). When set, counts reflect only articles matching the query."),
|
|
18
|
+
dateFrom: z.string().optional().describe("Start of the time window (e.g. 'now-1d', '2026-01-01'). Combine with dateTo for a bounded window (e.g. to compare two periods)."),
|
|
19
|
+
dateTo: z.string().optional().describe("End of the time window (e.g. 'now', 'now-1d', '2026-01-31')."),
|
|
16
20
|
size: z.number().optional().describe('Number of facet values to return'),
|
|
17
21
|
format: outputFormatEnum.optional().describe('Output format: markdown (default), json, or csv.'),
|
|
22
|
+
facets: z.record(z.string(), facetParamValueSchema).optional().describe("Additional facet filters passed to the AFP query (e.g. { country: ['usa'], genre: 'Papier général', urgency: 1 })."),
|
|
18
23
|
});
|
|
19
24
|
|
|
20
25
|
type ListFacetsInput = z.infer<typeof inputSchema>;
|
|
@@ -22,14 +27,17 @@ type ListFacetsInput = z.infer<typeof inputSchema>;
|
|
|
22
27
|
export const afpListFacetsTool = {
|
|
23
28
|
name: 'afp_list_facets',
|
|
24
29
|
title: 'List AFP Facet Values',
|
|
25
|
-
description: `List facet values and their article counts. Use this to discover available topics, genres, or countries, or
|
|
30
|
+
description: `List facet values and their article counts. Use this to discover available topics, genres, or countries, get trending topics, or compare activity across time windows.
|
|
26
31
|
|
|
27
32
|
Args:
|
|
28
33
|
- preset: Optional preset (trending-topics) — overrides facet to 'slug' with last 24h news
|
|
29
34
|
- facet: Facet to list (e.g. 'slug', 'genre', 'country'). Required when no preset is used.
|
|
30
35
|
- lang: Language filter (e.g. 'en', 'fr')
|
|
36
|
+
- query: Keywords to scope the counts (e.g. 'climate change')
|
|
37
|
+
- dateFrom / dateTo: Time window for the counts (e.g. dateFrom 'now-1d'; dateFrom '2026-01-01', dateTo '2026-01-31')
|
|
31
38
|
- size: Number of facet values to return
|
|
32
39
|
- format: Output format — markdown (default), json, or csv.
|
|
40
|
+
- facets: Additional filters (country, genre, urgency, …)
|
|
33
41
|
|
|
34
42
|
Returns:
|
|
35
43
|
- markdown: Formatted list with labels and article counts
|
|
@@ -39,10 +47,12 @@ Returns:
|
|
|
39
47
|
Examples:
|
|
40
48
|
- Trending topics in French: { preset: "trending-topics" }
|
|
41
49
|
- Trending topics in English: { preset: "trending-topics", lang: "en" }
|
|
42
|
-
-
|
|
50
|
+
- Top slugs over the last 24h: { facet: "slug", dateFrom: "now-1d", format: "json" }
|
|
51
|
+
- Top slugs over the previous 24h: { facet: "slug", dateFrom: "now-2d", dateTo: "now-1d", format: "json" }
|
|
52
|
+
- Genres about a topic: { facet: "genre", query: "elections", format: "csv" }
|
|
43
53
|
- List countries as JSON: { facet: "country", size: 30, format: "json" }`,
|
|
44
54
|
inputSchema,
|
|
45
|
-
handler: async (apicore: Pick<ApiCore, 'list'>, { preset, facet, lang, size, format = 'markdown' }: ListFacetsInput) => {
|
|
55
|
+
handler: async (apicore: Pick<ApiCore, 'list'>, { preset, facet, lang, query, dateFrom, dateTo, size, format = 'markdown', facets }: ListFacetsInput) => {
|
|
46
56
|
try {
|
|
47
57
|
const isTrendingTopics = preset === 'trending-topics';
|
|
48
58
|
const resolvedFacet = isTrendingTopics ? 'slug' : facet;
|
|
@@ -52,9 +62,20 @@ Examples:
|
|
|
52
62
|
}
|
|
53
63
|
|
|
54
64
|
const resolvedSize = size ?? 10;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
65
|
+
// Le preset trending impose slug + fenêtre 24h, mais dateFrom/dateTo/query
|
|
66
|
+
// explicites restent prioritaires pour affiner ou comparer des périodes.
|
|
67
|
+
const resolvedLang = lang ?? (isTrendingTopics ? 'fr' : undefined);
|
|
68
|
+
const resolvedDateFrom = dateFrom ?? (isTrendingTopics ? 'now-1d' : undefined);
|
|
69
|
+
const params: SearchQueryParams = {
|
|
70
|
+
class: ['text'],
|
|
71
|
+
provider: ['afp'],
|
|
72
|
+
size: resolvedSize,
|
|
73
|
+
...(resolvedLang ? { langs: [resolvedLang] } : {}),
|
|
74
|
+
...(query ? { query } : {}),
|
|
75
|
+
...(resolvedDateFrom ? { dateFrom: resolvedDateFrom } : {}),
|
|
76
|
+
...(dateTo ? { dateTo } : {}),
|
|
77
|
+
...(facets ?? {}),
|
|
78
|
+
};
|
|
58
79
|
|
|
59
80
|
const { keywords } = await apicore.list(resolvedFacet, params, 1);
|
|
60
81
|
const results: FacetResult[] = keywords.map(k => ({ name: k.name ?? '', count: k.count }));
|
|
@@ -3,9 +3,12 @@ import type { ApiCore, SearchQueryParams } from 'afpnews-api';
|
|
|
3
3
|
import { textContent, toolError, buildPaginationLine } from '../utils/format.js';
|
|
4
4
|
import {
|
|
5
5
|
formatMediaOutput,
|
|
6
|
+
formatMediaDocument,
|
|
6
7
|
normalizeMediaDocument,
|
|
7
8
|
} from '../utils/format-media.js';
|
|
8
9
|
import { DEFAULT_SEARCH_SIZE } from '../utils/types.js';
|
|
10
|
+
import type { AFPMediaDocument, AnyContent, MediaRendition } from '../utils/types.js';
|
|
11
|
+
import { selectRenditionForEmbed, isSvgRendition, embedRendition } from '../utils/embed-media.js';
|
|
9
12
|
import {
|
|
10
13
|
mediaClassEnum,
|
|
11
14
|
outputFormatEnum,
|
|
@@ -13,6 +16,33 @@ import {
|
|
|
13
16
|
facetParamValueSchema,
|
|
14
17
|
} from './shared.js';
|
|
15
18
|
|
|
19
|
+
// Fetching + base64-encoding images is costly: cap how many results get embedded per call,
|
|
20
|
+
// regardless of the requested `size` (see handler: `size` itself is clamped when embed is true).
|
|
21
|
+
export const EMBED_MAX_DOCS = 5;
|
|
22
|
+
|
|
23
|
+
async function embedMediaDocuments(docs: AFPMediaDocument[]): Promise<AnyContent[]> {
|
|
24
|
+
const content: AnyContent[] = [];
|
|
25
|
+
|
|
26
|
+
for (const doc of docs) {
|
|
27
|
+
content.push(formatMediaDocument(doc));
|
|
28
|
+
|
|
29
|
+
const allRenditions = Object.values(doc.renditions).filter(Boolean) as MediaRendition[];
|
|
30
|
+
if (doc.class === 'graphic' && allRenditions.some(isSvgRendition)) {
|
|
31
|
+
content.push(textContent('_Warning: SVG graphics cannot be embedded for vision._'));
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const renditionKey = doc.class === 'video' || doc.class === 'videography' ? 'thumbnail' : 'quicklook';
|
|
36
|
+
const chosen = selectRenditionForEmbed(doc.renditions, renditionKey);
|
|
37
|
+
if (!chosen) continue;
|
|
38
|
+
|
|
39
|
+
const embedded = await embedRendition(chosen);
|
|
40
|
+
if (embedded.ok) content.push(embedded.image);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return content;
|
|
44
|
+
}
|
|
45
|
+
|
|
16
46
|
const reservedMediaFacetKeys = new Set(['class', 'format', 'query', 'size', 'sortOrder', 'offset', 'facets']);
|
|
17
47
|
|
|
18
48
|
const MEDIA_API_FIELDS = [
|
|
@@ -31,6 +61,9 @@ const inputSchema = z.object({
|
|
|
31
61
|
facets: z.record(z.string(), facetParamValueSchema).optional().describe(
|
|
32
62
|
"Additional AFP facet filters (e.g. { langs: ['fr'], country: ['fra'], dateFrom: '2026-01-01' })"
|
|
33
63
|
),
|
|
64
|
+
embed: z.boolean().optional().describe(
|
|
65
|
+
`When true, fetches and embeds the lightest available image (quicklook, thumbnail poster frame for video) for each result as base64, for Claude vision analysis. Requires format: "markdown" (default). Size is capped to ${EMBED_MAX_DOCS} regardless of the requested value, since fetching/encoding images is costly. Default: false.`
|
|
66
|
+
),
|
|
34
67
|
}).strict().superRefine((value, ctx) => {
|
|
35
68
|
for (const key of Object.keys(value.facets ?? {})) {
|
|
36
69
|
if (reservedMediaFacetKeys.has(key)) {
|
|
@@ -67,6 +100,9 @@ Args:
|
|
|
67
100
|
- format: markdown (default, with inline thumbnails), json (structured with rendition URLs), csv
|
|
68
101
|
- facets: Additional AFP filters (e.g. { langs: ['fr'], country: ['fra'], dateFrom: '2026-01-01' }).
|
|
69
102
|
Defaults: provider=afp. Override provider only when partner media is explicitly needed.
|
|
103
|
+
- embed: When true, fetches and embeds the lightest image for each result as base64 (Claude vision analysis) —
|
|
104
|
+
useful to visually compare or verify a handful of candidate photos before picking one.
|
|
105
|
+
Requires format: "markdown". Size is capped to ${EMBED_MAX_DOCS} regardless of the requested value.
|
|
70
106
|
|
|
71
107
|
Pagination:
|
|
72
108
|
Use \`offset\` to paginate (e.g. offset=10 to skip the first 10).
|
|
@@ -75,9 +111,10 @@ Pagination:
|
|
|
75
111
|
Returns (json):
|
|
76
112
|
{ total, shown, offset, truncated, remaining, documents: [{ uno, title, caption, creditLine, creator,
|
|
77
113
|
country, city, published, urgency, class, aspectRatios, advisory,
|
|
78
|
-
renditions: { thumbnail, preview, highdef } }] }
|
|
114
|
+
renditions: { quicklook, thumbnail, preview, highdef } }] }
|
|
79
115
|
|
|
80
116
|
Rendition sizes:
|
|
117
|
+
- quicklook: ~245px wide (lightest, when available)
|
|
81
118
|
- thumbnail: ~320px wide (gallery grid)
|
|
82
119
|
- preview: ~1200px wide (display)
|
|
83
120
|
- highdef: ~3400px wide (download / analysis)
|
|
@@ -90,13 +127,18 @@ Examples:
|
|
|
90
127
|
inputSchema,
|
|
91
128
|
handler: async (
|
|
92
129
|
apicore: Pick<ApiCore, 'search'>,
|
|
93
|
-
{ class: mediaClass, query, size = DEFAULT_SEARCH_SIZE, offset, sortOrder = 'desc', format = 'markdown', facets }: SearchMediaInput,
|
|
130
|
+
{ class: mediaClass, query, size = DEFAULT_SEARCH_SIZE, offset, sortOrder = 'desc', format = 'markdown', facets, embed = false }: SearchMediaInput,
|
|
94
131
|
) => {
|
|
95
132
|
try {
|
|
133
|
+
if (embed && format !== 'markdown') {
|
|
134
|
+
return toolError(`embed is only supported with format: "markdown" (got "${format}").`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const effectiveSize = embed ? Math.min(size, EMBED_MAX_DOCS) : size;
|
|
96
138
|
const classFilter = mediaClass ? [mediaClass] : ['picture', 'video', 'graphic', 'videography'];
|
|
97
139
|
const request: SearchQueryParams = {
|
|
98
140
|
query,
|
|
99
|
-
size,
|
|
141
|
+
size: effectiveSize,
|
|
100
142
|
sortOrder,
|
|
101
143
|
startAt: offset,
|
|
102
144
|
class: classFilter,
|
|
@@ -112,6 +154,18 @@ Examples:
|
|
|
112
154
|
|
|
113
155
|
const docs = rawDocs.map(normalizeMediaDocument);
|
|
114
156
|
const currentOffset = offset ?? 0;
|
|
157
|
+
|
|
158
|
+
if (embed) {
|
|
159
|
+
const pagination = textContent(buildPaginationLine(docs.length, count, currentOffset));
|
|
160
|
+
const embeddedContent = await embedMediaDocuments(docs);
|
|
161
|
+
return {
|
|
162
|
+
content: [pagination, ...embeddedContent],
|
|
163
|
+
shown: docs.length,
|
|
164
|
+
truncated: false,
|
|
165
|
+
remaining: count - docs.length,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
115
169
|
return formatMediaOutput(docs, format, {
|
|
116
170
|
jsonMeta: { total: count, offset: currentOffset },
|
|
117
171
|
markdownPrefix: (shown) => [textContent(buildPaginationLine(shown, count, currentOffset))],
|
package/src/tools/shared.ts
CHANGED
|
@@ -28,7 +28,7 @@ const MEDIA_CLASS_VALUES = ['picture', 'video', 'graphic', 'videography'] as con
|
|
|
28
28
|
export const mediaClassEnum = z.enum(MEDIA_CLASS_VALUES);
|
|
29
29
|
export type MediaClass = z.infer<typeof mediaClassEnum>;
|
|
30
30
|
|
|
31
|
-
const RENDITION_VALUES = ['thumbnail', 'preview', 'highdef'] as const;
|
|
31
|
+
const RENDITION_VALUES = ['squared120', 'quicklook', 'thumbnail', 'mockup', 'preview', 'highdef'] as const;
|
|
32
32
|
export const renditionEnum = z.enum(RENDITION_VALUES);
|
|
33
33
|
export type RenditionKey = z.infer<typeof renditionEnum>;
|
|
34
34
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { MediaRendition, MediaRenditions, ImageContent } from './types.js';
|
|
2
|
+
|
|
3
|
+
// Exported for testing
|
|
4
|
+
export function inferMimeType(afpType: string | undefined, href: string): string {
|
|
5
|
+
if (afpType === 'Photo') return 'image/jpeg';
|
|
6
|
+
if (afpType === 'Graphic') return 'image/png';
|
|
7
|
+
const ext = href.split('?')[0].split('.').pop()?.toLowerCase();
|
|
8
|
+
if (ext === 'jpg' || ext === 'jpeg') return 'image/jpeg';
|
|
9
|
+
if (ext === 'png') return 'image/png';
|
|
10
|
+
if (ext === 'webp') return 'image/webp';
|
|
11
|
+
if (ext === 'gif') return 'image/gif';
|
|
12
|
+
if (ext === 'svg') return 'image/svg+xml';
|
|
13
|
+
return 'image/jpeg';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Exported for testing
|
|
17
|
+
export function selectRenditionForEmbed(
|
|
18
|
+
renditions: MediaRenditions,
|
|
19
|
+
requested: keyof MediaRenditions,
|
|
20
|
+
): MediaRendition | undefined {
|
|
21
|
+
const SIZE_LIMIT = 5_000_000;
|
|
22
|
+
|
|
23
|
+
const get = (key: keyof MediaRenditions): MediaRendition | undefined => renditions[key];
|
|
24
|
+
|
|
25
|
+
// Try requested rendition, then fallback chain (largest usable → lightest)
|
|
26
|
+
const candidate = get(requested) ?? get('preview') ?? get('thumbnail') ?? get('quicklook') ?? get('squared120');
|
|
27
|
+
if (!candidate) return undefined;
|
|
28
|
+
|
|
29
|
+
// Downgrade if over size limit (only once)
|
|
30
|
+
if ((candidate.sizeInBytes ?? 0) > SIZE_LIMIT) {
|
|
31
|
+
return get('thumbnail') ?? get('quicklook') ?? get('squared120') ?? candidate; // proceed with candidate if no lighter rendition
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return candidate;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// SVG graphics (URL ends with .svg OR AFP type field is 'Graphic') cannot be embedded for vision.
|
|
38
|
+
export function isSvgRendition(r: MediaRendition): boolean {
|
|
39
|
+
return r.href.split('?')[0].endsWith('.svg') || r.afpType === 'Graphic';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function embedRendition(
|
|
43
|
+
rendition: MediaRendition,
|
|
44
|
+
): Promise<{ ok: true; image: ImageContent } | { ok: false; error: string }> {
|
|
45
|
+
try {
|
|
46
|
+
const response = await fetch(rendition.href);
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
49
|
+
}
|
|
50
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
51
|
+
const chunks: string[] = [];
|
|
52
|
+
for (let i = 0; i < bytes.length; i += 8192) {
|
|
53
|
+
chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));
|
|
54
|
+
}
|
|
55
|
+
const data = btoa(chunks.join(''));
|
|
56
|
+
// MIME priority: AFP type field → URL extension → HTTP Content-Type → fallback
|
|
57
|
+
let mimeType = inferMimeType(rendition.afpType, rendition.href);
|
|
58
|
+
const ct = response.headers.get('content-type');
|
|
59
|
+
if (ct && ct.startsWith('image/')) mimeType = ct.split(';')[0].trim();
|
|
60
|
+
return { ok: true, image: { type: 'image', data, mimeType } };
|
|
61
|
+
} catch (err) {
|
|
62
|
+
const message = err instanceof Error ? err.message : 'Unknown fetch error';
|
|
63
|
+
return { ok: false, error: message };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -4,11 +4,14 @@ import { textContent, truncateToLimit, truncateContentItems, truncationHint } fr
|
|
|
4
4
|
// Mapping role AFP → clé normalisée (utilise m.role, pas m.rendition)
|
|
5
5
|
// Preview est prioritaire sur Preview_B/Preview_W (premier match gagne)
|
|
6
6
|
export const MEDIA_RENDITION_ROLE_MAP: Record<string, keyof MediaRenditions> = {
|
|
7
|
-
'
|
|
8
|
-
'
|
|
9
|
-
'
|
|
10
|
-
'
|
|
11
|
-
'
|
|
7
|
+
'Squared120': 'squared120',
|
|
8
|
+
'Quicklook': 'quicklook',
|
|
9
|
+
'Thumbnail': 'thumbnail',
|
|
10
|
+
'Mockup': 'mockup',
|
|
11
|
+
'Preview': 'preview',
|
|
12
|
+
'Preview_B': 'preview',
|
|
13
|
+
'Preview_W': 'preview',
|
|
14
|
+
'HighDef': 'highdef',
|
|
12
15
|
};
|
|
13
16
|
|
|
14
17
|
export function extractRenditions(bagItem: unknown): MediaRenditions {
|
package/src/utils/format.ts
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
|
+
import { parseShotList } from 'afpnews-api';
|
|
1
2
|
import type { AFPDocument, TextContent } from './types.js';
|
|
2
3
|
import { EXCERPT_PARAGRAPH_COUNT, CHARACTER_LIMIT } from './types.js';
|
|
3
4
|
|
|
5
|
+
const VIDEO_CLASSES = new Set(['video', 'videography']);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Render a video shot list (timecodes + descriptions + soundbite quotes) from
|
|
9
|
+
* the raw `news` lines. Returns `null` when the document is not a video or when
|
|
10
|
+
* no shot could be parsed, so callers can fall back to the plain text body.
|
|
11
|
+
*/
|
|
12
|
+
export function formatShotList(doc: unknown): string | null {
|
|
13
|
+
const d = doc as AFPDocument;
|
|
14
|
+
if (!VIDEO_CLASSES.has(String(d['class']))) return null;
|
|
15
|
+
|
|
16
|
+
const news = Array.isArray(d.news) ? d.news : [];
|
|
17
|
+
const shots = parseShotList(news.join('\n'));
|
|
18
|
+
if (shots.length === 0) return null;
|
|
19
|
+
|
|
20
|
+
const lines = shots.map((shot) => {
|
|
21
|
+
const head = `${shot.numero}. [${shot.start}-${shot.end}] ${shot.description}`.trimEnd();
|
|
22
|
+
const quotes = shot.citations.map((c) => ` "${c.text}"`);
|
|
23
|
+
return [head, ...quotes].join('\n');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
return `## Shot list\n\n${lines.join('\n')}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
4
29
|
export function truncationHint(remaining?: number): string {
|
|
5
30
|
const suffix = remaining != null && remaining > 0
|
|
6
31
|
? ` ${remaining} additional item(s) not returned.`
|
|
@@ -163,7 +188,7 @@ export function formatFullArticle(doc: unknown): TextContent {
|
|
|
163
188
|
if (flags) lines.push(flags);
|
|
164
189
|
|
|
165
190
|
const meta = lines.join('\n');
|
|
166
|
-
const body = (Array.isArray(d.news) ? d.news : []).join('\n\n');
|
|
191
|
+
const body = formatShotList(d) ?? (Array.isArray(d.news) ? d.news : []).join('\n\n');
|
|
167
192
|
|
|
168
193
|
return textContent(`## ${d.headline}\n\n${meta}\n\n---\n\n${body}`);
|
|
169
194
|
}
|
package/src/utils/types.ts
CHANGED
|
@@ -32,7 +32,10 @@ export interface MediaRendition {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
export interface MediaRenditions {
|
|
35
|
+
squared120?: MediaRendition;
|
|
36
|
+
quicklook?: MediaRendition;
|
|
35
37
|
thumbnail?: MediaRendition;
|
|
38
|
+
mockup?: MediaRendition;
|
|
36
39
|
preview?: MediaRendition;
|
|
37
40
|
highdef?: MediaRendition;
|
|
38
41
|
}
|