PageSourceSearch

https://rpksakh.ru/analytics.js?pageID=vpn

js rpksakh.ru collected 2026-10-07 18:12:54 UTC 68,600 bytes, 1,573 lines download raw bytes

1// Function to generate a random ID
2function generateID() {
3  return '_' + Math.random().toString(36).substr(2, 9);
4}
5
6// Function to get cookie by name
7function getCookie(name) {
8  return document.cookie.replace(new RegExp(`(?:(?:^|.*;\\s*)${name}\\s*\\=\\s*([^;]*).*$)|^.*$`), "$1");
9}
10
11// Function to set cookie
12function setCookie(name, value, days) {
13  const expires = days ? `; expires=${new Date(Date.now() + days * 864e5).toUTCString()}` : "";
14  document.cookie = `${name}=${value}${expires}; path=/`;
15}
16
17// The <script> element this file was loaded through. document.currentScript is
18// only valid during initial script execution, which is when these helpers run.
19function getScriptElement() {
20  return document.currentScript || (function() {
21    const scripts = document.getElementsByTagName('script');
22    return scripts[scripts.length - 1];
23  })();
24}
25
26// Function to get query parameter from script src
27function getScriptQueryParam(key) {
28  const params = new URLSearchParams(getScriptElement().src.split('?')[1]);
29  return params.get(key);
30}
31
32// Function to get a data-* attribute from the script tag
33function getScriptAttribute(name) {
34  const el = getScriptElement();
35  return el ? el.getAttribute(name) : null;
36}
37
38// Initialize User and Session IDs
39let userID = getCookie('userID');
40let sessionID = getCookie('sessionID');
41
42if (!userID) {
43  userID = generateID();
44  setCookie('userID', userID, 3650); // expires in 10 years
45}
46
47if (!sessionID) {
48  sessionID = generateID();
49  setCookie('sessionID', sessionID, 1); // Session cookie, expire in 1 day
50}
51
52// Optionally get the pageID
53const pageID = getScriptQueryParam('pageID');
54
55// Where this visitor was redirected to: set by nginx (@default_fallback / fb
56// redirect) to the variant they were funnelled into (e.g. "vpn/", "manyvoices/zh",
57// "fb"). Absent for visitors who arrive directly at a landing URL. Sent once with
58// the initial pageview, then cleared so refreshes/later views count as direct.
59const redirectedTo = getCookie('smm_redirected_to');
60
61// Banner A/B test, round 3. 'card' is the corner card that has now won two
62// rounds; 'sheet' keeps that card on desktop and on phones anchors it to the
63// bottom edge as a native-style sheet with a larger tap target. Round 2 tried
64// to fix desktop and found there was nothing there to fix — roughly nine in ten
65// desktop impressions are automated — so this round only moves mobile, where
66// interaction-verified visitors click at about 13%. Sticky per visitor so
67// nobody sees both arms, and recorded on every pageview (see below) so each arm
68// has an impressions denominator to divide its clicks by. A new cookie name
69// again: reusing smm_bv2 would pin every round 2 visitor to the arm they
70// already had.
71let bannerVariant = getCookie('smm_bv3');
72if (bannerVariant !== 'card' && bannerVariant !== 'sheet') {
73  bannerVariant = Math.random() < 0.5 ? 'card' : 'sheet';
74  setCookie('smm_bv3', bannerVariant, 90);
75}
76
77// First segment of the paths the banner links to (see fbPath / vpnPath below —
78// keep in step with them). Anywhere inside those sections the banner would be
79// advertising the destination the visitor is already reading, so it is not
80// shown at all. Both arms are suppressed on the same sections, otherwise the
81// two arms would be measured over different pages. Decided here rather than in
82// the banner code because the pageview below has to know: a suppressed view is
83// not an impression and must not land in either arm's denominator.
84const BANNER_DESTINATIONS = ['fb', 'vpn'];
85const bannerSuppressed = BANNER_DESTINATIONS.indexOf(
86  (window.location.pathname.split('/').filter(p => p !== '')[0] || '').toLowerCase()
87) !== -1;
88
89// Operator opt-out: data-banner="false" on the <script> tag turns the banner
90// off for that mirror. Read here, ahead of the banner code, for the same
91// reason as bannerSuppressed above — the pageview below must tag these views
92// 'bv:none' so they never land in an arm's impression denominator.
93const bannerDisabled = getScriptAttribute('data-banner') === 'false';
94
95// Tags that are recorded but must not stay in the address bar: campaign tags
96// (utm_*), and od — the domain the failover service worker hopped the visitor
97// away from, which would otherwise travel with every shared link.
98function isArrivalTag(key) {
99  return key === 'od' || key.lastIndexOf('utm_', 0) === 0;
100}
101
102// Tags the visitor arrived with, captured before they are stripped from the
103// address bar (see stripArrivalTagsFromUrl below). Duplicates are collapsed to
104// the first value: URLs in circulation carry the same tag repeated many times.
105const arrivalTags = (() => {
106  const kept = new URLSearchParams();
107  new URLSearchParams(window.location.search).forEach((value, key) => {
108    if (isArrivalTag(key) && !kept.has(key)) kept.set(key, value);
109  });
110  return kept.toString();
111})();
112
113// Later events on this page (hd/hd2 probes, banner clicks) fire after the tags
114// have left the URL, so re-attach them: the admin's Banner Clicks panel matches
115// on pagePath and its HD columns depend on those events carrying the campaign,
116// and the redirect-when-blocked stats regex od= out of pagePath. Only tags the
117// current URL lacks are appended, so a URL still carrying them is unchanged.
118function currentPagePath() {
119  const path = window.location.pathname + window.location.search;
120  if (!arrivalTags) return path;
121  const present = new URLSearchParams(window.location.search);
122  const missing = new URLSearchParams();
123  new URLSearchParams(arrivalTags).forEach((value, key) => {
124    if (!present.has(key)) missing.set(key, value);
125  });
126  const extra = missing.toString();
127  if (!extra) return path;
128  return path + (path.indexOf('?') === -1 ? '?' : '&') + extra;
129}
130
131// utm_* to gtag campaign field, per the Google tag API.
132const UTM_TO_CAMPAIGN_FIELD = {
133  utm_id: 'id',
134  utm_source: 'source',
135  utm_medium: 'medium',
136  utm_campaign: 'name',
137  utm_term: 'term',
138  utm_content: 'content'
139};
140
141// A destination's own Google tag reads the campaign off the URL when its hit
142// goes out, which is seconds after the tags have to be gone, so hand it the
143// campaign explicitly instead. gtag only applies `set` to commands queued after
144// it and the page's own `config` — the one that triggers the page_view — is
145// already queued by the time this file runs, hence the unshift. No-op on pages
146// without a Google tag.
147function passUtmToGoogleTag() {
148  if (!arrivalTags) return;
149  const tags = new URLSearchParams(arrivalTags);
150  const campaign = {};
151  Object.keys(UTM_TO_CAMPAIGN_FIELD).forEach(key => {
152    if (tags.get(key)) campaign[UTM_TO_CAMPAIGN_FIELD[key]] = tags.get(key);
153  });
154  if (Object.keys(campaign).length === 0) return;
155  // Queue entries are arguments objects, so mimic one exactly rather than
156  // pushing an array, which gtag would ignore.
157  const command = (function () { return arguments; })('set', 'campaign', campaign);
158  window.dataLayer = window.dataLayer || [];
159  window.dataLayer.unshift(command);
160  // Covers the case where the tag library already drained the queue: the
161  // unshifted entry would never be read, but a plain call still applies.
162  if (typeof window.gtag === 'function') window.gtag('set', 'campaign', campaign);
163}
164
165// The mirrored SPAs merge location.search into every link they build, so the
166// banner's tags are re-appended on each in-app navigation and one click grows
167// into a chain of tagged pageviews with the tag repeated a dozen times. The
168// links are built during boot, so the tags have to be gone before then — which
169// is also why od is stripped immediately rather than after a delay.
170function stripArrivalTagsFromUrl() {
171  if (!window.history || typeof history.replaceState !== 'function') return;
172  let url;
173  try { url = new URL(window.location.href); } catch (e) { return; }
174  const keys = [];
175  url.searchParams.forEach((value, key) => {
176    if (isArrivalTag(key) && keys.indexOf(key) < 0) keys.push(key);
177  });
178  if (keys.length === 0) return;
179  keys.forEach(key => url.searchParams.delete(key));
180  try {
181    history.replaceState(history.state, '', url.pathname + url.search + url.hash);
182  } catch (e) { /* replaceState is a no-op on /fb/, where nginx neuters it */ }
183}
184
185// Function to send data to backend with base event data
186function sendEvent(eventType, customData = {}) {
187  const dataToSend = {
188    userID,
189    sessionID,
190    eventType,
191    pagePath: currentPagePath(),
192    pageTitle: document.title ? document.title.substring(0, 512) : null,
193		userAgent: navigator.userAgent,
194    userLanguages: navigator.languages.join(),
195    screenWidth: screen && screen.width ? screen.width : null,
196    screenHeight: screen && screen.height ? screen.height : null,
197    viewportWidth: window.innerWidth || null,
198    viewportHeight: window.innerHeight || null,
199    ...customData
200  };
201
202  if (pageID !== null) {
203    dataToSend.pageID = pageID;
204  }
205
206  if (document.referrer !== null) {
207    dataToSend.documentReferrer = document.referrer;
208  }
209
210  const xhr = new XMLHttpRequest();
211  xhr.open('POST', '/analytics', true);
212  xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8');
213  xhr.send(JSON.stringify(dataToSend));
214}
215
216// Send initial page view event. `other` carries the A/B arm, or 'bv:none' where
217// the banner is suppressed; the admin's HD flags look for 'hd'/'hd2' on
218// eventType 'custom', so tagging pageviews here does not collide with them.
219const pageviewData = { other: ['bv:' + (bannerSuppressed || bannerDisabled ? 'none' : bannerVariant)] };
220if (redirectedTo) pageviewData.redirected_to = redirectedTo;
221sendEvent('pageview', pageviewData);
222if (redirectedTo) {
223  document.cookie = 'smm_redirected_to=; Max-Age=0; path=/';
224}
225
226// The pageview above has already serialised the tagged URL, so our own stats
227// keep the campaign and od; from here on the tags live in arrivalTags instead.
228passUtmToGoogleTag();
229stripArrivalTagsFromUrl();
230
231// Check the path before attaching the event listener
232if (window.location.pathname.startsWith('/fb/') || window.location.pathname.startsWith('/wiki/')) {
233  document.addEventListener('DOMContentLoaded', (event) => {
234    document.body.addEventListener('click', function(event) {
235      let target = event.target; // start with the target element
236      
237      // climb up the DOM tree
238      while (target != null) {
239        if (target.id === 'download') {
240          // found the element we're interested in, handle the event
241          sendEvent('custom', { click: 'download' });
242          return;
243        }
244        target = target.parentElement;
245      }
246    });
247  });
248}
249
250if (window.location.pathname.startsWith('/news/')) {
251  document.addEventListener('DOMContentLoaded', (event) => {
252    document.body.addEventListener('click', function(event) {
253      let target = event.target;
254      while (target != null) {
255        if (target.getAttribute('href') && target.getAttribute('href').startsWith('/news/')) {
256          sendEvent('custom', { click: 'news' });
257          return;
258        }
259        target = target.parentElement;
260      }
261    });
262  });
263}
264
265if (window.location.pathname.startsWith('/duckduckgo/')) {
266	const originalFetch = window.fetch;
267	window.fetch = function(req, options) {
268		try {
269			let url = (typeof req === 'string') ? req : req.url;
270			if(url == '/duckduckgo/duckchat/v1/chat') {
271				sendEvent('custom', { click: 'v1chat' });
272			}
273			return originalFetch(req, options);
274		} catch (e) {
275			console.error(e);
276		}
277	};
278}
279
280// Register the service worker
281if ('serviceWorker' in navigator) {
282	navigator.serviceWorker.register('/service-worker.js', { scope: '/' })
283	.then(function(registration) {
284			console.log('Service Worker registered with scope:', registration.scope);
285	})
286	.catch(function(error) {
287			console.log('Service Worker registration failed:', error);
288	});
289
290  navigator.serviceWorker.addEventListener('message', event => {
291    if (event.data && event.data.type === 'REDIRECT') {
292      window.location = event.data.url;
293    }
294  });
295}
296
297let mm = [];
298let hd = false;
299
300document.addEventListener('mousemove', function(event) {
301    if (hd) return;
302
303    const x = event.clientX;
304    const y = event.clientY;
305    const timestamp = Date.now();
306
307    mm.push({ x, y, timestamp });
308
309    if (mm.length >= 50) {
310        if (isHL(mm)) {
311            hd = true;
312						setCookie('smm_hd', '1', 3650); // persistent human-likelihood signal (survey gating)
313						notifyHd();
314						sendEvent('custom', { other: ['hd'] });
315        }
316        mm = [];
317    }
318});
319
320function isHL(movements) {
321    let totalDistance = 0;
322    let totalTime = 0;
323
324    for (let i = 1; i < movements.length; i++) {
325        const dx = movements[i].x - movements[i - 1].x;
326        const dy = movements[i].y - movements[i - 1].y;
327        const dt = movements[i].timestamp - movements[i - 1].timestamp;
328
329        totalDistance += Math.sqrt(dx * dx + dy * dy);
330        totalTime += dt;
331    }
332
333    const averageSpeed = totalDistance / totalTime;
334    return averageSpeed > 0.1 && averageSpeed < 1.0;
335}
336
337// Pointer-event based human-likelihood signal (mobile-friendly)
338let pm = [];
339let hd2 = false;
340const pointerTypeCounts = {};
341
342function trackPointerType(pointerType) {
343  const type = pointerType || 'unknown';
344  pointerTypeCounts[type] = (pointerTypeCounts[type] || 0) + 1;
345}
346
347function sendHd2Event() {
348  if (hd2) return;
349  hd2 = true;
350  setCookie('smm_hd', '1', 3650); // persistent human-likelihood signal (survey gating)
351  notifyHd();
352  sendEvent('custom', { other: ['hd2'], pointerTypes: Object.keys(pointerTypeCounts).sort() });
353}
354
355// Let the banner survey react to a freshly-set HD signal without a page reload.
356function notifyHd() {
357  try {
358    document.dispatchEvent(new CustomEvent('smm:hd'));
359  } catch (e) { /* CustomEvent unsupported: survey will still show on next load */ }
360}
361
362function isHL2(movements) {
363  if (movements.length < 12) return false;
364
365  let totalDistance = 0;
366  let totalTime = 0;
367  let stationarySteps = 0;
368  let directionChanges = 0;
369  let prevDirection = null;
370
371  for (let i = 1; i < movements.length; i++) {
372    const dx = movements[i].x - movements[i - 1].x;
373    const dy = movements[i].y - movements[i - 1].y;
374    const dt = movements[i].timestamp - movements[i - 1].timestamp;
375    const stepDistance = Math.sqrt(dx * dx + dy * dy);
376
377    totalDistance += stepDistance;
378    totalTime += dt;
379
380    if (stepDistance < 0.5) {
381      stationarySteps++;
382      continue;
383    }
384
385    const direction = Math.atan2(dy, dx);
386    if (prevDirection !== null) {
387      const directionDelta = Math.abs(direction - prevDirection);
388      if (directionDelta > 0.3 && directionDelta < 3.0) directionChanges++;
389    }
390    prevDirection = direction;
391  }
392
393  if (totalTime <= 0) return false;
394
395  const averageSpeed = totalDistance / totalTime;
396  const stationaryRatio = stationarySteps / Math.max(movements.length - 1, 1);
397
398  // Wide bounds to support mouse and touch, while rejecting ultra-linear or fully static traces.
399  return (
400    totalDistance > 80 &&
401    totalTime > 150 &&
402    averageSpeed > 0.03 &&
403    averageSpeed < 3.0 &&
404    stationaryRatio < 0.9 &&
405    directionChanges >= 2
406  );
407}
408
409document.addEventListener('pointerdown', function(event) {
410  if (hd2 || !event.isPrimary) return;
411  trackPointerType(event.pointerType);
412  pm.push({ x: event.clientX, y: event.clientY, timestamp: Date.now() });
413  if (pm.length > 80) pm.shift();
414});
415
416document.addEventListener('pointermove', function(event) {
417  if (hd2 || !event.isPrimary) return;
418  trackPointerType(event.pointerType);
419  pm.push({ x: event.clientX, y: event.clientY, timestamp: Date.now() });
420  if (pm.length > 80) pm.shift();
421
422  if (pm.length >= 20 && isHL2(pm)) {
423    sendHd2Event();
424  }
425});
426
427document.addEventListener('pointerup', function(event) {
428  if (hd2 || !event.isPrimary) return;
429  trackPointerType(event.pointerType);
430  pm.push({ x: event.clientX, y: event.clientY, timestamp: Date.now() });
431  if (pm.length > 80) pm.shift();
432
433  if (isHL2(pm)) {
434    sendHd2Event();
435  }
436});
437
438// ── Banner ────────────────────────────────────────────────────────────────
439
440(function () {
441  const BANNER_ID = '__smart-mirrors-banner__';
442  const NOTICE_ID = '__smart-mirrors-notice__';
443  const NOTICE_CHIP_ID = '__smart-mirrors-notice-chip__';
444  if (document.getElementById(BANNER_ID) || document.getElementById(NOTICE_ID)) return;
445
446  // Capture the script element synchronously — document.currentScript is only
447  // valid during initial script execution, so we grab it here at the top of
448  // the IIFE rather than inside a deferred callback.
449  const scriptEl = document.currentScript || (function () {
450    const scripts = document.getElementsByTagName('script');
451    return scripts[scripts.length - 1];
452  }());
453
454  const origin = (scriptEl && scriptEl.getAttribute('data-origin')) || (function () {
455    const m = ((scriptEl && scriptEl.getAttribute('src')) || '').match(/[?&]origin=([^&]*)/);
456    return m ? decodeURIComponent(m[1]) : null;
457  }());
458
459  const position = (scriptEl && scriptEl.getAttribute('data-position')) === 'bottom' ? 'bottom' : 'top';
460  const isBottom = position === 'bottom';
461
462  // ── Language detection ──────────────────────────────────────────────────
463
464  function langFromPath(path) {
465    for (const code of ['zh', 'ru', 'fa']) {
466      if (new RegExp('/' + code + '(?:/|-|$)').test(path)) return code;
467    }
468    return null;
469  }
470
471  function langFromBrowser() {
472    const langs = Array.from(navigator.languages || (navigator.language ? [navigator.language] : []));
473    for (const l of langs) {
474      const lc = l.toLowerCase();
475      if (lc === 'zh' || lc.startsWith('zh-')) return 'zh';
476      if (lc === 'ru' || lc.startsWith('ru-')) return 'ru';
477      if (lc === 'fa' || lc.startsWith('fa-')) return 'fa';
478    }
479    return null;
480  }
481
482  const lang = (window.location.pathname.startsWith('/wikipedia') || window.location.pathname.startsWith('/fzh/'))
483    ? 'zh'
484    : (langFromPath(window.location.pathname) || langFromBrowser() || 'en');
485
486  // Strip the first non-empty path segment so the origin link lands on the
487  // equivalent page: /wikipedia/Title -> /Title, /fb/en -> /en, / -> ''
488  function originPath() {
489    const parts = window.location.pathname.split('/').filter(p => p !== '');
490    if (parts.length <= 1) return '';
491    return '/' + parts.slice(1).join('/');
492  }
493
494  // Language-suffixed fb/vpn paths (zh and ru only); vpn gets a trailing slash
495  const fbLangSuffix  = { zh: '/zh',  ru: '/ru'  };
496  const vpnLangSuffix = { zh: '/zh/', ru: '/ru/' };
497
498  // Campaign tags for the fb/vpn links. Both are same-origin paths nginx proxies
499  // to freebrowser.org / greatfirevpn.com, so without these the destination's GA
500  // reports banner clicks as direct traffic. The mirror host is deliberately not
501  // in utm_source: GA already has it as the Hostname dimension. Our own stats pick
502  // the params up too, via the query string in sendEvent's pagePath.
503  // utm_content doubles as the A/B label so the admin's Banner Clicks panel,
504  // which groups by campaign + content, separates the arms with no query
505  // changes: "zh-card-top" vs "zh-sheet-top". It names the arm rather than the
506  // shape actually drawn, because the sheet arm falls back to the card on
507  // desktop and those clicks still belong to the sheet's denominator — the
508  // event's viewportWidth says which shape was on screen. Position is the
509  // mirror's configured one even in the sheet arm, which overrides it on
510  // phones: that is what makes it possible to ask whether forcing bottom helps
511  // more on the twelve mirrors configured top than on the two configured bottom.
512  function withUtm(path) {
513    const params = new URLSearchParams();
514    params.set('utm_source', 'smart-mirrors');
515    params.set('utm_medium', 'banner');
516    if (pageID) params.set('utm_campaign', pageID);
517    params.set('utm_content', lang + '-' + bannerVariant + '-' + position);
518    return path + (path.indexOf('?') === -1 ? '?' : '&') + params.toString();
519  }
520
521  const fbPath  = withUtm('/fb'  + (fbLangSuffix[lang]  || ''));
522  const vpnPath = withUtm('/vpn' + (vpnLangSuffix[lang] || ''));
523
524  // ── Translations ────────────────────────────────────────────────────────
525  // With origin:    s0 + originLink + s1 + gfLink + s2 + fbLink + s3 + vpnLink + s4 + emailLink + s5
526  // Without origin: g0 + gfLink + g1 + fbLink + g2 + vpnLink + g3 + emailLink + g4
527
528  const T = {
529    en: {
530      s0: 'This is a mirror of ', s1: '. It is operated by ',
531      s2: ' to make it easier to access. Since the mirror may be blocked we recommend that you download ',
532      s3: ' or ', s4: ' for long term access. If you have questions you can email us ', s5: '.',
533      g0: 'This website is a mirror operated by ',
534      g1: ' to make it easier to access. Since the mirror may be blocked we recommend that you download ',
535      g2: ' or ', g3: ' for long term access. If you have questions you can email us ', g4: '.',
536      gf: 'GreatFire.org', fb: 'FreeBrowser', vpn: 'GreatFireVPN',
537    },
538    zh: {
539      s0: '这是', s1: '的镜像网站,由',
540      s2: '运营,旨在帮助您更便捷地访问。由于镜像可能被屏蔽,我们建议您下载',
541      s3: '或', s4: '以获得长期访问。如有疑问,请发送邮件至', s5: '。',
542      g0: '本网站是由',
543      g1: '运营的镜像网站,旨在帮助您更便捷地访问。由于镜像可能被屏蔽,我们建议您下载',
544      g2: '或', g3: '以获得长期访问。如有疑问,请发送邮件至', g4: '。',
545      gf: 'GreatFire.org', fb: '自由浏览', vpn: 'GreatFireVPN',
546    },
547    ru: {
548      s0: 'Это зеркало сайта ', s1: '. Оно поддерживается организацией ',
549      s2: ', чтобы облегчить доступ к ресурсу. Поскольку зеркало может быть заблокировано, мы рекомендуем скачать ',
550      s3: ' или ', s4: ' для долгосрочного доступа. По вопросам пишите нам на ', s5: '.',
551      g0: 'Этот сайт является зеркалом, поддерживаемым организацией ',
552      g1: ', чтобы облегчить доступ. Поскольку зеркало может быть заблокировано, мы рекомендуем скачать ',
553      g2: ' или ', g3: ' для долгосрочного доступа. По вопросам пишите нам на ', g4: '.',
554      gf: 'GreatFire.org', fb: 'FreeBrowser', vpn: 'GreatFireVPN',
555    },
556    fa: {
557      s0: 'این یک آینه از ', s1: ' است که توسط ',
558      s2: ' اداره می‌شود تا دسترسی را آسان‌تر کند. از آنجا که این آینه ممکن است مسدود شود، توصیه می‌کنیم ',
559      s3: ' یا ', s4: ' را برای دسترسی بلندمدت دانلود کنید. در صورت داشتن سوال با ما از طریق ایمیل تماس بگیرید ', s5: '.',
560      g0: 'این وب‌سایت یک آینه است که توسط ',
561      g1: ' اداره می‌شود تا دسترسی را آسان‌تر کند. از آنجا که این آینه ممکن است مسدود شود، توصیه می‌کنیم ',
562      g2: ' یا ', g3: ' را برای دسترسی بلندمدت دانلود کنید. در صورت داشتن سوال با ما از طریق ایمیل تماس بگیرید ', g4: '.',
563      gf: 'GreatFire.org', fb: 'FreeBrowser', vpn: 'GreatFireVPN',
564    },
565  };
566
567  const tr = T[lang] || T.en;
568
569  // ── Survey (HD-gated, per browser language) ─────────────────────────────
570  // Hard-coded per-language surveys. Each language is its own survey (own id), so
571  // content can diverge later (e.g. a question added in one language only).
572  // Stored one document per answered question via POST /analytics
573  // (eventType:'survey'); see survey-feature-design.md §8.
574
575  // Master switch. False keeps the whole implementation in place but stops the
576  // survey ever rendering: surveyApplicable() gates both the auto-popup and the
577  // reopen link, which are the only two ways it can reach the screen.
578  const SURVEYS_ENABLED = false;
579
580  const SURVEYS = {
581    zh: {
582      id: 'civic-efficacy-v1',
583      scaleId: 'agreement-1to6',
584      showDelayMs: 4000, // wait a few seconds before auto-showing, to grab attention
585      ui: {
586        title: '问卷调查',
587        // Merged invite + progress line (shown under the title, centered).
588        subtitle: function (n, total) {
589          return '我们想听听您的看法(匿名)· 问题 ' + n + ' / ' + total;
590        },
591        thanks: '感谢您的参与!',
592        reopen: '参与问卷调查 ›',
593        close: '关闭',
594      },
595      questions: [
596        { id: 'Q1', text: '像我这样的人能够影响社会的运作方式。' },
597        { id: 'Q2', text: '当做出重要决定时,像我这样的人的意见会被听取。' },
598        { id: 'Q3', text: '即使改变缓慢,就公共问题发声也是值得的。' },
599        { id: 'Q4', text: '普通人应该在影响自身生活的决定中拥有更多的发言权。' },
600      ],
601      options: [
602        { value: 1, label: '非常不同意' },
603        { value: 2, label: '不同意' },
604        { value: 3, label: '既不同意也不反对' },
605        { value: 4, label: '同意' },
606        { value: 5, label: '非常同意' },
607        { value: 6, label: '不愿回答' },
608      ],
609    },
610    ru: {
611      id: 'civic-efficacy-ru-v1',
612      scaleId: 'agreement-1to6',
613      showDelayMs: 4000,
614      ui: {
615        title: 'Опрос',
616        subtitle: function (n, total) {
617          return 'Нам интересно ваше мнение (анонимно) · Вопрос ' + n + ' / ' + total;
618        },
619        thanks: 'Спасибо за участие!',
620        reopen: 'Пройти опрос ›',
621        close: 'Закрыть',
622      },
623      questions: [
624        { id: 'Q1', text: 'Такие люди, как я, могут влиять на то, как устроено общество.' },
625        { id: 'Q2', text: 'Когда принимаются важные решения, к таким людям, как я, прислушиваются.' },
626        { id: 'Q3', text: 'Высказываться по общественным вопросам стоит, даже если перемены идут медленно.' },
627        { id: 'Q4', text: 'Обычные люди должны иметь больше влияния на решения, которые затрагивают их жизнь.' },
628      ],
629      options: [
630        { value: 1, label: 'Полностью не согласен(на)' },
631        { value: 2, label: 'Не согласен(на)' },
632        { value: 3, label: 'Нейтрально' },
633        { value: 4, label: 'Согласен(на)' },
634        { value: 5, label: 'Полностью согласен(на)' },
635        { value: 6, label: 'Предпочитаю не отвечать' },
636      ],
637    },
638  };
639
640  // Pick the survey for the user's *browser* language (never the path-forced
641  // `lang`, which is pinned to 'zh' on /wikipedia and /fzh/). null = no survey
642  // for this language.
643  const SURVEY = SURVEYS[langFromBrowser()] || null;
644
645  const surveyDoneCookie = SURVEY ? 'smm_survey_' + SURVEY.id : null;
646
647  function surveyState() {
648    return surveyDoneCookie ? getCookie(surveyDoneCookie) : ''; // '' | 'dismissed' | 'done'
649  }
650
651  // Applicable = surveys are switched on, one exists for this browser language,
652  // and the persistent HD signal is set. See survey-feature-design.md §8.
653  function surveyApplicable() {
654    return SURVEYS_ENABLED && !!SURVEY && getCookie('smm_hd') === '1';
655  }
656
657  // Auto-eligible = should pop up on its own (applicable, and not already
658  // completed or dismissed).
659  function surveyAutoEligible() {
660    const s = surveyState();
661    return surveyApplicable() && s !== 'done' && s !== 'dismissed';
662  }
663
664  // ── DOM helpers ─────────────────────────────────────────────────────────
665
666  function lnk(href, label) {
667    const a = document.createElement('a');
668    a.href = href;
669    a.textContent = label;
670    a.target = '_blank';
671    a.rel = 'noopener noreferrer';
672    a.style.cssText = 'color:inherit;text-decoration:none;font-weight:bold';
673    a.onmouseover = () => { a.style.textDecoration = 'underline'; };
674    a.onmouseout  = () =>
674 { a.style.textDecoration = 'none'; };
675    return a;
676  }
677
678  // ── Build banner ─────────────────────────────────────────────────────────
679
680  // On narrow screens the long mirror-info message dominates the viewport, so we
681  // collapse it to a couple of lines and let a tap on the banner expand it.
682  const isMobile = !!(window.matchMedia && window.matchMedia('(max-width: 600px)').matches);
683
684  // The sheet is a phone-only treatment: desktop keeps the card untouched,
685  // both because a bottom sheet is a phone idiom and because round 2 showed
686  // desktop's rate is an artefact of automated traffic rather than a design
687  // problem worth spending an arm on. Desktop visitors in the sheet arm
688  // therefore see exactly what the card arm sees, which makes desktop a running
689  // A/A check on the split.
690  const showSheet = bannerVariant === 'sheet' && isMobile;
691
692  const banner = document.createElement('div');
693  banner.id = BANNER_ID;
694  banner.style.cssText = [
695    'position:fixed', isBottom ? 'bottom:0' : 'top:0', 'left:0', 'width:100%',
696    'z-index:2147483647', 'box-sizing:border-box',
697    'background:#1a1a2e', 'color:#e0e0ff',
698    'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif',
699    'font-size:' + (isMobile ? '12px' : '13px'),
700    'line-height:' + (isMobile ? '1.4' : '1.5'),
701    'padding:' + (isMobile ? '6px 12px' : '8px 16px'),
702    'display:flex', 'flex-direction:column', 'gap:8px',
703    isBottom ? 'box-shadow:0 -2px 8px rgba(0,0,0,0.4)' : 'box-shadow:0 2px 8px rgba(0,0,0,0.4)',
704    lang === 'fa' ? 'direction:rtl' : 'direction:ltr',
705  ].join(';');
706
707  // Top row: existing mirror-info message + dismiss button.
708  const topRow = document.createElement('div');
709  topRow.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%';
710
711  const msg = document.createElement('span');
712  // Desktop: centered. Mobile: left-aligned and collapsed (managed by applyMsgCollapse).
713  msg.style.cssText = isMobile ? 'flex:1;min-width:0;text-align:left' : 'flex:1;min-width:0;text-align:center';
714
715  // Shared by both arms: the bar shows it as its whole content, the card folds
716  // it behind "about this mirror". Builds fresh nodes on each call.
717  function fillMirrorMessage(el) {
718    const emailLink = lnk('mailto:[email protected]', '[email protected]');
719    if (origin) {
720      el.append(
721        tr.s0, lnk('https://' + origin + originPath(), origin), tr.s1,
722        lnk('https://greatfire.org', tr.gf), tr.s2,
723        lnk(fbPath, tr.fb), tr.s3, lnk(vpnPath, tr.vpn),
724        tr.s4, emailLink, tr.s5,
725      );
726    } else {
727      el.append(
728        tr.g0, lnk('https://greatfire.org', tr.gf), tr.g1,
729        lnk(fbPath, tr.fb), tr.g2, lnk(vpnPath, tr.vpn),
730        tr.g3, emailLink, tr.g4,
731      );
732    }
733  }
734
735  fillMirrorMessage(msg);
736
737  const closeBtn = document.createElement('button');
738  closeBtn.textContent = '\u00D7';
739  closeBtn.setAttribute('aria-label', 'Dismiss');
740  closeBtn.style.cssText = 'background:none;border:none;color:inherit;font-size:18px;line-height:1;cursor:pointer;padding:0 4px;flex-shrink:0;opacity:0.7';
741  closeBtn.onmouseover = () => { closeBtn.style.opacity = '1'; };
742  closeBtn.onmouseout  = () => { closeBtn.style.opacity = '0.7'; };
743  closeBtn.onclick = () => {
744    banner.style.display = 'none';
745    document.body.style[padProp] = originalPad ? originalPad + 'px' : '';
746  };
747
748  // Discreet link that (re)opens the survey. Shown whenever the survey is
749  // applicable but not currently open (including after it was dismissed), so a
750  // user who closed it can still bring it back.
751  const reopenLink = document.createElement('button');
752  reopenLink.type = 'button';
753  reopenLink.textContent = SURVEY ? SURVEY.ui.reopen : '';
754  reopenLink.style.cssText = [
755    'display:none', 'background:none', 'border:none', 'color:#aeb4ff',
756    'font:inherit', 'font-size:12px', 'cursor:pointer', 'text-decoration:underline',
757    'padding:0 4px', 'flex-shrink:0', 'white-space:nowrap',
758  ].join(';');
759  reopenLink.onmouseover = () => { reopenLink.style.color = '#ffffff'; };
760  reopenLink.onmouseout  = () => { reopenLink.style.color = '#aeb4ff'; };
761  reopenLink.onclick = () => { openSurvey(); };
762
763  // Mobile: a caret hints the message is expandable (tap anywhere on the banner
764  // toggles it). It's a <span>, not a <button>, so the banner tap handler still
765  // sees it (buttons/links are excluded from the toggle).
766  let caret = null;
767  if (isMobile) {
768    caret = document.createElement('span');
769    caret.setAttribute('aria-hidden', 'true');
770    caret.style.cssText = 'flex-shrink:0;
770opacity:0.7;font-size:12px;padding:0 2px';
771  }
772
773  // Mobile puts the reopen link on its own centered line; desktop keeps it inline.
774  if (isMobile) {
775    topRow.append(msg, caret, closeBtn);
776    banner.append(topRow);
777    const reopenRow = document.createElement('div');
778    reopenRow.style.cssText = 'width:100%;text-align:center';
779    reopenRow.append(reopenLink);
780    banner.append(reopenRow);
781  } else {
782    topRow.append(msg, reopenLink, closeBtn);
783    banner.append(topRow);
784  }
785
786  // ── Collapsible message (mobile) ──────────────────────────────────────────
787  const MSG_CLAMP = 'display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;';
788  let msgCollapsed = isMobile;
789  function applyMsgCollapse() {
790    if (!isMobile) return;
791    msg.style.cssText = 'flex:1;min-width:0;text-align:left;' + (msgCollapsed ? MSG_CLAMP : '');
792    if (caret) caret.textContent = msgCollapsed ? '\u25BE' : '\u25B4'; // ▾ / ▴
793  }
794  if (isMobile) {
795    banner.addEventListener('click', (e) => {
796      if (surveyOpen) return;                  // survey has its own controls
797      if (e.target.closest('a, button')) return; // don't hijack links / × / reopen
798      msgCollapsed = !msgCollapsed;
799      applyMsgCollapse();
800      applyPad();
801    });
802  }
803
804  // ── Padding management ────────────────────────────────────────────────────
805  // The banner is fixed, so reserve body space equal to its height. The survey
806  // grows the banner as it renders, so recompute on demand rather than once.
807  const padProp = isBottom ? 'paddingBottom' : 'paddingTop';
808  // Computed in insert(), once document.body is guaranteed to exist (the script
809  // may run in <head>, where document.body is still null).
810  let originalPad = 0;
811  function applyPad() {
812    document.body.style[padProp] = (originalPad + banner.offsetHeight) + 'px';
813  }
814
815  // ── Survey ────────────────────────────────────────────────────────────────
816  // The survey replaces the banner's main content while open (topRow hidden),
817  // with its own close button. State is tracked across reopen so we resume where
818  // the user left off rather than re-asking answered questions.
819
820  let surveyPanel = null;
821  let surveyOpen = false;
822  let surveyScheduledOrShown = false;
823  let surveyIdx = 0;
824
825  function refreshReopenLink() {
826    const show = !surveyOpen && surveyApplicable() && surveyState() !== 'done';
827    reopenLink.style.display = show ? 'inline' : 'none';
828  }
829
830  function optionButton(q, opt) {
831    const b = document.createElement('button');
832    b.type = 'button';
833    b.textContent = opt.label;
834    b.style.cssText = [
835      'background:#eceeff', 'color:#1a1a2e',
836      'border:1px solid #b9bdf2', 'border-radius:16px',
837      'padding:6px 14px', 'font-size:13px', 'font-weight:600',
838      'cursor:pointer', 'line-height:1.3', 'box-shadow:0 1px 2px rgba(0,0,0,0.3)',
839    ].join(';');
840    b.onmouseover = () => { b.style.background = '#ffffff'; b.style.borderColor = '#6b71e8'; };
841    b.onmouseout  = () => { b.style.background = '#eceeff'; b.style.borderColor = '#b9bdf2'; };
842    b.onclick = () => answer(q, opt);
843    return b;
844  }
845
846  function buildSurveyPanel() {
847    const panel = document.createElement('div');
848    panel.style.cssText = [
849      'width:100%', 'box-sizing:border-box',
850      'display:flex', 'flex-direction:column', 'gap:10px', 'align-items:center',
851      'text-align:center',
852    ].join(';');
853
854    // Header: centered title; survey-only close pinned to the top corner so it
855    // doesn't push the title off-center.
856    const header = document.createElement('div');
857    header.style.cssText = 'position:relative;width:100%;display:flex;align-items:center;justify-content:center';
858
859    const title = document.createElement('div');
860    title.textContent = SURVEY.ui.title;
861    title.style.cssText = 'font-size:13px;font-weight:bold;text-align:center';
862
863    const sClose = document.createElement('button');
864    sClose.type = 'button';
865    sClose.textContent = '\u00D7';
866    sClose.setAttribute('aria-label', SURVEY.ui.close);
867    sClose.title = SURVEY.ui.close;
868    sClose.style.cssText = 'position:absolute;top:50%;transform:translateY(-50%);' +
869      (lang === 'fa' ? 'left:0;' : 'right:0;') +
870      'background:none;border:none;color:inherit;font-size:18px;line-height:1;cursor:pointer;padding:0 4px;opacity:0.7';
871    sClose.onmouseover = () => { sClose.style.opacity = '1'; };
872    sClose.onmouseout  = () => { sClose.style.opacity = '0.7'; };
873    sClose.onclick = () => closeSurvey('dismiss');
874    header.append(title, sClose);
875
876    const body = document.createElement('div');
877    body.style.cssText = 'width:100%;display:flex;flex-direction:column;gap:8px;align-items:center;text-align:center;transition:opacity 0.16s ease';
878
879    panel.append(header, body);
880    panel._body = body;
881    return panel;
882  }
883
884  // Swap the survey body content with a short cross-fade.
885  function swapBody(buildFn) {
886    const body = surveyPanel._body;
887    body.style.opacity = '0';
888    setTimeout(() => {
889      body.textContent = '';
890      buildFn(body);
891      applyPad();
892      requestAnimationFrame(() => { body.style.opacity = '1'; });
893    }, 160);
894  }
895
896  function showQuestion() {
897    swapBody((body) => {
898      const sub = document.createElement('div');
899      sub.textContent = SURVEY.ui.subtitle(surveyIdx + 1, SURVEY.questions.length);
900      sub.style.cssText = 'font-size:12px;opacity:0.75';
901
902      const q = SURVEY.questions[surveyIdx];
903      const qText = document.createElement('div');
904      qText.textContent = q.text;
905      qText.style.cssText = 'font-size:16px;font-weight:bold;line-height:1.4;max-width:680px';
906
907      const opts = document.createElement('div');
908      opts.style.cssText = 'display:flex;flex-wrap:wrap;gap:8px;justify-content:center';
909      SURVEY.options.forEach((opt) => opts.append(optionButton(q, opt)));
910
911      body.append(sub, qText, opts);
912    });
913  }
914
915  function answer(q, opt) {
916    sendEvent('survey', {
917      surveyId: SURVEY.id,
918      questionId: q.id,
919      questionText: q.text,
920      scaleId: SURVEY.scaleId,
921      answerValue: opt.value,
922      answerLabel: opt.label,
923      lang: langFromBrowser(),
924      hadHd: true,
925    });
926    surveyIdx++;
927    if (surveyIdx < SURVEY.questions.length) {
928      showQuestion();
929    } else {
930      finishSurvey();
931    }
932  }
933
934  function finishSurvey() {
935    setCookie(surveyDoneCookie, 'done', 3650);
936    swapBody((body) => {
937      const thanks = document.createElement('div');
938      thanks.textContent = SURVEY.ui.thanks;
939      thanks.style.cssText = 'font-size:16px;font-weight:bold';
940      body.append(thanks);
941    });
942    setTimeout(() => closeSurvey('done'), 2660);
943  }
944
945  function openSurvey() {
946    if (surveyOpen) return;
947    surveyScheduledOrShown = true;
948    if (!surveyPanel) {
949      surveyPanel = buildSurveyPanel();
950      banner.append(surveyPanel);
951    }
952    surveyPanel.style.display = 'flex';
953    surveyOpen = true;
954    topRow.style.display = 'none'; // survey covers the main banner content
955    if (surveyIdx >= SURVEY.questions.length) surveyIdx = 0;
956    applyPad();
957    showQuestion();
958  }
959
960  function closeSurvey(reason) {
961    if (reason === 'dismiss') setCookie(surveyDoneCookie, 'dismissed', 3650);
962    surveyOpen = false;
963    if (surveyPanel) surveyPanel.style.display = 'none';
964    topRow.style.display = 'flex';
965    refreshReopenLink();
966    applyPad();
967  }
968
969  function evaluateSurvey() {
970    if (surveyOpen || surveyScheduledOrShown) { refreshReopenLink(); return; }
971    if (surveyAutoEligible()) {
972      surveyScheduledOrShown = true;
973      setTimeout(() => {
974        const s = surveyState();
975        if (!surveyOpen && s !== 'done' && s !== 'dismissed') openSurvey();
976      }, SURVEY.showDelayMs);
977    } else {
978      refreshReopenLink();
979    }
980  }
981
982  // ── Card variant ──────────────────────────────────────────────────────────
983  // One hook, three benefits, one button. The mirror explanation is still there
984  // but folded behind "about this mirror" instead of being the entire banner.
985  // Floats over the page, so unlike the bar it needs no body padding.
986
987  // The about text is deliberately shorter than the bar's and names no product:
988  // the card already has one CTA, and a second download suggestion inside the
989  // fold would compete with it.
990  // With origin:    a0 + originLink + a1 + gfLink + a2 + emailLink
991  // Without origin: g0 + gfLink + g1 + emailLink
992  const CARD_T = {
993    en: {
994      title: 'Tired of blocked sites?',
995      b: ['Unblocks everything', 'Free for 30 days', 'No signup needed'],
996      cta: 'Get GreatFireVPN', about: 'about this mirror',
997      a0: 'Mirror of ', a1: ', operated by ', a2: ' to make it easier to access. Questions: ',
998      g0: 'Mirror operated by ', g1: ' to make this site easier to access. Questions: ',
999    },
1000    zh: {
1001      title: '受够了被屏蔽的网站?',
1002      b: ['解锁所有网站', '免费试用30天', '无需注册'],
1003      cta: '获取 GreatFireVPN', about: '关于本镜像',
1004      a0: '本站是', a1: '的镜像,由', a2: '运营,方便访问。如有疑问:',
1005      g0: '本站是由', g1: '运营的镜像,方便访问。如有疑问:',
1006    },
1007    ru: {
1008      title: 'Надоели заблокированные сайты?',
1009      b: ['Открывает любые сайты', '30 дней бесплатно', 'Без регистрации'],
1010      cta: 'Скачать GreatFireVPN', about: 'об этом зеркале',
1011      a0: 'Зеркало сайта ', a1: ', поддерживается ', a2: ' для облегчения доступа. Вопросы: ',
1012      g0: 'Зеркало, поддерживается ', g1: ' для облегчения доступа. Вопросы: ',
1013    },
1014    fa: {
1015      title: 'از سایت‌های مسدود خسته شده‌اید؟',
1016      b: ['همه سایت‌ها را باز می‌کند', '۳۰ روز رایگان', 'بدون نیاز به ثبت‌نام'],
1017      cta: 'دریافت GreatFireVPN', about: 'درباره این آینه',
1018      a0: 'آینه‌ای از ', a1: ' که توسط ', a2: ' برای دسترسی آسان‌تر اداره می‌شود. سوالات: ',
1019      g0: 'آینه‌ای که توسط ', g1: ' برای دسترسی آسان‌تر اداره می‌شود. سوالات: ',
1020    },
1021  };
1022
1023  function fillCardAbout(el, ct) {
1024    const email = lnk('mailto:[email protected]', '[email protected]');
1025    const gf = lnk('https://greatfire.org', tr.gf);
1026    if (origin) {
1027      el.append(ct.a0, lnk('https://' + origin + originPath(), origin), ct.a1, gf, ct.a2, email);
1028    } else {
1029      el.append(ct.g0, gf, ct.g1, email);
1030    }
1031  }
1032
1033  function buildCard() {
1034    const ct = CARD_T[lang] || CARD_T.en;
1035    const rtl = lang === 'fa';
1036
1037    const card = document.createElement('div');
1038    card.id = BANNER_ID;
1039    card.style.cssText = [
1040      'position:fixed', isBottom ? 'bottom:14px' : 'top:14px',
1041      'z-index:2147483647', 'box-sizing:border-box',
1042      isMobile ? 'left:10px' : (rtl ? 'left:14px' : 'right:14px'),
1043      isMobile ? 'right:10px' : 'width:280px',
1044      'background:#101828', 'color:#eef2ff', 'border-radius:12px', 'padding:14px 16px',
1045      'box-shadow:0 10px 30px rgba(0,0,0,0.45)',
1046      'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif',
1047      'font-size:13px', 'line-height:1.5',
1048      rtl ? 'direction:rtl' : 'direction:ltr',
1049      // Slide-in is applied after insertion so the card catches the eye; it
1050      // travels away from the edge it is anchored to.
1051      'opacity:0', 'transform:translateY(' + (isBottom ? '12px' : '-12px') + ')',
1052      'transition:opacity .28s ease,transform .28s ease',
1053    ].join(';');
1054
1055    const head = document.createElement('div');
1056    head.style.cssText = 'display:flex;align-items:flex-start;gap:10px';
1057    head.innerHTML =
1058      '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true" style="flex-shrink:0;margin-top:1px">' +
1059      '<path d="M12 2l8 3v6c0 5-3.4 9.4-8 11-4.6-1.6-8-6-8-11V5l8-3z" fill="#22c55e"/>' +
1060      '<path d="M8.5 12l2.3 2.3L15.5 9.6" stroke="#06230f" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>' +
1061      '</svg>';
1062    const title = document.createElement('div');
1063    title.textContent = ct.title;
1064    title.style.cssText = 'font-weight:800;font-size:14px;line-height:1.35;flex:1;padding-' + (rtl ? 'left' : 'right') + ':18px';
1065    head.append(title);
1066
1067    const list = document.createElement('ul');
1068    list.style.cssText = 'list-style:none;margin:10px 0 12px;padding:0;color:#c3cbe4';
1069    ct.b.forEach((text) => {
1070      const li = document.createElement('li');
1071      li.style.cssText = 'margin:4px 0;display:flex;gap:7px;align-items:baseline';
1072      const tick = document.createElement('span');
1073      tick.textContent = '\u2713';
1074      tick.style.cssText = 'color:#22c55e;font-weight:800;flex-shrink:0';
1075      const label = document.createElement('span');
1076      label.textContent = text;
1077      li.append(tick, label);
1078      list.append(li);
1079    });
1080
1081    const cta = document.createElement('a');
1082    cta.href = vpnPath;
1083    cta.textContent = ct.cta;
1084    cta.target = '_blank';
1085    cta.rel = 'noopener noreferrer';
1086    cta.style.cssText = [
1087      'display:block', 'text-align:center', 'background:#22c55e', 'color:#06230f',
1088      'font-weight:800', 'font-size:13.5px', 'padding:9px 0', 'border-radius:8px',
1089      'text-decoration:none',
1090    ].join(';');
1091    cta.onmouseover = () => { cta.style.background = '#4ade80'; };
1092    cta.onmouseout  = () => { cta.style.background = '#22c55e'; };
1093
1094    const aboutPanel = document.createElement('div');
1095    aboutPanel.style.cssText = [
1096      'display:none', 'margin-top:10px', 'padding-top:10px',
1097      'border-top:1px solid #26304a', 'color:#aab3cc', 'font-size:11.5px', 'line-height:1.5',
1098    ].join(';');
1099    fillCardAbout(aboutPanel, ct);
1100
1101    const aboutBtn = document.createElement('button');
1102    aboutBtn.type = 'button';
1103    aboutBtn.textContent = ct.about;
1104    aboutBtn.style.cssText = [
1105      'background:none', 'border:none', 'padding:0', 'font:inherit', 'font-size:11.5px',
1106      'color:#9aa4c2', 'text-decoration:underline', 'cursor:pointer',
1107    ].join(';');
1108    aboutBtn.onmouseover = () => { aboutBtn.style.color = '#e6ebfa'; };
1109    aboutBtn.onmouseout  = () => { aboutBtn.style.color = '#9aa4c2'; };
1110    aboutBtn.onclick = () => {
1111      aboutPanel.style.display = aboutPanel.style.display === 'none' ? 'block' : 'none';
1112    };
1113
1114    const byline = document.createElement('span');
1115    byline.textContent = 'GreatFire.org';
1116    byline.style.cssText = 'color:#78829e;font-size:11px';
1117
1118    const foot = document.createElement('div');
1119    foot.style.cssText = 'display:flex;align-items:center;justify-content:center;gap:8px;margin-top:9px';
1120    foot.append(aboutBtn, byline);
1121
1122    const close = document.createElement('button');
1123    close.type = 'button';
1124    close.textContent = '\u00D7';
1125    close.setAttribute('aria-label', 'Dismiss');
1126    close.style.cssText = [
1127      'position:absolute', 'top:8px', rtl ? 'left:10px' : 'right:10px',
1128      'background:none', 'border:none', 'color:inherit', 'font-size:17px', 'line-height:1',
1129      'cursor:pointer', 'padding:0 4px', 'opacity:0.6',
1130    ].join(';');
1131    close.onmouseover = () => { close.style.opacity = '1'; };
1132    close.onmouseout  = () => { close.style.opacity = '0.6'; };
1133    close.onclick = () => { card.style.display = 'none'; };
1134
1135    card.append(close, head, list, cta, foot, aboutPanel);
1136    return card;
1137  }
1138
1139  function insertCard() {
1140    const card = buildCard();
1141    document.body.appendChild(card);
1142    requestAnimationFrame(() => {
1143      card.style.opacity = '1';
1144      card.style.transform = 'translateY(0)';
1145    });
1146  }
1147
1148  // ── Sheet variant (mobile) ────────────────────────────────────────────────
1149  // The card's words, benefits and button, flush to the bottom edge of the
1150  // phone as a native-style sheet: thumb-reachable, edge-to-edge, and with a
1151  // button around a quarter taller. Everything else is held constant, so a win
1152  // here belongs to placement and target size rather than to the copy.
1153
1154  function buildSheet() {
1155    const ct = CARD_T[lang] || CARD_T.en;
1156    const rtl = lang === 'fa';
1157
1158    const sheet = document.createElement('div');
1159    sheet.id = BANNER_ID;
1160    sheet.style.cssText = [
1161      // Bottom regardless of the mirror's data-position: the whole point of
1162      // this arm is the thumb, and on a phone the thumb is at the bottom. That
1163      // is a deliberate override for the twelve mirrors configured top, and
1164      // utm_content still records which setting the visitor was under.
1165      'position:fixed', 'bottom:0', 'left:0', 'right:0',
1166      'z-index:2147483647', 'box-sizing:border-box',
1167      'background:#101828', 'color:#eef2ff',
1168      'border-radius:18px 18px 0 0',
1169      // env() keeps the button clear of the iOS home indicator; browsers
1170      // without it fall back to the flat 18px.
1171      'padding:12px 18px calc(18px + env(safe-area-inset-bottom, 0px))',
1172      'box-shadow:0 -12px 34px rgba(0,0,0,0.5)',
1173      'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif',
1174      'font-size:13.5px', 'line-height:1.5',
1175      rtl ? 'direction:rtl' : 'direction:ltr',
1176      // Slides up out of the edge it is attached to, the way a sheet does.
1177      'opacity:0', 'transform:translateY(100%)',
1178      'transition:opacity .3s ease,transform .3s ease',
1179    ].join(';');
1180
1181    // Grab bar: no drag behaviour behind it, it is there because it is the
1182    // signal people read as "this panel belongs to the bottom of the screen".
1183    const grab = document.createElement('div');
1184    grab.setAttribute('aria-hidden', 'true');
1185    grab.style.cssText = 'width:40px;height:4px;border-radius:2px;background:#39435e;margin:0 auto 12px';
1186
1187    const title = document.createElement('div');
1188    title.textContent = ct.title;
1189    title.style.cssText = 'font-weight:800;font-size:16px;line-height:1.35;padding-' + (rtl ? 'left' : 'right') + ':28px';
1190
1191    const list = document.createElement('ul');
1192    list.style.cssText = 'list-style:none;margin:10px 0 14px;padding:0;color:#c3cbe4';
1193    ct.b.forEach((text) => {
1194      const li = document.createElement('li');
1195      li.style.cssText = 'margin:5px 0;display:flex;gap:8px;align-items:baseline';
1196      const tick = document.createElement('span');
1197      tick.textContent = '\u2713';
1198      tick.style.cssText = 'color:#22c55e;font-weight:800;flex-shrink:0';
1199      const label = document.createElement('span');
1200      label.textContent = text;
1201      li.append(tick, label);
1202      list.append(li);
1203    });
1204
1205    const cta = document.createElement('a');
1206    cta.href = vpnPath;
1207    cta.textContent = ct.cta;
1208    cta.target = '_blank';
1209    cta.rel = 'noopener noreferrer';
1210    cta.style.cssText = [
1211      'display:block', 'text-align:center', 'background:#22c55e', 'color:#06230f',
1212      'font-weight:800', 'font-size:15px', 'padding:13px 0', 'border-radius:10px',
1213      'text-decoration:none',
1214    ].join(';');
1215
1216    const aboutPanel = document.createElement('div');
1217    aboutPanel.style.cssText = [
1218      'display:none', 'margin-top:10px', 'padding-top:10px',
1219      'border-top:1px solid #26304a', 'color:#aab3cc', 'font-size:11.5px', 'line-height:1.5',
1220    ].join(';');
1221    fillCardAbout(aboutPanel, ct);
1222
1223    const aboutBtn = document.createElement('button');
1224    aboutBtn.type = 'button';
1225    aboutBtn.textContent = ct.about;
1226    aboutBtn.style.cssText = [
1227      'background:none', 'border:none', 'padding:0', 'font:inherit', 'font-size:11.5px',
1228      'color:#9aa4c2', 'text-decoration:underline', 'cursor:pointer',
1229    ].join(';');
1230    aboutBtn.onclick = () => {
1231      aboutPanel.style.display = aboutPanel.style.display === 'none' ? 'block' : 'none';
1232    };
1233
1234    const byline = document.createElement('span');
1235    byline.textContent = 'GreatFire.org';
1236    byline.style.cssText = 'color:#78829e;font-size:11px';
1237
1238    const foot = document.createElement('div');
1239    foot.style.cssText = 'display:flex;align-items:center;justify-content:center;gap:8px;margin-top:10px';
1240    foot.append(aboutBtn, byline);
1241
1242    const close = document.createElement('button');
1243    close.type = 'button';
1244    close.textContent = '\u00D7';
1245    close.setAttribute('aria-label', 'Dismiss');
1246    close.style.cssText = [
1247      'position:absolute', 'top:12px', rtl ? 'left:14px' : 'right:14px',
1248      'background:none', 'border:none', 'color:inherit', 'font-size:19px', 'line-height:1',
1249      'cursor:pointer', 'padding:2px 6px', 'opacity:0.6',
1250    ].join(';');
1251    close.onclick = () => { sheet.style.display = 'none'; };
1252
1253    sheet.append(close, grab, title, list, cta, foot, aboutPanel);
1254    return sheet;
1255  }
1256
1257  function insertSheet() {
1258    const sheet = buildSheet();
1259    document.body.appendChild(sheet);
1260    requestAnimationFrame(() => {
1261      sheet.style.opacity = '1';
1262      sheet.style.transform = 'translateY(0)';
1263    });
1264  }
1265
1266  // ── Mirror notice ─────────────────────────────────────────────────────────
1267  // Shown instead of the banner where the banner is suppressed (/vpn, /fb).
1268  // Suppressing the promo there is right — nobody needs the VPN advertised to
1269  // them on the VPN site — but the banner was also the only thing disclosing
1270  // that the page is a mirror, and that disclosure has to survive on its own.
1271  // The mirrored page is itself a sales page for the product, so this is
1272  // deliberately quiet: grey rather than green, one line, no call to action.
1273  // Dismissing collapses it to a corner chip rather than deleting it, so a
1274  // returning visitor isn't told the same thing every visit but can still find
1275  // it. Not an A/B impression — those pageviews stay tagged 'bv:none'.
1276
1277  const NOTICE_COOKIE = 'smm_mirror_notice'; // '' | 'chip'
1278
1279  const NOTICE_T = {
1280    en: {
1281      m0: 'You are reading a mirror of ',
1282      m1: ', run by GreatFire.org so it can be reached where it is blocked.',
1283      g: 'You are reading a mirror, run by GreatFire.org so this site can be reached where it is blocked.',
1284      d0: 'The page itself is unchanged — only the address is different. Mirrors can be blocked too; if this one stops working, email ',
1285      d1: '.',
1286      more: 'details', chip: 'mirror',
1287    },
1288    zh: {
1289      m0: '您正在访问 ',
1290      m1: ' 的镜像站点,由 GreatFire.org 运营,以便在被屏蔽的地区仍能访问。',
1291      g: '本站是由 GreatFire.org 运营的镜像站点,以便在被屏蔽的地区仍能访问。',
1292      d0: '页面内容未作任何修改,只是网址不同。镜像同样可能被屏蔽;如果本站无法访问,请发送邮件至 ',
1293      d1: '。',
1294      more: '详情', chip: '镜像',
1295    },
1296    ru: {
1297      m0: 'Вы читаете зеркало сайта ',
1298      m1: ', его поддерживает GreatFire.org, чтобы сайт был доступен там, где он заблокирован.',
1299      g: 'Вы читаете зеркало, которое поддерживает GreatFire.org, чтобы сайт был доступен там, где он заблокирован.',
1300      d0: 'Содержимое страницы не изменено — отличается только адрес. Зеркала тоже блокируют; если это перестанет работать, напишите на ',
1301      d1: '.',
1302      more: 'подробнее', chip: 'зеркало',
1303    },
1304    fa: {
1305      m0: 'شما در حال خواندن آینه‌ای از ',
1306      m1: ' هستید که توسط GreatFire.org اداره می‌شود تا در جایی که مسدود است در دسترس باشد.',
1307      g: 'شما در حال خواندن آینه‌ای هستید که توسط GreatFire.org اداره می‌شود تا این سایت در جایی که مسدود است در دسترس باشد.',
1308      d0: 'محتوای صفحه تغییری نکرده و تنها نشانی آن متفاوت است. آینه‌ها هم ممکن است مسدود شوند؛ اگر این نشانی کار نکرد، به ',
1309      d1: ' ایمیل بزنید.',
1310      more: 'جزئیات', chip: 'آینه',
1311    },
1312  };
1313
1314  const nt = NOTICE_T[lang] || NOTICE_T.en;
1315  const noticeRtl = lang === 'fa';
1316
1317  // The origin is named but not linked: the visitor is here precisely because
1318  // that address doesn't resolve for them, so a link would mostly produce
1319  // timeouts.
1320  function fillNoticeMessage(el) {
1321    if (!origin) { el.append(nt.g); return; }
1322    const site = document.createElement('b');
1323    site.textContent = origin;
1324    site.style.cssText = 'color:#f0f4ff;font-weight:700';
1325    el.append(nt.m0, site, nt.m1);
1326  }
1327
1328  function fillNoticeDetail(el) {
1329    el.append(nt.d0, lnk('mailto:[email protected]', '[email protected]'), nt.d1);
1330  }
1331
1332  let noticeEl = null;
1333  let noticeBasePad = 0;
1334
1335  function applyNoticePad() {
1336    if (!noticeEl || noticeEl.style.display === 'none') return;
1337    document.body.style[padProp] = (noticeBasePad + noticeEl.offsetHeight) + 'px';
1338  }
1339
1340  function buildNoticeStrip() {
1341    const strip = document.createElement('div');
1342    strip.id = NOTICE_ID;
1343    strip.style.cssText = [
1344      'position:fixed', isBottom ? 'bottom:0' : 'top:0', 'left:0', 'width:100%',
1345      'z-index:2147483647', 'box-sizing:border-box',
1346      'background:#1e2536', 'color:#c9d2e6',
1347      isBottom ? 'border-top:1px solid #303a52' : 'border-bottom:1px solid #303a52',
1348      'padding:8px 14px', 'display:flex', 'align-items:center', 'flex-wrap:wrap', 'gap:10px',
1349      'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif',
1350      'font-size:12.5px', 'line-height:1.5',
1351      noticeRtl ? 'direction:rtl' : 'direction:ltr',
1352    ].join(';');
1353
1354    const icon = document.createElement('span');
1355    icon.textContent = '\u24D8';
1356    icon.setAttribute('aria-hidden', 'true');
1357    icon.style.cssText = 'flex-shrink:0;
1357opacity:0.75';
1358
1359    const msg = document.createElement('span');
1360    msg.style.cssText = 'flex:1;min-width:0';
1361    fillNoticeMessage(msg);
1362
1363    const detail = document.createElement('div');
1364    detail.style.cssText = [
1365      'display:none', 'width:100%', 'color:#93a0bd', 'font-size:11.5px',
1366      'border-top:1px solid #303a52', 'padding-top:8px',
1367    ].join(';');
1368    fillNoticeDetail(detail);
1369
1370    const more = document.createElement('button');
1371    more.type = 'button';
1372    more.textContent = nt.more;
1373    more.style.cssText = [
1374      'background:none', 'border:none', 'padding:0', 'font:inherit', 'font-size:12px',
1375      'color:#8fa4d4', 'text-decoration:underline', 'cursor:pointer', 'white-space:nowrap',
1376    ].join(';');
1377    more.onmouseover = () => { more.style.color = '#dfe7fa'; };
1378    more.onmouseout  = () => { more.style.color = '#8fa4d4'; };
1379    more.onclick = () => {
1380      detail.style.display = detail.style.display === 'none' ? 'block' : 'none';
1381      applyNoticePad(); // the detail is a second row, so the strip just changed height
1382    };
1383
1384    const close = document.createElement('button');
1385    close.type = 'button';
1386    close.textContent = '\u00D7';
1387    close.setAttribute('aria-label', 'Dismiss');
1388    close.style.cssText = [
1389      'background:none', 'border:none', 'color:inherit', 'font-size:17px', 'line-height:1',
1390      'cursor:pointer', 'padding:0 4px', 'flex-shrink:0', 'opacity:0.6',
1391    ].join(';');
1392    close.onmouseover = () => { close.style.opacity = '1'; };
1393    close.onmouseout  = () => { close.style.opacity = '0.6'; };
1394    close.onclick = () => {
1395      setCookie(NOTICE_COOKIE, 'chip', 90);
1396      strip.style.display = 'none';
1397      document.body.style[padProp] = noticeBasePad ? noticeBasePad + 'px' : '';
1398      insertChip();
1399    };
1400
1401    strip.append(icon, msg, more, close, detail);
1402    return strip;
1403  }
1404
1405  function insertChip() {
1406    if (document.getElementById(NOTICE_CHIP_ID)) return;
1407
1408    const wrap = document.createElement('div');
1409    wrap.id = NOTICE_CHIP_ID;
1410    wrap.style.cssText = [
1411      // Always the bottom corner, whatever the strip's position was: it is
1412      // small enough not to matter there and it stays clear of site headers.
1413      'position:fixed', 'bottom:12px', noticeRtl ? 'right:12px' : 'left:12px',
1414      'z-index:2147483647', 'display:flex', 'flex-direction:column',
1415      noticeRtl ? 'align-items:flex-end' : 'align-items:flex-start', 'gap:8px',
1416      'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif',
1417      'font-size:11.5px', 'line-height:1.6',
1418      noticeRtl ? 'direction:rtl' : 'direction:ltr',
1419    ].join(';');
1420
1421    const panel = document.createElement('div');
1422    panel.style.cssText = [
1423      'display:none', 'box-sizing:border-box', 'width:262px', 'max-width:calc(100vw - 24px)',
1424      'background:#1e2536', 'color:#a9b4cd', 'border:1px solid #3b4661', 'border-radius:10px',
1425      'padding:11px 13px', 'box-shadow:0 12px 32px rgba(0,0,0,0.35)',
1426    ].join(';');
1427    const panelMsg = document.createElement('div');
1428    fillNoticeMessage(panelMsg);
1429    const panelDetail = document.createElement('div');
1430    panelDetail.style.cssText = 'margin-top:7px';
1431    fillNoticeDetail(panelDetail);
1432    panel.append(panelMsg, panelDetail);
1433
1434    const chip = document.createElement('button');
1435    chip.type = 'button';
1436    chip.style.cssText = [
1437      'display:inline-flex', 'align-items:center', 'gap:6px', 'cursor:pointer',
1438      'background:rgba(30,37,54,0.94)', 'color:#c9d2e6', 'border:1px solid #3b4661',
1439      'border-radius:999px', 'padding:5px 12px', 'font:inherit',
1440    ].join(';');
1441    const chipIcon = document.createElement('span');
1442    chipIcon.textContent = '\u24D8';
1443    chipIcon.setAttribute('aria-hidden', 'true');
1444    const chipLabel = document.createElement('span');
1445    chipLabel.textContent = nt.chip;
1446    chip.append(chipIcon, chipLabel);
1447    chip.onclick = (e) => {
1448      e.stopPropagation();
1449      panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
1450    };
1451
1452    // Anywhere else on the page closes it, the way a tooltip behaves.
1453    document.addEventListener('click', (e) => {
1454      if (panel.style.display === 'none' || wrap.contains(e.target)) return;
1455      panel.style.display = 'none';
1456    });
1457
1458    wrap.append(panel, chip);
1459    document.body.appendChild(wrap);
1460  }
1461
1462  function insertNotice() {
1463    if (getCookie(NOTICE_COOKIE) === 'chip') { insertChip(); return; }
1464    noticeEl = buildNoticeStrip();
1465    document.body.insertBefore(noticeEl, document.body.firstChild);
1466    const v = parseInt(window.getComputedStyle(document.body)[padProp] || '0', 10);
1467    noticeBasePad = isNaN(v) ? 0 : v;
1468    applyNoticePad();
1469    // Narrowing the window wraps the message onto a second line.
1470    window.addEventListener('resize', applyNoticePad);
1471  }
1472
1473  function insert() {
1474    // Operator opted this mirror out via data-banner="false": no card, no
1475    // sheet, no notice strip.
1476    if (bannerDisabled) return;
1477    // Neither arm attaches the old informational bar: round 1 retired it. Its
1478    // DOM is still built above because the survey is assembled inside it, and
1479    // the survey is switched off (SURVEYS_ENABLED) — reviving one means giving
1480    // the other a home first.
1481    if (bannerSuppressed) { insertNotice(); return; }
1482    if (showSheet) { insertSheet(); return; }
1483    insertCard();
1484  }
1485
1486  if (document.body) {
1487    insert();
1488  } else {
1489    document.addEventListener('DOMContentLoaded', insert);
1490  }
1491}());
1492
1493// ── Domain reachability probe ─────────────────────────────────────────────
1494// Once per session, ask the backend for one sibling mirror domain (same
1495// 10-domain silo as the service-worker backup — no extra name exposure) and
1496// test whether this user's network can reach it. The sentinel file is served
1497// with Access-Control-Allow-Origin:* on every mirror. A same-origin control
1498// fetch filters out clients with broken connectivity or blocking extensions.
1499// Results are aggregated per (domain, country) server-side.
1500
1501(function () {
1502  const PROBE_PATH = '/test74382647832.txt';
1503  const PROBE_TIMEOUT_MS = 10000;
1504  const TARGET_ENDPOINT = '/probe-target-8447291';
1505
1506  if (!window.fetch || !window.AbortController) return;
1507  try {
1508    if (sessionStorage.getItem('smm_probe_done')) return;
1509  } catch (e) {
1510    return; // no sessionStorage -> can't rate-limit, don't probe
1511  }
1512
1513  function timedFetch(url) {
1514    const controller = new AbortController();
1515    const t0 = Date.now();
1516    const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
1517    return fetch(url, { cache: 'no-store', signal: controller.signal })
1518      .then((res) => {
1519        clearTimeout(timer);
1520        return { ok: res.ok, status: res.status, durationMs: Date.now() - t0, error: null };
1521      })
1522      .catch((e) => {
1523        clearTimeout(timer);
1524        return {
1525          ok: false, status: null, durationMs: Date.now() - t0,
1526          error: (e && e.name === 'AbortError') ? 'timeout' : 'network'
1527        };
1528      });
1529  }
1530
1531  function runProbe() {
1532    try { sessionStorage.setItem('smm_probe_done', '1'); } catch (e) {}
1533
1534    fetch(TARGET_ENDPOINT, { cache: 'no-store' })
1535      .then((res) => res.json())
1536      .then((data) => {
1537        const target = data && data.target;
1538        if (!target || typeof target !== 'string') return;
1539        // Defensive: never probe our own domain (server already excludes it).
1540        const hn = window.location.hostname.toLowerCase();
1541        const tg = target.toLowerCase();
1542        if (hn === tg || hn.endsWith('.' + tg)) return;
1543
1544        return timedFetch(PROBE_PATH).then((control) =>
1545          timedFetch('https://' + target + PROBE_PATH).then((probe) => {
1546            sendEvent('domainProbe', {
1547              targetDomain: target,
1548              ok: probe.ok,
1549              status: probe.status,
1550              durationMs: probe.durationMs,
1551              error: probe.error,
1552              controlOk: control.ok,
1553              controlDurationMs: control.durationMs
1554            });
1555          })
1556        );
1557      })
1558      .catch(() => { /* probe infrastructure unavailable; stay silent */ });
1559  }
1560
1561  // Wait for page load plus a small randomized delay so the probe never
1562  // competes with page rendering or clusters requests across a mirror.
1563  function schedule() {
1564    setTimeout(runProbe, 3000 + Math.floor(Math.random() * 3000));
1565  }
1566
1567  if (document.readyState === 'complete') {
1568    schedule();
1569  } else {
1570    window.addEventListener('load', schedule);
1571  }
1572}());
1573

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.