@aglyn/plugins-ai 1.0.0-beta.271 → 1.0.0-beta.273

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.
Files changed (132) hide show
  1. package/package.json +17 -17
  2. package/src/lib/activity/ai-activity-actions.d.ts +4 -3
  3. package/src/lib/activity/ai-activity-actions.js +3 -0
  4. package/src/lib/activity/ai-activity-actions.js.map +1 -1
  5. package/src/lib/components/ai-site-build-page.component.js +18 -0
  6. package/src/lib/components/ai-site-build-page.component.js.map +1 -1
  7. package/src/lib/components/ai-site-import-job.component.d.ts +41 -0
  8. package/src/lib/components/ai-site-import-job.component.js +707 -0
  9. package/src/lib/components/ai-site-import-job.component.js.map +1 -0
  10. package/src/lib/components/ai-site-import-start.component.d.ts +64 -0
  11. package/src/lib/components/ai-site-import-start.component.js +335 -0
  12. package/src/lib/components/ai-site-import-start.component.js.map +1 -0
  13. package/src/lib/components/ai-site-ownership-proof.component.d.ts +42 -0
  14. package/src/lib/components/ai-site-ownership-proof.component.js +353 -0
  15. package/src/lib/components/ai-site-ownership-proof.component.js.map +1 -0
  16. package/src/lib/components/ai-site-start-card.component.js +329 -252
  17. package/src/lib/components/ai-site-start-card.component.js.map +1 -1
  18. package/src/lib/jobs/ai-empty-site-sweep.d.ts +12 -1
  19. package/src/lib/jobs/ai-empty-site-sweep.js +15 -3
  20. package/src/lib/jobs/ai-empty-site-sweep.js.map +1 -1
  21. package/src/lib/jobs/ai-job-drafts.d.ts +14 -0
  22. package/src/lib/jobs/ai-job-drafts.js +24 -0
  23. package/src/lib/jobs/ai-job-drafts.js.map +1 -1
  24. package/src/lib/jobs/ai-job-import-step.d.ts +136 -0
  25. package/src/lib/jobs/ai-job-import-step.js +809 -0
  26. package/src/lib/jobs/ai-job-import-step.js.map +1 -0
  27. package/src/lib/jobs/ai-job-page-language.d.ts +39 -1
  28. package/src/lib/jobs/ai-job-page-language.js +62 -4
  29. package/src/lib/jobs/ai-job-page-language.js.map +1 -1
  30. package/src/lib/jobs/ai-job-page-step.d.ts +2 -2
  31. package/src/lib/jobs/ai-job-page-step.js +26 -4
  32. package/src/lib/jobs/ai-job-page-step.js.map +1 -1
  33. package/src/lib/jobs/ai-job-seo-step.js +8 -4
  34. package/src/lib/jobs/ai-job-seo-step.js.map +1 -1
  35. package/src/lib/jobs/ai-job-site-datasets.d.ts +39 -1
  36. package/src/lib/jobs/ai-job-site-datasets.js +111 -15
  37. package/src/lib/jobs/ai-job-site-datasets.js.map +1 -1
  38. package/src/lib/jobs/ai-job-site-step.d.ts +12 -3
  39. package/src/lib/jobs/ai-job-site-step.js +85 -11
  40. package/src/lib/jobs/ai-job-site-step.js.map +1 -1
  41. package/src/lib/jobs/ai-job-text-step.d.ts +15 -0
  42. package/src/lib/jobs/ai-job-text-step.js.map +1 -1
  43. package/src/lib/jobs/ai-jobs.d.ts +8 -0
  44. package/src/lib/jobs/ai-jobs.js +19 -3
  45. package/src/lib/jobs/ai-jobs.js.map +1 -1
  46. package/src/lib/jobs/ai-layout-owner-photos.d.ts +16 -1
  47. package/src/lib/jobs/ai-layout-owner-photos.js +49 -2
  48. package/src/lib/jobs/ai-layout-owner-photos.js.map +1 -1
  49. package/src/lib/jobs/ai-layout-stock-photos.d.ts +35 -0
  50. package/src/lib/jobs/ai-layout-stock-photos.js +114 -43
  51. package/src/lib/jobs/ai-layout-stock-photos.js.map +1 -1
  52. package/src/lib/jobs/ai-site-late-photos.d.ts +143 -0
  53. package/src/lib/jobs/ai-site-late-photos.js +486 -0
  54. package/src/lib/jobs/ai-site-late-photos.js.map +1 -0
  55. package/src/lib/jobs/ai-site-publish.d.ts +18 -0
  56. package/src/lib/jobs/ai-site-publish.js +123 -5
  57. package/src/lib/jobs/ai-site-publish.js.map +1 -1
  58. package/src/lib/jobs/ai-theme-brand-inputs.d.ts +61 -3
  59. package/src/lib/jobs/ai-theme-brand-inputs.js +117 -27
  60. package/src/lib/jobs/ai-theme-brand-inputs.js.map +1 -1
  61. package/src/lib/jobs/fixtures/ai-site-late-photos-laurel-fork.d.ts +83 -0
  62. package/src/lib/jobs/fixtures/ai-site-late-photos-laurel-fork.js +1553 -0
  63. package/src/lib/jobs/fixtures/ai-site-late-photos-laurel-fork.js.map +1 -0
  64. package/src/lib/layout-language/ai-layout-pictures.d.ts +50 -13
  65. package/src/lib/layout-language/ai-layout-pictures.js +81 -19
  66. package/src/lib/layout-language/ai-layout-pictures.js.map +1 -1
  67. package/src/lib/model/ai-credit-estimate.d.ts +20 -0
  68. package/src/lib/model/ai-credit-estimate.js +13 -0
  69. package/src/lib/model/ai-credit-estimate.js.map +1 -1
  70. package/src/lib/model/ai-job-activity.d.ts +2 -2
  71. package/src/lib/model/ai-job-activity.js +6 -4
  72. package/src/lib/model/ai-job-activity.js.map +1 -1
  73. package/src/lib/model/ai-job-notice.js +18 -4
  74. package/src/lib/model/ai-job-notice.js.map +1 -1
  75. package/src/lib/model/ai-jobs.types.d.ts +39 -2
  76. package/src/lib/model/ai-jobs.types.js +2 -1
  77. package/src/lib/model/ai-jobs.types.js.map +1 -1
  78. package/src/lib/model/ai-site-import-view.d.ts +130 -0
  79. package/src/lib/model/ai-site-import-view.js +182 -0
  80. package/src/lib/model/ai-site-import-view.js.map +1 -0
  81. package/src/lib/model/ai-site-import.d.ts +302 -0
  82. package/src/lib/model/ai-site-import.js +526 -0
  83. package/src/lib/model/ai-site-import.js.map +1 -0
  84. package/src/lib/model/ai-site-photos.d.ts +119 -0
  85. package/src/lib/model/ai-site-photos.js +128 -0
  86. package/src/lib/model/ai-site-photos.js.map +1 -0
  87. package/src/lib/model/ai-site-start.d.ts +22 -9
  88. package/src/lib/model/ai-site-start.js +64 -23
  89. package/src/lib/model/ai-site-start.js.map +1 -1
  90. package/src/lib/runtime/ai-doctrine-validators.d.ts +3 -1
  91. package/src/lib/runtime/ai-doctrine-validators.js +3 -1
  92. package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
  93. package/src/lib/runtime/seo-fields.d.ts +29 -4
  94. package/src/lib/runtime/seo-fields.js +58 -11
  95. package/src/lib/runtime/seo-fields.js.map +1 -1
  96. package/src/lib/server/ai-jobs-route.js +11 -0
  97. package/src/lib/server/ai-jobs-route.js.map +1 -1
  98. package/src/lib/server/ai-site-import-route.d.ts +89 -0
  99. package/src/lib/server/ai-site-import-route.js +356 -0
  100. package/src/lib/server/ai-site-import-route.js.map +1 -0
  101. package/src/lib/server.js +14 -0
  102. package/src/lib/server.js.map +1 -1
  103. package/src/lib/site-import/fixtures/site-import-crawl-recordings.d.ts +45 -0
  104. package/src/lib/site-import/fixtures/site-import-crawl-recordings.js +313 -0
  105. package/src/lib/site-import/fixtures/site-import-crawl-recordings.js.map +1 -0
  106. package/src/lib/site-import/fixtures/site-import-wave-recordings.d.ts +50 -0
  107. package/src/lib/site-import/fixtures/site-import-wave-recordings.js +217 -0
  108. package/src/lib/site-import/fixtures/site-import-wave-recordings.js.map +1 -0
  109. package/src/lib/site-import/site-import-crawl.d.ts +163 -0
  110. package/src/lib/site-import/site-import-crawl.js +467 -0
  111. package/src/lib/site-import/site-import-crawl.js.map +1 -0
  112. package/src/lib/site-import/site-import-flag.d.ts +23 -0
  113. package/src/lib/site-import/site-import-flag.js +23 -0
  114. package/src/lib/site-import/site-import-flag.js.map +1 -0
  115. package/src/lib/site-import/site-import-html.d.ts +94 -0
  116. package/src/lib/site-import/site-import-html.js +462 -0
  117. package/src/lib/site-import/site-import-html.js.map +1 -0
  118. package/src/lib/site-import/site-import-images.d.ts +54 -0
  119. package/src/lib/site-import/site-import-images.js +204 -0
  120. package/src/lib/site-import/site-import-images.js.map +1 -0
  121. package/src/lib/site-import/site-import-page.d.ts +48 -0
  122. package/src/lib/site-import/site-import-page.js +150 -0
  123. package/src/lib/site-import/site-import-page.js.map +1 -0
  124. package/src/lib/site-import/site-import-review.d.ts +40 -0
  125. package/src/lib/site-import/site-import-review.js +149 -0
  126. package/src/lib/site-import/site-import-review.js.map +1 -0
  127. package/src/lib/site-import/site-import-robots.d.ts +52 -0
  128. package/src/lib/site-import/site-import-robots.js +126 -0
  129. package/src/lib/site-import/site-import-robots.js.map +1 -0
  130. package/src/lib/tools/ai-seo-tool.d.ts +25 -1
  131. package/src/lib/tools/ai-seo-tool.js +61 -0
  132. package/src/lib/tools/ai-seo-tool.js.map +1 -1
@@ -0,0 +1,149 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ /*==========================================
18
+ * EVERY SITE IMPORT IS LOGGED FOR TRUST & SAFETY (AGL-3976).
19
+ *
20
+ * A site import is a one-click copy of a website, which is exactly what a
21
+ * phishing clone is. Proving ownership is the control; this row is how a
22
+ * person checks the control held. Every import job files ONE row in the
23
+ * abuse queue (`abuseReports`), in the shape the outbound screen files a
24
+ * hold (`fileOutboundHold`): the same fields the admin page and the daily
25
+ * review already read, `source: 'site-import'`, the proved domain as text
26
+ * (the queue never renders a reported address as a link), and the proof.
27
+ *
28
+ * The row is written when the site is read and rewritten as each wave
29
+ * starts and finishes, its `reportCount` counting the waves, so the queue
30
+ * shows one import as one row however long it runs. It holds nothing: the
31
+ * import goes on while the row is open. Staff look at the old site and the
32
+ * new one; a workspace bringing in a site that is not its own is locked,
33
+ * and the row closed with what was done.
34
+ *
35
+ * Never throws: a log that cannot be written must not stop a person's
36
+ * import, and the failure is a log line staff alerts read.
37
+ *=========================================*/ import { ABUSE_REPORT_COLLECTION, ABUSE_REPORT_MAX_DETAILS } from "@aglyn/aglyn/app-utils/abuse-report";
38
+ import { FieldValue } from "firebase-admin/firestore";
39
+ import { AI_SITE_IMPORT_LEFT_OUT_REASONS, aiSiteImportPagesText } from "../model/ai-site-import.js";
40
+ /** The abuse queue's `source` for a site import's row. */ export const SITE_IMPORT_REVIEW_SOURCE = 'site-import';
41
+ /** One row per import job. */ export function siteImportReviewId(jobId) {
42
+ return `site-import-${jobId}`;
43
+ }
44
+ /** The reference staff quote: `SI-` and the job's id. */ export function siteImportReviewReference(jobId) {
45
+ return `SI-${jobId.slice(0, 10).toUpperCase()}`;
46
+ }
47
+ const PROOF_WORDS = {
48
+ dns: 'a DNS TXT record',
49
+ meta: 'a meta tag on its home page',
50
+ email: 'a code emailed to an address at the domain'
51
+ };
52
+ /** The row's details: what was proved, read, kept, left out and brought in, rebuilt whole each time. */ export function siteImportReviewDetails(input) {
53
+ var _PROOF_WORDS_state_ownership_method, _state_leftOutCount_loginform;
54
+ const { state, items } = input;
55
+ const proof = state.ownership.method ? (_PROOF_WORDS_state_ownership_method = PROOF_WORDS[state.ownership.method]) != null ? _PROOF_WORDS_state_ownership_method : state.ownership.method : 'an unknown method';
56
+ const provenOn = state.ownership.provenAt ? new Date(state.ownership.provenAt).toISOString().slice(0, 10) : 'an unknown day';
57
+ const leftOut = AI_SITE_IMPORT_LEFT_OUT_REASONS.filter((reason)=>{
58
+ var _state_leftOutCount_reason;
59
+ return ((_state_leftOutCount_reason = state.leftOutCount[reason]) != null ? _state_leftOutCount_reason : 0) > 0;
60
+ }).map((reason)=>`${reason} ${state.leftOutCount[reason]}`).join(', ');
61
+ const built = items.filter((row)=>row.op === 'page' && (row.status === 'succeeded' || row.status === 'degraded')).length;
62
+ const started = state.waves.filter((wave)=>wave.status !== 'waiting').length;
63
+ const photos = state.pages.reduce((total, page)=>{
64
+ var _ref;
65
+ var _page_photos;
66
+ return total + ((_ref = (_page_photos = page.photos) == null ? void 0 : _page_photos.brought) != null ? _ref : 0);
67
+ }, 0);
68
+ const lines = [
69
+ `Site import: ${state.domain} into site ${input.hostId} (AI job ${input.jobId}).`,
70
+ `Ownership proven by ${proof}${state.ownership.detail ? ` (${state.ownership.detail})` : ''} on ${provenOn}.`,
71
+ `Read ${aiSiteImportPagesText(state.found)}; bringing in ${aiSiteImportPagesText(state.pages.length)} in ${state.waves.length} ${state.waves.length === 1 ? 'wave' : 'waves'}` + `${state.pageLimit === null ? '' : ` (plan page count ${state.pageLimit}, ${state.existingPages} already on the site)`}.`,
72
+ ...leftOut ? [
73
+ `Left out: ${leftOut}.`
74
+ ] : [],
75
+ ...((_state_leftOutCount_loginform = state.leftOutCount['login-form']) != null ? _state_leftOutCount_loginform : 0) > 0 ? [
76
+ `Sign-in pages refused: ${state.leftOutCount['login-form']}. A site with sign-in pages is worth a closer look.`
77
+ ] : [],
78
+ `Waves started: ${started} of ${state.waves.length}. Pages brought in so far: ${built}. Photos copied into the media library: ${photos}.`,
79
+ 'Nothing is published by the import: its pages are drafts until a member publishes them.',
80
+ 'Look at the old site and the new one. If this copies a site the workspace does not own, lock the workspace, then close this row with what you did; otherwise dismiss it.'
81
+ ];
82
+ return lines.join('\n').slice(0, ABUSE_REPORT_MAX_DETAILS);
83
+ }
84
+ /** Writes (or rewrites) the import's row in the abuse queue. Never throws. */ export async function fileSiteImportReview(firestore, input) {
85
+ const reviewId = siteImportReviewId(input.jobId);
86
+ try {
87
+ var _input_state_leftOutCount_loginform;
88
+ const ref = firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId);
89
+ const first = !(await ref.get()).exists;
90
+ const built = input.items.filter((row)=>row.op === 'page' && (row.status === 'succeeded' || row.status === 'degraded')).length;
91
+ await ref.set(_extends({
92
+ reference: siteImportReviewReference(input.jobId),
93
+ // "Impersonation or trademark": the harm a copied site does is
94
+ // wearing a business it is not. Normal severity: the proof already
95
+ // held, and this row is the check that it did.
96
+ category: 'impersonation',
97
+ severity: 'normal',
98
+ source: SITE_IMPORT_REVIEW_SOURCE,
99
+ // The proved site as TEXT: the queue never renders it as a link.
100
+ url: `https://${input.state.domain}/`,
101
+ reportedHostname: input.state.domain,
102
+ hostId: input.hostId,
103
+ orgId: input.orgId,
104
+ details: siteImportReviewDetails(input),
105
+ reporterEmail: null,
106
+ reporterName: null,
107
+ dmca: null
108
+ }, input.event === 'wave-started' ? {
109
+ reportCount: FieldValue.increment(1)
110
+ } : first ? {
111
+ reportCount: 0
112
+ } : {}, {
113
+ siteImport: _extends({
114
+ jobId: input.jobId,
115
+ domain: input.state.domain,
116
+ method: input.state.ownership.method,
117
+ provenAt: input.state.ownership.provenAt,
118
+ detail: input.state.ownership.detail,
119
+ found: input.state.found,
120
+ kept: input.state.pages.length,
121
+ waves: input.state.waves.length,
122
+ wavesStarted: input.state.waves.filter((wave)=>wave.status !== 'waiting').length,
123
+ pagesBrought: built,
124
+ loginPagesRefused: (_input_state_leftOutCount_loginform = input.state.leftOutCount['login-form']) != null ? _input_state_leftOutCount_loginform : 0,
125
+ lastEvent: input.event
126
+ }, input.wave !== undefined ? {
127
+ lastWave: input.wave
128
+ } : {}, {
129
+ atMs: input.nowMs
130
+ }),
131
+ updatedAt: FieldValue.serverTimestamp()
132
+ }, first ? {
133
+ status: 'open',
134
+ createdAt: FieldValue.serverTimestamp()
135
+ } : {}), {
136
+ merge: true
137
+ });
138
+ return reviewId;
139
+ } catch (error) {
140
+ console.error('[site-import] trust and safety row failed', {
141
+ orgId: input.orgId,
142
+ jobId: input.jobId,
143
+ error
144
+ });
145
+ return null;
146
+ }
147
+ }
148
+
149
+ //# sourceMappingURL=site-import-review.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/site-import/site-import-review.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * EVERY SITE IMPORT IS LOGGED FOR TRUST & SAFETY (AGL-3976).\n *\n * A site import is a one-click copy of a website, which is exactly what a\n * phishing clone is. Proving ownership is the control; this row is how a\n * person checks the control held. Every import job files ONE row in the\n * abuse queue (`abuseReports`), in the shape the outbound screen files a\n * hold (`fileOutboundHold`): the same fields the admin page and the daily\n * review already read, `source: 'site-import'`, the proved domain as text\n * (the queue never renders a reported address as a link), and the proof.\n *\n * The row is written when the site is read and rewritten as each wave\n * starts and finishes, its `reportCount` counting the waves, so the queue\n * shows one import as one row however long it runs. It holds nothing: the\n * import goes on while the row is open. Staff look at the old site and the\n * new one; a workspace bringing in a site that is not its own is locked,\n * and the row closed with what was done.\n *\n * Never throws: a log that cannot be written must not stop a person's\n * import, and the failure is a log line staff alerts read.\n *=========================================*/\n\nimport { ABUSE_REPORT_COLLECTION, ABUSE_REPORT_MAX_DETAILS } from '@aglyn/aglyn/app-utils/abuse-report'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n AI_SITE_IMPORT_LEFT_OUT_REASONS,\n aiSiteImportPagesText,\n type AiSiteImportState,\n} from '../model/ai-site-import'\nimport type { AiJobItemLedger } from '../model/ai-jobs.types'\n\n/** The abuse queue's `source` for a site import's row. */\nexport const SITE_IMPORT_REVIEW_SOURCE = 'site-import'\n\n/** One row per import job. */\nexport function siteImportReviewId(jobId: string): string {\n return `site-import-${jobId}`\n}\n\n/** The reference staff quote: `SI-` and the job's id. */\nexport function siteImportReviewReference(jobId: string): string {\n return `SI-${jobId.slice(0, 10).toUpperCase()}`\n}\n\nconst PROOF_WORDS: Record<string, string> = {\n dns: 'a DNS TXT record',\n meta: 'a meta tag on its home page',\n email: 'a code emailed to an address at the domain',\n}\n\nexport type SiteImportReviewEvent = 'read' | 'wave-started' | 'wave-finished'\n\nexport interface SiteImportReviewInput {\n orgId: string\n hostId: string\n jobId: string\n state: AiSiteImportState\n items: readonly AiJobItemLedger[]\n event: SiteImportReviewEvent\n /** The wave the event is about; absent on `read`. */\n wave?: number\n nowMs: number\n}\n\n/** The row's details: what was proved, read, kept, left out and brought in, rebuilt whole each time. */\nexport function siteImportReviewDetails(input: SiteImportReviewInput): string {\n const { state, items } = input\n const proof = state.ownership.method ? (PROOF_WORDS[state.ownership.method] ?? state.ownership.method) : 'an unknown method'\n const provenOn = state.ownership.provenAt ? new Date(state.ownership.provenAt).toISOString().slice(0, 10) : 'an unknown day'\n const leftOut = AI_SITE_IMPORT_LEFT_OUT_REASONS.filter((reason) => (state.leftOutCount[reason] ?? 0) > 0)\n .map((reason) => `${reason} ${state.leftOutCount[reason]}`)\n .join(', ')\n const built = items.filter((row) => row.op === 'page' && (row.status === 'succeeded' || row.status === 'degraded')).length\n const started = state.waves.filter((wave) => wave.status !== 'waiting').length\n const photos = state.pages.reduce((total, page) => total + (page.photos?.brought ?? 0), 0)\n const lines = [\n `Site import: ${state.domain} into site ${input.hostId} (AI job ${input.jobId}).`,\n `Ownership proven by ${proof}${state.ownership.detail ? ` (${state.ownership.detail})` : ''} on ${provenOn}.`,\n `Read ${aiSiteImportPagesText(state.found)}; bringing in ${aiSiteImportPagesText(state.pages.length)} in ${state.waves.length} ${state.waves.length === 1 ? 'wave' : 'waves'}` +\n `${state.pageLimit === null ? '' : ` (plan page count ${state.pageLimit}, ${state.existingPages} already on the site)`}.`,\n ...(leftOut ? [`Left out: ${leftOut}.`] : []),\n ...((state.leftOutCount['login-form'] ?? 0) > 0\n ? [`Sign-in pages refused: ${state.leftOutCount['login-form']}. A site with sign-in pages is worth a closer look.`]\n : []),\n `Waves started: ${started} of ${state.waves.length}. Pages brought in so far: ${built}. Photos copied into the media library: ${photos}.`,\n 'Nothing is published by the import: its pages are drafts until a member publishes them.',\n 'Look at the old site and the new one. If this copies a site the workspace does not own, lock the workspace, then close this row with what you did; otherwise dismiss it.',\n ]\n return lines.join('\\n').slice(0, ABUSE_REPORT_MAX_DETAILS)\n}\n\n/** Writes (or rewrites) the import's row in the abuse queue. Never throws. */\nexport async function fileSiteImportReview(\n firestore: FirebaseFirestore.Firestore,\n input: SiteImportReviewInput,\n): Promise<string | null> {\n const reviewId = siteImportReviewId(input.jobId)\n try {\n const ref = firestore.collection(ABUSE_REPORT_COLLECTION).doc(reviewId)\n const first = !(await ref.get()).exists\n const built = input.items.filter((row) => row.op === 'page' && (row.status === 'succeeded' || row.status === 'degraded')).length\n await ref.set(\n {\n reference: siteImportReviewReference(input.jobId),\n // \"Impersonation or trademark\": the harm a copied site does is\n // wearing a business it is not. Normal severity: the proof already\n // held, and this row is the check that it did.\n category: 'impersonation',\n severity: 'normal',\n source: SITE_IMPORT_REVIEW_SOURCE,\n // The proved site as TEXT: the queue never renders it as a link.\n url: `https://${input.state.domain}/`,\n reportedHostname: input.state.domain,\n hostId: input.hostId,\n orgId: input.orgId,\n details: siteImportReviewDetails(input),\n reporterEmail: null,\n reporterName: null,\n dmca: null,\n // One a wave started, so a long import reads as the volume it is.\n ...(input.event === 'wave-started' ? { reportCount: FieldValue.increment(1) } : first ? { reportCount: 0 } : {}),\n siteImport: {\n jobId: input.jobId,\n domain: input.state.domain,\n method: input.state.ownership.method,\n provenAt: input.state.ownership.provenAt,\n detail: input.state.ownership.detail,\n found: input.state.found,\n kept: input.state.pages.length,\n waves: input.state.waves.length,\n wavesStarted: input.state.waves.filter((wave) => wave.status !== 'waiting').length,\n pagesBrought: built,\n loginPagesRefused: input.state.leftOutCount['login-form'] ?? 0,\n lastEvent: input.event,\n ...(input.wave !== undefined ? { lastWave: input.wave } : {}),\n atMs: input.nowMs,\n },\n updatedAt: FieldValue.serverTimestamp(),\n ...(first ? { status: 'open', createdAt: FieldValue.serverTimestamp() } : {}),\n },\n { merge: true },\n )\n return reviewId\n } catch (error) {\n console.error('[site-import] trust and safety row failed', { orgId: input.orgId, jobId: input.jobId, error })\n return null\n }\n}\n"],"names":["ABUSE_REPORT_COLLECTION","ABUSE_REPORT_MAX_DETAILS","FieldValue","AI_SITE_IMPORT_LEFT_OUT_REASONS","aiSiteImportPagesText","SITE_IMPORT_REVIEW_SOURCE","siteImportReviewId","jobId","siteImportReviewReference","slice","toUpperCase","PROOF_WORDS","dns","meta","email","siteImportReviewDetails","input","state","items","proof","ownership","method","provenOn","provenAt","Date","toISOString","leftOut","filter","reason","leftOutCount","map","join","built","row","op","status","length","started","waves","wave","photos","pages","reduce","total","page","brought","lines","domain","hostId","detail","found","pageLimit","existingPages","fileSiteImportReview","firestore","reviewId","ref","collection","doc","first","get","exists","set","reference","category","severity","source","url","reportedHostname","orgId","details","reporterEmail","reporterName","dmca","event","reportCount","increment","siteImport","kept","wavesStarted","pagesBrought","loginPagesRefused","lastEvent","undefined","lastWave","atMs","nowMs","updatedAt","serverTimestamp","createdAt","merge","error","console"],"mappings":"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"}
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /** The product token AglynBot matches robots.txt groups by. */
18
+ export declare const AGLYN_BOT_TOKEN = "aglynbot";
19
+ export interface RobotsRule {
20
+ allow: boolean;
21
+ /** The path pattern as written, `*` and `$` included. */
22
+ pattern: string;
23
+ }
24
+ export interface RobotsGroup {
25
+ /** Lowercased product tokens, version suffixes removed. */
26
+ agents: string[];
27
+ rules: RobotsRule[];
28
+ }
29
+ export interface RobotsTxt {
30
+ groups: RobotsGroup[];
31
+ /** Every `Sitemap:` address, as written. */
32
+ sitemaps: string[];
33
+ }
34
+ /** robots.txt as groups and sitemaps; unknown lines are skipped. */
35
+ export declare function parseRobotsTxt(text: string): RobotsTxt;
36
+ /**
37
+ * The rules that apply to `token`: every group naming it, merged; when none
38
+ * does, every `*` group, merged; when neither exists, none (all allowed).
39
+ */
40
+ export declare function robotsRulesFor(robots: RobotsTxt, token?: string): RobotsRule[];
41
+ /**
42
+ * Whether `pathWithQuery` (`/a/b?c=d`) may be crawled under `rules`.
43
+ * `/robots.txt` itself is always allowed.
44
+ */
45
+ export declare function robotsAllows(rules: readonly RobotsRule[], pathWithQuery: string): boolean;
46
+ export interface SitemapEntries {
47
+ /** True for a `<sitemapindex>`: `locations` are sitemaps, not pages. */
48
+ index: boolean;
49
+ locations: string[];
50
+ }
51
+ /** The `<loc>` addresses of a sitemap or a sitemap index, entities decoded. */
52
+ export declare function parseSitemap(xml: string, limit?: number): SitemapEntries;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /*==========================================
17
+ * ROBOTS.TXT AND SITEMAPS, FOR AGLYNBOT (AGL-3974).
18
+ *
19
+ * The site-import crawler asks a site's robots.txt before every page, as
20
+ * RFC 9309 describes it: the group naming `AglynBot` if there is one, else
21
+ * the `*` group; the longest matching rule decides, and `Allow` wins a tie;
22
+ * `*` matches any run and a trailing `$` anchors the end. A site that keeps
23
+ * a path from crawlers keeps it from this one, even though the person asking
24
+ * owns the site: the file is the site's own statement of what a robot may
25
+ * read, and the person can change it.
26
+ *
27
+ * Sitemaps are read for their `<loc>` addresses, and a sitemap index for the
28
+ * sitemaps it lists. Gzipped sitemaps are not read.
29
+ *=========================================*/ /** The product token AglynBot matches robots.txt groups by. */ export const AGLYN_BOT_TOKEN = 'aglynbot';
30
+ /** robots.txt as groups and sitemaps; unknown lines are skipped. */ export function parseRobotsTxt(text) {
31
+ const groups = [];
32
+ const sitemaps = [];
33
+ let group = null;
34
+ let lastWasAgent = false;
35
+ for (const rawLine of String(text != null ? text : '').split(/\r\n|\r|\n/)){
36
+ const line = rawLine.replace(/#.*$/, '').trim();
37
+ if (!line) continue;
38
+ const colon = line.indexOf(':');
39
+ if (colon <= 0) continue;
40
+ const key = line.slice(0, colon).trim().toLowerCase();
41
+ const value = line.slice(colon + 1).trim();
42
+ if (key === 'sitemap') {
43
+ if (value) sitemaps.push(value);
44
+ continue;
45
+ }
46
+ if (key === 'user-agent') {
47
+ const agent = value.toLowerCase().split('/')[0].trim();
48
+ if (!group || !lastWasAgent) {
49
+ group = {
50
+ agents: [],
51
+ rules: []
52
+ };
53
+ groups.push(group);
54
+ }
55
+ if (agent) group.agents.push(agent);
56
+ lastWasAgent = true;
57
+ continue;
58
+ }
59
+ lastWasAgent = false;
60
+ if (!group) continue;
61
+ if (key === 'allow' || key === 'disallow') {
62
+ // An empty Disallow allows everything, which is what no rule does.
63
+ if (!value) continue;
64
+ group.rules.push({
65
+ allow: key === 'allow',
66
+ pattern: value
67
+ });
68
+ }
69
+ }
70
+ return {
71
+ groups,
72
+ sitemaps
73
+ };
74
+ }
75
+ /**
76
+ * The rules that apply to `token`: every group naming it, merged; when none
77
+ * does, every `*` group, merged; when neither exists, none (all allowed).
78
+ */ export function robotsRulesFor(robots, token = AGLYN_BOT_TOKEN) {
79
+ const own = robots.groups.filter((group)=>group.agents.includes(token));
80
+ const chosen = own.length ? own : robots.groups.filter((group)=>group.agents.includes('*'));
81
+ return chosen.flatMap((group)=>group.rules);
82
+ }
83
+ function patternMatchLength(pattern, path) {
84
+ const anchored = pattern.endsWith('$');
85
+ const body = anchored ? pattern.slice(0, -1) : pattern;
86
+ const source = body.split('*').map((part)=>part.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*');
87
+ const regex = new RegExp(`^${source}${anchored ? '$' : ''}`);
88
+ return regex.test(path) ? body.length : -1;
89
+ }
90
+ /** A path with a percent-encoding case that compares the way robots.txt writes it. */ function comparablePath(path) {
91
+ return path.replace(/%[0-9a-f]{2}/gi, (escape)=>escape.toUpperCase());
92
+ }
93
+ /**
94
+ * Whether `pathWithQuery` (`/a/b?c=d`) may be crawled under `rules`.
95
+ * `/robots.txt` itself is always allowed.
96
+ */ export function robotsAllows(rules, pathWithQuery) {
97
+ const path = comparablePath(pathWithQuery || '/');
98
+ if (path === '/robots.txt') return true;
99
+ let best = -1;
100
+ let allowed = true;
101
+ for (const rule of rules){
102
+ const length = patternMatchLength(comparablePath(rule.pattern), path);
103
+ if (length < 0) continue;
104
+ if (length > best || length === best && rule.allow) {
105
+ best = length;
106
+ allowed = rule.allow;
107
+ }
108
+ }
109
+ return allowed;
110
+ }
111
+ /** The `<loc>` addresses of a sitemap or a sitemap index, entities decoded. */ export function parseSitemap(xml, limit = 5000) {
112
+ const source = String(xml != null ? xml : '');
113
+ const index = /<sitemapindex\b/i.test(source);
114
+ const locations = [];
115
+ for (const match of source.matchAll(/<loc\b[^>]*>([\s\S]*?)<\/loc\s*>/gi)){
116
+ const value = match[1].replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").trim();
117
+ if (value) locations.push(value);
118
+ if (locations.length >= limit) break;
119
+ }
120
+ return {
121
+ index,
122
+ locations
123
+ };
124
+ }
125
+
126
+ //# sourceMappingURL=site-import-robots.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/site-import/site-import-robots.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * ROBOTS.TXT AND SITEMAPS, FOR AGLYNBOT (AGL-3974).\n *\n * The site-import crawler asks a site's robots.txt before every page, as\n * RFC 9309 describes it: the group naming `AglynBot` if there is one, else\n * the `*` group; the longest matching rule decides, and `Allow` wins a tie;\n * `*` matches any run and a trailing `$` anchors the end. A site that keeps\n * a path from crawlers keeps it from this one, even though the person asking\n * owns the site: the file is the site's own statement of what a robot may\n * read, and the person can change it.\n *\n * Sitemaps are read for their `<loc>` addresses, and a sitemap index for the\n * sitemaps it lists. Gzipped sitemaps are not read.\n *=========================================*/\n\n/** The product token AglynBot matches robots.txt groups by. */\nexport const AGLYN_BOT_TOKEN = 'aglynbot'\n\nexport interface RobotsRule {\n allow: boolean\n /** The path pattern as written, `*` and `$` included. */\n pattern: string\n}\n\nexport interface RobotsGroup {\n /** Lowercased product tokens, version suffixes removed. */\n agents: string[]\n rules: RobotsRule[]\n}\n\nexport interface RobotsTxt {\n groups: RobotsGroup[]\n /** Every `Sitemap:` address, as written. */\n sitemaps: string[]\n}\n\n/** robots.txt as groups and sitemaps; unknown lines are skipped. */\nexport function parseRobotsTxt(text: string): RobotsTxt {\n const groups: RobotsGroup[] = []\n const sitemaps: string[] = []\n let group: RobotsGroup | null = null\n let lastWasAgent = false\n for (const rawLine of String(text ?? '').split(/\\r\\n|\\r|\\n/)) {\n const line = rawLine.replace(/#.*$/, '').trim()\n if (!line) continue\n const colon = line.indexOf(':')\n if (colon <= 0) continue\n const key = line.slice(0, colon).trim().toLowerCase()\n const value = line.slice(colon + 1).trim()\n if (key === 'sitemap') {\n if (value) sitemaps.push(value)\n continue\n }\n if (key === 'user-agent') {\n const agent = value.toLowerCase().split('/')[0].trim()\n if (!group || !lastWasAgent) {\n group = { agents: [], rules: [] }\n groups.push(group)\n }\n if (agent) group.agents.push(agent)\n lastWasAgent = true\n continue\n }\n lastWasAgent = false\n if (!group) continue\n if (key === 'allow' || key === 'disallow') {\n // An empty Disallow allows everything, which is what no rule does.\n if (!value) continue\n group.rules.push({ allow: key === 'allow', pattern: value })\n }\n }\n return { groups, sitemaps }\n}\n\n/**\n * The rules that apply to `token`: every group naming it, merged; when none\n * does, every `*` group, merged; when neither exists, none (all allowed).\n */\nexport function robotsRulesFor(robots: RobotsTxt, token: string = AGLYN_BOT_TOKEN): RobotsRule[] {\n const own = robots.groups.filter((group) => group.agents.includes(token))\n const chosen = own.length ? own : robots.groups.filter((group) => group.agents.includes('*'))\n return chosen.flatMap((group) => group.rules)\n}\n\nfunction patternMatchLength(pattern: string, path: string): number {\n const anchored = pattern.endsWith('$')\n const body = anchored ? pattern.slice(0, -1) : pattern\n const source = body\n .split('*')\n .map((part) => part.replace(/[.+?^${}()|[\\]\\\\]/g, '\\\\$&'))\n .join('.*')\n const regex = new RegExp(`^${source}${anchored ? '$' : ''}`)\n return regex.test(path) ? body.length : -1\n}\n\n/** A path with a percent-encoding case that compares the way robots.txt writes it. */\nfunction comparablePath(path: string): string {\n return path.replace(/%[0-9a-f]{2}/gi, (escape) => escape.toUpperCase())\n}\n\n/**\n * Whether `pathWithQuery` (`/a/b?c=d`) may be crawled under `rules`.\n * `/robots.txt` itself is always allowed.\n */\nexport function robotsAllows(rules: readonly RobotsRule[], pathWithQuery: string): boolean {\n const path = comparablePath(pathWithQuery || '/')\n if (path === '/robots.txt') return true\n let best = -1\n let allowed = true\n for (const rule of rules) {\n const length = patternMatchLength(comparablePath(rule.pattern), path)\n if (length < 0) continue\n if (length > best || (length === best && rule.allow)) {\n best = length\n allowed = rule.allow\n }\n }\n return allowed\n}\n\nexport interface SitemapEntries {\n /** True for a `<sitemapindex>`: `locations` are sitemaps, not pages. */\n index: boolean\n locations: string[]\n}\n\n/** The `<loc>` addresses of a sitemap or a sitemap index, entities decoded. */\nexport function parseSitemap(xml: string, limit = 5_000): SitemapEntries {\n const source = String(xml ?? '')\n const index = /<sitemapindex\\b/i.test(source)\n const locations: string[] = []\n for (const match of source.matchAll(/<loc\\b[^>]*>([\\s\\S]*?)<\\/loc\\s*>/gi)) {\n const value = match[1]\n .replace(/<!\\[CDATA\\[([\\s\\S]*?)\\]\\]>/g, '$1')\n .replace(/&amp;/g, '&')\n .replace(/&lt;/g, '<')\n .replace(/&gt;/g, '>')\n .replace(/&quot;/g, '\"')\n .replace(/&apos;/g, \"'\")\n .trim()\n if (value) locations.push(value)\n if (locations.length >= limit) break\n }\n return { index, locations }\n}\n"],"names":["AGLYN_BOT_TOKEN","parseRobotsTxt","text","groups","sitemaps","group","lastWasAgent","rawLine","String","split","line","replace","trim","colon","indexOf","key","slice","toLowerCase","value","push","agent","agents","rules","allow","pattern","robotsRulesFor","robots","token","own","filter","includes","chosen","length","flatMap","patternMatchLength","path","anchored","endsWith","body","source","map","part","join","regex","RegExp","test","comparablePath","escape","toUpperCase","robotsAllows","pathWithQuery","best","allowed","rule","parseSitemap","xml","limit","index","locations","match","matchAll"],"mappings":"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"}
@@ -19,7 +19,7 @@ import { type SeoListingFieldKey } from '@aglyn/aglyn/app-utils/seo-listing-fiel
19
19
  import type { SeoPageFacts } from '@aglyn/aglyn/app-utils/seo-page-facts';
20
20
  import type { AiSeoFieldValues, AiSeoSiteFormField } from '../model/ai-seo';
21
21
  import type { AiTool } from '../providers/contract';
22
- import type { AiDoctrineViolation } from '../runtime/ai-doctrine-validators';
22
+ import { type AiDoctrineViolation } from '../runtime/ai-doctrine-validators';
23
23
  /**
24
24
  * The strict tools SEO by AI answers through (AGL-2910), and the checks that
25
25
  * hold each answer to the editor it proposes for.
@@ -40,6 +40,24 @@ import type { AiDoctrineViolation } from '../runtime/ai-doctrine-validators';
40
40
  */
41
41
  /** The most times one keyword may appear, per field, before it reads as stuffing. */
42
42
  export declare const AI_SEO_KEYWORD_LIMITS: Readonly<Partial<Record<SeoListingFieldKey, number>>>;
43
+ /**
44
+ * One text held to `max` characters, cut the way the page step cuts the
45
+ * listing it stores (`aiClipAtWord`, AGL-3813): at a word, with no joining
46
+ * mark or dangling word left at the end.
47
+ *
48
+ * The model is told every length and re-asked once with the count it ran
49
+ * past, and a fast model still overruns by a few characters: counting
50
+ * characters is the one thing it reliably does badly. MindFullMe's listing
51
+ * failed on 2026-10-20 over a 65-character title, twice. So an answer whose
52
+ * only fault is a length is fitted rather than thrown away.
53
+ */
54
+ export declare function aiFitSeoText(value: string, max: number): string;
55
+ /**
56
+ * A listing answer with every text field past its length fitted to it, or
57
+ * `null` when no field ran over. Nothing else in the answer changes; the
58
+ * caller runs `checkAiSeoFields` on the result again.
59
+ */
60
+ export declare function fitAiSeoFieldLengths(answer: Record<string, unknown>, fields: readonly SeoListingFieldKey[]): Record<string, unknown> | null;
43
61
  export declare const AI_SEO_FIELDS_TOOL_NAME = "propose_search_listing";
44
62
  /** The fields a listing proposal must fill; the image description may be left out. */
45
63
  export declare const AI_SEO_REQUIRED_FIELDS: ReadonlySet<SeoListingFieldKey>;
@@ -124,6 +142,12 @@ export declare function checkAiSeoFixes(answer: Record<string, unknown>, pages:
124
142
  violations: AiDoctrineViolation[];
125
143
  offending?: Record<string, unknown>;
126
144
  };
145
+ /**
146
+ * A batch answer with each page's title, description, heading and image
147
+ * descriptions fitted to their lengths (AGL-4016), or `null` when nothing
148
+ * ran over. The caller runs `checkAiSeoFixes` on the result again.
149
+ */
150
+ export declare function fitAiSeoFixLengths(answer: Record<string, unknown>): Record<string, unknown> | null;
127
151
  export declare const AI_SEO_SITE_TOOL_NAME = "propose_site_listing";
128
152
  /** The longest structured-data description the site SEO form accepts. */
129
153
  export declare const AI_SEO_ENTITY_DESCRIPTION_MAX_CHARS = 300;
@@ -1,3 +1,4 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
1
2
  /**
2
3
  * @license
3
4
  * Copyright 2026 Aglyn LLC
@@ -18,6 +19,7 @@ import { seoKeywordCount } from "@aglyn/aglyn/app-utils/seo-keywords";
18
19
  import { SEO_LISTING_FIELDS } from "@aglyn/aglyn/app-utils/seo-listing-fields";
19
20
  import { HostEntityType } from "@aglyn/aglyn/foundation/definitions/platform.types";
20
21
  import { AI_TEXT_LIMITS } from "../runtime/ai-palette.js";
22
+ import { aiClipAtWord } from "../runtime/ai-doctrine-validators.js";
21
23
  /**
22
24
  * The strict tools SEO by AI answers through (AGL-2910), and the checks that
23
25
  * hold each answer to the editor it proposes for.
@@ -66,6 +68,36 @@ const text = (description)=>({
66
68
  ],
67
69
  description
68
70
  });
71
+ /* ------------------------------------------------------------------------ *
72
+ * Fitting an answer that ran past a length (AGL-4016)
73
+ * ------------------------------------------------------------------------ */ /**
74
+ * One text held to `max` characters, cut the way the page step cuts the
75
+ * listing it stores (`aiClipAtWord`, AGL-3813): at a word, with no joining
76
+ * mark or dangling word left at the end.
77
+ *
78
+ * The model is told every length and re-asked once with the count it ran
79
+ * past, and a fast model still overruns by a few characters: counting
80
+ * characters is the one thing it reliably does badly. MindFullMe's listing
81
+ * failed on 2026-10-20 over a 65-character title, twice. So an answer whose
82
+ * only fault is a length is fitted rather than thrown away.
83
+ */ export function aiFitSeoText(value, max) {
84
+ return aiClipAtWord(collapse(value), max);
85
+ }
86
+ /**
87
+ * A listing answer with every text field past its length fitted to it, or
88
+ * `null` when no field ran over. Nothing else in the answer changes; the
89
+ * caller runs `checkAiSeoFields` on the result again.
90
+ */ export function fitAiSeoFieldLengths(answer, fields) {
91
+ let fitted = null;
92
+ for (const key of orderedSeoListingFields(fields)){
93
+ const raw = answer[key];
94
+ const { maxLength } = SEO_LISTING_FIELDS[key];
95
+ if (typeof raw !== 'string' || collapse(raw).length <= maxLength) continue;
96
+ fitted != null ? fitted : fitted = _extends({}, answer);
97
+ fitted[key] = aiFitSeoText(raw, maxLength);
98
+ }
99
+ return fitted;
100
+ }
69
101
  /* ------------------------------------------------------------------------ *
70
102
  * One listing, from the editor's field list
71
103
  * ------------------------------------------------------------------------ */ export const AI_SEO_FIELDS_TOOL_NAME = 'propose_search_listing';
@@ -395,6 +427,35 @@ const normalizeTitle = (value)=>value.replace(/\s+/g, ' ').trim().toLowerCase();
395
427
  violations: []
396
428
  };
397
429
  }
430
+ /**
431
+ * A batch answer with each page's title, description, heading and image
432
+ * descriptions fitted to their lengths (AGL-4016), or `null` when nothing
433
+ * ran over. The caller runs `checkAiSeoFixes` on the result again.
434
+ */ export function fitAiSeoFixLengths(answer) {
435
+ if (!Array.isArray(answer['pages'])) return null;
436
+ let changed = false;
437
+ const fit = (raw, max)=>{
438
+ if (typeof raw !== 'string' || collapse(raw).length <= max) return raw;
439
+ changed = true;
440
+ return aiFitSeoText(raw, max);
441
+ };
442
+ const pages = answer['pages'].map((raw)=>{
443
+ if (!raw || typeof raw !== 'object') return raw;
444
+ const entry = raw;
445
+ return _extends({}, entry, {
446
+ title: fit(entry['title'], SEO_LISTING_FIELDS.title.maxLength),
447
+ description: fit(entry['description'], SEO_LISTING_FIELDS.description.maxLength),
448
+ h1: fit(entry['h1'], AI_TEXT_LIMITS.headline)
449
+ }, Array.isArray(entry['imageAlts']) ? {
450
+ imageAlts: entry['imageAlts'].map((alt)=>alt && typeof alt === 'object' ? _extends({}, alt, {
451
+ alt: fit(alt['alt'], AI_SEO_FIX_ALT_MAX_CHARS)
452
+ }) : alt)
453
+ } : {});
454
+ });
455
+ return changed ? _extends({}, answer, {
456
+ pages
457
+ }) : null;
458
+ }
398
459
  /* ------------------------------------------------------------------------ *
399
460
  * The site: structured data and guidance for AI agents
400
461
  * ------------------------------------------------------------------------ */ export const AI_SEO_SITE_TOOL_NAME = 'propose_site_listing';