1export const ANALYTICS_SCHEMA_VERSION = 1; 2 3export const ANALYTICS_EVENT_TYPES = [ 4 "page_view", 5 "cta_click", 6 "story_view", 7 "story_engaged", 8 "resource_view", 9 "outbound_help_click", 10 "form_start", 11 "form_submit", 12 "volunteer_signup", 13 "email_signup", 14 "sms_signup", 15 "conversion", 16 "donation_click", 17 "donation_complete", 18]; 19 20export const ANALYTICS_EVENT_TYPE_SET = new Set(ANALYTICS_EVENT_TYPES); 21 22// Campaign-shaped values (utm_source, utm_campaign, utm_content, and the 23// campaign_id property) validate by shape, not enumeration, so campaigns 24// minted in Shepherd's campaign registry work without a website deploy. 25// Shepherd's ingest stays the authoritative allowlist for which campaigns 26// exist. utm_medium is a fixed taxonomy and utm_term is locked (never 27// accepted); both still enumerate. 28export const ANALYTICS_CAMPAIGN_SLUG_PATTERN = /^(?=.*[a-z])[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/u; 29 30export const ANALYTICS_UTM_ENUMS = { 31 utm_medium: new Set(["qr", "print", "email", "campaign_page"]), 32 utm_term: new Set(), 33}; 34 35export const ANALYTICS_UTM_KEYS = [ 36 "utm_source", 37 "utm_medium", 38 "utm_campaign", 39 "utm_content", 40 "utm_term", 41]; 42 43export function isAnalyticsCampaignSlug(value) { 44 return typeof value === "string" && ANALYTICS_CAMPAIGN_SLUG_PATTERN.test(value); 45} 46 47const STATIC_PATHS = new Set([ 48 "/", 49 "/404", 50 "/about", 51 "/christmas", 52 "/contact", 53 "/donate", 54 "/donate/thank-you", 55 "/education", 56 "/events", 57 "/get-involved", 58 "/impact", 59 "/legacy", 60 "/lifehouse",
61 "/lifehouse-podcast", 62 "/madera-fair", 63 "/madera-fair/get-involved", 64 "/madera-fair/the-300", 65 "/op-eds", 66 "/pregnancy-help", 67 "/privacy", 68 "/rachels-vineyard", 69 "/radio", 70 "/samples/bloomerang-donate", 71 "/sidewalk-stories", 72 "/sms", 73 "/student-workshop", 74 "/terms", 75 "/the-300", 76 "/updates", 77 "/updates/july-2026-newsletter", 78 "/ways-to-give", 79 "/ways-to-give/vehicle-donation", 80 "/why-life", 81 "/es", 82 "/es/404", 83 "/es/about", 84 "/es/christmas", 85 "/es/contact", 86 "/es/donate", 87 "/es/donate/thank-you", 88 "/es/education", 89 "/es/events", 90 "/es/get-involved", 91 "/es/impact", 92 "/es/legacy", 93 "/es/lifehouse", 94 "/es/lifehouse-podcast", 95 "/es/madera-fair", 96 "/es/madera-fair/get-involved", 97 "/es/madera-fair/the-300", 98 "/es/op-eds", 99 "/es/pregnancy-help", 100 "/es/privacy", 101 "/es/rachels-vineyard", 102 "/es/radio", 103 "/es/sidewalk-stories", 104 "/es/sms", 105 "/es/student-workshop", 106 "/es/terms", 107 "/es/the-300", 108 "/es/updates", 109 "/es/updates/july-2026-newsletter", 110 "/es/ways-to-give", 111 "/es/ways-to-give/vehicle-donation", 112 "/es/why-life", 113]); 114 115const OP_ED_SLUGS = new Set([ 116 "2014-05-05-josh-brahm-dialogues-with-a-fresno-city-college-biology-professor", 117 "2014-09-14-can-you-explain-why-youre-pro-life-in-a-30-second-sound-bite", 118 "2014-10-03-the-most-undervalued-argument-in-the-prolife-movement", 119 "2014-10-27-what-happens-in-dc-doesnt-stay-in-dc", 120 "2014-11-07-election-2014-huge-victories-a-few-defeats-how-the-midterms-have-and-will-impact-the-pro-life-movement", 121 "2015-02-20-pencils-prisons-and-the-abortion-provider", 122 "2015-03-15-sex-selective-abortion-in-the-us", 123 "2015-07-23-7things", 124 "2015-07-31-press-conference-monday-august-3-2015", 125 "2015-08-04-watch-the-abortion-provider-videos", 126 "2016-11-11-prolife-response-presidential-election", 127 "2017-05-04-bad-california-state-bills", 128 "2017-05-08-ahca-and-religious-liberty-eo", 129 "2017-05-13-standing-up-for-speech-fresno-state-students-for-life", 130 "2017-05-23-the-danger-of-sb-481", 131 "2017-06-02-correcting-bias-on-hhs-mandate-and-assisted-suicide-in-todays-fresno-bee", 132 "2017-06-08-francis-collins-to-nih-trumps-first-pro-life-stumble", 133 "2017-06-12-response-to-fresno-bee-article-on-hhs-contraception-mandate", 134 "2017-11-09-whats-next-for-california-and-pro-lifers", 135 "2018-01-15-stop-sb-320-no-abortion-clinics-on-college-campuses", 136 "2018-06-26-going-to-the-capitol", 137 "2018-06-29-justice-anthony-kennedy-retiring", 138 "2018-07-11-new-supreme-court-justice-brett-kavanaugh", 139 "2018-08-22-the-rise-of-chemical-abortion-and-telemedicine", 140 "2018-08-31-sb-320-moves-forward", 141 "2018-09-04-urge-jerry-brown-to-veto-sb-320", 142 "2018-10-31-the-2018-elections-what-is-at-stake", 143 "2018-11-08-election-results", 144 "2019-02-01-thoughts-on-the-new-late-term-abortion-law", 145 "2019-04-10-abortion-ideology-running-roughshod-over-common-sense-in-state-legislature", 146 "2019-08-21-oppose-medication-abortion-on-csu-and-uc-campuses", 147 "2019-10-07-urge-gov-newsom-veto-sb-24", 148 "2020-03-12-prop-71-dumped-billions-into-useless-research-californians-should-not-re-up-this-november", 149 "2020-04-15-fresno-abortion-provider-sold-babies-bodies", 150 "2020-05-21-insane-pro-choice-bills", 151 "2020-07-03-the-abortion-distortion", 152 "2020-07-13-proposition-14-vote-no", 153 "2020-11-16-election-2020-abortion-policy-results", 154 "2021-06-04-dobbs-v-jackson-womens-health-organization", 155 "2021-06-18-roe-v-wade", 156 "2021-07-15-doe-v-bolton", 157 "2021-09-21-texas-abortion-law", 158 "2022-05-06-rlcc-statement-on-leak-of-draft-supreme-court-opinion", 159 "2022-06-24-statement-on-dobbs-v-jackson-womens-health-organization", 160 "2022-10-12-opposing-proposition-1", 161 "2022-10-28-tilting-the-scales", 162 "2022-12-13-records-request-shows-abortion-provider-grant-can-fund-abortion-directly", 163 "2023-03-01-stop-abortion-provider", 164 "2023-05-19-ab-710-315-are-defeated", 165 "2023-07-05-the-big-lie-of-proposition-1", 166 "2023-07-27-opposing-extremist-abortion-policies-in-the-military", 167 "2026-05-19-trumps-conflicted-views-are-complicating-abortion-policy", 168 "2026-06-25-right-to-life-report-june-2026", 169]); 170 171const UPDATE_SLUGS = new Set([ 172 "2014-05-13-greg-peterson-on-how-rlcc-has-changed-his-life", 173 "2014-07-21-5-tips-talking-to-your-teens-about-pregnancy", 174 "2015-09-14-conversations-at-the-justice-for-all-outreach", 175 "2015-09-16-why-we-ask-for-your-support", 176 "2015-11-13-3-lives-saved", 177 "2018-04-23-talking-with-teens", 178 "2018-04-26-directors-note", 179 "2018-09-12-end-of-life-treatments-and-documents-what-you-need-to-know", 180 "2018-10-22-fresno-fair-recap", 181 "2019-05-02-right-to-life-radio-may-4-2019", 182 "2019-05-22-may-18-2019-right-to-life-radio", 183 "2020-03-23-new-video-series", 184 "2020-07-08-temporary-suspension-of-sidewalk-ministry", 185 "2020-10-14-in-memoriam-debby-bone", 186 "2020-12-12-ethical-guidance-on-covid-vaccines", 187 "2022-05-10-a-father-looks-at-mothers-day", 188 "2022-06-01-mom-choosing-life", 189 "2023-09-14-what-parents-need-to-know-children-abortion-contraception-and-parental-rights", 190]); 191 192export const ANALYTICS_STORY_SLUGS = new Set([ 193 "2026-03-07-zayda", 194 "2026-04-11-harmony", 195 "2026-05-09-kiana", 196 "2026-05-29-maya", 197 "2026-07-08-eva", 198 "2026-08-03-naomi", 199]); 200 201const CTA_IDS = new Set([ 202 "donate", 203 "the_300_monthly_submit", 204 "christmas_2026_tickets", 205 "christmas_2026_sponsorship_packet", 206 "christmas_home_hero_event", 207 "christmas_home_hero_donate", 208 "christmas_home_lifehouse", 209 "madera_fair_hub_volunteer", 210 "madera_fair_hub_support", 211 "madera_fair_hub_get_involved", 212 "madera_fair_hub_the_300", 213 "madera_fair_hub_share", 214 "madera_fair_hub_email_invite", 215 "madera_fair_get_involved_back", 216 "madera_fair_get_involved_submit", 217 "madera_fair_get_involved_start_another", 218 "madera_fair_hub_shift_thursday_5_7", 219 "madera_fair_hub_shift_thursday_7_9", 220 "madera_fair_hub_shift_thursday_9_11", 221 "madera_fair_hub_shift_friday_5_7", 222 "madera_fair_hub_shift_friday_7_9", 223 "madera_fair_hub_shift_friday_9_11", 224 "madera_fair_hub_shift_saturday_3_5", 225 "madera_fair_hub_shift_saturday_5_7", 226 "madera_fair_hub_shift_saturday_7_9", 227 "madera_fair_hub_shift_saturday_9_11", 228 "madera_fair_hub_shift_sunday_3_5", 229 "madera_fair_hub_shift_sunday_5_7", 230 "madera_fair_hub_shift_sunday_7_9", 231 "madera_fair_hub_shift_sunday_9_11", 232]); 233 234export const ANALYTICS_PROPERTY_VALUE_ALLOWLISTS = { 235 cta_id: CTA_IDS, 236 form_id: new Set([ 237 "form", 238 "madera_fair_get_involved", 239 "madera_fair_the_300", 240 ]), 241 resource_id: new Set(["pregnancy_help", "lifehouse", "rachels_vineyard"]), 242 story_slug: ANALYTICS_STORY_SLUGS, 243 funnel_step: new Set([ 244 "get_involved_complete", 245 "donation_complete", 246 "the_300_checkout", 247 "the_300_complete", 248 ]), 249 frequency: new Set(["one-time", "monthly"]), 250 amount_bucket: new Set(["25", "26_to_49", "50_to_99", "100_plus"]), 251 placement: new Set([ 252 "hero", 253 "volunteer_schedule", 254 "path_cards", 255 "invite_panel", 256 "madera_fair_checkout", 257 ]), 258 donation_program: new Set(["general", "the_300"]), 259}; 260 261const EVENT_OUTCOMES = { 262 story_engaged: new Set(["engaged"]), 263 volunteer_signup: new Set(["success", "volunteer_signup"]), 264 email_signup: new Set(["success", "email_signup"]), 265 sms_signup: new Set(["success", "sms_signup"]), 266 conversion: new Set([ 267 "success", 268 "volunteer_signup", 269 "email_signup", 270 "sms_signup", 271 ]), 272 donation_complete: new Set(["success"]), 273}; 274 275const EVENT_PROPERTY_ALLOWLISTS = { 276 page_view: new Set(["campaign_id"]), 277 cta_click: new Set(["cta_id", "destination_path", "placement", "funnel_step", "campaign_id"]), 278 story_view: new Set(["story_slug", "campaign_id"]), 279 story_engaged: new Set(["story_slug", "outcome", "campaign_id"]), 280 resource_view: new Set(["resource_id", "campaign_id"]), 281 outbound_help_click: new Set(["cta_id", "destination_path", "resource_id", "placement", "campaign_id"]), 282 form_start: new Set(["form_id", "funnel_step", "placement", "campaign_id"]), 283 form_submit: new Set(["form_id", "funnel_step", "placement", "campaign_id"]), 284 volunteer_signup: new Set(["form_id", "funnel_step", "outcome", "placement", "campaign_id"]), 285 email_signup: new Set(["form_id", "funnel_step", "outcome", "placement", "campaign_id"]), 286 sms_signup: new Set(["form_id", "funnel_step", "outcome", "placement", "campaign_id"]), 287 conversion: new Set([ 288 "form_id", 289 "funnel_step", 290 "frequency", 291 "amount_bucket", 292 "outcome", 293 "placement", 294 "campaign_id", 295 "donation_program", 296 ]), 297 donation_click: new Set([ 298 "cta_id", 299 "destination_path", 300 "funnel_step", 301 "frequency", 302 "amount_bucket", 303 "placement", 304 "campaign_id", 305 "donation_program", 306 ]), 307 donation_complete: new Set([ 308 "funnel_step", 309 "frequency", 310 "amount_bucket", 311 "outcome", 312 "placement", 313 "campaign_id", 314 "donation_program", 315 ]), 316}; 317 318const PROPERTY_LIMITS = { 319 cta_id: 80, 320 destination_path: 240, 321 form_id: 80, 322 resource_id: 80, 323 story_slug: 120, 324 funnel_step: 80, 325 frequency: 32, 326 amount_bucket: 32, 327 outcome: 32, 328 placement: 80, 329 campaign_id: 80, 330 donation_program: 80, 331}; 332 333const SAFE_ROUTE_PATH_PATTERN = /^\/(?:[a-z0-9][a-z0-9._~-]*(?:\/[a-z0-9][a-z0-9._~-]*)*)?$/u; 334const SAME_SITE_REFERRER_HOSTS = new Set([ 335 "righttolifeca.org", 336 "www.righttolifeca.org", 337 "rtlca.org", 338 "www.rtlca.org", 339]); 340const SENSITIVE_ROUTE_ROOTS = [ 341 "/pregnancy-help", 342 "/es/pregnancy-help", 343 "/lifehouse", 344 "/es/lifehouse", 345 "/rachels-vineyard", 346 "/es/rachels-vineyard", 347]; 348 349export const ANALYTICS_EVENT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1_000; 350export const ANALYTICS_TOUCH_MAX_AGE_MS = 400 * 24 * 60 * 60 * 1_000; 351export const ANALYTICS_TOUCH_ROTATION_AGE_MS = 393 * 24 * 60 * 60 * 1_000; 352export const ANALYTICS_FUTURE_SKEW_MS = 5 * 60 * 1_000; 353 354const ID_PATTERNS = { 355 e: /^e_[0-9a-f]{32}$/u, 356 v: /^v_[0-9a-f]{32}$/u, 357 s: /^s_[0-9a-f]{32}$/u, 358}; 359 360function cleanString(value, maxLength) { 361 if (typeof value !== "string") return undefined; 362 const cleaned = value.replace(/[\u0000-\u001f\u007f]/gu, "").trim(); 363 return cleaned && cleaned.length <= maxLength ? cleaned : undefined; 364} 365 366function hasUnsafePathSegment(path) { 367 if (path.includes("\\")) return true; 368 for (const rawSegment of path.split("/")) { 369 let segment = rawSegment; 370 for (let pass = 0; pass < 2; pass += 1) {
371 try { 372 const decoded = decodeURIComponent(segment); 373 if (decoded === segment) break; 374 segment = decoded; 375 } catch { 376 return true; 377 } 378 } 379 if (segment === "." || segment === ".." || segment.includes("/") || segment.includes("\\")) { 380 return true; 381 } 382 } 383 return false; 384} 385 386export function isKnownAnalyticsPath(path) { 387 if (STATIC_PATHS.has(path)) return true; 388 const match = path.match(/^\/(?:es\/)?(op-eds|updates|sidewalk-stories)\/([^/]+)$/u); 389 if (!match) return false; 390 const [, collection, slug] = match; 391 if (collection === "op-eds") return OP_ED_SLUGS.has(slug); 392 if (collection === "updates") return UPDATE_SLUGS.has(slug); 393 return ANALYTICS_STORY_SLUGS.has(slug); 394} 395 396export function canonicalAnalyticsPath(value) { 397 const parsed = cleanString(value, 240); 398 if ( 399 !parsed || 400 !parsed.startsWith("/") || 401 parsed.startsWith("//") || 402 parsed.includes("?") || 403 parsed.includes("#") || 404 hasUnsafePathSegment(parsed) 405 ) { 406 return undefined; 407 } 408 if (parsed === "/") return parsed; 409 const collapsed = parsed.replace(/\/{2,}/gu, "/"); 410 const canonical = collapsed.endsWith("/") ? collapsed.slice(0, -1) : collapsed; 411 if (!SAFE_ROUTE_PATH_PATTERN.test(canonical)) return undefined; 412 if (SENSITIVE_ROUTE_ROOTS.some((root) => canonical.startsWith(`${root}/`))) return undefined; 413 return isKnownAnalyticsPath(canonical) ? canonical : undefined; 414} 415 416export function cleanAnalyticsHostname(value) { 417 const parsed = cleanString(value, 120)?.toLowerCase(); 418 if (!parsed) return undefined; 419 const labels = parsed.split("."); 420 const validHostname = labels.every( 421 (label) => 422 label.length >= 1 && 423 label.length <= 63 && 424 /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(label), 425 ); 426 const isIpv4 = labels.length === 4 && labels.every((label) => /^\d{1,3}$/u.test(label)); 427 if ( 428 !validHostname || 429 !/[a-z]/u.test(parsed) || 430 isIpv4 || 431 parsed.includes(":") || 432 parsed.includes("/") || 433 parsed.includes("@") || 434 parsed.includes(" ") 435 ) { 436 return undefined; 437 } 438 return parsed; 439} 440 441export function isAnalyticsId(value, prefix) { 442 return typeof value === "string" && Boolean(ID_PATTERNS[prefix]?.test(value)); 443} 444 445export function cleanAnalyticsTimestamp(value, maxAgeMs, nowMs = Date.now()) { 446 const parsed = cleanString(value, 40); 447 if (!parsed) return undefined; 448 const timestamp = new Date(parsed); 449 const time = timestamp.getTime(); 450 if (!Number.isFinite(time) || timestamp.toISOString() !== parsed) return undefined; 451 if (time > nowMs + ANALYTICS_FUTURE_SKEW_MS || nowMs - time > maxAgeMs) return undefined; 452 return parsed; 453} 454 455export function cleanAnalyticsCampaignValue(key, value) { 456 const parsed = cleanString(value, 100); 457 if (!parsed || !ANALYTICS_UTM_KEYS.includes(key)) return undefined; 458 const enumValues = ANALYTICS_UTM_ENUMS[key]; 459 if (enumValues) return enumValues.has(parsed) ? parsed : undefined; 460 return isAnalyticsCampaignSlug(parsed) ? parsed : undefined; 461} 462 463export function sanitizeAnalyticsTouch(value, maxAgeMs = ANALYTICS_TOUCH_MAX_AGE_MS, nowMs = Date.now()) { 464 if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; 465 const landingPath = canonicalAnalyticsPath(value.landing_path); 466 const occurredAt = cleanAnalyticsTimestamp(value.occurred_at, maxAgeMs, nowMs); 467 if (!landingPath || !occurredAt) return undefined; 468 469 const output = { landing_path: landingPath, occurred_at: occurredAt }; 470 const referrerHost = cleanAnalyticsHostname(value.referrer_host); 471 if (referrerHost) output.referrer_host = referrerHost; 472 if (value.referrer_path !== undefined) { 473 const referrerPath = canonicalAnalyticsPath(value.referrer_path); 474 if (referrerPath && referrerHost && SAME_SITE_REFERRER_HOSTS.has(referrerHost)) { 475 output.referrer_path = referrerPath; 476 } 477 } 478 for (const key of ANALYTICS_UTM_KEYS) { 479 const campaignValue = cleanAnalyticsCampaignValue(key, value[key]); 480 if (campaignValue) output[key] = campaignValue; 481 } 482 return output; 483} 484 485export function isAllowedAnalyticsPropertyValue(key, value, eventType) { 486 if (typeof value !== "string") return false;
487 if (key === "outcome") return Boolean(EVENT_OUTCOMES[eventType]?.has(value)); 488 if (key === "campaign_id") return isAnalyticsCampaignSlug(value); 489 return Boolean(ANALYTICS_PROPERTY_VALUE_ALLOWLISTS[key]?.has(value)); 490} 491 492export function sanitizeAnalyticsProperties(eventType, value) { 493 if (!value || typeof value !== "object" || Array.isArray(value)) return {}; 494 const allowedKeys = EVENT_PROPERTY_ALLOWLISTS[eventType]; 495 if (!allowedKeys) return {}; 496 const output = {}; 497 for (const key of allowedKeys) { 498 const maxLength = PROPERTY_LIMITS[key]; 499 if (key === "destination_path") { 500 const path = canonicalAnalyticsPath(value[key]); 501 if (path) output[key] = path; 502 continue; 503 } 504 const parsed = cleanString(value[key], maxLength); 505 if (parsed && isAllowedAnalyticsPropertyValue(key, parsed, eventType)) output[key] = parsed; 506 } 507 return output; 508} 509 510export function sanitizeAnalyticsEvent(value, nowMs = Date.now()) { 511 if (!value || typeof value !== "object" || Array.isArray(value)) return null; 512 if (value.schema_version !== ANALYTICS_SCHEMA_VERSION) return null; 513 if (!ANALYTICS_EVENT_TYPE_SET.has(value.event_type)) return null; 514 if (!isAnalyticsId(value.event_id, "e")) return null; 515 if (!isAnalyticsId(value.visitor_id, "v")) return null; 516 if (!isAnalyticsId(value.session_id, "s")) return null; 517 518 const occurredAt = cleanAnalyticsTimestamp(value.occurred_at, ANALYTICS_EVENT_MAX_AGE_MS, nowMs); 519 const pagePath = canonicalAnalyticsPath(value.page?.path); 520 const firstTouch = sanitizeAnalyticsTouch(value.attribution?.first_touch, ANALYTICS_TOUCH_MAX_AGE_MS, nowMs); 521 const currentTouch = sanitizeAnalyticsTouch(value.attribution?.current_touch, ANALYTICS_TOUCH_MAX_AGE_MS, nowMs); 522 if (!occurredAt || !pagePath || !firstTouch || !currentTouch) return null; 523 if (Date.parse(firstTouch.occurred_at) > Date.parse(currentTouch.occurred_at)) return null; 524 if (Date.parse(currentTouch.occurred_at) > Date.parse(occurredAt)) return null; 525 526 const page = { path: pagePath }; 527 const title = cleanString(value.page?.title, 160); 528 if (title) page.title = title; 529 if (value.page?.language === "en" || value.page?.language === "es") { 530 page.language = value.page.language; 531 } 532 const previousPath = canonicalAnalyticsPath(value.page?.previous_path); 533 if (previousPath) page.previous_path = previousPath; 534 const referrerHost = cleanAnalyticsHostname(value.page?.referrer_host); 535 if (referrerHost) page.referrer_host = referrerHost; 536 const referrerPath = canonicalAnalyticsPath(value.page?.referrer_path); 537 if (referrerPath && referrerHost && SAME_SITE_REFERRER_HOSTS.has(referrerHost)) { 538 page.referrer_path = referrerPath; 539 } 540 541 const properties = sanitizeAnalyticsProperties(value.event_type, value.properties); 542 if (value.event_type === "story_view" || value.event_type === "story_engaged") { 543 const pageStorySlug = pagePath.match(/^\/(?:es\/)?sidewalk-stories\/([^/]+)$/u)?.[1]; 544 if (!pageStorySlug || properties.story_slug !== pageStorySlug) return null; 545 } 546 547 return { 548 schema_version: ANALYTICS_SCHEMA_VERSION, 549 event_id: value.event_id, 550 visitor_id: value.visitor_id, 551 session_id: value.session_id, 552 event_type: value.event_type, 553 occurred_at: occurredAt, 554 page, 555 attribution: { 556 first_touch: firstTouch, 557 current_touch: currentTouch, 558 }, 559 properties, 560 }; 561}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.