diff --git a/build.py b/build.py
index 4ee99758..cf35f7c5 100644
--- a/build.py
+++ b/build.py
@@ -110,6 +110,7 @@ def build() -> None:
         "plugins/edsm.py",
         "plugins/edsy.py",
         "plugins/inara.py",
+        "plugins/spansh_core.py",
     ]
     options: dict = {
         "py2exe": {
diff --git a/plugins/edsm.py b/plugins/edsm.py
index 2ed31ab3..ba292ca6 100644
--- a/plugins/edsm.py
+++ b/plugins/edsm.py
@@ -205,7 +205,7 @@ def plugin_start3(plugin_dir: str) -> str:
     # Can't be earlier since can only call PhotoImage after window is created
     this._IMG_KNOWN = tk.PhotoImage(data=IMG_KNOWN_B64)  # green circle
     this._IMG_UNKNOWN = tk.PhotoImage(data=IMG_UNKNOWN_B64)  # red circle
-    this._IMG_NEW = tk.PhotoImage(data=IMG_NEW_B64)
+    this._IMG_NEW = tk.PhotoImage(data=IMG_NEW_B64)  # yellow star
     this._IMG_ERROR = tk.PhotoImage(data=IMG_ERR_B64)  # BBC Mode 5 '?'
 
     # Migrate old settings
diff --git a/plugins/spansh_core.py b/plugins/spansh_core.py
new file mode 100644
index 00000000..18a51846
--- /dev/null
+++ b/plugins/spansh_core.py
@@ -0,0 +1,195 @@
+"""
+spansh_core.py - Spansh URL provider.
+
+Copyright (c) EDCD, All Rights Reserved
+Licensed under the GNU General Public License.
+See LICENSE file.
+
+This is an EDMC 'core' plugin.
+All EDMC plugins are *dynamically* loaded at run-time.
+
+We build for Windows using `py2exe`.
+`py2exe` can't possibly know about anything in the dynamically loaded core plugins.
+
+Thus, you **MUST** check if any imports you add in this file are only
+referenced in this file (or only in any other core plugin), and if so...
+
+    YOU MUST ENSURE THAT PERTINENT ADJUSTMENTS ARE MADE IN
+    `build.py` TO ENSURE THE FILES ARE ACTUALLY PRESENT
+    IN AN END-USER INSTALLATION ON WINDOWS.
+"""
+from __future__ import annotations
+
+import tkinter as tk
+from typing import Any, cast
+import requests
+from companion import CAPIData
+from config import appname, config
+from EDMCLogging import get_main_logger
+
+logger = get_main_logger()
+
+
+class This:
+    """Holds module globals."""
+
+    def __init__(self):
+        self.parent: tk.Tk
+        self.shutting_down = False  # Plugin is shutting down.
+        self.system_link: tk.Widget = None  # type: ignore
+        self.system_name: str | None = None  # type: ignore
+        self.system_address: str | None = None  # type: ignore
+        self.system_population: int | None = None
+        self.station_link: tk.Widget = None  # type: ignore
+        self.station_name = None
+        self.station_marketid = None
+        self.on_foot = False
+
+
+this = This()
+STATION_UNDOCKED: str = '×'  # "Station" name to display when not docked = U+00D7
+
+
+def plugin_start3(plugin_dir: str) -> str:
+    """
+    Start the plugin.
+
+    :param plugin_dir: NAme of directory this was loaded from.
+    :return: Identifier string for this plugin.
+    """
+    return 'spansh'
+
+
+def plugin_app(parent: tk.Tk) -> None:
+    """
+    Construct this plugin's main UI, if any.
+
+    :param parent: The tk parent to place our widgets into.
+    :return: See PLUGINS.md#display
+    """
+    this.parent = parent
+    this.system_link = parent.nametowidget(f".{appname.lower()}.system")
+    this.station_link = parent.nametowidget(f".{appname.lower()}.station")
+
+
+def plugin_stop() -> None:
+    """Plugin shutdown hook."""
+    this.shutting_down = True
+
+
+def journal_entry(
+    cmdr: str, is_beta: bool, system: str, station: str, entry: dict[str, Any], state: dict[str, Any]
+) -> str:
+    """
+    Handle a new Journal event.
+
+    :param cmdr: Name of Commander.
+    :param is_beta: Whether game beta was detected.
+    :param system: Name of current tracked system.
+    :param station: Name of current tracked station location.
+    :param entry: The journal event.
+    :param state: `monitor.state`
+    :return: None if no error, else an error string.
+    """
+    this.on_foot = state['OnFoot']
+    this.system_address = state['SystemAddress']
+    this.system_name = state['SystemName']
+    this.system_population = state['SystemPopulation']
+    this.station_name = state['StationName']
+    this.station_marketid = state['MarketID']
+
+    # Only actually change URLs if we are current provider.
+    if config.get_str('system_provider') == 'spansh':
+        this.system_link['text'] = this.system_name
+        # Do *NOT* set 'url' here, as it's set to a function that will call
+        # through correctly.  We don't want a static string.
+        this.system_link.update_idletasks()
+
+    if config.get_str('station_provider') == 'spansh':
+        to_set: str = cast(str, this.station_name)
+        if not to_set:
+            if this.system_population is not None and this.system_population > 0:
+                to_set = STATION_UNDOCKED
+            else:
+                to_set = ''
+
+        this.station_link['text'] = to_set
+        # Do *NOT* set 'url' here, as it's set to a function that will call
+        # through correctly.  We don't want a static string.
+        this.station_link.update_idletasks()
+
+    return ''
+
+
+def cmdr_data(data: CAPIData, is_beta: bool) -> str | None:
+    """
+    Process new CAPI data.
+
+    :param data: The latest merged CAPI data.
+    :param is_beta: Whether game beta was detected.
+    :return: Optional error string.
+    """
+    # Always store initially, even if we're not the *current* system provider.
+    if not this.station_marketid and data['commander']['docked']:
+        this.station_marketid = data['lastStarport']['id']
+
+    # Only trust CAPI if these aren't yet set
+    if not this.system_name:
+        this.system_name = data['lastSystem']['name']
+    if not this.station_name and data['commander']['docked']:
+        this.station_name = data['lastStarport']['name']
+
+    # Override standard URL functions
+    if config.get_str('system_provider') == 'spansh':
+        this.system_link['text'] = this.system_name
+        # Do *NOT* set 'url' here, as it's set to a function that will call
+        # through correctly.  We don't want a static string.
+        this.system_link.update_idletasks()
+    if config.get_str('station_provider') == 'spansh':
+        if data['commander']['docked'] or this.on_foot and this.station_name:
+            this.station_link['text'] = this.station_name
+        elif data['lastStarport']['name'] and data['lastStarport']['name'] != "":
+            this.station_link['text'] = STATION_UNDOCKED
+        else:
+            this.station_link['text'] = ''
+        # Do *NOT* set 'url' here, as it's set to a function that will call
+        # through correctly.  We don't want a static string.
+        this.station_link.update_idletasks()
+
+    return ''
+
+
+def system_url(system_name: str) -> str:
+    """
+    Construct an appropriate spansh URL for the provided system.
+
+    :param system_name: Will be overridden with `this.system_address` if that
+      is set.
+    :return: The URL, empty if no data was available to construct it.
+    """
+    if this.system_address:
+        return requests.utils.requote_uri(f'https://www.spansh.co.uk/system/{this.system_address}')
+
+    if system_name:
+        return requests.utils.requote_uri(f'https://www.spansh.co.uk/search/{system_name}')
+
+    return ''
+
+
+def station_url(system_name: str, station_name: str) -> str:
+    """
+    Construct an appropriate spansh URL for a station.
+
+    Ignores `station_name` in favour of `this.station_marketid`.
+
+    :param system_name: Name of the system the station is in.
+    :param station_name: **NOT USED**
+    :return: The URL, empty if no data was available to construct it.
+    """
+    if this.station_marketid:
+        return requests.utils.requote_uri(f'https://www.spansh.co.uk/station/{this.station_marketid}')
+
+    if system_name:
+        return system_url(system_name)
+
+    return ''