PageSourceSearch

https://xagent.id/assets/dashboard-report-stage2.js?v=tenant-20260920

js xagent.id collected 2026-10-05 06:32:53 UTC 15,253 bytes, 267 lines download raw bytes

1/**
2 * dashboard-report-stage2.js — Dashboard 報告頁 Stage 2 渲染
3 *
4 * 掛入點:`dashboard.html` 在 dashboard-report-stage1.js 之後載入本檔。
5 * 四軸一覽卡插入既有 `.score-grid`(含 tabs 互動的原始區塊)**之後**,
6 * 不破壞既有 score-grid 與 demo.js 的渲染;成效指標統計展開掛在
7 * `[data-outcome-details]` 之前(同 block 插敘事)。
8 *
9 * 區塊:
10 * - report-axes   → 區塊 4:四軸健康度一覽(網主語言敘事 + 計數彙總)
11 * - report-outcomes → 區塊 5:成效指標統計展開(A1/A2/B1 統計層 + proof level)
12 *
13 * 合規政策(不可移除,與 stage 1 一致):
14 * 1. pending_not_zero:pending/partial 用 coral 強調;share_of_voice 為 null
15 *    時顯示 share_of_voice_status 原因 + pending chip,絕不顯示 0。
16 * 2. 不新增 KPI 話術:每行文案只讀 payload 自有欄位(limits 全文常駐)。
17 * 3. proof level 常駐:每個展開區塊標示資料的最高證明等級。
18 */
19(() => {
20  "use strict";
21
22  /* Language follows document.body.dataset.lang at call time; demo.js rewrites
23   * that attribute from the visitor's preference, so a top-level snapshot would
24   * go stale. Copy resolves through the shared eight-locale catalog, keyed by
25   * the English string; the Chinese argument is the fallback when the catalog
26   * has no entry. A missing English argument means the string is Chinese-only. */
27  const currentLang = () => document.body.dataset.lang || document.documentElement.lang || "en";
28  const t = (c, e) => {
29    const catalog = window.XAgentI18nCatalog;
30    const source = e !== undefined ? e : c;
31    const key = catalog?.aliases?.[source] || source;
32    const translated = catalog?.messages?.[key]?.[currentLang()];
33    if (translated) return translated;
34    return currentLang() === "zh-Hant" ? c : source;
35  };
36
37  /*  helpers  ------------------------------------------------------------- */
38
39  const el = (sel, root) => (root || document).querySelector(sel);
40  const pendingChip = (c, e) => `<span class="pending-chip">${t(c, e !== undefined ? e : c)}</span>`;
41  const escapeText = (v) => String(v ?? "").replace(/[&<>"']/g, (m) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[m]));
42  const read = (obj, ...path) => path.reduce((acc, k) => (acc && typeof acc === "object" ? acc[k] : undefined), obj);
43
44  const fmtPct = (v) => (typeof v === "number" ? `${v.toFixed(1)}%` : "—");
45
46  /* 區塊 4:四軸健康度 -------------------------------------------------------
47   * 敘事核心(design brief §5.2):網主標題 + 技術縮寫、大分數 + 進度條 +
48   * pass/partial/pending 計數 + 一句話狀態解讀。 */
49
50  const AXIS_TITLES = {
51    seo: ["AI 讀得懂你", "Read me"],
52    aeo: ["AI 答得對你", "Answer about me"],
53    geo: ["AI 敢推薦你", "Recommend me"],
54    ago: ["AI 能直接接單", "Take orders"],
55  };
56
57  const axisVerdict = (dim, axisKey) => {
58    /* 全部 pass → 完整理解;有 partial → 點出缺口;僅 pending → 待量測 */
59    if (!dim) return [t("待量測", "Awaiting measurement"), "pending"];
60    const { pass = 0, partial = 0, pending = 0 } = dim;
61    const total = pass + partial + pending;
62    if (total === 0) return [t("待量測", "Awaiting measurement"), "pending"];
63    if (partial === 0 && pending === 0) {
64      const line = {
65        seo: [t("AI 能完整理解你的公開頁面", "AI can fully parse your public pages"), "pass"],
66        aeo: [t("AI 能完整回答關於你的問題", "AI can fully answer questions about you"), "pass"],
67        geo: [t("AI 有完整依據可推薦你", "AI has full evidence to recommend you"), "pass"],
68        ago: [t("AI 能完整讀取你的下單流程", "AI can fully read your ordering flow"), "pass"],
69      }[axisKey] || ["", "pass"];
70      return [line[0], line[1]];
71    }
72    if (pending > 0) return [t("部分項目待量測", "Some checks awaiting measurement"), "partial"];
73    return [t("有可補強項目", "Improvable items found"), "partial"];
74  };
75
76  const renderAxes = (p) => {
77    const dims = read(p, "readiness", "dimensions") || {};
78    const cards = Object.keys(AXIS_TITLES).map((key) => {
79      const dim = dims[key] || {};
80      const [verdict, verdictState] = axisVerdict(dim, key);
81      const score = dim.score;
82      const fill = typeof score === "number" ? Math.max(0, Math.min(100, score)) : 0;
83      const hasScore = typeof score === "number";
84      return `
85      <article class="report-axis-card" data-report-axis="${key}">
86        <header class="axis-head">
87          <h3><span class="lang-zh">${t(...AXIS_TITLES[key])}</span></h3>
88          <p class="axis-abbr">${key.toUpperCase()}</p>
89        </header>
90        ${hasScore ? `<div class="axis-score-row"><span class="axis-score">${escapeText(score)}</span>
91          <div class="axis-bar" aria-hidden="true"><span class="axis-bar-fill" style="width:${fill}%"></span></div></div>`
92          : `<div class="axis-score-row"><span class="axis-score">—</span>
93            <div class="axis-bar" aria-hidden="true"><span class="axis-bar-fill" style="width:0"></span></div></div>`}
94        <p class="axis-counts">${t("通過", "pass")}: <b>${dim.pass ?? 0}</b> · ${t("部分", "partial")}: <b>${dim.partial ?? 0}</b>
95          ${dim.pending ? ` · ${t("待量測", "pending")}: <b class="axis-counts-pending">${escapeText(dim.pending)}</b>` : ""}</p>
96        <p class="axis-verdict ${verdictState}">${escapeText(verdict)}</p>
97      </article>`;
98    });
99    return `<section class="report-axes" data-report-axes data-report-block="axes">
100      <h2 class="report-section-title"><span class="lang-zh">四個關鍵問題</span><span class="lang-en">Four readiness axes</span></h2>
101      <div class="report-axes-grid">${cards.join("")}</div>
102    </section>`;
103  };
104
105  /* 區塊 5:成效指標統計展開 --------------------------------------------------
106   * design brief §9.4:降級為統計展開。A1 展開 by_provider/by_bot/by_status/
107   * top_paths/last_seen;A2 展開 proof 分級 + by_engine/by_outcome/top_cited_hosts
108   * + mention/citation rate + share_of_voice;B1 展開 by_event/by_source_class/
109   * by_ai_source。pending_not_zero:status 為 pending 的指標整列以 pending 呈現,
110   * 內部統計 0 值以「尚未觀測」呈現而非 0 百分比斷言。 */
111
112  const OUTCOME_META = {
113    ai_crawler_reads: {
114      id: "a1",
115      title: ["A1 · AI 爬蟲讀取", "A1 · AI crawler reads"],
116      kind: "reads",
117      detailKey: "ai_reads",
118    },
119    ai_citation_rate: {
120      id: "a2",
121      title: ["A2 · AI 提及與引用", "A2 · AI mentions &amp; citations"],
122      kind: "citations",
123      detailKey: "ai_citations",
124    },
125    ai_attributed_conversions: {
126      id: "b1",
127      title: ["B1 · AI 歸因轉換", "B1 · AI-attributed conversions"],
128      kind: "conversions",
129      detailKey: "conversions",
130    },
131  };
132
133  const objPairs = (obj) => {
134    if (!obj || typeof obj !== "object") return [];
135    return Object.entries(obj).filter(([, v]) => typeof v === "number" || typeof v === "string");
136  };
137
138  const renderStatDetail = (kind, d) => {
139    const rows = [];
140    const push = (labelZh, labelEn, value) => rows.push(`<tr><td>${t(labelZh, labelEn)}</td><td class="mono">${value}</td></tr>`);
141
142    if (kind === "reads") {
143      if (typeof d.total === "number") push("總讀取", "Total reads", escapeText(d.total));
144      if (typeof d.successful === "number") push("成功", "Successful", escapeText(d.successful));
145      if (typeof d.failed === "number") push("失敗", "Failed", escapeText(d.failed));
146      if (typeof d.bytes === "number") push("讀取字節", "Bytes fetched", escapeText(d.bytes));
147      objPairs(d.by_provider).forEach(([k, v]) => push(`bot family · ${k}`, `bot family · ${k}`, escapeText(v)));
148      objPairs(d.by_bot).forEach(([k, v]) => push(`bot · ${k}`, `bot · ${k}`, escapeText(v)));
149      objPairs(d.by_status).forEach(([k, v]) => push(`status · ${k}`, `status · ${k}`, escapeText(v)));
150      if (d.last_seen) push("最後觀測", "Last seen", escapeText(d.last_seen));
151    } else if (kind === "citations") {
152      if (typeof d.total === "number") push("回報筆數", "Reported rows", escapeText(d.total));
153      if (typeof d.reportable_runs === "number") push("可回報巡檢", "Reportable runs", escapeText(d.reportable_runs));
154      if (typeof d.observed_with_artifact === "number") push("帶證據觀測", "Observed with artifact", escapeText(d.observed_with_artifact));
155      if (typeof d.observed_manual_import === "number") push("手動匯入", "Manual import", escapeText(d.observed_manual_import));
156      if (typeof d.manual_pending === "number") push("手動待處理", "Manual pending", escapeText(d.manual_pending));
157      if (typeof d.synthetic_not_reportable === "number") push("模擬不可回報", "Synthetic (not reportable)", escapeText(d.synthetic_not_reportable));
158      if (typeof d.mention_rate_pct === "number") push("提及率", "Mention rate", fmtPct(d.mention_rate_pct));
159      if (typeof d.citation_rate_pct === "number") push("引用率", "Citation rate", fmtPct(d.citation_rate_pct));
160      if (typeof d.share_of_voice_pct === "number" && !d.share_of_voice_status) {
161        push("聲量占比", "Share of voice", fmtPct(d.share_of_voice_pct));
162      } else {
163        const reason = d.share_of_voice_status || t("需先供入競品提及,才能計算聲量占比", "Competitor mentions required before voice share can be computed");
164        push("聲量占比", "Share of voice", `${pendingChip(t("待取得", "pending"))} ${escapeText(reason)}`);
165      }
166      objPairs(d.by_engine).forEach(([k, v]) => push(`engine · ${k}`, `engine · ${k}`, escapeText(v)));
167      objPairs(d.by_outcome).forEach(([k, v]) => push(`outcome · ${k}`, `outcome · ${k}`, escapeText(v)));
168      (d.top_cited_hosts || []).forEach((h) => push("引用來源", "Cited host", escapeText(h)));
169      if (d.last_seen) push("最後觀測", "Last seen", escapeText(d.last_seen));
170    } else if (kind === "conversions") {
171      if (typeof d.total === "number") push("總事件", "Total events", escapeText(d.total));
172      if (typeof d.ai_attributed === "number") push("AI 歸因", "AI-attributed", escapeText(d.ai_attributed));
173      if (typeof d.non_ai_attributed === "number") push("非 AI 歸因", "Non-AI-attributed", escapeText(d.non_ai_attributed));
174      if (typeof d.total_value === "number") push("總金額", "Total value", escapeText(d.total_value));
175      if (typeof d.ai_attributed_value === "number") push("AI 歸因金額", "AI-attributed value", escapeText(d.ai_attributed_value));
176      if (typeof d.ai_attributed_rate_pct === "number") push("AI 歸因比率", "AI-attributed rate", fmtPct(d.ai_attributed_rate_pct));
177      objPairs(d.by_event).forEach(([k, v]) => push(`event · ${k}`, `event · ${k}`, escapeText(v)));
178      objPairs(d.by_source_class).forEach(([k, v]) => push(`source class · ${k}`, `source class · ${k}`, escapeText(v)));
179      objPairs(d.by_ai_source).forEach(([k, v]) => push(`AI source · ${k}`, `AI source · ${k}`, escapeText(v)));
180      if (d.last_seen) push("最後觀測", "Last seen", escapeText(d.last_seen));
181    }
182    if (rows.length === 0) return `<p class="outcome-no-data">${t("尚未觀測到統計資料", "No statistics observed yet")}</p>`;
183    return `<table class="outcome-detail-table">${rows.join("")}</table>`;
184  };
185
186  const renderOutcomes = (p) => {
187    const outcomes = read(p, "outcomes") || {};
188    const details = read(p, "outcome_details") || {};
189    const metaKeys = Object.keys(OUTCOME_META);
190
191    const rows = metaKeys.map((key) => {
192      const o = outcomes[key] || {};
193      const d = details[OUTCOME_META[key].detailKey] || {};
194      const meta = OUTCOME_META[key];
195      const statusPending = o.status === "pending";
196      const proofLevel = o.proof_level || read(d, "highest_proof_level") || "not_observed";
197      const limits = d.limits || [];
198
199      const valueCell = o.value != null ? escapeText(o.value)
200        : statusPending ? `${pendingChip(t("待量測", "pending"))}
200 ${t("需既有 CDN/伺服器/CRM 資料", "Requires existing CDN / server / CRM data")}`
201        : "—";
202
203      return `
204      <div class="outcome-row ${statusPending ? "outcome-pending" : ""}" data-report-outcome="${meta.id}">
205        <div class="outcome-head">
206          <div>
207            <h3 class="outcome-title">${t(...meta.title)}</h3>
208            <p class="outcome-proof">${t("證明等級", "Proof level")}:<b>${escapeText(proofLevel)}</b>
209              ${o.source ? ` · <a href="${escapeText(o.source)}" target="_blank" rel="noopener">${escapeText(o.source)}</a>` : ""}</p>
210          </div>
211          <div class="outcome-value-wrap"><span class="outcome-value">${valueCell}</span></div>
212        </div>
213        <p class="outcome-detail">${escapeText(read(o, "detail") || "")}</p>
214        <details class="outcome-expand">
215          <summary><span class="lang-zh">展開統計明細</span><span class="lang-en">Expand statistics</span></summary>
216          ${renderStatDetail(meta.kind, d)}
217          ${limits.length ? `<p class="outcome-limits">${limits.map((l) => pendingChip(l)).join(" ")}</p>` : ""}
218        </details>
219      </div>`;
220    });
221
222    return `<section class="report-outcomes" data-report-outcomes data-report-block="outcomes">
223      <h2 class="report-section-title"><span class="lang-zh">成效指標(你可以自己查)</span><span class="lang-en">Outcome metrics — verifiable by you</span></h2>
224      <div class="report-outcomes-list">${rows.join("")}</div>
225    </section>`;
226  };
227
228  /* 掛載 ------------------------------------------------------------------ */
229
230  const mountSections = (p) => {
231    const root = el("[data-dashboard-content]");
232    if (!root) return false;
233
234    const axesEl = document.createElement("div");
235    axesEl.innerHTML = renderAxes(p);
236    const scoreGrid = el(".score-grid", root);
237    if (scoreGrid) scoreGrid.after(axesEl.firstElementChild);
238    else root.append(axesEl.firstElementChild);
239
240    const outcomesEl = document.createElement("div");
241    outcomesEl.innerHTML = renderOutcomes(p);
242    const outcomeDetails = el("[data-outcome-details]", root);
243    if (outcomeDetails) outcomeDetails.before(outcomesEl.firstElementChild);
244    else {
245      const priorities = el("[data-priorities]", root) || el("[data-receipt]", root);
246      if (priorities) priorities.before(outcomesEl.firstElementChild);
247      else root.append(outcomesEl.firstElementChild);
248    }
249    return true;
250  };
251
252  /* 初始化:與 stage1 同一觀察器觸發(見 stage1 的 MutationObserver),
253   * 本檔只註冊根物件,避免重複觀察。 */
254  const init = () => {
255    const root = el("[data-dashboard-content]");
256    if (!root) return;
257    root.querySelectorAll("[data-report-block='axes'], [data-report-outcomes]").forEach((node) => node.remove());
258    const context = window.AgentIdReportApi?.current();
259    if (context) mountSections(context.data);
260  };
261
262  window.AgentIdReportStage2 = Object.freeze({ init, renderAxes, renderOutcomes });
263
264  /* 頁面已解鎖的直接觸發 */
265  window.addEventListener("dashboard-report-change", init);
266  init();
267})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.