PageSourceSearch

https://mega-21.ru/analytics.js?pageID=wikipedia2

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

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.