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


问题内容

我正在编写一个脚本,该脚本将更新macOS
Safari上的书签,以始终将所有订阅的子reddit作为单个书签保存在特定文件夹中。我已经到了一个地步,在Python中,所有子项都作为元组的排序列表,以想要的书签名称为第一个元素,以书签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/')
]

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

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

这是所需结果的图像,每个书签都链接到其各自的subreddit url:

书签文件夹


问题答案:

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

要恢复Bookmarks.plist以后运行:

cp ~/Desktop/Bookmarks.plist ~/Library/Safari/Bookmarks.plist

物业清单

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

.plist文件转换为XML

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

plutil -convert xml1 ~/Library/Safari/Bookmarks.plist

同样,以下命令将转换为二进制:

plutil -convert binary1 ~/Library/Safari/Bookmarks.plist

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


使用XSLT样式表

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

template.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>

运行转换:

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

  1. 首先,确保其Bookmarks.plits为XML格式:

    plutil -convert xml1 ~/Library/Safari/Bookmarks.plist
    
  2. 利用内置的xsltproc应用template.xslBookmarks.plist

首先,cd到哪里template.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

这会result-plist.xml在您的目录Desktop中创建一个QUUX包含两个新书签的新书签文件夹。

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

    • uuidgen生成新Bookmarks.plist文件中需要的三个UUID (一个用于文件夹,一个用于每个书签条目)。我们先生成它们,然后将它们传递给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

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

  1. 要评估刚刚发生的转换,diff可以显示两个文件之间的差异。例如运行:
    diff -yb --width 200 ~/Library/Safari/Bookmarks.plist ~/Desktop/result-plist.xml | less
    

然后按F几次该键以前进至每一页,直到您>在两列中间看到符号-它们指示在何处添加了新的元素节点。按键B后退一页,然后键入Q退出差异。


使用Bash脚本。

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

script.sh

#!/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."

说明

script.sh 提供以下功能:

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

运行shell脚本

  1. cdscript.sh驻留位置,然后运行以下chmod命令以使script.sh可执行文件:

    chmod +ux script.sh
    
  2. 运行以下命令:

    ./script.sh "stackOverflow" "bash https://stackoverflow.com/questions/tagged/bash,python https://stackoverflow.com/questions/tagged/python"
    

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

✔ Successfully created Safari bookmarks.

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


使用Python脚本

有两种方法可以script.sh通过.py文件执行。

方法A:外部shell脚本

以下.py文件执行外部script.sh文件。让我们命名文件create-safari- bookmarks.py并将其保存在与相同的文件夹中script.sh

创建Safari浏览器bookmarks.py

#!/usr/bin/env python

import subprocess


def run_script(folder_name, bkmarks):
    subprocess.call(["./script.sh", folder_name, bkmarks])


def tuple_to_shell_arg(tup):
    return ",".join("%s %s" % t for t in tup)

reddit_bkmarks = [
    ('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/'),
    ('r/gaming', 'https://www.reddit.com/r/gaming/')
]

so_bkmarks = [
    ('bash', 'https://stackoverflow.com/questions/tagged/bash'),
    ('python', 'https://stackoverflow.com/questions/tagged/python'),
    ('xslt', 'https://stackoverflow.com/questions/tagged/xslt'),
    ('xml', 'https://stackoverflow.com/questions/tagged/xml')
]

run_script('subreddit', tuple_to_shell_arg(reddit_bkmarks))
run_script("stackOverflow", tuple_to_shell_arg(so_bkmarks))

说明:

  1. The first def statement defines a run-script function. It has two parameters; folder_name and bkmarks. The subprocess modules call method essentially executes script.sh with the required arguments.

  2. The second def statement defines a tuple_to_shell_arg function. It has one parameter tup. The String join() method transforms a list of tuples into a format required by script.sh. It essentially transforms a list of tuples such as:

    [
    ('foo', 'https://www.foo.com/'),
    ('quux', 'https://www.quux.com')
    

    ]

and returns a string:

    foo https://www.foo.com/,quux https://www.quux.com
  1. The run_script function is invoked as follows:
    run_script('subreddit', tuple_to_shell_arg(reddit_bkmarks))
    

This passes two arguments; subreddit (the name of the bookmarks folder), and
the spec for each required bookmark (formatted as previously described in
point no. 2).

Runningcreate-safari-bookmarks.py

  1. Make create-safari-bookmarks.py executable:

    chmod +ux ./create-safari-bookmarks.py
    
  2. Then invoke it with:

    ./create-safari-bookmarks.py
    

Method B: Inline shell script

Depending on your exact use case, you may want to consider inlining
script.sh in your .py file instead of calling an external .sh file.
Let’s name this file create-safari-bookmarks-inlined.py and save it to the
same directory where create-safari-bookmarks.py resides.

Important:

  • You’ll need to copy and paste all the content from script.sh into create-safari-bookmarks-inlined.py where indicated.

  • Paste it on the next line following the bash_script = """\ part.

  • The """ part in create-safari-bookmarks-inlined.py should be on it’s own line following the last line of the pasted script.sh content.

  • Line 31 of script.sh when inlined in .py must have the '%s\0' part (\0 is a null character) escaped with another backslash, i.e. line 31 of script.sh should appear like this:
    ...
    

    done < <(printf ‘%s\0’ “${bkmarks_spec[@]}”)
    ^

This line will probably be on line 37 in create-safari-bookmarks-inlined.py.

create-safari-bookmarks-inlined.py

#!/usr/bin/env python

import tempfile
import subprocess

bash_script = """\
# <--- Copy and paste content of `script.sh` here and modify its line 31.
"""


def run_script(script, folder_name, bkmarks):
    with tempfile.NamedTemporaryFile() as scriptfile:
        scriptfile.write(script)
        scriptfile.flush()
        subprocess.call(["/bin/bash", scriptfile.name, folder_name, bkmarks])


def tuple_to_shell_arg(tup):
    return ",".join("%s %s" % t for t in tup)

reddit_bkmarks = [
    ('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/'),
    ('r/gaming', 'https://www.reddit.com/r/gaming/')
]

so_bkmarks = [
    ('bash', 'https://stackoverflow.com/questions/tagged/bash'),
    ('python', 'https://stackoverflow.com/questions/tagged/python'),
    ('xslt', 'https://stackoverflow.com/questions/tagged/xslt'),
    ('xml', 'https://stackoverflow.com/questions/tagged/xml')
]

run_script(bash_script, "subreddit", tuple_to_shell_arg(reddit_bkmarks))
run_script(bash_script, "stackOverflow", tuple_to_shell_arg(so_bkmarks))

Explanation

  1. This file achieves the same result as create-safari-bookmarks.py.

  2. This modified .py script includes a modified run_script function that utilizes Python’s tempfile module to save the inline shell script to a temporary file.

  3. Python’s subprocess modules call method then executes the temporary created shell file.

Runningcreate-safari-bookmarks-inlined.py

  1. Make create-safari-bookmarks-inlined.py executable:

    chmod +ux ./create-safari-bookmarks-inlined.py
    
  2. Then invoke it by running:

    ./create-safari-bookmarks-inlined.py
    

Additional Note: Appending bookmarks to an existing folder

Currently, each time the aforementioned scripts/commands are run again we are
effectively replacing any existing named Safari bookmark folder, (which has
the same name as the given bookmark folder name), with a completely new one
and creating the specified bookmarks.

However, if you wanted to append bookmarks to an exiting folder then
template.xsl includes one additional parameter/argument to be passed to it.
Note the part on line 14 that reads:

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

It’s default value is false. So, if we were to change the run_script
function in let’s say create-safari-bookmarks.py to the following.

def run_script(folder_name, bkmarks, keep_existing):
        subprocess.call(["./script.sh", folder_name, bkmarks, keep_existing])

That is to add a third parameter named keep_existing, and include a
reference to it in the subprocess.call([...]), i.e. so that it gets passed
as the third argument to script.sh (…and subsequently to the XSLT
stylesheet).

We can then invoke the run_script function and pass in an additional String
argument, either "true" or "false" like so:

run_script('subreddit', tuple_to_shell_arg(reddit_bkmarks), "true")
run_script("stackOverflow", tuple_to_shell_arg(so_bkmarks), "false")

However, making the changes above, (i.e. passing in "true" to keep existing
bookmarks), does have potential to result in duplicate bookmarks being
created. For example; duplicate bookmarks will occur when we have an exiting
bookmark (name and URL) which is then reprovided with the same name and URL at
a later time.

Limitations: Currently any name argument provided for a bookmark cannot
include a space character(s) because they are used as delimiters by the
script(s).


MacOS Mojave Restrictions

Due to stricter security policies on macOS Mojave (10.14.x) access to
~/Library/Safari/Bookmarks.plist is not permitted by default (as mentioned
in this answer).

Therefore, it is necessary to grant the Terminal.app , (or other preferred
CLI tool such as iTerm ), access to your whole disk. To do this you’ll need
to:

  1. Select System Preferences from the Apple menu.
  2. In the System Preferences window click the Security & Policy icon.
  3. In the Security & Policy pane click the Privacy tab.
  4. Choose Full Disk Access in the left-hand column.
  5. 单击左下角的锁定图标以允许更改。
  6. 输入管理员密码,然后单击 解锁 按钮。
  7. 接下来,单击加号图标(+)。
  8. 选择 Terminal.app ,它可以位于/Applications/Utilities/,然后单击“ 打开” 按钮。
  9. Terminal.app 将被添加到列表中。
  10. 单击锁定图标以防止进一步更改,然后退出 系统偏好设置

屏幕截图