dsh-fish-soul 0.15.21 → 0.15.23

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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  让一个 QQ 小号背后有"同一个人":固定人格、按人记记忆、有自己的生活节奏、会主动开口;同一个"她"同时出现在 QQ、B站、抖音。
4
4
 
5
- DSH 插件(单角色 `feiyu`)。版本 **0.15.21**。
5
+ DSH 插件(单角色 `feiyu`)。版本 **0.15.23**。
6
6
 
7
7
  QQ 交流群:**1125962189**
8
8
 
@@ -0,0 +1,77 @@
1
+ /**
2
+ * capability.js —— 「她关于自己装置能力」的说法(#76)。
3
+ *
4
+ * 问题:她曾长期坚称「我没有识图能力、只能读字」。这类陈述写进长期记忆后,
5
+ * 会因为「被反复想起」被自动升到 3 星(3 星不衰减 → 永生),每轮都注入,
6
+ * 于是现实(插件早就能发图 / 抽帧)变了,记忆却比现实更坚定,最后她被自己写下的记忆说服。
7
+ *
8
+ * 这里只做判定,不做存储策略:
9
+ * - isCapabilityClaim(text):这是不是一句「关于自己能不能做某事」的话;
10
+ * - CAPABILITY_NEGATIONS / CAPABILITY_SUBJECTS:判定用的词表(保守,宁可漏判也不误伤);
11
+ * - 她现在实际能做什么(PLUGIN_CAPABILITIES):插件侧的代码事实,供注入时的标注与人工核对。
12
+ *
13
+ * 只认**她自己**说的(owner_id = 'self'):说别人「他不能看图」是稳定的事实,不受影响。
14
+ */
15
+
16
+ /** 否定 / 限定的说法。 */
17
+ const NEGATIONS = ['不能', '不会', '没法', '无法', '不支持', '没有', '只能', '只会', '看不了', '读不了', '识不了', '发不了'];
18
+
19
+ /** 能力名词:装置 / 平台相关的功能。 */
20
+ const SUBJECTS = [
21
+ '看图', '识图', '读图', '看图片', '看照片', '图片', '照片', '看视频', '视频', '抽帧',
22
+ '语音', '表情包', '表情', '上网', '搜图', '搜索', '回评论', '评论', '刷B站', '刷抖音', 'B站', '抖音'
23
+ ];
24
+
25
+ /** 明确"只能读字"这类说法:字面不含上面的能力名词,但同样是能力断言。 */
26
+ const TEXT_ONLY = ['只能读字', '只能看字', '只认字', '只能看文字', '只能处理文字', '只支持文字'];
27
+
28
+ /**
29
+ * 插件现在实际做的事(代码事实,供人工核对 / 文档引用)。
30
+ * 注意:能不能"看懂"由宿主模型决定,插件只保证"确实把图/帧/语音送到她面前"。
31
+ */
32
+ export const PLUGIN_CAPABILITIES = [
33
+ '看图(QQ 照片、表情包原图)',
34
+ '看视频抽帧',
35
+ '收发语音',
36
+ '刷 B站 / 抖音',
37
+ '回评论(抖音 / B站)',
38
+ '主动开口、按人记忆'
39
+ ];
40
+
41
+ /**
42
+ * 这句话是不是「关于自己能不能做某事」的能力断言。
43
+ * 判定保守:必须同时出现"否定/限定"和"能力名词",或命中"只能读字"这类固定说法。
44
+ * @param {string} text
45
+ * @returns {boolean}
46
+ */
47
+ export function isCapabilityClaim(text) {
48
+ const s = String(text ?? '').trim();
49
+ if (s === '') return false;
50
+ if (TEXT_ONLY.some((t) => s.includes(t))) return true;
51
+ if (!NEGATIONS.some((n) => s.includes(n))) return false;
52
+ return SUBJECTS.some((t) => s.includes(t));
53
+ }
54
+
55
+ /** 第三人称标记:出现了就说明说的不是她自己("他不能看图"是稳定事实,不该按易失能力处理)。 */
56
+ const THIRD_PERSON = ['他们', '她们', '别人', '大家', '同事', '朋友', '老师', '他', '她'];
57
+
58
+ /**
59
+ * 这句话是不是「**她自己**说自己不能做某事」。
60
+ * 自己写的记忆通常是第一人称;出现第三人称就当说的是别人。
61
+ * @param {string} text
62
+ * @returns {boolean}
63
+ */
64
+ export function isSelfCapabilityClaim(text) {
65
+ const s = String(text ?? '').trim();
66
+ if (!isCapabilityClaim(s)) return false;
67
+ return !THIRD_PERSON.some((t) => s.includes(t));
68
+ }
69
+
70
+ /**
71
+ * 注入时给能力类记忆加的标注:提醒模型这是"以前的说法",装置 / 模型变了就可能过期。
72
+ * @param {string} text
73
+ * @returns {string} 标注(不是能力断言就返回空串)
74
+ */
75
+ export function capabilityStaleTag(text) {
76
+ return isSelfCapabilityClaim(text) ? '可能已过时' : '';
77
+ }
@@ -78,7 +78,9 @@ export function douyinConfigSchema(z) {
78
78
  videosPerSession: z.number().default(3),
79
79
  like: z.boolean().default(false),
80
80
  share: z.boolean().default(true), // 刷到特别想分享的,给主人发链接 + 感受(走主动开口的限制)
81
- shareInDm: z.boolean().default(true) // 抖音刷到的分享发在抖音私信里(主人;抖音没开 / 没连上就发 QQ)
81
+ shareInDm: z.boolean().default(true), // 抖音刷到的分享发在抖音私信里(主人;抖音没开 / 没连上就发 QQ)
82
+ // 刷抖音走哪条路:'http' = 纯 HTTP(tab/feed + detail + comment/list,不开浏览器);'browser' = 老路(专用浏览器里滑推荐页)
83
+ mode: z.string().default('http')
82
84
  }).default({}),
83
85
  post: z.object({ // 自己发图文作品(文字卡片 + 文案,走创作者中心,尽力而为)
84
86
  enabled: z.boolean().default(false),
@@ -1,8 +1,14 @@
1
1
  /**
2
- * douyin-browse.js —— 鱼自己刷抖音:空闲时在专用浏览器里打开推荐页,像人一样看几条(每条停 5–20 秒)再划走,
3
- * 然后挑出来的几条打开详情读文案和热评,用她自己的口吻写一两句感受,记进"自己的记忆"(owner_id='self')。
2
+ * douyin-browse.js —— 鱼自己刷抖音:空闲时拿一批推荐,像人一样看几条(每条停 5–20 秒)再划走,
3
+ * 然后挑出来的几条读详情和热评,用她自己的口吻写一两句感受,记进"自己的记忆"(owner_id='self')。
4
4
  * 默认不点赞(设置页可打开);直播间、看过的跳过。
5
5
  *
6
+ * 两条路(`douyin.browse.mode`):
7
+ * - **http(默认)**:纯 HTTP 拉推荐流(tab/feed)+ 详情 + 评论列表,不开浏览器。
8
+ * 实测(2026-11-08 真机):tab/feed 与 aweme/detail 只要 secsdk 那道 md5 签名;
9
+ * comment/list 还要 a_bogus(见 lib/douyin-http.js 与 vendor/douyin-bogus.js)。
10
+ * - **browser(旧路)**:在专用浏览器里滑推荐页,像以前一样。http 模式失败时自动退回这条路。
11
+ *
6
12
  * 读推荐页当前视频的做法参考了 LockyBee/dsh-douyin-agent(MIT)的 core.mjs,代码是这里自己写的。
7
13
  */
8
14
  import { afterRound } from './browse.js';
@@ -94,8 +100,9 @@ export class DouyinBrowser {
94
100
  * @param {() => {name: string, brief: string}} deps.persona
95
101
  * @param {[number, number]} [deps.dwellMs] 推荐页每条看多久(真实毫秒)
96
102
  * @param {(opts: {browser: object, id: string, commentLimit?: number}) => Promise<object>} [deps.readDetail]
103
+ * @param {import('./douyin-http.js').DouyinHttp} [deps.http] 纯 HTTP 客户端(http 模式用)
97
104
  */
98
- constructor({ db, clock, host, getConfig, callLlm, persona, worker, log, dwellMs = [5000, 20000], readDetail = detail, settleMs = 8000, fetchImpl, share, note }) {
105
+ constructor({ db, clock, host, getConfig, callLlm, persona, worker, log, dwellMs = [5000, 20000], readDetail = detail, settleMs = 8000, fetchImpl, share, note, http }) {
99
106
  this.share = share ?? null;
100
107
  this.note = note ?? null;
101
108
  this.db = db;
@@ -110,7 +117,9 @@ export class DouyinBrowser {
110
117
  this.readDetail = readDetail;
111
118
  this.settleMs = settleMs;
112
119
  this.fetch = fetchImpl ?? ((...a) => fetch(...a));
120
+ this.http = http ?? null; // 纯 HTTP 客户端(http 模式用;没有就退回浏览器)
113
121
  this.running = false;
122
+ this.httpBroken = false; // 这轮试过纯 HTTP 不行 → 退回浏览器
114
123
  this.described = new Map();
115
124
  }
116
125
 
@@ -128,9 +137,21 @@ export class DouyinBrowser {
128
137
  }
129
138
 
130
139
  get ready() {
140
+ // http 模式:cookie 里有登录态就行;没有 cookie 但浏览器在跑且已登录,也能现抓一次 cookie。
141
+ if (this.useHttp()) {
142
+ return (this.http !== null && this.http.isLoggedIn()) || (this.host.running && this.host.loggedIn === true);
143
+ }
131
144
  return this.host.running && this.host.loggedIn === true;
132
145
  }
133
146
 
147
+ /** 走纯 HTTP 还是走浏览器。默认 http;没有 http 客户端、或这轮试过不行,就退回浏览器。 */
148
+ useHttp() {
149
+ if (this.http === null) return false;
150
+ if (this.httpBroken === true) return false;
151
+ const mode = this.getConfig()?.browse?.mode;
152
+ return mode !== 'browser';
153
+ }
154
+
134
155
  seen(id) {
135
156
  return this.db.prepare('SELECT 1 FROM douyin_seen WHERE aweme_id = ?').get(String(id)) !== undefined;
136
157
  }
@@ -195,6 +216,43 @@ export class DouyinBrowser {
195
216
  }
196
217
 
197
218
  async scroll(count) {
219
+ if (this.useHttp()) {
220
+ const items = await this.httpScroll(count);
221
+ if (items.length > 0) return items;
222
+ this.log('warn', '[fish-soul] 抖音纯 HTTP 没拿到推荐,退回浏览器刷');
223
+ }
224
+ return this.browserScroll(count);
225
+ }
226
+
227
+ /**
228
+ * 纯 HTTP 拿推荐:tab/feed 每次返回都是全新的一批(实测 0 条重复),要多几条就多请求几次。
229
+ * 过滤:看过的、广告、直播/生活号、下架的。
230
+ */
231
+ async httpScroll(count) {
232
+ const picked = [];
233
+ const seenThisRound = new Set();
234
+ for (let round = 0; round < 3 && picked.length < count; round++) {
235
+ const feed = await this.http.feed(12);
236
+ if (!feed.ok) {
237
+ this.log('warn', `[fish-soul] 抖音推荐流没拉到:${feed.raw?.error ?? `status=${feed.raw?.status} status_code=${feed.raw?.json?.status_code}`}`);
238
+ break;
239
+ }
240
+ for (const a of feed.list) {
241
+ if (picked.length >= count) break;
242
+ const id = String(a.aweme_id ?? '');
243
+ if (id === '' || this.seen(id) || seenThisRound.has(id)) continue;
244
+ if (a.is_ads === true || a.is_life_item === true || a.is_story === true) continue; // 广告 / 直播 / 生活号
245
+ if (a.status?.is_prohibited === true || a.status?.in_reviewing === true) continue; // 下架 / 审核中
246
+ seenThisRound.add(id);
247
+ picked.push({ id, title: String(a.desc ?? '').trim().slice(0, 300), author: String(a.author?.nickname ?? '').trim().slice(0, 40) });
248
+ await sleep(this.dwell()); // 每条停一会儿 —— 跟以前在推荐页上滑一样
249
+ }
250
+ }
251
+ return picked;
252
+ }
253
+
254
+ /** 旧路:在浏览器推荐页里滑。 */
255
+ async browserScroll(count) {
198
256
  const b = this.host.browser;
199
257
  await b.goto(FEED_URL, { settleMs: this.settleMs });
200
258
  // 被 302 弹回精选页时,用真实鼠标点进推荐流;进不去就停下来,别再空刷一轮
@@ -252,6 +310,60 @@ export class DouyinBrowser {
252
310
 
253
311
  /** 细看一条:详情页读文案 + 热评 → 感受 → 记忆(要点赞就在详情页点)。 */
254
312
  async watch(item, like, share = false) {
313
+ if (this.useHttp()) {
314
+ const r = await this.httpWatch(item, like, share);
315
+ if (r !== null) return r;
316
+ this.log('warn', `[fish-soul] 抖音纯 HTTP 没读到 ${item.id},退回浏览器`);
317
+ }
318
+ return this.browserWatch(item, like, share);
319
+ }
320
+
321
+ /**
322
+ * 纯 HTTP 细看一条:aweme/detail 拿文案与话题 + comment/list 拿热评(comment/list 要 a_bogus)。
323
+ * 点赞暂时不做(纯 HTTP 的点赞接口要另外验签名),所以 liked 恒为 false。
324
+ * @returns {Promise<object|null>} null = 这条读不到,交给调用方退回浏览器
325
+ */
326
+ async httpWatch(item, like, share = false) {
327
+ const det = await this.http.detail(item.id);
328
+ if (!det.ok || det.detail === null) {
329
+ this.log('warn', `[fish-soul] 抖音视频 ${item.id} 详情没拉到:${det.raw?.error ?? `status=${det.raw?.status}`}`);
330
+ return null;
331
+ }
332
+ const d = det.detail;
333
+ const cm = await this.http.comments(item.id, { count: 6 });
334
+ const v = {
335
+ id: item.id,
336
+ title: String(d.desc ?? item.title ?? ''),
337
+ desc: String(d.desc ?? item.title ?? ''),
338
+ author: String(d.author?.nickname ?? item.author ?? ''),
339
+ tags: (Array.isArray(d.text_extra) ? d.text_extra : []).map((t) => String(t?.hashtag_name ?? '')).filter(Boolean),
340
+ comments: (cm.ok ? cm.list : []).map((c) => ({ text: String(c.text ?? ''), author: String(c.user?.nickname ?? '') }))
341
+ };
342
+ const out = await this.callLlm(this.promptFor(v, like, share));
343
+ const raw = String(out ?? '').trim();
344
+ const skipped = /\[划走\]/.test(raw);
345
+ const wantLike = like && /\[点赞\]/.test(raw);
346
+ const wantShare = share && !skipped && /\[分享\]/.test(raw);
347
+ const impression = oneLine(raw.replace(/\[(划走|点赞|分享)\]/g, ''), 120);
348
+ const title = oneLine(v.title || v.desc, 60);
349
+ let memoryId = null;
350
+ if (!skipped) {
351
+ const who = v.author ? `${oneLine(v.author, 20)} 的` : '';
352
+ const content = impression !== ''
353
+ ? `我在抖音刷到${who}视频《${title || '没写文案'}》:${impression}`
354
+ : `我在抖音刷到${who}视频《${title || '没写文案'}》`;
355
+ memoryId = this.writeMemory(content, v.id);
356
+ }
357
+ this.db.prepare(`
358
+ INSERT INTO douyin_seen (aweme_id, title, author, impression, skipped, liked, memory_id, seen_at, seen_real_at)
359
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
360
+ ON CONFLICT(aweme_id) DO NOTHING
361
+ `).run(v.id, oneLine(v.title || v.desc, 120), oneLine(v.author, 40), impression, skipped ? 1 : 0, 0, memoryId, this.clock.now(), Date.now());
362
+ return { id: v.id, title: v.title || v.desc, author: v.author, impression, skipped, liked: false, share: wantShare && impression !== '', url: `https://www.douyin.com/video/${v.id}` };
363
+ }
364
+
365
+ /** 旧路:浏览器里打开详情页读文案 + 热评(要点赞就在详情页点)。 */
366
+ async browserWatch(item, like, share = false) {
255
367
  const b = this.host.browser;
256
368
  let d = null;
257
369
  try { d = await this.readDetail({ browser: b, id: item.id, commentLimit: 6 }); } catch (error) {
@@ -297,6 +409,17 @@ export class DouyinBrowser {
297
409
  /** 刷一轮。返回看过的视频。 */
298
410
  async run({ count } = {}) {
299
411
  if (this.running) return { ok: false, error: '正在刷,稍等' };
412
+ // http 模式:先把登录态准备好(cookie 落盘优先,没有才去浏览器 CDP 抓一次)。
413
+ // 拿不到就这轮退回浏览器(旧路),别硬刷。
414
+ if (this.useHttp()) {
415
+ const ok = await this.http.ensureCookies();
416
+ if (!ok || !this.http.isLoggedIn()) {
417
+ this.httpBroken = true;
418
+ this.log('warn', `[fish-soul] 抖音纯 HTTP 没拿到登录态(${this.http.lastError || '未登录'}),这轮退回浏览器`);
419
+ } else {
420
+ this.httpBroken = false;
421
+ }
422
+ }
300
423
  if (!this.ready) return { ok: false, error: this.host.running ? '抖音还没登录' : '抖音浏览器没在运行' };
301
424
  this.running = true;
302
425
  try {
@@ -355,7 +478,8 @@ export class DouyinBrowser {
355
478
 
356
479
  /** 聊天里有人发了抖音视频:打开详情读文案和热评,拼一段简短介绍进消息,让她"看过"再回。 */
357
480
  async describe(refs, { max = 1 } = {}) {
358
- if (!this.ready) return [];
481
+ // http 模式不卡 ready:没有 cookie 就退回浏览器那条路(浏览器没在跑就自然读不到)
482
+ if (!this.ready && !(this.useHttp() && this.host.running)) return [];
359
483
  const ids = [...(refs.ids ?? [])];
360
484
  for (const s of refs.shorts ?? []) {
361
485
  if (ids.length >= max) break;
@@ -366,6 +490,16 @@ export class DouyinBrowser {
366
490
  for (const id of ids.slice(0, max)) {
367
491
  const hit = this.described.get(id);
368
492
  if (hit !== undefined && Date.now() - hit.at < DESCRIBE_TTL_MS) { notes.push(hit.text); continue; }
493
+ // http 模式:详情 + 热评都走接口(comment/list 要 a_bogus);读不到再退回浏览器
494
+ if (this.useHttp()) {
495
+ const text = await this.httpDescribe(id);
496
+ if (text !== null) {
497
+ this.described.set(id, { text, at: Date.now() });
498
+ if (this.described.size > 200) this.described.delete(this.described.keys().next().value);
499
+ notes.push(text);
500
+ continue;
501
+ }
502
+ }
369
503
  try {
370
504
  const d = await this.host.exclusive('看分享的抖音', () => this.readDetail({ browser: this.host.browser, id, commentLimit: 3 }), { maxMs: 45_000 });
371
505
  if (!d?.ok) continue;
@@ -379,4 +513,15 @@ export class DouyinBrowser {
379
513
  }
380
514
  return notes;
381
515
  }
516
+
517
+ /** 纯 HTTP 拼一段视频介绍(详情 + 第一条热评)。读不到返回 null。 */
518
+ async httpDescribe(id) {
519
+ const det = await this.http.detail(id);
520
+ if (!det.ok || det.detail === null) return null;
521
+ const d = det.detail;
522
+ const cm = await this.http.comments(id, { count: 3 });
523
+ const c0 = cm.ok ? cm.list[0] : null;
524
+ return `【抖音视频:${oneLine(d.desc, 120) || '(没有文案)'};作者 ${oneLine(d.author?.nickname, 20) || '未知'}` +
525
+ (c0 ? `;热评:${oneLine(c0.text ?? c0, 60)}` : '') + '】';
526
+ }
382
527
  }
@@ -0,0 +1,314 @@
1
+ /**
2
+ * douyin-http.js —— 抖音网页端的纯 HTTP 客户端(不开浏览器)。
3
+ *
4
+ * 三件事,别的模块都靠它:
5
+ * 1. **登录态**:cookie 落盘在 ~/.dsh/fish-soul/douyin-cookie.json;没有就从**正在跑的抖音浏览器**
6
+ * 用 CDP `Network.getCookies` 抓一次明文(Chrome 的 Cookies 文件是 DPAPI+AES 加密的,
7
+ * 浏览器开着时还锁着文件,copy 不了 —— 只能走 CDP)。抓完落盘,之后浏览器可以关。
8
+ * 2. **签名**:两道,都能本地算:
9
+ * - `x-secsdk-web-signature` = md5(`${uifid}_${timestamp}_${SALT}_${query}`)
10
+ * (SALT 是 project 34 = douyin_web 的常量;uifid 取自 cookie,名字是**大写** UIFID / UIFID_TEMP)
11
+ * —— tab/feed、aweme/detail 这类端点只要这一道。
12
+ * - `a_bogus`(SM3+RC4,见 vendor/douyin-bogus.js)—— comment/* 这类端点还要它,
13
+ * 光有 secsdk 签名会返回 0 字节 / status_code 8。
14
+ * 3. **公共参数**:网页端那组 device_platform/aid/channel/version_code/… **是整体校验的**,
15
+ * 少一个就报「参数不合法」——整组照抄(来源:douyin-cli 油猴脚本的公共参数块)。
16
+ *
17
+ * 实测(2026-11-08 真机):tab/feed → 200/status_code 0/12 条;aweme/detail → 200/84KB;
18
+ * comment/list → 0 字节(缺 a_bogus)。
19
+ */
20
+ import { createHash } from 'node:crypto';
21
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
22
+ import { dshHome } from './db.js';
23
+ import { join } from 'node:path';
24
+ import { generateABogus } from './vendor/douyin-bogus.js';
25
+
26
+ export const DOUYIN_WEB_ORIGIN = 'https://www.douyin.com';
27
+
28
+ /** x-secsdk-web-signature 的盐(project 34 = douyin_web)。抖音换 project id 就得换这个值。 */
29
+ export const SECSDK_SALT = 'A96D855A08C0A9707F8BEF0D9A527E4E';
30
+
31
+ /** cookie 里 uifid 的可能名字。**注意实际是大写**(UIFID / UIFID_TEMP)。 */
32
+ export const UIFID_COOKIE_NAMES = ['uifid', 'uifid_temp', 'uifidtemp', 'UIFID', 'UIFID_TEMP', 'UIFIDTEMP'];
33
+
34
+ export const CHROME_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36';
35
+
36
+ /** 屏指纹:a_bogus 会把它编进签名,必须和 User-Agent 对得上(Win 就用 Windows 那组)。 */
37
+ export const WIN_SCREEN_FINGERPRINT = '1920|366|1918|1048|1920|1050|1920|1080|Win32';
38
+
39
+ /**
40
+ * 网页端公共参数(整体校验,少一个就「参数不合法」)。
41
+ * @param {{width?: number, height?: number, chromeVersion?: string}} [opts]
42
+ * @returns {Array<[string, string]>}
43
+ */
44
+ export function douyinBaseParams(opts = {}) {
45
+ const chromeVer = opts.chromeVersion ?? '156.0.0.0';
46
+ return [
47
+ ['device_platform', 'webapp'],
48
+ ['aid', '6383'],
49
+ ['channel', 'channel_pc_web'],
50
+ ['pc_client_type', '1'],
51
+ ['pc_libra_divert', 'Windows'],
52
+ ['update_version_code', '170400'],
53
+ ['support_h265', '1'],
54
+ ['support_dash', '1'],
55
+ ['version_code', '170400'],
56
+ ['version_name', '17.4.0'],
57
+ ['cookie_enabled', 'true'],
58
+ ['screen_width', String(opts.width ?? 1536)],
59
+ ['screen_height', String(opts.height ?? 864)],
60
+ ['browser_language', 'zh-CN'],
61
+ ['browser_platform', 'Win32'],
62
+ ['browser_name', 'Chrome'],
63
+ ['browser_version', chromeVer],
64
+ ['browser_online', 'true'],
65
+ ['engine_name', 'Blink'],
66
+ ['engine_version', chromeVer],
67
+ ['os_name', 'Windows'],
68
+ ['os_version', '10'],
69
+ ['cpu_core_num', '12'],
70
+ ['device_memory', '16'],
71
+ ['platform', 'PC'],
72
+ ['downlink', '10'],
73
+ ['effective_type', '4g'],
74
+ ['round_trip_time', '100'],
75
+ ];
76
+ }
77
+
78
+ /** 秒级时间戳(签名里用)。 */
79
+ export const nowStamp = () => String(Math.floor(Date.now() / 1000));
80
+
81
+ /**
82
+ * 算 x-secsdk-web-signature。
83
+ * @param {{uifid: string, stamp: string, query: string}} args
84
+ * @returns {string} 32 位小写 hex
85
+ */
86
+ export function secsdkSignature({ uifid, stamp, query }) {
87
+ return createHash('md5').update(`${uifid}_${stamp}_${SECSDK_SALT}_${query}`).digest('hex');
88
+ }
89
+
90
+ /**
91
+ * 算 a_bogus(comment/* 这类端点要)。
92
+ * @param {{query: string, body?: string, userAgent?: string, screenFingerprint?: string}} args
93
+ * @returns {string}
94
+ */
95
+ export function aBogusSignature({ query, body = '', userAgent = CHROME_UA, screenFingerprint = WIN_SCREEN_FINGERPRINT }) {
96
+ return generateABogus({ query, body, userAgent, screenFingerprint });
97
+ }
98
+
99
+ /** cookie 文件位置(和插件其它数据放一起,不进仓库;跟着 DSH_HOME 走,测试才能隔离)。 */
100
+ export function douyinCookiePath() {
101
+ return join(dshHome(), 'fish-soul', 'douyin-cookie.json');
102
+ }
103
+
104
+ /**
105
+ * 抖音网页端的纯 HTTP 客户端。
106
+ */
107
+ export class DouyinHttp {
108
+ /**
109
+ * @param {object} deps
110
+ * @param {() => object} [deps.getConfig] config.douyin(取 browserPort 之类)
111
+ * @param {(level: string, msg: string) => void} [deps.log]
112
+ * @param {number} [deps.cdpPort] 抖音浏览器的 CDP 端口(默认 47890)
113
+ * @param {typeof fetch} [deps.fetchImpl]
114
+ */
115
+ constructor({ getConfig, log, cdpPort = 47890, fetchImpl } = {}) {
116
+ this.getConfig = getConfig ?? (() => ({}));
117
+ this.log = log ?? (() => {});
118
+ this.cdpPort = cdpPort;
119
+ this.fetch = fetchImpl ?? ((...a) => fetch(...a));
120
+ /** @type {Map<string,string>} name → value(同名取第一个) */
121
+ this.jar = new Map();
122
+ this.loaded = false;
123
+ this.lastError = '';
124
+ // 构造时就读一次落盘的 cookie:这样 isLoggedIn() 立刻准(路由层是同步判断)
125
+ this.loadCookies();
126
+ }
127
+
128
+ /** cookie 落盘路径。 */
129
+ cookiePath() {
130
+ return douyinCookiePath();
131
+ }
132
+
133
+ /** 从磁盘读 cookie(落盘过一次就用它,浏览器可以关)。 */
134
+ loadCookies() {
135
+ const p = this.cookiePath();
136
+ if (!existsSync(p)) return false;
137
+ try {
138
+ const raw = JSON.parse(readFileSync(p, 'utf8'));
139
+ const map = raw?.cookies ?? {};
140
+ this.jar = new Map(Object.entries(map).filter(([, v]) => typeof v === 'string' && v !== ''));
141
+ this.loaded = this.jar.size > 0;
142
+ return this.loaded;
143
+ } catch (error) {
144
+ this.lastError = `cookie 文件读不了:${error instanceof Error ? error.message : error}`;
145
+ return false;
146
+ }
147
+ }
148
+
149
+ /** 把 cookie 落盘(本地目录,不进仓库;含登录态,权限设 0600)。 */
150
+ saveCookies() {
151
+ try {
152
+ const p = this.cookiePath();
153
+ mkdirSync(join(p, '..'), { recursive: true });
154
+ writeFileSync(p, JSON.stringify({ savedAt: Date.now(), cookies: Object.fromEntries(this.jar) }), { mode: 0o600 });
155
+ return true;
156
+ } catch (error) {
157
+ this.lastError = `cookie 存不了:${error instanceof Error ? error.message : error}`;
158
+ return false;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * 从正在跑的抖音浏览器 CDP 抓一次明文 cookie(只读)。
164
+ * Chrome 的 Cookies 文件是 DPAPI+AES 加密的,浏览器开着时还锁着 —— 只能走 CDP。
165
+ * @returns {Promise<boolean>} 有没有抓到
166
+ */
167
+ async captureFromBrowser() {
168
+ if (!(this.cdpPort > 0)) {
169
+ this.lastError = '没配 CDP 端口,抓不了 cookie';
170
+ return false;
171
+ }
172
+ try {
173
+ const listRes = await this.fetch(`http://127.0.0.1:${this.cdpPort}/json/list`, { signal: AbortSignal.timeout(5000) });
174
+ const targets = await listRes.json();
175
+ const page = targets.find((t) => t.type === 'page' && String(t.url).includes('douyin.com'));
176
+ if (page === undefined) {
177
+ this.lastError = `CDP ${this.cdpPort} 上没有抖音页面`;
178
+ return false;
179
+ }
180
+ const cookies = await new Promise((resolve, reject) => {
181
+ const ws = new WebSocket(page.webSocketDebuggerUrl);
182
+ const timer = setTimeout(() => { ws.close(); reject(new Error('CDP 超时')); }, 10_000);
183
+ ws.addEventListener('open', () => ws.send(JSON.stringify({ id: 1, method: 'Network.getCookies', params: { urls: ['https://www.douyin.com/'] } })));
184
+ ws.addEventListener('message', (ev) => {
185
+ const msg = JSON.parse(String(ev.data));
186
+ if (msg.id !== 1) return;
187
+ clearTimeout(timer);
188
+ ws.close();
189
+ if (msg.error) { reject(new Error(`CDP 报错:${JSON.stringify(msg.error)}`)); return; }
190
+ resolve(msg.result?.cookies ?? []);
191
+ });
192
+ ws.addEventListener('error', (e) => { clearTimeout(timer); reject(new Error(`WS 出错:${e.message ?? e}`)); });
193
+ });
194
+ const jar = new Map();
195
+ for (const c of cookies) {
196
+ if (!String(c.domain ?? '').includes('douyin.com')) continue;
197
+ if (!jar.has(c.name)) jar.set(c.name, String(c.value ?? '')); // 同名取第一个
198
+ }
199
+ if (jar.size === 0) {
200
+ this.lastError = 'CDP 拿到的 cookie 是空的';
201
+ return false;
202
+ }
203
+ this.jar = jar;
204
+ this.loaded = true;
205
+ this.saveCookies();
206
+ return true;
207
+ } catch (error) {
208
+ this.lastError = `从浏览器抓 cookie 失败:${error instanceof Error ? error.message : error}`;
209
+ return false;
210
+ }
211
+ }
212
+
213
+ /** 有 cookie 就用;没有就先去浏览器抓一次(抓不到就返回 false)。 */
214
+ async ensureCookies() {
215
+ if (this.loaded && this.jar.size > 0) return true;
216
+ if (this.loadCookies()) return true;
217
+ return this.captureFromBrowser();
218
+ }
219
+
220
+ /** 登录态:有没有 sessionid。 */
221
+ isLoggedIn() {
222
+ return (this.jar.get('sessionid') ?? '') !== '' || (this.jar.get('sessionid_ss') ?? '') !== '';
223
+ }
224
+
225
+ /** uifid(secsdk 签名要用;cookie 里是大写名字)。 */
226
+ uifid() {
227
+ for (const n of UIFID_COOKIE_NAMES) {
228
+ const v = this.jar.get(n);
229
+ if (v !== undefined && v !== '') return v;
230
+ }
231
+ return '';
232
+ }
233
+
234
+ /** Cookie 头。 */
235
+ cookieHeader() {
236
+ return [...this.jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
237
+ }
238
+
239
+ /**
240
+ * 发一个网页端请求(GET / POST)。
241
+ * @param {object} args
242
+ * @param {string} args.path 例如 '/aweme/v1/web/tab/feed/'
243
+ * @param {Array<[string,string]>} [args.params] 端点自己的参数
244
+ * @param {string} [args.body] POST 的表单体(会自动算 a_bogus 的 body 摘要)
245
+ * @param {boolean} [args.aBogus] 是否要 a_bogus(comment/* 要)
246
+ * @param {boolean} [args.post] 用 POST
247
+ * @param {string} [args.referer]
248
+ * @returns {Promise<{status: number, ok: boolean, json: any, text: string}>}
249
+ */
250
+ async request({ path, params = [], body = '', aBogus = false, post = false, referer = `${DOUYIN_WEB_ORIGIN}/`, timeoutMs = 20_000 }) {
251
+ if (!(await this.ensureCookies())) {
252
+ return { status: 0, ok: false, json: null, text: '', error: this.lastError || '没有抖音登录态' };
253
+ }
254
+ const stamp = nowStamp();
255
+ const uifid = this.uifid();
256
+ const pairs = [...douyinBaseParams(), ...params];
257
+ if (uifid !== '') pairs.push(['uifid', uifid]);
258
+ pairs.push(['timestamp', stamp]);
259
+ const query = new URLSearchParams(pairs).toString();
260
+ const headers = {
261
+ 'User-Agent': CHROME_UA,
262
+ Referer: referer,
263
+ Accept: 'application/json, text/plain, */*',
264
+ ...(uifid !== '' ? { uifid } : {}),
265
+ 'x-secsdk-web-expire': stamp,
266
+ Cookie: this.cookieHeader(),
267
+ };
268
+ let url = `${DOUYIN_WEB_ORIGIN}${path}?${query}&x-secsdk-web-signature=${secsdkSignature({ uifid, stamp, query })}`;
269
+ if (aBogus) {
270
+ const ab = aBogusSignature({ query, body });
271
+ url += `&a_bogus=${encodeURIComponent(ab)}`;
272
+ headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
273
+ }
274
+ const init = { method: post ? 'POST' : 'GET', headers, signal: AbortSignal.timeout(timeoutMs) };
275
+ if (post) init.body = body;
276
+ let res;
277
+ try {
278
+ res = await this.fetch(url, init);
279
+ } catch (error) {
280
+ const message = error instanceof Error ? error.message : String(error);
281
+ this.lastError = message;
282
+ return { status: 0, ok: false, json: null, text: '', error: message };
283
+ }
284
+ const text = await res.text();
285
+ let json = null;
286
+ try { json = JSON.parse(text); } catch { /* 不是 JSON 就原样返回 */ }
287
+ return { status: res.status, ok: res.ok, json, text, url };
288
+ }
289
+
290
+ /** 首页推荐流。实测每次请求都是全新的一批(0 条重复),所以要更多就多请求几次。 */
291
+ async feed(count = 12) {
292
+ const r = await this.request({ path: '/aweme/v1/web/tab/feed/', params: [['count', String(count)], ['refresh_index', '1'], ['video_type_select', '1'], ['aweme_type', '0'], ['disable_group_auto_play', '1'], ['is_home_taken_down', 'false'], ['need_time_list', '0']] });
293
+ const list = r.json?.aweme_list ?? r.json?.data?.aweme_list ?? [];
294
+ return { ok: r.ok && Array.isArray(list) && list.length > 0, list, raw: r };
295
+ }
296
+
297
+ /** 单个视频详情(要 a_bogus 的端点用 aBogus: true)。 */
298
+ async detail(awemeId) {
299
+ const r = await this.request({ path: '/aweme/v1/web/aweme/detail/', params: [['aweme_id', String(awemeId)]] });
300
+ const d = r.json?.aweme_detail ?? r.json?.data?.aweme_detail ?? null;
301
+ return { ok: r.ok && d !== null, detail: d, raw: r };
302
+ }
303
+
304
+ /** 评论列表(**要 a_bogus**,否则返回 0 字节)。 */
305
+ async comments(awemeId, { cursor = '0', count = 20 } = {}) {
306
+ const r = await this.request({
307
+ path: '/aweme/v1/web/comment/list/',
308
+ params: [['aweme_id', String(awemeId)], ['cursor', String(cursor)], ['count', String(count)], ['item_type', '0'], ['insert_ids', '']],
309
+ aBogus: true,
310
+ });
311
+ const list = r.json?.comments ?? r.json?.data?.comments ?? [];
312
+ return { ok: r.ok && Array.isArray(list) && list.length > 0, list, raw: r };
313
+ }
314
+ }