@koda-sl/baker-cli 0.354.0-dev.faae9de67 → 0.354.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/dist/cli.js CHANGED
@@ -68,7 +68,7 @@ import {
68
68
  validateCanvasDeep,
69
69
  withoutCastName,
70
70
  ytDlpBlockSignal
71
- } from "./chunk-B7MLH3K6.js";
71
+ } from "./chunk-4HRJQGVW.js";
72
72
  import {
73
73
  csvOrJson,
74
74
  daysAgoIso,
@@ -10564,6 +10564,102 @@ function hintsFor(scope, days) {
10564
10564
  return hints2;
10565
10565
  }
10566
10566
 
10567
+ // src/commands/ads/google/list-fields.ts
10568
+ var LIST_FIELDS = /* @__PURE__ */ new Set([
10569
+ "ad.final_mobile_urls",
10570
+ "ad.final_urls",
10571
+ "ad_group.excluded_parent_asset_field_types",
10572
+ "ad_group.excluded_parent_asset_set_types",
10573
+ "ad_group.labels",
10574
+ "ad_group.primary_status_reasons",
10575
+ "ad_group_ad.ad.final_mobile_urls",
10576
+ "ad_group_ad.ad.final_urls",
10577
+ "ad_group_ad.labels",
10578
+ "ad_group_ad.primary_status_reasons",
10579
+ "ad_group_asset.primary_status_reasons",
10580
+ "ad_group_criterion.disapproval_reasons",
10581
+ "ad_group_criterion.final_mobile_urls",
10582
+ "ad_group_criterion.final_urls",
10583
+ "ad_group_criterion.labels",
10584
+ "ad_group_criterion.primary_status_reasons",
10585
+ "ad_group_criterion.topic.path",
10586
+ "ad_group_criterion.webpage.sample.sample_urls",
10587
+ "asset.dynamic_custom_asset.contextual_keywords",
10588
+ "asset.dynamic_custom_asset.similar_ids",
10589
+ "asset.dynamic_education_asset.contextual_keywords",
10590
+ "asset.dynamic_education_asset.similar_program_ids",
10591
+ "asset.dynamic_flights_asset.similar_destination_ids",
10592
+ "asset.dynamic_hotels_and_rentals_asset.contextual_keywords",
10593
+ "asset.dynamic_hotels_and_rentals_asset.similar_property_ids",
10594
+ "asset.dynamic_jobs_asset.contextual_keywords",
10595
+ "asset.dynamic_jobs_asset.similar_job_ids",
10596
+ "asset.dynamic_local_asset.contextual_keywords",
10597
+ "asset.dynamic_local_asset.similar_deal_ids",
10598
+ "asset.dynamic_real_estate_asset.contextual_keywords",
10599
+ "asset.dynamic_real_estate_asset.similar_listing_ids",
10600
+ "asset.dynamic_travel_asset.contextual_keywords",
10601
+ "asset.dynamic_travel_asset.similar_destination_ids",
10602
+ "asset.final_mobile_urls",
10603
+ "asset.final_urls",
10604
+ "asset.page_feed_asset.labels",
10605
+ "asset.structured_snippet_asset.values",
10606
+ "asset_group.final_mobile_urls",
10607
+ "asset_group.final_urls",
10608
+ "asset_group.primary_status_reasons",
10609
+ "asset_group_asset.primary_status_reasons",
10610
+ "asset_group_signal.disapproval_reasons",
10611
+ "asset_set.business_profile_location_group.dynamic_business_profile_location_group_filter.label_filters",
10612
+ "asset_set.business_profile_location_group.dynamic_business_profile_location_group_filter.listing_id_filters",
10613
+ "asset_set.location_set.business_profile_location_set.label_filters",
10614
+ "asset_set.location_set.business_profile_location_set.listing_id_filters",
10615
+ "bidding_data_exclusion.advertising_channel_types",
10616
+ "bidding_data_exclusion.campaigns",
10617
+ "bidding_data_exclusion.devices",
10618
+ "bidding_seasonality_adjustment.advertising_channel_types",
10619
+ "bidding_seasonality_adjustment.campaigns",
10620
+ "bidding_seasonality_adjustment.devices",
10621
+ "campaign.excluded_parent_asset_field_types",
10622
+ "campaign.excluded_parent_asset_set_types",
10623
+ "campaign.feed_types",
10624
+ "campaign.labels",
10625
+ "campaign.optimization_goal_setting.optimization_goal_types",
10626
+ "campaign.primary_status_reasons",
10627
+ "campaign.selective_optimization.conversion_actions",
10628
+ "campaign.shopping_setting.advertising_partner_ids",
10629
+ "campaign.text_guidelines.term_exclusions",
10630
+ "campaign_asset.primary_status_reasons",
10631
+ "campaign_criterion.location_group.geo_target_constants",
10632
+ "campaign_criterion.topic.path",
10633
+ "campaign_criterion.webpage.sample.sample_urls",
10634
+ "conversion_value_rule.audience_condition.user_interests",
10635
+ "conversion_value_rule.audience_condition.user_lists",
10636
+ "conversion_value_rule.device_condition.device_types",
10637
+ "conversion_value_rule.geo_location_condition.excluded_geo_target_constants",
10638
+ "conversion_value_rule.geo_location_condition.geo_target_constants",
10639
+ "conversion_value_rule_set.conversion_value_rules",
10640
+ "conversion_value_rule_set.dimensions",
10641
+ "custom_conversion_goal.conversion_actions",
10642
+ "customer.pay_per_conversion_eligibility_failure_reasons",
10643
+ "customer_asset.primary_status_reasons",
10644
+ "customer_client.applied_labels",
10645
+ "experiment_arm.campaigns",
10646
+ "experiment_arm.in_design_campaigns",
10647
+ "keyword_plan_campaign.language_constants",
10648
+ "local_services_employee.category_ids",
10649
+ "local_services_employee.languages_spoken",
10650
+ "metrics.interaction_event_types",
10651
+ "recommendation.campaigns",
10652
+ "shared_criterion.webpage.sample.sample_urls",
10653
+ "shopping_product.target_countries",
10654
+ "topic_constant.path",
10655
+ "user_list.lookalike_user_list.country_codes",
10656
+ "user_list.lookalike_user_list.seed_user_list_ids"
10657
+ ]);
10658
+ var LIST_FIELD_OPERATORS = "CONTAINS ALL, CONTAINS ANY, CONTAINS NONE";
10659
+ function listFieldGuidance(field) {
10660
+ return `${field} is a list field: it takes only ${LIST_FIELD_OPERATORS} \u2014 no comparison (=, !=, IN), pattern match or null test. To find rows that have it, drop that condition, keep ${field} in SELECT and filter the rows (rows without it omit the key); to match values, use ${field} CONTAINS ANY ('value')`;
10661
+ }
10662
+
10567
10663
  // src/commands/ads/google/correction-table.ts
10568
10664
  function buildCommand(query, ctx) {
10569
10665
  return `baker ads google query "${query}" --customer-id ${ctx.customerId}`;
@@ -10591,6 +10687,27 @@ var CORRECTION_RULES = [
10591
10687
  };
10592
10688
  }
10593
10689
  },
10690
+ // 0b. OPERATOR_FIELD_MISMATCH — the field refuses this operator, and Google names the
10691
+ // ones it takes. Kept ahead of the field-specific rules for the same reason as rule 0.
10692
+ // A repeated field (final_urls, labels, …) takes only CONTAINS ALL/ANY/NONE, so it has
10693
+ // no null test at all: read it unfiltered and filter the rows instead.
10694
+ {
10695
+ matchApiError: /OPERATOR_FIELD_MISMATCH|is not a valid operator to use with/i,
10696
+ fix: (ctx) => {
10697
+ const message = ctx.apiErrorMessage ?? "";
10698
+ const field = message.match(/valid operator to use with '([\w.]+)'/i)?.[1] ?? "this field";
10699
+ const operator = message.match(/([A-Z=!<>][A-Z_ =!<>]*?) is not a valid operator/)?.[1]?.trim();
10700
+ const valid = message.match(/Valid operators: (.+?)\.?(?:"|$)/i)?.[1]?.trim();
10701
+ const refused = operator ? `${operator} cannot be used on ${field}` : `${field} does not take that operator`;
10702
+ const allowed = valid ? `; its valid operators are ${valid}` : "";
10703
+ const repeated = valid?.split(",").every((op) => /^CONTAINS (ALL|ANY|NONE)$/i.test(op.trim())) ?? false;
10704
+ const guidance = repeated ? `. ${listFieldGuidance(field)}` : ". Rewrite that condition with one of them, or drop it and filter the rows";
10705
+ return {
10706
+ action: "change_operator",
10707
+ explanation: `${refused}${allowed}${guidance}`
10708
+ };
10709
+ }
10710
+ },
10594
10711
  // 1. bare keyword.text → ad_group_criterion.keyword.text (leaves <resource>.keyword.text untouched)
10595
10712
  {
10596
10713
  matchQuery: /(?<![\w.])keyword\.text\b/,
@@ -10633,10 +10750,11 @@ var CORRECTION_RULES = [
10633
10750
  };
10634
10751
  }
10635
10752
  },
10636
- // 4. CONTAINS → LIKE
10753
+ // 4. CONTAINS → LIKE. Keyed on the query alone: Google's error envelope ("Request contains an
10754
+ // invalid argument … Valid operators: CONTAINS ANY") matches any loose error pattern, and this
10755
+ // advice only makes sense when the query actually wrote CONTAINS(…).
10637
10756
  {
10638
10757
  matchQuery: /CONTAINS\s*\(/i,
10639
- matchApiError: /CONTAINS.*not.*supported|invalid.*operator.*CONTAINS/i,
10640
10758
  fix: (ctx) => {
10641
10759
  const match = ctx.originalQuery.match(/CONTAINS\s*\(\s*([^,]+),\s*'([^']+)'\s*\)/i);
10642
10760
  if (match) {
@@ -13082,6 +13200,73 @@ function rejectOversizedLimit(query, customerId) {
13082
13200
  }
13083
13201
  };
13084
13202
  }
13203
+ var WHERE_OPERATOR = /(?<![\w.])([a-z_]+(?:\.[a-z_]+)+)\s*(CONTAINS\s+(?:ALL|ANY|NONE)|IS\s+NOT\s+NULL|IS\s+NULL|NOT\s+LIKE|NOT\s+IN|NOT\s+REGEXP_MATCH|REGEXP_MATCH|LIKE|IN|BETWEEN|DURING|!=|<=|>=|=|<|>)/gi;
13204
+ function escapeRegExp(text2) {
13205
+ return text2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
13206
+ }
13207
+ function withoutNullTest(query, field) {
13208
+ const test = `${escapeRegExp(field)}\\s+IS\\s+(?:NOT\\s+)?NULL\\b`;
13209
+ const dropped = query.replace(new RegExp(`\\bWHERE\\s+${test}\\s+AND\\s+`, "i"), "WHERE ").replace(new RegExp(`\\s+AND\\s+${test}`, "i"), "").replace(new RegExp(`\\s*\\bWHERE\\s+${test}`, "i"), "");
13210
+ return mentions(selectClause(dropped), field) ? dropped : dropped.replace(/SELECT\s+/i, `SELECT ${field}, `);
13211
+ }
13212
+ function asListCondition(query, field) {
13213
+ const condition = new RegExp(
13214
+ `(?<![\\w.])${escapeRegExp(field)}\\s*(NOT\\s+IN\\b|IN\\b|!=|=)\\s*('[^']*'|\\([^)]*\\))`,
13215
+ "i"
13216
+ );
13217
+ const match = query.match(condition);
13218
+ if (!match) return null;
13219
+ const [, operator = "", value = ""] = match;
13220
+ const contains = /^(=|IN)$/i.test(operator.trim()) ? "CONTAINS ANY" : "CONTAINS NONE";
13221
+ const values = value.startsWith("(") ? value : `(${value})`;
13222
+ return query.replace(condition, `${field} ${contains} ${values}`);
13223
+ }
13224
+ function rejectListFieldOperator(query, customerId) {
13225
+ const where = whereClause(query).replace(/'[^']*'/g, "''");
13226
+ const refusals = [...where.matchAll(WHERE_OPERATOR)].map(([, field = "", operator = ""]) => ({
13227
+ field: field.toLowerCase(),
13228
+ operator: operator.toUpperCase().replace(/\s+/g, " ")
13229
+ })).filter(({ field, operator }) => LIST_FIELDS.has(field) && !operator.startsWith("CONTAINS"));
13230
+ const first = refusals[0];
13231
+ if (!first) return null;
13232
+ const fixed = refusals.reduce(
13233
+ (corrected, { field, operator }) => corrected === null ? null : operator.startsWith("IS ") ? withoutNullTest(corrected, field) : asListCondition(corrected, field),
13234
+ query
13235
+ );
13236
+ return {
13237
+ ok: false,
13238
+ error: {
13239
+ code: "INVALID_OPERATOR",
13240
+ message: `${first.operator} cannot be used on ${first.field}: it is a list field, so Google would refuse this query.`,
13241
+ fix: {
13242
+ action: "change_operator",
13243
+ ...fixed ? { correctedCommand: buildCommand2(fixed, customerId) } : {},
13244
+ explanation: listFieldGuidance(first.field)
13245
+ },
13246
+ retryable: false
13247
+ }
13248
+ };
13249
+ }
13250
+ function rejectContainsCall(query, customerId) {
13251
+ const containsMatch = query.match(/CONTAINS\s*\(\s*([^,]+),\s*'([^']+)'\s*\)/i);
13252
+ if (!containsMatch) return null;
13253
+ const field = containsMatch[1]?.trim();
13254
+ const value = containsMatch[2] ?? "";
13255
+ const fixed = query.replace(/CONTAINS\s*\(\s*[^,]+,\s*'[^']+'\s*\)/i, `${field} LIKE '%${value}%'`);
13256
+ return {
13257
+ ok: false,
13258
+ error: {
13259
+ code: "INVALID_OPERATOR",
13260
+ message: "GAQL does not support CONTAINS. Use LIKE with % wildcards.",
13261
+ fix: {
13262
+ action: "change_operator",
13263
+ correctedCommand: buildCommand2(fixed, customerId),
13264
+ explanation: "GAQL uses LIKE '%value%' for substring matching, not CONTAINS()"
13265
+ },
13266
+ retryable: false
13267
+ }
13268
+ };
13269
+ }
13085
13270
  function validatePreflight(query, customerId, limit) {
13086
13271
  const warnings = [];
13087
13272
  const corrected = query;
@@ -13104,29 +13289,6 @@ function validatePreflight(query, customerId, limit) {
13104
13289
  }
13105
13290
  };
13106
13291
  }
13107
- const containsMatch = corrected.match(/CONTAINS\s*\(\s*([^,]+),\s*'([^']+)'\s*\)/i);
13108
- if (containsMatch) {
13109
- const field = containsMatch[1]?.trim();
13110
- const value = containsMatch[2] ?? "";
13111
- const fixed = corrected.replace(/CONTAINS\s*\(\s*[^,]+,\s*'[^']+'\s*\)/i, `${field} LIKE '%${value}%'`);
13112
- return {
13113
- valid: false,
13114
- warnings: [],
13115
- error: {
13116
- ok: false,
13117
- error: {
13118
- code: "INVALID_OPERATOR",
13119
- message: "GAQL does not support CONTAINS. Use LIKE with % wildcards.",
13120
- fix: {
13121
- action: "change_operator",
13122
- correctedCommand: buildCommand2(fixed, customerId),
13123
- explanation: "GAQL uses LIKE '%value%' for substring matching, not CONTAINS()"
13124
- },
13125
- retryable: false
13126
- }
13127
- }
13128
- };
13129
- }
13130
13292
  const openDateMatch = corrected.match(/segments\.date\s*>=\s*'(\d{4}-\d{2}-\d{2})'/i);
13131
13293
  if (openDateMatch && !/BETWEEN/i.test(corrected)) {
13132
13294
  const startDate = openDateMatch[1] ?? "";
@@ -13153,10 +13315,10 @@ function validatePreflight(query, customerId, limit) {
13153
13315
  }
13154
13316
  };
13155
13317
  }
13156
- const duringRejection = rejectUnsupportedDuring(corrected, customerId);
13157
- if (duringRejection) return { valid: false, warnings: [], error: duringRejection };
13158
- const limitRejection = rejectOversizedLimit(corrected, customerId);
13159
- if (limitRejection) return { valid: false, warnings: [], error: limitRejection };
13318
+ for (const reject of [rejectContainsCall, rejectListFieldOperator, rejectUnsupportedDuring, rejectOversizedLimit]) {
13319
+ const rejection2 = reject(corrected, customerId);
13320
+ if (rejection2) return { valid: false, warnings: [], error: rejection2 };
13321
+ }
13160
13322
  if (/\bsegments\.conversion_action_name\b/i.test(corrected) && /\bmetrics\.cost_micros\b/i.test(corrected)) {
13161
13323
  return {
13162
13324
  valid: false,
@@ -47115,80 +47277,6 @@ async function declarationsOf(root, files) {
47115
47277
  return sources.flatMap(eventDeclarationsIn);
47116
47278
  }
47117
47279
 
47118
- // src/commands/experiment/readout.ts
47119
- function isoMoment(ms) {
47120
- return ms === null ? null : new Date(ms).toISOString();
47121
- }
47122
- function isoDay2(ms) {
47123
- return ms === null ? null : new Date(ms).toISOString().slice(0, 10);
47124
- }
47125
- function compactArm(arm) {
47126
- return { visitors: arm.visitors, conversions: arm.conversions, rate: Math.round(arm.rate * 1e4) / 1e4 };
47127
- }
47128
- function compactMetric(metric) {
47129
- return {
47130
- name: metric.name,
47131
- reading: metric.reading,
47132
- control: metric.control.conversions,
47133
- variant: metric.variant.conversions
47134
- };
47135
- }
47136
- function fullArm(arm) {
47137
- return {
47138
- ...arm,
47139
- ...arm.snapshotCapturedAt === void 0 ? {} : { snapshotCapturedAt: isoMoment(arm.snapshotCapturedAt) }
47140
- };
47141
- }
47142
- function statusReadout(row, { full, now }) {
47143
- const decisionWhen = {
47144
- earliestDecisionAt: isoDay2(row.earliestDecisionAt),
47145
- ...row.earliestDecisionAt === null ? {} : { earliestDecisionIn: describeWait(row.earliestDecisionAt, now) }
47146
- };
47147
- const moments = {
47148
- startedAt: isoMoment(row.startedAt),
47149
- pausedAt: isoMoment(row.pausedAt),
47150
- finishedAt: isoMoment(row.finishedAt)
47151
- };
47152
- if (full) {
47153
- return {
47154
- ...row,
47155
- ...moments,
47156
- ...decisionWhen,
47157
- control: fullArm(row.control),
47158
- variant: fullArm(row.variant)
47159
- };
47160
- }
47161
- return {
47162
- experimentId: row.experimentId,
47163
- landingSlug: row.landingSlug,
47164
- variantSlug: row.variantSlug,
47165
- status: row.status,
47166
- verdict: row.verdict,
47167
- summary: row.summary,
47168
- ...row.winner === null ? {} : { winner: row.winner },
47169
- ...row.invalidReason === null ? {} : { invalidReason: row.invalidReason },
47170
- stillNeeded: row.stillNeeded,
47171
- ...decisionWhen,
47172
- willNotConclude: row.willNotConclude,
47173
- control: compactArm(row.control),
47174
- variant: compactArm(row.variant),
47175
- secondary: row.secondary.map(compactMetric),
47176
- hypothesisStatement: row.hypothesisStatement,
47177
- minDetectableRelativeLift: row.minDetectableRelativeLift,
47178
- goal: row.goal,
47179
- ...row.variantGoal === null ? {} : { variantGoal: row.variantGoal },
47180
- goalLabel: row.goalLabel,
47181
- ...moments,
47182
- ...row.status === "finished" ? {
47183
- survivor: row.survivor,
47184
- hypothesisOutcome: row.hypothesisOutcome,
47185
- fold: row.fold,
47186
- learning: row.learning,
47187
- endReason: row.endReason
47188
- } : {}
47189
- };
47190
- }
47191
-
47192
47280
  // src/commands/experiment/update.ts
47193
47281
  function buildUpdateRequest(args5, rawArgs) {
47194
47282
  const expect = args5.expect === void 0 ? void 0 : String(args5.expect);
@@ -47430,26 +47518,18 @@ registerSchema({
47430
47518
  args: {
47431
47519
  id: { type: "string", description: "One test, by its id", required: false },
47432
47520
  landing: LANDING_FILTER_ARG,
47433
- full: {
47434
- type: "boolean",
47435
- description: "Every field the dashboard reads too \u2014 the hypothesis in parts, what each version owns, the preview pictures and the posteriors behind the verdict",
47436
- required: false
47437
- }
47521
+ full: { type: "boolean", description: "Include the posteriors behind the verdict", required: false }
47438
47522
  }
47439
47523
  });
47440
47524
  var statusCommand6 = defineCommand124({
47441
47525
  meta: {
47442
47526
  name: "status",
47443
- description: "The verdict on each test. Read `verdict` and nothing else to decide: keep_running | winner | no_difference | cannot_win | invalid. `cannot_win` means even the best case is under the lift the test was looking for \u2014 end it and keep the original. `stillNeeded` is the data (visitors, conversions) the test still needs; a test is gated on data, never on days. `earliestDecisionAt` is the day this page's traffic would get it there (YYYY-MM-DD) and `earliestDecisionIn` how far off that is \u2014 say those, never work a date out yourself. `summary` is one sentence you can show the client, and `secondary` lists every other conversion the company counts on the same two arms \u2014 context, never a second decision. The numbers under `--full` are for printing, never for deciding \u2014 a test that says keep_running has not finished, however good its numbers look."
47527
+ description: "The verdict on each test. Read `verdict` and nothing else to decide: keep_running | winner | no_difference | cannot_win | invalid. `cannot_win` means even the best case is under the lift the test was looking for \u2014 end it and keep the original. `stillNeeded` is the data (visitors, conversions) the test still needs; a test is gated on data, never on days. `summary` is one sentence you can show the client, and `secondary` lists every other conversion the company counts on the same two arms \u2014 context, never a second decision. The numbers under `--full` are for printing, never for deciding \u2014 a test that says keep_running has not finished, however good its numbers look."
47444
47528
  },
47445
47529
  args: {
47446
47530
  id: { type: "string", description: "One test, by its id", required: false },
47447
47531
  landing: LANDING_FILTER_ARG,
47448
- full: {
47449
- type: "boolean",
47450
- description: "Every field the dashboard reads too \u2014 the hypothesis in parts, what each version owns, the preview pictures and the posteriors behind the verdict",
47451
- required: false
47452
- }
47532
+ full: { type: "boolean", description: "Include the posteriors behind the verdict", required: false }
47453
47533
  },
47454
47534
  run: async ({ args: args5 }) => {
47455
47535
  try {
@@ -47458,13 +47538,10 @@ var statusCommand6 = defineCommand124({
47458
47538
  ...args5.landing ? { landingSlug: String(args5.landing) } : {},
47459
47539
  ...args5.full === true ? { full: true } : {}
47460
47540
  });
47461
- const now = Date.now();
47462
47541
  writeJsonEnvelope({
47463
47542
  ok: true,
47464
- data: {
47465
- experiments: response.data.experiments.map((row) => statusReadout(row, { full: args5.full === true, now }))
47466
- },
47467
- hints: buildStatusHints(response.data.experiments, now)
47543
+ data: response.data,
47544
+ hints: buildStatusHints(response.data.experiments)
47468
47545
  });
47469
47546
  } catch (error) {
47470
47547
  reportApiError(error);
@@ -57608,7 +57685,7 @@ function mentionsInSource(source, names) {
57608
57685
  const out = [];
57609
57686
  for (const name of names) {
57610
57687
  const seen = /* @__PURE__ */ new Set();
57611
- for (const m of visible.matchAll(new RegExp(`\\b${escapeRegExp(name)}\\b`, "gi"))) {
57688
+ for (const m of visible.matchAll(new RegExp(`\\b${escapeRegExp2(name)}\\b`, "gi"))) {
57612
57689
  const at = m.index ?? 0;
57613
57690
  if (tables.some(([from, to]) => at >= from && at < to)) continue;
57614
57691
  const start = sentenceStart(visible, at);
@@ -57637,7 +57714,7 @@ function blankHtmlKeepingOffsets(text2) {
57637
57714
  }
57638
57715
  return out.join("");
57639
57716
  }
57640
- function escapeRegExp(value) {
57717
+ function escapeRegExp2(value) {
57641
57718
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
57642
57719
  }
57643
57720
  function detectSource(source) {