如何以编程方式创建和管理 macOS Safari 书签?

ruo*_*ola 4 python safari macos applescript automation

我正在制作一个脚本,它更新我的 macOS Safari 上的书签,以始终将我订阅的所有 subreddits 作为特定文件夹中的单个书签。我已经到了在 Python 中将所有 subreddits 作为元组排序列表的地步,将想要的书签名称作为第一个元素,将书签 url 作为第二个元素:

bookmarks = [
     ('r/Android', 'https://www.reddit.com/r/Android/'),
     ('r/Apple', 'https://www.reddit.com/r/Apple/'),
     ('r/Mac', 'https://www.reddit.com/r/Mac/'),
     ('r/ProgrammerHumor', 'https://www.reddit.com/r/ProgrammerHumor/')
]
Run Code Online (Sandbox Code Playgroud)

如何清除 Safari 中的 subreddit 书签文件夹并在该文件夹中创建这些新书签?

到目前为止,我一直在使用 Python,但是从 Python 程序调用外部 AppleScript 或 Shell 脚本是没有问题的。

这是想要的结果的图像,每个书签都链接到各自的 subreddit 网址:

书签文件夹

Rob*_*obC 6

tl;dr有必要编辑 SafariBookmarks.plist以编程方式创建书签。查看下面的“使用 Python 脚本”部分。它需要在 Bash 脚本中使用 XSLT 样式表并通过您的.py文件调用它。实现这一目标所需的所有工具都内置在 macOS 上。

重要提示: 使用 macOS Mojave (10.14.x),+您需要执行下面“MacOS Mojave 限制”部分中的步骤 1-10。这些更改允许对Bookmarks.plist.

在继续之前创建一个副本Bookmarks.plist,可以在 中找到~/Library/Safari/Bookmarks.plist。您可以运行以下命令将其复制到桌面

cp ~/Library/Safari/Bookmarks.plist ~/Desktop/Bookmarks.plist
Run Code Online (Sandbox Code Playgroud)

要恢复Bookmarks.plist以后运行:

cp ~/Desktop/Bookmarks.plist ~/Library/Safari/Bookmarks.plist
Run Code Online (Sandbox Code Playgroud)

属性列表

MacOS 具有内置的与属性列表 ( .plist) 相关的命令行工具,即plutil, 和defaults,它们有助于编辑通常包含平面数据结构的应用程序首选项。然而,Safari 的Bookmarks.plist嵌套结构很深,这两种工具都不擅长编辑。

.plist文件转换为 XML

plutil提供了从二进制-convert转换.plist为 XML的选项。例如:

plutil -convert xml1 ~/Library/Safari/Bookmarks.plist
Run Code Online (Sandbox Code Playgroud)

类似地,以下命令转换为二进制:

plutil -convert binary1 ~/Library/Safari/Bookmarks.plist
Run Code Online (Sandbox Code Playgroud)

转换为 XML 可以使用XSLT,这是转换复杂 XML 结构的理想选择。


使用 XSLT 样式表

此自定义 XSLT 样式表转换Bookmarks.plist添加元素节点以创建书签:

模板.xsl

<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">

  <xsl:strip-space elements="*"/>
  <xsl:output
    method="xml"
    indent="yes"
    doctype-system="http://www.apple.com/DTDs/PropertyList-1.0.dtd"
    doctype-public="-//Apple//DTD PLIST 1.0//EN"/>

  <xsl:param name="bkmarks-folder"/>
  <xsl:param name="bkmarks"/>
  <xsl:param name="guid"/>
  <xsl:param name="keep-existing" select="false" />

  <xsl:variable name="bmCount">
    <xsl:value-of select="string-length($bkmarks) -
          string-length(translate($bkmarks, ',', '')) + 1"/>
  </xsl:variable>

  <xsl:template match="@*|node()">
    <xsl:copy>
      <xsl:apply-templates select="@*|node()" />
    </xsl:copy>
  </xsl:template>

  <xsl:template name="getNthValue">
    <xsl:param name="list"/>
    <xsl:param name="n"/>
    <xsl:param name="delimiter"/>

    <xsl:choose>
      <xsl:when test="$n = 1">
        <xsl:value-of select=
              "substring-before(concat($list, $delimiter), $delimiter)"/>
      </xsl:when>
      <xsl:when test="contains($list, $delimiter) and $n > 1">
        <!-- recursive call -->
        <xsl:call-template name="getNthValue">
          <xsl:with-param name="list"
              select="substring-after($list, $delimiter)"/>
          <xsl:with-param name="n" select="$n - 1"/>
          <xsl:with-param name="delimiter" select="$delimiter"/>
        </xsl:call-template>
      </xsl:when>
    </xsl:choose>
  </xsl:template>

  <xsl:template name="createBmEntryFragment">
    <xsl:param name="loopCount" select="1"/>

    <xsl:variable name="bmInfo">
      <xsl:call-template name="getNthValue">
        <xsl:with-param name="list" select="$bkmarks"/>
        <xsl:with-param name="delimiter" select="','"/>
        <xsl:with-param name="n" select="$loopCount"/>
      </xsl:call-template>
    </xsl:variable>

    <xsl:variable name="bmkName">
      <xsl:call-template name="getNthValue">
        <xsl:with-param name="list" select="$bmInfo"/>
        <xsl:with-param name="delimiter" select="' '"/>
        <xsl:with-param name="n" select="1"/>
      </xsl:call-template>
    </xsl:variable>

    <xsl:variable name="bmURL">
      <xsl:call-template name="getNthValue">
        <xsl:with-param name="list" select="$bmInfo"/>
        <xsl:with-param name="delimiter" select="' '"/>
        <xsl:with-param name="n" select="2"/>
      </xsl:call-template>
    </xsl:variable>

    <xsl:variable name="bmGUID">
      <xsl:call-template name="getNthValue">
        <xsl:with-param name="list" select="$bmInfo"/>
        <xsl:with-param name="delimiter" select="' '"/>
        <xsl:with-param name="n" select="3"/>
      </xsl:call-template>
    </xsl:variable>

    <xsl:if test="$loopCount > 0">
      <dict>
        <key>ReadingListNonSync</key>
        <dict>
          <key>neverFetchMetadata</key>
          <false/>
        </dict>
        <key>URIDictionary</key>
        <dict>
          <key>title</key>
          <string>
            <xsl:value-of select="$bmkName"/>
          </string>
        </dict>
        <key>URLString</key>
        <string>
          <xsl:value-of select="$bmURL"/>
        </string>
        <key>WebBookmarkType</key>
        <string>WebBookmarkTypeLeaf</string>
        <key>WebBookmarkUUID</key>
        <string>
          <xsl:value-of select="$bmGUID"/>
        </string>
      </dict>
      <!-- recursive call -->
      <xsl:call-template name="createBmEntryFragment">
        <xsl:with-param name="loopCount" select="$loopCount - 1"/>
      </xsl:call-template>
    </xsl:if>
  </xsl:template>

  <xsl:template name="createBmFolderFragment">
    <dict>
      <key>Children</key>
      <array>
        <xsl:call-template name="createBmEntryFragment">
          <xsl:with-param name="loopCount" select="$bmCount"/>
        </xsl:call-template>
        <xsl:if test="$keep-existing = 'true'">
          <xsl:copy-of select="./array/node()|@*"/>
        </xsl:if>
      </array>
      <key>Title</key>
      <string>
        <xsl:value-of select="$bkmarks-folder"/>
      </string>
      <key>WebBookmarkType</key>
      <string>WebBookmarkTypeList</string>
      <key>WebBookmarkUUID</key>
      <string>
        <xsl:value-of select="$guid"/>
      </string>
    </dict>
  </xsl:template>

  <xsl:template match="dict[string[text()='BookmarksBar']]/array">
    <array>
      <xsl:for-each select="dict">
        <xsl:choose>
          <xsl:when test="string[text()=$bkmarks-folder]">
            <xsl:call-template name="createBmFolderFragment"/>
          </xsl:when>
          <xsl:otherwise>
            <xsl:copy>
              <xsl:apply-templates select="@*|node()" />
            </xsl:copy>
          </xsl:otherwise>
        </xsl:choose>
      </xsl:for-each>

      <xsl:if test="not(./dict/string[text()=$bkmarks-folder])">
        <xsl:call-template name="createBmFolderFragment"/>
      </xsl:if>
    </array>
  </xsl:template>

</xsl:stylesheet>
Run Code Online (Sandbox Code Playgroud)

运行转换:

.xsl需要指定每个所需书签属性的参数。

  1. 首先确保Bookmarks.plits是 XML 格式:

    plutil -convert xml1 ~/Library/Safari/Bookmarks.plist
    
    Run Code Online (Sandbox Code Playgroud)
  2. 利用内置的xsltproc应用template.xslBookmarks.plist

    首先,cd到 wheretemplate.xsl所在,并运行这个复合命令:

    guid1=$(uuidgen) && guid2=$(uuidgen) && guid3=$(uuidgen) && xsltproc --novalid --stringparam bkmarks-folder "QUUX" --stringparam bkmarks "r/Android https://www.reddit.com/r/Android/ ${guid1},r/Apple https://www.reddit.com/r/Apple/ ${guid2}" --stringparam guid "$guid3" ./template.xsl - <~/Library/Safari/Bookmarks.plist > ~/Desktop/result-plist.xml
    
    Run Code Online (Sandbox Code Playgroud)

    这将创建result-plist.xmlDesktop包含一个新的书签文件夹命名为QUUX两个新的书签。

  3. 让我们进一步了解上述复合命令中的每个部分:

    • uuidgen生成三个新所需的 UUID Bookmarks.plist(一个用于文件夹,一个用于每个书签条目)。我们预先生成它们并将它们传递给 XSLT,因为:

      • XSLT 1.0 没有用于生成 UUID 的功能。
      • xsltproc 需要 XSLT 1.0
    • xsltproc--stringparam选项表示自定义参数如下:

      • --stringparam bkmarks-folder <value> - 书签文件夹的名称。
      • --stringparam bkmarks <value> - 每个书签的属性。

        每个书签规范都用逗号 ( ,)分隔。每个分隔的字符串都有三个值;书签名称、URL 和 GUID。这 3 个值以空格分隔。

      • --stringparam guid <value> - 书签文件夹的 GUID。

    • 最后部分:

      ./template.xsl - <~/Library/Safari/Bookmarks.plist > ~/Desktop/result-plist.xml
      
      Run Code Online (Sandbox Code Playgroud)

      定义路径;的.xsl,源XML,和目的地。

  4. 要评估刚刚发生的转换,请使用diff显示两个文件之间的差异。例如运行:

    diff -yb --width 200 ~/Library/Safari/Bookmarks.plist ~/Desktop/result-plist.xml | less
    
    Run Code Online (Sandbox Code Playgroud)

    然后F多次按下该键以向前导航到每个页面,直到您>在两列中间看到符号 - 它们指示已添加新元素节点的位置。按B键后退一页,然后键入Q退出 diff。


使用 Bash 脚本。

我们现在可以.xsl在 Bash 脚本中使用上述内容。

脚本文件

#!/usr/bin/env bash

declare -r plist_path=~/Library/Safari/Bookmarks.plist

# ANSI/VT100 Control sequences for colored error log.
declare -r fmt_red='\x1b[31m'
declare -r fmt_norm='\x1b[0m'
declare -r fmt_green='\x1b[32m'
declare -r fmt_bg_black='\x1b[40m'

declare -r error_badge="${fmt_red}${fmt_bg_black}ERR!${fmt_norm}"
declare -r tick_symbol="${fmt_green}\\xE2\\x9C\\x94${fmt_norm}"

if [ -z "$1" ] || [ -z "$2" ]; then
  echo -e "${error_badge} Missing required arguments" >&2
  exit 1
fi

bkmarks_folder_name=$1
bkmarks_spec=$2

keep_existing_bkmarks=${3:-false}

# Transform bookmark spec string into array using comma `,` as delimiter.
IFS=',' read -r -a bkmarks_spec <<< "${bkmarks_spec//, /,}"

# Append UUID/GUID to each bookmark spec element.
bkmarks_spec_with_uuid=()
while read -rd ''; do
  [[ $REPLY ]] && bkmarks_spec_with_uuid+=("${REPLY} $(uuidgen)")
done < <(printf '%s\0' "${bkmarks_spec[@]}")

# Transform bookmark spec array back to string using comma `,` as delimiter.
bkmarks_spec_str=$(printf '%s,' "${bkmarks_spec_with_uuid[@]}")
bkmarks_spec_str=${bkmarks_spec_str%,} # Omit trailing comma character.

# Check the .plist file exists.
if [ ! -f "$plist_path" ]; then
  echo -e "${error_badge} File not found: ${plist_path}" >&2
  exit 1
fi

# Verify that plist exists and contains no syntax errors.
if ! plutil -lint -s "$plist_path" >/dev/null; then
  echo -e "${error_badge} Broken or missing plist: ${plist_path}" >&2
  exit 1
fi

# Ignore ShellCheck errors regarding XSLT variable references in template below.
# shellcheck disable=SC2154
xslt() {
cat <<'EOX'
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">

  <xsl:strip-space elements="*"/>
  <xsl:output
    method="xml"
    indent="yes"
    doctype-system="http://www.apple.com/DTDs/PropertyList-1.0.dtd"
    doctype-public="-//Apple//DTD PLIST 1.0//EN"/>

  <xsl:param name="bkmarks-folder"/>
  <xsl:param name="bkmarks"/>
  <xsl:param name="guid"/>
  <xsl:param name="keep-existing" select="false" />

  <xsl:variable name="bmCount">
    <xsl:value-of select="string-length($bkmarks) -
          string-length(translate($bkmarks, ',', '')) + 1"/>
  </xsl:variable>

  <xsl:template match="@*|node()">
    <xsl:copy>
      <xsl:apply-templates select="@*|node()" />
    </xsl:copy>
  </xsl:template>

  <xsl:template name="getNthValue">
    <xsl:param name="list"/>
    <xsl:param name="n"/>
    <xsl:param name="delimiter"/>

    <xsl:choose>
      <xsl:when test="$n = 1">
        <xsl:value-of select=
              "substring-before(concat($list, $delimiter), $delimiter)"/>
      </xsl:when>
      <xsl:when test="contains($list, $delimiter) and $n > 1">
        <!-- recursive call -->
        <xsl:call-template name="getNthValue">
          <xsl:with-param name="list"
              select="substring-after($list, $delimiter)"/>
          <xsl:with-param name="n" select="$n - 1"/>
          <xsl:with-param name="delimiter" select="$delimiter"/>
        </xsl:call-template>
      </xsl:when>
    </xsl:choose>
  </xsl:template>

  <xsl:template name="createBmEntryFragment">
    <xsl:param name="loopCount" select="1"/>

    <xsl:variable name="bmInfo">
      <xsl:call-template name="getNthValue">
        <xsl:with-param name="list" select="$bkmarks"/>
        <xsl:with-param name="delimiter" select="','"/>
        <xsl:with-param name="n" select="$loopCount"/>
      </xsl:call-template>
    </xsl:variable>

    <xsl:variable name="bmkName">
      <xsl:call-template name="getNthValue">
        <xsl:with-param name="list" select="$bmInfo"/>
        <xsl:with-param name="delimiter" select="' '"/>
        <xsl:with-param name="n" select="1"/>
      </xsl:call-template>
    </xsl:variable>

    <xsl:variable name="bmURL">
      <xsl:call-template name="getNthValue">
        <xsl:with-param name="list" select="$bmInfo"/>
        <xsl:with-param name="delimiter" select="' '"/>
        <xsl:with-param name="n" select="2"/>
      </xsl:call-template>
    </xsl:variable>

    <xsl:variable name="bmGUID">
      <xsl:call-template name="getNthValue">
        <xsl:with-param name="list" select="$bmInfo"/>
        <xsl:with-param name="delimiter" select="' '"/>
        <xsl:with-param name="n" select="3"/>
      </xsl:call-template>
    </xsl:variable>

    <xsl:if test="$loopCount > 0">
      <dict>
        <key>ReadingListNonSync</key>
        <dict>
          <key>neverFetchMetadata</key>
          <false/>
        </dict>
        <key>URIDictionary</key>
        <dict>
          <key>title</key>
          <string>
            <xsl:value-of select="$bmkName"/>
          </string>
        </dict>
        <key>URLString</key>
        <string>
          <xsl:value-of select="$bmURL"/>
        </string>
        <key>WebBookmarkType</key>
        <string>WebBookmarkTypeLeaf</string>
        <key>WebBookmarkUUID</key>
        <string>
          <xsl:value-of select="$bmGUID"/>
        </string>
      </dict>
      <!-- recursive call -->
      <xsl:call-template name="createBmEntryFragment">
        <xsl:with-param name="loopCount" select="$loopCount - 1"/>
      </xsl:call-template>
    </xsl:if>
  </xsl:template>

  <xsl:template name="createBmFolderFragment">
    <dict>
      <key>Children</key>
      <array>
        <xsl:call-template name="createBmEntryFragment">
          <xsl:with-param name="loopCount" select="$bmCount"/>
        </xsl:call-template>
        <xsl:if test="$keep-existing = 'true'">
          <xsl:copy-of select="./array/node()|@*"/>
        </xsl:if>
      </array>
      <key>Title</key>
      <string>
        <xsl:value-of select="$bkmarks-folder"/>
      </string>
      <key>WebBookmarkType</key>
      <string>WebBookmarkTypeList</string>
      <key>WebBookmarkUUID</key>
      <string>
        <xsl:value-of select="$guid"/>
      </string>
    </dict>
  </xsl:template>

  <xsl:template match="dict[string[text()='BookmarksBar']]/array">
    <array>
      <xsl:for-each select="dict">
        <xsl:choose>
          <xsl:when test="string[text()=$bkmarks-folder]">
            <xsl:call-template name="createBmFolderFragment"/>
          </xsl:when>
          <xsl:otherwise>
            <xsl:copy>
              <xsl:apply-templates select="@*|node()" />
            </xsl:copy>
          </xsl:otherwise>
        </xsl:choose>
      </xsl:for-each>

      <xsl:if test="not(./dict/string[text()=$bkmarks-folder])">
        <xsl:call-template name="createBmFolderFragment"/>
      </xsl:if>
    </array>
  </xsl:template>

</xsl:stylesheet>
EOX
}

# Convert the .plist to XML format
plutil -convert xml1 -- "$plist_path" >/dev/null || {
  echo -e "${error_badge} Cannot convert .plist to xml format" >&2
  exit 1
}

# Generate a UUID/GUID for the folder.
folder_guid=$(uuidgen)

xsltproc --novalid \
    --stringparam keep-existing "$keep_existing_bkmarks" \
    --stringparam bkmarks-folder "$bkmarks_folder_name" \
    --stringparam bkmarks "$bkmarks_spec_str" \
    --stringparam guid "$folder_guid" \
    <(xslt) - <"$plist_path" > "${TMPDIR}result-plist.xml"

# Convert the .plist to binary format
plutil -convert binary1 -- "${TMPDIR}result-plist.xml" >/dev/null || {
  echo -e "${error_badge} Cannot convert .plist to binary format" >&2
  exit 1
}

mv -- "${TMPDIR}result-plist.xml" "$plist_path" 2>/dev/null || {
  echo -e "${error_badge} Cannot move .plist from TMPDIR to ${plist_path}" >&2
  exit 1
}

echo -e "${tick_symbol} Successfully created Safari bookmarks."
Run Code Online (Sandbox Code Playgroud)

解释

script.sh 提供以下功能:

  1. 通过 Python 执行时将有益的简化 API。
  2. 验证.plist没有损坏。
  3. 错误处理/记录。
  4. .plist通过xsltproc使用template.xsl内联进行转换。
  5. 根据编号创建用于传递给 XSLT 的 GUID。给定参数中指定的书签。
  6. 转换.plist为 XML,然后返回二进制。
  7. 将新文件写入操作系统的临时文件夹,然后将其移动到Bookmarks.plist目录,有效地替换原始文件。

运行 shell 脚本

  1. cdscript.sh所在的位置并运行以下chmod命令以使其script.sh可执行:

    chmod +ux script.sh
    
    Run Code Online (Sandbox Code Playgroud)
  2. 运行以下命令:

    ./script.sh "stackOverflow" "bash https://stackoverflow.com/questions/tagged/bash,python https://stackoverflow.com/questions/tagged/python"
    
    Run Code Online (Sandbox Code Playgroud)

    然后将以下内容打印到您的 CLI:

    ? Successfully created Safari bookmarks.

    Safari 现在有一个名为 bookmarks 的文件夹,stackOverflow其中包含两个书签(bashpy

    • 令人印象深刻的完整和详细的答案! (2认同)