Switch User Agent

Spoof navigator user-agent values for configured sites; enables lobste.rs by default only on Brave and exposes console helpers.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Switch User Agent
// @namespace    https://github.com/o-az/userscripts
// @version      1.2
// @description  Spoof navigator user-agent values for configured sites; enables lobste.rs by default only on Brave and exposes console helpers.
// @author       https://github.com/o-az
// @match        *://*/*
// @homepageURL  https://github.com/o-az/userscripts
// @source       https://github.com/o-az/userscripts/blob/main/src/switch-user-agent.user.js
// @supportURL   https://github.com/o-az/userscripts/issues
// @tag          user-agent
// @tag          brave
// @tag          navigator
// @tag          compatibility
// @license      MIT
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        unsafeWindow
// @run-at       document-start
// @noframes
// ==/UserScript==

;(() => {
  'use strict'

  const STORAGE_KEY = 'switch-user-agent.rules.v1'
  const GLOBAL = /** @type {typeof globalThis & {
    unsafeWindow?: Window
    GM_getValue?: (key: string, defaultValue: string) => string
    GM_setValue?: (key: string, value: string) => void
  }} */ (globalThis)
  const PAGE = GLOBAL.unsafeWindow || window
  const CHROME_120_WINDOWS_UA =
    'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'

  const UA_PRESETS = [
    {
      id: 'chrome-windows',
      label: 'Chrome on Windows',
      keywords: [
        'chrome',
        'chromium',
        'brave',
        'google',
        'windows',
        'win',
        'desktop',
        'default',
      ],
      userAgent: CHROME_120_WINDOWS_UA,
      platform: 'Win32',
      vendor: 'Google Inc.',
      uaPlatform: 'Windows',
      brands: [
        { brand: 'Not A(Brand', version: '99' },
        { brand: 'Google Chrome', version: '120' },
        { brand: 'Chromium', version: '120' },
      ],
    },
    {
      id: 'chrome-mac',
      label: 'Chrome on macOS',
      keywords: [
        'chrome',
        'chromium',
        'brave',
        'google',
        'mac',
        'macos',
        'osx',
        'desktop',
      ],
      userAgent:
        'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
      platform: 'MacIntel',
      vendor: 'Google Inc.',
      uaPlatform: 'macOS',
      brands: [
        { brand: 'Not A(Brand', version: '99' },
        { brand: 'Google Chrome', version: '120' },
        { brand: 'Chromium', version: '120' },
      ],
    },
    {
      id: 'edge-windows',
      label: 'Edge on Windows',
      keywords: ['edge', 'edg', 'microsoft', 'windows', 'win', 'desktop'],
      userAgent:
        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0',
      platform: 'Win32',
      vendor: 'Google Inc.',
      uaPlatform: 'Windows',
      brands: [
        { brand: 'Not A(Brand', version: '99' },
        { brand: 'Microsoft Edge', version: '120' },
        { brand: 'Chromium', version: '120' },
      ],
    },
    {
      id: 'safari-mac',
      label: 'Safari on macOS',
      keywords: ['safari', 'mac', 'macos', 'osx', 'desktop'],
      userAgent:
        'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15',
      platform: 'MacIntel',
      vendor: 'Apple Computer, Inc.',
      uaPlatform: 'macOS',
      brands: [],
    },
    {
      id: 'firefox-windows',
      label: 'Firefox on Windows',
      keywords: ['firefox', 'ff', 'mozilla', 'windows', 'win', 'desktop'],
      userAgent:
        'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0',
      platform: 'Win32',
      vendor: '',
      uaPlatform: 'Windows',
      brands: [],
    },
    {
      id: 'iphone-safari',
      label: 'Safari on iPhone',
      keywords: ['iphone', 'ios', 'mobile', 'safari', 'phone'],
      userAgent:
        'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1',
      platform: 'iPhone',
      vendor: 'Apple Computer, Inc.',
      uaPlatform: 'iOS',
      mobile: true,
      brands: [],
    },
  ]

  const DEFAULT_PRESET_ID = 'chrome-windows'

  const isBraveBrowser = () => {
    const navigatorWithBrave =
      /** @type {Navigator & { brave?: { isBrave?: () => Promise<boolean> } }} */ (
        PAGE.navigator
      )
    return typeof navigatorWithBrave.brave?.isBrave === 'function'
  }

  /** @param {string | undefined} presetId */
  const getPresetById = (presetId) =>
    UA_PRESETS.find((preset) => preset.id === presetId) ||
    /** @type {(typeof UA_PRESETS)[number]} */ (UA_PRESETS[0])

  /**
   * Pick the most likely preset from one or two human keywords like:
   * 'chrome', 'windows chrome', 'safari mac', 'iphone', or 'firefox'.
   *
   * @param {string} [keywords]
   */
  const pickPreset = (keywords = DEFAULT_PRESET_ID) => {
    const searchTerms = keywords
      .toLowerCase()
      .split(/[\s,;+/-]+/)
      .filter(Boolean)

    if (searchTerms.length === 0) return getPresetById(DEFAULT_PRESET_ID)

    const exactPreset = UA_PRESETS.find(
      (preset) => preset.id === keywords.toLowerCase(),
    )
    if (exactPreset) return exactPreset

    return UA_PRESETS.map((preset) => ({
      preset,
      score: searchTerms.reduce((score, term) => {
        if (preset.id.includes(term)) return score + 4
        if (preset.keywords.includes(term)) return score + 3
        if (preset.label.toLowerCase().includes(term)) return score + 2
        return score
      }, 0),
    })).sort((a, b) => b.score - a.score)[0]?.preset
  }

  /** @param {string | string[]} hostnames */
  const normalizeHostnames = (hostnames) => {
    const values = Array.isArray(hostnames) ? hostnames : [hostnames]
    return [
      ...new Set(
        values.flatMap((value) => {
          const hostname = value
            .trim()
            .replace(/^https?:\/\//, '')
            .replace(/\/.*$/, '')
            .toLowerCase()

          if (!hostname || hostname.startsWith('*.')) return [hostname]
          if (hostname.startsWith('www.')) return [hostname, hostname.slice(4)]
          return [hostname, `www.${hostname}`]
        }),
      ),
    ].filter(Boolean)
  }

  /**
   * @param {string | string[]} hostnames
   * @param {string} [keywords]
   * @param {Record<string, unknown>} [overrides]
   */
  const createRule = (
    hostnames,
    keywords = DEFAULT_PRESET_ID,
    overrides = {},
  ) => {
    const preset = pickPreset(keywords) || getPresetById(DEFAULT_PRESET_ID)
    const normalizedHostnames = normalizeHostnames(hostnames)
    const {
      name,
      hostnames: _hostnames,
      hostname: _hostname,
      ...rest
    } = overrides

    return {
      preset: preset.id,
      userAgent: preset.userAgent,
      platform: preset.platform,
      vendor: preset.vendor,
      uaPlatform: preset.uaPlatform,
      mobile: preset.mobile || false,
      brands: preset.brands,
      ...rest,
      name:
        typeof name === 'string'
          ? name
          : normalizedHostnames[0] || 'Custom site',
      hostnames: normalizedHostnames,
    }
  }

  /**
   * Built-in rules live in the userscript so they work before any page scripts run.
   *
   * Add more sites here when you want the rule to be synced with the userscript:
   * createRule('example.com', 'chrome')
   */
  const DEFAULT_RULES = isBraveBrowser()
    ? [createRule('lobste.rs', 'chrome windows', { name: 'Lobsters' })]
    : []

  const getStoredRules = () => {
    try {
      const raw = GLOBAL.GM_getValue
        ? GLOBAL.GM_getValue(STORAGE_KEY, '[]')
        : localStorage.getItem(STORAGE_KEY) || '[]'
      const parsed = JSON.parse(raw)
      return Array.isArray(parsed) ? parsed : []
    } catch {
      return []
    }
  }

  /** @param {Array<Record<string, unknown>>} rules */
  const setStoredRules = (rules) => {
    const serializedRules = JSON.stringify(rules, null, 2)

    if (GLOBAL.GM_setValue) {
      GLOBAL.GM_setValue(STORAGE_KEY, serializedRules)
      return
    }

    localStorage.setItem(STORAGE_KEY, serializedRules)
  }

  const getRules = () => [...getStoredRules(), ...DEFAULT_RULES]

  /** @param {string} pattern */
  const hostnameMatches = (pattern) => {
    const hostname = location.hostname.toLowerCase()
    const normalizedPattern = pattern.toLowerCase()

    if (normalizedPattern.startsWith('*.')) {
      const suffix = normalizedPattern.slice(2)
      return hostname === suffix || hostname.endsWith(`.${suffix}`)
    }

    return hostname === normalizedPattern
  }

  /** @param {Record<string, unknown>} rule */
  const ruleMatches = (rule) => {
    const hostnames = Array.isArray(rule.hostnames)
      ? rule.hostnames
      : rule.hostname
        ? [rule.hostname]
        : []

    const hostMatch = hostnames.some(
      (hostname) => typeof hostname === 'string' && hostnameMatches(hostname),
    )

    if (!hostMatch) return false

    if (typeof rule.pathnamePrefix === 'string') {
      return location.pathname.startsWith(rule.pathnamePrefix)
    }

    if (typeof rule.hrefPattern === 'string') {
      try {
        return new RegExp(rule.hrefPattern).test(location.href)
      } catch {
        return false
      }
    }

    return true
  }

  /** @param {Record<string, unknown>} rule */
  const getUserAgentData = (rule) => {
    const brands = Array.isArray(rule.brands) ? rule.brands : []

    if (brands.length === 0) return undefined

    return {
      brands,
      mobile: rule.mobile === true,
      platform:
        typeof rule.uaPlatform === 'string' ? rule.uaPlatform : 'Windows',
      getHighEntropyValues: async (/** @type {string[]} */ hints) => {
        const values = {
          architecture: 'x86',
          bitness: '64',
          brands,
          fullVersionList: brands.map((brand) => ({
            ...brand,
            version: `${brand.version}.0.0.0`,
          })),
          mobile: rule.mobile === true,
          model: '',
          platform:
            typeof rule.uaPlatform === 'string' ? rule.uaPlatform : 'Windows',
          platformVersion: '10.0.0',
          uaFullVersion: '120.0.0.0',
          wow64: false,
        }

        const typedValues = /** @type {Record<string, unknown>} */ (values)

        return Object.fromEntries(
          hints
            .filter((hint) => Object.hasOwn(typedValues, hint))
            .map((hint) => [hint, typedValues[hint]]),
        )
      },
      toJSON() {
        return {
          brands: this.brands,
          mobile: this.mobile,
          platform: this.platform,
        }
      },
    }
  }

  /**
   * @param {object} target
   * @param {string} property
   * @param {unknown} value
   */
  const defineNavigatorGetter = (target, property, value) => {
    try {
      Object.defineProperty(target, property, {
        configurable: true,
        enumerable: true,
        get: () => value,
      })
    } catch {
      // Some browsers expose non-configurable navigator properties. Try the next target.
    }
  }

  /** @param {Record<string, unknown>} rule */
  const applyRule = (rule) => {
    if (typeof rule.userAgent !== 'string') return

    const navigatorPrototype = Object.getPrototypeOf(PAGE.navigator)
    const properties = {
      userAgent: rule.userAgent,
      appVersion: rule.userAgent.replace(/^Mozilla\//, ''),
      platform: typeof rule.platform === 'string' ? rule.platform : 'Win32',
      vendor: typeof rule.vendor === 'string' ? rule.vendor : 'Google Inc.',
      userAgentData: getUserAgentData(rule),
    }

    for (const [property, value] of Object.entries(properties)) {
      defineNavigatorGetter(navigatorPrototype, property, value)
      defineNavigatorGetter(PAGE.navigator, property, value)
    }

    console.info(
      '[Switch User Agent] Active rule:',
      rule.name || rule.hostnames,
    )
    console.info(
      '[Switch User Agent] navigator.userAgent:',
      PAGE.navigator.userAgent,
    )
  }

  const activeRule = getRules().find(ruleMatches)
  if (activeRule) applyRule(activeRule)

  const api = {
    currentRule: activeRule || null,
    listRules: getRules,
    listCustomRules: getStoredRules,
    saveCustomRules: setStoredRules,
    presets: () =>
      UA_PRESETS.map(({ id, label, keywords, userAgent }) => ({
        id,
        label,
        keywords,
        userAgent,
      })),
    pickPreset,
    createRule,
    /**
     * Add a site with one or two human keywords. Defaults to Chrome on Windows.
     *
     * Examples:
     * SwitchUserAgent.add('example.com')
     * SwitchUserAgent.add('example.com', 'safari mac')
     * SwitchUserAgent.add(['example.com', '*.example.com'], 'firefox')
     *
     * @param {string | string[]} hostnames
     * @param {string} [keywords]
     * @param {Record<string, unknown>} [overrides]
     */
    add(hostnames, keywords = DEFAULT_PRESET_ID, overrides = {}) {
      const customRules = getStoredRules()
      const nextRule = createRule(hostnames, keywords, overrides)
      customRules.push(nextRule)
      setStoredRules(customRules)
      return nextRule
    },
    /** @param {Record<string, unknown>} rule */
    addRule(rule) {
      const customRules = getStoredRules()
      const hostnames = Array.isArray(rule.hostnames)
        ? rule.hostnames.filter((hostname) => typeof hostname === 'string')
        : typeof rule.hostname === 'string'
          ? rule.hostname
          : []
      const nextRule = rule.userAgent ? rule : createRule(hostnames)
      customRules.push(nextRule)
      setStoredRules(customRules)
      return nextRule
    },
    /** @param {string} nameOrHostname */
    removeRule(nameOrHostname) {
      const customRules = getStoredRules().filter((rule) => {
        const hostnames = Array.isArray(rule.hostnames)
          ? rule.hostnames
          : rule.hostname
            ? [rule.hostname]
            : []

        return (
          rule.name !== nameOrHostname && !hostnames.includes(nameOrHostname)
        )
      })
      setStoredRules(customRules)
      return customRules
    },
    exampleRule: createRule('example.com', 'chrome'),
  }

  Object.defineProperty(PAGE, 'SwitchUserAgent', {
    configurable: true,
    value: api,
  })
})()