PageSourceSearch

https://territory-ovk.ru/analytics.js?pageID=vpn

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

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.