From 0c9cb7bfa24516087143657c1eb6598eff568481 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 21 Jul 2026 04:22:27 +0700 Subject: [PATCH 01/10] Define IEC 61850 search authority graph --- landing/search-authority.json | 78 +++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 landing/search-authority.json diff --git a/landing/search-authority.json b/landing/search-authority.json new file mode 100644 index 000000000..edad7ed26 --- /dev/null +++ b/landing/search-authority.json @@ -0,0 +1,78 @@ +{ + "schemaVersion": 1, + "clusters": { + "connection": {"label": {"en": "MMS connection and live model", "id": "Koneksi MMS dan live model"}}, + "reporting": {"label": {"en": "Reporting, RCB and DataSet evidence", "id": "Evidence reporting, RCB dan DataSet"}}, + "file-transfer": {"label": {"en": "MMS file service and COMTRADE", "id": "MMS file service dan COMTRADE"}}, + "goose": {"label": {"en": "GOOSE sequence and network evidence", "id": "Sequence dan evidence network GOOSE"}}, + "scl": {"label": {"en": "SCL intent and live configuration", "id": "Intent SCL dan konfigurasi live"}}, + "control": {"label": {"en": "IEC 61850 control evidence", "id": "Evidence control IEC 61850"}}, + "commissioning": {"label": {"en": "FAT, SAT and commissioning workflow", "id": "Workflow FAT, SAT dan commissioning"}} + }, + "nodes": { + "mms-client.html": {"cluster": "connection", "role": "capability", "label": {"en": "IEC 61850 MMS Client", "id": "MMS Client IEC 61850"}, "summary": {"en": "Association, live model, DataSet, RCB and control-object evidence.", "id": "Evidence association, live model, DataSet, RCB dan control object."}, "primaryQuery": "IEC 61850 MMS client"}, + "mms-client-iec61850.html": {"cluster": "connection", "role": "capability", "label": {"en": "IEC 61850 MMS Client in Indonesian", "id": "MMS Client IEC 61850"}, "summary": {"en": "Indonesian workflow for association and live-model evidence.", "id": "Workflow association dan evidence live model dalam Bahasa Indonesia."}, "primaryQuery": "MMS client IEC 61850"}, + "smart-reporting.html": {"cluster": "reporting", "role": "capability", "label": {"en": "IEC 61850 Smart Reporting", "id": "Smart Reporting IEC 61850"}, "summary": {"en": "BRCB, URCB, exact DataSet coverage and bounded polling fallback.", "id": "BRCB, URCB, exact coverage DataSet dan polling fallback terkontrol."}, "primaryQuery": "IEC 61850 reporting BRCB URCB"}, + "smart-reporting-iec61850.html": {"cluster": "reporting", "role": "capability", "label": {"en": "Smart Reporting in Indonesian", "id": "Smart Reporting IEC 61850"}, "summary": {"en": "Indonesian reporting workflow and fallback evidence.", "id": "Workflow reporting dan evidence fallback dalam Bahasa Indonesia."}, "primaryQuery": "reporting IEC 61850 BRCB URCB"}, + "goose-analyzer.html": {"cluster": "goose", "role": "capability", "label": {"en": "IEC 61850 GOOSE Analyzer", "id": "Analyzer GOOSE IEC 61850"}, "summary": {"en": "APPID, VLAN, stNum, sqNum, TAL and payload continuity.", "id": "APPID, VLAN, stNum, sqNum, TAL dan continuity payload."}, "primaryQuery": "IEC 61850 GOOSE analyzer"}, + "analyzer-goose-iec61850.html": {"cluster": "goose", "role": "capability", "label": {"en": "GOOSE Analyzer in Indonesian", "id": "Analyzer GOOSE IEC 61850"}, "summary": {"en": "Indonesian packet and sequence evidence workflow.", "id": "Workflow evidence packet dan sequence dalam Bahasa Indonesia."}, "primaryQuery": "analyzer GOOSE IEC 61850"}, + "file-transfer.html": {"cluster": "file-transfer", "role": "capability", "label": {"en": "IEC 61850 File Transfer", "id": "Transfer File IEC 61850"}, "summary": {"en": "MMS file service, remote directories and COMTRADE record sets.", "id": "MMS file service, remote directory dan record set COMTRADE."}, "primaryQuery": "IEC 61850 file transfer COMTRADE"}, + "transfer-file-comtrade-iec61850.html": {"cluster": "file-transfer", "role": "capability", "label": {"en": "COMTRADE File Transfer in Indonesian", "id": "Transfer File COMTRADE IEC 61850"}, "summary": {"en": "Indonesian file-service and record-set workflow.", "id": "Workflow file service dan record set dalam Bahasa Indonesia."}, "primaryQuery": "download COMTRADE IEC 61850"}, + "scl-workspace.html": {"cluster": "scl", "role": "capability", "label": {"en": "IEC 61850 SCL Workspace", "id": "Workspace SCL IEC 61850"}, "summary": {"en": "Compare configured SCL intent with live IED evidence.", "id": "Bandingkan intent SCL dengan evidence live dari IED."}, "primaryQuery": "IEC 61850 SCL viewer workspace"}, + "workspace-scl-iec61850.html": {"cluster": "scl", "role": "capability", "label": {"en": "SCL Workspace in Indonesian", "id": "Workspace SCL IEC 61850"}, "summary": {"en": "Indonesian configured-intent and live-evidence workflow.", "id": "Workflow configured intent dan live evidence dalam Bahasa Indonesia."}, "primaryQuery": "workspace SCL IEC 61850"}, + "control.html": {"cluster": "control", "role": "capability", "label": {"en": "IEC 61850 Control Testing", "id": "Pengujian Control IEC 61850"}, "summary": {"en": "Direct, SBO, typed values, origin, checks and final termination.", "id": "Direct, SBO, typed value, origin, check dan final termination."}, "primaryQuery": "IEC 61850 direct SBO control testing"}, + "multi-ied-monitoring.html": {"cluster": "reporting", "role": "capability", "label": {"en": "Multi-IED Monitoring", "id": "Monitoring Multi-IED"}, "summary": {"en": "Independent associations with attributable live-value evidence.", "id": "Association independen dengan evidence live value yang tetap attributable."}, "primaryQuery": "IEC 61850 multi IED monitoring"}, + "fat-testing.html": {"cluster": "commissioning", "role": "solution", "label": {"en": "IEC 61850 FAT Testing", "id": "Pengujian FAT IEC 61850"}, "summary": {"en": "Repeatable factory evidence across MMS, reports, GOOSE and control.", "id": "Evidence factory yang repeatable untuk MMS, report, GOOSE dan control."}, "primaryQuery": "IEC 61850 FAT testing"}, + "pengujian-fat-iec61850.html": {"cluster": "commissioning", "role": "solution", "label": {"en": "IEC 61850 FAT in Indonesian", "id": "Pengujian FAT IEC 61850"}, "summary": {"en": "Indonesian FAT evidence workflow.", "id": "Workflow evidence FAT dalam Bahasa Indonesia."}, "primaryQuery": "pengujian FAT IEC 61850"}, + "sat-testing.html": {"cluster": "commissioning", "role": "solution", "label": {"en": "IEC 61850 SAT Testing", "id": "Pengujian SAT IEC 61850"}, "summary": {"en": "Site evidence for network, reports, GOOSE and control paths.", "id": "Evidence site untuk network, report, GOOSE dan control path."}, "primaryQuery": "IEC 61850 SAT testing"}, + "pengujian-sat-iec61850.html": {"cluster": "commissioning", "role": "solution", "label": {"en": "IEC 61850 SAT in Indonesian", "id": "Pengujian SAT IEC 61850"}, "summary": {"en": "Indonesian SAT evidence workflow.", "id": "Workflow evidence SAT dalam Bahasa Indonesia."}, "primaryQuery": "pengujian SAT IEC 61850"}, + "commissioning.html": {"cluster": "commissioning", "role": "solution", "label": {"en": "IEC 61850 Commissioning", "id": "Commissioning IEC 61850"}, "summary": {"en": "Traceable commissioning evidence from connection to final operation.", "id": "Evidence commissioning yang traceable dari koneksi sampai final operation."}, "primaryQuery": "IEC 61850 commissioning tool"}, + "commissioning-iec61850.html": {"cluster": "commissioning", "role": "solution", "label": {"en": "IEC 61850 Commissioning in Indonesian", "id": "Commissioning IEC 61850"}, "summary": {"en": "Indonesian commissioning evidence workflow.", "id": "Workflow evidence commissioning dalam Bahasa Indonesia."}, "primaryQuery": "commissioning IEC 61850"}, + "multi-vendor-integration.html": {"cluster": "scl", "role": "solution", "label": {"en": "Multi-vendor IEC 61850 Integration", "id": "Integrasi Multi-vendor IEC 61850"}, "summary": {"en": "Separate configured intent, live behavior and vendor-specific boundaries.", "id": "Pisahkan configured intent, behavior live dan batas vendor-specific."}, "primaryQuery": "multi vendor IEC 61850 integration"}, + "integrasi-multi-vendor-iec61850.html": {"cluster": "scl", "role": "solution", "label": {"en": "Multi-vendor Integration in Indonesian", "id": "Integrasi Multi-vendor IEC 61850"}, "summary": {"en": "Indonesian multi-vendor evidence workflow.", "id": "Workflow evidence multi-vendor dalam Bahasa Indonesia."}, "primaryQuery": "integrasi multi vendor IEC 61850"}, + "reporting-silent.html": {"cluster": "reporting", "role": "guide", "label": {"en": "Reporting is silent", "id": "Reporting tidak masuk"}, "summary": {"en": "Separate association, reads, DataSet coverage, RCB enablement and delivery evidence.", "id": "Pisahkan association, read, coverage DataSet, enablement RCB dan evidence delivery."}, "primaryQuery": "IEC 61850 reporting not working"}, + "brcb-vs-urcb.html": {"cluster": "reporting", "role": "guide", "label": {"en": "BRCB versus URCB", "id": "BRCB dibanding URCB"}, "summary": {"en": "Choose and test buffered or unbuffered reporting without assuming continuity.", "id": "Pilih dan uji buffered atau unbuffered reporting tanpa mengasumsikan continuity."}, "primaryQuery": "BRCB vs URCB"}, + "rcb-reserved.html": {"cluster": "reporting", "role": "guide", "label": {"en": "RCB reserved or occupied", "id": "RCB reserved atau occupied"}, "summary": {"en": "Inspect ownership and reservation without forcing takeover.", "id": "Periksa ownership dan reservation tanpa forced takeover."}, "primaryQuery": "IEC 61850 RCB reserved"}, + "empty-dataset.html": {"cluster": "reporting", "role": "guide", "label": {"en": "Empty report DataSet", "id": "DataSet report kosong"}, "summary": {"en": "Confirm references, member lists, permissions and fallback evidence.", "id": "Konfirmasi reference, member list, permission dan evidence fallback."}, "primaryQuery": "IEC 61850 empty dataset"}, + "port-102-connection-failed.html": {"cluster": "connection", "role": "guide", "label": {"en": "Port 102 connection failed", "id": "Koneksi port 102 gagal"}, "summary": {"en": "Separate routing, ping, TCP 102, firewall, association and application evidence.", "id": "Pisahkan routing, ping, TCP 102, firewall, association dan evidence aplikasi."}, "primaryQuery": "IEC 61850 port 102 connection failed"}, + "comtrade-download.html": {"cluster": "file-transfer", "role": "guide", "label": {"en": "COMTRADE download troubleshooting", "id": "Troubleshooting download COMTRADE"}, "summary": {"en": "Distinguish file-service support, transfer success and complete record sets.", "id": "Bedakan dukungan file service, transfer berhasil dan record set lengkap."}, "primaryQuery": "IEC 61850 COMTRADE download"}, + "goose-sequence.html": {"cluster": "goose", "role": "guide", "label": {"en": "GOOSE stNum and sqNum", "id": "GOOSE stNum dan sqNum"}, "summary": {"en": "Interpret state changes, retransmissions, TAL and capture continuity together.", "id": "Interpretasikan state change, retransmission, TAL dan capture continuity bersama-sama."}, "primaryQuery": "GOOSE stNum sqNum"}, + "cid-rejected.html": {"cluster": "scl", "role": "guide", "label": {"en": "CID or SCL rejected", "id": "CID atau SCL ditolak"}, "summary": {"en": "Inspect XML, namespace, edition, references and vendor requirements.", "id": "Periksa XML, namespace, edition, reference dan kebutuhan vendor."}, "primaryQuery": "IEC 61850 CID rejected"}, + "live-model-vs-scl.html": {"cluster": "scl", "role": "guide", "label": {"en": "Live model versus SCL", "id": "Live model dibanding SCL"}, "summary": {"en": "Use SCL as configured intent and the online model as current server evidence.", "id": "Gunakan SCL sebagai configured intent dan model online sebagai evidence server aktif."}, "primaryQuery": "IEC 61850 live model vs SCL"}, + "direct-vs-sbo.html": {"cluster": "control", "role": "guide", "label": {"en": "Direct versus SBO control", "id": "Control Direct dibanding SBO"}, "summary": {"en": "Confirm the control model and service sequence before operating.", "id": "Konfirmasi control model dan urutan service sebelum operate."}, "primaryQuery": "IEC 61850 direct vs SBO"}, + "commandtermination-addcause.html": {"cluster": "control", "role": "guide", "label": {"en": "CommandTermination and AddCause", "id": "CommandTermination dan AddCause"}, "summary": {"en": "Separate immediate acceptance from final process completion evidence.", "id": "Pisahkan immediate acceptance dari evidence final process completion."}, "primaryQuery": "IEC 61850 CommandTermination AddCause"} + }, + "pages": { + "mms-client.html": ["port-102-connection-failed.html", "live-model-vs-scl.html", "reporting-silent.html"], + "mms-client-iec61850.html": ["port-102-connection-failed.html", "live-model-vs-scl.html", "smart-reporting-iec61850.html"], + "smart-reporting.html": ["reporting-silent.html", "brcb-vs-urcb.html", "rcb-reserved.html", "empty-dataset.html"], + "smart-reporting-iec61850.html": ["reporting-silent.html", "brcb-vs-urcb.html", "rcb-reserved.html", "empty-dataset.html"], + "goose-analyzer.html": ["goose-sequence.html", "fat-testing.html", "sat-testing.html"], + "analyzer-goose-iec61850.html": ["goose-sequence.html", "pengujian-fat-iec61850.html", "pengujian-sat-iec61850.html"], + "file-transfer.html": ["comtrade-download.html", "port-102-connection-failed.html", "commissioning.html"], + "transfer-file-comtrade-iec61850.html": ["comtrade-download.html", "port-102-connection-failed.html", "commissioning-iec61850.html"], + "scl-workspace.html": ["cid-rejected.html", "live-model-vs-scl.html", "multi-vendor-integration.html"], + "workspace-scl-iec61850.html": ["cid-rejected.html", "live-model-vs-scl.html", "integrasi-multi-vendor-iec61850.html"], + "control.html": ["direct-vs-sbo.html", "commandtermination-addcause.html", "commissioning.html"], + "multi-ied-monitoring.html": ["reporting-silent.html", "port-102-connection-failed.html", "smart-reporting.html"], + "fat-testing.html": ["goose-sequence.html", "live-model-vs-scl.html", "direct-vs-sbo.html"], + "pengujian-fat-iec61850.html": ["goose-sequence.html", "live-model-vs-scl.html", "direct-vs-sbo.html"], + "sat-testing.html": ["port-102-connection-failed.html", "goose-sequence.html", "commandtermination-addcause.html"], + "pengujian-sat-iec61850.html": ["port-102-connection-failed.html", "goose-sequence.html", "commandtermination-addcause.html"], + "commissioning.html": ["port-102-connection-failed.html", "comtrade-download.html", "reporting-silent.html", "commandtermination-addcause.html"], + "commissioning-iec61850.html": ["port-102-connection-failed.html", "comtrade-download.html", "reporting-silent.html", "commandtermination-addcause.html"], + "multi-vendor-integration.html": ["cid-rejected.html", "live-model-vs-scl.html", "brcb-vs-urcb.html"], + "integrasi-multi-vendor-iec61850.html": ["cid-rejected.html", "live-model-vs-scl.html", "brcb-vs-urcb.html"], + "reporting-silent.html": ["smart-reporting.html", "rcb-reserved.html", "empty-dataset.html"], + "brcb-vs-urcb.html": ["smart-reporting.html", "rcb-reserved.html", "reporting-silent.html"], + "rcb-reserved.html": ["smart-reporting.html", "brcb-vs-urcb.html", "reporting-silent.html"], + "empty-dataset.html": ["smart-reporting.html", "reporting-silent.html", "live-model-vs-scl.html"], + "port-102-connection-failed.html": ["mms-client.html", "file-transfer.html", "commissioning.html"], + "comtrade-download.html": ["file-transfer.html", "port-102-connection-failed.html", "commissioning.html"], + "goose-sequence.html": ["goose-analyzer.html", "fat-testing.html", "sat-testing.html"], + "cid-rejected.html": ["scl-workspace.html", "live-model-vs-scl.html", "multi-vendor-integration.html"], + "live-model-vs-scl.html": ["scl-workspace.html", "cid-rejected.html", "multi-vendor-integration.html"], + "direct-vs-sbo.html": ["control.html", "commandtermination-addcause.html", "fat-testing.html"], + "commandtermination-addcause.html": ["control.html", "direct-vs-sbo.html", "sat-testing.html"] + } +} From 56789f3cb91e96eeabb29ccb74dd148c7f433099 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 21 Jul 2026 04:22:50 +0700 Subject: [PATCH 02/10] Mark English download CTA insertion point --- landing/partials/download-cta.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/landing/partials/download-cta.html b/landing/partials/download-cta.html index 89181a4ba..c14481415 100644 --- a/landing/partials/download-cta.html +++ b/landing/partials/download-cta.html @@ -1,4 +1,4 @@ -
+
From d6d0237480e69f029abbef7e5628c96f65b73ee2 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 21 Jul 2026 04:23:01 +0700 Subject: [PATCH 03/10] Mark Indonesian download CTA insertion point --- landing/partials/download-cta-id.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/landing/partials/download-cta-id.html b/landing/partials/download-cta-id.html index b100ed048..5b2e7c5f2 100644 --- a/landing/partials/download-cta-id.html +++ b/landing/partials/download-cta-id.html @@ -1 +1 @@ -
Release stabil terverifikasi

Unduh ARSAS {{STABLE_VERSION}} untuk Windows.

Gunakan installer untuk workstation normal, pilih portable ZIP untuk deployment berbasis folder yang terkontrol, dan verifikasi SHA-256 sebelum digunakan.

Yang baru, batasan dan status signing →
+
Release stabil terverifikasi

Unduh ARSAS {{STABLE_VERSION}} untuk Windows.

Gunakan installer untuk workstation normal, pilih portable ZIP untuk deployment berbasis folder yang terkontrol, dan verifikasi SHA-256 sebelum digunakan.

Yang baru, batasan dan status signing →
From a1507281cc504547f4c42df30f699d5528efdfec Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 21 Jul 2026 04:23:49 +0700 Subject: [PATCH 04/10] Generate contextual IEC 61850 authority links --- scripts/apply-search-authority.py | 247 ++++++++++++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 scripts/apply-search-authority.py diff --git a/scripts/apply-search-authority.py b/scripts/apply-search-authority.py new file mode 100644 index 000000000..5b45ac6e3 --- /dev/null +++ b/scripts/apply-search-authority.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +"""Validate and apply the ARSAS contextual search-authority graph to a built site.""" + +from __future__ import annotations + +import argparse +import json +import sys +from collections import defaultdict +from html import escape +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_GRAPH = ROOT / "landing" / "search-authority.json" +GUIDE_ROLES = {"guide"} +DISCOVERY_ROLES = {"capability", "solution"} + + +def load_object(path: Path, label: str) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise SystemExit(f"Cannot read {label}: {exc}") from exc + if not isinstance(value, dict): + raise SystemExit(f"{label} must contain a JSON object") + return value + + +def registry(site: Path) -> dict[str, dict[str, Any]]: + config = load_object(site / "site.json", "built site registry") + pages = config.get("pages") + if not isinstance(pages, list): + raise SystemExit("Built site registry has no pages list") + result: dict[str, dict[str, Any]] = {} + for item in pages: + if not isinstance(item, dict) or not isinstance(item.get("path"), str): + raise SystemExit("Built site registry contains an invalid page entry") + path = item["path"] or "index.html" + result[path] = item + return result + + +def text(value: Any, label: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise SystemExit(f"Search authority graph has invalid {label}") + return value.strip() + + +def localized(node: dict[str, Any], key: str, language: str) -> str: + values = node.get(key) + if not isinstance(values, dict): + raise SystemExit(f"Search authority node is missing {key}") + return text(values.get(language) or values.get("en"), f"{key}.{language}") + + +def validate_graph(graph: dict[str, Any], entries: dict[str, dict[str, Any]]) -> tuple[dict[str, dict[str, Any]], dict[str, list[str]], dict[str, int]]: + if graph.get("schemaVersion") != 1: + raise SystemExit("Search authority graph schemaVersion must be 1") + clusters = graph.get("clusters") + nodes = graph.get("nodes") + pages = graph.get("pages") + if not isinstance(clusters, dict) or not clusters: + raise SystemExit("Search authority graph must define clusters") + if not isinstance(nodes, dict) or not nodes: + raise SystemExit("Search authority graph must define nodes") + if not isinstance(pages, dict) or not pages: + raise SystemExit("Search authority graph must define page relationships") + + indexed = {path for path, item in entries.items() if item.get("index", True) is not False} + for cluster, value in clusters.items(): + text(cluster, "cluster key") + if not isinstance(value, dict): + raise SystemExit(f"Search authority cluster {cluster} must be an object") + labels = value.get("label") + if not isinstance(labels, dict): + raise SystemExit(f"Search authority cluster {cluster} is missing labels") + text(labels.get("en"), f"clusters.{cluster}.label.en") + text(labels.get("id"), f"clusters.{cluster}.label.id") + + normalized_nodes: dict[str, dict[str, Any]] = {} + for path, node in nodes.items(): + if path not in indexed: + raise SystemExit(f"Search authority node is not an indexable page: {path}") + if not isinstance(node, dict): + raise SystemExit(f"Search authority node {path} must be an object") + cluster = text(node.get("cluster"), f"nodes.{path}.cluster") + if cluster not in clusters: + raise SystemExit(f"Search authority node {path} uses unknown cluster {cluster}") + role = text(node.get("role"), f"nodes.{path}.role") + if role not in {"capability", "solution", "guide", "hub", "authority"}: + raise SystemExit(f"Search authority node {path} has invalid role {role}") + localized(node, "label", "en") + localized(node, "label", "id") + localized(node, "summary", "en") + localized(node, "summary", "id") + text(node.get("primaryQuery"), f"nodes.{path}.primaryQuery") + normalized_nodes[path] = node + + normalized_pages: dict[str, list[str]] = {} + inbound: dict[str, int] = defaultdict(int) + discovery_inbound: dict[str, int] = defaultdict(int) + for source, raw_targets in pages.items(): + if source not in normalized_nodes: + raise SystemExit(f"Search authority source has no node metadata: {source}") + if not isinstance(raw_targets, list) or not 2 <= len(raw_targets) <= 4: + raise SystemExit(f"Search authority source {source} must have two to four related pages") + targets = [text(item, f"pages.{source}") for item in raw_targets] + if len(targets) != len(set(targets)): + raise SystemExit(f"Search authority source {source} contains duplicate targets") + if source in targets: + raise SystemExit(f"Search authority source {source} links to itself") + for target in targets: + if target not in normalized_nodes: + raise SystemExit(f"Search authority target has no node metadata: {target}") + inbound[target] += 1 + if normalized_nodes[source]["role"] in DISCOVERY_ROLES: + discovery_inbound[target] += 1 + normalized_pages[source] = targets + + guide_paths = { + path for path, item in entries.items() + if item.get("contentType") == "guide" + } + missing_nodes = sorted(guide_paths - set(normalized_nodes)) + if missing_nodes: + raise SystemExit("Troubleshooting guides missing from authority graph: " + ", ".join(missing_nodes)) + missing_discovery = sorted(path for path in guide_paths if discovery_inbound[path] < 1) + if missing_discovery: + raise SystemExit("Guides lack capability/solution inbound paths: " + ", ".join(missing_discovery)) + + stats = { + "clusterCount": len(clusters), + "nodeCount": len(normalized_nodes), + "mappedPageCount": len(normalized_pages), + "guideCount": len(guide_paths), + "guidesWithDiscoveryInbound": sum(1 for path in guide_paths if discovery_inbound[path] >= 1), + "relationshipCount": sum(len(items) for items in normalized_pages.values()), + } + return normalized_nodes, normalized_pages, stats + + +def target_attributes(source_language: str, target_language: str) -> str: + if source_language == target_language: + return "" + return f' lang="{escape(target_language)}" hreflang="{escape(target_language)}"' + + +def section(source: str, language: str, nodes: dict[str, dict[str, Any]], pages: dict[str, list[str]]) -> str: + source_node = nodes[source] + cluster = str(source_node["cluster"]) + is_id = language == "id" + kicker = "Jalur evidence terkait" if is_id else "Related evidence paths" + heading = "Lanjutkan ke gejala atau workflow yang paling dekat." if is_id else "Continue with the closest failure mode or workflow." + intro = ( + "Link berikut dipilih dari cluster engineering yang sama agar diagnosis berpindah dari gejala ke evidence yang relevan." + if is_id else + "These links stay inside the same engineering evidence cluster, moving from the current workflow to the most relevant failure mode." + ) + cards: list[str] = [] + for target in pages[source]: + target_node = nodes[target] + target_language = "id" if target.endswith(".html") and target in { + "mms-client-iec61850.html", "smart-reporting-iec61850.html", "analyzer-goose-iec61850.html", + "transfer-file-comtrade-iec61850.html", "workspace-scl-iec61850.html", + "pengujian-fat-iec61850.html", "pengujian-sat-iec61850.html", + "commissioning-iec61850.html", "integrasi-multi-vendor-iec61850.html", + } else "en" + label = localized(target_node, "label", language) + summary = localized(target_node, "summary", language) + role = str(target_node["role"]) + role_label = { + "guide": "Panduan troubleshooting" if is_id else "Troubleshooting guide", + "capability": "Capability produk" if is_id else "Product capability", + "solution": "Workflow solusi" if is_id else "Solution workflow", + }.get(role, "Evidence path") + attrs = target_attributes(language, target_language) + cards.append( + f'' + ) + return ( + f'\n\n' + ) + + +def apply(site: Path, graph_path: Path) -> dict[str, int]: + entries = registry(site) + graph = load_object(graph_path, "landing/search-authority.json") + nodes, pages, stats = validate_graph(graph, entries) + for source in sorted(pages): + page = site / source + if not page.is_file(): + raise SystemExit(f"Built page required by authority graph is missing: {source}") + html = page.read_text(encoding="utf-8") + if "data-search-authority=" in html: + raise SystemExit(f"Built page already contains a search-authority section: {source}") + language = str(entries[source].get("language", "en")) + generated = section(source, language, nodes, pages) + marker = '
' + if marker in html: + html = html.replace(marker, generated + marker, 1) + elif "" in html: + html = html.replace("", generated + "", 1) + else: + raise SystemExit(f"Cannot place search-authority section in {source}") + page.write_text(html, encoding="utf-8") + + build_info_path = site / "build-info.json" + build_info = load_object(build_info_path, "build-info.json") + build_info["searchAuthority"] = { + "schemaVersion": 1, + **stats, + "allGuidesHaveCapabilityOrSolutionInbound": stats["guidesWithDiscoveryInbound"] == stats["guideCount"], + } + build_info_path.write_text(json.dumps(build_info, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + deployed_graph = site / "search-authority.json" + if deployed_graph.exists(): + deployed_graph.unlink() + return stats + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("site", nargs="?", default="_site") + parser.add_argument("--graph", default=str(DEFAULT_GRAPH)) + args = parser.parse_args() + stats = apply(Path(args.site).resolve(), Path(args.graph).resolve()) + print( + "Applied ARSAS search authority graph: " + f"{stats['mappedPageCount']} pages, {stats['relationshipCount']} contextual links, " + f"{stats['guidesWithDiscoveryInbound']}/{stats['guideCount']} guides with capability/solution inbound paths." + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as exc: + print(f"ARSAS search authority application failed: {exc}", file=sys.stderr) + raise From be24a65e4480b807e902f0610c02cd9a2941a9c7 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 21 Jul 2026 04:24:19 +0700 Subject: [PATCH 05/10] Validate rendered search authority graph --- scripts/validate-search-authority.py | 138 +++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 scripts/validate-search-authority.py diff --git a/scripts/validate-search-authority.py b/scripts/validate-search-authority.py new file mode 100644 index 000000000..40b6d89b6 --- /dev/null +++ b/scripts/validate-search-authority.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Validate rendered ARSAS search-authority relationships and build metadata.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from collections import defaultdict +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_GRAPH = ROOT / "landing" / "search-authority.json" +SECTION_PATTERN = re.compile( + r']*>(.*?)
', + re.IGNORECASE | re.DOTALL, +) +HREF_PATTERN = re.compile(r'href="([^"]+)"', re.IGNORECASE) + + +def load(path: Path, label: str) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise SystemExit(f"Cannot read {label}: {exc}") from exc + if not isinstance(value, dict): + raise SystemExit(f"{label} must contain an object") + return value + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("site", nargs="?", default="_site") + parser.add_argument("--graph", default=str(DEFAULT_GRAPH)) + args = parser.parse_args() + + site = Path(args.site).resolve() + graph = load(Path(args.graph).resolve(), "search authority graph") + config = load(site / "site.json", "built site registry") + build_info = load(site / "build-info.json", "build-info.json") + errors: list[str] = [] + + nodes = graph.get("nodes") + pages = graph.get("pages") + clusters = graph.get("clusters") + if not isinstance(nodes, dict) or not isinstance(pages, dict) or not isinstance(clusters, dict): + raise SystemExit("Search authority graph is incomplete") + + registry: dict[str, dict[str, Any]] = {} + for item in config.get("pages", []): + if isinstance(item, dict) and isinstance(item.get("path"), str): + registry[item["path"] or "index.html"] = item + + inbound: dict[str, int] = defaultdict(int) + relationship_count = 0 + for source, raw_targets in pages.items(): + targets = raw_targets if isinstance(raw_targets, list) else [] + relationship_count += len(targets) + page = site / source + if not page.is_file(): + errors.append(f"mapped page is missing: {source}") + continue + html = page.read_text(encoding="utf-8") + matches = SECTION_PATTERN.findall(html) + if len(matches) != 1: + errors.append(f"{source}: expected one rendered authority section, found {len(matches)}") + continue + cluster, body = matches[0] + node = nodes.get(source) + expected_cluster = node.get("cluster") if isinstance(node, dict) else None + if cluster != expected_cluster: + errors.append(f"{source}: authority cluster is {cluster!r}, expected {expected_cluster!r}") + hrefs = HREF_PATTERN.findall(body) + for target in targets: + if hrefs.count(target) != 1: + errors.append(f"{source}: expected exactly one contextual link to {target}") + inbound[target] += 1 + unexpected = sorted(set(hrefs) - set(targets)) + if unexpected: + errors.append(f"{source}: authority section has unexpected links {unexpected}") + if html.find('data-search-authority=') > html.find('data-section="download-cta"') >= 0: + errors.append(f"{source}: authority section must appear before the download CTA") + if 'aria-labelledby="related-evidence-heading"' not in html: + errors.append(f"{source}: authority section is missing accessible heading linkage") + + for path in registry: + if path in pages: + continue + page = site / path + if page.is_file() and "data-search-authority=" in page.read_text(encoding="utf-8"): + errors.append(f"{path}: unexpected authority section on unmapped page") + + guide_paths = { + path for path, item in registry.items() + if item.get("contentType") == "guide" + } + for guide in sorted(guide_paths): + if inbound[guide] < 1: + errors.append(f"{guide}: no rendered inbound authority relationship") + + authority = build_info.get("searchAuthority") + expected_stats = { + "schemaVersion": 1, + "clusterCount": len(clusters), + "nodeCount": len(nodes), + "mappedPageCount": len(pages), + "guideCount": len(guide_paths), + "guidesWithDiscoveryInbound": len(guide_paths), + "relationshipCount": relationship_count, + "allGuidesHaveCapabilityOrSolutionInbound": True, + } + if not isinstance(authority, dict): + errors.append("build-info.json is missing searchAuthority metadata") + else: + for key, value in expected_stats.items(): + if authority.get(key) != value: + errors.append(f"build-info.json searchAuthority.{key} must be {value!r}") + + if (site / "search-authority.json").exists(): + errors.append("source search-authority.json must not be deployed publicly") + + errors = list(dict.fromkeys(errors)) + if errors: + print("ARSAS search authority validation failed:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + print( + f"ARSAS search authority validation passed: {len(pages)} mapped pages, " + f"{relationship_count} contextual links, {len(guide_paths)} guides with inbound paths." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 3f1f704291f53c99461257b0480c965c52134805 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 21 Jul 2026 04:25:15 +0700 Subject: [PATCH 06/10] Build private search growth action queue --- scripts/build-search-growth-report.py | 285 ++++++++++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 scripts/build-search-growth-report.py diff --git a/scripts/build-search-growth-report.py b/scripts/build-search-growth-report.py new file mode 100644 index 000000000..b4050a4a5 --- /dev/null +++ b/scripts/build-search-growth-report.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python3 +"""Build a private ARSAS search-growth queue from structure and optional measurement evidence.""" + +from __future__ import annotations + +import argparse +import json +import math +from collections import defaultdict +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_GRAPH = ROOT / "landing" / "search-authority.json" +CANONICAL_PREFIX = "/arsas/" + + +def load(path: Path, required: bool = True) -> dict[str, Any]: + if not path.exists(): + if required: + raise SystemExit(f"Missing JSON input: {path}") + return {} + try: + value = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise SystemExit(f"Invalid JSON input {path}: {exc}") from exc + if not isinstance(value, dict): + raise SystemExit(f"JSON input must contain an object: {path}") + return value + + +def normalize_path(value: str) -> str: + parsed = urlsplit(value) + path = parsed.path + if path in {"/arsas", "/arsas/", ""}: + return "index.html" + if path.startswith(CANONICAL_PREFIX): + path = path[len(CANONICAL_PREFIX):] + return path.lstrip("/") or "index.html" + + +def registry(site: Path) -> dict[str, dict[str, Any]]: + config = load(site / "site.json") + result: dict[str, dict[str, Any]] = {} + for item in config.get("pages", []): + if isinstance(item, dict) and isinstance(item.get("path"), str): + result[item["path"] or "index.html"] = item + return result + + +def localized_counterparts(entries: dict[str, dict[str, Any]]) -> dict[str, str]: + pairs: dict[str, str] = {} + for path, item in entries.items(): + if item.get("language", "en") != "en": + continue + alternates = item.get("alternates") + if isinstance(alternates, dict) and isinstance(alternates.get("id"), str): + pairs[path] = alternates["id"] or "id.html" + return pairs + + +def add(queue: list[dict[str, Any]], *, priority: int, category: str, page: str = "", query: str = "", evidence: str, action: str, source: str) -> None: + queue.append({ + "priority": max(1, min(100, int(priority))), + "category": category, + "page": page, + "query": query, + "evidence": evidence, + "action": action, + "source": source, + }) + + +def structural_queue(entries: dict[str, dict[str, Any]], graph: dict[str, Any]) -> tuple[list[dict[str, Any]], dict[str, Any]]: + nodes = graph.get("nodes", {}) + pages = graph.get("pages", {}) + inbound: dict[str, int] = defaultdict(int) + for targets in pages.values(): + if isinstance(targets, list): + for target in targets: + if isinstance(target, str): + inbound[target] += 1 + + queue: list[dict[str, Any]] = [] + pairs = localized_counterparts(entries) + localized_targets = set(pairs.values()) + for path, node in nodes.items(): + if not isinstance(node, dict): + continue + role = str(node.get("role", "")) + language = str(entries.get(path, {}).get("language", "en")) + if language == "en" and role in {"guide", "capability", "solution"} and path not in pairs: + base = {"guide": 68, "capability": 54, "solution": 50}.get(role, 45) + add( + queue, + priority=base, + category="localization-gap", + page=path, + query=str(node.get("primaryQuery", "")), + evidence=f"Indexable English {role} page has no declared Indonesian alternate.", + action="Translate only after search impressions or Indonesian demand justify the work; then add reciprocal hreflang and hub links.", + source="authority-graph", + ) + if inbound[path] < 2: + add( + queue, + priority=42 if role == "guide" else 32, + category="authority-depth", + page=path, + query=str(node.get("primaryQuery", "")), + evidence=f"Contextual authority graph provides {inbound[path]} inbound relationship(s).", + action="Add another relevant contextual path from a capability, solution or sibling guide without creating generic sitewide links.", + source="authority-graph", + ) + + stats = { + "indexablePages": sum(1 for item in entries.values() if item.get("index", True) is not False), + "authorityNodes": len(nodes), + "mappedPages": len(pages), + "relationships": sum(len(value) for value in pages.values() if isinstance(value, list)), + "localizedPairs": len(pairs), + "localizedTargets": len(localized_targets), + "structuralQueueItems": len(queue), + } + return queue, stats + + +def measurement_queue(report: dict[str, Any]) -> tuple[list[dict[str, Any]], dict[str, str]]: + queue: list[dict[str, Any]] = [] + ga4 = report.get("ga4") if isinstance(report.get("ga4"), dict) else {} + search = report.get("searchConsole") if isinstance(report.get("searchConsole"), dict) else {} + speed = report.get("coreWebVitals") if isinstance(report.get("coreWebVitals"), dict) else {} + + for item in search.get("lowCtrPages", [])[:50]: + if not isinstance(item, dict): + continue + impressions = float(item.get("impressions", 0) or 0) + ctr = float(item.get("ctr", 0) or 0) + position = float(item.get("position", 0) or 0) + score = 62 + min(25, math.log10(max(impressions, 1)) * 7) + (8 if position <= 10 else 0) + add( + queue, + priority=score, + category="low-ctr-page", + page=normalize_path(str(item.get("page", ""))), + evidence=f"{int(impressions)} impressions, {ctr * 100:.2f}% CTR, average position {position:.1f}.", + action="Review title, description, visible answer and query intent before changing page scope; compare the next reporting window.", + source="search-console", + ) + + for item in search.get("lowCtrQueries", [])[:50]: + if not isinstance(item, dict): + continue + impressions = float(item.get("impressions", 0) or 0) + ctr = float(item.get("ctr", 0) or 0) + position = float(item.get("position", 0) or 0) + score = 58 + min(22, math.log10(max(impressions, 1)) * 6) + (8 if position <= 10 else 0) + add( + queue, + priority=score, + category="low-ctr-query", + query=str(item.get("query", "")), + evidence=f"{int(impressions)} impressions, {ctr * 100:.2f}% CTR, average position {position:.1f}.", + action="Map the query to one existing evidence page first; improve intent alignment rather than creating a near-duplicate page.", + source="search-console", + ) + + for item in ga4.get("notFound", [])[:30]: + if not isinstance(item, dict): + continue + count = int(item.get("eventCount", 0) or 0) + add( + queue, + priority=92 if count >= 5 else 78, + category="observed-404", + page=normalize_path(str(item.get("pagePath", ""))), + evidence=f"{count} consented 404 event(s); referrer {item.get('pageReferrer', '') or 'unavailable'}.", + action="Repair the source link or add a precise valid route; do not redirect unrelated paths to the homepage.", + source="ga4", + ) + + for item in speed.get("pages", []): + if not isinstance(item, dict) or item.get("status") != "available": + continue + category = str(item.get("fieldCategory", "none")).lower() + lab_score = item.get("lab", {}).get("performanceScore") if isinstance(item.get("lab"), dict) else None + poor_lab = isinstance(lab_score, (int, float)) and lab_score < 0.75 + if category in {"slow", "poor"} or poor_lab: + add( + queue, + priority=86 if category in {"slow", "poor"} else 70, + category="core-web-vitals", + page=normalize_path(str(item.get("url", ""))), + evidence=f"Field category {category}; Lighthouse performance score {lab_score if lab_score is not None else 'unavailable'}.", + action="Profile the exact page and remove the largest blocking or layout-shift source; confirm with the next field-data cycle.", + source="pagespeed-crux", + ) + + coverage = { + "ga4": str(ga4.get("status", "not-configured")), + "searchConsole": str(search.get("status", "not-configured")), + "coreWebVitals": str(speed.get("status", "not-run")), + } + return queue, coverage + + +def deduplicate(queue: list[dict[str, Any]]) -> list[dict[str, Any]]: + best: dict[tuple[str, str, str], dict[str, Any]] = {} + for item in queue: + key = (str(item.get("category", "")), str(item.get("page", "")), str(item.get("query", ""))) + if key not in best or int(item["priority"]) > int(best[key]["priority"]): + best[key] = item + return sorted(best.values(), key=lambda item: (-int(item["priority"]), str(item["category"]), str(item["page"]), str(item["query"]))) + + +def markdown(payload: dict[str, Any]) -> str: + coverage = payload["dataCoverage"] + lines = [ + "# ARSAS search growth queue", + "", + f"Generated: `{payload['generatedAtUtc']}`", + "", + "## Evidence coverage", + "", + f"- GA4: **{coverage['ga4']}**", + f"- Search Console: **{coverage['searchConsole']}**", + f"- Core Web Vitals: **{coverage['coreWebVitals']}**", + f"- Authority graph: **available** ({payload['structure']['mappedPages']} mapped pages, {payload['structure']['relationships']} relationships)", + "", + "## Prioritized actions", + "", + "| Priority | Category | Page / query | Evidence | Action |", + "|---:|---|---|---|---|", + ] + for item in payload["queue"][:50]: + subject = item.get("page") or item.get("query") or "—" + values = [ + str(item["priority"]), str(item["category"]), str(subject), + str(item["evidence"]), str(item["action"]), + ] + lines.append("| " + " | ".join(value.replace("|", "\\|").replace("\n", " ") for value in values) + " |") + if not payload["queue"]: + lines.append("| — | none | — | No threshold breach or structural gap detected. | Continue the weekly evidence cycle. |") + lines.extend([ + "", + "This queue is private workflow evidence. It does not publish traffic, queries, credentials or customer/project data to the website.", + "", + ]) + return "\n".join(lines) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--site", default="_site") + parser.add_argument("--graph", default=str(DEFAULT_GRAPH)) + parser.add_argument("--measurement-json", default="") + parser.add_argument("--output", default="_growth") + args = parser.parse_args() + + site = Path(args.site).resolve() + graph = load(Path(args.graph).resolve()) + entries = registry(site) + structural, structure_stats = structural_queue(entries, graph) + measurement = load(Path(args.measurement_json).resolve(), required=False) if args.measurement_json else {} + measured, coverage = measurement_queue(measurement) + queue = deduplicate([*measured, *structural]) + payload = { + "schemaVersion": 1, + "generatedAtUtc": datetime.now(timezone.utc).isoformat(), + "dataCoverage": coverage, + "structure": structure_stats, + "queue": queue, + } + output = Path(args.output).resolve() + output.mkdir(parents=True, exist_ok=True) + (output / "search-growth.json").write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + (output / "search-growth.md").write_text(markdown(payload), encoding="utf-8") + print(f"ARSAS search growth queue generated: {len(queue)} prioritized item(s), output {output}.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 4294fbd0279d2211d3cc3dfb0daa28d93148c029 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 21 Jul 2026 04:26:10 +0700 Subject: [PATCH 07/10] Gate Pages deployment on search authority graph --- .github/workflows/pages.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index c1e93d3a0..8502a07c5 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -8,6 +8,8 @@ on: - "scripts/validate-product-source.py" - "scripts/build-product-site.py" - "scripts/validate-product-build.py" + - "scripts/apply-search-authority.py" + - "scripts/validate-search-authority.py" - "scripts/submit-indexnow.py" - "scripts/inject-site-measurement.py" - "scripts/validate-site-measurement.py" @@ -23,6 +25,8 @@ on: - "scripts/validate-product-source.py" - "scripts/build-product-site.py" - "scripts/validate-product-build.py" + - "scripts/apply-search-authority.py" + - "scripts/validate-search-authority.py" - "scripts/submit-indexnow.py" - "scripts/inject-site-measurement.py" - "scripts/validate-site-measurement.py" @@ -118,6 +122,9 @@ jobs: - name: Build deterministic product website run: python scripts/build-product-site.py --output _site --release-evidence /tmp/arsas-published.json + - name: Apply contextual search authority graph + run: python scripts/apply-search-authority.py _site + - name: Generate bilingual privacy pages env: GA4_MEASUREMENT_ID: ${{ vars.GA4_MEASUREMENT_ID }} @@ -145,6 +152,9 @@ jobs: GA4_MEASUREMENT_ID: ${{ vars.GA4_MEASUREMENT_ID }} run: python scripts/validate-site-measurement.py _site --measurement-id "$GA4_MEASUREMENT_ID" + - name: Validate contextual authority and guide discovery + run: python scripts/validate-search-authority.py _site + - name: Check internal links and fragments run: python scripts/check-site-health.py --site _site --output _validation/site-health @@ -184,6 +194,7 @@ jobs: ! grep -R --line-number -E '(href|src|content)="http://' _site --include='*.html' ! grep -R --line-number --fixed-strings 'raw.githubusercontent.com/masarray/arsas/main/Assets/screenshot' _site --include='*.html' ! grep -R --line-number --fixed-strings '__ARSAS_GA4_MEASUREMENT_ID__' _site --include='*.html' + test ! -e _site/search-authority.json - name: Upload website artifact if: github.event_name != 'pull_request' @@ -222,7 +233,7 @@ jobs: with: python-version: "3.12" - - name: Verify deployed commit, privacy and measurement state + - name: Verify deployed commit, privacy, authority and measurement state env: GA4_MEASUREMENT_ID: ${{ vars.GA4_MEASUREMENT_ID }} shell: bash From d2fa7475c0046e8453db0df60fbb23f7f7929d16 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 21 Jul 2026 04:27:01 +0700 Subject: [PATCH 08/10] Attest contextual authority graph in production --- scripts/verify-pages-deployment.py | 42 ++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/scripts/verify-pages-deployment.py b/scripts/verify-pages-deployment.py index 4b2adaef2..9b8dc397a 100644 --- a/scripts/verify-pages-deployment.py +++ b/scripts/verify-pages-deployment.py @@ -33,7 +33,7 @@ def fetch(url: str, timeout: int = 20) -> tuple[int, str, dict[str, str]]: request = Request( url, headers={ - "User-Agent": "ARSAS-Pages-Attestation/1.0", + "User-Agent": "ARSAS-Pages-Attestation/1.1", "Accept": "application/json,text/html;q=0.9,*/*;q=0.8", "Cache-Control": "no-cache", "Pragma": "no-cache", @@ -116,6 +116,19 @@ def check_once(base_url: str, source_commit: str, stable_version: str, measureme if privacy.get("analyticsClientLoadedOnPolicyPages") is not False: errors.append("public privacy metadata must prohibit analytics client loading on policy pages") + authority = info.get("searchAuthority") + if not isinstance(authority, dict): + errors.append("public build is missing search-authority metadata") + else: + if authority.get("schemaVersion") != 1: + errors.append("public search-authority schema is invalid") + if authority.get("allGuidesHaveCapabilityOrSolutionInbound") is not True: + errors.append("public search-authority metadata does not prove guide discovery coverage") + if int(authority.get("mappedPageCount", 0) or 0) < 20: + errors.append("public search-authority graph maps too few pages") + if int(authority.get("relationshipCount", 0) or 0) < 60: + errors.append("public search-authority graph has too few contextual relationships") + for relative in ("privacy.html", "privasi.html"): url = urljoin(base_url, relative) + "?" + urlencode({"attest": source_commit, "n": nonce}) page_status, page, _ = fetch(url) @@ -149,12 +162,37 @@ def check_once(base_url: str, source_commit: str, stable_version: str, measureme if 'src="analytics.js"' in home or "googletagmanager.com" in home: errors.append("homepage must not load analytics before consent") + authority_url = urljoin(base_url, "smart-reporting.html") + "?" + urlencode({"attest": source_commit, "n": nonce}) + authority_status, authority_page, _ = fetch(authority_url) + evidence["authorityProbeStatus"] = authority_status + if authority_status != 200: + errors.append(f"smart-reporting.html returned HTTP {authority_status}") + else: + for required in ( + 'data-search-authority="reporting"', + 'href="reporting-silent.html"', + 'href="brcb-vs-urcb.html"', + 'href="rcb-reserved.html"', + 'href="empty-dataset.html"', + 'data-section="download-cta"', + ): + if required not in authority_page: + errors.append(f"smart-reporting.html is missing contextual authority evidence {required}") + if authority_page.find('data-search-authority="reporting"') > authority_page.find('data-section="download-cta"') >= 0: + errors.append("smart-reporting.html authority section appears after the download CTA") + + graph_url = urljoin(base_url, "search-authority.json") + "?" + urlencode({"attest": source_commit, "n": nonce}) + graph_status, _, _ = fetch(graph_url) + evidence["privateAuthoritySourceStatus"] = graph_status + if graph_status != 404: + errors.append(f"search-authority.json must not be publicly deployed; observed HTTP {graph_status}") + return errors, evidence def write_report(path: Path, success: bool, attempts: int, evidence: dict[str, object], errors: list[str]) -> None: payload = { - "schemaVersion": 2, + "schemaVersion": 3, "verifiedAtUtc": datetime.now(timezone.utc).isoformat(), "success": success, "attempts": attempts, From 36c65a877bf344c566def3a1f49c2f6cfea6c4db Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 21 Jul 2026 04:27:31 +0700 Subject: [PATCH 09/10] Add private search growth evidence workflow --- .github/workflows/search-growth.yml | 182 ++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 .github/workflows/search-growth.yml diff --git a/.github/workflows/search-growth.yml b/.github/workflows/search-growth.yml new file mode 100644 index 000000000..0d1559d66 --- /dev/null +++ b/.github/workflows/search-growth.yml @@ -0,0 +1,182 @@ +name: Build search growth queue + +on: + schedule: + - cron: "5 4 * * 1" + workflow_dispatch: + inputs: + days: + description: Aggregated evidence window in days + required: false + default: "28" + type: choice + options: ["7", "28", "60", "90"] + pull_request: + branches: [ main ] + paths: + - "landing/**" + - "scripts/build-product-site.py" + - "scripts/apply-search-authority.py" + - "scripts/validate-search-authority.py" + - "scripts/build-search-growth-report.py" + - "scripts/build-site-measurement-report.py" + - "scripts/generate-privacy-pages.py" + - "scripts/inject-site-measurement.py" + - "scripts/check-site-health.py" + - ".github/workflows/search-growth.yml" + - ".github/workflows/pages.yml" + push: + branches: [ main ] + paths: + - "landing/**" + - "scripts/build-product-site.py" + - "scripts/apply-search-authority.py" + - "scripts/validate-search-authority.py" + - "scripts/build-search-growth-report.py" + - "scripts/build-site-measurement-report.py" + - "scripts/generate-privacy-pages.py" + - "scripts/inject-site-measurement.py" + - "scripts/check-site-health.py" + - ".github/workflows/search-growth.yml" + - ".github/workflows/pages.yml" + +permissions: + contents: read + +concurrency: + group: search-growth-${{ github.ref }} + cancel-in-progress: true + +env: + CANONICAL_ROOT: https://masarray.github.io/arsas/ + +jobs: + quality: + name: Validate authority graph and structural growth queue + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + show-progress: false + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Prepare stable release evidence + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then + cp landing/latest.json /tmp/arsas-published.json + else + gh api "repos/$GITHUB_REPOSITORY/contents/published.json?ref=release-evidence" --jq .content | base64 -d > /tmp/arsas-published.json + fi + + - name: Build product website + run: python scripts/build-product-site.py --output _site --release-evidence /tmp/arsas-published.json + + - name: Apply contextual search authority graph + run: python scripts/apply-search-authority.py _site + + - name: Generate bilingual privacy pages + env: + GA4_MEASUREMENT_ID: ${{ vars.GA4_MEASUREMENT_ID }} + run: python scripts/generate-privacy-pages.py --output _site --release-evidence /tmp/arsas-published.json --measurement-id "$GA4_MEASUREMENT_ID" + + - name: Configure optional consent-gated measurement + env: + GA4_MEASUREMENT_ID: ${{ vars.GA4_MEASUREMENT_ID }} + run: python scripts/inject-site-measurement.py _site --measurement-id "$GA4_MEASUREMENT_ID" + + - name: Validate rendered authority graph + run: python scripts/validate-search-authority.py _site + + - name: Check internal links and fragments + run: python scripts/check-site-health.py --site _site --output _growth/health + + - name: Build credential-free structural growth queue + run: python scripts/build-search-growth-report.py --site _site --output _growth/queue + + - name: Add structural queue to job summary + shell: bash + run: cat _growth/queue/search-growth.md >> "$GITHUB_STEP_SUMMARY" + + - name: Upload structural growth evidence + uses: actions/upload-artifact@v4 + with: + name: search-growth-quality + path: _growth/ + if-no-files-found: error + retention-days: 30 + + insights: + name: Build private measured search growth queue + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + needs: quality + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + show-progress: false + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install read-only reporting dependencies + run: python -m pip install --disable-pip-version-check --quiet google-auth requests + + - name: Prepare stable release evidence + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: gh api "repos/$GITHUB_REPOSITORY/contents/published.json?ref=release-evidence" --jq .content | base64 -d > /tmp/arsas-published.json + + - name: Build current product website + run: python scripts/build-product-site.py --output _site --release-evidence /tmp/arsas-published.json + + - name: Apply contextual search authority graph + run: python scripts/apply-search-authority.py _site + + - name: Generate bilingual privacy pages + env: + GA4_MEASUREMENT_ID: ${{ vars.GA4_MEASUREMENT_ID }} + run: python scripts/generate-privacy-pages.py --output _site --release-evidence /tmp/arsas-published.json --measurement-id "$GA4_MEASUREMENT_ID" + + - name: Configure optional consent-gated measurement + env: + GA4_MEASUREMENT_ID: ${{ vars.GA4_MEASUREMENT_ID }} + run: python scripts/inject-site-measurement.py _site --measurement-id "$GA4_MEASUREMENT_ID" + + - name: Build aggregated traffic, search and CWV evidence + env: + GOOGLE_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_JSON }} + GA4_PROPERTY_ID: ${{ vars.GA4_PROPERTY_ID }} + GSC_SITE_URL: ${{ vars.GSC_SITE_URL || 'https://masarray.github.io/arsas/' }} + PAGESPEED_API_KEY: ${{ secrets.PAGESPEED_API_KEY }} + PAGESPEED_URLS: ${{ vars.PAGESPEED_URLS || 'https://masarray.github.io/arsas/,https://masarray.github.io/arsas/download.html,https://masarray.github.io/arsas/smart-reporting.html,https://masarray.github.io/arsas/guides.html,https://masarray.github.io/arsas/id.html,https://masarray.github.io/arsas/unduh.html' }} + REPORT_DAYS: ${{ inputs.days || '28' }} + GITHUB_STEP_SUMMARY: "" + run: python scripts/build-site-measurement-report.py --site _site --output _growth/measurement --days "$REPORT_DAYS" + + - name: Build prioritized measured growth queue + run: python scripts/build-search-growth-report.py --site _site --measurement-json _growth/measurement/measurement.json --output _growth/queue + + - name: Add measured queue to job summary + shell: bash + run: cat _growth/queue/search-growth.md >> "$GITHUB_STEP_SUMMARY" + + - name: Upload private measured growth evidence + uses: actions/upload-artifact@v4 + with: + name: search-growth-${{ github.run_number }} + path: _growth/ + if-no-files-found: error + retention-days: 90 From 678232b987ef6cafa6d23fb8bb957f4a6fc61f95 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 21 Jul 2026 04:31:04 +0700 Subject: [PATCH 10/10] Document P1 search authority workflow --- docs/search-growth-authority.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 docs/search-growth-authority.md diff --git a/docs/search-growth-authority.md b/docs/search-growth-authority.md new file mode 100644 index 000000000..ba389af6e --- /dev/null +++ b/docs/search-growth-authority.md @@ -0,0 +1,29 @@ +# ARSAS search growth and authority + +P1 turns the privacy-safe website measurement foundation into a controlled product-discovery loop. + +## Contextual authority graph + +`landing/search-authority.json` maps IEC 61850 capability pages, solution pages and troubleshooting guides into engineering clusters. Every mapped page receives two to four contextual evidence links before the Download CTA. + +The graph must prove that every troubleshooting guide has at least one inbound path from a capability or solution page. Generic sitewide links and guide-only loops do not satisfy this contract. + +`scripts/apply-search-authority.py` validates and renders the graph. The source graph is removed from the deployable artifact. `scripts/validate-search-authority.py` checks the rendered relationships, placement, inbound guide coverage and `build-info.json` evidence. + +The post-deployment Pages attestation verifies the public authority metadata, probes the Smart Reporting relationship set and confirms that the source graph is not publicly served. + +## Private growth queue + +`scripts/build-search-growth-report.py` creates a prioritized private queue from: + +- contextual inbound depth; +- English pages without an Indonesian alternate; +- Search Console low-CTR opportunities when evidence is available; +- consented 404 observations when evidence is available; +- PageSpeed or CrUX failures when evidence is available. + +The queue does not create pages automatically. Queries must first be mapped to an existing evidence page to avoid thin or duplicate content. Localization gaps remain candidates until Indonesian demand justifies translation. + +`.github/workflows/search-growth.yml` validates the structural queue on relevant changes and creates a measured private queue every Monday at 04:05 UTC or on manual dispatch. Structural artifacts are retained for 30 days and measured artifacts for 90 days. + +Traffic, search queries and growth queues are never deployed to the public website.