Files
probably-rendering-one-more…/scripts/publish-shadow-covers.mjs

47 lines
5.3 KiB
JavaScript

/** Run with the production auth CLI after staging reviewed covers and manifest. Never stores auth tokens. */
import {readFile,writeFile} from 'node:fs/promises';
import {join,resolve} from 'node:path';
import {createHash} from 'node:crypto';
const root=resolve(process.env.SHADOW_COVERS_DIR||'/tmp/shadow-covers');
const base=process.env.AUTH_CHECK_BASE_URL||'http://127.0.0.1:3000';
const manifest=JSON.parse(await readFile(join(root,'manifest.json'),'utf8'));
const backup=JSON.parse(await readFile(join(root,'notes-before.json'),'utf8'));
let state;try{state=JSON.parse(await readFile(join(root,'publication.json'),'utf8'))}catch{state={covers:{},archived:[],beforeArchive:[]}}
const persist=()=>writeFile(join(root,'publication.json'),JSON.stringify(state,null,2));
async function api(path,body,method=body?'POST':'GET'){const r=await fetch(base+path,{method,...(body?{headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}:{})});const data=await r.json();if(!r.ok)throw Error(path+' '+r.status+' '+JSON.stringify(data));return data}
async function upload(key,file){if(state.covers[key]?.assetId)return state.covers[key].assetId;const bytes=await readFile(join(root,file)),form=new FormData();form.set('kind','image');form.set('assetOnly','true');form.set('files',new Blob([bytes],{type:'image/png'}),file);const r=await fetch(base+'/api/upload',{method:'POST',body:form});const data=await r.json();if(!r.ok)throw Error('Upload '+r.status+JSON.stringify(data));state.covers[key]={assetId:data.assets[0],sha256:createHash('sha256').update(bytes).digest('hex')};await persist();return data.assets[0]}
for(const item of manifest){
const assetId=await upload(item.key,item.outputFile),{entry:e,tags}=await api('/api/notes/'+item.postId);
const marker='<!-- shadow-cover-2026-10-27:'+item.key+' -->';
if(e.coverAssetId!==assetId){
const ids=[...(e.systemTags||[]).map(t=>t.id),...e.categories.map(name=>{const t=tags.find(t=>t.kind==='normal'&&t.name===name);if(!t)throw Error('Missing tag '+name);return t.id})];
const attribution=`\n\n${marker}\n## 本篇封面:照片入皮影\n\n摄影/馆藏来源:[原始照片](${item.source});作者:${item.author}。\n\n封面图许可:[${item.license}](${item.licenseUrl})(仅适用于本封面及其改编,不改变正文原有许可)。照片经裁切,皮影由 AI 根据原照生成;上下分区经代码精确排版为 1:1。${item.key==='lion'?'上半部亦使用 AI 对原照扩图。':''}\n`;
const markdown=e.editableMarkdown.includes(marker)?e.editableMarkdown:e.editableMarkdown+attribution;
const assets=(e.assets||[]).map(({id,role})=>({id,role}));
if(!e.postSlug){
// Retain the original skill's two style examples, already stored by its companion notes.
assets.push({id:'102a1975-5343-4da9-91aa-c6914db50d5e',role:'content'},{id:'0a7a631d-0a4a-4a66-bae9-71d824469c50',role:'content'});
}
if(!assets.some(a=>a.id===assetId))assets.push({id:assetId,role:'content'});
const input={title:e.title,description:e.description,tags:[...new Set(ids)],assets,coverAssetId:assetId};
if(e.postSlug)await api('/api/notes/'+e.id,{...input,markdown,revision:e.revision},'PUT');
else await api('/api/posts',{...input,kind:'article',publicId:e.id,slug:'guide-shadow-puppet',text:markdown});
}
const after=(await api('/api/notes/'+e.id)).entry;if(after.coverAssetId!==assetId)throw Error('Cover mismatch');
const media=await fetch(base+'/api/media/'+assetId);if(!media.ok||createHash('sha256').update(Buffer.from(await media.arrayBuffer())).digest('hex')!==state.covers[item.key].sha256)throw Error('Media checksum mismatch');
state.covers[item.key].postId=e.id;state.covers[item.key].verified=true;await persist();console.log(JSON.stringify({cover:item.key,postId:e.id,verified:true}));
}
if(backup.oldRefs.length!==27)throw Error('Expected exact 27-entry archive allowlist');
for(const item of backup.oldRefs){
const e=(await api('/api/notes/'+item.id)).entry;
if(e.title!==item.title||!/^ref-(0[1-9]|1[0-9]|2[0-7]) · /.test(e.title)||!e.postSlug)throw Error('Archive allowlist mismatch '+item.id);
if(!state.beforeArchive.some(n=>n.id===e.id)){state.beforeArchive.push(e);await persist()}
await api('/api/posts/'+item.id+'/archive',{archived:true});
const after=(await api('/api/notes/'+item.id)).entry;
if(!after.archived||after.editableMarkdown!==e.editableMarkdown||JSON.stringify(after.assets)!==JSON.stringify(e.assets))throw Error('Archive preservation failed');
if(!state.archived.includes(e.id))state.archived.push(e.id);await persist();
}
for(const category of ['照片入皮影','高级感排版']){let cursor=null,entries=[];do{const d=await api('/api/explore-feed?category='+encodeURIComponent(category)+(cursor?'&cursor='+encodeURIComponent(cursor):''));entries.push(...d.entries);cursor=d.nextCursor}while(cursor);if(category==='照片入皮影'&&!manifest.every(m=>entries.some(e=>e.id===m.postId)))throw Error('Missing cover post');if(entries.some(e=>state.archived.includes(e.id)))throw Error('Archived article still discoverable');state[category]=entries.map(e=>({id:e.id,title:e.title}));}
const saved=(await api('/api/notes/38a7a8ea-6b3b-46fd-902f-431d2f351cf4')).entry;if(saved.archived)throw Error('User design incorrectly archived');
state.completedAt=new Date().toISOString();await persist();console.log(JSON.stringify({covers:manifest.length,archived:state.archived.length,userDesignPreserved:true}));