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:
@@ -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,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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user