@sonordev/contracts 2.7.1 → 2.9.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/CHANGELOG.md +34 -0
- package/README.md +315 -0
- package/dist/broadcast-layouts.d.ts +133 -0
- package/dist/broadcast-layouts.js +83 -0
- package/dist/broadcast-layouts.mjs +56 -0
- package/dist/business-claims.d.ts +456 -0
- package/dist/business-claims.js +1378 -0
- package/dist/business-claims.mjs +1340 -0
- package/dist/outreach.d.ts +267 -0
- package/dist/outreach.js +351 -0
- package/dist/outreach.mjs +328 -0
- package/dist/page-titles.d.ts +80 -0
- package/dist/page-titles.js +124 -0
- package/dist/page-titles.mjs +120 -0
- package/dist/publication-branding.d.ts +143 -0
- package/dist/publication-branding.js +350 -0
- package/dist/publication-branding.mjs +328 -0
- package/dist/publication-charter.d.ts +14 -0
- package/package.json +28 -2
|
@@ -0,0 +1,1378 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/contracts/voice.ts
|
|
4
|
+
var VOICE_MASK = "\u2800";
|
|
5
|
+
var TABLE = [
|
|
6
|
+
["do not", "don't"],
|
|
7
|
+
["does not", "doesn't"],
|
|
8
|
+
["did not", "didn't"],
|
|
9
|
+
["is not", "isn't"],
|
|
10
|
+
["are not", "aren't"],
|
|
11
|
+
["was not", "wasn't"],
|
|
12
|
+
["were not", "weren't"],
|
|
13
|
+
["cannot", "can't"],
|
|
14
|
+
["can not", "can't"],
|
|
15
|
+
["will not", "won't"],
|
|
16
|
+
["would not", "wouldn't"],
|
|
17
|
+
["should not", "shouldn't"],
|
|
18
|
+
["could not", "couldn't"],
|
|
19
|
+
["has not", "hasn't"],
|
|
20
|
+
["have not", "haven't"],
|
|
21
|
+
["had not", "hadn't"],
|
|
22
|
+
["must not", "mustn't"],
|
|
23
|
+
["need not", "needn't"],
|
|
24
|
+
["it is", "it's"],
|
|
25
|
+
["that is", "that's"],
|
|
26
|
+
["there is", "there's"],
|
|
27
|
+
["here is", "here's"],
|
|
28
|
+
["who is", "who's"],
|
|
29
|
+
["what is", "what's"],
|
|
30
|
+
["you are", "you're"],
|
|
31
|
+
["we are", "we're"],
|
|
32
|
+
["they are", "they're"],
|
|
33
|
+
["I am", "I'm"],
|
|
34
|
+
["you will", "you'll"],
|
|
35
|
+
["we will", "we'll"],
|
|
36
|
+
["they will", "they'll"],
|
|
37
|
+
["it will", "it'll"],
|
|
38
|
+
["we would", "we'd"],
|
|
39
|
+
["you would", "you'd"],
|
|
40
|
+
["they would", "they'd"],
|
|
41
|
+
["let us", "let's"]
|
|
42
|
+
];
|
|
43
|
+
var PAIRS = TABLE.map(([full, contracted]) => ({
|
|
44
|
+
full,
|
|
45
|
+
contracted,
|
|
46
|
+
negation: / not$|^cannot$/.test(full),
|
|
47
|
+
pattern: new RegExp(`\\b${full.replace(/ /g, "[ \\t]+")}\\b`, "gi")
|
|
48
|
+
}));
|
|
49
|
+
var CAN_NOT_ONLY = /[ \t]+only\b/iy;
|
|
50
|
+
var CLAUSE_END = /[ \t]*(?:[.,;:!?)\]}"\u201d\u2019\u2026\u2014\u2013-]|\r?\n|\r?$)/y;
|
|
51
|
+
var CONJUNCTION = /[ \t]+(?:and|or|but|nor)\b/iy;
|
|
52
|
+
var LET_US_SUGGESTIONS = /[ \t]+(?:look|start|begin|consider|turn|see|review|compare|break|dig|recap|unpack|explore|examine|revisit|focus|go|talk|move|get started|step back|zoom out)\b/iy;
|
|
53
|
+
var PROSE_GAP = /[\s*_\u2800]/;
|
|
54
|
+
var preserveCase = (match, replacement) => match[0] === match[0].toUpperCase() && match[0] !== match[0].toLowerCase() ? replacement[0].toUpperCase() + replacement.slice(1) : replacement;
|
|
55
|
+
var maskAll = (text) => text.replace(/[^\r\n]/g, VOICE_MASK);
|
|
56
|
+
function maskProtected(text, ranges) {
|
|
57
|
+
if (!ranges) return text;
|
|
58
|
+
if (!Array.isArray(ranges)) return maskAll(text);
|
|
59
|
+
if (ranges.length === 0) return text;
|
|
60
|
+
const length = text.length;
|
|
61
|
+
const edges = new Int32Array(length + 1);
|
|
62
|
+
let any = false;
|
|
63
|
+
for (const range of ranges) {
|
|
64
|
+
const from = range?.[0];
|
|
65
|
+
const to = range?.[1];
|
|
66
|
+
if (typeof from !== "number" || typeof to !== "number" || Number.isNaN(from) || Number.isNaN(to) || from > to) return maskAll(text);
|
|
67
|
+
const start = Math.max(0, Math.floor(from));
|
|
68
|
+
const end = Math.min(length, Math.ceil(to));
|
|
69
|
+
if (end <= start) continue;
|
|
70
|
+
edges[start] += 1;
|
|
71
|
+
edges[end] -= 1;
|
|
72
|
+
any = true;
|
|
73
|
+
}
|
|
74
|
+
if (!any) return text;
|
|
75
|
+
const parts = [];
|
|
76
|
+
let depth = 0;
|
|
77
|
+
let runStart = 0;
|
|
78
|
+
let runProtected = false;
|
|
79
|
+
for (let i = 0; i <= length; i++) {
|
|
80
|
+
const isProtected = i < length && (depth += edges[i]) > 0;
|
|
81
|
+
if (i === length || isProtected !== runProtected) {
|
|
82
|
+
if (i > runStart) parts.push(runProtected ? maskAll(text.slice(runStart, i)) : text.slice(runStart, i));
|
|
83
|
+
runStart = i;
|
|
84
|
+
runProtected = isProtected;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return parts.join("");
|
|
88
|
+
}
|
|
89
|
+
function walkBackOverGap(text, start) {
|
|
90
|
+
let at = start;
|
|
91
|
+
while (at > 0 && text[at - 1] !== "\n" && PROSE_GAP.test(text[at - 1])) at--;
|
|
92
|
+
return at;
|
|
93
|
+
}
|
|
94
|
+
function firstStop(text, from) {
|
|
95
|
+
for (let i = from; i < text.length; i++) {
|
|
96
|
+
const code = text.charCodeAt(i);
|
|
97
|
+
if (code === 46 || code === 33 || code === 63 || code === 10) return i;
|
|
98
|
+
}
|
|
99
|
+
return text.length;
|
|
100
|
+
}
|
|
101
|
+
function mustStayFull(pair, text, titleLike, start, end, look) {
|
|
102
|
+
if (pair.full === "can not") {
|
|
103
|
+
CAN_NOT_ONLY.lastIndex = end;
|
|
104
|
+
return CAN_NOT_ONLY.test(text);
|
|
105
|
+
}
|
|
106
|
+
if (pair.negation) return false;
|
|
107
|
+
CLAUSE_END.lastIndex = end;
|
|
108
|
+
if (CLAUSE_END.test(text)) return true;
|
|
109
|
+
CONJUNCTION.lastIndex = end;
|
|
110
|
+
if (CONJUNCTION.test(text)) return true;
|
|
111
|
+
if (pair.full === "what is") {
|
|
112
|
+
if (look.stop < end) look.stop = firstStop(text, end);
|
|
113
|
+
if (text.charCodeAt(look.stop) === 63) return true;
|
|
114
|
+
if (titleLike) {
|
|
115
|
+
const at = walkBackOverGap(text, start);
|
|
116
|
+
if (at === 0 || text[at - 1] === "\n") return true;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (pair.full === "let us") {
|
|
120
|
+
const at = walkBackOverGap(text, start);
|
|
121
|
+
if (at > 0 && !".!?:\n".includes(text[at - 1])) return true;
|
|
122
|
+
LET_US_SUGGESTIONS.lastIndex = end;
|
|
123
|
+
return !LET_US_SUGGESTIONS.test(text);
|
|
124
|
+
}
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
function scanUncontracted(text, masked, titleLike) {
|
|
128
|
+
if (masked.length < 4) return [];
|
|
129
|
+
const hits = [];
|
|
130
|
+
const claimed = new Uint8Array(masked.length);
|
|
131
|
+
for (const pair of PAIRS) {
|
|
132
|
+
const look = { stop: -1 };
|
|
133
|
+
for (const found of masked.matchAll(pair.pattern)) {
|
|
134
|
+
const start = found.index ?? 0;
|
|
135
|
+
const end = start + found[0].length;
|
|
136
|
+
let overlaps = false;
|
|
137
|
+
for (let i = start; i < end; i++) {
|
|
138
|
+
if (claimed[i] === 1) {
|
|
139
|
+
overlaps = true;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (overlaps || mustStayFull(pair, masked, titleLike, start, end, look)) continue;
|
|
144
|
+
claimed.fill(1, start, end);
|
|
145
|
+
const match = text.slice(start, end);
|
|
146
|
+
hits.push({ index: start, length: end - start, match, suggestion: preserveCase(match, pair.contracted) });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return hits.sort((a, b) => a.index - b.index);
|
|
150
|
+
}
|
|
151
|
+
function findUncontracted(text, options) {
|
|
152
|
+
if (typeof text !== "string" || !text) return [];
|
|
153
|
+
return scanUncontracted(text, maskProtected(text, options?.protectedRanges), options?.titleLike === true);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// src/contracts/sentences.ts
|
|
157
|
+
var ABBREVIATIONS = /* @__PURE__ */ new Set([
|
|
158
|
+
"mr",
|
|
159
|
+
"mrs",
|
|
160
|
+
"ms",
|
|
161
|
+
"dr",
|
|
162
|
+
"prof",
|
|
163
|
+
"st",
|
|
164
|
+
"jr",
|
|
165
|
+
"sr",
|
|
166
|
+
"vs",
|
|
167
|
+
"inc",
|
|
168
|
+
"co",
|
|
169
|
+
"corp",
|
|
170
|
+
"ltd",
|
|
171
|
+
"llc",
|
|
172
|
+
"approx",
|
|
173
|
+
"dept",
|
|
174
|
+
"jan",
|
|
175
|
+
"feb",
|
|
176
|
+
"mar",
|
|
177
|
+
"apr",
|
|
178
|
+
"jun",
|
|
179
|
+
"jul",
|
|
180
|
+
"aug",
|
|
181
|
+
"sep",
|
|
182
|
+
"sept",
|
|
183
|
+
"oct",
|
|
184
|
+
"nov",
|
|
185
|
+
"dec",
|
|
186
|
+
"gen",
|
|
187
|
+
"sen",
|
|
188
|
+
"gov",
|
|
189
|
+
"lt",
|
|
190
|
+
"col",
|
|
191
|
+
"capt",
|
|
192
|
+
"sgt",
|
|
193
|
+
"cpl",
|
|
194
|
+
"maj",
|
|
195
|
+
"adm",
|
|
196
|
+
"pres",
|
|
197
|
+
"atty",
|
|
198
|
+
"bros",
|
|
199
|
+
"mfg",
|
|
200
|
+
"assoc",
|
|
201
|
+
"univ",
|
|
202
|
+
"bldg",
|
|
203
|
+
"ste",
|
|
204
|
+
"ave",
|
|
205
|
+
"blvd",
|
|
206
|
+
"rd",
|
|
207
|
+
"ln",
|
|
208
|
+
"hwy",
|
|
209
|
+
"pkwy",
|
|
210
|
+
"pl"
|
|
211
|
+
]);
|
|
212
|
+
var CAPITALISED_ABBREVIATIONS = /* @__PURE__ */ new Set([
|
|
213
|
+
"Mon",
|
|
214
|
+
"Tue",
|
|
215
|
+
"Tues",
|
|
216
|
+
"Wed",
|
|
217
|
+
"Thu",
|
|
218
|
+
"Thur",
|
|
219
|
+
"Thurs",
|
|
220
|
+
"Fri",
|
|
221
|
+
"Sat",
|
|
222
|
+
"Sun",
|
|
223
|
+
"Mt",
|
|
224
|
+
"Ft",
|
|
225
|
+
"Ct",
|
|
226
|
+
"Rep",
|
|
227
|
+
"Rev",
|
|
228
|
+
"Hon"
|
|
229
|
+
]);
|
|
230
|
+
var BEFORE_NUMBER = /* @__PURE__ */ new Set([
|
|
231
|
+
"no",
|
|
232
|
+
"nos",
|
|
233
|
+
"fig",
|
|
234
|
+
"figs",
|
|
235
|
+
"vol",
|
|
236
|
+
"vols",
|
|
237
|
+
"ch",
|
|
238
|
+
"sec",
|
|
239
|
+
"pp",
|
|
240
|
+
"cf",
|
|
241
|
+
"ca",
|
|
242
|
+
"est",
|
|
243
|
+
"ext",
|
|
244
|
+
"tel",
|
|
245
|
+
"ph",
|
|
246
|
+
"ste",
|
|
247
|
+
"apt",
|
|
248
|
+
"bldg",
|
|
249
|
+
"rm",
|
|
250
|
+
"hwy",
|
|
251
|
+
"rt",
|
|
252
|
+
"rte"
|
|
253
|
+
]);
|
|
254
|
+
var DOTTED_SHORT = /^\d*(?:[A-Za-z]{1,2}\.)+[A-Za-z]{1,2}$/;
|
|
255
|
+
var DOTTED_CAPS = /^(?:[A-Z][A-Za-z]*\.)+[A-Z][A-Za-z]*$/;
|
|
256
|
+
var ABBREVIATION_RANGE = /[A-Za-z]\.[-–—/]/;
|
|
257
|
+
var LIST_MARKER_ALONE = /^\s*(?:(?:step|tip|reason|rule|point|item|option)\s+)?\d{1,3}\.$/i;
|
|
258
|
+
var LIST_MARKER_AFTER_COLON = /:\s+\d{1,3}\.$/;
|
|
259
|
+
var LIST_MARKER_REACH = 24;
|
|
260
|
+
var OPENERS = /* @__PURE__ */ new Set(['"', "'", "\u201C", "\u2018", "(", "["]);
|
|
261
|
+
var CLOSERS = /* @__PURE__ */ new Set(['"', "'", "\u201D", "\u2019", ")", "]"]);
|
|
262
|
+
var SPACE = /\s/;
|
|
263
|
+
var LETTER = /\p{L}/u;
|
|
264
|
+
var DIGIT = /[0-9]/;
|
|
265
|
+
var SENTENCE_START = /[A-Z0-9]/;
|
|
266
|
+
var isStop = (c) => c === "." || c === "!" || c === "?";
|
|
267
|
+
var isSpace = (c) => SPACE.test(c);
|
|
268
|
+
function startsSentence(source, at) {
|
|
269
|
+
const c = OPENERS.has(source[at]) ? source[at + 1] : source[at];
|
|
270
|
+
return c !== void 0 && SENTENCE_START.test(c);
|
|
271
|
+
}
|
|
272
|
+
function digitFollows(source, at) {
|
|
273
|
+
const c = OPENERS.has(source[at]) ? source[at + 1] : source[at];
|
|
274
|
+
return c !== void 0 && DIGIT.test(c);
|
|
275
|
+
}
|
|
276
|
+
function isDottedAbbreviation(bare) {
|
|
277
|
+
const compound = Math.max(bare.lastIndexOf("-"), bare.lastIndexOf("\u2013"), bare.lastIndexOf("\u2014"), bare.lastIndexOf("/"));
|
|
278
|
+
const last = bare.slice(compound + 1);
|
|
279
|
+
return DOTTED_SHORT.test(last) || DOTTED_CAPS.test(last) || ABBREVIATION_RANGE.test(bare);
|
|
280
|
+
}
|
|
281
|
+
function endsWithAbbreviation(source, end, digitNext) {
|
|
282
|
+
let from = end;
|
|
283
|
+
while (from > 0 && !isSpace(source[from - 1])) from--;
|
|
284
|
+
let to = end;
|
|
285
|
+
while (from < to && OPENERS.has(source[from])) from++;
|
|
286
|
+
while (to > from && CLOSERS.has(source[to - 1])) to--;
|
|
287
|
+
while (to > from && source[to - 1] === ".") to--;
|
|
288
|
+
const bare = source.slice(from, to);
|
|
289
|
+
const lower = bare.toLowerCase();
|
|
290
|
+
return isDottedAbbreviation(bare) || /^[A-Za-z]$/.test(bare) || ABBREVIATIONS.has(lower) || CAPITALISED_ABBREVIATIONS.has(bare) || digitNext && BEFORE_NUMBER.has(lower);
|
|
291
|
+
}
|
|
292
|
+
function endsWithListMarker(source, start, end) {
|
|
293
|
+
const from = Math.max(start, end - LIST_MARKER_REACH);
|
|
294
|
+
const tail = source.slice(from, end);
|
|
295
|
+
return from === start && LIST_MARKER_ALONE.test(tail) || LIST_MARKER_AFTER_COLON.test(tail);
|
|
296
|
+
}
|
|
297
|
+
function splitSentences(text) {
|
|
298
|
+
const source = (text ?? "").trim();
|
|
299
|
+
if (!source) return [];
|
|
300
|
+
const sentences = [];
|
|
301
|
+
let start = 0;
|
|
302
|
+
let scanned = 0;
|
|
303
|
+
let sawLetter = false;
|
|
304
|
+
let i = 0;
|
|
305
|
+
while (i < source.length) {
|
|
306
|
+
if (!isStop(source[i])) {
|
|
307
|
+
i++;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
let endsStrong = false;
|
|
311
|
+
while (i < source.length && isStop(source[i])) {
|
|
312
|
+
if (source[i] !== ".") endsStrong = true;
|
|
313
|
+
i++;
|
|
314
|
+
}
|
|
315
|
+
while (i < source.length && CLOSERS.has(source[i])) i++;
|
|
316
|
+
let next = i;
|
|
317
|
+
while (next < source.length && isSpace(source[next])) next++;
|
|
318
|
+
if (next === i || !startsSentence(source, next)) continue;
|
|
319
|
+
if (!sawLetter) sawLetter = LETTER.test(source.slice(scanned, i));
|
|
320
|
+
scanned = i;
|
|
321
|
+
if (!sawLetter) continue;
|
|
322
|
+
if (!endsStrong && (endsWithAbbreviation(source, i, digitFollows(source, next)) || endsWithListMarker(source, start, i))) continue;
|
|
323
|
+
sentences.push(source.slice(start, i).trim());
|
|
324
|
+
start = i;
|
|
325
|
+
sawLetter = false;
|
|
326
|
+
}
|
|
327
|
+
const rest = source.slice(start).trim();
|
|
328
|
+
if (rest) sentences.push(rest);
|
|
329
|
+
return sentences;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// src/contracts/business-claims.ts
|
|
333
|
+
var BUSINESS_CLAIMS_CONTRACT_VERSION = 1;
|
|
334
|
+
var NAMED_ENTITIES = {
|
|
335
|
+
nbsp: " ",
|
|
336
|
+
amp: "&",
|
|
337
|
+
lt: "<",
|
|
338
|
+
gt: ">",
|
|
339
|
+
quot: '"',
|
|
340
|
+
apos: "'",
|
|
341
|
+
lsquo: "\u2018",
|
|
342
|
+
rsquo: "\u2019",
|
|
343
|
+
ldquo: "\u201C",
|
|
344
|
+
rdquo: "\u201D",
|
|
345
|
+
sbquo: "\u201A",
|
|
346
|
+
bdquo: "\u201E",
|
|
347
|
+
ndash: "\u2013",
|
|
348
|
+
mdash: "\u2014",
|
|
349
|
+
minus: "\u2212",
|
|
350
|
+
hellip: "\u2026",
|
|
351
|
+
middot: "\xB7",
|
|
352
|
+
bull: "\u2022",
|
|
353
|
+
copy: "\xA9",
|
|
354
|
+
reg: "\xAE",
|
|
355
|
+
trade: "\u2122",
|
|
356
|
+
deg: "\xB0",
|
|
357
|
+
times: "\xD7",
|
|
358
|
+
divide: "\xF7",
|
|
359
|
+
euro: "\u20AC",
|
|
360
|
+
pound: "\xA3",
|
|
361
|
+
cent: "\xA2",
|
|
362
|
+
yen: "\xA5",
|
|
363
|
+
sect: "\xA7",
|
|
364
|
+
para: "\xB6",
|
|
365
|
+
frac12: "\xBD",
|
|
366
|
+
frac14: "\xBC",
|
|
367
|
+
frac34: "\xBE",
|
|
368
|
+
laquo: "\xAB",
|
|
369
|
+
raquo: "\xBB",
|
|
370
|
+
thinsp: "\u2009",
|
|
371
|
+
ensp: "\u2002",
|
|
372
|
+
emsp: "\u2003",
|
|
373
|
+
zwnj: "",
|
|
374
|
+
zwj: "",
|
|
375
|
+
shy: ""
|
|
376
|
+
};
|
|
377
|
+
function decodeHtmlEntities(text) {
|
|
378
|
+
if (typeof text !== "string") return "";
|
|
379
|
+
if (!text.includes("&")) return text;
|
|
380
|
+
return text.replace(/&(#x[0-9a-f]{1,6}|#[0-9]{1,7}|[a-z][a-z0-9]{1,31});/gi, (whole, ref) => {
|
|
381
|
+
if (ref[0] === "#") {
|
|
382
|
+
const code = ref[1] === "x" || ref[1] === "X" ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10);
|
|
383
|
+
if (!Number.isFinite(code) || code <= 0 || code > 1114111 || code >= 55296 && code <= 57343) return whole;
|
|
384
|
+
return code === 160 ? " " : String.fromCodePoint(code);
|
|
385
|
+
}
|
|
386
|
+
const named = NAMED_ENTITIES[ref.toLowerCase()];
|
|
387
|
+
return named === void 0 ? whole : named;
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
var MIN_QUOTE_CHARS = 12;
|
|
391
|
+
var INVISIBLE = /[----︀-️]|\uDB40[\uDC00-\uDC7F]/g;
|
|
392
|
+
function foldMatchable(text) {
|
|
393
|
+
return text.normalize("NFKC").replace(INVISIBLE, "").replace(/[‘’‚‛′`´]/g, "'").replace(/[“”„‟″]/g, '"').replace(/[‐-―−]/g, "-").replace(/\s+/g, " ").replace(/\s*-\s*/g, "-").replace(/\$\s+(?=\d)/g, "$").trim().toLowerCase();
|
|
394
|
+
}
|
|
395
|
+
function normalizeForQuote(text) {
|
|
396
|
+
const raw = typeof text === "string" ? text : typeof text === "number" ? String(text) : "";
|
|
397
|
+
return foldMatchable(decodeHtmlEntities(raw));
|
|
398
|
+
}
|
|
399
|
+
function normalizeMatchable(text) {
|
|
400
|
+
return normalizeForQuote(text);
|
|
401
|
+
}
|
|
402
|
+
function isWholeSpan(haystack, needle, at) {
|
|
403
|
+
const first = needle[0];
|
|
404
|
+
const last = needle[needle.length - 1];
|
|
405
|
+
const before = haystack.slice(Math.max(0, at - 2), at);
|
|
406
|
+
const after = haystack.slice(at + needle.length, at + needle.length + 2);
|
|
407
|
+
const glueBefore = /\d/.test(first) && /(\d|\d[.,])$/.test(before) || /\p{L}/u.test(first) && /\p{L}$/u.test(before);
|
|
408
|
+
const glueAfter = /\d/.test(last) && /^(\d|[.,]\d|k(?!\p{L}))/u.test(after) || /\p{L}/u.test(last) && /^\p{L}/u.test(after);
|
|
409
|
+
return !glueBefore && !glueAfter;
|
|
410
|
+
}
|
|
411
|
+
function wholeSpanAt(haystack, needle) {
|
|
412
|
+
if (!needle) return -1;
|
|
413
|
+
for (let at = haystack.indexOf(needle); at >= 0; at = haystack.indexOf(needle, at + 1)) {
|
|
414
|
+
if (isWholeSpan(haystack, needle, at)) return at;
|
|
415
|
+
}
|
|
416
|
+
return -1;
|
|
417
|
+
}
|
|
418
|
+
function wholeSpansOf(haystack, needle) {
|
|
419
|
+
const out = [];
|
|
420
|
+
if (!needle) return out;
|
|
421
|
+
for (let at = haystack.indexOf(needle); at >= 0; at = haystack.indexOf(needle, at + 1)) {
|
|
422
|
+
if (isWholeSpan(haystack, needle, at)) out.push(at);
|
|
423
|
+
}
|
|
424
|
+
return out;
|
|
425
|
+
}
|
|
426
|
+
function quoteFoundIn(quote, normalizedSource, minChars = MIN_QUOTE_CHARS) {
|
|
427
|
+
const needle = normalizeForQuote(quote);
|
|
428
|
+
return needle.length >= minChars && wholeSpanAt(normalizedSource, needle) >= 0;
|
|
429
|
+
}
|
|
430
|
+
var MONEY_TOKEN = /\$\d[\d,]*(?:\.\d+)?(?:k(?![a-z]))?/g;
|
|
431
|
+
function moneyAmounts(normalized) {
|
|
432
|
+
return (normalized.match(MONEY_TOKEN) ?? []).map((token) => {
|
|
433
|
+
const thousands = token.endsWith("k");
|
|
434
|
+
const n = Number(token.replace(/[$,k]/g, "").replace(/\.$/, ""));
|
|
435
|
+
return thousands ? n * 1e3 : n;
|
|
436
|
+
}).filter((n) => Number.isFinite(n));
|
|
437
|
+
}
|
|
438
|
+
var PRICE_RANGE_BEFORE = /(?:\b(?:from|starting at|starts at|as low as|up to|under|over|at least)|\dk?\s*(?:-|to))\s*$/;
|
|
439
|
+
var PRICE_RANGE_AFTER = /^\s*(?:\+|-\s*\$?\d|to\s+\$?\d)/;
|
|
440
|
+
function checkQuotedPrice(display, quote) {
|
|
441
|
+
const shown = normalizeForQuote(display);
|
|
442
|
+
const text = normalizeForQuote(quote);
|
|
443
|
+
if (!shown) return null;
|
|
444
|
+
const at = wholeSpanAt(text, shown);
|
|
445
|
+
if (at < 0) return null;
|
|
446
|
+
if (PRICE_RANGE_BEFORE.test(text.slice(Math.max(0, at - 16), at)) || PRICE_RANGE_AFTER.test(text.slice(at + shown.length, at + shown.length + 16))) {
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
const amounts = moneyAmounts(shown);
|
|
450
|
+
if (!amounts.length) return /\bfree\b/.test(shown) ? {} : null;
|
|
451
|
+
const min = Math.min(...amounts);
|
|
452
|
+
const max = Math.max(...amounts);
|
|
453
|
+
if (amounts.length === 1 && (/\$[\d,.]+k?\+/.test(shown) || /^(?:from|starting at|starts at)\b/.test(shown))) return { min };
|
|
454
|
+
return { min, max };
|
|
455
|
+
}
|
|
456
|
+
function numbersIn(normalized) {
|
|
457
|
+
return (normalized.match(/\d[\d,.]*/g) ?? []).map((n) => n.replace(/[.,]+$/, "").replace(/,/g, "")).filter(Boolean);
|
|
458
|
+
}
|
|
459
|
+
function numbersGrounded(value, quote) {
|
|
460
|
+
const have = new Set(numbersIn(normalizeForQuote(quote)));
|
|
461
|
+
return numbersIn(normalizeForQuote(value)).every((n) => have.has(n));
|
|
462
|
+
}
|
|
463
|
+
function removeDashes(text) {
|
|
464
|
+
return text.replace(/(\d)\s*[–—]\s*(?=\$)/g, "$1 - ").replace(/(\d)\s*[–—]\s*(?=\d)/g, "$1-").replace(/\s+–\s+(?=\d)/g, " - ").replace(/(\w)–(?=\w)/g, "$1-").replace(/\s*[–—]\s*/g, ", ").replace(/,\s*,/g, ",");
|
|
465
|
+
}
|
|
466
|
+
var WRITER_NOTE_PATTERNS = [
|
|
467
|
+
/\bunverified\b/i,
|
|
468
|
+
/\bwithout (?:attributing|assuming|speculating)\b/i,
|
|
469
|
+
/\bowner notes\b/i,
|
|
470
|
+
/\b(?:real data|measured) block\b/i,
|
|
471
|
+
/\bthe (?:data|evidence|information) (?:provided|given|supplied)\b/i,
|
|
472
|
+
/\b(?:can(?:no|['’])t|could(?:n['’]t| not)) (?:be )?(?:verif(?:y|ied)|confirm(?:ed)?)\b/i,
|
|
473
|
+
/\bnot recorded\b/i
|
|
474
|
+
];
|
|
475
|
+
function isWriterNote(text) {
|
|
476
|
+
if (typeof text !== "string" || !text) return false;
|
|
477
|
+
return WRITER_NOTE_PATTERNS.some((pattern) => pattern.test(text));
|
|
478
|
+
}
|
|
479
|
+
var ATTRS = `(?:[^>"']|"[^"]*"|'[^']*')*`;
|
|
480
|
+
var IMG_TAG = new RegExp(`<img\\b${ATTRS}>`, "gi");
|
|
481
|
+
var LINK = new RegExp(`<a\\b(${ATTRS})>([\\s\\S]*?)<\\/a\\s*>`, "gi");
|
|
482
|
+
var TITLED_TAG = new RegExp(`<[a-z][a-z0-9-]*\\s${ATTRS}\\btitle\\s*=${ATTRS}>`, "gi");
|
|
483
|
+
var BLOCK_TAG = new RegExp(`<\\/?(?:p|div|li|ul|ol|h[1-6]|tr|table|tbody|thead|tfoot|blockquote|section|article|header|footer|hr|pre|center)\\b${ATTRS}>`, "gi");
|
|
484
|
+
var CELL_TAG = new RegExp(`<\\/?(?:td|th)\\b${ATTRS}>`, "gi");
|
|
485
|
+
var ANY_TAG = new RegExp(`<\\/?[a-z][a-z0-9:-]*(?:\\s${ATTRS})?\\/?>`, "gi");
|
|
486
|
+
function attributeOf(tag, name) {
|
|
487
|
+
const found = new RegExp(`[\\s"']${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, "i").exec(tag);
|
|
488
|
+
return found ? found[1] ?? found[2] ?? found[3] ?? "" : null;
|
|
489
|
+
}
|
|
490
|
+
function linkTextIsAddress(text, href) {
|
|
491
|
+
const shown = text.trim().toLowerCase().replace(/\/$/, "");
|
|
492
|
+
const target = href.trim().toLowerCase().replace(/\/$/, "");
|
|
493
|
+
return !!shown && (shown === target || `mailto:${shown}` === target || `tel:${shown}` === target);
|
|
494
|
+
}
|
|
495
|
+
function outboundPlainText(body, format) {
|
|
496
|
+
const raw = typeof body === "string" ? body : "";
|
|
497
|
+
if (format !== "html") return decodeHtmlEntities(raw);
|
|
498
|
+
const html = raw.replace(/<!--[\s\S]*?(?:-->|$)/g, " ").replace(/<(script|style)\b[\s\S]*?(?:<\/\1\s*>|$)/gi, " ").replace(/\s+/g, " ").replace(IMG_TAG, (tag) => {
|
|
499
|
+
const words = [attributeOf(tag, "alt"), attributeOf(tag, "title")].filter((v) => !!v && !!v.trim());
|
|
500
|
+
return words.length ? `
|
|
501
|
+
${words.join("\n")}
|
|
502
|
+
` : "";
|
|
503
|
+
}).replace(LINK, (_whole, attrs, inner) => {
|
|
504
|
+
const tag = ` ${attrs}`;
|
|
505
|
+
const href = attributeOf(tag, "href");
|
|
506
|
+
const title = attributeOf(tag, "title");
|
|
507
|
+
const shown = decodeHtmlEntities(inner.replace(ANY_TAG, "")).replace(/\s+/g, " ");
|
|
508
|
+
const target = href === null ? "" : decodeHtmlEntities(href).trim();
|
|
509
|
+
const showTarget = !!target && !/^(?:#|javascript:)/i.test(target) && !linkTextIsAddress(shown, target);
|
|
510
|
+
return `${inner}${showTarget ? ` (${href.trim()})` : ""}${title && title.trim() ? `
|
|
511
|
+
${title}
|
|
512
|
+
` : ""}`;
|
|
513
|
+
}).replace(TITLED_TAG, (tag) => {
|
|
514
|
+
const title = attributeOf(tag, "title");
|
|
515
|
+
return title && title.trim() ? `${tag}
|
|
516
|
+
${title}
|
|
517
|
+
` : tag;
|
|
518
|
+
}).replace(/<br\s*\/?>/gi, "\n").replace(BLOCK_TAG, "\n").replace(CELL_TAG, " ").replace(ANY_TAG, "");
|
|
519
|
+
return decodeHtmlEntities(html).split(/\r\n?|\n/).map((line) => line.replace(/\s+/g, " ").trim()).filter(Boolean).join("\n");
|
|
520
|
+
}
|
|
521
|
+
var OUTBOUND_AUTHORS = ["signal", "agent", "operator"];
|
|
522
|
+
function fnv1a(text) {
|
|
523
|
+
let hash = 2166136261;
|
|
524
|
+
for (let i = 0; i < text.length; i++) {
|
|
525
|
+
hash ^= text.charCodeAt(i);
|
|
526
|
+
hash = Math.imul(hash, 16777619);
|
|
527
|
+
}
|
|
528
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
529
|
+
}
|
|
530
|
+
function outboundFindingId(code, sentence, match) {
|
|
531
|
+
return fnv1a(`${code}\0${foldMatchable(sentence)}\0${foldMatchable(match)}`);
|
|
532
|
+
}
|
|
533
|
+
var DEFAULT_COPY = {
|
|
534
|
+
empty_subject: { reason: "The subject line is empty.", fix: "Add a short subject." },
|
|
535
|
+
empty_body: { reason: "The message has almost nothing in it.", fix: "Write the message before it goes out." },
|
|
536
|
+
unrendered_token: { reason: "It has a placeholder that was never filled in.", fix: "Put in the real value or take it out." },
|
|
537
|
+
platform_banned_claim: { reason: "It makes a promise Sonor never lets an email make.", fix: "Take the promise out and say what you'll actually do." },
|
|
538
|
+
project_banned_claim: { reason: "It says something the Business Brief says never to claim.", fix: "Take it out or say it another way." },
|
|
539
|
+
forbidden_phrase: { reason: "It uses a phrase this outreach angle never says.", fix: "Take it out or say it another way." },
|
|
540
|
+
price_mismatch: { reason: "It names a price that doesn't match a published one.", fix: "Use the price as published, or leave the price out." },
|
|
541
|
+
mixed_script: { reason: "A word mixes letters from two alphabets, so it only looks like a normal word.", fix: "Retype it with ordinary letters." },
|
|
542
|
+
dash: { reason: "It uses an em or en dash.", fix: "Use a comma, a period or a plain hyphen." },
|
|
543
|
+
unsafe_markup: { reason: "The HTML has markup email clients strip or flag as spam.", fix: "Send plain HTML without scripts, styles or event handlers." },
|
|
544
|
+
prompt_control_token: { reason: "It has text meant for an AI, not the reader.", fix: "Take it out." },
|
|
545
|
+
price_unverifiable: { reason: "It names a price that isn't published, so it can't be checked.", fix: "Publish your prices in the Business Brief, or check this one by hand." },
|
|
546
|
+
unsupported_number: { reason: "Nothing on record backs up a number in it.", fix: "Cite a verified fact from the Business Brief, or take the number out." },
|
|
547
|
+
citation_invalid: { reason: "It cites a fact that isn't in the Business Brief.", fix: "Cite a verified fact, or take the claim out." },
|
|
548
|
+
citation_mismatch: { reason: "A number in it isn't in the source it cites.", fix: "Match what the source says, or take it out." },
|
|
549
|
+
citation_wrong_source: { reason: "It makes a claim about the sender that only research about the recipient backs.", fix: "Cite a fact from the Business Brief, or take it out." },
|
|
550
|
+
banned_claim_negated: { reason: "It brings up a promise Sonor never lets an email make, while saying it can't make it.", fix: "Keep it only if it clearly reads as a disclaimer." },
|
|
551
|
+
writer_note: { reason: "It reads like a note about how the copy was written, not something for the reader.", fix: "Take it out or say what's true instead." },
|
|
552
|
+
number_from_research: { reason: "A number in it comes from the research about the recipient.", fix: "Make sure it's still true before it goes out." },
|
|
553
|
+
audit_flag: { reason: "Signal's fact check flagged this.", fix: "Check it's true before you approve it." },
|
|
554
|
+
audit_not_run: { reason: "Signal couldn't check this email's claims.", fix: "Read it over before you approve it." },
|
|
555
|
+
uncontracted: { reason: "It reads stiff.", fix: "Use the contraction." },
|
|
556
|
+
first_person_firm: { reason: "It says I for work the business does.", fix: "Say we for the business, and keep I for what you noticed yourself." }
|
|
557
|
+
};
|
|
558
|
+
function makeOutboundFinding(code, sentence, match, extra = {}) {
|
|
559
|
+
const copy = DEFAULT_COPY[code] ?? { reason: "Check this before it goes out.", fix: "Fix it or take it out." };
|
|
560
|
+
const finding = {
|
|
561
|
+
id: outboundFindingId(code, sentence, match),
|
|
562
|
+
code,
|
|
563
|
+
sentence,
|
|
564
|
+
match,
|
|
565
|
+
reason: extra.reason ?? copy.reason,
|
|
566
|
+
fix: extra.fix ?? copy.fix
|
|
567
|
+
};
|
|
568
|
+
if (extra.fact_id) finding.fact_id = extra.fact_id;
|
|
569
|
+
return finding;
|
|
570
|
+
}
|
|
571
|
+
var PLATFORM_BANNED_CLAIMS = [
|
|
572
|
+
{ id: "guaranteed_ranking", description: "Guarantees or promises a search ranking (page one, first page, a top spot)." },
|
|
573
|
+
{ id: "number_one_on_google", description: "Promises a #1 or top spot on Google." },
|
|
574
|
+
{ id: "guaranteed_results", description: "Guarantees results, leads, traffic, sales or a growth figure." },
|
|
575
|
+
{ id: "hundred_percent_guaranteed", description: "Promises a 100% guarantee." },
|
|
576
|
+
{ id: "risk_free", description: "Calls an offer risk free or zero risk." },
|
|
577
|
+
{ id: "fake_scarcity", description: "Manufactured urgency: spots left, limited-time offers, act now, last chance." },
|
|
578
|
+
{ id: "google_endorsed", description: "Says Google endorses, approves or recommends the sender, or that the sender writes for Google." },
|
|
579
|
+
{
|
|
580
|
+
id: "pay_nothing_guarantee",
|
|
581
|
+
description: `Promises the prospect pays nothing, pays only for results or gets their money back: "or you pay nothing", "no results, no fee", "if we don't, you don't pay", a money-back or refund guarantee.`
|
|
582
|
+
}
|
|
583
|
+
];
|
|
584
|
+
var PLATFORM_CLAIM_REASON = {
|
|
585
|
+
guaranteed_ranking: "It promises a search ranking, and nobody controls where Google ranks a site.",
|
|
586
|
+
number_one_on_google: "It promises a #1 spot on Google, which nobody can promise.",
|
|
587
|
+
guaranteed_results: "It guarantees results that depend on things nobody controls.",
|
|
588
|
+
hundred_percent_guaranteed: "It promises a 100% guarantee.",
|
|
589
|
+
risk_free: "It calls the offer risk free.",
|
|
590
|
+
fake_scarcity: "It pushes urgency or scarcity that isn't real.",
|
|
591
|
+
google_endorsed: "It says Google endorses or works with the sender.",
|
|
592
|
+
pay_nothing_guarantee: "It promises they pay nothing or get their money back, and outreach never offers that."
|
|
593
|
+
};
|
|
594
|
+
var PLATFORM_CLAIM_NAME = {
|
|
595
|
+
guaranteed_ranking: "a guaranteed ranking",
|
|
596
|
+
number_one_on_google: "a #1 spot on Google",
|
|
597
|
+
guaranteed_results: "guaranteed results",
|
|
598
|
+
hundred_percent_guaranteed: "a 100% guarantee",
|
|
599
|
+
risk_free: "a risk free offer",
|
|
600
|
+
fake_scarcity: "urgency",
|
|
601
|
+
google_endorsed: "Google's backing",
|
|
602
|
+
pay_nothing_guarantee: "a pay-nothing guarantee"
|
|
603
|
+
};
|
|
604
|
+
var GUARANTEE = String.raw`(?:guarantee[sd]?|guaranteeing|guaranty|promise[sd]?|promising)`;
|
|
605
|
+
var CLAUSE_GAP = String.raw`(?:(?!\b(?:or|but|and|so|because|if|unless|until|while)\b)[^.!?;:,]){0,60}?`;
|
|
606
|
+
var RANK_WORD = String.raw`(?<![a-z0-9])(?:rank(?:s|ed|ing|ings)?(?![a-z])|page[- ]?(?:one|1)(?![a-z0-9])|(?:first|front)-?page|(?:first|front) page(?=\s*(?:$|[.!?;:,)])|\s+(?:on|of|in)\s+(?:google|bing|search)|\s+(?:rankings?|results?|placements?|positions?|spots?)(?![a-z]))|top (?:spot|position|result|ranking)s?|#\s?1(?![0-9])|number-?one|no\.\s?1(?![0-9])|first place|top[- ]?(?:\d{1,2}|three|five|ten)(?![0-9a-z%])(?!\s?(?:percent|seconds?|secs?|minutes?|mins?|hours?|days?|weeks?|months?|years?)(?![a-z]))|(?:map|local|3)-? ?pack(?![a-z]))`;
|
|
607
|
+
var RESULT_WORD = String.raw`(?<![a-z0-9])(?:results?|leads?|traffic|sales|revenue|roi|growth|customers|clients|calls|bookings|conversions|more business|(?:an? )?(?:increase|boost|spike)|\d+(?:\.\d+)?\s?(?:%|x(?![a-z])|percent))(?![a-z])`;
|
|
608
|
+
var TOP_OF_GOOGLE = String.raw`(?<![a-z0-9])(?:#\s?1|number-?one|no\.\s?1|first (?:place|position|spot|result)|(?:the )?top (?:spot|position|result)|(?:the )?top)\s+(?:spot\s+|position\s+|result\s+|ranking\s+)?(?:on|of|in)\s+google(?![a-z])`;
|
|
609
|
+
var AFTER_GUARANTEE = String.raw`(?:\s*:)?${CLAUSE_GAP}`;
|
|
610
|
+
var GUARANTEED_AFTER_OUTCOME = String.raw`(?:${RESULT_WORD}|\b(?:will|going to)\b|'ll\b)[^.!?;:]{0,80}?,\s*guaranteed(?=\s*(?:[.!?]|$))`;
|
|
611
|
+
var TO_THE_TOP = String.raw`\b(?:get|gets|got|getting|put|puts|putting|take|takes|took|taking|land|lands|landed|landing|bring|brings|brought|push|pushes|pushed|move|moves|moved|help|helps|helped|rank|ranks|ranked|ranking|be|keep|keeps|kept)\b${CLAUSE_GAP}`;
|
|
612
|
+
var PAY_NOTHING = [
|
|
613
|
+
String.raw`\bor (?:else )?(?:you|they|you'll|you will) (?:pay nothing|pay \$?0(?![0-9])|owe (?:us )?nothing|don't pay|do not pay|won't pay|will not pay|don't owe|get (?:it (?:for )?free|(?:a |your |all your |every penny |a full )?(?:money|refund)(?: back)?))`,
|
|
614
|
+
String.raw`\bor (?:it's|its|it is|that's|the (?:work|site|month|project|first month) is) (?:free|on us|on the house)\b`,
|
|
615
|
+
String.raw`\bor (?:we|we'll|we will) (?:refund|give (?:you )?(?:your |all your |every penny of your )?money back|work for free|waive)`,
|
|
616
|
+
String.raw`\bor (?:your|their) money back\b`,
|
|
617
|
+
String.raw`\b(?:pay nothing|don't pay(?: a (?:dime|cent|penny|thing))?|do not pay|owe (?:us )?nothing|pay \$?0(?![0-9]))\b[^.!?;:]{0,20}?\b(?:unless|until|if)\b`,
|
|
618
|
+
String.raw`\bno (?:results?|leads?|rankings?|growth|sales|calls|traffic|improvement),? no (?:pay|fees?|charge|cost|bill|invoice|payment)\b`,
|
|
619
|
+
String.raw`\b(?:only )?pay (?:only )?(?:for|when|if|once|after) (?:you |we )?(?:see |get |deliver |hit |start (?:to )?see(?:ing)? )?(?:real )?(?:results?|leads?|rankings?|growth|sales|more (?:calls|leads|customers|business))\b`,
|
|
620
|
+
String.raw`\bif (?:we|it|the site|this) (?:don't|doesn't|do not|does not|can't|cannot|fail to|won't|didn't)\b[^.!?;:]{0,80}?\b(?:you don't pay|you do not pay|you won't pay|you pay nothing|you owe nothing|don't pay|pay nothing|owe nothing|(?:you )?get (?:your |a full |a )?(?:money back|refund)|(?:it's|its|it is) free|we(?:'ll| will)? refund)`,
|
|
621
|
+
String.raw`\b(?:money-?back|money back|full-?refund|refund|pay-?nothing|no-?pay) guarantee[sd]?\b|\bguaranteed? (?:a )?(?:full )?refund\b|\bfull refund if\b|\bfree (?:if|unless) (?:we|it)\b`
|
|
622
|
+
];
|
|
623
|
+
var PLATFORM_PATTERNS = [
|
|
624
|
+
{
|
|
625
|
+
id: "guaranteed_ranking",
|
|
626
|
+
pattern: new RegExp(
|
|
627
|
+
String.raw`\b${GUARANTEE}\b${AFTER_GUARANTEE}${RANK_WORD}|${RANK_WORD}[^.!?;:]{0,30}?\b(?:are |is |will be )?(?:not |never |n't )?guaranteed\b`,
|
|
628
|
+
"g"
|
|
629
|
+
)
|
|
630
|
+
},
|
|
631
|
+
{ id: "number_one_on_google", pattern: new RegExp(`${TO_THE_TOP}${TOP_OF_GOOGLE}`, "g"), verb: true },
|
|
632
|
+
{
|
|
633
|
+
id: "guaranteed_results",
|
|
634
|
+
pattern: new RegExp(
|
|
635
|
+
String.raw`\b${GUARANTEE}\b${AFTER_GUARANTEE}${RESULT_WORD}|(?<![a-z])(?:results?|leads|traffic|sales|revenue|roi|growth|conversions),?\s+(?:are\s+|is\s+|will be\s+)?guaranteed\b|${GUARANTEED_AFTER_OUTCOME}`,
|
|
636
|
+
"g"
|
|
637
|
+
)
|
|
638
|
+
},
|
|
639
|
+
{
|
|
640
|
+
id: "hundred_percent_guaranteed",
|
|
641
|
+
pattern: /(?:100\s?%|100 percent|(?:one )?hundred percent)\s+(?:satisfaction\s+)?(?:guaranteed|guarantee[sd]?|money-back|risk-?free|results)\b|\bguaranteed\s+100\s?%/g
|
|
642
|
+
},
|
|
643
|
+
{ id: "risk_free", pattern: /\brisk-?free\b|\brisk free\b|\bzero-?risk\b|\bno risk (?:to you|whatsoever|at all|involved)\b|\bwithout any risk\b/g },
|
|
644
|
+
{
|
|
645
|
+
id: "fake_scarcity",
|
|
646
|
+
pattern: /\bonly \d+ (?:spots?|slots?|openings?|places?|seats?|positions?)\b[^.!?]{0,20}?\b(?:left|remaining|available)\b|\b(?:\d+|few|limited) (?:spots?|slots?|openings?) (?:left|remaining)\b|\blimited-?time (?:offer|only|deal|discount|pricing|price)\b|\blimited time (?:offer|only|deal|discount|pricing|price)\b|\bact (?:now|fast|quickly)\b|\b(?:offer|deal|discount|pricing|price|sale) (?:ends|expires) (?:today|tonight|tomorrow|soon|at midnight|this week|friday)\b|\blast chance\b|\b(?:offer|deal|price|pricing|discount)s? (?:won't|will not|wont) last\b|\bspots? (?:are )?filling (?:up )?fast\b|\bwhile (?:spots|supplies) last\b/g
|
|
647
|
+
},
|
|
648
|
+
{
|
|
649
|
+
id: "google_endorsed",
|
|
650
|
+
verb: true,
|
|
651
|
+
pattern: /\b(?:endorsed|approved|recommended|backed|sponsored|authorized) by google\b|\bgoogle-(?:endorsed|approved|recommended|backed|sponsored|authorized)\b|\bon behalf of google\b|\b(?:calling|writing|reaching out|emailing) from google\b|\bgoogle (?:recommends|endorses|approves of) (?:us|our)\b/g
|
|
652
|
+
},
|
|
653
|
+
{ id: "pay_nothing_guarantee", pattern: new RegExp(PAY_NOTHING.join("|"), "g") }
|
|
654
|
+
];
|
|
655
|
+
var GUARANTEE_WORD = new RegExp(String.raw`\b${GUARANTEE}\b`, "g");
|
|
656
|
+
var GOVERNING_NEGATION = /(?:^|[^a-z'])(?:can't|cannot|cant|can not|don't|dont|do not|won't|wont|will not|never|not|isn't|aren't|doesn't|does not|didn't|couldn't|wouldn't|shouldn't|wasn't|weren't|no one can|nobody can|no one could|nobody could)\s+$/;
|
|
657
|
+
var NEVER_SUPPRESS = /(?:^|[^a-z'])(?:not only|no other|don't settle|don't wait|don't miss)\b[^.!?;:,]*$/;
|
|
658
|
+
function governingVerbsAt(text, start, end, leadingVerb) {
|
|
659
|
+
const inside = new RegExp(GUARANTEE_WORD.source, "g").exec(text.slice(start, end));
|
|
660
|
+
if (inside) return [start + inside.index];
|
|
661
|
+
const verbs = leadingVerb ? [start] : [];
|
|
662
|
+
let clauseStart = 0;
|
|
663
|
+
for (let i = start - 1; i >= 0; i--) {
|
|
664
|
+
if (".!?;:,\n".includes(text[i])) {
|
|
665
|
+
clauseStart = i + 1;
|
|
666
|
+
break;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
let last = -1;
|
|
670
|
+
for (const found of text.slice(clauseStart, start).matchAll(new RegExp(GUARANTEE_WORD.source, "g"))) last = clauseStart + (found.index ?? 0);
|
|
671
|
+
if (last >= 0) verbs.push(last);
|
|
672
|
+
return verbs;
|
|
673
|
+
}
|
|
674
|
+
function negatedHit(text, start, end, leadingVerb) {
|
|
675
|
+
return governingVerbsAt(text, start, end, leadingVerb).some((verb) => {
|
|
676
|
+
const before = text.slice(Math.max(0, verb - 40), verb);
|
|
677
|
+
return !NEVER_SUPPRESS.test(before) && GOVERNING_NEGATION.test(before);
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
function platformHits(norm) {
|
|
681
|
+
const hits = [];
|
|
682
|
+
for (const { id, pattern, verb } of PLATFORM_PATTERNS) {
|
|
683
|
+
for (const found of norm.matchAll(pattern)) {
|
|
684
|
+
const index = found.index ?? 0;
|
|
685
|
+
hits.push({ id, index, match: found[0], negated: negatedHit(norm, index, index + found[0].length, verb === true) });
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
return hits.sort((a, b) => a.index - b.index);
|
|
689
|
+
}
|
|
690
|
+
function findPlatformBannedClaims(text) {
|
|
691
|
+
return platformHits(normalizeForQuote(text));
|
|
692
|
+
}
|
|
693
|
+
function phraseHits(norm, phrases) {
|
|
694
|
+
const hits = [];
|
|
695
|
+
for (const phrase of phrases ?? []) {
|
|
696
|
+
if (typeof phrase !== "string") continue;
|
|
697
|
+
const needle = normalizeForQuote(phrase);
|
|
698
|
+
if (needle.length < 2) continue;
|
|
699
|
+
for (const index of wholeSpansOf(norm, needle)) hits.push({ phrase, index, match: needle });
|
|
700
|
+
}
|
|
701
|
+
return hits.sort((a, b) => a.index - b.index);
|
|
702
|
+
}
|
|
703
|
+
function findPhrases(text, phrases) {
|
|
704
|
+
return phraseHits(normalizeForQuote(text), phrases);
|
|
705
|
+
}
|
|
706
|
+
var TOKEN_PATTERNS = [
|
|
707
|
+
/\{\{[^{}]{0,80}\}\}/g,
|
|
708
|
+
/\{%[^%]{0,80}%\}/g,
|
|
709
|
+
/\[\[[^[\]]{0,80}\]\]/g,
|
|
710
|
+
/%[a-z][a-z0-9_]{1,40}%/g,
|
|
711
|
+
/\$\{[^{}]{0,80}\}/g,
|
|
712
|
+
/\{[^{}|]{1,80}(?:\|[^{}|]{0,80})+\}/g,
|
|
713
|
+
/\{[a-z_][a-z0-9_. ]{0,40}\}/g,
|
|
714
|
+
/\[(?:(?:your|their|his|her|the|recipient's|prospect's|client's|contact's|sender's)\s)?(?:(?:first|last|full|company|business|contact|recipient|prospect|client|sender|agency|website|site|city|state|job)\s)?(?:name|first-?name|last-?name|company|business|city|state|website|url|link|title|role|industry|position|niche|location|address|phone|email|date|personali[sz]ation|personali[sz]ed (?:line|opener|intro)|opening line|compliment|pain point|insert [a-z' ]{1,30})\]/g,
|
|
715
|
+
/\[object object\]/g,
|
|
716
|
+
/\b(?:hi|hello|hey|dear|good (?:morning|afternoon|evening)|thanks|thank you)[ ,]+(?:undefined|null|nan)\b/g
|
|
717
|
+
];
|
|
718
|
+
function tokenHits(norm) {
|
|
719
|
+
const hits = [];
|
|
720
|
+
for (const pattern of TOKEN_PATTERNS) {
|
|
721
|
+
for (const found of norm.matchAll(pattern)) hits.push({ index: found.index ?? 0, match: found[0] });
|
|
722
|
+
}
|
|
723
|
+
hits.sort((a, b) => a.index - b.index || b.match.length - a.match.length);
|
|
724
|
+
const kept = [];
|
|
725
|
+
let reach = -1;
|
|
726
|
+
for (const hit of hits) {
|
|
727
|
+
if (hit.index + hit.match.length <= reach) continue;
|
|
728
|
+
kept.push(hit);
|
|
729
|
+
reach = Math.max(reach, hit.index + hit.match.length);
|
|
730
|
+
}
|
|
731
|
+
return kept;
|
|
732
|
+
}
|
|
733
|
+
function findUnrenderedTokens(text) {
|
|
734
|
+
return tokenHits(normalizeForQuote(text));
|
|
735
|
+
}
|
|
736
|
+
var EM_DASHES = /[—―⸺⸻]/;
|
|
737
|
+
function findDashes(text) {
|
|
738
|
+
const source = typeof text === "string" ? text : "";
|
|
739
|
+
const hits = [];
|
|
740
|
+
for (let i = 0; i < source.length; i++) {
|
|
741
|
+
const c = source[i];
|
|
742
|
+
if (EM_DASHES.test(c)) {
|
|
743
|
+
hits.push({ index: i, match: c, kind: "em" });
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
if (c !== "\u2013" && c !== "\u2012") continue;
|
|
747
|
+
let p = i - 1;
|
|
748
|
+
while (p >= 0 && /\s/.test(source[p])) p--;
|
|
749
|
+
let n = i + 1;
|
|
750
|
+
while (n < source.length && /\s/.test(source[n])) n++;
|
|
751
|
+
const before = p >= 0 ? source[p] : "";
|
|
752
|
+
const after = n < source.length ? source[n] : "";
|
|
753
|
+
if (!before || !after) continue;
|
|
754
|
+
if (/\d/.test(before) || /[\d$]/.test(after)) continue;
|
|
755
|
+
const spaced = p !== i - 1 || n !== i + 1;
|
|
756
|
+
if (!spaced && /\p{L}/u.test(before) && /\p{L}/u.test(after)) continue;
|
|
757
|
+
hits.push({ index: i, match: c, kind: "en" });
|
|
758
|
+
}
|
|
759
|
+
return hits;
|
|
760
|
+
}
|
|
761
|
+
function findMixedScript(text) {
|
|
762
|
+
const source = typeof text === "string" ? text.normalize("NFKC").replace(INVISIBLE, "") : "";
|
|
763
|
+
const hits = [];
|
|
764
|
+
for (const found of source.matchAll(/[\p{L}\p{M}]+/gu)) {
|
|
765
|
+
const word = found[0];
|
|
766
|
+
if (word.length < 2) continue;
|
|
767
|
+
if (/\p{Script=Latin}/u.test(word) && /[\p{Script=Cyrillic}\p{Script=Greek}]/u.test(word)) {
|
|
768
|
+
hits.push({ index: found.index ?? 0, match: word });
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
return hits;
|
|
772
|
+
}
|
|
773
|
+
var UNSAFE_MARKUP = [
|
|
774
|
+
{ pattern: /<style\b/i, what: "a style block" },
|
|
775
|
+
{ pattern: /<script\b/i, what: "a script" },
|
|
776
|
+
{ pattern: /<[a-z][a-z0-9-]*\b[^>]*\son[a-z]+\s*=/i, what: "an event handler (on...=)" },
|
|
777
|
+
{ pattern: /\bstyle\s*=\s*(?:"[^"]*\bcontent\s*:|'[^']*\bcontent\s*:)/i, what: "CSS content:" },
|
|
778
|
+
{ pattern: /<(?:iframe|object|embed)\b/i, what: "an embedded frame or object" },
|
|
779
|
+
{ pattern: /\bhref\s*=\s*["']?\s*javascript:/i, what: "a javascript: link" }
|
|
780
|
+
];
|
|
781
|
+
function findUnsafeMarkup(html) {
|
|
782
|
+
const source = typeof html === "string" ? html : "";
|
|
783
|
+
const hits = [];
|
|
784
|
+
for (const { pattern, what } of UNSAFE_MARKUP) {
|
|
785
|
+
const found = pattern.exec(source);
|
|
786
|
+
if (found) hits.push({ index: found.index, match: what });
|
|
787
|
+
}
|
|
788
|
+
return hits;
|
|
789
|
+
}
|
|
790
|
+
var AMOUNT = String.raw`(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?(?:\s?(?:k|m|million|thousand)(?![a-z]))?`;
|
|
791
|
+
var FLOOR_WORDS = /* @__PURE__ */ new Set(["starting at", "starts at", "start at", "starting from", "from", "as low as", "as little as", "at least", "over", "more than"]);
|
|
792
|
+
var PRICE_MENTION = new RegExp(
|
|
793
|
+
String.raw`\b(between)\s+\$(${AMOUNT})\s+and\s+\$?(${AMOUNT})|(?:\b(starting at|starts at|start at|starting from|from|as low as|as little as|up to|under|over|at least|at most|less than|more than|no more than)\s+)?\$(${AMOUNT})(\+)?(?:(?:-|\s+to\s+)\$?(${AMOUNT}))?`,
|
|
794
|
+
"g"
|
|
795
|
+
);
|
|
796
|
+
function amountValue(written) {
|
|
797
|
+
const m = /^([\d,]+(?:\.\d+)?)\s?(k|m|million|thousand)?$/.exec(written.trim());
|
|
798
|
+
if (!m) return NaN;
|
|
799
|
+
const n = Number(m[1].replace(/,/g, ""));
|
|
800
|
+
const unit = m[2];
|
|
801
|
+
return unit === "k" || unit === "thousand" ? n * 1e3 : unit === "m" || unit === "million" ? n * 1e6 : n;
|
|
802
|
+
}
|
|
803
|
+
function priceMentionsIn(norm) {
|
|
804
|
+
const out = [];
|
|
805
|
+
for (const found of norm.matchAll(PRICE_MENTION)) {
|
|
806
|
+
const index = found.index ?? 0;
|
|
807
|
+
if (found[1]) {
|
|
808
|
+
const a = amountValue(found[2]);
|
|
809
|
+
const b = amountValue(found[3]);
|
|
810
|
+
if (!Number.isFinite(a) || !Number.isFinite(b)) continue;
|
|
811
|
+
out.push({ index, match: found[0], shape: "range", min: Math.min(a, b), max: Math.max(a, b), amounts: [found[2], found[3]] });
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
814
|
+
const words = found[4];
|
|
815
|
+
const first = amountValue(found[5]);
|
|
816
|
+
const second = found[7] ? amountValue(found[7]) : NaN;
|
|
817
|
+
if (!Number.isFinite(first)) continue;
|
|
818
|
+
if (found[7] && Number.isFinite(second)) {
|
|
819
|
+
out.push({ index, match: found[0], shape: "range", min: Math.min(first, second), max: Math.max(first, second), amounts: [found[5], found[7]] });
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
const shape = found[6] || words && FLOOR_WORDS.has(words) ? "floor" : words ? "ceiling" : "exact";
|
|
823
|
+
out.push({ index, match: found[0], shape, min: first, max: first, amounts: [found[5]] });
|
|
824
|
+
}
|
|
825
|
+
return out;
|
|
826
|
+
}
|
|
827
|
+
function findPriceMentions(text) {
|
|
828
|
+
return priceMentionsIn(normalizeForQuote(text));
|
|
829
|
+
}
|
|
830
|
+
var sameAmount = (a, b) => Math.abs(a - b) < 5e-3;
|
|
831
|
+
function samePrice(a, b) {
|
|
832
|
+
return a.shape === b.shape && sameAmount(a.min, b.min) && sameAmount(a.max, b.max);
|
|
833
|
+
}
|
|
834
|
+
var NUMBER_MASK = "\u2800";
|
|
835
|
+
var mask = (m) => NUMBER_MASK.repeat(m.length);
|
|
836
|
+
var NOT_CLAIM_NUMBERS = [
|
|
837
|
+
/(?:https?:\/\/|www\.)\S+/g,
|
|
838
|
+
/[\w.+-]+@[\w-]+(?:\.[\w-]+)+/g,
|
|
839
|
+
/\b[a-z0-9-]+(?:\.[a-z0-9-]+)*\.(?:com|net|org|io|co|us|dev|app|ai|biz|info|me|site|online|tech|agency|pro|xyz|ca|uk|de|au)\b(?:\/\S*)?/g,
|
|
840
|
+
/(?:\+?1[-. ]?)?\(?\d{3}\)?[-. ]?\d{3}[-.]\d{4}\b/g,
|
|
841
|
+
/\b\d{1,2}(?::\d{2})?\s?(?:a\.?m\.?|p\.?m\.?)(?![a-z])/g,
|
|
842
|
+
/\b\d{1,2}:\d{2}(?::\d{2})?\b/g,
|
|
843
|
+
/\b\d{1,4}\/\d{1,2}(?:\/\d{2,4})?\b/g,
|
|
844
|
+
/\b\d{4}-\d{1,2}-\d{1,2}\b/g,
|
|
845
|
+
/\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\.?\s\d{1,2}(?:st|nd|rd|th)?(?:,?\s\d{4})?\b/g,
|
|
846
|
+
/\b\d{1,2}(?:st|nd|rd|th)?\s(?:of\s)?(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\.?(?:,?\s\d{4})?\b/g,
|
|
847
|
+
/\b(?:in|within the next|next|every|each|after|per|for the next|over the next)\s(?:a\s)?(?:few\s|couple(?: of)?\s)?\d+\s(?:hours?|days?|weeks?|months?|years?)\b/g,
|
|
848
|
+
/\b\d+-?\s?min(?:ute)?s?\s(?:call|chat|meeting|conversation|demo|walkthrough|consult(?:ation)?|intro|zoom|phone call|review|look)\b/g,
|
|
849
|
+
/\b\d+\smin(?:ute)?s?\s(?:of your time|to (?:chat|talk|connect))\b/g,
|
|
850
|
+
/\b\d+\s(?:[a-z0-9.'-]+\s){0,3}(?:st|street|ave|avenue|rd|road|blvd|boulevard|dr|drive|ln|lane|way|ct|court|pl|place|pkwy|parkway|hwy|highway|cir|circle|ter|terrace)\b/g,
|
|
851
|
+
/(?:\b(?:suite|ste\.?|unit|apt\.?)\s?|#\s?)\d+\b/g,
|
|
852
|
+
/\b[a-z]{2}\s\d{5}(?:-\d{4})?\b/g,
|
|
853
|
+
/(?<!\b(?:since|founded in|established in|started in|opened in|est\.?)\s)\b(?:19|20)\d{2}\b/g,
|
|
854
|
+
/\b\d+(?:st|nd|rd|th)\b/g,
|
|
855
|
+
/\b(?:301|302|400|401|403|404|410|500|502|503)(?=[\s-]?(?:page|error|status|code|redirect|not found)s?\b)/g,
|
|
856
|
+
/(?<=[a-z])\d+(?:\.\d+)?/g,
|
|
857
|
+
/\b\d+(?:\.\d+)?(?=[a-z])(?!(?:k|x|m)(?![a-z]))[a-z]+/g
|
|
858
|
+
];
|
|
859
|
+
var CLAIM_NOUN_AFTER = /^(?:\s?-\s?|\s)(?:[a-z'-]+\s)?(?:clients?|customers?|projects?|sites?|websites?|pages?|years?|months?|weeks?|days?|hours?|minutes?|mins?|seconds?|secs?|ms|milliseconds?|reviews?|stars?|ratings?|awards?|businesses|business|companies|company|brands?|leads?|calls?|jobs?|homes?|houses?|locations?|offices?|employees?|people|staff|members?|partners?|cities|states?|countries|campaigns?|apps?|stores?|restaurants?|patients?|students?|users?|visitors?|visits?|sales|deals?|installs?|orders?|bookings?|appointments?|referrals?|points?|positions?|spots?|keywords?|backlinks?|links?|errors?|issues?)\b/;
|
|
860
|
+
function claimNumbersIn(norm, excludeMoney) {
|
|
861
|
+
let masked = norm;
|
|
862
|
+
if (excludeMoney) {
|
|
863
|
+
for (const price of priceMentionsIn(norm)) masked = masked.slice(0, price.index) + mask(price.match) + masked.slice(price.index + price.match.length);
|
|
864
|
+
}
|
|
865
|
+
for (const pattern of NOT_CLAIM_NUMBERS) masked = masked.replace(pattern, mask);
|
|
866
|
+
const out = [];
|
|
867
|
+
for (const found of masked.matchAll(/\$?\d[\d,]*(?:\.\d+)?/g)) {
|
|
868
|
+
const index = found.index ?? 0;
|
|
869
|
+
const token = found[0].replace(/[,.]+$/, "");
|
|
870
|
+
if (/[\d.,]/.test(masked[index - 1] ?? "")) continue;
|
|
871
|
+
const money = token.startsWith("$");
|
|
872
|
+
if (money && excludeMoney) continue;
|
|
873
|
+
const value = token.replace(/[$,]/g, "");
|
|
874
|
+
if (!/\d/.test(value)) continue;
|
|
875
|
+
const end = index + token.length;
|
|
876
|
+
const after = masked.slice(end, end + 48);
|
|
877
|
+
const percent = /^\s?(?:%|percent(?![a-z]))/.exec(after);
|
|
878
|
+
const multiplier = /^(?:x(?![a-z])|×|\s?times\s(?:more|faster|better|higher|bigger|larger|as|the)\b)/.exec(after);
|
|
879
|
+
const thousands = /^k(?![a-z])/.exec(after);
|
|
880
|
+
const suffix = percent?.[0] ?? multiplier?.[0] ?? thousands?.[0] ?? "";
|
|
881
|
+
const kind = percent ? "percent" : multiplier ? "multiplier" : money ? "money" : "count";
|
|
882
|
+
const amount = Number(value) * (thousands ? 1e3 : 1);
|
|
883
|
+
if (kind === "count" && !(amount >= 11) && !CLAIM_NOUN_AFTER.test(after)) continue;
|
|
884
|
+
out.push({ index, match: norm.slice(index, end + suffix.length).trim(), value, kind });
|
|
885
|
+
}
|
|
886
|
+
return out;
|
|
887
|
+
}
|
|
888
|
+
function findUnsupportedNumbers(text, sources = []) {
|
|
889
|
+
const have = new Set(sources.flatMap((source) => numbersIn(normalizeForQuote(source))));
|
|
890
|
+
return claimNumbersIn(normalizeForQuote(text), false).filter((n) => !have.has(n.value));
|
|
891
|
+
}
|
|
892
|
+
var SUBJECT_PRONOUN = /(?<![\p{L}\p{N}'])(we|our|ours|us|ourselves|i|me|my|mine|myself|you|your|yours|yourself|yourselves)(?:['’](?:re|ve|ll|d|m|s))?(?![\p{L}\p{N}])/gu;
|
|
893
|
+
var SENDER_PRONOUNS = /* @__PURE__ */ new Set(["we", "our", "ours", "us", "ourselves", "i", "me", "my", "mine", "myself"]);
|
|
894
|
+
var OBSERVER_PRONOUNS = /* @__PURE__ */ new Set(["i", "we"]);
|
|
895
|
+
var OBSERVATION_VERB = /^\s+(?:(?:just|also|recently)\s+)?(?:noticed|saw|see|read|looked|checked|ran|found|measured|reviewed)\b/;
|
|
896
|
+
function claimSubjectOf(sentence, index, names = {}) {
|
|
897
|
+
const text = (typeof sentence === "string" ? sentence : "").slice(0, Math.max(0, index)).toLowerCase();
|
|
898
|
+
let clauseStart = 0;
|
|
899
|
+
for (let i = text.length - 1; i >= 0; i--) {
|
|
900
|
+
if (",;:\n\u2013\u2014".includes(text[i]) || text[i] === "-" && text[i - 1] === " ") {
|
|
901
|
+
clauseStart = i + 1;
|
|
902
|
+
break;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
const afterStop = clauseStart;
|
|
906
|
+
for (const found of text.slice(afterStop).matchAll(/\b(?:and|but)\b/g)) {
|
|
907
|
+
clauseStart = afterStop + (found.index ?? 0) + found[0].length;
|
|
908
|
+
}
|
|
909
|
+
const clause = text.slice(clauseStart);
|
|
910
|
+
let bestEnd = -1;
|
|
911
|
+
let best = "unknown";
|
|
912
|
+
const consider = (end, subject) => {
|
|
913
|
+
if (end > bestEnd) {
|
|
914
|
+
bestEnd = end;
|
|
915
|
+
best = subject;
|
|
916
|
+
}
|
|
917
|
+
};
|
|
918
|
+
for (const found of clause.matchAll(SUBJECT_PRONOUN)) {
|
|
919
|
+
const end = (found.index ?? 0) + found[0].length;
|
|
920
|
+
if (OBSERVER_PRONOUNS.has(found[0]) && OBSERVATION_VERB.test(clause.slice(end))) continue;
|
|
921
|
+
consider(end, SENDER_PRONOUNS.has(found[1]) ? "sender" : "recipient");
|
|
922
|
+
}
|
|
923
|
+
const nameHits = (list, subject) => {
|
|
924
|
+
for (const name of list ?? []) {
|
|
925
|
+
const needle = normalizeForQuote(name);
|
|
926
|
+
if (needle.length < 2) continue;
|
|
927
|
+
for (const at of wholeSpansOf(clause, needle)) consider(at + needle.length, subject);
|
|
928
|
+
}
|
|
929
|
+
};
|
|
930
|
+
nameHits(names.senderNames, "sender");
|
|
931
|
+
nameHits(names.recipientNames, "recipient");
|
|
932
|
+
return best;
|
|
933
|
+
}
|
|
934
|
+
var FIRST_PERSON_FIRM = /(?<![\p{L}'’])[Ii](?:['’](?:ve|ll|d|m))?\s+(?:(?:have|will|would|can|could|also|personally|just|recently|already|then|now|actually|really)\s+){0,2}(?:build|builds|built|building|design|designs|designed|designing|offer|offers|offered|offering|work with|worked with|working with|put together|deliver|delivers|delivered|delivering)(?![\p{L}])/gu;
|
|
935
|
+
function quotedRanges(text) {
|
|
936
|
+
const ranges = [];
|
|
937
|
+
for (const found of text.matchAll(/"[^"\n]*"|“[^”\n]*”|(?:https?:\/\/|www\.)\S+|[\w.+-]+@[\w-]+(?:\.[\w-]+)+/g)) {
|
|
938
|
+
ranges.push([found.index ?? 0, (found.index ?? 0) + found[0].length]);
|
|
939
|
+
}
|
|
940
|
+
return ranges;
|
|
941
|
+
}
|
|
942
|
+
function findFirstPersonFirm(text) {
|
|
943
|
+
const source = typeof text === "string" ? text : "";
|
|
944
|
+
const quoted = quotedRanges(source);
|
|
945
|
+
const hits = [];
|
|
946
|
+
for (const found of source.matchAll(FIRST_PERSON_FIRM)) {
|
|
947
|
+
const index = found.index ?? 0;
|
|
948
|
+
if (quoted.some(([s, e]) => index >= s && index < e)) continue;
|
|
949
|
+
hits.push({ index, match: found[0] });
|
|
950
|
+
}
|
|
951
|
+
return hits;
|
|
952
|
+
}
|
|
953
|
+
function partOf(lines, firstOrder, split) {
|
|
954
|
+
const sentences = [];
|
|
955
|
+
for (const line of lines) {
|
|
956
|
+
const trimmed = line.trim();
|
|
957
|
+
if (!trimmed) continue;
|
|
958
|
+
for (const text of split ? splitSentences(trimmed) : [trimmed]) {
|
|
959
|
+
sentences.push({ text, clean: text.normalize("NFKC").replace(INVISIBLE, ""), norm: foldMatchable(text), order: firstOrder + sentences.length });
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
const starts = [];
|
|
963
|
+
let at = 0;
|
|
964
|
+
for (const s of sentences) {
|
|
965
|
+
starts.push(at);
|
|
966
|
+
at += s.norm.length + 1;
|
|
967
|
+
}
|
|
968
|
+
return { sentences, joined: sentences.map((s) => s.norm).join(" "), starts };
|
|
969
|
+
}
|
|
970
|
+
function sentenceAt(part, index) {
|
|
971
|
+
let found = null;
|
|
972
|
+
for (let i = 0; i < part.sentences.length; i++) {
|
|
973
|
+
if (part.starts[i] <= index) found = part.sentences[i];
|
|
974
|
+
else break;
|
|
975
|
+
}
|
|
976
|
+
return found;
|
|
977
|
+
}
|
|
978
|
+
var LETTER2 = /\p{L}/gu;
|
|
979
|
+
var letterCount = (text) => (text.match(LETTER2) ?? []).length;
|
|
980
|
+
var quoteMatch = (match) => `"${match}"`;
|
|
981
|
+
function claimNorm(text) {
|
|
982
|
+
return typeof text === "string" ? normalizeForQuote(text) : "";
|
|
983
|
+
}
|
|
984
|
+
function attachesTo(claim, sentence) {
|
|
985
|
+
if (!claim || !sentence) return false;
|
|
986
|
+
if (claim === sentence) return true;
|
|
987
|
+
if (claim.length >= 8 && wholeSpanAt(sentence, claim) >= 0) return true;
|
|
988
|
+
return sentence.length >= 8 && wholeSpanAt(claim, sentence) >= 0;
|
|
989
|
+
}
|
|
990
|
+
function checkOutboundCopy(copy, ctx) {
|
|
991
|
+
const author = OUTBOUND_AUTHORS.includes(ctx?.author) ? ctx.author : "agent";
|
|
992
|
+
const agent = author === "agent";
|
|
993
|
+
const operator = author === "operator";
|
|
994
|
+
const format = copy?.format === "html" ? "html" : "text";
|
|
995
|
+
const channel = copy?.channel === "sms" ? "sms" : "email";
|
|
996
|
+
const rules = ctx?.rules ?? null;
|
|
997
|
+
const mode = ctx?.numbers ?? (agent ? "present" : "cited");
|
|
998
|
+
const senderNames = rules?.sender_names ?? [];
|
|
999
|
+
const recipientNames = ctx?.recipient?.names ?? [];
|
|
1000
|
+
const subjectNames = { senderNames, recipientNames };
|
|
1001
|
+
const primary = normalizeForQuote(ctx?.evidence?.primary ?? "");
|
|
1002
|
+
const primaryNumbers = new Set(numbersIn(primary));
|
|
1003
|
+
const primaryAmounts = moneyAmounts(primary);
|
|
1004
|
+
const facts = /* @__PURE__ */ new Map();
|
|
1005
|
+
for (const fact of rules?.facts ?? []) if (fact && typeof fact.id === "string") facts.set(fact.id, fact);
|
|
1006
|
+
const verifiedFactNumbers = /* @__PURE__ */ new Set();
|
|
1007
|
+
for (const fact of facts.values()) if (fact.verified) for (const n of numbersIn(normalizeForQuote(fact.text))) verifiedFactNumbers.add(n);
|
|
1008
|
+
const published = (rules?.prices ?? []).flatMap((price) => priceMentionsIn(normalizeForQuote(price?.display ?? "")));
|
|
1009
|
+
const publishedDisplays = (rules?.prices ?? []).map((price) => price?.display).filter((d) => typeof d === "string" && !!d.trim());
|
|
1010
|
+
const plain = outboundPlainText(copy?.body ?? "", format);
|
|
1011
|
+
const subjectText = channel === "email" ? decodeHtmlEntities(typeof copy?.subject === "string" ? copy.subject : "") : "";
|
|
1012
|
+
const subjectPart = partOf(subjectText ? [subjectText] : [], 0, false);
|
|
1013
|
+
const bodyPart = partOf(plain.split("\n"), subjectPart.sentences.length, true);
|
|
1014
|
+
const parts = [subjectPart, bodyPart];
|
|
1015
|
+
const sentences = [...subjectPart.sentences, ...bodyPart.sentences];
|
|
1016
|
+
const found = { blocking: [], flags: [], style: [] };
|
|
1017
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1018
|
+
const add = (bucket, sentence, pos, code, match, extra = {}) => {
|
|
1019
|
+
if (bucket === "flags" && operator) return;
|
|
1020
|
+
const finding = makeOutboundFinding(code, sentence?.text ?? "", match, extra);
|
|
1021
|
+
const key = `${bucket}:${finding.id}`;
|
|
1022
|
+
if (seen.has(key)) return;
|
|
1023
|
+
seen.add(key);
|
|
1024
|
+
found[bucket].push({ order: sentence?.order ?? -1, pos, finding });
|
|
1025
|
+
};
|
|
1026
|
+
if (channel === "email" && !/[\p{L}\p{N}]/u.test(subjectText)) add("blocking", null, 0, "empty_subject", "");
|
|
1027
|
+
const letters = letterCount(plain);
|
|
1028
|
+
if (channel === "sms" ? letters === 0 : letters < 8) {
|
|
1029
|
+
add("blocking", null, 0, "empty_body", "", channel === "sms" ? { reason: "The text has no words in it." } : { reason: "The email has almost nothing in it." });
|
|
1030
|
+
}
|
|
1031
|
+
for (const part of parts) {
|
|
1032
|
+
for (const hit of tokenHits(part.joined)) {
|
|
1033
|
+
add("blocking", sentenceAt(part, hit.index), hit.index, "unrendered_token", hit.match, {
|
|
1034
|
+
reason: `${quoteMatch(hit.match)} is a placeholder that was never filled in.`
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
for (const hit of platformHits(part.joined)) {
|
|
1038
|
+
const sentence = sentenceAt(part, hit.index);
|
|
1039
|
+
if (hit.negated) {
|
|
1040
|
+
add("flags", sentence, hit.index, "banned_claim_negated", hit.match, {
|
|
1041
|
+
reason: `It brings up ${PLATFORM_CLAIM_NAME[hit.id]} while saying it can't promise it.`
|
|
1042
|
+
});
|
|
1043
|
+
} else {
|
|
1044
|
+
add("blocking", sentence, hit.index, "platform_banned_claim", hit.match, { reason: PLATFORM_CLAIM_REASON[hit.id] });
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
if (rules) {
|
|
1048
|
+
for (const hit of phraseHits(part.joined, rules.banned)) {
|
|
1049
|
+
add("blocking", sentenceAt(part, hit.index), hit.index, "project_banned_claim", hit.match, {
|
|
1050
|
+
reason: `It says ${quoteMatch(hit.phrase)}, which the Business Brief says never to claim.`
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
for (const hit of phraseHits(part.joined, rules.forbidden)) {
|
|
1054
|
+
add("blocking", sentenceAt(part, hit.index), hit.index, "forbidden_phrase", hit.match, {
|
|
1055
|
+
reason: `It says ${quoteMatch(hit.phrase)}, which this outreach angle never says.`
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
if (agent && format === "html") {
|
|
1061
|
+
for (const hit of findUnsafeMarkup(copy?.body ?? "")) {
|
|
1062
|
+
add("blocking", null, hit.index, "unsafe_markup", hit.match, { reason: `The HTML has ${hit.match}, which email clients strip or flag as spam.` });
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
const claims = (ctx?.claims ?? []).filter((c) => !!c && typeof c === "object" && typeof c.text === "string");
|
|
1066
|
+
const claimsBySentence = /* @__PURE__ */ new Map();
|
|
1067
|
+
for (const claim of claims) {
|
|
1068
|
+
const norm = claimNorm(claim.text);
|
|
1069
|
+
for (const sentence of sentences) {
|
|
1070
|
+
if (!attachesTo(norm, sentence.norm)) continue;
|
|
1071
|
+
const list = claimsBySentence.get(sentence) ?? [];
|
|
1072
|
+
list.push(claim);
|
|
1073
|
+
claimsBySentence.set(sentence, list);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
const isResearch = (claim) => "evidence" in claim && claim.evidence === "research";
|
|
1077
|
+
const factOf = (claim) => {
|
|
1078
|
+
const id = "fact_id" in claim ? claim.fact_id : null;
|
|
1079
|
+
return typeof id === "string" ? facts.get(id) ?? null : null;
|
|
1080
|
+
};
|
|
1081
|
+
const validFactClaims = (list) => list.filter((c) => !isResearch(c) && factOf(c)?.verified === true);
|
|
1082
|
+
const recipientResearchNumbers = /* @__PURE__ */ new Set();
|
|
1083
|
+
for (const sentence of [...bodyPart.sentences, ...subjectPart.sentences]) {
|
|
1084
|
+
const attached = claimsBySentence.get(sentence) ?? [];
|
|
1085
|
+
const researchCited = attached.some(isResearch);
|
|
1086
|
+
const factClaims = validFactClaims(attached);
|
|
1087
|
+
const brokenCitation = attached.some((c) => !isResearch(c) && typeof ("fact_id" in c ? c.fact_id : null) === "string" && factOf(c)?.verified !== true);
|
|
1088
|
+
for (const claim of attached) {
|
|
1089
|
+
const id = !isResearch(claim) && "fact_id" in claim ? claim.fact_id : null;
|
|
1090
|
+
if (typeof id !== "string") continue;
|
|
1091
|
+
const fact = facts.get(id);
|
|
1092
|
+
if (fact?.verified === true) continue;
|
|
1093
|
+
add("flags", sentence, 0, "citation_invalid", id, {
|
|
1094
|
+
reason: fact ? `It cites ${id}, which nobody has verified yet.` : `It cites ${id}, which isn't in the Business Brief.`,
|
|
1095
|
+
fact_id: id
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
if (rules) {
|
|
1099
|
+
for (const mention of priceMentionsIn(sentence.norm)) {
|
|
1100
|
+
const factBacked = factClaims.some((c) => priceMentionsIn(normalizeForQuote(factOf(c).text)).some((p) => samePrice(p, mention)));
|
|
1101
|
+
if (factBacked || published.some((p) => samePrice(p, mention))) continue;
|
|
1102
|
+
const subject = claimSubjectOf(sentence.norm, mention.index, subjectNames);
|
|
1103
|
+
const amounts = mention.amounts.map(amountValue);
|
|
1104
|
+
const inPrimary = amounts.length > 0 && amounts.every((a) => primaryAmounts.some((p) => sameAmount(p, a)));
|
|
1105
|
+
const researchBacked = researchCited || mode === "present" && inPrimary;
|
|
1106
|
+
if (researchBacked && (subject === "recipient" || subject === "unknown" && inPrimary)) {
|
|
1107
|
+
if (inPrimary) {
|
|
1108
|
+
if (agent && mode === "present") add("flags", sentence, mention.index, "number_from_research", mention.match, { reason: `${quoteMatch(mention.match)} comes from the research about the recipient.` });
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1111
|
+
add("flags", sentence, mention.index, "citation_mismatch", mention.match, { reason: `${quoteMatch(mention.match)} isn't in the research it cites.` });
|
|
1112
|
+
} else if (researchBacked) {
|
|
1113
|
+
add("flags", sentence, mention.index, "citation_wrong_source", mention.match, {
|
|
1114
|
+
reason: `${quoteMatch(mention.match)} is a claim about the sender, but only research about the recipient backs it.`
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
const oneEnd = mention.shape !== "range" && published.some((p) => p.shape === "range" && (sameAmount(p.min, mention.min) || sameAmount(p.max, mention.max)));
|
|
1118
|
+
const shown = publishedDisplays.slice(0, 3).join("; ");
|
|
1119
|
+
if (rules.prices_known) {
|
|
1120
|
+
add("blocking", sentence, mention.index, "price_mismatch", mention.match, {
|
|
1121
|
+
reason: oneEnd ? `${quoteMatch(mention.match)} is only one end of a published range.` : `${quoteMatch(mention.match)} doesn't match a price you've published.`,
|
|
1122
|
+
fix: shown ? `Use the price exactly as published (${shown}), or leave the price out.` : "Use the price as published, or leave the price out."
|
|
1123
|
+
});
|
|
1124
|
+
} else {
|
|
1125
|
+
add("flags", sentence, mention.index, "price_unverifiable", mention.match, { reason: `${quoteMatch(mention.match)} isn't a published price, so it can't be checked.` });
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
if (!operator) {
|
|
1130
|
+
const subjectLine = sentence.order < subjectPart.sentences.length;
|
|
1131
|
+
for (const number of claimNumbersIn(sentence.norm, !!rules)) {
|
|
1132
|
+
const subject = claimSubjectOf(sentence.norm, number.index, subjectNames);
|
|
1133
|
+
const recipient = subject === "recipient";
|
|
1134
|
+
if (factClaims.some((c) => numbersIn(normalizeForQuote(factOf(c).text)).includes(number.value))) continue;
|
|
1135
|
+
if (researchCited) {
|
|
1136
|
+
const readsAsRecipient = recipient || subject === "unknown" && primaryNumbers.has(number.value);
|
|
1137
|
+
if (readsAsRecipient && primaryNumbers.has(number.value)) recipientResearchNumbers.add(number.value);
|
|
1138
|
+
if (!readsAsRecipient) {
|
|
1139
|
+
add("flags", sentence, number.index, "citation_wrong_source", number.match, {
|
|
1140
|
+
reason: `${quoteMatch(number.match)} is a claim about the sender, but only research about the recipient backs it.`
|
|
1141
|
+
});
|
|
1142
|
+
} else if (!primaryNumbers.has(number.value)) {
|
|
1143
|
+
add("flags", sentence, number.index, "citation_mismatch", number.match, { reason: `${quoteMatch(number.match)} isn't in the research it cites.` });
|
|
1144
|
+
}
|
|
1145
|
+
continue;
|
|
1146
|
+
}
|
|
1147
|
+
if (factClaims.length) {
|
|
1148
|
+
add("flags", sentence, number.index, "citation_mismatch", number.match, { reason: `${quoteMatch(number.match)} isn't in the fact it cites.` });
|
|
1149
|
+
continue;
|
|
1150
|
+
}
|
|
1151
|
+
if (brokenCitation) continue;
|
|
1152
|
+
if (subjectLine && subject !== "sender" && recipientResearchNumbers.has(number.value)) continue;
|
|
1153
|
+
if (mode === "present") {
|
|
1154
|
+
if (verifiedFactNumbers.has(number.value)) continue;
|
|
1155
|
+
if (primaryNumbers.has(number.value)) {
|
|
1156
|
+
if (recipient) {
|
|
1157
|
+
if (agent) add("flags", sentence, number.index, "number_from_research", number.match, { reason: `${quoteMatch(number.match)} comes from the research about the recipient.` });
|
|
1158
|
+
} else {
|
|
1159
|
+
add("flags", sentence, number.index, "citation_wrong_source", number.match, {
|
|
1160
|
+
reason: `${quoteMatch(number.match)} is a claim about the sender, but only research about the recipient backs it.`
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
continue;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
add("flags", sentence, number.index, "unsupported_number", number.match, {
|
|
1167
|
+
reason: `Nothing on record backs up ${quoteMatch(number.match)}.`,
|
|
1168
|
+
fix: recipient ? "Back it with the research about them, or take it out." : "Cite a verified fact from the Business Brief, or take the number out."
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
if (isWriterNote(sentence.text)) add("flags", sentence, 0, "writer_note", sentence.norm);
|
|
1173
|
+
for (const hit of findMixedScript(sentence.clean)) {
|
|
1174
|
+
add("blocking", sentence, hit.index, "mixed_script", hit.match, { reason: `${quoteMatch(hit.match)} mixes letters from two alphabets, so it only looks like a normal word.` });
|
|
1175
|
+
}
|
|
1176
|
+
for (const hit of findDashes(sentence.clean)) {
|
|
1177
|
+
add(agent ? "blocking" : "style", sentence, hit.index, "dash", hit.match, {
|
|
1178
|
+
reason: hit.kind === "em" ? "It uses an em dash." : "It uses an en dash between words."
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
const protectedRanges = quotedRanges(sentence.clean);
|
|
1182
|
+
for (const hit of findUncontracted(sentence.clean, { protectedRanges, titleLike: sentence.order < subjectPart.sentences.length })) {
|
|
1183
|
+
add("style", sentence, hit.index, "uncontracted", hit.match, { reason: `${quoteMatch(hit.match)} reads stiff.`, fix: `Try ${quoteMatch(hit.suggestion)}.` });
|
|
1184
|
+
}
|
|
1185
|
+
for (const hit of findFirstPersonFirm(sentence.clean)) {
|
|
1186
|
+
add("style", sentence, hit.index, "first_person_firm", hit.match, { reason: `${quoteMatch(hit.match)} says I for work the business does.` });
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
const sorted = (bucket) => found[bucket].sort((a, b) => a.order - b.order || a.pos - b.pos).map((x) => x.finding);
|
|
1190
|
+
return { blocking: sorted("blocking"), flags: sorted("flags"), style: sorted("style"), plain };
|
|
1191
|
+
}
|
|
1192
|
+
var OUTBOUND_AUDIT_STATUSES = ["clean", "flagged", "not_run", "not_needed", "stale"];
|
|
1193
|
+
var isRecord = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
1194
|
+
var isString = (value) => typeof value === "string";
|
|
1195
|
+
var isStringList = (value) => Array.isArray(value) && value.every(isString);
|
|
1196
|
+
function readFinding(value) {
|
|
1197
|
+
if (!isRecord(value)) return null;
|
|
1198
|
+
const { id, code, sentence, match, reason, fix, fact_id } = value;
|
|
1199
|
+
if (!isString(id) || !id || !isString(code) || !/^[a-z_]{1,48}$/.test(code)) return null;
|
|
1200
|
+
if (!isString(sentence) || !isString(match) || !isString(reason) || !isString(fix)) return null;
|
|
1201
|
+
if (fact_id !== void 0 && fact_id !== null && !isString(fact_id)) return null;
|
|
1202
|
+
const finding = { id, code, sentence, match, reason, fix };
|
|
1203
|
+
if (isString(fact_id)) finding.fact_id = fact_id;
|
|
1204
|
+
return finding;
|
|
1205
|
+
}
|
|
1206
|
+
function readFindings(value) {
|
|
1207
|
+
if (!Array.isArray(value)) return null;
|
|
1208
|
+
const out = [];
|
|
1209
|
+
for (const item of value) {
|
|
1210
|
+
const finding = readFinding(item);
|
|
1211
|
+
if (!finding) return null;
|
|
1212
|
+
out.push(finding);
|
|
1213
|
+
}
|
|
1214
|
+
return out;
|
|
1215
|
+
}
|
|
1216
|
+
function readRules(value) {
|
|
1217
|
+
if (value === null) return null;
|
|
1218
|
+
if (!isRecord(value)) return void 0;
|
|
1219
|
+
const { prices, prices_known, banned, forbidden, facts, sender_names } = value;
|
|
1220
|
+
if (!Array.isArray(prices) || typeof prices_known !== "boolean" || !isStringList(banned) || !isStringList(forbidden)) return void 0;
|
|
1221
|
+
const readPrices = [];
|
|
1222
|
+
for (const price of prices) {
|
|
1223
|
+
if (!isRecord(price) || !isString(price.display)) return void 0;
|
|
1224
|
+
readPrices.push(isString(price.fact_id) ? { display: price.display, fact_id: price.fact_id } : { display: price.display });
|
|
1225
|
+
}
|
|
1226
|
+
const rules = { prices: readPrices, prices_known, banned: [...banned], forbidden: [...forbidden] };
|
|
1227
|
+
if (facts !== void 0) {
|
|
1228
|
+
if (!Array.isArray(facts)) return void 0;
|
|
1229
|
+
const readFacts = [];
|
|
1230
|
+
for (const fact of facts) {
|
|
1231
|
+
if (!isRecord(fact) || !isString(fact.id) || !isString(fact.text) || typeof fact.verified !== "boolean") return void 0;
|
|
1232
|
+
readFacts.push({ id: fact.id, text: fact.text, verified: fact.verified });
|
|
1233
|
+
}
|
|
1234
|
+
rules.facts = readFacts;
|
|
1235
|
+
}
|
|
1236
|
+
if (sender_names !== void 0) {
|
|
1237
|
+
if (!isStringList(sender_names)) return void 0;
|
|
1238
|
+
rules.sender_names = [...sender_names];
|
|
1239
|
+
}
|
|
1240
|
+
return rules;
|
|
1241
|
+
}
|
|
1242
|
+
function readOutboundClaimReview(value) {
|
|
1243
|
+
if (!isRecord(value) || value.version !== 1) return null;
|
|
1244
|
+
const { checked_at, author, brain_version, audit, acknowledged, asserted } = value;
|
|
1245
|
+
if (!isString(checked_at) || !OUTBOUND_AUTHORS.includes(author)) return null;
|
|
1246
|
+
if (brain_version !== null && brain_version !== void 0 && (typeof brain_version !== "number" || !Number.isFinite(brain_version))) return null;
|
|
1247
|
+
const rules = readRules(value.rules ?? null);
|
|
1248
|
+
if (rules === void 0) return null;
|
|
1249
|
+
const flags = readFindings(value.flags);
|
|
1250
|
+
const style = readFindings(value.style ?? []);
|
|
1251
|
+
if (!flags || !style) return null;
|
|
1252
|
+
if (!isRecord(audit) || !OUTBOUND_AUDIT_STATUSES.includes(audit.status)) return null;
|
|
1253
|
+
let ack = null;
|
|
1254
|
+
if (acknowledged !== null && acknowledged !== void 0) {
|
|
1255
|
+
if (!isRecord(acknowledged) || !isString(acknowledged.by) || !isString(acknowledged.at) || !isString(acknowledged.via) || !isStringList(acknowledged.flag_ids)) return null;
|
|
1256
|
+
ack = { by: acknowledged.by, at: acknowledged.at, via: acknowledged.via, flag_ids: [...acknowledged.flag_ids] };
|
|
1257
|
+
}
|
|
1258
|
+
let assertion = null;
|
|
1259
|
+
if (asserted !== null && asserted !== void 0) {
|
|
1260
|
+
if (!isRecord(asserted) || !isString(asserted.by) || !isString(asserted.at) || !isStringList(asserted.flag_ids)) return null;
|
|
1261
|
+
assertion = { by: asserted.by, at: asserted.at, flag_ids: [...asserted.flag_ids] };
|
|
1262
|
+
}
|
|
1263
|
+
return {
|
|
1264
|
+
version: 1,
|
|
1265
|
+
checked_at,
|
|
1266
|
+
author,
|
|
1267
|
+
brain_version: typeof brain_version === "number" ? brain_version : null,
|
|
1268
|
+
rules,
|
|
1269
|
+
flags,
|
|
1270
|
+
style,
|
|
1271
|
+
audit: { ...audit, status: audit.status },
|
|
1272
|
+
acknowledged: ack,
|
|
1273
|
+
asserted: assertion
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1276
|
+
function unsettledFlags(review) {
|
|
1277
|
+
const settled = new Set(review.acknowledged?.flag_ids ?? []);
|
|
1278
|
+
if (review.author === "agent") for (const id of review.asserted?.flag_ids ?? []) settled.add(id);
|
|
1279
|
+
return review.flags.filter((flag) => !settled.has(flag.id));
|
|
1280
|
+
}
|
|
1281
|
+
function withoutDashChars(text) {
|
|
1282
|
+
return text.replace(/[—―⸺⸻]/g, "[em dash]").replace(/[‒–]/g, "[en dash]");
|
|
1283
|
+
}
|
|
1284
|
+
function clip(text, max) {
|
|
1285
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
1286
|
+
if (flat.length <= max) return flat;
|
|
1287
|
+
const cut = flat.slice(0, max);
|
|
1288
|
+
const space = cut.lastIndexOf(" ");
|
|
1289
|
+
return `${space > max * 0.6 ? cut.slice(0, space) : cut}...`;
|
|
1290
|
+
}
|
|
1291
|
+
function outboundRefusalMessage(findings) {
|
|
1292
|
+
const list = (findings ?? []).filter(Boolean);
|
|
1293
|
+
if (!list.length) return "";
|
|
1294
|
+
const head = list.length === 1 ? "This can't go out yet. Fix this and try again:" : `This can't go out yet. Fix these ${list.length} things and try again:`;
|
|
1295
|
+
const lines = list.map((f, i) => {
|
|
1296
|
+
const where = f.sentence && f.sentence.trim() ? ` (In: "${clip(f.sentence, 160)}")` : "";
|
|
1297
|
+
return `${i + 1}. ${f.reason} ${f.fix}${where}`;
|
|
1298
|
+
});
|
|
1299
|
+
return withoutDashChars([head, ...lines].join("\n"));
|
|
1300
|
+
}
|
|
1301
|
+
function readBackPlain(value) {
|
|
1302
|
+
if (value === null || value === void 0) return null;
|
|
1303
|
+
if (typeof value === "string") return outboundPlainText(value, /<\/?[a-z][a-z0-9]*\b[^>]*>/i.test(value) ? "html" : "text");
|
|
1304
|
+
if (typeof value.body !== "string") return null;
|
|
1305
|
+
return outboundPlainText(value.body, value.format ?? (/<\/?[a-z][a-z0-9]*\b[^>]*>/i.test(value.body) ? "html" : "text"));
|
|
1306
|
+
}
|
|
1307
|
+
function urlsIn(plain) {
|
|
1308
|
+
const urls = (plain.match(/\b(?:https?:\/\/|www\.)[^\s<>"')\]]+/gi) ?? []).map((u) => u.replace(/[.,;:!?]+$/, ""));
|
|
1309
|
+
return Array.from(new Set(urls));
|
|
1310
|
+
}
|
|
1311
|
+
function readBackVerdict(intended, read) {
|
|
1312
|
+
const intendedPlain = readBackPlain(intended) ?? "";
|
|
1313
|
+
const intendedNorm = foldMatchable(intendedPlain);
|
|
1314
|
+
const readPlain = readBackPlain(read);
|
|
1315
|
+
if (readPlain === null) {
|
|
1316
|
+
return { status: "unreadable", body_chars: 0, intended_chars: intendedNorm.length, chars_missing: intendedNorm.length, urls_missing: urlsIn(intendedPlain) };
|
|
1317
|
+
}
|
|
1318
|
+
const readNorm = foldMatchable(readPlain);
|
|
1319
|
+
const urls_missing = urlsIn(intendedPlain).filter((url) => !readNorm.includes(foldMatchable(url)));
|
|
1320
|
+
let matched = intendedNorm.length;
|
|
1321
|
+
if (!readNorm.includes(intendedNorm)) {
|
|
1322
|
+
let lo = 0;
|
|
1323
|
+
let hi = intendedNorm.length;
|
|
1324
|
+
while (lo < hi) {
|
|
1325
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
1326
|
+
if (readNorm.includes(intendedNorm.slice(0, mid))) lo = mid;
|
|
1327
|
+
else hi = mid - 1;
|
|
1328
|
+
}
|
|
1329
|
+
matched = lo;
|
|
1330
|
+
}
|
|
1331
|
+
const verdict = {
|
|
1332
|
+
body_chars: readNorm.length,
|
|
1333
|
+
intended_chars: intendedNorm.length,
|
|
1334
|
+
chars_missing: intendedNorm.length - matched,
|
|
1335
|
+
urls_missing
|
|
1336
|
+
};
|
|
1337
|
+
if (letterCount(readPlain) < 8) return { status: "empty", ...verdict };
|
|
1338
|
+
if (verdict.chars_missing === 0 && urls_missing.length === 0) return { status: "verified", ...verdict };
|
|
1339
|
+
return { status: "mismatch", ...verdict };
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
exports.BUSINESS_CLAIMS_CONTRACT_VERSION = BUSINESS_CLAIMS_CONTRACT_VERSION;
|
|
1343
|
+
exports.MIN_QUOTE_CHARS = MIN_QUOTE_CHARS;
|
|
1344
|
+
exports.OUTBOUND_AUDIT_STATUSES = OUTBOUND_AUDIT_STATUSES;
|
|
1345
|
+
exports.OUTBOUND_AUTHORS = OUTBOUND_AUTHORS;
|
|
1346
|
+
exports.PLATFORM_BANNED_CLAIMS = PLATFORM_BANNED_CLAIMS;
|
|
1347
|
+
exports.PRICE_RANGE_AFTER = PRICE_RANGE_AFTER;
|
|
1348
|
+
exports.PRICE_RANGE_BEFORE = PRICE_RANGE_BEFORE;
|
|
1349
|
+
exports.checkOutboundCopy = checkOutboundCopy;
|
|
1350
|
+
exports.checkQuotedPrice = checkQuotedPrice;
|
|
1351
|
+
exports.claimSubjectOf = claimSubjectOf;
|
|
1352
|
+
exports.decodeHtmlEntities = decodeHtmlEntities;
|
|
1353
|
+
exports.findDashes = findDashes;
|
|
1354
|
+
exports.findFirstPersonFirm = findFirstPersonFirm;
|
|
1355
|
+
exports.findMixedScript = findMixedScript;
|
|
1356
|
+
exports.findPhrases = findPhrases;
|
|
1357
|
+
exports.findPlatformBannedClaims = findPlatformBannedClaims;
|
|
1358
|
+
exports.findPriceMentions = findPriceMentions;
|
|
1359
|
+
exports.findUnrenderedTokens = findUnrenderedTokens;
|
|
1360
|
+
exports.findUnsafeMarkup = findUnsafeMarkup;
|
|
1361
|
+
exports.findUnsupportedNumbers = findUnsupportedNumbers;
|
|
1362
|
+
exports.fnv1a = fnv1a;
|
|
1363
|
+
exports.isWriterNote = isWriterNote;
|
|
1364
|
+
exports.makeOutboundFinding = makeOutboundFinding;
|
|
1365
|
+
exports.moneyAmounts = moneyAmounts;
|
|
1366
|
+
exports.normalizeForQuote = normalizeForQuote;
|
|
1367
|
+
exports.normalizeMatchable = normalizeMatchable;
|
|
1368
|
+
exports.numbersGrounded = numbersGrounded;
|
|
1369
|
+
exports.numbersIn = numbersIn;
|
|
1370
|
+
exports.outboundFindingId = outboundFindingId;
|
|
1371
|
+
exports.outboundPlainText = outboundPlainText;
|
|
1372
|
+
exports.outboundRefusalMessage = outboundRefusalMessage;
|
|
1373
|
+
exports.quoteFoundIn = quoteFoundIn;
|
|
1374
|
+
exports.readBackVerdict = readBackVerdict;
|
|
1375
|
+
exports.readOutboundClaimReview = readOutboundClaimReview;
|
|
1376
|
+
exports.removeDashes = removeDashes;
|
|
1377
|
+
exports.unsettledFlags = unsettledFlags;
|
|
1378
|
+
exports.wholeSpanAt = wholeSpanAt;
|