@houtini/seo-audit-console 0.6.0 → 0.7.8
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/README.md +3 -1
- package/dist/core/AuditDatabase.d.ts.map +1 -1
- package/dist/core/AuditDatabase.js +6 -0
- package/dist/core/AuditDatabase.js.map +1 -1
- package/dist/core/Backlinks.d.ts +5 -1
- package/dist/core/Backlinks.d.ts.map +1 -1
- package/dist/core/Backlinks.js +31 -1
- package/dist/core/Backlinks.js.map +1 -1
- package/dist/core/MajesticClient.d.ts.map +1 -1
- package/dist/core/MajesticClient.js +10 -1
- package/dist/core/MajesticClient.js.map +1 -1
- package/dist/core/dashboardData.d.ts +28 -0
- package/dist/core/dashboardData.d.ts.map +1 -1
- package/dist/core/dashboardData.js +64 -3
- package/dist/core/dashboardData.js.map +1 -1
- package/dist/core/googleNews.d.ts +26 -0
- package/dist/core/googleNews.d.ts.map +1 -0
- package/dist/core/googleNews.js +49 -0
- package/dist/core/googleNews.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +100 -17
- package/dist/server.js.map +1 -1
- package/dist/src/ui/dashboard.html +83 -51
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google News — the free, keyless source for news_discovery. Google's old News API was
|
|
3
|
+
* retired long ago; the live endpoint is the Google News RSS *search* feed, which returns
|
|
4
|
+
* recent articles for a query with title, source, link and publish time. No credentials,
|
|
5
|
+
* no cost — so news works even without a DataForSEO key (DataForSEO's Google News SERP
|
|
6
|
+
* stays the richer paid option; this is the always-available fallback / second source).
|
|
7
|
+
*/
|
|
8
|
+
export interface GoogleNewsArticle {
|
|
9
|
+
title: string;
|
|
10
|
+
url: string;
|
|
11
|
+
source: string | null;
|
|
12
|
+
timestamp: string | null;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Fetch recent Google News results for a keyword via the RSS search feed.
|
|
16
|
+
* `hl` = interface language, `gl` = country; `ceid` is derived (country:lang).
|
|
17
|
+
*/
|
|
18
|
+
export declare function fetchGoogleNews(keyword: string, opts?: {
|
|
19
|
+
hl?: string;
|
|
20
|
+
gl?: string;
|
|
21
|
+
limit?: number;
|
|
22
|
+
}): Promise<{
|
|
23
|
+
articles: GoogleNewsArticle[];
|
|
24
|
+
source: 'google-news-rss';
|
|
25
|
+
}>;
|
|
26
|
+
//# sourceMappingURL=googleNews.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"googleNews.d.ts","sourceRoot":"","sources":["../../src/core/googleNews.ts"],"names":[],"mappings":"AAEA;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED;;;GAGG;AACH,wBAAsB,eAAe,CACnC,OAAO,EAAE,MAAM,EACf,IAAI,GAAE;IAAE,EAAE,CAAC,EAAE,MAAM,CAAC;IAAC,EAAE,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAO,GACtD,OAAO,CAAC;IAAE,QAAQ,EAAE,iBAAiB,EAAE,CAAC;IAAC,MAAM,EAAE,iBAAiB,CAAA;CAAE,CAAC,CAsCvE"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import * as cheerio from 'cheerio';
|
|
2
|
+
/**
|
|
3
|
+
* Fetch recent Google News results for a keyword via the RSS search feed.
|
|
4
|
+
* `hl` = interface language, `gl` = country; `ceid` is derived (country:lang).
|
|
5
|
+
*/
|
|
6
|
+
export async function fetchGoogleNews(keyword, opts = {}) {
|
|
7
|
+
const hl = opts.hl ?? 'en-US';
|
|
8
|
+
const gl = opts.gl ?? 'US';
|
|
9
|
+
const ceid = `${gl}:${hl.split('-')[0]}`;
|
|
10
|
+
const url = `https://news.google.com/rss/search?q=${encodeURIComponent(keyword)}&hl=${encodeURIComponent(hl)}&gl=${encodeURIComponent(gl)}&ceid=${encodeURIComponent(ceid)}`;
|
|
11
|
+
const res = await fetch(url, {
|
|
12
|
+
headers: { 'user-agent': 'Mozilla/5.0 (compatible; seo-audit-console/1.0; +https://github.com/houtini-ai/seo-audit)' },
|
|
13
|
+
signal: AbortSignal.timeout(15000),
|
|
14
|
+
});
|
|
15
|
+
if (!res.ok)
|
|
16
|
+
throw new Error(`Google News RSS ${res.status}`);
|
|
17
|
+
const xml = await res.text();
|
|
18
|
+
// RSS is well-formed XML — parse with cheerio in xmlMode (already a dependency).
|
|
19
|
+
const $ = cheerio.load(xml, { xmlMode: true });
|
|
20
|
+
const limit = Math.min(opts.limit ?? 20, 100);
|
|
21
|
+
const articles = [];
|
|
22
|
+
$('item').each((_i, el) => {
|
|
23
|
+
if (articles.length >= limit)
|
|
24
|
+
return;
|
|
25
|
+
const $el = $(el);
|
|
26
|
+
const title = $el.find('title').first().text().trim();
|
|
27
|
+
if (!title)
|
|
28
|
+
return;
|
|
29
|
+
// <source url="...">Publisher</source>. Google News titles are usually "Headline - Publisher";
|
|
30
|
+
// prefer the explicit <source> element, fall back to the trailing " - Publisher".
|
|
31
|
+
let source = $el.find('source').first().text().trim() || null;
|
|
32
|
+
let headline = title;
|
|
33
|
+
if (!source) {
|
|
34
|
+
const m = title.match(/^(.*)\s+-\s+([^-]+)$/);
|
|
35
|
+
if (m) {
|
|
36
|
+
headline = m[1].trim();
|
|
37
|
+
source = m[2].trim();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
articles.push({
|
|
41
|
+
title: headline,
|
|
42
|
+
url: $el.find('link').first().text().trim(),
|
|
43
|
+
source,
|
|
44
|
+
timestamp: $el.find('pubDate').first().text().trim() || null,
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
return { articles, source: 'google-news-rss' };
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=googleNews.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"googleNews.js","sourceRoot":"","sources":["../../src/core/googleNews.ts"],"names":[],"mappings":"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"}
|
package/dist/server.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAiFpE,wBAAgB,OAAO,IAAI,MAAM,CAGhC;AAkLD,wBAAgB,YAAY,IAAI;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,GAAG,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAAE,CAiqE9E"}
|
package/dist/server.js
CHANGED
|
@@ -22,8 +22,11 @@ import { selectReconTargets, deterministicTodos, persistReconPage, insertTodos,
|
|
|
22
22
|
function browserLink(siteUrl) {
|
|
23
23
|
const base = dashboardServerUrl();
|
|
24
24
|
if (base)
|
|
25
|
-
return `\n\
|
|
26
|
-
|
|
25
|
+
return `\n\n📊 Browser dashboard: ${base}/dashboard${siteUrl ? `?siteUrl=${encodeURIComponent(siteUrl)}` : ''}`;
|
|
26
|
+
// No live server: point at the dashboard surfaces that always work. get_dashboard renders the
|
|
27
|
+
// interactive dashboard in chat (works through the Docker gateway where a served port would not);
|
|
28
|
+
// serve_dashboard opens a browser tab locally; export_report writes a shareable HTML file.
|
|
29
|
+
return `\n\n📊 See it in the dashboard — run get_dashboard${siteUrl ? ` for ${siteUrl}` : ''} (interactive, in chat), serve_dashboard (browser tab), or export_report (shareable HTML).`;
|
|
27
30
|
}
|
|
28
31
|
import { runAudit, runSingleCheck, listChecks } from './audit/engine.js';
|
|
29
32
|
import { buildAuditMarkdown } from './audit/report.js';
|
|
@@ -50,6 +53,7 @@ import { RankTracker } from './core/RankTracker.js';
|
|
|
50
53
|
import { Backlinks } from './core/Backlinks.js';
|
|
51
54
|
import { LinkIntersect } from './core/LinkIntersect.js';
|
|
52
55
|
import { MajesticClient } from './core/MajesticClient.js';
|
|
56
|
+
import { fetchGoogleNews } from './core/googleNews.js';
|
|
53
57
|
import { WikidataClient } from './core/WikidataClient.js';
|
|
54
58
|
import { Entities } from './core/Entities.js';
|
|
55
59
|
import { JobManager } from './core/JobManager.js';
|
|
@@ -253,17 +257,18 @@ export function createServer() {
|
|
|
253
257
|
const crawler = new Crawler(dataDir()); // no GSC credentials required
|
|
254
258
|
const dfsUser = process.env.DATAFORSEO_USERNAME;
|
|
255
259
|
const dfsPass = process.env.DATAFORSEO_PASSWORD;
|
|
256
|
-
const dfsCacheDays = Number(process.env.DATAFORSEO_CACHE_DAYS) ||
|
|
260
|
+
const dfsCacheDays = Number(process.env.DATAFORSEO_CACHE_DAYS) || 7;
|
|
257
261
|
const dfs = dfsUser && dfsPass
|
|
258
262
|
? new DataForSeoClient(dfsUser, dfsPass, path.join(dataDir(), 'dataforseo-cache.db'), dfsCacheDays)
|
|
259
263
|
: null;
|
|
260
264
|
const rankTracker = dfs ? new RankTracker(dfs, dataDir()) : null;
|
|
261
|
-
const backlinks = dfs ? new Backlinks(dfs, dataDir()) : null;
|
|
262
265
|
const linkIntersect = dfs ? new LinkIntersect(dfs, dataDir()) : null;
|
|
263
|
-
// Majestic (Trust Flow / Topical Trust Flow) — optional link_intersect
|
|
266
|
+
// Majestic (Trust Flow / Topical Trust Flow) — optional link_intersect + trapped-authority tier.
|
|
264
267
|
const majesticKey = process.env.MAJESTIC_API_KEY;
|
|
265
|
-
const majesticCacheDays = Number(process.env.MAJESTIC_CACHE_DAYS) ||
|
|
268
|
+
const majesticCacheDays = Number(process.env.MAJESTIC_CACHE_DAYS) || 30;
|
|
266
269
|
const majestic = majesticKey ? new MajesticClient(majesticKey, path.join(dataDir(), 'majestic-cache.db'), majesticCacheDays) : null;
|
|
270
|
+
// Backlinks after Majestic, so pull_backlinks can enrich per-URL pages with Trust Flow.
|
|
271
|
+
const backlinks = dfs ? new Backlinks(dfs, dataDir(), majestic) : null;
|
|
267
272
|
// Firecrawl (competitor-page scraping for content recon) — optional; degrades gracefully.
|
|
268
273
|
const firecrawlKey = process.env.FIRECRAWL_API_KEY;
|
|
269
274
|
const firecrawl = firecrawlKey ? new FirecrawlClient(firecrawlKey, path.join(dataDir(), 'firecrawl-cache.db')) : null;
|
|
@@ -282,6 +287,10 @@ export function createServer() {
|
|
|
282
287
|
throw new Error('DATAFORSEO_USERNAME / DATAFORSEO_PASSWORD not set — required for DataForSEO.');
|
|
283
288
|
return v;
|
|
284
289
|
};
|
|
290
|
+
// Which optional integrations have a key set — drives the dashboard's key-gated tabs
|
|
291
|
+
// (Links, Content research) and their affiliate-linked upsell states. The data layer has
|
|
292
|
+
// no env access, so it's injected here onto every dashboard payload surface.
|
|
293
|
+
const apiKeysStatus = () => ({ dataforseo: !!dfs, majestic: !!majestic, firecrawl: !!firecrawl, supadata: !!supadata });
|
|
285
294
|
// ── Introspection (no data / creds required) ────────────────────────────
|
|
286
295
|
server.registerTool('seo_audit_help', {
|
|
287
296
|
title: 'Help — what this audit can do',
|
|
@@ -865,21 +874,61 @@ export function createServer() {
|
|
|
865
874
|
};
|
|
866
875
|
});
|
|
867
876
|
server.registerTool('news_discovery', {
|
|
868
|
-
title: 'Google News
|
|
869
|
-
description: '[
|
|
877
|
+
title: 'News discovery (Google News RSS + DataForSEO)',
|
|
878
|
+
description: '[Google News is FREE (no key); DataForSEO adds a paid, richer Google News SERP | Use for: what has been PUBLISHED on a topic — freshness, "what changed since {date}", competitor coverage] Recent news for a keyword from Google News (the free RSS search feed — Google\'s old News API is retired) and/or the DataForSEO Google News SERP. Returns title, source, publish time, URL. Default source "both" merges + dedupes; pass source:"google" for a free keyless lookup, "dataforseo" for the paid SERP only. Default location: United States.',
|
|
870
879
|
inputSchema: {
|
|
871
880
|
keyword: z.string(),
|
|
872
881
|
location: z.union([z.string(), z.number()]).optional(),
|
|
873
882
|
languageCode: z.string().optional(),
|
|
874
883
|
depth: z.number().int().min(1).max(200).optional().describe('How many news results (default 20)'),
|
|
884
|
+
source: z.enum(['google', 'dataforseo', 'both']).optional().describe('google = free Google News RSS; dataforseo = paid Google News SERP; both (default) merges + dedupes'),
|
|
875
885
|
},
|
|
876
|
-
}, async ({ keyword, location, languageCode, depth }) => {
|
|
877
|
-
const
|
|
878
|
-
const
|
|
879
|
-
const
|
|
886
|
+
}, async ({ keyword, location, languageCode, depth, source }) => {
|
|
887
|
+
const src = source ?? 'both';
|
|
888
|
+
const n = depth ?? 20;
|
|
889
|
+
const seen = new Set();
|
|
890
|
+
const articles = [];
|
|
891
|
+
const add = (a) => { const k = String(a.url || a.title || '').toLowerCase(); if (k && !seen.has(k)) {
|
|
892
|
+
seen.add(k);
|
|
893
|
+
articles.push(a);
|
|
894
|
+
} };
|
|
895
|
+
let cost = 0, cached = true, googleCount = 0, dfsCount = 0, googleError = null;
|
|
896
|
+
// Free Google News RSS.
|
|
897
|
+
if (src === 'google' || src === 'both') {
|
|
898
|
+
try {
|
|
899
|
+
const g = await fetchGoogleNews(keyword, { limit: n });
|
|
900
|
+
googleCount = g.articles.length;
|
|
901
|
+
for (const a of g.articles)
|
|
902
|
+
add({ title: a.title, source: a.source, timestamp: a.timestamp, url: a.url, via: 'google-news' });
|
|
903
|
+
}
|
|
904
|
+
catch (e) {
|
|
905
|
+
googleError = e instanceof Error ? e.message : 'failed';
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
// Paid DataForSEO Google News SERP (only when explicitly asked, or 'both' AND a key is set).
|
|
909
|
+
if (src === 'dataforseo' || (src === 'both' && !!dfs)) {
|
|
910
|
+
const r = await requireDfs(dfs).serpNews(keyword, location, languageCode, n);
|
|
911
|
+
cost += r.cost;
|
|
912
|
+
cached = cached && r.cached;
|
|
913
|
+
dfsCount = r.articles.length;
|
|
914
|
+
for (const a of r.articles)
|
|
915
|
+
add({ ...a, via: 'dataforseo' });
|
|
916
|
+
}
|
|
917
|
+
// Top sources: which publishers are covering this topic, ranked by article count — the
|
|
918
|
+
// "who is talking about X" read. Powers "trending news + sources for X" in one call.
|
|
919
|
+
const sourceCount = new Map();
|
|
920
|
+
for (const a of articles) {
|
|
921
|
+
const s = String(a.source ?? '').trim();
|
|
922
|
+
if (s)
|
|
923
|
+
sourceCount.set(s, (sourceCount.get(s) ?? 0) + 1);
|
|
924
|
+
}
|
|
925
|
+
const topSources = [...sourceCount.entries()].sort((x, y) => y[1] - x[1]).slice(0, 12).map(([source, count]) => ({ source, count }));
|
|
926
|
+
const top = articles.slice(0, 15).map(a => `• ${a.title ?? '(untitled)'}${a.source ? ` — ${a.source}` : ''}${a.timestamp ? `, ${a.timestamp}` : ''}\n ${a.url ?? ''}`).join('\n');
|
|
927
|
+
const srcNote = [googleCount ? `${googleCount} Google News` : '', dfsCount ? `${dfsCount} DataForSEO` : ''].filter(Boolean).join(' + ') || 'no results';
|
|
928
|
+
const sourcesLine = topSources.length ? `\n\nTop sources: ${topSources.map(s => `${s.source} (${s.count})`).join(', ')}` : '';
|
|
880
929
|
return {
|
|
881
|
-
content: [{ type: 'text', text: `${
|
|
882
|
-
structuredContent: { keyword, articles
|
|
930
|
+
content: [{ type: 'text', text: `${articles.length} news results for "${keyword}" (${srcNote}${cost ? `, $${cost.toFixed(4)}` : ', free'})${googleError ? ` [Google News error: ${googleError}]` : ''}${sourcesLine}${articles.length ? `\n\n${top}` : ''}\n\nCompose with topic_trend for direction (rising/falling); schedule this call daily/hourly for a topic radar.` }],
|
|
931
|
+
structuredContent: { keyword, articles, topSources, cached, cost, sources: { googleNews: googleCount, dataforseo: dfsCount }, googleError },
|
|
883
932
|
};
|
|
884
933
|
});
|
|
885
934
|
server.registerTool('topic_trend', {
|
|
@@ -1937,7 +1986,7 @@ export function createServer() {
|
|
|
1937
1986
|
inputSchema: { siteUrl: z.string() },
|
|
1938
1987
|
_meta: { ui: { visibility: ['app'] } },
|
|
1939
1988
|
}, async ({ siteUrl }) => {
|
|
1940
|
-
const data = getDashboardData(dataDir(), siteUrl);
|
|
1989
|
+
const data = { ...getDashboardData(dataDir(), siteUrl), apiKeys: apiKeysStatus() };
|
|
1941
1990
|
return { content: [{ type: 'text', text: 'ok' }], structuredContent: data };
|
|
1942
1991
|
});
|
|
1943
1992
|
// export_report — the dependable deliverable: a self-contained interactive dashboard
|
|
@@ -1948,7 +1997,7 @@ export function createServer() {
|
|
|
1948
1997
|
description: 'Write a self-contained, interactive dashboard HTML for a property (all data + charts inlined) to the reports folder, and return the file path. Open it in any browser or send it to a client — no server, no MCP-App host support needed. Run refresh_property (+ run_audit for findings) first.',
|
|
1949
1998
|
inputSchema: { siteUrl: z.string(), theme: z.enum(['light', 'dark']).optional() },
|
|
1950
1999
|
}, async ({ siteUrl, theme }) => {
|
|
1951
|
-
const data = getDashboardData(dataDir(), siteUrl);
|
|
2000
|
+
const data = { ...getDashboardData(dataDir(), siteUrl), apiKeys: apiKeysStatus() };
|
|
1952
2001
|
if (data.empty) {
|
|
1953
2002
|
return { content: [{ type: 'text', text: `No synced data for ${siteUrl} — run refresh_property first.` }], structuredContent: { error: 'empty', siteUrl } };
|
|
1954
2003
|
}
|
|
@@ -1992,12 +2041,46 @@ export function createServer() {
|
|
|
1992
2041
|
// otherwise CREATE an empty DB file for any bogus siteUrl posted at the API.
|
|
1993
2042
|
if (!listLocalProperties(dataDir()).some(p => p.siteUrl === want))
|
|
1994
2043
|
throw new Error(`unknown property ${want}`);
|
|
1995
|
-
return getDashboardData(dataDir(), want);
|
|
2044
|
+
return { ...getDashboardData(dataDir(), want), apiKeys: apiKeysStatus() };
|
|
1996
2045
|
},
|
|
1997
2046
|
related_terms: async (a) => {
|
|
1998
2047
|
const r = await requireDfs(dfs).relatedTerms(String(a.keyword ?? ''), a.location, a.languageCode);
|
|
1999
2048
|
return r;
|
|
2000
2049
|
},
|
|
2050
|
+
// Content research (on-demand) — News (free Google News + paid DFS) / Videos / Trends.
|
|
2051
|
+
news_discovery: async (a) => {
|
|
2052
|
+
const kw = String(a.keyword ?? '');
|
|
2053
|
+
const seen = new Set();
|
|
2054
|
+
const articles = [];
|
|
2055
|
+
const add = (x) => { const k = String(x.url || x.title || '').toLowerCase(); if (k && !seen.has(k)) {
|
|
2056
|
+
seen.add(k);
|
|
2057
|
+
articles.push(x);
|
|
2058
|
+
} };
|
|
2059
|
+
try {
|
|
2060
|
+
const g = await fetchGoogleNews(kw, { limit: 20 });
|
|
2061
|
+
for (const x of g.articles)
|
|
2062
|
+
add({ ...x, via: 'google-news' });
|
|
2063
|
+
}
|
|
2064
|
+
catch { /* free source optional */ }
|
|
2065
|
+
if (dfs) {
|
|
2066
|
+
try {
|
|
2067
|
+
const r = await dfs.serpNews(kw, a.location, a.languageCode);
|
|
2068
|
+
for (const x of r.articles)
|
|
2069
|
+
add({ ...x, via: 'dataforseo' });
|
|
2070
|
+
}
|
|
2071
|
+
catch { /* paid optional */ }
|
|
2072
|
+
}
|
|
2073
|
+
return { articles };
|
|
2074
|
+
},
|
|
2075
|
+
youtube_discovery: async (a) => {
|
|
2076
|
+
const r = await requireDfs(dfs).serpYoutube(String(a.keyword ?? ''), a.location, a.languageCode, a.blockDepth);
|
|
2077
|
+
return r;
|
|
2078
|
+
},
|
|
2079
|
+
topic_trend: async (a) => {
|
|
2080
|
+
const kws = Array.isArray(a.keywords) ? a.keywords : [String(a.keyword ?? '')].filter(Boolean);
|
|
2081
|
+
const r = await requireDfs(dfs).googleTrends(kws, a.location, a.languageCode, {});
|
|
2082
|
+
return r;
|
|
2083
|
+
},
|
|
2001
2084
|
keyword_volume: async (a) => {
|
|
2002
2085
|
const r = await requireDfs(dfs).searchVolume(a.keywords ?? [], a.location, a.languageCode);
|
|
2003
2086
|
const items = (r.tasks[0]?.result ?? []).map((k) => ({
|