drupal-mcp-connector 2.26.0 → 3.0.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/.agents/commands/drupal-audit-site-health.md +1 -1
- package/.agents/commands/drupal-report-accessibility-audit.md +4 -2
- package/.agents/commands/drupal-report-alias-coverage.md +4 -2
- package/.agents/commands/drupal-report-broken-embeds.md +4 -2
- package/.agents/commands/drupal-report-broken-links.md +4 -2
- package/.agents/commands/drupal-report-content-by-author.md +4 -2
- package/.agents/commands/drupal-report-duplicate-content.md +4 -2
- package/.agents/commands/drupal-report-missing-field.md +2 -2
- package/.agents/commands/drupal-report-orphan-pages.md +4 -2
- package/.agents/commands/drupal-report-orphaned-references.md +4 -2
- package/.agents/commands/drupal-report-pii-exposure.md +4 -2
- package/.agents/commands/drupal-report-readability.md +4 -2
- package/.agents/commands/drupal-report-recently-published.md +1 -1
- package/.agents/commands/drupal-report-revision-hotspots.md +4 -2
- package/.agents/commands/drupal-report-scheduled-content.md +4 -4
- package/.agents/commands/drupal-report-seo-audit.md +4 -2
- package/.agents/commands/drupal-report-seo-meta-coverage.md +4 -2
- package/.agents/commands/drupal-report-stale-content.md +4 -2
- package/.agents/commands/drupal-report-taxonomy-usage.md +2 -2
- package/.agents/commands/drupal-report-translation-coverage.md +4 -2
- package/.agents/commands/drupal-report-unpublished.md +1 -1
- package/.agents/commands/drupal-report-workflow-bottlenecks.md +4 -2
- package/.agents/commands/drupal-schedule-publish.md +4 -4
- package/.agents/commands/drupal-search-content.md +1 -1
- package/.agents/commands/drupal-search.md +1 -1
- package/CHANGELOG.md +46 -0
- package/package.json +1 -1
- package/src/lib/backends/jsonapi.js +19 -7
- package/src/lib/reports-support.js +68 -0
- package/src/tools/audit-composite.js +21 -11
- package/src/tools/nodes.js +12 -6
- package/src/tools/reports-content.js +106 -38
- package/src/tools/reports-extra.js +56 -32
- package/src/tools/reports-links.js +13 -13
- package/src/tools/reports.js +65 -42
- package/src/tools/scheduler.js +90 -62
- package/src/tools/search.js +16 -12
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Shared helpers for the reporting tools — backend-neutral.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { assertReadAllowed } from "./security.js";
|
|
6
|
+
|
|
5
7
|
/** Canonical base property names (read directly off the entity, not from `fields`). */
|
|
6
8
|
const BASE_KEYS = new Set(["id", "title", "status", "langcode", "created", "changed", "url"]);
|
|
7
9
|
|
|
@@ -148,3 +150,69 @@ export function requestedFieldNames(fields) {
|
|
|
148
150
|
const list = typeof fields === "string" ? [fields] : (Array.isArray(fields) ? fields : []);
|
|
149
151
|
return [...new Set(list.filter((f) => typeof f === "string" && f.trim()).map((f) => f.trim()))];
|
|
150
152
|
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Return the content type a bundle-scoped tool reads, or fail clearly.
|
|
156
|
+
* Node tools used to default to `article`, which 404s on a site without that
|
|
157
|
+
* bundle (#403). There is no bundle every site has, so the caller names one.
|
|
158
|
+
* @param {?string} type Content type machine name from the caller.
|
|
159
|
+
* @param {string} tool Tool name, for the message.
|
|
160
|
+
* @param {string} [argName] Argument name, for the message.
|
|
161
|
+
* @returns {string} The content type.
|
|
162
|
+
* @throws {Error} When no type was given.
|
|
163
|
+
*/
|
|
164
|
+
export function requireContentType(type, tool, argName = "type") {
|
|
165
|
+
if (typeof type === "string" && type.trim()) return type;
|
|
166
|
+
throw new Error(
|
|
167
|
+
`${tool} reads one content type: pass ${argName} (a node bundle machine name). ` +
|
|
168
|
+
"Call drupal_list_content_types to see the bundles on this site.",
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Node bundles a tool may read when the caller named none. Bundles that
|
|
174
|
+
* connector policy denies are left out; they are not the caller's to see.
|
|
175
|
+
* @param {{listContentTypes: Function}} backend Resolved backend.
|
|
176
|
+
* @param {object} sec Resolved security config.
|
|
177
|
+
* @returns {Promise<string[]>} Bundle machine names.
|
|
178
|
+
*/
|
|
179
|
+
export async function readableNodeBundles(backend, sec) {
|
|
180
|
+
const types = await backend.listContentTypes();
|
|
181
|
+
return types.map((ct) => ct?.id).filter((id) => {
|
|
182
|
+
if (!id) return false;
|
|
183
|
+
try {
|
|
184
|
+
assertReadAllowed(sec, "node", id);
|
|
185
|
+
return true;
|
|
186
|
+
} catch {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* List nodes from several bundles with one descriptor, merged newest first by
|
|
194
|
+
* `sortField`. A bundle that fails to load fails the whole call with the
|
|
195
|
+
* bundle named, so a partial list is never returned as complete (#403).
|
|
196
|
+
* @param {{listEntities: Function}} backend Resolved backend.
|
|
197
|
+
* @param {string[]} bundles Node bundles to read.
|
|
198
|
+
* @param {object} descriptor listEntities descriptor without `bundle`.
|
|
199
|
+
* @param {number} limit Max nodes returned.
|
|
200
|
+
* @param {string} [sortField] Canonical date property to merge by.
|
|
201
|
+
* @returns {Promise<object[]>} Canonical entities.
|
|
202
|
+
* @throws {Error} When any bundle fails to load.
|
|
203
|
+
*/
|
|
204
|
+
export async function listNodesAcrossBundles(backend, bundles, descriptor, limit, sortField = "changed") {
|
|
205
|
+
const found = [];
|
|
206
|
+
for (const bundle of bundles) {
|
|
207
|
+
let res;
|
|
208
|
+
try {
|
|
209
|
+
res = await backend.listEntities({ ...descriptor, entityType: "node", bundle, page: { limit } });
|
|
210
|
+
} catch (err) {
|
|
211
|
+
throw new Error(`Could not read content type '${bundle}': ${err?.message || err}. Pass type to read one content type.`, { cause: err });
|
|
212
|
+
}
|
|
213
|
+
found.push(...(res.entities ?? []));
|
|
214
|
+
}
|
|
215
|
+
const key = (e) => String(new Map(Object.entries(e ?? {})).get(sortField) ?? "");
|
|
216
|
+
found.sort((x, y) => key(y).localeCompare(key(x)));
|
|
217
|
+
return found.slice(0, limit);
|
|
218
|
+
}
|
|
@@ -26,16 +26,16 @@ import * as reportsContent from "./reports-content.js";
|
|
|
26
26
|
const SECTIONS = [
|
|
27
27
|
// Content
|
|
28
28
|
{ key: "content_summary", group: "content", run: (o) => reports.handlers.drupal_report_content_summary({ site: o.site }), score: () => ({ issues: 0, high: 0 }) },
|
|
29
|
-
{ key: "stale_content", group: "content", run: (o) => reports.handlers.drupal_report_stale_content({ site: o.site, type: o.type, days: 180, limit: o.sampleSize }), score: (r) => ({ issues: (r.findings || r.nodes || []).length, high: 0 }) },
|
|
30
|
-
{ key: "seo_audit", group: "content", run: (o) => reports.handlers.drupal_report_seo_audit({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: sumIssueCounts(r.issues), high: 0 }) },
|
|
31
|
-
{ key: "accessibility", group: "content", run: (o) => reports.handlers.drupal_report_accessibility_audit({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: sumIssueCounts(r.issues), high: 0 }) },
|
|
32
|
-
{ key: "duplicate_content", group: "content", run: (o) => reportsContent.handlers.drupal_report_duplicate_content({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: r.duplicateGroups || 0, high: 0 }) },
|
|
33
|
-
{ key: "readability", group: "content", run: (o) => reportsContent.handlers.drupal_report_readability({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: (r.hardToRead || []).length, high: 0 }) },
|
|
34
|
-
{ key: "pii_exposure", group: "content", run: (o) => reportsContent.handlers.drupal_report_pii_exposure({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: r.flaggedNodes || 0, high: r.flaggedNodes || 0 }) },
|
|
29
|
+
{ key: "stale_content", needsType: true, group: "content", run: (o) => reports.handlers.drupal_report_stale_content({ site: o.site, type: o.type, days: 180, limit: o.sampleSize }), score: (r) => ({ issues: (r.findings || r.nodes || []).length, high: 0 }) },
|
|
30
|
+
{ key: "seo_audit", needsType: true, group: "content", run: (o) => reports.handlers.drupal_report_seo_audit({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: sumIssueCounts(r.issues), high: 0 }) },
|
|
31
|
+
{ key: "accessibility", needsType: true, group: "content", run: (o) => reports.handlers.drupal_report_accessibility_audit({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: sumIssueCounts(r.issues), high: 0 }) },
|
|
32
|
+
{ key: "duplicate_content", needsType: true, group: "content", run: (o) => reportsContent.handlers.drupal_report_duplicate_content({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: r.duplicateGroups || 0, high: 0 }) },
|
|
33
|
+
{ key: "readability", needsType: true, group: "content", run: (o) => reportsContent.handlers.drupal_report_readability({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: (r.hardToRead || []).length, high: 0 }) },
|
|
34
|
+
{ key: "pii_exposure", needsType: true, group: "content", run: (o) => reportsContent.handlers.drupal_report_pii_exposure({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: r.flaggedNodes || 0, high: r.flaggedNodes || 0 }) },
|
|
35
35
|
// Links
|
|
36
|
-
{ key: "broken_links", group: "links", run: (o) => reportsLinks.handlers.drupal_report_broken_links({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: r.summary?.malformed || 0, high: 0 }) },
|
|
36
|
+
{ key: "broken_links", needsType: true, group: "links", run: (o) => reportsLinks.handlers.drupal_report_broken_links({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: r.summary?.malformed || 0, high: 0 }) },
|
|
37
37
|
{ key: "redirect_health", group: "links", run: (o) => reportsLinks.handlers.drupal_report_redirect_health({ site: o.site }), score: (r) => ({ issues: (r.summary?.duplicateSources || 0) + (r.summary?.chains || 0) + (r.summary?.loops || 0), high: r.summary?.loops || 0 }) },
|
|
38
|
-
{ key: "alias_coverage", group: "links", run: (o) => reportsLinks.handlers.drupal_report_alias_coverage({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: (r.totalMissingAlias || 0) + (r.aliasConflicts?.conflicting?.length || 0), high: 0 }) },
|
|
38
|
+
{ key: "alias_coverage", needsType: true, group: "links", run: (o) => reportsLinks.handlers.drupal_report_alias_coverage({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: (r.totalMissingAlias || 0) + (r.aliasConflicts?.conflicting?.length || 0), high: 0 }) },
|
|
39
39
|
{ key: "log_404", group: "links", run: (o) => reportsLinks.handlers.drupal_report_404_log({ site: o.site }), score: (r) => ({ issues: (r.findings || []).length, high: 0 }) },
|
|
40
40
|
// Config
|
|
41
41
|
{ key: "config_best_practices", group: "config", run: (o) => reportsConfig.handlers.drupal_audit_config_best_practices({ site: o.site }), score: (r) => ({ issues: (r.counts?.high || 0) + (r.counts?.medium || 0) + (r.counts?.low || 0), high: r.counts?.high || 0 }) },
|
|
@@ -81,10 +81,13 @@ function grade(high, issues) {
|
|
|
81
81
|
/**
|
|
82
82
|
* Run a battery of audits and roll the results into a scored dashboard.
|
|
83
83
|
*
|
|
84
|
+
* Sections that audit one content type (`needsType`) are reported unavailable
|
|
85
|
+
* when `type` is omitted; there is no default bundle (#403).
|
|
86
|
+
*
|
|
84
87
|
* @param {object} args - { site?, type?, sampleSize?, sections? }.
|
|
85
88
|
* @returns {Promise<object>} The dashboard: per-section status + a roll-up grade.
|
|
86
89
|
*/
|
|
87
|
-
async function siteHealth({ site: siteName, type
|
|
90
|
+
async function siteHealth({ site: siteName, type, sampleSize = 50, sections }) {
|
|
88
91
|
const site = getSiteConfig(siteName);
|
|
89
92
|
const selected = sections && sections.length
|
|
90
93
|
? SECTIONS.filter((s) => sections.includes(s.key))
|
|
@@ -95,6 +98,13 @@ async function siteHealth({ site: siteName, type = "article", sampleSize = 50, s
|
|
|
95
98
|
let totalHigh = 0;
|
|
96
99
|
let totalIssues = 0;
|
|
97
100
|
for (const section of selected) {
|
|
101
|
+
if (section.needsType && !type) {
|
|
102
|
+
results.push({
|
|
103
|
+
key: section.key, group: section.group, status: "unavailable",
|
|
104
|
+
reason: "This section audits one content type: pass type. Call drupal_list_content_types to see the bundles on this site.",
|
|
105
|
+
});
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
98
108
|
try {
|
|
99
109
|
const result = await section.run(opts);
|
|
100
110
|
if (isGated(result)) {
|
|
@@ -113,7 +123,7 @@ async function siteHealth({ site: siteName, type = "article", sampleSize = 50, s
|
|
|
113
123
|
const byStatus = (s) => results.filter((r) => r.status === s).length;
|
|
114
124
|
return {
|
|
115
125
|
site: site._name,
|
|
116
|
-
type,
|
|
126
|
+
type: type || null,
|
|
117
127
|
grade: grade(totalHigh, totalIssues),
|
|
118
128
|
summary: {
|
|
119
129
|
sectionsRun: results.length,
|
|
@@ -139,7 +149,7 @@ export const definitions = [
|
|
|
139
149
|
type: "object",
|
|
140
150
|
properties: {
|
|
141
151
|
site: { type: "string" },
|
|
142
|
-
type: { type: "string", description: "
|
|
152
|
+
type: { type: "string", description: "Content type for the content/link sections. Omit it and those sections are reported unavailable; site-wide sections still run." },
|
|
143
153
|
sampleSize: { type: "number", default: 50, description: "Per-section scan cap (kept small for a fast roll-up)" },
|
|
144
154
|
sections: { type: "array", items: { type: "string", enum: SECTION_KEYS }, description: `Subset of sections to run (default: all). Available: ${SECTION_KEYS.join(", ")}` },
|
|
145
155
|
},
|
package/src/tools/nodes.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { getSiteConfig } from "../lib/config.js";
|
|
11
11
|
import { resolveBackend } from "../lib/backends/index.js";
|
|
12
|
+
import { readableNodeBundles, listNodesAcrossBundles } from "../lib/reports-support.js";
|
|
12
13
|
import {
|
|
13
14
|
resolveSecurityConfig, redactCanonicalEntity,
|
|
14
15
|
assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
|
|
@@ -357,7 +358,10 @@ async function listNodes({ site: siteName, type, status, filters = [], limit = 2
|
|
|
357
358
|
}
|
|
358
359
|
|
|
359
360
|
/**
|
|
360
|
-
* Search nodes by title substring
|
|
361
|
+
* Search nodes by title substring. With `type`, searches that bundle. Without
|
|
362
|
+
* it, searches every node bundle the connector may read and merges by
|
|
363
|
+
* `changed` (newest first). A bundle that fails to load fails the search with
|
|
364
|
+
* the bundle named, so a partial result is never returned as complete (#403).
|
|
361
365
|
*
|
|
362
366
|
* @param {object} args - { site?, query, type?, status?, limit? }.
|
|
363
367
|
* @returns {Promise<object[]>} Redacted matching nodes.
|
|
@@ -365,13 +369,15 @@ async function listNodes({ site: siteName, type, status, filters = [], limit = 2
|
|
|
365
369
|
async function searchContent({ site: siteName, query, type, status, limit = 10 }) {
|
|
366
370
|
const site = getSiteConfig(siteName);
|
|
367
371
|
const sec = resolveSecurityConfig(site);
|
|
368
|
-
|
|
369
|
-
assertReadAllowed(sec, "node", bundle);
|
|
372
|
+
assertReadAllowed(sec, "node", type);
|
|
370
373
|
const backend = await resolveBackend(site);
|
|
371
374
|
const filters = [{ field: "title", op: "contains", value: query }];
|
|
372
375
|
if (status !== undefined) filters.push({ field: "status", op: "eq", value: status });
|
|
373
|
-
const
|
|
374
|
-
|
|
376
|
+
const descriptor = { filters, sort: [{ field: "changed", dir: "desc" }] };
|
|
377
|
+
const found = type
|
|
378
|
+
? (await backend.listEntities({ ...descriptor, entityType: "node", bundle: type, page: { limit } })).entities
|
|
379
|
+
: await listNodesAcrossBundles(backend, await readableNodeBundles(backend, sec), descriptor, limit);
|
|
380
|
+
return found.map((e) => redactCanonicalEntity(e, sec, "node"));
|
|
375
381
|
}
|
|
376
382
|
|
|
377
383
|
/**
|
|
@@ -621,7 +627,7 @@ export const definitions = [
|
|
|
621
627
|
properties: {
|
|
622
628
|
site: { type: "string" },
|
|
623
629
|
query: { type: "string", description: "Search term to match against node titles" },
|
|
624
|
-
type: { type: "string", description: "
|
|
630
|
+
type: { type: "string", description: "Content type machine name. Omit to search every node bundle (merged, newest first); if any bundle cannot be read the search fails and names it." },
|
|
625
631
|
status: { type: "boolean", description: "Filter by publish status" },
|
|
626
632
|
limit: { type: "number", default: 10 },
|
|
627
633
|
},
|
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
* scheduled-content state, body readability, orphan pages (no inbound internal
|
|
7
7
|
* links), accidental PII exposure, and structured-meta (SEO) coverage.
|
|
8
8
|
*
|
|
9
|
+
* Each report except scheduled content scans one node bundle named by `type`
|
|
10
|
+
* (there is no default bundle; #403). Scheduled content scans every bundle when
|
|
11
|
+
* `type` is omitted.
|
|
12
|
+
*
|
|
9
13
|
* Each handler asserts read access in-handler, samples via collectEntities, and
|
|
10
14
|
* flags `approximate: true` when the scan is sampling-bounded. Audits that need a
|
|
11
15
|
* field the site doesn't expose (moderation state, scheduler dates, metatag
|
|
@@ -17,6 +21,7 @@ import { resolveBackend } from "../lib/backends/index.js";
|
|
|
17
21
|
import { resolveSecurityConfig, assertReadAllowed } from "../lib/security.js";
|
|
18
22
|
import {
|
|
19
23
|
collectEntities, fieldValue, fieldPresence, isEmptyFieldValue, requestedFieldNames, FIELDS_NOT_VISIBLE_NOTE, daysSince,
|
|
24
|
+
requireContentType,
|
|
20
25
|
} from "../lib/reports-support.js";
|
|
21
26
|
import { bodyHtml, extractAnchors, classifyLink, normalizePath } from "../lib/audit-support.js";
|
|
22
27
|
import {
|
|
@@ -61,11 +66,11 @@ function scalar(entity, candidates) {
|
|
|
61
66
|
* @returns {Promise<object>} Duplicate-title groups.
|
|
62
67
|
*/
|
|
63
68
|
async function duplicateContent({ site: siteName, type, sampleSize = 200 }) {
|
|
69
|
+
const contentType = requireContentType(type, "drupal_report_duplicate_content");
|
|
64
70
|
const site = getSiteConfig(siteName);
|
|
65
71
|
const sec = resolveSecurityConfig(site);
|
|
66
|
-
assertReadAllowed(sec, "node",
|
|
72
|
+
assertReadAllowed(sec, "node", contentType);
|
|
67
73
|
const backend = await resolveBackend(site);
|
|
68
|
-
const contentType = type || "article";
|
|
69
74
|
const nodes = await collectEntities(
|
|
70
75
|
backend,
|
|
71
76
|
{ entityType: "node", bundle: contentType, sort: [{ field: "changed", dir: "desc" }] },
|
|
@@ -106,11 +111,11 @@ async function duplicateContent({ site: siteName, type, sampleSize = 200 }) {
|
|
|
106
111
|
* @returns {Promise<object>} Stuck-content findings.
|
|
107
112
|
*/
|
|
108
113
|
async function workflowBottlenecks({ site: siteName, type, days = 30, states, sampleSize = 200, langcode }) {
|
|
114
|
+
const contentType = requireContentType(type, "drupal_report_workflow_bottlenecks");
|
|
109
115
|
const site = getSiteConfig(siteName);
|
|
110
116
|
const sec = resolveSecurityConfig(site);
|
|
111
|
-
assertReadAllowed(sec, "node",
|
|
117
|
+
assertReadAllowed(sec, "node", contentType);
|
|
112
118
|
const backend = await resolveBackend(site);
|
|
113
|
-
const contentType = type || "article";
|
|
114
119
|
const targetStates = (states && states.length ? states : ["draft", "needs_review", "review"]).map((s) => s.toLowerCase());
|
|
115
120
|
const targetLang = langcode ? assertDraftLangcode(langcode) : null;
|
|
116
121
|
|
|
@@ -200,11 +205,11 @@ async function workflowBottlenecks({ site: siteName, type, days = 30, states, sa
|
|
|
200
205
|
* @returns {Promise<object>} Coverage, or unavailable.
|
|
201
206
|
*/
|
|
202
207
|
async function translationCoverage({ site: siteName, type, sampleSize = 100 }) {
|
|
208
|
+
const contentType = requireContentType(type, "drupal_report_translation_coverage");
|
|
203
209
|
const site = getSiteConfig(siteName);
|
|
204
210
|
const sec = resolveSecurityConfig(site);
|
|
205
|
-
assertReadAllowed(sec, "node",
|
|
211
|
+
assertReadAllowed(sec, "node", contentType);
|
|
206
212
|
const backend = await resolveBackend(site);
|
|
207
|
-
const contentType = type || "article";
|
|
208
213
|
const nodes = await collectEntities(
|
|
209
214
|
backend,
|
|
210
215
|
{ entityType: "node", bundle: contentType, sort: [{ field: "changed", dir: "desc" }] },
|
|
@@ -283,19 +288,18 @@ async function translationCoverage({ site: siteName, type, sampleSize = 100 }) {
|
|
|
283
288
|
// ---------------------------------------------------------------------------
|
|
284
289
|
|
|
285
290
|
/**
|
|
286
|
-
*
|
|
287
|
-
* (date in the future) and overdue (date in the past but the action hasn't run).
|
|
288
|
-
* Gated when the scheduler fields aren't exposed.
|
|
291
|
+
* Scan one bundle for Scheduler dates.
|
|
289
292
|
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
293
|
+
* Whether Scheduler is exposed is decided from the attribute key, not its
|
|
294
|
+
* value: a node with `publish_on: null` has the field and nothing scheduled
|
|
295
|
+
* (#403). A bundle with no nodes cannot show either way, so it reports zero
|
|
296
|
+
* pending and overdue with `schedulerFields: "unknown"`.
|
|
297
|
+
* @param {object} backend Resolved backend.
|
|
298
|
+
* @param {string} contentType Node bundle.
|
|
299
|
+
* @param {number} sampleSize Max nodes to scan.
|
|
300
|
+
* @returns {Promise<object>} Per-bundle result.
|
|
292
301
|
*/
|
|
293
|
-
async function
|
|
294
|
-
const site = getSiteConfig(siteName);
|
|
295
|
-
const sec = resolveSecurityConfig(site);
|
|
296
|
-
assertReadAllowed(sec, "node", type);
|
|
297
|
-
const backend = await resolveBackend(site);
|
|
298
|
-
const contentType = type || "article";
|
|
302
|
+
async function scanScheduledBundle(backend, contentType, sampleSize) {
|
|
299
303
|
const nodes = await collectEntities(
|
|
300
304
|
backend,
|
|
301
305
|
{ entityType: "node", bundle: contentType, sort: [{ field: "changed", dir: "desc" }] },
|
|
@@ -308,7 +312,6 @@ async function scheduledContent({ site: siteName, type, sampleSize = 200 }) {
|
|
|
308
312
|
const overdue = [];
|
|
309
313
|
for (const n of nodes) {
|
|
310
314
|
for (const [field, action] of [["publish_on", "publish"], ["unpublish_on", "unpublish"]]) {
|
|
311
|
-
// Presence of the key (even null) means Scheduler is exposed (#403).
|
|
312
315
|
const { present, value } = fieldPresence(n, field);
|
|
313
316
|
if (present) sawField = true;
|
|
314
317
|
const raw = value && typeof value === "object" && "value" in value ? value.value : value;
|
|
@@ -321,11 +324,19 @@ async function scheduledContent({ site: siteName, type, sampleSize = 200 }) {
|
|
|
321
324
|
}
|
|
322
325
|
}
|
|
323
326
|
|
|
324
|
-
if (!sawField) {
|
|
325
|
-
return {
|
|
327
|
+
if (nodes.length && !sawField) {
|
|
328
|
+
return {
|
|
329
|
+
contentType,
|
|
330
|
+
gated: true,
|
|
331
|
+
schedulerFields: "absent",
|
|
332
|
+
reason: "No sampled node carries a publish_on or unpublish_on key: Scheduler is not enabled for this content type, or this account cannot view those fields.",
|
|
333
|
+
scanned: nodes.length,
|
|
334
|
+
approximate: nodes.length >= sampleSize,
|
|
335
|
+
};
|
|
326
336
|
}
|
|
327
337
|
return {
|
|
328
338
|
contentType,
|
|
339
|
+
schedulerFields: sawField ? "present" : "unknown",
|
|
329
340
|
scanned: nodes.length,
|
|
330
341
|
approximate: nodes.length >= sampleSize,
|
|
331
342
|
summary: { pending: pending.length, overdue: overdue.length },
|
|
@@ -333,6 +344,56 @@ async function scheduledContent({ site: siteName, type, sampleSize = 200 }) {
|
|
|
333
344
|
};
|
|
334
345
|
}
|
|
335
346
|
|
|
347
|
+
/**
|
|
348
|
+
* Report content with Scheduler publish/unpublish dates set, split into pending
|
|
349
|
+
* (date in the future) and overdue (date in the past but the action hasn't run).
|
|
350
|
+
*
|
|
351
|
+
* With `type`, scans that bundle. Without it, scans every node bundle and
|
|
352
|
+
* returns a combined summary plus one row per bundle; a bundle that cannot be
|
|
353
|
+
* read is listed with its error rather than failing the whole report.
|
|
354
|
+
*
|
|
355
|
+
* @param {object} args - { site?, type?, sampleSize? }.
|
|
356
|
+
* @returns {Promise<object>} Pending and overdue scheduled items.
|
|
357
|
+
*/
|
|
358
|
+
async function scheduledContent({ site: siteName, type, sampleSize = 200 }) {
|
|
359
|
+
const site = getSiteConfig(siteName);
|
|
360
|
+
const sec = resolveSecurityConfig(site);
|
|
361
|
+
assertReadAllowed(sec, "node", type);
|
|
362
|
+
const backend = await resolveBackend(site);
|
|
363
|
+
if (type) return scanScheduledBundle(backend, type, sampleSize);
|
|
364
|
+
|
|
365
|
+
const bundles = (await backend.listContentTypes()).map((ct) => ct.id).filter(Boolean);
|
|
366
|
+
const byContentType = [];
|
|
367
|
+
const pending = [];
|
|
368
|
+
const overdue = [];
|
|
369
|
+
let approximate = false;
|
|
370
|
+
for (const bundle of bundles) {
|
|
371
|
+
let row;
|
|
372
|
+
try {
|
|
373
|
+
assertReadAllowed(sec, "node", bundle);
|
|
374
|
+
row = await scanScheduledBundle(backend, bundle, sampleSize);
|
|
375
|
+
} catch (err) {
|
|
376
|
+
byContentType.push({ contentType: bundle, error: err?.message || String(err) });
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
approximate = approximate || Boolean(row.approximate);
|
|
380
|
+
for (const rec of row.findings?.pending ?? []) pending.push({ ...rec, contentType: bundle });
|
|
381
|
+
for (const rec of row.findings?.overdue ?? []) overdue.push({ ...rec, contentType: bundle });
|
|
382
|
+
const summaryRow = { ...row };
|
|
383
|
+
delete summaryRow.findings;
|
|
384
|
+
byContentType.push(summaryRow);
|
|
385
|
+
}
|
|
386
|
+
return {
|
|
387
|
+
contentType: null,
|
|
388
|
+
scannedBundles: bundles.length,
|
|
389
|
+
scanned: byContentType.reduce((n, r) => n + (r.scanned || 0), 0),
|
|
390
|
+
approximate,
|
|
391
|
+
summary: { pending: pending.length, overdue: overdue.length },
|
|
392
|
+
byContentType,
|
|
393
|
+
findings: { pending, overdue },
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
336
397
|
/**
|
|
337
398
|
* Coerce a scheduler date value (unix seconds, ms, or ISO string) to epoch ms.
|
|
338
399
|
* @param {*} raw Field value.
|
|
@@ -358,11 +419,11 @@ function toMillis(raw) {
|
|
|
358
419
|
* @returns {Promise<object>} Per-node scores and aggregate.
|
|
359
420
|
*/
|
|
360
421
|
async function readability({ site: siteName, type, sampleSize = 100, hardThreshold = 30 }) {
|
|
422
|
+
const contentType = requireContentType(type, "drupal_report_readability");
|
|
361
423
|
const site = getSiteConfig(siteName);
|
|
362
424
|
const sec = resolveSecurityConfig(site);
|
|
363
|
-
assertReadAllowed(sec, "node",
|
|
425
|
+
assertReadAllowed(sec, "node", contentType);
|
|
364
426
|
const backend = await resolveBackend(site);
|
|
365
|
-
const contentType = type || "article";
|
|
366
427
|
const nodes = await collectEntities(
|
|
367
428
|
backend,
|
|
368
429
|
{ entityType: "node", bundle: contentType, filters: [{ field: "status", op: "eq", value: true }] },
|
|
@@ -440,11 +501,11 @@ function countSyllables(word) {
|
|
|
440
501
|
* @returns {Promise<object>} Orphan pages.
|
|
441
502
|
*/
|
|
442
503
|
async function orphanPages({ site: siteName, type, sampleSize = 200 }) {
|
|
504
|
+
const contentType = requireContentType(type, "drupal_report_orphan_pages");
|
|
443
505
|
const site = getSiteConfig(siteName);
|
|
444
506
|
const sec = resolveSecurityConfig(site);
|
|
445
|
-
assertReadAllowed(sec, "node",
|
|
507
|
+
assertReadAllowed(sec, "node", contentType);
|
|
446
508
|
const backend = await resolveBackend(site);
|
|
447
|
-
const contentType = type || "article";
|
|
448
509
|
const baseUrl = site.baseUrl;
|
|
449
510
|
const nodes = await collectEntities(
|
|
450
511
|
backend,
|
|
@@ -493,11 +554,11 @@ const PII_PATTERNS = new Map([
|
|
|
493
554
|
* @returns {Promise<object>} PII findings, masked.
|
|
494
555
|
*/
|
|
495
556
|
async function piiExposure({ site: siteName, type, sampleSize = 100, kinds }) {
|
|
557
|
+
const contentType = requireContentType(type, "drupal_report_pii_exposure");
|
|
496
558
|
const site = getSiteConfig(siteName);
|
|
497
559
|
const sec = resolveSecurityConfig(site);
|
|
498
|
-
assertReadAllowed(sec, "node",
|
|
560
|
+
assertReadAllowed(sec, "node", contentType);
|
|
499
561
|
const backend = await resolveBackend(site);
|
|
500
|
-
const contentType = type || "article";
|
|
501
562
|
const active = (kinds && kinds.length ? kinds : [...PII_PATTERNS.keys()]).filter((k) => PII_PATTERNS.has(k));
|
|
502
563
|
const nodes = await collectEntities(
|
|
503
564
|
backend,
|
|
@@ -570,11 +631,11 @@ const DEFAULT_META_FIELDS = ["field_meta_tags", "field_metatag", "metatag", "fie
|
|
|
570
631
|
* @returns {Promise<object>} Per-field coverage and nodes missing all meta.
|
|
571
632
|
*/
|
|
572
633
|
async function seoMetaCoverage({ site: siteName, type, fields, sampleSize = 100 }) {
|
|
634
|
+
const contentType = requireContentType(type, "drupal_report_seo_meta_coverage");
|
|
573
635
|
const site = getSiteConfig(siteName);
|
|
574
636
|
const sec = resolveSecurityConfig(site);
|
|
575
|
-
assertReadAllowed(sec, "node",
|
|
637
|
+
assertReadAllowed(sec, "node", contentType);
|
|
576
638
|
const backend = await resolveBackend(site);
|
|
577
|
-
const contentType = type || "article";
|
|
578
639
|
const named = requestedFieldNames(fields);
|
|
579
640
|
const requested = named.length > 0;
|
|
580
641
|
const metaFields = requested ? named : DEFAULT_META_FIELDS;
|
|
@@ -638,9 +699,10 @@ export const definitions = [
|
|
|
638
699
|
description: "Find duplicate / near-duplicate titles within a content type (normalized title grouping). Surfaces accidental re-publishing and content cannibalization.",
|
|
639
700
|
inputSchema: {
|
|
640
701
|
type: "object",
|
|
702
|
+
required: ["type"],
|
|
641
703
|
properties: {
|
|
642
704
|
site: { type: "string" },
|
|
643
|
-
type: { type: "string", description: "Content type (
|
|
705
|
+
type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
644
706
|
sampleSize: { type: "number", default: 200, description: "Max nodes to scan" },
|
|
645
707
|
},
|
|
646
708
|
},
|
|
@@ -650,9 +712,10 @@ export const definitions = [
|
|
|
650
712
|
description: "Find content stuck in a non-published moderation state (draft/needs_review) beyond N days — editorial bottlenecks. Reads moderation_state; gated when content_moderation isn't exposed.",
|
|
651
713
|
inputSchema: {
|
|
652
714
|
type: "object",
|
|
715
|
+
required: ["type"],
|
|
653
716
|
properties: {
|
|
654
717
|
site: { type: "string" },
|
|
655
|
-
type: { type: "string", description: "Content type (
|
|
718
|
+
type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
656
719
|
days: { type: "number", default: 30, description: "Days-in-state threshold" },
|
|
657
720
|
states: { type: "array", items: { type: "string" }, description: "Moderation states to treat as bottlenecks" },
|
|
658
721
|
langcode: { type: "string", description: "Limit to this translation (Sentinel inventory). Omit for the default-language field on each node." },
|
|
@@ -665,22 +728,23 @@ export const definitions = [
|
|
|
665
728
|
description: "Per-node translation coverage from Sentinel's inventory (missing non-default language, outdated core flag, language counts). Without Sentinel the report is unavailable — JSON:API only shows the default language, so a histogram would be misleading.",
|
|
666
729
|
inputSchema: {
|
|
667
730
|
type: "object",
|
|
731
|
+
required: ["type"],
|
|
668
732
|
properties: {
|
|
669
733
|
site: { type: "string" },
|
|
670
|
-
type: { type: "string", description: "Content type (
|
|
734
|
+
type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
671
735
|
sampleSize: { type: "number", default: 100, description: "Max nodes to inventory" },
|
|
672
736
|
},
|
|
673
737
|
},
|
|
674
738
|
},
|
|
675
739
|
{
|
|
676
740
|
name: "drupal_report_scheduled_content",
|
|
677
|
-
description: "Report content with Scheduler publish/unpublish dates set, split into pending (future) and overdue (past, action not run).
|
|
741
|
+
description: "Report content with Scheduler publish/unpublish dates set, split into pending (future) and overdue (past, action not run). Omit type to scan every node bundle in one call (one row per bundle in byContentType). A bundle is gated only when sampled nodes carry no publish_on/unpublish_on key at all; fields that exist but are empty report pending 0 / overdue 0. A bundle with no nodes reports zero counts with schedulerFields 'unknown'.",
|
|
678
742
|
inputSchema: {
|
|
679
743
|
type: "object",
|
|
680
744
|
properties: {
|
|
681
745
|
site: { type: "string" },
|
|
682
|
-
type: { type: "string", description: "Content type
|
|
683
|
-
sampleSize: { type: "number", default: 200 },
|
|
746
|
+
type: { type: "string", description: "Content type machine name. Omit to scan every node bundle." },
|
|
747
|
+
sampleSize: { type: "number", default: 200, description: "Max nodes to scan per content type" },
|
|
684
748
|
},
|
|
685
749
|
},
|
|
686
750
|
},
|
|
@@ -689,9 +753,10 @@ export const definitions = [
|
|
|
689
753
|
description: "Score body readability (Flesch Reading Ease) for a content type and flag hard-to-read content and structural issues (no H2 subheadings, multiple H1s).",
|
|
690
754
|
inputSchema: {
|
|
691
755
|
type: "object",
|
|
756
|
+
required: ["type"],
|
|
692
757
|
properties: {
|
|
693
758
|
site: { type: "string" },
|
|
694
|
-
type: { type: "string", description: "Content type (
|
|
759
|
+
type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
695
760
|
sampleSize: { type: "number", default: 100 },
|
|
696
761
|
hardThreshold: { type: "number", default: 30, description: "Flag content scoring below this" },
|
|
697
762
|
},
|
|
@@ -702,9 +767,10 @@ export const definitions = [
|
|
|
702
767
|
description: "Find published pages with no inbound internal links from other sampled pages — content islands. Best-effort over the sampled set.",
|
|
703
768
|
inputSchema: {
|
|
704
769
|
type: "object",
|
|
770
|
+
required: ["type"],
|
|
705
771
|
properties: {
|
|
706
772
|
site: { type: "string" },
|
|
707
|
-
type: { type: "string", description: "Content type (
|
|
773
|
+
type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
708
774
|
sampleSize: { type: "number", default: 200 },
|
|
709
775
|
},
|
|
710
776
|
},
|
|
@@ -714,9 +780,10 @@ export const definitions = [
|
|
|
714
780
|
description: "Scan published body content for accidentally exposed PII (emails, US SSNs, phone numbers). Matched values are masked in the output so the report itself doesn't leak data.",
|
|
715
781
|
inputSchema: {
|
|
716
782
|
type: "object",
|
|
783
|
+
required: ["type"],
|
|
717
784
|
properties: {
|
|
718
785
|
site: { type: "string" },
|
|
719
|
-
type: { type: "string", description: "Content type (
|
|
786
|
+
type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
720
787
|
sampleSize: { type: "number", default: 100 },
|
|
721
788
|
kinds: { type: "array", items: { type: "string", enum: ["email", "ssn", "phone"] }, description: "PII kinds to scan (default: all)" },
|
|
722
789
|
},
|
|
@@ -727,9 +794,10 @@ export const definitions = [
|
|
|
727
794
|
description: "Report structured-meta (SEO) coverage for a content type: how many sampled nodes populate each meta field (metatag, meta description). Complements drupal_report_seo_audit with explicit per-field coverage. Reads scalar and entity-reference fields. A field absent from every sampled node has `present: false` and `coverage: null` (unknown, not 0), and is listed in `notVisible` when you named it. When none of the checked fields is visible, no node is flagged and `nodesMissingAllMeta` is null: JSON:API leaves out a field this account may not view.",
|
|
728
795
|
inputSchema: {
|
|
729
796
|
type: "object",
|
|
797
|
+
required: ["type"],
|
|
730
798
|
properties: {
|
|
731
799
|
site: { type: "string" },
|
|
732
|
-
type: { type: "string", description: "Content type (
|
|
800
|
+
type: { type: "string", description: "Content type machine name (required; see drupal_list_content_types)" },
|
|
733
801
|
fields: { type: "array", items: { type: "string" }, description: "Meta field machine names to check" },
|
|
734
802
|
sampleSize: { type: "number", default: 100 },
|
|
735
803
|
},
|