Files
memind/mindspace-rybbit.mjs
john c7718c683b
Memind CI / Test, build, and release guards (push) Successful in 1m36s
fix: inject Rybbit tracking on m.tkmind.cn publication pages
Publication routes and share shells were missing the Rybbit tracker, and
bootstrap now reuses the stored MindSpace analytics secret when Rybbit env
secret is absent.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 22:10:07 +08:00

123 lines
6.0 KiB
JavaScript

import crypto from 'node:crypto';
import {
pseudonymizeAnalyticsId,
resolveAnalyticsOwnerLabel,
resolveAnalyticsOwnerSegment,
} from './mindspace-analytics.mjs';
const RYBBIT_MARKER = 'data-memind-rybbit="1"';
export function resolveMindSpaceRybbitConfig(
env = process.env,
{ idSecret = '' } = {},
) {
const enabled = String(env.MEMIND_RYBBIT_ENABLED ?? '').toLowerCase() === 'true';
const siteId = String(env.MEMIND_RYBBIT_SITE_ID ?? '').trim();
const secret = String(
env.MEMIND_RYBBIT_ID_SECRET ??
env.MEMIND_ANALYTICS_ID_SECRET ??
idSecret ??
'',
).trim();
const rybbitUrl = String(env.MEMIND_RYBBIT_URL ?? env.RYBBIT_URL ?? 'https://rybbit.tkmind.cn').trim()
|| 'https://rybbit.tkmind.cn';
return {
enabled: enabled && Boolean(siteId) && Boolean(secret),
siteId,
idSecret: secret,
rybbitUrl: rybbitUrl.replace(/\/$/, ''),
// Same-origin proxy path. Script auto-discovers host by splitting on /script.js,
// so /rybbit/script.js makes events go to /rybbit/track → proxied to /api/track.
scriptPath: String(env.MEMIND_RYBBIT_SCRIPT_PATH ?? '/rybbit/script.js').trim() || '/rybbit/script.js',
hostPath: String(env.MEMIND_RYBBIT_HOST_PATH ?? '/rybbit').trim() || '/rybbit',
};
}
export { resolveAnalyticsOwnerLabel, resolveAnalyticsOwnerSegment, pseudonymizeAnalyticsId };
function jsonForInlineScript(value) {
return JSON.stringify(value)
.replaceAll('<', '\\u003c')
.replaceAll('>', '\\u003e')
.replaceAll('&', '\\u0026');
}
export function sendMindSpaceRybbitEvent({
config,
eventName,
ownerId,
pageId = '',
publicationId = '',
agentRunId = '',
channel = 'h5',
ownerSegment = 'unknown',
ownerLabel = '未命名用户',
url = '',
} = {}) {
if (!config?.enabled || !config.siteId || !config.idSecret || !eventName) return Promise.resolve(false);
const owner = pseudonymizeAnalyticsId(ownerId, config.idSecret);
if (!owner) return Promise.resolve(false);
const endpoint = `${String(config.rybbitUrl || 'https://rybbit.tkmind.cn').replace(/\/$/, '')}/api/track`;
const payload = {
site_id: String(config.siteId),
type: 'custom_event',
event_name: String(eventName).slice(0, 256),
pathname: url || '/',
page_title: '',
referrer: '',
hostname: '127.0.0.1',
user_id: owner,
properties: JSON.stringify({
owner_id: owner,
page_id: String(pageId || ''),
publication_id: String(publicationId || ''),
agent_run_id: String(agentRunId || ''),
channel,
owner_segment: String(ownerSegment || 'unknown'),
owner_label: resolveAnalyticsOwnerLabel({ displayName: ownerLabel }),
}),
};
return fetch(endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json', 'user-agent': 'Memind/local-rybbit' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(1500),
}).then((response) => response.ok).catch(() => false);
}
export function injectMindSpaceRybbit(html, {
ownerId,
pageId = '',
publicationId = '',
ownerSegment = 'unknown',
ownerLabel = '未命名用户',
channel = 'h5',
config = resolveMindSpaceRybbitConfig(),
} = {}) {
const source = String(html ?? '');
if (!config?.enabled || !config.siteId || !/^\s*(<!doctype html|<html\b)/i.test(source)) return source;
if (source.includes(RYBBIT_MARKER)) return source;
const owner = pseudonymizeAnalyticsId(ownerId, config.idSecret);
if (!owner) return source;
const metadata = {
owner_id: owner,
username: resolveAnalyticsOwnerLabel({ displayName: ownerLabel }),
owner_segment: String(ownerSegment || 'unknown'),
page_id: String(pageId || ''),
publication_id: String(publicationId || ''),
channel,
};
const attrs = [
RYBBIT_MARKER,
`data-site-id="${String(config.siteId).replaceAll('"', '&quot;')}"`,
'defer',
`src="${config.scriptPath}"`,
];
// Manual identify + engagement events; keep initial pageview from Rybbit site settings.
const block = `<script ${attrs.join(' ')}></script><script ${RYBBIT_MARKER}>(function(){var d=${jsonForInlineScript(metadata)},seen={},tries=0;function api(){return window.rybbit;}function t(n,x){var r=api();if(!r||typeof r.event!=='function')return;var p=Object.assign({},d,{page_url:location.href,page_title:document.title},x||{});r.event(n,p);}function identify(){var r=api();if(!r||typeof r.identify!=='function')return;r.identify(d.owner_id,{username:d.username,memind_page_url:location.href,owner_segment:d.owner_segment,channel:d.channel,page_id:d.page_id,publication_id:d.publication_id});}function once(n,x){if(seen[n])return;seen[n]=1;t(n,x);}function wire(){identify();document.addEventListener('click',function(e){var el=e.target&&e.target.closest?e.target.closest('a,button,[role="button"],[data-rybbit-event],[data-umami-event]'):null;if(!el)return;var custom=el.getAttribute('data-rybbit-event')||el.getAttribute('data-umami-event');var href=el.tagName==='A'?el.getAttribute('href')||'':'';t(custom||'page_click',{element:el.tagName.toLowerCase(),element_id:el.id||'',event_label:(custom||el.getAttribute('aria-label')||'').slice(0,100),target_url:href.slice(0,500)});},{passive:true});document.addEventListener('submit',function(e){var form=e.target;t('page_form_submit',{form_id:form&&form.id||'',form_action:form&&form.getAttribute('action')||''});},{passive:true});var marks=[25,50,75,90];function scroll(){var h=document.documentElement.scrollHeight-window.innerHeight;if(h<=0){once('page_scroll_100');return;}var pct=Math.round(window.scrollY/h*100);marks.forEach(function(m){if(pct>=m)once('page_scroll_'+m);});}window.addEventListener('scroll',scroll,{passive:true});setTimeout(function(){once('page_engaged_10s');},10000);setTimeout(function(){once('page_engaged_30s');},30000);scroll();}function ready(){if(api()){wire();return;}if(tries++>40)return;setTimeout(ready,50);}if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',ready,{once:true});}else{ready();}})();</script>`;
if (/<\/head>/i.test(source)) return source.replace(/<\/head>/i, `${block}</head>`);
return source.replace(/<body\b/i, `${block}<body`);
}