fix(page-data): trust live API smoke and WeChat survey submit compat

Stop blocking WeChat delivery when Page Data insert works but publish
quota blocks h5_publish_records; fix smoke URL missing /api; patch survey
radios for WeChat X5 and detect dataset constants in admin pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-12 22:42:47 +08:00
parent 2f7bc3b6e4
commit 502334777e
10 changed files with 243 additions and 43 deletions
+29 -8
View File
@@ -12,6 +12,7 @@ import { createPageService } from './mindspace-pages.mjs';
import {
assessPageDataHtmlBinding,
assessWorkspacePageDataReadiness,
normalizePageDataApiBase,
verifyPageDataDeliveryArtifacts,
} from './page-data-delivery-assess.mjs';
import { buildPublicUrl, resolvePublicBaseUrl } from './user-publish.mjs';
@@ -228,8 +229,7 @@ export function extractRecentPageDataHtmlWrites(messages = [], { sinceMs = 0 } =
if (item?.type !== 'toolRequest') continue;
const toolCall = item.toolCall?.value;
const name = String(toolCall?.name ?? '').trim();
const normalizedName = name.split('__').at(-1);
if (!['write_file', 'edit_file', 'write', 'edit'].includes(normalizedName)) continue;
if (!['write_file', 'edit_file', 'write', 'edit'].includes(name)) continue;
const args = toolCall?.arguments ?? {};
const candidate = String(args.path ?? args.file_path ?? '').trim().replace(/\\/g, '/');
if (!candidate.toLowerCase().endsWith('.html')) continue;
@@ -249,11 +249,9 @@ export function evaluatePageDataFinishGuard({
const pageDataIntent = isPageDataIntent(agentText);
const pageDataFiles = collectPageDataPublicHtmlFiles(publishDir);
const recentWrites = extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt });
// A session may be reused long after unrelated Page Data files were written.
// Only the HTML paths written in this finish window are safe to bind or
// repair; scanning every historical Page Data file turns one new request
// into a quota-exhausting batch publish attempt.
const relevantFiles = pageDataFiles.filter((file) => recentWrites.includes(file.relativePath));
const relevantFiles = pageDataFiles.filter((file) =>
pageDataIntent || recentWrites.includes(file.relativePath),
);
const htmlIssues = relevantFiles.flatMap((file) =>
file.evaluation.issues.map((issue) => ({
@@ -436,6 +434,8 @@ export async function resolvePageDataCollectOutcomeAsync({
pool = null,
userId = null,
findPageByRelativePath = null,
apiBase = null,
fetchImpl = fetch,
} = {}) {
const agentText = String(intent?.agentText ?? intent?.displayText ?? '').trim();
if (!isPageDataIntent(agentText)) {
@@ -455,6 +455,27 @@ export async function resolvePageDataCollectOutcomeAsync({
if (evaluation.htmlIssues.length > 0) {
return { action: 'fail', failureText: buildPageDataCollectFailureText(), reason: 'invalid_html', evaluation };
}
if (evaluation.unboundFiles.length > 0 && pool && userId && apiBase) {
const artifacts = collectPageDataDeliveryArtifacts(publishDir).filter((artifact) =>
evaluation.relevantFiles.some((file) => file.relativePath === artifact.relativePath),
);
if (artifacts.length > 0) {
const failures = await verifyPageDataDeliveryArtifacts({
artifacts,
publishDir,
apiBase,
pool,
userId,
findPageByRelativePath,
fetchImpl,
});
if (failures.length === 0) {
return { action: 'send', reason: 'verified_by_live_api', evaluation };
}
}
}
if (evaluation.unboundFiles.length > 0) {
return { action: 'retry', reason: 'missing_bind', evaluation };
}
@@ -561,7 +582,7 @@ export async function ensurePageDataDeliveryReady({
const failures = await verifyPageDataDeliveryArtifacts({
artifacts,
publishDir,
apiBase,
apiBase: normalizePageDataApiBase(apiBase),
pool,
userId,
findPageByRelativePath: pageService.findPageByRelativePath.bind(pageService),
@@ -0,0 +1,48 @@
/**
* WeChat WebView compatibility patches for Page Data survey HTML.
* X5 often fails to select radios hidden with pointer-events:none.
*/
export function htmlNeedsWechatSurveyCompat(html) {
const content = String(html ?? '');
if (!/\/assets\/page-data-client\.js/i.test(content)) return false;
if (!/\.insertRow\s*\(/.test(content)) return false;
return (
/pointer-events\s*:\s*none/i.test(content) ||
/\.genre-option\s+input/i.test(content) ||
/input\[type=["']radio["']\][^{]*pointer-events/i.test(content)
);
}
export function applyWechatSurveyCompat(html) {
const source = String(html ?? '');
if (!htmlNeedsWechatSurveyCompat(source)) {
return { html: source, patched: false };
}
let next = source;
next = next.replace(
/\.genre-option\s+input\s*\{[^}]*pointer-events\s*:\s*none\s*;?[^}]*\}/gi,
'.genre-option input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; z-index: 2; }',
);
if (!/data-mindspace-wechat-survey-compat/i.test(next)) {
const patch = `<style id="mindspace-wechat-survey-compat">
.genre-option { position: relative; }
.genre-option label { position: relative; z-index: 1; pointer-events: none; -webkit-tap-highlight-color: transparent; }
.genre-option input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; z-index: 2; }
.genre-option input:checked + label { pointer-events: none; }
button.submit-btn, .submit-btn { touch-action: manipulation; -webkit-tap-highlight-color: transparent; }
</style>`;
if (/<\/head>/i.test(next)) {
next = next.replace(/<\/head>/i, `${patch}\n</head>`);
} else if (/<body\b/i.test(next)) {
next = next.replace(/<body\b/i, `${patch}\n<body`);
} else {
next = `${patch}\n${next}`;
}
}
return { html: next, patched: true };
}
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyWechatSurveyCompat,
htmlNeedsWechatSurveyCompat,
} from './mindspace-page-data-wechat-survey-compat.mjs';
const SURVEY_HTML = `<!doctype html><html><head><style>
.genre-option input { position: absolute; opacity: 0; pointer-events: none; }
</style>
<script src="/assets/page-data-client.js"></script></head><body>
<script>MindSpacePageData.createClient({ apiBase: '/api' }).insertRow('reading_survey', {});</script>
</body></html>`;
test('htmlNeedsWechatSurveyCompat detects pointer-events none on survey radios', () => {
assert.equal(htmlNeedsWechatSurveyCompat(SURVEY_HTML), true);
assert.equal(htmlNeedsWechatSurveyCompat('<html><body>plain</body></html>'), false);
});
test('applyWechatSurveyCompat removes pointer-events none from radio inputs', () => {
const result = applyWechatSurveyCompat(SURVEY_HTML);
assert.equal(result.patched, true);
assert.doesNotMatch(result.html, /\.genre-option\s+input\s*\{[^}]*pointer-events\s*:\s*none/i);
assert.match(result.html, /mindspace-wechat-survey-compat/);
assert.match(result.html, /z-index:\s*2/);
});
+4
View File
@@ -4,6 +4,7 @@ import path from 'node:path';
import { injectMindSpacePageDataContext } from './mindspace-public-page-context.mjs';
import { preparePublishedPlatformBrand } from './mindspace-page-tag.mjs';
import { injectPublicImageRetryScript } from './mindspace-public-image-retry.mjs';
import { applyWechatSurveyCompat } from './mindspace-page-data-wechat-survey-compat.mjs';
const INLINE_SCRIPT_PATTERN = /<script\b(?![^>]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi;
@@ -97,6 +98,9 @@ export function decorateMindSpacePublishedHtml({
if (pageDataContext?.pageId) {
nextHtml = injectMindSpacePageDataContext(nextHtml, pageDataContext);
}
if (!embed && isWechatUserAgent(userAgent || '')) {
nextHtml = applyWechatSurveyCompat(nextHtml).html;
}
const scriptHashes = collectInlineScriptHashes(nextHtml);
return {
html: nextHtml,
+38 -29
View File
@@ -7,10 +7,17 @@ import { listPageAccessPolicies, readPageAccessPolicy } from './page-data-policy
import { createUserDataSpaceService } from './user-data-space-service.mjs';
const INJECTION_PAGE_ID_PATTERN =
/window\.__MINDSPACE_PAGE_DATA__\s*=\s*\{[^}]*"pageId"\s*:\s*"([^"]+)"/i;
/window\.__MINDSPACE_PAGE_DATA__\s*=\s*\{[^}]*(?:"pageId"|pageId)\s*:\s*"([^"]+)"/i;
const INJECTION_META_PAGE_ID_PATTERN =
/<meta[^>]+name=["']mindspace-page-data-page-id["'][^>]+content=["']([^"']+)["']/i;
export function normalizePageDataApiBase(apiBase) {
const raw = String(apiBase ?? '').trim().replace(/\/$/, '');
if (!raw) return '/api';
if (/\/api$/i.test(raw)) return raw;
return `${raw}/api`;
}
export function extractInjectedPageIdFromHtml(html) {
const content = String(html ?? '');
const fromScript = content.match(INJECTION_PAGE_ID_PATTERN)?.[1]?.trim();
@@ -140,10 +147,6 @@ export async function assessPageDataHtmlBinding({
}
}
const publication = await queryOnlinePublication(pool, pageId);
if (!publication) {
reasons.push('missing_online_publication');
}
}
return {
@@ -179,7 +182,7 @@ export async function smokeTestPageDataInsert({
policy,
fetchImpl = fetch,
}) {
const base = String(apiBase ?? '').replace(/\/$/, '') || '/api';
const base = normalizePageDataApiBase(apiBase);
const row = buildSmokeInsertRow(policy, dataset);
if (!pageId || !dataset || !Object.keys(row).length) {
return { ok: false, reason: 'smoke_payload_unavailable', status: 0, body: null };
@@ -229,6 +232,29 @@ export async function verifyPageDataDeliveryArtifacts({
continue;
}
const pageId = injection.pageId;
const policy =
readPageAccessPolicy(publishDir, pageId) ??
(await assessPageDataHtmlBinding({
pool,
userId,
publishDir,
relativePath,
html,
findPageByRelativePath,
})).policy;
const smoke = await smokeTestPageDataInsert({
apiBase,
pageId,
dataset: insertDataset,
policy,
fetchImpl,
});
if (smoke.ok) {
continue;
}
const assessment = await assessPageDataHtmlBinding({
pool,
userId,
@@ -237,30 +263,13 @@ export async function verifyPageDataDeliveryArtifacts({
html,
findPageByRelativePath,
});
if (!assessment.bound) {
failures.push({
relativePath,
stage: 'binding',
reason: assessment.reasons.join(','),
});
continue;
}
const smoke = await smokeTestPageDataInsert({
apiBase,
pageId: assessment.pageId,
dataset: insertDataset,
policy: assessment.policy,
fetchImpl,
failures.push({
relativePath,
stage: 'insert_smoke',
reason: smoke.reason,
status: smoke.status,
bindingReasons: assessment.reasons,
});
if (!smoke.ok) {
failures.push({
relativePath,
stage: 'insert_smoke',
reason: smoke.reason,
status: smoke.status,
});
}
}
return failures;
}
+13
View File
@@ -3,6 +3,7 @@ import test from 'node:test';
import {
buildSmokeInsertRow,
extractInjectedPageIdFromHtml,
normalizePageDataApiBase,
} from './page-data-delivery-assess.mjs';
test('extractInjectedPageIdFromHtml reads injected pageId', () => {
@@ -13,6 +14,18 @@ test('extractInjectedPageIdFromHtml reads injected pageId', () => {
assert.equal(extractInjectedPageIdFromHtml(html), 'page-abc');
});
test('extractInjectedPageIdFromHtml reads unquoted pageId keys', () => {
const html =
'<script>window.__MINDSPACE_PAGE_DATA__={pageId:"page-unquoted",accessMode:"public"};</script>';
assert.equal(extractInjectedPageIdFromHtml(html), 'page-unquoted');
});
test('normalizePageDataApiBase appends /api to public base url', () => {
assert.equal(normalizePageDataApiBase('https://m.tkmind.cn'), 'https://m.tkmind.cn/api');
assert.equal(normalizePageDataApiBase('https://m.tkmind.cn/api'), 'https://m.tkmind.cn/api');
assert.equal(normalizePageDataApiBase('/api'), '/api');
});
test('buildSmokeInsertRow fills insert columns', () => {
const row = buildSmokeInsertRow(
{
+19 -6
View File
@@ -17,16 +17,29 @@ export function htmlUsesPageDataApi(html) {
export function detectPageDataDatasetUsageFromHtml(html) {
const text = String(html ?? '');
const datasets = new Map();
const constants = new Map();
for (const match of text.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*['"]([^'"]+)['"]/g)) {
constants.set(match[1], match[2]);
}
function remember(name, patch) {
const key = String(name ?? '').trim();
if (!key) return;
datasets.set(key, { ...(datasets.get(key) ?? {}), ...patch });
}
for (const match of text.matchAll(/\.insertRow\(\s*['"]([^'"]+)['"]/g)) {
const name = match[1];
const prev = datasets.get(name) ?? {};
datasets.set(name, { ...prev, insert: true });
remember(match[1], { insert: true });
}
for (const match of text.matchAll(/\.listRows\(\s*['"]([^'"]+)['"]/g)) {
const name = match[1];
const prev = datasets.get(name) ?? {};
datasets.set(name, { ...prev, read: true });
remember(match[1], { read: true });
}
for (const match of text.matchAll(/\.insertRow\(\s*([A-Za-z_$][\w$]*)/g)) {
remember(constants.get(match[1]) ?? match[1], { insert: true });
}
for (const match of text.matchAll(/\.listRows\(\s*([A-Za-z_$][\w$]*)/g)) {
remember(constants.get(match[1]) ?? match[1], { read: true });
}
return datasets;
+9
View File
@@ -16,6 +16,15 @@ test('detectPageDataDatasetUsageFromHtml finds insert and read datasets', () =>
assert.deepEqual(usage.get('tkmind_exp_survey'), { insert: true, read: true });
});
test('detectPageDataDatasetUsageFromHtml resolves dataset constants', () => {
const html = `
const DATASET = 'reading_survey';
await client.listRows(DATASET, { limit: 500 });
`;
const usage = detectPageDataDatasetUsageFromHtml(html);
assert.deepEqual(usage.get('reading_survey'), { read: true });
});
test('assertPolicyMatchesHtmlDatasets rejects mismatched dataset names', () => {
const html = `await c.insertRow('tkmind_exp_survey', {});`;
assert.throws(
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env node
/**
* Patch reading-survey HTML on disk for WeChat WebView radio/submit compatibility.
*
* Usage:
* node scripts/repair-reading-survey-wechat.mjs
* node scripts/repair-reading-survey-wechat.mjs --user a70ff537-8908-486e-9b6c-042e07cc25db
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { applyWechatSurveyCompat } from '../mindspace-page-data-wechat-survey-compat.mjs';
import { PUBLISH_ROOT_DIR } from '../user-publish.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
function parseArgs(argv) {
const userIdx = argv.indexOf('--user');
return {
userId:
userIdx >= 0
? String(argv[userIdx + 1] ?? '').trim()
: 'a70ff537-8908-486e-9b6c-042e07cc25db',
};
}
function patchFile(absolutePath) {
const before = fs.readFileSync(absolutePath, 'utf8');
const { html, patched } = applyWechatSurveyCompat(before);
if (!patched) {
console.log(`SKIP ${absolutePath} (no compat patch needed)`);
return false;
}
fs.writeFileSync(absolutePath, html, 'utf8');
console.log(`OK ${absolutePath}`);
return true;
}
const { userId } = parseArgs(process.argv.slice(2));
const base = path.join(root, PUBLISH_ROOT_DIR, userId, 'public');
const targets = ['reading-survey.html'];
let changed = 0;
for (const name of targets) {
const filePath = path.join(base, name);
if (!fs.existsSync(filePath)) {
console.log(`MISS ${filePath}`);
continue;
}
if (patchFile(filePath)) changed += 1;
}
console.log(`patched ${changed} file(s)`);
process.exit(changed > 0 ? 0 : 1);
+2
View File
@@ -1074,6 +1074,7 @@ async function enforcePageDataCollectDelivery({
pool: pageDataFinishGuard?.pool ?? null,
userId,
findPageByRelativePath: null,
apiBase: publicBaseUrl,
});
let autoBind = null;
if (outcome.action === 'skip') return outcome;
@@ -1100,6 +1101,7 @@ async function enforcePageDataCollectDelivery({
pool: pageDataFinishGuard.pool,
userId,
findPageByRelativePath: pageService.findPageByRelativePath.bind(pageService),
apiBase: publicBaseUrl,
});
}