fix: inject Rybbit tracking on m.tkmind.cn publication pages
Memind CI / Test, build, and release guards (push) Successful in 1m36s

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>
This commit is contained in:
john
2026-07-27 22:10:07 +08:00
parent 48c61a3279
commit c7718c683b
9 changed files with 199 additions and 8 deletions
+7 -1
View File
@@ -58,8 +58,14 @@ Generated Page
MEMIND_RYBBIT_ID_SECRET=<random-local-secret> MEMIND_RYBBIT_ID_SECRET=<random-local-secret>
``` ```
If `MEMIND_RYBBIT_ID_SECRET` is omitted, Portal bootstrap reuses the
MindSpace analytics secret stored in `mindspace_config` (the same value
configured below in memind_adm → Analytics 配置).
3. Restart the local Memind server. Full generated HTML pages receive a 3. Restart the local Memind server. Full generated HTML pages receive a
same-origin `/rybbit/script.js` tracker. The tracker identifies the visitor same-origin `/rybbit/script.js` tracker. Publication routes under
`/u/:owner/pages/:slug` and `/MindSpace/:user/public/...` both receive
the tracker. The tracker identifies the visitor
with a stable pseudonymous owner ID, then records engagement events such as with a stable pseudonymous owner ID, then records engagement events such as
`page_click`, scroll depth, and dwell time. Initial pageviews come from `page_click`, scroll depth, and dwell time. Initial pageviews come from
Rybbit's site setting `trackInitialPageView`. Rybbit's site setting `trackInitialPageView`.
+10 -2
View File
@@ -8,10 +8,18 @@ import {
const RYBBIT_MARKER = 'data-memind-rybbit="1"'; const RYBBIT_MARKER = 'data-memind-rybbit="1"';
export function resolveMindSpaceRybbitConfig(env = process.env) { export function resolveMindSpaceRybbitConfig(
env = process.env,
{ idSecret = '' } = {},
) {
const enabled = String(env.MEMIND_RYBBIT_ENABLED ?? '').toLowerCase() === 'true'; const enabled = String(env.MEMIND_RYBBIT_ENABLED ?? '').toLowerCase() === 'true';
const siteId = String(env.MEMIND_RYBBIT_SITE_ID ?? '').trim(); const siteId = String(env.MEMIND_RYBBIT_SITE_ID ?? '').trim();
const secret = String(env.MEMIND_RYBBIT_ID_SECRET ?? env.MEMIND_ANALYTICS_ID_SECRET ?? '').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() const rybbitUrl = String(env.MEMIND_RYBBIT_URL ?? env.RYBBIT_URL ?? 'https://rybbit.tkmind.cn').trim()
|| 'https://rybbit.tkmind.cn'; || 'https://rybbit.tkmind.cn';
return { return {
+11
View File
@@ -26,6 +26,17 @@ test('rybbit config is disabled unless explicitly enabled and configured', () =>
assert.equal(config.scriptPath, '/rybbit/script.js'); assert.equal(config.scriptPath, '/rybbit/script.js');
}); });
test('rybbit config can reuse stored analytics secret', () => {
const config = resolveMindSpaceRybbitConfig({
MEMIND_RYBBIT_ENABLED: 'true',
MEMIND_RYBBIT_SITE_ID: '2',
}, {
idSecret: 'stored-secret',
});
assert.equal(config.enabled, true);
assert.equal(config.idSecret, 'stored-secret');
});
test('injects one local same-origin rybbit tracker with page dimensions', () => { test('injects one local same-origin rybbit tracker with page dimensions', () => {
const html = '<!doctype html><html><head><title>Demo</title></head><body><h1>Demo</h1></body></html>'; const html = '<!doctype html><html><head><title>Demo</title></head><body><h1>Demo</h1></body></html>';
const out = injectMindSpaceRybbit(html, { const out = injectMindSpaceRybbit(html, {
+14 -2
View File
@@ -348,8 +348,15 @@ async function bootstrapUserAuth() {
mindSpaceRuntimeAdapter, mindSpaceRuntimeAdapter,
resolveUserIdByDirKey, resolveUserIdByDirKey,
} = domainServices; } = domainServices;
mindSpaceAnalyticsConfig = Object.assign(
domainServices.mindSpaceAnalyticsConfig; mindSpaceAnalyticsConfig,
domainServices.mindSpaceAnalyticsConfig,
);
Object.assign(
mindSpaceRybbitConfig,
domainServices.mindSpaceRybbitConfig ??
mindSpaceRybbitConfig,
);
scheduleService = domainServices.scheduleService; scheduleService = domainServices.scheduleService;
pageDataService = domainServices.pageDataService; pageDataService = domainServices.pageDataService;
pageDataPublicService = pageDataPublicService =
@@ -1490,6 +1497,11 @@ const sendPublishedPage =
createPortalPublishedPageDelivery({ createPortalPublishedPageDelivery({
registerLongImageArtifact: registerLongImageArtifact:
registerPublishedLongImageArtifactForConversation, registerPublishedLongImageArtifactForConversation,
analyticsConfig: mindSpaceAnalyticsConfig,
rybbitConfig: mindSpaceRybbitConfig,
getAuthPool: () => authPool,
getUserAuth: () => userAuth,
getMindSpacePages: () => mindSpacePages,
}); });
attachPortalPublicationRoutes({ attachPortalPublicationRoutes({
@@ -1,4 +1,5 @@
import { ensureMindSpaceConfig, loadMindSpaceConfig } from '../mindspace-config.mjs'; import { ensureMindSpaceConfig, loadMindSpaceConfig } from '../mindspace-config.mjs';
import { resolveMindSpaceRybbitConfig } from '../mindspace-rybbit.mjs';
import { createMindSearchConfigService } from '../mindsearch-config.mjs'; import { createMindSearchConfigService } from '../mindsearch-config.mjs';
import { createMindSpaceService } from '../mindspace.mjs'; import { createMindSpaceService } from '../mindspace.mjs';
import { import {
@@ -105,6 +106,9 @@ export async function bootstrapPortalDomainServices({
scriptPath: '/analytics/script.js', scriptPath: '/analytics/script.js',
}; };
} }
const resolvedRybbitConfig = resolveMindSpaceRybbitConfig(env, {
idSecret: storedMindSpaceConfig?.analytics?.idSecret ?? '',
});
const scheduleService = createScheduleServiceFn(pool, { const scheduleService = createScheduleServiceFn(pool, {
defaultTimezone: defaultTimezone:
@@ -272,6 +276,7 @@ export async function bootstrapPortalDomainServices({
return { return {
mindSearchConfigService, mindSearchConfigService,
mindSpaceAnalyticsConfig: resolvedAnalyticsConfig, mindSpaceAnalyticsConfig: resolvedAnalyticsConfig,
mindSpaceRybbitConfig: resolvedRybbitConfig,
scheduleService, scheduleService,
pageDataService, pageDataService,
pageDataPublicService, pageDataPublicService,
@@ -61,6 +61,8 @@ function createBootstrapSetup(overrides = {}) {
env: { env: {
H5_DEFAULT_TIMEZONE: 'Asia/Singapore', H5_DEFAULT_TIMEZONE: 'Asia/Singapore',
PLAZA_REDIS_URL: 'redis://plaza', PLAZA_REDIS_URL: 'redis://plaza',
MEMIND_RYBBIT_ENABLED: 'true',
MEMIND_RYBBIT_SITE_ID: '2',
}, },
runtime: { runtime: {
maxFileBytes: 100, maxFileBytes: 100,
@@ -309,6 +311,9 @@ test('domain bootstrap preserves schema and service assembly order', async () =>
result.mindSpaceAnalyticsConfig.scriptPath, result.mindSpaceAnalyticsConfig.scriptPath,
'/analytics/script.js', '/analytics/script.js',
); );
assert.equal(result.mindSpaceRybbitConfig.enabled, true);
assert.equal(result.mindSpaceRybbitConfig.siteId, '2');
assert.equal(result.mindSpaceRybbitConfig.idSecret, 'secret-1');
}); });
test('domain bootstrap preserves delayed user, snapshot, and Page Data dependencies', async () => { test('domain bootstrap preserves delayed user, snapshot, and Page Data dependencies', async () => {
+1 -1
View File
@@ -64,7 +64,7 @@ export function publishedPageShellHtml({ iframeUrl, shareUrl, title, longImageUr
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'self'; script-src 'unsafe-inline'; frame-src 'self'; base-uri 'none'; form-action 'none'"> <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'self'; script-src 'unsafe-inline' 'self'; frame-src 'self'; base-uri 'none'; form-action 'none'">
<title>${safeTitle}</title> <title>${safeTitle}</title>
<style> <style>
* { box-sizing: border-box; } * { box-sizing: border-box; }
+117 -2
View File
@@ -1,9 +1,19 @@
import { import {
injectPublishedPageDataContext, injectPublishedPageDataContext,
resolveMindSpacePageDataContext,
} from '../mindspace-public-page-context.mjs'; } from '../mindspace-public-page-context.mjs';
import { import {
collectInlineScriptHashes, collectInlineScriptHashes,
} from '../mindspace-public-delivery.mjs'; } from '../mindspace-public-delivery.mjs';
import {
injectMindSpaceAnalytics,
resolveAnalyticsOwnerLabel,
resolveAnalyticsOwnerSegment,
resolveAnalyticsPlan,
} from '../mindspace-analytics.mjs';
import {
injectMindSpaceRybbit,
} from '../mindspace-rybbit.mjs';
import { import {
rewriteBrokenMindSpacePublicImageUrls, rewriteBrokenMindSpacePublicImageUrls,
rewritePublicationCanonicalAssetUrls, rewritePublicationCanonicalAssetUrls,
@@ -35,10 +45,97 @@ import {
resolveRequestOrigin, resolveRequestOrigin,
} from './portal-publication-shell.mjs'; } from './portal-publication-shell.mjs';
async function decoratePublicationHtmlAnalytics(
html,
{
result,
analyticsConfig,
rybbitConfig,
getAuthPool = () => null,
getUserAuth = () => null,
getMindSpacePages = () => null,
resolvePageDataContext =
resolveMindSpacePageDataContext,
logger = console,
} = {},
) {
const source = String(html ?? '');
const isFullHtml =
/^\s*<!doctype html/i.test(source) ||
/^\s*<html[\s>]/i.test(source);
if (!isFullHtml) return source;
const userAuth = getUserAuth?.();
const pageOwner =
result?.ownerId && userAuth
? await userAuth
.getUserById(result.ownerId)
.catch(() => null)
: null;
let pageDataContext = null;
const mindSpacePages = getMindSpacePages?.();
if (
result?.ownerId &&
result?.workspaceRelativePath &&
mindSpacePages
) {
pageDataContext = await resolvePageDataContext({
pool: getAuthPool?.(),
pageService: mindSpacePages,
userId: result.ownerId,
relativePath: result.workspaceRelativePath,
logger,
}).catch(() => null);
}
const ownerSegment = resolveAnalyticsOwnerSegment(
pageOwner ?? {},
);
const ownerLabel = resolveAnalyticsOwnerLabel(
pageOwner ?? {},
);
const pageId =
pageDataContext?.pageId ??
result?.pageSource?.pageId ??
'';
const publicationId =
pageDataContext?.publicationId ??
pageDataContext?.publication_id ??
result?.publication?.id ??
'';
let decorated = injectMindSpaceAnalytics(source, {
ownerId: result?.ownerId ?? '',
ownerSegment,
ownerLabel,
planType: resolveAnalyticsPlan(pageOwner ?? {}),
generatedAt: pageDataContext?.generatedAt ?? '',
pageId,
publicationId,
config: analyticsConfig,
});
decorated = injectMindSpaceRybbit(decorated, {
ownerId: result?.ownerId ?? '',
ownerSegment,
ownerLabel,
pageId,
publicationId,
channel: 'publication',
config: rybbitConfig,
});
return decorated;
}
export function createPortalPublishedPageDelivery({ export function createPortalPublishedPageDelivery({
registerLongImageArtifact, registerLongImageArtifact,
renderLongImage = renderLongImage =
renderLongImageBuffer, renderLongImageBuffer,
analyticsConfig = { enabled: false },
rybbitConfig = { enabled: false },
getAuthPool = () => null,
getUserAuth = () => null,
getMindSpacePages = () => null,
logger = console,
} = {}) { } = {}) {
if ( if (
typeof registerLongImageArtifact !== typeof registerLongImageArtifact !==
@@ -114,6 +211,15 @@ export function createPortalPublishedPageDelivery({
publication: result.publication, publication: result.publication,
}, },
); );
html = await decoratePublicationHtmlAnalytics(html, {
result,
analyticsConfig,
rybbitConfig,
getAuthPool,
getUserAuth,
getMindSpacePages,
logger,
});
if (!embed) { if (!embed) {
try { try {
html = injectOgTags(html, { html = injectOgTags(html, {
@@ -236,6 +342,15 @@ export function createPortalPublishedPageDelivery({
} catch { } catch {
// Keep the share shell usable. // Keep the share shell usable.
} }
shellHtml = await decoratePublicationHtmlAnalytics(shellHtml, {
result,
analyticsConfig,
rybbitConfig,
getAuthPool,
getUserAuth,
getMindSpacePages,
logger,
});
res.set( res.set(
'Content-Type', 'Content-Type',
'text/html; charset=utf-8', 'text/html; charset=utf-8',
@@ -243,8 +358,8 @@ export function createPortalPublishedPageDelivery({
res.set( res.set(
'Content-Security-Policy', 'Content-Security-Policy',
wechatShare wechatShare
? "default-src 'none'; style-src 'unsafe-inline'; img-src data: https:; font-src 'none'; connect-src 'self' https://cdn.jsdelivr.net; script-src 'unsafe-inline' https://cdn.jsdelivr.net https://res.wx.qq.com; frame-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'" ? "default-src 'none'; style-src 'unsafe-inline'; img-src data: https:; font-src 'none'; connect-src 'self' https://cdn.jsdelivr.net; script-src 'unsafe-inline' 'self' https://cdn.jsdelivr.net https://res.wx.qq.com; frame-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'"
: "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'self' https://cdn.jsdelivr.net; script-src 'unsafe-inline' https://cdn.jsdelivr.net; frame-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'", : "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'self' https://cdn.jsdelivr.net; script-src 'unsafe-inline' 'self' https://cdn.jsdelivr.net; frame-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'",
); );
res.set( res.set(
'Cache-Control', 'Cache-Control',
@@ -61,6 +61,35 @@ function createResult(overrides = {}) {
}; };
} }
test('injects rybbit tracker for publication HTML delivery', async () => {
const sendPublishedPage =
createPortalPublishedPageDelivery({
async registerLongImageArtifact() {},
rybbitConfig: {
enabled: true,
siteId: '2',
idSecret: 'secret',
scriptPath: '/rybbit/script.js',
hostPath: '/rybbit',
},
});
const response = createResponse();
await sendPublishedPage(
createRequest(),
response,
{
...createResult(),
ownerId: 'user-123',
pageSource: { pageId: 'page-1' },
publication: { id: 'pub-1', accessMode: 'login_required' },
},
{ raw: true },
);
assert.match(response.body, /src="\/rybbit\/script\.js"/);
assert.match(response.body, /data-site-id="2"/);
assert.doesNotMatch(response.body, /user-123/);
});
test('wraps a normal full HTML publication in the share shell', async () => { test('wraps a normal full HTML publication in the share shell', async () => {
const sendPublishedPage = const sendPublishedPage =
createPortalPublishedPageDelivery({ createPortalPublishedPageDelivery({