neoctl-web 0.1.10 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +244 -244
- package/bin/neow.mjs +153 -153
- package/core-runtime.mjs +55 -55
- package/cpa-quota.mjs +209 -209
- package/dist/assets/index-BbUoWpPT.css +1 -0
- package/dist/assets/index-DZgO3hdD.js +121 -0
- package/dist/favicon.svg +3 -3
- package/dist/index.html +2 -2
- package/memory-monitor.mjs +150 -150
- package/package.json +74 -73
- package/platform-paths.mjs +40 -40
- package/plugin-settings.mjs +67 -67
- package/plugins/downloads/downloads.mjs +147 -147
- package/plugins/downloads/index.mjs +19 -19
- package/plugins/downloads/neo-plugin.json +9 -9
- package/plugins/xhs-artifact/OBS-12.md +65 -0
- package/plugins/xhs-artifact/artifacts.mjs +487 -388
- package/plugins/xhs-artifact/editor-page.mjs +79 -73
- package/plugins/xhs-artifact/index.mjs +48 -48
- package/plugins/xhs-artifact/neo-plugin.json +9 -9
- package/plugins/xhs-artifact/version-client.mjs +118 -0
- package/plugins/xhs-artifact/version.test.mjs +151 -0
- package/plugins/xhs-artifact/xhs-artifact-contract.mjs +127 -125
- package/plugins.mjs +111 -111
- package/runtime-router-cleanup.mjs +152 -152
- package/runtime-workspaces.mjs +534 -534
- package/server.mjs +452 -452
- package/tool-settings.mjs +80 -80
- package/dist/assets/index-CIm8rv9k.css +0 -1
- package/dist/assets/index-DGNbGP2p.js +0 -123
|
@@ -1,73 +1,79 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
.replace(
|
|
6
|
-
.replace(
|
|
7
|
-
.replace(
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
'
|
|
22
|
-
'Content-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
<
|
|
34
|
-
<
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
.
|
|
41
|
-
.
|
|
42
|
-
.
|
|
43
|
-
:
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
function
|
|
62
|
-
|
|
63
|
-
$('
|
|
64
|
-
|
|
65
|
-
$('
|
|
66
|
-
|
|
67
|
-
$('
|
|
68
|
-
function
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
1
|
+
import { installVersioning } from './version-client.mjs';
|
|
2
|
+
|
|
3
|
+
function escapeHtml(value) {
|
|
4
|
+
return String(value ?? '')
|
|
5
|
+
.replace(/&/g, '&')
|
|
6
|
+
.replace(/</g, '<')
|
|
7
|
+
.replace(/>/g, '>')
|
|
8
|
+
.replace(/"/g, '"')
|
|
9
|
+
.replace(/'/g, ''');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function serialize(value) {
|
|
13
|
+
return JSON.stringify(value).replace(/</g, '\\u003c').replace(/-->/g, '--\\u003e');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function serveXhsEditorPage(res, artifact, options = {}) {
|
|
17
|
+
const sessionId = String(options.sessionId || artifact.sessionId || '');
|
|
18
|
+
const apiUrl = `/api/xhs-artifacts/${encodeURIComponent(artifact.id)}${sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : ''}`;
|
|
19
|
+
const html = renderXhsEditorPage(artifact, apiUrl);
|
|
20
|
+
res.writeHead(200, {
|
|
21
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
22
|
+
'Content-Length': String(Buffer.byteLength(html)),
|
|
23
|
+
'Cache-Control': 'no-store',
|
|
24
|
+
'Content-Security-Policy': "default-src 'self'; img-src 'self' data: blob: https: http:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'",
|
|
25
|
+
});
|
|
26
|
+
res.end(html);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function renderXhsEditorPage(artifact, apiUrl) {
|
|
30
|
+
return `<!doctype html>
|
|
31
|
+
<html lang="zh-CN">
|
|
32
|
+
<head>
|
|
33
|
+
<meta charset="utf-8">
|
|
34
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
35
|
+
<title>${escapeHtml(artifact.title || '小红书笔记')}</title>
|
|
36
|
+
<style>
|
|
37
|
+
:root{color-scheme:light;--bg:#fff;--panel:#fff;--panel2:#f7f7f7;--media:#f5f5f5;--text:#191919;--copy:#333;--muted:#8b8b8b;--line:#ededed;--accent:#ff2442;--soft:#fff0f2;--shadow:0 16px 48px rgba(0,0,0,.1);--neo-surface:#fff;--neo-text:#111827;--neo-border:#1f2937;--neo-shadow:#cbd5e1;--neo-pink:#ed68ac;--neo-active:#111827;--neo-active-text:#fff}
|
|
38
|
+
:root[data-theme=dark]{color-scheme:dark;--bg:#09090b;--panel:#111113;--panel2:#18181b;--media:#050505;--text:#f5f5f5;--copy:#f5f5f5;--muted:#96969d;--line:#2b2b2f;--accent:#ff2442;--soft:#35151c;--shadow:0 20px 60px rgba(0,0,0,.5);--neo-surface:#303842;--neo-text:#f3f4f6;--neo-border:#d8dee9;--neo-shadow:#111318;--neo-pink:#9d416f;--neo-active:#f3f4f6;--neo-active-text:#111827}
|
|
39
|
+
*{box-sizing:border-box;scrollbar-width:none}*::-webkit-scrollbar{display:none}html,body{margin:0;min-height:100%;background:var(--bg);color:var(--text);font:14px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif}button,input,textarea{font:inherit}button{cursor:pointer}
|
|
40
|
+
.shell{display:grid;gap:12px;padding:10px;background:var(--bg)}.head{display:flex;min-height:42px;align-items:center;justify-content:flex-end;border:0;background:transparent;padding:0 0 2px;color:var(--neo-text);box-shadow:none}
|
|
41
|
+
.actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:nowrap}.segmented{display:flex;gap:6px;background:transparent}.segmented button,.save,.screen,.upload,.small{background:var(--neo-surface);color:var(--neo-text);padding:7px 12px;font-weight:700}.segmented button,.save,.screen{border:2px solid var(--neo-border);border-radius:0;box-shadow:2px 2px 0 var(--neo-shadow)}.segmented button.active{background:var(--neo-active);color:var(--neo-active-text)}.save{background:var(--neo-pink);padding-inline:18px}.screen{background:var(--neo-surface)}.segmented button:active,.save:active,.screen:active{transform:translate(2px,2px);box-shadow:none}.save:disabled{opacity:.55}.status{min-width:42px;color:var(--muted);font-size:12px;line-height:1;white-space:nowrap;pointer-events:none}.status:empty{display:none}
|
|
42
|
+
.grid{display:grid;grid-template-columns:minmax(300px,.9fr) minmax(380px,1.1fr);gap:14px;align-items:start}.grid.preview{display:block;max-width:1050px;width:100%;margin:auto}.grid.edit{display:block;max-width:900px;width:100%;margin:auto}.hidden{display:none!important}
|
|
43
|
+
.preview-card,.form{border-radius:18px;box-shadow:none}.preview-card{display:grid;grid-template-columns:minmax(0,1.42fr) minmax(310px,.78fr);min-height:610px;overflow:hidden;border:1px solid var(--line);background:var(--panel);color:var(--text)}.grid:not(.preview) .preview-card{display:block;min-height:0}.media{position:relative;min-height:610px;background:var(--media);display:grid;place-items:center;overflow:hidden}.grid:not(.preview) .media{min-height:340px;aspect-ratio:4/3}.media img{width:100%;height:100%;object-fit:cover}.media.image-missing{background:radial-gradient(circle at 18% 18%,rgba(255,36,66,.12),transparent 26%),radial-gradient(circle at 82% 78%,rgba(255,137,154,.16),transparent 28%),var(--media)}.placeholder{display:grid;justify-items:center;gap:8px;width:min(320px,70%);padding:30px 24px;color:var(--muted);text-align:center}.placeholder-art{display:grid;place-items:center;width:68px;height:68px;margin-bottom:5px;border-radius:20px;background:var(--soft);color:var(--accent);font-size:30px;transform:rotate(-4deg)}.placeholder strong{color:var(--text);font-size:16px}.placeholder small{max-width:260px;font-size:13px;line-height:1.55}.overlay{position:absolute;left:24px;right:24px;bottom:26px;border-radius:10px;color:white;font-size:26px;font-weight:900;text-align:center;text-shadow:0 2px 12px #000;background:rgba(0,0,0,.34);padding:9px 12px}.arrows{position:absolute;left:14px;right:14px;top:50%;display:flex;justify-content:space-between;pointer-events:none}.arrows button{pointer-events:auto;width:36px;height:36px;border:0;border-radius:50%;background:rgba(0,0,0,.48);color:white;font-size:22px;backdrop-filter:blur(8px)}.dots{position:absolute;left:0;right:0;bottom:9px;text-align:center;color:white;text-shadow:0 1px 4px #000}.media.image-missing .dots{color:var(--muted);text-shadow:none}.body{position:relative;display:flex;min-width:0;max-height:610px;flex-direction:column;padding:22px;overflow:auto;background:var(--panel)}.author{display:flex;align-items:center;gap:10px;margin-bottom:20px;font-weight:700}.avatar{display:grid;place-items:center;width:38px;height:38px;border-radius:50%;background:linear-gradient(135deg,#ff8a9c,#ff2442);color:white}.author-name{min-width:0;flex:1}.follow{border:0;border-radius:999px;background:var(--accent);color:white;padding:7px 20px;font-weight:700}h1{margin:0 0 10px;font-size:19px;line-height:1.4}.preview-card .copy{white-space:pre-wrap;margin:0;color:var(--copy)}.preview-card .interaction{margin:14px 0 0;color:var(--copy)}.tags{display:flex;gap:6px;flex-wrap:wrap;margin-top:15px}.tags span{color:#3d6dcc;font-weight:550}:root[data-theme=dark] .tags span{color:#8ab4f8}.post-meta{margin-top:18px;padding-bottom:18px;border-bottom:1px solid var(--line);color:var(--muted);font-size:12px}.social{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:auto;padding-top:18px;color:var(--muted)}.social span{display:inline-flex;align-items:center;gap:4px}.social b{color:var(--text);font-size:17px}
|
|
44
|
+
.form{display:grid;gap:13px;padding:18px}.field{display:grid;gap:5px}.field span,.images-head strong{font-size:12px;font-weight:700}.field input,.field textarea{width:100%;border:1px solid var(--line);border-radius:10px;background:var(--panel2);color:var(--text);padding:10px 12px;outline:none}.field textarea{min-height:112px;resize:vertical}.field input:focus,.field textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 14%,transparent)}.images-head{display:flex;align-items:center;justify-content:space-between}.upload,.small{border:1px solid var(--line);border-radius:999px;background:var(--panel2)}.image-row{display:grid;grid-template-columns:92px minmax(0,1fr);gap:10px;border-top:1px solid var(--line);padding:12px 0}.thumb{width:92px;aspect-ratio:1;border:1px solid var(--line);border-radius:10px;background:var(--panel2);overflow:hidden;padding:0}.thumb img{width:100%;height:100%;object-fit:cover}.image-fields{display:grid;gap:7px}.image-fields input{padding:7px 9px}.row-actions{display:flex;gap:7px}.danger{color:var(--accent)}.empty{color:var(--muted);padding:14px;border:1px dashed var(--line);border-radius:10px}
|
|
45
|
+
:fullscreen{overflow:auto;background:var(--bg)}:fullscreen .shell{min-height:100vh}:fullscreen .grid.preview{max-width:1400px}:fullscreen .preview-card{min-height:calc(100vh - 100px)}:fullscreen .media{min-height:calc(100vh - 100px)}:fullscreen .body{max-height:calc(100vh - 100px)}
|
|
46
|
+
@media(max-width:760px){.shell{padding:8px}.head{align-items:stretch;flex-direction:column}.grid{grid-template-columns:1fr}.preview-card{display:block;min-height:0}.media{min-height:360px;aspect-ratio:4/5}.body{max-height:none}.image-row{grid-template-columns:72px minmax(0,1fr)}.thumb{width:72px}.overlay{font-size:20px}}
|
|
47
|
+
</style>
|
|
48
|
+
</head>
|
|
49
|
+
<body>
|
|
50
|
+
<main class="shell">
|
|
51
|
+
<header class="head"><div class="actions"><div class="segmented"><button data-mode="preview">预览</button><button data-mode="both">双栏</button><button data-mode="edit">编辑</button></div><button id="fullscreen" class="screen" type="button">全屏</button><button id="save" class="save">保存</button><span id="status" class="status"></span></div></header>
|
|
52
|
+
<section id="grid" class="grid">
|
|
53
|
+
<article id="preview" class="preview-card"><div id="mediaStage" class="media"><div id="media"></div><div id="overlay" class="overlay hidden"></div><div class="arrows"><button id="prev" aria-label="上一张">‹</button><button id="next" aria-label="下一张">›</button></div><div id="dots" class="dots"></div></div><div class="body"><div class="author"><span class="avatar">喵</span><span class="author-name">喵乘舰</span><button class="follow" type="button">关注</button></div><h1 id="previewTitle"></h1><p id="previewBody" class="copy"></p><p id="interaction" class="interaction"></p><div id="tags" class="tags"></div><div class="post-meta">刚刚 · 小红书</div><footer class="social"><span><b>♡</b> 赞</span><span><b>☆</b> 收藏</span><span><b>◯</b> 评论</span></footer></div></article>
|
|
54
|
+
<form id="form" class="form"><label class="field"><span>标题</span><input id="title" maxlength="160"></label><label class="field"><span>正文</span><textarea id="body"></textarea></label><label class="field"><span>互动文案</span><textarea id="interactionInput"></textarea></label><label class="field"><span>话题标签(空格分隔)</span><input id="hashtags"></label><label class="field"><span>审核备注</span><textarea id="review"></textarea></label><div class="images-head"><strong>配图</strong><div><button id="upload" class="upload" type="button">上传图片</button> <button id="add" class="upload" type="button">添加配图</button></div></div><input id="files" class="hidden" type="file" accept="image/*" multiple><div id="images"></div></form>
|
|
55
|
+
</section>
|
|
56
|
+
</main>
|
|
57
|
+
<script>
|
|
58
|
+
const initial=${serialize(artifact)};const apiUrl=${serialize(apiUrl)};let draft=clone(initial.payload||{}),active=0,saveTimer=0;const $=id=>document.getElementById(id);
|
|
59
|
+
const params=new URLSearchParams(location.search);const requestedTheme=params.get('theme');document.documentElement.dataset.theme=requestedTheme==='dark'?'dark':requestedTheme==='light'?'light':matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';
|
|
60
|
+
function clone(v){return JSON.parse(JSON.stringify(v))}function esc(v){return String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}function tags(v){return [...new Set(String(v||'').split(/[\\s,,、]+/).filter(Boolean).map(x=>x.startsWith('#')?x:'#'+x))]}
|
|
61
|
+
function field(id,key){$(id).value=key==='hashtags'?(draft[key]||[]).join(' '):draft[key]||'';$(id).addEventListener('input',e=>{draft[key]=key==='hashtags'?tags(e.target.value):e.target.value;renderPreview();scheduleSave()})}
|
|
62
|
+
field('title','title');field('body','body');field('interactionInput','interaction');field('hashtags','hashtags');field('review','review');
|
|
63
|
+
function missingMedia(image){return '<div class="placeholder"><span class="placeholder-art">▧</span><strong>待添加配图</strong><small>'+esc(image?.caption||image?.note||'上传图片后将在这里预览')+'</small></div>'}function showMissingMedia(image){$('mediaStage').classList.add('image-missing');$('media').innerHTML=missingMedia(image);$('overlay').classList.add('hidden')}function renderPreview(){const images=draft.images||[];active=Math.max(0,Math.min(active,Math.max(0,images.length-1)));const image=images[active];$('previewTitle').textContent=draft.title||'未命名笔记';$('previewBody').textContent=draft.body||'';$('interaction').textContent=draft.interaction||'';$('tags').innerHTML=(draft.hashtags||[]).map(x=>'<span>'+esc(x)+'</span>').join('');$('mediaStage').classList.remove('image-missing');if(image?.url){$('media').innerHTML='<img src="'+esc(image.url)+'" alt="'+esc(image.caption||'配图')+'">';$('media').querySelector('img')?.addEventListener('error',()=>showMissingMedia(image),{once:true})}else{showMissingMedia(image)}$('overlay').textContent=image?.overlay||'';$('overlay').classList.toggle('hidden',!image?.overlay||$('mediaStage').classList.contains('image-missing'));$('prev').classList.toggle('hidden',images.length<2);$('next').classList.toggle('hidden',images.length<2);$('dots').textContent=images.length>1?(active+1)+' / '+images.length:''}
|
|
64
|
+
function renderImages(){const host=$('images'),images=draft.images||[];host.innerHTML=images.length?images.map((im,i)=>'<section class="image-row" data-index="'+i+'"><button class="thumb" type="button" data-pick="'+i+'">'+(im.url?'<img src="'+esc(im.url)+'" alt="">':'配图 '+(i+1))+'</button><div class="image-fields"><input data-key="url" value="'+esc(im.url)+'" placeholder="图片 URL"><input data-key="caption" value="'+esc(im.caption)+'" placeholder="图片说明"><input data-key="overlay" value="'+esc(im.overlay)+'" placeholder="画面文案"><textarea data-key="note" placeholder="备注">'+esc(im.note)+'</textarea><div class="row-actions"><button type="button" class="small" data-upload="'+i+'">上传替换</button><button type="button" class="small danger" data-remove="'+i+'">删除</button></div></div></section>').join(''):'<div class="empty">还没有配图</div>'}
|
|
65
|
+
$('images').addEventListener('input',e=>{const row=e.target.closest('[data-index]');if(!row||!e.target.dataset.key)return;draft.images[+row.dataset.index][e.target.dataset.key]=e.target.value;renderPreview();scheduleSave()});$('images').addEventListener('click',e=>{const pick=e.target.closest('[data-pick]');if(pick){active=+pick.dataset.pick;renderPreview()}const remove=e.target.closest('[data-remove]');if(remove){draft.images.splice(+remove.dataset.remove,1);renderImages();renderPreview();scheduleSave()}const upload=e.target.closest('[data-upload]');if(upload){$('files').dataset.target=upload.dataset.upload;$('files').click()}});
|
|
66
|
+
$('prev').onclick=()=>{active=(active-1+(draft.images||[]).length)%(draft.images||[]).length;renderPreview()};$('next').onclick=()=>{active=(active+1)%(draft.images||[]).length;renderPreview()};let wheelLock=0;$('mediaStage').addEventListener('wheel',e=>{const images=draft.images||[];if(images.length<2)return;e.preventDefault();const now=Date.now();if(now<wheelLock)return;wheelLock=now+320;const delta=Math.abs(e.deltaY)>=Math.abs(e.deltaX)?e.deltaY:e.deltaX;active=(active+(delta>=0?1:-1)+images.length)%images.length;renderPreview()},{passive:false});$('add').onclick=()=>{(draft.images||(draft.images=[])).push({url:'',caption:'配图 '+((draft.images?.length||0)+1),overlay:'',note:''});renderImages();scheduleSave()};$('upload').onclick=()=>{$('files').dataset.target='';$('files').click()};
|
|
67
|
+
$('files').onchange=async e=>{const files=[...e.target.files];e.target.value='';if(!files.length)return;setStatus('上传中…');try{const uploaded=[];for(const file of files){const data=await new Promise((resolve,reject)=>{const r=new FileReader;r.onload=()=>resolve(String(r.result).replace(/^data:[^,]*,/,''));r.onerror=()=>reject(r.error);r.readAsDataURL(file)});const res=await fetch('/api/uploads',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:file.name,mimeType:file.type,data})});const body=await res.json();if(!res.ok||!body.file?.url)throw new Error(body.error||'上传失败');uploaded.push({url:body.file.url,caption:file.name.replace(/\\.[^.]+$/,''),overlay:'',note:file.name})}if(versioning.readonly()){setStatus('已过期,上传未应用;请加载最新版',true);return}const target=Number(e.target.dataset.target);if(e.target.dataset.target!==''&&draft.images[target]){draft.images[target]={...draft.images[target],...uploaded.shift()}}draft.images.push(...uploaded);renderImages();renderPreview();await save()}catch(err){setStatus(err.message||String(err),true)}};
|
|
68
|
+
function setMode(mode){localStorage.setItem('neoctl.plugin.xhs.mode.v2',mode);document.querySelectorAll('[data-mode]').forEach(b=>b.classList.toggle('active',b.dataset.mode===mode));$('grid').className='grid '+mode;$('preview').classList.toggle('hidden',mode==='edit');$('form').classList.toggle('hidden',mode==='preview');resize()}document.querySelectorAll('[data-mode]').forEach(b=>b.onclick=()=>setMode(b.dataset.mode));
|
|
69
|
+
$('fullscreen').onclick=async()=>{if(document.fullscreenElement)await document.exitFullscreen();else await document.documentElement.requestFullscreen()};document.addEventListener('fullscreenchange',()=>{$('fullscreen').textContent=document.fullscreenElement?'退出全屏':'全屏'});
|
|
70
|
+
function payload(){return{title:draft.title||'',body:draft.body||'',interaction:draft.interaction||'',hashtags:draft.hashtags||[],images:draft.images||[],review:draft.review||''}}
|
|
71
|
+
function setStatus(v,error=false){$('status').textContent=v;$('status').style.color=error?'#ef4444':''}
|
|
72
|
+
const versioning=(${installVersioning.toString()})(initial,apiUrl,{payload,status:setStatus,replace(value){draft=clone(value);for(const [id,key] of [['title','title'],['body','body'],['interactionInput','interaction'],['hashtags','hashtags'],['review','review']])$(id).value=key==='hashtags'?(draft[key]||[]).join(' '):draft[key]||'';renderPreview();renderImages()}});
|
|
73
|
+
function scheduleSave(){versioning.changed()}async function save(){versioning.changed();await versioning.save()}$('form').onsubmit=e=>e.preventDefault();
|
|
74
|
+
|
|
75
|
+
function resize(){parent.postMessage({type:'neo-plugin-resource-resize',artifact_id:initial.id,sessionId:initial.sessionId,height:Math.min(1400,Math.max(480,document.documentElement.scrollHeight))},location.origin)}new ResizeObserver(resize).observe(document.body);renderPreview();renderImages();setMode(localStorage.getItem('neoctl.plugin.xhs.mode.v2')||'preview');
|
|
76
|
+
</script>
|
|
77
|
+
</body>
|
|
78
|
+
</html>`;
|
|
79
|
+
}
|
|
@@ -1,48 +1,48 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
|
-
import { createOpenXhsArtifactEditorTool, createReadXhsArtifactTool, serveXhsArtifact, XhsArtifactRegistry, xhsArtifactPresentation } from './artifacts.mjs';
|
|
3
|
-
import { serveXhsEditorPage } from './editor-page.mjs';
|
|
4
|
-
import { parseXhsArtifactToolOutput } from './xhs-artifact-contract.mjs';
|
|
5
|
-
|
|
6
|
-
export function createPlugin(context) {
|
|
7
|
-
const configuredStorageDir = String(context.env.NEO_XHS_ARTIFACTS_DIR || '').trim();
|
|
8
|
-
const storageDir = configuredStorageDir
|
|
9
|
-
? path.resolve(configuredStorageDir)
|
|
10
|
-
: path.join(context.appDataDir || path.join(context.pluginDir, '.data'), 'xhs-artifacts');
|
|
11
|
-
const registry = new XhsArtifactRegistry({ storageDir });
|
|
12
|
-
return {
|
|
13
|
-
tools: [
|
|
14
|
-
createOpenXhsArtifactEditorTool({ registry }),
|
|
15
|
-
createReadXhsArtifactTool({ registry }),
|
|
16
|
-
],
|
|
17
|
-
presentToolResult({ toolName, output, ok, sessionId }) {
|
|
18
|
-
if (!ok) return undefined;
|
|
19
|
-
if (toolName === 'read_xhs_artifact') {
|
|
20
|
-
return { title: '读取小红书笔记', text: '已读取稿件。', presentationLevel: 'process' };
|
|
21
|
-
}
|
|
22
|
-
if (toolName !== 'open_xhs_artifact_editor') return undefined;
|
|
23
|
-
const artifact = parseXhsArtifactToolOutput(output);
|
|
24
|
-
return artifact ? xhsArtifactPresentation(artifact, sessionId) : undefined;
|
|
25
|
-
},
|
|
26
|
-
async route(req, res, url, helpers) {
|
|
27
|
-
if (!['GET', 'PUT'].includes(req.method || '') || !url.pathname.startsWith('/api/xhs-artifacts/')) return false;
|
|
28
|
-
const suffix = url.pathname.slice('/api/xhs-artifacts/'.length);
|
|
29
|
-
const editorMatch = /^([^/]+)\/editor$/.exec(suffix);
|
|
30
|
-
const id = decodeURIComponent(editorMatch?.[1] || suffix);
|
|
31
|
-
if (editorMatch) {
|
|
32
|
-
if (req.method !== 'GET') {
|
|
33
|
-
helpers.sendJson?.(res, { error: 'method not allowed' }, 405);
|
|
34
|
-
return true;
|
|
35
|
-
}
|
|
36
|
-
const artifact = registry.get(id, url.searchParams.get('sessionId') || undefined);
|
|
37
|
-
if (!artifact) {
|
|
38
|
-
helpers.sendJson?.(res, { error: 'artifact not found' }, 404);
|
|
39
|
-
return true;
|
|
40
|
-
}
|
|
41
|
-
serveXhsEditorPage(res, artifact, { sessionId: url.searchParams.get('sessionId') || undefined });
|
|
42
|
-
return true;
|
|
43
|
-
}
|
|
44
|
-
await serveXhsArtifact(registry, req, res, id, helpers.readJsonBody, url.searchParams.get('sessionId') || undefined);
|
|
45
|
-
return true;
|
|
46
|
-
},
|
|
47
|
-
};
|
|
48
|
-
}
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { createOpenXhsArtifactEditorTool, createReadXhsArtifactTool, serveXhsArtifact, XhsArtifactRegistry, xhsArtifactPresentation } from './artifacts.mjs';
|
|
3
|
+
import { serveXhsEditorPage } from './editor-page.mjs';
|
|
4
|
+
import { parseXhsArtifactToolOutput } from './xhs-artifact-contract.mjs';
|
|
5
|
+
|
|
6
|
+
export function createPlugin(context) {
|
|
7
|
+
const configuredStorageDir = String(context.env.NEO_XHS_ARTIFACTS_DIR || '').trim();
|
|
8
|
+
const storageDir = configuredStorageDir
|
|
9
|
+
? path.resolve(configuredStorageDir)
|
|
10
|
+
: path.join(context.appDataDir || path.join(context.pluginDir, '.data'), 'xhs-artifacts');
|
|
11
|
+
const registry = new XhsArtifactRegistry({ storageDir });
|
|
12
|
+
return {
|
|
13
|
+
tools: [
|
|
14
|
+
createOpenXhsArtifactEditorTool({ registry }),
|
|
15
|
+
createReadXhsArtifactTool({ registry }),
|
|
16
|
+
],
|
|
17
|
+
presentToolResult({ toolName, output, ok, sessionId }) {
|
|
18
|
+
if (!ok) return undefined;
|
|
19
|
+
if (toolName === 'read_xhs_artifact') {
|
|
20
|
+
return { title: '读取小红书笔记', text: '已读取稿件。', presentationLevel: 'process' };
|
|
21
|
+
}
|
|
22
|
+
if (toolName !== 'open_xhs_artifact_editor') return undefined;
|
|
23
|
+
const artifact = parseXhsArtifactToolOutput(output);
|
|
24
|
+
return artifact ? xhsArtifactPresentation(artifact, sessionId) : undefined;
|
|
25
|
+
},
|
|
26
|
+
async route(req, res, url, helpers) {
|
|
27
|
+
if (!['GET', 'PUT'].includes(req.method || '') || !url.pathname.startsWith('/api/xhs-artifacts/')) return false;
|
|
28
|
+
const suffix = url.pathname.slice('/api/xhs-artifacts/'.length);
|
|
29
|
+
const editorMatch = /^([^/]+)\/editor$/.exec(suffix);
|
|
30
|
+
const id = decodeURIComponent(editorMatch?.[1] || suffix);
|
|
31
|
+
if (editorMatch) {
|
|
32
|
+
if (req.method !== 'GET') {
|
|
33
|
+
helpers.sendJson?.(res, { error: 'method not allowed' }, 405);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
const artifact = registry.get(id, url.searchParams.get('sessionId') || undefined);
|
|
37
|
+
if (!artifact) {
|
|
38
|
+
helpers.sendJson?.(res, { error: 'artifact not found' }, 404);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
serveXhsEditorPage(res, artifact, { sessionId: url.searchParams.get('sessionId') || undefined });
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
await serveXhsArtifact(registry, req, res, id, helpers.readJsonBody, url.searchParams.get('sessionId') || undefined);
|
|
45
|
+
return true;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
{
|
|
2
|
-
"protocol": "neo-plugin/v1",
|
|
3
|
-
"id": "xhs-artifact",
|
|
4
|
-
"name": "小红书编辑器",
|
|
5
|
-
"version": "1.0.0",
|
|
6
|
-
"entry": "index.mjs",
|
|
7
|
-
"defaultEnabled": true,
|
|
8
|
-
"description": "提供小红书稿件编辑器工具、持久化和 HTTP 访问能力。"
|
|
9
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"protocol": "neo-plugin/v1",
|
|
3
|
+
"id": "xhs-artifact",
|
|
4
|
+
"name": "小红书编辑器",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"entry": "index.mjs",
|
|
7
|
+
"defaultEnabled": true,
|
|
8
|
+
"description": "提供小红书稿件编辑器工具、持久化和 HTTP 访问能力。"
|
|
9
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Serialized into the existing editor; no dependencies or alternate visual surface.
|
|
2
|
+
export function installVersioning(initial, apiUrl, editor) {
|
|
3
|
+
const $ = id => document.getElementById(id);
|
|
4
|
+
let version = initial.version || 1, dirty = false, stale = false, saving = false, timer, checking = false;
|
|
5
|
+
const identity = { artifact_id: initial.id, sessionId: initial.sessionId || '' };
|
|
6
|
+
function siblingFrames() { try { return window === parent ? [] : [...parent.document.querySelectorAll('iframe')]; } catch { return []; } }
|
|
7
|
+
const slot = window === parent ? 'standalone' : String(siblingFrames().indexOf(window.frameElement));
|
|
8
|
+
const key = 'neoctl.xhs.draft.v1:' + JSON.stringify([identity.sessionId, identity.artifact_id, slot]);
|
|
9
|
+
let basePayload = JSON.parse(JSON.stringify(initial.payload));
|
|
10
|
+
let backup;
|
|
11
|
+
try { backup = JSON.parse(sessionStorage.getItem(key) || 'null'); } catch {}
|
|
12
|
+
const actions = document.createElement('span');
|
|
13
|
+
actions.innerHTML = ' <span id="versionState" role="status"></span> <button type="button" id="latestVersion" hidden>加载最新版</button> <button type="button" id="downloadDraft" hidden>下载未保存草稿</button> <button type="button" id="restoreDraft" hidden>恢复草稿供人工处理</button>';
|
|
14
|
+
$('status').after(actions);
|
|
15
|
+
function status(text, error = false) { editor.status(text, error); }
|
|
16
|
+
function controls() {
|
|
17
|
+
$('versionState').textContent = 'v' + version + (stale ? ' · 已过期/只读' : '');
|
|
18
|
+
$('latestVersion').hidden = !stale;
|
|
19
|
+
$('downloadDraft').hidden = !backup;
|
|
20
|
+
$('restoreDraft').hidden = !backup || stale;
|
|
21
|
+
$('form').querySelectorAll('input,textarea,button').forEach(el => { el.disabled = stale; });
|
|
22
|
+
$('save').disabled = stale || saving;
|
|
23
|
+
$('add').disabled = $('upload').disabled = stale;
|
|
24
|
+
}
|
|
25
|
+
function persist() {
|
|
26
|
+
backup = { ...identity, version, basePayload, payload: editor.payload() };
|
|
27
|
+
try { sessionStorage.setItem(key, JSON.stringify(backup)); }
|
|
28
|
+
catch { status('草稿备份失败,请勿关闭页面并下载草稿', true); }
|
|
29
|
+
controls();
|
|
30
|
+
}
|
|
31
|
+
function expired() {
|
|
32
|
+
stale = true; clearTimeout(timer);
|
|
33
|
+
status('版本冲突:本实例已过期,只读;未保存草稿已保留', true); controls();
|
|
34
|
+
}
|
|
35
|
+
function valid(artifact) { return artifact?.id === identity.artifact_id && (artifact.sessionId || '') === identity.sessionId && Number.isSafeInteger(artifact.version); }
|
|
36
|
+
async function fetchLatest() {
|
|
37
|
+
const res = await fetch(apiUrl, { cache: 'no-store' });
|
|
38
|
+
const body = await res.json();
|
|
39
|
+
if (!res.ok || !valid(body.artifact)) throw new Error(body.error || '版本读取失败');
|
|
40
|
+
return body.artifact;
|
|
41
|
+
}
|
|
42
|
+
async function check() {
|
|
43
|
+
if (checking || saving) return;
|
|
44
|
+
checking = true;
|
|
45
|
+
const checkedVersion = version;
|
|
46
|
+
try { const current = await fetchLatest(); if (!saving && version === checkedVersion && current.version > version) expired(); }
|
|
47
|
+
catch (e) { status('版本检查失败,可重试:' + e.message, true); }
|
|
48
|
+
finally { checking = false; }
|
|
49
|
+
}
|
|
50
|
+
function changed() {
|
|
51
|
+
dirty = true; persist(); clearTimeout(timer);
|
|
52
|
+
if (!stale) { timer = setTimeout(save, 600); status('待保存'); }
|
|
53
|
+
}
|
|
54
|
+
async function save() {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
if (stale || saving || !dirty) return;
|
|
57
|
+
const sent = JSON.stringify(editor.payload());
|
|
58
|
+
saving = true; controls(); status('保存中…');
|
|
59
|
+
try {
|
|
60
|
+
const res = await fetch(apiUrl, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: editor.payload().title, payload: JSON.parse(sent), expected_version: version }) });
|
|
61
|
+
const body = await res.json();
|
|
62
|
+
if (res.status === 409) { expired(); return; }
|
|
63
|
+
if (!res.ok || !valid(body.artifact)) throw new Error(body.error || '保存失败');
|
|
64
|
+
version = body.artifact.version;
|
|
65
|
+
basePayload = body.artifact.payload;
|
|
66
|
+
dirty = JSON.stringify(editor.payload()) !== sent;
|
|
67
|
+
if (!dirty) {
|
|
68
|
+
editor.replace(body.artifact.payload);
|
|
69
|
+
backup = null; sessionStorage.removeItem(key);
|
|
70
|
+
} else persist();
|
|
71
|
+
status(dirty ? '待保存(保留保存期间的输入)' : '已保存');
|
|
72
|
+
notify();
|
|
73
|
+
} catch (e) { status(e.message || '保存失败,可重试', true); }
|
|
74
|
+
finally { saving = false; controls(); if (dirty && !stale && JSON.stringify(editor.payload()) !== sent) timer = setTimeout(save, 600); }
|
|
75
|
+
}
|
|
76
|
+
function notify() {
|
|
77
|
+
const message = { type: 'neo-xhs-version-invalidated', ...identity };
|
|
78
|
+
if (window !== parent) for (const frame of siblingFrames()) {
|
|
79
|
+
if (frame.contentWindow !== window) frame.contentWindow?.postMessage(message, location.origin);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
addEventListener('message', e => {
|
|
83
|
+
if (e.origin !== location.origin || e.data?.type !== 'neo-xhs-version-invalidated' || e.data.artifact_id !== identity.artifact_id || e.data.sessionId !== identity.sessionId) return;
|
|
84
|
+
if (window === parent || !siblingFrames().some(frame => frame.contentWindow === e.source && new URL(frame.src, location.href).origin === location.origin && new URL(frame.src, location.href).pathname === location.pathname && new URL(frame.src, location.href).searchParams.get('sessionId') === (identity.sessionId || null))) return;
|
|
85
|
+
void check();
|
|
86
|
+
});
|
|
87
|
+
$('latestVersion').onclick = async () => {
|
|
88
|
+
try {
|
|
89
|
+
const latest = await fetchLatest();
|
|
90
|
+
version = latest.version; basePayload = latest.payload; stale = false; dirty = false;
|
|
91
|
+
editor.replace(latest.payload); status('已加载最新版;原草稿仍可下载或恢复'); controls();
|
|
92
|
+
} catch (e) { status(e.message, true); }
|
|
93
|
+
};
|
|
94
|
+
$('downloadDraft').onclick = () => {
|
|
95
|
+
const url = URL.createObjectURL(new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' }));
|
|
96
|
+
const a = document.createElement('a'); a.href = url; a.download = initial.id + '-draft.json'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
97
|
+
};
|
|
98
|
+
$('restoreDraft').onclick = () => {
|
|
99
|
+
if (!backup || stale) return;
|
|
100
|
+
const merged = { ...editor.payload() };
|
|
101
|
+
for (const key of Object.keys(backup.payload)) if (!backup.basePayload || JSON.stringify(backup.payload[key]) !== JSON.stringify(backup.basePayload[key])) merged[key] = backup.payload[key];
|
|
102
|
+
editor.replace(merged); dirty = true; persist();
|
|
103
|
+
status('草稿已恢复,请人工核对后点击保存(不会自动覆盖最新版)');
|
|
104
|
+
};
|
|
105
|
+
$('save').onclick = save;
|
|
106
|
+
addEventListener('focus', check);
|
|
107
|
+
addEventListener('online', check);
|
|
108
|
+
addEventListener('beforeunload', e => { if (dirty) { persist(); e.preventDefault(); e.returnValue = ''; } });
|
|
109
|
+
setInterval(check, 1500);
|
|
110
|
+
if (backup?.artifact_id === identity.artifact_id && backup.sessionId === identity.sessionId) {
|
|
111
|
+
editor.replace(backup.payload); dirty = true;
|
|
112
|
+
basePayload = backup.basePayload || basePayload;
|
|
113
|
+
if (backup.version !== version) { version = backup.version; expired(); }
|
|
114
|
+
else status('已恢复未保存草稿,请核对后保存');
|
|
115
|
+
} else backup = null;
|
|
116
|
+
controls(); void check();
|
|
117
|
+
return { changed, save, readonly: () => stale };
|
|
118
|
+
}
|