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>
```
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
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
`page_click`, scroll depth, and dwell time. Initial pageviews come from
Rybbit's site setting `trackInitialPageView`.
+10 -2
View File
@@ -8,10 +8,18 @@ import {
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 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()
|| 'https://rybbit.tkmind.cn';
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');
});
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', () => {
const html = '<!doctype html><html><head><title>Demo</title></head><body><h1>Demo</h1></body></html>';
const out = injectMindSpaceRybbit(html, {
+14 -2
View File
@@ -348,8 +348,15 @@ async function bootstrapUserAuth() {
mindSpaceRuntimeAdapter,
resolveUserIdByDirKey,
} = domainServices;
mindSpaceAnalyticsConfig =
domainServices.mindSpaceAnalyticsConfig;
Object.assign(
mindSpaceAnalyticsConfig,
domainServices.mindSpaceAnalyticsConfig,
);
Object.assign(
mindSpaceRybbitConfig,
domainServices.mindSpaceRybbitConfig ??
mindSpaceRybbitConfig,
);
scheduleService = domainServices.scheduleService;
pageDataService = domainServices.pageDataService;
pageDataPublicService =
@@ -1490,6 +1497,11 @@ const sendPublishedPage =
createPortalPublishedPageDelivery({
registerLongImageArtifact:
registerPublishedLongImageArtifactForConversation,
analyticsConfig: mindSpaceAnalyticsConfig,
rybbitConfig: mindSpaceRybbitConfig,
getAuthPool: () => authPool,
getUserAuth: () => userAuth,
getMindSpacePages: () => mindSpacePages,
});
attachPortalPublicationRoutes({
@@ -1,4 +1,5 @@
import { ensureMindSpaceConfig, loadMindSpaceConfig } from '../mindspace-config.mjs';
import { resolveMindSpaceRybbitConfig } from '../mindspace-rybbit.mjs';
import { createMindSearchConfigService } from '../mindsearch-config.mjs';
import { createMindSpaceService } from '../mindspace.mjs';
import {
@@ -105,6 +106,9 @@ export async function bootstrapPortalDomainServices({
scriptPath: '/analytics/script.js',
};
}
const resolvedRybbitConfig = resolveMindSpaceRybbitConfig(env, {
idSecret: storedMindSpaceConfig?.analytics?.idSecret ?? '',
});
const scheduleService = createScheduleServiceFn(pool, {
defaultTimezone:
@@ -272,6 +276,7 @@ export async function bootstrapPortalDomainServices({
return {
mindSearchConfigService,
mindSpaceAnalyticsConfig: resolvedAnalyticsConfig,
mindSpaceRybbitConfig: resolvedRybbitConfig,
scheduleService,
pageDataService,
pageDataPublicService,
@@ -61,6 +61,8 @@ function createBootstrapSetup(overrides = {}) {
env: {
H5_DEFAULT_TIMEZONE: 'Asia/Singapore',
PLAZA_REDIS_URL: 'redis://plaza',
MEMIND_RYBBIT_ENABLED: 'true',
MEMIND_RYBBIT_SITE_ID: '2',
},
runtime: {
maxFileBytes: 100,
@@ -309,6 +311,9 @@ test('domain bootstrap preserves schema and service assembly order', async () =>
result.mindSpaceAnalyticsConfig.scriptPath,
'/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 () => {
+1 -1
View File
@@ -64,7 +64,7 @@ export function publishedPageShellHtml({ iframeUrl, shareUrl, title, longImageUr
<head>
<meta charset="utf-8">
<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>
<style>
* { box-sizing: border-box; }
+117 -2
View File
@@ -1,9 +1,19 @@
import {
injectPublishedPageDataContext,
resolveMindSpacePageDataContext,
} from '../mindspace-public-page-context.mjs';
import {
collectInlineScriptHashes,
} from '../mindspace-public-delivery.mjs';
import {
injectMindSpaceAnalytics,
resolveAnalyticsOwnerLabel,
resolveAnalyticsOwnerSegment,
resolveAnalyticsPlan,
} from '../mindspace-analytics.mjs';
import {
injectMindSpaceRybbit,
} from '../mindspace-rybbit.mjs';
import {
rewriteBrokenMindSpacePublicImageUrls,
rewritePublicationCanonicalAssetUrls,
@@ -35,10 +45,97 @@ import {
resolveRequestOrigin,
} 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({
registerLongImageArtifact,
renderLongImage =
renderLongImageBuffer,
analyticsConfig = { enabled: false },
rybbitConfig = { enabled: false },
getAuthPool = () => null,
getUserAuth = () => null,
getMindSpacePages = () => null,
logger = console,
} = {}) {
if (
typeof registerLongImageArtifact !==
@@ -114,6 +211,15 @@ export function createPortalPublishedPageDelivery({
publication: result.publication,
},
);
html = await decoratePublicationHtmlAnalytics(html, {
result,
analyticsConfig,
rybbitConfig,
getAuthPool,
getUserAuth,
getMindSpacePages,
logger,
});
if (!embed) {
try {
html = injectOgTags(html, {
@@ -236,6 +342,15 @@ export function createPortalPublishedPageDelivery({
} catch {
// Keep the share shell usable.
}
shellHtml = await decoratePublicationHtmlAnalytics(shellHtml, {
result,
analyticsConfig,
rybbitConfig,
getAuthPool,
getUserAuth,
getMindSpacePages,
logger,
});
res.set(
'Content-Type',
'text/html; charset=utf-8',
@@ -243,8 +358,8 @@ export function createPortalPublishedPageDelivery({
res.set(
'Content-Security-Policy',
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:; 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: 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' 'self' https://cdn.jsdelivr.net; frame-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'",
);
res.set(
'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 () => {
const sendPublishedPage =
createPortalPublishedPageDelivery({