@petercjl/ecom-cli 0.3.0 → 0.3.2
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 +24 -1
- package/capabilities.json +1096 -94
- package/lib/browser.mjs +69 -12
- package/lib/cdp.mjs +79 -0
- package/lib/cli.mjs +41 -5
- package/lib/dmp-audience-dev.json +4 -0
- package/lib/maintenance.mjs +14 -2
- package/lib/memory.mjs +146 -0
- package/lib/page.mjs +63 -0
- package/lib/sycm.mjs +80 -8
- package/lib/xhs-note.mjs +322 -0
- package/lib/xhs.mjs +36 -0
- package/package.json +1 -1
- package/skills/ecom-cli/SKILL.md +70 -4
- package/skills/ecom-cli/capabilities.json +29 -3
- package/skills/ecom-cli/package.json +1 -1
- package/skills/ecom-cli/references/sycm.md +7 -1
package/lib/sycm.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
|
+
import {fileURLToPath} from 'node:url';
|
|
5
6
|
import {fail,exists,privatePath,writeNew} from './common.mjs';
|
|
6
7
|
import {discover,requestInConnection,registeredReadInConnection,openBusinessPageInConnection,callMailbox,resolveContainer} from './browser.mjs';
|
|
7
8
|
import {listOperations,operationRegistry,getService,validateOperationParams} from './sycm-vendor/operation-registry.mjs';
|
|
@@ -22,8 +23,42 @@ const legacyReads=[
|
|
|
22
23
|
['word.related','/mc/mq/mkt/keyword/relate/analysis.json',['keyWord','dateRange'],{cycleFlag:'cycle',order:'desc',orderBy:'seIpvUvHits',rankType:'related',page:1,pageSize:10,device:'0',marketVersion:'free'}],
|
|
23
24
|
['word.category','/mc/mq/mkt/keyword/cate/analysis.json',['keyWord','dateRange'],{order:'desc',orderBy:'clickHitsRatio',page:1,pageSize:10,device:'0',marketVersion:'free'}]
|
|
24
25
|
].map(([id,path,requiredParams,defaultParams])=>({id,service:'sycm',method:'GET',path,requiredParams,defaultParams,sideEffect:'none',source:'src/sycm.mjs'}));
|
|
25
|
-
const
|
|
26
|
+
const browserOperations=()=>[...listOperations(),...legacyReads];
|
|
27
|
+
const dailyTrade={
|
|
28
|
+
...operationRegistry['shop.trade-summary'],
|
|
29
|
+
id:'shop.trade-daily',
|
|
30
|
+
browserOperation:'shop.trade-summary',
|
|
31
|
+
requiredParams:['date'],
|
|
32
|
+
defaultParams:{...operationRegistry['shop.trade-summary'].defaultParams,dateType:'recent1'},
|
|
33
|
+
};
|
|
34
|
+
const audienceEnvelope=JSON.parse(fs.readFileSync(fileURLToPath(new URL('./dmp-audience-dev.json',import.meta.url)),'utf8'));
|
|
35
|
+
const audienceManifest=JSON.parse(audienceEnvelope.payload);
|
|
36
|
+
const competitorAudience={
|
|
37
|
+
id:audienceManifest.id,service:'dmp',method:audienceManifest.method,path:audienceManifest.path,
|
|
38
|
+
requiredParams:['itemId'],defaultParams:{days:30,audienceType:'purchase',dimension:'gender'},
|
|
39
|
+
signedDevRoute:true,sideEffect:'none',
|
|
40
|
+
};
|
|
41
|
+
const audienceTypes={browse:['1','浏览'],favorite:['2','收藏'],cart:['3','加购'],purchase:['4','购买'],search:['5','搜索']};
|
|
42
|
+
const audienceDimensions={gender:114554,age:114555,consumption:163535,city:213510,segments:266238};
|
|
43
|
+
function audienceRequest(params) {
|
|
44
|
+
const {itemId,days=30,audienceType='purchase',dimension='gender'}=params;
|
|
45
|
+
if(Object.keys(params).some(key=>!['itemId','days','audienceType','dimension'].includes(key))||
|
|
46
|
+
typeof itemId!=='string'||!/^[1-9]\d{0,29}$/.test(itemId)||
|
|
47
|
+
![7,15,30,90].includes(days)||!Object.hasOwn(audienceTypes,audienceType)||
|
|
48
|
+
!Object.hasOwn(audienceDimensions,dimension)) {
|
|
49
|
+
throw fail('INVALID_ARGUMENT','Specify itemId as digits; use days 7/15/30/90, a known audienceType and dimension');
|
|
50
|
+
}
|
|
51
|
+
const [behavior,label]=audienceTypes[audienceType];
|
|
52
|
+
const select=(optionGroupId,values,names)=>({tagId:'283736',optionGroupId,tagName:'同行宝贝行为人群',values,names});
|
|
53
|
+
return {appCode:'dmpInsight',needUnknown:true,tagId:audienceDimensions[dimension],
|
|
54
|
+
selectTagOptionSetDTO:{operator:1,selectTagOptionSetDTO:[{operator:1,selects:[
|
|
55
|
+
select(303279,itemId,itemId),select(303280,behavior,label),select(303281,String(days),'最近'+days+'天'),
|
|
56
|
+
]}]}};
|
|
57
|
+
}
|
|
58
|
+
const allOperations=()=>[...browserOperations(),dailyTrade,competitorAudience];
|
|
26
59
|
const resolveOperation=id=>{
|
|
60
|
+
if(id===dailyTrade.id)return dailyTrade;
|
|
61
|
+
if(id===competitorAudience.id)return competitorAudience;
|
|
27
62
|
const found=legacyReads.find(x=>x.id===id)??(Object.hasOwn(operationRegistry,id)?operationRegistry[id]:null);
|
|
28
63
|
if(!found)throw fail('UNKNOWN_OPERATION','Choose a registered operation from sycm operations');
|
|
29
64
|
return found;
|
|
@@ -55,16 +90,20 @@ const transportFor=op=>op.method==='GET'&&getService(op.service).origin===ORIGIN
|
|
|
55
90
|
export function operationSchema(id) {
|
|
56
91
|
const op=resolveOperation(id), service=getService(op.service);
|
|
57
92
|
return {...op,origin:service.origin,entryUrl:service.entryUrl,tokenStrategy:service.tokenStrategy,transport:'native-business-read',legacyTransport:transportFor(op),runtimeVerified:false,
|
|
58
|
-
minimumBrowserCapability:'businessReadProtocol=1 with matching registry hash',limits:'Credentials remain native. Per-endpoint permissions, CSRF, date semantics and service login require real validation.'};
|
|
93
|
+
minimumBrowserCapability:op.signedDevRoute?'signedBusinessRouteProtocol=1 (Ecom Dev)':'businessReadProtocol=1 with businessRouteProtocol=1 or matching legacy registry hash',limits:'Credentials remain native. Per-endpoint permissions, CSRF, date semantics and service login require real validation.'};
|
|
59
94
|
}
|
|
60
95
|
export function nativeRegistry() {
|
|
61
|
-
return Object.fromEntries(
|
|
96
|
+
return Object.fromEntries(browserOperations().map(({id})=>operationSchema(id)).map(op=>[op.id,{
|
|
62
97
|
id:op.id,service:op.service,method:op.method,path:op.path,origin:op.origin,
|
|
63
98
|
entryUrl:op.entryUrl,tokenStrategy:op.tokenStrategy,requiredParams:op.requiredParams,
|
|
64
99
|
defaultParams:op.defaultParams??{},...(op.encoding?{encoding:op.encoding}:{}),
|
|
65
100
|
...(op.requestShape?{requestShape:op.requestShape,moduleId:op.moduleId,bizCode:op.bizCode,accessBizCode:op.accessBizCode}:{})
|
|
66
101
|
}]));
|
|
67
102
|
}
|
|
103
|
+
function browserRoute(id) {
|
|
104
|
+
const {id: routeId, defaultParams, ...route}=nativeRegistry()[id];
|
|
105
|
+
return route;
|
|
106
|
+
}
|
|
68
107
|
export const nativeRegistryHash=()=>crypto.createHash('sha256').update(JSON.stringify(nativeRegistry())).digest('hex');
|
|
69
108
|
export function inventory() {
|
|
70
109
|
return {ok:true,schemaVersion:1,source:{package:'@petercjl/sycmcli',version:'0.4.0',license:'MIT'},
|
|
@@ -131,26 +170,59 @@ export function resolveModuleResponse(response,flows) {
|
|
|
131
170
|
}
|
|
132
171
|
export async function runRead(options,deps={}) {
|
|
133
172
|
const params=paramsObject(options['params-json']),op=resolveOperation(options.operation);
|
|
134
|
-
|
|
173
|
+
const browserOperation=op.browserOperation??op.id;
|
|
174
|
+
if(op.id===dailyTrade.id) {
|
|
175
|
+
const date=params.date;
|
|
176
|
+
if(typeof date!=='string'||!/^\d{4}-\d{2}-\d{2}$/.test(date)||
|
|
177
|
+
new Date(date).toJSON()?.slice(0,10)!==date||Object.hasOwn(params,'dateRange')) {
|
|
178
|
+
throw fail('INVALID_ARGUMENT','Specify one valid YYYY-MM-DD date for shop.trade-daily');
|
|
179
|
+
}
|
|
180
|
+
delete params.date;
|
|
181
|
+
params.dateRange=date+'|'+date;
|
|
182
|
+
params.dateType='recent1';
|
|
183
|
+
}
|
|
184
|
+
assertPublicParams(params);validateOperationParams(resolveOperation(browserOperation),params);
|
|
185
|
+
const signedParams=op.signedDevRoute?audienceRequest(params):null;
|
|
135
186
|
if(Buffer.byteLength(JSON.stringify(params))>24576)throw fail('INVALID_ARGUMENT','Read parameters exceed the native 24 KiB limit');
|
|
136
187
|
const connection=(deps.discover??discover)(options);
|
|
137
188
|
let result;
|
|
138
|
-
if(
|
|
189
|
+
if(signedParams) {
|
|
190
|
+
result=await (deps.registeredRequest??registeredReadInConnection)(
|
|
191
|
+
connection,options,op.id,signedParams,undefined,undefined,audienceEnvelope);
|
|
192
|
+
} else if(connection.endpoint.businessReadProtocol===1) {
|
|
193
|
+
const supportsRoute=connection.endpoint.businessRouteProtocol===1;
|
|
194
|
+
const effective=supportsRoute?{...op.defaultParams,...params}:params;
|
|
195
|
+
assertPublicParams(effective);
|
|
196
|
+
if(Buffer.byteLength(JSON.stringify(effective))>24576)throw fail('INVALID_ARGUMENT','Read parameters exceed the native 24 KiB limit');
|
|
197
|
+
result=await (deps.registeredRequest??registeredReadInConnection)(
|
|
198
|
+
connection,options,browserOperation,effective,supportsRoute?undefined:nativeRegistryHash(),
|
|
199
|
+
supportsRoute?browserRoute(browserOperation):undefined);
|
|
200
|
+
}
|
|
139
201
|
else {
|
|
140
202
|
if(transportFor(op)!=='native-session-get')throw fail('BROWSER_UPGRADE_REQUIRED','Upgrade Ecom to native business read protocol 1 for this registered service; keep the existing container login');
|
|
141
|
-
const built=buildRead(
|
|
203
|
+
const built=buildRead(browserOperation,params);
|
|
142
204
|
result=await (deps.request??requestInConnection)(connection,{...options,url:built.url,method:'GET'});
|
|
143
205
|
}
|
|
144
206
|
try {
|
|
145
207
|
const data=businessResponse(result.data),flows=result.dataFlows??[];
|
|
146
208
|
const expanded=op.requestShape==='alimama-dynamic-module'&&hasTemplate(data)?resolveModuleResponse(data,flows):undefined;
|
|
209
|
+
if(signedParams&&(data.info?.ok!==true||
|
|
210
|
+
!Array.isArray(data.data?.result?.chartDataFull??data.data?.chartDataFull))) {
|
|
211
|
+
throw fail('BUSINESS_RESPONSE_INVALID','DMP did not return a verified audience chart',{executed:true});
|
|
212
|
+
}
|
|
147
213
|
const partial=Boolean(expanded&&hasTemplate(expanded));
|
|
148
214
|
return {...result,...(flows.length?{dataFlows:sanitized(flows)}:{}),...(expanded?{resolvedResponse:expanded,completeness:{complete:!partial,scope:'registered-module-response',...(partial?{reason:'Platform data flows returned successfully but some template references have no returned value; inspect resolvedResponse and do not infer missing values.'}:{})}}:{}),operation:op.id,service:op.service,data,businessStatus:partial?'partial':'success',fetchedAt:new Date().toISOString(),dataDateVerification:'caller-must-check-requested-period-and-platform-update-time'};
|
|
149
215
|
}catch(error){error.operationId=result.operationId;throw error;}
|
|
150
216
|
}
|
|
151
217
|
export async function openOperation(options,deps={}) {
|
|
152
|
-
const op=operationSchema(options.operation)
|
|
153
|
-
|
|
218
|
+
const op=operationSchema(options.operation);
|
|
219
|
+
if(op.signedDevRoute)throw fail('BUSINESS_PAGE_UNSUPPORTED','Signed routes support reads only');
|
|
220
|
+
const connection=(deps.discover??discover)(options);
|
|
221
|
+
const supportsRoute=connection.endpoint.businessRouteProtocol===1;
|
|
222
|
+
const browserOperation=op.browserOperation??op.id;
|
|
223
|
+
const result=await (deps.open??openBusinessPageInConnection)(connection,options,browserOperation,op.entryUrl,
|
|
224
|
+
supportsRoute?undefined:nativeRegistryHash(),supportsRoute?browserRoute(browserOperation):undefined);
|
|
225
|
+
return {...result,operation:op.id};
|
|
154
226
|
}
|
|
155
227
|
async function account(connection,selector) {
|
|
156
228
|
const list=await callMailbox(connection,{method:'shops.list'});
|
package/lib/xhs-note.mjs
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {setTimeout as pause} from 'node:timers/promises';
|
|
5
|
+
import {fail, privatePath, writeNew} from './common.mjs';
|
|
6
|
+
import {discover, inspectInConnection, callMailbox} from './browser.mjs';
|
|
7
|
+
|
|
8
|
+
const maxImage = 10 * 1024 * 1024;
|
|
9
|
+
const maxTotal = 24 * 1024 * 1024;
|
|
10
|
+
const uuid = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/;
|
|
11
|
+
const accountPattern = /^[0-9a-f]{24}$/;
|
|
12
|
+
const states = new Set(['opening','login-required','verification-required','page-changed',
|
|
13
|
+
'account-unverified','account-ready','account-mismatch','unsupported-form',
|
|
14
|
+
'uploading','upload-incomplete','form-readback-mismatch','form-not-empty','form-changed','form-ready',
|
|
15
|
+
'images-ready','review-incomplete','review-ready',
|
|
16
|
+
'submission-checking','submission-unknown','not-submitted','submitted','published']);
|
|
17
|
+
export function validateNoteOptions(action, options) {
|
|
18
|
+
if (['preflight','prepare','upload','fill'].includes(action) && !options.manifest)
|
|
19
|
+
throw fail('INVALID_ARGUMENT','Specify --manifest');
|
|
20
|
+
if (action === 'preflight') return;
|
|
21
|
+
if (!options.container) throw fail('INVALID_ARGUMENT','Specify --container');
|
|
22
|
+
if (options['fixture-port'] !== undefined && (!/^\d{4,5}$/.test(options['fixture-port']) ||
|
|
23
|
+
Number(options['fixture-port']) < 1024 || Number(options['fixture-port']) > 65535))
|
|
24
|
+
throw fail('INVALID_ARGUMENT','Specify a loopback fixture port from 1024 to 65535');
|
|
25
|
+
if (['status','verify','prepare','submit','upload','fill'].includes(action) && !uuid.test(options.preparation??''))
|
|
26
|
+
throw fail('INVALID_ARGUMENT','Specify --preparation with the ID returned by open');
|
|
27
|
+
if (['prepare','submit','upload','fill'].includes(action) &&
|
|
28
|
+
(!uuid.test(options.instance??'') || !accountPattern.test(options['account-id']??'')))
|
|
29
|
+
throw fail('INVALID_ARGUMENT','Pin --instance and the verified --account-id');
|
|
30
|
+
if (['prepare','upload','fill'].includes(action)&&!/^[0-9a-f]{64}$/.test(options.digest??''))
|
|
31
|
+
throw fail('INVALID_ARGUMENT','Specify --digest from the reviewed preflight');
|
|
32
|
+
if (action==='submit'&&(!options.confirm?.startsWith(options.preparation+':') ||
|
|
33
|
+
!/^[0-9a-f]{64}$/.test(options.confirm.slice(37))))
|
|
34
|
+
throw fail('INVALID_ARGUMENT','Specify --confirm from the reviewed ready form');
|
|
35
|
+
}
|
|
36
|
+
const creatorUrl = 'https://creator.xiaohongshu.com/publish/publish';
|
|
37
|
+
const hash = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
|
|
38
|
+
const invalid = message => fail('INVALID_NOTE', message, {executed:false});
|
|
39
|
+
|
|
40
|
+
function writeAttempt(file, value) {
|
|
41
|
+
const fd = fs.openSync(file,'wx',0o600);
|
|
42
|
+
try {
|
|
43
|
+
fs.writeFileSync(fd,JSON.stringify(value)+'\n');
|
|
44
|
+
fs.fsyncSync(fd);
|
|
45
|
+
} finally { fs.closeSync(fd); }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readBounded(file, limit) {
|
|
49
|
+
let fd;
|
|
50
|
+
try {
|
|
51
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
|
52
|
+
const stat = fs.fstatSync(fd);
|
|
53
|
+
if (!stat.isFile() || !stat.size || stat.size > limit) throw Error();
|
|
54
|
+
const bytes = Buffer.alloc(stat.size + 1);
|
|
55
|
+
let n = 0;
|
|
56
|
+
while (n < bytes.length) {
|
|
57
|
+
const got = fs.readSync(fd, bytes, n, bytes.length - n, null);
|
|
58
|
+
if (!got) break;
|
|
59
|
+
n += got;
|
|
60
|
+
}
|
|
61
|
+
if (n !== stat.size) throw Error();
|
|
62
|
+
return bytes.subarray(0,n);
|
|
63
|
+
} catch {
|
|
64
|
+
throw invalid('Input must be a readable regular file within the size limit');
|
|
65
|
+
} finally {
|
|
66
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function imageType(bytes) {
|
|
71
|
+
if (bytes.length >= 33 && bytes.subarray(0,8).equals(Buffer.from('89504e470d0a1a0a','hex')) &&
|
|
72
|
+
bytes.subarray(8,16).equals(Buffer.from('0000000d49484452','hex')) &&
|
|
73
|
+
bytes.readUInt32BE(16) > 0 && bytes.readUInt32BE(20) > 0 &&
|
|
74
|
+
bytes.subarray(-12).equals(Buffer.from('0000000049454e44ae426082','hex'))) return 'image/png';
|
|
75
|
+
if (bytes.length >= 20 && bytes[0] === 255 && bytes[1] === 216 &&
|
|
76
|
+
bytes[2] === 255 && bytes.at(-2) === 255 && bytes.at(-1) === 217) return 'image/jpeg';
|
|
77
|
+
throw invalid('Images must have PNG or JPEG signatures');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function readNote(file, keepBytes = false) {
|
|
81
|
+
if (!file) throw invalid('Specify --manifest with a JSON note manifest');
|
|
82
|
+
const manifest = path.resolve(file);
|
|
83
|
+
let doc;
|
|
84
|
+
try { doc = JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(readBounded(manifest,16384))); }
|
|
85
|
+
catch { throw invalid('Expected a readable UTF-8 JSON manifest of at most 16 KiB'); }
|
|
86
|
+
if (!doc || Array.isArray(doc) || typeof doc !== 'object' ||
|
|
87
|
+
Object.keys(doc).some(k=>!['title','body','topics','images'].includes(k)))
|
|
88
|
+
throw invalid('Manifest accepts title, body, topics and ordered images');
|
|
89
|
+
const {title,body,topics=[],images} = doc;
|
|
90
|
+
if (typeof title !== 'string' || !title.trim() || title !== title.trim() || title.length > 20 || /[\r\n\x00-\x1f]/.test(title))
|
|
91
|
+
throw invalid('Title must be a single trimmed line of 1 to 20 UTF-16 units');
|
|
92
|
+
if (typeof body !== 'string' || !body.trim() || /[\x00-\x08\x0b-\x1f]/.test(body))
|
|
93
|
+
throw invalid('Body must contain text; use LF for line breaks');
|
|
94
|
+
if (!Array.isArray(topics) || topics.length > 10 || new Set(topics).size !== topics.length ||
|
|
95
|
+
topics.some(t=>typeof t !== 'string' || !/^[\p{L}\p{M}\p{N}_-]{1,30}$/u.test(t)))
|
|
96
|
+
throw invalid('Topics must be up to 10 unique plain names without # or spaces');
|
|
97
|
+
const text = body + (topics.length ? '\n\n' + topics.map(t=>'#'+t).join(' ') : '');
|
|
98
|
+
if (text.length > 1000) throw invalid('Body including topic text must fit 1000 UTF-16 units');
|
|
99
|
+
if (!Array.isArray(images) || !images.length || images.length > 18)
|
|
100
|
+
throw invalid('Provide 1 to 18 ordered images');
|
|
101
|
+
let total = 0;
|
|
102
|
+
const media = images.map((name,index)=>{
|
|
103
|
+
if (typeof name !== 'string' || !name.trim() || name.includes('\0'))
|
|
104
|
+
throw invalid('Each image must be a local file path');
|
|
105
|
+
const bytes = readBounded(path.resolve(path.dirname(manifest),name),maxImage);
|
|
106
|
+
total += bytes.length;
|
|
107
|
+
if (total > maxTotal) throw invalid('Combined images exceed the Ecom limit of 24 MiB');
|
|
108
|
+
return {index:index+1,name:path.basename(name),type:imageType(bytes),size:bytes.length,sha256:hash(bytes),...(keepBytes?{bytes}:{})};
|
|
109
|
+
});
|
|
110
|
+
if (new Set(media.map(m=>m.sha256)).size !== media.length)
|
|
111
|
+
throw invalid('Image bytes must be unique');
|
|
112
|
+
const summary = {title,body,topics,images:media.map(({bytes,...m})=>m)};
|
|
113
|
+
return {summary,text,digest:hash(JSON.stringify({title,text,images:media.map(m=>m.sha256)})),media};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function noteCommand(action, options, deps={}) {
|
|
117
|
+
if (action === 'preflight') {
|
|
118
|
+
const note = readNote(options.manifest);
|
|
119
|
+
return {ok:true,schemaVersion:1,state:'preflight-passed',manifestDigest:note.digest,
|
|
120
|
+
note:note.summary,topicMode:'literal-text',submitted:false};
|
|
121
|
+
}
|
|
122
|
+
if (!options.container) throw fail('INVALID_ARGUMENT','Specify --container');
|
|
123
|
+
const connection = (deps.discover??discover)(options);
|
|
124
|
+
const fixture = options.fixture === true || options['fixture-port'] !== undefined;
|
|
125
|
+
if (fixture && connection.channel !== 'dev')
|
|
126
|
+
throw fail('XHS_FIXTURE_DEV_REQUIRED','Loopback fixtures require an explicitly selected Ecom Dev instance');
|
|
127
|
+
const {container} = await (deps.inspect??inspectInConnection)(connection,options.container);
|
|
128
|
+
if (container.platform !== 'xiaohongshu')
|
|
129
|
+
throw fail('XHS_CONTAINER_REQUIRED','Select a Xiaohongshu account container');
|
|
130
|
+
const preparation = options.preparation;
|
|
131
|
+
const instance = {id:connection.endpoint.instanceId,channel:connection.channel};
|
|
132
|
+
const receiptPath = preparation && uuid.test(preparation)
|
|
133
|
+
? path.join(connection.folder,preparation+'.xhs-attempt.json') : null;
|
|
134
|
+
const resultPath = receiptPath?.replace('.xhs-attempt.json','.xhs-result.json');
|
|
135
|
+
const receipt = () => {
|
|
136
|
+
if (!receiptPath) return null;
|
|
137
|
+
try {
|
|
138
|
+
privatePath(receiptPath);
|
|
139
|
+
const item = JSON.parse(fs.readFileSync(receiptPath,'utf8'));
|
|
140
|
+
if (item.containerId !== container.id || item.fixture !== fixture ||
|
|
141
|
+
item.preparationId !== preparation)
|
|
142
|
+
throw fail('XHS_PREPARATION_NOT_FOUND','Attempt belongs to a different container or fixture');
|
|
143
|
+
return item;
|
|
144
|
+
} catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
145
|
+
};
|
|
146
|
+
const claimPath = (accountId,digest) => path.join(connection.folder,
|
|
147
|
+
hash(JSON.stringify([container.id,fixture,accountId,digest]))+'.xhs-claim.json');
|
|
148
|
+
const alreadyClaimed = file => {
|
|
149
|
+
try {
|
|
150
|
+
privatePath(file);
|
|
151
|
+
const previous = JSON.parse(fs.readFileSync(file,'utf8'));
|
|
152
|
+
throw fail('XHS_SAME_NOTE_ATTEMPTED','This account already has a submission attempt for the same material',
|
|
153
|
+
{executed:'unknown',recovery:'xhs note status --preparation '+previous.preparationId});
|
|
154
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
155
|
+
};
|
|
156
|
+
const prior = action === 'status' || action === 'submit' ? receipt() : null;
|
|
157
|
+
const settled = () => {
|
|
158
|
+
if (!prior) return null;
|
|
159
|
+
try {
|
|
160
|
+
privatePath(resultPath);
|
|
161
|
+
const item = JSON.parse(fs.readFileSync(resultPath,'utf8'));
|
|
162
|
+
if (item.preparationId === preparation && item.container?.id === container.id &&
|
|
163
|
+
item.instance?.id === prior.instance.id && item.fixture === fixture) return item;
|
|
164
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
165
|
+
return null;
|
|
166
|
+
};
|
|
167
|
+
const recovered = () => settled() ?? ({
|
|
168
|
+
ok:true,schemaVersion:1,state:'submission-unknown',instance:prior.instance,
|
|
169
|
+
container:{id:container.id,name:container.name},preparationId:preparation,fixture,
|
|
170
|
+
accountId:prior.accountId,manifestDigest:prior.manifestDigest,
|
|
171
|
+
recovery:'Inspect the creator account’s note management page before considering another submission'
|
|
172
|
+
});
|
|
173
|
+
if (prior && action === 'submit')
|
|
174
|
+
throw fail('XHS_SUBMISSION_ALREADY_ATTEMPTED','This preparation has a saved submission attempt; use xhs note status',{executed:'unknown'});
|
|
175
|
+
if (prior && (prior.instance.id !== instance.id || settled())) return recovered();
|
|
176
|
+
const settle = (result, attempt) => {
|
|
177
|
+
if (!['published','not-submitted'].includes(result.state)) return;
|
|
178
|
+
try { writeNew(resultPath,result); } catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
179
|
+
if (result.state === 'not-submitted') {
|
|
180
|
+
const claim = claimPath(attempt.accountId,attempt.manifestDigest);
|
|
181
|
+
try {
|
|
182
|
+
privatePath(claim);
|
|
183
|
+
if (JSON.parse(fs.readFileSync(claim,'utf8')).preparationId === preparation)
|
|
184
|
+
fs.unlinkSync(claim);
|
|
185
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
if (connection.endpoint.xhsNoteProtocol !== 1)
|
|
189
|
+
throw fail('BROWSER_UPGRADE_REQUIRED','Xiaohongshu note actions require Ecom with xhsNoteProtocol 1');
|
|
190
|
+
if (['upload','fill','verify'].includes(action) && connection.endpoint.xhsNoteReviewProtocol !== 1)
|
|
191
|
+
throw fail('BROWSER_UPGRADE_REQUIRED','Split note preparation requires xhsNoteReviewProtocol 1');
|
|
192
|
+
const call = deps.call??callMailbox;
|
|
193
|
+
const native = async (verb, extra={}) => {
|
|
194
|
+
const result = await call(connection,{method:'xhs.note.'+verb,containerId:container.id,
|
|
195
|
+
...(preparation?{preparationId:preparation}:{}),...extra},15000);
|
|
196
|
+
const data = result.noteState;
|
|
197
|
+
if (result.containerId !== container.id || result.fixture !== fixture ||
|
|
198
|
+
!uuid.test(result.preparationId??'') ||
|
|
199
|
+
(verb !== 'open' && result.preparationId !== (extra.preparationId??preparation)) ||
|
|
200
|
+
!data || !states.has(data.state) ||
|
|
201
|
+
(data.accountId !== undefined && !accountPattern.test(data.accountId)) ||
|
|
202
|
+
(data.state === 'review-ready' && (!Number.isInteger(data.imageCount) ||
|
|
203
|
+
data.imageCount < 1 || data.imageCount > 18 ||
|
|
204
|
+
data.fields?.imageCount !== data.imageCount || data.fields?.imagesMatch !== true ||
|
|
205
|
+
data.fields?.title !== 'matched' || data.fields?.body !== 'matched' ||
|
|
206
|
+
data.fields?.publishEnabled !== true)) ||
|
|
207
|
+
(data.state === 'published' && !accountPattern.test(data.noteId??'')) ||
|
|
208
|
+
(data.state === 'form-ready' && (!data.confirmation ||
|
|
209
|
+
data.confirmation !== result.preparationId+':'+data.manifestDigest ||
|
|
210
|
+
!/^[0-9a-f]{64}$/.test(data.manifestDigest??'') ||
|
|
211
|
+
!Number.isInteger(data.imageCount) || data.imageCount<1 || data.imageCount>18)))
|
|
212
|
+
throw fail('PROTOCOL_MISMATCH','Native note response did not match the selected preparation',{executed:'unknown',operationId:result.operationId});
|
|
213
|
+
return {ok:true,schemaVersion:1,...data,operationId:result.operationId,instance,
|
|
214
|
+
container:{id:container.id,name:container.name},preparationId:result.preparationId,fixture,
|
|
215
|
+
creatorUrl:fixture?null:creatorUrl,
|
|
216
|
+
...(data.state==='published'&&!fixture?{noteUrl:'https://www.xiaohongshu.com/explore/'+data.noteId}:{})};
|
|
217
|
+
};
|
|
218
|
+
const until = async (current, waiting, ms) => {
|
|
219
|
+
const end = Date.now()+ms;
|
|
220
|
+
while (waiting.includes(current.state) && Date.now()<end) {
|
|
221
|
+
await (deps.pause??pause)(500);
|
|
222
|
+
current = await native('status',{preparationId:current.preparationId});
|
|
223
|
+
}
|
|
224
|
+
return current;
|
|
225
|
+
};
|
|
226
|
+
if (action === 'open') {
|
|
227
|
+
// Creator identity can arrive before the upload controls render.
|
|
228
|
+
return until(await native('open',fixture?{fixturePort:Number(options['fixture-port'])}:{}),['opening','unsupported-form'],15000);
|
|
229
|
+
}
|
|
230
|
+
if (action === 'verify') {
|
|
231
|
+
const result = await until(await native('status'),['opening'],9000);
|
|
232
|
+
const fields = result.fields;
|
|
233
|
+
if (!['images-ready','review-incomplete','review-ready'].includes(result.state) || !fields)
|
|
234
|
+
return result;
|
|
235
|
+
const contentVerified = fields.title === 'matched' && fields.body === 'matched' &&
|
|
236
|
+
fields.imagesMatch === true && fields.imageCount === result.imageCount &&
|
|
237
|
+
Number.isInteger(result.imageCount) && result.imageCount >= 1 && result.imageCount <= 18;
|
|
238
|
+
return {...result,state:contentVerified?'content-verified':'review-incomplete',
|
|
239
|
+
contentVerified,publishControl:fields.publishEnabled?'enabled':'unconfirmed'};
|
|
240
|
+
}
|
|
241
|
+
if (action === 'status') {
|
|
242
|
+
try {
|
|
243
|
+
const result = await native('status');
|
|
244
|
+
if (prior) settle(result,prior);
|
|
245
|
+
return prior && !['published','submitted','not-submitted','submission-checking','submission-unknown'].includes(result.state) ? recovered() : result;
|
|
246
|
+
} catch (error) {
|
|
247
|
+
if (prior && ['XHS_PREPARATION_NOT_FOUND','XHS_PREPARATION_EXPIRED','OPERATION_TIMEOUT','INSTANCE_CHANGED'].includes(error.code))
|
|
248
|
+
return recovered();
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const state = await until(await native('status'),['opening'],9000);
|
|
253
|
+
const accountId = options['account-id'];
|
|
254
|
+
if (state.accountId !== accountId)
|
|
255
|
+
throw fail('XHS_ACCOUNT_NOT_VERIFIED','The current creator account did not match --account-id: '+state.state);
|
|
256
|
+
if (action === 'fill') {
|
|
257
|
+
const note = readNote(options.manifest);
|
|
258
|
+
if (note.digest !== options.digest || state.manifestDigest !== note.digest)
|
|
259
|
+
throw fail('XHS_MANIFEST_CHANGED','The manifest does not match the uploaded images and text');
|
|
260
|
+
if (!['images-ready','review-incomplete','review-ready'].includes(state.state))
|
|
261
|
+
throw fail('XHS_FORM_UNAVAILABLE','Check image upload status before filling: '+state.state);
|
|
262
|
+
return native('fill',{accountId,manifestDigest:note.digest});
|
|
263
|
+
}
|
|
264
|
+
if (action === 'prepare' || action === 'upload') {
|
|
265
|
+
if (state.state !== 'account-ready')
|
|
266
|
+
throw fail('XHS_FORM_UNAVAILABLE','Open a fresh creator preparation and inspect the account: '+state.state);
|
|
267
|
+
const note = readNote(options.manifest,true);
|
|
268
|
+
if (note.digest !== options.digest)
|
|
269
|
+
throw fail('XHS_MANIFEST_CHANGED','Repeat preflight and review the current manifest digest');
|
|
270
|
+
alreadyClaimed(claimPath(accountId,note.digest));
|
|
271
|
+
const staged = [];
|
|
272
|
+
try {
|
|
273
|
+
privatePath(connection.folder,true);
|
|
274
|
+
const media = note.media.map(({bytes,type,sha256})=>{
|
|
275
|
+
const stage = crypto.randomUUID();
|
|
276
|
+
const file = path.join(connection.folder,stage+'.xhs-media');
|
|
277
|
+
const fd = fs.openSync(file,'wx',0o600);
|
|
278
|
+
staged.push(file);
|
|
279
|
+
try { fs.writeFileSync(fd,bytes); } finally { fs.closeSync(fd); }
|
|
280
|
+
return {stage,type,sha256};
|
|
281
|
+
});
|
|
282
|
+
return await until(await native(action,{accountId,manifestDigest:note.digest,
|
|
283
|
+
title:note.summary.title,text:note.text,media}),['uploading','opening'],95000);
|
|
284
|
+
} finally {
|
|
285
|
+
for (const file of staged) {
|
|
286
|
+
try { fs.unlinkSync(file); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (action === 'submit') {
|
|
291
|
+
if (state.state !== 'form-ready' || state.confirmation !== options.confirm)
|
|
292
|
+
throw fail('XHS_CONFIRMATION_MISMATCH','Use the confirmation returned by this ready preparation');
|
|
293
|
+
const attempt = {preparationId:preparation,containerId:container.id,instance,fixture,accountId,manifestDigest:state.manifestDigest};
|
|
294
|
+
const claim = claimPath(accountId,state.manifestDigest);
|
|
295
|
+
alreadyClaimed(claim);
|
|
296
|
+
try { writeAttempt(receiptPath,attempt); }
|
|
297
|
+
catch (error) {
|
|
298
|
+
if (error.code === 'EEXIST') throw fail('XHS_SUBMISSION_ALREADY_ATTEMPTED','Inspect xhs note status; the preparation already has a submission attempt',{executed:'unknown'});
|
|
299
|
+
throw error;
|
|
300
|
+
}
|
|
301
|
+
try { writeAttempt(claim,attempt); }
|
|
302
|
+
catch (error) {
|
|
303
|
+
settle({...state,state:'not-submitted',reason:'local-attempt-claim-failed'},attempt);
|
|
304
|
+
if (error.code === 'EEXIST') alreadyClaimed(claim);
|
|
305
|
+
throw error;
|
|
306
|
+
}
|
|
307
|
+
let result;
|
|
308
|
+
try {
|
|
309
|
+
result = await until(await native('submit',{accountId,confirm:options.confirm}),['submission-checking','submission-unknown'],12000);
|
|
310
|
+
} catch (error) {
|
|
311
|
+
throw fail(error.code??'XHS_SUBMISSION_UNKNOWN','Submission outcome is uncertain; inspect xhs note status before any new attempt',
|
|
312
|
+
{executed:'unknown',operationId:error.operationId,recovery:'xhs note status --preparation '+preparation});
|
|
313
|
+
}
|
|
314
|
+
settle(result,attempt);
|
|
315
|
+
if (result.state === 'submission-checking') {
|
|
316
|
+
return {...result,state:'submission-unknown',
|
|
317
|
+
recovery:'Inspect xhs note status before considering another submission'};
|
|
318
|
+
}
|
|
319
|
+
return result;
|
|
320
|
+
}
|
|
321
|
+
throw fail('INVALID_ARGUMENT','Unknown note command');
|
|
322
|
+
}
|
package/lib/xhs.mjs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import {fail} from './common.mjs';
|
|
3
|
+
import {discover,inspectInConnection,callMailbox} from './browser.mjs';
|
|
4
|
+
|
|
5
|
+
export function validateSearchResult(body, keyword) {
|
|
6
|
+
if(body?.success!==true||body.transport!=='background-page-dom'||body.keyword!==keyword||body.data?.keyword!==keyword||body.data?.state!=='ready'||!Array.isArray(body.data.items)||!body.data.items.length||body.data.items.length>20)
|
|
7
|
+
throw fail('XHS_SEARCH_REJECTED','Xiaohongshu did not return the requested search list',{executed:true});
|
|
8
|
+
const seen=new Set();
|
|
9
|
+
const items=body.data.items.map(item=>{
|
|
10
|
+
if(!item||typeof item.id!=='string'||!/^[a-f0-9]{24}$/.test(item.id)||seen.has(item.id)||typeof item.title!=='string'||item.title.length>500||typeof item.author!=='string'||item.author.length>100||typeof item.likes!=='string'||item.likes.length>30||item.url!=='https://www.xiaohongshu.com/explore/'+item.id)
|
|
11
|
+
throw fail('PROTOCOL_MISMATCH','Invalid Xiaohongshu note projection',{executed:true});
|
|
12
|
+
seen.add(item.id);
|
|
13
|
+
return {id:item.id,title:item.title,author:item.author,likes:item.likes,url:item.url};
|
|
14
|
+
});
|
|
15
|
+
return {transport:body.transport,pageClosed:body.pageClosed===true,keyword,count:items.length,items};
|
|
16
|
+
}
|
|
17
|
+
export async function searchXhs(options, dependencies={}) {
|
|
18
|
+
const keyword=options.keyword?.trim();
|
|
19
|
+
if(!options.container||!keyword||Buffer.byteLength(keyword)>180||options.via!=='page')
|
|
20
|
+
throw fail('INVALID_ARGUMENT','Specify --container, --keyword and --via page');
|
|
21
|
+
const connect=dependencies.discover??discover;
|
|
22
|
+
const inspect=dependencies.inspect??inspectInConnection;
|
|
23
|
+
const call=dependencies.call??callMailbox;
|
|
24
|
+
const connection=connect(options);
|
|
25
|
+
if(connection.endpoint.xhsPageSearchProtocol!==1)
|
|
26
|
+
throw fail('BROWSER_UPGRADE_REQUIRED','This search requires a compatible Ecom Dev with xhsPageSearchProtocol 1');
|
|
27
|
+
const {container}=await inspect(connection,options.container);
|
|
28
|
+
if(container.platform!=='xiaohongshu')throw fail('XHS_CONTAINER_REQUIRED','Select a Xiaohongshu account container');
|
|
29
|
+
const searchId=((BigInt(Date.now())<<64n)+BigInt(crypto.randomInt(2147483647))).toString(36).toUpperCase();
|
|
30
|
+
const result=await call(connection,{method:'business.read',operation:'xhs.search-page',containerId:container.id,params:{keyword,search_id:searchId}},30000);
|
|
31
|
+
if(result.containerId!==container.id||result.businessOperation!=='xhs.search-page')
|
|
32
|
+
throw fail('PROTOCOL_MISMATCH','Search response does not match the pinned container',{executed:'unknown',operationId:result.operationId});
|
|
33
|
+
return {ok:true,schemaVersion:1,operationId:result.operationId,
|
|
34
|
+
instance:{id:connection.endpoint.instanceId,channel:connection.channel},
|
|
35
|
+
container:{id:container.id,name:container.name},...validateSearchResult(result.data,keyword)};
|
|
36
|
+
}
|