// 저장 계획을 브라우저 문맥 안에서 보낸다. // // **왜 브라우저 안인가.** 세션 쿠키가 httpOnly 라 밖으로 못 읽고, 읽어서 옮기는 것은 // 「비밀은 길이·존재 여부만」에 걸린다. `page.request` 는 그 브라우저 문맥의 쿠키를 // 그대로 쓰므로 값이 이 프로세스 밖으로 나가지 않는다. // // **왜 계획을 그대로 안 보내나.** `studio-save.py` 의 계획은 `projectId`·`topicId` 를 // `null` 로, `relations` 를 빈 배열로 낸다. 서버 저장은 부분 갱신이 아니라서 그대로 // 보내면 **그 세 칸이 지워진다.** 그래서 저장 직전에 GET 으로 읽어 그 셋과 // `variantIds` 는 서버 값을 쓰고, 기록이 갖는 칸만 계획 값으로 덮는다. // // **게시하지 않는다.** publish/unpublish 경로가 섞이면 그 편을 건너뛴다. async (page) => { // 이 문맥은 샌드박스라 require 도 동적 import 도 안 된다. 그래서 계획은 // 127.0.0.1 의 정적 서버에서 받아 온다 — page.request 는 CORS 를 안 탄다. const SUB = 'plans'; const LOCAL = 'http://127.0.0.1:8731/' + SUB + '/'; const getLocal = async (n) => { const r = await page.request.fetch(LOCAL + n); if (!r.ok()) throw new Error('계획을 못 받았다: ' + n + ' ' + r.status()); return await r.json(); }; const files = await getLocal('_index.json'); // 기록이 소유하는 칸. 이것만 계획 값으로 덮는다. const OWNED = new Set([ 'kind', 'title', 'slug', 'summary', 'problem', 'conclusion', 'environment', 'reproduction', 'bodyMarkdown', 'lastVerifiedOn', 'basisVersion', 'pinnedVersions', 'purpose', 'rules', 'applyWhen', 'exceptions', 'examples', 'verifiedOn', 'facts', 'assumptions', 'unknowns', 'constraints', 'options', 'nextValidation', 'resolution', 'questionStatus', ]); // 서버가 정본인 칸. 계획이 뭐라 하든 서버 값을 쓴다. const SERVER_OWNED = ['projectId', 'topicId', 'relations', 'variantIds']; const csrf = await page.evaluate( () => document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/)?.[1] || null); if (!csrf) return { fatal: 'XSRF-TOKEN 쿠키가 없다 — 로그인 상태가 아니다' }; const api = async (method, url, body, headers) => { const r = await page.request.fetch('https://hyeonworks.com' + url, { method, headers: Object.assign({ accept: 'application/json' }, body ? { 'content-type': 'application/json' } : {}, headers || {}), data: body ? JSON.stringify(body) : undefined, }); let j = null; try { j = await r.json(); } catch (e) { /* 204 는 본문이 없다 */ } return { status: r.status(), body: j }; }; const results = []; for (const f of files) { const plan = await getLocal(f); const name = f.replace(/\.json$/, ''); const put = plan.requests.find(r => r.method === 'PUT'); if (!put) { results.push({ name, skipped: 'PUT 요청이 없다' }); continue; } if (plan.requests.some(r => /\/(publish|unpublish)(\/|$)/.test(r.path))) { results.push({ name, skipped: '게시 경로가 섞여 있다' }); continue; } const id = put.path.split('/').pop(); // ① 지금 상태를 읽는다 const cur = await api('GET', put.path); if (cur.status !== 200) { results.push({ name, id, step: 'GET', status: cur.status }); continue; } const server = cur.body.data.document; const pub = cur.body.data.currentPublication; if (pub && pub.status === 'PUBLISHED') { results.push({ name, id, skipped: '게시된 문서다' }); continue; } // ② 합친다 — 서버 값을 살리고 기록이 갖는 칸만 덮는다 const planned = put.body.document; const doc = {}; for (const [k, v] of Object.entries(planned)) if (OWNED.has(k)) doc[k] = v; for (const k of SERVER_OWNED) doc[k] = server[k]; // ③ 보낸다 const res = await api('PUT', put.path, { expectedVersion: server.version, document: doc }, { 'x-csrf-token': csrf, 'idempotency-key': (put.headers && put.headers['Idempotency-Key']) || ('send-' + id + '-' + Date.now()) }); if (res.status !== 200) { results.push({ name, id, step: 'PUT', status: res.status, code: res.body && res.body.error && res.body.error.code }); continue; } // ④ 되읽어 대조한다 const back = await api('GET', put.path); const b = back.body.data.document; results.push({ name, id, kind: server.kind, status: 200, versionBefore: server.version, versionAfter: b.version, bodyMatches: (b.bodyMarkdown || '') === (doc.bodyMarkdown || ''), titleMatches: b.title === doc.title, summaryMatches: b.summary === doc.summary, keptProject: b.projectId === server.projectId, keptTopic: b.topicId === server.topicId, keptRelations: (b.relations || []).length === (server.relations || []).length, stillUnpublished: !back.body.data.currentPublication, }); } const ok = results.filter(r => r.status === 200); return { total: files.length, saved: ok.length, skipped: results.filter(r => r.skipped).length, failed: results.filter(r => r.status && r.status !== 200).length, bodyMismatch: ok.filter(r => !r.bodyMatches).length, lostProject: ok.filter(r => !r.keptProject).length, lostTopic: ok.filter(r => !r.keptTopic).length, lostRelations: ok.filter(r => !r.keptRelations).length, becamePublished: ok.filter(r => !r.stillUnpublished).length, problems: results.filter(r => r.skipped || (r.status && r.status !== 200) || (r.status === 200 && (!r.bodyMatches || !r.keptProject || !r.keptTopic || !r.keptRelations || !r.stillUnpublished))), }; }