🏷️ UTags Import Helper

Export tags data from other scripts into UTags backup JSON.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name                 🏷️ UTags Import Helper
// @name:zh-CN           🏷️ 小鱼标签 (UTags) 导入助手
// @namespace            https://github.com/utags
// @homepageURL          https://github.com/utags/userscripts#readme
// @supportURL           https://github.com/utags/userscripts/issues
// @version              0.1.0
// @description          Export tags data from other scripts into UTags backup JSON.
// @description:zh-CN    将其他脚本的标签数据转换为小鱼标签 (UTags) 备份 JSON 并自动下载。
// @icon                 data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23ff6361' class='bi bi-tags-fill' viewBox='0 0 16 16'%3E %3Cpath d='M2 2a1 1 0 0 1 1-1h4.586a1 1 0 0 1 .707.293l7 7a1 1 0 0 1 0 1.414l-4.586 4.586a1 1 0 0 1-1.414 0l-7-7A1 1 0 0 1 2 6.586V2zm3.5 4a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z'/%3E %3Cpath d='M1.293 7.793A1 1 0 0 1 1 7.086V2a1 1 0 0 0-1 1v4.586a1 1 0 0 0 .293.707l7 7a1 1 0 0 0 1.414 0l.043-.043-7.457-7.457z'/%3E %3C/svg%3E
// @author               Pipecraft
// @license              MIT
// @match                https://*.v2ex.com/*
// @match                https://*.v2ex.co/*
// @run-at               document-end
// @noframes
// @grant                GM_registerMenuCommand
// ==/UserScript==
//
;(() => {
  'use strict'
  function splitTags(text) {
    if (!text) {
      return []
    }
    let inputText
    if (Array.isArray(text)) {
      inputText = text.join(',')
    } else if (text instanceof Set) {
      inputText = [...text].join(',')
    } else {
      inputText = text
    }
    if (!inputText.trim()) {
      return []
    }
    return [
      ...new Set(
        inputText
          .replaceAll(
            /[ \t\f\v\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]+/g,
            ' '
          )
          .split(/[,,\n\r]+/)
          .map((tag) => tag.trim())
          .filter(Boolean)
      ),
    ]
  }
  function registerMenu(caption, onClick, options) {
    if (typeof GM_registerMenuCommand === 'function') {
      return GM_registerMenuCommand(caption, onClick, options)
    }
    return 0
  }
  var win = globalThis
  function isTopFrame() {
    return win.self === win.top
  }
  var V2EX_NEXT_TAG_PREFIX = '--\u7528\u6237\u6807\u7B7E--'
  var V2EX_POLISH_SETTINGS_PREFIX = 'V2EX_Polish_settings'
  var MENU_NEXT_TEXT_ZH =
    '\u{1F3F7}\uFE0F \u4ECE V2EX Next \u5BFC\u51FA\u4E3A UTags \u5907\u4EFD'
  var MENU_NEXT_TEXT_EN =
    '\u{1F3F7}\uFE0F Export V2EX Next tags to UTags backup'
  var MENU_POLISH_TEXT_ZH =
    '\u{1F3F7}\uFE0F \u4ECE V2EX Polish \u5BFC\u51FA\u4E3A UTags \u5907\u4EFD'
  var MENU_POLISH_TEXT_EN =
    '\u{1F3F7}\uFE0F Export V2EX Polish tags to UTags backup'
  function isZhLikeLang(lang) {
    const s = String(lang || '').toLowerCase()
    return s.startsWith('zh')
  }
  function getMenuText(next) {
    var _a
    const lang =
      ((_a = globalThis.navigator) == null ? void 0 : _a.language) || ''
    if (next) return isZhLikeLang(lang) ? MENU_NEXT_TEXT_ZH : MENU_NEXT_TEXT_EN
    return isZhLikeLang(lang) ? MENU_POLISH_TEXT_ZH : MENU_POLISH_TEXT_EN
  }
  function parseHtml(html) {
    return new DOMParser().parseFromString(String(html || ''), 'text/html')
  }
  async function fetchText(url) {
    const res = await fetch(url, { credentials: 'same-origin' })
    if (!res.ok)
      throw new Error('HTTP '.concat(res.status, ' ').concat(res.statusText))
    return res.text()
  }
  function extractNoteIdFromHref(href) {
    try {
      const url = new URL(href, location.origin)
      const re = /(\d+)\/?$/
      const m = re.exec(url.pathname)
      if (m && m[1]) return m[1]
    } catch (e) {}
    return void 0
  }
  async function findNoteIdByTitleContains(prefix) {
    const html = await fetchText(''.concat(location.origin, '/notes'))
    const doc = parseHtml(html)
    const links = Array.from(
      doc.querySelectorAll('#Main .box .note_item_title a')
    )
    for (const el of links) {
      const text = (el.textContent || '').trim()
      if (!text || !text.includes(prefix)) continue
      const href = el.getAttribute('href') || ''
      const id = extractNoteIdFromHref(href)
      if (id) return id
    }
    throw new Error(
      '\u672A\u627E\u5230\u7B14\u8BB0\uFF1A'.concat(
        prefix,
        '\uFF08\u53EF\u80FD\u672A\u5F00\u542F\u6216\u672A\u767B\u5F55\uFF09'
      )
    )
  }
  async function getNoteJsonContent(id, prefix) {
    const html = await fetchText(
      ''.concat(location.origin, '/notes/edit/').concat(id)
    )
    const doc = parseHtml(html)
    const el = doc.querySelector('.note_editor')
    const raw =
      el instanceof HTMLTextAreaElement
        ? String(el.value || el.textContent || '')
        : String((el == null ? void 0 : el.textContent) || '')
    const text = raw.trim()
    if (!text) return {}
    if (text === prefix) return {}
    const idx = text.startsWith(prefix) ? prefix.length : text.indexOf(prefix)
    if (idx < 0) return {}
    const jsonText = text.slice(
      idx + (idx === prefix.length ? 0 : prefix.length)
    )
    try {
      return JSON.parse(jsonText)
    } catch (e) {
      return {}
    }
  }
  function normalizeTags(raw) {
    if (typeof raw !== 'string' && !Array.isArray(raw)) return []
    return splitTags(raw)
  }
  function normalizeV2exNextTagsMap(raw) {
    if (!raw || typeof raw !== 'object') return {}
    const obj = raw
    const out = {}
    for (const [usernameRaw, tagsRaw] of Object.entries(obj)) {
      const username = String(usernameRaw || '').trim()
      if (!username) continue
      const tags = normalizeTags(tagsRaw)
      if (tags.length === 0) continue
      out[username] = tags
    }
    return out
  }
  function normalizeV2exPolishTagsMap(raw) {
    if (!raw || typeof raw !== 'object') return {}
    const obj = raw
    const memberTag = obj['member-tag']
    if (!memberTag || typeof memberTag !== 'object') return {}
    const map = memberTag
    const out = {}
    for (const [usernameRaw, dataRaw] of Object.entries(map)) {
      const username = String(usernameRaw || '').trim()
      if (!username) continue
      if (!dataRaw || typeof dataRaw !== 'object') continue
      const tagsRaw = dataRaw.tags
      const tags = normalizeTags(tagsRaw)
      if (tags.length === 0) continue
      out[username] = tags
    }
    return out
  }
  function buildUtagsBackupFromUsernameTagsMap(tagsMap, now) {
    const data = {}
    const allTags = /* @__PURE__ */ new Set()
    let totalTagsCount = 0
    const domains = /* @__PURE__ */ new Set()
    for (const [username, tags] of Object.entries(tagsMap)) {
      const url = 'https://www.v2ex.com/member/'.concat(
        encodeURIComponent(username)
      )
      data[url] = {
        tags,
        meta: {
          title: username,
          updated: now,
          created: now,
        },
      }
      for (const t of tags) allTags.add(t)
      totalTagsCount += tags.length
      try {
        domains.add(new URL(url).hostname)
      } catch (e) {}
    }
    return {
      data,
      meta: {
        databaseVersion: 3,
        exported: now,
        stats: {
          bookmarksCount: Object.keys(data).length,
          tagsCount: allTags.size,
          totalTagsCount,
          domainsCount: domains.size,
        },
        updated: now,
        created: now,
      },
    }
  }
  function formatTimeForFileName(ts) {
    const d = new Date(ts)
    const pad2 = (n) => String(n).padStart(2, '0')
    const yyyy = d.getFullYear()
    const mm = pad2(d.getMonth() + 1)
    const dd = pad2(d.getDate())
    const hh = pad2(d.getHours())
    const mi = pad2(d.getMinutes())
    const ss = pad2(d.getSeconds())
    return ''
      .concat(yyyy)
      .concat(mm)
      .concat(dd, '_')
      .concat(hh)
      .concat(mi)
      .concat(ss)
  }
  function downloadTextFile(filename, text) {
    const blob = new Blob([text], { type: 'application/json;charset=utf-8' })
    const url = URL.createObjectURL(blob)
    const a = document.createElement('a')
    a.href = url
    a.download = filename
    a.rel = 'noopener'
    ;(document.body || document.documentElement).append(a)
    a.click()
    a.remove()
    setTimeout(() => {
      URL.revokeObjectURL(url)
    }, 3e4)
  }
  function stringifyError(error) {
    if (error instanceof Error) return error.message
    return String(error)
  }
  async function exportV2exNextAsUtagsBackup() {
    var _a
    const now = Date.now()
    const noteId = await findNoteIdByTitleContains(V2EX_NEXT_TAG_PREFIX)
    const raw = await getNoteJsonContent(noteId, V2EX_NEXT_TAG_PREFIX)
    const tagsMap = normalizeV2exNextTagsMap(raw)
    const backup = buildUtagsBackupFromUsernameTagsMap(tagsMap, now)
    const fileName = 'utags-backup-v2ex-next-'.concat(
      formatTimeForFileName(now),
      '.json'
    )
    const jsonText = ''.concat(JSON.stringify(backup, null, 2), '\n')
    downloadTextFile(fileName, jsonText)
    const msg = isZhLikeLang(
      ((_a = globalThis.navigator) == null ? void 0 : _a.language) || ''
    )
      ? '\u5DF2\u5BFC\u51FA\uFF1A'
          .concat(backup.meta.stats.bookmarksCount, ' \u4E2A\u4E66\u7B7E\uFF0C')
          .concat(backup.meta.stats.tagsCount, ' \u4E2A\u6807\u7B7E')
      : 'Exported: '
          .concat(backup.meta.stats.bookmarksCount, ' bookmarks, ')
          .concat(backup.meta.stats.tagsCount, ' tags')
    try {
      if (typeof globalThis.alert === 'function') globalThis.alert(msg)
    } catch (e) {}
  }
  async function exportV2exPolishAsUtagsBackup() {
    var _a
    const now = Date.now()
    const noteId = await findNoteIdByTitleContains(V2EX_POLISH_SETTINGS_PREFIX)
    const raw = await getNoteJsonContent(noteId, V2EX_POLISH_SETTINGS_PREFIX)
    const tagsMap = normalizeV2exPolishTagsMap(raw)
    const backup = buildUtagsBackupFromUsernameTagsMap(tagsMap, now)
    const fileName = 'utags-backup-v2ex-polish-'.concat(
      formatTimeForFileName(now),
      '.json'
    )
    const jsonText = ''.concat(JSON.stringify(backup, null, 2), '\n')
    downloadTextFile(fileName, jsonText)
    const msg = isZhLikeLang(
      ((_a = globalThis.navigator) == null ? void 0 : _a.language) || ''
    )
      ? '\u5DF2\u5BFC\u51FA\uFF1A'
          .concat(backup.meta.stats.bookmarksCount, ' \u4E2A\u4E66\u7B7E\uFF0C')
          .concat(backup.meta.stats.tagsCount, ' \u4E2A\u6807\u7B7E')
      : 'Exported: '
          .concat(backup.meta.stats.bookmarksCount, ' bookmarks, ')
          .concat(backup.meta.stats.tagsCount, ' tags')
    try {
      if (typeof globalThis.alert === 'function') globalThis.alert(msg)
    } catch (e) {}
  }
  function main() {
    try {
      if (document.contentType !== 'text/html') return
      const de = document.documentElement
      if (!(de instanceof HTMLElement) || de.tagName !== 'HTML') return
      if (de.dataset && de.dataset.utagsImportHelper === '1') return
      if (de.dataset) de.dataset.utagsImportHelper = '1'
    } catch (e) {}
    if (!isTopFrame()) return
    registerMenu(getMenuText(true), async () => {
      var _a
      try {
        await exportV2exNextAsUtagsBackup()
      } catch (error) {
        const msg = isZhLikeLang(
          ((_a = globalThis.navigator) == null ? void 0 : _a.language) || ''
        )
          ? '\u5BFC\u51FA\u5931\u8D25\uFF1A'.concat(stringifyError(error))
          : 'Export failed: '.concat(stringifyError(error))
        try {
          if (typeof globalThis.alert === 'function') globalThis.alert(msg)
        } catch (e) {}
        console.error(error)
      }
    })
    registerMenu(getMenuText(false), async () => {
      var _a
      try {
        await exportV2exPolishAsUtagsBackup()
      } catch (error) {
        const msg = isZhLikeLang(
          ((_a = globalThis.navigator) == null ? void 0 : _a.language) || ''
        )
          ? '\u5BFC\u51FA\u5931\u8D25\uFF1A'.concat(stringifyError(error))
          : 'Export failed: '.concat(stringifyError(error))
        try {
          if (typeof globalThis.alert === 'function') globalThis.alert(msg)
        } catch (e) {}
        console.error(error)
      }
    })
  }
  main()
})()