diff --git a/Gemfile.lock b/Gemfile.lock index 1d894dc..5293866 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -30,7 +30,7 @@ GEM racc prettier_print (1.2.1) racc (1.8.1) - rack (3.1.8) + rack (3.1.16) rainbow (3.1.1) regexp_parser (2.9.2) rubocop (1.67.0) diff --git a/javascripts/discourse/initializers/alert.js b/javascripts/discourse/initializers/alert.js new file mode 100644 index 0000000..520a4c3 --- /dev/null +++ b/javascripts/discourse/initializers/alert.js @@ -0,0 +1,183 @@ +import { reportNavigationClick } from "../lib/navigation.js"; +import reportSearch from "../lib/search.js"; +import { reportSidebarClick } from "../lib/sidebar.js"; +import { reportTagsClick } from "../lib/tags.js"; +import { reportTopicClick, reportTopicLeave } from "../lib/topic.js"; + +function isCookieAgreed() { + const regexp = /\bagreed-cookiepolicy=([^;])+/; + const res = document.cookie.match(regexp)?.[1]; + return res === "1"; +} + +export default { + name: "alert", + initialize() { + import( + "https://unpkg.com/@opensig/open-analytics@0.0.9/dist/open-analytics.mjs" + ).then(({ OpenAnalytics, getClientInfo, OpenEventKeys }) => { + const oa = new OpenAnalytics({ + appKey: "openEuler", + request: (data) => { + if (!isCookieAgreed()) { + disableOA(); + return; + } + fetch("https://dsapi.test.osinfra.cn/query/track/openeuler", { + body: JSON.stringify(data), + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + }, + }); + + /** + * 开启埋点上报功能 + * + * 设置上报内容的header信息为浏览器相关信息 + */ + const enableOA = () => { + oa.setHeader(getClientInfo()); + oa.enableReporting(true); + }; + + /** + * 关闭埋点上报功能,清除localStorage中关于埋点的条目 + */ + const disableOA = () => { + oa.enableReporting(false); + [ + "oa-openEuler-client", + "oa-openEuler-events", + "oa-openEuler-session", + ].forEach((key) => { + localStorage.removeItem(key); + }); + }; + + function oaReport(event, eventData, $service = "forum", options) { + return oa.report( + event, + async (...opt) => { + return { + $service, + ...(typeof eventData === "function" + ? await eventData(...opt) + : eventData), + }; + }, + options + ); + } + + /** + * 上报PageView事件 + * @param $referrer 从哪一个页面跳转过来 + */ + const reportPV = ($referrer) => { + oaReport(OpenEventKeys.PV, ($referrer && { $referrer }) || null); + }; + + /** + * 上报性能指标 + */ + const reportPerformance = () => { + oaReport(OpenEventKeys.LCP); + oaReport(OpenEventKeys.INP); + oaReport(OpenEventKeys.PageBasePerformance); + }; + + function listenCookieSet() { + if (isCookieAgreed()) { + enableOA(); + } + const desc = Object.getOwnPropertyDescriptor( + Document.prototype, + "cookie" + ); + Object.defineProperty(Document.prototype, "cookie", { + ...desc, + set(val) { + desc.set.call(this, val); + if (isCookieAgreed()) { + enableOA(); + } else { + disableOA(); + } + }, + }); + } + + function listenHistoryChange() { + let referrer; + + ["replaceState", "pushState"].forEach((method) => { + const native = History.prototype[method]; + History.prototype[method] = function (...args) { + try { + if (oa.enabled) { + const beforePath = location.pathname; + native.call(this, ...args); + const afterPath = location.pathname; + if ( + beforePath.startsWith("/t/topic/") && + afterPath.startsWith("/t/topic/") && + beforePath.split("/")[3] === afterPath.split("/")[3] + ) { + return; + } + if (beforePath !== afterPath) { + reportPV(referrer); + window.dispatchEvent( + new CustomEvent("afterRouteChange", { + detail: { from: beforePath, to: afterPath }, + }) + ); + } + } else { + native.call(this, ...args); + } + } catch { + native.call(this, ...args); + } finally { + referrer = location.href; + } + }; + }); + + window.addEventListener("popstate", () => { + try { + const beforePath = new URL(referrer).pathname; + if (beforePath !== location.pathname) { + setTimeout(() => reportPV(referrer)); + window.dispatchEvent( + new CustomEvent("afterRouteChange", { + detail: { from: beforePath, to: location.pathname }, + }) + ); + } + } finally { + referrer = location.href; + } + }); + } + + listenCookieSet(); + listenHistoryChange(); + reportPV(); + reportPerformance(); + + window._oaReport = oaReport; + window._enableOA = enableOA; + window._disableOA = disableOA; + + reportSidebarClick(); + reportNavigationClick(); + reportTopicClick(); + reportTopicLeave(); + reportTagsClick(); + + reportSearch(); + }); + }, +}; diff --git a/javascripts/discourse/lib/navigation.js b/javascripts/discourse/lib/navigation.js new file mode 100644 index 0000000..e26a345 --- /dev/null +++ b/javascripts/discourse/lib/navigation.js @@ -0,0 +1,55 @@ +import { onNodeInserted } from "./utils.js"; + +export function reportNavigationClick() { + // 类别下拉点击 + onNodeInserted( + ".navigation-container .category-drop.is-expanded .select-kit-collection", + (node) => { + window + .$(node) + .children() + .on("click", (ev) => + window._oaReport("click", { + target: ev.currentTarget.textContent.trim(), + type: "类别", + module: "nav-dropdown", + $url: location.href, + }) + ); + } + ); + // 标签下拉点击 + onNodeInserted( + ".navigation-container .tag-drop.is-expanded .select-kit-collection", + (node) => { + window + .$(node) + .children() + .on("click", (ev) => + window._oaReport("click", { + target: ev.currentTarget.textContent.trim(), + type: "标签", + module: "nav-dropdown", + $url: location.href, + }) + ); + } + ); + // 所有下拉点击 + onNodeInserted( + ".navigation-container .has-selection.is-expanded .select-kit-collection", + (node) => { + window + .$(node) + .children() + .on("click", (ev) => + window._oaReport("click", { + target: ev.currentTarget.textContent.trim(), + type: "所有", + module: "nav-dropdown", + $url: location.href, + }) + ); + } + ); +} diff --git a/javascripts/discourse/lib/search.js b/javascripts/discourse/lib/search.js new file mode 100644 index 0000000..cbf91d1 --- /dev/null +++ b/javascripts/discourse/lib/search.js @@ -0,0 +1,110 @@ +import { debounce, onNodeInserted } from "./utils.js"; + +function onClickSearchInput() { + window._oaReport("click", { + type: "search-input", + module: 'search', + $url: location.href, + }); +} + +function onInputSearchInput(ev) { + window._oaReport("input", { + type: "search-input", + module: 'search', + content: ev.currentTarget.value.trim(), + $url: location.href, + }); +} + +function onClickSearchHistory(ev) { + window._oaReport("click", { + type: "search-history", + module: 'search', + target: ev.currentTarget.textContent.trim(), + $url: location.href, + }); +} + +function onClearSearchHistoryClick() { + window._oaReport("click", { + type: "clear-search-history", + module: 'search', + $url: location.href, + }); +} + +function onClickSuggestion(ev) { + window._oaReport("click", { + type: "search-suggestion", + module: 'search', + target: ev.currentTarget.textContent.trim(), + detail: ev.currentTarget.href, + $url: location.href, + }); +} + +function onClickSearchResultTopic(ev) { + const current$ = window.$(ev.currentTarget); + window._oaReport("click", { + type: "search-result", + module: 'search', + target: current$.find(".first-line").text().trim(), + detail: { + path: ev.currentTarget.href, + categories: current$.find(".badge-category__name").text().trim(), + tags: current$.find(".discourse-tags").text().trim(), + }, + $url: location.href, + }); +} + +export default function reportSearch() { + onNodeInserted( + ".search-input-wrapper input", + (node) => { + window.$(node).on("click", onClickSearchInput); + window.$(node).on("input", debounce(onInputSearchInput, 300)); + window.$(node).on("keydown", (ev) => { + if (ev.key === "Enter") { + window._oaReport("input", { + type: "search", + module: 'search', + content: ev.currentTarget.value.trim(), + $url: location.href, + }); + } + }); + } + ); + + // 历史记录点击 + onNodeInserted( + ".search-menu-panel .search-menu-recent", + (node) => { + window + .$(node) + .children(".search-menu-assistant-item") + .on("click", onClickSearchHistory); + // 清除历史记录 + window + .$(node) + .find(".clear-recent-searches") + .on("click", onClearSearchHistoryClick); + } + ); + + // 联想/帖子结果点击 + onNodeInserted( + ".search-menu-panel .results div[class^=search-result]", + (node) => { + if (node.classList.contains("search-result-topic")) { + // 话题结果点击 + window.$(node).find(".list .item a").on("click", onClickSearchResultTopic); + } else { + // 联想结果点击 + window.$(node).find(".list .item a").on("click", onClickSuggestion); + } + } + ); +} diff --git a/javascripts/discourse/lib/sidebar.js b/javascripts/discourse/lib/sidebar.js new file mode 100644 index 0000000..2e2ee19 --- /dev/null +++ b/javascripts/discourse/lib/sidebar.js @@ -0,0 +1,30 @@ +import { onNodeInserted } from "./utils.js"; + +export function reportSidebarClick() { + onNodeInserted("#sidebar-section-content-categories", (node) => { + window + .$(node) + .children() + .on("click", (ev) => { + window._oaReport("click", { + target: ev.currentTarget.textContent.trim(), + level1: "类别", + module: "sidebar", + $url: location.href, + }); + }); + }); + onNodeInserted("#sidebar-section-content-tags", (node) => { + window + .$(node) + .children() + .on("click", (ev) => { + window._oaReport("click", { + target: ev.currentTarget.textContent.trim(), + level1: "标签", + module: "sidebar", + $url: location.href, + }); + }); + }); +} diff --git a/javascripts/discourse/lib/tags.js b/javascripts/discourse/lib/tags.js new file mode 100644 index 0000000..edf756a --- /dev/null +++ b/javascripts/discourse/lib/tags.js @@ -0,0 +1,20 @@ +function allTagsClick() { + window._oaReport("click", { + target: this.textContent.trim(), + level1: "所有标签", + $url: location.href, + }); +} + +export function reportTagsClick() { + window.addEventListener("afterRouteChange", ({ detail }) => { + if (detail.to === "/tags") { + document + .querySelector("#main-outlet .all-tag-lists .tags-list") + ?.querySelectorAll("tag-box") + .forEach((el) => { + el.querySelector("a").addEventListener("click", allTagsClick); + }); + } + }); +} diff --git a/javascripts/discourse/lib/topic.js b/javascripts/discourse/lib/topic.js new file mode 100644 index 0000000..c0f4b84 --- /dev/null +++ b/javascripts/discourse/lib/topic.js @@ -0,0 +1,74 @@ +import { onNodeInserted } from "./utils.js"; + +function onTopicListClick(ev) { + if (ev.target === ev.currentTarget) { + return; + } + let target = ev.target; + while (!(target instanceof HTMLAnchorElement && target.classList.contains("title"))) { + target = target.parentElement; + if (target === ev.currentTarget || !target) { + return; + } + } + + const mainLink = target.closest(".main-link"); + const categories = [...mainLink.querySelectorAll('.badge-category__wrapper')].map(el => el.textContent.trim()).join(); + const tags = mainLink.querySelector('.discourse-tags')?.textContent?.trim(); + const path = target.getAttribute('href').match(/^\/t\/topic\/[^/]+/)?.[0]; + sessionStorage.setItem('topicRead', JSON.stringify({ + title: target.textContent.trim(), + path, + readTime: Date.now(), + categories, + ...(tags && { tags }), + })); + window._oaReport("click", { + target: target.textContent.trim(), + type: 'topic-click', + $url: location.href, + detail: { + path, + categories, + ...(tags && { tags }), + } + }); +} + +/** + * 点击某个帖子 + */ +export function reportTopicClick() { + onNodeInserted( + '#main-outlet .topic-list .topic-list-body', + node => window.$(node).on('click', onTopicListClick) + ); +} + +export function reportTopicLeave() { + window.addEventListener('afterRouteChange', ({ detail }) => { + if (detail.from.startsWith('/t/topic/')) { + const topicRead = sessionStorage.getItem('topicRead'); + if (!topicRead) { + return; + } + const readInfo = JSON.parse(topicRead); + if (readInfo.path !== detail.from.match(/^\/t\/topic\/[^/]+/)?.[0]) { + return; + } + sessionStorage.removeItem('topicRead'); + window._oaReport( + 'pageLeave', + { + target: readInfo.title, + detail: { + path: readInfo.path, + readTime: Date.now() - Number(readInfo.readTime), + categories: readInfo.categories, + ...(readInfo.tags ? { tags: readInfo.tags } : null), + } + } + ); + } + }); +} diff --git a/javascripts/discourse/lib/utils.js b/javascripts/discourse/lib/utils.js new file mode 100644 index 0000000..2d05428 --- /dev/null +++ b/javascripts/discourse/lib/utils.js @@ -0,0 +1,53 @@ +/** + * @template {function} T + * @param {T} fn fn + * @param {number} delay dealy + * @returns {T} + */ +export function debounce(fn, delay = 10) { + let timeout; + return (...args) => { + if (timeout) { + clearTimeout(timeout); + } + timeout = setTimeout(() => { + fn(...args); + clearTimeout(timeout); + timeout = null; + }, delay); + }; +} + +const seqGenerator = (function* () { + let i = 0; + while (true) { + yield i++; + } +})(); + +/** + * @param {string} selector css selector + * @param {(HTMLElement) => void} callback callback + * @returns cancel function + */ +export function onNodeInserted(selector, callback) { + const css = document.createElement("style"); + const detectorName = `__nodeInserted_${seqGenerator.next().value}`; + css.innerHTML = + `@keyframes ${detectorName} { from { transform: none; } to { transform: none; } }` + + `${selector} { animation-duration: 0.01s; animation-name: ${detectorName}; }`; + document.head.appendChild(css); + + const handler = (event) => { + if (event.animationName === detectorName) { + event.stopImmediatePropagation(); + callback(event.target); + } + }; + document.addEventListener("animationstart", handler); + + return () => { + document.head.removeChild(css); + document.removeEventListener("animationstart", handler); + }; +}