Watch / download MP4 · Russian subtitles
90 seconds, Full HD, Russian narration. All domains, accounts and metrics are synthetic.
| All sites — multi-account, sparklines, Bing/IndexNow | Dashboard — site cards with a separate Today |
|---|---|
![]() |
![]() |
Local self-hosted multi-account hub for Google Search Console. Connect several Google accounts via OAuth, view all Search Console sites in a single table, aggregate queries and pages across accounts, see per-site dashboards with sparklines and period-over-period deltas, drill into 16-month query history with one click. Since 0.6.0 it also ships a full SEO analytics suite — per-site deep-dives (striking distance, cannibalization, CTR benchmark, content decay, branded split, site health) and a portfolio-wide view across all sites — plus Bing/IndexNow and optional multi-user login. Since 0.8.0: traffic charts annotated with Google updates and your own site events, a site-scoped machine API with a built-in MCP server for AI agents, and optional link buying straight from striking-distance tables. Since 0.9.0: saved URL exclusions and filters, sorting and remembered site preferences, purchases on a custom page path, weekly backlink monitoring with provider pulse history, and page/sitemap submissions through NeuralIndexer. The video walkthrough uses synthetic data. Search Analytics metrics stay on your machine, fetched live with a short in-memory cache. Optional link buying and indexing send selected queries or URLs to the configured provider; backlink checks request donor pages. SQLite stores tokens, settings and operational records described under Privacy.
The interface is Russian-first: navigation and table headers are English, many buttons and messages are Russian.
Built as a personal alternative to seogets-style SaaS tools when you have multiple Google accounts (personal, work, clients) and don't want to log in to each Search Console separately.
Status: working MVP, used daily on macOS. Ships with a Docker Compose + OrbStack deploy served at
https://gsc.local. Accounts are connected overhttp://localhost:5173because Google rejects OAuth redirects to.localdomains (see Deploying).
- Connect any number of Google accounts. Each click on Connect Google account runs a normal Google consent flow (
webmastersread-write scope,access_type=offline,prompt=consent). The read-write scope is required to submit sitemaps; if you only need read access, changesrc/auth.tsback towebmasters.readonly. - Tokens are stored in a local SQLite file (
./data/gsc-hub.db). Refresh tokens auto-rotate; access tokens are refreshed transparently 60 seconds before they expire. - Revoked tokens detected via the first 401 from Google: account is marked
revoked, UI prompts reconnect, no silent failures.
- All Search Console properties from all connected accounts, in one full-bleed table.
- Per-site live aggregate for the selected period: Clicks / Impressions / CTR / Avg Pos, plus a CSV export column (queries or pages, configurable period).
- A totals bar above the table sums Sites / Impressions / Clicks across all non-hidden sites for the selected period.
- URL-driven sort and filter:
?days=1|3|7|28|60&sort=clicks|impressions|ctr|position|site|account&dir=asc|desc. Bookmark, share, browser-back work as expected. - A "G" badge next to each site opens a Google
site:search for a quick manual indexation check. - Hide sites you don't care about. The list lives in SQLite, so it is the same in every browser and bulk actions skip hidden sites.
- Domain properties displayed as
example.com(thesc-domain:GSC prefix is stripped for display, link still goes tohttps://example.com/). - Click any site row to expand a quick URL Inspection report for its top 10 URLs (verdict, coverage, robots, last crawl, canonical mismatch). Uses Google's URL Inspection API; daily quota is 2000 per Google account. Results are cached for 12 hours in SQLite (key: account + site + url-set hash). Re-clicking the same site reuses the cache; Force refresh button bypasses it. The cache exists because URL Inspection has a hard 2000-call/day quota per Google account. Sites with no/low impressions in the current period fall back to URLs from their sitemap: gsc-hub asks Search Console which sitemaps the site submitted, downloads the first one (following sitemapindex if needed), and uses up to 10 page URLs from there. The homepage is always included.
- Submit sitemap button per site: resubmits every sitemap Search Console already knows for that property, falling back to a guessed
/sitemap.xmlif none are registered. Inline status (✓ N/ failure reason in the tooltip). - Submit all sitemaps button in the toolbar: fans out across all visible (non-hidden) sites in parallel, with progress and an aggregate result.
- Requires the read-write
webmastersscope (see Multi-account OAuth).
- Aggregated query-level rollup across all visible (non-hidden) sites for the selected period, broken down by query × page × country. Clicks, impressions, CTR (computed), Avg Pos (impression-weighted).
- Click the position cell to open the Google SERP for that query in the matching country.
- Click a row to expand a 16-month inline history chart for that exact query: red position line + blue impression bars, gridlines, axis range labels. Aggregated by day across visible sites only.
- Client-side column sort (Query / Clicks / Impressions / CTR / Avg Pos), independent from the sites-table sort.
- Same pattern as Top queries, but at page-URL granularity. Useful for finding the URL that pulled a sudden spike.
Click through to a single property for a full SEO deep-dive. Tabs:
- Striking Distance — queries ranking at positions 4–20 with real impressions: the fastest wins to page 1.
- Keyword Cannibalization — queries where several of your own URLs compete, with a clear winner/loser breakdown.
- CTR Benchmark — your actual click-through rate by position vs an industry-reference curve, plus the pages that under-perform it.
- Content Decay — pages losing clicks or impressions vs the previous period.
- Branded vs non-branded — traffic split, with editable brand terms per site.
- Site Health — SSL (expiry / issuer / grade), Google Safe Browsing, and Core Web Vitals (PageSpeed Insights, mobile). Run on demand, cached per site. Needs a Google API key (see Configuration); the tab stays hidden without one.
All tabs are computed live from Search Console; nothing is persisted beyond the editable brand terms and the health cache.
The same lenses across every non-hidden site at once, computed from a single query fan-out:
- Tabbed Striking / Cannibalization / CTR / Branded / Decay, all URL-addressable via
?tab=. - Country (Geo) filter and copy-queries-to-clipboard, both respecting the current filter.
- Per-decaying-page index status via the URL Inspection API, with a direct link into the owning Google account's inspection panel.
- Async streaming (the shell renders immediately) plus a short in-memory result cache, so re-opens and period switches are instant.
- The site page draws a full traffic chart (clicks, impressions, position) with Google ranking updates shaded in, taken from Google's public status feed.
- The Sites page opens with a portfolio pulse: all visible sites summed per day, on the same update bands.
- Site events are your own markers - domain merges (donor → site), migrations, anything worth seeing on the timeline. Add and remove them on the site page; they show up on the charts and dashboard cards.
/eventslists all of them in one table. Link purchases are included automatically from Magiclinks history: one purple marker per order and site, with the ordered quantity, provider and an order link. The marker uses the purchase date (UTC), not the placement date; historical orders appear without a migration.
- Bing performance data alongside GSC, with merged GSC/Bing keyword rollups.
- Submit sitemap to Bing and push URLs to IndexNow, plus an IndexNow-key indicator per site.
- Requires
BING_API_KEY(see Configuration).
- Create API keys on
/api. A key is shown once; only its sha256 is stored. Each key can be limited to a list of sites - everything else is invisible to it (404, not403). GET /api/v1/sites,/queries,/query-pages,/properties,/countries,/site-countriesgive a script or a SERP monitor the same numbers the UI shows.POST /api/v1/site-eventsrecords a domain merge.- Agent-facing documentation is served at
/api/v1/doc.md(with a key).
- A built-in MCP server at
/api/v1/mcp: Claude Code, Claude Desktop, Cursor or any Streamable-HTTP MCP client reads your Search Console data directly. Nine read-only tools - portfolio summary, per-site queries, pages for a query, striking distance, cannibalization, CTR benchmark, content decay, country split and domain-merge events. - Keys are scoped to sites. Create as many keys as you need on
/apiand tick which properties each one may see: an agent working on one pool, a contractor on a single site. Everything outside the scope is invisible - missing from lists,404when asked for by name. A key with nothing ticked sees all sites, which is how keys behaved before scopes existed. - The MCP surface never writes: no merges, no purchases, no edits, so an agent
talking MCP cannot change anything or spend money. The key itself is not
read-only, though: the same Bearer is accepted by the REST endpoint
POST /api/v1/site-events, which records a domain merge on a site in the key's scope. Hand out keys with that in mind. - Four agent skills ship with the hub (striking plan, cannibalization,
decay triage, portfolio review). They are served by the same instance as MCP
prompts -
/mcp__gsc-hub__gsc-striking-planin Claude Code - and as files over/api/v1/skills, so installing them needs a key and a URL, not a copy of this repository. See docs/MCP-SETUP.md.
claude mcp add --transport http gsc-hub https://your-hub.example.com/api/v1/mcp \
--header "Authorization: Bearer gsk_your_key"- Buy posts with links to pages that sit in striking distance, straight from the striking tables (portfolio and per site): tick rows, get a quote, pay. Two providers are supported, and the purchase window picks the one with the larger balance by default:
- FieldLink - two-step purchase (task → quote → order), the quoted amount is re-checked when the order is sent. Sign up (referral link), then create an API key in the dashboard.
- 369Team (
magiclinks.online) - one-step purchase, charged when the order is created; the price is re-checked right before. For an account and an API key, message @marketplace369_bot on Telegram.
- Homepage links (FieldLink). Choose “Ссылки с главных (морды)” in the purchase window, pick a traffic tier (1, 2 or 5 credits) and describe the required text subject. Donors are matched automatically. Links have no bonus. Confirm the actual quote before starting; CSV includes donor, placement text and subject when supplied. Post topics default to automatic, estimates use account prices. 369Team homepage links use a single account price from
/balance, with a 30% bonus per row rounded down. Choose the same homepage-link service; leave the surrounding text empty for a bare link, or provide plain text containing$LINKexactly once. Language and traffic tiers are not required. Orders, CSV exports and backlink checks use/link-orders. /magiclinksholds both keys, balances and every order with its provider, progress and (for FieldLink) search-indexing status; each order opens to its positions and published URLs, with a CSV export.- Service data is never cached: orders and statuses are read live. The hub keeps only the keys and a purchase trail (which query/URL pairs were already bought), so striking tables can mark them.
- Nothing here is on by default - without a key the feature stays out of the way.
- Disclosure: both integrations are affiliate ones. The FieldLink sign-up link is a referral link, and requests to 369Team carry the author's referral ID (
X-Referal-ID).
- Off by default — the tool stays single-user and loopback-only. Set
ADMIN_EMAIL/ADMIN_PASSWORDto turn on a login form, server sessions (argon2-hashed passwords), a user-management page, and roles (admin / manager) with per-owner account scoping. - Intended for when you expose the app beyond
127.0.0.1; secure cookies switch on automatically onceORIGINstarts withhttps://.
- A separate Today block: the current day in Search Console's timezone, still accumulating, deliberately not compared with anything.
- Grid of per-site cards: account label, site, sparkline of daily clicks for the current period, four metrics with deltas vs the previous period of the same length (e.g., last 7 days vs the 7 days before that). Green/red, also dual-encoded with
+/−so colour-blind users get the signal. - Configurable density: 2 / 4 / 6 columns via URL
?cols=. Same period filter as Sites. - Stable order across reloads (clicks desc, impressions tiebreaker, then alphabetical).
- Queries or pages, last N days (matches the period filter), sanitized filename. Streams
text/csv; charset=utf-8withContent-Disposition: attachment. Downloaded directly from/properties/export?account=...&site=...&days=...&dim=query|page.
- Sparklines of daily clicks on the site cards.
- Privacy Blur — one click blurs PII (emails, domains, metrics) for screenshots and screen-sharing.
- Refresh preserves all URL state (period, sort, dir, cols) via SvelteKit's
invalidateAll(). No<form method="POST">redirect dance. - All numbers in tables are tabular-nums for vertical alignment.
- Light hover affordance on rows. Sortable headers show ↑ / ↓.
- Short in-memory cache. Search Console responses are kept in process memory for up to 60 minutes, so page switches are fast and quota lasts; Refresh drops the cache, and a restart starts cold. Search Analytics responses never reach the database; the disk exceptions (URL Inspection cache, bought query + URL pairs) are listed under Privacy.
- Purchased backlinks are checked in a persistent background queue, weekly by default, once link-buying providers are configured. Disable automatic checks in MagicLinks settings or set
BACKLINK_AUTO_ENABLED=0; manual checks remain available. No email notifications.
Requirements: Node 22+, pnpm.
git clone https://github.com/izzipizzy/gsc-hub.git
cd gsc-hub
pnpm install
pnpm devOpen http://localhost:5173 — the setup wizard opens on first run and asks for your Google Client ID/Secret; AUTH_SECRET is generated automatically, no manual .env editing needed. (Copying .env.example to .env is optional, only needed for automated/CI setups — see Configuration.) Click Connect Google account, complete consent, repeat for each account you want to connect.
For a long-running local deploy, use Docker Compose instead — see Deploying with Docker Compose + OrbStack.
Connect accounts over
http://localhost:5173, nothttps://gsc.local. Google's OAuth policy rejects redirects to the.localTLD (Error 400: invalid_request). The Compose setup exposes a loopback port specifically so the consent flow can run on localhost; day-to-day you can still usehttps://gsc.local. The SQLite DB is shared, so a token obtained on localhost works ongsc.localtoo.
docker compose up -d --build- Open the app (
http://localhost:5173for connecting Google accounts). - The setup wizard opens automatically: paste your Google Client ID and
Client Secret (the page shows the exact redirect URI to register in GCP),
choose access mode (loopback-only or exposed-with-login), and save. No manual
.envediting —AUTH_SECRETis generated for you and stored in SQLite.
If the app will be reachable from other machines, set GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET, ADMIN_EMAIL and ADMIN_PASSWORD in the environment
before exposing it (or finish the wizard over loopback first, with login
enabled). An exposed instance without Google credentials, a login and an admin
answers 503 to everything - the anonymous wizard and single-user mode are
loopback-only. The bundled compose.yaml sets EXPOSED_MODE=0, which declares
the OrbStack network trusted and keeps single-user mode; drop it if that network
is not yours alone.
The detailed GCP OAuth walkthrough below is only needed to obtain the two values the wizard asks for. Everything under "Configuration" is optional / for automated deploys (env vars take precedence over wizard values).
- Open https://console.cloud.google.com/apis/credentials.
- Create credentials → OAuth client ID → Application type Web application, name
gsc-hub. - Authorized redirect URIs:
http://localhost:5173/auth/callback/google(dev)https://your-domain.example/auth/callback/google(only if you deploy)
- Enable the Search Console API in the same project: https://console.cloud.google.com/apis/library/searchconsole.googleapis.com.
- OAuth consent screen (now under Google Auth Platform → Audience): set User Type to External. Either Publish the app (any Google account can sign in) or keep it in Testing and add your Google emails as Test users.
- Copy the Client ID and Client Secret — paste them into the setup wizard when you first open the app.
AUTH_SECRETneeds no manual step; the wizard generates and stores it in SQLite.
The webmasters scope is a "sensitive scope" in Google's classification, but Google does not require formal verification for personal-tier usage (the OAuth user cap allows up to 100 consenting users for unverified sensitive scopes). You'll see an "unverified app" warning on the consent screen; click Advanced → Go to gsc-hub (unsafe) to proceed. Only register http://localhost:5173/auth/callback/google as the redirect URI — Google will not accept a .local redirect.
.env keys (see .env.example):
| key | required | description |
|---|---|---|
GOOGLE_CLIENT_ID |
yes | OAuth Web Application client ID |
GOOGLE_CLIENT_SECRET |
yes | OAuth Web Application client secret |
AUTH_SECRET |
yes | Random 32-byte base64 secret for Auth.js (openssl rand -base64 32) |
AUTH_TRUST_HOST |
recommended | true for self-hosted/proxy setups |
DB_PATH |
optional | Path to SQLite file. Defaults to ./data/gsc-hub.db |
PAGESPEED_KEY |
optional | Google API key for the Site Health tab (Core Web Vitals via PageSpeed Insights). Enable the PageSpeed Insights API. One Google API key can serve both Health checks. |
GOOGLE_SAFE_BROWSING_KEY |
optional | Google API key for the Site Health tab's Safe Browsing check. Enable the Safe Browsing API — the same key as PAGESPEED_KEY works. Without these two, the Health tab stays hidden; nothing else needs them. |
BING_API_KEY |
optional | Bing Webmaster API key — enables Bing data, "Submit to Bing", and IndexNow push. |
SERP_API_TOKEN |
optional | Shared token the SERP monitor presents to GET /api/v1/sites and GET /api/v1/queries. Keys created on /api are accepted as well; with neither, the machine API rejects every request - it is never open. |
SERP_MONITOR_URL |
optional | Base URL of the SERP monitor (e.g. https://serp.example.com). Enables the real-position column and the "check now" button on a site page. |
SERP_MONITOR_TOKEN |
optional | Token this hub presents to the SERP monitor. Without it the site page says the integration is off rather than claiming the site is not monitored. |
MAGICLINKS_API_TOKEN |
optional | FieldLink key for link buying. Usually entered on /magiclinks instead; the env var wins over the stored one. |
MAGIC369_API_TOKEN |
optional | 369Team key for link buying, same rules. Keys are issued via @marketplace369_bot. |
GSC_CONCURRENCY |
optional | Concurrent Search Console calls per fan-out. Defaults to 8. Guards the local socket pool — unbounded, a ~200-site account times out the whole batch. Lower it if you still see connect timeouts. |
GSC_INSPECT_CONCURRENCY |
optional | Concurrent URL Inspection calls. Defaults to 4 — lower on purpose, since inspection is capped by quota (2000/day and 600/min per property), not by sockets. |
ADMIN_EMAIL / ADMIN_PASSWORD |
optional | Set both to enable multi-user login/roles (seeds an admin on first start). Leave unset for the default single-user, loopback-only mode. |
EXPOSED_MODE |
optional | 1 forces the app to treat itself as reachable by other machines, 0 accepts the risk on a trusted network. Left unset it is inferred from the configured origin and the request host — only loopback counts as local. An exposed app with no login configured returns 503 rather than falling back to single-user local admin. |
UPDATE_CHECK |
optional | off disables the update check — the browser stops contacting api.github.com; the version stays in the footer. Enabled by default. |
ORIGIN |
optional | Public origin (e.g. https://your-domain.example). Enables secure cookies when it starts with https://. |
AUTH_URL |
optional | Public URL Auth.js uses to build the OAuth redirect; must match the GCP OAuth redirect base. |
[browser] ──┬─ https://gsc.local (OrbStack proxy, TLS) ──┐
└─ http://localhost:5173 (loopback, for OAuth)┴─> [SvelteKit (Node) :3000] ──> Google Search Console API
│
└──> ./data/gsc-hub.db (only OAuth tokens)
One Node process. One SQLite file. Tables are created and migrated at startup:
| table | holds |
|---|---|
google_accounts |
OAuth tokens of connected Google accounts |
users, sessions |
optional login (argon2 hashes, server sessions) |
app_config |
settings from the setup wizard and service keys (env wins over the DB) |
api_keys |
machine API keys: sha256 hash, prefix and site scope |
hidden_sites, query_filters |
what you hid: sites and junk query patterns |
site_branded_keywords, site_dates |
per-site brand terms and creation dates |
site_events |
your timeline markers (domain merges and the like) |
indexnow_keys |
per-site IndexNow keys |
url_inspection_cache, site_health |
caches of URL Inspection (12 h) and external health checks |
magiclinks_purchases |
purchase trail: which query + URL pairs were bought, from which provider |
No GSC analytics data is persisted. Every request to /properties, the per-site and portfolio analytics, /dashboard, the query-history endpoint or the CSV export goes to Google; the only thing between you and the API is the in-memory cache (up to 60 minutes, dropped by Refresh). The cost is page-load latency proportional to the number of active sites on a cold cache.
Auth.js v5 is wired with a custom signIn callback that, instead of creating an app session, upserts the Google profile + tokens into google_accounts keyed by profile.sub, then returns '/' to redirect cleanly without setting a session cookie. Connecting a Google account and logging in to the hub are separate things: the Google OAuth flow only adds a Search Console account, while the optional hub login (ADMIN_EMAIL/ADMIN_PASSWORD, users and roles) is a server session of its own. Without that login the app runs single-user and trusts loopback.
- SvelteKit (Svelte 5 runes) + TypeScript, Node adapter
- Auth.js (
@auth/sveltekit) for the Google OAuth dance - better-sqlite3 for the local token store
- TailwindCSS v3 for styles (no custom palette extension; tokens documented in DESIGN.md)
- Vitest for unit tests
No chart libraries: every sparkline and the query-history chart are hand-rolled SVG (<rect> + <path>). No icon fonts: every icon is a 14×14 inline SVG.
gsc-hub/
├── PRODUCT.md — strategic context (users, principles, anti-references)
├── DESIGN.md — visual system (colors, typography, components, rules)
├── Dockerfile — multi-stage production image (Node runtime)
├── compose.yaml — Docker Compose: OrbStack domain + loopback port 5173
├── src/
│ ├── auth.ts — SvelteKitAuth config + custom signIn callback
│ ├── hooks.server.ts
│ ├── app.css — Tailwind + Operator Console utilities
│ ├── lib/
│ │ ├── server/
│ │ │ ├── db.ts — SQLite open + migration
│ │ │ ├── accounts.ts — CRUD over google_accounts (only file with SQL for that table)
│ │ │ ├── google.ts — GSC client, refresh, fan-out, search analytics
│ │ │ ├── gsc-cache.ts — 60-minute in-memory cache of GSC responses
│ │ │ ├── gsc-calendar.ts — dates the way Search Console means them (Pacific time, completed days)
│ │ │ ├── analytics.ts — pure SEO analytics (striking / cannibalization / CTR benchmark / branded split / decay)
│ │ │ ├── algo-updates.ts — Google ranking updates for chart bands
│ │ │ ├── site-events.ts — your timeline markers (domain merges)
│ │ │ ├── health.ts — Site Health (SSL / Safe Browsing / Core Web Vitals)
│ │ │ ├── bing.ts, indexnow.ts — Bing Webmaster client, IndexNow submit + keys
│ │ │ ├── api-keys.ts, api-token.ts — machine API keys and the per-key site scope
│ │ │ ├── mcp.ts — MCP server (JSON-RPC over HTTP, read-only tools)
│ │ │ ├── magiclinks.ts, magic369.ts — link-buying clients (FieldLink, 369Team)
│ │ │ ├── guard.ts, auth-session.ts — access guard, optional login and sessions
│ │ │ └── csv.ts — RFC 4180 CSV writer
│ │ └── utils/ — shared helpers (site/country/language formatting)
│ └── routes/
│ ├── +page.svelte — Accounts list (/)
│ ├── setup/ — first-run setup wizard
│ ├── dashboard/ — site cards + Today
│ ├── properties/ — Sites table, per-site analytics ([site]/), portfolio (striking/), exports, sitemaps, IndexNow
│ ├── events/ — all site events in one table
│ ├── api/ — machine API keys page; api/v1/* — machine API and MCP
│ ├── magiclinks/ — link-buying keys, orders, positions
│ └── admin/, login/, logout/ — optional multi-user login
├── tests/ — Vitest, mocks fetch/env
└── data/gsc-hub.db — gitignored, created on first run
| command | description |
|---|---|
pnpm dev |
Dev server on http://localhost:5173 |
pnpm build |
Production build (Node target) |
pnpm preview |
Preview the production build |
pnpm test |
Run Vitest |
pnpm test:watch |
Watch mode |
pnpm check |
svelte-check type-check |
docker compose up -d --build |
Build and run the production container (OrbStack) |
docker compose logs -f gsc |
Follow container logs |
docker compose down |
Stop and remove the container (data in ./data persists) |
pnpm test runs the Vitest suite (550+ tests): SQLite migrations, the GSC client (token refresh, revocation, fan-out, date windows in Search Console's timezone), the pure SEO analytics, CSV and the export, auth and the access guard, the machine API and MCP tools (including a check that MCP stays read-only), both link-buying clients and the release/publish scripts. Routes are covered through their server modules, the Google and vendor APIs through a mocked fetch.
- Search Analytics responses (queries, clicks, impressions, positions) are never written to disk: they live in process memory for up to 60 minutes and vanish on restart.
- Two things from Search Console do reach SQLite: URL Inspection results (cached for 12 hours to spare the daily quota) and, if you buy links, the query + URL pairs you bought (so striking tables can mark them). A purchase also sends those queries and URLs to the provider you chose.
- SQLite also stores OAuth tokens and settings, saved URL exclusions and site preferences, purchased placements and check results/history, queued check jobs and pulse snapshots, cached provider responses, and indexing requests (URL lists, quotes, results and charges). Provider cache lifetimes depend on the operation: balances 1 minute, lists/statuses 5 minutes, articles 1 hour and final orders 6 hours; failures retry after 1 minute while retaining the last good response. The SQLite file lives in
./data/(gitignored); delete it to wipe everything. - Outbound calls go to Google APIs and configured services: Bing Webmaster/IndexNow (
BING_API_KEY), the SERP monitor (SERP_MONITOR_URL), link-buying providers and NeuralIndexer (inderixingbot.com). Explicit indexing calculations request your balance; paid submissions send the selected page URLs. Sitemap calculations download the specified maps. Purchased-link checks request donor pages using a Googlebot user agent, directly or through the optional SOCKS proxy. Without a proxy, donor sites see the server's IP; changing the user agent does not hide it. Weekly checks are enabled by default and can be disabled withBACKLINK_AUTO_ENABLED=0. The browser checks GitHub Releases unlessUPDATE_CHECK=off. No telemetry. - Tokens and service keys are stored in plaintext. That is acceptable for a local single-user tool; encrypt at rest if you ever expose this beyond
127.0.0.1. Machine API keys are the exception: only their sha256 is stored.
The repo ships a Dockerfile (multi-stage, Node runtime) and a compose.yaml tuned for OrbStack on macOS:
docker compose up -d --build # build and start
docker compose logs -f gsc # follow logs
docker compose restart gsc # restart
docker compose down # stop (data in ./data persists)- Served at
https://gsc.localvia the OrbStack proxy (automatic TLS) — set by thedev.orbstack.domainslabel. Also reachable athttps://gsc.orb.local(auto domain by container name). restart: unless-stoppedbrings the container back after a reboot (OrbStack starts on login).- The container also binds
127.0.0.1:5173 → 3000(loopback only). This exists solely so the OAuth flow can run onhttp://localhost:5173— Google rejects.localredirects. Use this URL to connect accounts; usehttps://gsc.localfor everyday work. .envis read viaenv_file; secrets are injected at runtime through$env/dynamic/private, not baked into the image../datais bind-mounted, so the SQLite token store survives rebuilds and is shared withpnpm dev.
Security note: by default the app runs single-user with no login, which is why the port is bound to 127.0.0.1 only - the DB holds Google OAuth tokens. To put it on a network, turn on the built-in login (ADMIN_EMAIL/ADMIN_PASSWORD, see above); an exposed instance refuses to serve without it. A proxy in front (e.g. Cloudflare Access) is a good extra layer, not a replacement for the login. Add the production callback URL to your Google OAuth client.
The footer prints the version this instance is running — v0.9.0, linked to the
release notes for that tag. On a dev host the working tree's commit is shown
beside it (v0.9.0 · <commit>); inside a container there is no git, so only the
tag appears.
Once every 12 hours the browser asks the GitHub Releases API for the newest
release. If it is newer than the running one, a banner appears above the header
for signed-in users: Доступна v0.9.0 — что нового (the UI says "v0.9.0 is available - what's new" in Russian), linked to the release
notes and dismissible per version (the next release shows up again). Nothing is
checked server-side, nothing is stored in the database, and the answer is cached
in localStorage — so the 12-hour window is per browser, not per instance.
Set UPDATE_CHECK=off to stop the check entirely; the footer keeps showing the
version.
git pull
docker compose up -d --buildYour data survives: the SQLite file lives in the bind-mounted ./data (or a
named volume), not in the image. Schema migrations run automatically at start.
git pull
pnpm install # only needed when dependencies changed
pnpm dev # or: pnpm build && HOST=127.0.0.1 ORIGIN=http://localhost:3000 node build/index.jsgit checkout v0.7.1
docker compose up -d --buildRolling back the code is safe; rolling back across a schema migration is not
— the migration only moves forward. Copy data/gsc-hub.db aside before a major
downgrade.
Deterministic sequence, no interactive steps. Run from the repository root.
# 1. What is running now (container has no git — read the manifest)
docker compose exec -T gsc sh -c 'grep -m1 "\"version\"" /app/package.json'
# 2. What is the newest release
curl -s https://api.github.com/repos/izzipizzy/gsc-hub/releases/latest | grep -m1 '"tag_name"'
# 3. Update if they differ
git pull --ff-only
docker compose up -d --build
# 4. Verify: the tag in the footer must match the tag you pulled
curl -sL http://localhost:5173/ | grep -o 'releases/tag/v[0-9.]*' | head -1 # -L: the app 303s to /login
docker compose logs --tail 30 gscExpected end state: step 4 prints releases/tag/<new version> and the logs end
with Listening on http://0.0.0.0:3000 and no stack trace. If git pull --ff-only fails, the checkout has local commits — stop and report rather than
merging or rebasing. If the build fails, the previous container keeps running;
nothing has been lost.
Versions are plain vMAJOR.MINOR.PATCH tags. The comparison the app itself uses
is numeric per component, and anything that does not parse into exactly three
numbers (a pre-release, a dev build) is treated as "not newer" — apply the same
rule if you automate the decision.
This works only in the maintainer's private source checkout: the publish configuration (.publish.conf, .publicignore) is deliberately not part of the public tree, so the commands below fail in a public clone.
pnpm release 0.8.0 # bumps package.json and commits — no tag, pushes nothing
# add the 0.8.0 sections to CHANGELOG.md / CHANGELOG.ru.md, commit them
git push origin main
pnpm release-publish --dry-run 0.8.0 # preview only, writes nothing
pnpm release-publish 0.8.0release-publish builds the public tree, commits it as a child of the public
tip, tags it, pushes to GitHub, and creates the release. If the tree carries
a public path that was never published before, it prints the list and a
confirmation hash and asks you to retype the hash before it pushes
anything — that prompt is the only thing standing between a private file and
a public push, so read the list. Never set PUBLISH_CONFIRM=auto for a
real release; it exists only for the test suite and skips the check with
just a warning on stderr.
The footer reads its version from package.json, so the bump is what the app
reports about itself. The version tag is created when the release is published,
on the public release commit — and the update banner fires for other people only
once a GitHub release exists for that tag; a pushed tag alone is not enough.
The SEO analytics suite and Bing/IndexNow shipped in 0.6.0; charts with Google-update bands, site events, the machine API with MCP and link buying in 0.8.0. Still on the list:
- Daily background pull of aggregates into Postgres for trends and period comparisons that span weeks/months without re-querying GSC each time.
- Sitemap change monitoring.
- Alerts on traffic drops.
Analytics stay live-fetched with only the short in-memory cache; a persistent store will come only when usage shows it's needed.
MIT. See LICENSE.
Configure the shared NeuralIndexer API key under Indexers. The Indexing button on a domain opens page/path or XML sitemap submission, including child maps. The quote shows unique URLs, queue, estimated cost and balance before a paid submission. Quotes do not submit pages; request history and actual charges are stored per domain.
API v2: Inderixing documentation.
Retries reuse the same external_id to avoid duplicate charges. Service
acceptance does not confirm Google indexing. Split maps exceeding 50,000 URLs
or 200 sitemap files; failed children never produce a partial quote. URLs must
use the selected domain host (including its www variant); other subdomains
are rejected even for sc-domain: properties. The token may alternatively be
set with NEURALINDEXER_API_TOKEN; it is never returned to the browser.





