yuque-cookie-plugin 0.1.0

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.
@@ -0,0 +1,544 @@
1
+ import { mkdir, stat, writeFile } from 'node:fs/promises';
2
+ import { basename, extname } from 'node:path';
3
+ import path from 'node:path';
4
+ import { openAsBlob } from 'node:fs';
5
+ import { timestampForFile } from "./fs-utils.js";
6
+ import { writeReport } from "./reports.js";
7
+ const DEFAULT_HOST = 'https://www.yuque.com';
8
+ export class YuqueCookieClient {
9
+ session;
10
+ ctoken;
11
+ extraCookies;
12
+ host;
13
+ constructor({ session, ctoken, extraCookies = {}, host = DEFAULT_HOST }) {
14
+ if (!session)
15
+ throw new Error('Missing YUQUE_SESSION.');
16
+ if (!ctoken)
17
+ throw new Error('Missing YUQUE_CTOKEN.');
18
+ this.session = session;
19
+ this.ctoken = ctoken;
20
+ this.extraCookies = extraCookies;
21
+ this.host = host;
22
+ }
23
+ cookieHeader() {
24
+ const cookies = {
25
+ _yuque_session: this.session,
26
+ yuque_ctoken: this.ctoken,
27
+ ...this.extraCookies
28
+ };
29
+ return Object.entries(cookies)
30
+ .filter(([, value]) => value)
31
+ .map(([key, value]) => `${key}=${value};`)
32
+ .join(' ');
33
+ }
34
+ htmlHeaders(extra = {}) {
35
+ return {
36
+ cookie: this.cookieHeader(),
37
+ 'user-agent': 'Mozilla/5.0',
38
+ accept: 'text/html,application/xhtml+xml',
39
+ ...extra
40
+ };
41
+ }
42
+ jsonHeaders(extra = {}) {
43
+ return {
44
+ cookie: this.cookieHeader(),
45
+ 'user-agent': 'Mozilla/5.0',
46
+ accept: 'application/json',
47
+ 'content-type': 'application/json',
48
+ 'x-csrf-token': this.ctoken,
49
+ 'x-requested-with': 'XMLHttpRequest',
50
+ ...extra
51
+ };
52
+ }
53
+ async inspect(url) {
54
+ const appData = await this.fetchAppData(url);
55
+ return summarizeAppData(appData);
56
+ }
57
+ async getBookInfo(url) {
58
+ const appData = await this.fetchAppData(url);
59
+ if (!appData.book?.id)
60
+ throw new Error('No found book id');
61
+ return {
62
+ bookId: appData.book.id,
63
+ bookSlug: appData.book.slug,
64
+ tocList: appData.book.toc || [],
65
+ bookName: appData.book.name || '',
66
+ bookDesc: appData.book.description || '',
67
+ host: appData.space?.host || DEFAULT_HOST,
68
+ imageServiceDomains: appData.imageServiceDomains || []
69
+ };
70
+ }
71
+ async getDocInfoFromUrl(url) {
72
+ const appData = await this.fetchAppData(url);
73
+ if (!appData.doc)
74
+ throw new Error('Failed to get document info from URL');
75
+ return {
76
+ docId: appData.doc.id,
77
+ docSlug: appData.doc.slug,
78
+ docTitle: appData.doc.title || '',
79
+ bookId: appData.doc.book_id,
80
+ bookSlug: appData.book?.slug || '',
81
+ bookName: appData.book?.name || '',
82
+ host: appData.space?.host || DEFAULT_HOST,
83
+ imageServiceDomains: appData.imageServiceDomains || []
84
+ };
85
+ }
86
+ async getDocMarkdownData(params) {
87
+ const { articleUrl, bookId, host = DEFAULT_HOST, isMarkdown = true } = params;
88
+ const queryParams = {
89
+ book_id: String(bookId),
90
+ merge_dynamic_data: String(false)
91
+ };
92
+ if (isMarkdown)
93
+ queryParams.mode = 'markdown';
94
+ const query = new URLSearchParams(queryParams).toString();
95
+ const apiUrl = `${host}/api/docs/${articleUrl}?${query}`;
96
+ const res = await fetch(apiUrl, {
97
+ headers: this.jsonHeaders()
98
+ });
99
+ const text = await res.text();
100
+ return {
101
+ apiUrl,
102
+ httpStatus: res.status,
103
+ response: text ? JSON.parse(text) : undefined
104
+ };
105
+ }
106
+ async getVideoInfo(videoId) {
107
+ const apiUrl = `${this.host}/api/video?${new URLSearchParams({ video_id: videoId }).toString()}`;
108
+ const res = await fetch(apiUrl, {
109
+ headers: this.jsonHeaders()
110
+ });
111
+ if (!res.ok)
112
+ return false;
113
+ const data = await res.json();
114
+ if (data?.data?.status === 'success')
115
+ return data.data.info || false;
116
+ return false;
117
+ }
118
+ async createBook(params) {
119
+ const referer = `${this.host}/dashboard`;
120
+ const payload = {
121
+ name: params.name,
122
+ slug: params.slug,
123
+ description: params.description || '',
124
+ public: params.public ? 1 : 0,
125
+ type: 'Book',
126
+ repo_type: 'Book'
127
+ };
128
+ const created = await this.requestJson('POST', '/api/books', payload, referer);
129
+ const book = created.data || created;
130
+ const url = book.user?.login && book.slug ? `${this.host}/${book.user.login}/${book.slug}` : `${this.host}/${book.slug || ''}`;
131
+ const report = await writeReport('create-book', {
132
+ ok: true,
133
+ book: {
134
+ id: book.id,
135
+ slug: book.slug,
136
+ name: book.name,
137
+ public: book.public
138
+ },
139
+ url
140
+ });
141
+ return {
142
+ ok: true,
143
+ book_id: book.id,
144
+ slug: book.slug,
145
+ name: book.name,
146
+ public: book.public,
147
+ url,
148
+ report
149
+ };
150
+ }
151
+ async createMarkdownDoc(bookUrl, params) {
152
+ const appData = await this.fetchAppData(bookUrl);
153
+ const book = appData.book;
154
+ if (!book?.id)
155
+ throw new Error('URL is not a readable Yuque book page.');
156
+ if (!book.abilities?.create_doc)
157
+ throw new Error(`Current account cannot create docs in book "${book.name || book.slug || book.id}".`);
158
+ const payload = {
159
+ book_id: book.id,
160
+ title: params.title,
161
+ format: 'markdown',
162
+ body: params.body,
163
+ body_draft: params.body,
164
+ public: 0
165
+ };
166
+ if (params.slug)
167
+ payload.slug = params.slug;
168
+ if (params.attachToToc !== false) {
169
+ payload.insert_to_catalog = true;
170
+ payload.action = params.tocAction || 'appendByDocs';
171
+ payload.target_uuid = params.targetUuid || '';
172
+ }
173
+ const created = await this.requestJson('POST', '/api/docs', payload, bookUrl);
174
+ const doc = created.data || created;
175
+ const toc = Array.isArray(created.toc) ? created.toc : [];
176
+ const tocAttached = toc.some((item) => Number(item.doc_id || item.id) === Number(doc.id));
177
+ const docUrl = buildDocUrl(this.host, appData, book, doc);
178
+ const report = await writeReport('create-doc', {
179
+ ok: true,
180
+ book: {
181
+ id: book.id,
182
+ slug: book.slug,
183
+ name: book.name
184
+ },
185
+ doc: {
186
+ id: doc.id,
187
+ slug: doc.slug,
188
+ title: doc.title,
189
+ format: doc.format
190
+ },
191
+ url: docUrl,
192
+ toc_attached: tocAttached,
193
+ toc
194
+ });
195
+ return {
196
+ ok: true,
197
+ book_id: book.id,
198
+ doc_id: doc.id,
199
+ slug: doc.slug,
200
+ title: doc.title,
201
+ format: doc.format,
202
+ url: docUrl,
203
+ toc_attached: tocAttached,
204
+ toc,
205
+ report
206
+ };
207
+ }
208
+ async uploadAttach(refererUrl, filePath) {
209
+ const appData = await this.fetchAppData(refererUrl);
210
+ const me = appData.me;
211
+ if (!me?.id)
212
+ throw new Error('Failed to resolve current Yuque user id for upload.');
213
+ const absolutePath = path.resolve(filePath);
214
+ const info = await stat(absolutePath);
215
+ if (!info.isFile())
216
+ throw new Error(`Upload path is not a file: ${filePath}`);
217
+ const fileName = basename(absolutePath);
218
+ const contentType = guessContentType(fileName);
219
+ const blob = await openAsBlob(absolutePath, { type: contentType });
220
+ const form = new FormData();
221
+ form.append('file', new File([blob], fileName, { type: contentType }));
222
+ const query = new URLSearchParams({
223
+ attachable_type: 'User',
224
+ attachable_id: String(me.id),
225
+ type: contentType.startsWith('image/') ? 'image' : 'attachment',
226
+ ctoken: this.ctoken
227
+ }).toString();
228
+ const endpoint = `/api/upload/attach?${query}`;
229
+ const safeEndpoint = `/api/upload/attach?${new URLSearchParams({
230
+ attachable_type: 'User',
231
+ attachable_id: String(me.id),
232
+ type: contentType.startsWith('image/') ? 'image' : 'attachment',
233
+ ctoken: '<redacted>'
234
+ }).toString()}`;
235
+ const res = await fetch(`${this.host}${endpoint}`, {
236
+ method: 'POST',
237
+ headers: {
238
+ cookie: this.cookieHeader(),
239
+ 'user-agent': 'Mozilla/5.0',
240
+ accept: 'application/json',
241
+ 'x-csrf-token': this.ctoken,
242
+ 'x-requested-with': 'XMLHttpRequest',
243
+ referer: refererUrl
244
+ },
245
+ body: form
246
+ });
247
+ const text = await res.text();
248
+ const response = text ? JSON.parse(text) : {};
249
+ const upload = sanitizeUploadResponse(response.data || response);
250
+ const report = await writeReport('upload-attach', {
251
+ ok: res.ok,
252
+ url: refererUrl,
253
+ endpoint: safeEndpoint,
254
+ file: {
255
+ name: fileName,
256
+ size: info.size,
257
+ type: contentType
258
+ },
259
+ upload
260
+ });
261
+ if (!res.ok)
262
+ throw new Error(`POST ${safeEndpoint} failed: ${res.status} ${text.slice(0, 500)}. Report: ${report}`);
263
+ return {
264
+ ok: true,
265
+ file: {
266
+ name: fileName,
267
+ size: info.size,
268
+ type: contentType
269
+ },
270
+ upload,
271
+ report
272
+ };
273
+ }
274
+ async fetchAppData(url) {
275
+ const res = await fetch(url, { headers: this.htmlHeaders() });
276
+ const html = await res.text();
277
+ const match = /decodeURIComponent\("(.+)"\)\);/m.exec(html);
278
+ if (!match)
279
+ throw new Error(`Failed to parse appData from ${url}; HTTP ${res.status}`);
280
+ return JSON.parse(decodeURIComponent(match[1]));
281
+ }
282
+ async snapshotDoc(url) {
283
+ const appData = await this.fetchAppData(url);
284
+ const doc = appData.doc;
285
+ const book = appData.book;
286
+ if (!doc?.id || !book?.id)
287
+ throw new Error('URL is not a readable Yuque document page.');
288
+ const edit = await this.getDocById(doc.id, book.id, 'edit', url);
289
+ const markdown = await this.getDocById(doc.id, book.id, 'markdown', url);
290
+ const lake = await this.getDocById(doc.id, book.id, 'lake', url);
291
+ return {
292
+ captured_at: new Date().toISOString(),
293
+ url,
294
+ book: {
295
+ id: book.id,
296
+ slug: book.slug,
297
+ name: book.name
298
+ },
299
+ doc: {
300
+ id: doc.id,
301
+ slug: doc.slug,
302
+ title: doc.title,
303
+ format: doc.format
304
+ },
305
+ edit: pickDocFields(edit.data),
306
+ markdown: pickDocFields(markdown.data),
307
+ lake: pickDocFields(lake.data)
308
+ };
309
+ }
310
+ async getDocById(docId, bookId, mode, referer) {
311
+ const endpoint = `${this.host}/api/docs/${docId}?book_id=${bookId}&mode=${mode}`;
312
+ const res = await fetch(endpoint, {
313
+ headers: this.jsonHeaders({ referer })
314
+ });
315
+ const text = await res.text();
316
+ if (!res.ok)
317
+ throw new Error(`GET ${endpoint} failed: ${res.status} ${text.slice(0, 500)}`);
318
+ return JSON.parse(text);
319
+ }
320
+ async updateLakeDoc(url, bodyAsl) {
321
+ return this.applyLakeDoc(url, bodyAsl, { dryRun: false, command: 'update-lake' });
322
+ }
323
+ async planLakeApply(url, bodyAsl) {
324
+ const snapshot = await this.snapshotDoc(url);
325
+ const validation = validateLakeApply(snapshot, bodyAsl);
326
+ return buildApplyPlan(snapshot, bodyAsl, validation);
327
+ }
328
+ async applyLakeDoc(url, bodyAsl, { dryRun = false, command = 'apply-lake' } = {}) {
329
+ const snapshot = await this.snapshotDoc(url);
330
+ const validation = validateLakeApply(snapshot, bodyAsl);
331
+ if (!validation.ok) {
332
+ const report = await writeReport(command, {
333
+ ok: false,
334
+ dry_run: dryRun,
335
+ url,
336
+ validation
337
+ });
338
+ throw new Error(`Refusing to apply Lake document: ${validation.errors.join('; ')}. Report: ${report}`);
339
+ }
340
+ const plan = buildApplyPlan(snapshot, bodyAsl, validation);
341
+ if (dryRun) {
342
+ const report = await writeReport(command, {
343
+ ok: true,
344
+ dry_run: true,
345
+ ...plan
346
+ });
347
+ return {
348
+ ok: true,
349
+ dry_run: true,
350
+ report,
351
+ plan
352
+ };
353
+ }
354
+ const backupPath = await writeSnapshotBackup(snapshot);
355
+ const { id } = snapshot.doc;
356
+ await this.requestJson('PUT', `/api/docs/${id}/content`, {
357
+ id,
358
+ body_asl: bodyAsl,
359
+ body_draft_asl: bodyAsl,
360
+ format: 'lake',
361
+ save_type: 'user',
362
+ draft_version: snapshot.edit.draft_version
363
+ }, url);
364
+ await this.requestJson('PUT', `/api/docs/${id}/publish`, { id }, url);
365
+ const report = await writeReport(command, {
366
+ ok: true,
367
+ dry_run: false,
368
+ backup: backupPath,
369
+ ...plan
370
+ });
371
+ return {
372
+ ok: true,
373
+ dry_run: false,
374
+ doc_id: id,
375
+ book_id: snapshot.book.id,
376
+ backup: backupPath,
377
+ report
378
+ };
379
+ }
380
+ async requestJson(method, pathName, body, referer) {
381
+ const res = await fetch(`${this.host}${pathName}`, {
382
+ method,
383
+ headers: this.jsonHeaders({ referer }),
384
+ body: body ? JSON.stringify(body) : undefined
385
+ });
386
+ const text = await res.text();
387
+ if (!res.ok)
388
+ throw new Error(`${method} ${pathName} failed: ${res.status} ${text.slice(0, 500)}`);
389
+ return text ? JSON.parse(text) : {};
390
+ }
391
+ }
392
+ function validateLakeApply(snapshot, bodyAsl) {
393
+ const errors = [];
394
+ const warnings = [];
395
+ const current = snapshot.edit?.body_asl || '';
396
+ if (snapshot.doc?.format !== 'lake')
397
+ errors.push(`document format is "${snapshot.doc?.format}", expected "lake"`);
398
+ if (snapshot.edit?.draft_version === undefined || snapshot.edit?.draft_version === null)
399
+ errors.push('missing draft_version');
400
+ if (!bodyAsl?.trim())
401
+ errors.push('lake file is empty');
402
+ if (bodyAsl && bodyAsl.length < 20)
403
+ errors.push('lake file is suspiciously short');
404
+ if (current && bodyAsl) {
405
+ const ratio = bodyAsl.length / current.length;
406
+ if (ratio < 0.25 || ratio > 4)
407
+ warnings.push(`large body size change: ${current.length} -> ${bodyAsl.length}`);
408
+ }
409
+ return {
410
+ ok: errors.length === 0,
411
+ errors,
412
+ warnings
413
+ };
414
+ }
415
+ function buildApplyPlan(snapshot, bodyAsl, validation) {
416
+ const current = snapshot.edit?.body_asl || '';
417
+ return {
418
+ captured_at: new Date().toISOString(),
419
+ url: snapshot.url,
420
+ doc: snapshot.doc,
421
+ book: snapshot.book,
422
+ validation,
423
+ stats: {
424
+ current_lake_length: current.length,
425
+ next_lake_length: bodyAsl.length,
426
+ length_delta: bodyAsl.length - current.length,
427
+ changed: current !== bodyAsl
428
+ }
429
+ };
430
+ }
431
+ async function writeSnapshotBackup(snapshot) {
432
+ const backupDir = path.resolve('backups');
433
+ await mkdir(backupDir, { recursive: true });
434
+ const backupPath = path.join(backupDir, `${snapshot.doc.id}-${timestampForFile()}.snapshot.json`);
435
+ await writeFile(backupPath, `${JSON.stringify(snapshot, null, 2)}\n`);
436
+ return backupPath;
437
+ }
438
+ function summarizeAppData(appData) {
439
+ return {
440
+ book: appData.book && {
441
+ id: appData.book.id,
442
+ slug: appData.book.slug,
443
+ name: appData.book.name,
444
+ toc_count: appData.book.toc?.length,
445
+ abilities: appData.book.abilities
446
+ },
447
+ doc: appData.doc && {
448
+ id: appData.doc.id,
449
+ slug: appData.doc.slug,
450
+ title: appData.doc.title,
451
+ book_id: appData.doc.book_id,
452
+ format: appData.doc.format
453
+ },
454
+ me: appData.me && {
455
+ id: appData.me.id,
456
+ login: appData.me.login,
457
+ name: appData.me.name
458
+ },
459
+ space: appData.space && {
460
+ host: appData.space.host,
461
+ login: appData.space.login
462
+ }
463
+ };
464
+ }
465
+ function pickDocFields(doc = {}) {
466
+ return {
467
+ id: doc.id,
468
+ slug: doc.slug,
469
+ title: doc.title,
470
+ format: doc.format,
471
+ draft_version: doc.draft_version,
472
+ content_updated_at: doc.content_updated_at,
473
+ updated_at: doc.updated_at,
474
+ published_at: doc.published_at,
475
+ body: doc.body,
476
+ body_draft: doc.body_draft,
477
+ body_asl: doc.body_asl,
478
+ body_draft_asl: doc.body_draft_asl,
479
+ sourcecode: doc.sourcecode,
480
+ serializer: doc.serializer
481
+ };
482
+ }
483
+ function buildDocUrl(host, appData, book, doc) {
484
+ const spaceLogin = appData.space?.login || appData.me?.login;
485
+ const bookSlug = book.slug;
486
+ const docSlug = doc.slug || doc.id;
487
+ if (spaceLogin && bookSlug && docSlug)
488
+ return `${host}/${spaceLogin}/${bookSlug}/${docSlug}`;
489
+ if (bookSlug && docSlug)
490
+ return `${host}/${bookSlug}/${docSlug}`;
491
+ return `${host}/api/docs/${doc.id || ''}`;
492
+ }
493
+ function guessContentType(fileName) {
494
+ const ext = extname(fileName).toLowerCase();
495
+ const types = {
496
+ '.png': 'image/png',
497
+ '.jpg': 'image/jpeg',
498
+ '.jpeg': 'image/jpeg',
499
+ '.webp': 'image/webp',
500
+ '.gif': 'image/gif',
501
+ '.svg': 'image/svg+xml',
502
+ '.pdf': 'application/pdf',
503
+ '.zip': 'application/zip',
504
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
505
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
506
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
507
+ };
508
+ return types[ext] || 'application/octet-stream';
509
+ }
510
+ function sanitizeUploadResponse(upload) {
511
+ const attachment = upload.attachment && typeof upload.attachment === 'object'
512
+ ? {
513
+ id: upload.attachment.id,
514
+ filename: upload.attachment.filename,
515
+ ext: upload.attachment.ext,
516
+ filesize: upload.attachment.filesize,
517
+ filekey: upload.attachment.filekey,
518
+ filemd5: upload.attachment.filemd5,
519
+ mode: upload.attachment.mode,
520
+ attachable_type: upload.attachment.attachable_type,
521
+ attachable_id: upload.attachment.attachable_id,
522
+ user_id: upload.attachment.user_id,
523
+ created_at: upload.attachment.created_at,
524
+ updated_at: upload.attachment.updated_at
525
+ }
526
+ : undefined;
527
+ return {
528
+ filekey: upload.filekey,
529
+ extname: upload.extname,
530
+ mode: upload.mode,
531
+ url: upload.url,
532
+ etag: upload.etag,
533
+ size: upload.size,
534
+ filename: upload.filename,
535
+ filemd5: upload.filemd5,
536
+ element_id: upload.element_id,
537
+ attachment_id: upload.attachment_id,
538
+ attachable_type: upload.attachable_type,
539
+ attachable_id: upload.attachable_id,
540
+ ...(upload.symlink === undefined ? {} : { symlink: upload.symlink }),
541
+ ...(upload.isCopy === undefined ? {} : { isCopy: upload.isCopy }),
542
+ ...(attachment ? { attachment } : {})
543
+ };
544
+ }