feat(seo): index public online pages without confirmation
Memind CI / Test, build, and release guards (push) Failing after 5m19s
Memind CI / Test, build, and release guards (push) Failing after 5m19s
Drop the user_confirmed_at gate so public, online, unexpired pages can enter sitemap/llms and receive SEO/GEO tags. Defaults now enable all discovery switches; stored all-off config is still preserved until admin saves. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+4
-4
@@ -205,15 +205,15 @@ Plaza 本地开发说明见 [plaza-local.md](./plaza-local.md)。生产发布、
|
|||||||
|
|
||||||
## MindSpace SEO / GEO(本地联调)
|
## MindSpace SEO / GEO(本地联调)
|
||||||
|
|
||||||
默认关闭,与生产一致。在 **memind_adm**(见上表)打开 **MindSpace 配置**,保存 **SEO / GEO** 卡片中的总开关与子开关。
|
默认开启。在 **memind_adm**(见上表)打开 **MindSpace 配置**,可按需关闭 **SEO / GEO** 卡片中的总开关与子开关。
|
||||||
|
|
||||||
| 开关 | 作用 |
|
| 开关 | 作用 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 总开关 `seoGeo.enabled` | 关闭时交付链与改前一致;开启后按收录策略注入 meta / JSON-LD |
|
| 总开关 `seoGeo.enabled` | 关闭时不注入 meta / JSON-LD;开启后按收录策略注入 |
|
||||||
| `seo.sitemap` / `seo.robots` / `geo.llms` | 控制 `GET /sitemap.xml`、`/robots.txt`、`/llms.txt` |
|
| `seo.sitemap` / `seo.robots` / `geo.llms` | 控制 `GET /sitemap.xml`、`/robots.txt`、`/llms.txt` |
|
||||||
| `seo.baiduPush` | 用户确认公开 MindSpace 页或 Plaza 发帖后推送百度 |
|
| `seo.baiduPush` | 公开 MindSpace 页或 Plaza 发帖后推送百度 |
|
||||||
|
|
||||||
可索引页条件:`public` + `online` + `user_confirmed_at` 非空。私有页、未确认公开、embed 预览一律 `noindex`。
|
可索引页条件:`public` + `online` + 未过期。密码、登录可见、owner_only、已过期、embed 预览一律 `noindex`。
|
||||||
|
|
||||||
本地验证:
|
本地验证:
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
|
|
||||||
## 保护内容
|
## 保护内容
|
||||||
|
|
||||||
1. **收录策略硬规则**:仅 `access_mode=public` 且 `status=online` 且 `user_confirmed_at` 非空的发布页允许 SEO/GEO 注入与 sitemap/llms 收录。
|
1. **收录策略硬规则**:仅 `access_mode=public` 且 `status=online` 且未过期的发布页允许 SEO/GEO 注入与 sitemap/llms 收录。不再要求 `user_confirmed_at`。
|
||||||
2. **私有页强制 noindex**:密码、登录可见、owner_only、未确认公开、工作区预览直链等必须注入 `noindex, nofollow` 并设置 `X-Robots-Tag`。
|
2. **私有页强制 noindex**:密码、登录可见、owner_only、已过期、工作区预览直链等必须注入 `noindex, nofollow` 并设置 `X-Robots-Tag`。
|
||||||
3. **总开关默认关闭**:`mindspace_config.seo_geo_config.enabled=false` 时,交付链行为与未上线前一致。
|
3. **总开关默认开启**:`mindspace_config.seo_geo_config` 缺省为全开;库内已保存的旧值仍以数据库为准,需在 memind_adm MindSpace 配置页保存后才会改写生产。
|
||||||
4. **配置来源**:memind_adm MindSpace 配置页 → `PATCH /admin-api/mindspace/config` → Portal `loadMindSpaceConfigCached()`。
|
4. **配置来源**:memind_adm MindSpace 配置页 → `PATCH /admin-api/mindspace/config` → Portal `loadMindSpaceConfigCached()`。
|
||||||
5. **百度推送**:仅在 admin 开启 `seo.baiduPush` 且页面可索引时触发;MindSpace 确认公开与 Plaza 发帖共用该开关。
|
5. **百度推送**:仅在 admin 开启 `seo.baiduPush` 且页面可索引时触发;公开页发布与 Plaza 发帖共用该开关。
|
||||||
|
|
||||||
## 必跑验证
|
## 必跑验证
|
||||||
|
|
||||||
|
|||||||
@@ -59,18 +59,18 @@ function parseJsonObject(value, fallback = {}) {
|
|||||||
|
|
||||||
export function defaultSeoGeoConfig() {
|
export function defaultSeoGeoConfig() {
|
||||||
return {
|
return {
|
||||||
enabled: false,
|
enabled: true,
|
||||||
seo: {
|
seo: {
|
||||||
enabled: false,
|
enabled: true,
|
||||||
canonical: true,
|
canonical: true,
|
||||||
sitemap: false,
|
sitemap: true,
|
||||||
robotsTxt: false,
|
robotsTxt: true,
|
||||||
baiduPush: false,
|
baiduPush: true,
|
||||||
},
|
},
|
||||||
geo: {
|
geo: {
|
||||||
enabled: false,
|
enabled: true,
|
||||||
jsonLd: false,
|
jsonLd: true,
|
||||||
llmsTxt: false,
|
llmsTxt: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,18 @@ import {
|
|||||||
updateMindSpaceConfig,
|
updateMindSpaceConfig,
|
||||||
} from './mindspace-config.mjs';
|
} from './mindspace-config.mjs';
|
||||||
|
|
||||||
|
test('defaultSeoGeoConfig enables all discovery switches', () => {
|
||||||
|
const config = defaultSeoGeoConfig();
|
||||||
|
assert.equal(config.enabled, true);
|
||||||
|
assert.equal(config.seo.enabled, true);
|
||||||
|
assert.equal(config.seo.sitemap, true);
|
||||||
|
assert.equal(config.seo.robotsTxt, true);
|
||||||
|
assert.equal(config.seo.baiduPush, true);
|
||||||
|
assert.equal(config.geo.enabled, true);
|
||||||
|
assert.equal(config.geo.jsonLd, true);
|
||||||
|
assert.equal(config.geo.llmsTxt, true);
|
||||||
|
});
|
||||||
|
|
||||||
test('defaultMindSpaceConfig falls back to env or 5', () => {
|
test('defaultMindSpaceConfig falls back to env or 5', () => {
|
||||||
assert.equal(defaultMindSpaceConfig({ MINDSPACE_FREE_PUBLIC_PAGE_LIMIT: '8' }).publicPageLimit, 8);
|
assert.equal(defaultMindSpaceConfig({ MINDSPACE_FREE_PUBLIC_PAGE_LIMIT: '8' }).publicPageLimit, 8);
|
||||||
assert.equal(defaultMindSpaceConfig({ MINDSPACE_FREE_PUBLIC_PAGE_LIMIT: '0' }).publicPageLimit, 5);
|
assert.equal(defaultMindSpaceConfig({ MINDSPACE_FREE_PUBLIC_PAGE_LIMIT: '0' }).publicPageLimit, 5);
|
||||||
@@ -24,7 +36,7 @@ test('normalizeSeoGeoConfig merges nested switches', () => {
|
|||||||
assert.equal(config.enabled, true);
|
assert.equal(config.enabled, true);
|
||||||
assert.equal(config.seo.sitemap, true);
|
assert.equal(config.seo.sitemap, true);
|
||||||
assert.equal(config.geo.llmsTxt, true);
|
assert.equal(config.geo.llmsTxt, true);
|
||||||
assert.equal(config.seo.baiduPush, false);
|
assert.equal(config.seo.baiduPush, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ensureMindSpaceConfig seeds the default row only when empty', async () => {
|
test('ensureMindSpaceConfig seeds the default row only when empty', async () => {
|
||||||
@@ -45,6 +57,31 @@ test('ensureMindSpaceConfig seeds the default row only when empty', async () =>
|
|||||||
assert.deepEqual(calls[2].params.slice(0, 3), ['public_page_limit', '9', '公开页面数量上限']);
|
assert.deepEqual(calls[2].params.slice(0, 3), ['public_page_limit', '9', '公开页面数量上限']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('loadMindSpaceConfig keeps stored all-off seoGeo instead of new defaults', async () => {
|
||||||
|
const pool = {
|
||||||
|
async query(sql) {
|
||||||
|
if (sql.includes('FROM mindspace_config')) {
|
||||||
|
return [[
|
||||||
|
{
|
||||||
|
key: 'seo_geo_config',
|
||||||
|
value: JSON.stringify({
|
||||||
|
enabled: false,
|
||||||
|
seo: { enabled: false, canonical: true, sitemap: false, robotsTxt: false, baiduPush: false },
|
||||||
|
geo: { enabled: false, jsonLd: false, llmsTxt: false },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]];
|
||||||
|
}
|
||||||
|
return [[]];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = await loadMindSpaceConfig(pool);
|
||||||
|
assert.equal(config.seoGeo.enabled, false);
|
||||||
|
assert.equal(config.seoGeo.seo.sitemap, false);
|
||||||
|
assert.equal(config.seoGeo.geo.jsonLd, false);
|
||||||
|
});
|
||||||
|
|
||||||
test('loadMindSpaceConfig merges stored config with fallback', async () => {
|
test('loadMindSpaceConfig merges stored config with fallback', async () => {
|
||||||
const pool = {
|
const pool = {
|
||||||
async query(sql) {
|
async query(sql) {
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ export function isPublicationIndexable(publication, { now = Date.now() } = {}) {
|
|||||||
if (!snapshot) return false;
|
if (!snapshot) return false;
|
||||||
if (snapshot.status !== 'online') return false;
|
if (snapshot.status !== 'online') return false;
|
||||||
if (snapshot.accessMode !== INDEXABLE_ACCESS_MODE) return false;
|
if (snapshot.accessMode !== INDEXABLE_ACCESS_MODE) return false;
|
||||||
if (snapshot.userConfirmedAt == null) return false;
|
|
||||||
if (snapshot.expiresAt != null && snapshot.expiresAt <= now) return false;
|
if (snapshot.expiresAt != null && snapshot.expiresAt <= now) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -74,7 +73,7 @@ export function resolveIndexPolicy({
|
|||||||
return {
|
return {
|
||||||
mode: 'indexable',
|
mode: 'indexable',
|
||||||
injectNoindex: false,
|
injectNoindex: false,
|
||||||
reason: 'public_confirmed',
|
reason: 'public_online',
|
||||||
seoEnabled: Boolean(seoGeoConfig.seo?.enabled),
|
seoEnabled: Boolean(seoGeoConfig.seo?.enabled),
|
||||||
geoEnabled: Boolean(seoGeoConfig.geo?.enabled),
|
geoEnabled: Boolean(seoGeoConfig.geo?.enabled),
|
||||||
canonicalEnabled: seoGeoConfig.seo?.canonical !== false,
|
canonicalEnabled: seoGeoConfig.seo?.canonical !== false,
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ test('isPublicationIndexable accepts confirmed public online publications', () =
|
|||||||
assert.equal(isPublicationIndexable(indexablePublication), true);
|
assert.equal(isPublicationIndexable(indexablePublication), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('isPublicationIndexable accepts public online pages without confirmation', () => {
|
||||||
|
assert.equal(
|
||||||
|
isPublicationIndexable({ ...indexablePublication, userConfirmedAt: null }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('isPublicationIndexable rejects private access modes', () => {
|
test('isPublicationIndexable rejects private access modes', () => {
|
||||||
for (const accessMode of ['password', 'private_link', 'login_required', 'owner_only']) {
|
for (const accessMode of ['password', 'private_link', 'login_required', 'owner_only']) {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -44,7 +51,7 @@ test('resolveIndexPolicy noindexes non-public publications when enabled', () =>
|
|||||||
assert.equal(policy.injectNoindex, true);
|
assert.equal(policy.injectNoindex, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolveIndexPolicy marks confirmed public pages indexable', () => {
|
test('resolveIndexPolicy marks public online pages indexable', () => {
|
||||||
const policy = resolveIndexPolicy({
|
const policy = resolveIndexPolicy({
|
||||||
seoGeoConfig: {
|
seoGeoConfig: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ export async function listIndexablePublications(
|
|||||||
LEFT JOIN h5_page_records p ON p.id = pr.page_id
|
LEFT JOIN h5_page_records p ON p.id = pr.page_id
|
||||||
WHERE pr.status = 'online'
|
WHERE pr.status = 'online'
|
||||||
AND pr.access_mode = 'public'
|
AND pr.access_mode = 'public'
|
||||||
AND pr.user_confirmed_at IS NOT NULL
|
|
||||||
AND (pr.expires_at IS NULL OR pr.expires_at > ?)
|
AND (pr.expires_at IS NULL OR pr.expires_at > ?)
|
||||||
ORDER BY pr.published_at DESC
|
ORDER BY pr.published_at DESC
|
||||||
LIMIT ? OFFSET ?`,
|
LIMIT ? OFFSET ?`,
|
||||||
@@ -153,7 +152,7 @@ export function renderRobotsTxt({
|
|||||||
export function renderLlmsTxt(entries, { origin = '' } = {}) {
|
export function renderLlmsTxt(entries, { origin = '' } = {}) {
|
||||||
const header = [
|
const header = [
|
||||||
'# TKMind MindSpace public pages',
|
'# TKMind MindSpace public pages',
|
||||||
'# Only confirmed public publications are listed here.',
|
'# Public online publications are listed here.',
|
||||||
'',
|
'',
|
||||||
];
|
];
|
||||||
const body = entries
|
const body = entries
|
||||||
|
|||||||
@@ -108,6 +108,6 @@ test('listAdminSeoGeoPublicationCatalog merges zero-traffic pages with view stat
|
|||||||
assert.equal(result.data[0].indexable, true);
|
assert.equal(result.data[0].indexable, true);
|
||||||
assert.equal(result.data[1].publicationId, 'pub-2');
|
assert.equal(result.data[1].publicationId, 'pub-2');
|
||||||
assert.equal(result.data[1].totalViews, 0);
|
assert.equal(result.data[1].totalViews, 0);
|
||||||
assert.equal(result.data[1].indexable, false);
|
assert.equal(result.data[1].indexable, true);
|
||||||
assert.match(result.data[1].publicUrl, /^https:\/\/m\.tkmind\.cn\//);
|
assert.match(result.data[1].publicUrl, /^https:\/\/m\.tkmind\.cn\//);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ test('decorateMindSpaceSeoGeoHtml noindexes private publications', () => {
|
|||||||
assert.match(result.html, /noindex, nofollow/);
|
assert.match(result.html, /noindex, nofollow/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('decorateMindSpaceSeoGeoHtml injects seo and geo for public pages', () => {
|
test('decorateMindSpaceSeoGeoHtml injects seo and geo for unconfirmed public pages', () => {
|
||||||
const result = decorateMindSpaceSeoGeoHtml(
|
const result = decorateMindSpaceSeoGeoHtml(
|
||||||
'<html><head><title>Demo</title><meta name="description" content="摘要"></head><body></body></html>',
|
'<html><head><title>Demo</title><meta name="description" content="摘要"></head><body></body></html>',
|
||||||
{
|
{
|
||||||
@@ -32,7 +32,7 @@ test('decorateMindSpaceSeoGeoHtml injects seo and geo for public pages', () => {
|
|||||||
publication: {
|
publication: {
|
||||||
status: 'online',
|
status: 'online',
|
||||||
accessMode: 'public',
|
accessMode: 'public',
|
||||||
userConfirmedAt: Date.now(),
|
userConfirmedAt: null,
|
||||||
publicUrl: '/u/john/pages/demo',
|
publicUrl: '/u/john/pages/demo',
|
||||||
},
|
},
|
||||||
context: {
|
context: {
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ npm run check:mindspace-cover
|
|||||||
|
|
||||||
## SEO / GEO 元数据(推荐)
|
## SEO / GEO 元数据(推荐)
|
||||||
|
|
||||||
平台在 memind_adm **MindSpace 配置 → SEO / GEO** 开启后,会对**已确认公开**(`access_mode=public` 且用户已确认)的发布页自动注入 canonical、结构化数据等。Agent 仍应写好基础 meta,以提升搜索与 AI 引用质量:
|
平台在 memind_adm **MindSpace 配置 → SEO / GEO** 开启后,会对**公开在线**(`access_mode=public` 且 `status=online`)的发布页自动注入 canonical、结构化数据等。Agent 仍应写好基础 meta,以提升搜索与 AI 引用质量:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<title>页面真实主题</title>
|
<title>页面真实主题</title>
|
||||||
|
|||||||
Reference in New Issue
Block a user